March 27, 202611 min read

Build a CLI Tool with Node.js (That People Actually Want to Use)

Build polished command-line tools with Node.js. Commander, Inquirer, chalk, spinners, and publishing to npm with a real project scaffolder.

nodejs cli javascript npm tutorial
Ad 336x280

There's something deeply satisfying about building a CLI tool. You type a command, things happen, output appears. No browsers, no CSS, no "have you tried clearing the cache." Just clean input and output.

And Node.js is perfect for it. The ecosystem has mature libraries for argument parsing, interactive prompts, colored output, and progress indicators. You can go from idea to published npm package in an afternoon.

By the end of this tutorial, you'll build a project scaffolder CLI -- the kind of tool that creates new project directories with templates, config files, and dependencies pre-configured. Think create-react-app but yours.

The Basics: process.argv

Every CLI tool starts with reading command-line arguments:

// index.js
console.log(process.argv);
$ node index.js hello --name world
[
  '/usr/local/bin/node',  // Node executable path
  '/path/to/index.js',    // Script path
  'hello',                // First actual argument
  '--name',
  'world'
]
process.argv[0] is always the Node path, [1] is the script path, and everything from [2] onward is your actual arguments.

You could parse these manually:

const args = process.argv.slice(2);
const command = args[0];
const flags = {};

for (let i = 1; i < args.length; i++) {
if (args[i].startsWith('--')) {
flags[args[i].slice(2)] = args[i + 1] || true;
i++;
}
}

console.log({ command, flags });

But don't. Use a library.

Commander.js: Proper Argument Parsing

Commander is the standard for Node.js CLIs. It handles commands, options, help text, and validation.

npm install commander
// cli.js
import { Command } from 'commander';

const program = new Command();

program
.name('mytool')
.description('A helpful CLI tool')
.version('1.0.0');

program
.command('greet')
.description('Greet someone')
.argument('<name>', 'Who to greet')
.option('-l, --loud', 'Greet loudly')
.option('-t, --times <number>', 'Number of times to greet', '1')
.action((name, options) => {
const greeting = Hello, ${name}!;
const count = parseInt(options.times);

for (let i = 0; i < count; i++) {
console.log(options.loud ? greeting.toUpperCase() : greeting);
}
});

program.parse();

$ node cli.js greet Alice
Hello, Alice!

$ node cli.js greet Alice --loud --times 3
HELLO, ALICE!
HELLO, ALICE!
HELLO, ALICE!

$ node cli.js --help
Usage: mytool [options] [command]

A helpful CLI tool

Options:
-V, --version output the version number
-h, --help display help for command

Commands:
greet [options] <name> Greet someone
help [command] display help for command

Commander auto-generates help text. That's not a small thing -- good help text is the difference between a tool people use and one they abandon.

Interactive Prompts with Inquirer

Sometimes you want to ask the user questions instead of expecting flags. Inquirer provides beautiful interactive prompts:

npm install @inquirer/prompts
import { input, select, checkbox, confirm } from '@inquirer/prompts';

async function setupProject() {
const projectName = await input({
message: 'Project name:',
default: 'my-project',
validate: (value) => {
if (/^[a-z0-9-]+$/.test(value)) return true;
return 'Use lowercase letters, numbers, and hyphens only';
}
});

const framework = await select({
message: 'Choose a framework:',
choices: [
{ name: 'React', value: 'react' },
{ name: 'Vue', value: 'vue' },
{ name: 'Svelte', value: 'svelte' },
{ name: 'Vanilla JS', value: 'vanilla' }
]
});

const features = await checkbox({
message: 'Select features:',
choices: [
{ name: 'TypeScript', value: 'typescript' },
{ name: 'ESLint', value: 'eslint' },
{ name: 'Prettier', value: 'prettier' },
{ name: 'Testing (Vitest)', value: 'vitest' },
{ name: 'Tailwind CSS', value: 'tailwind' }
]
});

const useGit = await confirm({
message: 'Initialize a Git repository?',
default: true
});

return { projectName, framework, features, useGit };
}

The user gets arrow-key navigation, multi-select checkboxes, and input validation. Way better than raw readline.

Colored Output with Chalk

Plain white terminal output is functional but boring. Chalk adds colors and formatting:

npm install chalk
import chalk from 'chalk';

// Basic colors
console.log(chalk.green('Success!'));
console.log(chalk.red('Error: something went wrong'));
console.log(chalk.yellow('Warning: this might break'));
console.log(chalk.blue('Info: processing files...'));

// Styles
console.log(chalk.bold('Important text'));
console.log(chalk.italic('Side note'));
console.log(chalk.underline('Clickable thing'));
console.log(chalk.strikethrough('Deprecated'));

// Combine
console.log(chalk.bold.green('All good!'));
console.log(chalk.bgRed.white(' ERROR ') + ' File not found');

// Template literals
const name = 'Alice';
console.log(Hello, ${chalk.cyan(name)}! Welcome to ${chalk.bold('MyTool')}.);

A common pattern is creating a logger utility:

const log = {
  info: (msg) => console.log(chalk.blue('i') + ' ' + msg),
  success: (msg) => console.log(chalk.green('✓') + ' ' + msg),
  warn: (msg) => console.log(chalk.yellow('!') + ' ' + msg),
  error: (msg) => console.log(chalk.red('x') + ' ' + msg),
};

log.info('Installing dependencies...');
log.success('Dependencies installed');
log.warn('Some packages have deprecation warnings');
log.error('Failed to install foobar');

Spinners for Long Operations

When your CLI does something that takes a few seconds, show a spinner so the user knows it's working:

npm install ora
import ora from 'ora';

async function installDeps() {
const spinner = ora('Installing dependencies...').start();

try {
await runCommand('npm install');
spinner.succeed('Dependencies installed');
} catch (error) {
spinner.fail('Failed to install dependencies');
process.exit(1);
}
}

// Multiple steps
async function setup() {
const spinner = ora();

spinner.start('Creating project directory...');
await createDir();
spinner.succeed('Project directory created');

spinner.start('Copying template files...');
await copyTemplate();
spinner.succeed('Template files copied');

spinner.start('Installing dependencies...');
await installDeps();
spinner.succeed('Dependencies installed');
}

File System Operations

Most CLI tools need to read and write files:

import fs from 'fs/promises';
import path from 'path';

async function createProject(projectName, template) {
const projectPath = path.resolve(process.cwd(), projectName);

// Check if directory already exists
try {
await fs.access(projectPath);
console.error(Directory ${projectName} already exists!);
process.exit(1);
} catch {
// Directory doesn't exist, good
}

// Create directory structure
await fs.mkdir(projectPath, { recursive: true });
await fs.mkdir(path.join(projectPath, 'src'), { recursive: true });
await fs.mkdir(path.join(projectPath, 'public'), { recursive: true });

// Write files
const packageJson = {
name: projectName,
version: '0.1.0',
private: true,
type: 'module',
scripts: {
dev: 'vite',
build: 'vite build',
preview: 'vite preview'
},
dependencies: {},
devDependencies: {
vite: '^6.0.0'
}
};

await fs.writeFile(
path.join(projectPath, 'package.json'),
JSON.stringify(packageJson, null, 2) + '\n'
);

// Copy template files
const templateDir = path.join(import.meta.dirname, 'templates', template);
await copyDir(templateDir, projectPath);
}

async function copyDir(src, dest) {
const entries = await fs.readdir(src, { withFileTypes: true });

for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);

if (entry.isDirectory()) {
await fs.mkdir(destPath, { recursive: true });
await copyDir(srcPath, destPath);
} else {
await fs.copyFile(srcPath, destPath);
}
}
}

Running Shell Commands

For tasks like git init or npm install, use child_process:

import { exec } from 'child_process';
import { promisify } from 'util';

const execAsync = promisify(exec);

async function runCommand(command, cwd) {
try {
const { stdout, stderr } = await execAsync(command, {
cwd,
timeout: 60000 // 60 second timeout
});
return stdout.trim();
} catch (error) {
throw new Error(Command failed: ${command}\n${error.stderr});
}
}

// Usage
await runCommand('git init', projectPath);
await runCommand('npm install', projectPath);

Building the Project Scaffolder

Let's put everything together into a real CLI tool:

#!/usr/bin/env node
// bin/create-project.js

import { Command } from 'commander';
import { input, select, checkbox, confirm } from '@inquirer/prompts';
import chalk from 'chalk';
import ora from 'ora';
import fs from 'fs/promises';
import path from 'path';
import { exec } from 'child_process';
import { promisify } from 'util';

const execAsync = promisify(exec);

const program = new Command();

program
.name('create-project')
.description('Scaffold a new project with sensible defaults')
.version('1.0.0')
.argument('[name]', 'Project name')
.action(async (nameArg) => {
console.log(chalk.bold('\nProject Scaffolder\n'));

// Gather configuration
const name = nameArg || await input({
message: 'Project name:',
validate: (v) => /^[a-z0-9-]+$/.test(v) || 'Lowercase, numbers, hyphens only'
});

const template = await select({
message: 'Template:',
choices: [
{ name: 'React + Vite', value: 'react' },
{ name: 'Express API', value: 'express' },
{ name: 'Static Site', value: 'static' }
]
});

const features = await checkbox({
message: 'Features:',
choices: [
{ name: 'TypeScript', value: 'typescript', checked: true },
{ name: 'ESLint + Prettier', value: 'linting' },
{ name: 'Vitest', value: 'testing' },
{ name: 'Docker', value: 'docker' }
]
});

const initGit = await confirm({ message: 'Initialize Git?', default: true });

// Create the project
const projectPath = path.resolve(process.cwd(), name);
const spinner = ora();

try {
// Step 1: Create directory structure
spinner.start('Creating project structure...');
await createStructure(projectPath, template);
spinner.succeed('Project structure created');

// Step 2: Generate config files
spinner.start('Generating configuration...');
await generateConfigs(projectPath, name, template, features);
spinner.succeed('Configuration generated');

// Step 3: Install dependencies
spinner.start('Installing dependencies (this may take a minute)...');
await execAsync('npm install', { cwd: projectPath, timeout: 120000 });
spinner.succeed('Dependencies installed');

// Step 4: Initialize Git
if (initGit) {
spinner.start('Initializing Git repository...');
await execAsync('git init', { cwd: projectPath });
await execAsync('git add -A', { cwd: projectPath });
await execAsync('git commit -m "Initial commit"', { cwd: projectPath });
spinner.succeed('Git repository initialized');
}

// Done
console.log('\n' + chalk.green.bold('Project created successfully!'));
console.log('\nNext steps:');
console.log(chalk.cyan( cd ${name}));
console.log(chalk.cyan(' npm run dev'));
console.log();

} catch (error) {
spinner.fail('Something went wrong');
console.error(chalk.red(error.message));
process.exit(1);
}
});

async function createStructure(projectPath, template) {
await fs.mkdir(projectPath, { recursive: true });
await fs.mkdir(path.join(projectPath, 'src'), { recursive: true });

if (template === 'react') {
await fs.mkdir(path.join(projectPath, 'src', 'components'), { recursive: true });
await fs.mkdir(path.join(projectPath, 'public'), { recursive: true });
} else if (template === 'express') {
await fs.mkdir(path.join(projectPath, 'src', 'routes'), { recursive: true });
await fs.mkdir(path.join(projectPath, 'src', 'middleware'), { recursive: true });
}
}

async function generateConfigs(projectPath, name, template, features) {
const useTS = features.includes('typescript');

// package.json
const pkg = {
name,
version: '0.1.0',
private: true,
type: 'module',
scripts: getScripts(template, useTS),
dependencies: getDeps(template),
devDependencies: getDevDeps(template, features)
};

await fs.writeFile(
path.join(projectPath, 'package.json'),
JSON.stringify(pkg, null, 2) + '\n'
);

// .gitignore
await fs.writeFile(
path.join(projectPath, '.gitignore'),
'node_modules/\ndist/\n.env\n.env.local\n'
);

// Template-specific entry file
const ext = useTS ? 'ts' : 'js';
if (template === 'react') {
await fs.writeFile(
path.join(projectPath, src/main.${ext}x),
getReactEntry(useTS)
);
} else if (template === 'express') {
await fs.writeFile(
path.join(projectPath, src/index.${ext}),
getExpressEntry(useTS)
);
}

// Docker if requested
if (features.includes('docker')) {
await fs.writeFile(
path.join(projectPath, 'Dockerfile'),
getDockerfile(template)
);
}
}

function getScripts(template, useTS) {
if (template === 'react') {
return { dev: 'vite', build: 'vite build', preview: 'vite preview' };
}
if (template === 'express') {
return {
dev: useTS ? 'tsx watch src/index.ts' : 'node --watch src/index.js',
build: useTS ? 'tsc' : 'echo "No build step"',
start: useTS ? 'node dist/index.js' : 'node src/index.js'
};
}
return { dev: 'vite', build: 'vite build' };
}

function getDeps(template) {
if (template === 'react') return { react: '^19.0.0', 'react-dom': '^19.0.0' };
if (template === 'express') return { express: '^5.0.0' };
return {};
}

function getDevDeps(template, features) {
const deps = {};
if (template === 'react') deps.vite = '^6.0.0';
if (features.includes('typescript')) deps.typescript = '^5.7.0';
if (features.includes('linting')) {
deps.eslint = '^9.0.0';
deps.prettier = '^3.4.0';
}
if (features.includes('testing')) deps.vitest = '^3.0.0';
return deps;
}

function getReactEntry(useTS) {
return import React from 'react';
import ReactDOM from 'react-dom/client';

function App() {
return <h1>Hello from your new project!</h1>;
}

ReactDOM.createRoot(document.getElementById('root')${useTS ? '!' : ''}).render(<App />);
;
}

function getExpressEntry(useTS) {
return import express from 'express';

const app = express();
const port = process.env.PORT || 3000;

app.use(express.json());

app.get('/', (req, res) => {
res.json({ message: 'Hello from your new API!' });
});

app.listen(port, () => {
console.log(\
Server running on port \${port}\);
});
;
}

function getDockerfile(template) {
return FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
${template === 'react' ? 'RUN npm run build\nFROM nginx:alpine\nCOPY --from=0 /app/dist /usr/share/nginx/html' : 'CMD ["npm", "start"]'}
;
}

program.parse();

Publishing to npm

Make your CLI installable globally with npm install -g.

// package.json
{
  "name": "create-my-project",
  "version": "1.0.0",
  "type": "module",
  "bin": {
    "create-my-project": "./bin/create-project.js"
  },
  "files": [
    "bin/",
    "templates/"
  ],
  "engines": {
    "node": ">=18"
  }
}

The bin field is key -- it tells npm which file to make executable. The files field controls what gets published (keep it small).

Make sure your entry file has the shebang line:

#!/usr/bin/env node

Publish:

# Login to npm (one time)
npm login

# Publish
npm publish

# Now anyone can install it
npm install -g create-my-project

Before publishing, test locally:

# Creates a global symlink to your local project
npm link

# Now you can run it anywhere
create-my-project my-app

# When done testing
npm unlink -g create-my-project

Common Mistakes

Not handling Ctrl+C gracefully. Users will hit Ctrl+C. Handle SIGINT to clean up partial work:
process.on('SIGINT', async () => {
  console.log('\nCancelled. Cleaning up...');
  // Remove partially created directories, etc.
  process.exit(1);
});
Forgetting the shebang. Without #!/usr/bin/env node, the OS doesn't know to run your file with Node. Your CLI will fail with cryptic errors. Giant npm packages. Use the files field in package.json to only include what's needed. Nobody wants to download your node_modules, test files, or development screenshots. No error messages. When something fails, tell the user what went wrong and how to fix it. "Error: EACCES" is useless. "Permission denied: cannot create directory. Try running with sudo or check directory permissions." is helpful. Synchronous I/O. Use fs/promises, not fs.readFileSync. Synchronous operations block the event loop and prevent spinners from animating.

What's Next

You've built a complete CLI tool with argument parsing, interactive prompts, colored output, spinners, file operations, and npm publishing. The project scaffolder pattern is just one application -- you can build CLI tools for anything: deployment, code generation, data processing, API clients.

The best CLI tools are opinionated. They make good default choices and get out of the way. Build something that saves you time, then share it.

Sharpen your Node.js skills with hands-on projects at CodeUp.

Ad 728x90