Creación de herramientas CLI con TypeScript y Commander.js
Crea herramientas de línea de comandos en TypeScript con Commander.js: argumentos, prompts interactivos, indicadores de progreso y salida estructurada.

Por qué crear herramientas CLI personalizadas
Todo equipo de ingeniería maduro acumula scripts: ayudantes de despliegue, ejecutores de migraciones de datos, comandos de configuración de entornos. Estos scripts empiezan como one-liners de bash, se convierten en monstruos ilegibles y, con el tiempo, necesitan un análisis de argumentos, manejo de errores y documentación adecuados. Las herramientas CLI en TypeScript te dan seguridad de tipos, capacidad de prueba y una interfaz profesional para las herramientas internas de tu equipo.
Configuración del proyecto
Empieza con una estructura de paquete enfocada. El punto de entrada del CLI debe ser mínimo y delegar en manejadores de comandos que contienen la lógica real.
// 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();Creación de comandos con opciones y validación
Cada comando es un módulo autocontenido con sus propias opciones, validación y manejador. Commander analiza los argumentos; tu manejador valida la lógica de negocio.
// ❌ 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);
}
});Prompts interactivos para operaciones peligrosas
Algunos comandos necesitan confirmación. Los despliegues a producción, las eliminaciones de datos y las reversiones deberían pedir confirmación al usuario, salvo que se anulen explícitamente con una bandera --force.
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);
}Indicadores de progreso y salida estructurada
Los comandos de larga duración necesitan retroalimentación. Usa spinners para operaciones indeterminadas y tablas para resultados estructurados.
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",
});
}
}Manejo de errores y códigos de salida
Las herramientas CLI comunican éxito y fracaso mediante códigos de salida. Úsalos correctamente para que los scripts y los pipelines de CI puedan reaccionar adecuadamente.
// 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);Pruebas de comandos CLI
Prueba los manejadores de comandos como funciones puras, separados de la capa de análisis de Commander. Así puedes verificar la lógica sin lanzar procesos hijos.
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();
});
});Conclusiones clave
Las herramientas CLI en TypeScript reemplazan scripts de bash frágiles por interfaces de comandos tipadas, testeables y documentadas. Usa Commander.js para el análisis de argumentos, Inquirer para prompts interactivos en operaciones peligrosas y Ora para retroalimentación de progreso. Valida las entradas pronto con mensajes de error claros. Devuelve códigos de salida significativos para que los scripts y el CI puedan depender de tu herramienta.
Prueba los manejadores de comandos como funciones puras: pásales opciones y verifica el comportamiento. Estructura el CLI como punto de entrada más módulos de comandos, manteniendo cada comando enfocado en una sola operación. La inversión en una herramienta CLI adecuada se amortiza cada vez que un compañero la ejecuta sin leer el código fuente para entender los argumentos.


