refactor(cli): centralize process lifecycle

This commit is contained in:
James
2026-07-20 09:04:03 -07:00
parent 6ad738b580
commit 3aa2404747
71 changed files with 812 additions and 434 deletions
@@ -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" });
});
});
+111
View File
@@ -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 };
}
+2 -1
View File
@@ -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;
}
+3 -2
View File
@@ -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;
}
+3 -2
View File
@@ -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();
}
}