Skip to content

Latest commit

 

History

History
1134 lines (916 loc) · 27.7 KB

02-cli-development.md

File metadata and controls

1134 lines (916 loc) · 27.7 KB

CLI 工具开发

面向 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 启动快)

CLI 工具开发完整流程

1. 初始化项目 (npm init)
2. 编写命令逻辑
3. 配置 package.json 的 bin 字段
4. 本地测试 (npm link)
5. 打包 (esbuild)
6. 发布 (npm publish)

1. commander — 命令行框架

commander 是 Node.js 最流行的命令行框架,类似 Java 的 picocli,提供命令定义、选项解析、帮助信息生成等功能。

安装

pnpm add commander
pnpm add -D @types/node

1.1 基本用法:定义命令和选项

// 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);
    }
}

1.2 带参数的命令

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!

1.3 子命令

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

2. @inquirer/prompts — 交互式输入

@inquirer/prompts 提供丰富的交互式命令行提示,类似 Java 中用 Scanner 读取用户输入,但功能强大得多。

安装

pnpm add @inquirer/prompts

2.1 各种提示类型

import {
  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}`);

2.2 实战:项目初始化交互

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));

3. chalk — 彩色终端输出

chalk 让终端输出变得丰富多彩。Java 中需要手动使用 ANSI 转义码,而 chalk 提供了优雅的 API。

安装

pnpm add chalk

3.1 基本用法

import 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')} 个警告`);

3.2 实用日志工具

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');

3.3 格式化表格输出

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' },
  ]
);

4. ora — 加载动画(Spinner)

ora 提供终端加载动画,适合表示正在进行的异步操作。

安装

pnpm add ora

4.1 基本用法

import 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();

4.2 封装异步操作

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));
  }
);

5. 读取 stdin / 管道输入

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

6. package.json 的 bin 字段配置

要让你的工具可以作为全局命令使用,需要配置 bin 字段。

6.1 基本配置

{
  "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"
  }
}

6.2 入口文件的 shebang

入口文件的第一行必须添加 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();

6.3 TypeScript 项目的 tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "declaration": true
  },
  "include": ["src/**/*"]
}

7. npm link — 本地调试

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 dist

8. 打包发布

8.1 esbuild 打包单文件

esbuild 可以将 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('构建完成');

8.2 发布到 npm

# 确保已登录 npm
npm login

# 检查包名是否可用
npm search my-cli-tool

# 发布前测试
npm pack --dry-run

# 发布
npm publish

# 发布后安装测试
npm install -g my-cli-tool
mytool --version

8.3 完整的 package.json 示例

{
  "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"
  }
}

9. 综合实战:项目统计 CLI

结合以上所有工具,实现一个完整的 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

动手练习

  1. 创建一个带子命令的 CLI 工具:todo add "任务"、todo list、todo done <id>
  2. 使用 @inquirer/prompts 做一个交互式调查问卷生成器
  3. 给你的 CLI 工具添加 --no-color 选项来禁用彩色输出
  4. 尝试用 esbuild 将你的工具打包为单文件并测试