在当今的开发世界中,命令行界面(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, {