Communitygithub.com

photoeven2-netizen/astronusa

Dart 명령줄 인터페이스(CLI) 애플리케이션을 위한 아키텍처 패턴, 진입점 구조, 종료 코드, 스트림 라우팅 및 서브프로세스 생성 방법을 제공합니다. CLI 도구, 콘솔 유틸리티, 스크립트, `package:args`(ArgParser 또는 CommandRunner)를 사용한 인수 파싱, 종료 코드 처리, pubspec.yaml의 실행 파일 구성, Dart 서브프로세스 생성, 또는 네이티브 CLI 바이너리 컴파일 시 사용하세요. Flutter UI 위젯, 웹 애플리케이션, 또는 독립형 HTTP 백엔드 서버에는 사용하지 마세요.

astronusa란 무엇인가요?

이 스킬은 Dart CLI 애플리케이션을 구축하기 위한 포괄적인 가이드를 제공합니다. 핵심 아키텍처부터 프로세스 생명주기 관리, 스트림 및 진단 출력, 프로젝트 구성과 패키징, 인수 파싱과 명령 라우팅, 비동기 처리와 최신 스택 트레이스, 서브프로세스 생성과 AOT(Ahead-of-Time) 컴파일 환경에서의 복원력, 시그널 처리와 터미널 종료 절차, CLI 애플리케이션 테스트, 최신 컴파일 및 배포 방법까지 폭넓게 다룹니다. 특히, `exit()` 함수 직접 호출은 디버거 연결 해제, 코드 커버리지 손실, 버퍼 잘림, 리소스 누수와 같은 심각한 문제를 일으킬 수 있으므로, `exitCode` 속성을 설정하거나 `CommandRunner<int>`를 통해 자연스럽게 종료되어야 한다는 점을 강조합니다. 또한, 패키지의 공개 API는 `lib/src/` 디렉토리에 배치하고 `bin/` 디렉토리의 파일은 최소한의 진입점(trampoline) 역할만 하도록 하여 단위 테스트 가능성을 높이는 것이 중요합니다. 이 스킬은 `package:args`, `package:io`, `package:stack_trace`, `package:cli_util` 등 공식 패키지를 권장하며, `package:test`와 `package:test_process`를 활용한 인메모리 단위 테스트와 통합 테스트 전략을 제시합니다.

지원 대상~Claude Code~Codex CLI✓Cursor
npx skills add https://github.com/photoeven2-netizen/astronusa/tree/HEAD/.agents/skills/dart-build-cli-app

즐겨 사용하는 AI에게 물어보기

이 에이전트 스킬이 미리 로드된 새 채팅을 엽니다.

문서

Dart CLI 애플리케이션 구축하기

목차


1. 핵심 아키텍처 및 프로세스 생명주기

파괴적인 종료(exit(N)) 피하기

dart:io의 exit(int code)를 호출하면 C++ 런타임에서 Platform::Exit(code)를 호출합니다. 이는 Dart 스택을 해제하지 않고 OS 프로세스를 즉시 종료시킵니다:

  • 디버거 연결 해제: --pause-isolates-on-exit 플래그와 함께 실행되면 VM 서비스가 IDE 검사를 위해 종료 전에 격리(isolate)를 일시 중지합니다. exit()는 VM 서비스가 상태를 일시 중지하거나 검사하기 전에 OS 프로세스를 종료시킵니다.
  • 커버리지 손실: package:coverage는 일시 중지된 종료 상태에서 VM 서비스 RPC를 통해 실행 라인을 쿼리합니다. exit()는 RPC 추출 전에 프로세스를 파괴하여 0% 커버리지를 초래합니다.
  • 버퍼 잘림: stdout과 stderr는 버퍼링된 비동기 IOSink 스트림입니다. exit()는 플러시되지 않은 바이트를 버립니다.
  • 리소스 누수: finally 블록(락 해제, 임시 디렉토리 삭제 등)이 무시됩니다.

규칙: 정상 실행 중에는 exit(code)를 직접 호출하지 말고, exitCode = code를 설정하거나 CommandRunner<int>(package:args에서 제공)에서 정수 종료 코드를 반환하여 비동기 main() 함수가 자연스럽게 반환되도록 하세요. 처리되지 않은 오류에 대해 exit()를 호출하지 말고, 처리되지 않은 Error 또는 예외를 던져 런타임이 깔끔하게 해제되고 0이 아닌 상태로 종료되도록 하세요.

표준 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; // 표준 POSIX ExitCode 상수 제공

Future<void> main(List<String> args) async {
  final runner = CommandRunner<int>('tool', 'CLI 도구 설명.');
  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/ vs. lib/src/)

bin/*.dart 파일은 최소한의 진입점 트램펄린(인스턴스 생성, args 전달, 종료 코드 대기)으로 엄격하게 유지하세요. 모든 명령 정의, 인수 파서, 포맷터 및 비즈니스 로직은 lib/src/ 안에 배치하세요.

  • 이유: bin/의 코드는 package: URI를 통해 깔끔하게 가져올 수 없습니다. 로직을 lib/src/로 이동하면 전체 명령 실행기, 하위 명령 계층 구조 및 비즈니스 로직을 OS 서브프로세스를 생성하지 않고 메모리 내에서 밀리초(< 2ms) 단위로 단위 테스트할 수 있습니다.
// bin/my_cli.dart — 얇은 진입점 트램펄린
import 'dart:io';
import 'package:my_cli/src/cli.dart';

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

2. 출력, 진단 및 형식 지정

  • 데이터 vs. 진단: 의도된 프로그램 결과와 기계 판독 가능 데이터는 stdout에만 기록하세요. 경고, 오류 메시지 및 디버그 로그는 stderr에만 기록하세요.
  • 오류 사용 규칙: 인수 파싱 또는 필수 옵션 오류가 발생할 때(FormatException, UsageException, 또는 results.option(...)을 통해 누락된 mandatory: true 옵션에 접근할 때 발생하는 ArgumentError), 오류 메시지와 사용법 텍스트 모두 stderr에 기록되어야 하며, 종료 코드 64(EX_USAGE / ExitCode.usage.code)가 반환되어야 합니다. stdout은 사용자가 --help 또는 -h를 통해 명시적으로 요청할 때만 사용법 도움말을 받아야 합니다.
  • 오류 처리기에서 print() 사용 금지: print()는 stdout으로 라우팅됩니다. 모든 실패 알림에는 stderr.writeln()을 사용하세요. 표준 출력의 경우, avoid_print 린트 규칙을 준수하려면 print()보다 stdout.writeln()을 선호하세요(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: 고성능 CLI 도구.
version: 1.0.0

executables:
  my_cli: # bin/my_cli.dart에 매핑
  secondary_cmd: helper # 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를 가져와서 명령줄 인수를 관리하세요:

  • 간단한 스크립트: addFlag() 및 addOption()과 함께 ArgParser를 직접 사용하세요.
  • 다중 명령 도구: 각 하위 명령에 대해 CommandRunner<int>를 구현하고 Command<int>를 확장하여 POSIX 종료 코드를 직접 반환하세요.
  • 타입 안전 접근자: 맵 인덱싱 operator [] 대신 results.flag('name'), results.option('name') 및 results.multiOption('name')을 사용하세요(package:args 2.5+에서 사용 가능). 이를 통해 수동 타입 캐스트(as bool, as String?)를 제거할 수 있습니다.
  • 복잡한 옵션 모델: 플래그가 많은 애플리케이션의 경우 package:build_cli를 사용하여 강력한 타입의 옵션 클래스를 생성하세요. 명명된 기본값 재정의(예: {String? hostDefaultOverride})를 활용하여 구성 파일과 CLI 플래그를 깔끔하게 병합하세요.

5. 네이티브 비동기 및 최신 스택 트레이스

  • Chain.capture() 피하기: Dart VM은 await 중단 지점에서 비동기 스택 프레임을 기본적으로 보존합니다. Chain.capture는 사용자 정의 Zone으로 이벤트 루프를 래핑하여 상당한 할당 오버헤드를 발생시키고 Zone 경계에서 오류를 포착합니다.
  • Trace.from(st).terse로 정리하기: Zone을 캡처하지 않고 처리되지 않은 오류에 대해 package:stack_trace의 정적 유틸리티를 사용하세요:
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('치명적 오류: $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 사용하기: 사용자 정의 PATH 또는 디렉토리 스크레이퍼를 작성하는 대신 cli_util.dartExecutable 또는 cli_util.sdkPath를 사용하여 Dart SDK 실행 파일을 확인하세요.
  • 버전 요구 사항 및 자세한 기술 가이드는 references/aot_sdk_discovery.md를 참조하세요.

7. 시그널 처리 및 터미널 종료

CLI가 터미널 모드를 변경하거나, 스피너를 표시하거나, 수신 소켓을 여는 경우:

  • Windows 시그널 보호: Windows에서 ProcessSignal.sigterm.watch()는 UnsupportedError를 던집니다. sigterm을 if (!Platform.isWindows)로 보호하세요.
  • 에코 및 라인 모드 종료: stdin.echoMode = false 또는 stdin.lineMode = false를 설정하는 경우, 먼저 if (!stdin.hasTerminal) return;을 확인하고 SIGINT 리스너와 finally 블록을 설치하여 종료 후에도 사용자 키 입력이 계속 표시되도록 복원하세요.
  • 커서 가시성: ANSI 커서 숨기기(\x1B[?25l)를 내보내는 경우, 종료 또는 취소 시 항상 커서 가시성(\x1B[?25h)을 복원하세요.
  • 소켓 정리: 종료 시그널에서 수신 중인 HttpServer 또는 ServerSocket 인스턴스를 명시적으로 닫아(server.close(force: true)) OS 포트를 즉시 해제하세요.
  • 자세한 패턴은 references/signals_and_terminal.md를 참조하세요.

8. CLI 애플리케이션 테스트

두 가지 별개의 계층으로 테스트를 구성하세요:

  1. 단위 테스트 (메모리 내, < 5ms): test/에서 package:<pkg>/src/...를 가져와 명령 클래스, 옵션 파싱 및 비즈니스 로직을 직접 테스트하세요.
  2. 통합 테스트 (서브프로세스): package:test_process와 package:test_descriptor를 사용하여 종단 간 바이너리 실행, 프로세스 I/O 스트리밍 및 OS 종료 코드를 확인하세요:
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가 입력을 처리하고 깔끔하게 종료됩니다', () 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('처리 완료.'));
    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로 종단 간 바이너리 실행을 테스트합니다.

참고 자료 및 예제

Individual skills in this repo

This repo contains 3 individual skills — each has its own dedicated page.

관련 스킬