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.
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) {
returnimport 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) {
returnimport 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) {
returnFROM 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. HandleSIGINT 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.