Building CLI Tools with Node.js: From Zero to Published npm Package | SoniNow Blog

Limited TimeLearn More

node.jsclinpmcommand linedev tools

Building CLI Tools with Node.js: From Zero to Published npm Package

Published

2026-06-23

Read Time

5 mins

Building CLI Tools with Node.js: From Zero to Published npm Package

Node.js is the most accessible platform for building CLI tools. You start with a single JavaScript file and end with a globally installed npm package that your entire team can use. From scaffolding projects to running code generators to automating deployments, CLI tools eliminate repetitive tasks. Here is the complete path from zero to a published npm CLI.

Project Structure and Entry Point

Every CLI package needs a bin field in package.json that maps your command name to an entry script. Use a shebang and ensure the file is executable.

{
  "name": "soninow-toolkit",
  "version": "0.1.0",
  "description": "CLI toolkit for SoniNow projects",
  "bin": {
    "stk": "./bin/cli.js"
  },
  "files": [
    "bin/",
    "dist/"
  ],
  "scripts": {
    "build": "tsc",
    "prepublishOnly": "npm run build"
  },
  "dependencies": {
    "commander": "^12.0.0",
    "chalk": "^5.3.0",
    "inquirer": "^9.0.0",
    "ora": "^8.0.0",
    "conf": "^11.0.0"
  },
  "devDependencies": {
    "@types/node": "^20.0.0",
    "typescript": "^5.4.0"
  }
}

The entry script needs the Node.js shebang:

#!/usr/bin/env node
// bin/cli.ts

import { program } from 'commander'
import { createCommand } from './commands/create.js'
import { buildCommand } from './commands/build.js'
import { initCommand } from './commands/init.js'

program
  .name('stk')
  .description('SoniNow Development Toolkit')
  .version('0.1.0')

program.addCommand(createCommand)
program.addCommand(buildCommand)
program.addCommand(initCommand)

program.parse(process.argv)

Argument Parsing with Commander

Commander.js is the standard for CLI argument parsing. Define subcommands, options, and help text declaratively.

// commands/create.ts
import { Command } from 'commander'

interface CreateOptions {
  template: string
  typescript: boolean
  output: string
  force: boolean
}

export const createCommand = new Command('create')
  .description('Scaffold a new project')
  .argument('<name>', 'Project name')
  .option('-t, --template <type>', 'Template type', 'next-app')
  .option('--no-typescript', 'Use JavaScript instead of TypeScript')
  .option('-o, --output <dir>', 'Output directory', process.cwd())
  .option('-f, --force', 'Overwrite existing files')
  .action(async (name: string, options: CreateOptions) => {
    await scaffoldProject(name, options)
  })

Interactive Prompts with Inquirer

CLIs that accept flags are powerful. CLIs that ask questions are user-friendly. Inquirer v9 provides checkbox lists, text input, confirmations, and autocomplete.

import inquirer from 'inquirer'

async function collectProjectConfig(): Promise<ProjectConfig> {
  const answers = await inquirer.prompt([
    {
      type: 'input',
      name: 'projectName',
      message: 'What is your project name?',
      default: 'my-app',
      validate: (input: string) => 
        /^[a-z0-9-]+$/.test(input) || 'Use kebab-case (e.g., my-new-app)',
    },
    {
      type: 'list',
      name: 'framework',
      message: 'Select a framework:',
      choices: [
        { name: 'Next.js', value: 'next' },
        { name: 'Express', value: 'express' },
        { name: 'React (Vite)', value: 'react-vite' },
      ],
    },
    {
      type: 'checkbox',
      name: 'features',
      message: 'Select features:',
      choices: [
        { name: 'TypeScript', value: 'typescript', checked: true },
        { name: 'ESLint', value: 'eslint', checked: true },
        { name: 'Docker', value: 'docker' },
        { name: 'Testing (Vitest)', value: 'vitest' },
      ],
    },
    {
      type: 'confirm',
      name: 'confirm',
      message: 'Proceed with these settings?',
    },
  ])

  if (!answers.confirm) {
    console.log('Cancelled.')
    process.exit(0)
  }

  return answers as ProjectConfig
}

Color Output and Spinners

Visual feedback matters in a terminal. Use Chalk for colored output and Ora for spinners during async operations.

import chalk from 'chalk'
import ora from 'ora'
import { execa } from 'execa'

async function installDependencies(projectDir: string) {
  const spinner = ora('Installing dependencies...').start()

  try {
    await execa('npm', ['install'], { cwd: projectDir })
    spinner.succeed(chalk.green('Dependencies installed'))
  } catch (error) {
    spinner.fail(chalk.red('Failed to install dependencies'))
    console.error(chalk.dim((error as Error).message))
    process.exit(1)
  }
}

// Informational messages
console.log(chalk.cyan('🔧 SoniNow Toolkit'))
console.log(chalk.yellow('⚠️  This action cannot be undone'))
console.log(chalk.green('✔ Project created successfully'))
console.log(chalk.dim('   Next: cd my-app && npm run dev'))

Error Handling and Exit Codes

CLI tools should exit with appropriate codes and provide actionable error messages. Wrap the main execution in a try-catch block that handles both expected and unexpected errors.

// bin/cli.ts
async function main() {
  try {
    await program.parseAsync(process.argv)
  } catch (error) {
    if (error instanceof CommanderError) {
      console.error(chalk.red(`Error: ${error.message}`))
      process.exit(error.exitCode)
    }

    if (error instanceof UserFacingError) {
      console.error(chalk.red(`\n${error.message}`))
      if (error.hint) console.error(chalk.dim(error.hint))
      process.exit(1)
    }

    // Unexpected error — include full trace
    console.error(chalk.red('Unexpected error:'), error)
    process.exit(1)
  }
}

class UserFacingError extends Error {
  constructor(message: string, public hint?: string) {
    super(message)
    this.name = 'UserFacingError'
  }
}

Configuration Persistence

CLI tools often need to remember user preferences. The conf package provides a simple config store backed by a JSON file at ~/.config/soninow-toolkit/config.json.

import Conf from 'conf'

const config = new Conf({
  projectName: 'soninow-toolkit',
  defaults: {
    defaultTemplate: 'next-app',
    npmClient: 'npm',
    githubToken: null,
  },
})

// Set and get values
config.set('defaultTemplate', 'express')
const token = config.get('githubToken')

Publishing to npm

Before publishing, ensure your package is ready:

# Build TypeScript
npm run build

# Test your CLI locally
npm link
stk --help

# Ensure only necessary files are published
npm pack --dry-run  # see what gets included

# Publish
npm publish --access public

Set up automated publishing with GitHub Actions:

name: Publish
on:
  release:
    types: [published]
jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          registry-url: 'https://registry.npmjs.org'
      - run: npm ci
      - run: npm publish
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

From Script to Ecosystem

A well-built CLI tool respects POSIX conventions, provides clear error messages, and offers both flags and interactive prompts. Users should never need to read source code to understand how to use it.

At SoniNow, we build internal and customer-facing CLI tools that streamline development workflows. Our web development services include custom CLI development, npm package architecture, and developer tooling.

Build tools your team will love. Partner with SoniNow to create CLI tools that eliminate repetitive work.