Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion packages/core/src/shell/select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<string, string | undefined>()

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(
Expand Down
38 changes: 38 additions & 0 deletions packages/core/test/shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>) => {
Expand Down Expand Up @@ -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))
})
}
})
Loading