Building CLI Tools in Node.js That People Actually Want to Use
Every team has a handful of internal CLI tools that are fragile, undocumented, and produce output no script can parse. Here is the structured argument parsing, exit code discipline, JSON output mode, and testing pattern that turns a glue script into a tool people trust in CI and at 2 AM.
The deployment script was a 400-line bash file that had grown like a vine through three teams and six years. It accepted a -e flag for environment, a positional argument for the service name, and an undocumented --skip-tests that only the original author knew existed. When it failed, it printed a Postgres stack trace and nothing else. Every engineer on call had a different theory about what the exit code meant. The CI pipeline called it with || true because nobody trusted it to exit correctly.
That script is why this post exists. Most internal CLI tools start as a one-off Node.js file or a bash alias, accumulate flags without a plan, and rot into something nobody wants to touch. But a well-built CLI tool is one of the highest-leverage things you can ship on a team. It codifies tribal knowledge, standardizes workflows, and replaces a Slack message with a single command.
This post covers the patterns that separate a throwaway script from a tool your teammates reach for first. Every section includes code you can use today.
The skeleton: argument parsing without the pain
The first mistake most CLI tools make is parsing arguments by hand with process.argv.slice(2) and a switch statement. That works for exactly one flag. After that you get ambiguous shorthand flags, missing required arguments, and the dreaded --help output that surprises everyone who reads it.
Node.js 18.3+ ships util.parseArgs for lightweight parsing. It is good enough for small tools and avoids a dependency. For anything more complex, Commander.js is the standard. It handles subcommands, variadic arguments, type coercion, and auto-generated help text without ceremony.
Here is the skeleton using Commander:
#!/usr/bin/env node
import { Command } from 'commander';
const program = new Command();
program
.name('deploy')
.description('Deploy a service to an environment')
.argument('<service>', 'Service name (e.g. api, worker, web)')
.option('-e, --environment <env>', 'Target environment', 'staging')
.option('--skip-tests', 'Skip the test suite before deploying')
.option('--tag <tag>', 'Docker image tag to deploy', 'latest')
.option('--timeout <ms>', 'Deploy timeout in milliseconds', parseInt, 300_000)
.action(async (service, options) => {
await deploy(service, options);
});
program.parse();
A few details stand out here.
The argument is positional and required. Commander automatically shows an error if the user omits it. The -e, --environment <env> option has a default of staging, so the help text reads (default: "staging") automatically. The --timeout option uses parseInt as a custom coercion function, turning the string "300000" into the number 300000 before the action runs. No manual Number() calls.
The shebang line #!/usr/bin/env node makes the file directly executable on Unix. Run chmod +x deploy.mjs and your team calls ./deploy.mjs api instead of node deploy.mjs api.
Exit codes: the most ignored contract
An exit code is the only signal a parent process (CI runner, shell script, your terminal) gets about whether a command succeeded. Zero means success. Non-zero means failure. Everything else is noise.
Node.js defaults to exit code 0 for a clean exit and 1 for an uncaught exception. That is not enough for production-grade tooling. You need distinct exit codes for different failure modes so that CI pipelines and wrapper scripts can branch on the reason.
const ExitCode = {
SUCCESS: 0,
USAGE_ERROR: 1, // bad arguments or missing required flags
RUNTIME_ERROR: 2, // the operation itself failed
TIMEOUT: 3, // the operation exceeded the deadline
DEPENDENCY_ERROR: 4, // a prerequisite (auth, network, config) is missing
} as const;
async function main() {
try {
const result = await deploy(service, options);
if (!result.succeeded) {
console.error(`Deploy failed: ${result.reason}`);
process.exit(ExitCode.RUNTIME_ERROR);
}
console.log('Deploy succeeded');
process.exit(ExitCode.SUCCESS);
} catch (err) {
if (err.code === 'ENOTFOUND' || err.code === 'ECONNREFUSED') {
console.error('Network error: cannot reach the deployment API');
process.exit(ExitCode.DEPENDENCY_ERROR);
}
console.error(`Unexpected error: ${err instanceof Error ? err.message : err}`);
process.exit(ExitCode.RUNTIME_ERROR);
}
}
The key rule: every code path calls process.exit() with an explicit code. Do not rely on the default zero. An early return from main() that the author forgot about is a silent success that masks a skipped operation.
Document the exit codes in your --help output or in a companion README:
Exit codes:
0 Success
1 Usage error (bad arguments)
2 Runtime error (operation failed)
3 Timeout
4 Dependency error (network, auth, config)
When CI runs deploy api --timeout 600000, it can check $? to decide whether to retry, alert, or fail the pipeline.
Output: the human-machine contract
Most CLI tools print text to stdout for humans to read. That works until someone needs to parse the output in a script. Human-oriented output is fragile as a machine interface. A single log line addition changes the column count in every downstream awk pipeline.
The fix is two output modes: human-friendly by default, structured JSON with --json for scripting.
interface DeployResult {
service: string;
environment: string;
tag: string;
url: string;
durationMs: number;
success: boolean;
error?: string;
}
interface CLIOptions {
environment: string;
skipTests: boolean;
tag: string;
timeout: number;
json?: boolean;
}
function output(result: DeployResult, options: CLIOptions): void {
if (options.json) {
console.log(JSON.stringify(result, null, 2));
return;
}
if (result.success) {
console.log(`Deployed ${result.service} to ${result.environment} in ${result.durationMs}ms`);
console.log(` URL: ${result.url}`);
console.log(` Tag: ${result.tag}`);
} else {
console.error(`Deploy failed: ${result.error}`);
}
}
The JSON mode is not an afterthought. It is the primary interface for CI. A GitHub Actions step that calls your tool with --json can use fromJSON(steps.deploy.outputs.result) directly in the workflow YAML.
Apply the same discipline to error output. Errors go to stderr (console.error), not stdout. Structured errors in JSON mode follow a consistent shape:
if (options.json) {
console.error(JSON.stringify({
error: true,
code: 'DEPLOY_TIMEOUT',
message: `Deploy did not complete within ${options.timeout}ms`,
service,
environment: options.environment,
durationMs,
}));
}
This lets a CI step parse both success and failure output from the same JSON pipe.
Progress indicators: the silent courtesy
A CLI tool that prints nothing for 30 seconds feels broken. Every second of silence makes the user wonder if it hung. The fix is a progress indicator that works in both terminal and non-terminal (CI) contexts.
process.stderr.isTTY tells you whether stderr is connected to a terminal or piped to a file. Show a spinner in terminal mode. Show nothing (or a simple timestamped log) in non-terminal mode.
function createSpinner(text: string) {
if (!process.stderr.isTTY) {
// Non-interactive: log and return a no-op
console.error(`[${new Date().toISOString()}] ${text}...`);
return { update: () => {}, stop: () => {} };
}
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
let i = 0;
const timer = setInterval(() => {
process.stderr.write(`\r${frames[i]} ${text}`);
i = (i + 1) % frames.length;
}, 80);
return {
update(newText: string) {
text = newText;
},
stop(finalText?: string) {
clearInterval(timer);
process.stderr.write(`\r${finalText || text}\n`);
},
};
}
Usage is straightforward:
const spinner = createSpinner('Running tests');
try {
const passed = await runTests();
spinner.stop(passed ? 'Tests passed' : 'Tests failed');
if (!passed) process.exit(ExitCode.RUNTIME_ERROR);
} catch (err) {
spinner.stop('Tests errored');
throw err;
}
The spinner writes to stderr, so it never contaminates the stdout stream. If someone pipes stdout to a file, they get clean data. If they run interactively, they get a visual indicator that the tool is alive.
Configuration the right way
Hardcoding values in the tool or requiring every flag at the command line is painful. The standard pattern is: command-line flags override config file values, which override defaults.
import { readFileSync, existsSync } from 'node:fs';
import { resolve } from 'node:path';
type Config = {
environment: string;
timeout: number;
dockerRegistry: string;
slackWebhook?: string;
requiredApprovals: number;
};
function loadConfig(options: { config?: string }): Partial<Config> {
const configPath = options.config || resolve(process.cwd(), 'deploy.config.json');
if (!existsSync(configPath)) return {};
try {
const raw = readFileSync(configPath, 'utf-8');
return JSON.parse(raw);
} catch (err) {
console.error(`Warning: could not read config at ${configPath}: ${err}`);
return {};
}
}
function mergeConfig(
fileConfig: Partial<Config>,
options: Record<string, unknown>
): Config {
return {
environment: (options.environment || fileConfig.environment) as string ?? 'staging',
timeout: (options.timeout || fileConfig.timeout) as number ?? 300_000,
dockerRegistry: (options.dockerRegistry || fileConfig.dockerRegistry) as string ?? 'ghcr.io/myorg',
slackWebhook: (options.slackWebhook || fileConfig.slackWebhook) as string | undefined,
requiredApprovals: (options.requiredApprovals || fileConfig.requiredApprovals) as number ?? 1,
};
}
The precedence is clear: the --environment flag on the command line beats whatever is in deploy.config.json. The config file beats the hardcoded defaults. This lets the team commit a baseline config to the repo and override per-invocation without editing files.
Support --config to point at a different file, and document the config file format in the README next to the CLI flags.
Testing CLI tools
CLI tools are notoriously undertested because running a subprocess in tests feels heavy. But the core logic (argument parsing, result formatting, exit code decisions) is just functions. Extract it from the action handler and test those functions directly.
// deploy.ts - extract the logic
export async function deployService(
service: string,
options: CLIOptions
): Promise<DeployResult> {
// ... pure logic, no process.exit, no console.log ...
return { service, environment: options.environment, tag: options.tag, ... };
}
// cli.ts - wire it up with Commander
program.action(async (service, options) => {
const result = await deployService(service, options);
output(result, options);
process.exit(result.success ? ExitCode.SUCCESS : ExitCode.RUNTIME_ERROR);
});
Now the test file does not need to spawn a child process:
import { describe, it, expect } from 'vitest';
import { deployService } from './deploy';
describe('deployService', () => {
it('returns a success result for a valid deployment', async () => {
const result = await deployService('api', {
environment: 'staging',
tag: 'abc123',
skipTests: true,
timeout: 60_000,
});
expect(result.success).toBe(true);
expect(result.service).toBe('api');
expect(result.environment).toBe('staging');
});
it('rejects an unknown service name', async () => {
const result = await deployService('nonexistent', { ...defaultOptions });
expect(result.success).toBe(false);
expect(result.error).toContain('Unknown service');
});
});
For integration tests that verify the full tool works as a subprocess, use execa and test the exit code and stdout together:
import { execa } from 'execa';
import { resolve } from 'node:path';
const cli = resolve(__dirname, '../dist/cli.mjs');
describe('CLI integration', () => {
it('prints usage with --help', async () => {
const { stdout, exitCode } = await execa(cli, ['--help']);
expect(exitCode).toBe(0);
expect(stdout).toContain('Usage:');
expect(stdout).toContain('deploy');
});
it('exits with code 1 for missing required argument', async () => {
try {
await execa(cli, []);
} catch (err) {
expect(err.exitCode).toBe(1);
return;
}
expect.fail('Should have thrown');
});
it('outputs JSON with --json flag', async () => {
const { stdout, exitCode } = await execa(cli, [
'api',
'--json',
'--skip-tests',
]);
expect(exitCode).toBe(0);
const parsed = JSON.parse(stdout);
expect(parsed.service).toBe('api');
});
});
The integration tests are slower (they compile and spawn a subprocess), so run them separately from the unit test suite. One integration test per major flag combination is enough.
Distributing the tool
Your team should be able to install and update the tool with a single command. There are three good options.
Option 1: npm package (scoped). Publish to a private npm registry or GitHub Packages. Your team installs with npm install -g @yourorg/deploy-tool. Versioning follows semver. CI installs whatever tag the pipeline defines.
Option 2: npx. If the package is public or scoped to an org that your teammates are authenticated against, npx @yourorg/deploy-tool downloads and runs the latest version without explicit installation. This is the lowest-friction option for tools used infrequently.
Option 3: standalone binary. Use pkg or sea (Node.js single-executable applications) to produce a binary that does not require Node.js at runtime. This is the right choice when your team includes non-Node developers or when the tool runs in a minimal Docker container.
For the standalone binary route, Node.js 20+ has a built-in single-executable application feature:
// package.json
{
"name": "@yourorg/deploy-tool",
"type": "module",
"bin": {
"deploy": "./dist/cli.mjs"
},
"exports": {
".": "./dist/deploy.mjs"
}
}
Document the install command in the project README right at the top, not at the bottom:
npm install -g @yourorg/deploy-tool
# or run without installing:
npx @yourorg/deploy-tool --help
The complete checklist
Before you ship your CLI tool, verify these seven things.
-
--helpoutput is complete. Every argument, option, default, and exit code is documented. A teammate who has never used the tool should be able to run it from the help text alone. -
Errors always go to stderr.
console.errorfor errors,console.logfor output. No exceptions. -
--jsonmode exists. All structured output is available as JSON. CI pipelines should never need to parse colored text. -
Exit codes are deliberate. Every failure mode has a unique code. The codes are documented.
-
Configuration has a clear precedence. Command-line flags override config file values, which override defaults. The tool tells you which config file it loaded (or that it found none).
-
Progress indicators respect TTY. Spinners and progress bars only show in interactive terminals. In CI, the tool logs timestamped progress to stderr.
-
The core logic is unit-testable. Argument parsing, output formatting, and business logic are separated from
process.exitand side effects.
What to do differently next time
The patterns in this post are not framework-specific. Commander.js is the most popular argument parser, but the same discipline applies with yargs, meow, or Node’s built-in parseArgs. The JSON output mode and exit code contract are framework-agnostic.
If you maintain an existing CLI tool today, the single highest-ROI change you can make is adding --json output. It costs one code path and unlocks every CI integration your team will want for the next two years. Next, add explicit exit codes. The difference between “the tool failed” and “the tool failed because the network was unreachable” saves an engineer 15 minutes of log diving every time it matters.
The team with well-built CLI tools ships faster because their workflows are automated, scriptable, and documented in the tool itself instead of a stale wiki page.
A note from Yojji
Building internal tooling that your team trusts enough to automate and script requires the same engineering discipline as building customer-facing APIs: clear contracts, consistent error handling, and a focus on the integration experience. Yojji’s development teams regularly build CLI tooling and automation pipelines for clients alongside their core product work, applying the same patterns of argument validation, structured output, and CI-friendly design to reduce operational overhead and keep delivery fast.
Yojji is an international custom software development company founded in 2016, with teams across Europe, the US, and the UK. They specialize in full-cycle product engineering, DevOps automation, and the kind of tooling that makes teams more effective without adding complexity.