#!/usr/bin/env node /* Copyright (C) 2026 Moko Consulting * SPDX-License-Identifier: GPL-3.0-or-later */ import { execFile, spawn, ChildProcess } from 'node:child_process'; import { promisify } from 'node:util'; const execFileAsync = promisify(execFile); export interface ShellResult { stdout: string; stderr: string; exitCode: number; } const POWERSHELL = 'powershell.exe'; const DEFAULT_TIMEOUT = 30_000; /** * Run a PowerShell command and return stdout/stderr/exitCode. * Commands are wrapped with -NoProfile -NonInteractive for speed and safety. */ export async function runPowerShell( command: string, options: { timeout?: number; cwd?: string } = {}, ): Promise { const timeout = options.timeout ?? DEFAULT_TIMEOUT; try { const { stdout, stderr } = await execFileAsync(POWERSHELL, [ '-NoProfile', '-NonInteractive', '-Command', command, ], { timeout, cwd: options.cwd, maxBuffer: 10 * 1024 * 1024, windowsHide: true, }); return { stdout: stdout.trimEnd(), stderr: stderr.trimEnd(), exitCode: 0 }; } catch (err: unknown) { const e = err as { stdout?: string; stderr?: string; code?: number | string; killed?: boolean }; if (e.killed) { return { stdout: e.stdout?.trimEnd() ?? '', stderr: `Command timed out after ${timeout}ms`, exitCode: -1, }; } return { stdout: e.stdout?.trimEnd() ?? '', stderr: e.stderr?.trimEnd() ?? String(err), exitCode: typeof e.code === 'number' ? e.code : 1, }; } } /** * Run a PowerShell command that returns JSON. Wraps output with ConvertTo-Json * and parses the result. */ export async function runPowerShellJson( command: string, options: { timeout?: number; cwd?: string } = {}, ): Promise { const wrapped = `${command} | ConvertTo-Json -Depth 10 -Compress`; const result = await runPowerShell(wrapped, options); if (result.exitCode !== 0) { throw new Error(result.stderr || `PowerShell exited with code ${result.exitCode}`); } if (!result.stdout) { return [] as unknown as T; } return JSON.parse(result.stdout) as T; } /** * Run a generic shell command (cmd, bash, pwsh). */ export async function runShell( command: string, options: { shell?: 'pwsh' | 'cmd' | 'bash'; timeout?: number; cwd?: string; } = {}, ): Promise { const shell = options.shell ?? 'pwsh'; const timeout = options.timeout ?? DEFAULT_TIMEOUT; let executable: string; let args: string[]; switch (shell) { case 'cmd': executable = 'cmd.exe'; args = ['/c', command]; break; case 'bash': executable = 'bash'; args = ['-c', command]; break; case 'pwsh': default: executable = POWERSHELL; args = ['-NoProfile', '-NonInteractive', '-Command', command]; break; } try { const { stdout, stderr } = await execFileAsync(executable, args, { timeout, cwd: options.cwd, maxBuffer: 10 * 1024 * 1024, windowsHide: true, }); return { stdout: stdout.trimEnd(), stderr: stderr.trimEnd(), exitCode: 0 }; } catch (err: unknown) { const e = err as { stdout?: string; stderr?: string; code?: number | string; killed?: boolean }; if (e.killed) { return { stdout: e.stdout?.trimEnd() ?? '', stderr: `Command timed out after ${timeout}ms`, exitCode: -1, }; } return { stdout: e.stdout?.trimEnd() ?? '', stderr: e.stderr?.trimEnd() ?? String(err), exitCode: typeof e.code === 'number' ? e.code : 1, }; } } // ── Persistent Terminal Sessions ───────────────────────────────────────── export interface TerminalSession { pid: number; shell: string; process: ChildProcess; output: string[]; startedAt: Date; } const sessions = new Map(); const MAX_OUTPUT_LINES = 5000; export function startSession(shell: 'pwsh' | 'cmd' | 'bash' | 'python' | 'node' | 'wsl' = 'pwsh'): TerminalSession { let executable: string; let args: string[]; switch (shell) { case 'cmd': executable = 'cmd.exe'; args = []; break; case 'bash': executable = 'bash'; args = []; break; case 'python': executable = 'python'; args = ['-i']; break; case 'node': executable = 'node'; args = []; break; case 'wsl': executable = 'wsl.exe'; args = []; break; case 'pwsh': default: executable = POWERSHELL; args = ['-NoProfile', '-NoLogo']; break; } const proc = spawn(executable, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, detached: false, }); // Don't let terminal sessions prevent the MCP server from exiting proc.unref(); (proc.stdout as any)?.unref?.(); (proc.stderr as any)?.unref?.(); (proc.stdin as any)?.unref?.(); const session: TerminalSession = { pid: proc.pid!, shell, process: proc, output: [], startedAt: new Date(), }; const pushLine = (line: string) => { session.output.push(line); if (session.output.length > MAX_OUTPUT_LINES) { session.output.splice(0, session.output.length - MAX_OUTPUT_LINES); } }; proc.stdout?.on('data', (data: Buffer) => { data.toString().split('\n').forEach(pushLine); }); proc.stderr?.on('data', (data: Buffer) => { data.toString().split('\n').forEach(l => pushLine(`[stderr] ${l}`)); }); proc.on('exit', () => { pushLine(`[session ended]`); }); sessions.set(proc.pid!, session); return session; } export function sendToSession(pid: number, input: string): void { const session = sessions.get(pid); if (!session) throw new Error(`No session with PID ${pid}`); if (session.process.exitCode !== null) throw new Error(`Session ${pid} has ended`); session.process.stdin?.write(input + '\n'); } export function readSessionOutput(pid: number, offset = 0, length?: number): string[] { const session = sessions.get(pid); if (!session) throw new Error(`No session with PID ${pid}`); const start = offset < 0 ? Math.max(0, session.output.length + offset) : offset; const end = length !== undefined ? start + length : undefined; return session.output.slice(start, end); } export function listSessions(): Array<{ pid: number; shell: string; running: boolean; lines: number; startedAt: string }> { return Array.from(sessions.values()).map(s => ({ pid: s.pid, shell: s.shell, running: s.process.exitCode === null, lines: s.output.length, startedAt: s.startedAt.toISOString(), })); } export function terminateSession(pid: number): boolean { const session = sessions.get(pid); if (!session) return false; session.process.kill(); sessions.delete(pid); return true; }