mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 23:00:03 +00:00
refactor(cli): centralize process lifecycle
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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("{"));
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user