Building a Modern CLI Tool with Node.js and TypeScript

A comprehensive guide to building professional command-line tools using modern JavaScript technologies

ByYour Name
12 min read
CLINode.jsTypeScriptDeveloper ToolsJavaScript

Building a Modern CLI Tool with Node.js and TypeScript

Command-line tools are incredibly powerful for automating tasks, improving developer workflows, and building utilities that developers love to use. In this post, I'll walk you through building a professional CLI tool from scratch using Node.js and TypeScript.

We'll build a tool called project-init that helps developers quickly scaffold new projects with different templates and configurations.

Setting Up the Foundation

Project Structure

Let's start with a well-organized project structure:

project-init/
├── src/
│   ├── commands/
│   │   ├── create.ts
│   │   ├── list.ts
│   │   └── index.ts
│   ├── templates/
│   │   ├── react-app/
│   │   ├── node-api/
│   │   └── templates.ts
│   ├── utils/
│   │   ├── file-system.ts
│   │   ├── git.ts
│   │   └── validation.ts
│   ├── types/
│   │   └── index.ts
│   ├── cli.ts
│   └── index.ts
├── bin/
│   └── project-init
├── package.json
├── tsconfig.json
├── .gitignore
└── README.md

Package.json Configuration

{
  "name": "project-init-cli",
  "version": "1.0.0",
  "description": "A modern project scaffolding CLI tool",
  "bin": {
    "project-init": "./bin/project-init"
  },
  "scripts": {
    "build": "tsc",
    "dev": "tsx src/index.ts",
    "start": "node dist/index.js",
    "lint": "eslint src/**/*.ts",
    "test": "jest"
  },
  "dependencies": {
    "commander": "^11.0.0",
    "inquirer": "^9.2.0",
    "chalk": "^5.3.0",
    "ora": "^7.0.0",
    "fs-extra": "^11.1.0",
    "axios": "^1.5.0",
    "glob": "^10.3.0"
  },
  "devDependencies": {
    "@types/node": "^20.5.0",
    "@types/inquirer": "^9.0.0",
    "@types/fs-extra": "^11.0.0",
    "typescript": "^5.1.0",
    "tsx": "^3.12.0",
    "eslint": "^8.47.0",
    "@typescript-eslint/eslint-plugin": "^6.4.0",
    "@typescript-eslint/parser": "^6.4.0",
    "jest": "^29.6.0",
    "@types/jest": "^29.5.0"
  },
  "engines": {
    "node": ">=16.0.0"
  },
  "keywords": ["cli", "scaffolding", "project", "template", "generator"],
  "author": "Your Name <your.email@example.com>",
  "license": "MIT"
}

Building the Core CLI Structure

Entry Point and Binary

First, let's create the binary file that will be executed when users run our CLI:

#!/usr/bin/env node
# bin/project-init

require('../dist/index.js');

Main CLI Interface

// src/cli.ts
import { Command } from 'commander';
import chalk from 'chalk';
import { createProject } from './commands/create';
import { listTemplates } from './commands/list';

const program = new Command();

program
  .name('project-init')
  .description('A modern project scaffolding CLI tool')
  .version('1.0.0');

program
  .command('create')
  .alias('c')
  .description('Create a new project from a template')
  .argument('[project-name]', 'Name of the project to create')
  .option('-t, --template <template>', 'Template to use')
  .option('-d, --directory <directory>', 'Target directory', process.cwd())
  .option('--git', 'Initialize git repository', false)
  .option('--install', 'Install dependencies', true)
  .option('--no-install', 'Skip dependency installation')
  .action(createProject);

program
  .command('list')
  .alias('ls')
  .description('List available templates')
  .action(listTemplates);

// Global error handling
program.exitOverride((err) => {
  console.error(chalk.red('Error:'), err.message);
  process.exit(1);
});

export { program };

Entry Point

// src/index.ts
#!/usr/bin/env node

import { program } from './cli';

async function main() {
  try {
    await program.parseAsync(process.argv);
  } catch (error) {
    console.error('An unexpected error occurred:', error);
    process.exit(1);
  }
}

main();

Implementing Commands

The Create Command

// src/commands/create.ts
import inquirer from 'inquirer';
import chalk from 'chalk';
import ora from 'ora';
import path from 'path';
import { promises as fs } from 'fs';
import { validateProjectName, ensureDirectoryExists } from '../utils/validation';
import { getTemplates, downloadTemplate } from '../templates/templates';
import { initializeGit, installDependencies } from '../utils/git';
import { ProjectOptions, Template } from '../types';

export async function createProject(
  projectName?: string,
  options: ProjectOptions = {}
) {
  console.log(chalk.blue.bold('🚀 Project Init CLI\n'));

  try {
    // Get project name if not provided
    if (!projectName) {
      const nameAnswer = await inquirer.prompt([
        {
          type: 'input',
          name: 'projectName',
          message: 'What is your project name?',
          validate: validateProjectName,
        },
      ]);
      projectName = nameAnswer.projectName;
    }

    // Validate project name
    if (!validateProjectName(projectName)) {
      throw new Error('Invalid project name');
    }

    const projectPath = path.join(options.directory || process.cwd(), projectName);

    // Check if directory already exists
    try {
      await fs.access(projectPath);
      const overwriteAnswer = await inquirer.prompt([
        {
          type: 'confirm',
          name: 'overwrite',
          message: `Directory "${projectName}" already exists. Overwrite?`,
          default: false,
        },
      ]);

      if (!overwriteAnswer.overwrite) {
        console.log(chalk.yellow('Operation cancelled.'));
        return;
      }
    } catch {
      // Directory doesn't exist, which is what we want
    }

    // Get available templates
    const templates = await getTemplates();
    let selectedTemplate: Template;

    if (options.template) {
      selectedTemplate = templates.find(t => t.name === options.template);
      if (!selectedTemplate) {
        throw new Error(`Template "${options.template}" not found`);
      }
    } else {
      const templateAnswer = await inquirer.prompt([
        {
          type: 'list',
          name: 'template',
          message: 'Choose a template:',
          choices: templates.map(template => ({
            name: `${template.name} - ${template.description}`,
            value: template,
          })),
        },
      ]);
      selectedTemplate = templateAnswer.template;
    }

    // Additional configuration based on template
    const config = await getTemplateConfig(selectedTemplate);

    // Create project
    const spinner = ora('Creating project...').start();

    try {
      await ensureDirectoryExists(projectPath);
      await downloadTemplate(selectedTemplate, projectPath, config);

      spinner.succeed(chalk.green('Project created successfully!'));

      // Initialize git if requested
      if (options.git) {
        const gitSpinner = ora('Initializing git repository...').start();
        try {
          await initializeGit(projectPath);
          gitSpinner.succeed('Git repository initialized');
        } catch (error) {
          gitSpinner.fail(`Failed to initialize git: ${error.message}`);
        }
      }

      // Install dependencies if requested
      if (options.install) {
        const installSpinner = ora('Installing dependencies...').start();
        try {
          await installDependencies(projectPath);
          installSpinner.succeed('Dependencies installed');
        } catch (error) {
          installSpinner.fail(`Failed to install dependencies: ${error.message}`);
        }
      }

      console.log(chalk.green.bold('\n✨ Project created successfully!\n'));
      console.log('Next steps:');
      console.log(chalk.cyan(`  cd ${projectName}`));
      
      if (!options.install) {
        console.log(chalk.cyan('  npm install'));
      }
      
      console.log(chalk.cyan('  npm run dev'));

    } catch (error) {
      spinner.fail(`Failed to create project: ${error.message}`);
      throw error;
    }

  } catch (error) {
    console.error(chalk.red('Error creating project:'), error.message);
    process.exit(1);
  }
}

async function getTemplateConfig(template: Template): Promise<Record<string, any>> {
  const config: Record<string, any> = {};

  if (template.prompts) {
    const answers = await inquirer.prompt(template.prompts);
    Object.assign(config, answers);
  }

  return config;
}

The List Command

// src/commands/list.ts
import chalk from 'chalk';
import { getTemplates } from '../templates/templates';

export async function listTemplates() {
  console.log(chalk.blue.bold('📋 Available Templates\n'));

  try {
    const templates = await getTemplates();

    if (templates.length === 0) {
      console.log(chalk.yellow('No templates available.'));
      return;
    }

    templates.forEach(template => {
      console.log(chalk.green.bold(`${template.name}`));
      console.log(chalk.gray(`  ${template.description}`));
      
      if (template.tags && template.tags.length > 0) {
        console.log(chalk.blue(`  Tags: ${template.tags.join(', ')}`));
      }
      
      console.log(); // Empty line for spacing
    });

  } catch (error) {
    console.error(chalk.red('Error listing templates:'), error.message);
    process.exit(1);
  }
}

Template System

Template Definition

// src/types/index.ts
export interface Template {
  name: string;
  description: string;
  tags: string[];
  source: 'local' | 'github' | 'url';
  path: string;
  prompts?: PromptQuestion[];
}

export interface PromptQuestion {
  type: 'input' | 'confirm' | 'list' | 'checkbox';
  name: string;
  message: string;
  choices?: string[] | { name: string; value: any }[];
  default?: any;
  validate?: (input: any) => boolean | string;
}

export interface ProjectOptions {
  template?: string;
  directory?: string;
  git?: boolean;
  install?: boolean;
}

Template Manager

// src/templates/templates.ts
import path from 'path';
import { promises as fs } from 'fs';
import axios from 'axios';
import { glob } from 'glob';
import { Template } from '../types';
import { downloadGitHubRepo, cloneRepository } from '../utils/git';
import { copyDirectory, processTemplateFile } from '../utils/file-system';

const TEMPLATES: Template[] = [
  {
    name: 'react-app',
    description: 'Modern React application with TypeScript and Vite',
    tags: ['react', 'typescript', 'vite', 'frontend'],
    source: 'local',
    path: path.join(__dirname, 'react-app'),
    prompts: [
      {
        type: 'confirm',
        name: 'useRouter',
        message: 'Include React Router?',
        default: true,
      },
      {
        type: 'list',
        name: 'styling',
        message: 'Choose styling solution:',
        choices: [
          { name: 'Tailwind CSS', value: 'tailwind' },
          { name: 'Styled Components', value: 'styled-components' },
          { name: 'CSS Modules', value: 'css-modules' },
        ],
        default: 'tailwind',
      },
    ],
  },
  {
    name: 'node-api',
    description: 'Express.js API with TypeScript and authentication',
    tags: ['node', 'express', 'api', 'typescript', 'backend'],
    source: 'local',
    path: path.join(__dirname, 'node-api'),
    prompts: [
      {
        type: 'list',
        name: 'database',
        message: 'Choose database:',
        choices: ['PostgreSQL', 'MongoDB', 'SQLite'],
        default: 'PostgreSQL',
      },
      {
        type: 'confirm',
        name: 'authentication',
        message: 'Include JWT authentication?',
        default: true,
      },
    ],
  },
  {
    name: 'nextjs-app',
    description: 'Next.js application with App Router',
    tags: ['nextjs', 'react', 'typescript', 'fullstack'],
    source: 'github',
    path: 'vercel/next.js/tree/canary/examples/hello-world',
  },
];

export async function getTemplates(): Promise<Template[]> {
  return TEMPLATES;
}

export async function downloadTemplate(
  template: Template,
  targetPath: string,
  config: Record<string, any> = {}
): Promise<void> {
  switch (template.source) {
    case 'local':
      await copyLocalTemplate(template.path, targetPath, config);
      break;
    case 'github':
      await downloadGitHubTemplate(template.path, targetPath, config);
      break;
    case 'url':
      await downloadUrlTemplate(template.path, targetPath, config);
      break;
    default:
      throw new Error(`Unsupported template source: ${template.source}`);
  }
}

async function copyLocalTemplate(
  sourcePath: string,
  targetPath: string,
  config: Record<string, any>
): Promise<void> {
  await copyDirectory(sourcePath, targetPath);
  await processTemplateFiles(targetPath, config);
}

async function downloadGitHubTemplate(
  repoPath: string,
  targetPath: string,
  config: Record<string, any>
): Promise<void> {
  await downloadGitHubRepo(repoPath, targetPath);
  await processTemplateFiles(targetPath, config);
}

async function downloadUrlTemplate(
  url: string,
  targetPath: string,
  config: Record<string, any>
): Promise<void> {
  // Implementation for downloading from URL
  // This could be a zip file or tar.gz
  throw new Error('URL templates not implemented yet');
}

async function processTemplateFiles(
  templatePath: string,
  config: Record<string, any>
): Promise<void> {
  const files = await glob('**/*', {
    cwd: templatePath,
    nodir: true,
    absolute: true,
  });

  for (const file of files) {
    await processTemplateFile(file, config);
  }
}

Utility Functions

File System Operations

// src/utils/file-system.ts
import { promises as fs } from 'fs';
import path from 'path';
import fse from 'fs-extra';

export async function copyDirectory(source: string, target: string): Promise<void> {
  await fse.copy(source, target, {
    filter: (src) => {
      // Skip node_modules and other unwanted directories
      return !src.includes('node_modules') && !src.includes('.git');
    },
  });
}

export async function processTemplateFile(
  filePath: string,
  config: Record<string, any>
): Promise<void> {
  try {
    const content = await fs.readFile(filePath, 'utf-8');
    
    // Replace template variables
    let processedContent = content;
    
    Object.entries(config).forEach(([key, value]) => {
      const regex = new RegExp(`{{\\s*${key}\\s*}}`, 'g');
      processedContent = processedContent.replace(regex, String(value));
    });

    // Handle conditional blocks
    processedContent = processConditionalBlocks(processedContent, config);

    await fs.writeFile(filePath, processedContent);
  } catch (error) {
    // If file is binary or can't be processed as text, skip it
    if (error.code !== 'EISDIR') {
      console.warn(`Warning: Could not process template file ${filePath}`);
    }
  }
}

function processConditionalBlocks(
  content: string,
  config: Record<string, any>
): string {
  // Process {{#if condition}} blocks
  const ifRegex = /{{#if\s+(\w+)}}([\s\S]*?){{\/if}}/g;
  
  return content.replace(ifRegex, (match, condition, block) => {
    return config[condition] ? block : '';
  });
}

export async function ensureDirectoryExists(dirPath: string): Promise<void> {
  await fse.ensureDir(dirPath);
}

Git Operations

// src/utils/git.ts
import { exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';

const execAsync = promisify(exec);

export async function initializeGit(projectPath: string): Promise<void> {
  await execAsync('git init', { cwd: projectPath });
  await execAsync('git add .', { cwd: projectPath });
  await execAsync('git commit -m "Initial commit"', { cwd: projectPath });
}

export async function installDependencies(projectPath: string): Promise<void> {
  // Check if package.json exists
  try {
    await execAsync('test -f package.json', { cwd: projectPath });
  } catch {
    return; // No package.json, skip installation
  }

  // Detect package manager
  const packageManager = await detectPackageManager(projectPath);
  
  const installCommand = {
    npm: 'npm install',
    yarn: 'yarn install',
    pnpm: 'pnpm install',
  }[packageManager];

  await execAsync(installCommand, { cwd: projectPath });
}

async function detectPackageManager(projectPath: string): Promise<'npm' | 'yarn' | 'pnpm'> {
  try {
    await execAsync('test -f yarn.lock', { cwd: projectPath });
    return 'yarn';
  } catch {}

  try {
    await execAsync('test -f pnpm-lock.yaml', { cwd: projectPath });
    return 'pnpm';
  } catch {}

  return 'npm';
}

export async function downloadGitHubRepo(
  repoPath: string,
  targetPath: string
): Promise<void> {
  const [owner, repo, ...pathParts] = repoPath.split('/');
  const subPath = pathParts.join('/');
  
  const command = subPath
    ? `npx degit ${owner}/${repo}/${subPath} ${targetPath}`
    : `npx degit ${owner}/${repo} ${targetPath}`;

  await execAsync(command);
}

Validation Utilities

// src/utils/validation.ts
import { promises as fs } from 'fs';

export function validateProjectName(name: string): boolean | string {
  if (!name) {
    return 'Project name is required';
  }

  if (!/^[a-zA-Z][a-zA-Z0-9-_]*$/.test(name)) {
    return 'Project name must start with a letter and contain only letters, numbers, hyphens, and underscores';
  }

  if (name.length > 50) {
    return 'Project name must be less than 50 characters';
  }

  return true;
}

export async function ensureDirectoryExists(dirPath: string): Promise<void> {
  try {
    await fs.access(dirPath);
  } catch {
    await fs.mkdir(dirPath, { recursive: true });
  }
}

Advanced Features

Configuration File Support

// src/config.ts
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';

interface CLIConfig {
  defaultTemplate?: string;
  defaultDirectory?: string;
  autoInstall?: boolean;
  autoGit?: boolean;
  customTemplates?: string[];
}

const CONFIG_FILE = path.join(os.homedir(), '.project-init.json');

export async function loadConfig(): Promise<CLIConfig> {
  try {
    const configContent = await fs.readFile(CONFIG_FILE, 'utf-8');
    return JSON.parse(configContent);
  } catch {
    return {}; // Return empty config if file doesn't exist
  }
}

export async function saveConfig(config: CLIConfig): Promise<void> {
  await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
}

// Add config command to CLI
program
  .command('config')
  .description('Manage CLI configuration')
  .option('--set <key=value>', 'Set configuration value')
  .option('--get <key>', 'Get configuration value')
  .option('--list', 'List all configuration')
  .action(async (options) => {
    const config = await loadConfig();
    
    if (options.list) {
      console.log(JSON.stringify(config, null, 2));
    } else if (options.get) {
      console.log(config[options.get] || 'Not set');
    } else if (options.set) {
      const [key, value] = options.set.split('=');
      config[key] = value === 'true' ? true : value === 'false' ? false : value;
      await saveConfig(config);
      console.log(`Set ${key} = ${value}`);
    }
  });

Plugin System

// src/plugins/index.ts
import path from 'path';
import { promises as fs } from 'fs';

export interface Plugin {
  name: string;
  version: string;
  commands?: PluginCommand[];
  templates?: Template[];
}

export interface PluginCommand {
  name: string;
  description: string;
  handler: (args: any[], options: any) => Promise<void>;
}

export async function loadPlugins(): Promise<Plugin[]> {
  const pluginDir = path.join(process.cwd(), 'node_modules');
  const plugins: Plugin[] = [];

  try {
    const packages = await fs.readdir(pluginDir);
    
    for (const pkg of packages) {
      if (pkg.startsWith('project-init-plugin-')) {
        try {
          const pluginPath = path.join(pluginDir, pkg);
          const plugin = require(pluginPath);
          plugins.push(plugin);
        } catch {
          // Skip invalid plugins
        }
      }
    }
  } catch {
    // No node_modules directory
  }

  return plugins;
}

Testing the CLI

Unit Tests

// src/__tests__/validation.test.ts
import { validateProjectName } from '../utils/validation';

describe('validateProjectName', () => {
  test('should accept valid project names', () => {
    expect(validateProjectName('my-project')).toBe(true);
    expect(validateProjectName('myProject')).toBe(true);
    expect(validateProjectName('my_project')).toBe(true);
    expect(validateProjectName('project123')).toBe(true);
  });

  test('should reject invalid project names', () => {
    expect(validateProjectName('')).toContain('required');
    expect(validateProjectName('123project')).toContain('start with a letter');
    expect(validateProjectName('my-project-with-very-long-name-that-exceeds-fifty-characters')).toContain('less than 50 characters');
    expect(validateProjectName('my project')).toContain('only letters, numbers, hyphens, and underscores');
  });
});

Integration Tests

// src/__tests__/cli.test.ts
import { exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import { promises as fs } from 'fs';
import os from 'os';

const execAsync = promisify(exec);

describe('CLI Integration', () => {
  let testDir: string;

  beforeEach(async () => {
    testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'project-init-test-'));
  });

  afterEach(async () => {
    await fs.rmdir(testDir, { recursive: true });
  });

  test('should create project with default template', async () => {
    const { stdout } = await execAsync(
      `node dist/index.js create test-project --template react-app --no-install --no-git`,
      { cwd: testDir }
    );

    expect(stdout).toContain('Project created successfully');
    
    const projectPath = path.join(testDir, 'test-project');
    const packageJsonExists = await fs.access(path.join(projectPath, 'package.json'))
      .then(() => true)
      .catch(() => false);
    
    expect(packageJsonExists).toBe(true);
  });
});

Building and Publishing

Build Configuration

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "removeComments": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "**/*.test.ts"]
}

Publishing Script

#!/bin/bash
# scripts/publish.sh

# Build the project
npm run build

# Run tests
npm test

# Update version
npm version patch

# Publish to npm
npm publish

# Create git tag
git push --tags

Conclusion

Building a modern CLI tool requires careful consideration of user experience, error handling, and maintainability. Key takeaways:

  1. Use TypeScript for better developer experience and fewer runtime errors
  2. Implement proper error handling and user feedback with spinners and colors
  3. Make it interactive with prompts for better usability
  4. Support configuration for power users
  5. Test thoroughly with both unit and integration tests
  6. Document well with clear help text and examples

The patterns shown here can be adapted for any CLI tool, whether it's for code generation, deployment automation, or developer utilities.


What CLI tools have you built or would like to build? Share your experiences and ideas in the comments!