Building a CLI Tool with Node.js: From Zero to npm
CLI tools are the best way to automate repetitive tasks. Here's how to build and publish one.
Why Build a CLI?
- Automate your daily workflows
- Share tools with your team (or the world)
- Learn Node.js streams, file system, process management
- Publish to npm — instant credibility + potential users
Step 1: Project Setup
mkdir my-cli && cd my-cli
npm init -y
# Install dependencies
npm install commander inquirer chalk ora
package.json essentials:
{
"name": "my-cli",
"version": "1.0.0",
"description": "My awesome CLI tool",
"bin": {
"my-cli": "./bin/cli.js"
},
"type": "module", // Use ES modules
"engines": {
"node": ">=18.0.0"
},
"files": [ # What gets published to npm
"bin/",
"src/"
],
"keywords": ["cli", "tool"],
"license": "MIT"
}
Step 2: The Entry Point
#!/usr/bin/env node
// bin/cli.js — Shebang line makes it executable
import { program } from 'commander';
import chalk from 'chalk';
import { init } from '../src/commands/init.js';
program
.name('my-cli')
.description('My awesome CLI tool')
.version('1.0.0');
// Register commands
program
.command('init')
.description('Initialize a new project')
.option('-n, --name <name>', 'Project name', 'my-app')
.option('-t, --template <template>', 'Template to use', 'basic')
.action(init);
// Default action if no command given
program.action(() => {
program.help();
});
program.parse();
Make it executable:
chmod +x bin/cli.js
Step 3: Your First Command
// src/commands/init.js
import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
import chalk from 'chalk';
import ora from 'ora';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export async function init(options) {
const spinner = ora('Creating project...').start();
const projectName = options.name;
const targetDir = path.resolve(process.cwd(), projectName);
try {
// Create directory
await fs.mkdir(targetDir, { recursive: true });
// Create package.json
const pkgJson = {
name: projectName,
version: '1.0.0',
type: 'module',
scripts: { start: 'node index.js' }
};
await fs.writeFile(
path.join(targetDir, 'package.json'),
JSON.stringify(pkgJson, null, 2)
);
// Create main file
await fs.writeFile(
path.join(targetDir, 'index.js'),
`console.log("Hello from ${projectName}!");\n`
);
// Create README
await fs.writeFile(
path.join(targetDir, 'README.md'),
`# ${projectName}\n\nGetting started:\n\n\`\`\`bash\nnpm install\nnpm start\n\`\`\`\n`
);
spinner.succeed(chalk.green('Project created!'));
console.log('\nNext steps:');
console.log(` cd ${projectName}`);
console.log(' npm install');
console.log(' npm start');
} catch (err) {
spinner.fail(chalk.red('Failed to create project'));
console.error(err.message);
process.exit(1);
}
}
Step 4: Interactive Prompts
// src/commands/create.js
import inquirer from 'inquirer';
import chalk from 'chalk';
export async function create() {
console.log(chalk.blue.bold('\n🚀 Create a new component\n'));
const answers = await inquirer.prompt([
{
type: 'input',
name: 'name',
message: 'Component name:',
validate: (input) => input.length > 0 || 'Name is required'
},
{
type: 'list',
name: 'type',
message: 'Component type:',
choices: ['Functional', 'Class-based', 'Hook'],
default: 'Functional'
},
{
type: 'checkbox',
name: 'features',
message: 'Features:',
choices: [
{ name: 'TypeScript support', value: 'ts' },
{ name: 'CSS Modules', value: 'css' },
{ name: 'Storybook story', value: 'storybook' },
{ name: 'Unit tests', value: 'test' }
]
},
{
type: 'confirm',
name: 'confirm',
message: 'Confirm creation?',
default: true
}
]);
if (!answers.confirm) {
console.log(chalk.yellow('Cancelled.'));
return;
}
console.log(chalk.green('\n✅ Creating component...'));
console.log(JSON.stringify(answers, null, 2));
}
Step 5: Output Formatting
// src/utils/display.js
import chalk from 'chalk';
import Table from 'cli-table3'; // npm install cli-table3
export function showTable(data) {
const table = new Table({
head: ['Name', 'Status', 'Size'].map(h => chalk.cyan(h)),
style: { head: [], border: [] }
});
data.forEach(row => {
const status = row.status === 'ok'
? chalk.green('✓')
: chalk.red('✗');
table.push([row.name, status, row.size]);
});
console.log(table.toString());
}
export function success(message) {
console.log(chalk.green(`✔ ${message}`));
}
export function error(message) {
console.log(chalk.red(`✖ ${message}`));
}
export function warning(message) {
console.log(chalk.yellow(`⚠ ${message}`));
}
export function info(message) {
console.log(chalk.blue(`ℹ ${message}`));
}
Step 6: File Operations
// src/utils/files.js
import fs from 'fs/promises';
import path from 'path';
export async function ensureDir(dirPath) {
await fs.mkdir(dirPath, { recursive: true });
}
export async function copyTemplate(templateDir, targetDir, replacements = {}) {
const files = await fs.readdir(templateDir);
for (const file of files) {
const src = path.join(templateDir, file);
const dest = path.join(targetDir, file);
const stat = await fs.stat(src);
if (stat.isDirectory()) {
await copyTemplate(src, dest, replacements);
} else {
let content = await fs.readFile(src, 'utf8');
// Replace template variables
for (const [key, value] of Object.entries(replacements)) {
content = content.replaceAll(`{{${key}}}`, value);
}
await fs.writeFile(dest, content);
}
}
}
export async function readConfig(dirPath) {
const configPath = path.join(dirPath, '.config.json');
try {
return JSON.parse(await fs.readFile(configPath, 'utf8'));
} catch {
return null;
}
}
Step 7: Error Handling
// src/utils/errors.js
import chalk from 'chalk';
class CLIError extends Error {
constructor(message, code = 1) {
super(message);
this.name = 'CLIError';
this.code = code;
}
}
function handleError(err) {
if (err instanceof CLIError) {
console.error(chalk.red(`Error: ${err.message}`));
process.exit(err.code);
}
if (err.code === 'ENOENT') {
console.error(chalk.red(`File not found: ${err.path}`));
process.exit(1);
}
if (err.code === 'EACCES') {
console.error(chalk.red('Permission denied. Try with sudo?'));
process.exit(1);
}
// Unexpected error
console.error(chalk.red('Something went wrong:'));
console.error(err.stack || err.message);
process.exit(1);
}
process.on('uncaughtException', handleError);
process.on('unhandledRejection', handleError);
export { CLIError, handleError };
Step 8: Testing Locally
# Link globally for testing
npm link
# Now you can run it anywhere:
my-cli --help
my-cli init --name test-app
my-cli create
# Unlink when done:
npm unlink -g my-cli
Step 9: Publishing to npm
# 1. Login to npm
npm login
# 2. Check if name is available
npm view my-cli 2>/dev/null && echo "TAKEN" || echo "AVAILABLE"
# 3. Dry run (check what will be published)
npm pack --dry-run
# 4. Publish!
npm publish --access public
# After publishing:
# Users can install: npm install -g my-cli
Step 10: CI/CD for CLI Tools
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm install
- run: npm test
- run: npm link
- run: my-cli --help
- run: my-cli init --name test-project
Pro Tips
Make It Fast
// Lazy-load heavy modules
async function loadHeavyModule() {
const { somethingHeavy } = await import('./heavy-module.js');
return somethingHeavy;
}
Add Shell Autocomplete
// Commander supports autocomplete
program.configureHelp({
commandDescription: (cmd) => `${cmd.description()} [options]`
});
Update Notification
import pkg from '../package.json' assert { type: 'json' };
import { checkForUpdate } from './utils/update.js';
// On every run, silently check for updates
checkForUpdate(pkg.name, pkg.version).catch(() => {});
What CLI tool would make YOUR life easier? Maybe you should build it.
Follow @armorbreak for more Node.js tutorials.


























