Skip to content

From a Quick Script to a Node.js CLI You Can Publish

A complete walkthrough of building a Node.js CLI with TypeScript: argument parsing, exit codes, testing, and publishing without ts-node.

· · 8 min read
Lines of code shown on a screen

Quick Take

Every internal script I'd written started as a quick node script.js and grew arms and legs until it needed real argument parsing. I finally built the version I should have started with, and it took less setup than I expected.

Run this yourself. The complete CLI skeleton from this guide, ready to clone and run: cli-tool-nodejs-typescript/ in the Coding Dunia code-examples repo.

A Node.js CLI tool is a command-line program, written and typed in TypeScript, that parses its own arguments, exits with a meaningful status code, and installs as a real command via npm's bin field rather than staying a node script.js invocation forever. Building one properly in 2026 takes less setup than it used to, since Node's native TypeScript support and built-in argument parser remove two dependencies that used to be assumed defaults.

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.

Quick take: A real Node.js CLI tool needs four things: argument parsing (util.parseArgs() for simple cases, Commander for subcommands), meaningful exit codes (0 for success, non-zero on every failure), a bin entry with a shebang so it installs as a real command, and tests that don't require spawning the whole process to run.

How Do You Parse Arguments 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. It's been stable since Node 20.0.0 and lives directly under node:util, so there's no version-pinning risk the way a third-party package on a fast-moving major version can carry.

Three details trip people up the first time. Unknown flags throw by default (strict: true is the default, not opt-in), so a typo like --verbse fails loudly instead of silently getting ignored, which is the opposite of what most hand-rolled process.argv parsers do. Positional arguments require allowPositionals: true explicitly, without it, anything not matching a defined flag throws. And boolean flags default to false only if you set default: false yourself, an unset boolean option is undefined, not false, which matters if your code does a plain truthy check on it.

Node's built-in util.parseArgs(), stable since Node 20.0.0, handles short and long flags, typed values, and defaults without a dependency, and it throws on unknown flags by default. For a flat CLI with no subcommands, it covers the whole surface a heavier library like Commander or yargs would otherwise be pulled in for.

Share this Post on X Bluesky

What Exit Codes Should a CLI Tool Use?

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.

Pick a small, documented set of codes and stick to it. A common split: 1 for "the tool ran but the operation failed" (bad input file, validation error), 2 for "the tool was invoked wrong" (missing required argument, unknown flag), and 130 reserved by convention for SIGINT (Ctrl+C), which Node already sets automatically if you don't intercept the signal yourself. Reusing 1 for every failure category is fine for a small internal tool; a CLI meant for CI pipelines benefits from the split because a caller can branch on "fix your input" versus "fix your invocation" without parsing stderr text.

  1. Wrap the whole run() body in one top-level try/catch, never scatter exits across nested functions.
  2. Log to console.error, not console.log, so failure output doesn't get mixed into piped stdout that another tool might parse.
  3. Call process.exit() only after the catch block finishes logging, an early exit inside a finally can swallow the real error.

How Do You Run TypeScript Without a Build Step?

// 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.

Native type stripping is the term for what Node 23.6+ does here: it erases type annotations at parse time without a full TypeScript compiler pass, so the runtime cost is close to zero and there's no tsc step to forget before publishing. The tradeoff is that stripping doesn't type-check, a CLI that runs fine with a stripped type error still misbehaves exactly like plain JavaScript would. Run tsc --noEmit in CI separately if you want that safety net without giving up the no-build-step local workflow.

How Do You Publish 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. Per the npm CLI docs for npm v10, Windows additionally gets a .cmd shim alongside the symlink, so test on Windows specifically if any published users run it there, a shebang line alone doesn't do anything on that platform.

Semantic versioning is the convention that ties a version bump's size to how much a release can break for callers: patch for fixes that don't change behavior, minor for additive changes, major for anything that changes existing behavior. A CLI's bin name and flag surface are effectively a public API the moment a script or CI pipeline depends on it, renaming a flag in a patch release breaks callers silently. Version deliberately once the package is public.

How Do You Test a CLI Without Running It 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.

Node's built-in test runner, stable since Node 20, is enough here, there's no need to pull in Jest or Vitest just to cover a CLI's business logic. For the subprocess-level tests specifically, use node:child_process's execFileSync wrapped in a try/catch, since a non-zero exit code makes it throw rather than returning a status you'd otherwise have to check manually. Asserting on both the exit code and stderr content in the same test catches the case where a command exits 1 for the wrong reason, a real bug that a code-only assertion would miss entirely.

The Four Pieces at a Glance

What it doesTool/API
Argument parsingFlags, defaults, required-value validationutil.parseArgs() (simple), Commander (subcommands)
Exit codes0 success, non-zero on every failure pathprocess.exit() inside a top-level try/catch
Runnable commandSymlinks the compiled entry into PATHbin field + #!/usr/bin/env node shebang
Running TS directlyNo build step, native type strippingNode 23.6+, or compile with tsc for older Node

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.

Frequently Asked Questions

Do I need a library like Commander or yargs to build a CLI in 2026?
For a simple CLI with a handful of flags, no. Node's built-in util.parseArgs(), stable since Node 20, handles flag parsing, defaults, and required-value validation without a dependency. Reach for Commander or yargs once you need subcommands with their own distinct flag sets (git commit vs git push style), auto-generated help text, or shell completion, features parseArgs() intentionally doesn't cover.
What exit code should a CLI tool use for errors?
0 for success, and a non-zero code for failure, by convention 1 for a generic error. Some tools use more specific codes (2 for a usage error, 3 for a specific known failure category) so calling scripts and CI pipelines can branch on the failure type without parsing output text. Whatever scheme you pick, document it, and never let an uncaught exception exit with code 0, that silently tells calling scripts the tool succeeded when it didn't.
How do I make my TypeScript CLI runnable directly as a command after npm install?
Set the bin field in package.json to point at your compiled entry file, and make sure that file starts with a #!/usr/bin/env node shebang line. After npm install -g (or npm link during development), npm creates a symlink in the user's PATH pointing at that file, so typing your command name runs it directly, provided the file also has execute permissions, which npm sets automatically from the bin field.