/** * Shell Discovery — cross-platform shell detection * * Detects available shells on Windows (CMD, PowerShell, WSL, Git Bash, Cygwin) * and Unix/macOS (via /etc/shells). Registry access on Windows uses `reg.exe` * via child_process — no native npm dependency. */ const fs = require("node:fs"); const path = require("node:path"); const { execFileSync } = require("node:child_process"); const { isWindowsAppExecutionAliasPath } = require("../../lib/localShell.cjs"); const EXEC_OPTS = { encoding: "utf8", timeout: 5000, windowsHide: true }; /** Module-level cache for later use by the unified discoverShells() (Task 3). */ let cachedShells = null; // --------------------------------------------------------------------------- // Helper utilities // --------------------------------------------------------------------------- /** * Query a specific value from a Windows registry key. * Returns the value string, or `null` on failure. * * @param {string} keyPath e.g. "HKLM\\SOFTWARE\\GitForWindows" * @param {string} valueName e.g. "InstallPath" * @returns {string|null} */ function regQueryValue(keyPath, valueName) { try { // /ve queries the default (unnamed) value; /v queries a named value. const args = valueName === "" || valueName == null ? ["query", keyPath, "/ve"] : ["query", keyPath, "/v", valueName]; const output = execFileSync("reg", args, EXEC_OPTS); // Output format: // HKEY_LOCAL_MACHINE\SOFTWARE\GitForWindows // InstallPath REG_SZ C:\Program Files\Git const lines = output.split(/\r?\n/).filter(Boolean); for (const line of lines) { const match = line.match(/^\s+.+?\s+REG_\w+\s+(.+)$/); if (match) { return match[1].trim(); } } } catch (_err) { // Key or value not found — expected on many systems. } return null; } /** * Enumerate immediate subkey names under a registry key. * Returns an array of full subkey paths, or an empty array on failure. * * @param {string} keyPath e.g. "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss" * @returns {string[]} */ function regEnumSubkeys(keyPath) { try { const output = execFileSync( "reg", ["query", keyPath], EXEC_OPTS, ); // `reg query ` prints the key itself, then each subkey on its own line // prefixed with the full path. Values appear with leading whitespace. const lines = output.split(/\r?\n/).filter(Boolean); const subkeys = []; const normalizedParent = keyPath.toLowerCase(); for (const line of lines) { const trimmed = line.trim(); // Subkeys start with "HK" and are longer than the parent key. if ( trimmed.toLowerCase().startsWith("hk") && trimmed.toLowerCase() !== normalizedParent && trimmed.toLowerCase().startsWith(normalizedParent + "\\") ) { subkeys.push(trimmed); } } return subkeys; } catch (_err) { // Key not found or access denied. } return []; } /** * Locate an executable on the system PATH using `where.exe`. * Returns the first valid path, or `null` if not found. * * Windows App Execution Aliases (MSIX/Store installs, e.g. winget PowerShell 7) * are kept as a last-resort candidate: they are reparse points that fail * `fs.existsSync()`, but spawning the alias path launches the packaged app * just fine. A regular executable always wins when one is on the PATH. * * @param {string} name Executable name, e.g. "pwsh" * @returns {string|null} */ function findExecutableOnPath(name) { try { const result = execFileSync("where.exe", [name], EXEC_OPTS); const candidates = result .split(/\r?\n/) .map((l) => l.trim()) .filter(Boolean); let aliasCandidate = null; for (const candidate of candidates) { if (isWindowsAppExecutionAliasPath(candidate)) { aliasCandidate ??= candidate; continue; } if (!fs.existsSync(candidate)) continue; return candidate; } if (aliasCandidate) return aliasCandidate; } catch (_err) { // Not found on PATH. } return null; } /** * Map a WSL distro name to an icon identifier for SVG lookup. * * @param {string} distroName e.g. "Ubuntu-22.04", "Debian", "kali-linux" * @returns {string} */ function mapWslDistroIcon(distroName) { const lower = (distroName || "").toLowerCase(); if (lower.includes("ubuntu")) return "ubuntu"; if (lower.includes("debian")) return "debian"; if (lower.includes("kali")) return "kali"; if (lower.includes("alpine")) return "alpine"; if (lower.includes("opensuse") || lower.includes("suse")) return "opensuse"; if (lower.includes("fedora")) return "fedora"; if (lower.includes("arch")) return "arch"; if (lower.includes("oracle")) return "oracle"; return "linux"; } // --------------------------------------------------------------------------- // Individual shell detectors // --------------------------------------------------------------------------- /** * Detect CMD. * @returns {object|null} DiscoveredShell or null */ function detectCmd() { try { const comSpec = process.env.ComSpec; const cmdPath = comSpec || "cmd.exe"; // Verify the path actually exists when ComSpec provides a full path. if (comSpec && !fs.existsSync(comSpec)) { // Fallback to bare name — Windows will resolve it. return { id: "cmd", name: "CMD", command: "cmd.exe", args: [], icon: "cmd", }; } return { id: "cmd", name: "CMD", command: cmdPath, args: [], icon: "cmd", }; } catch (_err) { // Should never fail, but guard anyway. } return null; } /** * Detect Windows PowerShell 5.1. * @returns {object|null} */ function detectPowerShell() { try { // Try where.exe first. const found = findExecutableOnPath("powershell"); if (found) { return { id: "powershell", name: "Windows PowerShell", command: found, args: ["-NoLogo"], icon: "powershell", }; } // Fallback: well-known path. const systemRoot = process.env.SystemRoot || "C:\\Windows"; const fallback = path.join( systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe", ); if (fs.existsSync(fallback)) { return { id: "powershell", name: "Windows PowerShell", command: fallback, args: ["-NoLogo"], icon: "powershell", }; } } catch (_err) { // Detection failed — not critical. } return null; } /** * Detect PowerShell Core (pwsh 7+). * @returns {object|null} */ function detectPwsh() { try { // 1. where.exe const found = findExecutableOnPath("pwsh"); if (found) { return { id: "pwsh", name: "PowerShell 7", command: found, args: ["-NoLogo"], icon: "pwsh", }; } // 2. Registry App Paths const regPath = regQueryValue( "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\pwsh.exe", "", ); if (regPath && fs.existsSync(regPath)) { return { id: "pwsh", name: "PowerShell 7", command: regPath, args: ["-NoLogo"], icon: "pwsh", }; } // 3. Common fallback path. const fallback = path.join( process.env.ProgramFiles || "C:\\Program Files", "PowerShell", "7", "pwsh.exe", ); if (fs.existsSync(fallback) && fallback.toLowerCase().includes("powershell")) { return { id: "pwsh", name: "PowerShell 7", command: fallback, args: ["-NoLogo"], icon: "pwsh", }; } } catch (_err) { // Detection failed. } return null; } /** * Detect installed WSL distributions via the registry. * @returns {object[]} Array of DiscoveredShell objects (may be empty). */ function detectWslDistros() { const systemRoot = process.env.SystemRoot || "C:\\Windows"; const wslExe = path.join(systemRoot, "System32", "wsl.exe"); if ( !fs.existsSync(wslExe) || !path.dirname(wslExe).toLowerCase().endsWith("system32") ) { return []; } const distros = []; // Primary: use `wsl.exe -l -q` which lists installed distros one per line. // More reliable than registry parsing across Windows versions. // Note: wsl.exe outputs UTF-16LE on some builds, so we read as buffer and decode. try { const buf = execFileSync(wslExe, ["-l", "-q"], { timeout: 5000, windowsHide: true, maxBuffer: 1024 * 64, }); // wsl.exe outputs UTF-16LE on most Windows builds (has NUL bytes between chars). // Detect by checking for NUL bytes in the raw buffer; if present → UTF-16LE, else UTF-8. const isUtf16 = buf.length >= 2 && buf.includes(0x00); const output = buf.toString(isUtf16 ? "utf16le" : "utf8"); const names = output .split(/\r?\n/) .map((l) => l.replace(/\0/g, "").trim()) .filter(Boolean); for (const distroName of names) { distros.push({ id: `wsl-${distroName.toLowerCase().replace(/[^a-z0-9-]/g, "-")}`, name: `${distroName} (WSL)`, command: wslExe, args: ["-d", distroName], icon: mapWslDistroIcon(distroName), }); } if (distros.length > 0) return distros; } catch (_err) { // wsl.exe -l -q failed, fall through to registry method. } // Fallback: enumerate registry subkeys under Lxss try { const lxssKey = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss"; const subkeys = regEnumSubkeys(lxssKey); for (const subkey of subkeys) { try { const distroName = regQueryValue(subkey, "DistributionName"); if (!distroName) continue; distros.push({ id: `wsl-${distroName.toLowerCase().replace(/[^a-z0-9-]/g, "-")}`, name: `${distroName} (WSL)`, command: wslExe, args: ["-d", distroName], icon: mapWslDistroIcon(distroName), }); } catch (_err) { // Skip this distro but continue with others. } } } catch (_err) { // WSL not installed or registry not accessible. } return distros; } /** * Detect Git Bash (from Git for Windows). * @returns {object|null} */ function detectGitBash() { try { // Try registry first. const installPath = regQueryValue( "HKLM\\SOFTWARE\\GitForWindows", "InstallPath", ); if (installPath) { const bashExe = path.join(installPath, "bin", "bash.exe"); if (fs.existsSync(bashExe)) { return { id: "git-bash", name: "Git Bash", command: bashExe, args: ["--login", "-i"], icon: "git-bash", }; } } // Fallback: common installation path. const fallbackPaths = [ path.join( process.env.ProgramFiles || "C:\\Program Files", "Git", "bin", "bash.exe", ), path.join( process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)", "Git", "bin", "bash.exe", ), ]; for (const p of fallbackPaths) { if (fs.existsSync(p)) { return { id: "git-bash", name: "Git Bash", command: p, args: ["--login", "-i"], icon: "git-bash", }; } } } catch (_err) { // Git Bash not installed. } return null; } /** * Detect Cygwin bash. * @returns {object|null} */ function detectCygwin() { try { // Try 64-bit registry key first, then 32-bit (WOW6432Node). const rootDir = regQueryValue("HKLM\\SOFTWARE\\Cygwin\\setup", "rootdir") || regQueryValue("HKLM\\SOFTWARE\\WOW6432Node\\Cygwin\\setup", "rootdir"); if (rootDir) { const bashExe = path.join(rootDir, "bin", "bash.exe"); if (fs.existsSync(bashExe)) { return { id: "cygwin", name: "Cygwin", command: bashExe, args: ["--login", "-i"], icon: "cygwin", }; } } // Fallback: common path. const fallback = "C:\\cygwin64\\bin\\bash.exe"; if (fs.existsSync(fallback)) { return { id: "cygwin", name: "Cygwin", command: fallback, args: ["--login", "-i"], icon: "cygwin", }; } } catch (_err) { // Cygwin not installed. } return null; } // --------------------------------------------------------------------------- // Main discovery entry point for Windows // --------------------------------------------------------------------------- /** * Discover all available shells on a Windows system. * Returns an array of DiscoveredShell objects. Exactly one shell will have * `isDefault: true` based on priority: pwsh > powershell > cmd. * * @returns {Array<{id: string, name: string, command: string, args: string[], icon: string, isDefault?: boolean}>} */ function discoverWindowsShells() { const shells = []; // Detect each shell type independently — failures are isolated. const cmd = detectCmd(); if (cmd) shells.push(cmd); const powershell = detectPowerShell(); if (powershell) shells.push(powershell); const pwsh = detectPwsh(); if (pwsh) shells.push(pwsh); const wslDistros = detectWslDistros(); shells.push(...wslDistros); const gitBash = detectGitBash(); if (gitBash) shells.push(gitBash); const cygwin = detectCygwin(); if (cygwin) shells.push(cygwin); // Assign default: pwsh > powershell > cmd const defaultShell = shells.find((s) => s.id === "pwsh") || shells.find((s) => s.id === "powershell") || shells.find((s) => s.id === "cmd"); if (defaultShell) { defaultShell.isDefault = true; } return shells; } // --------------------------------------------------------------------------- // Unix shell detection helpers // --------------------------------------------------------------------------- /** * Map a Unix shell binary basename to a human-readable display name. * * @param {string} basename e.g. "zsh", "bash", "nu" * @returns {string} */ function mapUnixShellName(basename) { const map = { zsh: "Zsh", bash: "Bash", fish: "Fish", sh: "sh", ksh: "Ksh", tcsh: "Tcsh", csh: "Csh", dash: "Dash", nu: "Nushell", pwsh: "PowerShell", }; return map[basename] || basename; } /** * Map a Unix shell binary basename to an icon identifier. * * @param {string} basename e.g. "zsh", "fish", "nu" * @returns {string} */ function mapUnixShellIcon(basename) { const map = { zsh: "zsh", bash: "bash", fish: "fish", sh: "terminal", ksh: "terminal", tcsh: "terminal", csh: "terminal", dash: "terminal", nu: "nushell", pwsh: "pwsh", }; return map[basename] || "terminal"; } /** * Returns true for shells that should be launched with the `-l` (login) flag. * * @param {string} basename * @returns {boolean} */ function isLoginShell(basename) { return ["bash", "zsh", "fish", "ksh", "sh"].includes(basename); } // --------------------------------------------------------------------------- // Main discovery entry point for Unix // --------------------------------------------------------------------------- /** * Discover all available shells on a Unix/macOS system by reading /etc/shells. * The shell referenced by $SHELL is marked as default. If $SHELL is not in * /etc/shells it is prepended to the list. * * @returns {Array<{id: string, name: string, command: string, args: string[], icon: string, isDefault?: boolean}>} */ function discoverUnixShells() { const shells = []; const seen = new Set(); // Read /etc/shells — each non-comment line is an absolute path. let etcShellPaths = []; try { const content = fs.readFileSync("/etc/shells", "utf8"); etcShellPaths = content .split(/\r?\n/) .map((l) => l.trim()) .filter((l) => l && !l.startsWith("#")); } catch (_err) { // /etc/shells not readable — fall through to $SHELL only. } // Filter to existing files and deduplicate by real path. const validPaths = []; for (const shellPath of etcShellPaths) { try { if (!fs.existsSync(shellPath)) continue; const real = fs.realpathSync(shellPath); if (seen.has(real)) continue; seen.add(real); validPaths.push(shellPath); } catch (_err) { // Skip unresolvable paths. } } // Build DiscoveredShell objects. // Track basename counts to detect duplicates (e.g., /bin/bash vs /usr/local/bin/bash) const baseCount = new Map(); for (const shellPath of validPaths) { const base = path.basename(shellPath); baseCount.set(base, (baseCount.get(base) || 0) + 1); } for (const shellPath of validPaths) { const base = path.basename(shellPath); const args = isLoginShell(base) ? ["-l"] : []; // Use basename as id when unique, otherwise use path slug to guarantee uniqueness const needsDisambiguation = baseCount.get(base) > 1; const id = needsDisambiguation ? shellPath.replace(/^\/+/, "").replace(/[/\\]+/g, "-") : base; const name = needsDisambiguation ? `${mapUnixShellName(base)} (${shellPath})` : mapUnixShellName(base); shells.push({ id, name, command: shellPath, args, icon: mapUnixShellIcon(base), }); } // Ensure $SHELL is present — prepend it if missing. const envShell = process.env.SHELL; if (envShell) { try { const envReal = fs.realpathSync(envShell); if (!seen.has(envReal) && fs.existsSync(envShell)) { const base = path.basename(envShell); const args = isLoginShell(base) ? ["-l"] : []; // Check if basename already exists in the list to disambiguate const hasDuplicate = shells.some((s) => path.basename(s.command) === base); const id = hasDuplicate ? envShell.replace(/^\/+/, "").replace(/[/\\]+/g, "-") : base; const name = hasDuplicate ? `${mapUnixShellName(base)} (${envShell})` : mapUnixShellName(base); shells.unshift({ id, name, command: envShell, args, icon: mapUnixShellIcon(base), }); } } catch (_err) { // $SHELL path invalid — ignore. } } // Mark $SHELL as default (match by command path or basename). if (envShell) { const defaultShell = shells.find((s) => s.command === envShell) || shells.find((s) => s.id === path.basename(envShell)); if (defaultShell) { defaultShell.isDefault = true; } } // Fallback: mark first shell as default if none matched. if (shells.length > 0 && !shells.some((s) => s.isDefault)) { shells[0].isDefault = true; } return shells; } // --------------------------------------------------------------------------- // Unified shell discovery entry point // --------------------------------------------------------------------------- /** * Discover all available shells for the current platform. * Results are cached after the first call. * * @returns {Array<{id: string, name: string, command: string, args: string[], icon: string, isDefault?: boolean}>} */ function discoverShells() { if (cachedShells) return cachedShells; if (process.platform === "win32") { cachedShells = discoverWindowsShells(); } else { cachedShells = discoverUnixShells(); } return cachedShells; } // --------------------------------------------------------------------------- // Exports // --------------------------------------------------------------------------- module.exports = { discoverShells, discoverWindowsShells, discoverUnixShells, mapUnixShellName, mapUnixShellIcon, isLoginShell, regQueryValue, regEnumSubkeys, findExecutableOnPath, mapWslDistroIcon, };