diff --git a/apps/code/src/main/utils/fixPath.test.ts b/apps/code/src/main/utils/fixPath.test.ts index a7823aae1..430cf2de9 100644 --- a/apps/code/src/main/utils/fixPath.test.ts +++ b/apps/code/src/main/utils/fixPath.test.ts @@ -151,6 +151,46 @@ describe("fixPath", () => { expect(parts).toContain("/opt/homebrew/bin"); }); + it("preserves entries from the inherited PATH that the login shell lacks", async () => { + // Simulate launching from a terminal where .zshrc has added nvm/mise + // paths that the -lc resolution (only sources .zprofile) won't see. + process.env.PATH = + "/Users/me/.nvm/versions/node/v22.0.0/bin:/Users/me/.local/share/pnpm:/usr/bin:/bin"; + mockSpawnSync.mockReturnValue({ + status: 0, + stdout: shellOutput("/opt/homebrew/bin:/usr/bin:/bin"), + }); + + const { fixPath } = await import("./fixPath"); + fixPath(); + + const parts = process.env.PATH?.split(":") ?? []; + // Inherited entries (added by .zshrc, missing from .zprofile) survive. + expect(parts).toContain("/Users/me/.nvm/versions/node/v22.0.0/bin"); + expect(parts).toContain("/Users/me/.local/share/pnpm"); + // Shell-resolved entries are still merged in. + expect(parts).toContain("/opt/homebrew/bin"); + }); + + it("preserves inherited PATH entries when reading from the cache", async () => { + process.env.PATH = "/Users/me/.nvm/versions/node/v22.0.0/bin:/usr/bin:/bin"; + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue( + JSON.stringify({ + path: "/opt/homebrew/bin:/usr/bin:/bin", + timestamp: Date.now(), + }), + ); + + const { fixPath } = await import("./fixPath"); + fixPath(); + + const parts = process.env.PATH?.split(":") ?? []; + expect(parts).toContain("/Users/me/.nvm/versions/node/v22.0.0/bin"); + expect(parts).toContain("/opt/homebrew/bin"); + expect(mockSpawnSync).not.toHaveBeenCalled(); + }); + it("returns early on win32 without touching PATH", async () => { setPlatform("win32"); process.env.PATH = "C:\\Windows\\System32"; diff --git a/apps/code/src/main/utils/fixPath.ts b/apps/code/src/main/utils/fixPath.ts index bd055b983..b048a8502 100644 --- a/apps/code/src/main/utils/fixPath.ts +++ b/apps/code/src/main/utils/fixPath.ts @@ -4,7 +4,7 @@ * includes /opt/homebrew/bin, ~/.local/bin, etc. * * This reads the PATH from the user's default shell (in login mode) and - * applies it to process.env.PATH so child processes have access to + * merges it into process.env.PATH so child processes have access to * user-installed binaries. * * IMPORTANT: We use `-lc` (login, non-interactive) instead of `-ilc` @@ -13,6 +13,13 @@ * subprocesses and cause zombie process chains when the timeout kills * only the parent shell. * + * Because `-lc` skips .zshrc, version-manager paths (nvm, mise, volta) and + * other entries added there are missing from the resolved shell PATH. We + * therefore *merge* with the inherited process.env.PATH rather than + * replacing it — when launched from a terminal (e.g. `pnpm dev`), the + * inherited PATH already has those entries and must be preserved so git + * pre-commit hooks (husky, lint-staged, etc.) can find their tools. + * * See: https://github.com/PostHog/code/issues/1399 */ @@ -170,11 +177,19 @@ function getShellPath(shell: string): string | undefined { return env?.PATH; } -function mergeFallbackPaths(basePath: string | undefined): string { - const base = basePath ? basePath.split(":").filter(Boolean) : []; - const existing = new Set(base); - const additions = FALLBACK_PATHS.filter((p) => !existing.has(p)); - return [...additions, ...base].join(":"); +function mergePaths(sources: (string | undefined)[]): string { + const seen = new Set(); + const result: string[] = []; + for (const source of sources) { + if (!source) continue; + for (const segment of source.split(":")) { + if (segment && !seen.has(segment)) { + seen.add(segment); + result.push(segment); + } + } + } + return result.join(":"); } export function fixPath(): void { @@ -182,9 +197,11 @@ export function fixPath(): void { return; } + const originalPath = process.env.PATH; + const cached = readCachedPath(); if (cached) { - process.env.PATH = mergeFallbackPaths(cached); + process.env.PATH = mergePaths([...FALLBACK_PATHS, originalPath, cached]); return; } @@ -193,10 +210,9 @@ export function fixPath(): void { if (shellPath) { const cleaned = stripAnsi(shellPath); - const merged = mergeFallbackPaths(cleaned); - process.env.PATH = merged; + process.env.PATH = mergePaths([...FALLBACK_PATHS, originalPath, cleaned]); writeCachedPath(cleaned); } else { - process.env.PATH = mergeFallbackPaths(process.env.PATH); + process.env.PATH = mergePaths([...FALLBACK_PATHS, originalPath]); } }