Building a CLI Tool with Node.js: From Zero to npm Publish
A few months ago I got tired of manually creating the same project scaffold every time I started a new side project. Same folder structure. Same config files. Same initial dependencies. I’d been copying from a template repo, but it still required cleanup every time.
So I built a CLI tool. It asks a few questions (project name, framework, database), generates the scaffold, installs dependencies, and initialises git. The whole thing took a weekend. And honestly? It was one of the most satisfying projects I’ve built.
CLI tools feel different from web apps. There’s no CSS to fuss with. No layout breakpoints. No cross-browser testing. Just input, logic, and output. If you’ve never built one, here’s how to get started.
Project Setup
Create a new directory and initialise it:
mkdir my-cli && cd my-cli
npm init -y
The key field in package.json is bin. This tells npm which file to execute when someone runs your command:
{
"name": "create-my-scaffold",
"version": "1.0.0",
"bin": {
"create-scaffold": "./src/index.js"
},
"type": "module"
}
The bin field maps a command name (create-scaffold) to an entry file. When someone installs your package globally with npm install -g, they’ll be able to type create-scaffold in their terminal and your code runs.
Your entry file needs a shebang line at the top:
#!/usr/bin/env node
// src/index.js
console.log('Hello from my CLI!')
The shebang (#!/usr/bin/env node) tells the operating system to run this file with Node.js. Without it, the OS won’t know how to execute the file.
Test it locally:
npm link
create-scaffold
# Output: Hello from my CLI!
npm link creates a global symlink to your project, letting you test the command without publishing.
Argument Parsing
Most CLI tools accept arguments and flags. You could parse process.argv manually, but libraries make this much easier.
I use Commander.js for argument parsing:
npm install commander
#!/usr/bin/env node
import { program } from 'commander'
program
.name('create-scaffold')
.description('Generate a project scaffold')
.version('1.0.0')
.argument('[project-name]', 'Name of the project')
.option('-t, --template <template>', 'Template to use', 'default')
.option('--no-git', 'Skip git initialisation')
.option('--no-install', 'Skip dependency installation')
.action((projectName, options) => {
console.log('Project:', projectName)
console.log('Template:', options.template)
console.log('Init git:', options.git)
console.log('Install deps:', options.install)
})
program.parse()
Commander handles the parsing, generates help text automatically (create-scaffold --help), validates arguments, and provides sensible defaults. It saves a lot of boilerplate.
Interactive Prompts
For a scaffolding tool, I wanted interactive prompts: ask the user questions and use their answers to generate the project. Inquirer.js is the go-to library for this, but I’ve been using @inquirer/prompts (the newer, modular version):
npm install @inquirer/prompts
import { input, select, confirm } from '@inquirer/prompts'
async function getProjectConfig(projectName) {
const name = projectName || await input({
message: 'Project name:',
validate: (value) => {
if (!value.trim()) return 'Project name is required'
if (!/^[a-z0-9-]+$/.test(value)) return 'Use lowercase letters, numbers, and hyphens only'
return true
},
})
const framework = await select({
message: 'Framework:',
choices: [
{ name: 'React + Vite', value: 'react-vite' },
{ name: 'Next.js', value: 'nextjs' },
{ name: 'Astro', value: 'astro' },
{ name: 'Express API', value: 'express' },
],
})
const database = await select({
message: 'Database:',
choices: [
{ name: 'PostgreSQL (with Drizzle)', value: 'postgres-drizzle' },
{ name: 'SQLite (with Drizzle)', value: 'sqlite-drizzle' },
{ name: 'None', value: 'none' },
],
})
const useTypescript = await confirm({
message: 'Use TypeScript?',
default: true,
})
return { name, framework, database, useTypescript }
}
The prompt library handles arrow key navigation, validation, default values, and terminal rendering. Your code stays focused on the questions, not the UI mechanics.
File Generation
Once you have the user’s configuration, you need to generate files. There are two approaches:
Template files - store template files in your package and copy them to the project directory, replacing variables. This works well for simple scaffolds:
import fs from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
async function generateFiles(config) {
const projectDir = path.resolve(process.cwd(), config.name)
// Create directory
await fs.mkdir(projectDir, { recursive: true })
// Copy template files
const templateDir = path.join(__dirname, '..', 'templates', config.framework)
await copyDir(templateDir, projectDir)
// Generate package.json
const packageJson = {
name: config.name,
version: '0.1.0',
private: true,
scripts: getScripts(config),
dependencies: getDependencies(config),
devDependencies: getDevDependencies(config),
}
await fs.writeFile(
path.join(projectDir, 'package.json'),
JSON.stringify(packageJson, null, 2)
)
}
Programmatic generation - build file contents in code. This is more flexible for complex scaffolds where files depend heavily on configuration.
I use a mix of both: template files for things that rarely change (.gitignore, tsconfig.json) and programmatic generation for files that vary based on user choices.
Running Shell Commands
Scaffolding tools typically need to run commands: npm install, git init, etc. Use Node’s built-in child_process:
import { execSync } from 'node:child_process'
function runCommand(command, cwd) {
try {
execSync(command, {
cwd,
stdio: 'pipe',
encoding: 'utf-8'
})
return true
} catch (error) {
console.error(`Command failed: ${command}`)
console.error(error.stderr)
return false
}
}
// Usage
if (config.install) {
console.log('Installing dependencies...')
runCommand('npm install', projectDir)
}
if (config.git) {
runCommand('git init', projectDir)
runCommand('git add -A', projectDir)
runCommand('git commit -m "Initial scaffold"', projectDir)
}
Use stdio: 'pipe' to capture output silently, or stdio: 'inherit' to show the command’s output in real time (useful for npm install so users can see progress).
Adding Visual Polish
CLI tools benefit from visual feedback. A few libraries help:
npm install ora chalk
import ora from 'ora'
import chalk from 'chalk'
const spinner = ora('Creating project structure...').start()
await generateFiles(config)
spinner.succeed('Project structure created')
spinner.start('Installing dependencies...')
runCommand('npm install', projectDir)
spinner.succeed('Dependencies installed')
console.log('')
console.log(chalk.green('Done!') + ' Your project is ready.')
console.log('')
console.log(' ' + chalk.cyan(`cd ${config.name}`))
console.log(' ' + chalk.cyan('npm run dev'))
ora provides animated spinners. chalk adds colours to terminal output. These small touches make the CLI feel polished and professional.
Publishing to npm
When your tool is ready, publishing is straightforward:
npm login
npm publish
A few things to get right:
Make sure your package.json has a unique name field. Check npmjs.com first.
Add a .npmignore or use the files field in package.json to control what gets published. You don’t want to ship test files, development configs, or large asset directories.
{
"files": ["src/", "templates/", "README.md"]
}
Test the published package by installing it globally on a different machine or in a clean environment: npx create-my-scaffold.
For AI-assisted development workflows, CLI tools are especially useful because they enforce consistent project setup across teams. Every new project starts with the same structure, same linting rules, same testing setup. It reduces the “how do I set this up” friction that slows down new team members.
What I Learned
Building a CLI tool taught me things that building web apps hadn’t. Terminal I/O is different from DOM manipulation. Error handling matters more because there’s no “refresh the page” escape hatch. And the constraints of a text-only interface force you to think carefully about information hierarchy: what does the user need to see right now?
If you’ve never built a CLI tool, try it. Pick a repetitive task you do regularly, and automate it. It doesn’t need to be published. It doesn’t need to be polished. It just needs to save you time. That’s where the best tools start.