Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ jobs:
node-version: 22
cache: npm
- run: npm ci
- run: npm run typecheck
- run: npm test

# Story 11.2: the container image's "test" is build + boot + /healthz + a smoke
Expand Down
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends python3 build-e
&& rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
# Runtime deps only (tsx + typescript are runtime deps — Story 11.1 — so the app runs
# via `node --import tsx server.ts` with no build step).
# via `node --import tsx src/server.ts` with no build step).
RUN npm ci --omit=dev

# ---- runtime ----------------------------------------------------------------
Expand Down Expand Up @@ -60,4 +60,4 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
# Chromium-in-container uses the no-sandbox launch args already set in Story 6.2
# (--no-sandbox --disable-setuid-sandbox --disable-dev-shm-usage) — no --privileged
# / SYS_ADMIN needed.
CMD ["node", "--import", "tsx", "server.ts"]
CMD ["node", "--import", "tsx", "src/server.ts"]
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Seanathon

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ installs the app to `/opt/board-oss` with a persistent `DATA_DIR` at
(`deploy/board-oss.service`), and waits for `/healthz`. Tunables via env:
`APP_DIR`, `DATA_DIR`, `PORT`, `APP_USER`.

- **Run mode:** the service runs `node --import tsx server.ts` (no build step; `tsx`
- **Run mode:** the service runs `node --import tsx src/server.ts` (no build step; `tsx`
+ `typescript` are runtime deps, so `npm ci --omit=dev` keeps them).
- **`better-sqlite3`** uses its prebuilt binary on glibc Linux / Node LTS (no
compiler needed). If a from-source build is ever required, `apt-get install -y
Expand Down Expand Up @@ -143,3 +143,7 @@ asserting `/healthz` and a real in-container screenshot capture.
Your data is a plain SQLite file plus a `screenshots/` directory under `DATA_DIR` —
copy the directory and walk away. Upgrading the code (a `git pull` / container
rebuild) never touches `DATA_DIR`.

## License

[MIT](./LICENSE) © Seanathon
2 changes: 1 addition & 1 deletion deploy/board-oss.service
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ WorkingDirectory=/opt/board-oss

# Story 11.1: production run mode = tsx at runtime (no build step). tsx + typescript
# are runtime dependencies, so `npm ci --omit=dev` keeps them.
ExecStart=/usr/bin/env node --import tsx server.ts
ExecStart=/usr/bin/env node --import tsx src/server.ts

# Epic 2 config seam. localhost-only bind (Story 2.4) — auth/TLS is the reverse proxy's
# job. DATA_DIR (Story 2.2) is persistent + separate from the code dir, so upgrades /
Expand Down
2 changes: 1 addition & 1 deletion deploy/proxmoxve/install/board-install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ After=network.target
Type=simple
WorkingDirectory=/opt/board
EnvironmentFile=/opt/board/.env
ExecStart=/usr/bin/env node --import tsx server.ts
ExecStart=/usr/bin/env node --import tsx src/server.ts
Restart=on-failure
RestartSec=5

Expand Down
File renamed without changes.
18 changes: 10 additions & 8 deletions extension/api-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ function recordingFetch(response: unknown = {}, status = 200) {
json: async () => response,
};
};
return { fetchFn, calls };
// The client only needs a fetch-shaped callable; cast the minimal stand-in to the
// global fetch type at the injection seam (the test asserts behavior, not types).
return { fetchFn: fetchFn as unknown as typeof fetch, calls };
}

// AC 1/5 — save() POSTs the current tab to the authed /api/v1/items with NO board.
Expand Down Expand Up @@ -87,10 +89,10 @@ test("13.4: reviewAction() shows a chip for a suggestion, falls back to manual w
// in the Inbox; assign() actually moves board_id. This is what makes the mocks above
// trustworthy (e.g. it would catch a wrong assign body shape — the real route 400s).
test("13.4 (contract): save→Inbox and assign→move work against a real buildServer", async () => {
const { buildServer } = await import("../server.js");
const { initDb } = await import("../db/index.js");
const { seed } = await import("../db/seed.js");
const { items } = await import("../db/schema.js");
const { buildServer } = await import("../src/server.js");
const { initDb } = await import("../src/db/index.js");
const { seed } = await import("../src/db/seed.js");
const { items } = await import("../src/db/schema.js");
const { eq } = await import("drizzle-orm");
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "board-oss-ext-"));
const handle = initDb(path.join(dir, "c.db"));
Expand All @@ -101,17 +103,17 @@ test("13.4 (contract): save→Inbox and assign→move work against a real buildS
const res = await app.inject({ method: opts.method ?? "GET", url, headers: opts.headers, payload: opts.body });
return { ok: res.statusCode < 400, status: res.statusCode, json: async () => JSON.parse(res.body) };
};
const client = createBoardClient({ baseUrl: "", token: "test-token", fetch: fetchAdapter });
const client = createBoardClient({ baseUrl: "", token: "test-token", fetch: fetchAdapter as unknown as typeof fetch });
try {
// save() → an Inbox item exists (AC1, → Inbox via the live omitted-board default).
const saved = await client.save({ url: "https://ext.example/a", title: "A" });
assert.ok(saved.id, "save returned a created item id");
assert.equal(handle.db.select().from(items).where(eq(items.id, saved.id)).get().boardId, "inbox");
assert.equal(handle.db.select().from(items).where(eq(items.id, saved.id)).get()!.boardId, "inbox");

// assign() → board_id actually moved to the target (AC2, the one assign verb).
const result = await client.assign(saved.id, "library");
assert.deepEqual(result.assigned, [saved.id], "the live assign endpoint accepted the batch body and moved the item");
assert.equal(handle.db.select().from(items).where(eq(items.id, saved.id)).get().boardId, "library");
assert.equal(handle.db.select().from(items).where(eq(items.id, saved.id)).get()!.boardId, "library");
} finally {
handle.sqlite.close();
fs.rmSync(dir, { recursive: true, force: true });
Expand Down
12 changes: 12 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 19 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,14 +1,26 @@
{
"name": "board",
"version": "0.2.0-alpha",
"description": "A lightweight, self-hostable, agent-native curation tool. Node/TypeScript + Fastify + SQLite, built to run on a small LXC.",
"license": "MIT",
"author": "Seanathon <seanyalda@pm.me>",
"repository": {
"type": "git",
"url": "git+https://github.com/Hayawan/board.git"
},
"homepage": "https://github.com/Hayawan/board#readme",
"bugs": {
"url": "https://github.com/Hayawan/board/issues"
},
"type": "module",
"scripts": {
"add": "tsx add.ts",
"dev": "node --env-file-if-exists=.env --import tsx server.ts",
"start": "node --env-file-if-exists=.env --import tsx server.ts",
"import:flat": "tsx db/import-cli.ts",
"archive:backfill": "tsx db/archive-backfill-cli.ts",
"test": "node --import tsx --test --test-concurrency=1 add.test.ts storage.test.ts processors.test.ts processor-library.test.ts library-e2e.test.ts server.test.ts collections-ui.test.ts db/schema.test.ts descriptor/descriptor.test.ts descriptor/render-map.test.ts descriptor/guardrails.test.ts descriptor/inbox-suggest.test.ts db/seed.test.ts db/inbox-seed.test.ts db/queue.test.ts db/worker.test.ts db/status.test.ts db/item-actions.test.ts db/board-actions.test.ts db/search.test.ts db/view.test.ts db/materialize.test.ts db/fts.test.ts db/importer.test.ts db/suggestion-override.test.ts db/export.test.ts db/archive-footprint.test.ts db/archive-backfill.test.ts capture/adapter.test.ts capture/net-guard.test.ts capture/url-screenshot.test.ts capture/url-readable.test.ts capture/manual-upload.test.ts capture/concurrency.test.ts capture/url-snapshot.test.ts enrichment/worker.test.ts enrichment/refetch.test.ts enrichment/pipeline.test.ts enrichment/tier.test.ts enrichment/assign.test.ts enrichment/suggest.test.ts config.test.ts paths.test.ts browser.test.ts server-lock.test.ts api/v1.test.ts capture-clients/bookmarklet.test.ts extension/api-client.test.ts skills/registry.test.ts skills-route.test.ts skills/import-bookmarks.test.ts skills/core-skills.test.ts skills/compose-board.test.ts skills/compose-collection.test.ts skills/generate-fields.test.ts skills/export.test.ts llm/provider.test.ts llm/http-provider.test.ts llm/cli-provider.characterization.test.ts llm/cli-provider.test.ts llm/select-provider.test.ts sse.test.ts"
"add": "tsx src/add.ts",
"dev": "node --env-file-if-exists=.env --import tsx src/server.ts",
"start": "node --env-file-if-exists=.env --import tsx src/server.ts",
"import:flat": "tsx src/db/import-cli.ts",
"archive:backfill": "tsx src/db/archive-backfill-cli.ts",
"typecheck": "tsc --noEmit",
"test": "node --import tsx --test --test-concurrency=1 \"src/**/*.test.ts\" \"extension/**/*.test.ts\""
},
"dependencies": {
"@fastify/cors": "11.2.0",
Expand All @@ -25,6 +37,7 @@
"zod": "3.25.76"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/jsdom": "28.0.3",
"@types/turndown": "5.0.6"
},
Expand Down
File renamed without changes
File renamed without changes
File renamed without changes
60 changes: 53 additions & 7 deletions index.html → public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1680,8 +1680,27 @@
async function load() {
const helpers = window.collectionHelpers;
const storedCid = localStorage.getItem('board.activeCollection') || 'inspiration';
const collectionsRes = await fetch('/api/collections');
collections = await collectionsRes.json();
// Boards must load before anything else can render. A fetch failure (server down)
// or zero boards used to throw past renderSwitcher and white-screen the app; instead
// render a dignified board-level fallback so the chrome (incl. the + affordance) and
// a way forward always appear (Design Principle 3: degrade with dignity).
try {
const collectionsRes = await fetch('/api/collections');
if (!collectionsRes.ok) throw new Error('collections ' + collectionsRes.status);
collections = await collectionsRes.json();
} catch {
renderSwitcher();
showBoardsFallback({ unavailable: true });
return;
}
if (!collections.length) {
renderSwitcher();
showBoardsFallback({ unavailable: false });
return;
}
// Boards loaded → restore the toolbar row the fallback hides, then render normally.
const toolbar = document.querySelector('.filters')?.closest('.header-row');
if (toolbar) toolbar.style.display = '';
activeCollection = helpers.resolveActiveCollection(storedCid, collections);
const activeCol = collections.find(c => c.id === activeCollection);

Expand Down Expand Up @@ -1711,6 +1730,30 @@
maybeShowAiNudge();
}

// Render a whole-board fallback (server unreachable, or zero boards) into the main
// area: hide the filter toolbar + clouds, take over grid-view, and wire the variant's
// affordance — Retry re-runs load(), "Describe a board" opens the existing composer
// (progressive disclosure: the empty state is the invite, the modal is the detail).
function showBoardsFallback(opts) {
// Hide the whole filter/sort/view toolbar row (board-scoped + inert here); the
// header-row-left (title, switcher, search, add) stays so the chrome is intact.
const toolbar = document.querySelector('.filters')?.closest('.header-row');
if (toolbar) toolbar.style.display = 'none';
const tagCloud = document.getElementById('tag-cloud');
const libCloud = document.getElementById('library-topic-cloud');
if (tagCloud) tagCloud.style.display = 'none';
if (libCloud) libCloud.style.display = 'none';
const listView = document.getElementById('list-view');
const gridView = document.getElementById('grid-view');
listView.innerHTML = ''; listView.style.display = 'none';
gridView.style.display = '';
gridView.innerHTML = window.collectionHelpers.renderBoardsFallback(opts);
const retry = gridView.querySelector('[data-boards-retry]');
if (retry) retry.addEventListener('click', () => load());
const create = gridView.querySelector('[data-boards-new]');
if (create) create.addEventListener('click', openComposeModal);
}

// Story 8.6: a peripheral, dismissible "enable AI" nudge — only when no provider is
// configured (the Story 4.4 signal via /api/meta) AND not previously dismissed. The
// board stays the hero (SM-C2): a small corner card, never a blocking modal/banner.
Expand Down Expand Up @@ -1762,17 +1805,20 @@
const headerLeft = document.querySelector('.header-row-left');
headerLeft.insertBefore(sw, document.getElementById('search'));
}
// Edit (⚙) only makes sense with an active board; the New-board (+) affordance is
// ALWAYS present so a board can be created even from the empty/degraded states.
const hasActive = !!collections.find(c => c.id === activeCollection);
sw.innerHTML = collections.map(c =>
`<button class="coll-btn${activeCollection === c.id ? ' active' : ''}" data-cid="${esc(c.id)}">${esc(c.name)}</button>`
).join('')
// New-board + Edit-board affordances (compose-board / edit-board modals).
+ `<button class="coll-btn coll-edit" id="edit-board-btn" title="Edit this board">⚙</button>`
+ (hasActive ? `<button class="coll-btn coll-edit" id="edit-board-btn" title="Edit this board">⚙</button>` : '')
+ `<button class="coll-btn coll-new" id="new-board-btn" title="New board">+</button>`;
sw.querySelectorAll('.coll-btn[data-cid]').forEach(b =>
b.addEventListener('click', () => setActiveCollection(b.dataset.cid))
);
document.getElementById('new-board-btn').addEventListener('click', openComposeModal);
document.getElementById('edit-board-btn').addEventListener('click', () => openEditBoardModal(activeCollection));
const editBtn = document.getElementById('edit-board-btn');
if (editBtn) editBtn.addEventListener('click', () => openEditBoardModal(activeCollection));
}

// --- Story 10.1/10.3 + Edit: New board (compose→preview→create) & Edit board (rename/delete) ---
Expand Down Expand Up @@ -3355,8 +3401,8 @@ <h3>Worth knowing</h3>
// load() is called by the module script below after collectionHelpers are initialized
</script>
<script type="module">
import { resolveActiveCollection, itemsUrl, itemUrl, addUrl, refetchUrl, screenshotUrl, moveUrl, eventsUrl, collectionChrome, matchesLibraryFilters, topicCounts, selectView, itemFieldEntries, getFieldValue, buildFilters, matchesFilters, applySseEvent, renderEnrichmentState, boardPurpose, renderEmptyState, shouldShowEnableAiNudge } from './collections-ui.js';
window.collectionHelpers = { resolveActiveCollection, itemsUrl, itemUrl, addUrl, refetchUrl, screenshotUrl, moveUrl, eventsUrl, collectionChrome, matchesLibraryFilters, topicCounts, selectView, itemFieldEntries, getFieldValue, buildFilters, matchesFilters, applySseEvent, renderEnrichmentState, boardPurpose, renderEmptyState, shouldShowEnableAiNudge };
import { resolveActiveCollection, itemsUrl, itemUrl, addUrl, refetchUrl, screenshotUrl, moveUrl, eventsUrl, collectionChrome, matchesLibraryFilters, topicCounts, selectView, itemFieldEntries, getFieldValue, buildFilters, matchesFilters, applySseEvent, renderEnrichmentState, boardPurpose, renderEmptyState, renderBoardsFallback, shouldShowEnableAiNudge } from './collections-ui.js';
window.collectionHelpers = { resolveActiveCollection, itemsUrl, itemUrl, addUrl, refetchUrl, screenshotUrl, moveUrl, eventsUrl, collectionChrome, matchesLibraryFilters, topicCounts, selectView, itemFieldEntries, getFieldValue, buildFilters, matchesFilters, applySseEvent, renderEnrichmentState, boardPurpose, renderEmptyState, renderBoardsFallback, shouldShowEnableAiNudge };
// Story 7.2: generic descriptor-driven field renderer (Epic 8 wires it into the
// descriptor-driven card/modal when the UI consumes the SQLite item model).
import { renderField, renderFields, renderAsset, isSafeUrl } from './descriptor/render-map.js';
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
4 changes: 2 additions & 2 deletions add.ts → src/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { launchBrowser } from "./browser.js";
import { config } from "./config.js";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const TAXONOMY_FILE = path.join(__dirname, "taxonomy.json");
const TAXONOMY_FILE = path.join(__dirname, "..", "taxonomy.json");
// Story 2.2: screenshots live under DATA_DIR (config.screenshotsDir), not the app tree.
const SCREENSHOTS_DIR = config.screenshotsDir;

Expand Down Expand Up @@ -450,7 +450,7 @@ async function analyze(
fs.writeFileSync(schemaFile, JSON.stringify(outputSchema));
const { command, args } = buildAnalysisCommand(agent, prompt, processor.schema, processor.systemPrompt, { schemaFile, resultFile });
const result = spawnSync(command, args, {
cwd: __dirname,
cwd: path.join(__dirname, ".."),
encoding: "utf-8",
maxBuffer: 10 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
Expand Down
Loading
Loading