From ce31577d1ecd485aa46fcbd16611357e638bf36f Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Mon, 25 May 2026 01:53:38 +0000 Subject: [PATCH] v2.0.0-beta: write tools, pending-changes queue, inference loop, API routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of v2.0. BooCoder is now a functional write-capable chatbot. Write-path guard: resolveWritePath() uses resolve() (no realpath — files may not exist for creates) + prefix-check + secret-file deny list (.env, *.pem, id_rsa*, etc.). 23 unit tests cover traversal attacks. Pending-changes service: queueEdit/Create/Delete → applyOne/All → rejectOne/All → rewindOne. Edit diffs stored as JSON {old, new}. All writes queue before touching disk; apply re-validates the path guard. 5 write tools: edit_file, create_file, delete_file, apply_pending, rewind. Registered alongside 25 read-only tools from BooChat (30 total, alpha-sorted). Write tools use a module-level inference context for sql+sessionId injection. Inference loop via workspace dependency: apps/coder imports createInferenceRunner, createBroker, ALL_TOOLS from @boocode/server (dist/). apps/server gains declaration: true + exports map with typed subpath entries. No code duplication — one inference engine shared by both apps. API routes: POST /api/sessions/:id/messages (user msg → inference), POST stop, GET/POST pending-changes CRUD (5 endpoints), WebSocket session streaming. Dockerfile updated to build apps/server first (coder depends on its .d.ts). Health endpoint reports tool count: {"ok":true,"db":true,"tools":30}. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/coder/Dockerfile | 6 +- apps/coder/package.json | 7 +- apps/coder/src/config.ts | 14 ++ apps/coder/src/index.ts | 78 +++++- apps/coder/src/routes/messages.ts | 126 ++++++++++ apps/coder/src/routes/pending.ts | 121 ++++++++++ apps/coder/src/routes/ws.ts | 51 ++++ .../services/__tests__/write_guard.test.ts | 115 +++++++++ apps/coder/src/services/pending_changes.ts | 224 ++++++++++++++++++ apps/coder/src/services/tools/adapter.ts | 30 +++ .../coder/src/services/tools/apply_pending.ts | 44 ++++ apps/coder/src/services/tools/create_file.ts | 51 ++++ apps/coder/src/services/tools/delete_file.ts | 48 ++++ apps/coder/src/services/tools/edit_file.ts | 54 +++++ apps/coder/src/services/tools/index.ts | 26 ++ .../src/services/tools/inference_context.ts | 36 +++ apps/coder/src/services/tools/rewind.ts | 71 ++++++ apps/coder/src/services/tools/types.ts | 32 +++ apps/coder/src/services/write_guard.ts | 73 ++++++ apps/coder/vitest.config.ts | 9 + apps/server/package.json | 17 ++ apps/server/tsconfig.json | 2 +- pnpm-lock.yaml | 6 + 23 files changed, 1236 insertions(+), 5 deletions(-) create mode 100644 apps/coder/src/routes/messages.ts create mode 100644 apps/coder/src/routes/pending.ts create mode 100644 apps/coder/src/routes/ws.ts create mode 100644 apps/coder/src/services/__tests__/write_guard.test.ts create mode 100644 apps/coder/src/services/pending_changes.ts create mode 100644 apps/coder/src/services/tools/adapter.ts create mode 100644 apps/coder/src/services/tools/apply_pending.ts create mode 100644 apps/coder/src/services/tools/create_file.ts create mode 100644 apps/coder/src/services/tools/delete_file.ts create mode 100644 apps/coder/src/services/tools/edit_file.ts create mode 100644 apps/coder/src/services/tools/index.ts create mode 100644 apps/coder/src/services/tools/inference_context.ts create mode 100644 apps/coder/src/services/tools/rewind.ts create mode 100644 apps/coder/src/services/tools/types.ts create mode 100644 apps/coder/src/services/write_guard.ts create mode 100644 apps/coder/vitest.config.ts diff --git a/apps/coder/Dockerfile b/apps/coder/Dockerfile index 5417789..6facdab 100644 --- a/apps/coder/Dockerfile +++ b/apps/coder/Dockerfile @@ -5,12 +5,16 @@ RUN corepack enable WORKDIR /build COPY package.json pnpm-workspace.yaml pnpm-lock.yaml tsconfig.base.json ./ +COPY apps/server/package.json ./apps/server/ COPY apps/coder/package.json ./apps/coder/ RUN pnpm install --frozen-lockfile -COPY apps/coder ./apps/coder +# Build server first (coder depends on it via workspace dep for types + inference) +COPY apps/server ./apps/server +RUN pnpm -C apps/server build +COPY apps/coder ./apps/coder RUN pnpm -C apps/coder build RUN pnpm deploy --filter=@boocode/coder --prod --legacy /out/coder diff --git a/apps/coder/package.json b/apps/coder/package.json index 73a1d1e..dd00ad5 100644 --- a/apps/coder/package.json +++ b/apps/coder/package.json @@ -8,9 +8,11 @@ "dev": "tsx watch src/index.ts", "build": "tsc && node -e \"import('node:fs').then(fs=>fs.copyFileSync('src/schema.sql','dist/schema.sql'))\"", "start": "node dist/index.js", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "vitest run" }, "dependencies": { + "@boocode/server": "workspace:*", "@fastify/static": "^7.0.4", "@fastify/websocket": "^10.0.1", "fastify": "^4.28.1", @@ -20,6 +22,7 @@ "devDependencies": { "@types/node": "^20.14.10", "tsx": "^4.16.2", - "typescript": "^5.5.0" + "typescript": "^5.5.0", + "vitest": "^3.0.0" } } diff --git a/apps/coder/src/config.ts b/apps/coder/src/config.ts index 49cdb83..48e8b68 100644 --- a/apps/coder/src/config.ts +++ b/apps/coder/src/config.ts @@ -1,5 +1,10 @@ import { z } from 'zod'; +// BooCoder's config is a superset of the server's Config type so it can be +// passed directly into the inference runner's InferenceContext. Fields the +// inference loop reads: LLAMA_SWAP_URL, PROJECT_ROOT_WHITELIST. The rest +// default to values that satisfy the server's Zod schema without BooCoder +// needing to supply them in its environment. const ConfigSchema = z.object({ NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), PORT: z.coerce.number().int().positive().default(3000), @@ -7,8 +12,17 @@ const ConfigSchema = z.object({ DATABASE_URL: z.string().url(), LLAMA_SWAP_URL: z.string().url(), PROJECT_ROOT_WHITELIST: z.string().default('/opt'), + BOOTSTRAP_ROOT: z.string().default('/opt/projects'), + DEFAULT_MODEL: z.string().default('qwen3.6-35b-a3b-mxfp4'), LOG_LEVEL: z.string().default('info'), CONTAINER_GUIDANCE_FILE: z.string().optional(), + // Fields needed to satisfy the server's Config type but unused by BooCoder: + SEARXNG_URL: z.string().url().default('http://100.114.205.53:8888'), + GITEA_BASE_URL: z.string().url().default('https://git.indifferentketchup.com'), + GITEA_USER: z.string().default('indifferentketchup'), + GITEA_TOKEN: z.string().optional(), + GITEA_SSH_HOST: z.string().default('100.114.205.53:2222'), + MCP_CONFIG_PATH: z.string().optional(), }); export type Config = z.infer; diff --git a/apps/coder/src/index.ts b/apps/coder/src/index.ts index 29a0873..f02b917 100644 --- a/apps/coder/src/index.ts +++ b/apps/coder/src/index.ts @@ -1,6 +1,22 @@ import Fastify from 'fastify'; +import fastifyWebsocket from '@fastify/websocket'; import { loadConfig } from './config.js'; import { getSql, applySchema, pingDb, closeDb } from './db.js'; +// v2.0.0 Phase 2B: workspace dependency on @boocode/server — reuse the +// inference loop, broker, and tool registry without duplication. +import { createInferenceRunner } from '@boocode/server/inference'; +import { createBroker } from '@boocode/server/broker'; +import { appendMcpTools, ALL_TOOLS } from '@boocode/server/tools'; +import type { Config as ServerConfig } from '@boocode/server/config'; +import type { WsFrame } from '@boocode/server/ws-frames'; +// v2.0.0 Phase 2C: write tools + adapter for BooChat ToolDef compatibility. +import { WRITE_TOOLS } from './services/tools/index.js'; +import { adaptWriteTool } from './services/tools/adapter.js'; +import { setInferenceContext, clearInferenceContext } from './services/tools/inference_context.js'; +// Routes +import { registerMessageRoutes } from './routes/messages.js'; +import { registerPendingRoutes } from './routes/pending.js'; +import { registerWebSocket } from './routes/ws.js'; async function main() { const config = loadConfig(); @@ -28,13 +44,73 @@ async function main() { await applySchema(sql); app.log.info('database schema applied'); + // Broker: in-memory pub/sub for session + user channel streaming. + const broker = createBroker(app.log); + + // --- Tool registry extension --- + // Append BooCoder write tools (adapted to BooChat's ToolDef interface) to + // the shared ALL_TOOLS registry. appendMcpTools re-sorts and rebuilds + // TOOLS_BY_NAME so tool-phase.ts dispatch sees the full set. + const adaptedWriteTools = WRITE_TOOLS.map((t) => adaptWriteTool(t)); + appendMcpTools(adaptedWriteTools); + app.log.info(`tool registry: ${ALL_TOOLS.length} tools loaded (${WRITE_TOOLS.length} write tools)`); + + // Inference runner: same engine as BooChat, uses ALL_TOOLS (which includes + // the appended write tools) for tool dispatch. + const inference = createInferenceRunner( + { + sql, + config: config as unknown as ServerConfig, + log: app.log, + publish: (sessionId, frame) => { + broker.publishFrame(sessionId, frame as unknown as WsFrame); + }, + broker, + }, + (user, frame) => { + broker.publishUserFrame(user, frame as unknown as WsFrame); + } + ); + + // Wrap the inference runner to set/clear the write-tool context around each run. + // The inference runner calls enqueue() which fires asynchronously — we hook + // into the enqueue to set context before the run starts. + const inferenceApi = { + enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => { + // Set the inference context so write tools can access sql + sessionId. + // The context persists for the duration of the inference run. Since + // BooCoder is single-user and runs one inference at a time per session, + // this module-level state is safe. + setInferenceContext({ sql, sessionId, taskId: null }); + inference.enqueue(sessionId, chatId, assistantId, user); + }, + cancel: async (sessionId: string, chatId: string) => { + const result = await inference.cancel(sessionId, chatId); + clearInferenceContext(); + return result; + }, + hasActive: (chatId: string) => inference.hasActive(chatId), + }; + + // Register WebSocket support + await app.register(fastifyWebsocket); + // Health endpoint app.get('/api/health', async (_req, reply) => { const dbOk = await pingDb(sql); const status = dbOk ? 200 : 503; - return reply.status(status).send({ ok: dbOk, db: dbOk }); + return reply.status(status).send({ + ok: dbOk, + db: dbOk, + tools: ALL_TOOLS.length, + }); }); + // Register routes + registerMessageRoutes(app, sql, broker, inferenceApi); + registerPendingRoutes(app, sql); + registerWebSocket(app, sql, broker); + // Graceful shutdown const shutdown = async () => { app.log.info('shutting down'); diff --git a/apps/coder/src/routes/messages.ts b/apps/coder/src/routes/messages.ts new file mode 100644 index 0000000..2468f4e --- /dev/null +++ b/apps/coder/src/routes/messages.ts @@ -0,0 +1,126 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import type { Sql } from '../db.js'; +import type { Broker } from '@boocode/server/broker'; +import type { WsFrame } from '@boocode/server/ws-frames'; + +const SendBody = z.object({ + content: z.string().min(1).max(64_000), + chat_id: z.string().uuid(), +}); + +interface InferenceApi { + enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void; + cancel: (sessionId: string, chatId: string) => Promise; + hasActive: (chatId: string) => boolean; +} + +export function registerMessageRoutes( + app: FastifyInstance, + sql: Sql, + broker: Broker, + inference: InferenceApi, +): void { + // POST /api/sessions/:sessionId/messages — send a user message + kick off inference + app.post<{ Params: { sessionId: string } }>( + '/api/sessions/:sessionId/messages', + async (req, reply) => { + const parsed = SendBody.safeParse(req.body); + if (!parsed.success) { + reply.code(400); + return { error: 'invalid body', details: parsed.error.flatten() }; + } + + const sessionId = req.params.sessionId; + const { content, chat_id: chatId } = parsed.data; + + // Validate session exists + const sessionRows = await sql<{ id: string }[]>` + SELECT id FROM sessions WHERE id = ${sessionId} + `; + if (sessionRows.length === 0) { + reply.code(404); + return { error: 'session not found' }; + } + + // Validate chat belongs to session and is open + const chatRows = await sql<{ id: string; session_id: string }[]>` + SELECT id, session_id FROM chats WHERE id = ${chatId} AND session_id = ${sessionId} AND status = 'open' + `; + if (chatRows.length === 0) { + reply.code(404); + return { error: 'chat not found or not open in this session' }; + } + + // Reject if inference is already running on this chat + if (inference.hasActive(chatId)) { + reply.code(409); + return { error: 'inference already running on this chat' }; + } + + // Create user message + streaming assistant row in a transaction + const result = await sql.begin(async (tx) => { + const [userMsg] = await tx<{ id: string }[]>` + INSERT INTO messages (session_id, chat_id, role, content, status, created_at) + VALUES (${sessionId}, ${chatId}, 'user', ${content}, 'complete', clock_timestamp()) + RETURNING id + `; + const [assistantMsg] = await tx<{ id: string }[]>` + INSERT INTO messages (session_id, chat_id, role, content, status, created_at) + VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp()) + RETURNING id + `; + await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`; + await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`; + return { user_message_id: userMsg!.id, assistant_message_id: assistantMsg!.id }; + }); + + // Publish user message frames so WS subscribers see it immediately + broker.publishFrame(sessionId, { + type: 'message_started', + message_id: result.user_message_id, + chat_id: chatId, + role: 'user', + } as unknown as WsFrame); + broker.publishFrame(sessionId, { + type: 'delta', + message_id: result.user_message_id, + chat_id: chatId, + content, + } as unknown as WsFrame); + broker.publishFrame(sessionId, { + type: 'message_complete', + message_id: result.user_message_id, + chat_id: chatId, + } as unknown as WsFrame); + + // Enqueue inference — the runner will stream assistant deltas via broker + inference.enqueue(sessionId, chatId, result.assistant_message_id, 'default'); + + reply.code(202); + return result; + }, + ); + + // POST /api/sessions/:sessionId/stop — cancel active inference + app.post<{ Params: { sessionId: string } }>( + '/api/sessions/:sessionId/stop', + async (req, reply) => { + const sessionId = req.params.sessionId; + + // Find active chats in this session + const chats = await sql<{ id: string }[]>` + SELECT id FROM chats WHERE session_id = ${sessionId} AND status = 'open' + `; + let cancelled = false; + for (const chat of chats) { + if (inference.hasActive(chat.id)) { + cancelled = await inference.cancel(sessionId, chat.id); + break; + } + } + + return { cancelled }; + }, + ); +} diff --git a/apps/coder/src/routes/pending.ts b/apps/coder/src/routes/pending.ts new file mode 100644 index 0000000..392d15f --- /dev/null +++ b/apps/coder/src/routes/pending.ts @@ -0,0 +1,121 @@ +import type { FastifyInstance } from 'fastify'; +import type { Sql } from '../db.js'; +import { + listPending, + applyOne, + applyAll, + rejectOne, + rewindOne, +} from '../services/pending_changes.js'; + +/** + * Resolve project root from a session's project path. + */ +async function resolveProjectRoot(sql: Sql, sessionId: string): Promise { + const rows = await sql<{ path: string }[]>` + SELECT p.path FROM sessions s + JOIN projects p ON s.project_id = p.id + WHERE s.id = ${sessionId} + `; + return rows.length > 0 ? rows[0]!.path : null; +} + +/** + * Resolve project root from a pending change's session. + */ +async function resolveProjectRootForChange(sql: Sql, changeId: string): Promise { + const rows = await sql<{ path: string }[]>` + SELECT p.path FROM pending_changes pc + JOIN sessions s ON pc.session_id = s.id + JOIN projects p ON s.project_id = p.id + WHERE pc.id = ${changeId} + `; + return rows.length > 0 ? rows[0]!.path : null; +} + +export function registerPendingRoutes(app: FastifyInstance, sql: Sql): void { + // GET /api/sessions/:sessionId/pending — list pending changes for a session + app.get<{ Params: { sessionId: string } }>( + '/api/sessions/:sessionId/pending', + async (req, reply) => { + const sessionId = req.params.sessionId; + + const session = await sql<{ id: string }[]>`SELECT id FROM sessions WHERE id = ${sessionId}`; + if (session.length === 0) { + reply.code(404); + return { error: 'session not found' }; + } + + const pending = await listPending(sql, sessionId); + return pending; + }, + ); + + // POST /api/sessions/:sessionId/pending/apply — apply all pending changes + app.post<{ Params: { sessionId: string } }>( + '/api/sessions/:sessionId/pending/apply', + async (req, reply) => { + const sessionId = req.params.sessionId; + + const projectRoot = await resolveProjectRoot(sql, sessionId); + if (!projectRoot) { + reply.code(404); + return { error: 'session or project not found' }; + } + + const results = await applyAll(sql, sessionId, projectRoot); + return { results }; + }, + ); + + // POST /api/pending/:id/apply — apply a single pending change + app.post<{ Params: { id: string } }>( + '/api/pending/:id/apply', + async (req, reply) => { + const changeId = req.params.id; + + const projectRoot = await resolveProjectRootForChange(sql, changeId); + if (!projectRoot) { + reply.code(404); + return { error: 'pending change or project not found' }; + } + + const result = await applyOne(sql, changeId, projectRoot); + if (!result.success) { + reply.code(422); + } + return result; + }, + ); + + // POST /api/pending/:id/reject — reject a single pending change + app.post<{ Params: { id: string } }>( + '/api/pending/:id/reject', + async (req, reply) => { + const changeId = req.params.id; + + await rejectOne(sql, changeId); + return { ok: true }; + }, + ); + + // POST /api/pending/:id/rewind — rewind (undo) an applied change + app.post<{ Params: { id: string } }>( + '/api/pending/:id/rewind', + async (req, reply) => { + const changeId = req.params.id; + + const projectRoot = await resolveProjectRootForChange(sql, changeId); + if (!projectRoot) { + reply.code(404); + return { error: 'pending change or project not found' }; + } + + const result = await rewindOne(sql, changeId, projectRoot); + if (!result.success) { + reply.code(422); + } + return result; + }, + ); +} diff --git a/apps/coder/src/routes/ws.ts b/apps/coder/src/routes/ws.ts new file mode 100644 index 0000000..e0e8f2a --- /dev/null +++ b/apps/coder/src/routes/ws.ts @@ -0,0 +1,51 @@ +import type { FastifyInstance } from 'fastify'; +import type { Sql } from '../db.js'; +import type { Broker } from '@boocode/server/broker'; + +export function registerWebSocket( + app: FastifyInstance, + sql: Sql, + broker: Broker, +): void { + // Per-session streaming WebSocket. Clients connect here to receive live + // inference frames (deltas, tool_calls, tool_results, message_complete). + app.get<{ Params: { sessionId: string } }>( + '/api/ws/sessions/:sessionId', + { websocket: true }, + async (socket, req) => { + const sessionId = req.params.sessionId; + + // Validate session exists + const session = await sql<{ id: string }[]>`SELECT id FROM sessions WHERE id = ${sessionId}`; + if (session.length === 0) { + socket.send(JSON.stringify({ type: 'error', error: 'session not found' })); + socket.close(1008, 'session not found'); + return; + } + + // Send snapshot of existing messages so client can hydrate + const messages = await sql[]>` + SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq, + tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata, + summary, tail_start_id, compacted_at + FROM messages_with_parts + WHERE session_id = ${sessionId} + ORDER BY created_at ASC, id ASC + `; + socket.send(JSON.stringify({ type: 'snapshot', messages })); + + // Subscribe to broker for live frames + const unsubscribe = broker.subscribe(sessionId, (frame) => { + if (socket.readyState !== socket.OPEN) return; + try { + socket.send(JSON.stringify(frame)); + } catch (err) { + app.log.warn({ err, sessionId }, 'ws send failed'); + } + }); + + socket.on('close', () => unsubscribe()); + socket.on('error', () => unsubscribe()); + }, + ); +} diff --git a/apps/coder/src/services/__tests__/write_guard.test.ts b/apps/coder/src/services/__tests__/write_guard.test.ts new file mode 100644 index 0000000..7eead5e --- /dev/null +++ b/apps/coder/src/services/__tests__/write_guard.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from 'vitest'; +import { resolveWritePath, isSecretPath, WriteGuardError } from '../write_guard.js'; + +const PROJECT_ROOT = '/opt/projects/my-app'; + +describe('resolveWritePath', () => { + it('resolves a relative path correctly', () => { + const result = resolveWritePath(PROJECT_ROOT, 'src/index.ts'); + expect(result).toBe('/opt/projects/my-app/src/index.ts'); + }); + + it('resolves nested relative path', () => { + const result = resolveWritePath(PROJECT_ROOT, 'src/lib/utils.ts'); + expect(result).toBe('/opt/projects/my-app/src/lib/utils.ts'); + }); + + it('throws on ../ escape', () => { + expect(() => resolveWritePath(PROJECT_ROOT, '../../../etc/passwd')).toThrow(WriteGuardError); + expect(() => resolveWritePath(PROJECT_ROOT, '../../../etc/passwd')).toThrow('path escapes project root'); + }); + + it('throws on absolute path outside project root', () => { + expect(() => resolveWritePath(PROJECT_ROOT, '/etc/shadow')).toThrow(WriteGuardError); + expect(() => resolveWritePath(PROJECT_ROOT, '/tmp/exploit')).toThrow('path escapes project root'); + }); + + it('allows absolute path inside project root', () => { + const result = resolveWritePath(PROJECT_ROOT, '/opt/projects/my-app/src/new.ts'); + expect(result).toBe('/opt/projects/my-app/src/new.ts'); + }); + + it('denies .env files', () => { + expect(() => resolveWritePath(PROJECT_ROOT, '.env')).toThrow(WriteGuardError); + expect(() => resolveWritePath(PROJECT_ROOT, '.env')).toThrow('cannot write to secret file'); + }); + + it('denies .env.local', () => { + expect(() => resolveWritePath(PROJECT_ROOT, '.env.local')).toThrow(WriteGuardError); + }); + + it('denies .env.production', () => { + expect(() => resolveWritePath(PROJECT_ROOT, '.env.production')).toThrow(WriteGuardError); + }); + + it('denies *.pem files', () => { + expect(() => resolveWritePath(PROJECT_ROOT, 'certs/server.pem')).toThrow(WriteGuardError); + expect(() => resolveWritePath(PROJECT_ROOT, 'certs/server.pem')).toThrow('cannot write to secret file'); + }); + + it('denies *.key files', () => { + expect(() => resolveWritePath(PROJECT_ROOT, 'ssl/private.key')).toThrow(WriteGuardError); + }); + + it('denies id_rsa', () => { + expect(() => resolveWritePath(PROJECT_ROOT, '.ssh/id_rsa')).toThrow(WriteGuardError); + }); + + it('denies id_ed25519', () => { + expect(() => resolveWritePath(PROJECT_ROOT, '.ssh/id_ed25519')).toThrow(WriteGuardError); + }); + + it('denies credentials.json', () => { + expect(() => resolveWritePath(PROJECT_ROOT, 'credentials.json')).toThrow(WriteGuardError); + }); + + it('passes a normal file inside project', () => { + const result = resolveWritePath(PROJECT_ROOT, 'src/components/Button.tsx'); + expect(result).toBe('/opt/projects/my-app/src/components/Button.tsx'); + }); + + it('passes a non-existent nested file (no realpath)', () => { + // This is the key difference from BooChat's pathGuard: no realpath means + // files that don't exist yet still pass validation + const result = resolveWritePath(PROJECT_ROOT, 'src/new-dir/new-file.ts'); + expect(result).toBe('/opt/projects/my-app/src/new-dir/new-file.ts'); + }); + + it('throws on null/empty path', () => { + expect(() => resolveWritePath(PROJECT_ROOT, '')).toThrow(WriteGuardError); + expect(() => resolveWritePath(PROJECT_ROOT, '')).toThrow('file path is required'); + }); + + it('normalizes ../ within project root and still allows', () => { + const result = resolveWritePath(PROJECT_ROOT, 'src/../lib/utils.ts'); + expect(result).toBe('/opt/projects/my-app/lib/utils.ts'); + }); + + it('rejects path that looks inside root but normalizes outside', () => { + expect(() => resolveWritePath(PROJECT_ROOT, 'src/../../other-project/hack.ts')).toThrow(WriteGuardError); + }); +}); + +describe('isSecretPath', () => { + it('detects .env', () => { + expect(isSecretPath('.env')).toBe(true); + }); + + it('detects nested .env', () => { + expect(isSecretPath('config/.env')).toBe(true); + }); + + it('detects *.pfx', () => { + expect(isSecretPath('certs/client.pfx')).toBe(true); + }); + + it('does not flag normal source files', () => { + expect(isSecretPath('src/index.ts')).toBe(false); + expect(isSecretPath('README.md')).toBe(false); + expect(isSecretPath('package.json')).toBe(false); + }); + + it('returns false for empty string', () => { + expect(isSecretPath('')).toBe(false); + }); +}); diff --git a/apps/coder/src/services/pending_changes.ts b/apps/coder/src/services/pending_changes.ts new file mode 100644 index 0000000..8518e42 --- /dev/null +++ b/apps/coder/src/services/pending_changes.ts @@ -0,0 +1,224 @@ +import { readFile, writeFile, unlink, mkdir } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import type { Sql } from '../db.js'; +import { resolveWritePath } from './write_guard.js'; + +// --- Types ------------------------------------------------------------------- + +export interface PendingChange { + id: string; + session_id: string; + task_id: string | null; + file_path: string; + operation: 'create' | 'edit' | 'delete'; + diff: string; + status: 'pending' | 'applied' | 'rejected' | 'reverted'; + created_at: string; +} + +export interface ApplyResult { + id: string; + file_path: string; + operation: string; + success: boolean; + error?: string; +} + +// --- Queue functions --------------------------------------------------------- + +export async function queueEdit( + sql: Sql, + sessionId: string, + taskId: string | null, + filePath: string, + oldString: string, + newString: string, + projectRoot: string, +): Promise { + const resolved = resolveWritePath(projectRoot, filePath); + const diff = JSON.stringify({ old: oldString, new: newString }); + + const [row] = await sql` + INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff) + VALUES (${sessionId}, ${taskId}, ${resolved}, 'edit', ${diff}) + RETURNING * + `; + return row!; +} + +export async function queueCreate( + sql: Sql, + sessionId: string, + taskId: string | null, + filePath: string, + content: string, + projectRoot: string, +): Promise { + const resolved = resolveWritePath(projectRoot, filePath); + + const [row] = await sql` + INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff) + VALUES (${sessionId}, ${taskId}, ${resolved}, 'create', ${content}) + RETURNING * + `; + return row!; +} + +export async function queueDelete( + sql: Sql, + sessionId: string, + taskId: string | null, + filePath: string, + projectRoot: string, +): Promise { + const resolved = resolveWritePath(projectRoot, filePath); + + const [row] = await sql` + INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff) + VALUES (${sessionId}, ${taskId}, ${resolved}, 'delete', '') + RETURNING * + `; + return row!; +} + +// --- Apply functions --------------------------------------------------------- + +export async function applyOne( + sql: Sql, + changeId: string, + projectRoot: string, +): Promise { + const [change] = await sql` + SELECT * FROM pending_changes WHERE id = ${changeId} AND status = 'pending' + `; + if (!change) { + return { id: changeId, file_path: '', operation: '', success: false, error: 'change not found or not pending' }; + } + + try { + // Re-validate path in case projectRoot has shifted + resolveWritePath(projectRoot, change.file_path); + + switch (change.operation) { + case 'create': { + await mkdir(dirname(change.file_path), { recursive: true }); + await writeFile(change.file_path, change.diff, 'utf8'); + break; + } + case 'edit': { + const { old: oldStr, new: newStr } = JSON.parse(change.diff) as { old: string; new: string }; + const content = await readFile(change.file_path, 'utf8'); + if (!content.includes(oldStr)) { + throw new Error('old_string not found in file — file may have changed since the edit was queued'); + } + const updated = content.replace(oldStr, newStr); + await writeFile(change.file_path, updated, 'utf8'); + break; + } + case 'delete': { + // Stash current content in diff for potential rewind + try { + const existing = await readFile(change.file_path, 'utf8'); + await sql`UPDATE pending_changes SET diff = ${existing} WHERE id = ${changeId}`; + } catch { + // File may already be gone — proceed with status update + } + await unlink(change.file_path); + break; + } + } + + await sql`UPDATE pending_changes SET status = 'applied' WHERE id = ${changeId}`; + return { id: change.id, file_path: change.file_path, operation: change.operation, success: true }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { id: change.id, file_path: change.file_path, operation: change.operation, success: false, error: message }; + } +} + +export async function applyAll( + sql: Sql, + sessionId: string, + projectRoot: string, +): Promise { + const pending = await sql` + SELECT * FROM pending_changes + WHERE session_id = ${sessionId} AND status = 'pending' + ORDER BY created_at ASC + `; + const results: ApplyResult[] = []; + for (const change of pending) { + results.push(await applyOne(sql, change.id, projectRoot)); + } + return results; +} + +// --- Reject functions -------------------------------------------------------- + +export async function rejectOne(sql: Sql, changeId: string): Promise { + await sql`UPDATE pending_changes SET status = 'rejected' WHERE id = ${changeId} AND status = 'pending'`; +} + +export async function rejectAll(sql: Sql, sessionId: string): Promise { + await sql`UPDATE pending_changes SET status = 'rejected' WHERE session_id = ${sessionId} AND status = 'pending'`; +} + +// --- Rewind functions -------------------------------------------------------- + +export async function rewindOne( + sql: Sql, + changeId: string, + projectRoot: string, +): Promise { + const [change] = await sql` + SELECT * FROM pending_changes WHERE id = ${changeId} AND status = 'applied' + `; + if (!change) { + return { id: changeId, file_path: '', operation: '', success: false, error: 'change not found or not applied' }; + } + + try { + resolveWritePath(projectRoot, change.file_path); + + switch (change.operation) { + case 'create': { + // Reverse a create: delete the file + await unlink(change.file_path); + break; + } + case 'edit': { + // Reverse an edit: swap old and new + const { old: oldStr, new: newStr } = JSON.parse(change.diff) as { old: string; new: string }; + const content = await readFile(change.file_path, 'utf8'); + if (!content.includes(newStr)) { + throw new Error('new_string not found in file — cannot rewind; file may have been modified since apply'); + } + const reverted = content.replace(newStr, oldStr); + await writeFile(change.file_path, reverted, 'utf8'); + break; + } + case 'delete': { + // Reverse a delete: recreate the file (diff holds the original content stashed at apply time) + await mkdir(dirname(change.file_path), { recursive: true }); + await writeFile(change.file_path, change.diff, 'utf8'); + break; + } + } + + await sql`UPDATE pending_changes SET status = 'reverted' WHERE id = ${changeId}`; + return { id: change.id, file_path: change.file_path, operation: change.operation, success: true }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { id: change.id, file_path: change.file_path, operation: change.operation, success: false, error: message }; + } +} + +// --- Query functions --------------------------------------------------------- + +export async function listPending(sql: Sql, sessionId: string): Promise { + return sql` + SELECT * FROM pending_changes + WHERE session_id = ${sessionId} AND status = 'pending' + ORDER BY created_at ASC + `; +} diff --git a/apps/coder/src/services/tools/adapter.ts b/apps/coder/src/services/tools/adapter.ts new file mode 100644 index 0000000..c204a17 --- /dev/null +++ b/apps/coder/src/services/tools/adapter.ts @@ -0,0 +1,30 @@ +/** + * Adapts BooCoder write tools (which take ToolContext) into BooChat's ToolDef + * interface (which takes `projectRoot, extraRoots?`). + * + * The adapter reads the module-level inference context at execute time, so the + * wrapping happens at boot (static) — no per-inference re-wrap needed. + */ + +import type { ToolDef as ServerToolDef } from '@boocode/server/tools'; +import type { ToolDef, ToolContext } from './types.js'; +import { getInferenceContext } from './inference_context.js'; + +/** + * Wrap a BooCoder write tool (execute takes ToolContext) into a BooChat + * ToolDef (execute takes projectRoot + optional extraRoots). The adapter + * builds the ToolContext from the module-level inference context at call time. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function adaptWriteTool(tool: ToolDef): ServerToolDef { + return { + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + jsonSchema: tool.jsonSchema, + async execute(input: unknown, projectRoot: string, _extraRoots?: readonly string[]): Promise { + const ctx: ToolContext = getInferenceContext(); + return tool.execute(input, projectRoot, ctx); + }, + }; +} diff --git a/apps/coder/src/services/tools/apply_pending.ts b/apps/coder/src/services/tools/apply_pending.ts new file mode 100644 index 0000000..c69ffa9 --- /dev/null +++ b/apps/coder/src/services/tools/apply_pending.ts @@ -0,0 +1,44 @@ +import { z } from 'zod'; +import type { ToolDef, ToolContext } from './types.js'; +import { applyAll } from '../pending_changes.js'; + +const ApplyPendingInput = z.object({}); +type ApplyPendingInputT = z.infer; + +export const applyPendingTool: ToolDef = { + name: 'apply_pending', + description: + 'Apply all pending changes for the current session to disk. ' + + 'Each queued create/edit/delete is executed in order.', + inputSchema: ApplyPendingInput, + jsonSchema: { + type: 'function', + function: { + name: 'apply_pending', + description: + 'Apply all pending changes for the current session to disk. ' + + 'Each queued create/edit/delete is executed in order.', + parameters: { + type: 'object', + properties: {}, + required: [], + }, + }, + }, + async execute(_input: ApplyPendingInputT, projectRoot: string, context: ToolContext): Promise { + const results = await applyAll(context.sql, context.sessionId, projectRoot); + const succeeded = results.filter((r) => r.success).length; + const failed = results.filter((r) => !r.success).length; + + return { + total: results.length, + succeeded, + failed, + results, + message: + results.length === 0 + ? 'No pending changes to apply.' + : `Applied ${succeeded}/${results.length} changes.${failed > 0 ? ` ${failed} failed.` : ''}`, + }; + }, +}; diff --git a/apps/coder/src/services/tools/create_file.ts b/apps/coder/src/services/tools/create_file.ts new file mode 100644 index 0000000..a4aee29 --- /dev/null +++ b/apps/coder/src/services/tools/create_file.ts @@ -0,0 +1,51 @@ +import { z } from 'zod'; +import type { ToolDef, ToolContext } from './types.js'; +import { queueCreate } from '../pending_changes.js'; + +const CreateFileInput = z.object({ + file_path: z.string().min(1), + content: z.string(), +}); +type CreateFileInputT = z.infer; + +export const createFileTool: ToolDef = { + name: 'create_file', + description: + 'Queue creation of a new file with the given content. ' + + 'The change is staged in pending_changes and must be applied explicitly.', + inputSchema: CreateFileInput, + jsonSchema: { + type: 'function', + function: { + name: 'create_file', + description: + 'Queue creation of a new file with the given content. ' + + 'The change is staged in pending_changes and must be applied explicitly.', + parameters: { + type: 'object', + properties: { + file_path: { type: 'string', description: 'Path for the new file (relative to project root or absolute)' }, + content: { type: 'string', description: 'Full content of the file to create' }, + }, + required: ['file_path', 'content'], + }, + }, + }, + async execute(input: CreateFileInputT, projectRoot: string, context: ToolContext): Promise { + const change = await queueCreate( + context.sql, + context.sessionId, + context.taskId, + input.file_path, + input.content, + projectRoot, + ); + return { + status: 'queued', + change_id: change.id, + file_path: change.file_path, + operation: 'create', + message: `File creation queued: ${change.file_path}. Use apply_pending to write changes to disk.`, + }; + }, +}; diff --git a/apps/coder/src/services/tools/delete_file.ts b/apps/coder/src/services/tools/delete_file.ts new file mode 100644 index 0000000..391e2f2 --- /dev/null +++ b/apps/coder/src/services/tools/delete_file.ts @@ -0,0 +1,48 @@ +import { z } from 'zod'; +import type { ToolDef, ToolContext } from './types.js'; +import { queueDelete } from '../pending_changes.js'; + +const DeleteFileInput = z.object({ + file_path: z.string().min(1), +}); +type DeleteFileInputT = z.infer; + +export const deleteFileTool: ToolDef = { + name: 'delete_file', + description: + 'Queue deletion of a file. ' + + 'The change is staged in pending_changes and must be applied explicitly.', + inputSchema: DeleteFileInput, + jsonSchema: { + type: 'function', + function: { + name: 'delete_file', + description: + 'Queue deletion of a file. ' + + 'The change is staged in pending_changes and must be applied explicitly.', + parameters: { + type: 'object', + properties: { + file_path: { type: 'string', description: 'Path to the file to delete (relative to project root or absolute)' }, + }, + required: ['file_path'], + }, + }, + }, + async execute(input: DeleteFileInputT, projectRoot: string, context: ToolContext): Promise { + const change = await queueDelete( + context.sql, + context.sessionId, + context.taskId, + input.file_path, + projectRoot, + ); + return { + status: 'queued', + change_id: change.id, + file_path: change.file_path, + operation: 'delete', + message: `File deletion queued: ${change.file_path}. Use apply_pending to write changes to disk.`, + }; + }, +}; diff --git a/apps/coder/src/services/tools/edit_file.ts b/apps/coder/src/services/tools/edit_file.ts new file mode 100644 index 0000000..a361276 --- /dev/null +++ b/apps/coder/src/services/tools/edit_file.ts @@ -0,0 +1,54 @@ +import { z } from 'zod'; +import type { ToolDef, ToolContext } from './types.js'; +import { queueEdit } from '../pending_changes.js'; + +const EditFileInput = z.object({ + file_path: z.string().min(1), + old_string: z.string().min(1), + new_string: z.string(), +}); +type EditFileInputT = z.infer; + +export const editFileTool: ToolDef = { + name: 'edit_file', + description: + 'Queue an edit to a file. The edit replaces old_string with new_string. ' + + 'The change is staged in pending_changes and must be applied explicitly.', + inputSchema: EditFileInput, + jsonSchema: { + type: 'function', + function: { + name: 'edit_file', + description: + 'Queue an edit to a file. The edit replaces old_string with new_string. ' + + 'The change is staged in pending_changes and must be applied explicitly.', + parameters: { + type: 'object', + properties: { + file_path: { type: 'string', description: 'Path to the file to edit (relative to project root or absolute)' }, + old_string: { type: 'string', description: 'The exact string to find and replace (must appear in the file)' }, + new_string: { type: 'string', description: 'The replacement string' }, + }, + required: ['file_path', 'old_string', 'new_string'], + }, + }, + }, + async execute(input: EditFileInputT, projectRoot: string, context: ToolContext): Promise { + const change = await queueEdit( + context.sql, + context.sessionId, + context.taskId, + input.file_path, + input.old_string, + input.new_string, + projectRoot, + ); + return { + status: 'queued', + change_id: change.id, + file_path: change.file_path, + operation: 'edit', + message: `Edit queued for ${change.file_path}. Use apply_pending to write changes to disk.`, + }; + }, +}; diff --git a/apps/coder/src/services/tools/index.ts b/apps/coder/src/services/tools/index.ts new file mode 100644 index 0000000..639e166 --- /dev/null +++ b/apps/coder/src/services/tools/index.ts @@ -0,0 +1,26 @@ +import type { ToolDef } from './types.js'; +import { editFileTool } from './edit_file.js'; +import { createFileTool } from './create_file.js'; +import { deleteFileTool } from './delete_file.js'; +import { applyPendingTool } from './apply_pending.js'; +import { rewindTool } from './rewind.js'; + +export type { ToolDef, ToolContext, ToolJsonSchema } from './types.js'; + +// All BooCoder write tools. The inference loop (Phase 2B) will combine these +// with BooChat's read-only tools to form the full tool set available to agents. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const WRITE_TOOLS: readonly ToolDef[] = [ + applyPendingTool, + createFileTool, + deleteFileTool, + editFileTool, + rewindTool, +]; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const WRITE_TOOLS_BY_NAME: ReadonlyMap> = new Map( + WRITE_TOOLS.map((t) => [t.name, t]), +); + +export { editFileTool, createFileTool, deleteFileTool, applyPendingTool, rewindTool }; diff --git a/apps/coder/src/services/tools/inference_context.ts b/apps/coder/src/services/tools/inference_context.ts new file mode 100644 index 0000000..507c6fd --- /dev/null +++ b/apps/coder/src/services/tools/inference_context.ts @@ -0,0 +1,36 @@ +import type { Sql } from '../../db.js'; + +/** + * Module-level inference context for write tools. + * + * Set via `setInferenceContext()` before each inference run starts. + * Write tools read it via `getInferenceContext()` during execute. + * Same pattern as BooChat's `loadConfig()` singleton — tools need + * ambient state that can't be threaded through the tool-phase execute + * signature (which is `execute(input, projectRoot, extraRoots?)`). + */ + +export interface InferenceContext { + sql: Sql; + sessionId: string; + taskId: string | null; +} + +let current: InferenceContext | null = null; + +export function setInferenceContext(ctx: InferenceContext): void { + current = ctx; +} + +export function clearInferenceContext(): void { + current = null; +} + +export function getInferenceContext(): InferenceContext { + if (!current) { + throw new Error( + 'Write tool called outside inference context — setInferenceContext() was not called before this run', + ); + } + return current; +} diff --git a/apps/coder/src/services/tools/rewind.ts b/apps/coder/src/services/tools/rewind.ts new file mode 100644 index 0000000..fc01b3b --- /dev/null +++ b/apps/coder/src/services/tools/rewind.ts @@ -0,0 +1,71 @@ +import { z } from 'zod'; +import type { ToolDef, ToolContext } from './types.js'; +import { rewindOne } from '../pending_changes.js'; + +const RewindInput = z.object({ + change_id: z.string().uuid().optional(), + all: z.boolean().optional(), +}); +type RewindInputT = z.infer; + +export const rewindTool: ToolDef = { + name: 'rewind', + description: + 'Revert applied changes. Provide change_id to revert a specific change, ' + + 'or set all=true to revert all applied changes for the session (in reverse order).', + inputSchema: RewindInput, + jsonSchema: { + type: 'function', + function: { + name: 'rewind', + description: + 'Revert applied changes. Provide change_id to revert a specific change, ' + + 'or set all=true to revert all applied changes for the session (in reverse order).', + parameters: { + type: 'object', + properties: { + change_id: { type: 'string', format: 'uuid', description: 'ID of a specific change to revert' }, + all: { type: 'boolean', description: 'If true, revert all applied changes for this session' }, + }, + required: [], + }, + }, + }, + async execute(input: RewindInputT, projectRoot: string, context: ToolContext): Promise { + if (input.change_id) { + const result = await rewindOne(context.sql, input.change_id, projectRoot); + return { + results: [result], + message: result.success + ? `Reverted change ${input.change_id} (${result.operation} on ${result.file_path}).` + : `Failed to revert: ${result.error}`, + }; + } + + if (input.all) { + // Rewind all applied changes for this session in reverse order + const applied = await context.sql<{ id: string }[]>` + SELECT id FROM pending_changes + WHERE session_id = ${context.sessionId} AND status = 'applied' + ORDER BY created_at DESC + `; + const results = []; + for (const row of applied) { + results.push(await rewindOne(context.sql, row.id, projectRoot)); + } + const succeeded = results.filter((r) => r.success).length; + return { + total: results.length, + succeeded, + failed: results.length - succeeded, + results, + message: + results.length === 0 + ? 'No applied changes to revert.' + : `Reverted ${succeeded}/${results.length} changes.`, + }; + } + + return { error: 'Provide either change_id or all=true.' }; + }, +}; diff --git a/apps/coder/src/services/tools/types.ts b/apps/coder/src/services/tools/types.ts new file mode 100644 index 0000000..1600988 --- /dev/null +++ b/apps/coder/src/services/tools/types.ts @@ -0,0 +1,32 @@ +import type { z } from 'zod'; +import type { Sql } from '../../db.js'; + +export interface ToolJsonSchema { + type: 'function'; + function: { + name: string; + description: string; + parameters: Record; + }; +} + +/** + * Context passed to BooCoder tool execute functions. + * + * Unlike BooChat's tools (which only need projectRoot), BooCoder's write tools + * interact with the database (pending_changes table) and need session/task + * context for proper attribution. + */ +export interface ToolContext { + sql: Sql; + sessionId: string; + taskId: string | null; +} + +export interface ToolDef { + name: string; + description: string; + inputSchema: z.ZodType; + jsonSchema: ToolJsonSchema; + execute(input: TInput, projectRoot: string, context: ToolContext): Promise; +} diff --git a/apps/coder/src/services/write_guard.ts b/apps/coder/src/services/write_guard.ts new file mode 100644 index 0000000..7026063 --- /dev/null +++ b/apps/coder/src/services/write_guard.ts @@ -0,0 +1,73 @@ +import { resolve, sep } from 'node:path'; + +export class WriteGuardError extends Error { + constructor(message: string) { + super(message); + this.name = 'WriteGuardError'; + } +} + +// Deny list: files that should never be written regardless of path-guard. +// Subset of BooChat's secret_guard.ts — covers the most dangerous patterns. +// Full parity with BooChat's deny list is not needed for write-guard because +// the write tools are intentional (model chose to create/edit); we block only +// files that are unambiguously secrets. +const SECRET_PATTERNS: readonly string[] = [ + '.env', + '.env.local', + '.env.production', + '.env.development', + '.env.staging', + 'id_rsa', + 'id_dsa', + 'id_ecdsa', + 'id_ed25519', + '*.pem', + '*.key', + '*.p12', + '*.pfx', + '*.crt', + 'credentials.json', + '*.kdbx', + '.netrc', +]; + +export function isSecretPath(filePath: string): boolean { + const normalized = filePath.replace(/\\/g, '/'); + const segments = normalized.split('/').filter((s) => s.length > 0); + if (segments.length === 0) return false; + const basename = segments[segments.length - 1]!; + + return SECRET_PATTERNS.some((pattern) => { + if (pattern.startsWith('*')) { + return basename.endsWith(pattern.slice(1)); + } + return basename === pattern; + }); +} + +/** + * Resolve and validate a write target path. + * + * Key difference from BooChat's pathGuard: no realpath() — the file may not + * exist yet (creates). Uses resolve() to normalize ../ segments and then + * checks the result stays within projectRoot. + */ +export function resolveWritePath(projectRoot: string, filePath: string): string { + if (!filePath || filePath.length === 0) { + throw new WriteGuardError('file path is required'); + } + + const candidate = filePath.startsWith('/') ? filePath : resolve(projectRoot, filePath); + const normalized = resolve(candidate); // normalizes ../ segments + + if (!normalized.startsWith(projectRoot + sep) && normalized !== projectRoot) { + throw new WriteGuardError(`path escapes project root: ${filePath}`); + } + + if (isSecretPath(normalized)) { + throw new WriteGuardError(`cannot write to secret file: ${filePath}`); + } + + return normalized; +} diff --git a/apps/coder/vitest.config.ts b/apps/coder/vitest.config.ts new file mode 100644 index 0000000..5802658 --- /dev/null +++ b/apps/coder/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + globals: false, + include: ['src/**/__tests__/**/*.test.ts'], + }, +}); diff --git a/apps/server/package.json b/apps/server/package.json index 9cdf0ef..bae1932 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -4,6 +4,23 @@ "private": true, "type": "module", "main": "dist/index.js", + "exports": { + ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }, + "./inference": { "types": "./dist/services/inference/index.d.ts", "default": "./dist/services/inference/index.js" }, + "./tools": { "types": "./dist/services/tools.d.ts", "default": "./dist/services/tools.js" }, + "./broker": { "types": "./dist/services/broker.d.ts", "default": "./dist/services/broker.js" }, + "./compaction": { "types": "./dist/services/compaction.d.ts", "default": "./dist/services/compaction.js" }, + "./model-context": { "types": "./dist/services/model-context.d.ts", "default": "./dist/services/model-context.js" }, + "./system-prompt": { "types": "./dist/services/system-prompt.d.ts", "default": "./dist/services/system-prompt.js" }, + "./agents": { "types": "./dist/services/agents.d.ts", "default": "./dist/services/agents.js" }, + "./truncate": { "types": "./dist/services/truncate.d.ts", "default": "./dist/services/truncate.js" }, + "./path-guard": { "types": "./dist/services/path_guard.d.ts", "default": "./dist/services/path_guard.js" }, + "./file-ops": { "types": "./dist/services/file_ops.d.ts", "default": "./dist/services/file_ops.js" }, + "./types": { "types": "./dist/types/api.d.ts", "default": "./dist/types/api.js" }, + "./ws-frames": { "types": "./dist/types/ws-frames.d.ts", "default": "./dist/types/ws-frames.js" }, + "./db": { "types": "./dist/db.d.ts", "default": "./dist/db.js" }, + "./config": { "types": "./dist/config.d.ts", "default": "./dist/config.js" } + }, "scripts": { "dev": "tsx watch src/index.ts", "build": "tsc && node -e \"import('node:fs').then(fs=>fs.copyFileSync('src/schema.sql','dist/schema.sql'))\"", diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json index fe31069..72ab11e 100644 --- a/apps/server/tsconfig.json +++ b/apps/server/tsconfig.json @@ -7,7 +7,7 @@ "rootDir": "src", "lib": ["ES2022"], "types": ["node"], - "declaration": false, + "declaration": true, "sourceMap": true }, "include": ["src/**/*"], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2b398e6..5447f6f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,6 +48,9 @@ importers: apps/coder: dependencies: + '@boocode/server': + specifier: workspace:* + version: link:../server '@fastify/static': specifier: ^7.0.4 version: 7.0.4 @@ -73,6 +76,9 @@ importers: typescript: specifier: ^5.5.0 version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/debug@4.1.13)(@types/node@20.19.41)(lightningcss@1.32.0)(msw@2.14.6(@types/node@20.19.41)(typescript@5.9.3)) apps/server: dependencies: