mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
Merge pull request #320 from Dylanwooo/feat/doctor-json-output
feat(cli): add --json output to doctor
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user