import { spawn } from 'node:child_process'; import fs from 'node:fs/promises'; import path from 'node:path'; import { DiagramSource, DiagramType, hasSourceEvidence, stripSourceEvidence } from './detect'; export interface RuntimeOptions { /** Absolute path to vendor/archify/bin/archify.mjs. */ cliPath: string; /** Node (or Electron) executable. */ nodePath: string; /** Set ELECTRON_RUN_AS_NODE so VS Code's Electron binary behaves as Node. */ runAsNode: boolean; /** Scratch directory for input/output files. */ workDir: string; quality?: string; repoRoot?: string; timeoutMs?: number; log?: (line: string) => void; } export interface ArchifyDiagnostic { code?: string; severity?: 'error' | 'warning' | 'info'; message: string; subject?: { path?: string; identity?: string; [key: string]: unknown }; supportedFixes?: string[]; } export interface ValidateResult { ok: boolean; diagnostics: ArchifyDiagnostic[]; /** Set when the CLI did not produce parseable JSON. */ crash?: string; } export interface RenderResult { ok: boolean; html?: string; error?: string; /** Set when the diagram rendered without its source evidence. */ evidenceNotice?: string; } interface RunResult { code: number | null; stdout: string; stderr: string; } const EVIDENCE_ERROR_RE = /repository-evidence\/|source evidence|--repo-root|Evidence (root|repository|revision)/i; function run(options: RuntimeOptions, args: string[], env: Record = {}): Promise { return new Promise((resolve, reject) => { options.log?.(`archify ${args.join(' ')}`); const child = spawn(options.nodePath, [options.cliPath, ...args], { cwd: options.workDir, env: { ...process.env, ...(options.runAsNode ? { ELECTRON_RUN_AS_NODE: '1' } : {}), ...env, }, windowsHide: true, }); let stdout = ''; let stderr = ''; child.stdout.setEncoding('utf8').on('data', (chunk: string) => (stdout += chunk)); child.stderr.setEncoding('utf8').on('data', (chunk: string) => (stderr += chunk)); const timer = setTimeout(() => child.kill(), options.timeoutMs ?? 60_000); child.on('error', (error) => { clearTimeout(timer); reject(error); }); child.on('close', (code) => { clearTimeout(timer); if (stderr.trim()) options.log?.(stderr.trim()); resolve({ code, stdout, stderr }); }); }); } function commonArgs(options: RuntimeOptions, withRepoRoot: boolean): string[] { const args: string[] = []; if (options.quality) args.push('--quality', options.quality); if (withRepoRoot && options.repoRoot) args.push('--repo-root', options.repoRoot); return args; } async function withScratch(options: RuntimeOptions, body: (dir: string) => Promise): Promise { await fs.mkdir(options.workDir, { recursive: true }); const dir = await fs.mkdtemp(path.join(options.workDir, 'run-')); try { return await body(dir); } finally { await fs.rm(dir, { recursive: true, force: true }); } } export async function validate(options: RuntimeOptions, type: DiagramType, text: string): Promise { return withScratch(options, async (dir) => { const input = path.join(dir, `diagram.${type}.json`); await fs.writeFile(input, text, 'utf8'); const result = await run(options, ['validate', type, input, '--json', ...commonArgs(options, true)]); try { const report = JSON.parse(result.stdout); const diagnostics: ArchifyDiagnostic[] = Array.isArray(report.diagnostics) ? report.diagnostics : []; if (!report.ok && diagnostics.length === 0 && report.error) { diagnostics.push({ severity: 'error', message: String(report.error) }); } return { ok: Boolean(report.ok), diagnostics }; } catch { const crash = (result.stderr || result.stdout || `archify exited with code ${result.code}`).trim(); return { ok: false, diagnostics: [], crash }; } }); } async function renderOnce( options: RuntimeOptions, type: DiagramType, text: string, withRepoRoot: boolean, ): Promise { return withScratch(options, async (dir) => { const input = path.join(dir, `diagram.${type}.json`); const output = path.join(dir, 'diagram.html'); await fs.writeFile(input, text, 'utf8'); const result = await run(options, ['render', type, input, output, ...commonArgs(options, withRepoRoot)]); if (result.code === 0) { try { return { ok: true, html: await fs.readFile(output, 'utf8') }; } catch { // fall through to the error below } } const error = (result.stderr || result.stdout || `archify exited with code ${result.code}`).trim(); return { ok: false, error }; }); } /** * Renders a diagram to HTML. With `evidence: 'fallback'`, a diagram whose * source evidence cannot be verified is rendered again without it, and the * reason is returned as `evidenceNotice`. */ export async function renderDiagram( options: RuntimeOptions, source: DiagramSource & { text: string }, evidence: 'fallback' | 'strict', ): Promise { const withEvidence = hasSourceEvidence(source.type, source.diagram); if (withEvidence && evidence === 'fallback' && !options.repoRoot) { const stripped = JSON.stringify(stripSourceEvidence(source.type, source.diagram), null, 2); const result = await renderOnce(options, source.type, stripped, false); return { ...result, evidenceNotice: 'Source links are hidden: the diagram is not inside a Git checkout, so its source evidence cannot be verified. Set "archify.repoRoot" to the matching checkout.', }; } const first = await renderOnce(options, source.type, source.text, withEvidence); if (first.ok || !withEvidence || evidence !== 'fallback' || !isEvidenceError(first.error)) { return first; } const stripped = JSON.stringify(stripSourceEvidence(source.type, source.diagram), null, 2); const second = await renderOnce(options, source.type, stripped, false); if (!second.ok) return first; const reason = (first.error ?? '').split('\n').find((line) => line.trim()) ?? 'verification failed'; return { ...second, evidenceNotice: `Source links are hidden: ${reason.replace(/^\[[^\]]+\]\s*/, '')}` }; } /** * Renders to a file the user chose. Source evidence is verified strictly * unless `stripEvidence` is set, in which case it is removed first. */ export async function renderToFile( options: RuntimeOptions, source: DiagramSource & { text: string }, outputPath: string, stripEvidence = false, ): Promise { const text = stripEvidence ? JSON.stringify(stripSourceEvidence(source.type, source.diagram), null, 2) : source.text; const withEvidence = !stripEvidence && hasSourceEvidence(source.type, source.diagram); return withScratch(options, async (dir) => { const input = path.join(dir, `diagram.${source.type}.json`); await fs.writeFile(input, text, 'utf8'); const result = await run(options, ['render', source.type, input, outputPath, ...commonArgs(options, withEvidence)]); if (result.code === 0) return { ok: true }; return { ok: false, error: (result.stderr || result.stdout || `archify exited with code ${result.code}`).trim() }; }); } export function isEvidenceError(error: string | undefined): boolean { return EVIDENCE_ERROR_RE.test(error ?? ''); }