Skip to content

Commit 3547913

Browse files
committed
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
1 parent 7b6d197 commit 3547913

4 files changed

Lines changed: 243 additions & 62 deletions

File tree

src/bargs.ts

Lines changed: 69 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -760,22 +760,42 @@ const createCliBuilder = <V, P extends readonly unknown[]>(
760760

761761
parse(
762762
args: string[] = process.argv.slice(2),
763-
): 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-
);
763+
): ParseResult<V, P> & { command?: string; helpShown?: boolean } {
764+
try {
765+
const result = parseCore(state, args, false);
766+
if (isThenable(result)) {
767+
throw new BargsError(
768+
'Async transform or handler detected. Use parseAsync() instead of parse().',
769+
);
770+
}
771+
return result as ParseResult<V, P> & { command?: string };
772+
} catch (error) {
773+
if (error instanceof HelpError) {
774+
return handleHelpError(error, state) as ParseResult<V, P> & {
775+
command?: string;
776+
helpShown: true;
777+
};
778+
}
779+
throw error;
769780
}
770-
return result as ParseResult<V, P> & { command?: string };
771781
},
772782

773783
async parseAsync(
774784
args: string[] = process.argv.slice(2),
775-
): Promise<ParseResult<V, P> & { command?: string }> {
776-
return parseCore(state, args, true) as Promise<
777-
ParseResult<V, P> & { command?: string }
778-
>;
785+
): Promise<ParseResult<V, P> & { command?: string; helpShown?: boolean }> {
786+
try {
787+
return (await parseCore(state, args, true)) as ParseResult<V, P> & {
788+
command?: string;
789+
};
790+
} catch (error) {
791+
if (error instanceof HelpError) {
792+
return handleHelpError(error, state) as ParseResult<V, P> & {
793+
command?: string;
794+
helpShown: true;
795+
};
796+
}
797+
throw error;
798+
}
779799
},
780800
};
781801

@@ -1053,6 +1073,44 @@ const generateHelpNew = (state: InternalCliState, theme: Theme): string => {
10531073
};
10541074
/* c8 ignore stop */
10551075

1076+
/**
1077+
* Handle a HelpError by displaying the error message and help text to stderr,
1078+
* setting the exit code, and returning a result indicating help was shown.
1079+
*
1080+
* This prevents HelpError from bubbling up to global exception handlers while
1081+
* still providing useful feedback to the user.
1082+
*
1083+
* @function
1084+
*/
1085+
const handleHelpError = (
1086+
error: HelpError,
1087+
state: InternalCliState,
1088+
): ParseResult<unknown, readonly unknown[]> & {
1089+
command?: string;
1090+
helpShown: true;
1091+
} => {
1092+
const { theme } = state;
1093+
1094+
// Write error message to stderr
1095+
process.stderr.write(`Error: ${error.message}\n\n`);
1096+
1097+
// Generate and write help text to stderr
1098+
const helpText = generateHelpNew(state, theme);
1099+
process.stderr.write(helpText);
1100+
process.stderr.write('\n');
1101+
1102+
// Set exit code to indicate error (don't call process.exit())
1103+
process.exitCode = 1;
1104+
1105+
// Return a result indicating help was shown
1106+
return {
1107+
command: error.command,
1108+
helpShown: true,
1109+
positionals: [],
1110+
values: {},
1111+
};
1112+
};
1113+
10561114
/**
10571115
* Check if something is a Parser (has __brand: 'Parser'). Parsers can be either
10581116
* objects or functions (CallableParser).

src/types.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -162,20 +162,30 @@ export interface CliBuilder<
162162
* Parse arguments synchronously and run handlers.
163163
*
164164
* Throws if any transform or handler returns a Promise.
165+
*
166+
* When a HelpError occurs (e.g., unknown command, no command specified), help
167+
* text is displayed to stderr, process.exitCode is set to 1, and a result
168+
* with `helpShown: true` is returned instead of throwing.
165169
*/
166-
parse(
167-
args?: string[],
168-
): ParseResult<TGlobalValues, TGlobalPositionals> & { command?: string };
170+
parse(args?: string[]): ParseResult<TGlobalValues, TGlobalPositionals> & {
171+
command?: string;
172+
helpShown?: boolean;
173+
};
169174

170175
/**
171176
* Parse arguments asynchronously and run handlers.
172177
*
173178
* Supports async transforms and handlers.
179+
*
180+
* When a HelpError occurs (e.g., unknown command, no command specified), help
181+
* text is displayed to stderr, process.exitCode is set to 1, and a result
182+
* with `helpShown: true` is returned instead of rejecting.
174183
*/
175-
parseAsync(
176-
args?: string[],
177-
): Promise<
178-
ParseResult<TGlobalValues, TGlobalPositionals> & { command?: string }
184+
parseAsync(args?: string[]): Promise<
185+
ParseResult<TGlobalValues, TGlobalPositionals> & {
186+
command?: string;
187+
helpShown?: boolean;
188+
}
179189
>;
180190
}
181191

test/bargs.test.ts

Lines changed: 126 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* Tests for the main bargs API.
33
*/
4-
import { expect, expectAsync } from 'bupkis';
4+
import { expect } from 'bupkis';
55
import { describe, it } from 'node:test';
66

77
import type { StringOption } from '../src/types.js';
@@ -189,17 +189,31 @@ describe('.parseAsync()', () => {
189189
expect(handlerCalled, 'to be', true);
190190
});
191191

192-
it('throws on unknown command', async () => {
193-
const cli = bargs('test-cli').command(
194-
'greet',
195-
handle(opt.options({}), () => {}),
196-
);
192+
it('handles unknown command by showing help and setting exitCode', async () => {
193+
const originalExitCode = process.exitCode;
194+
const stderrWrites: string[] = [];
195+
const originalStderrWrite = process.stderr.write.bind(process.stderr);
197196

198-
await expectAsync(
199-
cli.parseAsync(['unknown']),
200-
'to reject with error satisfying',
201-
/Unknown command/,
202-
);
197+
process.stderr.write = ((chunk: unknown) => {
198+
stderrWrites.push(String(chunk));
199+
return true;
200+
}) as typeof process.stderr.write;
201+
202+
try {
203+
const cli = bargs('test-cli').command(
204+
'greet',
205+
handle(opt.options({}), () => {}),
206+
);
207+
208+
const result = await cli.parseAsync(['unknown']);
209+
210+
expect(result.helpShown, 'to be true');
211+
expect(process.exitCode, 'to equal', 1);
212+
expect(stderrWrites.join(''), 'to contain', 'Unknown command: unknown');
213+
} finally {
214+
process.stderr.write = originalStderrWrite;
215+
process.exitCode = originalExitCode;
216+
}
203217
});
204218

205219
it('returns parsed result with command name', async () => {
@@ -786,23 +800,108 @@ describe('merge() edge cases', () => {
786800
});
787801

788802
describe('error paths', () => {
789-
it('throws HelpError when no command specified and no default', async () => {
790-
const cli = bargs('test-cli')
791-
.command(
792-
'run',
793-
handle(opt.options({}), () => {}),
794-
)
795-
.command(
796-
'build',
797-
handle(opt.options({}), () => {}),
798-
);
799-
// No defaultCommand set
803+
describe('HelpError handling', () => {
804+
it('catches HelpError on no command, displays help, and sets exitCode', async () => {
805+
const originalExitCode = process.exitCode;
806+
const stderrWrites: string[] = [];
807+
const originalStderrWrite = process.stderr.write.bind(process.stderr);
808+
809+
// Capture stderr
810+
process.stderr.write = ((chunk: unknown) => {
811+
stderrWrites.push(String(chunk));
812+
return true;
813+
}) as typeof process.stderr.write;
814+
815+
try {
816+
const cli = bargs('test-cli')
817+
.command(
818+
'run',
819+
handle(opt.options({}), () => {}),
820+
)
821+
.command(
822+
'build',
823+
handle(opt.options({}), () => {}),
824+
);
825+
826+
// This should NOT throw - it should catch HelpError and handle it
827+
const result = await cli.parseAsync([]);
828+
829+
// Verify exitCode was set to 1
830+
expect(process.exitCode, 'to equal', 1);
831+
832+
// Verify error message was shown
833+
const output = stderrWrites.join('');
834+
expect(output, 'to contain', 'No command specified');
835+
836+
// Verify help was displayed
837+
expect(output, 'to contain', 'USAGE');
838+
expect(output, 'to contain', 'COMMANDS');
839+
840+
// Verify result indicates help was shown
841+
expect(result.helpShown, 'to be true');
842+
} finally {
843+
process.stderr.write = originalStderrWrite;
844+
process.exitCode = originalExitCode;
845+
}
846+
});
800847

801-
await expectAsync(
802-
cli.parseAsync([]),
803-
'to reject with error satisfying',
804-
/No command specified/,
805-
);
848+
it('catches HelpError on unknown command, displays help, and sets exitCode', async () => {
849+
const originalExitCode = process.exitCode;
850+
const stderrWrites: string[] = [];
851+
const originalStderrWrite = process.stderr.write.bind(process.stderr);
852+
853+
process.stderr.write = ((chunk: unknown) => {
854+
stderrWrites.push(String(chunk));
855+
return true;
856+
}) as typeof process.stderr.write;
857+
858+
try {
859+
const cli = bargs('test-cli').command(
860+
'run',
861+
handle(opt.options({}), () => {}),
862+
);
863+
864+
const result = await cli.parseAsync(['unknown-command']);
865+
866+
expect(process.exitCode, 'to equal', 1);
867+
868+
const output = stderrWrites.join('');
869+
expect(output, 'to contain', 'Unknown command: unknown-command');
870+
expect(output, 'to contain', 'USAGE');
871+
872+
expect(result.helpShown, 'to be true');
873+
} finally {
874+
process.stderr.write = originalStderrWrite;
875+
process.exitCode = originalExitCode;
876+
}
877+
});
878+
879+
it('catches HelpError in sync parse() as well', () => {
880+
const originalExitCode = process.exitCode;
881+
const stderrWrites: string[] = [];
882+
const originalStderrWrite = process.stderr.write.bind(process.stderr);
883+
884+
process.stderr.write = ((chunk: unknown) => {
885+
stderrWrites.push(String(chunk));
886+
return true;
887+
}) as typeof process.stderr.write;
888+
889+
try {
890+
const cli = bargs('test-cli').command(
891+
'run',
892+
handle(opt.options({}), () => {}),
893+
);
894+
895+
const result = cli.parse([]);
896+
897+
expect(process.exitCode, 'to equal', 1);
898+
expect(stderrWrites.join(''), 'to contain', 'No command specified');
899+
expect(result.helpShown, 'to be true');
900+
} finally {
901+
process.stderr.write = originalStderrWrite;
902+
process.exitCode = originalExitCode;
903+
}
904+
});
806905
});
807906

808907
it('handles async global transform in nested commands', async () => {

test/parser-commands.test.ts

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* These tests focus on command dispatching and parsing behaviors complementary
55
* to the tests in bargs.test.ts.
66
*/
7-
import { expect, expectAsync } from 'bupkis';
7+
import { expect } from 'bupkis';
88
import { describe, it } from 'node:test';
99

1010
import { bargs, handle } from '../src/bargs.js';
@@ -96,22 +96,36 @@ describe('command parsing', () => {
9696
});
9797
});
9898

99-
it('throws on unknown command', async () => {
100-
const cli = bargs('test-cli')
101-
.command(
102-
'add',
103-
handle(opt.options({}), () => {}),
104-
)
105-
.command(
106-
'remove',
107-
handle(opt.options({}), () => {}),
108-
);
109-
110-
await expectAsync(
111-
cli.parseAsync(['unknown']),
112-
'to reject with error satisfying',
113-
/Unknown command: unknown/,
114-
);
99+
it('handles unknown command by showing help and setting exitCode', async () => {
100+
const originalExitCode = process.exitCode;
101+
const stderrWrites: string[] = [];
102+
const originalStderrWrite = process.stderr.write.bind(process.stderr);
103+
104+
process.stderr.write = ((chunk: unknown) => {
105+
stderrWrites.push(String(chunk));
106+
return true;
107+
}) as typeof process.stderr.write;
108+
109+
try {
110+
const cli = bargs('test-cli')
111+
.command(
112+
'add',
113+
handle(opt.options({}), () => {}),
114+
)
115+
.command(
116+
'remove',
117+
handle(opt.options({}), () => {}),
118+
);
119+
120+
const result = await cli.parseAsync(['unknown']);
121+
122+
expect(result.helpShown, 'to be true');
123+
expect(process.exitCode, 'to equal', 1);
124+
expect(stderrWrites.join(''), 'to contain', 'Unknown command: unknown');
125+
} finally {
126+
process.stderr.write = originalStderrWrite;
127+
process.exitCode = originalExitCode;
128+
}
115129
});
116130

117131
it('merges global and command options', async () => {

0 commit comments

Comments
 (0)