← Recipes

阅读中文版本

Build Interactive CLI Prompts with Validation and Safe Defaults

Ask questions only when arguments are absent, validate answers in the loop, and make non-interactive mode the escape hatch that always works.

~30 minclideveloper-experiencenodejs

Task

Add an interactive prompt layer with re-ask on invalid input, an explicit default, and a clean non-interactive path for scripts and CI.

When to use this

An init command prompts for a project name, accepts anything including the empty string, and hangs forever when a CI job runs it with no TTY.

Prerequisites

  • Node.js 18+ (or Bash with read -r -p)
  • A command that currently requires arguments users never remember
  • A test path without a TTY for the non-interactive check

Steps

1. Detect a TTY and refuse interactive mode without one.

// prompt.mjs
import { createInterface } from 'node:readline/promises';
import { stdin, stdout } from 'node:process';

export const isInteractive = stdin.isTTY === true;

export function failNoTty(flagName) {
  console.error(
    `error: ${flagName} is required when stdin is not a TTY\n` +
    `pass --${flagName} explicitly in scripts and CI`
  );
  process.exit(2);
}

Expected output: Running under CI or a pipe now exits with a clear message instead of hanging on a prompt nobody can answer.

2. Prompt only for missing values, with a visible default.

async function ask(question, { default: def, validate } = {}) {
  if (!isInteractive) failNoTty('see individual flags');
  const rl = createInterface({ input: stdin, output: stdout });
  try {
    for (;;) {
      const suffix = def !== undefined ? ` (${def})` : '';
      const raw = (await rl.question(`${question}${suffix}: `)).trim();
      const val = raw === '' && def !== undefined ? def : raw;
      const err = validate?.(val);
      if (!err) return val;
      console.error(`  invalid: ${err}`);
    }
  } finally {
    rl.close();
  }
}

const validateName = (v) =>
  !v ? 'name is required' :
  !/^[a-z0-9-]+$/.test(v) ? 'lowercase letters, digits and dashes only' :
  v.length > 40 ? 'max 40 chars' : undefined;

Expected output: Invalid answers re-prompt with the specific reason shown; empty input falls back to the default, and Ctrl-C still exits cleanly via the finally block.

3. Wire prompts behind absent flags, never in front of them.

import { parseArgs } from 'node:util';

const { values: args } = parseArgs({
  options: {
    name: { type: 'string' },
    region: { type: 'string', default: 'us-east-1' },
    yes: { type: 'boolean', short: 'y' },
  },
});

const name = args.name ?? await ask('project name', { validate: validateName });
const region = args.region;   // never prompted: has a default
if (!args.yes && isInteractive) {
  const ok = await ask(`create ${name} in ${region}?`, { default: 'yes' });
  if (!/^y(es)?$/i.test(ok)) { console.error('aborted'); process.exit(1); }
}

Expected output: Flags drive automation with no prompts at all; humans get asked only for what they omitted, and the confirm step respects a --yes bypass.

4. Prove the non-interactive path works under a pipe.

echo "" | node cli.mjs --name demo-proj --yes 2>&1 | tail -2; node cli.mjs < /dev/null 2>&1 | head -2; echo "exit=$?"

Expected output: The piped run completes with the flags given; the TTY-less run without required flags exits 2 with the actionable message, and nothing hangs.

5. Add a scripted end-to-end check for the prompt loop itself.

// test-prompt.mjs -- drive the loop through a pipe
import { ExecFile } from 'node:child_process';
import { promisify } from 'node:util';
const exec = promisify(ExecFile);

const bad = await exec('node', ['cli.mjs'], {
  input: 'Bad Name!\n',
});
// a real test would assert the retry message appeared, then a valid name succeeded

Expected output: The driven run exercises invalid-then-valid input through stdin, verifying the re-ask loop without a human at the keyboard.

Troubleshooting

Problem

Prompt hangs in CI

Solution

The TTY check is missing or checked too late; test with < /dev/null in CI before shipping, and exit early with the required-flag message whenever stdin is not a terminal.

Problem

Answers with spaces or quotes break the script

Solution

Read with read -r in shell or readline in Node, never bare read; quote every interpolation, and validate the shape before use so punctuation never reaches downstream commands.

Problem

Users fat-finger destructive confirmations

Solution

For destroy actions require typing the resource name (type-to-confirm), not y; defaults belong on safe operations only, and destructive prompts should default to abort.

Limitations

  • Interactive prompts are for humans only; every prompt is one more thing a script cannot answer, so flags remain the contract.
  • Type-to-confirm patterns need exact-match input, which screen-recording tools and paste may circumvent; keep hard deletes behind an explicit id, not just a word.

Every prompt is a question a script cannot answer

Interactive CLIs are friendly until they meet a pipe. The architecture that survives both worlds: flags as the contract, prompts only for absent values, validation inside the ask loop, and a hard TTY check that turns a hang into an actionable error message.

interactive session:
  $ node cli.mjs
  project name (demo-app): BAD NAME!
    invalid: lowercase letters, digits and dashes only
  project name (demo-app): demo-app
  create demo-app in us-east-1? (yes): yes
  created.

non-interactive session (CI):
  $ node cli.mjs < /dev/null
  error: --name is required when stdin is not a TTY
  pass --name explicitly in scripts and CI
  exit=2   (2 seconds, not a hang)

Validate at the prompt, not after

An answer accepted without validation becomes a bad argument with a stack trace three layers later. The ask loop with a validate callback rejects bad input immediately with the specific reason, so the user corrects it at the point of entry while their intent is still fresh.

Safe defaults, unsafe confirmations

Defaults belong on reversible choices: region, verbosity, template. Destructive actions invert the polarity: no default (empty aborts), or type-the-name-to-confirm. The asymmetry is deliberate, because the cost of a wrong enter-key is not symmetric either.