diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 63595a8..6c90508 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/Dockerfile b/Dockerfile index 67d5227..fc549f5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 ---------------------------------------------------------------- @@ -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"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ab6a7a9 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md index 25ad842..06792e0 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/deploy/board-oss.service b/deploy/board-oss.service index 48fee4c..65d8875 100644 --- a/deploy/board-oss.service +++ b/deploy/board-oss.service @@ -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 / diff --git a/deploy/proxmoxve/install/board-install.sh b/deploy/proxmoxve/install/board-install.sh index 92c7e49..5b27609 100644 --- a/deploy/proxmoxve/install/board-install.sh +++ b/deploy/proxmoxve/install/board-install.sh @@ -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 diff --git a/_planning_documents/2026-05-01-bookmark-categories.md b/docs/planning/2026-05-01-bookmark-categories.md similarity index 100% rename from _planning_documents/2026-05-01-bookmark-categories.md rename to docs/planning/2026-05-01-bookmark-categories.md diff --git a/stories/1-1-collections-storage-foundation.md b/docs/stories/1-1-collections-storage-foundation.md similarity index 100% rename from stories/1-1-collections-storage-foundation.md rename to docs/stories/1-1-collections-storage-foundation.md diff --git a/stories/1-2-processor-registry-dispatch.md b/docs/stories/1-2-processor-registry-dispatch.md similarity index 100% rename from stories/1-2-processor-registry-dispatch.md rename to docs/stories/1-2-processor-registry-dispatch.md diff --git a/stories/1-3-library-capture-pipeline.md b/docs/stories/1-3-library-capture-pipeline.md similarity index 100% rename from stories/1-3-library-capture-pipeline.md rename to docs/stories/1-3-library-capture-pipeline.md diff --git a/stories/1-4-library-cli-end-to-end-proof.md b/docs/stories/1-4-library-cli-end-to-end-proof.md similarity index 100% rename from stories/1-4-library-cli-end-to-end-proof.md rename to docs/stories/1-4-library-cli-end-to-end-proof.md diff --git a/stories/1-5-server-collection-api.md b/docs/stories/1-5-server-collection-api.md similarity index 100% rename from stories/1-5-server-collection-api.md rename to docs/stories/1-5-server-collection-api.md diff --git a/stories/1-6-sidebar-collection-switcher.md b/docs/stories/1-6-sidebar-collection-switcher.md similarity index 100% rename from stories/1-6-sidebar-collection-switcher.md rename to docs/stories/1-6-sidebar-collection-switcher.md diff --git a/stories/1-7-library-list-view.md b/docs/stories/1-7-library-list-view.md similarity index 100% rename from stories/1-7-library-list-view.md rename to docs/stories/1-7-library-list-view.md diff --git a/extension/api-client.test.ts b/extension/api-client.test.ts index eece4d4..b6808a8 100644 --- a/extension/api-client.test.ts +++ b/extension/api-client.test.ts @@ -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. @@ -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")); @@ -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 }); diff --git a/package-lock.json b/package-lock.json index 20a8d54..1fce772 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "board", "version": "0.2.0-alpha", + "license": "MIT", "dependencies": { "@fastify/cors": "11.2.0", "@fastify/static": "^8.1.1", @@ -22,6 +23,7 @@ "zod": "3.25.76" }, "devDependencies": { + "@types/better-sqlite3": "^7.6.13", "@types/jsdom": "28.0.3", "@types/turndown": "5.0.6" }, @@ -915,6 +917,16 @@ "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", "license": "MIT" }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/jsdom": { "version": "28.0.3", "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-28.0.3.tgz", diff --git a/package.json b/package.json index dcaa8c5..3875ef2 100644 --- a/package.json +++ b/package.json @@ -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 ", + "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", @@ -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" }, diff --git a/favicon.png b/public/favicon.png similarity index 100% rename from favicon.png rename to public/favicon.png diff --git a/icon-maskable.svg b/public/icon-maskable.svg similarity index 100% rename from icon-maskable.svg rename to public/icon-maskable.svg diff --git a/icon.svg b/public/icon.svg similarity index 100% rename from icon.svg rename to public/icon.svg diff --git a/index.html b/public/index.html similarity index 97% rename from index.html rename to public/index.html index 7ca6394..f31115b 100644 --- a/index.html +++ b/public/index.html @@ -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); @@ -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. @@ -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 => `` ).join('') - // New-board + Edit-board affordances (compose-board / edit-board modals). - + `` + + (hasActive ? `` : '') + ``; 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) --- @@ -3355,8 +3401,8 @@

Worth knowing

// load() is called by the module script below after collectionHelpers are initialized