fix: harden windows support

This commit is contained in:
Peter Steinberger 2025-11-10 16:00:55 +00:00
parent 38dbcb19b0
commit b7301ab9c2
5 changed files with 191 additions and 15 deletions

View File

@ -29,3 +29,25 @@ jobs:
env:
FIRECRAWL_API_KEY: test
LINEAR_API_KEY: test
windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: 10.20.0
run_install: false
- uses: actions/setup-node@v4
with:
node-version: 22
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.1
- run: pnpm install --no-frozen-lockfile
- run: pnpm --version
- run: pnpm check
- run: pnpm build
- run: pnpm test
env:
FIRECRAWL_API_KEY: test
LINEAR_API_KEY: test

View File

@ -5,6 +5,11 @@
### CLI & runtime
- Added a per-login daemon that auto-starts when keep-alive MCP servers (e.g., Chrome DevTools, Mobile MCP, Playwright) are invoked. The daemon keeps STDIO transports alive across agents, exposes `mcporter daemon <start|status|stop>`, and supports idle shutdown plus manual restarts.
- Keep-alive detection now honors the `lifecycle` config flag/env overrides and also inspects STDIO command signatures, so renaming `chrome-devtools` (or other stateful servers) no longer disables the daemon accidentally.
- Windows imports now probe workspace `.cursor/mcp.json`, `%USERPROFILE%\\.cursor\\mcp.json`, `%APPDATA%\\Cursor\\User\\mcp.json`, `.vscode/mcp.json`, and the current Windsurf/Codeium directories so editor-managed MCP servers show up without manual copies on Windows machines.
- Implemented Windows process-tree enumeration/termination (via `powershell.exe Get-CimInstance Win32_Process`) so closing stdio transports tears down the full process tree on Windows just like macOS/Linux, preventing orphaned child servers.
### CI
- Added a `windows-latest` job to the GitHub Actions workflow to run the same install/lint/build/test sequence as Linux, catching platform regressions before release.
## [0.4.5] - 2025-11-10

View File

@ -196,8 +196,7 @@ async function listDescendantPids(rootPid: number): Promise<number[]> {
return [];
}
if (process.platform === 'win32') {
// TODO: implement Windows process tree enumeration if/when needed.
return [];
return listDescendantPidsWindows(rootPid);
}
try {
@ -219,23 +218,42 @@ async function listDescendantPids(rootPid: number): Promise<number[]> {
children.set(ppid, bucket);
}
const result: number[] = [];
const queue = [...(children.get(rootPid) ?? [])];
const seen = new Set<number>(queue);
while (queue.length > 0) {
const current = queue.shift();
if (current === undefined) {
return collectDescendantsFromChildren(rootPid, children);
} catch {
return [];
}
}
async function listDescendantPidsWindows(rootPid: number): Promise<number[]> {
try {
const powershellScript =
'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId | ConvertTo-Json -Compress';
const { stdout } = await execFileAsync('powershell.exe', ['-NoProfile', '-Command', powershellScript]);
const trimmed = stdout.trim();
if (!trimmed) {
return [];
}
const parsed = JSON.parse(trimmed) as
| { ProcessId?: number; ParentProcessId?: number }
| Array<{ ProcessId?: number; ParentProcessId?: number }>;
const entries = Array.isArray(parsed) ? parsed : [parsed];
const children = new Map<number, number[]>();
for (const entry of entries) {
const pidCandidate = entry?.ProcessId;
const ppidCandidate = entry?.ParentProcessId;
if (typeof pidCandidate !== 'number' || typeof ppidCandidate !== 'number') {
continue;
}
result.push(current);
for (const child of children.get(current) ?? []) {
if (!seen.has(child)) {
seen.add(child);
queue.push(child);
}
const pid = Number.isFinite(pidCandidate) ? pidCandidate : undefined;
const ppid = Number.isFinite(ppidCandidate) ? ppidCandidate : undefined;
if (pid === undefined || ppid === undefined) {
continue;
}
const bucket = children.get(ppid) ?? [];
bucket.push(pid);
children.set(ppid, bucket);
}
return result;
return collectDescendantsFromChildren(rootPid, children);
} catch {
return [];
}
@ -258,6 +276,30 @@ async function collectProcessTreePids(rootPid: number): Promise<number[]> {
return [...descendants, rootPid];
}
function collectDescendantsFromChildren(rootPid: number, children: Map<number, number[]>): number[] {
const result: number[] = [];
const queue = [...(children.get(rootPid) ?? [])];
const seen = new Set<number>(queue);
while (queue.length > 0) {
const current = queue.shift();
if (current === undefined) {
continue;
}
result.push(current);
for (const child of children.get(current) ?? []) {
if (!seen.has(child)) {
seen.add(child);
queue.push(child);
}
}
}
return result;
}
export const __testHooks = {
listDescendantPids,
};
async function waitForTreeExit(pids: number[], durationMs: number): Promise<boolean> {
const deadline = Date.now() + durationMs;
while (true) {

View File

@ -0,0 +1,52 @@
import os from 'node:os';
import path from 'node:path';
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
import { pathsForImport } from '../src/config-imports.js';
describe('pathsForImport on Windows', () => {
let platformSpy: ReturnType<typeof vi.spyOn>;
let homeSpy: ReturnType<typeof vi.spyOn>;
const homeDir = path.join(os.tmpdir(), 'mcporter-win-home');
const appData = path.join(homeDir, 'AppData', 'Roaming');
beforeEach(() => {
platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32');
homeSpy = vi.spyOn(os, 'homedir').mockReturnValue(homeDir);
process.env.HOME = homeDir;
process.env.USERPROFILE = homeDir;
process.env.APPDATA = appData;
process.env.XDG_CONFIG_HOME = '';
});
afterEach(() => {
platformSpy.mockRestore();
homeSpy.mockRestore();
delete process.env.HOME;
delete process.env.USERPROFILE;
delete process.env.APPDATA;
delete process.env.XDG_CONFIG_HOME;
});
it('includes Windows Cursor directories and workspace override', () => {
const rootDir = 'C:/repo';
const paths = pathsForImport('cursor', rootDir);
expect(paths).toContain(path.resolve(rootDir, '.cursor', 'mcp.json'));
expect(paths).toContain(path.join(homeDir, '.cursor', 'mcp.json'));
expect(paths).toContain(path.join(appData, 'Cursor', 'User', 'mcp.json'));
});
it('includes Windows windsuf configs across Codeium directories', () => {
const rootDir = 'C:/repo';
const paths = pathsForImport('windsurf', rootDir);
expect(paths).toContain(path.join(appData, 'Codeium', 'windsurf', 'mcp_config.json'));
expect(paths).toContain(path.join(homeDir, '.codeium', 'windsurf', 'mcp_config.json'));
});
it('prefers workspace .vscode/mcp.json before user-level VS Code configs', () => {
const rootDir = 'C:/repo';
const paths = pathsForImport('vscode', rootDir);
expect(paths[0]).toBe(path.resolve(rootDir, '.vscode', 'mcp.json'));
expect(paths).toContain(path.join(appData, 'Code', 'User', 'mcp.json'));
expect(paths).toContain(path.join(appData, 'Code - Insiders', 'User', 'mcp.json'));
});
});

View File

@ -0,0 +1,55 @@
import type { ChildProcess } from 'node:child_process';
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
const execFileMock = vi.fn();
vi.mock('node:child_process', async () => {
const actual = await vi.importActual<typeof import('node:child_process')>('node:child_process');
return {
...actual,
execFile: execFileMock,
};
});
describe('runtime-process-utils Windows process tree', () => {
let platformSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.resetModules();
execFileMock.mockReset();
platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32');
});
afterEach(() => {
platformSpy.mockRestore();
});
it('parses PowerShell output to enumerate descendants', async () => {
const { __testHooks } = await import('../src/runtime-process-utils.js');
const rootPid = process.pid;
const powershellOutput = JSON.stringify([
{ ProcessId: rootPid + 1, ParentProcessId: rootPid },
{ ProcessId: rootPid + 2, ParentProcessId: rootPid + 1 },
{ ProcessId: rootPid + 3, ParentProcessId: 42 },
]);
execFileMock.mockImplementation((command, args, options, callback) => {
const cb = typeof options === 'function' ? options : callback;
if (command === 'powershell.exe') {
cb?.(null, powershellOutput, '');
} else {
cb?.(new Error('unexpected command'));
}
return { pid: 1 } as ChildProcess;
});
const descendants = await __testHooks.listDescendantPids(rootPid);
expect(descendants).toEqual([rootPid + 1, rootPid + 2]);
expect(execFileMock).toHaveBeenCalledWith(
'powershell.exe',
expect.arrayContaining(['-NoProfile', '-Command', expect.stringContaining('Get-CimInstance')]),
expect.any(Object),
expect.any(Function)
);
});
});