Cli Tools

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 --json is 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.

References

Powered by Contentful