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
+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);