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

# 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.

```javascript
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:

```bash
# 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:

```javascript
// 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:

```bash
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:

```bash
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`:

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

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

```bash
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:

```javascript
// 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.

```javascript
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:

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

And you can parse the structured output with `jq`:

```bash
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:

```javascript
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:

```javascript
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

```bash
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:

```javascript
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`:

```bash
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.

```javascript
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.

```javascript
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:

```json
{
  "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`:

```json
{
  "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:

```javascript
#!/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:

```javascript
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.

```javascript
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:

```javascript
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.

