Trust is earned, not given

A different perspective

2023-06-13 · Projects

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.