Ein CLI-Tool von Grund auf mit Node.js bauen
Baue ein produktionsreifes Node.js-CLI-Tool: Argument-Parsing, interaktive Prompts, Fortschrittsanzeigen, Fehlerbehandlung und Distribution über npm.

Entwickler-Tools stehen und fallen mit ihrem CLI-Erlebnis. Ein Tool, das leicht zu installieren ist, hilfreiche Fehlermeldungen liefert und klares Feedback gibt, wird übernommen. Eines, das Stack Traces ausspuckt, das Auswendiglernen kryptischer Flags verlangt und bei langen Operationen keine Ausgabe erzeugt, wird aufgegeben.
Ein gutes CLI-Tool mit Node.js zu bauen ist erstaunlich unkompliziert, sobald man die Muster kennt. Das Ökosystem bietet exzellente Bibliotheken für Argument-Parsing, interaktive Prompts und Terminal-Rendering. Die Herausforderung ist nicht die Technologie – es sind die Design-Entscheidungen darüber, wie dein Tool mit seinen Nutzern kommuniziert.
Projektstruktur und Setup
Ein CLI-Tool ist ein Node.js-Projekt mit einem bin-Einstiegspunkt. Die Binärdatei läuft mit #!/usr/bin/env node und steht nach der Installation als Befehl zur Verfügung.
{
"name": "deploy-tool",
"version": "1.0.0",
"description": "Deploy applications to staging and production",
"bin": {
"deploy": "./dist/cli.js"
},
"files": ["dist"],
"type": "module",
"scripts": {
"build": "tsc",
"dev": "tsx src/cli.ts"
},
"dependencies": {
"commander": "^12.0.0",
"chalk": "^5.3.0",
"ora": "^8.0.0",
"prompts": "^2.4.2"
},
"devDependencies": {
"typescript": "^5.4.0",
"tsx": "^4.7.0",
"@types/prompts": "^2.4.9"
}
}// src/cli.ts — the entry point
#!/usr/bin/env node
import { Command } from 'commander';
import { deployCommand } from './commands/deploy.js';
import { statusCommand } from './commands/status.js';
import { configCommand } from './commands/config.js';
const program = new Command();
program
.name('deploy')
.description('Deploy applications to staging and production')
.version('1.0.0');
program
.command('push')
.description('Deploy to a target environment')
.argument('<environment>', 'Target environment (staging|production)')
.option('-t, --tag <tag>', 'Docker image tag to deploy')
.option('--dry-run', 'Show what would happen without deploying')
.option('--no-confirm', 'Skip confirmation prompt')
.action(deployCommand);
program
.command('status')
.description('Show deployment status')
.argument('[environment]', 'Environment to check', 'all')
.action(statusCommand);
program
.command('config')
.description('Manage configuration')
.addCommand(
new Command('set')
.argument('<key>', 'Configuration key')
.argument('<value>', 'Configuration value')
.action(configCommand.set)
)
.addCommand(
new Command('get')
.argument('<key>', 'Configuration key')
.action(configCommand.get)
);
program.parse();Interaktive Prompts und Bestätigungen
Gute CLI-Tools bestätigen destruktive Operationen und führen Nutzer mit interaktiven Prompts durch komplexe Eingaben.
// src/commands/deploy.ts
import chalk from 'chalk';
import prompts from 'prompts';
import ora from 'ora';
interface DeployOptions {
tag?: string;
dryRun?: boolean;
confirm?: boolean;
}
export async function deployCommand(
environment: string,
options: DeployOptions
): Promise<void> {
// Validate environment
const validEnvs = ['staging', 'production'];
if (!validEnvs.includes(environment)) {
console.error(
chalk.red(`Error: Invalid environment "${environment}"`)
);
console.error(
chalk.dim(`Valid environments: ${validEnvs.join(', ')}`)
);
process.exit(1);
}
// If no tag specified, prompt for one
let tag = options.tag;
if (!tag) {
const response = await prompts({
type: 'select',
name: 'tag',
message: 'Select a version to deploy',
choices: [
{ title: 'v2.4.1 (latest)', value: 'v2.4.1' },
{ title: 'v2.4.0', value: 'v2.4.0' },
{ title: 'v2.3.9', value: 'v2.3.9' },
],
});
if (!response.tag) {
console.log(chalk.yellow('Deployment cancelled.'));
process.exit(0);
}
tag = response.tag;
}
// Confirmation for production deploys
if (environment === 'production' && options.confirm !== false) {
const confirm = await prompts({
type: 'confirm',
name: 'value',
message: chalk.yellow(
`Deploy ${tag} to PRODUCTION? This affects live users.`
),
initial: false,
});
if (!confirm.value) {
console.log('Deployment cancelled.');
process.exit(0);
}
}
if (options.dryRun) {
console.log(chalk.cyan('DRY RUN — no changes will be made'));
console.log(`Would deploy ${tag} to ${environment}`);
return;
}
await executeDeploy(environment, tag);
}Fortschritts-Feedback und Spinner
Langlaufende Operationen brauchen visuelles Feedback. Stille lässt Nutzer vermuten, dass das Tool eingefroren ist.
// src/deploy/executor.ts
import ora from 'ora';
import chalk from 'chalk';
async function executeDeploy(
environment: string,
tag: string
): Promise<void> {
console.log(
chalk.bold(`\nDeploying ${chalk.cyan(tag)} to ${chalk.green(environment)}\n`)
);
const steps = [
{ label: 'Pulling Docker image', fn: pullImage },
{ label: 'Running health checks', fn: runHealthChecks },
{ label: 'Updating service', fn: updateService },
{ label: 'Waiting for rollout', fn: waitForRollout },
{ label: 'Verifying deployment', fn: verifyDeployment },
];
for (const step of steps) {
const spinner = ora(step.label).start();
try {
const result = await step.fn(environment, tag);
spinner.succeed(
`${step.label} ${chalk.dim(result.message ?? '')}`
);
} catch (error) {
spinner.fail(`${step.label} — ${(error as Error).message}`);
console.error(
chalk.red('\nDeployment failed. Rolling back...')
);
await rollback(environment);
process.exit(1);
}
}
console.log(
chalk.green.bold('\n✓ Deployment complete!\n')
);
console.log(
chalk.dim(` Environment: ${environment}`)
);
console.log(
chalk.dim(` Version: ${tag}`)
);
console.log(
chalk.dim(` Dashboard: https://deploy.internal/${environment}\n`)
);
}Fehlerbehandlung, die hilft
Der Unterschied zwischen einem Tool, das Entwickler lieben, und einem, das sie hassen, zeigt sich, wenn etwas schiefgeht.
// src/utils/errors.ts
import chalk from 'chalk';
// ❌ Bad error handling: dump the stack trace
process.on('uncaughtException', (error) => {
console.error(error); // Unreadable for users
process.exit(1);
});
// ✅ Good error handling: explain what went wrong and how to fix it
class CLIError extends Error {
constructor(
message: string,
public readonly hint?: string,
public readonly code?: string
) {
super(message);
}
}
function handleError(error: unknown): never {
if (error instanceof CLIError) {
console.error(chalk.red(`\nError: ${error.message}`));
if (error.hint) {
console.error(chalk.yellow(`\nHint: ${error.hint}`));
}
process.exit(1);
}
if (error instanceof Error) {
console.error(chalk.red(`\nError: ${error.message}`));
// Common patterns with actionable suggestions
if (error.message.includes('ECONNREFUSED')) {
console.error(
chalk.yellow('\nHint: Cannot connect to the deploy server.')
);
console.error(
chalk.yellow(' - Check if you\'re connected to the VPN')
);
console.error(
chalk.yellow(' - Run `deploy config get server-url` to verify')
);
}
if (error.message.includes('401') || error.message.includes('403')) {
console.error(
chalk.yellow('\nHint: Authentication failed.')
);
console.error(
chalk.yellow(' - Run `deploy config set token <your-token>`')
);
console.error(
chalk.yellow(' - Tokens expire after 24 hours')
);
}
// Show stack trace only with DEBUG flag
if (process.env.DEBUG) {
console.error(chalk.dim(`\n${error.stack}`));
} else {
console.error(
chalk.dim('\nRun with DEBUG=1 for full stack trace')
);
}
}
process.exit(1);
}
// Wrap the entire CLI
process.on('uncaughtException', handleError);
process.on('unhandledRejection', handleError);CLI-Befehle testen
CLI-Tools brauchen ebenfalls Tests. Teste die Befehlslogik getrennt von der Terminal-Interaktion.
// src/commands/__tests__/deploy.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { deployCommand } from '../deploy.js';
// Mock external dependencies
vi.mock('prompts', () => ({
default: vi.fn(),
}));
vi.mock('ora', () => ({
default: () => ({
start: vi.fn().mockReturnThis(),
succeed: vi.fn().mockReturnThis(),
fail: vi.fn().mockReturnThis(),
}),
}));
describe('deploy command', () => {
beforeEach(() => {
vi.clearAllMocks();
// Prevent process.exit from actually exiting in tests
vi.spyOn(process, 'exit').mockImplementation(
(() => {}) as never
);
});
it('rejects invalid environments', async () => {
const consoleSpy = vi.spyOn(console, 'error');
await deployCommand('invalid-env', { tag: 'v1.0.0' });
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('Invalid environment')
);
expect(process.exit).toHaveBeenCalledWith(1);
});
it('skips confirmation with --no-confirm flag', async () => {
const prompts = (await import('prompts')).default;
await deployCommand('staging', {
tag: 'v1.0.0',
confirm: false,
});
expect(prompts).not.toHaveBeenCalled();
});
it('shows dry run output without deploying', async () => {
const consoleSpy = vi.spyOn(console, 'log');
await deployCommand('staging', {
tag: 'v1.0.0',
dryRun: true,
});
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('DRY RUN')
);
});
});Distribution und Installation
Dein CLI einfach installierbar zu machen ist der letzte Schritt vor der Adoption. npm macht die globale Installation nahtlos.
# Users install your tool globally
npm install -g deploy-tool
# Or use npx for one-off usage
npx deploy-tool push staging --tag v2.4.1
# For team-internal tools, publish to a private registry
npm publish --registry https://npm.internal.company.com// Post-install message for first-time users
// package.json: "postinstall": "node dist/postinstall.js"
// src/postinstall.ts
import chalk from 'chalk';
console.log(chalk.cyan('\n Deploy Tool installed successfully!\n'));
console.log(' Get started:');
console.log(chalk.dim(' deploy config set token <your-token>'));
console.log(chalk.dim(' deploy push staging --tag latest'));
console.log(chalk.dim(' deploy status'));
console.log('');Wichtigste Erkenntnisse
Strukturiere CLI-Tools mit einer klaren Befehlshierarchie unter Verwendung von Bibliotheken wie Commander – Top-Level-Befehle für die Hauptaktionen (push, status, config) mit Unterbefehlen und Flags zur Anpassung, damit Nutzer Funktionalität über --help auf jeder Ebene entdecken können. Interaktive Prompts für fehlende Argumente und Bestätigungen für destruktive Operationen machen Tools sicher und leicht entdeckbar – frage nach dem Deployment-Tag, wenn der Nutzer keinen angibt, bestätige Produktions-Deployments immer und unterstütze --no-confirm für Automatisierungs-Skripte. Fortschritts-Spinner und farbige Ausgabe verwandeln das Nutzererlebnis von „Ist das Tool eingefroren?" in klares Schritt-für-Schritt-Feedback – zeige, was während langer Operationen passiert, markiere Schritte als erfolgreich oder fehlgeschlagen und gib am Ende eine Zusammenfassung mit relevanten Links aus. Schreibe Fehlermeldungen, die Nutzern sagen, was schiefgegangen ist und wie man es behebt – erkenne häufige Fehler wie Verbindungsprobleme und Authentifizierungsfehler und gib umsetzbare Hinweise, verstecke Stack Traces hinter einem DEBUG-Flag und behandle Fehlermeldungen als eine Benutzeroberfläche, die die gleiche Sorgfalt verdient wie der Happy Path.


