Cli Tools

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 --help with 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.

References

Powered by Contentful