/ utils / shell / powershellDetection.ts
powershellDetection.ts
  1  import { realpath, stat } from 'fs/promises'
  2  import { getPlatform } from '../platform.js'
  3  import { which } from '../which.js'
  4  
  5  async function probePath(p: string): Promise<string | null> {
  6    try {
  7      return (await stat(p)).isFile() ? p : null
  8    } catch {
  9      return null
 10    }
 11  }
 12  
 13  /**
 14   * Attempts to find PowerShell on the system via PATH.
 15   * Prefers pwsh (PowerShell Core 7+), falls back to powershell (5.1).
 16   *
 17   * On Linux, if PATH resolves to a snap launcher (/snap/…) — directly or
 18   * via a symlink chain like /usr/bin/pwsh → /snap/bin/pwsh — probe known
 19   * apt/rpm install locations instead: the snap launcher can hang in
 20   * subprocesses while snapd initializes confinement, but the underlying
 21   * binary at /opt/microsoft/powershell/7/pwsh is reliable. On
 22   * Windows/macOS, PATH is sufficient.
 23   */
 24  export async function findPowerShell(): Promise<string | null> {
 25    const pwshPath = await which('pwsh')
 26    if (pwshPath) {
 27      // Snap launcher hangs in subprocesses. Prefer the direct binary.
 28      // Check both the resolved PATH entry and its symlink target: on
 29      // some distros /usr/bin/pwsh is a symlink to /snap/bin/pwsh, which
 30      // would bypass a naive startsWith('/snap/') on the which() result.
 31      if (getPlatform() === 'linux') {
 32        const resolved = await realpath(pwshPath).catch(() => pwshPath)
 33        if (pwshPath.startsWith('/snap/') || resolved.startsWith('/snap/')) {
 34          const direct =
 35            (await probePath('/opt/microsoft/powershell/7/pwsh')) ??
 36            (await probePath('/usr/bin/pwsh'))
 37          if (direct) {
 38            const directResolved = await realpath(direct).catch(() => direct)
 39            if (
 40              !direct.startsWith('/snap/') &&
 41              !directResolved.startsWith('/snap/')
 42            ) {
 43              return direct
 44            }
 45          }
 46        }
 47      }
 48      return pwshPath
 49    }
 50  
 51    const powershellPath = await which('powershell')
 52    if (powershellPath) {
 53      return powershellPath
 54    }
 55  
 56    return null
 57  }
 58  
 59  let cachedPowerShellPath: Promise<string | null> | null = null
 60  
 61  /**
 62   * Gets the cached PowerShell path. Returns a memoized promise that
 63   * resolves to the PowerShell executable path or null.
 64   */
 65  export function getCachedPowerShellPath(): Promise<string | null> {
 66    if (!cachedPowerShellPath) {
 67      cachedPowerShellPath = findPowerShell()
 68    }
 69    return cachedPowerShellPath
 70  }
 71  
 72  export type PowerShellEdition = 'core' | 'desktop'
 73  
 74  /**
 75   * Infers the PowerShell edition from the binary name without spawning.
 76   * - `pwsh` / `pwsh.exe` → 'core' (PowerShell 7+: supports `&&`, `||`, `?:`, `??`)
 77   * - `powershell` / `powershell.exe` → 'desktop' (Windows PowerShell 5.1:
 78   *   no pipeline chain operators, stderr-sets-$? bug, UTF-16 default encoding)
 79   *
 80   * PowerShell 6 (also `pwsh`, no `&&`) has been EOL since 2020 and is not
 81   * a realistic install target, so 'core' safely implies 7+ semantics.
 82   *
 83   * Used by the tool prompt to give version-appropriate syntax guidance so
 84   * the model doesn't emit `cmd1 && cmd2` on 5.1 (parser error) or avoid
 85   * `&&` on 7+ where it's the correct short-circuiting operator.
 86   */
 87  export async function getPowerShellEdition(): Promise<PowerShellEdition | null> {
 88    const p = await getCachedPowerShellPath()
 89    if (!p) return null
 90    // basename without extension, case-insensitive. Covers:
 91    //   C:\Program Files\PowerShell\7\pwsh.exe
 92    //   /opt/microsoft/powershell/7/pwsh
 93    //   C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe
 94    const base = p
 95      .split(/[/\\]/)
 96      .pop()!
 97      .toLowerCase()
 98      .replace(/\.exe$/, '')
 99    return base === 'pwsh' ? 'core' : 'desktop'
100  }
101  
102  /**
103   * Resets the cached PowerShell path. Only for testing.
104   */
105  export function resetPowerShellCache(): void {
106    cachedPowerShellPath = null
107  }