mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(cli): exit 2 for remaining flag validation errors (#6639)
* fix(cli): exit 2 for remaining flag validation errors Flag and argument checks that commands make themselves still exited 1 and reported runtime_error under --json. They now take the existing usage-error path (failUsage / CliUsageError): exit 2 and a usage_error envelope. This covers lambda and cloudrun render/render-batch (--width/--height, --fps, positive-number, enum and --output-resolution flags, unknown subcommands), capture, feedback, benchmark --runs, tts, init, snapshot, compare, grade-compare, check, normalize-audio, media-treatment, play/present --port, models, docs, browser and telemetry unknown subcommands, cloud render conflicting inputs, cloud delete without --no-confirm, and inline --variables. A few checks now run before project or stack lookup so a bad flag is reported first. Runtime failures (missing files, network, render errors) still exit 1. An unattended spinner now writes its start and progress text as plain stderr lines, not only the outcome, so tools watching init can see "Transcoding". Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(cli): reject a bad snapshot --frames, quiet spinner progress, drop added comments - `snapshot --frames abc` (or 0, -3, 2.5) is now a usage error: exit 2, and a usage_error envelope under --json. It used to fall back to 5 frames silently. - An unattended spinner prints its start line once and its outcome, but no longer one line per progress update, which flooded stderr during remove-background and the Chrome download. - Removes the comment lines this branch added, so the comment ratchet passes. - The render --json test now expects exit 2 and usage_error for bad inline --variables. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b84762230f
commit
2d3033c719
@@ -190,6 +190,79 @@ describe("agent-mode contract", () => {
|
||||
expect(run.code, run.stderr).toBe(2);
|
||||
});
|
||||
|
||||
// A bad flag value or flag combination a command checks itself is a usage error like any other:
|
||||
// exit 2, and under --json a usage_error envelope that names the problem.
|
||||
describe.concurrent("flag checks a command makes itself", () => {
|
||||
it.each([
|
||||
["lambda render proj", "--width and --height are required"],
|
||||
["lambda render proj --width 1920 --height 1080 --fps 7", "--fps must be one of"],
|
||||
["lambda render proj --width 0 --height 1080", "--width must be a positive integer"],
|
||||
["lambda render proj --width 1920 --height 1080 --chunk-size 0", "--chunk-size must be"],
|
||||
["lambda render proj --width 1920 --height 1080 --format avi", "--format must be"],
|
||||
["lambda render proj --width 1920 --height 1080 --output-resolution 8k", "8k"],
|
||||
["lambda render-batch proj --batch b.jsonl", "--width and --height are required"],
|
||||
["lambda render-batch proj --batch b.jsonl --width 1920 --height 1080 --fps 7", "--fps"],
|
||||
["lambda deploy --concurrency 0", "--concurrency must be"],
|
||||
["lambda sites bogus", 'unknown verb "bogus"'],
|
||||
["lambda bogus", "Unknown subcommand"],
|
||||
["cloudrun render proj", "--width and --height are required"],
|
||||
["cloudrun render proj --width 1920 --height 1080 --fps 7", "--fps must be one of"],
|
||||
["cloudrun render proj --width 1920 --height 1080 --chunk-size 0", "--chunk-size must be"],
|
||||
["cloudrun render proj --width 1920 --height 1080 --wait-interval-ms 0", "--wait-interval"],
|
||||
["cloudrun render proj --width 1920 --height 1080 --variables {bad", "--variables"],
|
||||
["cloudrun render-batch proj --batch b.jsonl", "--width and --height are required"],
|
||||
["cloudrun render-batch proj --batch b.jsonl --width 1920 --height 1080 --fps 7", "--fps"],
|
||||
["cloudrun render-batch proj --batch b.jsonl --width 640 --height 360 --format avi", "avi"],
|
||||
["cloudrun sites bogus", 'unknown verb "bogus"'],
|
||||
["cloudrun bogus", "Unknown subcommand"],
|
||||
["capture https://example.com --max-screenshots 0", "--max-screenshots must be"],
|
||||
["capture https://example.com --capture-budget 0", "--capture-budget must be"],
|
||||
["capture https://example.com --local-file page.html", "not both"],
|
||||
["capture not-a-url", "Invalid URL"],
|
||||
["benchmark --runs 0", "Invalid runs"],
|
||||
["models bogus", "Unknown: models bogus"],
|
||||
["tts", "Provide text to speak"],
|
||||
["tts hello --speed 9", "Speed must be"],
|
||||
["cloud render --asset-id a --url https://example.com/p.zip", "Conflicting inputs"],
|
||||
["cloud delete render_1", "Confirmation required"],
|
||||
["snapshot --at 1 --range 0,1", "Use only one of"],
|
||||
["snapshot --at x", "--at must be"],
|
||||
["snapshot --frames abc", "--frames must be a positive integer"],
|
||||
["snapshot --frames -3", "--frames must be a positive integer"],
|
||||
["compare a.html", "need 2+ paths"],
|
||||
["compare a.html b.html --cols 0", "--cols must be"],
|
||||
["grade-compare --for frame.png", "Exactly one of --grades or --luts"],
|
||||
["check --range 5,1", "Invalid --range"],
|
||||
["normalize-audio --target music --tolerance=-1", "--tolerance must be"],
|
||||
["media-treatment --capabilities --all --capability x", "--all or --capability"],
|
||||
])("`%s` exits 2 with a usage_error envelope", async (args, message) => {
|
||||
const run = await runAsAgent([...args.split(" "), "--json"]);
|
||||
expectQuiet(run);
|
||||
expect(run.code, run.stderr).toBe(2);
|
||||
expectJsonFailure(run, "usage_error");
|
||||
expect(JSON.parse(run.stdout).error.message).toContain(message);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["feedback --rating 99", "Rating must be an integer between 0 and 10"],
|
||||
["feedback", "Give a --rating"],
|
||||
["feedback --comment x --file-issue", "--file-issue needs a --rating"],
|
||||
["feedback --rating 5 --source bot", "Source must be"],
|
||||
["browser bogus", "Unknown subcommand"],
|
||||
["telemetry bogus", "Unknown subcommand"],
|
||||
["docs bogus", "Unknown topic"],
|
||||
["init demo --template x --non-interactive", "renamed to --example"],
|
||||
["init demo --video a.mp4 --audio b.mp3 --non-interactive", "--video and --audio"],
|
||||
["play --port 0", "--port must be"],
|
||||
["present --port 0", "--port must be"],
|
||||
])("`%s` has no JSON output and exits 2 naming the problem", async (args, message) => {
|
||||
const run = await runAsAgent(args.split(" "));
|
||||
expect(run.signal, "finished before the timeout").toBeNull();
|
||||
expect(run.code, run.stderr).toBe(2);
|
||||
expect(run.stdout + run.stderr).toContain(message);
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["help", "Usage:"],
|
||||
["help render", "USAGE hyperframes render"],
|
||||
@@ -256,9 +329,9 @@ describe("agent-mode contract", () => {
|
||||
writeFileSync(join(project, "index.html"), "<!doctype html><html><body></body></html>");
|
||||
const run = await runAsAgent(["render", project, "--variables", "{bad", "--json"]);
|
||||
expectQuiet(run);
|
||||
expect(run.code, run.stderr).toBe(1);
|
||||
expect(run.code, run.stderr).toBe(2);
|
||||
expect(run.stdout.trim().split("\n")).toHaveLength(1);
|
||||
expectJsonFailure(run, "runtime_error");
|
||||
expectJsonFailure(run, "usage_error");
|
||||
expect(JSON.parse(run.stdout).error.message).toBe("Invalid JSON in --variables");
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { failCommand, setCommandExitCode } from "../utils/commandResult.js";
|
||||
import { failCommand, failUsage, setCommandExitCode } from "../utils/commandResult.js";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import type { Example } from "./_examples.js";
|
||||
import { existsSync, mkdtempSync, rmSync, statSync } from "node:fs";
|
||||
@@ -59,16 +59,16 @@ export default defineCommand({
|
||||
json: { type: "boolean", description: "Output results as JSON", default: false },
|
||||
},
|
||||
async run({ args }) {
|
||||
// ── Resolve project ──────────────────────────────────────────────────
|
||||
const project = resolveProject(args.dir);
|
||||
|
||||
// ── Parse runs ───────────────────────────────────────────────────────
|
||||
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.`);
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
|
||||
// ── Resolve project ──────────────────────────────────────────────────
|
||||
const project = resolveProject(args.dir);
|
||||
|
||||
const jsonOutput = args.json ?? false;
|
||||
|
||||
// ── Temp output for benchmark renders ────────────────────────────────
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { failCommand } from "../utils/commandResult.js";
|
||||
import { failCommand, failUsage } from "../utils/commandResult.js";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import type { Example } from "./_examples.js";
|
||||
import * as clack from "../ui/prompts.js";
|
||||
@@ -182,7 +182,7 @@ ${c.bold("EXAMPLES:")}
|
||||
console.error(
|
||||
`${c.error("Unknown subcommand:")} ${subcommand}\n\nRun ${c.accent("hyperframes browser --help")} for usage.`,
|
||||
);
|
||||
failCommand(1, `Unknown subcommand: ${subcommand}`);
|
||||
failUsage();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -169,7 +169,7 @@ describe("capture command — vision control", () => {
|
||||
json: true,
|
||||
},
|
||||
} as never),
|
||||
).rejects.toBeInstanceOf(CliRuntimeError);
|
||||
).rejects.toBeInstanceOf(CliUsageError);
|
||||
|
||||
expect(captureWebsiteMock).not.toHaveBeenCalled();
|
||||
},
|
||||
@@ -189,7 +189,7 @@ describe("capture command — vision control", () => {
|
||||
json: true,
|
||||
},
|
||||
} as never),
|
||||
).rejects.toBeInstanceOf(CliRuntimeError);
|
||||
).rejects.toBeInstanceOf(CliUsageError);
|
||||
expect(error).toHaveBeenCalledWith("--max-screenshots must be a positive integer.");
|
||||
expect(captureWebsiteMock).not.toHaveBeenCalled();
|
||||
},
|
||||
@@ -232,7 +232,7 @@ describe("capture command — vision control", () => {
|
||||
captureCommand.run!({
|
||||
args: { ...target, output: "/tmp/hf-local-refused", json: true },
|
||||
} as never),
|
||||
).rejects.toThrow("local-root" in target ? CliUsageError : CliRuntimeError);
|
||||
).rejects.toThrow("url" in target ? CliUsageError : CliRuntimeError);
|
||||
expect(error.mock.calls.flat().join("\n")).toContain(message);
|
||||
expect(captureWebsiteMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ function parsePositiveInteger(
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
console.error(`${flag} must be a positive integer${unit}.`);
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
@@ -152,7 +152,7 @@ export default defineCommand({
|
||||
if (localFile !== undefined) {
|
||||
if (url) {
|
||||
console.error("Pass either a URL or --local-file, not both.");
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
const { resolveLocalCaptureTarget } = await import("../capture/localPage.js");
|
||||
try {
|
||||
@@ -176,7 +176,7 @@ export default defineCommand({
|
||||
new URL(url);
|
||||
} catch {
|
||||
console.error(`Invalid URL: ${url}`);
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,11 @@ import {
|
||||
type MotionSpecResolution,
|
||||
} from "../utils/checkPipeline.js";
|
||||
import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js";
|
||||
import { consumeCommandResult, registerRootExitRequester } from "../utils/commandResult.js";
|
||||
import {
|
||||
CliUsageError,
|
||||
consumeCommandResult,
|
||||
registerRootExitRequester,
|
||||
} from "../utils/commandResult.js";
|
||||
import { ambiguousIssue } from "../utils/motionAudit.js";
|
||||
import type { ProjectLintResult } from "../utils/lintProject.js";
|
||||
import type {
|
||||
@@ -654,7 +658,9 @@ it("prints a dash-prefixed --frame-check value's parse failure exactly once, not
|
||||
withMeta: (value) => value,
|
||||
});
|
||||
|
||||
await runViaCli(command, { rawArgs: ["--frame-check=--json"] });
|
||||
await expect(runViaCli(command, { rawArgs: ["--frame-check=--json"] })).rejects.toBeInstanceOf(
|
||||
CliUsageError,
|
||||
);
|
||||
|
||||
expect(runPipeline).not.toHaveBeenCalled();
|
||||
const matching = errorLog.mock.calls.filter(
|
||||
@@ -841,16 +847,20 @@ it("rejects malformed caption-zone specs instead of silently disabling the gate"
|
||||
withMeta: (value) => ({ ...value, _meta: { version: "test" } }),
|
||||
});
|
||||
|
||||
await runCommand(command, {
|
||||
rawArgs: ["--json", "--caption-zone", "x0=0;y0=.8;x1=1;y1=1.2"],
|
||||
});
|
||||
await expect(
|
||||
runCommand(command, {
|
||||
rawArgs: ["--json", "--caption-zone", "x0=0;y0=.8;x1=1;y1=1.2"],
|
||||
}),
|
||||
).rejects.toMatchObject({ result: { exitCode: 2, kind: "usage_error" } });
|
||||
|
||||
expect(runPipeline).not.toHaveBeenCalled();
|
||||
expect(consumeCommandResult().exitCode).toBe(1);
|
||||
expect(log).toHaveBeenCalledTimes(1);
|
||||
expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({
|
||||
ok: false,
|
||||
error: expect.objectContaining({ message: expect.stringContaining("Invalid --caption-zone") }),
|
||||
error: expect.objectContaining({
|
||||
code: "usage_error",
|
||||
message: expect.stringContaining("Invalid --caption-zone"),
|
||||
}),
|
||||
_meta: { version: "test" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ import { parseAt } from "./layout.js";
|
||||
import { c } from "../ui/colors.js";
|
||||
import { registerBrowserSessionCleanup } from "../browser/launch.js";
|
||||
import { normalizeErrorMessage } from "../utils/errorMessage.js";
|
||||
import { setCommandExitCode } from "../utils/commandResult.js";
|
||||
import { CliUsageError, failUsage, setCommandExitCode } from "../utils/commandResult.js";
|
||||
import { swallowedFlagUsageError } from "../utils/reject-unknown-flags.js";
|
||||
import { formatLayoutIssue } from "../utils/layoutAudit.js";
|
||||
import { resolveProject, type ProjectDir } from "../utils/project.js";
|
||||
@@ -278,8 +278,8 @@ export function createCheckCommand(
|
||||
|
||||
const reported = untilReported();
|
||||
try {
|
||||
const project = dependencies.resolveProject(args.dir);
|
||||
const options = parseCheckOptions(args);
|
||||
const project = dependencies.resolveProject(args.dir);
|
||||
if (!asJson) {
|
||||
console.log(`${c.accent("◆")} Checking ${c.accent(project.name)}`);
|
||||
}
|
||||
@@ -303,15 +303,7 @@ export function createCheckCommand(
|
||||
}
|
||||
setCommandExitCode(checkExitCode(report));
|
||||
} catch (error) {
|
||||
const message = failureMessage(error);
|
||||
if (asJson) {
|
||||
console.log(
|
||||
JSON.stringify(dependencies.withMeta(jsonFailure("runtime_error", message)), null, 2),
|
||||
);
|
||||
} else {
|
||||
console.error(`${c.error("✗")} Check failed: ${message}`);
|
||||
}
|
||||
setCommandExitCode(1);
|
||||
reportCheckFailure(error, asJson, (value) => dependencies.withMeta(value));
|
||||
} finally {
|
||||
reported.done();
|
||||
}
|
||||
@@ -319,6 +311,23 @@ export function createCheckCommand(
|
||||
});
|
||||
}
|
||||
|
||||
function reportCheckFailure(
|
||||
error: unknown,
|
||||
asJson: boolean,
|
||||
addMeta: (value: object) => object,
|
||||
): void {
|
||||
const message = failureMessage(error);
|
||||
const usage = error instanceof CliUsageError;
|
||||
if (asJson) {
|
||||
const code = usage ? error.code : "runtime_error";
|
||||
console.log(JSON.stringify(addMeta(jsonFailure(code, message)), null, 2));
|
||||
} else {
|
||||
console.error(`${c.error("✗")} Check failed: ${message}`);
|
||||
}
|
||||
if (usage) failUsage();
|
||||
setCommandExitCode(1);
|
||||
}
|
||||
|
||||
const REPORT_GRACE_MS = 5_000;
|
||||
|
||||
function untilReported(): { done: () => void } {
|
||||
@@ -427,7 +436,7 @@ export function parseCheckRange(value: unknown): [number, number] | undefined {
|
||||
start < 0 ||
|
||||
end <= start
|
||||
)
|
||||
throw new Error("Invalid --range: use non-negative start,end with end > start");
|
||||
throw new CliUsageError("Invalid --range: use non-negative start,end with end > start");
|
||||
return [start, end];
|
||||
}
|
||||
|
||||
@@ -474,7 +483,7 @@ function parseFrameCheckTolerance(raw: string | undefined): number | undefined {
|
||||
}
|
||||
|
||||
function frameCheckError(): Error {
|
||||
return new Error(
|
||||
return new CliUsageError(
|
||||
'Invalid --frame-check: use bare --frame-check or "severity=warning|error;seek=.25,.75;tol=4" (all fields optional)',
|
||||
);
|
||||
}
|
||||
@@ -513,7 +522,7 @@ function parseProseCoverageFloor(raw: string | undefined): number | undefined {
|
||||
}
|
||||
|
||||
function layoutError(): Error {
|
||||
return new Error(
|
||||
return new CliUsageError(
|
||||
'Invalid --layout: use "proseCoverageFloor=0.05" with a fraction from 0 to 1 (inclusive)',
|
||||
);
|
||||
}
|
||||
@@ -615,7 +624,7 @@ function captionSeeks(
|
||||
}
|
||||
|
||||
function captionZoneError(): Error {
|
||||
return new Error(
|
||||
return new CliUsageError(
|
||||
'Invalid --caption-zone; use "x0=0;y0=.82;x1=1;y1=1[;severity=warning|error][;seek=.5,1]" with fractions from 0 to 1.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { failCommand } from "../../utils/commandResult.js";
|
||||
import { failCommand, failUsage } from "../../utils/commandResult.js";
|
||||
/**
|
||||
* `hyperframes cloud delete <render_id>` — soft-delete a cloud render.
|
||||
*
|
||||
@@ -55,7 +55,7 @@ export default defineCommand({
|
||||
"delete cannot prompt for confirmation here: this run cannot answer a prompt, or --json was passed.",
|
||||
"Re-run with --no-confirm to acknowledge the irreversible delete.",
|
||||
);
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
const ok = await confirmDelete(args.id);
|
||||
if (!ok) {
|
||||
|
||||
@@ -425,7 +425,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.");
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
if (explicit.assetId) return { kind: "asset_id", assetId: opts.assetId };
|
||||
if (explicit.url) return { kind: "url", url: opts.url };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// fallow-ignore-file code-duplication
|
||||
import { failCommand, failUsage } from "../utils/commandResult.js";
|
||||
import { CliUsageError, failCommand, failUsage } from "../utils/commandResult.js";
|
||||
/**
|
||||
* `hyperframes cloudrun` — deploy + drive distributed renders on Google
|
||||
* Cloud Run + Cloud Workflows.
|
||||
@@ -259,7 +259,7 @@ export default defineCommand({
|
||||
return runDestroy(args);
|
||||
default:
|
||||
console.error(`${c.error("Unknown subcommand:")} ${subcommand}\n${HELP}`);
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -492,7 +492,7 @@ async function runSites(args: Record<string, unknown>): Promise<void> {
|
||||
console.error(
|
||||
`[cloudrun sites] unknown verb "${String(args.target)}". Only "create" is supported.`,
|
||||
);
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
const projectDir = args.extra as string | undefined;
|
||||
if (!projectDir) {
|
||||
@@ -528,7 +528,7 @@ function resolveCloudRunFps(
|
||||
console.error(
|
||||
`[cloudrun ${command}] --fps must be one of ${DISTRIBUTED_FPS.join(", ")}; got ${fps}.`,
|
||||
);
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
@@ -544,12 +544,13 @@ async function runRender(args: Record<string, unknown>): Promise<void> {
|
||||
const height = parsePositiveInt(args.height, "--height");
|
||||
if (width === undefined || height === undefined) {
|
||||
console.error("[cloudrun render] --width and --height are required.");
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
const fps = resolveCloudRunFps(args, projectDir, "render");
|
||||
const state = readState(args);
|
||||
const variables = resolveAndValidateVariables(args, resolve(projectDir));
|
||||
const config = buildRenderConfig(args, fps, width, height, variables);
|
||||
const intervalMs = parsePositiveInt(args["wait-interval-ms"], "--wait-interval-ms") ?? 5000;
|
||||
const state = readState(args);
|
||||
|
||||
const { renderToCloudRun, getRenderProgress } = await loadCloudRunAdapter();
|
||||
const handle = await renderToCloudRun({
|
||||
@@ -576,7 +577,6 @@ async function runRender(args: Record<string, unknown>): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalMs = parsePositiveInt(args["wait-interval-ms"], "--wait-interval-ms") ?? 5000;
|
||||
let progress = await getRenderProgress({ executionName: handle.executionName });
|
||||
while (progress.status === "running") {
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
@@ -650,9 +650,10 @@ async function runRenderBatch(args: Record<string, unknown>): Promise<void> {
|
||||
const height = parsePositiveInt(args.height, "--height");
|
||||
if (width === undefined || height === undefined) {
|
||||
console.error("[cloudrun render-batch] --width and --height are required.");
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
const fps = resolveCloudRunFps(args, projectDir, "render-batch");
|
||||
buildRenderConfig(args, fps, width, height, undefined);
|
||||
if (!existsSync(resolve(batchPath))) {
|
||||
console.error(`[cloudrun render-batch] batch file not found: ${batchPath}`);
|
||||
failCommand();
|
||||
@@ -674,9 +675,9 @@ async function runRenderBatch(args: Record<string, unknown>): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = readState(args);
|
||||
const maxConcurrent =
|
||||
parsePositiveInt(args["max-concurrent"], "--max-concurrent") ?? DEFAULT_BATCH_MAX_CONCURRENT;
|
||||
const state = readState(args);
|
||||
const { deploySite, renderToCloudRun } = await loadCloudRunAdapter();
|
||||
|
||||
// Upload the project once; every entry reuses the same content-addressed
|
||||
@@ -898,7 +899,7 @@ function parsePositiveInt(raw: unknown, flagName: string): number | undefined {
|
||||
const n = parseIntFlag(raw);
|
||||
if (n === undefined) return undefined;
|
||||
if (!Number.isInteger(n) || n < 1) {
|
||||
throw new Error(`[cloudrun] ${flagName} must be a positive integer; got ${n}`);
|
||||
throw new CliUsageError(`[cloudrun] ${flagName} must be a positive integer; got ${n}`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
@@ -912,7 +913,7 @@ function parseEnum<T extends string>(
|
||||
if (raw === undefined || raw === null || raw === "") return defaultValue;
|
||||
const s = String(raw);
|
||||
if ((allowed as readonly string[]).includes(s)) return s as T;
|
||||
throw new Error(`${errorPrefix} must be ${allowed.join("|")}; got ${s}`);
|
||||
throw new CliUsageError(`${errorPrefix} must be ${allowed.join("|")}; got ${s}`);
|
||||
}
|
||||
const FORMATS = ["mp4", "mov", "png-sequence", "webm"] as const;
|
||||
const CODECS = ["h264", "h265"] as const;
|
||||
|
||||
@@ -27,6 +27,7 @@ import { withMeta } from "../utils/updateCheck.js";
|
||||
import type { Example } from "./_examples.js";
|
||||
import { isPathInside } from "@hyperframes/parsers/asset-paths";
|
||||
import { failCaught } from "../utils/jsonError.js";
|
||||
import { CliUsageError } from "../utils/commandResult.js";
|
||||
|
||||
const MAX_COMPARE_VARIANTS = 16;
|
||||
const MAX_COLUMNS = 4;
|
||||
@@ -95,10 +96,12 @@ function parseLabels(value: unknown, pathCount: number): string[] | undefined {
|
||||
if (!raw) return undefined;
|
||||
const labels = raw.split(",").map((part) => part.trim());
|
||||
if (labels.some((label) => label.length === 0)) {
|
||||
throw new Error("--labels entries must be non-empty");
|
||||
throw new CliUsageError("--labels entries must be non-empty");
|
||||
}
|
||||
if (labels.length !== pathCount) {
|
||||
throw new Error(`--labels count (${labels.length}) must match path count (${pathCount})`);
|
||||
throw new CliUsageError(
|
||||
`--labels count (${labels.length}) must match path count (${pathCount})`,
|
||||
);
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
@@ -108,7 +111,7 @@ function parseAtSeconds(value: unknown): number {
|
||||
if (!raw) return 0;
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
throw new Error("--at must be a non-negative number of seconds");
|
||||
throw new CliUsageError("--at must be a non-negative number of seconds");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
@@ -118,7 +121,7 @@ function parseColumns(value: unknown): number | undefined {
|
||||
if (!raw) return undefined;
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isInteger(parsed) || parsed < 1) {
|
||||
throw new Error("--cols must be a positive integer");
|
||||
throw new CliUsageError("--cols must be a positive integer");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
@@ -141,7 +144,7 @@ export function parseCompareArgs(
|
||||
): ParsedCompareArgs {
|
||||
const paths = parsePathArgs(args);
|
||||
if (paths.length < 2) {
|
||||
throw new Error("need 2+ paths to compare");
|
||||
throw new CliUsageError("need 2+ paths to compare");
|
||||
}
|
||||
|
||||
const labels = parseLabels(args.labels, paths.length);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { failCommand } from "../utils/commandResult.js";
|
||||
import { failCommand, failUsage } from "../utils/commandResult.js";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import type { Example } from "./_examples.js";
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
@@ -128,7 +128,7 @@ export default defineCommand({
|
||||
for (const name of Object.keys(TOPICS)) {
|
||||
console.error(` ${c.accent(name)}`);
|
||||
}
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
|
||||
const filePath = join(docsDir(), entry.file);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { CliUsageError } from "../utils/commandResult.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
// Never settles: waiting on it would hang the command.
|
||||
@@ -57,7 +58,9 @@ it("refuses a source that is neither person nor agent, and sends nothing", async
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
mocks.submitFeedback.mockClear();
|
||||
const { default: feedback } = await import("./feedback.js");
|
||||
await expect(feedback.run?.({ args: { rating: "6", source: "bot" } } as never)).rejects.toThrow();
|
||||
await expect(feedback.run?.({ args: { rating: "6", source: "bot" } } as never)).rejects.toThrow(
|
||||
CliUsageError,
|
||||
);
|
||||
expect(mocks.submitFeedback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -82,16 +85,16 @@ it.each([
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
mocks.submitFeedback.mockClear();
|
||||
const { default: feedback } = await import("./feedback.js");
|
||||
await expect(feedback.run?.({ args } as never)).rejects.toThrow();
|
||||
await expect(feedback.run?.({ args } as never)).rejects.toThrow(CliUsageError);
|
||||
expect(mocks.submitFeedback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the rating error for an unreadable rating, so a retry keeps the rating", async () => {
|
||||
const errors = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const { default: feedback } = await import("./feedback.js");
|
||||
await expect(
|
||||
feedback.run?.({ args: { rating: "8/10", comment: "x" } } as never),
|
||||
).rejects.toThrow();
|
||||
await expect(feedback.run?.({ args: { rating: "8/10", comment: "x" } } as never)).rejects.toThrow(
|
||||
CliUsageError,
|
||||
);
|
||||
expect(String(errors.mock.calls.at(-1)?.[0])).toContain(
|
||||
"Rating must be an integer between 0 and 10",
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { failCommand } from "../utils/commandResult.js";
|
||||
import { failUsage } from "../utils/commandResult.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { resolve } from "node:path";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
@@ -174,15 +174,15 @@ function reportRating(
|
||||
const rating = raw === undefined ? undefined : parseFeedbackRating(raw);
|
||||
if (rating === null) {
|
||||
console.error(c.error("Rating must be an integer between 0 and 10"));
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
if (rating === undefined && !comment?.trim()) {
|
||||
console.error(c.error("Give a --rating (an integer from 0 to 10), a --comment, or both"));
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
if (rating === undefined && fileIssue) {
|
||||
console.error(c.error("--file-issue needs a --rating"));
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
return rating;
|
||||
}
|
||||
@@ -279,7 +279,7 @@ export default defineCommand({
|
||||
const source = parseFeedbackSource(args.source);
|
||||
if (source === null) {
|
||||
console.error(c.error('Source must be "person" or "agent"'));
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
|
||||
if (!shouldTrack()) {
|
||||
|
||||
@@ -29,6 +29,7 @@ import { trackCompareSheet } from "../telemetry/events.js";
|
||||
import { serveStaticProjectHtml } from "../utils/staticProjectServer.js";
|
||||
import { withMeta } from "../utils/updateCheck.js";
|
||||
import { failCaught } from "../utils/jsonError.js";
|
||||
import { CliUsageError } from "../utils/commandResult.js";
|
||||
|
||||
const COMPOSITION_ID = "grade-compare";
|
||||
const COMPOSITION_DURATION = "1";
|
||||
@@ -243,7 +244,7 @@ export function resolveLutCells(luts: string): GradeCompareCell[] {
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
if (paths.length === 0) {
|
||||
throw new Error("--luts must include at least one LUT path");
|
||||
throw new CliUsageError("--luts must include at least one LUT path");
|
||||
}
|
||||
return paths.map((lutPath) =>
|
||||
validateCell(basename(lutPath, extname(lutPath)), { lut: { src: lutPath } }),
|
||||
@@ -267,12 +268,12 @@ export function parseGradeCompareArgs(args: {
|
||||
timeout?: unknown;
|
||||
}): ParsedGradeCompareArgs {
|
||||
const frameArg = readOptionalString(args.for);
|
||||
if (!frameArg) throw new Error("--for <path> is required");
|
||||
if (!frameArg) throw new CliUsageError("--for <path> is required");
|
||||
|
||||
const gradesArg = readOptionalString(args.grades);
|
||||
const lutsArg = readOptionalString(args.luts);
|
||||
if (Boolean(gradesArg) === Boolean(lutsArg)) {
|
||||
throw new Error("Exactly one of --grades or --luts is required");
|
||||
throw new CliUsageError("Exactly one of --grades or --luts is required");
|
||||
}
|
||||
|
||||
const projectDir = resolve(readOptionalString(args.project) ?? process.cwd());
|
||||
|
||||
@@ -445,7 +445,7 @@ describe("hyperframes init flag rename", () => {
|
||||
"--audio",
|
||||
"track.mp3",
|
||||
]);
|
||||
expect(res.status).toBe(1);
|
||||
expect(res.status).toBe(2);
|
||||
expect(res.stderr).toContain("Cannot use --video and --audio together");
|
||||
expect(existsSync(target)).toBe(false);
|
||||
} finally {
|
||||
@@ -529,19 +529,19 @@ describe("hyperframes init flag rename", () => {
|
||||
"-V",
|
||||
"missing.mp4",
|
||||
]);
|
||||
expect(res.status).toBe(1);
|
||||
expect(res.status).toBe(2);
|
||||
expect(res.stderr).toContain("The -V short flag no longer maps to --video");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("--template prints a rename hint and exits non-zero", () => {
|
||||
it("--template prints a rename hint and exits 2", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-init-test-"));
|
||||
const target = join(dir, "proj");
|
||||
try {
|
||||
const res = runInit([target, "--template", "blank", "--non-interactive", "--skip-skills"]);
|
||||
expect(res.status).toBe(1);
|
||||
expect(res.status).toBe(2);
|
||||
expect(res.stderr).toContain("--template flag was renamed to --example");
|
||||
expect(res.stderr).toContain(`--example "blank"`);
|
||||
expect(existsSync(target)).toBe(false);
|
||||
|
||||
@@ -802,7 +802,7 @@ export default defineCommand({
|
||||
`The --template flag was renamed to --example. Example:\n npx hyperframes init ${args.name ?? "my-video"} --example "${args.template}"`,
|
||||
),
|
||||
);
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
if (args["video-legacy"] !== undefined) {
|
||||
console.error(
|
||||
@@ -810,7 +810,7 @@ export default defineCommand({
|
||||
`The -V short flag no longer maps to --video. Use --video (or -v). Example:\n npx hyperframes init ${args.name ?? "my-video"} --video "${args["video-legacy"]}"`,
|
||||
),
|
||||
);
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
const exampleFlag = args.example;
|
||||
const videoFlag = args.video;
|
||||
@@ -864,7 +864,7 @@ export default defineCommand({
|
||||
|
||||
if (videoFlag && audioFlag) {
|
||||
console.error(c.error("Cannot use --video and --audio together"));
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
|
||||
// Validate source files before creating destDir so a failed run does
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { failCommand, failUsage } from "../utils/commandResult.js";
|
||||
import { CliUsageError, failCommand, failUsage } from "../utils/commandResult.js";
|
||||
/**
|
||||
* `hyperframes lambda` — top-level dispatcher for AWS Lambda subcommands.
|
||||
*
|
||||
@@ -289,7 +289,7 @@ export default defineCommand({
|
||||
console.error(
|
||||
`[lambda sites] unknown verb "${String(args.target)}". Only "create" is supported.`,
|
||||
);
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
const projectDir = args.extra as string | undefined;
|
||||
if (!projectDir) {
|
||||
@@ -319,14 +319,14 @@ export default defineCommand({
|
||||
const height = parsePositiveInt(args.height, "--height");
|
||||
if (width === undefined || height === undefined) {
|
||||
console.error("[lambda render] --width and --height are required.");
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
const fpsRaw = parseIntFlag(args.fps) ?? defaultCloudFps(projectDir);
|
||||
if (!isDistributedFps(fpsRaw)) {
|
||||
console.error(
|
||||
`[lambda render] --fps must be one of ${DISTRIBUTED_FPS.join(", ")}; got ${fpsRaw}.`,
|
||||
);
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
const { runRender } = await import("./lambda/render.js");
|
||||
const renderResolution = parseOutputResolution(args["output-resolution"]);
|
||||
@@ -376,14 +376,14 @@ export default defineCommand({
|
||||
const height = parsePositiveInt(args.height, "--height");
|
||||
if (width === undefined || height === undefined) {
|
||||
console.error("[lambda render-batch] --width and --height are required.");
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
const fpsRaw = parseIntFlag(args.fps) ?? defaultCloudFps(projectDir);
|
||||
if (!isDistributedFps(fpsRaw)) {
|
||||
console.error(
|
||||
`[lambda render-batch] --fps must be one of ${DISTRIBUTED_FPS.join(", ")}; got ${fpsRaw}.`,
|
||||
);
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
const { runRenderBatch } = await import("./lambda/render-batch.js");
|
||||
const batchResolution = parseOutputResolution(args["output-resolution"]);
|
||||
@@ -446,7 +446,7 @@ export default defineCommand({
|
||||
}
|
||||
default:
|
||||
console.error(`${c.error("Unknown subcommand:")} ${subcommand}\n${HELP}`);
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -466,7 +466,7 @@ function parsePositiveInt(raw: unknown, flagName: string): number | undefined {
|
||||
const n = parseIntFlag(raw);
|
||||
if (n === undefined) return undefined;
|
||||
if (!Number.isInteger(n) || n < 1) {
|
||||
throw new Error(`[lambda] ${flagName} must be a positive integer; got ${n}`);
|
||||
throw new CliUsageError(`[lambda] ${flagName} must be a positive integer; got ${n}`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
@@ -486,7 +486,7 @@ function parseEnum<T extends string>(
|
||||
if (raw === undefined || raw === null || raw === "") return defaultValue;
|
||||
const s = String(raw);
|
||||
if ((allowed as readonly string[]).includes(s)) return s as T;
|
||||
throw new Error(`${errorPrefix} must be ${allowed.join("|")}; got ${s}`);
|
||||
throw new CliUsageError(`${errorPrefix} must be ${allowed.join("|")}; got ${s}`);
|
||||
}
|
||||
|
||||
const FORMATS = [
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { CliUsageError } from "../../utils/commandResult.js";
|
||||
|
||||
/** Throws with a clear hint when the SAM CLI is not on PATH. */
|
||||
function assertSamAvailable(): void {
|
||||
@@ -83,7 +84,7 @@ export function parseReservedConcurrency(raw: unknown): number | undefined {
|
||||
if (raw === "none") return -1;
|
||||
const n = Number(raw);
|
||||
if (!Number.isInteger(n) || n < 1) {
|
||||
throw new Error(
|
||||
throw new CliUsageError(
|
||||
`[lambda] --concurrency must be a positive integer or "none"; got ${String(raw)}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
mediaTreatmentCommand,
|
||||
resolveMediaTreatmentSource,
|
||||
} from "./media-treatment.js";
|
||||
import { CliRuntimeError } from "../utils/commandResult.js";
|
||||
import { CliRuntimeError, CliUsageError } from "../utils/commandResult.js";
|
||||
import {
|
||||
COLOR_GRADING_TOP_LEVEL_KEYS,
|
||||
validateColorGradingContract,
|
||||
@@ -812,7 +812,7 @@ describe("applyMediaTreatmentToHtml", () => {
|
||||
runCommand(mediaTreatmentCommand, {
|
||||
rawArgs: ["--project", project, "--selector", "#hero", "--grading", grading],
|
||||
}),
|
||||
).rejects.toThrow(CliRuntimeError);
|
||||
).rejects.toThrow(CliUsageError);
|
||||
expect(error).toHaveBeenLastCalledWith(expect.stringContaining("--grading requires --apply"));
|
||||
expect(readFileSync(file, "utf8")).toBe(VIDEO);
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ import { lintHyperframeHtml } from "@hyperframes/lint";
|
||||
import { parseHTML } from "linkedom";
|
||||
import type { Example } from "./_examples.js";
|
||||
import { c } from "../ui/colors.js";
|
||||
import { failCommand } from "../utils/commandResult.js";
|
||||
import { CliUsageError, failCommand, failUsage } from "../utils/commandResult.js";
|
||||
import { readOptionalString } from "../utils/pathArgs.js";
|
||||
import { projectCompositionFiles } from "../utils/mountedCompositions.js";
|
||||
import { readJsonFlag } from "../utils/jsonFlag.js";
|
||||
@@ -528,7 +528,7 @@ function parseSelectorIndex(raw: string | undefined): number | undefined {
|
||||
if (raw === undefined) return undefined;
|
||||
const value = Number(raw);
|
||||
if (!Number.isInteger(value) || value < 0) {
|
||||
throw new Error("--selector-index must be a non-negative integer");
|
||||
throw new CliUsageError("--selector-index must be a non-negative integer");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -569,15 +569,15 @@ function parseGrading(
|
||||
): unknown {
|
||||
if (clear) {
|
||||
if (raw !== undefined || apply) {
|
||||
throw new Error("Use either --apply with --grading or --clear, not both");
|
||||
throw new CliUsageError("Use either --apply with --grading or --clear, not both");
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (!apply && !dryRun) {
|
||||
if (raw !== undefined) throw new Error("--grading requires --apply or --dry-run");
|
||||
throw new Error("Use --apply with --grading <json> or --clear");
|
||||
if (raw !== undefined) throw new CliUsageError("--grading requires --apply or --dry-run");
|
||||
throw new CliUsageError("Use --apply with --grading <json> or --clear");
|
||||
}
|
||||
if (raw === undefined) throw new Error("--apply requires --grading <json>");
|
||||
if (raw === undefined) throw new CliUsageError("--apply requires --grading <json>");
|
||||
return readJsonFlag("--grading", raw);
|
||||
}
|
||||
|
||||
@@ -613,9 +613,10 @@ function runCapabilityQuery(args: MediaTreatmentCommandArgs): void {
|
||||
args.apply,
|
||||
args.analyze,
|
||||
].some(Boolean);
|
||||
if (hasMutationOption) throw new Error("--capabilities cannot be combined with mutation options");
|
||||
if (hasMutationOption)
|
||||
throw new CliUsageError("--capabilities cannot be combined with mutation options");
|
||||
if (args.all === true && capability)
|
||||
throw new Error("Use either --all or --capability, not both");
|
||||
throw new CliUsageError("Use either --all or --capability, not both");
|
||||
|
||||
let capabilities: unknown = getMediaTreatmentCapabilityOverview();
|
||||
if (args.all === true) capabilities = getHfColorGradingCapabilities();
|
||||
@@ -647,7 +648,7 @@ function resolveMutationFile(args: MediaTreatmentCommandArgs, selector: string |
|
||||
(selector ? fileDeclaringSelector(project.dir, selector) : "index.html");
|
||||
const filePath = resolve(project.dir, fileArg);
|
||||
if (!isPathInside(filePath, project.dir) || !filePath.toLowerCase().endsWith(".html")) {
|
||||
throw new Error("--file must be an HTML file inside the project");
|
||||
throw new CliUsageError("--file must be an HTML file inside the project");
|
||||
}
|
||||
if (!existsSync(filePath)) throw new Error(`Composition file not found: ${fileArg}`);
|
||||
return { project, filePath };
|
||||
@@ -656,14 +657,14 @@ function resolveMutationFile(args: MediaTreatmentCommandArgs, selector: string |
|
||||
function analyzeTarget(args: MediaTreatmentCommandArgs) {
|
||||
const selector = readOptionalString(args.selector);
|
||||
const { project, filePath } = resolveMutationFile(args, selector);
|
||||
if (!selector) throw new Error("--selector is required");
|
||||
if (!selector) throw new CliUsageError("--selector is required");
|
||||
if (
|
||||
readOptionalString(args.grading) ||
|
||||
args.clear === true ||
|
||||
args.apply === true ||
|
||||
args["dry-run"] === true
|
||||
) {
|
||||
throw new Error("--analyze cannot be combined with mutation options");
|
||||
throw new CliUsageError("--analyze cannot be combined with mutation options");
|
||||
}
|
||||
const selectorIndex = parseSelectorIndex(readOptionalString(args["selector-index"]));
|
||||
const { element, tag } = selectMediaElement(
|
||||
@@ -689,7 +690,7 @@ function analyzeTarget(args: MediaTreatmentCommandArgs) {
|
||||
async function prepareMutation(args: MediaTreatmentCommandArgs) {
|
||||
const selector = readOptionalString(args.selector);
|
||||
const { project, filePath } = resolveMutationFile(args, selector);
|
||||
if (!selector) throw new Error("--selector is required");
|
||||
if (!selector) throw new CliUsageError("--selector is required");
|
||||
const clear = args.clear === true;
|
||||
const apply = args.apply === true;
|
||||
const dryRun = args["dry-run"] === true;
|
||||
@@ -770,8 +771,13 @@ async function printMutation(args: MediaTreatmentCommandArgs): Promise<void> {
|
||||
|
||||
function printFailure(error: unknown, json: boolean): void {
|
||||
const message = failureMessage(error);
|
||||
if (json) console.log(JSON.stringify(withMeta(jsonFailure("runtime_error", message))));
|
||||
else console.error(`${c.error("✗")} ${message}`);
|
||||
const usage = error instanceof CliUsageError;
|
||||
if (json) {
|
||||
console.log(
|
||||
JSON.stringify(withMeta(jsonFailure(usage ? error.code : "runtime_error", message))),
|
||||
);
|
||||
} else console.error(`${c.error("✗")} ${message}`);
|
||||
if (usage) failUsage();
|
||||
failCommand(1, error);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as clack from "../ui/prompts.js";
|
||||
import type { Example } from "./_examples.js";
|
||||
import { c } from "../ui/colors.js";
|
||||
import { formatBytes } from "../ui/format.js";
|
||||
import { failCommand, setCommandExitCode } from "../utils/commandResult.js";
|
||||
import { failCommand, failUsage, setCommandExitCode } from "../utils/commandResult.js";
|
||||
import { createRenderCancellationScope } from "../utils/renderCancellation.js";
|
||||
import { PARAKEET_INSTALL_COMMAND, PARAKEET_MODEL_LABEL } from "../whisper/parakeet.js";
|
||||
import type { ParakeetRunner } from "../whisper/parakeetRunner.js";
|
||||
@@ -14,9 +14,11 @@ export const examples: Example[] = [
|
||||
["Download the Parakeet speech model that transcribe uses", PARAKEET_INSTALL_COMMAND],
|
||||
];
|
||||
|
||||
function fail(message: string, json: boolean): never {
|
||||
if (json) console.log(JSON.stringify(jsonFailure("runtime_error", message)));
|
||||
function fail(message: string, json: boolean, usage = false): never {
|
||||
if (json)
|
||||
console.log(JSON.stringify(jsonFailure(usage ? "usage_error" : "runtime_error", message)));
|
||||
else console.error(c.error(message));
|
||||
if (usage) failUsage();
|
||||
failCommand();
|
||||
}
|
||||
|
||||
@@ -163,6 +165,7 @@ export default defineCommand({
|
||||
fail(
|
||||
`Unknown: models ${[args.action, args.name].filter(Boolean).join(" ")}. Try: hyperframes models list, or ${PARAKEET_INSTALL_COMMAND}`,
|
||||
args.json,
|
||||
true,
|
||||
);
|
||||
}
|
||||
return installParakeet(args.json);
|
||||
|
||||
@@ -19,7 +19,7 @@ import { defineCommand } from "../utils/defineCommand.js";
|
||||
import { c } from "../ui/colors.js";
|
||||
import { projectCompositionFiles } from "../utils/mountedCompositions.js";
|
||||
import { resolveProject } from "../utils/project.js";
|
||||
import { failCommand } from "../utils/commandResult.js";
|
||||
import { CliUsageError, failCommand, failUsage } from "../utils/commandResult.js";
|
||||
import type { Example } from "./_examples.js";
|
||||
import { jsonFailure } from "../utils/jsonError.js";
|
||||
|
||||
@@ -35,8 +35,13 @@ export {
|
||||
const DEFAULT_TOLERANCE_LU = 0.5;
|
||||
|
||||
function fail(message: string, json: boolean, cause?: unknown): never {
|
||||
if (json) console.log(JSON.stringify(jsonFailure("runtime_error", message), null, 2));
|
||||
else console.error(c.error(message));
|
||||
const usage = cause instanceof CliUsageError;
|
||||
if (json) {
|
||||
console.log(
|
||||
JSON.stringify(jsonFailure(usage ? cause.code : "runtime_error", message), null, 2),
|
||||
);
|
||||
} else console.error(c.error(message));
|
||||
if (usage) failUsage();
|
||||
failCommand(1, cause);
|
||||
}
|
||||
|
||||
@@ -81,7 +86,7 @@ function selectedTags(tags: readonly ProjectAudioTag[], referenceId: string, tar
|
||||
const referenceTag = byId(tags, referenceId, "Reference");
|
||||
const targetTag = byId(tags, targetId, "Target");
|
||||
if (referenceTag.id === targetTag.id) {
|
||||
throw new Error("Reference and target must be different audio elements");
|
||||
throw new CliUsageError("Reference and target must be different audio elements");
|
||||
}
|
||||
return { referenceTag, targetTag };
|
||||
}
|
||||
@@ -123,14 +128,15 @@ function writeAtomically(path: string, contents: string): void {
|
||||
function parsedTolerance(raw: string): number {
|
||||
const tolerance = Number(raw);
|
||||
if (!Number.isFinite(tolerance) || tolerance < 0) {
|
||||
throw new Error("--tolerance must be a non-negative number");
|
||||
throw new CliUsageError("--tolerance must be a non-negative number");
|
||||
}
|
||||
return tolerance;
|
||||
}
|
||||
|
||||
function parsedLufs(raw: string): number {
|
||||
const lufs = Number(raw);
|
||||
if (!Number.isFinite(lufs) || lufs >= 0) throw new Error("--lufs must be a negative number");
|
||||
if (!Number.isFinite(lufs) || lufs >= 0)
|
||||
throw new CliUsageError("--lufs must be a negative number");
|
||||
return lufs;
|
||||
}
|
||||
|
||||
@@ -173,9 +179,9 @@ async function plannedNormalization(
|
||||
}
|
||||
|
||||
async function normalizeAudio(options: NormalizeAudioOptions): Promise<NormalizeAudioResult> {
|
||||
const tolerance = parsedTolerance(options.tolerance);
|
||||
const project = resolveProject(options.dir);
|
||||
const tags = projectAudioTags(project.dir);
|
||||
const tolerance = parsedTolerance(options.tolerance);
|
||||
const { targetTag, withinTolerance, plan } = await plannedNormalization(
|
||||
project.dir,
|
||||
tags,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { setCommandExitCode } from "../utils/commandResult.js";
|
||||
import { failUsage, setCommandExitCode } from "../utils/commandResult.js";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import type { Example } from "./_examples.js";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
@@ -91,16 +91,14 @@ export default defineCommand({
|
||||
startPort = parsePort(args.port ?? "3003");
|
||||
} catch (error) {
|
||||
clack.log.error(normalizeErrorMessage(error));
|
||||
setCommandExitCode(1);
|
||||
return;
|
||||
failUsage();
|
||||
}
|
||||
const project = resolveProject(args.dir);
|
||||
|
||||
// Validation: --user-data-dir requires --browser-path
|
||||
if (args["user-data-dir"] && !args["browser-path"]) {
|
||||
clack.log.error("--user-data-dir requires --browser-path");
|
||||
setCommandExitCode(1);
|
||||
return;
|
||||
failUsage();
|
||||
}
|
||||
// Validation: --remote-debugging-port deps
|
||||
const depsError = validateRemoteDebuggingPortDeps({
|
||||
@@ -110,8 +108,7 @@ export default defineCommand({
|
||||
});
|
||||
if (depsError) {
|
||||
clack.log.error(depsError);
|
||||
setCommandExitCode(1);
|
||||
return;
|
||||
failUsage();
|
||||
}
|
||||
// Parse --remote-debugging-port before any server setup so an invalid value
|
||||
// exits cleanly instead of leaving an orphan listening socket behind.
|
||||
@@ -122,8 +119,7 @@ export default defineCommand({
|
||||
);
|
||||
} catch (err) {
|
||||
clack.log.error((err as Error).message);
|
||||
setCommandExitCode(1);
|
||||
return;
|
||||
failUsage();
|
||||
}
|
||||
|
||||
// Resolve runtime path — same logic as studioServer.ts
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { setCommandExitCode } from "../utils/commandResult.js";
|
||||
import { failUsage, setCommandExitCode } from "../utils/commandResult.js";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import type { Example } from "./_examples.js";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
@@ -55,15 +55,13 @@ export default defineCommand({
|
||||
startPort = parsePort(args.port ?? "3004");
|
||||
} catch (error) {
|
||||
clack.log.error(normalizeErrorMessage(error));
|
||||
setCommandExitCode(1);
|
||||
return;
|
||||
failUsage();
|
||||
}
|
||||
const project = resolveProject(args.dir);
|
||||
|
||||
if (args["user-data-dir"] && !args["browser-path"]) {
|
||||
clack.log.error("--user-data-dir requires --browser-path");
|
||||
setCommandExitCode(1);
|
||||
return;
|
||||
failUsage();
|
||||
}
|
||||
const depsError = validateRemoteDebuggingPortDeps({
|
||||
browserPath: args["browser-path"] as string | undefined,
|
||||
@@ -72,8 +70,7 @@ export default defineCommand({
|
||||
});
|
||||
if (depsError) {
|
||||
clack.log.error(depsError);
|
||||
setCommandExitCode(1);
|
||||
return;
|
||||
failUsage();
|
||||
}
|
||||
let remoteDebuggingPort: number | undefined;
|
||||
try {
|
||||
@@ -82,8 +79,7 @@ export default defineCommand({
|
||||
);
|
||||
} catch (err) {
|
||||
clack.log.error((err as Error).message);
|
||||
setCommandExitCode(1);
|
||||
return;
|
||||
failUsage();
|
||||
}
|
||||
|
||||
const playerPath = resolvePlayerPath();
|
||||
|
||||
@@ -24,9 +24,9 @@ describe.each(commands)("%s --port", (_name, run) => {
|
||||
it.each(["0", "65536", "3003oops", "22.5"])(
|
||||
"rejects invalid value %j before resolving a project",
|
||||
async (value) => {
|
||||
await run(["--port", value, "--no-open"]);
|
||||
|
||||
expect(consumeCommandResult()).toMatchObject({ exitCode: 1 });
|
||||
await expect(run(["--port", value, "--no-open"])).rejects.toMatchObject({
|
||||
result: { exitCode: 2, kind: "usage_error" },
|
||||
});
|
||||
expect(clack.log.error).toHaveBeenCalledWith("--port must be an integer between 1 and 65535");
|
||||
},
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ import { reportArtifacts } from "../utils/toolContext.js";
|
||||
import { withProjectLock } from "../utils/projectLock.js";
|
||||
import {
|
||||
parseSnapshotAt,
|
||||
parseSnapshotFrames,
|
||||
parseSnapshotRange,
|
||||
sampleSnapshotRange,
|
||||
resolveSnapshotCompositionRange,
|
||||
@@ -29,7 +30,7 @@ import {
|
||||
} from "@hyperframes/studio-server/history";
|
||||
import { pngDimensions } from "../utils/pngDimensions.js";
|
||||
import { runWithConcurrencyLimit } from "../utils/concurrency.js";
|
||||
import { CliRuntimeError, failCommand } from "../utils/commandResult.js";
|
||||
import { CliRuntimeError, CliUsageError, failCommand } from "../utils/commandResult.js";
|
||||
// fallow-ignore-file complexity
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import {
|
||||
@@ -207,7 +208,7 @@ function assertDistinctKeepNames(times: number[]): void {
|
||||
const label = formatSnapshotTimestamp(time);
|
||||
const other = timeByLabel.get(label);
|
||||
if (other !== undefined && other !== time) {
|
||||
throw new Error(
|
||||
throw new CliUsageError(
|
||||
`--keep would name both ${other} and ${time} at-${label}.png; pass times at least 1 ms apart`,
|
||||
);
|
||||
}
|
||||
@@ -1480,21 +1481,21 @@ export default defineCommand({
|
||||
failCommand();
|
||||
};
|
||||
if ([args.at, args.range, args.composition].filter((value) => value !== undefined).length > 1)
|
||||
throw new Error("Use only one of --at, --range, or --composition");
|
||||
throw new CliUsageError("Use only one of --at, --range, or --composition");
|
||||
const reviewMode = args.review === true;
|
||||
if (reviewMode) {
|
||||
const clash = (["at", "range", "zoom", "keep", "against"] as const).find(
|
||||
(flag) => args[flag] !== undefined && args[flag] !== false,
|
||||
);
|
||||
if (clash) throw new Error(`--review picks its own frames; drop --${clash}`);
|
||||
if (clash) throw new CliUsageError(`--review picks its own frames; drop --${clash}`);
|
||||
}
|
||||
const frames = parseInt(args.frames as string, 10) || 5;
|
||||
const frames = args.frames === undefined ? 5 : parseSnapshotFrames(String(args.frames));
|
||||
const range = args.range === undefined ? undefined : parseSnapshotRange(String(args.range));
|
||||
const atTimestamps = args.at === undefined ? undefined : parseSnapshotAt(String(args.at));
|
||||
if (args.keep === true && atTimestamps) assertDistinctKeepNames(atTimestamps);
|
||||
const composition =
|
||||
args.composition === undefined ? undefined : String(args.composition).trim();
|
||||
if (composition === "") throw new Error("--composition requires a composition ID");
|
||||
if (composition === "") throw new CliUsageError("--composition requires a composition ID");
|
||||
const project = resolveProject(args.dir);
|
||||
const lintResult = await lintProject(project.dir);
|
||||
if (hasDefinitiveEntryMismatch(lintResult)) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
parseSnapshotFrames,
|
||||
parseSnapshotRange,
|
||||
sampleSnapshotRange,
|
||||
resolveSnapshotCompositionRange,
|
||||
@@ -18,6 +19,9 @@ describe("snapshot scoped sampling", () => {
|
||||
expect(() => parseSnapshotRange(v)).toThrow();
|
||||
expect(parseSnapshotRange("1.8:2.5")).toEqual([1.8, 2.5]);
|
||||
expect(parseSnapshotRange("1.8,2.5")).toEqual([1.8, 2.5]);
|
||||
for (const v of ["abc", "-3", "0", "2.5", ""])
|
||||
expect(() => parseSnapshotFrames(v)).toThrow("--frames must be a positive integer");
|
||||
expect(parseSnapshotFrames("10")).toBe(10);
|
||||
for (const n of [0, -1, 1.5, NaN]) expect(() => sampleSnapshotRange([1, 2], n, 5)).toThrow();
|
||||
expect(() => sampleSnapshotRange([1, 6], 5, 5)).toThrow();
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { parseHTML } from "linkedom";
|
||||
import { createRuntimeStartTimeResolver } from "@hyperframes/core";
|
||||
import { CliUsageError } from "../utils/commandResult.js";
|
||||
export type SnapshotRange = [number, number];
|
||||
export function parseSnapshotRange(value: string): SnapshotRange {
|
||||
const parts = value.split(/[,:]/);
|
||||
@@ -10,13 +11,19 @@ export function parseSnapshotRange(value: string): SnapshotRange {
|
||||
range[0]! < 0 ||
|
||||
range[1]! <= range[0]!
|
||||
)
|
||||
throw new Error("--range must be START,END in seconds, with 0 <= START < END");
|
||||
throw new CliUsageError("--range must be START,END in seconds, with 0 <= START < END");
|
||||
return [range[0]!, range[1]!];
|
||||
}
|
||||
export function parseSnapshotFrames(value: string): number {
|
||||
const frames = Number(value);
|
||||
if (value.trim() === "" || !Number.isInteger(frames) || frames < 1)
|
||||
throw new CliUsageError("--frames must be a positive integer");
|
||||
return frames;
|
||||
}
|
||||
export function parseSnapshotAt(value: string): number[] {
|
||||
const times = value.split(",").map((part) => (part.trim() === "" ? NaN : Number(part)));
|
||||
if (!times.every((time) => Number.isFinite(time) && time >= 0))
|
||||
throw new Error("--at must be comma-separated times in seconds, each 0 or more");
|
||||
throw new CliUsageError("--at must be comma-separated times in seconds, each 0 or more");
|
||||
return times;
|
||||
}
|
||||
export function sampleSnapshotRange(
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from "../telemetry/config.js";
|
||||
import { effectiveTelemetryStatus, type TelemetryStatusSource } from "../telemetry/policy.js";
|
||||
import { c } from "../ui/colors.js";
|
||||
import { failCommand } from "../utils/commandResult.js";
|
||||
import { failCommand, failUsage } from "../utils/commandResult.js";
|
||||
import type { Example } from "./_examples.js";
|
||||
|
||||
export const examples: Example[] = [
|
||||
@@ -123,7 +123,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.`,
|
||||
);
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -100,7 +100,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."));
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
|
||||
let text: string;
|
||||
@@ -126,7 +126,7 @@ export default defineCommand({
|
||||
|
||||
if (!Number.isFinite(speed) || speed < 0.1 || speed > 3) {
|
||||
console.error(c.error("Speed must be a number between 0.1 and 3.0"));
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
|
||||
const inferredLang = inferLangFromVoiceId(voice);
|
||||
|
||||
@@ -46,14 +46,14 @@ describe("prompt wrapper", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("an unattended spinner writes only its outcome, as a plain line on stderr", () => {
|
||||
it("an unattended spinner writes its start and outcome, not its progress, on stderr", () => {
|
||||
const write = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||
try {
|
||||
const spin = prompts.spinner();
|
||||
spin.start("Checking...");
|
||||
spin.message("Still checking...");
|
||||
spin.start("\u001b[36mTranscoding\u001b[39m clip.mov");
|
||||
spin.message("Still transcoding...");
|
||||
spin.stop("\u001b[32mDone\u001b[39m");
|
||||
expect(write.mock.calls).toEqual([["Done\n"]]);
|
||||
expect(write.mock.calls).toEqual([["Transcoding clip.mov\n"], ["Done\n"]]);
|
||||
expect(clack.spinner).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
write.mockRestore();
|
||||
|
||||
@@ -57,7 +57,7 @@ function lineSpinner(): SpinnerResult {
|
||||
if (message) process.stderr.write(`${stripVTControlCharacters(message)}\n`);
|
||||
};
|
||||
return {
|
||||
start: () => {},
|
||||
start: say,
|
||||
message: () => {},
|
||||
stop: say,
|
||||
cancel: say,
|
||||
@@ -67,7 +67,7 @@ function lineSpinner(): SpinnerResult {
|
||||
};
|
||||
}
|
||||
|
||||
/** Unattended, only the outcome is written, as one plain line on stderr. */
|
||||
/** Unattended, the start and outcome messages are each written as one plain line on stderr. */
|
||||
export function spinner(opts?: SpinnerOptions): SpinnerResult {
|
||||
return isUnattended() ? lineSpinner() : clack.spinner(opts);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
CliRuntimeError,
|
||||
CliUsageError,
|
||||
failCommand,
|
||||
failUsage,
|
||||
type CommandResult,
|
||||
} from "./commandResult.js";
|
||||
|
||||
@@ -22,7 +23,7 @@ export function jsonFailure(code: string, message: string, hint?: string): JsonF
|
||||
return { ok: false, error: hint ? { code, message, hint } : { code, message } };
|
||||
}
|
||||
|
||||
/** Reports a caught failure as the --json envelope or one line on stderr, then fails the command. */
|
||||
/** Reports a caught failure as the --json envelope or one stderr line; exit 2 if usage, else 1. */
|
||||
export function failCaught(
|
||||
error: unknown,
|
||||
json: boolean,
|
||||
@@ -30,8 +31,10 @@ export function failCaught(
|
||||
print: (payload: object) => void = (payload) => console.log(JSON.stringify(payload)),
|
||||
): never {
|
||||
const message = failureMessage(error);
|
||||
if (json) print(jsonFailure("runtime_error", message));
|
||||
const usage = error instanceof CliUsageError;
|
||||
if (json) print(jsonFailure(usage ? error.code : "runtime_error", message));
|
||||
else console.error(line(message));
|
||||
if (usage) failUsage();
|
||||
failCommand();
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
import { type CanvasResolution, resolveResolutionFlagPair } from "@hyperframes/core";
|
||||
import { VALID_CANVAS_RESOLUTIONS } from "@hyperframes/core";
|
||||
import { CliUsageError } from "./commandResult.js";
|
||||
|
||||
/**
|
||||
* Free-text prefix the thrown error is scoped to (e.g. `"[cloudrun render]"`,
|
||||
@@ -60,7 +61,7 @@ export function parseOutputResolutionFlag(
|
||||
const { outputResolution, outputResolutionAspectAgnostic } = resolveResolutionFlagPair(asString);
|
||||
if (outputResolution) return { outputResolution, outputResolutionAspectAgnostic };
|
||||
const aliasHint = options.aliasHint ?? "1080p, 4k, uhd, hd, …";
|
||||
throw new Error(
|
||||
throw new CliUsageError(
|
||||
`${options.surfaceLabel} --output-resolution must be one of ${VALID_CANVAS_RESOLUTIONS.join("|")} ` +
|
||||
`(or an alias: ${aliasHint}); got ${asString}`,
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { failCommand } from "./commandResult.js";
|
||||
import { failCommand, failUsage } from "./commandResult.js";
|
||||
/**
|
||||
* Shared `--variables` / `--variables-file` / `--strict-variables` parsing
|
||||
* and validation helpers used by both `hyperframes render` (in-process) and
|
||||
@@ -135,6 +135,9 @@ export function resolveVariablesArg(
|
||||
if (!result.ok) {
|
||||
const { title, message } = variablesErrorMessage(result.error);
|
||||
errorBox(title, message);
|
||||
const { kind } = result.error;
|
||||
const flagsAreWrong = kind === "conflict" || (inline != null && kind !== "read-error");
|
||||
if (flagsAreWrong) failUsage();
|
||||
failCommand();
|
||||
}
|
||||
return result.value;
|
||||
|
||||
Reference in New Issue
Block a user