Skip to content

Commit 818fb9c

Browse files
committed
feat: add error handling
1 parent 8b59440 commit 818fb9c

File tree

4 files changed

+200
-2
lines changed

4 files changed

+200
-2
lines changed

README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ And it should look good too, right?
3434
- Required options
3535
- Options with multiple values
3636
- Nameless options
37+
- Built-in error handling with customizable handlers
3738
- Automatically generated help text:
3839
- Customizable colors
3940
- Customizable header and footer text
@@ -143,6 +144,38 @@ const parser = massarg({
143144
})
144145
```
145146

147+
### Error Handling
148+
149+
By default, massarg catches all errors and displays a friendly error message in red to stderr,
150+
then exits with code 1. You can customize this behavior with the `.onError()` method:
151+
152+
```ts
153+
massarg({
154+
name: 'my-cli',
155+
description: 'My CLI application',
156+
})
157+
.option({
158+
name: 'config',
159+
description: 'Config file path',
160+
aliases: ['c'],
161+
required: true,
162+
})
163+
// Custom error handler
164+
.onError((error) => {
165+
console.error(`Error: ${error.message}`)
166+
// Log to external service, show custom UI, etc.
167+
})
168+
.parse()
169+
```
170+
171+
**Error handling features:**
172+
173+
- **Default handler**: Logs error message in red to stderr
174+
- **Custom handler**: Use `.onError(callback)` to override the default behavior
175+
- **Automatic exit**: Process exits with code 1 after any error (regardless of handler)
176+
- **Process-level handling**: Handlers also catch `uncaughtException` and `unhandledRejection`
177+
- **Handler inheritance**: Error handlers propagate from parent commands to subcommands
178+
146179
## Documentation
147180

148181
The full documentation can be found here:

src/command.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@ export type Runner<Args extends ArgsObject> = (
4848
instance: MassargCommand<Args>,
4949
) => Promise<void> | void
5050

51+
/**
52+
* Error handler callback type.
53+
* Called when an error occurs during parsing or command execution.
54+
*/
55+
export type ErrorHandler = (error: Error) => void
56+
5157
/**
5258
* A command is a named function that can be invoked with a set of options.
5359
*
@@ -79,6 +85,7 @@ export class MassargCommand<Args extends ArgsObject = ArgsObject>
7985
examples: MassargExample[] = []
8086
args: Partial<Args> = {}
8187
private _helpConfig: HelpConfig
88+
private _errorHandler?: ErrorHandler
8289
parent?: MassargCommand<any>
8390
optionPrefix = DEFAULT_OPT_FULL_PREFIX
8491
aliasPrefix = DEFAULT_OPT_SHORT_PREFIX
@@ -321,6 +328,46 @@ export class MassargCommand<Args extends ArgsObject = ArgsObject>
321328
return this
322329
}
323330

331+
/**
332+
* Configure a custom error handler for this command.
333+
*
334+
* By default, errors are caught and logged to stderr with a red color.
335+
* Use this method to override the default error handling behavior.
336+
*
337+
* Note: The process will always exit with code 1 after an error, regardless of the handler.
338+
*
339+
* @example
340+
* ```ts
341+
* massarg(options)
342+
* .onError((error) => {
343+
* console.error('Custom error:', error.message)
344+
* // Log to external service, show custom UI, etc.
345+
* })
346+
* .parse()
347+
* ```
348+
*/
349+
onError(handler: ErrorHandler): MassargCommand<Args> {
350+
this._errorHandler = handler
351+
return this
352+
}
353+
354+
/** Get the error handler, checking parent chain if not set locally */
355+
private get errorHandler(): ErrorHandler {
356+
if (this._errorHandler) {
357+
return this._errorHandler
358+
}
359+
if (this.parent) {
360+
return this.parent.errorHandler
361+
}
362+
return this.defaultErrorHandler
363+
}
364+
365+
/** Default error handler that logs red error message to stderr */
366+
private defaultErrorHandler = (e: Error): void => {
367+
const message = getErrorMessage(e)
368+
console.error(format(message, { color: 'red' }))
369+
}
370+
324371
/**
325372
* Parse the given arguments and run the command or sub-commands along with the given options
326373
* and flags.
@@ -333,13 +380,30 @@ export class MassargCommand<Args extends ArgsObject = ArgsObject>
333380
args?: Partial<Args>,
334381
parent?: MassargCommand<Args>,
335382
): Promise<void> | void {
383+
// Set up process-level error handlers
384+
const handleProcessError = (error: Error) => {
385+
this.handleError(error)
386+
}
387+
process.on('uncaughtException', handleProcessError)
388+
process.on('unhandledRejection', (reason) => {
389+
const error = reason instanceof Error ? reason : new Error(String(reason))
390+
handleProcessError(error)
391+
})
392+
336393
try {
337394
this.getArgs(argv, args, parent, true)
338395
} catch (e) {
339-
this.printError(e)
396+
this.handleError(e)
340397
}
341398
}
342399

400+
/** Handle an error using the configured error handler, then exit */
401+
private handleError(e: unknown): void {
402+
const error = e instanceof Error ? e : new Error(getErrorMessage(e))
403+
this.errorHandler(error)
404+
process.exit(1)
405+
}
406+
343407
private printError(e: unknown) {
344408
const message = getErrorMessage(e)
345409
console.error(format(message, { color: 'red' }))

src/massarg.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import { ArgsObject, CommandConfig, MassargCommand } from './command'
1+
import { ArgsObject, CommandConfig, ErrorHandler, MassargCommand } from './command'
2+
3+
export type { ErrorHandler }
24

35
/** A minimal command config that can be used to create a top-level command. */
46
export type MinimalCommandConfig<Args extends ArgsObject = ArgsObject> = Omit<

test/command.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,3 +210,102 @@ describe('main', () => {
210210
expect(command.main(fn)).toBeInstanceOf(MassargCommand)
211211
})
212212
})
213+
214+
describe('onError', () => {
215+
let mockExit: jest.SpyInstance
216+
let mockConsoleError: jest.SpyInstance
217+
218+
beforeEach(() => {
219+
mockExit = jest.spyOn(process, 'exit').mockImplementation(() => undefined as never)
220+
mockConsoleError = jest.spyOn(console, 'error').mockImplementation(() => {})
221+
})
222+
223+
afterEach(() => {
224+
mockExit.mockRestore()
225+
mockConsoleError.mockRestore()
226+
})
227+
228+
test('add handler', () => {
229+
const handler = jest.fn()
230+
const command = massarg(opts)
231+
expect(command.onError).toBeInstanceOf(Function)
232+
expect(command.onError(handler)).toBeInstanceOf(MassargCommand)
233+
})
234+
235+
test('default error handler logs to stderr', () => {
236+
const command = massarg(opts).option({
237+
name: 'required-opt',
238+
description: 'required option',
239+
aliases: [],
240+
required: true,
241+
})
242+
command.parse([])
243+
244+
expect(mockConsoleError).toHaveBeenCalled()
245+
expect(mockExit).toHaveBeenCalledWith(1)
246+
})
247+
248+
test('custom error handler is called', () => {
249+
const handler = jest.fn()
250+
const command = massarg(opts)
251+
.onError(handler)
252+
.option({
253+
name: 'required-opt',
254+
description: 'required option',
255+
aliases: [],
256+
required: true,
257+
})
258+
command.parse([])
259+
260+
expect(handler).toHaveBeenCalled()
261+
expect(handler.mock.calls[0][0]).toBeInstanceOf(Error)
262+
expect(mockExit).toHaveBeenCalledWith(1)
263+
})
264+
265+
test('custom error handler overrides default', () => {
266+
const handler = jest.fn()
267+
const command = massarg(opts)
268+
.onError(handler)
269+
.option({
270+
name: 'required-opt',
271+
description: 'required option',
272+
aliases: [],
273+
required: true,
274+
})
275+
command.parse([])
276+
277+
expect(handler).toHaveBeenCalled()
278+
expect(mockConsoleError).not.toHaveBeenCalled()
279+
})
280+
281+
test('error handler catches errors from run function', () => {
282+
const handler = jest.fn()
283+
const command = massarg(opts)
284+
.onError(handler)
285+
.main(() => {
286+
throw new Error('main error')
287+
})
288+
command.parse([])
289+
290+
expect(handler).toHaveBeenCalled()
291+
expect(handler.mock.calls[0][0].message).toBe('main error')
292+
expect(mockExit).toHaveBeenCalledWith(1)
293+
})
294+
295+
test('error handler propagates to subcommands', () => {
296+
const handler = jest.fn()
297+
const command = massarg(opts)
298+
.onError(handler)
299+
.command({
300+
name: 'sub',
301+
description: 'sub command',
302+
run: () => {
303+
throw new Error('sub error')
304+
},
305+
})
306+
command.parse(['sub'])
307+
308+
expect(handler).toHaveBeenCalled()
309+
expect(handler.mock.calls[0][0].message).toBe('sub error')
310+
})
311+
})

0 commit comments

Comments
 (0)