{"version":3,"file":"shell.d.ts","sourceRoot":"","sources":["../../src/utils/shell.ts"],"names":[],"mappings":"AASA,MAAM,WAAW,gBAAgB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,MAAM,EAAE,MAAM,GAAG,YAAY,CAAC;IAC9B,uEAAuE;IACvE,WAAW,EAAE,OAAO,CAAC;CACrB;AAyCD;;;;;;GAMG;AACH,wBAAgB,cAAc,IAAI;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,EAAE,CAAA;CAAE,CAoElE;AAED,wBAAgB,mBAAmB,IAAI,gBAAgB,CA8CtD;AAED,wBAAgB,qBAAqB,IAAI,IAAI,CAG5C;AAED,wBAAgB,WAAW,IAAI,MAAM,CAAC,UAAU,CAY/C;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CA8BxD;AAED;;GAEG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAwBjD;AAMD;;;;GAIG;AACH,eAAO,MAAM,0BAA0B,QAI3B,CAAC;AAEb;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAM9D;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAUlD;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CAMjF;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,+BAA+B,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CAGvF","sourcesContent":["import { existsSync } from \"node:fs\";\nimport { delimiter } from \"node:path\";\nimport { spawn, spawnSync } from \"child_process\";\nimport { getBinDir, getSettingsPath } from \"../config.js\";\nimport { SettingsManager } from \"../core/settings-manager.js\";\n\nlet cachedShellConfig: { shell: string; args: string[] } | null = null;\nlet cachedPowerShellConfig: PowerShellConfig | null = null;\n\nexport interface PowerShellConfig {\n\tshell: string;\n\targs: string[];\n\tflavor: \"pwsh\" | \"powershell\";\n\t/** Whether to hide the window on Windows (windowsHide spawn option) */\n\twindowsHide: boolean;\n}\n\nfunction findExecutableOnPath(command: string): string | null {\n\tif (process.platform === \"win32\") {\n\t\t// Windows: Use 'where' and verify file exists (where can return non-existent paths)\n\t\ttry {\n\t\t\tconst result = spawnSync(\"where\", [command], { encoding: \"utf-8\", timeout: 5000 });\n\t\t\tif (result.status === 0 && result.stdout) {\n\t\t\t\tconst firstMatch = result.stdout.trim().split(/\\r?\\n/)[0];\n\t\t\t\tif (firstMatch && existsSync(firstMatch)) {\n\t\t\t\t\treturn firstMatch;\n\t\t\t\t}\n\t\t\t}\n\t\t} catch {\n\t\t\t// Ignore errors\n\t\t}\n\t\treturn null;\n\t}\n\n\t// Unix: Use 'which' and trust its output (handles Termux and special filesystems)\n\ttry {\n\t\tconst result = spawnSync(\"which\", [command], { encoding: \"utf-8\", timeout: 5000 });\n\t\tif (result.status === 0 && result.stdout) {\n\t\t\tconst firstMatch = result.stdout.trim().split(/\\r?\\n/)[0];\n\t\t\tif (firstMatch) {\n\t\t\t\treturn firstMatch;\n\t\t\t}\n\t\t}\n\t} catch {\n\t\t// Ignore errors\n\t}\n\treturn null;\n}\n\n/**\n * Find bash executable on PATH (cross-platform)\n */\nfunction findBashOnPath(): string | null {\n\treturn findExecutableOnPath(process.platform === \"win32\" ? \"bash.exe\" : \"bash\");\n}\n\n/**\n * Get shell configuration based on platform.\n * Resolution order:\n * 1. User-specified shellPath in settings.json\n * 2. On Windows: Git Bash in known locations, then bash on PATH\n * 3. On Unix: /bin/bash, then bash on PATH, then fallback to sh\n */\nexport function getShellConfig(): { shell: string; args: string[] } {\n\tif (cachedShellConfig) {\n\t\treturn cachedShellConfig;\n\t}\n\n\tconst settings = SettingsManager.create();\n\tconst customShellPath = settings.getShellPath();\n\n\t// 1. Check user-specified shell path\n\tif (customShellPath) {\n\t\tif (existsSync(customShellPath)) {\n\t\t\tcachedShellConfig = { shell: customShellPath, args: [\"-c\"] };\n\t\t\treturn cachedShellConfig;\n\t\t}\n\t\tthrow new Error(\n\t\t\t`Custom shell path not found: ${customShellPath}\\nPlease update shellPath in ${getSettingsPath()}`,\n\t\t);\n\t}\n\n\tif (process.platform === \"win32\") {\n\t\t// 2. Try Git Bash in known locations\n\t\tconst paths: string[] = [];\n\t\tconst programFiles = process.env.ProgramFiles;\n\t\tif (programFiles) {\n\t\t\tpaths.push(`${programFiles}\\\\Git\\\\bin\\\\bash.exe`);\n\t\t}\n\t\tconst programFilesX86 = process.env[\"ProgramFiles(x86)\"];\n\t\tif (programFilesX86) {\n\t\t\tpaths.push(`${programFilesX86}\\\\Git\\\\bin\\\\bash.exe`);\n\t\t}\n\n\t\tfor (const path of paths) {\n\t\t\tif (existsSync(path)) {\n\t\t\t\tcachedShellConfig = { shell: path, args: [\"-c\"] };\n\t\t\t\treturn cachedShellConfig;\n\t\t\t}\n\t\t}\n\n\t\t// 3. Fallback: search bash.exe on PATH (Cygwin, MSYS2, WSL, etc.)\n\t\tconst bashOnPath = findBashOnPath();\n\t\tif (bashOnPath) {\n\t\t\tcachedShellConfig = { shell: bashOnPath, args: [\"-c\"] };\n\t\t\treturn cachedShellConfig;\n\t\t}\n\n\t\tthrow new Error(\n\t\t\t`No bash shell found. Options:\\n` +\n\t\t\t\t`  1. Install Git for Windows: https://git-scm.com/download/win\\n` +\n\t\t\t\t`  2. Add your bash to PATH (Cygwin, MSYS2, etc.)\\n` +\n\t\t\t\t`  3. Set shellPath in ${getSettingsPath()}\\n\\n` +\n\t\t\t\t`Searched Git Bash in:\\n${paths.map((p) => `  ${p}`).join(\"\\n\")}`,\n\t\t);\n\t}\n\n\t// Unix: try /bin/bash, then bash on PATH, then fallback to sh\n\tif (existsSync(\"/bin/bash\")) {\n\t\tcachedShellConfig = { shell: \"/bin/bash\", args: [\"-c\"] };\n\t\treturn cachedShellConfig;\n\t}\n\n\tconst bashOnPath = findBashOnPath();\n\tif (bashOnPath) {\n\t\tcachedShellConfig = { shell: bashOnPath, args: [\"-c\"] };\n\t\treturn cachedShellConfig;\n\t}\n\n\tcachedShellConfig = { shell: \"sh\", args: [\"-c\"] };\n\treturn cachedShellConfig;\n}\n\nexport function getPowerShellConfig(): PowerShellConfig {\n\tif (cachedPowerShellConfig) {\n\t\treturn cachedPowerShellConfig;\n\t}\n\n\tconst args = [\"-NoLogo\", \"-NoProfile\", \"-NonInteractive\", \"-Command\"];\n\tconst windowsHide = process.platform === \"win32\";\n\n\t// Resolution order:\n\t// 1. Explicit JENSEN_PWSH_PATH environment variable\n\tconst explicitPath = process.env.JENSEN_PWSH_PATH;\n\tif (explicitPath) {\n\t\tif (existsSync(explicitPath)) {\n\t\t\tcachedPowerShellConfig = { shell: explicitPath, args, flavor: \"pwsh\", windowsHide };\n\t\t\treturn cachedPowerShellConfig;\n\t\t}\n\t\tthrow new Error(`JENSEN_PWSH_PATH is set to \"${explicitPath}\" but the file does not exist or is not executable.`);\n\t}\n\n\tif (process.platform === \"win32\") {\n\t\tconst pwsh = findExecutableOnPath(\"pwsh.exe\");\n\t\tif (pwsh) {\n\t\t\tcachedPowerShellConfig = { shell: pwsh, args, flavor: \"pwsh\", windowsHide };\n\t\t\treturn cachedPowerShellConfig;\n\t\t}\n\n\t\tconst powershell = findExecutableOnPath(\"powershell.exe\");\n\t\tif (powershell) {\n\t\t\tcachedPowerShellConfig = { shell: powershell, args, flavor: \"powershell\", windowsHide };\n\t\t\treturn cachedPowerShellConfig;\n\t\t}\n\n\t\tthrow new Error(\n\t\t\t\"PowerShell is not available on this system. Install PowerShell 7 (pwsh) or ensure Windows PowerShell is available on PATH.\",\n\t\t);\n\t}\n\n\tconst pwsh = findExecutableOnPath(\"pwsh\");\n\tif (pwsh) {\n\t\tcachedPowerShellConfig = { shell: pwsh, args, flavor: \"pwsh\", windowsHide: false };\n\t\treturn cachedPowerShellConfig;\n\t}\n\n\tthrow new Error(\n\t\t\"PowerShell is not available on this system. On non-Windows hosts, install PowerShell 7+ (pwsh) or use the bash tool instead.\",\n\t);\n}\n\nexport function resetShellConfigCache(): void {\n\tcachedShellConfig = null;\n\tcachedPowerShellConfig = null;\n}\n\nexport function getShellEnv(): NodeJS.ProcessEnv {\n\tconst binDir = getBinDir();\n\tconst pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === \"path\") ?? \"PATH\";\n\tconst currentPath = process.env[pathKey] ?? \"\";\n\tconst pathEntries = currentPath.split(delimiter).filter(Boolean);\n\tconst hasBinDir = pathEntries.includes(binDir);\n\tconst updatedPath = hasBinDir ? currentPath : [binDir, currentPath].filter(Boolean).join(delimiter);\n\n\treturn {\n\t\t...process.env,\n\t\t[pathKey]: updatedPath,\n\t};\n}\n\n/**\n * Sanitize binary output for display/storage.\n * Removes characters that crash string-width or cause display issues:\n * - Control characters (except tab, newline, carriage return)\n * - Lone surrogates\n * - Unicode Format characters (crash string-width due to a bug)\n * - Characters with undefined code points\n */\nexport function sanitizeBinaryOutput(str: string): string {\n\t// Use Array.from to properly iterate over code points (not code units)\n\t// This handles surrogate pairs correctly and catches edge cases where\n\t// codePointAt() might return undefined\n\treturn Array.from(str)\n\t\t.filter((char) => {\n\t\t\t// Filter out characters that cause string-width to crash\n\t\t\t// This includes:\n\t\t\t// - Unicode format characters\n\t\t\t// - Lone surrogates (already filtered by Array.from)\n\t\t\t// - Control chars except \\t \\n \\r\n\t\t\t// - Characters with undefined code points\n\n\t\t\tconst code = char.codePointAt(0);\n\n\t\t\t// Skip if code point is undefined (edge case with invalid strings)\n\t\t\tif (code === undefined) return false;\n\n\t\t\t// Allow tab, newline, carriage return\n\t\t\tif (code === 0x09 || code === 0x0a || code === 0x0d) return true;\n\n\t\t\t// Filter out control characters (0x00-0x1F, except 0x09, 0x0a, 0x0x0d)\n\t\t\tif (code <= 0x1f) return false;\n\n\t\t\t// Filter out Unicode format characters\n\t\t\tif (code >= 0xfff9 && code <= 0xfffb) return false;\n\n\t\t\treturn true;\n\t\t})\n\t\t.join(\"\");\n}\n\n/**\n * Kill a process and all its children (cross-platform)\n */\nexport function killProcessTree(pid: number): void {\n\tif (process.platform === \"win32\") {\n\t\t// Use taskkill on Windows to kill process tree\n\t\ttry {\n\t\t\tspawn(\"taskkill\", [\"/F\", \"/T\", \"/PID\", String(pid)], {\n\t\t\t\tstdio: \"ignore\",\n\t\t\t\tdetached: true,\n\t\t\t});\n\t\t} catch {\n\t\t\t// Ignore errors if taskkill fails\n\t\t}\n\t} else {\n\t\t// Use SIGKILL on Unix/Linux/Mac\n\t\ttry {\n\t\t\tprocess.kill(-pid, \"SIGKILL\");\n\t\t} catch {\n\t\t\t// Fallback to killing just the child if process group kill fails\n\t\t\ttry {\n\t\t\t\tprocess.kill(pid, \"SIGKILL\");\n\t\t\t} catch {\n\t\t\t\t// Process already dead\n\t\t\t}\n\t\t}\n\t}\n}\n\n// ============================================================================\n// PowerShell remote execution utilities\n// ============================================================================\n\n/**\n * PowerShell preamble for remote execution via SSH.\n * Sets up: error action preference, progress suppression, output encoding.\n * Compatible with Windows PowerShell 5.1 and PowerShell 7+.\n */\nexport const REMOTE_POWERSHELL_PREAMBLE = [\n\t\"$ErrorActionPreference = 'Stop'\",\n\t\"$ProgressPreference = 'SilentlyContinue'\",\n\t\"[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new()\",\n].join(\"; \");\n\n/**\n * Encode a PowerShell script as a UTF-16LE Base64 string suitable for\n * powershell.exe -EncodedCommand.\n *\n * Windows PowerShell and pwsh both require UTF-16LE encoding.\n * Using UTF-8 produces garbled output or parse errors.\n *\n * No BOM is included — both PowerShell 7 and Windows PowerShell 5.1\n * accept UTF-16LE without BOM. A BOM (U+FEFF) causes corruption of\n * the first statement when used with -EncodedCommand.\n *\n * This function is pure — it does not execute anything, does not shell-quote,\n * and is fully unit-testable.\n *\n * @param source - PowerShell source code (can contain Unicode)\n * @returns Base64-encoded UTF-16LE string (no BOM)\n */\nexport function encodePowerShellCommand(source: string): string {\n\tconst buf = Buffer.alloc(source.length * 2);\n\tfor (let i = 0; i < source.length; i++) {\n\t\tbuf.writeUInt16LE(source.charCodeAt(i), i * 2);\n\t}\n\treturn buf.toString(\"base64\");\n}\n\n/**\n * Validate an SSH hostname/destination for use in spawn argv.\n *\n * Rejects: empty hosts, hosts starting with \"-\" (option injection),\n * hosts containing spaces or newlines, hosts that are empty after trim.\n *\n * Accepts: user@host, host-alias, IPv4, IPv6 (with or without brackets),\n * and any hostname valid per RFC 952/1123.\n *\n * Throws a descriptive Error on invalid input so the caller gets a\n * clear failure instead of a silently corrupted command.\n */\nexport function validateSshHost(host: string): void {\n\tif (!host || host.trim().length === 0) {\n\t\tthrow new Error(\"SSH host must not be empty\");\n\t}\n\tif (host.startsWith(\"-\")) {\n\t\tthrow new Error(`SSH host starts with \"-\" — possible option injection: \"${host}\"`);\n\t}\n\tif (/[\\s]/.test(host)) {\n\t\tthrow new Error(`SSH host contains whitespace — possible injection: \"${host}\"`);\n\t}\n}\n\n/**\n * Build argv for executing a PowerShell command on a remote Windows host via SSH.\n *\n * Produces a structured argv array suitable for spawn/exec with shell: false.\n * Uses -EncodedCommand for reliable quoting, exit code propagation, and Unicode.\n *\n * The returned array includes \"--\" to prevent hostname-based option injection.\n *\n * @param host - SSH hostname (validated; must not be empty, start with -, or contain spaces)\n * @param command - PowerShell source code\n * @returns Structured argv for the ssh process\n */\nexport function buildRemotePowerShellArgs(host: string, command: string): string[] {\n\tvalidateSshHost(host);\n\tconst preamble = REMOTE_POWERSHELL_PREAMBLE;\n\tconst fullScript = `${preamble}; ${command}`;\n\tconst encoded = encodePowerShellCommand(fullScript);\n\treturn [\"--\", host, \"powershell.exe\", \"-NoProfile\", \"-NonInteractive\", \"-EncodedCommand\", encoded];\n}\n\n/**\n * Build argv for a simple PowerShell command on a remote Windows host via SSH.\n *\n * Uses -Command (not -EncodedCommand). Only appropriate for simple commands\n * without pipes, complex quoting, multiline scripts, or untrusted data.\n *\n * The returned array includes \"--\" to prevent hostname-based option injection.\n *\n * @param host - SSH hostname (validated)\n * @param command - Simple PowerShell command\n * @returns Structured argv for the ssh process\n */\nexport function buildSimpleRemotePowerShellArgs(host: string, command: string): string[] {\n\tvalidateSshHost(host);\n\treturn [\"--\", host, \"powershell.exe\", \"-NoProfile\", \"-NonInteractive\", \"-Command\", command];\n}\n"]}