DEV Community

Alex Chen
Alex Chen

Posted on

Building a CLI Tool with Node.js

Building a CLI Tool with Node.js in 2026: From Zero to npm Publish

I built 5 CLI tools last year. Here's the exact setup I use every time.

Why Node.js for CLIs?

  • One language for your whole stack (frontend + backend + CLI)
  • npm ecosystem — thousands of packages to leverage
  • Cross-platform — works on Mac, Linux, Windows
  • Fast enough — V8 is fast, startup time is acceptable
  • Familiar — if you know JS/TS, you can build CLIs

The Setup

mkdir my-cli && cd my-cli
npm init -y

# Core dependencies (my go-to stack):
npm install commander inquirer chalk ora figlet update-notifier
npm install -D typescript @types/node tsx esbuild
Enter fullscreen mode Exit fullscreen mode
Package Purpose
commander Argument parsing, subcommands
inquirer Interactive prompts
chalk Terminal colors
ora Spinners/loading indicators
figlet ASCII art banners
update-notifier Notify users of updates

Project Structure

my-cli/
├── src/
│   ├── index.ts          # Entry point (bin)
│   ├── commands/
│   │   ├── init.ts       # my-cli init
│   │   ├── deploy.ts     # my-cli deploy
│   │   └── status.ts     # my-cli status
│   ├── utils/
│   │   ├── logger.ts     # Styled console output
│   │   ├── config.ts     # Config file handling
│   │   └── api.ts        # API calls
│   └── types.ts          # Shared types
├── package.json
├── tsconfig.json
└── README.md
Enter fullscreen mode Exit fullscreen mode

The Code

package.json

{
  "name": "@armorbreak/my-cli",
  "version": "1.0.0",
  "description": "My awesome CLI tool",
  "type": "module",
  "bin": {
    "my-cli": "./dist/index.js"
  },
  "engines": {
    "node": ">=18"
  },
  "files": [
    "dist/"
  ],
  "keywords": ["cli", "my-tool"],
  "license": "MIT"
}
Enter fullscreen mode Exit fullscreen mode

src/index.ts

#!/usr/bin/env node

import { Command } from 'commander';
import chalk from 'chalk';
import figlet from 'figlet';
import { initCommand } from './commands/init.js';
import { { deployCommand } from './commands/deploy.js';
import { statusCommand } from './commands/status.js';

// ASCII art banner
console.log(
  chalk.cyan(figlet.textSync('MY-CLI', { font: 'Standard' }))
);

const program = new Command()
  .name('my-cli')
  .description('My awesome CLI tool')
  .version('1.0.0')
  .helpOption('-h, --help', 'Show help');

// Register commands
program.addCommand(initCommand);
program.addCommand(deployCommand);
program.addCommand(statusCommand);

// Parse and execute
program.parse();
Enter fullscreen mode Exit fullscreen mode

A Real Command: init

// src/commands/init.ts
import { Command } from 'commander';
import inquirer from 'inquirer';
import chalk from 'chalk';
import ora from 'ora';
import fs from 'fs/promises';
import path from 'path';

export const initCommand = new Command('init')
  .description('Initialize a new project')
  .option('-n, --name <name>', 'Project name')
  .option('-t, --template <template>', 'Template to use', 'basic')
  .action(async (options) => {
    console.log(chalk.yellow('\n🚀 Creating new project...\n'));

    // Interactive prompts (if options not provided)
    const answers = options.name 
      ? { name: options.name, template: options.template }
      : await inquirer.prompt([
          {
            type: 'input',
            name: 'name',
            message: 'Project name:',
            validate: (input) => input.length > 0 ? true : 'Name is required',
          },
          {
            type: 'list',
            name: 'template',
            message: 'Choose a template:',
            choices: ['basic', 'api', 'fullstack', 'cli'],
          },
        ]);

    const targetDir = path.resolve(process.cwd(), answers.name);

    // Create directory
    const spinner = ora('Creating project structure').start();

    try {
      // Check if dir exists
      await fs.access(targetDir);
      spinner.fail(chalk.red(`Directory "${answers.name}" already exists`));
      process.exit(1);
    } catch {
      // Dir doesn't exist, good
    }

    await fs.mkdir(targetDir, { recursive: true });

    // Create files based on template
    const files = getTemplateFiles(answers.template);
    for (const [filePath, content] of Object.entries(files)) {
      const fullPath = path.join(targetDir, filePath);
      await fs.mkdir(path.dirname(fullPath), { recursive: true });
      await fs.writeFile(fullPath, content);
    }

    spinner.succeed(chalk.green('Project created!'));

    console.log(chalk.cyan(`\n  cd ${answers.name}`));
    console.log(chalk.cyan('  npm install\n'));
  });

function getTemplateFiles(template: string): Record<string, string> {
  switch (template) {
    case 'basic':
      return {
        'package.json': JSON.stringify({
          name: 'my-project',
          version: '1.0.0',
          type: 'module',
          scripts: { start: 'node index.js' },
        }, null, 2),
        'index.js': '// Entry point\nconsole.log("Hello!");\n',
        '.gitignore': 'node_modules/\ndist/\n.env\n',
      };
    case 'api':
      return {
        'package.json': JSON.stringify({
          name: 'my-api',
          version: '1.0.0',
          type: 'module',
          dependencies: { express: '^4.21.0' },
          scripts: { start: 'node server.js' },
        }, null, 2),
        'server.js': [
          'import express from "express";',
          'const app = express();',
          'app.get("/", (req, res) => res.json({ status: "ok" }));',
          'app.listen(3000, () => console.log("Running on :3000"));',
        ].join('\n'),
      };
    default:
      return getTemplateFiles('basic');
  }
}
Enter fullscreen mode Exit fullscreen mode

Logger Utility

// src/utils/logger.ts
import chalk from 'chalk';

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.log(chalk.red('') + msg),
  title: (msg: string) => console.log('\n' + chalk.bold.white.bgBlue(` ${msg} `)),
  step: (num: number, msg: string) => 
    console.log(`  ${chalk.cyan(num + '.')} ${msg}`),
  divider: () => console.log(chalk.gray(''.repeat(40))),
};
Enter fullscreen mode Exit fullscreen mode

Build & Publish

Build Script

{
  "scripts": {
    "build": "esbuild src/index.ts --bundle --platform=node --target=node18 --outfile=dist/index.js --format=esm --banner:#!/usr/bin/env\\nnode",
    "dev": "tsx watch src/index.ts",
    "prepublishOnly": "npm run build"
  }
}
Enter fullscreen mode Exit fullscreen mode
# Build
npm run build

# Test locally
node dist/index.js --help
# Or link globally:
npm link
my-cli --help
Enter fullscreen mode Exit fullscreen mode

Publishing to npm

# 1. Login
npm login

# 2. Check availability
npm view @armorbreak/my-cli 2>&1 || echo "Available!"

# 3. Dry run (recommended first)
npm publish --dry-run

# 4. Publish!
npm publish --access public
Enter fullscreen mode Exit fullscreen mode

After Publishing

# Users can now:
npm install -g @armorbreak/my-cli
my-cli --help
Enter fullscreen mode Exit fullscreen mode

Pro Tips

1. Update Notifier

import updateNotifier from 'update-notifier';

const pkg = import.meta.require('../package.json');
const notifier = updateNotifier({ pkg });

if (notifier.update) {
  console.log(
    chalk.yellow(`\n  Update available: ${pkg.version}${notifier.update.latest}`)
  );
}
Enter fullscreen mode Exit fullscreen mode

2. Error Handling

// Wrap main logic in error handler
try {
  program.parse();
} catch (err) {
  console.error(chalk.red('Fatal error:'), err.message);
  process.exit(1);
}

// Handle unhandled rejections
process.on('unhandledRejection', (reason) => {
  console.error(chalk.red('Unhandled rejection:'), reason);
  process.exit(1);
});
Enter fullscreen mode Exit fullscreen mode

3. Debug Mode

// Add --debug flag
if (opts.debug) {
  process.env.DEBUG = '*';
  // Enable verbose logging
}
Enter fullscreen mode Exit fullscreen mode

4. Config File Support

import { readFileSync, existsSync } from 'fs';
import { homedir } from 'os';
import { join } from 'path';

function loadConfig() {
  const configPath = join(homedir(), '.my-cli-config.json');

  if (!existsSync(configPath)) {
    return createDefaultConfig(configPath);
  }

  return JSON.parse(readFileSync(configPath, 'utf8'));
}

function createDefaultConfig(path: string) {
  const defaults = {
    defaultTemplate: 'basic',
    outputDir: process.cwd(),
    confirmBeforeDeploy: true,
  };

  writeFileSync(path, JSON.stringify(defaults, null, 2));
  return defaults;
}
Enter fullscreen mode Exit fullscreen mode

What Makes a Great CLI?

Feature Why It Matters
--help flag Every command should have one
Colored output Makes it scannable at a glance
Progress spinners Shows it's working, not stuck
Exit codes 0 = success, 1 = error (for scripting)
Tab completion Bash/Zsh completion files
Update notifier Users stay current
Good error messages Tell them WHAT went wrong and HOW to fix it
Config file Don't make them repeat preferences

What CLI tools do you use daily? Drop your favorites.

Follow @armorbreak for more Node.js tooling content.

Top comments (0)