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