Cli Tools

Building Interactive Terminal Prompts

A hands-on guide to building rich interactive terminal prompts using raw TTY input, ANSI escape codes, and prompt libraries in Node.js.

Building Interactive Terminal Prompts

CLI tools that just accept flags and print output are functional, but they are not memorable. The tools developers actually enjoy using — npm init, create-react-app, gh — guide you through decisions with interactive prompts. Well-built prompts turn a forgettable command into a polished experience.

I have built interactive prompts from scratch and with libraries. This guide covers both approaches, from raw terminal I/O up to production-quality prompt patterns.

Prerequisites

  • Node.js installed (v14+)
  • Understanding of streams and event emitters in Node.js
  • Basic terminal/TTY concepts
  • Familiarity with ANSI escape codes is helpful but not required

Understanding the Terminal

Before building prompts, you need to understand how the terminal processes input. When you type in a terminal, the input goes through the TTY (teletypewriter) driver, which normally operates in "cooked" mode — it buffers a full line before sending it to your program.

For interactive prompts, you need "raw" mode where every keypress arrives immediately.

var readline = require("readline");

// Check if we are running in a TTY
if (!process.stdin.isTTY) {
  console.error("This program requires an interactive terminal");
  process.exit(1);
}

// Enable raw mode to capture individual keypresses
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.setEncoding("utf8");

process.stdin.on("data", function(key) {
  // Ctrl+C to exit
  if (key === "\u0003") {
    process.exit();
  }

  var charCode = key.charCodeAt(0);
  console.log("Key: " + JSON.stringify(key) + " Code: " + charCode);
});

console.log("Press any key (Ctrl+C to exit):");

Output when pressing various keys:

Press any key (Ctrl+C to exit):
Key: "a" Code: 97
Key: "\u001b[A" Code: 27    # Up arrow
Key: "\u001b[B" Code: 27    # Down arrow
Key: "\r" Code: 13          # Enter
Key: "\u007f" Code: 127     # Backspace
Key: "\t" Code: 9           # Tab

ANSI Escape Codes for Prompt Rendering

ANSI escape codes let you control cursor position, colors, and text formatting. These are the building blocks of interactive prompts.

var ANSI = {
  // Cursor movement
  up: function(n) { return "\u001b[" + (n || 1) + "A"; },
  down: function(n) { return "\u001b[" + (n || 1) + "B"; },
  forward: function(n) { return "\u001b[" + (n || 1) + "C"; },
  back: function(n) { return "\u001b[" + (n || 1) + "D"; },

  // Cursor position
  moveTo: function(row, col) { return "\u001b[" + row + ";" + col + "H"; },
  saveCursor: "\u001b[s",
  restoreCursor: "\u001b[u",

  // Line operations
  clearLine: "\u001b[2K",
  clearDown: "\u001b[J",
  clearScreen: "\u001b[2J",

  // Colors
  reset: "\u001b[0m",
  bold: "\u001b[1m",
  dim: "\u001b[2m",
  underline: "\u001b[4m",
  green: "\u001b[32m",
  yellow: "\u001b[33m",
  blue: "\u001b[34m",
  cyan: "\u001b[36m",
  gray: "\u001b[90m",
  white: "\u001b[37m",
  bgBlue: "\u001b[44m",

  // Visibility
  hideCursor: "\u001b[?25l",
  showCursor: "\u001b[?25h"
};

// Demo: Colored output
process.stdout.write(
  ANSI.bold + ANSI.green + "✔ " + ANSI.reset +
  "Successfully deployed " +
  ANSI.cyan + "v2.1.0" + ANSI.reset +
  " to " +
  ANSI.yellow + "production" + ANSI.reset + "\n"
);

Building a Text Input Prompt

Let's build a text input prompt from scratch. This is the foundation for more complex prompts.

var readline = require("readline");

function textPrompt(question, options) {
  options = options || {};
  var defaultValue = options.default || "";
  var validate = options.validate || function() { return true; };

  return new Promise(function(resolve) {
    var input = "";
    var cursorPos = 0;

    function render() {
      var line = "\r\u001b[2K"; // Move to start, clear line
      line += "\u001b[36m? \u001b[0m"; // Cyan question mark
      line += "\u001b[1m" + question + "\u001b[0m "; // Bold question

      if (defaultValue && !input) {
        line += "\u001b[90m(" + defaultValue + ") \u001b[0m";
      }

      line += input;

      process.stdout.write(line);

      // Position cursor correctly
      var totalLength = 2 + question.length + 1 + input.length;
      var offset = input.length - cursorPos;
      if (offset > 0) {
        process.stdout.write("\u001b[" + offset + "D");
      }
    }

    process.stdin.setRawMode(true);
    process.stdin.resume();
    process.stdin.setEncoding("utf8");

    render();

    function onKeypress(key) {
      // Ctrl+C
      if (key === "\u0003") {
        process.stdout.write("\n");
        process.exit();
      }

      // Enter
      if (key === "\r" || key === "\n") {
        var value = input || defaultValue;
        var valid = validate(value);

        if (valid !== true) {
          // Show validation error
          process.stdout.write("\n\u001b[31m  ✖ " + valid + "\u001b[0m");
          process.stdout.write("\u001b[1A"); // Move back up
          render();
          return;
        }

        process.stdin.removeListener("data", onKeypress);
        process.stdin.setRawMode(false);
        process.stdin.pause();

        // Show final answer
        process.stdout.write("\r\u001b[2K");
        process.stdout.write(
          "\u001b[32m✔ \u001b[0m\u001b[1m" + question + "\u001b[0m " +
          "\u001b[36m" + value + "\u001b[0m\n"
        );

        resolve(value);
        return;
      }

      // Backspace
      if (key === "\u007f" || key === "\b") {
        if (cursorPos > 0) {
          input = input.substring(0, cursorPos - 1) + input.substring(cursorPos);
          cursorPos--;
        }
        render();
        return;
      }

      // Delete
      if (key === "\u001b[3~") {
        if (cursorPos < input.length) {
          input = input.substring(0, cursorPos) + input.substring(cursorPos + 1);
        }
        render();
        return;
      }

      // Left arrow
      if (key === "\u001b[D") {
        if (cursorPos > 0) cursorPos--;
        render();
        return;
      }

      // Right arrow
      if (key === "\u001b[C") {
        if (cursorPos < input.length) cursorPos++;
        render();
        return;
      }

      // Home
      if (key === "\u001b[H" || key === "\u0001") {
        cursorPos = 0;
        render();
        return;
      }

      // End
      if (key === "\u001b[F" || key === "\u0005") {
        cursorPos = input.length;
        render();
        return;
      }

      // Regular character
      if (key.length === 1 && key.charCodeAt(0) >= 32) {
        input = input.substring(0, cursorPos) + key + input.substring(cursorPos);
        cursorPos++;
        render();
      }
    }

    process.stdin.on("data", onKeypress);
  });
}

// Usage
textPrompt("Project name:", { default: "my-project" }).then(function(name) {
  console.log("Got: " + name);
});

Terminal output:

? Project name: (my-project) █

After typing and pressing Enter:

✔ Project name: my-awesome-app

Building a Select List Prompt

Select lists let users choose from options using arrow keys:

function selectPrompt(question, choices) {
  return new Promise(function(resolve) {
    var selected = 0;
    var pageSize = Math.min(choices.length, 10);
    var scrollOffset = 0;

    function render() {
      // Clear previous render
      process.stdout.write("\u001b[?25l"); // Hide cursor

      var output = "\r\u001b[2K";
      output += "\u001b[36m? \u001b[0m";
      output += "\u001b[1m" + question + "\u001b[0m";
      output += " \u001b[90m(Use arrow keys)\u001b[0m\n";

      // Adjust scroll
      if (selected >= scrollOffset + pageSize) {
        scrollOffset = selected - pageSize + 1;
      }
      if (selected < scrollOffset) {
        scrollOffset = selected;
      }

      var end = Math.min(scrollOffset + pageSize, choices.length);

      if (scrollOffset > 0) {
        output += "  \u001b[90m(↑ more)\u001b[0m\n";
      }

      for (var i = scrollOffset; i < end; i++) {
        var choice = typeof choices[i] === "string"
          ? { name: choices[i], value: choices[i] }
          : choices[i];

        if (i === selected) {
          output += "  \u001b[36m❯ " + choice.name + "\u001b[0m";
        } else {
          output += "    " + choice.name;
        }

        if (choice.description) {
          output += " \u001b[90m- " + choice.description + "\u001b[0m";
        }

        output += "\n";
      }

      if (end < choices.length) {
        output += "  \u001b[90m(↓ more)\u001b[0m\n";
      }

      process.stdout.write(output);
    }

    function clearRender() {
      var linesToClear = pageSize + 2; // question + choices + possible scroll indicators
      for (var i = 0; i < linesToClear; i++) {
        process.stdout.write("\u001b[1A\u001b[2K");
      }
    }

    process.stdin.setRawMode(true);
    process.stdin.resume();
    process.stdin.setEncoding("utf8");

    render();

    function onKeypress(key) {
      if (key === "\u0003") {
        process.stdout.write("\u001b[?25h\n");
        process.exit();
      }

      // Up arrow or k
      if (key === "\u001b[A" || key === "k") {
        clearRender();
        selected = selected > 0 ? selected - 1 : choices.length - 1;
        render();
        return;
      }

      // Down arrow or j
      if (key === "\u001b[B" || key === "j") {
        clearRender();
        selected = selected < choices.length - 1 ? selected + 1 : 0;
        render();
        return;
      }

      // Enter
      if (key === "\r" || key === "\n") {
        process.stdin.removeListener("data", onKeypress);
        process.stdin.setRawMode(false);
        process.stdin.pause();

        clearRender();

        var choice = typeof choices[selected] === "string"
          ? { name: choices[selected], value: choices[selected] }
          : choices[selected];

        process.stdout.write(
          "\u001b[32m✔ \u001b[0m\u001b[1m" + question + "\u001b[0m " +
          "\u001b[36m" + choice.name + "\u001b[0m\n"
        );
        process.stdout.write("\u001b[?25h"); // Show cursor

        resolve(choice.value);
        return;
      }
    }

    process.stdin.on("data", onKeypress);
  });
}

// Usage
selectPrompt("Select a framework:", [
  { name: "Express", value: "express", description: "Fast, minimal" },
  { name: "Fastify", value: "fastify", description: "High performance" },
  { name: "Koa", value: "koa", description: "Expressive middleware" },
  { name: "Hapi", value: "hapi", description: "Enterprise-grade" }
]).then(function(framework) {
  console.log("Selected: " + framework);
});

Terminal output:

? Select a framework: (Use arrow keys)
  ❯ Express - Fast, minimal
    Fastify - High performance
    Koa - Expressive middleware
    Hapi - Enterprise-grade

Building a Confirm Prompt

Confirm prompts are simple but used everywhere:

function confirmPrompt(question, defaultYes) {
  if (defaultYes === undefined) defaultYes = true;

  return new Promise(function(resolve) {
    var hint = defaultYes ? "Y/n" : "y/N";

    process.stdout.write(
      "\u001b[36m? \u001b[0m\u001b[1m" + question + "\u001b[0m " +
      "\u001b[90m(" + hint + ") \u001b[0m"
    );

    process.stdin.setRawMode(true);
    process.stdin.resume();
    process.stdin.setEncoding("utf8");

    function onKeypress(key) {
      if (key === "\u0003") {
        process.stdout.write("\n");
        process.exit();
      }

      var answer;

      if (key === "y" || key === "Y") {
        answer = true;
      } else if (key === "n" || key === "N") {
        answer = false;
      } else if (key === "\r" || key === "\n") {
        answer = defaultYes;
      } else {
        return; // Ignore other keys
      }

      process.stdin.removeListener("data", onKeypress);
      process.stdin.setRawMode(false);
      process.stdin.pause();

      // Overwrite the prompt line with the answer
      process.stdout.write("\r\u001b[2K");
      process.stdout.write(
        "\u001b[32m✔ \u001b[0m\u001b[1m" + question + "\u001b[0m " +
        "\u001b[36m" + (answer ? "Yes" : "No") + "\u001b[0m\n"
      );

      resolve(answer);
    }

    process.stdin.on("data", onKeypress);
  });
}

// Usage
confirmPrompt("Deploy to production?", false).then(function(confirmed) {
  if (confirmed) {
    console.log("Deploying...");
  } else {
    console.log("Aborted.");
  }
});

Building a Checkbox Prompt

Multi-select prompts let users toggle options with space:

function checkboxPrompt(question, choices) {
  return new Promise(function(resolve) {
    var selected = new Array(choices.length);
    for (var s = 0; s < selected.length; s++) {
      selected[s] = choices[s].checked || false;
    }
    var cursor = 0;

    function render() {
      process.stdout.write("\u001b[?25l");

      var output = "\r\u001b[2K";
      output += "\u001b[36m? \u001b[0m";
      output += "\u001b[1m" + question + "\u001b[0m";
      output += " \u001b[90m(Space to select, Enter to confirm)\u001b[0m\n";

      for (var i = 0; i < choices.length; i++) {
        var name = typeof choices[i] === "string" ? choices[i] : choices[i].name;
        var prefix = cursor === i ? "\u001b[36m❯\u001b[0m " : "  ";
        var checkbox = selected[i]
          ? "\u001b[32m◉\u001b[0m "
          : "\u001b[90m○\u001b[0m ";

        output += prefix + checkbox;
        output += cursor === i ? "\u001b[36m" + name + "\u001b[0m" : name;
        output += "\n";
      }

      process.stdout.write(output);
    }

    function clearRender() {
      for (var i = 0; i <= choices.length; i++) {
        process.stdout.write("\u001b[1A\u001b[2K");
      }
    }

    process.stdin.setRawMode(true);
    process.stdin.resume();
    process.stdin.setEncoding("utf8");

    render();

    function onKeypress(key) {
      if (key === "\u0003") {
        process.stdout.write("\u001b[?25h\n");
        process.exit();
      }

      if (key === "\u001b[A" || key === "k") {
        clearRender();
        cursor = cursor > 0 ? cursor - 1 : choices.length - 1;
        render();
        return;
      }

      if (key === "\u001b[B" || key === "j") {
        clearRender();
        cursor = cursor < choices.length - 1 ? cursor + 1 : 0;
        render();
        return;
      }

      // Space to toggle
      if (key === " ") {
        clearRender();
        selected[cursor] = !selected[cursor];
        render();
        return;
      }

      // 'a' to toggle all
      if (key === "a") {
        clearRender();
        var allSelected = selected.every(function(s) { return s; });
        for (var i = 0; i < selected.length; i++) {
          selected[i] = !allSelected;
        }
        render();
        return;
      }

      if (key === "\r" || key === "\n") {
        process.stdin.removeListener("data", onKeypress);
        process.stdin.setRawMode(false);
        process.stdin.pause();

        clearRender();

        var results = [];
        var names = [];
        for (var j = 0; j < choices.length; j++) {
          if (selected[j]) {
            var choice = typeof choices[j] === "string"
              ? { name: choices[j], value: choices[j] }
              : choices[j];
            results.push(choice.value || choice.name);
            names.push(choice.name);
          }
        }

        process.stdout.write(
          "\u001b[32m✔ \u001b[0m\u001b[1m" + question + "\u001b[0m " +
          "\u001b[36m" + names.join(", ") + "\u001b[0m\n"
        );
        process.stdout.write("\u001b[?25h");

        resolve(results);
      }
    }

    process.stdin.on("data", onKeypress);
  });
}

// Usage
checkboxPrompt("Select features:", [
  { name: "TypeScript", value: "typescript" },
  { name: "ESLint", value: "eslint", checked: true },
  { name: "Prettier", value: "prettier", checked: true },
  { name: "Jest", value: "jest" },
  { name: "Docker", value: "docker" },
  { name: "CI/CD Pipeline", value: "cicd" }
]).then(function(features) {
  console.log("Features: " + features.join(", "));
});

Terminal output:

? Select features: (Space to select, Enter to confirm)
❯ ○ TypeScript
  ◉ ESLint
  ◉ Prettier
  ○ Jest
  ○ Docker
  ○ CI/CD Pipeline

Building a Password Prompt

Password prompts need to mask input while still tracking what was typed:

function passwordPrompt(question, options) {
  options = options || {};
  var mask = options.mask !== undefined ? options.mask : "*";

  return new Promise(function(resolve) {
    var input = "";

    function render() {
      process.stdout.write("\r\u001b[2K");
      process.stdout.write("\u001b[36m? \u001b[0m");
      process.stdout.write("\u001b[1m" + question + "\u001b[0m ");

      if (mask) {
        var masked = "";
        for (var i = 0; i < input.length; i++) {
          masked += mask;
        }
        process.stdout.write(masked);
      }
    }

    process.stdin.setRawMode(true);
    process.stdin.resume();
    process.stdin.setEncoding("utf8");

    render();

    function onKeypress(key) {
      if (key === "\u0003") {
        process.stdout.write("\n");
        process.exit();
      }

      if (key === "\r" || key === "\n") {
        process.stdin.removeListener("data", onKeypress);
        process.stdin.setRawMode(false);
        process.stdin.pause();

        process.stdout.write("\r\u001b[2K");
        process.stdout.write(
          "\u001b[32m✔ \u001b[0m\u001b[1m" + question + "\u001b[0m " +
          "\u001b[90m" + (mask ? "[hidden]" : "[entered]") + "\u001b[0m\n"
        );

        resolve(input);
        return;
      }

      if (key === "\u007f" || key === "\b") {
        if (input.length > 0) {
          input = input.substring(0, input.length - 1);
        }
        render();
        return;
      }

      if (key.length === 1 && key.charCodeAt(0) >= 32) {
        input += key;
        render();
      }
    }

    process.stdin.on("data", onKeypress);
  });
}

// Usage
passwordPrompt("API key:", { mask: "•" }).then(function(key) {
  console.log("Key length: " + key.length);
});

Output while typing:

? API key: ••••••••••••

After pressing Enter:

✔ API key: [hidden]

Composing Prompts into Flows

Real CLI tools chain multiple prompts into a flow. Here is a project scaffolding wizard:

function runWizard() {
  console.log("\n\u001b[1mCreate a new project\u001b[0m\n");

  textPrompt("Project name:", {
    validate: function(val) {
      if (!val) return "Name is required";
      if (!/^[a-z0-9-]+$/.test(val)) return "Use lowercase letters, numbers, and hyphens";
      return true;
    }
  })
  .then(function(name) {
    return selectPrompt("Framework:", [
      { name: "Express", value: "express" },
      { name: "Fastify", value: "fastify" },
      { name: "Koa", value: "koa" }
    ]).then(function(framework) {
      return { name: name, framework: framework };
    });
  })
  .then(function(answers) {
    return checkboxPrompt("Features:", [
      { name: "TypeScript", value: "typescript" },
      { name: "ESLint", value: "eslint", checked: true },
      { name: "Docker", value: "docker" },
      { name: "Tests", value: "tests", checked: true }
    ]).then(function(features) {
      answers.features = features;
      return answers;
    });
  })
  .then(function(answers) {
    return selectPrompt("Package manager:", [
      "npm", "yarn", "pnpm"
    ]).then(function(pm) {
      answers.packageManager = pm;
      return answers;
    });
  })
  .then(function(answers) {
    return confirmPrompt("Create project?").then(function(ok) {
      if (!ok) {
        console.log("\nAborted.");
        process.exit(0);
      }
      return answers;
    });
  })
  .then(function(answers) {
    console.log("\n\u001b[1mProject configuration:\u001b[0m\n");
    console.log("  Name:      " + answers.name);
    console.log("  Framework: " + answers.framework);
    console.log("  Features:  " + answers.features.join(", "));
    console.log("  PM:        " + answers.packageManager);
    console.log("\n\u001b[32m✔ Project created successfully!\u001b[0m\n");
  });
}

runWizard();

Terminal session:

Create a new project

? Project name: my-api
? Framework: Fastify
? Features: ESLint, Tests
? Package manager: npm
? Create project? Yes

Project configuration:

  Name:      my-api
  Framework: fastify
  Features:  eslint, tests
  PM:        npm

✔ Project created successfully!

Handling Non-Interactive Environments

Your prompts must detect and handle non-interactive environments like CI pipelines:

function isInteractive() {
  return process.stdin.isTTY && process.stdout.isTTY;
}

function promptWithFallback(question, options) {
  options = options || {};

  if (!isInteractive()) {
    if (options.default !== undefined) {
      console.log(question + " " + options.default + " (non-interactive, using default)");
      return Promise.resolve(options.default);
    }

    console.error("Error: " + question + " requires interactive input");
    console.error("Provide a default value or run in an interactive terminal");
    process.exit(1);
  }

  return textPrompt(question, options);
}

// For select prompts, accept a --flag as override
function selectWithOverride(question, choices, overrideValue) {
  if (overrideValue) {
    var valid = choices.some(function(c) {
      var val = typeof c === "string" ? c : c.value;
      return val === overrideValue;
    });

    if (valid) {
      return Promise.resolve(overrideValue);
    }

    var validValues = choices.map(function(c) {
      return typeof c === "string" ? c : c.value;
    });
    console.error("Invalid value: " + overrideValue);
    console.error("Valid options: " + validValues.join(", "));
    process.exit(1);
  }

  if (!isInteractive()) {
    console.error("Error: " + question + " requires interactive input or --flag");
    process.exit(1);
  }

  return selectPrompt(question, choices);
}

Complete Working Example: Interactive Project Scaffolder

Here is a complete, production-quality project scaffolding CLI that combines all the prompt patterns:

#!/usr/bin/env node

var fs = require("fs");
var path = require("path");
var childProcess = require("child_process");

// ---- ANSI helpers ----

var c = {
  reset: "\u001b[0m",
  bold: "\u001b[1m",
  dim: "\u001b[2m",
  green: "\u001b[32m",
  yellow: "\u001b[33m",
  cyan: "\u001b[36m",
  gray: "\u001b[90m",
  red: "\u001b[31m",
  hide: "\u001b[?25l",
  show: "\u001b[?25h",
  clear: "\u001b[2K",
  up: function(n) { return "\u001b[" + n + "A"; }
};

// ---- Prompt primitives ----

function ask(question, opts) {
  opts = opts || {};
  if (!process.stdin.isTTY) {
    if (opts.default !== undefined) return Promise.resolve(opts.default);
    console.error("Non-interactive: cannot ask \"" + question + "\"");
    process.exit(1);
  }

  return new Promise(function(resolve) {
    var input = "";

    process.stdin.setRawMode(true);
    process.stdin.resume();
    process.stdin.setEncoding("utf8");

    function draw() {
      var line = "\r" + c.clear;
      line += c.cyan + "? " + c.reset + c.bold + question + c.reset + " ";
      if (!input && opts.default) line += c.gray + "(" + opts.default + ") " + c.reset;
      line += input;
      process.stdout.write(line);
    }

    draw();

    function handler(key) {
      if (key === "\u0003") { process.stdout.write("\n"); process.exit(); }

      if (key === "\r") {
        var val = input || opts.default || "";
        if (opts.validate) {
          var err = opts.validate(val);
          if (err !== true) {
            process.stdout.write("\n" + c.red + "  ✖ " + err + c.reset + c.up(1));
            draw();
            return;
          }
        }
        cleanup();
        process.stdout.write("\r" + c.clear + c.green + "✔ " + c.reset +
          c.bold + question + c.reset + " " + c.cyan + val + c.reset + "\n");
        resolve(val);
        return;
      }

      if (key === "\u007f") {
        input = input.slice(0, -1);
        draw();
        return;
      }

      if (key.length === 1 && key.charCodeAt(0) >= 32) {
        input += key;
        draw();
      }
    }

    function cleanup() {
      process.stdin.removeListener("data", handler);
      process.stdin.setRawMode(false);
      process.stdin.pause();
    }

    process.stdin.on("data", handler);
  });
}

function choose(question, items) {
  if (!process.stdin.isTTY) {
    console.error("Non-interactive: cannot choose \"" + question + "\"");
    process.exit(1);
  }

  return new Promise(function(resolve) {
    var idx = 0;

    function draw() {
      process.stdout.write(c.hide);
      var out = "\r" + c.clear + c.cyan + "? " + c.reset +
        c.bold + question + c.reset + c.gray + " (↑/↓)" + c.reset + "\n";

      for (var i = 0; i < items.length; i++) {
        var name = typeof items[i] === "string" ? items[i] : items[i].name;
        out += i === idx
          ? "  " + c.cyan + "❯ " + name + c.reset + "\n"
          : "    " + name + "\n";
      }
      process.stdout.write(out);
    }

    function wipe() {
      for (var i = 0; i <= items.length; i++) {
        process.stdout.write(c.up(1) + c.clear);
      }
    }

    process.stdin.setRawMode(true);
    process.stdin.resume();
    process.stdin.setEncoding("utf8");
    draw();

    function handler(key) {
      if (key === "\u0003") { process.stdout.write(c.show + "\n"); process.exit(); }
      if (key === "\u001b[A") { wipe(); idx = (idx - 1 + items.length) % items.length; draw(); return; }
      if (key === "\u001b[B") { wipe(); idx = (idx + 1) % items.length; draw(); return; }
      if (key === "\r") {
        process.stdin.removeListener("data", handler);
        process.stdin.setRawMode(false);
        process.stdin.pause();
        wipe();
        var item = typeof items[idx] === "string"
          ? { name: items[idx], value: items[idx] }
          : items[idx];
        process.stdout.write(c.green + "✔ " + c.reset + c.bold + question +
          c.reset + " " + c.cyan + item.name + c.reset + c.show + "\n");
        resolve(item.value || item.name);
      }
    }

    process.stdin.on("data", handler);
  });
}

function confirm(question, def) {
  if (def === undefined) def = true;
  if (!process.stdin.isTTY) return Promise.resolve(def);

  return new Promise(function(resolve) {
    var hint = def ? "Y/n" : "y/N";
    process.stdout.write(c.cyan + "? " + c.reset + c.bold + question +
      c.reset + " " + c.gray + "(" + hint + ") " + c.reset);

    process.stdin.setRawMode(true);
    process.stdin.resume();
    process.stdin.setEncoding("utf8");

    function handler(key) {
      if (key === "\u0003") { process.stdout.write("\n"); process.exit(); }
      var val;
      if (key === "y" || key === "Y") val = true;
      else if (key === "n" || key === "N") val = false;
      else if (key === "\r") val = def;
      else return;

      process.stdin.removeListener("data", handler);
      process.stdin.setRawMode(false);
      process.stdin.pause();
      process.stdout.write("\r" + c.clear + c.green + "✔ " + c.reset +
        c.bold + question + c.reset + " " + c.cyan +
        (val ? "Yes" : "No") + c.reset + "\n");
      resolve(val);
    }

    process.stdin.on("data", handler);
  });
}

// ---- Scaffolding logic ----

var TEMPLATES = {
  express: {
    deps: { "express": "^4.18.0" },
    entry: 'var express = require("express");\nvar app = express();\nvar port = process.env.PORT || 3000;\n\napp.get("/", function(req, res) {\n  res.json({ status: "ok" });\n});\n\napp.listen(port, function() {\n  console.log("Server running on port " + port);\n});\n'
  },
  fastify: {
    deps: { "fastify": "^4.0.0" },
    entry: 'var fastify = require("fastify")({ logger: true });\n\nfastify.get("/", function(req, reply) {\n  reply.send({ status: "ok" });\n});\n\nfastify.listen({ port: 3000 }, function(err) {\n  if (err) throw err;\n});\n'
  },
  koa: {
    deps: { "koa": "^2.14.0", "@koa/router": "^12.0.0" },
    entry: 'var Koa = require("koa");\nvar Router = require("@koa/router");\n\nvar app = new Koa();\nvar router = new Router();\n\nrouter.get("/", function(ctx) {\n  ctx.body = { status: "ok" };\n});\n\napp.use(router.routes());\napp.listen(3000, function() {\n  console.log("Server running on port 3000");\n});\n'
  }
};

function scaffold(answers) {
  var dir = path.join(process.cwd(), answers.name);

  console.log("\n" + c.bold + "Creating project..." + c.reset + "\n");

  // Create directories
  fs.mkdirSync(dir, { recursive: true });
  fs.mkdirSync(path.join(dir, "src"), { recursive: true });

  // package.json
  var pkg = {
    name: answers.name,
    version: "1.0.0",
    main: "src/index.js",
    scripts: { start: "node src/index.js" },
    dependencies: TEMPLATES[answers.framework].deps
  };

  if (answers.features.indexOf("tests") !== -1) {
    pkg.scripts.test = "jest";
    pkg.devDependencies = pkg.devDependencies || {};
    pkg.devDependencies.jest = "^29.0.0";
  }

  if (answers.features.indexOf("eslint") !== -1) {
    pkg.scripts.lint = "eslint src/";
    pkg.devDependencies = pkg.devDependencies || {};
    pkg.devDependencies.eslint = "^8.0.0";
  }

  fs.writeFileSync(
    path.join(dir, "package.json"),
    JSON.stringify(pkg, null, 2) + "\n"
  );

  // Entry file
  fs.writeFileSync(
    path.join(dir, "src", "index.js"),
    TEMPLATES[answers.framework].entry
  );

  // Dockerfile
  if (answers.features.indexOf("docker") !== -1) {
    var dockerfile = "FROM node:20-alpine\nWORKDIR /app\nCOPY package*.json ./\n" +
      "RUN npm ci --production\nCOPY . .\nEXPOSE 3000\nCMD [\"node\", \"src/index.js\"]\n";
    fs.writeFileSync(path.join(dir, "Dockerfile"), dockerfile);
    fs.writeFileSync(path.join(dir, ".dockerignore"), "node_modules\n.git\n");
    console.log("  " + c.green + "✔" + c.reset + " Dockerfile");
  }

  // .gitignore
  fs.writeFileSync(path.join(dir, ".gitignore"), "node_modules/\n.env\ncoverage/\n");

  console.log("  " + c.green + "✔" + c.reset + " package.json");
  console.log("  " + c.green + "✔" + c.reset + " src/index.js");
  console.log("  " + c.green + "✔" + c.reset + " .gitignore");

  // Install dependencies
  if (answers.install) {
    console.log("\n" + c.dim + "Installing dependencies..." + c.reset);
    childProcess.execSync("npm install", { cwd: dir, stdio: "inherit" });
  }

  console.log("\n" + c.green + c.bold + "✔ Project ready!" + c.reset);
  console.log("\n  cd " + answers.name);
  console.log("  npm start\n");
}

// ---- Main ----

function main() {
  console.log("\n" + c.bold + "🛠  Create New Project" + c.reset + "\n");

  ask("Project name:", {
    validate: function(v) {
      if (!v) return "Required";
      if (!/^[a-z0-9-]+$/.test(v)) return "Lowercase, numbers, hyphens only";
      if (fs.existsSync(path.join(process.cwd(), v))) return "Directory already exists";
      return true;
    }
  })
  .then(function(name) {
    return choose("Framework:", [
      { name: "Express", value: "express" },
      { name: "Fastify", value: "fastify" },
      { name: "Koa", value: "koa" }
    ]).then(function(fw) { return { name: name, framework: fw }; });
  })
  .then(function(a) {
    return choose("Node version:", [
      { name: "20 LTS", value: "20" },
      { name: "18 LTS", value: "18" }
    ]).then(function(v) { a.nodeVersion = v; return a; });
  })
  .then(function(a) {
    return choose("Features:", [
      { name: "ESLint + Tests", value: ["eslint", "tests"] },
      { name: "ESLint + Tests + Docker", value: ["eslint", "tests", "docker"] },
      { name: "Minimal (none)", value: [] }
    ]).then(function(f) { a.features = f; return a; });
  })
  .then(function(a) {
    return confirm("Install dependencies now?").then(function(ok) {
      a.install = ok;
      return a;
    });
  })
  .then(function(a) {
    return confirm("Create project?").then(function(ok) {
      if (!ok) { console.log("\nAborted.\n"); process.exit(0); }
      return a;
    });
  })
  .then(function(answers) {
    scaffold(answers);
  });
}

main();

Run the scaffolder:

$ node create-project.js

🛠  Create New Project

? Project name: my-api
? Framework: Fastify
? Node version: 20 LTS
? Features: ESLint + Tests + Docker
? Install dependencies now? Yes
? Create project? Yes

Creating project...

  ✔ Dockerfile
  ✔ package.json
  ✔ src/index.js
  ✔ .gitignore

Installing dependencies...

✔ Project ready!

  cd my-api
  npm start

Common Issues and Troubleshooting

Prompts hang when piped input

When stdin is piped (echo "yes" | mytool), setRawMode will throw because piped streams are not TTYs:

TypeError: process.stdin.setRawMode is not a function

Fix: Always check process.stdin.isTTY before calling setRawMode. Fall back to readline for piped input or use defaults.

Arrow keys print escape characters instead of navigating

This happens when raw mode is not enabled or when the terminal does not support ANSI:

? Select framework: ^[[A^[[B

Fix: Ensure setRawMode(true) is called before reading input. For terminals without ANSI support, fall back to numbered selection.

Cursor left visible after Ctrl+C

If the user interrupts during a prompt where the cursor was hidden, it stays hidden in the terminal:

$ # cursor is invisible, terminal seems broken

Fix: Always register a SIGINT handler that restores the cursor:

process.on("SIGINT", function() {
  process.stdout.write("\u001b[?25h\n");
  process.exit();
});

Multi-byte characters break cursor positioning

Emoji and CJK characters take 2 columns but count as 1 in string.length:

? Name: 🎉test   # Cursor is misaligned

Fix: Use a string width library like string-width to calculate visual width instead of string.length. This is especially important for East Asian locales.

Prompts break inside Docker or CI

Docker containers and CI runners often lack a proper TTY. process.stdin.isTTY will be undefined:

Fix: Support --yes or --no-interactive flags that skip all prompts and use defaults.

Best Practices

  • Always check process.stdin.isTTY before enabling raw mode. Non-interactive environments will crash if you do not.
  • Clean up terminal state on exit. Register handlers for SIGINT, SIGTERM, and exit to restore cursor visibility and raw mode.
  • Support both keyboard and vi-style navigation. Accept j/k for down/up in addition to arrow keys. Power users expect this.
  • Show the final answer inline after each prompt. Replace the prompt with a green checkmark and the selected value so users can review their choices as they go.
  • Provide --yes flags to skip interactive prompts. Every prompt should have a sensible default that CI systems can use.
  • Validate input immediately and show errors inline. Do not wait until the end of a wizard to tell users something is wrong.
  • Limit visible options to 10 or fewer. For longer lists, add type-to-filter search. Scrolling through 50 items with arrow keys is painful.
  • Use colors sparingly and support NO_COLOR. Check process.env.NO_COLOR or process.env.TERM === "dumb" and disable ANSI codes when set.

References

Powered by Contentful