/** * Shell utilities optimized for bun-pty * Provides cross-platform shell detection and PTY creation */ import { join } from 'path'; import { spawn, type IPty } from 'bun-pty'; import { debug } from '$shared/utils/logger'; import { getCleanSpawnEnv } from '../utils/env'; // Platform detection export const isWindows = process.platform === 'win32'; export const isMacOS = process.platform === 'darwin'; export const isLinux = process.platform === 'linux'; /** * Find Git Bash executable on Windows */ export async function findGitBash(): Promise { if (!isWindows) return null; const possiblePaths = [ 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\bin\\bash.exe', process.env.PROGRAMFILES ? join(process.env.PROGRAMFILES, 'Git', 'bin', 'bash.exe') : null, process.env.LOCALAPPDATA ? join(process.env.LOCALAPPDATA, 'Programs', 'Git', 'bin', 'bash.exe') : null, ].filter(Boolean) as string[]; for (const bashPath of possiblePaths) { try { if (await Bun.file(bashPath).exists()) { debug.log('terminal', '✅ Found Git Bash at:', bashPath); return bashPath; } } catch { // Continue to next path } } return null; } /** * Find PowerShell executable on Windows */ export async function findPowerShell(): Promise { if (!isWindows) return null; const possiblePaths = [ 'C:\\Program Files\\PowerShell\\7\\pwsh.exe', 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', process.env.SYSTEMROOT ? join(process.env.SYSTEMROOT, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe') : null, ].filter(Boolean) as string[]; for (const pwshPath of possiblePaths) { try { if (await Bun.file(pwshPath).exists()) { debug.log('terminal', '✅ Found PowerShell at:', pwshPath); return pwshPath; } } catch { // Continue to next path } } return null; } /** * Check if Git Bash is available */ export async function isGitBashAvailable(): Promise { if (!isWindows) return false; return (await findGitBash()) !== null; } /** * Get Git Bash required message */ export function getGitBashRequiredMessage(command: string): string { return `⚠️ Git Bash is required for Unix commands! The command '${command}' requires Git Bash. Please install Git for Windows: • Download from: https://git-scm.com/download/win • Or install via: winget install Git.Git After installation, restart the application.`; } /** * Get the appropriate shell configuration for the current platform */ export async function getShellConfig(preferGitBash = false): Promise<{ shell: string; args: (command: string) => string[]; name: string; isUnixLike: boolean; }> { if (isWindows) { const psPath = Bun.which('powershell.exe') ?? 'powershell.exe'; return { shell: psPath, args: (command: string) => ['-NoProfile', '-Command', command], name: 'PowerShell', isUnixLike: false }; } // macOS if (isMacOS) { if (await Bun.file('/bin/zsh').exists()) { return { shell: '/bin/zsh', args: (command: string) => ['-c', command], name: 'Zsh', isUnixLike: true }; } return { shell: '/bin/bash', args: (command: string) => ['-c', command], name: 'Bash', isUnixLike: true }; } // Linux if (isLinux) { const userShell = process.env.SHELL || '/bin/bash'; const shellName = userShell.split('/').pop() || 'Shell'; return { shell: userShell, args: (command: string) => ['-c', command], name: shellName.charAt(0).toUpperCase() + shellName.slice(1), isUnixLike: true }; } // Fallback return { shell: '/bin/sh', args: (command: string) => ['-c', command], name: 'Shell', isUnixLike: true }; } /** * Create a clean PTY environment with terminal-specific overrides. * Uses getCleanSpawnEnv() to filter Bun/npm/Vite runtime pollution * on every call (catches Vite re-injection after startup). */ export function createCleanPtyEnv(terminalSize?: { cols: number; rows: number }): Record { const ptyEnv = getCleanSpawnEnv(); // Terminal-specific environment variables for xterm.js const cols = terminalSize?.cols || 80; const rows = terminalSize?.rows || 24; Object.assign(ptyEnv, { FORCE_COLOR: '1', COLORTERM: 'truecolor', TERM: 'xterm-256color', COLUMNS: cols.toString(), LINES: rows.toString(), TERM_PROGRAM: 'xterm.js', CLICOLOR: '1', LC_ALL: 'en_US.UTF-8', LANG: 'en_US.UTF-8' }); return ptyEnv; } /** * Create a PTY instance using bun-pty with xterm.js optimizations */ export function createPty(shell: string, args: string[], cwd: string, terminalSize?: { cols: number; rows: number }): IPty { // Use clean environment (no Bun/npm/Vite pollution) const ptyEnv = createCleanPtyEnv(terminalSize); const cols = terminalSize?.cols || 80; const rows = terminalSize?.rows || 24; if (isWindows) { const psPath = Bun.which('powershell.exe') ?? 'powershell.exe'; const isPowershell = shell === 'powershell' || shell === 'powershell.exe' || shell.endsWith('powershell.exe') || shell.endsWith('pwsh.exe'); if (isPowershell) { let actualCommand = args.join(' '); if (args.length >= 2 && (args[0] === '-Command' || args[0] === '-NoProfile')) { const commandIndex = args.findIndex(arg => arg === '-Command'); if (commandIndex !== -1 && commandIndex + 1 < args.length) { actualCommand = args[commandIndex + 1]; } else if (args[args.length - 1] !== '-Command' && args[args.length - 1] !== '-NoProfile') { actualCommand = args[args.length - 1]; } } return spawn(psPath, ['-NoProfile', '-NoLogo', '-Command', actualCommand], { name: 'xterm-256color', cols, rows, cwd: cwd, env: ptyEnv }); } return spawn(psPath, ['-NoProfile', '-NoLogo', '-Command', args.join(' ') || 'Write-Host "Terminal ready"'], { name: 'xterm-256color', cols, rows, cwd: cwd, env: ptyEnv }); } // Unix-like systems return spawn(shell, args, { name: 'xterm-256color', cols, rows, cwd: cwd, env: ptyEnv }); } /** * Create a PTY wrapper for process-manager compatibility with xterm.js support */ export function createPtyWrapper(pty: IPty, sessionId?: string): any { let isKilled = false; return { pid: pty.pid, pty: pty, // Expose the original PTY instance // Expose PTY event handlers onData: (callback: (data: string) => void) => { return pty.onData(callback); }, onExit: (callback: (event: { exitCode: number; signal?: number | string }) => void) => { return pty.onExit((event) => { isKilled = true; debug.log('terminal', `🏁 PTY ${pty.pid} exited with code:`, event.exitCode); callback({ exitCode: event.exitCode, signal: event.signal }); }); }, resize: (cols: number, rows: number) => { try { debug.log('terminal', `🔧 Resizing PTY ${pty.pid} to ${cols}x${rows}`); pty.resize(cols, rows); debug.log('terminal', `✅ PTY ${pty.pid} resized successfully`); } catch (error) { debug.error('terminal', `❌ Failed to resize PTY ${pty.pid}:`, error); throw error; } }, write: (data: string) => { return pty.write(data); }, kill: (signal?: number | string) => { if (isKilled) { debug.log('terminal', '⚠️ PTY already killed, ignoring kill signal'); return; } try { isKilled = true; debug.log('terminal', `💀 Killing PTY ${pty.pid} with signal:`, signal); if (signal === 'SIGKILL' || signal === 9) { pty.kill('SIGKILL'); } else if (signal === 'SIGTERM' || signal === 15) { pty.kill('SIGTERM'); } else { // Send Ctrl+C for graceful termination, then SIGKILL as fallback debug.log('terminal', '⌨️ Sending Ctrl+C to PTY...'); pty.write('\x03'); // Give the process time to handle Ctrl+C gracefully setTimeout(() => { try { if (pty.pid && !isKilled) { debug.log('terminal', '⏰ Graceful termination timeout, sending SIGKILL...'); pty.kill('SIGKILL'); } } catch (killError) { // Process might already be dead, this is expected debug.log('terminal', '💀 PTY process already terminated'); } }, 1000); } } catch (e) { debug.log('terminal', '⚠️ Error killing PTY (this may be normal during shutdown):', e instanceof Error ? e.message : e); } }, exited: new Promise((resolve) => { pty.onExit((event) => { isKilled = true; debug.log('terminal', `🏁 PTY ${pty.pid} exited with code:`, event.exitCode); resolve(event.exitCode); }); }) }; }