diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 83c4250de152c9bdf4e9cc7b733fb9684dd86550..677eb44a8f93e3a5bac0be804efc588b239af0ae 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -367,6 +367,27 @@ describe("DesktopShellEnvironment", () => { }), ); + it.effect("keeps mise's PATH record with the login shell PATH", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/bash", + PATH: "/usr/bin", + }; + + yield* runShellEnvironment({ + env, + platform: "linux", + handler: () => + envOutput({ + PATH: "/home/me/.local/bin:/mise/installs/claude/latest:/mise/shims", + __MISE_DIFF: "mise-diff", + }), + }); + + assert.equal(env.__MISE_DIFF, "mise-diff"); + }), + ); + it.effect("falls back to launchctl PATH on macOS when shell probing does not return one", () => Effect.gen(function* () { const env: NodeJS.ProcessEnv = { diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 4fa98261fbccd8753ceeff5b853a25f5db6b888a..ebf5abfdc9f8437e8c4f30071b26305f256f81c1 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -87,6 +87,7 @@ const LOGIN_SHELL_ENV_NAMES = [ "XDG_SESSION_TYPE", "WAYLAND_DISPLAY", "T3CODE_TELEMETRY_ENABLED", + "__MISE_DIFF", ] as const; const WINDOWS_PROFILE_ENV_NAMES = ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"] as const; const LOCALE_ENV_NAMES = ["LANG", "LC_ALL", "LC_CTYPE"] as const; @@ -433,6 +434,10 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix "XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "XDG_SESSION_TYPE", + // mise's record of the PATH entries it added. It must travel with the + // shell's PATH, or `mise x` can rebuild PATH behind a same-named wrapper + // (`exec mise x claude -- claude`) that then re-runs itself forever. + "__MISE_DIFF", ] as const; for (const name of shellPreferredEnvNames) { if (shellEnvironment[name]) { diff --git a/apps/server/src/os-jank.test.ts b/apps/server/src/os-jank.test.ts index 9a72829ad3db559a9d5d648cf26f0ea6d88af6fc..b8f5848ff10777faa3652c09d8a84edd647bdc3f 100644 --- a/apps/server/src/os-jank.test.ts +++ b/apps/server/src/os-jank.test.ts @@ -98,6 +98,38 @@ effectIt.effect("hydrates PATH when the desktop handoff is absent", () => ), ); +effectIt.effect("keeps mise's PATH record with the login shell PATH", () => + Effect.acquireUseRelease( + Effect.sync(() => + vi + .spyOn(NodeChildProcess, "execFileSync") + .mockReturnValue( + [ + "__T3CODE_ENV_PATH_START__", + "/home/me/.local/bin:/mise/installs/claude/latest:/mise/shims", + "__T3CODE_ENV_PATH_END__", + "__T3CODE_ENV___MISE_DIFF_START__", + "mise-diff", + "__T3CODE_ENV___MISE_DIFF_END__", + ].join("\n"), + ), + ), + () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { SHELL: "/bin/bash", PATH: "/usr/bin" }; + + yield* fixPath().pipe( + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provideService(HostProcess.Environment, env), + Effect.provide(NodeServices.layer), + ); + + assert.equal(env.__MISE_DIFF, "mise-diff"); + }), + (probe) => Effect.sync(() => probe.mockRestore()), + ), +); + effectIt.effect("repairs Windows PATH even when the desktop marks it prepared", () => Effect.gen(function* () { const env: NodeJS.ProcessEnv = { PATH: "C:\\Windows" }; diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index a94e03ea871c7f198ee3476108afd0e988aa6321..c1948c60908898479fcf1634a906bb46adc48f81 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -2,7 +2,7 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import { listLoginShellCandidates, mergePathEntries, - readPathFromLoginShell, + readEnvironmentFromLoginShell, readPathFromLaunchctl, resolveWindowsEnvironment, } from "@t3tools/shared/shell"; @@ -18,22 +18,29 @@ function logPathHydrationWarning(message: string, error?: unknown): void { } function hydratePosixPath(env: NodeJS.ProcessEnv, platform: NodeJS.Platform): void { - let shellPath: string | undefined; + let shellEnvironment: Partial> = {}; for (const shell of listLoginShellCandidates(platform, env.SHELL)) { try { - shellPath = readPathFromLoginShell(shell); + // mise's record of the PATH entries it added. It must travel with the + // shell's PATH, or `mise x` can rebuild PATH behind a same-named wrapper + // (`exec mise x claude -- claude`) that then re-runs itself forever. + shellEnvironment = readEnvironmentFromLoginShell(shell, ["PATH", "__MISE_DIFF"]); } catch (error) { logPathHydrationWarning(`Failed to read PATH from login shell ${shell}.`, error); } - if (shellPath) break; + if (shellEnvironment.PATH) break; } + const shellPath = shellEnvironment.PATH; const launchctlPath = platform === "darwin" && !shellPath ? readPathFromLaunchctl() : undefined; const mergedPath = mergePathEntries(shellPath ?? launchctlPath, env.PATH, platform); if (mergedPath) { env.PATH = mergedPath; } + if (shellPath && shellEnvironment.__MISE_DIFF) { + env.__MISE_DIFF = shellEnvironment.__MISE_DIFF; + } } export function hydratePosixHome( diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index 56c1b4fa4caf0febf829995234f355d3c36823d9..b9e10812f005f382bb04667ee5b1a14a0961f57e 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -22,7 +22,6 @@ import { readEnvironmentFromLoginShell, readEnvironmentFromWindowsShell, readPathFromLaunchctl, - readPathFromLoginShell, resolveCommandPath, resolveKnownWindowsCliDirs, resolveSpawnCommand, @@ -57,7 +56,7 @@ const recordCommandTrace = Effect.fnUntraced(function* (effect: Effect. return { result, spans }; }); -describe("readPathFromLoginShell", () => { +describe("readEnvironmentFromLoginShell probe", () => { it("uses a shell-agnostic printenv PATH probe", () => { const execFile = vi.fn< ( @@ -67,7 +66,9 @@ describe("readPathFromLoginShell", () => { ) => string >(() => "__T3CODE_ENV_PATH_START__\n/a:/b\n__T3CODE_ENV_PATH_END__\n"); - expect(readPathFromLoginShell("/opt/homebrew/bin/fish", execFile)).toBe("/a:/b"); + expect(readEnvironmentFromLoginShell("/opt/homebrew/bin/fish", ["PATH"], execFile).PATH).toBe( + "/a:/b", + ); expect(execFile).toHaveBeenCalledTimes(1); const firstCall = execFile.mock.calls[0] as diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index d97fcd15fda64685f5cf28a3c5b232c6470ef5b9..d6d8a84afae9ceba54c883df919f9bf11a9ab572 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -172,13 +172,6 @@ export function listLoginShellCandidates( return candidates; } -export function readPathFromLoginShell( - shell: string, - execFile: ExecFileSyncLike = NodeChildProcess.execFileSync, -): string | undefined { - return readEnvironmentFromLoginShell(shell, ["PATH"], execFile).PATH; -} - export function readPathFromLaunchctl( execFile: ExecFileSyncLike = NodeChildProcess.execFileSync, ): string | undefined {