Merge pull request #320 from Dylanwooo/feat/doctor-json-output

feat(cli): add --json output to doctor
This commit is contained in:
James Russo
2026-05-06 17:21:33 -07:00
committed by GitHub
3 changed files with 242 additions and 18 deletions
+141
View File
@@ -0,0 +1,141 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { buildDoctorReport, redactHome, type CheckOutcome } from "./doctor.js";
// ── Fixtures ────────────────────────────────────────────────────────────────
const OUTCOMES_ALL_OK: CheckOutcome[] = [
{ name: "Version", ok: true, detail: "0.4.4 (latest)" },
{ name: "Node.js", ok: true, detail: "v22.0.0 (darwin arm64)" },
{ name: "FFmpeg", ok: true, detail: "ffmpeg version 8.1" },
];
const OUTCOMES_WITH_FAILURE: CheckOutcome[] = [
{ name: "Version", ok: true, detail: "0.4.4 (latest)" },
{
name: "Docker",
ok: false,
detail: "Not found",
hint: "https://docs.docker.com/get-docker/",
},
];
describe("redactHome", () => {
const originalHome = process.env["HOME"];
const originalUserProfile = process.env["USERPROFILE"];
afterEach(() => {
if (originalHome !== undefined) process.env["HOME"] = originalHome;
else delete process.env["HOME"];
if (originalUserProfile !== undefined) process.env["USERPROFILE"] = originalUserProfile;
else delete process.env["USERPROFILE"];
});
it("replaces HOME paths with the literal $HOME", () => {
process.env["HOME"] = "/Users/alice";
delete process.env["USERPROFILE"];
expect(redactHome("system: /Users/alice/Library/Caches/chrome")).toBe(
"system: $HOME/Library/Caches/chrome",
);
});
it("replaces all occurrences, not just the first", () => {
process.env["HOME"] = "/home/bob";
delete process.env["USERPROFILE"];
expect(redactHome("/home/bob/a and /home/bob/b")).toBe("$HOME/a and $HOME/b");
});
it("falls back to USERPROFILE when HOME is unset (Windows)", () => {
delete process.env["HOME"];
process.env["USERPROFILE"] = "C:\\Users\\carol";
expect(redactHome("C:\\Users\\carol\\AppData")).toBe("$HOME\\AppData");
});
it("is a no-op when neither HOME nor USERPROFILE is set", () => {
delete process.env["HOME"];
delete process.env["USERPROFILE"];
expect(redactHome("/Users/someone/path")).toBe("/Users/someone/path");
});
it("leaves strings without HOME unchanged", () => {
process.env["HOME"] = "/Users/alice";
expect(redactHome("brew install ffmpeg")).toBe("brew install ffmpeg");
});
});
describe("buildDoctorReport", () => {
it("emits the locked schema shape", () => {
const report = buildDoctorReport(OUTCOMES_ALL_OK);
expect(report).toMatchObject({
ok: expect.any(Boolean),
platform: expect.any(String),
arch: expect.any(String),
checks: expect.any(Array),
_meta: expect.objectContaining({
version: expect.any(String),
updateAvailable: expect.any(Boolean),
}),
});
// Top-level keys are exactly these — any accidental addition or rename
// should force an explicit update to this test + PR review.
expect(Object.keys(report).sort()).toEqual(["_meta", "arch", "checks", "ok", "platform"]);
});
it("reports ok=true when all checks pass", () => {
expect(buildDoctorReport(OUTCOMES_ALL_OK).ok).toBe(true);
});
it("reports ok=false when any check fails", () => {
expect(buildDoctorReport(OUTCOMES_WITH_FAILURE).ok).toBe(false);
});
it("preserves check order exactly as provided", () => {
const report = buildDoctorReport(OUTCOMES_ALL_OK);
expect(report.checks.map((c) => c.name)).toEqual(["Version", "Node.js", "FFmpeg"]);
});
it("omits hint when not provided (doesn't emit hint:undefined)", () => {
const report = buildDoctorReport([{ name: "X", ok: true, detail: "fine" }]);
expect(report.checks[0]).toEqual({ name: "X", ok: true, detail: "fine" });
expect("hint" in report.checks[0]!).toBe(false);
});
it("preserves hint when provided", () => {
const report = buildDoctorReport(OUTCOMES_WITH_FAILURE);
const docker = report.checks.find((c) => c.name === "Docker");
expect(docker?.hint).toBe("https://docs.docker.com/get-docker/");
});
describe("redact option", () => {
const originalHome = process.env["HOME"];
beforeEach(() => {
process.env["HOME"] = "/Users/alice";
});
afterEach(() => {
if (originalHome !== undefined) process.env["HOME"] = originalHome;
else delete process.env["HOME"];
});
it("redacts HOME in detail and hint when redact=true", () => {
const outcomes: CheckOutcome[] = [
{
name: "Chrome",
ok: false,
detail: "system: /Users/alice/Applications/Chrome",
hint: "Try /Users/alice/bin/chrome",
},
];
const report = buildDoctorReport(outcomes, { redact: true });
expect(report.checks[0]?.detail).toBe("system: $HOME/Applications/Chrome");
expect(report.checks[0]?.hint).toBe("Try $HOME/bin/chrome");
});
it("leaves HOME alone when redact is off (default)", () => {
const outcomes: CheckOutcome[] = [
{ name: "Chrome", ok: true, detail: "/Users/alice/Chrome" },
];
expect(buildDoctorReport(outcomes).checks[0]?.detail).toBe("/Users/alice/Chrome");
});
});
});
+89 -18
View File
@@ -1,16 +1,19 @@
import { defineCommand } from "citty";
import type { Example } from "./_examples.js";
import { execSync } from "node:child_process";
export const examples: Example[] = [["Check system dependencies", "hyperframes doctor"]];
import { freemem, platform } from "node:os";
import type { Example } from "./_examples.js";
import { c } from "../ui/colors.js";
import { findBrowser } from "../browser/manager.js";
import { findFFmpeg, getFFmpegInstallHint } from "../browser/ffmpeg.js";
import { VERSION } from "../version.js";
import { getUpdateMeta } from "../utils/updateCheck.js";
import { getUpdateMeta, withMeta } from "../utils/updateCheck.js";
import { getSystemMeta, getShmSizeMb, getFreeDiskMb, bytesToMb } from "../telemetry/system.js";
export const examples: Example[] = [
["Check system dependencies", "hyperframes doctor"],
["Output as JSON for CI / agents", "hyperframes doctor --json"],
];
interface Check {
name: string;
run: () => CheckResult | Promise<CheckResult>;
@@ -181,14 +184,59 @@ function checkEnvironment(): CheckResult {
return { ok: true, detail: parts.join(" \u00B7 ") };
}
export interface CheckOutcome {
name: string;
ok: boolean;
detail: string;
hint?: string;
}
/**
* Replace the user's home directory path with the literal string `$HOME` so
* JSON output pasted into bug reports or agent contexts doesn't leak usernames.
* Safe no-op when HOME/USERPROFILE is unset.
*/
export function redactHome(s: string): string {
const home = process.env["HOME"] || process.env["USERPROFILE"];
if (!home) return s;
return s.split(home).join("$HOME");
}
function redactOutcome(o: CheckOutcome): CheckOutcome {
return {
name: o.name,
ok: o.ok,
detail: redactHome(o.detail),
...(o.hint ? { hint: redactHome(o.hint) } : {}),
};
}
/**
* Build the JSON report payload from raw check outcomes. Pure function so the
* output schema can be locked down with a snapshot test — any future refactor
* that renames fields, drops `hint`, or reorders `checks[]` will fail that
* test before it reaches users or agents parsing the output.
*
* @param options.redact - when true, replaces HOME paths in `detail`/`hint`
* with the literal `$HOME`. Default off so tests can assert on raw values;
* the CLI turns it on for `--json` output.
*/
export function buildDoctorReport(outcomes: CheckOutcome[], options: { redact?: boolean } = {}) {
const checks = options.redact ? outcomes.map(redactOutcome) : outcomes;
return withMeta({
ok: checks.every((o) => o.ok),
platform: process.platform,
arch: process.arch,
checks,
});
}
export default defineCommand({
meta: { name: "doctor", description: "Check system dependencies and environment" },
args: {},
async run() {
console.log();
console.log(c.bold("hyperframes doctor"));
console.log();
args: {
json: { type: "boolean", description: "Output as JSON", default: false },
},
async run({ args }) {
const checks: Check[] = [
{ name: "Version", run: checkVersion },
{ name: "Node.js", run: checkNode },
@@ -211,19 +259,42 @@ export default defineCommand({
{ name: "Docker running", run: checkDockerRunning },
);
let allOk = true;
const outcomes: CheckOutcome[] = [];
for (const check of checks) {
const result = await check.run();
const icon = result.ok ? c.success("\u2713") : c.error("\u2717");
const name = check.name.padEnd(16);
outcomes.push({
name: check.name,
ok: result.ok,
detail: result.detail,
...(result.hint ? { hint: result.hint } : {}),
});
}
const allOk = outcomes.every((o) => o.ok);
if (args.json) {
// Exit code intentionally reflects command success, not environment
// health — `checkVersion` returns ok:false when an npm update is
// available, which would poison any CI pipeline doing
// `hyperframes doctor --json || fail` the next time a new version is
// published. Consumers who want a gate can do:
// hyperframes doctor --json | jq -e '.ok' > /dev/null || handle_failure
console.log(JSON.stringify(buildDoctorReport(outcomes, { redact: true }), null, 2));
return;
}
console.log();
console.log(c.bold("hyperframes doctor"));
console.log();
for (const outcome of outcomes) {
const icon = outcome.ok ? c.success("\u2713") : c.error("\u2717");
const name = outcome.name.padEnd(16);
console.log(
` ${icon} ${c.bold(name)} ${result.ok ? c.dim(result.detail) : c.error(result.detail)}`,
` ${icon} ${c.bold(name)} ${outcome.ok ? c.dim(outcome.detail) : c.error(outcome.detail)}`,
);
if (!result.ok && result.hint) {
console.log(` ${" ".repeat(19)}${c.accent(result.hint)}`);
if (!outcome.ok && outcome.hint) {
console.log(` ${" ".repeat(19)}${c.accent(outcome.hint)}`);
}
if (!result.ok) allOk = false;
}
console.log();