diff --git a/package.json b/package.json index b9d79b71b5..f67c25008a 100644 --- a/package.json +++ b/package.json @@ -26,12 +26,13 @@ "sync-schemas:check": "tsx scripts/sync-schemas.ts --check", "sync:package-subpaths": "node scripts/package-subpaths.mjs --write", "check:package-subpaths": "node scripts/package-subpaths.mjs", - "lint": "bun run check:tracked-artifacts && bun run check:workspace-contracts && bun run check:package-cycles && bun run check:package-subpaths && oxlint . && tsx scripts/lint-skills.ts", + "lint": "bun run check:tracked-artifacts && bun run check:workspace-contracts && bun run check:package-cycles && bun run check:package-subpaths && bun run check:cli-process-ownership && oxlint . && tsx scripts/lint-skills.ts", "lint:skills": "tsx scripts/lint-skills.ts", "lint:fix": "oxlint --fix .", "check:tracked-artifacts": "node scripts/check-tracked-artifacts.mjs", "check:workspace-contracts": "node scripts/check-workspace-contracts.mjs", "check:package-cycles": "node scripts/check-package-cycles.mjs", + "check:cli-process-ownership": "node scripts/check-cli-process-ownership.mjs", "format": "oxfmt .", "test": "bun run test:unit", "test:unit": "bun run --filter '*' test", @@ -44,7 +45,7 @@ "player:perf": "bun run --filter @hyperframes/player perf", "format:check": "oxfmt --check .", "knip": "knip", - "test:scripts": "node --import tsx --test scripts/check-tracked-artifacts.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-package-cycles.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs", + "test:scripts": "node --import tsx --test scripts/check-tracked-artifacts.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs", "test:skills": "node --test 'skills/**/*.test.mjs'", "generate:previews": "tsx scripts/generate-template-previews.ts", "generate:catalog-previews": "tsx scripts/generate-catalog-previews.ts", diff --git a/packages/cli/src/auth/oauth.ts b/packages/cli/src/auth/oauth.ts index 33e739915b..6cb4a36487 100644 --- a/packages/cli/src/auth/oauth.ts +++ b/packages/cli/src/auth/oauth.ts @@ -1,3 +1,4 @@ +import { failCommand } from "../utils/commandResult.js"; /** * OAuth 2.0 + PKCE driver for the HeyGen public OAuth flow. * @@ -115,7 +116,7 @@ export function assertOAuthConfiguredOrExit(): void { if (isAuthError(err) && err.code === "OAUTH_NOT_CONFIGURED") { console.error(`Error: ${err.message}`); if (err.hint) console.error(err.hint); - process.exit(1); + failCommand(); } throw err; } diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index a640bf2185..0e468565c4 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -99,10 +99,19 @@ try { // Telemetry, update checks, and heavy modules are imported only when needed. // For --help we skip telemetry entirely. -import { defineCommand, runMain } from "citty"; +import { defineCommand, runCommand } from "citty"; import type { ArgsDef, CommandDef } from "citty"; import { getRunId } from "./telemetry/runId.js"; import { reportCommandFailure, trackCommandFailures } from "./utils/command-failure-tracking.js"; +import { resolveCommandUsage } from "./utils/commandUsageResolution.js"; +import { + CliResultSignal, + CliRuntimeError, + CliUsageError, + consumeCommandResult, + registerRootExitRequester, + type CommandResult, +} from "./utils/commandResult.js"; const isHelp = process.argv.includes("--help") || process.argv.includes("-h"); @@ -151,14 +160,10 @@ const commandLoaders = { figma: () => import("./commands/figma.js").then((m) => m.default), }; -// Wrap each command's run() so a thrown failure reports its reason to telemetry -// before citty catches the error and exits 1. The error is re-thrown unchanged, -// preserving citty's print + exit-1 behavior. Commands that call process.exit() -// themselves (e.g. `browser path`) bypass this and report inline. const subCommands = Object.fromEntries( Object.entries(commandLoaders).map(([name, load]) => [ name, - trackCommandFailures(load, (err) => reportCommandFailure(command, err)), + trackCommandFailures(load, (error) => reportCommandFailure(command, error)), ]), ); @@ -207,12 +212,13 @@ let _trackCommandResult: | undefined; let _printUpdateNotice: (() => void) | undefined; let _printSkillsUpdateNotice: (() => void) | undefined; +let telemetryReady: Promise = Promise.resolve(); // `events` is a telemetry-internal beacon: it self-tracks + self-flushes, so it // skips the per-command wrapper (no duplicate cli_command, no first-run notice // printed into a skill's captured output). if (!isHelp && command !== "telemetry" && command !== "events" && command !== "unknown") { - import("./telemetry/index.js").then((mod) => { + telemetryReady = import("./telemetry/index.js").then((mod) => { _flush = mod.flush; _flushSync = mod.flushSync; _trackCliError = mod.trackCliError; @@ -262,33 +268,55 @@ if ( const commandStart = Date.now(); const runId = getRunId(); +let finalized = false; -// Async flush for normal exit. `beforeExit` re-fires every time the -// event loop drains, and the async `_flush()` itself schedules new -// work — so a plain `on` listener would print the update notice (and -// re-flush) once per drain (the user-reported double-print). `once` -// detaches after first invocation, which is what we want for both. -process.once("beforeExit", () => { - _flush?.().catch(() => {}); +// fallow-ignore-next-line complexity +async function finalizeCli(result: CommandResult): Promise { + if (finalized) return; + finalized = true; + commandFailed ||= result.exitCode !== 0; + await telemetryReady.catch(() => {}); + _trackCommandResult?.({ + command, + success: result.exitCode === 0 && !commandFailed, + exitCode: result.exitCode, + durationMs: Date.now() - commandStart, + runId, + }); + await _flush?.().catch(() => {}); if (!hasJsonFlag) { _printUpdateNotice?.(); _printSkillsUpdateNotice?.(); } + process.exitCode = result.exitCode; +} + +registerRootExitRequester((exitCode) => { + void finalizeCli({ + exitCode, + kind: exitCode === 0 ? "success" : "runtime_error", + presented: true, + }).finally(() => process.exit(exitCode)); }); // Sync-only: exit handlers cannot await promises or drain microtasks. // _trackCommandResult / _trackCliError are captured references resolved // at init time, so they're callable synchronously here. -process.on("exit", (code) => { - _trackCommandResult?.({ - command, - success: code === 0 && !commandFailed, - exitCode: code, - durationMs: Date.now() - commandStart, - runId, - }); - _flushSync?.(); -}); +process.on( + "exit", + // fallow-ignore-next-line complexity + (code) => { + if (finalized) return; + _trackCommandResult?.({ + command, + success: code === 0 && !commandFailed, + exitCode: code, + durationMs: Date.now() - commandStart, + runId, + }); + _flushSync?.(); + }, +); process.on("uncaughtException", (error) => { if ((error as NodeJS.ErrnoException).code === "EPIPE") { @@ -312,6 +340,7 @@ process.on("uncaughtException", (error) => { // The exit handler above will still fire with the real exit code. process.on("unhandledRejection", (reason) => { commandFailed = true; + process.exitCode = 1; const error = reason instanceof Error ? reason : new Error(String(reason)); _trackCliError?.({ error_name: error.name, @@ -331,4 +360,39 @@ async function showUsage( return impl(cmd as CommandDef, parent as CommandDef | undefined); } -runMain(main, { showUsage }); +async function showRequestedUsage(): Promise { + const requested = await resolveCommandUsage(main as CommandDef, argv); + return showUsage(requested.command, requested.parent); +} + +function commandResultForError(error: unknown): CommandResult { + if (error instanceof CliResultSignal) return error.result; + if (error instanceof CliUsageError || error instanceof CliRuntimeError) return error.result; + return { exitCode: 1, kind: "runtime_error" }; +} + +// fallow-ignore-next-line complexity +async function executeCli(): Promise { + let result: CommandResult = { exitCode: 0, kind: "success" }; + try { + if (isHelp) await showRequestedUsage(); + else await runCommand(main, { rawArgs: argv }); + } catch (error) { + result = commandResultForError(error); + if (!(error instanceof CliResultSignal)) { + commandFailed = true; + reportCommandFailure(command, error); + const typed = error instanceof CliUsageError || error instanceof CliRuntimeError; + if (error instanceof CliUsageError && !error.result.presented) await showRequestedUsage(); + if (!typed || !error.result.presented) { + console.error(error instanceof Error ? error.message : String(error)); + } + } + } finally { + const pending = consumeCommandResult(); + if (pending.exitCode !== 0 || result.exitCode === 0) result = pending; + await finalizeCli(result); + } +} + +await executeCli(); diff --git a/packages/cli/src/cloud/errors.test.ts b/packages/cli/src/cloud/errors.test.ts index bb3cd381c0..7fe3441073 100644 --- a/packages/cli/src/cloud/errors.test.ts +++ b/packages/cli/src/cloud/errors.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; // errorBox writes to the console; mock it so we can assert which title / // message / third line the cascade picked without matching ANSI output. @@ -10,30 +10,19 @@ vi.mock("../ui/format.js", async (importOriginal) => ({ import { errorBox } from "../ui/format.js"; import { HyperframesApiError } from "./_gen/client.js"; import { reportApiError } from "./errors.js"; +import { CliRuntimeError } from "../utils/commandResult.js"; describe("cloud/errors reportApiError", () => { - // process.exit has signature `(code?) => never` which doesn't unify with - // vi.spyOn's inference; cast through `unknown` like cloud/parsing.test.ts. - let exitSpy: { mockRestore: () => void } & { mock: { calls: unknown[][] } }; - beforeEach(() => { - exitSpy = vi.spyOn(process, "exit").mockImplementation((() => { - throw new Error("process.exit called"); - }) as unknown as (code?: string | number | null) => never) as unknown as typeof exitSpy; vi.mocked(errorBox).mockClear(); }); - afterEach(() => { - exitSpy.mockRestore(); - }); - it("short-circuits a 404 to a Not found box when notFound is provided", () => { const err = new HyperframesApiError({ status: 404, message: "missing" }); expect(() => reportApiError("Get failed", err, { notFound: "render hfr_x no longer exists" }), - ).toThrow("process.exit called"); + ).toThrow(CliRuntimeError); expect(errorBox).toHaveBeenCalledWith("Not found", "render hfr_x no longer exists"); - expect(exitSpy).toHaveBeenCalledWith(1); }); it("uses the code-specific hint when the error code is known", () => { @@ -42,7 +31,7 @@ describe("cloud/errors reportApiError", () => { message: "slow down", code: "rate_limit_exceeded", }); - expect(() => reportApiError("Submit failed", err)).toThrow("process.exit called"); + expect(() => reportApiError("Submit failed", err)).toThrow(CliRuntimeError); expect(errorBox).toHaveBeenCalledWith( "Submit failed (HTTP 429)", "slow down", @@ -57,7 +46,7 @@ describe("cloud/errors reportApiError", () => { code: "invalid_parameter", }); expect(() => reportApiError("Submit failed", err, { suggestion: "see --help" })).toThrow( - "process.exit called", + CliRuntimeError, ); expect(errorBox).toHaveBeenCalledWith( "Submit failed (HTTP 400)", @@ -73,7 +62,7 @@ describe("cloud/errors reportApiError", () => { code: "some_unmapped_code", }); expect(() => reportApiError("List failed", err, { suggestion: "retry shortly" })).toThrow( - "process.exit called", + CliRuntimeError, ); expect(errorBox).toHaveBeenCalledWith("List failed (HTTP 500)", "boom", "retry shortly"); }); @@ -84,7 +73,7 @@ describe("cloud/errors reportApiError", () => { message: "boom", code: "some_unmapped_code", }); - expect(() => reportApiError("List failed", err)).toThrow("process.exit called"); + expect(() => reportApiError("List failed", err)).toThrow(CliRuntimeError); expect(errorBox).toHaveBeenCalledWith( "List failed (HTTP 500)", "boom", @@ -94,7 +83,7 @@ describe("cloud/errors reportApiError", () => { it("omits the third line when there is no code, hint, or suggestion", () => { const err = new HyperframesApiError({ status: 503, message: "unavailable" }); - expect(() => reportApiError("Get failed", err)).toThrow("process.exit called"); + expect(() => reportApiError("Get failed", err)).toThrow(CliRuntimeError); expect(errorBox).toHaveBeenCalledWith("Get failed (HTTP 503)", "unavailable"); }); @@ -102,7 +91,7 @@ describe("cloud/errors reportApiError", () => { const err = new HyperframesApiError({ status: 418, message: "teapot", code: "custom_code" }); expect(() => reportApiError("Brew failed", err, { extraHints: { custom_code: "use coffee instead" } }), - ).toThrow("process.exit called"); + ).toThrow(CliRuntimeError); expect(errorBox).toHaveBeenCalledWith("Brew failed (HTTP 418)", "teapot", "use coffee instead"); }); @@ -116,7 +105,7 @@ describe("cloud/errors reportApiError", () => { reportApiError("Submit failed", err, { extraHints: { rate_limit_exceeded: "custom backoff guidance" }, }), - ).toThrow("process.exit called"); + ).toThrow(CliRuntimeError); expect(errorBox).toHaveBeenCalledWith( "Submit failed (HTTP 429)", "slow down", @@ -127,12 +116,12 @@ describe("cloud/errors reportApiError", () => { it("reports a plain Error with the stage title and suggestion", () => { expect(() => reportApiError("Upload failed", new Error("disk full"), { suggestion: "free up space" }), - ).toThrow("process.exit called"); + ).toThrow(CliRuntimeError); expect(errorBox).toHaveBeenCalledWith("Upload failed", "disk full", "free up space"); }); it("stringifies a non-Error thrown value", () => { - expect(() => reportApiError("Weird failure", "just a string")).toThrow("process.exit called"); + expect(() => reportApiError("Weird failure", "just a string")).toThrow(CliRuntimeError); expect(errorBox).toHaveBeenCalledWith("Weird failure", "just a string", undefined); }); }); diff --git a/packages/cli/src/cloud/errors.ts b/packages/cli/src/cloud/errors.ts index 7a6c248e48..fe4d2b292c 100644 --- a/packages/cli/src/cloud/errors.ts +++ b/packages/cli/src/cloud/errors.ts @@ -1,3 +1,4 @@ +import { failCommand } from "../utils/commandResult.js"; /** * Shared API-error reporter for the cloud subverbs. * @@ -61,7 +62,7 @@ export function reportApiError( if (err instanceof HyperframesApiError) { if (err.status === 404 && options.notFound) { errorBox("Not found", options.notFound); - process.exit(1); + failCommand(); } const hint = err.code ? hints[err.code] : undefined; const title = `${stage} (HTTP ${err.status})`; @@ -78,12 +79,12 @@ export function reportApiError( } else { errorBox(title, err.message); } - process.exit(1); + failCommand(); } if (err instanceof Error) { errorBox(stage, err.message, options.suggestion); - process.exit(1); + failCommand(); } errorBox(stage, String(err), options.suggestion); - process.exit(1); + failCommand(); } diff --git a/packages/cli/src/cloud/parsing.test.ts b/packages/cli/src/cloud/parsing.test.ts index b75d0ed613..19c1b3486c 100644 --- a/packages/cli/src/cloud/parsing.test.ts +++ b/packages/cli/src/cloud/parsing.test.ts @@ -1,22 +1,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { parseEnumFlag, parseIntFlag, parseNumericFlag } from "./parsing.js"; +import { CliUsageError } from "../utils/commandResult.js"; describe("cloud/parsing", () => { - // process.exit has signature `(code?) => never` which doesn't unify - // with vi.spyOn's mock-function inference; cast through `unknown` so - // the test compiles. The spy itself still records calls correctly. - let exitSpy: { mockRestore: () => void } & { mock: { calls: unknown[][] } }; let errorSpy: { mockRestore: () => void }; beforeEach(() => { - exitSpy = vi.spyOn(process, "exit").mockImplementation((() => { - throw new Error("process.exit called"); - }) as unknown as (code?: string | number | null) => never) as unknown as typeof exitSpy; errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { - exitSpy.mockRestore(); errorSpy.mockRestore(); }); @@ -30,20 +23,19 @@ describe("cloud/parsing", () => { }); it("rejects trailing garbage that Number.parseInt would silently accept", () => { - expect(() => parseIntFlag("10abc", { flag: "--x" })).toThrow("process.exit called"); - expect(exitSpy).toHaveBeenCalledWith(1); + expect(() => parseIntFlag("10abc", { flag: "--x" })).toThrow(CliUsageError); }); it("rejects decimals", () => { - expect(() => parseIntFlag("10.5", { flag: "--x" })).toThrow("process.exit called"); + expect(() => parseIntFlag("10.5", { flag: "--x" })).toThrow(CliUsageError); }); it("enforces min", () => { - expect(() => parseIntFlag("0", { flag: "--x", min: 1 })).toThrow("process.exit called"); + expect(() => parseIntFlag("0", { flag: "--x", min: 1 })).toThrow(CliUsageError); }); it("enforces max", () => { - expect(() => parseIntFlag("101", { flag: "--x", max: 100 })).toThrow("process.exit called"); + expect(() => parseIntFlag("101", { flag: "--x", max: 100 })).toThrow(CliUsageError); }); it("accepts negative integers when no min is set", () => { @@ -61,13 +53,11 @@ describe("cloud/parsing", () => { }); it("rejects trailing garbage that Number.parseFloat would silently accept", () => { - expect(() => parseNumericFlag("10seconds", { flag: "--x" })).toThrow("process.exit called"); + expect(() => parseNumericFlag("10seconds", { flag: "--x" })).toThrow(CliUsageError); }); it("rejects NaN", () => { - expect(() => parseNumericFlag("not-a-number", { flag: "--x" })).toThrow( - "process.exit called", - ); + expect(() => parseNumericFlag("not-a-number", { flag: "--x" })).toThrow(CliUsageError); }); }); @@ -81,7 +71,7 @@ describe("cloud/parsing", () => { it("rejects an unknown value", () => { expect(() => parseEnumFlag("ultra", ["draft", "standard", "high"], { flag: "--quality" }), - ).toThrow("process.exit called"); + ).toThrow(CliUsageError); }); it("returns undefined when raw is undefined", () => { diff --git a/packages/cli/src/cloud/parsing.ts b/packages/cli/src/cloud/parsing.ts index f8d8364f96..42228827f4 100644 --- a/packages/cli/src/cloud/parsing.ts +++ b/packages/cli/src/cloud/parsing.ts @@ -1,3 +1,4 @@ +import { failUsage } from "../utils/commandResult.js"; /** * Strict numeric parsers for CLI flags. * @@ -28,7 +29,7 @@ export function parseIntFlag(raw: string | undefined, opts: IntFlagOptions): num if (raw === undefined) return undefined; if (!INTEGER_RE.test(raw)) { errorBox(`Invalid ${opts.flag}`, `Got "${raw}". Must be an integer${rangeSuffix(opts)}.`); - process.exit(1); + failUsage(); } const n = Number.parseInt(raw, 10); enforceRange(opts.flag, raw, n, opts); @@ -52,12 +53,12 @@ export function parseNumericFlag( if (raw === undefined) return undefined; if (!NUMERIC_RE.test(raw)) { errorBox(`Invalid ${opts.flag}`, `Got "${raw}". Must be a number${rangeSuffix(opts)}.`); - process.exit(1); + failUsage(); } const n = Number.parseFloat(raw); if (!Number.isFinite(n)) { errorBox(`Invalid ${opts.flag}`, `Got "${raw}". Must be a finite number.`); - process.exit(1); + failUsage(); } enforceRange(opts.flag, raw, n, opts); return n; @@ -71,11 +72,11 @@ function enforceRange( ): void { if (bounds.min !== undefined && value < bounds.min) { errorBox(`Invalid ${flag}`, `Got "${raw}". Minimum is ${bounds.min}.`); - process.exit(1); + failUsage(); } if (bounds.max !== undefined && value > bounds.max) { errorBox(`Invalid ${flag}`, `Got "${raw}". Maximum is ${bounds.max}.`); - process.exit(1); + failUsage(); } } @@ -88,7 +89,7 @@ export function parseEnumFlag( if (raw === undefined) return undefined; if ((allowed as readonly string[]).includes(raw)) return raw as T; errorBox(`Invalid ${opts.flag}`, `Got "${raw}". Must be one of: ${allowed.join(", ")}.`); - process.exit(1); + failUsage(); } function rangeSuffix(opts: { min?: number; max?: number }): string { diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts index 8342c1baac..34fae74372 100644 --- a/packages/cli/src/commands/add.ts +++ b/packages/cli/src/commands/add.ts @@ -1,3 +1,4 @@ +import { failCommand } from "../utils/commandResult.js"; import { defineCommand } from "citty"; import type { Example } from "./_examples.js"; @@ -297,7 +298,7 @@ export default defineCommand({ const msg = singleErr instanceof Error ? singleErr.message : String(singleErr); if (json) console.log(JSON.stringify({ ok: false, error: msg })); else console.error(c.error(msg)); - process.exit(1); + failCommand(); } let config = loadProjectConfig(projectDir); @@ -320,7 +321,7 @@ export default defineCommand({ const msg = singleErr instanceof Error ? singleErr.message : String(singleErr); if (json) console.log(JSON.stringify({ ok: false, error: msg })); else console.error(c.error(msg)); - process.exit(1); + failCommand(); } if (!json) { diff --git a/packages/cli/src/commands/auth/login.test.ts b/packages/cli/src/commands/auth/login.test.ts index c5af5d48dd..2f762aa30c 100644 --- a/packages/cli/src/commands/auth/login.test.ts +++ b/packages/cli/src/commands/auth/login.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { readStore, writeStore } from "../../auth/store.js"; +import { CliRuntimeError } from "../../utils/commandResult.js"; // Mock only AuthClient — keep the real store/resolver so the test // exercises the actual on-disk rollback / persistence behavior. @@ -58,10 +59,6 @@ describe("auth login --api-key rollback", () => { verifyState.reject = false; verifyState.user = { email: "alice@example.com" }; for (const fn of Object.values(telemetry)) fn.mockClear(); - // process.exit throws so we can assert the post-rollback state. - vi.spyOn(process, "exit").mockImplementation(((code?: string | number | null) => { - throw new Error(`process.exit:${code ?? 0}`); - }) as never); vi.spyOn(console, "log").mockImplementation(() => {}); vi.spyOn(console, "error").mockImplementation(() => {}); }); @@ -86,7 +83,7 @@ describe("auth login --api-key rollback", () => { it("removes the rejected key on a failed FIRST login (no prior credential)", async () => { verifyState.reject = true; - await expect(runLogin("hg_badkey123")).rejects.toThrow(/process\.exit:1/); + await expect(runLogin("hg_badkey123")).rejects.toThrow(CliRuntimeError); // The store must NOT retain the rejected key — otherwise the next // command would silently resolve a known-bad credential. @@ -97,7 +94,7 @@ describe("auth login --api-key rollback", () => { it("restores the previous credential on a failed re-login", async () => { await writeStore({ api_key: "hg_previous_good" }); verifyState.reject = true; - await expect(runLogin("hg_newbadkey99")).rejects.toThrow(/process\.exit:1/); + await expect(runLogin("hg_newbadkey99")).rejects.toThrow(CliRuntimeError); const { credentials } = await readStore(); expect(credentials.api_key).toBe("hg_previous_good"); @@ -144,7 +141,7 @@ describe("auth login --api-key rollback", () => { it("rollback on a rejected key restores the previous user block too", async () => { await writeStore({ api_key: "hg_prev", user: { email: "prev@example.com" } }); verifyState.reject = true; - await expect(runLogin("hg_badnewkey")).rejects.toThrow(/process\.exit:1/); + await expect(runLogin("hg_badnewkey")).rejects.toThrow(CliRuntimeError); const { credentials } = await readStore(); expect(credentials.api_key).toBe("hg_prev"); @@ -163,7 +160,7 @@ describe("auth login --api-key rollback", () => { { mode: 0o600 }, ); verifyState.reject = true; - await expect(runLogin("hg_badnewkey")).rejects.toThrow(/process\.exit:1/); + await expect(runLogin("hg_badnewkey")).rejects.toThrow(CliRuntimeError); const onDisk = JSON.parse(await fs.readFile(join(dir, "credentials"), "utf8")); expect(onDisk.api_key).toBeUndefined(); @@ -193,7 +190,7 @@ describe("auth login --api-key rollback", () => { it("records a rejected key as failed and never identifies", async () => { verifyState.reject = true; - await expect(runLogin("hg_badkey123")).rejects.toThrow(/process\.exit:1/); + await expect(runLogin("hg_badkey123")).rejects.toThrow(CliRuntimeError); expect(telemetry.identifyUser).not.toHaveBeenCalled(); expect(telemetry.trackAuthLoginFailed).toHaveBeenCalledWith("api_key", "rejected"); }); diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index 907f126baa..3115e4e26d 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -1,3 +1,4 @@ +import { failCommand } from "../../utils/commandResult.js"; /** * `hyperframes auth login` — sign in to HeyGen. * @@ -91,7 +92,7 @@ async function runOAuthLogin(): Promise { // (IdP misconfig, network) instead of lumping everything as flow_error. trackAuthLoginFailed("oauth", /timed out/i.test(message) ? "flow_timeout" : "flow_error"); console.error(c.error(`Sign-in failed: ${message}`)); - process.exit(1); + failCommand(); } await reportIdentity(); @@ -105,7 +106,7 @@ async function reportIdentity(): Promise { if (!credential) { trackAuthLoginFailed("oauth", "no_credential"); console.error(c.warn("Sign-in completed but no credential was persisted.")); - process.exit(1); + failCommand(); } // Wire the refresh hook here too — a freshly-minted token shouldn't // need it, but a fast IdP-side rotation (or a misconfigured short @@ -211,12 +212,12 @@ async function runApiKeyLogin(inlineKey: string): Promise { } catch (err) { trackAuthLoginFailed("api_key", "aborted"); console.error(c.error((err as Error).message || "Sign-in aborted.")); - process.exit(1); + failCommand(); } if (!key) { trackAuthLoginFailed("api_key", "invalid_input"); console.error(c.error("No API key provided.")); - process.exit(1); + failCommand(); } if (!isHeaderSafe(key)) { // CR/LF in the value would smuggle headers when the key is sent @@ -224,12 +225,12 @@ async function runApiKeyLogin(inlineKey: string): Promise { // header-injection has to be caught here. trackAuthLoginFailed("api_key", "invalid_input"); console.error(c.error("API key must not contain newline or control characters.")); - process.exit(1); + failCommand(); } if (key.length < MIN_KEY_LENGTH) { trackAuthLoginFailed("api_key", "invalid_input"); console.error(c.error(`API key looks too short (got ${key.length} chars).`)); - process.exit(1); + failCommand(); } const previous = await snapshotStore(); @@ -240,7 +241,7 @@ async function runApiKeyLogin(inlineKey: string): Promise { if (!user) { trackAuthLoginFailed("api_key", "rejected"); await rollback(previous); - process.exit(1); + failCommand(); } const id = identityKey(user); if (id) identifyUser(id); diff --git a/packages/cli/src/commands/auth/logout.ts b/packages/cli/src/commands/auth/logout.ts index a836bf956c..459e886dc0 100644 --- a/packages/cli/src/commands/auth/logout.ts +++ b/packages/cli/src/commands/auth/logout.ts @@ -1,3 +1,4 @@ +import { failCommand } from "../../utils/commandResult.js"; /** * `hyperframes auth logout` — remove the credential file. With * `--keep-api-key`, only the OAuth block is cleared (no-op for @@ -38,7 +39,7 @@ export default defineCommand({ if (!(await ensureConfirmed(Boolean(args.yes), keepApiKey))) { console.log("Aborted."); - process.exit(1); + failCommand(); } // Best-effort revoke before we wipe local state. RFC 7009 says diff --git a/packages/cli/src/commands/auth/refresh.ts b/packages/cli/src/commands/auth/refresh.ts index d8b161db7a..860dfabfdf 100644 --- a/packages/cli/src/commands/auth/refresh.ts +++ b/packages/cli/src/commands/auth/refresh.ts @@ -1,3 +1,4 @@ +import { failCommand } from "../../utils/commandResult.js"; /** * `hyperframes auth refresh` — force-refresh the OAuth access_token * using the stored refresh_token. @@ -26,7 +27,7 @@ export default defineCommand({ const { credentials, source } = await readStore(); if (source === "absent" || !credentials.oauth?.refresh_token) { console.error(c.warn("No OAuth refresh token to use. Run `hyperframes auth login` first.")); - process.exit(1); + failCommand(); } try { @@ -40,7 +41,7 @@ export default defineCommand({ if (isAuthError(err) && err.code === "REFRESH_FAILED") { console.error(c.error(err.message)); if (err.hint) console.error(c.dim(err.hint)); - process.exit(1); + failCommand(); } throw err; } diff --git a/packages/cli/src/commands/auth/status-user.test.ts b/packages/cli/src/commands/auth/status-user.test.ts index d7c7462f67..208996314c 100644 --- a/packages/cli/src/commands/auth/status-user.test.ts +++ b/packages/cli/src/commands/auth/status-user.test.ts @@ -1,8 +1,10 @@ +// fallow-ignore-file code-duplication import { promises as fs } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { writeStore } from "../../auth/store.js"; +import { consumeCommandResult } from "../../utils/commandResult.js"; // Mock only AuthClient so the live /v3/users/me probe is controllable; // keep the real store/resolver/user helpers so the test exercises the @@ -48,9 +50,7 @@ describe("auth status — persisted user block surface", () => { probeState.apiReject = false; probeState.user = { email: "live@example.com" }; stdout = []; - vi.spyOn(process, "exit").mockImplementation(((code?: string | number | null) => { - throw new Error(`process.exit:${code ?? 0}`); - }) as never); + consumeCommandResult(); vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => { stdout.push(args.join(" ")); }); @@ -58,6 +58,7 @@ describe("auth status — persisted user block surface", () => { }); afterEach(async () => { + consumeCommandResult(); vi.restoreAllMocks(); for (const k of ENV_KEYS) { const v = saved[k]; @@ -69,16 +70,10 @@ describe("auth status — persisted user block surface", () => { async function runStatus(asJson: boolean): Promise { const cmd = (await import("./status.js")).default; - try { - await (cmd.run as (ctx: { args: Record }) => Promise)({ - args: { json: asJson }, - }); - return 0; - } catch (err) { - const m = /process\.exit:(\d+)/.exec((err as Error).message); - if (m) return Number(m[1]); - throw err; - } + await (cmd.run as (ctx: { args: Record }) => Promise)({ + args: { json: asJson }, + }); + return consumeCommandResult().exitCode; } function lastJson(): Record { diff --git a/packages/cli/src/commands/auth/status.ts b/packages/cli/src/commands/auth/status.ts index f801243f05..6b05965093 100644 --- a/packages/cli/src/commands/auth/status.ts +++ b/packages/cli/src/commands/auth/status.ts @@ -1,3 +1,4 @@ +import { failCommand, setCommandExitCode } from "../../utils/commandResult.js"; /** * `hyperframes auth status` — print the active credential's source, * type, and identity (verified against `GET /v3/users/me`). @@ -81,7 +82,7 @@ export default defineCommand({ const status = await verify(credential); if (asJson) printJsonStatus(status); else printHumanStatus(status); - process.exit(status.apiError ? 1 : 0); + setCommandExitCode(status.apiError ? 1 : 0); }, }); @@ -123,7 +124,7 @@ function handleUnconfigured(asJson: boolean): never { ? JSON.stringify(buildUnconfiguredJson(ctx, engines)) : buildUnconfiguredLines(ctx, engines).join("\n"); console.log(output); - process.exit(1); + failCommand(); } // fallow-ignore-next-line complexity @@ -135,7 +136,7 @@ function handleResolveError(err: unknown, asJson: boolean): never { console.error(c.error(err.message)); if (err.hint) console.error(c.dim(err.hint)); } - process.exit(1); + failCommand(); } async function verify(credential: ResolvedCredential): Promise { diff --git a/packages/cli/src/commands/batchRender.ts b/packages/cli/src/commands/batchRender.ts index d1e9d1846b..34ba27f829 100644 --- a/packages/cli/src/commands/batchRender.ts +++ b/packages/cli/src/commands/batchRender.ts @@ -1,3 +1,4 @@ +import { failCommand } from "../utils/commandResult.js"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join, resolve, sep } from "node:path"; import { c } from "../ui/colors.js"; @@ -34,7 +35,7 @@ export interface PreparedBatchRender { rows: PreparedBatchRow[]; } -export interface BatchRenderResult { +interface BatchRenderResult { durationMs?: number; renderTimeMs: number; } @@ -154,7 +155,7 @@ function isSameOrChildPath(path: string, parent: string): boolean { return path === parent || path.startsWith(parent.endsWith(sep) ? parent : parent + sep); } -export function commonOutputDirectory(outputPaths: readonly string[]): string { +function commonOutputDirectory(outputPaths: readonly string[]): string { const firstPath = outputPaths[0]; if (!firstPath) return resolve("renders"); @@ -449,10 +450,12 @@ export async function runBatchRender(options: RunBatchRenderOptions): Promise 20) { errorBox("Invalid runs", `Got "${args.runs ?? "3"}". Must be between 1 and 20.`); - process.exit(1); + failCommand(); } const jsonOutput = args.json ?? false; @@ -88,7 +89,7 @@ export default defineCommand({ "Ensure @hyperframes/producer is built and linked.", ); } - process.exit(1); + failCommand(); } // ── Print header ───────────────────────────────────────────────────── diff --git a/packages/cli/src/commands/browser.ts b/packages/cli/src/commands/browser.ts index 2d0c74a233..6be367d3ae 100644 --- a/packages/cli/src/commands/browser.ts +++ b/packages/cli/src/commands/browser.ts @@ -1,3 +1,4 @@ +import { failCommand } from "../utils/commandResult.js"; import { defineCommand } from "citty"; import type { Example } from "./_examples.js"; import * as clack from "@clack/prompts"; @@ -56,7 +57,7 @@ async function runEnsure(options?: { force?: boolean }): Promise { trackCommandFailure("browser", err); clack.log.error(err instanceof Error ? err.message : String(err)); clack.outro(c.warn("Manual setup required (see instructions above).")); - process.exit(1); + failCommand(); } return; } @@ -134,7 +135,7 @@ async function runPath(): Promise { } catch (err: unknown) { trackCommandFailure("browser", err); console.error(err instanceof Error ? err.message : "Failed to find browser"); - process.exit(1); + failCommand(); } return; } @@ -203,7 +204,7 @@ ${c.bold("EXAMPLES:")} console.error( `${c.error("Unknown subcommand:")} ${subcommand}\n\nRun ${c.accent("hyperframes browser --help")} for usage.`, ); - process.exit(1); + failCommand(); } }, }); diff --git a/packages/cli/src/commands/capture.ts b/packages/cli/src/commands/capture.ts index 8a3c926272..601567e344 100644 --- a/packages/cli/src/commands/capture.ts +++ b/packages/cli/src/commands/capture.ts @@ -1,3 +1,4 @@ +import { failCommand } from "../utils/commandResult.js"; import { defineCommand } from "citty"; import { resolve } from "node:path"; import type { Example } from "./_examples.js"; @@ -88,14 +89,14 @@ export default defineCommand({ console.error( "Missing URL. Pass a website URL, or use --video for video download.", ); - process.exit(1); + failCommand(); } try { new URL(url); } catch { console.error(`Invalid URL: ${url}`); - process.exit(1); + failCommand(); } const isDefaultOutput = !args.output; @@ -114,7 +115,7 @@ export default defineCommand({ } if (existsSync(outputDir)) { console.error(`./capture-{2..99} are all taken. Pass -o to pick a directory.`); - process.exit(1); + failCommand(); } } @@ -236,7 +237,7 @@ export default defineCommand({ } else { console.error(`\n ✗ Capture failed: ${errMsg}\n`); } - process.exit(1); + failCommand(); } }, }); diff --git a/packages/cli/src/commands/capture/video.ts b/packages/cli/src/commands/capture/video.ts index 7dd621903d..35f8c31f6f 100644 --- a/packages/cli/src/commands/capture/video.ts +++ b/packages/cli/src/commands/capture/video.ts @@ -1,3 +1,4 @@ +import { setCommandExitCode } from "../../utils/commandResult.js"; import { createWriteStream, existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs"; import { resolve, join, basename } from "node:path"; import { c } from "../../ui/colors.js"; @@ -196,7 +197,7 @@ export async function runVideoMode(args: VideoModeArgs): Promise { `${c.error("✗")} no video-manifest.json at ${directPath} or ${w2hPath}\n` + ` Was this directory produced by \`hyperframes capture\`?`, ); - process.exitCode = 1; + setCommandExitCode(1); return; } let manifest: ManifestEntry[]; @@ -204,7 +205,7 @@ export async function runVideoMode(args: VideoModeArgs): Promise { manifest = JSON.parse(readFileSync(manifestPath, "utf-8")); } catch (e) { console.error(`${c.error("✗")} video-manifest.json is malformed: ${(e as Error).message}`); - process.exitCode = 1; + setCommandExitCode(1); return; } @@ -232,7 +233,7 @@ export async function runVideoMode(args: VideoModeArgs): Promise { `${c.error("✗")} ${pick.message}` + (pick.code === "no-match-url" ? `\n Run with --list to see what's available.` : ""), ); - process.exitCode = 1; + setCommandExitCode(1); return; } const entry = pick.entry; @@ -245,7 +246,7 @@ export async function runVideoMode(args: VideoModeArgs): Promise { `${collisions.map((co) => `[${co.index}]`).join(", ")}. ` + `Refusing to download — the on-disk file's bytes would not match the requested entry.`, ); - process.exitCode = 1; + setCommandExitCode(1); return; } @@ -278,6 +279,6 @@ export async function runVideoMode(args: VideoModeArgs): Promise { return; } console.error(`${c.error("✗")} download failed: ${(e as Error).message}`); - process.exitCode = 1; + setCommandExitCode(1); } } diff --git a/packages/cli/src/commands/catalog.ts b/packages/cli/src/commands/catalog.ts index 7cce80a117..2bf367cec1 100644 --- a/packages/cli/src/commands/catalog.ts +++ b/packages/cli/src/commands/catalog.ts @@ -1,3 +1,4 @@ +import { failCommand, finishCommand } from "../utils/commandResult.js"; import { defineCommand } from "citty"; import type { Example } from "./_examples.js"; @@ -51,7 +52,7 @@ export default defineCommand({ else if (args.type === "component") typeFilter = "hyperframes:component"; else if (args.type) { console.error(`Invalid --type: "${args.type}". Use "block" or "component".`); - process.exit(1); + failCommand(); } const entries = await listRegistryItems(typeFilter ? { type: typeFilter } : undefined, { @@ -106,7 +107,7 @@ export default defineCommand({ if (clack.isCancel(selected)) { clack.cancel("Cancelled."); - process.exit(0); + finishCommand(0); } const result = await runAdd({ diff --git a/packages/cli/src/commands/check.test.ts b/packages/cli/src/commands/check.test.ts index 06af01eb14..2ec0e7f0bf 100644 --- a/packages/cli/src/commands/check.test.ts +++ b/packages/cli/src/commands/check.test.ts @@ -31,6 +31,7 @@ import { type MotionSpecResolution, } from "../utils/checkPipeline.js"; import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js"; +import { consumeCommandResult } from "../utils/commandResult.js"; import type { ProjectLintResult } from "../utils/lintProject.js"; import type { LayoutIssue, @@ -46,10 +47,8 @@ const PROJECT: ProjectDir = { indexPath: "/project/index.html", }; const PNG_BASE64 = Buffer.from("png-bytes").toString("base64"); -const ORIGINAL_EXIT_CODE = process.exitCode; - afterEach(() => { - process.exitCode = ORIGINAL_EXIT_CODE; + consumeCommandResult(); trackCheckReport.mockClear(); vi.restoreAllMocks(); }); @@ -369,7 +368,7 @@ it("rejects malformed caption-zone specs instead of silently disabling the gate" }); expect(runPipeline).not.toHaveBeenCalled(); - expect(process.exitCode).toBe(1); + expect(consumeCommandResult().exitCode).toBe(1); expect(log).toHaveBeenCalledTimes(1); expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ ok: false, @@ -784,10 +783,8 @@ describe("selectFindingCropRequests", () => { }); describe("check pipeline", () => { - const originalExitCode = process.exitCode; - afterEach(() => { - process.exitCode = originalExitCode; + consumeCommandResult(); vi.restoreAllMocks(); }); @@ -804,7 +801,7 @@ describe("check pipeline", () => { expect(report.ok).toBe(true); expect(checkExitCode(report)).toBe(0); - expect(process.exitCode).toBe(0); + expect(consumeCommandResult().exitCode).toBe(0); expect(log).toHaveBeenCalledTimes(1); const output = log.mock.calls[0]?.[0]; expect(typeof output).toBe("string"); diff --git a/packages/cli/src/commands/check.ts b/packages/cli/src/commands/check.ts index a0adf37678..d83fee2856 100644 --- a/packages/cli/src/commands/check.ts +++ b/packages/cli/src/commands/check.ts @@ -3,6 +3,7 @@ import type { Example } from "./_examples.js"; import { parseAt } from "./layout.js"; import { c } from "../ui/colors.js"; import { normalizeErrorMessage } from "../utils/errorMessage.js"; +import { setCommandExitCode } from "../utils/commandResult.js"; import { formatLayoutIssue } from "../utils/layoutAudit.js"; import { resolveProject, type ProjectDir } from "../utils/project.js"; import { withMeta } from "../utils/updateCheck.js"; @@ -129,7 +130,7 @@ export function createCheckCommand( } else { printHumanReport(report); } - process.exitCode = checkExitCode(report); + setCommandExitCode(checkExitCode(report)); } catch (error) { const message = normalizeErrorMessage(error); if (asJson) { @@ -139,7 +140,7 @@ export function createCheckCommand( } else { console.error(`${c.error("✗")} Check failed: ${message}`); } - process.exitCode = 1; + setCommandExitCode(1); } }, }); diff --git a/packages/cli/src/commands/cloud/delete.ts b/packages/cli/src/commands/cloud/delete.ts index 37e465bbcd..3b0f1de249 100644 --- a/packages/cli/src/commands/cloud/delete.ts +++ b/packages/cli/src/commands/cloud/delete.ts @@ -1,3 +1,4 @@ +import { failCommand } from "../../utils/commandResult.js"; /** * `hyperframes cloud delete ` — soft-delete a cloud render. * @@ -53,14 +54,14 @@ export default defineCommand({ "delete cannot prompt for confirmation here — stdin isn't a TTY or --json was passed.", "Re-run with --no-confirm to acknowledge the irreversible delete.", ); - process.exit(1); + failCommand(); } const ok = await confirmDelete(args.id); if (!ok) { // Distinct exit code so wrapper scripts can tell an explicit // decline apart from an API/system error. console.log(c.dim("Aborted.")); - process.exit(2); + failCommand(2); } } const client = await createCloudClient(); diff --git a/packages/cli/src/commands/cloud/list.ts b/packages/cli/src/commands/cloud/list.ts index ab575e2bf3..51117eb556 100644 --- a/packages/cli/src/commands/cloud/list.ts +++ b/packages/cli/src/commands/cloud/list.ts @@ -1,3 +1,4 @@ +import { failCommand } from "../../utils/commandResult.js"; /** * `hyperframes cloud list` — page through GET /v3/hyperframes/renders. * @@ -85,7 +86,7 @@ async function fetchAll( "Server returned has_more: true with no next_token — incomplete response.", "Retry the command, or report this if it persists.", ); - process.exit(1); + failCommand(); } if (seenCursors.has(result.next_token)) { errorBox( @@ -93,7 +94,7 @@ async function fetchAll( `Server returned the same next_token (${result.next_token}) twice.`, "Retry the command, or report this if it persists.", ); - process.exit(1); + failCommand(); } seenCursors.add(result.next_token); token = result.next_token; @@ -103,7 +104,7 @@ async function fetchAll( `Stopped after ${MAX_ALL_PAGES} pages to avoid an unbounded loop.`, `Re-run with a higher --limit, or paginate manually with --token.`, ); - process.exit(1); + failCommand(); } // fallow-ignore-next-line complexity diff --git a/packages/cli/src/commands/cloud/render.test.ts b/packages/cli/src/commands/cloud/render.test.ts index 55fa5dd85a..45402184ca 100644 --- a/packages/cli/src/commands/cloud/render.test.ts +++ b/packages/cli/src/commands/cloud/render.test.ts @@ -8,6 +8,7 @@ import { validateResolutionFormatCombo, type ProjectInputSource, } from "./render.js"; +import { CliRuntimeError } from "../../utils/commandResult.js"; // errorBox writes to console; silence it so test output stays clean. beforeEach(() => { @@ -18,13 +19,6 @@ afterEach(() => { vi.restoreAllMocks(); }); -/** Make process.exit throw so we can assert on the failure path. */ -function trapExit() { - return vi.spyOn(process, "exit").mockImplementation((code?: string | number | null): never => { - throw new Error(`process.exit:${code ?? ""}`); - }); -} - function writeComposition(width: number, height: number): string { const dir = mkdtempSync(join(tmpdir(), "hf-cloud-render-test-")); writeFileSync( @@ -37,14 +31,11 @@ function writeComposition(width: number, height: number): string { describe("validateResolutionFormatCombo", () => { it("rejects 4k + webm and 4k + mov", () => { - const exit = trapExit(); - expect(() => validateResolutionFormatCombo("4k", "webm")).toThrow("process.exit:1"); - expect(() => validateResolutionFormatCombo("4k", "mov")).toThrow("process.exit:1"); - expect(exit).toHaveBeenCalled(); + expect(() => validateResolutionFormatCombo("4k", "webm")).toThrow(CliRuntimeError); + expect(() => validateResolutionFormatCombo("4k", "mov")).toThrow(CliRuntimeError); }); it("allows 4k + mp4 and 1080p + any format", () => { - trapExit(); expect(() => validateResolutionFormatCombo("4k", "mp4")).not.toThrow(); expect(() => validateResolutionFormatCombo("1080p", "webm")).not.toThrow(); expect(() => validateResolutionFormatCombo(undefined, undefined)).not.toThrow(); @@ -53,7 +44,6 @@ describe("validateResolutionFormatCombo", () => { describe("resolveAspectRatioForSubmit — non-local sources", () => { it("trusts an explicit flag for asset_id / url", () => { - trapExit(); const asset: ProjectInputSource = { kind: "asset_id", assetId: "a" }; expect(resolveAspectRatioForSubmit(asset, undefined, "9:16", true)).toBe("9:16"); const url: ProjectInputSource = { kind: "url", url: "https://x/z.zip" }; @@ -63,7 +53,6 @@ describe("resolveAspectRatioForSubmit — non-local sources", () => { describe("resolveAspectRatioForSubmit — local dir", () => { it("auto-detects from composition dims when no explicit flag", () => { - trapExit(); const dir = writeComposition(1920, 1080); expect(resolveAspectRatioForSubmit({ kind: "dir", dir }, undefined, undefined, true)).toBe( "16:9", @@ -75,32 +64,26 @@ describe("resolveAspectRatioForSubmit — local dir", () => { }); it("accepts an explicit flag that matches the composition", () => { - trapExit(); const dir = writeComposition(1920, 1080); expect(resolveAspectRatioForSubmit({ kind: "dir", dir }, undefined, "16:9", true)).toBe("16:9"); }); it("rejects an explicit flag that conflicts with the composition", () => { - const exit = trapExit(); const dir = writeComposition(1920, 1080); expect(() => resolveAspectRatioForSubmit({ kind: "dir", dir }, undefined, "1:1", true)).toThrow( - "process.exit:1", + CliRuntimeError, ); - expect(exit).toHaveBeenCalled(); }); it("rejects an explicit flag when the composition ratio is unsupported (no-match)", () => { - const exit = trapExit(); // 1080×1350 is 4:5 — not one of 16:9 / 9:16 / 1:1, so detection is `no-match`. const dir = writeComposition(1080, 1350); expect(() => resolveAspectRatioForSubmit({ kind: "dir", dir }, undefined, "9:16", true), - ).toThrow("process.exit:1"); - expect(exit).toHaveBeenCalled(); + ).toThrow(CliRuntimeError); }); it("fails fast when the --composition entry is missing", () => { - const exit = trapExit(); const dir = writeComposition(1920, 1080); expect(() => resolveAspectRatioForSubmit( @@ -109,7 +92,6 @@ describe("resolveAspectRatioForSubmit — local dir", () => { undefined, true, ), - ).toThrow("process.exit:1"); - expect(exit).toHaveBeenCalled(); + ).toThrow(CliRuntimeError); }); }); diff --git a/packages/cli/src/commands/cloud/render.ts b/packages/cli/src/commands/cloud/render.ts index e6dd5532ce..aedaabbd7e 100644 --- a/packages/cli/src/commands/cloud/render.ts +++ b/packages/cli/src/commands/cloud/render.ts @@ -1,3 +1,4 @@ +import { failCommand } from "../../utils/commandResult.js"; /** * `hyperframes cloud render` — orchestrate a cloud-rendered HyperFrames * composition end-to-end: @@ -288,7 +289,7 @@ export default defineCommand({ "Render completed but returned no video_url", `render_id: ${renderId}. Try \`hyperframes cloud get ${renderId}\` to inspect raw fields.`, ); - process.exit(1); + failCommand(); } const outputPath = resolveOutputPath(args.output, renderId, detail.format); @@ -330,7 +331,7 @@ function validateIdempotencyKey(key: string | undefined): void { if (key === undefined) return; if (!IDEMPOTENCY_KEY_RE.test(key)) { errorBox("Invalid --idempotency-key", `Got "${key}". Must be 1-255 chars from [A-Za-z0-9_:.-]`); - process.exit(1); + failCommand(); } } @@ -362,7 +363,7 @@ function resolveProjectInput(opts: { const count = Number(explicit.dir) + Number(explicit.assetId) + Number(explicit.url); if (count > 1) { errorBox("Conflicting inputs", "Pass only one of: project dir, --asset-id, --url."); - process.exit(1); + failCommand(); } if (explicit.assetId) return { kind: "asset_id", assetId: opts.assetId }; if (explicit.url) return { kind: "url", url: opts.url }; @@ -412,7 +413,7 @@ export function resolveAspectRatioForSubmit( `Entry file "${entryRelative}" does not exist in ${dir}.`, "Pass --composition with a path that exists inside the project, or omit it to use index.html.", ); - process.exit(1); + failCommand(); } const detection = detectAspectRatioFromHtml(entryPath); @@ -436,7 +437,7 @@ export function resolveAspectRatioForSubmit( `--aspect-ratio ${explicit} doesn't match the composition (${conflictDetail}).`, "The renderer matches the composition's authored aspect ratio — it can't reshape it. Drop --aspect-ratio (it's auto-detected) or re-author the composition at the target ratio.", ); - process.exit(1); + failCommand(); } return explicit; } @@ -460,7 +461,7 @@ export function validateResolutionFormatCombo( `--resolution 4k cannot be combined with --format ${format}.`, "The alpha (webm/mov) capture path doesn't support 4k supersampling. Render 4k as mp4, or render alpha at composition resolution.", ); - process.exit(1); + failCommand(); } } @@ -548,7 +549,7 @@ async function maybeUploadProject( } catch (err) { const msg = normalizeErrorMessage(err); errorBox("Zip failed", msg, "Check the project for missing files or unreadable permissions."); - process.exit(1); + failCommand(); } if (!asJson) { console.log(c.dim(` ${archive.fileCount} files · ${formatBytes(archive.buffer.byteLength)}`)); @@ -684,7 +685,7 @@ async function pollWithProgress( err.message, `The render may still complete. Resume with: hyperframes cloud get ${renderId}`, ); - process.exit(1); + failCommand(); } return reportApiError("API error during poll", err, { suggestion: `The render may still be running. Resume with: hyperframes cloud get ${renderId}`, @@ -706,14 +707,14 @@ function formatTickLine(detail: HyperframesRenderDetail, elapsedMs: number): str function handleFailedRender(detail: HyperframesRenderDetail, asJson: boolean): never { if (asJson) { console.log(JSON.stringify(withMeta({ render: detail }), null, 2)); - process.exit(1); + failCommand(); } errorBox( "Render failed", detail.failure_message ?? "(no failure_message returned)", `Inspect: hyperframes cloud get ${detail.render_id}`, ); - process.exit(1); + failCommand(); } function resolveOutputPath(output: string | undefined, renderId: string, format: string): string { @@ -749,6 +750,6 @@ async function streamVideo( message, "The presigned URL is short-lived; re-fetch with `hyperframes cloud get`.", ); - process.exit(1); + failCommand(); } } diff --git a/packages/cli/src/commands/cloudrun.ts b/packages/cli/src/commands/cloudrun.ts index 7ff0f9a3f3..ce436b7a12 100644 --- a/packages/cli/src/commands/cloudrun.ts +++ b/packages/cli/src/commands/cloudrun.ts @@ -1,3 +1,5 @@ +// fallow-ignore-file code-duplication +import { failCommand } from "../utils/commandResult.js"; /** * `hyperframes cloudrun` — deploy + drive distributed renders on Google * Cloud Run + Cloud Workflows. @@ -247,7 +249,7 @@ export default defineCommand({ } catch (error) { if (isMissingCloudRunAdapterError(error)) { console.error(missingCloudRunAdapterMessage(subcommand)); - process.exit(1); + failCommand(); } throw error; } @@ -267,7 +269,7 @@ export default defineCommand({ return runDestroy(args); default: console.error(`${c.error("Unknown subcommand:")} ${subcommand}\n${HELP}`); - process.exit(1); + failCommand(); } }, }); @@ -306,7 +308,7 @@ function readState(args: Record): StackState { `[cloudrun] missing stack coordinates: ${missing.join(", ")}. ` + `Run \`hyperframes cloudrun deploy --project \` first, or pass them as flags.`, ); - process.exit(1); + failCommand(); } return merged as StackState; } @@ -335,7 +337,7 @@ async function runDeploy(args: Record): Promise { const project = args.project as string | undefined; if (!project) { console.error("[cloudrun deploy] --project is required."); - process.exit(1); + failCommand(); } const region = (args.region as string | undefined) ?? "us-central1"; const repo = (args.repo as string | undefined) ?? "hyperframes"; @@ -363,7 +365,7 @@ async function runDeploy(args: Record): Promise { console.error( "[cloudrun deploy] --image is required when not running from a hyperframes checkout (no Dockerfile context found).", ); - process.exit(1); + failCommand(); } // Ensure the Artifact Registry repo exists. const exists = @@ -500,12 +502,12 @@ async function runSites(args: Record): Promise { console.error( `[cloudrun sites] unknown verb "${String(args.target)}". Only "create" is supported.`, ); - process.exit(1); + failCommand(); } const projectDir = args.extra as string | undefined; if (!projectDir) { console.error("[cloudrun sites create] usage: hyperframes cloudrun sites create "); - process.exit(1); + failCommand(); } const state = readState(args); const { deploySite } = await loadCloudRunAdapter(); @@ -533,19 +535,19 @@ async function runRender(args: Record): Promise { console.error( "[cloudrun render] usage: hyperframes cloudrun render --width --height ", ); - process.exit(1); + failCommand(); } const width = parsePositiveInt(args.width, "--width"); const height = parsePositiveInt(args.height, "--height"); if (width === undefined || height === undefined) { console.error("[cloudrun render] --width and --height are required."); - process.exit(1); + failCommand(); } const fps = parseIntFlag(args.fps) ?? readAllowedCompositionFpsFromDir(projectDir, [24, 30, 60]) ?? 30; if (fps !== 24 && fps !== 30 && fps !== 60) { console.error(`[cloudrun render] --fps must be 24, 30, or 60; got ${fps}.`); - process.exit(1); + failCommand(); } const state = readState(args); const variables = resolveAndValidateVariables(args, resolve(projectDir)); @@ -593,7 +595,7 @@ async function runRender(args: Record): Promise { } else { console.error(`${c.error("✗ render " + progress.status)}`); for (const e of progress.errors) console.error(` ${e.state}: ${e.cause}`); - process.exit(1); + failCommand(); } } @@ -604,7 +606,7 @@ async function runProgress(args: Record): Promise { const executionName = args.target as string | undefined; if (!executionName) { console.error("[cloudrun progress] usage: hyperframes cloudrun progress "); - process.exit(1); + failCommand(); } const { getRenderProgress } = await loadCloudRunAdapter(); const progress = await getRenderProgress({ executionName }); @@ -644,28 +646,28 @@ async function runRenderBatch(args: Record): Promise { console.error( "[cloudrun render-batch] usage: hyperframes cloudrun render-batch --batch --width --height ", ); - process.exit(1); + failCommand(); } const width = parsePositiveInt(args.width, "--width"); const height = parsePositiveInt(args.height, "--height"); if (width === undefined || height === undefined) { console.error("[cloudrun render-batch] --width and --height are required."); - process.exit(1); + failCommand(); } const fps = parseIntFlag(args.fps) ?? readAllowedCompositionFpsFromDir(projectDir, [24, 30, 60]) ?? 30; if (fps !== 24 && fps !== 30 && fps !== 60) { console.error(`[cloudrun render-batch] --fps must be 24, 30, or 60; got ${fps}.`); - process.exit(1); + failCommand(); } if (!existsSync(resolve(batchPath))) { console.error(`[cloudrun render-batch] batch file not found: ${batchPath}`); - process.exit(1); + failCommand(); } const entries = parseBatchFile(resolve(batchPath)); if (entries.length === 0) { console.error("[cloudrun render-batch] batch file has no entries."); - process.exit(1); + failCommand(); } const dryRun = Boolean(args["dry-run"]); @@ -732,7 +734,7 @@ async function runRenderBatch(args: Record): Promise { ); for (const r of failed) console.error(` ✗ ${r.outputKey}: ${r.error}`); } - if (failed.length > 0) process.exit(1); + if (failed.length > 0) failCommand(); } /** Parse a JSONL batch file into entries, exiting with a clear error on a bad line. */ @@ -748,7 +750,7 @@ function parseBatchFile(path: string): BatchEntry[] { parsed = JSON.parse(trimmed); } catch { console.error(`[cloudrun render-batch] line ${idx + 1}: not valid JSON`); - process.exit(1); + failCommand(); } if ( !parsed || @@ -758,7 +760,7 @@ function parseBatchFile(path: string): BatchEntry[] { console.error( `[cloudrun render-batch] line ${idx + 1}: must be an object with a string "outputKey"`, ); - process.exit(1); + failCommand(); } entries.push(parsed as BatchEntry); }); @@ -779,7 +781,7 @@ async function runDestroy(args: Record): Promise { const image = (args.image as string | undefined) ?? "unused:latest"; if (!project) { console.error("[cloudrun destroy] --project is required (or deploy first to cache it)."); - process.exit(1); + failCommand(); } const vars = [ "-var", diff --git a/packages/cli/src/commands/compare.ts b/packages/cli/src/commands/compare.ts index a55fd168e3..fac16738b7 100644 --- a/packages/cli/src/commands/compare.ts +++ b/packages/cli/src/commands/compare.ts @@ -1,3 +1,4 @@ +import { failCommand } from "../utils/commandResult.js"; import { cpSync, existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, statSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, dirname, extname, join } from "node:path"; @@ -407,7 +408,7 @@ export default defineCommand({ } else { console.error(`\n${c.error("✗")} Compare failed: ${message}`); } - process.exit(1); + failCommand(); } }, }); diff --git a/packages/cli/src/commands/deprecationTestHarness.ts b/packages/cli/src/commands/deprecationTestHarness.ts index bce26290db..ba66b6e3da 100644 --- a/packages/cli/src/commands/deprecationTestHarness.ts +++ b/packages/cli/src/commands/deprecationTestHarness.ts @@ -11,6 +11,7 @@ import type { ArgsDef, CommandDef } from "citty"; import { runCommand } from "citty"; import { expect, vi } from "vitest"; +import { CliRuntimeError } from "../utils/commandResult.js"; const FAKE_PROJECT = { dir: "/fake-project", @@ -51,6 +52,18 @@ export function metaDescription(command: CommandDef throw new Error("expected a synchronous meta object"); } +async function runExpectedFailure( + command: CommandDef, + rawArgs: string[], +): Promise { + try { + await runCommand(command, { rawArgs }); + } catch (error) { + if (error instanceof CliRuntimeError && error.result.presented) return; + throw error; + } +} + /** * Run a command with stdout/stderr writes captured (and process.exit / * console.log stubbed so the run stays silent and non-terminating), and @@ -73,7 +86,7 @@ export async function runAndCaptureStdio( vi.spyOn(process, "exit").mockImplementation(() => undefined as never); vi.spyOn(console, "log").mockImplementation(() => {}); - await runCommand(command, { rawArgs }); + await runExpectedFailure(command, rawArgs); return { stderrText: stderrWrites.join(""), stdoutText: stdoutWrites.join("") }; } @@ -92,7 +105,7 @@ export async function runAndFindJsonLogCall( vi.spyOn(process, "exit").mockImplementation(() => undefined as never); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - await runCommand(command, { rawArgs }); + await runExpectedFailure(command, rawArgs); return logSpy.mock.calls.find(([arg]) => typeof arg === "string" && arg.trim().startsWith("{")); } diff --git a/packages/cli/src/commands/docs.ts b/packages/cli/src/commands/docs.ts index cd699a2bd7..0af91d8e81 100644 --- a/packages/cli/src/commands/docs.ts +++ b/packages/cli/src/commands/docs.ts @@ -1,3 +1,4 @@ +import { failCommand } from "../utils/commandResult.js"; import { defineCommand } from "citty"; import type { Example } from "./_examples.js"; import { readFileSync, existsSync } from "node:fs"; @@ -127,13 +128,13 @@ export default defineCommand({ for (const name of Object.keys(TOPICS)) { console.error(` ${c.accent(name)}`); } - process.exit(1); + failCommand(); } const filePath = join(docsDir(), entry.file); if (!existsSync(filePath)) { console.error(c.error(`Doc file not found: ${filePath}`)); - process.exit(1); + failCommand(); } const content = readFileSync(filePath, "utf-8"); diff --git a/packages/cli/src/commands/feedback.ts b/packages/cli/src/commands/feedback.ts index 14e97c0582..04e5f72577 100644 --- a/packages/cli/src/commands/feedback.ts +++ b/packages/cli/src/commands/feedback.ts @@ -1,3 +1,4 @@ +import { failCommand } from "../utils/commandResult.js"; import { resolve } from "node:path"; import { defineCommand } from "citty"; import * as clack from "@clack/prompts"; @@ -149,7 +150,7 @@ export default defineCommand({ const rating = parseRating(args.rating); if (rating === null) { console.error(c.error("Rating must be between 1 and 5")); - process.exit(1); + failCommand(); } if (!shouldTrack()) { diff --git a/packages/cli/src/commands/figma/cliError.ts b/packages/cli/src/commands/figma/cliError.ts index 33e34ae01a..2a3f904386 100644 --- a/packages/cli/src/commands/figma/cliError.ts +++ b/packages/cli/src/commands/figma/cliError.ts @@ -1,3 +1,4 @@ +import { failCommand } from "../../utils/commandResult.js"; /** * Shared CLI error boundary for `hyperframes figma` subcommands: typed * client errors (NO_TOKEN, BAD_TOKEN, …) and input errors (bad ref, bad @@ -36,7 +37,7 @@ export async function withFigmaErrors(command: string, fn: () => Promise): } const [title = "figma command failed", ...rest] = err.message.split("\n"); errorBox(title, rest.length > 0 ? rest.join("\n") : undefined); - process.exit(1); + failCommand(); } throw err; } diff --git a/packages/cli/src/commands/grade-compare.ts b/packages/cli/src/commands/grade-compare.ts index d62c172b87..67fe1571fa 100644 --- a/packages/cli/src/commands/grade-compare.ts +++ b/packages/cli/src/commands/grade-compare.ts @@ -1,3 +1,4 @@ +import { failCommand } from "../utils/commandResult.js"; import { copyFileSync, existsSync, @@ -688,7 +689,7 @@ export default defineCommand({ } else { console.error(`\n${c.error("✗")} Grade compare failed: ${message}`); } - process.exit(1); + failCommand(); } finally { if (preparedDir) { rmSync(preparedDir, { recursive: true, force: true }); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 9a8ed3cbe8..334f5de8d4 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -3,6 +3,7 @@ // This branch only repointed the scaffolded npm scripts; the refactor is its // own task. // fallow-ignore-file complexity +import { failCommand, finishCommand } from "../utils/commandResult.js"; import { defineCommand, runCommand } from "citty"; import type { Example } from "./_examples.js"; @@ -404,7 +405,7 @@ async function handleVideoFile( }); if (clack.isCancel(transcode)) { clack.cancel("Setup cancelled."); - process.exit(0); + finishCommand(0); } shouldTranscode = transcode === "yes"; } @@ -708,7 +709,7 @@ export default defineCommand({ `The --template flag was renamed to --example. Example:\n npx hyperframes init ${args.name ?? "my-video"} --example "${args.template}"`, ), ); - process.exit(1); + failCommand(); } if (args["video-legacy"] !== undefined) { console.error( @@ -716,7 +717,7 @@ export default defineCommand({ `The -V short flag no longer maps to --video. Use --video (or -v). Example:\n npx hyperframes init ${args.name ?? "my-video"} --video "${args["video-legacy"]}"`, ), ); - process.exit(1); + failCommand(); } const exampleFlag = args.example; const videoFlag = args.video; @@ -759,7 +760,7 @@ export default defineCommand({ `(or aliases 1080p, 4k, uhd, 1080p-square, square-1080p, 4k-square).`, ), ); - process.exit(1); + failCommand(); } } @@ -773,12 +774,12 @@ export default defineCommand({ if (existsSync(destDir) && readdirSync(destDir).length > 0) { console.error(c.error(`Directory already exists and is not empty: ${name}`)); - process.exit(1); + failCommand(); } if (videoFlag && audioFlag) { console.error(c.error("Cannot use --video and --audio together")); - process.exit(1); + failCommand(); } // Validate source files before creating destDir so a failed run does @@ -787,12 +788,12 @@ export default defineCommand({ const videoPath = videoFlag ? resolve(videoFlag) : undefined; if (videoPath && !existsSync(videoPath)) { console.error(c.error(`Video file not found: ${videoFlag}`)); - process.exit(1); + failCommand(); } const audioPath = audioFlag ? resolve(audioFlag) : undefined; if (audioPath && !existsSync(audioPath)) { console.error(c.error(`Audio file not found: ${audioFlag}`)); - process.exit(1); + failCommand(); } mkdirSync(destDir, { recursive: true }); @@ -858,7 +859,7 @@ export default defineCommand({ ), ); console.error(c.dim("Use --example blank for offline use.")); - process.exit(1); + failCommand(); } trackInitTemplate(templateId, { tailwind }); const transcriptFile = resolve(destDir, "transcript.json"); @@ -930,7 +931,7 @@ export default defineCommand({ }); if (clack.isCancel(nameResult)) { clack.cancel("Setup cancelled."); - process.exit(0); + finishCommand(0); } name = nameResult; } @@ -944,7 +945,7 @@ export default defineCommand({ }); if (clack.isCancel(overwrite) || !overwrite) { clack.cancel("Setup cancelled."); - process.exit(0); + finishCommand(0); } } @@ -958,7 +959,7 @@ export default defineCommand({ if (!existsSync(videoPath)) { clack.log.error(`File not found: ${videoFlag}`); clack.cancel("Setup cancelled."); - process.exit(1); + failCommand(); } mkdirSync(destDir, { recursive: true }); sourceFilePath = videoPath; @@ -970,7 +971,7 @@ export default defineCommand({ if (!existsSync(audioPath)) { clack.log.error(`File not found: ${audioFlag}`); clack.cancel("Setup cancelled."); - process.exit(1); + failCommand(); } mkdirSync(destDir, { recursive: true }); sourceFilePath = audioPath; @@ -1044,7 +1045,7 @@ export default defineCommand({ }); if (clack.isCancel(templateResult)) { clack.cancel("Setup cancelled."); - process.exit(0); + finishCommand(0); } templateId = templateResult; } @@ -1075,7 +1076,7 @@ export default defineCommand({ clack.log.error( `${err instanceof Error ? err.message : err}\n${c.dim("Use --example blank for offline use.")}`, ); - process.exit(1); + failCommand(); } trackInitTemplate(templateId, { tailwind }); diff --git a/packages/cli/src/commands/lambda.ts b/packages/cli/src/commands/lambda.ts index f5ad901a3d..ea5006dd75 100644 --- a/packages/cli/src/commands/lambda.ts +++ b/packages/cli/src/commands/lambda.ts @@ -1,3 +1,4 @@ +import { failCommand } from "../utils/commandResult.js"; /** * `hyperframes lambda` — top-level dispatcher for AWS Lambda subcommands. * @@ -241,7 +242,7 @@ export default defineCommand({ `Or, for an opt-in dev setup:\n` + ` ${c.accent("npm install @hyperframes/aws-lambda")}`, ); - process.exit(1); + failCommand(); } throw err; } @@ -266,14 +267,14 @@ export default defineCommand({ console.error( `[lambda sites] unknown verb "${String(args.target)}". Only "create" is supported.`, ); - process.exit(1); + failCommand(); } const projectDir = args.extra as string | undefined; if (!projectDir) { console.error( "[lambda sites create] usage: hyperframes lambda sites create ", ); - process.exit(1); + failCommand(); } const { runSitesCreate } = await import("./lambda/sites.js"); await runSitesCreate({ @@ -290,13 +291,13 @@ export default defineCommand({ console.error( "[lambda render] usage: hyperframes lambda render --width --height ", ); - process.exit(1); + failCommand(); } const width = parsePositiveInt(args.width, "--width"); const height = parsePositiveInt(args.height, "--height"); if (width === undefined || height === undefined) { console.error("[lambda render] --width and --height are required."); - process.exit(1); + failCommand(); } const fpsRaw = parseIntFlag(args.fps) ?? @@ -304,7 +305,7 @@ export default defineCommand({ 30; if (fpsRaw !== 24 && fpsRaw !== 30 && fpsRaw !== 60) { console.error(`[lambda render] --fps must be 24, 30, or 60; got ${fpsRaw}.`); - process.exit(1); + failCommand(); } const { runRender } = await import("./lambda/render.js"); await runRender({ @@ -338,20 +339,20 @@ export default defineCommand({ console.error( "[lambda render-batch] usage: hyperframes lambda render-batch --batch --width --height ", ); - process.exit(1); + failCommand(); } const batch = args.batch as string | undefined; if (!batch) { console.error( "[lambda render-batch] --batch is required. Each line is a JSON object with at least { outputKey: '...' }.", ); - process.exit(1); + failCommand(); } const width = parsePositiveInt(args.width, "--width"); const height = parsePositiveInt(args.height, "--height"); if (width === undefined || height === undefined) { console.error("[lambda render-batch] --width and --height are required."); - process.exit(1); + failCommand(); } const fpsRaw = parseIntFlag(args.fps) ?? @@ -359,7 +360,7 @@ export default defineCommand({ 30; if (fpsRaw !== 24 && fpsRaw !== 30 && fpsRaw !== 60) { console.error(`[lambda render-batch] --fps must be 24, 30, or 60; got ${fpsRaw}.`); - process.exit(1); + failCommand(); } const { runRenderBatch } = await import("./lambda/render-batch.js"); await runRenderBatch({ @@ -390,7 +391,7 @@ export default defineCommand({ console.error( "[lambda progress] usage: hyperframes lambda progress ", ); - process.exit(1); + failCommand(); } const { runProgress } = await import("./lambda/progress.js"); await runProgress({ target, stackName, json: Boolean(args.json) }); @@ -407,7 +408,7 @@ export default defineCommand({ console.error( `[lambda policies] usage: hyperframes lambda policies [args]`, ); - process.exit(1); + failCommand(); } const { runPolicies } = await import("./lambda/policies.js"); await runPolicies({ @@ -419,7 +420,7 @@ export default defineCommand({ } default: console.error(`${c.error("Unknown subcommand:")} ${subcommand}\n${HELP}`); - process.exit(1); + failCommand(); } }, }); diff --git a/packages/cli/src/commands/lambda/policies.ts b/packages/cli/src/commands/lambda/policies.ts index 9514ff24d7..e1f12d0790 100644 --- a/packages/cli/src/commands/lambda/policies.ts +++ b/packages/cli/src/commands/lambda/policies.ts @@ -1,3 +1,4 @@ +import { setCommandExitCode } from "../../utils/commandResult.js"; /** * `hyperframes lambda policies role|user|validate` — IAM bootstrap. * @@ -245,7 +246,7 @@ export async function runPolicies(args: PoliciesArgs): Promise { "[lambda policies validate] usage: hyperframes lambda policies validate "; if (args.json) { console.log(JSON.stringify({ ok: false, error: msg }, null, 2)); - process.exitCode = 1; + setCommandExitCode(1); return; } throw new Error(msg); @@ -257,16 +258,16 @@ export async function runPolicies(args: PoliciesArgs): Promise { const msg = normalizeErrorMessage(err); if (args.json) { console.log(JSON.stringify({ ok: false, error: msg }, null, 2)); - process.exitCode = 1; + setCommandExitCode(1); return; } console.error(c.error(`Failed to validate ${args.inputPath}: ${msg}`)); - process.exitCode = 1; + setCommandExitCode(1); return; } if (args.json) { console.log(JSON.stringify({ ok: result.missing.length === 0, ...result }, null, 2)); - if (result.missing.length > 0) process.exitCode = 1; + if (result.missing.length > 0) setCommandExitCode(1); return; } for (const warning of result.warnings) { @@ -284,7 +285,7 @@ export async function runPolicies(args: PoliciesArgs): Promise { console.log( c.dim("Run `hyperframes lambda policies user` to print the full required policy."), ); - process.exitCode = 1; + setCommandExitCode(1); return; } } diff --git a/packages/cli/src/commands/lambda/progress.ts b/packages/cli/src/commands/lambda/progress.ts index 233777723f..0c6ccfdfe2 100644 --- a/packages/cli/src/commands/lambda/progress.ts +++ b/packages/cli/src/commands/lambda/progress.ts @@ -1,3 +1,4 @@ +import { setCommandExitCode } from "../../utils/commandResult.js"; /** * `hyperframes lambda progress ` — print a single progress * snapshot for a render. Wraps {@link getRenderProgress}. Accepts a @@ -60,7 +61,7 @@ export async function runProgress(args: ProgressArgs): Promise { } } if (progress.fatalErrorEncountered) { - process.exitCode = 1; + setCommandExitCode(1); } } diff --git a/packages/cli/src/commands/lambda/render-batch.test.ts b/packages/cli/src/commands/lambda/render-batch.test.ts index 283ca00883..0ff6a44292 100644 --- a/packages/cli/src/commands/lambda/render-batch.test.ts +++ b/packages/cli/src/commands/lambda/render-batch.test.ts @@ -1,7 +1,8 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { CliRuntimeError } from "../../utils/commandResult.js"; import { parseBatchFile, runWithConcurrencyLimit } from "./render-batch.js"; let tmpDir: string; @@ -110,19 +111,9 @@ describe("parseBatchFile", () => { expect(out[1]?.lineNumber).toBe(5); }); - // Helper: stub `process.exit` to throw a sentinel, run the parser, and - // verify it called exit(1). Dedupes the 3 error-path tests so each one - // is a single readable assertion. + // Keep malformed input assertions focused on the typed command boundary. function expectExitOne(content: string): void { - const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { - throw new Error("EXIT_CALLED"); - }); - try { - expect(() => parseBatchFile(writeBatch(content))).toThrow(/EXIT_CALLED/); - expect(exitSpy).toHaveBeenCalledWith(1); - } finally { - exitSpy.mockRestore(); - } + expect(() => parseBatchFile(writeBatch(content))).toThrow(CliRuntimeError); } it("exits with a clear message on malformed JSON, naming the offending line", () => { diff --git a/packages/cli/src/commands/lambda/render-batch.ts b/packages/cli/src/commands/lambda/render-batch.ts index 2c5ad177c3..7039e2f3b0 100644 --- a/packages/cli/src/commands/lambda/render-batch.ts +++ b/packages/cli/src/commands/lambda/render-batch.ts @@ -1,3 +1,4 @@ +import { failCommand, setCommandExitCode } from "../../utils/commandResult.js"; /** * `hyperframes lambda render-batch --batch ` — * fan out N personalised renders of the same project, one per JSONL line. @@ -160,12 +161,12 @@ export async function runRenderBatch(args: RenderBatchArgs): Promise { const batchPath = resolvePath(args.batch); if (!existsSync(batchPath)) { errorBox("Batch file not found", `No such file: ${batchPath}`); - process.exit(1); + failCommand(); } const entries = parseBatchFile(batchPath); if (entries.length === 0) { errorBox("Empty batch", `${batchPath} contains zero entries (every line was blank).`); - process.exit(1); + failCommand(); } warnOnDimensionMismatch({ @@ -206,7 +207,7 @@ export async function runRenderBatch(args: RenderBatchArgs): Promise { "Variable validation failed", "Aborting batch due to variable issues in one or more entries (--strict-variables mode).", ); - process.exit(1); + failCommand(); } const config: SerializableDistributedRenderConfig = { @@ -330,7 +331,7 @@ export async function runRenderBatch(args: RenderBatchArgs): Promise { : (row.executionArn ?? c.dim("(no execution)")); console.log(` ${tag} line ${row.inputLine} ${c.dim(row.outputKey)} ${detail}`); } - if (failed > 0) process.exitCode = 1; + if (failed > 0) setCommandExitCode(1); } /** @@ -373,14 +374,14 @@ export function parseBatchFile(path: string): Array<{ entry: BatchEntry; lineNum parsed = JSON.parse(line); } catch (err) { errorBox(`Invalid JSON in batch file on line ${i + 1}`, normalizeErrorMessage(err)); - process.exit(1); + failCommand(); } if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { errorBox( `Invalid batch entry on line ${i + 1}`, 'Each line must be a JSON object with at least { "outputKey": "..." }.', ); - process.exit(1); + failCommand(); } const obj = parsed as Record; const outputKey = obj.outputKey; @@ -389,7 +390,7 @@ export function parseBatchFile(path: string): Array<{ entry: BatchEntry; lineNum `Missing outputKey on line ${i + 1}`, 'Each batch entry needs a non-empty "outputKey" string (e.g. "renders/alice.mp4").', ); - process.exit(1); + failCommand(); } if (obj.variables !== undefined) { if ( @@ -401,7 +402,7 @@ export function parseBatchFile(path: string): Array<{ entry: BatchEntry; lineNum `Invalid variables on line ${i + 1}`, '"variables" must be a JSON object (or omitted).', ); - process.exit(1); + failCommand(); } } if (obj.executionName !== undefined && typeof obj.executionName !== "string") { @@ -409,7 +410,7 @@ export function parseBatchFile(path: string): Array<{ entry: BatchEntry; lineNum `Invalid executionName on line ${i + 1}`, '"executionName" must be a string (or omitted).', ); - process.exit(1); + failCommand(); } out.push({ entry: { diff --git a/packages/cli/src/commands/lambda/render.ts b/packages/cli/src/commands/lambda/render.ts index d9b31e2c97..831d357f76 100644 --- a/packages/cli/src/commands/lambda/render.ts +++ b/packages/cli/src/commands/lambda/render.ts @@ -1,3 +1,4 @@ +import { setCommandExitCode } from "../../utils/commandResult.js"; /** * `hyperframes lambda render ` — start a distributed render * against the deployed stack. Wraps {@link renderToLambda}. Does NOT @@ -222,7 +223,7 @@ async function waitForCompletion( for (const err of progress.errors) { console.log(` ${c.dim(err.state)}: ${err.error} — ${err.cause}`); } - process.exitCode = 1; + setCommandExitCode(1); } return; } diff --git a/packages/cli/src/commands/lambda/state.ts b/packages/cli/src/commands/lambda/state.ts index ccece6a0d2..f55c53e53c 100644 --- a/packages/cli/src/commands/lambda/state.ts +++ b/packages/cli/src/commands/lambda/state.ts @@ -1,3 +1,4 @@ +import { failCommand } from "../../utils/commandResult.js"; /** * Persists `hyperframes lambda` stack outputs (bucket, state-machine ARN, * region) so `render` / `progress` / `destroy` don't need to re-derive @@ -93,7 +94,7 @@ export function requireStack(stackName: string, cwd: string = process.cwd()): St console.error( `[hyperframes lambda] no stack state for "${stackName}" at ${stateFilePath(stackName, cwd)}. ${hint}`, ); - process.exit(1); + failCommand(); } return stack; } diff --git a/packages/cli/src/commands/layout.ts b/packages/cli/src/commands/layout.ts index a7025d787e..ecc5ceaa05 100644 --- a/packages/cli/src/commands/layout.ts +++ b/packages/cli/src/commands/layout.ts @@ -1,3 +1,4 @@ +import { failCommand, setCommandExitCode } from "../utils/commandResult.js"; import { defineCommand } from "citty"; import { existsSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; @@ -405,7 +406,7 @@ function resolveMotionSpec(specPath: string, json: boolean): MotionSpec { } else { console.error(`${c.error("✗")} ${message}`); } - process.exit(1); + failCommand(); } export function parseAt(value: unknown): number[] | undefined { @@ -561,7 +562,8 @@ export function createInspectCommand(commandName: "inspect" | "layout") { 2, ), ); - process.exit(ok ? 0 : 1); + setCommandExitCode(ok ? 0 : 1); + return; } if (result.samples.length === 0) { @@ -569,7 +571,7 @@ export function createInspectCommand(commandName: "inspect" | "layout") { console.log( `${c.error("✗")} Could not determine composition duration — no layout samples run`, ); - process.exit(1); + failCommand(); } console.log(); @@ -600,7 +602,8 @@ export function createInspectCommand(commandName: "inspect" | "layout") { const suffix = limited.truncated ? c.dim(`, truncated at ${maxIssues} issue(s)`) : ""; console.log(`${ok ? c.success("◇") : c.error("◇")} ${parts.join(", ")}${suffix}`); - process.exit(ok ? 0 : 1); + setCommandExitCode(ok ? 0 : 1); + return; } catch (err) { const message = normalizeErrorMessage(err); if (args.json) { @@ -623,10 +626,10 @@ export function createInspectCommand(commandName: "inspect" | "layout") { 2, ), ); - process.exit(1); + failCommand(); } console.error(`${c.error("✗")} Inspect failed: ${message}`); - process.exit(1); + failCommand(); } }, }); diff --git a/packages/cli/src/commands/lint.test.ts b/packages/cli/src/commands/lint.test.ts index 60931ae31d..6844f49612 100644 --- a/packages/cli/src/commands/lint.test.ts +++ b/packages/cli/src/commands/lint.test.ts @@ -1,3 +1,4 @@ +// fallow-ignore-file code-duplication // Regression: `lint --json` used process.exit() right after console.log(JSON). // process.exit() terminates before Node flushes an async (non-TTY / piped) // stdout, so piping `hyperframes lint --json` on Windows silently lost the whole @@ -6,6 +7,7 @@ // right exitCode, for the success, error-findings, and thrown-error paths. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { consumeCommandResult } from "../utils/commandResult.js"; const lintProjectMock = vi.fn(); @@ -28,10 +30,8 @@ function run(args: Record): Promise { } describe("lint command exit handling", () => { - const origExitCode = process.exitCode; - beforeEach(() => { - process.exitCode = undefined; + consumeCommandResult(); vi.spyOn(console, "log").mockImplementation(() => {}); vi.spyOn(console, "error").mockImplementation(() => {}); // If run() ever calls process.exit, fail loudly (that's the bug). @@ -42,7 +42,7 @@ describe("lint command exit handling", () => { afterEach(() => { vi.restoreAllMocks(); - process.exitCode = origExitCode; + consumeCommandResult(); }); it("--json with errors sets exitCode 1 and does NOT call process.exit", async () => { @@ -54,7 +54,7 @@ describe("lint command exit handling", () => { }); await run({ json: true, verbose: false }); expect(vi.mocked(process.exit)).not.toHaveBeenCalled(); - expect(process.exitCode).toBe(1); + expect(consumeCommandResult().exitCode).toBe(1); }); it("--json when clean sets exitCode 0 and does NOT call process.exit", async () => { @@ -66,14 +66,14 @@ describe("lint command exit handling", () => { }); await run({ json: true, verbose: false }); expect(vi.mocked(process.exit)).not.toHaveBeenCalled(); - expect(process.exitCode).toBe(0); + expect(consumeCommandResult().exitCode).toBe(0); }); it("--json on a thrown error sets exitCode 1 and does NOT call process.exit", async () => { lintProjectMock.mockRejectedValue(new Error("boom")); await run({ json: true, verbose: false }); expect(vi.mocked(process.exit)).not.toHaveBeenCalled(); - expect(process.exitCode).toBe(1); + expect(consumeCommandResult().exitCode).toBe(1); }); it("human-readable path with errors sets exitCode 1 without process.exit", async () => { @@ -85,6 +85,6 @@ describe("lint command exit handling", () => { }); await run({ json: false, verbose: false }); expect(vi.mocked(process.exit)).not.toHaveBeenCalled(); - expect(process.exitCode).toBe(1); + expect(consumeCommandResult().exitCode).toBe(1); }); }); diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index f00a012bb5..b592fd1e50 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -1,3 +1,4 @@ +import { setCommandExitCode } from "../utils/commandResult.js"; import { defineCommand } from "citty"; import type { Example } from "./_examples.js"; import { c } from "../ui/colors.js"; @@ -57,7 +58,7 @@ export default defineCommand({ filesScanned: lintResult.results.length, }; console.log(JSON.stringify(withMeta(combined), null, 2)); - process.exitCode = combined.ok ? 0 : 1; + setCommandExitCode(combined.ok ? 0 : 1); return; } @@ -79,7 +80,7 @@ export default defineCommand({ }); for (const line of lines) console.log(line); - process.exitCode = lintResult.totalErrors > 0 ? 1 : 0; + setCommandExitCode(lintResult.totalErrors > 0 ? 1 : 0); return; } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); @@ -99,11 +100,11 @@ export default defineCommand({ 2, ), ); - process.exitCode = 1; + setCommandExitCode(1); return; } console.error(message); - process.exitCode = 1; + setCommandExitCode(1); } }, }); diff --git a/packages/cli/src/commands/play.ts b/packages/cli/src/commands/play.ts index ca23b55e5b..d7035830f2 100644 --- a/packages/cli/src/commands/play.ts +++ b/packages/cli/src/commands/play.ts @@ -1,3 +1,4 @@ +import { setCommandExitCode } from "../utils/commandResult.js"; import { defineCommand } from "citty"; import type { Example } from "./_examples.js"; import { existsSync, readFileSync } from "node:fs"; @@ -60,7 +61,7 @@ export default defineCommand({ // Validation: --user-data-dir requires --browser-path if (args["user-data-dir"] && !args["browser-path"]) { clack.log.error("--user-data-dir requires --browser-path"); - process.exitCode = 1; + setCommandExitCode(1); return; } // Validation: --remote-debugging-port deps @@ -71,7 +72,7 @@ export default defineCommand({ }); if (depsError) { clack.log.error(depsError); - process.exitCode = 1; + setCommandExitCode(1); return; } // Parse --remote-debugging-port before any server setup so an invalid value @@ -83,7 +84,7 @@ export default defineCommand({ ); } catch (err) { clack.log.error((err as Error).message); - process.exitCode = 1; + setCommandExitCode(1); return; } @@ -91,7 +92,7 @@ export default defineCommand({ const runtimePath = resolveRuntimePath(); if (!runtimePath) { clack.log.error("HyperFrames runtime not found. Run `bun run build` first."); - process.exitCode = 1; + setCommandExitCode(1); return; } @@ -101,7 +102,7 @@ export default defineCommand({ clack.log.error( "@hyperframes/player not found. Run `bun run --cwd packages/player build` first.", ); - process.exitCode = 1; + setCommandExitCode(1); return; } diff --git a/packages/cli/src/commands/present.ts b/packages/cli/src/commands/present.ts index edd974aff6..6abe35e393 100644 --- a/packages/cli/src/commands/present.ts +++ b/packages/cli/src/commands/present.ts @@ -1,3 +1,4 @@ +import { setCommandExitCode } from "../utils/commandResult.js"; import { defineCommand } from "citty"; import type { Example } from "./_examples.js"; import { existsSync, readFileSync } from "node:fs"; @@ -50,7 +51,7 @@ export default defineCommand({ if (args["user-data-dir"] && !args["browser-path"]) { clack.log.error("--user-data-dir requires --browser-path"); - process.exitCode = 1; + setCommandExitCode(1); return; } const depsError = validateRemoteDebuggingPortDeps({ @@ -60,7 +61,7 @@ export default defineCommand({ }); if (depsError) { clack.log.error(depsError); - process.exitCode = 1; + setCommandExitCode(1); return; } let remoteDebuggingPort: number | undefined; @@ -70,7 +71,7 @@ export default defineCommand({ ); } catch (err) { clack.log.error((err as Error).message); - process.exitCode = 1; + setCommandExitCode(1); return; } @@ -80,7 +81,7 @@ export default defineCommand({ clack.log.error( "@hyperframes/player not found. Run `bun run --cwd packages/player build` first.", ); - process.exitCode = 1; + setCommandExitCode(1); return; } @@ -94,7 +95,7 @@ export default defineCommand({ `No slideshow island found in ${project.indexPath}. ` + `Add a