Files
pmg/internal/shim/shim_test.go
T
6546116e28 feat: migrate PMG to use PATH shims for package manager wrapper (#246)
* feat: add FilterPMGFromPath utility for PATH shim recursion prevention

* feat: add FilterPMGFromEnv to filter PATH from env slices

* feat: filter ~/.pmg/bin from PATH in proxy subprocess env

* feat: add PathExport method to Shell interface for shim PATH integration

* feat: add ShimManager for PATH shim install/remove lifecycle

* feat: wire ShimManager into setup commands with --use-aliases fallback

* refactor: add DefaultShimConfig helper to reduce setup boilerplate

* fix: resolve real binary path to prevent shim double-invocation

exec.CommandContext resolves the binary using the current process PATH,
which still contains ~/.pmg/bin. This caused pmg to launch the shim
instead of the real package manager, resulting in a second pmg instance
with its own proxy — producing duplicate error messages and wasted work.

ResolveRealBinary searches a filtered PATH (without ~/.pmg/bin) to find
the real package manager binary before execution.

* fix: resolve real binary in runner.Execute and expand path resolution tests

Ensure guard mode and proxy skip paths also resolve through
ResolveRealBinary to prevent infinite shim recursion. Add table-driven
tests covering error cases, multi-binary PATH, and PATH restoration.

* fix: handle error return values from os.Setenv and file Close calls

Address errcheck lint failures: check os.Setenv returns in
ResolveRealBinary, and check f.Close/tempFile.Close in ShimManager.

* feat: auto-migrate shell aliases to PATH shims on setup install

When running `pmg setup install`, detect existing shell aliases and
automatically remove them before installing shims. Existing users
get a seamless migration with no extra flags or commands needed.

* fix: update E2E test to verify shim installation instead of alias RC file

Replace the .pmg.rc file check with assertions that ~/.pmg/bin/ exists
and contains executable shim scripts for npm and pip.

* feat: add FilterPMGFromPath utility for PATH shim recursion prevention

* feat: add FilterPMGFromEnv to filter PATH from env slices

* feat: filter ~/.pmg/bin from PATH in proxy subprocess env

* feat: add PathExport method to Shell interface for shim PATH integration

* feat: add ShimManager for PATH shim install/remove lifecycle

* feat: wire ShimManager into setup commands with --use-aliases fallback

* refactor: add DefaultShimConfig helper to reduce setup boilerplate

* fix: resolve real binary path to prevent shim double-invocation

exec.CommandContext resolves the binary using the current process PATH,
which still contains ~/.pmg/bin. This caused pmg to launch the shim
instead of the real package manager, resulting in a second pmg instance
with its own proxy — producing duplicate error messages and wasted work.

ResolveRealBinary searches a filtered PATH (without ~/.pmg/bin) to find
the real package manager binary before execution.

* fix: resolve real binary in runner.Execute and expand path resolution tests

Ensure guard mode and proxy skip paths also resolve through
ResolveRealBinary to prevent infinite shim recursion. Add table-driven
tests covering error cases, multi-binary PATH, and PATH restoration.

* fix: handle error return values from os.Setenv and file Close calls

Address errcheck lint failures: check os.Setenv returns in
ResolveRealBinary, and check f.Close/tempFile.Close in ShimManager.

* feat: auto-migrate shell aliases to PATH shims on setup install

When running `pmg setup install`, detect existing shell aliases and
automatically remove them before installing shims. Existing users
get a seamless migration with no extra flags or commands needed.

* fix: update E2E test to verify shim installation instead of alias RC file

Replace the .pmg.rc file check with assertions that ~/.pmg/bin/ exists
and contains executable shim scripts for npm and pip.

* feat: install both aliases and shims for full coverage

Aliases win in interactive shells (including venvs), shims catch
non-interactive contexts (IDEs, CI, subprocesses). Remove --use-aliases
flag and migration logic since both are always installed together.
Update E2E to verify all shim scripts and alias RC file.

* feat: address review feedback for shim implementation

- Install both aliases and shims together for full coverage
- Move homeDir resolution into NewDefaultShimManager (internal concern)
- Add mutex to ResolveRealBinary to guard against concurrent PATH mutation
- Use filepath.SplitList for platform-correct PATH splitting
- Add ResolveRealBinary to runner.Execute and proxy flow to prevent
  shim recursion in all execution paths
- Remove print side-effects from ShimManager.Remove
- Update E2E to verify all shim scripts and alias RC file
- Expand ResolveRealBinary tests with table-driven cases

* fix: restore errcheck handling and add concurrency test for ResolveRealBinary

- Restore proper defer with log.Warnf for PATH restoration in ResolveRealBinary
- Restore errcheck handling for f.Close() and tempFile.Close() in ShimManager
- Add explanatory comment for ResolveRealBinary call in proxy_flow
- Add TestResolveRealBinaryConcurrent to verify mutex guards concurrent access

* feat: skip shell integration on Windows with informative warning

On Windows, pmg setup install now writes only the config file and
prints a warning that shell aliases and PATH shims require WSL.

* fix: PMG use pre-resolved binary path (#253)

---------

Co-authored-by: Abhisek Datta <abhisek.datta@gmail.com>
2026-05-12 22:27:05 +05:30

201 lines
5.9 KiB
Go

package shim
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/safedep/pmg/internal/alias"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestShimManagerInstall(t *testing.T) {
homeDir := t.TempDir()
binDir := filepath.Join(homeDir, ".pmg", "bin")
bashrc := filepath.Join(homeDir, ".bashrc")
zshrc := filepath.Join(homeDir, ".zshrc")
fishConfig := filepath.Join(homeDir, ".config", "fish")
require.NoError(t, os.MkdirAll(fishConfig, 0o755))
require.NoError(t, os.WriteFile(bashrc, []byte("# existing bashrc\n"), 0o644))
require.NoError(t, os.WriteFile(zshrc, []byte("# existing zshrc\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(fishConfig, "config.fish"), []byte("# existing fish config\n"), 0o644))
pms := []string{"npm", "pip"}
pmgBin := filepath.Join(homeDir, "bin", "pmg")
shells := []alias.Shell{
&stubShell{name: "bash", path: ".bashrc", useFish: false},
&stubShell{name: "fish", path: ".config/fish/config.fish", useFish: true},
}
mgr := NewShimManager(ShimConfig{
BinDir: binDir,
HomeDir: homeDir,
PMGBin: pmgBin,
PackageManagers: pms,
Shells: shells,
})
require.NoError(t, mgr.Install())
for _, pm := range pms {
shimPath := filepath.Join(binDir, pm)
info, err := os.Stat(shimPath)
require.NoError(t, err, "shim %s should exist", pm)
assert.NotZero(t, info.Mode()&0o111, "shim %s should be executable", pm)
content, err := os.ReadFile(shimPath)
require.NoError(t, err)
assert.Contains(t, string(content), "#!/bin/sh")
assert.Contains(t, string(content), "PMG_BIN='"+pmgBin+"'")
assert.Contains(t, string(content), `exec "$PMG_BIN" `+pm+` "$@"`)
assert.NotContains(t, string(content), "command -v pmg")
assert.NotContains(t, string(content), "exec pmg")
assert.NotContains(t, string(content), "falling back to native")
}
bashContent, err := os.ReadFile(bashrc)
require.NoError(t, err)
assert.Contains(t, string(bashContent), ".pmg/bin")
fishContent, err := os.ReadFile(filepath.Join(fishConfig, "config.fish"))
require.NoError(t, err)
assert.Contains(t, string(fishContent), ".pmg/bin")
}
func TestShimManagerInstallIdempotent(t *testing.T) {
homeDir := t.TempDir()
binDir := filepath.Join(homeDir, ".pmg", "bin")
bashrc := filepath.Join(homeDir, ".bashrc")
require.NoError(t, os.WriteFile(bashrc, []byte("# existing bashrc\n"), 0o644))
mgr := NewShimManager(ShimConfig{
BinDir: binDir,
HomeDir: homeDir,
PackageManagers: []string{"npm"},
Shells: []alias.Shell{&stubShell{name: "bash", path: ".bashrc", useFish: false}},
})
require.NoError(t, mgr.Install())
require.NoError(t, mgr.Install())
content, err := os.ReadFile(bashrc)
require.NoError(t, err)
count := 0
for _, line := range strings.Split(string(content), "\n") {
if strings.Contains(line, ".pmg/bin") {
count++
}
}
assert.Equal(t, 1, count, "PATH export should appear exactly once")
}
func TestShimManagerRemove(t *testing.T) {
homeDir := t.TempDir()
binDir := filepath.Join(homeDir, ".pmg", "bin")
bashrc := filepath.Join(homeDir, ".bashrc")
require.NoError(t, os.WriteFile(bashrc, []byte("# existing bashrc\n"), 0o644))
mgr := NewShimManager(ShimConfig{
BinDir: binDir,
HomeDir: homeDir,
PackageManagers: []string{"npm"},
Shells: []alias.Shell{&stubShell{name: "bash", path: ".bashrc", useFish: false}},
})
require.NoError(t, mgr.Install())
require.NoError(t, mgr.Remove())
_, err := os.Stat(binDir)
assert.True(t, os.IsNotExist(err), "bin dir should be removed")
content, err := os.ReadFile(bashrc)
require.NoError(t, err)
assert.NotContains(t, string(content), ".pmg/bin")
}
func TestShimManagerIsInstalled(t *testing.T) {
homeDir := t.TempDir()
binDir := filepath.Join(homeDir, ".pmg", "bin")
bashrc := filepath.Join(homeDir, ".bashrc")
require.NoError(t, os.WriteFile(bashrc, []byte("# existing bashrc\n"), 0o644))
mgr := NewShimManager(ShimConfig{
BinDir: binDir,
HomeDir: homeDir,
PackageManagers: []string{"npm"},
Shells: []alias.Shell{&stubShell{name: "bash", path: ".bashrc", useFish: false}},
})
installed, err := mgr.IsInstalled()
require.NoError(t, err)
assert.False(t, installed)
require.NoError(t, mgr.Install())
installed, err = mgr.IsInstalled()
require.NoError(t, err)
assert.True(t, installed)
}
func TestNewDefaultShimManager(t *testing.T) {
mgr, err := NewDefaultShimManager()
require.NoError(t, err)
assert.NotEmpty(t, mgr.GetBinDir())
assert.Contains(t, mgr.GetBinDir(), ".pmg/bin")
assert.NotEmpty(t, mgr.config.PMGBin)
assert.True(t, filepath.IsAbs(mgr.config.PMGBin))
assert.NotEmpty(t, mgr.config.PackageManagers)
assert.Contains(t, mgr.config.PackageManagers, "npm")
assert.Contains(t, mgr.config.PackageManagers, "pip")
assert.NotEmpty(t, mgr.config.Shells)
}
func TestShimManagerInstallEscapesPMGBin(t *testing.T) {
homeDir := t.TempDir()
binDir := filepath.Join(homeDir, ".pmg", "bin")
pmgBin := filepath.Join(homeDir, "PMG's bin", "pmg")
mgr := NewShimManager(ShimConfig{
BinDir: binDir,
HomeDir: homeDir,
PMGBin: pmgBin,
PackageManagers: []string{"npm"},
})
require.NoError(t, mgr.Install())
content, err := os.ReadFile(filepath.Join(binDir, "npm"))
require.NoError(t, err)
assert.Contains(t, string(content), `PMG_BIN='`+homeDir+`/PMG'\''s bin/pmg'`)
assert.NotContains(t, string(content), "command -v pmg")
}
type stubShell struct {
name string
path string
useFish bool
}
func (s *stubShell) Source(rcPath string) string {
return ""
}
func (s *stubShell) PathExport(binDir string) string {
if s.useFish {
return fmt.Sprintf("fish_add_path --prepend \"%s\" # PMG shims\n", binDir)
}
return fmt.Sprintf("export PATH=\"%s:$PATH\" # PMG shims\n", binDir)
}
func (s *stubShell) Name() string { return s.name }
func (s *stubShell) Path() string { return s.path }