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.
This commit is contained in:
committed by
GitHub
parent
f3ac595d06
commit
ca585e09be
291
packages/mcp-server/src/lib/__tests__/process-utils.test.ts
Normal file
291
packages/mcp-server/src/lib/__tests__/process-utils.test.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
import { createServer } from 'node:net';
|
||||
import { afterAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
IS_WINDOWS,
|
||||
crossExecFileSync,
|
||||
execFileAsync,
|
||||
findProcessesByName,
|
||||
getPortProcess,
|
||||
killProcess,
|
||||
spawnDetached,
|
||||
} from '../process-utils';
|
||||
|
||||
const pidsToCleanup: number[] = [];
|
||||
|
||||
afterAll(async () => {
|
||||
for (const pid of pidsToCleanup) {
|
||||
try {
|
||||
await killProcess(pid);
|
||||
} catch {
|
||||
// already dead
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function isCommandAvailable(cmd: string): Promise<boolean> {
|
||||
try {
|
||||
await execFileAsync(cmd, ['--version']);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// pnpm — the command that triggered this whole cross-platform fix.
|
||||
// The MCP tools call pnpm for migrations, seeding, checks, and dev server.
|
||||
// On Windows pnpm is a .cmd file that requires shell: true.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('pnpm commands', () => {
|
||||
it('pnpm --version (used by kit_prerequisites)', async () => {
|
||||
const result = await execFileAsync('pnpm', ['--version']);
|
||||
expect(result.stdout.trim()).toMatch(/^\d+\.\d+\.\d+/);
|
||||
});
|
||||
|
||||
it('pnpm --version sync (used by migrations tool)', () => {
|
||||
const result = crossExecFileSync('pnpm', ['--version'], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
expect(String(result).trim()).toMatch(/^\d+\.\d+\.\d+/);
|
||||
});
|
||||
|
||||
it('pnpm run with unknown script returns non-zero (used by run_checks)', async () => {
|
||||
// run_checks calls: pnpm run <script>
|
||||
// Verify the cross-platform wrapper surfaces the error correctly.
|
||||
await expect(
|
||||
execFileAsync('pnpm', ['run', '__nonexistent_script_xyz__']),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('pnpm ls --json (read-only, similar to deps-advisor outdated)', async () => {
|
||||
const result = await execFileAsync('pnpm', ['ls', '--json', '--depth=0']);
|
||||
// pnpm ls --json returns valid JSON
|
||||
expect(() => JSON.parse(result.stdout)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// git — used by kit_status for branch, modified files, merge checks.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('git commands', () => {
|
||||
it('git --version (used by kit_prerequisites)', async () => {
|
||||
const result = await execFileAsync('git', ['--version']);
|
||||
expect(result.stdout).toContain('git version');
|
||||
});
|
||||
|
||||
it('git rev-parse --abbrev-ref HEAD (used by kit_status)', async () => {
|
||||
const result = await execFileAsync('git', [
|
||||
'rev-parse',
|
||||
'--abbrev-ref',
|
||||
'HEAD',
|
||||
]);
|
||||
|
||||
expect(result.stdout.trim().length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('git status --porcelain (used by kit_status)', async () => {
|
||||
const result = await execFileAsync('git', ['status', '--porcelain']);
|
||||
// Can be empty (clean) or have entries — just shouldn't throw
|
||||
expect(result.stderr).toBe('');
|
||||
});
|
||||
|
||||
it('git log --oneline -1 (common git operation)', async () => {
|
||||
const result = await execFileAsync('git', ['log', '--oneline', '-1']);
|
||||
expect(result.stdout.trim().length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// node — used by kit_prerequisites.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('node commands', () => {
|
||||
it('node --version (used by kit_prerequisites)', async () => {
|
||||
const result = await execFileAsync('node', ['--version']);
|
||||
expect(result.stdout.trim()).toMatch(/^v\d+\.\d+\.\d+$/);
|
||||
});
|
||||
|
||||
it('node -e (used for spawning scripts)', async () => {
|
||||
const result = await execFileAsync('node', ['-e', 'console.log("hello")']);
|
||||
|
||||
expect(result.stdout.trim()).toBe('hello');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// docker — used by kit_dev_start/stop for database and mailbox containers.
|
||||
// Skipped if docker is not installed.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('docker commands', async () => {
|
||||
const hasDocker = await isCommandAvailable('docker');
|
||||
|
||||
it.skipIf(!hasDocker)(
|
||||
'docker --version (used by kit_prerequisites)',
|
||||
async () => {
|
||||
const result = await execFileAsync('docker', ['--version']);
|
||||
expect(result.stdout).toContain('Docker');
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(!hasDocker)(
|
||||
'docker compose version (used before compose up/stop)',
|
||||
async () => {
|
||||
const result = await execFileAsync('docker', ['compose', 'version']);
|
||||
expect(result.stdout).toContain('Docker Compose');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Port operations — TCP socket check + getPortProcess.
|
||||
// Used by kit_dev_status, kit_db_status, mailbox status to detect running
|
||||
// services on specific ports (3000, 54333, 8025).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('port operations', () => {
|
||||
it('getPortProcess finds listener on a bound port', async () => {
|
||||
const server = createServer();
|
||||
|
||||
const port = await new Promise<number>((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const addr = server.address();
|
||||
resolve(typeof addr === 'object' && addr ? addr.port : 0);
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
const proc = await getPortProcess(port, process.cwd());
|
||||
|
||||
expect(proc).not.toBeNull();
|
||||
expect(proc!.pid).toBeGreaterThan(0);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('getPortProcess returns null for port with no listener', async () => {
|
||||
// Use a high port unlikely to be in use
|
||||
const proc = await getPortProcess(59998, process.cwd());
|
||||
expect(proc).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Process lifecycle — spawnDetached + findProcessesByName + killProcess.
|
||||
// The dev tools spawn pnpm and stripe as detached processes, find them by
|
||||
// pattern (e.g. "stripe.*listen"), and kill them via PID/group.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('process lifecycle', () => {
|
||||
it('spawn a long-running node process, find it, then kill it', async () => {
|
||||
// Spawn a detached node process (mirrors how pnpm exec next dev works)
|
||||
const marker = `__test_marker_${Date.now()}__`;
|
||||
|
||||
const child = spawnDetached('node', [
|
||||
'-e',
|
||||
`process.title = "${marker}"; setTimeout(() => {}, 60000)`,
|
||||
]);
|
||||
|
||||
expect(child.pid).toBeGreaterThan(0);
|
||||
pidsToCleanup.push(child.pid!);
|
||||
|
||||
await sleep(500);
|
||||
|
||||
// Verify it's alive (process.kill(pid, 0) is cross-platform)
|
||||
expect(() => process.kill(child.pid!, 0)).not.toThrow();
|
||||
|
||||
// findProcessesByName should find it (mirrors "stripe.*listen" pattern)
|
||||
const found = await findProcessesByName(marker, process.cwd());
|
||||
expect(found.length).toBeGreaterThan(0);
|
||||
|
||||
// Kill it (mirrors kit_dev_stop stopping services)
|
||||
await killProcess(child.pid!);
|
||||
await sleep(300);
|
||||
|
||||
// Verify it's dead
|
||||
expect(() => process.kill(child.pid!, 0)).toThrow();
|
||||
});
|
||||
|
||||
it('spawn a pnpm process via spawnDetached', async () => {
|
||||
// This is exactly how kit_dev_start spawns the Next.js dev server:
|
||||
// spawnDetached('pnpm', ['exec', 'node', '-e', '...'])
|
||||
const child = spawnDetached('pnpm', [
|
||||
'exec',
|
||||
'node',
|
||||
'-e',
|
||||
'setTimeout(() => {}, 60000)',
|
||||
]);
|
||||
|
||||
expect(child.pid).toBeGreaterThan(0);
|
||||
pidsToCleanup.push(child.pid!);
|
||||
|
||||
await sleep(500);
|
||||
|
||||
// Verify pnpm-spawned process is alive
|
||||
expect(() => process.kill(child.pid!, 0)).not.toThrow();
|
||||
|
||||
// Clean up
|
||||
await killProcess(child.pid!);
|
||||
await sleep(300);
|
||||
});
|
||||
|
||||
it('findProcessesByName returns empty for nonexistent pattern', async () => {
|
||||
const procs = await findProcessesByName(
|
||||
'zzz_no_such_process_12345',
|
||||
process.cwd(),
|
||||
);
|
||||
|
||||
expect(procs).toEqual([]);
|
||||
});
|
||||
|
||||
it('killProcess does not throw for already-dead PID', async () => {
|
||||
const child = spawnDetached('node', ['-e', 'process.exit(0)']);
|
||||
|
||||
pidsToCleanup.push(child.pid!);
|
||||
await sleep(300);
|
||||
|
||||
// Process already exited — killProcess should not throw
|
||||
await expect(killProcess(child.pid!)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error handling — unknown commands should reject cleanly.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('error handling', () => {
|
||||
it('execFileAsync rejects for unknown command', async () => {
|
||||
await expect(
|
||||
execFileAsync('__nonexistent_cmd_xyz__', ['--help']),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('crossExecFileSync throws for unknown command', () => {
|
||||
expect(() =>
|
||||
crossExecFileSync('__nonexistent_cmd_xyz__', ['--help']),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Platform constant
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('IS_WINDOWS', () => {
|
||||
it('matches process.platform', () => {
|
||||
expect(IS_WINDOWS).toBe(process.platform === 'win32');
|
||||
});
|
||||
});
|
||||
263
packages/mcp-server/src/lib/process-utils.ts
Normal file
263
packages/mcp-server/src/lib/process-utils.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user