mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 15:39:41 +02:00
feat(settings): codeman doctor in Settings -> System -> Diagnostics
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
ffaa5ee80c
commit
d9174a7a03
@@ -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',
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user