diff --git a/bitrise.yml b/bitrise.yml index 67c520968..1234de8b5 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -75,7 +75,7 @@ workflows: - bundle::run_integration_tests: { } meta: bitrise.io: - stack: osx-xcode-16.4.x + stack: osx-xcode-edge # TODO: temporary machine_type_id: g2.mac.large run_integration_tests_linux: @@ -86,6 +86,10 @@ workflows: - workflow_id: unpin-go-version - bundle::setup_go_junit_report: { } - bundle::run_integration_tests: { } + meta: + bitrise.io: + stack: ubuntu-noble-24.04-bitrise-2025-android + machine_type_id: g2.linux.x-large run_docker_integration_tests_linux: steps: diff --git a/integrationtests/toolprovider/mise/install_nixpkgs_ruby_test.go b/integrationtests/toolprovider/mise/install_nixpkgs_ruby_test.go new file mode 100644 index 000000000..87f083309 --- /dev/null +++ b/integrationtests/toolprovider/mise/install_nixpkgs_ruby_test.go @@ -0,0 +1,95 @@ +//go:build linux_and_mac +// +build linux_and_mac + +package mise + +import ( + "testing" + + "github.com/bitrise-io/bitrise/v2/toolprovider/mise" + "github.com/bitrise-io/bitrise/v2/toolprovider/provider" + "github.com/stretchr/testify/require" +) + +func TestMiseInstallNixpkgsRuby(t *testing.T) { + tests := []struct { + name string + tool string + version string + resolutionStrategy provider.ResolutionStrategy + want string + wantErr bool + }{ + { + name: "Install specific version", + tool: "ruby", + version: "3.3.9", + resolutionStrategy: provider.ResolutionStrategyStrict, + want: "3.3.9", + }, + { + name: "Install fuzzy version and released strategy", + tool: "ruby", + version: "3.1", // EOL version, won't receive new patch versions suddenly + resolutionStrategy: provider.ResolutionStrategyLatestReleased, + want: "3.1.7", + }, + { + name: "Install fuzzy version and installed strategy", + tool: "ruby", + version: "3.1", // EOL version, won't receive new patch versions + resolutionStrategy: provider.ResolutionStrategyLatestInstalled, + want: "3.1.7", + }, + { + name: "Nonexistent version in nixpkgs index", + tool: "ruby", + version: "0.1.999", + resolutionStrategy: provider.ResolutionStrategyStrict, + wantErr: true, + }, + { + name: "Install some other tool with forced nixpkgs backend", + tool: "node", + version: "22.22.1", + resolutionStrategy: provider.ResolutionStrategyStrict, + wantErr: true, + }, + } + + t.Setenv("BITRISE_TOOLSETUP_FAST_INSTALL", "true") + t.Setenv("BITRISE_TOOLSETUP_FAST_INSTALL_FORCE", "true") + + for _, tt := range tests { + miseInstallDir := t.TempDir() + miseDataDir := t.TempDir() + miseProvider, err := mise.NewToolProvider(miseInstallDir, miseDataDir) + require.NoError(t, err) + + err = miseProvider.Bootstrap() + require.NoError(t, err) + + t.Run(tt.name, func(t *testing.T) { + request := provider.ToolRequest{ + ToolName: provider.ToolID(tt.tool), + UnparsedVersion: tt.version, + ResolutionStrategy: tt.resolutionStrategy, + } + result, installErr := miseProvider.InstallTool(request) + + if tt.wantErr { + require.Error(t, installErr) + return + } + require.NoError(t, installErr) + if tt.tool == "ruby" { + // We purposely return ruby with the nixpkgs: prefix for environment activation later + require.Equal(t, provider.ToolID("nixpkgs:ruby"), result.ToolName) + } else { + require.Equal(t, provider.ToolID(tt.tool), result.ToolName) + } + require.Equal(t, tt.want, result.ConcreteVersion) + require.False(t, result.IsAlreadyInstalled) + }) + } +} diff --git a/toolprovider/asdf/install_plugin.go b/toolprovider/asdf/install_plugin.go index 88abc70a2..543ec84c4 100644 --- a/toolprovider/asdf/install_plugin.go +++ b/toolprovider/asdf/install_plugin.go @@ -46,7 +46,7 @@ func (a *AsdfToolProvider) InstallPlugin(tool provider.ToolRequest) error { log.Warnf("Failed to check if plugin is already installed: %v", err) } if installed { - log.Debugf("Tool plugin %s is already installed, skipping installation.", tool.ToolName) + log.Debugf("[TOOLPROVIDER] Tool plugin %s is already installed, skipping installation.", tool.ToolName) return nil } diff --git a/toolprovider/mise/bootstrap.go b/toolprovider/mise/bootstrap.go index f17a3d21d..91095a141 100644 --- a/toolprovider/mise/bootstrap.go +++ b/toolprovider/mise/bootstrap.go @@ -23,18 +23,18 @@ const fallbackDownloadURLBase = "https://storage.googleapis.com/mise-release-mir func isMiseInstalled(targetDir string) bool { misePath := filepath.Join(targetDir, "bin", "mise") - // Check if the file exists and is executable + // Check if the file exists and is executable. info, err := os.Stat(misePath) if err != nil { return false } - // Check if it's a regular file and has execute permissions + // Check if it's a regular file and has execute permissions. if !info.Mode().IsRegular() || info.Mode().Perm()&0111 == 0 { return false } - // Try to run mise --version to verify it's actually runnable + // Try to run mise --version to verify it's actually runnable. cmd := exec.Command(misePath, "--version") err = cmd.Run() return err == nil @@ -106,7 +106,7 @@ func downloadAndVerify(url, expectedChecksum string) (string, error) { _ = tempFile.Close() }() - // Compute SHA256 hash of the downloaded file and store contents in the temp file + // Compute SHA256 hash of the downloaded file and store contents in the temp file. hash := sha256.New() multiWriter := io.MultiWriter(tempFile, hash) if _, err := io.Copy(multiWriter, resp.Body); err != nil { @@ -211,18 +211,18 @@ func FallbackDownloadURL(version, platformName string) string { // processHeader processes a tar header and determines the target extraction path. func processHeader(header *tar.Header, targetDir string) (string, bool) { - // Skip the top-level "mise" directory and extract its contents directly + // Skip the top-level "mise" directory and extract its contents directly. pathParts := strings.Split(header.Name, "/") if len(pathParts) > 0 && pathParts[0] == "mise" { if len(pathParts) == 1 { - // This is the top-level "mise" directory itself, skip it + // This is the top-level "mise" directory itself, skip it. return "", false } - // Remove the top-level "mise" directory from the path + // Remove the top-level "mise" directory from the path. header.Name = strings.Join(pathParts[1:], "/") } - // Clean the path to prevent directory traversal attacks + // Clean the path to prevent directory traversal attacks. targetPath := filepath.Join(targetDir, header.Name) if !strings.HasPrefix(targetPath, filepath.Clean(targetDir)) { return "", false @@ -260,14 +260,14 @@ func extractFile(tarReader *tar.Reader, header *tar.Header, targetPath string) e } if filepath.Base(targetPath) == "mise" { - // Make mise binary executable + // Make mise binary executable. err = os.Chmod(targetPath, 0755) if err != nil { return fmt.Errorf("make mise binary executable %s: %w", targetPath, err) } } default: - // Skip other file types (symlinks, etc.) + // Skip other file types (symlinks, etc.). } return nil } diff --git a/toolprovider/mise/execenv/execenv.go b/toolprovider/mise/execenv/execenv.go index 6acdb07c7..fd6ff0790 100644 --- a/toolprovider/mise/execenv/execenv.go +++ b/toolprovider/mise/execenv/execenv.go @@ -16,28 +16,42 @@ const ( // ExecEnv contains everything needed to run mise commands in a specific environment // that is installed and pre-configured. -type ExecEnv struct { +type ExecEnv interface { // InstallDir is the directory where mise is installed. This is not necessarily the same as the data directory. - InstallDir string + InstallDir() string + RunMise(args ...string) (string, error) + RunMiseWithTimeout(timeout time.Duration, args ...string) (string, error) + RunMisePlugin(args ...string) (string, error) +} + +type MiseExecEnv struct { + installDir string - // Additional env vars that configure mise and are required for its operation. - ExtraEnvs map[string]string + extraEnvs map[string]string } -func (e *ExecEnv) RunMise(args ...string) (string, error) { +// extraEnvs: additional env vars that configure mise and are required for its operation. +func NewMiseExecEnv(installDir string, extraEnvs map[string]string) MiseExecEnv { + return MiseExecEnv{ + installDir: installDir, + extraEnvs: extraEnvs, + } +} + +func (e MiseExecEnv) RunMise(args ...string) (string, error) { return e.RunMiseWithTimeout(0, args...) } -func (e *ExecEnv) RunMisePlugin(args ...string) (string, error) { +func (e MiseExecEnv) RunMisePlugin(args ...string) (string, error) { cmdWithArgs := append([]string{"plugin"}, args...) - // Use timeout for all plugin operations as they involve unknown code execution + // Use timeout for all plugin operations as they involve unknown code execution. return e.RunMiseWithTimeout(DefaultTimeout, cmdWithArgs...) } // RunMiseWithTimeout runs mise commands that involve untrusted operations (plugin execution, remote network calls) -// with a timeout to prevent hanging -func (e *ExecEnv) RunMiseWithTimeout(timeout time.Duration, args ...string) (string, error) { +// with a timeout to prevent hanging. +func (e MiseExecEnv) RunMiseWithTimeout(timeout time.Duration, args ...string) (string, error) { var ctx context.Context if timeout == 0 { ctx = context.Background() @@ -47,10 +61,10 @@ func (e *ExecEnv) RunMiseWithTimeout(timeout time.Duration, args ...string) (str defer cancel() } - executable := filepath.Join(e.InstallDir, "bin", "mise") + executable := filepath.Join(e.installDir, "bin", "mise") cmd := exec.CommandContext(ctx, executable, args...) cmd.Env = os.Environ() - for k, v := range e.ExtraEnvs { + for k, v := range e.extraEnvs { cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, v)) } output, err := cmd.CombinedOutput() @@ -63,3 +77,7 @@ func (e *ExecEnv) RunMiseWithTimeout(timeout time.Duration, args ...string) (str return string(output), nil } + +func (e MiseExecEnv) InstallDir() string { + return e.installDir +} diff --git a/toolprovider/mise/helpers_test.go b/toolprovider/mise/helpers_test.go new file mode 100644 index 000000000..33c74c1f9 --- /dev/null +++ b/toolprovider/mise/helpers_test.go @@ -0,0 +1,59 @@ +package mise + +import ( + "fmt" + "strings" + "time" +) + +type fakeExecEnv struct { + // responses maps command strings to their outputs. + responses map[string]string + // errors maps command strings to errors. + errors map[string]error +} + +func newFakeExecEnv() *fakeExecEnv { + return &fakeExecEnv{ + responses: make(map[string]string), + errors: make(map[string]error), + } +} + +func (m *fakeExecEnv) setResponse(cmdKey string, output string) { + m.responses[cmdKey] = output +} + +func (m *fakeExecEnv) setError(cmdKey string, err error) { + m.errors[cmdKey] = err +} + +func (m *fakeExecEnv) InstallDir() string { + return "/fake/mise/install/dir" +} + +func (m *fakeExecEnv) RunMise(args ...string) (string, error) { + return m.runCommand(args...) +} + +func (m *fakeExecEnv) RunMisePlugin(args ...string) (string, error) { + return m.runCommand(append([]string{"plugin"}, args...)...) +} + +func (m *fakeExecEnv) RunMiseWithTimeout(timeout time.Duration, args ...string) (string, error) { + return m.runCommand(args...) +} + +func (m *fakeExecEnv) runCommand(args ...string) (string, error) { + cmdKey := strings.Join(args, " ") + + if err, ok := m.errors[cmdKey]; ok { + return "", err + } + + if output, ok := m.responses[cmdKey]; ok { + return output, nil + } + + return "", fmt.Errorf("no mock response configured for command: %s", cmdKey) +} diff --git a/toolprovider/mise/install.go b/toolprovider/mise/install.go index 24e3b3274..3c160fe9e 100644 --- a/toolprovider/mise/install.go +++ b/toolprovider/mise/install.go @@ -3,11 +3,81 @@ package mise import ( "errors" "fmt" + "os" + "github.com/bitrise-io/bitrise/v2/log" "github.com/bitrise-io/bitrise/v2/toolprovider/mise/execenv" + "github.com/bitrise-io/bitrise/v2/toolprovider/mise/nixpkgs" "github.com/bitrise-io/bitrise/v2/toolprovider/provider" ) +func installRequest(toolRequest provider.ToolRequest, useNix bool) provider.ToolRequest { + if useNix { + return provider.ToolRequest{ + // Use Mise's backend plugin convention of pluginID:toolID. + ToolName: provider.ToolID(fmt.Sprintf("%s:%s", nixpkgs.PluginName, toolRequest.ToolName)), + UnparsedVersion: toolRequest.UnparsedVersion, + ResolutionStrategy: toolRequest.ResolutionStrategy, + // Only relevant for plugins, that are not handled by the given backend. + // Nixpkgs handles all tools it supports internally, we should not install anything extra. + PluginURL: nil, + } + } else { + return toolRequest + } +} + +// nixChecker is a helper for testing. +// The real implementation returns true if Nix (the daemon) is available on the system and various other conditions are met. +type nixChecker func(tool provider.ToolRequest) (bool, error) + +func canBeInstalledWithNix(tool provider.ToolRequest, execEnv execenv.ExecEnv, nixChecker nixChecker) bool { + // Force switch for integration testing. No fallback to regular install when this is active. This makes failures explicit. + forceNix := os.Getenv("BITRISE_TOOLSETUP_FAST_INSTALL_FORCE") == "true" + + useNix, err := nixChecker(tool) + if err != nil { + // Note: if Nix is unavailable we cannot force install. + log.Warnf("Error while checking if nixpkgs backend should be used: %v. Falling back to core plugin installation.", err) + return false + } + + if !forceNix && !useNix { + return false + } + + _, err = execEnv.RunMisePlugin("install", nixpkgs.PluginName, nixpkgs.PluginGitURL) + if err != nil { + log.Warnf("Error while installing nixpkgs plugin (%s): %v. Falling back to core plugin installation.", nixpkgs.PluginGitURL, err) + // Warning, if false is not returned here, force install will be allowed even though plugin install failed. + return false + } + + _, err = execEnv.RunMisePlugin("update", nixpkgs.PluginName) + if err != nil { + log.Warnf("Error while updating nixpkgs plugin (%s): %v. Possibly using outdated plugin version.", nixpkgs.PluginGitURL, err) + } + + if forceNix { + // In force mode, we do not care about version existence, as failure is expected if the version is not in nixpkgs. + // But we still need to make sure the plugin above is installed. + return true + } + + nameWithBackend := provider.ToolID(fmt.Sprintf("nixpkgs:%s", tool.ToolName)) + available, err := versionExists(execEnv, nameWithBackend, tool.UnparsedVersion) + if err != nil { + log.Warnf("Error while checking nixpkgs index for %s@%s: %v. Falling back to core plugin installation.", tool.ToolName, tool.UnparsedVersion, err) + return false + } + if !available { + log.Warnf("%s@%s not found in nixpkgs index, doing a source build. This may take some time...", tool.ToolName, tool.UnparsedVersion) + return false + } + + return true +} + func (m *MiseToolProvider) installToolVersion(tool provider.ToolRequest) error { versionString, err := miseVersionString(tool, m.resolveToLatestInstalled) if err != nil { @@ -27,8 +97,8 @@ func (m *MiseToolProvider) installToolVersion(tool provider.ToolRequest) error { } // Helper for easier testing. -// Inputs: tool ID, tool version -// Returns: latest installed version of the tool, or an error if no matching version is installed +// Inputs: tool ID, tool version. +// Returns: latest installed version of the tool, or an error if no matching version is installed. type latestInstalledResolver func(provider.ToolID, string) (string, error) func isAlreadyInstalled(tool provider.ToolRequest, latestInstalledResolver latestInstalledResolver) (bool, error) { @@ -65,7 +135,7 @@ func miseVersionString(tool provider.ToolRequest, latestInstalledResolver latest miseVersionString = fmt.Sprintf("%s@%s", tool.ToolName, latestInstalledV) } else { if errors.Is(err, errNoMatchingVersion) { - // No local version satisfies the request -> fallback to latest released + // No local version satisfies the request -> fallback to latest released. miseVersionString = fmt.Sprintf("%s@prefix:%s", tool.ToolName, tool.UnparsedVersion) } else { return "", fmt.Errorf("resolve %s %s to latest installed version: %w", tool.ToolName, tool.UnparsedVersion, err) diff --git a/toolprovider/mise/install_plugin.go b/toolprovider/mise/install_plugin.go index e055a3a50..b219db6c5 100644 --- a/toolprovider/mise/install_plugin.go +++ b/toolprovider/mise/install_plugin.go @@ -44,7 +44,7 @@ func (m *MiseToolProvider) InstallPlugin(tool provider.ToolRequest) error { } if plugin == nil { // No plugin installation needed (either core tool or registry tool). - log.Debugf("No plugin installation needed for tool %s", tool.ToolName) + log.Debugf("[TOOLPROVIDER] No plugin installation needed for tool %s", tool.ToolName) return nil } @@ -53,7 +53,7 @@ func (m *MiseToolProvider) InstallPlugin(tool provider.ToolRequest) error { log.Warnf("Failed to check if plugin is already installed: %v", err) } if installed { - log.Debugf("Tool plugin %s is already installed, skipping installation.", tool.ToolName) + log.Debugf("[TOOLPROVIDER] Tool plugin %s is already installed, skipping installation.", tool.ToolName) return nil } @@ -80,12 +80,12 @@ func (m *MiseToolProvider) InstallPlugin(tool provider.ToolRequest) error { return nil } -// RegistryChecker interface required for testing +// RegistryChecker interface required for testing. type RegistryChecker interface { isPluginInRegistry(name string) error } -// pluginToInstall is a wrapper to call the pure function with the MiseToolProvider as RegistryChecker +// pluginToInstall is a wrapper to call the pure function with the MiseToolProvider as RegistryChecker. func (m *MiseToolProvider) pluginToInstall(tool provider.ToolRequest) (*PluginSource, error) { return pluginToInstall(tool, m) } @@ -157,7 +157,7 @@ func (m *MiseToolProvider) isPluginInRegistry(name string) error { _, err := m.ExecEnv.RunMise(registryArgs...) if err != nil { // If the tool is not found in registry, mise returns exit code 1 with error message - // "tool not found in registry: " + // "tool not found in registry: ". return fmt.Errorf("tool not found in registry: %s", name) } diff --git a/toolprovider/mise/install_plugin_test.go b/toolprovider/mise/install_plugin_test.go index 5c1eda28e..5d049c36e 100644 --- a/toolprovider/mise/install_plugin_test.go +++ b/toolprovider/mise/install_plugin_test.go @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/require" ) -// MockRegistryChecker is a mock implementation of RegistryChecker for testing +// MockRegistryChecker is a mock implementation of RegistryChecker for testing. type MockRegistryChecker struct { registryTools map[string]bool } @@ -234,7 +234,7 @@ func TestMiseCoreTools_Consistency(t *testing.T) { } } -// stringPtr helper for creating string pointers +// stringPtr helper for creating string pointers. func stringPtr(s string) *string { return &s } diff --git a/toolprovider/mise/install_test.go b/toolprovider/mise/install_test.go index fceef639c..6c0a49e9b 100644 --- a/toolprovider/mise/install_test.go +++ b/toolprovider/mise/install_test.go @@ -5,6 +5,7 @@ import ( "fmt" "testing" + "github.com/bitrise-io/bitrise/v2/toolprovider/mise/nixpkgs" "github.com/bitrise-io/bitrise/v2/toolprovider/provider" "github.com/stretchr/testify/require" ) @@ -76,21 +77,31 @@ func TestMiseVersionString(t *testing.T) { want: "", wantErr: true, }, + { + name: "strict resolution with nixpkgs backend", + tool: provider.ToolRequest{ + ToolName: "nixpkgs:ruby", + UnparsedVersion: "3.3.0", + ResolutionStrategy: provider.ResolutionStrategyStrict, + }, + want: "nixpkgs:ruby@3.3.0", + wantErr: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { latestInstalledResolver := func(toolName provider.ToolID, version string) (string, error) { - // Setup fake behavior based on test case + // Setup fake behavior based on test case. switch tt.tool.ToolName { case "go": - // Fake successful resolution + // Fake successful resolution. return "1.21.5", nil case "java": - // Fake no matching version found + // Fake no matching version found. return "", errNoMatchingVersion case "ruby": - // Fake other error + // Fake other error. return "", errors.New("some other error") } return "", fmt.Errorf("no fake behavior defined for tool %s", toolName) @@ -170,3 +181,144 @@ func TestIsAlreadyInstalled(t *testing.T) { }) } } + +func TestCanBeInstalledWithNix(t *testing.T) { + tests := []struct { + name string + toolID provider.ToolID + version string + resolutionStrategy provider.ResolutionStrategy + setupFake func(m *fakeExecEnv) + want bool + }{ + { + name: "concrete Ruby version that exists in index", + toolID: provider.ToolID("ruby"), + version: "3.3.9", + resolutionStrategy: provider.ResolutionStrategyStrict, + setupFake: func(m *fakeExecEnv) { + m.setResponse(fmt.Sprintf("plugin install %s %s", nixpkgs.PluginName, nixpkgs.PluginGitURL), "") + m.setResponse(fmt.Sprintf("plugin update %s", nixpkgs.PluginName), "") + m.setResponse("ls --installed --json --quiet ruby", "[]") + m.setResponse("ls-remote --quiet nixpkgs:ruby@3.3.9", "3.3.9") + }, + want: true, + }, + { + name: "fuzzy Ruby version that matches an existing version in index", + toolID: provider.ToolID("ruby"), + version: "3.3", + resolutionStrategy: provider.ResolutionStrategyLatestReleased, + setupFake: func(m *fakeExecEnv) { + m.setResponse(fmt.Sprintf("plugin install %s %s", nixpkgs.PluginName, nixpkgs.PluginGitURL), "") + m.setResponse(fmt.Sprintf("plugin update %s", nixpkgs.PluginName), "") + m.setResponse("ls --installed --json --quiet ruby", "[]") + m.setResponse("ls-remote --quiet nixpkgs:ruby@3.3", "3.3.8\n3.3.9") + }, + want: true, + }, + { + name: "concrete Ruby version that doesn't exist in index", + toolID: provider.ToolID("ruby"), + version: "0.0.1", + resolutionStrategy: provider.ResolutionStrategyStrict, + setupFake: func(m *fakeExecEnv) { + m.setResponse(fmt.Sprintf("plugin install %s %s", nixpkgs.PluginName, nixpkgs.PluginGitURL), "") + m.setResponse(fmt.Sprintf("plugin update %s", nixpkgs.PluginName), "") + m.setResponse("ls --installed --json --quiet ruby", "[]") + m.setResponse("ls-remote --quiet nixpkgs:ruby@0.0.1", "") + }, + want: false, + }, + { + name: "nixpkgs plugin install error", + toolID: provider.ToolID("ruby"), + version: "3.3.9", + resolutionStrategy: provider.ResolutionStrategyStrict, + setupFake: func(m *fakeExecEnv) { + m.setError(fmt.Sprintf("plugin install %s %s", nixpkgs.PluginName, nixpkgs.PluginGitURL), fmt.Errorf("fake error")) + }, + want: false, + }, + { + name: "nixpkgs index check error", + toolID: provider.ToolID("ruby"), + version: "3.3.9", + resolutionStrategy: provider.ResolutionStrategyStrict, + setupFake: func(m *fakeExecEnv) { + m.setResponse(fmt.Sprintf("plugin install %s %s", nixpkgs.PluginName, nixpkgs.PluginGitURL), "") + m.setResponse(fmt.Sprintf("plugin update %s", nixpkgs.PluginName), "") + m.setResponse("ls --installed --json --quiet ruby", "[]") + m.setError("ls-remote --quiet nixpkgs:ruby@3.3.9", fmt.Errorf("fake error")) + }, + want: false, + }, + } + + t.Setenv("BITRISE_TOOLSETUP_FAST_INSTALL", "true") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + execEnv := newFakeExecEnv() + tt.setupFake(execEnv) + + request := provider.ToolRequest{ + ToolName: tt.toolID, + UnparsedVersion: tt.version, + ResolutionStrategy: tt.resolutionStrategy, + } + + nixChecker := func(tool provider.ToolRequest) (bool, error) { + return true, nil + } + + got := canBeInstalledWithNix(request, execEnv, nixChecker) + require.Equal(t, tt.want, got) + + }) + } +} + +func TestInstallRequest(t *testing.T) { + tests := []struct { + name string + tool provider.ToolRequest + useNix bool + want provider.ToolRequest + }{ + { + name: "without nixpkgs", + tool: provider.ToolRequest{ + ToolName: "node", + UnparsedVersion: "18.20.0", + ResolutionStrategy: provider.ResolutionStrategyStrict, + }, + useNix: false, + want: provider.ToolRequest{ + ToolName: "node", + UnparsedVersion: "18.20.0", + ResolutionStrategy: provider.ResolutionStrategyStrict, + }, + }, + { + name: "with nixpkgs", + tool: provider.ToolRequest{ + ToolName: "node", + UnparsedVersion: "18", + ResolutionStrategy: provider.ResolutionStrategyLatestInstalled, + }, + useNix: true, + want: provider.ToolRequest{ + ToolName: "nixpkgs:node", + UnparsedVersion: "18", + ResolutionStrategy: provider.ResolutionStrategyLatestInstalled, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := installRequest(tt.tool, tt.useNix) + require.Equal(t, tt.want, got) + }) + } +} diff --git a/toolprovider/mise/mise.go b/toolprovider/mise/mise.go index 1e61f140f..e2a622a21 100644 --- a/toolprovider/mise/mise.go +++ b/toolprovider/mise/mise.go @@ -5,10 +5,10 @@ import ( "fmt" "os" "path/filepath" - "strings" "github.com/bitrise-io/bitrise/v2/log" "github.com/bitrise-io/bitrise/v2/toolprovider/mise/execenv" + "github.com/bitrise-io/bitrise/v2/toolprovider/mise/nixpkgs" "github.com/bitrise-io/bitrise/v2/toolprovider/provider" ) @@ -63,23 +63,20 @@ func NewToolProvider(installDir string, dataDir string) (*MiseToolProvider, erro } return &MiseToolProvider{ - ExecEnv: execenv.ExecEnv{ - InstallDir: installDir, - + ExecEnv: execenv.NewMiseExecEnv(installDir, map[string]string{ // https://mise.jdx.dev/configuration.html#environment-variables - ExtraEnvs: map[string]string{ - "MISE_DATA_DIR": dataDir, - - // Isolate this mise instance's "global" config from system-wide config - "MISE_CONFIG_DIR": filepath.Join(dataDir), - "MISE_GLOBAL_CONFIG_FILE": filepath.Join(dataDir, "config.toml"), - "MISE_GLOBAL_CONFIG_ROOT": dataDir, - - // Enable corepack by default for Node.js installations. This mirrors the preinstalled Node versions on Bitrise stacks. - // https://mise.jdx.dev/lang/node.html#environment-variables - "MISE_NODE_COREPACK": "1", - }, + "MISE_DATA_DIR": dataDir, + + // Isolate this mise instance's "global" config from system-wide config. + "MISE_CONFIG_DIR": filepath.Join(dataDir), + "MISE_GLOBAL_CONFIG_FILE": filepath.Join(dataDir, "config.toml"), + "MISE_GLOBAL_CONFIG_ROOT": dataDir, + + // Enable corepack by default for Node.js installations. This mirrors the preinstalled Node versions on Bitrise stacks. + // https://mise.jdx.dev/lang/node.html#environment-variables + "MISE_NODE_COREPACK": "1", }, + ), }, nil } @@ -88,12 +85,13 @@ func (m *MiseToolProvider) ID() string { } func (m *MiseToolProvider) Bootstrap() error { - if isMiseInstalled(m.ExecEnv.InstallDir) { - log.Debugf("[TOOLPROVIDER] Mise already installed in %s, skipping bootstrap", m.ExecEnv.InstallDir) + installDir := m.ExecEnv.InstallDir() + if isMiseInstalled(installDir) { + log.Debugf("[TOOLPROVIDER] Mise already installed in %s, skipping bootstrap", installDir) return nil } - err := installReleaseBinary(GetMiseVersion(), GetMiseChecksums(), m.ExecEnv.InstallDir) + err := installReleaseBinary(GetMiseVersion(), GetMiseChecksums(), installDir) if err != nil { return fmt.Errorf("bootstrap mise: %w", err) } @@ -102,40 +100,51 @@ func (m *MiseToolProvider) Bootstrap() error { } func (m *MiseToolProvider) InstallTool(tool provider.ToolRequest) (provider.ToolInstallResult, error) { - err := m.InstallPlugin(tool) - if err != nil { - return provider.ToolInstallResult{}, fmt.Errorf("install tool plugin %s: %w", tool.ToolName, err) - } + useNix := canBeInstalledWithNix(tool, m.ExecEnv, nixpkgs.ShouldUseBackend) + if !useNix { + err := m.InstallPlugin(tool) + if err != nil { + return provider.ToolInstallResult{}, fmt.Errorf("install tool plugin %s: %w", tool.ToolName, err) + } + } // else: nixpkgs plugin is already installed in ShouldInstallWithNix() - isAlreadyInstalled, err := isAlreadyInstalled(tool, m.resolveToLatestInstalled) + installRequest := installRequest(tool, useNix) + + // Note: tools get reinstalled with Nix even if they are already installed with the core plugin for consistency. + isAlreadyInstalled, err := isAlreadyInstalled(installRequest, m.resolveToLatestInstalled) if err != nil { return provider.ToolInstallResult{}, err } - versionExists, err := m.versionExists(tool.ToolName, tool.UnparsedVersion) - if err != nil { - return provider.ToolInstallResult{}, fmt.Errorf("check if version exists: %w", err) - } - if !versionExists { - return provider.ToolInstallResult{}, provider.ToolInstallError{ - ToolName: tool.ToolName, - RequestedVersion: tool.UnparsedVersion, - Cause: fmt.Sprintf("no match for requested version %s", tool.UnparsedVersion), + if !useNix { + versionExists, err := m.versionExists(tool.ToolName, tool.UnparsedVersion) + if err != nil { + return provider.ToolInstallResult{}, fmt.Errorf("check if version exists: %w", err) } - } + if !versionExists { + return provider.ToolInstallResult{}, provider.ToolInstallError{ + ToolName: tool.ToolName, + RequestedVersion: tool.UnparsedVersion, + Cause: fmt.Sprintf("no match for requested version %s", tool.UnparsedVersion), + } + } + } // else: version existence is already checked in canBeInstalledWithNix() - err = m.installToolVersion(tool) + err = m.installToolVersion(installRequest) if err != nil { return provider.ToolInstallResult{}, err } - concreteVersion, err := m.resolveToConcreteVersionAfterInstall(tool) + concreteVersion, err := m.resolveToConcreteVersionAfterInstall(installRequest) if err != nil { return provider.ToolInstallResult{}, fmt.Errorf("resolve exact version after install: %w", err) } return provider.ToolInstallResult{ - ToolName: tool.ToolName, + // Note: we return installRequest.ToolName instead of the original tool.ToolName. + // This is because installRequest might use a custom backend plugin and the value returned here + // is what gets used in ActivateEnv(), the two should be consistent. + ToolName: installRequest.ToolName, IsAlreadyInstalled: isAlreadyInstalled, ConcreteVersion: concreteVersion, }, nil @@ -149,19 +158,21 @@ func (m *MiseToolProvider) ActivateEnv(result provider.ToolInstallResult) (provi activationResult := processEnvOutput(envs) // Some core plugins create shims to executables (e.g. npm). These shims call `mise reshim` and require the `mise` binary to be in $PATH. - miseExecPath := filepath.Join(m.ExecEnv.InstallDir, "bin") + miseExecPath := filepath.Join(m.ExecEnv.InstallDir(), "bin") activationResult.ContributedPaths = append(activationResult.ContributedPaths, miseExecPath) return activationResult, nil } func isEdgeStack() (isEdge bool) { - if stack, variablePresent := os.LookupEnv("BITRISEIO_STACK_ID"); variablePresent && strings.Contains(stack, "edge") { - isEdge = true - } else { - isEdge = false - } - log.Debugf("Mise: Stack is edge: %s", isEdge) - return + // if stack, variablePresent := os.LookupEnv("BITRISEIO_STACK_ID"); variablePresent && strings.Contains(stack, "edge") { + // isEdge = true + // } else { + // isEdge = false + // } + // log.Debugf("[TOOLPROVIDER] Stack is edge: %s", isEdge) + // return + // TODO: temporary + return true } func GetMiseVersion() string { diff --git a/toolprovider/mise/nixpkgs/nixpkgs.go b/toolprovider/mise/nixpkgs/nixpkgs.go new file mode 100644 index 000000000..03d802b5e --- /dev/null +++ b/toolprovider/mise/nixpkgs/nixpkgs.go @@ -0,0 +1,53 @@ +package nixpkgs + +import ( + "fmt" + "os" + "os/exec" + "strings" + + "github.com/bitrise-io/bitrise/v2/log" + "github.com/bitrise-io/bitrise/v2/toolprovider/provider" +) + +const ( + PluginGitURL = "https://github.com/bitrise-io/mise-nixpkgs-plugin.git" + PluginName = "nixpkgs" +) + +func ShouldUseBackend(request provider.ToolRequest) (bool, error) { + if request.ToolName != "ruby" { + log.Debugf("[TOOLPROVIDER] The mise-nixpkgs backend is only enabled for Ruby for now. Using core plugin to install %s", request.ToolName) + return false, nil + } + + value, ok := os.LookupEnv("BITRISE_TOOLSETUP_FAST_INSTALL") + if !ok || strings.TrimSpace(value) != "true" { + log.Debugf("[TOOLPROVIDER] Using core mise plugin for %s", request.ToolName) + return false, nil + } + + if !isNixAvailable() { + log.Debugf("[TOOLPROVIDER] Nix is not available on the system, cannot use nixpkgs backend for %s", request.ToolName) + return false, fmt.Errorf("nix not available on system") + } + + log.Debugf("[TOOLPROVIDER] Using nixpkgs backend for %s as BITRISE_TOOLSETUP_FAST_INSTALL is set", request.ToolName) + return true, nil +} + +func isNixAvailable() bool { + _, err := exec.LookPath("nix") + if err != nil { + return false + } + + cmd := exec.Command("nix", "--version") + out, err := cmd.CombinedOutput() + if err != nil { + log.Debugf("[TOOLPROVIDER] Exec nix --version failed: %v\nOutput: %s", err, string(out)) + return false + } + + return true +} diff --git a/toolprovider/mise/resolve.go b/toolprovider/mise/resolve.go index f273a63b4..76ad6d347 100644 --- a/toolprovider/mise/resolve.go +++ b/toolprovider/mise/resolve.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "strings" - "time" "github.com/bitrise-io/bitrise/v2/toolprovider/mise/execenv" "github.com/bitrise-io/bitrise/v2/toolprovider/provider" @@ -13,11 +12,6 @@ import ( var errNoMatchingVersion = errors.New("no matching version found") -// MiseExecutor defines the interface for executing mise commands -type MiseExecutor interface { - RunMiseWithTimeout(timeout time.Duration, args ...string) (string, error) -} - func (m *MiseToolProvider) resolveToConcreteVersionAfterInstall(tool provider.ToolRequest) (string, error) { // Mise doesn't tell us what version it resolved to when installing the user-provided (and potentially fuzzy) version. // But we can use `mise latest` to find out the concrete version. @@ -35,12 +29,12 @@ func (m *MiseToolProvider) resolveToConcreteVersionAfterInstall(tool provider.To } func (m *MiseToolProvider) resolveToLatestReleased(toolName provider.ToolID, version string) (string, error) { - return resolveToLatestReleased(&m.ExecEnv, toolName, version) + return resolveToLatestReleased(m.ExecEnv, toolName, version) } -func resolveToLatestReleased(executor MiseExecutor, toolName provider.ToolID, version string) (string, error) { +func resolveToLatestReleased(execEnv execenv.ExecEnv, toolName provider.ToolID, version string) (string, error) { // Even if version is empty string "sometool@" will not cause an error. - output, err := executor.RunMiseWithTimeout(execenv.DefaultTimeout, "latest", fmt.Sprintf("%s@%s", toolName, version)) + output, err := execEnv.RunMiseWithTimeout(execenv.DefaultTimeout, "latest", fmt.Sprintf("%s@%s", toolName, version)) if err != nil { return "", fmt.Errorf("mise latest %s@%s: %w", toolName, version, err) } @@ -54,10 +48,10 @@ func resolveToLatestReleased(executor MiseExecutor, toolName provider.ToolID, ve } func (m *MiseToolProvider) resolveToLatestInstalled(toolName provider.ToolID, version string) (string, error) { - return resolveToLatestInstalled(&m.ExecEnv, toolName, version) + return resolveToLatestInstalled(m.ExecEnv, toolName, version) } -func resolveToLatestInstalled(executor MiseExecutor, toolName provider.ToolID, version string) (string, error) { +func resolveToLatestInstalled(execEnv execenv.ExecEnv, toolName provider.ToolID, version string) (string, error) { // Even if version is empty string "sometool@" will not cause an error. var toolString = string(toolName) if version != "" && version != "installed" { @@ -65,7 +59,7 @@ func resolveToLatestInstalled(executor MiseExecutor, toolName provider.ToolID, v toolString = fmt.Sprintf("%s@%s", toolName, version) } - output, err := executor.RunMiseWithTimeout(execenv.DefaultTimeout, "latest", "--installed", "--quiet", toolString) + output, err := execEnv.RunMiseWithTimeout(execenv.DefaultTimeout, "latest", "--installed", "--quiet", toolString) if err != nil { return "", fmt.Errorf("mise latest --installed %s: %w", toolString, err) } @@ -79,13 +73,13 @@ func resolveToLatestInstalled(executor MiseExecutor, toolName provider.ToolID, v } func (m *MiseToolProvider) versionExists(toolName provider.ToolID, version string) (bool, error) { - return versionExists(&m.ExecEnv, toolName, version) + return versionExists(m.ExecEnv, toolName, version) } -func versionExists(executor MiseExecutor, toolName provider.ToolID, version string) (bool, error) { +func versionExists(execEnv execenv.ExecEnv, toolName provider.ToolID, version string) (bool, error) { if version == "installed" { // List all installed versions to see if there is at least one version available. - output, err := executor.RunMiseWithTimeout(execenv.DefaultTimeout, "ls", "--installed", "--json", "--quiet", string(toolName)) + output, err := execEnv.RunMiseWithTimeout(execenv.DefaultTimeout, "ls", "--installed", "--json", "--quiet", string(toolName)) if err != nil { return false, fmt.Errorf("mise ls --installed %s: %w", toolName, err) } @@ -103,9 +97,9 @@ func versionExists(executor MiseExecutor, toolName provider.ToolID, version stri // Fallback: no installed versions found, fall through to remote (ls-remote) existence check. } - search := string(toolName) + versionString := string(toolName) if version != "" && version != "latest" && version != "installed" { - search = fmt.Sprintf("%s@%s", toolName, version) + versionString = fmt.Sprintf("%s@%s", toolName, version) } // Notes: @@ -113,9 +107,9 @@ func versionExists(executor MiseExecutor, toolName provider.ToolID, version stri // - it can return multiple versions (one per line) when a fuzzy version is provided // - in case of no matching version, the exit code is still 0, just there is no output // - in case of a non-existing tool, the exit code is 1, but a non-existing tool ID fails earlier than this check - output, err := executor.RunMiseWithTimeout(execenv.DefaultTimeout, "ls-remote", "--quiet", search) + output, err := execEnv.RunMiseWithTimeout(execenv.DefaultTimeout, "ls-remote", "--quiet", versionString) if err != nil { - return false, fmt.Errorf("mise ls-remote %s: %w", search, err) + return false, fmt.Errorf("mise ls-remote %s: %w", versionString, err) } return strings.TrimSpace(string(output)) != "", nil diff --git a/toolprovider/mise/resolve_test.go b/toolprovider/mise/resolve_test.go index d643807b4..c84611751 100644 --- a/toolprovider/mise/resolve_test.go +++ b/toolprovider/mise/resolve_test.go @@ -2,51 +2,12 @@ package mise import ( "fmt" - "strings" "testing" - "time" "github.com/bitrise-io/bitrise/v2/toolprovider/provider" ) -// mockMiseExecutor is a mock implementation of MiseExecutor for testing -type mockMiseExecutor struct { - // responses maps command strings to their outputs - responses map[string]string - // errors maps command strings to errors - errors map[string]error -} - -func newMockMiseExecutor() *mockMiseExecutor { - return &mockMiseExecutor{ - responses: make(map[string]string), - errors: make(map[string]error), - } -} - -func (m *mockMiseExecutor) setResponse(cmdKey string, output string) { - m.responses[cmdKey] = output -} - -func (m *mockMiseExecutor) setError(cmdKey string, err error) { - m.errors[cmdKey] = err -} - -func (m *mockMiseExecutor) RunMiseWithTimeout(timeout time.Duration, args ...string) (string, error) { - cmdKey := strings.Join(args, " ") - - if err, ok := m.errors[cmdKey]; ok { - return "", err - } - - if output, ok := m.responses[cmdKey]; ok { - return output, nil - } - - return "", fmt.Errorf("no mock response configured for command: %s", cmdKey) -} - -// Helper functions to construct mise command strings for mocking +// Helper functions to construct mise command strings for mocking. func miseLatestCmd(tool provider.ToolID, version string) string { return fmt.Sprintf("latest %s@%s", tool, version) @@ -125,7 +86,7 @@ func TestVersionExists(t *testing.T) { name string toolName provider.ToolID version string - setupMock func(*mockMiseExecutor) + setupFake func(*fakeExecEnv) expectedExists bool wantErr bool }{ @@ -133,7 +94,7 @@ func TestVersionExists(t *testing.T) { name: "version exists in ls-remote", toolName: "ruby", version: "3.3.0", - setupMock: func(m *mockMiseExecutor) { + setupFake: func(m *fakeExecEnv) { m.setResponse(miseLsRemoteCmd("ruby", "3.3.0"), "3.3.0\n3.3.1\n3.3.2") }, expectedExists: true, @@ -143,7 +104,7 @@ func TestVersionExists(t *testing.T) { name: "version does not exist in ls-remote", toolName: "ruby", version: "9.9.9", - setupMock: func(m *mockMiseExecutor) { + setupFake: func(m *fakeExecEnv) { m.setResponse(miseLsRemoteCmd("ruby", "9.9.9"), "") }, expectedExists: false, @@ -153,7 +114,7 @@ func TestVersionExists(t *testing.T) { name: "installed version exists", toolName: "ruby", version: "installed", - setupMock: func(m *mockMiseExecutor) { + setupFake: func(m *fakeExecEnv) { m.setResponse(miseLsInstalledCmd("ruby"), installedVersionsJSON) }, expectedExists: true, @@ -163,7 +124,7 @@ func TestVersionExists(t *testing.T) { name: "installed version does not exist - empty array", toolName: "ruby", version: "installed", - setupMock: func(m *mockMiseExecutor) { + setupFake: func(m *fakeExecEnv) { m.setResponse(miseLsInstalledCmd("ruby"), "[]") // ls-remote called for fallback m.setResponse(miseLsRemoteCmd("ruby", ""), "3.3.0\n3.3.1") @@ -175,7 +136,7 @@ func TestVersionExists(t *testing.T) { name: "installed version with none actually installed - falls through to ls-remote", toolName: "ruby", version: "installed", - setupMock: func(m *mockMiseExecutor) { + setupFake: func(m *fakeExecEnv) { // Entries exist but none are installed m.setResponse(miseLsInstalledCmd("ruby"), `[{"installed":false}]`) // Falls through to ls-remote @@ -188,7 +149,7 @@ func TestVersionExists(t *testing.T) { name: "installed version does not exist - empty response", toolName: "node", version: "installed", - setupMock: func(m *mockMiseExecutor) { + setupFake: func(m *fakeExecEnv) { m.setResponse(miseLsInstalledCmd("node"), "") m.setResponse(miseLsRemoteCmd("node", ""), "") }, @@ -199,7 +160,7 @@ func TestVersionExists(t *testing.T) { name: "latest version", toolName: "go", version: "latest", - setupMock: func(m *mockMiseExecutor) { + setupFake: func(m *fakeExecEnv) { m.setResponse(miseLsRemoteCmd("go", "latest"), "1.21.0\n1.22.0\n1.23.0") }, expectedExists: true, @@ -209,7 +170,7 @@ func TestVersionExists(t *testing.T) { name: "empty version defaults to tool name search", toolName: "python", version: "", - setupMock: func(m *mockMiseExecutor) { + setupFake: func(m *fakeExecEnv) { m.setResponse(miseLsRemoteCmd("python", ""), "3.11.0\n3.12.0") }, expectedExists: true, @@ -219,7 +180,7 @@ func TestVersionExists(t *testing.T) { name: "ls-remote error", toolName: "java", version: "17", - setupMock: func(m *mockMiseExecutor) { + setupFake: func(m *fakeExecEnv) { m.setError(miseLsRemoteCmd("java", "17"), fmt.Errorf("network error")) }, expectedExists: false, @@ -229,7 +190,7 @@ func TestVersionExists(t *testing.T) { name: "ls installed error", toolName: "ruby", version: "installed", - setupMock: func(m *mockMiseExecutor) { + setupFake: func(m *fakeExecEnv) { m.setError(miseLsInstalledCmd("ruby"), fmt.Errorf("command failed")) }, expectedExists: false, @@ -239,7 +200,7 @@ func TestVersionExists(t *testing.T) { name: "installed with malformed JSON", toolName: "ruby", version: "installed", - setupMock: func(m *mockMiseExecutor) { + setupFake: func(m *fakeExecEnv) { m.setResponse(miseLsInstalledCmd("ruby"), `{"invalid": "json"}`) }, expectedExists: false, @@ -249,10 +210,10 @@ func TestVersionExists(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - mock := newMockMiseExecutor() - tt.setupMock(mock) + fake := newFakeExecEnv() + tt.setupFake(fake) - exists, err := versionExists(mock, tt.toolName, tt.version) + exists, err := versionExists(fake, tt.toolName, tt.version) if tt.wantErr && err == nil { t.Errorf("expected error but got nil") @@ -272,7 +233,7 @@ func TestResolveToLatestReleased(t *testing.T) { name string toolName provider.ToolID version string - setupMock func(*mockMiseExecutor) + setupFake func(*fakeExecEnv) expectedVersion string wantErr bool }{ @@ -280,7 +241,7 @@ func TestResolveToLatestReleased(t *testing.T) { name: "resolve specific version", toolName: "ruby", version: "3.3", - setupMock: func(m *mockMiseExecutor) { + setupFake: func(m *fakeExecEnv) { m.setResponse(miseLatestCmd("ruby", "3.3"), "3.3.8") }, expectedVersion: "3.3.8", @@ -290,7 +251,7 @@ func TestResolveToLatestReleased(t *testing.T) { name: "resolve latest version with empty string", toolName: "node", version: "", - setupMock: func(m *mockMiseExecutor) { + setupFake: func(m *fakeExecEnv) { m.setResponse(miseLatestCmd("node", ""), "22.11.0") }, expectedVersion: "22.11.0", @@ -300,7 +261,7 @@ func TestResolveToLatestReleased(t *testing.T) { name: "resolve exact version", toolName: "go", version: "1.23.0", - setupMock: func(m *mockMiseExecutor) { + setupFake: func(m *fakeExecEnv) { m.setResponse(miseLatestCmd("go", "1.23.0"), "1.23.0") }, expectedVersion: "1.23.0", @@ -310,7 +271,7 @@ func TestResolveToLatestReleased(t *testing.T) { name: "no matching version - empty output", toolName: "python", version: "9.9", - setupMock: func(m *mockMiseExecutor) { + setupFake: func(m *fakeExecEnv) { m.setResponse(miseLatestCmd("python", "9.9"), "") }, expectedVersion: "", @@ -320,7 +281,7 @@ func TestResolveToLatestReleased(t *testing.T) { name: "command error", toolName: "java", version: "17", - setupMock: func(m *mockMiseExecutor) { + setupFake: func(m *fakeExecEnv) { m.setError(miseLatestCmd("java", "17"), fmt.Errorf("network timeout")) }, expectedVersion: "", @@ -330,7 +291,7 @@ func TestResolveToLatestReleased(t *testing.T) { name: "output with whitespace is trimmed", toolName: "ruby", version: "3.4", - setupMock: func(m *mockMiseExecutor) { + setupFake: func(m *fakeExecEnv) { m.setResponse(miseLatestCmd("ruby", "3.4"), " 3.4.1 \n") }, expectedVersion: "3.4.1", @@ -340,7 +301,7 @@ func TestResolveToLatestReleased(t *testing.T) { name: "fuzzy version prefix", toolName: "node", version: "20", - setupMock: func(m *mockMiseExecutor) { + setupFake: func(m *fakeExecEnv) { m.setResponse(miseLatestCmd("node", "20"), "20.18.1") }, expectedVersion: "20.18.1", @@ -350,8 +311,8 @@ func TestResolveToLatestReleased(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - mock := newMockMiseExecutor() - tt.setupMock(mock) + mock := newFakeExecEnv() + tt.setupFake(mock) version, err := resolveToLatestReleased(mock, tt.toolName, tt.version) @@ -376,7 +337,7 @@ func TestResolveToLatestInstalled(t *testing.T) { name string toolName provider.ToolID version string - setupMock func(*mockMiseExecutor) + setupFake func(*fakeExecEnv) expectedVersion string wantErr bool }{ @@ -384,7 +345,7 @@ func TestResolveToLatestInstalled(t *testing.T) { name: "resolve latest installed for fuzzy version", toolName: "ruby", version: "3.3", - setupMock: func(m *mockMiseExecutor) { + setupFake: func(m *fakeExecEnv) { m.setResponse(miseLatestInstalledCmd("ruby", "3.3"), "3.3.8") }, expectedVersion: "3.3.8", @@ -394,7 +355,7 @@ func TestResolveToLatestInstalled(t *testing.T) { name: "resolve latest installed with empty version", toolName: "node", version: "", - setupMock: func(m *mockMiseExecutor) { + setupFake: func(m *fakeExecEnv) { m.setResponse(miseLatestInstalledCmd("node", ""), "22.10.0") }, expectedVersion: "22.10.0", @@ -404,7 +365,7 @@ func TestResolveToLatestInstalled(t *testing.T) { name: "exact version already installed", toolName: "go", version: "1.23.0", - setupMock: func(m *mockMiseExecutor) { + setupFake: func(m *fakeExecEnv) { m.setResponse(miseLatestInstalledCmd("go", "1.23.0"), "1.23.0") }, expectedVersion: "1.23.0", @@ -414,7 +375,7 @@ func TestResolveToLatestInstalled(t *testing.T) { name: "no matching installed version - empty output", toolName: "python", version: "3.12", - setupMock: func(m *mockMiseExecutor) { + setupFake: func(m *fakeExecEnv) { m.setResponse(miseLatestInstalledCmd("python", "3.12"), "") }, expectedVersion: "", @@ -424,7 +385,7 @@ func TestResolveToLatestInstalled(t *testing.T) { name: "command error - not installed", toolName: "java", version: "21", - setupMock: func(m *mockMiseExecutor) { + setupFake: func(m *fakeExecEnv) { m.setError(miseLatestInstalledCmd("java", "21"), fmt.Errorf("no versions installed")) }, expectedVersion: "", @@ -434,7 +395,7 @@ func TestResolveToLatestInstalled(t *testing.T) { name: "output with whitespace is trimmed", toolName: "ruby", version: "3.4", - setupMock: func(m *mockMiseExecutor) { + setupFake: func(m *fakeExecEnv) { m.setResponse(miseLatestInstalledCmd("ruby", "3.4"), "\n 3.4.1 \n") }, expectedVersion: "3.4.1", @@ -444,7 +405,7 @@ func TestResolveToLatestInstalled(t *testing.T) { name: "multiple versions installed - returns latest", toolName: "node", version: "20", - setupMock: func(m *mockMiseExecutor) { + setupFake: func(m *fakeExecEnv) { // mise latest returns only the highest matching version m.setResponse(miseLatestInstalledCmd("node", "20"), "20.18.0") }, @@ -455,18 +416,29 @@ func TestResolveToLatestInstalled(t *testing.T) { name: "latest installed without version constraint", toolName: "python", version: "", - setupMock: func(m *mockMiseExecutor) { + setupFake: func(m *fakeExecEnv) { m.setResponse(miseLatestInstalledCmd("python", ""), "3.13.0") }, expectedVersion: "3.13.0", wantErr: false, }, + { + name: "tool name with backend prefix", + toolName: "nixpkgs:ruby", + version: "", + setupFake: func(m *fakeExecEnv) { + m.setResponse(miseLatestInstalledCmd("nixpkgs:ruby", ""), "3.3.7") + m.setResponse(miseLatestInstalledCmd("ruby", ""), "3.13.0") + }, + expectedVersion: "3.3.7", + wantErr: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - mock := newMockMiseExecutor() - tt.setupMock(mock) + mock := newFakeExecEnv() + tt.setupFake(mock) version, err := resolveToLatestInstalled(mock, tt.toolName, tt.version)