diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2103d41 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,65 @@ +name: CI + +on: + pull_request: + branches: [master, main] + push: + branches: [master, main] + +jobs: + lint-and-typecheck: + name: Lint & Typecheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: package-lock.json + - run: npm ci + - run: npm run check + + unit-tests: + name: Unit Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: package-lock.json + - run: npm ci + - name: Pure unit tests + run: | + npm run test:chunker + npm run test:vectorstore + npm run test:dedup + npm run test:smart-compile + npm run test:skill-sources + npm run test:mcp-hosts + npm run test:modes + npm run test:crypto + npm run test:security + npm run test:validation + npm run test:backup + npm run test:ranking + npm run test:mutex + + integration-tests: + name: Integration Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: package-lock.json + - run: npm ci + - name: Server + HTTP tests + run: | + npm run smoke + npm run test:handoffs + npm run test:projects diff --git a/bench/README.md b/bench/README.md index 153d8a0..9e6bb81 100644 --- a/bench/README.md +++ b/bench/README.md @@ -10,7 +10,7 @@ For each task in `tasks.json`, three numbers (apples-to-apples on full skill bod - **Smart** — tokens after CE's `/api/compile/smart` picks the relevant skills for this specific task. Same content type as Raw all, just a subset. - **Search** — tokens an MCP host (Claude Desktop, Codex) actually pulls when it calls `context_engine_search` — chunks, not full skills. -Reference column: `CONTEXT.md` size — CE's pre-compressed system-prompt summary, a *different* path entirely. Reported alongside so both CE delivery paths are visible without inflating the savings number by mixing content types. +Reference column: `CONTEXT.md` size — CE's pre-compressed system-prompt summary, a _different_ path entirely. Reported alongside so both CE delivery paths are visible without inflating the savings number by mixing content types. Optional quality column (`--grade`): for each task, actually run it through Claude in both smart and search modes, capture the response, and have a judge model score it 1-10. The summary adds tokens-per-quality-point efficiency — the real "are we saving tokens AND maintaining answer quality?" question. @@ -64,7 +64,7 @@ Cost expectation at defaults (Haiku for both task + grader, 15 tasks): ~$0.10 pe - **Tokenizer**: tiktoken `cl100k_base` (the BPE GPT-3.5/4 uses). It's within ~5% of Anthropic's own tokenizer on prose. Same encoding is used for baseline + smart + search counts, so the percentages are internally consistent. - **Baseline definition**: only **active** skills (per `data/skill-states.json`) — that's the realistic ceiling. Including inactive skills would inflate the saving artificially. -- **Search realism**: simulates a host calling `context_engine_search` *once* per task and pulling N chunks. Real host apps may call it multiple times or fall back to `context_engine_get_skill` for full bodies, so this is a lower-bound on what they actually consume. +- **Search realism**: simulates a host calling `context_engine_search` _once_ per task and pulling N chunks. Real host apps may call it multiple times or fall back to `context_engine_get_skill` for full bodies, so this is a lower-bound on what they actually consume. - **Smart-compile token budget**: defaults to 16k. Run with `--max-tokens 4000` to see how aggressive selection becomes for small-context models, or `--max-tokens 64000` to see what large-context users get. - **Cross-check**: the summary prints CE's own estimator's ratio vs tiktoken's count. If those diverge a lot, CE's internal budget UI is over- or under-stating. diff --git a/cli/index.js b/cli/index.js index afcfb36..5bab7cc 100644 --- a/cli/index.js +++ b/cli/index.js @@ -434,4 +434,4 @@ function help() { context-engine add ./my-skills/react-rules context-engine status `); -} \ No newline at end of file +} diff --git a/data/projects.json b/data/projects.json new file mode 100644 index 0000000..57b3865 --- /dev/null +++ b/data/projects.json @@ -0,0 +1,4 @@ +{ + "version": "1.0", + "projects": [] +} \ No newline at end of file diff --git a/docs/specs/onboarding-redesign.md b/docs/specs/onboarding-redesign.md index 4cff3d3..8f4dab0 100644 --- a/docs/specs/onboarding-redesign.md +++ b/docs/specs/onboarding-redesign.md @@ -28,12 +28,12 @@ The sibling product `model-db` ships a polished onboarding wizard. Its pattern Four steps, each focused on one decision. -| # | Step | Concern | Replaces | -| - | ---------- | ----------------------------------------------------------------------------- | --------------------------------------- | -| 1 | **Connect**| Pick which MCP hosts (Claude Desktop, Codex CLI, ChatGPT) to wire CE into. | Most of current "Discover" + "Connect". | -| 2 | **Context**| Review skills/memory/index. Optional inline "Build index" if Ollama present. | "Available context" panel + part of "Health". | -| 3 | **IDE** | Show detected fallback targets. Note that file output remains available. | "IDE and file-output surfaces" panel. | -| 4 | **Health** | Quick verification: hosts connected, skills active, index ready. Finish. | Current "Health" step. | +| # | Step | Concern | Replaces | +| --- | ----------- | ---------------------------------------------------------------------------- | --------------------------------------------- | +| 1 | **Connect** | Pick which MCP hosts (Claude Desktop, Codex CLI, ChatGPT) to wire CE into. | Most of current "Discover" + "Connect". | +| 2 | **Context** | Review skills/memory/index. Optional inline "Build index" if Ollama present. | "Available context" panel + part of "Health". | +| 3 | **IDE** | Show detected fallback targets. Note that file output remains available. | "IDE and file-output surfaces" panel. | +| 4 | **Health** | Quick verification: hosts connected, skills active, index ready. Finish. | Current "Health" step. | Each step has Back / Next (or Skip for now / Finish on the last step). Steps 1 and 3 are skippable — the user can connect later via the Connections tab. @@ -67,11 +67,11 @@ No `.onboarding-mark` text monogram. No typeset "Context Engine" string paired w Horizontal row of numbered circles connected by short lines. State per step: -| State | Circle background | Circle text | Connector line | -| -------- | -------------------- | --------------- | --------------- | -| Done | `--accent` | white | `--accent` | -| Current | `--accent` | white | `--dram-line-subtle` (line *after* current) | -| Upcoming | `--dram-bg-recessed` | `--text-4` | `--dram-line-subtle` | +| State | Circle background | Circle text | Connector line | +| -------- | -------------------- | ----------- | ------------------------------------------- | +| Done | `--accent` | white | `--accent` | +| Current | `--accent` | white | `--dram-line-subtle` (line _after_ current) | +| Upcoming | `--dram-bg-recessed` | `--text-4` | `--dram-line-subtle` | Connector: `height: 1px; width: 32px;`. Circle: `28×28; border-radius: 999px`. Step label sits under each circle in `--text-3`, `11px`, uppercase, `letter-spacing: 0.07em` (matches card-name typography from DRAM). @@ -97,16 +97,16 @@ Common pattern inside each step: All cards in the onboarding body use the global card pattern from [dram-design-map.md → Cards](../dram-design-map.md#cards): -| Property | Token | -| ------------------ | --------------------------- | -| Background | `--dram-card-bg` | -| Border | `--dram-card-border` | -| Hover background | `--dram-card-hover` | -| Hover border | `--dram-card-border-hover` | -| Selected background| `--dram-card-active` | -| Selected border | `--dram-card-border-active` | -| Radius | `--r-3` | -| Disabled opacity | `--dram-card-muted-opacity` | +| Property | Token | +| ------------------- | --------------------------- | +| Background | `--dram-card-bg` | +| Border | `--dram-card-border` | +| Hover background | `--dram-card-hover` | +| Hover border | `--dram-card-border-hover` | +| Selected background | `--dram-card-active` | +| Selected border | `--dram-card-border-active` | +| Radius | `--r-3` | +| Disabled opacity | `--dram-card-muted-opacity` | Three card variants used: diff --git a/docs/specs/skill-sources.md b/docs/specs/skill-sources.md index 1d65e16..1e4b812 100644 --- a/docs/specs/skill-sources.md +++ b/docs/specs/skill-sources.md @@ -61,13 +61,13 @@ A registered external directory CE reads `SKILL.md` files from. Stored in `data/ - `removeSource(id)` → deletes by id. Refuses if id is `internal`. - `scanHostSkillPaths()` → probes known locations, returns `{ path, label, exists, skillCount }[]`. Probed paths: - | Path | Label | - | ------------------------------------- | --------------------------- | - | `~/.claude/skills` | Claude Code (global) | - | `/.claude/skills` | Claude Code (current project)| - | `/.clinerules` | Cline / Roo rules | - | `/.continue/rules` | Continue.dev rules | - | `~/.opencode/skills` | OpenCode (global) | + | Path | Label | + | ----------------------- | ----------------------------- | + | `~/.claude/skills` | Claude Code (global) | + | `/.claude/skills` | Claude Code (current project) | + | `/.clinerules` | Cline / Roo rules | + | `/.continue/rules` | Continue.dev rules | + | `~/.opencode/skills` | OpenCode (global) | Each entry returns the resolved absolute path, a friendly label, `exists` boolean, and skill count if any. @@ -99,13 +99,13 @@ The denylist is reused but **inverted** for this case: we're reading, not writin ### New endpoints -| Method | Path | Description | -| ------ | ----------------------------- | ------------------------------------------------------------------------------ | -| GET | `/api/skill-sources` | List registered sources + implicit internal. Returns skill counts per source. | -| POST | `/api/skill-sources` | Add an external source. Body: `{ path, label? }`. Validates + denylist-checks. | -| DELETE | `/api/skill-sources/:id` | Remove a source. Refuses on `internal`. | -| GET | `/api/skill-sources/scan` | Probe known host-skill paths. Returns the candidates with counts. | -| POST | `/api/skill-sources/import` | Phase 2 — copy a source's contents into `/skills/imported//`. | +| Method | Path | Description | +| ------ | --------------------------- | ------------------------------------------------------------------------------ | +| GET | `/api/skill-sources` | List registered sources + implicit internal. Returns skill counts per source. | +| POST | `/api/skill-sources` | Add an external source. Body: `{ path, label? }`. Validates + denylist-checks. | +| DELETE | `/api/skill-sources/:id` | Remove a source. Refuses on `internal`. | +| GET | `/api/skill-sources/scan` | Probe known host-skill paths. Returns the candidates with counts. | +| POST | `/api/skill-sources/import` | Phase 2 — copy a source's contents into `/skills/imported//`. | ## UI @@ -167,6 +167,7 @@ A new "Sources" affordance in the Skills tab header — a small select/expander ## Phase 2 detailed design (2026-05-11) Locked decisions: + - **Import action lives on the linked row.** Link is the cheap commitment, Import is the heavier one; forcing Link-then-Import is the right progression. No Import button on candidate rows. - **Manual Sync only.** No filesystem watching; a Sync button on imported rows triggers the diff. Keeps the user in control of when CE walks their dirs. @@ -175,6 +176,7 @@ Locked decisions: Three states for a registered source: `external` (linked, read from original), `imported` (linked + a copy/hard-link tree has been written into `/skills/imported//`), `internal` (implicit, never stored). Transitions: + ``` external --[POST /api/skill-sources/:id/import]--> imported imported --[POST /api/skill-sources/:id/sync/apply]--> imported (manifest rewritten) @@ -187,6 +189,7 @@ Unlinking an imported source intentionally **keeps** the imported tree. The user ### Import strategy: hard-link + copy fallback File-level `fs.linkSync` is the primary strategy. Fall back to `fs.copyFileSync` per-file on: + - `EXDEV` — cross-volume on Windows. NTFS hard-links are intra-volume only. - `EPERM` / `EACCES` — source permissions. - Non-link-capable filesystems (FAT/exFAT). @@ -205,9 +208,7 @@ One file per imported source at `data/skill-imports/.json`: "importedAt": "2026-05-11T15:00:00Z", "lastSyncedAt": "2026-05-11T15:00:00Z", "aggregateStrategy": "link", - "files": [ - { "rel": "react/SKILL.md", "size": 2341, "mtimeMs": 1715000000000, "strategy": "link" } - ] + "files": [{ "rel": "react/SKILL.md", "size": 2341, "mtimeMs": 1715000000000, "strategy": "link" }] } ``` @@ -226,11 +227,13 @@ One file per imported source at `data/skill-imports/.json`: ``` Definitions: + - **Added**: in source, absent from manifest. - **Removed**: in manifest, absent from source. - **Modified**: in both, but `size` or `mtimeMs` differs **and** the per-file strategy is `copy`. Hard-linked files can't drift in content (shared inode), so we don't list them as modified even if mtime moved on the source. `POST /api/skill-sources/:id/sync/apply` body `{ mode: 'append' | 'overwrite' }`: + - `append`: adds new files only. Removed and modified are left. - `overwrite`: applies all three categories — add new, delete removed, re-link/re-copy modified. @@ -238,11 +241,11 @@ Manifest is rewritten after successful apply. ### Endpoints (Phase 2) -| Method | Path | Description | -| ------ | ----------------------------------------------- | -------------------------------------------------------------------------- | -| POST | `/api/skill-sources/:id/import` | Walk source + write imported tree + manifest. Idempotent: refuses if already imported. | -| GET | `/api/skill-sources/:id/sync` | Compute diff without applying. | -| POST | `/api/skill-sources/:id/sync/apply` | Body `{ mode }`. Apply diff + rewrite manifest. | +| Method | Path | Description | +| ------ | ----------------------------------- | -------------------------------------------------------------------------------------- | +| POST | `/api/skill-sources/:id/import` | Walk source + write imported tree + manifest. Idempotent: refuses if already imported. | +| GET | `/api/skill-sources/:id/sync` | Compute diff without applying. | +| POST | `/api/skill-sources/:id/sync/apply` | Body `{ mode }`. Apply diff + rewrite manifest. | ### UI placement (Phase 2) @@ -306,6 +309,7 @@ Verification (direct node script): linking a fixture with both an `app-launcher` - `conflicts`: dest AND source both diverged. Overwrite discards the local edit. Apply behaviour: + - `append` ignores both. - `overwrite` re-places `modified` + `localEdits` + `conflicts` (excluding conflicts whose source file no longer exists; those flow through `removed`). diff --git a/electron/main.cjs b/electron/main.cjs index 8fa1fc5..6d1cc45 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -345,9 +345,9 @@ ipcMain.on('window:close', () => { ipcMain.handle('dialog:select-folder', async (_event, options) => { const dialogOptions = { properties: ['openDirectory'], - title: (options && typeof options.title === 'string' ? options.title : 'Pick a folder'), + title: options && typeof options.title === 'string' ? options.title : 'Pick a folder', }; const result = await dialog.showOpenDialog(mainWindow || undefined, dialogOptions); if (result.canceled || !result.filePaths?.length) return null; return result.filePaths[0]; -}); \ No newline at end of file +}); diff --git a/electron/preload.cjs b/electron/preload.cjs index 94f3368..7862f7e 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -31,4 +31,4 @@ contextBridge.exposeInMainWorld('contextEngineDesktop', { window.addEventListener('DOMContentLoaded', () => { document.documentElement.dataset.runtime = 'electron'; -}); \ No newline at end of file +}); diff --git a/electron/updater.cjs b/electron/updater.cjs index 42e9d5f..68c115c 100644 --- a/electron/updater.cjs +++ b/electron/updater.cjs @@ -88,4 +88,4 @@ function startAutoUpdate(window, options = {}) { }, intervalMs); } -module.exports = { startAutoUpdate }; \ No newline at end of file +module.exports = { startAutoUpdate }; diff --git a/mcp-http-server.mjs b/mcp-http-server.mjs index 3617426..0f9a688 100644 --- a/mcp-http-server.mjs +++ b/mcp-http-server.mjs @@ -175,4 +175,4 @@ server.listen(PORT, HOST, () => { `context-engine HTTP MCP server listening on http://${HOST}:${PORT}/mcp (auth=${auth}, CE_BASE=${CE_BASE})\n`, ); if (oauth) process.stderr.write('OAuth consent passphrase is set with MCP_OAUTH_PASSWORD.\n'); -}); \ No newline at end of file +}); diff --git a/mcp-oauth.mjs b/mcp-oauth.mjs index 91ef3da..7abcb45 100644 --- a/mcp-oauth.mjs +++ b/mcp-oauth.mjs @@ -299,4 +299,4 @@ async function collect(req) { const chunks = []; for await (const chunk of req) chunks.push(chunk); return chunks; -} \ No newline at end of file +} diff --git a/mcp-server.mjs b/mcp-server.mjs index 203295c..fee22a3 100644 --- a/mcp-server.mjs +++ b/mcp-server.mjs @@ -14,4 +14,4 @@ const transport = new StdioServerTransport(); await server.connect(transport); // stderr is the only safe channel under stdio MCP; stdout is protocol traffic. -process.stderr.write(`context-engine MCP server connected (CE_BASE=${CE_BASE})\n`); \ No newline at end of file +process.stderr.write(`context-engine MCP server connected (CE_BASE=${CE_BASE})\n`); diff --git a/package-lock.json b/package-lock.json index d0b2801..d671f8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "context-engine", - "version": "0.2.1", + "version": "0.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "context-engine", - "version": "0.2.1", + "version": "0.3.1", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.0.4", diff --git a/package.json b/package.json index 6f18219..f54fe15 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "context-engine", - "version": "0.3.1", + "version": "0.4.0", "description": "Local-first continuity layer for AI work across tools, providers, and fresh sessions.", "main": "electron/main.cjs", "bin": { @@ -40,6 +40,14 @@ "test:skill-sources": "node scripts/skill-sources-smoke.js", "migrate:handoffs": "node scripts/migrate-legacy-handoff.js", "test:mcp-hosts": "node scripts/mcp-host-config-smoke.js", + "test:modes": "node scripts/mode-apply-smoke.js", + "test:projects": "node scripts/projects-smoke.js", + "test:crypto": "node scripts/crypto-smoke.js", + "test:security": "node scripts/security-smoke.js", + "test:validation": "node scripts/validation-smoke.js", + "test:backup": "node scripts/backup-smoke.js", + "test:ranking": "node scripts/ranking-smoke.js", + "test:mutex": "node scripts/mutex-smoke.js", "mcp": "node mcp-server.mjs", "mcp:http": "node mcp-http-server.mjs", "mcpb:pack": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/pack-mcpb.ps1", diff --git a/scripts/backup-smoke.js b/scripts/backup-smoke.js new file mode 100644 index 0000000..f1c3868 --- /dev/null +++ b/scripts/backup-smoke.js @@ -0,0 +1,133 @@ +// @ts-check + +// backup-smoke.js — Smoke test for backup, restore, and session logging + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ce-backup-')); +process.env.CE_ROOT = testRoot; +fs.mkdirSync(path.join(testRoot, 'data'), { recursive: true }); + +delete require.cache[require.resolve('../server/lib/config')]; +delete require.cache[require.resolve('../server/lib/backup')]; + +const { DATA_DIR, BACKUPS_DIR } = require('../server/lib/config'); +const { + readData, + writeData, + createBackup, + listBackups, + restoreBackup, + getSessionLog, + appendSession, +} = require('../server/lib/backup'); + +// ---- readData / writeData ---- + +// GIVEN no data files exist +// WHEN we read memory.json +const empty = readData('memory.json'); +assert.strictEqual(empty, null, 'readData returns null for missing file'); + +// GIVEN we write data +const memoryObj = { version: '1.1', entries: [{ content: 'User prefers dark mode', category: 'general' }] }; +writeData('memory.json', memoryObj); +// THEN we can read it back +const readBack = readData('memory.json'); +assert.deepStrictEqual(readBack, memoryObj, 'readData returns written data'); + +// GIVEN a corrupt JSON file +fs.writeFileSync(path.join(DATA_DIR, 'corrupt.json'), 'NOT JSON', 'utf8'); +const corrupt = readData('corrupt.json'); +assert.strictEqual(corrupt, null, 'readData returns null for corrupt file'); + +// ---- createBackup ---- + +// GIVEN existing data files +writeData('rules.json', { coding: 'Use strict', general: '', soul: '' }); +writeData('skill-states.json', { 'skill-a': true }); +// WHEN we create a backup +const backup1 = createBackup(); +assert.ok(backup1.timestamp, 'createBackup returns a timestamp'); +// THEN the backup directory exists +const backupDir1 = path.join(BACKUPS_DIR, String(backup1.timestamp)); +assert(fs.existsSync(backupDir1), 'backup directory is created'); +// AND it contains the data files +assert(fs.existsSync(path.join(backupDir1, 'memory.json')), 'backup includes memory.json'); +assert(fs.existsSync(path.join(backupDir1, 'rules.json')), 'backup includes rules.json'); +assert(fs.existsSync(path.join(backupDir1, 'skill-states.json')), 'backup includes skill-states.json'); + +// ---- listBackups ---- + +// WHEN we list backups +const backups = listBackups(); +assert.ok(Array.isArray(backups), 'listBackups returns an array'); +assert.strictEqual(backups.length, 1, 'one backup exists'); +const firstBackup = backups[0]; +assert.ok(firstBackup, 'first backup entry exists'); +assert.strictEqual(firstBackup.timestamp, String(backup1.timestamp), 'timestamp matches'); + +// GIVEN no backups directory +fs.rmSync(BACKUPS_DIR, { recursive: true, force: true }); +const noBackups = listBackups(); +assert.deepStrictEqual(noBackups, [], 'listBackups returns empty array when no dir'); + +// Recreate for restore test +writeData('memory.json', memoryObj); +writeData('rules.json', { coding: 'Use strict', general: '', soul: '' }); +writeData('skill-states.json', { 'skill-a': true }); +const backupForRestore = createBackup(); + +// ---- restoreBackup ---- + +// GIVEN we change the data +writeData('memory.json', { version: '1.1', entries: [] }); +const changed = readData('memory.json'); +assert.deepStrictEqual(changed.entries, [], 'data was changed'); +// WHEN we restore from backup +const restored = restoreBackup(backupForRestore.timestamp); +assert.strictEqual(restored, true, 'restoreBackup returns true for existing backup'); +// THEN the data is restored +const afterRestore = readData('memory.json'); +assert.deepStrictEqual(afterRestore, memoryObj, 'data restored from backup'); + +// WHEN we try to restore a nonexistent backup +const restoreMiss = restoreBackup('nonexistent-timestamp'); +assert.strictEqual(restoreMiss, false, 'restoreBackup returns false for missing backup'); + +// ---- getSessionLog ---- + +// GIVEN no session log +const emptyLog = getSessionLog(); +assert.ok(emptyLog.sessions, 'getSessionLog returns sessions array'); +assert.strictEqual(emptyLog.sessions.length, 0, 'empty log has no sessions'); + +// ---- appendSession ---- + +// WHEN we append a session entry +appendSession({ type: 'mode_apply', modeId: 'coding' }); +// THEN it appears in the log +const log1 = getSessionLog(); +assert.strictEqual(log1.sessions.length, 1, 'session log has one entry'); +assert.strictEqual(log1.sessions[0].type, 'mode_apply', 'session type preserved'); + +// WHEN we append more entries +for (let i = 0; i < 60; i++) { + appendSession({ type: 'bulk', index: i }); +} +// THEN the log is capped at 50 +const log2 = getSessionLog(); +assert.strictEqual(log2.sessions.length, 50, 'session log is capped at 50 entries'); + +// GIVEN a corrupt session log +fs.writeFileSync(path.join(DATA_DIR, 'session-log.json'), 'NOT JSON', 'utf8'); +const corruptLog = getSessionLog(); +assert.ok(corruptLog.sessions, 'getSessionLog returns sessions for corrupt file'); +assert.strictEqual(corruptLog.sessions.length, 0, 'corrupt log returns empty sessions'); + +// cleanup +fs.rmSync(testRoot, { recursive: true, force: true }); +console.log('backup smoke ok'); diff --git a/scripts/chunker-smoke.js b/scripts/chunker-smoke.js index 79688e1..3b323ae 100644 --- a/scripts/chunker-smoke.js +++ b/scripts/chunker-smoke.js @@ -95,3 +95,79 @@ assert( ); console.log(`chunker smoke ok: ${chunks.length + complexChunks.length} chunks`); + +// ---- Edge cases ---- + +// GIVEN empty content +// WHEN chunked +const emptyChunks = chunkSkillContent({ skillId: 'empty', sourcePath: 'empty/SKILL.md', content: '' }); +assert.deepStrictEqual(emptyChunks, [], 'empty content produces zero chunks'); + +// GIVEN content with no frontmatter +const noFrontmatterChunks = chunkSkillContent({ + skillId: 'no-fm', + sourcePath: 'no-fm/SKILL.md', + content: '# No Frontmatter\n\nJust a plain skill with no YAML block.\n', +}); +assert( + !noFrontmatterChunks.some((c) => c.section === 'Skill Manifest'), + 'no manifest chunk when frontmatter absent', +); +assert( + noFrontmatterChunks.some((c) => c.section === 'No Frontmatter'), + 'heading section is still produced', +); + +// GIVEN empty frontmatter (---\n---) +const emptyFmChunks = chunkSkillContent({ + skillId: 'empty-fm', + sourcePath: 'empty-fm/SKILL.md', + content: '---\n---\n\n# Hello\n\nBody text.\n', +}); +assert( + !emptyFmChunks.some((c) => c.section === 'Skill Manifest'), + 'empty frontmatter produces no manifest chunk', +); + +// GIVEN CRLF line endings +const crlfChunks = chunkSkillContent({ + skillId: 'crlf', + sourcePath: 'crlf/SKILL.md', + content: + '---\r\nname: CRLF Skill\r\ndescription: Windows line endings.\r\n---\r\n\r\n# CRLF Section\r\n\r\nAlways use CRLF.\r\n', +}); +assert( + crlfChunks.some((c) => c.section === 'CRLF Section'), + 'CRLF content is parsed correctly', +); +assert( + crlfChunks.some((c) => c.type === 'rule'), + 'CRLF content with "Always" is classified as rule', +); + +// GIVEN oversized content (> 2200 chars) +const longParagraph = 'A'.repeat(3000); +const oversizedChunks = chunkSkillContent({ + skillId: 'oversize', + sourcePath: 'oversize/SKILL.md', + content: `# Big Section\n\n${longParagraph}\n\n## Next\n\nSmall content.\n`, +}); +assert( + oversizedChunks.some((c) => c.section === 'Big Section'), + 'oversized section is still chunked', +); +assert( + oversizedChunks.some((c) => c.section === 'Next'), + 'section after oversized is preserved', +); + +// GIVEN content with multiple code blocks in one section +const multiCodeChunks = chunkSkillContent({ + skillId: 'multi-code', + sourcePath: 'multi-code/SKILL.md', + content: `# Examples\n\n\`\`\`js\nconsole.log('first');\n\`\`\`\n\n\`\`\`python\nprint('second')\n\`\`\`\n`, +}); +const exampleChunks = multiCodeChunks.filter((c) => c.type === 'example'); +assert.ok(exampleChunks.length >= 2, 'multiple code blocks produce multiple example chunks'); + +console.log('chunker smoke ok'); diff --git a/scripts/crypto-smoke.js b/scripts/crypto-smoke.js new file mode 100644 index 0000000..3c464ec --- /dev/null +++ b/scripts/crypto-smoke.js @@ -0,0 +1,103 @@ +// @ts-check + +// crypto-smoke.js — Smoke test for API key encryption/decryption + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ce-crypto-')); +process.env.CE_ROOT = testRoot; +fs.mkdirSync(path.join(testRoot, 'data'), { recursive: true }); + +delete require.cache[require.resolve('../server/lib/config')]; +delete require.cache[require.resolve('../server/lib/crypto')]; + +const { KEYS_FILE } = require('../server/lib/config'); +const { getApiKey, setApiKey, removeApiKey } = require('../server/lib/crypto'); + +// GIVEN no keys file exists +// WHEN we retrieve a key +const missing = getApiKey('CE_TEST_KEY'); +assert.strictEqual(missing, null, 'getApiKey returns null when no keys file exists'); + +// WHEN we store a key +setApiKey('CE_TEST_KEY', 'sk-secret-123'); +// THEN the key file exists +assert(fs.existsSync(KEYS_FILE), 'key file is created after setApiKey'); +// AND we can retrieve it +const retrieved = getApiKey('CE_TEST_KEY'); +assert.strictEqual(retrieved, 'sk-secret-123', 'getApiKey returns the stored value'); + +// GIVEN an environment variable with the same name +process.env.CE_TEST_KEY = 'env-override'; +// WHEN we retrieve the key +const envResult = getApiKey('CE_TEST_KEY'); +// THEN the env var takes precedence +assert.strictEqual(envResult, 'env-override', 'env var overrides stored key'); +delete process.env.CE_TEST_KEY; + +// WHEN we retrieve after env var is removed +const afterEnv = getApiKey('CE_TEST_KEY'); +assert.strictEqual(afterEnv, 'sk-secret-123', 'stored key used when env var is gone'); + +// WHEN we overwrite a key +setApiKey('CE_TEST_KEY', 'sk-new-value'); +const overwritten = getApiKey('CE_TEST_KEY'); +assert.strictEqual(overwritten, 'sk-new-value', 'setApiKey overwrites existing key'); + +// GIVEN multiple keys +setApiKey('CE_KEY_A', 'value-a'); +setApiKey('CE_KEY_B', 'value-b'); +// THEN both are retrievable +assert.strictEqual(getApiKey('CE_KEY_A'), 'value-a', 'first key preserved after second set'); +assert.strictEqual(getApiKey('CE_KEY_B'), 'value-b', 'second key stored correctly'); + +// WHEN we remove a key +removeApiKey('CE_KEY_A'); +// THEN it is gone +assert.strictEqual(getApiKey('CE_KEY_A'), null, 'removed key returns null'); +// AND the other key is unaffected +assert.strictEqual(getApiKey('CE_KEY_B'), 'value-b', 'other key unaffected by removal'); + +// WHEN we remove a key that does not exist +removeApiKey('CE_NONEXISTENT'); +// THEN no error is thrown and the file remains valid +assert.strictEqual(getApiKey('CE_KEY_B'), 'value-b', 'file still valid after removing nonexistent key'); + +// GIVEN a keys file with a corrupted entry (bad ciphertext) +const corruptKeys = JSON.parse(fs.readFileSync(KEYS_FILE, 'utf8')); +corruptKeys['CE_CORRUPT'] = { iv: '00', tag: '00', data: 'not-real-hex!' }; +fs.writeFileSync(KEYS_FILE, JSON.stringify(corruptKeys, null, 2), 'utf8'); +// WHEN we retrieve the corrupted key +const corruptResult = getApiKey('CE_CORRUPT'); +// THEN it returns null (graceful failure) +assert.strictEqual(corruptResult, null, 'getApiKey returns null for corrupted ciphertext'); + +// WHEN we retrieve a key that never existed +assert.strictEqual(getApiKey('CE_NEVER_SET'), null, 'getApiKey returns null for never-set key'); + +// GIVEN a key with special characters +const specialValue = 'key-with-quotes-"and-escapes\n\t\\'; +setApiKey('CE_SPECIAL', specialValue); +// WHEN retrieved +const specialResult = getApiKey('CE_SPECIAL'); +assert.strictEqual(specialResult, specialValue, 'special characters survive round-trip'); + +// GIVEN a key with unicode +const unicodeValue = 'k\u00e9y-\u00e9\u00e0\u00fc\u00f1'; +setApiKey('CE_UNICODE', unicodeValue); +const unicodeResult = getApiKey('CE_UNICODE'); +assert.strictEqual(unicodeResult, unicodeValue, 'unicode survives round-trip'); + +// GIVEN a keys file that is not valid JSON +fs.writeFileSync(KEYS_FILE, 'NOT JSON', 'utf8'); +// WHEN we try to set a new key (should overwrite) +setApiKey('CE_AFTER_CORRUPT', 'survives'); +const afterCorrupt = getApiKey('CE_AFTER_CORRUPT'); +assert.strictEqual(afterCorrupt, 'survives', 'setApiKey recovers from corrupt keys file'); + +// cleanup +fs.rmSync(testRoot, { recursive: true, force: true }); +console.log('crypto smoke ok'); diff --git a/scripts/embeddings-smoke.js b/scripts/embeddings-smoke.js index 5726159..cdbf2f1 100644 --- a/scripts/embeddings-smoke.js +++ b/scripts/embeddings-smoke.js @@ -13,4 +13,4 @@ checkOllamaEmbeddings({ timeoutMs: 1000 }) .catch((error) => { console.error(error.message); process.exitCode = 1; - }); \ No newline at end of file + }); diff --git a/scripts/generate-icons.js b/scripts/generate-icons.js index 4f2960e..b805c14 100644 --- a/scripts/generate-icons.js +++ b/scripts/generate-icons.js @@ -77,4 +77,4 @@ for (const size of PNG_SIZES) { } fs.writeFileSync(path.join(BRAND_DIR, 'icon.icns'), buildIcns()); -console.log('Generated brand icons'); \ No newline at end of file +console.log('Generated brand icons'); diff --git a/scripts/handoffs-smoke.js b/scripts/handoffs-smoke.js index 2682073..17d1795 100644 --- a/scripts/handoffs-smoke.js +++ b/scripts/handoffs-smoke.js @@ -15,6 +15,8 @@ const { createHandoff, listHandoffs, listArchived, + getHandoff, + updateHandoff, restoreHandoff, purgeHandoff, } = require('../server/lib/handoffs'); @@ -22,218 +24,297 @@ const { PROJECT_HANDOFF_RELATIVE, syncProjectHandoff } = require('../server/lib/ const { parseLegacyHandoff, migrateLegacyHandoff } = require('../server/lib/handoff-migration'); (async () => { -const active = createHandoff({ - title: 'Thread resume', - thread_tag: 'thread-resume', - body: 'Continue from the mocked thread state.', -}); -assert.strictEqual(active.ok, true, 'expected thread handoff to create'); -assert.strictEqual(active.handoff.type, 'thread'); -assert.strictEqual(active.handoff.slug, 'thread-resume'); -assert.strictEqual(listHandoffs().length, 1, 'expected active handoff in list'); - -const archived = fs.readFileSync(path.join(HANDOFFS_DIR, 'thread-resume.md'), 'utf8'); -fs.writeFileSync( - path.join(HANDOFFS_DIR, 'thread-resume.md'), - archived.replace(/last_touched: .+/, 'last_touched: 2020-01-01T00:00:00.000Z'), - 'utf8', -); -assert.strictEqual(listHandoffs().length, 0, 'idle thread handoff should auto-archive'); -assert.strictEqual(listArchived().length, 1, 'archived list should include stale thread handoff'); - -const restored = await restoreHandoff('thread-resume'); -assert.strictEqual(restored.ok, true, 'expected archived handoff to restore'); -assert.strictEqual(listHandoffs().length, 1, 'restored handoff should be active again'); - -const dual = createHandoff({ - title: 'Dual stale thread', - repo: tmpRoot, - thread_tag: 'dual-stale-thread', - body: 'A dual-bound handoff should archive when the thread is idle.', -}); -assert.strictEqual(dual.ok, true, 'expected dual handoff to create against existing directory'); -const dualPath = path.join(HANDOFFS_DIR, 'dual-stale-thread.md'); -fs.writeFileSync( - dualPath, - fs.readFileSync(dualPath, 'utf8').replace(/last_touched: .+/, 'last_touched: 2020-01-01T00:00:00.000Z'), - 'utf8', -); -listHandoffs(); -assert( - fs.existsSync(path.join(ARCHIVE_DIR, 'dual-stale-thread.md')), - 'dual-bound handoff should archive when thread is idle even if commit count is unavailable', -); - -const purged = await purgeHandoff('dual-stale-thread'); -assert.strictEqual(purged.ok, true, 'expected purge of archived handoff'); -assert(!fs.existsSync(path.join(ARCHIVE_DIR, 'dual-stale-thread.md')), 'purged handoff should be deleted'); - -const repoDir = path.join(tmpRoot, 'repo'); -fs.mkdirSync(repoDir); -/** @param {string[]} args */ -const git = (args) => execFileSync('git', args, { cwd: repoDir, stdio: ['ignore', 'pipe', 'ignore'] }); -git(['init']); -git(['config', 'user.email', 'context-engine@example.test']); -git(['config', 'user.name', 'Context Engine Test']); -fs.writeFileSync(path.join(repoDir, 'notes.txt'), 'baseline\n', 'utf8'); -git(['add', 'notes.txt']); -git(['commit', '-m', 'baseline']); -const project = createHandoff({ - title: 'Project timeline', - repo: repoDir, - thread_tag: 'project-timeline', - body: 'Track commits made after the handoff was written.', -}); -assert.strictEqual(project.ok, true, 'expected project handoff to create against git repo'); -fs.appendFileSync(path.join(repoDir, 'notes.txt'), 'first\n', 'utf8'); -git(['add', 'notes.txt']); -git(['commit', '-m', 'first change']); -fs.appendFileSync(path.join(repoDir, 'notes.txt'), 'second\n', 'utf8'); -git(['add', 'notes.txt']); -git(['commit', '-m', 'second change']); -const timelineHandoff = listHandoffs().find((handoff) => handoff.slug === 'project-timeline'); -assert(timelineHandoff, 'expected project timeline handoff to stay active under commit threshold'); -assert.strictEqual(timelineHandoff.staleness.commits_past_head, 2, 'expected two commits past handoff head'); - -// Restore-doesn't-re-archive regression: archive a project handoff via the -// commit threshold, then restore. The previous bug left head_sha pointing -// at the pre-archive sha so the next listHandoffs() would immediately re- -// archive on the same trip. Restore should refresh head_sha so the counter -// resets and the entry stays active. -const projectArchiveTarget = listHandoffs().find((h) => h.slug === 'project-timeline'); -assert(projectArchiveTarget, 'project handoff should exist before forced archive'); -const archiveResult = await require('../server/lib/handoffs').archiveHandoff('project-timeline'); -assert.strictEqual(archiveResult.ok, true, 'project handoff should archive on demand'); -// Advance the repo so commits_past_head against the old sha is > threshold. -for (let i = 0; i < 6; i++) { - fs.appendFileSync(path.join(repoDir, 'notes.txt'), `extra-${i}\n`, 'utf8'); + const active = createHandoff({ + title: 'Thread resume', + thread_tag: 'thread-resume', + body: 'Continue from the mocked thread state.', + }); + assert.strictEqual(active.ok, true, 'expected thread handoff to create'); + assert.strictEqual(active.handoff.type, 'thread'); + assert.strictEqual(active.handoff.slug, 'thread-resume'); + assert.strictEqual(listHandoffs().length, 1, 'expected active handoff in list'); + + const archived = fs.readFileSync(path.join(HANDOFFS_DIR, 'thread-resume.md'), 'utf8'); + fs.writeFileSync( + path.join(HANDOFFS_DIR, 'thread-resume.md'), + archived.replace(/last_touched: .+/, 'last_touched: 2020-01-01T00:00:00.000Z'), + 'utf8', + ); + assert.strictEqual(listHandoffs().length, 0, 'idle thread handoff should auto-archive'); + assert.strictEqual(listArchived().length, 1, 'archived list should include stale thread handoff'); + + const restored = await restoreHandoff('thread-resume'); + assert.strictEqual(restored.ok, true, 'expected archived handoff to restore'); + assert.strictEqual(listHandoffs().length, 1, 'restored handoff should be active again'); + + const dual = createHandoff({ + title: 'Dual stale thread', + repo: tmpRoot, + thread_tag: 'dual-stale-thread', + body: 'A dual-bound handoff should archive when the thread is idle.', + }); + assert.strictEqual(dual.ok, true, 'expected dual handoff to create against existing directory'); + const dualPath = path.join(HANDOFFS_DIR, 'dual-stale-thread.md'); + fs.writeFileSync( + dualPath, + fs.readFileSync(dualPath, 'utf8').replace(/last_touched: .+/, 'last_touched: 2020-01-01T00:00:00.000Z'), + 'utf8', + ); + listHandoffs(); + assert( + fs.existsSync(path.join(ARCHIVE_DIR, 'dual-stale-thread.md')), + 'dual-bound handoff should archive when thread is idle even if commit count is unavailable', + ); + + const purged = await purgeHandoff('dual-stale-thread'); + assert.strictEqual(purged.ok, true, 'expected purge of archived handoff'); + assert(!fs.existsSync(path.join(ARCHIVE_DIR, 'dual-stale-thread.md')), 'purged handoff should be deleted'); + + const repoDir = path.join(tmpRoot, 'repo'); + fs.mkdirSync(repoDir); + /** @param {string[]} args */ + const git = (args) => execFileSync('git', args, { cwd: repoDir, stdio: ['ignore', 'pipe', 'ignore'] }); + git(['init']); + git(['config', 'user.email', 'context-engine@example.test']); + git(['config', 'user.name', 'Context Engine Test']); + fs.writeFileSync(path.join(repoDir, 'notes.txt'), 'baseline\n', 'utf8'); + git(['add', 'notes.txt']); + git(['commit', '-m', 'baseline']); + const project = createHandoff({ + title: 'Project timeline', + repo: repoDir, + thread_tag: 'project-timeline', + body: 'Track commits made after the handoff was written.', + }); + assert.strictEqual(project.ok, true, 'expected project handoff to create against git repo'); + fs.appendFileSync(path.join(repoDir, 'notes.txt'), 'first\n', 'utf8'); git(['add', 'notes.txt']); - git(['commit', '-m', `extra ${i}`]); -} -const restoredProject = await restoreHandoff('project-timeline'); -assert.strictEqual(restoredProject.ok, true, 'archived project handoff should restore'); -// After restore + auto-sweep, the handoff must still be active (i.e. head_sha -// was refreshed; the commit counter is now zero against the new head). -const afterRestoreList = listHandoffs().map((h) => h.slug); -assert( - afterRestoreList.includes('project-timeline'), - 'restored project handoff should stay active — head_sha must be refreshed on restore', -); -const refreshed = listHandoffs().find((h) => h.slug === 'project-timeline'); -assert(refreshed, 'restored project handoff should appear in active list'); -assert.strictEqual( - refreshed.staleness.commits_past_head, - 0, - 'restored project handoff should report 0 commits past head', -); - -assert.strictEqual(timelineHandoff.staleness.commit_timeline.length, 2, 'expected bounded commit timeline'); -const latestCommit = timelineHandoff.staleness.commit_timeline[0]; -assert(latestCommit, 'expected at least one commit in timeline'); -assert.strictEqual(latestCommit.subject, 'second change'); -assert(latestCommit.short_sha, 'expected short sha in commit timeline'); - -const projectHandoffFile = path.join(repoDir, PROJECT_HANDOFF_RELATIVE); -fs.mkdirSync(path.dirname(projectHandoffFile), { recursive: true }); -fs.writeFileSync( - projectHandoffFile, - [ - '---', - 'title: Host-written checkpoint', - 'thread_tag: host-sync', - '---', - '# Current state', - '', - 'The host wrote this handoff inside the project directory.', - '', - '## Next', - '', - 'Context Engine should pull it into managed handoffs.', - ].join('\n'), - 'utf8', -); -const synced = await syncProjectHandoff(repoDir); -assert.strictEqual(synced.ok, true, 'expected project handoff file to sync'); -assert.strictEqual(synced.created, true, 'expected first project file sync to create a handoff'); -assert.strictEqual(synced.handoff.thread_tag, 'host-sync'); -assert(synced.handoff.body.includes('Current state'), 'expected synced body from project file'); -fs.writeFileSync( - projectHandoffFile, - [ - '---', - 'title: LLM should not overwrite UI title', - 'thread_tag: host-sync', - '---', - '# Updated by host', - '', - 'Second sync should update the body only.', - ].join('\n'), - 'utf8', -); -const resynced = await syncProjectHandoff(repoDir); -assert.strictEqual(resynced.ok, true, 'expected project handoff file to resync'); -assert.strictEqual(resynced.created, false, 'expected second project file sync to update existing handoff'); -assert.strictEqual( - resynced.handoff.title, - 'Host-written checkpoint', - 'existing UI title should be preserved', -); -assert(resynced.handoff.body.includes('Second sync'), 'expected synced body to update'); - -const appJs = fs.readFileSync(path.join(__dirname, '..', 'ui', 'app.js'), 'utf8'); -const handoffsUi = fs.readFileSync(path.join(__dirname, '..', 'ui', 'handoffs.js'), 'utf8'); -assert(appJs.includes("name === 'handoffs'"), 'switchTab should handle Handoffs activation'); -assert(appJs.includes('HandoffsTab.ensureLoaded'), 'Handoffs tab activation should retry load'); -assert(handoffsUi.includes('ensureLoaded'), 'HandoffsTab should expose an idempotent loader'); -assert(handoffsUi.includes('renderHandoffTimeline'), 'Handoffs detail should render body as timeline cards'); -assert( - handoffsUi.includes('handoff-edit-body'), - 'Handoffs detail should expose a body textarea so users can write the handoff prose themselves', -); -assert( - handoffsUi.includes('handoff-modal-body'), - 'Handoffs create modal should expose a body textarea so the feature is usable end-to-end from the GUI', -); - -const legacySource = path.join(tmpRoot, 'llm-handoff.md'); -fs.writeFileSync( - legacySource, - [ - '# LLM Handoff', - '', - '## Last session', - '', - '**2026-05-12 Current feature** - Continue wiring the new managed surface.', - '', - 'Details stay with the first parsed entry.', - '', - '**2026-05-10 Older work** - Preserve this in archive.', - '', - 'Older detail body.', - '', - '## Open threads', - '', - '- This section is not a dated legacy handoff entry.', - '', - ].join('\n'), - 'utf8', -); -const parsedLegacy = parseLegacyHandoff(fs.readFileSync(legacySource, 'utf8')); -assert.strictEqual(parsedLegacy.length, 2, 'expected two dated legacy entries'); -const firstLegacy = parsedLegacy[0]; -assert(firstLegacy, 'expected first parsed legacy entry'); -assert.strictEqual(firstLegacy.title, 'Current feature'); -assert(firstLegacy.body.includes('Details stay'), 'expected body continuation to stay with entry'); - -const migrated = await migrateLegacyHandoff({ sourceFile: legacySource, repo: tmpRoot, keepActive: 1 }); -assert.strictEqual(migrated.ok, true, 'expected legacy migration to succeed'); -assert.strictEqual(migrated.imported, 2, 'expected two imported legacy entries'); -assert.strictEqual(migrated.active, 1, 'expected newest legacy entry to stay active'); -assert.strictEqual(migrated.archived, 1, 'expected older legacy entry to archive'); -assert(fs.existsSync(path.join(HANDOFFS_DIR, 'legacy-2026-05-12-current-feature.md'))); -assert(fs.existsSync(path.join(ARCHIVE_DIR, 'legacy-2026-05-10-older-work.md'))); - -console.log('handoffs smoke ok'); + git(['commit', '-m', 'first change']); + fs.appendFileSync(path.join(repoDir, 'notes.txt'), 'second\n', 'utf8'); + git(['add', 'notes.txt']); + git(['commit', '-m', 'second change']); + const timelineHandoff = listHandoffs().find((handoff) => handoff.slug === 'project-timeline'); + assert(timelineHandoff, 'expected project timeline handoff to stay active under commit threshold'); + assert.strictEqual( + timelineHandoff.staleness.commits_past_head, + 2, + 'expected two commits past handoff head', + ); + + // Restore-doesn't-re-archive regression: archive a project handoff via the + // commit threshold, then restore. The previous bug left head_sha pointing + // at the pre-archive sha so the next listHandoffs() would immediately re- + // archive on the same trip. Restore should refresh head_sha so the counter + // resets and the entry stays active. + const projectArchiveTarget = listHandoffs().find((h) => h.slug === 'project-timeline'); + assert(projectArchiveTarget, 'project handoff should exist before forced archive'); + const archiveResult = await require('../server/lib/handoffs').archiveHandoff('project-timeline'); + assert.strictEqual(archiveResult.ok, true, 'project handoff should archive on demand'); + // Advance the repo so commits_past_head against the old sha is > threshold. + for (let i = 0; i < 6; i++) { + fs.appendFileSync(path.join(repoDir, 'notes.txt'), `extra-${i}\n`, 'utf8'); + git(['add', 'notes.txt']); + git(['commit', '-m', `extra ${i}`]); + } + const restoredProject = await restoreHandoff('project-timeline'); + assert.strictEqual(restoredProject.ok, true, 'archived project handoff should restore'); + // After restore + auto-sweep, the handoff must still be active (i.e. head_sha + // was refreshed; the commit counter is now zero against the new head). + const afterRestoreList = listHandoffs().map((h) => h.slug); + assert( + afterRestoreList.includes('project-timeline'), + 'restored project handoff should stay active — head_sha must be refreshed on restore', + ); + const refreshed = listHandoffs().find((h) => h.slug === 'project-timeline'); + assert(refreshed, 'restored project handoff should appear in active list'); + assert.strictEqual( + refreshed.staleness.commits_past_head, + 0, + 'restored project handoff should report 0 commits past head', + ); + + assert.strictEqual(timelineHandoff.staleness.commit_timeline.length, 2, 'expected bounded commit timeline'); + const latestCommit = timelineHandoff.staleness.commit_timeline[0]; + assert(latestCommit, 'expected at least one commit in timeline'); + assert.strictEqual(latestCommit.subject, 'second change'); + assert(latestCommit.short_sha, 'expected short sha in commit timeline'); + + const projectHandoffFile = path.join(repoDir, PROJECT_HANDOFF_RELATIVE); + fs.mkdirSync(path.dirname(projectHandoffFile), { recursive: true }); + fs.writeFileSync( + projectHandoffFile, + [ + '---', + 'title: Host-written checkpoint', + 'thread_tag: host-sync', + '---', + '# Current state', + '', + 'The host wrote this handoff inside the project directory.', + '', + '## Next', + '', + 'Context Engine should pull it into managed handoffs.', + ].join('\n'), + 'utf8', + ); + const synced = await syncProjectHandoff(repoDir); + assert.strictEqual(synced.ok, true, 'expected project handoff file to sync'); + assert.strictEqual(synced.created, true, 'expected first project file sync to create a handoff'); + assert.strictEqual(synced.handoff.thread_tag, 'host-sync'); + assert(synced.handoff.body.includes('Current state'), 'expected synced body from project file'); + fs.writeFileSync( + projectHandoffFile, + [ + '---', + 'title: LLM should not overwrite UI title', + 'thread_tag: host-sync', + '---', + '# Updated by host', + '', + 'Second sync should update the body only.', + ].join('\n'), + 'utf8', + ); + const resynced = await syncProjectHandoff(repoDir); + assert.strictEqual(resynced.ok, true, 'expected project handoff file to resync'); + assert.strictEqual(resynced.created, false, 'expected second project file sync to update existing handoff'); + assert.strictEqual( + resynced.handoff.title, + 'Host-written checkpoint', + 'existing UI title should be preserved', + ); + assert(resynced.handoff.body.includes('Second sync'), 'expected synced body to update'); + + const appJs = fs.readFileSync(path.join(__dirname, '..', 'ui', 'app.js'), 'utf8'); + const handoffsUi = fs.readFileSync(path.join(__dirname, '..', 'ui', 'handoffs.js'), 'utf8'); + assert(appJs.includes("name === 'handoffs'"), 'switchTab should handle Handoffs activation'); + assert(appJs.includes('HandoffsTab.ensureLoaded'), 'Handoffs tab activation should retry load'); + assert(handoffsUi.includes('ensureLoaded'), 'HandoffsTab should expose an idempotent loader'); + assert( + handoffsUi.includes('renderHandoffTimeline'), + 'Handoffs detail should render body as timeline cards', + ); + assert( + handoffsUi.includes('handoff-edit-body'), + 'Handoffs detail should expose a body textarea so users can write the handoff prose themselves', + ); + assert( + handoffsUi.includes('handoff-modal-body'), + 'Handoffs create modal should expose a body textarea so the feature is usable end-to-end from the GUI', + ); + + // ---- getHandoff ---- + + // GIVEN an existing active handoff + const foundActive = getHandoff('project-timeline'); + assert.ok(foundActive, 'getHandoff returns handoff for existing slug'); + assert.strictEqual(foundActive.slug, 'project-timeline', 'getHandoff returns correct slug'); + + // GIVEN a non-existent handoff + const notFound = getHandoff('no-such-handoff'); + assert.strictEqual(notFound, null, 'getHandoff returns null for unknown slug'); + + // GIVEN a path-traversal slug + const traversal = getHandoff('../../etc/passwd'); + assert.strictEqual(traversal, null, 'getHandoff returns null for path-traversal slug'); + + // ---- updateHandoff ---- + + // GIVEN an active handoff + createHandoff({ title: 'Update Target', thread_tag: 'update-target', body: 'Original body.' }); + // WHEN we update the title + const titleUpdate = await updateHandoff('update-target', { title: 'Updated Title' }); + assert.strictEqual(titleUpdate.ok, true, 'updateHandoff succeeds for title'); + assert.strictEqual(titleUpdate.handoff.title, 'Updated Title', 'title is updated'); + // AND slug does not change + assert.strictEqual(titleUpdate.handoff.slug, 'update-target', 'slug stays same after title update'); + + // WHEN we update the body + const bodyUpdate = await updateHandoff('update-target', { body: 'New body content.' }); + assert.strictEqual(bodyUpdate.ok, true, 'updateHandoff succeeds for body'); + assert.ok(bodyUpdate.handoff.body.includes('New body content'), 'body is updated'); + + // WHEN we try to update a non-existent handoff + const updateMiss = await updateHandoff('nonexistent-slug', { title: 'X' }); + assert.strictEqual(updateMiss.ok, false, 'updateHandoff fails for non-existent slug'); + + // WHEN we try to update with path-traversal slug + const updateTraversal = await updateHandoff('../../etc/passwd', { title: 'X' }); + assert.strictEqual(updateTraversal.ok, false, 'updateHandoff rejects path-traversal slug'); + + // ---- createHandoff slug collision ---- + + createHandoff({ title: 'Collision Test', thread_tag: 'collision-test', body: 'First.' }); + const collision = createHandoff({ + title: 'Collision Test', + thread_tag: 'collision-test-2', + body: 'Second.', + }); + assert.strictEqual(collision.ok, true, 'createHandoff succeeds when slug collides'); + assert.ok(collision.handoff.slug.startsWith('collision-test'), 'collision slug has prefix'); + assert.notStrictEqual(collision.handoff.slug, 'collision-test', 'collision slug has suffix'); + + // ---- createHandoff uses tag as slug seed when present ---- + + const tagSlug = createHandoff({ + title: 'Title Different', + thread_tag: 'tag-based-slug', + body: 'Tag slug.', + }); + assert.strictEqual(tagSlug.ok, true, 'createHandoff with thread_tag succeeds'); + assert.strictEqual(tagSlug.handoff.slug, 'tag-based-slug', 'slug derives from thread_tag when present'); + + // ---- createHandoff without title ---- + + const noTitle = createHandoff({ title: '', body: 'No title.' }); + assert.strictEqual(noTitle.ok, false, 'createHandoff fails with empty title'); + assert.strictEqual(noTitle.error, 'title is required', 'error mentions title'); + + // ---- createHandoff with non-directory repo ---- + + const badRepo = createHandoff({ title: 'Bad Repo', repo: '/nonexistent/path/xyz', body: 'Bad repo.' }); + assert.strictEqual(badRepo.ok, false, 'createHandoff fails with nonexistent repo path'); + + const legacySource = path.join(tmpRoot, 'llm-handoff.md'); + fs.writeFileSync( + legacySource, + [ + '# LLM Handoff', + '', + '## Last session', + '', + '**2026-05-12 Current feature** - Continue wiring the new managed surface.', + '', + 'Details stay with the first parsed entry.', + '', + '**2026-05-10 Older work** - Preserve this in archive.', + '', + 'Older detail body.', + '', + '## Open threads', + '', + '- This section is not a dated legacy handoff entry.', + '', + ].join('\n'), + 'utf8', + ); + const parsedLegacy = parseLegacyHandoff(fs.readFileSync(legacySource, 'utf8')); + assert.strictEqual(parsedLegacy.length, 2, 'expected two dated legacy entries'); + const firstLegacy = parsedLegacy[0]; + assert(firstLegacy, 'expected first parsed legacy entry'); + assert.strictEqual(firstLegacy.title, 'Current feature'); + assert(firstLegacy.body.includes('Details stay'), 'expected body continuation to stay with entry'); + + const migrated = await migrateLegacyHandoff({ sourceFile: legacySource, repo: tmpRoot, keepActive: 1 }); + assert.strictEqual(migrated.ok, true, 'expected legacy migration to succeed'); + assert.strictEqual(migrated.imported, 2, 'expected two imported legacy entries'); + assert.strictEqual(migrated.active, 1, 'expected newest legacy entry to stay active'); + assert.strictEqual(migrated.archived, 1, 'expected older legacy entry to archive'); + assert(fs.existsSync(path.join(HANDOFFS_DIR, 'current-feature.md'))); + assert(fs.existsSync(path.join(ARCHIVE_DIR, 'older-work.md'))); + + console.log('handoffs smoke ok'); })().catch((err) => { console.error(err); process.exit(1); diff --git a/scripts/mcp-http-smoke.mjs b/scripts/mcp-http-smoke.mjs index 15104c6..7d66326 100644 --- a/scripts/mcp-http-smoke.mjs +++ b/scripts/mcp-http-smoke.mjs @@ -219,4 +219,4 @@ if (failures.length) { process.exitCode = 1; } else { console.log('\nall checks passed'); -} \ No newline at end of file +} diff --git a/scripts/migrate-legacy-handoff.js b/scripts/migrate-legacy-handoff.js index 0151962..deca6d8 100644 --- a/scripts/migrate-legacy-handoff.js +++ b/scripts/migrate-legacy-handoff.js @@ -11,55 +11,55 @@ const keepActiveArg = process.argv.find((arg) => arg.startsWith('--keep-active=' const keepActive = keepActiveArg ? Number(keepActiveArg.split('=')[1]) || 0 : 0; void (async () => { -const result = await migrateLegacyHandoff({ - sourceFile, - repo: APP_DIR, - keepActive, -}); - -if (!result.ok) { - console.error(result.error); - process.exit(1); -} - -const existingCurrent = getHandoff('handoff-feature'); -let currentSlug = existingCurrent?.slug || null; -if (!currentSlug) { - const current = createHandoff({ - title: 'Handoffs feature implementation', + const result = await migrateLegacyHandoff({ + sourceFile, repo: APP_DIR, - thread_tag: 'handoff-feature', - body: [ - 'Managed Handoffs backend, MCP bridge, admin tab, and legacy migration are in flight.', - '', - 'Implemented so far:', - '- data/handoffs storage with active/archive lifecycle', - '- /api/handoffs routes split into server/lib/handoff-routes.js', - '- context_engine_handoffs across stdio, HTTP, and MCPB transports', - '- Handoffs admin tab with active/archive views, side-panel edit, create modal, restore, and purge', - '- legacy docs/llm-handoff.md parser/importer', - '', - 'Verification already run: test:handoffs, typecheck, lint, lint:css, smoke, smoke:mcp, smoke:mcp:http, smoke:mcpb, diff --check.', - 'Preview/render validation is intentionally left for Jeremy per request.', - ].join('\n'), + keepActive, }); - currentSlug = current.ok ? current.handoff.slug : null; -} -console.log( - JSON.stringify( - { - ok: true, - imported: result.imported, - skipped: result.skipped, - active: result.active + (currentSlug ? 1 : 0), - archived: result.archived, - current: currentSlug, - }, - null, - 2, - ), -); + if (!result.ok) { + console.error(result.error); + process.exit(1); + } + + const existingCurrent = getHandoff('handoff-feature'); + let currentSlug = existingCurrent?.slug || null; + if (!currentSlug) { + const current = createHandoff({ + title: 'Handoffs feature implementation', + repo: APP_DIR, + thread_tag: 'handoff-feature', + body: [ + 'Managed Handoffs backend, MCP bridge, admin tab, and legacy migration are in flight.', + '', + 'Implemented so far:', + '- data/handoffs storage with active/archive lifecycle', + '- /api/handoffs routes split into server/lib/handoff-routes.js', + '- context_engine_handoffs across stdio, HTTP, and MCPB transports', + '- Handoffs admin tab with active/archive views, side-panel edit, create modal, restore, and purge', + '- legacy docs/llm-handoff.md parser/importer', + '', + 'Verification already run: test:handoffs, typecheck, lint, lint:css, smoke, smoke:mcp, smoke:mcp:http, smoke:mcpb, diff --check.', + 'Preview/render validation is intentionally left for Jeremy per request.', + ].join('\n'), + }); + currentSlug = current.ok ? current.handoff.slug : null; + } + + console.log( + JSON.stringify( + { + ok: true, + imported: result.imported, + skipped: result.skipped, + active: result.active + (currentSlug ? 1 : 0), + archived: result.archived, + current: currentSlug, + }, + null, + 2, + ), + ); })().catch((err) => { console.error(err); process.exit(1); diff --git a/scripts/mode-apply-smoke.js b/scripts/mode-apply-smoke.js new file mode 100644 index 0000000..112dff6 --- /dev/null +++ b/scripts/mode-apply-smoke.js @@ -0,0 +1,120 @@ +// @ts-check + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +// GIVEN: a CE_ROOT with two skills and default mode data +const testRoot = path.join(os.tmpdir(), 'ce-mode-test-' + Date.now()); +const dataDir = path.join(testRoot, 'data'); +const skillsDir = path.join(testRoot, 'skills'); +fs.mkdirSync(dataDir, { recursive: true }); +fs.mkdirSync(path.join(skillsDir, 'skill-a'), { recursive: true }); +fs.mkdirSync(path.join(skillsDir, 'skill-b'), { recursive: true }); +fs.writeFileSync( + path.join(skillsDir, 'skill-a', 'SKILL.md'), + '---\nname: skill-a\ndescription: Skill A\n---\n# A', +); +fs.writeFileSync( + path.join(skillsDir, 'skill-b', 'SKILL.md'), + '---\nname: skill-b\ndescription: Skill B\n---\n# B', +); +fs.writeFileSync(path.join(dataDir, 'rules.json'), JSON.stringify({ coding: '', general: '', soul: '' })); + +// Override CE_ROOT before requiring modules +process.env.CE_ROOT = testRoot; + +// Clear require cache so config picks up new CE_ROOT +delete require.cache[require.resolve('../server/lib/config')]; +delete require.cache[require.resolve('../server/lib/modes')]; +delete require.cache[require.resolve('../server/lib/skills')]; +delete require.cache[require.resolve('../server/lib/backup')]; + +const { applyMode, DEFAULT_MODES } = require('../server/lib/modes'); +const { scanSkills, invalidateSkillCache } = require('../server/lib/skills'); +const { writeData } = require('../server/lib/backup'); + +invalidateSkillCache(); +scanSkills(true); + +// WHEN: a mode with an empty skills list is applied +const result = applyMode('coding'); + +// THEN: no skills should be disabled +assert(result, 'applyMode should return a result'); +const states = /** @type {Record} */ (result.states || result); +console.log('States after coding mode:', JSON.stringify(states)); + +assert(states['skill-a'] !== false, 'skill-a should NOT be disabled by a mode with an empty skill list'); +assert(states['skill-b'] !== false, 'skill-b should NOT be disabled by a mode with an empty skill list'); + +// WHEN: the "all" mode is applied +invalidateSkillCache(); +const allResult = applyMode('all'); +assert(allResult, 'all mode should return a result'); +const allStates = /** @type {Record} */ (allResult.states || allResult); +console.log('States after all mode:', JSON.stringify(allStates)); + +// THEN: all skills should be active +assert(allStates['skill-a'] === true, 'skill-a should be active in all mode'); +assert(allStates['skill-b'] === true, 'skill-b should be active in all mode'); + +// WHEN: a mode that lists only skill-a is created and applied +const modesFile = path.join(dataDir, 'modes.json'); +const modesData = { modes: [...DEFAULT_MODES.modes] }; +/** @type {Array<{id: string, label: string, icon: string, desc: string, skills: string[]}>} */ +const modesArr = modesData.modes; +modesArr.push({ + id: 'only-a', + label: 'Only A', + icon: 'bolt', + desc: 'Only skill A', + skills: ['skill-a'], +}); +fs.writeFileSync(modesFile, JSON.stringify(modesData), 'utf8'); + +invalidateSkillCache(); +const onlyAResult = applyMode('only-a'); +assert(onlyAResult, 'only-a mode should return a result'); +const onlyAStates = /** @type {Record} */ (onlyAResult.states || onlyAResult); +console.log('States after only-a mode:', JSON.stringify(onlyAStates)); + +// THEN: skill-a should be active, skill-b should keep its previous state (not be force-disabled) +assert(onlyAStates['skill-a'] === true, 'skill-a should be active in only-a mode'); +// skill-b was true from the "all" mode apply above — it should remain true +assert( + onlyAStates['skill-b'] !== false, + 'skill-b should NOT be force-disabled by a mode that only lists skill-a', +); + +// GIVEN: fresh skills with an explicitly disabled skill +invalidateSkillCache(); +const explicitResult = applyMode('all'); +assert(explicitResult, 'explicit all mode should return a result'); +const explicitStates = /** @type {Record} */ (explicitResult.states || explicitResult); +// Manually disable skill-b to simulate a user toggle +explicitStates['skill-b'] = false; +writeData('skill-states.json', { + version: '1.0', + last_updated: new Date().toISOString().split('T')[0], + states: explicitStates, +}); + +// WHEN: the only-a mode is applied to a state where skill-b was off +invalidateSkillCache(); +const afterToggleResult = applyMode('only-a'); +assert(afterToggleResult, 'after-toggle only-a mode should return a result'); +const afterToggleStates = /** @type {Record} */ ( + afterToggleResult.states || afterToggleResult +); +console.log('States after only-a with skill-b previously off:', JSON.stringify(afterToggleStates)); + +// THEN: skill-a should be active, skill-b should stay off (its last explicit state) +assert(afterToggleStates['skill-a'] === true, 'skill-a should be active'); +assert(afterToggleStates['skill-b'] === false, 'skill-b should keep its explicitly-set-off state'); + +// Cleanup +fs.rmSync(testRoot, { recursive: true, force: true }); + +console.log('mode-apply smoke ok'); diff --git a/scripts/mutex-smoke.js b/scripts/mutex-smoke.js new file mode 100644 index 0000000..e24cdd4 --- /dev/null +++ b/scripts/mutex-smoke.js @@ -0,0 +1,109 @@ +// @ts-check + +// mutex-smoke.js — Smoke test for per-key mutex concurrency + +const assert = require('assert'); +const { createKeyMutex } = require('../server/lib/per-key-mutex'); + +void (async () => { + // ---- serial execution per key ---- + + // GIVEN a mutex + const mutex = createKeyMutex(); + + // WHEN two operations run on the same key + const order = /** @type {string[]} */ ([]); + let p1Done = false; + const p1 = mutex('key-a', () => { + /** @type {Promise} */ + const promise = new Promise((resolve) => + setTimeout(() => { + order.push('p1'); + p1Done = true; + resolve(); + }, 50), + ); + return promise; + }); + // eslint-disable-next-line @typescript-eslint/require-await + const p2 = mutex('key-a', async () => { + assert.ok(p1Done, 'p2 must wait for p1'); + order.push('p2'); + }); + await p1; + await p2; + assert.deepStrictEqual(order, ['p1', 'p2'], 'same-key operations are serialized'); + + // ---- parallel execution across keys ---- + + // WHEN two operations run on different keys + const parallelOrder = /** @type {string[]} */ ([]); + let aStarted = false; + let bStarted = false; + const pa = mutex('key-x', () => { + /** @type {Promise} */ + const promise = new Promise((resolve) => + setTimeout(() => { + aStarted = true; + parallelOrder.push('a'); + resolve(); + }, 30), + ); + return promise; + }); + const pb = mutex('key-y', () => { + /** @type {Promise} */ + const promise = new Promise((resolve) => + setTimeout(() => { + bStarted = true; + parallelOrder.push('b'); + resolve(); + }, 10), + ); + return promise; + }); + await Promise.all([pa, pb]); + assert.ok(aStarted && bStarted, 'different keys run in parallel'); + assert.strictEqual(parallelOrder[0], 'b', 'shorter task on different key finishes first'); + + // ---- predecessor error doesn't block successor ---- + + // GIVEN a mutex + const errMutex = createKeyMutex(); + + // WHEN the first operation on a key throws + let successorRan = false; + const failing = errMutex('err-key', () => Promise.reject(new Error('intentional'))); + try { + await failing; + } catch { + // expected + } + // eslint-disable-next-line @typescript-eslint/require-await + const succeeding = errMutex('err-key', async () => { + successorRan = true; + }); + await succeeding; + assert.ok(successorRan, 'successor runs even after predecessor error'); + + // ---- return value preserved ---- + + const returnMutex = createKeyMutex(); + const result = await returnMutex('ret-key', () => Promise.resolve(42)); + assert.strictEqual(result, 42, 'return value is preserved'); + + // ---- cleanup after completion ---- + + // WHEN an operation completes + const cleanMutex = createKeyMutex(); + await cleanMutex('clean-key', () => Promise.resolve()); + // AND we start a new operation on the same key + let secondRan = false; + // eslint-disable-next-line @typescript-eslint/require-await + await cleanMutex('clean-key', async () => { + secondRan = true; + }); + assert.ok(secondRan, 'key is reusable after completion'); + + console.log('mutex smoke ok'); +})(); diff --git a/scripts/projects-smoke.js b/scripts/projects-smoke.js new file mode 100644 index 0000000..6f46f26 --- /dev/null +++ b/scripts/projects-smoke.js @@ -0,0 +1,427 @@ +// @ts-check + +// projects-smoke.js — Self-contained smoke test for project CRUD + +const fs = require('fs'); +const http = require('http'); +const os = require('os'); +const path = require('path'); + +// GIVEN a fresh CE_ROOT so we test in isolation +const testRoot = path.join(os.tmpdir(), 'ce-projects-test-' + Date.now()); +fs.mkdirSync(path.join(testRoot, 'data'), { recursive: true }); +process.env.CE_ROOT = testRoot; + +let pass = 0; +let fail = 0; + +/** @param {boolean} cond @param {string} label */ +function check(cond, label) { + if (cond) { + pass++; + console.log(` PASS: ${label}`); + } else { + fail++; + console.log(` FAIL: ${label}`); + } +} + +// =================================================================== +// SECTION 1: Library-level unit tests +// =================================================================== + +// Clear require cache so modules pick up our CE_ROOT +delete require.cache[require.resolve('../server/lib/config')]; +delete require.cache[require.resolve('../server/lib/projects')]; + +const projects = require('../server/lib/projects'); + +// ---- listProjects ---- + +// GIVEN no projects exist yet +check(projects.listProjects().length === 0, 'listProjects returns empty array when no projects'); + +// GIVEN a registry where projects key exists but is not an array +fs.writeFileSync(projects.PROJECTS_FILE, JSON.stringify({ version: '1.0', projects: 'not-array' }), 'utf8'); +const nonArrayResult = projects.listProjects(); +check(Array.isArray(nonArrayResult), 'listProjects returns array when projects key is not array'); +check(nonArrayResult.length === 0, 'listProjects returns empty array when projects key is not array'); + +// GIVEN a registry that is valid JSON but not an object (e.g. an array) +fs.writeFileSync(projects.PROJECTS_FILE, JSON.stringify([]), 'utf8'); +const arrayRegistryResult = projects.listProjects(); +check(Array.isArray(arrayRegistryResult), 'listProjects returns array when registry is valid JSON array'); +check(arrayRegistryResult.length === 0, 'listProjects returns empty array for array registry'); + +// GIVEN a project created after registry normalization +const rNorm = projects.createProject({ name: 'After Normalize' }); +check(rNorm.ok === true, 'createProject succeeds after registry normalization'); +check(projects.listProjects().length === 1, 'project is persisted after creating on normalized registry'); + +// GIVEN a corrupted projects.json +fs.writeFileSync(projects.PROJECTS_FILE, 'NOT JSON', 'utf8'); +const corruptResult = projects.listProjects(); +check(Array.isArray(corruptResult), 'listProjects returns array when registry file is corrupt'); +check(corruptResult.length === 0, 'listProjects returns empty array when registry file is corrupt'); + +// GIVEN projects.json doesn't exist at all +fs.rmSync(path.join(testRoot, 'data'), { recursive: true, force: true }); +fs.mkdirSync(path.join(testRoot, 'data'), { recursive: true }); +const missingResult = projects.listProjects(); +check(Array.isArray(missingResult), 'listProjects returns array when registry file is missing'); +check(missingResult.length === 0, 'listProjects returns empty array when registry file is missing'); + +// ---- uniqueSlug (tested via createProject) ---- + +// WHEN we create a project with name only +const r1 = projects.createProject({ name: 'My App' }); +check(r1.ok === true, 'createProject succeeds with name only'); +const p1 = + /** @type {{ slug: string, created: string, last_touched: string, path: string | undefined, name: string }} */ ( + r1.project + ); +check(p1.slug === 'my-app', 'slug derives from name (spaces to hyphens)'); +check(p1.name === 'My App', 'project stores original name'); +check(typeof p1.created === 'string', 'project has created timestamp'); +check(typeof p1.last_touched === 'string', 'project has last_touched timestamp'); +check(p1.path === undefined, 'path is undefined when not provided'); + +// WHEN name has leading/trailing special chars +const rSpecial = projects.createProject({ name: '---Hello!!!World---' }); +check(rSpecial.ok === true, 'createProject succeeds with special chars in name'); +check( + /** @type {{ slug: string }} */ (rSpecial.project).slug === 'hello-world', + 'slug strips leading/trailing hyphens and special chars', +); + +// WHEN name is all special chars (slug falls back to "project") +const rAllSpecial = projects.createProject({ name: '!!!@@@###' }); +check(rAllSpecial.ok === true, 'createProject succeeds with all-special-char name'); +check( + /** @type {{ slug: string }} */ (rAllSpecial.project).slug === 'project', + 'slug falls back to "project" when all chars are stripped', +); + +// WHEN name is whitespace only +const rWhitespace = projects.createProject({ name: ' ' }); +check(rWhitespace.ok === false, 'createProject fails with whitespace-only name'); +check(rWhitespace.error === 'name is required', 'whitespace-only name gives "name is required" error'); + +// WHEN name is very long (slug truncates to 60 chars) +const longName = 'A'.repeat(100); +const rLong = projects.createProject({ name: longName }); +check(rLong.ok === true, 'createProject succeeds with very long name'); +check( + /** @type {{ slug: string }} */ (rLong.project).slug.length <= 60, + 'slug is truncated to at most 60 chars', +); + +// ---- createProject directory structure ---- + +const pDir = path.join(projects.PROJECTS_DIR, p1.slug); +check(fs.existsSync(pDir), 'project directory is created'); +check(fs.existsSync(path.join(pDir, 'handoffs')), 'handoffs subdirectory is created'); +check(fs.existsSync(path.join(pDir, 'memory.json')), 'memory.json seed file is created'); +check(fs.existsSync(path.join(pDir, 'rules.json')), 'rules.json seed file is created'); + +// AND the seed files are valid JSON +const memData = JSON.parse(fs.readFileSync(path.join(pDir, 'memory.json'), 'utf8')); +check(memData.version === '1.1', 'memory.json has correct version'); +check(Array.isArray(memData.entries), 'memory.json has entries array'); +const rulesData = JSON.parse(fs.readFileSync(path.join(pDir, 'rules.json'), 'utf8')); +check('coding' in rulesData && 'general' in rulesData && 'soul' in rulesData, 'rules.json has expected keys'); + +// WHEN we create a project with name and path +const r2 = projects.createProject({ name: 'Has Path', path: 'C:\\dev\\has-path' }); +check(r2.ok === true, 'createProject succeeds with name and path'); +const p2 = /** @type {{ slug: string, path: string }} */ (r2.project); +check(p2.path === 'C:\\dev\\has-path', 'project stores the path'); +check(p2.slug === 'has-path', 'slug derives from name with path provided'); + +// WHEN we create a project with whitespace-padded path +const rPadPath = projects.createProject({ name: 'Padded', path: ' C:\\dev\\pad ' }); +check(rPadPath.ok === true, 'createProject succeeds with whitespace-padded path'); +check(/** @type {{ path: string }} */ (rPadPath.project).path === 'C:\\dev\\pad', 'path is trimmed'); + +// WHEN we create a project with empty path +const rEmptyPath = projects.createProject({ name: 'Empty Path', path: '' }); +check(rEmptyPath.ok === true, 'createProject succeeds with empty path'); +check( + /** @type {{ path: string | undefined }} */ (rEmptyPath.project).path === undefined, + 'empty path becomes undefined', +); + +// WHEN we create a project without a name +const rNoName = projects.createProject({ name: '' }); +check(rNoName.ok === false, 'createProject fails with empty name'); +check(rNoName.error === 'name is required', 'error message is "name is required"'); + +// WHEN we create a project with no input at all +const rNoInput = projects.createProject({}); +check(rNoInput.ok === false, 'createProject fails with no input'); + +// ---- slug collision ---- + +// GIVEN a project named "duplicate" already exists +projects.createProject({ name: 'duplicate' }); + +// WHEN we create another project with the same name +const rDup = projects.createProject({ name: 'duplicate' }); +check(rDup.ok === true, 'createProject succeeds when slug collides'); +check( + /** @type {{ slug: string }} */ (rDup.project).slug === 'duplicate-2', + 'slug gets -2 suffix on collision', +); + +// WHEN we create a third duplicate +const rDup3 = projects.createProject({ name: 'duplicate' }); +check(rDup3.ok === true, 'createProject succeeds on third collision'); +check( + /** @type {{ slug: string }} */ (rDup3.project).slug === 'duplicate-3', + 'slug gets -3 suffix on third collision', +); + +// WHEN the "project" fallback slug already exists (created above via '!!!@@@###') +const rFallbackDup = projects.createProject({ name: '@$$%' }); +check(rFallbackDup.ok === true, 'fallback slug "project" gets collision suffix'); +const fallbackSlug = /** @type {{ slug: string }} */ (rFallbackDup.project).slug; +check(fallbackSlug.startsWith('project-'), 'fallback slug collision produces "project-N"'); + +// ---- getProject ---- + +const found = projects.getProject('my-app'); +check(found !== null, 'getProject returns project when slug exists'); +check(found && found.name === 'My App', 'getProject returns correct project name'); + +check(projects.getProject('no-such-slug') === null, 'getProject returns null for unknown slug'); + +// ---- updateProject ---- + +const uName = projects.updateProject('my-app', { name: 'My Updated App' }); +check(uName.ok === true, 'updateProject succeeds for name'); +check( + /** @type {{ name: string, slug: string }} */ (uName.project).name === 'My Updated App', + 'name is updated', +); +check( + /** @type {{ name: string, slug: string }} */ (uName.project).slug === 'my-app', + 'slug stays the same after name update', +); + +const uPath = projects.updateProject('my-app', { path: 'C:\\new\\path' }); +check(uPath.ok === true, 'updateProject succeeds for path'); +check(/** @type {{ path: string }} */ (uPath.project).path === 'C:\\new\\path', 'path is updated'); + +const uClear = projects.updateProject('my-app', { path: '' }); +check(uClear.ok === true, 'updateProject succeeds for clearing path'); +check( + /** @type {{ path: string | undefined }} */ (uClear.project).path === undefined, + 'path becomes undefined when cleared', +); + +const beforeTouch = projects.getProject('my-app')?.last_touched; +Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 15); +const uTouch = projects.updateProject('my-app', { name: 'My Updated App' }); +check(uTouch.ok === true, 'updateProject succeeds with same name'); +check( + /** @type {{ last_touched: string }} */ (uTouch.project).last_touched !== beforeTouch, + 'last_touched changes on update', +); + +// WHEN we update with empty patch (no name, no path) +Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 15); +const uEmpty = projects.updateProject('my-app', {}); +check(uEmpty.ok === true, 'updateProject succeeds with empty patch'); +const uEmptyLastTouched = /** @type {{ last_touched: string }} */ (uEmpty.project).last_touched; +check(uEmptyLastTouched !== uTouch.project.last_touched, 'last_touched changes even with empty patch'); + +const uMiss = projects.updateProject('no-such-slug', { name: 'X' }); +check(uMiss.ok === false, 'updateProject fails for non-existent slug'); +check(uMiss.error === 'Project not found', 'error message is "Project not found"'); + +// WHEN we update with whitespace-only name +const uBlank = projects.updateProject('my-app', { name: ' ' }); +check(uBlank.ok === false, 'updateProject rejects whitespace-only name'); +check(uBlank.error === 'name cannot be empty', 'error message is "name cannot be empty"'); + +// ---- deleteProject ---- + +const d1 = projects.deleteProject('has-path'); +check(d1.ok === true, 'deleteProject succeeds for existing project'); +check(!fs.existsSync(path.join(projects.PROJECTS_DIR, 'has-path')), 'project directory is removed on delete'); +check( + projects.listProjects().find(/** @param {{ slug: string }} p */ (p) => p.slug === 'has-path') === undefined, + 'deleted project no longer in listing', +); + +const dMiss = projects.deleteProject('no-such-slug'); +check(dMiss.ok === false, 'deleteProject fails for non-existent slug'); +check(dMiss.error === 'Project not found', 'delete error is "Project not found"'); + +// WHEN we delete a project whose directory was already removed +const rAlreadyGone = projects.createProject({ name: 'Already Gone' }); +const slugAlreadyGone = /** @type {{ slug: string }} */ (rAlreadyGone.project).slug; +fs.rmSync(path.join(projects.PROJECTS_DIR, slugAlreadyGone), { recursive: true, force: true }); +const dAlreadyGone = projects.deleteProject(slugAlreadyGone); +check(dAlreadyGone.ok === true, 'deleteProject succeeds even when directory already removed'); + +// WHEN we delete a project whose directory cannot be removed (locked/permission) +const rLocked = projects.createProject({ name: 'Locked Project' }); +const slugLocked = /** @type {{ slug: string }} */ (rLocked.project).slug; +const lockedDir = path.join(projects.PROJECTS_DIR, slugLocked); +if (process.platform !== 'win32') { + fs.chmodSync(lockedDir, 0o444); +} +const dLocked = projects.deleteProject(slugLocked); +if (process.platform !== 'win32') { + check(dLocked.ok === false, 'deleteProject fails when directory removal fails'); + check(typeof dLocked.error === 'string', 'deleteProject returns error when directory removal fails'); + // Registry entry should NOT be removed on failure + check(projects.getProject(slugLocked) !== null, 'project remains in registry when directory removal fails'); + try { + fs.chmodSync(lockedDir, 0o755); + fs.rmSync(lockedDir, { recursive: true, force: true }); + } catch { + // best effort cleanup + } +} else { + check(dLocked.ok === true, 'deleteProject succeeds on Windows where chmod is not restrictive'); +} + +// WHEN we delete a valid project, directory is removed before registry update +const rOrder = projects.createProject({ name: 'Order Test' }); +const slugOrder = /** @type {{ slug: string }} */ (rOrder.project).slug; +const orderDir = path.join(projects.PROJECTS_DIR, slugOrder); +check(fs.existsSync(orderDir), 'project directory exists before delete'); +const dOrder = projects.deleteProject(slugOrder); +check(dOrder.ok === true, 'deleteProject succeeds for order test'); +check(!fs.existsSync(orderDir), 'project directory removed on delete'); +check(!dOrder.error, 'no error when directory removal succeeds'); + +// =================================================================== +// SECTION 2: HTTP-level integration tests +// =================================================================== + +// Re-clear require cache for config so the server picks up CE_ROOT +delete require.cache[require.resolve('../server/lib/config')]; +delete require.cache[require.resolve('../server/lib/backup')]; +delete require.cache[require.resolve('../server/lib/projects')]; + +const { startServer } = require('../server/server'); + +const serverPort = 3857; +process.env.CE_PORT = String(serverPort); + +/** + * @param {string} method + * @param {string} urlPath + * @param {Record} [body] + * @returns {Promise<{ status: number | undefined, data: unknown }>} + */ +function api(method, urlPath, body) { + return new Promise((resolve, reject) => { + const opts = { + hostname: 'localhost', + port: serverPort, + path: urlPath, + method, + headers: { 'Content-Type': 'application/json' }, + }; + const req = http.request(opts, (res) => { + let d = ''; + res.on('data', (c) => (d += c)); + res.on('end', () => { + try { + resolve({ status: res.statusCode, data: JSON.parse(d) }); + } catch { + resolve({ status: res.statusCode, data: d }); + } + }); + }); + req.on('error', reject); + if (body) req.write(JSON.stringify(body)); + req.end(); + }); +} + +async function runHttpTests() { + const server = startServer({ port: serverPort, refresh: false }); + try { + await new Promise((resolve) => server.once('listening', resolve)); + + // GET /api/projects + const getList = await api('GET', '/api/projects'); + check(getList.status === 200, 'HTTP GET /api/projects returns 200'); + check( + Array.isArray(/** @type {any} */ (getList.data).projects), + 'HTTP GET /api/projects returns projects array', + ); + + // POST /api/projects with valid data + const postOk = await api('POST', '/api/projects', { name: 'HTTP Project' }); + check(postOk.status === 200, 'HTTP POST /api/projects with valid data returns 200'); + check(/** @type {any} */ (postOk.data).ok === true, 'HTTP POST /api/projects returns ok'); + + const httpSlug = /** @type {any} */ (postOk.data).project?.slug; + check(typeof httpSlug === 'string', 'HTTP POST /api/projects returns project with slug'); + + // POST /api/projects with empty name + const postFail = await api('POST', '/api/projects', { name: '' }); + check(postFail.status === 400, 'HTTP POST /api/projects with empty name returns 400'); + + // PATCH /api/projects/:slug + const patchOk = await api('PATCH', `/api/projects/${encodeURIComponent(httpSlug)}`, { + name: 'HTTP Updated', + }); + check(patchOk.status === 200, 'HTTP PATCH /api/projects/:slug returns 200'); + check( + /** @type {any} */ (patchOk.data).project?.name === 'HTTP Updated', + 'HTTP PATCH updates project name', + ); + + // PATCH /api/projects/:slug with non-existent slug + const patchMiss = await api('PATCH', '/api/projects/no-such', { name: 'X' }); + check(patchMiss.status === 404, 'HTTP PATCH non-existent project returns 404'); + + // DELETE /api/projects/:slug + const deleteOk = await api('DELETE', `/api/projects/${encodeURIComponent(httpSlug)}`); + check(deleteOk.status === 200, 'HTTP DELETE /api/projects/:slug returns 200'); + check(/** @type {any} */ (deleteOk.data).ok === true, 'HTTP DELETE returns ok'); + + // DELETE /api/projects/:slug with non-existent slug + const deleteMiss = await api('DELETE', '/api/projects/no-such'); + check(deleteMiss.status === 404, 'HTTP DELETE non-existent project returns 404'); + + // PATCH /api/projects/%E0%A4%A (malformed percent encoding) + const patchBad = await api('PATCH', '/api/projects/%E0%A4%A', { name: 'X' }); + check(patchBad.status === 400, 'HTTP PATCH with malformed slug encoding returns 400'); + + // DELETE /api/projects/%E0%A4%A (malformed percent encoding) + const deleteBad = await api('DELETE', '/api/projects/%E0%A4%A'); + check(deleteBad.status === 400, 'HTTP DELETE with malformed slug encoding returns 400'); + } finally { + server.close(); + } +} + +// =================================================================== +// Run +// =================================================================== + +void (async () => { + try { + await runHttpTests(); + } catch (e) { + console.error('HTTP tests failed:', e); + } + + // ---- cleanup ---- + try { + fs.rmSync(testRoot, { recursive: true, force: true }); + } catch { + // ignore + } + + console.log(`\n${pass} passed, ${fail} failed`); + process.exit(fail ? 1 : 0); +})(); diff --git a/scripts/ranking-smoke.js b/scripts/ranking-smoke.js new file mode 100644 index 0000000..de22cb6 --- /dev/null +++ b/scripts/ranking-smoke.js @@ -0,0 +1,105 @@ +// @ts-check + +// ranking-smoke.js — Smoke test for dedup ranking/scoring + +const assert = require('assert'); +const { chooseKeeper, scoreChunk, scoreSkills, tokenize } = require('../server/lib/ranking'); + +// ---- tokenize ---- + +// GIVEN a simple sentence +// WHEN tokenized +const tokens = tokenize('Use TypeScript for safer refactors'); +assert.ok( + tokens.some((t) => t === 'typescript'), + 'lowercases tokens', +); +assert.ok( + tokens.some((t) => t === 'refactors'), + 'extracts individual words', +); + +// GIVEN text with backtick code blocks +const codeTokens = tokenize('Set `foo.bar = 1` in the config'); +assert.ok(!codeTokens.some((t) => t === 'foo.bar'), 'code inside backticks is removed'); +assert.ok( + codeTokens.some((t) => t === 'config'), + 'non-code text is preserved', +); + +// GIVEN empty text +const emptyTokens = tokenize(''); +assert.deepStrictEqual(emptyTokens, [], 'empty text produces empty tokens'); + +// GIVEN text with only short words +const shortTokens = tokenize('a b c d'); +assert.deepStrictEqual(shortTokens, [], 'words under 3 chars are filtered'); + +// ---- scoreChunk ---- + +/** @type {import('../server/lib/vectorstore').VectorRecord} */ +const ruleChunk = { + id: 'test:1', + skillId: 'test-skill', + section: 'Rules', + text: 'Always use TypeScript strict mode for safer refactoring workflows', + type: 'rule', + sourcePath: 'test-skill/SKILL.md', + vector: [0.1], +}; +const ruleScore = scoreChunk(ruleChunk); +assert.strictEqual(typeof ruleScore.total, 'number', 'scoreChunk returns total'); +assert.ok(ruleScore.total >= 0 && ruleScore.total <= 1, 'total is between 0 and 1'); +assert.strictEqual(typeof ruleScore.specificity, 'number', 'scoreChunk returns specificity'); +assert.strictEqual(typeof ruleScore.coverage, 'number', 'scoreChunk returns coverage'); +assert.strictEqual(typeof ruleScore.sourceWeight, 'number', 'scoreChunk returns sourceWeight'); +assert.strictEqual(typeof ruleScore.freshness, 'number', 'scoreChunk returns freshness'); + +/** @type {import('../server/lib/vectorstore').VectorRecord} */ +const exampleChunk = { ...ruleChunk, id: 'test:2', type: 'example', section: 'Example', vector: [0.1] }; +const exampleScore = scoreChunk(exampleChunk); +assert.ok(exampleScore.total < ruleScore.total, 'example score is lower than rule score'); + +/** @type {import('../server/lib/vectorstore').VectorRecord} */ +const knowledgeChunk = { ...ruleChunk, id: 'test:3', type: 'knowledge', section: 'Overview', vector: [0.1] }; +const knowledgeScore = scoreChunk(knowledgeChunk); +assert.ok(knowledgeScore.total < ruleScore.total, 'knowledge score is lower than rule score'); + +// ---- scoreSkills ---- + +/** @type {import('../server/lib/vectorstore').VectorRecord[]} */ +const records = [ + { ...ruleChunk, id: 'a:1', skillId: 'skill-a', vector: [0.1] }, + { + ...ruleChunk, + id: 'a:2', + skillId: 'skill-a', + text: 'Different workflow implementation process', + vector: [0.1], + }, + { ...ruleChunk, id: 'b:1', skillId: 'skill-b', type: 'knowledge', vector: [0.1] }, +]; +const skillScores = scoreSkills(records); +assert.strictEqual(typeof skillScores['skill-a'], 'number', 'skill-a has a score'); +assert.strictEqual(typeof skillScores['skill-b'], 'number', 'skill-b has a score'); +const aScore = /** @type {number} */ (skillScores['skill-a']); +const bScore = /** @type {number} */ (skillScores['skill-b']); +assert.ok(aScore > bScore, 'skill with rule records scores higher'); + +// GIVEN empty records +const emptyScores = scoreSkills(/** @type {import('../server/lib/vectorstore').VectorRecord[]} */ ([])); +assert.deepStrictEqual(emptyScores, {}, 'empty records produce empty scores'); + +// ---- chooseKeeper ---- + +const keeper = chooseKeeper(records); +assert.strictEqual(keeper, 'skill-a', 'chooseKeeper picks highest-scoring skill'); + +// GIVEN empty records +assert.strictEqual( + chooseKeeper(/** @type {import('../server/lib/vectorstore').VectorRecord[]} */ ([])), + null, + 'chooseKeeper returns null for empty records', +); + +console.log('ranking smoke ok'); diff --git a/scripts/security-smoke.js b/scripts/security-smoke.js new file mode 100644 index 0000000..44626bd --- /dev/null +++ b/scripts/security-smoke.js @@ -0,0 +1,135 @@ +// @ts-check + +// security-smoke.js — Smoke test for request validation and write-path protection + +const assert = require('assert'); +const path = require('path'); + +const { isLocalRequest, checkSafeWritePath } = require('../server/lib/security'); + +/** @type {(headers: Record) => import('http').IncomingMessage} */ +function mockReq(headers) { + return /** @type {import('http').IncomingMessage} */ ({ headers }); +} + +// ---- isLocalRequest ---- + +// GIVEN a request with loopback Host header +// WHEN isLocalRequest checks it +assert.strictEqual( + isLocalRequest(mockReq({ host: '127.0.0.1:3847' }), 3847), + true, + '127.0.0.1 host is local', +); +assert.strictEqual( + isLocalRequest(mockReq({ host: 'localhost:3847' }), 3847), + true, + 'localhost host is local', +); +assert.strictEqual( + isLocalRequest(mockReq({ host: '[::1]:3847' }), 3847), + true, + 'IPv6 loopback host is local', +); + +// GIVEN a request with external Host header +assert.strictEqual( + isLocalRequest(mockReq({ host: 'evil.example.com' }), 3847), + false, + 'external hostname is not local', +); +assert.strictEqual(isLocalRequest(mockReq({ host: '192.168.1.1:3847' }), 3847), false, 'LAN IP is not local'); + +// GIVEN a request with no Host header +assert.strictEqual(isLocalRequest(mockReq({}), 3847), false, 'missing host header is not local'); + +// GIVEN a request with loopback Origin header and loopback Host +assert.strictEqual( + isLocalRequest(mockReq({ host: '127.0.0.1', origin: 'http://localhost:3847' }), 3847), + true, + 'loopback origin + loopback host is local', +); + +// GIVEN a request with external Origin header and loopback Host (CSRF) +assert.strictEqual( + isLocalRequest(mockReq({ host: '127.0.0.1', origin: 'https://evil.example.com' }), 3847), + false, + 'external origin with loopback host is CSRF', +); + +// GIVEN a request with malformed Origin header +assert.strictEqual( + isLocalRequest(mockReq({ host: '127.0.0.1', origin: 'not-a-url' }), 3847), + false, + 'malformed origin is rejected', +); + +// ---- checkSafeWritePath ---- + +// GIVEN a normal project directory +assert.strictEqual( + checkSafeWritePath(path.join(process.cwd(), 'my-project')), + null, + 'normal project path is safe', +); + +// GIVEN no path +assert.strictEqual(checkSafeWritePath(''), 'path is required', 'empty path is rejected'); +assert.strictEqual( + checkSafeWritePath(/** @type {string} */ (/** @type {unknown} */ (null))), + 'path is required', + 'null path is rejected', +); + +// GIVEN Windows system directories (only meaningful on win32 where path.resolve +// produces an absolute Windows path that matches DEFAULT_DENY_ABSOLUTE entries) +if (process.platform === 'win32') { + const sysResult = checkSafeWritePath('C:\\Windows\\System32\\drivers'); + assert.ok(sysResult !== null, 'Windows system dir is blocked'); + assert.ok( + sysResult && sysResult.includes('Refusing to write into protected location'), + 'error mentions protected location', + ); + + const pfResult = checkSafeWritePath('C:\\Program Files\\MyApp'); + assert.ok(pfResult !== null, 'Program Files is blocked'); +} + +// GIVEN SSH directory +const homeSsh = path.join(require('os').homedir(), '.ssh'); +assert.strictEqual( + checkSafeWritePath(homeSsh), + 'Refusing to write into protected location: ' + homeSsh, + 'user .ssh dir is blocked', +); + +// GIVEN a path with a protected fragment +assert.strictEqual( + checkSafeWritePath(path.join(process.cwd(), 'my-project', '.ssh', 'keys')), + 'Refusing to write into protected directory segment: .ssh', + '.ssh fragment in path is blocked', +); +assert.strictEqual( + checkSafeWritePath(path.join(process.cwd(), 'my-project', '.aws', 'config')), + 'Refusing to write into protected directory segment: .aws', + '.aws fragment in path is blocked', +); + +// GIVEN a UNC path (Windows-specific — Linux collapses // to /) +if (process.platform === 'win32') { + assert.strictEqual( + checkSafeWritePath('\\\\server\\share\\path'), + 'Refusing to use UNC / network-share paths', + 'UNC path is blocked', + ); +} + +// GIVEN a path that starts with protected but is different (e.g. ".sshconfig") +const safeButSimilar = path.join(process.cwd(), 'sshconfig-project'); +assert.strictEqual( + checkSafeWritePath(safeButSimilar), + null, + 'path containing ssh as substring but not segment is safe', +); + +console.log('security smoke ok'); diff --git a/scripts/skill-sources-smoke.js b/scripts/skill-sources-smoke.js index b27cbc5..4de7dfc 100644 --- a/scripts/skill-sources-smoke.js +++ b/scripts/skill-sources-smoke.js @@ -54,8 +54,14 @@ void (async () => { // ---- listSources ---- const listed = sourcesMod.listSources(); - assert(listed.some((s) => s.id === 'internal'), 'listSources should always include the internal source'); - assert(listed.some((s) => s.id === sourceId), 'listSources should include the linked source'); + assert( + listed.some((s) => s.id === 'internal'), + 'listSources should always include the internal source', + ); + assert( + listed.some((s) => s.id === sourceId), + 'listSources should include the linked source', + ); // ---- importSource ---- const imported = await importMod.importSource(sourceId); @@ -78,16 +84,15 @@ void (async () => { // Add a new file to source. fs.mkdirSync(path.join(fixture, 'gamma')); - fs.writeFileSync( - path.join(fixture, 'gamma', 'SKILL.md'), - '---\nname: gamma\n---\n# Gamma\n', - 'utf8', - ); + fs.writeFileSync(path.join(fixture, 'gamma', 'SKILL.md'), '---\nname: gamma\n---\n# Gamma\n', 'utf8'); const addedDiff = importMod.computeSyncDiff(sourceId); assert(addedDiff.ok, 'addedDiff should succeed'); assert.strictEqual(addedDiff.diff.added.length, 1, 'diff should detect the new gamma file'); const firstAdded = addedDiff.diff.added[0]; - assert(firstAdded && firstAdded.rel.includes('gamma/SKILL.md'), 'diff added entry should reference gamma path'); + assert( + firstAdded && firstAdded.rel.includes('gamma/SKILL.md'), + 'diff added entry should reference gamma path', + ); // ---- applySyncDiff: append picks up added only ---- const appended = await importMod.applySyncDiff(sourceId, 'append'); diff --git a/scripts/validation-smoke.js b/scripts/validation-smoke.js new file mode 100644 index 0000000..57318c6 --- /dev/null +++ b/scripts/validation-smoke.js @@ -0,0 +1,102 @@ +// @ts-check + +// validation-smoke.js — Smoke test for request body validators + +const assert = require('assert'); +const { validateMemory, validateRules, validateStates } = require('../server/lib/validation'); + +// ---- validateMemory ---- + +// GIVEN valid memory data +// WHEN validated +const memOk = validateMemory({ entries: [{ content: 'User prefers dark mode' }] }); +assert.strictEqual(memOk.valid, true, 'valid memory passes'); +assert.strictEqual(memOk.error, null, 'valid memory has no error'); + +// GIVEN missing entries array +const memNoEntries = validateMemory({}); +assert.strictEqual(memNoEntries.valid, false, 'missing entries fails'); +assert.strictEqual(memNoEntries.error, 'Missing or invalid "entries" array'); + +// GIVEN entries not an array +const memBadEntries = validateMemory({ entries: 'not-array' }); +assert.strictEqual(memBadEntries.valid, false, 'entries as string fails'); + +// GIVEN entry without content +const memNoContent = validateMemory({ entries: [{ content: '' }] }); +assert.strictEqual(memNoContent.valid, false, 'empty content fails'); +assert.ok( + memNoContent.error && memNoContent.error.includes('missing "content" string'), + 'error mentions content', +); + +// GIVEN entry with whitespace-only content +const memWhitespace = validateMemory({ entries: [{ content: ' ' }] }); +assert.strictEqual(memWhitespace.valid, false, 'whitespace-only content fails'); + +// GIVEN entry that is not an object +const memBadEntry = validateMemory({ entries: ['string-entry'] }); +assert.strictEqual(memBadEntry.valid, false, 'string entry fails'); +assert.ok(memBadEntry.error && memBadEntry.error.includes('must be an object'), 'error mentions object'); + +// GIVEN null input +const memNull = validateMemory(null); +assert.strictEqual(memNull.valid, false, 'null input fails'); +assert.strictEqual(memNull.error, 'Must be a JSON object'); + +// GIVEN parse error marker +const memParseError = validateMemory({ _parseError: true }); +assert.strictEqual(memParseError.valid, false, 'parse error marker fails'); + +// GIVEN valid entry at index boundary +const memIdx = validateMemory({ entries: [null, { content: 'ok' }] }); +assert.strictEqual(memIdx.valid, false, 'null entry at index 0 fails'); +assert.ok(memIdx.error && memIdx.error.includes('Entry 0'), 'error references correct index'); + +// ---- validateRules ---- + +// GIVEN valid rules data +const rulesOk = validateRules({ coding: 'Use strict mode', general: 'Be helpful', soul: 'Curious' }); +assert.strictEqual(rulesOk.valid, true, 'valid rules passes'); + +// GIVEN missing one of the required keys +const rulesMissing = validateRules({ coding: 'x', general: 'y' }); +assert.strictEqual(rulesMissing.valid, false, 'missing soul key fails'); +assert.ok(rulesMissing.error && rulesMissing.error.includes('soul'), 'error mentions missing key'); + +// GIVEN key with wrong type +const rulesWrongType = validateRules({ coding: 42, general: '', soul: '' }); +assert.strictEqual(rulesWrongType.valid, false, 'numeric coding value fails'); + +// GIVEN null input +assert.strictEqual(validateRules(null).valid, false, 'null rules fails'); + +// GIVEN parse error marker +assert.strictEqual(validateRules({ _parseError: true }).valid, false, 'parse error in rules fails'); + +// ---- validateStates ---- + +// GIVEN valid states +const statesOk = validateStates({ states: { 'skill-a': true, 'skill-b': false } }); +assert.strictEqual(statesOk.valid, true, 'valid states passes'); + +// GIVEN states without wrapper key (should accept bare object) +const statesBare = validateStates({ 'skill-a': true }); +assert.strictEqual(statesBare.valid, true, 'bare states object passes'); + +// GIVEN states value that is not boolean +const statesBadType = validateStates({ states: { 'skill-a': 'yes' } }); +assert.strictEqual(statesBadType.valid, false, 'non-boolean state fails'); +assert.ok(statesBadType.error && statesBadType.error.includes('must be boolean'), 'error mentions boolean'); + +// GIVEN states as array +const statesArray = validateStates({ states: ['a', 'b'] }); +assert.strictEqual(statesArray.valid, false, 'array states fails'); + +// GIVEN null input +assert.strictEqual(validateStates(null).valid, false, 'null states fails'); + +// GIVEN parse error marker +assert.strictEqual(validateStates({ _parseError: true }).valid, false, 'parse error in states fails'); + +console.log('validation smoke ok'); diff --git a/scripts/vectorstore-smoke.js b/scripts/vectorstore-smoke.js index 9eee769..dae6731 100644 --- a/scripts/vectorstore-smoke.js +++ b/scripts/vectorstore-smoke.js @@ -8,8 +8,12 @@ const { loadVectorStore, saveVectorStore, upsertVectors, + replaceVectors, searchVectors, cosineSimilarity, + markIndexStale, + clearIndexStale, + getIndexStale, } = require('../server/lib/vectorstore'); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ce-vectorstore-')); @@ -48,7 +52,90 @@ try { assert.strictEqual(reloaded.records.length, 2); assert.strictEqual(results[0]?.skillId, 'skill-a'); assert.strictEqual(cosineSimilarity([1, 0], [1, 0]), 1); + + // ---- replaceVectors ---- + + // GIVEN a store with existing records + // WHEN we replace with a new set + const replaced = replaceVectors( + [ + { + id: 'skill-c:overview:1', + skillId: 'skill-c', + section: 'Overview', + text: 'Brand new skill.', + type: 'knowledge', + sourcePath: 'skill-c/SKILL.md', + vector: [0, 0, 1], + }, + ], + 'fixture-model', + ); + assert.strictEqual(replaced.records.length, 1, 'replaceVectors discards previous records'); + assert.strictEqual(replaced.records[0]?.skillId, 'skill-c', 'replaceVectors keeps new records'); + + // ---- upsertVectors update semantics ---- + + // WHEN we upsert a record with the same ID + const updatedStore = upsertVectors( + store, + [ + { + id: 'skill-a:overview:1', + skillId: 'skill-a', + section: 'Overview', + text: 'Updated text for skill-a.', + type: 'rule', + sourcePath: 'skill-a/SKILL.md', + vector: [1, 0, 0], + }, + ], + 'fixture-model', + ); + assert.strictEqual(updatedStore.records.length, 2, 'upsert keeps same record count'); + const updatedRecord = updatedStore.records.find((r) => r.id === 'skill-a:overview:1'); + assert.strictEqual(updatedRecord?.text, 'Updated text for skill-a.', 'upsert updates existing record'); + + // ---- searchVectors with skillId filter ---- + + const filteredResults = searchVectors(store, [1, 0, 0], { limit: 10, skillId: 'skill-b' }); + assert.ok( + filteredResults.every((r) => r.skillId === 'skill-b'), + 'skillId filter works', + ); + + // ---- searchVectors on empty store ---- + + const emptyResults = searchVectors(loadVectorStore(path.join(tmpDir, 'nonexistent.json')), [1, 0]); + assert.deepStrictEqual(emptyResults, [], 'search on empty store returns empty'); + + // ---- cosineSimilarity edge cases ---- + + assert.strictEqual(cosineSimilarity([0, 0], [1, 0]), 0, 'zero vector returns 0'); + assert.strictEqual(cosineSimilarity([1, 0], [0, 1]), 0, 'orthogonal vectors return 0'); + assert.strictEqual(cosineSimilarity([], []), 0, 'empty vectors return 0'); + assert.strictEqual(cosineSimilarity([1, 0], [-1, 0]), -1, 'opposite vectors return -1'); + assert.ok(Math.abs(cosineSimilarity([1, 1], [1, 1]) - 1) < 0.001, 'parallel vectors return ~1'); + + // ---- loadVectorStore with corrupt file ---- + + const corruptPath = path.join(tmpDir, 'corrupt.json'); + fs.writeFileSync(corruptPath, 'NOT JSON', 'utf8'); + const corruptStore = loadVectorStore(corruptPath); + assert.strictEqual(corruptStore.records.length, 0, 'corrupt file returns empty store'); + + // ---- stale index lifecycle ---- + + markIndexStale('skills changed'); + const staleState = getIndexStale(); + assert.strictEqual(staleState.stale, true, 'after markIndexStale, index is stale'); + assert.strictEqual(staleState.reason, 'skills changed', 'stale reason is preserved'); + + clearIndexStale(); + const clearedState = getIndexStale(); + assert.strictEqual(clearedState.stale, false, 'after clearIndexStale, index is not stale'); + console.log('vectorstore smoke ok'); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); -} \ No newline at end of file +} diff --git a/server/compiler.js b/server/compiler.js index f6a091e..8e45569 100644 --- a/server/compiler.js +++ b/server/compiler.js @@ -504,4 +504,4 @@ module.exports = { compileToGlobal, ADAPTERS, TOOL_REGISTRY, -}; \ No newline at end of file +}; diff --git a/server/lib/api-docs.js b/server/lib/api-docs.js index 5ce1e4b..4944bcf 100644 --- a/server/lib/api-docs.js +++ b/server/lib/api-docs.js @@ -11,19 +11,51 @@ function apiDocs(extra = []) { path: '/api/skills/:id', description: 'Get one skill (record + body + section index). Optional ?section= for a slice.', }, - { method: 'POST', path: '/api/skills/ingest', description: 'Clone a skill repo into skills/ingested (allowlisted hosts only)' }, + { + method: 'POST', + path: '/api/skills/ingest', + description: 'Clone a skill repo into skills/ingested (allowlisted hosts only)', + }, { method: 'GET', path: '/api/skills/ingest/:jobId', description: 'Poll an in-flight ingest job' }, - { method: 'POST', path: '/api/skills/parse', description: 'LLM-parse skill descriptions for unparsed entries' }, - { method: 'POST', path: '/api/skills/organise', description: 'Tidy skill library (move/remove/review)' }, + { + method: 'POST', + path: '/api/skills/parse', + description: 'LLM-parse skill descriptions for unparsed entries', + }, + { + method: 'POST', + path: '/api/skills/organise', + description: 'Tidy skill library (move/remove/review)', + }, { method: 'POST', path: '/api/skills/review-similar', description: 'LLM review of similar skills' }, // Skill sources (Phase 1 + 2 — Link + Import + Sync) - { method: 'GET', path: '/api/skill-sources', description: 'List registered skill sources + implicit internal' }, + { + method: 'GET', + path: '/api/skill-sources', + description: 'List registered skill sources + implicit internal', + }, { method: 'POST', path: '/api/skill-sources', description: 'Link an external skill directory' }, - { method: 'DELETE', path: '/api/skill-sources/:id', description: 'Unlink a source (manifest dropped; imported tree kept)' }, + { + method: 'DELETE', + path: '/api/skill-sources/:id', + description: 'Unlink a source (manifest dropped; imported tree kept)', + }, { method: 'GET', path: '/api/skill-sources/scan', description: 'Probe known host-app skill paths' }, - { method: 'POST', path: '/api/skill-sources/:id/import', description: 'Hard-link or copy a source into /skills/imported//' }, - { method: 'GET', path: '/api/skill-sources/:id/sync', description: 'Diff source vs imported tree (added/removed/modified/localEdits/conflicts)' }, - { method: 'POST', path: '/api/skill-sources/:id/sync/apply', description: 'Apply sync diff. Body { mode: "append" | "overwrite" }' }, + { + method: 'POST', + path: '/api/skill-sources/:id/import', + description: 'Hard-link or copy a source into /skills/imported//', + }, + { + method: 'GET', + path: '/api/skill-sources/:id/sync', + description: 'Diff source vs imported tree (added/removed/modified/localEdits/conflicts)', + }, + { + method: 'POST', + path: '/api/skill-sources/:id/sync/apply', + description: 'Apply sync diff. Body { mode: "append" | "overwrite" }', + }, { method: 'GET', path: '/api/memory', description: 'Get memory entries' }, { method: 'POST', path: '/api/memory', description: 'Update memory (validated)' }, { method: 'GET', path: '/api/rules', description: 'Get rules configuration' }, @@ -43,9 +75,21 @@ function apiDocs(extra = []) { { method: 'GET', path: '/api/modes', description: 'List mode presets' }, { method: 'POST', path: '/api/modes/apply', description: 'Apply mode preset (transactional)' }, ...extra, - { method: 'GET', path: '/api/onboarding', description: 'First-run discovery summary (hosts + tools + context + index)' }, - { method: 'POST', path: '/api/onboarding/complete', description: 'Mark onboarding complete (suppresses re-prompt)' }, - { method: 'POST', path: '/api/onboarding/reset', description: 'Re-arm the onboarding flow for the next launch' }, + { + method: 'GET', + path: '/api/onboarding', + description: 'First-run discovery summary (hosts + tools + context + index)', + }, + { + method: 'POST', + path: '/api/onboarding/complete', + description: 'Mark onboarding complete (suppresses re-prompt)', + }, + { + method: 'POST', + path: '/api/onboarding/reset', + description: 'Re-arm the onboarding flow for the next launch', + }, { method: 'GET', path: '/api/mcp/hosts', description: 'List MCP host config status and snippets' }, { method: 'POST', diff --git a/server/lib/app-version.js b/server/lib/app-version.js index a0061c4..2b33c32 100644 --- a/server/lib/app-version.js +++ b/server/lib/app-version.js @@ -28,4 +28,4 @@ function getAppVersion() { return { version: String(Math.floor(latest)), checkedAt: Date.now() }; } -module.exports = { getAppVersion }; \ No newline at end of file +module.exports = { getAppVersion }; diff --git a/server/lib/backup.js b/server/lib/backup.js index 69c68b7..2971fb3 100644 --- a/server/lib/backup.js +++ b/server/lib/backup.js @@ -82,4 +82,4 @@ module.exports = { restoreBackup, getSessionLog, appendSession, -}; \ No newline at end of file +}; diff --git a/server/lib/compiler-generic-configs.js b/server/lib/compiler-generic-configs.js index 2b0c6fd..17f4ca1 100644 --- a/server/lib/compiler-generic-configs.js +++ b/server/lib/compiler-generic-configs.js @@ -160,4 +160,4 @@ const GENERIC_FILENAMES = { module.exports = { GENERIC_CONFIGS, GENERIC_FILENAMES, -}; \ No newline at end of file +}; diff --git a/server/lib/config.js b/server/lib/config.js index 6114dda..f052837 100644 --- a/server/lib/config.js +++ b/server/lib/config.js @@ -26,6 +26,8 @@ const MODES_FILE = path.join(DATA_DIR, 'modes.json'); const KEYS_FILE = path.join(DATA_DIR, '.keys.enc'); const SKILL_CACHE_FILE = path.join(DATA_DIR, 'skill-parse-cache.json'); const DEDUP_FILE = path.join(DATA_DIR, 'dedup.json'); +const PROJECTS_FILE = path.join(DATA_DIR, 'projects.json'); +const PROJECTS_DIR = path.join(DATA_DIR, 'projects'); const MIME = { '.html': 'text/html', @@ -50,5 +52,7 @@ module.exports = { KEYS_FILE, SKILL_CACHE_FILE, DEDUP_FILE, + PROJECTS_FILE, + PROJECTS_DIR, MIME, }; diff --git a/server/lib/crypto.js b/server/lib/crypto.js index 32fd9a5..0eaedeb 100644 --- a/server/lib/crypto.js +++ b/server/lib/crypto.js @@ -90,4 +90,4 @@ function removeApiKey(name) { saveKeys(keys); } -module.exports = { getApiKey, setApiKey, removeApiKey }; \ No newline at end of file +module.exports = { getApiKey, setApiKey, removeApiKey }; diff --git a/server/lib/handoff-migration.js b/server/lib/handoff-migration.js index 66d2a60..79cbab4 100644 --- a/server/lib/handoff-migration.js +++ b/server/lib/handoff-migration.js @@ -122,7 +122,6 @@ async function migrateLegacyHandoff(input) { const result = createHandoff({ title: entry.title, repo, - thread_tag: entry.slug, body: entry.body, }); if (!result.ok) { diff --git a/server/lib/handoffs.js b/server/lib/handoffs.js index ab81bfc..98c474f 100644 --- a/server/lib/handoffs.js +++ b/server/lib/handoffs.js @@ -329,9 +329,6 @@ function createHandoff(input) { const repo = input?.repo ? String(input.repo).trim() : ''; const tag = input?.thread_tag ? String(input.thread_tag).trim() : ''; - if (!repo && !tag) { - return { ok: false, error: 'At least one of repo or thread_tag is required' }; - } /** @type {'project' | 'thread' | 'dual'} */ const type = repo && tag ? 'dual' : repo ? 'project' : 'thread'; diff --git a/server/lib/modes.js b/server/lib/modes.js index 4c77e5b..675438b 100644 --- a/server/lib/modes.js +++ b/server/lib/modes.js @@ -61,15 +61,19 @@ function applyMode(modeId) { /** @type {Record} */ const stateMap = { ...(states.states || {}) }; + // Ensure every discovered skill has an entry in the state map. + // New skills default to active so they remain visible and available. Object.keys(SKILL_MAP).forEach((/** @type {string} */ id) => { - stateMap[id] = false; + if (!(id in stateMap)) stateMap[id] = true; }); if (mode.id === 'all') { Object.keys(SKILL_MAP).forEach((/** @type {string} */ id) => { stateMap[id] = true; }); - } else { + } else if (mode.skills.length > 0) { + // Only activate skills the mode explicitly lists; leave all others at + // their current state so skills never disappear when a mode is applied. mode.skills.forEach((/** @type {string} */ id) => { if (SKILL_MAP[id]) stateMap[id] = true; }); @@ -129,4 +133,4 @@ function estimateContextBudget() { } } -module.exports = { DEFAULT_MODES, getModes, regenerateCONTEXTmd, applyMode, estimateContextBudget }; \ No newline at end of file +module.exports = { DEFAULT_MODES, getModes, regenerateCONTEXTmd, applyMode, estimateContextBudget }; diff --git a/server/lib/onboarding.js b/server/lib/onboarding.js index d8efbac..b321bb2 100644 --- a/server/lib/onboarding.js +++ b/server/lib/onboarding.js @@ -143,4 +143,4 @@ module.exports = { getOnboardingSummary, completeOnboarding, resetOnboarding, -}; \ No newline at end of file +}; diff --git a/server/lib/projects.js b/server/lib/projects.js new file mode 100644 index 0000000..445aeec --- /dev/null +++ b/server/lib/projects.js @@ -0,0 +1,134 @@ +// @ts-nocheck — Path-A backlog: file in tsconfig include, opt out until incremental typing is done. See docs/llm-handoff.md. + +// projects.js — Project-scoped context directories + +const fs = require('fs'); +const path = require('path'); +const { DATA_DIR } = require('./config'); + +const PROJECTS_FILE = path.join(DATA_DIR, 'projects.json'); +const PROJECTS_DIR = path.join(DATA_DIR, 'projects'); + +function ensureDirs() { + if (!fs.existsSync(PROJECTS_DIR)) fs.mkdirSync(PROJECTS_DIR, { recursive: true }); +} + +function readRegistry() { + let data; + try { + data = JSON.parse(fs.readFileSync(PROJECTS_FILE, 'utf8')); + } catch { + return { version: '1.0', projects: [] }; + } + if (!data || typeof data !== 'object' || Array.isArray(data)) { + return { version: '1.0', projects: [] }; + } + if (!Array.isArray(data.projects)) data.projects = []; + return data; +} + +function writeRegistry(data) { + fs.writeFileSync(PROJECTS_FILE, JSON.stringify(data, null, 2), 'utf8'); +} + +function uniqueSlug(seed, taken) { + const base = + String(seed) + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 60) || 'project'; + if (!taken.has(base)) return base; + let n = 2; + while (taken.has(`${base}-${n}`)) n++; + return `${base}-${n}`; +} + +function listProjects() { + const reg = readRegistry(); + return Array.isArray(reg.projects) ? reg.projects : []; +} + +function getProject(slug) { + const projects = listProjects(); + return projects.find((p) => p.slug === slug) || null; +} + +function createProject(input) { + ensureDirs(); + const name = String(input?.name || '').trim(); + const repoPath = input?.path ? String(input.path).trim() : ''; + if (!name) return { ok: false, error: 'name is required' }; + + const reg = readRegistry(); + const taken = new Set(reg.projects.map((p) => p.slug)); + const slug = uniqueSlug(name, taken); + const now = new Date().toISOString(); + + const projectDir = path.join(PROJECTS_DIR, slug); + fs.mkdirSync(projectDir, { recursive: true }); + fs.mkdirSync(path.join(projectDir, 'handoffs'), { recursive: true }); + + const defaultMemory = { version: '1.1', entries: [] }; + const defaultRules = { coding: '', general: '', soul: '' }; + fs.writeFileSync(path.join(projectDir, 'memory.json'), JSON.stringify(defaultMemory, null, 2), 'utf8'); + fs.writeFileSync(path.join(projectDir, 'rules.json'), JSON.stringify(defaultRules, null, 2), 'utf8'); + + const project = { + slug, + name, + path: repoPath || undefined, + created: now, + last_touched: now, + }; + reg.projects.push(project); + writeRegistry(reg); + return { ok: true, project }; +} + +function deleteProject(slug) { + const reg = readRegistry(); + if (!Array.isArray(reg.projects)) return { ok: false, error: 'No projects' }; + const idx = reg.projects.findIndex((p) => p.slug === slug); + if (idx === -1) return { ok: false, error: 'Project not found' }; + + const projectDir = path.join(PROJECTS_DIR, slug); + if (fs.existsSync(projectDir)) { + try { + fs.rmSync(projectDir, { recursive: true, force: true }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return { ok: false, error: `Failed to remove project directory: ${msg}` }; + } + } + + reg.projects.splice(idx, 1); + writeRegistry(reg); + return { ok: true }; +} + +function updateProject(slug, patch) { + const reg = readRegistry(); + if (!Array.isArray(reg.projects)) return { ok: false, error: 'No projects' }; + const project = reg.projects.find((p) => p.slug === slug); + if (!project) return { ok: false, error: 'Project not found' }; + if (patch?.name !== undefined) { + const trimmed = String(patch.name).trim(); + if (!trimmed) return { ok: false, error: 'name cannot be empty' }; + project.name = trimmed; + } + if (patch?.path !== undefined) project.path = String(patch.path).trim() || undefined; + project.last_touched = new Date().toISOString(); + writeRegistry(reg); + return { ok: true, project }; +} + +module.exports = { + PROJECTS_DIR, + PROJECTS_FILE, + listProjects, + getProject, + createProject, + deleteProject, + updateProject, +}; diff --git a/server/lib/skill-sources.js b/server/lib/skill-sources.js index 465ad2e..16a5b37 100644 --- a/server/lib/skill-sources.js +++ b/server/lib/skill-sources.js @@ -83,11 +83,12 @@ function getSource(id) { * @param {Set} taken */ function uniqueId(seed, taken) { - const base = String(seed) - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '') - .slice(0, 40) || 'source'; + const base = + String(seed) + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 40) || 'source'; if (base === 'internal') return uniqueId(base + '-ext', taken); let candidate = base; let n = 2; @@ -161,7 +162,11 @@ async function addSource(input) { // Refuse paths inside CE's own skills tree — that's already the internal source. // Compare against the realpath form so a symlink into SKILLS_DIR is caught. const skillsDirReal = (() => { - try { return fs.realpathSync(SKILLS_DIR); } catch { return SKILLS_DIR; } + try { + return fs.realpathSync(SKILLS_DIR); + } catch { + return SKILLS_DIR; + } })(); if ( realResolved === skillsDirReal || @@ -169,7 +174,7 @@ async function addSource(input) { realResolved.toLowerCase() === skillsDirReal.toLowerCase() || realResolved.toLowerCase().startsWith(skillsDirReal.toLowerCase() + path.sep) ) { - return { ok: false, error: 'Path is already inside Context Engine\'s skills directory' }; + return { ok: false, error: "Path is already inside Context Engine's skills directory" }; } // Registry mutex: read-modify-write to skill-sources.json must be atomic diff --git a/server/lib/skills.js b/server/lib/skills.js index e319570..f1dd64c 100644 --- a/server/lib/skills.js +++ b/server/lib/skills.js @@ -605,4 +605,4 @@ module.exports = { getOllamaModels, loadParseCache, saveParseCache, -}; \ No newline at end of file +}; diff --git a/server/lib/validation.js b/server/lib/validation.js index 215cc7e..9eb9ffb 100644 --- a/server/lib/validation.js +++ b/server/lib/validation.js @@ -40,4 +40,4 @@ function validateStates(data) { return { valid: true, error: null }; } -module.exports = { validateMemory, validateRules, validateStates }; \ No newline at end of file +module.exports = { validateMemory, validateRules, validateStates }; diff --git a/server/lib/vectorstore.js b/server/lib/vectorstore.js index 5a27087..dbe0f1c 100644 --- a/server/lib/vectorstore.js +++ b/server/lib/vectorstore.js @@ -23,11 +23,15 @@ function markIndexStale(reason) { } fs.writeFileSync( INDEX_STALE_FILE, - JSON.stringify({ - stale: true, - reason: reason || 'Skill set changed', - since: new Date().toISOString(), - }, null, 2), + JSON.stringify( + { + stale: true, + reason: reason || 'Skill set changed', + since: new Date().toISOString(), + }, + null, + 2, + ), 'utf8', ); } catch { diff --git a/server/router.js b/server/router.js index d7b43d1..f64b926 100644 --- a/server/router.js +++ b/server/router.js @@ -59,6 +59,7 @@ const { } = require('./lib/skill-import'); const { markIndexStale } = require('./lib/vectorstore'); const { handleHandoffRequest, handoffRouteDocs } = require('./lib/handoff-routes'); +const { listProjects, getProject, createProject, deleteProject, updateProject } = require('./lib/projects'); const { apiDocs } = require('./lib/api-docs'); const ALLOWED_INGEST_HOSTS = new Set(['github.com', 'gitlab.com', 'codeberg.org', 'bitbucket.org']); @@ -433,11 +434,35 @@ async function handleRequest(req, res, url) { } // ---- STATES ---- - if (p === '/api/states' && req.method === 'GET') return json(res, readData('skill-states.json')); + if (p === '/api/states' && req.method === 'GET') { + const states = readData('skill-states.json') || {}; + const stateMap = states.states || states; + // Ensure every discovered skill appears in the response so the UI + // never loses sight of a skill just because it's missing from the file. + const SKILL_MAP = scanSkills(); + const merged = typeof stateMap === 'object' && !Array.isArray(stateMap) ? { ...stateMap } : {}; + for (const id of Object.keys(SKILL_MAP)) { + if (!(id in merged)) merged[id] = true; + } + return json(res, { + version: states.version || '1.0', + last_updated: states.last_updated || '', + states: merged, + }); + } if (p === '/api/states' && req.method === 'POST') { const data = await body(req); const v = validateStates(data); if (!v.valid) return json(res, { ok: false, error: v.error }, 400); + // Merge in any discovered skills missing from the posted states so they + // default to active instead of vanishing from the UI. + const SKILL_MAP = scanSkills(); + const incomingStates = data.states || data; + if (typeof incomingStates === 'object' && !Array.isArray(incomingStates)) { + for (const id of Object.keys(SKILL_MAP)) { + if (!(id in incomingStates)) incomingStates[id] = true; + } + } const backup = readData('skill-states.json'); try { writeData('skill-states.json', data); @@ -714,6 +739,37 @@ async function handleRequest(req, res, url) { return json(res, { ok: true, results, errors, workspaces: data.workspaces }); } + // ---- PROJECTS ---- + if (p === '/api/projects' && req.method === 'GET') { + return json(res, { ok: true, projects: listProjects() }); + } + if (p === '/api/projects' && req.method === 'POST') { + const input = await body(req); + const result = createProject(input); + return json(res, result, result.ok ? 200 : 400); + } + if (p.startsWith('/api/projects/') && req.method === 'PATCH') { + let slug; + try { + slug = decodeURIComponent(p.slice('/api/projects/'.length)); + } catch { + return json(res, { ok: false, error: 'Invalid path encoding' }, 400); + } + const patch = await body(req); + const result = updateProject(slug, patch); + return json(res, result, result.ok ? 200 : 404); + } + if (p.startsWith('/api/projects/') && req.method === 'DELETE') { + let slug; + try { + slug = decodeURIComponent(p.slice('/api/projects/'.length)); + } catch { + return json(res, { ok: false, error: 'Invalid path encoding' }, 400); + } + const result = deleteProject(slug); + return json(res, result, result.ok ? 200 : 404); + } + return null; // Not an API route } diff --git a/ui/app-update.js b/ui/app-update.js index a293c01..3c62bbe 100644 --- a/ui/app-update.js +++ b/ui/app-update.js @@ -125,4 +125,4 @@ const AppUpdate = (() => { return { init, check: pollCheck }; })(); -document.addEventListener('DOMContentLoaded', AppUpdate.init); \ No newline at end of file +document.addEventListener('DOMContentLoaded', AppUpdate.init); diff --git a/ui/app.js b/ui/app.js index d58701c..5576a33 100644 --- a/ui/app.js +++ b/ui/app.js @@ -102,6 +102,7 @@ async function boot() { SkillsTab.init(); MemoryTab.init(); if (typeof HandoffsTab !== 'undefined') await HandoffsTab.init(); + if (typeof ProjectsTab !== 'undefined') await ProjectsTab.init(); ConfigTab.init(); await ModesTab.init(); if (typeof CompileTab !== 'undefined') await CompileTab.init(); diff --git a/ui/ce-select.js b/ui/ce-select.js index ec260d9..91b6ea8 100644 --- a/ui/ce-select.js +++ b/ui/ce-select.js @@ -207,8 +207,7 @@ for (const m of mutations) { m.addedNodes.forEach((node) => { if (node instanceof HTMLElement) { - if (node.matches?.('select.add-input') && node instanceof HTMLSelectElement) - enhance(node); + if (node.matches?.('select.add-input') && node instanceof HTMLSelectElement) enhance(node); enhanceAll(node); } }); @@ -224,4 +223,4 @@ } window.CESelect = { enhance, enhanceAll }; -})(); \ No newline at end of file +})(); diff --git a/ui/command-bar.js b/ui/command-bar.js index 8d1e2ac..67a0f74 100644 --- a/ui/command-bar.js +++ b/ui/command-bar.js @@ -169,4 +169,4 @@ const CommandBar = (() => { }); return { open, close, isOpen }; -})(); \ No newline at end of file +})(); diff --git a/ui/config.js b/ui/config.js index 1be0e5b..278c03f 100644 --- a/ui/config.js +++ b/ui/config.js @@ -163,4 +163,4 @@ const ConfigTab = (() => { } return { init, save, reset, saveApiKey, removeApiKey, toggleKeyVisibility, updateRuleMetrics }; -})(); \ No newline at end of file +})(); diff --git a/ui/context-flow.js b/ui/context-flow.js index de35d3a..4ee5468 100644 --- a/ui/context-flow.js +++ b/ui/context-flow.js @@ -148,4 +148,4 @@ const ContextFlow = (() => { } return { init }; -})(); \ No newline at end of file +})(); diff --git a/ui/data.js b/ui/data.js index 0e950a5..4ed2ae7 100644 --- a/ui/data.js +++ b/ui/data.js @@ -25,4 +25,4 @@ const DEFAULT_RULES = { Comment the why, not the what.`, general: `Memory is a core skill. Think independently.`, soul: DEFAULT_SOUL, -}; \ No newline at end of file +}; diff --git a/ui/handoffs.js b/ui/handoffs.js index 8f1d9b8..3fe19b0 100644 --- a/ui/handoffs.js +++ b/ui/handoffs.js @@ -151,10 +151,14 @@ const HandoffsTab = (() => { } async function save(slug) { - const title = /** @type {HTMLInputElement|null} */ (document.getElementById('handoff-edit-title'))?.value.trim(); + const title = /** @type {HTMLInputElement|null} */ ( + document.getElementById('handoff-edit-title') + )?.value.trim(); if (!title) return Toast.error('Title is required'); // Body is optional; pass through even when empty so users can clear it. - const body = /** @type {HTMLTextAreaElement|null} */ (document.getElementById('handoff-edit-body'))?.value ?? undefined; + const body = + /** @type {HTMLTextAreaElement|null} */ (document.getElementById('handoff-edit-body'))?.value ?? + undefined; const patch = { title }; if (body !== undefined) patch.body = body; const result = await apiFetch(`/handoffs/${encodeURIComponent(slug)}`, 'PATCH', patch); @@ -360,10 +364,14 @@ const HandoffsTab = (() => { function openAddModal() { const overlay = document.getElementById('handoff-modal-overlay'); if (!overlay) return; - ['handoff-modal-title', 'handoff-modal-thread', 'handoff-modal-repo', 'handoff-modal-body'].forEach((id) => { - const el = document.getElementById(id); - if (el) /** @type {HTMLInputElement|HTMLTextAreaElement} */ (el).value = ''; - }); + ['handoff-modal-title', 'handoff-modal-thread', 'handoff-modal-repo', 'handoff-modal-body'].forEach( + (id) => { + const el = document.getElementById(id); + if (el) /** @type {HTMLInputElement|HTMLTextAreaElement} */ (el).value = ''; + }, + ); + const browseBtn = overlay.querySelector('.local-browse-btn'); + if (browseBtn) browseBtn.hidden = !window.contextEngineDesktop?.selectFolder; overlay.classList.add('open'); setTimeout(() => document.getElementById('handoff-modal-title')?.focus(), 0); } @@ -373,13 +381,34 @@ const HandoffsTab = (() => { document.getElementById('handoff-modal-overlay')?.classList.remove('open'); } + async function browseRepoPath() { + const picker = window.contextEngineDesktop?.selectFolder; + if (!picker) return Toast.error('Folder picker not available in this environment'); + try { + const picked = await picker({ title: 'Select repository folder' }); + if (picked) { + const el = /** @type {HTMLInputElement|null} */ (document.getElementById('handoff-modal-repo')); + if (el) el.value = picked; + } + } catch (err) { + console.error('handoffs: folder picker failed', err); + Toast.error('Could not open folder picker'); + } + } + async function createFromModal() { - const title = /** @type {HTMLInputElement|null} */ (document.getElementById('handoff-modal-title'))?.value.trim(); - const thread_tag = /** @type {HTMLInputElement|null} */ (document.getElementById('handoff-modal-thread'))?.value.trim(); - const repo = /** @type {HTMLInputElement|null} */ (document.getElementById('handoff-modal-repo'))?.value.trim(); - const body = /** @type {HTMLTextAreaElement|null} */ (document.getElementById('handoff-modal-body'))?.value || ''; + const title = /** @type {HTMLInputElement|null} */ ( + document.getElementById('handoff-modal-title') + )?.value.trim(); + const thread_tag = /** @type {HTMLInputElement|null} */ ( + document.getElementById('handoff-modal-thread') + )?.value.trim(); + const repo = /** @type {HTMLInputElement|null} */ ( + document.getElementById('handoff-modal-repo') + )?.value.trim(); + const body = + /** @type {HTMLTextAreaElement|null} */ (document.getElementById('handoff-modal-body'))?.value || ''; if (!title) return Toast.error('Title is required'); - if (!thread_tag && !repo) return Toast.error('Add a thread tag or repo path'); const result = await apiFetch('/handoffs', 'POST', { title, thread_tag, repo, body }); if (!result?.ok) return; closeAddModal(); @@ -408,7 +437,9 @@ const HandoffsTab = (() => { * @param {string} value */ function projectTitle(value) { - const cleaned = String(value || '').replace(/\\/g, '/').replace(/\/+$/, ''); + const cleaned = String(value || '') + .replace(/\\/g, '/') + .replace(/\/+$/, ''); if (!cleaned) return ''; const last = cleaned.split('/').filter(Boolean).pop(); return last || cleaned; @@ -489,5 +520,6 @@ const HandoffsTab = (() => { openAddModal, closeAddModal, createFromModal, + browseRepoPath, }; })(); diff --git a/ui/index.html b/ui/index.html index 213e6a3..de16b03 100644 --- a/ui/index.html +++ b/ui/index.html @@ -87,6 +87,20 @@ Context + + + +
+ + +