mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 07:29:42 +02:00
- 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>
244 lines
8.8 KiB
TypeScript
244 lines
8.8 KiB
TypeScript
/**
|
|
* @fileoverview Probe engine for `codeman doctor`. Resolves each registry tool
|
|
* against an injectable ProbeHost (real impl uses child_process/fs; tests inject
|
|
* fakes) and returns structured results. Pure given the host — no global I/O.
|
|
*
|
|
* @module utils/dependency-checker
|
|
*/
|
|
|
|
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 {
|
|
platform: NodeJS.Platform;
|
|
procVersion: string;
|
|
hasWindowsInterop: boolean;
|
|
}
|
|
|
|
export function detectEnvironment(inputs: EnvDetectionInputs): ProbeEnvironment {
|
|
if (inputs.platform === 'win32') return 'win32';
|
|
if (inputs.platform === 'darwin') return 'darwin';
|
|
const isWsl = /microsoft|wsl/i.test(inputs.procVersion) && inputs.hasWindowsInterop;
|
|
return isWsl ? 'wsl' : 'linux';
|
|
}
|
|
|
|
const DEFAULT_VERSION_RE = /(\d+\.\d+(?:\.\d+)?)/;
|
|
|
|
export function extractVersion(text: string, re?: RegExp): string | undefined {
|
|
const m = (re ?? DEFAULT_VERSION_RE).exec(text);
|
|
return m ? m[1] : undefined;
|
|
}
|
|
|
|
/** Returns -1 if a < b, 0 if equal, 1 if a > b (numeric, component-wise). */
|
|
export function compareVersions(a: string, b: string): number {
|
|
const pa = a.split('.').map((n) => parseInt(n, 10) || 0);
|
|
const pb = b.split('.').map((n) => parseInt(n, 10) || 0);
|
|
const len = Math.max(pa.length, pb.length);
|
|
for (let i = 0; i < len; i++) {
|
|
const d = (pa[i] || 0) - (pb[i] || 0);
|
|
if (d !== 0) return d < 0 ? -1 : 1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
export type ToolStatus = 'ok' | 'missing' | 'outdated' | 'skipped' | 'error';
|
|
|
|
export interface ToolResult {
|
|
id: string;
|
|
label: string;
|
|
category: ToolCategory;
|
|
required: boolean;
|
|
usedBy: string[];
|
|
status: ToolStatus;
|
|
version?: string;
|
|
path?: string;
|
|
installHint?: string;
|
|
reason?: string;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function finalize(
|
|
base: Pick<ToolResult, 'id' | 'label' | 'category' | 'required' | 'usedBy'>,
|
|
tool: ToolDependency,
|
|
path: string,
|
|
version: string | undefined
|
|
): ToolResult {
|
|
if (tool.minVersion) {
|
|
if (!version) return { ...base, status: 'error', path, reason: 'version required but could not be parsed' };
|
|
if (compareVersions(version, tool.minVersion) < 0) return { ...base, status: 'outdated', path, version };
|
|
}
|
|
return { ...base, status: 'ok', path, version };
|
|
}
|
|
|
|
export function checkTool(tool: ToolDependency, host: ProbeHost): ToolResult {
|
|
const base = {
|
|
id: tool.id,
|
|
label: tool.label,
|
|
category: tool.category,
|
|
required: tool.required,
|
|
usedBy: tool.usedBy ?? [],
|
|
};
|
|
const installHint = tool.installHint?.[host.environment];
|
|
|
|
const spec = tool.resolvers.find((r) => r.match.includes(host.environment));
|
|
if (!spec) return { ...base, status: 'skipped', reason: `not applicable on ${host.environment}` };
|
|
|
|
if (spec.resolver.kind === 'path') {
|
|
const { bins, versionArg, versionRegex, requireVersionMatch, searchDirs } = spec.resolver;
|
|
for (const bin of bins) {
|
|
// 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);
|
|
}
|
|
for (const candidate of candidates) {
|
|
// Run the RESOLVED path: a bare name would miss the same binary `which` just missed.
|
|
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). 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, candidate, version);
|
|
}
|
|
}
|
|
return { ...base, status: 'missing', installHint };
|
|
}
|
|
|
|
// windows-side
|
|
const { appDirs, exes } = spec.resolver;
|
|
for (const root of host.windowsProgramRoots()) {
|
|
for (const dir of appDirs) {
|
|
for (const exe of exes) {
|
|
const winPath = `${root}/${dir}/${exe}`;
|
|
if (host.fileExists(winPath)) {
|
|
const raw = host.windowsFileVersion(winPath);
|
|
const version = raw ? extractVersion(raw) : undefined;
|
|
return finalize(base, tool, winPath, version);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return { ...base, status: 'missing', installHint };
|
|
}
|
|
|
|
export function checkAll(registry: ToolDependency[], host: ProbeHost): ToolResult[] {
|
|
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;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function safeRunVersion(bin: string, args: string[]): string | null {
|
|
try {
|
|
return execFileSync(bin, args, {
|
|
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
|
|
const stdout = (err as { stdout?: Buffer | string })?.stdout;
|
|
return stdout ? stdout.toString() : null;
|
|
}
|
|
}
|
|
|
|
function readProcVersion(): string {
|
|
try {
|
|
return readFileSync('/proc/version', 'utf-8');
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function listWindowsProgramRoots(): string[] {
|
|
const roots: string[] = [];
|
|
try {
|
|
for (const entry of readdirSync('/mnt')) {
|
|
for (const pf of ['Program Files', 'Program Files (x86)']) {
|
|
const root = `/mnt/${entry}/${pf}`;
|
|
if (existsSync(root)) roots.push(root);
|
|
}
|
|
}
|
|
} catch {
|
|
// /mnt absent (not WSL) -> no roots
|
|
}
|
|
return roots;
|
|
}
|
|
|
|
function readWindowsFileVersion(winPath: string): string | null {
|
|
try {
|
|
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, killSignal: 'SIGKILL' }
|
|
).trim();
|
|
return out || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function createRealHost(): ProbeHost {
|
|
const environment = detectEnvironment({
|
|
platform: process.platform,
|
|
procVersion: readProcVersion(),
|
|
hasWindowsInterop: safeWhich('cmd.exe') !== null || safeWhich('powershell.exe') !== null,
|
|
});
|
|
return {
|
|
environment,
|
|
which: safeWhich,
|
|
fileExists: existsSync,
|
|
isExecutableFile: isExecutableRegularFile,
|
|
runVersion: safeRunVersion,
|
|
windowsProgramRoots: listWindowsProgramRoots,
|
|
windowsFileVersion: readWindowsFileVersion,
|
|
};
|
|
}
|