diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index b7092d70f0e2..add3e11587f1 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -5,7 +5,6 @@ import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; -import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; @@ -16,7 +15,7 @@ import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import * as ExternalLauncher from "./externalLauncher.ts"; @@ -89,7 +88,7 @@ const testLayer = (input: { SpawnExecutableResolution, (command) => input.resolveExecutable?.(command) ?? command, ), - ConfigProvider.layer(ConfigProvider.fromEnv({ env: input.env ?? {} })), + Layer.succeed(HostProcessEnvironment, input.env ?? {}), ); }; @@ -716,6 +715,31 @@ it.effect.skipIf(windowsHost)("reveals by opening the containing directory on Li }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); +// A Dock/Finder launch starts with the bare launchd PATH and `fixPath` hydrates +// process.env in place afterwards; discovery has to see the hydrated value. +it.effect.skipIf(windowsHost)("discovers editors on a PATH hydrated after startup", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + const cursorPath = path.join(binDir, "cursor"); + yield* fileSystem.writeFileString(cursorPath, "#!/bin/sh\n"); + yield* fileSystem.chmod(cursorPath, 0o755); + + const emptyDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-empty-" }); + const env: Record = { PATH: emptyDir }; + const editors = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + env.PATH = `${binDir}:${emptyDir}`; + return yield* launcher.resolveAvailableEditors(); + }).pipe(Effect.provide(testLayer({ platform: "linux", env }))); + + // Exact match: a lookup that fell back to the ambient process PATH would + // also report whatever editors the host machine has installed. + assert.deepEqual(editors, ["cursor"]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + it.effect.skipIf(windowsHost)( "does not advertise a Linux file manager without a graphical session", () => @@ -968,14 +992,10 @@ it.effect("memoizes editor discovery and refreshes after the cache window", () = Layer.mergeAll( launcherLayer, Layer.succeed(HostProcessPlatform, "win32"), - ConfigProvider.layer( - ConfigProvider.fromEnv({ - env: { - PATH: "C:\\t3-editor-discovery-cache-test", - PATHEXT: ".COM;.EXE;.BAT;.CMD", - }, - }), - ), + Layer.succeed(HostProcessEnvironment, { + PATH: "C:\\t3-editor-discovery-cache-test", + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }), TestClock.layer(), ), ), @@ -1033,14 +1053,10 @@ it.effect("rescans after an interrupted discovery instead of caching the interru Layer.mergeAll( launcherLayer, Layer.succeed(HostProcessPlatform, "win32"), - ConfigProvider.layer( - ConfigProvider.fromEnv({ - env: { - PATH: "C:\\t3-editor-discovery-interrupt-test", - PATHEXT: ".COM;.EXE;.BAT;.CMD", - }, - }), - ), + Layer.succeed(HostProcessEnvironment, { + PATH: "C:\\t3-editor-discovery-interrupt-test", + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }), ), ), ); diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 56d729b52fbb..2e43a9a53b32 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -18,10 +18,9 @@ import { type FileManagerRevealKind, type LaunchEditorInput, } from "@t3tools/contracts"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell"; import * as Clock from "effect/Clock"; -import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; @@ -82,37 +81,34 @@ const DETACHED_IGNORE_STDIO_OPTIONS = { stderr: "ignore", } as const satisfies ChildProcess.CommandOptions; -const compactEnv = (input: Record>): NodeJS.ProcessEnv => - Object.fromEntries( - Object.entries(input).flatMap(([key, value]) => - Option.match(value, { - onNone: () => [], - onSome: (resolved) => [[key, resolved]], +const BROWSER_LAUNCH_ENV_NAMES = [ + "SYSTEMROOT", + "windir", + "WSL_DISTRO_NAME", + "WSL_INTEROP", + "SSH_CONNECTION", + "SSH_TTY", + "container", + "DISPLAY", + "WAYLAND_DISPLAY", +] as const; + +const COMMAND_LOOKUP_ENV_NAMES = ["PATH", "Path", "path", "PATHEXT"] as const; + +// Not Config: the default ConfigProvider snapshots process.env on first use, +// before `fixPath` hydrates PATH, so it only ever sees the bare launchd PATH. +const readHostEnv = (names: ReadonlyArray) => + Effect.map(HostProcessEnvironment, (env): NodeJS.ProcessEnv => + Object.fromEntries( + names.flatMap((name) => { + const value = env[name]; + return value === undefined || value.length === 0 ? [] : [[name, value]]; }), ), ); -const BrowserLaunchEnvConfig = Config.all({ - SYSTEMROOT: Config.String("SYSTEMROOT").pipe(Config.option), - windir: Config.String("windir").pipe(Config.option), - WSL_DISTRO_NAME: Config.String("WSL_DISTRO_NAME").pipe(Config.option), - WSL_INTEROP: Config.String("WSL_INTEROP").pipe(Config.option), - SSH_CONNECTION: Config.String("SSH_CONNECTION").pipe(Config.option), - SSH_TTY: Config.String("SSH_TTY").pipe(Config.option), - container: Config.String("container").pipe(Config.option), - DISPLAY: Config.String("DISPLAY").pipe(Config.option), - WAYLAND_DISPLAY: Config.String("WAYLAND_DISPLAY").pipe(Config.option), -}).pipe(Config.map(compactEnv)); - -const CommandLookupEnvConfig = Config.all({ - PATH: Config.String("PATH").pipe(Config.option), - Path: Config.String("Path").pipe(Config.option), - path: Config.String("path").pipe(Config.option), - PATHEXT: Config.String("PATHEXT").pipe(Config.option), -}).pipe(Config.map(compactEnv)); - -const readBrowserLaunchEnv = BrowserLaunchEnvConfig.pipe(Effect.orElseSucceed(() => ({}))); -const readCommandLookupEnv = CommandLookupEnvConfig.pipe(Effect.orElseSucceed(() => ({}))); +const readBrowserLaunchEnv = readHostEnv(BROWSER_LAUNCH_ENV_NAMES); +const readCommandLookupEnv = readHostEnv(COMMAND_LOOKUP_ENV_NAMES); function parseTargetPathAndPosition(target: string): Option.Option { const match = TARGET_WITH_POSITION_PATTERN.exec(target);