Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,15 @@
"sync-schemas:check": "tsx scripts/sync-schemas.ts --check",
"sync:package-subpaths": "node scripts/package-subpaths.mjs --write",
"check:package-subpaths": "node scripts/package-subpaths.mjs",
"lint": "bun run check:tracked-artifacts && bun run check:workspace-contracts && bun run check:gcp-cloud-run-dockerfile && bun run check:package-cycles && bun run check:package-subpaths && oxlint . && tsx scripts/lint-skills.ts && node scripts/check-skill-mirror.mjs",
"lint": "bun run check:tracked-artifacts && bun run check:workspace-contracts && bun run check:gcp-cloud-run-dockerfile && bun run check:package-cycles && bun run check:package-subpaths && bun run check:cli-process-ownership && oxlint . && tsx scripts/lint-skills.ts && node scripts/check-skill-mirror.mjs",
"check:gcp-cloud-run-dockerfile": "bun run --cwd packages/gcp-cloud-run test:dockerfile-workspaces",
"lint:skills": "tsx scripts/lint-skills.ts",
"check:skill-mirror": "node scripts/check-skill-mirror.mjs",
"lint:fix": "oxlint --fix .",
"check:tracked-artifacts": "node scripts/check-tracked-artifacts.mjs",
"check:workspace-contracts": "node scripts/check-workspace-contracts.mjs",
"check:package-cycles": "node scripts/check-package-cycles.mjs",
"check:cli-process-ownership": "node scripts/check-cli-process-ownership.mjs",
"format": "oxfmt .",
"test": "bun run test:unit",
"test:unit": "bun run --filter '*' test",
Expand All @@ -46,7 +47,7 @@
"player:perf": "bun run --filter @hyperframes/player perf",
"format:check": "oxfmt --check .",
"knip": "knip",
"test:scripts": "node --import tsx --test scripts/check-tracked-artifacts.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-package-cycles.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs",
"test:scripts": "node --import tsx --test scripts/check-tracked-artifacts.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs",
"test:skills": "node --test 'skills/**/*.test.mjs'",
"generate:previews": "tsx scripts/generate-template-previews.ts",
"generate:catalog-previews": "tsx scripts/generate-catalog-previews.ts",
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/auth/oauth.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { failCommand } from "../utils/commandResult.js";
/**
* OAuth 2.0 + PKCE driver for the HeyGen public OAuth flow.
*
Expand Down Expand Up @@ -115,7 +116,7 @@ export function assertOAuthConfiguredOrExit(): void {
if (isAuthError(err) && err.code === "OAUTH_NOT_CONFIGURED") {
console.error(`Error: ${err.message}`);
if (err.hint) console.error(err.hint);
process.exit(1);
failCommand();
}
throw err;
}
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/cli.commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,10 @@ describe("CLI command registration", () => {
expect(condition).toContain('command !== "events"');
expect(condition).toContain('command !== "skills"');
});

it("reports each command failure only at the executable boundary", () => {
expect(cliSource).toContain("trackCommandFailures(load)");
expect(cliSource).not.toContain("trackCommandFailures(load,");
expect(cliSource.match(/reportCommandFailure\(command, error\)/g)).toHaveLength(1);
});
});
70 changes: 70 additions & 0 deletions packages/cli/src/cli.lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { afterEach, describe, expect, it, vi } from "vitest";

const originalArgv = [...process.argv];
const originalExitCode = process.exitCode;

afterEach(() => {
process.argv = [...originalArgv];
process.exitCode = originalExitCode;
vi.doUnmock("./commands/init.js");
vi.doUnmock("./telemetry/events.js");
vi.doUnmock("./telemetry/index.js");
vi.resetModules();
});

describe("CLI lifecycle", () => {
it("queues a command failure before finalizing telemetry", async () => {
let resolveEvents!: (events: {
trackCommandFailure: (command: string, error: unknown) => void;
}) => void;
const eventsModule = new Promise<{
trackCommandFailure: (command: string, error: unknown) => void;
}>((resolve) => {
resolveEvents = resolve;
});
let markEventsImportStarted!: () => void;
const eventsImportStarted = new Promise<void>((resolve) => {
markEventsImportStarted = resolve;
});
const order: string[] = [];

vi.doMock("./commands/init.js", () => ({
default: {
meta: { name: "init" },
args: { json: { type: "boolean" } },
run: vi.fn(),
},
}));
vi.doMock("./telemetry/index.js", () => ({
flush: async () => {
order.push("flush");
},
flushSync: vi.fn(),
incrementCommandCount: vi.fn(),
showTelemetryNotice: vi.fn(),
shouldTrack: () => false,
trackCliError: vi.fn(),
trackCommand: vi.fn(),
trackCommandResult: vi.fn(),
}));
vi.doMock("./telemetry/events.js", async () => {
markEventsImportStarted();
return eventsModule;
});

process.argv = ["node", "cli.ts", "init", "--bogus", "--json"];
const execution = import("./cli.js");

await eventsImportStarted;
expect(order).toEqual([]);

resolveEvents({
trackCommandFailure: () => {
order.push("cli_error");
},
});
await execution;

expect(order).toEqual(["cli_error", "flush"]);
});
});
123 changes: 95 additions & 28 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,11 +99,21 @@ try {
// Telemetry, update checks, and heavy modules are imported only when needed.
// For --help we skip telemetry entirely.

import { defineCommand, runMain } from "citty";
import { defineCommand, runCommand } from "citty";
import type { ArgsDef, CommandDef } from "citty";
import { getRunId } from "./telemetry/runId.js";
import { reportCommandFailure, trackCommandFailures } from "./utils/command-failure-tracking.js";
import { isRenderSucceeded } from "./utils/render-success-state.js";
import { resolveCommandUsage } from "./utils/commandUsageResolution.js";
import {
CliResultSignal,
CliRuntimeError,
CliUsageError,
consumeCommandResult,
registerRootExitCodeSanitizer,
registerRootExitRequester,
type CommandResult,
} from "./utils/commandResult.js";

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

Expand Down Expand Up @@ -152,15 +162,8 @@ const commandLoaders = {
figma: () => import("./commands/figma.js").then((m) => m.default),
};

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

const main = defineCommand({
Expand Down Expand Up @@ -209,12 +212,13 @@ let _trackCommandResult:
let _printUpdateNotice: (() => void) | undefined;
let _printStalePinNotice: (() => void) | undefined;
let _printSkillsUpdateNotice: (() => void) | undefined;
let telemetryReady: Promise<void> = Promise.resolve();

// `events` is a telemetry-internal beacon: it self-tracks + self-flushes, so it
// skips the per-command wrapper (no duplicate cli_command, no first-run notice
// printed into a skill's captured output).
if (!isHelp && command !== "telemetry" && command !== "events" && command !== "unknown") {
import("./telemetry/index.js").then((mod) => {
telemetryReady = import("./telemetry/index.js").then((mod) => {
_flush = mod.flush;
_flushSync = mod.flushSync;
_trackCliError = mod.trackCliError;
Expand Down Expand Up @@ -265,35 +269,62 @@ if (

const commandStart = Date.now();
const runId = getRunId();
let finalized = false;

// Async flush for normal exit. `beforeExit` re-fires every time the
// event loop drains, and the async `_flush()` itself schedules new
// work — so a plain `on` listener would print the update notice (and
// re-flush) once per drain (the user-reported double-print). `once`
// detaches after first invocation, which is what we want for both.
// fallow-ignore-next-line complexity
process.once("beforeExit", () => {
_flush?.().catch(() => {});
async function finalizeCli(result: CommandResult): Promise<void> {
if (finalized) return;
finalized = true;
commandFailed ||= result.exitCode !== 0;
await telemetryReady.catch(() => {});
_trackCommandResult?.({
command,
success: result.exitCode === 0 && !commandFailed,
exitCode: result.exitCode,
durationMs: Date.now() - commandStart,
runId,
});
await _flush?.().catch(() => {});
if (!hasJsonFlag) {
_printUpdateNotice?.();
_printStalePinNotice?.();
_printSkillsUpdateNotice?.();
}
process.exitCode = result.exitCode;
}

registerRootExitRequester((exitCode) => {
void finalizeCli({
exitCode,
kind: exitCode === 0 ? "success" : "runtime_error",
presented: true,
}).finally(() => process.exit(exitCode));
});

registerRootExitCodeSanitizer(() => {
if (process.exitCode !== undefined && process.exitCode !== 0) {
process.exitCode = 0;
}
});

// Sync-only: exit handlers cannot await promises or drain microtasks.
// _trackCommandResult / _trackCliError are captured references resolved
// at init time, so they're callable synchronously here.
process.on("exit", (code) => {
_trackCommandResult?.({
command,
success: code === 0 && !commandFailed,
exitCode: code,
durationMs: Date.now() - commandStart,
runId,
});
_flushSync?.();
});
process.on(
"exit",
// fallow-ignore-next-line complexity
(code) => {
if (finalized) return;
_trackCommandResult?.({
command,
success: code === 0 && !commandFailed,
exitCode: code,
durationMs: Date.now() - commandStart,
runId,
});
_flushSync?.();
},
);

// Report a CLI error event to telemetry. Extracted from the process-error
// handlers so their bodies stay simple linear branches (see fallow CRAP
Expand Down Expand Up @@ -382,6 +413,7 @@ process.on("unhandledRejection", (reason) => {
return;
}
commandFailed = true;
process.exitCode = 1;
emitCliErrorEvent("unhandled_rejection", error);
});

Expand All @@ -394,4 +426,39 @@ async function showUsage<T extends ArgsDef>(
return impl(cmd as CommandDef, parent as CommandDef | undefined);
}

runMain(main, { showUsage });
async function showRequestedUsage(): Promise<void> {
const requested = await resolveCommandUsage(main as CommandDef, argv);
return showUsage(requested.command, requested.parent);
}

function commandResultForError(error: unknown): CommandResult {
if (error instanceof CliResultSignal) return error.result;
if (error instanceof CliUsageError || error instanceof CliRuntimeError) return error.result;
return { exitCode: 1, kind: "runtime_error" };
}

// fallow-ignore-next-line complexity
async function executeCli(): Promise<void> {
let result: CommandResult = { exitCode: 0, kind: "success" };
try {
if (isHelp) await showRequestedUsage();
else await runCommand(main, { rawArgs: argv });
} catch (error) {
result = commandResultForError(error);
if (!(error instanceof CliResultSignal)) {
commandFailed = true;
await reportCommandFailure(command, error);
const typed = error instanceof CliUsageError || error instanceof CliRuntimeError;
if (error instanceof CliUsageError && !error.result.presented) await showRequestedUsage();
if (!typed || !error.result.presented) {
console.error(error instanceof Error ? error.message : String(error));
}
}
} finally {
const pending = consumeCommandResult();
if (pending.exitCode !== 0 || result.exitCode === 0) result = pending;
await finalizeCli(result);
}
}

await executeCli();
Loading
Loading