Building a CLI Tool with Node.js: Basics to Distribution
A complete guide to building, testing and distributing a Node.js CLI tool: argument parsing, interactive prompts, output formatting and npm publishing.

The best developer tools are CLI tools. They compose with pipes, automate with scripts, and run in CI without a browser. Building your own CLI in Node.js is straightforward once you know the patterns — argument parsing, interactive prompts, colored output, and error handling all have mature libraries.
This guide builds a real CLI from scratch: a project scaffolding tool that creates directories, writes config files, and installs dependencies.
Project Setup
A CLI tool is just a Node.js script with a special header (shebang) and a bin field in package.json.
{
"name": "create-project",
"version": "1.0.0",
"bin": {
"create-project": "./dist/index.js"
},
"type": "module",
"scripts": {
"build": "tsc",
"dev": "tsx src/index.ts"
},
"dependencies": {
"commander": "^11.0.0",
"chalk": "^5.3.0",
"inquirer": "^9.2.0",
"ora": "^7.0.0"
},
"devDependencies": {
"typescript": "^5.3.0",
"tsx": "^4.0.0",
"@types/inquirer": "^9.0.0"
}
}#!/usr/bin/env node
// src/index.ts — the entry point
// The shebang line tells the OS to run this with Node.js
import { program } from 'commander';
import chalk from 'chalk';
program
.name('create-project')
.description('Scaffold a new project with sensible defaults')
.version('1.0.0');
program
.argument('<name>', 'project name')
.option('-t, --template <type>', 'project template', 'node')
.option('--no-git', 'skip git initialization')
.option('--no-install', 'skip dependency installation')
.action(async (name, options) => {
console.log(chalk.blue(`Creating project: ${name}`));
await createProject(name, options);
});
program.parse();Running npm link during development makes the CLI available globally. Running npm publish makes it available to everyone.
Argument Parsing with Commander
Commander handles arguments, options, flags, and subcommands. It generates help text automatically from your definitions.
// src/commands/init.ts
import { Command } from 'commander';
export const initCommand = new Command('init')
.description('Initialize a new project in the current directory')
.argument('<name>', 'project name (used for package.json and directory)')
.option('-t, --template <type>', 'project template', 'node')
.option('-p, --package-manager <pm>', 'package manager', 'npm')
.option('--typescript', 'use TypeScript', true)
.option('--no-typescript', 'use plain JavaScript')
.option('--dry-run', 'show what would be created without writing files')
.addHelpText('after', `
Templates:
node Basic Node.js project with Express
react React app with Vite
api REST API with Fastify
cli CLI tool scaffold
Examples:
$ create-project init my-app
$ create-project init my-api --template api --package-manager pnpm
$ create-project init my-cli --template cli --dry-run
`)
.action(async (name, options) => {
await handleInit(name, options);
});# Auto-generated help output
$ create-project init --help
Usage: create-project init [options] <name>
Initialize a new project in the current directory
Arguments:
name project name (used for package.json and directory)
Options:
-t, --template <type> project template (default: "node")
-p, --package-manager <pm> package manager (default: "npm")
--typescript use TypeScript (default: true)
--no-typescript use plain JavaScript
--dry-run show what would be created without writing files
-h, --help display help for commandInteractive Prompts
When arguments are not provided, fall back to interactive prompts. This makes the CLI work both for scripts (all flags provided) and for humans (guided prompts).
// src/prompts.ts
import inquirer from 'inquirer';
interface ProjectOptions {
name: string;
template: string;
packageManager: string;
typescript: boolean;
git: boolean;
}
export async function promptForMissing(
partial: Partial<ProjectOptions>
): Promise<ProjectOptions> {
const questions = [];
if (!partial.name) {
questions.push({
type: 'input',
name: 'name',
message: 'Project name:',
validate: (input: string) => {
if (!/^[a-z0-9-]+$/.test(input)) {
return 'Name must be lowercase alphanumeric with hyphens';
}
return true;
},
});
}
if (!partial.template) {
questions.push({
type: 'list',
name: 'template',
message: 'Select a template:',
choices: [
{ name: 'Node.js + Express', value: 'node' },
{ name: 'React + Vite', value: 'react' },
{ name: 'REST API + Fastify', value: 'api' },
{ name: 'CLI Tool', value: 'cli' },
],
});
}
if (!partial.packageManager) {
questions.push({
type: 'list',
name: 'packageManager',
message: 'Package manager:',
choices: ['npm', 'pnpm', 'yarn', 'bun'],
});
}
const answers = await inquirer.prompt(questions);
return { ...partial, ...answers } as ProjectOptions;
}# Without arguments — interactive mode
$ create-project init
? Project name: my-app
? Select a template: Node.js + Express
? Package manager: pnpm
# With arguments — no prompts, script-friendly
$ create-project init my-app -t node -p pnpmColored Output and Spinners
Terminal output should communicate progress clearly. Use colors for status, spinners for long operations, and structured output for results.
// src/ui.ts
import chalk from 'chalk';
import ora from 'ora';
export const log = {
info: (msg: string) => console.log(chalk.blue('ℹ'), msg),
success: (msg: string) => console.log(chalk.green('✓'), msg),
warn: (msg: string) => console.log(chalk.yellow('⚠'), msg),
error: (msg: string) => console.error(chalk.red('✗'), msg),
step: (num: number, total: number, msg: string) =>
console.log(chalk.dim(`[${num}/${total}]`), msg),
};
export async function withSpinner<T>(
message: string,
fn: () => Promise<T>
): Promise<T> {
const spinner = ora(message).start();
try {
const result = await fn();
spinner.succeed();
return result;
} catch (error) {
spinner.fail();
throw error;
}
}// src/create.ts — using the UI helpers
import { log, withSpinner } from './ui.js';
import { mkdir, writeFile } from 'fs/promises';
import { execSync } from 'child_process';
async function createProject(name: string, options: ProjectOptions) {
log.info(`Creating ${chalk.bold(name)} with template ${options.template}`);
console.log();
// Step 1: Create directory structure
log.step(1, 4, 'Creating project structure...');
await mkdir(`${name}/src`, { recursive: true });
await mkdir(`${name}/tests`, { recursive: true });
log.success('Project structure created');
// Step 2: Write config files
log.step(2, 4, 'Writing configuration files...');
await writeFile(`${name}/package.json`, generatePackageJson(name, options));
await writeFile(`${name}/tsconfig.json`, generateTsConfig());
log.success('Configuration files written');
// Step 3: Initialize git
if (options.git) {
log.step(3, 4, 'Initializing git repository...');
execSync('git init', { cwd: name, stdio: 'ignore' });
await writeFile(`${name}/.gitignore`, 'node_modules\ndist\n.env\n');
log.success('Git repository initialized');
} else {
log.step(3, 4, chalk.dim('Skipping git initialization'));
}
// Step 4: Install dependencies
if (options.install) {
await withSpinner('Installing dependencies...', async () => {
const cmd = options.packageManager === 'npm'
? 'npm install'
: `${options.packageManager} install`;
execSync(cmd, { cwd: name, stdio: 'ignore' });
});
}
console.log();
log.success(chalk.bold('Project created successfully!'));
console.log();
console.log(` ${chalk.cyan('cd')} ${name}`);
console.log(` ${chalk.cyan(options.packageManager)} run dev`);
console.log();
}Error Handling for CLI
CLI errors need clear messages, not stack traces. Catch errors at the top level and format them for human consumption.
// ❌ Unhandled errors dump stack traces to the terminal
program.action(async (name) => {
await createProject(name); // throws Error: EEXIST
});
// Error: EEXIST: file already exists, mkdir 'my-app'
// at Object.mkdirSync (node:fs:...)
// at createProject (/src/create.ts:15:3)
// ...20 more lines nobody reads
// ✅ Graceful error handling with actionable messages
program.action(async (name) => {
try {
await createProject(name);
} catch (error) {
if (isNodeError(error) && error.code === 'EEXIST') {
log.error(`Directory "${name}" already exists.`);
log.info(`Use a different name or remove the existing directory.`);
process.exit(1);
}
if (isNodeError(error) && error.code === 'EACCES') {
log.error(`Permission denied. Cannot write to this directory.`);
process.exit(1);
}
// Unknown error — show message without stack trace
log.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
});
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && 'code' in error;
}// Global unhandled rejection handler — safety net
process.on('unhandledRejection', (reason) => {
log.error('An unexpected error occurred');
if (reason instanceof Error) {
log.error(reason.message);
}
process.exit(1);
});Testing CLI Commands
Test CLI tools by invoking them as child processes and asserting on stdout, stderr, and exit codes.
// tests/cli.test.ts
import { execSync } from 'child_process';
import { existsSync, rmSync } from 'fs';
const CLI = 'tsx src/index.ts';
describe('create-project CLI', () => {
afterEach(() => {
// Cleanup created directories
if (existsSync('test-project')) {
rmSync('test-project', { recursive: true });
}
});
it('creates a project with default options', () => {
const output = execSync(
`${CLI} init test-project --no-install`,
{ encoding: 'utf-8' }
);
expect(output).toContain('Project created successfully');
expect(existsSync('test-project/package.json')).toBe(true);
expect(existsSync('test-project/src')).toBe(true);
expect(existsSync('test-project/.gitignore')).toBe(true);
});
it('shows error for existing directory', () => {
execSync(`mkdir test-project`);
try {
execSync(`${CLI} init test-project`, { encoding: 'utf-8' });
fail('Should have thrown');
} catch (error: any) {
expect(error.stderr || error.stdout).toContain('already exists');
expect(error.status).toBe(1);
}
});
it('supports dry run mode', () => {
const output = execSync(
`${CLI} init test-project --dry-run`,
{ encoding: 'utf-8' }
);
expect(output).toContain('Dry run');
expect(existsSync('test-project')).toBe(false);
});
});Test the command-line interface from the outside. Internal function tests are unit tests. CLI tests verify that argument parsing, file creation, and output formatting all work together.
Publishing to npm
Once the tool works, publish it so anyone can use it with npx create-project or npm install -g create-project.
# Build the TypeScript source
npm run build
# Ensure the shebang is in the compiled output
head -1 dist/index.js
# #!/usr/bin/env node
# Test the built version
node dist/index.js init test-project --dry-run
# Login and publish
npm login
npm publish
# Users can now run:
npx create-project init my-app
# or
npm install -g create-project
create-project init my-app// package.json fields that matter for publishing
{
"name": "create-project",
"version": "1.0.0",
"bin": { "create-project": "./dist/index.js" },
"files": ["dist"],
"engines": { "node": ">=18" },
"keywords": ["cli", "scaffold", "generator"],
"repository": { "type": "git", "url": "https://github.com/user/create-project" }
}The files array ensures only the compiled dist directory is published — not tests, source files, or development configs.
Key Takeaways
- Commander handles argument parsing — define arguments, options, and subcommands declaratively with auto-generated help
- Fall back to interactive prompts — use
inquirerwhen users do not provide all arguments, making the CLI script-friendly and human-friendly - Use spinners for long operations —
oraprovides visual feedback during installation and network requests - Catch errors gracefully — translate system errors (EEXIST, EACCES) into human-readable messages
- Test via child process execution — invoke the CLI binary and assert on stdout, exit codes, and file system effects
- Publish with minimal files — include only
distandpackage.jsonin the npm package


