Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 46 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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);
Expand Down
156 changes: 117 additions & 39 deletions src/bargs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,7 @@ const createCliBuilder = <V, P extends readonly unknown[]>(
},

// 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<unknown, readonly unknown[]>,
Expand All @@ -565,9 +566,23 @@ const createCliBuilder = <V, P extends readonly unknown[]>(
| (ParseResult<V, P> & { command?: string })
| Promise<ParseResult<V, P> & { command?: string }> {
const stateWithGlobals = { ...state, parentGlobals };
return parseCore(stateWithGlobals, args, allowAsync) as
| (ParseResult<V, P> & { command?: string })
| Promise<ParseResult<V, P> & { 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<ParseResult<V, P> & { command?: string }>;
}
return result as ParseResult<V, P> & { command?: string };
} catch (error) {
if (error instanceof HelpError) {
handleHelpError(error, stateWithGlobals); // exits process
}
throw error;
}
},

// Overloaded command(): accepts (name, factory, options?),
Expand Down Expand Up @@ -761,21 +776,35 @@ const createCliBuilder = <V, P extends readonly unknown[]>(
parse(
args: string[] = process.argv.slice(2),
): ParseResult<V, P> & { 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<V, P> & { command?: string };
} catch (error) {
if (error instanceof HelpError) {
handleHelpError(error, state); // exits process, never returns
}
throw error;
}
return result as ParseResult<V, P> & { command?: string };
},

async parseAsync(
args: string[] = process.argv.slice(2),
): Promise<ParseResult<V, P> & { command?: string }> {
return parseCore(state, args, true) as Promise<
ParseResult<V, P> & { command?: string }
>;
try {
return (await parseCore(state, args, true)) as ParseResult<V, P> & {
command?: string;
};
} catch (error) {
if (error instanceof HelpError) {
handleHelpError(error, state); // exits process, never returns
}
throw error;
}
},
};

Expand All @@ -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
Expand Down Expand Up @@ -832,27 +874,25 @@ 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,
);
}

// 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
Expand All @@ -863,7 +903,7 @@ const parseCore = (
} else {
console.log('Version information not available');
}
process.exit(0);
return exitProcess(0);
}

// Handle shell completion (when enabled)
Expand All @@ -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);
}
}

Expand All @@ -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);
Expand All @@ -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) {
Expand All @@ -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[]
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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).
Expand Down
Loading