diff --git a/internal/detector/nodepm.go b/internal/detector/nodepm.go index a26402e..e819ad8 100644 --- a/internal/detector/nodepm.go +++ b/internal/detector/nodepm.go @@ -35,18 +35,40 @@ func (d *NodePMDetector) DetectManagers(ctx context.Context) []model.PkgManager var results []model.PkgManager for _, pm := range packageManagers { - path, err := d.exec.LookPath(pm.Binary) - if err != nil { - continue + // LookPath returns "" on error, so a failed lookup leaves path empty + // and triggers the default-path fallback below rather than dropping + // the manager outright. + path, _ := d.exec.LookPath(pm.Binary) + + version := "" + if path != "" { + stdout, _, _, err := d.exec.RunWithTimeout(ctx, 10*time.Second, pm.Binary, pm.VersionCmd) + if err == nil { + version = strings.TrimSpace(stdout) + } } - version := "unknown" - stdout, _, _, err := d.exec.RunWithTimeout(ctx, 10*time.Second, pm.Binary, pm.VersionCmd) - if err == nil { - v := strings.TrimSpace(stdout) - if v != "" { - version = v + // Fallback: the binary wasn't on PATH, or it was but --version returned + // nothing — both happen under launchd's stripped PATH when the login + // shell sourcing doesn't surface the manager. Probe the OS-specific + // default install dirs and run the binary by absolute path. + if path == "" || version == "" { + fbPath, fbVersion := resolveNodePMFromDefaults(ctx, d.exec, pm.Binary, pm.VersionCmd) + if path == "" { + path = fbPath } + if version == "" { + version = fbVersion + } + } + + // Found nowhere we know to look — not installed on this device. + if path == "" { + continue + } + + if version == "" { + version = "unknown" } results = append(results, model.PkgManager{ diff --git a/internal/detector/nodepm_fallback.go b/internal/detector/nodepm_fallback.go new file mode 100644 index 0000000..9adcce1 --- /dev/null +++ b/internal/detector/nodepm_fallback.go @@ -0,0 +1,198 @@ +package detector + +import ( + "context" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" +) + +// pmBinaryCandidateDirs returns an ordered list of directories where Node.js +// package managers (npm/yarn/pnpm/bun) are commonly installed on the current +// OS. It is the source list for a deterministic fallback used when a package +// manager can't be resolved on PATH. +// +// Why this exists: under launchd the agent inherits a stripped PATH +// (/usr/bin:/bin:/usr/sbin:/sbin), so a bare LookPath / " --version" can't +// see managers installed via Homebrew/nvm/Volta/bun/etc. The login-shell +// sourcing in executor.RunAsUser fixes most machines, but probing these +// well-known absolute locations is the deterministic backstop for the rest — +// generalizing the pnpm-only defaultPnpmBinDir (nodescan.go) to every manager. +// +// Dirs are anchored on the logged-in user's home (getHomeDir), NOT $HOME, which +// under a LaunchDaemon is /var/root. System dirs come first, then per-user +// package-manager dirs, then nvm's node bin dirs (newest version first). +func pmBinaryCandidateDirs(exec executor.Executor) []string { + home := getHomeDir(exec) + var dirs []string + + switch exec.GOOS() { + case model.PlatformDarwin: + dirs = append(dirs, + "/opt/homebrew/bin", // Apple Silicon Homebrew + "/usr/local/bin", // Intel Homebrew, n, manual installs + ) + if home != "" { + dirs = append(dirs, + filepath.Join(home, ".bun", "bin"), // bun + filepath.Join(home, "Library", "pnpm"), // pnpm (PNPM_HOME) + filepath.Join(home, "Library", "pnpm", "bin"), // pnpm global shims + filepath.Join(home, ".npm-global", "bin"), // npm prefix override + filepath.Join(home, ".volta", "bin"), // Volta shims + filepath.Join(home, ".asdf", "shims"), // asdf shims + ) + dirs = append(dirs, nvmNodeBinDirs(exec, home)...) + } + case model.PlatformLinux: + dirs = append(dirs, + "/usr/bin", + "/usr/local/bin", + "/home/linuxbrew/.linuxbrew/bin", // Linuxbrew (shared install) + ) + if home != "" { + dirs = append(dirs, + filepath.Join(home, ".linuxbrew", "bin"), // Linuxbrew (per-user) + filepath.Join(home, ".bun", "bin"), // bun + filepath.Join(home, ".local", "share", "pnpm"), // pnpm (PNPM_HOME) + filepath.Join(home, ".npm-global", "bin"), // npm prefix override + filepath.Join(home, ".volta", "bin"), // Volta shims + filepath.Join(home, ".asdf", "shims"), // asdf shims + ) + dirs = append(dirs, nvmNodeBinDirs(exec, home)...) + } + case model.PlatformWindows: + // Windows has no launchd/stripped-PATH problem; these are for symmetry. + if appData := exec.Getenv("APPDATA"); appData != "" { + dirs = append(dirs, filepath.Join(appData, "npm")) // npm global + } + if localAppData := exec.Getenv("LOCALAPPDATA"); localAppData != "" { + dirs = append(dirs, + filepath.Join(localAppData, "pnpm"), // pnpm + filepath.Join(localAppData, "Volta", "bin"), // Volta + ) + } + if home != "" { + dirs = append(dirs, filepath.Join(home, ".bun", "bin")) // bun + } + if pf := exec.Getenv("ProgramFiles"); pf != "" { + dirs = append(dirs, filepath.Join(pf, "nodejs")) // node installer + } + // TODO: nvm-windows (%APPDATA%\nvm) and fnm multishell dirs. + } + + return dirs +} + +// nvmNodeBinDirs returns the bin directories of every nvm-managed node release +// under home, newest version first. nvm installs each release at +// ~/.nvm/versions/node//bin. The newest is the most likely to hold a +// working npm/npx, so it is probed first; a lexical descending sort is good +// enough for the common vMAJOR.MINOR.PATCH layout (we only need a working +// binary, not strict semver ordering). +func nvmNodeBinDirs(exec executor.Executor, home string) []string { + pattern := filepath.Join(home, ".nvm", "versions", "node", "*", "bin") + matches, err := exec.Glob(pattern) + if err != nil || len(matches) == 0 { + return nil + } + sort.Sort(sort.Reverse(sort.StringSlice(matches))) + return matches +} + +// pmBinaryFilenames returns the on-disk filenames to probe for a package +// manager binary inside a candidate dir. On Unix the binary name is used as-is. +// On Windows npm/yarn/pnpm ship as .cmd shims (with .exe/.bat variants), while +// bun ships as bun.exe. +func pmBinaryFilenames(exec executor.Executor, binary string) []string { + if exec.GOOS() != model.PlatformWindows { + return []string{binary} + } + if binary == "bun" { + return []string{binary + ".exe"} + } + return []string{binary + ".cmd", binary + ".exe", binary + ".bat"} +} + +// resolveNodePMFromDefaults probes the OS-specific default install dirs +// (pmBinaryCandidateDirs) for the given package-manager binary. On the first +// existing file it runs that absolute path with versionCmd to read the version. +// +// Returns the resolved absolute path and the version. The version is the first +// non-empty "--version" output found; the path is the binary that produced that +// version, or — when no probed binary yields a version — the first one that +// merely exists (so the caller can still report a path with version "unknown"). +// Both are "" when the binary is found in no candidate dir. +func resolveNodePMFromDefaults(ctx context.Context, exec executor.Executor, binary, versionCmd string) (path, version string) { + dirs := pmBinaryCandidateDirs(exec) + filenames := pmBinaryFilenames(exec, binary) + for _, dir := range dirs { + for _, name := range filenames { + candidate := filepath.Join(dir, name) + if !exec.FileExists(candidate) { + continue + } + if path == "" { + path = candidate + } + if v := runPMVersion(ctx, exec, dirs, candidate, versionCmd); v != "" { + return candidate, v + } + } + } + return path, version +} + +// runPMVersion runs binPath's version command and returns the trimmed output, +// or "" on failure. +// +// npm/yarn/pnpm are Node scripts (#!/usr/bin/env node), so invoking them by +// absolute path is not enough — they still need `node` resolvable on PATH, and +// node lives in one of the candidate dirs (e.g. /opt/homebrew/bin), often a +// DIFFERENT dir than the manager itself (yarn under ~/.npm-global/bin while +// node is under Homebrew). So on Unix the call is wrapped in `sh -c` with every +// candidate dir prepended to PATH; without it the fallback recovers only bun (a +// native binary) and still reports "unknown" for the Node-script managers. The +// wrapper behaves the same whether exec is the plain Real executor (bare PATH) +// or the UserAwareExecutor (login shell): the inner sh prepends to whatever +// $PATH it inherits, then runs the manager. +// +// On Windows the .cmd shims locate node relative to themselves and there is no +// launchd stripped-PATH problem, so the binary is invoked directly. +func runPMVersion(ctx context.Context, exec executor.Executor, dirs []string, binPath, versionCmd string) string { + if exec.GOOS() == model.PlatformWindows { + stdout, _, _, err := exec.RunWithTimeout(ctx, 10*time.Second, binPath, versionCmd) + if err != nil { + return "" + } + return strings.TrimSpace(stdout) + } + cmd := pmVersionShellCommand(exec, dirs, binPath, versionCmd) + stdout, _, _, err := exec.RunWithTimeout(ctx, 10*time.Second, "/bin/sh", "-c", cmd) + if err != nil { + return "" + } + return strings.TrimSpace(stdout) +} + +// pmVersionShellCommand builds the POSIX shell command that runs binPath's +// version flag with every candidate dir prepended to PATH (so the Node-script +// managers' "env node" shebang resolves — see runPMVersion). $PATH is left for +// the inner shell to expand, so the candidate dirs are added on top of whatever +// PATH that shell already has. +func pmVersionShellCommand(exec executor.Executor, dirs []string, binPath, versionCmd string) string { + var b strings.Builder + b.WriteString("PATH=") + for _, d := range dirs { + b.WriteString(platformShellQuote(exec, d)) + b.WriteString(":") + } + b.WriteString(`"$PATH" `) + b.WriteString(platformShellQuote(exec, binPath)) + b.WriteString(" ") + b.WriteString(platformShellQuote(exec, versionCmd)) + return b.String() +} diff --git a/internal/detector/nodepm_fallback_test.go b/internal/detector/nodepm_fallback_test.go new file mode 100644 index 0000000..e684334 --- /dev/null +++ b/internal/detector/nodepm_fallback_test.go @@ -0,0 +1,262 @@ +package detector + +import ( + "context" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/step-security/dev-machine-guard/internal/executor" +) + +func TestPMBinaryCandidateDirs(t *testing.T) { + t.Run("darwin", func(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("darwin") + mock.SetHomeDir("/Users/foo") + got := pmBinaryCandidateDirs(mock) + want := []string{ + "/opt/homebrew/bin", + "/usr/local/bin", + filepath.Join("/Users/foo", ".bun", "bin"), + filepath.Join("/Users/foo", "Library", "pnpm"), + filepath.Join("/Users/foo", "Library", "pnpm", "bin"), + filepath.Join("/Users/foo", ".npm-global", "bin"), + filepath.Join("/Users/foo", ".volta", "bin"), + filepath.Join("/Users/foo", ".asdf", "shims"), + } + if !reflect.DeepEqual(got, want) { + t.Errorf("darwin dirs mismatch:\n got %v\nwant %v", got, want) + } + }) + + t.Run("linux", func(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("linux") + mock.SetHomeDir("/home/foo") + got := pmBinaryCandidateDirs(mock) + want := []string{ + "/usr/bin", + "/usr/local/bin", + "/home/linuxbrew/.linuxbrew/bin", + filepath.Join("/home/foo", ".linuxbrew", "bin"), + filepath.Join("/home/foo", ".bun", "bin"), + filepath.Join("/home/foo", ".local", "share", "pnpm"), + filepath.Join("/home/foo", ".npm-global", "bin"), + filepath.Join("/home/foo", ".volta", "bin"), + filepath.Join("/home/foo", ".asdf", "shims"), + } + if !reflect.DeepEqual(got, want) { + t.Errorf("linux dirs mismatch:\n got %v\nwant %v", got, want) + } + }) + + t.Run("windows", func(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("windows") + mock.SetHomeDir(`C:\Users\foo`) + mock.SetEnv("APPDATA", `C:\Users\foo\AppData\Roaming`) + mock.SetEnv("LOCALAPPDATA", `C:\Users\foo\AppData\Local`) + mock.SetEnv("ProgramFiles", `C:\Program Files`) + got := pmBinaryCandidateDirs(mock) + want := []string{ + filepath.Join(`C:\Users\foo\AppData\Roaming`, "npm"), + filepath.Join(`C:\Users\foo\AppData\Local`, "pnpm"), + filepath.Join(`C:\Users\foo\AppData\Local`, "Volta", "bin"), + filepath.Join(`C:\Users\foo`, ".bun", "bin"), + filepath.Join(`C:\Program Files`, "nodejs"), + } + if !reflect.DeepEqual(got, want) { + t.Errorf("windows dirs mismatch:\n got %v\nwant %v", got, want) + } + }) + + t.Run("darwin appends nvm dirs newest-first", func(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("darwin") + mock.SetHomeDir("/Users/foo") + pattern := filepath.Join("/Users/foo", ".nvm", "versions", "node", "*", "bin") + v18 := filepath.Join("/Users/foo", ".nvm", "versions", "node", "v18.20.0", "bin") + v20 := filepath.Join("/Users/foo", ".nvm", "versions", "node", "v20.11.0", "bin") + // Provide unsorted to prove the helper reverse-sorts. + mock.SetGlob(pattern, []string{v18, v20}) + + got := pmBinaryCandidateDirs(mock) + if len(got) < 2 { + t.Fatalf("expected nvm dirs appended, got %v", got) + } + gotLast2 := got[len(got)-2:] + wantLast2 := []string{v20, v18} // newest (v20) first + if !reflect.DeepEqual(gotLast2, wantLast2) { + t.Errorf("nvm dirs not newest-first:\n got %v\nwant %v", gotLast2, wantLast2) + } + }) +} + +func TestNvmNodeBinDirs(t *testing.T) { + t.Run("reverse sorted", func(t *testing.T) { + mock := executor.NewMock() + pattern := filepath.Join("/Users/foo", ".nvm", "versions", "node", "*", "bin") + a := filepath.Join("/Users/foo", ".nvm", "versions", "node", "v16.0.0", "bin") + b := filepath.Join("/Users/foo", ".nvm", "versions", "node", "v18.20.0", "bin") + c := filepath.Join("/Users/foo", ".nvm", "versions", "node", "v20.11.0", "bin") + mock.SetGlob(pattern, []string{b, a, c}) + + got := nvmNodeBinDirs(mock, "/Users/foo") + want := []string{c, b, a} + if !reflect.DeepEqual(got, want) { + t.Errorf("nvmNodeBinDirs:\n got %v\nwant %v", got, want) + } + }) + + t.Run("no matches → nil", func(t *testing.T) { + mock := executor.NewMock() + if got := nvmNodeBinDirs(mock, "/Users/foo"); got != nil { + t.Errorf("expected nil, got %v", got) + } + }) +} + +func TestPMBinaryFilenames(t *testing.T) { + tests := []struct { + name string + goos string + binary string + want []string + }{ + {"unix npm", "darwin", "npm", []string{"npm"}}, + {"unix pnpm", "linux", "pnpm", []string{"pnpm"}}, + {"windows npm", "windows", "npm", []string{"npm.cmd", "npm.exe", "npm.bat"}}, + {"windows yarn", "windows", "yarn", []string{"yarn.cmd", "yarn.exe", "yarn.bat"}}, + {"windows bun", "windows", "bun", []string{"bun.exe"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS(tt.goos) + got := pmBinaryFilenames(mock, tt.binary) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("pmBinaryFilenames(%q) = %v, want %v", tt.binary, got, tt.want) + } + }) + } +} + +func TestResolveNodePMFromDefaults(t *testing.T) { + ctx := context.Background() + + t.Run("hit with version", func(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("darwin") + mock.SetHomeDir("/Users/foo") + mock.SetFile("/opt/homebrew/bin/npm", []byte{}) + stubFallbackVersion(mock, "/opt/homebrew/bin/npm", "--version", "10.2.0\n") + + path, version := resolveNodePMFromDefaults(ctx, mock, "npm", "--version") + if path != "/opt/homebrew/bin/npm" { + t.Errorf("path = %q, want /opt/homebrew/bin/npm", path) + } + if version != "10.2.0" { + t.Errorf("version = %q, want 10.2.0", version) + } + }) + + t.Run("hit but --version empty → path set, version empty", func(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("darwin") + mock.SetHomeDir("/Users/foo") + // File exists but no --version stub → RunWithTimeout errors. + mock.SetFile("/opt/homebrew/bin/pnpm", []byte{}) + + path, version := resolveNodePMFromDefaults(ctx, mock, "pnpm", "--version") + if path != "/opt/homebrew/bin/pnpm" { + t.Errorf("path = %q, want /opt/homebrew/bin/pnpm (first existing binary)", path) + } + if version != "" { + t.Errorf("version = %q, want empty", version) + } + }) + + t.Run("miss → both empty", func(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("darwin") + mock.SetHomeDir("/Users/foo") + + path, version := resolveNodePMFromDefaults(ctx, mock, "npm", "--version") + if path != "" || version != "" { + t.Errorf("path=%q version=%q, want both empty", path, version) + } + }) + + t.Run("returns the binary that yields a version", func(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("darwin") + mock.SetHomeDir("/Users/foo") + // Homebrew npm exists but its --version fails; a later candidate works. + mock.SetFile("/opt/homebrew/bin/npm", []byte{}) + npmGlobal := filepath.Join("/Users/foo", ".npm-global", "bin", "npm") + mock.SetFile(npmGlobal, []byte{}) + stubFallbackVersion(mock, npmGlobal, "--version", "9.8.1\n") + + path, version := resolveNodePMFromDefaults(ctx, mock, "npm", "--version") + if path != npmGlobal { + t.Errorf("path = %q, want %q (binary that produced a version)", path, npmGlobal) + } + if version != "9.8.1" { + t.Errorf("version = %q, want 9.8.1", version) + } + }) + + t.Run("windows .cmd shim", func(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("windows") + mock.SetEnv("LOCALAPPDATA", `C:\Users\foo\AppData\Local`) + pnpmCmd := filepath.Join(`C:\Users\foo\AppData\Local`, "pnpm", "pnpm.cmd") + mock.SetFile(pnpmCmd, []byte{}) + mock.SetCommand("9.1.0\n", "", 0, pnpmCmd, "--version") + + path, version := resolveNodePMFromDefaults(ctx, mock, "pnpm", "--version") + if path != pnpmCmd { + t.Errorf("path = %q, want %q", path, pnpmCmd) + } + if version != "9.1.0" { + t.Errorf("version = %q, want 9.1.0", version) + } + }) +} + +// stubFallbackVersion stubs the fallback's Unix version invocation +// (`sh -c "PATH=:$PATH "`) so a probed +// binary returns the given version. Mirrors how runPMVersion builds the +// command so the mock's exact command key matches. +func stubFallbackVersion(mock *executor.Mock, binPath, versionCmd, version string) { + dirs := pmBinaryCandidateDirs(mock) + cmd := pmVersionShellCommand(mock, dirs, binPath, versionCmd) + mock.SetCommand(version, "", 0, "/bin/sh", "-c", cmd) +} + +func TestPMVersionShellCommand(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("darwin") + mock.SetHomeDir("/Users/foo") + dirs := pmBinaryCandidateDirs(mock) + + // yarn lives under ~/.npm-global/bin, but node lives under /opt/homebrew/bin. + // The command must put node's dir on PATH so the env-node shebang resolves. + yarn := filepath.Join("/Users/foo", ".npm-global", "bin", "yarn") + cmd := pmVersionShellCommand(mock, dirs, yarn, "--version") + + if !strings.HasPrefix(cmd, "PATH=") { + t.Errorf("command should start with a PATH assignment: %q", cmd) + } + if !strings.Contains(cmd, "'/opt/homebrew/bin':") { + t.Errorf("command must prepend node's dir (/opt/homebrew/bin) to PATH: %q", cmd) + } + if !strings.Contains(cmd, `:"$PATH" `) { + t.Errorf("command must preserve the inherited $PATH: %q", cmd) + } + if !strings.HasSuffix(cmd, "'"+yarn+"' '--version'") { + t.Errorf("command must end by running the probed binary: %q", cmd) + } +} diff --git a/internal/detector/nodepm_test.go b/internal/detector/nodepm_test.go index 9f55eb0..23ea903 100644 --- a/internal/detector/nodepm_test.go +++ b/internal/detector/nodepm_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" ) func TestNodePMDetector_FindsNPM(t *testing.T) { @@ -75,6 +76,86 @@ func TestNodePMDetector_Windows_FindsNPM(t *testing.T) { } } +// findPM returns the detected package manager with the given name, or nil. +func findPM(results []model.PkgManager, name string) *model.PkgManager { + for i := range results { + if results[i].Name == name { + return &results[i] + } + } + return nil +} + +// When a manager isn't on PATH (launchd's stripped PATH) but exists in a +// default install dir, the fallback resolves it by absolute path instead of +// dropping it from the list. +func TestNodePMDetector_FallbackResolvesWhenNotOnPath(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("darwin") + mock.SetHomeDir("/Users/foo") + // npm is NOT on PATH (no SetPath) but exists in the Homebrew dir. + mock.SetFile("/opt/homebrew/bin/npm", []byte{}) + stubFallbackVersion(mock, "/opt/homebrew/bin/npm", "--version", "10.2.0\n") + + det := NewNodePMDetector(mock) + results := det.DetectManagers(context.Background()) + + npm := findPM(results, "npm") + if npm == nil { + t.Fatal("expected npm to be resolved via the default-path fallback") + } + if npm.Version != "10.2.0" { + t.Errorf("version = %q, want 10.2.0", npm.Version) + } + if npm.Path != "/opt/homebrew/bin/npm" { + t.Errorf("path = %q, want /opt/homebrew/bin/npm", npm.Path) + } +} + +// When a manager is on PATH but its `--version` returns nothing (a stripped-PATH +// shim), the fallback recovers the version. The path stays the PATH location, +// since the fallback only fills an empty path. +func TestNodePMDetector_FallbackRecoversVersionWhenPathVersionFails(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("darwin") + mock.SetHomeDir("/Users/foo") + mock.SetPath("yarn", "/usr/local/bin/yarn") + // No SetCommand for ["yarn" "--version"] → primary version stays empty. + mock.SetFile("/opt/homebrew/bin/yarn", []byte{}) + stubFallbackVersion(mock, "/opt/homebrew/bin/yarn", "--version", "1.22.19\n") + + det := NewNodePMDetector(mock) + results := det.DetectManagers(context.Background()) + + yarn := findPM(results, "yarn") + if yarn == nil { + t.Fatal("expected yarn in results") + } + if yarn.Version != "1.22.19" { + t.Errorf("version = %q, want 1.22.19 (recovered via fallback)", yarn.Version) + } + if yarn.Path != "/usr/local/bin/yarn" { + t.Errorf("path = %q, want /usr/local/bin/yarn (LookPath location preserved)", yarn.Path) + } +} + +// A manager found neither on PATH nor in any default dir is still dropped. +func TestNodePMDetector_DroppedWhenFoundNowhere(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("darwin") + mock.SetHomeDir("/Users/foo") + // Only npm is resolvable (via fallback); yarn/pnpm/bun exist nowhere. + mock.SetFile("/opt/homebrew/bin/npm", []byte{}) + stubFallbackVersion(mock, "/opt/homebrew/bin/npm", "--version", "10.2.0\n") + + det := NewNodePMDetector(mock) + results := det.DetectManagers(context.Background()) + + if len(results) != 1 || results[0].Name != "npm" { + t.Fatalf("expected only npm, got %+v", results) + } +} + func TestDetectProjectPM_Windows(t *testing.T) { // Note: filepath.Join is host-OS dependent; on macOS it uses "/" even for // Windows-style project dirs. We use filepath.Join here to match what diff --git a/internal/detector/nodescan.go b/internal/detector/nodescan.go index a5a3b59..2803f96 100644 --- a/internal/detector/nodescan.go +++ b/internal/detector/nodescan.go @@ -362,16 +362,18 @@ func (s *NodeScanner) scanPnpmGlobal(ctx context.Context) (model.NodeScanResult, }, true } -// defaultPnpmBinDir returns the default pnpm global bin directory for the current OS -// based on environment variables. +// defaultPnpmBinDir returns the default pnpm global bin directory for the current OS. +// The user-home dirs are anchored on getHomeDir (the logged-in console user), +// not $HOME — under a LaunchDaemon $HOME is /var/root, which would point the +// fallback at root's home instead of the developer's. func defaultPnpmBinDir(exec executor.Executor) string { switch exec.GOOS() { case model.PlatformDarwin: - if home := exec.Getenv("HOME"); home != "" { + if home := getHomeDir(exec); home != "" { return filepath.Join(home, "Library", "pnpm", "bin") } case model.PlatformLinux: - if home := exec.Getenv("HOME"); home != "" { + if home := getHomeDir(exec); home != "" { return filepath.Join(home, ".local", "share", "pnpm", "bin") } case model.PlatformWindows: diff --git a/internal/detector/nodescan_test.go b/internal/detector/nodescan_test.go index daffcb5..190d446 100644 --- a/internal/detector/nodescan_test.go +++ b/internal/detector/nodescan_test.go @@ -222,7 +222,7 @@ func TestNodeScanner_ScanPnpmGlobal_Delegated(t *testing.T) { func TestNodeScanner_ScanPnpmGlobal_RootGFallback(t *testing.T) { mock := executor.NewMock() mock.SetGOOS("darwin") - mock.SetEnv("HOME", "/Users/foo") + mock.SetHomeDir("/Users/foo") mock.SetPath("pnpm", "/opt/homebrew/bin/pnpm") mock.SetCommand("11.1.2\n", "", 0, "pnpm", "--version") // pnpm root -g errors on every attempt — both the plain first call AND @@ -260,34 +260,29 @@ func TestNodeScanner_ScanPnpmGlobal_RootGFallback(t *testing.T) { // it emits names "/bin" as the dir that must be on PATH. func TestDefaultPnpmBinDir(t *testing.T) { tests := []struct { - name string - goos string - envs map[string]string - want string + name string + goos string + homeDir string // drives getHomeDir via the mock's CurrentUser + envs map[string]string // LOCALAPPDATA on Windows + want string }{ { - name: "darwin with HOME → bin subdir", - goos: "darwin", - envs: map[string]string{"HOME": "/Users/foo"}, - want: "/Users/foo/Library/pnpm/bin", + name: "darwin → bin subdir under home", + goos: "darwin", + homeDir: "/Users/foo", + want: "/Users/foo/Library/pnpm/bin", }, { - name: "darwin without HOME → empty", - goos: "darwin", - envs: map[string]string{}, - want: "", + name: "linux → bin subdir under home", + goos: "linux", + homeDir: "/home/foo", + want: "/home/foo/.local/share/pnpm/bin", }, { - name: "linux with HOME → bin subdir", - goos: "linux", - envs: map[string]string{"HOME": "/home/foo"}, - want: "/home/foo/.local/share/pnpm/bin", - }, - { - name: "linux without HOME → empty", - goos: "linux", - envs: map[string]string{}, - want: "", + name: "windows with LOCALAPPDATA → bin subdir", + goos: "windows", + envs: map[string]string{"LOCALAPPDATA": `C:\Users\foo\AppData\Local`}, + want: filepath.Join(`C:\Users\foo\AppData\Local`, "pnpm", "bin"), }, { name: "windows without LOCALAPPDATA → empty", @@ -296,16 +291,19 @@ func TestDefaultPnpmBinDir(t *testing.T) { want: "", }, { - name: "unrecognized OS → empty", - goos: "freebsd", - envs: map[string]string{"HOME": "/home/foo"}, - want: "", + name: "unrecognized OS → empty", + goos: "freebsd", + homeDir: "/home/foo", + want: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mock := executor.NewMock() mock.SetGOOS(tt.goos) + if tt.homeDir != "" { + mock.SetHomeDir(tt.homeDir) + } for k, v := range tt.envs { mock.SetEnv(k, v) }