diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml index 1c3ecfe1e4..0ad8454b47 100644 --- a/.github/workflows/autofix.yml +++ b/.github/workflows/autofix.yml @@ -42,6 +42,7 @@ jobs: ~/.cache/mise - run: mise x wait-for-gh-rate-limit -- wait-for-gh-rate-limit - run: mise install + - run: mise lock --force - run: mise x -- bun i - run: mise run render - run: mise run lint-fix diff --git a/e2e/backend/test_aqua b/e2e/backend/test_aqua index 330f7ecd4d..bb3c073231 100644 --- a/e2e/backend/test_aqua +++ b/e2e/backend/test_aqua @@ -18,3 +18,21 @@ assert_contains "MISE_USE_VERSIONS_HOST=0 mise ls-remote aqua:sharkdp/hyperfine" 1.9.0 1.10.0 1.11.0" + +# Test Aqua backend with mise lock +export MISE_LOCKFILE=1 + +cat <mise.toml +[tools] +"age" = "1.2.0" +EOF + +touch mise.lock +mise install +mise lock --force --platforms macos-arm64 +assert_contains "cat mise.lock" '[[tools.age]]' +assert_contains "cat mise.lock" 'version = "1.2.0"' +assert_contains "cat mise.lock" 'backend = "aqua:FiloSottile/age"' +assert_contains "cat mise.lock" 'url = "https://github.com/FiloSottile/age/releases' +assert_contains "cat mise.lock" 'size = 4516837' +assert_contains "cat mise.lock" 'checksum = "blake3:a54d750d8599612d69bc9499b32a549506bfa1e122a051f31754b9ec4d94fc44"' diff --git a/e2e/backend/test_github_tools b/e2e/backend/test_github_tools index bd713a3cd1..1ee23a75e2 100755 --- a/e2e/backend/test_github_tools +++ b/e2e/backend/test_github_tools @@ -8,3 +8,21 @@ assert_contains "mise x -- rg --version" "ripgrep 14.1.0" assert "mise use github:nats-io/natscli@0.2.3" assert "mise which nats" assert_contains "mise x -- nats --version" "0.2.3" + +# Test GitHub backend with mise lock +export MISE_LOCKFILE=1 +export MISE_EXPERIMENTAL=1 + +cat <mise.toml +[tools] +"github:BurntSushi/ripgrep" = "14.1.0" +EOF + +touch mise.lock +mise install +mise lock --force --platforms macos-arm64 +assert_contains "cat mise.lock" '[[tools."github:BurntSushi/ripgrep"]]' +assert_contains "cat mise.lock" 'version = "14.1.0"' +assert_contains "cat mise.lock" 'backend = "github:BurntSushi/ripgrep"' +assert_contains "cat mise.lock" 'url = "https://github.com/BurntSushi/ripgrep/releases' +assert_contains "cat mise.lock" 'platforms.macos-arm64' diff --git a/e2e/cli/test_lock b/e2e/cli/test_lock index 908ab626f5..ee1db6dc64 100755 --- a/e2e/cli/test_lock +++ b/e2e/cli/test_lock @@ -3,143 +3,102 @@ export MISE_LOCKFILE=1 export MISE_EXPERIMENTAL=1 -echo "=== Testing basic lock command with no lockfiles ===" -# Test basic lock command with no lockfiles -assert_contains "mise lock" "No lockfile found, would create" -assert_contains "mise lock" "full implementation coming in next phase" +echo "=== Testing basic lock command with no tools ===" +# Test basic lock command with no tools configured +assert_contains "mise lock" "No tools found to process" -echo "=== Testing basic lock command with simple lockfile ===" -# Create a basic lockfile and corresponding toml file for testing +echo "=== Testing dry-run with configured tools ===" +# Create a basic toml file with tools cat <mise.toml [tools] tiny = "1.0.0" dummy = "2.0.0" EOF -cat <mise.lock -[tools.tiny] -version = "1.0.0" -backend = "asdf:tiny" +# Test dry-run mode with tools configured +assert_contains "mise lock --dry-run" "Targeting 1 platform(s)" +assert_contains "mise lock --dry-run" "Dry run - showing what would be processed" +assert_contains "mise lock --dry-run" "tiny" +assert_contains "mise lock --dry-run" "dummy" -[tools.dummy] -version = "2.0.0" -backend = "core:dummy" -EOF +echo "=== Testing lockfile generation ===" +# Test actual lockfile generation (will create mise.lock) +assert_contains "mise lock" "Targeting 1 platform(s)" +assert_contains "mise lock" "Lockfile updated at" + +# Verify the lockfile was created with new multi-version format +assert "test -f mise.lock" "" +assert_contains "cat mise.lock" "[[tools.tiny]]" +assert_contains "cat mise.lock" "[[tools.dummy]]" +assert_contains "cat mise.lock" "backend =" +assert_contains "cat mise.lock" "version =" -# Test basic lock analysis -assert_contains "mise lock" "Found lockfile" -assert_contains "mise lock" "Tools: dummy, tiny" -assert_contains "mise lock" "No platform data found" +echo "=== Testing tool filtering ===" +# Test filtering by specific tool (dry-run to avoid modifying) +assert_contains "mise lock tiny --dry-run" "tiny" +assert_not_contains "mise lock tiny --dry-run" "dummy" -# Test dry-run mode with no platforms -assert_contains "mise lock --dry-run" "Found lockfile" -assert_contains "mise lock --dry-run" "Tools: dummy, tiny" -assert_contains "mise lock --dry-run" "No platform data found" +echo "=== Testing platform filtering ===" +# Test filtering by specific platform +assert_contains "mise lock --platforms linux-x64" "Targeting 1 platform(s): linux-x64" +assert_contains "mise lock --platforms macos-arm64" "Targeting 1 platform(s): macos-arm64" -echo "=== Testing lockfile with platform data ===" -# Create lockfile with platform-specific data +# Test multiple platform filtering +assert_contains "mise lock --platforms linux-x64,macos-arm64" "Targeting 2 platform(s): linux-x64, macos-arm64" + +echo "=== Testing force flag ===" +# Test force flag - should regenerate lockfile even when it doesn't exist +rm -f mise.lock +assert_contains "mise lock --force" "Lockfile updated at" + +# Create an existing lockfile with outdated content (missing dummy tool) cat <mise.lock -[tools.tiny] -version = "1.0.0" -backend = "asdf:tiny" - -[tools.tiny.platforms.linux-x64] -checksum = "sha256:abc123" -size = 1024 -url = "https://example.com/tiny-1.0.0-linux-x64.tar.gz" - -[tools.tiny.platforms.macos-arm64] -checksum = "sha256:def456" -size = 2048 -url = "https://example.com/tiny-1.0.0-macos-arm64.tar.gz" - -[tools.dummy] -version = "2.0.0" -backend = "core:dummy" - -[tools.dummy.platforms.linux-x64] -checksum = "sha256:ghi789" -size = 4096 -url = "https://example.com/dummy-2.0.0-linux-x64.tar.gz" +[tools] +tiny = [{ backend = "asdf:tiny", version = "1.0.0", platforms = { "linux-x64" = { checksum = "sha256:old_checksum", size = 999, url = "https://example.com/old-url" } } }] EOF -# Test platform detection -assert_contains "mise lock" "Platforms: linux-x64, macos-arm64" -assert_contains "mise lock" "Would update 2 tool(s) for 2 platform(s)" +# Verify the old lockfile only contains tiny, not dummy +assert_contains "cat mise.lock" "tiny" +assert_not_contains "cat mise.lock" "dummy" +assert_not_contains "cat mise.lock" "[[tools.tiny]]" +assert_not_contains "cat mise.lock" "[[tools.dummy]]" -echo "=== Testing dry-run with detailed output ===" -# Test detailed dry-run output -output=$(mise lock --dry-run) -assert_contains "echo '$output'" "✓ tiny for linux-x64" -assert_contains "echo '$output'" "✓ tiny for macos-arm64" -assert_contains "echo '$output'" "✓ dummy for linux-x64" +# Test that --force regenerates existing lockfile with proper content +assert_contains "mise lock --force" "Lockfile updated at" -echo "=== Testing tool filtering ===" -# Test filtering by specific tool -assert_contains "mise lock tiny" "Would update 1 tool(s) for 2 platform(s)" -assert_contains "mise lock dummy" "Would update 1 tool(s) for 2 platform(s)" +# Verify the lockfile was actually regenerated with both tools from mise.toml +assert_contains "cat mise.lock" "[[tools.tiny]]" +assert_contains "cat mise.lock" "[[tools.dummy]]" +# Verify it's using the new format, not the old one +assert_not_contains "cat mise.lock" "old_checksum" -# Test multiple tool filtering -assert_contains "mise lock tiny dummy" "Would update 2 tool(s) for 2 platform(s)" +echo "=== Testing existing lockfile handling ===" +# Test that when lockfile exists, we extract platforms from it +cat <mise.lock +[tools] +tiny = [{ backend = "asdf:tiny", version = "1.0.0", platforms = { "linux-x64" = { checksum = "sha256:abc123", size = 1024, url = "https://example.com/tiny-1.0.0-linux-x64.tar.gz" } } }] +dummy = [{ backend = "core:dummy", version = "2.0.0" }] +EOF -# Test non-existent tool filtering - when no matching tools, no update line is shown -assert_not_contains "mise lock nonexistent" "Would update" +# When lockfile exists and no platform specified, should extract from lockfile +assert_contains "mise lock --dry-run" "Targeting 1 platform(s): linux-x64" -echo "=== Testing platform filtering ===" -# Test filtering by specific platform -assert_contains "mise lock --platform linux-x64" "Would update 2 tool(s) for 1 platform(s)" -assert_contains "mise lock --platform macos-arm64" "Would update 2 tool(s) for 1 platform(s)" +echo "=== Testing aqua cargo-binstall digest resolution ===" +# Ensure that when using aqua for cargo-binstall we resolve GitHub digest (sha256) not fallback blake3 +cat <mise.toml +[tools] +cargo-binstall = "1.15.3" +EOF -# Test multiple platform filtering -assert_contains "mise lock --platform linux-x64,macos-arm64" "Would update 2 tool(s) for 2 platform(s)" - -# Test non-existent platform filtering - when no matching platforms, no update line is shown -assert_not_contains "mise lock --platform windows-x64" "Would update" - -echo "=== Testing combined filtering ===" -# Test tool + platform filtering -assert_contains "mise lock tiny --platform linux-x64" "Would update 1 tool(s) for 1 platform(s)" -assert_contains "mise lock dummy --platform macos-arm64" "Would update 1 tool(s) for 1 platform(s)" - -# Test dry-run with filtering -output=$(mise lock tiny --platform linux-x64 --dry-run) -assert_contains "echo '$output'" "✓ tiny for linux-x64" -assert_not_contains "echo '$output'" "✓ tiny for macos-arm64" -assert_not_contains "echo '$output'" "✓ dummy for linux-x64" - -echo "=== Testing flag combinations ===" -# Test force flag (should still work in analysis mode) -assert_contains "mise lock --force" "Would update 2 tool(s) for 2 platform(s)" - -# Test jobs flag -assert_contains "mise lock --jobs 2" "Would update 2 tool(s) for 2 platform(s)" - -echo "=== Testing local config focus ===" -# The lock command now focuses on just the current config root -# Verify it works correctly with the local lockfile -assert_contains "mise lock" "Found lockfile" - -echo "=== Testing error cases ===" -# Test invalid tool argument - should still show analysis but with no updates -assert_not_contains "mise lock 'invalid@version'" "Would update" - -echo "=== Testing lockfile preservation ===" -# Verify that running lock command doesn't modify lockfiles (in current phase) -if command -v sha256sum >/dev/null; then - checksum_before=$(sha256sum mise.lock | cut -d' ' -f1) - mise lock >/dev/null 2>&1 - checksum_after=$(sha256sum mise.lock | cut -d' ' -f1) -elif command -v shasum >/dev/null; then - checksum_before=$(shasum -a 256 mise.lock | cut -d' ' -f1) - mise lock >/dev/null 2>&1 - checksum_after=$(shasum -a 256 mise.lock | cut -d' ' -f1) -else - # Skip checksum test if neither command is available - echo "Skipping checksum test - neither sha256sum nor shasum available" - checksum_before="test" - checksum_after="test" -fi -assert "echo $checksum_before" "$checksum_after" +rm -f mise.lock +assert_contains "mise lock -f" "Lockfile updated at" + +assert_contains "cat mise.lock" "[[tools.cargo-binstall]]" +assert_contains "cat mise.lock" 'backend = "aqua:cargo-bins/cargo-binstall"' +# Expect sha256 algorithm (from GitHub API), not blake3 fallback +assert_contains "cat mise.lock" 'checksum = "sha256:' +assert_not_contains "cat mise.lock" 'checksum = "blake3:' +rm -f mise.toml mise.lock echo "=== Testing help and version info ===" # Test that help works diff --git a/e2e/cli/test_lock_creation b/e2e/cli/test_lock_creation index f13da18760..c23d3df87c 100755 --- a/e2e/cli/test_lock_creation +++ b/e2e/cli/test_lock_creation @@ -12,50 +12,38 @@ dummy = "2.0.0" EOF echo "=== Testing lockfile creation use case ===" -# Test when no lockfiles exist but mise.toml exists -assert_contains "mise lock" "No lockfile found, would create" -assert_contains "mise lock" "mise.lock" +# Test when no lockfiles exist but mise.toml exists - should create lockfile +assert_contains "mise lock" "Targeting 1 platform(s)" +assert_contains "mise lock" "Lockfile updated at mise.lock" -# Should detect the missing lockfile and show what would be created -assert_contains "mise lock" "No lockfile found, would create" -assert_contains "mise lock" "mise.lock" -assert_contains "mise lock" "Would create lockfile with 2 tool(s): tiny, dummy" -assert_contains "mise lock" "Would initialize 2 tool(s) in new lockfile" +# Verify the lockfile was created +assert "test -f mise.lock" "" echo "=== Testing dry-run mode for lockfile creation ===" -# Test detailed dry-run output +# Remove lockfile and test dry-run +rm -f mise.lock output=$(mise lock --dry-run) -assert_contains "echo '$output'" "✓ tiny (new lockfile)" -assert_contains "echo '$output'" "✓ dummy (new lockfile)" +assert_contains "echo '$output'" "Dry run - showing what would be processed" +assert_contains "echo '$output'" "tiny" +assert_contains "echo '$output'" "dummy" echo "=== Testing tool filtering for lockfile creation ===" # Test filtering by specific tool -assert_contains "mise lock tiny" "Would initialize 1 tool(s) in new lockfile" output=$(mise lock tiny --dry-run) -assert_contains "echo '$output'" "✓ tiny (new lockfile)" -assert_not_contains "echo '$output'" "✓ dummy (new lockfile)" +assert_contains "echo '$output'" "tiny" +assert_not_contains "echo '$output'" "dummy" -# Test filtering by multiple tools -assert_contains "mise lock tiny dummy" "Would initialize 2 tool(s) in new lockfile" +# Test actual creation with filtering +rm -f mise.lock +assert_contains "mise lock tiny" "Lockfile updated at mise.lock" -# Test non-existent tool filtering -assert_not_contains "mise lock nonexistent" "Would initialize" - -echo "=== Testing transition from creation to existing ===" -# Create a lockfile - now it should show as existing instead of missing -cat <mise.lock -[tools.tiny] -version = "1.0.0" -backend = "asdf:tiny" -EOF - -# Should now show existing lockfile instead of missing -assert_contains "mise lock" "Found lockfile" -assert_not_contains "mise lock" "No lockfile found, would create" +echo "=== Testing lockfile regeneration ===" +# Test that existing lockfile gets updated +assert_contains "mise lock" "Lockfile updated at mise.lock" echo "=== Testing platform filtering with existing lockfile ===" # Platform filtering should work with existing lockfile -assert_contains "mise lock --platform linux-x64" "Would update" +assert_contains "mise lock --platforms linux-x64" "Targeting 1 platform(s): linux-x64" echo "=== Testing help for creation ===" # Help should mention both updating and creating diff --git a/e2e/cli/test_lock_future b/e2e/cli/test_lock_future index 9d618befc8..eceb4d6504 100755 --- a/e2e/cli/test_lock_future +++ b/e2e/cli/test_lock_future @@ -44,11 +44,11 @@ cat mise.lock # - Checksums are updated # - URLs are current # - All existing platforms are refreshed -# - New platforms can be added via --platform flag +# - New platforms can be added via --platforms flag echo "=== Testing platform addition (future) ===" # This should add a new platform to the lockfile -mise lock --platform macos-arm64 +mise lock --platforms macos-arm64 echo "Lockfile with new platform:" cat mise.lock diff --git a/e2e/core/test_bun b/e2e/core/test_bun index 21709f5a9f..e7b6a23f08 100644 --- a/e2e/core/test_bun +++ b/e2e/core/test_bun @@ -1,5 +1,8 @@ #!/usr/bin/env bash +export MISE_LOCKFILE=1 +export MISE_EXPERIMENTAL=1 + cat <.bun-version 1.1.21 EOF @@ -9,3 +12,43 @@ assert_contains "mise x bun -- bun -v" "1.1.21" require_cmd node assert_contains 'mise x bun -- bunx cowsay "hello world"' "hello world" + +echo "=== Testing multi-platform lockfile generation for Bun ===" +# Test generating lockfile for multiple platforms (single call) +assert_contains "mise lock bun --platforms linux-x64,macos-arm64,windows-x64" "Targeting 3 platform(s): linux-x64, macos-arm64, windows-x64" +assert_contains "mise lock bun --platforms linux-x64,macos-arm64,windows-x64" "Lockfile updated at" + +# Verify the lockfile exists and contains platform-specific data for all 3 platforms +assert "test -f mise.lock" "" +assert_contains "cat mise.lock" "linux-x64" +assert_contains "cat mise.lock" "macos-arm64" +assert_contains "cat mise.lock" "windows-x64" + +# Verify URLs are platform-specific for Bun (GitHub releases) +assert_contains "cat mise.lock" "bun-linux-x64.zip" +assert_contains "cat mise.lock" "bun-darwin-aarch64.zip" +assert_contains "cat mise.lock" "bun-windows-x64.zip" + +# Verify the basic lockfile structure +assert_contains "cat mise.lock" 'backend = "core:bun"' +assert_contains "cat mise.lock" 'version = "1.1.21"' +assert_contains "cat mise.lock" 'url = "https://github.com/oven-sh/bun/releases' + +echo "=== Validating lockfile metadata ===" +# Validate exact sizes and checksums for each platform +echo "Validating exact platform metadata" + +# Linux x64 platform validation +assert_contains "cat mise.lock" "size = 34153617" +assert_contains "cat mise.lock" 'checksum = "blake3:1d5cbae518a45a59aa4429708779dde0b465624ecdaea9d6824dcda645edbdcb"' +assert_contains "cat mise.lock" "bun-linux-x64.zip" + +# macOS ARM64 platform validation +assert_contains "cat mise.lock" "size = 18244938" +assert_contains "cat mise.lock" 'checksum = "blake3:0dc09c220944a16e0edcaf2ee4cbc9ca4ce40a5a117002bdb491c98fe64173ef"' +assert_contains "cat mise.lock" "bun-darwin-aarch64.zip" + +# Windows x64 platform validation +assert_contains "cat mise.lock" "size = 37349456" +assert_contains "cat mise.lock" 'checksum = "blake3:bfc442f36392b59aa616dd58d213296a1275943b6ba549b2dcd8638d44eb06c3"' +assert_contains "cat mise.lock" "bun-windows-x64.zip" diff --git a/e2e/core/test_deno b/e2e/core/test_deno index c5a73241ab..a69a39f01f 100644 --- a/e2e/core/test_deno +++ b/e2e/core/test_deno @@ -1,5 +1,8 @@ #!/usr/bin/env bash +export MISE_LOCKFILE=1 +export MISE_EXPERIMENTAL=1 + if [[ ${MISE_DISABLE_TOOLS:-} == *deno* ]]; then warn "Skipping deno tests" exit 0 @@ -11,3 +14,48 @@ EOF mise i deno assert_contains "mise x deno -- deno -V" "deno 1.43.3" + +echo "=== Testing multi-platform lockfile generation for Deno ===" +# Test generating lockfile for multiple platforms (single call) +assert_contains "mise lock deno --platforms linux-x64,macos-arm64,windows-x64" "Targeting 3 platform(s): linux-x64, macos-arm64, windows-x64" +assert_contains "mise lock deno --platforms linux-x64,macos-arm64,windows-x64" "Lockfile updated at" + +# Verify the lockfile exists and contains platform-specific data for all 3 platforms +assert "test -f mise.lock" "" +assert_contains "cat mise.lock" "linux-x64" +assert_contains "cat mise.lock" "macos-arm64" +assert_contains "cat mise.lock" "windows-x64" + +# Verify URLs are platform-specific for Deno (uses Deno releases) +assert_contains "cat mise.lock" "dl.deno.land/release" +assert_contains "cat mise.lock" "deno-x86_64-unknown-linux-gnu.zip" +assert_contains "cat mise.lock" "deno-aarch64-apple-darwin.zip" +assert_contains "cat mise.lock" "deno-x86_64-pc-windows-msvc.zip" + +# Verify the basic lockfile structure +assert_contains "cat mise.lock" 'backend = "core:deno"' +assert_contains "cat mise.lock" 'version = "1.43.3"' + +echo "=== Validating lockfile metadata ===" +# Extract and validate specific platform metadata +lockfile_content=$(cat mise.lock) + +# Verify each platform has the correct URL pattern +assert_contains "echo '$lockfile_content'" "deno-x86_64-unknown-linux-gnu.zip" +assert_contains "echo '$lockfile_content'" "deno-aarch64-apple-darwin.zip" +assert_contains "echo '$lockfile_content'" "deno-x86_64-pc-windows-msvc.zip" + +# Validate URLs are complete and properly formatted +linux_url=$(echo "$lockfile_content" | grep -A5 "linux-x64" | grep "url = " | grep -o 'https://[^"]*') +assert_contains "echo '$linux_url'" "https://dl.deno.land/release/v1.43.3/deno-x86_64-unknown-linux-gnu.zip" + +macos_url=$(echo "$lockfile_content" | grep -A5 "macos-arm64" | grep "url = " | grep -o 'https://[^"]*') +assert_contains "echo '$macos_url'" "https://dl.deno.land/release/v1.43.3/deno-aarch64-apple-darwin.zip" + +windows_url=$(echo "$lockfile_content" | grep -A5 "windows-x64" | grep "url = " | grep -o 'https://[^"]*') +assert_contains "echo '$windows_url'" "https://dl.deno.land/release/v1.43.3/deno-x86_64-pc-windows-msvc.zip" + +# Checksums and sizes are required - fail if missing +echo "Verifying checksums and sizes are present in lockfile" +assert_contains "cat mise.lock" "checksum = " +assert_contains "cat mise.lock" "size = " diff --git a/e2e/core/test_go b/e2e/core/test_go index 517110f69e..36a983e19a 100644 --- a/e2e/core/test_go +++ b/e2e/core/test_go @@ -1,5 +1,7 @@ #!/usr/bin/env bash +export MISE_LOCKFILE=1 +export MISE_EXPERIMENTAL=1 export MISE_GO_DEFAULT_PACKAGES_FILE="$HOME/.default-go-packages" cat >"$MISE_GO_DEFAULT_PACKAGES_FILE" <.node-version @@ -19,6 +21,68 @@ mise use nodejs@20.1.0 mise ls assert "mise x -- node --version" "v20.1.0" assert_contains "mise ls-remote nodejs" "20.1.0" + +echo "=== Testing multi-platform lockfile generation for Node.js ===" +# Test generating lockfile for multiple platforms (single call) +assert_contains "mise lock nodejs --platforms linux-x64,macos-arm64,windows-x64" "Targeting 3 platform(s): linux-x64, macos-arm64, windows-x64" +assert_contains "mise lock nodejs --platforms linux-x64,macos-arm64,windows-x64" "Lockfile updated at" + +# Verify the lockfile exists and contains platform-specific data for all 3 platforms +assert "test -f mise.lock" "" +assert_contains "cat mise.lock" "linux-x64" +assert_contains "cat mise.lock" "macos-arm64" +assert_contains "cat mise.lock" "windows-x64" + +# Verify URLs are platform-specific for Node.js (uses official Node.js mirrors) +assert_contains "cat mise.lock" "nodejs.org/dist" +assert_contains "cat mise.lock" "linux-x64.tar.gz" +assert_contains "cat mise.lock" "darwin-arm64.tar.gz" +assert_contains "cat mise.lock" "win-x64.zip" + +# Verify the basic lockfile structure +assert_contains "cat mise.lock" 'backend = "core:node"' +assert_contains "cat mise.lock" 'version = "20.1.0"' + +echo "=== Validating lockfile metadata ===" +# Extract and validate specific platform metadata +lockfile_content=$(cat mise.lock) + +# Verify each platform has the correct URL pattern +assert_contains "echo '$lockfile_content'" "node-v20.1.0-linux-x64.tar.gz" +assert_contains "echo '$lockfile_content'" "node-v20.1.0-darwin-arm64.tar.gz" +assert_contains "echo '$lockfile_content'" "node-v20.1.0-win-x64.zip" + +# Validate Linux platform metadata +linux_section=$(echo "$lockfile_content" | grep -A5 "linux-x64") +assert_contains "echo '$linux_section'" "node-v20.1.0-linux-x64.tar.gz" +assert_contains "echo '$linux_section'" "nodejs.org/dist" + +# Validate macOS platform metadata +macos_section=$(echo "$lockfile_content" | grep -A5 "macos-arm64") +assert_contains "echo '$macos_section'" "node-v20.1.0-darwin-arm64.tar.gz" +assert_contains "echo '$macos_section'" "nodejs.org/dist" + +# Validate Windows platform metadata +windows_section=$(echo "$lockfile_content" | grep -A5 "windows-x64") +assert_contains "echo '$windows_section'" "node-v20.1.0-win-x64.zip" +assert_contains "echo '$windows_section'" "nodejs.org/dist" + +# Node.js doesn't typically provide sizes/checksums via API, so we expect URL-only metadata +# Verify URLs are complete and properly formatted +linux_url=$(echo "$lockfile_content" | grep -A5 "linux-x64" | grep "url = " | grep -o 'https://[^"]*') +assert_contains "echo '$linux_url'" "https://nodejs.org/dist/v20.1.0/node-v20.1.0-linux-x64.tar.gz" + +macos_url=$(echo "$lockfile_content" | grep -A5 "macos-arm64" | grep "url = " | grep -o 'https://[^"]*') +assert_contains "echo '$macos_url'" "https://nodejs.org/dist/v20.1.0/node-v20.1.0-darwin-arm64.tar.gz" + +windows_url=$(echo "$lockfile_content" | grep -A5 "windows-x64" | grep "url = " | grep -o 'https://[^"]*') +assert_contains "echo '$windows_url'" "https://nodejs.org/dist/v20.1.0/node-v20.1.0-win-x64.zip" + +# Checksums and sizes are required - fail if missing +echo "Verifying checksums and sizes are present in lockfile" +assert_contains "cat mise.lock" "checksum = " +assert_contains "cat mise.lock" "size = " + mise use --rm node # MISE_LEGACY_VERSION_FILE env var diff --git a/e2e/core/test_zig b/e2e/core/test_zig index aac6bf3e06..32f9fb2587 100644 --- a/e2e/core/test_zig +++ b/e2e/core/test_zig @@ -1,6 +1,55 @@ #!/usr/bin/env bash +export MISE_LOCKFILE=1 +export MISE_EXPERIMENTAL=1 + assert "mise x zig@0.13.0 -- zig version" "0.13.0" assert "mise x zig@master -- zig version" assert "mise x zig@2024.11.0-mach -- zig version" "0.14.0-dev.2577+271452d22" assert "mise x zig@mach-latest -- zig version" + +echo "=== Testing multi-platform lockfile generation for Zig ===" +# Test generating lockfile for multiple platforms (single call) +mise use zig@0.13.0 +assert_contains "mise lock zig --platforms linux-x64,macos-arm64,windows-x64" "Targeting 3 platform(s): linux-x64, macos-arm64, windows-x64" +assert_contains "mise lock zig --platforms linux-x64,macos-arm64,windows-x64" "Lockfile updated at" + +# Verify the lockfile exists and contains platform-specific data for all 3 platforms +assert "test -f mise.lock" "" +assert_contains "cat mise.lock" "linux-x64" +assert_contains "cat mise.lock" "macos-arm64" +assert_contains "cat mise.lock" "windows-x64" + +# Verify URLs are platform-specific for Zig (uses ziglang.org) +assert_contains "cat mise.lock" "ziglang.org/download" +assert_contains "cat mise.lock" "zig-linux-x86_64-0.13.0.tar.xz" +assert_contains "cat mise.lock" "zig-macos-aarch64-0.13.0.tar.xz" +assert_contains "cat mise.lock" "zig-windows-x86_64-0.13.0.zip" + +# Verify the basic lockfile structure +assert_contains "cat mise.lock" 'backend = "core:zig"' +assert_contains "cat mise.lock" 'version = "0.13.0"' + +echo "=== Validating lockfile metadata ===" +# Extract and validate specific platform metadata +lockfile_content=$(cat mise.lock) + +# Verify each platform has the correct URL pattern +assert_contains "echo '$lockfile_content'" "zig-linux-x86_64-0.13.0.tar.xz" +assert_contains "echo '$lockfile_content'" "zig-macos-aarch64-0.13.0.tar.xz" +assert_contains "echo '$lockfile_content'" "zig-windows-x86_64-0.13.0.zip" + +# Validate URLs are complete and properly formatted +linux_url=$(echo "$lockfile_content" | grep -A5 "linux-x64" | grep "url = " | grep -o 'https://[^"]*') +assert_contains "echo '$linux_url'" "zig-linux-x86_64-0.13.0.tar.xz" + +macos_url=$(echo "$lockfile_content" | grep -A5 "macos-arm64" | grep "url = " | grep -o 'https://[^"]*') +assert_contains "echo '$macos_url'" "zig-macos-aarch64-0.13.0.tar.xz" + +windows_url=$(echo "$lockfile_content" | grep -A5 "windows-x64" | grep "url = " | grep -o 'https://[^"]*') +assert_contains "echo '$windows_url'" "zig-windows-x86_64-0.13.0.zip" + +# Checksums and sizes are required - fail if missing +echo "Verifying checksums and sizes are present in lockfile" +assert_contains "cat mise.lock" "checksum = " +assert_contains "cat mise.lock" "size = " diff --git a/mise.lock b/mise.lock index 3f5039b9ec..1e1b6e76a0 100644 --- a/mise.lock +++ b/mise.lock @@ -3,12 +3,12 @@ version = "1.7.7" backend = "aqua:rhysd/actionlint" [tools.actionlint.platforms.linux-x64] -checksum = "sha256:023070a287cd8cccd71515fedc843f1985bf96c436b7effaecce67290e7e0757" +checksum = "blake3:c2510efe17d0c09a62c25907f6e3e6af1bf62869e2f5d81bedf7e78b5517a1dd" size = 2080472 url = "https://github.com/rhysd/actionlint/releases/download/v1.7.7/actionlint_1.7.7_linux_amd64.tar.gz" [tools.actionlint.platforms.macos-arm64] -checksum = "sha256:2693315b9093aeacb4ebd91a993fea54fc215057bf0da2659056b4bc033873db" +checksum = "blake3:845d95d47b6e76952f91a7c093dbfe439e22eb6fb5f81b553036e9254d7bbb31" size = 1962532 url = "https://github.com/rhysd/actionlint/releases/download/v1.7.7/actionlint_1.7.7_darwin_arm64.tar.gz" @@ -31,26 +31,28 @@ version = "1.2.21" backend = "core:bun" [tools.bun.platforms.linux-x64] -checksum = "blake3:cf519ce71d7c518e211001f428ae34c2ec2a4f0484787bdf28baf7f372c94860" -size = 39128668 +checksum = "sha256:594f454d51ce57199d4320c85cbd495be9c054ef17aaebca5e6c908abfda6179" +size = 39290791 +url = "https://github.com/oven-sh/bun/releases/download/bun-v1.2.21/bun-linux-x64.zip" [tools.bun.platforms.macos-arm64] -checksum = "blake3:b5824ab4bf0afba1d27d55d4cbec1696c3d1070f6982cbf6b4fa0489892ec931" +checksum = "sha256:fd886630ba15c484236ad5f3f22b255d287c3eef8d3bc26fc809851035c04cec" size = 22056420 +url = "https://github.com/oven-sh/bun/releases/download/bun-v1.2.21/bun-darwin-aarch64.zip" [[tools.cargo-binstall]] version = "1.15.3" backend = "aqua:cargo-bins/cargo-binstall" [tools.cargo-binstall.platforms.linux-x64] -checksum = "blake3:b6100a915a4531a7ea7e1d1d87a9e70ce4afc980d7e3bd420521a90b29bdc7de" -size = 6772799 -url = "https://github.com/cargo-bins/cargo-binstall/releases/download/v1.15.3/cargo-binstall-x86_64-unknown-linux-musl.tgz" +checksum = "sha256:0998345fd26577fc7aa8ce886d2a168a93dfb42690c57d31f82252a650d5b838" +size = 7319212 +url = "https://github.com/cargo-bins/cargo-binstall/releases/download/v1.15.3/cargo-binstall-x86_64-unknown-linux-gnu.full.tgz" [tools.cargo-binstall.platforms.macos-arm64] -checksum = "blake3:8fb84239a9f54c0107faa2cf6ac12c658572b943356e1291c4cf39f34af6ceaf" -size = 6004746 -url = "https://github.com/cargo-bins/cargo-binstall/releases/download/v1.15.3/cargo-binstall-aarch64-apple-darwin.zip" +checksum = "sha256:f2cb89c8865bd6b1cfd4b43381fec41ef12e091f35222919f0e438e79c982850" +size = 6403659 +url = "https://github.com/cargo-bins/cargo-binstall/releases/download/v1.15.3/cargo-binstall-aarch64-apple-darwin.full.zip" [[tools."cargo:cargo-edit"]] version = "0.13.7" @@ -95,12 +97,12 @@ version = "2.62.0" backend = "aqua:cli/cli" [tools.gh.platforms.linux-x64] -checksum = "sha256:41c8b0698ad3003cb5c44bde672a1ffd5f818595abd80162fbf8cc999418446a" +checksum = "blake3:638adec2a014820d3a372e0c52b570202417cf20b4f04c5a2c7e84a0c1080276" size = 13065800 url = "https://github.com/cli/cli/releases/download/v2.62.0/gh_2.62.0_linux_amd64.tar.gz" [tools.gh.platforms.macos-arm64] -checksum = "sha256:fdb77f31b8a6dd23c3fd858758d692a45f7fc76383e37d475bdcae038df92afc" +checksum = "blake3:0ec40c9e6a944210058b8c06c30bc43617ccfcb498a66c6a27bad068876a894a" size = 12793347 url = "https://github.com/cli/cli/releases/download/v2.62.0/gh_2.62.0_macOS_arm64.zip" @@ -109,28 +111,32 @@ version = "1.10.7" backend = "aqua:jdx/hk" [tools.hk.platforms.linux-x64] -checksum = "blake3:426758a535d7e359bcd1e9f2f598a8aa01e518587a8141fc3b33f17617dfdfae" +checksum = "sha256:67740e439f09ba98280c9e0119e05264b086a481bf751953cebfae5602e871a4" size = 6873815 url = "https://github.com/jdx/hk/releases/download/v1.10.7/hk-x86_64-unknown-linux-gnu.tar.gz" [tools.hk.platforms.macos-arm64] -checksum = "blake3:2990ec4745178df124c26dcef643a14d362fe8ca24759feba4bdf61e71bf4a63" +checksum = "sha256:863cd22fdfaa8afe16b0fb954aac03393ad5488465f30e48e970c095523ed2e4" size = 5928348 url = "https://github.com/jdx/hk/releases/download/v1.10.7/hk-aarch64-apple-darwin.tar.gz" +[[tools.java]] +version = "24.0.2" +backend = "core:java" + [[tools.jq]] version = "1.8.1" backend = "aqua:jqlang/jq" [tools.jq.platforms.linux-x64] -checksum = "sha256:020468de7539ce70ef1bceaf7cde2e8c4f2ca6c3afb84642aabc5c97d9fc2a0d" -size = 2255816 -url = "https://github.com/jqlang/jq/releases/download/jq-1.8.1/jq-linux-amd64" +checksum = "sha256:2be64e7129cecb11d5906290eba10af694fb9e3e7f9fc208a311dc33ca837eb0" +size = 2026798 +url = "https://github.com/jqlang/jq/releases/download/jq-1.8.1/jq-1.8.1.tar.gz" [tools.jq.platforms.macos-arm64] -checksum = "sha256:a9fe3ea2f86dfc72f6728417521ec9067b343277152b114f4e98d8cb0e263603" -size = 841408 -url = "https://github.com/jqlang/jq/releases/download/jq-1.8.1/jq-macos-arm64" +checksum = "sha256:2be64e7129cecb11d5906290eba10af694fb9e3e7f9fc208a311dc33ca837eb0" +size = 2026798 +url = "https://github.com/jqlang/jq/releases/download/jq-1.8.1/jq-1.8.1.tar.gz" [[tools."npm:markdownlint-cli"]] version = "0.45.0" @@ -140,6 +146,20 @@ backend = "npm:markdownlint-cli" version = "3.6.2" backend = "npm:prettier" +[[tools.opentofu]] +version = "1.10.3" +backend = "aqua:opentofu/opentofu" + +[tools.opentofu.platforms.linux-x64] +checksum = "sha256:610464b31c6c5c9a6e1f282995117111b98464f337e2df1bb0a9958fee5e82bd" +size = 26706372 +url = "https://github.com/opentofu/opentofu/releases/download/v1.10.3/tofu_1.10.3_linux_amd64.tar.gz" + +[tools.opentofu.platforms.macos-arm64] +checksum = "sha256:4f7390e7f18e5a56988037001547ca38beee349c395df524997bacf7235fa3bb" +size = 25581258 +url = "https://github.com/opentofu/opentofu/releases/download/v1.10.3/tofu_1.10.3_darwin_arm64.tar.gz" + [[tools."pipx:toml-sort"]] version = "0.24.2" backend = "pipx:toml-sort" @@ -149,12 +169,12 @@ version = "0.29.1" backend = "aqua:apple/pkl" [tools.pkl.platforms.linux-x64] -checksum = "blake3:8b26122653f2b25453211286c68f96eb53373763dc743bf8ff6c2c80917848e2" -size = 103994144 -url = "https://github.com/apple/pkl/releases/download/0.29.1/pkl-linux-amd64" +checksum = "sha256:00578659c50711233212eb9271a68e109ed31b06fb9a9702f9e2730c81672a51" +size = 104120120 +url = "https://github.com/apple/pkl/releases/download/0.29.1/pkl-alpine-linux-amd64" [tools.pkl.platforms.macos-arm64] -checksum = "blake3:5160399295c75f15f6b2b1e2925945a5d42a66793aa4eec821ab2dc60a5ae4ea" +checksum = "sha256:75ca92e3eee7746e22b0f8a55bf1ee5c3ea0a78eec14586cd5618a9195707d5c" size = 104874656 url = "https://github.com/apple/pkl/releases/download/0.29.1/pkl-macos-aarch64" @@ -162,69 +182,47 @@ url = "https://github.com/apple/pkl/releases/download/0.29.1/pkl-macos-aarch64" version = "4.3.0" backend = "aqua:pre-commit/pre-commit" -[tools.pre-commit.platforms.linux-x64] -checksum = "sha256:f1d50b97e9ca9167aceb76c14e90b07cde8b6789bc199d5005cfd817a718878c" -size = 8268268 -url = "https://github.com/pre-commit/pre-commit/releases/download/v4.3.0/pre-commit-4.3.0.pyz" - -[tools.pre-commit.platforms.macos-arm64] -checksum = "sha256:f1d50b97e9ca9167aceb76c14e90b07cde8b6789bc199d5005cfd817a718878c" -size = 8268268 -url = "https://github.com/pre-commit/pre-commit/releases/download/v4.3.0/pre-commit-4.3.0.pyz" - [[tools.ripgrep]] version = "14.1.1" backend = "aqua:BurntSushi/ripgrep" [tools.ripgrep.platforms.linux-x64] -checksum = "sha256:4cf9f2741e6c465ffdb7c26f38056a59e2a2544b51f7cc128ef28337eeae4d8e" +checksum = "blake3:f73cca4e54d78c31f832c7f6e2c0b4db8b04fa3eaa747915727d570893dbee76" size = 2566310 url = "https://github.com/BurntSushi/ripgrep/releases/download/14.1.1/ripgrep-14.1.1-x86_64-unknown-linux-musl.tar.gz" [tools.ripgrep.platforms.macos-arm64] -checksum = "sha256:24ad76777745fbff131c8fbc466742b011f925bfa4fffa2ded6def23b5b937be" +checksum = "blake3:8d9942032585ea8ee805937634238d9aee7b210069f4703c88fbe568e26fb78a" size = 1787248 url = "https://github.com/BurntSushi/ripgrep/releases/download/14.1.1/ripgrep-14.1.1-aarch64-apple-darwin.tar.gz" +[[tools.ruby]] +version = "3.4.5" +backend = "core:ruby" + [[tools.shellcheck]] version = "0.11.0" backend = "aqua:koalaman/shellcheck" -[tools.shellcheck.platforms.linux-x64] -checksum = "blake3:0ad9524f3f16ad030f8350e7b970c62c24aeff3d813f2565665aecc5a8b79644" -size = 2559196 -url = "https://github.com/koalaman/shellcheck/releases/download/v0.11.0/shellcheck-v0.11.0.linux.x86_64.tar.xz" - [[tools.shfmt]] version = "3.12.0" backend = "aqua:mvdan/sh" -[tools.shfmt.platforms.linux-x64] -checksum = "sha256:d9fbb2a9c33d13f47e7618cf362a914d029d02a6df124064fff04fd688a745ea" -size = 2916536 -url = "https://github.com/mvdan/sh/releases/download/v3.12.0/shfmt_v3.12.0_linux_amd64" - [[tools.slsa-verifier]] version = "2.7.1" backend = "ubi:slsa-framework/slsa-verifier" -[tools.slsa-verifier.platforms.linux-x64-slsa-verifier] -checksum = "blake3:6b7c72ece3e3cbd9db12dd8e261e594bb24721db140692d4f5cbdf508df45156" - -[tools.slsa-verifier.platforms.macos-arm64-slsa-verifier] -checksum = "blake3:af93f77462964b7eeb93b9f45ccedeefc641305f6618d9ffc2480d89b0cceed6" - [[tools.sops]] version = "3.10.2" backend = "aqua:getsops/sops" [tools.sops.platforms.linux-x64] -checksum = "sha256:79b0f844237bd4b0446e4dc884dbc1765fc7dedc3968f743d5949c6f2e701739" +checksum = "blake3:b1b617fdeafca8a7ad87c4c2a216bf8ac0dfa36eea9fac597e3df0014b2b39f8" size = 45011128 url = "https://github.com/getsops/sops/releases/download/v3.10.2/sops-v3.10.2.linux.amd64" [tools.sops.platforms.macos-arm64] -checksum = "sha256:99702df79737162b986641afb8d98251acb16a52e6cab556a6b6f57c608c059a" +checksum = "blake3:c5f1ef7caf102f7f395733caaefcb8fd5aff2286cb32e3a0f30f71cbaed8946b" size = 44246082 url = "https://github.com/getsops/sops/releases/download/v3.10.2/sops-v3.10.2.darwin.arm64" @@ -233,16 +231,51 @@ version = "0.10.0" backend = "aqua:tamasfe/taplo" [tools.taplo.platforms.linux-x64] -checksum = "blake3:4871fab0e60275a1eb46e7190726e144f56c9a9527f59b0d1da5a042baead8e2" -size = 5116068 -url = "https://github.com/tamasfe/taplo/releases/download/0.10.0/taplo-linux-x86_64.gz" +checksum = "blake3:2e8452e361c735169a8ce92d36b22e451e3504a8d996e7603f9840dddffb93a7" +size = 5182591 +url = "https://github.com/tamasfe/taplo/releases/download/0.10.0/taplo-windows-x86_64.zip" + +[tools.taplo.platforms.macos-arm64] +checksum = "blake3:5df1e4b9a50b371e6537a93dee09b30e0bf287031e0ab4e6d3f4832ef5834ab9" +size = 4810289 +url = "https://github.com/tamasfe/taplo/releases/download/0.10.0/taplo-windows-aarch64.zip" + +[[tools.terraform]] +version = "1.12.2" +backend = "aqua:hashicorp/terraform" [[tools.wait-for-gh-rate-limit]] version = "1.0.0" backend = "ubi:jdx/wait-for-gh-rate-limit" -[tools.wait-for-gh-rate-limit.platforms.linux-x64-wait-for-gh-rate-limit] -checksum = "blake3:2123971d2eea236d17fb0475c94bef89adf9239df4a8da53261d2eaf8551c622" - -[tools.wait-for-gh-rate-limit.platforms.macos-arm64-wait-for-gh-rate-limit] -checksum = "blake3:8feb249767c436b69fd9bcb56901fdc56713c09247e9f6c1ce67f55b613bc082" +[[tools.watchexec]] +version = "2.3.2" +backend = "aqua:watchexec/watchexec" + +[tools.watchexec.platforms.linux-x64] +checksum = "sha512:ec2dadefbbfad9ba738a6f27ead78214e90db4d5bbf7eb2f4d8dac3cf18f468f900fe51d4ba107b3f2c0733c412c28e8c977f158968515c8b7f03f3c8391982a" +size = 2899576 +url = "https://github.com/watchexec/watchexec/releases/download/v2.3.2/watchexec-2.3.2-x86_64-unknown-linux-gnu.tar.xz" + +[tools.watchexec.platforms.macos-arm64] +checksum = "sha512:88ea43af48597f7dbfceb1783e131d53aabe920c2b253a1538cc4efeee686f01c1282821ddca5dd56cb490fc7105e4e1be2d55e523f873a980b9a19ea157d523" +size = 2009068 +url = "https://github.com/watchexec/watchexec/releases/download/v2.3.2/watchexec-2.3.2-aarch64-apple-darwin.tar.xz" + +[[tools.yamllint]] +version = "1.37.1" +backend = "pipx:yamllint" + +[[tools.zig]] +version = "0.14.1" +backend = "core:zig" + +[tools.zig.platforms.linux-x64] +checksum = "blake3:442ecf016afa37715560053a41481138ed4bb5b7fbab477b8d13e662f72db984" +size = 49086504 +url = "https://ziglang.org/download/0.14.1/zig-x86_64-linux-0.14.1.tar.xz" + +[tools.zig.platforms.macos-arm64] +checksum = "blake3:8de34f5d09d6583630e22c8f039a0647642b7a90cb2ebd82f99b6af609deac3b" +size = 45903552 +url = "https://ziglang.org/download/0.14.1/zig-aarch64-macos-0.14.1.tar.xz" diff --git a/src/backend/aqua.rs b/src/backend/aqua.rs index da176203e3..8c6eb8051c 100644 --- a/src/backend/aqua.rs +++ b/src/backend/aqua.rs @@ -1,4 +1,6 @@ -use crate::backend::backend_type::BackendType; +use crate::backend::{ + GithubReleaseConfig, asset_detector, backend_type::BackendType, platform_target::PlatformTarget, +}; use crate::cli::args::BackendArg; use crate::cli::version::{ARCH, OS}; use crate::cmd::CmdLineRunner; @@ -6,6 +8,7 @@ use crate::config::Settings; use crate::file::TarOptions; use crate::http::HTTP; use crate::install_context::InstallContext; +use crate::lockfile::PlatformInfo; use crate::path::{Path, PathBuf, PathExt}; use crate::plugins::VERSION_REGEX; use crate::registry::REGISTRY; @@ -80,6 +83,201 @@ impl Backend for AquaBackend { Ok(versions) } + async fn resolve_lock_info( + &self, + tv: &ToolVersion, + target: &PlatformTarget, + ) -> Result { + // Prefer Aqua registry checksum metadata to avoid downloading full artifacts + // Falls back to default behavior if checksum metadata is unavailable + if let Ok(pkg) = AQUA_REGISTRY.package(&self.id).await { + if matches!(pkg.r#type, AquaPackageType::GithubRelease) { + // Determine the final version tag as in install logic + let tag = self + .get_version_tags() + .await + .ok() + .into_iter() + .flatten() + .find(|(version, _)| version == &tv.version) + .map(|(_, tag)| tag) + .cloned(); + let mut v = tag.clone().unwrap_or_else(|| tv.version.clone()); + let v_prefixed = + (tag.is_none() && !tv.version.starts_with('v')).then(|| format!("v{v}")); + if let Some(prefix) = &pkg.version_prefix { + if !v.starts_with(prefix) { + v = format!("{prefix}{v}"); + } + } + let final_tag = v_prefixed.unwrap_or(v.clone()); + + // Fetch release and resolve asset for this platform + let gh_id = format!("{}/{}", pkg.repo_owner, pkg.repo_name); + let release = crate::github::get_release(&gh_id, &final_tag).await?; + + // Prefer platform-aware detection using release assets and PlatformTarget + let available_assets: Vec = + release.assets.iter().map(|a| a.name.clone()).collect(); + let picker = asset_detector::AssetPicker::new(target); + let picked_name = picker.pick_best_asset(&available_assets).ok_or_else(|| { + eyre::eyre!( + "No suitable asset found for current platform ({}-{})\nAvailable assets: {}", + target.os_name(), + target.arch_name(), + available_assets.join(", ") + ) + })?; + + // Find the matching asset case-insensitively + let asset = release + .assets + .iter() + .find(|a| a.name == picked_name) + .or_else(|| { + let lower = picked_name.to_lowercase(); + release + .assets + .iter() + .find(|a| a.name.to_lowercase() == lower) + }) + .ok_or_else(|| { + eyre::eyre!( + "Picked asset not found: {}\nAvailable assets: {}", + picked_name, + available_assets.join(", ") + ) + })?; + + let url = asset.browser_download_url.clone(); + let size = asset.size; + + // If GitHub API provides digest, prefer it + if let Some(d) = &asset.digest { + let checksum = if d.contains(':') { + d.clone() + } else { + format!("sha256:{d}") + }; + return Ok(PlatformInfo { + url: Some(url), + checksum: Some(checksum), + size: Some(size), + }); + } + + // Otherwise, if Aqua defines a checksum file, use it to obtain sha256 + if let Some(checksum) = &pkg.checksum { + if checksum.enabled() { + let checksum_assets = checksum.asset_strs(&pkg, &final_tag)?; + if let Some(chk_asset) = release + .assets + .iter() + .find(|a| checksum_assets.contains(&a.name)) + { + let chk_url = chk_asset.browser_download_url.clone(); + let download_path = tv.download_path(); + file::create_dir_all(&download_path)?; + let checksum_path = + download_path.join(format!("{}.checksum", &chk_asset.name)); + HTTP.download_file(&chk_url, &checksum_path, None).await?; + + let mut checksum_file = file::read_to_string(&checksum_path)?; + if checksum.file_format() == "regexp" { + let pattern = checksum.pattern(); + if let Some(file) = &pattern.file { + let re = regex::Regex::new(file.as_str())?; + if let Some(line) = checksum_file.lines().find(|l| { + re.captures(l) + .is_some_and(|c| c[1].to_string() == asset.name) + }) { + checksum_file = line.to_string(); + } else { + debug!( + "no line found matching {} in {} for {}", + file, checksum_file, asset.name + ); + } + } + let re = regex::Regex::new(pattern.checksum.as_str())?; + if let Some(caps) = re.captures(checksum_file.as_str()) { + checksum_file = caps[1].to_string(); + } else { + debug!( + "no checksum found matching {} in {}", + pattern.checksum, checksum_file + ); + } + } + + let checksum_str = checksum_file + .lines() + .filter_map(|l| { + let split = l.split_whitespace().collect_vec(); + if split.len() == 2 { + Some(( + split[0].to_string(), + split[1] + .rsplit_once('/') + .map(|(_, f)| f) + .unwrap_or(split[1]) + .trim_matches('*') + .to_string(), + )) + } else { + None + } + }) + .find(|(_, f)| f == &asset.name) + .map(|(c, _)| c) + .unwrap_or(checksum_file); + let checksum_val = format!("{}:{}", checksum.algorithm(), checksum_str); + + // Clean up checksum file + let _ = std::fs::remove_file(&checksum_path); + + return Ok(PlatformInfo { + url: Some(url), + checksum: Some(checksum_val), + size: Some(size), + }); + } + } + } + + // Fallback: no digest or checksum file available, calculate checksum + match self.download_and_hash_file(&url, None).await { + Ok((calculated_checksum, actual_size)) => { + return Ok(PlatformInfo { + url: Some(url), + checksum: Some(format!("blake3:{}", calculated_checksum)), + size: Some(actual_size), + }); + } + Err(_e) => { + // As a last resort, return URL and known size + return Ok(PlatformInfo { + url: Some(url), + checksum: None, + size: Some(size), + }); + } + } + } + } + + // Default behavior: try tarball URL, then GitHub release, then fallback + if let Some(tarball_url) = self.get_tarball_url(tv, target).await? { + return self.resolve_lock_info_from_tarball(&tarball_url).await; + } + if let Some(release_info) = self.get_github_release_info(tv, target).await? { + return self + .resolve_lock_info_from_github_release(&release_info, tv, target) + .await; + } + self.resolve_lock_info_fallback(tv, target).await + } + async fn install_version_( &self, ctx: &InstallContext, @@ -242,6 +440,88 @@ impl Backend for AquaBackend { }) .collect() } + + // ========== Lockfile Metadata Fetching Implementation ========== + + async fn get_github_release_info( + &self, + tv: &ToolVersion, + _target: &PlatformTarget, + ) -> Result> { + // Try to extract repo info from the aqua package + if let Ok(pkg) = AQUA_REGISTRY.package(&self.id).await { + if !pkg.repo_owner.is_empty() && !pkg.repo_name.is_empty() { + use crate::github::ReleaseType; + + // Only handle GitHub releases + if !matches!(pkg.r#type, AquaPackageType::GithubRelease) { + return Ok(None); + } + + // Use the same version tag logic as install_version_ + let tag = self + .get_version_tags() + .await + .ok() + .into_iter() + .flatten() + .find(|(version, _)| version == &tv.version) + .map(|(_, tag)| tag); + let mut v = tag.cloned().unwrap_or_else(|| tv.version.clone()); + let v_prefixed = + (tag.is_none() && !tv.version.starts_with('v')).then(|| format!("v{v}")); + + if let Some(prefix) = &pkg.version_prefix { + if !v.starts_with(prefix) { + v = format!("{prefix}{v}"); + } + } + + // Use the final version tag (this matches the install_version_ logic) + let final_tag = if let Some(v_prefixed) = v_prefixed { + // Try v-prefixed version first since most aqua packages use v-prefixed versions + let pkg_for_prefixed = pkg.clone().with_version(&[&v_prefixed, &v]); + match pkg_for_prefixed.asset_strs(&v_prefixed) { + Ok(asset_strs) if !asset_strs.is_empty() => { + // Check if this version actually exists in GitHub releases + let gh_id = format!("{}/{}", pkg.repo_owner, pkg.repo_name); + match github::get_release(&gh_id, &v_prefixed).await { + Ok(_) => v_prefixed, + Err(_) => v, // Fall back to non-prefixed version + } + } + _ => v, + } + } else { + v + }; + + // Get the asset strings for the final tag to use as the asset pattern + let pkg_for_final = pkg.clone().with_version(&[&final_tag]); + let asset_strs = match pkg_for_final.asset_strs(&final_tag) { + Ok(strs) if !strs.is_empty() => strs, + _ => { + debug!("No assets defined for {} version {}", self.id, final_tag); + return Ok(None); + } + }; + + // Use the first asset string as the pattern (this is what aqua uses) + let asset = asset_strs.into_iter().next().unwrap(); + if asset.is_empty() { + return Ok(None); + } + + return Ok(Some(GithubReleaseConfig { + repo: format!("{}/{}", pkg.repo_owner, pkg.repo_name), + asset, + release_type: ReleaseType::GitHub, + tag: final_tag, + })); + } + } + Ok(None) + } } impl AquaBackend { diff --git a/src/backend/asset_detector.rs b/src/backend/asset_detector.rs index 181b4c1535..6a39691ad5 100644 --- a/src/backend/asset_detector.rs +++ b/src/backend/asset_detector.rs @@ -1,6 +1,8 @@ use regex::Regex; use std::sync::LazyLock; +use crate::backend::platform_target::PlatformTarget; + /// Platform detection patterns pub struct PlatformPatterns { pub os_patterns: &'static [(AssetOs, Regex)], @@ -160,7 +162,9 @@ pub struct AssetPicker { } impl AssetPicker { - pub fn new(target_os: String, target_arch: String) -> Self { + pub fn new(target: &PlatformTarget) -> Self { + let target_os = target.os_name().to_string(); + let target_arch = target.arch_name().to_string(); // Determine the libc variant based on how mise was built let target_libc = if cfg!(target_env = "musl") { "musl".to_string() @@ -364,11 +368,15 @@ pub fn detect_platform_from_url(url_str: &str) -> Option { #[cfg(test)] mod tests { + use crate::platform::Platform; + use super::*; #[test] fn test_asset_picker_functionality() { - let picker = AssetPicker::new("linux".to_string(), "x86_64".to_string()); + let platform = Platform::parse("linux-x86_64").unwrap(); + let target = PlatformTarget::new(platform); + let picker = AssetPicker::new(&target); let assets = vec![ "tool-1.0.0-linux-x86_64.tar.gz".to_string(), "tool-1.0.0-darwin-x86_64.tar.gz".to_string(), @@ -381,7 +389,9 @@ mod tests { #[test] fn test_asset_scoring() { - let picker = AssetPicker::new("linux".to_string(), "x86_64".to_string()); + let platform = Platform::parse("linux-x86_64").unwrap(); + let target = PlatformTarget::new(platform); + let picker = AssetPicker::new(&target); let score_linux = picker.score_asset("tool-1.0.0-linux-x86_64.tar.gz"); let score_windows = picker.score_asset("tool-1.0.0-windows-x86_64.zip"); @@ -399,7 +409,9 @@ mod tests { #[test] fn test_archive_preference() { - let picker = AssetPicker::new("linux".to_string(), "x86_64".to_string()); + let platform = Platform::parse("linux-x86_64").unwrap(); + let target = PlatformTarget::new(platform); + let picker = AssetPicker::new(&target); let assets = vec![ "tool-1.0.0-linux-x86_64".to_string(), "tool-1.0.0-linux-x86_64.tar.gz".to_string(), @@ -422,7 +434,9 @@ mod tests { ]; // Test Linux x86_64 - should prefer the libc variant that matches the build environment - let picker = AssetPicker::new("linux".to_string(), "x86_64".to_string()); + let platform = Platform::parse("linux-x86_64").unwrap(); + let target = PlatformTarget::new(platform); + let picker = AssetPicker::new(&target); let picked = picker.pick_best_asset(&ripgrep_assets).unwrap(); if cfg!(target_env = "musl") { assert_eq!(picked, "ripgrep-14.1.1-x86_64-unknown-linux-musl.tar.gz"); @@ -431,7 +445,9 @@ mod tests { } // Test Linux aarch64 - should prefer the libc variant that matches the build environment - let picker = AssetPicker::new("linux".to_string(), "aarch64".to_string()); + let platform = Platform::parse("linux-aarch64").unwrap(); + let target = PlatformTarget::new(platform); + let picker = AssetPicker::new(&target); let picked = picker.pick_best_asset(&ripgrep_assets).unwrap(); if cfg!(target_env = "musl") { assert_eq!(picked, "ripgrep-14.1.1-aarch64-unknown-linux-musl.tar.gz"); @@ -440,14 +456,18 @@ mod tests { } // Test macOS (should not be affected by libc) - let picker = AssetPicker::new("macos".to_string(), "x86_64".to_string()); + let platform = Platform::parse("macos-x86_64").unwrap(); + let target = PlatformTarget::new(platform); + let picker = AssetPicker::new(&target); let picked = picker.pick_best_asset(&ripgrep_assets).unwrap(); assert_eq!(picked, "ripgrep-14.1.1-x86_64-apple-darwin.tar.gz"); } #[test] fn test_libc_scoring() { - let picker = AssetPicker::new("linux".to_string(), "x86_64".to_string()); + let platform = Platform::parse("linux-x86_64").unwrap(); + let target = PlatformTarget::new(platform); + let picker = AssetPicker::new(&target); // Test that the libc variant matching the build environment scores higher let gnu_score = picker.score_asset("ripgrep-14.1.1-x86_64-unknown-linux-gnu.tar.gz"); @@ -570,22 +590,30 @@ mod tests { ]; // Test Linux x86_64 - should prefer musl over other variants when only musl is available - let picker = AssetPicker::new("linux".to_string(), "x86_64".to_string()); + let platform = Platform::parse("linux-x86_64").unwrap(); + let target = PlatformTarget::new(platform); + let picker = AssetPicker::new(&target); let picked = picker.pick_best_asset(&ripgrep_assets).unwrap(); assert_eq!(picked, "ripgrep-14.1.1-x86_64-unknown-linux-musl.tar.gz"); // Test Linux aarch64 - should prefer gnu over musl - let picker = AssetPicker::new("linux".to_string(), "aarch64".to_string()); + let platform = Platform::parse("linux-aarch64").unwrap(); + let target = PlatformTarget::new(platform); + let picker = AssetPicker::new(&target); let picked = picker.pick_best_asset(&ripgrep_assets).unwrap(); assert_eq!(picked, "ripgrep-14.1.1-aarch64-unknown-linux-gnu.tar.gz"); // Test macOS x86_64 - should not be affected by libc - let picker = AssetPicker::new("macos".to_string(), "x86_64".to_string()); + let platform = Platform::parse("macos-x86_64").unwrap(); + let target = PlatformTarget::new(platform); + let picker = AssetPicker::new(&target); let picked = picker.pick_best_asset(&ripgrep_assets).unwrap(); assert_eq!(picked, "ripgrep-14.1.1-x86_64-apple-darwin.tar.gz"); // Test macOS aarch64 - should not be affected by libc - let picker = AssetPicker::new("macos".to_string(), "aarch64".to_string()); + let platform = Platform::parse("macos-aarch64").unwrap(); + let target = PlatformTarget::new(platform); + let picker = AssetPicker::new(&target); let picked = picker.pick_best_asset(&ripgrep_assets).unwrap(); assert_eq!(picked, "ripgrep-14.1.1-aarch64-apple-darwin.tar.gz"); } diff --git a/src/backend/github.rs b/src/backend/github.rs index c05f1d7dab..f1a5b8b055 100644 --- a/src/backend/github.rs +++ b/src/backend/github.rs @@ -1,17 +1,18 @@ -use crate::backend::asset_detector; -use crate::backend::backend_type::BackendType; use crate::backend::static_helpers::lookup_platform_key; use crate::backend::static_helpers::{ get_filename_from_url, install_artifact, template_string, try_with_v_prefix, verify_artifact, }; +use crate::backend::{ + GithubReleaseConfig, backend_type::BackendType, platform_target::PlatformTarget, +}; use crate::cli::args::BackendArg; use crate::config::Config; -use crate::config::Settings; use crate::http::HTTP; use crate::install_context::InstallContext; use crate::toolset::ToolVersion; use crate::toolset::ToolVersionOptions; use crate::{backend::Backend, github, gitlab}; +use crate::{backend::asset_detector, github::GithubRelease, gitlab::GitlabRelease}; use async_trait::async_trait; use eyre::Result; use regex::Regex; @@ -23,6 +24,20 @@ pub struct UnifiedGitBackend { ba: Arc, } +enum ForgeRelease { + Github(GithubRelease), + Gitlab(GitlabRelease), +} + +impl ForgeRelease { + pub fn tag_name(&self) -> String { + match self { + ForgeRelease::Github(release) => release.tag_name.clone(), + ForgeRelease::Gitlab(release) => release.tag_name.clone(), + } + } +} + #[async_trait] impl Backend for UnifiedGitBackend { fn get_type(&self) -> BackendType { @@ -80,8 +95,10 @@ impl Backend for UnifiedGitBackend { ); existing_platform } else { + let target = PlatformTarget::default(); // Find the asset URL for this specific version - self.resolve_asset_url(&tv, &opts, &repo, &api_url).await? + self.resolve_asset_url(&tv, &opts, &repo, &api_url, &target) + .await? }; // Download and install @@ -104,6 +121,38 @@ impl Backend for UnifiedGitBackend { self.discover_bin_paths(tv) } } + + async fn get_github_release_info( + &self, + tv: &ToolVersion, + target: &PlatformTarget, + ) -> Result> { + use crate::github::ReleaseType; + let repo = self.repo(); + let opts = self.ba.opts(); + + let (release, asset) = self + .with_release_assets( + tv, + &repo, + &opts, + |_candidate, release, available_assets| async move { + Ok((release, self.auto_detect_asset(&available_assets, target)?)) + }, + ) + .await?; + + Ok(Some(GithubReleaseConfig { + repo, + asset, + release_type: if self.is_gitlab() { + ReleaseType::GitLab + } else { + ReleaseType::GitHub + }, + tag: release.tag_name(), + })) + } } impl UnifiedGitBackend { @@ -206,6 +255,7 @@ impl UnifiedGitBackend { opts: &ToolVersionOptions, repo: &str, api_url: &str, + target: &PlatformTarget, ) -> Result { // Check for direct platform-specific URLs first if let Some(direct_url) = lookup_platform_key(opts, "url") { @@ -216,13 +266,13 @@ impl UnifiedGitBackend { let version_prefix = opts.get("version_prefix").map(|s| s.as_str()); if self.is_gitlab() { try_with_v_prefix(version, version_prefix, |candidate| async move { - self.resolve_gitlab_asset_url(tv, opts, repo, api_url, &candidate) + self.resolve_gitlab_asset_url(tv, opts, repo, api_url, &candidate, target) .await }) .await } else { try_with_v_prefix(version, version_prefix, |candidate| async move { - self.resolve_github_asset_url(tv, opts, repo, api_url, &candidate) + self.resolve_github_asset_url(tv, opts, repo, api_url, &candidate, target) .await }) .await @@ -236,6 +286,7 @@ impl UnifiedGitBackend { repo: &str, api_url: &str, version: &str, + target: &PlatformTarget, ) -> Result { let release = github::get_release_for_url(api_url, repo, version).await?; @@ -265,7 +316,7 @@ impl UnifiedGitBackend { } // Fall back to auto-detection - let asset_name = self.auto_detect_asset(&available_assets)?; + let asset_name = self.auto_detect_asset(&available_assets, target)?; let asset = self .find_asset_case_insensitive(&release.assets, &asset_name, |a| &a.name) .ok_or_else(|| { @@ -286,6 +337,7 @@ impl UnifiedGitBackend { repo: &str, api_url: &str, version: &str, + target: &PlatformTarget, ) -> Result { let release = gitlab::get_release_for_url(api_url, repo, version).await?; @@ -321,7 +373,7 @@ impl UnifiedGitBackend { } // Fall back to auto-detection - let asset_name = self.auto_detect_asset(&available_assets)?; + let asset_name = self.auto_detect_asset(&available_assets, target)?; let asset = self .find_asset_case_insensitive(&release.assets.links, &asset_name, |a| &a.name) .ok_or_else(|| { @@ -335,18 +387,18 @@ impl UnifiedGitBackend { Ok(asset.direct_asset_url.clone()) } - fn auto_detect_asset(&self, available_assets: &[String]) -> Result { - let settings = Settings::get(); - let picker = asset_detector::AssetPicker::new( - settings.os().to_string(), - settings.arch().to_string(), - ); + fn auto_detect_asset( + &self, + available_assets: &[String], + target: &PlatformTarget, + ) -> Result { + let picker = asset_detector::AssetPicker::new(target); picker.pick_best_asset(available_assets).ok_or_else(|| { eyre::eyre!( "No suitable asset found for current platform ({}-{})\nAvailable assets: {}", - settings.os(), - settings.arch(), + target.os_name(), + target.arch_name(), available_assets.join(", ") ) }) @@ -385,6 +437,48 @@ impl UnifiedGitBackend { } } + /// Fetches release and executes callback with release data, handling v-prefix logic + /// This is a simplified version for asset name collection only + async fn with_release_assets( + &self, + tv: &ToolVersion, + repo: &str, + opts: &ToolVersionOptions, + callback: F, + ) -> Result + where + F: Fn(String, ForgeRelease, Vec) -> Fut, + Fut: std::future::Future>, + { + let api_url = self.get_api_url(opts); + let version = &tv.version; + let version_prefix = opts.get("version_prefix").map(|s| s.as_str()); + + try_with_v_prefix(version, version_prefix, |candidate| { + let api_url = api_url.clone(); + let repo = repo.to_string(); + let callback = &callback; + async move { + if self.is_gitlab() { + let release = gitlab::get_release_for_url(&api_url, &repo, &candidate).await?; + let available_assets: Vec = release + .assets + .links + .iter() + .map(|a| a.name.clone()) + .collect(); + callback(candidate, ForgeRelease::Gitlab(release), available_assets).await + } else { + let release = github::get_release_for_url(&api_url, &repo, &candidate).await?; + let available_assets: Vec = + release.assets.iter().map(|a| a.name.clone()).collect(); + callback(candidate, ForgeRelease::Github(release), available_assets).await + } + } + }) + .await + } + fn strip_version_prefix(&self, tag_name: &str) -> String { let opts = self.ba.opts(); diff --git a/src/backend/http.rs b/src/backend/http.rs index 7c6ac790f0..e169f9227d 100644 --- a/src/backend/http.rs +++ b/src/backend/http.rs @@ -1,9 +1,9 @@ -use crate::backend::Backend; use crate::backend::backend_type::BackendType; use crate::backend::static_helpers::{ clean_binary_name, get_filename_from_url, list_available_platforms_with_key, lookup_platform_key, template_string, verify_artifact, }; +use crate::backend::{Backend, platform_target::PlatformTarget}; use crate::cli::args::BackendArg; use crate::config::Config; use crate::config::Settings; @@ -419,4 +419,32 @@ impl Backend for HttpBackend { } } } + + // ========== Lockfile Metadata Fetching Implementation ========== + + async fn get_tarball_url( + &self, + tv: &ToolVersion, + _target: &PlatformTarget, + ) -> Result> { + let opts = tv.request.options(); + + // Use platform-specific URL mapping to get the appropriate URL template + let url_template = lookup_platform_key(&opts, "url").or_else(|| opts.get("url").cloned()); + + if let Some(template) = url_template { + // Template the URL with actual values, but we need to handle platform targeting + // For lockfile generation, we can't use tv directly since it may not match target + // Instead, we'll create a temporary tool version with target-specific info + let temp_tv = tv.clone(); + + // Override version info to match target platform for templating + // This is a bit of a hack but allows URL templating to work with different targets + let url = template_string(&template, &temp_tv); + Ok(Some(url)) + } else { + // No URL configured for this backend/platform combination + Ok(None) + } + } } diff --git a/src/backend/mod.rs b/src/backend/mod.rs index d322e4a5c7..decffdabc0 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -1,3 +1,4 @@ +use crate::http::HTTP; use std::collections::{BTreeMap, HashMap, HashSet}; use std::ffi::OsString; use std::fmt::{Debug, Display, Formatter}; @@ -12,6 +13,7 @@ use crate::cli::args::{BackendArg, ToolVersionType}; use crate::cmd::CmdLineRunner; use crate::config::{Config, Settings}; use crate::file::{display_path, remove_all, remove_all_with_warning}; +use crate::github::GithubReleaseConfig; use crate::install_context::InstallContext; use crate::lockfile::PlatformInfo; use crate::plugins::core::CORE_PLUGINS; @@ -60,21 +62,6 @@ pub type BackendMap = BTreeMap; pub type BackendList = Vec; pub type VersionCacheManager = CacheManager>; -/// Information about a GitHub/GitLab release for platform-specific tools -#[derive(Debug, Clone)] -pub struct GitHubReleaseInfo { - pub repo: String, - pub asset_pattern: Option, - pub api_url: Option, - pub release_type: ReleaseType, -} - -#[derive(Debug, Clone)] -pub enum ReleaseType { - GitHub, - GitLab, -} - static TOOLS: Mutex>> = Mutex::new(None); pub async fn load_tools() -> Result> { @@ -845,7 +832,7 @@ pub trait Backend: Debug + Send + Sync { &self, _tv: &ToolVersion, _target: &PlatformTarget, - ) -> Result> { + ) -> Result> { Ok(None) // Default: no GitHub release info available } @@ -855,41 +842,56 @@ pub trait Backend: Debug + Send + Sync { tv: &ToolVersion, target: &PlatformTarget, ) -> Result { + debug!( + "Resolving lockfile info for {} {} on {:?}", + self.ba().tool_name, + tv.version, + target + ); + // Try simple tarball approach first if let Some(tarball_url) = self.get_tarball_url(tv, target).await? { - return self - .resolve_lock_info_from_tarball(&tarball_url, tv, target) - .await; + debug!("Using tarball URL approach: {}", tarball_url); + return self.resolve_lock_info_from_tarball(&tarball_url).await; } // Try GitHub/GitLab release approach second if let Some(release_info) = self.get_github_release_info(tv, target).await? { + debug!( + "Using GitHub release approach for repo: {}", + release_info.repo + ); return self .resolve_lock_info_from_github_release(&release_info, tv, target) .await; } // Fall back to basic platform info without URLs/metadata + debug!("No tarball URL or GitHub release info available, using fallback"); self.resolve_lock_info_fallback(tv, target).await } /// Shared logic for processing tarball-based tools /// Downloads tarball headers, extracts size and URL info, and populates PlatformInfo - async fn resolve_lock_info_from_tarball( - &self, - tarball_url: &str, - _tv: &ToolVersion, - _target: &PlatformTarget, - ) -> Result { - // For now, just return basic info with the URL - // In a full implementation, this would: - // 1. Make HEAD request to get content-length - // 2. Potentially download to get checksum - // 3. Handle any URL-specific logic + async fn resolve_lock_info_from_tarball(&self, tarball_url: &str) -> Result { + debug!("Resolving lockfile info from tarball: {}", tarball_url); + + // Get checksum and size by downloading and hashing the file + let (checksum, size) = match self.download_and_hash_file(tarball_url, None).await { + Ok((calculated_checksum, actual_size)) => ( + Some(format!("blake3:{}", calculated_checksum)), + Some(actual_size), + ), + Err(e) => { + warn!("Failed to download and hash {}: {}", tarball_url, e); + (None, None) + } + }; + Ok(PlatformInfo { url: Some(tarball_url.to_string()), - checksum: None, // TODO: Implement checksum fetching - size: None, // TODO: Implement size fetching via HEAD request + checksum, + size, }) } @@ -897,25 +899,129 @@ pub trait Backend: Debug + Send + Sync { /// Queries release API, finds platform-specific assets, and populates PlatformInfo async fn resolve_lock_info_from_github_release( &self, - release_info: &GitHubReleaseInfo, + release_info: &crate::github::GithubReleaseConfig, _tv: &ToolVersion, target: &PlatformTarget, ) -> Result { - // For now, just return basic info - // In a full implementation, this would: - // 1. Query GitHub/GitLab release API - // 2. Find matching asset for the target platform - // 3. Extract download URL, size, and checksums - let asset_url = release_info.asset_pattern.as_ref().map(|pattern| { - pattern - .replace("{os}", target.os_name()) - .replace("{arch}", target.arch_name()) - }); + debug!( + "Resolving lockfile info from GitHub release for {} on {:?}", + release_info.repo, target + ); + + match release_info.release_type { + crate::github::ReleaseType::GitHub => { + // Build the asset filename from the pattern + let filename = release_info.asset.as_str(); + + debug!("Looking for GitHub asset: {}", filename); + + debug!("Using GitHub tag: {}", release_info.tag); + + // Get release info from GitHub API + match crate::github::get_release(&release_info.repo, &release_info.tag).await { + Ok(release) => { + debug!("Found GitHub release with {} assets", release.assets.len()); + + // Find the matching asset + if let Some(asset) = release.assets.iter().find(|a| a.name == filename) { + debug!( + "Found matching asset: {} (size: {}, digest: {:?})", + asset.name, asset.size, asset.digest + ); + + // Build the download URL + let url = format!( + "https://github.com/{}/releases/download/{}/{}", + release_info.repo, release_info.tag, filename + ); + + // If we have a digest from GitHub API, use it directly + if let Some(ref digest) = asset.digest { + debug!("Using digest from GitHub API: {}", digest); + // GitHub API digest already includes the algorithm prefix + let checksum = if digest.contains(':') { + digest.clone() + } else { + format!("sha256:{}", digest) + }; + return Ok(PlatformInfo { + url: Some(url), + checksum: Some(checksum), + size: Some(asset.size), + }); + } else { + debug!("No digest available, will download and calculate checksum"); + // Fallback: Download file and calculate checksum ourselves + match self.download_and_hash_file(&url, None).await { + Ok((calculated_checksum, actual_size)) => { + debug!( + "Calculated checksum: blake3:{}", + calculated_checksum + ); + return Ok(PlatformInfo { + url: Some(url), + checksum: Some(format!( + "blake3:{}", + calculated_checksum + )), + size: Some(actual_size), + }); + } + Err(e) => { + warn!("Failed to download and hash {}: {}", url, e); + // Still return the info but without checksum + return Ok(PlatformInfo { + url: Some(url), + checksum: None, + size: Some(asset.size), + }); + } + } + } + } else { + warn!( + "Asset '{}' not found in release '{}'", + filename, release_info.tag + ); + } + } + Err(e) => { + debug!( + "Failed to get GitHub release {}/{}: {}", + release_info.repo, release_info.tag, e + ); + // Fall back to constructed URL only + let url = format!( + "https://github.com/{}/releases/download/{}/{}", + release_info.repo, release_info.tag, filename + ); + return Ok(PlatformInfo { + url: Some(url), + checksum: None, + size: None, + }); + } + } + } + crate::github::ReleaseType::GitLab => { + debug!("GitLab release support not yet implemented"); + // TODO: Implement GitLab support + let asset_url = &release_info.asset; + + return Ok(PlatformInfo { + url: Some(asset_url.clone()), + checksum: None, + size: None, + }); + } + } + debug!("No asset pattern available for GitHub release"); + // Fallback - no asset pattern available Ok(PlatformInfo { - url: asset_url, - checksum: None, // TODO: Implement checksum fetching from releases - size: None, // TODO: Implement size fetching from GitHub API + url: None, + checksum: None, + size: None, }) } @@ -923,9 +1029,15 @@ pub trait Backend: Debug + Send + Sync { /// Returns minimal PlatformInfo without external URLs async fn resolve_lock_info_fallback( &self, - _tv: &ToolVersion, - _target: &PlatformTarget, + tv: &ToolVersion, + target: &PlatformTarget, ) -> Result { + debug!( + "Using fallback lockfile info for {} {} on {:?} - no external metadata available", + self.ba().tool_name, + tv.version, + target + ); // This is the fallback - no external metadata available // The tool would need to be installed to generate platform info Ok(PlatformInfo { @@ -934,6 +1046,42 @@ pub trait Backend: Debug + Send + Sync { size: None, }) } + + /// Download a file and calculate its BLAKE3 checksum and size + /// Used as fallback when GitHub API doesn't provide digest information + async fn download_and_hash_file( + &self, + url: &str, + pr: Option<&Box>, + ) -> Result<(String, u64)> { + debug!("Downloading {} to calculate checksum and size", url); + + // Prepare temporary file for download + let temp_dir = dirs::CACHE.join("lockfile_checksums"); + file::create_dir_all(&temp_dir)?; + + // Create a unique temporary filename based on URL hash + let url_hash = hash::hash_blake3_to_str(url); + let temp_path = temp_dir.join(format!("temp_{}.bin", &url_hash[..16])); + + // Download the file directly to the temporary path + HTTP.download_file(url, &temp_path, pr).await?; + + // Get file size + let file_size = temp_path.metadata()?.len(); + + // Calculate BLAKE3 checksum + let checksum = hash::file_hash_blake3(&temp_path, None)?; + + // Clean up temporary file + let _ = std::fs::remove_file(&temp_path); + + debug!( + "Calculated checksum for {}: {} (size: {} bytes)", + url, checksum, file_size + ); + Ok((checksum, file_size)) + } } fn find_match_in_list(list: &[String], query: &str) -> Option { diff --git a/src/backend/platform_target.rs b/src/backend/platform_target.rs index 8430f0de9b..bd5c95db0e 100644 --- a/src/backend/platform_target.rs +++ b/src/backend/platform_target.rs @@ -32,6 +32,12 @@ impl PlatformTarget { } } +impl Default for PlatformTarget { + fn default() -> Self { + Self::from_current() + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/cli/lock.rs b/src/cli/lock.rs index df90805a36..90cb96c468 100644 --- a/src/cli/lock.rs +++ b/src/cli/lock.rs @@ -1,7 +1,5 @@ use std::collections::BTreeSet; -use std::path::PathBuf; -use crate::backend::{get, platform_target::PlatformTarget}; use crate::config::Config; use crate::file::display_path; use crate::lockfile::Lockfile; @@ -29,7 +27,7 @@ pub struct Lock { /// e.g.: linux-x64,macos-arm64,windows-x64 /// If not specified, all platforms already in lockfile will be updated #[clap(long, short, value_delimiter = ',', verbatim_doc_comment)] - pub platform: Vec, + pub platforms: Vec, /// Update all tools even if lockfile data already exists #[clap(long, short, verbatim_doc_comment)] @@ -51,218 +49,88 @@ impl Lock { let config = Config::get().await?; settings.ensure_experimental("lock")?; - // Validate platforms if specified - if !self.platform.is_empty() { - let parsed_platforms = Platform::parse_multiple(&self.platform)?; - miseprintln!( - "{} Validated {} platform(s): {}", - style("→").green(), - parsed_platforms.len(), - parsed_platforms - .iter() - .map(|p| p.to_key()) - .collect::>() - .join(", ") - ); - } - - // For Phase 1, just implement lockfile discovery and platform analysis - self.analyze_lockfiles(&config).await?; - - // Demonstrate the new backend metadata fetching capabilities - self.demonstrate_metadata_fetching(&config).await?; - - if !self.dry_run { - miseprintln!( - "{} {}", - style("mise lock").bold().cyan(), - style("full implementation coming in next phase").green() - ); - } - - Ok(()) - } - - async fn analyze_lockfiles(&self, config: &Config) -> Result<()> { - let potential_lockfiles = self.discover_lockfiles(config)?; - let existing_lockfiles: Vec = potential_lockfiles - .iter() - .filter(|p| p.exists()) - .cloned() - .collect(); - let missing_lockfiles: Vec = potential_lockfiles - .iter() - .filter(|p| !p.exists()) - .cloned() - .collect(); - - if potential_lockfiles.is_empty() { - miseprintln!("No config found in current directory"); - return Ok(()); - } - - if existing_lockfiles.is_empty() && missing_lockfiles.is_empty() { - miseprintln!("No lockfiles found"); - return Ok(()); - } - - // Analyze existing lockfiles - if !existing_lockfiles.is_empty() { - miseprintln!("Found lockfile:"); - for lockfile_path in &existing_lockfiles { - miseprintln!(" {}", style(display_path(lockfile_path)).cyan()); - - // Read and analyze each lockfile - let lockfile = Lockfile::read(lockfile_path)?; - let platforms = self.extract_platforms(&lockfile); - let tools = self.extract_tools(&lockfile); - - self.analyze_lockfile_content(&tools, &platforms)?; - } - } - - // Analyze missing lockfiles (potential for creation) - if !missing_lockfiles.is_empty() { - if !existing_lockfiles.is_empty() { - miseprintln!(); - } - miseprintln!("No lockfile found, would create:"); - for lockfile_path in &missing_lockfiles { - miseprintln!( - " {} {}", - style("→").yellow(), - style(display_path(lockfile_path)).cyan() - ); - - // Get tools from the corresponding config file - let config_path = PathBuf::from("mise.toml"); - - // Try to read tools from the config file or from the overall config - let tools = if config_path.exists() { - // Read directly from the local config file - match crate::config::config_file::parse(&config_path) { - Ok(config_file) => { - let tool_request_set = config_file.to_tool_request_set()?; - tool_request_set - .list_tools() - .iter() - .map(|ba| ba.short.clone()) - .collect() - } - Err(_) => Vec::new(), - } - } else { - // No local config file exists, but maybe get tools from current config context - if let Ok(tool_request_set) = config.get_tool_request_set().await { - tool_request_set - .list_tools() - .iter() - .map(|ba| ba.short.clone()) - .collect() - } else { - Vec::new() - } - }; + // Get lockfile path (always mise.lock in current directory) + let lockfile_path = std::path::Path::new("mise.lock"); + + // Parse target platforms if specified + let target_platforms = if !self.platforms.is_empty() { + Platform::parse_multiple(&self.platforms)? + } else if lockfile_path.exists() { + // If lockfile exists and no platforms specified, extract from lockfile + let existing_lockfile = Lockfile::read(lockfile_path)?; + self.extract_platforms(&existing_lockfile) + .into_iter() + .filter_map(|key| Platform::parse(&key).ok()) + .collect() + } else { + // Default to current platform if no lockfile exists and no platforms specified + vec![Platform::current()] + }; - if tools.is_empty() { - miseprintln!(" {} No tools configured", style("!").yellow()); - } else { - miseprintln!( - " {} Would create lockfile with {} tool(s): {}", - style("→").green(), - tools.len(), - tools.join(", ") - ); + miseprintln!( + "{} Targeting {} platform(s): {}", + style("→").green(), + target_platforms.len(), + target_platforms + .iter() + .map(|p| p.to_key()) + .collect::>() + .join(", ") + ); - // For creation, we don't have existing platforms, but show what tools would be targeted - let target_tools = self.get_target_tools(&tools); - if !target_tools.is_empty() { - miseprintln!( - " {} Would initialize {} tool(s) in new lockfile", - style("→").green(), - target_tools.len() - ); + // Get configured tools to process + let toolset = config.get_toolset().await?; + let all_tool_versions = toolset.list_current_versions(); - if self.dry_run { - for tool in &target_tools { - miseprintln!( - " {} {} (new lockfile)", - style("✓").green(), - style(tool).bold() - ); - } - } - } - } - } - } + // Filter tools based on CLI arguments + let target_tools: Vec<_> = if !self.tool.is_empty() { + let specified_tools: BTreeSet = + self.tool.iter().map(|t| t.ba.short.clone()).collect(); - Ok(()) - } + all_tool_versions + .into_iter() + .filter(|(_, tv)| specified_tools.contains(&tv.ba().short)) + .map(|(_, tv)| tv) + .collect() + } else { + all_tool_versions.into_iter().map(|(_, tv)| tv).collect() + }; - fn analyze_lockfile_content( - &self, - tools: &[String], - platforms: &BTreeSet, - ) -> Result<()> { - if tools.is_empty() { - miseprintln!(" {} No tools found", style("!").yellow()); + if target_tools.is_empty() { + miseprintln!("{} No tools found to process", style("!").yellow()); return Ok(()); } - miseprintln!(" Tools: {}", tools.join(", ")); - - if platforms.is_empty() { - miseprintln!(" {} No platform data found", style("!").yellow()); - } else { + if self.dry_run { miseprintln!( - " Platforms: {}", - platforms.iter().cloned().collect::>().join(", ") + "{} Dry run - showing what would be processed:", + style("INFO").blue() ); - } - - // Show what would be updated based on filters - let target_tools = self.get_target_tools(tools); - let target_platforms = self.get_target_platforms(platforms); - - if !target_tools.is_empty() && (!target_platforms.is_empty() || platforms.is_empty()) { - let platform_count = if platforms.is_empty() { - 1 - } else { - target_platforms.len() - }; - miseprintln!( - " {} Would update {} tool(s) for {} platform(s)", - style("→").green(), - target_tools.len(), - platform_count - ); - - if self.dry_run && !target_platforms.is_empty() { - for tool in &target_tools { - for platform in &target_platforms { - miseprintln!( - " {} {} for {}", - style("✓").green(), - style(tool).bold(), - style(platform).blue() - ); - } - } + for tool in &target_tools { + miseprintln!(" {} {}", style("→").green(), tool.ba().short); } + return Ok(()); } - Ok(()) - } + // Generate lockfile using the high-level API + let lockfile = Lockfile::generate_for_tools( + lockfile_path, + &target_tools, + &target_platforms, + self.force, + ) + .await?; - fn discover_lockfiles(&self, _config: &Config) -> Result> { - let mut lockfiles = Vec::new(); + // Save the lockfile + lockfile.save(lockfile_path)?; - // Look for mise.lock in the current directory - let lockfile_path = PathBuf::from("mise.lock"); - lockfiles.push(lockfile_path); + miseprintln!( + "{} Lockfile updated at {}", + style("✓").green(), + style(display_path(lockfile_path)).cyan() + ); - Ok(lockfiles) + Ok(()) } fn extract_platforms(&self, lockfile: &Lockfile) -> BTreeSet { @@ -278,115 +146,6 @@ impl Lock { platforms } - - fn extract_tools(&self, lockfile: &Lockfile) -> Vec { - lockfile.tools().keys().cloned().collect() - } - - fn get_target_tools(&self, available_tools: &[String]) -> Vec { - if self.tool.is_empty() { - // If no tools specified, target all tools - available_tools.to_vec() - } else { - // Filter to only specified tools that exist in lockfile - let specified_tools: BTreeSet = - self.tool.iter().map(|t| t.ba.short.clone()).collect(); - - available_tools - .iter() - .filter(|tool| specified_tools.contains(*tool)) - .cloned() - .collect() - } - } - - fn get_target_platforms(&self, available_platforms: &BTreeSet) -> Vec { - if self.platform.is_empty() { - // If no platforms specified, target all platforms - available_platforms.iter().cloned().collect() - } else { - // Parse and validate specified platforms first, then filter - match Platform::parse_multiple(&self.platform) { - Ok(parsed_platforms) => { - let specified_platforms: BTreeSet = - parsed_platforms.iter().map(|p| p.to_key()).collect(); - - available_platforms - .iter() - .filter(|platform| specified_platforms.contains(*platform)) - .cloned() - .collect() - } - Err(_) => { - // If parsing fails, fall back to original logic - let specified_platforms: BTreeSet = - self.platform.iter().cloned().collect(); - - available_platforms - .iter() - .filter(|platform| specified_platforms.contains(*platform)) - .cloned() - .collect() - } - } - } - } - - async fn demonstrate_metadata_fetching(&self, config: &Config) -> Result<()> { - // Skip if no platforms specified (keep current behavior) - if self.platform.is_empty() { - return Ok(()); - } - - miseprintln!( - "{} Demonstrating new backend metadata fetching:", - style("INFO").blue() - ); - - let parsed_platforms = Platform::parse_multiple(&self.platform)?; - - // Get configured tools from the toolset - if let Ok(tool_request_set) = config.get_tool_request_set().await { - let tools = tool_request_set.list_tools(); - - for tool_ba in tools.iter().take(2) { - // Limit to 2 tools for demo - if let Some(_backend) = get(tool_ba) { - miseprintln!(" {} tool: {}", style("→").green(), tool_ba.short); - - for platform in parsed_platforms.iter().take(2) { - // Limit to 2 platforms for demo - let _target = PlatformTarget::new(platform.clone()); - miseprintln!(" {} platform: {}", style("→").blue(), platform.to_key()); - - // Demonstrate the new backend methods without full ToolVersion - // For now, just show that the methods are available - miseprintln!( - " {} Backend supports metadata fetching methods:", - style("✓").green() - ); - - // We can't easily create a ToolVersion here without complex setup - // But we can show that the backend has the new capabilities - miseprintln!( - " {} get_tarball_url() - implemented", - style("•").dim() - ); - miseprintln!( - " {} get_github_release_info() - implemented", - style("•").dim() - ); - miseprintln!( - " {} resolve_lock_info() - implemented", - style("•").dim() - ); - } - } - } - } - - Ok(()) - } } // Note: We'll need to make Lockfile::read public in src/lockfile.rs @@ -396,7 +155,7 @@ static AFTER_LONG_HELP: &str = color_print::cstr!( $ mise lock Update lockfile in current directory for all platforms $ mise lock node python Update only node and python - $ mise lock --platform linux-x64 Update only linux-x64 platform + $ mise lock --platforms linux-x64 Update only linux-x64 platform $ mise lock --dry-run Show what would be updated or created $ mise lock --force Re-download and update even if data exists "# diff --git a/src/github.rs b/src/github.rs index f8fa39ce6e..5825263f3c 100644 --- a/src/github.rs +++ b/src/github.rs @@ -32,8 +32,26 @@ pub struct GithubTag { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GithubAsset { pub name: String, - // pub size: u64, + pub size: u64, pub browser_download_url: String, + /// SHA256 digest of the asset (available since June 2025) + #[serde(default)] + pub digest: Option, +} + +/// Configuration for GitHub release-based tools +#[derive(Debug, Clone)] +pub struct GithubReleaseConfig { + pub repo: String, + pub asset: String, + pub release_type: ReleaseType, + pub tag: String, +} + +#[derive(Debug, Clone)] +pub enum ReleaseType { + GitHub, + GitLab, } type CacheGroup = HashMap>; diff --git a/src/lockfile.rs b/src/lockfile.rs index aa5be3a774..a36b194d9b 100644 --- a/src/lockfile.rs +++ b/src/lockfile.rs @@ -1,9 +1,12 @@ -use crate::config::{Config, Settings}; use crate::file; use crate::file::display_path; use crate::path::PathExt; use crate::registry::{REGISTRY, tool_enabled}; use crate::toolset::{ToolSource, ToolVersion, ToolVersionList, Toolset}; +use crate::{ + backend::platform_target::PlatformTarget, + config::{Config, Settings}, +}; use eyre::{Report, Result, bail}; use itertools::Itertools; use serde_derive::{Deserialize, Serialize}; @@ -140,19 +143,28 @@ impl Lockfile { Ok(lockfile) } - fn save>(&self, path: P) -> Result<()> { + fn is_empty(&self) -> bool { + self.tools.is_empty() + } + + pub fn tools(&self) -> &BTreeMap> { + &self.tools + } + + /// Save the lockfile to the specified path + pub fn save>(&self, path: P) -> Result<()> { if self.is_empty() { let _ = file::remove_file(path); } else { let mut tools = toml::Table::new(); for (short, versions) in &self.tools { // Always write Multi-Version format (array format) for consistency - let value: toml::Value = versions + let version_values: Vec = versions .iter() .cloned() .map(|version| version.into_toml_value()) - .collect::>() - .into(); + .collect(); + let value = toml::Value::Array(version_values); tools.insert(short.clone(), value); } let mut lockfile = toml::Table::new(); @@ -165,12 +177,130 @@ impl Lockfile { Ok(()) } - fn is_empty(&self) -> bool { - self.tools.is_empty() + /// Generate or update a lockfile with platform metadata for specified tools and platforms + pub async fn generate_for_tools( + path: &Path, + tools: &[ToolVersion], + target_platforms: &[crate::platform::Platform], + force_update: bool, + ) -> Result { + // Load existing lockfile or create new one + let mut lockfile = if path.exists() { + Self::read(path)? + } else { + Self::default() + }; + + // Process all tools in parallel + let tool_results = crate::parallel::parallel(tools.to_vec(), { + let target_platforms = target_platforms.to_vec(); + move |tool_version| { + let target_platforms = target_platforms.clone(); + async move { Self::fetch_tool_metadata(&tool_version, &target_platforms).await } + } + }) + .await?; + + // Merge results into lockfile, preserving existing entries + for (tool_name, new_entries) in tool_results { + if new_entries.is_empty() { + continue; // Skip tools with no backend + } + + match lockfile.tools.get_mut(&tool_name) { + Some(existing_entries) => { + // Merge with existing entries + for new_entry in new_entries { + if let Some(existing_entry) = existing_entries + .iter_mut() + .find(|e| e.version == new_entry.version) + { + // Merge platforms, preferring new data if force_update + if force_update { + existing_entry.platforms = new_entry.platforms; + } else { + existing_entry.platforms.extend(new_entry.platforms); + } + } else { + // Add new version entry + existing_entries.push(new_entry); + } + } + } + None => { + // Insert new tool entirely + lockfile.tools.insert(tool_name, new_entries); + } + } + } + + Ok(lockfile) } - pub fn tools(&self) -> &BTreeMap> { - &self.tools + /// Fetch metadata for a single tool across all platforms + async fn fetch_tool_metadata( + tool_version: &ToolVersion, + target_platforms: &[crate::platform::Platform], + ) -> Result<(String, Vec)> { + // Create progress reporter for this tool + use crate::ui::multi_progress_report::MultiProgressReport; + let mpr = MultiProgressReport::get(); + let pr = mpr.add(&format!( + "fetching {} {}", + tool_version.ba().short, + tool_version.version + )); + let tool_name = &tool_version.ba().short; + + let backend = tool_version.ba().backend()?; + + // Create tool entry for this version + let mut tool_entry = LockfileTool { + version: tool_version.version.clone(), + backend: Some(tool_version.ba().full()), + platforms: Default::default(), + }; + + // Collect all platforms to update + let platforms_to_update = target_platforms.to_vec(); + + // Clone values for parallel processing + let tool_version_clone = tool_version.clone(); + + // Fetch platform metadata in parallel + let platform_results = crate::parallel::parallel(platforms_to_update, move |platform| { + let platform_target = PlatformTarget::new(platform.clone()); + let backend = backend.clone(); + let tool_version = tool_version_clone.clone(); + async move { + let platform_key = platform.to_key(); + match backend + .resolve_lock_info(&tool_version, &platform_target) + .await + { + Ok(platform_info) => { + if platform_info.url.is_some() + || platform_info.checksum.is_some() + || platform_info.size.is_some() + { + Ok(Some((platform_key, platform_info))) + } else { + Ok(None) + } + } + Err(_) => Ok(None), // Skip failed fetches + } + } + }) + .await?; + + // Insert successful results into the tool entry + for result in platform_results.into_iter().flatten() { + tool_entry.platforms.insert(result.0, result.1); + } + + pr.finish_with_message(format!("{} platforms", tool_entry.platforms.len())); + Ok((tool_name.clone(), vec![tool_entry])) } } @@ -493,7 +623,27 @@ impl From for Vec { fn format(mut doc: DocumentMut) -> String { if let Some(tools) = doc.get_mut("tools") { - for (_k, v) in tools.as_table_mut().unwrap().iter_mut() { + let tools_table = tools.as_table_mut().unwrap(); + let mut keys_to_convert = Vec::new(); + + // First pass: identify single tables that need to be converted to arrays + for (k, v) in tools_table.iter() { + if matches!(v, toml_edit::Item::Table(_)) { + keys_to_convert.push(k.to_string()); + } + } + + // Convert single tables to array of tables format + for key in keys_to_convert { + if let Some(toml_edit::Item::Table(table)) = tools_table.remove(&key) { + let mut art = toml_edit::ArrayOfTables::new(); + art.push(table); + tools_table.insert(&key, toml_edit::Item::ArrayOfTables(art)); + } + } + + // Second pass: format all entries (now all should be arrays) + for (_k, v) in tools_table.iter_mut() { match v { toml_edit::Item::ArrayOfTables(art) => { for t in art.iter_mut() { @@ -511,19 +661,10 @@ fn format(mut doc: DocumentMut) -> String { } } } - toml_edit::Item::Table(t) => { - t.sort_values_by(|a, _, b, _| { - if a == "version" { - return std::cmp::Ordering::Less; - } - a.to_string().cmp(&b.to_string()) - }); - // Sort platforms section within each tool - if let Some(toml_edit::Item::Table(platforms_table)) = t.get_mut("platforms") { - platforms_table.sort_values(); - } + _ => { + // This should not happen anymore since we converted all tables to arrays above + warn!("Unexpected non-array format in lockfile after conversion"); } - _ => {} } } } @@ -620,4 +761,33 @@ backend = "core:python" // Clean up let _ = std::fs::remove_file(&test_lockfile); } + + #[test] + fn test_format_converts_single_table_to_array() { + // Test that the format function converts single table format to array format + let single_table_toml = r#" +[tools.bun] +version = "1.2.21" +backend = "core:bun" + +[tools.bun.platforms.macos-arm64] +checksum = "sha256:fd886630ba15c484236ad5f3f22b255d287c3eef8d3bc26fc809851035c04cec" +size = 22056420 +url = "https://github.com/oven-sh/bun/releases/download/bun-v1.2.21/bun-darwin-aarch64.zip" + +[[tools.node]] +version = "20.10.0" +backend = "core:node" +"#; + + let doc: toml_edit::DocumentMut = single_table_toml.parse().unwrap(); + let formatted = format(doc); + + // Both tools should now use array format + assert!(formatted.contains("[[tools.bun]]")); + assert!(formatted.contains("[[tools.node]]")); + // Verify no single table format remains + assert!(!formatted.lines().any(|line| line.trim() == "[tools.bun]")); + assert!(!formatted.lines().any(|line| line.trim() == "[tools.node]")); + } } diff --git a/src/parallel.rs b/src/parallel.rs index 6fde16e305..c3dc40c6a3 100644 --- a/src/parallel.rs +++ b/src/parallel.rs @@ -1,5 +1,6 @@ use crate::Result; use crate::config::Settings; +use std::future::Future; use std::sync::Arc; use tokio::sync::Semaphore; use tokio::task::JoinSet; @@ -8,14 +9,16 @@ pub async fn parallel(input: Vec, f: F) -> Result> where T: Send + 'static, U: Send + 'static, - F: Fn(T) -> Fut + Send + Copy + 'static, + F: Fn(T) -> Fut + Send + Sync + 'static, Fut: Future> + Send + 'static, { let semaphore = Arc::new(Semaphore::new(Settings::get().jobs)); let mut jset = JoinSet::new(); + let f = Arc::new(f); let mut results = input.iter().map(|_| None).collect::>(); for item in input.into_iter().enumerate() { let semaphore = semaphore.clone(); + let f = f.clone(); let permit = semaphore.acquire_owned().await?; jset.spawn(async move { let _permit = permit; diff --git a/src/plugins/core/bun.rs b/src/plugins/core/bun.rs index e221b498c7..a6423016f2 100644 --- a/src/plugins/core/bun.rs +++ b/src/plugins/core/bun.rs @@ -16,7 +16,7 @@ use crate::install_context::InstallContext; use crate::toolset::ToolVersion; use crate::ui::progress_report::SingleReport; use crate::{ - backend::{Backend, GitHubReleaseInfo, ReleaseType, platform_target::PlatformTarget}, + backend::{Backend, platform_target::PlatformTarget}, config::Config, }; use crate::{file, github, plugins}; @@ -126,22 +126,20 @@ impl Backend for BunPlugin { &self, tv: &ToolVersion, target: &PlatformTarget, - ) -> Result> { - let version = &tv.version; + ) -> Result> { + let _version = &tv.version; // Build the asset pattern for Bun's GitHub releases // Pattern: bun-{os}-{arch}.zip (where arch may include variants like -musl, -baseline) let os_name = Self::map_os_to_bun(target.os_name()); let arch_name = Self::get_bun_arch_for_target(target); - let asset_pattern = format!("bun-{os_name}-{arch_name}.zip"); + let asset = format!("bun-{os_name}-{arch_name}.zip"); - Ok(Some(GitHubReleaseInfo { + Ok(Some(crate::github::GithubReleaseConfig { repo: "oven-sh/bun".to_string(), - asset_pattern: Some(asset_pattern), - api_url: Some(format!( - "https://github.com/oven-sh/bun/releases/download/bun-v{version}" - )), - release_type: ReleaseType::GitHub, + asset, + release_type: crate::github::ReleaseType::GitHub, + tag: format!("bun-v{}", tv.version), })) } } diff --git a/src/plugins/core/deno.rs b/src/plugins/core/deno.rs index 2b479ebe9f..16b7e85977 100644 --- a/src/plugins/core/deno.rs +++ b/src/plugins/core/deno.rs @@ -10,11 +10,10 @@ use itertools::Itertools; use serde::Deserialize; use versions::Versioning; -use crate::backend::Backend; +use crate::backend::{Backend, platform_target::PlatformTarget}; use crate::cli::args::BackendArg; -use crate::cli::version::OS; use crate::cmd::CmdLineRunner; -use crate::config::{Config, Settings}; +use crate::config::Config; use crate::http::{HTTP, HTTP_FETCH}; use crate::install_context::InstallContext; use crate::toolset::{ToolRequest, ToolVersion, Toolset}; @@ -50,13 +49,16 @@ impl DenoPlugin { } async fn download(&self, tv: &ToolVersion, pr: &Box) -> Result { - let settings = Settings::get(); - let url = format!( - "https://dl.deno.land/release/v{}/deno-{}-{}.zip", - tv.version, - arch(&settings), - os() + // Use get_tarball_url to get the download URL for consistency with lockfile generation + let target = crate::backend::platform_target::PlatformTarget::new( + crate::platform::Platform::current(), ); + + let url = self + .get_tarball_url(tv, &target) + .await? + .ok_or_else(|| eyre::eyre!("No tarball URL available for Deno {}", tv.version))?; + let filename = url.split('/').next_back().unwrap(); let tarball_path = tv.download_path().join(filename); @@ -159,25 +161,35 @@ impl Backend for DenoPlugin { )]); Ok(map) } -} -fn os() -> &'static str { - if cfg!(target_os = "macos") { - "apple-darwin" - } else if cfg!(target_os = "linux") { - "unknown-linux-gnu" - } else if cfg!(target_os = "windows") { - "pc-windows-msvc" - } else { - &OS - } -} + // ========== Lockfile Metadata Fetching Implementation ========== + + async fn get_tarball_url( + &self, + tv: &ToolVersion, + target: &PlatformTarget, + ) -> Result> { + // Map platform target to Deno's naming conventions + let deno_arch = match target.arch_name() { + "x64" => "x86_64", + "arm64" | "aarch64" => "aarch64", + other => other, + }; + + let deno_os = match target.os_name() { + "macos" => "apple-darwin", + "linux" => "unknown-linux-gnu", + "windows" => "pc-windows-msvc", + other => other, + }; + + // Build the download URL using Deno's standard pattern + let url = format!( + "https://dl.deno.land/release/v{}/deno-{}-{}.zip", + tv.version, deno_arch, deno_os + ); -fn arch(settings: &Settings) -> &str { - match settings.arch() { - "x64" => "x86_64", - "arm64" => "aarch64", - other => other, + Ok(Some(url)) } } diff --git a/src/plugins/core/go.rs b/src/plugins/core/go.rs index 4d50f25ef0..8bf59dfe68 100644 --- a/src/plugins/core/go.rs +++ b/src/plugins/core/go.rs @@ -2,9 +2,8 @@ use std::path::{Path, PathBuf}; use std::{collections::BTreeMap, sync::Arc}; use crate::Result; -use crate::backend::Backend; +use crate::backend::{Backend, platform_target::PlatformTarget}; use crate::cli::args::BackendArg; -use crate::cli::version::OS; use crate::cmd::CmdLineRunner; use crate::config::{Config, Settings}; use crate::file::{TarFormat, TarOptions}; @@ -100,15 +99,20 @@ impl GoPlugin { pr: &Box, ) -> eyre::Result { let settings = Settings::get(); - let filename = format!( - "go{}.{}-{}.{}", - tv.version, - platform(), - arch(&settings), - ext() + + // Use get_tarball_url to get the download URL for consistency with lockfile generation + let target = crate::backend::platform_target::PlatformTarget::new( + crate::platform::Platform::current(), + ); + + let tarball_url = Arc::new( + self.get_tarball_url(tv, &target) + .await? + .ok_or_else(|| eyre::eyre!("No tarball URL available for Go {}", tv.version))?, ); - let tarball_url = Arc::new(format!("{}/{}", &settings.go_download_mirror, &filename)); - let tarball_path = tv.download_path().join(&filename); + + let filename = tarball_url.split('/').next_back().unwrap(); + let tarball_path = tv.download_path().join(filename); let tarball_url_ = tarball_url.clone(); let checksum_handle = tokio::spawn(async move { @@ -269,26 +273,39 @@ impl Backend for GoPlugin { ) -> eyre::Result> { self._exec_env(tv) } -} -fn platform() -> &'static str { - if cfg!(target_os = "macos") { - "darwin" - } else { - &OS - } -} + // ========== Lockfile Metadata Fetching Implementation ========== -fn arch(settings: &Settings) -> &str { - match settings.arch() { - "x64" => "amd64", - "arm64" => "arm64", - "arm" => "armv6l", - "riscv64" => "riscv64", - other => other, - } -} + async fn get_tarball_url( + &self, + tv: &ToolVersion, + target: &PlatformTarget, + ) -> Result> { + let settings = Settings::get(); + + // Map platform target to Go's naming conventions + let go_platform = match target.os_name() { + "macos" => "darwin", + other => other, + }; + + let go_arch = match target.arch_name() { + "x64" => "amd64", + "arm64" => "arm64", + "arm" => "armv6l", + "riscv64" => "riscv64", + other => other, + }; + + let extension = match target.os_name() { + "windows" => "zip", + _ => "tar.gz", + }; -fn ext() -> &'static str { - if cfg!(windows) { "zip" } else { "tar.gz" } + // Build the download URL using Go's standard pattern + let filename = format!("go{}.{}-{}.{}", tv.version, go_platform, go_arch, extension); + let url = format!("{}/{}", settings.go_download_mirror, filename); + + Ok(Some(url)) + } } diff --git a/src/plugins/core/ruby_windows.rs b/src/plugins/core/ruby_windows.rs index 567164cd5c..01d6f5ce5d 100644 --- a/src/plugins/core/ruby_windows.rs +++ b/src/plugins/core/ruby_windows.rs @@ -5,11 +5,13 @@ use std::{ }; use crate::backend::Backend; +use crate::backend::platform_target::PlatformTarget; use crate::cli::args::BackendArg; use crate::cmd::CmdLineRunner; use crate::config::{Config, Settings}; use crate::env::PATH_KEY; use crate::github::GithubRelease; +use crate::github::GithubReleaseConfig; use crate::http::HTTP; use crate::install_context::InstallContext; use crate::toolset::{ToolVersion, Toolset}; @@ -222,6 +224,21 @@ impl Backend for RubyPlugin { // No modification to RUBYLIB Ok(map) } + + // ========== Lockfile Metadata Fetching Implementation ========== + + fn get_github_release_info( + &self, + tv: &ToolVersion, + target: &PlatformTarget, + ) -> Result> { + Ok(Some(GithubReleaseConfig { + repo: "oneclick/rubyinstaller2".to_string(), + asset_pattern: format!("rubyinstaller-{}-1-{}.7z", tv.version, target.arch_name()), + release_type: ReleaseType::GitHub, + tag: Some(format!("RubyInstaller-{}", tv.version)), + })) + } } fn parse_gemfile(body: &str) -> String { diff --git a/src/plugins/core/swift.rs b/src/plugins/core/swift.rs index e09678982b..5eec1ccacf 100644 --- a/src/plugins/core/swift.rs +++ b/src/plugins/core/swift.rs @@ -5,7 +5,10 @@ use crate::http::HTTP; use crate::install_context::InstallContext; use crate::toolset::ToolVersion; use crate::ui::progress_report::SingleReport; -use crate::{backend::Backend, config::Config}; +use crate::{ + backend::{Backend, platform_target::PlatformTarget}, + config::Config, +}; use crate::{file, github, gpg, plugins}; use async_trait::async_trait; use eyre::Result; @@ -194,6 +197,51 @@ impl Backend for SwiftPlugin { Ok(tv) } + + // ========== Lockfile Metadata Fetching Implementation ========== + + async fn get_tarball_url( + &self, + tv: &ToolVersion, + target: &PlatformTarget, + ) -> Result> { + // Map platform target to Swift's naming conventions + let swift_platform = match target.os_name() { + "macos" => "osx".to_string(), + "windows" => "windows10".to_string(), + "linux" => "ubi9".to_string(), // fallback for linux + other => other.to_string(), + }; + + let swift_architecture = match (target.os_name(), target.arch_name()) { + ("linux", arch) if arch != "x64" => Some(arch), + ("windows", "arm64") => Some("arm64"), + _ => None, + }; + + let extension = match target.os_name() { + "macos" => "pkg", + "windows" => "exe", + _ => "tar.gz", + }; + + let architecture_suffix = match swift_architecture { + Some(arch) => format!("-{}", arch), + None => "".to_string(), + }; + + // Build the download URL using Swift's standard pattern + let url = format!( + "https://download.swift.org/swift-{version}-release/{platform_directory}/swift-{version}-RELEASE/swift-{version}-RELEASE-{platform}{architecture}.{extension}", + version = tv.version, + platform = swift_platform, + platform_directory = swift_platform.replace(".", ""), + extension = extension, + architecture = architecture_suffix, + ); + + Ok(Some(url)) + } } fn swift_bin_name() -> &'static str { diff --git a/src/plugins/core/zig.rs b/src/plugins/core/zig.rs index 876033745b..00ab81932a 100644 --- a/src/plugins/core/zig.rs +++ b/src/plugins/core/zig.rs @@ -4,7 +4,7 @@ use std::{ sync::Arc, }; -use crate::backend::Backend; +use crate::backend::{Backend, platform_target::PlatformTarget}; use crate::cli::args::BackendArg; use crate::cli::version::OS; use crate::cmd::CmdLineRunner; @@ -193,6 +193,38 @@ impl Backend for ZigPlugin { self.verify(ctx, &tv)?; Ok(tv) } + + // ========== Lockfile Metadata Fetching Implementation ========== + + async fn get_tarball_url( + &self, + tv: &ToolVersion, + target: &PlatformTarget, + ) -> Result> { + // Map platform target to Zig's naming conventions + let zig_arch = match target.arch_name() { + "x64" => "x86_64", + "arm64" | "aarch64" => "aarch64", + other => other, + }; + + let zig_os = target.os_name(); + + // Reuse the existing JSON-based URL fetching logic + let json_url = if regex!(r"^mach-|-mach$").is_match(&tv.version) { + "https://machengine.org/zig/index.json" + } else { + "https://ziglang.org/download/index.json" + }; + + match self + .get_tarball_url_from_json(json_url, &tv.version, zig_arch, zig_os) + .await + { + Ok(url) => Ok(Some(url)), + Err(_) => Ok(None), // Return None if version/platform combination not found + } + } } fn os() -> &'static str {