/** * Doctor core — shared diagnostic primitives used by both the Electron app * (`packages/electron/src/lib/doctor.ts`) and the dashboard server route * (`packages/server/src/routes/doctor-routes.ts`). * * Hosts the canonical type system, section taxonomy, suggestion mapping, * fault-tolerance helpers (`safeCheck` / `safeExec` / `assumedMandatory`), * a shared `runSharedChecks` for non-Electron checks, and the Markdown * report formatter. * * See change: doctor-rich-output (proposal.md, design.md). */ import { execSync } from "./platform/exec.js"; import { existsSync, readFileSync, statSync, renameSync, appendFileSync, rmSync } from "node:fs"; import path from "node:path"; // ─── Types ───────────────────────────────────────────────────────────── export type DoctorSection = "runtime" | "pi-tooling" | "server" | "setup" | "diagnostics"; export type DoctorStatus = "ok" | "warning" | "error"; export type ExecFailureKind = | "not-found" | "permission-denied" | "timeout" | "non-zero-exit" | "unknown"; export interface DoctorCheck { name: string; status: DoctorStatus; section: DoctorSection; message: string; detail?: string; suggestion?: string; fixable?: boolean; /** Populated when the check ran an external command and it failed. */ kind?: ExecFailureKind; } export interface DoctorReport { checks: DoctorCheck[]; summary: { ok: number; warnings: number; errors: number }; generatedAt?: number; } // ─── stripAnsi ───────────────────────────────────────────────────────── /** * Strip standard ANSI CSI / OSC escape sequences. No external dependency. */ export function stripAnsi(input: string): string { if (!input) return ""; // CSI sequences: ESC [ ... letter (incl. SGR colors, cursor moves) // OSC sequences: ESC ] ... BEL or ESC \ // Plus a few standalone escapes (ESC = ESC + char like ESC ( B). // eslint-disable-next-line no-control-regex const csi = /\u001b\[[0-?]*[ -/]*[@-~]/g; // eslint-disable-next-line no-control-regex const osc = /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g; // eslint-disable-next-line no-control-regex const single = /\u001b[@-Z\\-_]/g; return input.replace(csi, "").replace(osc, "").replace(single, ""); } // ─── safeExec ────────────────────────────────────────────────────────── export interface SafeExecOk { ok: true; stdout: string; } export interface SafeExecErr { ok: false; kind: ExecFailureKind; message: string; detail: string; exitCode?: number; stderrTail?: string; /** Whatever timeoutMs was used for the call (ms). */ timeoutMs: number; } export type SafeExecResult = SafeExecOk | SafeExecErr; export interface SafeExecOpts { timeoutMs?: number; env?: NodeJS.ProcessEnv; cwd?: string; } /** * Run a command via `execSync`, classify failures, and capture a stderr tail. * * Defaults: 5000 ms timeout, `windowsHide: true`. Cold-start probes (bundled * Node, server-launch test) pass `timeoutMs: 15000`. */ export function safeExec(cmd: string, opts: SafeExecOpts = {}): SafeExecResult { const timeoutMs = opts.timeoutMs ?? 5000; try { const stdout = execSync(cmd, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], timeout: timeoutMs, env: opts.env, cwd: opts.cwd, }); return { ok: true, stdout: stdout.toString() }; } catch (err) { return classifyExecError(err, cmd, timeoutMs); } } function classifyExecError(err: unknown, cmd: string, timeoutMs: number): SafeExecErr { const e = err as NodeJS.ErrnoException & { status?: number; signal?: NodeJS.Signals | null; stdout?: Buffer | string; stderr?: Buffer | string; }; const stderrRaw = e.stderr ? e.stderr.toString() : ""; const stderrTail = stripAnsi(stderrRaw).slice(-500); const stdoutRaw = e.stdout ? e.stdout.toString() : ""; const code = e.code ?? ""; const errno = (e as { errno?: number }).errno; const status = e.status; const signal = e.signal; const baseMsg = e.message || String(err); // ENOENT — binary not found / file missing if (code === "ENOENT") { return { ok: false, kind: "not-found", message: "Command not found", detail: `${cmd}\n${baseMsg}`, stderrTail: stderrTail || undefined, timeoutMs, }; } // EACCES / EPERM — permission denied if (code === "EACCES" || code === "EPERM") { return { ok: false, kind: "permission-denied", message: "Permission denied", detail: `${cmd}\n${baseMsg}`, stderrTail: stderrTail || undefined, timeoutMs, }; } // Timeout — execSync throws ETIMEDOUT (errno -2 on linux, signal SIGTERM, code "ETIMEDOUT") if ( code === "ETIMEDOUT" || signal === "SIGTERM" || errno === -2 || /timed?\s*out/i.test(baseMsg) ) { return { ok: false, kind: "timeout", message: `Command did not respond within ${Math.round(timeoutMs / 1000)}s`, detail: `${cmd}\nDeadline: ${timeoutMs}ms`, stderrTail: stderrTail || undefined, timeoutMs, }; } // Non-zero exit if (typeof status === "number" && status !== 0) { return { ok: false, kind: "non-zero-exit", message: `Command exited with status ${status}`, detail: `${cmd}${stdoutRaw ? `\nstdout: ${stripAnsi(stdoutRaw).slice(-200)}` : ""}`, exitCode: status, stderrTail: stderrTail || undefined, timeoutMs, }; } // Unknown return { ok: false, kind: "unknown", message: "Command failed", detail: `${cmd}\n${baseMsg}`, stderrTail: stderrTail || undefined, timeoutMs, }; } // ─── safeCheck ───────────────────────────────────────────────────────── /** * Per-check fault-isolation wrapper. Catches any throw / rejection from * `fn` and returns a `diagnostics`-section error row that carries a * non-empty `message` / `detail` / `suggestion`. Never propagates. */ export async function safeCheck( name: string, section: DoctorSection, fn: () => DoctorCheck | Promise, ): Promise { try { const result = await fn(); // If caller forgot to set section, default it. if (!result.section) result.section = section; return result; } catch (err) { const e = err instanceof Error ? err : new Error(String(err)); const stack = (e.stack || "").split("\n").slice(0, 4).join("\n"); return { name, section, status: "error", message: "Check failed to run", detail: `${e.message}\n${stack}`, suggestion: "This is a doctor-internal failure. Please file an issue with the Markdown export attached.", }; } } // ─── assumedMandatory ───────────────────────────────────────────────── export interface AssumedDeps { /** Managed install dir. `/doctor.log` is the log path. */ managedDir: string; } const DOCTOR_LOG_MAX_BYTES = 1 * 1024 * 1024; // 1 MB /** * Wrap a "should-never-fail" operation. On throw: * 1. Append a JSON line to `/doctor.log` (with prior ring rotation if >1MB). * 2. Return a diagnostics-section error row labelled "Doctor internal: