Node.js, part 20: a real CLI in one file — parseArgs, readline, and exit codes
Part 20from the Node.js series · 24 parts in all
CLIs are where Node's standard library quietly became complete. Argument parsing, interactive prompts, coloured output, and process control are all core now, so a useful tool is one file with no dependencies — which matters, because a CLI that installs forty packages from an npm registry is a supply-chain surface you did not need.
parseArgs: the flags, without a library
#!/usr/bin/env node
const { parseArgs } = require('node:util');
const { readFile } = require('node:fs/promises');
const { values, positionals } = parseArgs({
allowPositionals: true,
options: {
out: { type: 'string', short: 'o', default: '-' },
retries:{ type: 'string', short: 'r', default: '3' },
verbose:{ type: 'boolean', short: 'v' },
help: { type: 'boolean', short: 'h' },
},
});
if (values.help) {
console.log(`usage: report [options] <file>
-o, --out <path> output file (default: stdout)
-v, --verbose log progress`);
process.exit(0);
}
It handles --out=x, --out x, -o x, clustered short
booleans, and -- for literal positionals. It does not handle subcommands
or validation — those are ten lines of your own, and worth writing because the error messages
are the entire user experience.
Prompts, and reading a pipeline
const readline = require('node:readline/promises');
// Only prompt a human when there is one, or a script hangs forever.
if (process.stdin.isTTY) {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const answer = await rl.question('Overwrite? [y/N] ');
rl.close();
if (!/^y/i.test(answer)) process.exit(1);
}
// Reading stdin when it is a pipe - the Unix-shaped case.
const piped = await readFile(0, 'utf8');
Exit codes and signal handling
async function main() {
// ...
}
// One place decides the exit code, and a failure is never a stack trace
// when a message will do.
main().catch((err) => {
if (values.verbose) console.error(err);
else console.error(`error: ${err.message}`);
process.exitCode = 1; // better than process.exit(1): lets stdout flush
});
Use process.exitCode rather than process.exit() wherever you can.
exit() truncates pending writes to a pipe, which is why piping a CLI into
head sometimes loses the last line — a bug that lives in a surprising number of
tools. Next: shipping that CLI as a single executable, with no Node installed.