Skip to main content

Command Palette

Search for a command to run...

Debugging Node.js CLI Tools: From console.log to Professional Diagnostics

Published
•13 min read•View as Markdown

Debugging Node.js CLI Tools: From console.log to Professional Diagnostics

If you have built a few command-line tools in Node.js, you know the drill. Something breaks, you scatter console.log statements across your code, find the bug, delete the logs, and ship. It works until it does not. CLI tools introduce debugging challenges that web applications never face: no browser DevTools, no request/response cycle to inspect, output that mixes with your diagnostic noise, and users who run your tool in environments you have never seen.

After building and maintaining over 30 Node.js CLI tools, I have moved from ad-hoc logging to a professional diagnostics stack. This guide walks through that progression — from quick-and-dirty debugging to production-grade observability — so you can build CLI tools that are easy to maintain, easy to diagnose, and easy for users to report issues with.

Why CLI Debugging Is Different From Web App Debugging

Web developers are spoiled. Browser DevTools give you a network tab, a console, element inspection, performance profiling, and memory snapshots all in one interface. When you move to CLI tools, you lose all of that.

Here is what makes CLI debugging uniquely challenging:

  • stdout is your UI. Every console.log you add for debugging competes with the output your user expects. Pipe my-tool | jq and your debug output breaks the JSON parser on the other end.
  • Short-lived processes. Many CLI tools run for less than a second. By the time you attach a debugger, the process has already exited.
  • Environment diversity. Your tool runs on macOS, Linux, Windows, inside Docker containers, in CI pipelines, and through SSH sessions. You cannot reproduce every environment.
  • No request context. Web apps have request IDs and traces. CLI invocations have arguments and environment variables, but correlating a bug report to a specific execution requires deliberate instrumentation.
  • Interactive vs piped modes. Your tool might behave differently when stdout is a TTY versus a pipe, and debugging the piped mode is harder because you cannot see intermediate output.

Understanding these constraints shapes every debugging decision that follows.

console.log vs the debug Module: Structured Logging With Namespaces

The first upgrade from raw console.log is the debug module. It gives you namespaced, toggleable output that writes to stderr (not stdout), so it never corrupts your tool's actual output.

import createDebug from 'debug';

const debug = createDebug('mytool');
const debugParser = createDebug('mytool:parser');
const debugNetwork = createDebug('mytool:network');

export function parseConfig(filePath) {
  debug('loading config from %s', filePath);
  const raw = fs.readFileSync(filePath, 'utf8');
  debugParser('raw config length: %d bytes', raw.length);

  const parsed = JSON.parse(raw);
  debugParser('parsed %d keys: %O', Object.keys(parsed).length, Object.keys(parsed));

  return parsed;
}

Users enable debugging with an environment variable:

# See all debug output
DEBUG=mytool:* my-tool build

# Only parser logs
DEBUG=mytool:parser my-tool build

# Multiple namespaces
DEBUG=mytool:parser,mytool:network my-tool build

This is the single most impactful change you can make. The debug module is almost zero-cost when disabled (the format string is not even evaluated), it writes to stderr so piped output stays clean, and it gives users granular control over what they see.

A pattern I use in every tool: create a debug.js module that exports pre-configured loggers:

// src/debug.js
import createDebug from 'debug';

const APP_NAME = 'mytool';

export const debug = createDebug(APP_NAME);
export const debugConfig = createDebug(`${APP_NAME}:config`);
export const debugIO = createDebug(`${APP_NAME}:io`);
export const debugPerf = createDebug(`${APP_NAME}:perf`);

Import what you need in each module. Consistent namespace prefixes make it easy for users to zoom in on specific subsystems.

Node.js Inspector: node --inspect With CLI Tools

The built-in Node.js inspector works with CLI tools, but you need to handle the short-lived process problem. The trick is --inspect-brk, which pauses execution on the first line and waits for a debugger to attach:

node --inspect-brk ./bin/my-tool.js build --output dist

Node prints a WebSocket URL. Open chrome://inspect in Chrome, and your tool appears under "Remote Target." Click "inspect," and you are in a full DevTools session with breakpoints, watch expressions, and call stack inspection.

For tools installed globally via npm, find the entry point first:

which my-tool
# /usr/local/bin/my-tool → symlink to ../lib/node_modules/my-tool/bin/cli.js

node --inspect-brk /usr/local/lib/node_modules/my-tool/bin/cli.js build

The inspector is invaluable for complex bugs that are hard to reason about from logs alone — async flow issues, closure scoping problems, or unexpected state mutations.

Source Maps for TypeScript CLI Debugging

Most modern CLI tools are written in TypeScript and compiled to JavaScript. Without source maps, your debugging session shows transpiled code that bears little resemblance to what you wrote.

Enable source maps in your tsconfig.json:

{
  "compilerOptions": {
    "sourceMap": true,
    "inlineSources": true,
    "outDir": "dist"
  }
}

Then run with the --enable-source-maps flag:

node --enable-source-maps --inspect-brk dist/cli.js

Now breakpoints in DevTools map back to your TypeScript source. Stack traces in error messages also show TypeScript line numbers, which helps enormously when users paste error output into bug reports.

For tools that use a bundler like esbuild or tsup, make sure you configure source map generation:

// tsup.config.ts
export default {
  entry: ['src/cli.ts'],
  sourcemap: true,
  format: ['esm'],
  target: 'node18',
};

One gotcha: if you publish your tool with "files" in package.json that excludes .map files, your users lose source-mapped stack traces. I always include map files in published packages — the size overhead is minimal and the debugging benefit is significant.

Environment-Based Debug Output: DEBUG=myapp:*

Beyond the debug module, you can build environment-aware diagnostics that provide different levels of detail based on context.

const VERBOSITY = process.env.VERBOSE ? 2
                : process.env.DEBUG ? 1
                : 0;

function diagnose(level, message, data) {
  if (level > VERBOSITY) return;
  const timestamp = new Date().toISOString();
  const entry = { timestamp, message, ...data };
  process.stderr.write(JSON.stringify(entry) + '\n');
}

// Usage
diagnose(1, 'config loaded', { path: configPath, keys: Object.keys(config) });
diagnose(2, 'full config dump', { config });

This pattern lets you do structured JSON logging to stderr. Users can pipe stderr to a file for analysis:

VERBOSE=1 my-tool build 2>debug.log

And you can parse the structured output with jq:

VERBOSE=1 my-tool build 2>&1 >/dev/null | jq 'select(.message == "config loaded")'

Logging to Files: Rotating Logs With Winston and Pino

For long-running CLI tools — file watchers, dev servers, daemon processes — you need persistent logging with rotation to prevent disk exhaustion.

Pino is the fastest option. Its worker-thread-based transport system keeps logging off the main event loop:

import pino from 'pino';
import { join } from 'path';
import { homedir } from 'os';

const LOG_DIR = join(homedir(), '.my-tool', 'logs');

const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  transport: {
    targets: [
      {
        target: 'pino/file',
        options: { destination: join(LOG_DIR, 'app.log'), mkdir: true },
        level: 'debug',
      },
      {
        target: 'pino-pretty',
        options: { destination: 2 }, // stderr
        level: process.env.DEBUG ? 'debug' : 'warn',
      },
    ],
  },
});

For log rotation, use pino-roll or manage it externally with logrotate. A practical approach for CLI tools is date-based log files:

const today = new Date().toISOString().split('T')[0];
const logFile = join(LOG_DIR, `${today}.log`);

Then add a cleanup routine that runs on startup and deletes logs older than 7 days. Users should not have to manage your tool's log files.

Profiling CLI Performance: --prof and --cpu-prof Flags

When your CLI tool is slow and you do not know why, Node.js has built-in profiling that requires zero dependencies.

CPU Profiling

node --cpu-prof --cpu-prof-dir=./profiles ./bin/my-tool.js build

This generates a .cpuprofile file that you can load directly into Chrome DevTools (Performance tab) or VS Code. You get a flame chart showing exactly where time is spent.

For more targeted profiling, start and stop programmatically:

import { Session } from 'inspector';
import { writeFileSync } from 'fs';

const session = new Session();
session.connect();

export function startProfiling() {
  session.post('Profiler.enable');
  session.post('Profiler.start');
}

export function stopProfiling(outputPath) {
  return new Promise((resolve) => {
    session.post('Profiler.stop', (err, { profile }) => {
      writeFileSync(outputPath, JSON.stringify(profile));
      resolve(profile);
    });
  });
}

V8 Profiling

The --prof flag generates a V8 log that you process with --prof-process:

node --prof ./bin/my-tool.js build
node --prof-process isolate-0x*.log > processed.txt

The processed output shows statistical profiling data with ticks per function. Look for the "Bottom up (heavy) profile" section — it tells you which functions consumed the most CPU time, including time spent in their callees.

Memory Leak Detection in Long-Running CLIs

File watchers, dev servers, and daemon-style CLI tools are susceptible to memory leaks. Node.js provides heap snapshots for tracking them down.

import v8 from 'v8';
import { writeFileSync } from 'fs';

function takeHeapSnapshot(label) {
  const fileName = `heap-${label}-${Date.now()}.heapsnapshot`;
  const snapshotStream = v8.writeHeapSnapshot(fileName);
  console.error(`Heap snapshot written to ${snapshotStream}`);
}

// Take snapshots at intervals
let snapshotCount = 0;
setInterval(() => {
  if (process.env.HEAP_DEBUG) {
    takeHeapSnapshot(`interval-${++snapshotCount}`);
  }
}, 60_000);

Load these snapshots into Chrome DevTools Memory tab. Take two snapshots minutes apart and use the "Comparison" view to see what objects are accumulating.

Common leak patterns in CLI tools:

  • Event listeners that are never removed. File watchers that add listeners on every file change without cleanup.
  • Growing caches without eviction. Memoization maps that grow unbounded during long watch sessions.
  • Closures capturing large objects. Callback functions that hold references to parsed ASTs or file buffers long after they are needed.
  • Unreferenced timers. setInterval calls that keep the process alive and accumulate state.

A quick diagnostic without heap snapshots: log process.memoryUsage() periodically and watch heapUsed. If it trends upward monotonically, you have a leak.

setInterval(() => {
  const { heapUsed, heapTotal, rss } = process.memoryUsage();
  debugPerf('memory: heap=%dMB total=%dMB rss=%dMB',
    (heapUsed / 1048576).toFixed(1),
    (heapTotal / 1048576).toFixed(1),
    (rss / 1048576).toFixed(1)
  );
}, 30_000);

Remote Debugging With VS Code Launch Configs

VS Code's debugger works exceptionally well with CLI tools. Create a .vscode/launch.json in your project:

{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "Debug CLI",
      "program": "${workspaceFolder}/src/cli.ts",
      "args": ["build", "--output", "dist"],
      "outFiles": ["${workspaceFolder}/dist/**/*.js"],
      "sourceMaps": true,
      "console": "integratedTerminal",
      "env": {
        "DEBUG": "mytool:*",
        "NODE_ENV": "development"
      }
    },
    {
      "type": "node",
      "request": "launch",
      "name": "Debug CLI (current file as input)",
      "program": "${workspaceFolder}/src/cli.ts",
      "args": ["lint", "${file}"],
      "sourceMaps": true,
      "console": "integratedTerminal"
    },
    {
      "type": "node",
      "request": "attach",
      "name": "Attach to Running CLI",
      "port": 9229,
      "sourceMaps": true,
      "outFiles": ["${workspaceFolder}/dist/**/*.js"]
    }
  ]
}

Key details:

  • Use "console": "integratedTerminal" so your CLI's interactive features (prompts, spinners, colors) work correctly. The default debug console does not support TTY features.
  • The "outFiles" pattern tells VS Code where to find compiled JavaScript and source maps.
  • The attach configuration lets you connect to a CLI started with --inspect from the terminal.

For TypeScript projects using tsx or ts-node, set the runtimeArgs:

{
  "type": "node",
  "request": "launch",
  "name": "Debug TS CLI",
  "runtimeArgs": ["-r", "tsx/cjs"],
  "program": "${workspaceFolder}/src/cli.ts",
  "sourceMaps": true
}

Building a --debug Flag Into Your CLI

Every CLI tool should have a --debug or --verbose flag that users can toggle without knowing about environment variables. Here is how I implement it:

#!/usr/bin/env node
import { program } from 'commander';
import createDebug from 'debug';

// Enable debug output BEFORE any other imports that use debug
if (process.argv.includes('--debug') || process.argv.includes('-D')) {
  createDebug.enable('mytool:*');
}

const debug = createDebug('mytool');

program
  .option('-D, --debug', 'enable debug output')
  .option('-v, --verbose', 'increase verbosity')
  .hook('preAction', (thisCommand) => {
    const opts = thisCommand.opts();
    if (opts.debug) {
      createDebug.enable('mytool:*');
    }
    if (opts.verbose) {
      process.env.VERBOSE = '1';
    }
  });

program
  .command('build')
  .description('Build the project')
  .action(async (options) => {
    debug('build started with options: %O', options);
    // ... build logic
  });

program.parse();

The critical detail is the process.argv.includes check at the top, before other modules are imported. If you wait until Commander parses arguments, modules that initialize debug loggers at import time will already have checked whether they are enabled.

For extra polish, detect --debug and also print system information:

if (opts.debug) {
  console.error('--- Debug Info ---');
  console.error(`Tool version: ${pkg.version}`);
  console.error(`Node.js: ${process.version}`);
  console.error(`Platform: ${process.platform} ${process.arch}`);
  console.error(`CWD: ${process.cwd()}`);
  console.error(`Args: ${process.argv.slice(2).join(' ')}`);
  console.error('------------------');
}

This information appears in every bug report where the user ran with --debug, saving you multiple round-trips asking for environment details.

Error Reporting: Capturing and Sending Crash Reports

Unhandled exceptions in CLI tools produce ugly stack traces that most users do not know how to interpret. Build a top-level error handler that captures context and guides users toward filing useful bug reports.

process.on('uncaughtException', (error) => {
  handleFatalError(error);
});

process.on('unhandledRejection', (reason) => {
  handleFatalError(reason instanceof Error ? reason : new Error(String(reason)));
});

function handleFatalError(error) {
  const report = {
    message: error.message,
    stack: error.stack,
    timestamp: new Date().toISOString(),
    nodeVersion: process.version,
    platform: `${process.platform}-${process.arch}`,
    toolVersion: pkg.version,
    argv: process.argv.slice(2),
    env: {
      NODE_ENV: process.env.NODE_ENV,
      SHELL: process.env.SHELL,
      TERM: process.env.TERM,
    },
  };

  // Write crash report to file
  const crashDir = join(homedir(), '.my-tool', 'crashes');
  mkdirSync(crashDir, { recursive: true });
  const crashFile = join(crashDir, `crash-${Date.now()}.json`);
  writeFileSync(crashFile, JSON.stringify(report, null, 2));

  // User-friendly output
  console.error('\n  Unexpected error occurred.\n');
  console.error(`  ${error.message}\n`);
  console.error(`  Crash report saved to: ${crashFile}`);
  console.error(`  Please report this issue at: https://github.com/yourorg/my-tool/issues`);
  console.error(`  Include the crash report file with your issue.\n`);

  process.exit(1);
}

For opt-in telemetry, you can send crash reports to a service like Sentry. But always make it opt-in and respect privacy:

if (config.telemetry !== false && !process.env.DO_NOT_TRACK) {
  Sentry.init({
    dsn: 'https://your-dsn@sentry.io/project',
    release: pkg.version,
    beforeSend(event) {
      // Strip file paths to protect user privacy
      delete event.server_name;
      return event;
    },
  });
}

The DO_NOT_TRACK environment variable is a community convention that your tool should respect.

Real Debugging Sessions From Our 30+ Tools

Across the dozens of CLI tools we have built and maintained, several debugging patterns have saved us repeatedly.

The Invisible Encoding Bug

One of our tools, websnap-reader, was silently producing corrupted output for some URLs. No errors, no warnings — just garbled text. Adding DEBUG=websnap:* revealed that certain pages returned content with a BOM (byte order mark) that our parser did not strip. The fix was two lines of code, but without structured debug output showing the raw bytes, we would have spent hours guessing.

The Race Condition in Parallel Processing

Our depcheck-ai tool analyzed multiple package.json files in parallel. Occasionally it would report incorrect results. CPU profiling showed nothing unusual. The bug only appeared with --debug output, which revealed that a shared configuration object was being mutated across parallel operations. The debug timestamps showed interleaved access patterns that made the race condition obvious.

The Memory Leak in Watch Mode

filewatch, a tool that monitors directories for changes, would consume gigabytes of memory after running for a day. Heap snapshots taken at 1-hour intervals showed that parsed file content was being stored in a Map that was keyed by file path but never evicted when files were deleted. A simple WeakRef-based cache solved it, and we only found it because we had built the HEAP_DEBUG interval snapshot mechanism described above.

The Platform-Specific Failure

gitclean worked perfectly on macOS and Linux but crashed on Windows with a cryptic EPERM error. The crash report system captured the platform, the exact arguments, and the error. A Windows user filed an issue with the crash report JSON attached, and we immediately saw the problem: we were using fs.unlinkSync on a file that Windows had locked because another process was reading it. The fix was adding retry logic with a brief delay, but the key insight was that the crash report gave us everything we needed without asking the user to "run it again with these flags."

Putting It All Together

Here is a minimal diagnostic setup I add to every new CLI tool from day one:

  1. Install debug and create namespaced loggers for each subsystem.
  2. Add a --debug flag that enables all loggers and prints system information.
  3. Write to stderr, never stdout, for all diagnostic output.
  4. Add a top-level error handler that writes crash reports to ~/.my-tool/crashes/.
  5. Include source maps in published packages.
  6. Add a VS Code launch config to the repository for contributors.

This takes about 30 minutes to set up and saves hours on every debugging session that follows. The progression from console.log to professional diagnostics is not about complexity — it is about building habits that make your future self's life easier.

Start with the debug module. Add the crash reporter. Build the --debug flag. You will wonder how you ever shipped CLI tools without them.

More from this blog

W

Wilson Xu

108 posts