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/CHANGELOG.md b/CHANGELOG.md index 9b2b608..2aba6db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## 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. +- 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 - Add installer one-liner smoke tests across Linux/macOS/Windows on `x64` and `arm64`. diff --git a/README.md b/README.md index 0c10603..49f222e 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 ``` @@ -83,17 +90,27 @@ 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. - Unsigned assets are blocked unless you pass `--allow-unsigned`. +## GitHub Mode Behavior + +- `--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` and runtime selection (`jit`/`aot`/`auto`). +- `--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..4c1191e 100644 --- a/doc/compatibility.md +++ b/doc/compatibility.md @@ -68,6 +68,27 @@ 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` (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/doc/maintainers.md b/doc/maintainers.md index abf7f70..426c20d 100644 --- a/doc/maintainers.md +++ b/doc/maintainers.md @@ -22,8 +22,11 @@ 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/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` @@ -74,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/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..e7af905 100644 --- a/lib/src/cli_parser.dart +++ b/lib/src/cli_parser.dart @@ -11,6 +11,9 @@ final class CliParser { RuntimeMode runtime = RuntimeMode.auto; var refresh = false; var isolated = false; + GhMode ghMode = GhMode.auto; + var ghModeExplicit = false; + String? gitPath; String? asset; var allowUnsigned = false; var verbose = false; @@ -60,6 +63,36 @@ final class CliParser { continue; } + if (token.startsWith('--gh-mode=')) { + ghMode = _parseGhMode(token.substring('--gh-mode='.length).trim()); + ghModeExplicit = true; + 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()); + ghModeExplicit = true; + 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 +182,30 @@ final class CliParser { } final commandArgs = _extractCommandArgs(commandArgsInput); + final normalizedGitPath = gitPath?.trim(); + + 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 && + 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 +216,8 @@ final class CliParser { isolated: isolated, allowUnsigned: allowUnsigned, verbose: verbose, + ghMode: ghMode, + gitPath: normalizedGitPath, asset: asset?.isEmpty == true ? null : asset, ); @@ -180,6 +239,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..de1c75f 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] @@ -465,10 +466,12 @@ 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. - --asset Asset override for gh source. + --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. --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..93db13a 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,491 @@ final class GitHubRunner { }); } + Future _executeSource( + CommandRequest request, { + required String owner, + required String repo, + }) async { + 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.', + ); + } + + 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); + } + } + }); + } + + ({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()); + } + + 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')) { + 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, @@ -396,3 +909,5 @@ final class GitHubRunner { await Process.run('chmod', ['+x', filePath]); } } + +const _packageConfigRelativePath = '.dart_tool/package_config.json'; 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..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']); }); @@ -107,6 +109,44 @@ 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('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']); @@ -131,6 +171,28 @@ 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', + '--gh-mode', + 'binary', + '--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/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', + }, + ], + }), + ); +} 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)), + ); + }); +} diff --git a/test/github_runner_test.dart b/test/github_runner_test.dart index 9d28b9f..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'; @@ -282,5 +283,300 @@ void main() { expect(fakeExec.calls, hasLength(1)); }, ); + + 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)); + + 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.jit, + 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('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 { + 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.jit, + 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', + ]); + }, + ); }); }