从零构建现代化 CLI 工具:Node.js 最佳实践指南

0 阅读1分钟

引言

在当今的开发世界中,命令行界面(CLI)工具仍然是开发者日常工作中不可或缺的一部分。无论是用于项目脚手架、代码生成、自动化任务还是系统管理,一个设计良好的 CLI 工具可以显著提升开发效率。本文将带你从零开始,使用 Node.js 构建一个现代化、功能完整的 CLI 工具,涵盖架构设计、最佳实践和高级技巧。

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

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

  • 缺乏良好的用户体验
  • 错误处理不完善
  • 配置管理混乱
  • 测试覆盖不足
  • 文档不完整

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

  • 直观的命令结构
  • 丰富的交互体验
  • 完善的错误处理
  • 易于扩展的架构
  • 完整的测试套件

项目初始化与架构设计

1. 创建项目结构

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

2. 基础目录结构

// package.json 中的 scripts 配置
{
  "name": "my-cli",
  "version": "1.0.0",
  "description": "A modern CLI tool built with Node.js",
  "bin": {
    "mycli": "./bin/cli.js"
  },
  "scripts": {
    "start": "node ./bin/cli.js",
    "test": "jest",
    "lint": "eslint .",
    "build": "ncc build src/index.js -o dist"
  },
  "files": ["bin", "lib", "dist"]
}

项目目录结构:

my-cli-tool/
├── bin/
│   └── cli.js          # CLI 入口点
├── src/
│   ├── commands/       # 命令实现
│   ├── utils/          # 工具函数
│   ├── config/         # 配置管理
│   └── index.js        # 主模块
├── tests/              # 测试文件
├── docs/               # 文档
└── package.json

核心实现

1. CLI 入口文件

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

'use strict';

// 检查 Node.js 版本
const currentNodeVersion = process.versions.node;
const semver = currentNodeVersion.split('.');
const major = semver[0];

if (major < 14) {
  console.error(
    `You are running Node ${currentNodeVersion}.\n` +
    `This CLI requires Node 14 or higher.\n` +
    `Please update your Node version.`
  );
  process.exit(1);
}

// 初始化 CLI
require('../src/cli').run();

2. 主 CLI 模块

// src/cli.js
const { Command } = require('commander');
const chalk = require('chalk');
const figlet = require('figlet');
const inquirer = require('inquirer');
const ora = require('ora');

class MyCLI {
  constructor() {
    this.program = new Command();
    this.initProgram();
  }

  initProgram() {
    this.program
      .name('mycli')
      .description('A modern CLI tool for developers')
      .version('1.0.0', '-v, --version')
      .option('-d, --debug', 'enable debug mode')
      .hook('preAction', this.preAction.bind(this))
      .hook('postAction', this.postAction.bind(this));

    // 添加命令
    this.initCommands();
  }

  initCommands() {
    // 初始化命令
    const initCommand = new Command('init')
      .description('Initialize a new project')
      .option('-t, --template <template>', 'project template')
      .action(this.handleInit.bind(this));

    // 构建命令
    const buildCommand = new Command('build')
      .description('Build project')
      .option('-o, --output <dir>', 'output directory')
      .action(this.handleBuild.bind(this));

    // 添加子命令
    this.program.addCommand(initCommand);
    this.program.addCommand(buildCommand);
  }

  async handleInit(options) {
    const spinner = ora('Initializing project...').start();
    
    try {
      // 交互式问答
      const answers = await inquirer.prompt([
        {
          type: 'list',
          name: 'template',
          message: 'Select a template:',
          choices: ['react', 'vue', 'node', 'typescript'],
          when: !options.template
        },
        {
          type: 'input',
          name: 'projectName',
          message: 'Project name:',
          default: 'my-project'
        }
      ]);

      // 模拟初始化过程
      await this.simulateInit(answers);
      
      spinner.succeed(chalk.green('Project initialized successfully!'));
      
      // 显示下一步提示
      console.log(chalk.blue('\nNext steps:'));
      console.log(`  cd ${answers.projectName}`);
      console.log('  npm install');
      console.log('  npm start');
      
    } catch (error) {
      spinner.fail(chalk.red('Initialization failed'));
      this.handleError(error);
    }
  }

  async handleBuild(options) {
    // 构建逻辑实现
    console.log('Building project...');
  }

  async simulateInit(config) {
    // 模拟异步操作
    return new Promise(resolve => {
      setTimeout(() => {
        console.log(chalk.cyan(`\nCreating ${config.template} project...`));
        console.log(`Project name: ${config.projectName}`);
        resolve();
      }, 1000);
    });
  }

  preAction() {
    // 显示欢迎信息
    console.log(
      chalk.yellow(
        figlet.textSync('MyCLI', { horizontalLayout: 'full' })
      )
    );
  }

  postAction() {
    // 清理工作
    if (this.program.opts().debug) {
      console.log(chalk.gray('\nDebug mode enabled'));
    }
  }

  handleError(error) {
    if (this.program.opts().debug) {
      console.error(chalk.red('\nError details:'));
      console.error(error.stack);
    } else {
      console.error(chalk.red(`\nError: ${error.message}`));
    }
    process.exit(1);
  }

  run() {
    this.program.parse(process.argv);
  }
}

module.exports = new MyCLI();

3. 配置管理模块

// src/config/config-manager.js
const fs = require('fs').promises;
const path = require('path');
const os = require('os');

class ConfigManager {
  constructor() {
    this.configDir = path.join(os.homedir(), '.mycli');
    this.configFile = path.join(this.configDir, 'config.json');
    this.defaultConfig = {
      version: '1.0.0',
      preferences: {
        theme: 'light',
        autoUpdate: true,
        defaultTemplate: 'react'
      }
    };
  }

  async ensureConfigDir() {
    try {
      await fs.access(this.configDir);
    } catch {
      await fs.mkdir(this.configDir, { recursive: true });
    }
  }

  async loadConfig() {
    await this.ensureConfigDir();
    
    try {
      const data = await fs.readFile(this.configFile, 'utf8');
      return JSON.parse(data);
    } catch (error) {
      if (error.code === 'ENOENT') {
        return await this.saveConfig(this.defaultConfig);
      }
      throw error;
    }
  }

  async saveConfig(config) {
    await this.ensureConfigDir();
    await fs.writeFile(
      this.configFile,
      JSON.stringify(config, null, 2),
      'utf8'
    );
    return config;
  }

  async updateConfig(updates) {
    const config = await this.loadConfig();
    const newConfig = { ...config, ...updates };
    return await this.saveConfig(newConfig);
  }
}

module.exports = new ConfigManager();

4. 高级功能:插件系统

// src/plugins/plugin-manager.js
const path = require('path');
const fs = require('fs').promises;

class PluginManager {
  constructor() {
    this.plugins = new Map();
    this.pluginDir = path.join(__dirname, '../../plugins');
  }

  async loadPlugins() {
    try {
      const files = await fs.readdir(this.pluginDir);
      
      for (const file of files) {
        if (file.endsWith('.js')) {
          await this.loadPlugin(path.join(this.pluginDir, file));
        }
      }
    } catch (error) {
      // 插件目录可能不存在
      if (error.code !== 'ENOENT') {
        throw error;
      }
    }
  }

  async loadPlugin(pluginPath) {
    try {
      const plugin = require(pluginPath);
      
      if (this.validatePlugin(plugin)) {
        this.plugins.set(plugin.name, plugin);
        console