DEV Community

Anup Karanjkar
Anup Karanjkar

Posted on • Originally published at wowhow.cloud

Build a CLI Tool with Node.js & TypeScript in 30 Minutes

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
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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() }
}
Enter fullscreen mode Exit fullscreen mode

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,
    },
  }
}
Enter fullscreen mode Exit fullscreen mode

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())
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)