diff --git a/.specs/features/web-console/spec.md b/.specs/features/web-console/spec.md index b6d0d72..694c748 100644 --- a/.specs/features/web-console/spec.md +++ b/.specs/features/web-console/spec.md @@ -245,28 +245,28 @@ Each acceptance criterion has one requirement ID and maps to the task that imple | Requirement ID | Story | Phase | Status | Task | | --- | --- | --- | --- | --- | -| WEB-01 | P1: Shared local server and home page | P1 | In Tasks | T1 | -| WEB-02 | P1: Shared local server and home page | P1 | In Tasks | T1, T7 | -| WEB-03 | P1: Shared local server and home page | P1 | In Tasks | T1 | -| WEB-04 | P1: Shared local server and home page | P1 | In Tasks | T1 | -| WEB-05 | P1: Shared local server and home page | P1 | In Tasks | T3 | -| WEB-06 | P1: Shared local server and home page | P1 | In Tasks | T3 | -| WEB-07 | P1: Shared local server and home page | P1 | In Tasks | T3 | +| WEB-01 | P1: Shared local server and home page | P1 | Implemented | T1 | +| WEB-02 | P1: Shared local server and home page | P1 | Implemented | T1, T7 | +| WEB-03 | P1: Shared local server and home page | P1 | Implemented | T1 | +| WEB-04 | P1: Shared local server and home page | P1 | Implemented | T1 | +| WEB-05 | P1: Shared local server and home page | P1 | Implemented | T3 | +| WEB-06 | P1: Shared local server and home page | P1 | Implemented | T3 | +| WEB-07 | P1: Shared local server and home page | P1 | Implemented | T3 | | WEB-08 | P1: Shared local server and home page | P1 | In Tasks | T2, T4, T5, T13, T18 | | WEB-09 | P2: Local request security | P2 | In Tasks | T7, T12, T17 | -| WEB-10 | P2: Local request security | P2 | In Tasks | T6, T7 | -| WEB-11 | P2: Local request security | P2 | In Tasks | T6 | -| WEB-12 | P2: Local request security | P2 | In Tasks | T6, T7 | -| WEB-13 | P2: Local request security | P2 | In Tasks | T6, T7 | -| WEB-14 | P3: Shared setup planning | P3 | In Tasks | T8 | -| WEB-15 | P3: Shared setup planning | P3 | In Tasks | T8 | -| WEB-16 | P3: Shared setup planning | P3 | In Tasks | T8 | -| WEB-17 | P3: Shared setup planning | P3 | In Tasks | T8 | -| WEB-18 | P3: Shared setup planning | P3 | In Tasks | T8 | -| WEB-19 | P3: Shared setup planning | P3 | In Tasks | T8 | +| WEB-10 | P2: Local request security | P2 | Implemented | T6, T7 | +| WEB-11 | P2: Local request security | P2 | Implemented | T6 | +| WEB-12 | P2: Local request security | P2 | Implemented | T6, T7 | +| WEB-13 | P2: Local request security | P2 | Implemented | T6, T7 | +| WEB-14 | P3: Shared setup planning | P3 | Implemented | T8 | +| WEB-15 | P3: Shared setup planning | P3 | Implemented | T8 | +| WEB-16 | P3: Shared setup planning | P3 | Implemented | T8 | +| WEB-17 | P3: Shared setup planning | P3 | Implemented | T8 | +| WEB-18 | P3: Shared setup planning | P3 | Implemented | T8 | +| WEB-19 | P3: Shared setup planning | P3 | Implemented | T8 | | WEB-20 | P4: Browser setup | P4 | In Tasks | T11 | | WEB-21 | P4: Browser setup | P4 | In Tasks | T11 | -| WEB-22 | P3: Shared setup planning | P3 | In Tasks | T9 | +| WEB-22 | P3: Shared setup planning | P3 | Implemented | T9 | | WEB-23 | P4: Browser setup | P4 | In Tasks | T10 | | WEB-24 | P4: Browser setup | P4 | In Tasks | T11 | | WEB-25 | P4: Browser setup | P4 | In Tasks | T10 | @@ -301,11 +301,11 @@ Each acceptance criterion has one requirement ID and maps to the task that imple | WEB-54 | P5: Browser usage analytics | P5 | In Tasks | T17 | | WEB-55 | P5: Browser usage analytics | P5 | In Tasks | T17 | | WEB-56 | P5: Browser usage analytics | P5 | In Tasks | T17 | -| WEB-57 | P1: Shared local server and home page | P1 | In Tasks | T1 | +| WEB-57 | P1: Shared local server and home page | P1 | Implemented | T1 | | WEB-59 | P4: Browser setup | P4 | In Tasks | T11 | | WEB-60 | P5: Browser usage analytics | P5 | In Tasks | T16 | -| WEB-61 | P3: Shared setup planning | P3 | In Tasks | T8 | -| WEB-62 | P3: Shared setup planning | P3 | In Tasks | T8 | +| WEB-61 | P3: Shared setup planning | P3 | Implemented | T8 | +| WEB-62 | P3: Shared setup planning | P3 | Implemented | T8 | | WEB-63 | P4: Browser setup | P4 | In Tasks | T10, T11 | | WEB-64 | P4: Browser setup | P4 | In Tasks | T10 | | WEB-65 | P4: Browser setup | P4 | In Tasks | T10, T11 | @@ -314,11 +314,11 @@ Each acceptance criterion has one requirement ID and maps to the task that imple | WEB-68 | P4: Browser setup | P4 | In Tasks | T8, T10 | | WEB-69 | P4: Browser setup | P4 | In Tasks | T12 | | WEB-70 | P4: Browser setup | P4 | In Tasks | T12 | -| WEB-71 | P2: Local request security | P2 | In Tasks | T6, T7 | -| WEB-72 | P2: Local request security | P2 | In Tasks | T6, T7 | +| WEB-71 | P2: Local request security | P2 | Implemented | T6, T7 | +| WEB-72 | P2: Local request security | P2 | Implemented | T6, T7 | | WEB-73 | P2: Local request security | P2 | In Tasks | T10 | -| WEB-74 | P2: Local request security | P2 | In Tasks | T6, T7 | -| WEB-75 | P1: Shared local server and home page | P1 | In Tasks | T1, T7 | +| WEB-74 | P2: Local request security | P2 | Implemented | T6, T7 | +| WEB-75 | P1: Shared local server and home page | P1 | Implemented | T1, T7 | | WEB-76 | P4: Browser setup | P4 | In Tasks | T11 | | WEB-77 | P4: Browser setup | P4 | In Tasks | T11 | | WEB-78 | P5: Browser usage analytics | P5 | In Tasks | T15, T16 | @@ -328,8 +328,8 @@ Each acceptance criterion has one requirement ID and maps to the task that imple | WEB-83 | P5: Browser usage analytics | P5 | In Tasks | T17 | | WEB-84 | P4: Browser setup | P4 | In Tasks | T11 | | WEB-85 | P4: Browser setup | P4 | In Tasks | T11 | -| WEB-86 | P3: Shared setup planning | P3 | In Tasks | T8 | -| WEB-87 | P3: Shared setup planning | P3 | In Tasks | T8 | +| WEB-86 | P3: Shared setup planning | P3 | Implemented | T8 | +| WEB-87 | P3: Shared setup planning | P3 | Implemented | T8 | | WEB-88 | P4: Browser setup | P4 | In Tasks | T10, T11 | | WEB-89 | P4: Browser setup | P4 | In Tasks | T10, T11 | | WEB-90 | P4: Browser setup | P4 | In Tasks | T10, T11 | diff --git a/.specs/features/web-console/tasks.md b/.specs/features/web-console/tasks.md index 7847f5e..940f224 100644 --- a/.specs/features/web-console/tasks.md +++ b/.specs/features/web-console/tasks.md @@ -6,7 +6,7 @@ Implement these tasks with the tlc-spec-driven skill. Keep tests in the task tha **Design**: .specs/features/web-console/design.md -**Status**: Draft +**Status**: In progress. Done: T1-T9, T14 (feat/web-console-foundation) ## Test Coverage Matrix diff --git a/src/cli/commands/review.ts b/src/cli/commands/review.ts index 14f071c..1414bc2 100644 --- a/src/cli/commands/review.ts +++ b/src/cli/commands/review.ts @@ -1,39 +1,16 @@ import http from "node:http"; -import { spawn } from "node:child_process"; -import { InvalidArgumentError, type Command } from "commander"; +import type { Command } from "commander"; import { REVIEW_PAGE } from "../../web/review-page.js"; import { getLocalReview } from "../../git/review.js"; +import { DEFAULT_WEB_PORT, openBrowser, parseWebPort, startWebServer, type WebRoute } from "../../web/server.js"; -export const DEFAULT_REVIEW_PORT = 3100; +export const DEFAULT_REVIEW_PORT = DEFAULT_WEB_PORT; export function parseReviewPort(raw: string | undefined): number { - if (raw === undefined) return DEFAULT_REVIEW_PORT; - if (!/^\d+$/.test(raw)) { - throw new InvalidArgumentError("--port must be a positive integer"); - } - const port = Number(raw); - if (!Number.isInteger(port) || port <= 0 || port > 65535) { - throw new InvalidArgumentError("--port must be a positive integer"); - } - return port; + return parseWebPort(raw); } -export function openBrowser(url: string): boolean { - const opener = - process.platform === "darwin" - ? "open" - : process.platform === "win32" - ? "cmd" - : "xdg-open"; - const args = process.platform === "win32" ? ["/c", "start", "", url] : [url]; - try { - const child = spawn(opener, args, { detached: true, stdio: "ignore" }); - child.unref(); - return true; - } catch { - return false; - } -} +export { openBrowser }; export interface ReviewDeps { /** Repo root for /api/review. Defaults to process.cwd() at request time. */ @@ -41,6 +18,10 @@ export interface ReviewDeps { loadReview?: (root: string, ref: string, file?: string) => Promise; } +export interface ReviewCommandDependencies extends ReviewDeps { + startServer?: typeof startWebServer; +} + function sendJson(res: http.ServerResponse, code: number, value: unknown): void { res.writeHead(code, { "content-type": "application/json; charset=utf-8" }); res.end(JSON.stringify(value)); @@ -91,7 +72,16 @@ export interface ReviewCommandOptions { open?: boolean; } -export function registerReviewCommand(program: Command): void { +export function createReviewRoutes(reviewDeps?: ReviewDeps): WebRoute[] { + const handler = createReviewHandler(reviewDeps); + return [ + { path: "/", handler, kind: "page" }, + { path: "/review", handler, kind: "page", label: "Review" }, + { path: "/api/review", handler, kind: "api" }, + ]; +} + +export function registerReviewCommand(program: Command, dependencies: ReviewCommandDependencies = {}): void { program .command("review") .description("Open a local review of the current git changes") @@ -107,29 +97,17 @@ export function registerReviewCommand(program: Command): void { return; } - const server = http.createServer(createReviewHandler()); try { - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(port, "127.0.0.1", () => resolve()); + await (dependencies.startServer ?? startWebServer)({ + routes: createReviewRoutes(dependencies), + port, + initialPath: "/", + title: "CodeDeck review", + open: opts.open, }); } catch (error) { console.error(`Failed to listen on 127.0.0.1:${port}: ${error instanceof Error ? error.message : String(error)}`); process.exitCode = 1; - return; - } - - const url = `http://127.0.0.1:${port}/`; - console.log(`CodeDeck review on ${url}`); - if (opts.open !== false && !openBrowser(url)) { - console.log(`Could not open a browser, visit ${url} manually.`); } - - const shutdown = () => { - server.close(() => process.exit(0)); - setTimeout(() => process.exit(0), 1000).unref?.(); - }; - process.on("SIGINT", shutdown); - process.on("SIGTERM", shutdown); }); } diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index 0438582..9fe44ae 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -19,16 +19,25 @@ import { getCliName } from "../cli-name.js"; import { getRegistry } from "../../drivers/registry.js"; import { ROLES, type Role } from "../../core/roles.js"; import { getPaths } from "../../config/paths.js"; +import { + buildSetupPlan, + diffConfig, + resolveSetupTarget, + SetupUsageError, + validateBindings, + type BindingValidation, + type JsonValue, + type SetupBinding, + type SetupEnvelope, + type SetupSelection, +} from "../../config/setup.js"; import { BALANCED_PRESET, - baseConfig, DISPATCHER_PRESET, EXPLORER_PRESET, - getProfileSnapshot, ORCHESTRATOR_PRESETS, createSetupConfigStore, DEFAULT_CONFIG, - extractProfileSnapshot, isOrchestratorMode, loadConfig, orchestratorModeLabel, @@ -46,6 +55,8 @@ import { type RunAgentConfig, type SelfWorkMode, } from "../../config/config.js"; +export { diffConfig, SetupUsageError }; +export type { JsonValue, SetupEnvelope }; const ORCHESTRATOR_SCREEN_ROLE = "orchestrator-mode"; const ORCHESTRATOR_PICKER_GROUP = "orchestrator"; @@ -743,20 +754,20 @@ export async function runModelSetupWizard(options: ModelWizardOptions = {}): Pro } } - const updatedConfig: RunAgentConfig = { ...config, agents }; - if (orchestrator !== undefined) updatedConfig.orchestrator = orchestrator; + const selections: SetupSelection = { agents }; + if (orchestrator !== undefined) selections.orchestrator = orchestrator; const sandboxSelection = answeredResults.find((result) => result.role === SANDBOX_SCREEN_ROLE); if (sandboxSelection?.kind === "picked") { if (sandboxSelection.id === SANDBOX_OFF || sandboxSelection.id === SANDBOX_ON) { - updatedConfig.defaultSandbox = sandboxSelection.id; + selections.sandbox = sandboxSelection.id; } } const autocompactSelection = answeredResults.find((result) => result.role === AUTOCOMPACT_SCREEN_ROLE); if (autocompactSelection?.kind === "picked") { if (autocompactSelection.id === AUTOCOMPACT_ON) { - updatedConfig.autocompact = { ...config.autocompact, enabled: true }; + selections.autocompact = { enabled: true }; } else if (autocompactSelection.id === AUTOCOMPACT_OFF && config.autocompact !== undefined) { - updatedConfig.autocompact = { ...config.autocompact, enabled: false }; + selections.autocompact = { enabled: false }; } } @@ -770,13 +781,8 @@ export async function runModelSetupWizard(options: ModelWizardOptions = {}): Pro agents[target] = { ...agents[target]!, effort: result.id as ReasoningEffort }; } } - updatedConfig.agents = { ...agents }; - const toSave: RunAgentConfig = profile === undefined - ? updatedConfig - : { - ...loaded, - profiles: { ...loaded.profiles, [profile]: extractProfileSnapshot(updatedConfig) }, - }; + const { proposedConfig: toSave } = buildSetupPlan(loaded, { profile, config }, selections); + const updatedConfig = profile === undefined ? toSave : resolveSetupTarget(toSave, profile).config; let saved = false; try { (options.save ?? saveConfig)(toSave); @@ -801,10 +807,7 @@ export async function runModelSetupWizard(options: ModelWizardOptions = {}): Pro return updatedConfig; } -export interface ParsedSetupBinding { - role: Role; - binding: RoleBinding; -} +export type ParsedSetupBinding = SetupBinding; export interface SetupCliOptions { refresh: boolean; @@ -816,43 +819,6 @@ export interface SetupCliOptions { profile?: string; } -export class SetupUsageError extends Error { - constructor(message: string) { - super(message); - this.name = "SetupUsageError"; - } -} - -interface SetupTarget { - profile?: string; - config: RunAgentConfig; -} - -function resolveSetupTarget(loaded: RunAgentConfig, explicitProfile?: string): SetupTarget { - const isExplicit = explicitProfile !== undefined; - const profile = isExplicit - ? parseProfileName(explicitProfile) - : loaded.activeProfile === undefined || loaded.activeProfile.trim() === "" - ? undefined - : parseProfileName(loaded.activeProfile); - const snapshot = profile === undefined ? undefined : getProfileSnapshot(loaded, profile); - - if (!isExplicit && profile !== undefined && snapshot === undefined) { - throw new SetupUsageError( - `Active profile "${profile}" does not exist. Choose an existing profile with "codedeck profile use " or pass --profile .`, - ); - } - - return { - ...(profile === undefined ? {} : { profile }), - config: profile === undefined - ? loaded - : snapshot === undefined - ? { ...baseConfig(loaded), agents: {} } - : { ...baseConfig(loaded), ...snapshot }, - }; -} - function invalidBindMessage(value: string): string { return `Invalid --bind "${value}": expected role=harness:model[:effort] (role: general|orchestrator|reviewer|auditor; harness: claude|codex|opencode|omp; model: non-empty and without whitespace, control characters or '='; effort: ${REASONING_EFFORTS.join("|")})`; } @@ -989,52 +955,6 @@ export function parseSetupArgs(args: readonly string[]): SetupParseResult { }; } -export type JsonValue = - | null - | boolean - | number - | string - | JsonValue[] - | { [key: string]: JsonValue }; - -export interface SetupEnvelope { - proposta: RunAgentConfig | null; - validacoes: { - config: { - status: "not-run" | "ok" | "missing" | "invalid"; - source: "none" | "canonical" | "legacy"; - path: string; - message: string | null; - }; - catalogo: { - status: "not-needed" | "fresh" | "offline" | "unavailable"; - source: "none" | "cache" | "network" | "stale-cache"; - ageMs: number | null; - message: string | null; - }; - bindings: Array<{ - role: Role; - harness: AgentId; - model: string; - status: "accepted" | "unknown-model" | "harness-unavailable" | "unverified"; - message: string; - }>; - }; - mudancas: Array<{ - path: string; - beforePresent: boolean; - before: JsonValue; - afterPresent: boolean; - after: JsonValue; - }>; - resultado: { - status: "applied" | "dry-run" | "unchanged" | "aborted" | "error"; - code: 0 | 1 | 2 | 10 | 11 | 12 | 13 | 14 | 15 | 130; - saved: boolean; - message: string; - }; -} - export interface SetupBatchDependencies { registry?: DriverRegistry; configStore?: SetupConfigStore; @@ -1084,241 +1004,6 @@ function emptyEnvelope(message: string, code: SetupEnvelope["resultado"]["code"] }; } -function jsonObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function jsonValue(value: unknown): JsonValue { - if (value === undefined) return null; - if (Array.isArray(value)) return value.map(jsonValue); - if (jsonObject(value)) { - return Object.fromEntries( - Object.keys(value).sort((left, right) => left.localeCompare(right)).map((key) => [key, jsonValue(value[key])]), - ) as { - [key: string]: JsonValue; - }; - } - if (typeof value === "string" || typeof value === "number" || typeof value === "boolean" || value === null) { - return value; - } - return null; -} - -function jsonEqual(left: unknown, right: unknown): boolean { - return JSON.stringify(jsonValue(left)) === JSON.stringify(jsonValue(right)); -} - -function pointerPart(value: string): string { - return value.replaceAll("~", "~0").replaceAll("/", "~1"); -} - -function diffAt( - output: SetupEnvelope["mudancas"], - pathValue: string, - beforePresent: boolean, - before: unknown, - afterPresent: boolean, - after: unknown, - expandObjectChildren = false, -): void { - const beforeObject = beforePresent && jsonObject(before) ? before : undefined; - const afterObject = afterPresent && jsonObject(after) ? after : undefined; - if (beforeObject !== undefined && afterObject !== undefined) { - const keys = [...new Set([...Object.keys(beforeObject), ...Object.keys(afterObject)])].sort((left, right) => left.localeCompare(right)); - if (keys.length === 0) return; - for (const key of keys) { - diffAt( - output, - `${pathValue}/${pointerPart(key)}`, - Object.hasOwn(beforeObject, key), - beforeObject[key], - Object.hasOwn(afterObject, key), - afterObject[key], - ); - } - return; - } - if (!beforePresent && afterObject !== undefined) { - if (expandObjectChildren) { - const keys = Object.keys(afterObject).sort((left, right) => left.localeCompare(right)); - if (keys.length === 0) { - output.push({ - path: pathValue, - beforePresent: false, - before: null, - afterPresent: true, - after: jsonValue(afterObject), - }); - return; - } - for (const key of keys) { - const value = afterObject[key]; - const childPath = `${pathValue}/${pointerPart(key)}`; - if (jsonObject(value)) { - output.push({ - path: childPath, - beforePresent: false, - before: null, - afterPresent: true, - after: jsonValue(value), - }); - } else { - diffAt(output, childPath, false, undefined, true, value); - } - } - return; - } - output.push({ - path: pathValue, - beforePresent: false, - before: null, - afterPresent: true, - after: jsonValue(afterObject), - }); - return; - } - if (beforeObject !== undefined && !afterPresent) { - if (expandObjectChildren) { - const keys = Object.keys(beforeObject).sort((left, right) => left.localeCompare(right)); - if (keys.length === 0) { - output.push({ - path: pathValue, - beforePresent: true, - before: jsonValue(beforeObject), - afterPresent: false, - after: null, - }); - return; - } - for (const key of keys) { - const value = beforeObject[key]; - const childPath = `${pathValue}/${pointerPart(key)}`; - if (jsonObject(value)) { - output.push({ - path: childPath, - beforePresent: true, - before: jsonValue(value), - afterPresent: false, - after: null, - }); - } else { - diffAt(output, childPath, true, value, false, undefined); - } - } - return; - } - output.push({ - path: pathValue, - beforePresent: true, - before: jsonValue(beforeObject), - afterPresent: false, - after: null, - }); - return; - } - if (beforePresent === afterPresent && (!beforePresent || jsonEqual(before, after))) return; - output.push({ - path: pathValue, - beforePresent, - before: jsonValue(before), - afterPresent, - after: jsonValue(after), - }); -} - -export function diffConfig(before: RunAgentConfig, after: RunAgentConfig): SetupEnvelope["mudancas"] { - const output: SetupEnvelope["mudancas"] = []; - const beforeObject = jsonObject(before) ? before : {}; - const afterObject = jsonObject(after) ? after : {}; - const keys = [...new Set([...Object.keys(beforeObject), ...Object.keys(afterObject)])].sort((left, right) => left.localeCompare(right)); - for (const key of keys) { - diffAt( - output, - `/${pointerPart(key)}`, - Object.hasOwn(beforeObject, key), - beforeObject[key], - Object.hasOwn(afterObject, key), - afterObject[key], - true, - ); - } - return output.sort((left, right) => left.path.localeCompare(right.path)); -} - -function catalogContains(catalog: HarnessModels, model: string): boolean { - return catalog.providers.some((provider) => - provider.models.some( - (candidate) => - candidate.id === model || (candidate.aliases !== undefined && candidate.aliases.some((alias) => alias === model)), - ), - ); -} - -type BindingValidation = SetupEnvelope["validacoes"]["bindings"][number]; - -function validateBindings( - bindings: readonly ParsedSetupBinding[], - catalog: Awaited>, -): { entries: BindingValidation[]; code?: 11 | 12 | 13; message: string | null } { - const byAgent = new Map(catalog.models.map((item) => [item.agent, item])); - const entries: BindingValidation[] = bindings.map(({ role, binding }) => { - const found = byAgent.get(binding.harness); - if (found?.available === false) { - return { - role, - harness: binding.harness, - model: binding.model, - status: "harness-unavailable", - message: `Cannot apply binding for role "${role}": harness "${binding.harness}" is unavailable.`, - }; - } - if (catalog.status === "unavailable" || found === undefined) { - return { - role, - harness: binding.harness, - model: binding.model, - status: "unverified", - message: `Cannot validate model "${binding.model}" for harness "${binding.harness}": catalog unavailable.`, - }; - } - if (catalog.status === "offline" && !catalogContains(found, binding.model)) { - return { - role, - harness: binding.harness, - model: binding.model, - status: "unverified", - message: `Cannot validate model "${binding.model}" for harness "${binding.harness}": the catalog is stale. Re-run with --refresh.`, - }; - } - if (!catalogContains(found, binding.model)) { - return { - role, - harness: binding.harness, - model: binding.model, - status: "unknown-model", - message: `Model "${binding.model}" is not in the ${binding.harness} catalog for role "${role}".`, - }; - } - return { role, harness: binding.harness, model: binding.model, status: "accepted", message: "" }; - }); - - const failed = entries.find((entry) => entry.status !== "accepted"); - const code = failed === undefined - ? undefined - : failed.status === "harness-unavailable" - ? 11 - : failed.status === "unknown-model" - ? 12 - : 13; - let message: string | null = null; - if (catalog.status === "offline") { - message = failed?.status === "unverified" ? failed.message : "Catalog is stale; using offline cache."; - } else if (catalog.status === "unavailable") { - message = failed?.message ?? "Catalog unavailable."; - } - return { entries, code, message }; -} - function catalogValidation( catalog: Awaited> | undefined, message: string | null, @@ -1388,12 +1073,9 @@ export async function runSetupBatch( } const current: RunAgentConfig = { ...DEFAULT_CONFIG, ...(read.config ?? {}) }; - let profile: string | undefined; - let target: RunAgentConfig; + let target: ReturnType; try { - const resolved = resolveSetupTarget(current, options.profile); - profile = resolved.profile; - target = resolved.config; + target = resolveSetupTarget(current, options.profile); } catch (error) { const message = error instanceof Error ? error.message : String(error); writeLine(stderr, message); @@ -1402,23 +1084,19 @@ export async function runSetupBatch( const lastByRole = new Map(); options.binds.forEach((binding, index) => lastByRole.set(binding.role, index)); const winning = options.binds.filter((binding, index) => lastByRole.get(binding.role) === index); - const existingAgents = jsonObject(target.agents) ? target.agents : {}; - const updatedAgents = { - ...existingAgents, - ...Object.fromEntries(winning.map(({ role, binding }) => [role, binding])), - }; - const proposed: RunAgentConfig = options.binds.length === 0 - ? { ...current } - : profile === undefined - ? { ...current, agents: updatedAgents } - : { - ...current, - profiles: { - ...current.profiles, - [profile]: { ...extractProfileSnapshot(target), agents: updatedAgents }, - }, - }; - const changes = diffConfig(current, proposed); + const profile = target.profile; + let proposed: RunAgentConfig; + let changes: SetupEnvelope["mudancas"]; + if (winning.length === 0) { + proposed = { ...current }; + changes = diffConfig(current, proposed); + } else { + const agents: Partial> = {}; + for (const { role, binding } of winning) agents[role] = binding; + const plan = buildSetupPlan(current, target, { agents }); + proposed = plan.proposedConfig; + changes = plan.diff; + } let catalog: Awaited> | undefined; let bindingValidations: BindingValidation[] = []; diff --git a/src/cli/commands/ui.ts b/src/cli/commands/ui.ts new file mode 100644 index 0000000..b53f382 --- /dev/null +++ b/src/cli/commands/ui.ts @@ -0,0 +1,61 @@ +import type { Command } from "commander"; +import { createReviewRoutes } from "./review.js"; +import { renderHomePage } from "../../web/home-page.js"; +import { DEFAULT_WEB_PORT, parseWebPort, startWebServer, type WebRoute } from "../../web/server.js"; + +export interface UiCommandOptions { + port?: string; + open?: boolean; +} + +export interface UiCommandDependencies { + startServer?: typeof startWebServer; +} + +export function createUiRoutes(): WebRoute[] { + const reviewRoutes = createReviewRoutes(); + const pages = reviewRoutes.flatMap((route) => + route.kind === "page" && route.label ? [{ label: route.label, path: route.path }] : [], + ); + const reviewRouteTable = reviewRoutes.filter((route) => route.path !== "/"); + const home: WebRoute = { + path: "/", + kind: "page", + handler: (_request, response) => { + response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + response.end(renderHomePage(pages)); + }, + }; + return [home, ...reviewRouteTable]; +} + +export function registerUiCommand(program: Command, dependencies: UiCommandDependencies = {}): void { + program + .command("ui") + .description("Open the local CodeDeck console") + .option("--port ", "port to listen on (default: 3100)", String(DEFAULT_WEB_PORT)) + .option("--no-open", "serve the console without opening a browser") + .action(async (opts: UiCommandOptions) => { + let port: number; + try { + port = parseWebPort(opts.port); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + return; + } + + try { + await (dependencies.startServer ?? startWebServer)({ + routes: createUiRoutes(), + port, + initialPath: "/", + title: "CodeDeck UI", + open: opts.open, + }); + } catch (error) { + console.error(`Failed to listen on 127.0.0.1:${port}: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + } + }); +} diff --git a/src/cli/commands/usage.ts b/src/cli/commands/usage.ts index f4c7d97..3f1d38f 100644 --- a/src/cli/commands/usage.ts +++ b/src/cli/commands/usage.ts @@ -2,8 +2,8 @@ import fs from "node:fs"; import type { Command } from "commander"; import { IpcClient } from "../../daemon/ipc.js"; import type { RunUsageSummary } from "../../core/run-usage.js"; -import type { AgentId } from "../../core/session.js"; -import type { UsagePeriod, UsageQueryParams, UsageQueryResult } from "../../daemon/protocol.js"; +import { buildUsageQueryParams } from "../../core/usage-query.js"; +import type { UsageQueryParams, UsageQueryResult } from "../../daemon/protocol.js"; import { getPaths } from "../../config/paths.js"; import { SESSION_ID_PATTERN } from "../../open/runtime.js"; import { Database } from "../../store/database.js"; @@ -173,42 +173,7 @@ export function registerUsageCommand(program: Command): void { } // 2. Aggregate Analytics Branch - let period: UsagePeriod | undefined; - let since = opts.since; - const until = opts.until; - - if (opts.all) { - period = "all"; - } else if (opts.today) { - period = "today"; - } else if (opts.days) { - const d = parseInt(opts.days, 10); - if (d === 3) period = "3d"; - else if (d === 7) period = "7d"; - else if (d === 30) period = "30d"; - else if (!isNaN(d) && d > 0) { - const now = new Date(); - const sinceDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - (d - 1), 0, 0, 0, 0); - since = sinceDate.toISOString(); - } - } else if (!since) { - // Default to today if no temporal flags provided - period = "today"; - } - - let repository = opts.repo; - if (opts.current) { - repository = process.cwd(); - } - - const queryParams: UsageQueryParams = { - period, - since, - until, - repository, - model: opts.model, - agent: opts.agent as AgentId | undefined, - }; + const queryParams = buildUsageQueryParams(opts, process.cwd(), new Date()); // Interactive TUI Mode if (opts.tui) { diff --git a/src/cli/index.ts b/src/cli/index.ts index 820a345..7311051 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node import { Command } from "commander"; -import { readFileSync } from "node:fs"; +import { readFileSync, realpathSync } from "node:fs"; import { fileURLToPath } from "node:url"; import path from "node:path"; @@ -22,6 +22,7 @@ import { registerProfileCommand } from "./commands/profile.js"; import { registerSetupCommand } from "./commands/setup.js"; import { registerUsageCommand } from "./commands/usage.js"; import { registerReviewCommand } from "./commands/review.js"; +import { registerUiCommand } from "./commands/ui.js"; import { getCliInvocation, getCliName } from "./cli-name.js"; function getVersion(): string { @@ -34,21 +35,22 @@ function getVersion(): string { } } -const program = new Command(); +export function createCliProgram(): Command { + const program = new Command(); -// CODEDECK_CLI_NAME renames the tool (for example a `codedeck-dev` -// alias), so the help below shows that name instead of `npx codedeck`. -const cliName = getCliName(); -const cli = getCliInvocation(); + // CODEDECK_CLI_NAME renames the tool (for example a `codedeck-dev` + // alias), so the help below shows that name instead of `npx codedeck`. + const cliName = getCliName(); + const cli = getCliInvocation(); -program - .name(cliName) - .description("CodeDeck — local runtime for coding agents\nManage Claude, Codex, OpenCode and OMP through a single session interface") - .version(getVersion()) - .helpOption("-h, --help", "display help for command") - .showHelpAfterError("(add --help for details)") - .showSuggestionAfterError(true) - .addHelpText("after", ` + program + .name(cliName) + .description("CodeDeck — local runtime for coding agents\nManage Claude, Codex, OpenCode and OMP through a single session interface") + .version(getVersion()) + .helpOption("-h, --help", "display help for command") + .showHelpAfterError("(add --help for details)") + .showSuggestionAfterError(true) + .addHelpText("after", ` Examples: $ ${cli} run "implement authentication" --agent claude --worktree $ ${cli} run "fix the tests" --agent codex --bg @@ -78,29 +80,44 @@ Run '${cli} --help' for command-specific options. Docs: https://github.com/4ndreello/run-agent `); -registerRunCommand(program); -registerPsCommand(program); -registerClaimsCommand(program); -registerShowCommand(program); -registerRenameCommand(program); -registerLogsCommand(program); -registerWaitCommand(program); -registerSendCommand(program); -registerStopCommand(program); -registerDoneCommand(program); -registerDiffCommand(program); -registerDoctorCommand(program); -registerModelsCommand(program); -registerOpenCommand(program); -registerProfileCommand(program); -registerSetupCommand(program); -registerUsageCommand(program); -registerReviewCommand(program); + registerRunCommand(program); + registerPsCommand(program); + registerClaimsCommand(program); + registerShowCommand(program); + registerRenameCommand(program); + registerLogsCommand(program); + registerWaitCommand(program); + registerSendCommand(program); + registerStopCommand(program); + registerDoneCommand(program); + registerDiffCommand(program); + registerDoctorCommand(program); + registerModelsCommand(program); + registerOpenCommand(program); + registerProfileCommand(program); + registerSetupCommand(program); + registerUsageCommand(program); + registerReviewCommand(program); + registerUiCommand(program); -// Make `codedeck help` behave like `codedeck --help` -program.command("help", { hidden: true }).action(() => program.outputHelp()); + // Make `codedeck help` behave like `codedeck --help`. + program.command("help", { hidden: true }).action(() => program.outputHelp()); + return program; +} -program.parseAsync(process.argv).catch((err) => { - console.error(err instanceof Error ? err.message : String(err)); - process.exit(1); -}); +function isCliEntryPoint(): boolean { + const entryPath = process.argv[1]; + if (!entryPath) return false; + try { + return realpathSync(entryPath) === realpathSync(fileURLToPath(import.meta.url)); + } catch { + return path.resolve(entryPath) === path.resolve(fileURLToPath(import.meta.url)); + } +} + +if (isCliEntryPoint()) { + createCliProgram().parseAsync(process.argv).catch((err) => { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); + }); +} diff --git a/src/config/setup.ts b/src/config/setup.ts new file mode 100644 index 0000000..be14971 --- /dev/null +++ b/src/config/setup.ts @@ -0,0 +1,411 @@ +import type { BatchModelsResult, HarnessModels } from "../core/models.js"; +import type { AgentId } from "../core/session.js"; +import type { Role } from "../core/roles.js"; +import { + baseConfig, + extractProfileSnapshot, + getProfileSnapshot, + parseProfileName, + type RoleBinding, + type RunAgentConfig, +} from "./config.js"; +import type { OrchestratorMode } from "./orchestrator-mode.js"; + +export class SetupUsageError extends Error { + constructor(message: string) { + super(message); + this.name = "SetupUsageError"; + } +} + +export interface SetupTarget { + profile?: string; + config: RunAgentConfig; +} + +export function resolveSetupTarget(loaded: RunAgentConfig, explicitProfile?: string): SetupTarget { + const isExplicit = explicitProfile !== undefined; + const profile = isExplicit + ? parseProfileName(explicitProfile) + : loaded.activeProfile === undefined || loaded.activeProfile.trim() === "" + ? undefined + : parseProfileName(loaded.activeProfile); + const snapshot = profile === undefined ? undefined : getProfileSnapshot(loaded, profile); + + if (!isExplicit && profile !== undefined && snapshot === undefined) { + throw new SetupUsageError( + 'Active profile "' + + profile + + '" does not exist. Choose an existing profile with "codedeck profile use " or pass --profile .', + ); + } + + return { + ...(profile === undefined ? {} : { profile }), + config: profile === undefined + ? loaded + : snapshot === undefined + ? { ...baseConfig(loaded), agents: {} } + : { ...baseConfig(loaded), ...snapshot }, + }; +} + +export type JsonValue = + | null + | boolean + | number + | string + | JsonValue[] + | { [key: string]: JsonValue }; + +export interface SetupEnvelope { + proposta: RunAgentConfig | null; + validacoes: { + config: { + status: "not-run" | "ok" | "missing" | "invalid"; + source: "none" | "canonical" | "legacy"; + path: string; + message: string | null; + }; + catalogo: { + status: "not-needed" | "fresh" | "offline" | "unavailable"; + source: "none" | "cache" | "network" | "stale-cache"; + ageMs: number | null; + message: string | null; + }; + bindings: Array<{ + role: Role; + harness: AgentId; + model: string; + status: "accepted" | "unknown-model" | "harness-unavailable" | "unverified"; + message: string; + }>; + }; + mudancas: Array<{ + path: string; + beforePresent: boolean; + before: JsonValue; + afterPresent: boolean; + after: JsonValue; + }>; + resultado: { + status: "applied" | "dry-run" | "unchanged" | "aborted" | "error"; + code: 0 | 1 | 2 | 10 | 11 | 12 | 13 | 14 | 15 | 130; + saved: boolean; + message: string; + }; +} + +export interface SetupBinding { + role: Role; + binding: RoleBinding; +} + +export interface SetupSelection { + agents: Partial>; + orchestrator?: OrchestratorMode; + sandbox?: RunAgentConfig["defaultSandbox"]; + autocompact?: RunAgentConfig["autocompact"]; + offCatalogConfirmed?: Partial>; +} + +export interface SetupPlanResult { + proposedConfig: RunAgentConfig; + diff: SetupEnvelope["mudancas"]; +} + +function jsonObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function jsonValue(value: unknown): JsonValue { + if (value === undefined) return null; + if (Array.isArray(value)) return value.map(jsonValue); + if (jsonObject(value)) { + return Object.fromEntries( + Object.keys(value) + .sort((left, right) => left.localeCompare(right)) + .map((key) => [key, jsonValue(value[key])]), + ) as { [key: string]: JsonValue }; + } + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean" || value === null) { + return value; + } + return null; +} + +function jsonEqual(left: unknown, right: unknown): boolean { + return JSON.stringify(jsonValue(left)) === JSON.stringify(jsonValue(right)); +} + +function pointerPart(value: string): string { + return value.replaceAll("~", "~0").replaceAll("/", "~1"); +} + +function diffAt( + output: SetupEnvelope["mudancas"], + pathValue: string, + beforePresent: boolean, + before: unknown, + afterPresent: boolean, + after: unknown, + expandObjectChildren = false, +): void { + const beforeObject = beforePresent && jsonObject(before) ? before : undefined; + const afterObject = afterPresent && jsonObject(after) ? after : undefined; + if (beforeObject !== undefined && afterObject !== undefined) { + const keys = [...new Set([...Object.keys(beforeObject), ...Object.keys(afterObject)])] + .sort((left, right) => left.localeCompare(right)); + if (keys.length === 0) return; + for (const key of keys) { + diffAt( + output, + pathValue + "/" + pointerPart(key), + Object.hasOwn(beforeObject, key), + beforeObject[key], + Object.hasOwn(afterObject, key), + afterObject[key], + ); + } + return; + } + if (!beforePresent && afterObject !== undefined) { + if (expandObjectChildren) { + const keys = Object.keys(afterObject).sort((left, right) => left.localeCompare(right)); + if (keys.length === 0) { + output.push({ + path: pathValue, + beforePresent: false, + before: null, + afterPresent: true, + after: jsonValue(afterObject), + }); + return; + } + for (const key of keys) { + const value = afterObject[key]; + const childPath = pathValue + "/" + pointerPart(key); + if (jsonObject(value)) { + output.push({ + path: childPath, + beforePresent: false, + before: null, + afterPresent: true, + after: jsonValue(value), + }); + } else { + diffAt(output, childPath, false, undefined, true, value); + } + } + return; + } + output.push({ + path: pathValue, + beforePresent: false, + before: null, + afterPresent: true, + after: jsonValue(afterObject), + }); + return; + } + if (beforeObject !== undefined && !afterPresent) { + if (expandObjectChildren) { + const keys = Object.keys(beforeObject).sort((left, right) => left.localeCompare(right)); + if (keys.length === 0) { + output.push({ + path: pathValue, + beforePresent: true, + before: jsonValue(beforeObject), + afterPresent: false, + after: null, + }); + return; + } + for (const key of keys) { + const value = beforeObject[key]; + const childPath = pathValue + "/" + pointerPart(key); + if (jsonObject(value)) { + output.push({ + path: childPath, + beforePresent: true, + before: jsonValue(value), + afterPresent: false, + after: null, + }); + } else { + diffAt(output, childPath, true, value, false, undefined); + } + } + return; + } + output.push({ + path: pathValue, + beforePresent: true, + before: jsonValue(beforeObject), + afterPresent: false, + after: null, + }); + return; + } + if (beforePresent === afterPresent && (!beforePresent || jsonEqual(before, after))) return; + output.push({ + path: pathValue, + beforePresent, + before: jsonValue(before), + afterPresent, + after: jsonValue(after), + }); +} + +export function diffConfig(before: RunAgentConfig, after: RunAgentConfig): SetupEnvelope["mudancas"] { + const output: SetupEnvelope["mudancas"] = []; + const beforeObject = jsonObject(before) ? before : {}; + const afterObject = jsonObject(after) ? after : {}; + const keys = [...new Set([...Object.keys(beforeObject), ...Object.keys(afterObject)])] + .sort((left, right) => left.localeCompare(right)); + for (const key of keys) { + diffAt( + output, + "/" + pointerPart(key), + Object.hasOwn(beforeObject, key), + beforeObject[key], + Object.hasOwn(afterObject, key), + afterObject[key], + true, + ); + } + return output.sort((left, right) => left.path.localeCompare(right.path)); +} + +export function catalogContains(catalog: HarnessModels, model: string): boolean { + return catalog.providers.some((provider) => + provider.models.some( + (candidate) => + candidate.id === model || (candidate.aliases !== undefined && candidate.aliases.some((alias) => alias === model)), + ), + ); +} + +export type BindingValidation = SetupEnvelope["validacoes"]["bindings"][number]; + +export function validateBindings( + bindings: readonly SetupBinding[], + catalog: BatchModelsResult, +): { entries: BindingValidation[]; code?: 11 | 12 | 13; message: string | null } { + const byAgent = new Map(catalog.models.map((item) => [item.agent, item])); + const entries: BindingValidation[] = bindings.map(({ role, binding }) => { + const found = byAgent.get(binding.harness); + if (found?.available === false) { + return { + role, + harness: binding.harness, + model: binding.model, + status: "harness-unavailable", + message: + 'Cannot apply binding for role "' + role + '": harness "' + binding.harness + '" is unavailable.', + }; + } + if (catalog.status === "unavailable" || found === undefined) { + return { + role, + harness: binding.harness, + model: binding.model, + status: "unverified", + message: + 'Cannot validate model "' + + binding.model + + '" for harness "' + + binding.harness + + '": catalog unavailable.', + }; + } + if (catalog.status === "offline" && !catalogContains(found, binding.model)) { + return { + role, + harness: binding.harness, + model: binding.model, + status: "unverified", + message: + 'Cannot validate model "' + + binding.model + + '" for harness "' + + binding.harness + + '": the catalog is stale. Re-run with --refresh.', + }; + } + if (!catalogContains(found, binding.model)) { + return { + role, + harness: binding.harness, + model: binding.model, + status: "unknown-model", + message: + 'Model "' + + binding.model + + '" is not in the ' + + binding.harness + + ' catalog for role "' + + role + + '".', + }; + } + return { role, harness: binding.harness, model: binding.model, status: "accepted", message: "" }; + }); + + const failed = entries.find((entry) => entry.status !== "accepted"); + const code = failed === undefined + ? undefined + : failed.status === "harness-unavailable" + ? 11 + : failed.status === "unknown-model" + ? 12 + : 13; + let message: string | null = null; + if (catalog.status === "offline") { + message = failed?.status === "unverified" ? failed.message : "Catalog is stale; using offline cache."; + } else if (catalog.status === "unavailable") { + message = failed?.message ?? "Catalog unavailable."; + } + return { entries, code, message }; +} + +export function buildSetupPlan( + currentConfig: RunAgentConfig, + target: SetupTarget, + selections: SetupSelection, +): SetupPlanResult { + const currentAgents = jsonObject(target.config.agents) + ? target.config.agents as Partial> + : {}; + const updatedTarget: RunAgentConfig = { + ...target.config, + agents: { ...currentAgents, ...selections.agents }, + }; + if (selections.orchestrator !== undefined) { + updatedTarget.orchestrator = { + investigate: selections.orchestrator.investigate, + selfWork: selections.orchestrator.selfWork, + tools: selections.orchestrator.tools, + ...(selections.orchestrator.parallelism === undefined + ? {} + : { parallelism: selections.orchestrator.parallelism }), + }; + } + if (selections.sandbox !== undefined) updatedTarget.defaultSandbox = selections.sandbox; + if (selections.autocompact !== undefined) { + if (selections.autocompact.enabled !== false || target.config.autocompact !== undefined) { + updatedTarget.autocompact = { ...target.config.autocompact, ...selections.autocompact }; + } + } + + const proposedConfig = target.profile === undefined + ? updatedTarget + : { + ...currentConfig, + profiles: { + ...currentConfig.profiles, + [target.profile]: extractProfileSnapshot(updatedTarget), + }, + }; + return { proposedConfig, diff: diffConfig(currentConfig, proposedConfig) }; +} diff --git a/src/core/usage-query.ts b/src/core/usage-query.ts new file mode 100644 index 0000000..55d7227 --- /dev/null +++ b/src/core/usage-query.ts @@ -0,0 +1,56 @@ +import type { AgentId } from "./session.js"; +import type { UsagePeriod, UsageQueryParams } from "../daemon/protocol.js"; + +export interface UsageQueryOptions { + today?: boolean; + days?: string; + since?: string; + until?: string; + all?: boolean; + repo?: string; + current?: boolean; + model?: string; + agent?: string; +} + +export function buildUsageQueryParams( + opts: UsageQueryOptions, + cwd: string, + now: Date, +): UsageQueryParams { + let period: UsagePeriod | undefined; + let since = opts.since; + const until = opts.until; + + if (opts.all) { + period = "all"; + } else if (opts.today) { + period = "today"; + } else if (opts.days) { + const days = parseInt(opts.days, 10); + if (days === 3) period = "3d"; + else if (days === 7) period = "7d"; + else if (days === 30) period = "30d"; + else if (!isNaN(days) && days > 0) { + const sinceDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - (days - 1), 0, 0, 0, 0); + since = sinceDate.toISOString(); + } + } else if (!since) { + // Default to today if no temporal flags provided + period = "today"; + } + + let repository = opts.repo; + if (opts.current) { + repository = cwd; + } + + return { + period, + since, + until, + repository, + model: opts.model, + agent: opts.agent as AgentId | undefined, + }; +} diff --git a/src/web/home-page.ts b/src/web/home-page.ts new file mode 100644 index 0000000..db3a0ee --- /dev/null +++ b/src/web/home-page.ts @@ -0,0 +1,54 @@ +export interface HomePageRoute { + label: string; + path: string; +} + +export function renderHomePage(routes: readonly HomePageRoute[]): string { + const links = routes + .map((route) => `
  • ${escapeHtml(route.label)}
  • `) + .join("\n"); + + return ` + + + + +CodeDeck + + + + +
    +

    CodeDeck

    +

    Choose a local console page.

    +
      ${links}
    +
    + +`; +} + +function escapeHtml(value: string): string { + return value.replace(/[&<>"']/g, (character) => { + switch (character) { + case "&": return "&"; + case "<": return "<"; + case ">": return ">"; + case '"': return """; + case "'": return "'"; + default: return character; + } + }); +} diff --git a/src/web/security.ts b/src/web/security.ts new file mode 100644 index 0000000..fc8529f --- /dev/null +++ b/src/web/security.ts @@ -0,0 +1,112 @@ +import { randomBytes } from "node:crypto"; +import type { IncomingMessage, ServerResponse } from "node:http"; + +export interface WebSecurity { + port: number; + token: string; + cookieName: string; +} + +export interface WebRoutePolicy { + htmlPage?: boolean; +} + +export const WEB_FORBIDDEN_MESSAGE = "forbidden"; + +export function createWebSecurity(port: number): WebSecurity { + const token = randomBytes(32).toString("hex"); + return { + port, + token, + cookieName: `codedeck_ui_token_${port}`, + }; +} + +export function isAllowedWebHost(host: string | undefined, port: number): boolean { + if (!host) return false; + const normalized = host.toLowerCase(); + return normalized === `127.0.0.1:${port}` || normalized === `localhost:${port}`; +} + +export function getTokenUrl(url: string, token: string): string { + const tokenUrl = new URL(url); + tokenUrl.searchParams.set("t", token); + return tokenUrl.toString(); +} + +export function checkWebRequest( + request: IncomingMessage, + response: ServerResponse, + security: WebSecurity, + policy: WebRoutePolicy = {}, +): boolean { + if (!isAllowedWebHost(request.headers.host, security.port)) { + reject(response); + return false; + } + + if (request.method === "POST" && !hasValidActionCredentials(request, security)) { + reject(response); + return false; + } + + if (policy.htmlPage) { + response.setHeader("Content-Security-Policy", "frame-ancestors 'none'"); + if (request.method === "GET" && redirectWithSessionCookie(request, response, security)) return false; + } + + return true; +} + +function hasValidActionCredentials(request: IncomingMessage, security: WebSecurity): boolean { + const cookie = request.headers.cookie + ?.split(";") + .map((part) => part.trim()) + .find((part) => part.startsWith(`${security.cookieName}=`)); + if (cookie?.slice(security.cookieName.length + 1) !== security.token) return false; + + const origin = request.headers.origin; + const host = request.headers.host; + if (!origin || !host) return false; + + try { + const parsedOrigin = new URL(origin); + return ( + parsedOrigin.protocol === "http:" && + parsedOrigin.host.toLowerCase() === host.toLowerCase() && + parsedOrigin.port === String(security.port) + ); + } catch { + return false; + } +} + +function redirectWithSessionCookie( + request: IncomingMessage, + response: ServerResponse, + security: WebSecurity, +): boolean { + let url: URL; + try { + url = new URL(request.url || "/", `http://127.0.0.1:${security.port}`); + } catch { + return false; + } + + const tokens = url.searchParams.getAll("t"); + if (tokens.length !== 1 || tokens[0] !== security.token) return false; + + url.searchParams.delete("t"); + response.setHeader( + "Set-Cookie", + `${security.cookieName}=${security.token}; Path=/; HttpOnly; SameSite=Strict`, + ); + response.writeHead(303, { Location: `${url.pathname}${url.search}` }); + response.end(); + return true; +} + +function reject(response: ServerResponse): void { + response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }); + response.end(WEB_FORBIDDEN_MESSAGE); +} diff --git a/src/web/server.ts b/src/web/server.ts new file mode 100644 index 0000000..d507c3f --- /dev/null +++ b/src/web/server.ts @@ -0,0 +1,202 @@ +import http, { type RequestListener, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { spawn } from "node:child_process"; +import { EventEmitter } from "node:events"; +import { InvalidArgumentError } from "commander"; +import { checkWebRequest, createWebSecurity, getTokenUrl, isAllowedWebHost, type WebSecurity } from "./security.js"; + +export const DEFAULT_WEB_PORT = 3100; + +export interface WebRoute { + path: string; + handler: RequestListener; + kind: "page" | "api"; + label?: string; +} + +export interface WebServerOptions { + routes: readonly WebRoute[]; + port?: number; + initialPath: string; + title?: string; + open?: boolean; + openBrowser?: (url: string) => boolean | Promise; + log?: (message: string) => void; + serverFactory?: (handler: RequestListener) => Server; + signalTarget?: EventEmitter; + closeServer?: () => Promise | void; + exit?: (code: number) => void; +} + +export interface CreateWebServerOptions { + routes: readonly WebRoute[]; + getSecurity: () => WebSecurity | undefined; + serverFactory?: (handler: RequestListener) => Server; +} + +export interface WebServerHandle { + server: Server; + address: AddressInfo; + port: number; + baseUrl: string; + initialUrl: string; + security: WebSecurity; + close(): Promise; +} + +export function parseWebPort(raw: string | undefined): number { + if (raw === undefined) return DEFAULT_WEB_PORT; + if (!/^\d+$/.test(raw)) throw new InvalidArgumentError("--port must be a positive integer"); + const port = Number(raw); + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + throw new InvalidArgumentError("--port must be a positive integer"); + } + return port; +} + +export function openBrowser(url: string): Promise { + const opener = + process.platform === "darwin" + ? "open" + : process.platform === "win32" + ? "cmd" + : "xdg-open"; + const args = process.platform === "win32" ? ["/c", "start", "", url] : [url]; + return new Promise((resolve) => { + try { + const child = spawn(opener, args, { detached: true, stdio: "ignore" }); + child.once("spawn", () => { + child.unref(); + resolve(true); + }); + child.once("error", () => resolve(false)); + } catch { + resolve(false); + } + }); +} + +export function createWebServer(options: CreateWebServerOptions): Server { + const serverFactory = options.serverFactory ?? ((handler) => http.createServer({ requireHostHeader: false }, handler)); + return serverFactory((request, response) => { + const security = options.getSecurity(); + if (!security) { + response.writeHead(503, { "content-type": "text/plain; charset=utf-8" }); + response.end("server starting"); + return; + } + dispatchRequest(options.routes, security, request, response); + }); +} + +export async function startWebServer(options: WebServerOptions): Promise { + const requestedPort = options.port ?? DEFAULT_WEB_PORT; + let security: WebSecurity | undefined; + const server = createWebServer({ + routes: options.routes, + getSecurity: () => security, + serverFactory: options.serverFactory, + }); + + await new Promise((resolve, reject) => { + const onError = (error: Error) => { + server.off("listening", onListening); + reject(error); + }; + const onListening = () => { + server.off("error", onError); + resolve(); + }; + server.once("error", onError); + server.once("listening", onListening); + server.listen(requestedPort, "127.0.0.1"); + }); + + const address = server.address(); + if (!address || typeof address === "string") { + await new Promise((resolve) => server.close(() => resolve())); + throw new Error("Web server did not return a TCP address"); + } + + security = createWebSecurity(address.port); + const baseUrl = `http://127.0.0.1:${address.port}`; + const pageUrl = new URL(options.initialPath, baseUrl).toString(); + const initialUrl = getTokenUrl(pageUrl, security.token); + const log = options.log ?? ((message: string) => console.log(message)); + + if (options.open === false) { + log(`${options.title ?? "CodeDeck"} on ${initialUrl}`); + } else if (!(await (options.openBrowser ?? openBrowser)(initialUrl))) { + log(`Could not open a browser, visit ${initialUrl} manually.`); + } else { + log(`${options.title ?? "CodeDeck"} on ${initialUrl}`); + } + + const signalTarget = options.signalTarget ?? process; + const exit = options.exit ?? ((code: number) => process.exit(code)); + let signalClose: Promise | undefined; + let actualClose: Promise | undefined; + let shutdown = () => {}; + const close = (): Promise => { + signalTarget.off("SIGINT", shutdown); + signalTarget.off("SIGTERM", shutdown); + if (!actualClose) { + actualClose = new Promise((resolve) => server.close(() => resolve())); + } + return actualClose; + }; + shutdown = () => { + signalTarget.off("SIGINT", shutdown); + signalTarget.off("SIGTERM", shutdown); + if (!signalClose) { + signalClose = options.closeServer + ? Promise.resolve(options.closeServer()) + : close(); + void signalClose.then(() => exit(0), () => exit(0)); + } + }; + signalTarget.on("SIGINT", shutdown); + signalTarget.on("SIGTERM", shutdown); + + return { + server, + address, + port: address.port, + baseUrl, + initialUrl, + security, + close, + }; +} + +function dispatchRequest( + routes: readonly WebRoute[], + security: WebSecurity, + request: http.IncomingMessage, + response: http.ServerResponse, +): void { + if (!isAllowedWebHost(request.headers.host, security.port)) { + response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }); + response.end("forbidden"); + return; + } + + let pathname: string; + try { + pathname = new URL(request.url || "/", `http://127.0.0.1:${security.port}`).pathname; + } catch { + response.writeHead(400, { "content-type": "text/plain; charset=utf-8" }); + response.end("bad request"); + return; + } + + const route = routes.find((candidate) => candidate.path === pathname); + if (!checkWebRequest(request, response, security, { htmlPage: route?.kind === "page" })) return; + if (!route) { + response.writeHead(404, { "content-type": "application/json; charset=utf-8" }); + response.end(JSON.stringify({ error: "not found" })); + return; + } + + route.handler(request, response); +} diff --git a/tests/review-command.test.ts b/tests/review-command.test.ts index eda5363..d15014c 100644 --- a/tests/review-command.test.ts +++ b/tests/review-command.test.ts @@ -1,6 +1,10 @@ import { Command } from "commander"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { parseReviewPort, registerReviewCommand } from "../src/cli/commands/review.js"; +import { startWebServer, type WebServerHandle } from "../src/web/server.js"; +import { EventEmitter } from "node:events"; + +afterEach(() => vi.restoreAllMocks()); describe("parseReviewPort", () => { it("defaults to 3100", () => { @@ -26,4 +30,39 @@ describe("registerReviewCommand", () => { expect(program.commands.map((command) => command.name())).toEqual(["review"]); expect(program.commands[0].description()).toContain("local review"); }); + + it("starts the shared server with the review aliases and API route", async () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const program = new Command(); + let started: WebServerHandle | undefined; + registerReviewCommand(program, { + startServer: async (options) => { + started = await startWebServer({ + ...options, + port: 0, + signalTarget: new EventEmitter(), + exit: () => {}, + }); + return started; + }, + loadReview: async (_root, ref, file) => ({ ref, file }), + }); + + await program.parseAsync(["node", "codedeck", "review", "--no-open"], { from: "node" }); + + expect(started?.initialUrl).toContain("?t="); + expect(log.mock.calls.flat().join(" ")).toContain(started?.initialUrl); + const root = await fetch(`${started?.baseUrl}/`); + const alias = await fetch(`${started?.baseUrl}/review`); + expect(root.status).toBe(200); + expect(alias.status).toBe(200); + expect(await root.text()).toContain("Review local"); + expect(await alias.text()).toContain("Review local"); + + const api = await fetch(`${started?.baseUrl}/api/review?file=src/web/server.ts`); + expect(api.status).toBe(200); + expect(await api.json()).toEqual({ ref: "HEAD", file: "src/web/server.ts" }); + + await started?.close(); + }); }); diff --git a/tests/setup-plan.test.ts b/tests/setup-plan.test.ts new file mode 100644 index 0000000..18cdd71 --- /dev/null +++ b/tests/setup-plan.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, it } from "vitest"; + +import type { BatchModelsResult, HarnessModels } from "../src/core/models.js"; +import type { RunAgentConfig } from "../src/config/config.js"; +import { + buildSetupPlan, + resolveSetupTarget, + SetupUsageError, + validateBindings, +} from "../src/config/setup.js"; + +describe("buildSetupPlan", () => { + it("applies selected fields, preserves unrelated config, and returns their diff paths", () => { + const current = { + defaultAgent: "claude", + worktree: false, + remoteControl: true, + pty: true, + custom: { keep: true }, + agents: { + general: { harness: "claude", model: "sonnet" }, + reviewer: { harness: "claude", model: "old", effort: "low" }, + }, + orchestrator: { investigate: "none", selfWork: "none", tools: "dispatch" }, + defaultSandbox: "workspace-write", + autocompact: { enabled: false, cap: 300_000, percent: 0.7, tokens: 210_000, mode: "tokens" }, + } as RunAgentConfig; + const original = structuredClone(current); + const target = resolveSetupTarget(current); + + const plan = buildSetupPlan(current, target, { + agents: { reviewer: { harness: "codex", model: "gpt-5.7", effort: "high" } }, + orchestrator: { investigate: "read", selfWork: "small", tools: "edit", parallelism: 7 }, + sandbox: "danger-full-access", + autocompact: { enabled: true }, + }); + + expect(plan.proposedConfig).toEqual({ + ...current, + agents: { + general: { harness: "claude", model: "sonnet" }, + reviewer: { harness: "codex", model: "gpt-5.7", effort: "high" }, + }, + orchestrator: { investigate: "read", selfWork: "small", tools: "edit", parallelism: 7 }, + defaultSandbox: "danger-full-access", + autocompact: { enabled: true, cap: 300_000, percent: 0.7, tokens: 210_000, mode: "tokens" }, + }); + expect(plan.diff.map((change) => change.path)).toEqual([ + "/agents/reviewer/effort", + "/agents/reviewer/harness", + "/agents/reviewer/model", + "/autocompact/enabled", + "/defaultSandbox", + "/orchestrator/investigate", + "/orchestrator/parallelism", + "/orchestrator/selfWork", + "/orchestrator/tools", + ]); + expect(plan.diff.find((change) => change.path === "/orchestrator/parallelism")).toMatchObject({ + beforePresent: false, + afterPresent: true, + after: 7, + }); + expect(current).toEqual(original); + }); + + it("preserves skipped bindings and an omitted orchestrator", () => { + const binding = { harness: "codex", model: "typed:model", effort: "max" } as const; + const orchestrator = { investigate: "free", selfWork: "small", tools: "read", parallelism: 4 } as const; + const current: RunAgentConfig = { agents: { reviewer: binding }, orchestrator }; + const target = resolveSetupTarget(current); + + const plan = buildSetupPlan(current, target, { agents: {} }); + + expect(plan.proposedConfig.agents).toEqual({ reviewer: binding }); + expect(plan.proposedConfig.orchestrator).toEqual(orchestrator); + expect(plan.diff).toEqual([]); + }); + + it("preserves an absent orchestrator and absent autocompact when they are not selected", () => { + const current: RunAgentConfig = { + agents: { general: { harness: "claude", model: "sonnet" } }, + defaultSandbox: "workspace-write", + }; + const target = resolveSetupTarget(current); + + const plan = buildSetupPlan(current, target, { + agents: { general: current.agents!.general! }, + autocompact: { enabled: false }, + }); + + expect(Object.hasOwn(plan.proposedConfig, "orchestrator")).toBe(false); + expect(Object.hasOwn(plan.proposedConfig, "autocompact")).toBe(false); + expect(plan.diff).toEqual([]); + }); + + it("turns off an existing autocompact block and preserves its other fields", () => { + const current: RunAgentConfig = { + agents: { general: { harness: "claude", model: "sonnet" } }, + autocompact: { enabled: true, cap: 300_000, percent: 0.7, tokens: 210_000, mode: "tokens" }, + }; + + const plan = buildSetupPlan(current, resolveSetupTarget(current), { + agents: { general: current.agents!.general! }, + autocompact: { enabled: false }, + }); + + expect(plan.proposedConfig.autocompact).toEqual({ + enabled: false, + cap: 300_000, + percent: 0.7, + tokens: 210_000, + mode: "tokens", + }); + expect(plan.diff.map((change) => change.path)).toEqual(["/autocompact/enabled"]); + }); + + it("updates only the selected profile snapshot", () => { + const current: RunAgentConfig = { + defaultAgent: "claude", + worktree: false, + activeProfile: "first", + agents: { general: { harness: "claude", model: "global" } }, + profiles: { + first: { agents: { general: { harness: "omp", model: "first" } } }, + second: { + agents: { reviewer: { harness: "codex", model: "before" } }, + defaultSandbox: "workspace-write", + }, + }, + }; + const firstTarget = resolveSetupTarget(current); + const target = resolveSetupTarget(current, "second"); + + expect(firstTarget.profile).toBe("first"); + expect(firstTarget.config.agents?.general).toEqual({ harness: "omp", model: "first" }); + const plan = buildSetupPlan(current, target, { + agents: { reviewer: { harness: "codex", model: "after" } }, + sandbox: "danger-full-access", + }); + + expect(plan.proposedConfig.agents).toEqual(current.agents); + expect(plan.proposedConfig.profiles?.first).toEqual(current.profiles?.first); + expect(plan.proposedConfig.profiles?.second).toEqual({ + agents: { reviewer: { harness: "codex", model: "after" } }, + defaultSandbox: "danger-full-access", + }); + expect(plan.diff.map((change) => change.path)).toEqual([ + "/profiles/second/agents/reviewer/model", + "/profiles/second/defaultSandbox", + ]); + }); + + it("uses explicit defaults for a new profile and rejects a missing active profile", () => { + const current: RunAgentConfig = { + defaultAgent: "claude", + activeProfile: "missing", + custom: "preserved", + } as RunAgentConfig; + + expect(() => resolveSetupTarget(current)).toThrow( + new SetupUsageError( + 'Active profile "missing" does not exist. Choose an existing profile with "codedeck profile use " or pass --profile .', + ), + ); + + const target = resolveSetupTarget(current, "new-profile"); + expect(target.profile).toBe("new-profile"); + expect(target.config.agents).toEqual({}); + const plan = buildSetupPlan(current, target, { agents: {} }); + expect(plan.proposedConfig.profiles?.["new-profile"]).toEqual({ agents: {} }); + expect(plan.proposedConfig.custom).toBe("preserved"); + }); +}); + +describe("setup binding validation", () => { + function catalog(status: BatchModelsResult["status"], models: HarnessModels[]): BatchModelsResult { + return { + models, + status, + source: status === "fresh" ? "cache" : status === "offline" ? "stale-cache" : "none", + ageMs: status === "unavailable" ? null : 1, + cacheWriteFailed: false, + }; + } + + const codex: HarnessModels = { + agent: "codex", + available: true, + providers: [ + { + provider: "openai", + models: [{ id: "gpt-5.7", name: "GPT-5.7", provider: "openai", aliases: ["gpt-latest"] }], + }, + ], + }; + + it("validates a catalog alias separately from planning", () => { + const plan = buildSetupPlan( + {}, + resolveSetupTarget({}), + { agents: { reviewer: { harness: "codex", model: "gpt-latest" } } }, + ); + const validation = validateBindings( + [{ role: "reviewer", binding: { harness: "codex", model: "gpt-latest" } }], + catalog("fresh", [codex]), + ); + + expect(plan.proposedConfig.agents?.reviewer).toEqual({ harness: "codex", model: "gpt-latest" }); + expect(validation).toEqual({ + entries: [{ + role: "reviewer", + harness: "codex", + model: "gpt-latest", + status: "accepted", + message: "", + }], + code: undefined, + message: null, + }); + }); + + it("reports an off-catalog selection from separate validation", () => { + const selected: RunAgentConfig = {}; + const plan = buildSetupPlan(selected, resolveSetupTarget(selected), { + agents: { reviewer: { harness: "codex", model: "typed:model" } }, + }); + const validation = validateBindings( + [{ role: "reviewer", binding: { harness: "codex", model: "typed:model" } }], + catalog("fresh", [codex]), + ); + + expect(plan.proposedConfig.agents?.reviewer).toEqual({ harness: "codex", model: "typed:model" }); + expect(validation.entries).toEqual([{ + role: "reviewer", + harness: "codex", + model: "typed:model", + status: "unknown-model", + message: 'Model "typed:model" is not in the codex catalog for role "reviewer".', + }]); + expect(validation.code).toBe(12); + }); + + it("keeps stale unknown bindings unverified", () => { + const validation = validateBindings( + [{ role: "reviewer", binding: { harness: "codex", model: "typed:model" } }], + catalog("offline", [codex]), + ); + + expect(validation.entries[0]).toMatchObject({ status: "unverified" }); + expect(validation.code).toBe(13); + expect(validation.message).toContain("catalog is stale"); + }); +}); diff --git a/tests/usage-cli.test.ts b/tests/usage-cli.test.ts index 528fcc1..85b25fb 100644 --- a/tests/usage-cli.test.ts +++ b/tests/usage-cli.test.ts @@ -1,5 +1,6 @@ import { Command } from "commander"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { buildUsageQueryParams } from "../src/core/usage-query.js"; const ensureDaemonStarted = vi.fn(async () => {}); const request = vi.fn(); @@ -79,10 +80,64 @@ beforeEach(() => { }); afterEach(() => { + vi.useRealTimers(); process.exitCode = originalExitCode; vi.restoreAllMocks(); }); +describe("buildUsageQueryParams", () => { + const now = new Date(2026, 8, 22, 12, 0, 0, 0); + + it("applies all, today, then days precedence", () => { + expect(buildUsageQueryParams({ all: true, today: true, days: "7" }, "/repo", now).period).toBe("all"); + expect(buildUsageQueryParams({ today: true, days: "7" }, "/repo", now).period).toBe("today"); + expect(buildUsageQueryParams({ days: "7" }, "/repo", now).period).toBe("7d"); + }); + + it.each([ + ["3", "3d"], + ["7", "7d"], + ["30", "30d"], + ])("maps %s days to the %s period", (days, period) => { + expect(buildUsageQueryParams({ days }, "/repo", now).period).toBe(period); + }); + + it("maps other positive day counts to a local-midnight since value", () => { + const expectedSince = new Date(2026, 8, 18, 0, 0, 0, 0).toISOString(); + + expect(buildUsageQueryParams({ days: "5" }, "/repo", now)).toEqual({ + period: undefined, + since: expectedSince, + until: undefined, + repository: undefined, + model: undefined, + agent: undefined, + }); + }); + + it("defaults to today when since is absent", () => { + expect(buildUsageQueryParams({}, "/repo", now).period).toBe("today"); + }); + + it("passes through explicit filters and lets current override repo", () => { + expect(buildUsageQueryParams({ + since: "2026-09-01T00:00:00.000Z", + until: "2026-09-20T23:59:59.999Z", + repo: "/selected/repo", + current: true, + model: "gpt-5.6-luna", + agent: "codex", + }, "/current/repo", now)).toEqual({ + period: undefined, + since: "2026-09-01T00:00:00.000Z", + until: "2026-09-20T23:59:59.999Z", + repository: "/current/repo", + model: "gpt-5.6-luna", + agent: "codex", + }); + }); +}); + describe("usage CLI", () => { it("sends a valid native id and finite non-negative cost as an observation", async () => { request.mockResolvedValue(runSummary); @@ -131,4 +186,30 @@ describe("usage CLI", () => { expect(logs[0]).toContain("orchestrator: 1 sessions"); expect(logs[0]).toContain("cost $0.25"); }); + + it("sends aggregate filters built with the CLI cwd and current date", async () => { + request.mockResolvedValue(usageResult); + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 8, 22, 12, 0, 0, 0)); + vi.spyOn(process, "cwd").mockReturnValue("/current/repo"); + + await runProgram([ + "--days", "5", + "--since", "2026-09-01T00:00:00.000Z", + "--until", "2026-09-20T23:59:59.999Z", + "--repo", "/selected/repo", + "--current", + "--model", "gpt-5.6-luna", + "--agent", "codex", + ]); + + expect(request).toHaveBeenCalledWith("usage.query", { + period: undefined, + since: new Date(2026, 8, 18, 0, 0, 0, 0).toISOString(), + until: "2026-09-20T23:59:59.999Z", + repository: "/current/repo", + model: "gpt-5.6-luna", + agent: "codex", + }); + }); }); diff --git a/tests/web-cli.test.ts b/tests/web-cli.test.ts new file mode 100644 index 0000000..8aced0b --- /dev/null +++ b/tests/web-cli.test.ts @@ -0,0 +1,84 @@ +import { EventEmitter } from "node:events"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Command } from "commander"; +import { createCliProgram } from "../src/cli/index.js"; +import { registerUiCommand } from "../src/cli/commands/ui.js"; +import { startWebServer, type WebServerHandle } from "../src/web/server.js"; + +const handles: WebServerHandle[] = []; +const originalExitCode = process.exitCode; + +afterEach(async () => { + await Promise.all(handles.splice(0).map((handle) => handle.close())); + process.exitCode = originalExitCode; + vi.restoreAllMocks(); +}); + +describe("ui CLI command", () => { + it("appears in root help and serves only its registered home and review pages", async () => { + const root = createCliProgram(); + expect(root.helpInformation()).toContain("ui"); + + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + let started: WebServerHandle | undefined; + const program = new Command(); + registerUiCommand(program, { + startServer: async (options) => { + started = await startWebServer({ + ...options, + port: 0, + signalTarget: new EventEmitter(), + exit: vi.fn(), + }); + handles.push(started); + return started; + }, + }); + await program.parseAsync(["node", "codedeck", "ui", "--no-open"], { from: "node" }); + + expect(started).toBeDefined(); + expect(started?.initialUrl).toContain("?t="); + expect(log.mock.calls.flat().join(" ")).toContain(started?.initialUrl); + const rootResponse = await fetch(`${started?.baseUrl}/`); + const rootHtml = await rootResponse.text(); + expect(rootResponse.status).toBe(200); + expect(rootHtml).toContain('href="/review"'); + expect(rootHtml).not.toContain('href="/setup"'); + expect(rootHtml).not.toContain('href="/usage"'); + + const reviewResponse = await fetch(`${started?.baseUrl}/review`); + expect(reviewResponse.status).toBe(200); + expect(reviewResponse.headers.get("content-security-policy")).toBe("frame-ancestors 'none'"); + expect(await reviewResponse.text()).toContain("Review local"); + }); + + it("rejects an invalid port without starting a server", async () => { + const startServer = vi.fn(); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const program = new Command(); + registerUiCommand(program, { startServer }); + + await program.parseAsync(["node", "codedeck", "ui", "--port", "0"], { from: "node" }); + + expect(startServer).not.toHaveBeenCalled(); + expect(error).toHaveBeenCalledWith("--port must be a positive integer"); + expect(process.exitCode).toBe(1); + }); + + it("reports a listen failure without printing a started URL", async () => { + const startServer = vi.fn(async () => { + throw new Error("EADDRINUSE"); + }); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const program = new Command(); + registerUiCommand(program, { startServer }); + + await program.parseAsync(["node", "codedeck", "ui", "--no-open"], { from: "node" }); + + expect(startServer).toHaveBeenCalledOnce(); + expect(error).toHaveBeenCalledWith("Failed to listen on 127.0.0.1:3100: EADDRINUSE"); + expect(log).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); +}); diff --git a/tests/web-pages.test.ts b/tests/web-pages.test.ts new file mode 100644 index 0000000..bf7c982 --- /dev/null +++ b/tests/web-pages.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { renderHomePage } from "../src/web/home-page.js"; + +describe("renderHomePage", () => { + it("links only the pages registered by the active route table", () => { + const html = renderHomePage([{ label: "Review", path: "/review" }]); + + expect(html).toContain('href="/review"'); + expect(html).toContain("Review"); + expect(html).not.toContain('href="/usage"'); + expect(html).not.toContain('href="/setup"'); + expect(html).toContain("