从零构建一个现代化的 CLI 工具:设计、开发与最佳实践

7 阅读1分钟

在当今的开发者工具生态中,命令行界面(CLI)工具仍然扮演着至关重要的角色。无论是前端构建工具(如 Webpack、Vite)、包管理器(如 npm、yarn),还是基础设施工具(如 Docker、Kubernetes),CLI 都是开发者与复杂系统交互的主要方式。本文将带你从零开始,构建一个现代化、用户友好的 CLI 工具,涵盖架构设计、开发实践和发布部署的全过程。

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

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

  • 缺乏直观的帮助系统
  • 错误信息不友好
  • 缺少自动补全功能
  • 配置方式复杂
  • 跨平台兼容性差

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

  • 优雅的命令行解析
  • 智能的自动补全
  • 丰富的交互式提示
  • 清晰的文档和帮助
  • 良好的错误处理
  • 美观的输出格式

技术栈选择

我们将使用以下技术栈构建我们的 CLI 工具:

  1. Node.js - 运行时环境
  2. Commander.js - 命令行参数解析
  3. Inquirer.js - 交互式命令行界面
  4. Chalk - 终端字符串样式
  5. Ora - 优雅的加载动画
  6. Boxen - 创建终端框
  7. Figlet - ASCII 艺术字
  8. ShellJS - 跨平台 Shell 命令

项目初始化

首先,创建我们的项目结构:

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

安装依赖:

npm install commander inquirer chalk ora boxen figlet shelljs
npm install -D @types/node typescript ts-node nodemon

配置 TypeScript:

// 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
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

核心架构设计

1. 入口文件设计

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

import { Command } from 'commander';
import chalk from 'chalk';
import figlet from 'figlet';
import { version } from '../package.json';

// 初始化 CLI
const program = new Command();

// 基本信息
program
  .name('my-cli')
  .description('一个现代化的 CLI 工具示例')
  .version(version)
  .configureOutput({
    outputError: (str, write) => write(chalk.red(str))
  });

// 显示欢迎信息
function showWelcome() {
  console.log(
    chalk.blue(
      figlet.textSync('My CLI', {
        font: 'Standard',
        horizontalLayout: 'default',
        verticalLayout: 'default'
      })
    )
  );
  console.log(chalk.gray(`版本: ${version}\n`));
}

// 错误处理中间件
function errorHandler(fn: Function) {
  return async (...args: any[]) => {
    try {
      await fn(...args);
    } catch (error) {
      console.error(chalk.red('错误:'), error.message);
      process.exit(1);
    }
  };
}

export { program, showWelcome, errorHandler };

2. 命令模块设计

// src/commands/init.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';

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

export const initCommand = new Command('init')
  .description('初始化新项目')
  .argument('[project-name]', '项目名称')
  .option('-t, --template <template>', '项目模板')
  .option('-f, --force', '强制覆盖已存在的目录')
  .action(async (projectName, options: InitOptions) => {
    // 交互式获取项目名称
    if (!projectName) {
      const answers = await inquirer.prompt([
        {
          type: 'input',
          name: 'projectName',
          message: '请输入项目名称:',
          validate: (input: string) => {
            if (!input.trim()) {
              return '项目名称不能为空';
            }
            return true;
          }
        }
      ]);
      projectName = answers.projectName;
    }

    const projectPath = path.resolve(process.cwd(), projectName);
    
    // 检查目录是否存在
    if (fs.existsSync(projectPath) && !options.force) {
      const { confirm } = await inquirer.prompt([
        {
          type: 'confirm',
          name: 'confirm',
          message: `目录 "${projectName}" 已存在,是否覆盖?`,
          default: false
        }
      ]);
      
      if (!confirm) {
        console.log(chalk.yellow('操作已取消'));
        return;
      }
    }

    // 选择模板
    let template = options.template;
    if (!template) {
      const { selectedTemplate } = await inquirer.prompt([
        {
          type: 'list',
          name: 'selectedTemplate',
          message: '请选择项目模板:',
          choices: [
            { name: 'React + TypeScript', value: 'react-ts' },
            { name: 'Vue 3 + TypeScript', value: 'vue3-ts' },
            { name: 'Node.js API', value: 'node-api' },
            { name: 'CLI 工具', value: 'cli-tool' }
          ]
        }
      ]);
      template = selectedTemplate;
    }

    // 开始初始化
    const spinner = ora('正在初始化项目...').start();
    
    try {
      // 创建项目目录
      await fs.ensureDir(projectPath);
      
      // 根据模板复制文件
      const templatePath = path.join(__dirname, '../../templates', template);
      
      if (await fs.pathExists(templatePath)) {
        await fs.copy(templatePath, projectPath);
      } else {
        // 创建默认项目结构
        await createDefaultProject(projectPath, template);
      }
      
      // 更新 package.json
      await updatePackageJson(projectPath, projectName);
      
      spinner.succeed(chalk.green('项目初始化完成!'));
      
      // 显示后续步骤
      console.log('\n' + chalk.bold('下一步:'));
      console.log(chalk.cyan(`  cd ${projectName}`));
      console.log(chalk.cyan('  npm install'));
      console.log(chalk.cyan('  npm run dev\n'));
      
    } catch (error) {
      spinner.fail(chalk.red('初始化失败'));
      throw error;
    }
  });

async function createDefaultProject(projectPath: string, template: string) {
  const defaultFiles: Record<string, string> = {
    'package.json': JSON.stringify({
      name: path.basename(projectPath),
      version: '1.0.0',
      main: 'src/index.ts',
      scripts: {
        dev: 'ts-node src/index.ts',
        build: 'tsc',
        start: 'node dist/index.js'
      },
      dependencies: {},
      devDependencies: {
        typescript: '^4.0.0',
        'ts-node': '^10.0.0'
      }
    }, null, 2),
    
    'tsconfig.json': JSON.stringify({
      compilerOptions: {
        target: "ES2020",
        module: "commonjs",
        outDir: "./dist",
        rootDir: "./src",
        strict: true,
        esModuleInterop: true
      }
    }, null, 2),
    
    'src/index.ts': `console.log('Hello from ${template} template!');`,
    
    '.gitignore': `node_modules/\ndist/\n.env\n`
  };
  
  await Promise.all(
    Object.entries(defaultFiles).map(async ([filename, content]) => {
      const filePath = path.join(projectPath, filename);
      await fs.ensureDir(path.dirname(filePath));
      await fs.writeFile(filePath, content);
    })
  );
}

async function updatePackageJson(projectPath: string, projectName: string) {
  const packageJsonPath = path.join(projectPath, 'package.json');
  
  if (await fs.pathExists(packageJsonPath)) {
    const packageJson = await fs.readJson(packageJsonPath);
    packageJson.name = projectName.toLowerCase().replace(/\s+/g, '-');
    await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 });
  }
}

3.