@@ -199,27 +199,73 @@ func (s *NodeScanner) scanYarnGlobal(ctx context.Context) (model.NodeScanResult,
199199
200200func (s * NodeScanner ) scanPnpmGlobal (ctx context.Context ) (model.NodeScanResult , bool ) {
201201 if err := s .checkPath (ctx , "pnpm" ); err != nil {
202+ s .log .Warn ("pnpm not found on PATH — skipping pnpm global scan: %v" , err )
202203 return model.NodeScanResult {}, false
203204 }
204205
205- version := s .getVersion (ctx , "pnpm" , "--version" )
206- globalDir := s .getOutput (ctx , "pnpm" , "root" , "-g" )
207- if globalDir == "" {
206+ pnpmCmd := "pnpm"
207+
208+ versionOut , _ , _ , verErr := s .runCmd (ctx , 10 * time .Second , pnpmCmd , "--version" )
209+ version := strings .TrimSpace (versionOut )
210+ if verErr != nil || version == "" {
211+ version = "unknown"
212+ }
213+
214+ rootOut , _ , rootExit , _ := s .runCmd (ctx , 10 * time .Second , pnpmCmd , "root" , "-g" )
215+ globalDir := strings .TrimSpace (rootOut )
216+
217+ // fallback logic in case `pnpm root -g` returns empty
218+ var extra string
219+ if rootExit != 0 || globalDir == "" {
220+ extra = defaultPnpmBinDir (s .exec )
221+ if extra != "" {
222+ oldPath := os .Getenv ("PATH" )
223+ _ = os .Setenv ("PATH" , extra + string (os .PathListSeparator )+ oldPath )
224+ defer os .Setenv ("PATH" , oldPath )
225+
226+ // For the delegation path, embed `PATH='extra':$PATH` in the command name.
227+ // runCmd's delegation branch space-joins name+args into the shell command
228+ // string, so the env-prefix flows through to the user's shell intact.
229+ if s .shouldRunAsUser () {
230+ pnpmCmd = "PATH=" + platformShellQuote (s .exec , extra ) + ":$PATH pnpm"
231+ }
232+
233+ s .log .Debug ("pnpm root -g returned empty; retrying with bin dir %q prepended to PATH" , extra )
234+ rootOut , _ , _ , _ = s .runCmd (ctx , 10 * time .Second , pnpmCmd , "root" , "-g" )
235+ globalDir = strings .TrimSpace (rootOut )
236+ }
237+ }
238+
239+ if globalDir != "" {
240+ globalDir = filepath .Dir (globalDir )
241+ } else if extra != "" {
242+ // Both attempts failed; use the bin dir itself as last-resort
243+ // ProjectPath so we still produce a result rather than dropping the
244+ // scan entirely.
245+ s .log .Debug ("pnpm root -g still empty after PATH workaround; using defaultPnpmBinDir=%q" , extra )
246+ globalDir = extra
247+ } else {
248+ s .log .Warn ("pnpm found but `pnpm root -g` returned empty and no fallback available — skipping pnpm global scan" )
208249 return model.NodeScanResult {}, false
209250 }
210- globalDir = filepath .Dir (globalDir )
211251
252+ // Try with --depth=3 first for transitive coverage (works on pnpm v10).
253+ // Fall back to no --depth on non-zero exit — pnpm v11 hard-fails any
254+ // --depth>=1 on -g and pnpm itself recommends omitting --depth.
212255 start := time .Now ()
213- // pnpm v11 exits non-zero on `--depth=3` for global scans; use `--depth=Infinity` there.
214- stdout , stderr , exitCode , _ := s .runCmd (ctx , 60 * time .Second , "pnpm" , "list" , "-g" , "--json" , pnpmDepthArg (version ))
256+ stdout , stderr , exitCode , err := s .runCmd (ctx , 60 * time .Second , pnpmCmd , "list" , "-g" , "--json" , "--depth=3" )
257+ if exitCode != 0 {
258+ s .log .Debug ("pnpm list -g --depth=3 failed (exit=%d) — retrying without --depth (v11 path)" , exitCode )
259+ stdout , stderr , exitCode , err = s .runCmd (ctx , 60 * time .Second , pnpmCmd , "list" , "-g" , "--json" )
260+ }
215261 duration := time .Since (start ).Milliseconds ()
216262
217263 errMsg := ""
218264 if exitCode != 0 {
219265 errMsg = "pnpm list -g command failed"
220266 s .log .Warn ("pnpm list -g failed (exit_code=%d, %dms) — results may be incomplete" , exitCode , duration )
221267 }
222- s .log .Debug ("pnpm global scan: version=%s global_dir=%s exit_code=%d stdout_bytes=%d duration=%dms" , version , globalDir , exitCode , len (stdout ), duration )
268+ s .log .Debug ("pnpm global scan: version=%s global_dir=%s exit_code=%d stdout_bytes=%d duration=%dms err=%v " , version , globalDir , exitCode , len (stdout ), duration , err )
223269
224270 return model.NodeScanResult {
225271 ProjectPath : globalDir ,
@@ -234,6 +280,26 @@ func (s *NodeScanner) scanPnpmGlobal(ctx context.Context) (model.NodeScanResult,
234280 }, true
235281}
236282
283+ // defaultPnpmBinDir returns the default pnpm global bin directory for the current OS
284+ // based on environment variables.
285+ func defaultPnpmBinDir (exec executor.Executor ) string {
286+ switch exec .GOOS () {
287+ case model .PlatformDarwin :
288+ if home := exec .Getenv ("HOME" ); home != "" {
289+ return filepath .Join (home , "Library" , "pnpm" , "bin" )
290+ }
291+ case model .PlatformLinux :
292+ if home := exec .Getenv ("HOME" ); home != "" {
293+ return filepath .Join (home , ".local" , "share" , "pnpm" )
294+ }
295+ case model .PlatformWindows :
296+ if localAppData := exec .Getenv ("LOCALAPPDATA" ); localAppData != "" {
297+ return filepath .Join (localAppData , "pnpm" )
298+ }
299+ }
300+ return ""
301+ }
302+
237303// projectEntry holds a discovered package.json with its modification time for sorting.
238304type projectEntry struct {
239305 dir string
@@ -405,19 +471,3 @@ func isInsideNodeModules(projectDir string) bool {
405471 normalized := strings .ReplaceAll (projectDir , "\\ " , "/" )
406472 return strings .Contains (normalized , "/node_modules/" )
407473}
408-
409- // pnpmDepthArg picks the `--depth` arg for `pnpm list -g` based on the pnpm
410- // version. pnpm v11 exits non-zero when `--depth=3` is passed to a global
411- // scan; `--depth=Infinity` works on v11 and preserves transitive depth.
412- // Falls back to `--depth=3` for older / unparseable versions to preserve
413- // existing behavior.
414- func pnpmDepthArg (version string ) string {
415- v := strings .TrimSpace (version )
416- v = strings .TrimPrefix (v , "v" )
417- major , _ , _ := strings .Cut (v , "." )
418- n , err := strconv .Atoi (major )
419- if err == nil && n >= 11 {
420- return "--depth=Infinity"
421- }
422- return "--depth=3"
423- }
0 commit comments