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.isTTYbefore enabling raw mode. Non-interactive environments will crash if you do not. - Clean up terminal state on exit. Register handlers for
SIGINT,SIGTERM, andexitto restore cursor visibility and raw mode. - Support both keyboard and vi-style navigation. Accept
j/kfor 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
--yesflags 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. Checkprocess.env.NO_COLORorprocess.env.TERM === "dumb"and disable ANSI codes when set.