From 7c6ad1ad1ff37706c44e0b0a5a1f58821102be18 Mon Sep 17 00:00:00 2001 From: Ezra Developer Date: Fri, 4 Sep 2026 01:59:31 +0300 Subject: [PATCH 1/2] fix(server): open stable Zed when a prerelease Zed is first on PATH Zed's stable, preview, and nightly channels each install their own `zed` CLI. The editor launcher took the first `zed` on PATH, so a machine with a prerelease channel earlier on PATH opened Zed Nightly or Zed Preview when the user chose "Zed". Add `resolveCommandPaths` to the shared shell module, which lists every PATH match for a command in order. The launcher uses it for Zed only: 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. Single installs and correctly ordered PATHs keep the bare command, so nothing changes for them. No environment or PATH rewriting. Fixes #1978. Model: Claude Fable 5.1. Harness: Claude Code. --- .../src/process/externalLauncher.test.ts | 46 ++++++++++++++++++ apps/server/src/process/externalLauncher.ts | 35 +++++++++++++- packages/shared/src/shell.test.ts | 38 +++++++++++++++ packages/shared/src/shell.ts | 48 +++++++++++++++++++ 4 files changed, 165 insertions(+), 2 deletions(-) diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 2ae1684b67d4..07b24e324e69 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -159,6 +159,52 @@ 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.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..2c418c60f99e 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,31 @@ 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. +function isPrereleaseZedPath(filePath: string): boolean { + const normalized = filePath.toLowerCase(); + return 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 +569,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, From 0c4d469f210f58b253bd8183e2b09ba7f715c9e9 Mon Sep 17 00:00:00 2001 From: Ezra Developer Date: Fri, 4 Sep 2026 02:18:30 +0300 Subject: [PATCH 2/2] fix(server): match prerelease Zed by install folder, not whole path The prerelease check searched the full path for "nightly" or "preview", so a stable Zed under a home directory like `C:\Users\preview-user` was treated as prerelease. Check each path component on its own and require it to name both Zed and the channel, which matches `Zed Preview`, `Zed Nightly.app`, and `zed-nightly.app` but not unrelated folders. Model: Claude Fable 5.1. Harness: Claude Code. --- .../src/process/externalLauncher.test.ts | 19 +++++++++++++++++++ apps/server/src/process/externalLauncher.ts | 15 ++++++++++++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 07b24e324e69..44312a2ddb0c 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -205,6 +205,25 @@ it.effect("launches stable Zed when a nightly install comes first on PATH", () = }).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 2c418c60f99e..a85296a34ae4 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -183,9 +183,18 @@ const resolveAvailableCommand = Effect.fn("externalLauncher.resolveAvailableComm // 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. -function isPrereleaseZedPath(filePath: string): boolean { - const normalized = filePath.toLowerCase(); - return normalized.includes("nightly") || normalized.includes("preview"); +// 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* (