diff --git a/apps/desktop/src/fixPath.ts b/apps/desktop/src/fixPath.ts index 8853248b24..96d842693d 100644 --- a/apps/desktop/src/fixPath.ts +++ b/apps/desktop/src/fixPath.ts @@ -1,15 +1,14 @@ -import { readPathFromLoginShell } from "@t3tools/shared/shell"; +import { + defaultShellCandidates, + resolvePathFromLoginShells, + shouldRepairPath, +} from "@t3tools/shared/shell"; export function fixPath(): void { - if (process.platform !== "darwin") return; + if (!shouldRepairPath()) 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..fda2fc480d 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -1,18 +1,17 @@ import * as OS from "node:os"; import { Effect, Path } from "effect"; -import { readPathFromLoginShell } from "@t3tools/shared/shell"; +import { + defaultShellCandidates, + resolvePathFromLoginShells, + shouldRepairPath, +} from "@t3tools/shared/shell"; export function fixPath(): void { - if (process.platform !== "darwin") return; + if (!shouldRepairPath()) 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 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 f659725c73..c9d046017c 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it, vi } from "vitest"; -import { extractPathFromShellOutput, readPathFromLoginShell } from "./shell"; +import { + defaultShellCandidates, + extractPathFromShellOutput, + readPathFromLoginShell, + resolvePathFromLoginShells, + shouldRepairPath, +} from "./shell"; describe("extractPathFromShellOutput", () => { it("extracts the path between capture markers", () => { @@ -52,6 +58,110 @@ 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", () => { + 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 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); + }); +}); + +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("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; + } + }); +}); + +describe("shouldRepairPath", () => { + it("skips repair when macOS already has a likely interactive PATH", () => { + expect(shouldRepairPath("darwin", "/usr/bin:/bin:/opt/homebrew/bin")).toBe(false); + }); + + 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 e6029c4432..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,12 +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; +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); @@ -29,10 +38,123 @@ export function extractPathFromShellOutput(output: string): string | null { export function readPathFromLoginShell( shell: string, execFile: ExecFileSyncLike = execFileSync, + onError?: LoginShellErrorReporter, ): string | undefined { - const output = execFile(shell, ["-ilc", PATH_CAPTURE_COMMAND], { - encoding: "utf8", - timeout: 5000, - }); - return extractPathFromShellOutput(output) ?? undefined; + let lastError: unknown; + for (const args of LOGIN_SHELL_ARG_SETS) { + try { + const output = execFile(shell, args, { + encoding: "utf8", + timeout: LOGIN_SHELL_TIMEOUT_MS, + }); + const resolvedPath = extractPathFromShellOutput(output) ?? undefined; + if (resolvedPath) { + return resolvedPath; + } + } catch (error) { + lastError = error; + onError?.(shell, args, error); + } + } + + if (lastError) { + throw lastError; + } + 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", + ]); +} + +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 { + 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, (_failedShell, _args, error) => { + onError?.(shell, error); + }); + if (result) { + return result; + } + } 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"); }