Cli Tools

Cross-Platform CLIs with Node.js

Build Node.js CLI tools that work reliably on Windows, macOS, and Linux, covering path handling, shell differences, file system quirks, spawning processes, and distribution strategies.

Cross-Platform CLIs with Node.js

Node.js runs everywhere. Your CLI tool should too. But "runs on Node.js" does not mean "works on every platform." Path separators, line endings, shell behavior, file permissions, and process spawning all differ between Windows, macOS, and Linux. I have shipped CLI tools used by thousands of developers across all three platforms, and the bugs that slip through are always the same handful of cross-platform gotchas. This guide covers every one of them.

Prerequisites

  • Node.js 18+
  • Access to at least two of: Windows, macOS, Linux (or WSL)
  • A Node.js CLI project
  • Basic understanding of OS differences

Path Handling

Path separators are the most common cross-platform bug. Windows uses backslashes. Everything else uses forward slashes.

// WRONG: hardcoded separator
var configPath = homedir + '/.config/myapp/settings.json';
// Breaks on Windows: C:\Users\user/.config/myapp/settings.json (mixed separators)

// RIGHT: use path.join
var path = require('path');
var configPath = path.join(homedir, '.config', 'myapp', 'settings.json');
// Windows: C:\Users\user\.config\myapp\settings.json
// macOS:   /Users/user/.config/myapp/settings.json
// Linux:   /home/user/.config/myapp/settings.json

Critical Path Functions

var path = require('path');

// Join segments (uses correct separator)
path.join('src', 'routes', 'index.js');
// Windows: src\routes\index.js
// Unix:    src/routes/index.js

// Resolve to absolute path
path.resolve('src', 'app.js');
// Windows: C:\project\src\app.js
// Unix:    /home/user/project/src/app.js

// Get directory name
path.dirname('/home/user/project/app.js');
// /home/user/project

// Get file name
path.basename('/home/user/project/app.js');
// app.js

// Get extension
path.extname('app.config.js');
// .js

// Normalize messy paths
path.normalize('src//routes/../models/./user.js');
// src/models/user.js (Unix)
// src\models\user.js (Windows)

// Convert to forward slashes (for URLs, glob patterns, etc.)
function toPosix(filepath) {
  return filepath.split(path.sep).join('/');
}

Home Directory

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

// Cross-platform home directory
var homedir = os.homedir();
// Windows: C:\Users\username
// macOS:   /Users/username
// Linux:   /home/username

// Config directory convention
function getConfigDir(appName) {
  var platform = process.platform;

  if (platform === 'win32') {
    return path.join(process.env.APPDATA || homedir, appName);
  }
  if (platform === 'darwin') {
    return path.join(homedir, 'Library', 'Application Support', appName);
  }
  // Linux: XDG convention
  return path.join(process.env.XDG_CONFIG_HOME || path.join(homedir, '.config'), appName);
}

// Data directory
function getDataDir(appName) {
  var platform = process.platform;

  if (platform === 'win32') {
    return path.join(process.env.LOCALAPPDATA || homedir, appName, 'data');
  }
  if (platform === 'darwin') {
    return path.join(homedir, 'Library', 'Application Support', appName);
  }
  return path.join(process.env.XDG_DATA_HOME || path.join(homedir, '.local', 'share'), appName);
}

Line Endings

Windows uses \r\n (CRLF). Unix uses \n (LF). macOS switched from \r (CR) to \n (LF) with OS X.

var os = require('os');

// Use os.EOL for platform-native line endings
var content = 'line 1' + os.EOL + 'line 2' + os.EOL;

// When reading files, normalize to LF
function normalizeLineEndings(text) {
  return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
}

// When writing files that should always use LF (e.g., shell scripts)
function writeLF(filepath, content) {
  var fs = require('fs');
  var normalized = content.replace(/\r\n/g, '\n');
  fs.writeFileSync(filepath, normalized, 'utf8');
}

// When writing files that should use platform-native endings
function writeNative(filepath, content) {
  var fs = require('fs');
  var native = content.replace(/\r\n/g, '\n').replace(/\n/g, os.EOL);
  fs.writeFileSync(filepath, native, 'utf8');
}

.gitattributes for Generated Files

If your CLI generates files, include a .gitattributes:

# Force LF for shell scripts
*.sh text eol=lf

# Force LF for config files
.editorconfig text eol=lf
.gitignore text eol=lf
Dockerfile text eol=lf

# Let git decide for source code
*.js text
*.json text

Spawning Child Processes

Process spawning is where most cross-platform CLIs break. On Windows, commands work differently.

The shell: true Trap

var execSync = require('child_process').execSync;

// WRONG: works on Unix, fails on Windows
execSync('npm install && npm test');
// Windows doesn't have && in cmd.exe the same way (it does, but quoting differs)

// RIGHT: use shell option explicitly
execSync('npm install && npm test', { shell: true });

// Or chain with Node.js logic
execSync('npm install', { stdio: 'inherit' });
execSync('npm test', { stdio: 'inherit' });

cross-spawn for Reliable Process Spawning

The built-in child_process.spawn has bugs on Windows with .cmd and .bat files. Use cross-spawn:

npm install cross-spawn
var spawn = require('cross-spawn');

// This works on all platforms
var result = spawn.sync('npm', ['install'], {
  stdio: 'inherit',
  cwd: projectDir
});

if (result.status !== 0) {
  console.error('npm install failed with code ' + result.status);
  process.exit(1);
}

// Async version
var child = spawn('npm', ['test'], { stdio: 'inherit' });
child.on('close', function(code) {
  console.log('Tests exited with code ' + code);
});

Finding Executables

var which = require('which');

// Find an executable in PATH
try {
  var npmPath = which.sync('npm');
  console.log('npm found at:', npmPath);
} catch (err) {
  console.error('npm is not installed');
}

// Check if a command exists
function commandExists(cmd) {
  try {
    which.sync(cmd);
    return true;
  } catch (err) {
    return false;
  }
}

if (!commandExists('docker')) {
  console.error('Docker is required but not installed');
  process.exit(1);
}

Running npm Scripts

// WRONG: may not work on Windows
execSync('node_modules/.bin/jest', { stdio: 'inherit' });

// RIGHT: use npx or npm run
var spawn = require('cross-spawn');
spawn.sync('npx', ['jest'], { stdio: 'inherit' });

// Or reference from npm scripts in package.json
spawn.sync('npm', ['test'], { stdio: 'inherit' });

File System Differences

File Permissions

Windows has a fundamentally different permission model. POSIX permissions (chmod) are approximated.

var fs = require('fs');

// Making a file executable
function makeExecutable(filepath) {
  if (process.platform !== 'win32') {
    fs.chmodSync(filepath, '755');
  }
  // On Windows, executability is determined by file extension (.exe, .cmd, .bat)
}

// Creating a bin script that works everywhere
function createBinScript(name, entryPoint) {
  var path = require('path');

  if (process.platform === 'win32') {
    // Create a .cmd wrapper for Windows
    var cmdContent = '@echo off\r\nnode "%~dp0\\' + entryPoint + '" %*\r\n';
    fs.writeFileSync(name + '.cmd', cmdContent);
  } else {
    // Create a shell script for Unix
    var shContent = '#!/usr/bin/env node\n';
    fs.writeFileSync(name, shContent + fs.readFileSync(entryPoint, 'utf8'));
    fs.chmodSync(name, '755');
  }
}

Case Sensitivity

Windows and macOS (by default) have case-insensitive filesystems. Linux is case-sensitive.

// This works on Windows and macOS, fails on Linux:
var config = require('./Config.json');  // File is actually config.json

// Always use exact case
var config = require('./config.json');

// When searching for files, normalize case on case-insensitive systems
function findFileInsensitive(dir, filename) {
  var fs = require('fs');
  var lowerName = filename.toLowerCase();
  var files = fs.readdirSync(dir);

  for (var i = 0; i < files.length; i++) {
    if (files[i].toLowerCase() === lowerName) {
      return path.join(dir, files[i]);
    }
  }
  return null;
}

File Name Restrictions

Windows has stricter filename rules:

// Characters not allowed in Windows filenames
var WINDOWS_FORBIDDEN = /[<>:"/\\|?*\x00-\x1f]/g;

function sanitizeFilename(name) {
  var sanitized = name.replace(WINDOWS_FORBIDDEN, '-');
  // Windows also forbids certain names
  var reserved = ['CON', 'PRN', 'AUX', 'NUL',
    'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
    'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9'];

  var baseName = path.parse(sanitized).name.toUpperCase();
  if (reserved.indexOf(baseName) !== -1) {
    sanitized = '_' + sanitized;
  }

  return sanitized;
}

Temp Directories

var os = require('os');
var fs = require('fs');
var path = require('path');

// Cross-platform temp directory
var tmpDir = os.tmpdir();
// Windows: C:\Users\user\AppData\Local\Temp
// macOS:   /var/folders/xx/xxxx/T
// Linux:   /tmp

// Create a temp directory safely
var workDir = fs.mkdtempSync(path.join(tmpDir, 'myapp-'));

// Clean up when done
process.on('exit', function() {
  fs.rmSync(workDir, { recursive: true, force: true });
});

Terminal and Output

Detecting Terminal Capabilities

// Is stdout a TTY? (interactive terminal, not piped)
var isTTY = process.stdout.isTTY;

// Support NO_COLOR convention
var noColor = 'NO_COLOR' in process.env;

// Terminal width
var termWidth = process.stdout.columns || 80;

// Can we use interactive features?
var isInteractive = isTTY && !process.env.CI;

// Color support
function supportsColor() {
  if (noColor) return false;
  if (!isTTY) return false;
  if (process.env.FORCE_COLOR) return true;
  if (process.platform === 'win32') {
    // Windows 10 1607+ supports ANSI colors
    var osRelease = os.release().split('.');
    return parseInt(osRelease[0]) >= 10 && parseInt(osRelease[2]) >= 14393;
  }
  return true;
}

Unicode and Symbols

// Windows cmd.exe may not support Unicode
var isWindows = process.platform === 'win32';

var symbols = {
  check: isWindows ? '√' : '✓',
  cross: isWindows ? '×' : '✗',
  arrow: isWindows ? '>' : '→',
  bullet: isWindows ? '*' : '•',
  ellipsis: isWindows ? '...' : '…',
  warning: isWindows ? '!!' : '⚠',
  info: isWindows ? 'i' : 'ℹ'
};

console.log(symbols.check + ' Tests passed');
console.log(symbols.cross + ' Build failed');

Clearing the Screen

function clearScreen() {
  if (process.platform === 'win32') {
    process.stdout.write('\x1B[2J\x1B[0f');
  } else {
    process.stdout.write('\x1B[2J\x1B[3J\x1B[H');
  }
}

Environment Variables

// PATH separator differs
var pathSeparator = process.platform === 'win32' ? ';' : ':';
var paths = process.env.PATH.split(pathSeparator);

// HOME directory
var home = process.env.HOME || process.env.USERPROFILE;

// Editor preference
var editor = process.env.VISUAL || process.env.EDITOR ||
  (process.platform === 'win32' ? 'notepad' : 'vi');

// Shell
var shell = process.env.SHELL ||
  (process.platform === 'win32' ? process.env.COMSPEC || 'cmd.exe' : '/bin/sh');

Distribution

npm Global Install

The simplest distribution method:

{
  "name": "my-cli",
  "version": "1.0.0",
  "bin": {
    "my-cli": "./bin/cli.js"
  },
  "engines": {
    "node": ">=18"
  }
}
npm install -g my-cli
my-cli --version

The bin field tells npm to create a platform-appropriate executable wrapper. On Unix, it creates a symlink. On Windows, it creates .cmd and PowerShell .ps1 wrapper scripts.

npx Without Install

npx my-cli init my-project

Works without global installation. npx downloads the package, runs it, and cleans up.

Shebang Line

#!/usr/bin/env node
// bin/cli.js

var program = require('commander');
// ...

The #!/usr/bin/env node shebang works on macOS and Linux. On Windows, npm's wrapper scripts handle the Node.js invocation, so the shebang is ignored (harmlessly).

Single-File Distribution with pkg

Bundle your CLI into a standalone binary:

npm install -g pkg

pkg bin/cli.js --targets node18-linux-x64,node18-macos-x64,node18-win-x64
# Creates:
#   cli-linux
#   cli-macos
#   cli-win.exe

Users do not need Node.js installed. Each binary is ~40-80MB.

Complete Working Example

A cross-platform project initialization CLI:

#!/usr/bin/env node
// bin/cli.js

var path = require('path');
var fs = require('fs');
var os = require('os');
var spawn = require('cross-spawn');

// Cross-platform symbols
var isWin = process.platform === 'win32';
var SYM = {
  ok: isWin ? '[OK]' : '✓',
  fail: isWin ? '[FAIL]' : '✗',
  arrow: isWin ? '->' : '→',
  info: isWin ? '[i]' : 'ℹ'
};

// Cross-platform config directory
function getConfigDir() {
  if (process.platform === 'win32') {
    return path.join(process.env.APPDATA || os.homedir(), 'my-cli');
  }
  return path.join(
    process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'),
    'my-cli'
  );
}

// Cross-platform temp directory
function createTempDir() {
  return fs.mkdtempSync(path.join(os.tmpdir(), 'my-cli-'));
}

// Safe process spawning
function runCommand(cmd, args, options) {
  var result = spawn.sync(cmd, args, Object.assign({
    stdio: 'inherit',
    env: Object.assign({}, process.env)
  }, options));

  if (result.error) {
    console.error(SYM.fail + ' Failed to run: ' + cmd + ' ' + args.join(' '));
    console.error('  ' + result.error.message);
    return false;
  }

  return result.status === 0;
}

// Initialize project
function initProject(name, options) {
  var projectDir = path.resolve(name);

  // Validate
  if (fs.existsSync(projectDir)) {
    console.error(SYM.fail + ' Directory already exists: ' + projectDir);
    process.exit(1);
  }

  console.log(SYM.info + ' Creating project: ' + name);
  console.log('  ' + SYM.arrow + ' ' + projectDir);

  // Create directories
  var dirs = ['src', 'src' + path.sep + 'routes', 'test'];
  dirs.forEach(function(dir) {
    fs.mkdirSync(path.join(projectDir, dir), { recursive: true });
  });
  console.log(SYM.ok + ' Directories created');

  // Write package.json
  var pkg = {
    name: name,
    version: '1.0.0',
    main: 'src/app.js',
    scripts: { start: 'node src/app.js', test: 'jest' }
  };
  fs.writeFileSync(
    path.join(projectDir, 'package.json'),
    JSON.stringify(pkg, null, 2) + os.EOL
  );
  console.log(SYM.ok + ' package.json created');

  // Write .gitignore with LF endings (cross-platform git compatibility)
  var gitignore = ['node_modules/', '.env', 'dist/', 'coverage/'].join('\n') + '\n';
  fs.writeFileSync(path.join(projectDir, '.gitignore'), gitignore);
  console.log(SYM.ok + ' .gitignore created');

  // Install dependencies
  console.log(SYM.info + ' Installing dependencies...');
  var success = runCommand('npm', ['install', 'express'], { cwd: projectDir });

  if (success) {
    console.log(SYM.ok + ' Dependencies installed');
    console.log('\n  Next steps:');
    console.log('    cd ' + name);
    console.log('    npm start');
  } else {
    console.error(SYM.fail + ' Failed to install dependencies');
    process.exit(1);
  }
}

// Parse arguments
var args = process.argv.slice(2);

if (args.length === 0 || args[0] === '--help') {
  console.log('Usage: my-cli <project-name>');
  console.log('');
  console.log('Creates a new Node.js project.');
  process.exit(0);
}

if (args[0] === '--version') {
  console.log(require('../package.json').version);
  process.exit(0);
}

initProject(args[0], {});

Common Issues and Troubleshooting

1. ENOENT When Spawning Commands on Windows

Error: spawn npm ENOENT

Windows looks for npm.cmd, not npm. Use cross-spawn or add shell: true:

// Fix
var spawn = require('cross-spawn');
spawn.sync('npm', ['install']); // cross-spawn handles .cmd resolution

2. Path Too Long on Windows

ENAMETOOLONG: name too long

Windows has a 260-character path limit (MAX_PATH). Deep node_modules nesting hits this. npm v5+ uses flat node_modules by default, which helps. For generated files, keep paths short.

3. EPERM on Windows When Deleting Files

EPERM: operation not permitted, unlink 'file.js'

Another process (antivirus, file watcher, editor) has the file locked. Add retry logic:

function removeWithRetry(filepath, retries) {
  retries = retries || 3;
  for (var i = 0; i < retries; i++) {
    try {
      fs.rmSync(filepath, { recursive: true, force: true });
      return;
    } catch (err) {
      if (i === retries - 1) throw err;
      // Wait briefly for lock to release
      var start = Date.now();
      while (Date.now() - start < 500) {} // busy wait
    }
  }
}

4. Colors Not Working in Windows cmd.exe

←[32m✓ Tests passed←[0m

Older Windows terminals do not process ANSI escape codes. Use a library like chalk that detects support, or check supportsColor() before emitting codes.

Best Practices

  • Use path.join() and path.resolve() for all file paths. Never concatenate paths with string operators.
  • Use cross-spawn instead of child_process.spawn. It handles Windows .cmd resolution and argument escaping correctly.
  • Respect NO_COLOR and TTY detection. Not every environment supports colors or interactive prompts.
  • Use Windows-safe Unicode alternatives. Not every terminal renders Unicode symbols. Provide ASCII fallbacks.
  • Normalize line endings when reading files. Process with \n internally, write with os.EOL or explicit \n depending on the use case.
  • Test on all target platforms. Use GitHub Actions matrix builds with ubuntu-latest, macos-latest, and windows-latest.
  • Use os.tmpdir() and os.homedir() instead of hardcoded paths. Temp and home directories differ across platforms.
  • Handle file permission differences gracefully. Skip chmod on Windows. Check platform before setting Unix permissions.

References

Powered by Contentful