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更加灵活和可扩展。