Building CLI Tools with Node.js
Command-line tools are essential for developers. They automate repetitive tasks, provide utilities, and enhance workflows. Node.js is an excellent choice for building CLI tools because of its rich ecosystem and JavaScript familiarity. In this comprehensive guide, we'll explore how to create powerful CLI tools with Node.js, from simple scripts to full-featured applications.
Why Build CLI Tools?
Benefits of CLI Tools:
- Automation: Eliminate repetitive manual tasks
- Consistency: Ensure uniform execution across environments
- Integration: Easy integration with existing workflows
- Speed: Faster than GUI alternatives for power users
- Scripting: Chain commands together for complex operations
When to Choose CLI:
- Batch processing tasks
- DevOps operations
- Code generation
- Project scaffolding
- Data processing
- System administration
Project Setup
1. Initialize Project
# Create project directory mkdir my-cli-tool && cd my-cli-tool # Initialize npm project npm init -y # Create basic structure mkdir bin lib touch bin/index.js chmod +x bin/index.js
2. Package.json Configuration
{
"name": "my-cli-tool",
"version": "1.0.0",
"description": "A powerful CLI tool built with Node.js",
"main": "bin/index.js",
"bin": {
"mytool": "./bin/index.js"
},
"scripts": {
"start": "node bin/index.js",
"dev": "nodemon bin/index.js",
"test": "jest",
"build": "pkg . --out-path dist/"
},
"keywords": ["cli", "tool", "automation"],
"author": "Your Name",
"license": "MIT",
"dependencies": {
"commander": "^10.0.0",
"chalk": "^5.2.0",
"ora": "^6.3.1",
"inquirer": "^9.2.0"
},
"devDependencies": {
"jest": "^29.5.0",
"nodemon": "^2.0.22",
"pkg": "^5.8.1"
},
"engines": {
"node": ">=14.0.0"
},
"preferGlobal": true
}3. Basic CLI Entry Point
#!/usr/bin/env node
// bin/index.js
const { program } = require('commander');
const chalk = require('chalk');
const package = require('../package.json');
program
.name('mytool')
.description('A powerful CLI tool')
.version(package.version);
program
.command('hello')
.description('Say hello')
.option('-n, --name <name>', 'Your name', 'World')
.action((options) => {
console.log(chalk.green(`Hello, ${options.name}!`));
});
program.parse();Command-Line Argument Parsing
1. Commander.js Basic Usage
const { program } = require('commander');
program
.option('-c, --config <path>', 'Configuration file path')
.option('-v, --verbose', 'Enable verbose output')
.option('--dry-run', 'Show what would be done without executing');
program
.command('build')
.description('Build the project')
.option('-o, --output <dir>', 'Output directory', 'dist')
.option('-w, --watch', 'Watch for changes')
.action(async (options) => {
console.log('Building project...');
if (options.watch) {
console.log('Watching for changes...');
}
});
program
.command('deploy')
.description('Deploy the application')
.option('-e, --environment <env>', 'Deployment environment', 'production')
.action(async (options) => {
console.log(`Deploying to ${options.environment}...`);
});
program.parse();2. Advanced Argument Types
const { program } = require('commander');
program
.command('create')
.description('Create a new resource')
.argument('<type>', 'Resource type (user, project, task)')
.argument('[name]', 'Resource name')
.option('-t, --tags <tags...>', 'Tags for the resource')
.option('-p, --public', 'Make resource public')
.action((type, name, options) => {
console.log(`Creating ${type}: ${name || 'unnamed'}`);
if (options.tags) {
console.log(`Tags: ${options.tags.join(', ')}`);
}
});
program.parse();3. Interactive Prompts
const inquirer = require('inquirer');
async function getUserInput() {
const answers = await inquirer.prompt([
{
type: 'input',
name: 'projectName',
message: 'What is your project name?',
validate: (input) => input.length >= 3 || 'Project name must be at least 3 characters'
},
{
type: 'list',
name: 'framework',
message: 'Which framework would you like to use?',
choices: ['React', 'Vue', 'Angular', 'Svelte']
},
{
type: 'checkbox',
name: 'features',
message: 'Select features to include:',
choices: [
{ name: 'TypeScript', checked: true },
{ name: 'ESLint', checked: true },
{ name: 'Prettier', checked: false },
{ name: 'Testing', checked: true }
]
},
{
type: 'confirm',
name: 'confirm',
message: 'Are you sure you want to proceed?',
default: true
}
]);
return answers;
}
// Usage
async function createProject() {
const config = await getUserInput();
console.log('Creating project with config:', config);
}File System Operations
1. Reading and Writing Files
const fs = require('fs').promises;
const path = require('path');
class FileManager {
async readFile(filePath) {
try {
const content = await fs.readFile(filePath, 'utf-8');
return content;
} catch (error) {
throw new Error(`Failed to read file ${filePath}: ${error.message}`);
}
}
async writeFile(filePath, content) {
try {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, content, 'utf-8');
console.log(`File written: ${filePath}`);
} catch (error) {
throw new Error(`Failed to write file ${filePath}: ${error.message}`);
}
}
async copyFile(src, dest) {
try {
await fs.mkdir(path.dirname(dest), { recursive: true });
await fs.copyFile(src, dest);
console.log(`File copied: ${src} -> ${dest}`);
} catch (error) {
throw new Error(`Failed to copy file: ${error.message}`);
}
}
async findFiles(dir, pattern) {
const files = [];
async function scan(directory) {
const items = await fs.readdir(directory);
for (const item of items) {
const fullPath = path.join(directory, item);
const stat = await fs.stat(fullPath);
if (stat.isDirectory()) {
await scan(fullPath);
} else if (pattern.test(item)) {
files.push(fullPath);
}
}
}
await scan(dir);
return files;
}
}
module.exports = FileManager;2. Template Processing
const handlebars = require('handlebars');
class TemplateEngine {
constructor(templateDir) {
this.templateDir = templateDir;
}
async render(templateName, data) {
const templatePath = path.join(this.templateDir, templateName);
const templateContent = await fs.readFile(templatePath, 'utf-8');
const template = handlebars.compile(templateContent);
return template(data);
}
async generate(templateName, data, outputPath) {
const content = await this.render(templateName, data);
await fs.writeFile(outputPath, content, 'utf-8');
console.log(`Generated file: ${outputPath}`);
}
}
// Usage
const templates = new TemplateEngine('./templates');
// Register helpers
handlebars.registerHelper('capitalize', (str) => {
return str.charAt(0).toUpperCase() + str.slice(1);
});
handlebars.registerHelper('lowercase', (str) => str.toLowerCase());Project Scaffolding
1. Component Generator
const { program } = require('commander');
const fs = require('fs').promises;
const path = require('path');
program
.command('generate <type> <name>')
.description('Generate a new component or module')
.option('-d, --directory <dir>', 'Target directory', 'src/components')
.action(async (type, name, options) => {
const templates = {
component: {
'Component.js': `import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
const ${name} = ({ title }) => {
return (
<View style={styles.container}>
<Text style={styles.title}>{title}</Text>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
title: {
fontSize: 24,
fontWeight: 'bold',
},
});
export default ${name};
`,
'index.js': `export { default } from './${name}';`
},
hook: {
'useHook.js': `import { useState, useEffect } from 'react';
const use${name} = (initialValue) => {
const [value, setValue] = useState(initialValue);
useEffect(() => {
// Hook logic here
}, []);
return [value, setValue];
};
export default use${name};
`
}
};
if (!templates[type]) {
console.error(`Unknown type: ${type}. Available: ${Object.keys(templates).join(', ')}`);
return;
}
const targetDir = path.join(options.directory, name);
try {
await fs.mkdir(targetDir, { recursive: true });
for (const [filename, content] of Object.entries(templates[type])) {
const filePath = path.join(targetDir, filename);
await fs.writeFile(filePath, content);
console.log(`Created: ${filePath}`);
}
console.log(`Successfully generated ${type}: ${name}`);
} catch (error) {
console.error(`Error generating ${type}: ${error.message}`);
}
});
program.parse();2. Full Project Boilerplate
const { exec } = require('child_process');
const util = require('util');
const execAsync = util.promisify(exec);
class ProjectGenerator {
constructor() {
this.templates = {
react: {
dependencies: ['react', 'react-dom'],
devDependencies: ['webpack', 'babel-loader', '@babel/preset-react'],
files: {
'src/App.js': `import React from 'react';
function App() {
return (
<div className="App">
<h1>Hello React!</h1>
</div>
);
}
export default App;
`,
'webpack.config.js': `const path = require('path');
module.exports = {
entry: './src/index.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js',
},
module: {
rules: [
{
test: /\\.js$/,
exclude: /node_modules/,
use: 'babel-loader',
},
],
},
};
`
}
}
};
}
async createProject(type, name) {
console.log(`Creating new ${type} project: ${name}`);
// Create directory
await fs.mkdir(name, { recursive: true });
process.chdir(name);
// Initialize package.json
await execAsync('npm init -y');
// Install dependencies
const deps = this.templates[type].dependencies.join(' ');
const devDeps = this.templates[type].devDependencies.join(' ');
if (deps) {
console.log('Installing dependencies...');
await execAsync(`npm install ${deps}`);
}
if (devDeps) {
console.log('Installing dev dependencies...');
await execAsync(`npm install --save-dev ${devDeps}`);
}
// Create files
for (const [filePath, content] of Object.entries(this.templates[type].files)) {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, content);
}
console.log(`Project ${name} created successfully!`);
console.log('Run "npm start" to begin development.');
}
}Progress Indicators and Logging
1. Spinners and Progress Bars
const ora = require('ora');
const chalk = require('chalk');
class Logger {
constructor() {
this.spinner = null;
}
startSpinner(text) {
this.spinner = ora(text).start();
}
succeedSpinner(text) {
if (this.spinner) {
this.spinner.succeed(text);
this.spinner = null;
}
}
failSpinner(text) {
if (this.spinner) {
this.spinner.fail(text);
this.spinner = null;
}
}
info(message) {
console.log(chalk.blue('ℹ'), message);
}
success(message) {
console.log(chalk.green('✓'), message);
}
warning(message) {
console.log(chalk.yellow('⚠'), message);
}
error(message) {
console.log(chalk.red('✗'), message);
}
progress(current, total, description = '') {
const percentage = Math.round((current / total) * 100);
const progressBar = '█'.repeat(Math.floor(percentage / 5)) +
'░'.repeat(20 - Math.floor(percentage / 5));
process.stdout.write(`
${progressBar} ${percentage}% ${description}`);
}
}
// Usage
const logger = new Logger();
async function buildProject() {
logger.startSpinner('Building project...');
try {
// Simulate build steps
await new Promise(resolve => setTimeout(resolve, 1000));
logger.info('Compiling JavaScript...');
await new Promise(resolve => setTimeout(resolve, 1000));
logger.info('Processing assets...');
await new Promise(resolve => setTimeout(resolve, 1000));
logger.succeedSpinner('Project built successfully!');
} catch (error) {
logger.failSpinner(`Build failed: ${error.message}`);
}
}2. Colored Output and Formatting
const chalk = require('chalk');
const boxen = require('boxen');
class OutputFormatter {
static success(message) {
console.log(chalk.green.bold('✓ ') + chalk.green(message));
}
static error(message) {
console.log(chalk.red.bold('✗ ') + chalk.red(message));
}
static warning(message) {
console.log(chalk.yellow.bold('⚠ ') + chalk.yellow(message));
}
static info(message) {
console.log(chalk.blue.bold('ℹ ') + chalk.blue(message));
}
static header(title) {
const headerText = chalk.cyan.bold(`= ${title} =`);
const separator = chalk.cyan('='.repeat(title.length + 4));
console.log(separator);
console.log(headerText);
console.log(separator);
}
static table(data, headers = null) {
if (!data.length) return;
const cols = headers || Object.keys(data[0]);
const colWidths = cols.map(col => {
const maxContent = Math.max(
col.length,
...data.map(row => String(row[col] || '').length)
);
return Math.min(maxContent, 30); // Max column width
});
// Print headers
if (headers) {
const headerRow = cols.map((col, i) =>
chalk.cyan.bold(col.padEnd(colWidths[i]))
).join(' │ ');
console.log(headerRow);
console.log(chalk.gray('─'.repeat(headerRow.replace(/\x1b\[[0-9;]*m/g, '').length)));
}
// Print data rows
data.forEach(row => {
const rowStr = cols.map((col, i) =>
String(row[col] || '').padEnd(colWidths[i])
).join(' │ ');
console.log(rowStr);
});
}
static box(message, options = {}) {
console.log(boxen(message, {
padding: 1,
margin: 1,
borderStyle: 'round',
borderColor: 'cyan',
...options
}));
}
}Configuration Management
1. Config File Handling
const fs = require('fs').promises;
const path = require('path');
const os = require('os');
class ConfigManager {
constructor(configName = 'mytool') {
this.configDir = path.join(os.homedir(), '.config', configName);
this.configFile = path.join(this.configDir, 'config.json');
}
async ensureConfigDir() {
try {
await fs.mkdir(this.configDir, { recursive: true });
} catch (error) {
if (error.code !== 'EEXIST') throw error;
}
}
async loadConfig() {
try {
await this.ensureConfigDir();
const configData = await fs.readFile(this.configFile, 'utf-8');
return JSON.parse(configData);
} catch (error) {
// Return default config if file doesn't exist
return {
apiUrl: 'https://api.example.com',
timeout: 30000,
verbose: false,
theme: 'dark'
};
}
}
async saveConfig(config) {
await this.ensureConfigDir();
await fs.writeFile(this.configFile, JSON.stringify(config, null, 2));
}
async get(key) {
const config = await this.loadConfig();
return config[key];
}
async set(key, value) {
const config = await this.loadConfig();
config[key] = value;
await this.saveConfig(config);
}
async list() {
const config = await this.loadConfig();
return Object.entries(config);
}
}
// Usage
const config = new ConfigManager();
// Set configuration
await config.set('apiUrl', 'https://api.production.com');
// Get configuration
const apiUrl = await config.get('apiUrl');2. Environment Variables
require('dotenv').config();
class EnvManager {
static get(key, defaultValue = null) {
return process.env[key] || defaultValue;
}
static get required(key) {
const value = process.env[key];
if (!value) {
throw new Error(`Required environment variable ${key} is not set`);
}
return value;
}
static get number(key, defaultValue = 0) {
const value = process.env[key];
return value ? parseFloat(value) : defaultValue;
}
static get boolean(key, defaultValue = false) {
const value = process.env[key];
if (!value) return defaultValue;
return value.toLowerCase() === 'true' || value === '1';
}
static get array(key, separator = ',') {
const value = process.env[key];
return value ? value.split(separator).map(s => s.trim()) : [];
}
}
// Usage
const PORT = EnvManager.get.number('PORT', 3000);
const DEBUG = EnvManager.get.boolean('DEBUG', false);
const ALLOWED_HOSTS = EnvManager.get.array('ALLOWED_HOSTS');Error Handling and Validation
1. Custom Error Classes
class CLIError extends Error {
constructor(message, code = 'GENERIC_ERROR') {
super(message);
this.name = 'CLIError';
this.code = code;
}
}
class ValidationError extends CLIError {
constructor(message) {
super(message, 'VALIDATION_ERROR');
}
}
class NetworkError extends CLIError {
constructor(message) {
super(message, 'NETWORK_ERROR');
}
}
class FileSystemError extends CLIError {
constructor(message) {
super(message, 'FILESYSTEM_ERROR');
}
}2. Error Handler
const chalk = require('chalk');
class ErrorHandler {
static handle(error) {
if (error instanceof CLIError) {
this.handleCLIError(error);
} else {
this.handleUnexpectedError(error);
}
process.exit(1);
}
static handleCLIError(error) {
const errorMessages = {
'VALIDATION_ERROR': chalk.red('Validation Error:'),
'NETWORK_ERROR': chalk.yellow('Network Error:'),
'FILESYSTEM_ERROR': chalk.magenta('File System Error:'),
'GENERIC_ERROR': chalk.red('Error:')
};
const prefix = errorMessages[error.code] || errorMessages.GENERIC_ERROR;
console.error(`${prefix} ${error.message}`);
if (error.code === 'VALIDATION_ERROR') {
console.error('Use --help for usage information.');
}
}
static handleUnexpectedError(error) {
console.error(chalk.red('An unexpected error occurred:'));
console.error(error.message);
if (process.env.DEBUG) {
console.error('\nStack trace:');
console.error(error.stack);
} else {
console.error('\nRun with DEBUG=1 for more details.');
}
}
static withErrorHandler(fn) {
return async (...args) => {
try {
await fn(...args);
} catch (error) {
this.handle(error);
}
};
}
}
// Usage
const command = ErrorHandler.withErrorHandler(async () => {
// Your command logic here
throw new ValidationError('Invalid input provided');
});Testing CLI Tools
1. Unit Tests
const { exec } = require('child_process');
const fs = require('fs').promises;
const path = require('path');
describe('CLI Tool Tests', () => {
const testDir = path.join(__dirname, 'test-output');
beforeEach(async () => {
await fs.mkdir(testDir, { recursive: true });
});
afterEach(async () => {
await fs.rm(testDir, { recursive: true, force: true });
});
test('should create component files', async () => {
const componentName = 'TestComponent';
const componentDir = path.join(testDir, componentName);
// Run CLI command
await new Promise((resolve, reject) => {
exec(`node bin/index.js generate component ${componentName} -d ${testDir}`,
(error, stdout, stderr) => {
if (error) reject(error);
else resolve({ stdout, stderr });
});
});
// Check files were created
const componentFile = path.join(componentDir, `${componentName}.js`);
const indexFile = path.join(componentDir, 'index.js');
expect(await fs.access(componentFile)).resolves.toBeUndefined();
expect(await fs.access(indexFile)).resolves.toBeUndefined();
// Check file contents
const componentContent = await fs.readFile(componentFile, 'utf-8');
expect(componentContent).toContain(`const ${componentName}`);
});
test('should handle invalid component name', async () => {
await expect(
new Promise((resolve, reject) => {
exec('node bin/index.js generate component ""',
(error, stdout, stderr) => {
if (error && error.code !== 0) resolve();
else reject(new Error('Expected command to fail'));
});
})
).resolves.toBeUndefined();
});
});2. Integration Tests
const { spawn } = require('child_process');
describe('CLI Integration Tests', () => {
test('should display help information', async () => {
const output = await runCommand(['--help']);
expect(output).toContain('Usage:');
expect(output).toContain('Commands:');
expect(output).toContain('Options:');
});
test('should create project interactively', async () => {
const child = spawn('node', ['bin/index.js', 'create'], {
stdio: ['pipe', 'pipe', 'pipe']
});
// Simulate user input
child.stdin.write('My Project\n');
child.stdin.write('React\n');
child.stdin.write('a\n'); // Select all features
child.stdin.write('y\n'); // Confirm
const output = await getCommandOutput(child);
expect(output).toContain('Project created successfully');
});
});
function runCommand(args) {
return new Promise((resolve, reject) => {
const child = spawn('node', ['bin/index.js', ...args], {
stdio: 'pipe'
});
let output = '';
child.stdout.on('data', (data) => output += data.toString());
child.stderr.on('data', (data) => output += data.toString());
child.on('close', (code) => {
if (code === 0) {
resolve(output);
} else {
reject(new Error(`Command failed with code ${code}\n${output}`));
}
});
});
}Distribution and Packaging
1. NPM Publishing
# Prepare for publishing npm version patch # or minor, major npm publish # Or publish with specific tag npm publish --tag beta
2. Standalone Binaries
# Install pkg globally npm install -g pkg # Build binaries for different platforms pkg . --targets node14-win-x64,node14-macos-x64,node14-linux-x64 # The binaries will be created in the current directory ls -la # my-cli-tool-win.exe # my-cli-tool-macos # my-cli-tool-linux
3. Installation Script
#!/bin/bash # install.sh echo "Installing My CLI Tool..." # Download binary curl -L https://github.com/user/my-cli-tool/releases/latest/download/my-cli-tool-linux -o mytool # Make executable chmod +x mytool # Move to PATH sudo mv mytool /usr/local/bin/ echo "Installation complete! Run 'mytool --help' to get started."
Best Practices
1. CLI Design Principles
- Consistency: Use familiar command patterns
- Helpful: Provide clear help and examples
- Robust: Handle errors gracefully
- Fast: Optimize for performance
- Discoverable: Good naming and documentation
2. User Experience
- Progressive disclosure: Show basic options first
- Sensible defaults: Choose reasonable defaults
- Confirmation: Ask before destructive operations
- Progress feedback: Show progress for long operations
- Clear messaging: Use colors and formatting effectively
3. Code Quality
- Modular design: Separate concerns into modules
- Error handling: Comprehensive error handling
- Testing: Unit and integration tests
- Documentation: Inline docs and README
- Linting: Use ESLint for code quality
4. Security
- Input validation: Validate all user inputs
- Safe file operations: Check permissions and paths
- Network security: Use HTTPS and validate certificates
- Credential handling: Secure storage of sensitive data
Advanced Topics
1. Plugin System
class PluginManager {
constructor() {
this.plugins = new Map();
}
register(name, plugin) {
this.plugins.set(name, plugin);
}
async execute(name, ...args) {
const plugin = this.plugins.get(name);
if (!plugin) {
throw new Error(`Plugin ${name} not found`);
}
return await plugin.execute(...args);
}
list() {
return Array.from(this.plugins.keys());
}
}
// Usage
const plugins = new PluginManager();
plugins.register('deploy', {
execute: async (environment) => {
console.log(`Deploying to ${environment}`);
// Deployment logic
}
});2. Command Auto-completion
# Generate completion script mytool completion > /usr/local/etc/bash_completion.d/mytool # Or for Zsh mytool completion zsh > ~/.zsh/completion/_mytool
3. Multi-command Architecture
const commands = {
build: require('./commands/build'),
deploy: require('./commands/deploy'),
test: require('./commands/test'),
lint: require('./commands/lint')
};
program
.command('build')
.description('Build the project')
.action(commands.build);
program
.command('deploy')
.description('Deploy the application')
.action(commands.deploy);
// Dynamic command loading
async function loadCommands() {
const commandFiles = await fs.readdir('./commands');
for (const file of commandFiles) {
if (file.endsWith('.js')) {
const commandName = path.basename(file, '.js');
const commandModule = require(`./commands/${file}`);
program
.command(commandName)
.description(commandModule.description)
.action(commandModule.action);
}
}
}Conclusion
Building CLI tools with Node.js offers incredible power and flexibility. From simple automation scripts to complex, feature-rich applications, the Node.js ecosystem provides everything you need. Start with the basics—argument parsing, file operations, and user interaction—then layer on advanced features like plugins, auto-completion, and multi-platform distribution.
Remember that great CLI tools focus on user experience, provide clear feedback, handle errors gracefully, and solve real problems efficiently. With the techniques covered in this guide, you'll be able to create professional-grade command-line tools that developers love to use.
Related articles