Processes and the shell

Write a command-line tool that behaves like every other one: arguments, exit codes and a clean stop.

16of 18
≈ 10Min read
7Parts

A CLI is just a script with arguments

and a line that makes it runnable
#!/usr/bin/env node
// doors.js
const args = process.argv.slice(2);
console.log("Building", args);

// chmod +x doors.js && ./doors.js build --watch

// package.json
{ "bin": { "doors": "./doors.js" } }     // npm link → `doors build`

Parsing arguments

For anything bigger — subcommands, help text, completion — commander or yargs. For a script you own, parseArgs is usually enough and adds nothing to install.

built in since Node 18.3
import { parseArgs } from "node:util";

const { values, positionals } = parseArgs({
  options: {
    watch: { type: "boolean", short: "w", default: false },
    out:   { type: "string",  short: "o" },
  },
  allowPositionals: true,
});

// doors build --watch -o dist
// values      → { watch: true, out: "dist" }
// positionals → ["build"]

Input and output

stdout is data, stderr is everything else
console.log(JSON.stringify(result));    // stdout — the answer
console.error("Building…");             // stderr — progress and warnings

// so this still works:
// doors list --json | jq '.[].name'        (stderr goes to the terminal)

// reading piped input
let input = "";
for await (const chunk of process.stdin) input += chunk;

process.stdout.isTTY   // false when piped — turn colour off
The rule that makes a tool composable

Anything a person reads goes to stderr; anything a program reads goes to stdout. Progress bars and log lines on stdout break every pipeline your tool is put into.

Exit codes

A tool that prints "Error!" and exits 0 will be ignored by every script that runs it. The status is the machine-readable part; the message is for the human.

zero is success, and CI is reading it
process.exit(0);    // fine
process.exit(1);    // a general failure
process.exit(2);    // by convention: wrong usage

process.exitCode = 1;   // set it and let the process end naturally — safer:
// process.exit() truncates output that is still being written

Signals

stopping on purpose
process.on("SIGINT", handle);    // Ctrl-C            → exit 130
process.on("SIGTERM", handle);   // kill, a deploy    → exit 143

function handle(signal) {
  console.error("Stopping on " + signal);
  cleanup();
  process.exit(signal === "SIGINT" ? 130 : 143);   // 128 + the signal number
}

// SIGKILL cannot be caught. Never rely on cleanup running.

Running something else

and the one you must not use
import { execFile, spawn } from "node:child_process";

// arguments as an array: no shell, no injection
const { stdout } = await execFileAsync("git", ["log", "-1", "--format=%H"]);

// streaming output, for something long
const child = spawn("npm", ["run", "build"], { stdio: "inherit" });
child.on("exit", (code) => process.exit(code ?? 1));

// exec() runs a SHELL — never build its string from input
exec(`rm -rf ${dir}`);          // dir = "x; rm -rf /"

The small things people notice

  • --help and --version, both exiting 0.
  • Errors on stderr with a non-zero exit, not a stack trace.
  • No colour when process.stdout.isTTY is false or NO_COLOR is set.
  • A --json flag for anything a script might want to read.
  • Doing nothing when run with no arguments, except printing help.

Practice