diff --git a/src/cli.ts b/src/cli.ts index b209567a..6f249a3e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -626,4 +626,29 @@ program } }); +program + .command('doctor') + .alias('check-deps') + .description('Check Codeman tool dependencies (Node, Claude CLI, tmux, LibreOffice, MS Office)') + .option('--json', 'Output structured JSON instead of a table') + .option('--category ', 'Only check one category (core|office|documents|media|other)') + .action(async (options) => { + const { createRealHost, checkAll } = await import('./utils/dependency-checker.js'); + const { renderTable, renderJson, computeExitCode } = await import('./utils/dependency-report.js'); + const { DEPENDENCY_REGISTRY } = await import('./config/dependency-registry.js'); + + const host = createRealHost(); + const registry = options.category + ? DEPENDENCY_REGISTRY.filter((t) => t.category === options.category) + : DEPENDENCY_REGISTRY; + const results = checkAll(registry, host); + + if (options.json) { + console.log(JSON.stringify(renderJson(results, host.environment), null, 2)); + } else { + console.log(renderTable(results, host.environment)); + } + process.exit(computeExitCode(results)); + }); + export { program }; diff --git a/src/config/dependency-registry.ts b/src/config/dependency-registry.ts new file mode 100644 index 00000000..2e4a6220 --- /dev/null +++ b/src/config/dependency-registry.ts @@ -0,0 +1,128 @@ +/** + * @fileoverview Static registry of downstream tool dependencies probed by + * `codeman doctor`. Each entry declares per-environment resolvers and the + * skills that use it. EXTENSION POINT: skill-manifest-driven discovery + * (COD follow-up) will merge dynamically-found entries into this list. + * + * @module config/dependency-registry + */ + +export type ProbeEnvironment = 'linux' | 'darwin' | 'win32' | 'wsl'; +export type ToolCategory = 'core' | 'office' | 'other'; + +/** Resolve a binary on the PATH and read its version. */ +export interface PathResolver { + kind: 'path'; + bins: string[]; + versionArg?: string; // default '--version' + versionRegex?: RegExp; // default matches first \d+.\d+(.\d+)? +} + +/** Resolve a Windows-installed app reachable from win32 or WSL. */ +export interface WindowsSideResolver { + kind: 'windows-side'; + appDirs: string[]; // relative to a Program Files root + exes: string[]; // candidate executables; first found wins +} + +export interface ResolverSpec { + match: ProbeEnvironment[]; + resolver: PathResolver | WindowsSideResolver; +} + +export interface ToolDependency { + id: string; + label: string; + category: ToolCategory; + required: boolean; + usedBy?: string[]; + minVersion?: string; + resolvers: ResolverSpec[]; + installHint?: Partial>; +} + +const ALL: ProbeEnvironment[] = ['linux', 'darwin', 'wsl', 'win32']; + +export const DEPENDENCY_REGISTRY: ToolDependency[] = [ + { + id: 'node', + label: 'Node.js', + category: 'core', + required: true, + minVersion: '18.0.0', + resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['node'], versionArg: '--version' } }], + installHint: { linux: 'https://nodejs.org', darwin: 'brew install node', wsl: 'https://nodejs.org' }, + }, + { + id: 'claude', + label: 'Claude CLI', + category: 'core', + required: false, + usedBy: ['Claude Code sessions (default backend)'], + resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['claude'], versionArg: '--version' } }], + installHint: { linux: 'https://docs.claude.com/claude-code', darwin: 'https://docs.claude.com/claude-code' }, + }, + { + id: 'tmux', + label: 'tmux', + category: 'core', + required: true, + resolvers: [{ match: ['linux', 'darwin', 'wsl'], resolver: { kind: 'path', bins: ['tmux'], versionArg: '-V' } }], + installHint: { linux: 'sudo apt install tmux', darwin: 'brew install tmux', wsl: 'sudo apt install tmux' }, + }, + { + id: 'opencode', + label: 'OpenCode CLI', + category: 'core', + required: false, + usedBy: ['OpenCode sessions'], + resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['opencode'], versionArg: '--version' } }], + }, + { + id: 'codex', + label: 'Codex CLI', + category: 'core', + required: false, + usedBy: ['Codex sessions'], + resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['codex'], versionArg: '--version' } }], + }, + { + id: 'gemini', + label: 'Gemini CLI', + category: 'core', + required: false, + usedBy: ['Gemini sessions'], + resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['gemini'], versionArg: '--version' } }], + }, + { + id: 'libreoffice', + label: 'LibreOffice', + category: 'office', + required: false, + usedBy: ['document preview', 'thumbnails'], + resolvers: [ + { + match: ['linux', 'darwin', 'wsl'], + resolver: { kind: 'path', bins: ['libreoffice', 'soffice'], versionArg: '--version' }, + }, + ], + installHint: { linux: 'sudo apt install libreoffice', darwin: 'brew install --cask libreoffice' }, + }, + { + id: 'msoffice', + label: 'MS Office', + category: 'office', + required: false, + usedBy: ['document preview', 'thumbnails'], + resolvers: [ + { + match: ['wsl', 'win32'], + resolver: { + kind: 'windows-side', + appDirs: ['Microsoft Office/root/Office16'], + exes: ['WINWORD.EXE', 'POWERPNT.EXE', 'EXCEL.EXE'], + }, + }, + ], + }, +]; diff --git a/src/utils/dependency-checker.ts b/src/utils/dependency-checker.ts new file mode 100644 index 00000000..beada7a9 --- /dev/null +++ b/src/utils/dependency-checker.ts @@ -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, + 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, + }; +} diff --git a/src/utils/dependency-report.ts b/src/utils/dependency-report.ts new file mode 100644 index 00000000..0737322f --- /dev/null +++ b/src/utils/dependency-report.ts @@ -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, + }; +} diff --git a/test/dependency-checker.test.ts b/test/dependency-checker.test.ts new file mode 100644 index 00000000..cc82966c --- /dev/null +++ b/test/dependency-checker.test.ts @@ -0,0 +1,181 @@ +import { describe, it, expect } from 'vitest'; +import { DEPENDENCY_REGISTRY } from '../src/config/dependency-registry.js'; +import { + detectEnvironment, + extractVersion, + compareVersions, + checkTool, + checkAll, + createRealHost, +} from '../src/utils/dependency-checker.js'; +import type { ProbeHost } from '../src/utils/dependency-checker.js'; +import type { ProbeEnvironment, ToolDependency } from '../src/config/dependency-registry.js'; + +describe('DEPENDENCY_REGISTRY', () => { + it('has unique ids', () => { + const ids = DEPENDENCY_REGISTRY.map((t) => t.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it('hard-requires only node and tmux; agent CLIs and office are optional', () => { + const required = DEPENDENCY_REGISTRY.filter((t) => t.required) + .map((t) => t.id) + .sort(); + expect(required).toEqual(['node', 'tmux']); + // all four agent CLIs are optional (Codeman runs any of them) + const agentClis = ['claude', 'opencode', 'codex', 'gemini']; + expect(DEPENDENCY_REGISTRY.filter((t) => agentClis.includes(t.id)).every((t) => t.required === false)).toBe(true); + const office = DEPENDENCY_REGISTRY.filter((t) => t.category === 'office'); + expect(office.every((t) => t.required === false)).toBe(true); + }); + + it('gives msoffice a windows-side resolver scoped to wsl + win32 only', () => { + const ms = DEPENDENCY_REGISTRY.find((t) => t.id === 'msoffice'); + expect(ms).toBeDefined(); + const spec = ms!.resolvers.find((r) => r.resolver.kind === 'windows-side'); + expect(spec).toBeDefined(); + expect([...spec!.match].sort()).toEqual(['win32', 'wsl']); + expect(ms!.resolvers.some((r) => r.match.includes('linux'))).toBe(false); + }); +}); + +describe('detectEnvironment', () => { + it('returns win32/darwin straight from platform', () => { + expect(detectEnvironment({ platform: 'win32', procVersion: '', hasWindowsInterop: false })).toBe('win32'); + expect(detectEnvironment({ platform: 'darwin', procVersion: '', hasWindowsInterop: false })).toBe('darwin'); + }); + + it('detects wsl from /proc/version + interop, else linux', () => { + const wsl = detectEnvironment({ + platform: 'linux', + procVersion: 'Linux version 6.6 (Microsoft@WSL2)', + hasWindowsInterop: true, + }); + expect(wsl).toBe('wsl'); + expect(detectEnvironment({ platform: 'linux', procVersion: 'Microsoft', hasWindowsInterop: false })).toBe('linux'); + expect(detectEnvironment({ platform: 'linux', procVersion: 'generic', hasWindowsInterop: true })).toBe('linux'); + }); +}); + +describe('extractVersion', () => { + it('pulls a dotted version from typical --version output', () => { + expect(extractVersion('v22.22.1')).toBe('22.22.1'); + expect(extractVersion('tmux 3.4')).toBe('3.4'); + expect(extractVersion('no digits here')).toBeUndefined(); + }); + it('honors a custom regex', () => { + expect(extractVersion('ProductVersion 16.0.19929.20172', /(\d+\.\d+\.\d+)/)).toBe('16.0.19929'); + }); +}); + +describe('compareVersions', () => { + it('orders by numeric components', () => { + expect(compareVersions('18.0.0', '18.0.0')).toBe(0); + expect(compareVersions('16.5.0', '18.0.0')).toBe(-1); + expect(compareVersions('22.22.1', '18.0.0')).toBe(1); + expect(compareVersions('3.4', '3.4.0')).toBe(0); + }); +}); + +function fakeHost(env: ProbeEnvironment, over: Partial = {}): ProbeHost { + return { + environment: env, + which: () => null, + fileExists: () => false, + runVersion: () => null, + windowsProgramRoots: () => [], + windowsFileVersion: () => null, + ...over, + }; +} + +const tmuxTool: ToolDependency = { + id: 'tmux', + label: 'tmux', + category: 'core', + required: true, + resolvers: [{ match: ['linux', 'wsl'], resolver: { kind: 'path', bins: ['tmux'], versionArg: '-V' } }], +}; +const nodeTool: ToolDependency = { + id: 'node', + label: 'Node.js', + category: 'core', + required: true, + minVersion: '18.0.0', + resolvers: [{ match: ['linux'], resolver: { kind: 'path', bins: ['node'] } }], +}; +const msTool: ToolDependency = { + id: 'msoffice', + label: 'MS Office', + category: 'office', + required: false, + resolvers: [ + { + match: ['wsl', 'win32'], + resolver: { kind: 'windows-side', appDirs: ['Microsoft Office/root/Office16'], exes: ['WINWORD.EXE'] }, + }, + ], +}; + +describe('checkTool', () => { + it('reports ok with path + version when found on PATH', () => { + const host = fakeHost('linux', { + which: (b) => (b === 'tmux' ? '/usr/bin/tmux' : null), + runVersion: () => 'tmux 3.4', + }); + expect(checkTool(tmuxTool, host)).toMatchObject({ + id: 'tmux', + status: 'ok', + version: '3.4', + path: '/usr/bin/tmux', + }); + }); + + it('reports missing when no bin resolves', () => { + expect(checkTool(tmuxTool, fakeHost('linux'))).toMatchObject({ id: 'tmux', status: 'missing' }); + }); + + it('reports outdated when below minVersion', () => { + const host = fakeHost('linux', { which: () => '/n', runVersion: () => 'v16.5.0' }); + expect(checkTool(nodeTool, host)).toMatchObject({ id: 'node', status: 'outdated', version: '16.5.0' }); + }); + + it('reports error when minVersion set but version unparseable', () => { + const host = fakeHost('linux', { which: () => '/n', runVersion: () => 'unknown' }); + expect(checkTool(nodeTool, host)).toMatchObject({ id: 'node', status: 'error' }); + }); + + it('reports skipped when no resolver matches the environment', () => { + expect(checkTool(msTool, fakeHost('linux'))).toMatchObject({ id: 'msoffice', status: 'skipped' }); + }); + + it('finds windows-side apps under WSL', () => { + const host = fakeHost('wsl', { + windowsProgramRoots: () => ['/mnt/c/Program Files'], + fileExists: (p) => p === '/mnt/c/Program Files/Microsoft Office/root/Office16/WINWORD.EXE', + windowsFileVersion: () => '16.0.19929.20172', + }); + expect(checkTool(msTool, host)).toMatchObject({ + id: 'msoffice', + status: 'ok', + version: '16.0.19929', + path: '/mnt/c/Program Files/Microsoft Office/root/Office16/WINWORD.EXE', + }); + }); +}); + +describe('checkAll', () => { + it('maps every tool to a result', () => { + const results = checkAll([tmuxTool, msTool], fakeHost('linux')); + expect(results.map((r) => r.id)).toEqual(['tmux', 'msoffice']); + }); +}); + +describe('createRealHost', () => { + it('returns a host with a valid detected environment and callable methods', () => { + const host = createRealHost(); + expect(['linux', 'darwin', 'win32', 'wsl']).toContain(host.environment); + expect(typeof host.which).toBe('function'); + expect(Array.isArray(host.windowsProgramRoots())).toBe(true); + }); +}); diff --git a/test/dependency-report.test.ts b/test/dependency-report.test.ts new file mode 100644 index 00000000..813a9497 --- /dev/null +++ b/test/dependency-report.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest'; +import { renderTable, renderJson, computeExitCode } from '../src/utils/dependency-report.js'; +import type { ToolResult } from '../src/utils/dependency-checker.js'; + +const results: ToolResult[] = [ + { + id: 'node', + label: 'Node.js', + category: 'core', + required: true, + usedBy: [], + status: 'ok', + version: '22.22.1', + path: '/n', + }, + { + id: 'tmux', + label: 'tmux', + category: 'core', + required: true, + usedBy: [], + status: 'missing', + installHint: 'sudo apt install tmux', + }, + { + id: 'libreoffice', + label: 'LibreOffice', + category: 'office', + required: false, + usedBy: ['document preview', 'thumbnails'], + status: 'missing', + }, + { + id: 'msoffice', + label: 'MS Office', + category: 'office', + required: false, + usedBy: ['document preview', 'thumbnails'], + status: 'skipped', + reason: 'not applicable on linux', + }, +]; + +describe('computeExitCode', () => { + it('non-zero when a required tool is missing/outdated/error', () => { + expect(computeExitCode(results)).toBe(1); + }); + it('zero when only optional tools are missing', () => { + const ok = results.filter((r) => r.id !== 'tmux'); + expect(computeExitCode(ok)).toBe(0); + }); +}); + +describe('renderTable', () => { + it('groups by category and shows status, version, and install hints', () => { + const out = renderTable(results, 'linux'); + expect(out).toContain('CORE'); + expect(out).toContain('Node.js'); + expect(out).toContain('22.22.1'); + expect(out).toContain('OFFICE'); + expect(out).toContain('document preview'); + expect(out).toContain('sudo apt install tmux'); + }); +}); + +describe('renderJson', () => { + it('includes environment, summary, and per-tool data', () => { + const json = renderJson(results, 'linux'); + expect(json.platform.environment).toBe('linux'); + expect(json.summary.exitCode).toBe(1); + expect(json.summary.ok).toBe(1); + expect(json.tools).toHaveLength(4); + }); +});