fix: harden windows support
This commit is contained in:
parent
38dbcb19b0
commit
b7301ab9c2
22
.github/workflows/ci.yml
vendored
22
.github/workflows/ci.yml
vendored
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
52
tests/config-import-paths.test.ts
Normal file
52
tests/config-import-paths.test.ts
Normal 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'));
|
||||
});
|
||||
});
|
||||
55
tests/runtime-process-utils.test.ts
Normal file
55
tests/runtime-process-utils.test.ts
Normal 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)
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user