CLI Error Handling and User-Friendly Messages
How to build CLI tools that handle errors gracefully with clear messages, actionable suggestions, and proper exit codes in Node.js.
CLI Error Handling and User-Friendly Messages
The difference between a CLI tool developers tolerate and one they recommend comes down to what happens when things go wrong. A stack trace dumped to the terminal is not an error message. It is a confession that the developer did not think about failure modes.
I have debugged hundreds of CLI tool failures — mine and others. The tools that save time are the ones that tell you what went wrong, why it happened, and what to do about it. This guide covers building that kind of error handling from scratch.
Prerequisites
- Node.js installed (v14+)
- Experience building CLI tools
- Understanding of process exit codes
- Familiarity with try/catch and error objects
Exit Code Conventions
Exit codes are how CLI tools communicate success or failure to scripts and CI pipelines. The convention is simple but widely ignored.
// Standard exit codes
var EXIT = {
SUCCESS: 0, // Everything worked
GENERAL_ERROR: 1, // Catch-all for errors
MISUSE: 2, // Invalid arguments or usage
CANNOT_EXECUTE: 126, // Permission denied
NOT_FOUND: 127, // Command not found
SIGINT: 130 // Terminated by Ctrl+C
};
// Use specific exit codes for your tool
var APP_EXIT = {
SUCCESS: 0,
CONFIG_ERROR: 10,
AUTH_ERROR: 11,
NETWORK_ERROR: 12,
VALIDATION_ERROR: 13,
FILE_ERROR: 14,
TIMEOUT: 15,
DEPENDENCY_ERROR: 16
};
function exit(code, message) {
if (message) {
var stream = code === 0 ? process.stdout : process.stderr;
stream.write(message + "\n");
}
process.exit(code);
}
Scripts can then branch on these codes:
mytool deploy production
status=$?
case $status in
0) echo "Deploy succeeded" ;;
10) echo "Fix your config file" ;;
11) echo "Re-authenticate with: mytool login" ;;
12) echo "Check your network connection" ;;
*) echo "Failed with exit code $status" ;;
esac
Error Classification
Not all errors deserve the same treatment. Classify them by who needs to act.
function CLIError(message, options) {
options = options || {};
this.message = message;
this.code = options.code || "GENERAL_ERROR";
this.exitCode = options.exitCode || 1;
this.suggestion = options.suggestion || null;
this.details = options.details || null;
this.url = options.url || null;
this.cause = options.cause || null;
}
CLIError.prototype = Object.create(Error.prototype);
CLIError.prototype.constructor = CLIError;
// User errors: bad input, missing config, wrong flags
function UserError(message, options) {
options = options || {};
options.code = options.code || "USER_ERROR";
options.exitCode = options.exitCode || 2;
CLIError.call(this, message, options);
}
UserError.prototype = Object.create(CLIError.prototype);
// Environment errors: missing deps, wrong Node version, network
function EnvError(message, options) {
options = options || {};
options.code = options.code || "ENV_ERROR";
options.exitCode = options.exitCode || 12;
CLIError.call(this, message, options);
}
EnvError.prototype = Object.create(CLIError.prototype);
// Bug: unexpected state, should not happen
function InternalError(message, options) {
options = options || {};
options.code = options.code || "INTERNAL_ERROR";
options.exitCode = options.exitCode || 1;
options.suggestion = options.suggestion ||
"This is a bug. Please report it at https://github.com/yourorg/tool/issues";
CLIError.call(this, message, options);
}
InternalError.prototype = Object.create(CLIError.prototype);
// Usage examples
function validatePort(value) {
var port = parseInt(value, 10);
if (isNaN(port) || port < 1 || port > 65535) {
throw new UserError("Invalid port number: " + value, {
suggestion: "Port must be a number between 1 and 65535",
exitCode: 2
});
}
return port;
}
function checkNodeVersion(required) {
var current = process.versions.node;
var currentMajor = parseInt(current.split(".")[0], 10);
var requiredMajor = parseInt(required.split(".")[0], 10);
if (currentMajor < requiredMajor) {
throw new EnvError(
"Node.js " + required + "+ is required (current: " + current + ")", {
suggestion: "Upgrade Node.js: https://nodejs.org/en/download/",
exitCode: 16
});
}
}
function loadConfig(filePath) {
var fs = require("fs");
try {
var content = fs.readFileSync(filePath, "utf8");
return JSON.parse(content);
} catch (err) {
if (err.code === "ENOENT") {
throw new UserError("Config file not found: " + filePath, {
code: "CONFIG_NOT_FOUND",
exitCode: 10,
suggestion: "Run 'mytool init' to create a config file"
});
}
if (err instanceof SyntaxError) {
throw new UserError("Invalid JSON in config file: " + filePath, {
code: "CONFIG_PARSE_ERROR",
exitCode: 10,
details: err.message,
suggestion: "Check your JSON syntax. Common issues: trailing commas, missing quotes"
});
}
throw new InternalError("Failed to read config: " + err.message, {
cause: err
});
}
}
Formatting Error Output
Raw error messages are hard to scan. Structure them visually.
var os = require("os");
function formatError(err) {
var output = [];
var isVerbose = process.env.DEBUG || process.argv.indexOf("--verbose") !== -1;
// Error header
output.push("");
output.push("\u001b[31m ERROR\u001b[0m " + err.message);
// Error code (for scripting)
if (err.code) {
output.push("\u001b[90m Code: " + err.code + "\u001b[0m");
}
// Details
if (err.details) {
output.push("");
output.push(" \u001b[90mDetails:\u001b[0m");
var detailLines = err.details.split("\n");
for (var i = 0; i < detailLines.length; i++) {
output.push(" " + detailLines[i]);
}
}
// Suggestion
if (err.suggestion) {
output.push("");
output.push(" \u001b[33mSuggestion:\u001b[0m " + err.suggestion);
}
// Documentation link
if (err.url) {
output.push(" \u001b[36mMore info:\u001b[0m " + err.url);
}
// Stack trace (only in verbose/debug mode)
if (isVerbose && err.stack) {
output.push("");
output.push(" \u001b[90mStack trace:\u001b[0m");
var stackLines = (err.stack || "").split("\n").slice(1);
for (var j = 0; j < stackLines.length; j++) {
output.push(" " + stackLines[j]);
}
}
// Cause chain
if (isVerbose && err.cause) {
output.push("");
output.push(" \u001b[90mCaused by: " + err.cause.message + "\u001b[0m");
}
output.push("");
return output.join("\n");
}
// Global error handler
function handleError(err) {
if (err instanceof CLIError) {
process.stderr.write(formatError(err));
process.exit(err.exitCode);
}
// Unexpected errors get the internal error treatment
var wrapped = new InternalError("Unexpected error: " + err.message, {
cause: err
});
wrapped.stack = err.stack;
process.stderr.write(formatError(wrapped));
process.exit(1);
}
Output for a user error:
ERROR Config file not found: .mytoolrc
Code: CONFIG_NOT_FOUND
Suggestion: Run 'mytool init' to create a config file
Output for a verbose internal error:
ERROR Unexpected error: Cannot read property 'id' of undefined
Code: INTERNAL_ERROR
Suggestion: This is a bug. Please report it at https://github.com/yourorg/tool/issues
Stack trace:
at processItem (/usr/local/lib/node_modules/mytool/src/process.js:42:18)
at Array.forEach (<anonymous>)
at run (/usr/local/lib/node_modules/mytool/src/index.js:15:10)
Caused by: Cannot read property 'id' of undefined
Common Error Patterns
Network Errors with Retry Suggestions
var http = require("http");
var https = require("https");
function fetchWithErrorHandling(url) {
return new Promise(function(resolve, reject) {
var client = url.indexOf("https") === 0 ? https : http;
var request = client.get(url, function(response) {
if (response.statusCode === 401) {
reject(new UserError("Authentication failed", {
code: "AUTH_FAILED",
exitCode: 11,
suggestion: "Run 'mytool login' to authenticate, or set MYTOOL_TOKEN"
}));
return;
}
if (response.statusCode === 403) {
reject(new UserError("Access denied to " + url, {
code: "FORBIDDEN",
exitCode: 11,
suggestion: "Check that your token has the required permissions"
}));
return;
}
if (response.statusCode === 404) {
reject(new UserError("Resource not found: " + url, {
code: "NOT_FOUND",
exitCode: 1,
suggestion: "Verify the URL or resource name is correct"
}));
return;
}
if (response.statusCode >= 500) {
reject(new EnvError("Server error (" + response.statusCode + ") from " + url, {
code: "SERVER_ERROR",
exitCode: 12,
suggestion: "The server may be experiencing issues. Try again in a few minutes"
}));
return;
}
var body = "";
response.on("data", function(chunk) { body += chunk; });
response.on("end", function() { resolve(body); });
});
request.on("error", function(err) {
if (err.code === "ECONNREFUSED") {
reject(new EnvError("Connection refused: " + url, {
code: "CONN_REFUSED",
exitCode: 12,
suggestion: "Is the server running? Check the URL and port"
}));
} else if (err.code === "ENOTFOUND") {
reject(new EnvError("DNS lookup failed for " + url, {
code: "DNS_FAILED",
exitCode: 12,
suggestion: "Check your internet connection and the hostname"
}));
} else if (err.code === "ETIMEDOUT") {
reject(new EnvError("Connection timed out: " + url, {
code: "TIMEOUT",
exitCode: 15,
suggestion: "The server is not responding. Try again or increase --timeout"
}));
} else {
reject(new EnvError("Network error: " + err.message, {
code: "NETWORK_ERROR",
exitCode: 12,
cause: err
}));
}
});
request.setTimeout(30000, function() {
request.destroy();
reject(new EnvError("Request timed out after 30 seconds", {
code: "TIMEOUT",
exitCode: 15,
suggestion: "Use --timeout to increase the timeout value"
}));
});
});
}
File System Errors
var fs = require("fs");
var path = require("path");
function readFileWithContext(filePath) {
var resolved = path.resolve(filePath);
try {
return fs.readFileSync(resolved, "utf8");
} catch (err) {
switch (err.code) {
case "ENOENT":
// Check if parent directory exists
var dir = path.dirname(resolved);
var dirExists = fs.existsSync(dir);
var suggestion = dirExists
? "Check the file path. Did you mean one of these?\n" +
listSimilarFiles(dir, path.basename(resolved))
: "Directory does not exist: " + dir;
throw new UserError("File not found: " + resolved, {
code: "FILE_NOT_FOUND",
exitCode: 14,
suggestion: suggestion
});
case "EACCES":
throw new UserError("Permission denied: " + resolved, {
code: "PERMISSION_DENIED",
exitCode: 14,
suggestion: "Check file permissions. Current user: " + os.userInfo().username
});
case "EISDIR":
throw new UserError("Expected a file but got a directory: " + resolved, {
code: "IS_DIRECTORY",
exitCode: 14,
suggestion: "Provide a file path, not a directory"
});
default:
throw new InternalError("Failed to read " + resolved + ": " + err.message, {
cause: err
});
}
}
}
function listSimilarFiles(dir, target) {
try {
var files = fs.readdirSync(dir);
var similar = files.filter(function(f) {
return f.indexOf(path.parse(target).name) !== -1 ||
path.extname(f) === path.extname(target);
}).slice(0, 5);
if (similar.length === 0) return " (no similar files found)";
return similar.map(function(f) { return " " + f; }).join("\n");
} catch (e) {
return " (could not list directory)";
}
}
Argument Validation with Did-You-Mean
function didYouMean(input, candidates) {
var best = null;
var bestScore = Infinity;
for (var i = 0; i < candidates.length; i++) {
var score = levenshtein(input.toLowerCase(), candidates[i].toLowerCase());
if (score < bestScore && score <= 3) {
bestScore = score;
best = candidates[i];
}
}
return best;
}
function levenshtein(a, b) {
var matrix = [];
for (var i = 0; i <= b.length; i++) {
matrix[i] = [i];
}
for (var j = 0; j <= a.length; j++) {
matrix[0][j] = j;
}
for (var i2 = 1; i2 <= b.length; i2++) {
for (var j2 = 1; j2 <= a.length; j2++) {
if (b.charAt(i2 - 1) === a.charAt(j2 - 1)) {
matrix[i2][j2] = matrix[i2 - 1][j2 - 1];
} else {
matrix[i2][j2] = Math.min(
matrix[i2 - 1][j2 - 1] + 1,
matrix[i2][j2 - 1] + 1,
matrix[i2 - 1][j2] + 1
);
}
}
}
return matrix[b.length][a.length];
}
function validateCommand(input, commands) {
if (commands.indexOf(input) !== -1) {
return input;
}
var suggestion = didYouMean(input, commands);
var msg = "Unknown command: " + input;
var hint = suggestion
? "Did you mean '" + suggestion + "'?"
: "Available commands: " + commands.join(", ");
throw new UserError(msg, {
code: "UNKNOWN_COMMAND",
exitCode: 2,
suggestion: hint
});
}
// Usage
var COMMANDS = ["deploy", "build", "test", "init", "config", "login"];
try {
validateCommand("deplyo", COMMANDS);
} catch (err) {
handleError(err);
}
Output:
ERROR Unknown command: deplyo
Code: UNKNOWN_COMMAND
Suggestion: Did you mean 'deploy'?
Debug Mode and Verbose Logging
Support multiple verbosity levels for progressive disclosure:
var LOG_LEVELS = {
silent: 0,
error: 1,
warn: 2,
info: 3,
debug: 4,
trace: 5
};
function createLogger(options) {
options = options || {};
var level = LOG_LEVELS.info;
if (options.silent || process.argv.indexOf("--silent") !== -1) {
level = LOG_LEVELS.silent;
} else if (options.verbose || process.argv.indexOf("--verbose") !== -1 ||
process.argv.indexOf("-v") !== -1) {
level = LOG_LEVELS.debug;
} else if (process.env.DEBUG) {
level = LOG_LEVELS.trace;
}
function timestamp() {
if (level < LOG_LEVELS.debug) return "";
return "\u001b[90m[" + new Date().toISOString().split("T")[1].slice(0, 12) + "]\u001b[0m ";
}
return {
error: function(msg, data) {
if (level < LOG_LEVELS.error) return;
process.stderr.write(timestamp() + "\u001b[31mERROR\u001b[0m " + msg + "\n");
if (data && level >= LOG_LEVELS.debug) {
process.stderr.write(" " + JSON.stringify(data, null, 2) + "\n");
}
},
warn: function(msg) {
if (level < LOG_LEVELS.warn) return;
process.stderr.write(timestamp() + "\u001b[33mWARN\u001b[0m " + msg + "\n");
},
info: function(msg) {
if (level < LOG_LEVELS.info) return;
process.stdout.write(timestamp() + msg + "\n");
},
debug: function(msg, data) {
if (level < LOG_LEVELS.debug) return;
process.stderr.write(timestamp() + "\u001b[90mDEBUG " + msg + "\u001b[0m\n");
if (data) {
var lines = JSON.stringify(data, null, 2).split("\n");
for (var i = 0; i < lines.length; i++) {
process.stderr.write(" \u001b[90m" + lines[i] + "\u001b[0m\n");
}
}
},
trace: function(msg) {
if (level < LOG_LEVELS.trace) return;
process.stderr.write(timestamp() + "\u001b[90mTRACE " + msg + "\u001b[0m\n");
}
};
}
// Usage
var log = createLogger();
log.info("Deploying to production");
log.debug("Config loaded", { provider: "aws", region: "us-east-1" });
log.trace("HTTP request: GET /api/deploy");
Normal output:
Deploying to production
Verbose output (--verbose):
[14:32:01.234] Deploying to production
[14:32:01.235] DEBUG Config loaded
{
"provider": "aws",
"region": "us-east-1"
}
Structured Error Reporting
For tools used in automation, support machine-readable error output:
function outputError(err, format) {
if (format === "json") {
var errorObj = {
error: true,
code: err.code || "UNKNOWN",
message: err.message,
exitCode: err.exitCode || 1
};
if (err.suggestion) errorObj.suggestion = err.suggestion;
if (err.details) errorObj.details = err.details;
if (err.url) errorObj.documentation = err.url;
process.stderr.write(JSON.stringify(errorObj, null, 2) + "\n");
} else {
process.stderr.write(formatError(err));
}
process.exit(err.exitCode || 1);
}
// Detect if output should be JSON
function getOutputFormat() {
var flagIndex = process.argv.indexOf("--output");
if (flagIndex !== -1 && process.argv[flagIndex + 1]) {
return process.argv[flagIndex + 1];
}
if (process.argv.indexOf("--json") !== -1) return "json";
// Auto-detect piped output
if (!process.stdout.isTTY) return "json";
return "text";
}
JSON error output for scripting:
{
"error": true,
"code": "AUTH_FAILED",
"message": "Authentication failed",
"exitCode": 11,
"suggestion": "Run 'mytool login' to authenticate, or set MYTOOL_TOKEN"
}
Complete Working Example: CLI with Full Error Handling
#!/usr/bin/env node
var fs = require("fs");
var path = require("path");
var os = require("os");
var https = require("https");
// ---- Error classes ----
function CLIError(message, opts) {
opts = opts || {};
Error.call(this, message);
this.message = message;
this.code = opts.code || "ERROR";
this.exitCode = opts.exitCode || 1;
this.suggestion = opts.suggestion || null;
this.details = opts.details || null;
this.cause = opts.cause || null;
}
CLIError.prototype = Object.create(Error.prototype);
// ---- Logger ----
var verbose = process.argv.indexOf("--verbose") !== -1 ||
process.argv.indexOf("-v") !== -1 ||
!!process.env.DEBUG;
function log(msg) { process.stdout.write(msg + "\n"); }
function debug(msg) { if (verbose) process.stderr.write("\u001b[90m [debug] " + msg + "\u001b[0m\n"); }
function warn(msg) { process.stderr.write("\u001b[33m warning:\u001b[0m " + msg + "\n"); }
// ---- Error formatter ----
function printError(err) {
var lines = [];
lines.push("");
lines.push("\u001b[31m ERROR\u001b[0m " + err.message);
if (err.code) lines.push("\u001b[90m Code: " + err.code + "\u001b[0m");
if (err.details) {
lines.push("");
err.details.split("\n").forEach(function(l) { lines.push(" " + l); });
}
if (err.suggestion) {
lines.push("");
lines.push(" \u001b[33mFix:\u001b[0m " + err.suggestion);
}
if (verbose && err.stack) {
lines.push("");
lines.push(" \u001b[90mStack:\u001b[0m");
err.stack.split("\n").slice(1, 6).forEach(function(l) {
lines.push(" " + l);
});
}
if (verbose && err.cause) {
lines.push(" \u001b[90mCaused by: " + err.cause.message + "\u001b[0m");
}
lines.push("");
process.stderr.write(lines.join("\n"));
}
// ---- Did-you-mean ----
function levenshtein(a, b) {
var m = [];
for (var i = 0; i <= b.length; i++) m[i] = [i];
for (var j = 0; j <= a.length; j++) m[0][j] = j;
for (var i2 = 1; i2 <= b.length; i2++) {
for (var j2 = 1; j2 <= a.length; j2++) {
m[i2][j2] = b[i2-1] === a[j2-1]
? m[i2-1][j2-1]
: Math.min(m[i2-1][j2-1]+1, m[i2][j2-1]+1, m[i2-1][j2]+1);
}
}
return m[b.length][a.length];
}
function suggest(input, options) {
var best = null;
var bestDist = 4;
for (var i = 0; i < options.length; i++) {
var d = levenshtein(input.toLowerCase(), options[i].toLowerCase());
if (d < bestDist) { bestDist = d; best = options[i]; }
}
return best;
}
// ---- Argument parsing ----
function parseArgs() {
var args = process.argv.slice(2);
var command = null;
var flags = {};
var positional = [];
for (var i = 0; i < args.length; i++) {
var arg = args[i];
if (arg === "--help" || arg === "-h") { flags.help = true; continue; }
if (arg === "--verbose" || arg === "-v") { continue; } // Already handled
if (arg.indexOf("--") === 0) {
var eq = arg.indexOf("=");
if (eq !== -1) {
flags[arg.slice(2, eq)] = arg.slice(eq + 1);
} else if (i + 1 < args.length && args[i+1].charAt(0) !== "-") {
flags[arg.slice(2)] = args[++i];
} else {
flags[arg.slice(2)] = true;
}
continue;
}
if (!command) { command = arg; } else { positional.push(arg); }
}
return { command: command, flags: flags, args: positional };
}
// ---- Commands ----
var COMMANDS = ["deploy", "build", "init", "config", "status"];
function cmdDeploy(flags, args) {
var target = args[0];
if (!target) {
throw new CLIError("Missing deploy target", {
code: "MISSING_ARG",
exitCode: 2,
suggestion: "Usage: mytool deploy <target>\n Targets: staging, production"
});
}
var validTargets = ["staging", "production", "dev"];
if (validTargets.indexOf(target) === -1) {
var hint = suggest(target, validTargets);
throw new CLIError("Unknown deploy target: " + target, {
code: "INVALID_TARGET",
exitCode: 2,
suggestion: hint
? "Did you mean '" + hint + "'?"
: "Valid targets: " + validTargets.join(", ")
});
}
// Check config exists
var configPath = path.join(process.cwd(), ".mytoolrc");
if (!fs.existsSync(configPath)) {
throw new CLIError("No config file found in current directory", {
code: "CONFIG_NOT_FOUND",
exitCode: 10,
suggestion: "Run 'mytool init' to create a config file"
});
}
debug("Loading config from " + configPath);
var config;
try {
config = JSON.parse(fs.readFileSync(configPath, "utf8"));
} catch (err) {
throw new CLIError("Invalid config file: " + configPath, {
code: "CONFIG_PARSE",
exitCode: 10,
details: err.message,
suggestion: "Fix the JSON syntax in " + configPath,
cause: err
});
}
// Check required config keys
if (!config.provider) {
throw new CLIError("Missing 'provider' in config", {
code: "CONFIG_INCOMPLETE",
exitCode: 10,
suggestion: "Add \"provider\": \"aws\" to " + configPath
});
}
// Check auth
var token = process.env.MYTOOL_TOKEN || config.token;
if (!token) {
throw new CLIError("No authentication token found", {
code: "AUTH_MISSING",
exitCode: 11,
suggestion: "Set MYTOOL_TOKEN environment variable or run 'mytool login'"
});
}
debug("Deploying to " + target + " with provider " + config.provider);
log("\u001b[32m✔\u001b[0m Deploying to " + target + "...");
log(" Provider: " + config.provider);
log(" Region: " + (config.region || "default"));
}
function cmdInit(flags) {
var configPath = path.join(process.cwd(), ".mytoolrc");
if (fs.existsSync(configPath) && !flags.force) {
throw new CLIError("Config file already exists: " + configPath, {
code: "CONFIG_EXISTS",
exitCode: 1,
suggestion: "Use --force to overwrite the existing config"
});
}
var template = { provider: "aws", region: "us-east-1" };
fs.writeFileSync(configPath, JSON.stringify(template, null, 2) + "\n");
log("\u001b[32m✔\u001b[0m Created " + configPath);
}
function showHelp() {
log("");
log(" \u001b[1mmytool\u001b[0m - Deployment CLI");
log("");
log(" \u001b[1mUsage:\u001b[0m");
log(" mytool <command> [options]");
log("");
log(" \u001b[1mCommands:\u001b[0m");
log(" deploy <target> Deploy to a target environment");
log(" build Build the project");
log(" init Create a config file");
log(" config Show configuration");
log(" status Show deploy status");
log("");
log(" \u001b[1mOptions:\u001b[0m");
log(" --verbose, -v Show debug output");
log(" --help, -h Show this help");
log("");
}
// ---- Main ----
function main() {
var parsed = parseArgs();
if (parsed.flags.help || !parsed.command) {
showHelp();
process.exit(0);
}
if (COMMANDS.indexOf(parsed.command) === -1) {
var hint = suggest(parsed.command, COMMANDS);
throw new CLIError("Unknown command: " + parsed.command, {
code: "UNKNOWN_COMMAND",
exitCode: 2,
suggestion: hint
? "Did you mean '" + hint + "'?\n Run 'mytool --help' to see available commands"
: "Run 'mytool --help' to see available commands"
});
}
switch (parsed.command) {
case "deploy": cmdDeploy(parsed.flags, parsed.args); break;
case "init": cmdInit(parsed.flags); break;
default:
log(parsed.command + " is not implemented yet");
}
}
// ---- Global handlers ----
process.on("uncaughtException", function(err) {
var wrapped = new CLIError("Unexpected error: " + err.message, {
code: "UNCAUGHT",
cause: err,
suggestion: "This is a bug. Report it with --verbose output"
});
wrapped.stack = err.stack;
printError(wrapped);
process.exit(1);
});
process.on("unhandledRejection", function(reason) {
var msg = reason instanceof Error ? reason.message : String(reason);
var wrapped = new CLIError("Unhandled promise rejection: " + msg, {
code: "UNHANDLED_REJECTION",
suggestion: "This is a bug. Report it with --verbose output"
});
if (reason instanceof Error) wrapped.stack = reason.stack;
printError(wrapped);
process.exit(1);
});
process.on("SIGINT", function() {
log("\n\u001b[90mInterrupted\u001b[0m");
process.exit(130);
});
// Run
try {
main();
} catch (err) {
if (err instanceof CLIError) {
printError(err);
process.exit(err.exitCode);
}
// Re-throw unexpected errors for uncaughtException handler
throw err;
}
Sample outputs:
$ mytool deplyo staging
ERROR Unknown command: deplyo
Code: UNKNOWN_COMMAND
Fix: Did you mean 'deploy'?
Run 'mytool --help' to see available commands
$ mytool deploy productoin
ERROR Unknown deploy target: productoin
Code: INVALID_TARGET
Fix: Did you mean 'production'?
$ mytool deploy production
ERROR No config file found in current directory
Code: CONFIG_NOT_FOUND
Fix: Run 'mytool init' to create a config file
$ mytool deploy production --verbose
ERROR No authentication token found
Code: AUTH_MISSING
Fix: Set MYTOOL_TOKEN environment variable or run 'mytool login'
Stack:
at cmdDeploy (/usr/local/lib/node_modules/mytool/index.js:128:11)
at main (/usr/local/lib/node_modules/mytool/index.js:172:14)
Common Issues and Troubleshooting
Stack traces shown to end users
Unhandled exceptions dump raw stack traces that confuse users:
TypeError: Cannot read properties of undefined (reading 'map')
at Object.<anonymous> (/usr/local/lib/mytool/index.js:42:15)
Fix: Register process.on("uncaughtException") and process.on("unhandledRejection") handlers that format errors cleanly. Show stack traces only with --verbose.
Exit code always 0 despite errors
Forgetting process.exit(1) means scripts think the command succeeded:
mytool build && echo "Success" # Prints "Success" even on failure
Fix: Always call process.exit() with a non-zero code in error paths. Never just console.error() and return.
Error messages swallowed in piped output
When stdout is piped, errors written to stdout disappear:
mytool list | grep "foo" # Error message goes to grep, not the terminal
Fix: Always write errors to process.stderr, not process.stdout. Errors should be visible regardless of piping.
Ctrl+C leaves terminal in broken state
If raw mode is enabled for prompts and the user hits Ctrl+C, the terminal stays in raw mode:
Fix: Register a SIGINT handler that restores terminal state:
process.on("SIGINT", function() {
process.stdout.write("\u001b[?25h"); // Show cursor
if (process.stdin.isTTY) process.stdin.setRawMode(false);
process.exit(130);
});
Best Practices
- Write errors to stderr, results to stdout. This lets users pipe results without error messages contaminating the output.
- Always exit with non-zero codes on failure. Scripts and CI depend on exit codes. Zero means success, everything else means failure.
- Include "did you mean" suggestions for typos. Levenshtein distance of 3 or less catches most typos and makes the tool feel intelligent.
- Show stack traces only in verbose/debug mode. End users need actionable messages, not implementation details.
- Classify errors by audience. User errors need suggestions. Environment errors need setup steps. Internal errors need bug report links.
- Validate inputs early and fail fast. Check all arguments, config, and permissions before starting work. Finding an error after a 5-minute build is infuriating.
- Support JSON error output for automation. When stdout is not a TTY or
--jsonis passed, output structured errors that scripts can parse. - Register global handlers for uncaught exceptions. Every unhandled error should still produce a clean, formatted message — not a raw stack trace.