From 3547913d91aaeeef9100e50b02dbbccbb400d8dc Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Tue, 3 Feb 2026 19:42:53 -0800 Subject: [PATCH 1/5] fix: catch HelpError and display help instead of throwing When a HelpError is thrown (e.g., unknown command, no command specified), `parse()` and `parseAsync()` now catch it and handle gracefully: - Error message is printed to stderr - Help text is displayed to stderr - `process.exitCode` is set to 1 (no `process.exit()` call) - Returns result with `helpShown: true` flag This prevents HelpError from bubbling up to global exception handlers while still providing useful feedback to the user. Closes #31 --- src/bargs.ts | 80 +++++++++++++++--- src/types.ts | 24 ++++-- test/bargs.test.ts | 153 ++++++++++++++++++++++++++++------- test/parser-commands.test.ts | 48 +++++++---- 4 files changed, 243 insertions(+), 62 deletions(-) diff --git a/src/bargs.ts b/src/bargs.ts index 3227ba8..11e0910 100644 --- a/src/bargs.ts +++ b/src/bargs.ts @@ -760,22 +760,42 @@ const createCliBuilder = ( parse( args: string[] = process.argv.slice(2), - ): ParseResult & { command?: string } { - const result = parseCore(state, args, false); - if (isThenable(result)) { - throw new BargsError( - 'Async transform or handler detected. Use parseAsync() instead of parse().', - ); + ): ParseResult & { command?: string; helpShown?: boolean } { + try { + const result = parseCore(state, args, false); + if (isThenable(result)) { + throw new BargsError( + 'Async transform or handler detected. Use parseAsync() instead of parse().', + ); + } + return result as ParseResult & { command?: string }; + } catch (error) { + if (error instanceof HelpError) { + return handleHelpError(error, state) as ParseResult & { + command?: string; + helpShown: true; + }; + } + throw error; } - return result as ParseResult & { command?: string }; }, async parseAsync( args: string[] = process.argv.slice(2), - ): Promise & { command?: string }> { - return parseCore(state, args, true) as Promise< - ParseResult & { command?: string } - >; + ): Promise & { command?: string; helpShown?: boolean }> { + try { + return (await parseCore(state, args, true)) as ParseResult & { + command?: string; + }; + } catch (error) { + if (error instanceof HelpError) { + return handleHelpError(error, state) as ParseResult & { + command?: string; + helpShown: true; + }; + } + throw error; + } }, }; @@ -1053,6 +1073,44 @@ const generateHelpNew = (state: InternalCliState, theme: Theme): string => { }; /* c8 ignore stop */ +/** + * Handle a HelpError by displaying the error message and help text to stderr, + * setting the exit code, and returning a result indicating help was shown. + * + * This prevents HelpError from bubbling up to global exception handlers while + * still providing useful feedback to the user. + * + * @function + */ +const handleHelpError = ( + error: HelpError, + state: InternalCliState, +): ParseResult & { + command?: string; + helpShown: true; +} => { + const { theme } = state; + + // Write error message to stderr + process.stderr.write(`Error: ${error.message}\n\n`); + + // Generate and write help text to stderr + const helpText = generateHelpNew(state, theme); + process.stderr.write(helpText); + process.stderr.write('\n'); + + // Set exit code to indicate error (don't call process.exit()) + process.exitCode = 1; + + // Return a result indicating help was shown + return { + command: error.command, + helpShown: true, + positionals: [], + values: {}, + }; +}; + /** * Check if something is a Parser (has __brand: 'Parser'). Parsers can be either * objects or functions (CallableParser). diff --git a/src/types.ts b/src/types.ts index a97e678..d6cb16b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -162,20 +162,30 @@ export interface CliBuilder< * Parse arguments synchronously and run handlers. * * Throws if any transform or handler returns a Promise. + * + * When a HelpError occurs (e.g., unknown command, no command specified), help + * text is displayed to stderr, process.exitCode is set to 1, and a result + * with `helpShown: true` is returned instead of throwing. */ - parse( - args?: string[], - ): ParseResult & { command?: string }; + parse(args?: string[]): ParseResult & { + command?: string; + helpShown?: boolean; + }; /** * Parse arguments asynchronously and run handlers. * * Supports async transforms and handlers. + * + * When a HelpError occurs (e.g., unknown command, no command specified), help + * text is displayed to stderr, process.exitCode is set to 1, and a result + * with `helpShown: true` is returned instead of rejecting. */ - parseAsync( - args?: string[], - ): Promise< - ParseResult & { command?: string } + parseAsync(args?: string[]): Promise< + ParseResult & { + command?: string; + helpShown?: boolean; + } >; } diff --git a/test/bargs.test.ts b/test/bargs.test.ts index 49b50d9..9e62dbb 100644 --- a/test/bargs.test.ts +++ b/test/bargs.test.ts @@ -1,7 +1,7 @@ /** * Tests for the main bargs API. */ -import { expect, expectAsync } from 'bupkis'; +import { expect } from 'bupkis'; import { describe, it } from 'node:test'; import type { StringOption } from '../src/types.js'; @@ -189,17 +189,31 @@ describe('.parseAsync()', () => { expect(handlerCalled, 'to be', true); }); - it('throws on unknown command', async () => { - const cli = bargs('test-cli').command( - 'greet', - handle(opt.options({}), () => {}), - ); + it('handles unknown command by showing help and setting exitCode', async () => { + const originalExitCode = process.exitCode; + const stderrWrites: string[] = []; + const originalStderrWrite = process.stderr.write.bind(process.stderr); - await expectAsync( - cli.parseAsync(['unknown']), - 'to reject with error satisfying', - /Unknown command/, - ); + process.stderr.write = ((chunk: unknown) => { + stderrWrites.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + + try { + const cli = bargs('test-cli').command( + 'greet', + handle(opt.options({}), () => {}), + ); + + const result = await cli.parseAsync(['unknown']); + + expect(result.helpShown, 'to be true'); + expect(process.exitCode, 'to equal', 1); + expect(stderrWrites.join(''), 'to contain', 'Unknown command: unknown'); + } finally { + process.stderr.write = originalStderrWrite; + process.exitCode = originalExitCode; + } }); it('returns parsed result with command name', async () => { @@ -786,23 +800,108 @@ describe('merge() edge cases', () => { }); describe('error paths', () => { - it('throws HelpError when no command specified and no default', async () => { - const cli = bargs('test-cli') - .command( - 'run', - handle(opt.options({}), () => {}), - ) - .command( - 'build', - handle(opt.options({}), () => {}), - ); - // No defaultCommand set + describe('HelpError handling', () => { + it('catches HelpError on no command, displays help, and sets exitCode', async () => { + const originalExitCode = process.exitCode; + const stderrWrites: string[] = []; + const originalStderrWrite = process.stderr.write.bind(process.stderr); + + // Capture stderr + process.stderr.write = ((chunk: unknown) => { + stderrWrites.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + + try { + const cli = bargs('test-cli') + .command( + 'run', + handle(opt.options({}), () => {}), + ) + .command( + 'build', + handle(opt.options({}), () => {}), + ); + + // This should NOT throw - it should catch HelpError and handle it + const result = await cli.parseAsync([]); + + // Verify exitCode was set to 1 + expect(process.exitCode, 'to equal', 1); + + // Verify error message was shown + const output = stderrWrites.join(''); + expect(output, 'to contain', 'No command specified'); + + // Verify help was displayed + expect(output, 'to contain', 'USAGE'); + expect(output, 'to contain', 'COMMANDS'); + + // Verify result indicates help was shown + expect(result.helpShown, 'to be true'); + } finally { + process.stderr.write = originalStderrWrite; + process.exitCode = originalExitCode; + } + }); - await expectAsync( - cli.parseAsync([]), - 'to reject with error satisfying', - /No command specified/, - ); + it('catches HelpError on unknown command, displays help, and sets exitCode', async () => { + const originalExitCode = process.exitCode; + const stderrWrites: string[] = []; + const originalStderrWrite = process.stderr.write.bind(process.stderr); + + process.stderr.write = ((chunk: unknown) => { + stderrWrites.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + + try { + const cli = bargs('test-cli').command( + 'run', + handle(opt.options({}), () => {}), + ); + + const result = await cli.parseAsync(['unknown-command']); + + expect(process.exitCode, 'to equal', 1); + + const output = stderrWrites.join(''); + expect(output, 'to contain', 'Unknown command: unknown-command'); + expect(output, 'to contain', 'USAGE'); + + expect(result.helpShown, 'to be true'); + } finally { + process.stderr.write = originalStderrWrite; + process.exitCode = originalExitCode; + } + }); + + it('catches HelpError in sync parse() as well', () => { + const originalExitCode = process.exitCode; + const stderrWrites: string[] = []; + const originalStderrWrite = process.stderr.write.bind(process.stderr); + + process.stderr.write = ((chunk: unknown) => { + stderrWrites.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + + try { + const cli = bargs('test-cli').command( + 'run', + handle(opt.options({}), () => {}), + ); + + const result = cli.parse([]); + + expect(process.exitCode, 'to equal', 1); + expect(stderrWrites.join(''), 'to contain', 'No command specified'); + expect(result.helpShown, 'to be true'); + } finally { + process.stderr.write = originalStderrWrite; + process.exitCode = originalExitCode; + } + }); }); it('handles async global transform in nested commands', async () => { diff --git a/test/parser-commands.test.ts b/test/parser-commands.test.ts index 9af6bd1..34cbc32 100644 --- a/test/parser-commands.test.ts +++ b/test/parser-commands.test.ts @@ -4,7 +4,7 @@ * These tests focus on command dispatching and parsing behaviors complementary * to the tests in bargs.test.ts. */ -import { expect, expectAsync } from 'bupkis'; +import { expect } from 'bupkis'; import { describe, it } from 'node:test'; import { bargs, handle } from '../src/bargs.js'; @@ -96,22 +96,36 @@ describe('command parsing', () => { }); }); - it('throws on unknown command', async () => { - const cli = bargs('test-cli') - .command( - 'add', - handle(opt.options({}), () => {}), - ) - .command( - 'remove', - handle(opt.options({}), () => {}), - ); - - await expectAsync( - cli.parseAsync(['unknown']), - 'to reject with error satisfying', - /Unknown command: unknown/, - ); + it('handles unknown command by showing help and setting exitCode', async () => { + const originalExitCode = process.exitCode; + const stderrWrites: string[] = []; + const originalStderrWrite = process.stderr.write.bind(process.stderr); + + process.stderr.write = ((chunk: unknown) => { + stderrWrites.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + + try { + const cli = bargs('test-cli') + .command( + 'add', + handle(opt.options({}), () => {}), + ) + .command( + 'remove', + handle(opt.options({}), () => {}), + ); + + const result = await cli.parseAsync(['unknown']); + + expect(result.helpShown, 'to be true'); + expect(process.exitCode, 'to equal', 1); + expect(stderrWrites.join(''), 'to contain', 'Unknown command: unknown'); + } finally { + process.stderr.write = originalStderrWrite; + process.exitCode = originalExitCode; + } }); it('merges global and command options', async () => { From 33a4e5984050ba6e5eec57e7f2848ffdc336d000 Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Tue, 3 Feb 2026 19:45:18 -0800 Subject: [PATCH 2/5] refactor: remove all process.exit() calls Replace `process.exit()` with `process.exitCode` throughout `parseCore()`: - `--help` now sets `process.exitCode = 0` and returns `{ helpShown: true }` - `--version` now sets `process.exitCode = 0` and returns `{ helpShown: true }` - `--completion-script` now sets appropriate exit code and returns - `--get-bargs-completions` now sets exit code and returns - `showNestedCommandHelp()` now returns a result instead of calling exit This allows the process to terminate naturally, enabling proper cleanup handlers and making the code more testable and composable. --- src/bargs.ts | 76 +++++++++++++++++++++++++++++++++------------------- 1 file changed, 48 insertions(+), 28 deletions(-) diff --git a/src/bargs.ts b/src/bargs.ts index 11e0910..3e60010 100644 --- a/src/bargs.ts +++ b/src/bargs.ts @@ -819,7 +819,22 @@ const parseCore = ( > => { const { aliasMap, commands, options, theme } = state; - /* c8 ignore start -- help/version output calls process.exit() */ + /** + * Helper to create an early-exit result (for help, version, completions). + * Sets process.exitCode and returns a result with helpShown: true. + * + * @function + */ + const earlyExit = ( + exitCode: number, + ): ParseResult & { + command?: string; + helpShown: true; + } => { + process.exitCode = exitCode; + return { command: undefined, helpShown: true, positionals: [], values: {} }; + }; + // Handle --help if (args.includes('--help') || args.includes('-h')) { // Check for command-specific help @@ -852,8 +867,7 @@ const parseCore = ( values: {}, }; // This will trigger the nested builder's help handling - // and call process.exit(0) if --help is handled - void internalNestedBuilder.__parseWithParentGlobals( + return internalNestedBuilder.__parseWithParentGlobals( nestedArgs, emptyGlobals, true, @@ -861,18 +875,17 @@ const parseCore = ( } // If no more args, show help for this nested command group - showNestedCommandHelp(state, commandName); - // showNestedCommandHelp calls process.exit(0) + return showNestedCommandHelp(state, commandName); } // Regular command help console.log(generateCommandHelpNew(state, commandName, theme)); - process.exit(0); + return earlyExit(0); } } console.log(generateHelpNew(state, theme)); - process.exit(0); + return earlyExit(0); } // Handle --version @@ -883,7 +896,7 @@ const parseCore = ( } else { console.log('Version information not available'); } - process.exit(0); + return earlyExit(0); } // Handle shell completion (when enabled) @@ -896,15 +909,15 @@ const parseCore = ( console.error( 'Error: --completion-script requires a shell argument (bash, zsh, or fish)', ); - process.exit(1); + return earlyExit(1); } try { const shell = validateShell(shellArg); console.log(generateCompletionScript(state.name, shell)); - process.exit(0); + return earlyExit(0); } catch (err) { console.error(`Error: ${(err as Error).message}`); - process.exit(1); + return earlyExit(1); } } @@ -914,7 +927,7 @@ const parseCore = ( const shellArg = args[getCompletionsIndex + 1]; if (!shellArg) { // No shell specified, output nothing - process.exit(0); + return earlyExit(0); } try { const shell = validateShell(shellArg); @@ -924,14 +937,13 @@ const parseCore = ( if (candidates.length > 0) { console.log(candidates.join('\n')); } - process.exit(0); + return earlyExit(0); } catch { // Invalid shell, output nothing - process.exit(0); + return earlyExit(0); } } } - /* c8 ignore stop */ // If we have commands, dispatch to the appropriate one if (commands.size > 0) { @@ -947,15 +959,25 @@ const parseCore = ( * * @function */ -/* c8 ignore start -- only called from help paths that call process.exit() */ const showNestedCommandHelp = ( state: InternalCliState, commandName: string, -): void => { +): + | (ParseResult & { + command?: string; + helpShown?: boolean; + }) + | Promise< + ParseResult & { + command?: string; + helpShown?: boolean; + } + > => { const commandEntry = state.commands.get(commandName); if (!commandEntry || commandEntry.type !== 'nested') { - console.log(`Unknown command group: ${commandName}`); - process.exit(1); + console.error(`Unknown command group: ${commandName}`); + process.exitCode = 1; + return { command: undefined, helpShown: true, positionals: [], values: {} }; } // Delegate to nested builder with --help @@ -968,21 +990,20 @@ const showNestedCommandHelp = ( values: {}, }; - // This will show the nested builder's help and call process.exit(0) - void internalNestedBuilder.__parseWithParentGlobals( + // This will show the nested builder's help + return internalNestedBuilder.__parseWithParentGlobals( ['--help'], emptyGlobals, true, ); }; -/* c8 ignore stop */ /** * Generate command-specific help. * * @function */ -/* c8 ignore start -- only called from help paths that call process.exit() */ +/* c8 ignore start -- only called from help paths */ const generateCommandHelpNew = ( state: InternalCliState, commandName: string, @@ -993,11 +1014,10 @@ const generateCommandHelpNew = ( return `Unknown command: ${commandName}`; } - // Handle nested commands - this shouldn't be reached as nested commands - // delegate to showNestedCommandHelp in parseCore, but handle it gracefully + // Nested commands are handled by showNestedCommandHelp in parseCore, + // so this function should never be called for nested commands if (commandEntry.type === 'nested') { - showNestedCommandHelp(state, commandName); - return ''; // Never reached, showNestedCommandHelp calls process.exit + return `${commandName} is a command group. Use --help after a subcommand.`; } // Regular command help @@ -1024,7 +1044,7 @@ const generateCommandHelpNew = ( * * @function */ -/* c8 ignore start -- only called from help paths that call process.exit() */ +/* c8 ignore start -- only called from help paths */ const generateHelpNew = (state: InternalCliState, theme: Theme): string => { // Build options schema, adding built-in options let options = state.globalParser?.__optionsSchema; From 33249a7eeef72e41e71ce81464ad395236567510 Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Tue, 3 Feb 2026 19:59:43 -0800 Subject: [PATCH 3/5] fix: address review comments - Rename `helpShown` to `earlyExit` for clarity (issue #4) The flag is now accurately named since it's set for help, version, and completion output, not just help display. - Extract shared test helper `withCapturedStderr()` (issues #2, #3) Reduces code duplication in tests that capture stderr and exitCode. - Handle HelpError in nested command delegation (issue #1) `__parseWithParentGlobals()` now catches HelpError so nested builders can render their own help instead of bubbling up to the parent. --- src/bargs.ts | 83 ++++++++----- src/types.ts | 16 +-- test/bargs.test.ts | 219 ++++++++++++++++++----------------- test/parser-commands.test.ts | 103 +++++++++++----- 4 files changed, 249 insertions(+), 172 deletions(-) diff --git a/src/bargs.ts b/src/bargs.ts index 3e60010..b85b6ec 100644 --- a/src/bargs.ts +++ b/src/bargs.ts @@ -557,17 +557,46 @@ const createCliBuilder = ( }, // Internal method for nested command support - not part of public API + // Handles HelpError here so nested builders can render their own help __parseWithParentGlobals( args: string[], parentGlobals: ParseResult, allowAsync: boolean, ): - | (ParseResult & { command?: string }) - | Promise & { command?: string }> { + | (ParseResult & { command?: string; earlyExit?: boolean }) + | Promise & { command?: string; earlyExit?: boolean }> { const stateWithGlobals = { ...state, parentGlobals }; - return parseCore(stateWithGlobals, args, allowAsync) as - | (ParseResult & { command?: string }) - | Promise & { command?: string }>; + try { + const result = parseCore(stateWithGlobals, args, allowAsync); + if (isThenable(result)) { + return result.catch((error: unknown) => { + if (error instanceof HelpError) { + return handleHelpError(error, stateWithGlobals) as ParseResult< + V, + P + > & { + command?: string; + earlyExit: true; + }; + } + throw error; + }) as Promise< + ParseResult & { command?: string; earlyExit?: boolean } + >; + } + return result as ParseResult & { command?: string }; + } catch (error) { + if (error instanceof HelpError) { + return handleHelpError(error, stateWithGlobals) as ParseResult< + V, + P + > & { + command?: string; + earlyExit: true; + }; + } + throw error; + } }, // Overloaded command(): accepts (name, factory, options?), @@ -760,7 +789,7 @@ const createCliBuilder = ( parse( args: string[] = process.argv.slice(2), - ): ParseResult & { command?: string; helpShown?: boolean } { + ): ParseResult & { command?: string; earlyExit?: boolean } { try { const result = parseCore(state, args, false); if (isThenable(result)) { @@ -773,7 +802,7 @@ const createCliBuilder = ( if (error instanceof HelpError) { return handleHelpError(error, state) as ParseResult & { command?: string; - helpShown: true; + earlyExit: true; }; } throw error; @@ -782,7 +811,7 @@ const createCliBuilder = ( async parseAsync( args: string[] = process.argv.slice(2), - ): Promise & { command?: string; helpShown?: boolean }> { + ): Promise & { command?: string; earlyExit?: boolean }> { try { return (await parseCore(state, args, true)) as ParseResult & { command?: string; @@ -791,7 +820,7 @@ const createCliBuilder = ( if (error instanceof HelpError) { return handleHelpError(error, state) as ParseResult & { command?: string; - helpShown: true; + earlyExit: true; }; } throw error; @@ -821,18 +850,18 @@ const parseCore = ( /** * Helper to create an early-exit result (for help, version, completions). - * Sets process.exitCode and returns a result with helpShown: true. + * Sets process.exitCode and returns a result with earlyExit: true. * * @function */ - const earlyExit = ( + const createEarlyExitResult = ( exitCode: number, ): ParseResult & { command?: string; - helpShown: true; + earlyExit: true; } => { process.exitCode = exitCode; - return { command: undefined, helpShown: true, positionals: [], values: {} }; + return { command: undefined, earlyExit: true, positionals: [], values: {} }; }; // Handle --help @@ -880,12 +909,12 @@ const parseCore = ( // Regular command help console.log(generateCommandHelpNew(state, commandName, theme)); - return earlyExit(0); + return createEarlyExitResult(0); } } console.log(generateHelpNew(state, theme)); - return earlyExit(0); + return createEarlyExitResult(0); } // Handle --version @@ -896,7 +925,7 @@ const parseCore = ( } else { console.log('Version information not available'); } - return earlyExit(0); + return createEarlyExitResult(0); } // Handle shell completion (when enabled) @@ -909,15 +938,15 @@ const parseCore = ( console.error( 'Error: --completion-script requires a shell argument (bash, zsh, or fish)', ); - return earlyExit(1); + return createEarlyExitResult(1); } try { const shell = validateShell(shellArg); console.log(generateCompletionScript(state.name, shell)); - return earlyExit(0); + return createEarlyExitResult(0); } catch (err) { console.error(`Error: ${(err as Error).message}`); - return earlyExit(1); + return createEarlyExitResult(1); } } @@ -927,7 +956,7 @@ const parseCore = ( const shellArg = args[getCompletionsIndex + 1]; if (!shellArg) { // No shell specified, output nothing - return earlyExit(0); + return createEarlyExitResult(0); } try { const shell = validateShell(shellArg); @@ -937,10 +966,10 @@ const parseCore = ( if (candidates.length > 0) { console.log(candidates.join('\n')); } - return earlyExit(0); + return createEarlyExitResult(0); } catch { // Invalid shell, output nothing - return earlyExit(0); + return createEarlyExitResult(0); } } } @@ -965,19 +994,19 @@ const showNestedCommandHelp = ( ): | (ParseResult & { command?: string; - helpShown?: boolean; + earlyExit?: boolean; }) | Promise< ParseResult & { command?: string; - helpShown?: boolean; + earlyExit?: boolean; } > => { const commandEntry = state.commands.get(commandName); if (!commandEntry || commandEntry.type !== 'nested') { console.error(`Unknown command group: ${commandName}`); process.exitCode = 1; - return { command: undefined, helpShown: true, positionals: [], values: {} }; + return { command: undefined, earlyExit: true, positionals: [], values: {} }; } // Delegate to nested builder with --help @@ -1107,7 +1136,7 @@ const handleHelpError = ( state: InternalCliState, ): ParseResult & { command?: string; - helpShown: true; + earlyExit: true; } => { const { theme } = state; @@ -1125,7 +1154,7 @@ const handleHelpError = ( // Return a result indicating help was shown return { command: error.command, - helpShown: true, + earlyExit: true, positionals: [], values: {}, }; diff --git a/src/types.ts b/src/types.ts index d6cb16b..36f1deb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -163,13 +163,13 @@ export interface CliBuilder< * * Throws if any transform or handler returns a Promise. * - * When a HelpError occurs (e.g., unknown command, no command specified), help - * text is displayed to stderr, process.exitCode is set to 1, and a result - * with `helpShown: true` is returned instead of throwing. + * When an early exit occurs (--help, --version, --completion-script, or + * HelpError), output is displayed, process.exitCode is set appropriately, and + * a result with `earlyExit: true` is returned instead of throwing. */ parse(args?: string[]): ParseResult & { command?: string; - helpShown?: boolean; + earlyExit?: boolean; }; /** @@ -177,14 +177,14 @@ export interface CliBuilder< * * Supports async transforms and handlers. * - * When a HelpError occurs (e.g., unknown command, no command specified), help - * text is displayed to stderr, process.exitCode is set to 1, and a result - * with `helpShown: true` is returned instead of rejecting. + * When an early exit occurs (--help, --version, --completion-script, or + * HelpError), output is displayed, process.exitCode is set appropriately, and + * a result with `earlyExit: true` is returned instead of rejecting. */ parseAsync(args?: string[]): Promise< ParseResult & { command?: string; - helpShown?: boolean; + earlyExit?: boolean; } >; } diff --git a/test/bargs.test.ts b/test/bargs.test.ts index 9e62dbb..23ac447 100644 --- a/test/bargs.test.ts +++ b/test/bargs.test.ts @@ -9,6 +9,63 @@ import type { StringOption } from '../src/types.js'; import { bargs, handle, map, merge } from '../src/bargs.js'; import { opt, pos } from '../src/opt.js'; +/** + * Helper to capture stderr output and process.exitCode during tests. Returns + * the captured output, result, and exitCode. + * + * @function + */ +const withCapturedStderr = ( + fn: () => Promise | T, +): Promise<{ + exitCode: typeof process.exitCode; + output: string; + result: T; +}> => { + const originalExitCode = process.exitCode; + const stderrWrites: string[] = []; + const originalStderrWrite = process.stderr.write.bind(process.stderr); + + process.stderr.write = ((chunk: unknown) => { + stderrWrites.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + + /** + * @function + */ + const createResult = (result: T) => ({ + exitCode: process.exitCode, + output: stderrWrites.join(''), + result, + }); + + /** + * @function + */ + const cleanup = () => { + process.stderr.write = originalStderrWrite; + process.exitCode = originalExitCode; + }; + + try { + const maybePromise = fn(); + if (maybePromise instanceof Promise) { + return maybePromise.then((result) => { + const capturedResult = createResult(result); + cleanup(); + return capturedResult; + }); + } + const capturedResult = createResult(maybePromise); + cleanup(); + return Promise.resolve(capturedResult); + } catch (error) { + cleanup(); + throw error; + } +}; + describe('bargs()', () => { it('creates a CLI builder', () => { const cli = bargs('test-cli'); @@ -190,30 +247,18 @@ describe('.parseAsync()', () => { }); it('handles unknown command by showing help and setting exitCode', async () => { - const originalExitCode = process.exitCode; - const stderrWrites: string[] = []; - const originalStderrWrite = process.stderr.write.bind(process.stderr); - - process.stderr.write = ((chunk: unknown) => { - stderrWrites.push(String(chunk)); - return true; - }) as typeof process.stderr.write; - - try { - const cli = bargs('test-cli').command( - 'greet', - handle(opt.options({}), () => {}), - ); + const cli = bargs('test-cli').command( + 'greet', + handle(opt.options({}), () => {}), + ); - const result = await cli.parseAsync(['unknown']); + const { exitCode, output, result } = await withCapturedStderr(() => + cli.parseAsync(['unknown']), + ); - expect(result.helpShown, 'to be true'); - expect(process.exitCode, 'to equal', 1); - expect(stderrWrites.join(''), 'to contain', 'Unknown command: unknown'); - } finally { - process.stderr.write = originalStderrWrite; - process.exitCode = originalExitCode; - } + expect(result.earlyExit, 'to be true'); + expect(exitCode, 'to equal', 1); + expect(output, 'to contain', 'Unknown command: unknown'); }); it('returns parsed result with command name', async () => { @@ -802,105 +847,63 @@ describe('merge() edge cases', () => { describe('error paths', () => { describe('HelpError handling', () => { it('catches HelpError on no command, displays help, and sets exitCode', async () => { - const originalExitCode = process.exitCode; - const stderrWrites: string[] = []; - const originalStderrWrite = process.stderr.write.bind(process.stderr); - - // Capture stderr - process.stderr.write = ((chunk: unknown) => { - stderrWrites.push(String(chunk)); - return true; - }) as typeof process.stderr.write; - - try { - const cli = bargs('test-cli') - .command( - 'run', - handle(opt.options({}), () => {}), - ) - .command( - 'build', - handle(opt.options({}), () => {}), - ); - - // This should NOT throw - it should catch HelpError and handle it - const result = await cli.parseAsync([]); - - // Verify exitCode was set to 1 - expect(process.exitCode, 'to equal', 1); - - // Verify error message was shown - const output = stderrWrites.join(''); - expect(output, 'to contain', 'No command specified'); - - // Verify help was displayed - expect(output, 'to contain', 'USAGE'); - expect(output, 'to contain', 'COMMANDS'); - - // Verify result indicates help was shown - expect(result.helpShown, 'to be true'); - } finally { - process.stderr.write = originalStderrWrite; - process.exitCode = originalExitCode; - } - }); - - it('catches HelpError on unknown command, displays help, and sets exitCode', async () => { - const originalExitCode = process.exitCode; - const stderrWrites: string[] = []; - const originalStderrWrite = process.stderr.write.bind(process.stderr); - - process.stderr.write = ((chunk: unknown) => { - stderrWrites.push(String(chunk)); - return true; - }) as typeof process.stderr.write; - - try { - const cli = bargs('test-cli').command( + const cli = bargs('test-cli') + .command( 'run', handle(opt.options({}), () => {}), + ) + .command( + 'build', + handle(opt.options({}), () => {}), ); - const result = await cli.parseAsync(['unknown-command']); + const { exitCode, output, result } = await withCapturedStderr(() => + cli.parseAsync([]), + ); + + // Verify exitCode was set to 1 + expect(exitCode, 'to equal', 1); - expect(process.exitCode, 'to equal', 1); + // Verify error message was shown + expect(output, 'to contain', 'No command specified'); - const output = stderrWrites.join(''); - expect(output, 'to contain', 'Unknown command: unknown-command'); - expect(output, 'to contain', 'USAGE'); + // Verify help was displayed + expect(output, 'to contain', 'USAGE'); + expect(output, 'to contain', 'COMMANDS'); - expect(result.helpShown, 'to be true'); - } finally { - process.stderr.write = originalStderrWrite; - process.exitCode = originalExitCode; - } + // Verify result indicates early exit + expect(result.earlyExit, 'to be true'); }); - it('catches HelpError in sync parse() as well', () => { - const originalExitCode = process.exitCode; - const stderrWrites: string[] = []; - const originalStderrWrite = process.stderr.write.bind(process.stderr); + it('catches HelpError on unknown command, displays help, and sets exitCode', async () => { + const cli = bargs('test-cli').command( + 'run', + handle(opt.options({}), () => {}), + ); + + const { exitCode, output, result } = await withCapturedStderr(() => + cli.parseAsync(['unknown-command']), + ); - process.stderr.write = ((chunk: unknown) => { - stderrWrites.push(String(chunk)); - return true; - }) as typeof process.stderr.write; + expect(exitCode, 'to equal', 1); + expect(output, 'to contain', 'Unknown command: unknown-command'); + expect(output, 'to contain', 'USAGE'); + expect(result.earlyExit, 'to be true'); + }); - try { - const cli = bargs('test-cli').command( - 'run', - handle(opt.options({}), () => {}), - ); + it('catches HelpError in sync parse() as well', async () => { + const cli = bargs('test-cli').command( + 'run', + handle(opt.options({}), () => {}), + ); - const result = cli.parse([]); + const { exitCode, output, result } = await withCapturedStderr(() => + cli.parse([]), + ); - expect(process.exitCode, 'to equal', 1); - expect(stderrWrites.join(''), 'to contain', 'No command specified'); - expect(result.helpShown, 'to be true'); - } finally { - process.stderr.write = originalStderrWrite; - process.exitCode = originalExitCode; - } + expect(exitCode, 'to equal', 1); + expect(output, 'to contain', 'No command specified'); + expect(result.earlyExit, 'to be true'); }); }); diff --git a/test/parser-commands.test.ts b/test/parser-commands.test.ts index 34cbc32..ce1ceda 100644 --- a/test/parser-commands.test.ts +++ b/test/parser-commands.test.ts @@ -10,6 +10,63 @@ import { describe, it } from 'node:test'; import { bargs, handle } from '../src/bargs.js'; import { opt, pos } from '../src/opt.js'; +/** + * Helper to capture stderr output and process.exitCode during tests. Returns + * the captured output, result, and exitCode. + * + * @function + */ +const withCapturedStderr = ( + fn: () => Promise | T, +): Promise<{ + exitCode: typeof process.exitCode; + output: string; + result: T; +}> => { + const originalExitCode = process.exitCode; + const stderrWrites: string[] = []; + const originalStderrWrite = process.stderr.write.bind(process.stderr); + + process.stderr.write = ((chunk: unknown) => { + stderrWrites.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + + /** + * @function + */ + const createResult = (result: T) => ({ + exitCode: process.exitCode, + output: stderrWrites.join(''), + result, + }); + + /** + * @function + */ + const cleanup = () => { + process.stderr.write = originalStderrWrite; + process.exitCode = originalExitCode; + }; + + try { + const maybePromise = fn(); + if (maybePromise instanceof Promise) { + return maybePromise.then((result) => { + const capturedResult = createResult(result); + cleanup(); + return capturedResult; + }); + } + const capturedResult = createResult(maybePromise); + cleanup(); + return Promise.resolve(capturedResult); + } catch (error) { + cleanup(); + throw error; + } +}; + describe('command parsing', () => { it('parses a command with options', async () => { let result: unknown; @@ -97,35 +154,23 @@ describe('command parsing', () => { }); it('handles unknown command by showing help and setting exitCode', async () => { - const originalExitCode = process.exitCode; - const stderrWrites: string[] = []; - const originalStderrWrite = process.stderr.write.bind(process.stderr); - - process.stderr.write = ((chunk: unknown) => { - stderrWrites.push(String(chunk)); - return true; - }) as typeof process.stderr.write; - - try { - const cli = bargs('test-cli') - .command( - 'add', - handle(opt.options({}), () => {}), - ) - .command( - 'remove', - handle(opt.options({}), () => {}), - ); - - const result = await cli.parseAsync(['unknown']); - - expect(result.helpShown, 'to be true'); - expect(process.exitCode, 'to equal', 1); - expect(stderrWrites.join(''), 'to contain', 'Unknown command: unknown'); - } finally { - process.stderr.write = originalStderrWrite; - process.exitCode = originalExitCode; - } + const cli = bargs('test-cli') + .command( + 'add', + handle(opt.options({}), () => {}), + ) + .command( + 'remove', + handle(opt.options({}), () => {}), + ); + + const { exitCode, output, result } = await withCapturedStderr(() => + cli.parseAsync(['unknown']), + ); + + expect(result.earlyExit, 'to be true'); + expect(exitCode, 'to equal', 1); + expect(output, 'to contain', 'Unknown command: unknown'); }); it('merges global and command options', async () => { From 1f2f4db6aff8e0f108491f2661621176d6b14035 Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Tue, 3 Feb 2026 20:05:43 -0800 Subject: [PATCH 4/5] fix: use process.exit() for early exit scenarios Restore standard CLI behavior where --help, --version, completion flags, and error conditions (unknown/missing commands) terminate the process. Changes: - `exitProcess()` calls `process.exit()` for help/version/completions - `handleHelpError()` calls `process.exit(1)` after displaying help - Remove `earlyExit` flag from return types (no longer needed) - Update tests to mock `process.exit` instead of checking return values - Document process termination behavior in README.md This is what users expect from a CLI - these flags print output and exit. --- README.md | 47 ++++++++++--- src/bargs.ts | 131 +++++++++++++---------------------- src/types.ts | 16 ++--- test/bargs.test.ts | 99 +++++++++++++++----------- test/parser-commands.test.ts | 78 ++++++++++++++------- 5 files changed, 207 insertions(+), 164 deletions(-) diff --git a/README.md b/README.md index 24335f4..2e7e855 100644 --- a/README.md +++ b/README.md @@ -707,17 +707,46 @@ See `examples/completion.ts` for a complete example. ## Advanced Usage +### Process Termination + +**bargs** automatically terminates the process (via `process.exit()`) in certain scenarios. This is standard CLI behavior—users expect these flags to print output and exit immediately: + +| Scenario | Exit Code | Output | +| ------------------------- | --------- | --------------------------------- | +| `--help` / `-h` | 0 | Help text to stdout | +| `--version` | 0 | Version string to stdout | +| `--completion-script` | 0 | Shell completion script to stdout | +| Unknown command | 1 | Error + help text to stderr | +| Missing required command | 1 | Error + help text to stderr | +| `--get-bargs-completions` | 0 | Completion candidates to stdout | + +For testing, you can mock `process.exit` to capture the exit code: + +```typescript +const originalExit = process.exit; +let exitCode: number | undefined; + +process.exit = ((code?: number) => { + exitCode = code ?? 0; + throw new Error('process.exit called'); +}) as typeof process.exit; + +try { + cli.parse(['--help']); +} catch { + // process.exit was called +} + +process.exit = originalExit; +console.log(exitCode); // 0 +``` + ### Error Handling -**bargs** exports some `Error` subclasses: +**bargs** exports some `Error` subclasses for errors that _don't_ cause automatic process termination: ```typescript -import { - bargs, - BargsError, - HelpError, - ValidationError, -} from '@boneskull/bargs'; +import { bargs, BargsError, ValidationError } from '@boneskull/bargs'; try { await bargs('my-cli').parseAsync(); @@ -726,10 +755,6 @@ try { // Config validation failed (e.g., invalid schema) // i.e., "you screwed up" console.error(`Config error at "${error.path}": ${error.message}`); - } else if (error instanceof HelpError) { - // Likely invalid options, command or positionals; - // re-throw to trigger help display - throw error; } else if (error instanceof BargsError) { // General bargs error console.error(error.message); diff --git a/src/bargs.ts b/src/bargs.ts index b85b6ec..45f5908 100644 --- a/src/bargs.ts +++ b/src/bargs.ts @@ -563,37 +563,23 @@ const createCliBuilder = ( parentGlobals: ParseResult, allowAsync: boolean, ): - | (ParseResult & { command?: string; earlyExit?: boolean }) - | Promise & { command?: string; earlyExit?: boolean }> { + | (ParseResult & { command?: string }) + | Promise & { command?: string }> { const stateWithGlobals = { ...state, parentGlobals }; try { const result = parseCore(stateWithGlobals, args, allowAsync); if (isThenable(result)) { return result.catch((error: unknown) => { if (error instanceof HelpError) { - return handleHelpError(error, stateWithGlobals) as ParseResult< - V, - P - > & { - command?: string; - earlyExit: true; - }; + handleHelpError(error, stateWithGlobals); // exits process } throw error; - }) as Promise< - ParseResult & { command?: string; earlyExit?: boolean } - >; + }) as Promise & { command?: string }>; } return result as ParseResult & { command?: string }; } catch (error) { if (error instanceof HelpError) { - return handleHelpError(error, stateWithGlobals) as ParseResult< - V, - P - > & { - command?: string; - earlyExit: true; - }; + handleHelpError(error, stateWithGlobals); // exits process } throw error; } @@ -789,7 +775,7 @@ const createCliBuilder = ( parse( args: string[] = process.argv.slice(2), - ): ParseResult & { command?: string; earlyExit?: boolean } { + ): ParseResult & { command?: string } { try { const result = parseCore(state, args, false); if (isThenable(result)) { @@ -800,10 +786,7 @@ const createCliBuilder = ( return result as ParseResult & { command?: string }; } catch (error) { if (error instanceof HelpError) { - return handleHelpError(error, state) as ParseResult & { - command?: string; - earlyExit: true; - }; + handleHelpError(error, state); // exits process, never returns } throw error; } @@ -811,17 +794,14 @@ const createCliBuilder = ( async parseAsync( args: string[] = process.argv.slice(2), - ): Promise & { command?: string; earlyExit?: boolean }> { + ): Promise & { command?: string }> { try { return (await parseCore(state, args, true)) as ParseResult & { command?: string; }; } catch (error) { if (error instanceof HelpError) { - return handleHelpError(error, state) as ParseResult & { - command?: string; - earlyExit: true; - }; + handleHelpError(error, state); // exits process, never returns } throw error; } @@ -849,19 +829,17 @@ const parseCore = ( const { aliasMap, commands, options, theme } = state; /** - * Helper to create an early-exit result (for help, version, completions). - * Sets process.exitCode and returns a result with earlyExit: true. + * Terminates the process for early-exit scenarios (--help, --version, + * --completion-script). This is standard CLI behavior - users expect these + * flags to print output and exit immediately. * + * @remarks + * The return statement exists only to satisfy TypeScript. In practice, + * `process.exit()` terminates the process and this function never returns. * @function */ - const createEarlyExitResult = ( - exitCode: number, - ): ParseResult & { - command?: string; - earlyExit: true; - } => { - process.exitCode = exitCode; - return { command: undefined, earlyExit: true, positionals: [], values: {} }; + const exitProcess = (exitCode: number): never => { + process.exit(exitCode); }; // Handle --help @@ -909,12 +887,12 @@ const parseCore = ( // Regular command help console.log(generateCommandHelpNew(state, commandName, theme)); - return createEarlyExitResult(0); + return exitProcess(0); } } console.log(generateHelpNew(state, theme)); - return createEarlyExitResult(0); + return exitProcess(0); } // Handle --version @@ -925,7 +903,7 @@ const parseCore = ( } else { console.log('Version information not available'); } - return createEarlyExitResult(0); + return exitProcess(0); } // Handle shell completion (when enabled) @@ -938,15 +916,15 @@ const parseCore = ( console.error( 'Error: --completion-script requires a shell argument (bash, zsh, or fish)', ); - return createEarlyExitResult(1); + return exitProcess(1); } try { const shell = validateShell(shellArg); console.log(generateCompletionScript(state.name, shell)); - return createEarlyExitResult(0); + return exitProcess(0); } catch (err) { console.error(`Error: ${(err as Error).message}`); - return createEarlyExitResult(1); + return exitProcess(1); } } @@ -956,7 +934,7 @@ const parseCore = ( const shellArg = args[getCompletionsIndex + 1]; if (!shellArg) { // No shell specified, output nothing - return createEarlyExitResult(0); + return exitProcess(0); } try { const shell = validateShell(shellArg); @@ -966,10 +944,10 @@ const parseCore = ( if (candidates.length > 0) { console.log(candidates.join('\n')); } - return createEarlyExitResult(0); + return exitProcess(0); } catch { // Invalid shell, output nothing - return createEarlyExitResult(0); + return exitProcess(0); } } } @@ -985,31 +963,22 @@ const parseCore = ( /** * Show help for a nested command group by delegating to the nested builder. + * This function always terminates the process (either via the nested builder's + * help handling or via error exit). * * @function */ const showNestedCommandHelp = ( state: InternalCliState, commandName: string, -): - | (ParseResult & { - command?: string; - earlyExit?: boolean; - }) - | Promise< - ParseResult & { - command?: string; - earlyExit?: boolean; - } - > => { +): never => { const commandEntry = state.commands.get(commandName); if (!commandEntry || commandEntry.type !== 'nested') { console.error(`Unknown command group: ${commandName}`); - process.exitCode = 1; - return { command: undefined, earlyExit: true, positionals: [], values: {} }; + process.exit(1); } - // Delegate to nested builder with --help + // Delegate to nested builder with --help - this will exit the process const internalNestedBuilder = commandEntry.builder as InternalCliBuilder< unknown, readonly unknown[] @@ -1019,12 +988,18 @@ const showNestedCommandHelp = ( values: {}, }; - // This will show the nested builder's help - return internalNestedBuilder.__parseWithParentGlobals( + // This will show the nested builder's help and exit the process. + // The void operator explicitly marks this as intentionally unhandled since + // process.exit() inside will terminate before the promise resolves. + void internalNestedBuilder.__parseWithParentGlobals( ['--help'], emptyGlobals, true, ); + + // This should never be reached since help handling calls process.exit() + // but TypeScript needs it for the never return type + process.exit(0); }; /** @@ -1131,13 +1106,15 @@ const generateHelpNew = (state: InternalCliState, theme: Theme): string => { * * @function */ -const handleHelpError = ( - error: HelpError, - state: InternalCliState, -): ParseResult & { - command?: string; - earlyExit: true; -} => { +/** + * Handles HelpError by displaying the error message, showing help, and + * terminating the process with exit code 1. This is standard CLI behavior - + * when a user provides an unknown command or forgets to specify a required + * command, they see help and the process exits. + * + * @function + */ +const handleHelpError = (error: HelpError, state: InternalCliState): never => { const { theme } = state; // Write error message to stderr @@ -1148,16 +1125,8 @@ const handleHelpError = ( process.stderr.write(helpText); process.stderr.write('\n'); - // Set exit code to indicate error (don't call process.exit()) - process.exitCode = 1; - - // Return a result indicating help was shown - return { - command: error.command, - earlyExit: true, - positionals: [], - values: {}, - }; + // Terminate with error exit code + process.exit(1); }; /** diff --git a/src/types.ts b/src/types.ts index 36f1deb..974e630 100644 --- a/src/types.ts +++ b/src/types.ts @@ -163,13 +163,13 @@ export interface CliBuilder< * * Throws if any transform or handler returns a Promise. * - * When an early exit occurs (--help, --version, --completion-script, or - * HelpError), output is displayed, process.exitCode is set appropriately, and - * a result with `earlyExit: true` is returned instead of throwing. + * @remarks + * Early exit scenarios (`--help`, `--version`, `--completion-script`, or + * invalid/missing commands) will call `process.exit()` and never return. This + * is standard CLI behavior. */ parse(args?: string[]): ParseResult & { command?: string; - earlyExit?: boolean; }; /** @@ -177,14 +177,14 @@ export interface CliBuilder< * * Supports async transforms and handlers. * - * When an early exit occurs (--help, --version, --completion-script, or - * HelpError), output is displayed, process.exitCode is set appropriately, and - * a result with `earlyExit: true` is returned instead of rejecting. + * @remarks + * Early exit scenarios (`--help`, `--version`, `--completion-script`, or + * invalid/missing commands) will call `process.exit()` and never return. This + * is standard CLI behavior. */ parseAsync(args?: string[]): Promise< ParseResult & { command?: string; - earlyExit?: boolean; } >; } diff --git a/test/bargs.test.ts b/test/bargs.test.ts index 23ac447..4af1f1c 100644 --- a/test/bargs.test.ts +++ b/test/bargs.test.ts @@ -10,59 +10,88 @@ import { bargs, handle, map, merge } from '../src/bargs.js'; import { opt, pos } from '../src/opt.js'; /** - * Helper to capture stderr output and process.exitCode during tests. Returns - * the captured output, result, and exitCode. + * Custom error thrown when process.exit is mocked and called. + */ +class MockExitError extends Error { + readonly exitCode: number; + + constructor(exitCode: number) { + super(`process.exit(${exitCode}) was called`); + this.name = 'MockExitError'; + this.exitCode = exitCode; + } +} + +/** + * Helper to capture stderr output and mock process.exit during tests. Returns + * the captured output and exit code. * * @function */ -const withCapturedStderr = ( +const withMockedExit = ( fn: () => Promise | T, ): Promise<{ - exitCode: typeof process.exitCode; + exitCode: number; output: string; - result: T; + result?: T; }> => { - const originalExitCode = process.exitCode; const stderrWrites: string[] = []; const originalStderrWrite = process.stderr.write.bind(process.stderr); + const originalExit = process.exit; process.stderr.write = ((chunk: unknown) => { stderrWrites.push(String(chunk)); return true; }) as typeof process.stderr.write; + // Mock process.exit to throw instead of actually exiting + process.exit = ((code?: number) => { + throw new MockExitError(code ?? 0); + }) as typeof process.exit; + /** * @function */ - const createResult = (result: T) => ({ - exitCode: process.exitCode, - output: stderrWrites.join(''), - result, - }); + const cleanup = () => { + process.stderr.write = originalStderrWrite; + process.exit = originalExit; + }; /** * @function */ - const cleanup = () => { - process.stderr.write = originalStderrWrite; - process.exitCode = originalExitCode; + const handleMockExit = (error: unknown) => { + if (error instanceof MockExitError) { + return { + exitCode: error.exitCode, + output: stderrWrites.join(''), + result: undefined, + }; + } + throw error; }; try { const maybePromise = fn(); if (maybePromise instanceof Promise) { - return maybePromise.then((result) => { - const capturedResult = createResult(result); - cleanup(); - return capturedResult; - }); + return maybePromise + .then((result) => ({ + exitCode: 0, + output: stderrWrites.join(''), + result, + })) + .catch(handleMockExit) + .finally(cleanup); } - const capturedResult = createResult(maybePromise); cleanup(); - return Promise.resolve(capturedResult); + return Promise.resolve({ + exitCode: 0, + output: stderrWrites.join(''), + result: maybePromise, + }); } catch (error) { cleanup(); - throw error; + return Promise.resolve(handleMockExit(error)); } }; @@ -246,17 +275,16 @@ describe('.parseAsync()', () => { expect(handlerCalled, 'to be', true); }); - it('handles unknown command by showing help and setting exitCode', async () => { + it('handles unknown command by showing help and exiting', async () => { const cli = bargs('test-cli').command( 'greet', handle(opt.options({}), () => {}), ); - const { exitCode, output, result } = await withCapturedStderr(() => + const { exitCode, output } = await withMockedExit(() => cli.parseAsync(['unknown']), ); - expect(result.earlyExit, 'to be true'); expect(exitCode, 'to equal', 1); expect(output, 'to contain', 'Unknown command: unknown'); }); @@ -846,7 +874,7 @@ describe('merge() edge cases', () => { describe('error paths', () => { describe('HelpError handling', () => { - it('catches HelpError on no command, displays help, and sets exitCode', async () => { + it('handles no command by displaying help and exiting', async () => { const cli = bargs('test-cli') .command( 'run', @@ -857,11 +885,11 @@ describe('error paths', () => { handle(opt.options({}), () => {}), ); - const { exitCode, output, result } = await withCapturedStderr(() => + const { exitCode, output } = await withMockedExit(() => cli.parseAsync([]), ); - // Verify exitCode was set to 1 + // Verify process exits with code 1 expect(exitCode, 'to equal', 1); // Verify error message was shown @@ -870,40 +898,33 @@ describe('error paths', () => { // Verify help was displayed expect(output, 'to contain', 'USAGE'); expect(output, 'to contain', 'COMMANDS'); - - // Verify result indicates early exit - expect(result.earlyExit, 'to be true'); }); - it('catches HelpError on unknown command, displays help, and sets exitCode', async () => { + it('handles unknown command by displaying help and exiting', async () => { const cli = bargs('test-cli').command( 'run', handle(opt.options({}), () => {}), ); - const { exitCode, output, result } = await withCapturedStderr(() => + const { exitCode, output } = await withMockedExit(() => cli.parseAsync(['unknown-command']), ); expect(exitCode, 'to equal', 1); expect(output, 'to contain', 'Unknown command: unknown-command'); expect(output, 'to contain', 'USAGE'); - expect(result.earlyExit, 'to be true'); }); - it('catches HelpError in sync parse() as well', async () => { + it('handles HelpError in sync parse() as well', async () => { const cli = bargs('test-cli').command( 'run', handle(opt.options({}), () => {}), ); - const { exitCode, output, result } = await withCapturedStderr(() => - cli.parse([]), - ); + const { exitCode, output } = await withMockedExit(() => cli.parse([])); expect(exitCode, 'to equal', 1); expect(output, 'to contain', 'No command specified'); - expect(result.earlyExit, 'to be true'); }); }); diff --git a/test/parser-commands.test.ts b/test/parser-commands.test.ts index ce1ceda..cf2affd 100644 --- a/test/parser-commands.test.ts +++ b/test/parser-commands.test.ts @@ -11,59 +11,88 @@ import { bargs, handle } from '../src/bargs.js'; import { opt, pos } from '../src/opt.js'; /** - * Helper to capture stderr output and process.exitCode during tests. Returns - * the captured output, result, and exitCode. + * Custom error thrown when process.exit is mocked and called. + */ +class MockExitError extends Error { + readonly exitCode: number; + + constructor(exitCode: number) { + super(`process.exit(${exitCode}) was called`); + this.name = 'MockExitError'; + this.exitCode = exitCode; + } +} + +/** + * Helper to capture stderr output and mock process.exit during tests. Returns + * the captured output and exit code. * * @function */ -const withCapturedStderr = ( +const withMockedExit = ( fn: () => Promise | T, ): Promise<{ - exitCode: typeof process.exitCode; + exitCode: number; output: string; - result: T; + result?: T; }> => { - const originalExitCode = process.exitCode; const stderrWrites: string[] = []; const originalStderrWrite = process.stderr.write.bind(process.stderr); + const originalExit = process.exit; process.stderr.write = ((chunk: unknown) => { stderrWrites.push(String(chunk)); return true; }) as typeof process.stderr.write; + // Mock process.exit to throw instead of actually exiting + process.exit = ((code?: number) => { + throw new MockExitError(code ?? 0); + }) as typeof process.exit; + /** * @function */ - const createResult = (result: T) => ({ - exitCode: process.exitCode, - output: stderrWrites.join(''), - result, - }); + const cleanup = () => { + process.stderr.write = originalStderrWrite; + process.exit = originalExit; + }; /** * @function */ - const cleanup = () => { - process.stderr.write = originalStderrWrite; - process.exitCode = originalExitCode; + const handleMockExit = (error: unknown) => { + if (error instanceof MockExitError) { + return { + exitCode: error.exitCode, + output: stderrWrites.join(''), + result: undefined, + }; + } + throw error; }; try { const maybePromise = fn(); if (maybePromise instanceof Promise) { - return maybePromise.then((result) => { - const capturedResult = createResult(result); - cleanup(); - return capturedResult; - }); + return maybePromise + .then((result) => ({ + exitCode: 0, + output: stderrWrites.join(''), + result, + })) + .catch(handleMockExit) + .finally(cleanup); } - const capturedResult = createResult(maybePromise); cleanup(); - return Promise.resolve(capturedResult); + return Promise.resolve({ + exitCode: 0, + output: stderrWrites.join(''), + result: maybePromise, + }); } catch (error) { cleanup(); - throw error; + return Promise.resolve(handleMockExit(error)); } }; @@ -153,7 +182,7 @@ describe('command parsing', () => { }); }); - it('handles unknown command by showing help and setting exitCode', async () => { + it('handles unknown command by showing help and exiting', async () => { const cli = bargs('test-cli') .command( 'add', @@ -164,11 +193,10 @@ describe('command parsing', () => { handle(opt.options({}), () => {}), ); - const { exitCode, output, result } = await withCapturedStderr(() => + const { exitCode, output } = await withMockedExit(() => cli.parseAsync(['unknown']), ); - expect(result.earlyExit, 'to be true'); expect(exitCode, 'to equal', 1); expect(output, 'to contain', 'Unknown command: unknown'); }); From 896f965d208fd81f26d80a54727bd2e991063171 Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Tue, 3 Feb 2026 20:18:58 -0800 Subject: [PATCH 5/5] refactor: extract shared test helper for mocking process.exit Address review feedback: - Extract `MockExitError` and `withMockedExit` to `test/helpers/mock-exit.ts` - Use `Promise.resolve().then(fn)` to handle any thenable, not just Promise - Consolidate duplicate JSDoc blocks for `handleHelpError` - Update README example to use sentinel error and `finally` block --- README.md | 18 ++++++-- src/bargs.ts | 10 +++-- test/bargs.test.ts | 87 +----------------------------------- test/helpers/mock-exit.ts | 81 +++++++++++++++++++++++++++++++++ test/parser-commands.test.ts | 87 +----------------------------------- 5 files changed, 103 insertions(+), 180 deletions(-) create mode 100644 test/helpers/mock-exit.ts diff --git a/README.md b/README.md index 2e7e855..fb09351 100644 --- a/README.md +++ b/README.md @@ -723,21 +723,31 @@ See `examples/completion.ts` for a complete example. For testing, you can mock `process.exit` to capture the exit code: ```typescript +// Sentinel error to distinguish process.exit from other errors +class ProcessExitError extends Error { + constructor(public code: number) { + super(`process.exit(${code})`); + } +} + const originalExit = process.exit; let exitCode: number | undefined; process.exit = ((code?: number) => { exitCode = code ?? 0; - throw new Error('process.exit called'); + throw new ProcessExitError(exitCode); }) as typeof process.exit; try { cli.parse(['--help']); -} catch { - // process.exit was called +} catch (err) { + if (!(err instanceof ProcessExitError)) { + throw err; // Re-throw unexpected errors + } +} finally { + process.exit = originalExit; // Always restore } -process.exit = originalExit; console.log(exitCode); // 0 ``` diff --git a/src/bargs.ts b/src/bargs.ts index 45f5908..de17b5e 100644 --- a/src/bargs.ts +++ b/src/bargs.ts @@ -1106,11 +1106,13 @@ const generateHelpNew = (state: InternalCliState, theme: Theme): string => { * * @function */ + /** - * Handles HelpError by displaying the error message, showing help, and - * terminating the process with exit code 1. This is standard CLI behavior - - * when a user provides an unknown command or forgets to specify a required - * command, they see help and the process exits. + * Handles a HelpError by displaying the error message and help text to stderr, + * then terminating the process with exit code 1. This is standard CLI behavior: + * when a user provides an unknown command or omits a required command, they see + * help and the process exits instead of allowing the error to bubble to a + * global exception handler. This function does not return. * * @function */ diff --git a/test/bargs.test.ts b/test/bargs.test.ts index 4af1f1c..0fcbb36 100644 --- a/test/bargs.test.ts +++ b/test/bargs.test.ts @@ -8,92 +8,7 @@ import type { StringOption } from '../src/types.js'; import { bargs, handle, map, merge } from '../src/bargs.js'; import { opt, pos } from '../src/opt.js'; - -/** - * Custom error thrown when process.exit is mocked and called. - */ -class MockExitError extends Error { - readonly exitCode: number; - - constructor(exitCode: number) { - super(`process.exit(${exitCode}) was called`); - this.name = 'MockExitError'; - this.exitCode = exitCode; - } -} - -/** - * Helper to capture stderr output and mock process.exit during tests. Returns - * the captured output and exit code. - * - * @function - */ -const withMockedExit = ( - fn: () => Promise | T, -): Promise<{ - exitCode: number; - output: string; - result?: T; -}> => { - const stderrWrites: string[] = []; - const originalStderrWrite = process.stderr.write.bind(process.stderr); - const originalExit = process.exit; - - process.stderr.write = ((chunk: unknown) => { - stderrWrites.push(String(chunk)); - return true; - }) as typeof process.stderr.write; - - // Mock process.exit to throw instead of actually exiting - process.exit = ((code?: number) => { - throw new MockExitError(code ?? 0); - }) as typeof process.exit; - - /** - * @function - */ - const cleanup = () => { - process.stderr.write = originalStderrWrite; - process.exit = originalExit; - }; - - /** - * @function - */ - const handleMockExit = (error: unknown) => { - if (error instanceof MockExitError) { - return { - exitCode: error.exitCode, - output: stderrWrites.join(''), - result: undefined, - }; - } - throw error; - }; - - try { - const maybePromise = fn(); - if (maybePromise instanceof Promise) { - return maybePromise - .then((result) => ({ - exitCode: 0, - output: stderrWrites.join(''), - result, - })) - .catch(handleMockExit) - .finally(cleanup); - } - cleanup(); - return Promise.resolve({ - exitCode: 0, - output: stderrWrites.join(''), - result: maybePromise, - }); - } catch (error) { - cleanup(); - return Promise.resolve(handleMockExit(error)); - } -}; +import { withMockedExit } from './helpers/mock-exit.js'; describe('bargs()', () => { it('creates a CLI builder', () => { diff --git a/test/helpers/mock-exit.ts b/test/helpers/mock-exit.ts new file mode 100644 index 0000000..5d53af5 --- /dev/null +++ b/test/helpers/mock-exit.ts @@ -0,0 +1,81 @@ +/** + * Test utilities for mocking process.exit and capturing stderr. + */ + +/** + * Custom error thrown when process.exit is mocked and called. + */ +export class MockExitError extends Error { + readonly exitCode: number; + + constructor(exitCode: number) { + super(`process.exit(${exitCode}) was called`); + this.name = 'MockExitError'; + this.exitCode = exitCode; + } +} + +/** + * Helper to capture stderr output and mock process.exit during tests. Returns + * the captured output and exit code. + * + * Uses `Promise.resolve(fn())` to handle both sync functions and any thenable, + * ensuring cleanup always runs via `.finally()`. + * + * @function + */ +export const withMockedExit = ( + fn: () => Promise | T, +): Promise<{ + exitCode: number; + output: string; + result?: T; +}> => { + const stderrWrites: string[] = []; + const originalStderrWrite = process.stderr.write.bind(process.stderr); + // eslint-disable-next-line @typescript-eslint/unbound-method -- we restore it in cleanup + const originalExit = process.exit; + + process.stderr.write = ((chunk: unknown) => { + stderrWrites.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + + // Mock process.exit to throw instead of actually exiting + process.exit = ((code?: number) => { + throw new MockExitError(code ?? 0); + }) as typeof process.exit; + + /** + * @function + */ + const cleanup = () => { + process.stderr.write = originalStderrWrite; + process.exit = originalExit; + }; + + /** + * @function + */ + const handleMockExit = (error: unknown) => { + if (error instanceof MockExitError) { + return { + exitCode: error.exitCode, + output: stderrWrites.join(''), + result: undefined, + }; + } + throw error; + }; + + // Use Promise.resolve() to normalize sync/async and handle any thenable + return Promise.resolve() + .then(fn) + .then((result) => ({ + exitCode: 0, + output: stderrWrites.join(''), + result, + })) + .catch(handleMockExit) + .finally(cleanup); +}; diff --git a/test/parser-commands.test.ts b/test/parser-commands.test.ts index cf2affd..15bf184 100644 --- a/test/parser-commands.test.ts +++ b/test/parser-commands.test.ts @@ -9,92 +9,7 @@ import { describe, it } from 'node:test'; import { bargs, handle } from '../src/bargs.js'; import { opt, pos } from '../src/opt.js'; - -/** - * Custom error thrown when process.exit is mocked and called. - */ -class MockExitError extends Error { - readonly exitCode: number; - - constructor(exitCode: number) { - super(`process.exit(${exitCode}) was called`); - this.name = 'MockExitError'; - this.exitCode = exitCode; - } -} - -/** - * Helper to capture stderr output and mock process.exit during tests. Returns - * the captured output and exit code. - * - * @function - */ -const withMockedExit = ( - fn: () => Promise | T, -): Promise<{ - exitCode: number; - output: string; - result?: T; -}> => { - const stderrWrites: string[] = []; - const originalStderrWrite = process.stderr.write.bind(process.stderr); - const originalExit = process.exit; - - process.stderr.write = ((chunk: unknown) => { - stderrWrites.push(String(chunk)); - return true; - }) as typeof process.stderr.write; - - // Mock process.exit to throw instead of actually exiting - process.exit = ((code?: number) => { - throw new MockExitError(code ?? 0); - }) as typeof process.exit; - - /** - * @function - */ - const cleanup = () => { - process.stderr.write = originalStderrWrite; - process.exit = originalExit; - }; - - /** - * @function - */ - const handleMockExit = (error: unknown) => { - if (error instanceof MockExitError) { - return { - exitCode: error.exitCode, - output: stderrWrites.join(''), - result: undefined, - }; - } - throw error; - }; - - try { - const maybePromise = fn(); - if (maybePromise instanceof Promise) { - return maybePromise - .then((result) => ({ - exitCode: 0, - output: stderrWrites.join(''), - result, - })) - .catch(handleMockExit) - .finally(cleanup); - } - cleanup(); - return Promise.resolve({ - exitCode: 0, - output: stderrWrites.join(''), - result: maybePromise, - }); - } catch (error) { - cleanup(); - return Promise.resolve(handleMockExit(error)); - } -}; +import { withMockedExit } from './helpers/mock-exit.js'; describe('command parsing', () => { it('parses a command with options', async () => {