UNPKG

@skyramp/mcp

Version:

Skyramp MCP (Model Context Protocol) Server - AI-powered test generation and execution

440 lines (439 loc) 17.8 kB
import { spawn, exec } from "child_process"; import path from "path"; import fs from "fs"; import os from "os"; import { promisify } from "util"; import { logger } from "./logger.js"; const execAsync = promisify(exec); // Mapping of environment variables const shellEnvVariables = { http_proxy: "http://localhost:8888", HTTPS_PROXY: "http://localhost:8888", HTTP_PROXY: "http://localhost:8888", https_proxy: "http://localhost:8888", no_proxy: "", NO_PROXY: "", }; let terminalProcess; let terminalPID; function getTerminalConfig() { const platform = os.platform(); switch (platform) { case "win32": return { command: "cmd", args: ["/c", "start", "cmd", "/k"], scriptExtension: ".bat", scriptHeader: "@echo off", pidCommand: "echo %$", }; case "darwin": return { command: "open", args: ["-a", "Terminal"], scriptExtension: ".sh", scriptHeader: "#!/bin/bash", pidCommand: "echo $$", }; case "linux": default: // Try common Linux terminals in order of preference const linuxTerminals = [ "gnome-terminal", "konsole", "xfce4-terminal", "xterm", "terminator", "alacritty", ]; // For now, default to gnome-terminal, but this could be enhanced // to detect which terminal is available return { command: "gnome-terminal", args: ["--"], scriptExtension: ".sh", scriptHeader: "#!/bin/bash", pidCommand: "echo $$", }; } } function createEnvironmentScript(config) { const tempDir = os.tmpdir(); const scriptPath = path.join(tempDir, `skyramp-launch${config.scriptExtension}`); const pidFile = path.join(tempDir, "skyramp-terminal.pid"); const infoMessage = os.platform() === "win32" ? "A new terminal has been spawned here by Skyramp to enable trace collection.\\nAsk skyramp agent to stop trace to terminate trace collection and return to where you were.\\nFor more information, visit skyramp.dev/docs." : "\\033[33mA new terminal has been spawned here by Skyramp to enable trace collection.\\nAsk skyramp agent to stop trace to terminate trace collection and return to where you were.\\nFor more information, visit skyramp.dev/docs.\\033[0m"; let scriptContent; if (os.platform() === "win32") { // Windows batch script const setStatements = Object.entries(shellEnvVariables) .map(([key, value]) => `set ${key}=${value}`) .join("\n"); scriptContent = `${config.scriptHeader} ${setStatements} echo %^%^% > "${pidFile}" echo ${infoMessage} cmd /k`; } else { // Unix-like script (macOS/Linux) const exportStatements = Object.entries(shellEnvVariables) .map(([key, value]) => `export ${key}="${value}"`) .join("\n"); scriptContent = `${config.scriptHeader} ${exportStatements} ${config.pidCommand} > "${pidFile}" echo -e "${infoMessage}" exec $SHELL`; } try { // Remove existing script if it exists if (fs.existsSync(scriptPath)) { try { fs.unlinkSync(scriptPath); logger.debug("Removed existing script file", { scriptPath }); } catch (removeError) { logger.warning("Could not remove existing script file", { scriptPath, error: removeError instanceof Error ? removeError.message : String(removeError), }); } } // Create the script fs.writeFileSync(scriptPath, scriptContent, { mode: 0o755 }); // Verify the script was created successfully if (!fs.existsSync(scriptPath)) { throw new Error("Script file was not created successfully"); } // Check if the script is readable and executable try { fs.accessSync(scriptPath, fs.constants.R_OK | fs.constants.X_OK); } catch (accessError) { logger.warning("Script may not have correct permissions", { scriptPath, error: accessError instanceof Error ? accessError.message : String(accessError), }); } logger.debug("Cross-platform script created and verified", { platform: os.platform(), scriptPath, scriptSize: fs.statSync(scriptPath).size, }); return scriptPath; } catch (error) { logger.error("Failed to create terminal script", { platform: os.platform(), scriptPath, tempDir, error: error instanceof Error ? error.message : String(error), }); throw error; } } async function findAvailableTerminal() { if (os.platform() !== "linux") { return null; // Use default for non-Linux platforms } const terminals = [ "gnome-terminal", "konsole", "xfce4-terminal", "xterm", "terminator", "alacritty", "kitty", ]; for (const terminal of terminals) { try { await execAsync(`which ${terminal}`); logger.debug("Found available terminal", { terminal }); return terminal; } catch { // Terminal not found, try next } } logger.warning("No common Linux terminal found, falling back to xterm"); return "xterm"; } export default async function openProxyTerminal() { logger.info("Starting cross-platform proxy terminal creation", { platform: os.platform(), arch: os.arch(), }); try { const config = getTerminalConfig(); // For Linux, try to find an available terminal if (os.platform() === "linux") { const availableTerminal = await findAvailableTerminal(); if (availableTerminal && availableTerminal !== "gnome-terminal") { config.command = availableTerminal; // Adjust args based on terminal if (availableTerminal === "xterm") { config.args = ["-e"]; } else if (availableTerminal === "konsole") { config.args = ["-e"]; } } } const scriptPath = createEnvironmentScript(config); const pidFile = path.join(os.tmpdir(), "skyramp-terminal.pid"); // Verify script exists before launching terminal if (!fs.existsSync(scriptPath)) { throw new Error(`Script file does not exist: ${scriptPath}`); } // Verify script is accessible try { fs.accessSync(scriptPath, fs.constants.R_OK | fs.constants.X_OK); } catch (accessError) { throw new Error(`Script file is not accessible: ${scriptPath} - ${accessError instanceof Error ? accessError.message : String(accessError)}`); } logger.debug("Script verified before terminal launch", { scriptPath, scriptSize: fs.statSync(scriptPath).size, }); // Launch terminal with script const spawnArgs = [...config.args, scriptPath]; terminalProcess = spawn(config.command, spawnArgs, { env: { ...process.env, ...shellEnvVariables, }, detached: true, stdio: "ignore", }); logger.debug("Spawned cross-platform terminal", { platform: os.platform(), command: config.command, args: spawnArgs, pid: terminalProcess.pid, }); // Handle process events terminalProcess.on("exit", (code) => { logger.info("Terminal launch command completed", { platform: os.platform(), exitCode: code, }); // Wait for terminal to write PID and execute script // On macOS, Terminal.app takes longer to start and execute the script const cleanupDelay = os.platform() === "darwin" ? 10000 : 3000; setTimeout(() => { try { if (fs.existsSync(pidFile)) { const pidContent = fs.readFileSync(pidFile, "utf8").trim(); const pid = parseInt(pidContent); if (!isNaN(pid)) { terminalPID = pid; logger.info("Terminal PID captured", { terminalPID: pid }); } // Cleanup PID file try { fs.unlinkSync(pidFile); } catch (cleanupError) { logger.debug("PID file cleanup completed with minor issues", { error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError), }); } } // Cleanup script file with additional delay and verification setTimeout(() => { try { if (fs.existsSync(scriptPath)) { fs.unlinkSync(scriptPath); logger.debug("Script file cleaned up", { scriptPath }); } } catch (scriptCleanupError) { logger.debug("Script cleanup completed with minor issues", { error: scriptCleanupError instanceof Error ? scriptCleanupError.message : String(scriptCleanupError), }); } }, 2000); } catch (err) { logger.warning("Could not read terminal PID", { error: err instanceof Error ? err.message : String(err), }); // Even if PID reading fails, still cleanup script after delay setTimeout(() => { try { if (fs.existsSync(scriptPath)) { fs.unlinkSync(scriptPath); logger.debug("Script file cleaned up (fallback)", { scriptPath, }); } } catch (scriptCleanupError) { logger.debug("Fallback script cleanup completed with minor issues", { error: scriptCleanupError instanceof Error ? scriptCleanupError.message : String(scriptCleanupError), }); } }, 2000); } }, cleanupDelay); }); terminalProcess.on("error", (error) => { logger.error("Terminal spawn error", { platform: os.platform(), command: config.command, error: error.message, }); }); logger.info("Cross-platform proxy terminal opened successfully", { platform: os.platform(), arch: os.arch(), }); } catch (error) { logger.error("Failed to open proxy terminal", { platform: os.platform(), error: error instanceof Error ? error.message : String(error), }); throw error; } } export async function closeProxyTerminal() { logger.info("Starting cross-platform proxy terminal closure", { platform: os.platform(), }); // Kill the spawn process if it exists if (terminalProcess) { const killResult = terminalProcess.kill(); logger.debug("Terminal spawn process kill result", { killResult }); } // Handle platform-specific terminal closing if (terminalPID) { try { const platform = os.platform(); if (platform === "darwin") { // macOS: First try to kill the specific PID and its process group try { // Kill the process group to ensure child processes are also terminated process.kill(-terminalPID, "SIGTERM"); logger.info("macOS terminal process group killed via SIGTERM", { terminalPID, }); // Give processes time to terminate gracefully await new Promise((resolve) => setTimeout(resolve, 1000)); // Check if process still exists and force kill if needed try { process.kill(terminalPID, 0); // Check if process exists process.kill(-terminalPID, "SIGKILL"); logger.info("macOS terminal process group force killed", { terminalPID, }); } catch (killErr) { // Process is already dead, which is what we want logger.debug("Terminal process already terminated", { terminalPID, }); } } catch (pidErr) { logger.debug("PID-based kill failed, trying AppleScript fallback", { error: pidErr instanceof Error ? pidErr.message : String(pidErr), }); // Fallback: Use AppleScript to close terminal windows with our proxy environment const closeScript = `tell application "Terminal" repeat with w in windows repeat with t in tabs of w try -- Send exit command to close the shell in this tab do script "exit" in t end try end repeat end repeat end tell`; await execAsync(`osascript -e '${closeScript.replace(/'/g, "'\\''")}'`); logger.info("macOS terminal closed via AppleScript fallback"); } } else if (platform === "win32") { // Windows: Use taskkill with /T flag to kill process tree await execAsync(`taskkill /PID ${terminalPID} /T /F`); logger.info("Windows terminal and children closed via taskkill", { terminalPID, }); } else { // Linux: Send SIGTERM to process group try { process.kill(-terminalPID, "SIGTERM"); logger.info("Linux terminal process group closed via SIGTERM", { terminalPID, }); // Give processes time to terminate gracefully await new Promise((resolve) => setTimeout(resolve, 1000)); // Check if process still exists and force kill if needed try { process.kill(terminalPID, 0); // Check if process exists process.kill(-terminalPID, "SIGKILL"); logger.info("Linux terminal process group force killed", { terminalPID, }); } catch (killErr) { // Process is already dead, which is what we want logger.debug("Terminal process already terminated", { terminalPID, }); } } catch (pidErr) { // Fallback to regular process kill try { process.kill(terminalPID, "SIGTERM"); logger.info("Linux terminal closed via fallback SIGTERM", { terminalPID, }); } catch (fallbackErr) { logger.warning("Could not kill terminal process", { terminalPID, error: fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr), }); } } } } catch (err) { logger.warning("Error during platform-specific terminal closure", { platform: os.platform(), terminalPID, error: err instanceof Error ? err.message : String(err), }); } } else { logger.warning("No terminal PID available for cleanup - terminal may not have been properly tracked"); } // Reset state terminalProcess = undefined; terminalPID = undefined; logger.info("Cross-platform proxy terminal closure completed", { platform: os.platform(), }); }