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
| 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
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"
}
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();
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');
}
}
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))),
};
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"
}
}
# Build
npm run build
# Test locally
node dist/index.js --help
# Or link globally:
npm link
my-cli --help
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
After Publishing
# Users can now:
npm install -g @armorbreak/my-cli
my-cli --help
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}`)
);
}
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);
});
3. Debug Mode
// Add --debug flag
if (opts.debug) {
process.env.DEBUG = '*';
// Enable verbose logging
}
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;
}
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)