Files
mcp-windows/src/shell.ts
T
Jonathan Miller 327b51589a
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Universal: Cascade Main → Dev / Cascade main → branches (push) Has been cancelled
Universal: Changelog Validation / Validate CHANGELOG.md (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
MCP: Standards Compliance / Secret Scanning (push) Has been cancelled
MCP: Standards Compliance / License Header Validation (push) Has been cancelled
MCP: Build & Validate / build (20) (push) Has been cancelled
MCP: Build & Validate / build (22) (push) Has been cancelled
MCP: Standards Compliance / Repository Structure Validation (push) Has been cancelled
MCP: Standards Compliance / Coding Standards Check (push) Has been cancelled
Universal: Build & Release / Build & Release Pipeline (push) Has been cancelled
MCP: Standards Compliance / Workflow Configuration Check (push) Has been cancelled
MCP: Standards Compliance / Documentation Quality Check (push) Has been cancelled
MCP: Standards Compliance / README Completeness Check (push) Has been cancelled
MCP: Standards Compliance / Git Repository Hygiene (push) Has been cancelled
MCP: Standards Compliance / Line Length Check (push) Has been cancelled
MCP: Standards Compliance / File Naming Standards (push) Has been cancelled
MCP: Standards Compliance / Insecure Code Pattern Detection (push) Has been cancelled
MCP: Standards Compliance / Script Integrity Validation (push) Has been cancelled
MCP: Standards Compliance / Dead Code Detection (push) Has been cancelled
MCP: Standards Compliance / File Size Limits (push) Has been cancelled
MCP: Standards Compliance / Binary File Detection (push) Has been cancelled
MCP: Standards Compliance / TODO/FIXME Tracking (push) Has been cancelled
MCP: Build & Release / Build, Validate & Release (push) Has been cancelled
MCP: Standards Compliance / Broken Link Detection (push) Has been cancelled
MCP: Standards Compliance / API Documentation Coverage (push) Has been cancelled
MCP: Standards Compliance / Accessibility Check (push) Has been cancelled
MCP: Standards Compliance / Performance Metrics (push) Has been cancelled
MCP: Standards Compliance / Version Consistency Check (push) Has been cancelled
Universal: CodeQL Analysis / Analyze (actions) (push) Has been cancelled
MCP: Standards Compliance / Code Complexity Analysis (push) Has been cancelled
MCP: Standards Compliance / Code Duplication Detection (push) Has been cancelled
MCP: Standards Compliance / Unused Dependencies Check (push) Has been cancelled
MCP: Standards Compliance / Terraform Configuration Validation (push) Has been cancelled
MCP: Standards Compliance / Dependency Vulnerability Scanning (push) Has been cancelled
Universal: CodeQL Analysis / Analyze (javascript) (push) Has been cancelled
Universal: CodeQL Analysis / Security Scan Summary (push) Has been cancelled
MCP: Standards Compliance / Enterprise Readiness Check (push) Has been cancelled
MCP: Standards Compliance / Repository Health Check (push) Has been cancelled
MCP: Standards Compliance / Compliance Summary (push) Has been cancelled
Universal: Sync Version on Merge / Propagate README version (push) Has been cancelled
fix: resolve 6 test failures — timeouts and window_list syntax
- audio.ts: increase Add-Type C# compilation timeout to 60s
- system.ts: increase WMI query timeout to 45s
- service.ts: batch WMI lookups (single query instead of per-service), 45s timeout
- power.ts: increase powercfg timeout to 30s
- window.ts: fix .Where() syntax to pipe-based Where-Object
- shell.ts: unref terminal session child processes so MCP server can exit cleanly

Test results: 36/36 passed (12 skipped — destructive/interactive)

Authored-by: Moko Consulting
2026-05-25 22:27:42 -05:00

254 lines
6.5 KiB
TypeScript

#!/usr/bin/env node
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* 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<ShellResult> {
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<T = unknown>(
command: string,
options: { timeout?: number; cwd?: string } = {},
): Promise<T> {
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<ShellResult> {
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<number, TerminalSession>();
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;
}