mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 15:39:41 +02:00
fix(doctor): Diagnostics landing fixes (#536)
- The doctor now judges candidates like the run mode's resolver: the PATH hit, then each search dir, each one version-checked on its own and skipped on a mismatch (a wrong `pi`/`grok` on the PATH no longer hides the real one in a search dir). A search-dir candidate must be an absolute path to an executable regular file, so a relative dir or a file without the x bit reads as missing, as it does in the Run menu. `isExecutableRegularFile` is exported from cli-executable-resolver.ts and reused rather than copied. - Every doctor probe passes killSignal: 'SIGKILL'; a --version that ignores SIGTERM held the probe for its full runtime (15 s vs 5 s measured with a TERM-trapping script). - README no longer claims parity with the Run menu or nvm prefixes. - The Diagnostics panel marks a missing optional tool with ○, a missing required one with ✗, as the terminal doctor does. - expandSearchDir names its twin, expandHome() in cli-resolver.ts. - test/doctor-cli-json.test.ts is hermetic: temp HOME, a PATH of only `which` and `node`, and a clis.json that drops the registry's absolute search dirs, so it never runs the machine's installed agent CLIs. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,76 +2,108 @@
|
||||
// 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.
|
||||
//
|
||||
// Hermetic: the doctor runs `--version` on every CLI it finds, and a suite must never execute
|
||||
// whatever happens to be installed on the machine running it (cli-executable-resolver.ts
|
||||
// @fileoverview). Each run gets a temp HOME and a PATH holding only `which` and `node`, and a
|
||||
// clis.json in that HOME's data dir drops the registry's absolute search dirs
|
||||
// (`/usr/local/bin`), so the only CLI the doctor can find is a fixture this file wrote.
|
||||
|
||||
import { execFile, execFileSync } from 'node:child_process';
|
||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { isAbsolute, join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
|
||||
|
||||
const ROOT = join(import.meta.dirname, '..');
|
||||
|
||||
interface DoctorRun {
|
||||
report: { tools: Array<{ id: string; status: string; path?: string; category: string }> } & Record<string, any>;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
function hermeticDoctorEnv(): { home: string; bare: string; env: NodeJS.ProcessEnv; cleanup: () => void } {
|
||||
const home = mkdtempSync(join(tmpdir(), 'doctor-home-'));
|
||||
const bare = mkdtempSync(join(tmpdir(), 'doctor-path-'));
|
||||
symlinkSync(execFileSync('sh', ['-c', 'command -v which'], { encoding: 'utf-8' }).trim(), join(bare, 'which'));
|
||||
symlinkSync(process.execPath, join(bare, 'node'));
|
||||
// Overrides deep-merge by id and arrays replace wholesale, so this keeps every stock entry
|
||||
// and only narrows its search dirs to the `~` ones, which resolve inside the temp HOME.
|
||||
const clis = Object.fromEntries(
|
||||
STOCK_CLIS.map((e) => [e.id, { discovery: { searchDirs: e.discovery.searchDirs.filter((d) => !isAbsolute(d)) } }])
|
||||
);
|
||||
mkdirSync(join(home, '.codeman'), { recursive: true });
|
||||
// 0600 or the registry ignores the file (isUnsafePermissions).
|
||||
writeFileSync(join(home, '.codeman', 'clis.json'), JSON.stringify({ schemaVersion: 1, clis }), { mode: 0o600 });
|
||||
const env: NodeJS.ProcessEnv = { ...process.env, HOME: home, PATH: bare };
|
||||
delete env.CODEMAN_DATA_DIR;
|
||||
delete env.CODEMAN_INSTANCE;
|
||||
return {
|
||||
home,
|
||||
bare,
|
||||
env,
|
||||
cleanup: () => {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
rmSync(bare, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function runDoctor(env: NodeJS.ProcessEnv): Promise<DoctorRun> {
|
||||
return 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, env },
|
||||
(err, out, stderr) => (out ? resolve({ report: JSON.parse(out), stderr }) : reject(err ?? new Error('no output')))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
const h = hermeticDoctorEnv();
|
||||
try {
|
||||
const { report, stderr } = await runDoctor(h.env);
|
||||
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) => t.id === 'node');
|
||||
expect(node?.status).toBe('ok');
|
||||
expect(report.tools.every((t) => t.category === 'core')).toBe(true);
|
||||
// The override was accepted (an ignored or invalid clis.json warns on stderr), and nothing
|
||||
// the doctor found, and so ran, lives outside this test's own temp dirs.
|
||||
expect(stderr).not.toContain('[cli-registry]');
|
||||
for (const t of report.tools.filter((t) => t.path)) {
|
||||
expect(t.path!.startsWith(h.bare) || t.path!.startsWith(h.home)).toBe(true);
|
||||
}
|
||||
} finally {
|
||||
h.cleanup();
|
||||
}
|
||||
}, 90_000);
|
||||
|
||||
// The report an operator got wrong in production: under systemd the PATH is minimal, so a CLI
|
||||
// installed in ~/.local/bin read `missing` while the Run menu (which also searches the registry's
|
||||
// searchDirs) found it. The PATH here holds nothing but `which`.
|
||||
// searchDirs) found it.
|
||||
it('finds a CLI that lives only in a registry searchDirs entry when the PATH is minimal', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'doctor-home-'));
|
||||
const bare = mkdtempSync(join(tmpdir(), 'doctor-path-'));
|
||||
const h = hermeticDoctorEnv();
|
||||
try {
|
||||
mkdirSync(join(home, '.local/bin'), { recursive: true });
|
||||
const fake = join(home, '.local/bin/claude');
|
||||
mkdirSync(join(h.home, '.local/bin'), { recursive: true });
|
||||
const fake = join(h.home, '.local/bin/claude');
|
||||
writeFileSync(fake, '#!/bin/sh\necho "2.1.0 (Claude Code)"\n');
|
||||
chmodSync(fake, 0o755);
|
||||
symlinkSync(execFileSyncWhich(), join(bare, 'which'));
|
||||
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, env: { ...process.env, HOME: home, PATH: bare } },
|
||||
(err, out) => (out ? resolve(out) : reject(err ?? new Error('no output')))
|
||||
);
|
||||
});
|
||||
const claude = JSON.parse(stdout).tools.find((t: { id: string }) => t.id === 'claude');
|
||||
const { report } = await runDoctor(h.env);
|
||||
const claude = report.tools.find((t) => t.id === 'claude');
|
||||
expect(claude).toMatchObject({ status: 'ok', path: fake });
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
rmSync(bare, { recursive: true, force: true });
|
||||
h.cleanup();
|
||||
}
|
||||
}, 90_000);
|
||||
});
|
||||
|
||||
function execFileSyncWhich(): string {
|
||||
return execFileSync('sh', ['-c', 'command -v which'], { encoding: 'utf-8' }).trim();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user