@@ -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" ;
103103import type { ArgsDef , CommandDef } from "citty" ;
104104import { getRunId } from "./telemetry/runId.js" ;
105105import { 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
107116const 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.
158163const 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 ;
208213let _printUpdateNotice : ( ( ) => void ) | undefined ;
209214let _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).
214220if ( ! 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
263269const commandStart = Date . now ( ) ;
264270const 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
293321process . 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.
313341process . 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 ( ) ;
0 commit comments