The script that prompted this was a data-migration tool that had grown three undocumented flags, no help text, and a habit of exiting with code 0 even when it failed halfway through. None of that was intentional, it just accumulated because the script never got the fifteen minutes of structure a real CLI needs from the start.
TL;DR: A real Node.js CLI needs four things: argument parsing (
util.parseArgs()for simple cases, Commander for subcommands), meaningful exit codes (0 for success, non-zero for every failure path, no exceptions), abinentry inpackage.jsonwith a shebang line so it runs as a real command, and tests that don't require the whole tool to actually execute against real systems.
Argument Parsing With util.parseArgs()
#!/usr/bin/env node
import { parseArgs } from 'node:util';
const { values, positionals } = parseArgs({
options: {
output: { type: 'string', short: 'o', default: './dist' },
verbose: { type: 'boolean', short: 'v', default: false },
force: { type: 'boolean', default: false },
},
allowPositionals: true,
});
const [inputFile] = positionals;
if (!inputFile) {
console.error('Usage: mycli <input-file> [--output <dir>] [--verbose] [--force]');
process.exit(1);
}
if (values.verbose) {
console.log(`Processing ${inputFile} -> ${values.output}`);
}
parseArgs() handles short and long flags, typed values (string/boolean), and defaults, all without a dependency. For a CLI with a flat set of flags and no subcommands, this covers the whole surface a heavier library like Commander would otherwise be pulled in for.
Exit Codes That Mean Something
async function run(): Promise<void> {
try {
const inputFile = positionals[0];
if (!inputFile) {
console.error('Error: input file required');
process.exit(1);
}
await validateFile(inputFile); // throws if the file doesn't exist or is malformed
await processFile(inputFile, values.output as string);
console.log('Done.');
process.exit(0);
} catch (error) {
console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
}
run();
The try/catch around the whole run function is what stops an uncaught exception from producing Node's default stack trace dump with an ambiguous exit code, or worse, exiting 0 despite the failure. A CI pipeline or shell script calling this tool needs $? (the exit code) to be trustworthy, if my-cli input.json; then echo ok; fi only works correctly if a real failure always produces a non-zero code.
TypeScript Without a Build Step, Using Native Type Stripping
// package.json
{
"name": "mycli",
"version": "1.0.0",
"type": "module",
"bin": {
"mycli": "./src/index.ts"
},
"engines": {
"node": ">=23.6.0"
}
}
// src/index.ts
#!/usr/bin/env node
// ... the CLI code above
As of Node 23.6+, Node runs .ts files directly via native type stripping, no ts-node, no tsx, no build step, as long as the TypeScript doesn't rely on features requiring actual transformation (enums, namespaces, without the extra --experimental-transform-types flag). For a CLI targeting a Node version that new, this removes an entire category of "works on my machine" build tooling drift. For broader compatibility with older Node versions, compile with tsc to plain .js and point bin at the compiled output instead.
Publishing It
npm link # test locally: creates a global symlink to your local package
mycli --help # now runs as a real command
npm publish # ships it to the npm registry, once bin/shebang/package.json are set
npm link is the step worth doing before a real publish, it exercises the exact bin-to-PATH symlink mechanism a real install will use, catching a missing shebang or incorrect file permissions before they become a bug report from an actual user.
Testing Without Actually Running the CLI End-to-End
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { validateFile } from '../src/validate.js';
describe('validateFile', () => {
it('rejects a missing file', async () => {
await assert.rejects(() => validateFile('/nonexistent.json'));
});
});
The parsing and business logic, validateFile, processFile, live in their own exported functions, tested directly with node --test, separate from the parseArgs()/process.exit() wiring in index.ts. Testing the wiring itself (does --force actually get read correctly) is worth a handful of integration tests that spawn the CLI as a subprocess and check its exit code and output, but the bulk of the logic should be testable without spawning a process at all.
Conclusion
A CLI tool earns real structure the moment more than one person, including future you, has to run it without reading the source first. Argument parsing, meaningful exit codes, and a proper bin entry are the three pieces that turn a node script.js habit into something that behaves like every other command a user already trusts.