diff --git a/docs/server-api.md b/docs/server-api.md new file mode 100644 index 00000000..7ec23ef2 --- /dev/null +++ b/docs/server-api.md @@ -0,0 +1,23 @@ +# DeepCode Server API + +[English](./server-api_en.md) · 中文 + +```bash +deepcode-server --port 8787 +``` + +默认开启 token 鉴权。stdout 会打印类似: + +```text +deepcode server listening on http://127.0.0.1:8787 token= +``` + +通过以下任一方式传递: + +- `?token=` +- `x-deepcode-token: ` +- `Authorization: Bearer ` + +## 范围 + +Server 暴露现有 DeepCode runtime / CLI-TUI 已有能力作为 http api。斜杠命令含义参考 [README 的“斜杠命令与按键功能”](../README.md#斜杠命令与按键功能)。 diff --git a/docs/server-api_en.md b/docs/server-api_en.md new file mode 100644 index 00000000..1ddf0fc9 --- /dev/null +++ b/docs/server-api_en.md @@ -0,0 +1,23 @@ +# DeepCode Server API + +English · [中文](./server-api.md) + +```bash +deepcode-server --port 8787 +``` + +Token authentication is enabled by default. stdout prints a line similar to: + +```text +deepcode server listening on http://127.0.0.1:8787 token= +``` + +Pass the token with any of: + +- `?token=` +- `x-deepcode-token: ` +- `Authorization: Bearer ` + +## Scope + +The server exposes existing DeepCode runtime / CLI-TUI capabilities as HTTP APIs. For slash command meanings, see [README “Slash Commands & Keyboard Shortcuts”](../README-en.md#slash-commands--keyboard-shortcuts). diff --git a/package-lock.json b/package-lock.json index 0e4b47f0..2710105a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1965,6 +1965,10 @@ "resolved": "packages/core", "link": true }, + "node_modules/@vegamo/deepcode-server": { + "resolved": "packages/server", + "link": true + }, "node_modules/@vscode/vsce": { "version": "3.9.2", "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.9.2.tgz", @@ -7549,6 +7553,21 @@ "node": ">= 4" } }, + "packages/server": { + "name": "@vegamo/deepcode-server", + "version": "0.1.31", + "license": "MIT", + "dependencies": { + "@vegamo/deepcode-core": "file:../core" + }, + "bin": { + "deepcode-headless-server": "dist/server.js", + "deepcode-server": "dist/server.js" + }, + "engines": { + "node": ">=22" + } + }, "packages/vscode-ide-companion": { "name": "deepcode-vscode", "version": "0.1.22", diff --git a/packages/server/package.json b/packages/server/package.json new file mode 100644 index 00000000..41c2511a --- /dev/null +++ b/packages/server/package.json @@ -0,0 +1,36 @@ +{ + "name": "@vegamo/deepcode-server", + "version": "0.1.31", + "description": "Deep Code local HTTP/SSE server runtime host", + "license": "MIT", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/lessweb/deepcode-cli.git" + }, + "homepage": "https://deepcode.vegamo.cn", + "bin": { + "deepcode-server": "./dist/server.js", + "deepcode-headless-server": "./dist/server.js" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist/**", + "README.md", + "LICENSE" + ], + "engines": { + "node": ">=22" + }, + "scripts": { + "typecheck": "tsc -p ./ --noEmit", + "build": "tsc -p ./ && node scripts/rewrite-esm-imports.js && node -e \"require('fs').chmodSync('dist/server.js', 0o755)\"", + "test": "tsx --test src/tests/*.test.ts", + "prepublishOnly": "npm run build", + "format": "prettier --write ." + }, + "dependencies": { + "@vegamo/deepcode-core": "file:../core" + } +} diff --git a/packages/server/scripts/rewrite-esm-imports.js b/packages/server/scripts/rewrite-esm-imports.js new file mode 100644 index 00000000..5cb9f6bd --- /dev/null +++ b/packages/server/scripts/rewrite-esm-imports.js @@ -0,0 +1,44 @@ +/** + * Post-build script: rewrites extensionless relative imports in the server + * package's dist/ output to include explicit ".js" extensions. + * + * Keep this package-local so the server feature does not change the existing + * root-level core rewrite script. + */ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { globSync } from "glob"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const packageRoot = join(__dirname, ".."); +const distDir = join(packageRoot, "dist"); + +if (!existsSync(distDir)) { + throw new Error(`Cannot rewrite server ESM imports because dist directory does not exist: ${distDir}`); +} + +const files = globSync("**/*.js", { cwd: distDir, absolute: true }); + +// Match: from "./anything" or from "../anything" +// Negative lookahead: skip if already ends with .js, .json, .node, or is a bare specifier +const IMPORT_RE = /(from\s+["'])(\.\.?\/[^"']+?)(? { + rewrites++; + return `${prefix}${specifier}.js${quote}`; + }); + + if (rewrites > 0) { + writeFileSync(filePath, updated, "utf8"); + totalRewrites += rewrites; + } +} + +console.log(`\n✅ Rewrote ${totalRewrites} imports across ${files.length} files in server/dist/\n`); diff --git a/packages/server/src/command-map.ts b/packages/server/src/command-map.ts new file mode 100644 index 00000000..12e84cda --- /dev/null +++ b/packages/server/src/command-map.ts @@ -0,0 +1,71 @@ +/** + * Server slash-command route metadata. + * + * Summary: + * Maps supported DeepCode runtime slash commands to HTTP route metadata for the + * local server. This module is intentionally independent from the CLI slash + * command table so server behavior does not depend on terminal UI code. + * + * Exports: + * - buildHeadlessCommandRoutes(): HeadlessCommandRoute[] + * - findHeadlessCommandRoute(pathname: string): HeadlessCommandRoute | null + */ +export type HeadlessCommandRoute = { + name: string; + label: string; + description: string; + method: "GET" | "POST"; + path: string; + aliases: string[]; + implemented: boolean; +}; + +type RuntimeCommand = { + name: string; + label: string; + description: string; +}; + +const RUNTIME_COMMANDS: RuntimeCommand[] = [ + { name: "skills", label: "/skills", description: "List available skills" }, + { name: "model", label: "/model", description: "Select model, thinking mode and effort control" }, + { name: "new", label: "/new", description: "Start a fresh conversation" }, + { name: "init", label: "/init", description: "Initialize an AGENTS.md file with instructions for LLM" }, + { name: "resume", label: "/resume", description: "Pick a previous conversation to continue" }, + { name: "continue", label: "/continue", description: "Continue the active conversation or pick one to resume" }, + { name: "undo", label: "/undo", description: "Restore code and/or conversation to a previous point" }, + { name: "mcp", label: "/mcp", description: "Show MCP server status and available tools" }, + { name: "raw", label: "/raw", description: "CLI display mode command; no backend raw display state is exposed" }, + { name: "exit", label: "/exit", description: "Quit Deep Code server" }, +]; + +const READ_ONLY_COMMANDS = new Set(["skills", "resume", "mcp", "model"]); +const IMPLEMENTED_COMMANDS = new Set(["skills", "new", "init", "resume", "continue", "undo", "mcp", "model", "exit"]); + +function commandMethod(command: RuntimeCommand): "GET" | "POST" { + return READ_ONLY_COMMANDS.has(command.name) ? "GET" : "POST"; +} + +function commandAliases(command: RuntimeCommand): string[] { + if (command.name === "model") { + return ["/model"]; + } + return [`/${command.name}`, `/api/${command.name}`]; +} + +export function buildHeadlessCommandRoutes(): HeadlessCommandRoute[] { + return RUNTIME_COMMANDS.map((command) => ({ + name: command.name, + label: command.label, + description: command.description, + method: commandMethod(command), + path: `/${command.name}`, + aliases: commandAliases(command), + implemented: IMPLEMENTED_COMMANDS.has(command.name), + })); +} + +export function findHeadlessCommandRoute(pathname: string): HeadlessCommandRoute | null { + const normalized = pathname.replace(/\/+$/u, "") || "/"; + return buildHeadlessCommandRoutes().find((route) => route.aliases.includes(normalized)) ?? null; +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts new file mode 100644 index 00000000..df892b0c --- /dev/null +++ b/packages/server/src/index.ts @@ -0,0 +1,2 @@ +export { runHeadlessHttp } from "./services/http-server"; +export type { HeadlessOptions } from "./services/http-server"; diff --git a/packages/server/src/model-options.ts b/packages/server/src/model-options.ts new file mode 100644 index 00000000..026dec15 --- /dev/null +++ b/packages/server/src/model-options.ts @@ -0,0 +1,27 @@ +/** + * Server model option constants. + * + * Summary: + * Defines the model and thinking-mode choices exposed by the local HTTP/SSE + * server contract. This module contains data only and has no CLI, UI, Ink, or + * React dependency. + * + * Exports: + * - MODEL_COMMAND_MODELS: readonly model id list exposed by GET /model. + * - MODEL_COMMAND_THINKING_OPTIONS: thinking-mode choices exposed by GET /model. + */ +import type { ReasoningEffort } from "@vegamo/deepcode-core"; + +export type ThinkingModeOption = { + label: string; + thinkingEnabled: boolean; + reasoningEffort?: ReasoningEffort; +}; + +export const MODEL_COMMAND_MODELS = ["deepseek-v4-pro", "deepseek-v4-flash"] as const; + +export const MODEL_COMMAND_THINKING_OPTIONS: ThinkingModeOption[] = [ + { label: "Thinking mode [max]", thinkingEnabled: true, reasoningEffort: "max" }, + { label: "Thinking mode [high]", thinkingEnabled: true, reasoningEffort: "high" }, + { label: "No thinking", thinkingEnabled: false }, +]; diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts new file mode 100755 index 00000000..cec614b0 --- /dev/null +++ b/packages/server/src/server.ts @@ -0,0 +1,77 @@ +#!/usr/bin/env node +import * as path from "node:path"; +import { createRequire } from "node:module"; +import { setShellIfWindows } from "@vegamo/deepcode-core"; +import { runHeadlessHttp } from "./index"; +import { readArgValue } from "./services/server-options"; + +const require = createRequire(import.meta.url); + +type PackageInfo = { + version?: unknown; +}; + +const args = process.argv.slice(2); +const version = readVersion(); + +if (args.includes("--version") || args.includes("-v")) { + process.stdout.write(`${version}\n`); + process.exit(0); +} + +if (args.includes("--help") || args.includes("-h")) { + process.stdout.write(buildHelpText()); + process.exit(0); +} + +const projectRoot = path.resolve(readArgValue(args, "--project-root") ?? process.cwd()); +configureWindowsShell(); + +try { + await runHeadlessHttp({ args, projectRoot, version }); +} catch (error) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`deepcode server failed: ${message}\n`); + process.exit(1); +} + +function configureWindowsShell(): void { + process.env.NoDefaultCurrentDirectoryInExePath = "1"; + try { + setShellIfWindows(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`deepcode server: ${message}\n`); + process.exit(1); + } +} + +function readVersion(): string { + try { + const pkg = require("../package.json") as PackageInfo; + return typeof pkg.version === "string" ? pkg.version : "unknown"; + } catch { + return "unknown"; + } +} + +function buildHelpText(): string { + return [ + "deepcode-server - Deep Code local HTTP/SSE runtime server", + "", + "Usage:", + " deepcode-server [--host ] [--port ] [--project-root ]", + " deepcode-server --version", + " deepcode-server --help", + "", + "Options:", + " --host Bind address. Defaults to 127.0.0.1.", + " --port Bind port. Defaults to 8787.", + " --project-root Deep Code project root. Defaults to current working directory.", + " --no-auth Turn off local token auth for trusted local development.", + " --unsafe-bind Allow non-local bind addresses.", + " --version, -v Print the version.", + " --help, -h Show this help.", + "", + ].join("\n"); +} diff --git a/packages/server/src/services/auth.ts b/packages/server/src/services/auth.ts new file mode 100644 index 00000000..8a97e03b --- /dev/null +++ b/packages/server/src/services/auth.ts @@ -0,0 +1,35 @@ +/** + * Request authorization helpers. + * + * Summary: + * Contains token matching logic for local HTTP requests. This is separated from + * route execution so auth policy can be audited independently. + * + * Exports: + * - isAuthorized(request: IncomingMessage, token: string): boolean + */ +import type { IncomingMessage } from "node:http"; + +export function isAuthorized(request: IncomingMessage, token: string): boolean { + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + if (url.searchParams.get("token") === token) { + return true; + } + + const tokenHeader = firstHeaderValue(request.headers["x-deepcode-token"]); + if (tokenHeader === token) { + return true; + } + + const authorization = firstHeaderValue(request.headers.authorization); + if (!authorization) { + return false; + } + + const [scheme, ...rest] = authorization.trim().split(/\s+/u); + return scheme.toLowerCase() === "bearer" && rest.join(" ") === token; +} + +function firstHeaderValue(value: string | string[] | undefined): string { + return Array.isArray(value) ? value[0] ?? "" : value ?? ""; +} diff --git a/packages/server/src/services/events.ts b/packages/server/src/services/events.ts new file mode 100644 index 00000000..fc39918b --- /dev/null +++ b/packages/server/src/services/events.ts @@ -0,0 +1,17 @@ +/** + * Server event envelope types. + * + * Summary: + * Defines the structured event envelope used by the HTTP/SSE server. Event + * production remains in the runtime service until the runtime class is split. + * + * Exports: + * - type HeadlessEvent + */ +export type HeadlessEvent = { + type: string; + requestId?: string; + sequence?: number; + timestamp?: string; + [key: string]: unknown; +}; diff --git a/packages/server/src/services/http-server.ts b/packages/server/src/services/http-server.ts new file mode 100644 index 00000000..12a68897 --- /dev/null +++ b/packages/server/src/services/http-server.ts @@ -0,0 +1,132 @@ +/** + * HTTP/SSE server service entry. + * + * Summary: + * Starts the standalone local HTTP/SSE runtime host and wires request handling to + * the split service modules. + * + * Exports: + * - runHeadlessHttp(options: HeadlessOptions): Promise + * - type HeadlessOptions + */ +import crypto from "node:crypto"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { isAuthorized } from "./auth"; +import { shutdownServer } from "./lifecycle"; +import { sendJson } from "./response"; +import { routeRequest } from "./routes"; +import { parseServerOptions } from "./server-options"; +import { openSseStream } from "./sse"; +import { ServerRuntimeService } from "./runtime"; + +export type HeadlessOptions = { + args: string[]; + projectRoot: string; + version: string; +}; + +export async function runHeadlessHttp(options: HeadlessOptions): Promise { + const { host, port, authDisabled } = parseServerOptions(options.args); + if (authDisabled) { + process.stderr.write("Warning: deepcode server auth is disabled. Use only in a trusted local dev environment.\n"); + } + + const accessToken = authDisabled ? null : crypto.randomUUID(); + const runtime = new ServerRuntimeService(options.projectRoot); + await runtime.init(); + + const activeResponses = new Set(); + const httpServer = createServer(async (request, response) => { + activeResponses.add(response); + response.on("close", () => activeResponses.delete(response)); + try { + setBaseHeaders(request, response); + if (request.method === "OPTIONS") { + response.writeHead(204); + response.end(); + return; + } + if (accessToken && !isAuthorized(request, accessToken)) { + sendJson(response, 401, { ok: false, error: "Unauthorized" }); + return; + } + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + const pathname = url.pathname.replace(/\/+$/u, "") || "/"; + if (request.method === "GET" && pathname === "/events") { + openSseStream(request, response, runtime); + return; + } + await routeRequest({ + request, + response, + runtime, + version: options.version, + projectRoot: options.projectRoot, + shutdown: () => shutdown(), + }); + } catch (error) { + sendJson(response, statusCodeFromError(error), { + ok: false, + error: error instanceof Error ? error.message : String(error), + }); + } + }); + + let shuttingDown = false; + const shutdown = (): void => { + if (shuttingDown) { + return; + } + shuttingDown = true; + runtime.notifyShutdown(); + shutdownServer(httpServer, activeResponses); + }; + process.once("SIGINT", shutdown); + process.once("SIGTERM", shutdown); + + await new Promise((resolve) => { + httpServer.listen(port, host, resolve); + }); + + const authHint = accessToken ? ` token=${accessToken}` : " auth=disabled"; + process.stdout.write(`deepcode server listening on http://${host}:${port}${authHint}\n`); + + await new Promise((resolve) => { + httpServer.on("close", resolve); + }); + runtime.dispose(); +} + +function setBaseHeaders(request: IncomingMessage, response: ServerResponse): void { + const origin = request.headers.origin; + response.setHeader("Access-Control-Allow-Origin", isAllowedLocalOrigin(origin) ? origin : "http://127.0.0.1"); + response.setHeader("Vary", "Origin"); + response.setHeader("Access-Control-Allow-Headers", "content-type, x-deepcode-token, authorization"); + response.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); +} + +function isAllowedLocalOrigin(origin: unknown): origin is string { + if (typeof origin !== "string") { + return false; + } + try { + const url = new URL(origin); + return ( + (url.protocol === "http:" || url.protocol === "https:") && + (url.hostname === "127.0.0.1" || url.hostname === "localhost") + ); + } catch { + return false; + } +} + +function statusCodeFromError(error: unknown): number { + if (isRecord(error) && typeof error.statusCode === "number" && Number.isInteger(error.statusCode)) { + return error.statusCode; + } + return 500; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/packages/server/src/services/images.ts b/packages/server/src/services/images.ts new file mode 100644 index 00000000..67b884c2 --- /dev/null +++ b/packages/server/src/services/images.ts @@ -0,0 +1,136 @@ +/** + * Prompt image normalization helpers. + * + * Summary: + * Converts image payload inputs into data URLs or remote image URLs for user + * prompt content. This service owns image path validation, local file loading, + * and MIME inference. + * + * Exports: + * - normalizeImageList(projectRoot: string, value: unknown): Promise<{ ok: true; data: string[] } | { ok: false; error: string }> + */ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { normalizeProjectFilePath } from "./open-file"; + +const MAX_IMAGE_BYTES = 10 * 1024 * 1024; + +export async function normalizeImageList( + projectRoot: string, + value: unknown +): Promise<{ ok: true; data: string[] } | { ok: false; error: string }> { + const items = Array.isArray(value) ? value : value === undefined ? [] : [value]; + const imageUrls: string[] = []; + for (const item of items) { + const normalized = await normalizeImageItem(projectRoot, item); + if (!normalized.ok) { + return normalized; + } + if (normalized.data) { + imageUrls.push(normalized.data); + } + } + return { ok: true, data: imageUrls }; +} + +async function normalizeImageItem( + projectRoot: string, + item: unknown +): Promise<{ ok: true; data: string | null } | { ok: false; error: string }> { + if (typeof item === "string") { + return normalizeImageString(projectRoot, item); + } + if (!isRecord(item)) { + return { ok: false, error: "Image item must be a string or object" }; + } + if (typeof item.dataUrl === "string") { + return normalizeImageString(projectRoot, item.dataUrl); + } + if (typeof item.url === "string") { + return normalizeImageString(projectRoot, item.url); + } + if (typeof item.filePath === "string") { + return readImageFileAsDataUrl(projectRoot, item.filePath); + } + if (typeof item.path === "string") { + return readImageFileAsDataUrl(projectRoot, item.path); + } + return { ok: false, error: "Image object requires dataUrl, url, filePath, or path" }; +} + +async function normalizeImageString( + projectRoot: string, + value: string +): Promise<{ ok: true; data: string | null } | { ok: false; error: string }> { + const trimmed = value.trim(); + if (!trimmed) { + return { ok: true, data: null }; + } + if (trimmed.startsWith("data:image/")) { + return { ok: true, data: trimmed }; + } + if (trimmed.startsWith("blob:")) { + return { ok: false, error: "blob image URLs must be converted before sending to the server" }; + } + if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) { + return { ok: true, data: trimmed }; + } + if (trimmed.startsWith("file://")) { + try { + return readImageFileAsDataUrl(projectRoot, fileURLToPath(trimmed)); + } catch { + return { ok: false, error: "Invalid file URL image" }; + } + } + return readImageFileAsDataUrl(projectRoot, trimmed); +} + +async function readImageFileAsDataUrl( + projectRoot: string, + filePath: string +): Promise<{ ok: true; data: string } | { ok: false; error: string }> { + const request = normalizeProjectFilePath(projectRoot, filePath); + if (!request.ok) { + return request; + } + let stat; + try { + stat = await fs.stat(request.data.absolutePath); + } catch { + return { ok: false, error: `Image file not found: ${request.data.relativePath}` }; + } + if (!stat.isFile()) { + return { ok: false, error: `Image path is not a file: ${request.data.relativePath}` }; + } + if (stat.size > MAX_IMAGE_BYTES) { + return { ok: false, error: `Image file is too large: ${request.data.relativePath}` }; + } + const mime = getImageMimeType(request.data.absolutePath); + if (!mime) { + return { ok: false, error: `Unsupported image type: ${request.data.relativePath}` }; + } + const content = await fs.readFile(request.data.absolutePath); + return { ok: true, data: `data:${mime};base64,${content.toString("base64")}` }; +} + +function getImageMimeType(filePath: string): string | null { + const ext = path.extname(filePath).toLowerCase(); + if (ext === ".png") { + return "image/png"; + } + if (ext === ".jpg" || ext === ".jpeg") { + return "image/jpeg"; + } + if (ext === ".gif") { + return "image/gif"; + } + if (ext === ".webp") { + return "image/webp"; + } + return null; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/packages/server/src/services/lifecycle.ts b/packages/server/src/services/lifecycle.ts new file mode 100644 index 00000000..13d33ca3 --- /dev/null +++ b/packages/server/src/services/lifecycle.ts @@ -0,0 +1,26 @@ +/** + * Server lifecycle helpers. + * + * Summary: + * Owns graceful HTTP server shutdown behavior and active response cleanup. + * + * Exports: + * - shutdownServer(httpServer: ReturnType, activeResponses: Set): void + */ +import type { createServer, ServerResponse } from "node:http"; + +export function shutdownServer( + httpServer: ReturnType, + activeResponses: Set +): void { + for (const response of activeResponses) { + if (!response.writableEnded) { + try { + response.end(); + } catch { + // Ignore close failures during shutdown. + } + } + } + httpServer.close(); +} diff --git a/packages/server/src/services/model-config.ts b/packages/server/src/services/model-config.ts new file mode 100644 index 00000000..57a9e679 --- /dev/null +++ b/packages/server/src/services/model-config.ts @@ -0,0 +1,70 @@ +/** + * Model configuration helpers. + * + * Summary: + * Builds and validates the model configuration payload exposed by GET /model and + * accepted by POST /model. This keeps model option handling outside HTTP route + * dispatch and independent from CLI UI code. + * + * Exports: + * - buildAvailableModelOptions(): ModelOption[] + * - buildReasoningEffortOptions(): ReasoningEffort[] + * - buildThinkingOptions(): boolean[] + * - normalizeModelSelection(body: RequestBody, current: ResolvedDeepcodingSettings) + * - type ModelOption + */ +import { + defaultsToThinkingMode, + supportsMultimodal, + type ModelConfigSelection, + type ReasoningEffort, + type ResolvedDeepcodingSettings, +} from "@vegamo/deepcode-core"; +import { MODEL_COMMAND_MODELS, MODEL_COMMAND_THINKING_OPTIONS } from "../model-options"; +import type { RequestBody } from "./request-body"; + +export type ModelOption = { + model: string; + thinkingDefault: boolean; + supportsMultimodal: boolean; +}; + +export function buildAvailableModelOptions(): ModelOption[] { + return MODEL_COMMAND_MODELS.map((model) => ({ + model, + thinkingDefault: defaultsToThinkingMode(model), + supportsMultimodal: supportsMultimodal(model), + })); +} + +export function buildReasoningEffortOptions(): ReasoningEffort[] { + const efforts = MODEL_COMMAND_THINKING_OPTIONS.map((option) => option.reasoningEffort).filter( + (effort): effort is ReasoningEffort => effort === "high" || effort === "max" + ); + return Array.from(new Set(efforts)); +} + +export function buildThinkingOptions(): boolean[] { + return Array.from(new Set(MODEL_COMMAND_THINKING_OPTIONS.map((option) => option.thinkingEnabled))); +} + +export function normalizeModelSelection( + body: RequestBody, + current: ResolvedDeepcodingSettings +): { ok: true; data: ModelConfigSelection } | { ok: false; error: string } { + const model = typeof body.model === "string" && body.model.trim() ? body.model.trim() : current.model; + const thinkingEnabled = typeof body.thinkingEnabled === "boolean" ? body.thinkingEnabled : current.thinkingEnabled; + const hasReasoningEffort = Object.prototype.hasOwnProperty.call(body, "reasoningEffort"); + const requestedReasoningEffort = hasReasoningEffort ? normalizeReasoningEffort(body.reasoningEffort) : undefined; + if (hasReasoningEffort && !requestedReasoningEffort) { + return { ok: false, error: "reasoningEffort must be high or max" }; + } + return { + ok: true, + data: { model, thinkingEnabled, reasoningEffort: requestedReasoningEffort ?? current.reasoningEffort }, + }; +} + +function normalizeReasoningEffort(value: unknown): ReasoningEffort | undefined { + return value === "high" || value === "max" ? value : undefined; +} diff --git a/packages/server/src/services/open-file.ts b/packages/server/src/services/open-file.ts new file mode 100644 index 00000000..99179a36 --- /dev/null +++ b/packages/server/src/services/open-file.ts @@ -0,0 +1,54 @@ +/** + * Project file opening helpers. + * + * Summary: + * Normalizes project-local file paths and builds platform-specific opener command + * candidates for editor/file open requests. + * + * Exports: + * - normalizeProjectFilePath(projectRoot: string, filePath: string) + * - getOpenFileCommands(filePath: string, line: number): OpenFileCommand[] + * - type OpenFileRequest + * - type OpenFileCommand + */ +import * as path from "node:path"; + +export type OpenFileRequest = { + absolutePath: string; + relativePath: string; + line: number; +}; + +export type OpenFileCommand = { + command: string; + args: string[]; +}; + +export function normalizeProjectFilePath( + projectRoot: string, + filePath: string +): { ok: true; data: Omit } | { ok: false; error: string } { + const trimmedPath = filePath.trim(); + if (!trimmedPath) { + return { ok: false, error: "filePath is required" }; + } + const root = path.resolve(projectRoot); + const absolutePath = path.resolve(path.isAbsolute(trimmedPath) ? trimmedPath : path.join(root, trimmedPath)); + const relativePath = path.relative(root, absolutePath); + if (!relativePath || relativePath.startsWith("..") || path.isAbsolute(relativePath)) { + return { ok: false, error: "filePath must point to a file inside the project root" }; + } + return { ok: true, data: { absolutePath, relativePath } }; +} + +export function getOpenFileCommands(filePath: string, line: number): OpenFileCommand[] { + const commands: OpenFileCommand[] = [{ command: "code", args: ["-g", `${filePath}:${line}`] }]; + if (process.platform === "darwin") { + commands.push({ command: "open", args: [filePath] }); + } else if (process.platform === "win32") { + commands.push({ command: "cmd.exe", args: ["/c", "start", "", filePath] }); + } else { + commands.push({ command: "xdg-open", args: [filePath] }); + } + return commands; +} diff --git a/packages/server/src/services/permissions.ts b/packages/server/src/services/permissions.ts new file mode 100644 index 00000000..6a6403f7 --- /dev/null +++ b/packages/server/src/services/permissions.ts @@ -0,0 +1,58 @@ +/** + * Permission request normalization helpers. + * + * Summary: + * Converts frontend permission reply payloads into core UserToolPermission and + * PermissionScope values. + * + * Exports: + * - normalizeUserPermissions(value: unknown): UserToolPermission[] + * - normalizePermissionScopes(value: unknown): PermissionScope[] | undefined + */ +import type { PermissionScope, UserToolPermission } from "@vegamo/deepcode-core"; + +const VALID_PERMISSION_SCOPES = new Set([ + "read-in-cwd", + "read-out-cwd", + "write-in-cwd", + "write-out-cwd", + "delete-in-cwd", + "delete-out-cwd", + "query-git-log", + "mutate-git-log", + "network", + "mcp", +]); + +export function normalizeUserPermissions(value: unknown): UserToolPermission[] { + const entries = Array.isArray(value) + ? value + : isRecord(value) + ? Object.entries(value).map(([toolCallId, permission]) => ({ toolCallId, permission })) + : []; + return entries + .map((item) => { + if (!isRecord(item) || typeof item.toolCallId !== "string") { + return null; + } + if (item.permission !== "allow" && item.permission !== "deny") { + return null; + } + return { toolCallId: item.toolCallId, permission: item.permission } satisfies UserToolPermission; + }) + .filter((item): item is UserToolPermission => item !== null); +} + +export function normalizePermissionScopes(value: unknown): PermissionScope[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const scopes = value.filter( + (item): item is PermissionScope => typeof item === "string" && VALID_PERMISSION_SCOPES.has(item as PermissionScope) + ); + return scopes.length > 0 ? Array.from(new Set(scopes)) : undefined; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/packages/server/src/services/prompt-content.ts b/packages/server/src/services/prompt-content.ts new file mode 100644 index 00000000..2dde3e87 --- /dev/null +++ b/packages/server/src/services/prompt-content.ts @@ -0,0 +1,48 @@ +/** + * Prompt content helpers. + * + * Summary: + * Converts request payloads into core UserPromptContent values. This module is + * intentionally limited to payload normalization and does not execute prompts. + * + * Exports: + * - buildPromptContent(projectRoot: string, body: RequestBody) + */ +import type { SkillInfo, UserPromptContent } from "@vegamo/deepcode-core"; +import { normalizeImageList } from "./images"; +import { normalizePermissionScopes, normalizeUserPermissions } from "./permissions"; +import type { RequestBody } from "./request-body"; + +export async function buildPromptContent( + projectRoot: string, + body: RequestBody +): Promise<{ ok: true; data: UserPromptContent } | { ok: false; error: string }> { + const text = typeof body.text === "string" ? body.text : typeof body.prompt === "string" ? body.prompt : ""; + const images = await normalizeImageList(projectRoot, body.images ?? body.imageUrls); + if (!images.ok) { + return images; + } + const userPermissions = normalizeUserPermissions(body.permissions ?? body.decisions); + return { + ok: true, + data: { + text, + skills: normalizeSkillList(body.skills), + imageUrls: images.data.length > 0 ? images.data : undefined, + permissions: userPermissions.length > 0 ? userPermissions : undefined, + alwaysAllows: normalizePermissionScopes(body.alwaysAllows), + }, + }; +} + +function normalizeSkillList(value: unknown): SkillInfo[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const skills = value.filter((item): item is SkillInfo => isRecord(item) && typeof item.name === "string"); + return skills.length > 0 ? skills : undefined; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/packages/server/src/services/request-body.ts b/packages/server/src/services/request-body.ts new file mode 100644 index 00000000..533d7182 --- /dev/null +++ b/packages/server/src/services/request-body.ts @@ -0,0 +1,65 @@ +/** + * Request body parsing helpers. + * + * Summary: + * Reads JSON request bodies with a byte limit and returns typed request payloads. + * This module deliberately contains only request parsing and validation concerns. + * + * Exports: + * - readJsonBody(request: IncomingMessage): Promise + * - type RequestBody + */ +import type { IncomingMessage } from "node:http"; + +export type RequestBody = { + text?: unknown; + prompt?: unknown; + skills?: unknown; + images?: unknown; + imageUrls?: unknown; + sessionId?: unknown; + permissions?: unknown; + alwaysAllows?: unknown; + decisions?: unknown; + mode?: unknown; + filePath?: unknown; + path?: unknown; + line?: unknown; + messageId?: unknown; + restoreCode?: unknown; + restoreConversation?: unknown; + summary?: unknown; + name?: unknown; + model?: unknown; + thinkingEnabled?: unknown; + reasoningEffort?: unknown; + deltaMs?: unknown; +}; + +const MAX_BODY_BYTES = 16 * 1024 * 1024; +const MAX_BODY_MIB = MAX_BODY_BYTES / 1024 / 1024; + +export async function readJsonBody(request: IncomingMessage): Promise { + const chunks: Buffer[] = []; + let size = 0; + for await (const chunk of request) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)); + size += buffer.length; + if (size > MAX_BODY_BYTES) { + throw Object.assign(new Error(`Request body too large; limit is ${MAX_BODY_MIB} MiB`), { statusCode: 413 }); + } + chunks.push(buffer); + } + if (chunks.length === 0) { + return {}; + } + const raw = Buffer.concat(chunks).toString("utf8").trim(); + if (!raw) { + return {}; + } + try { + return JSON.parse(raw) as RequestBody; + } catch { + throw Object.assign(new Error("Invalid JSON body"), { statusCode: 400 }); + } +} diff --git a/packages/server/src/services/response.ts b/packages/server/src/services/response.ts new file mode 100644 index 00000000..b618495c --- /dev/null +++ b/packages/server/src/services/response.ts @@ -0,0 +1,61 @@ +/** + * JSON response serialization helpers. + * + * Summary: + * Serializes service payloads to HTTP responses and normalizes failed payloads + * into stable HTTP status codes. + * + * Exports: + * - sendJson(response: ServerResponse, statusCode: number, payload: JsonValue): void + * - statusCodeForFailure(payload: { ok: false; error?: unknown }): number + */ +import type { ServerResponse } from "node:http"; +import type { JsonValue } from "./types"; + +export function sendJson(response: ServerResponse, statusCode: number, payload: JsonValue): void { + if (response.headersSent) { + return; + } + const finalStatusCode = statusCode < 400 && isFailurePayload(payload) ? statusCodeForFailure(payload) : statusCode; + response.writeHead(finalStatusCode, { "content-type": "application/json; charset=utf-8" }); + response.end(`${JSON.stringify(payload, null, 2)}\n`); +} + +export function statusCodeForFailure(payload: Record & { ok: false; error?: unknown }): number { + const error = typeof payload.error === "string" ? payload.error.toLowerCase() : ""; + if (error.includes("not found")) { + return 404; + } + if ( + error.includes("required") || + error.includes("invalid") || + error.includes("unsupported") || + error.includes("too large") || + error.includes("must") || + error.includes("no permission replies") + ) { + return 400; + } + if ( + error.includes("busy") || + error.includes("no active") || + error.includes("pending") || + error.includes("permission request") || + error.includes("permission mismatch") || + error.includes("permission denied") || + error.includes("conflict") || + error.includes("state") || + error.includes("adjustable") + ) { + return 409; + } + return 400; +} + +function isFailurePayload(payload: JsonValue): payload is Record & { ok: false; error?: unknown } { + return isRecord(payload) && payload.ok === false; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/packages/server/src/services/routes.ts b/packages/server/src/services/routes.ts new file mode 100644 index 00000000..05d03505 --- /dev/null +++ b/packages/server/src/services/routes.ts @@ -0,0 +1,161 @@ +/** + * HTTP route dispatch service. + * + * Summary: + * Maps local server HTTP paths to runtime actions. This service owns request path + * dispatch only; runtime behavior stays behind the shared ServerRuntime contract. + * + * Exports: + * - routeRequest(input: RouteRequestInput): Promise + * - type RouteRequestInput + */ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { buildHeadlessCommandRoutes, findHeadlessCommandRoute } from "../command-map"; +import { buildPromptContent } from "./prompt-content"; +import { readJsonBody } from "./request-body"; +import type { ServerRuntime } from "./runtime-contract"; +import { sendJson } from "./response"; + +export type RouteRequestInput = { + request: IncomingMessage; + response: ServerResponse; + runtime: ServerRuntime; + version: string; + projectRoot: string; + shutdown: () => void; +}; + +export async function routeRequest(input: RouteRequestInput): Promise { + const { request, response, runtime, version, projectRoot, shutdown } = input; + const method = request.method ?? "GET"; + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + const pathname = url.pathname.replace(/\/+$/u, "") || "/"; + + if ((method === "GET" || method === "POST") && pathname === "/ready") { + return sendJson(response, 200, await runtime.ready()); + } + if (method === "GET" && pathname === "/health") { + return sendJson(response, 200, { ok: true, data: { version, projectRoot } }); + } + if (method === "GET" && pathname === "/version") { + return sendJson(response, 200, { ok: true, data: { version } }); + } + if (method === "GET" && pathname === "/commands") { + return sendJson(response, 200, { ok: true, data: buildHeadlessCommandRoutes() }); + } + if (method === "GET" && pathname === "/model") { + return sendJson(response, 200, runtime.getModelConfig()); + } + if (method === "POST" && pathname === "/model") { + return sendJson(response, 200, runtime.updateModelConfig(await readJsonBody(request))); + } + if (method === "GET" && pathname === "/processes") { + return sendJson(response, 200, runtime.listProcesses()); + } + if (method === "POST" && pathname === "/processes/timeout") { + return sendJson(response, 200, runtime.adjustProcessTimeout(await readJsonBody(request))); + } + if (method === "GET" && pathname === "/sessions") { + return sendJson(response, 200, { ok: true, data: runtime.listSessions() }); + } + if (method === "POST" && pathname === "/sessions/rename") { + return sendJson(response, 200, runtime.renameSession(await readJsonBody(request))); + } + if (method === "POST" && pathname === "/sessions/delete") { + return sendJson(response, 200, runtime.deleteSession(await readJsonBody(request))); + } + if ((method === "GET" || method === "POST") && pathname === "/request-skills") { + return sendJson(response, 200, await runtime.sendSkillsList()); + } + if ((method === "GET" || method === "POST") && pathname === "/back-to-list") { + return sendJson(response, 200, runtime.showSessionsList()); + } + if (method === "POST" && (pathname === "/open-file" || pathname === "/openFile")) { + return sendJson(response, 200, runtime.openFile(await readJsonBody(request))); + } + if (method === "GET" && pathname === "/permissions/pending") { + return sendJson(response, 200, runtime.pendingPermissions()); + } + if (method === "POST" && pathname === "/permissions/reply") { + return sendJson(response, 202, runtime.replyPermissions(await readJsonBody(request))); + } + if (method === "POST" && pathname === "/select-session") { + const body = await readJsonBody(request); + return sendJson(response, 200, await runtime.selectSession(String(body.sessionId ?? ""))); + } + if (method === "POST" && pathname === "/prompt") { + const prompt = await buildPromptContent(projectRoot, await readJsonBody(request)); + return sendJson( + response, + prompt.ok ? 202 : 400, + prompt.ok ? runtime.startPrompt(prompt.data) : { ok: false, error: prompt.error } + ); + } + if (method === "POST" && pathname === "/interrupt") { + return sendJson(response, 200, runtime.interrupt()); + } + if (method === "POST" && pathname === "/undo/restore") { + return sendJson(response, 200, runtime.restoreUndo(await readJsonBody(request))); + } + if (method === "POST" && pathname === "/undo/restore-code") { + return sendJson( + response, + 200, + runtime.restoreUndo(await readJsonBody(request), { restoreCode: true, restoreConversation: false }) + ); + } + if (method === "POST" && pathname === "/undo/restore-conversation") { + return sendJson( + response, + 200, + runtime.restoreUndo(await readJsonBody(request), { restoreCode: false, restoreConversation: true }) + ); + } + if (method === "POST" && pathname === "/exit") { + sendJson(response, 200, { ok: true }); + setTimeout(shutdown, 0); + return; + } + + const command = findHeadlessCommandRoute(pathname); + if (!command) { + return sendJson(response, 404, { ok: false, error: "Not found" }); + } + if (method !== command.method && !(command.name === "undo" && method === "GET")) { + return sendJson(response, 405, { ok: false, error: `Use ${command.method} ${command.path}` }); + } + if (!command.implemented) { + return sendJson(response, 501, { + ok: false, + error: `Command ${command.label} is not implemented in server mode yet.`, + }); + } + if (command.name === "skills") { + return sendJson(response, 200, await runtime.sendSkillsList()); + } + if (command.name === "mcp") { + return sendJson(response, 200, { ok: true, data: runtime.getMcpStatus() }); + } + if (command.name === "resume") { + return sendJson(response, 200, runtime.showSessionsList()); + } + if (command.name === "new") { + return sendJson(response, 200, await runtime.newSession()); + } + if (command.name === "undo") { + return sendJson(response, 200, runtime.undoTargets()); + } + if (command.name === "exit") { + sendJson(response, 200, { ok: true }); + setTimeout(shutdown, 0); + return; + } + + const body = method === "POST" ? await readJsonBody(request) : {}; + const prompt = await buildPromptContent(projectRoot, { ...body, text: `/${command.name}` }); + return sendJson( + response, + prompt.ok ? 202 : 400, + prompt.ok ? runtime.startPrompt(prompt.data) : { ok: false, error: prompt.error } + ); +} diff --git a/packages/server/src/services/runtime-contract.ts b/packages/server/src/services/runtime-contract.ts new file mode 100644 index 00000000..8fecdb27 --- /dev/null +++ b/packages/server/src/services/runtime-contract.ts @@ -0,0 +1,38 @@ +/** + * Runtime contract for HTTP services. + * + * Summary: + * Defines the runtime surface consumed by route and SSE services. The concrete + * runtime implementation lives in runtime.ts and the HTTP wiring consumes this + * contract through the split service modules. + * + * Exports: + * - type ServerRuntime + */ +import type { HeadlessEvent } from "./events"; +import type { RequestBody } from "./request-body"; +import type { JsonValue } from "./types"; + +export type ServerRuntime = { + subscribe(listener: (event: HeadlessEvent) => void): () => void; + ready(): Promise; + getModelConfig(): JsonValue; + updateModelConfig(body: RequestBody): JsonValue; + listProcesses(): JsonValue; + adjustProcessTimeout(body: RequestBody): JsonValue; + listSessions(): JsonValue; + renameSession(body: RequestBody): JsonValue; + deleteSession(body: RequestBody): JsonValue; + sendSkillsList(): Promise; + showSessionsList(): JsonValue; + openFile(body: RequestBody): JsonValue; + pendingPermissions(): JsonValue; + replyPermissions(body: RequestBody): JsonValue; + selectSession(sessionId: string): Promise; + startPrompt(prompt: unknown): JsonValue; + interrupt(): JsonValue; + restoreUndo(body: RequestBody, defaults?: { restoreCode?: boolean; restoreConversation?: boolean }): JsonValue; + newSession(): Promise; + undoTargets(): JsonValue; + getMcpStatus(): unknown[]; +}; diff --git a/packages/server/src/services/runtime.ts b/packages/server/src/services/runtime.ts new file mode 100644 index 00000000..aa9404b1 --- /dev/null +++ b/packages/server/src/services/runtime.ts @@ -0,0 +1,548 @@ +/** + * DeepCode server runtime service. + * + * Summary: + * Owns the SessionManager-backed runtime surface used by HTTP routes and SSE. + * This module moves the concrete runtime class out of the legacy HTTP server + * shell and implements the shared ServerRuntime contract. + * + * Exports: + * - ServerRuntimeService(projectRoot: string) + */ +import crypto from "node:crypto"; +import { spawn } from "node:child_process"; +import { + createOpenAIClient, + getCompactPromptTokenThreshold, + resolveCurrentSettings, + SessionManager, + writeModelConfigSelection, + type ResolvedDeepcodingSettings, + type SessionEntry, + type UserPromptContent, +} from "@vegamo/deepcode-core"; +import type { HeadlessEvent } from "./events"; +import { + buildAvailableModelOptions, + buildReasoningEffortOptions, + buildThinkingOptions, + normalizeModelSelection, +} from "./model-config"; +import { getOpenFileCommands, normalizeProjectFilePath, type OpenFileCommand, type OpenFileRequest } from "./open-file"; +import { normalizePermissionScopes, normalizeUserPermissions } from "./permissions"; +import type { RequestBody } from "./request-body"; +import type { ServerRuntime } from "./runtime-contract"; +import { serializeMessage, serializeProcesses } from "./session-serialization"; +import type { JsonValue } from "./types"; + +export class ServerRuntimeService implements ServerRuntime { + private readonly listeners = new Set<(event: HeadlessEvent) => void>(); + private readonly sessionManager: SessionManager; + private activeRequestId: string | null = null; + private sequence = 0; + + constructor(private readonly projectRoot: string) { + this.sessionManager = new SessionManager({ + projectRoot, + createOpenAIClient: () => createOpenAIClient(projectRoot), + getResolvedSettings: () => resolveCurrentSettings(projectRoot), + renderMarkdown: (text) => text, + onAssistantMessage: (message, shouldConnect) => { + if (message.visible === false) { + return; + } + this.pushEvent({ type: "appendMessage", message: serializeMessage(message), shouldConnect }); + }, + onSessionEntryUpdated: (entry) => { + this.pushEvent({ + type: "sessionStatus", + sessionId: entry.id, + status: entry.status, + processes: serializeProcesses(entry.processes), + askPermissions: entry.askPermissions, + tokenTelemetry: this.buildTokenTelemetry(entry), + }); + if (entry.status === "ask_permission") { + this.pushEvent({ + type: "permissionRequest", + sessionId: entry.id, + askPermissions: entry.askPermissions ?? [], + }); + } + }, + onLlmStreamProgress: (progress) => this.pushEvent({ type: "llmStreamProgress", progress }), + onMcpStatusChanged: () => this.pushEvent({ type: "mcpStatus", statuses: this.getMcpStatus() }), + onProcessStdout: (pid, chunk) => this.pushEvent({ type: "processStdout", pid, chunk }), + }); + } + + async init(): Promise { + await this.sessionManager.initMcpServers(resolveCurrentSettings(this.projectRoot).mcpServers); + } + + dispose(): void { + this.sessionManager.dispose(); + this.listeners.clear(); + } + + notifyShutdown(): void { + this.pushEvent({ type: "shutdown" }); + } + + subscribe(listener: (event: HeadlessEvent) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + async ready(): Promise { + const events: HeadlessEvent[] = []; + events.push(this.pushEvent(this.buildInitialSessionEvent())); + events.push(this.pushEvent(await this.buildSkillsListEvent())); + events.push( + this.pushEvent({ type: "modelConfig", config: this.buildModelConfig(resolveCurrentSettings(this.projectRoot)) }) + ); + return { ok: true, data: { events } }; + } + + listSessions(): JsonValue { + return this.sessionManager.listSessions() as unknown as JsonValue; + } + + async sendSkillsList(sessionId?: string): Promise { + const event = await this.buildSkillsListEvent(sessionId); + this.pushEvent(event); + return { ok: true, data: event }; + } + + getMcpStatus(): unknown[] { + return this.sessionManager.getMcpStatus(); + } + + getModelConfig(): JsonValue { + return { ok: true, data: this.buildModelConfig(resolveCurrentSettings(this.projectRoot)) }; + } + + updateModelConfig(body: RequestBody): JsonValue { + const current = resolveCurrentSettings(this.projectRoot); + const selected = normalizeModelSelection(body, current); + if (!selected.ok) { + return { ok: false, error: selected.error }; + } + const result = writeModelConfigSelection(selected.data, current, this.projectRoot); + const next = resolveCurrentSettings(this.projectRoot); + const event = this.pushEvent({ type: "modelConfig", config: this.buildModelConfig(next), changed: result.changed }); + return { ok: true, data: event }; + } + + listProcesses(): JsonValue { + const sessionId = this.sessionManager.getActiveSessionId(); + if (!sessionId) { + return { ok: false, error: "No active session" }; + } + const session = this.sessionManager.getSession(sessionId); + if (!session) { + return { ok: false, error: "Session not found" }; + } + return { ok: true, data: { sessionId, processes: serializeProcesses(session.processes) } }; + } + + adjustProcessTimeout(body: RequestBody): JsonValue { + const delta = normalizeDeltaMs(body.deltaMs); + if (!delta.ok) { + return { ok: false, error: delta.error }; + } + const result = this.sessionManager.adjustActiveBashTimeout(delta.data); + if (!result) { + return { ok: false, error: "No adjustable active bash timeout" }; + } + this.pushActiveSessionStatus(); + return { ok: true, data: result as JsonValue }; + } + + showSessionsList(): JsonValue { + const event = { type: "showSessionsList", sessions: this.buildSessionsList() }; + this.pushEvent(event); + return { ok: true, data: event }; + } + + async selectSession(sessionId: string): Promise { + const session = this.sessionManager.getSession(sessionId); + if (!session) { + return { ok: false, error: "Session not found" }; + } + this.sessionManager.setActiveSessionId(sessionId); + const loadEvent = this.buildLoadSessionEvent(session); + const skillsEvent = await this.buildSkillsListEvent(sessionId); + this.pushEvent(loadEvent); + this.pushEvent(skillsEvent); + return { ok: true, data: { events: [loadEvent, skillsEvent] } }; + } + + async newSession(): Promise { + if (this.activeRequestId) { + return { ok: false, error: "DeepCode is busy" }; + } + this.sessionManager.setActiveSessionId(null); + const initEvent = this.buildInitializeEmptyEvent(); + const skillsEvent = await this.buildSkillsListEvent(); + this.pushEvent(initEvent); + this.pushEvent(skillsEvent); + return { ok: true, data: { events: [initEvent, skillsEvent] } }; + } + + interrupt(): JsonValue { + const sessionId = this.sessionManager.getActiveSessionId(); + this.sessionManager.interruptActiveSession(); + this.pushActiveSessionStatus(); + const session = sessionId ? this.sessionManager.getSession(sessionId) : null; + return { ok: true, data: { sessionId, status: session?.status ?? null } }; + } + + openFile(body: RequestBody): JsonValue { + const request = normalizeOpenFileRequest(this.projectRoot, body); + if (!request.ok) { + return { ok: false, error: request.error }; + } + const opened = launchOpenFile(request.data, (error) => { + this.pushEvent({ + type: "openFileFailed", + filePath: request.data.relativePath, + absolutePath: request.data.absolutePath, + line: request.data.line, + error: error instanceof Error ? error.message : String(error), + }); + }); + const event = this.pushEvent({ + type: "openFile", + filePath: request.data.relativePath, + absolutePath: request.data.absolutePath, + line: request.data.line, + opener: opened, + }); + return { ok: true, data: event }; + } + + undoTargets(): JsonValue { + const sessionId = this.sessionManager.getActiveSessionId(); + if (!sessionId) { + return { ok: false, error: "No active session" }; + } + return { ok: true, data: this.sessionManager.listUndoTargets(sessionId) as unknown as JsonValue }; + } + + restoreUndo(body: RequestBody, defaults: { restoreCode?: boolean; restoreConversation?: boolean } = {}): JsonValue { + const sessionId = normalizeSessionId(body.sessionId, this.sessionManager.getActiveSessionId()); + if (!sessionId) { + return { ok: false, error: "No active session" }; + } + const messageId = typeof body.messageId === "string" ? body.messageId.trim() : ""; + if (!messageId) { + return { ok: false, error: "messageId is required" }; + } + const restoreCode = defaults.restoreCode ?? body.restoreCode === true; + const restoreConversation = defaults.restoreConversation ?? body.restoreConversation !== false; + if (!restoreCode && !restoreConversation) { + return { ok: false, error: "restoreCode or restoreConversation must be true" }; + } + try { + if (restoreCode) { + this.sessionManager.restoreSessionCode(sessionId, messageId); + } + if (restoreConversation) { + this.sessionManager.restoreSessionConversation(sessionId, messageId); + } + const events: HeadlessEvent[] = []; + const session = this.sessionManager.getSession(sessionId); + if (session) { + const loadEvent = this.buildLoadSessionEvent(session); + this.pushEvent(loadEvent); + events.push(loadEvent); + } + const listEvent = { type: "showSessionsList", sessions: this.buildSessionsList() }; + this.pushEvent(listEvent); + events.push(listEvent); + return { + ok: true, + data: { sessionId, messageId, restoredCode: restoreCode, restoredConversation: restoreConversation, events }, + }; + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) }; + } + } + + renameSession(body: RequestBody): JsonValue { + const sessionId = normalizeSessionId(body.sessionId, this.sessionManager.getActiveSessionId()); + const summary = typeof body.summary === "string" ? body.summary : typeof body.name === "string" ? body.name : ""; + if (!sessionId) { + return { ok: false, error: "sessionId is required" }; + } + if (!summary.trim()) { + return { ok: false, error: "summary is required" }; + } + const renamed = this.sessionManager.renameSession(sessionId, summary); + if (!renamed) { + return { ok: false, error: "Session not found or summary is empty" }; + } + const event = { type: "showSessionsList", sessions: this.buildSessionsList() }; + this.pushEvent(event); + this.pushActiveSessionStatus(); + return { ok: true, data: event }; + } + + deleteSession(body: RequestBody): JsonValue { + const sessionId = normalizeSessionId(body.sessionId, this.sessionManager.getActiveSessionId()); + if (!sessionId) { + return { ok: false, error: "sessionId is required" }; + } + const wasActive = this.sessionManager.getActiveSessionId() === sessionId; + const deleted = this.sessionManager.deleteSession(sessionId); + if (!deleted) { + return { ok: false, error: "Session not found" }; + } + const events: HeadlessEvent[] = []; + if (wasActive) { + this.sessionManager.setActiveSessionId(null); + const initEvent = this.buildInitializeEmptyEvent(); + this.pushEvent(initEvent); + events.push(initEvent); + } + const listEvent = { type: "showSessionsList", sessions: this.buildSessionsList() }; + this.pushEvent(listEvent); + events.push(listEvent); + return { ok: true, data: { sessionId, events } }; + } + + pendingPermissions(): JsonValue { + const sessionId = this.sessionManager.getActiveSessionId(); + if (!sessionId) { + return { ok: false, error: "No active session" }; + } + const session = this.sessionManager.getSession(sessionId); + if (!session) { + return { ok: false, error: "Session not found" }; + } + return { ok: true, data: { sessionId, status: session.status, askPermissions: session.askPermissions ?? [] } }; + } + + replyPermissions(body: RequestBody): JsonValue { + const sessionId = this.sessionManager.getActiveSessionId(); + if (!sessionId) { + return { ok: false, error: "No active session" }; + } + const session = this.sessionManager.getSession(sessionId); + if (!session) { + return { ok: false, error: "Session not found" }; + } + if (!session.askPermissions || session.askPermissions.length === 0) { + return { ok: false, error: "No pending permission request" }; + } + const permissions = normalizeUserPermissions(body.permissions ?? body.decisions); + if (permissions.length === 0) { + return { ok: false, error: "No permission replies provided" }; + } + const alwaysAllows = normalizePermissionScopes(body.alwaysAllows); + const hasDeny = permissions.some((permission) => permission.permission === "deny"); + const mode = typeof body.mode === "string" ? body.mode : undefined; + const text = + typeof body.text === "string" ? body.text : typeof body.prompt === "string" ? body.prompt : "/continue"; + if (hasDeny && mode === "deny-and-stop") { + this.sessionManager.denySessionPermission(sessionId); + this.pushActiveSessionStatus(); + return { ok: true, data: { sessionId, denied: true } }; + } + return this.startPrompt({ text: text.trim() || "/continue", permissions, alwaysAllows }); + } + + startPrompt(userPrompt: unknown): JsonValue { + if (this.activeRequestId) { + return { ok: false, error: "DeepCode is busy", requestId: this.activeRequestId }; + } + const requestId = crypto.randomUUID(); + this.activeRequestId = requestId; + void this.runPromptTurn(requestId, userPrompt as UserPromptContent); + return { ok: true, data: { accepted: true, requestId } }; + } + + private async runPromptTurn(requestId: string, userPrompt: UserPromptContent): Promise { + const previousRequestId = this.activeRequestId; + this.activeRequestId = requestId; + const displayPrompt = + userPrompt.text || (userPrompt.imageUrls && userPrompt.imageUrls.length > 0 ? "粘贴的图像" : ""); + this.pushEvent({ type: "userMessage", content: displayPrompt }); + this.pushEvent({ type: "loading", value: true }); + try { + await this.sessionManager.handleUserPrompt(userPrompt); + this.pushEvent(await this.buildSkillsListEvent()); + this.pushActiveSessionStatus(); + this.pushEvent({ type: "showSessionsList", sessions: this.buildSessionsList() }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.pushEvent({ type: "assistant", content: `Request failed: ${message}` }); + this.pushEvent({ type: "error", message }); + } finally { + this.pushEvent({ type: "loading", value: false }); + this.activeRequestId = previousRequestId === requestId ? null : previousRequestId; + } + } + + private pushActiveSessionStatus(): void { + const sessionId = this.sessionManager.getActiveSessionId(); + const session = sessionId ? this.sessionManager.getSession(sessionId) : null; + if (!sessionId || !session) { + return; + } + this.pushEvent({ + type: "sessionStatus", + sessionId, + status: session.status, + processes: serializeProcesses(session.processes), + askPermissions: session.askPermissions, + tokenTelemetry: this.buildTokenTelemetry(session), + }); + } + + private pushEvent(event: HeadlessEvent): HeadlessEvent { + const enriched: HeadlessEvent = { + ...event, + requestId: event.requestId ?? this.activeRequestId ?? undefined, + sequence: ++this.sequence, + timestamp: new Date().toISOString(), + }; + for (const listener of this.listeners) { + listener(enriched); + } + return enriched; + } + + private buildInitialSessionEvent(): HeadlessEvent { + const sessions = this.sessionManager.listSessions(); + if (sessions.length === 0) { + return this.buildInitializeEmptyEvent(); + } + const latestSession = sessions[0]; + this.sessionManager.setActiveSessionId(latestSession.id); + return this.buildLoadSessionEvent(latestSession); + } + + private buildInitializeEmptyEvent(): HeadlessEvent { + return { + type: "initializeEmpty", + sessions: this.buildSessionsList(), + status: null, + tokenTelemetry: this.buildTokenTelemetry(null), + }; + } + + private buildLoadSessionEvent(session: SessionEntry): HeadlessEvent { + const messages = this.sessionManager.listSessionMessages(session.id).filter((message) => message.visible); + return { + type: "loadSession", + sessionId: session.id, + summary: session.summary || "Untitled", + status: session.status, + processes: serializeProcesses(session.processes), + tokenTelemetry: this.buildTokenTelemetry(session), + sessions: this.buildSessionsList(), + messages: messages.map((message) => serializeMessage(message)), + }; + } + + private async buildSkillsListEvent(sessionId?: string): Promise { + const skills = await this.sessionManager.listSkills( + sessionId ?? this.sessionManager.getActiveSessionId() ?? undefined + ); + return { type: "skillsList", skills }; + } + + private buildSessionsList(): Array< + Pick & { summary: string } + > { + return this.sessionManager.listSessions().map((session) => ({ + id: session.id, + summary: session.summary || "Untitled", + createTime: session.createTime, + updateTime: session.updateTime, + status: session.status, + })); + } + + private buildTokenTelemetry(session: SessionEntry | null): JsonValue { + const settings = resolveCurrentSettings(this.projectRoot); + return { + model: settings.model, + thinkingEnabled: settings.thinkingEnabled, + reasoningEffort: settings.reasoningEffort, + activeTokens: session?.activeTokens ?? 0, + compactPromptTokenThreshold: getCompactPromptTokenThreshold(settings.model), + usage: session?.usage ?? null, + }; + } + + private buildModelConfig(settings: ResolvedDeepcodingSettings): JsonValue { + return { + model: settings.model, + baseURL: settings.baseURL, + provider: { baseURL: settings.baseURL, apiKeyConfigured: Boolean(settings.apiKey) }, + availableModels: buildAvailableModelOptions(), + reasoningEfforts: buildReasoningEffortOptions(), + thinkingOptions: buildThinkingOptions(), + temperature: settings.temperature, + thinkingEnabled: settings.thinkingEnabled, + reasoningEffort: settings.reasoningEffort, + debugLogEnabled: settings.debugLogEnabled, + telemetryEnabled: settings.telemetryEnabled, + webSearchTool: settings.webSearchTool, + }; + } +} + +function normalizeOpenFileRequest( + projectRoot: string, + body: RequestBody +): { ok: true; data: OpenFileRequest } | { ok: false; error: string } { + const rawPath = typeof body.filePath === "string" ? body.filePath : typeof body.path === "string" ? body.path : ""; + const request = normalizeProjectFilePath(projectRoot, rawPath); + if (!request.ok) { + return request; + } + const lineNumber = Number(body.line ?? 1); + const line = Number.isInteger(lineNumber) && lineNumber > 0 ? lineNumber : 1; + return { ok: true, data: { ...request.data, line } }; +} + +function launchOpenFile(request: OpenFileRequest, onFinalError: (error: unknown) => void): OpenFileCommand | null { + const commands = getOpenFileCommands(request.absolutePath, request.line); + let index = 0; + const tryNext = (): void => { + const candidate = commands[index]; + if (!candidate) { + onFinalError(new Error("No available opener command")); + return; + } + index += 1; + try { + const child = spawn(candidate.command, candidate.args, { detached: true, stdio: "ignore" }); + child.once("error", () => tryNext()); + child.unref(); + } catch (error) { + if (index >= commands.length) { + onFinalError(error); + } else { + tryNext(); + } + } + }; + tryNext(); + return commands[0] ?? null; +} + +function normalizeDeltaMs(value: unknown): { ok: true; data: number } | { ok: false; error: string } { + const deltaMs = typeof value === "number" ? value : typeof value === "string" && value.trim() ? Number(value) : NaN; + return Number.isFinite(deltaMs) && deltaMs !== 0 + ? { ok: true, data: deltaMs } + : { ok: false, error: "deltaMs must be a non-zero finite number" }; +} + +function normalizeSessionId(value: unknown, fallback: string | null): string | null { + return typeof value === "string" && value.trim() ? value.trim() : fallback; +} diff --git a/packages/server/src/services/server-options.ts b/packages/server/src/services/server-options.ts new file mode 100644 index 00000000..b5e7b2a3 --- /dev/null +++ b/packages/server/src/services/server-options.ts @@ -0,0 +1,43 @@ +/** + * Server option parsing helpers. + * + * Summary: + * Parses argument arrays accepted by the standalone local server package and + * preserves the local-only default binding policy. + * + * Exports: + * - parseServerOptions(args: string[]): ParsedServerOptions + * - readArgValue(args: string[], name: string): string | undefined + * - type ParsedServerOptions + */ +export type ParsedServerOptions = { + host: string; + port: number; + authDisabled: boolean; +}; + +const DEFAULT_HOST = "127.0.0.1"; +const DEFAULT_PORT = 8787; + +export function parseServerOptions(args: string[]): ParsedServerOptions { + const host = readArgValue(args, "--host") ?? DEFAULT_HOST; + const rawPort = readArgValue(args, "--port") ?? String(DEFAULT_PORT); + const port = Number(rawPort); + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + throw new Error(`Invalid --port value: ${rawPort}`); + } + if ((host === "0.0.0.0" || host === "::") && !args.includes("--unsafe-bind")) { + throw new Error("Binding outside localhost requires --unsafe-bind."); + } + return { host, port, authDisabled: args.includes("--no-auth") }; +} + +export function readArgValue(args: string[], name: string): string | undefined { + const prefix = `${name}=`; + const inline = args.find((arg) => arg.startsWith(prefix)); + if (inline) { + return inline.slice(prefix.length); + } + const index = args.indexOf(name); + return index !== -1 && index + 1 < args.length ? args[index + 1] : undefined; +} diff --git a/packages/server/src/services/session-serialization.ts b/packages/server/src/services/session-serialization.ts new file mode 100644 index 00000000..71b62eb4 --- /dev/null +++ b/packages/server/src/services/session-serialization.ts @@ -0,0 +1,41 @@ +/** + * Session serialization helpers. + * + * Summary: + * Converts core SessionMessage and process maps into JSON-compatible payloads for + * SSE events and HTTP route responses. + * + * Exports: + * - serializeMessage(message: SessionMessage): JsonValue + * - serializeProcesses(processes: SessionEntry["processes"]): JsonValue + */ +import type { SessionEntry, SessionMessage } from "@vegamo/deepcode-core"; +import type { JsonValue } from "./types"; + +export function serializeMessage(message: SessionMessage): JsonValue { + return { + id: message.id, + sessionId: message.sessionId, + role: message.role, + content: message.content, + contentParams: message.contentParams, + messageParams: message.messageParams, + compacted: message.compacted, + visible: message.visible, + createTime: message.createTime, + updateTime: message.updateTime, + meta: message.meta, + checkpointHash: message.checkpointHash, + }; +} + +export function serializeProcesses(processes: SessionEntry["processes"]): JsonValue { + if (!processes || processes.size === 0) { + return null; + } + const result: Record = {}; + for (const [pid, entry] of processes.entries()) { + result[pid] = entry; + } + return result; +} diff --git a/packages/server/src/services/sse.ts b/packages/server/src/services/sse.ts new file mode 100644 index 00000000..c04fcb1a --- /dev/null +++ b/packages/server/src/services/sse.ts @@ -0,0 +1,35 @@ +/** + * Server-sent events stream helpers. + * + * Summary: + * Opens and writes SSE streams for frontend event subscriptions. Runtime event + * production is represented by the shared ServerRuntime contract. + * + * Exports: + * - openSseStream(request: IncomingMessage, response: ServerResponse, runtime: ServerRuntime): void + * - writeSseEvent(response: ServerResponse, eventName: string, data: JsonValue): void + */ +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { ServerRuntime } from "./runtime-contract"; +import type { JsonValue } from "./types"; + +export function openSseStream(request: IncomingMessage, response: ServerResponse, runtime: ServerRuntime): void { + response.writeHead(200, { + "content-type": "text/event-stream; charset=utf-8", + "cache-control": "no-cache, no-transform", + connection: "keep-alive", + "x-accel-buffering": "no", + }); + writeSseEvent(response, "connected", { type: "connected" }); + const unsubscribe = runtime.subscribe((event) => writeSseEvent(response, event.type, event)); + const timer = setInterval(() => response.write(": keep-alive\n\n"), 15000); + request.on("close", () => { + clearInterval(timer); + unsubscribe(); + }); +} + +export function writeSseEvent(response: ServerResponse, eventName: string, data: JsonValue): void { + response.write(`event: ${eventName}\n`); + response.write(`data: ${JSON.stringify(data)}\n\n`); +} diff --git a/packages/server/src/services/types.ts b/packages/server/src/services/types.ts new file mode 100644 index 00000000..277a6d2c --- /dev/null +++ b/packages/server/src/services/types.ts @@ -0,0 +1,11 @@ +/** + * Shared server service types. + * + * Summary: + * Defines JSON-like payload types shared by server services during the HTTP + * module split. + * + * Exports: + * - JsonValue + */ +export type JsonValue = Record | unknown[] | string | number | boolean | null; diff --git a/packages/server/src/tests/auth.test.ts b/packages/server/src/tests/auth.test.ts new file mode 100644 index 00000000..46055cec --- /dev/null +++ b/packages/server/src/tests/auth.test.ts @@ -0,0 +1,28 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import type { IncomingMessage } from "node:http"; +import { isAuthorized } from "../services/auth"; + +function request(url: string, headers: IncomingMessage["headers"] = {}): IncomingMessage { + return { url, headers } as IncomingMessage; +} + +test("isAuthorized accepts query token", () => { + assert.equal(isAuthorized(request("/health?token=secret"), "secret"), true); +}); + +test("isAuthorized accepts x-deepcode-token header values", () => { + assert.equal(isAuthorized(request("/health", { "x-deepcode-token": "secret" }), "secret"), true); + assert.equal(isAuthorized(request("/health", { "x-deepcode-token": ["secret", "other"] }), "secret"), true); +}); + +test("isAuthorized accepts bearer authorization case-insensitively", () => { + assert.equal(isAuthorized(request("/health", { authorization: "Bearer secret" }), "secret"), true); + assert.equal(isAuthorized(request("/health", { authorization: "bearer secret" }), "secret"), true); +}); + +test("isAuthorized rejects missing or mismatched tokens", () => { + assert.equal(isAuthorized(request("/health"), "secret"), false); + assert.equal(isAuthorized(request("/health?token=wrong"), "secret"), false); + assert.equal(isAuthorized(request("/health", { authorization: "Bearer wrong" }), "secret"), false); +}); diff --git a/packages/server/src/tests/images.test.ts b/packages/server/src/tests/images.test.ts new file mode 100644 index 00000000..38515df2 --- /dev/null +++ b/packages/server/src/tests/images.test.ts @@ -0,0 +1,49 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { normalizeImageList } from "../services/images"; + +test("normalizeImageList keeps remote and data image URLs", async () => { + const result = await normalizeImageList(process.cwd(), [ + "https://example.com/image.png", + "data:image/png;base64,AAAA", + ]); + assert.deepEqual(result, { ok: true, data: ["https://example.com/image.png", "data:image/png;base64,AAAA"] }); +}); + +test("normalizeImageList rejects blob URLs", async () => { + const result = await normalizeImageList(process.cwd(), "blob:https://example.com/image"); + assert.equal(result.ok, false); + if (!result.ok) { + assert.match(result.error, /blob image URLs/u); + } +}); + +test("normalizeImageList reads project-local files asynchronously", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "deepcode-server-images-")); + try { + await fs.writeFile(path.join(root, "pixel.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47])); + const result = await normalizeImageList(root, { path: "pixel.png" }); + assert.equal(result.ok, true); + if (result.ok) { + assert.match(result.data[0] ?? "", /^data:image\/png;base64,/u); + } + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); + +test("normalizeImageList rejects paths outside project root", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "deepcode-server-images-")); + try { + const result = await normalizeImageList(root, "../outside.png"); + assert.equal(result.ok, false); + if (!result.ok) { + assert.match(result.error, /inside the project root/u); + } + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/tests/request-body.test.ts b/packages/server/src/tests/request-body.test.ts new file mode 100644 index 00000000..b3e85604 --- /dev/null +++ b/packages/server/src/tests/request-body.test.ts @@ -0,0 +1,34 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { Readable } from "node:stream"; +import type { IncomingMessage } from "node:http"; +import { readJsonBody } from "../services/request-body"; + +function requestBody(content?: string): IncomingMessage { + return Readable.from(content === undefined ? [] : [Buffer.from(content)]) as IncomingMessage; +} + +test("readJsonBody returns empty object for empty bodies", async () => { + assert.deepEqual(await readJsonBody(requestBody()), {}); + assert.deepEqual(await readJsonBody(requestBody(" ")), {}); +}); + +test("readJsonBody parses JSON bodies", async () => { + assert.deepEqual(await readJsonBody(requestBody('{"text":"hello"}')), { text: "hello" }); +}); + +test("readJsonBody rejects invalid JSON with statusCode 400", async () => { + await assert.rejects(readJsonBody(requestBody("{")), (error) => { + assert.equal((error as { statusCode?: number }).statusCode, 400); + return true; + }); +}); + +test("readJsonBody rejects bodies over the limit with statusCode 413", async () => { + const tooLarge = "x".repeat(16 * 1024 * 1024 + 1); + await assert.rejects(readJsonBody(requestBody(tooLarge)), (error) => { + assert.equal((error as { statusCode?: number }).statusCode, 413); + assert.match(String((error as Error).message), /16 MiB/u); + return true; + }); +}); diff --git a/packages/server/src/tests/server-options.test.ts b/packages/server/src/tests/server-options.test.ts new file mode 100644 index 00000000..09596ee1 --- /dev/null +++ b/packages/server/src/tests/server-options.test.ts @@ -0,0 +1,22 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { parseServerOptions, readArgValue } from "../services/server-options"; + +test("parseServerOptions uses local defaults", () => { + assert.deepEqual(parseServerOptions([]), { host: "127.0.0.1", port: 8787, authDisabled: false }); +}); + +test("parseServerOptions accepts inline and separated option values", () => { + assert.deepEqual(parseServerOptions(["--host=localhost", "--port", "9000", "--no-auth"]), { + host: "localhost", + port: 9000, + authDisabled: true, + }); + assert.equal(readArgValue(["--project-root", "/tmp/work"], "--project-root"), "/tmp/work"); +}); + +test("parseServerOptions rejects invalid ports and unsafe binds", () => { + assert.throws(() => parseServerOptions(["--port", "0"]), /Invalid --port value/u); + assert.throws(() => parseServerOptions(["--host", "0.0.0.0"]), /requires --unsafe-bind/u); + assert.equal(parseServerOptions(["--host", "0.0.0.0", "--unsafe-bind"]).host, "0.0.0.0"); +}); diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json new file mode 100644 index 00000000..ac00d953 --- /dev/null +++ b/packages/server/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "composite": true, + "declaration": true, + "declarationMap": true, + "outDir": "./dist", + "rootDir": "./src", + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "src/tests"] +}