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');
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.sepgives you the platform separator when you need it explicitly. -
path.delimitergives you:on Unix and;on Windows (forPATHparsing). -
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);
}
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
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);
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
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
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');
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/);
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');
}
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);
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']);
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:
SIGTERMandSIGINTwork on Unix but not reliably on Windows. Useprocess.on('exit', cleanup)as a fallback. -
Shell option:
spawn('command', { shell: true })works cross-platform but introduces shell injection risks. Prefercross-spawnwithoutshell: 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 callchild.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');
}
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: '->' };
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));
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);
}
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
This gives you 9 test runs (3 OS x 3 Node versions) for every push. A few tips from running this across 30+ repositories:
- Windows tests are slower — they take 2-3x longer than Linux. Budget for it.
-
Use
npxcarefully — it resolves differently on Windows. Usenpm execor call scripts throughnpm run. -
Path assertions in tests — don't hardcode
/in expected paths. Usepath.join()orpath.sepin your test assertions too. -
Temp directories — use
os.tmpdir()andfs.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-'));
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');
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();
}
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',
};
Pattern 2: The safe config initializer.
function ensureConfigDir(appName) {
const dir = getConfigDir(appName);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
return dir;
}
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);
}
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);
}
Wrapping Up
Cross-platform CLI development in Node.js is not hard — it's just detail-oriented. The core principles are:
-
Never hardcode paths. Use
path.join(),os.homedir(), andos.tmpdir(). -
Never assume the shell. Use
cross-spawninstead ofchild_process.spawn(). - Detect capabilities, don't assume them. Colors, Unicode, terminal width — check before you use.
-
Store configs correctly. Respect platform conventions with
env-pathsor manual detection. - Test on all platforms. A GitHub Actions matrix takes five minutes to set up and saves hours of debugging.
-
Use the ecosystem. Packages like
cross-spawn,chalk,figures, andfast-globexist 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)