diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 2ae1684b67d4..44312a2ddb0c 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -159,6 +159,71 @@ it.effect("launches an installed editor with platform-safe arguments", () => }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); +it.effect("launches stable Zed when a nightly install comes first on PATH", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-zed-" }); + const nightlyDir = path.join(root, "Zed Nightly", "bin"); + const stableDir = path.join(root, "Zed", "bin"); + yield* fileSystem.makeDirectory(nightlyDir, { recursive: true }); + yield* fileSystem.makeDirectory(stableDir, { recursive: true }); + yield* fileSystem.writeFileString(path.join(nightlyDir, "zed.EXE"), ""); + yield* fileSystem.writeFileString(path.join(stableDir, "zed.EXE"), ""); + const env = { PATH: `${nightlyDir};${stableDir}`, PATHEXT: ".COM;.EXE;.BAT;.CMD" }; + + const launchZed = Effect.gen(function* () { + let spawned: ChildProcess.StandardCommand | undefined; + yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + yield* launcher.launchEditor({ editor: "zed", cwd: "C:\\workspace" }); + }).pipe( + Effect.provide( + testLayer({ + platform: "win32", + env, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); + assert.ok(spawned); + return spawned; + }); + + const preferred = yield* launchZed; + assert.equal(preferred.command, path.join(stableDir, "zed.EXE")); + assert.deepEqual(preferred.args, ["C:\\workspace"]); + assert.equal(preferred.options.shell, false); + + // With only a nightly install left, PATH order is already right and the + // bare command is kept. + yield* fileSystem.remove(path.join(stableDir, "zed.EXE")); + const fallback = yield* launchZed; + assert.equal(fallback.command, "zed"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it("recognizes prerelease Zed installs by their own folder name only", () => { + for (const prerelease of [ + "C:\\Users\\me\\AppData\\Local\\Programs\\Zed Preview\\bin\\zed.exe", + "C:\\Users\\me\\AppData\\Local\\Programs\\Zed Nightly\\bin\\zed.exe", + "/Applications/Zed Nightly.app/Contents/MacOS/cli", + "/home/me/.local/zed-preview.app/bin/zed", + ]) { + assert.equal(ExternalLauncher.isPrereleaseZedPath(prerelease), true, prerelease); + } + for (const stable of [ + "C:\\Users\\preview-user\\AppData\\Local\\Programs\\Zed\\bin\\zed.exe", + "C:\\nightly-builds\\Zed\\bin\\zed.exe", + "/Applications/Zed.app/Contents/MacOS/cli", + "/home/me/.local/zed.app/bin/zed", + ]) { + assert.equal(ExternalLauncher.isPrereleaseZedPath(stable), false, stable); + } +}); + it.effect.skipIf(windowsHost)("reveals a file in Finder with open -R on macOS", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index fbde96ff58b1..a85296a34ae4 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -19,7 +19,11 @@ import { type LaunchEditorInput, } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell"; +import { + isCommandAvailable, + resolveCommandPaths, + resolveSpawnCommand, +} from "@t3tools/shared/shell"; import * as Clock from "effect/Clock"; import * as Config from "effect/Config"; import * as Context from "effect/Context"; @@ -175,6 +179,40 @@ const resolveAvailableCommand = Effect.fn("externalLauncher.resolveAvailableComm return Option.none(); }); +// Zed's stable, preview, and nightly channels each install a `zed` CLI, so +// PATH order alone can open a prerelease build when the user picked "Zed". +// Prefer the first install whose location does not name a prerelease channel +// and, when that is not the first on PATH, launch it by absolute path. +// A prerelease install is recognized by a single path component that names +// both Zed and the channel (`Zed Preview`, `Zed Nightly.app`, +// `zed-nightly.app`), so a user or folder called "preview" elsewhere in the +// path does not count. +export function isPrereleaseZedPath(filePath: string): boolean { + return filePath.split(/[\\/]/).some((component) => { + const normalized = component.toLowerCase(); + return ( + normalized.includes("zed") && + (normalized.includes("nightly") || normalized.includes("preview")) + ); + }); +} + +const resolveZedCommand = Effect.fn("externalLauncher.resolveZedCommand")(function* ( + commands: ReadonlyArray, + env: NodeJS.ProcessEnv, +): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { + const installs: Array<{ readonly command: string; readonly path: string }> = []; + for (const command of commands) { + for (const resolvedPath of yield* resolveCommandPaths(command, { env })) { + installs.push({ command, path: resolvedPath }); + } + } + const first = installs[0]; + if (!first) return Option.none(); + const preferred = installs.find((install) => !isPrereleaseZedPath(install.path)) ?? first; + return Option.some(preferred === first ? first.command : preferred.path); +}); + function encodeUtf16LeBase64(input: string): string { const bytes = new Uint8Array(input.length * 2); for (let index = 0; index < input.length; index += 1) { @@ -540,7 +578,9 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( if (editorDef.commands) { const command = Option.getOrElse( - yield* resolveAvailableCommand(editorDef.commands, env), + editorDef.id === "zed" + ? yield* resolveZedCommand(editorDef.commands, env) + : yield* resolveAvailableCommand(editorDef.commands, env), () => editorDef.commands[0], ); return { diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index 621fe49b3087..44be47afd8d1 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -20,6 +20,7 @@ import { readPathFromLaunchctl, readPathFromLoginShell, resolveCommandPath, + resolveCommandPaths, resolveKnownWindowsCliDirs, resolveSpawnCommand, resolveWindowsEnvironment, @@ -346,6 +347,43 @@ effectIt.layer(NodeServices.layer)("isCommandAvailable", (it) => { ); }); +effectIt.layer(NodeServices.layer)("resolveCommandPaths", (it) => { + it.effect("lists every PATH match in order and yields nothing for an empty PATH", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-command-paths-" }); + const firstDir = path.join(root, "first"); + const secondDir = path.join(root, "second"); + const emptyDir = path.join(root, "empty"); + yield* fileSystem.makeDirectory(firstDir, { recursive: true }); + yield* fileSystem.makeDirectory(secondDir, { recursive: true }); + yield* fileSystem.makeDirectory(emptyDir, { recursive: true }); + yield* fileSystem.writeFileString(path.join(firstDir, "zed.CMD"), "@echo off\r\n"); + yield* fileSystem.writeFileString(path.join(secondDir, "zed.EXE"), ""); + const env = { + PATH: [firstDir, emptyDir, secondDir].join(";"), + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }; + + const resolved = yield* resolveCommandPaths("zed", { env }).pipe( + Effect.provideService(HostProcessPlatform, "win32"), + ); + expect(resolved).toEqual([path.join(firstDir, "zed.CMD"), path.join(secondDir, "zed.EXE")]); + + const explicit = yield* resolveCommandPaths(path.join(secondDir, "zed.EXE"), { env }).pipe( + Effect.provideService(HostProcessPlatform, "win32"), + ); + expect(explicit).toEqual([path.join(secondDir, "zed.EXE")]); + + const none = yield* resolveCommandPaths("zed", { env: { PATH: "" } }).pipe( + Effect.provideService(HostProcessPlatform, "win32"), + ); + expect(none).toEqual([]); + }).pipe(Effect.scoped), + ); +}); + effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => { it.effect("fails when PATH is empty", () => Effect.gen(function* () { diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 07ac73f8c6a7..6d2a8d9dc3b3 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -626,6 +626,54 @@ export const resolveCommandPath = Effect.fn("shell.resolveCommandPath")(function }); }); +/** + * Every executable on PATH that `command` could resolve to, in PATH order. + * Callers that must choose between same-named installs (for example stable + * and nightly Zed both shipping a `zed` CLI) use this instead of the + * first-match `resolveCommandPath`. Explicit paths yield at most one entry. + * Never cached and never fails: an empty PATH yields an empty list. + */ +export const resolveCommandPaths = Effect.fn("shell.resolveCommandPaths")(function* ( + command: string, + options: CommandAvailabilityOptions = {}, +): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { + const path = yield* Path.Path; + const platform = yield* HostProcessPlatform; + const env = options.env ?? (yield* HostProcessEnvironment); + const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : []; + const commandCandidates = resolveCommandCandidates( + command, + platform, + windowsPathExtensions, + path.extname, + ); + + if (command.includes("/") || command.includes("\\")) { + for (const candidate of commandCandidates) { + if (yield* isExecutableFile(candidate, platform, windowsPathExtensions)) { + return [candidate]; + } + } + return []; + } + + const resolvedPaths: string[] = []; + for (const entry of resolvePathEnvironmentVariable(env).split( + pathDelimiterForPlatform(platform), + )) { + const pathEntry = stripWrappingQuotes(entry.trim()); + if (pathEntry.length === 0) continue; + for (const candidate of commandCandidates) { + const candidatePath = path.join(pathEntry, candidate); + if (yield* isExecutableFile(candidatePath, platform, windowsPathExtensions)) { + resolvedPaths.push(candidatePath); + break; + } + } + } + return resolvedPaths; +}); + export const resolveSpawnCommand = Effect.fn("shell.resolveSpawnCommand")(function* ( command: string, args: ReadonlyArray,