diff --git a/packages/core/src/shell.ts b/packages/core/src/shell.ts index 29089106d904..33834fe6f616 100644 --- a/packages/core/src/shell.ts +++ b/packages/core/src/shell.ts @@ -1,7 +1,7 @@ export * as Shell from "./shell" import path from "path" -import { spawn, type ChildProcess } from "child_process" +import { spawn, spawnSync, type ChildProcess } from "child_process" import { readFile } from "fs/promises" import { statSync } from "fs" import { setTimeout as sleep } from "node:timers/promises" @@ -92,7 +92,18 @@ function resolve(file: string) { if (stat(shell)?.isFile()) return shell return } - return which(shell) ?? undefined + const found = which(shell) + 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. + if (process.platform === "win32" && meta(shell) && aliased(shell)) return shell + return undefined +} + +function aliased(file: string) { + return spawnSync("where.exe", [file], { stdio: "ignore", windowsHide: true }).status === 0 } function win() { diff --git a/packages/core/test/shell.test.ts b/packages/core/test/shell.test.ts index 1cc47a79f6cd..4c8ea628e1de 100644 --- a/packages/core/test/shell.test.ts +++ b/packages/core/test/shell.test.ts @@ -49,6 +49,15 @@ describe("shell", () => { }) }) + test("falls back for known shell families that are not installed", async () => { + if (process.platform !== "win32") return + await withShell(undefined, () => { + // ksh is in the shell metadata table but is not present on Windows. Resolution must not + // trust a bare name just because the family is known, only when the OS can actually find it. + expect(Shell.acceptable("ksh")).toBe(Shell.acceptable()) + }) + }) + test("falls back for terminal-only acceptable shells", () => { expect(Shell.name(Shell.acceptable("fish"))).not.toBe("fish") expect(Shell.name(Shell.acceptable("nu"))).not.toBe("nu")