Saltar al contenido

Construyendo una herramienta CLI desde cero con Node.js

Crea una CLI de calidad de producción en Node.js: argumentos, prompts interactivos, indicadores de progreso, manejo de errores y distribución en npm.

6 min de lectura
Ventana de terminal que muestra una herramienta CLI pulida con salida en color, barras de progreso y prompts de selección interactivos

Las herramientas para desarrolladores viven o mueren por su experiencia de CLI. Una herramienta fácil de instalar, con mensajes de error útiles y retroalimentación clara, se adopta. Una que vuelca stack traces, exige memorizar flags crípticos y no produce ninguna salida durante operaciones largas, se abandona.

Construir una buena herramienta CLI con Node.js es sorprendentemente sencillo una vez que conoces los patrones. El ecosistema ofrece excelentes librerías para el análisis de argumentos, prompts interactivos y renderizado en terminal. El desafío no es la tecnología: son las decisiones de diseño sobre cómo tu herramienta se comunica con su usuario.

Estructura y configuración del proyecto

Una herramienta CLI es un proyecto de Node.js con un punto de entrada bin. El archivo binario se ejecuta con #!/usr/bin/env node y queda disponible como comando después de la instalación.

jsonjson
{
  "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"
  }
}
tstypescript
// 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();

Prompts interactivos y confirmación

Las buenas herramientas CLI confirman las operaciones destructivas y guían a los usuarios a través de entradas complejas con prompts interactivos.

tstypescript
// 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);
}

Retroalimentación de progreso y spinners

Las operaciones de larga duración necesitan retroalimentación visual. El silencio hace que los usuarios se pregunten si la herramienta se congeló.

tstypescript
// 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`)
  );
}

Manejo de errores que ayuda

La diferencia entre una herramienta que los desarrolladores aman y una que odian es lo que sucede cuando algo sale mal.

tstypescript
// 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);

Pruebas de comandos CLI

Las herramientas CLI también necesitan pruebas. Prueba la lógica de los comandos por separado de la interacción con la terminal.

tstypescript
// 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')
    );
  });
});

Distribución e instalación

Hacer que tu CLI sea fácil de instalar es el último paso antes de la adopción. npm hace que la instalación global sea sencilla.

shbash
# 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
tstypescript
// 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('');

Puntos clave

Estructura las herramientas CLI con una jerarquía de comandos clara usando librerías como Commander: comandos de nivel superior para las acciones principales (push, status, config) con subcomandos y flags para la personalización, de modo que los usuarios puedan descubrir la funcionalidad a través de --help en cualquier nivel. Los prompts interactivos para argumentos faltantes y las confirmaciones de operaciones destructivas hacen que las herramientas sean seguras y fáciles de descubrir: solicita el tag de despliegue cuando el usuario no lo especifica, confirma siempre los despliegues a producción y admite --no-confirm para scripts de automatización. Los spinners de progreso y la salida en color transforman la experiencia de usuario de "¿esta herramienta se congeló?" a una retroalimentación clara paso a paso: muestra qué está sucediendo durante las operaciones largas, marca los pasos como exitosos o fallidos e imprime un resumen con enlaces relevantes al finalizar. Escribe mensajes de error que digan a los usuarios qué salió mal y cómo solucionarlo: identifica errores comunes como fallos de conexión y problemas de autenticación para dar sugerencias accionables, oculta los stack traces detrás de un flag DEBUG y trata los mensajes de error como una interfaz de usuario que merece el mismo cuidado que el camino feliz.

Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX