Construir una herramienta CLI con Node.js desde cero
Guía paso a paso para una CLI profesional en Node.js: argumentos, prompts interactivos, salida con colores, progreso, configuración y publicación en npm.

Las herramientas CLI son la columna vertebral de los flujos de trabajo de los desarrolladores: desde git hasta npm y los generadores de proyectos. Construir una te enseña análisis de argumentos, E/S de terminal, señales de proceso y distribución de software de una forma inmediatamente útil. El resultado es una herramienta que tu equipo y tú realmente usan, no un artefacto de tutorial que se queda olvidado en un repositorio.
Este recorrido construye desde cero un CLI de scaffolding de proyectos, cubriendo todo el camino desde el análisis de argumentos hasta la publicación en npm.
Configuración del proyecto y punto de entrada
Una herramienta CLI necesita un campo bin en package.json y una línea shebang. La compilación de TypeScript apunta a CommonJS para lograr la máxima compatibilidad con Node.js.
{
"name": "create-project-scaffold",
"version": "1.0.0",
"bin": {
"scaffold": "./dist/cli.js"
},
"type": "commonjs",
"scripts": {
"build": "tsc",
"dev": "tsx src/cli.ts"
},
"dependencies": {
"commander": "^12.0.0",
"chalk": "^4.1.2",
"ora": "^5.4.1",
"inquirer": "^8.2.6"
},
"devDependencies": {
"typescript": "^5.3.0",
"tsx": "^4.0.0",
"@types/inquirer": "^8.2.10",
"@types/node": "^20.0.0"
}
}#!/usr/bin/env node
// src/cli.ts — the entry point
import { Command } from "commander";
import chalk from "chalk";
import { createCommand } from "./commands/create";
import { configCommand } from "./commands/config";
const program = new Command();
program
.name("scaffold")
.description("Project scaffolding CLI")
.version("1.0.0");
// Register subcommands
program.addCommand(createCommand);
program.addCommand(configCommand);
// Global error handling
program.exitOverride();
try {
program.parse();
} catch (err: unknown) {
if (
err instanceof Error &&
"exitCode" in err &&
(err as any).exitCode !== 0
) {
console.error(
chalk.red(`Error: ${err.message}`)
);
process.exit(1);
}
}El shebang #!/usr/bin/env node le indica al sistema operativo que use Node.js para ejecutar el archivo. Commander se encarga del análisis de argumentos, los subcomandos y el texto de ayuda autogenerado.
Prompts interactivos y validación
Cuando faltan argumentos obligatorios, recurre a prompts interactivos. Esto ofrece a los usuarios avanzados flags rápidos de CLI mientras mantiene la herramienta accesible para todos.
// ❌ Fail if arguments are missing
function createProject(name: string, template: string) {
if (!name) {
console.error("Error: project name is required");
process.exit(1);
}
// Users must read --help to discover all flags
}// ✅ Fall back to interactive prompts for missing args
import inquirer from "inquirer";
import { Command } from "commander";
import chalk from "chalk";
interface CreateOptions {
name: string;
template: string;
typescript: boolean;
git: boolean;
install: boolean;
}
export const createCommand = new Command("create")
.description("Create a new project from a template")
.argument("[name]", "Project name")
.option("-t, --template <template>", "Template to use")
.option("--typescript", "Use TypeScript", true)
.option("--no-git", "Skip git initialization")
.option("--no-install", "Skip dependency installation")
.action(async (name, opts) => {
const options = await resolveOptions(name, opts);
await executeCreate(options);
});
async function resolveOptions(
name: string | undefined,
opts: Record<string, unknown>
): Promise<CreateOptions> {
const questions: any[] = [];
if (!name) {
questions.push({
type: "input",
name: "name",
message: "Project name:",
validate: (input: string) => {
if (!input.trim()) return "Name is required";
if (!/^[a-z0-9-]+$/.test(input)) {
return "Use lowercase letters, numbers, hyphens only";
}
return true;
},
});
}
if (!opts.template) {
questions.push({
type: "list",
name: "template",
message: "Select a template:",
choices: [
{ name: "React + Vite", value: "react-vite" },
{ name: "Next.js App Router", value: "nextjs" },
{ name: "Express API", value: "express" },
{ name: "CLI Tool", value: "cli" },
],
});
}
const answers =
questions.length > 0
? await inquirer.prompt(questions)
: {};
return {
name: name ?? answers.name,
template: (opts.template as string) ?? answers.template,
typescript: opts.typescript !== false,
git: opts.git !== false,
install: opts.install !== false,
};
}Indicadores de progreso y salida con colores
Las operaciones de larga duración necesitan retroalimentación visual. Un spinner durante la copia de archivos y la instalación de dependencias mantiene informados a los usuarios.
import ora from "ora";
import chalk from "chalk";
import { execSync } from "child_process";
import * as fs from "fs";
import * as path from "path";
async function executeCreate(
options: CreateOptions
): Promise<void> {
const projectPath = path.resolve(
process.cwd(),
options.name
);
// Check if directory exists
if (fs.existsSync(projectPath)) {
console.error(
chalk.red(
`Directory "${options.name}" already exists`
)
);
process.exit(1);
}
console.log();
console.log(
chalk.bold(`Creating ${chalk.cyan(options.name)}...`)
);
console.log();
// Step 1: Copy template
const spinner = ora("Copying template files").start();
try {
await copyTemplate(options.template, projectPath);
spinner.succeed("Template files copied");
} catch (err) {
spinner.fail("Failed to copy template");
throw err;
}
// Step 2: Customize project
spinner.start("Customizing project configuration");
await customizePackageJson(projectPath, options);
spinner.succeed("Project configured");
// Step 3: Initialize git
if (options.git) {
spinner.start("Initializing git repository");
execSync("git init", {
cwd: projectPath,
stdio: "ignore",
});
spinner.succeed("Git repository initialized");
}
// Step 4: Install dependencies
if (options.install) {
spinner.start("Installing dependencies");
execSync("npm install", {
cwd: projectPath,
stdio: "ignore",
});
spinner.succeed("Dependencies installed");
}
// Summary
console.log();
console.log(chalk.green("✓ Project created successfully!"));
console.log();
console.log(" Next steps:");
console.log(
chalk.cyan(` cd ${options.name}`)
);
console.log(chalk.cyan(" npm run dev"));
console.log();
}
async function copyTemplate(
template: string,
dest: string
): Promise<void> {
const templateDir = path.join(
__dirname,
"..",
"templates",
template
);
if (!fs.existsSync(templateDir)) {
throw new Error(`Template "${template}" not found`);
}
fs.cpSync(templateDir, dest, { recursive: true });
}
async function customizePackageJson(
projectPath: string,
options: CreateOptions
): Promise<void> {
const pkgPath = path.join(projectPath, "package.json");
const pkg = JSON.parse(
fs.readFileSync(pkgPath, "utf-8")
);
pkg.name = options.name;
pkg.version = "0.1.0";
fs.writeFileSync(
pkgPath,
JSON.stringify(pkg, null, 2) + "\n"
);
}Soporte de archivo de configuración
Las herramientas CLI que recuerdan las preferencias del usuario mediante un archivo de configuración reducen la repetición de flags.
import * as fs from "fs";
import * as path from "path";
import * as os from "os";
interface ScaffoldConfig {
defaultTemplate: string;
typescript: boolean;
git: boolean;
author: string;
license: string;
}
const CONFIG_DIR = path.join(os.homedir(), ".scaffold");
const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
const DEFAULT_CONFIG: ScaffoldConfig = {
defaultTemplate: "react-vite",
typescript: true,
git: true,
author: "",
license: "MIT",
};
function loadConfig(): ScaffoldConfig {
try {
if (fs.existsSync(CONFIG_FILE)) {
const raw = fs.readFileSync(CONFIG_FILE, "utf-8");
return { ...DEFAULT_CONFIG, ...JSON.parse(raw) };
}
} catch {
// Corrupted config — use defaults
}
return { ...DEFAULT_CONFIG };
}
function saveConfig(
updates: Partial<ScaffoldConfig>
): void {
const current = loadConfig();
const merged = { ...current, ...updates };
if (!fs.existsSync(CONFIG_DIR)) {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
}
fs.writeFileSync(
CONFIG_FILE,
JSON.stringify(merged, null, 2) + "\n"
);
}
// Config subcommand
export const configCommand = new Command("config")
.description("Manage CLI configuration")
.addCommand(
new Command("set")
.argument("<key>", "Configuration key")
.argument("<value>", "Configuration value")
.action((key: string, value: string) => {
const config = loadConfig();
if (!(key in config)) {
console.error(
chalk.red(`Unknown config key: ${key}`)
);
process.exit(1);
}
let parsed: unknown = value;
if (value === "true") parsed = true;
if (value === "false") parsed = false;
saveConfig({ [key]: parsed });
console.log(
chalk.green(`Set ${key} = ${value}`)
);
})
)
.addCommand(
new Command("list").action(() => {
const config = loadConfig();
console.log(chalk.bold("\nCurrent configuration:\n"));
for (const [key, value] of Object.entries(config)) {
console.log(` ${chalk.cyan(key)}: ${value}`);
}
console.log();
})
);Manejo elegante de señales y limpieza
Las herramientas CLI deben manejar SIGINT (Ctrl+C) con elegancia, limpiando los archivos temporales y los directorios creados parcialmente.
// Register cleanup handlers
let cleanupPath: string | null = null;
function registerCleanup(projectPath: string): void {
cleanupPath = projectPath;
}
function cleanup(): void {
if (cleanupPath && fs.existsSync(cleanupPath)) {
console.log(
chalk.yellow("\nCleaning up partial project...")
);
fs.rmSync(cleanupPath, {
recursive: true,
force: true,
});
console.log(chalk.yellow("Cleanup complete."));
}
}
process.on("SIGINT", () => {
cleanup();
process.exit(130);
});
process.on("SIGTERM", () => {
cleanup();
process.exit(143);
});
// In executeCreate, register before starting work:
async function executeCreate(
options: CreateOptions
): Promise<void> {
const projectPath = path.resolve(
process.cwd(),
options.name
);
registerCleanup(projectPath);
// ... creation steps ...
// Clear cleanup after successful completion
cleanupPath = null;
}Conclusiones clave
Una herramienta CLI profesional combina Commander para el análisis de argumentos con Inquirer para los prompts interactivos, recurriendo a los prompts cuando faltan flags de CLI, de modo que los usuarios avanzados obtienen velocidad mientras los recién llegados reciben orientación. Los spinners de Ora y la salida con colores de Chalk ofrecen retroalimentación visual esencial: los usuarios necesitan saber que un npm install de 30 segundos sigue ejecutándose y no se ha colgado. Los archivos de configuración en ~/.toolname/config.json reducen la repetición de flags almacenando los valores predeterminados del usuario, con un subcomando config set para gestionarlos fácilmente. El manejo de señales mediante process.on('SIGINT') garantiza una limpieza elegante de los archivos temporales y las salidas parciales cuando el usuario presiona Ctrl+C a mitad de una operación. La validación de entradas en los prompts interactivos detecta errores a tiempo con mensajes útiles, evitando fallos posteriores por nombres de proyecto mal formados o plantillas faltantes. El campo bin de package.json y el shebang #!/usr/bin/env node son las dos piezas que hacen que npm install -g cree un comando disponible en todo el sistema.


