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
- 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
254 lines
6.5 KiB
TypeScript
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;
|
|
}
|