diff --git a/apps/desktop/src/syncShellEnvironment.test.ts b/apps/desktop/src/syncShellEnvironment.test.ts index 69e73da0aa..cda78a20b2 100644 --- a/apps/desktop/src/syncShellEnvironment.test.ts +++ b/apps/desktop/src/syncShellEnvironment.test.ts @@ -62,14 +62,13 @@ describe("syncShellEnvironment", () => { expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock"); }); - it("does nothing outside macOS", () => { + it("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on linux", () => { const env: NodeJS.ProcessEnv = { SHELL: "/bin/zsh", PATH: "/usr/bin", - SSH_AUTH_SOCK: "/tmp/inherited.sock", }; const readEnvironment = vi.fn(() => ({ - PATH: "/opt/homebrew/bin:/usr/bin", + PATH: "/home/linuxbrew/.linuxbrew/bin:/usr/bin", SSH_AUTH_SOCK: "/tmp/secretive.sock", })); @@ -78,8 +77,29 @@ describe("syncShellEnvironment", () => { readEnvironment, }); + expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"]); + expect(env.PATH).toBe("/home/linuxbrew/.linuxbrew/bin:/usr/bin"); + expect(env.SSH_AUTH_SOCK).toBe("/tmp/secretive.sock"); + }); + + it("does nothing outside macOS and linux", () => { + const env: NodeJS.ProcessEnv = { + SHELL: "C:/Program Files/Git/bin/bash.exe", + PATH: "C:\\Windows\\System32", + SSH_AUTH_SOCK: "/tmp/inherited.sock", + }; + const readEnvironment = vi.fn(() => ({ + PATH: "/usr/local/bin:/usr/bin", + SSH_AUTH_SOCK: "/tmp/secretive.sock", + })); + + syncShellEnvironment(env, { + platform: "win32", + readEnvironment, + }); + expect(readEnvironment).not.toHaveBeenCalled(); - expect(env.PATH).toBe("/usr/bin"); + expect(env.PATH).toBe("C:\\Windows\\System32"); expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock"); }); }); diff --git a/apps/desktop/src/syncShellEnvironment.ts b/apps/desktop/src/syncShellEnvironment.ts index 2181bea0ca..13036149b8 100644 --- a/apps/desktop/src/syncShellEnvironment.ts +++ b/apps/desktop/src/syncShellEnvironment.ts @@ -1,4 +1,8 @@ -import { readEnvironmentFromLoginShell, ShellEnvironmentReader } from "@t3tools/shared/shell"; +import { + readEnvironmentFromLoginShell, + resolveLoginShell, + ShellEnvironmentReader, +} from "@t3tools/shared/shell"; export function syncShellEnvironment( env: NodeJS.ProcessEnv = process.env, @@ -7,10 +11,13 @@ export function syncShellEnvironment( readEnvironment?: ShellEnvironmentReader; } = {}, ): void { - if ((options.platform ?? process.platform) !== "darwin") return; + const platform = options.platform ?? process.platform; + if (platform !== "darwin" && platform !== "linux") return; try { - const shell = env.SHELL ?? "/bin/zsh"; + const shell = resolveLoginShell(platform, env.SHELL); + if (!shell) return; + const shellEnvironment = (options.readEnvironment ?? readEnvironmentFromLoginShell)(shell, [ "PATH", "SSH_AUTH_SOCK", diff --git a/apps/server/src/main.test.ts b/apps/server/src/main.test.ts index c644b4778e..0c96203dbd 100644 --- a/apps/server/src/main.test.ts +++ b/apps/server/src/main.test.ts @@ -21,6 +21,7 @@ import { ServerSettingsService } from "./serverSettings"; const start = vi.fn(() => undefined); const stop = vi.fn(() => undefined); +const fixPath = vi.fn(() => undefined); let resolvedConfig: ServerConfigShape | null = null; const serverStart = Effect.acquireRelease( Effect.gen(function* () { @@ -36,7 +37,7 @@ const findAvailablePort = vi.fn((preferred: number) => Effect.succeed(preferred) const testLayer = Layer.mergeAll( Layer.succeed(CliConfig, { cwd: "/tmp/t3-test-workspace", - fixPath: Effect.void, + fixPath: Effect.sync(fixPath), resolveStaticDir: Effect.undefined, } satisfies CliConfigShape), Layer.succeed(NetService, { @@ -81,6 +82,7 @@ beforeEach(() => { resolvedConfig = null; start.mockImplementation(() => undefined); stop.mockImplementation(() => undefined); + fixPath.mockImplementation(() => undefined); findAvailablePort.mockImplementation((preferred: number) => Effect.succeed(preferred)); }); @@ -329,6 +331,21 @@ it.layer(testLayer)("server CLI command", (it) => { }), ); + it.effect("hydrates PATH before server startup", () => + Effect.gen(function* () { + yield* runCli([]); + + assert.equal(fixPath.mock.calls.length, 1); + assert.equal(start.mock.calls.length, 1); + const fixPathOrder = fixPath.mock.invocationCallOrder[0]; + const startOrder = start.mock.invocationCallOrder[0]; + if (typeof fixPathOrder !== "number" || typeof startOrder !== "number") { + assert.fail("Expected fixPath and start to be called"); + } + assert.isTrue(fixPathOrder < startOrder); + }), + ); + it.effect("records a startup heartbeat with thread/project counts", () => Effect.gen(function* () { const recordTelemetry = vi.fn( diff --git a/apps/server/src/os-jank.test.ts b/apps/server/src/os-jank.test.ts new file mode 100644 index 0000000000..ca03ab5868 --- /dev/null +++ b/apps/server/src/os-jank.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from "vitest"; + +import { fixPath } from "./os-jank"; + +describe("fixPath", () => { + it("hydrates PATH on linux using the resolved login shell", () => { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + }; + const readPath = vi.fn(() => "/opt/homebrew/bin:/usr/bin"); + + fixPath({ + env, + platform: "linux", + readPath, + }); + + expect(readPath).toHaveBeenCalledWith("/bin/zsh"); + expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin"); + }); + + it("does nothing outside macOS and linux even when SHELL is set", () => { + const env: NodeJS.ProcessEnv = { + SHELL: "C:/Program Files/Git/bin/bash.exe", + PATH: "C:\\Windows\\System32", + }; + const readPath = vi.fn(() => "/usr/local/bin:/usr/bin"); + + fixPath({ + env, + platform: "win32", + readPath, + }); + + expect(readPath).not.toHaveBeenCalled(); + expect(env.PATH).toBe("C:\\Windows\\System32"); + }); +}); diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index 0721d0d9f8..c3629e8fde 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -1,15 +1,25 @@ import * as OS from "node:os"; import { Effect, Path } from "effect"; -import { readPathFromLoginShell } from "@t3tools/shared/shell"; +import { readPathFromLoginShell, resolveLoginShell } from "@t3tools/shared/shell"; -export function fixPath(): void { - if (process.platform !== "darwin") return; +export function fixPath( + options: { + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + readPath?: typeof readPathFromLoginShell; + } = {}, +): void { + const platform = options.platform ?? process.platform; + if (platform !== "darwin" && platform !== "linux") return; + + const env = options.env ?? process.env; try { - const shell = process.env.SHELL ?? "/bin/zsh"; - const result = readPathFromLoginShell(shell); + const shell = resolveLoginShell(platform, env.SHELL); + if (!shell) return; + const result = (options.readPath ?? readPathFromLoginShell)(shell); if (result) { - process.env.PATH = result; + env.PATH = result; } } catch { // Silently ignore — keep default PATH diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index f1d60bf334..d9e8a7881b 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -10,6 +10,26 @@ 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; + } + + if (platform === "darwin") { + return "/bin/zsh"; + } + + if (platform === "linux") { + return "/bin/bash"; + } + + return undefined; +} + export function extractPathFromShellOutput(output: string): string | null { const startIndex = output.indexOf(PATH_CAPTURE_START); if (startIndex === -1) return null;