mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 15:39:41 +02:00
Merge pull request #536 from opticon454/feat/doctor-in-settings
feat(settings): codeman doctor in Settings → System → Diagnostics # Conflicts: # config/test-suites.ts
This commit is contained in:
@@ -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 (`<dir>/<bin>`) 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),
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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?.();
|
||||
});
|
||||
|
||||
@@ -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` + `<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) => {
|
||||
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<string, Promise<DependencyReportJson>>();
|
||||
const runShared = (category?: string): Promise<DependencyReportJson> => {
|
||||
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<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 runShared(category) };
|
||||
} catch (err) {
|
||||
reply.code(500);
|
||||
return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, `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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user