Skip to content

Commit 09f8ac2

Browse files
committed
refactor(cli): centralize process lifecycle
1 parent 54f4eb0 commit 09f8ac2

71 files changed

Lines changed: 805 additions & 426 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,13 @@
2626
"sync-schemas:check": "tsx scripts/sync-schemas.ts --check",
2727
"sync:package-subpaths": "node scripts/package-subpaths.mjs --write",
2828
"check:package-subpaths": "node scripts/package-subpaths.mjs",
29-
"lint": "bun run check:tracked-artifacts && bun run check:workspace-contracts && bun run check:package-cycles && bun run check:package-subpaths && oxlint . && tsx scripts/lint-skills.ts",
29+
"lint": "bun run check:tracked-artifacts && bun run check:workspace-contracts && bun run check:package-cycles && bun run check:package-subpaths && bun run check:cli-process-ownership && oxlint . && tsx scripts/lint-skills.ts",
3030
"lint:skills": "tsx scripts/lint-skills.ts",
3131
"lint:fix": "oxlint --fix .",
3232
"check:tracked-artifacts": "node scripts/check-tracked-artifacts.mjs",
3333
"check:workspace-contracts": "node scripts/check-workspace-contracts.mjs",
3434
"check:package-cycles": "node scripts/check-package-cycles.mjs",
35+
"check:cli-process-ownership": "node scripts/check-cli-process-ownership.mjs",
3536
"format": "oxfmt .",
3637
"test": "bun run test:unit",
3738
"test:unit": "bun run --filter '*' test",
@@ -44,7 +45,7 @@
4445
"player:perf": "bun run --filter @hyperframes/player perf",
4546
"format:check": "oxfmt --check .",
4647
"knip": "knip",
47-
"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",
48+
"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",
4849
"test:skills": "node --test 'skills/**/*.test.mjs'",
4950
"generate:previews": "tsx scripts/generate-template-previews.ts",
5051
"generate:catalog-previews": "tsx scripts/generate-catalog-previews.ts",

packages/cli/src/auth/oauth.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { failCommand } from "../utils/commandResult.js";
12
/**
23
* OAuth 2.0 + PKCE driver for the HeyGen public OAuth flow.
34
*
@@ -115,7 +116,7 @@ export function assertOAuthConfiguredOrExit(): void {
115116
if (isAuthError(err) && err.code === "OAUTH_NOT_CONFIGURED") {
116117
console.error(`Error: ${err.message}`);
117118
if (err.hint) console.error(err.hint);
118-
process.exit(1);
119+
failCommand();
119120
}
120121
throw err;
121122
}

packages/cli/src/cli.ts

Lines changed: 89 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -99,10 +99,19 @@ try {
9999
// Telemetry, update checks, and heavy modules are imported only when needed.
100100
// For --help we skip telemetry entirely.
101101

102-
import { defineCommand, runMain } from "citty";
102+
import { defineCommand, runCommand } from "citty";
103103
import type { ArgsDef, CommandDef } from "citty";
104104
import { getRunId } from "./telemetry/runId.js";
105105
import { reportCommandFailure, trackCommandFailures } from "./utils/command-failure-tracking.js";
106+
import { resolveCommandUsage } from "./utils/commandUsageResolution.js";
107+
import {
108+
CliResultSignal,
109+
CliRuntimeError,
110+
CliUsageError,
111+
consumeCommandResult,
112+
registerRootExitRequester,
113+
type CommandResult,
114+
} from "./utils/commandResult.js";
106115

107116
const isHelp = process.argv.includes("--help") || process.argv.includes("-h");
108117

@@ -151,14 +160,10 @@ const commandLoaders = {
151160
figma: () => import("./commands/figma.js").then((m) => m.default),
152161
};
153162

154-
// Wrap each command's run() so a thrown failure reports its reason to telemetry
155-
// before citty catches the error and exits 1. The error is re-thrown unchanged,
156-
// preserving citty's print + exit-1 behavior. Commands that call process.exit()
157-
// themselves (e.g. `browser path`) bypass this and report inline.
158163
const subCommands = Object.fromEntries(
159164
Object.entries(commandLoaders).map(([name, load]) => [
160165
name,
161-
trackCommandFailures(load, (err) => reportCommandFailure(command, err)),
166+
trackCommandFailures(load, (error) => reportCommandFailure(command, error)),
162167
]),
163168
);
164169

@@ -207,12 +212,13 @@ let _trackCommandResult:
207212
| undefined;
208213
let _printUpdateNotice: (() => void) | undefined;
209214
let _printSkillsUpdateNotice: (() => void) | undefined;
215+
let telemetryReady: Promise<void> = Promise.resolve();
210216

211217
// `events` is a telemetry-internal beacon: it self-tracks + self-flushes, so it
212218
// skips the per-command wrapper (no duplicate cli_command, no first-run notice
213219
// printed into a skill's captured output).
214220
if (!isHelp && command !== "telemetry" && command !== "events" && command !== "unknown") {
215-
import("./telemetry/index.js").then((mod) => {
221+
telemetryReady = import("./telemetry/index.js").then((mod) => {
216222
_flush = mod.flush;
217223
_flushSync = mod.flushSync;
218224
_trackCliError = mod.trackCliError;
@@ -262,33 +268,55 @@ if (
262268

263269
const commandStart = Date.now();
264270
const runId = getRunId();
271+
let finalized = false;
265272

266-
// Async flush for normal exit. `beforeExit` re-fires every time the
267-
// event loop drains, and the async `_flush()` itself schedules new
268-
// work — so a plain `on` listener would print the update notice (and
269-
// re-flush) once per drain (the user-reported double-print). `once`
270-
// detaches after first invocation, which is what we want for both.
271-
process.once("beforeExit", () => {
272-
_flush?.().catch(() => {});
273+
// fallow-ignore-next-line complexity
274+
async function finalizeCli(result: CommandResult): Promise<void> {
275+
if (finalized) return;
276+
finalized = true;
277+
commandFailed ||= result.exitCode !== 0;
278+
await telemetryReady.catch(() => {});
279+
_trackCommandResult?.({
280+
command,
281+
success: result.exitCode === 0 && !commandFailed,
282+
exitCode: result.exitCode,
283+
durationMs: Date.now() - commandStart,
284+
runId,
285+
});
286+
await _flush?.().catch(() => {});
273287
if (!hasJsonFlag) {
274288
_printUpdateNotice?.();
275289
_printSkillsUpdateNotice?.();
276290
}
291+
process.exitCode = result.exitCode;
292+
}
293+
294+
registerRootExitRequester((exitCode) => {
295+
void finalizeCli({
296+
exitCode,
297+
kind: exitCode === 0 ? "success" : "runtime_error",
298+
presented: true,
299+
}).finally(() => process.exit(exitCode));
277300
});
278301

279302
// Sync-only: exit handlers cannot await promises or drain microtasks.
280303
// _trackCommandResult / _trackCliError are captured references resolved
281304
// at init time, so they're callable synchronously here.
282-
process.on("exit", (code) => {
283-
_trackCommandResult?.({
284-
command,
285-
success: code === 0 && !commandFailed,
286-
exitCode: code,
287-
durationMs: Date.now() - commandStart,
288-
runId,
289-
});
290-
_flushSync?.();
291-
});
305+
process.on(
306+
"exit",
307+
// fallow-ignore-next-line complexity
308+
(code) => {
309+
if (finalized) return;
310+
_trackCommandResult?.({
311+
command,
312+
success: code === 0 && !commandFailed,
313+
exitCode: code,
314+
durationMs: Date.now() - commandStart,
315+
runId,
316+
});
317+
_flushSync?.();
318+
},
319+
);
292320

293321
process.on("uncaughtException", (error) => {
294322
if ((error as NodeJS.ErrnoException).code === "EPIPE") {
@@ -312,6 +340,7 @@ process.on("uncaughtException", (error) => {
312340
// The exit handler above will still fire with the real exit code.
313341
process.on("unhandledRejection", (reason) => {
314342
commandFailed = true;
343+
process.exitCode = 1;
315344
const error = reason instanceof Error ? reason : new Error(String(reason));
316345
_trackCliError?.({
317346
error_name: error.name,
@@ -331,4 +360,39 @@ async function showUsage<T extends ArgsDef>(
331360
return impl(cmd as CommandDef, parent as CommandDef | undefined);
332361
}
333362

334-
runMain(main, { showUsage });
363+
async function showRequestedUsage(): Promise<void> {
364+
const requested = await resolveCommandUsage(main as CommandDef, argv);
365+
return showUsage(requested.command, requested.parent);
366+
}
367+
368+
function commandResultForError(error: unknown): CommandResult {
369+
if (error instanceof CliResultSignal) return error.result;
370+
if (error instanceof CliUsageError || error instanceof CliRuntimeError) return error.result;
371+
return { exitCode: 1, kind: "runtime_error" };
372+
}
373+
374+
// fallow-ignore-next-line complexity
375+
async function executeCli(): Promise<void> {
376+
let result: CommandResult = { exitCode: 0, kind: "success" };
377+
try {
378+
if (isHelp) await showRequestedUsage();
379+
else await runCommand(main, { rawArgs: argv });
380+
} catch (error) {
381+
result = commandResultForError(error);
382+
if (!(error instanceof CliResultSignal)) {
383+
commandFailed = true;
384+
reportCommandFailure(command, error);
385+
const typed = error instanceof CliUsageError || error instanceof CliRuntimeError;
386+
if (error instanceof CliUsageError && !error.result.presented) await showRequestedUsage();
387+
if (!typed || !error.result.presented) {
388+
console.error(error instanceof Error ? error.message : String(error));
389+
}
390+
}
391+
} finally {
392+
const pending = consumeCommandResult();
393+
if (pending.exitCode !== 0 || result.exitCode === 0) result = pending;
394+
await finalizeCli(result);
395+
}
396+
}
397+
398+
await executeCli();

packages/cli/src/cloud/errors.test.ts

Lines changed: 12 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
22

33
// errorBox writes to the console; mock it so we can assert which title /
44
// message / third line the cascade picked without matching ANSI output.
@@ -10,30 +10,19 @@ vi.mock("../ui/format.js", async (importOriginal) => ({
1010
import { errorBox } from "../ui/format.js";
1111
import { HyperframesApiError } from "./_gen/client.js";
1212
import { reportApiError } from "./errors.js";
13+
import { CliRuntimeError } from "../utils/commandResult.js";
1314

1415
describe("cloud/errors reportApiError", () => {
15-
// process.exit has signature `(code?) => never` which doesn't unify with
16-
// vi.spyOn's inference; cast through `unknown` like cloud/parsing.test.ts.
17-
let exitSpy: { mockRestore: () => void } & { mock: { calls: unknown[][] } };
18-
1916
beforeEach(() => {
20-
exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
21-
throw new Error("process.exit called");
22-
}) as unknown as (code?: string | number | null) => never) as unknown as typeof exitSpy;
2317
vi.mocked(errorBox).mockClear();
2418
});
2519

26-
afterEach(() => {
27-
exitSpy.mockRestore();
28-
});
29-
3020
it("short-circuits a 404 to a Not found box when notFound is provided", () => {
3121
const err = new HyperframesApiError({ status: 404, message: "missing" });
3222
expect(() =>
3323
reportApiError("Get failed", err, { notFound: "render hfr_x no longer exists" }),
34-
).toThrow("process.exit called");
24+
).toThrow(CliRuntimeError);
3525
expect(errorBox).toHaveBeenCalledWith("Not found", "render hfr_x no longer exists");
36-
expect(exitSpy).toHaveBeenCalledWith(1);
3726
});
3827

3928
it("uses the code-specific hint when the error code is known", () => {
@@ -42,7 +31,7 @@ describe("cloud/errors reportApiError", () => {
4231
message: "slow down",
4332
code: "rate_limit_exceeded",
4433
});
45-
expect(() => reportApiError("Submit failed", err)).toThrow("process.exit called");
34+
expect(() => reportApiError("Submit failed", err)).toThrow(CliRuntimeError);
4635
expect(errorBox).toHaveBeenCalledWith(
4736
"Submit failed (HTTP 429)",
4837
"slow down",
@@ -57,7 +46,7 @@ describe("cloud/errors reportApiError", () => {
5746
code: "invalid_parameter",
5847
});
5948
expect(() => reportApiError("Submit failed", err, { suggestion: "see --help" })).toThrow(
60-
"process.exit called",
49+
CliRuntimeError,
6150
);
6251
expect(errorBox).toHaveBeenCalledWith(
6352
"Submit failed (HTTP 400)",
@@ -73,7 +62,7 @@ describe("cloud/errors reportApiError", () => {
7362
code: "some_unmapped_code",
7463
});
7564
expect(() => reportApiError("List failed", err, { suggestion: "retry shortly" })).toThrow(
76-
"process.exit called",
65+
CliRuntimeError,
7766
);
7867
expect(errorBox).toHaveBeenCalledWith("List failed (HTTP 500)", "boom", "retry shortly");
7968
});
@@ -84,7 +73,7 @@ describe("cloud/errors reportApiError", () => {
8473
message: "boom",
8574
code: "some_unmapped_code",
8675
});
87-
expect(() => reportApiError("List failed", err)).toThrow("process.exit called");
76+
expect(() => reportApiError("List failed", err)).toThrow(CliRuntimeError);
8877
expect(errorBox).toHaveBeenCalledWith(
8978
"List failed (HTTP 500)",
9079
"boom",
@@ -94,15 +83,15 @@ describe("cloud/errors reportApiError", () => {
9483

9584
it("omits the third line when there is no code, hint, or suggestion", () => {
9685
const err = new HyperframesApiError({ status: 503, message: "unavailable" });
97-
expect(() => reportApiError("Get failed", err)).toThrow("process.exit called");
86+
expect(() => reportApiError("Get failed", err)).toThrow(CliRuntimeError);
9887
expect(errorBox).toHaveBeenCalledWith("Get failed (HTTP 503)", "unavailable");
9988
});
10089

10190
it("merges extraHints on top of the built-in table", () => {
10291
const err = new HyperframesApiError({ status: 418, message: "teapot", code: "custom_code" });
10392
expect(() =>
10493
reportApiError("Brew failed", err, { extraHints: { custom_code: "use coffee instead" } }),
105-
).toThrow("process.exit called");
94+
).toThrow(CliRuntimeError);
10695
expect(errorBox).toHaveBeenCalledWith("Brew failed (HTTP 418)", "teapot", "use coffee instead");
10796
});
10897

@@ -116,7 +105,7 @@ describe("cloud/errors reportApiError", () => {
116105
reportApiError("Submit failed", err, {
117106
extraHints: { rate_limit_exceeded: "custom backoff guidance" },
118107
}),
119-
).toThrow("process.exit called");
108+
).toThrow(CliRuntimeError);
120109
expect(errorBox).toHaveBeenCalledWith(
121110
"Submit failed (HTTP 429)",
122111
"slow down",
@@ -127,12 +116,12 @@ describe("cloud/errors reportApiError", () => {
127116
it("reports a plain Error with the stage title and suggestion", () => {
128117
expect(() =>
129118
reportApiError("Upload failed", new Error("disk full"), { suggestion: "free up space" }),
130-
).toThrow("process.exit called");
119+
).toThrow(CliRuntimeError);
131120
expect(errorBox).toHaveBeenCalledWith("Upload failed", "disk full", "free up space");
132121
});
133122

134123
it("stringifies a non-Error thrown value", () => {
135-
expect(() => reportApiError("Weird failure", "just a string")).toThrow("process.exit called");
124+
expect(() => reportApiError("Weird failure", "just a string")).toThrow(CliRuntimeError);
136125
expect(errorBox).toHaveBeenCalledWith("Weird failure", "just a string", undefined);
137126
});
138127
});

packages/cli/src/cloud/errors.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { failCommand } from "../utils/commandResult.js";
12
/**
23
* Shared API-error reporter for the cloud subverbs.
34
*
@@ -61,7 +62,7 @@ export function reportApiError(
6162
if (err instanceof HyperframesApiError) {
6263
if (err.status === 404 && options.notFound) {
6364
errorBox("Not found", options.notFound);
64-
process.exit(1);
65+
failCommand();
6566
}
6667
const hint = err.code ? hints[err.code] : undefined;
6768
const title = `${stage} (HTTP ${err.status})`;
@@ -78,12 +79,12 @@ export function reportApiError(
7879
} else {
7980
errorBox(title, err.message);
8081
}
81-
process.exit(1);
82+
failCommand();
8283
}
8384
if (err instanceof Error) {
8485
errorBox(stage, err.message, options.suggestion);
85-
process.exit(1);
86+
failCommand();
8687
}
8788
errorBox(stage, String(err), options.suggestion);
88-
process.exit(1);
89+
failCommand();
8990
}

0 commit comments

Comments
 (0)