面向 Java 工程师的 Node.js CLI 工具开发指南
Node.js 非常适合构建命令行工具(CLI)。相比 Java 的 CLI 开发(通常需要 Maven 插件或 GraalVM native-image),Node.js 的 CLI 开发更加轻量、分发更简便。
Java CLI 开发 vs Node.js CLI 开发:
| 方面 | Java | Node.js |
|---|---|---|
| 参数解析 | commons-cli / picocli | commander / yargs |
| 交互输入 | Scanner | @inquirer/prompts |
| 彩色输出 | 手动 ANSI 码 / jansi | chalk |
| 打包分发 | jar + JVM 或 GraalVM native | npm publish 或 esbuild 单文件 |
| 安装方式 | 下载 jar / brew | npm install -g |
| 启动速度 | 慢(JVM 启动) | 快(V8 启动快) |
1. 初始化项目 (npm init)
2. 编写命令逻辑
3. 配置 package.json 的 bin 字段
4. 本地测试 (npm link)
5. 打包 (esbuild)
6. 发布 (npm publish)
commander 是 Node.js 最流行的命令行框架,类似 Java 的 picocli,提供命令定义、选项解析、帮助信息生成等功能。
pnpm add commander
pnpm add -D @types/node// src/cli.ts
import { Command } from 'commander';
// 创建程序实例
const program = new Command();
program
.name('mytool') // 命令名称
.description('一个示例 CLI 工具') // 描述
.version('1.0.0'); // 版本号(自动支持 -V, --version)
// 定义选项
program
.option('-d, --debug', '开启调试模式') // 布尔选项
.option('-p, --port <number>', '指定端口', '3000') // 带值选项,有默认值
.option('-o, --output <path>', '输出目录') // 带值选项,无默认值
.option('-e, --extensions <items...>', '文件扩展名列表'); // 多值选项
// 定义动作
program.action((options) => {
console.log('选项:', options);
// 运行 mytool --debug -p 8080 -e ts js
// 输出: { debug: true, port: '8080', extensions: ['ts', 'js'] }
if (options.debug) {
console.log('调试模式已开启');
}
const port = parseInt(options.port, 10);
console.log(`使用端口: ${port}`);
});
// 解析命令行参数
program.parse();Java picocli 对比:
// Java picocli
@Command(name = "mytool", description = "一个示例 CLI 工具", version = "1.0.0")
public class MyTool implements Runnable {
@Option(names = {"-d", "--debug"}, description = "开启调试模式")
private boolean debug;
@Option(names = {"-p", "--port"}, description = "指定端口", defaultValue = "3000")
private int port;
@Override
public void run() {
System.out.println("调试: " + debug);
System.out.println("端口: " + port);
}
}import { Command } from 'commander';
const program = new Command();
program
.name('greet')
.description('问候工具');
// 必选参数 <name>,可选参数 [title]
program
.argument('<name>', '要问候的人名')
.argument('[title]', '称谓', '同学')
.option('-l, --lang <language>', '语言', 'zh')
.action((name: string, title: string, options: { lang: string }) => {
if (options.lang === 'zh') {
console.log(`你好,${title} ${name}!`);
} else {
console.log(`Hello, ${title} ${name}!`);
}
});
program.parse();
// 运行示例:
// greet 张三 → 你好,同学 张三!
// greet 张三 老师 → 你好,老师 张三!
// greet John Mr --lang en → Hello, Mr John!import { Command } from 'commander';
const program = new Command();
program
.name('config')
.description('配置管理工具');
// 子命令: config get <key>
program
.command('get')
.description('获取配置项')
.argument('<key>', '配置键名')
.action((key: string) => {
console.log(`获取配置: ${key}`);
// 实际实现:从文件读取配置
});
// 子命令: config set <key> <value>
program
.command('set')
.description('设置配置项')
.argument('<key>', '配置键名')
.argument('<value>', '配置值')
.option('-g, --global', '全局配置')
.action((key: string, value: string, options: { global?: boolean }) => {
const scope = options.global ? '全局' : '项目';
console.log(`设置${scope}配置: ${key} = ${value}`);
});
// 子命令: config list
program
.command('list')
.alias('ls') // 命令别名
.description('列出所有配置项')
.option('--json', '以 JSON 格式输出')
.action((options: { json?: boolean }) => {
const config = { name: 'my-app', port: 3000, debug: false };
if (options.json) {
console.log(JSON.stringify(config, null, 2));
} else {
for (const [key, value] of Object.entries(config)) {
console.log(`${key} = ${value}`);
}
}
});
// 子命令: config init
program
.command('init')
.description('初始化配置文件')
.option('-f, --force', '强制覆盖已有配置')
.action((options: { force?: boolean }) => {
console.log(`初始化配置${options.force ? '(覆盖已有)' : ''}...`);
});
program.parse();
// 运行示例:
// config get port
// config set port 8080 --global
// config list --json
// config ls
// config init --force@inquirer/prompts 提供丰富的交互式命令行提示,类似 Java 中用 Scanner 读取用户输入,但功能强大得多。
pnpm add @inquirer/promptsimport {
input,
select,
confirm,
checkbox,
password,
number,
} from '@inquirer/prompts';
// ========== 文本输入 ==========
const name = await input({
message: '请输入项目名称:',
default: 'my-project',
validate: (value) => {
if (!/^[a-z0-9-]+$/.test(value)) {
return '项目名只能包含小写字母、数字和连字符';
}
return true;
},
});
console.log(`项目名: ${name}`);
// ========== 数字输入 ==========
const port = await number({
message: '请输入端口号:',
default: 3000,
validate: (value) => {
if (value === undefined || value < 1024 || value > 65535) {
return '端口号必须在 1024-65535 之间';
}
return true;
},
});
console.log(`端口: ${port}`);
// ========== 单选 ==========
const template = await select({
message: '选择项目模板:',
choices: [
{ name: 'Node.js CLI 工具', value: 'node-cli' },
{ name: 'Express API 服务', value: 'express-api' },
{ name: 'React 前端应用', value: 'react-app' },
{ name: '空项目', value: 'empty', description: '仅包含 package.json' },
],
});
console.log(`模板: ${template}`);
// ========== 多选 ==========
const features = await checkbox({
message: '选择要启用的功能:',
choices: [
{ name: 'TypeScript', value: 'typescript', checked: true },
{ name: 'ESLint', value: 'eslint', checked: true },
{ name: 'Prettier', value: 'prettier' },
{ name: 'Jest 测试', value: 'jest' },
{ name: 'Docker 支持', value: 'docker' },
],
});
console.log(`功能: ${features.join(', ')}`);
// ========== 确认 ==========
const confirmed = await confirm({
message: '确认创建项目?',
default: true,
});
if (!confirmed) {
console.log('已取消');
process.exit(0);
}
// ========== 密码输入 ==========
const secret = await password({
message: '请输入 API 密钥:',
mask: '*',
});
console.log(`密钥长度: ${secret.length}`);import { input, select, checkbox, confirm } from '@inquirer/prompts';
// 项目配置接口
interface ProjectConfig {
name: string;
description: string;
template: string;
features: string[];
initGit: boolean;
installDeps: boolean;
}
// 交互式收集项目配置
async function collectProjectConfig(): Promise<ProjectConfig> {
const name = await input({
message: '项目名称:',
default: 'my-project',
validate: (v) => (v.trim() ? true : '项目名不能为空'),
});
const description = await input({
message: '项目描述:',
default: `A ${name} project`,
});
const template = await select({
message: '项目模板:',
choices: [
{ name: 'node-cli — Node.js CLI 工具', value: 'node-cli' },
{ name: 'express-api — Express REST API', value: 'express-api' },
{ name: 'react-app — React 前端应用', value: 'react-app' },
],
});
const features = await checkbox({
message: '附加功能:',
choices: [
{ name: 'TypeScript', value: 'typescript', checked: true },
{ name: 'ESLint + Prettier', value: 'linting' },
{ name: '单元测试 (Vitest)', value: 'testing' },
{ name: 'Docker', value: 'docker' },
],
});
const initGit = await confirm({
message: '初始化 Git 仓库?',
default: true,
});
const installDeps = await confirm({
message: '自动安装依赖?',
default: true,
});
return { name, description, template, features, initGit, installDeps };
}
// 使用
const config = await collectProjectConfig();
console.log('\n项目配置:');
console.log(JSON.stringify(config, null, 2));chalk 让终端输出变得丰富多彩。Java 中需要手动使用 ANSI 转义码,而 chalk 提供了优雅的 API。
pnpm add chalkimport chalk from 'chalk';
// 基本颜色
console.log(chalk.red('错误信息'));
console.log(chalk.green('成功信息'));
console.log(chalk.yellow('警告信息'));
console.log(chalk.blue('提示信息'));
console.log(chalk.gray('灰色文字'));
// 背景色
console.log(chalk.bgRed.white(' ERROR ') + ' 发生了错误');
console.log(chalk.bgGreen.black(' SUCCESS ') + ' 操作成功');
console.log(chalk.bgYellow.black(' WARN ') + ' 请注意');
// 样式
console.log(chalk.bold('粗体文字'));
console.log(chalk.italic('斜体文字'));
console.log(chalk.underline('下划线文字'));
console.log(chalk.strikethrough('删除线文字'));
// 组合样式
console.log(chalk.bold.red('粗体红色'));
console.log(chalk.bgBlue.white.bold(' INFO ') + ' 这是一条信息');
// 模板字面量
const name = 'TypeScript';
console.log(`正在编译 ${chalk.cyan(name)} 项目...`);
console.log(`发现 ${chalk.red.bold('3')} 个错误,${chalk.yellow('5')} 个警告`);import chalk from 'chalk';
// 日志级别工具
const log = {
info(message: string): void {
console.log(chalk.blue('ℹ') + ' ' + message);
},
success(message: string): void {
console.log(chalk.green('✔') + ' ' + message);
},
warn(message: string): void {
console.log(chalk.yellow('⚠') + ' ' + chalk.yellow(message));
},
error(message: string): void {
console.log(chalk.red('✖') + ' ' + chalk.red(message));
},
debug(message: string): void {
console.log(chalk.gray('●') + ' ' + chalk.gray(message));
},
// 带标签的日志
tag(tag: string, message: string, color: 'red' | 'green' | 'blue' | 'yellow' = 'blue'): void {
const colorFn = chalk[color];
console.log(colorFn(`[${tag}]`) + ' ' + message);
},
// 高亮关键信息
highlight(template: string, ...values: string[]): void {
let result = template;
for (const value of values) {
result = result.replace('{}', chalk.cyan.bold(value));
}
console.log(result);
},
};
// 使用示例
log.info('正在初始化项目...');
log.success('项目创建成功');
log.warn('未找到配置文件,使用默认配置');
log.error('编译失败: 语法错误');
log.debug('正在扫描目录: /src');
log.tag('GIT', '已初始化 Git 仓库', 'green');
log.tag('NPM', '正在安装依赖...', 'yellow');
log.highlight('找到 {} 个文件,共 {} 行代码', '42', '3,156');import chalk from 'chalk';
interface TableRow {
[key: string]: string | number;
}
// 简单表格输出
function printTable(headers: string[], rows: TableRow[]): void {
// 计算每列最大宽度
const widths = headers.map((h, i) => {
const key = headers[i];
const maxDataWidth = Math.max(...rows.map((r) => String(r[key]).length));
return Math.max(key.length, maxDataWidth) + 2;
});
// 表头
const headerLine = headers.map((h, i) => chalk.bold.cyan(h.padEnd(widths[i]))).join('');
console.log(headerLine);
console.log(chalk.gray('-'.repeat(widths.reduce((a, b) => a + b, 0))));
// 数据行
for (const row of rows) {
const line = headers
.map((h, i) => {
const value = String(row[h]);
return value.padEnd(widths[i]);
})
.join('');
console.log(line);
}
}
// 使用
printTable(
['文件类型', '文件数', '代码行数', '大小'],
[
{ '文件类型': '.ts', '文件数': 24, '代码行数': 1856, '大小': '45.2 KB' },
{ '文件类型': '.json', '文件数': 5, '代码行数': 120, '大小': '3.8 KB' },
{ '文件类型': '.md', '文件数': 3, '代码行数': 240, '大小': '8.1 KB' },
]
);ora 提供终端加载动画,适合表示正在进行的异步操作。
pnpm add oraimport ora from 'ora';
// 基本 spinner
const spinner = ora('正在加载...').start();
// 模拟异步操作
await new Promise((resolve) => setTimeout(resolve, 2000));
spinner.succeed('加载完成');
// 其他结束状态:
// spinner.fail('加载失败');
// spinner.warn('部分加载');
// spinner.info('加载信息');
// 更新文本
const spinner2 = ora('正在处理...').start();
await new Promise((resolve) => setTimeout(resolve, 1000));
spinner2.text = '正在编译 TypeScript...';
await new Promise((resolve) => setTimeout(resolve, 1000));
spinner2.text = '正在打包...';
await new Promise((resolve) => setTimeout(resolve, 1000));
spinner2.succeed('构建完成');
// 自定义样式
const spinner3 = ora({
text: '正在部署...',
spinner: 'dots12', // 动画样式: dots, line, arc, bouncingBall 等
color: 'cyan', // 颜色
}).start();import ora from 'ora';
// 封装:执行异步操作并显示 spinner
async function withSpinner<T>(
text: string,
task: () => Promise<T>,
successText?: string,
failText?: string
): Promise<T> {
const spinner = ora(text).start();
try {
const result = await task();
spinner.succeed(successText ?? `${text} 完成`);
return result;
} catch (error) {
spinner.fail(failText ?? `${text} 失败`);
throw error;
}
}
// 使用示例
const files = await withSpinner(
'正在扫描文件...',
async () => {
// 模拟扫描
await new Promise((resolve) => setTimeout(resolve, 1500));
return ['file1.ts', 'file2.ts', 'file3.ts'];
},
'扫描完成,找到 3 个文件'
);
await withSpinner(
'正在安装依赖...',
async () => {
// 模拟 npm install
await new Promise((resolve) => setTimeout(resolve, 3000));
}
);
await withSpinner(
'正在编译项目...',
async () => {
await new Promise((resolve) => setTimeout(resolve, 2000));
}
);CLI 工具经常需要从管道(pipe)中读取输入,例如 cat file.txt | mytool 或 echo "hello" | mytool。
// 检测是否有管道输入
function isPiped(): boolean {
return !process.stdin.isTTY;
}
// 读取全部 stdin 输入
async function readStdin(): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(chunk as Buffer);
}
return Buffer.concat(chunks).toString('utf-8');
}
// 逐行读取 stdin
async function readStdinLines(): Promise<string[]> {
const content = await readStdin();
return content.split('\n').filter((line) => line.length > 0);
}
// 实际使用:支持文件参数或管道输入
async function getInput(): Promise<string> {
if (isPiped()) {
// 管道输入: echo "hello" | mytool
return await readStdin();
}
const filePath = process.argv[2];
if (filePath) {
// 文件参数: mytool input.txt
const { readFile } = await import('node:fs/promises');
return await readFile(filePath, 'utf-8');
}
console.error('用法: mytool <file> 或 echo "text" | mytool');
process.exit(1);
}
// 使用示例
const input = await getInput();
const lineCount = input.split('\n').length;
console.log(`输入共 ${lineCount} 行`);
// 运行方式:
// node mytool.js input.txt
// cat input.txt | node mytool.js
// echo "hello\nworld" | node mytool.js要让你的工具可以作为全局命令使用,需要配置 bin 字段。
{
"name": "my-cli-tool",
"version": "1.0.0",
"type": "module",
"bin": {
"mytool": "./dist/index.js"
},
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"dev": "tsx src/index.ts"
},
"dependencies": {
"commander": "^12.0.0",
"chalk": "^5.3.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0",
"tsx": "^4.0.0"
}
}入口文件的第一行必须添加 shebang,告诉系统用 Node.js 执行:
#!/usr/bin/env node
// src/index.ts (编译后变为 dist/index.js)
import { Command } from 'commander';
const program = new Command();
program
.name('mytool')
.description('我的 CLI 工具')
.version('1.0.0');
// ... 命令定义 ...
program.parse();{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true
},
"include": ["src/**/*"]
}npm link 让你在开发过程中可以在全局使用你的 CLI 工具,无需每次构建和发布。
# 在项目根目录执行
npm link
# 现在可以在任何目录使用你的命令了
mytool --version
mytool --help
# 取消链接
npm unlink -g my-cli-tool
# 开发流程(推荐使用 tsx 直接运行 TypeScript)
# 在 package.json 中配置:
# "bin": { "mytool": "./src/index.ts" }
# 开发时直接链接 TypeScript 源码(需要先安装 tsx)更推荐的开发方式:
# 开发时直接用 tsx 运行,无需编译
npx tsx src/index.ts --help
npx tsx src/index.ts build --output dist
# 或者在 package.json scripts 中配置
# "dev": "tsx src/index.ts"
npm run dev -- --help
npm run dev -- build --output distesbuild 可以将 TypeScript 项目打包为单个 JavaScript 文件,无需用户安装任何依赖。
pnpm add -D esbuild{
"scripts": {
"build": "esbuild src/index.ts --bundle --platform=node --target=node20 --outfile=dist/index.js --format=esm --banner:js='#!/usr/bin/env node'",
"build:minify": "esbuild src/index.ts --bundle --platform=node --target=node20 --outfile=dist/index.js --format=esm --minify --banner:js='#!/usr/bin/env node'"
}
}也可以使用配置文件:
// build.ts
import { build } from 'esbuild';
await build({
entryPoints: ['src/index.ts'],
bundle: true,
platform: 'node',
target: 'node20',
outfile: 'dist/index.js',
format: 'esm',
minify: true,
banner: {
js: '#!/usr/bin/env node',
},
// 排除不需要打包的模块(如果有 native 模块)
external: [],
});
console.log('构建完成');# 确保已登录 npm
npm login
# 检查包名是否可用
npm search my-cli-tool
# 发布前测试
npm pack --dry-run
# 发布
npm publish
# 发布后安装测试
npm install -g my-cli-tool
mytool --version{
"name": "my-awesome-cli",
"version": "1.0.0",
"description": "一个示例 CLI 工具",
"type": "module",
"bin": {
"my-cli": "./dist/index.js"
},
"files": [
"dist"
],
"scripts": {
"dev": "tsx src/index.ts",
"build": "esbuild src/index.ts --bundle --platform=node --target=node20 --outfile=dist/index.js --format=esm --banner:js='#!/usr/bin/env node'",
"prepublishOnly": "npm run build"
},
"keywords": ["cli", "tool"],
"license": "MIT",
"engines": {
"node": ">=20.0.0"
},
"dependencies": {
"commander": "^12.0.0",
"chalk": "^5.3.0",
"@inquirer/prompts": "^7.0.0",
"ora": "^8.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0",
"tsx": "^4.0.0",
"esbuild": "^0.24.0"
}
}结合以上所有工具,实现一个完整的 CLI 工具:
#!/usr/bin/env node
// src/index.ts
import { Command } from 'commander';
import chalk from 'chalk';
import ora from 'ora';
import { readdir, stat, readFile } from 'node:fs/promises';
import path from 'node:path';
const program = new Command();
program
.name('projstat')
.description('项目文件统计工具')
.version('1.0.0');
// 统计命令
program
.command('count')
.description('统计项目文件数量和代码行数')
.argument('[dir]', '目标目录', '.')
.option('-e, --extensions <exts...>', '指定文件扩展名', ['.ts', '.js'])
.option('--no-ignore', '不排除 node_modules 等目录')
.option('-f, --format <type>', '输出格式 (table/json)', 'table')
.action(async (dir: string, options) => {
const absDir = path.resolve(dir);
const spinner = ora(`正在扫描 ${chalk.cyan(absDir)}...`).start();
try {
const result = await scanFiles(absDir, {
extensions: options.extensions as string[],
ignore: options.ignore as boolean,
});
spinner.succeed(`扫描完成`);
console.log();
if (options.format === 'json') {
console.log(JSON.stringify(result, null, 2));
} else {
printReport(result);
}
} catch (error) {
spinner.fail('扫描失败');
console.error(chalk.red((error as Error).message));
process.exit(1);
}
});
// 查找命令
program
.command('find')
.description('查找指定扩展名的文件')
.argument('<extension>', '文件扩展名 (如 .ts)')
.argument('[dir]', '目标目录', '.')
.option('-s, --sort <by>', '排序方式 (name/size/lines)', 'name')
.action(async (extension: string, dir: string, options) => {
const absDir = path.resolve(dir);
const ext = extension.startsWith('.') ? extension : `.${extension}`;
console.log(`在 ${chalk.cyan(absDir)} 中查找 ${chalk.yellow(ext)} 文件...\n`);
const files = await findFiles(absDir, ext);
if (files.length === 0) {
console.log(chalk.yellow('未找到匹配的文件'));
return;
}
// 排序
if (options.sort === 'size') {
files.sort((a, b) => b.size - a.size);
} else if (options.sort === 'lines') {
files.sort((a, b) => b.lines - a.lines);
}
for (const file of files) {
const relPath = path.relative(absDir, file.path);
console.log(
` ${chalk.gray(relPath.padEnd(50))} ${String(file.lines).padStart(6)} 行 ${formatSize(file.size).padStart(10)}`
);
}
console.log(`\n共 ${chalk.bold(String(files.length))} 个文件`);
});
program.parse();
// ========== 工具函数 ==========
interface FileStat {
path: string;
size: number;
lines: number;
}
interface ScanResult {
[ext: string]: { count: number; lines: number; size: number };
}
interface ScanOptions {
extensions: string[];
ignore: boolean;
}
const IGNORE_DIRS = ['node_modules', '.git', 'dist', 'build', 'coverage', '.next'];
async function scanFiles(dir: string, options: ScanOptions): Promise<ScanResult> {
const result: ScanResult = {};
async function walk(currentDir: string): Promise<void> {
const entries = await readdir(currentDir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
if (options.ignore && IGNORE_DIRS.includes(entry.name)) continue;
await walk(fullPath);
} else {
const ext = path.extname(entry.name);
if (!options.extensions.includes(ext)) continue;
const fileStat = await stat(fullPath);
let lines = 0;
try {
const content = await readFile(fullPath, 'utf-8');
lines = content.split('\n').length;
} catch {
// 忽略无法读取的文件
}
if (!result[ext]) {
result[ext] = { count: 0, lines: 0, size: 0 };
}
result[ext].count++;
result[ext].lines += lines;
result[ext].size += fileStat.size;
}
}
}
await walk(dir);
return result;
}
async function findFiles(dir: string, ext: string): Promise<FileStat[]> {
const files: FileStat[] = [];
async function walk(currentDir: string): Promise<void> {
const entries = await readdir(currentDir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
if (IGNORE_DIRS.includes(entry.name)) continue;
await walk(fullPath);
} else if (path.extname(entry.name) === ext) {
const fileStat = await stat(fullPath);
let lines = 0;
try {
const content = await readFile(fullPath, 'utf-8');
lines = content.split('\n').length;
} catch {
// 忽略
}
files.push({ path: fullPath, size: fileStat.size, lines });
}
}
}
await walk(dir);
return files;
}
function printReport(result: ScanResult): void {
const entries = Object.entries(result).sort(([, a], [, b]) => b.count - a.count);
if (entries.length === 0) {
console.log(chalk.yellow('未找到匹配的文件'));
return;
}
console.log(chalk.bold(' 扩展名 文件数 行数 大小'));
console.log(chalk.gray(' ' + '-'.repeat(50)));
let totalCount = 0;
let totalLines = 0;
let totalSize = 0;
for (const [ext, data] of entries) {
totalCount += data.count;
totalLines += data.lines;
totalSize += data.size;
console.log(
` ${chalk.cyan(ext.padEnd(10))} ${String(data.count).padStart(6)} ${String(data.lines).padStart(8)} ${formatSize(data.size).padStart(10)}`
);
}
console.log(chalk.gray(' ' + '-'.repeat(50)));
console.log(
` ${chalk.bold('合计'.padEnd(10))} ${chalk.bold(String(totalCount).padStart(6))} ${chalk.bold(String(totalLines).padStart(8))} ${chalk.bold(formatSize(totalSize).padStart(10))}`
);
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}运行效果:
# 查看帮助
projstat --help
projstat count --help
# 统计当前目录
projstat count
# 统计指定目录的 TypeScript 文件
projstat count ./src -e .ts .tsx
# JSON 格式输出
projstat count --format json
# 查找文件
projstat find .ts ./src --sort lines| 工具 | 用途 | Java 对比 |
|---|---|---|
| commander | 命令/选项定义 | picocli |
| @inquirer/prompts | 交互式输入 | Scanner + 大量代码 |
| chalk | 彩色输出 | ANSI 转义码 / jansi |
| ora | 加载动画 | 需手动实现 |
| esbuild | 打包 | GraalVM native-image |
| npm link | 本地调试 | mvn install |
| npm publish | 发布 | mvn deploy |
- 创建一个带子命令的 CLI 工具:
todo add "任务"、todo list、todo done <id> - 使用 @inquirer/prompts 做一个交互式调查问卷生成器
- 给你的 CLI 工具添加
--no-color选项来禁用彩色输出 - 尝试用 esbuild 将你的工具打包为单文件并测试