在当今的开发者工具生态中,命令行界面(CLI)工具仍然扮演着至关重要的角色。无论是前端构建工具(如 Webpack、Vite)、包管理器(如 npm、yarn),还是基础设施工具(如 Docker、Kubernetes),CLI 都是开发者与复杂系统交互的主要方式。本文将带你从零开始,构建一个现代化、用户友好的 CLI 工具,涵盖架构设计、开发实践和发布部署的全过程。
为什么需要现代化的 CLI 工具?
传统的 CLI 工具往往存在以下问题:
- 缺乏直观的帮助系统
- 错误信息不友好
- 缺少自动补全功能
- 配置方式复杂
- 跨平台兼容性差
现代化的 CLI 工具应该具备:
- 优雅的命令行解析
- 智能的自动补全
- 丰富的交互式提示
- 清晰的文档和帮助
- 良好的错误处理
- 美观的输出格式
技术栈选择
我们将使用以下技术栈构建我们的 CLI 工具:
- Node.js - 运行时环境
- Commander.js - 命令行参数解析
- Inquirer.js - 交互式命令行界面
- Chalk - 终端字符串样式
- Ora - 优雅的加载动画
- Boxen - 创建终端框
- Figlet - ASCII 艺术字
- 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 });
}
}