Files
myeasycms-v2/packages/mcp-server/src/lib/process-utils.ts
Giancarlo Buomprisco ca585e09be 2.24.1 (#453)
* 2.24.1

- Updated dependencies
- MCP Server: better compatibility with Windows
- MCP Server: allow using a custom root for better flexibility
- Version Check: use package.json version instead of number of commits
- Prettier: reformatted some files
- Add SSH_AUTH_SOCK to dev passThroughEnv to solve SSH issues; handle execSync errors
- Use GIT_SSH to fix SSH issues on Windows
- Updated Stripe version
- Updated application version from 2.24.0 to 2.24.1 in package.json.
- Enhanced error handling in billing services to include error causes for better debugging.
2026-02-26 18:22:35 +08:00

264 lines
6.7 KiB
TypeScript

import {
type ExecFileOptions,
type ExecFileSyncOptions,
type SpawnOptions,
execFile,
execFileSync,
spawn,
} from 'node:child_process';
import { platform } from 'node:os';
import { promisify } from 'node:util';
const rawExecFileAsync = promisify(execFile);
export const IS_WINDOWS = platform() === 'win32';
/**
* Cross-platform execFile for `.cmd` / `.bat` commands (pnpm, npm, etc.).
*
* On Windows, `.cmd`/`.bat` files cannot be executed without a shell
* (see https://nodejs.org/api/child_process.html#spawning-bat-and-cmd-files-on-windows).
* Adds `shell: true` + `windowsHide: true` on Windows to resolve them
* without opening a visible console window.
*
* For native executables (git, node, docker) prefer `execFileSync` / `execFile`
* directly — they don't need a shell.
*/
export function execFileAsync(
command: string,
args: string[],
options: ExecFileOptions = {},
) {
return rawExecFileAsync(command, args, withShell(options));
}
/**
* Cross-platform execFileSync.
*/
export function crossExecFileSync(
command: string,
args: string[],
options: ExecFileSyncOptions = {},
) {
return execFileSync(command, args, withShell(options));
}
/**
* Spawn a long-running detached process.
*
* - **Unix**: uses `detached: true` so the child becomes a process-group
* leader (allows group-kill via negative PID).
* - **Windows**: omits `detached` to avoid opening a visible console window.
* The child stays alive as long as the MCP-server process does.
*/
export function spawnDetached(
command: string,
args: string[],
options: SpawnOptions = {},
) {
const child = spawn(command, args, {
...options,
...(IS_WINDOWS ? { shell: true, windowsHide: true } : {}),
detached: !IS_WINDOWS,
stdio: 'ignore',
});
child.unref();
return child;
}
/**
* Kill a process (and its tree on Windows).
*
* - **Unix**: kills the process group via `process.kill(-pid)`, falling back
* to the individual process.
* - **Windows**: uses `taskkill /T /F /PID` which terminates the whole tree.
*/
export async function killProcess(
pid: number,
signal: string = 'SIGTERM',
): Promise<void> {
if (IS_WINDOWS) {
try {
await rawExecFileAsync('taskkill', ['/T', '/F', '/PID', String(pid)], {
windowsHide: true,
});
} catch {
// Process may already be dead.
}
return;
}
try {
process.kill(-pid, signal);
} catch {
try {
process.kill(pid, signal);
} catch {
// Process may already be dead.
}
}
}
/**
* Find which process is listening on a TCP port.
*
* - **Unix**: parses `lsof` output.
* - **Windows**: parses `netstat -ano` output.
*/
export async function getPortProcess(
port: number,
cwd: string,
): Promise<{ pid: number; command: string } | null> {
try {
if (IS_WINDOWS) {
return await getPortProcessWindows(port, cwd);
}
return await getPortProcessUnix(port, cwd);
} catch {
return null;
}
}
/**
* Find running processes whose command line matches a pattern.
*
* - **Unix**: uses `pgrep -fl`.
* - **Windows**: uses PowerShell `Get-CimInstance Win32_Process`.
*/
export async function findProcessesByName(
pattern: string,
cwd: string,
): Promise<Array<{ pid: number; command: string }>> {
try {
if (IS_WINDOWS) {
return await findProcessesByNameWindows(pattern, cwd);
}
return await findProcessesByNameUnix(pattern, cwd);
} catch {
return [];
}
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
function withShell<T extends SpawnOptions | ExecFileOptions>(options: T): T {
if (IS_WINDOWS) {
return { ...options, shell: true, windowsHide: true };
}
return options;
}
async function getPortProcessUnix(port: number, cwd: string) {
const result = await rawExecFileAsync(
'lsof',
['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-Fpc'],
{ cwd },
);
const lines = result.stdout.split('\n').map((l) => l.trim());
const pidLine = lines.find((l) => l.startsWith('p'));
const commandLine = lines.find((l) => l.startsWith('c'));
if (!pidLine || !commandLine) return null;
const pid = Number(pidLine.slice(1));
if (!Number.isFinite(pid)) return null;
return { pid, command: commandLine.slice(1) };
}
async function getPortProcessWindows(port: number, cwd: string) {
const result = await rawExecFileAsync('netstat', ['-ano'], {
cwd,
windowsHide: true,
});
const portStr = `:${port}`;
for (const line of result.stdout.split('\n')) {
const trimmed = line.trim();
if (!trimmed.includes('LISTENING')) continue;
if (!trimmed.includes(portStr)) continue;
// Netstat format: TCP 0.0.0.0:PORT 0.0.0.0:0 LISTENING PID
const parts = trimmed.split(/\s+/);
const pid = Number(parts[parts.length - 1]);
if (!Number.isFinite(pid) || pid === 0) continue;
// Verify the port column actually matches (avoid partial matches)
const localAddr = parts[1] ?? '';
if (localAddr.endsWith(portStr)) {
return { pid, command: 'unknown' };
}
}
return null;
}
async function findProcessesByNameUnix(pattern: string, cwd: string) {
const result = await rawExecFileAsync('pgrep', ['-fl', pattern], { cwd });
return result.stdout
.split('\n')
.filter(Boolean)
.map((line) => {
const spaceIdx = line.indexOf(' ');
if (spaceIdx <= 0) return null;
const pid = Number(line.slice(0, spaceIdx));
const command = line.slice(spaceIdx + 1).trim();
if (!Number.isFinite(pid)) return null;
return { pid, command };
})
.filter((p): p is { pid: number; command: string } => p !== null);
}
async function findProcessesByNameWindows(pattern: string, cwd: string) {
// Convert simple regex-like pattern to a PowerShell -match pattern.
// The patterns used in the codebase (e.g. "stripe.*listen") are already
// valid PowerShell regex.
const psCommand = [
`Get-CimInstance Win32_Process`,
`| Where-Object { $_.CommandLine -match '${pattern.replace(/'/g, "''")}' }`,
`| ForEach-Object { "$($_.ProcessId) $($_.CommandLine)" }`,
].join(' ');
const result = await rawExecFileAsync(
'powershell',
['-NoProfile', '-Command', psCommand],
{ cwd, windowsHide: true },
);
return result.stdout
.split('\n')
.filter(Boolean)
.map((line) => {
const trimmed = line.trim();
const spaceIdx = trimmed.indexOf(' ');
if (spaceIdx <= 0) return null;
const pid = Number(trimmed.slice(0, spaceIdx));
const command = trimmed.slice(spaceIdx + 1).trim();
if (!Number.isFinite(pid)) return null;
return { pid, command };
})
.filter((p): p is { pid: number; command: string } => p !== null);
}