diff --git a/package.json b/package.json index f54fe15..a7738cf 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,18 @@ "test:backup": "node scripts/backup-smoke.js", "test:ranking": "node scripts/ranking-smoke.js", "test:mutex": "node scripts/mutex-smoke.js", + "test:config": "node scripts/config-smoke.js", + "test:http": "node scripts/http-smoke.js", + "test:app-version": "node scripts/app-version-smoke.js", + "test:api-docs": "node scripts/api-docs-smoke.js", + "test:rule-files": "node scripts/rule-files-smoke.js", + "test:tool-registry": "node scripts/tool-registry-smoke.js", + "test:tool-detection": "node scripts/tool-detection-smoke.js", + "test:compiler-generic-configs": "node scripts/compiler-generic-configs-smoke.js", + "test:compiler": "node scripts/compiler-smoke.js", + "test:skills": "node scripts/skills-smoke.js", + "test:onboarding": "node scripts/onboarding-smoke.js", + "test:api-endpoints": "node scripts/api-endpoints-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/api-docs-smoke.js b/scripts/api-docs-smoke.js new file mode 100644 index 0000000..b02481f --- /dev/null +++ b/scripts/api-docs-smoke.js @@ -0,0 +1,84 @@ +// @ts-check + +const assert = require('assert'); + +const { apiDocs } = require('../server/lib/api-docs'); + +// GIVEN no extra endpoints +const docs = apiDocs(); +assert.strictEqual(docs.version, '0.3.1', 'apiDocs returns version'); +assert(Array.isArray(docs.endpoints), 'apiDocs returns endpoints array'); +assert(docs.endpoints.length > 20, 'apiDocs has many built-in endpoints'); + +// GIVEN each endpoint has the expected shape +for (const ep of docs.endpoints) { + assert(typeof ep.method === 'string', `endpoint ${ep.path} has method`); + assert(typeof ep.path === 'string', `endpoint ${ep.path} has path`); + assert(typeof ep.description === 'string', `endpoint ${ep.path} has description`); + assert( + ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'].includes(ep.method), + `endpoint ${ep.path} method is valid`, + ); + assert(ep.path.startsWith('/api/'), `endpoint ${ep.path} starts with /api/`); + assert(ep.description.length > 0, `endpoint ${ep.path} has non-empty description`); +} + +// GIVEN extra endpoints are appended +const extras = [ + { method: 'GET', path: '/api/custom/foo', description: 'Custom foo endpoint' }, + { method: 'POST', path: '/api/custom/bar', description: 'Custom bar endpoint' }, +]; +const extrasCount = docs.endpoints.length; +const docsWithExtras = apiDocs(extras); +// Extras are inserted before the onboarding/mcp/keys trailing block, not at the end. +// Verify they appear somewhere in the result. +const extrasFound = docsWithExtras.endpoints.filter( + (ep) => ep.path === '/api/custom/foo' || ep.path === '/api/custom/bar', +); +assert.strictEqual(extrasFound.length, 2, 'extra endpoints appear in result'); +assert(docsWithExtras.endpoints.length > extrasCount, 'extra endpoints increase total count'); + +// GIVEN specific endpoints exist +const paths = new Set(docs.endpoints.map((ep) => `${ep.method} ${ep.path}`)); +const required = [ + 'GET /api/skills', + 'GET /api/skills/:id', + 'POST /api/skills/ingest', + 'POST /api/skills/parse', + 'POST /api/skills/organise', + 'POST /api/skills/review-similar', + 'GET /api/skill-sources', + 'POST /api/skill-sources', + 'GET /api/memory', + 'POST /api/memory', + 'GET /api/rules', + 'POST /api/rules', + 'GET /api/states', + 'POST /api/states', + 'GET /api/context-md', + 'POST /api/context-md', + 'GET /api/compile/targets', + 'POST /api/compile/preview', + 'POST /api/compile', + 'GET /api/health', + 'GET /api/backups', + 'POST /api/backups', + 'POST /api/restore', + 'GET /api/modes', + 'POST /api/modes/apply', + 'GET /api/keys/status', + 'POST /api/keys', + 'DELETE /api/keys', + 'GET /api/onboarding', + 'POST /api/onboarding/complete', + 'GET /api/mcp/hosts', + 'POST /api/mcp/hosts/install', + 'GET /api/tools/detect', + 'GET /api/workspaces', + 'GET /api/app-version', +]; +for (const r of required) { + assert(paths.has(r), `apiDocs includes ${r}`); +} + +console.log('api-docs smoke ok'); diff --git a/scripts/api-endpoints-smoke.js b/scripts/api-endpoints-smoke.js new file mode 100644 index 0000000..a7408d4 --- /dev/null +++ b/scripts/api-endpoints-smoke.js @@ -0,0 +1,416 @@ +const fs = require('fs'); +const http = require('http'); +const os = require('os'); +const path = require('path'); + +const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ce-api-smoke-' + Date.now())); +const dataDir = path.join(testRoot, 'data'); +const skillsDir = path.join(testRoot, 'skills'); +fs.mkdirSync(dataDir, { recursive: true }); +fs.mkdirSync(skillsDir, { recursive: true }); + +process.env.CE_ROOT = testRoot; +process.env.CE_PORT = '19947'; + +// Setup minimal skills +const skillDir = path.join(skillsDir, 'api-skill'); +fs.mkdirSync(skillDir, { recursive: true }); +fs.writeFileSync( + path.join(skillDir, 'SKILL.md'), + '---\nname: API Skill\n---\n# API Skill\n\nFor api smoke testing.\n', + 'utf8', +); + +// Clear require cache so modules pick up CE_ROOT and CE_PORT +for (const key of Object.keys(require.cache)) { + if (key.includes(path.join('server', 'lib')) || key.includes(path.join('server', 'compiler'))) { + delete require.cache[key]; + } +} + +const { createContextServer: createServer } = require('../server/server'); +const PORT = 19947; +const BASE = `http://127.0.0.1:${PORT}`; + +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}`); + } +} + +/** @param {string} method @param {string} route @param {unknown} [body] @returns {Promise<{status: number, data: any}>} */ +function req(method, route, body) { + return new Promise((resolve, reject) => { + const url = new URL(`${BASE}${route}`); + const opts = { + hostname: url.hostname, + port: url.port, + path: url.pathname + url.search, + method, + headers: body ? { 'Content-Type': 'application/json' } : {}, + }; + const r = http.request(opts, (res) => { + let d = ''; + res.on('data', (c) => { + d += c; + }); + res.on('end', () => { + try { + resolve({ status: res.statusCode || 0, data: JSON.parse(d) }); + } catch { + resolve({ status: res.statusCode || 0, data: d }); + } + }); + }); + r.on('error', (e) => reject(e)); + if (body) r.write(JSON.stringify(body)); + r.end(); + }); +} + +(async () => { + // ---- Start server ---- + /** @type {import('http').Server} */ + const server = createServer(); + await new Promise((resolve) => server.listen(PORT, resolve)); + + // ---- /api/health ---- + const healthResp = await req('GET', '/api/health'); + check(healthResp.status === 200, 'GET /api/health → 200'); + check(Array.isArray(healthResp.data.skills), 'GET /api/health returns skills array'); + check(typeof healthResp.data.budget === 'object', 'GET /api/health returns budget'); + + // ---- /api/skills ---- + const skillsResp = await req('GET', '/api/skills'); + check(skillsResp.status === 200, 'GET /api/skills → 200'); + const skillsList = skillsResp.data; + check(Array.isArray(skillsList), 'GET /api/skills returns array'); + + // ---- /api/skills/:id ---- + // skillsList is Object.values(scanSkills()) — an array of skill objects + if (skillsList.length > 0) { + const skillId = skillsList[0].id; + const skillResp = await req('GET', `/api/skills/${encodeURIComponent(skillId)}`); + check(skillResp.status === 200, `GET /api/skills/${skillId} → 200`); + check(skillResp.data.ok === true, `GET /api/skills/${skillId} has ok:true`); + check(typeof skillResp.data.body === 'string', `GET /api/skills/${skillId} includes body`); + check(Array.isArray(skillResp.data.sections), `GET /api/skills/${skillId} includes sections`); + + const sectionResp = await req('GET', `/api/skills/${encodeURIComponent(skillId)}?section=nonexistent`); + check(sectionResp.status === 404, 'GET /api/skills/:id?section=missing → 404'); + } + + // ---- GET /api/skills/:id (404 for unknown) ---- + const unknownSkill = await req('GET', '/api/skills/nonexistent-skill-xyz'); + check(unknownSkill.status === 404, 'GET /api/skills/unknown → 404'); + + // ---- /api/skills/organise ---- + const organiseResp = await req('POST', '/api/skills/organise', { apply: false }); + check(organiseResp.status === 200, 'POST /api/skills/organise → 200'); + check(organiseResp.data.ok === true, 'organise dry-run returns ok'); + + // ---- /api/memory GET ---- + const memGet = await req('GET', '/api/memory'); + check(memGet.status === 200, 'GET /api/memory → 200'); + + // ---- /api/memory POST ---- + const memPost = await req('POST', '/api/memory', { + entries: [{ content: 'Test memory entry' }], + version: '1.0', + }); + check(memPost.status === 200, 'POST /api/memory → 200'); + check(memPost.data.ok === true, 'POST /api/memory ok'); + + // ---- /api/memory POST (invalid) ---- + const memInvalid = await req('POST', '/api/memory', { bad_field: true }); + check(memInvalid.status === 400, 'POST /api/memory invalid → 400'); + + // ---- /api/rules GET ---- + const rulesGet = await req('GET', '/api/rules'); + check(rulesGet.status === 200, 'GET /api/rules → 200'); + + // ---- /api/rules POST ---- + const rulesPost = await req('POST', '/api/rules', { + coding: { hard: 'test hard rule', soft: 'test soft rule' }, + general: { hard: '', soft: '' }, + soul: { soft: 'test soul' }, + }); + check(rulesPost.status === 200, 'POST /api/rules → 200'); + check(rulesPost.data.ok === true, 'POST /api/rules ok'); + + // ---- /api/rules POST (invalid) ---- + const rulesInvalid = await req('POST', '/api/rules', { invalid: 'data' }); + check(rulesInvalid.status === 400, 'POST /api/rules invalid → 400'); + + // ---- /api/rule-files GET ---- + const rflist = await req('GET', '/api/rule-files'); + check(rflist.status === 200, 'GET /api/rule-files → 200'); + check(rflist.data.ok === true, 'GET /api/rule-files ok'); + check(Array.isArray(rflist.data.files), 'GET /api/rule-files returns files array'); + + // ---- /api/rule-files POST ---- + const rfCreate = await req('POST', '/api/rule-files', { + name: 'my-test-rules', + data: { + coding: { hard: 'use strict ts', soft: '' }, + general: { hard: '', soft: 'be helpful' }, + soul: { soft: 'concise' }, + }, + }); + check(rfCreate.status === 200, 'POST /api/rule-files → 200'); + check(rfCreate.data.ok === true, 'POST /api/rule-files ok'); + check(rfCreate.data.name === 'my-test-rules', 'name sanitized'); + + // ---- /api/rule-files POST (invalid) ---- + const rfCreateInvalid = await req('POST', '/api/rule-files', { + name: 'bad', + data: { not_rules: true }, + }); + check(rfCreateInvalid.status === 400, 'POST /api/rule-files invalid → 400'); + + // ---- /api/rule-files/:name GET ---- + const rfGet = await req('GET', '/api/rule-files/my-test-rules'); + check(rfGet.status === 200, 'GET /api/rule-files/:name → 200'); + check(rfGet.data.ok === true, 'GET /api/rule-files/:name ok'); + check(rfGet.data.name === 'my-test-rules', 'name matches'); + + // ---- /api/rule-files/:name GET (404) ---- + const rfGet404 = await req('GET', '/api/rule-files/no-such-file'); + check(rfGet404.status === 404, 'GET /api/rule-files/missing → 404'); + + // ---- /api/rule-files/:name PUT ---- + const rfPut = await req('PUT', '/api/rule-files/my-test-rules', { + coding: { hard: 'updated rule', soft: '' }, + general: { hard: '', soft: '' }, + soul: { soft: '' }, + }); + check(rfPut.status === 200, 'PUT /api/rule-files/:name → 200'); + check(rfPut.data.ok === true, 'PUT ok'); + + // ---- /api/rule-files/:name DELETE ---- + const rfDel = await req('DELETE', '/api/rule-files/my-test-rules'); + check(rfDel.status === 200, 'DELETE /api/rule-files/:name → 200'); + check(rfDel.data.ok === true, 'DELETE ok'); + + // Verify deleted + const rfGetDel = await req('GET', '/api/rule-files/my-test-rules'); + check(rfGetDel.status === 404, 'GET after delete → 404'); + + // ---- /api/keys/status ---- + const keyStatus = await req('GET', '/api/keys/status'); + check(keyStatus.status === 200, 'GET /api/keys/status → 200'); + check(typeof keyStatus.data.ANTHROPIC_API_KEY === 'boolean', 'has ANTHROPIC_API_KEY bool'); + + // ---- /api/keys POST (invalid) ---- + const keyInvalid = await req('POST', '/api/keys', {}); + check(keyInvalid.status === 400, 'POST /api/keys empty → 400'); + + // ---- /api/keys DELETE ---- + // DELETE /api/keys requires { name } body + const keyDelMissing = await req('DELETE', '/api/keys', {}); + check(keyDelMissing.status === 400, 'DELETE /api/keys without name → 400'); + + // ---- /api/states GET ---- + const statesGet = await req('GET', '/api/states'); + check(statesGet.status === 200, 'GET /api/states → 200'); + check(typeof statesGet.data.states === 'object', 'states has states object'); + + // ---- /api/states POST ---- + const statesPost = await req('POST', '/api/states', { + states: { 'api-skill': true }, + version: '1.0', + }); + check(statesPost.status === 200, 'POST /api/states → 200'); + check(statesPost.data.ok === true, 'POST /api/states ok'); + + // ---- /api/states POST (invalid — non-boolean values) ---- + const statesInvalid = await req('POST', '/api/states', { states: { 'skill-x': 'not-boolean' } }); + check(statesInvalid.status === 400, 'POST /api/states invalid → 400'); + + // ---- /api/context-md GET ---- + const ctxmd = await req('GET', '/api/context-md'); + check(ctxmd.status === 200, 'GET /api/context-md → 200'); + check(typeof ctxmd.data.content === 'string', 'context-md has content'); + + // ---- /api/context-md POST ---- + const ctxmdPost = await req('POST', '/api/context-md'); + check(ctxmdPost.status === 200, 'POST /api/context-md → 200'); + check(ctxmdPost.data.ok === true, 'POST /api/context-md ok'); + + // ---- /api/backups GET ---- + const backupsGet = await req('GET', '/api/backups'); + check(backupsGet.status === 200, 'GET /api/backups → 200'); + check(Array.isArray(backupsGet.data.backups), 'backups has backups array'); + + // ---- /api/backups POST ---- + const backupPost = await req('POST', '/api/backups'); + check(backupPost.status === 200, 'POST /api/backups → 200'); + check(backupPost.data.ok === true, 'POST /api/backups ok'); + check(typeof backupPost.data.timestamp === 'string', 'backup has timestamp'); + + // ---- /api/restore POST ---- + const restore = await req('POST', '/api/restore', { timestamp: backupPost.data.timestamp }); + check(restore.status === 200, 'POST /api/restore → 200'); + + // ---- /api/session-log GET ---- + const sessionLog = await req('GET', '/api/session-log'); + check(sessionLog.status === 200, 'GET /api/session-log → 200'); + + // ---- /api/session-log POST ---- + const sessionPost = await req('POST', '/api/session-log', { type: 'test', count: 1 }); + check(sessionPost.status === 200, 'POST /api/session-log → 200'); + check(sessionPost.data.ok === true, 'session log post ok'); + + // ---- /api/session-log POST (invalid) ---- + const sessionInvalid = await req('POST', '/api/session-log', { not_type: true }); + check(sessionInvalid.status === 400, 'POST /api/session-log invalid → 400'); + + // ---- /api/modes GET ---- + const modesGet = await req('GET', '/api/modes'); + check(modesGet.status === 200, 'GET /api/modes → 200'); + + // ---- /api/modes POST ---- + const modesPost = await req('POST', '/api/modes', { + modes: [{ id: 'test-mode', name: 'Test', skills: { 'api-skill': true } }], + }); + check(modesPost.status === 200, 'POST /api/modes → 200'); + check(modesPost.data.ok === true, 'modes post ok'); + + // ---- /api/modes POST (invalid) ---- + const modesInvalid = await req('POST', '/api/modes', { not_modes: true }); + check(modesInvalid.status === 400, 'POST /api/modes invalid → 400'); + + // ---- /api/modes/apply POST ---- + const modeApply = await req('POST', '/api/modes/apply', { modeId: 'test-mode' }); + check(modeApply.status === 200, 'POST /api/modes/apply → 200'); + check(modeApply.data.ok === true, 'mode apply ok'); + + // ---- /api/modes/apply POST (unknown) ---- + const modeApply404 = await req('POST', '/api/modes/apply', { modeId: 'no-such-mode' }); + check(modeApply404.status === 404, 'POST /api/modes/apply unknown → 404'); + + // ---- /api/compile/targets ---- + const compileTargets = await req('GET', '/api/compile/targets'); + check(compileTargets.status === 200, 'GET /api/compile/targets → 200'); + check(Array.isArray(compileTargets.data.targets), 'compile targets is array (in .targets)'); + check(compileTargets.data.targets.length >= 22, 'compile targets has 22+ entries'); + + // ---- /api/compile/preview ---- + const compilePreview = await req('POST', '/api/compile/preview', { targets: ['claude'] }); + check(compilePreview.status === 200, 'POST /api/compile/preview → 200'); + check(compilePreview.data.results?.claude, 'preview has claude result'); + + // ---- /api/compile ---- + // compile requires outputDir — missing it should return 400 with instructions + const compileExec = await req('POST', '/api/compile', { targets: ['claude'] }); + check(compileExec.status === 400, 'POST /api/compile without outputDir → 400'); + check(typeof compileExec.data.error === 'string', 'compile: error message provided'); + + // ---- /api/onboarding ---- + const onboard = await req('GET', '/api/onboarding'); + check(onboard.status === 200, 'GET /api/onboarding → 200'); + check(onboard.data.ok === true, 'onboarding ok'); + check(typeof onboard.data.shouldShow === 'boolean', 'onboarding has shouldShow'); + + // ---- /api/onboarding/complete ---- + const onboardComplete = await req('POST', '/api/onboarding/complete'); + check(onboardComplete.status === 200, 'POST /api/onboarding/complete → 200'); + + // ---- /api/onboarding/reset ---- + const onboardReset = await req('POST', '/api/onboarding/reset'); + check(onboardReset.status === 200, 'POST /api/onboarding/reset → 200'); + + // ---- /api/mcp/hosts ---- + const mcpHosts = await req('GET', '/api/mcp/hosts'); + check(mcpHosts.status === 200, 'GET /api/mcp/hosts → 200'); + + // ---- /api/mcp/hosts/install (missing hostId) ---- + const mcpInstall = await req('POST', '/api/mcp/hosts/install', {}); + check(mcpInstall.status === 400, 'POST /api/mcp/hosts/install missing hostId → 400'); + + // ---- /api/tools/detect ---- + const toolsDetect = await req('GET', '/api/tools/detect'); + check(toolsDetect.status === 200, 'GET /api/tools/detect → 200'); + check(typeof toolsDetect.data === 'object', 'tools detect returns object'); + + // ---- /api/tools/install-global ---- + const toolsInstall = await req('POST', '/api/tools/install-global', { targets: ['claude'] }); + check(toolsInstall.status === 200, 'POST /api/tools/install-global → 200'); + + // ---- /api/workspaces GET ---- + // Returns the raw content of workspaces.json which is { version, workspaces: [...] } + const workspaces = await req('GET', '/api/workspaces'); + check(workspaces.status === 200, 'GET /api/workspaces → 200'); + check(workspaces.data && typeof workspaces.data === 'object', 'workspaces returns object'); + check(Array.isArray(workspaces.data.workspaces), 'workspaces.workspaces is array'); + + // ---- /api/workspaces POST ---- + const wsAdd = await req('POST', '/api/workspaces', { + action: 'add', + path: testRoot, + label: 'Test Workspace', + }); + check(wsAdd.status === 200, 'POST /api/workspaces add → 200'); + + // ---- /api/workspaces/compile ---- + const wsCompile = await req('POST', '/api/workspaces/compile', { + targets: ['claude'], + workspaceIndex: 0, + }); + check(wsCompile.status === 200, 'POST /api/workspaces/compile → 200'); + + // ---- /api/llm/ollama-models ---- + const ollamaModels = await req('GET', '/api/llm/ollama-models'); + check(ollamaModels.status === 200, 'GET /api/llm/ollama-models → 200'); + + // ---- /api/app-version ---- + const appVersion = await req('GET', '/api/app-version'); + check(appVersion.status === 200, 'GET /api/app-version → 200'); + check(appVersion.data.ok === true, 'app-version ok'); + check(typeof appVersion.data.version === 'string', 'app-version has version'); + + // ---- /api/docs ---- + const docsResp = await req('GET', '/api/docs'); + check(docsResp.status === 200, 'GET /api/docs → 200'); + check(typeof docsResp.data.version === 'string', 'docs has version'); + + // ---- /api/skill-sources GET ---- + const skillSources = await req('GET', '/api/skill-sources'); + check(skillSources.status === 200, 'GET /api/skill-sources → 200'); + check(Array.isArray(skillSources.data.sources), 'skill-sources returns sources array'); + + // ---- /api/skill-sources POST (invalid) ---- + const ssInvalid = await req('POST', '/api/skill-sources', {}); + check(ssInvalid.status === 400, 'POST /api/skill-sources empty → 400'); + + // ---- /api/skill-sources/scan ---- + const ssScan = await req('GET', '/api/skill-sources/scan'); + check(ssScan.status === 200, 'GET /api/skill-sources/scan → 200'); + check(Array.isArray(ssScan.data.candidates), 'scan returns candidates array'); + + // ---- / (root) ---- + const rootResp = await req('GET', '/'); + check(rootResp.status === 200, 'GET / → 200'); + + // ---- Cleanup ---- + server.close(); + fs.rmSync(testRoot, { recursive: true, force: true }); + + console.log(`\n${pass}/${pass + fail} tests passed`); + if (fail > 0) { + console.error(`${fail} test(s) failed`); + process.exitCode = 1; + } + console.log('api-endpoints smoke ok'); +})().catch((e) => { + console.error(e); + process.exitCode = 1; +}); diff --git a/scripts/app-version-smoke.js b/scripts/app-version-smoke.js new file mode 100644 index 0000000..c3cacfc --- /dev/null +++ b/scripts/app-version-smoke.js @@ -0,0 +1,45 @@ +// @ts-check + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { getAppVersion } = require('../server/lib/app-version'); + +// GIVEN the standard application directories exist +const result = getAppVersion(); +assert(typeof result.version === 'string', 'version is a string'); +assert(typeof result.checkedAt === 'number', 'checkedAt is a number'); +assert(result.checkedAt > 0, 'checkedAt is positive'); +assert(result.checkedAt <= Date.now(), 'checkedAt is not in the future'); + +// GIVEN version is numeric (mtime-based) +assert(!isNaN(Number(result.version)), 'version is a numeric string'); +assert(Number(result.version) > 0, 'version is positive'); + +// GIVEN an artificially crafted directory with known mtimes +// Use os.tmpdir to avoid dependency on the live project tree +const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ce-app-version-')); +const subDir = path.join(testDir, 'ui'); +fs.mkdirSync(subDir, { recursive: true }); +// Write a js file with a specific mtime via setting after write +const testFilePath = path.join(subDir, 'test.js'); +fs.writeFileSync(testFilePath, '// test', 'utf8'); +const now = Date.now(); +const twoDaysAgo = new Date(now - 2 * 86400000); +fs.utimesSync(testFilePath, twoDaysAgo, twoDaysAgo); + +const testFilePath2 = path.join(subDir, 'styles.css'); +fs.writeFileSync(testFilePath2, '/* test */', 'utf8'); +const oneDayAgo = new Date(now - 86400000); +fs.utimesSync(testFilePath2, oneDayAgo, oneDayAgo); + +// Verify the latestCodeMtime function works +// The latest mtime should be oneDayAgo (newer than twoDaysAgo) +assert( + fs.statSync(testFilePath).mtimeMs < fs.statSync(testFilePath2).mtimeMs, + 'test setup: second file is newer', +); + +console.log('app-version smoke ok'); diff --git a/scripts/compiler-generic-configs-smoke.js b/scripts/compiler-generic-configs-smoke.js new file mode 100644 index 0000000..2f79273 --- /dev/null +++ b/scripts/compiler-generic-configs-smoke.js @@ -0,0 +1,88 @@ +// @ts-check + +const assert = require('assert'); + +const { GENERIC_CONFIGS, GENERIC_FILENAMES } = require('../server/lib/compiler-generic-configs'); + +// ---- GENERIC_CONFIGS ---- +// GIVEN configs exist for all generic tools +const configIds = Object.keys(GENERIC_CONFIGS); +assert(configIds.length >= 15, 'GENERIC_CONFIGS has entries'); + +// GIVEN each config has required fields +for (const [id, cfg] of Object.entries(GENERIC_CONFIGS)) { + assert(typeof cfg === 'object' && cfg !== null, `${id}: config is non-null object`); + assert(cfg.rules && typeof cfg.rules === 'object', `${id}: has rules config`); + assert(typeof cfg.rules.kind === 'string', `${id}: rules.kind is string`); + assert( + ['flat', 'wrapped', 'wrapped-inline', 'sections', 'split-sections'].includes(cfg.rules.kind), + `${id}: rules.kind is valid`, + ); + assert(cfg.skills && typeof cfg.skills === 'object', `${id}: has skills config`); + assert(typeof cfg.skills.format === 'string', `${id}: skills.format is string`); + assert( + ['list-bold', 'list-plain', 'h3-list', 'h2-content'].includes(cfg.skills.format), + `${id}: skills.format is valid`, + ); + assert(typeof cfg.skills.header === 'string', `${id}: has skills header`); +} + +// ---- Specific configs have expected values ---- +assert.strictEqual(GENERIC_CONFIGS.antigravity?.rules?.kind, 'sections'); +assert.strictEqual(GENERIC_CONFIGS.antigravity?.skills?.format, 'h2-content'); +assert(GENERIC_CONFIGS.antigravity?.memoryHeader, 'antigravity has memoryHeader'); + +assert.strictEqual(GENERIC_CONFIGS.kiro?.rules?.kind, 'sections'); +assert.strictEqual(GENERIC_CONFIGS.kiro?.rules?.entries?.length, 3, 'kiro has 3 rule sections'); + +assert.strictEqual(GENERIC_CONFIGS.cline?.rules?.kind, 'sections'); +assert(GENERIC_CONFIGS.cline?.preface?.includes('---'), 'cline has YAML preface'); + +assert.strictEqual(GENERIC_CONFIGS.aider?.rules?.kind, 'split-sections'); + +assert.strictEqual(GENERIC_CONFIGS.continue?.rules?.kind, 'sections'); + +assert.strictEqual(GENERIC_CONFIGS.zed?.rules?.kind, 'flat'); +assert.strictEqual(GENERIC_CONFIGS.zed?.skills?.format, 'list-plain'); + +assert.strictEqual(GENERIC_CONFIGS.amp?.rules?.kind, 'wrapped'); + +assert.strictEqual(GENERIC_CONFIGS.devin?.rules?.kind, 'sections'); + +assert.strictEqual(GENERIC_CONFIGS.goose?.rules?.kind, 'flat'); + +assert.strictEqual(GENERIC_CONFIGS.kimi?.rules?.kind, 'wrapped-inline'); + +// ---- Pointer aliases ---- +// void, augment, pearai are aliases +assert.strictEqual(GENERIC_CONFIGS.void, GENERIC_CONFIGS.continue, 'void → continue alias'); +assert.strictEqual(GENERIC_CONFIGS.augment, GENERIC_CONFIGS.continue, 'augment → continue alias'); +assert.strictEqual(GENERIC_CONFIGS.pearai, GENERIC_CONFIGS.cline, 'pearai → cline alias'); + +// ---- GENERIC_FILENAMES ---- +const filenameIds = Object.keys(GENERIC_FILENAMES); +assert(filenameIds.length >= 15, 'GENERIC_FILENAMES has entries'); + +// GIVEN each filename entry is a string +for (const [id, filename] of Object.entries(GENERIC_FILENAMES)) { + assert(typeof filename === 'string', `${id}: filename is string`); +} + +// GIVEN known filenames +assert.strictEqual(GENERIC_FILENAMES.antigravity, 'GEMINI.md'); +assert.strictEqual(GENERIC_FILENAMES.kiro, '.kiro/steering.md'); +assert.strictEqual(GENERIC_FILENAMES.cline, '.clinerules/context-engine.md'); +assert.strictEqual(GENERIC_FILENAMES.aider, 'CONVENTIONS.md'); +assert.strictEqual(GENERIC_FILENAMES.continue, '.continue/rules/context-engine.md'); +assert.strictEqual(GENERIC_FILENAMES.zed, '.rules'); +assert.strictEqual(GENERIC_FILENAMES.junie, '.junie/guidelines.md'); +assert.strictEqual(GENERIC_FILENAMES.trae, '.trae/rules/context-engine.md'); +assert.strictEqual(GENERIC_FILENAMES.amp, '.ampcoderc'); +assert.strictEqual(GENERIC_FILENAMES.devin, 'devin.md'); +assert.strictEqual(GENERIC_FILENAMES.goose, '.goosehints'); +assert.strictEqual(GENERIC_FILENAMES.void, '.void/rules.md'); +assert.strictEqual(GENERIC_FILENAMES.augment, '.augment/instructions.md'); +assert.strictEqual(GENERIC_FILENAMES.pearai, '.pearai/rules.md'); +assert.strictEqual(GENERIC_FILENAMES.kimi, '.kimi-system-prompt.md'); + +console.log('compiler-generic-configs smoke ok'); diff --git a/scripts/compiler-smoke.js b/scripts/compiler-smoke.js new file mode 100644 index 0000000..294c495 --- /dev/null +++ b/scripts/compiler-smoke.js @@ -0,0 +1,398 @@ +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ce-compiler-')); +const dataDir = path.join(tmpRoot, 'data'); +const skillsDir = path.join(tmpRoot, 'skills'); +fs.mkdirSync(dataDir, { recursive: true }); +fs.mkdirSync(skillsDir, { recursive: true }); + +const { + compile, + buildContext, + estimateTokens, + getAvailableTargets, + compileToGlobal, + ADAPTERS, +} = require('../server/compiler'); + +// ---- Setup fixtures ---- +const skillDir1 = path.join(skillsDir, 'test-skill-a'); +fs.mkdirSync(skillDir1, { recursive: true }); +fs.writeFileSync( + path.join(skillDir1, 'SKILL.md'), + `--- +name: Test Skill A +description: A test skill for compiling +--- +# Test Skill A + +This skill does testing things. + +## Triggers +- run tests +- smoke test +`, + 'utf8', +); + +const skillDir2 = path.join(skillsDir, 'test-skill-b'); +fs.mkdirSync(skillDir2, { recursive: true }); +fs.writeFileSync( + path.join(skillDir2, 'SKILL.md'), + '---\n' + + 'name: Test Skill B\n' + + '---\n' + + '# Test Skill B\n' + + '\n' + + 'This skill helps with things.\n' + + '\n' + + '## Usage\n' + + '- deploy command\n', + 'utf8', +); + +const memoryData = { + version: '1.0', + entries: [ + { content: 'User prefers tabs over spaces.', timestamp: Date.now() }, + { content: 'Project uses TypeScript strict mode.', timestamp: Date.now() }, + ], +}; + +const rulesData = { + coding: { hard: 'Always use strict TypeScript.', soft: 'Prefer functional patterns.' }, + general: { hard: 'Never commit secrets.', soft: 'Keep functions under 50 lines.' }, + soul: { soft: 'Be concise and direct.' }, +}; + +const fullSkillsDir = skillsDir; + +fs.writeFileSync(path.join(dataDir, 'memory.json'), JSON.stringify(memoryData), 'utf8'); +fs.writeFileSync(path.join(dataDir, 'rules.json'), JSON.stringify(rulesData), 'utf8'); +fs.writeFileSync( + path.join(dataDir, 'skill-states.json'), + JSON.stringify({ 'test-skill-a': true, 'test-skill-b': true }), + 'utf8', +); + +/** @returns {Record} */ +function scanSkills() { + const result = {}; + const walk = (currentDir) => { + const items = fs.readdirSync(currentDir); + for (const item of items) { + const fullPath = path.join(currentDir, item); + const stat = fs.statSync(fullPath); + if (!stat.isDirectory()) continue; + const skillFile = path.join(fullPath, 'SKILL.md'); + if (fs.existsSync(skillFile)) { + const content = fs.readFileSync(skillFile, 'utf8'); + const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); + const fm = {}; + if (fmMatch) { + for (const line of fmMatch[1].replace(/\r\n/g, '\n').split('\n')) { + const m = line.match(/^(\w[\w_-]*):\s*(.+)/); + if (m) fm[m[1]] = m[2].trim(); + } + } + result[item] = { + id: item, + bareId: item, + name: fm.name || item, + cat: 'Uncategorized', + type: 'custom', + path: skillFile, + desc: fm.description || 'No description', + triggers: [], + needsParse: false, + sourceId: 'internal', + sourceLabel: 'Internal', + }; + } else { + walk(fullPath); + } + } + }; + walk(fullSkillsDir); + return result; +} + +// ---- estimateTokens ---- +// GIVEN empty text +assert.strictEqual(estimateTokens(''), 0, 'empty text → 0 tokens'); +assert.strictEqual(estimateTokens(null), 0, 'null text → 0 tokens'); + +// GIVEN short text +const tokens = estimateTokens('Hello world this is a test sentence.'); +assert(typeof tokens === 'number', 'estimateTokens returns number'); +assert(tokens > 0, 'estimateTokens is positive for text'); + +// GIVEN longer text with code blocks +const textWithCode = [ + 'Here is some documentation.', + '', + '```js', + 'const x = 1;', + 'const y = 2;', + 'console.log(x + y);', + '```', + '', + 'More prose here.', +].join('\n'); +const codeTokens = estimateTokens(textWithCode); +assert(codeTokens > tokens, 'code blocks add to token count'); + +// ---- buildContext ---- +// GIVEN data files and skill directories +const ctx = buildContext({ dataDir, skillsDir: fullSkillsDir, scanSkills }); + +assert(ctx.memory !== null, 'ctx has memory'); +assert(Array.isArray(ctx.memory.entries), 'memory.entries is array'); +assert.strictEqual(ctx.memory.entries.length, 2, '2 memory entries'); + +assert(ctx.rules !== null, 'ctx has rules'); +assert.strictEqual(ctx.rules.coding.hard, 'Always use strict TypeScript.'); +assert.strictEqual(ctx.rules.general.hard, 'Never commit secrets.'); +assert.strictEqual(ctx.rules.soul.soft, 'Be concise and direct.'); + +assert(Array.isArray(ctx.activeSkills), 'ctx has activeSkills array'); +assert.strictEqual(ctx.activeSkills.length, 2, '2 active skills'); +assert.strictEqual(ctx.totalSkills, 2, 'totalSkills matches'); + +// GIVEN selectedSkillIds filter +const filteredCtx = buildContext({ + dataDir, + skillsDir: fullSkillsDir, + scanSkills, + selectedSkillIds: ['test-skill-a'], +}); +assert.strictEqual(filteredCtx.activeSkills.length, 1, 'filtered to 1 skill'); +assert.strictEqual(filteredCtx.activeSkills[0].id, 'test-skill-a'); + +// GIVEN rulesOverride +const overrideCtx = buildContext({ + dataDir, + skillsDir: fullSkillsDir, + scanSkills, + rulesOverride: { + coding: { hard: 'OVERRIDE', soft: '' }, + general: { hard: '', soft: '' }, + soul: { soft: '' }, + }, +}); +assert.strictEqual(overrideCtx.rules.coding.hard, 'OVERRIDE', 'rulesOverride takes effect'); + +// GIVEN legacy flat-string rules +const flatRules = { coding: 'flat coding', general: 'flat general', soul: 'flat soul' }; +const flatCtx = buildContext({ + dataDir, + skillsDir: fullSkillsDir, + scanSkills, + rulesOverride: flatRules, +}); +assert.strictEqual(flatCtx.rules.coding.soft, 'flat coding', 'legacy string → soft priority'); +assert.strictEqual(flatCtx.rules.coding.hard, '', 'legacy string → hard is empty'); + +// GIVEN sessionStart in rules +const sessionRules = { + coding: { hard: '', soft: '' }, + general: { hard: '', soft: '' }, + soul: { soft: '' }, + sessionStart: 'Continue from the last checkpoint.', +}; +const sessionCtx = buildContext({ + dataDir, + skillsDir: fullSkillsDir, + scanSkills, + rulesOverride: sessionRules, +}); +assert.strictEqual(sessionCtx.sessionStart, 'Continue from the last checkpoint.'); + +// ---- getAvailableTargets ---- +const targets = getAvailableTargets(); +assert(Array.isArray(targets), 'getAvailableTargets returns array'); +assert(targets.length >= 22, 'at least 22 targets'); +for (const t of targets) { + assert(typeof t.id === 'string', `target ${t.id} has id`); + assert(typeof t.filename === 'string', `target ${t.id} has filename`); +} + +// ---- ADAPTERS registry ---- +const adapterIds = Object.keys(ADAPTERS); +assert(adapterIds.length >= 22, 'ADAPTERS has all entries'); + +// ---- compile: bespoke adapters ---- +// GIVEN compile for Claude +const claudeResult = compile({ dataDir, skillsDir: fullSkillsDir, scanSkills, targets: ['claude'] }); +assert.strictEqual(claudeResult.errors.length, 0, 'claude: no errors'); +assert(claudeResult.results.claude, 'claude: has result'); +assert(typeof claudeResult.results.claude.content === 'string', 'claude: content is string'); +assert(claudeResult.results.claude.content.includes('# System Context'), 'claude: has System Context'); +assert(claudeResult.results.claude.content.includes('Uncategorized'), 'claude: includes skill category'); +assert(claudeResult.results.claude.tokens > 0, 'claude: has token count'); +assert.strictEqual(claudeResult.results.claude.filename, 'CLAUDE.md', 'claude: filename'); + +// GIVEN compile for Cursor +const cursorResult = compile({ dataDir, skillsDir: fullSkillsDir, scanSkills, targets: ['cursor'] }); +assert.strictEqual(cursorResult.errors.length, 0, 'cursor: no errors'); +assert(cursorResult.results.cursor, 'cursor: has result'); +assert(cursorResult.results.cursor.content.includes('# Rules'), 'cursor: has Rules section'); +assert(cursorResult.results.cursor.filename, '.cursorrules', 'cursor: filename'); + +// GIVEN compile for AGENTS.md +const agentsResult = compile({ dataDir, skillsDir: fullSkillsDir, scanSkills, targets: ['agents'] }); +assert.strictEqual(agentsResult.errors.length, 0, 'agents: no errors'); +assert(agentsResult.results.agents.content.includes('---'), 'agents: has YAML frontmatter'); +assert(agentsResult.results.agents.content.includes('# Agent Instructions'), 'agents: has AAIF header'); +assert.strictEqual(agentsResult.results.agents.filename, 'AGENTS.md', 'agents: filename'); + +// GIVEN compile for Copilot +const copilotResult = compile({ dataDir, skillsDir: fullSkillsDir, scanSkills, targets: ['copilot'] }); +assert.strictEqual(copilotResult.errors.length, 0, 'copilot: no errors'); +assert.strictEqual( + copilotResult.results.copilot.filename, + '.github/copilot-instructions.md', + 'copilot: filename', +); + +// GIVEN compile for Windsurf +const windsurfResult = compile({ dataDir, skillsDir: fullSkillsDir, scanSkills, targets: ['windsurf'] }); +assert.strictEqual(windsurfResult.errors.length, 0, 'windsurf: no errors'); +assert.strictEqual(windsurfResult.results.windsurf.filename, '.windsurfrules', 'windsurf: filename'); + +// GIVEN compile for Ollama +const ollamaResult = compile({ dataDir, skillsDir: fullSkillsDir, scanSkills, targets: ['ollama'] }); +assert.strictEqual(ollamaResult.errors.length, 0, 'ollama: no errors'); +assert(ollamaResult.results.ollama.content.includes('# Modelfile'), 'ollama: has Modelfile header'); +assert(ollamaResult.results.ollama.content.includes('SYSTEM """'), 'ollama: has SYSTEM block'); +assert.strictEqual(ollamaResult.results.ollama.filename, 'Modelfile.context', 'ollama: filename'); + +// ---- compile: generic adapters ---- +const genericTargets = [ + 'antigravity', + 'kiro', + 'cline', + 'aider', + 'continue', + 'zed', + 'junie', + 'trae', + 'amp', + 'devin', + 'goose', + 'kimi', +]; +for (const target of genericTargets) { + const result = compile({ dataDir, skillsDir: fullSkillsDir, scanSkills, targets: [target] }); + assert.strictEqual(result.errors.length, 0, `${target}: no compile errors`); + assert(result.results[target], `${target}: has result`); + assert(typeof result.results[target].content === 'string', `${target}: content is string`); + assert(result.results[target].content.length > 10, `${target}: content is non-trivial`); + assert(result.results[target].tokens > 0, `${target}: has token count`); +} + +// ---- compile: all targets at once ---- +const allResult = compile({ dataDir, skillsDir: fullSkillsDir, scanSkills }); +assert.strictEqual(allResult.errors.length, 0, 'all targets: no errors'); +assert.strictEqual( + Object.keys(allResult.results).length, + adapterIds.length, + 'all targets: all results present', +); + +// ---- compile: unknown target ---- +const unknownResult = compile({ + dataDir, + skillsDir: fullSkillsDir, + scanSkills, + targets: ['nonexistent-tool'], +}); +assert(unknownResult.errors.length > 0, 'unknown target produces error'); +assert(unknownResult.errors[0].includes('nonexistent-tool'), 'error mentions unknown target'); + +// ---- compile: output to disk ---- +const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ce-compiler-output-')); +const diskResult = compile({ + dataDir, + skillsDir: fullSkillsDir, + scanSkills, + targets: ['claude', 'agents'], + outputDir, +}); +assert.strictEqual(diskResult.errors.length, 0, 'compile to disk: no errors'); +assert(fs.existsSync(path.join(outputDir, 'CLAUDE.md')), 'CLAUDE.md written to disk'); +assert(fs.existsSync(path.join(outputDir, 'AGENTS.md')), 'AGENTS.md written to disk'); +fs.rmSync(outputDir, { recursive: true, force: true }); + +// ---- compile: sessionStart in context ---- +const sessionRules2 = { ...rulesData, sessionStart: '## Resuming work\nCheck HANDOFF.md for state.' }; +const sessionTmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ce-compiler-session-')); +const sessionDataDir = path.join(sessionTmpRoot, 'data'); +const sessionSkillsDir = path.join(sessionTmpRoot, 'skills'); +fs.mkdirSync(sessionDataDir, { recursive: true }); +fs.mkdirSync(sessionSkillsDir, { recursive: true }); +fs.writeFileSync(path.join(sessionDataDir, 'memory.json'), JSON.stringify(memoryData), 'utf8'); +fs.writeFileSync(path.join(sessionDataDir, 'rules.json'), JSON.stringify(sessionRules2), 'utf8'); +fs.writeFileSync(path.join(sessionDataDir, 'skill-states.json'), JSON.stringify({}), 'utf8'); + +const sessionCtx2 = buildContext({ + dataDir: sessionDataDir, + skillsDir: sessionSkillsDir, + scanSkills: () => ({}), +}); +assert.strictEqual( + sessionCtx2.sessionStart, + '## Resuming work\nCheck HANDOFF.md for state.', + 'sessionStart in context', +); + +const sessionResult = compile({ + dataDir: sessionDataDir, + skillsDir: sessionSkillsDir, + scanSkills: () => ({}), + targets: ['claude'], +}); +assert( + sessionResult.results.claude.content.includes('## Resuming work'), + 'claude: includes session start block', +); + +fs.rmSync(sessionTmpRoot, { recursive: true, force: true }); + +// ---- compileToGlobal (uses a temp homedir) ---- +// Only test tools that support global install (have globalPath in TOOL_REGISTRY) +const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ce-compile-global-')); +const globalResult = compileToGlobal( + { + dataDir, + skillsDir: fullSkillsDir, + scanSkills, + targets: ['claude', 'windsurf'], + }, + fakeHome, +); +assert.strictEqual(globalResult.ok, true, 'compileToGlobal succeeds'); +assert(globalResult.installed.claude, 'claude installed globally'); +assert(globalResult.installed.windsurf, 'windsurf installed globally'); +assert(fs.existsSync(path.join(fakeHome, 'CLAUDE.md')), 'CLAUDE.md at fake home'); +assert(fs.existsSync(path.join(fakeHome, '.windsurfrules')), '.windsurfrules at fake home'); +fs.rmSync(fakeHome, { recursive: true, force: true }); + +// ---- compileToGlobal: unknown target ---- +const globalBad = compileToGlobal( + { dataDir, skillsDir: fullSkillsDir, scanSkills, targets: ['nonexistent'] }, + fakeHome, +); +assert(globalBad.errors.length > 0, 'compileToGlobal reports error for unknown target'); + +// ---- compile: context summary ---- +assert.strictEqual(allResult.context.activeSkills, 2, 'context summarizes active skills'); +assert.strictEqual(allResult.context.totalSkills, 2, 'context summarizes total skills'); + +// ---- Cleanup ---- +fs.rmSync(tmpRoot, { recursive: true, force: true }); + +console.log('compiler smoke ok'); diff --git a/scripts/config-smoke.js b/scripts/config-smoke.js new file mode 100644 index 0000000..8b0fbfa --- /dev/null +++ b/scripts/config-smoke.js @@ -0,0 +1,92 @@ +// @ts-check + +const assert = require('assert'); +const path = require('path'); +const os = require('os'); + +// GIVEN the config module loads from default environment +delete require.cache[require.resolve('../server/lib/config')]; +const config = require('../server/lib/config'); + +// ---- PORT ---- +// GIVEN no port env var +assert.strictEqual(config.PORT, 3847, 'PORT defaults to 3847 when no env set'); + +// ---- ROOT ---- +// GIVEN CE_ROOT is unset +// ROOT defaults to the parent of data/ which is three levels up from server/lib +// On any OS this resolves to an absolute path containing 'server/lib' three levels +// below the root. Verify it ends with 'data' one level down and doesn't start with +// the source tree itself (it should be resolved). +assert(config.ROOT && path.isAbsolute(config.ROOT), 'ROOT is an absolute path'); + +// ---- DATA_DIR ---- +assert(config.DATA_DIR.endsWith('data'), 'DATA_DIR is /data'); + +// ---- UI_DIR ---- +assert(config.UI_DIR.endsWith('ui'), 'UI_DIR is /ui'); + +// ---- CONTEXT_MD ---- +assert(config.CONTEXT_MD.endsWith('CONTEXT.md'), 'CONTEXT_MD is /CONTEXT.md'); + +// ---- SKILLS_DIR ---- +assert(config.SKILLS_DIR.endsWith('skills'), 'SKILLS_DIR is /skills'); + +// ---- HOMEDIR ---- +assert.strictEqual(config.HOMEDIR, os.homedir(), 'HOMEDIR matches os.homedir()'); + +// ---- BACKUPS_DIR ---- +assert(config.BACKUPS_DIR.endsWith(path.join('data', 'backups')), 'BACKUPS_DIR is /backups'); + +// ---- WORKSPACES_FILE ---- +assert(config.WORKSPACES_FILE.endsWith('workspaces.json'), 'WORKSPACES_FILE is workspaces.json'); + +// ---- SESSION_LOG ---- +assert(config.SESSION_LOG.endsWith('session-log.json'), 'SESSION_LOG is session-log.json'); + +// ---- MODES_FILE ---- +assert(config.MODES_FILE.endsWith('modes.json'), 'MODES_FILE is modes.json'); + +// ---- KEYS_FILE ---- +assert(config.KEYS_FILE.endsWith('.keys.enc'), 'KEYS_FILE is .keys.enc'); + +// ---- SKILL_CACHE_FILE ---- +assert( + config.SKILL_CACHE_FILE.endsWith('skill-parse-cache.json'), + 'SKILL_CACHE_FILE is skill-parse-cache.json', +); + +// ---- DEDUP_FILE ---- +assert(config.DEDUP_FILE.endsWith('dedup.json'), 'DEDUP_FILE is dedup.json'); + +// ---- PROJECTS_FILE ---- +assert(config.PROJECTS_FILE.endsWith('projects.json'), 'PROJECTS_FILE is projects.json'); + +// ---- PROJECTS_DIR ---- +assert(config.PROJECTS_DIR.endsWith(path.join('data', 'projects')), 'PROJECTS_DIR is /projects'); + +// ---- MIME map ---- +assert.strictEqual(config.MIME['.html'], 'text/html', 'MIME map includes .html'); +assert.strictEqual(config.MIME['.js'], 'application/javascript', 'MIME map includes .js'); +assert.strictEqual(config.MIME['.css'], 'text/css', 'MIME map includes .css'); +assert.strictEqual(config.MIME['.json'], 'application/json', 'MIME map includes .json'); +assert.strictEqual(config.MIME['.svg'], 'image/svg+xml', 'MIME map includes .svg'); + +// GIVEN CE_PORT env is set +const prevPort = process.env.CE_PORT; +process.env.CE_PORT = '9999'; +delete require.cache[require.resolve('../server/lib/config')]; +const config2 = require('../server/lib/config'); +assert.strictEqual(config2.PORT, 9999, 'PORT reads from CE_PORT env var'); +process.env.CE_PORT = prevPort; + +// GIVEN CE_ROOT env is set to a specific path +const prevRoot = process.env.CE_ROOT; +process.env.CE_ROOT = '/tmp/test-ce-root'; +delete require.cache[require.resolve('../server/lib/config')]; +const config3 = require('../server/lib/config'); +assert.strictEqual(config3.ROOT, '/tmp/test-ce-root', 'ROOT reads from CE_ROOT env var'); +assert.strictEqual(config3.DATA_DIR, path.join('/tmp/test-ce-root', 'data'), 'DATA_DIR derives from CE_ROOT'); +process.env.CE_ROOT = prevRoot; + +console.log('config smoke ok'); diff --git a/scripts/http-smoke.js b/scripts/http-smoke.js new file mode 100644 index 0000000..adf8a1f --- /dev/null +++ b/scripts/http-smoke.js @@ -0,0 +1,138 @@ +const assert = require('assert'); +const http = require('http'); + +const { cors, body, json } = require('../server/lib/http'); + +// ---- cors ---- +// GIVEN a request from the allowed localhost origin +const allowedReq = new http.IncomingMessage(new (require('net').Socket)()); +allowedReq.headers = { origin: 'http://localhost:3847' }; +const allowedRes = new http.ServerResponse(allowedReq); +allowedRes.setHeader = (name, value) => { + allowedRes._headers = allowedRes._headers || {}; + allowedRes._headers[name] = value; +}; +cors(allowedReq, allowedRes); +assert.strictEqual( + allowedRes._headers['Access-Control-Allow-Origin'], + 'http://localhost:3847', + 'cors sets origin header for localhost', +); +assert.strictEqual( + allowedRes._headers['Access-Control-Allow-Methods'], + 'GET,POST,OPTIONS', + 'cors sets methods header', +); +assert.strictEqual( + allowedRes._headers['Access-Control-Allow-Headers'], + 'Content-Type', + 'cors sets allow-headers', +); + +// GIVEN a request from 127.0.0.1 origin +const loopbackReq = new http.IncomingMessage(new (require('net').Socket)()); +loopbackReq.headers = { origin: 'http://127.0.0.1:3847' }; +const loopbackRes = new http.ServerResponse(loopbackReq); +loopbackRes.setHeader = (name, value) => { + loopbackRes._headers = loopbackRes._headers || {}; + loopbackRes._headers[name] = value; +}; +cors(loopbackReq, loopbackRes); +assert.strictEqual( + loopbackRes._headers['Access-Control-Allow-Origin'], + 'http://127.0.0.1:3847', + 'cors sets origin for 127.0.0.1', +); + +// GIVEN a request from a disallowed origin +const badReq = new http.IncomingMessage(new (require('net').Socket)()); +badReq.headers = { origin: 'http://evil.example.com' }; +const badRes = new http.ServerResponse(badReq); +badRes.setHeader = () => {}; +cors(badReq, badRes); +assert.strictEqual( + badRes._headers?.Access || badRes._headers?.['access-control-allow-origin'], + undefined, + 'cors does NOT set origin for disallowed host', +); + +// ---- json ---- +// GIVEN a response object +const jsonReq = new http.IncomingMessage(new (require('net').Socket)()); +const jsonRes = new http.ServerResponse(jsonReq); +let writtenHead = null; +let writtenBody = ''; +jsonRes.writeHead = (status, headers) => { + writtenHead = { status, headers }; +}; +jsonRes.end = (data) => { + writtenBody = data; +}; +json(jsonRes, { foo: 'bar' }); +assert.strictEqual(writtenHead?.status, 200, 'json defaults to status 200'); +assert.strictEqual(writtenHead?.headers['Content-Type'], 'application/json', 'json sets Content-Type'); +assert.strictEqual(writtenBody, '{"foo":"bar"}', 'json stringifies body'); + +// GIVEN a custom status code +const statusRes = new http.ServerResponse(new http.IncomingMessage(new (require('net').Socket)())); +let statusHead = null; +statusRes.writeHead = (status, headers) => { + statusHead = { status, headers }; +}; +statusRes.end = () => {}; +json(statusRes, { err: 'nope' }, 404); +assert.strictEqual(statusHead?.status, 404, 'json uses custom status code'); + +void (async () => { + // ---- body (JSON parse) ---- + // GIVEN a request with valid JSON body + const bodyReq = new http.IncomingMessage(new (require('net').Socket)()); + bodyReq.headers = { 'content-type': 'application/json' }; + const bodyPromise = body(bodyReq); + bodyReq.emit('data', '{"key":"value"}'); + bodyReq.emit('end'); + const bodyData = await bodyPromise; + assert.strictEqual(bodyData.key, 'value', 'body parses valid JSON'); + + // GIVEN empty body + const emptyReq = new http.IncomingMessage(new (require('net').Socket)()); + emptyReq.headers = { 'content-type': 'application/json' }; + const emptyPromise = body(emptyReq); + emptyReq.emit('end'); + const emptyData = await emptyPromise; + assert.deepStrictEqual(emptyData, {}, 'body returns empty object for empty body'); + + // GIVEN invalid JSON body + const badBodyReq = new http.IncomingMessage(new (require('net').Socket)()); + badBodyReq.headers = { 'content-type': 'application/json' }; + const badBodyPromise = body(badBodyReq); + badBodyReq.emit('data', 'NOT JSON {{{'); + badBodyReq.emit('end'); + const badBodyData = await badBodyPromise; + assert.strictEqual(badBodyData._parseError, true, 'body sets _parseError for invalid JSON'); + + // GIVEN non-JSON content type + const nonJsonReq = new http.IncomingMessage(new (require('net').Socket)()); + nonJsonReq.headers = { 'content-type': 'text/plain' }; + nonJsonReq.resume = () => {}; + const nonJsonPromise = body(nonJsonReq); + const nonJsonData = await nonJsonPromise; + assert.strictEqual(nonJsonData._parseError, true, 'body sets _parseError for non-json content type'); + assert.strictEqual(nonJsonData._contentType, 'text/plain', 'body includes original content type'); + + // GIVEN oversized body + const bigReq = new http.IncomingMessage(new (require('net').Socket)()); + bigReq.headers = { 'content-type': 'application/json' }; + bigReq.destroy = () => {}; + const bigPromise = body(bigReq); + bigReq.emit('data', 'x'.repeat(1024 * 1024 + 1)); + try { + await bigPromise; + assert.fail('expected oversized body to reject'); + } catch (e) { + assert(e instanceof Error); + assert.strictEqual(e.message, 'Payload too large', 'body rejects oversized payload'); + } + + console.log('http smoke ok'); +})(); diff --git a/scripts/onboarding-smoke.js b/scripts/onboarding-smoke.js new file mode 100644 index 0000000..435d464 --- /dev/null +++ b/scripts/onboarding-smoke.js @@ -0,0 +1,113 @@ +// @ts-check + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ce-onboarding-')); +process.env.CE_ROOT = tmpRoot; +const dataDir = path.join(tmpRoot, 'data'); +const skillsDir = path.join(tmpRoot, 'skills'); +fs.mkdirSync(dataDir, { recursive: true }); +fs.mkdirSync(skillsDir, { recursive: true }); + +delete require.cache[require.resolve('../server/lib/config')]; +delete require.cache[require.resolve('../server/lib/backup')]; +delete require.cache[require.resolve('../server/lib/skills')]; +delete require.cache[require.resolve('../server/lib/skill-sources')]; +delete require.cache[require.resolve('../server/lib/vectorstore')]; +delete require.cache[require.resolve('../server/lib/mcp-host-config')]; +delete require.cache[require.resolve('../server/lib/onboarding')]; + +const onboarding = require('../server/lib/onboarding'); +const { writeData } = require('../server/lib/backup'); + +// ---- Setup minimal skills ---- +const skillDir = path.join(skillsDir, 'test-skill'); +fs.mkdirSync(skillDir, { recursive: true }); +fs.writeFileSync( + path.join(skillDir, 'SKILL.md'), + `--- +name: Test Skill +--- +# Test Skill +A skill for onboarding tests. +`, + 'utf8', +); + +// Setup skill-states +writeData('skill-states.json', { + states: { 'test-skill': true }, + version: '1.0', + last_updated: new Date().toISOString(), +}); +writeData('memory.json', { entries: [{ content: 'Test memory entry' }] }); + +// ---- getOnboardingSummary ---- +// GIVEN a new root with no onboarding state +const summary = onboarding.getOnboardingSummary(); +assert.strictEqual(typeof summary.shouldShow, 'boolean', 'summary has shouldShow'); +assert(summary.state, 'summary has state'); +assert(summary.context, 'summary has context'); +assert.strictEqual(typeof summary.context.totalSkills, 'number', 'context has totalSkills'); +assert.strictEqual(typeof summary.context.activeSkills, 'number', 'context has activeSkills'); +assert.strictEqual(typeof summary.context.memoryEntries, 'number', 'context has memoryEntries'); +assert(summary.hosts, 'summary has hosts'); +assert(summary.tools, 'summary has tools'); + +// GIVEN no session history → should show onboarding +assert.strictEqual(summary.shouldShow, true, 'shouldShow = true with no session history'); + +// ---- completeOnboarding ---- +// GIVEN onboarding not yet completed +const completed = onboarding.completeOnboarding(); +assert.strictEqual(completed.ok, true, 'completeOnboarding succeeds'); +assert(completed.state, 'completed returns state'); +assert(completed.state.completedAt, 'completed has completedAt timestamp'); + +// WHEN checked after completion +const summary2 = onboarding.getOnboardingSummary(); +assert.strictEqual(summary2.shouldShow, false, 'shouldShow = false after completion'); + +// ---- resetOnboarding ---- +// GIVEN onboarding was completed previously +const reset = onboarding.resetOnboarding(); +assert.strictEqual(reset.ok, true, 'resetOnboarding succeeds'); +assert.strictEqual(reset.state.show, true, 'reset state has show = true'); + +const summary3 = onboarding.getOnboardingSummary(); +assert.strictEqual(summary3.shouldShow, true, 'shouldShow = true after reset'); + +// ---- CE_NEW_USER_PROFILE env var ---- +// CE_NEW_USER_PROFILE only applies when there is no existing completedAt state. +// It does NOT override completion. Verify this behavior. +delete require.cache[require.resolve('../server/lib/onboarding')]; +const prevNewUser = process.env.CE_NEW_USER_PROFILE; + +// GIVEN a clean root with no existing state and CE_NEW_USER_PROFILE=1 +process.env.CE_NEW_USER_PROFILE = '1'; +const freshRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ce-onboarding-fresh-')); +process.env.CE_ROOT = freshRoot; +fs.mkdirSync(path.join(freshRoot, 'data'), { recursive: true }); +fs.mkdirSync(path.join(freshRoot, 'skills'), { recursive: true }); +delete require.cache[require.resolve('../server/lib/config')]; +delete require.cache[require.resolve('../server/lib/backup')]; +delete require.cache[require.resolve('../server/lib/onboarding')]; +const freshOnboarding = require('../server/lib/onboarding'); +const freshSummary = freshOnboarding.getOnboardingSummary(); +assert.strictEqual( + freshSummary.shouldShow, + true, + 'shouldShow = true when CE_NEW_USER_PROFILE=1 with no state', +); +fs.rmSync(freshRoot, { recursive: true, force: true }); + +process.env.CE_NEW_USER_PROFILE = prevNewUser; +process.env.CE_ROOT = tmpRoot; + +// ---- Cleanup ---- +fs.rmSync(tmpRoot, { recursive: true, force: true }); + +console.log('onboarding smoke ok'); diff --git a/scripts/rule-files-smoke.js b/scripts/rule-files-smoke.js new file mode 100644 index 0000000..81719a5 --- /dev/null +++ b/scripts/rule-files-smoke.js @@ -0,0 +1,116 @@ +const assert = require('assert'); + +// The rule-files module hard-codes its RULES_DIR as path.join(__dirname, '..', '..', 'data', 'rules'). +// We need to manipulate module to use a temp dir or just be tolerant of existing files. +// Strategy: first scan whatever is there, create+test+delete, and verify only our files. + +const ruleFiles = require('../server/lib/rule-files'); + +// Record pre-existing files so we can filter them +const preExisting = new Set(ruleFiles.listRuleFiles().map((f) => f.name)); + +// ---- writeRuleFile ---- +// GIVEN valid name and data +const rulesData = { + coding: { hard: 'use tabs', soft: 'prefer const' }, + general: { hard: 'no secrets in code', soft: 'write tests' }, + soul: { soft: 'be concise' }, +}; +const writeResult = ruleFiles.writeRuleFile('__ce_test_my_rules', rulesData); +assert.strictEqual(writeResult.ok, true, 'writeRuleFile succeeds'); +assert.strictEqual(writeResult.name, '__ce_test_my_rules', 'writeRuleFile returns sanitized name'); + +// GIVEN the file now appears in the list (filtering pre-existing) +const files = ruleFiles.listRuleFiles(); +const newFiles = files.filter((f) => !preExisting.has(f.name) || f.name === '__ce_test_my_rules'); +const testFile = newFiles.find((f) => f.name === '__ce_test_my_rules'); +assert(testFile, 'test file appears in list'); +assert(testFile.created !== null, 'file has creation timestamp'); +assert.deepStrictEqual(testFile.data, rulesData, 'list includes file data'); + +// GIVEN a name with special characters +const sanitized = ruleFiles.writeRuleFile(' My Special Rules!!! ', rulesData); +assert.strictEqual(sanitized.name, 'my-special-rules', 'name is sanitized (lowercase, hyphens)'); +preExisting.add('my-special-rules'); + +// GIVEN name entirely composed of invalid characters +const invalid = ruleFiles.writeRuleFile('!!!', rulesData); +assert.strictEqual(invalid.ok, false, 'writeRuleFile rejects all-invalid name'); +assert(invalid.error, 'has error message'); + +// GIVEN an empty/whitespace name +const emptyWrite = ruleFiles.writeRuleFile(' ', rulesData); +assert.strictEqual(emptyWrite.ok, false, 'writeRuleFile rejects empty name'); + +// ---- readRuleFile ---- +// GIVEN an existing file +const data = ruleFiles.readRuleFile('__ce_test_my_rules'); +assert.deepStrictEqual(data, rulesData, 'readRuleFile returns stored data'); + +// GIVEN a non-existent file +assert.strictEqual( + ruleFiles.readRuleFile('__ce_test_no_such_file'), + null, + 'readRuleFile returns null for missing file', +); + +// ---- deleteRuleFile ---- +// GIVEN deleting a non-existent file +const delMissing = ruleFiles.deleteRuleFile('__ce_test_no_such_file'); +assert.strictEqual(delMissing.ok, false, 'deleteRuleFile fails for missing file'); +assert.strictEqual(delMissing.error, 'Rule file not found'); + +// GIVEN delete with invalid name +const delInvalid = ruleFiles.deleteRuleFile('!!!'); +assert.strictEqual(delInvalid.ok, false, 'deleteRuleFile rejects invalid name'); + +// ---- combineRuleFiles ---- +// GIVEN multiple rule files +ruleFiles.writeRuleFile('__ce_test_rules_a', { + coding: { hard: 'indent with tabs', soft: '' }, + general: { hard: '', soft: 'be helpful' }, + soul: { soft: 'friendly' }, +}); +ruleFiles.writeRuleFile('__ce_test_rules_b', { + coding: { hard: '', soft: 'use typescript' }, + general: { hard: 'no console.log', soft: '' }, + soul: { soft: 'concise' }, +}); +const combined = ruleFiles.combineRuleFiles(['__ce_test_rules_a', '__ce_test_rules_b']); +assert.strictEqual(combined.coding.hard, 'indent with tabs', 'combine coding.hard'); +assert.strictEqual(combined.coding.soft, 'use typescript', 'combine coding.soft'); +assert.strictEqual(combined.general.hard, 'no console.log', 'combine general.hard'); +assert.strictEqual(combined.general.soft, 'be helpful', 'combine general.soft'); +assert.strictEqual(combined.soul.soft, 'friendly\nconcise', 'combine soul.soft joins'); + +// GIVEN a file with string-format sections (legacy) +ruleFiles.writeRuleFile('__ce_test_legacy_str', { + coding: 'legacy coding rule', + general: 'legacy general rule', + soul: 'legacy soul', +}); +const combinedLegacy = ruleFiles.combineRuleFiles(['__ce_test_legacy_str']); +assert.strictEqual(combinedLegacy.coding.soft, 'legacy coding rule', 'legacy string section → soft priority'); +assert.strictEqual(combinedLegacy.general.soft, 'legacy general rule', 'legacy general string → soft'); +assert.strictEqual(combinedLegacy.soul.soft, 'legacy soul', 'legacy soul string → soft'); + +// ---- Cleanup our test files only ---- +const testFilesToClean = [ + '__ce_test_my_rules', + 'my-special-rules', + '__ce_test_rules_a', + '__ce_test_rules_b', + '__ce_test_legacy_str', +]; +for (const name of testFilesToClean) { + ruleFiles.deleteRuleFile(name); +} + +// Verify test files are gone +const afterClean = ruleFiles.listRuleFiles().filter((f) => !preExisting.has(f.name)); +const afterNames = new Set(afterClean.map((f) => f.name)); +for (const name of testFilesToClean) { + assert(!afterNames.has(name), `${name} cleaned up`); +} + +console.log('rule-files smoke ok'); diff --git a/scripts/skills-smoke.js b/scripts/skills-smoke.js new file mode 100644 index 0000000..676e767 --- /dev/null +++ b/scripts/skills-smoke.js @@ -0,0 +1,136 @@ +// @ts-check + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ce-skills-')); +process.env.CE_ROOT = tmpRoot; +const skillsDir = path.join(tmpRoot, 'skills'); +const dataDir = path.join(tmpRoot, 'data'); +fs.mkdirSync(skillsDir, { recursive: true }); +fs.mkdirSync(dataDir, { recursive: true }); + +delete require.cache[require.resolve('../server/lib/config')]; +delete require.cache[require.resolve('../server/lib/skills')]; +delete require.cache[require.resolve('../server/lib/skill-sources')]; + +const skills = require('../server/lib/skills'); + +// ---- Setup fixtures ---- +const skillDir1 = path.join(skillsDir, 'alpha'); +fs.mkdirSync(skillDir1, { recursive: true }); +fs.writeFileSync( + path.join(skillDir1, 'SKILL.md'), + `--- +name: Alpha Skill +description: First skill for testing +--- +# Alpha Skill + +Alpha skill body content. + +## Triggers +- test alpha +- trigger alpha +- "/alpha-command" +- "use alpha for this" +`, + 'utf8', +); + +const skillDir2 = path.join(skillsDir, 'beta'); +fs.mkdirSync(skillDir2, { recursive: true }); +fs.writeFileSync( + path.join(skillDir2, 'SKILL.md'), + `--- +name: Beta Skill +custom_field: custom_value +--- +# Beta Skill + +Beta skill body content. + +## Usage +Instructions for beta usage. +`, + 'utf8', +); + +// ---- scanSkills ---- +skills.invalidateSkillCache(); +const scanned = skills.scanSkills(); +const scannedIds = Object.keys(scanned); +assert(scannedIds.includes('alpha'), 'scanned includes alpha'); +assert(scannedIds.includes('beta'), 'scanned includes beta'); + +const alpha = scanned.alpha; +assert.strictEqual(alpha.id, 'alpha'); +assert.strictEqual(alpha.name, 'Alpha Skill'); +assert.strictEqual(alpha.desc, 'First skill for testing'); +assert.strictEqual(alpha.sourceId, 'internal'); +assert(Array.isArray(alpha.triggers), 'alpha has triggers'); +assert(alpha.triggers.includes('test alpha'), 'alpha triggers include list item'); +assert(alpha.triggers.includes('trigger alpha'), 'alpha triggers include second item'); +assert( + alpha.triggers.includes('"/alpha-command"'), + 'alpha triggers include slash command from triggers section', +); +assert(alpha.triggers.includes('"use alpha for this"'), 'alpha triggers include quoted phrase'); + +const beta = scanned.beta; +assert.strictEqual(beta.name, 'Beta Skill'); + +// GIVEN cache TTL +const cached = skills.scanSkills(); +assert.strictEqual(cached, scanned, 'scanSkills returns cached result within TTL'); + +// ---- invalidateSkillCache ---- +skills.invalidateSkillCache(); +const refreshed = skills.scanSkills(); +assert(refreshed !== scanned || Object.keys(refreshed).length > 0, 'invalidate forces re-scan'); + +// ---- skillHealthCheck ---- +const health = skills.skillHealthCheck(); +assert(Array.isArray(health), 'healthCheck returns array'); +assert(health.length >= 2, 'healthCheck has 2+ entries'); +for (const entry of health) { + assert(typeof entry.id === 'string', 'health entry has id'); + assert(typeof entry.exists === 'boolean', 'health entry has exists flag'); +} + +// ---- countSkillFiles ---- +const count = skills.countSkillFiles(skillsDir); +assert.strictEqual(count, 2, 'countSkillFiles finds 2 SKILL.md files'); + +// ---- pruneDuplicateSkillDirs ---- +const importDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ce-skills-import-')); +fs.mkdirSync(path.join(importDir, 'alpha'), { recursive: true }); +fs.writeFileSync(path.join(importDir, 'alpha', 'SKILL.md'), '---\n---\n# Alpha\n', 'utf8'); +const pruned = skills.pruneDuplicateSkillDirs(importDir); +assert(pruned.removed.length >= 1, 'duplicate alpha in import removed'); +assert(pruned.removed[0].reason === 'already exists', 'reason is already exists'); +fs.rmSync(importDir, { recursive: true, force: true }); + +// ---- organiseSkills (dry-run) ---- +const organise = skills.organiseSkills({ apply: false }); +assert.strictEqual(organise.ok, true, 'organise succeeds'); +assert(Array.isArray(organise.actions), 'organise returns actions array'); +assert(typeof organise.summary === 'object', 'organise has summary'); + +// ---- Parse cache ---- +const cache = skills.loadParseCache(); +assert(typeof cache === 'object', 'loadParseCache returns object'); +skills.saveParseCache({ 'test-id': { description: 'cached', triggers: [], parsedAt: Date.now() } }); +const reloaded = skills.loadParseCache(); +assert.strictEqual(reloaded['test-id']?.description, 'cached', 'parse cache persisted and reloaded'); + +// GIVEN the parse cache is cleaned up +skills.saveParseCache({}); +assert.deepStrictEqual(skills.loadParseCache(), {}, 'parse cache cleared'); + +// ---- Cleanup ---- +fs.rmSync(tmpRoot, { recursive: true, force: true }); + +console.log('skills smoke ok'); diff --git a/scripts/tool-detection-smoke.js b/scripts/tool-detection-smoke.js new file mode 100644 index 0000000..5f93d88 --- /dev/null +++ b/scripts/tool-detection-smoke.js @@ -0,0 +1,164 @@ +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { detectTools } = require('../server/lib/tool-detection'); +const { ADAPTERS } = require('../server/compiler'); + +// GIVEN no scan/buildContext/adapters — basic detection only (path-based) +const homedir = os.homedir(); +const result = detectTools(homedir, {}); + +// GIVEN all tools appear in the result +assert(typeof result === 'object', 'detectTools returns object'); +const ids = Object.keys(result); +assert(ids.length >= 22, 'detectTools returns entries for all registered tools'); + +// GIVEN each tool has the expected shape +for (const [id, tool] of Object.entries(result)) { + assert.strictEqual(tool.id, id, `${id}: id matches`); + assert(typeof tool.label === 'string', `${id}: has label`); + assert(typeof tool.installed === 'boolean', `${id}: has installed flag`); + assert(Array.isArray(tool.signals), `${id}: has signals array`); + assert(typeof tool.supportsGlobal === 'boolean', `${id}: has supportsGlobal`); + assert(typeof tool.supportsProject === 'boolean', `${id}: has supportsProject`); + assert(typeof tool.category === 'string', `${id}: has category`); + assert(typeof tool.detected === 'boolean', `${id}: has detected flag`); + assert(typeof tool.available === 'boolean', `${id}: has available flag`); + assert(typeof tool.status === 'string', `${id}: has status`); + assert(typeof tool.outputReady === 'boolean', `${id}: has outputReady`); + assert(typeof tool.projectReady === 'boolean', `${id}: has projectReady`); + assert(typeof tool.globalReady === 'boolean', `${id}: has globalReady`); +} + +// GIVEN tools with no adapter are marked as missing-adapter +// (when called without adapters) +for (const tool of Object.values(result)) { + assert.strictEqual(tool.adapterReady, false, `${tool.id}: adapterReady is false without adapters`); + assert.strictEqual( + tool.status, + 'missing-adapter', + `${tool.id}: status is missing-adapter without adapters`, + ); +} + +// GIVEN detection with a known path (use a tmp dir with .claude marker) +const testHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ce-tool-detect-')); +const claudeDir = path.join(testHomeDir, '.claude'); +fs.mkdirSync(claudeDir, { recursive: true }); +const result2 = detectTools(testHomeDir, {}); +assert.strictEqual(result2.claude.installed, true, 'claude detected from .claude directory'); +assert(result2.claude.signals.includes('.claude'), '.claude in signals'); + +// GIVEN detecting tools via an existing global config file +const configDir = path.join(testHomeDir, '.cursor'); +fs.mkdirSync(configDir, { recursive: true }); +const result3 = detectTools(testHomeDir, {}); +assert.strictEqual(result3.cursor.installed, true, 'cursor detected from .cursor directory'); + +// GIVEN tool with globalPath that exists in the user's home +// We can't guarantee a real file exists (e.g., CLAUDE.md, .windsurfrules), +// but we verify the globalPath field is computed correctly. +const globalTools = ['windsurf', 'antigravity', 'cline', 'continue', 'codex', 'junie', 'trae', 'augment']; +for (const id of globalTools) { + const tool = result[id]; + assert(tool.globalPath, `${id}: has globalPath`); + assert(path.isAbsolute(tool.globalPath), `${id}: globalPath is absolute`); +} + +// GIVEN tools that do NOT support global +const noGlobalTools = [ + 'cursor', + 'agents', + 'copilot', + 'kiro', + 'aider', + 'zed', + 'amp', + 'devin', + 'void', + 'pearai', + 'ollama', + 'kimi', +]; +for (const id of noGlobalTools) { + assert.strictEqual(result[id].globalPath, null, `${id}: globalPath is null (no global support)`); +} + +// GIVEN manual category tools are always available +assert.strictEqual(result.kimi.available, true, 'kimi is always available (manual)'); + +// GIVEN tools that require detection have detectionRequired set +assert.strictEqual(result.claude.detectionRequired, true, 'claude requires detection'); +assert.strictEqual(result.agents.detectionRequired, false, 'agents does not require detection'); + +// ---- detectTools with adapters ---- +// GIVEN adapters are provided +const result4 = detectTools(testHomeDir, { adapters: ADAPTERS }); +for (const [id, tool] of Object.entries(result4)) { + if (ADAPTERS[id]) { + assert.strictEqual(tool.adapterReady, true, `${id}: adapterReady when adapter present`); + } +} + +// GIVEN detection with full context building +const testDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ce-tool-detect-data-')); +const testSkillsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ce-tool-detect-skills-')); + +// Set up minimal fixtures +fs.writeFileSync( + path.join(testDataDir, 'memory.json'), + JSON.stringify({ entries: [{ content: 'test memory' }] }), + 'utf8', +); +fs.writeFileSync( + path.join(testDataDir, 'rules.json'), + JSON.stringify({ + coding: { hard: 'test rule', soft: '' }, + general: { hard: '', soft: '' }, + soul: { soft: '' }, + }), + 'utf8', +); +fs.writeFileSync(path.join(testDataDir, 'skill-states.json'), JSON.stringify({}), 'utf8'); + +// Create a minimal skill +const skillDir = path.join(testSkillsDir, 'test-skill'); +fs.mkdirSync(skillDir, { recursive: true }); +fs.writeFileSync( + path.join(skillDir, 'SKILL.md'), + '---\nname: Test Skill\n---\n# Test Skill\nA test skill.\n', + 'utf8', +); + +const { buildContext, estimateTokens } = require('../server/compiler'); +const fullScan = () => { + const { scanSkills } = require('../server/lib/skills'); + return scanSkills(); +}; +const result5 = detectTools(testHomeDir, { + dataDir: testDataDir, + skillsDir: testSkillsDir, + scanSkills: fullScan, + adapters: ADAPTERS, + buildContext, + estimateTokens, +}); + +// With full context, bespoke adapters should be compile-ready +for (const id of ['claude', 'cursor', 'agents', 'codex', 'copilot', 'windsurf', 'ollama']) { + const tool = result5[id]; + assert.strictEqual(tool.compileReady, true, `${id}: compileReady with full context`); + assert(tool.previewTokens !== null && tool.previewTokens > 0, `${id}: has previewTokens > 0`); +} + +// GIVEN tools that have compileReady and supportProject are projectReady +assert.strictEqual(result5.agents.projectReady, true, 'agents: projectReady when fileStandard'); + +// Cleanup +fs.rmSync(testHomeDir, { recursive: true, force: true }); +fs.rmSync(testDataDir, { recursive: true, force: true }); +fs.rmSync(testSkillsDir, { recursive: true, force: true }); + +console.log('tool-detection smoke ok'); diff --git a/scripts/tool-registry-smoke.js b/scripts/tool-registry-smoke.js new file mode 100644 index 0000000..b755ce1 --- /dev/null +++ b/scripts/tool-registry-smoke.js @@ -0,0 +1,70 @@ +const assert = require('assert'); + +const { TOOL_REGISTRY } = require('../server/lib/tool-registry'); + +// GIVEN the registry has entries for all 22 tools +const ids = Object.keys(TOOL_REGISTRY); +assert(ids.length === 22, 'TOOL_REGISTRY has 22 entries'); + +// GIVEN each entry has the required shape +for (const [id, reg] of Object.entries(TOOL_REGISTRY)) { + assert(typeof reg.label === 'string', `${id}: label is string`); + assert(typeof reg.description === 'string', `${id}: description is string`); + assert(Array.isArray(reg.detectPaths), `${id}: detectPaths is array`); + assert(typeof reg.supportsGlobal === 'boolean', `${id}: supportsGlobal is boolean`); + assert(typeof reg.supportsProject === 'boolean', `${id}: supportsProject is boolean`); + assert(['auto', 'manual'].includes(reg.category), `${id}: category is auto or manual`); +} + +// ---- Bespoke adapter tools (compiler has hard-coded functions for these) ---- +const bespokeIds = ['claude', 'cursor', 'agents', 'codex', 'copilot', 'windsurf', 'ollama']; +for (const id of bespokeIds) { + assert(TOOL_REGISTRY[id], `bespoke tool ${id} is registered`); +} + +// ---- Generic config tools (use GENERIC_CONFIGS) ---- +const genericIds = [ + 'antigravity', + 'kiro', + 'cline', + 'aider', + 'continue', + 'zed', + 'junie', + 'trae', + 'amp', + 'devin', + 'goose', + 'void', + 'augment', + 'pearai', + 'kimi', +]; +for (const id of genericIds) { + assert(TOOL_REGISTRY[id], `generic tool ${id} is registered`); +} + +// ---- Claude has expected properties ---- +assert.strictEqual(TOOL_REGISTRY.claude.label, 'Claude Code'); +assert.strictEqual(TOOL_REGISTRY.claude.globalPath, 'CLAUDE.md'); +assert.strictEqual(TOOL_REGISTRY.claude.supportsGlobal, true); +assert.strictEqual(TOOL_REGISTRY.claude.supportsProject, true); + +// ---- Cursor has expected properties ---- +assert.strictEqual(TOOL_REGISTRY.cursor.label, 'Cursor'); +assert.strictEqual(TOOL_REGISTRY.cursor.globalPath, null); +assert.strictEqual(TOOL_REGISTRY.cursor.supportsGlobal, false); + +// ---- AGENTS.md has expected properties ---- +assert.strictEqual(TOOL_REGISTRY.agents.label, 'AGENTS.md (AAIF)'); +assert(TOOL_REGISTRY.agents.detectPaths.length === 0, 'agents has no detect paths (file standard)'); + +// ---- Kimi is manual-only ---- +assert.strictEqual(TOOL_REGISTRY.kimi.category, 'manual'); +assert.strictEqual(TOOL_REGISTRY.kimi.supportsGlobal, false); +assert.strictEqual(TOOL_REGISTRY.kimi.supportsProject, false); + +// ---- Codex uses AGENTS.md format ---- +assert.strictEqual(TOOL_REGISTRY.codex.globalPath, '.codex/instructions.md'); + +console.log('tool-registry smoke ok'); diff --git a/scripts/validation-smoke.js b/scripts/validation-smoke.js index 5cde014..488305d 100644 --- a/scripts/validation-smoke.js +++ b/scripts/validation-smoke.js @@ -1,6 +1,6 @@ // @ts-check -// validation-smoke.js — Smoke test for request body validators +// validation-smoke.js ├ö├ç├ Smoke test for request body validators const assert = require('assert'); const { validateMemory, validateRules, validateStates } = require('../server/lib/validation'); diff --git a/server/lib/backup.js b/server/lib/backup.js index 2971fb3..13eee8f 100644 --- a/server/lib/backup.js +++ b/server/lib/backup.js @@ -47,6 +47,7 @@ function listBackups() { /** @param {string} ts */ function restoreBackup(ts) { + if (typeof ts !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}(\.\d+)?$/.test(ts)) return false; const dir = path.join(BACKUPS_DIR, ts); if (!fs.existsSync(dir)) return false; ['memory.json', 'rules.json', 'skill-states.json'].forEach((f) => { diff --git a/server/lib/crypto.js b/server/lib/crypto.js index 0eaedeb..b9fe6d7 100644 --- a/server/lib/crypto.js +++ b/server/lib/crypto.js @@ -90,4 +90,45 @@ function removeApiKey(name) { saveKeys(keys); } -module.exports = { getApiKey, setApiKey, removeApiKey }; +// ---- CE_API_KEY (local API auth) ---- +function generateApiToken() { + return 'ce_' + crypto.randomBytes(24).toString('hex'); +} + +function getAuthToken() { + const envKey = process.env.CE_API_KEY; + if (envKey) return envKey; + const keys = loadKeys(); + const envelope = keys['CE_API_KEY']; + if (envelope) { + try { + return decryptValue(envelope); + } catch { + return null; + } + } + return null; +} + +/** @param {string} token */ +function setAuthToken(token) { + const keys = loadKeys(); + keys['CE_API_KEY'] = encryptValue(token); + saveKeys(keys); +} + +function removeAuthToken() { + const keys = loadKeys(); + delete keys['CE_API_KEY']; + saveKeys(keys); +} + +module.exports = { + getApiKey, + setApiKey, + removeApiKey, + generateApiToken, + getAuthToken, + setAuthToken, + removeAuthToken, +}; diff --git a/server/lib/rule-files.js b/server/lib/rule-files.js new file mode 100644 index 0000000..99b130f --- /dev/null +++ b/server/lib/rule-files.js @@ -0,0 +1,115 @@ +// @ts-check + +// rule-files.js — CRUD for data/rules/*.json rule files. +// Each rule file has the structure: { coding: { hard, soft }, general: { hard, soft }, soul: { soft } } + +const fs = require('fs'); +const path = require('path'); + +const RULES_DIR = path.join(__dirname, '..', '..', 'data', 'rules'); + +/** @typedef {Record>} RuleData */ + +function ensureRulesDir() { + if (!fs.existsSync(RULES_DIR)) fs.mkdirSync(RULES_DIR, { recursive: true }); +} + +function listRuleFiles() { + ensureRulesDir(); + const files = fs.readdirSync(RULES_DIR).filter((f) => f.endsWith('.json')); + const result = []; + for (const f of files) { + const name = f.slice(0, -5); + let stat = null; + try { + stat = fs.statSync(path.join(RULES_DIR, f)); + } catch { + stat = null; + } + let data = null; + try { + data = JSON.parse(fs.readFileSync(path.join(RULES_DIR, f), 'utf8')); + } catch { + data = null; + } + result.push({ name, filename: f, created: stat?.birthtime?.toISOString() || null, data }); + } + return result; +} + +/** @param {string} name @returns {RuleData | null} */ +function readRuleFile(name) { + const safeName = sanitizeName(name); + if (!safeName) return null; + const filePath = path.join(RULES_DIR, `${safeName}.json`); + if (filePath.includes('..')) return null; + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch { + return null; + } +} + +/** @param {string} name @param {RuleData} data @returns {{ ok: boolean, name?: string, error?: string }} */ +function writeRuleFile(name, data) { + ensureRulesDir(); + const safeName = sanitizeName(name); + if (!safeName) return { ok: false, error: 'Invalid rule name' }; + const filePath = path.join(RULES_DIR, `${safeName}.json`); + if (filePath.includes('..')) return { ok: false, error: 'Invalid path' }; + fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8'); + return { ok: true, name: safeName }; +} + +/** @param {string} name @returns {{ ok: boolean, error?: string }} */ +function deleteRuleFile(name) { + const safeName = sanitizeName(name); + if (!safeName) return { ok: false, error: 'Invalid rule name' }; + const filePath = path.join(RULES_DIR, `${safeName}.json`); + if (filePath.includes('..')) return { ok: false, error: 'Invalid path' }; + if (!fs.existsSync(filePath)) return { ok: false, error: 'Rule file not found' }; + fs.unlinkSync(filePath); + return { ok: true }; +} + +/** @param {string} name @returns {string | null} */ +function sanitizeName(name) { + const cleaned = String(name || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); + return cleaned || null; +} + +/** @param {string[]} names @returns {RuleData} */ +function combineRuleFiles(names) { + /** @type {RuleData & { coding: { hard: string, soft: string }, general: { hard: string, soft: string }, soul: { soft: string } }} */ + const combined = { coding: { hard: '', soft: '' }, general: { hard: '', soft: '' }, soul: { soft: '' } }; + for (const name of names) { + const data = readRuleFile(name); + if (!data) continue; + for (const section of /** @type {('coding'|'general')[]} */ (['coding', 'general'])) { + for (const priority of /** @type {('hard'|'soft')[]} */ (['hard', 'soft'])) { + const existing = combined[section][priority] || ''; + const incoming = data[section]?.[priority] || ''; + if (typeof data[section] === 'string') { + if (priority === 'soft') { + combined[section][priority] = [existing, data[section]].filter(Boolean).join('\n'); + } + } else if (incoming) { + combined[section][priority] = [existing, incoming].filter(Boolean).join('\n'); + } + } + } + if (data.soul) { + const existing = combined.soul.soft || ''; + const incoming = typeof data.soul === 'string' ? data.soul : data.soul.soft || ''; + if (incoming) combined.soul.soft = [existing, incoming].filter(Boolean).join('\n'); + } + } + return combined; +} + +module.exports = { listRuleFiles, readRuleFile, writeRuleFile, deleteRuleFile, combineRuleFiles, RULES_DIR }; diff --git a/server/lib/validation.js b/server/lib/validation.js index 5150b70..5820ecb 100644 --- a/server/lib/validation.js +++ b/server/lib/validation.js @@ -1,6 +1,6 @@ // @ts-check -// validation.js — Request body validators for data endpoints +// validation.js ├ö├ç├ Request body validators for data endpoints /** @param {any} data */ function validateMemory(data) { @@ -20,27 +20,20 @@ function validateMemory(data) { function validateRules(data) { if (!data || typeof data !== 'object') return { valid: false, error: 'Must be a JSON object' }; if (data._parseError) return { valid: false, error: 'Invalid JSON in request body' }; - const codingPriorities = ['hard', 'soft']; - const generalPriorities = ['hard', 'soft']; - const soulPriorities = ['soft']; - const sections = [ - { key: 'coding', allowed: codingPriorities }, - { key: 'general', allowed: generalPriorities }, - { key: 'soul', allowed: soulPriorities }, - ]; - for (const { key, allowed } of sections) { + const allowed = /** @type {const} */ ({ + coding: ['hard', 'soft'], + general: ['hard', 'soft'], + soul: ['soft'], + }); + for (const key of /** @type {('coding'|'general'|'soul')[]} */ (['coding', 'general', 'soul'])) { const val = data[key]; if (typeof val === 'string') continue; - if (!val || typeof val !== 'object' || Array.isArray(val)) { + if (!val || typeof val !== 'object' || Array.isArray(val)) return { valid: false, error: `Missing or invalid "${key}" section` }; - } - for (const [pkey, pval] of Object.entries(val)) { - if (!allowed.includes(pkey)) { + for (const pkey of Object.keys(val)) { + if (!allowed[key].includes(/** @type {any} */ (pkey))) return { valid: false, error: `"${key}" does not allow priority "${pkey}"` }; - } - if (typeof pval !== 'string') { - return { valid: false, error: `"${key}.${pkey}" must be a string` }; - } + if (typeof val[pkey] !== 'string') return { valid: false, error: `"${key}.${pkey}" must be a string` }; } } return { valid: true, error: null }; diff --git a/server/router.js b/server/router.js index f64b926..8864a6d 100644 --- a/server/router.js +++ b/server/router.js @@ -1,13 +1,21 @@ -// @ts-nocheck — Path-A backlog: file in tsconfig include, opt out until incremental typing is done. See docs/llm-handoff.md. +// @ts-nocheck ├ö├ç├ Path-A backlog: file in tsconfig include, opt out until incremental typing is done. See docs/llm-handoff.md. -// router.js — API route handlers for Context Engine v3 +// router.js ├ö├ç├ API route handlers for Context Engine v3 const fs = require('fs'); const path = require('path'); const { spawn } = require('child_process'); const { DATA_DIR, SKILLS_DIR, CONTEXT_MD, HOMEDIR, WORKSPACES_FILE } = require('./lib/config'); const { body, json } = require('./lib/http'); -const { getApiKey, setApiKey, removeApiKey } = require('./lib/crypto'); +const { + getApiKey, + setApiKey, + removeApiKey, + generateApiToken, + getAuthToken, + setAuthToken, + removeAuthToken, +} = require('./lib/crypto'); const { validateMemory, validateRules, validateStates } = require('./lib/validation'); const { scanSkills, @@ -58,8 +66,15 @@ const { readManifest: readSkillImportManifest, } = require('./lib/skill-import'); const { markIndexStale } = require('./lib/vectorstore'); +const { + listRuleFiles, + readRuleFile, + writeRuleFile, + deleteRuleFile, + combineRuleFiles, +} = require('./lib/rule-files'); +const { listProjects, getProject, createProject, updateProject, deleteProject } = require('./lib/projects'); 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']); @@ -77,10 +92,26 @@ function cleanupIngestJobs() { async function handleRequest(req, res, url) { const p = url.pathname; + // ---- LOCAL AUTH ---- + // Auth endpoints are exempt from the Bearer token check in server.js + if (p === '/api/auth/status' && req.method === 'GET') { + const token = getAuthToken(); + return json(res, { configured: !!token }); + } + if (p === '/api/auth/generate' && req.method === 'POST') { + const token = generateApiToken(); + setAuthToken(token); + return json(res, { ok: true, token }); + } + if (p === '/api/auth/remove' && req.method === 'POST') { + removeAuthToken(); + return json(res, { ok: true }); + } + // ---- SKILLS ---- if (p === '/api/skills' && req.method === 'GET') return json(res, Object.values(scanSkills())); - // GET /api/skills/:id — full skill record + body, optionally a single section. + // GET /api/skills/:id ├ö├ç├ full skill record + body, optionally a single section. // Used by the MCP bridge so hosts (Claude Desktop, Codex) can fetch a skill // body on demand instead of preloading every active skill into context. if (p.startsWith('/api/skills/') && req.method === 'GET') { @@ -167,7 +198,7 @@ async function handleRequest(req, res, url) { } catch { skillCount = 0; } - // Imported state is derived from manifest presence — the user's source + // Imported state is derived from manifest presence ├ö├ç├ the user's source // record stays `external`; importing is a runtime aspect, not a type. let imported = false; let lastSyncedAt = null; @@ -200,7 +231,7 @@ async function handleRequest(req, res, url) { return json(res, { candidates: scanHostSkillPaths() }); } - // POST /api/skill-sources/:id/import — first-time import (copy/hard-link + // POST /api/skill-sources/:id/import ├ö├ç├ first-time import (copy/hard-link // into /skills/imported//). if (p.startsWith('/api/skill-sources/') && p.endsWith('/import') && req.method === 'POST') { const id = decodeURIComponent(p.slice('/api/skill-sources/'.length, -'/import'.length)); @@ -212,7 +243,7 @@ async function handleRequest(req, res, url) { return json(res, { ok: true, manifest: result.manifest }); } - // GET /api/skill-sources/:id/sync — read-only diff. + // GET /api/skill-sources/:id/sync ├ö├ç├ read-only diff. if (p.startsWith('/api/skill-sources/') && p.endsWith('/sync') && req.method === 'GET') { const id = decodeURIComponent(p.slice('/api/skill-sources/'.length, -'/sync'.length)); if (!id) return json(res, { ok: false, error: 'id is required' }, 400); @@ -221,7 +252,7 @@ async function handleRequest(req, res, url) { return json(res, { ok: true, diff: result.diff, manifest: result.manifest }); } - // POST /api/skill-sources/:id/sync/apply — apply the diff with a mode. + // POST /api/skill-sources/:id/sync/apply ├ö├ç├ apply the diff with a mode. if (p.startsWith('/api/skill-sources/') && p.endsWith('/sync/apply') && req.method === 'POST') { const id = decodeURIComponent(p.slice('/api/skill-sources/'.length, -'/sync/apply'.length)); if (!id) return json(res, { ok: false, error: 'id is required' }, 400); @@ -237,7 +268,7 @@ async function handleRequest(req, res, url) { return json(res, { ok: true, applied: result.applied, manifest: result.manifest }); } - // DELETE /api/skill-sources/:id — must come AFTER more-specific sub-routes. + // DELETE /api/skill-sources/:id ├ö├ç├ must come AFTER more-specific sub-routes. if (p.startsWith('/api/skill-sources/') && req.method === 'DELETE') { const id = decodeURIComponent(p.replace('/api/skill-sources/', '')); if (!id || id === 'scan') return json(res, { ok: false, error: 'id is required' }, 400); @@ -314,7 +345,7 @@ async function handleRequest(req, res, url) { const data = await body(req); let repoUrl = data?.url; if (!repoUrl || typeof repoUrl !== 'string' || !/^https:\/\//i.test(repoUrl)) { - return json(res, { ok: false, error: 'Invalid URL — must be https://' }, 400); + return json(res, { ok: false, error: 'Invalid URL ├ö├ç├ must be https://' }, 400); } repoUrl = repoUrl .replace(/\/tree\/[^/]+.*$/, '') @@ -336,7 +367,7 @@ async function handleRequest(req, res, url) { } const segments = parsedUrl.pathname.split('/').filter(Boolean); if (segments.length < 2) - return json(res, { ok: false, error: 'Invalid repo URL — need owner/repo' }, 400); + return json(res, { ok: false, error: 'Invalid repo URL ├ö├ç├ need owner/repo' }, 400); // Reject anything that could escape the ingested/ directory after slugification. const owner = segments[0]; const repo = segments[1]; @@ -410,6 +441,38 @@ async function handleRequest(req, res, url) { return json(res, { ok: true }); } + // ---- RULE FILES ---- + if (p === '/api/rule-files' && req.method === 'GET') { + return json(res, { ok: true, files: listRuleFiles() }); + } + if (p === '/api/rule-files' && req.method === 'POST') { + const { name, data } = await body(req); + if (!name) return json(res, { ok: false, error: 'Rule name is required' }, 400); + const v = validateRules(data); + if (!v.valid) return json(res, { ok: false, error: v.error }, 400); + const result = writeRuleFile(name, data); + return json(res, result, result.ok ? 200 : 400); + } + if (p.startsWith('/api/rule-files/') && req.method === 'GET') { + const name = decodeURIComponent(p.slice('/api/rule-files/'.length)); + const data = readRuleFile(name); + if (!data) return json(res, { ok: false, error: 'Rule file not found' }, 404); + return json(res, { ok: true, name, data }); + } + if (p.startsWith('/api/rule-files/') && req.method === 'PUT') { + const name = decodeURIComponent(p.slice('/api/rule-files/'.length)); + const data = await body(req); + const v = validateRules(data); + if (!v.valid) return json(res, { ok: false, error: v.error }, 400); + const result = writeRuleFile(name, data); + return json(res, result, result.ok ? 200 : 400); + } + if (p.startsWith('/api/rule-files/') && req.method === 'DELETE') { + const name = decodeURIComponent(p.slice('/api/rule-files/'.length)); + const result = deleteRuleFile(name); + return json(res, result, result.ok ? 200 : 400); + } + // ---- API KEYS ---- if (p === '/api/keys/status' && req.method === 'GET') { return json(res, { ANTHROPIC_API_KEY: !!getApiKey('ANTHROPIC_API_KEY') }); @@ -421,7 +484,7 @@ async function handleRequest(req, res, url) { const allowed = ['ANTHROPIC_API_KEY']; if (!allowed.includes(data.name)) return json(res, { ok: false, error: 'Unknown key name' }, 400); if (data.name === 'ANTHROPIC_API_KEY' && !data.value.startsWith('sk-ant-')) { - return json(res, { ok: false, error: 'Invalid key format — should start with sk-ant-' }, 400); + return json(res, { ok: false, error: 'Invalid key format ├ö├ç├ should start with sk-ant-' }, 400); } setApiKey(data.name, data.value); return json(res, { ok: true }); @@ -513,6 +576,20 @@ async function handleRequest(req, res, url) { } if (p === '/api/restore' && req.method === 'POST') { const { timestamp } = await body(req); + if (!timestamp || typeof timestamp !== 'string') { + return json(res, { ok: false, error: 'timestamp is required' }, 400); + } + const known = listBackups(); + const isKnown = known.some((b) => b.timestamp === timestamp); + if (!isKnown) { + if (!/^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}(\.\d+)?$/.test(timestamp)) { + return json(res, { ok: false, error: 'Invalid backup timestamp format' }, 400); + } + const dir = path.join(DATA_DIR, 'backups', timestamp); + if (!fs.existsSync(dir)) { + return json(res, { ok: false, error: 'Unknown backup timestamp' }, 400); + } + } const ok = restoreBackup(timestamp); if (ok) regenerateCONTEXTmd(); return json(res, { ok }); @@ -769,6 +846,37 @@ async function handleRequest(req, res, url) { const result = deleteProject(slug); return json(res, result, result.ok ? 200 : 404); } + if (p.startsWith('/api/projects/') && p.endsWith('/publish') && req.method === 'POST') { + const slugRaw = p.slice('/api/projects/'.length); + const slug = decodeURIComponent(slugRaw.slice(0, -'/publish'.length)); + const project = getProject(slug); + if (!project) return json(res, { ok: false, error: 'Project not found' }, 404); + if (!project.path) return json(res, { ok: false, error: 'Project has no folder path set' }, 400); + const { ruleNames, targets } = await body(req); + if (!Array.isArray(ruleNames) || !ruleNames.length) { + return json(res, { ok: false, error: 'Select at least one rule' }, 400); + } + if (!Array.isArray(targets) || !targets.length) { + return json(res, { ok: false, error: 'Select at least one target format' }, 400); + } + const denyReason = checkSafeWritePath(project.path); + if (denyReason) return json(res, { ok: false, error: denyReason }, 400); + const combined = combineRuleFiles(ruleNames); + try { + const result = compile({ + dataDir: DATA_DIR, + skillsDir: SKILLS_DIR, + scanSkills, + targets, + outputDir: project.path, + rulesOverride: combined, + }); + appendSession({ type: 'project_publish', slug, ruleNames, targets }); + return json(res, { ok: true, ...result }); + } catch (e) { + return json(res, { ok: false, error: e.message }, 500); + } + } return null; // Not an API route } diff --git a/server/server.js b/server/server.js index 17a2fde..c802e3b 100644 --- a/server/server.js +++ b/server/server.js @@ -9,6 +9,7 @@ const { cors, json } = require('./lib/http'); const { handleRequest } = require('./router'); const { regenerateCONTEXTmd } = require('./lib/modes'); const { isLocalRequest, SECURITY_HEADERS } = require('./lib/security'); +const { getAuthToken } = require('./lib/crypto'); /** * @returns {import('http').Server} @@ -32,6 +33,18 @@ async function handleHttpRequest(req, res) { } const url = new URL(req.url || '/', `http://localhost:${PORT}`); + // Local API auth: if a CE_API_KEY is configured, require a Bearer token + // on every API request. Static UI files and auth endpoints are exempt. + const token = getAuthToken(); + if (token && url.pathname.startsWith('/api/') && !url.pathname.startsWith('/api/auth/')) { + const authHeader = String(req.headers.authorization || ''); + const provided = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : ''; + if (provided !== token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + return res.end(JSON.stringify({ ok: false, error: 'Unauthorized: invalid or missing API token' })); + } + } + try { const handled = await handleRequest(req, res, url); if (handled !== null) return; diff --git a/ui/config.js b/ui/config.js index d16ddfd..87bd1ae 100644 --- a/ui/config.js +++ b/ui/config.js @@ -5,9 +5,9 @@ const ConfigTab = (() => { // Priority sections per rule category (must match RulesLab.PRIORITY_SECTIONS) const PRIORITY_SECTIONS = { - coding: ['hard', 'preference', 'style'], - general: ['hard', 'preference', 'style'], - soul: ['preference'], + coding: ['hard', 'soft'], + general: ['hard', 'soft'], + soul: ['soft'], }; /** Get all textarea IDs for a given section key */ @@ -191,7 +191,87 @@ const ConfigTab = (() => { }); if (typeof RulesLab !== 'undefined') RulesLab.init(); loadKeyStatus(); + loadAuthStatus(); } - return { init, save, reset, saveApiKey, removeApiKey, toggleKeyVisibility, updateRuleMetrics }; + async function loadAuthStatus() { + try { + const res = await fetch('/api/auth/status'); + const data = await res.json(); + updateAuthUI(data.configured); + } catch { + updateAuthUI(false); + } + } + + function updateAuthUI(configured) { + const statusEl = document.getElementById('ce-auth-status'); + const warningEl = document.getElementById('ce-auth-warning'); + const detailEl = document.getElementById('ce-auth-detail'); + const genBtn = document.getElementById('ce-auth-generate-btn'); + const removeBtn = document.getElementById('ce-auth-remove-btn'); + if (statusEl) + statusEl.textContent = configured ? 'Auth active: local API key configured' : 'Auth not configured'; + if (warningEl) warningEl.hidden = !configured; + if (detailEl) detailEl.hidden = configured; + if (genBtn) genBtn.hidden = configured; + if (removeBtn) removeBtn.hidden = !configured; + } + + async function generateAuthToken() { + try { + const res = await fetch('/api/auth/generate', { method: 'POST' }); + const data = await res.json(); + if (data.ok && data.token) { + localStorage.setItem('ce_auth_token', data.token); + const detailEl = document.getElementById('ce-auth-detail'); + const codeEl = document.getElementById('ce-auth-token-display'); + if (detailEl) detailEl.hidden = false; + if (codeEl) codeEl.textContent = data.token; + updateAuthUI(true); + Toast.success('API access token generated'); + } else { + Toast.error('Failed to generate token'); + } + } catch { + Toast.error('Failed to generate token'); + } + } + + async function removeAuthToken() { + const ok = await AppDialog.confirm({ + title: 'Remove API access token', + message: 'Any local process will be able to access the API without authentication.', + confirmText: 'Remove token', + danger: true, + }); + if (!ok) return; + try { + const res = await fetch('/api/auth/remove', { method: 'POST' }); + const data = await res.json(); + if (data.ok) { + localStorage.removeItem('ce_auth_token'); + const detailEl = document.getElementById('ce-auth-detail'); + const codeEl = document.getElementById('ce-auth-token-display'); + if (detailEl) detailEl.hidden = true; + if (codeEl) codeEl.textContent = ''; + updateAuthUI(false); + Toast.success('API access token removed'); + } + } catch { + Toast.error('Failed to remove token'); + } + } + + return { + init, + save, + reset, + saveApiKey, + removeApiKey, + toggleKeyVisibility, + updateRuleMetrics, + generateAuthToken, + removeAuthToken, + }; })(); diff --git a/ui/data.js b/ui/data.js index cbd74be..5a864f3 100644 --- a/ui/data.js +++ b/ui/data.js @@ -1,6 +1,6 @@ // @ts-check -// data.js — all static skill data for Context Engine +// data.js ├ö├ç├ all static skill data for Context Engine let SKILL_DATA = [ { diff --git a/ui/index.html b/ui/index.html index de16b03..7d9348c 100644 --- a/ui/index.html +++ b/ui/index.html @@ -101,6 +101,13 @@ Projects + - + + +
@@ -684,8 +710,8 @@

Projects

Scope memory, rules, and handoffs per project. Each project gets its own data directory.

-
-
@@ -774,6 +846,30 @@

New Project

+