Zum Inhalt springen

CLI-Tools mit TypeScript und Commander.js erstellen

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

4 Min. Lesezeit
Ein Terminalfenster mit einem CLI-Tool mit farbiger Ausgabe, Fortschrittsbalken und strukturiertem Hilfetext

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.

jsonjson
// 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"
  }
}
tstypescript
// 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.

tstypescript
// ❌ 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.

tstypescript
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.

tstypescript
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.

tstypescript
// 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.

tstypescript
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.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX