2026 年,每个开发者的工具箱里都少不了一两个自己写的 CLI 工具。无论是自动化部署脚本、项目脚手架生成器,还是数据处理管道,Node.js CLI 工具开发已经成为后端和全栈工程师的必备技能。根据 npm 2025 年度报告,npm registry 上标记为 cli 的包数量已突破 12 万个,年增长率达 35%。但真正高质量、生产可用的 CLI 工具凤毛麟角——大多数工具要么缺少帮助文档,要么错误处理粗糙,要么跨平台兼容一塌糊涂。
💡 提示: 本文所有代码示例均基于 Node.js 22+ 和 TypeScript 5.5+,完整项目可在文章末尾找到 GitHub 模板链接。
🔧 一、CLI 工具的核心架构
一个生产级 CLI 工具不是简单的 process.argv 解析。它需要一套完整的架构:参数解析、交互提示、输出美化、错误处理、配置管理。我们先看整体架构,再逐个击破。
1.1 项目初始化与依赖选型
先搭好项目骨架。以下是核心依赖和它们的职责:
| 库 | 职责 | 周下载量 | 推荐 |
|---|---|---|---|
commander |
参数解析、子命令、帮助文档 | 3200 万 | ✅ 成熟稳定 |
yargs |
参数解析(另一种风格) | 6800 万 | ✅ 功能丰富但较重 |
inquirer |
交互式提示(选择、输入、确认) | 1500 万 | ✅ 交互首选 |
@inquirer/prompts |
Inquirer 的轻量替代 | 200 万 | ✅ 更现代 |
ora |
终端进度指示器(spinner) | 1000 万 | ✅ 轻量好用 |
chalk |
终端文字着色 | 7000 万 | ✅ 行业标准 |
cli-table3 |
终端表格渲染 | 50 万 | ✅ 按需使用 |
conf |
配置持久化存储 | 300 万 | ✅ 简单可靠 |
⚠️ 警告: 不要在 CLI 工具里使用
colors包。2022 年的供应链投毒事件(作者在自己的包里注入了无限循环恶意代码)让这个包永远失去了社区信任。用chalk或picocolors(更轻量)替代。
初始化项目:
# 初始化 TypeScript 项目
mkdir my-cli && cd my-cli
npm init -y
npm install typescript @types/node tsx --save-dev
npm install commander ora chalk conf inquirer
# 配置 tsconfig.json
npx tsc --init --target ES2022 --module NodeNext --moduleResolution NodeNext --outDir dist --rootDir src --strict true
1.2 Commander.js 参数解析实战
Commander.js 是 Node.js CLI 事实标准的参数解析库。它支持选项(options)、参数(arguments)、子命令(commands)、自动帮助文档生成。
// src/index.ts — CLI 入口文件
import { Command } from 'commander'
import chalk from 'chalk'
const program = new Command()
program
.name('mytool')
.description('一个示例 CLI 工具')
.version('1.0.0')
// 定义一个主命令
program
.command('format')
.description('格式化 JSON 文件')
.argument('<input>', '输入文件路径')
.option('-o, --output <path>', '输出文件路径(默认覆盖原文件)')
.option('-i, --indent <size>', '缩进空格数', '2')
.option('--sort-keys', '按字母排序键名', false)
.option('--no-pretty', '压缩输出(不美化)')
.action(async (input: string, options: FormatOptions) => {
console.log(chalk.blue(`正在格式化 ${input}...`))
// 格式化逻辑在这里实现
})
// 定义子命令
program
.command('validate')
.description('验证 JSON 文件格式')
.argument('<files...>', '一个或多个 JSON 文件路径')
.option('-s, --strict', '严格模式(检查尾逗号、注释等)', false)
.action(async (files: string[], options: ValidateOptions) => {
for (const file of files) {
console.log(chalk.cyan(`验证: ${file}`))
// 验证逻辑
}
})
// 全局错误处理
program.exitOverride()
try {
await program.parseAsync()
} catch (err: any) {
if (err.code === 'commander.helpDisplayed') {
process.exit(0)
}
console.error(chalk.red(`错误: ${err.message}`))
process.exit(1)
}
⚡ 关键结论: Commander.js 的 argument() 和 option() 有本质区别。argument 是位置参数(必填),option 是带 -- 前缀的可选参数。新手最常犯的错是把必填参数放在 option 里,导致用户不知道要传什么。
1.3 交互式提示
很多时候,用户需要被引导填写参数。Inquirer 提供了丰富的交互模式:输入框、选择列表、确认框、多选、密码输入等。
// src/prompts.ts — 交互式提示封装
import inquirer from 'inquirer'
import chalk from 'chalk'
interface ProjectAnswers {
projectName: string
template: 'react' | 'vue' | 'node'
typescript: boolean
packageManager: 'npm' | 'pnpm' | 'bun'
features: string[]
}
async function promptProjectConfig(): Promise<ProjectAnswers> {
const answers = await inquirer.prompt<ProjectAnswers>([
{
type: 'input',
name: 'projectName',
message: '项目名称:',
validate: (input: string) => {
if (/^[a-z][a-z0-9-]*$/.test(input)) return true
return '项目名只能包含小写字母、数字和连字符,且以字母开头'
},
},
{
type: 'list',
name: 'template',
message: '选择项目模板:',
choices: [
{ name: '⚛️ React + Vite', value: 'react' },
{ name: '💚 Vue 3 + Vite', value: 'vue' },
{ name: '🟢 Node.js API', value: 'node' },
],
},
{
type: 'confirm',
name: 'typescript',
message: '使用 TypeScript?',
default: true,
},
{
type: 'list',
name: 'packageManager',
message: '包管理器:',
choices: ['npm', 'pnpm', 'bun'],
default: 'pnpm',
},
{
type: 'checkbox',
name: 'features',
message: '选择附加功能:',
choices: [
{ name: 'ESLint + Prettier', value: 'lint', checked: true },
{ name: 'Docker 配置', value: 'docker' },
{ name: 'GitHub Actions CI', value: 'ci' },
{ name: '单元测试 (Vitest)', value: 'test' },
],
},
])
return answers
}
💡 提示:
@inquirer/prompts是 Inquirer 的新模块化版本,每个提示类型独立安装,打包体积更小(从 150KB 降到按需的 10-20KB)。如果你的 CLI 对启动速度敏感(比如用户会频繁调用),优先选择@inquirer/prompts。
🚀 二、生产级特性实现
基础功能搭好后,一个 CLI 工具要达到生产级,还需要:进度指示、优雅的错误处理、配置持久化、跨平台兼容。
2.1 进度指示与输出美化
用户在等待操作时,如果终端一片空白,会以为程序卡死了。Ora 提供了优雅的 spinner 动画,Listr2 可以展示多步骤任务进度。
// src/progress.ts — 进度指示封装
import ora, { type Ora } from 'ora'
import chalk from 'chalk'
// 单任务 spinner
async function withSpinner<T>(
text: string,
fn: () => Promise<T>
): Promise<T> {
const spinner = ora({
text,
color: 'cyan',
spinner: 'dots12', // 可选: dots, line, arc, star 等
}).start()
try {
const result = await fn()
spinner.succeed(chalk.green(`${text} — 完成`))
return result
} catch (err: any) {
spinner.fail(chalk.red(`${text} — 失败: ${err.message}`))
throw err
}
}
// 使用示例
async function processFiles(files: string[]) {
// 依次处理多个文件,每个都有独立的 spinner
const results = []
for (const file of files) {
const result = await withSpinner(
`处理 ${chalk.cyan(file)}`,
async () => {
// 模拟文件处理
await new Promise(resolve => setTimeout(resolve, 1000))
return { file, lines: 42 }
}
)
results.push(result)
}
// 输出汇总表格
console.log('\n' + chalk.bold('处理结果:'))
console.log(chalk.gray('─'.repeat(40)))
for (const r of results) {
console.log(` ${chalk.green('✓')} ${r.file} — ${r.lines} 行`)
}
}
❌ 错误写法: 用 console.log('正在处理...') 加 console.log('完成!') — 没有动画,用户体验差。
✅ 正确写法: 用 Ora 提供实时 spinner 反馈,成功/失败状态一目了然。
2.2 配置持久化
用户不想每次都输入相同的参数。用 conf 库可以把配置保存到 ~/.config/ 目录下。
// src/config.ts — 配置管理
import Conf from 'conf'
interface AppConfig {
defaultIndent: number
defaultTemplate: string
recentFiles: string[]
theme: 'light' | 'dark'
verbose: boolean
}
const config = new Conf<AppConfig>({
projectName: 'mytool',
defaults: {
defaultIndent: 2,
defaultTemplate: 'node',
recentFiles: [],
theme: 'dark',
verbose: false,
},
// 配置文件路径: ~/.config/mytool/config.json (Linux/Mac)
// 或 %APPDATA%/mytool/config.json (Windows)
})
// 读取配置
function getConfig(): AppConfig {
return {
defaultIndent: config.get('defaultIndent'),
defaultTemplate: config.get('defaultTemplate'),
recentFiles: config.get('recentFiles'),
theme: config.get('theme'),
verbose: config.get('verbose'),
}
}
// 更新配置(CLI 的 config 子命令)
function updateConfig(key: keyof AppConfig, value: unknown): void {
config.set(key, value)
console.log(chalk.green(`✓ 已更新 ${key} = ${value}`))
}
// 添加到最近文件列表(最多保留 10 个)
function addRecentFile(filePath: string): void {
const recent = config.get('recentFiles')
const updated = [filePath, ...recent.filter(f => f !== filePath)].slice(0, 10)
config.set('recentFiles', updated)
}
在 Commander.js 中注册配置子命令:
// 注册 config 子命令
program
.command('config')
.description('查看或修改配置')
.option('-k, --key <key>', '配置键名')
.option('-v, --value <value>', '配置值')
.option('-l, --list', '列出所有配置')
.action((options) => {
if (options.list) {
const cfg = getConfig()
console.log(chalk.bold('\n当前配置:'))
for (const [key, value] of Object.entries(cfg)) {
console.log(` ${chalk.cyan(key)}: ${JSON.stringify(value)}`)
}
return
}
if (options.key && options.value !== undefined) {
updateConfig(options.key, options.value)
} else if (options.key) {
console.log(config.get(options.key))
}
})
2.3 跨平台兼容的坑点
CLI 工具最头疼的就是跨平台。以下是几个高频踩坑点:
坑点 1:路径分隔符
// ❌ 错误 — 硬编码 Unix 路径
const configPath = '/home/user/.config/mytool'
// ✅ 正确 — 使用 Node.js API
import path from 'node:path'
import os from 'node:os'
function getConfigDir(): string {
// 跨平台获取配置目录
const home = os.homedir()
switch (process.platform) {
case 'darwin':
return path.join(home, 'Library', 'Application Support', 'mytool')
case 'win32':
return path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'mytool')
default:
return path.join(home, '.config', 'mytool')
}
}
坑点 2:进程信号处理
// ❌ 错误 — 忽略 SIGINT
process.on('SIGINT', () => {})
// ✅ 正确 — 优雅退出
process.on('SIGINT', () => {
console.log(chalk.yellow('\n⚠️ 收到中断信号,正在清理...'))
// 清理临时文件、关闭数据库连接等
cleanup()
process.exit(130) // 130 = 128 + SIGINT(2)
})
process.on('SIGTERM', () => {
cleanup()
process.exit(143) // 143 = 128 + SIGTERM(15)
})
坑点 3:Windows 下的 shebang
⚠️ 警告: Windows 不识别 Unix 的 shebang(
#!/usr/bin/env node)。如果你用npm link或全局安装,npm 会自动处理这个问题。但如果你直接用node dist/index.js执行,Windows 用户需要手动指定解释器。解决方案是在package.json里正确配置bin字段(见下文发布章节)。
📦 三、构建、测试与发布
3.1 构建配置
推荐用 tsup(基于 esbuild)来构建 CLI 工具,速度快且配置简单:
{
"name": "mytool",
"version": "1.0.0",
"type": "module",
"bin": {
"mytool": "./dist/index.js"
},
"files": ["dist"],
"scripts": {
"dev": "tsx src/index.ts",
"build": "tsup src/index.ts --format esm --dts --clean",
"test": "vitest run",
"lint": "biome check src/"
}
}
// tsup.config.ts
import { defineConfig } from 'tsup'
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
target: 'node22',
clean: true,
dts: true,
// 外部化大型依赖,减小打包体积
external: ['commander', 'inquirer', 'ora', 'chalk'],
banner: {
// shebang 必须在文件首行
js: '#!/usr/bin/env node\n',
},
})
📌 记住:
banner.js里的 shebang 不能省略。没有 shebang,用户全局安装后执行mytool会报command not found(Linux/Mac)或弹出「选择打开方式」对话框(Windows)。
3.2 测试策略
CLI 工具的测试分三层:单元测试(核心逻辑)、集成测试(命令执行)、端到端测试(模拟用户操作)。
// __tests__/format.test.ts — 集成测试
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import { writeFile, readFile, unlink } from 'node:fs/promises'
import path from 'node:path'
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
const execFileAsync = promisify(execFile)
const CLI_PATH = path.resolve(__dirname, '../dist/index.js')
describe('mytool format', () => {
const testFile = path.join(__dirname, 'fixtures', 'test-input.json')
const outputFile = path.join(__dirname, 'fixtures', 'test-output.json')
afterEach(async () => {
try { await unlink(outputFile) } catch {}
})
it('应该格式化 JSON 并写入输出文件', async () => {
const input = '{"name":"test","items":[1,2,3]}'
await writeFile(testFile, input)
const { stdout } = await execFileAsync('node', [
CLI_PATH, 'format', testFile,
'-o', outputFile,
'-i', '4',
])
expect(stdout).toContain('完成')
const result = await readFile(outputFile, 'utf-8')
const parsed = JSON.parse(result)
expect(parsed.name).toBe('test')
expect(parsed.items).toEqual([1, 2, 3])
})
it('应该在文件不存在时报错', async () => {
await expect(
execFileAsync('node', [CLI_PATH, 'format', 'nonexistent.json'])
).rejects.toThrow()
})
it('--sort-keys 应该按键名排序', async () => {
const input = '{"zebra":"z","apple":"a","mango":"m"}'
await writeFile(testFile, input)
await execFileAsync('node', [
CLI_PATH, 'format', testFile,
'-o', outputFile,
'--sort-keys',
])
const result = await readFile(outputFile, 'utf-8')
const keys = Object.keys(JSON.parse(result))
expect(keys).toEqual(['apple', 'mango', 'zebra'])
})
})
3.3 npm 发布完整流程
发布一个 CLI 工具到 npm 需要几个关键步骤:
# 1. 确保构建通过
npm run build
# 2. 测试本地执行
node dist/index.js --help
# 3. 本地链接测试(模拟全局安装)
npm link
mytool --help # 应该能正常执行
# 4. 检查包内容(确保不会发布多余文件)
npm pack --dry-run
# 5. 登录 npm(如果没有账号先 npm adduser)
npm login
# 6. 发布
npm publish
# 如果包名带 scope: npm publish --access public
package.json 中有几个字段对 CLI 工具至关重要:
{
"name": "mytool",
"version": "1.0.0",
"description": "一个示例 CLI 工具",
"type": "module",
"bin": {
"mytool": "./dist/index.js"
},
"files": ["dist", "README.md", "LICENSE"],
"engines": {
"node": ">=18.0.0"
},
"keywords": ["cli", "json", "format", "developer-tool"],
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/yourname/mytool"
}
}
⚠️ 警告:
files字段是白名单机制,只列出需要发布的文件。不要省略这个字段——否则npm publish会把node_modules、.git、dist全部打包上传,轻则包体积巨大,重则泄露敏感信息。
💡 四、最佳实践与避坑指南
4.1 启动速度优化
CLI 工具的启动速度直接影响用户体验。用户执行一个命令如果等 2 秒才出结果,会觉得这个工具很「重」。
| 优化手段 | 效果 | 难度 |
|---|---|---|
使用 tsx 代替 ts-node |
启动快 3-5 倍 | ⭐ 简单 |
| 懒加载依赖(动态 import) | 减少初始加载 50%+ | ⭐⭐ 中等 |
用 picocolors 代替 chalk |
减少 50KB 打包体积 | ⭐ 简单 |
用 citty 代替 commander |
减少 100KB 打包体积 | ⭐⭐ 中等 |
用 tsup 打包成单文件 |
减少文件 I/O | ⭐ 简单 |
懒加载示例:
// ❌ 同步加载所有依赖 — 启动慢
import inquirer from 'inquirer'
import ora from 'ora'
import cliTable from 'cli-table3'
// ✅ 按需懒加载 — 只在用到时才加载
program
.command('init')
.action(async () => {
const { default: inquirer } = await import('inquirer')
const answers = await inquirer.prompt([...])
})
program
.command('process')
.action(async () => {
const { default: ora } = await import('ora')
const spinner = ora('处理中...').start()
})
4.2 错误处理的正确姿势
// src/errors.ts — 自定义错误类
import chalk from 'chalk'
class CLIError extends Error {
constructor(
message: string,
public code: string,
public exitCode: number = 1,
) {
super(message)
this.name = 'CLIError'
}
}
class FileNotFoundError extends CLIError {
constructor(filePath: string) {
super(`文件不存在: ${filePath}`, 'FILE_NOT_FOUND', 1)
}
}
class InvalidJSONError extends CLIError {
constructor(filePath: string, detail: string) {
super(`JSON 格式错误: ${filePath}\n ${detail}`, 'INVALID_JSON', 1)
}
}
// 全局错误处理
function handleCLIError(err: unknown): never {
if (err instanceof CLIError) {
console.error(chalk.red(`✗ ${err.message}`))
process.exit(err.exitCode)
}
// 未知错误 — 打印完整堆栈
console.error(chalk.red('✗ 未知错误:'))
console.error(err)
process.exit(2)
}
4.3 可选:用 Ink 构建 React 风格的终端 UI
对于复杂的 CLI 工具(比如交互式安装向导、仪表盘),Ink 让你用 React 组件的方式构建终端 UI:
// src/tui/App.tsx — Ink React 终端 UI(可选进阶)
import React, { useState, useEffect } from 'react'
import { render, Box, Text, useInput, useApp } from 'ink'
function App() {
const [step, setStep] = useState(0)
const { exit } = useApp()
useInput((input, key) => {
if (key.return) {
if (step < 2) setStep(step + 1)
else exit()
}
})
const steps = ['📋 项目信息', '⚙️ 配置选项', '✅ 完成']
return (
<Box flexDirection="column" padding={1}>
<Text bold color="cyan">
{steps[step]}
</Text>
<Box marginTop={1}>
<Text dimColor>按 Enter 继续...</Text>
</Box>
</Box>
)
}
render(<App />)
💡 提示: Ink 适合构建复杂的交互式 CLI 向导,但它的依赖体积较大(~500KB)。如果你的工具只是简单的命令-参数模式,Commander.js + Inquirer 就够了,没必要引入 Ink。
🎯 总结
开发一个生产级 Node.js CLI 工具,核心要点如下:
- ✅ 参数解析用 Commander.js — 成熟稳定,文档完善,社区首选
- ✅ 交互提示用 Inquirer 或 @inquirer/prompts — 复杂交互用前者,轻量场景用后者
- ✅ 进度反馈用 Ora — 让用户知道程序在正常运行
- ✅ 配置持久化用 Conf — 避免用户重复输入参数
- ✅ 构建用 tsup — 速度快,配置简单,自动生成 shebang
- ✅ 跨平台测试覆盖三大系统 — Linux、macOS、Windows 各有各的坑
- ❌ 不要硬编码路径 — 用
path.join和os.homedir() - ❌ 不要忽略错误处理 — 用自定义错误类,给用户清晰的反馈
- ❌ 不要省略
files字段 — 否则可能泄露敏感文件
| 方案 | 适用场景 | 学习成本 | 推荐 |
|---|---|---|---|
| Commander.js + Inquirer | 中等复杂度 CLI | ⭐⭐ 低 | ✅ 推荐 |
| Yargs | 需要自动补全的复杂 CLI | ⭐⭐⭐ 中 | ✅ 可选 |
| Ink (React 终端 UI) | 交互式向导/仪表盘 | ⭐⭐⭐⭐ 高 | ⚠️ 按需 |
| Citty | 极简轻量 CLI | ⭐ 低 | ✅ 小工具 |
相关工具推荐:
- jsjson.com JSON 格式化工具 — 开发 CLI 工具时格式化输出的 JSON
- jsjson.com Base64 编解码 — 处理 CLI 工具中的编码场景
- Commander.js 官方文档
- Ink — React 终端 UI
- tsup 构建工具