From d9174a7a03a93bb8038c24ff03384c7d4f56f9c4 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:05:55 +0800 Subject: [PATCH] feat(settings): codeman doctor in Settings -> System -> Diagnostics Co-Authored-By: Claude Sonnet 5.5 --- config/test-suites.ts | 1 + src/web/public/index.html | 14 ++++ src/web/public/settings-ui.js | 61 ++++++++++++++ src/web/routes/doctor-routes.ts | 85 +++++++++++++++++++ src/web/routes/index.ts | 1 + src/web/server.ts | 2 + test/doctor-cli-json.test.ts | 36 ++++++++ test/doctor-settings.browser.test.ts | 94 +++++++++++++++++++++ test/routes/doctor-routes.test.ts | 119 +++++++++++++++++++++++++++ 9 files changed, 413 insertions(+) create mode 100644 src/web/routes/doctor-routes.ts create mode 100644 test/doctor-cli-json.test.ts create mode 100644 test/doctor-settings.browser.test.ts create mode 100644 test/routes/doctor-routes.test.ts diff --git a/config/test-suites.ts b/config/test-suites.ts index 004af462..7566a063 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/src/web/public/index.html b/src/web/public/index.html index cc8f0337..b05076bf 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2829,6 +2829,20 @@

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 91efc5d3..2faf4ebc 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -1364,6 +1364,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; } diff --git a/src/web/routes/doctor-routes.ts b/src/web/routes/doctor-routes.ts new file mode 100644 index 00000000..611e2238 --- /dev/null +++ b/src/web/routes/doctor-routes.ts @@ -0,0 +1,85 @@ +/** + * @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) => { + 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 { + 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 runner(category) }; + } catch (err) { + reply.code(500); + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `doctor failed: ${getErrorMessage(err)}`); + } + } + ); +} diff --git a/src/web/routes/index.ts b/src/web/routes/index.ts index a8886041..97041b41 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 b9f40556..d1d3b98a 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/doctor-cli-json.test.ts b/test/doctor-cli-json.test.ts new file mode 100644 index 00000000..72d6aa78 --- /dev/null +++ b/test/doctor-cli-json.test.ts @@ -0,0 +1,36 @@ +// @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 } from 'node:child_process'; +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); +}); diff --git a/test/doctor-settings.browser.test.ts b/test/doctor-settings.browser.test.ts new file mode 100644 index 00000000..04974648 --- /dev/null +++ b/test/doctor-settings.browser.test.ts @@ -0,0 +1,94 @@ +/** @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 }); + page = await browser.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); + }); +}); diff --git a/test/routes/doctor-routes.test.ts b/test/routes/doctor-routes.test.ts new file mode 100644 index 00000000..af965c58 --- /dev/null +++ b/test/routes/doctor-routes.test.ts @@ -0,0 +1,119 @@ +/** + * @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'); + }); + + 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('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'); + }); +});