diff --git a/.changeset/doctor-in-settings.md b/.changeset/doctor-in-settings.md new file mode 100644 index 000000000..b58e4bf86 --- /dev/null +++ b/.changeset/doctor-in-settings.md @@ -0,0 +1,5 @@ +--- +"aicodeman": minor +--- + +Settings → System → Diagnostics runs `codeman doctor` on the server (`GET /api/doctor`) and lists which agent CLIs, tmux, Node and the optional office tools are installed, their versions, paths and install hints. The probe runs in a child process so a slow `--version` can never freeze the server; admin only in multi-user mode. diff --git a/README.md b/README.md index 4869b0fdb..0f6f5927f 100644 --- a/README.md +++ b/README.md @@ -444,6 +444,7 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt ## More Features - **Background daemon & service install** — `codeman web -d` runs the server detached with a pidfile, `~/.codeman/web.log`, and verified startup (it polls the server until it answers, so a port clash never reads as success); `codeman service install` writes a systemd user unit (Linux) or LaunchAgent (macOS) with your shell's PATH baked in, so an nvm or Homebrew `node`, `tmux` and `claude` are actually found. Secrets are never written into unit files +- **Diagnostics in Settings** — **App Settings → System → Diagnostics → Run checks** runs `codeman doctor` on the server and lists Node, tmux, every agent CLI and the optional office tools with versions, paths and install hints. CLIs are found the same way the Run menu finds them (including `~/.local/bin` and npm/nvm prefixes), so a service with a minimal `PATH` still reports them correctly. Admin only in multi-user mode. - **Self-update** — git-clone installs under systemd/launchd update in place from **App Settings → System → Updates**: it detects the latest release, auto-stashes a dirty tree, and streams build progress across the service restart (npm installs report as non-updatable) - **Clone a GitHub repo as a case** — paste a repository URL into **Add Case → Clone Repo** and Codeman clones it into `~/codeman-cases/` and registers it as a normal case, ready to run an agent in. It preflights the URL while you type (tells you whether it can be cloned anonymously and offers the repo's real branches and tags for the optional branch/tag field), fills the case name in from the URL, and lets you pick which CLI the Run button should use. Public repositories over `https://`; Codeman never collects or stores credentials - **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, **Grok**, **DeepSeek Harness**, or **OMP** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*` vs `GROK_*`/`XAI_*` vs `DSH_*`/`DEEPSEEK_*` vs `OMP_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md), [`docs/pi-integration.md`](docs/pi-integration.md), [`docs/grok-integration.md`](docs/grok-integration.md), [`docs/deepseek-integration.md`](docs/deepseek-integration.md) and [`docs/omp-integration.md`](docs/omp-integration.md) diff --git a/config/test-suites.ts b/config/test-suites.ts index 004af4625..7566a0635 100644 --- a/config/test-suites.ts +++ b/config/test-suites.ts @@ -35,6 +35,7 @@ export const BROWSER_TEST_GLOBS = [ 'test/shift-enter-keypress.browser.test.ts', 'test/key-tester.browser.test.ts', 'test/webhook-settings.browser.test.ts', + 'test/doctor-settings.browser.test.ts', 'test/split-pane-orchestration.browser.test.ts', 'test/split-pane-auto-collapse.browser.test.ts', 'test/mobile-ime-preview.browser.test.ts', diff --git a/docs/api-reference.md b/docs/api-reference.md index 7b534ddc2..13d31aa20 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -748,6 +748,10 @@ Posts the Web Push events to ntfy, Slack, Discord or a generic JSON URL (Setting Delivery goes through the same egress guard as web tabs (refused on the resolved address too), does not follow redirects, times out after 5 s, sends the same event for the same session at most once per 3 s, and has at most 5 requests in flight. Error text never contains the URL. +## Diagnostics + +`GET /api/doctor[?category=core|office|other]` returns the `codeman doctor --json` report (`platform`, `summary`, `tools[]` with `status` `ok` \| `missing` \| `outdated` \| `skipped` \| `error`, `version`, `path`, `installHint`). The probe engine is synchronous, so it runs in a child process of the same entry script, never on the server's event loop (30 s timeout). It names install paths and versions, so it is admin only in multi-user mode (`403`). `400` for an unknown category, `500` if the child produces no report. + ## Voice dictation Browser dictation transcribed through this server's Claude Code login, i.e. the diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md index 4892d3be8..d5904133f 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -145,8 +145,10 @@ Rebinding for the shortcut registry. See [Keyboard Shortcuts](Keyboard-Shortcuts ### System `CLAUDE.md` template for new cases, default working directory, the image watcher, and -Cloudflare tunnel controls including the tunnel and upload URLs. In multi-user mode, the -**Users** administration entry is injected here. +Cloudflare tunnel controls including the tunnel and upload URLs. The **Diagnostics** group runs +`codeman doctor` on the server and lists the agent CLIs, tmux, Node and the optional office +tools with their versions and install hints (admin only in multi-user mode). In multi-user +mode, the **Users** administration entry is injected here. ## Session Options diff --git a/src/config/dependency-registry.ts b/src/config/dependency-registry.ts index 96512a363..c7b96cdc4 100644 --- a/src/config/dependency-registry.ts +++ b/src/config/dependency-registry.ts @@ -7,6 +7,8 @@ * @module config/dependency-registry */ +import { homedir } from 'node:os'; +import { join } from 'node:path'; import { enabledClis } from './cli-registry/registry.js'; import { compileVersionRegex } from './cli-registry/patterns.js'; @@ -30,6 +32,20 @@ export interface PathResolver { * there and a false "installed" contradicts the run mode's own resolver. */ requireVersionMatch?: boolean; + /** + * Absolute directories to probe (`/`) when `which` misses. A service (systemd, + * launchd) runs with a minimal PATH, so a CLI installed under `~/.local/bin` or an npm/nvm + * prefix is invisible to `which` while the run mode, which falls back to the registry's + * `discovery.searchDirs`, still finds it. Carries those dirs so the doctor agrees. + */ + searchDirs?: string[]; +} + +/** Expand a leading `~` (the only form registry `searchDirs` use). */ +function expandSearchDir(dir: string): string { + if (dir === '~') return homedir(); + if (dir.startsWith('~/')) return join(homedir(), dir.slice(2)); + return dir; } /** Resolve a Windows-installed app reachable from win32 or WSL. */ @@ -131,6 +147,7 @@ function cliDependencyEntries(): ToolDependency[] { // (pi, grok, dsh): a bare `which` hit there is not evidence of the right // program, so a version mismatch means MISSING rather than unknown-version. requireVersionMatch: version?.requireVersionMatch, + searchDirs: cli.discovery.searchDirs.map(expandSearchDir), }, }, ], diff --git a/src/utils/dependency-checker.ts b/src/utils/dependency-checker.ts index 6d5c6b4a6..6081078ab 100644 --- a/src/utils/dependency-checker.ts +++ b/src/utils/dependency-checker.ts @@ -94,11 +94,23 @@ export function checkTool(tool: ToolDependency, host: ProbeHost): ToolResult { if (!spec) return { ...base, status: 'skipped', reason: `not applicable on ${host.environment}` }; if (spec.resolver.kind === 'path') { - const { bins, versionArg, versionRegex, requireVersionMatch } = spec.resolver; + const { bins, versionArg, versionRegex, requireVersionMatch, searchDirs } = spec.resolver; for (const bin of bins) { - const resolved = host.which(bin); + // `which` first (the PATH), then the registry's search dirs: under a service the PATH is + // minimal and the run mode finds the CLI through those dirs, so the doctor must too. + let resolved = host.which(bin); + if (!resolved && searchDirs) { + for (const dir of searchDirs) { + const candidate = `${dir.replace(/\/+$/, '')}/${bin}`; + if (host.fileExists(candidate)) { + resolved = candidate; + break; + } + } + } if (resolved) { - const out = host.runVersion(bin, [versionArg ?? '--version']); + // Run the RESOLVED path: a bare name would miss the same binary `which` just missed. + const out = host.runVersion(resolved, [versionArg ?? '--version']); const version = out ? extractVersion(out, versionRegex) : undefined; // A generic binary name that prints the wrong thing is some OTHER program (see // PathResolver.requireVersionMatch). Keep looking, then report MISSING; the diff --git a/src/web/public/index.html b/src/web/public/index.html index cc8f03370..b05076bf3 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2829,6 +2829,20 @@

System

Paths, automation and remote access. Set once, rarely touched.

+
+

Diagnostics

server
+
+
+
+ Check this machine + Runs codeman doctor on the server: which agent CLIs, tmux, Node and the optional office tools are installed, their versions, and how to install what is missing. +
+ +
+ +
+
+

Paths

synced
diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 91efc5d33..dbe66cf3f 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -422,6 +422,7 @@ Object.assign(CodemanApp.prototype, { this._mcpSyncSavedOn = settings.mcpSyncEnabled === true; document.getElementById('appSettingsMcpSync').checked = this._mcpSyncSavedOn; this.applyMcpSyncVisibility(); + this._applyDoctorAdminGate(); this.loadWebhook(); // Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens). document.getElementById('appSettingsReadMyMind').checked = settings.readMyMindEnabled === true; @@ -1192,6 +1193,18 @@ Object.assign(CodemanApp.prototype, { group.style.display = me.multiUser && me.role !== 'admin' ? 'none' : ''; }, + /** + * GET /api/doctor is admin-only in multi-user mode (it names install paths on the host), so a + * non-admin gets no Diagnostics group instead of a button that can only answer 403. Also + * wired to `codeman:me` for the same late-resolving role as the groups above. + */ + _applyDoctorAdminGate() { + const group = document.getElementById('doctorGroup'); + if (!group) return; + const me = window.__codemanUser || {}; + group.style.display = me.multiUser && me.role !== 'admin' ? 'none' : ''; + }, + /** Preview (apply=false) or run (apply=true) the MCP server sync across enabled CLIs. */ async mcpSync(apply) { const out = this.$('mcpSyncResult'); @@ -1364,6 +1377,67 @@ Object.assign(CodemanApp.prototype, { } }, + /** + * Settings → System → Diagnostics: run `codeman doctor` on the server (GET /api/doctor) and list + * each tool. Built with DOM nodes and textContent: paths and versions come from the host. + */ + async runDoctor() { + const out = document.getElementById('doctorResult'); + const btn = document.getElementById('doctorRunBtn'); + if (!out) return; + const say = (text) => { + out.replaceChildren(document.createTextNode(text)); + out.style.display = 'block'; + }; + if (btn) btn.disabled = true; + say('Checking…'); + try { + const res = await this._api('/api/doctor'); + let body = null; + try { body = res ? await res.json() : null; } catch { /* fall through */ } + if (!res || !res.ok || !body || body.success === false) { + say(body?.error || 'The check failed.'); + return; + } + const { tools, summary, platform } = body.data; + const glyph = { ok: '✓', missing: '✗', outdated: '!', error: '!', skipped: '–' }; + const list = document.createElement('ul'); + list.style.margin = '0'; + list.style.paddingLeft = '1.2em'; + for (const t of tools) { + const li = document.createElement('li'); + const strong = document.createElement('b'); + strong.textContent = `${glyph[t.status] || '?'} ${t.label}`; + li.append(strong); + const bits = [t.status]; + if (t.version) bits.push(t.version); + if (t.status !== 'ok' && t.status !== 'skipped') bits.push(t.required ? 'required' : 'optional'); + if (t.reason) bits.push(t.reason); + li.append(document.createTextNode(` ${bits.join(' · ')}`)); + if (t.path) { + const p = document.createElement('div'); + p.className = 'mono'; + p.textContent = t.path; + li.append(p); + } + if (t.status === 'missing' && t.installHint) { + const h = document.createElement('div'); + h.textContent = `Install: ${t.installHint}`; + li.append(h); + } + list.append(li); + } + const head = document.createElement('p'); + head.textContent = + `${summary.ok} ok · ${summary.requiredMissing} required missing · ${summary.optionalMissing} optional missing` + + ` (${platform.environment})`; + out.replaceChildren(head, list); + out.style.display = 'block'; + } finally { + if (btn) btn.disabled = false; + } + }, + _setUpdateResult(html) { const el = this.$('updateResult'); if (el) { el.style.display = 'block'; el.innerHTML = html; } @@ -4373,4 +4447,5 @@ document.addEventListener?.('codeman:me', () => { window.app?._applyCustomModelAdminGate?.(); window.app?._applyCliManagementAdminGate?.(); window.app?._applyMcpSyncAdminGate?.(); + window.app?._applyDoctorAdminGate?.(); }); diff --git a/src/web/routes/doctor-routes.ts b/src/web/routes/doctor-routes.ts new file mode 100644 index 000000000..c835224ed --- /dev/null +++ b/src/web/routes/doctor-routes.ts @@ -0,0 +1,100 @@ +/** + * @fileoverview `GET /api/doctor` — the `codeman doctor` dependency report (Node, the agent CLIs, + * tmux, LibreOffice, MS Office) for Settings → System → Diagnostics. + * + * The probe engine is synchronous (`which` + ` --version` per tool, each up to its own + * timeout), so it must never run on the server's event loop: a handful of slow probes would + * freeze every request and every SSE client, with the process still alive. The default runner + * therefore runs `codeman doctor --json` in a CHILD PROCESS of this same entry script and + * parses its output; the runner is injected so tests never spawn anything. + * + * Read-only, but the report names install paths and versions on the host, so in multi-user + * mode it is admin only (the same bar as the other host-introspection routes). + */ + +import { execFile } from 'node:child_process'; +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js'; +import { isAdmin } from '../route-helpers.js'; +import { isMultiUserMode } from '../../config/multiuser.js'; +import { TOOL_CATEGORIES } from '../../config/dependency-registry.js'; +import type { DependencyReportJson } from '../../utils/dependency-report.js'; + +export type DoctorRunner = (category?: string) => Promise; + +const DOCTOR_TIMEOUT_MS = 30_000; + +function isReport(v: unknown): v is DependencyReportJson { + const r = v as Partial | null; + return !!r && Array.isArray(r.tools) && typeof r.summary === 'object' && r.summary !== null; +} + +/** + * Run `doctor --json` out of process. The CLI exits non-zero when a required tool is missing, + * and still prints the report, so a non-zero exit with parseable stdout is a normal result. + */ +export const defaultDoctorRunner: DoctorRunner = (category) => + new Promise((resolve, reject) => { + const args = [ + ...process.execArgv, + process.argv[1], + 'doctor', + '--json', + ...(category ? ['--category', category] : []), + ]; + execFile( + process.execPath, + args, + { timeout: DOCTOR_TIMEOUT_MS, maxBuffer: 1024 * 1024, env: process.env }, + (err, stdout) => { + if (err && (err as { killed?: boolean }).killed) { + return reject(new Error(`timed out after ${DOCTOR_TIMEOUT_MS / 1000} s`)); + } + try { + const parsed: unknown = JSON.parse(stdout); + if (isReport(parsed)) return resolve(parsed); + } catch { + /* fall through to the error below */ + } + reject(err ?? new Error('doctor produced no report')); + } + ); + }); + +export function registerDoctorRoutes(app: FastifyInstance, runner: DoctorRunner = defaultDoctorRunner): void { + // Each run forks a full Node process, so two tabs or a script must not stack them: callers + // asking for the same category while one is in flight share its promise. + const inFlight = new Map>(); + const runShared = (category?: string): Promise => { + const key = category ?? ''; + let running = inFlight.get(key); + if (!running) { + running = runner(category).finally(() => inFlight.delete(key)); + inFlight.set(key, running); + } + return running; + }; + app.get( + '/api/doctor', + async (req: FastifyRequest, reply: FastifyReply): Promise> => { + if (isMultiUserMode() && !isAdmin(req)) { + reply.code(403); + return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode'); + } + const { category } = req.query as { category?: string }; + if (category !== undefined && !(TOOL_CATEGORIES as readonly string[]).includes(category)) { + reply.code(400); + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + `Unknown category "${category}". Valid categories: ${TOOL_CATEGORIES.join(', ')}` + ); + } + try { + return { success: true, data: await runShared(category) }; + } catch (err) { + reply.code(500); + return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, `doctor failed: ${getErrorMessage(err)}`); + } + } + ); +} diff --git a/src/web/routes/index.ts b/src/web/routes/index.ts index a88860419..97041b41c 100644 --- a/src/web/routes/index.ts +++ b/src/web/routes/index.ts @@ -30,6 +30,7 @@ export { registerWebviewRoutes, tryWebviewRefererFallback } from './webview-rout export { registerTabLayoutRoutes } from './tab-layout-routes.js'; export { registerMcpSyncRoutes } from './mcp-sync-routes.js'; export { registerWebhookRoutes } from './webhook-routes.js'; +export { registerDoctorRoutes } from './doctor-routes.js'; export { registerCustomModelRoutes, refreshAllCustomModelHosts, diff --git a/src/web/server.ts b/src/web/server.ts index b9f405562..d1d3b98a6 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -202,6 +202,7 @@ import { registerTabLayoutRoutes, registerMcpSyncRoutes, registerWebhookRoutes, + registerDoctorRoutes, registerCustomModelRoutes, refreshAllCustomModelHosts, readCustomModelEndpointsEnabled, @@ -1143,6 +1144,7 @@ export class WebServer extends EventEmitter { configDir: getDataDir(), hostTitle: () => this.windowTitle, }); + registerDoctorRoutes(this.app); registerCustomModelRoutes(this.app); registerCliRegistryRoutes(this.app); diff --git a/test/dependency-checker.test.ts b/test/dependency-checker.test.ts index 70e69bd5d..978bbc629 100644 --- a/test/dependency-checker.test.ts +++ b/test/dependency-checker.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { dependencyRegistry } from '../src/config/dependency-registry.js'; import { detectEnvironment, @@ -259,6 +259,53 @@ describe('checkTool with requireVersionMatch (generic binary names)', () => { }); }); +describe('checkTool with searchDirs (service PATH is minimal)', () => { + const claudeLike: ToolDependency = { + ...tmuxTool, + id: 'claude', + label: 'Claude CLI', + resolvers: [ + { + match: ['linux'], + resolver: { kind: 'path', bins: ['claude'], searchDirs: ['/home/u/.local/bin', '/opt/npm/bin/'] }, + }, + ], + }; + + it('finds a CLI that only lives in a searchDirs entry and runs --version on the absolute path', () => { + const runVersion = vi.fn(() => 'claude 2.1.0'); + const host = fakeHost('linux', { fileExists: (p) => p === '/opt/npm/bin/claude', runVersion }); + expect(checkTool(claudeLike, host)).toMatchObject({ + status: 'ok', + path: '/opt/npm/bin/claude', + version: '2.1.0', + }); + expect(runVersion).toHaveBeenCalledWith('/opt/npm/bin/claude', ['--version']); + }); + + it('still reports missing when neither PATH nor any search dir has it', () => { + expect(checkTool(claudeLike, fakeHost('linux'))).toMatchObject({ status: 'missing' }); + }); + + it('prefers the PATH hit over a search dir', () => { + const host = fakeHost('linux', { + which: () => '/usr/bin/claude', + fileExists: () => true, + runVersion: () => '1.0.0', + }); + expect(checkTool(claudeLike, host)).toMatchObject({ path: '/usr/bin/claude' }); + }); + + it('carries each enabled CLI’s expanded discovery.searchDirs onto its registry row', () => { + const rows = dependencyRegistry().flatMap((t) => t.resolvers.map((r) => r.resolver)); + const withDirs = rows.filter((r) => r.kind === 'path' && r.searchDirs?.length); + expect(withDirs.length).toBeGreaterThan(0); + for (const r of withDirs) { + if (r.kind === 'path') for (const d of r.searchDirs ?? []) expect(d.startsWith('~')).toBe(false); + } + }); +}); + describe('checkAll', () => { it('maps every tool to a result', () => { const results = checkAll([tmuxTool, msTool], fakeHost('linux')); diff --git a/test/doctor-cli-json.test.ts b/test/doctor-cli-json.test.ts new file mode 100644 index 000000000..64c04881d --- /dev/null +++ b/test/doctor-cli-json.test.ts @@ -0,0 +1,77 @@ +// @vitest-environment node +// The contract GET /api/doctor's default runner relies on: the same entry script, given +// `doctor --json`, prints a parseable DependencyReportJson on stdout, even when it exits +// non-zero because something required is missing. + +import { execFile, execFileSync } from 'node:child_process'; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const ROOT = join(import.meta.dirname, '..'); + +describe('codeman doctor --json', () => { + it('prints a report that includes Node and a summary, whatever the exit code', async () => { + const stdout = await new Promise((resolve, reject) => { + execFile( + process.execPath, + [ + join(ROOT, 'node_modules/tsx/dist/cli.mjs'), + join(ROOT, 'src/index.ts'), + 'doctor', + '--json', + '--category', + 'core', + ], + { timeout: 60_000, cwd: ROOT }, + (err, out) => (out ? resolve(out) : reject(err ?? new Error('no output'))) + ); + }); + const report = JSON.parse(stdout); + expect(report.platform.environment).toMatch(/linux|darwin|win32|wsl/); + expect(report.summary).toEqual(expect.objectContaining({ ok: expect.any(Number), exitCode: expect.any(Number) })); + const node = report.tools.find((t: { id: string }) => t.id === 'node'); + expect(node?.status).toBe('ok'); + expect(report.tools.every((t: { category: string }) => t.category === 'core')).toBe(true); + }, 90_000); + + // The report an operator got wrong in production: under systemd the PATH is minimal, so a CLI + // installed in ~/.local/bin read `missing` while the Run menu (which also searches the registry's + // searchDirs) found it. The PATH here holds nothing but `which`. + it('finds a CLI that lives only in a registry searchDirs entry when the PATH is minimal', async () => { + const home = mkdtempSync(join(tmpdir(), 'doctor-home-')); + const bare = mkdtempSync(join(tmpdir(), 'doctor-path-')); + try { + mkdirSync(join(home, '.local/bin'), { recursive: true }); + const fake = join(home, '.local/bin/claude'); + writeFileSync(fake, '#!/bin/sh\necho "2.1.0 (Claude Code)"\n'); + chmodSync(fake, 0o755); + symlinkSync(execFileSyncWhich(), join(bare, 'which')); + const stdout = await new Promise((resolve, reject) => { + execFile( + process.execPath, + [ + join(ROOT, 'node_modules/tsx/dist/cli.mjs'), + join(ROOT, 'src/index.ts'), + 'doctor', + '--json', + '--category', + 'core', + ], + { timeout: 60_000, cwd: ROOT, env: { ...process.env, HOME: home, PATH: bare } }, + (err, out) => (out ? resolve(out) : reject(err ?? new Error('no output'))) + ); + }); + const claude = JSON.parse(stdout).tools.find((t: { id: string }) => t.id === 'claude'); + expect(claude).toMatchObject({ status: 'ok', path: fake }); + } finally { + rmSync(home, { recursive: true, force: true }); + rmSync(bare, { recursive: true, force: true }); + } + }, 90_000); +}); + +function execFileSyncWhich(): string { + return execFileSync('sh', ['-c', 'command -v which'], { encoding: 'utf-8' }).trim(); +} diff --git a/test/doctor-settings.browser.test.ts b/test/doctor-settings.browser.test.ts new file mode 100644 index 000000000..e1dc8ff8d --- /dev/null +++ b/test/doctor-settings.browser.test.ts @@ -0,0 +1,108 @@ +/** @fileoverview Settings → System → Diagnostics in a real browser, with GET /api/doctor stubbed at the network layer. */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { chromium, type Browser, type Page } from 'playwright'; +import { WebServer } from '../src/web/server.js'; + +const PORT = 3196; + +const REPORT = { + platform: { environment: 'linux' }, + summary: { ok: 1, requiredMissing: 1, optionalMissing: 0, exitCode: 1 }, + tools: [ + { + id: 'node', + label: 'Node.js', + category: 'core', + required: true, + usedBy: [], + status: 'ok', + version: '22.1.0', + path: '/usr/bin/node', + }, + { + id: 'tmux', + label: 'tmux', + category: 'core', + required: true, + usedBy: [], + status: 'missing', + installHint: 'apt install tmux', + }, + // Host-supplied strings must be rendered as text, never as markup. + { + id: 'x', + label: '', + category: 'other', + required: false, + usedBy: [], + status: 'missing', + }, + ], +}; + +describe('Diagnostics panel in a real browser', () => { + let server: WebServer; + let browser: Browser; + let page: Page; + + beforeAll(async () => { + server = new WebServer(PORT, false, true); + await server.start(); + browser = await chromium.launch({ headless: true }); + // A controlling service worker can swallow requests before page.route() sees them, letting the + // real /api/doctor (a forked Node process) answer instead; block it so the stub is reliable. + page = await (await browser.newContext({ serviceWorkers: 'block' })).newPage(); + await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 }); + await page.evaluate(() => (window as any).app.openAppSettings()); + }, 90000); + + afterAll(async () => { + if (browser) await browser.close(); + if (server) await server.stop(); + }, 60000); + + it('lists each tool with status, version, path and install hint, and renders host strings as text', async () => { + await page.route('**/api/doctor', (route) => + route.fulfill({ contentType: 'application/json', body: JSON.stringify({ success: true, data: REPORT }) }) + ); + await page.click('#doctorRunBtn'); + await page.waitForFunction(() => /1 ok/.test(document.getElementById('doctorResult')?.textContent ?? '')); + const text = await page.textContent('#doctorResult'); + expect(text).toContain('1 ok · 1 required missing · 0 optional missing (linux)'); + expect(text).toContain('✓ Node.js ok · 22.1.0'); + expect(text).toContain('/usr/bin/node'); + expect(text).toContain('✗ tmux missing · required'); + expect(text).toContain('Install: apt install tmux'); + expect(text).toContain(''); // shown literally + expect(await page.evaluate(() => (window as any).__pwned)).toBeUndefined(); + expect(await page.$('#doctorResult img')).toBeNull(); + expect(await page.isDisabled('#doctorRunBtn')).toBe(false); + }); + + it('shows the server’s message when the check fails, and re-enables the button', async () => { + await page.unroute('**/api/doctor'); + await page.route('**/api/doctor', (route) => + route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ success: false, errorCode: 'OPERATION_FAILED', error: 'doctor failed: boom' }), + }) + ); + await page.click('#doctorRunBtn'); + await page.waitForFunction(() => /boom/.test(document.getElementById('doctorResult')?.textContent ?? '')); + expect(await page.isDisabled('#doctorRunBtn')).toBe(false); + }); + + it('hides the Diagnostics group from a non-admin in multi-user mode and shows it to an admin', async () => { + const visible = (user: Record) => + page.evaluate((u) => { + (window as any).__codemanUser = u; + document.dispatchEvent(new CustomEvent('codeman:me')); + return getComputedStyle(document.getElementById('doctorGroup')!).display !== 'none'; + }, user); + expect(await visible({ multiUser: true, role: 'user' })).toBe(false); + expect(await visible({ multiUser: true, role: 'admin' })).toBe(true); + expect(await visible({ multiUser: false })).toBe(true); + }); +}); diff --git a/test/routes/doctor-routes.test.ts b/test/routes/doctor-routes.test.ts new file mode 100644 index 000000000..1a6926d50 --- /dev/null +++ b/test/routes/doctor-routes.test.ts @@ -0,0 +1,144 @@ +/** + * @fileoverview GET /api/doctor: the `codeman doctor` report for Settings → System → Diagnostics. + * The route runs the probe out of process (the engine is synchronous), so every test injects the + * runner; the default runner's parsing is covered against a faked `execFile`, and the CLI contract + * it relies on is exercised for real in test/doctor-cli-json.test.ts. + * + * Port: N/A (app.inject()). + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createRouteTestHarness } from './_route-test-utils.js'; +import { defaultDoctorRunner, registerDoctorRoutes, type DoctorRunner } from '../../src/web/routes/doctor-routes.js'; +import type { DependencyReportJson } from '../../src/utils/dependency-report.js'; + +const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() })); +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, execFile: execFileMock }; +}); + +const REPORT: DependencyReportJson = { + platform: { environment: 'linux' }, + summary: { ok: 1, requiredMissing: 1, optionalMissing: 0, exitCode: 1 }, + tools: [ + { id: 'node', label: 'Node.js', category: 'core', required: true, usedBy: [], status: 'ok', version: '22.1.0' }, + { id: 'tmux', label: 'tmux', category: 'core', required: true, usedBy: [], status: 'missing' }, + ], +}; + +afterEach(() => { + delete process.env.CODEMAN_MULTIUSER; + execFileMock.mockReset(); +}); + +describe('GET /api/doctor', () => { + it('returns the runner’s report in the success envelope', async () => { + const runner = vi.fn(async () => REPORT); + const { app } = await createRouteTestHarness((a) => registerDoctorRoutes(a, runner)); + const res = await app.inject({ method: 'GET', url: '/api/doctor' }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ success: true, data: REPORT }); + expect(runner).toHaveBeenCalledWith(undefined); + }); + + it('passes a valid category through and rejects an unknown one without running anything', async () => { + const runner = vi.fn(async () => REPORT); + const { app } = await createRouteTestHarness((a) => registerDoctorRoutes(a, runner)); + expect((await app.inject({ method: 'GET', url: '/api/doctor?category=office' })).statusCode).toBe(200); + expect(runner).toHaveBeenLastCalledWith('office'); + runner.mockClear(); + const bad = await app.inject({ method: 'GET', url: '/api/doctor?category=%3Brm%20-rf' }); + expect(bad.statusCode).toBe(400); + expect(bad.json().errorCode).toBe('INVALID_INPUT'); + expect(runner).not.toHaveBeenCalled(); + }); + + it('answers 500 with a message when the runner fails', async () => { + const runner: DoctorRunner = async () => { + throw new Error('spawn blew up'); + }; + const { app } = await createRouteTestHarness((a) => registerDoctorRoutes(a, runner)); + const res = await app.inject({ method: 'GET', url: '/api/doctor' }); + expect(res.statusCode).toBe(500); + expect(res.json().error).toContain('spawn blew up'); + expect(res.json().errorCode).toBe('INTERNAL_ERROR'); + }); + + it('single-flights: concurrent requests for a category share one run, and a later one runs again', async () => { + const releases: Array<(r: DependencyReportJson) => void> = []; + const runner = vi.fn(() => new Promise((res) => releases.push(res))); + const { app } = await createRouteTestHarness((a) => registerDoctorRoutes(a, runner)); + const first = app.inject({ method: 'GET', url: '/api/doctor' }); + const second = app.inject({ method: 'GET', url: '/api/doctor' }); + const other = app.inject({ method: 'GET', url: '/api/doctor?category=office' }); + await vi.waitFor(() => expect(runner).toHaveBeenCalledTimes(2)); + releases[0](REPORT); + expect((await first).statusCode).toBe(200); + expect((await second).statusCode).toBe(200); + expect(runner).toHaveBeenCalledTimes(2); // unfiltered (shared) + office + releases[1](REPORT); + await other; + runner.mockImplementation(async () => REPORT); + await app.inject({ method: 'GET', url: '/api/doctor' }); + expect(runner).toHaveBeenCalledTimes(3); + }); + + it('multi-user: a non-admin is refused and nothing is probed', async () => { + process.env.CODEMAN_MULTIUSER = '1'; + const runner = vi.fn(async () => REPORT); + const { app } = await createRouteTestHarness((a) => registerDoctorRoutes(a, runner), { + authUser: { username: 'bob', role: 'user' }, + }); + const res = await app.inject({ method: 'GET', url: '/api/doctor' }); + expect(res.statusCode).toBe(403); + expect(runner).not.toHaveBeenCalled(); + }); + + it('multi-user: an admin is allowed', async () => { + process.env.CODEMAN_MULTIUSER = '1'; + const { app } = await createRouteTestHarness((a) => registerDoctorRoutes(a, async () => REPORT), { + authUser: { username: 'root', role: 'admin' }, + }); + expect((await app.inject({ method: 'GET', url: '/api/doctor' })).statusCode).toBe(200); + }); +}); + +describe('defaultDoctorRunner', () => { + type Done = (err: Error | null, stdout: string) => void; + const respond = (err: Error | null, stdout: string) => + execFileMock.mockImplementation((_bin: string, _args: string[], _opts: unknown, done: Done) => done(err, stdout)); + + it('runs `doctor --json` in a child of this same entry script, never in-process', async () => { + respond(null, JSON.stringify(REPORT)); + await defaultDoctorRunner('core'); + const [bin, args, opts] = execFileMock.mock.calls[0]; + expect(bin).toBe(process.execPath); + expect(args.slice(-4)).toEqual(['doctor', '--json', '--category', 'core']); + expect(args).toContain(process.argv[1]); + expect((opts as { timeout: number }).timeout).toBeGreaterThan(0); + }); + + it('treats a non-zero exit with a valid report as a normal result (a missing required tool exits 1)', async () => { + respond(Object.assign(new Error('exit 1'), { code: 1 }), JSON.stringify(REPORT)); + await expect(defaultDoctorRunner()).resolves.toEqual(REPORT); + }); + + it.each([ + ['empty output', ''], + ['non-JSON output', 'Segmentation fault'], + ['JSON of the wrong shape', '{"hello":"world"}'], + ])('rejects %s', async (_label, stdout) => { + respond(null, stdout); + await expect(defaultDoctorRunner()).rejects.toThrow(); + }); + + it('reports a killed child (the 30 s timeout) as a timeout, not the raw command line', async () => { + respond(Object.assign(new Error('Command failed: node doctor --json'), { killed: true, signal: 'SIGTERM' }), ''); + await expect(defaultDoctorRunner()).rejects.toThrow('timed out after 30 s'); + }); + + it('passes the child’s own error through when there is no report at all', async () => { + respond(new Error('ETIMEDOUT'), ''); + await expect(defaultDoctorRunner()).rejects.toThrow('ETIMEDOUT'); + }); +});