diff --git a/README.md b/README.md index 24335f4..fb09351 100644 --- a/README.md +++ b/README.md @@ -707,17 +707,56 @@ 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 +// 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 ProcessExitError(exitCode); +}) as typeof process.exit; + +try { + cli.parse(['--help']); +} catch (err) { + if (!(err instanceof ProcessExitError)) { + throw err; // Re-throw unexpected errors + } +} finally { + process.exit = originalExit; // Always restore +} + +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 +765,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 3227ba8..de17b5e 100644 --- a/src/bargs.ts +++ b/src/bargs.ts @@ -557,6 +557,7 @@ 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, @@ -565,9 +566,23 @@ const createCliBuilder = ( | (ParseResult & { command?: string }) | Promise & { command?: string }> { 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) { + handleHelpError(error, stateWithGlobals); // exits process + } + throw error; + }) as Promise & { command?: string }>; + } + return result as ParseResult & { command?: string }; + } catch (error) { + if (error instanceof HelpError) { + handleHelpError(error, stateWithGlobals); // exits process + } + throw error; + } }, // Overloaded command(): accepts (name, factory, options?), @@ -761,21 +776,35 @@ 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().', - ); + 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) { + handleHelpError(error, state); // exits process, never returns + } + 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 } - >; + try { + return (await parseCore(state, args, true)) as ParseResult & { + command?: string; + }; + } catch (error) { + if (error instanceof HelpError) { + handleHelpError(error, state); // exits process, never returns + } + throw error; + } }, }; @@ -799,7 +828,20 @@ const parseCore = ( > => { const { aliasMap, commands, options, theme } = state; - /* c8 ignore start -- help/version output calls process.exit() */ + /** + * 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 exitProcess = (exitCode: number): never => { + process.exit(exitCode); + }; + // Handle --help if (args.includes('--help') || args.includes('-h')) { // Check for command-specific help @@ -832,8 +874,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, @@ -841,18 +882,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 exitProcess(0); } } console.log(generateHelpNew(state, theme)); - process.exit(0); + return exitProcess(0); } // Handle --version @@ -863,7 +903,7 @@ const parseCore = ( } else { console.log('Version information not available'); } - process.exit(0); + return exitProcess(0); } // Handle shell completion (when enabled) @@ -876,15 +916,15 @@ const parseCore = ( console.error( 'Error: --completion-script requires a shell argument (bash, zsh, or fish)', ); - process.exit(1); + return exitProcess(1); } try { const shell = validateShell(shellArg); console.log(generateCompletionScript(state.name, shell)); - process.exit(0); + return exitProcess(0); } catch (err) { console.error(`Error: ${(err as Error).message}`); - process.exit(1); + return exitProcess(1); } } @@ -894,7 +934,7 @@ const parseCore = ( const shellArg = args[getCompletionsIndex + 1]; if (!shellArg) { // No shell specified, output nothing - process.exit(0); + return exitProcess(0); } try { const shell = validateShell(shellArg); @@ -904,14 +944,13 @@ const parseCore = ( if (candidates.length > 0) { console.log(candidates.join('\n')); } - process.exit(0); + return exitProcess(0); } catch { // Invalid shell, output nothing - process.exit(0); + return exitProcess(0); } } } - /* c8 ignore stop */ // If we have commands, dispatch to the appropriate one if (commands.size > 0) { @@ -924,21 +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 */ -/* c8 ignore start -- only called from help paths that call process.exit() */ const showNestedCommandHelp = ( state: InternalCliState, commandName: string, -): void => { +): never => { const commandEntry = state.commands.get(commandName); if (!commandEntry || commandEntry.type !== 'nested') { - console.log(`Unknown command group: ${commandName}`); + console.error(`Unknown command group: ${commandName}`); 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[] @@ -948,21 +988,26 @@ const showNestedCommandHelp = ( values: {}, }; - // This will show the nested builder's help and call process.exit(0) + // 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); }; -/* 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, @@ -973,11 +1018,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 @@ -1004,7 +1048,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; @@ -1053,6 +1097,40 @@ 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 + */ + +/** + * 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 + */ +const handleHelpError = (error: HelpError, state: InternalCliState): never => { + 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'); + + // Terminate with error exit code + process.exit(1); +}; + /** * 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..974e630 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. + * + * @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 }; + parse(args?: string[]): ParseResult & { + command?: string; + }; /** * Parse arguments asynchronously and run handlers. * * Supports async transforms and handlers. + * + * @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 } + parseAsync(args?: string[]): Promise< + ParseResult & { + command?: string; + } >; } diff --git a/test/bargs.test.ts b/test/bargs.test.ts index 49b50d9..0fcbb36 100644 --- a/test/bargs.test.ts +++ b/test/bargs.test.ts @@ -1,13 +1,14 @@ /** * 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'; import { bargs, handle, map, merge } from '../src/bargs.js'; import { opt, pos } from '../src/opt.js'; +import { withMockedExit } from './helpers/mock-exit.js'; describe('bargs()', () => { it('creates a CLI builder', () => { @@ -189,17 +190,18 @@ describe('.parseAsync()', () => { expect(handlerCalled, 'to be', true); }); - it('throws on unknown command', async () => { + it('handles unknown command by showing help and exiting', async () => { const cli = bargs('test-cli').command( 'greet', handle(opt.options({}), () => {}), ); - await expectAsync( + const { exitCode, output } = await withMockedExit(() => cli.parseAsync(['unknown']), - 'to reject with error satisfying', - /Unknown command/, ); + + expect(exitCode, 'to equal', 1); + expect(output, 'to contain', 'Unknown command: unknown'); }); it('returns parsed result with command name', async () => { @@ -786,23 +788,59 @@ describe('merge() edge cases', () => { }); describe('error paths', () => { - it('throws HelpError when no command specified and no default', async () => { - const cli = bargs('test-cli') - .command( + describe('HelpError handling', () => { + it('handles no command by displaying help and exiting', async () => { + const cli = bargs('test-cli') + .command( + 'run', + handle(opt.options({}), () => {}), + ) + .command( + 'build', + handle(opt.options({}), () => {}), + ); + + const { exitCode, output } = await withMockedExit(() => + cli.parseAsync([]), + ); + + // Verify process exits with code 1 + expect(exitCode, 'to equal', 1); + + // Verify error message was shown + expect(output, 'to contain', 'No command specified'); + + // Verify help was displayed + expect(output, 'to contain', 'USAGE'); + expect(output, 'to contain', 'COMMANDS'); + }); + + it('handles unknown command by displaying help and exiting', async () => { + const cli = bargs('test-cli').command( 'run', handle(opt.options({}), () => {}), - ) - .command( - 'build', + ); + + 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'); + }); + + it('handles HelpError in sync parse() as well', async () => { + const cli = bargs('test-cli').command( + 'run', handle(opt.options({}), () => {}), ); - // No defaultCommand set - await expectAsync( - cli.parseAsync([]), - 'to reject with error satisfying', - /No command specified/, - ); + const { exitCode, output } = await withMockedExit(() => cli.parse([])); + + expect(exitCode, 'to equal', 1); + expect(output, 'to contain', 'No command specified'); + }); }); it('handles async global transform in nested commands', async () => { 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 9af6bd1..15bf184 100644 --- a/test/parser-commands.test.ts +++ b/test/parser-commands.test.ts @@ -4,11 +4,12 @@ * 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'; import { opt, pos } from '../src/opt.js'; +import { withMockedExit } from './helpers/mock-exit.js'; describe('command parsing', () => { it('parses a command with options', async () => { @@ -96,7 +97,7 @@ describe('command parsing', () => { }); }); - it('throws on unknown command', async () => { + it('handles unknown command by showing help and exiting', async () => { const cli = bargs('test-cli') .command( 'add', @@ -107,11 +108,12 @@ describe('command parsing', () => { handle(opt.options({}), () => {}), ); - await expectAsync( + const { exitCode, output } = await withMockedExit(() => cli.parseAsync(['unknown']), - 'to reject with error satisfying', - /Unknown command: unknown/, ); + + expect(exitCode, 'to equal', 1); + expect(output, 'to contain', 'Unknown command: unknown'); }); it('merges global and command options', async () => {