Patrones de Scripts de Utilidad
Patrones comunes para escribir scripts de utilidad en Node.js — E/S de archivos, globbing, salida coloreada, indicaciones interactivas y spinners.
Receta
Tarjeta de receta de referencia rápida — lista para copiar y pegar.
# Instala el kit de herramientas de scripts de utilidad estándar
npm install --save-dev \
fast-glob \
chalk \
@inquirer/prompts \
ora
# Integrados de Node — no se requiere instalación
# node:fs/promises — E/S de archivos asincrónica
# node:path — rutas multiplataforma
# node:url — fileURLToPath para __dirname en ESM// Esqueleto mínimo
import { readFile, writeFile } from "node:fs/promises";
import fg from "fast-glob";
import chalk from "chalk";
import { confirm } from "@inquirer/prompts";
import ora from "ora";Cuándo usarlo: Cualquier utilidad CLI, codemod, paso de construcción o script de mantenimiento donde necesites retroalimentación de progreso y operaciones de archivo seguras.
Ejemplo Funcional
// scripts/process-ts-files.ts
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import fg from "fast-glob";
import chalk from "chalk";
import { confirm } from "@inquirer/prompts";
import ora from "ora";
async function main(): Promise<void> {
// 1. Busca archivos
const files = await fg("src/**/*.ts", {
ignore: ["**/*.d.ts", "**/node_modules/**"],
absolute: true,
});
console.log(chalk.cyan(`Se encontraron ${files.length} archivos TypeScript.`));
if (files.length === 0) {
console.log(chalk.yellow("Nada que hacer."));
return;
}
// 2. Confirma con el usuario
const proceed = await confirm({
message: `¿Procesar ${files.length} archivos?`,
default: false,
});
if (!proceed) {
console.log(chalk.gray("Abortado."));
return;
}
// 3. Procesa con un spinner
const spinner = ora("Procesando archivos...").start();
let changed = 0;
try {
for (const file of files) {
const contents = await readFile(file, "utf8");
const next = contents.replace(/\r\n/g, "\n");
if (next !== contents) {
await writeFile(file, next, "utf8");
changed += 1;
}
spinner.text = `Procesando ${path.basename(file)}`;
}
spinner.succeed(chalk.green(`Normalizados ${changed} archivo(s).`));
} catch (err) {
spinner.fail(chalk.red("Error al procesar."));
throw err;
}
}
main().catch((err: unknown) => {
console.error(chalk.red("Error de script:"), err);
process.exitCode = 1;
});Qué demuestra esto:
- E/S de archivos asincrónica vía
node:fs/promises(nuncareadFileSyncen un script asincrónico) fast-globpara coincidencia de archivos rápida y flexible con patrones ignore- API de función nombrada de
@inquirer/prompts(reemplaza la API de objeto antigua deinquirer.prompt()) - Spinner
oracon actualizaciones detextdinámicas y estadossucceed/fail - Manejo de errores adecuado que establece
process.exitCodeen lugar de llamar aprocess.exit()
Profundización
Cómo funciona
node:fs/promisesexpone todas las funcionesfscomo variantes que devuelven promesas. Ya no se necesitautil.promisify.fast-globes la librería glob más rápida y ampliamente utilizada para Node. Devuelve unstring[]simple por defecto (oEntry[]conobjectMode: true).chalkv5 es solo ESM. Si estás en CommonJS, fija chalk v4 o convierte tu proyecto a ESM.@inquirer/promptses el reemplazo moderno del paquete monolíticoinquirer. Cada prompt (input,confirm,select,checkbox) se importa como una función nombrada que devuelve una promesa.oraescribe en stderr por defecto y detecta TTY para desactivar animaciones en CI. Puedes forzar el estado vía{ isEnabled: process.stdout.isTTY }.
Variaciones
Lectura y escritura de archivos JSON:
import { readFile, writeFile } from "node:fs/promises";
interface Config {
name: string;
version: string;
}
const raw = await readFile("config.json", "utf8");
const config = JSON.parse(raw) as Config;
config.version = "2.0.0";
await writeFile("config.json", JSON.stringify(config, null, 2) + "\n");Procesamiento basado en streams para archivos grandes:
import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";
const rl = createInterface({
input: createReadStream("huge.log"),
crlfDelay: Infinity,
});
for await (const line of rl) {
// procesa una línea a la vez — la memoria se mantiene plana
}Recorrido recursivo de directorios sin una librería glob:
import { readdir } from "node:fs/promises";
// Node 20+ admite recursive: true
const entries = await readdir("src", { recursive: true, withFileTypes: true });
const files = entries.filter((e) => e.isFile()).map((e) => e.name);Barras de progreso con cli-progress:
import cliProgress from "cli-progress";
const bar = new cliProgress.SingleBar({}, cliProgress.Presets.shades_classic);
bar.start(files.length, 0);
for (const file of files) {
await process(file);
bar.increment();
}
bar.stop();Detectando TTY para silenciar la decoración en CI:
const isInteractive = process.stdout.isTTY && !process.env.CI;
const spinner = ora({ text: "Working...", isEnabled: isInteractive });Cadenas de plantilla con chalk:
console.log(`${chalk.bold.blue("info")} Found ${chalk.yellow(files.length)} files`);Notas de TypeScript
fs.readFile(path, "utf8")devuelvePromise<string>. Sin la codificación, devuelvePromise<Buffer>. Siempre pasa"utf8"cuando quieras una cadena.fast-globdevuelvePromise<string[]>cuando se llama sin opciones. Con{ objectMode: true }devuelvePromise<Entry[]>.@inquirer/promptsinfiere tipos de devolución desde el prompt:confirm()devuelvePromise<boolean>,input()devuelvePromise<string>, yselect<T>()acepta un genérico para el valor de la opción.- Escribe el manejador de error de script de nivel superior como
(err: unknown)y reduce antes de registrar.
Trampas
Cosas que te atraparán. Cada trampa incluye qué sale mal, por qué sucede y la solución.
-
Usar
fs.readFileSyncen un script asincrónico — Bloquea el event loop, anula la concurrencia y hace que los spinners se entrecorten. Solución: Usaimport { readFile } from "node:fs/promises"yawait. Las APIs sincrónicas solo son apropiadas en código de inicio antes de cualquier trabajo asincrónico. -
Olvidar
awaiten una llamada defs.promises—writeFile(path, data)sinawaitdevuelve una promesa sin controlar. El script se cierra antes de que se complete la escritura, perdiendo datos silenciosamente. Solución: Siempreawait, y activa@typescript-eslint/no-floating-promisespara detectar esto en tiempo de lint. -
Los colores de chalk no aparecen en CI — Chalk detecta automáticamente TTY y desactiva los colores en entornos no interactivos. Los registros en GitHub Actions o CircleCI aparecen sin colorear por diseño. Solución: Establece
FORCE_COLOR=1como variable de entorno en CI, o usanew Chalk(\{ level: 3 \})para forzar el nivel de color. -
Rutas relativas vs absolutas en glob —
fast-globdevuelve rutas relativas acwdpor defecto. Pasar esas afs.readFilefunciona solo si el script se ejecuta desde el mismo directorio. Solución: Usa{ absolute: true }o resuelve manualmente conpath.resolve(process.cwd(), file). -
Emoji rompiendo terminales de Windows —
cmd.exeheredada y PowerShell más antiguos renderizan emoji como bytes garrapateados. Solución: Detecta Windows víaprocess.platform === "win32"y recurre a ASCII, o requiere Windows Terminal (UTF-8 por defecto). -
Llamar
process.exit()antes de que las escrituras se vacíen —process.exit(1)termina inmediatamente, truncando escrituras de stdout y llamadas defs.writeFilependientes. Solución: Estableceprocess.exitCode = 1y deja que el event loop se drene naturalmente.
Alternativas
Otras formas de resolver el mismo problema — y cuándo cada una es la mejor opción.
| Alternativa | Úsalo cuando | No lo uses cuando |
|---|---|---|
| execa | Necesitas generar comandos externos con buen DX | Solo necesitas E/S de archivos en proceso |
| zx | Quieres ergonomía de shell-script con plantillas JS | Necesitas tipado estricto u APIs explícitas |
Bun.file / Bun shell | Estás ejecutando en Bun, no en Node | Apuntas a Node.js en producción |
node:readline | Necesitas entrada interactiva de una sola línea sin deps | Quieres prompts enriquecidos (select, checkbox, etc.) |
Preguntas frecuentes
¿Por qué preferir node:fs/promises sobre util.promisify(fs.readFile)?
node:fs/promiseses la API moderna canónica — no se necesita envolver.- Cada función
fsya tiene una variante de promesa exportada. util.promisifysolo es útil para APIs de callback antiguas de terceros.
¿Por qué usar fast-glob en lugar de la opción recursiva fs.readdir integrada?
fs.readdir(\{ recursive: true \})(Node 20+) funciona para casos simples pero no tiene soporte de patrones glob.fast-globadmite**,*, expansión de llaves, negación y patrones ignore.- Para cualquier cosa más allá de un listado recursivo plano,
fast-globes más simple y rápido.
¿Cuál es la diferencia entre @inquirer/prompts e inquirer antiguo?
inquirerantiguo expone una API monolíticainquirer.prompt([\{ type, name, message \}]).@inquirer/promptsexpone cada prompt como una función nombrada:confirm(),input(),select().- La nueva API es más ligera, tree-shakeable y tiene mejor inferencia de TypeScript.
¿Por qué chalk v5 requiere ESM?
- Chalk v5 eliminó el soporte de CommonJS para reducir la carga de mantenimiento.
- Migra tu proyecto a ESM (
"type": "module"enpackage.json). - O fija chalk a v4 que aún admite CommonJS
require().
Trampa: ¿Por qué mi script se cierra antes de que los archivos terminen de escribirse?
- Olvidaste
awaiten una llamada defs.writeFile, o llamasteprocess.exit()demasiado pronto. - Las promesas sin controlar no mantienen el event loop vivo de la manera en que lo hacen las que esperas.
- Solución:
awaitcada llamada asincrónica y usaprocess.exitCode = 1en lugar deprocess.exit(1).
Trampa: ¿Por qué faltan los colores de chalk en mis registros de CI?
- Chalk detecta automáticamente TTY y desactiva el color en entornos no interactivos.
- Los corredores de CI típicamente canalizan stdout, así que
isTTYes falso. - Solución: Establece
FORCE_COLOR=1en tu entorno de CI, o configura un nivel de color más alto explícitamente.
TypeScript: ¿Cómo escribo el valor de devolución de fs.readFile?
- Con codificación
"utf8":Promise<string>. - Sin codificación:
Promise<Buffer>. - Siempre pasa la codificación explícitamente cuando quieras una cadena, o usa un envoltorio genérico.
TypeScript: ¿Cómo escribo un prompt select de @inquirer/prompts?
select()acepta un parámetro de tipo genérico para el valor elegido.- Ejemplo:
const choice = await select<"a" | "b">(\{ message, choices \}). - El tipo de devolución se infiere del genérico, dando una unión de literal de cadena.
¿Cómo muestro progreso dinámico en un spinner ora?
- Reasigna
spinner.textdentro de tu bucle — ora vuelve a renderizar en cada tick. - Llama a
spinner.succeed(msg)ospinner.fail(msg)para detener con un estado final. - Para progreso verdadero (x de N), usa
cli-progressen su lugar.
¿Cuándo debo hacer stream de un archivo en lugar de leerlo en memoria?
- Archivos más grandes que ~100 MB, u archivos que no caben en tu presupuesto de memoria de proceso.
- Usa
createReadStream+readline.createInterfacepara iteración línea por línea. - Los streams mantienen la memoria plana independientemente del tamaño del archivo.
¿Cómo escribo JSON de forma segura con una línea nueva final?
JSON.stringify(obj, null, 2) + "\n"— la línea nueva final coincide con convenciones POSIX.- Siempre
awaitwriteFiley usa codificación"utf8". - Para escrituras concurrentes al mismo archivo, usa un bloqueo o serializa a través de una cola.
¿Por qué usar process.exitCode en lugar de process.exit?
process.exit(code)termina inmediatamente, truncando escrituras de stdout pendientes y operaciones asincrónicas.process.exitCode = codeestablece el código de salida pero deja que el event loop se drene naturalmente.- El script se cierra con tu código una vez que se completa todo el trabajo pendiente — sin pérdida de datos.
Relacionado
- ESLint for Node.js Scripts — lint tus scripts con reglas conscientes de tipos
- Git Utilities — herramientas de shell para scripts de automatización
- TypeScript Basics — tsconfig para proyectos de Node
- Environment Variables — cargando configuración en scripts