Skip to content

Commit eee2451

Browse files
authored
fix HelpError handling (#36)
* 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 * 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. * 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. * 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. * 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
1 parent 7b6d197 commit eee2451

File tree

6 files changed

+323
-79
lines changed

6 files changed

+323
-79
lines changed

README.md

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -707,17 +707,56 @@ See `examples/completion.ts` for a complete example.
707707

708708
## Advanced Usage
709709

710+
### Process Termination
711+
712+
**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:
713+
714+
| Scenario | Exit Code | Output |
715+
| ------------------------- | --------- | --------------------------------- |
716+
| `--help` / `-h` | 0 | Help text to stdout |
717+
| `--version` | 0 | Version string to stdout |
718+
| `--completion-script` | 0 | Shell completion script to stdout |
719+
| Unknown command | 1 | Error + help text to stderr |
720+
| Missing required command | 1 | Error + help text to stderr |
721+
| `--get-bargs-completions` | 0 | Completion candidates to stdout |
722+
723+
For testing, you can mock `process.exit` to capture the exit code:
724+
725+
```typescript
726+
// Sentinel error to distinguish process.exit from other errors
727+
class ProcessExitError extends Error {
728+
constructor(public code: number) {
729+
super(`process.exit(${code})`);
730+
}
731+
}
732+
733+
const originalExit = process.exit;
734+
let exitCode: number | undefined;
735+
736+
process.exit = ((code?: number) => {
737+
exitCode = code ?? 0;
738+
throw new ProcessExitError(exitCode);
739+
}) as typeof process.exit;
740+
741+
try {
742+
cli.parse(['--help']);
743+
} catch (err) {
744+
if (!(err instanceof ProcessExitError)) {
745+
throw err; // Re-throw unexpected errors
746+
}
747+
} finally {
748+
process.exit = originalExit; // Always restore
749+
}
750+
751+
console.log(exitCode); // 0
752+
```
753+
710754
### Error Handling
711755

712-
**bargs** exports some `Error` subclasses:
756+
**bargs** exports some `Error` subclasses for errors that _don't_ cause automatic process termination:
713757

714758
```typescript
715-
import {
716-
bargs,
717-
BargsError,
718-
HelpError,
719-
ValidationError,
720-
} from '@boneskull/bargs';
759+
import { bargs, BargsError, ValidationError } from '@boneskull/bargs';
721760

722761
try {
723762
await bargs('my-cli').parseAsync();
@@ -726,10 +765,6 @@ try {
726765
// Config validation failed (e.g., invalid schema)
727766
// i.e., "you screwed up"
728767
console.error(`Config error at "${error.path}": ${error.message}`);
729-
} else if (error instanceof HelpError) {
730-
// Likely invalid options, command or positionals;
731-
// re-throw to trigger help display
732-
throw error;
733768
} else if (error instanceof BargsError) {
734769
// General bargs error
735770
console.error(error.message);

src/bargs.ts

Lines changed: 117 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -557,6 +557,7 @@ const createCliBuilder = <V, P extends readonly unknown[]>(
557557
},
558558

559559
// Internal method for nested command support - not part of public API
560+
// Handles HelpError here so nested builders can render their own help
560561
__parseWithParentGlobals(
561562
args: string[],
562563
parentGlobals: ParseResult<unknown, readonly unknown[]>,
@@ -565,9 +566,23 @@ const createCliBuilder = <V, P extends readonly unknown[]>(
565566
| (ParseResult<V, P> & { command?: string })
566567
| Promise<ParseResult<V, P> & { command?: string }> {
567568
const stateWithGlobals = { ...state, parentGlobals };
568-
return parseCore(stateWithGlobals, args, allowAsync) as
569-
| (ParseResult<V, P> & { command?: string })
570-
| Promise<ParseResult<V, P> & { command?: string }>;
569+
try {
570+
const result = parseCore(stateWithGlobals, args, allowAsync);
571+
if (isThenable(result)) {
572+
return result.catch((error: unknown) => {
573+
if (error instanceof HelpError) {
574+
handleHelpError(error, stateWithGlobals); // exits process
575+
}
576+
throw error;
577+
}) as Promise<ParseResult<V, P> & { command?: string }>;
578+
}
579+
return result as ParseResult<V, P> & { command?: string };
580+
} catch (error) {
581+
if (error instanceof HelpError) {
582+
handleHelpError(error, stateWithGlobals); // exits process
583+
}
584+
throw error;
585+
}
571586
},
572587

573588
// Overloaded command(): accepts (name, factory, options?),
@@ -761,21 +776,35 @@ const createCliBuilder = <V, P extends readonly unknown[]>(
761776
parse(
762777
args: string[] = process.argv.slice(2),
763778
): ParseResult<V, P> & { command?: string } {
764-
const result = parseCore(state, args, false);
765-
if (isThenable(result)) {
766-
throw new BargsError(
767-
'Async transform or handler detected. Use parseAsync() instead of parse().',
768-
);
779+
try {
780+
const result = parseCore(state, args, false);
781+
if (isThenable(result)) {
782+
throw new BargsError(
783+
'Async transform or handler detected. Use parseAsync() instead of parse().',
784+
);
785+
}
786+
return result as ParseResult<V, P> & { command?: string };
787+
} catch (error) {
788+
if (error instanceof HelpError) {
789+
handleHelpError(error, state); // exits process, never returns
790+
}
791+
throw error;
769792
}
770-
return result as ParseResult<V, P> & { command?: string };
771793
},
772794

773795
async parseAsync(
774796
args: string[] = process.argv.slice(2),
775797
): Promise<ParseResult<V, P> & { command?: string }> {
776-
return parseCore(state, args, true) as Promise<
777-
ParseResult<V, P> & { command?: string }
778-
>;
798+
try {
799+
return (await parseCore(state, args, true)) as ParseResult<V, P> & {
800+
command?: string;
801+
};
802+
} catch (error) {
803+
if (error instanceof HelpError) {
804+
handleHelpError(error, state); // exits process, never returns
805+
}
806+
throw error;
807+
}
779808
},
780809
};
781810

@@ -799,7 +828,20 @@ const parseCore = (
799828
> => {
800829
const { aliasMap, commands, options, theme } = state;
801830

802-
/* c8 ignore start -- help/version output calls process.exit() */
831+
/**
832+
* Terminates the process for early-exit scenarios (--help, --version,
833+
* --completion-script). This is standard CLI behavior - users expect these
834+
* flags to print output and exit immediately.
835+
*
836+
* @remarks
837+
* The return statement exists only to satisfy TypeScript. In practice,
838+
* `process.exit()` terminates the process and this function never returns.
839+
* @function
840+
*/
841+
const exitProcess = (exitCode: number): never => {
842+
process.exit(exitCode);
843+
};
844+
803845
// Handle --help
804846
if (args.includes('--help') || args.includes('-h')) {
805847
// Check for command-specific help
@@ -832,27 +874,25 @@ const parseCore = (
832874
values: {},
833875
};
834876
// This will trigger the nested builder's help handling
835-
// and call process.exit(0) if --help is handled
836-
void internalNestedBuilder.__parseWithParentGlobals(
877+
return internalNestedBuilder.__parseWithParentGlobals(
837878
nestedArgs,
838879
emptyGlobals,
839880
true,
840881
);
841882
}
842883

843884
// If no more args, show help for this nested command group
844-
showNestedCommandHelp(state, commandName);
845-
// showNestedCommandHelp calls process.exit(0)
885+
return showNestedCommandHelp(state, commandName);
846886
}
847887

848888
// Regular command help
849889
console.log(generateCommandHelpNew(state, commandName, theme));
850-
process.exit(0);
890+
return exitProcess(0);
851891
}
852892
}
853893

854894
console.log(generateHelpNew(state, theme));
855-
process.exit(0);
895+
return exitProcess(0);
856896
}
857897

858898
// Handle --version
@@ -863,7 +903,7 @@ const parseCore = (
863903
} else {
864904
console.log('Version information not available');
865905
}
866-
process.exit(0);
906+
return exitProcess(0);
867907
}
868908

869909
// Handle shell completion (when enabled)
@@ -876,15 +916,15 @@ const parseCore = (
876916
console.error(
877917
'Error: --completion-script requires a shell argument (bash, zsh, or fish)',
878918
);
879-
process.exit(1);
919+
return exitProcess(1);
880920
}
881921
try {
882922
const shell = validateShell(shellArg);
883923
console.log(generateCompletionScript(state.name, shell));
884-
process.exit(0);
924+
return exitProcess(0);
885925
} catch (err) {
886926
console.error(`Error: ${(err as Error).message}`);
887-
process.exit(1);
927+
return exitProcess(1);
888928
}
889929
}
890930

@@ -894,7 +934,7 @@ const parseCore = (
894934
const shellArg = args[getCompletionsIndex + 1];
895935
if (!shellArg) {
896936
// No shell specified, output nothing
897-
process.exit(0);
937+
return exitProcess(0);
898938
}
899939
try {
900940
const shell = validateShell(shellArg);
@@ -904,14 +944,13 @@ const parseCore = (
904944
if (candidates.length > 0) {
905945
console.log(candidates.join('\n'));
906946
}
907-
process.exit(0);
947+
return exitProcess(0);
908948
} catch {
909949
// Invalid shell, output nothing
910-
process.exit(0);
950+
return exitProcess(0);
911951
}
912952
}
913953
}
914-
/* c8 ignore stop */
915954

916955
// If we have commands, dispatch to the appropriate one
917956
if (commands.size > 0) {
@@ -924,21 +963,22 @@ const parseCore = (
924963

925964
/**
926965
* Show help for a nested command group by delegating to the nested builder.
966+
* This function always terminates the process (either via the nested builder's
967+
* help handling or via error exit).
927968
*
928969
* @function
929970
*/
930-
/* c8 ignore start -- only called from help paths that call process.exit() */
931971
const showNestedCommandHelp = (
932972
state: InternalCliState,
933973
commandName: string,
934-
): void => {
974+
): never => {
935975
const commandEntry = state.commands.get(commandName);
936976
if (!commandEntry || commandEntry.type !== 'nested') {
937-
console.log(`Unknown command group: ${commandName}`);
977+
console.error(`Unknown command group: ${commandName}`);
938978
process.exit(1);
939979
}
940980

941-
// Delegate to nested builder with --help
981+
// Delegate to nested builder with --help - this will exit the process
942982
const internalNestedBuilder = commandEntry.builder as InternalCliBuilder<
943983
unknown,
944984
readonly unknown[]
@@ -948,21 +988,26 @@ const showNestedCommandHelp = (
948988
values: {},
949989
};
950990

951-
// This will show the nested builder's help and call process.exit(0)
991+
// This will show the nested builder's help and exit the process.
992+
// The void operator explicitly marks this as intentionally unhandled since
993+
// process.exit() inside will terminate before the promise resolves.
952994
void internalNestedBuilder.__parseWithParentGlobals(
953995
['--help'],
954996
emptyGlobals,
955997
true,
956998
);
999+
1000+
// This should never be reached since help handling calls process.exit()
1001+
// but TypeScript needs it for the never return type
1002+
process.exit(0);
9571003
};
958-
/* c8 ignore stop */
9591004

9601005
/**
9611006
* Generate command-specific help.
9621007
*
9631008
* @function
9641009
*/
965-
/* c8 ignore start -- only called from help paths that call process.exit() */
1010+
/* c8 ignore start -- only called from help paths */
9661011
const generateCommandHelpNew = (
9671012
state: InternalCliState,
9681013
commandName: string,
@@ -973,11 +1018,10 @@ const generateCommandHelpNew = (
9731018
return `Unknown command: ${commandName}`;
9741019
}
9751020

976-
// Handle nested commands - this shouldn't be reached as nested commands
977-
// delegate to showNestedCommandHelp in parseCore, but handle it gracefully
1021+
// Nested commands are handled by showNestedCommandHelp in parseCore,
1022+
// so this function should never be called for nested commands
9781023
if (commandEntry.type === 'nested') {
979-
showNestedCommandHelp(state, commandName);
980-
return ''; // Never reached, showNestedCommandHelp calls process.exit
1024+
return `${commandName} is a command group. Use --help after a subcommand.`;
9811025
}
9821026

9831027
// Regular command help
@@ -1004,7 +1048,7 @@ const generateCommandHelpNew = (
10041048
*
10051049
* @function
10061050
*/
1007-
/* c8 ignore start -- only called from help paths that call process.exit() */
1051+
/* c8 ignore start -- only called from help paths */
10081052
const generateHelpNew = (state: InternalCliState, theme: Theme): string => {
10091053
// Build options schema, adding built-in options
10101054
let options = state.globalParser?.__optionsSchema;
@@ -1053,6 +1097,40 @@ const generateHelpNew = (state: InternalCliState, theme: Theme): string => {
10531097
};
10541098
/* c8 ignore stop */
10551099

1100+
/**
1101+
* Handle a HelpError by displaying the error message and help text to stderr,
1102+
* setting the exit code, and returning a result indicating help was shown.
1103+
*
1104+
* This prevents HelpError from bubbling up to global exception handlers while
1105+
* still providing useful feedback to the user.
1106+
*
1107+
* @function
1108+
*/
1109+
1110+
/**
1111+
* Handles a HelpError by displaying the error message and help text to stderr,
1112+
* then terminating the process with exit code 1. This is standard CLI behavior:
1113+
* when a user provides an unknown command or omits a required command, they see
1114+
* help and the process exits instead of allowing the error to bubble to a
1115+
* global exception handler. This function does not return.
1116+
*
1117+
* @function
1118+
*/
1119+
const handleHelpError = (error: HelpError, state: InternalCliState): never => {
1120+
const { theme } = state;
1121+
1122+
// Write error message to stderr
1123+
process.stderr.write(`Error: ${error.message}\n\n`);
1124+
1125+
// Generate and write help text to stderr
1126+
const helpText = generateHelpNew(state, theme);
1127+
process.stderr.write(helpText);
1128+
process.stderr.write('\n');
1129+
1130+
// Terminate with error exit code
1131+
process.exit(1);
1132+
};
1133+
10561134
/**
10571135
* Check if something is a Parser (has __brand: 'Parser'). Parsers can be either
10581136
* objects or functions (CallableParser).

0 commit comments

Comments
 (0)