Node.js CLI工具开发实践

使用TypeScript开发专业级命令行工具,包含交互式界面、配置管理、插件系统和NPM包发布的完整实践

2024年3月10日
源代码
Node.jsCLITypeScript工具开发

项目简介

这是一个功能完整的Node.js命令行工具项目,实现了代码生成、项目初始化和文件处理等功能。项目使用TypeScript开发,提供了丰富的交互式界面、灵活的配置系统和可扩展的插件架构。

核心功能

1. 命令系统

  • 子命令支持
  • 选项和参数解析
  • 帮助文档生成
  • 别名支持

2. 交互式界面

  • 问答式输入
  • 单选多选
  • 进度条显示
  • 美化输出

3. 配置管理

  • 配置文件读写
  • 多环境支持
  • 默认值合并
  • 配置验证

4. 插件系统

  • 动态加载插件
  • 生命周期钩子
  • 插件通信
  • 插件市场

技术栈

  • Node.js + TypeScript
  • Commander.js (命令解析)
  • Inquirer.js (交互式提示)
  • Chalk (终端样式)
  • Ora (加载动画)
  • Figlet (ASCII艺术字)
  • Cosmiconfig (配置管理)
  • Yargs (参数解析)

项目结构

cli-tool/
├── bin/
│   └── cli.js              # 入口文件
├── src/
│   ├── commands/           # 命令实现
│   │   ├── init.ts
│   │   ├── generate.ts
│   │   └── config.ts
│   ├── utils/             # 工具函数
│   │   ├── logger.ts
│   │   ├── file.ts
│   │   └── template.ts
│   ├── plugins/           # 插件系统
│   │   ├── plugin-manager.ts
│   │   └── base-plugin.ts
│   ├── config/            # 配置管理
│   │   └── config-manager.ts
│   ├── types/             # 类型定义
│   └── index.ts           # 主入口
├── templates/             # 模板文件
├── tests/                # 测试文件
├── package.json
└── tsconfig.json

核心实现

1. CLI入口配置

// src/index.ts
#!/usr/bin/env node
import { Command } from 'commander'
import chalk from 'chalk'
import figlet from 'figlet'
import { init } from './commands/init'
import { generate } from './commands/generate'
import { config } from './commands/config'
 
const program = new Command()
 
// 显示欢迎信息
console.log(
  chalk.cyan(
    figlet.textSync('Dev CLI', {
      font: 'Standard',
      horizontalLayout: 'default',
    })
  )
)
 
// 配置CLI
program
  .name('dev-cli')
  .description('一个强大的开发工具CLI')
  .version('1.0.0')
 
// init命令 - 初始化项目
program
  .command('init [project-name]')
  .description('初始化一个新项目')
  .option('-t, --template <template>', '使用的模板', 'default')
  .option('-f, --force', '强制覆盖已存在的目录')
  .action(init)
 
// generate命令 - 生成代码
program
  .command('generate <type>')
  .alias('g')
  .description('生成代码文件')
  .option('-n, --name <name>', '文件名称')
  .option('-p, --path <path>', '生成路径', '.')
  .action(generate)
 
// config命令 - 配置管理
program
  .command('config <action>')
  .description('管理CLI配置 (get, set, list)')
  .argument('<key>', '配置键')
  .argument('[value]', '配置值')
  .action(config)
 
// 错误处理
program.exitOverride((err) => {
  console.error(chalk.red('错误:'), err.message)
  process.exit(1)
})
 
program.parse(process.argv)
 
// 如果没有参数,显示帮助
if (!process.argv.slice(2).length) {
  program.outputHelp()
}

2. Init命令实现

// src/commands/init.ts
import inquirer from 'inquirer'
import chalk from 'chalk'
import ora from 'ora'
import fs from 'fs-extra'
import path from 'path'
import { execSync } from 'child_process'
 
interface InitOptions {
  template?: string
  force?: boolean
}
 
export async function init(projectName: string, options: InitOptions) {
  console.log(chalk.blue('\n🚀 开始初始化项目...\n'))
 
  // 如果没有提供项目名,询问用户
  if (!projectName) {
    const answers = await inquirer.prompt([
      {
        type: 'input',
        name: 'projectName',
        message: '请输入项目名称:',
        default: 'my-project',
        validate: (input) => {
          if (/^[a-z0-9-_]+$/.test(input)) {
            return true
          }
          return '项目名只能包含小写字母、数字、连字符和下划线'
        },
      },
    ])
    projectName = answers.projectName
  }
 
  const projectPath = path.join(process.cwd(), projectName)
 
  // 检查目录是否存在
  if (fs.existsSync(projectPath)) {
    if (!options.force) {
      const { overwrite } = await inquirer.prompt([
        {
          type: 'confirm',
          name: 'overwrite',
          message: `目录 ${projectName} 已存在,是否覆盖?`,
          default: false,
        },
      ])
 
      if (!overwrite) {
        console.log(chalk.yellow('已取消'))
        return
      }
    }
 
    fs.removeSync(projectPath)
  }
 
  // 询问项目配置
  const answers = await inquirer.prompt([
    {
      type: 'list',
      name: 'template',
      message: '选择项目模板:',
      choices: [
        { name: 'React + TypeScript', value: 'react-ts' },
        { name: 'Next.js', value: 'nextjs' },
        { name: 'Node.js + Express', value: 'node-express' },
        { name: 'Vue 3 + TypeScript', value: 'vue-ts' },
      ],
      default: options.template,
    },
    {
      type: 'input',
      name: 'description',
      message: '项目描述:',
    },
    {
      type: 'input',
      name: 'author',
      message: '作者:',
    },
    {
      type: 'checkbox',
      name: 'features',
      message: '选择需要的功能:',
      choices: [
        { name: 'ESLint', value: 'eslint', checked: true },
        { name: 'Prettier', value: 'prettier', checked: true },
        { name: 'Husky (Git hooks)', value: 'husky', checked: true },
        { name: 'Jest (单元测试)', value: 'jest' },
        { name: 'GitHub Actions', value: 'github-actions' },
      ],
    },
  ])
 
  const spinner = ora('正在创建项目...').start()
 
  try {
    // 创建项目目录
    fs.ensureDirSync(projectPath)
 
    // 复制模板文件
    const templatePath = path.join(__dirname, '../../templates', answers.template)
    fs.copySync(templatePath, projectPath)
 
    // 生成package.json
    const packageJson = {
      name: projectName,
      version: '1.0.0',
      description: answers.description,
      author: answers.author,
      scripts: {
        dev: getDevScript(answers.template),
        build: getBuildScript(answers.template),
        test: answers.features.includes('jest') ? 'jest' : 'echo "No test"',
      },
      dependencies: getDepenencies(answers.template),
      devDependencies: getDevDependencies(answers.features),
    }
 
    fs.writeJsonSync(path.join(projectPath, 'package.json'), packageJson, {
      spaces: 2,
    })
 
    // 生成配置文件
    if (answers.features.includes('eslint')) {
      generateESLintConfig(projectPath, answers.template)
    }
 
    if (answers.features.includes('prettier')) {
      generatePrettierConfig(projectPath)
    }
 
    if (answers.features.includes('husky')) {
      generateHuskyConfig(projectPath)
    }
 
    spinner.succeed(chalk.green('项目创建成功!'))
 
    // 询问是否安装依赖
    const { install } = await inquirer.prompt([
      {
        type: 'confirm',
        name: 'install',
        message: '是否立即安装依赖?',
        default: true,
      },
    ])
 
    if (install) {
      const installSpinner = ora('正在安装依赖...').start()
 
      try {
        process.chdir(projectPath)
        execSync('npm install', { stdio: 'inherit' })
        installSpinner.succeed(chalk.green('依赖安装完成!'))
      } catch (error) {
        installSpinner.fail(chalk.red('依赖安装失败'))
        throw error
      }
    }
 
    // 显示下一步操作
    console.log(chalk.green('\n✨ 项目初始化完成!\n'))
    console.log(chalk.cyan('下一步操作:'))
    console.log(chalk.gray(`  cd ${projectName}`))
    if (!install) {
      console.log(chalk.gray('  npm install'))
    }
    console.log(chalk.gray('  npm run dev\n'))
  } catch (error) {
    spinner.fail(chalk.red('项目创建失败'))
    console.error(error)
    process.exit(1)
  }
}
 
// 辅助函数
function getDevScript(template: string): string {
  const scripts: Record<string, string> = {
    'react-ts': 'vite',
    'nextjs': 'next dev',
    'node-express': 'ts-node-dev src/index.ts',
    'vue-ts': 'vite',
  }
  return scripts[template] || 'echo "No dev script"'
}
 
function getBuildScript(template: string): string {
  const scripts: Record<string, string> = {
    'react-ts': 'vite build',
    'nextjs': 'next build',
    'node-express': 'tsc',
    'vue-ts': 'vite build',
  }
  return scripts[template] || 'echo "No build script"'
}

3. Generate命令实现

// src/commands/generate.ts
import inquirer from 'inquirer'
import chalk from 'chalk'
import fs from 'fs-extra'
import path from 'path'
import Handlebars from 'handlebars'
 
export async function generate(type: string, options: any) {
  console.log(chalk.blue(`\n⚡ 生成${type}...\n`))
 
  // 如果没有提供名称,询问用户
  if (!options.name) {
    const answers = await inquirer.prompt([
      {
        type: 'input',
        name: 'name',
        message: `请输入${type}名称:`,
        validate: (input) => {
          if (input.trim()) return true
          return '名称不能为空'
        },
      },
    ])
    options.name = answers.name
  }
 
  const generators: Record<string, Function> = {
    component: generateComponent,
    page: generatePage,
    api: generateAPI,
    model: generateModel,
  }
 
  const generatorFn = generators[type]
  if (!generatorFn) {
    console.error(chalk.red(`不支持的类型: ${type}`))
    console.log(chalk.gray('支持的类型:', Object.keys(generators).join(', ')))
    return
  }
 
  await generatorFn(options.name, options.path)
 
  console.log(chalk.green(`\n✨ ${type} 生成成功!`))
}
 
async function generateComponent(name: string, outputPath: string) {
  const componentName = toPascalCase(name)
 
  const template = `
import React from 'react'
import styles from './{{componentName}}.module.css'
 
interface {{componentName}}Props {
  // 定义props类型
}
 
export function {{componentName}}({}: {{componentName}}Props) {
  return (
    <div className={styles.container}>
      <h1>{{componentName}}</h1>
    </div>
  )
}
`
 
  const cssTemplate = `
.container {
  padding: 20px;
}
`
 
  const compiled = Handlebars.compile(template)
  const content = compiled({ componentName })
 
  const componentDir = path.join(outputPath, componentName)
  fs.ensureDirSync(componentDir)
 
  fs.writeFileSync(path.join(componentDir, `${componentName}.tsx`), content)
  fs.writeFileSync(
    path.join(componentDir, `${componentName}.module.css`),
    cssTemplate
  )
  fs.writeFileSync(
    path.join(componentDir, 'index.ts'),
    `export { ${componentName} } from './${componentName}'`
  )
 
  console.log(chalk.green(`  创建 ${componentDir}/${componentName}.tsx`))
  console.log(chalk.green(`  创建 ${componentDir}/${componentName}.module.css`))
  console.log(chalk.green(`  创建 ${componentDir}/index.ts`))
}
 
function toPascalCase(str: string): string {
  return str
    .split(/[-_]/)
    .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
    .join('')
}

4. 配置管理

// src/config/config-manager.ts
import { cosmiconfig } from 'cosmiconfig'
import fs from 'fs-extra'
import path from 'path'
import os from 'os'
 
const CONFIG_DIR = path.join(os.homedir(), '.dev-cli')
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json')
 
export class ConfigManager {
  private config: Record<string, any> = {}
 
  constructor() {
    this.load()
  }
 
  // 加载配置
  load() {
    if (fs.existsSync(CONFIG_FILE)) {
      this.config = fs.readJsonSync(CONFIG_FILE)
    } else {
      this.config = this.getDefaultConfig()
      this.save()
    }
  }
 
  // 保存配置
  save() {
    fs.ensureDirSync(CONFIG_DIR)
    fs.writeJsonSync(CONFIG_FILE, this.config, { spaces: 2 })
  }
 
  // 获取配置
  get(key: string): any {
    return key.split('.').reduce((obj, k) => obj?.[k], this.config)
  }
 
  // 设置配置
  set(key: string, value: any) {
    const keys = key.split('.')
    const lastKey = keys.pop()!
    const target = keys.reduce((obj, k) => {
      if (!obj[k]) obj[k] = {}
      return obj[k]
    }, this.config)
 
    target[lastKey] = value
    this.save()
  }
 
  // 列出所有配置
  list(): Record<string, any> {
    return this.config
  }
 
  // 删除配置
  delete(key: string) {
    const keys = key.split('.')
    const lastKey = keys.pop()!
    const target = keys.reduce((obj, k) => obj?.[k], this.config)
 
    if (target) {
      delete target[lastKey]
      this.save()
    }
  }
 
  // 默认配置
  private getDefaultConfig() {
    return {
      template: {
        default: 'react-ts',
      },
      author: {
        name: '',
        email: '',
      },
      plugins: {
        enabled: [],
      },
    }
  }
}

5. 插件系统

// src/plugins/plugin-manager.ts
import fs from 'fs-extra'
import path from 'path'
 
export interface Plugin {
  name: string
  version: string
  init: (context: PluginContext) => void | Promise<void>
  commands?: Record<string, Function>
  hooks?: PluginHooks
}
 
export interface PluginContext {
  config: any
  logger: Logger
  utils: any
}
 
export interface PluginHooks {
  beforeInit?: () => void | Promise<void>
  afterInit?: () => void | Promise<void>
  beforeBuild?: () => void | Promise<void>
  afterBuild?: () => void | Promise<void>
}
 
export class PluginManager {
  private plugins: Map<string, Plugin> = new Map()
  private context: PluginContext
 
  constructor(context: PluginContext) {
    this.context = context
  }
 
  // 加载插件
  async loadPlugin(pluginPath: string) {
    try {
      const plugin: Plugin = require(pluginPath)
 
      if (!plugin.name || !plugin.init) {
        throw new Error('插件必须包含name和init')
      }
 
      await plugin.init(this.context)
      this.plugins.set(plugin.name, plugin)
 
      console.log(`✓ 插件 ${plugin.name} 加载成功`)
    } catch (error) {
      console.error(`✗ 插件 ${pluginPath} 加载失败:`, error)
    }
  }
 
  // 执行钩子
  async runHook(hookName: keyof PluginHooks, ...args: any[]) {
    for (const [name, plugin] of this.plugins) {
      const hook = plugin.hooks?.[hookName]
      if (hook) {
        try {
          await hook(...args)
        } catch (error) {
          console.error(`插件 ${name} 的 ${hookName} 钩子执行失败:`, error)
        }
      }
    }
  }
 
  // 获取插件
  getPlugin(name: string): Plugin | undefined {
    return this.plugins.get(name)
  }
 
  // 列出所有插件
  listPlugins(): Plugin[] {
    return Array.from(this.plugins.values())
  }
}

NPM发布

// package.json
{
  "name": "@yourorg/cli-tool",
  "version": "1.0.0",
  "bin": {
    "dev-cli": "./dist/index.js"
  },
  "files": [
    "dist",
    "templates"
  ],
  "scripts": {
    "build": "tsc",
    "prepublishOnly": "npm run build",
    "release": "np"
  },
  "keywords": [
    "cli",
    "tool",
    "generator"
  ]
}
# 发布到NPM
npm login
npm publish --access public
 
# 使用np自动化发布
npm install -g np
np

测试

// tests/commands/init.test.ts
import { init } from '../../src/commands/init'
import fs from 'fs-extra'
import path from 'path'
 
describe('init command', () => {
  const testDir = path.join(__dirname, '../temp')
 
  beforeEach(() => {
    fs.ensureDirSync(testDir)
  })
 
  afterEach(() => {
    fs.removeSync(testDir)
  })
 
  it('should create project directory', async () => {
    const projectName = 'test-project'
    await init(projectName, { template: 'react-ts' })
 
    const projectPath = path.join(testDir, projectName)
    expect(fs.existsSync(projectPath)).toBe(true)
  })
})

总结

开发CLI工具需要注重用户体验,提供清晰的提示和美观的输出。通过Commander.js和Inquirer.js等库,可以快速构建功能强大的命令行工具。插件系统和配置管理使CLI更加灵活和可扩展。