@@ -30,32 +30,38 @@ function handleStreamError(err: NodeJS.ErrnoException): void {
3030process . stdout . on ( "error" , handleStreamError ) ;
3131process . stderr . on ( "error" , handleStreamError ) ;
3232
33- /** Run CLI command with telemetry wrapper */
34- async function runCommand ( args : string [ ] ) : Promise < void > {
35- await withTelemetry ( async ( span ) =>
36- run ( app , args , buildContext ( process , span ) )
37- ) ;
38- }
39-
4033/**
41- * Execute command with automatic Seer trial prompt.
34+ * Error-recovery middleware for the CLI.
35+ *
36+ * Each middleware wraps command execution and may intercept specific errors
37+ * to perform recovery actions (e.g., login, start trial) then retry.
4238 *
43- * If the command fails with a trial-eligible SeerError in an interactive TTY,
44- * checks for available trial, prompts user, starts trial, and retries.
39+ * Middlewares are applied innermost-first: the last middleware in the array
40+ * wraps the outermost layer, so it gets first crack at errors. This means
41+ * auth recovery (outermost) can catch errors from both the command AND
42+ * the trial prompt retry.
4543 *
46- * Shows a brief context message (not the full error format with URLs) before
47- * the trial prompt. If the trial isn't available or the user declines, the
48- * full error is re-thrown so the outer handler in main() displays it normally.
44+ * @param next - The next function in the chain (command or inner middleware)
45+ * @param args - CLI arguments for retry
46+ * @returns A function with the same signature, with error recovery added
47+ */
48+ type ErrorMiddleware = (
49+ next : ( argv : string [ ] ) => Promise < void > ,
50+ args : string [ ]
51+ ) => Promise < void > ;
52+
53+ /**
54+ * Seer trial prompt middleware.
4955 *
50- * @throws Re-throws the original error when trial is unavailable or declined
56+ * Catches trial-eligible SeerErrors and offers to start a free trial.
57+ * On success, retries the original command. On failure/decline, re-throws
58+ * so the outer error handler displays the full error with upgrade URL.
5159 */
52- async function executeWithSeerTrialPrompt ( args : string [ ] ) : Promise < void > {
60+ const seerTrialMiddleware : ErrorMiddleware = async ( next , args ) = > {
5361 try {
54- await runCommand ( args ) ;
62+ await next ( args ) ;
5563 } catch ( err ) {
56- // isTrialEligible handles instanceof SeerError check + reason + orgSlug + TTY
5764 if ( isTrialEligible ( err ) ) {
58- // isTrialEligible narrows err to SeerError with defined orgSlug
5965 const started = await promptAndStartTrial (
6066 // biome-ignore lint/style/noNonNullAssertion: isTrialEligible guarantees orgSlug is defined
6167 err . orgSlug ! ,
@@ -64,31 +70,25 @@ async function executeWithSeerTrialPrompt(args: string[]): Promise<void> {
6470
6571 if ( started ) {
6672 process . stderr . write ( "\nRetrying command...\n\n" ) ;
67- await runCommand ( args ) ;
73+ await next ( args ) ;
6874 return ;
6975 }
70-
71- // Trial not started (unavailable, declined, or failed) — re-throw
72- // so the outer error handler in main() displays the full error
73- // with the upgrade/settings URL
7476 }
7577 throw err ;
7678 }
77- }
79+ } ;
7880
7981/**
80- * Execute command with automatic authentication.
81- *
82- * If the command fails due to missing authentication and we're in a TTY,
83- * automatically run the interactive login flow and retry the command.
82+ * Auto-authentication middleware.
8483 *
85- * @throws Re-throws any non-authentication errors from the command
84+ * Catches auth errors (not_authenticated, expired) in interactive TTYs
85+ * and runs the login flow. On success, retries through the full middleware
86+ * chain so inner middlewares (e.g., trial prompt) also apply to the retry.
8687 */
87- async function executeWithAutoAuth ( args : string [ ] ) : Promise < void > {
88+ const autoAuthMiddleware : ErrorMiddleware = async ( next , args ) = > {
8889 try {
89- await executeWithSeerTrialPrompt ( args ) ;
90+ await next ( args ) ;
9091 } catch ( err ) {
91- // Auto-login for auth errors in interactive TTY environments
9292 // Use isatty(0) for reliable stdin TTY detection (process.stdin.isTTY can be undefined in Bun)
9393 // Errors can opt-out via skipAutoAuth (e.g., auth status command)
9494 if (
@@ -111,21 +111,58 @@ async function executeWithAutoAuth(args: string[]): Promise<void> {
111111
112112 if ( loginSuccess ) {
113113 process . stderr . write ( "\nRetrying command...\n\n" ) ;
114- await executeWithSeerTrialPrompt ( args ) ;
114+ await next ( args ) ;
115115 return ;
116116 }
117117
118- // Login failed or was cancelled - set exit code and return
119- // (don't call process.exit() directly to allow finally blocks to run)
118+ // Login failed or was cancelled
120119 process . exitCode = 1 ;
121120 return ;
122121 }
123122
124- // Re-throw non-auth errors to be handled by main
125123 throw err ;
126124 }
125+ } ;
126+
127+ /**
128+ * Error-recovery middlewares applied around command execution.
129+ *
130+ * Order matters: applied innermost-first, so the last entry wraps the
131+ * outermost layer. Auth middleware is outermost so it catches errors
132+ * from both the command and any inner middleware retries.
133+ *
134+ * To add a new middleware, append it to this array.
135+ */
136+ const errorMiddlewares : ErrorMiddleware [ ] = [
137+ seerTrialMiddleware ,
138+ autoAuthMiddleware ,
139+ ] ;
140+
141+ /** Run CLI command with telemetry wrapper */
142+ async function runCommand ( args : string [ ] ) : Promise < void > {
143+ await withTelemetry ( async ( span ) =>
144+ run ( app , args , buildContext ( process , span ) )
145+ ) ;
146+ }
147+
148+ /**
149+ * Build the command executor by composing error-recovery middlewares.
150+ *
151+ * Wraps `runCommand` with each middleware in order (innermost-first),
152+ * producing a single function that handles all error recovery.
153+ */
154+ function buildExecutor ( ) : ( args : string [ ] ) => Promise < void > {
155+ let executor = runCommand ;
156+ for ( const mw of errorMiddlewares ) {
157+ const next = executor ;
158+ executor = ( args ) => mw ( next , args ) ;
159+ }
160+ return executor ;
127161}
128162
163+ /** Command executor with all error-recovery middlewares applied */
164+ const executeCommand = buildExecutor ( ) ;
165+
129166async function main ( ) : Promise < void > {
130167 // Clean up old binary from previous Windows upgrade (no-op if file doesn't exist)
131168 startCleanupOldBinary ( ) ;
@@ -148,7 +185,7 @@ async function main(): Promise<void> {
148185 }
149186
150187 try {
151- await executeWithAutoAuth ( args ) ;
188+ await executeCommand ( args ) ;
152189 } catch ( err ) {
153190 process . stderr . write ( `${ error ( "Error:" ) } ${ formatError ( err ) } \n` ) ;
154191 process . exitCode = getExitCode ( err ) ;
0 commit comments