diff --git a/internal/update/installs.go b/internal/update/installs.go index 6c1c8ac3..2e631533 100644 --- a/internal/update/installs.go +++ b/internal/update/installs.go @@ -26,34 +26,38 @@ type Install struct { func FindInstalls(getenv func(string) string) []Install { runningInfo, runningResolved := runningExecutable() - var installs []Install - var seen []os.FileInfo + var candidates []string for _, dir := range filepath.SplitList(getenv("PATH")) { // Relative and empty entries (cwd-dependent lookup) are skipped: they // resolve differently per invocation and are not real install locations. if dir == "" || !filepath.IsAbs(dir) { continue } - for _, candidate := range executableCandidates(dir, getenv) { - resolved, err := filepath.EvalSymlinks(candidate) - if err != nil { - resolved = candidate - } - info, err := os.Stat(resolved) - if err != nil { - continue - } - if isDuplicate(seen, info) { - continue - } - seen = append(seen, info) - installs = append(installs, Install{ - Path: candidate, - ResolvedPath: resolved, - Method: classifyPath(resolved), - Running: isRunning(info, resolved, runningInfo, runningResolved), - }) + candidates = append(candidates, executableCandidates(dir, getenv)...) + } + + var installs []Install + var seen []os.FileInfo + for _, candidate := range candidates { + alias := executableAlias(candidate, candidates) + resolved, err := filepath.EvalSymlinks(alias) + if err != nil { + resolved = alias + } + info, err := os.Stat(resolved) + if err != nil { + continue + } + if isDuplicate(seen, info) { + continue } + seen = append(seen, info) + installs = append(installs, Install{ + Path: candidate, + ResolvedPath: resolved, + Method: classifyPath(resolved), + Running: isRunning(info, resolved, runningInfo, runningResolved), + }) } return installs } diff --git a/internal/update/installs_test.go b/internal/update/installs_test.go index 87ee3067..a0c11987 100644 --- a/internal/update/installs_test.go +++ b/internal/update/installs_test.go @@ -63,6 +63,108 @@ func TestFindInstallsDeduplicatesSymlinkAliases(t *testing.T) { } } +func TestFindInstallsDeduplicatesAsdfShimAlias(t *testing.T) { + t.Parallel() + asdfDataDir := t.TempDir() + installBin := filepath.Join(asdfDataDir, "installs", "nodejs", "22.22.0", "bin") + npmPackage := filepath.Join(asdfDataDir, "installs", "nodejs", "22.22.0", "lib", "node_modules", "@localstack", "lstk") + shimsDir := filepath.Join(asdfDataDir, "shims") + for _, dir := range []string{installBin, npmPackage, shimsDir} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + } + + launcher := writeFakeExecutable(t, npmPackage) + installPath := filepath.Join(installBin, binaryName) + if err := os.Symlink(launcher, installPath); err != nil { + t.Fatal(err) + } + shimPath := filepath.Join(shimsDir, binaryName) + shim := "#!/usr/bin/env bash\n# asdf-plugin: nodejs 22.22.0\nexec asdf exec \"lstk\" \"$@\"\n" + if err := os.WriteFile(shimPath, []byte(shim), 0o755); err != nil { + t.Fatal(err) + } + + for _, dirs := range [][]string{{installBin, shimsDir}, {shimsDir, installBin}} { + installs := FindInstalls(pathGetenv(dirs...)) + if len(installs) != 1 { + t.Fatalf("expected asdf shim and npm launcher to be one install, got %d: %+v", len(installs), installs) + } + if installs[0].Path != filepath.Join(dirs[0], binaryName) { + t.Errorf("expected first PATH hit to be reported, got %s", installs[0].Path) + } + if installs[0].Method != InstallNPM { + t.Errorf("expected npm install, got %s", installs[0].Method) + } + } +} + +func TestFindInstallsDeduplicatesMiseShimAlias(t *testing.T) { + t.Parallel() + miseDataDir := t.TempDir() + versionDir := filepath.Join(miseDataDir, "installs", "github-localstack-lstk", "0.18.0") + shimsDir := filepath.Join(miseDataDir, "shims") + miseBinDir := t.TempDir() + for _, dir := range []string{versionDir, shimsDir} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + } + + writeFakeExecutable(t, versionDir) + latestDir := filepath.Join(miseDataDir, "installs", "github-localstack-lstk", "latest") + if err := os.Symlink("./0.18.0", latestDir); err != nil { + t.Fatal(err) + } + // mise shims are symlinks to the mise binary itself (argv[0] dispatch). + miseBinary := filepath.Join(miseBinDir, "mise") + if err := os.WriteFile(miseBinary, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(miseBinary, filepath.Join(shimsDir, binaryName)); err != nil { + t.Fatal(err) + } + + for _, dirs := range [][]string{{latestDir, shimsDir}, {shimsDir, latestDir}} { + installs := FindInstalls(pathGetenv(dirs...)) + if len(installs) != 1 { + t.Fatalf("expected mise shim and install to be one install, got %d: %+v", len(installs), installs) + } + if installs[0].Path != filepath.Join(dirs[0], binaryName) { + t.Errorf("expected first PATH hit to be reported, got %s", installs[0].Path) + } + if installs[0].Method != InstallBinary { + t.Errorf("expected binary install, got %s", installs[0].Method) + } + } +} + +func TestFindInstallsKeepsMiseShimWithoutBackingInstallOnPath(t *testing.T) { + t.Parallel() + miseDataDir := t.TempDir() + shimsDir := filepath.Join(miseDataDir, "shims") + miseBinDir, otherDir := t.TempDir(), t.TempDir() + if err := os.MkdirAll(shimsDir, 0o755); err != nil { + t.Fatal(err) + } + + miseBinary := filepath.Join(miseBinDir, "mise") + if err := os.WriteFile(miseBinary, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(miseBinary, filepath.Join(shimsDir, binaryName)); err != nil { + t.Fatal(err) + } + writeFakeExecutable(t, otherDir) + + installs := FindInstalls(pathGetenv(otherDir, shimsDir)) + + if len(installs) != 2 { + t.Fatalf("expected shim without a backing install on PATH to stay a distinct install, got %d: %+v", len(installs), installs) + } +} + func TestFindInstallsDeduplicatesRepeatedPathDir(t *testing.T) { t.Parallel() dir := t.TempDir() diff --git a/internal/update/installs_unix.go b/internal/update/installs_unix.go index e80ecb82..1ba342f9 100644 --- a/internal/update/installs_unix.go +++ b/internal/update/installs_unix.go @@ -3,8 +3,10 @@ package update import ( + "io" "os" "path/filepath" + "strings" ) // executableCandidates returns the lstk executables present in dir. On Unix @@ -20,3 +22,83 @@ func executableCandidates(dir string, _ func(string) string) []string { } return []string{path} } + +// executableAlias resolves a version-manager shim to the matching installed +// executable when both appear on PATH. Shims are dispatchers, not separate +// installations, but os.SameFile cannot identify that relationship. +func executableAlias(candidate string, candidates []string) string { + if filepath.Base(filepath.Dir(candidate)) != "shims" { + return candidate + } + // asdf shims are scripts naming their backing plugin/version in a + // comment; prefer that exact mapping. + for _, target := range asdfShimTargets(candidate) { + for _, other := range candidates { + if filepath.Clean(target) == filepath.Clean(other) { + return other + } + } + } + return dispatcherShimAlias(candidate, candidates) +} + +// dispatcherShimAlias handles argv[0]-dispatch shims: mise shims are symlinks +// to the mise binary itself, so neither file identity nor shim content can +// reveal the backing install. A shim symlink-resolving to a foreign-named +// executable is such a dispatcher; it aliases to the first PATH candidate +// inside the sibling installs/ tree (shims/ and installs/ always share the +// mise data dir — there is no override that separates them). The true +// dispatch target is resolved by mise per-invocation and cannot be known +// here, so a candidate from the same installs/ tree is trusted to be it; +// anything under that tree is managed by the same version manager, not a +// competing installation this warning is meant to catch. +func dispatcherShimAlias(candidate string, candidates []string) string { + resolved, err := filepath.EvalSymlinks(candidate) + if err != nil || filepath.Base(resolved) == binaryName { + return candidate + } + installsPrefix := filepath.Join(filepath.Dir(filepath.Dir(candidate)), "installs") + string(filepath.Separator) + for _, other := range candidates { + if other != candidate && strings.HasPrefix(filepath.Clean(other), installsPrefix) { + return other + } + } + return candidate +} + +func asdfShimTargets(candidate string) []string { + shimsDir := filepath.Dir(candidate) + if filepath.Base(shimsDir) != "shims" { + return nil + } + + f, err := os.Open(candidate) + if err != nil { + return nil + } + defer func() { _ = f.Close() }() + + content, err := io.ReadAll(io.LimitReader(f, 4096)) + if err != nil { + return nil + } + + asdfDataDir := filepath.Dir(shimsDir) + var targets []string + for line := range strings.SplitSeq(string(content), "\n") { + fields := strings.Fields(line) + if len(fields) != 4 || fields[0] != "#" || fields[1] != "asdf-plugin:" { + continue + } + plugin, version := fields[2], fields[3] + if !isPathSegment(plugin) || !isPathSegment(version) { + continue + } + targets = append(targets, filepath.Join(asdfDataDir, "installs", plugin, version, "bin", binaryName)) + } + return targets +} + +func isPathSegment(value string) bool { + return value != "" && value != "." && value != ".." && filepath.Base(value) == value +} diff --git a/internal/update/installs_windows.go b/internal/update/installs_windows.go index d9da91e8..7daeacce 100644 --- a/internal/update/installs_windows.go +++ b/internal/update/installs_windows.go @@ -37,3 +37,7 @@ func executableCandidates(dir string, getenv func(string) string) []string { } return out } + +func executableAlias(candidate string, _ []string) string { + return candidate +} diff --git a/test/integration/multiple_installs_test.go b/test/integration/multiple_installs_test.go index 083f2b15..953b358d 100644 --- a/test/integration/multiple_installs_test.go +++ b/test/integration/multiple_installs_test.go @@ -73,6 +73,59 @@ func TestUpdateCheckDoesNotWarnOnSymlinkedAliases(t *testing.T) { require.NotContains(t, stdout, "Multiple lstk installations found") } +func TestUpdateCheckDoesNotWarnOnAsdfShimAlias(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("asdf shell shims are Unix-specific") + } + t.Parallel() + asdfDataDir := t.TempDir() + installBin := filepath.Join(asdfDataDir, "installs", "nodejs", "22.22.0", "bin") + npmPackage := filepath.Join(asdfDataDir, "installs", "nodejs", "22.22.0", "lib", "node_modules", "@localstack", "lstk") + shimsDir := filepath.Join(asdfDataDir, "shims") + for _, dir := range []string{installBin, npmPackage, shimsDir} { + require.NoError(t, os.MkdirAll(dir, 0o755)) + } + + npmBinary := copyBinaryTo(t, npmPackage) + require.NoError(t, os.Symlink(npmBinary, filepath.Join(installBin, "lstk"))) + shim := "#!/usr/bin/env bash\n# asdf-plugin: nodejs 22.22.0\nexec asdf exec \"lstk\" \"$@\"\n" + require.NoError(t, os.WriteFile(filepath.Join(shimsDir, "lstk"), []byte(shim), 0o755)) + + environ := env.Environ(testEnvWithHome(t.TempDir(), "")). + With(env.Path, installBin+string(os.PathListSeparator)+shimsDir) + + stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), environ, "update", "--check") + require.NoError(t, err, stderr) + require.NotContains(t, stdout, "Multiple lstk installations found") +} + +func TestUpdateCheckDoesNotWarnOnMiseShimAlias(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("mise symlink shims are Unix-specific") + } + t.Parallel() + miseDataDir := t.TempDir() + versionDir := filepath.Join(miseDataDir, "installs", "github-localstack-lstk", "0.18.0") + shimsDir := filepath.Join(miseDataDir, "shims") + require.NoError(t, os.MkdirAll(versionDir, 0o755)) + require.NoError(t, os.MkdirAll(shimsDir, 0o755)) + + copyBinaryTo(t, versionDir) + latestDir := filepath.Join(miseDataDir, "installs", "github-localstack-lstk", "latest") + require.NoError(t, os.Symlink("./0.18.0", latestDir)) + // mise shims are symlinks to the mise binary itself (argv[0] dispatch). + miseBinary := filepath.Join(t.TempDir(), "mise") + require.NoError(t, os.WriteFile(miseBinary, []byte("#!/bin/sh\nexit 0\n"), 0o755)) + require.NoError(t, os.Symlink(miseBinary, filepath.Join(shimsDir, "lstk"))) + + environ := env.Environ(testEnvWithHome(t.TempDir(), "")). + With(env.Path, latestDir+string(os.PathListSeparator)+shimsDir) + + stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), environ, "update", "--check") + require.NoError(t, err, stderr) + require.NotContains(t, stdout, "Multiple lstk installations found") +} + func TestUpdateCheckJSONReportsMultipleInstallsWarning(t *testing.T) { t.Parallel() dirA, dirB := t.TempDir(), t.TempDir()