Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 25 additions & 21 deletions internal/update/installs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
102 changes: 102 additions & 0 deletions internal/update/installs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
82 changes: 82 additions & 0 deletions internal/update/installs_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
package update

import (
"io"
"os"
"path/filepath"
"strings"
)

// executableCandidates returns the lstk executables present in dir. On Unix
Expand All @@ -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
}
4 changes: 4 additions & 0 deletions internal/update/installs_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,7 @@ func executableCandidates(dir string, getenv func(string) string) []string {
}
return out
}

func executableAlias(candidate string, _ []string) string {
return candidate
}
53 changes: 53 additions & 0 deletions test/integration/multiple_installs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading