从零到一:构建一个现代化的Node.js CLI工具

4 阅读1分钟

在当今的开发世界中,命令行界面(CLI)工具已经成为开发者日常工作中不可或缺的一部分。无论是前端构建工具、后端脚手架,还是DevOps自动化脚本,CLI工具都扮演着重要角色。本文将带你从零开始,构建一个现代化、功能完善的Node.js CLI工具,涵盖从项目初始化到发布上线的完整流程。

为什么需要现代化的CLI工具?

传统的CLI工具往往存在以下问题:

  • 缺乏良好的用户体验
  • 错误处理不完善
  • 文档缺失或过时
  • 难以维护和扩展

现代化的CLI工具应该具备:

  • 直观的命令行界面
  • 完善的错误处理和提示
  • 丰富的交互功能
  • 易于测试和维护
  • 良好的文档支持

项目初始化

首先,让我们创建一个新的CLI项目:

mkdir my-cli-tool && cd my-cli-tool
npm init -y

修改package.json,添加必要的配置:

{
  "name": "my-cli-tool",
  "version": "1.0.0",
  "description": "一个现代化的CLI工具示例",
  "main": "dist/index.js",
  "bin": {
    "mycli": "./dist/index.js"
  },
  "files": ["dist"],
  "scripts": {
    "build": "tsc",
    "dev": "tsc --watch",
    "start": "node dist/index.js",
    "test": "jest",
    "lint": "eslint src --ext .ts",
    "format": "prettier --write src/**/*.ts"
  },
  "keywords": ["cli", "nodejs", "typescript"],
  "author": "Your Name",
  "license": "MIT",
  "engines": {
    "node": ">=14.0.0"
  }
}

核心依赖安装

安装必要的开发和生产依赖:

# TypeScript相关
npm install -D typescript @types/node ts-node

# CLI核心库
npm install commander inquirer chalk figlet ora

# 开发工具
npm install -D @types/inquirer @types/figlet eslint prettier jest @types/jest

# 初始化TypeScript配置
npx tsc --init

项目结构设计

采用清晰的项目结构有助于长期维护:

my-cli-tool/
├── src/
│   ├── commands/     # 命令模块
│   ├── utils/        # 工具函数
│   ├── types/        # TypeScript类型定义
│   ├── templates/    # 模板文件
│   └── index.ts      # 入口文件
├── tests/           # 测试文件
├── dist/            # 编译输出
└── package.json

核心实现

1. 入口文件 (src/index.ts)

#!/usr/bin/env node

import { Command } from 'commander';
import chalk from 'chalk';
import figlet from 'figlet';
import { createProject } from './commands/create';
import { initProject } from './commands/init';
import { version } from '../package.json';

const program = new Command();

// 显示欢迎标语
console.log(
  chalk.cyan(
    figlet.textSync('MyCLI', {
      font: 'Standard',
      horizontalLayout: 'default',
      verticalLayout: 'default'
    })
  )
);

program
  .name('mycli')
  .description('一个现代化的CLI工具')
  .version(version)
  .option('-d, --debug', '启用调试模式')
  .hook('preAction', (thisCommand, actionCommand) => {
    if (thisCommand.opts().debug) {
      console.log(chalk.gray('调试模式已启用'));
    }
  });

// 创建项目命令
program
  .command('create <project-name>')
  .description('创建一个新项目')
  .option('-t, --template <template>', '指定项目模板')
  .option('-f, --force', '强制覆盖已存在的目录')
  .action(createProject);

// 初始化项目命令
program
  .command('init')
  .description('在当前目录初始化项目配置')
  .option('-y, --yes', '跳过确认提示')
  .action(initProject);

// 错误处理
program.configureOutput({
  writeErr: (str) => process.stderr.write(chalk.red(str)),
  outputError: (str, write) => write(chalk.red(`错误: ${str}`))
});

// 解析命令行参数
program.parse(process.argv);

2. 创建项目命令 (src/commands/create.ts)

import { Command } from 'commander';
import inquirer from 'inquirer';
import chalk from 'chalk';
import ora from 'ora';
import fs from 'fs-extra';
import path from 'path';
import { TemplateManager } from '../utils/template-manager';
import { validateProjectName } from '../utils/validators';

interface CreateOptions {
  template?: string;
  force?: boolean;
}

export async function createProject(
  projectName: string,
  options: CreateOptions
) {
  const spinner = ora('正在创建项目...').start();

  try {
    // 验证项目名称
    const validation = validateProjectName(projectName);
    if (!validation.valid) {
      spinner.fail(chalk.red(validation.message));
      process.exit(1);
    }

    // 检查目录是否存在
    const projectPath = path.resolve(process.cwd(), projectName);
    if (fs.existsSync(projectPath)) {
      if (!options.force) {
        spinner.stop();
        const { overwrite } = await inquirer.prompt([
          {
            type: 'confirm',
            name: 'overwrite',
            message: `目录 "${projectName}" 已存在,是否覆盖?`,
            default: false
          }
        ]);

        if (!overwrite) {
          console.log(chalk.yellow('操作已取消'));
          process.exit(0);
        }
      }
      await fs.remove(projectPath);
    }

    // 选择模板
    let template = options.template;
    if (!template) {
      const templateManager = new TemplateManager();
      const availableTemplates = await templateManager.getAvailableTemplates();
      
      const { selectedTemplate } = await inquirer.prompt([
        {
          type: 'list',
          name: 'selectedTemplate',
          message: '请选择项目模板:',
          choices: availableTemplates
        }
      ]);
      template = selectedTemplate;
    }

    // 创建项目目录
    await fs.ensureDir(projectPath);

    // 复制模板文件
    spinner.text = '正在复制模板文件...';
    await templateManager.copyTemplate(template, projectPath);

    // 更新package.json
    const packageJsonPath = path.join(projectPath, 'package.json');
    if (fs.existsSync(packageJsonPath)) {
      const packageJson = await fs.readJson(packageJsonPath);
      packageJson.name = projectName;
      packageJson.version = '1.0.0';
      await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 });
    }

    spinner.succeed(chalk.green(`项目 "${projectName}" 创建成功!`));
    
    console.log(chalk.cyan('\n下一步:'));
    console.log(`  cd ${projectName}`);
    console.log('  npm install');
    console.log('  npm start\n');

  } catch (error) {
    spinner.fail(chalk.red('创建项目失败'));
    console.error(chalk.red(error instanceof Error ? error.message : '未知错误'));
    process.exit(1);
  }
}

3. 模板管理器 (src/utils/template-manager.ts)

import fs from 'fs-extra';
import path from 'path';
import chalk from 'chalk';

export class TemplateManager {
  private templatesDir: string;

  constructor() {
    this.templatesDir = path.join(__dirname, '../../templates');
  }

  async getAvailableTemplates(): Promise<string[]> {
    try {
      if (!fs.existsSync(this.templatesDir)) {
        await fs.ensureDir(this.templatesDir);
        // 创建默认模板
        await this.createDefaultTemplates();
      }

      const items = await fs.readdir(this.templatesDir);
      const templates = items.filter(item => 
        fs.statSync(path.join(this.templatesDir, item)).isDirectory()
      );

      if (templates.length === 0) {
        console.log(chalk.yellow('未找到模板,正在创建默认模板...'));
        await this.createDefaultTemplates();
        return this.getAvailableTemplates();
      }

      return templates;
    } catch (error) {
      throw new Error(`获取模板列表失败: ${error instanceof Error ? error.message : '未知错误'}`);
    }
  }

  async copyTemplate(templateName: string, targetDir: string): Promise<void> {
    const templatePath = path.join(this.templatesDir, templateName);
    
    if (!fs.existsSync(templatePath)) {
      throw new Error(`模板 "${templateName}" 不存在`);
    }

    try {
      await fs.copy(templatePath, targetDir, {