Shell pipelines, && chains, make targets, and CI systems all read exit codes to decide whether to continue. A CLI that exits 0 after printing an error message signals success to every caller — the deploy runs, the migration fires, the release ships. CWE-390 (Detection of Error Condition Without Action) covers exactly this failure: the error is detected and logged, but the process status communicates success. AI-generated CLIs frequently introduce this bug by placing process.exit(0) (or nothing at all) in catch blocks. ISO 25010:2011 reliability.fault-tolerance requires that faults be observable by callers — exit code 0 makes them invisible.
Critical because a non-zero exit code is the only signal CI pipelines and shell scripts have that a command failed — exit 0 on error makes failures invisible to automation.
Every error path must call process.exit with a non-zero code before the process terminates. Use exit code 2 for usage/argument errors (convention) and 1 for runtime failures:
async function main() {
try {
await run(program.parse())
} catch (err) {
if (err instanceof UsageError) {
console.error(`Error: ${err.message}`)
console.error('Run with --help for usage information')
process.exit(2) // usage error
}
console.error(`Error: ${err.message}`)
process.exit(1) // general error
}
}
main()
For unhandled promise rejections in Node.js, add:
process.on('unhandledRejection', (err) => {
console.error('Unhandled error:', err)
process.exit(1)
})
Verify each exit path by running mytool <failing-command>; echo $? — you must never see 0 after a visible error.
ID: cli-quality.error-handling.exit-codes
Severity: critical
What to look for: Enumerate every exit path in the CLI code (process.exit calls, uncaught error handlers, successful completions). For each, examine every process.exit(), sys.exit(), os.Exit(), and std::process::exit() call. Check that successful operations exit with code 0 and failures exit with non-zero codes. Check that the CLI doesn't always exit 0 (a common AI-generation bug — catch blocks that log the error but don't set the exit code). Check for distinct exit codes for different failure types (configuration error vs runtime error vs user error). Verify that unhandled promise rejections and uncaught exceptions result in non-zero exit codes.
Pass criteria: Exit code 0 for success, non-zero for any failure. Unhandled errors result in non-zero exit. Different failure types ideally use distinct non-zero codes (1 for general error, 2 for usage error is the convention). No code path can reach a failed state and exit 0 — at least 2 distinct exit codes used (0 for success, non-zero for failure). Report: "X exit paths found, all Y use meaningful exit codes."
Fail criteria: Any failure path exits with code 0, or all exit codes are 0, or errors are caught and logged but process.exit(0) is called (or the process exits naturally with code 0 after printing an error).
Do NOT pass when: The CLI always exits 0 regardless of outcome — even if error messages are printed to stderr, exit code 0 signals success to shell pipelines and CI.
Skip (N/A) when: All checks skip when no CLI entry point is detected.
Cross-reference: The error-messages check verifies that failures include descriptive messages alongside the exit code.
Detail on fail: "catch block in main() calls console.error(err) then process.exit(0) — failures report success to calling scripts" or "No process.exit() calls found — unhandled promise rejections will exit 0 in Node.js >= 15 only if --unhandled-rejections=throw is set"
Remediation: Exit codes are how CI systems, shell scripts, and && chains know if your tool succeeded:
// Node.js — proper exit code handling
async function main() {
try {
await run(program.parse())
} catch (err) {
if (err instanceof UsageError) {
console.error(`Error: ${err.message}`)
console.error('Run with --help for usage information')
process.exit(2) // usage error
}
console.error(`Error: ${err.message}`)
process.exit(1) // general error
}
}
main()
# Python — sys.exit with proper codes
def main():
try:
cli()
except UsageError as e:
click.echo(f'Error: {e}', err=True)
sys.exit(2)
except Exception as e:
click.echo(f'Error: {e}', err=True)
sys.exit(1)