feat(settings): codeman doctor in Settings -> System -> Diagnostics

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Devvyn
2026-10-05 07:03:40 +08:00
co-authored by Claude Sonnet 5.5
parent ffaa5ee80c
commit d9174a7a03
9 changed files with 413 additions and 0 deletions
+1
View File
@@ -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',
+14
View File
@@ -2829,6 +2829,20 @@
</div>
<p class="set-section-blurb">Paths, automation and remote access. Set once, rarely touched.</p>
<div class="set-group" id="doctorGroup">
<div class="set-group-head"><h4>Diagnostics</h4><span class="set-scope">server</span></div>
<div class="set-group-body">
<div class="set-row" data-search="diagnostics doctor dependencies tmux node claude codex check install">
<div class="set-row-text">
<span class="set-row-label">Check this machine</span>
<span class="set-row-desc">Runs <code>codeman doctor</code> on the server: which agent CLIs, tmux, Node and the optional office tools are installed, their versions, and how to install what is missing.</span>
</div>
<button class="btn-toolbar btn-sm" id="doctorRunBtn" onclick="app.runDoctor()">Run checks</button>
</div>
<div id="doctorResult" class="set-note" style="display:none" data-i18n-skip></div>
</div>
</div>
<div class="set-group">
<div class="set-group-head"><h4>Paths</h4><span class="set-scope">synced</span></div>
<div class="set-group-body">
+61
View File
@@ -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; }
+85
View File
@@ -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` + `<bin> --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<DependencyReportJson>;
const DOCTOR_TIMEOUT_MS = 30_000;
function isReport(v: unknown): v is DependencyReportJson {
const r = v as Partial<DependencyReportJson> | 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<ApiResponse<DependencyReportJson>> => {
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)}`);
}
}
);
}
+1
View File
@@ -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,
+2
View File
@@ -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);
+36
View File
@@ -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<string>((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);
});
+94
View File
@@ -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: '<img src=x onerror=window.__pwned=1>',
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('<img src=x onerror=window.__pwned=1>'); // 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);
});
});
+119
View File
@@ -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<typeof import('node:child_process')>();
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<DoctorRunner>(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<DoctorRunner>(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<DoctorRunner>(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');
});
});