A CLI is just a script with arguments
#!/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.
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
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.
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
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
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
--helpand--version, both exiting 0.- Errors on stderr with a non-zero exit, not a stack trace.
- No colour when
process.stdout.isTTYis false orNO_COLORis set. - A
--jsonflag for anything a script might want to read. - Doing nothing when run with no arguments, except printing help.