Skip to content

Commit 1f2f4db

Browse files
committed
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.
1 parent 33249a7 commit 1f2f4db

File tree

5 files changed

+207
-164
lines changed

5 files changed

+207
-164
lines changed

README.md

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -707,17 +707,46 @@ 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+
const originalExit = process.exit;
727+
let exitCode: number | undefined;
728+
729+
process.exit = ((code?: number) => {
730+
exitCode = code ?? 0;
731+
throw new Error('process.exit called');
732+
}) as typeof process.exit;
733+
734+
try {
735+
cli.parse(['--help']);
736+
} catch {
737+
// process.exit was called
738+
}
739+
740+
process.exit = originalExit;
741+
console.log(exitCode); // 0
742+
```
743+
710744
### Error Handling
711745

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

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

722751
try {
723752
await bargs('my-cli').parseAsync();
@@ -726,10 +755,6 @@ try {
726755
// Config validation failed (e.g., invalid schema)
727756
// i.e., "you screwed up"
728757
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;
733758
} else if (error instanceof BargsError) {
734759
// General bargs error
735760
console.error(error.message);

src/bargs.ts

Lines changed: 50 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -563,37 +563,23 @@ const createCliBuilder = <V, P extends readonly unknown[]>(
563563
parentGlobals: ParseResult<unknown, readonly unknown[]>,
564564
allowAsync: boolean,
565565
):
566-
| (ParseResult<V, P> & { command?: string; earlyExit?: boolean })
567-
| Promise<ParseResult<V, P> & { command?: string; earlyExit?: boolean }> {
566+
| (ParseResult<V, P> & { command?: string })
567+
| Promise<ParseResult<V, P> & { command?: string }> {
568568
const stateWithGlobals = { ...state, parentGlobals };
569569
try {
570570
const result = parseCore(stateWithGlobals, args, allowAsync);
571571
if (isThenable(result)) {
572572
return result.catch((error: unknown) => {
573573
if (error instanceof HelpError) {
574-
return handleHelpError(error, stateWithGlobals) as ParseResult<
575-
V,
576-
P
577-
> & {
578-
command?: string;
579-
earlyExit: true;
580-
};
574+
handleHelpError(error, stateWithGlobals); // exits process
581575
}
582576
throw error;
583-
}) as Promise<
584-
ParseResult<V, P> & { command?: string; earlyExit?: boolean }
585-
>;
577+
}) as Promise<ParseResult<V, P> & { command?: string }>;
586578
}
587579
return result as ParseResult<V, P> & { command?: string };
588580
} catch (error) {
589581
if (error instanceof HelpError) {
590-
return handleHelpError(error, stateWithGlobals) as ParseResult<
591-
V,
592-
P
593-
> & {
594-
command?: string;
595-
earlyExit: true;
596-
};
582+
handleHelpError(error, stateWithGlobals); // exits process
597583
}
598584
throw error;
599585
}
@@ -789,7 +775,7 @@ const createCliBuilder = <V, P extends readonly unknown[]>(
789775

790776
parse(
791777
args: string[] = process.argv.slice(2),
792-
): ParseResult<V, P> & { command?: string; earlyExit?: boolean } {
778+
): ParseResult<V, P> & { command?: string } {
793779
try {
794780
const result = parseCore(state, args, false);
795781
if (isThenable(result)) {
@@ -800,28 +786,22 @@ const createCliBuilder = <V, P extends readonly unknown[]>(
800786
return result as ParseResult<V, P> & { command?: string };
801787
} catch (error) {
802788
if (error instanceof HelpError) {
803-
return handleHelpError(error, state) as ParseResult<V, P> & {
804-
command?: string;
805-
earlyExit: true;
806-
};
789+
handleHelpError(error, state); // exits process, never returns
807790
}
808791
throw error;
809792
}
810793
},
811794

812795
async parseAsync(
813796
args: string[] = process.argv.slice(2),
814-
): Promise<ParseResult<V, P> & { command?: string; earlyExit?: boolean }> {
797+
): Promise<ParseResult<V, P> & { command?: string }> {
815798
try {
816799
return (await parseCore(state, args, true)) as ParseResult<V, P> & {
817800
command?: string;
818801
};
819802
} catch (error) {
820803
if (error instanceof HelpError) {
821-
return handleHelpError(error, state) as ParseResult<V, P> & {
822-
command?: string;
823-
earlyExit: true;
824-
};
804+
handleHelpError(error, state); // exits process, never returns
825805
}
826806
throw error;
827807
}
@@ -849,19 +829,17 @@ const parseCore = (
849829
const { aliasMap, commands, options, theme } = state;
850830

851831
/**
852-
* Helper to create an early-exit result (for help, version, completions).
853-
* Sets process.exitCode and returns a result with earlyExit: true.
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.
854835
*
836+
* @remarks
837+
* The return statement exists only to satisfy TypeScript. In practice,
838+
* `process.exit()` terminates the process and this function never returns.
855839
* @function
856840
*/
857-
const createEarlyExitResult = (
858-
exitCode: number,
859-
): ParseResult<unknown, readonly unknown[]> & {
860-
command?: string;
861-
earlyExit: true;
862-
} => {
863-
process.exitCode = exitCode;
864-
return { command: undefined, earlyExit: true, positionals: [], values: {} };
841+
const exitProcess = (exitCode: number): never => {
842+
process.exit(exitCode);
865843
};
866844

867845
// Handle --help
@@ -909,12 +887,12 @@ const parseCore = (
909887

910888
// Regular command help
911889
console.log(generateCommandHelpNew(state, commandName, theme));
912-
return createEarlyExitResult(0);
890+
return exitProcess(0);
913891
}
914892
}
915893

916894
console.log(generateHelpNew(state, theme));
917-
return createEarlyExitResult(0);
895+
return exitProcess(0);
918896
}
919897

920898
// Handle --version
@@ -925,7 +903,7 @@ const parseCore = (
925903
} else {
926904
console.log('Version information not available');
927905
}
928-
return createEarlyExitResult(0);
906+
return exitProcess(0);
929907
}
930908

931909
// Handle shell completion (when enabled)
@@ -938,15 +916,15 @@ const parseCore = (
938916
console.error(
939917
'Error: --completion-script requires a shell argument (bash, zsh, or fish)',
940918
);
941-
return createEarlyExitResult(1);
919+
return exitProcess(1);
942920
}
943921
try {
944922
const shell = validateShell(shellArg);
945923
console.log(generateCompletionScript(state.name, shell));
946-
return createEarlyExitResult(0);
924+
return exitProcess(0);
947925
} catch (err) {
948926
console.error(`Error: ${(err as Error).message}`);
949-
return createEarlyExitResult(1);
927+
return exitProcess(1);
950928
}
951929
}
952930

@@ -956,7 +934,7 @@ const parseCore = (
956934
const shellArg = args[getCompletionsIndex + 1];
957935
if (!shellArg) {
958936
// No shell specified, output nothing
959-
return createEarlyExitResult(0);
937+
return exitProcess(0);
960938
}
961939
try {
962940
const shell = validateShell(shellArg);
@@ -966,10 +944,10 @@ const parseCore = (
966944
if (candidates.length > 0) {
967945
console.log(candidates.join('\n'));
968946
}
969-
return createEarlyExitResult(0);
947+
return exitProcess(0);
970948
} catch {
971949
// Invalid shell, output nothing
972-
return createEarlyExitResult(0);
950+
return exitProcess(0);
973951
}
974952
}
975953
}
@@ -985,31 +963,22 @@ const parseCore = (
985963

986964
/**
987965
* 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).
988968
*
989969
* @function
990970
*/
991971
const showNestedCommandHelp = (
992972
state: InternalCliState,
993973
commandName: string,
994-
):
995-
| (ParseResult<unknown, readonly unknown[]> & {
996-
command?: string;
997-
earlyExit?: boolean;
998-
})
999-
| Promise<
1000-
ParseResult<unknown, readonly unknown[]> & {
1001-
command?: string;
1002-
earlyExit?: boolean;
1003-
}
1004-
> => {
974+
): never => {
1005975
const commandEntry = state.commands.get(commandName);
1006976
if (!commandEntry || commandEntry.type !== 'nested') {
1007977
console.error(`Unknown command group: ${commandName}`);
1008-
process.exitCode = 1;
1009-
return { command: undefined, earlyExit: true, positionals: [], values: {} };
978+
process.exit(1);
1010979
}
1011980

1012-
// Delegate to nested builder with --help
981+
// Delegate to nested builder with --help - this will exit the process
1013982
const internalNestedBuilder = commandEntry.builder as InternalCliBuilder<
1014983
unknown,
1015984
readonly unknown[]
@@ -1019,12 +988,18 @@ const showNestedCommandHelp = (
1019988
values: {},
1020989
};
1021990

1022-
// This will show the nested builder's help
1023-
return internalNestedBuilder.__parseWithParentGlobals(
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.
994+
void internalNestedBuilder.__parseWithParentGlobals(
1024995
['--help'],
1025996
emptyGlobals,
1026997
true,
1027998
);
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);
10281003
};
10291004

10301005
/**
@@ -1131,13 +1106,15 @@ const generateHelpNew = (state: InternalCliState, theme: Theme): string => {
11311106
*
11321107
* @function
11331108
*/
1134-
const handleHelpError = (
1135-
error: HelpError,
1136-
state: InternalCliState,
1137-
): ParseResult<unknown, readonly unknown[]> & {
1138-
command?: string;
1139-
earlyExit: true;
1140-
} => {
1109+
/**
1110+
* Handles HelpError by displaying the error message, showing help, and
1111+
* terminating the process with exit code 1. This is standard CLI behavior -
1112+
* when a user provides an unknown command or forgets to specify a required
1113+
* command, they see help and the process exits.
1114+
*
1115+
* @function
1116+
*/
1117+
const handleHelpError = (error: HelpError, state: InternalCliState): never => {
11411118
const { theme } = state;
11421119

11431120
// Write error message to stderr
@@ -1148,16 +1125,8 @@ const handleHelpError = (
11481125
process.stderr.write(helpText);
11491126
process.stderr.write('\n');
11501127

1151-
// Set exit code to indicate error (don't call process.exit())
1152-
process.exitCode = 1;
1153-
1154-
// Return a result indicating help was shown
1155-
return {
1156-
command: error.command,
1157-
earlyExit: true,
1158-
positionals: [],
1159-
values: {},
1160-
};
1128+
// Terminate with error exit code
1129+
process.exit(1);
11611130
};
11621131

11631132
/**

src/types.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -163,28 +163,28 @@ export interface CliBuilder<
163163
*
164164
* Throws if any transform or handler returns a Promise.
165165
*
166-
* When an early exit occurs (--help, --version, --completion-script, or
167-
* HelpError), output is displayed, process.exitCode is set appropriately, and
168-
* a result with `earlyExit: true` is returned instead of throwing.
166+
* @remarks
167+
* Early exit scenarios (`--help`, `--version`, `--completion-script`, or
168+
* invalid/missing commands) will call `process.exit()` and never return. This
169+
* is standard CLI behavior.
169170
*/
170171
parse(args?: string[]): ParseResult<TGlobalValues, TGlobalPositionals> & {
171172
command?: string;
172-
earlyExit?: boolean;
173173
};
174174

175175
/**
176176
* Parse arguments asynchronously and run handlers.
177177
*
178178
* Supports async transforms and handlers.
179179
*
180-
* When an early exit occurs (--help, --version, --completion-script, or
181-
* HelpError), output is displayed, process.exitCode is set appropriately, and
182-
* a result with `earlyExit: true` is returned instead of rejecting.
180+
* @remarks
181+
* Early exit scenarios (`--help`, `--version`, `--completion-script`, or
182+
* invalid/missing commands) will call `process.exit()` and never return. This
183+
* is standard CLI behavior.
183184
*/
184185
parseAsync(args?: string[]): Promise<
185186
ParseResult<TGlobalValues, TGlobalPositionals> & {
186187
command?: string;
187-
earlyExit?: boolean;
188188
}
189189
>;
190190
}

0 commit comments

Comments
 (0)