| import { realpath, stat } from 'fs/promises' | |
| import { getPlatform } from '../platform.js' | |
| import { which } from '../which.js' | |
| async function probePath(p: string): Promise<string | null> { | |
| try { | |
| return (await stat(p)).isFile() ? p : null | |
| } catch { | |
| return null | |
| } | |
| } | |
| /** | |
| * Attempts to find PowerShell on the system via PATH. | |
| * Prefers pwsh (PowerShell Core 7+), falls back to powershell (5.1). | |
| * | |
| * On Linux, if PATH resolves to a snap launcher (/snap/β¦) β directly or | |
| * via a symlink chain like /usr/bin/pwsh β /snap/bin/pwsh β probe known | |
| * apt/rpm install locations instead: the snap launcher can hang in | |
| * subprocesses while snapd initializes confinement, but the underlying | |
| * binary at /opt/microsoft/powershell/7/pwsh is reliable. On | |
| * Windows/macOS, PATH is sufficient. | |
| */ | |
| export async function findPowerShell(): Promise<string | null> { | |
| const pwshPath = await which('pwsh') | |
| if (pwshPath) { | |
| // Snap launcher hangs in subprocesses. Prefer the direct binary. | |
| // Check both the resolved PATH entry and its symlink target: on | |
| // some distros /usr/bin/pwsh is a symlink to /snap/bin/pwsh, which | |
| // would bypass a naive startsWith('/snap/') on the which() result. | |
| if (getPlatform() === 'linux') { | |
| const resolved = await realpath(pwshPath).catch(() => pwshPath) | |
| if (pwshPath.startsWith('/snap/') || resolved.startsWith('/snap/')) { | |
| const direct = | |
| (await probePath('/opt/microsoft/powershell/7/pwsh')) ?? | |
| (await probePath('/usr/bin/pwsh')) | |
| if (direct) { | |
| const directResolved = await realpath(direct).catch(() => direct) | |
| if ( | |
| !direct.startsWith('/snap/') && | |
| !directResolved.startsWith('/snap/') | |
| ) { | |
| return direct | |
| } | |
| } | |
| } | |
| } | |
| return pwshPath | |
| } | |
| const powershellPath = await which('powershell') | |
| if (powershellPath) { | |
| return powershellPath | |
| } | |
| return null | |
| } | |
| let cachedPowerShellPath: Promise<string | null> | null = null | |
| /** | |
| * Gets the cached PowerShell path. Returns a memoized promise that | |
| * resolves to the PowerShell executable path or null. | |
| */ | |
| export function getCachedPowerShellPath(): Promise<string | null> { | |
| if (!cachedPowerShellPath) { | |
| cachedPowerShellPath = findPowerShell() | |
| } | |
| return cachedPowerShellPath | |
| } | |
| export type PowerShellEdition = 'core' | 'desktop' | |
| /** | |
| * Infers the PowerShell edition from the binary name without spawning. | |
| * - `pwsh` / `pwsh.exe` β 'core' (PowerShell 7+: supports `&&`, `||`, `?:`, `??`) | |
| * - `powershell` / `powershell.exe` β 'desktop' (Windows PowerShell 5.1: | |
| * no pipeline chain operators, stderr-sets-$? bug, UTF-16 default encoding) | |
| * | |
| * PowerShell 6 (also `pwsh`, no `&&`) has been EOL since 2020 and is not | |
| * a realistic install target, so 'core' safely implies 7+ semantics. | |
| * | |
| * Used by the tool prompt to give version-appropriate syntax guidance so | |
| * the model doesn't emit `cmd1 && cmd2` on 5.1 (parser error) or avoid | |
| * `&&` on 7+ where it's the correct short-circuiting operator. | |
| */ | |
| export async function getPowerShellEdition(): Promise<PowerShellEdition | null> { | |
| const p = await getCachedPowerShellPath() | |
| if (!p) return null | |
| // basename without extension, case-insensitive. Covers: | |
| // C:\Program Files\PowerShell\7\pwsh.exe | |
| // /opt/microsoft/powershell/7/pwsh | |
| // C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe | |
| const base = p | |
| .split(/[/\\]/) | |
| .pop()! | |
| .toLowerCase() | |
| .replace(/\.exe$/, '') | |
| return base === 'pwsh' ? 'core' : 'desktop' | |
| } | |
| /** | |
| * Resets the cached PowerShell path. Only for testing. | |
| */ | |
| export function resetPowerShellCache(): void { | |
| cachedPowerShellPath = null | |
| } | |