Skip to main content
Browse all
WOWHOW/FIELD NOTES/DEVELOPER-PRODUCTIVITY·21 MAY 2026·13 MIN READ

A complete guide to building a production-quality CLI tool with Node.js and TypeScript. Covers Commander.js, Inquirer prompts, Ora spinners, Chalk colors, file operations, binary entry points, and publishing to npm.

WW
WOWHOW
FOUNDER · 14YR SHIPPING
Published
21 May 2026
Reading
13 min · 1,800 words
TL;DR

Build a production CLI tool with Node.js and TypeScript: Commander.js, interactive prompts, progress bars, file I/O, colored output, and publishing to npm. Complete 2026 guide.

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 <name>', 'Template to use', 'minimal')
  .option('-d, --directory <path>', '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<string, string>
  devDependencies: Record<string, string>
}

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<TemplateVars & { template: string; initGit: boolean; runInstall: boolean }> {
  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<void> {
  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<string, string>; devDependencies: Record<string, string> }
) {
  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<T>(filePath: string): Promise<T | null> {
  try {
    return await fs.readJson(filePath)
  } catch {
    return null
  }
}

export async function writeJsonSafe(filePath: string, data: unknown): Promise<boolean> {
  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<string, string>
): Promise<void> {
  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<string[]> {
  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.

Tags:Node.jsTypeScriptCLI toolCommander.jsInquirer.jsnpm publishora spinnerchalkterminal tooldeveloper tools
All Articles
WW

Written by

WOWHOW

The WOWHOW team brings 14+ years of production engineering experience. Every tool and product in the catalog is personally built, tested, and curated.

Monday Memo · Free

One insight, every Monday. 7am IST. Zero fluff.

1 field report, 3 links, 1 tool we actually use. No fluff, no spam.

Need production-ready templates?

Free browser tools with no signup, plus 2,000+ premium dev templates and starter kits.

Comments · 0

Beta: comments are stored locally on your device and not visible to other readers.

Sign in to join the conversation

No comments yet. Be the first to share your thoughts.

Pairs with this note

More from developer-productivity

See all