CLI-Tools mit TypeScript und Commander.js erstellen
Professionelle Kommandozeilen-Tools in TypeScript mit Commander.js: Argument-Parsing, interaktive Prompts, Fortschritt und strukturierte Ausgaben.

Warum eigene CLI-Tools bauen
Jedes erfahrene Engineering-Team sammelt Skripte an – Deployment-Helfer, Runner für Datenmigrationen, Befehle zum Einrichten von Umgebungen. Diese Skripte beginnen als Bash-Einzeiler, wachsen zu unlesbaren Monstern heran und brauchen irgendwann sauberes Argument-Parsing, Fehlerbehandlung und Dokumentation. CLI-Tools in TypeScript bieten Typsicherheit, Testbarkeit und eine professionelle Schnittstelle für das interne Tooling deines Teams.
Projekt-Setup
Beginne mit einer fokussierten Paketstruktur. Der CLI-Einstiegspunkt sollte minimal sein und an Command-Handler delegieren, die die eigentliche Logik enthalten.
// package.json
{
"name": "@company/deploy-tool",
"version": "1.0.0",
"type": "module",
"bin": {
"deploy": "./dist/cli.js"
},
"scripts": {
"build": "tsc",
"dev": "tsx src/cli.ts",
"lint": "eslint src/"
},
"dependencies": {
"commander": "^12.0.0",
"chalk": "^5.3.0",
"ora": "^8.0.0",
"inquirer": "^9.0.0"
},
"devDependencies": {
"typescript": "^5.4.0",
"tsx": "^4.0.0",
"@types/node": "^20.0.0"
}
}// src/cli.ts
import { Command } from "commander";
import { deployCommand } from "./commands/deploy.js";
import { statusCommand } from "./commands/status.js";
import { rollbackCommand } from "./commands/rollback.js";
const program = new Command()
.name("deploy")
.description("Deployment management CLI")
.version("1.0.0");
program.addCommand(deployCommand);
program.addCommand(statusCommand);
program.addCommand(rollbackCommand);
program.parse();Befehle mit Optionen und Validierung bauen
Jeder Befehl ist ein in sich geschlossenes Modul mit eigenen Optionen, Validierung und Handler. Commander parst die Argumente; dein Handler validiert die Geschäftslogik.
// ❌ No validation, unclear errors, raw process.argv parsing
const env = process.argv[2]; // "staging" hopefully
const tag = process.argv[3]; // who knows
runDeploy(env, tag);
// ✅ Typed options, validation, clear error messages
// src/commands/deploy.ts
import { Command, Option } from "commander";
import chalk from "chalk";
interface DeployOptions {
environment: "staging" | "production";
tag: string;
dryRun: boolean;
force: boolean;
notify: boolean;
}
export const deployCommand = new Command("run")
.description("Deploy a tagged release to an environment")
.requiredOption(
"-e, --environment <env>",
"Target environment"
)
.requiredOption(
"-t, --tag <tag>",
"Docker image tag to deploy"
)
.option("--dry-run", "Preview changes without deploying", false)
.option("--force", "Skip confirmation prompts", false)
.option("--notify", "Send Slack notification on completion", true)
.addOption(
new Option("-e, --environment <env>", "Target environment")
.choices(["staging", "production"])
.makeOptionMandatory()
)
.action(async (options: DeployOptions) => {
try {
await handleDeploy(options);
} catch (error) {
console.error(chalk.red(`Deploy failed: ${(error as Error).message}`));
process.exit(1);
}
});Interaktive Prompts für riskante Operationen
Manche Befehle brauchen eine Bestätigung. Produktions-Deployments, Datenlöschungen und Rollbacks sollten den Nutzer fragen, sofern sie nicht explizit mit einem --force-Flag überschrieben werden.
import inquirer from "inquirer";
import chalk from "chalk";
async function handleDeploy(options: DeployOptions): Promise<void> {
// Validate tag format
if (!/^v\d+\.\d+\.\d+(-[\w.]+)?$/.test(options.tag)) {
throw new Error(
`Invalid tag format: "${options.tag}". Expected semver like v1.2.3`
);
}
console.log(chalk.bold("\nDeployment Plan:"));
console.log(` Environment: ${chalk.cyan(options.environment)}`);
console.log(` Tag: ${chalk.cyan(options.tag)}`);
console.log(` Dry run: ${options.dryRun ? chalk.yellow("yes") : "no"}`);
if (options.environment === "production" && !options.force) {
const { confirmed } = await inquirer.prompt([
{
type: "confirm",
name: "confirmed",
message: chalk.red(
"You are deploying to PRODUCTION. Continue?"
),
default: false,
},
]);
if (!confirmed) {
console.log(chalk.yellow("Deployment cancelled."));
return;
}
}
if (options.dryRun) {
console.log(chalk.yellow("\n[DRY RUN] Would execute the following:"));
console.log(` kubectl set image deployment/app app=${options.tag}`);
return;
}
await executeDeploy(options);
}Fortschrittsanzeigen und strukturierte Ausgabe
Langlebige Befehle brauchen Rückmeldung. Verwende Spinner für Operationen unbestimmter Dauer und Tabellen für strukturierte Ergebnisse.
import ora from "ora";
import chalk from "chalk";
async function executeDeploy(options: DeployOptions): Promise<void> {
const steps: Array<{ label: string; fn: () => Promise<void> }> = [
{
label: "Pulling image",
fn: () => pullImage(options.tag),
},
{
label: "Running pre-deploy checks",
fn: () => runHealthChecks(options.environment),
},
{
label: "Updating deployment",
fn: () => updateDeployment(options.environment, options.tag),
},
{
label: "Waiting for rollout",
fn: () => waitForRollout(options.environment),
},
{
label: "Running post-deploy verification",
fn: () => verifyDeployment(options.environment),
},
];
console.log("");
for (const step of steps) {
const spinner = ora(step.label).start();
try {
await step.fn();
spinner.succeed();
} catch (error) {
spinner.fail();
throw error;
}
}
console.log(chalk.green("\n✓ Deployment complete"));
if (options.notify) {
await sendSlackNotification({
environment: options.environment,
tag: options.tag,
status: "success",
});
}
}Fehlerbehandlung und Exit-Codes
CLI-Tools kommunizieren Erfolg und Misserfolg über Exit-Codes. Nutze sie korrekt, damit Skripte und CI-Pipelines angemessen reagieren können.
// src/errors.ts
class CLIError extends Error {
constructor(
message: string,
public readonly exitCode: number = 1,
public readonly hint?: string
) {
super(message);
this.name = "CLIError";
}
}
class ValidationError extends CLIError {
constructor(message: string) {
super(message, 2, "Run with --help for usage information");
}
}
class NetworkError extends CLIError {
constructor(message: string) {
super(message, 3, "Check your network connection and VPN status");
}
}
// Global error handler in cli.ts
function handleError(error: unknown): never {
if (error instanceof CLIError) {
console.error(chalk.red(`Error: ${error.message}`));
if (error.hint) {
console.error(chalk.dim(`Hint: ${error.hint}`));
}
process.exit(error.exitCode);
}
// Unexpected errors
console.error(chalk.red("Unexpected error:"));
console.error(error);
process.exit(1);
}
process.on("uncaughtException", handleError);
process.on("unhandledRejection", handleError);CLI-Befehle testen
Teste Command-Handler als reine Funktionen, getrennt von der Commander-Parsing-Schicht. So kannst du die Logik verifizieren, ohne Kindprozesse zu starten.
import { describe, it, expect, vi } from "vitest";
describe("deploy command", () => {
it("rejects invalid tag format", async () => {
await expect(
handleDeploy({
environment: "staging",
tag: "not-a-semver",
dryRun: false,
force: true,
notify: false,
})
).rejects.toThrow('Invalid tag format: "not-a-semver"');
});
it("dry run does not execute deployment", async () => {
const deploySpy = vi.spyOn(deployModule, "updateDeployment");
await handleDeploy({
environment: "staging",
tag: "v1.2.3",
dryRun: true,
force: true,
notify: false,
});
expect(deploySpy).not.toHaveBeenCalled();
});
});Die wichtigsten Erkenntnisse
CLI-Tools in TypeScript ersetzen fragile Bash-Skripte durch typisierte, testbare und dokumentierte Befehlsschnittstellen. Verwende Commander.js für das Argument-Parsing, Inquirer für interaktive Prompts bei riskanten Operationen und Ora für Fortschritts-Feedback. Validiere Eingaben frühzeitig mit klaren Fehlermeldungen. Gib aussagekräftige Exit-Codes zurück, damit Skripte und CI sich auf dein Tool verlassen können.
Teste Command-Handler als reine Funktionen: Optionen übergeben, Verhalten prüfen. Strukturiere das CLI als Einstiegspunkt plus Befehlsmodule und halte jeden Befehl auf eine einzige Operation fokussiert. Die Investition in ein sauberes CLI-Tool zahlt sich jedes Mal aus, wenn ein Teamkollege es ausführt, ohne den Quellcode lesen zu müssen, um die Argumente zu verstehen.


