Building Interactive CLIs with Node.js and Inquirer
Complete guide to building interactive command-line interfaces with Node.js using Inquirer.js, covering prompts, validation, dynamic questions, progress indicators, and distribution.
Building Interactive CLIs with Node.js and Inquirer
Command-line tools are how developers actually get work done. GUIs come and go, but the terminal is permanent. When you need a CLI that asks users questions, validates input, and guides them through multi-step workflows, Inquirer.js is the standard library. I have built a dozen production CLI tools with it — scaffolding generators, deployment scripts, database migration wizards — and the patterns in this guide are what I reach for every time.
Prerequisites
- Node.js 18+
- npm or yarn
- Basic familiarity with the terminal
- Understanding of Node.js streams and callbacks
Getting Started
mkdir my-cli && cd my-cli
npm init -y
npm install [email protected]
We pin Inquirer to v8 because v9+ switched to ES modules. For CommonJS projects (which is what most Node.js CLIs use), v8 is the right choice.
// index.js
var inquirer = require('inquirer');
inquirer.prompt([
{
type: 'input',
name: 'projectName',
message: 'What is your project name?',
default: 'my-project'
},
{
type: 'list',
name: 'language',
message: 'Which language?',
choices: ['JavaScript', 'TypeScript', 'Python']
}
]).then(function(answers) {
console.log('Creating project:', answers.projectName);
console.log('Language:', answers.language);
});
node index.js
# ? What is your project name? my-project
# ? Which language? (Use arrow keys)
# ❯ JavaScript
# TypeScript
# Python
Prompt Types
Input
Free-text input with optional default and validation:
{
type: 'input',
name: 'email',
message: 'Enter your email:',
default: function() {
return process.env.USER + '@example.com';
},
validate: function(value) {
if (value.indexOf('@') === -1) {
return 'Please enter a valid email address';
}
return true;
},
filter: function(value) {
return value.trim().toLowerCase();
}
}
validate returns true or an error message string. filter transforms the value before storing it.
List (Single Selection)
{
type: 'list',
name: 'database',
message: 'Select database:',
choices: [
{ name: 'PostgreSQL (recommended)', value: 'postgresql', short: 'PostgreSQL' },
{ name: 'MySQL', value: 'mysql' },
{ name: 'MongoDB', value: 'mongodb' },
new inquirer.Separator('--- Experimental ---'),
{ name: 'SQLite (local only)', value: 'sqlite' }
],
default: 'postgresql'
}
The name is what the user sees. The value is what your code receives. The short is displayed after selection. Separators group related options visually.
Checkbox (Multiple Selection)
{
type: 'checkbox',
name: 'features',
message: 'Select features to include:',
choices: [
{ name: 'Authentication', value: 'auth', checked: true },
{ name: 'Database ORM', value: 'orm', checked: true },
{ name: 'API Documentation', value: 'docs' },
{ name: 'Docker Support', value: 'docker' },
{ name: 'CI/CD Pipeline', value: 'cicd' },
{ name: 'Testing Framework', value: 'testing', checked: true }
],
validate: function(answers) {
if (answers.length === 0) {
return 'Select at least one feature';
}
return true;
}
}
Confirm (Yes/No)
{
type: 'confirm',
name: 'proceed',
message: 'This will overwrite existing files. Continue?',
default: false
}
Password
{
type: 'password',
name: 'apiKey',
message: 'Enter your API key:',
mask: '*',
validate: function(value) {
if (value.length < 10) {
return 'API key must be at least 10 characters';
}
return true;
}
}
Number
{
type: 'number',
name: 'port',
message: 'Server port:',
default: 3000,
validate: function(value) {
if (isNaN(value) || value < 1 || value > 65535) {
return 'Please enter a valid port number (1-65535)';
}
return true;
}
}
Editor
Opens the user's $EDITOR for multi-line input:
{
type: 'editor',
name: 'description',
message: 'Enter project description:',
default: '# My Project\n\nA brief description.'
}
Dynamic Questions
Conditional Questions
Use the when property to show questions based on previous answers:
var questions = [
{
type: 'list',
name: 'database',
message: 'Select database:',
choices: ['PostgreSQL', 'MySQL', 'MongoDB', 'None']
},
{
type: 'input',
name: 'dbHost',
message: 'Database host:',
default: 'localhost',
when: function(answers) {
return answers.database !== 'None';
}
},
{
type: 'number',
name: 'dbPort',
message: 'Database port:',
when: function(answers) {
return answers.database !== 'None';
},
default: function(answers) {
var ports = { PostgreSQL: 5432, MySQL: 3306, MongoDB: 27017 };
return ports[answers.database];
}
},
{
type: 'input',
name: 'dbName',
message: 'Database name:',
when: function(answers) {
return answers.database !== 'None';
},
validate: function(value) {
if (!/^[a-z][a-z0-9_]*$/.test(value)) {
return 'Database name must start with a letter and contain only lowercase letters, numbers, and underscores';
}
return true;
}
}
];
Dynamic Choices
Choices can be functions that compute options based on context:
{
type: 'list',
name: 'template',
message: 'Select template:',
choices: function(answers) {
var templates = [
{ name: 'Basic Express API', value: 'express-basic' },
{ name: 'Express with Auth', value: 'express-auth' }
];
if (answers.database === 'PostgreSQL') {
templates.push({ name: 'Express + PostgreSQL + Knex', value: 'express-pg' });
}
if (answers.database === 'MongoDB') {
templates.push({ name: 'Express + MongoDB + Mongoose', value: 'express-mongo' });
}
return templates;
}
}
Building a Complete CLI Tool
Project Structure
my-cli/
├── bin/
│ └── cli.js # Entry point
├── lib/
│ ├── prompts.js # Question definitions
│ ├── generator.js # Project generation logic
│ ├── templates/ # Template files
│ └── utils.js # Shared utilities
├── package.json
└── README.md
Entry Point with Commander
Combine Inquirer with Commander for argument parsing:
npm install commander@11
#!/usr/bin/env node
// bin/cli.js
var program = require('commander');
var inquirer = require('inquirer');
var generator = require('../lib/generator');
var prompts = require('../lib/prompts');
var pkg = require('../package.json');
program
.name('create-app')
.version(pkg.version)
.description('Scaffold a new Node.js application')
.argument('[project-name]', 'name of the project')
.option('-t, --template <template>', 'project template')
.option('-d, --database <database>', 'database type')
.option('--skip-prompts', 'use defaults for all options')
.action(function(projectName, options) {
if (options.skipPrompts) {
var defaults = prompts.getDefaults();
defaults.projectName = projectName || defaults.projectName;
if (options.template) defaults.template = options.template;
if (options.database) defaults.database = options.database;
return generator.run(defaults);
}
var questions = prompts.getQuestions(projectName, options);
inquirer.prompt(questions).then(function(answers) {
generator.run(answers);
});
});
program.parse();
Prompt Definitions
// lib/prompts.js
var fs = require('fs');
var path = require('path');
function getDefaults() {
return {
projectName: 'my-app',
database: 'postgresql',
template: 'express-basic',
features: ['auth', 'orm', 'testing'],
port: 3000,
docker: true
};
}
function getQuestions(projectName, options) {
var questions = [];
if (!projectName) {
questions.push({
type: 'input',
name: 'projectName',
message: 'Project name:',
default: 'my-app',
validate: function(value) {
if (!/^[a-z][a-z0-9-]*$/.test(value)) {
return 'Project name must be lowercase with hyphens only';
}
var targetDir = path.join(process.cwd(), value);
if (fs.existsSync(targetDir)) {
return 'Directory "' + value + '" already exists';
}
return true;
}
});
}
if (!options.database) {
questions.push({
type: 'list',
name: 'database',
message: 'Database:',
choices: [
{ name: 'PostgreSQL (recommended)', value: 'postgresql' },
{ name: 'MySQL', value: 'mysql' },
{ name: 'MongoDB', value: 'mongodb' },
{ name: 'None', value: 'none' }
]
});
}
questions.push({
type: 'checkbox',
name: 'features',
message: 'Features:',
choices: [
{ name: 'Authentication (JWT)', value: 'auth', checked: true },
{ name: 'Database ORM', value: 'orm', checked: true },
{ name: 'API Documentation (Swagger)', value: 'docs' },
{ name: 'Docker Support', value: 'docker', checked: true },
{ name: 'Testing (Jest)', value: 'testing', checked: true },
{ name: 'CI/CD (GitHub Actions)', value: 'cicd' }
]
});
questions.push({
type: 'number',
name: 'port',
message: 'Server port:',
default: 3000
});
questions.push({
type: 'confirm',
name: 'confirm',
message: 'Generate project?',
default: true
});
return questions;
}
module.exports = {
getQuestions: getQuestions,
getDefaults: getDefaults
};
Generator Logic
// lib/generator.js
var fs = require('fs');
var path = require('path');
var execSync = require('child_process').execSync;
function run(answers) {
if (answers.confirm === false) {
console.log('Aborted.');
return;
}
var projectDir = path.join(process.cwd(), answers.projectName);
console.log('\nCreating project: ' + answers.projectName);
// Create directory structure
var dirs = ['src', 'src/routes', 'src/models', 'src/middleware', 'test'];
if (answers.features.indexOf('docker') !== -1) dirs.push('docker');
fs.mkdirSync(projectDir, { recursive: true });
dirs.forEach(function(dir) {
fs.mkdirSync(path.join(projectDir, dir), { recursive: true });
console.log(' Created: ' + dir + '/');
});
// Generate package.json
var pkg = {
name: answers.projectName,
version: '1.0.0',
main: 'src/app.js',
scripts: {
start: 'node src/app.js',
dev: 'nodemon src/app.js',
test: 'jest'
},
dependencies: {
express: '^4.18.0'
},
devDependencies: {
nodemon: '^3.0.0'
}
};
if (answers.database === 'postgresql') {
pkg.dependencies.pg = '^8.11.0';
}
if (answers.features.indexOf('auth') !== -1) {
pkg.dependencies.jsonwebtoken = '^9.0.0';
pkg.dependencies.bcrypt = '^5.1.0';
}
if (answers.features.indexOf('testing') !== -1) {
pkg.devDependencies.jest = '^29.0.0';
pkg.devDependencies.supertest = '^6.3.0';
}
fs.writeFileSync(
path.join(projectDir, 'package.json'),
JSON.stringify(pkg, null, 2)
);
console.log(' Created: package.json');
// Generate app.js
var appContent = generateApp(answers);
fs.writeFileSync(path.join(projectDir, 'src/app.js'), appContent);
console.log(' Created: src/app.js');
// Generate Dockerfile if selected
if (answers.features.indexOf('docker') !== -1) {
var dockerfile = generateDockerfile(answers);
fs.writeFileSync(path.join(projectDir, 'Dockerfile'), dockerfile);
console.log(' Created: Dockerfile');
}
// Install dependencies
console.log('\nInstalling dependencies...');
execSync('npm install', { cwd: projectDir, stdio: 'inherit' });
console.log('\nDone! Run these commands to get started:');
console.log(' cd ' + answers.projectName);
console.log(' npm run dev');
}
function generateApp(answers) {
var lines = [
"var express = require('express');",
"var app = express();",
"",
"app.use(express.json());",
"",
"app.get('/health', function(req, res) {",
" res.json({ status: 'healthy' });",
"});",
"",
"var port = process.env.PORT || " + answers.port + ";",
"app.listen(port, function() {",
" console.log('Server running on port ' + port);",
"});",
"",
"module.exports = app;"
];
return lines.join('\n');
}
function generateDockerfile(answers) {
var lines = [
"FROM node:20-alpine",
"WORKDIR /app",
"COPY package*.json ./",
"RUN npm ci --only=production",
"COPY . .",
"EXPOSE " + answers.port,
"USER node",
'CMD ["node", "src/app.js"]'
];
return lines.join('\n');
}
module.exports = { run: run };
Making It Executable
{
"name": "create-app",
"version": "1.0.0",
"bin": {
"create-app": "./bin/cli.js"
}
}
# Make executable
chmod +x bin/cli.js
# Link for local testing
npm link
# Now use it
create-app my-new-project
Progress Indicators
Simple Spinner
npm install ora@5
var ora = require('ora');
function installDependencies(projectDir) {
var spinner = ora('Installing dependencies...').start();
try {
execSync('npm install', {
cwd: projectDir,
stdio: 'pipe'
});
spinner.succeed('Dependencies installed');
} catch (err) {
spinner.fail('Failed to install dependencies');
console.error(err.stderr.toString());
process.exit(1);
}
}
Progress Bar
npm install cli-progress
var ProgressBar = require('cli-progress');
function processFiles(files) {
var bar = new ProgressBar.SingleBar({
format: 'Processing |{bar}| {percentage}% | {value}/{total} files',
barCompleteChar: '\u2588',
barIncompleteChar: '\u2591'
});
bar.start(files.length, 0);
files.forEach(function(file, index) {
// Process file...
bar.update(index + 1);
});
bar.stop();
console.log('All files processed.');
}
Colored Output
npm install chalk@4
var chalk = require('chalk');
console.log(chalk.green('✓') + ' Project created successfully');
console.log(chalk.yellow('⚠') + ' Warning: No database selected');
console.log(chalk.red('✗') + ' Error: Directory already exists');
console.log(chalk.cyan('ℹ') + ' Run ' + chalk.bold('npm start') + ' to begin');
// Styled boxes
function printBox(title, content) {
var width = 50;
var line = '─'.repeat(width);
console.log(chalk.cyan('┌' + line + '┐'));
console.log(chalk.cyan('│') + ' ' + chalk.bold(title).padEnd(width + 9) + chalk.cyan('│'));
console.log(chalk.cyan('├' + line + '┤'));
content.forEach(function(text) {
console.log(chalk.cyan('│') + ' ' + text.padEnd(width - 1) + chalk.cyan('│'));
});
console.log(chalk.cyan('└' + line + '┘'));
}
printBox('Project Created', [
'Name: my-app',
'Database: PostgreSQL',
'Port: 3000',
'',
'Next steps:',
' cd my-app',
' npm run dev'
]);
Error Handling
// lib/utils.js
var chalk = require('chalk');
function handleError(message, err) {
console.error(chalk.red('\n✗ Error: ' + message));
if (err && err.message) {
console.error(chalk.gray(' ' + err.message));
}
if (process.env.DEBUG) {
console.error(err);
}
process.exit(1);
}
function validateNodeVersion(required) {
var current = process.version;
var currentMajor = parseInt(current.split('.')[0].substring(1));
if (currentMajor < required) {
handleError(
'Node.js ' + required + '+ is required (current: ' + current + ')'
);
}
}
module.exports = {
handleError: handleError,
validateNodeVersion: validateNodeVersion
};
Complete Working Example
A full project scaffolding CLI:
#!/usr/bin/env node
// bin/cli.js
var program = require('commander');
var inquirer = require('inquirer');
var chalk = require('chalk');
var ora = require('ora');
var fs = require('fs');
var path = require('path');
var execSync = require('child_process').execSync;
var pkg = require('../package.json');
program
.name('scaffold')
.version(pkg.version)
.description('Scaffold a new Node.js project')
.argument('[name]', 'project name')
.option('--skip-install', 'skip npm install')
.action(function(name, options) {
console.log(chalk.bold('\n Project Scaffolder v' + pkg.version + '\n'));
var questions = [];
if (!name) {
questions.push({
type: 'input',
name: 'name',
message: 'Project name:',
default: 'my-app',
validate: function(v) {
if (!/^[a-z][a-z0-9-]*$/.test(v)) return 'Lowercase with hyphens only';
if (fs.existsSync(path.join(process.cwd(), v))) return 'Directory exists';
return true;
}
});
}
questions.push(
{
type: 'list',
name: 'type',
message: 'Project type:',
choices: [
{ name: 'API Server', value: 'api' },
{ name: 'CLI Tool', value: 'cli' },
{ name: 'Library', value: 'lib' }
]
},
{
type: 'list',
name: 'database',
message: 'Database:',
choices: ['PostgreSQL', 'MongoDB', 'None'],
when: function(a) { return a.type === 'api'; }
},
{
type: 'checkbox',
name: 'extras',
message: 'Include:',
choices: [
{ name: 'Docker', value: 'docker', checked: true },
{ name: 'Jest tests', value: 'jest', checked: true },
{ name: 'ESLint', value: 'eslint' },
{ name: 'GitHub Actions', value: 'gha' }
]
},
{
type: 'confirm',
name: 'go',
message: 'Create project?',
default: true
}
);
inquirer.prompt(questions).then(function(answers) {
if (!answers.go) return console.log(chalk.yellow('Aborted.'));
answers.name = name || answers.name;
var dir = path.join(process.cwd(), answers.name);
fs.mkdirSync(dir, { recursive: true });
var spinner = ora('Generating files...').start();
// ... generate files based on answers
spinner.succeed('Files generated');
if (!options.skipInstall) {
spinner = ora('Installing dependencies...').start();
try {
execSync('npm install', { cwd: dir, stdio: 'pipe' });
spinner.succeed('Dependencies installed');
} catch (err) {
spinner.fail('Install failed');
}
}
console.log(chalk.green('\n ✓ Project created: ' + answers.name));
console.log(chalk.gray(' cd ' + answers.name));
console.log(chalk.gray(' npm run dev\n'));
});
});
program.parse();
Common Issues and Troubleshooting
1. Inquirer v9 Import Error
Error [ERR_REQUIRE_ESM]: require() of ES Module inquirer not supported
Inquirer v9+ is ESM-only. Use v8 for CommonJS projects:
npm install [email protected]
2. Arrow Keys Not Working in Git Bash on Windows
? Select database: (Use arrow keys)
# Arrow keys type escape sequences instead of moving selection
Git Bash on Windows does not support raw terminal mode properly. Use Windows Terminal or PowerShell instead, or set TERM=xterm:
TERM=xterm node bin/cli.js
3. Prompt Not Showing in CI
# Prompt hangs in CI/CD pipeline
Detect non-interactive environments and use defaults:
var isInteractive = process.stdin.isTTY;
if (!isInteractive) {
console.log('Non-interactive mode, using defaults');
generator.run(prompts.getDefaults());
} else {
inquirer.prompt(questions).then(function(answers) {
generator.run(answers);
});
}
4. Colors Not Displaying
# Output shows ANSI escape codes instead of colors
Some terminals do not support colors. Chalk auto-detects, but you can force it:
FORCE_COLOR=1 node bin/cli.js # Force colors
NO_COLOR=1 node bin/cli.js # Disable colors
Best Practices
- Always provide defaults. Users should be able to press Enter through every prompt and get a working result.
- Validate early, validate clearly. Return human-readable error messages from validators, not cryptic codes.
- Support both interactive and non-interactive modes. CLI flags should allow skipping prompts entirely for scripting and CI.
- Use conditional questions with
when. Do not ask about database configuration if the user selected "None" for database. - Show a summary before destructive actions. Let users review their selections and confirm before generating or modifying files.
- Pin Inquirer to v8 for CommonJS. The ESM migration in v9 breaks
require()imports. - Add
--helpwith Commander or Yargs. Every CLI should document its options and arguments. - Use spinners for operations over 1 second. Silent waiting feels broken. A spinner tells users something is happening.