refactor(cli): centralize process lifecycle

This commit is contained in:
James
2026-07-20 09:04:03 -07:00
parent 6ad738b580
commit 3aa2404747
71 changed files with 812 additions and 434 deletions
+3 -2
View File
@@ -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) {
+6 -9
View File
@@ -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");
});
+8 -7
View File
@@ -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<void> {
// (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<void> {
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<void> {
} 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<void> {
// 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<void> {
if (!user) {
trackAuthLoginFailed("api_key", "rejected");
await rollback(previous);
process.exit(1);
failCommand();
}
const id = identityKey(user);
if (id) identifyUser(id);
+2 -1
View File
@@ -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
+3 -2
View File
@@ -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;
}
@@ -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<number> {
const cmd = (await import("./status.js")).default;
try {
await (cmd.run as (ctx: { args: Record<string, unknown> }) => Promise<void>)({
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<string, unknown> }) => Promise<void>)({
args: { json: asJson },
});
return consumeCommandResult().exitCode;
}
function lastJson(): Record<string, unknown> {
+4 -3
View File
@@ -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<VerifiedStatus> {
+6 -3
View File
@@ -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<Ba
return manifest;
}
// Called through render.ts's lazy batch module; static reachability cannot see it.
// fallow-ignore-next-line unused-export
export function exitBatchRenderInputError(error: unknown): never {
if (error instanceof BatchRenderInputError) {
errorBox(error.title, error.message, error.hint);
process.exit(1);
failCommand();
}
throw error;
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { failCommand } from "../utils/commandResult.js";
import { defineCommand } from "citty";
import { existsSync, readFileSync, mkdirSync, writeFileSync } from "node:fs";
import { resolve, join, dirname } from "node:path";
@@ -14,7 +15,7 @@ export const examples: Example[] = [
function fail(message: string): never {
console.error(c.error(message));
process.exit(1);
failCommand();
}
/** Locate the music track + its on-disk audio, or fail with a clear message. */
+3 -2
View File
@@ -1,3 +1,4 @@
import { failCommand } from "../utils/commandResult.js";
import { defineCommand } from "citty";
import type { Example } from "./_examples.js";
import { existsSync, statSync } from "node:fs";
@@ -64,7 +65,7 @@ export default defineCommand({
const runsPerConfig = parseInt(args.runs ?? "3", 10);
if (isNaN(runsPerConfig) || runsPerConfig < 1 || runsPerConfig > 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 ─────────────────────────────────────────────────────
+4 -3
View File
@@ -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<void> {
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<void> {
} 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();
}
},
});
+5 -4
View File
@@ -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 <project> 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 <name> 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();
}
},
});
+6 -5
View File
@@ -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<void> {
`${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<void> {
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<void> {
`${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<void> {
`${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<void> {
return;
}
console.error(`${c.error("✗")} download failed: ${(e as Error).message}`);
process.exitCode = 1;
setCommandExitCode(1);
}
}
+3 -2
View File
@@ -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({
+5 -8
View File
@@ -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();
});
@@ -384,7 +383,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,
@@ -799,10 +798,8 @@ describe("selectFindingCropRequests", () => {
});
describe("check pipeline", () => {
const originalExitCode = process.exitCode;
afterEach(() => {
process.exitCode = originalExitCode;
consumeCommandResult();
vi.restoreAllMocks();
});
@@ -819,7 +816,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");
+3 -2
View File
@@ -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";
@@ -135,7 +136,7 @@ export function createCheckCommand(
} else {
printHumanReport(report);
}
process.exitCode = checkExitCode(report);
setCommandExitCode(checkExitCode(report));
} catch (error) {
const message = normalizeErrorMessage(error);
if (asJson) {
@@ -145,7 +146,7 @@ export function createCheckCommand(
} else {
console.error(`${c.error("✗")} Check failed: ${message}`);
}
process.exitCode = 1;
setCommandExitCode(1);
}
},
});
+3 -2
View File
@@ -1,3 +1,4 @@
import { failCommand } from "../../utils/commandResult.js";
/**
* `hyperframes cloud delete <render_id>` 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();
+4 -3
View File
@@ -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
+6 -24
View File
@@ -11,6 +11,7 @@ import {
validateResolutionFormatCombo,
type ProjectInputSource,
} from "./render.js";
import { CliRuntimeError } from "../../utils/commandResult.js";
const cliEntry = resolve(fileURLToPath(import.meta.url), "..", "..", "..", "cli.ts");
@@ -23,13 +24,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(
@@ -42,14 +36,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();
@@ -128,7 +119,6 @@ describe("cloud render --dry-run", () => {
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" };
@@ -138,7 +128,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",
@@ -150,32 +139,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(
@@ -184,7 +167,6 @@ describe("resolveAspectRatioForSubmit — local dir", () => {
undefined,
true,
),
).toThrow("process.exit:1");
expect(exit).toHaveBeenCalled();
).toThrow(CliRuntimeError);
});
});
+11 -10
View File
@@ -1,3 +1,4 @@
import { failCommand } from "../../utils/commandResult.js";
/**
* `hyperframes cloud render` orchestrate a cloud-rendered HyperFrames
* composition end-to-end:
@@ -317,7 +318,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);
@@ -359,7 +360,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();
}
}
@@ -400,7 +401,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 };
@@ -450,7 +451,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);
@@ -474,7 +475,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;
}
@@ -498,7 +499,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();
}
}
@@ -805,7 +806,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}`,
@@ -827,14 +828,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 {
@@ -870,6 +871,6 @@ async function streamVideo(
message,
"The presigned URL is short-lived; re-fetch with `hyperframes cloud get`.",
);
process.exit(1);
failCommand();
}
}
+23 -21
View File
@@ -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.
@@ -244,7 +246,7 @@ export default defineCommand({
} catch (error) {
if (isMissingCloudRunAdapterError(error)) {
console.error(missingCloudRunAdapterMessage(subcommand));
process.exit(1);
failCommand();
}
throw error;
}
@@ -264,7 +266,7 @@ export default defineCommand({
return runDestroy(args);
default:
console.error(`${c.error("Unknown subcommand:")} ${subcommand}\n${HELP}`);
process.exit(1);
failCommand();
}
},
});
@@ -303,7 +305,7 @@ function readState(args: Record<string, unknown>): StackState {
`[cloudrun] missing stack coordinates: ${missing.join(", ")}. ` +
`Run \`hyperframes cloudrun deploy --project <id>\` first, or pass them as flags.`,
);
process.exit(1);
failCommand();
}
return merged as StackState;
}
@@ -332,7 +334,7 @@ async function runDeploy(args: Record<string, unknown>): Promise<void> {
const project = args.project as string | undefined;
if (!project) {
console.error("[cloudrun deploy] --project <gcp-project-id> is required.");
process.exit(1);
failCommand();
}
const region = (args.region as string | undefined) ?? "us-central1";
const repo = (args.repo as string | undefined) ?? "hyperframes";
@@ -360,7 +362,7 @@ async function runDeploy(args: Record<string, unknown>): Promise<void> {
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 =
@@ -497,12 +499,12 @@ async function runSites(args: Record<string, unknown>): Promise<void> {
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 <projectDir>");
process.exit(1);
failCommand();
}
const state = readState(args);
const { deploySite } = await loadCloudRunAdapter();
@@ -530,19 +532,19 @@ async function runRender(args: Record<string, unknown>): Promise<void> {
console.error(
"[cloudrun render] usage: hyperframes cloudrun render <projectDir> --width <px> --height <px>",
);
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));
@@ -590,7 +592,7 @@ async function runRender(args: Record<string, unknown>): Promise<void> {
} else {
console.error(`${c.error("✗ render " + progress.status)}`);
for (const e of progress.errors) console.error(` ${e.state}: ${e.cause}`);
process.exit(1);
failCommand();
}
}
@@ -601,7 +603,7 @@ async function runProgress(args: Record<string, unknown>): Promise<void> {
const executionName = args.target as string | undefined;
if (!executionName) {
console.error("[cloudrun progress] usage: hyperframes cloudrun progress <executionName>");
process.exit(1);
failCommand();
}
const { getRenderProgress } = await loadCloudRunAdapter();
const progress = await getRenderProgress({ executionName });
@@ -641,28 +643,28 @@ async function runRenderBatch(args: Record<string, unknown>): Promise<void> {
console.error(
"[cloudrun render-batch] usage: hyperframes cloudrun render-batch <projectDir> --batch <file.jsonl> --width <px> --height <px>",
);
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"]);
@@ -729,7 +731,7 @@ async function runRenderBatch(args: Record<string, unknown>): Promise<void> {
);
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. */
@@ -745,7 +747,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 ||
@@ -755,7 +757,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);
});
@@ -776,7 +778,7 @@ async function runDestroy(args: Record<string, unknown>): Promise<void> {
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",
+2 -1
View File
@@ -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();
}
},
});
@@ -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<T extends ArgsDef = ArgsDef>(command: CommandDef
throw new Error("expected a synchronous meta object");
}
async function runExpectedFailure<T extends ArgsDef>(
command: CommandDef<T>,
rawArgs: string[],
): Promise<void> {
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<T extends ArgsDef = ArgsDef>(
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<T extends ArgsDef = ArgsDef>(
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("{"));
}
+3 -2
View File
@@ -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");
+2 -1
View File
@@ -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";
@@ -158,7 +159,7 @@ export default defineCommand({
const rating = parseFeedbackRating(args.rating);
if (rating === null) {
console.error(c.error("Rating must be an integer between 0 and 10"));
process.exit(1);
failCommand();
}
if (!shouldTrack()) {
+2 -1
View File
@@ -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
@@ -37,7 +38,7 @@ export async function withFigmaErrors(command: string, fn: () => Promise<void>):
}
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;
}
+2 -1
View File
@@ -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 });
+18 -17
View File
@@ -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";
@@ -433,7 +434,7 @@ async function handleVideoFile(
});
if (clack.isCancel(transcode)) {
clack.cancel("Setup cancelled.");
process.exit(0);
finishCommand(0);
}
shouldTranscode = transcode === "yes";
}
@@ -737,7 +738,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(
@@ -745,12 +746,12 @@ 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;
if (exampleFlag?.startsWith("-")) {
console.error(c.error(`--example requires a value; received flag "${exampleFlag}" instead.`));
process.exit(1);
failCommand();
}
const videoFlag = args.video;
const audioFlag = args.audio;
@@ -796,7 +797,7 @@ export default defineCommand({
`(or aliases 1080p, 4k, uhd, 1080p-square, square-1080p, 4k-square).`,
),
);
process.exit(1);
failCommand();
}
}
@@ -811,7 +812,7 @@ export default defineCommand({
"For an empty starter project, pass --example blank explicitly.",
),
);
process.exit(1);
failCommand();
}
const templateId = exampleFlag ?? "blank";
@@ -820,12 +821,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
@@ -834,12 +835,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 });
@@ -905,7 +906,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");
@@ -977,7 +978,7 @@ export default defineCommand({
});
if (clack.isCancel(nameResult)) {
clack.cancel("Setup cancelled.");
process.exit(0);
finishCommand(0);
}
name = nameResult;
}
@@ -991,7 +992,7 @@ export default defineCommand({
});
if (clack.isCancel(overwrite) || !overwrite) {
clack.cancel("Setup cancelled.");
process.exit(0);
finishCommand(0);
}
}
@@ -1005,7 +1006,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;
@@ -1017,7 +1018,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;
@@ -1091,7 +1092,7 @@ export default defineCommand({
});
if (clack.isCancel(templateResult)) {
clack.cancel("Setup cancelled.");
process.exit(0);
finishCommand(0);
}
templateId = templateResult;
}
@@ -1122,7 +1123,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 });
+14 -13
View File
@@ -1,3 +1,4 @@
import { failCommand } from "../utils/commandResult.js";
/**
* `hyperframes lambda` top-level dispatcher for AWS Lambda subcommands.
*
@@ -238,7 +239,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;
}
@@ -263,14 +264,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 <projectDir>",
);
process.exit(1);
failCommand();
}
const { runSitesCreate } = await import("./lambda/sites.js");
await runSitesCreate({
@@ -287,13 +288,13 @@ export default defineCommand({
console.error(
"[lambda render] usage: hyperframes lambda render <projectDir> --width <px> --height <px>",
);
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) ??
@@ -301,7 +302,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");
const renderResolution = parseOutputResolution(args["output-resolution"]);
@@ -337,20 +338,20 @@ export default defineCommand({
console.error(
"[lambda render-batch] usage: hyperframes lambda render-batch <projectDir> --batch <path.jsonl> --width <px> --height <px>",
);
process.exit(1);
failCommand();
}
const batch = args.batch as string | undefined;
if (!batch) {
console.error(
"[lambda render-batch] --batch <path.jsonl> 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) ??
@@ -358,7 +359,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");
const batchResolution = parseOutputResolution(args["output-resolution"]);
@@ -391,7 +392,7 @@ export default defineCommand({
console.error(
"[lambda progress] usage: hyperframes lambda progress <renderId | executionArn>",
);
process.exit(1);
failCommand();
}
const { runProgress } = await import("./lambda/progress.js");
await runProgress({ target, stackName, json: Boolean(args.json) });
@@ -408,7 +409,7 @@ export default defineCommand({
console.error(
`[lambda policies] usage: hyperframes lambda policies <role|user|validate> [args]`,
);
process.exit(1);
failCommand();
}
const { runPolicies } = await import("./lambda/policies.js");
await runPolicies({
@@ -420,7 +421,7 @@ export default defineCommand({
}
default:
console.error(`${c.error("Unknown subcommand:")} ${subcommand}\n${HELP}`);
process.exit(1);
failCommand();
}
},
});
+6 -5
View File
@@ -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<void> {
"[lambda policies validate] usage: hyperframes lambda policies validate <policy.json>";
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<void> {
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<void> {
console.log(
c.dim("Run `hyperframes lambda policies user` to print the full required policy."),
);
process.exitCode = 1;
setCommandExitCode(1);
return;
}
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { setCommandExitCode } from "../../utils/commandResult.js";
/**
* `hyperframes lambda progress <executionArn>` print a single progress
* snapshot for a render. Wraps {@link getRenderProgress}. Accepts a
@@ -60,7 +61,7 @@ export async function runProgress(args: ProgressArgs): Promise<void> {
}
}
if (progress.fatalErrorEncountered) {
process.exitCode = 1;
setCommandExitCode(1);
}
}
@@ -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 {
buildLambdaBatchRenderConfig,
parseBatchFile,
@@ -115,19 +116,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", () => {
@@ -1,3 +1,4 @@
import { failCommand, setCommandExitCode } from "../../utils/commandResult.js";
/**
* `hyperframes lambda render-batch <projectDir> --batch <path.jsonl>`
* fan out N personalised renders of the same project, one per JSONL line.
@@ -167,12 +168,12 @@ export async function runRenderBatch(args: RenderBatchArgs): Promise<void> {
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({
@@ -213,7 +214,7 @@ export async function runRenderBatch(args: RenderBatchArgs): Promise<void> {
"Variable validation failed",
"Aborting batch due to variable issues in one or more entries (--strict-variables mode).",
);
process.exit(1);
failCommand();
}
const config: SerializableDistributedRenderConfig = buildLambdaBatchRenderConfig(args);
@@ -325,7 +326,7 @@ export async function runRenderBatch(args: RenderBatchArgs): Promise<void> {
: (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);
}
/**
@@ -396,14 +397,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<string, unknown>;
const outputKey = obj.outputKey;
@@ -412,7 +413,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 (
@@ -424,7 +425,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") {
@@ -432,7 +433,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: {
+2 -1
View File
@@ -1,3 +1,4 @@
import { setCommandExitCode } from "../../utils/commandResult.js";
/**
* `hyperframes lambda render <projectDir>` start a distributed render
* against the deployed stack. Wraps {@link renderToLambda}. Does NOT
@@ -218,7 +219,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;
}
+2 -1
View File
@@ -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;
}
+9 -6
View File
@@ -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";
@@ -412,7 +413,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 {
@@ -568,7 +569,8 @@ export function createInspectCommand(commandName: "inspect" | "layout") {
2,
),
);
process.exit(ok ? 0 : 1);
setCommandExitCode(ok ? 0 : 1);
return;
}
if (result.samples.length === 0) {
@@ -576,7 +578,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();
@@ -607,7 +609,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) {
@@ -630,10 +633,10 @@ export function createInspectCommand(commandName: "inspect" | "layout") {
2,
),
);
process.exit(1);
failCommand();
}
console.error(`${c.error("✗")} Inspect failed: ${message}`);
process.exit(1);
failCommand();
}
},
});
+8 -8
View File
@@ -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<string, unknown>): Promise<unknown> {
}
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);
});
});
+5 -4
View File
@@ -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);
}
},
});
+6 -5
View File
@@ -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";
@@ -86,7 +87,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
@@ -97,7 +98,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
@@ -109,7 +110,7 @@ export default defineCommand({
);
} catch (err) {
clack.log.error((err as Error).message);
process.exitCode = 1;
setCommandExitCode(1);
return;
}
@@ -117,7 +118,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;
}
@@ -127,7 +128,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;
}
+7 -6
View File
@@ -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 <script type="application/hyperframes-slideshow+json"> block — see /hyperframes (slideshow).`,
);
process.exitCode = 1;
setCommandExitCode(1);
return;
}
const islandJson = islandMatch[1].trim();
@@ -106,7 +107,7 @@ export default defineCommand({
clack.log.error(
`Slideshow island in ${project.indexPath} is not valid JSON: ${(err as Error).message}`,
);
process.exitCode = 1;
setCommandExitCode(1);
return;
}
+12 -11
View File
@@ -1,3 +1,4 @@
import { setCommandExitCode, requestCliExit } from "../utils/commandResult.js";
import { defineCommand } from "citty";
import type { Example } from "./_examples.js";
import { spawn, type ChildProcessByStdio } from "node:child_process";
@@ -299,7 +300,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
@@ -310,7 +311,7 @@ export default defineCommand({
});
if (depsError) {
clack.log.error(depsError);
process.exitCode = 1;
setCommandExitCode(1);
return;
}
@@ -321,7 +322,7 @@ export default defineCommand({
clack.log.error(
"--browser-no-gpu requires --browser-path (the system default browser cannot receive Chromium flags — use --no-open on GPU-unstable hosts)",
);
process.exitCode = 1;
setCommandExitCode(1);
return;
}
const userDataDir = args["user-data-dir"] as string | undefined;
@@ -332,7 +333,7 @@ export default defineCommand({
);
} catch (err) {
clack.log.error((err as Error).message);
process.exitCode = 1;
setCommandExitCode(1);
return;
}
// Resolve once so embedded, monorepo-dev, and locally installed Studio
@@ -342,7 +343,7 @@ export default defineCommand({
if (isDevMode()) {
if (args.background) {
clack.log.error("--background currently supports the embedded preview server only");
process.exitCode = 1;
setCommandExitCode(1);
return;
}
return runDevMode(dir, {
@@ -360,7 +361,7 @@ export default defineCommand({
if (hasLocalStudio(dir)) {
if (args.background) {
clack.log.error("--background currently supports the embedded preview server only");
process.exitCode = 1;
setCommandExitCode(1);
return;
}
return runLocalStudioMode(dir, {
@@ -382,7 +383,7 @@ export default defineCommand({
});
} catch (error) {
clack.log.error(errorMessage(error));
process.exitCode = 1;
setCommandExitCode(1);
return;
}
const url = `http://localhost:${background.port}`;
@@ -441,7 +442,7 @@ function printSelectionFailure(code: string, message: string, json: boolean): vo
} else {
clack.log.error(message);
}
process.exitCode = 1;
setCommandExitCode(1);
}
function previewServerPayload(server: {
@@ -1037,7 +1038,7 @@ async function runEmbeddedMode(
console.error();
console.error(` ${c.dim("Rebuild the CLI package with")} ${c.accent("bun run build")}`);
console.error();
process.exitCode = 1;
setCommandExitCode(1);
return;
}
@@ -1062,7 +1063,7 @@ async function runEmbeddedMode(
console.error();
console.error(` ${(err as Error).message}`);
console.error();
process.exitCode = 1;
setCommandExitCode(1);
return;
}
@@ -1125,7 +1126,7 @@ async function runEmbeddedMode(
// Hard deadline: if cleanup hangs (e.g. dead Chrome never responds to
// browser.close()), force exit. Armed before awaiting cleanup so it
// can't be blocked by a stuck drainBrowserPool().
setTimeout(() => process.exit(0), 3000).unref();
setTimeout(() => requestCliExit(0), 3000).unref();
// Kill ffmpeg first (sync, fast), then drain browsers (async, slower).
const cleanup = async () => {
+3 -2
View File
@@ -1,4 +1,5 @@
import { join, relative, resolve } from "node:path";
import { setCommandExitCode } from "../utils/commandResult.js";
import { existsSync } from "node:fs";
import { defineCommand } from "citty";
import * as clack from "@clack/prompts";
@@ -128,7 +129,7 @@ export default defineCommand({
` ${c.error(`${updateTarget ? "--update" : "--space"} requires authentication. Run 'hyperframes auth login' first.`)}`,
);
console.log();
process.exitCode = 1;
setCommandExitCode(1);
return;
}
}
@@ -252,7 +253,7 @@ export default defineCommand({
console.error();
console.error(` ${(err as Error).message}`);
console.error();
process.exitCode = 1;
setCommandExitCode(1);
return;
}
},
@@ -1,3 +1,4 @@
import { failCommand } from "../utils/commandResult.js";
import { defineCommand } from "citty";
import { resolve } from "node:path";
import { existsSync } from "node:fs";
@@ -94,23 +95,23 @@ export default defineCommand({
"Input file is required. Run `hyperframes remove-background --info` for providers.",
),
);
process.exit(1);
failCommand();
}
if (!args.output) {
console.error(c.error("--output (-o) is required. Use a .webm, .mov, or .png path."));
process.exit(1);
failCommand();
}
if (!isDevice(args.device)) {
console.error(
c.error(`Invalid --device '${String(args.device)}'. Use: ${DEVICES.join(", ")}.`),
);
process.exit(1);
failCommand();
}
if (!isQuality(args.quality)) {
console.error(
c.error(`Invalid --quality '${String(args.quality)}'. Use: ${QUALITIES.join(", ")}.`),
);
process.exit(1);
failCommand();
}
const inputPath = resolve(args.input);
@@ -184,7 +185,7 @@ export default defineCommand({
} else {
spin?.stop(c.error(`Background removal failed: ${message}`));
}
process.exit(1);
failCommand();
}
},
});
+5 -9
View File
@@ -677,13 +677,10 @@ describe("renderLocal browser GPU config", () => {
expect(producerState.createdJobs[0]?.outputResolution).toBeUndefined();
});
it("can force the CLI process to exit after a successful local render", async () => {
it("requests a root-owned CLI exit after a successful local render", async () => {
vi.useFakeTimers();
const exit = vi
.spyOn(process, "exit")
.mockImplementation((code?: string | number | null): never => {
throw new Error(`process.exit:${code ?? ""}`);
});
const { consumeCommandResult } = await import("../utils/commandResult.js");
consumeCommandResult();
await renderLocal("/tmp/project", "/tmp/out.mp4", {
fps: { num: 30, den: 1 },
@@ -696,9 +693,8 @@ describe("renderLocal browser GPU config", () => {
exitAfterComplete: true,
});
expect(exit).not.toHaveBeenCalled();
expect(() => vi.advanceTimersByTime(100)).toThrow("process.exit:0");
expect(exit).toHaveBeenCalledWith(0);
vi.advanceTimersByTime(100);
expect(consumeCommandResult().exitCode).toBe(0);
});
});
+33 -31
View File
@@ -1,3 +1,4 @@
import { failCommand, setCommandExitCode, requestCliExit } from "../utils/commandResult.js";
import { defineCommand } from "citty";
import type { Example } from "./_examples.js";
import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, rmSync } from "node:fs";
@@ -445,7 +446,7 @@ export default defineCommand({
const fpsParse = parseFps(fpsArg ?? "30");
if (!fpsParse.ok) {
errorBox("Invalid fps", formatFpsParseError(fpsArg ?? "30", fpsParse.reason));
process.exit(1);
failCommand();
}
let fps: Fps = fpsParse.value;
@@ -453,7 +454,7 @@ export default defineCommand({
const qualityRaw = args.quality ?? "standard";
if (!VALID_QUALITY.has(qualityRaw)) {
errorBox("Invalid quality", `Got "${qualityRaw}". Must be draft, standard, or high.`);
process.exit(1);
failCommand();
}
const quality = qualityRaw as "draft" | "standard" | "high";
@@ -478,7 +479,7 @@ export default defineCommand({
const format = parseRenderFormat(formatRaw);
if (!format) {
errorBox("Invalid format", `Got "${formatRaw}". Must be ${RENDER_FORMAT_LABEL}.`);
process.exit(1);
failCommand();
}
let gifFpsCapped = false;
@@ -490,7 +491,7 @@ export default defineCommand({
const gifLoopParse = parseGifLoopArg(args["gif-loop"]);
if (!gifLoopParse.ok) {
errorBox("Invalid gif-loop", gifLoopParse.message);
process.exit(1);
failCommand();
}
const gifLoop = gifLoopParse.value ?? (format === "gif" ? 0 : undefined);
@@ -500,7 +501,7 @@ export default defineCommand({
"Invalid video-frame-format",
`Got "${videoFrameFormatRaw}". Must be auto, jpg, or png.`,
);
process.exit(1);
failCommand();
}
const videoFrameFormat = videoFrameFormatRaw;
@@ -523,7 +524,7 @@ export default defineCommand({
`Got "${args.resolution}". Must be one of: landscape, portrait, landscape-4k, portrait-4k, square, square-4k ` +
`(or aliases 1080p, 4k, uhd, 1080p-square, square-1080p, 4k-square).`,
);
process.exit(1);
failCommand();
}
outputResolutionAspectAgnostic = isAspectAgnosticResolutionAlias(args.resolution);
// Reject the --resolution + --hdr combination at the CLI layer so the
@@ -536,7 +537,7 @@ export default defineCommand({
"--resolution cannot be combined with --hdr. The HDR pipeline composites at composition dimensions and does not yet support supersampling.",
"Render in two passes: HDR at composition resolution, then upscale separately with ffmpeg.",
);
process.exit(1);
failCommand();
}
}
@@ -546,7 +547,7 @@ export default defineCommand({
const parsed = parseInt(args.workers, 10);
if (isNaN(parsed) || parsed < 1) {
errorBox("Invalid workers", `Got "${args.workers}". Must be a positive number or "auto".`);
process.exit(1);
failCommand();
}
workers = parsed;
}
@@ -560,7 +561,7 @@ export default defineCommand({
"Invalid protocol-timeout",
`Got "${args["protocol-timeout"]}". Must be a number >= 1000 (ms).`,
);
process.exit(1);
failCommand();
}
protocolTimeout = parsed;
}
@@ -572,7 +573,7 @@ export default defineCommand({
"Invalid player-ready-timeout",
`Got "${args["player-ready-timeout"]}". Must be a number >= 1000 (ms).`,
);
process.exit(1);
failCommand();
}
playerReadyTimeout = parsed;
}
@@ -618,7 +619,7 @@ export default defineCommand({
"Invalid max-concurrent-renders",
`Got "${args["max-concurrent-renders"]}". Must be a number between 1 and 10.`,
);
process.exit(1);
failCommand();
}
process.env.PRODUCER_MAX_CONCURRENT_RENDERS = String(parsed);
}
@@ -631,16 +632,16 @@ export default defineCommand({
"Conflicting variables flags",
"Use either --batch or --variables/--variables-file, not both.",
);
process.exit(1);
failCommand();
}
if (!batchPath && args["batch-concurrency"] != null) {
errorBox("Invalid batch-concurrency", "--batch-concurrency requires --batch.");
process.exit(1);
failCommand();
}
if (!batchPath && args["batch-fail-fast"]) {
errorBox("Invalid batch-fail-fast", "--batch-fail-fast requires --batch.");
process.exit(1);
failCommand();
}
let batchConcurrency = 1;
@@ -651,7 +652,7 @@ export default defineCommand({
"Invalid batch-concurrency",
`Got "${args["batch-concurrency"]}". Must be a positive integer.`,
);
process.exit(1);
failCommand();
}
batchConcurrency = parsed;
}
@@ -687,7 +688,7 @@ export default defineCommand({
if (crfRaw != null && videoBitrate) {
errorBox("Conflicting encoder settings", "Use either --crf or --video-bitrate, not both.");
process.exit(1);
failCommand();
}
if (useDocker && browserGpuArg === true) {
@@ -696,7 +697,7 @@ export default defineCommand({
"--browser-gpu uses the host Chrome GPU backend. Docker mode keeps browser rendering deterministic and does not expose a cross-platform Chrome GPU backend.",
"Run without --docker, or use --gpu for Docker GPU encoding where your Docker host supports GPU passthrough.",
);
process.exit(1);
failCommand();
}
let crf: number | undefined;
@@ -704,7 +705,7 @@ export default defineCommand({
const parsed = Number(crfRaw);
if (!Number.isInteger(parsed) || parsed < 0) {
errorBox("Invalid crf", `Got "${crfRaw}". Must be a non-negative integer.`);
process.exit(1);
failCommand();
}
crf = parsed;
}
@@ -718,7 +719,7 @@ export default defineCommand({
"Invalid vp9-cpu-used",
`Got "${raw}". Must be an integer between ${MIN_VP9_CPU_USED} and ${MAX_VP9_CPU_USED}.`,
);
process.exit(1);
failCommand();
}
vp9CpuUsed = parsed;
}
@@ -728,7 +729,7 @@ export default defineCommand({
"Invalid video-bitrate",
`Got "${args["video-bitrate"]}". Must be a non-empty bitrate such as "10M".`,
);
process.exit(1);
failCommand();
}
if (!quiet && gifFpsCapped) {
@@ -859,7 +860,7 @@ export default defineCommand({
normalizeErrorMessage(err),
"Run: npx hyperframes browser ensure",
);
process.exit(1);
failCommand();
}
}
@@ -884,7 +885,7 @@ export default defineCommand({
console.log("");
console.log(c.error(` Aborting render due to lint issues (${mode} mode).`));
console.log("");
process.exit(1);
failCommand();
}
console.log(c.dim(renderLintContinuationHint(strictErrors)));
console.log("");
@@ -921,14 +922,14 @@ export default defineCommand({
// gave up — i.e. measure whether the P1-3 fix is doing its job.
trackRenderPreflightRejected({ kind: resolutionIssue.kind });
errorBox("Output resolution incompatible", resolutionIssue.message);
process.exit(1);
failCommand();
}
}
// ── Validate HDR/SDR mutual exclusion ────────────────────────────────
if (args.hdr && args.sdr) {
console.error("Error: --hdr and --sdr are mutually exclusive.");
process.exit(1);
failCommand();
}
// ── Batch render ──────────────────────────────────────────────────────
@@ -988,7 +989,7 @@ export default defineCommand({
variables: row.variables,
}),
});
if (manifest.failed > 0) process.exitCode = 1;
if (manifest.failed > 0) setCommandExitCode(1);
return;
}
@@ -1076,6 +1077,7 @@ export interface SingleRenderResult {
warnings?: Array<{ code: string; message: string }>;
}
// fallow-ignore-next-line unused-export
export function renderLintContinuationHint(strictErrors: boolean): string {
return strictErrors
? " Continuing render despite lint warnings. Use --strict-all to block warnings."
@@ -1400,7 +1402,7 @@ function resolveDockerHostPlatform(options: RenderOptions): string {
"Docker Desktop/colima on Apple Silicon doesn't expose --gpus host passthrough to linux/arm64 containers.",
"Drop --gpu, or run a native (non-Docker) render on this host, or set HYPERFRAMES_DOCKER_PLATFORM=linux/amd64 if you need GPU encoding (slow under qemu but works).",
);
process.exit(1);
failCommand();
}
if (!options.quiet && platform === "linux/arm64") {
@@ -1453,7 +1455,7 @@ async function renderDocker(
? "Install Docker: https://docs.docker.com/get-docker/"
: "Check Docker is running: docker info",
);
process.exit(1);
failCommand();
}
const outputDir = dirname(outputPath);
@@ -1583,7 +1585,7 @@ export async function renderLocal(
for (const check of failedChecks) {
errorBox(check.title ?? `${check.name} check failed`, check.detail, check.hint);
}
process.exit(1);
failCommand();
}
if (!options.quiet) {
for (const outcome of preflight.outcomes) {
@@ -1751,7 +1753,7 @@ function isUnrefableTimer(
}
function scheduleRenderProcessExit(): void {
const timer = setTimeout(() => process.exit(0), 100);
const timer = setTimeout(() => requestCliExit(0), 100);
if (isUnrefableTimer(timer)) timer.unref();
}
@@ -2142,7 +2144,7 @@ function handleRenderError(
const remediation = chromeLaunchRemediation(message);
if (remediation) {
errorBox("Render failed — Chrome could not launch", message, remediation);
process.exit(1);
failCommand();
}
// macOS <13 dyld Symbol-not-found on the pinned chrome-headless-shell
// build. Different remediation shape (older shell + env-var override)
@@ -2153,7 +2155,7 @@ function handleRenderError(
process.exit(1);
}
errorBox("Render failed", message, hint);
process.exit(1);
failCommand();
}
/**
+40 -37
View File
@@ -5,6 +5,16 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EventEmitter } from "node:events";
async function commandExitCode(): Promise<number> {
const { consumeCommandResult } = await import("../utils/commandResult.js");
return consumeCommandResult().exitCode;
}
async function resetCommandResult(): Promise<void> {
const { consumeCommandResult } = await import("../utils/commandResult.js");
consumeCommandResult();
}
type SpawnCall = {
command: string;
args: ReadonlyArray<string>;
@@ -173,8 +183,6 @@ function skillFlagValues(args: ReadonlyArray<string>): string[] {
}
describe("hyperframes skills", () => {
let prevExitCode: typeof process.exitCode;
beforeEach(async () => {
state.execCalls = [];
state.spawnCalls = [];
@@ -197,15 +205,13 @@ describe("hyperframes skills", () => {
vi.mocked(presentSkills).mockImplementation((names: readonly string[]) => [...names]);
vi.mocked(pruneOrphanedLockEntries).mockReset();
vi.mocked(pruneOrphanedLockEntries).mockImplementation(() => []);
// Each test asserts on process.exitCode; isolate it from the runner's own.
prevExitCode = process.exitCode;
process.exitCode = 0;
await resetCommandResult();
});
afterEach(() => {
afterEach(async () => {
setPlatform(originalPlatform);
vi.restoreAllMocks();
process.exitCode = prevExitCode;
await resetCommandResult();
});
it("sets clone-safe env on the spawned skills CLI child (GH #316 + LFS skip)", async () => {
@@ -290,13 +296,13 @@ describe("hyperframes skills", () => {
setPlatform("linux");
state.spawnExitCode = 1; // simulate `skills add` exiting non-zero
await runSkillsUpdate();
expect(process.exitCode).toBe(1);
expect(await commandExitCode()).toBe(1);
});
it("skills update refreshes only the stale core + installed skills — never the full set", async () => {
setPlatform("linux");
await runSkillsUpdate();
expect(process.exitCode).toBe(0);
expect(await commandExitCode()).toBe(0);
const args = state.spawnCalls[0]?.args ?? [];
// straight from GitHub, globally, as a faithful clone
expect(args).toContain("https://github.com/heygen-com/hyperframes");
@@ -350,7 +356,7 @@ describe("hyperframes skills", () => {
await runSkillsUpdate();
expect(state.spawnCalls.some((s) => s.args.includes("add"))).toBe(false);
expect(process.exitCode).toBe(0);
expect(await commandExitCode()).toBe(0);
});
// `skills add` never deletes, so update must separately prune skills the
@@ -375,7 +381,7 @@ describe("hyperframes skills", () => {
expect(removeCall!.args).toContain("graphic-overlays");
expect(removeCall!.args).toContain("--yes");
expect(removeCall!.args).toContain("-g"); // attributed from the global lock → remove globally
expect(process.exitCode).toBe(0);
expect(await commandExitCode()).toBe(0);
});
// The scope the skill was attributed from drives the remove scope: a
@@ -443,7 +449,7 @@ describe("hyperframes skills", () => {
await runSkillsUpdate();
expect(pruneOrphanedLockEntries).toHaveBeenCalledWith(["hyperframes-captions"], "global");
expect(process.exitCode).toBe(0);
expect(await commandExitCode()).toBe(0);
});
// The idempotent-second-run contract at the command level: once nothing is
@@ -463,7 +469,7 @@ describe("hyperframes skills", () => {
vi.mocked(pruneOrphanedLockEntries).mockReturnValueOnce(["hyperframes-captions"]);
await runSkillsUpdate();
expect(process.exitCode).toBe(0);
expect(await commandExitCode()).toBe(0);
expect(state.spawnCalls.some((s) => s.args.includes("remove"))).toBe(true);
// Second run: nothing attributed as removed anymore (the lock entry was
@@ -474,7 +480,7 @@ describe("hyperframes skills", () => {
.mockResolvedValueOnce({ scope: "global", skills: [] } as never);
await runSkillsUpdate();
expect(process.exitCode).toBe(0);
expect(await commandExitCode()).toBe(0);
expect(state.spawnCalls.some((s) => s.args.includes("remove"))).toBe(false);
// Nothing to prune this time — pruneOrphanedLockEntries isn't even reached.
expect(pruneOrphanedLockEntries).toHaveBeenCalledTimes(1);
@@ -542,7 +548,7 @@ describe("hyperframes skills", () => {
// The install still ran and the update still succeeded — a cleanup no-op
// doesn't fail the update.
expect(state.spawnCalls[0]?.args).toContain("add");
expect(process.exitCode).toBe(0);
expect(await commandExitCode()).toBe(0);
});
// When git is missing the upstream `skills add` would clone-abort with a noisy
@@ -557,7 +563,7 @@ describe("hyperframes skills", () => {
await skillsCmd.run?.({ args: {}, rawArgs: [], cmd: skillsCmd } as never);
expect(state.spawnCalls).toHaveLength(0);
expect(process.exitCode).toBe(0);
expect(await commandExitCode()).toBe(0);
// Diagnostic instrumentation: the skip records why, so rare boxes hitting
// this (fresh Windows without git) are visible instead of silently no-op.
expect(trackSkillsInstallSkipped).toHaveBeenCalledWith({ reason: "git_missing" });
@@ -585,7 +591,7 @@ describe("hyperframes skills", () => {
await runSkillsUpdate();
expect(state.spawnCalls).toHaveLength(0);
expect(process.exitCode).toBe(1);
expect(await commandExitCode()).toBe(1);
});
// The stale-24h-cache regression: the skills commands are excluded from the
@@ -599,7 +605,7 @@ describe("hyperframes skills", () => {
await runSkillsUpdate();
expect(process.exitCode).toBe(0);
expect(await commandExitCode()).toBe(0);
expect(invalidateSkillsCache).toHaveBeenCalled();
});
@@ -610,7 +616,7 @@ describe("hyperframes skills", () => {
await runSkillsUpdate();
expect(process.exitCode).toBe(1);
expect(await commandExitCode()).toBe(1);
expect(invalidateSkillsCache).not.toHaveBeenCalled();
});
@@ -653,8 +659,6 @@ describe("hyperframes skills", () => {
// set it depends on) is guaranteed present and current before the agent reads
// it. Positional names are the ONLY way update expands an install.
describe("hyperframes skills update <names>", () => {
let prevExitCode: typeof process.exitCode;
beforeEach(async () => {
state.execCalls = [];
state.spawnCalls = [];
@@ -671,21 +675,20 @@ describe("hyperframes skills update <names>", () => {
vi.mocked(presentSkills).mockImplementation((names: readonly string[]) => [...names]);
vi.mocked(pruneOrphanedLockEntries).mockReset();
vi.mocked(pruneOrphanedLockEntries).mockImplementation(() => []);
prevExitCode = process.exitCode;
process.exitCode = 0;
await resetCommandResult();
});
afterEach(() => {
afterEach(async () => {
setPlatform(originalPlatform);
vi.restoreAllMocks();
process.exitCode = prevExitCode;
await resetCommandResult();
});
it("installs the requested workflow plus the stale core set — nothing else", async () => {
setPlatform("linux");
await runSkillsUpdateWith(["pr-to-video"]);
expect(process.exitCode).toBe(0);
expect(await commandExitCode()).toBe(0);
const args = state.spawnCalls[0]?.args ?? [];
expect(args).toContain("add");
// requested workflow (missing) + the stale core skills; embedded-captions
@@ -713,7 +716,7 @@ describe("hyperframes skills update <names>", () => {
await runSkillsUpdateWith(["pr-to-video"]);
expect(state.spawnCalls).toHaveLength(0);
expect(process.exitCode).toBe(0);
expect(await commandExitCode()).toBe(0);
});
it("fails loudly on a skill name the manifest doesn't ship", async () => {
@@ -721,7 +724,7 @@ describe("hyperframes skills update <names>", () => {
await runSkillsUpdateWith(["graphic-overlays"]); // renamed upstream → unknown
expect(state.spawnCalls).toHaveLength(0);
expect(process.exitCode).toBe(1);
expect(await commandExitCode()).toBe(1);
});
it("rejects flag-like skill names before any spawn", async () => {
@@ -729,7 +732,7 @@ describe("hyperframes skills update <names>", () => {
await runSkillsUpdateWith(["--config=evil.js"]);
expect(state.spawnCalls).toHaveLength(0);
expect(process.exitCode).toBe(1);
expect(await commandExitCode()).toBe(1);
});
it("offline with the skill already on disk: proceeds without installing", async () => {
@@ -740,7 +743,7 @@ describe("hyperframes skills update <names>", () => {
await runSkillsUpdateWith(["pr-to-video"]);
expect(state.spawnCalls).toHaveLength(0);
expect(process.exitCode).toBe(0);
expect(await commandExitCode()).toBe(0);
});
it("offline with the skill absent: blind-installs it plus the fallback core set", async () => {
@@ -759,7 +762,7 @@ describe("hyperframes skills update <names>", () => {
// depends on, not silently shrink to just the named skill.
const args = state.spawnCalls[0]?.args ?? [];
expect(skillFlagValues(args).sort()).toEqual(["pr-to-video", ...FALLBACK_CORE_SKILLS].sort());
expect(process.exitCode).toBe(0);
expect(await commandExitCode()).toBe(0);
});
// The `check || update` CI contract: offline, a bare update can't verify
@@ -773,7 +776,7 @@ describe("hyperframes skills update <names>", () => {
await runSkillsUpdate();
expect(state.spawnCalls.some((s) => s.args.includes("add"))).toBe(false);
expect(process.exitCode).toBe(1);
expect(await commandExitCode()).toBe(1);
});
it("a malformed canonical manifest warns distinctly, then still degrades to presence mode", async () => {
@@ -792,7 +795,7 @@ describe("hyperframes skills update <names>", () => {
.mock.calls.some((args) => String(args[0]).includes("malformed"));
expect(warnedMalformed).toBe(true);
// Still degrades rather than failing the whole command.
expect(process.exitCode).toBe(0);
expect(await commandExitCode()).toBe(0);
});
it("a genuine offline error degrades silently — no malformed-manifest warning", async () => {
@@ -816,7 +819,7 @@ describe("hyperframes skills update <names>", () => {
await runSkillsUpdateWith(["pr-to-video"], { json: true });
expect(process.exitCode).toBe(0);
expect(await commandExitCode()).toBe(0);
// The engine logs install progress lines too; the JSON result is the last
// console.log of the run (the prune prints nothing when nothing was removed).
const last = String(logSpy.mock.calls.at(-1)?.[0] ?? "");
@@ -830,7 +833,7 @@ describe("hyperframes skills update <names>", () => {
await runSkillsUpdateWith(["graphic-overlays"], { json: true }); // unknown name
expect(process.exitCode).toBe(1);
expect(await commandExitCode()).toBe(1);
const last = String(logSpy.mock.calls.at(-1)?.[0] ?? "");
const parsed = JSON.parse(last) as { error?: string };
expect(parsed.error).toMatch(/Unknown skill/);
@@ -842,7 +845,7 @@ describe("hyperframes skills update <names>", () => {
await runSkillsUpdateWith(["pr-to-video"]);
expect(process.exitCode).toBe(1);
expect(await commandExitCode()).toBe(1);
});
it("exits non-zero when the skill is still missing after an install that exited 0", async () => {
@@ -854,6 +857,6 @@ describe("hyperframes skills update <names>", () => {
await runSkillsUpdateWith(["pr-to-video"]);
expect(state.spawnCalls[0]?.args).toContain("add");
expect(process.exitCode).toBe(1);
expect(await commandExitCode()).toBe(1);
});
});
+6 -4
View File
@@ -1,3 +1,4 @@
import { setCommandExitCode, CliResultSignal } from "../utils/commandResult.js";
import { defineCommand } from "citty";
import { execFileSync, spawn } from "node:child_process";
import * as clack from "@clack/prompts";
@@ -85,7 +86,8 @@ function spawnNpx(args: string[], opts: { cwd?: string } = {}): Promise<void> {
});
child.on("close", (code, signal) => {
if (code === 0) resolve();
else if (signal === "SIGINT" || code === 130) process.exit(0);
else if (signal === "SIGINT" || code === 130)
reject(new CliResultSignal({ exitCode: 0, kind: "success", presented: true }));
else reject(new Error(`npx ${args.join(" ")} exited with code ${code}`));
});
child.on("error", reject);
@@ -581,7 +583,7 @@ const checkCommand = defineCommand({
// Exit non-zero when installed skills are stale, so agents and CI can gate:
// hyperframes skills check || npx hyperframes skills update
if (result.updateAvailable) process.exitCode = 1;
if (result.updateAvailable) setCommandExitCode(1);
},
});
@@ -679,7 +681,7 @@ const updateCommand = defineCommand({
const { requested, rejected } = requestedNamesFrom(args._ ?? []);
if (rejected.length) {
reportUpdateFailure(`Invalid skill name(s): ${rejected.join(", ")}`, args.json === true);
process.exitCode = 1;
setCommandExitCode(1);
return;
}
@@ -709,7 +711,7 @@ const updateCommand = defineCommand({
reportUpdate(result, requested, args.json === true);
} catch (err) {
reportUpdateFailure(`Update failed: ${(err as Error).message}`, args.json === true);
process.exitCode = 1;
setCommandExitCode(1);
return;
}
+3 -2
View File
@@ -1,3 +1,4 @@
import { failCommand } from "../utils/commandResult.js";
// fallow-ignore-file complexity
import { defineCommand } from "citty";
import { existsSync, mkdtempSync, readFileSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
@@ -666,7 +667,7 @@ export default defineCommand({
console.log(
`\n${c.error("✗")} Could not determine composition duration — no frames captured`,
);
process.exit(1);
failCommand();
}
console.log(
@@ -789,7 +790,7 @@ export default defineCommand({
} catch (err) {
const msg = normalizeErrorMessage(err);
console.error(`\n${c.error("✗")} Snapshot failed: ${msg}`);
process.exit(1);
failCommand();
}
},
});
+2 -1
View File
@@ -1,3 +1,4 @@
import { failCommand } from "../utils/commandResult.js";
import { defineCommand } from "citty";
import type { Example } from "./_examples.js";
import { c } from "../ui/colors.js";
@@ -86,7 +87,7 @@ ${c.dim("You can also set")} ${c.accent("HYPERFRAMES_NO_TELEMETRY=1")} ${c.dim("
console.error(
`${c.error("Unknown subcommand:")} ${subcommand}\n\nRun ${c.accent("hyperframes telemetry --help")} for usage.`,
);
process.exit(1);
failCommand();
}
},
});
+5 -7
View File
@@ -3,6 +3,7 @@ import { writeFileSync, readFileSync, mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { WhisperUnavailableError } from "../whisper/manager.js";
import { consumeCommandResult } from "../utils/commandResult.js";
// Make the whisper core report "unavailable" so we exercise the soft-skip path.
const transcribeMock = vi.fn();
@@ -26,12 +27,9 @@ function dummyAudio(): { dir: string; input: string } {
describe("transcribe command", () => {
let dirs: string[] = [];
let priorExitCode: typeof process.exitCode;
beforeEach(() => {
dirs = [];
priorExitCode = process.exitCode;
process.exitCode = undefined;
consumeCommandResult();
transcribeMock.mockReset();
trackTranscribeUnavailable.mockReset();
trackCommandFailure.mockReset();
@@ -42,7 +40,7 @@ describe("transcribe command", () => {
});
afterEach(() => {
process.exitCode = priorExitCode;
consumeCommandResult();
for (const d of dirs) rmSync(d, { recursive: true, force: true });
vi.restoreAllMocks();
});
@@ -52,7 +50,7 @@ describe("transcribe command", () => {
dirs.push(dir);
await transcribeCmd.run!({ args: { input, json: true, optional: false } } as never);
expect(process.exitCode).toBe(1);
expect(consumeCommandResult().exitCode).toBe(1);
expect(trackTranscribeUnavailable).toHaveBeenCalledWith({ optional: false });
expect(trackCommandFailure).not.toHaveBeenCalled();
});
@@ -62,7 +60,7 @@ describe("transcribe command", () => {
dirs.push(dir);
await transcribeCmd.run!({ args: { input, json: true, optional: true } } as never);
expect(process.exitCode).toBe(0);
expect(consumeCommandResult().exitCode).toBe(0);
expect(trackTranscribeUnavailable).toHaveBeenCalledWith({ optional: true });
expect(trackCommandFailure).not.toHaveBeenCalled();
});
+5 -4
View File
@@ -1,3 +1,4 @@
import { failCommand, setCommandExitCode } from "../utils/commandResult.js";
// fallow-ignore-file code-duplication
import { defineCommand } from "citty";
import type { Example } from "./_examples.js";
@@ -110,7 +111,7 @@ export default defineCommand({
const message = `File not found: ${args.input}`;
trackCommandFailure("transcribe", message);
console.error(c.error(message));
process.exit(1);
failCommand();
}
// Default to the directory containing the input file so transcript.json
@@ -181,7 +182,7 @@ function failWith(message: string, json: boolean): never {
} else {
console.error(c.error(message));
}
process.exit(1);
failCommand();
}
function parseExportFormat(
@@ -378,7 +379,7 @@ async function transcribeAudio(
// Optional callers (pipelines) treat a missing prerequisite as a clean
// skip; explicit runs still surface non-zero. Set the status and return
// rather than guarding a process.exit() on the flag.
process.exitCode = opts.optional ? 0 : 1;
setCommandExitCode(opts.optional ? 0 : 1);
return;
}
@@ -388,6 +389,6 @@ async function transcribeAudio(
} else {
spin?.stop(c.error(`Transcription failed: ${message}`));
}
process.exit(1);
failCommand();
}
}
+7 -6
View File
@@ -1,3 +1,4 @@
import { failCommand } from "../utils/commandResult.js";
// fallow-ignore-file code-duplication
import { defineCommand } from "citty";
import type { Example } from "./_examples.js";
@@ -92,7 +93,7 @@ export default defineCommand({
const input = args["text-file"] ?? args.input;
if (!input) {
console.error(c.error("Provide text to speak, or use --list to see available voices."));
process.exit(1);
failCommand();
}
let text: string;
@@ -102,7 +103,7 @@ export default defineCommand({
text = readFileSync(maybeFile, "utf-8").trim();
if (!text) {
console.error(c.error("File is empty."));
process.exit(1);
failCommand();
}
} else {
text = input;
@@ -110,7 +111,7 @@ export default defineCommand({
if (!text.trim()) {
console.error(c.error("No text provided."));
process.exit(1);
failCommand();
}
// ── Resolve output path ───────────────────────────────────────────
@@ -120,7 +121,7 @@ export default defineCommand({
if (isNaN(speed) || speed <= 0 || speed > 3) {
console.error(c.error("Speed must be a number between 0.1 and 3.0"));
process.exit(1);
failCommand();
}
const inferredLang = inferLangFromVoiceId(voice);
@@ -129,7 +130,7 @@ export default defineCommand({
const requested = String(args.lang).toLowerCase();
if (!isSupportedLang(requested)) {
errorBox("Invalid --lang", `Got "${args.lang}". Must be one of: ${langList}.`);
process.exit(1);
failCommand();
}
lang = requested;
}
@@ -190,7 +191,7 @@ export default defineCommand({
} else {
spin?.stop(c.error(`Speech synthesis failed: ${message}`));
}
process.exit(1);
failCommand();
}
},
});
+4 -3
View File
@@ -40,7 +40,6 @@ describe("runDetectedInstall", () => {
vi.doMock("node:child_process", () => ({ execFileSync: execSpy }));
const { runDetectedInstall } = await import("./upgrade.js");
const original = process.exitCode;
try {
expect(() =>
runDetectedInstall(
@@ -49,9 +48,11 @@ describe("runDetectedInstall", () => {
"1.2.3",
),
).not.toThrow();
expect(process.exitCode).toBe(1);
const { consumeCommandResult } = await import("../utils/commandResult.js");
expect(consumeCommandResult().exitCode).toBe(1);
} finally {
process.exitCode = original;
const { consumeCommandResult } = await import("../utils/commandResult.js");
consumeCommandResult();
}
});
});
+3 -2
View File
@@ -1,3 +1,4 @@
import { setCommandExitCode } from "../utils/commandResult.js";
import { defineCommand } from "citty";
import type { Example } from "./_examples.js";
import * as clack from "@clack/prompts";
@@ -112,7 +113,7 @@ function applyUpgrade(result: UpdateCheckResult, autoYes: boolean): void {
// notice via isSafeVersion.
if (!isSafeVersion(result.latest)) {
clack.outro(c.dim("Refusing to install: unexpected version string from npm registry."));
process.exitCode = 1;
setCommandExitCode(1);
return;
}
@@ -241,6 +242,6 @@ export function runDetectedInstall(
} catch {
clack.outro(c.dim("Install failed. Try running manually:"));
console.log(` ${c.accent(displayCmd)}`);
process.exitCode = 1;
setCommandExitCode(1);
}
}
+4 -2
View File
@@ -3,6 +3,7 @@
// cannot import the Node helper. Line-level markers don't survive the clone
// window drifting as the file is edited, hence the file-level suppression.
// fallow-ignore-file code-duplication
import { failCommand, setCommandExitCode } from "../utils/commandResult.js";
import { defineCommand } from "citty";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
@@ -627,11 +628,12 @@ Examples:
try {
const result = await validateInBrowser(project, { timeout, contrast: useContrast });
const exitCode = printValidationResult(result, asJson);
process.exit(exitCode);
setCommandExitCode(exitCode);
return;
} catch (err: unknown) {
const message = normalizeErrorMessage(err);
emitFailureReport(message, asJson);
process.exit(1);
failCommand();
}
},
});