mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-15 13:58:20 +00:00
Merge pull request #2171 from heygen-com/07-10-refactor-cli-centralize-process-lifecycle
refactor(cli): centralize process lifecycle
This commit is contained in:
+3
-2
@@ -26,7 +26,7 @@
|
||||
"sync-schemas:check": "tsx scripts/sync-schemas.ts --check",
|
||||
"sync:package-subpaths": "node scripts/package-subpaths.mjs --write",
|
||||
"check:package-subpaths": "node scripts/package-subpaths.mjs",
|
||||
"lint": "bun run check:tracked-artifacts && bun run check:workspace-contracts && bun run check:gcp-cloud-run-dockerfile && bun run check:package-cycles && bun run check:package-subpaths && oxlint . && tsx scripts/lint-skills.ts && node scripts/check-skill-mirror.mjs",
|
||||
"lint": "bun run check:tracked-artifacts && bun run check:workspace-contracts && bun run check:gcp-cloud-run-dockerfile && bun run check:package-cycles && bun run check:package-subpaths && bun run check:cli-process-ownership && oxlint . && tsx scripts/lint-skills.ts && node scripts/check-skill-mirror.mjs",
|
||||
"check:gcp-cloud-run-dockerfile": "bun run --cwd packages/gcp-cloud-run test:dockerfile-workspaces",
|
||||
"lint:skills": "tsx scripts/lint-skills.ts",
|
||||
"check:skill-mirror": "node scripts/check-skill-mirror.mjs",
|
||||
@@ -34,6 +34,7 @@
|
||||
"check:tracked-artifacts": "node scripts/check-tracked-artifacts.mjs",
|
||||
"check:workspace-contracts": "node scripts/check-workspace-contracts.mjs",
|
||||
"check:package-cycles": "node scripts/check-package-cycles.mjs",
|
||||
"check:cli-process-ownership": "node scripts/check-cli-process-ownership.mjs",
|
||||
"format": "oxfmt .",
|
||||
"test": "bun run test:unit",
|
||||
"test:unit": "bun run --filter '*' test",
|
||||
@@ -46,7 +47,7 @@
|
||||
"player:perf": "bun run --filter @hyperframes/player perf",
|
||||
"format:check": "oxfmt --check .",
|
||||
"knip": "knip",
|
||||
"test:scripts": "node --import tsx --test scripts/check-tracked-artifacts.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-package-cycles.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs",
|
||||
"test:scripts": "node --import tsx --test scripts/check-tracked-artifacts.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs",
|
||||
"test:skills": "node --test 'skills/**/*.test.mjs'",
|
||||
"generate:previews": "tsx scripts/generate-template-previews.ts",
|
||||
"generate:catalog-previews": "tsx scripts/generate-catalog-previews.ts",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { failCommand } from "../utils/commandResult.js";
|
||||
/**
|
||||
* OAuth 2.0 + PKCE driver for the HeyGen public OAuth flow.
|
||||
*
|
||||
@@ -115,7 +116,7 @@ export function assertOAuthConfiguredOrExit(): void {
|
||||
if (isAuthError(err) && err.code === "OAUTH_NOT_CONFIGURED") {
|
||||
console.error(`Error: ${err.message}`);
|
||||
if (err.hint) console.error(err.hint);
|
||||
process.exit(1);
|
||||
failCommand();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -47,4 +47,10 @@ describe("CLI command registration", () => {
|
||||
expect(condition).toContain('command !== "events"');
|
||||
expect(condition).toContain('command !== "skills"');
|
||||
});
|
||||
|
||||
it("reports each command failure only at the executable boundary", () => {
|
||||
expect(cliSource).toContain("trackCommandFailures(load)");
|
||||
expect(cliSource).not.toContain("trackCommandFailures(load,");
|
||||
expect(cliSource.match(/reportCommandFailure\(command, error\)/g)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const originalArgv = [...process.argv];
|
||||
const originalExitCode = process.exitCode;
|
||||
|
||||
afterEach(() => {
|
||||
process.argv = [...originalArgv];
|
||||
process.exitCode = originalExitCode;
|
||||
vi.doUnmock("./commands/init.js");
|
||||
vi.doUnmock("./telemetry/events.js");
|
||||
vi.doUnmock("./telemetry/index.js");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("CLI lifecycle", () => {
|
||||
it("queues a command failure before finalizing telemetry", async () => {
|
||||
let resolveEvents!: (events: {
|
||||
trackCommandFailure: (command: string, error: unknown) => void;
|
||||
}) => void;
|
||||
const eventsModule = new Promise<{
|
||||
trackCommandFailure: (command: string, error: unknown) => void;
|
||||
}>((resolve) => {
|
||||
resolveEvents = resolve;
|
||||
});
|
||||
let markEventsImportStarted!: () => void;
|
||||
const eventsImportStarted = new Promise<void>((resolve) => {
|
||||
markEventsImportStarted = resolve;
|
||||
});
|
||||
const order: string[] = [];
|
||||
|
||||
vi.doMock("./commands/init.js", () => ({
|
||||
default: {
|
||||
meta: { name: "init" },
|
||||
args: { json: { type: "boolean" } },
|
||||
run: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.doMock("./telemetry/index.js", () => ({
|
||||
flush: async () => {
|
||||
order.push("flush");
|
||||
},
|
||||
flushSync: vi.fn(),
|
||||
incrementCommandCount: vi.fn(),
|
||||
showTelemetryNotice: vi.fn(),
|
||||
shouldTrack: () => false,
|
||||
trackCliError: vi.fn(),
|
||||
trackCommand: vi.fn(),
|
||||
trackCommandResult: vi.fn(),
|
||||
}));
|
||||
vi.doMock("./telemetry/events.js", async () => {
|
||||
markEventsImportStarted();
|
||||
return eventsModule;
|
||||
});
|
||||
|
||||
process.argv = ["node", "cli.ts", "init", "--bogus", "--json"];
|
||||
const execution = import("./cli.js");
|
||||
|
||||
await eventsImportStarted;
|
||||
expect(order).toEqual([]);
|
||||
|
||||
resolveEvents({
|
||||
trackCommandFailure: () => {
|
||||
order.push("cli_error");
|
||||
},
|
||||
});
|
||||
await execution;
|
||||
|
||||
expect(order).toEqual(["cli_error", "flush"]);
|
||||
});
|
||||
});
|
||||
+95
-28
@@ -99,11 +99,21 @@ try {
|
||||
// Telemetry, update checks, and heavy modules are imported only when needed.
|
||||
// For --help we skip telemetry entirely.
|
||||
|
||||
import { defineCommand, runMain } from "citty";
|
||||
import { defineCommand, runCommand } from "citty";
|
||||
import type { ArgsDef, CommandDef } from "citty";
|
||||
import { getRunId } from "./telemetry/runId.js";
|
||||
import { reportCommandFailure, trackCommandFailures } from "./utils/command-failure-tracking.js";
|
||||
import { isRenderSucceeded } from "./utils/render-success-state.js";
|
||||
import { resolveCommandUsage } from "./utils/commandUsageResolution.js";
|
||||
import {
|
||||
CliResultSignal,
|
||||
CliRuntimeError,
|
||||
CliUsageError,
|
||||
consumeCommandResult,
|
||||
registerRootExitCodeSanitizer,
|
||||
registerRootExitRequester,
|
||||
type CommandResult,
|
||||
} from "./utils/commandResult.js";
|
||||
|
||||
const isHelp = process.argv.includes("--help") || process.argv.includes("-h");
|
||||
|
||||
@@ -152,15 +162,8 @@ const commandLoaders = {
|
||||
figma: () => import("./commands/figma.js").then((m) => m.default),
|
||||
};
|
||||
|
||||
// Wrap each command's run() so a thrown failure reports its reason to telemetry
|
||||
// before citty catches the error and exits 1. The error is re-thrown unchanged,
|
||||
// preserving citty's print + exit-1 behavior. Commands that call process.exit()
|
||||
// themselves (e.g. `browser path`) bypass this and report inline.
|
||||
const subCommands = Object.fromEntries(
|
||||
Object.entries(commandLoaders).map(([name, load]) => [
|
||||
name,
|
||||
trackCommandFailures(load, (err) => reportCommandFailure(command, err)),
|
||||
]),
|
||||
Object.entries(commandLoaders).map(([name, load]) => [name, trackCommandFailures(load)]),
|
||||
);
|
||||
|
||||
const main = defineCommand({
|
||||
@@ -209,12 +212,13 @@ let _trackCommandResult:
|
||||
let _printUpdateNotice: (() => void) | undefined;
|
||||
let _printStalePinNotice: (() => void) | undefined;
|
||||
let _printSkillsUpdateNotice: (() => void) | undefined;
|
||||
let telemetryReady: Promise<void> = Promise.resolve();
|
||||
|
||||
// `events` is a telemetry-internal beacon: it self-tracks + self-flushes, so it
|
||||
// skips the per-command wrapper (no duplicate cli_command, no first-run notice
|
||||
// printed into a skill's captured output).
|
||||
if (!isHelp && command !== "telemetry" && command !== "events" && command !== "unknown") {
|
||||
import("./telemetry/index.js").then((mod) => {
|
||||
telemetryReady = import("./telemetry/index.js").then((mod) => {
|
||||
_flush = mod.flush;
|
||||
_flushSync = mod.flushSync;
|
||||
_trackCliError = mod.trackCliError;
|
||||
@@ -265,35 +269,62 @@ if (
|
||||
|
||||
const commandStart = Date.now();
|
||||
const runId = getRunId();
|
||||
let finalized = false;
|
||||
|
||||
// Async flush for normal exit. `beforeExit` re-fires every time the
|
||||
// event loop drains, and the async `_flush()` itself schedules new
|
||||
// work — so a plain `on` listener would print the update notice (and
|
||||
// re-flush) once per drain (the user-reported double-print). `once`
|
||||
// detaches after first invocation, which is what we want for both.
|
||||
// fallow-ignore-next-line complexity
|
||||
process.once("beforeExit", () => {
|
||||
_flush?.().catch(() => {});
|
||||
async function finalizeCli(result: CommandResult): Promise<void> {
|
||||
if (finalized) return;
|
||||
finalized = true;
|
||||
commandFailed ||= result.exitCode !== 0;
|
||||
await telemetryReady.catch(() => {});
|
||||
_trackCommandResult?.({
|
||||
command,
|
||||
success: result.exitCode === 0 && !commandFailed,
|
||||
exitCode: result.exitCode,
|
||||
durationMs: Date.now() - commandStart,
|
||||
runId,
|
||||
});
|
||||
await _flush?.().catch(() => {});
|
||||
if (!hasJsonFlag) {
|
||||
_printUpdateNotice?.();
|
||||
_printStalePinNotice?.();
|
||||
_printSkillsUpdateNotice?.();
|
||||
}
|
||||
process.exitCode = result.exitCode;
|
||||
}
|
||||
|
||||
registerRootExitRequester((exitCode) => {
|
||||
void finalizeCli({
|
||||
exitCode,
|
||||
kind: exitCode === 0 ? "success" : "runtime_error",
|
||||
presented: true,
|
||||
}).finally(() => process.exit(exitCode));
|
||||
});
|
||||
|
||||
registerRootExitCodeSanitizer(() => {
|
||||
if (process.exitCode !== undefined && process.exitCode !== 0) {
|
||||
process.exitCode = 0;
|
||||
}
|
||||
});
|
||||
|
||||
// Sync-only: exit handlers cannot await promises or drain microtasks.
|
||||
// _trackCommandResult / _trackCliError are captured references resolved
|
||||
// at init time, so they're callable synchronously here.
|
||||
process.on("exit", (code) => {
|
||||
_trackCommandResult?.({
|
||||
command,
|
||||
success: code === 0 && !commandFailed,
|
||||
exitCode: code,
|
||||
durationMs: Date.now() - commandStart,
|
||||
runId,
|
||||
});
|
||||
_flushSync?.();
|
||||
});
|
||||
process.on(
|
||||
"exit",
|
||||
// fallow-ignore-next-line complexity
|
||||
(code) => {
|
||||
if (finalized) return;
|
||||
_trackCommandResult?.({
|
||||
command,
|
||||
success: code === 0 && !commandFailed,
|
||||
exitCode: code,
|
||||
durationMs: Date.now() - commandStart,
|
||||
runId,
|
||||
});
|
||||
_flushSync?.();
|
||||
},
|
||||
);
|
||||
|
||||
// Report a CLI error event to telemetry. Extracted from the process-error
|
||||
// handlers so their bodies stay simple linear branches (see fallow CRAP
|
||||
@@ -382,6 +413,7 @@ process.on("unhandledRejection", (reason) => {
|
||||
return;
|
||||
}
|
||||
commandFailed = true;
|
||||
process.exitCode = 1;
|
||||
emitCliErrorEvent("unhandled_rejection", error);
|
||||
});
|
||||
|
||||
@@ -394,4 +426,39 @@ async function showUsage<T extends ArgsDef>(
|
||||
return impl(cmd as CommandDef, parent as CommandDef | undefined);
|
||||
}
|
||||
|
||||
runMain(main, { showUsage });
|
||||
async function showRequestedUsage(): Promise<void> {
|
||||
const requested = await resolveCommandUsage(main as CommandDef, argv);
|
||||
return showUsage(requested.command, requested.parent);
|
||||
}
|
||||
|
||||
function commandResultForError(error: unknown): CommandResult {
|
||||
if (error instanceof CliResultSignal) return error.result;
|
||||
if (error instanceof CliUsageError || error instanceof CliRuntimeError) return error.result;
|
||||
return { exitCode: 1, kind: "runtime_error" };
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
async function executeCli(): Promise<void> {
|
||||
let result: CommandResult = { exitCode: 0, kind: "success" };
|
||||
try {
|
||||
if (isHelp) await showRequestedUsage();
|
||||
else await runCommand(main, { rawArgs: argv });
|
||||
} catch (error) {
|
||||
result = commandResultForError(error);
|
||||
if (!(error instanceof CliResultSignal)) {
|
||||
commandFailed = true;
|
||||
await reportCommandFailure(command, error);
|
||||
const typed = error instanceof CliUsageError || error instanceof CliRuntimeError;
|
||||
if (error instanceof CliUsageError && !error.result.presented) await showRequestedUsage();
|
||||
if (!typed || !error.result.presented) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
const pending = consumeCommandResult();
|
||||
if (pending.exitCode !== 0 || result.exitCode === 0) result = pending;
|
||||
await finalizeCli(result);
|
||||
}
|
||||
}
|
||||
|
||||
await executeCli();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// errorBox writes to the console; mock it so we can assert which title /
|
||||
// message / third line the cascade picked without matching ANSI output.
|
||||
@@ -10,30 +10,19 @@ vi.mock("../ui/format.js", async (importOriginal) => ({
|
||||
import { errorBox } from "../ui/format.js";
|
||||
import { HyperframesApiError } from "./_gen/client.js";
|
||||
import { reportApiError } from "./errors.js";
|
||||
import { CliRuntimeError } from "../utils/commandResult.js";
|
||||
|
||||
describe("cloud/errors reportApiError", () => {
|
||||
// process.exit has signature `(code?) => never` which doesn't unify with
|
||||
// vi.spyOn's inference; cast through `unknown` like cloud/parsing.test.ts.
|
||||
let exitSpy: { mockRestore: () => void } & { mock: { calls: unknown[][] } };
|
||||
|
||||
beforeEach(() => {
|
||||
exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
|
||||
throw new Error("process.exit called");
|
||||
}) as unknown as (code?: string | number | null) => never) as unknown as typeof exitSpy;
|
||||
vi.mocked(errorBox).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("short-circuits a 404 to a Not found box when notFound is provided", () => {
|
||||
const err = new HyperframesApiError({ status: 404, message: "missing" });
|
||||
expect(() =>
|
||||
reportApiError("Get failed", err, { notFound: "render hfr_x no longer exists" }),
|
||||
).toThrow("process.exit called");
|
||||
).toThrow(CliRuntimeError);
|
||||
expect(errorBox).toHaveBeenCalledWith("Not found", "render hfr_x no longer exists");
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("uses the code-specific hint when the error code is known", () => {
|
||||
@@ -42,7 +31,7 @@ describe("cloud/errors reportApiError", () => {
|
||||
message: "slow down",
|
||||
code: "rate_limit_exceeded",
|
||||
});
|
||||
expect(() => reportApiError("Submit failed", err)).toThrow("process.exit called");
|
||||
expect(() => reportApiError("Submit failed", err)).toThrow(CliRuntimeError);
|
||||
expect(errorBox).toHaveBeenCalledWith(
|
||||
"Submit failed (HTTP 429)",
|
||||
"slow down",
|
||||
@@ -56,7 +45,7 @@ describe("cloud/errors reportApiError", () => {
|
||||
message: "project too large",
|
||||
code: "hyperframes_project_too_large",
|
||||
});
|
||||
expect(() => reportApiError("Upload failed", err)).toThrow("process.exit called");
|
||||
expect(() => reportApiError("Upload failed", err)).toThrow(CliRuntimeError);
|
||||
expect(errorBox).toHaveBeenCalledWith(
|
||||
"Upload failed (HTTP 413)",
|
||||
"project too large",
|
||||
@@ -71,7 +60,7 @@ describe("cloud/errors reportApiError", () => {
|
||||
code: "invalid_parameter",
|
||||
});
|
||||
expect(() => reportApiError("Submit failed", err, { suggestion: "see --help" })).toThrow(
|
||||
"process.exit called",
|
||||
CliRuntimeError,
|
||||
);
|
||||
expect(errorBox).toHaveBeenCalledWith(
|
||||
"Submit failed (HTTP 400)",
|
||||
@@ -87,7 +76,7 @@ describe("cloud/errors reportApiError", () => {
|
||||
code: "some_unmapped_code",
|
||||
});
|
||||
expect(() => reportApiError("List failed", err, { suggestion: "retry shortly" })).toThrow(
|
||||
"process.exit called",
|
||||
CliRuntimeError,
|
||||
);
|
||||
expect(errorBox).toHaveBeenCalledWith("List failed (HTTP 500)", "boom", "retry shortly");
|
||||
});
|
||||
@@ -98,7 +87,7 @@ describe("cloud/errors reportApiError", () => {
|
||||
message: "boom",
|
||||
code: "some_unmapped_code",
|
||||
});
|
||||
expect(() => reportApiError("List failed", err)).toThrow("process.exit called");
|
||||
expect(() => reportApiError("List failed", err)).toThrow(CliRuntimeError);
|
||||
expect(errorBox).toHaveBeenCalledWith(
|
||||
"List failed (HTTP 500)",
|
||||
"boom",
|
||||
@@ -108,7 +97,7 @@ describe("cloud/errors reportApiError", () => {
|
||||
|
||||
it("omits the third line when there is no code, hint, or suggestion", () => {
|
||||
const err = new HyperframesApiError({ status: 503, message: "unavailable" });
|
||||
expect(() => reportApiError("Get failed", err)).toThrow("process.exit called");
|
||||
expect(() => reportApiError("Get failed", err)).toThrow(CliRuntimeError);
|
||||
expect(errorBox).toHaveBeenCalledWith("Get failed (HTTP 503)", "unavailable");
|
||||
});
|
||||
|
||||
@@ -116,7 +105,7 @@ describe("cloud/errors reportApiError", () => {
|
||||
const err = new HyperframesApiError({ status: 418, message: "teapot", code: "custom_code" });
|
||||
expect(() =>
|
||||
reportApiError("Brew failed", err, { extraHints: { custom_code: "use coffee instead" } }),
|
||||
).toThrow("process.exit called");
|
||||
).toThrow(CliRuntimeError);
|
||||
expect(errorBox).toHaveBeenCalledWith("Brew failed (HTTP 418)", "teapot", "use coffee instead");
|
||||
});
|
||||
|
||||
@@ -130,7 +119,7 @@ describe("cloud/errors reportApiError", () => {
|
||||
reportApiError("Submit failed", err, {
|
||||
extraHints: { rate_limit_exceeded: "custom backoff guidance" },
|
||||
}),
|
||||
).toThrow("process.exit called");
|
||||
).toThrow(CliRuntimeError);
|
||||
expect(errorBox).toHaveBeenCalledWith(
|
||||
"Submit failed (HTTP 429)",
|
||||
"slow down",
|
||||
@@ -141,12 +130,12 @@ describe("cloud/errors reportApiError", () => {
|
||||
it("reports a plain Error with the stage title and suggestion", () => {
|
||||
expect(() =>
|
||||
reportApiError("Upload failed", new Error("disk full"), { suggestion: "free up space" }),
|
||||
).toThrow("process.exit called");
|
||||
).toThrow(CliRuntimeError);
|
||||
expect(errorBox).toHaveBeenCalledWith("Upload failed", "disk full", "free up space");
|
||||
});
|
||||
|
||||
it("stringifies a non-Error thrown value", () => {
|
||||
expect(() => reportApiError("Weird failure", "just a string")).toThrow("process.exit called");
|
||||
expect(() => reportApiError("Weird failure", "just a string")).toThrow(CliRuntimeError);
|
||||
expect(errorBox).toHaveBeenCalledWith("Weird failure", "just a string", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { failCommand } from "../utils/commandResult.js";
|
||||
/**
|
||||
* Shared API-error reporter for the cloud subverbs.
|
||||
*
|
||||
@@ -61,7 +62,7 @@ export function reportApiError(
|
||||
if (err instanceof HyperframesApiError) {
|
||||
if (err.status === 404 && options.notFound) {
|
||||
errorBox("Not found", options.notFound);
|
||||
process.exit(1);
|
||||
failCommand();
|
||||
}
|
||||
const hint = err.code ? hints[err.code] : undefined;
|
||||
const title = `${stage} (HTTP ${err.status})`;
|
||||
@@ -78,12 +79,12 @@ export function reportApiError(
|
||||
} else {
|
||||
errorBox(title, err.message);
|
||||
}
|
||||
process.exit(1);
|
||||
failCommand();
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
errorBox(stage, err.message, options.suggestion);
|
||||
process.exit(1);
|
||||
failCommand();
|
||||
}
|
||||
errorBox(stage, String(err), options.suggestion);
|
||||
process.exit(1);
|
||||
failCommand();
|
||||
}
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { parseEnumFlag, parseIntFlag, parseNumericFlag } from "./parsing.js";
|
||||
import { CliUsageError } from "../utils/commandResult.js";
|
||||
|
||||
describe("cloud/parsing", () => {
|
||||
// process.exit has signature `(code?) => never` which doesn't unify
|
||||
// with vi.spyOn's mock-function inference; cast through `unknown` so
|
||||
// the test compiles. The spy itself still records calls correctly.
|
||||
let exitSpy: { mockRestore: () => void } & { mock: { calls: unknown[][] } };
|
||||
let errorSpy: { mockRestore: () => void };
|
||||
|
||||
beforeEach(() => {
|
||||
exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
|
||||
throw new Error("process.exit called");
|
||||
}) as unknown as (code?: string | number | null) => never) as unknown as typeof exitSpy;
|
||||
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
exitSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
@@ -30,20 +23,19 @@ describe("cloud/parsing", () => {
|
||||
});
|
||||
|
||||
it("rejects trailing garbage that Number.parseInt would silently accept", () => {
|
||||
expect(() => parseIntFlag("10abc", { flag: "--x" })).toThrow("process.exit called");
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
expect(() => parseIntFlag("10abc", { flag: "--x" })).toThrow(CliUsageError);
|
||||
});
|
||||
|
||||
it("rejects decimals", () => {
|
||||
expect(() => parseIntFlag("10.5", { flag: "--x" })).toThrow("process.exit called");
|
||||
expect(() => parseIntFlag("10.5", { flag: "--x" })).toThrow(CliUsageError);
|
||||
});
|
||||
|
||||
it("enforces min", () => {
|
||||
expect(() => parseIntFlag("0", { flag: "--x", min: 1 })).toThrow("process.exit called");
|
||||
expect(() => parseIntFlag("0", { flag: "--x", min: 1 })).toThrow(CliUsageError);
|
||||
});
|
||||
|
||||
it("enforces max", () => {
|
||||
expect(() => parseIntFlag("101", { flag: "--x", max: 100 })).toThrow("process.exit called");
|
||||
expect(() => parseIntFlag("101", { flag: "--x", max: 100 })).toThrow(CliUsageError);
|
||||
});
|
||||
|
||||
it("accepts negative integers when no min is set", () => {
|
||||
@@ -61,13 +53,11 @@ describe("cloud/parsing", () => {
|
||||
});
|
||||
|
||||
it("rejects trailing garbage that Number.parseFloat would silently accept", () => {
|
||||
expect(() => parseNumericFlag("10seconds", { flag: "--x" })).toThrow("process.exit called");
|
||||
expect(() => parseNumericFlag("10seconds", { flag: "--x" })).toThrow(CliUsageError);
|
||||
});
|
||||
|
||||
it("rejects NaN", () => {
|
||||
expect(() => parseNumericFlag("not-a-number", { flag: "--x" })).toThrow(
|
||||
"process.exit called",
|
||||
);
|
||||
expect(() => parseNumericFlag("not-a-number", { flag: "--x" })).toThrow(CliUsageError);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,7 +71,7 @@ describe("cloud/parsing", () => {
|
||||
it("rejects an unknown value", () => {
|
||||
expect(() =>
|
||||
parseEnumFlag("ultra", ["draft", "standard", "high"], { flag: "--quality" }),
|
||||
).toThrow("process.exit called");
|
||||
).toThrow(CliUsageError);
|
||||
});
|
||||
|
||||
it("returns undefined when raw is undefined", () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { failUsage } from "../utils/commandResult.js";
|
||||
/**
|
||||
* Strict numeric parsers for CLI flags.
|
||||
*
|
||||
@@ -28,7 +29,7 @@ export function parseIntFlag(raw: string | undefined, opts: IntFlagOptions): num
|
||||
if (raw === undefined) return undefined;
|
||||
if (!INTEGER_RE.test(raw)) {
|
||||
errorBox(`Invalid ${opts.flag}`, `Got "${raw}". Must be an integer${rangeSuffix(opts)}.`);
|
||||
process.exit(1);
|
||||
failUsage();
|
||||
}
|
||||
const n = Number.parseInt(raw, 10);
|
||||
enforceRange(opts.flag, raw, n, opts);
|
||||
@@ -52,12 +53,12 @@ export function parseNumericFlag(
|
||||
if (raw === undefined) return undefined;
|
||||
if (!NUMERIC_RE.test(raw)) {
|
||||
errorBox(`Invalid ${opts.flag}`, `Got "${raw}". Must be a number${rangeSuffix(opts)}.`);
|
||||
process.exit(1);
|
||||
failUsage();
|
||||
}
|
||||
const n = Number.parseFloat(raw);
|
||||
if (!Number.isFinite(n)) {
|
||||
errorBox(`Invalid ${opts.flag}`, `Got "${raw}". Must be a finite number.`);
|
||||
process.exit(1);
|
||||
failUsage();
|
||||
}
|
||||
enforceRange(opts.flag, raw, n, opts);
|
||||
return n;
|
||||
@@ -71,11 +72,11 @@ function enforceRange(
|
||||
): void {
|
||||
if (bounds.min !== undefined && value < bounds.min) {
|
||||
errorBox(`Invalid ${flag}`, `Got "${raw}". Minimum is ${bounds.min}.`);
|
||||
process.exit(1);
|
||||
failUsage();
|
||||
}
|
||||
if (bounds.max !== undefined && value > bounds.max) {
|
||||
errorBox(`Invalid ${flag}`, `Got "${raw}". Maximum is ${bounds.max}.`);
|
||||
process.exit(1);
|
||||
failUsage();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +89,7 @@ export function parseEnumFlag<T extends string>(
|
||||
if (raw === undefined) return undefined;
|
||||
if ((allowed as readonly string[]).includes(raw)) return raw as T;
|
||||
errorBox(`Invalid ${opts.flag}`, `Got "${raw}". Must be one of: ${allowed.join(", ")}.`);
|
||||
process.exit(1);
|
||||
failUsage();
|
||||
}
|
||||
|
||||
function rangeSuffix(opts: { min?: number; max?: number }): string {
|
||||
|
||||
@@ -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();
|
||||
@@ -58,15 +49,13 @@ describe("validateResolutionFormatCombo", () => {
|
||||
|
||||
describe("validateDryRunSource", () => {
|
||||
it("accepts a local directory and rejects already-uploaded sources", () => {
|
||||
const exit = trapExit();
|
||||
expect(() => validateDryRunSource({ kind: "dir", dir: "." }, true)).not.toThrow();
|
||||
expect(() => validateDryRunSource({ kind: "asset_id", assetId: "asst_123" }, true)).toThrow(
|
||||
"process.exit:1",
|
||||
CliRuntimeError,
|
||||
);
|
||||
expect(() =>
|
||||
validateDryRunSource({ kind: "url", url: "https://example.com/project.zip" }, true),
|
||||
).toThrow("process.exit:1");
|
||||
expect(exit).toHaveBeenCalledTimes(2);
|
||||
).toThrow(CliRuntimeError);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -128,7 +117,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 +126,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 +137,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 +165,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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,7 +370,7 @@ export function validateDryRunSource(source: ProjectInputSource, dryRun: boolean
|
||||
"Invalid --dry-run input",
|
||||
"--dry-run inspects a local project directory and cannot be combined with --asset-id or --url.",
|
||||
);
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -615,7 +616,7 @@ function prepareLocalArchive(
|
||||
} catch (err) {
|
||||
const msg = normalizeErrorMessage(err);
|
||||
errorBox("Zip failed", msg, "Check the project and .hyperframesignore for missing files.");
|
||||
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)
|
||||
@@ -2150,10 +2152,10 @@ function handleRenderError(
|
||||
const macosRemediation = macosOldChromeCrashRemediation(message);
|
||||
if (macosRemediation) {
|
||||
errorBox("Render failed — Chrome could not launch", message, macosRemediation);
|
||||
process.exit(1);
|
||||
failCommand();
|
||||
}
|
||||
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 { CliRuntimeError, 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();
|
||||
});
|
||||
@@ -103,16 +101,13 @@ Render video. Built for agents.
|
||||
const { dir, input } = dummyAudio();
|
||||
dirs.push(dir);
|
||||
const consoleLog = vi.mocked(console.log);
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
|
||||
throw new Error(`__exit_${code}__`);
|
||||
}) as never);
|
||||
|
||||
// 100 is well below the 5000ms minimum — must fail loud instead of silently
|
||||
// reverting to the auto-scaled default (the whole point of the flag is
|
||||
// that the user explicitly asked for a specific value).
|
||||
await expect(
|
||||
transcribeCmd.run!({ args: { input, json: true, timeout: "100" } } as never),
|
||||
).rejects.toThrow("__exit_1__");
|
||||
).rejects.toThrow(CliRuntimeError);
|
||||
|
||||
const log = consoleLog.mock.calls.at(-1)?.[0];
|
||||
expect(typeof log).toBe("string");
|
||||
@@ -121,8 +116,6 @@ Render video. Built for agents.
|
||||
expect(parsed.ok).toBe(false);
|
||||
expect(parsed.error).toContain("--timeout");
|
||||
expect(parsed.error).toContain("5000");
|
||||
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("--preserve-cues keeps single-word cues separate when exporting from JSON", async () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -14,40 +14,32 @@ function defineRun(run: CommandDef["run"]): CommandDef {
|
||||
}
|
||||
|
||||
describe("trackCommandFailures", () => {
|
||||
it("reports the error and re-throws when run() rejects", async () => {
|
||||
const onFailure = vi.fn();
|
||||
it("re-throws when run() rejects so the executable boundary can report it", async () => {
|
||||
const boom = new Error("ffmpeg not found");
|
||||
const wrapped = trackCommandFailures(
|
||||
() => Promise.resolve(defineRun(() => Promise.reject(boom))),
|
||||
onFailure,
|
||||
const wrapped = trackCommandFailures(() =>
|
||||
Promise.resolve(defineRun(() => Promise.reject(boom))),
|
||||
);
|
||||
|
||||
const cmd = await wrapped();
|
||||
await expect((cmd.run as () => Promise<unknown>)()).rejects.toBe(boom);
|
||||
expect(onFailure).toHaveBeenCalledWith(boom);
|
||||
});
|
||||
|
||||
it("does not report when run() succeeds, and returns its value", async () => {
|
||||
const onFailure = vi.fn();
|
||||
const wrapped = trackCommandFailures(
|
||||
() => Promise.resolve(defineRun(() => Promise.resolve("ok" as unknown as void))),
|
||||
onFailure,
|
||||
it("returns the command value when run() succeeds", async () => {
|
||||
const wrapped = trackCommandFailures(() =>
|
||||
Promise.resolve(defineRun(() => Promise.resolve("ok" as unknown as void))),
|
||||
);
|
||||
|
||||
const cmd = await wrapped();
|
||||
await expect((cmd.run as () => Promise<unknown>)()).resolves.toBe("ok");
|
||||
expect(onFailure).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an unknown flag on a leaf command", async () => {
|
||||
const wrapped = trackCommandFailures(
|
||||
() =>
|
||||
Promise.resolve({
|
||||
meta: { name: "leaf" },
|
||||
args: { out: { type: "string" } },
|
||||
run: () => Promise.resolve(),
|
||||
} as CommandDef),
|
||||
vi.fn(),
|
||||
const wrapped = trackCommandFailures(() =>
|
||||
Promise.resolve({
|
||||
meta: { name: "leaf" },
|
||||
args: { out: { type: "string" } },
|
||||
run: () => Promise.resolve(),
|
||||
} as CommandDef),
|
||||
);
|
||||
const cmd = await wrapped();
|
||||
await expect(
|
||||
@@ -59,14 +51,12 @@ describe("trackCommandFailures", () => {
|
||||
// `figma component <ref> --name x`: --name belongs to the subcommand's
|
||||
// table; the group (subCommands + fallback-help run) must not reject it.
|
||||
const run = vi.fn(() => Promise.resolve());
|
||||
const wrapped = trackCommandFailures(
|
||||
() =>
|
||||
Promise.resolve({
|
||||
meta: { name: "figma" },
|
||||
subCommands: { component: () => Promise.resolve({ meta: { name: "component" } }) },
|
||||
run,
|
||||
} as unknown as CommandDef),
|
||||
vi.fn(),
|
||||
const wrapped = trackCommandFailures(() =>
|
||||
Promise.resolve({
|
||||
meta: { name: "figma" },
|
||||
subCommands: { component: () => Promise.resolve({ meta: { name: "component" } }) },
|
||||
run,
|
||||
} as unknown as CommandDef),
|
||||
);
|
||||
const cmd = await wrapped();
|
||||
await expect(
|
||||
@@ -78,14 +68,12 @@ describe("trackCommandFailures", () => {
|
||||
});
|
||||
|
||||
it("still rejects an unknown flag when the group is NOT delegating", async () => {
|
||||
const wrapped = trackCommandFailures(
|
||||
() =>
|
||||
Promise.resolve({
|
||||
meta: { name: "figma" },
|
||||
subCommands: { component: () => Promise.resolve({ meta: { name: "component" } }) },
|
||||
run: () => Promise.resolve(),
|
||||
} as unknown as CommandDef),
|
||||
vi.fn(),
|
||||
const wrapped = trackCommandFailures(() =>
|
||||
Promise.resolve({
|
||||
meta: { name: "figma" },
|
||||
subCommands: { component: () => Promise.resolve({ meta: { name: "component" } }) },
|
||||
run: () => Promise.resolve(),
|
||||
} as unknown as CommandDef),
|
||||
);
|
||||
const cmd = await wrapped();
|
||||
await expect(
|
||||
@@ -94,49 +82,29 @@ describe("trackCommandFailures", () => {
|
||||
});
|
||||
|
||||
it("passes through a command with no run() untouched", async () => {
|
||||
const onFailure = vi.fn();
|
||||
const parent: CommandDef = { meta: { name: "parent" } };
|
||||
const wrapped = trackCommandFailures(() => Promise.resolve(parent), onFailure);
|
||||
const wrapped = trackCommandFailures(() => Promise.resolve(parent));
|
||||
|
||||
const cmd = await wrapped();
|
||||
expect(cmd).toBe(parent);
|
||||
expect(onFailure).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("awaits onFailure and re-throws the ORIGINAL error even if onFailure rejects", async () => {
|
||||
const boom = new Error("original failure");
|
||||
const wrapped = trackCommandFailures(
|
||||
() => Promise.resolve(defineRun(() => Promise.reject(boom))),
|
||||
() => Promise.reject(new Error("telemetry is down")),
|
||||
);
|
||||
|
||||
const cmd = await wrapped();
|
||||
await expect((cmd.run as () => Promise<unknown>)()).rejects.toBe(boom);
|
||||
});
|
||||
|
||||
it("REPORTS an unknown-flag rejection to onFailure (HF#2033: assertion inside the try)", async () => {
|
||||
// The flag assertion used to run before the try/catch, so an unknown-flag
|
||||
// throw skipped telemetry. It must now be reported like any other failure.
|
||||
const onFailure = vi.fn();
|
||||
it("re-throws an unknown-flag rejection to the executable boundary", async () => {
|
||||
const cmd = {
|
||||
meta: { name: "render" },
|
||||
args: { output: { type: "string", alias: "o" } },
|
||||
run: vi.fn(() => Promise.resolve()),
|
||||
} as unknown as CommandDef;
|
||||
const wrapped = trackCommandFailures(() => Promise.resolve(cmd), onFailure);
|
||||
const wrapped = trackCommandFailures(() => Promise.resolve(cmd));
|
||||
|
||||
const resolved = await wrapped();
|
||||
await expect(
|
||||
(resolved.run as (ctx: unknown) => Promise<unknown>)({ rawArgs: ["--nope", "x"] }),
|
||||
).rejects.toThrow(/unknown flag/i);
|
||||
expect(onFailure).toHaveBeenCalledTimes(1);
|
||||
expect(cmd.run).not.toHaveBeenCalled(); // body never ran — flag rejected first
|
||||
});
|
||||
|
||||
it("recursively wraps nested subcommands so their failures report too (HF#2033)", async () => {
|
||||
// cli.ts wraps only top-level loaders; a group's leaves (cloud/*, auth/*, …)
|
||||
// were never wrapped, silently dropping unknown-flag + failure telemetry.
|
||||
const onFailure = vi.fn();
|
||||
it("recursively wraps nested subcommands so their failures reach the boundary", async () => {
|
||||
const boom = new Error("nested boom");
|
||||
const group: CommandDef = {
|
||||
meta: { name: "cloud" },
|
||||
@@ -144,7 +112,7 @@ describe("trackCommandFailures", () => {
|
||||
render: defineRun(() => Promise.reject(boom)),
|
||||
},
|
||||
};
|
||||
const wrapped = trackCommandFailures(() => Promise.resolve(group), onFailure);
|
||||
const wrapped = trackCommandFailures(() => Promise.resolve(group));
|
||||
|
||||
const resolvedGroup = await wrapped();
|
||||
const subLoader = (resolvedGroup.subCommands as Record<string, () => Promise<CommandDef>>)
|
||||
@@ -152,7 +120,6 @@ describe("trackCommandFailures", () => {
|
||||
if (!subLoader) throw new Error("expected a wrapped 'render' subcommand loader");
|
||||
const leaf = await subLoader();
|
||||
await expect((leaf.run as () => Promise<unknown>)()).rejects.toBe(boom);
|
||||
expect(onFailure).toHaveBeenCalledWith(boom);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,43 +6,27 @@ import { assertKnownFlags } from "./reject-unknown-flags.js";
|
||||
type AnyCommandDef = CommandDef<any>;
|
||||
|
||||
/**
|
||||
* Wrap a lazy command loader so a thrown failure is reported via `onFailure`
|
||||
* before it propagates. citty's `runMain` catches command errors and exits 1
|
||||
* without re-throwing, so this is the only place to capture the reason. The
|
||||
* error is re-thrown unchanged, preserving citty's print + exit-1 behavior.
|
||||
*
|
||||
* `onFailure` is awaited so it can resolve the (lazily-loaded) telemetry module
|
||||
* before the error propagates — otherwise a command that throws before the
|
||||
* telemetry import settles would lose its event. A throw from `onFailure` is
|
||||
* swallowed so telemetry can never mask the real command failure.
|
||||
*
|
||||
* Commands that call `process.exit()` themselves bypass this (the process is
|
||||
* already gone) and must report their failure inline.
|
||||
* Wrap a lazy command loader so leaf commands and nested subcommands share the
|
||||
* unknown-flag guard. Errors propagate unchanged to the executable boundary,
|
||||
* which is the sole command-failure telemetry reporter.
|
||||
*/
|
||||
export function trackCommandFailures(
|
||||
load: () => Promise<AnyCommandDef>,
|
||||
onFailure: (err: unknown) => void | Promise<void>,
|
||||
): () => Promise<AnyCommandDef> {
|
||||
return () => load().then((cmd) => wrapCommand(cmd, onFailure));
|
||||
return () => load().then((cmd) => wrapCommand(cmd));
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a resolved command's `run` (assert-flags + report-failure) AND
|
||||
* Wrap a resolved command's `run` (assert-flags) AND
|
||||
* recursively wrap every entry in its `subCommands`. Two HF#2033 fixes live
|
||||
* here:
|
||||
* 1. `assertKnownFlags` runs INSIDE the try, so an unknown-flag throw is
|
||||
* routed through `onFailure` (telemetry) like any other failure — it
|
||||
* used to throw before the try and lose the event entirely.
|
||||
* 1. `assertKnownFlags` runs in the wrapped command, so an unknown-flag
|
||||
* throw reaches the executable boundary like every other failure.
|
||||
* 2. Recursion covers nested command groups (`cloud/*`, `auth/*`, `figma/*`,
|
||||
* `lambda/*`, `capture/*`, `skills`). cli.ts only wraps the top-level
|
||||
* loaders, so before this a `hyperframes cloud render --badflag` silently
|
||||
* ignored the flag and reported nothing — citty dispatches to the leaf,
|
||||
* whose `run` was never wrapped.
|
||||
* `lambda/*`, `capture/*`, `skills`). Without it, a nested command's
|
||||
* unknown flags would bypass the leaf's guard.
|
||||
*/
|
||||
function wrapCommand(
|
||||
cmd: AnyCommandDef,
|
||||
onFailure: (err: unknown) => void | Promise<void>,
|
||||
): AnyCommandDef {
|
||||
function wrapCommand(cmd: AnyCommandDef): AnyCommandDef {
|
||||
const run = cmd.run;
|
||||
// Nothing to wrap (no run, no nested subcommands) — preserve identity.
|
||||
if (typeof run !== "function" && !cmd.subCommands) return cmd;
|
||||
@@ -50,36 +34,27 @@ function wrapCommand(
|
||||
const wrapped: AnyCommandDef = { ...cmd };
|
||||
if (typeof run === "function") {
|
||||
wrapped.run = async (ctx: Parameters<typeof run>[0]) => {
|
||||
try {
|
||||
// Reject unknown flags before the command body: citty silently ignores
|
||||
// them otherwise, dropping the value (e.g. `render --out x` fell back to
|
||||
// the default output path). Inside the try so the rejection is reported.
|
||||
//
|
||||
// A command group (subCommands + fallback-help run) delegating to a
|
||||
// subcommand must NOT assert here: the flags belong to the subcommand's
|
||||
// table, not the group's, and would be falsely rejected (e.g.
|
||||
// `figma component <ref> --name x`). The wrapped subcommand loaders
|
||||
// below assert the leaf's own table, so typo protection is preserved.
|
||||
// Heuristic caveat: "first non-dash token names a subcommand" is sound
|
||||
// only while command groups declare no flags of their own — if a group
|
||||
// grows a flag whose value could match a subcommand name, replace this
|
||||
// with a real argv parse.
|
||||
const rawArgs = ctx?.rawArgs ?? [];
|
||||
const firstPositional = rawArgs.find((tok) => tok && !tok.startsWith("-"));
|
||||
const delegatesToSub =
|
||||
cmd.subCommands != null &&
|
||||
firstPositional != null &&
|
||||
Object.prototype.hasOwnProperty.call(cmd.subCommands, firstPositional);
|
||||
if (!delegatesToSub) assertKnownFlags(cmd, rawArgs);
|
||||
return await run(ctx);
|
||||
} catch (err) {
|
||||
try {
|
||||
await onFailure(err);
|
||||
} catch {
|
||||
// Telemetry must never mask the real command failure.
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
// Reject unknown flags before the command body: citty silently ignores
|
||||
// them otherwise, dropping the value (e.g. `render --out x` fell back to
|
||||
// the default output path).
|
||||
//
|
||||
// A command group (subCommands + fallback-help run) delegating to a
|
||||
// subcommand must NOT assert here: the flags belong to the subcommand's
|
||||
// table, not the group's, and would be falsely rejected (e.g.
|
||||
// `figma component <ref> --name x`). The wrapped subcommand loaders
|
||||
// below assert the leaf's own table, so typo protection is preserved.
|
||||
// Heuristic caveat: "first non-dash token names a subcommand" is sound
|
||||
// only while command groups declare no flags of their own — if a group
|
||||
// grows a flag whose value could match a subcommand name, replace this
|
||||
// with a real argv parse.
|
||||
const rawArgs = ctx?.rawArgs ?? [];
|
||||
const firstPositional = rawArgs.find((tok) => tok && !tok.startsWith("-"));
|
||||
const delegatesToSub =
|
||||
cmd.subCommands != null &&
|
||||
firstPositional != null &&
|
||||
Object.prototype.hasOwnProperty.call(cmd.subCommands, firstPositional);
|
||||
if (!delegatesToSub) assertKnownFlags(cmd, rawArgs);
|
||||
return await run(ctx);
|
||||
};
|
||||
}
|
||||
if (cmd.subCommands) {
|
||||
@@ -89,7 +64,7 @@ function wrapCommand(
|
||||
// (possibly async) loader. Normalize to a loader that resolves then wraps.
|
||||
wrappedSubs[name] = () =>
|
||||
Promise.resolve(typeof sub === "function" ? (sub as () => unknown)() : sub).then((c) =>
|
||||
wrapCommand(c as AnyCommandDef, onFailure),
|
||||
wrapCommand(c as AnyCommandDef),
|
||||
);
|
||||
}
|
||||
wrapped.subCommands = wrappedSubs;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
CliResultSignal,
|
||||
CliRuntimeError,
|
||||
CliUsageError,
|
||||
consumeCommandResult,
|
||||
failCommand,
|
||||
failUsage,
|
||||
finishCommand,
|
||||
requestCliExit,
|
||||
setCommandResult,
|
||||
} from "./commandResult.js";
|
||||
|
||||
afterEach(() => {
|
||||
consumeCommandResult();
|
||||
});
|
||||
|
||||
describe("command result contract", () => {
|
||||
it("keeps a failure from being overwritten by a later success", () => {
|
||||
setCommandResult({ exitCode: 1, kind: "runtime_error", presented: true });
|
||||
setCommandResult({ exitCode: 0, kind: "success" });
|
||||
expect(consumeCommandResult().exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("represents fatal and early-success control flow without process.exit", () => {
|
||||
expect(() => failCommand(2)).toThrow(CliRuntimeError);
|
||||
expect(() => failUsage(2)).toThrow(CliUsageError);
|
||||
expect(() => finishCommand()).toThrow(CliResultSignal);
|
||||
});
|
||||
|
||||
it("records a requested exit when no root handler is registered", () => {
|
||||
requestCliExit(1);
|
||||
expect(consumeCommandResult()).toMatchObject({ exitCode: 1, kind: "runtime_error" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
export type CommandResultKind = "success" | "usage_error" | "runtime_error";
|
||||
|
||||
export interface CommandResult {
|
||||
exitCode: number;
|
||||
kind: CommandResultKind;
|
||||
/** The command already emitted its structured/human output. */
|
||||
presented?: boolean;
|
||||
}
|
||||
|
||||
const SUCCESS_RESULT: CommandResult = { exitCode: 0, kind: "success" };
|
||||
let pendingResult: CommandResult = SUCCESS_RESULT;
|
||||
let rootExitRequester: ((exitCode: number) => void) | undefined;
|
||||
let rootExitCodeSanitizer: (() => void) | undefined;
|
||||
|
||||
export class CliUsageError extends Error {
|
||||
readonly result: CommandResult;
|
||||
|
||||
constructor(
|
||||
message = "Invalid command usage",
|
||||
options: { exitCode?: number; presented?: boolean } = {},
|
||||
) {
|
||||
super(message);
|
||||
this.name = "CliUsageError";
|
||||
this.result = {
|
||||
exitCode: options.exitCode ?? 1,
|
||||
kind: "usage_error",
|
||||
presented: options.presented,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class CliRuntimeError extends Error {
|
||||
readonly result: CommandResult;
|
||||
|
||||
constructor(
|
||||
message = "Command failed",
|
||||
options: { exitCode?: number; presented?: boolean } = {},
|
||||
) {
|
||||
super(message);
|
||||
this.name = "CliRuntimeError";
|
||||
this.result = {
|
||||
exitCode: options.exitCode ?? 1,
|
||||
kind: "runtime_error",
|
||||
presented: options.presented,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Internal control-flow signal for an early, already-presented result. */
|
||||
export class CliResultSignal extends Error {
|
||||
readonly result: CommandResult;
|
||||
|
||||
constructor(result: CommandResult) {
|
||||
super(`CLI result ${result.exitCode}`);
|
||||
this.name = "CliResultSignal";
|
||||
this.result = result;
|
||||
}
|
||||
}
|
||||
|
||||
export function failCommand(exitCode = 1): never {
|
||||
throw new CliRuntimeError("Command failed", { exitCode, presented: true });
|
||||
}
|
||||
|
||||
export function failUsage(exitCode = 1): never {
|
||||
throw new CliUsageError("Invalid command usage", { exitCode, presented: true });
|
||||
}
|
||||
|
||||
export function finishCommand(exitCode = 0): never {
|
||||
throw new CliResultSignal({
|
||||
exitCode,
|
||||
kind: exitCode === 0 ? "success" : "runtime_error",
|
||||
presented: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** Record a non-fatal result while allowing output/finalizers to complete. */
|
||||
export function setCommandResult(result: CommandResult): void {
|
||||
if (pendingResult.exitCode !== 0 && result.exitCode === 0) return;
|
||||
pendingResult = result;
|
||||
}
|
||||
|
||||
export function setCommandExitCode(exitCode: number): void {
|
||||
setCommandResult({
|
||||
exitCode,
|
||||
kind: exitCode === 0 ? "success" : "runtime_error",
|
||||
presented: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function consumeCommandResult(): CommandResult {
|
||||
const result = pendingResult;
|
||||
pendingResult = SUCCESS_RESULT;
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Called only by cli.ts to retain ownership of forced process termination. */
|
||||
export function registerRootExitRequester(requester: (exitCode: number) => void): void {
|
||||
rootExitRequester = requester;
|
||||
}
|
||||
|
||||
/** Called only by cli.ts to retain ownership of process exit-code mutation. */
|
||||
export function registerRootExitCodeSanitizer(sanitizer: () => void): void {
|
||||
rootExitCodeSanitizer = sanitizer;
|
||||
}
|
||||
|
||||
/** Ask cli.ts to clear stray process exit state after a successful render. */
|
||||
export function sanitizeSuccessfulExitCode(): void {
|
||||
rootExitCodeSanitizer?.();
|
||||
}
|
||||
|
||||
/** Ask cli.ts to finalize telemetry/output and then terminate the process. */
|
||||
export function requestCliExit(exitCode = 0): void {
|
||||
if (!rootExitRequester) {
|
||||
setCommandResult({
|
||||
exitCode,
|
||||
kind: exitCode === 0 ? "success" : "runtime_error",
|
||||
presented: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
rootExitRequester(exitCode);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { defineCommand } from "citty";
|
||||
import { resolveCommandUsage } from "./commandUsageResolution.js";
|
||||
|
||||
describe("resolveCommandUsage", () => {
|
||||
it("resolves the deepest lazy subcommand and its immediate parent", async () => {
|
||||
const render = defineCommand({
|
||||
meta: { name: "render" },
|
||||
args: { fps: { type: "string" } },
|
||||
});
|
||||
const loadRender = vi.fn(async () => render);
|
||||
const cloud = defineCommand({
|
||||
meta: { name: "cloud" },
|
||||
subCommands: { render: loadRender },
|
||||
});
|
||||
const root = defineCommand({
|
||||
meta: { name: "hyperframes" },
|
||||
subCommands: { cloud: async () => cloud },
|
||||
});
|
||||
|
||||
const resolved = await resolveCommandUsage(root, ["cloud", "render", "--help"]);
|
||||
|
||||
expect(resolved.command).toBe(render);
|
||||
expect(resolved.parent).toBe(cloud);
|
||||
expect(loadRender).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps group and root help at the requested level", async () => {
|
||||
const cloud = defineCommand({ meta: { name: "cloud" }, subCommands: {} });
|
||||
const root = defineCommand({
|
||||
meta: { name: "hyperframes" },
|
||||
subCommands: { cloud: async () => cloud },
|
||||
});
|
||||
|
||||
expect((await resolveCommandUsage(root, ["cloud", "--help"])).command).toBe(cloud);
|
||||
expect((await resolveCommandUsage(root, ["--help"])).command).toBe(root);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { CommandDef } from "citty";
|
||||
|
||||
type LazyCommandDef = CommandDef | (() => CommandDef | Promise<CommandDef>);
|
||||
|
||||
export interface ResolvedCommandUsage {
|
||||
command: CommandDef;
|
||||
parent?: CommandDef;
|
||||
}
|
||||
|
||||
async function loadSubcommand(command: CommandDef, name: string): Promise<CommandDef | undefined> {
|
||||
const subcommands = command.subCommands as Record<string, LazyCommandDef> | undefined;
|
||||
const candidate = subcommands?.[name];
|
||||
if (!candidate) return undefined;
|
||||
return typeof candidate === "function" ? candidate() : candidate;
|
||||
}
|
||||
|
||||
/** Resolve the deepest adjacent citty subcommand named by argv. */
|
||||
export async function resolveCommandUsage(
|
||||
root: CommandDef,
|
||||
argv: readonly string[],
|
||||
): Promise<ResolvedCommandUsage> {
|
||||
let command = root;
|
||||
let parent: CommandDef | undefined;
|
||||
for (const argument of argv) {
|
||||
if (argument.startsWith("-")) break;
|
||||
const child = await loadSubcommand(command, argument);
|
||||
if (!child) break;
|
||||
parent = command;
|
||||
command = child;
|
||||
}
|
||||
return { command, parent };
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { failCommand } from "./commandResult.js";
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { resolve, basename } from "node:path";
|
||||
import { errorBox } from "../ui/format.js";
|
||||
@@ -72,7 +73,7 @@ export function resolveProject(
|
||||
// outside a project; the redaction in trackCliError strips the dir path.
|
||||
trackCommandFailure(process.argv[2] ?? "unknown", err);
|
||||
errorBox(err.title, err.hint, err.suggestion);
|
||||
process.exit(1);
|
||||
failCommand();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerRootExitCodeSanitizer } from "./commandResult.js";
|
||||
import {
|
||||
_resetRenderSuccessForTests,
|
||||
isRenderSucceeded,
|
||||
@@ -38,6 +39,12 @@ describe("render-success-state flag", () => {
|
||||
describe("runPostRenderStep", () => {
|
||||
const originalExitCode = process.exitCode;
|
||||
|
||||
beforeEach(() => {
|
||||
registerRootExitCodeSanitizer(() => {
|
||||
process.exitCode = 0;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.exitCode = originalExitCode;
|
||||
_resetRenderSuccessForTests();
|
||||
@@ -118,6 +125,12 @@ describe("runPostRenderStep", () => {
|
||||
describe("runPostRenderStepAsync", () => {
|
||||
const originalExitCode = process.exitCode;
|
||||
|
||||
beforeEach(() => {
|
||||
registerRootExitCodeSanitizer(() => {
|
||||
process.exitCode = 0;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.exitCode = originalExitCode;
|
||||
_resetRenderSuccessForTests();
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
* still exited 1 after the terminal "artifact validated" checkpoint.
|
||||
*/
|
||||
|
||||
import { sanitizeSuccessfulExitCode } from "./commandResult.js";
|
||||
|
||||
let renderSucceeded = false;
|
||||
|
||||
/**
|
||||
@@ -57,8 +59,8 @@ const defaultErrorSink: PostRenderErrorSink = (message) => {
|
||||
* Run a post-artifact-validated cleanup step so a throw cannot flip the CLI
|
||||
* exit code. `markRenderSucceeded()` MUST have been called first — this
|
||||
* helper is only safe on the success path where the artifact is already
|
||||
* committed to disk. Logs a compact warning to stderr, resets a stray
|
||||
* `process.exitCode` back to 0, and swallows the error.
|
||||
* committed to disk. Logs a compact warning to stderr, asks the root CLI to
|
||||
* clear a stray exit code, and swallows the error.
|
||||
*/
|
||||
export function runPostRenderStep(
|
||||
label: string,
|
||||
@@ -87,9 +89,7 @@ export async function runPostRenderStepAsync(
|
||||
function reportPostRenderStepFailure(label: string, err: unknown, sink: PostRenderErrorSink): void {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
sink(` [hyperframes] Post-render step '${label}' failed (render already succeeded): ${message}`);
|
||||
// Guard against the failing step (or something it triggered) setting a
|
||||
// non-zero exitCode. The render succeeded → the CLI must exit 0.
|
||||
if (process.exitCode !== undefined && process.exitCode !== 0) {
|
||||
process.exitCode = 0;
|
||||
}
|
||||
// The failing step (or something it triggered) may have set a non-zero
|
||||
// exitCode. The render succeeded, so ask the root CLI owner to clear it.
|
||||
sanitizeSuccessfulExitCode();
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { failUsage } from "./commandResult.js";
|
||||
/**
|
||||
* Pure parsers for `hyperframes render` argv that aren't already shared
|
||||
* (fps, quality, format, variables live elsewhere). Lives separately so
|
||||
@@ -112,7 +113,7 @@ export function resolveBrowserTimeoutMsArg(raw: string | undefined): number | un
|
||||
if (!result.ok) {
|
||||
const { title, message, hint } = browserTimeoutErrorMessage(result.error);
|
||||
errorBox(title, message, hint);
|
||||
process.exit(1);
|
||||
failUsage();
|
||||
}
|
||||
return result.value;
|
||||
}
|
||||
@@ -234,7 +235,7 @@ export function resolveCompositionEntryArg(
|
||||
if (!result.ok) {
|
||||
const { title, message, hint } = compositionEntryErrorMessage(result.error);
|
||||
errorBox(title, message, hint);
|
||||
process.exit(1);
|
||||
failUsage();
|
||||
}
|
||||
return result.value;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { failCommand } from "./commandResult.js";
|
||||
/**
|
||||
* Shared `--variables` / `--variables-file` / `--strict-variables` parsing
|
||||
* and validation helpers used by both `hyperframes render` (in-process) and
|
||||
@@ -134,7 +135,7 @@ export function resolveVariablesArg(
|
||||
if (!result.ok) {
|
||||
const { title, message } = variablesErrorMessage(result.error);
|
||||
errorBox(title, message);
|
||||
process.exit(1);
|
||||
failCommand();
|
||||
}
|
||||
return result.value;
|
||||
}
|
||||
@@ -227,6 +228,6 @@ export function reportVariableIssues(
|
||||
"Variable validation failed",
|
||||
"Aborting render due to variable issues (--strict-variables mode).",
|
||||
);
|
||||
process.exit(1);
|
||||
failCommand();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// fallow-ignore-file complexity
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ts from "typescript";
|
||||
|
||||
const ROOT = join(import.meta.dirname, "..");
|
||||
|
||||
export function listDirectProcessTermination(source, filename = "source.ts") {
|
||||
const sourceFile = ts.createSourceFile(filename, source, ts.ScriptTarget.Latest, true);
|
||||
const issues = [];
|
||||
|
||||
function visit(node) {
|
||||
if (
|
||||
ts.isPropertyAccessExpression(node) &&
|
||||
ts.isIdentifier(node.expression) &&
|
||||
node.expression.text === "process" &&
|
||||
(node.name.text === "exit" || node.name.text === "exitCode")
|
||||
) {
|
||||
const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
|
||||
issues.push(
|
||||
`${filename}:${position.line + 1}:${position.character + 1} uses process.${node.name.text}`,
|
||||
);
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
|
||||
visit(sourceFile);
|
||||
return issues;
|
||||
}
|
||||
|
||||
function listTypeScriptFiles(directory, rootCliPath) {
|
||||
return readdirSync(directory).flatMap((name) => {
|
||||
const path = join(directory, name);
|
||||
if (statSync(path).isDirectory()) return listTypeScriptFiles(path, rootCliPath);
|
||||
if (!path.endsWith(".ts") || path.endsWith(".test.ts") || path === rootCliPath) return [];
|
||||
return [path];
|
||||
});
|
||||
}
|
||||
|
||||
export function checkCliProcessOwnership(root = ROOT) {
|
||||
const sourceRoot = join(root, "packages/cli/src");
|
||||
if (!existsSync(sourceRoot)) return [];
|
||||
return listTypeScriptFiles(sourceRoot, join(sourceRoot, "cli.ts")).flatMap((path) =>
|
||||
listDirectProcessTermination(readFileSync(path, "utf8"), relative(root, path)),
|
||||
);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const issues = checkCliProcessOwnership();
|
||||
if (issues.length > 0) {
|
||||
console.error("CLI process ownership violations:");
|
||||
issues.forEach((issue) => console.error(`- ${issue}`));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
console.log("CLI process ownership verified: only cli.ts terminates the process.");
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) main();
|
||||
@@ -0,0 +1,28 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { listDirectProcessTermination } from "./check-cli-process-ownership.mjs";
|
||||
|
||||
describe("CLI process ownership checker", () => {
|
||||
it("reports direct process exit and exitCode writes", () => {
|
||||
assert.deepEqual(
|
||||
listDirectProcessTermination(
|
||||
"process.exit(1);\nprocess.exitCode = 2;\n",
|
||||
"commands/example.ts",
|
||||
),
|
||||
[
|
||||
"commands/example.ts:1:1 uses process.exit",
|
||||
"commands/example.ts:2:1 uses process.exitCode",
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores typed command-result helpers and text in comments", () => {
|
||||
assert.deepEqual(
|
||||
listDirectProcessTermination(
|
||||
"// process.exit(1)\nfailCommand();\nsetCommandExitCode(1);\n",
|
||||
"commands/example.ts",
|
||||
),
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user