rl);
const pkg = require('../package.json');
async function main() {
try {
await runProgram(pkg.version);
} catch (error) {
if (error instanceof CommandError) {
console.error(chalk.red(\nβ ${error.message}));
process.exit(error.exitCode);
}
console.error(chalk.red('\nβ Unexpected failure:'));
console.error(error.stack);
process.exit(1);
}
}
main();
#### 2. Modular Command Architecture
Commands should be isolated modules that export configuration and action handlers. This allows for lazy loading and easier testing. We use `commander` to define the interface.
**Bootstrap Command (`lib/commands/bootstrap.mjs`):**
This command scaffolds a new project structure. It demonstrates safe file system operations and user feedback.
```javascript
import fs from 'fs/promises';
import path from 'path';
import chalk from 'chalk';
import { CommandError } from '../core/errors.mjs';
export const bootstrapCommand = {
name: 'bootstrap',
description: 'Scaffold a new project workspace',
arguments: '<target-directory>',
options: [
{ flag: '--template <name>', description: 'Specify a starter template', default: 'default' }
],
action: async (targetDir, options) => {
const targetPath = path.resolve(targetDir);
if (fs.stat(targetPath).catch(() => null)) {
throw new CommandError(`Directory '${targetDir}' already exists.`, 1);
}
console.log(chalk.blue(`\nβ Initializing workspace: ${targetDir}`));
await fs.mkdir(targetPath, { recursive: true });
const manifest = {
name: path.basename(targetDir),
version: '0.1.0',
scripts: { dev: 'node index.js' }
};
await fs.writeFile(
path.join(targetPath, 'package.json'),
JSON.stringify(manifest, null, 2)
);
console.log(chalk.green(`\nβ Workspace created at ${targetPath}`));
console.log(chalk.cyan(` Run: cd ${targetDir} && npm install`));
}
};
3. Interactive Workflows with Inquirer
For complex operations, interactive prompts guide the user. We encapsulate inquirer to ensure consistent prompt styling and validation.
Provision Command (lib/commands/provision.mjs):
This command provisions infrastructure resources based on user input.
import inquirer from 'inquirer';
import chalk from 'chalk';
import { CommandError } from '../core/errors.mjs';
export const provisionCommand = {
name: 'provision',
description: 'Provision cloud resources interactively',
action: async () => {
const prompt = inquirer.createPromptModule();
const answers = await prompt([
{
type: 'list',
name: 'resourceType',
message: 'Select resource type:',
choices: ['Database', 'Storage', 'Compute', 'Network'],
filter: (input) => input.toLowerCase()
},
{
type: 'input',
name: 'resourceName',
message: 'Enter resource identifier:',
validate: (input) => /^[a-z0-9-]+$/.test(input) || 'Must be lowercase alphanumeric with hyphens'
},
{
type: 'confirm',
name: 'enableMonitoring',
message: 'Enable monitoring alerts?',
default: true
}
]);
console.log(chalk.yellow(`\nβ Provisioning ${answers.resourceType}...`));
// Simulate async provisioning
await new Promise(resolve => setTimeout(resolve, 1500));
console.log(chalk.green(`\nβ Resource '${answers.resourceName}' provisioned successfully.`));
if (answers.enableMonitoring) {
console.log(chalk.cyan(' Monitoring alerts configured.'));
}
}
};
4. Configuration Management
Persistent configuration allows users to customize behavior without repeating flags. We implement a singleton configuration store that reads from a dotfile in the user's home directory.
Config Store (lib/core/config.mjs):
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
const CONFIG_DIR = path.join(os.homedir(), '.devkit');
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
const DEFAULTS = {
theme: 'auto',
editor: 'code',
timeout: 30000,
verbose: false
};
class ConfigStore {
static async load() {
try {
const raw = await fs.readFile(CONFIG_FILE, 'utf8');
return { ...DEFAULTS, ...JSON.parse(raw) };
} catch {
return { ...DEFAULTS };
}
}
static async save(updates) {
await fs.mkdir(CONFIG_DIR, { recursive: true });
const current = await this.load();
const merged = { ...current, ...updates };
await fs.writeFile(CONFIG_FILE, JSON.stringify(merged, null, 2));
return merged;
}
}
export { ConfigStore };
5. Robust Error Handling
Custom error classes allow the CLI to distinguish between expected failures and unexpected bugs. This enables clean exit codes and user-friendly messages.
Error Definitions (lib/core/errors.mjs):
export class CommandError extends Error {
constructor(message, exitCode = 1) {
super(message);
this.name = 'CommandError';
this.exitCode = exitCode;
}
}
export class ValidationError extends CommandError {
constructor(message) {
super(message, 2);
this.name = 'ValidationError';
}
}
6. Program Assembly
The main program file wires up commands, loads configuration, and handles global options.
Program Setup (lib/index.mjs):
import { Command } from 'commander';
import { bootstrapCommand } from './commands/bootstrap.mjs';
import { provisionCommand } from './commands/provision.mjs';
import { ConfigStore } from './core/config.mjs';
export async function runProgram(version) {
const config = await ConfigStore.load();
const program = new Command();
program
.name('devkit')
.description('Developer toolkit for workspace management')
.version(version);
if (config.verbose) {
program.option('-v, --verbose', 'Enable verbose logging');
}
// Register commands
program
.command(bootstrapCommand.name)
.description(bootstrapCommand.description)
.argument(bootstrapCommand.arguments)
.option(bootstrapCommand.options[0].flag, bootstrapCommand.options[0].description, bootstrapCommand.options[0].default)
.action(bootstrapCommand.action);
program
.command(provisionCommand.name)
.description(provisionCommand.description)
.action(provisionCommand.action);
program.parse(process.argv);
}
Pitfall Guide
Building CLIs involves subtle traps that can degrade reliability. Below are common mistakes and their remedies based on production experience.
| Pitfall | Explanation | Fix |
|---|
| Command Injection | Passing user input directly to execSync or exec allows malicious input to execute arbitrary shell commands. | Use execFile with argument arrays, or sanitize/validate inputs rigorously. Avoid string interpolation in shell calls. |
| Silent Failures | Failing to set process.exit(code) on errors causes the CLI to exit with code 0, masking failures in CI/CD pipelines. | Always throw CommandError with appropriate exit codes. Ensure the global handler calls process.exit. |
| Blocking the Event Loop | Using synchronous file operations or heavy computation blocks the event loop, preventing spinner updates and signal handling. | Use fs/promises and async/await patterns. Offload CPU-heavy tasks to worker threads if necessary. |
| Config Path Collisions | Hardcoding config paths or using generic names like .config can conflict with other tools or break across OS environments. | Use a unique dotfile name (e.g., .devkitrc) and resolve paths using os.homedir(). |
| Ignoring Signals | Users pressing Ctrl+C may leave spinners running or temp files behind if SIGINT is not handled. | Register a process.on('SIGINT') handler to clean up resources and exit gracefully. |
| Dependency Bloat | Including unnecessary packages increases install time and attack surface. | Audit dependencies regularly. Prefer native Node.js APIs over third-party libraries where feasible. |
| Inconsistent Output | Mixing console.log, chalk, and raw strings leads to messy output that breaks piping and redirection. | Centralize output through a logger module. Respect --no-color flags and check process.stdout.isTTY. |
Production Bundle
This section provides actionable artifacts to deploy your CLI to production standards.
Action Checklist
Decision Matrix
Use this matrix to select the appropriate architectural pattern based on your use case.
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Internal Team Tool | Structured CLI with commander and inquirer | Balances developer experience with maintainability; easy to share via private npm. | Low: Standard dependencies. |
| Public OSS Tool | Full CLI with update checks, telemetry opt-out, and extensive docs | Users expect polish, reliability, and transparency; higher support burden requires robust error handling. | Medium: Requires CI/CD and documentation effort. |
| Simple Automation | Single-file script with execa | Over-engineering adds maintenance cost; simple tasks don't need modular architecture. | Minimal: No extra dependencies. |
| High-Performance CLI | Rust/Go binary or Node.js with worker threads | Node.js event loop may bottleneck CPU-heavy tasks; compiled binaries offer speed. | High: Requires multi-language expertise. |
Configuration Template
Copy this package.json configuration to ensure your CLI is publish-ready and follows best practices.
{
"name": "@scope/devkit",
"version": "1.0.0",
"description": "Production-grade developer toolkit",
"type": "module",
"bin": {
"devkit": "./bin/run.mjs"
},
"files": [
"bin/",
"lib/"
],
"engines": {
"node": ">=18.0.0"
},
"dependencies": {
"commander": "^12.0.0",
"inquirer": "^9.0.0",
"chalk": "^5.0.0"
},
"scripts": {
"link": "npm link",
"test": "node --test"
},
"keywords": ["cli", "toolkit", "developer-tools"],
"license": "MIT"
}
Quick Start Guide
Get your CLI running in under five minutes.
- Initialize Project: Run
npm init -y and install dependencies: npm install commander inquirer chalk.
- Create Entry Point: Create
bin/run.mjs with the shebang and import your program logic.
- Wire Commands: Define your first command in
lib/commands/ and register it in lib/index.mjs.
- Link Locally: Run
npm link to make the command available globally in your terminal.
- Verify: Execute
devkit --help to confirm the CLI is accessible and responding correctly.