Saltar al contenido

Crear una herramienta CLI con Node.js: de lo básico a la distribución

Guía completa para crear, probar y distribuir una herramienta CLI de Node.js: argumentos, prompts interactivos, formato de salida y publicación en npm.

6 min de lectura
Terminal que muestra una herramienta CLI personalizada con salida en color y prompts interactivos

Las mejores herramientas para desarrolladores son herramientas CLI. Se componen con pipes, se automatizan con scripts y se ejecutan en CI sin navegador. Crear tu propio CLI en Node.js es sencillo una vez que conoces los patrones: análisis de argumentos, prompts interactivos, salida en color y manejo de errores tienen librerías maduras.

Esta guía construye un CLI real desde cero: una herramienta de scaffolding de proyectos que crea directorios, escribe archivos de configuración e instala dependencias.

Configuración del proyecto

Una herramienta CLI no es más que un script de Node.js con una cabecera especial (shebang) y un campo bin en package.json.

jsonjson
{
  "name": "create-project",
  "version": "1.0.0",
  "bin": {
    "create-project": "./dist/index.js"
  },
  "type": "module",
  "scripts": {
    "build": "tsc",
    "dev": "tsx src/index.ts"
  },
  "dependencies": {
    "commander": "^11.0.0",
    "chalk": "^5.3.0",
    "inquirer": "^9.2.0",
    "ora": "^7.0.0"
  },
  "devDependencies": {
    "typescript": "^5.3.0",
    "tsx": "^4.0.0",
    "@types/inquirer": "^9.0.0"
  }
}
tstypescript
#!/usr/bin/env node
// src/index.ts — the entry point
// The shebang line tells the OS to run this with Node.js
 
import { program } from 'commander';
import chalk from 'chalk';
 
program
  .name('create-project')
  .description('Scaffold a new project with sensible defaults')
  .version('1.0.0');
 
program
  .argument('<name>', 'project name')
  .option('-t, --template <type>', 'project template', 'node')
  .option('--no-git', 'skip git initialization')
  .option('--no-install', 'skip dependency installation')
  .action(async (name, options) => {
    console.log(chalk.blue(`Creating project: ${name}`));
    await createProject(name, options);
  });
 
program.parse();

Ejecutar npm link durante el desarrollo hace que el CLI esté disponible globalmente. Ejecutar npm publish lo pone a disposición de todos.

Análisis de argumentos con Commander

Commander gestiona argumentos, opciones, flags y subcomandos. Genera el texto de ayuda automáticamente a partir de tus definiciones.

tstypescript
// src/commands/init.ts
import { Command } from 'commander';
 
export const initCommand = new Command('init')
  .description('Initialize a new project in the current directory')
  .argument('<name>', 'project name (used for package.json and directory)')
  .option('-t, --template <type>', 'project template', 'node')
  .option('-p, --package-manager <pm>', 'package manager', 'npm')
  .option('--typescript', 'use TypeScript', true)
  .option('--no-typescript', 'use plain JavaScript')
  .option('--dry-run', 'show what would be created without writing files')
  .addHelpText('after', `
Templates:
  node        Basic Node.js project with Express
  react       React app with Vite
  api         REST API with Fastify
  cli         CLI tool scaffold
 
Examples:
  $ create-project init my-app
  $ create-project init my-api --template api --package-manager pnpm
  $ create-project init my-cli --template cli --dry-run
  `)
  .action(async (name, options) => {
    await handleInit(name, options);
  });
shbash
# Auto-generated help output
$ create-project init --help
 
Usage: create-project init [options] <name>
 
Initialize a new project in the current directory
 
Arguments:
  name                        project name (used for package.json and directory)
 
Options:
  -t, --template <type>       project template (default: "node")
  -p, --package-manager <pm>  package manager (default: "npm")
  --typescript                use TypeScript (default: true)
  --no-typescript             use plain JavaScript
  --dry-run                   show what would be created without writing files
  -h, --help                  display help for command

Prompts interactivos

Cuando no se proporcionan los argumentos, recurre a prompts interactivos. Esto hace que el CLI funcione tanto para scripts (todos los flags proporcionados) como para humanos (prompts guiados).

tstypescript
// src/prompts.ts
import inquirer from 'inquirer';
 
interface ProjectOptions {
  name: string;
  template: string;
  packageManager: string;
  typescript: boolean;
  git: boolean;
}
 
export async function promptForMissing(
  partial: Partial<ProjectOptions>
): Promise<ProjectOptions> {
  const questions = [];
 
  if (!partial.name) {
    questions.push({
      type: 'input',
      name: 'name',
      message: 'Project name:',
      validate: (input: string) => {
        if (!/^[a-z0-9-]+$/.test(input)) {
          return 'Name must be lowercase alphanumeric with hyphens';
        }
        return true;
      },
    });
  }
 
  if (!partial.template) {
    questions.push({
      type: 'list',
      name: 'template',
      message: 'Select a template:',
      choices: [
        { name: 'Node.js + Express', value: 'node' },
        { name: 'React + Vite', value: 'react' },
        { name: 'REST API + Fastify', value: 'api' },
        { name: 'CLI Tool', value: 'cli' },
      ],
    });
  }
 
  if (!partial.packageManager) {
    questions.push({
      type: 'list',
      name: 'packageManager',
      message: 'Package manager:',
      choices: ['npm', 'pnpm', 'yarn', 'bun'],
    });
  }
 
  const answers = await inquirer.prompt(questions);
  return { ...partial, ...answers } as ProjectOptions;
}
shbash
# Without arguments — interactive mode
$ create-project init
? Project name: my-app
? Select a template: Node.js + Express
? Package manager: pnpm
 
# With arguments — no prompts, script-friendly
$ create-project init my-app -t node -p pnpm

Salida en color y spinners

La salida en la terminal debe comunicar el progreso con claridad. Usa colores para el estado, spinners para las operaciones largas y salida estructurada para los resultados.

tstypescript
// src/ui.ts
import chalk from 'chalk';
import ora from 'ora';
 
export const log = {
  info: (msg: string) => console.log(chalk.blue('ℹ'), msg),
  success: (msg: string) => console.log(chalk.green('✓'), msg),
  warn: (msg: string) => console.log(chalk.yellow('⚠'), msg),
  error: (msg: string) => console.error(chalk.red('✗'), msg),
  step: (num: number, total: number, msg: string) =>
    console.log(chalk.dim(`[${num}/${total}]`), msg),
};
 
export async function withSpinner<T>(
  message: string,
  fn: () => Promise<T>
): Promise<T> {
  const spinner = ora(message).start();
  try {
    const result = await fn();
    spinner.succeed();
    return result;
  } catch (error) {
    spinner.fail();
    throw error;
  }
}
tstypescript
// src/create.ts — using the UI helpers
import { log, withSpinner } from './ui.js';
import { mkdir, writeFile } from 'fs/promises';
import { execSync } from 'child_process';
 
async function createProject(name: string, options: ProjectOptions) {
  log.info(`Creating ${chalk.bold(name)} with template ${options.template}`);
  console.log();
 
  // Step 1: Create directory structure
  log.step(1, 4, 'Creating project structure...');
  await mkdir(`${name}/src`, { recursive: true });
  await mkdir(`${name}/tests`, { recursive: true });
  log.success('Project structure created');
 
  // Step 2: Write config files
  log.step(2, 4, 'Writing configuration files...');
  await writeFile(`${name}/package.json`, generatePackageJson(name, options));
  await writeFile(`${name}/tsconfig.json`, generateTsConfig());
  log.success('Configuration files written');
 
  // Step 3: Initialize git
  if (options.git) {
    log.step(3, 4, 'Initializing git repository...');
    execSync('git init', { cwd: name, stdio: 'ignore' });
    await writeFile(`${name}/.gitignore`, 'node_modules\ndist\n.env\n');
    log.success('Git repository initialized');
  } else {
    log.step(3, 4, chalk.dim('Skipping git initialization'));
  }
 
  // Step 4: Install dependencies
  if (options.install) {
    await withSpinner('Installing dependencies...', async () => {
      const cmd = options.packageManager === 'npm'
        ? 'npm install'
        : `${options.packageManager} install`;
      execSync(cmd, { cwd: name, stdio: 'ignore' });
    });
  }
 
  console.log();
  log.success(chalk.bold('Project created successfully!'));
  console.log();
  console.log(`  ${chalk.cyan('cd')} ${name}`);
  console.log(`  ${chalk.cyan(options.packageManager)} run dev`);
  console.log();
}

Manejo de errores en un CLI

Los errores de un CLI necesitan mensajes claros, no stack traces. Captura los errores en el nivel superior y formatéalos para el consumo humano.

tstypescript
// ❌ Unhandled errors dump stack traces to the terminal
program.action(async (name) => {
  await createProject(name);  // throws Error: EEXIST
});
// Error: EEXIST: file already exists, mkdir 'my-app'
//     at Object.mkdirSync (node:fs:...)
//     at createProject (/src/create.ts:15:3)
//     ...20 more lines nobody reads
 
// ✅ Graceful error handling with actionable messages
program.action(async (name) => {
  try {
    await createProject(name);
  } catch (error) {
    if (isNodeError(error) && error.code === 'EEXIST') {
      log.error(`Directory "${name}" already exists.`);
      log.info(`Use a different name or remove the existing directory.`);
      process.exit(1);
    }
 
    if (isNodeError(error) && error.code === 'EACCES') {
      log.error(`Permission denied. Cannot write to this directory.`);
      process.exit(1);
    }
 
    // Unknown error — show message without stack trace
    log.error(error instanceof Error ? error.message : String(error));
    process.exit(1);
  }
});
 
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
  return error instanceof Error && 'code' in error;
}
tstypescript
// Global unhandled rejection handler — safety net
process.on('unhandledRejection', (reason) => {
  log.error('An unexpected error occurred');
  if (reason instanceof Error) {
    log.error(reason.message);
  }
  process.exit(1);
});

Pruebas de comandos CLI

Prueba las herramientas CLI invocándolas como procesos hijo y haciendo aserciones sobre stdout, stderr y los códigos de salida.

tstypescript
// tests/cli.test.ts
import { execSync } from 'child_process';
import { existsSync, rmSync } from 'fs';
 
const CLI = 'tsx src/index.ts';
 
describe('create-project CLI', () => {
  afterEach(() => {
    // Cleanup created directories
    if (existsSync('test-project')) {
      rmSync('test-project', { recursive: true });
    }
  });
 
  it('creates a project with default options', () => {
    const output = execSync(
      `${CLI} init test-project --no-install`,
      { encoding: 'utf-8' }
    );
 
    expect(output).toContain('Project created successfully');
    expect(existsSync('test-project/package.json')).toBe(true);
    expect(existsSync('test-project/src')).toBe(true);
    expect(existsSync('test-project/.gitignore')).toBe(true);
  });
 
  it('shows error for existing directory', () => {
    execSync(`mkdir test-project`);
 
    try {
      execSync(`${CLI} init test-project`, { encoding: 'utf-8' });
      fail('Should have thrown');
    } catch (error: any) {
      expect(error.stderr || error.stdout).toContain('already exists');
      expect(error.status).toBe(1);
    }
  });
 
  it('supports dry run mode', () => {
    const output = execSync(
      `${CLI} init test-project --dry-run`,
      { encoding: 'utf-8' }
    );
 
    expect(output).toContain('Dry run');
    expect(existsSync('test-project')).toBe(false);
  });
});

Prueba la interfaz de línea de comandos desde fuera. Las pruebas de funciones internas son pruebas unitarias. Las pruebas del CLI verifican que el análisis de argumentos, la creación de archivos y el formato de salida funcionen juntos.

Publicación en npm

Una vez que la herramienta funciona, publícala para que cualquiera pueda usarla con npx create-project o npm install -g create-project.

shbash
# Build the TypeScript source
npm run build
 
# Ensure the shebang is in the compiled output
head -1 dist/index.js
# #!/usr/bin/env node
 
# Test the built version
node dist/index.js init test-project --dry-run
 
# Login and publish
npm login
npm publish
 
# Users can now run:
npx create-project init my-app
# or
npm install -g create-project
create-project init my-app
jsonjson
// package.json fields that matter for publishing
{
  "name": "create-project",
  "version": "1.0.0",
  "bin": { "create-project": "./dist/index.js" },
  "files": ["dist"],
  "engines": { "node": ">=18" },
  "keywords": ["cli", "scaffold", "generator"],
  "repository": { "type": "git", "url": "https://github.com/user/create-project" }
}

El array files garantiza que solo se publique el directorio compilado dist, no las pruebas, los archivos fuente ni las configuraciones de desarrollo.

Puntos clave

  1. Commander gestiona el análisis de argumentos: define argumentos, opciones y subcomandos de forma declarativa con ayuda generada automáticamente
  2. Recurre a prompts interactivos: usa inquirer cuando los usuarios no proporcionan todos los argumentos, haciendo el CLI apto tanto para scripts como para humanos
  3. Usa spinners para operaciones largas: ora proporciona retroalimentación visual durante la instalación y las peticiones de red
  4. Captura los errores con elegancia: traduce los errores del sistema (EEXIST, EACCES) a mensajes legibles para humanos
  5. Prueba mediante la ejecución de procesos hijo: invoca el binario del CLI y haz aserciones sobre stdout, códigos de salida y efectos en el sistema de archivos
  6. Publica con los mínimos archivos: incluye solo dist y package.json en el paquete npm
Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX