Most CLI tutorials show you how to print "Hello World" to the terminal. This one shows you how to build something you would actually ship. We are building a real tool — a project scaffolder that creates new Node.js/TypeScript projects from templates — and covering every production concern along the way: argument parsing, interactive prompts, progress indicators, file I/O, error handling, and npm publishing.
All code is TypeScript strict-mode and compiles without errors. The finished project is ~200 lines of application code.
Need starter kits for your next project? See our free developer tools and project templates.
Project Setup
mkdir create-my-app && cd create-my-app
npm init -y
npm install commander inquirer ora chalk fs-extra
npm install -D typescript @types/node @types/inquirer @types/fs-extra tsx
# tsconfig.json
npx tsc --init --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist --rootDir src --strict true
Update package.json to wire up the binary entry point:
{
"name": "create-my-app",
"version": "1.0.0",
"bin": {
"create-my-app": "./dist/index.js"
},
"scripts": {
"build": "tsc",
"dev": "tsx src/index.ts",
"start": "node dist/index.js"
},
"type": "module"
}
Entry Point with Commander.js
// src/index.ts
#!/usr/bin/env node
import { Command } from 'commander'
import { createProject } from './commands/create.js'
import { listTemplates } from './commands/list.js'
const program = new Command()
program
.name('create-my-app')
.description('Scaffold new Node.js/TypeScript projects from templates')
.version('1.0.0')
program
.command('create [project-name]')
.description('Create a new project from a template')
.option('-t, --template ', 'Template to use', 'minimal')
.option('-d, --directory
', 'Output directory', '.')
.option('--no-install', 'Skip npm install after scaffolding')
.option('--git', 'Initialize git repository', false)
.action(createProject)
program
.command('list')
.description('List available templates')
.action(listTemplates)
program.parse()
Typed Options Interface
// src/types.ts
export interface CreateOptions {
template: string
directory: string
install: boolean
git: boolean
}
export interface ProjectTemplate {
name: string
description: string
files: TemplateFile[]
dependencies: Record
devDependencies: Record
}
export interface TemplateFile {
path: string
content: string | ((vars: TemplateVars) => string)
}
export interface TemplateVars {
projectName: string
authorName: string
description: string
year: number
}
Interactive Prompts with Inquirer
// src/prompts.ts
import inquirer from 'inquirer'
import type { TemplateVars } from './types.js'
export async function promptProjectDetails(
projectName?: string
): Promise {
const answers = await inquirer.prompt([
{
type: 'input',
name: 'projectName',
message: 'Project name:',
default: projectName ?? 'my-app',
validate: (input: string) =>
/^[a-z0-9-]+$/.test(input) || 'Use lowercase letters, numbers, and hyphens only',
},
{
type: 'input',
name: 'description',
message: 'Project description:',
default: 'A new Node.js project',
},
{
type: 'input',
name: 'authorName',
message: 'Author name:',
default: process.env.GIT_AUTHOR_NAME ?? 'Your Name',
},
{
type: 'list',
name: 'template',
message: 'Choose a template:',
choices: [
{ name: 'Minimal — TypeScript + ESLint', value: 'minimal' },
{ name: 'API — Express + Zod + Prisma', value: 'api' },
{ name: 'CLI — Commander + Inquirer', value: 'cli' },
{ name: 'Full-stack — Next.js 16', value: 'nextjs' },
],
},
{
type: 'confirm',
name: 'initGit',
message: 'Initialize git repository?',
default: true,
},
{
type: 'confirm',
name: 'runInstall',
message: 'Run npm install after scaffolding?',
default: true,
},
])
return { ...answers, year: new Date().getFullYear() }
}
The Create Command with Progress Indicators
// src/commands/create.ts
import path from 'path'
import ora from 'ora'
import chalk from 'chalk'
import fs from 'fs-extra'
import { execSync } from 'child_process'
import { promptProjectDetails } from '../prompts.js'
import { getTemplate } from '../templates/index.js'
import type { CreateOptions } from '../types.js'
export async function createProject(
nameArg: string | undefined,
options: CreateOptions
): Promise {
console.log(chalk.bold.cyan('
create-my-app v1.0.0
'))
// Collect details interactively if not provided via flags
const details = await promptProjectDetails(nameArg)
const targetDir = path.resolve(options.directory, details.projectName)
if (await fs.pathExists(targetDir)) {
console.error(chalk.red(`Error: Directory "${details.projectName}" already exists.`))
process.exit(1)
}
const template = getTemplate(details.template)
if (!template) {
console.error(chalk.red(`Error: Template "${details.template}" not found.`))
process.exit(1)
}
// Step 1: Scaffold files
const scaffoldSpinner = ora('Scaffolding project files...').start()
try {
await fs.ensureDir(targetDir)
for (const file of template.files) {
const content =
typeof file.content === 'function' ? file.content(details) : file.content
await fs.outputFile(path.join(targetDir, file.path), content)
}
// Write package.json
await fs.writeJson(
path.join(targetDir, 'package.json'),
buildPackageJson(details.projectName, details, template),
{ spaces: 2 }
)
scaffoldSpinner.succeed(chalk.green('Project files created'))
} catch (error) {
scaffoldSpinner.fail('Failed to scaffold files')
throw error
}
// Step 2: npm install
if (options.install) {
const installSpinner = ora('Installing dependencies...').start()
try {
execSync('npm install', { cwd: targetDir, stdio: 'ignore' })
installSpinner.succeed(chalk.green('Dependencies installed'))
} catch {
installSpinner.fail('npm install failed — run it manually')
}
}
// Step 3: git init
if (options.git || details.initGit) {
const gitSpinner = ora('Initializing git repository...').start()
try {
execSync('git init && git add -A && git commit -m "Initial commit"', {
cwd: targetDir,
stdio: 'ignore',
})
gitSpinner.succeed(chalk.green('Git repository initialized'))
} catch {
gitSpinner.fail('git init failed — run it manually')
}
}
// Success summary
console.log(`
${chalk.bold.green('Project created successfully!')}
${chalk.cyan('Next steps:')}
${chalk.white(`cd ${details.projectName}`)}
${chalk.white('npm run dev')}
${chalk.gray('Happy building!')}
`)
}
function buildPackageJson(
name: string,
vars: { description: string; authorName: string },
template: { dependencies: Record; devDependencies: Record }
) {
return {
name,
version: '0.1.0',
description: vars.description,
author: vars.authorName,
license: 'MIT',
type: 'module',
scripts: { dev: 'tsx src/index.ts', build: 'tsc', start: 'node dist/index.js' },
dependencies: template.dependencies,
devDependencies: {
typescript: '^5.4.0',
tsx: '^4.7.0',
'@types/node': '^20.0.0',
...template.devDependencies,
},
}
}
File I/O Helper — Safe Read/Write
// src/utils/file-io.ts
import fs from 'fs-extra'
import path from 'path'
export async function readJsonSafe(filePath: string): Promise {
try {
return await fs.readJson(filePath)
} catch {
return null
}
}
export async function writeJsonSafe(filePath: string, data: unknown): Promise {
try {
await fs.ensureDir(path.dirname(filePath))
await fs.writeJson(filePath, data, { spaces: 2 })
return true
} catch {
return false
}
}
export async function copyTemplate(
templateDir: string,
destDir: string,
replacements: Record
): Promise {
await fs.copy(templateDir, destDir)
// Walk all files and apply replacements
const files = await walkDir(destDir)
for (const file of files) {
if (isBinaryFile(file)) continue
let content = await fs.readFile(file, 'utf-8')
for (const [placeholder, value] of Object.entries(replacements)) {
content = content.replaceAll(placeholder, value)
}
await fs.writeFile(file, content)
}
}
async function walkDir(dir: string): Promise {
const entries = await fs.readdir(dir, { withFileTypes: true })
const files = await Promise.all(
entries.map((entry) => {
const fullPath = path.join(dir, entry.name)
return entry.isDirectory() ? walkDir(fullPath) : [fullPath]
})
)
return files.flat()
}
function isBinaryFile(filePath: string): boolean {
const binaryExts = ['.png', '.jpg', '.gif', '.ico', '.woff', '.woff2', '.ttf', '.zip']
return binaryExts.includes(path.extname(filePath).toLowerCase())
}
Publishing to npm
# 1. Build TypeScript to dist/
npm run build
# 2. Add shebang to compiled entry point (if not already present)
# dist/index.js should start with: #!/usr/bin/env node
# 3. Test locally before publishing
npm link
create-my-app --help
create-my-app create test-project
# 4. Ensure your package.json has the right fields
# "main": "dist/index.js", "files": ["dist"], "bin": {"create-my-app": "dist/index.js"}
# 5. Login and publish
npm login
npm publish --access public
# 6. Test the published package
npx create-my-app create hello-world
People Also Ask
Should I use Commander.js or yargs for building a CLI tool?
Both are solid choices. Commander.js is slightly lighter and has a cleaner TypeScript API in version 12+. Yargs has more built-in features (completion, middleware) but a heavier bundle. For most CLIs, Commander.js is the better starting point — you can add complexity as needed. Avoid building your own argument parser; both libraries handle edge cases (quoted arguments, boolean flags, negation) that are tedious to implement correctly.
How do I make my CLI tool work on both Windows and macOS/Linux?
Key cross-platform concerns: use path.join() instead of string concatenation for file paths (forward vs backslash), use os.homedir() instead of ~ for the home directory, avoid shell-specific syntax in execSync commands, and use cross-env for environment variables in npm scripts. Test on Windows with WSL if you do not have a Windows machine handy.
How do I store user configuration between CLI runs?
Use the conf or configstore npm packages — they handle the platform-specific config directory (~/.config/your-app on Linux/macOS, %APPDATA%your-app on Windows) transparently. For simple cases, write a JSON file to path.join(os.homedir(), '.your-app', 'config.json') using fs-extra.
Originally published at wowhow.cloud
Top comments (0)