From 2446f0f80e9be00b9772c671919ddbad43e8f167 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Sun, 15 Feb 2026 15:22:24 -0500 Subject: [PATCH 1/5] feat: add gh source mode for Dart repositories Introduce --gh-mode and --git-path so gh sources can run Dart tools directly from GitHub when release binaries are unavailable. --- CHANGELOG.md | 5 + README.md | 14 +++ doc/compatibility.md | 18 +++ example/README.md | 4 + lib/src/cache_paths.dart | 20 +++ lib/src/cli_parser.dart | 66 ++++++++++ lib/src/engine.dart | 5 +- lib/src/github_runner.dart | 235 ++++++++++++++++++++++++++++++++++- lib/src/models.dart | 7 ++ test/cache_paths_test.dart | 23 ++++ test/cli_parser_test.dart | 41 ++++++ test/github_runner_test.dart | 117 +++++++++++++++++ 12 files changed, 551 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b2b608..e0b909c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## Unreleased + +- Add `--gh-mode` (`binary|source|auto`) and `--git-path` to support running Dart CLIs directly from GitHub source. +- Add source-mode execution for Dart GitHub repos via sandboxed git dependencies and `auto` fallback when release binaries are unavailable. + ## 0.3.1 - Add installer one-liner smoke tests across Linux/macOS/Windows on `x64` and `arm64`. diff --git a/README.md b/README.md index 0c10603..e8f4aed 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ Explicit source selection: ```bash drx --from pub: [--] [args...] drx --from gh:/ [--] [args...] +# Optional for gh source: --gh-mode binary|source|auto --git-path ``` Examples: @@ -65,6 +66,12 @@ drx --from gh:BurntSushi/ripgrep rg -- --version drx --from gh:junegunn/fzf fzf -- --version drx --from gh:charmbracelet/gum gum -- --version +# Run a Dart CLI directly from GitHub source: +drx --gh-mode source --from gh:leehack/mcp_dart@mcp_dart_cli-v0.1.6 --git-path packages/mcp_dart_cli mcp_dart_cli:mcp_dart -- --help + +# Try release binary first, then fallback to source mode: +drx --gh-mode auto --from gh:leehack/mcp_dart@mcp_dart_cli-v0.1.6 --git-path packages/mcp_dart_cli mcp_dart -- --help + # Some repos do not publish checksums: drx --allow-unsigned --from gh:sharkdp/fd fd -- --version ``` @@ -94,6 +101,13 @@ drx --json cache list - GitHub assets require checksum verification by default. - Unsigned assets are blocked unless you pass `--allow-unsigned`. +## GitHub Mode Behavior + +- `--gh-mode binary` (default): run precompiled release assets. +- `--gh-mode source`: run Dart CLI from GitHub source via sandboxed `dart pub get` + `dart run`. +- `--gh-mode auto`: prefer release assets, fallback to source if no compatible binary is available. +- `--git-path ` selects a package path inside a monorepo for source mode. + ## Platform Support - Linux: `x64`, `arm64` diff --git a/doc/compatibility.md b/doc/compatibility.md index 53054e0..abcd4a6 100644 --- a/doc/compatibility.md +++ b/doc/compatibility.md @@ -68,6 +68,24 @@ This source is language-agnostic. Your executable can be built from any stack (for example Go, Rust, C/C++, Zig, or .NET native AOT) as long as release assets match platform/arch naming and include checksums. +### Dart source fallback mode + +For Dart tools that do not publish release binaries, `drx` can run directly from +GitHub source: + +```bash +drx --gh-mode source --from gh:/ -- [args...] +``` + +Use `--git-path ` when the Dart package is in a monorepo subdirectory. + +```bash +drx --gh-mode source --from gh:leehack/mcp_dart@mcp_dart_cli-v0.1.6 --git-path packages/mcp_dart_cli mcp_dart_cli:mcp_dart -- --help +``` + +`--gh-mode auto` tries binary release assets first and falls back to source mode +when no compatible binary is available. + ### Requirements 1. Publish binaries on GitHub Releases. diff --git a/example/README.md b/example/README.md index e7fc545..cec4c6b 100644 --- a/example/README.md +++ b/example/README.md @@ -22,6 +22,10 @@ drx --from gh:BurntSushi/ripgrep rg -- --version drx --from gh:junegunn/fzf fzf -- --version drx --from gh:charmbracelet/gum gum -- --version drx --allow-unsigned --from gh:sharkdp/fd fd -- --version + +# GitHub Dart source mode +drx --gh-mode source --from gh:leehack/mcp_dart@mcp_dart_cli-v0.1.6 --git-path packages/mcp_dart_cli mcp_dart_cli:mcp_dart -- --help +drx --gh-mode auto --from gh:leehack/mcp_dart@mcp_dart_cli-v0.1.6 --git-path packages/mcp_dart_cli mcp_dart -- --help ``` Use runtime controls for pub tools: diff --git a/lib/src/cache_paths.dart b/lib/src/cache_paths.dart index 407e413..265caa0 100644 --- a/lib/src/cache_paths.dart +++ b/lib/src/cache_paths.dart @@ -88,6 +88,26 @@ final class DrxPaths { return Directory(p.join(cacheDirectory.path, 'gh', key)); } + /// Cache location for GitHub source-mode Dart installs. + Directory ghSourceDir( + String owner, + String repo, + String ref, + String gitPath, + HostPlatform platform, + ) { + final key = stableKey([ + 'gh-source', + owner, + repo, + ref, + gitPath, + platform.os, + platform.arch, + ]); + return Directory(p.join(cacheDirectory.path, 'gh-source', key)); + } + /// Lock file path derived from a deterministic key. File lockFileFor(String key) { final hash = stableKey([key]); diff --git a/lib/src/cli_parser.dart b/lib/src/cli_parser.dart index 88f3421..fc7a835 100644 --- a/lib/src/cli_parser.dart +++ b/lib/src/cli_parser.dart @@ -11,6 +11,8 @@ final class CliParser { RuntimeMode runtime = RuntimeMode.auto; var refresh = false; var isolated = false; + GhMode ghMode = GhMode.binary; + String? gitPath; String? asset; var allowUnsigned = false; var verbose = false; @@ -60,6 +62,34 @@ final class CliParser { continue; } + if (token.startsWith('--gh-mode=')) { + ghMode = _parseGhMode(token.substring('--gh-mode='.length).trim()); + i++; + continue; + } + if (token == '--gh-mode') { + if (i + 1 >= argv.length) { + throw const CliParseException('Missing value for --gh-mode.'); + } + ghMode = _parseGhMode(argv[i + 1].trim()); + i += 2; + continue; + } + + if (token.startsWith('--git-path=')) { + gitPath = token.substring('--git-path='.length).trim(); + i++; + continue; + } + if (token == '--git-path') { + if (i + 1 >= argv.length) { + throw const CliParseException('Missing value for --git-path.'); + } + gitPath = argv[i + 1].trim(); + i += 2; + continue; + } + if (token.startsWith('--from=')) { fromRaw = token.substring('--from='.length).trim(); i++; @@ -149,6 +179,25 @@ final class CliParser { } final commandArgs = _extractCommandArgs(commandArgsInput); + final normalizedGitPath = gitPath?.trim(); + + if (source.type != SourceType.gh && ghMode != GhMode.binary) { + throw const CliParseException('--gh-mode is only valid for gh source.'); + } + if (source.type != SourceType.gh && normalizedGitPath != null) { + throw const CliParseException('--git-path is only valid for gh source.'); + } + if (source.type == SourceType.gh && + ghMode == GhMode.binary && + normalizedGitPath != null && + normalizedGitPath.isNotEmpty) { + throw const CliParseException( + '--git-path requires --gh-mode source or --gh-mode auto.', + ); + } + if (normalizedGitPath != null && normalizedGitPath.isEmpty) { + throw const CliParseException('--git-path cannot be empty.'); + } final request = CommandRequest( source: source, @@ -159,6 +208,8 @@ final class CliParser { isolated: isolated, allowUnsigned: allowUnsigned, verbose: verbose, + ghMode: ghMode, + gitPath: normalizedGitPath, asset: asset?.isEmpty == true ? null : asset, ); @@ -180,6 +231,21 @@ final class CliParser { } } + GhMode _parseGhMode(String value) { + switch (value) { + case 'binary': + return GhMode.binary; + case 'source': + return GhMode.source; + case 'auto': + return GhMode.auto; + default: + throw CliParseException( + 'Invalid gh mode "$value". Use binary, source, or auto.', + ); + } + } + ({SourceSpec source, String command}) _parseDefaultPubTarget(String token) { final at = token.lastIndexOf('@'); final hasVersion = at > 0; diff --git a/lib/src/engine.dart b/lib/src/engine.dart index 94b795b..8ac4194 100644 --- a/lib/src/engine.dart +++ b/lib/src/engine.dart @@ -455,6 +455,7 @@ Usage: drx [--] [args...] drx --from pub: [--] [args...] drx --from gh:/ [--] [args...] + [--gh-mode binary|source|auto] [--git-path ] drx cache [list|clean|prune] [--json] drx cache prune [--max-age-days N] [--max-size-mb N] [--json] drx versions [--limit N] [--json] @@ -468,7 +469,9 @@ Options: --runtime auto | jit | aot --refresh Refresh cached artifacts. --isolated Use isolated temporary environment. - --asset Asset override for gh source. + --gh-mode binary | source | auto (gh source only) + --git-path Package path in GitHub monorepo (gh source mode). + --asset Asset override for gh binary mode. --allow-unsigned Allow running unsigned gh assets. --json JSON output (cache/versions commands). -v, --verbose Verbose output. diff --git a/lib/src/github_runner.dart b/lib/src/github_runner.dart index 21c8c05..7a74e21 100644 --- a/lib/src/github_runner.dart +++ b/lib/src/github_runner.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:archive/archive.dart'; import 'package:path/path.dart' as p; +import 'package:yaml/yaml.dart'; import 'cache_paths.dart'; import 'checksum.dart'; @@ -13,7 +14,7 @@ import 'models.dart'; import 'platform_info.dart'; import 'process_executor.dart'; -/// Executes tools from GitHub release assets. +/// Executes tools from GitHub release assets or Dart source. final class GitHubRunner { GitHubRunner({ required this.paths, @@ -29,7 +30,7 @@ final class GitHubRunner { final GitHubApi api; final ByteFetcher fetcher; - /// Resolves, verifies, and runs a command from a GitHub release. + /// Resolves and runs a command from GitHub. Future execute(CommandRequest request) async { final ownerRepo = request.source.identifier.split('/'); if (ownerRepo.length != 2) { @@ -42,9 +43,36 @@ final class GitHubRunner { _log( request.verbose, - 'source=gh repo=$owner/$repo tag=${request.source.version ?? 'latest'} command=${request.command}', + 'source=gh repo=$owner/$repo tag=${request.source.version ?? 'latest'} ' + 'mode=${request.ghMode.name} command=${request.command}', ); + switch (request.ghMode) { + case GhMode.binary: + return _executeBinary(request, owner: owner, repo: repo); + case GhMode.source: + return _executeSource(request, owner: owner, repo: repo); + case GhMode.auto: + try { + return await _executeBinary(request, owner: owner, repo: repo); + } on DrxException catch (error) { + if (!_shouldFallbackToSource(error)) { + rethrow; + } + _log( + request.verbose, + 'binary mode unavailable (${error.message}); falling back to source mode', + ); + return _executeSource(request, owner: owner, repo: repo); + } + } + } + + Future _executeBinary( + CommandRequest request, { + required String owner, + required String repo, + }) async { final release = request.source.version == null ? await api.latestRelease(owner, repo) : await api.releaseByTag(owner, repo, request.source.version!); @@ -111,6 +139,207 @@ final class GitHubRunner { }); } + Future _executeSource( + CommandRequest request, { + required String owner, + required String repo, + }) async { + if (request.runtime == RuntimeMode.aot) { + throw const DrxException( + 'AOT is not supported for --gh-mode source. Use --runtime jit or --runtime auto.', + ); + } + + final ref = request.source.version; + final gitPath = request.gitPath; + final refKey = ref ?? 'default'; + final gitPathKey = gitPath ?? '.'; + final parsedCommand = _parseSourceCommand(request.command); + final packageName = + parsedCommand.package ?? + await _resolveSourcePackageName( + owner: owner, + repo: repo, + ref: ref, + gitPath: gitPath, + ); + final executable = parsedCommand.executable; + final lock = paths.lockFileFor( + 'gh-source:$owner/$repo:$refKey:$gitPathKey:$packageName:${platform.os}:${platform.arch}', + ); + + return withFileLock(lock, () async { + final installDir = request.isolated + ? await Directory.systemTemp.createTemp('drx_gh_source_') + : paths.ghSourceDir(owner, repo, refKey, gitPathKey, platform); + + if (request.refresh && await installDir.exists() && !request.isolated) { + _log(request.verbose, 'refresh requested, clearing ${installDir.path}'); + await installDir.delete(recursive: true); + } + await installDir.create(recursive: true); + + final sandbox = Directory(p.join(installDir.path, 'sandbox')); + await sandbox.create(recursive: true); + + try { + await _writeSourcePubspec( + sandbox, + packageName: packageName, + owner: owner, + repo: repo, + ref: ref, + gitPath: gitPath, + ); + + final pubGetCode = await processExecutor.run( + 'dart', + const ['pub', 'get'], + workingDirectory: sandbox.path, + runInShell: platform.isWindows, + ); + if (pubGetCode != 0) { + throw DrxException( + 'Failed to resolve Dart source dependencies for $owner/$repo.', + ); + } + + _log( + request.verbose, + 'executing dart run $packageName:$executable in ${sandbox.path}', + ); + return processExecutor.run( + 'dart', + ['run', '$packageName:$executable', ...request.args], + workingDirectory: sandbox.path, + runInShell: platform.isWindows, + ); + } finally { + if (request.isolated) { + await installDir.delete(recursive: true); + } + } + }); + } + + ({String? package, String executable}) _parseSourceCommand(String command) { + final index = command.indexOf(':'); + if (index < 0) { + return (package: null, executable: command); + } + + final packageName = command.substring(0, index).trim(); + final executable = command.substring(index + 1).trim(); + if (packageName.isEmpty || executable.isEmpty) { + throw DrxException( + 'Invalid source command "$command". Use or .', + ); + } + return (package: packageName, executable: executable); + } + + Future _resolveSourcePackageName({ + required String owner, + required String repo, + required String? ref, + required String? gitPath, + }) async { + final pubspecUri = _sourcePubspecUri( + owner: owner, + repo: repo, + ref: ref, + gitPath: gitPath, + ); + final bytes = await fetcher.fetch(pubspecUri); + final node = loadYaml(utf8.decode(bytes)); + if (node is! YamlMap) { + throw DrxException( + 'Failed to parse pubspec.yaml from $owner/$repo. ' + 'Use to specify the package explicitly.', + ); + } + + final name = node['name']; + if (name is! String || name.trim().isEmpty) { + throw DrxException( + 'pubspec.yaml in $owner/$repo has no valid package name. ' + 'Use to specify the package explicitly.', + ); + } + return name.trim(); + } + + Uri _sourcePubspecUri({ + required String owner, + required String repo, + required String? ref, + required String? gitPath, + }) { + final refKey = ref ?? 'HEAD'; + final normalizedPath = gitPath + ?.trim() + .replaceAll('\\', '/') + .replaceAll(RegExp(r'^/+'), '') + .replaceAll(RegExp(r'/+$'), ''); + final pathPrefix = normalizedPath == null || normalizedPath.isEmpty + ? '' + : '$normalizedPath/'; + return Uri.https( + 'raw.githubusercontent.com', + '$owner/$repo/$refKey/${pathPrefix}pubspec.yaml', + ); + } + + Future _writeSourcePubspec( + Directory sandbox, { + required String packageName, + required String owner, + required String repo, + required String? ref, + required String? gitPath, + }) async { + final pubspec = File(p.join(sandbox.path, 'pubspec.yaml')); + final normalizedPath = gitPath + ?.trim() + .replaceAll('\\', '/') + .replaceAll(RegExp(r'^/+'), '') + .replaceAll(RegExp(r'/+$'), ''); + + final content = StringBuffer() + ..writeln('name: drx_sandbox') + ..writeln('environment:') + ..writeln(" sdk: '>=3.0.0 <4.0.0'") + ..writeln('dependencies:') + ..writeln(' $packageName:') + ..writeln(' git:') + ..writeln(' url: https://github.com/$owner/$repo.git'); + if (ref != null) { + content.writeln(' ref: $ref'); + } + if (normalizedPath != null && normalizedPath.isNotEmpty) { + content.writeln(' path: $normalizedPath'); + } + + await pubspec.writeAsString(content.toString()); + } + + bool _shouldFallbackToSource(DrxException error) { + final message = error.message.toLowerCase(); + if (message.contains('no runnable assets found')) { + return true; + } + if (message.contains('no compatible asset found')) { + return true; + } + if (message.contains('/releases/latest') && message.contains('(404)')) { + return true; + } + if (message.contains('/releases/tags/') && message.contains('(404)')) { + return true; + } + return false; + } + GitHubAsset _selectAsset( GitHubRelease release, String command, diff --git a/lib/src/models.dart b/lib/src/models.dart index 23b43f7..2a0553b 100644 --- a/lib/src/models.dart +++ b/lib/src/models.dart @@ -4,6 +4,9 @@ enum SourceType { pub, gh } /// Runtime strategy for pub executables. enum RuntimeMode { auto, jit, aot } +/// Strategy for resolving `gh:` sources. +enum GhMode { binary, source, auto } + /// Parsed source selector and optional version/tag. final class SourceSpec { const SourceSpec({ @@ -28,6 +31,8 @@ final class CommandRequest { required this.isolated, required this.allowUnsigned, required this.verbose, + this.ghMode = GhMode.binary, + this.gitPath, this.asset, }); @@ -39,6 +44,8 @@ final class CommandRequest { final bool isolated; final bool allowUnsigned; final bool verbose; + final GhMode ghMode; + final String? gitPath; final String? asset; } diff --git a/test/cache_paths_test.dart b/test/cache_paths_test.dart index 7c70036..002bd3c 100644 --- a/test/cache_paths_test.dart +++ b/test/cache_paths_test.dart @@ -41,5 +41,28 @@ void main() { expect(first, isNot(third)); expect(first.length, 64); }); + + test('builds stable gh source cache path', () { + final paths = DrxPaths(Directory.systemTemp); + final platform = const HostPlatform(os: 'linux', arch: 'x64'); + + final first = paths.ghSourceDir( + 'leehack', + 'mcp_dart', + 'mcp_dart_cli-v0.1.6', + 'packages/mcp_dart_cli', + platform, + ); + final second = paths.ghSourceDir( + 'leehack', + 'mcp_dart', + 'mcp_dart_cli-v0.1.6', + 'packages/mcp_dart_cli', + platform, + ); + + expect(first.path, second.path); + expect(first.path, contains('${p.separator}gh-source${p.separator}')); + }); }); } diff --git a/test/cli_parser_test.dart b/test/cli_parser_test.dart index 09ad29e..21cdea4 100644 --- a/test/cli_parser_test.dart +++ b/test/cli_parser_test.dart @@ -107,6 +107,27 @@ void main() { expect(request.asset, 'tool-linux-x64.tar.gz'); }); + test('parses gh source mode flags', () { + final parsed = parser.parse([ + '--from', + 'gh:leehack/mcp_dart@mcp_dart_cli-v0.1.6', + '--gh-mode', + 'source', + '--git-path', + 'packages/mcp_dart_cli', + 'mcp_dart_cli:mcp_dart', + '--', + '--help', + ]); + final request = parsed.request!; + + expect(request.source.type, SourceType.gh); + expect(request.ghMode, GhMode.source); + expect(request.gitPath, 'packages/mcp_dart_cli'); + expect(request.command, 'mcp_dart_cli:mcp_dart'); + expect(request.args, ['--help']); + }); + test('returns help and version without requiring command', () { final help = parser.parse(['--help']); final version = parser.parse(['--version']); @@ -131,6 +152,26 @@ void main() { ); }); + test('throws when --gh-mode is used with pub source', () { + expect( + () => parser.parse(['--gh-mode', 'source', 'melos']), + throwsA(isA()), + ); + }); + + test('throws when --git-path is used with binary gh mode', () { + expect( + () => parser.parse([ + '--from', + 'gh:org/repo', + '--git-path', + 'packages/tool', + 'tool', + ]), + throwsA(isA()), + ); + }); + test('throws when gh shorthand command is missing', () { expect( () => parser.parse(['gh:cli/cli@v2.70.0']), diff --git a/test/github_runner_test.dart b/test/github_runner_test.dart index 9d28b9f..a48b3ce 100644 --- a/test/github_runner_test.dart +++ b/test/github_runner_test.dart @@ -282,5 +282,122 @@ void main() { expect(fakeExec.calls, hasLength(1)); }, ); + + test('resolves and runs Dart executable in gh source mode', () async { + final home = await Directory.systemTemp.createTemp('drx_gh_test_'); + addTearDown(() => home.delete(recursive: true)); + + final fakeExec = FakeProcessExecutor( + handler: (exe, args, {workingDirectory, runInShell = false}) async { + if (exe == 'dart' && args.length >= 2 && args[0] == 'pub') { + return 0; + } + if (exe == 'dart' && args.length >= 2 && args[0] == 'run') { + expect(args[1], 'mcp_dart_cli:mcp_dart'); + expect(args.skip(2), ['--help']); + return 0; + } + return 1; + }, + ); + + final runner = GitHubRunner( + paths: DrxPaths(home), + platform: const HostPlatform(os: 'linux', arch: 'x64'), + processExecutor: fakeExec, + api: FakeGitHubApi( + latest: const GitHubRelease(tag: 'v0', assets: []), + ), + fetcher: FakeByteFetcher(const {}), + ); + + final code = await runner.execute( + const CommandRequest( + source: SourceSpec( + type: SourceType.gh, + identifier: 'leehack/mcp_dart', + version: 'mcp_dart_cli-v0.1.6', + ), + command: 'mcp_dart_cli:mcp_dart', + args: ['--help'], + runtime: RuntimeMode.auto, + refresh: false, + isolated: false, + allowUnsigned: false, + verbose: false, + ghMode: GhMode.source, + gitPath: 'packages/mcp_dart_cli', + ), + ); + + expect(code, 0); + expect(fakeExec.calls, hasLength(2)); + expect(fakeExec.calls.first.executable, 'dart'); + expect(fakeExec.calls.first.arguments, ['pub', 'get']); + expect(fakeExec.calls.last.arguments, [ + 'run', + 'mcp_dart_cli:mcp_dart', + '--help', + ]); + }); + + test( + 'auto gh mode falls back to source when binaries are unavailable', + () async { + final home = await Directory.systemTemp.createTemp('drx_gh_test_'); + addTearDown(() => home.delete(recursive: true)); + + final fakeExec = FakeProcessExecutor( + handler: (exe, args, {workingDirectory, runInShell = false}) async { + if (exe == 'dart' && args.length >= 2 && args[0] == 'pub') { + return 0; + } + if (exe == 'dart' && args.length >= 2 && args[0] == 'run') { + expect(args[1], 'tool:tool'); + expect(args.skip(2), ['--version']); + return 0; + } + return 1; + }, + ); + + final runner = GitHubRunner( + paths: DrxPaths(home), + platform: const HostPlatform(os: 'linux', arch: 'x64'), + processExecutor: fakeExec, + api: FakeGitHubApi( + latest: const GitHubRelease(tag: 'v1.0.0', assets: []), + ), + fetcher: FakeByteFetcher({ + 'https://raw.githubusercontent.com/org/repo/HEAD/pubspec.yaml': utf8 + .encode('name: tool\n'), + }), + ); + + final code = await runner.execute( + const CommandRequest( + source: SourceSpec(type: SourceType.gh, identifier: 'org/repo'), + command: 'tool', + args: ['--version'], + runtime: RuntimeMode.auto, + refresh: false, + isolated: false, + allowUnsigned: false, + verbose: false, + ghMode: GhMode.auto, + ), + ); + + expect(code, 0); + expect(fakeExec.calls, hasLength(2)); + expect(fakeExec.calls.first.executable, 'dart'); + expect(fakeExec.calls.first.arguments, ['pub', 'get']); + expect(fakeExec.calls.last.arguments, [ + 'run', + 'tool:tool', + '--version', + ]); + }, + ); }); } From 79ee5b798a7961728090b704d0d5a7cda949c260 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 16 Feb 2026 08:23:03 -0500 Subject: [PATCH 2/5] feat: default gh mode to auto fallback Use auto as the default for gh sources so binary execution is attempted first and Dart source fallback is used when release assets are unavailable. --- CHANGELOG.md | 1 + README.md | 4 ++-- doc/compatibility.md | 4 ++-- lib/src/cli_parser.dart | 12 ++++++++++-- lib/src/engine.dart | 2 +- test/cli_parser_test.dart | 21 +++++++++++++++++++++ 6 files changed, 37 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0b909c..e285169 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ - Add `--gh-mode` (`binary|source|auto`) and `--git-path` to support running Dart CLIs directly from GitHub source. - Add source-mode execution for Dart GitHub repos via sandboxed git dependencies and `auto` fallback when release binaries are unavailable. +- Default `gh:` execution to `--gh-mode auto` (binary first, source fallback). ## 0.3.1 diff --git a/README.md b/README.md index e8f4aed..5614f94 100644 --- a/README.md +++ b/README.md @@ -103,9 +103,9 @@ drx --json cache list ## GitHub Mode Behavior -- `--gh-mode binary` (default): run precompiled release assets. +- `--gh-mode auto` (default for `gh:`): try precompiled release assets first, then fallback to source mode when no compatible binary is available. +- `--gh-mode binary`: run precompiled release assets only. - `--gh-mode source`: run Dart CLI from GitHub source via sandboxed `dart pub get` + `dart run`. -- `--gh-mode auto`: prefer release assets, fallback to source if no compatible binary is available. - `--git-path ` selects a package path inside a monorepo for source mode. ## Platform Support diff --git a/doc/compatibility.md b/doc/compatibility.md index abcd4a6..e9c2971 100644 --- a/doc/compatibility.md +++ b/doc/compatibility.md @@ -83,8 +83,8 @@ Use `--git-path ` when the Dart package is in a monorepo subdirectory. drx --gh-mode source --from gh:leehack/mcp_dart@mcp_dart_cli-v0.1.6 --git-path packages/mcp_dart_cli mcp_dart_cli:mcp_dart -- --help ``` -`--gh-mode auto` tries binary release assets first and falls back to source mode -when no compatible binary is available. +`--gh-mode auto` (default for `gh:`) tries binary release assets first and +falls back to source mode when no compatible binary is available. ### Requirements diff --git a/lib/src/cli_parser.dart b/lib/src/cli_parser.dart index fc7a835..e7af905 100644 --- a/lib/src/cli_parser.dart +++ b/lib/src/cli_parser.dart @@ -11,7 +11,8 @@ final class CliParser { RuntimeMode runtime = RuntimeMode.auto; var refresh = false; var isolated = false; - GhMode ghMode = GhMode.binary; + GhMode ghMode = GhMode.auto; + var ghModeExplicit = false; String? gitPath; String? asset; var allowUnsigned = false; @@ -64,6 +65,7 @@ final class CliParser { if (token.startsWith('--gh-mode=')) { ghMode = _parseGhMode(token.substring('--gh-mode='.length).trim()); + ghModeExplicit = true; i++; continue; } @@ -72,6 +74,7 @@ final class CliParser { throw const CliParseException('Missing value for --gh-mode.'); } ghMode = _parseGhMode(argv[i + 1].trim()); + ghModeExplicit = true; i += 2; continue; } @@ -181,12 +184,17 @@ final class CliParser { final commandArgs = _extractCommandArgs(commandArgsInput); final normalizedGitPath = gitPath?.trim(); - if (source.type != SourceType.gh && ghMode != GhMode.binary) { + if (source.type != SourceType.gh && ghModeExplicit) { throw const CliParseException('--gh-mode is only valid for gh source.'); } if (source.type != SourceType.gh && normalizedGitPath != null) { throw const CliParseException('--git-path is only valid for gh source.'); } + + if (source.type != SourceType.gh) { + ghMode = GhMode.binary; + } + if (source.type == SourceType.gh && ghMode == GhMode.binary && normalizedGitPath != null && diff --git a/lib/src/engine.dart b/lib/src/engine.dart index 8ac4194..c007403 100644 --- a/lib/src/engine.dart +++ b/lib/src/engine.dart @@ -469,7 +469,7 @@ Options: --runtime auto | jit | aot --refresh Refresh cached artifacts. --isolated Use isolated temporary environment. - --gh-mode binary | source | auto (gh source only) + --gh-mode binary | source | auto (gh source only; default: auto) --git-path Package path in GitHub monorepo (gh source mode). --asset Asset override for gh binary mode. --allow-unsigned Allow running unsigned gh assets. diff --git a/test/cli_parser_test.dart b/test/cli_parser_test.dart index 21cdea4..13d1cda 100644 --- a/test/cli_parser_test.dart +++ b/test/cli_parser_test.dart @@ -55,6 +55,7 @@ void main() { expect(request.source.type, SourceType.gh); expect(request.source.identifier, 'cli/cli'); expect(request.source.version, 'v2.70.0'); + expect(request.ghMode, GhMode.auto); expect(request.command, 'gh'); expect(request.args, ['version']); }); @@ -82,6 +83,7 @@ void main() { expect(request.source.type, SourceType.gh); expect(request.source.identifier, 'cli/cli'); expect(request.source.version, 'v2.70.0'); + expect(request.ghMode, GhMode.auto); expect(request.command, 'gh'); expect(request.args, ['version']); }); @@ -128,6 +130,23 @@ void main() { expect(request.args, ['--help']); }); + test('parses explicit gh binary mode', () { + final parsed = parser.parse([ + '--from', + 'gh:cli/cli@v2.70.0', + '--gh-mode', + 'binary', + 'gh', + 'version', + ]); + final request = parsed.request!; + + expect(request.source.type, SourceType.gh); + expect(request.ghMode, GhMode.binary); + expect(request.command, 'gh'); + expect(request.args, ['version']); + }); + test('returns help and version without requiring command', () { final help = parser.parse(['--help']); final version = parser.parse(['--version']); @@ -164,6 +183,8 @@ void main() { () => parser.parse([ '--from', 'gh:org/repo', + '--gh-mode', + 'binary', '--git-path', 'packages/tool', 'tool', From 8003bcb59978f43a6fb6451ea05ae8730d982a14 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 16 Feb 2026 09:03:35 -0500 Subject: [PATCH 3/5] feat: add AOT runtime for gh source mode Support --runtime aot for gh source execution and make --runtime auto try AOT first before falling back to JIT. --- CHANGELOG.md | 1 + README.md | 9 +- doc/compatibility.md | 3 + lib/src/engine.dart | 2 +- lib/src/github_runner.dart | 318 +++++++++++++++++++++++++++++++++-- test/github_runner_test.dart | 185 +++++++++++++++++++- 6 files changed, 495 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e285169..2aba6db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ - Add `--gh-mode` (`binary|source|auto`) and `--git-path` to support running Dart CLIs directly from GitHub source. - Add source-mode execution for Dart GitHub repos via sandboxed git dependencies and `auto` fallback when release binaries are unavailable. - Default `gh:` execution to `--gh-mode auto` (binary first, source fallback). +- Add AOT support for GitHub Dart source mode, with `--runtime auto` trying AOT first and falling back to JIT. ## 0.3.1 diff --git a/README.md b/README.md index 5614f94..49f222e 100644 --- a/README.md +++ b/README.md @@ -90,12 +90,15 @@ drx --json versions gh:cli/cli drx --json cache list ``` -## Runtime Modes (pub source) +## Runtime Modes (Dart source execution) -- `--runtime auto` (default): prefer AOT, fallback to JIT +- `--runtime auto` (default): try AOT first, fallback to JIT - `--runtime jit`: `dart run` - `--runtime aot`: compile and run native executable +These runtime modes apply to `pub:` and `gh:` when execution is from Dart source +(`--gh-mode source`, or `--gh-mode auto` when it falls back to source mode). + ## Security Defaults - GitHub assets require checksum verification by default. @@ -105,7 +108,7 @@ drx --json cache list - `--gh-mode auto` (default for `gh:`): try precompiled release assets first, then fallback to source mode when no compatible binary is available. - `--gh-mode binary`: run precompiled release assets only. -- `--gh-mode source`: run Dart CLI from GitHub source via sandboxed `dart pub get` + `dart run`. +- `--gh-mode source`: run Dart CLI from GitHub source via sandboxed `dart pub get` and runtime selection (`jit`/`aot`/`auto`). - `--git-path ` selects a package path inside a monorepo for source mode. ## Platform Support diff --git a/doc/compatibility.md b/doc/compatibility.md index e9c2971..4c1191e 100644 --- a/doc/compatibility.md +++ b/doc/compatibility.md @@ -86,6 +86,9 @@ drx --gh-mode source --from gh:leehack/mcp_dart@mcp_dart_cli-v0.1.6 --git-path p `--gh-mode auto` (default for `gh:`) tries binary release assets first and falls back to source mode when no compatible binary is available. +When running from source mode, `--runtime auto` tries AOT first and falls back +to JIT. You can also force `--runtime aot` or `--runtime jit`. + ### Requirements 1. Publish binaries on GitHub Releases. diff --git a/lib/src/engine.dart b/lib/src/engine.dart index c007403..de1c75f 100644 --- a/lib/src/engine.dart +++ b/lib/src/engine.dart @@ -466,7 +466,7 @@ Options: -h, --help Show this help. --version Show version. --from Source: pub:... or gh:... - --runtime auto | jit | aot + --runtime auto | jit | aot (for pub and gh source modes) --refresh Refresh cached artifacts. --isolated Use isolated temporary environment. --gh-mode binary | source | auto (gh source only; default: auto) diff --git a/lib/src/github_runner.dart b/lib/src/github_runner.dart index 7a74e21..93db13a 100644 --- a/lib/src/github_runner.dart +++ b/lib/src/github_runner.dart @@ -144,12 +144,6 @@ final class GitHubRunner { required String owner, required String repo, }) async { - if (request.runtime == RuntimeMode.aot) { - throw const DrxException( - 'AOT is not supported for --gh-mode source. Use --runtime jit or --runtime auto.', - ); - } - final ref = request.source.version; final gitPath = request.gitPath; final refKey = ref ?? 'default'; @@ -204,16 +198,46 @@ final class GitHubRunner { ); } - _log( - request.verbose, - 'executing dart run $packageName:$executable in ${sandbox.path}', - ); - return processExecutor.run( - 'dart', - ['run', '$packageName:$executable', ...request.args], - workingDirectory: sandbox.path, - runInShell: platform.isWindows, - ); + switch (request.runtime) { + case RuntimeMode.jit: + return _runSourceJit( + request, + sandbox, + packageName: packageName, + executable: executable, + ); + case RuntimeMode.aot: + final binary = await _ensureSourceAotBinary( + request, + installDir, + sandbox, + packageName: packageName, + executable: executable, + allowFallback: false, + ); + if (binary == null) { + throw const DrxException('AOT compile did not produce a binary.'); + } + return _runCompiled(binary, request.args); + case RuntimeMode.auto: + final binary = await _ensureSourceAotBinary( + request, + installDir, + sandbox, + packageName: packageName, + executable: executable, + allowFallback: true, + ); + if (binary != null) { + return _runCompiled(binary, request.args); + } + return _runSourceJit( + request, + sandbox, + packageName: packageName, + executable: executable, + ); + } } finally { if (request.isolated) { await installDir.delete(recursive: true); @@ -323,6 +347,266 @@ final class GitHubRunner { await pubspec.writeAsString(content.toString()); } + Future _runSourceJit( + CommandRequest request, + Directory sandbox, { + required String packageName, + required String executable, + }) { + _log( + request.verbose, + 'executing dart run $packageName:$executable in ${sandbox.path}', + ); + return processExecutor.run( + 'dart', + ['run', '$packageName:$executable', ...request.args], + workingDirectory: sandbox.path, + runInShell: platform.isWindows, + ); + } + + Future _runCompiled(String binaryPath, List args) { + return processExecutor.run( + binaryPath, + args, + runInShell: platform.isWindows && _isShellScript(binaryPath), + ); + } + + Future _ensureSourceAotBinary( + CommandRequest request, + Directory installDir, + Directory sandbox, { + required String packageName, + required String executable, + required bool allowFallback, + }) async { + final sdkVersion = Platform.version.split(' ').first; + final aotDir = Directory( + p.join(installDir.path, 'aot', sdkVersion, packageName), + ); + await aotDir.create(recursive: true); + + final binaryName = platform.isWindows ? '$executable.exe' : executable; + final binaryFile = File(p.join(aotDir.path, binaryName)); + + final entrypoint = await _resolveEntrypoint( + sandbox, + package: packageName, + command: executable, + ); + + if (await _usesCliLauncher(entrypoint)) { + if (allowFallback) { + _log( + request.verbose, + 'entrypoint uses cli_launcher, skipping AOT and falling back to JIT', + ); + return null; + } + throw DrxException( + 'AOT is not supported for $packageName:$executable ' + 'because its executable uses package:cli_launcher. Use --runtime jit.', + ); + } + + if (!request.refresh && await binaryFile.exists()) { + _log(request.verbose, 'reusing cached AOT binary ${binaryFile.path}'); + return binaryFile.path; + } + + _log(request.verbose, 'compiling AOT binary ${binaryFile.path}'); + final compileCode = await processExecutor.run( + 'dart', + [ + 'compile', + 'exe', + '--packages', + p.join(sandbox.path, _packageConfigRelativePath), + '--output', + binaryFile.path, + entrypoint, + ], + workingDirectory: sandbox.path, + runInShell: platform.isWindows, + ); + + if (compileCode != 0) { + if (allowFallback) { + _log(request.verbose, 'AOT compile failed, falling back to JIT'); + return null; + } + throw DrxException('AOT compile failed for $packageName:$executable.'); + } + + if (!await binaryFile.exists()) { + if (allowFallback) { + _log( + request.verbose, + 'AOT compile succeeded but no binary was produced, falling back to JIT', + ); + return null; + } + throw const DrxException('AOT compile did not produce a binary.'); + } + + return binaryFile.path; + } + + Future _resolveEntrypoint( + Directory sandbox, { + required String package, + required String command, + }) async { + final packageConfig = File( + p.join(sandbox.path, _packageConfigRelativePath), + ); + if (!await packageConfig.exists()) { + throw const DrxException( + 'Missing package config after pub get. Cannot compile AOT.', + ); + } + + final decoded = jsonDecode(await packageConfig.readAsString()); + if (decoded is! Map) { + throw const DrxException( + 'Invalid .dart_tool/package_config.json format.', + ); + } + + final packages = decoded['packages']; + if (packages is! List) { + throw const DrxException('Invalid package list in package_config.json.'); + } + + String? rootPath; + for (final node in packages) { + if (node is! Map) { + continue; + } + if (node['name'] == package) { + final rootUriRaw = node['rootUri']; + if (rootUriRaw is! String) { + break; + } + var rootUri = Uri.parse(rootUriRaw); + if (!rootUri.isAbsolute) { + rootUri = packageConfig.uri.resolve(rootUriRaw); + } + rootPath = p.normalize(p.fromUri(rootUri)); + break; + } + } + + if (rootPath == null) { + throw DrxException('Package "$package" not found in package config.'); + } + + final executableScript = await _resolveExecutableScript( + rootPath, + command: command, + ); + final entrypoint = File(p.join(rootPath, 'bin', '$executableScript.dart')); + if (!await entrypoint.exists()) { + final availableExecutables = await _readExecutableNames(rootPath); + final hint = availableExecutables.isEmpty + ? '' + : ' Available executables: ${availableExecutables.join(', ')}.'; + throw DrxException( + 'Executable "$command" not found in package "$package".$hint', + ); + } + return entrypoint.path; + } + + Future> _readExecutableNames(String packageRoot) async { + final names = {}; + + final pubspec = await _loadPubspec(packageRoot); + if (pubspec != null) { + final executablesNode = pubspec['executables']; + if (executablesNode is YamlMap) { + for (final key in executablesNode.keys) { + final name = key.toString(); + if (name.isNotEmpty) { + names.add(name); + } + } + } + } + + final binDir = Directory(p.join(packageRoot, 'bin')); + if (await binDir.exists()) { + await for (final entity in binDir.list(followLinks: false)) { + if (entity is! File) { + continue; + } + final fileName = p.basename(entity.path); + if (!fileName.endsWith('.dart')) { + continue; + } + final script = p.basenameWithoutExtension(fileName); + if (script.isNotEmpty) { + names.add(script); + } + } + } + + final result = names.toList(growable: false)..sort(); + return result; + } + + Future _resolveExecutableScript( + String packageRoot, { + required String command, + }) async { + final pubspecNode = await _loadPubspec(packageRoot); + if (pubspecNode == null) { + return command; + } + + final executablesNode = pubspecNode['executables']; + if (executablesNode is! YamlMap) { + return command; + } + + if (!executablesNode.containsKey(command)) { + return command; + } + + final rawValue = executablesNode[command]; + if (rawValue == null) { + return command; + } + + final value = rawValue.toString().trim(); + return value.isEmpty ? command : value; + } + + Future _loadPubspec(String packageRoot) async { + final pubspecFile = File(p.join(packageRoot, 'pubspec.yaml')); + if (!await pubspecFile.exists()) { + return null; + } + + final pubspecNode = loadYaml(await pubspecFile.readAsString()); + if (pubspecNode is! YamlMap) { + return null; + } + return pubspecNode; + } + + Future _usesCliLauncher(String entrypoint) async { + final file = File(entrypoint); + if (!await file.exists()) { + return false; + } + + final content = await file.readAsString(); + return content.contains('package:cli_launcher/cli_launcher.dart') || + content.contains('launchExecutable('); + } + bool _shouldFallbackToSource(DrxException error) { final message = error.message.toLowerCase(); if (message.contains('no runnable assets found')) { @@ -625,3 +909,5 @@ final class GitHubRunner { await Process.run('chmod', ['+x', filePath]); } } + +const _packageConfigRelativePath = '.dart_tool/package_config.json'; diff --git a/test/github_runner_test.dart b/test/github_runner_test.dart index a48b3ce..06a1941 100644 --- a/test/github_runner_test.dart +++ b/test/github_runner_test.dart @@ -9,6 +9,7 @@ import 'package:drx/src/github_api.dart'; import 'package:drx/src/github_runner.dart'; import 'package:drx/src/models.dart'; import 'package:drx/src/platform_info.dart'; +import 'package:path/path.dart' as p; import 'package:test/test.dart'; import 'test_fakes.dart'; @@ -283,7 +284,7 @@ void main() { }, ); - test('resolves and runs Dart executable in gh source mode', () async { + test('runs Dart executable in gh source mode with jit runtime', () async { final home = await Directory.systemTemp.createTemp('drx_gh_test_'); addTearDown(() => home.delete(recursive: true)); @@ -320,7 +321,7 @@ void main() { ), command: 'mcp_dart_cli:mcp_dart', args: ['--help'], - runtime: RuntimeMode.auto, + runtime: RuntimeMode.jit, refresh: false, isolated: false, allowUnsigned: false, @@ -341,6 +342,184 @@ void main() { ]); }); + test('compiles and runs AOT binary in gh source mode', () async { + final home = await Directory.systemTemp.createTemp('drx_gh_test_'); + addTearDown(() => home.delete(recursive: true)); + + final fakeExec = FakeProcessExecutor( + handler: (exe, args, {workingDirectory, runInShell = false}) async { + if (exe == 'dart' && args.length >= 2 && args[0] == 'pub') { + final sandbox = workingDirectory!; + final packageRoot = Directory(p.join(sandbox, 'tool_pkg')); + await packageRoot.create(recursive: true); + await File( + p.join(packageRoot.path, 'pubspec.yaml'), + ).writeAsString('name: tool\nexecutables:\n tool: launcher\n'); + await Directory( + p.join(packageRoot.path, 'bin'), + ).create(recursive: true); + await File( + p.join(packageRoot.path, 'bin', 'launcher.dart'), + ).writeAsString('void main(List args) {}\n'); + + final packageConfigFile = File( + p.join(sandbox, '.dart_tool', 'package_config.json'), + ); + await packageConfigFile.parent.create(recursive: true); + await packageConfigFile.writeAsString( + jsonEncode({ + 'configVersion': 2, + 'packages': [ + { + 'name': 'tool', + 'rootUri': '../tool_pkg/', + 'packageUri': 'lib/', + 'languageVersion': '3.0', + }, + ], + }), + ); + return 0; + } + + if (exe == 'dart' && args.length >= 2 && args[0] == 'compile') { + final outputIndex = args.indexOf('--output'); + final outputPath = args[outputIndex + 1]; + final outputFile = File(outputPath); + await outputFile.parent.create(recursive: true); + await outputFile.writeAsString('binary'); + return 0; + } + + if (p.basenameWithoutExtension(exe) == 'tool') { + expect(args, ['--version']); + return 0; + } + + return 1; + }, + ); + + final runner = GitHubRunner( + paths: DrxPaths(home), + platform: const HostPlatform(os: 'linux', arch: 'x64'), + processExecutor: fakeExec, + api: FakeGitHubApi( + latest: const GitHubRelease(tag: 'v0', assets: []), + ), + fetcher: FakeByteFetcher(const {}), + ); + + final code = await runner.execute( + const CommandRequest( + source: SourceSpec(type: SourceType.gh, identifier: 'org/repo'), + command: 'tool:tool', + args: ['--version'], + runtime: RuntimeMode.aot, + refresh: false, + isolated: false, + allowUnsigned: false, + verbose: false, + ghMode: GhMode.source, + ), + ); + + expect(code, 0); + expect(fakeExec.calls, hasLength(3)); + expect(fakeExec.calls[0].arguments, ['pub', 'get']); + expect(fakeExec.calls[1].arguments[0], 'compile'); + expect(fakeExec.calls[1].arguments[1], 'exe'); + expect(p.basenameWithoutExtension(fakeExec.calls[2].executable), 'tool'); + }); + + test( + 'runtime auto tries AOT then falls back to jit in gh source mode', + () async { + final home = await Directory.systemTemp.createTemp('drx_gh_test_'); + addTearDown(() => home.delete(recursive: true)); + + final fakeExec = FakeProcessExecutor( + handler: (exe, args, {workingDirectory, runInShell = false}) async { + if (exe == 'dart' && args.length >= 2 && args[0] == 'pub') { + final sandbox = workingDirectory!; + final packageRoot = Directory(p.join(sandbox, 'tool_pkg')); + await packageRoot.create(recursive: true); + await File( + p.join(packageRoot.path, 'pubspec.yaml'), + ).writeAsString('name: tool\n'); + await Directory( + p.join(packageRoot.path, 'bin'), + ).create(recursive: true); + await File( + p.join(packageRoot.path, 'bin', 'tool.dart'), + ).writeAsString('void main(List args) {}\n'); + + final packageConfigFile = File( + p.join(sandbox, '.dart_tool', 'package_config.json'), + ); + await packageConfigFile.parent.create(recursive: true); + await packageConfigFile.writeAsString( + jsonEncode({ + 'configVersion': 2, + 'packages': [ + { + 'name': 'tool', + 'rootUri': '../tool_pkg/', + 'packageUri': 'lib/', + 'languageVersion': '3.0', + }, + ], + }), + ); + return 0; + } + + if (exe == 'dart' && args.length >= 2 && args[0] == 'compile') { + return 1; + } + + if (exe == 'dart' && args.length >= 2 && args[0] == 'run') { + expect(args[1], 'tool:tool'); + expect(args.skip(2), ['--version']); + return 0; + } + + return 1; + }, + ); + + final runner = GitHubRunner( + paths: DrxPaths(home), + platform: const HostPlatform(os: 'linux', arch: 'x64'), + processExecutor: fakeExec, + api: FakeGitHubApi( + latest: const GitHubRelease(tag: 'v0', assets: []), + ), + fetcher: FakeByteFetcher(const {}), + ); + + final code = await runner.execute( + const CommandRequest( + source: SourceSpec(type: SourceType.gh, identifier: 'org/repo'), + command: 'tool:tool', + args: ['--version'], + runtime: RuntimeMode.auto, + refresh: false, + isolated: false, + allowUnsigned: false, + verbose: false, + ghMode: GhMode.source, + ), + ); + + expect(code, 0); + expect(fakeExec.calls, hasLength(3)); + expect(fakeExec.calls[0].arguments, ['pub', 'get']); + expect(fakeExec.calls[1].arguments[0], 'compile'); + expect(fakeExec.calls[2].arguments, ['run', 'tool:tool', '--version']); + }, + ); + test( 'auto gh mode falls back to source when binaries are unavailable', () async { @@ -379,7 +558,7 @@ void main() { source: SourceSpec(type: SourceType.gh, identifier: 'org/repo'), command: 'tool', args: ['--version'], - runtime: RuntimeMode.auto, + runtime: RuntimeMode.jit, refresh: false, isolated: false, allowUnsigned: false, From 4cd8574fdca112d551517a8e829dc7edb9d4e7ec Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 16 Feb 2026 09:20:31 -0500 Subject: [PATCH 4/5] test: add gh mode runtime integration matrix Cover gh-mode and runtime combinations through DrxEngine, including binary-first auto behavior, source AOT execution, and source auto fallback to JIT. --- doc/maintainers.md | 1 + test/gh_integration_test.dart | 363 ++++++++++++++++++++++++++++++++++ 2 files changed, 364 insertions(+) create mode 100644 test/gh_integration_test.dart diff --git a/doc/maintainers.md b/doc/maintainers.md index abf7f70..abf463b 100644 --- a/doc/maintainers.md +++ b/doc/maintainers.md @@ -22,6 +22,7 @@ dart run tool/check_coverage.dart 80 coverage/lcov.info - `/.github/workflows/ci.yml` - analyze + tests on Linux/macOS/Windows (`x64`, `arm64`) and coverage gate. + - includes GH mode/runtime integration matrix coverage (`test/gh_integration_test.dart`). - `/.github/workflows/installer-smoke.yml` - validates one-liner install scripts on Linux/macOS/Windows (`x64`, `arm64`). - `/.github/workflows/publish-pubdev.yml` diff --git a/test/gh_integration_test.dart b/test/gh_integration_test.dart new file mode 100644 index 0000000..9057c5b --- /dev/null +++ b/test/gh_integration_test.dart @@ -0,0 +1,363 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:drx/src/cache_paths.dart'; +import 'package:drx/src/checksum.dart'; +import 'package:drx/src/engine.dart'; +import 'package:drx/src/github_api.dart'; +import 'package:drx/src/platform_info.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +import 'test_fakes.dart'; + +void main() { + group('GH integration matrix', () { + test( + 'default gh-mode auto prefers binary when release asset is available', + () async { + final temp = await Directory.systemTemp.createTemp('drx_gh_int_'); + addTearDown(() => temp.delete(recursive: true)); + + final assetBytes = utf8.encode('binary'); + final checksum = sha256Hex(assetBytes); + final release = GitHubRelease( + tag: 'v1.0.0', + assets: const [ + GitHubAsset( + name: 'tool.exe', + downloadUrl: 'https://example/tool.exe', + ), + GitHubAsset( + name: 'SHA256SUMS', + downloadUrl: 'https://example/SHA256SUMS', + ), + ], + ); + + final fakeExec = FakeProcessExecutor( + handler: (exe, args, {workingDirectory, runInShell = false}) { + expect(exe.toLowerCase(), endsWith('.exe')); + expect(args, ['--version']); + return 0; + }, + ); + + final engine = DrxEngine( + paths: DrxPaths(temp), + platform: const HostPlatform(os: 'windows', arch: 'x64'), + processExecutor: fakeExec, + gitHubApi: FakeGitHubApi(byTag: {'v1.0.0': release}), + fetcher: FakeByteFetcher({ + 'https://example/tool.exe': assetBytes, + 'https://example/SHA256SUMS': utf8.encode('$checksum tool.exe\n'), + }), + ); + + final code = await engine.run([ + '--from', + 'gh:org/repo@v1.0.0', + 'tool', + '--', + '--version', + ]); + + expect(code, 0); + expect(fakeExec.calls, hasLength(1)); + }, + ); + + test('gh source runtime aot compiles and runs native binary', () async { + final temp = await Directory.systemTemp.createTemp('drx_gh_int_'); + addTearDown(() => temp.delete(recursive: true)); + + final fakeExec = FakeProcessExecutor( + handler: (exe, args, {workingDirectory, runInShell = false}) async { + if (exe == 'dart' && args.length >= 2 && args[0] == 'pub') { + await _seedResolvedPackage( + workingDirectory!, + packageName: 'tool', + scriptName: 'tool', + ); + return 0; + } + + if (exe == 'dart' && args.length >= 2 && args[0] == 'compile') { + final outputIndex = args.indexOf('--output'); + final outputPath = args[outputIndex + 1]; + final outputFile = File(outputPath); + await outputFile.parent.create(recursive: true); + await outputFile.writeAsString('binary'); + return 0; + } + + if (p.basenameWithoutExtension(exe) == 'tool') { + expect(args, ['--version']); + return 0; + } + + return 1; + }, + ); + + final engine = DrxEngine( + paths: DrxPaths(temp), + platform: const HostPlatform(os: 'linux', arch: 'x64'), + processExecutor: fakeExec, + gitHubApi: FakeGitHubApi( + latest: const GitHubRelease(tag: 'v0', assets: []), + ), + fetcher: FakeByteFetcher(const {}), + ); + + final code = await engine.run([ + '--gh-mode', + 'source', + '--runtime', + 'aot', + '--from', + 'gh:org/repo', + 'tool:tool', + '--', + '--version', + ]); + + expect(code, 0); + expect(fakeExec.calls, hasLength(3)); + expect(fakeExec.calls[0].arguments, ['pub', 'get']); + expect(fakeExec.calls[1].arguments[0], 'compile'); + expect(fakeExec.calls[1].arguments[1], 'exe'); + expect(p.basenameWithoutExtension(fakeExec.calls[2].executable), 'tool'); + }); + + test( + 'gh source runtime auto falls back to jit on compile failure', + () async { + final temp = await Directory.systemTemp.createTemp('drx_gh_int_'); + addTearDown(() => temp.delete(recursive: true)); + + final fakeExec = FakeProcessExecutor( + handler: (exe, args, {workingDirectory, runInShell = false}) async { + if (exe == 'dart' && args.length >= 2 && args[0] == 'pub') { + await _seedResolvedPackage( + workingDirectory!, + packageName: 'tool', + scriptName: 'tool', + ); + return 0; + } + + if (exe == 'dart' && args.length >= 2 && args[0] == 'compile') { + return 1; + } + + if (exe == 'dart' && args.length >= 2 && args[0] == 'run') { + expect(args, ['run', 'tool:tool', '--version']); + return 0; + } + + return 1; + }, + ); + + final engine = DrxEngine( + paths: DrxPaths(temp), + platform: const HostPlatform(os: 'linux', arch: 'x64'), + processExecutor: fakeExec, + gitHubApi: FakeGitHubApi( + latest: const GitHubRelease(tag: 'v0', assets: []), + ), + fetcher: FakeByteFetcher(const {}), + ); + + final code = await engine.run([ + '--gh-mode', + 'source', + '--runtime', + 'auto', + '--from', + 'gh:org/repo', + 'tool:tool', + '--', + '--version', + ]); + + expect(code, 0); + expect(fakeExec.calls, hasLength(3)); + expect(fakeExec.calls[0].arguments, ['pub', 'get']); + expect(fakeExec.calls[1].arguments[0], 'compile'); + expect(fakeExec.calls[2].arguments, ['run', 'tool:tool', '--version']); + }, + ); + + test( + 'gh source runtime auto falls back to jit for cli_launcher wrappers', + () async { + final temp = await Directory.systemTemp.createTemp('drx_gh_int_'); + addTearDown(() => temp.delete(recursive: true)); + + final fakeExec = FakeProcessExecutor( + handler: (exe, args, {workingDirectory, runInShell = false}) async { + if (exe == 'dart' && args.length >= 2 && args[0] == 'pub') { + await _seedResolvedPackage( + workingDirectory!, + packageName: 'tool', + scriptName: 'tool', + useCliLauncher: true, + ); + return 0; + } + + if (exe == 'dart' && args.length >= 2 && args[0] == 'run') { + expect(args, ['run', 'tool:tool', '--version']); + return 0; + } + + if (exe == 'dart' && args.length >= 2 && args[0] == 'compile') { + fail('compile should be skipped for cli_launcher wrappers'); + } + + return 1; + }, + ); + + final engine = DrxEngine( + paths: DrxPaths(temp), + platform: const HostPlatform(os: 'linux', arch: 'x64'), + processExecutor: fakeExec, + gitHubApi: FakeGitHubApi( + latest: const GitHubRelease(tag: 'v0', assets: []), + ), + fetcher: FakeByteFetcher(const {}), + ); + + final code = await engine.run([ + '--gh-mode', + 'source', + '--runtime', + 'auto', + '--from', + 'gh:org/repo', + 'tool:tool', + '--', + '--version', + ]); + + expect(code, 0); + expect(fakeExec.calls, hasLength(2)); + expect(fakeExec.calls[0].arguments, ['pub', 'get']); + expect(fakeExec.calls[1].arguments, ['run', 'tool:tool', '--version']); + }, + ); + + test( + 'default gh-mode auto falls back to source and uses runtime auto strategy', + () async { + final temp = await Directory.systemTemp.createTemp('drx_gh_int_'); + addTearDown(() => temp.delete(recursive: true)); + + final fakeExec = FakeProcessExecutor( + handler: (exe, args, {workingDirectory, runInShell = false}) async { + if (exe == 'dart' && args.length >= 2 && args[0] == 'pub') { + await _seedResolvedPackage( + workingDirectory!, + packageName: 'tool', + scriptName: 'tool', + ); + return 0; + } + + if (exe == 'dart' && args.length >= 2 && args[0] == 'compile') { + final outputIndex = args.indexOf('--output'); + final outputPath = args[outputIndex + 1]; + final outputFile = File(outputPath); + await outputFile.parent.create(recursive: true); + await outputFile.writeAsString('binary'); + return 0; + } + + if (p.basenameWithoutExtension(exe) == 'tool') { + expect(args, ['--version']); + return 0; + } + + return 1; + }, + ); + + final engine = DrxEngine( + paths: DrxPaths(temp), + platform: const HostPlatform(os: 'linux', arch: 'x64'), + processExecutor: fakeExec, + gitHubApi: FakeGitHubApi( + latest: const GitHubRelease(tag: 'v1.0.0', assets: []), + ), + fetcher: FakeByteFetcher({ + 'https://raw.githubusercontent.com/org/repo/HEAD/pubspec.yaml': utf8 + .encode('name: tool\n'), + }), + ); + + final code = await engine.run([ + '--runtime', + 'auto', + '--from', + 'gh:org/repo', + 'tool', + '--', + '--version', + ]); + + expect(code, 0); + expect(fakeExec.calls, hasLength(3)); + expect(fakeExec.calls[0].arguments, ['pub', 'get']); + expect(fakeExec.calls[1].arguments[0], 'compile'); + expect( + p.basenameWithoutExtension(fakeExec.calls[2].executable), + 'tool', + ); + }, + ); + }); +} + +Future _seedResolvedPackage( + String sandboxPath, { + required String packageName, + required String scriptName, + bool useCliLauncher = false, +}) async { + final packageDirName = '${packageName}_pkg'; + final packageRoot = Directory(p.join(sandboxPath, packageDirName)); + await packageRoot.create(recursive: true); + + await File( + p.join(packageRoot.path, 'pubspec.yaml'), + ).writeAsString('name: $packageName\n'); + + final binDir = Directory(p.join(packageRoot.path, 'bin')); + await binDir.create(recursive: true); + final entrypoint = useCliLauncher + ? "import 'package:cli_launcher/cli_launcher.dart';\n" + 'Future main(List args) async => launchExecutable();\n' + : 'void main(List args) {}\n'; + await File(p.join(binDir.path, '$scriptName.dart')).writeAsString(entrypoint); + + final packageConfig = File( + p.join(sandboxPath, '.dart_tool', 'package_config.json'), + ); + await packageConfig.parent.create(recursive: true); + await packageConfig.writeAsString( + jsonEncode({ + 'configVersion': 2, + 'packages': [ + { + 'name': packageName, + 'rootUri': '../$packageDirName/', + 'packageUri': 'lib/', + 'languageVersion': '3.0', + }, + ], + }), + ); +} From 5083e4e829bb86ce9b2fcb39a7e0dc42b371362b Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 16 Feb 2026 09:28:04 -0500 Subject: [PATCH 5/5] test: add manual live GitHub smoke lane Introduce opt-in live-network smoke tests for binary and source fallback paths, and wire a workflow_dispatch job to run them in CI. --- .github/workflows/live-gh-smoke.yml | 23 +++++++++ doc/maintainers.md | 12 +++++ test/gh_live_smoke_test.dart | 77 +++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+) create mode 100644 .github/workflows/live-gh-smoke.yml create mode 100644 test/gh_live_smoke_test.dart diff --git a/.github/workflows/live-gh-smoke.yml b/.github/workflows/live-gh-smoke.yml new file mode 100644 index 0000000..b98b7fe --- /dev/null +++ b/.github/workflows/live-gh-smoke.yml @@ -0,0 +1,23 @@ +name: Live GH Smoke + +on: + workflow_dispatch: + +jobs: + live-gh-smoke: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v4 + + - name: Set up Dart + uses: dart-lang/setup-dart@v1 + + - name: Install dependencies + run: dart pub get + + - name: Run live GitHub smoke tests + env: + DRX_ENABLE_LIVE_GH_TESTS: '1' + run: dart test test/gh_live_smoke_test.dart -r expanded diff --git a/doc/maintainers.md b/doc/maintainers.md index abf463b..426c20d 100644 --- a/doc/maintainers.md +++ b/doc/maintainers.md @@ -25,6 +25,8 @@ dart run tool/check_coverage.dart 80 coverage/lcov.info - includes GH mode/runtime integration matrix coverage (`test/gh_integration_test.dart`). - `/.github/workflows/installer-smoke.yml` - validates one-liner install scripts on Linux/macOS/Windows (`x64`, `arm64`). +- `/.github/workflows/live-gh-smoke.yml` + - manual live-network smoke tests against real GitHub repos. - `/.github/workflows/publish-pubdev.yml` - publishes to pub.dev when a tag like `vX.Y.Z` is pushed. - `/.github/workflows/release-binaries.yml` @@ -75,3 +77,13 @@ without changing the package version. - Installer scripts support `DRX_DOWNLOAD_BASE` override for local/test payloads. - This is used by installer smoke workflow to test the one-liner flow end-to-end without depending on external GitHub release artifacts. + +## Live GitHub Smoke Tests + +- `test/gh_live_smoke_test.dart` is disabled by default and only runs when + `DRX_ENABLE_LIVE_GH_TESTS=1`. +- Run locally: + +```bash +DRX_ENABLE_LIVE_GH_TESTS=1 dart test test/gh_live_smoke_test.dart +``` diff --git a/test/gh_live_smoke_test.dart b/test/gh_live_smoke_test.dart new file mode 100644 index 0000000..7930636 --- /dev/null +++ b/test/gh_live_smoke_test.dart @@ -0,0 +1,77 @@ +import 'dart:io'; + +import 'package:drx/src/cache_paths.dart'; +import 'package:drx/src/engine.dart'; +import 'package:drx/src/platform_info.dart'; +import 'package:test/test.dart'; + +const _liveEnvKey = 'DRX_ENABLE_LIVE_GH_TESTS'; + +void main() { + final liveEnabled = Platform.environment[_liveEnvKey] == '1'; + final skipReason = liveEnabled + ? false + : 'Set $_liveEnvKey=1 to run live GitHub smoke tests.'; + + group('Live GitHub smoke', () { + late Directory home; + + setUpAll(() async { + home = await Directory.systemTemp.createTemp('drx_gh_live_'); + }); + + tearDownAll(() async { + if (await home.exists()) { + await home.delete(recursive: true); + } + }); + + test( + 'default gh-mode auto uses release binary when available', + () async { + final engine = DrxEngine( + paths: DrxPaths(home), + platform: HostPlatform.detect(), + ); + + final code = await engine.run([ + '--from', + 'gh:BurntSushi/ripgrep@14.1.1', + 'rg', + '--', + '--version', + ]); + + expect(code, 0); + }, + skip: skipReason, + timeout: const Timeout(Duration(minutes: 5)), + ); + + test( + 'default gh-mode auto falls back to source and runs runtime auto', + () async { + final engine = DrxEngine( + paths: DrxPaths(home), + platform: HostPlatform.detect(), + ); + + final code = await engine.run([ + '--runtime', + 'auto', + '--from', + 'gh:leehack/mcp_dart@mcp_dart_cli-v0.1.6', + '--git-path', + 'packages/mcp_dart_cli', + 'mcp_dart', + '--', + '--help', + ]); + + expect(code, 0); + }, + skip: skipReason, + timeout: const Timeout(Duration(minutes: 8)), + ); + }); +}