diff --git a/apps/desktop/src/syncShellEnvironment.test.ts b/apps/desktop/src/syncShellEnvironment.test.ts index cda78a20b2..7d4578895f 100644 --- a/apps/desktop/src/syncShellEnvironment.test.ts +++ b/apps/desktop/src/syncShellEnvironment.test.ts @@ -6,11 +6,12 @@ describe("syncShellEnvironment", () => { it("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on macOS", () => { const env: NodeJS.ProcessEnv = { SHELL: "/bin/zsh", - PATH: "/usr/bin", + PATH: "/Users/test/.local/bin:/usr/bin", }; const readEnvironment = vi.fn(() => ({ PATH: "/opt/homebrew/bin:/usr/bin", SSH_AUTH_SOCK: "/tmp/secretive.sock", + HOMEBREW_PREFIX: "/opt/homebrew", })); syncShellEnvironment(env, { @@ -18,9 +19,18 @@ describe("syncShellEnvironment", () => { readEnvironment, }); - expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"]); - expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin"); + expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", [ + "PATH", + "SSH_AUTH_SOCK", + "HOMEBREW_PREFIX", + "HOMEBREW_CELLAR", + "HOMEBREW_REPOSITORY", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + ]); + expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin:/Users/test/.local/bin"); expect(env.SSH_AUTH_SOCK).toBe("/tmp/secretive.sock"); + expect(env.HOMEBREW_PREFIX).toBe("/opt/homebrew"); }); it("preserves an inherited SSH_AUTH_SOCK value", () => { @@ -77,11 +87,67 @@ describe("syncShellEnvironment", () => { readEnvironment, }); - expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"]); + expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", [ + "PATH", + "SSH_AUTH_SOCK", + "HOMEBREW_PREFIX", + "HOMEBREW_CELLAR", + "HOMEBREW_REPOSITORY", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + ]); expect(env.PATH).toBe("/home/linuxbrew/.linuxbrew/bin:/usr/bin"); expect(env.SSH_AUTH_SOCK).toBe("/tmp/secretive.sock"); }); + it("falls back to launchctl PATH on macOS when shell probing does not return one", () => { + const env: NodeJS.ProcessEnv = { + SHELL: "/opt/homebrew/bin/nu", + PATH: "/usr/bin", + }; + const readEnvironment = vi + .fn() + .mockImplementationOnce(() => { + throw new Error("unknown flag"); + }) + .mockImplementationOnce(() => ({})); + const readLaunchctlPath = vi.fn(() => "/opt/homebrew/bin:/usr/bin"); + const logWarning = vi.fn(); + + syncShellEnvironment(env, { + platform: "darwin", + readEnvironment, + readLaunchctlPath, + userShell: "/bin/zsh", + logWarning, + }); + + expect(readEnvironment).toHaveBeenNthCalledWith(1, "/opt/homebrew/bin/nu", [ + "PATH", + "SSH_AUTH_SOCK", + "HOMEBREW_PREFIX", + "HOMEBREW_CELLAR", + "HOMEBREW_REPOSITORY", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + ]); + expect(readEnvironment).toHaveBeenNthCalledWith(2, "/bin/zsh", [ + "PATH", + "SSH_AUTH_SOCK", + "HOMEBREW_PREFIX", + "HOMEBREW_CELLAR", + "HOMEBREW_REPOSITORY", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + ]); + expect(readLaunchctlPath).toHaveBeenCalledTimes(1); + expect(logWarning).toHaveBeenCalledWith( + "Failed to read login shell environment from /opt/homebrew/bin/nu.", + expect.any(Error), + ); + expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin"); + }); + it("does nothing outside macOS and linux", () => { const env: NodeJS.ProcessEnv = { SHELL: "C:/Program Files/Git/bin/bash.exe", diff --git a/apps/desktop/src/syncShellEnvironment.ts b/apps/desktop/src/syncShellEnvironment.ts index 13036149b8..b23127596b 100644 --- a/apps/desktop/src/syncShellEnvironment.ts +++ b/apps/desktop/src/syncShellEnvironment.ts @@ -1,36 +1,77 @@ import { + listLoginShellCandidates, + mergePathEntries, + readPathFromLaunchctl, readEnvironmentFromLoginShell, - resolveLoginShell, ShellEnvironmentReader, } from "@t3tools/shared/shell"; +const LOGIN_SHELL_ENV_NAMES = [ + "PATH", + "SSH_AUTH_SOCK", + "HOMEBREW_PREFIX", + "HOMEBREW_CELLAR", + "HOMEBREW_REPOSITORY", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", +] as const; + +function logShellEnvironmentWarning(message: string, error?: unknown): void { + console.warn(`[desktop] ${message}`, error instanceof Error ? error.message : (error ?? "")); +} + export function syncShellEnvironment( env: NodeJS.ProcessEnv = process.env, options: { platform?: NodeJS.Platform; readEnvironment?: ShellEnvironmentReader; + readLaunchctlPath?: typeof readPathFromLaunchctl; + userShell?: string; + logWarning?: (message: string, error?: unknown) => void; } = {}, ): void { const platform = options.platform ?? process.platform; if (platform !== "darwin" && platform !== "linux") return; - try { - const shell = resolveLoginShell(platform, env.SHELL); - if (!shell) return; + const logWarning = options.logWarning ?? logShellEnvironmentWarning; + const readEnvironment = options.readEnvironment ?? readEnvironmentFromLoginShell; + const shellEnvironment: Partial> = {}; - const shellEnvironment = (options.readEnvironment ?? readEnvironmentFromLoginShell)(shell, [ - "PATH", - "SSH_AUTH_SOCK", - ]); + try { + for (const shell of listLoginShellCandidates(platform, env.SHELL, options.userShell)) { + try { + Object.assign(shellEnvironment, readEnvironment(shell, LOGIN_SHELL_ENV_NAMES)); + if (shellEnvironment.PATH) { + break; + } + } catch (error) { + logWarning(`Failed to read login shell environment from ${shell}.`, error); + } + } - if (shellEnvironment.PATH) { - env.PATH = shellEnvironment.PATH; + const launchctlPath = + platform === "darwin" ? (options.readLaunchctlPath ?? readPathFromLaunchctl)() : undefined; + const mergedPath = mergePathEntries(shellEnvironment.PATH ?? launchctlPath, env.PATH, platform); + if (mergedPath) { + env.PATH = mergedPath; } if (!env.SSH_AUTH_SOCK && shellEnvironment.SSH_AUTH_SOCK) { env.SSH_AUTH_SOCK = shellEnvironment.SSH_AUTH_SOCK; } + + for (const name of [ + "HOMEBREW_PREFIX", + "HOMEBREW_CELLAR", + "HOMEBREW_REPOSITORY", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + ] as const) { + if (!env[name] && shellEnvironment[name]) { + env[name] = shellEnvironment[name]; + } + } } catch { - // Keep inherited environment if shell lookup fails. + logWarning("Failed to synchronize the desktop shell environment."); } } diff --git a/apps/server/src/os-jank.test.ts b/apps/server/src/os-jank.test.ts index ca03ab5868..89eba62d2a 100644 --- a/apps/server/src/os-jank.test.ts +++ b/apps/server/src/os-jank.test.ts @@ -6,7 +6,7 @@ describe("fixPath", () => { it("hydrates PATH on linux using the resolved login shell", () => { const env: NodeJS.ProcessEnv = { SHELL: "/bin/zsh", - PATH: "/usr/bin", + PATH: "/Users/test/.local/bin:/usr/bin", }; const readPath = vi.fn(() => "/opt/homebrew/bin:/usr/bin"); @@ -17,6 +17,39 @@ describe("fixPath", () => { }); expect(readPath).toHaveBeenCalledWith("/bin/zsh"); + expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin:/Users/test/.local/bin"); + }); + + it("falls back to launchctl PATH on macOS when shell probing fails", () => { + const env: NodeJS.ProcessEnv = { + SHELL: "/opt/homebrew/bin/nu", + PATH: "/usr/bin", + }; + const readPath = vi + .fn() + .mockImplementationOnce(() => { + throw new Error("unknown flag"); + }) + .mockImplementationOnce(() => undefined); + const readLaunchctlPath = vi.fn(() => "/opt/homebrew/bin:/usr/bin"); + const logWarning = vi.fn(); + + fixPath({ + env, + platform: "darwin", + readPath, + readLaunchctlPath, + userShell: "/bin/zsh", + logWarning, + }); + + expect(readPath).toHaveBeenNthCalledWith(1, "/opt/homebrew/bin/nu"); + expect(readPath).toHaveBeenNthCalledWith(2, "/bin/zsh"); + expect(readLaunchctlPath).toHaveBeenCalledTimes(1); + expect(logWarning).toHaveBeenCalledWith( + "Failed to read PATH from login shell /opt/homebrew/bin/nu.", + expect.any(Error), + ); expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin"); }); diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index c3629e8fde..21052eabac 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -1,28 +1,55 @@ import * as OS from "node:os"; import { Effect, Path } from "effect"; -import { readPathFromLoginShell, resolveLoginShell } from "@t3tools/shared/shell"; +import { + listLoginShellCandidates, + mergePathEntries, + readPathFromLaunchctl, + readPathFromLoginShell, +} from "@t3tools/shared/shell"; + +function logPathHydrationWarning(message: string, error?: unknown): void { + console.warn(`[server] ${message}`, error instanceof Error ? error.message : (error ?? "")); +} export function fixPath( options: { env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform; readPath?: typeof readPathFromLoginShell; + readLaunchctlPath?: typeof readPathFromLaunchctl; + userShell?: string; + logWarning?: (message: string, error?: unknown) => void; } = {}, ): void { const platform = options.platform ?? process.platform; if (platform !== "darwin" && platform !== "linux") return; const env = options.env ?? process.env; + const logWarning = options.logWarning ?? logPathHydrationWarning; + const readPath = options.readPath ?? readPathFromLoginShell; try { - const shell = resolveLoginShell(platform, env.SHELL); - if (!shell) return; - const result = (options.readPath ?? readPathFromLoginShell)(shell); - if (result) { - env.PATH = result; + let shellPath: string | undefined; + for (const shell of listLoginShellCandidates(platform, env.SHELL, options.userShell)) { + try { + shellPath = readPath(shell); + } catch (error) { + logWarning(`Failed to read PATH from login shell ${shell}.`, error); + } + + if (shellPath) { + break; + } + } + + const launchctlPath = + platform === "darwin" ? (options.readLaunchctlPath ?? readPathFromLaunchctl)() : undefined; + const mergedPath = mergePathEntries(shellPath ?? launchctlPath, env.PATH, platform); + if (mergedPath) { + env.PATH = mergedPath; } - } catch { - // Silently ignore — keep default PATH + } catch (error) { + logWarning("Failed to hydrate PATH from the user environment.", error); } } diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index e2393eefff..5f17198b7a 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -2,8 +2,12 @@ import { describe, expect, it, vi } from "vitest"; import { extractPathFromShellOutput, + listLoginShellCandidates, + mergePathEntries, readEnvironmentFromLoginShell, + readPathFromLaunchctl, readPathFromLoginShell, + resolveLoginShell, } from "./shell"; describe("extractPathFromShellOutput", () => { @@ -60,6 +64,38 @@ describe("readPathFromLoginShell", () => { }); }); +describe("readPathFromLaunchctl", () => { + it("returns a trimmed PATH value from launchctl", () => { + const execFile = vi.fn< + ( + file: string, + args: ReadonlyArray, + options: { encoding: "utf8"; timeout: number }, + ) => string + >(() => " /opt/homebrew/bin:/usr/bin \n"); + + expect(readPathFromLaunchctl(execFile)).toBe("/opt/homebrew/bin:/usr/bin"); + expect(execFile).toHaveBeenCalledWith("/bin/launchctl", ["getenv", "PATH"], { + encoding: "utf8", + timeout: 2000, + }); + }); + + it("returns undefined when launchctl is unavailable", () => { + const execFile = vi.fn< + ( + file: string, + args: ReadonlyArray, + options: { encoding: "utf8"; timeout: number }, + ) => string + >(() => { + throw new Error("spawn /bin/launchctl ENOENT"); + }); + + expect(readPathFromLaunchctl(execFile)).toBeUndefined(); + }); +}); + describe("readEnvironmentFromLoginShell", () => { it("extracts multiple environment variables from a login shell command", () => { const execFile = vi.fn< @@ -126,3 +162,38 @@ describe("readEnvironmentFromLoginShell", () => { }); }); }); + +describe("listLoginShellCandidates", () => { + it("returns env shell, user shell, then the platform fallback without duplicates", () => { + expect(listLoginShellCandidates("darwin", " /opt/homebrew/bin/nu ", "/bin/zsh")).toEqual([ + "/opt/homebrew/bin/nu", + "/bin/zsh", + ]); + }); + + it("falls back to the platform default when no shells are available", () => { + expect(listLoginShellCandidates("linux", undefined, "")).toEqual(["/bin/bash"]); + }); +}); + +describe("resolveLoginShell", () => { + it("returns the first available login shell candidate", () => { + expect(resolveLoginShell("darwin", undefined, "/opt/homebrew/bin/fish")).toBe( + "/opt/homebrew/bin/fish", + ); + }); +}); + +describe("mergePathEntries", () => { + it("prefers login-shell PATH entries and keeps inherited extras", () => { + expect( + mergePathEntries("/opt/homebrew/bin:/usr/bin", "/Users/test/.local/bin:/usr/bin", "darwin"), + ).toBe("/opt/homebrew/bin:/usr/bin:/Users/test/.local/bin"); + }); + + it("uses the platform-specific delimiter", () => { + expect(mergePathEntries("C:\\Tools;C:\\Windows", "C:\\Windows;C:\\Git", "win32")).toBe( + "C:\\Tools;C:\\Windows;C:\\Git", + ); + }); +}); diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index d9e8a7881b..89e158fb69 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -1,3 +1,4 @@ +import * as OS from "node:os"; import { execFileSync } from "node:child_process"; const PATH_CAPTURE_START = "__T3CODE_PATH_START__"; @@ -10,24 +11,46 @@ type ExecFileSyncLike = ( options: { encoding: "utf8"; timeout: number }, ) => string; -export function resolveLoginShell( - platform: NodeJS.Platform, - shell: string | undefined, -): string | undefined { - const trimmedShell = shell?.trim(); - if (trimmedShell) { - return trimmedShell; - } +function trimNonEmpty(value: string | null | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : undefined; +} - if (platform === "darwin") { - return "/bin/zsh"; +function readUserLoginShell(): string | undefined { + try { + return trimNonEmpty(OS.userInfo().shell); + } catch { + return undefined; } +} - if (platform === "linux") { - return "/bin/bash"; +export function listLoginShellCandidates( + platform: NodeJS.Platform, + shell: string | undefined, + userShell = readUserLoginShell(), +): ReadonlyArray { + const fallbackShell = + platform === "darwin" ? "/bin/zsh" : platform === "linux" ? "/bin/bash" : undefined; + const seen = new Set(); + const candidates: string[] = []; + + for (const candidate of [trimNonEmpty(shell), trimNonEmpty(userShell), fallbackShell]) { + if (!candidate || seen.has(candidate)) { + continue; + } + seen.add(candidate); + candidates.push(candidate); } - return undefined; + return candidates; +} + +export function resolveLoginShell( + platform: NodeJS.Platform, + shell: string | undefined, + userShell = readUserLoginShell(), +): string | undefined { + return listLoginShellCandidates(platform, shell, userShell)[0]; } export function extractPathFromShellOutput(output: string): string | null { @@ -49,6 +72,45 @@ export function readPathFromLoginShell( return readEnvironmentFromLoginShell(shell, ["PATH"], execFile).PATH; } +export function readPathFromLaunchctl( + execFile: ExecFileSyncLike = execFileSync, +): string | undefined { + try { + return trimNonEmpty( + execFile("/bin/launchctl", ["getenv", "PATH"], { + encoding: "utf8", + timeout: 2000, + }), + ); + } catch { + return undefined; + } +} + +export function mergePathEntries( + preferredPath: string | undefined, + inheritedPath: string | undefined, + platform: NodeJS.Platform, +): string | undefined { + const delimiter = platform === "win32" ? ";" : ":"; + const merged: string[] = []; + const seen = new Set(); + + for (const pathValue of [preferredPath, inheritedPath]) { + if (!pathValue) continue; + for (const entry of pathValue.split(delimiter)) { + const trimmedEntry = entry.trim(); + if (!trimmedEntry || seen.has(trimmedEntry)) { + continue; + } + seen.add(trimmedEntry); + merged.push(trimmedEntry); + } + } + + return merged.length > 0 ? merged.join(delimiter) : undefined; +} + function envCaptureStart(name: string): string { return `__T3CODE_ENV_${name}_START__`; }