n.
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}
// package.json
{
"name": "devkit",
"version": "2.1.0",
"type": "module",
"bin": {
"devkit": "./dist/cli.js"
},
"scripts": {
"build": "tsc",
"dev": "tsx watch src/cli.ts",
"prepublishOnly": "npm run build"
},
"dependencies": {
"commander": "^12.0.0",
"@inquirer/prompts": "^4.0.0",
"chalk": "^5.3.0",
"ora": "^8.0.0",
"cli-progress": "^3.12.0"
},
"devDependencies": {
"typescript": "^5.4.0",
"tsx": "^4.7.0"
}
}
Architecture Rationale:
type: "module" enables top-level await and modern import syntax.
bin maps the executable name to the compiled output, ensuring npm install -g registers the command globally.
tsx provides instant TypeScript execution during development without manual compilation steps.
Step 2: Declarative Command Routing
Avoid monolithic if/else blocks. Use commander to register commands with explicit type signatures, option validation, and isolated action handlers.
// src/cli.ts
#!/usr/bin/env node
import { Command } from 'commander';
import { registerBootstrap } from './commands/bootstrap.js';
import { registerAnalyze } from './commands/analyze.js';
import { registerDeploy } from './commands/deploy.js';
import { handleGlobalError } from './utils/error-handler.js';
const app = new Command();
app
.name('devkit')
.description('Production-grade developer workflow orchestrator')
.version('2.1.0')
.exitOverride()
.showHelpAfterError();
// Register modular command groups
registerBootstrap(app);
registerAnalyze(app);
registerDeploy(app);
// Global error boundary
process.on('unhandledRejection', (err) => {
handleGlobalError(err as Error);
process.exitCode = 1;
});
await app.parseAsync(process.argv);
Why this structure:
exitOverride() prevents Commander from calling process.exit() prematurely, allowing custom error formatting.
- Modular registration keeps each command isolated, testable, and independently versioned.
- Global rejection handling catches async failures that would otherwise crash the terminal silently.
Step 3: Interactive Workflows & Progress Feedback
Terminal UX requires explicit feedback loops. Use @inquirer/prompts for async input collection, ora for transient state indicators, and cli-progress for deterministic long-running operations.
// src/commands/bootstrap.ts
import { Command } from 'commander';
import { select, input, confirm } from '@inquirer/prompts';
import chalk from 'chalk';
import ora from 'ora';
import { createProjectStructure } from '../services/project-factory.js';
export function registerBootstrap(program: Command): void {
program
.command('bootstrap <project-name>')
.description('Scaffold a new repository with standardized configuration')
.option('-f, --framework <type>', 'Target framework', 'react')
.option('-t, --ts', 'Enable TypeScript', false)
.action(async (name: string, opts: { framework: string; ts: boolean }) => {
const spinner = ora('Resolving template dependencies...').start();
try {
const useTs = opts.ts || await confirm({
message: 'Enable TypeScript configuration?',
default: true
});
const pkgManager = await select({
message: 'Select package manager:',
choices: [
{ name: 'npm', value: 'npm' },
{ name: 'pnpm', value: 'pnpm' },
{ name: 'yarn', value: 'yarn' }
]
});
spinner.text = 'Generating project structure...';
await createProjectStructure({
name,
framework: opts.framework,
typescript: useTs,
packageManager: pkgManager
});
spinner.succeed(chalk.green(`Project "${name}" initialized successfully.`));
console.log(chalk.dim(` Run: cd ${name} && ${pkgManager} install`));
} catch (error) {
spinner.fail(chalk.red('Bootstrap failed. Check logs for details.'));
throw error;
}
});
}
Architecture Decisions:
- Async/await throughout prevents callback hell and aligns with Node.js best practices.
ora state transitions (start â text â succeed/fail) provide deterministic UX without blocking the event loop.
- Configuration objects passed to services decouple CLI parsing from business logic, enabling unit testing.
Step 4: Deterministic Progress Tracking
For batch operations, progress bars must update without terminal flicker. cli-progress handles ANSI escape sequences safely.
// src/services/batch-processor.ts
import cliProgress from 'cli-progress';
import chalk from 'chalk';
export async function processAssets(filePaths: string[]): Promise<void> {
const bar = new cliProgress.SingleBar({
format: chalk.cyan('Processing') + ' |{bar}| {percentage}% | {value}/{total}',
barCompleteChar: 'â',
barIncompleteChar: 'â',
hideCursor: true
});
bar.start(filePaths.length, 0);
for (const file of filePaths) {
await optimizeFile(file);
bar.increment();
}
bar.stop();
console.log(chalk.green('\nâ Batch processing complete.'));
}
Pitfall Guide
Production CLIs fail in predictable ways. The following pitfalls account for the majority of terminal tooling defects in real-world deployments.
| Pitfall | Explanation | Fix |
|---|
| Missing or Incorrect Shebang | The #!/usr/bin/env node directive tells POSIX systems which interpreter to use. Without it, ./cli.js throws Permission denied or executes with the wrong runtime. | Always place #!/usr/bin/env node as the absolute first line. Run chmod +x on the entry file. Verify with head -n 1 dist/cli.js. |
| Silent Failures (Missing Exit Codes) | Node.js exits with 0 by default, even when async operations fail. CI/CD pipelines interpret this as success, masking broken deployments. | Explicitly set process.exitCode = 1 on errors. Use process.exit(1) only when immediate termination is required. Wrap async actions in try/catch blocks that propagate exit states. |
| Blocking the Event Loop | Synchronous fs methods (readFileSync, mkdirSync) freeze the terminal UI, preventing spinner updates and interrupt handling. | Replace all sync filesystem calls with async equivalents (fs/promises). Use await consistently. Reserve sync methods only for startup configuration parsing. |
| Cross-Platform Path Resolution | Hardcoded / separators break on Windows. process.cwd() and __dirname behave differently in ESM vs CJS. | Use path.posix.join() for CLI arguments, path.resolve() for local files. In ESM, derive __dirname via import.meta.url. Test on Windows/macOS/Linux early. |
| Unhandled Promise Rejections | Async command actions that throw outside try/catch crash the process without formatting. Node.js 15+ warns but doesn't always exit cleanly. | Attach a global unhandledRejection listener. Wrap every .action() callback in try/catch. Use commander's .showHelpAfterError() for user-facing validation. |
| Ignoring Signal Handling | Users expect Ctrl+C to terminate gracefully. Unhandled SIGINT leaves temp files, locks, or spinner artifacts in the terminal. | Register process.on('SIGINT', () => { cleanup(); process.exit(0); }). Clear spinners and progress bars before exit. Remove partial temp directories. |
| Over-Engineering Interactive Flows | Deeply nested prompts increase cognitive load and break automation pipelines that expect non-interactive execution. | Support --non-interactive or --ci flags that skip prompts and use defaults. Validate flags before prompting. Keep prompt depth †3 levels. |
Production Bundle
Action Checklist
Decision Matrix
Selecting the right terminal libraries depends on project scale, team familiarity, and distribution requirements.
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Small internal tool (< 5 commands) | cac + prompts | Lightweight, zero-config, fast startup | Low (minimal dependencies) |
| Enterprise CLI with complex routing | commander + @inquirer/prompts | Declarative API, type-safe options, mature ecosystem | Medium (slightly larger bundle) |
| Legacy CJS codebase | yargs + inquirer | CJS compatibility, extensive middleware support | Medium (requires bundler) |
| Minimalist styling needs | picocolors | 100x smaller than chalk, same API | Low (negligible bundle size) |
| Rich terminal UI (tables, trees) | ink (React for CLI) | Component-based rendering, virtual DOM | High (learning curve, larger runtime) |
Configuration Template
Copy this baseline for new Node.js CLI projects. It includes ESM setup, binary mapping, and production-ready scripts.
{
"name": "@scope/your-cli",
"version": "1.0.0",
"type": "module",
"bin": {
"your-cli": "./dist/index.js"
},
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"dev": "tsx watch src/index.ts",
"lint": "eslint src --ext .ts",
"test": "vitest run",
"prepublishOnly": "npm run lint && npm run test && npm run build"
},
"dependencies": {
"commander": "^12.0.0",
"@inquirer/prompts": "^4.0.0",
"chalk": "^5.3.0",
"ora": "^8.0.0"
},
"devDependencies": {
"typescript": "^5.4.0",
"tsx": "^4.7.0",
"vitest": "^1.2.0",
"@types/node": "^20.11.0"
},
"engines": {
"node": ">=18.0.0"
}
}
Quick Start Guide
Deploy a functional CLI in under five minutes:
- Initialize & Install: Run
npm init -y, then npm i commander @inquirer/prompts chalk ora. Add typescript and tsx to devDependencies.
- Configure Entry Point: Create
src/index.ts, add #!/usr/bin/env node as line one, and import Command from commander. Map the binary in package.json under "bin".
- Define First Command: Register a command with
.command(), .option(), and .action(). Use await for async operations and wrap in try/catch.
- Test Locally: Run
npm link to register the command globally. Execute your-cli --help to verify routing. Use tsx watch src/index.ts for live reload during development.
- Publish: Run
npm run build, verify dist/index.js contains the shebang, then execute npm publish --access public. Users install via npm install -g @scope/your-cli.