* 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:
Giancarlo Buomprisco
2026-02-26 18:22:35 +08:00
committed by GitHub
parent f3ac595d06
commit ca585e09be
41 changed files with 2322 additions and 1803 deletions

View 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');
});
});

View 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);
}