Building a CLI Tool from Scratch with Node.js
Build a production-quality Node.js CLI: argument parsing, interactive prompts, progress indicators, error handling and npm distribution, done properly.

Developer tools live and die by their CLI experience. A tool that's easy to install, has helpful error messages, and provides clear feedback gets adopted. One that dumps stack traces, requires memorizing cryptic flags, and produces no output during long operations gets abandoned.
Building a good CLI tool with Node.js is surprisingly straightforward once you know the patterns. The ecosystem provides excellent libraries for argument parsing, interactive prompts, and terminal rendering. The challenge isn't the technology—it's the design decisions about how your tool communicates with its user.
Project Structure and Setup
A CLI tool is a Node.js project with a bin entry point. The binary file runs with #!/usr/bin/env node and becomes available as a command after installation.
{
"name": "deploy-tool",
"version": "1.0.0",
"description": "Deploy applications to staging and production",
"bin": {
"deploy": "./dist/cli.js"
},
"files": ["dist"],
"type": "module",
"scripts": {
"build": "tsc",
"dev": "tsx src/cli.ts"
},
"dependencies": {
"commander": "^12.0.0",
"chalk": "^5.3.0",
"ora": "^8.0.0",
"prompts": "^2.4.2"
},
"devDependencies": {
"typescript": "^5.4.0",
"tsx": "^4.7.0",
"@types/prompts": "^2.4.9"
}
}// src/cli.ts — the entry point
#!/usr/bin/env node
import { Command } from 'commander';
import { deployCommand } from './commands/deploy.js';
import { statusCommand } from './commands/status.js';
import { configCommand } from './commands/config.js';
const program = new Command();
program
.name('deploy')
.description('Deploy applications to staging and production')
.version('1.0.0');
program
.command('push')
.description('Deploy to a target environment')
.argument('<environment>', 'Target environment (staging|production)')
.option('-t, --tag <tag>', 'Docker image tag to deploy')
.option('--dry-run', 'Show what would happen without deploying')
.option('--no-confirm', 'Skip confirmation prompt')
.action(deployCommand);
program
.command('status')
.description('Show deployment status')
.argument('[environment]', 'Environment to check', 'all')
.action(statusCommand);
program
.command('config')
.description('Manage configuration')
.addCommand(
new Command('set')
.argument('<key>', 'Configuration key')
.argument('<value>', 'Configuration value')
.action(configCommand.set)
)
.addCommand(
new Command('get')
.argument('<key>', 'Configuration key')
.action(configCommand.get)
);
program.parse();Interactive Prompts and Confirmation
Good CLI tools confirm destructive operations and guide users through complex inputs with interactive prompts.
// src/commands/deploy.ts
import chalk from 'chalk';
import prompts from 'prompts';
import ora from 'ora';
interface DeployOptions {
tag?: string;
dryRun?: boolean;
confirm?: boolean;
}
export async function deployCommand(
environment: string,
options: DeployOptions
): Promise<void> {
// Validate environment
const validEnvs = ['staging', 'production'];
if (!validEnvs.includes(environment)) {
console.error(
chalk.red(`Error: Invalid environment "${environment}"`)
);
console.error(
chalk.dim(`Valid environments: ${validEnvs.join(', ')}`)
);
process.exit(1);
}
// If no tag specified, prompt for one
let tag = options.tag;
if (!tag) {
const response = await prompts({
type: 'select',
name: 'tag',
message: 'Select a version to deploy',
choices: [
{ title: 'v2.4.1 (latest)', value: 'v2.4.1' },
{ title: 'v2.4.0', value: 'v2.4.0' },
{ title: 'v2.3.9', value: 'v2.3.9' },
],
});
if (!response.tag) {
console.log(chalk.yellow('Deployment cancelled.'));
process.exit(0);
}
tag = response.tag;
}
// Confirmation for production deploys
if (environment === 'production' && options.confirm !== false) {
const confirm = await prompts({
type: 'confirm',
name: 'value',
message: chalk.yellow(
`Deploy ${tag} to PRODUCTION? This affects live users.`
),
initial: false,
});
if (!confirm.value) {
console.log('Deployment cancelled.');
process.exit(0);
}
}
if (options.dryRun) {
console.log(chalk.cyan('DRY RUN — no changes will be made'));
console.log(`Would deploy ${tag} to ${environment}`);
return;
}
await executeDeploy(environment, tag);
}Progress Feedback and Spinners
Long-running operations need visual feedback. Silence makes users wonder if the tool is frozen.
// src/deploy/executor.ts
import ora from 'ora';
import chalk from 'chalk';
async function executeDeploy(
environment: string,
tag: string
): Promise<void> {
console.log(
chalk.bold(`\nDeploying ${chalk.cyan(tag)} to ${chalk.green(environment)}\n`)
);
const steps = [
{ label: 'Pulling Docker image', fn: pullImage },
{ label: 'Running health checks', fn: runHealthChecks },
{ label: 'Updating service', fn: updateService },
{ label: 'Waiting for rollout', fn: waitForRollout },
{ label: 'Verifying deployment', fn: verifyDeployment },
];
for (const step of steps) {
const spinner = ora(step.label).start();
try {
const result = await step.fn(environment, tag);
spinner.succeed(
`${step.label} ${chalk.dim(result.message ?? '')}`
);
} catch (error) {
spinner.fail(`${step.label} — ${(error as Error).message}`);
console.error(
chalk.red('\nDeployment failed. Rolling back...')
);
await rollback(environment);
process.exit(1);
}
}
console.log(
chalk.green.bold('\n✓ Deployment complete!\n')
);
console.log(
chalk.dim(` Environment: ${environment}`)
);
console.log(
chalk.dim(` Version: ${tag}`)
);
console.log(
chalk.dim(` Dashboard: https://deploy.internal/${environment}\n`)
);
}Error Handling That Helps
The difference between a tool developers love and one they hate is what happens when things go wrong.
// src/utils/errors.ts
import chalk from 'chalk';
// ❌ Bad error handling: dump the stack trace
process.on('uncaughtException', (error) => {
console.error(error); // Unreadable for users
process.exit(1);
});
// ✅ Good error handling: explain what went wrong and how to fix it
class CLIError extends Error {
constructor(
message: string,
public readonly hint?: string,
public readonly code?: string
) {
super(message);
}
}
function handleError(error: unknown): never {
if (error instanceof CLIError) {
console.error(chalk.red(`\nError: ${error.message}`));
if (error.hint) {
console.error(chalk.yellow(`\nHint: ${error.hint}`));
}
process.exit(1);
}
if (error instanceof Error) {
console.error(chalk.red(`\nError: ${error.message}`));
// Common patterns with actionable suggestions
if (error.message.includes('ECONNREFUSED')) {
console.error(
chalk.yellow('\nHint: Cannot connect to the deploy server.')
);
console.error(
chalk.yellow(' - Check if you\'re connected to the VPN')
);
console.error(
chalk.yellow(' - Run `deploy config get server-url` to verify')
);
}
if (error.message.includes('401') || error.message.includes('403')) {
console.error(
chalk.yellow('\nHint: Authentication failed.')
);
console.error(
chalk.yellow(' - Run `deploy config set token <your-token>`')
);
console.error(
chalk.yellow(' - Tokens expire after 24 hours')
);
}
// Show stack trace only with DEBUG flag
if (process.env.DEBUG) {
console.error(chalk.dim(`\n${error.stack}`));
} else {
console.error(
chalk.dim('\nRun with DEBUG=1 for full stack trace')
);
}
}
process.exit(1);
}
// Wrap the entire CLI
process.on('uncaughtException', handleError);
process.on('unhandledRejection', handleError);Testing CLI Commands
CLI tools need tests too. Test the command logic separately from the terminal interaction.
// src/commands/__tests__/deploy.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { deployCommand } from '../deploy.js';
// Mock external dependencies
vi.mock('prompts', () => ({
default: vi.fn(),
}));
vi.mock('ora', () => ({
default: () => ({
start: vi.fn().mockReturnThis(),
succeed: vi.fn().mockReturnThis(),
fail: vi.fn().mockReturnThis(),
}),
}));
describe('deploy command', () => {
beforeEach(() => {
vi.clearAllMocks();
// Prevent process.exit from actually exiting in tests
vi.spyOn(process, 'exit').mockImplementation(
(() => {}) as never
);
});
it('rejects invalid environments', async () => {
const consoleSpy = vi.spyOn(console, 'error');
await deployCommand('invalid-env', { tag: 'v1.0.0' });
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('Invalid environment')
);
expect(process.exit).toHaveBeenCalledWith(1);
});
it('skips confirmation with --no-confirm flag', async () => {
const prompts = (await import('prompts')).default;
await deployCommand('staging', {
tag: 'v1.0.0',
confirm: false,
});
expect(prompts).not.toHaveBeenCalled();
});
it('shows dry run output without deploying', async () => {
const consoleSpy = vi.spyOn(console, 'log');
await deployCommand('staging', {
tag: 'v1.0.0',
dryRun: true,
});
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('DRY RUN')
);
});
});Distribution and Installation
Making your CLI easy to install is the last step before adoption. npm makes global installation seamless.
# Users install your tool globally
npm install -g deploy-tool
# Or use npx for one-off usage
npx deploy-tool push staging --tag v2.4.1
# For team-internal tools, publish to a private registry
npm publish --registry https://npm.internal.company.com// Post-install message for first-time users
// package.json: "postinstall": "node dist/postinstall.js"
// src/postinstall.ts
import chalk from 'chalk';
console.log(chalk.cyan('\n Deploy Tool installed successfully!\n'));
console.log(' Get started:');
console.log(chalk.dim(' deploy config set token <your-token>'));
console.log(chalk.dim(' deploy push staging --tag latest'));
console.log(chalk.dim(' deploy status'));
console.log('');Key Takeaways
Structure CLI tools with a clear command hierarchy using libraries like Commander—top-level commands for major actions (push, status, config) with subcommands and flags for customization, so users can discover functionality through --help at any level. Interactive prompts for missing arguments and destructive operation confirmations make tools safe and discoverable—prompt for the deployment tag when the user doesn't specify one, always confirm production deployments, and support --no-confirm for automation scripts. Progress spinners and colored output transform the user experience from "is this tool frozen?" to clear step-by-step feedback—show what's happening during long operations, mark steps as succeeded or failed, and print a summary with relevant links when done. Write error messages that tell users what went wrong and how to fix it—pattern-match common errors like connection failures and auth issues to actionable hints, hide stack traces behind a DEBUG flag, and treat error messages as a user interface that deserves the same care as the happy path.


