DEV Community

Wilson Xu
Wilson Xu

Posted on

Building Cross-Platform CLI Tools That Work Everywhere: macOS, Linux, and Windows

Building Cross-Platform CLI Tools That Work Everywhere: macOS, Linux, and Windows

Your CLI tool works perfectly on your MacBook. You push it to npm. Then the bug reports roll in: "crashes on Windows," "wrong path separator," "colors don't show up in PowerShell." You've just learned the hard way that building a CLI tool for your machine and building one for everyone's machine are two very different things.

After shipping over 30 npm CLI tools — from websnap-reader (a headless page snapshot utility) to ghbounty (a GitHub bounty scanner) to pricemon (a price monitoring daemon) — I've catalogued every cross-platform pitfall worth knowing about. This guide is the distilled playbook: the problems you'll hit, the solutions that work, and the libraries that save you hours of debugging.

The Cross-Platform Challenge

Node.js promises "write once, run anywhere." And for pure computation, it delivers. But CLI tools aren't pure computation. They touch the file system, spawn child processes, read environment variables, detect terminal capabilities, and store configuration files. Every single one of those surfaces has platform-specific behavior.

The three platforms you must support — macOS, Linux, and Windows — differ in ways that are sometimes obvious (path separators) and sometimes maddening (file locking semantics, case sensitivity, executable resolution). The good news: Node.js provides abstractions for most of these. The bad news: you have to actually use them.

Path Handling: The Foundation

The most fundamental cross-platform issue is path construction. Unix uses / as a separator; Windows uses \. If you've ever written a hardcoded path string, you've written a bug.

// WRONG — breaks on Windows
const configPath = homedir + '/.myapp/config.json';

// RIGHT — works everywhere
const configPath = path.join(os.homedir(), '.myapp', 'config.json');
Enter fullscreen mode Exit fullscreen mode

The path module is your best friend. Use it religiously:

  • path.join() concatenates path segments with the correct separator.
  • path.resolve() produces an absolute path from relative segments.
  • path.sep gives you the platform separator when you need it explicitly.
  • path.delimiter gives you : on Unix and ; on Windows (for PATH parsing).
  • path.normalize() cleans up redundant separators and resolves . and ...

A subtle gotcha: path.basename() on Unix won't correctly parse a Windows-style path like C:\Users\me\file.txt — it returns the whole string. If you're processing paths from user input or config files, be aware that they may use either separator style regardless of the current platform.

// Normalize user-provided paths before processing
function normalizePath(inputPath) {
  return inputPath.split(/[/\\]/).join(path.sep);
}
Enter fullscreen mode Exit fullscreen mode

For home directory resolution, always use os.homedir() — never assume ~ works (it doesn't in all contexts, especially on Windows) and never read process.env.HOME directly (it's USERPROFILE on Windows).

const os = require('os');
const home = os.homedir();
// macOS:   /Users/wilson
// Linux:   /home/wilson
// Windows: C:\Users\wilson
Enter fullscreen mode Exit fullscreen mode

Shell Differences: bash vs cmd vs PowerShell

When your CLI tool needs to spawn a shell command, the target shell is wildly different across platforms. macOS and Linux use bash (or zsh, fish, etc.), while Windows has both cmd.exe and PowerShell — and they have different quoting rules, different built-in commands, and different environment variable syntax.

// WRONG — 'which' doesn't exist on Windows
const result = execSync('which node');

// RIGHT — use a cross-platform approach
const { execSync } = require('child_process');
const command = process.platform === 'win32' ? 'where node' : 'which node';
const result = execSync(command);
Enter fullscreen mode Exit fullscreen mode

Better yet, avoid shelling out for things Node.js can do natively. Instead of running which to find an executable, use a library like which (the npm package) that handles the lookup in JavaScript:

const which = require('which');
const nodePath = which.sync('node'); // cross-platform
Enter fullscreen mode Exit fullscreen mode

Environment variable expansion is another tripwire. In bash, you write $HOME; in cmd, it's %USERPROFILE%; in PowerShell, it's $env:USERPROFILE. If your tool generates scripts or shell commands for the user to run, you need to know which shell they're using.

The cross-env package solves this for npm scripts. If you set "scripts": { "start": "cross-env NODE_ENV=production node app.js" }, it works on all platforms. For more complex process spawning, cross-spawn is essential — it fixes Windows issues with child_process.spawn().

File System: Three Flavors of "File"

Case Sensitivity

macOS uses a case-insensitive (but case-preserving) file system by default. Linux is case-sensitive. Windows is case-insensitive. This matters when you're resolving module names, looking for config files, or matching glob patterns.

// This file exists: Config.json
// Linux:  fs.existsSync('config.json') → false
// macOS:  fs.existsSync('config.json') → true
// Windows: fs.existsSync('config.json') → true
Enter fullscreen mode Exit fullscreen mode

The safe approach: always use consistent casing in your own files and document the expected casing for user-provided paths. If you need case-insensitive matching, do it explicitly:

const files = fs.readdirSync(dir);
const match = files.find(f => f.toLowerCase() === 'config.json');
Enter fullscreen mode Exit fullscreen mode

Line Endings

Unix uses \n (LF). Windows uses \r\n (CRLF). When you read a file and split by lines, always handle both:

// WRONG — misses CRLF
const lines = content.split('\n');

// RIGHT — handles both
const lines = content.split(/\r?\n/);
Enter fullscreen mode Exit fullscreen mode

When writing files, be intentional about line endings. For config files your tool manages, pick one (LF is the standard for most developer tools) and stick with it. For files the user edits, respect their existing line-ending convention or use os.EOL.

Permissions and File Locking

Unix has the classic read/write/execute permission model. Windows has ACLs. When your CLI creates executable scripts, you need to set permissions on Unix but not on Windows:

if (process.platform !== 'win32') {
  fs.chmodSync(scriptPath, '755');
}
Enter fullscreen mode Exit fullscreen mode

Windows also has file locking: a file open for writing by one process can't be deleted or renamed by another. This bites you when updating config files or implementing hot-reload. The workaround is to write to a temp file and rename — fs.rename() is atomic on most file systems:

const tmpPath = configPath + '.tmp';
fs.writeFileSync(tmpPath, newContent);
fs.renameSync(tmpPath, configPath);
Enter fullscreen mode Exit fullscreen mode

Process Spawning: Here Be Dragons

child_process.spawn() on Windows is a minefield. The biggest issue: Windows doesn't use shebangs (#!/usr/bin/env node). When you try to spawn a Node.js script directly, it fails.

// WRONG on Windows — can't execute .js files directly
spawn('some-cli-tool', ['--flag']);

// RIGHT — use cross-spawn
const spawn = require('cross-spawn');
spawn('some-cli-tool', ['--flag']);
Enter fullscreen mode Exit fullscreen mode

cross-spawn fixes this by properly resolving .cmd and .bat wrappers that npm creates on Windows. It also fixes quoting issues with arguments that contain spaces or special characters. After building 30+ tools, I consider it non-negotiable — it belongs in every CLI tool's dependencies.

Other spawning gotchas:

  • Signal handling: SIGTERM and SIGINT work on Unix but not reliably on Windows. Use process.on('exit', cleanup) as a fallback.
  • Shell option: spawn('command', { shell: true }) works cross-platform but introduces shell injection risks. Prefer cross-spawn without shell: true.
  • Detached processes: { detached: true } behaves differently. On Unix, it creates a new process group; on Windows, it creates a new console window. Use { detached: true, stdio: 'ignore' } and call child.unref() for background daemons.

Terminal Capabilities: Colors, Unicode, and Width

Colors

Not all terminals support ANSI color codes. Windows cmd.exe traditionally didn't (though Windows 10+ does). Modern terminals support 256 colors or even true color; older ones support only 16. And CI environments often have colors disabled entirely.

Libraries like chalk handle detection automatically, but there are edge cases:

const chalk = require('chalk');

// Chalk auto-detects, but you can check explicitly
const supportsColor = require('supports-color');
if (supportsColor.stdout) {
  console.log(chalk.green('Success'));
} else {
  console.log('Success');
}
Enter fullscreen mode Exit fullscreen mode

Respect the NO_COLOR environment variable (see https://no-color.org/) and the --no-color flag. Also respect FORCE_COLOR for CI environments that support color but don't advertise it.

Unicode

Your beautiful checkmark and arrow look great in iTerm2 on macOS. In Windows cmd.exe with the default code page? They render as garbled characters.

const isUnicodeSupported = require('is-unicode-supported');

const symbols = isUnicodeSupported()
  ? { success: '', error: '', arrow: '' }
  : { success: 'OK', error: 'FAIL', arrow: '->' };
Enter fullscreen mode Exit fullscreen mode

The figures npm package provides cross-platform symbol replacements automatically. It maps Unicode symbols to ASCII fallbacks on terminals that don't support Unicode.

Terminal Width

Don't assume 80 columns. Don't assume process.stdout.columns is defined (it's not when piped). Handle gracefully:

function getTerminalWidth() {
  return process.stdout.columns || 80;
}

// Use it for formatting tables, progress bars, etc.
const width = getTerminalWidth();
const bar = '='.repeat(Math.min(progress * width, width));
Enter fullscreen mode Exit fullscreen mode

Config File Locations: Do It Right

Every platform has a conventional location for application configuration. Hardcoding ~/.myapp works on macOS and Linux but looks foreign on Windows. Here's the right approach:

const os = require('os');
const path = require('path');

function getConfigDir(appName) {
  const platform = process.platform;

  if (platform === 'win32') {
    // %APPDATA%\myapp (e.g., C:\Users\me\AppData\Roaming\myapp)
    return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), appName);
  }

  if (platform === 'darwin') {
    // ~/Library/Application Support/myapp
    return path.join(os.homedir(), 'Library', 'Application Support', appName);
  }

  // Linux: follow XDG Base Directory spec
  // $XDG_CONFIG_HOME/myapp or ~/.config/myapp
  return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), appName);
}
Enter fullscreen mode Exit fullscreen mode

Similarly, cache directories and data directories have platform-specific conventions:

Type macOS Linux Windows
Config ~/Library/Application Support/app ~/.config/app %APPDATA%\app
Cache ~/Library/Caches/app ~/.cache/app %LOCALAPPDATA%\app\Cache
Data ~/Library/Application Support/app ~/.local/share/app %LOCALAPPDATA%\app\Data

The env-paths npm package computes all of these for you. In practice, many Node.js CLI tools still use ~/.toolname on all platforms for simplicity — and that's fine for developer tools where users expect dotfiles. Just know the "proper" locations exist.

Testing on Multiple Platforms with GitHub Actions

You can't truly verify cross-platform behavior without running tests on all three platforms. GitHub Actions makes this straightforward with matrix builds:

name: CI
on: [push, pull_request]

jobs:
  test:
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
        node-version: [18, 20, 22]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
      - run: npm ci
      - run: npm test
Enter fullscreen mode Exit fullscreen mode

This gives you 9 test runs (3 OS x 3 Node versions) for every push. A few tips from running this across 30+ repositories:

  1. Windows tests are slower — they take 2-3x longer than Linux. Budget for it.
  2. Use npx carefully — it resolves differently on Windows. Use npm exec or call scripts through npm run.
  3. Path assertions in tests — don't hardcode / in expected paths. Use path.join() or path.sep in your test assertions too.
  4. Temp directories — use os.tmpdir() and fs.mkdtempSync() instead of hardcoding /tmp.
const os = require('os');
const fs = require('fs');
const path = require('path');

// Cross-platform temp directory for tests
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'myapp-test-'));
Enter fullscreen mode Exit fullscreen mode

Common Pitfalls and Their Solutions

Pitfall 1: Executable shebangs. Your bin entry in package.json points to a file with #!/usr/bin/env node. This is ignored on Windows — npm creates a .cmd wrapper instead. Make sure your entry point is a .js file (not extensionless) and always test npm link on Windows.

Pitfall 2: Path length limits. Windows historically limited paths to 260 characters. Deeply nested node_modules used to break regularly. Modern Windows supports long paths, but some tools and APIs still don't. Keep your package names and nesting reasonable.

Pitfall 3: fs.watch() inconsistencies. File watching behaves differently on every platform. On macOS, it uses FSEvents. On Linux, it uses inotify (with file descriptor limits). On Windows, it uses ReadDirectoryChangesW. Use chokidar instead of raw fs.watch() — it normalizes the behavior and handles edge cases like atomic saves and symlinks.

Pitfall 4: Globbing. Windows cmd.exe doesn't expand globs (*.js). If your tool accepts glob patterns as arguments, you need to expand them yourself. The glob or fast-glob packages handle this:

const fg = require('fast-glob');
const files = fg.sync(process.argv[2] || '**/*.js');
Enter fullscreen mode Exit fullscreen mode

Pitfall 5: TTY detection. When your tool is piped (myapp | less) or redirected (myapp > file.txt), process.stdout.isTTY is undefined. Use this to decide whether to show interactive elements like progress bars and spinners.

if (process.stdout.isTTY) {
  showProgressBar();
} else {
  logPlainProgress();
}
Enter fullscreen mode Exit fullscreen mode

The Cross-Platform Toolkit

Here are the packages I install in nearly every CLI tool:

Package Purpose
cross-spawn Fix child_process.spawn() on Windows
cross-env Set environment variables in npm scripts
chalk Terminal colors with auto-detection
figures Unicode symbols with ASCII fallbacks
env-paths Platform-correct config/cache/data directories
which Cross-platform executable lookup
fast-glob Glob expansion (Windows doesn't expand globs)
chokidar Reliable file watching across platforms
is-unicode-supported Detect Unicode terminal support
supports-color Detect color terminal support

You don't need all of these in every tool. But knowing they exist — and reaching for them instead of hand-rolling platform checks — saves time and prevents user-facing bugs.

Real-World Patterns From 30+ Tools

After building tools like websnap-reader, ghbounty, devpitch, pricemon, and repo-readme-gen, patterns emerge. Here are the ones that have become second nature:

Pattern 1: The platform-aware defaults object.

const defaults = {
  editor: process.env.EDITOR || (process.platform === 'win32' ? 'notepad' : 'vi'),
  open: process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open',
  shell: process.platform === 'win32' ? 'cmd.exe' : process.env.SHELL || '/bin/sh',
};
Enter fullscreen mode Exit fullscreen mode

Pattern 2: The safe config initializer.

function ensureConfigDir(appName) {
  const dir = getConfigDir(appName);
  if (!fs.existsSync(dir)) {
    fs.mkdirSync(dir, { recursive: true });
  }
  return dir;
}
Enter fullscreen mode Exit fullscreen mode

The { recursive: true } option in fs.mkdirSync() was added in Node 10 and works cross-platform. Before it existed, you'd need mkdirp.

Pattern 3: The cleanup handler.

function setupCleanup(callback) {
  // Unix signals
  process.on('SIGINT', () => { callback(); process.exit(130); });
  process.on('SIGTERM', () => { callback(); process.exit(143); });

  // Works everywhere, including Windows
  process.on('exit', callback);
}
Enter fullscreen mode Exit fullscreen mode

Pattern 4: The output formatter.

function formatOutput(data) {
  // If piped, output machine-readable JSON
  if (!process.stdout.isTTY) {
    return JSON.stringify(data);
  }
  // If terminal, output human-readable format
  return formatTable(data);
}
Enter fullscreen mode Exit fullscreen mode

Wrapping Up

Cross-platform CLI development in Node.js is not hard — it's just detail-oriented. The core principles are:

  1. Never hardcode paths. Use path.join(), os.homedir(), and os.tmpdir().
  2. Never assume the shell. Use cross-spawn instead of child_process.spawn().
  3. Detect capabilities, don't assume them. Colors, Unicode, terminal width — check before you use.
  4. Store configs correctly. Respect platform conventions with env-paths or manual detection.
  5. Test on all platforms. A GitHub Actions matrix takes five minutes to set up and saves hours of debugging.
  6. Use the ecosystem. Packages like cross-spawn, chalk, figures, and fast-glob exist because these problems are solved. Don't re-solve them.

The gap between "works on my machine" and "works everywhere" is smaller than you think — if you build with cross-platform awareness from day one. Start every new CLI tool with the right abstractions, test in CI on all three platforms, and you'll ship tools that just work, no matter what OS your users are running.

Top comments (0)