React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

nodejsscriptsfsglobchalkinquirerora

Utility Script Patterns

Common patterns for writing Node.js utility scripts — file I/O, globbing, colored output, interactive prompts, and spinners.

Recipe

Quick-reference recipe card — copy-paste ready.

# Install the standard utility-script toolkit
npm install --save-dev \
  fast-glob \
  chalk \
  @inquirer/prompts \
  ora
 
# Node built-ins — no install needed
#   node:fs/promises   — async file I/O
#   node:path          — cross-platform paths
#   node:url           — fileURLToPath for __dirname in ESM
// Minimal skeleton
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";

When to reach for this: Any CLI utility, codemod, build step, or maintenance script where you need progress feedback and safe file operations.

Working Example

// 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. Find files
  const files = await fg("src/**/*.ts", {
    ignore: ["**/*.d.ts", "**/node_modules/**"],
    absolute: true,
  });
 
  console.log(chalk.cyan(`Found ${files.length} TypeScript files.`));
 
  if (files.length === 0) {
    console.log(chalk.yellow("Nothing to do."));
    return;
  }
 
  // 2. Confirm with the user
  const proceed = await confirm({
    message: `Process ${files.length} files?`,
    default: false,
  });
 
  if (!proceed) {
    console.log(chalk.gray("Aborted."));
    return;
  }
 
  // 3. Process with a spinner
  const spinner = ora("Processing files...").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 = `Processing ${path.basename(file)}`;
    }
    spinner.succeed(chalk.green(`Normalized ${changed} file(s).`));
  } catch (err) {
    spinner.fail(chalk.red("Processing failed."));
    throw err;
  }
}
 
main().catch((err: unknown) => {
  console.error(chalk.red("Script error:"), err);
  process.exitCode = 1;
});

What this demonstrates:

  • Async file I/O via node:fs/promises (never readFileSync in an async script)
  • fast-glob for fast, flexible file matching with ignore patterns
  • @inquirer/prompts named-function API (replaces the legacy inquirer.prompt() object API)
  • ora spinner with dynamic text updates and succeed / fail states
  • Proper error handling that sets process.exitCode instead of calling process.exit()

Deep Dive

How It Works

  • node:fs/promises exposes all fs functions as promise-returning variants. No need for util.promisify anymore.
  • fast-glob is the fastest widely-used glob library for Node. It returns a plain string[] by default (or Entry[] with objectMode: true).
  • chalk v5 is ESM-only. If you're on CommonJS, either pin to chalk v4 or convert your project to ESM.
  • @inquirer/prompts is the modern replacement for the monolithic inquirer package. Each prompt (input, confirm, select, checkbox) is imported as a named function that returns a promise.
  • ora writes to stderr by default and detects TTY to disable animations in CI. You can force state via { isEnabled: process.stdout.isTTY }.

Variations

Reading and writing JSON files:

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");

Stream-based processing for large files:

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) {
  // process one line at a time — memory stays flat
}

Recursive directory walking without a glob library:

import { readdir } from "node:fs/promises";
 
// Node 20+ supports recursive: true
const entries = await readdir("src", { recursive: true, withFileTypes: true });
const files = entries.filter((e) => e.isFile()).map((e) => e.name);

Progress bars with 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();

Detecting TTY to silence decoration in CI:

const isInteractive = process.stdout.isTTY && !process.env.CI;
const spinner = ora({ text: "Working...", isEnabled: isInteractive });

Template strings with chalk:

console.log(`${chalk.bold.blue("info")} Found ${chalk.yellow(files.length)} files`);

TypeScript Notes

  • fs.readFile(path, "utf8") returns Promise<string>. Without the encoding, it returns Promise<Buffer>. Always pass "utf8" when you want a string.
  • fast-glob returns Promise<string[]> when called without options. With { objectMode: true } it returns Promise<Entry[]>.
  • @inquirer/prompts infers return types from the prompt: confirm() returns Promise<boolean>, input() returns Promise<string>, and select<T>() accepts a generic for the choice value.
  • Type the top-level script error handler as (err: unknown) and narrow before logging.

Gotchas

Things that will bite you. Each gotcha includes what goes wrong, why it happens, and the fix.

  • Using fs.readFileSync in an async script — Blocks the event loop, defeats concurrency, and makes spinners stutter. Fix: Use import { readFile } from "node:fs/promises" and await it. Sync APIs are only appropriate in startup code before any async work.

  • Forgetting to await an fs.promises callwriteFile(path, data) without await returns an unhandled promise. The script exits before the write completes, silently losing data. Fix: Always await, and enable @typescript-eslint/no-floating-promises to catch this at lint time.

  • Chalk colors not appearing in CI — Chalk auto-detects TTY and disables colors in non-interactive environments. Logs in GitHub Actions or CircleCI appear uncolored by design. Fix: Set FORCE_COLOR=1 as an env var in CI, or use new Chalk(\{ level: 3 \}) to force color level.

  • Relative vs absolute paths in globfast-glob returns paths relative to cwd by default. Passing those to fs.readFile works only if the script is run from the same directory. Fix: Use { absolute: true } or resolve manually with path.resolve(process.cwd(), file).

  • Emoji breaking Windows terminals — Legacy cmd.exe and older PowerShell render emoji as garbled bytes. Fix: Detect Windows via process.platform === "win32" and fall back to ASCII, or require Windows Terminal (UTF-8 by default).

  • Calling process.exit() before writes flushprocess.exit(1) terminates immediately, truncating stdout and pending fs.writeFile calls. Fix: Set process.exitCode = 1 and let the event loop drain naturally.

Alternatives

Other ways to solve the same problem — and when each is the better choice.

AlternativeUse WhenDon't Use When
execaYou need to spawn external commands with good DXYou only need in-process file I/O
zxYou want shell-script ergonomics with JS templatesYou need strict typing or prefer explicit APIs
Bun.file / Bun shellYou're running on Bun, not NodeYou target Node.js in production
node:readlineYou need interactive single-line input with no depsYou want rich prompts (select, checkbox, etc.)

FAQs

Why prefer node:fs/promises over util.promisify(fs.readFile)?
  • node:fs/promises is the canonical modern API — no wrapping needed.
  • Every fs function has a promise variant already exported.
  • util.promisify is only useful for older third-party callback APIs.
Why use fast-glob instead of the built-in fs.readdir recursive option?
  • fs.readdir(\{ recursive: true \}) (Node 20+) works for simple cases but has no glob pattern support.
  • fast-glob supports **, *, brace expansion, negation, and ignore patterns.
  • For anything beyond a flat recursive listing, fast-glob is simpler and faster.
What's the difference between @inquirer/prompts and legacy inquirer?
  • Legacy inquirer exposes a monolithic inquirer.prompt([\{ type, name, message \}]) API.
  • @inquirer/prompts exposes each prompt as a named function: confirm(), input(), select().
  • The new API is lighter, tree-shakeable, and has better TypeScript inference.
Why does chalk v5 require ESM?
  • Chalk v5 dropped CommonJS support to reduce maintenance burden.
  • Either migrate your project to ESM ("type": "module" in package.json).
  • Or pin chalk to v4 which still supports CommonJS require().
Gotcha: Why does my script exit before files finish writing?
  • You forgot to await an fs.writeFile call, or you called process.exit() too early.
  • Unhandled promises don't keep the event loop alive the way awaited ones do.
  • Fix: await every async call and use process.exitCode = 1 instead of process.exit(1).
Gotcha: Why are chalk colors missing in my CI logs?
  • Chalk auto-detects TTY and disables color in non-interactive environments.
  • CI runners typically pipe stdout, so isTTY is false.
  • Fix: Set FORCE_COLOR=1 in your CI env, or configure a higher color level explicitly.
TypeScript: How do I type the return value of fs.readFile?
  • With "utf8" encoding: Promise<string>.
  • Without encoding: Promise<Buffer>.
  • Always pass the encoding explicitly when you want a string, or use a generic wrapper.
TypeScript: How do I type an @inquirer/prompts select prompt?
  • select() accepts a generic type parameter for the chosen value.
  • Example: const choice = await select<"a" | "b">(\{ message, choices \}).
  • The return type is inferred from the generic, giving a string literal union.
How do I show dynamic progress in an ora spinner?
  • Reassign spinner.text inside your loop — ora re-renders on each tick.
  • Call spinner.succeed(msg) or spinner.fail(msg) to stop with a final state.
  • For true progress (x of N), use cli-progress instead.
When should I stream a file instead of reading it into memory?
  • Files larger than ~100 MB, or files that don't fit your process memory budget.
  • Use createReadStream + readline.createInterface for line-by-line iteration.
  • Streams keep memory flat regardless of file size.
How do I safely write JSON with a trailing newline?
  • JSON.stringify(obj, null, 2) + "\n" — the trailing newline matches POSIX conventions.
  • Always await writeFile and use "utf8" encoding.
  • For concurrent writes to the same file, use a lock or serialize through a queue.
Why use process.exitCode instead of process.exit?
  • process.exit(code) terminates immediately, truncating pending stdout writes and async operations.
  • process.exitCode = code sets the exit code but lets the event loop drain naturally.
  • The script exits with your code once all pending work completes — no data loss.