From 70ca0c49d8380315a471ba6d4f1e122d292065ef Mon Sep 17 00:00:00 2001 From: Ellie Gummere Date: Tue, 10 Mar 2026 15:09:07 -0400 Subject: [PATCH 1/3] fix(shell): bound PATH repair fallbacks --- apps/desktop/src/fixPath.ts | 15 ++-- apps/server/src/os-jank.ts | 17 ++--- packages/shared/src/shell.test.ts | 114 +++++++++++++++++++++++++++++- packages/shared/src/shell.ts | 86 ++++++++++++++++++++-- 4 files changed, 206 insertions(+), 26 deletions(-) diff --git a/apps/desktop/src/fixPath.ts b/apps/desktop/src/fixPath.ts index 8853248b24..bd22809b87 100644 --- a/apps/desktop/src/fixPath.ts +++ b/apps/desktop/src/fixPath.ts @@ -1,15 +1,10 @@ -import { readPathFromLoginShell } from "@t3tools/shared/shell"; +import { defaultShellCandidates, resolvePathFromLoginShells } from "@t3tools/shared/shell"; export function fixPath(): void { - if (process.platform !== "darwin") return; + if (process.platform !== "darwin" && process.platform !== "linux") return; - try { - const shell = process.env.SHELL ?? "/bin/zsh"; - const result = readPathFromLoginShell(shell); - if (result) { - process.env.PATH = result; - } - } catch { - // Keep inherited PATH if shell lookup fails. + const result = resolvePathFromLoginShells(defaultShellCandidates()); + if (result) { + process.env.PATH = result; } } diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index 586aca6f79..e3a8987ebe 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -1,18 +1,15 @@ import * as OS from "node:os"; import { Effect, Path } from "effect"; -import { readPathFromLoginShell } from "@t3tools/shared/shell"; +import { defaultShellCandidates, resolvePathFromLoginShells } from "@t3tools/shared/shell"; export function fixPath(): void { - if (process.platform !== "darwin") return; + if (process.platform !== "darwin" && process.platform !== "linux") return; - try { - const shell = process.env.SHELL ?? "/bin/zsh"; - const result = readPathFromLoginShell(shell); - if (result) { - process.env.PATH = result; - } - } catch { - // Silently ignore — keep default PATH + const shells = defaultShellCandidates(); + + const resolvedPath = resolvePathFromLoginShells(shells); + if (resolvedPath) { + process.env.PATH = resolvedPath; } } diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index f659725c73..ceceab208d 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it, vi } from "vitest"; -import { extractPathFromShellOutput, readPathFromLoginShell } from "./shell"; +import { + defaultShellCandidates, + extractPathFromShellOutput, + readPathFromLoginShell, + resolvePathFromLoginShells, +} from "./shell"; describe("extractPathFromShellOutput", () => { it("extracts the path between capture markers", () => { @@ -54,4 +59,111 @@ describe("readPathFromLoginShell", () => { expect(args?.[1]).toContain("__T3CODE_PATH_END__"); expect(options).toEqual({ encoding: "utf8", timeout: 5000 }); }); + + it("falls back to non-interactive login mode when interactive login fails", () => { + const execFile = vi.fn< + ( + file: string, + args: ReadonlyArray, + options: { encoding: "utf8"; timeout: number }, + ) => string + >((_, args) => { + if (args[0] === "-ilc") { + throw new Error("interactive login unsupported"); + } + return "__T3CODE_PATH_START__\n/a:/b\n__T3CODE_PATH_END__\n"; + }); + + expect(readPathFromLoginShell("/bin/sh", execFile)).toBe("/a:/b"); + expect(execFile).toHaveBeenCalledTimes(2); + expect(execFile.mock.calls[0]?.[1]?.[0]).toBe("-ilc"); + expect(execFile.mock.calls[1]?.[1]?.[0]).toBe("-lc"); + }); + + describe("resolvePathFromLoginShells", () => { + it("returns the first resolved PATH from the provided shells", () => { + const execFile = vi.fn< + ( + file: string, + args: ReadonlyArray, + options: { encoding: "utf8"; timeout: number }, + ) => string + >((file) => { + if (file === "/bin/zsh") { + throw new Error("zsh unavailable"); + } + return "__T3CODE_PATH_START__\n/a:/b\n__T3CODE_PATH_END__\n"; + }); + + const result = resolvePathFromLoginShells(["/bin/zsh", "/bin/bash"], execFile); + expect(result).toBe("/a:/b"); + expect(execFile).toHaveBeenCalledTimes(2); + }); + + it("returns undefined when all shells fail to resolve PATH", () => { + const execFile = vi.fn< + ( + file: string, + args: ReadonlyArray, + options: { encoding: "utf8"; timeout: number }, + ) => string + >(() => { + throw new Error("no shells available"); + }); + + const result = resolvePathFromLoginShells(["/bin/zsh", "/bin/bash"], execFile); + expect(result).toBeUndefined(); + expect(execFile).toHaveBeenCalledTimes(2); + }); + }); +}); + +describe("defaultShellCandidates", () => { + it("limits Linux candidates to the configured shell and POSIX fallback", () => { + const originalShell = process.env.SHELL; + process.env.SHELL = "/bin/bash"; + + try { + expect(defaultShellCandidates("linux")).toEqual(["/bin/bash", "/bin/sh"]); + } finally { + process.env.SHELL = originalShell; + } + }); + + it("dedupes repeated Linux shell candidates", () => { + const originalShell = process.env.SHELL; + process.env.SHELL = "/bin/sh"; + + try { + expect(defaultShellCandidates("linux")).toEqual(["/bin/sh"]); + } finally { + process.env.SHELL = originalShell; + } + }); + + it("limits macOS candidates to a small bounded fallback set", () => { + const originalShell = process.env.SHELL; + process.env.SHELL = "/opt/homebrew/bin/fish"; + + try { + expect(defaultShellCandidates("darwin")).toEqual([ + "/opt/homebrew/bin/fish", + "/bin/zsh", + "/bin/bash", + ]); + } finally { + process.env.SHELL = originalShell; + } + }); + + it("dedupes repeated macOS shell candidates", () => { + const originalShell = process.env.SHELL; + process.env.SHELL = "/bin/zsh"; + + try { + expect(defaultShellCandidates("darwin")).toEqual(["/bin/zsh", "/bin/bash"]); + } finally { + process.env.SHELL = originalShell; + } + }); }); diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index e6029c4432..f6cacbb795 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -14,6 +14,11 @@ type ExecFileSyncLike = ( options: { encoding: "utf8"; timeout: number }, ) => string; +const LOGIN_SHELL_ARG_SETS = [ + ["-ilc", PATH_CAPTURE_COMMAND], + ["-lc", PATH_CAPTURE_COMMAND], +] as const; + export function extractPathFromShellOutput(output: string): string | null { const startIndex = output.indexOf(PATH_CAPTURE_START); if (startIndex === -1) return null; @@ -30,9 +35,80 @@ export function readPathFromLoginShell( shell: string, execFile: ExecFileSyncLike = execFileSync, ): string | undefined { - const output = execFile(shell, ["-ilc", PATH_CAPTURE_COMMAND], { - encoding: "utf8", - timeout: 5000, - }); - return extractPathFromShellOutput(output) ?? undefined; + for (const args of LOGIN_SHELL_ARG_SETS) { + try { + const output = execFile(shell, args, { + encoding: "utf8", + timeout: 5000, + }); + const resolvedPath = extractPathFromShellOutput(output) ?? undefined; + if (resolvedPath) { + return resolvedPath; + } + } catch { + // Try the next shell invocation mode. + } + } + + return undefined; +} + +function uniqueShellCandidates(candidates: ReadonlyArray): string[] { + const unique = new Set(); + + for (const candidate of candidates) { + if (typeof candidate !== "string") continue; + const normalized = candidate.trim(); + if (normalized.length === 0 || unique.has(normalized)) continue; + unique.add(normalized); + } + + return [...unique]; +} + +export function defaultShellCandidates(platform = process.platform): string[] { + if (platform === "linux") { + return uniqueShellCandidates([process.env.SHELL, "/bin/sh"]); + } + + if (platform === "darwin") { + return uniqueShellCandidates([process.env.SHELL, "/bin/zsh", "/bin/bash"]); + } + + return uniqueShellCandidates([ + process.env.SHELL, + "/bin/zsh", + "/usr/bin/zsh", + "/bin/bash", + "/usr/bin/bash", + ]); +} + +type ShellPathResolveErrorReporter = (shell: string, error: unknown) => void; + +const defaultShellPathErrorReporter: ShellPathResolveErrorReporter | undefined = + process.env.T3CODE_DEBUG_SHELL_PATH === "1" + ? (shell, error) => { + const message = error instanceof Error ? error.message : String(error); + console.warn(`[shell] PATH resolution failed for ${shell}: ${message}`); + } + : undefined; + +export function resolvePathFromLoginShells( + shells: ReadonlyArray, + execFile: ExecFileSyncLike = execFileSync, + onError: ShellPathResolveErrorReporter | undefined = defaultShellPathErrorReporter, +): string | undefined { + for (const shell of shells) { + try { + const result = readPathFromLoginShell(shell, execFile); + if (result) { + return result; + } + } catch (error) { + onError?.(shell, error); + // Try next shell candidate. + } + } + return undefined; } From 4b617ff0ea680e78931c33e93ba0c401b1d04d95 Mon Sep 17 00:00:00 2001 From: Ellie Gummere Date: Tue, 10 Mar 2026 15:38:25 -0400 Subject: [PATCH 2/3] Update packages/shared/src/shell.test.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/shared/src/shell.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index ceceab208d..c6c1c0504b 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -113,7 +113,7 @@ describe("readPathFromLoginShell", () => { const result = resolvePathFromLoginShells(["/bin/zsh", "/bin/bash"], execFile); expect(result).toBeUndefined(); - expect(execFile).toHaveBeenCalledTimes(2); + expect(execFile).toHaveBeenCalledTimes(4); }); }); }); From e488a7f73bb16aaffb48da93fe7655a19ddb3f38 Mon Sep 17 00:00:00 2001 From: Ellie Gummere Date: Tue, 10 Mar 2026 15:49:19 -0400 Subject: [PATCH 3/3] fix(shell): bound PATH repair startup cost --- apps/desktop/src/fixPath.ts | 8 ++- apps/server/src/os-jank.ts | 12 ++-- packages/shared/src/shell.test.ts | 102 +++++++++++++++--------------- packages/shared/src/shell.ts | 74 ++++++++++++++++++---- 4 files changed, 123 insertions(+), 73 deletions(-) diff --git a/apps/desktop/src/fixPath.ts b/apps/desktop/src/fixPath.ts index bd22809b87..96d842693d 100644 --- a/apps/desktop/src/fixPath.ts +++ b/apps/desktop/src/fixPath.ts @@ -1,7 +1,11 @@ -import { defaultShellCandidates, resolvePathFromLoginShells } from "@t3tools/shared/shell"; +import { + defaultShellCandidates, + resolvePathFromLoginShells, + shouldRepairPath, +} from "@t3tools/shared/shell"; export function fixPath(): void { - if (process.platform !== "darwin" && process.platform !== "linux") return; + if (!shouldRepairPath()) return; const result = resolvePathFromLoginShells(defaultShellCandidates()); if (result) { diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index e3a8987ebe..fda2fc480d 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -1,13 +1,15 @@ import * as OS from "node:os"; import { Effect, Path } from "effect"; -import { defaultShellCandidates, resolvePathFromLoginShells } from "@t3tools/shared/shell"; +import { + defaultShellCandidates, + resolvePathFromLoginShells, + shouldRepairPath, +} from "@t3tools/shared/shell"; export function fixPath(): void { - if (process.platform !== "darwin" && process.platform !== "linux") return; + if (!shouldRepairPath()) return; - const shells = defaultShellCandidates(); - - const resolvedPath = resolvePathFromLoginShells(shells); + const resolvedPath = resolvePathFromLoginShells(defaultShellCandidates()); if (resolvedPath) { process.env.PATH = resolvedPath; } diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index c6c1c0504b..c9d046017c 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -5,6 +5,7 @@ import { extractPathFromShellOutput, readPathFromLoginShell, resolvePathFromLoginShells, + shouldRepairPath, } from "./shell"; describe("extractPathFromShellOutput", () => { @@ -57,7 +58,7 @@ describe("readPathFromLoginShell", () => { expect(args?.[1]).toContain("printenv PATH"); expect(args?.[1]).toContain("__T3CODE_PATH_START__"); expect(args?.[1]).toContain("__T3CODE_PATH_END__"); - expect(options).toEqual({ encoding: "utf8", timeout: 5000 }); + expect(options).toEqual({ encoding: "utf8", timeout: 750 }); }); it("falls back to non-interactive login mode when interactive login fails", () => { @@ -79,42 +80,45 @@ describe("readPathFromLoginShell", () => { expect(execFile.mock.calls[0]?.[1]?.[0]).toBe("-ilc"); expect(execFile.mock.calls[1]?.[1]?.[0]).toBe("-lc"); }); +}); - describe("resolvePathFromLoginShells", () => { - it("returns the first resolved PATH from the provided shells", () => { - const execFile = vi.fn< - ( - file: string, - args: ReadonlyArray, - options: { encoding: "utf8"; timeout: number }, - ) => string - >((file) => { - if (file === "/bin/zsh") { - throw new Error("zsh unavailable"); - } - return "__T3CODE_PATH_START__\n/a:/b\n__T3CODE_PATH_END__\n"; - }); - - const result = resolvePathFromLoginShells(["/bin/zsh", "/bin/bash"], execFile); - expect(result).toBe("/a:/b"); - expect(execFile).toHaveBeenCalledTimes(2); +describe("resolvePathFromLoginShells", () => { + it("returns the first resolved PATH from the provided shells", () => { + const execFile = vi.fn< + ( + file: string, + args: ReadonlyArray, + options: { encoding: "utf8"; timeout: number }, + ) => string + >((file) => { + if (file === "/bin/zsh") { + throw new Error("zsh unavailable"); + } + return "__T3CODE_PATH_START__\n/a:/b\n__T3CODE_PATH_END__\n"; }); - it("returns undefined when all shells fail to resolve PATH", () => { - const execFile = vi.fn< - ( - file: string, - args: ReadonlyArray, - options: { encoding: "utf8"; timeout: number }, - ) => string - >(() => { - throw new Error("no shells available"); - }); - - const result = resolvePathFromLoginShells(["/bin/zsh", "/bin/bash"], execFile); - expect(result).toBeUndefined(); - expect(execFile).toHaveBeenCalledTimes(4); + const onError = vi.fn<(shell: string, error: unknown) => void>(); + const result = resolvePathFromLoginShells(["/bin/zsh", "/bin/bash"], execFile, onError); + expect(result).toBe("/a:/b"); + expect(execFile).toHaveBeenCalledTimes(3); + expect(onError).toHaveBeenCalledTimes(2); + expect(onError.mock.calls.map(([shell]) => shell)).toEqual(["/bin/zsh", "/bin/zsh"]); + }); + + it("returns undefined when all shells fail to resolve PATH", () => { + const execFile = vi.fn< + ( + file: string, + args: ReadonlyArray, + options: { encoding: "utf8"; timeout: number }, + ) => string + >(() => { + throw new Error("no shells available"); }); + + const result = resolvePathFromLoginShells(["/bin/zsh", "/bin/bash"], execFile); + expect(result).toBeUndefined(); + expect(execFile).toHaveBeenCalledTimes(4); }); }); @@ -130,17 +134,6 @@ describe("defaultShellCandidates", () => { } }); - it("dedupes repeated Linux shell candidates", () => { - const originalShell = process.env.SHELL; - process.env.SHELL = "/bin/sh"; - - try { - expect(defaultShellCandidates("linux")).toEqual(["/bin/sh"]); - } finally { - process.env.SHELL = originalShell; - } - }); - it("limits macOS candidates to a small bounded fallback set", () => { const originalShell = process.env.SHELL; process.env.SHELL = "/opt/homebrew/bin/fish"; @@ -155,15 +148,20 @@ describe("defaultShellCandidates", () => { process.env.SHELL = originalShell; } }); +}); - it("dedupes repeated macOS shell candidates", () => { - const originalShell = process.env.SHELL; - process.env.SHELL = "/bin/zsh"; +describe("shouldRepairPath", () => { + it("skips repair when macOS already has a likely interactive PATH", () => { + expect(shouldRepairPath("darwin", "/usr/bin:/bin:/opt/homebrew/bin")).toBe(false); + }); - try { - expect(defaultShellCandidates("darwin")).toEqual(["/bin/zsh", "/bin/bash"]); - } finally { - process.env.SHELL = originalShell; - } + it("requires repair when Linux is missing common user PATH entries", () => { + expect(shouldRepairPath("linux", "/usr/bin:/bin", "/home/tester")).toBe(true); + }); + + it("skips repair when Linux already exposes ~/.local/bin", () => { + expect(shouldRepairPath("linux", "/home/tester/.local/bin:/usr/bin", "/home/tester")).toBe( + false, + ); }); }); diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index f6cacbb795..ac7e26d470 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -1,4 +1,5 @@ import { execFileSync } from "node:child_process"; +import { homedir } from "node:os"; const PATH_CAPTURE_START = "__T3CODE_PATH_START__"; const PATH_CAPTURE_END = "__T3CODE_PATH_END__"; @@ -7,17 +8,20 @@ const PATH_CAPTURE_COMMAND = [ "printenv PATH", `printf '%s\n' '${PATH_CAPTURE_END}'`, ].join("; "); +const LOGIN_SHELL_TIMEOUT_MS = 750; +const PATH_REPAIR_DEADLINE_MS = 2_000; +const LOGIN_SHELL_ARG_SETS = [ + ["-ilc", PATH_CAPTURE_COMMAND], + ["-lc", PATH_CAPTURE_COMMAND], +] as const; type ExecFileSyncLike = ( file: string, args: ReadonlyArray, options: { encoding: "utf8"; timeout: number }, ) => string; - -const LOGIN_SHELL_ARG_SETS = [ - ["-ilc", PATH_CAPTURE_COMMAND], - ["-lc", PATH_CAPTURE_COMMAND], -] as const; +type LoginShellErrorReporter = (shell: string, args: ReadonlyArray, error: unknown) => void; +type ShellPathResolveErrorReporter = (shell: string, error: unknown) => void; export function extractPathFromShellOutput(output: string): string | null { const startIndex = output.indexOf(PATH_CAPTURE_START); @@ -34,22 +38,28 @@ export function extractPathFromShellOutput(output: string): string | null { export function readPathFromLoginShell( shell: string, execFile: ExecFileSyncLike = execFileSync, + onError?: LoginShellErrorReporter, ): string | undefined { + let lastError: unknown; for (const args of LOGIN_SHELL_ARG_SETS) { try { const output = execFile(shell, args, { encoding: "utf8", - timeout: 5000, + timeout: LOGIN_SHELL_TIMEOUT_MS, }); const resolvedPath = extractPathFromShellOutput(output) ?? undefined; if (resolvedPath) { return resolvedPath; } - } catch { - // Try the next shell invocation mode. + } catch (error) { + lastError = error; + onError?.(shell, args, error); } } + if (lastError) { + throw lastError; + } return undefined; } @@ -84,8 +94,6 @@ export function defaultShellCandidates(platform = process.platform): string[] { ]); } -type ShellPathResolveErrorReporter = (shell: string, error: unknown) => void; - const defaultShellPathErrorReporter: ShellPathResolveErrorReporter | undefined = process.env.T3CODE_DEBUG_SHELL_PATH === "1" ? (shell, error) => { @@ -99,16 +107,54 @@ export function resolvePathFromLoginShells( execFile: ExecFileSyncLike = execFileSync, onError: ShellPathResolveErrorReporter | undefined = defaultShellPathErrorReporter, ): string | undefined { + const deadline = Date.now() + PATH_REPAIR_DEADLINE_MS; + for (const shell of shells) { + if (Date.now() >= deadline) { + return undefined; + } + try { - const result = readPathFromLoginShell(shell, execFile); + const result = readPathFromLoginShell(shell, execFile, (_failedShell, _args, error) => { + onError?.(shell, error); + }); if (result) { return result; } - } catch (error) { - onError?.(shell, error); - // Try next shell candidate. + } catch { + // Per-attempt failures are already reported via onError when enabled. } } + return undefined; } + +function pathEntries(pathValue: string | undefined): Set { + return new Set( + (pathValue ?? "") + .split(":") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0), + ); +} + +export function shouldRepairPath( + platform = process.platform, + pathValue = process.env.PATH, + homePath = process.env.HOME ?? homedir(), +): boolean { + if (platform !== "darwin" && platform !== "linux") { + return false; + } + + const entries = pathEntries(pathValue); + if (entries.size === 0) { + return true; + } + + if (platform === "darwin") { + return !entries.has("/opt/homebrew/bin") && !entries.has("/usr/local/bin"); + } + + return !entries.has(`${homePath}/.local/bin`) && !entries.has("/usr/local/bin"); +}