mcpField
D
dart-build-cli-app

dart-build-cli-app

github.com/flutter开发3.1k14.5k 次安装2 天前更新
代码仓库

关于 dart-build-cli-app

面向 Dart 开发者的 CLI 构建指南,涵盖架构、参数处理、测试与发布。重点说明异步退出、终端清理和 AOT 子进程处理等实践。

主要功能

  1. 01规划 CLI 入口与代码布局
  2. 02解析参数并路由子命令
  3. 03区分标准输出与错误输出
  4. 04处理信号与终端状态恢复
  5. 05测试命令逻辑和进程行为
  6. 06编译并分发 CLI 程序

适用场景

  1. 01设计新的 Dart 命令行项目
  2. 02整理现有 CLI 的退出流程
  3. 03为命令行工具编写测试
  4. 04准备 AOT 编译与分发

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/flutter/agent-plugins --skill dart-build-cli-app

安装后在 Claude Code 中输入/dart-build-cli-app确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

examples/
multi_command_runner.dart
single_command_tool.dart
references/
aot_sdk_discovery.md
signals_and_terminal.md

详细介绍

构建 Dart CLI 应用程序

目录


1. 核心架构与进程生命周期

避免破坏性退出(exit(N))

调用 dart:io 的 exit(int code) 会在 C++ 运行时中调用 Platform::Exit(code)。它会立即终止 OS 进程,而不会展开 Dart 堆栈:

  • 调试器断开:使用 --pause-isolates-on-exit 启动时,VM Service 会在关闭前暂停 isolates,以便 IDE 检查。exit() 会在 VM Service 暂停或检查状态之前终止 OS 进程。
  • 覆盖率丢失:package:coverage 会在退出时暂停的状态下,通过 VM Service RPC 查询执行行。exit() 会在 RPC 提取之前销毁进程,导致覆盖率为 0%。
  • 缓冲区截断:stdout 和 stderr 是缓冲的异步 IOSink 流。exit() 会丢弃尚未刷新的字节。
  • 资源泄漏:finally 块(关闭锁、删除临时目录)会被跳过。

规则:正常执行期间避免直接调用 exit(code);设置 exitCode = code,或从 CommandRunner<int>(来自 package:args)返回整数退出码,并让异步 main() 函数自然返回。发生未处理错误时不要调用 exit();应抛出未处理的 Error 或异常,让运行时正常展开堆栈并以非零状态退出。

标准 POSIX 退出码(/usr/include/sysexits.h):

  • 0:成功(EX_OK / ExitCode.success.code)
  • 64:命令行用法错误(EX_USAGE / ExitCode.usage.code)
  • 65:数据格式错误(EX_DATAERR / ExitCode.data.code)
  • 70:内部软件崩溃(EX_SOFTWARE / ExitCode.software.code)
  • 78:配置错误(EX_CONFIG / ExitCode.config.code)

注意:优先导入 package:io/io.dart 并使用 ExitCode 常量 (例如 ExitCode.usage.code、ExitCode.software.code),而不是魔法 整数。对于没有包依赖的最简独立脚本,可以使用标准 POSIX 整数字面量 (0、64、70)。

import 'dart:io';
import 'package:args/command_runner.dart';
import 'package:io/io.dart' show ExitCode; // Provides standard POSIX ExitCode constants

Future<void> main(List<String> args) async {
  final runner = CommandRunner<int>('tool', 'CLI tool description.');
  try {
    final status = await runner.run(args);
    exitCode = status ?? ExitCode.success.code;
  } on UsageException catch (e) {
    stderr
      ..writeln(e.message)
      ..writeln(e.usage);
    exitCode = ExitCode.usage.code;
  }
}
精简入口模式(bin/ 与 lib/src/)

严格将 bin/*.dart 文件用作最简入口跳板(实例化 runner、传递 args、等待退出码)。将所有命令定义、参数解析器、格式化器和业务逻辑放在 lib/src/ 中。

  • 原因:无法通过 package: URI 干净地导入 bin/ 中的代码。将逻辑移入 lib/src/ 后,整个命令 runner、子命令层级和业务逻辑便可在内存中进行单元测试,耗时仅几毫秒(< 2ms),且无需生成 OS 子进程。
// bin/my_cli.dart — Thin entrypoint trampoline
import 'dart:io';
import 'package:my_cli/src/cli.dart';

Future<void> main(List<String> args) async {
  exitCode = await runCli(args);
}

2. 输出、诊断信息与格式

  • 数据与诊断信息:将预期的程序结果和机器可读数据仅写入 stdout。将警告、错误消息和调试日志仅写入 stderr。
  • 错误处理规则:发生参数解析错误或必填选项错误时(FormatException、UsageException,或通过 results.option(...) 访问缺失的 mandatory: true 选项时抛出的 ArgumentError),错误消息和用法文本都必须写入 stderr,并返回退出代码 64(EX_USAGE / ExitCode.usage.code)。只有用户通过 --help 或 -h 明确请求帮助时,才可以将用法帮助写入 stdout。
  • 错误处理程序中不要使用 print():print() 会将内容写入 stdout。所有失败通知都应使用 stderr.writeln()。标准输出优先使用 stdout.writeln(),而非 print(),以符合 avoid_print lint 规则(除非 analysis_options.yaml 明确配置了 avoid_print: false)。
  • 终端能力检测与 NO_COLOR:输出 ANSI 颜色或光标转义码前,请检查 stdout.hasTerminal、stdout.supportsAnsiEscapes 和 !Platform.environment.containsKey('NO_COLOR'):
    bool get useAnsi =>
        stdout.hasTerminal &&
        stdout.supportsAnsiEscapes &&
        !Platform.environment.containsKey('NO_COLOR');
    
  • 机器可读模式:传入 --json 或 --machine 标志时,将数据格式化为 JSON 并写入 stdout,日志则写入 stderr。

3. 项目配置与打包

脚手架与 Pubspec 可执行文件映射(executables:)

使用 dart create -t console <package_name> 搭建新的命令行项目,该命令会初始化标准的 bin/ 和 lib/ 目录结构。务必在 pubspec.yaml 的 executables: 下声明可执行文件,将命令名称映射到 bin/ 中的脚本,以便通过 dart run <command>(无需指定 bin/...dart)直接运行,并为 dart install 配置全局二进制文件符号链接:

name: my_cli
description: High-performance CLI tool.
version: 1.0.0

executables:
  my_cli: # Maps to bin/my_cli.dart
  secondary_cmd: helper # Maps to bin/helper.dart
单一来源的版本管理(package:build_version)

避免在 bin/*.dart 中硬编码 --version 字符串,或手动同步常量文件。使用 package:build_version 在构建期间直接从 pubspec.yaml 生成包含 const packageVersion = 'x.y.z'; 的 lib/src/version.dart。

缓存约定

将临时缓存文件存放在 .dart_tool/<package_name>/ 中。切勿将持久缓存文件直接写入项目根目录。


4. 参数解析与命令路由

导入 package:args 来管理命令行参数:

  • 简单脚本:直接使用 ArgParser 和 addFlag()、addOption()。
  • 多命令工具:实现 CommandRunner<int>,并为每个子命令扩展 Command<int>,直接返回 POSIX 退出代码。
  • 类型安全访问器:使用 results.flag('name')、results.option('name') 和 results.multiOption('name')(在 package:args 2.5+ 中可用),而非使用映射索引运算符 operator [],以免手动进行类型转换(as bool、as String?)。
  • 复杂选项模型:对于包含大量标志的应用,使用 package:build_cli 生成强类型选项类。利用命名默认值覆盖项(例如 {String? hostDefaultOverride}),以便将配置文件与 CLI 标志清晰地合并。

5. 原生异步与现代堆栈跟踪

  • 避免使用 Chain.capture():Dart VM 会原生保留跨越 await 挂起点的异步堆栈帧。Chain.capture 会使用自定义 Zone 包装事件循环,带来显著的分配开销,并会捕获跨 Zone 边界的错误。
  • 使用 Trace.from(st).terse 清理堆栈跟踪:在未捕获的错误上使用 package:stack_trace 中的静态工具,而无需捕获 Zone:
import 'dart:io';
import 'package:io/io.dart' show ExitCode;
import 'package:stack_trace/stack_trace.dart';

Future<void> runMain(List<String> args) async {
  try {
    await executeLogic(args);
    exitCode = ExitCode.success.code;
  } catch (e, st) {
    stderr.writeln('Fatal error: $e');
    if (args.contains('-v') || args.contains('--verbose')) {
      stderr.writeln(Trace.from(st).terse);
    }
    exitCode = ExitCode.software.code;
  }
}

6. 子进程启动与 AOT 弹性

启动 Dart SDK 子进程或执行其他 Dart 工具(例如 dart format、dart test、build_runner)时:

  • 不要假设 Platform.resolvedExecutable 或 Platform.executable 指向 dart 命令行可执行文件:在独立的 AOT 编译二进制文件(dart install / dart compile exe)中,resolvedExecutable 指向已编译的应用程序二进制文件,可能导致递归自调用循环或拒绝标志时崩溃。
  • 使用 package:cli_util:使用 cli_util.dartExecutable 或 cli_util.sdkPath 解析 Dart SDK 可执行文件,不要自行编写 PATH 或目录扫描器。
  • 版本要求和详细技术指南请参阅 references/aot_sdk_discovery.md。

7. 信号处理与终端恢复

如果 CLI 会更改终端模式、显示旋转指示器或打开监听套接字:

  • Windows 信号保护:在 Windows 上,ProcessSignal.sigterm.watch() 会抛出 UnsupportedError。使用 if (!Platform.isWindows) 对 sigterm 做保护。
  • 回显与行模式恢复:如果设置 stdin.echoMode = false 或 stdin.lineMode = false,请先检查 if (!stdin.hasTerminal) return;,并安装 SIGINT 监听器和 finally 块来恢复这些设置,确保用户退出后按键仍可见。
  • 光标可见性:如果输出了 ANSI 隐藏光标代码(\x1B[?25l),务必在退出或取消时恢复光标可见(\x1B[?25h)。
  • 套接字清理:收到终止信号时,显式关闭监听的 HttpServer 或 ServerSocket 实例(server.close(force: true)),以便立即释放操作系统端口。
  • 详细模式请参阅 references/signals_and_terminal.md。

8. 测试 CLI 应用程序

将测试分为两个不同层次:

  1. 单元测试(内存中,< 5ms):在 test/ 中直接导入 package:<pkg>/src/...,测试命令类、选项解析和业务逻辑。
  2. 集成测试(子进程):使用 package:test_process 和 package:test_descriptor 验证端到端二进制执行、进程 I/O 流以及操作系统退出代码:
import 'package:test/test.dart';
import 'package:test_descriptor/test_descriptor.dart' as d;
import 'package:test_process/test_process.dart';

void main() {
  test('CLI processes input and exits cleanly', () async {
    await d.file('input.txt', 'hello').create();

    final process = await TestProcess.start('dart', [
      'run',
      'bin/my_cli.dart',
      '--input',
      d.path('input.txt'),
    ]);

    await expectLater(process.stdout, emitsThrough('Processing complete.'));
    await process.shouldExit(0);
  });
}

9. 现代化编译与分发

Dart 3.12+ 将 CLI 分发标准化为使用 dart run 和 dart install(逐步弃用 dart pub global activate):

  • 临时执行(JIT):dart run <package>@<version> [args] 会按需下载并运行 CLI。
  • 全局安装(原生 AOT):dart install <package> 会将软件包入口点编译为快速的原生独立二进制文件,存放在 ~/.dart/install/bin/。
  • 本地开发:使用 dart run <command>(通过 pubspec.yaml 中的 executables: 解析)或 dart run bin/cli.dart。
  • 捆绑动态库与代码资源:使用 dart build cli。输出捆绑包至 build/cli/_/bundle/。
  • 独立可执行文件编译:使用 dart compile exe bin/cli.dart -o <output_path>。

10. 工作流与审核清单

实现工作流
  • 在 pubspec.yaml 的 executables: 下声明入口点。
  • 将 bin/*.dart 保持为轻量入口点;将命令逻辑放在 lib/src/ 中。
  • 返回整数退出代码或设置 exitCode = N;避免直接调用 exit(N)。
  • 解析失败时,将错误、警告和用法信息输出到 stderr。
  • 使用 results.flag()、results.option() 和 results.multiOption() 确保类型安全。
  • 输出 ANSI 代码前,验证 useAnsi(检查 stdout.hasTerminal、supportsAnsiEscapes 和 NO_COLOR)。
  • 使用 cli_util.dartExecutable 启动子工具,绝不使用 Platform.resolvedExecutable。
  • 在内存中对命令运行器进行单元测试;使用 test_process 测试端到端二进制执行。

References & Examples

评论

支持 Markdown