diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 88c45f37cc467..3486c296da516 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -486,7 +486,7 @@ describe("DesktopShellEnvironment", () => { Effect.gen(function* () { const env: NodeJS.ProcessEnv = { SHELL: "/bin/zsh", - PATH: "/Users/test/.local/bin:/usr/bin", + PATH: " /Users/test/.local/bin:/usr/bin", }; const commands: ChildProcess.Command[] = []; @@ -496,7 +496,7 @@ describe("DesktopShellEnvironment", () => { handler: (command) => { commands.push(command); return envOutput({ - PATH: "/opt/homebrew/bin:/usr/bin", + PATH: "/opt/homebrew/bin:/usr/bin:/opt/bin ", SSH_AUTH_SOCK: "/tmp/secretive.sock", HOMEBREW_PREFIX: "/opt/homebrew", }); @@ -505,7 +505,7 @@ describe("DesktopShellEnvironment", () => { assert.equal(commands.length, 1); assert.equal(commands[0]?._tag === "StandardCommand" ? commands[0].command : "", "/bin/zsh"); - assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin:/Users/test/.local/bin"); + assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin:/opt/bin : /Users/test/.local/bin"); assert.equal(env.SSH_AUTH_SOCK, "/tmp/secretive.sock"); assert.equal(env.HOMEBREW_PREFIX, "/opt/homebrew"); assert.isTrue(prepared); diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 299b0902b57a5..5389d94c1e8e8 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -3,6 +3,7 @@ import { applyShellEnvironment, buildEnvironmentCaptureCommand, listLoginShellCandidates, + mergePathValues, readUserLoginShell, setWindowsPath, } from "@t3tools/shared/shell"; @@ -131,10 +132,12 @@ const trimNonEmpty = (value: string | null | undefined): Option.Option = Option.filter((entry) => entry.length > 0), ); -const pathDelimiter = (platform: NodeJS.Platform) => (platform === "win32" ? ";" : ":"); +// POSIX directory names may begin or end with whitespace; mergePathValues trims only on Windows. +const nonEmptyPath = (value: string | null | undefined): Option.Option => + Option.fromNullishOr(value).pipe(Option.filter((entry) => entry.length > 0)); const readEnvPath = (env: NodeJS.ProcessEnv): Option.Option => - trimNonEmpty(env.PATH ?? env.Path ?? env.path); + nonEmptyPath(env.PATH ?? env.Path ?? env.path); const normalizeRuntimeDir = (value: string): string => value.replace(/\/+$/u, ""); @@ -153,39 +156,16 @@ const linuxRuntimeDirCandidates = ( return candidates.filter((candidate) => candidate.length > 0); }; -const pathComparisonKey = (entry: string, platform: NodeJS.Platform) => { - const normalized = entry.trim().replace(/^"+|"+$/g, ""); - return platform === "win32" ? normalized.toLowerCase() : normalized; -}; - -const sanitizePathEntry = (entry: string, platform: NodeJS.Platform) => - platform === "win32" ? entry.replaceAll('"', "") : entry; - const mergePaths = ( platform: NodeJS.Platform, values: ReadonlyArray>, -): Option.Option => { - const delimiter = pathDelimiter(platform); - const entries: string[] = []; - const seen = new Set(); - - for (const value of values) { - if (Option.isNone(value)) continue; - - for (const entry of value.value.split(delimiter)) { - const sanitized = sanitizePathEntry(entry.trim(), platform); - if (sanitized.length === 0) continue; - - const key = pathComparisonKey(sanitized, platform); - if (key.length === 0 || seen.has(key)) continue; - - seen.add(key); - entries.push(sanitized); - } - } - - return entries.length > 0 ? Option.some(entries.join(delimiter)) : Option.none(); -}; +): Option.Option => + Option.fromNullishOr( + values.reduce( + (merged, value) => mergePathValues(merged, Option.getOrUndefined(value), platform), + undefined, + ), + ); const knownWindowsCliDirs = (env: NodeJS.ProcessEnv): ReadonlyArray => [ ...trimNonEmpty(env.APPDATA).pipe( @@ -484,7 +464,7 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix ? yield* readLaunchctlPath(config.env) : Option.none(); const mergedPath = mergePaths(config.platform, [ - trimNonEmpty(shellEnvironment.PATH).pipe(Option.orElse(() => launchctlPath)), + nonEmptyPath(shellEnvironment.PATH).pipe(Option.orElse(() => launchctlPath)), readEnvPath(config.env), ]); diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index 6df1169a9a7cc..1c2a2c8cf4f25 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -251,6 +251,9 @@ describe("mergePathEntries", () => { expect( mergePathEntries("/opt/homebrew/bin:/usr/bin", "/Users/test/.local/bin:/usr/bin", "darwin"), ).toBe("/opt/homebrew/bin:/usr/bin:/Users/test/.local/bin"); + expect(mergePathEntries("/opt/bin :/opt/bin", "/opt/bin :/usr/bin", "linux")).toBe( + "/opt/bin :/opt/bin:/usr/bin", + ); }); }); @@ -511,10 +514,36 @@ describe("mergePathValues", () => { expect(mergePathValues("/usr/local/bin:/usr/bin", "/usr/bin:/USR/BIN", "linux")).toBe( "/usr/local/bin:/usr/bin:/USR/BIN", ); + expect(mergePathValues("/opt/bin :/opt/bin", "/opt/bin :/usr/bin", "linux")).toBe( + "/opt/bin :/opt/bin:/usr/bin", + ); }); }); effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => { + it.effect("finds executables in a POSIX PATH directory ending in whitespace", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectoryScoped({ directory: ".", prefix: "t3-path-" }); + const directory = `${cwd}/bin \u00a0`; + yield* fs.makeDirectory(directory); + const executable = `${directory}/audit-command`; + yield* fs.writeFileString(executable, "#!/bin/sh\n"); + yield* fs.chmod(executable, 0o755); + const env = { PATH: directory }; + + expect( + yield* resolveCommandPath("audit-command", { env }).pipe( + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provideService(CommandResolutionCache, new Map()), + ), + ).toBe(path.join(directory, "audit-command")); + const resolveExecutable = yield* SpawnExecutableResolution; + expect(resolveExecutable("audit-command", "linux", env)).toBe(executable); + }), + ); + const recordProbes = (env: NodeJS.ProcessEnv) => Effect.gen(function* () { const probed: Array = []; diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 317d0b58c945e..e5833d097029b 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -106,7 +106,7 @@ function resolveSpawnExecutableWithNode( } for (const pathEntry of (readEnvPath(env) ?? "").split(pathDelimiterForPlatform(platform))) { - const normalizedPathEntry = stripWrappingQuotes(pathEntry.trim()); + const normalizedPathEntry = stripWrappingQuotes(trimPathEntry(pathEntry, platform)); if (normalizedPathEntry.length === 0) continue; for (const candidate of candidates) { const candidatePath = path.join(normalizedPathEntry, candidate); @@ -188,7 +188,7 @@ export function mergePathEntries( for (const pathValue of [preferredPath, inheritedPath]) { if (!pathValue) continue; for (const entry of pathValue.split(delimiter)) { - const trimmedEntry = entry.trim(); + const trimmedEntry = trimPathEntry(entry, platform); if (!trimmedEntry || seen.has(trimmedEntry)) { continue; } @@ -443,12 +443,16 @@ function stripWrappingQuotes(value: string): string { return value.replace(/^"+|"+$/g, ""); } +function trimPathEntry(entry: string, platform: NodeJS.Platform): string { + return platform === "win32" ? entry.trim() : entry; +} + function pathDelimiterForPlatform(platform: NodeJS.Platform): string { return platform === "win32" ? WINDOWS_PATH_DELIMITER : POSIX_PATH_DELIMITER; } function normalizePathEntryForComparison(entry: string, platform: NodeJS.Platform): string { - const normalized = stripWrappingQuotes(entry.trim()); + const normalized = stripWrappingQuotes(trimPathEntry(entry, platform)); return platform === "win32" ? normalized.toLowerCase() : normalized; } @@ -469,7 +473,7 @@ export function mergePathValues( if (!rawValue) continue; for (const entry of rawValue.split(delimiter)) { - const sanitized = sanitizePathEntry(entry.trim(), platform); + const sanitized = sanitizePathEntry(trimPathEntry(entry, platform), platform); if (sanitized.length === 0) continue; const normalized = normalizePathEntryForComparison(sanitized, platform); @@ -657,7 +661,7 @@ const findOnPath = Effect.fnUntraced(function* ( const pathEntries: string[] = []; const seenPathEntries = new Set(); for (const entry of pathValue.split(pathDelimiterForPlatform(platform))) { - const pathEntry = stripWrappingQuotes(entry.trim()); + const pathEntry = stripWrappingQuotes(trimPathEntry(entry, platform)); if (pathEntry.length === 0 || seenPathEntries.has(pathEntry)) continue; seenPathEntries.add(pathEntry);