07 — Command Line Applications — Talking to the Terminal
"console.log and read the args" was my CLI model, and it stopped at the surface. The model that finally stuck: a CLI is just a Node process with three streams attached — standard input, standard output, standard error — and every CLI library is sugar over reading and writing those streams. Once I saw the streams underneath, the libraries stopped feeling like magic [1].
The framing that landed for me is the streams-first view, then the libraries built on top.
The process object: argv, env, and exit codes
Every Node CLI starts with the process global — the running OS process itself. Three properties on it cover most of what a CLI needs.
process.argv is an array of the command-line arguments [2]. The first element is the path to the Node binary, the second is the path to the script, and the rest are the arguments the user typed:
node mycli.js --port 3000 users// process.argv === ['/usr/bin/node', '/path/mycli.js', '--port', '3000', 'users']
const args = process.argv.slice(2); // ['--port', '3000', 'users']Parsing that array by hand is the lowest-level option — fine for two flags, painful for ten. The libraries exist precisely to lift this work up.
process.env is an object holding every environment variable visible to the process [3]. This is where ports, API keys, and database URLs live — values that change between environments and must not be hardcoded. process.env.PORT, process.env.DATABASE_URL, process.env.API_KEY is the canonical pattern. A .env file paired with the dotenv package loads these at startup in development, so the same process.env.X reads work locally and in production.
Exit codes are how a CLI signals success or failure to the shell that launched it [4]. An exit code of 0 means success; any non-zero code means an error. The shell (&&, scripts, CI) checks this number to decide whether to continue. process.exit(1) exits immediately with a failure code; setting process.exitCode = 1 lets the process finish naturally but still exit non-zero.
stdin, stdout, stderr: the three streams
A process has three standard streams inherited from the terminal, and they are the actual substrate of every CLI:
- stdout — where normal output goes. console.log is sugar over process.stdout.write with a newline and formatting [5]. For raw control (writing without a newline, or piping binary), process.stdout.write is the direct call.
- stderr — where diagnostic output goes. console.error writes here. The point of separating them: a user can pipe stdout into a file (mycli > out.txt) while still seeing errors on screen, because stderr is not redirected by >.
- stdin — where input comes from, typically the keyboard [6]. process.stdin is a Readable stream; the readline module wraps it into a line-by-line interface for interactive prompts.
Inquirer and prompts: rich interactive input
For a CLI that needs multi-step, multi-type input — pick from a list, confirm a dangerous action, type a password — reading process.stdin by hand is grim. Inquirer.js is the standard library for this, supporting text, password, list, checkbox, and confirm prompt types [7]. prompts is a lighter alternative with a similar API [8].
import inquirer from 'inquirer';
const answers = await inquirer.prompt([
{ type: 'input', name: 'name', message: 'Project name?' },
{ type: 'confirm', name: 'ts', message: 'Use TypeScript?', default: true },
{ type: 'list', name: 'pkg', message: 'Package manager?', choices: ['npm', 'pnpm', 'yarn'] }
]);
// answers.name, answers.ts, answers.pkgThe library renders the prompt, handles keyboard navigation, validates the input, and resolves the promise with an answers object. This is what makes scaffolders like create-next-app feel polished — they are Inquirer (or a sibling) driving stdin/stdout under the hood.
Commander.js: parsing flags and routing subcommands
The other half of a real CLI is argument parsing — turning mycli users add --admin ave into a routed function call. Commander.js is the de-facto standard, giving me option parsing, subcommands, help text, and type coercion for free [9].
import { program } from 'commander';
program
.name('mycli')
.description('A small example CLI')
.version('1.0.0');
program
.command('users <name>')
.option('--admin', 'make the user an admin')
.action((name, opts) => {
console.log(`adding ${name}${opts.admin ? ' as admin' : ''}`);
});
program.parse();That gives me mycli --help, mycli users ave, mycli users ave --admin, all routed correctly. For anything beyond two flags, Commander pays for itself within the first command.
chalk, figlet, cli-progress: making output readable
The remaining libraries in this layer are about presentation. chalk styles terminal output — colors, bold, underline — so errors read red and success reads green across different terminals [10]. figlet renders text as large ASCII art, the stylized app name on a CLI's startup banner. cli-progress draws progress bars for long operations like downloads or migrations. None of these change behavior; they all write to stdout, just with ANSI escape codes that terminals interpret as styling. The discipline: styling is decoration on the streams, never a substitute for clear exit codes and good stderr messages.
How I use this
The model I keep is "this is a process with three streams." A few habits fall out. I keep stdout and stderr separate — normal output goes to stdout so it can be piped, diagnostics and errors go to stderr so they survive a redirect. I set a non-zero process.exitCode on failure rather than calling process.exit() abruptly, because the latter can truncate in-flight stdout writes. For anything beyond a single flag, I reach for Commander on the input side and Inquirer (or prompts) for interactive collection. And I treat process.env as the configuration contract — every environment-sensitive value reads from it, never hardcoded. The streams-first view is what makes the libraries legible.
References
[1] Okta Developer, "Build a Command Line Application with Node.js," 2019. [Online]. Available: https://developer.okta.com/blog/2019/06/18/command-line-app-with-nodejs
[2] OpenJS Foundation, "process.argv," Node.js API Docs. [Online]. Available: https://nodejs.org/docs/latest/api/process.html#processargv
[3] OpenJS Foundation, "How to read environment variables from Node.js," nodejs.org. [Online]. Available: https://nodejs.org/en/learn/command-line/how-to-read-environment-variables-from-nodejs
[4] OpenJS Foundation, "Process: Event 'exit'," Node.js API Docs. [Online]. Available: https://nodejs.org/api/process.html#event-exit
[5] OpenJS Foundation, "process.stdout," Node.js API Docs. [Online]. Available: https://nodejs.org/api/process.html#processstdout
[6] OpenJS Foundation, "Accept input from the command line in Node.js," nodejs.org. [Online]. Available: https://nodejs.org/en/learn/command-line/accept-input-from-the-command-line-in-nodejs
[7] S. Boudrias, "Inquirer.js," GitHub. [Online]. Available: https://github.com/SBoudrias/Inquirer.js#readme
[8] "prompts," npm. [Online]. Available: https://www.npmjs.com/package/prompts
[9] "commander," npm. [Online]. Available: https://www.npmjs.com/package/commander
[10] "chalk," GitHub. [Online]. Available: https://github.com/chalk/chalk#readme
Knowledge check · Question 1 of 5
In process.argv, the user-supplied arguments start at which index?
Comments
Leave a Comment
You must be signed in to comment
0 Comments
No comments yet. Be the first to comment!