Skip to content

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.

6 min read
Terminal showing a custom CLI tool with colored output and interactive prompts

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.

jsonjson
{
  "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"
  }
}
tstypescript
#!/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.

tstypescript
// 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);
  });
shbash
# 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 command

Interactive 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).

tstypescript
// 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;
}
shbash
# 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 pnpm

Colored Output and Spinners

Terminal output should communicate progress clearly. Use colors for status, spinners for long operations, and structured output for results.

tstypescript
// 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;
  }
}
tstypescript
// 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.

tstypescript
// ❌ 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;
}
tstypescript
// 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.

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

shbash
# 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
jsonjson
// 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

  1. Commander handles argument parsing — define arguments, options, and subcommands declaratively with auto-generated help
  2. Fall back to interactive prompts — use inquirer when users do not provide all arguments, making the CLI script-friendly and human-friendly
  3. Use spinners for long operations — ora provides visual feedback during installation and network requests
  4. Catch errors gracefully — translate system errors (EEXIST, EACCES) into human-readable messages
  5. Test via child process execution — invoke the CLI binary and assert on stdout, exit codes, and file system effects
  6. Publish with minimal files — include only dist and package.json in the npm package
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX