@@ -121,8 +121,12 @@ func TestNodeScanner_ScanPnpmGlobal_Windows(t *testing.T) {
121121 mock .SetCommand ("9.1.0\n " , "" , 0 , "pnpm" , "--version" )
122122 // pnpm root -g returns the global node_modules dir. The code calls
123123 // filepath.Dir on it. Since filepath.Dir is host-OS dependent, we use
124- // forward slashes here so the test works on macOS hosts too.
124+ // forward slashes here so the test works on macOS hosts too. First
125+ // attempt succeeds so the PATH workaround is skipped on this path.
125126 mock .SetCommand ("C:/Users/dev/AppData/Local/pnpm/global/5/node_modules\n " , "" , 0 , "pnpm" , "root" , "-g" )
127+ // Production tries `--depth=3` first (v10 transitive), falls back to no --depth
128+ // on non-zero exit (v11 path). Stub both legs so the fallback is verified.
129+ mock .SetCommand ("" , "ERR_PNPM_GLOBAL_LS_DEPTH_NOT_SUPPORTED" , 1 , "pnpm" , "list" , "-g" , "--json" , "--depth=3" )
126130 mock .SetCommand (`{"dependencies":{"typescript":{"version":"5.4.0"}}}` , "" , 0 , "pnpm" , "list" , "-g" , "--json" )
127131
128132 scanner := newTestScanner (mock )
@@ -151,11 +155,13 @@ func TestNodeScanner_ScanPnpmGlobal_Windows(t *testing.T) {
151155}
152156
153157// TestNodeScanner_ScanPnpmGlobal_Delegated exercises the root → user delegation
154- // path (macOS-as-root or Linux-as-root with a logged-in user). Verifies that
155- // the inline `PATH='…':$PATH pnpm <args>` shell prefix reaches the delegated
156- // command intact. Without this prefix, pnpm v11 hard-fails each `-g` call on
157- // hosts where sudo strips the caller's PATH (Linux `secure_path`, hardened
158- // macOS sudoers).
158+ // path (macOS-as-root or Linux-as-root with a logged-in user). Verifies the
159+ // lazy-fallback flow:
160+ // - `pnpm --version` runs plainly (doesn't need bin dir on PATH).
161+ // - First `pnpm root -g` runs plainly; on failure the scanner applies the
162+ // inline `PATH='…':$PATH pnpm` workaround and retries.
163+ // - `pnpm list -g` then uses the same prefixed pnpmCmd, so it survives sudo's
164+ // env policy (Linux `secure_path` or hardened macOS sudoers).
159165func TestNodeScanner_ScanPnpmGlobal_Delegated (t * testing.T ) {
160166 mock := executor .NewMock ()
161167 mock .SetGOOS ("darwin" )
@@ -166,12 +172,19 @@ func TestNodeScanner_ScanPnpmGlobal_Delegated(t *testing.T) {
166172 // the Mock dispatches as `bash -c "<cmd>"`.
167173 mock .SetCommand ("/opt/homebrew/bin/pnpm\n " , "" , 0 , "bash" , "-c" , "which pnpm" )
168174
169- // All pnpm calls in scanPnpmGlobal are built as
170- // PATH='<extra>':$PATH pnpm <args>
171- // then sent through runCmd → RunAsUser → bash -c "<cmd>".
175+ // `pnpm --version` is called plainly (no prefix) — it doesn't need the
176+ // bin dir on PATH.
177+ mock .SetCommand ("11.1.2\n " , "" , 0 , "bash" , "-c" , "pnpm --version" )
178+
179+ // First `pnpm root -g` attempt runs plainly; v11 errors when bin dir not on PATH.
180+ mock .SetCommand ("" , "ERR_PNPM_GLOBAL_LS_DEPTH_NOT_SUPPORTED" , 1 , "bash" , "-c" , "pnpm root -g" )
181+
182+ // Production then applies the workaround and retries with the inline PATH= prefix.
172183 prefix := `PATH='/Users/testuser/Library/pnpm/bin':$PATH pnpm`
173- mock .SetCommand ("11.1.2\n " , "" , 0 , "bash" , "-c" , prefix + " --version" )
174184 mock .SetCommand ("/Users/testuser/Library/pnpm/global/v11/node_modules\n " , "" , 0 , "bash" , "-c" , prefix + " root -g" )
185+
186+ // `pnpm list -g` tries with --depth=3 first; v11 path errors → fall back to no --depth.
187+ mock .SetCommand ("" , "ERR_PNPM_GLOBAL_LS_DEPTH_NOT_SUPPORTED" , 1 , "bash" , "-c" , prefix + " list -g --json --depth=3" )
175188 mock .SetCommand (`{"dependencies":{"typescript":{"version":"5.4.0"}}}` , "" , 0 , "bash" , "-c" , prefix + " list -g --json" )
176189
177190 log := progress .NewLogger (progress .LevelInfo )
@@ -190,16 +203,55 @@ func TestNodeScanner_ScanPnpmGlobal_Delegated(t *testing.T) {
190203 t .Fatal ("expected pnpm in delegated scan results" )
191204 }
192205 if pnpm .PMVersion != "11.1.2" {
193- t .Errorf ("PMVersion = %q, want 11.1.2 — PATH= prefix likely missing from `pnpm --version` invocation " , pnpm .PMVersion )
206+ t .Errorf ("PMVersion = %q, want 11.1.2 — `pnpm --version` should run plainly without PATH prefix " , pnpm .PMVersion )
194207 }
195208 if pnpm .ProjectPath != "/Users/testuser/Library/pnpm/global/v11" {
196- t .Errorf ("ProjectPath = %q, want /Users/testuser/Library/pnpm/global/v11 — PATH= prefix likely missing from `pnpm root -g` invocation " , pnpm .ProjectPath )
209+ t .Errorf ("ProjectPath = %q, want /Users/testuser/Library/pnpm/global/v11 — PATH= prefix likely missing from `pnpm root -g` retry " , pnpm .ProjectPath )
197210 }
198211 if pnpm .ExitCode != 0 {
199212 t .Errorf ("ExitCode = %d, want 0 — PATH= prefix likely missing from `pnpm list -g` invocation" , pnpm .ExitCode )
200213 }
201214}
202215
216+ // TestNodeScanner_ScanPnpmGlobal_RootGFallback verifies that when BOTH
217+ // `pnpm root -g` attempts fail (plain + with PATH workaround), the scan does
218+ // not bail out — it falls back to the platform-default bin dir
219+ // (defaultPnpmBinDir) as ProjectPath so the result is still produced.
220+ func TestNodeScanner_ScanPnpmGlobal_RootGFallback (t * testing.T ) {
221+ mock := executor .NewMock ()
222+ mock .SetGOOS ("darwin" )
223+ mock .SetEnv ("HOME" , "/Users/foo" )
224+ mock .SetPath ("pnpm" , "/opt/homebrew/bin/pnpm" )
225+ mock .SetCommand ("11.1.2\n " , "" , 0 , "pnpm" , "--version" )
226+ // pnpm root -g errors on every attempt — both the plain first call AND
227+ // the retry use the same plain `pnpm root -g` command, because
228+ // shouldRunAsUser is false on this in-process path (IsRoot not set).
229+ mock .SetCommand ("" , "ERR_PNPM_GLOBAL_LS_DEPTH_NOT_SUPPORTED" , 1 , "pnpm" , "root" , "-g" )
230+ mock .SetCommand ("" , "ERR_PNPM_GLOBAL_LS_DEPTH_NOT_SUPPORTED" , 1 , "pnpm" , "list" , "-g" , "--json" , "--depth=3" )
231+ mock .SetCommand (`{"dependencies":{"jest":{"version":"30.4.2"}}}` , "" , 0 , "pnpm" , "list" , "-g" , "--json" )
232+
233+ scanner := newTestScanner (mock )
234+ results := scanner .ScanGlobalPackages (context .Background ())
235+
236+ var pnpm * model.NodeScanResult
237+ for i , r := range results {
238+ if r .PackageManager == "pnpm" {
239+ pnpm = & results [i ]
240+ break
241+ }
242+ }
243+ if pnpm == nil {
244+ t .Fatal ("expected pnpm in results — fallback should have prevented an early return" )
245+ }
246+ // ProjectPath falls back to defaultPnpmBinDir on darwin = $HOME/Library/pnpm/bin.
247+ if pnpm .ProjectPath != "/Users/foo/Library/pnpm/bin" {
248+ t .Errorf ("ProjectPath = %q, want /Users/foo/Library/pnpm/bin (defaultPnpmBinDir fallback)" , pnpm .ProjectPath )
249+ }
250+ if pnpm .ExitCode != 0 {
251+ t .Errorf ("ExitCode = %d, want 0 — `pnpm list -g` should have succeeded via the no-depth fallback" , pnpm .ExitCode )
252+ }
253+ }
254+
203255// TestDefaultPnpmBinDir pins pnpm's per-platform global bin-dir layout. macOS
204256// uses a /bin subdirectory; Linux and Windows place global binaries directly
205257// in PNPM_HOME (no /bin). This asymmetry matches pnpm's own `pnpm setup`.
0 commit comments