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:
@@ -41,7 +41,11 @@ export interface PathResolver {
|
||||
searchDirs?: string[];
|
||||
}
|
||||
|
||||
/** Expand a leading `~` (the only form registry `searchDirs` use). */
|
||||
/**
|
||||
* Expand a leading `~` (the only form registry `searchDirs` use). Twin of `expandHome()` in
|
||||
* src/utils/cli-resolver.ts, copied rather than imported because importing it from config/
|
||||
* would pull in the whole resolver chain; keep the two in step.
|
||||
*/
|
||||
function expandSearchDir(dir: string): string {
|
||||
if (dir === '~') return homedir();
|
||||
if (dir.startsWith('~/')) return join(homedir(), dir.slice(2));
|
||||
|
||||
@@ -122,7 +122,8 @@ export interface ProductionCliResolverHostOptions {
|
||||
allowRealIoUnderVitest?: boolean;
|
||||
}
|
||||
|
||||
function isExecutableRegularFile(path: string): boolean {
|
||||
/** An executable regular file. Exported for `codeman doctor`, which must judge a candidate the same way. */
|
||||
export function isExecutableRegularFile(path: string): boolean {
|
||||
try {
|
||||
if (!statSync(path).isFile()) return false;
|
||||
accessSync(path, constants.X_OK);
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import { isAbsolute, join } from 'node:path';
|
||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||
import { isExecutableRegularFile } from './cli-executable-resolver.js';
|
||||
import type { ProbeEnvironment, ToolCategory, ToolDependency } from '../config/dependency-registry.js';
|
||||
|
||||
export interface EnvDetectionInputs {
|
||||
@@ -62,6 +64,8 @@ export interface ProbeHost {
|
||||
environment: ProbeEnvironment;
|
||||
which(bin: string): string | null;
|
||||
fileExists(path: string): boolean;
|
||||
/** An executable regular file, the run mode's own test for a `searchDirs` candidate. */
|
||||
isExecutableFile(path: string): boolean;
|
||||
runVersion(bin: string, args: string[]): string | null;
|
||||
windowsProgramRoots(): string[];
|
||||
windowsFileVersion(winPath: string): string | null;
|
||||
@@ -96,28 +100,31 @@ export function checkTool(tool: ToolDependency, host: ProbeHost): ToolResult {
|
||||
if (spec.resolver.kind === 'path') {
|
||||
const { bins, versionArg, versionRegex, requireVersionMatch, searchDirs } = spec.resolver;
|
||||
for (const bin of bins) {
|
||||
// `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;
|
||||
}
|
||||
}
|
||||
// The same candidate order and the same per-candidate test as the run mode's resolver
|
||||
// (createCliExecutableResolver): the `which` hit (the PATH), then each search dir. Under
|
||||
// a service the PATH is minimal and the run mode finds the CLI through those dirs, so
|
||||
// the doctor must too. A search-dir candidate counts only as an absolute path to an
|
||||
// executable regular file, so a relative dir from a custom clis.json or a file without
|
||||
// the x bit reads as missing here exactly as it does in the Run menu.
|
||||
const candidates: string[] = [];
|
||||
const onPath = host.which(bin);
|
||||
if (onPath && isAbsolute(onPath)) candidates.push(onPath);
|
||||
for (const dir of searchDirs ?? []) {
|
||||
const candidate = join(dir, bin);
|
||||
if (candidates.includes(candidate)) continue; // a search dir that is also on the PATH
|
||||
if (isAbsolute(candidate) && host.isExecutableFile(candidate)) candidates.push(candidate);
|
||||
}
|
||||
if (resolved) {
|
||||
for (const candidate of candidates) {
|
||||
// Run the RESOLVED path: a bare name would miss the same binary `which` just missed.
|
||||
const out = host.runVersion(resolved, [versionArg ?? '--version']);
|
||||
const out = host.runVersion(candidate, [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
|
||||
// alternative is claiming a tool is installed that the feature's own resolver
|
||||
// rejects, which reads as "the mode is broken" rather than "install it".
|
||||
// PathResolver.requireVersionMatch). Try the next candidate, then report MISSING;
|
||||
// the alternative is claiming a tool is installed that the feature's own resolver
|
||||
// rejects, or missing one it accepts (an npm squatter on the PATH in front of the
|
||||
// real grok in ~/.grok/bin), which reads as "the mode is broken".
|
||||
if (requireVersionMatch && !version) continue;
|
||||
return finalize(base, tool, resolved, version);
|
||||
return finalize(base, tool, candidate, version);
|
||||
}
|
||||
}
|
||||
return { ...base, status: 'missing', installHint };
|
||||
@@ -144,11 +151,16 @@ export function checkAll(registry: ToolDependency[], host: ProbeHost): ToolResul
|
||||
return registry.map((tool) => checkTool(tool, host));
|
||||
}
|
||||
|
||||
// SIGKILL on every probe below: execFileSync's `timeout` only SENDS the kill signal and then
|
||||
// keeps waiting for the child, so a `--version` that ignores the default SIGTERM would hold
|
||||
// the doctor (now a Settings button) until GET /api/doctor's own timeout, then be orphaned.
|
||||
// Same reasoning as the resolver host in cli-executable-resolver.ts.
|
||||
function safeWhich(bin: string): string | null {
|
||||
try {
|
||||
const out = execFileSync(process.platform === 'win32' ? 'where' : 'which', [bin], {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
killSignal: 'SIGKILL',
|
||||
}).trim();
|
||||
const first = out.split(/\r?\n/)[0]?.trim();
|
||||
return first && existsSync(first) ? first : null;
|
||||
@@ -163,6 +175,7 @@ function safeRunVersion(bin: string, args: string[]): string | null {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
killSignal: 'SIGKILL',
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
// Some tools (e.g. ffmpeg) exit non-zero on -version but still print to stdout
|
||||
@@ -199,11 +212,12 @@ function readWindowsFileVersion(winPath: string): string | null {
|
||||
const windowsPath = execFileSync('wslpath', ['-w', winPath], {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
killSignal: 'SIGKILL',
|
||||
}).trim();
|
||||
const out = execFileSync(
|
||||
'powershell.exe',
|
||||
['-NoProfile', '-Command', `(Get-Item '${windowsPath.replace(/'/g, "''")}').VersionInfo.ProductVersion`],
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS, killSignal: 'SIGKILL' }
|
||||
).trim();
|
||||
return out || null;
|
||||
} catch {
|
||||
@@ -221,6 +235,7 @@ export function createRealHost(): ProbeHost {
|
||||
environment,
|
||||
which: safeWhich,
|
||||
fileExists: existsSync,
|
||||
isExecutableFile: isExecutableRegularFile,
|
||||
runVersion: safeRunVersion,
|
||||
windowsProgramRoots: listWindowsProgramRoots,
|
||||
windowsFileVersion: readWindowsFileVersion,
|
||||
|
||||
@@ -1409,7 +1409,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
for (const t of tools) {
|
||||
const li = document.createElement('li');
|
||||
const strong = document.createElement('b');
|
||||
strong.textContent = `${glyph[t.status] || '?'} ${t.label}`;
|
||||
// As the terminal doctor marks it: a missing OPTIONAL tool is ○, only a required one ✗.
|
||||
const mark = t.status === 'missing' && !t.required ? '○' : glyph[t.status] || '?';
|
||||
strong.textContent = `${mark} ${t.label}`;
|
||||
li.append(strong);
|
||||
const bits = [t.status];
|
||||
if (t.version) bits.push(t.version);
|
||||
|
||||
Reference in New Issue
Block a user