| author | |
| committer | |
| log | 32c1f98a02d5cf8e42d70dbd65822f9b08151024 |
| tree | 2b1719f37fbd6fd6273de3f26c1d41bc1ab55078 |
| parent | 5851b8aca4a13e1a5c6db644a93c20b779e0e04d |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <julius0216@outlook.com>6 files changed, 127 insertions(+), 14 deletions(-)
apps/desktop/src/syncShellEnvironment.test.ts+24-4| ... | @@ -62,14 +62,13 @@ describe("syncShellEnvironment", () => { | ... | @@ -62,14 +62,13 @@ describe("syncShellEnvironment", () => { |
| 62 | expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock"); | 62 | expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock"); |
| 63 | }); | 63 | }); |
| 64 | 64 | ||
| 65 | it("does nothing outside macOS", () => { | 65 | it("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on linux", () => { |
| 66 | const env: NodeJS.ProcessEnv = { | 66 | const env: NodeJS.ProcessEnv = { |
| 67 | SHELL: "/bin/zsh", | 67 | SHELL: "/bin/zsh", |
| 68 | PATH: "/usr/bin", | 68 | PATH: "/usr/bin", |
| 69 | SSH_AUTH_SOCK: "/tmp/inherited.sock", | ||
| 70 | }; | 69 | }; |
| 71 | const readEnvironment = vi.fn(() => ({ | 70 | const readEnvironment = vi.fn(() => ({ |
| 72 | PATH: "/opt/homebrew/bin:/usr/bin", | 71 | PATH: "/home/linuxbrew/.linuxbrew/bin:/usr/bin", |
| 73 | SSH_AUTH_SOCK: "/tmp/secretive.sock", | 72 | SSH_AUTH_SOCK: "/tmp/secretive.sock", |
| 74 | })); | 73 | })); |
| 75 | 74 | ||
| ... | @@ -78,8 +77,29 @@ describe("syncShellEnvironment", () => { | ... | @@ -78,8 +77,29 @@ describe("syncShellEnvironment", () => { |
| 78 | readEnvironment, | 77 | readEnvironment, |
| 79 | }); | 78 | }); |
| 80 | 79 | ||
| 80 | expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"]); | ||
| 81 | expect(env.PATH).toBe("/home/linuxbrew/.linuxbrew/bin:/usr/bin"); | ||
| 82 | expect(env.SSH_AUTH_SOCK).toBe("/tmp/secretive.sock"); | ||
| 83 | }); | ||
| 84 | |||
| 85 | it("does nothing outside macOS and linux", () => { | ||
| 86 | const env: NodeJS.ProcessEnv = { | ||
| 87 | SHELL: "C:/Program Files/Git/bin/bash.exe", | ||
| 88 | PATH: "C:\\Windows\\System32", | ||
| 89 | SSH_AUTH_SOCK: "/tmp/inherited.sock", | ||
| 90 | }; | ||
| 91 | const readEnvironment = vi.fn(() => ({ | ||
| 92 | PATH: "/usr/local/bin:/usr/bin", | ||
| 93 | SSH_AUTH_SOCK: "/tmp/secretive.sock", | ||
| 94 | })); | ||
| 95 | |||
| 96 | syncShellEnvironment(env, { | ||
| 97 | platform: "win32", | ||
| 98 | readEnvironment, | ||
| 99 | }); | ||
| 100 | |||
| 81 | expect(readEnvironment).not.toHaveBeenCalled(); | 101 | expect(readEnvironment).not.toHaveBeenCalled(); |
| 82 | expect(env.PATH).toBe("/usr/bin"); | 102 | expect(env.PATH).toBe("C:\\Windows\\System32"); |
| 83 | expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock"); | 103 | expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock"); |
| 84 | }); | 104 | }); |
| 85 | }); | 105 | }); |
apps/desktop/src/syncShellEnvironment.ts+10-3| ... | @@ -1,4 +1,8 @@ | ... | @@ -1,4 +1,8 @@ |
| 1 | import { readEnvironmentFromLoginShell, ShellEnvironmentReader } from "@t3tools/shared/shell"; | 1 | import { |
| 2 | readEnvironmentFromLoginShell, | ||
| 3 | resolveLoginShell, | ||
| 4 | ShellEnvironmentReader, | ||
| 5 | } from "@t3tools/shared/shell"; | ||
| 2 | 6 | ||
| 3 | export function syncShellEnvironment( | 7 | export function syncShellEnvironment( |
| 4 | env: NodeJS.ProcessEnv = process.env, | 8 | env: NodeJS.ProcessEnv = process.env, |
| ... | @@ -7,10 +11,13 @@ export function syncShellEnvironment( | ... | @@ -7,10 +11,13 @@ export function syncShellEnvironment( |
| 7 | readEnvironment?: ShellEnvironmentReader; | 11 | readEnvironment?: ShellEnvironmentReader; |
| 8 | } = {}, | 12 | } = {}, |
| 9 | ): void { | 13 | ): void { |
| 10 | if ((options.platform ?? process.platform) !== "darwin") return; | 14 | const platform = options.platform ?? process.platform; |
| 15 | if (platform !== "darwin" && platform !== "linux") return; | ||
| 11 | 16 | ||
| 12 | try { | 17 | try { |
| 13 | const shell = env.SHELL ?? "/bin/zsh"; | 18 | const shell = resolveLoginShell(platform, env.SHELL); |
| 19 | if (!shell) return; | ||
| 20 | |||
| 14 | const shellEnvironment = (options.readEnvironment ?? readEnvironmentFromLoginShell)(shell, [ | 21 | const shellEnvironment = (options.readEnvironment ?? readEnvironmentFromLoginShell)(shell, [ |
| 15 | "PATH", | 22 | "PATH", |
| 16 | "SSH_AUTH_SOCK", | 23 | "SSH_AUTH_SOCK", |
apps/server/src/main.test.ts+18-1| ... | @@ -21,6 +21,7 @@ import { ServerSettingsService } from "./serverSettings"; | ... | @@ -21,6 +21,7 @@ import { ServerSettingsService } from "./serverSettings"; |
| 21 | 21 | ||
| 22 | const start = vi.fn(() => undefined); | 22 | const start = vi.fn(() => undefined); |
| 23 | const stop = vi.fn(() => undefined); | 23 | const stop = vi.fn(() => undefined); |
| 24 | const fixPath = vi.fn(() => undefined); | ||
| 24 | let resolvedConfig: ServerConfigShape | null = null; | 25 | let resolvedConfig: ServerConfigShape | null = null; |
| 25 | const serverStart = Effect.acquireRelease( | 26 | const serverStart = Effect.acquireRelease( |
| 26 | Effect.gen(function* () { | 27 | Effect.gen(function* () { |
| ... | @@ -36,7 +37,7 @@ const findAvailablePort = vi.fn((preferred: number) => Effect.succeed(preferred) | ... | @@ -36,7 +37,7 @@ const findAvailablePort = vi.fn((preferred: number) => Effect.succeed(preferred) |
| 36 | const testLayer = Layer.mergeAll( | 37 | const testLayer = Layer.mergeAll( |
| 37 | Layer.succeed(CliConfig, { | 38 | Layer.succeed(CliConfig, { |
| 38 | cwd: "/tmp/t3-test-workspace", | 39 | cwd: "/tmp/t3-test-workspace", |
| 39 | fixPath: Effect.void, | 40 | fixPath: Effect.sync(fixPath), |
| 40 | resolveStaticDir: Effect.undefined, | 41 | resolveStaticDir: Effect.undefined, |
| 41 | } satisfies CliConfigShape), | 42 | } satisfies CliConfigShape), |
| 42 | Layer.succeed(NetService, { | 43 | Layer.succeed(NetService, { |
| ... | @@ -81,6 +82,7 @@ beforeEach(() => { | ... | @@ -81,6 +82,7 @@ beforeEach(() => { |
| 81 | resolvedConfig = null; | 82 | resolvedConfig = null; |
| 82 | start.mockImplementation(() => undefined); | 83 | start.mockImplementation(() => undefined); |
| 83 | stop.mockImplementation(() => undefined); | 84 | stop.mockImplementation(() => undefined); |
| 85 | fixPath.mockImplementation(() => undefined); | ||
| 84 | findAvailablePort.mockImplementation((preferred: number) => Effect.succeed(preferred)); | 86 | findAvailablePort.mockImplementation((preferred: number) => Effect.succeed(preferred)); |
| 85 | }); | 87 | }); |
| 86 | 88 | ||
| ... | @@ -329,6 +331,21 @@ it.layer(testLayer)("server CLI command", (it) => { | ... | @@ -329,6 +331,21 @@ it.layer(testLayer)("server CLI command", (it) => { |
| 329 | }), | 331 | }), |
| 330 | ); | 332 | ); |
| 331 | 333 | ||
| 334 | it.effect("hydrates PATH before server startup", () => | ||
| 335 | Effect.gen(function* () { | ||
| 336 | yield* runCli([]); | ||
| 337 | |||
| 338 | assert.equal(fixPath.mock.calls.length, 1); | ||
| 339 | assert.equal(start.mock.calls.length, 1); | ||
| 340 | const fixPathOrder = fixPath.mock.invocationCallOrder[0]; | ||
| 341 | const startOrder = start.mock.invocationCallOrder[0]; | ||
| 342 | if (typeof fixPathOrder !== "number" || typeof startOrder !== "number") { | ||
| 343 | assert.fail("Expected fixPath and start to be called"); | ||
| 344 | } | ||
| 345 | assert.isTrue(fixPathOrder < startOrder); | ||
| 346 | }), | ||
| 347 | ); | ||
| 348 | |||
| 332 | it.effect("records a startup heartbeat with thread/project counts", () => | 349 | it.effect("records a startup heartbeat with thread/project counts", () => |
| 333 | Effect.gen(function* () { | 350 | Effect.gen(function* () { |
| 334 | const recordTelemetry = vi.fn( | 351 | const recordTelemetry = vi.fn( |
apps/server/src/os-jank.test.ts created+39| ... | @@ -0,0 +1,39 @@ | ||
| 1 | import { describe, expect, it, vi } from "vitest"; | ||
| 2 | |||
| 3 | import { fixPath } from "./os-jank"; | ||
| 4 | |||
| 5 | describe("fixPath", () => { | ||
| 6 | it("hydrates PATH on linux using the resolved login shell", () => { | ||
| 7 | const env: NodeJS.ProcessEnv = { | ||
| 8 | SHELL: "/bin/zsh", | ||
| 9 | PATH: "/usr/bin", | ||
| 10 | }; | ||
| 11 | const readPath = vi.fn(() => "/opt/homebrew/bin:/usr/bin"); | ||
| 12 | |||
| 13 | fixPath({ | ||
| 14 | env, | ||
| 15 | platform: "linux", | ||
| 16 | readPath, | ||
| 17 | }); | ||
| 18 | |||
| 19 | expect(readPath).toHaveBeenCalledWith("/bin/zsh"); | ||
| 20 | expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin"); | ||
| 21 | }); | ||
| 22 | |||
| 23 | it("does nothing outside macOS and linux even when SHELL is set", () => { | ||
| 24 | const env: NodeJS.ProcessEnv = { | ||
| 25 | SHELL: "C:/Program Files/Git/bin/bash.exe", | ||
| 26 | PATH: "C:\\Windows\\System32", | ||
| 27 | }; | ||
| 28 | const readPath = vi.fn(() => "/usr/local/bin:/usr/bin"); | ||
| 29 | |||
| 30 | fixPath({ | ||
| 31 | env, | ||
| 32 | platform: "win32", | ||
| 33 | readPath, | ||
| 34 | }); | ||
| 35 | |||
| 36 | expect(readPath).not.toHaveBeenCalled(); | ||
| 37 | expect(env.PATH).toBe("C:\\Windows\\System32"); | ||
| 38 | }); | ||
| 39 | }); | ||
apps/server/src/os-jank.ts+16-6| ... | @@ -1,15 +1,25 @@ | ... | @@ -1,15 +1,25 @@ |
| 1 | import * as OS from "node:os"; | 1 | import * as OS from "node:os"; |
| 2 | import { Effect, Path } from "effect"; | 2 | import { Effect, Path } from "effect"; |
| 3 | import { readPathFromLoginShell } from "@t3tools/shared/shell"; | 3 | import { readPathFromLoginShell, resolveLoginShell } from "@t3tools/shared/shell"; |
| 4 | 4 | ||
| 5 | export function fixPath(): void { | 5 | export function fixPath( |
| 6 | if (process.platform !== "darwin") return; | 6 | options: { |
| 7 | env?: NodeJS.ProcessEnv; | ||
| 8 | platform?: NodeJS.Platform; | ||
| 9 | readPath?: typeof readPathFromLoginShell; | ||
| 10 | } = {}, | ||
| 11 | ): void { | ||
| 12 | const platform = options.platform ?? process.platform; | ||
| 13 | if (platform !== "darwin" && platform !== "linux") return; | ||
| 14 | |||
| 15 | const env = options.env ?? process.env; | ||
| 7 | 16 | ||
| 8 | try { | 17 | try { |
| 9 | const shell = process.env.SHELL ?? "/bin/zsh"; | 18 | const shell = resolveLoginShell(platform, env.SHELL); |
| 10 | const result = readPathFromLoginShell(shell); | 19 | if (!shell) return; |
| 20 | const result = (options.readPath ?? readPathFromLoginShell)(shell); | ||
| 11 | if (result) { | 21 | if (result) { |
| 12 | process.env.PATH = result; | 22 | env.PATH = result; |
| 13 | } | 23 | } |
| 14 | } catch { | 24 | } catch { |
| 15 | // Silently ignore — keep default PATH | 25 | // Silently ignore — keep default PATH |
packages/shared/src/shell.ts+20| ... | @@ -10,6 +10,26 @@ type ExecFileSyncLike = ( | ... | @@ -10,6 +10,26 @@ type ExecFileSyncLike = ( |
| 10 | options: { encoding: "utf8"; timeout: number }, | 10 | options: { encoding: "utf8"; timeout: number }, |
| 11 | ) => string; | 11 | ) => string; |
| 12 | 12 | ||
| 13 | export function resolveLoginShell( | ||
| 14 | platform: NodeJS.Platform, | ||
| 15 | shell: string | undefined, | ||
| 16 | ): string | undefined { | ||
| 17 | const trimmedShell = shell?.trim(); | ||
| 18 | if (trimmedShell) { | ||
| 19 | return trimmedShell; | ||
| 20 | } | ||
| 21 | |||
| 22 | if (platform === "darwin") { | ||
| 23 | return "/bin/zsh"; | ||
| 24 | } | ||
| 25 | |||
| 26 | if (platform === "linux") { | ||
| 27 | return "/bin/bash"; | ||
| 28 | } | ||
| 29 | |||
| 30 | return undefined; | ||
| 31 | } | ||
| 32 | |||
| 13 | export function extractPathFromShellOutput(output: string): string | null { | 33 | export function extractPathFromShellOutput(output: string): string | null { |
| 14 | const startIndex = output.indexOf(PATH_CAPTURE_START); | 34 | const startIndex = output.indexOf(PATH_CAPTURE_START); |
| 15 | if (startIndex === -1) return null; | 35 | if (startIndex === -1) return null; |