mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 22:49:41 +02:00
Add codeman doctor tool-dependency checker (COD-45)
Environment-aware dependency probe (linux|darwin|win32|wsl) with a static registry, an injectable ProbeHost seam for testing, grouped table + `--json` output, and a non-zero exit when a required dependency is missing/outdated. Node and tmux are the only hard-required tools; the agent CLIs and document converters (LibreOffice / MS Office via WSL interop) are optional. CI-safe unit tests (no tmux, injected host).
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* @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 { EXEC_TIMEOUT_MS } from '../config/exec-timeout.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;
|
||||
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 } = spec.resolver;
|
||||
for (const bin of bins) {
|
||||
const resolved = host.which(bin);
|
||||
if (resolved) {
|
||||
const out = host.runVersion(bin, [versionArg ?? '--version']);
|
||||
const version = out ? extractVersion(out, versionRegex) : undefined;
|
||||
return finalize(base, tool, resolved, 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));
|
||||
}
|
||||
|
||||
function safeWhich(bin: string): string | null {
|
||||
try {
|
||||
const out = execFileSync(process.platform === 'win32' ? 'where' : 'which', [bin], {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).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'],
|
||||
});
|
||||
} 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,
|
||||
}).trim();
|
||||
const out = execFileSync(
|
||||
'powershell.exe',
|
||||
['-NoProfile', '-Command', `(Get-Item '${windowsPath.replace(/'/g, "''")}').VersionInfo.ProductVersion`],
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||
).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,
|
||||
runVersion: safeRunVersion,
|
||||
windowsProgramRoots: listWindowsProgramRoots,
|
||||
windowsFileVersion: readWindowsFileVersion,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* @fileoverview Renders ToolResult[] from the dependency checker into a
|
||||
* human-readable grouped table or JSON, and computes the process exit code.
|
||||
* Plain text only (no color) so output is stable and snapshot-friendly; the
|
||||
* CLI layer may colorize.
|
||||
*
|
||||
* @module utils/dependency-report
|
||||
*/
|
||||
|
||||
import type { ProbeEnvironment, ToolCategory } from '../config/dependency-registry.js';
|
||||
import type { ToolResult, ToolStatus } from './dependency-checker.js';
|
||||
|
||||
const CATEGORY_ORDER: ToolCategory[] = ['core', 'office', 'other'];
|
||||
|
||||
function glyph(r: ToolResult): string {
|
||||
if (r.status === 'ok') return '✓';
|
||||
if (r.status === 'skipped') return '○';
|
||||
return r.required ? '✗' : '○';
|
||||
}
|
||||
|
||||
function statusText(r: ToolResult): string {
|
||||
if (r.status === 'ok') return r.version ?? 'installed';
|
||||
if (r.status === 'outdated') return `${r.version ?? '?'} (below minimum)`;
|
||||
if (r.status === 'skipped') return 'n/a';
|
||||
if (r.status === 'error') return 'version error';
|
||||
return 'not found';
|
||||
}
|
||||
|
||||
export function computeExitCode(results: ToolResult[]): number {
|
||||
const failed = results.some(
|
||||
(r) => r.required && (r.status === 'missing' || r.status === 'outdated' || r.status === 'error')
|
||||
);
|
||||
return failed ? 1 : 0;
|
||||
}
|
||||
|
||||
export function renderTable(results: ToolResult[], environment: ProbeEnvironment): string {
|
||||
const lines: string[] = [`Codeman dependency check — ${environment}`, ''];
|
||||
for (const category of CATEGORY_ORDER) {
|
||||
const rows = results.filter((r) => r.category === category);
|
||||
if (rows.length === 0) continue;
|
||||
lines.push(category.toUpperCase());
|
||||
for (const r of rows) {
|
||||
const detail = r.path ? ` ${r.path}` : '';
|
||||
lines.push(` ${glyph(r)} ${r.label.padEnd(14)} ${statusText(r).padEnd(22)}${detail}`);
|
||||
if (r.usedBy.length) lines.push(` used by: ${r.usedBy.join(', ')}`);
|
||||
if (r.installHint) lines.push(` install: ${r.installHint}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
const ok = results.filter((r) => r.status === 'ok').length;
|
||||
const requiredMissing = results.filter((r) => r.required && r.status !== 'ok' && r.status !== 'skipped').length;
|
||||
const optionalMissing = results.filter((r) => !r.required && r.status === 'missing').length;
|
||||
lines.push(`Summary: ${ok} ok · ${requiredMissing} required missing · ${optionalMissing} optional missing`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export interface DependencyReportJson {
|
||||
platform: { environment: ProbeEnvironment };
|
||||
summary: { ok: number; requiredMissing: number; optionalMissing: number; exitCode: number };
|
||||
tools: ToolResult[];
|
||||
}
|
||||
|
||||
export function renderJson(results: ToolResult[], environment: ProbeEnvironment): DependencyReportJson {
|
||||
const byStatus = (s: ToolStatus) => results.filter((r) => r.status === s).length;
|
||||
return {
|
||||
platform: { environment },
|
||||
summary: {
|
||||
ok: byStatus('ok'),
|
||||
requiredMissing: results.filter((r) => r.required && r.status !== 'ok' && r.status !== 'skipped').length,
|
||||
optionalMissing: results.filter((r) => !r.required && r.status === 'missing').length,
|
||||
exitCode: computeExitCode(results),
|
||||
},
|
||||
tools: results,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user