diff --git a/packages/core/src/shell/select.ts b/packages/core/src/shell/select.ts index 6645454c0a45..7377370ff481 100644 --- a/packages/core/src/shell/select.ts +++ b/packages/core/src/shell/select.ts @@ -3,6 +3,7 @@ export * as ShellSelect from "./select.js" import path from "path" import { readFile } from "fs/promises" import { statSync } from "fs" +import { spawnSync } from "child_process" import { Context, Effect, Layer, Schema } from "effect" import { makeLocationNode } from "@opencode/util/effect/app-node" import { FSUtil } from "@opencode/util/fs-util" @@ -89,9 +90,38 @@ function executable(file: string, options?: Options, bin?: string) { if (stat(shell)?.isFile()) return shell return } - return findExecutable(shell, bin) ?? undefined + const found = findExecutable(shell, bin) + if (found) return found + // Store/MSIX installs expose their executable as a Windows app-execution alias, an + // AppExecLink reparse point that stat and which cannot see even though CreateProcess + // resolves it by name. where.exe does see it, so it separates "installed as an alias" + // from "not installed" and keeps a configured shell like pwsh from silently falling back. + // Restricted to the known shell families so an arbitrary configured name cannot resolve + // to some unrelated Store app that happens to share it. + if (process.platform === "win32" && meta(shell)) return aliased(shell) + return undefined } +const aliases = new Map() + +function line(out?: string) { + // trim() drops the trailing CR, so splitting on the LF alone is enough + return out?.split("\n")[0]?.trim() || undefined +} + +// Spawning where.exe costs ~300ms, and resolve() skips its own cache whenever a shell is +// configured, so memoize both hits and misses per name. +export function aliased(file: string) { + if (aliases.has(file)) return aliases.get(file) + const result = spawnSync("where.exe", [file], { encoding: "utf8", windowsHide: true }) + // spawnSync reports a failed launch on .error rather than throwing, so a missing or + // blocked where.exe lands here as a miss. + const path = result.status === 0 ? line(result.stdout) : undefined + aliases.set(file, path) + return path +} +aliased.reset = () => aliases.clear() + function win(options?: Options, bin?: string) { return Array.from( new Set( diff --git a/packages/core/test/shell.test.ts b/packages/core/test/shell.test.ts index 204d6e146d72..3a7b8720e5f7 100644 --- a/packages/core/test/shell.test.ts +++ b/packages/core/test/shell.test.ts @@ -4,6 +4,7 @@ import { ShellSelect } from "@opencode/core/shell/select" import { FSUtil } from "@opencode/util/fs-util" import { which } from "@opencode/core/util/which" import fs from "node:fs/promises" +import { spawnSync } from "node:child_process" import { tmpdir } from "./fixture/tmpdir" const withShell = async (shell: string | undefined, fn: () => void | Promise) => { @@ -119,5 +120,42 @@ describe("shell", () => { expect(ShellSelect.resolve({ priority: "config" })).toBe(shell) }) }) + + test("does not resolve known shells that are not installed", async () => { + // The app-execution-alias fallback may only resolve a shell where.exe can actually + // find; a known-but-absent shell must still fall back. + for (const name of ["zsh", "ksh"]) { + if (which(name)) continue + if (spawnSync("where.exe", [name], { stdio: "ignore", windowsHide: true }).status === 0) continue + expect(ShellSelect.resolve({ priority: "config" }, name)).not.toBe(name) + } + }) + + test("finds app-execution aliases that which cannot see", async () => { + // winget ships as an alias on stock Windows 11 and is not a shell, so it exercises + // the detection without depending on which shells happen to be installed. + if (which("winget")) return + const found = ShellSelect.aliased("winget") + if (!found) return + expect(path.win32.isAbsolute(found)).toBe(true) + expect(path.win32.basename(found).toLowerCase()).toBe("winget.exe") + }) + + test("returns undefined for names where.exe cannot find", async () => { + expect(ShellSelect.aliased("opencode-not-a-real-binary")).toBeUndefined() + }) + + test("memoizes alias lookups including misses", async () => { + ShellSelect.aliased.reset() + const name = "opencode-not-a-real-binary" + const cold = Date.now() + ShellSelect.aliased(name) + const coldMs = Date.now() - cold + const warm = Date.now() + ShellSelect.aliased(name) + const warmMs = Date.now() - warm + // A where.exe launch costs ~300ms here; a cached miss must not spawn again. + expect(warmMs).toBeLessThan(Math.max(coldMs, 10)) + }) } })