chore: initialize qiming workspace repository
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* 单元测试: logRedact
|
||||
*
|
||||
* 测试日志脱敏逻辑
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { redactForLog, redactStringForLog } from './logRedact';
|
||||
|
||||
describe('logRedact', () => {
|
||||
describe('redactForLog', () => {
|
||||
describe('基本脱敏', () => {
|
||||
it('should mask api_key', () => {
|
||||
const input = { api_key: 'ak-1234567890abcdef' };
|
||||
const result = redactForLog(input) as Record<string, string>;
|
||||
expect(result.api_key).toBe('ak-12345...');
|
||||
expect(result.api_key).not.toContain('90abcdef');
|
||||
});
|
||||
|
||||
it('should mask token', () => {
|
||||
const input = { token: 'my-secret-token-12345' };
|
||||
const result = redactForLog(input) as Record<string, string>;
|
||||
expect(result.token).toContain('...');
|
||||
expect(result.token).not.toContain('secret');
|
||||
});
|
||||
|
||||
it('should mask password (long value shows prefix)', () => {
|
||||
const input = { password: 'supersecretpassword' };
|
||||
const result = redactForLog(input) as Record<string, string>;
|
||||
// 长值显示前缀 + "..."
|
||||
expect(result.password).toContain('...');
|
||||
expect(result.password).not.toContain('secretpassword');
|
||||
});
|
||||
|
||||
it('should mask short values as ***', () => {
|
||||
const input = { api_key: 'short' };
|
||||
const result = redactForLog(input) as Record<string, string>;
|
||||
expect(result.api_key).toBe('***');
|
||||
});
|
||||
});
|
||||
|
||||
describe('模式匹配', () => {
|
||||
it('should match *_API_KEY pattern', () => {
|
||||
const input = { CUSTOM_API_KEY: 'ak-custom-key-12345678' };
|
||||
const result = redactForLog(input) as Record<string, string>;
|
||||
expect(result.CUSTOM_API_KEY).toContain('...');
|
||||
});
|
||||
|
||||
it('should match *_TOKEN pattern', () => {
|
||||
const input = { SESSION_TOKEN: 'session-token-abcdefg' };
|
||||
const result = redactForLog(input) as Record<string, string>;
|
||||
expect(result.SESSION_TOKEN).toContain('...');
|
||||
});
|
||||
|
||||
it('should match *_SECRET pattern', () => {
|
||||
const input = { APP_SECRET: 'app-secret-value' };
|
||||
const result = redactForLog(input) as Record<string, string>;
|
||||
expect(result.APP_SECRET).toContain('...');
|
||||
});
|
||||
});
|
||||
|
||||
describe('特殊键名', () => {
|
||||
it('should mask anthropic_api_key', () => {
|
||||
const input = { anthropic_api_key: 'sk-ant-1234567890' };
|
||||
const result = redactForLog(input) as Record<string, string>;
|
||||
expect(result.anthropic_api_key).toContain('...');
|
||||
});
|
||||
|
||||
it('should mask openai_api_key', () => {
|
||||
const input = { openai_api_key: 'sk-openai-1234567890' };
|
||||
const result = redactForLog(input) as Record<string, string>;
|
||||
expect(result.openai_api_key).toContain('...');
|
||||
});
|
||||
|
||||
it('should mask amap_maps_api_key', () => {
|
||||
const input = { amap_maps_api_key: 'amap-key-12345678' };
|
||||
const result = redactForLog(input) as Record<string, string>;
|
||||
expect(result.amap_maps_api_key).toContain('...');
|
||||
});
|
||||
});
|
||||
|
||||
describe('非字符串值处理', () => {
|
||||
it('should handle null value', () => {
|
||||
const input = { api_key: null };
|
||||
const result = redactForLog(input) as Record<string, null>;
|
||||
expect(result.api_key).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle undefined value (treated as *** for sensitive key)', () => {
|
||||
const input = { api_key: undefined };
|
||||
const result = redactForLog(input) as Record<string, unknown>;
|
||||
// 敏感键的 undefined 值被替换为 '***'
|
||||
expect(result.api_key).toBe('***');
|
||||
});
|
||||
|
||||
it('should handle number value as ***', () => {
|
||||
const input = { api_key: 12345 };
|
||||
const result = redactForLog(input) as Record<string, unknown>;
|
||||
expect(result.api_key).toBe('***');
|
||||
});
|
||||
|
||||
it('should handle boolean value as ***', () => {
|
||||
const input = { api_key: true };
|
||||
const result = redactForLog(input) as Record<string, unknown>;
|
||||
expect(result.api_key).toBe('***');
|
||||
});
|
||||
|
||||
it('should handle nested object', () => {
|
||||
const input = { env: { api_key: 'nested-key-12345678' } };
|
||||
const result = redactForLog(input) as Record<string, unknown>;
|
||||
const env = result.env as Record<string, string>;
|
||||
expect(env.api_key).toContain('...');
|
||||
});
|
||||
});
|
||||
|
||||
describe('数组和嵌套', () => {
|
||||
it('should handle array', () => {
|
||||
const input = [
|
||||
{ api_key: 'key1-12345678' },
|
||||
{ api_key: 'key2-12345678' },
|
||||
];
|
||||
const result = redactForLog(input) as Array<Record<string, string>>;
|
||||
expect(result[0].api_key).toContain('...');
|
||||
expect(result[1].api_key).toContain('...');
|
||||
});
|
||||
|
||||
it('should handle deeply nested object', () => {
|
||||
const input = {
|
||||
level1: {
|
||||
level2: {
|
||||
api_key: 'deep-key-12345678',
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = redactForLog(input) as Record<string, unknown>;
|
||||
const level1 = result.level1 as Record<string, unknown>;
|
||||
const level2 = level1.level2 as Record<string, string>;
|
||||
expect(level2.api_key).toContain('...');
|
||||
});
|
||||
});
|
||||
|
||||
describe('不修改原对象', () => {
|
||||
it('should not modify original object', () => {
|
||||
const input = { api_key: 'original-key-12345678' };
|
||||
const originalValue = input.api_key;
|
||||
redactForLog(input);
|
||||
expect(input.api_key).toBe(originalValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe('非敏感键保留', () => {
|
||||
it('should preserve non-sensitive keys', () => {
|
||||
const input = {
|
||||
user_id: 'user123',
|
||||
project_id: 'proj456',
|
||||
api_key: 'secret-key-12345678',
|
||||
};
|
||||
const result = redactForLog(input) as Record<string, unknown>;
|
||||
expect(result.user_id).toBe('user123');
|
||||
expect(result.project_id).toBe('proj456');
|
||||
expect(result.api_key).toContain('...');
|
||||
});
|
||||
});
|
||||
|
||||
describe('原始类型', () => {
|
||||
it('should return null as-is', () => {
|
||||
expect(redactForLog(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return undefined as-is', () => {
|
||||
expect(redactForLog(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return string as-is', () => {
|
||||
expect(redactForLog('hello')).toBe('hello');
|
||||
});
|
||||
|
||||
it('should return number as-is', () => {
|
||||
expect(redactForLog(42)).toBe(42);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('redactStringForLog', () => {
|
||||
describe('JSON 键值对', () => {
|
||||
it('should mask api_key in JSON string', () => {
|
||||
const input = '{"api_key": "ak-1234567890abcdef"}';
|
||||
const result = redactStringForLog(input);
|
||||
expect(result).toContain('ak-12345...');
|
||||
expect(result).not.toContain('90abcdef');
|
||||
});
|
||||
|
||||
it('should mask ANTHROPIC_API_KEY in JSON string', () => {
|
||||
const input = '{"ANTHROPIC_API_KEY": "sk-ant-1234567890"}';
|
||||
const result = redactStringForLog(input);
|
||||
expect(result).toContain('...');
|
||||
});
|
||||
|
||||
it('should be case-insensitive', () => {
|
||||
const input = '{"API_KEY": "ak-1234567890"}';
|
||||
const result = redactStringForLog(input);
|
||||
expect(result).toContain('...');
|
||||
});
|
||||
});
|
||||
|
||||
describe('URL 参数', () => {
|
||||
it('should mask ak= in URL', () => {
|
||||
const input = 'https://example.com/api?ak=my-secret-key';
|
||||
const result = redactStringForLog(input);
|
||||
expect(result).toContain('ak=***');
|
||||
expect(result).not.toContain('my-secret-key');
|
||||
});
|
||||
});
|
||||
|
||||
describe('API key 前缀', () => {
|
||||
it('should mask ak- prefix', () => {
|
||||
const input = 'key: ak-1234567890abcdef';
|
||||
const result = redactStringForLog(input);
|
||||
expect(result).toBe('key: ak-12345678...');
|
||||
});
|
||||
|
||||
it('should mask sk- prefix', () => {
|
||||
const input = 'key: sk-1234567890abcdef';
|
||||
const result = redactStringForLog(input);
|
||||
expect(result).toBe('key: sk-12345678...');
|
||||
});
|
||||
|
||||
it('should mask sak- prefix', () => {
|
||||
const input = 'key: sak-1234567890abcdef';
|
||||
const result = redactStringForLog(input);
|
||||
// sak- 前缀保留 4 字符(sak-)+ 8 字符 = 12 字符
|
||||
expect(result).toBe('key: sak-12345678...');
|
||||
});
|
||||
|
||||
it('should mask pk- prefix', () => {
|
||||
const input = 'key: pk-1234567890abcdef';
|
||||
const result = redactStringForLog(input);
|
||||
expect(result).toBe('key: pk-12345678...');
|
||||
});
|
||||
|
||||
it('should mask Bearer token', () => {
|
||||
const input = 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9';
|
||||
const result = redactStringForLog(input);
|
||||
expect(result).toContain('Bearer eyJhbGci...');
|
||||
expect(result).not.toContain('IkpXVCJ9');
|
||||
});
|
||||
});
|
||||
|
||||
describe('边界情况', () => {
|
||||
it('should handle non-string input', () => {
|
||||
expect(redactStringForLog(null as any)).toBe('null');
|
||||
expect(redactStringForLog(undefined as any)).toBe('undefined');
|
||||
expect(redactStringForLog(123 as any)).toBe('123');
|
||||
});
|
||||
|
||||
it('should handle empty string', () => {
|
||||
expect(redactStringForLog('')).toBe('');
|
||||
});
|
||||
|
||||
it('should handle string without sensitive data', () => {
|
||||
const input = '{"user_id": "123", "name": "test"}';
|
||||
expect(redactStringForLog(input)).toBe(input);
|
||||
});
|
||||
|
||||
it('should handle short API key (<=8 chars)', () => {
|
||||
const input = 'key: ak-short';
|
||||
// 短 key 不匹配 ak-[a-zA-Z0-9]{8} 模式
|
||||
expect(redactStringForLog(input)).toBe('key: ak-short');
|
||||
});
|
||||
});
|
||||
|
||||
describe('多个匹配', () => {
|
||||
it('should mask multiple keys', () => {
|
||||
const input = '{"api_key": "ak-1111111111", "token": "sk-2222222222"}';
|
||||
const result = redactStringForLog(input);
|
||||
// api_key 被 JSON 键匹配处理,保留 min(8, floor(12/2))=6 字符
|
||||
expect(result).toContain('ak-111...');
|
||||
// token 被 sk- 前缀正则匹配,保留 sk- 后 8 字符
|
||||
expect(result).toContain('sk-22222222...');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* 日志脱敏:避免将 api_key、token、agent_config.env 等敏感信息写入日志。
|
||||
*
|
||||
* 使用方式:在 log.info/debug 前对对象调用 redactForLog(obj),或对字符串调用 redactStringForLog(s).
|
||||
*
|
||||
* 本地调试需要看完整密钥时(勿用于生产/勿提交日志):通过 .env.development 配置
|
||||
* `QIMING_AGENT_LOG_FULL_SECRETS=true`,将跳过脱敏。
|
||||
*/
|
||||
|
||||
import { FEATURES } from "@shared/featureFlags";
|
||||
|
||||
/**
|
||||
* 未设置 LOG_FULL_SECRETS 时默认为 false → 始终脱敏。
|
||||
* 为 true 时不脱敏(仅本机调试用)。
|
||||
*/
|
||||
const logRedactDisabled: boolean = FEATURES.LOG_FULL_SECRETS;
|
||||
|
||||
/** 需要脱敏的键名(小写匹配),值替换为前缀 + "..." */
|
||||
const SENSITIVE_KEYS_LOWER = new Set([
|
||||
"api_key",
|
||||
"apikey",
|
||||
"anthropic_api_key",
|
||||
"anthropic_auth_token",
|
||||
"openai_api_key",
|
||||
"sandbox_access_key",
|
||||
"auth_token",
|
||||
"access_key",
|
||||
"secret",
|
||||
"password",
|
||||
"token",
|
||||
"amap_maps_api_key",
|
||||
]);
|
||||
|
||||
/** 键名匹配这些模式则脱敏(如 *_API_KEY, *_TOKEN) */
|
||||
const SENSITIVE_KEY_PATTERNS =
|
||||
/_(?:API_KEY|AUTH_TOKEN|ACCESS_KEY|SECRET|PASSWORD|TOKEN)$/i;
|
||||
|
||||
/** 脱敏后显示的前缀长度(ak-xxxx 保留前 8 个字符) */
|
||||
const MASK_PREFIX_LEN = 8;
|
||||
|
||||
function maskValue(value: string): string {
|
||||
if (logRedactDisabled) return value;
|
||||
if (!value || typeof value !== "string") return "***";
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length <= MASK_PREFIX_LEN) return "***";
|
||||
return (
|
||||
trimmed.slice(
|
||||
0,
|
||||
Math.min(MASK_PREFIX_LEN, Math.floor(trimmed.length / 2)),
|
||||
) + "..."
|
||||
);
|
||||
}
|
||||
|
||||
function isSensitiveKey(key: string): boolean {
|
||||
const lower = key.toLowerCase();
|
||||
if (SENSITIVE_KEYS_LOWER.has(lower)) return true;
|
||||
if (SENSITIVE_KEY_PATTERNS.test(key)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 深拷贝对象并在拷贝中脱敏敏感字段(不修改原对象)
|
||||
*/
|
||||
export function redactForLog(obj: unknown): unknown {
|
||||
if (logRedactDisabled) return obj;
|
||||
if (obj === null || typeof obj !== "object") {
|
||||
return obj;
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((item) => redactForLog(item));
|
||||
}
|
||||
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
|
||||
if (isSensitiveKey(key)) {
|
||||
if (value === null) {
|
||||
out[key] = null;
|
||||
} else if (typeof value === "string") {
|
||||
out[key] = maskValue(value);
|
||||
} else if (typeof value === "object") {
|
||||
// 嵌套对象也脱敏(如 env.api_key)
|
||||
out[key] = redactForLog(value);
|
||||
} else {
|
||||
out[key] = "***";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
out[key] = redactForLog(value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 对 JSON 字符串中的敏感值做正则脱敏(用于已序列化的 agent_config 等)
|
||||
* - 匹配 "key": "ak-xxx" / "key": "sk-xxx" 等
|
||||
* - 匹配 URL 中的 ak=xxx
|
||||
* - 匹配常见 API key 前缀:ak-, sk-, sak-, pk-,Bearer 等
|
||||
*/
|
||||
export function redactStringForLog(s: string): string {
|
||||
if (typeof s !== "string") return String(s);
|
||||
if (logRedactDisabled) return s;
|
||||
return (
|
||||
s
|
||||
.replace(
|
||||
/"((?:ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|SANDBOX_ACCESS_KEY|OPENAI_API_KEY|api_key|apiKey|AMAP_MAPS_API_KEY))"\s*:\s*"([^"]+)"/gi,
|
||||
(_, k, v) => {
|
||||
return `"${k}":"${maskValue(v)}"`;
|
||||
},
|
||||
)
|
||||
.replace(/([?&]ak=)([a-zA-Z0-9_-]+)/g, (_, prefix) => `${prefix}***`)
|
||||
.replace(/([?&]ts=)([a-zA-Z0-9_-]+)/g, (_, prefix) => `${prefix}***`)
|
||||
// 匹配常见 API key 前缀:ak-, sk-, sak-, pk-,Bearer
|
||||
.replace(/(ak-[a-zA-Z0-9]{8})[a-zA-Z0-9_-]+/g, "$1...")
|
||||
.replace(/(sk-[a-zA-Z0-9]{8})[a-zA-Z0-9_-]+/g, "$1...")
|
||||
.replace(/(sak-[a-zA-Z0-9]{8})[a-zA-Z0-9_-]+/g, "$1...")
|
||||
.replace(/(pk-[a-zA-Z0-9]{8})[a-zA-Z0-9_-]+/g, "$1...")
|
||||
// Bearer token
|
||||
.replace(/(Bearer\s+)([a-zA-Z0-9_-]{8})[a-zA-Z0-9_-]+/gi, "$1$2...")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
/**
|
||||
* Unit tests: processTree (process tree kill utility)
|
||||
*
|
||||
* Covers:
|
||||
* 1. killProcessTree on Unix — group kill, descendant fallback, ESRCH
|
||||
* 2. killProcessTree on Windows — taskkill args, exit code 128, other errors
|
||||
* 3. killProcessTreeGraceful — SIGTERM-only, SIGKILL escalation
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// ── Mocks ──────────────────────────────────────────────────
|
||||
|
||||
const mockExecFile = vi.fn();
|
||||
|
||||
vi.mock("child_process", () => ({
|
||||
execFile: (...args: unknown[]) => mockExecFile(...args),
|
||||
}));
|
||||
|
||||
const mockLog = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("electron-log", () => ({ default: mockLog }));
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Set up mockExecFile to handle pgrep calls (return no children by default).
|
||||
* Can be overridden by setting up a custom implementation before the test.
|
||||
*/
|
||||
function mockPgrepNoChildren() {
|
||||
mockExecFile.mockImplementation(
|
||||
(
|
||||
cmd: string,
|
||||
_args: string[],
|
||||
cb: (err: Error | null, stdout?: string) => void,
|
||||
) => {
|
||||
if (cmd === "pgrep") {
|
||||
// No children found
|
||||
cb(new Error("no match"), "");
|
||||
return;
|
||||
}
|
||||
cb(null);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────
|
||||
|
||||
describe("processTree", () => {
|
||||
let processKillSpy: ReturnType<typeof vi.spyOn>;
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
processKillSpy = vi.spyOn(process, "kill").mockImplementation(
|
||||
// Default: process.kill succeeds (no-op)
|
||||
() => true,
|
||||
);
|
||||
mockPgrepNoChildren();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
processKillSpy.mockRestore();
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: originalPlatform,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
function setPlatform(platform: string) {
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: platform,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
// ── killProcessTree (Unix) ──
|
||||
|
||||
describe("killProcessTree (Unix)", () => {
|
||||
it("should call process.kill(-pid, signal) for group kill", async () => {
|
||||
setPlatform("darwin");
|
||||
|
||||
const { killProcessTree } = await import("./processTree");
|
||||
await killProcessTree(1234, "SIGTERM");
|
||||
|
||||
expect(processKillSpy).toHaveBeenCalledWith(-1234, "SIGTERM");
|
||||
});
|
||||
|
||||
it("should fall back to descendant kill when group kill fails with non-ESRCH error", async () => {
|
||||
setPlatform("darwin");
|
||||
|
||||
const epermError = Object.assign(new Error("EPERM"), { code: "EPERM" });
|
||||
processKillSpy.mockImplementation(
|
||||
(pid: number, _signal?: string | number) => {
|
||||
if (pid < 0) throw epermError; // group kill fails
|
||||
return true; // direct kill succeeds
|
||||
},
|
||||
);
|
||||
|
||||
const { killProcessTree } = await import("./processTree");
|
||||
await killProcessTree(1234, "SIGTERM");
|
||||
|
||||
// First call: group kill (-1234) — fails with EPERM
|
||||
expect(processKillSpy).toHaveBeenCalledWith(-1234, "SIGTERM");
|
||||
// Falls back to descendant kill: pgrep returns no children, so kills root directly
|
||||
expect(processKillSpy).toHaveBeenCalledWith(1234, "SIGTERM");
|
||||
});
|
||||
|
||||
it("should fall back to descendant kill on ESRCH (group doesn't exist but process may be alive)", async () => {
|
||||
setPlatform("darwin");
|
||||
|
||||
const esrchError = Object.assign(new Error("ESRCH"), { code: "ESRCH" });
|
||||
processKillSpy.mockImplementation(
|
||||
(pid: number, _signal?: string | number) => {
|
||||
if (pid < 0) throw esrchError; // group doesn't exist
|
||||
return true; // direct kill succeeds
|
||||
},
|
||||
);
|
||||
|
||||
const { killProcessTree } = await import("./processTree");
|
||||
await killProcessTree(1234, "SIGTERM");
|
||||
|
||||
// Group kill attempted first
|
||||
expect(processKillSpy).toHaveBeenCalledWith(-1234, "SIGTERM");
|
||||
// Falls back to descendant kill: kills root process directly
|
||||
expect(processKillSpy).toHaveBeenCalledWith(1234, "SIGTERM");
|
||||
});
|
||||
|
||||
it("should kill descendants found by pgrep", async () => {
|
||||
setPlatform("darwin");
|
||||
|
||||
const esrchError = Object.assign(new Error("ESRCH"), { code: "ESRCH" });
|
||||
processKillSpy.mockImplementation(
|
||||
(pid: number, _signal?: string | number) => {
|
||||
if (pid < 0) throw esrchError; // group doesn't exist
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
// pgrep returns children for pid 1234, no grandchildren
|
||||
mockExecFile.mockImplementation(
|
||||
(
|
||||
cmd: string,
|
||||
args: string[],
|
||||
cb: (err: Error | null, stdout?: string) => void,
|
||||
) => {
|
||||
if (cmd === "pgrep" && args[1] === "1234") {
|
||||
cb(null, "5555\n6666\n");
|
||||
return;
|
||||
}
|
||||
if (cmd === "pgrep") {
|
||||
cb(new Error("no match"), "");
|
||||
return;
|
||||
}
|
||||
cb(null);
|
||||
},
|
||||
);
|
||||
|
||||
const { killProcessTree } = await import("./processTree");
|
||||
await killProcessTree(1234, "SIGTERM");
|
||||
|
||||
// Should kill children (bottom-up: reversed) then root
|
||||
expect(processKillSpy).toHaveBeenCalledWith(6666, "SIGTERM");
|
||||
expect(processKillSpy).toHaveBeenCalledWith(5555, "SIGTERM");
|
||||
expect(processKillSpy).toHaveBeenCalledWith(1234, "SIGTERM");
|
||||
});
|
||||
|
||||
it("should use SIGTERM as default signal", async () => {
|
||||
setPlatform("darwin");
|
||||
|
||||
const { killProcessTree } = await import("./processTree");
|
||||
await killProcessTree(5678);
|
||||
|
||||
expect(processKillSpy).toHaveBeenCalledWith(-5678, "SIGTERM");
|
||||
});
|
||||
});
|
||||
|
||||
// ── killProcessTree (Windows) ──
|
||||
|
||||
describe("killProcessTree (Windows)", () => {
|
||||
it("should call taskkill with correct args", async () => {
|
||||
setPlatform("win32");
|
||||
|
||||
mockExecFile.mockImplementation(((...args: unknown[]) => {
|
||||
const cb = args[args.length - 1] as (err: Error | null) => void;
|
||||
cb(null);
|
||||
}) as typeof mockExecFile);
|
||||
|
||||
const { killProcessTree } = await import("./processTree");
|
||||
await killProcessTree(4567);
|
||||
|
||||
expect(mockExecFile).toHaveBeenCalledWith(
|
||||
"taskkill",
|
||||
["/T", "/F", "/PID", "4567"],
|
||||
{ windowsHide: true },
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it("should resolve when process not found (exit code 128)", async () => {
|
||||
setPlatform("win32");
|
||||
|
||||
mockExecFile.mockImplementation(((...args: unknown[]) => {
|
||||
const cb = args[args.length - 1] as (err: Error | null) => void;
|
||||
const err = Object.assign(new Error("process not found"), {
|
||||
code: 128,
|
||||
});
|
||||
cb(err);
|
||||
}) as typeof mockExecFile);
|
||||
|
||||
const { killProcessTree } = await import("./processTree");
|
||||
await expect(killProcessTree(4567)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("should resolve when process not found (ESRCH code)", async () => {
|
||||
setPlatform("win32");
|
||||
|
||||
mockExecFile.mockImplementation(((...args: unknown[]) => {
|
||||
const cb = args[args.length - 1] as (err: Error | null) => void;
|
||||
const err = Object.assign(new Error("no such process"), {
|
||||
code: "ESRCH",
|
||||
});
|
||||
cb(err);
|
||||
}) as typeof mockExecFile);
|
||||
|
||||
const { killProcessTree } = await import("./processTree");
|
||||
await expect(killProcessTree(4567)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("should reject on other errors", async () => {
|
||||
setPlatform("win32");
|
||||
|
||||
mockExecFile.mockImplementation(((...args: unknown[]) => {
|
||||
const cb = args[args.length - 1] as (err: Error | null) => void;
|
||||
const err = Object.assign(new Error("access denied"), {
|
||||
code: 5,
|
||||
});
|
||||
cb(err);
|
||||
}) as typeof mockExecFile);
|
||||
|
||||
const { killProcessTree } = await import("./processTree");
|
||||
await expect(killProcessTree(4567)).rejects.toThrow("access denied");
|
||||
});
|
||||
});
|
||||
|
||||
// ── killProcessTreeGraceful ──
|
||||
|
||||
describe("killProcessTreeGraceful", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("should only send SIGTERM when process exits promptly", async () => {
|
||||
setPlatform("darwin");
|
||||
|
||||
let killCallCount = 0;
|
||||
processKillSpy.mockImplementation(
|
||||
(pid: number, signal?: string | number) => {
|
||||
killCallCount++;
|
||||
// First call: group kill with SIGTERM (succeeds)
|
||||
if (killCallCount === 1) {
|
||||
expect(pid).toBe(-9999);
|
||||
expect(signal).toBe("SIGTERM");
|
||||
return true;
|
||||
}
|
||||
// Second call: waitForExit check with signal 0 → process gone
|
||||
if (signal === 0) {
|
||||
const err = Object.assign(new Error("ESRCH"), { code: "ESRCH" });
|
||||
throw err;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
const { killProcessTreeGraceful } = await import("./processTree");
|
||||
const promise = killProcessTreeGraceful(9999, 3000);
|
||||
|
||||
// Advance timers to let waitForExit polling run
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
await promise;
|
||||
|
||||
// SIGKILL should NOT have been sent — no log.warn about escalation
|
||||
const warnCalls = mockLog.warn.mock.calls.map((c) => c[0]);
|
||||
expect(warnCalls).not.toContain(
|
||||
expect.stringContaining("sending SIGKILL"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should escalate to SIGKILL when process does not exit within timeout", async () => {
|
||||
setPlatform("darwin");
|
||||
|
||||
let sigkillSent = false;
|
||||
processKillSpy.mockImplementation(
|
||||
(pid: number, signal?: string | number) => {
|
||||
// Group kill calls (negative pid)
|
||||
if (pid < 0) {
|
||||
if (signal === "SIGKILL") {
|
||||
sigkillSent = true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// waitForExit: signal 0 checks — process stays alive until SIGKILL sent
|
||||
if (signal === 0) {
|
||||
if (sigkillSent) {
|
||||
const err = Object.assign(new Error("ESRCH"), { code: "ESRCH" });
|
||||
throw err;
|
||||
}
|
||||
return true; // still alive
|
||||
}
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
const { killProcessTreeGraceful } = await import("./processTree");
|
||||
const promise = killProcessTreeGraceful(9999, 1000);
|
||||
|
||||
// Advance past the timeout to trigger SIGKILL escalation
|
||||
// waitForExit polls every 200ms, so we need to advance enough times
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
}
|
||||
|
||||
await promise;
|
||||
|
||||
expect(sigkillSent).toBe(true);
|
||||
expect(mockLog.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("sending SIGKILL"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectListeningPidsOnPortFromNetstatStdout", () => {
|
||||
it("returns PIDs for 127.0.0.1 LISTENING", async () => {
|
||||
const { collectListeningPidsOnPortFromNetstatStdout } =
|
||||
await import("./processTree");
|
||||
const sample = `
|
||||
TCP 127.0.0.1:60008 0.0.0.0:0 LISTENING 12345
|
||||
TCP 0.0.0.0:135 0.0.0.0:0 LISTENING 4
|
||||
`;
|
||||
expect(
|
||||
collectListeningPidsOnPortFromNetstatStdout(sample, 60008),
|
||||
).toEqual([12345]);
|
||||
});
|
||||
|
||||
it("ignores ESTABLISHED when port only appears on remote side", async () => {
|
||||
const { collectListeningPidsOnPortFromNetstatStdout } =
|
||||
await import("./processTree");
|
||||
const sample =
|
||||
" TCP 127.0.0.1:80 192.168.1.1:60008 ESTABLISHED 99999";
|
||||
expect(
|
||||
collectListeningPidsOnPortFromNetstatStdout(sample, 60008),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("parses Chinese 监听 line", async () => {
|
||||
const { collectListeningPidsOnPortFromNetstatStdout } =
|
||||
await import("./processTree");
|
||||
const sample =
|
||||
" TCP 127.0.0.1:60008 0.0.0.0:0 监听 23456";
|
||||
expect(
|
||||
collectListeningPidsOnPortFromNetstatStdout(sample, 60008),
|
||||
).toEqual([23456]);
|
||||
});
|
||||
|
||||
it("parses [::ffff:127.0.0.1]:port (IPv4-mapped)", async () => {
|
||||
const { collectListeningPidsOnPortFromNetstatStdout } =
|
||||
await import("./processTree");
|
||||
const sample =
|
||||
" TCP [::ffff:127.0.0.1]:60008 [::]:0 LISTENING 77777";
|
||||
expect(
|
||||
collectListeningPidsOnPortFromNetstatStdout(sample, 60008),
|
||||
).toEqual([77777]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,486 @@
|
||||
/**
|
||||
* Process tree kill utility.
|
||||
*
|
||||
* Kills a process and all its descendants to prevent zombie processes.
|
||||
*
|
||||
* Strategy (Unix):
|
||||
* 1. Try process group kill (-pid) — works when spawned with detached: true
|
||||
* and setsid() succeeds (PGID = pid).
|
||||
* 2. Fall back to recursive descendant kill via `pgrep -P` — handles the case
|
||||
* where detached: true didn't create a new process group (e.g. dev mode
|
||||
* under make → Electron, where all processes share the terminal's PGID).
|
||||
* 3. Kill the root process directly.
|
||||
*
|
||||
* Windows: Uses taskkill /T /F /PID for tree kill.
|
||||
*/
|
||||
|
||||
import { execFile } from "child_process";
|
||||
import * as fs from "fs";
|
||||
import log from "electron-log";
|
||||
import { createPlatformAdapter } from "../system/platformAdapter";
|
||||
|
||||
const isWindows = (): boolean => createPlatformAdapter().isWindows;
|
||||
|
||||
/**
|
||||
* Kill a process tree (the process and all its descendants).
|
||||
*
|
||||
* @param pid - Process ID to kill
|
||||
* @param signal - Signal to send (default: SIGTERM). Ignored on Windows (always force-kills).
|
||||
*/
|
||||
export async function killProcessTree(
|
||||
pid: number,
|
||||
signal: NodeJS.Signals = "SIGTERM",
|
||||
): Promise<void> {
|
||||
if (isWindows()) {
|
||||
return killProcessTreeWindows(pid);
|
||||
}
|
||||
return killProcessTreeUnix(pid, signal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a process tree with graceful escalation:
|
||||
* 1. Send SIGTERM to the process tree
|
||||
* 2. Wait up to `timeoutMs` for the root process to exit
|
||||
* 3. If still alive, send SIGKILL to the process tree
|
||||
*
|
||||
* @param pid - Process ID to kill
|
||||
* @param timeoutMs - Time to wait before escalating to SIGKILL (default: 5000ms)
|
||||
*/
|
||||
export async function killProcessTreeGraceful(
|
||||
pid: number,
|
||||
timeoutMs = 5000,
|
||||
): Promise<void> {
|
||||
// Step 1: Send SIGTERM to the entire tree
|
||||
try {
|
||||
await killProcessTree(pid, "SIGTERM");
|
||||
} catch (e) {
|
||||
log.warn(`[processTree] SIGTERM failed for pid ${pid}:`, e);
|
||||
}
|
||||
|
||||
// Step 2: Wait for root process to exit
|
||||
const alive = await waitForExit(pid, timeoutMs);
|
||||
|
||||
// Step 3: Escalate to SIGKILL if still alive
|
||||
if (alive) {
|
||||
log.warn(
|
||||
`[processTree] Process ${pid} still alive after ${timeoutMs}ms, sending SIGKILL`,
|
||||
);
|
||||
try {
|
||||
await killProcessTree(pid, "SIGKILL");
|
||||
} catch (e) {
|
||||
log.warn(`[processTree] SIGKILL failed for pid ${pid}:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 系统保留 / 非用户进程 PID 上限,按端口清进程时跳过 */
|
||||
const SYSTEM_PID_CEILING = 4;
|
||||
|
||||
/**
|
||||
* 从 Windows `netstat -ano` 标准输出中解析「本机在该 TCP 端口上 LISTENING」的 PID。
|
||||
* 用于 uv 已退出但子进程仍占用端口、ManagedProcess 已无 PID 等场景的兜底排查。
|
||||
*/
|
||||
export function collectListeningPidsOnPortFromNetstatStdout(
|
||||
stdout: string,
|
||||
port: number,
|
||||
): number[] {
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
return [];
|
||||
}
|
||||
const portSuffix = `:${port}`;
|
||||
const localMarkers = [
|
||||
`127.0.0.1${portSuffix}`,
|
||||
`0.0.0.0${portSuffix}`,
|
||||
`[::1]${portSuffix}`,
|
||||
`[::ffff:127.0.0.1]${portSuffix}`,
|
||||
`[::]${portSuffix}`,
|
||||
`*${portSuffix}`,
|
||||
];
|
||||
const pids = new Set<number>();
|
||||
for (const line of stdout.split(/\r?\n/)) {
|
||||
const isListening = /\bLISTENING\b/i.test(line) || line.includes("监听");
|
||||
if (!isListening) {
|
||||
continue;
|
||||
}
|
||||
if (!localMarkers.some((m) => line.includes(m))) {
|
||||
continue;
|
||||
}
|
||||
const m =
|
||||
line.match(/\bLISTENING\s+(\d+)\s*$/i) || line.match(/监听\s+(\d+)\s*$/);
|
||||
if (!m) {
|
||||
continue;
|
||||
}
|
||||
const pid = parseInt(m[1], 10);
|
||||
if (Number.isFinite(pid) && pid > SYSTEM_PID_CEILING) {
|
||||
pids.add(pid);
|
||||
}
|
||||
}
|
||||
return [...pids];
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows:按 TCP 端口清理 LISTENING 进程(整树终止)。
|
||||
* 在 stopWindowsMcp / 手动重启 GUI MCP 时,作为 taskkill 跟踪 PID 之外的第二道防线。
|
||||
*/
|
||||
export async function killProcessTreesListeningOnTcpPortWindows(
|
||||
port: number,
|
||||
timeoutMsPerTree = 3000,
|
||||
): Promise<void> {
|
||||
if (!isWindows()) {
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
return;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
execFile("netstat", ["-ano"], { windowsHide: true }, (err, stdout) => {
|
||||
void (async () => {
|
||||
if (err) {
|
||||
log.warn("[processTree] netstat -ano failed:", err);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const raw = collectListeningPidsOnPortFromNetstatStdout(stdout, port);
|
||||
const own = process.pid;
|
||||
const pids = raw.filter(
|
||||
(pid) => pid > SYSTEM_PID_CEILING && pid !== own,
|
||||
);
|
||||
if (pids.length > 0) {
|
||||
log.info(
|
||||
`[processTree] port ${port} TCP LISTENING -> taskkill tree for PIDs: ${pids.join(", ")}`,
|
||||
);
|
||||
}
|
||||
for (const pid of pids) {
|
||||
try {
|
||||
await killProcessTreeGraceful(pid, timeoutMsPerTree);
|
||||
} catch (e) {
|
||||
log.warn(
|
||||
`[processTree] killProcessTreeGraceful pid=${pid} port=${port}:`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
resolve();
|
||||
})();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* macOS/Linux:按 TCP 端口清理 LISTENING 进程(整树终止)。
|
||||
* macOS 使用 lsof;Linux 优先 lsof,lsof 不可用时回退到 /proc/net/tcp。
|
||||
*/
|
||||
export async function killProcessTreesListeningOnTcpPortUnix(
|
||||
port: number,
|
||||
timeoutMsPerTree = 3000,
|
||||
): Promise<void> {
|
||||
const platformAdapter = createPlatformAdapter();
|
||||
if (platformAdapter.isWindows) {
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Linux: 优先尝试 lsof,若不可用则回退到 /proc/net/tcp
|
||||
if (platformAdapter.isLinux) {
|
||||
const pids = await getListeningPidsFromProcNet(port);
|
||||
if (pids.length > 0) {
|
||||
log.info(
|
||||
`[processTree] port ${port} TCP LISTENING (from /proc/net/tcp) -> killing PIDs: ${pids.join(", ")}`,
|
||||
);
|
||||
for (const pid of pids) {
|
||||
try {
|
||||
await killProcessTreeGraceful(pid, timeoutMsPerTree);
|
||||
} catch (e) {
|
||||
log.warn(
|
||||
`[processTree] killProcessTreeGraceful pid=${pid} port=${port}:`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// macOS: 使用 lsof
|
||||
return new Promise((resolve) => {
|
||||
execFile("lsof", ["-t", "-i", `:${port}`], (err, stdout) => {
|
||||
void (async () => {
|
||||
if (err || !stdout.trim()) {
|
||||
// 无进程占用或 lsof 失败,静默忽略
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const pids = stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((s) => parseInt(s, 10))
|
||||
.filter((n) => Number.isFinite(n) && n > SYSTEM_PID_CEILING);
|
||||
|
||||
// 排除当前进程自己
|
||||
const own = process.pid;
|
||||
const filteredPids = pids.filter((pid) => pid !== own);
|
||||
|
||||
if (filteredPids.length > 0) {
|
||||
log.info(
|
||||
`[processTree] port ${port} TCP LISTENING (from lsof) -> killing PIDs: ${filteredPids.join(", ")}`,
|
||||
);
|
||||
}
|
||||
for (const pid of filteredPids) {
|
||||
try {
|
||||
await killProcessTreeGraceful(pid, timeoutMsPerTree);
|
||||
} catch (e) {
|
||||
log.warn(
|
||||
`[processTree] killProcessTreeGraceful pid=${pid} port=${port}:`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
resolve();
|
||||
})();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Linux 专用:从 /proc/net/tcp 解析占用指定端口的 PID 列表。
|
||||
* /proc/net/tcp 的本地地址是十六进制表示,inode 可用于关联 /proc/{pid}/fd。
|
||||
* 注意:仅匹配 IPv4 (0.0.0.0 和 127.0.0.1) 和 IPv6 (::1) 的 LISTEN 套接字。
|
||||
*
|
||||
* @param port 十进制端口号
|
||||
* @returns PID 列表
|
||||
*/
|
||||
function getListeningPidsFromProcNet(port: number): Promise<number[]> {
|
||||
return new Promise((resolve) => {
|
||||
const pids = new Set<number>();
|
||||
const portHex = port.toString(16).toUpperCase().padStart(4, "0");
|
||||
|
||||
// 读取 /proc/net/tcp
|
||||
fs.readFile("/proc/net/tcp", "utf8", (err, data) => {
|
||||
if (err) {
|
||||
log.warn("[processTree] Failed to read /proc/net/tcp:", err);
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = data.split("\n").slice(1); // 跳过标题行
|
||||
for (const line of lines) {
|
||||
const fields = line.trim().split(/\s+/);
|
||||
if (fields.length < 10) continue;
|
||||
|
||||
const localAddress = fields[1];
|
||||
const inode = fields[9];
|
||||
|
||||
// 检查是否为 LISTEN 状态且端口匹配
|
||||
const isListening = fields[3] === "0A"; // 0A = LISTEN in hex
|
||||
const portMatch = localAddress.endsWith(`:${portHex}`);
|
||||
|
||||
if (!isListening || !portMatch || !inode) continue;
|
||||
|
||||
// 查找拥有此 inode 的进程
|
||||
const inodeNum = parseInt(inode, 10);
|
||||
if (!Number.isFinite(inodeNum)) continue;
|
||||
|
||||
// 遍历 /proc/*/fd,寻找 socket:[inode] 符号链接
|
||||
try {
|
||||
const procEntries = fs.readdirSync("/proc");
|
||||
for (const entryName of procEntries) {
|
||||
if (entryName === "." || entryName === "..") continue;
|
||||
const pid = parseInt(entryName, 10);
|
||||
if (!Number.isFinite(pid) || pid <= SYSTEM_PID_CEILING) continue;
|
||||
|
||||
const fdDir = `/proc/${pid}/fd`;
|
||||
try {
|
||||
const fdEntries = fs.readdirSync(fdDir);
|
||||
for (const fdEntry of fdEntries) {
|
||||
try {
|
||||
const linkTarget = fs.readlinkSync(`${fdDir}/${fdEntry}`);
|
||||
if (linkTarget === `socket:[${inodeNum}]`) {
|
||||
pids.add(pid);
|
||||
}
|
||||
} catch {
|
||||
// 权限不足或 fd 已消失,跳过
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 进程已退出或无权限
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// /proc 读取失败
|
||||
}
|
||||
}
|
||||
|
||||
// 排除当前进程
|
||||
pids.delete(process.pid);
|
||||
resolve([...pids]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 跨平台:按 TCP 端口清理 LISTENING 进程(整树终止)。
|
||||
* Windows 调用 killProcessTreesListeningOnTcpPortWindows,
|
||||
* macOS/Linux 调用 killProcessTreesListeningOnTcpPortUnix。
|
||||
*/
|
||||
export async function killProcessTreesListeningOnTcpPort(
|
||||
port: number,
|
||||
timeoutMsPerTree = 3000,
|
||||
): Promise<void> {
|
||||
if (isWindows()) {
|
||||
return killProcessTreesListeningOnTcpPortWindows(port, timeoutMsPerTree);
|
||||
}
|
||||
return killProcessTreesListeningOnTcpPortUnix(port, timeoutMsPerTree);
|
||||
}
|
||||
|
||||
// ==================== Internal ====================
|
||||
|
||||
function killProcessTreeWindows(pid: number): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
"taskkill",
|
||||
["/T", "/F", "/PID", String(pid)],
|
||||
{ windowsHide: true },
|
||||
(err) => {
|
||||
if (err) {
|
||||
// taskkill returns exit code 128 if process not found — treat as success
|
||||
const code = (err as any).code;
|
||||
if (code === 128 || code === "ESRCH") {
|
||||
resolve();
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a process and all its descendants on Unix.
|
||||
*
|
||||
* Two strategies, tried in order:
|
||||
* 1. Process group kill: `kill(-pid, signal)` — fast, kills all processes whose
|
||||
* PGID equals `pid`. Works when child was spawned with `detached: true` and
|
||||
* setsid() actually created a new process group.
|
||||
* 2. Recursive descendant kill: walks the process tree using `pgrep -P` and
|
||||
* kills each descendant bottom-up. This handles the case where the process
|
||||
* group was NOT changed (e.g. Electron dev mode under make, where all
|
||||
* processes share the terminal's PGID).
|
||||
*/
|
||||
async function killProcessTreeUnix(
|
||||
pid: number,
|
||||
signal: NodeJS.Signals,
|
||||
): Promise<void> {
|
||||
let groupKillWorked = false;
|
||||
|
||||
// Strategy 1: Try process group kill
|
||||
try {
|
||||
process.kill(-pid, signal);
|
||||
groupKillWorked = true;
|
||||
log.info(`[processTree] Sent ${signal} to process group -${pid}`);
|
||||
} catch (e: any) {
|
||||
if (e.code === "ESRCH") {
|
||||
// ESRCH from group kill means no process with PGID=pid exists.
|
||||
// But the process itself may still be alive with a different PGID
|
||||
// (e.g. dev mode where detached: true didn't create a new group).
|
||||
log.info(
|
||||
`[processTree] No process group -${pid}, falling back to descendant kill`,
|
||||
);
|
||||
} else {
|
||||
log.warn(`[processTree] Group kill failed for pid ${pid}:`, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Recursive descendant kill (always run as safety net)
|
||||
if (!groupKillWorked) {
|
||||
const descendants = await getDescendants(pid);
|
||||
if (descendants.length > 0) {
|
||||
log.info(
|
||||
`[processTree] Killing ${descendants.length} descendant(s) of pid ${pid}: ${descendants.join(",")}`,
|
||||
);
|
||||
}
|
||||
// Kill bottom-up (children first, then parent)
|
||||
for (const childPid of descendants.reverse()) {
|
||||
try {
|
||||
process.kill(childPid, signal);
|
||||
} catch {
|
||||
// ESRCH — already dead, ignore
|
||||
}
|
||||
}
|
||||
// Kill root process
|
||||
try {
|
||||
process.kill(pid, signal);
|
||||
} catch (e: any) {
|
||||
if (e.code !== "ESRCH") {
|
||||
log.warn(`[processTree] Direct kill failed for pid ${pid}:`, e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all descendant PIDs of a process (children, grandchildren, etc.)
|
||||
* using `pgrep -P`. Returns PIDs in depth-first order.
|
||||
*/
|
||||
async function getDescendants(pid: number): Promise<number[]> {
|
||||
const result: number[] = [];
|
||||
const children = await getChildPids(pid);
|
||||
for (const child of children) {
|
||||
result.push(child);
|
||||
const grandchildren = await getDescendants(child);
|
||||
result.push(...grandchildren);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get direct child PIDs of a process using `pgrep -P`.
|
||||
*/
|
||||
function getChildPids(pid: number): Promise<number[]> {
|
||||
return new Promise((resolve) => {
|
||||
execFile("pgrep", ["-P", String(pid)], (err, stdout) => {
|
||||
if (err || !stdout.trim()) {
|
||||
// No children or pgrep failed
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
const pids = stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((s) => parseInt(s, 10))
|
||||
.filter((n) => !isNaN(n));
|
||||
resolve(pids);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a process is still alive, polling until timeout.
|
||||
* Returns true if still alive, false if exited.
|
||||
*/
|
||||
function waitForExit(pid: number, timeoutMs: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const start = Date.now();
|
||||
const check = () => {
|
||||
try {
|
||||
// signal 0 doesn't kill, just checks if process exists
|
||||
process.kill(pid, 0);
|
||||
// Still alive
|
||||
if (Date.now() - start >= timeoutMs) {
|
||||
resolve(true);
|
||||
} else {
|
||||
setTimeout(check, 200);
|
||||
}
|
||||
} catch {
|
||||
// Process gone
|
||||
resolve(false);
|
||||
}
|
||||
};
|
||||
check();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Spawn utility functions
|
||||
*
|
||||
* Provides cross-platform spawn wrappers that handle Windows-specific requirements.
|
||||
*/
|
||||
|
||||
import { spawn, SpawnOptions, ChildProcess } from 'child_process';
|
||||
import { isWindows } from '../system/shellEnv';
|
||||
|
||||
/**
|
||||
* Cross-platform spawn wrapper that automatically adds shell: true on Windows.
|
||||
*
|
||||
* On Windows, spawning .cmd files (like npm.cmd) requires shell: true.
|
||||
* This wrapper ensures consistent behavior across platforms.
|
||||
*
|
||||
* @param command - The command to spawn
|
||||
* @param args - Arguments to pass to the command
|
||||
* @param options - Spawn options (shell option will be overridden on Windows)
|
||||
* @returns ChildProcess instance
|
||||
*/
|
||||
export function spawnCrossPlatform(
|
||||
command: string,
|
||||
args: string[],
|
||||
options?: SpawnOptions,
|
||||
): ChildProcess {
|
||||
return spawn(command, args, {
|
||||
...options,
|
||||
shell: isWindows(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the platform-specific npm command name
|
||||
*
|
||||
* @returns 'npm.cmd' on Windows, 'npm' on other platforms
|
||||
*/
|
||||
export function getNpmCommand(): string {
|
||||
return isWindows() ? 'npm.cmd' : 'npm';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the platform-specific node command name
|
||||
*
|
||||
* @returns 'node.cmd' on Windows, 'node' on other platforms
|
||||
*/
|
||||
export function getNodeCommand(): string {
|
||||
return isWindows() ? 'node.cmd' : 'node';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the platform-specific command checker
|
||||
*
|
||||
* @returns 'where' on Windows, 'which' on other platforms
|
||||
*/
|
||||
export function getCommandChecker(): string {
|
||||
return isWindows() ? 'where' : 'which';
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
/**
|
||||
* Unit tests for spawnNoWindow utility
|
||||
*
|
||||
* Tests the cross-platform spawn functionality:
|
||||
* - Windows: Uses Electron's bundled Node with ELECTRON_RUN_AS_NODE=1
|
||||
* - macOS/Linux: Uses system node to avoid Dock icon issue
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { resolveNpmPackageEntry, spawnJsFile, spawnNpmPackage, isWindows, isMacOS, getNodePath, findSystemNode, resetCache } from './spawnNoWindow';
|
||||
|
||||
// Cross-platform path matching helper: normalizes backslashes for endsWith checks
|
||||
const pathEndsWith = (p: string, suffix: string) =>
|
||||
p.replace(/\\/g, '/').endsWith(suffix);
|
||||
|
||||
// Mock child_process
|
||||
const mockSpawn = vi.fn();
|
||||
const mockExecSync = vi.fn();
|
||||
vi.mock('child_process', () => ({
|
||||
spawn: (...args: unknown[]) => mockSpawn(...args),
|
||||
execSync: (...args: unknown[]) => mockExecSync(...args),
|
||||
}));
|
||||
|
||||
// Mock fs
|
||||
const mockExistsSync = vi.fn();
|
||||
const mockReadFileSync = vi.fn();
|
||||
vi.mock('fs', () => ({
|
||||
existsSync: (p: string) => mockExistsSync(p),
|
||||
readFileSync: (p: string) => mockReadFileSync(p),
|
||||
}));
|
||||
|
||||
// Store original platform
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
describe('spawnNoWindow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset internal caches before each test
|
||||
resetCache();
|
||||
// Default mocks
|
||||
mockSpawn.mockReturnValue({
|
||||
pid: 12345,
|
||||
on: vi.fn(),
|
||||
stdout: { on: vi.fn() },
|
||||
stderr: { on: vi.fn() },
|
||||
});
|
||||
mockExecSync.mockReturnValue('__PATH__=/usr/local/bin:/opt/homebrew/bin');
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (p.includes('node')) return true;
|
||||
return false;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('isWindows', () => {
|
||||
it('should return boolean based on platform', () => {
|
||||
const result = isWindows();
|
||||
expect(typeof result).toBe('boolean');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isMacOS', () => {
|
||||
it('should return boolean based on platform', () => {
|
||||
const result = isMacOS();
|
||||
expect(typeof result).toBe('boolean');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNodePath', () => {
|
||||
it('should return process.execPath', () => {
|
||||
const result = getNodePath();
|
||||
expect(result).toBe(process.execPath);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveNpmPackageEntry', () => {
|
||||
it('should return null if package.json not found', () => {
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
|
||||
const result = resolveNpmPackageEntry('/nonexistent/package');
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockExistsSync).toHaveBeenCalledWith(path.join('/nonexistent/package', 'package.json'));
|
||||
});
|
||||
|
||||
it('should resolve entry from string bin field', () => {
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (pathEndsWith(p, 'package.json')) return true;
|
||||
if (pathEndsWith(p, 'cli.js')) return true;
|
||||
return false;
|
||||
});
|
||||
mockReadFileSync.mockReturnValue(JSON.stringify({
|
||||
name: 'test-package',
|
||||
bin: './cli.js',
|
||||
}));
|
||||
|
||||
const result = resolveNpmPackageEntry('/packages/test-package');
|
||||
|
||||
expect(result).toBe(path.join('/packages/test-package', 'cli.js'));
|
||||
});
|
||||
|
||||
it('should resolve entry from object bin field', () => {
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (pathEndsWith(p, 'package.json')) return true;
|
||||
if (pathEndsWith(p, 'dist/index.js')) return true;
|
||||
return false;
|
||||
});
|
||||
mockReadFileSync.mockReturnValue(JSON.stringify({
|
||||
name: 'test-package',
|
||||
bin: {
|
||||
'test-command': './dist/index.js',
|
||||
},
|
||||
}));
|
||||
|
||||
const result = resolveNpmPackageEntry('/packages/test-package', 'test-command');
|
||||
|
||||
expect(result).toBe(path.join('/packages/test-package', 'dist', 'index.js'));
|
||||
});
|
||||
|
||||
it('should fallback to common locations if bin not found', () => {
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (pathEndsWith(p, 'package.json')) return true;
|
||||
if (pathEndsWith(p, 'index.js')) return true;
|
||||
return false;
|
||||
});
|
||||
mockReadFileSync.mockReturnValue(JSON.stringify({
|
||||
name: 'test-package',
|
||||
// No bin field
|
||||
}));
|
||||
|
||||
const result = resolveNpmPackageEntry('/packages/test-package');
|
||||
|
||||
expect(result).toBe(path.join('/packages/test-package', 'index.js'));
|
||||
});
|
||||
|
||||
it('should handle malformed package.json', () => {
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (pathEndsWith(p, 'package.json')) return true;
|
||||
return false;
|
||||
});
|
||||
mockReadFileSync.mockReturnValue('not valid json');
|
||||
|
||||
const result = resolveNpmPackageEntry('/packages/test-package');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('spawnJsFile - Windows behavior', () => {
|
||||
it('should use ELECTRON_RUN_AS_NODE=1 on Windows', () => {
|
||||
// This test verifies Windows behavior
|
||||
// On Windows, process.execPath is used with ELECTRON_RUN_AS_NODE=1
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
|
||||
try {
|
||||
spawnJsFile('/path/to/script.js');
|
||||
|
||||
const callArgs = mockSpawn.mock.calls[0];
|
||||
// On Windows, should use process.execPath
|
||||
expect(callArgs[0]).toBe(process.execPath);
|
||||
// On Windows, should set ELECTRON_RUN_AS_NODE
|
||||
expect(callArgs[2].env.ELECTRON_RUN_AS_NODE).toBe('1');
|
||||
expect(callArgs[2].windowsHide).toBe(true);
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', originalPlatform!);
|
||||
}
|
||||
});
|
||||
|
||||
it('should merge custom environment variables on Windows', () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
|
||||
try {
|
||||
spawnJsFile('/path/to/script.js', [], {
|
||||
env: { CUSTOM_VAR: 'custom_value' },
|
||||
});
|
||||
|
||||
const callArgs = mockSpawn.mock.calls[0];
|
||||
expect(callArgs[2].env.CUSTOM_VAR).toBe('custom_value');
|
||||
expect(callArgs[2].env.ELECTRON_RUN_AS_NODE).toBe('1');
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', originalPlatform!);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('spawnJsFile - macOS/Linux behavior', () => {
|
||||
it('should use system node on macOS (no ELECTRON_RUN_AS_NODE)', () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
|
||||
// Mock fs.existsSync to find node
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (p === '/usr/local/bin/node') return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
try {
|
||||
spawnJsFile('/path/to/script.js');
|
||||
|
||||
const callArgs = mockSpawn.mock.calls[0];
|
||||
// On macOS, should use system node (not process.execPath)
|
||||
expect(callArgs[0]).toBe('/usr/local/bin/node');
|
||||
// On macOS, should NOT set ELECTRON_RUN_AS_NODE
|
||||
expect(callArgs[2].env.ELECTRON_RUN_AS_NODE).toBeUndefined();
|
||||
expect(callArgs[2].windowsHide).toBe(true);
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', originalPlatform!);
|
||||
}
|
||||
});
|
||||
|
||||
it('should use system node on Linux (no ELECTRON_RUN_AS_NODE)', () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||
|
||||
// Mock fs.existsSync to find node
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (p === '/usr/local/bin/node') return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
try {
|
||||
spawnJsFile('/path/to/script.js');
|
||||
|
||||
const callArgs = mockSpawn.mock.calls[0];
|
||||
// On Linux, should use system node
|
||||
expect(callArgs[0]).toBe('/usr/local/bin/node');
|
||||
// On Linux, should NOT set ELECTRON_RUN_AS_NODE
|
||||
expect(callArgs[2].env.ELECTRON_RUN_AS_NODE).toBeUndefined();
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', originalPlatform!);
|
||||
}
|
||||
});
|
||||
|
||||
it('should allow custom node path on macOS', () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
|
||||
try {
|
||||
spawnJsFile('/path/to/script.js', [], {
|
||||
nodePath: '/custom/node',
|
||||
});
|
||||
|
||||
const callArgs = mockSpawn.mock.calls[0];
|
||||
expect(callArgs[0]).toBe('/custom/node');
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', originalPlatform!);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('spawnNpmPackage', () => {
|
||||
it('should return null if entry not found', () => {
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
|
||||
const result = spawnNpmPackage('/nonexistent/package');
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should spawn with resolved entry', () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (pathEndsWith(p, 'package.json')) return true;
|
||||
if (pathEndsWith(p, 'cli.js')) return true;
|
||||
return false;
|
||||
});
|
||||
mockReadFileSync.mockReturnValue(JSON.stringify({
|
||||
name: 'test-package',
|
||||
bin: './cli.js',
|
||||
}));
|
||||
|
||||
try {
|
||||
const result = spawnNpmPackage('/packages/test-package', ['--port', '8080']);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
process.execPath,
|
||||
[path.join('/packages/test-package', 'cli.js'), '--port', '8080'],
|
||||
expect.objectContaining({
|
||||
windowsHide: true,
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', originalPlatform!);
|
||||
}
|
||||
});
|
||||
|
||||
it('should pass binName for resolution', () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (pathEndsWith(p, 'package.json')) return true;
|
||||
if (pathEndsWith(p, 'dist/bin.js')) return true;
|
||||
return false;
|
||||
});
|
||||
mockReadFileSync.mockReturnValue(JSON.stringify({
|
||||
name: 'test-package',
|
||||
bin: {
|
||||
'custom-bin': './dist/bin.js',
|
||||
},
|
||||
}));
|
||||
|
||||
try {
|
||||
const result = spawnNpmPackage('/packages/test-package', [], {}, 'custom-bin');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
const callArgs = mockSpawn.mock.calls[0];
|
||||
expect(callArgs[1][0]).toBe(path.join('/packages/test-package', 'dist', 'bin.js'));
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', originalPlatform!);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('findSystemNode', () => {
|
||||
it('should return process.execPath on Windows', () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
|
||||
try {
|
||||
const result = findSystemNode();
|
||||
expect(result).toBe(process.execPath);
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', originalPlatform!);
|
||||
}
|
||||
});
|
||||
|
||||
it('should find node in user PATH on macOS', () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
|
||||
mockExecSync.mockReturnValue('__PATH__=/opt/homebrew/bin:/usr/local/bin');
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (p === '/opt/homebrew/bin/node') return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
try {
|
||||
const result = findSystemNode();
|
||||
expect(result).toBe('/opt/homebrew/bin/node');
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', originalPlatform!);
|
||||
}
|
||||
});
|
||||
|
||||
it('should fallback to common paths if not in PATH', () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
|
||||
mockExecSync.mockReturnValue('__PATH__=/some/path/without/node');
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (p === '/usr/local/bin/node') return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
try {
|
||||
const result = findSystemNode();
|
||||
expect(result).toBe('/usr/local/bin/node');
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', originalPlatform!);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return "node" as final fallback', () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
|
||||
mockExecSync.mockImplementation(() => {
|
||||
throw new Error('Shell failed');
|
||||
});
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
|
||||
try {
|
||||
const result = findSystemNode();
|
||||
expect(result).toBe('node');
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', originalPlatform!);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetCache', () => {
|
||||
it('should reset cached values', () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
|
||||
mockExecSync.mockReturnValue('__PATH__=/opt/homebrew/bin');
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (p === '/opt/homebrew/bin/node') return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
try {
|
||||
// First call - should cache the result
|
||||
const result1 = findSystemNode();
|
||||
expect(result1).toBe('/opt/homebrew/bin/node');
|
||||
|
||||
// Reset cache
|
||||
resetCache();
|
||||
|
||||
// Change mock behavior
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (p === '/usr/local/bin/node') return true;
|
||||
return false;
|
||||
});
|
||||
mockExecSync.mockReturnValue('__PATH__=/usr/local/bin');
|
||||
|
||||
// Second call - should use new mock after cache reset
|
||||
const result2 = findSystemNode();
|
||||
expect(result2).toBe('/usr/local/bin/node');
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', originalPlatform!);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,450 @@
|
||||
/**
|
||||
* Spawn utility for cross-platform console-free execution
|
||||
*
|
||||
* This module provides solutions for spawning Node.js-based npm packages
|
||||
* without console popups (Windows) or Dock icons (macOS).
|
||||
*
|
||||
* ## Problems Solved
|
||||
*
|
||||
* 1. **Windows CMD Popup**: When spawning `.cmd` files (npm bin entries),
|
||||
* a console window may flash/popup even with `windowsHide: true`.
|
||||
*
|
||||
* 2. **macOS Dock Icon**: When using `ELECTRON_RUN_AS_NODE=1` with Electron's
|
||||
* executable, it appears as a new app in the Dock and bounces.
|
||||
*
|
||||
* ## Solutions (Platform-Specific)
|
||||
*
|
||||
* - **Windows**: Use Electron's bundled Node.js with `ELECTRON_RUN_AS_NODE=1`
|
||||
* + `windowsHide: true` to bypass `.cmd` files.
|
||||
*
|
||||
* - **macOS/Linux**: Resolve and use the system `node` executable from user's
|
||||
* shell PATH. This avoids creating a new Dock icon.
|
||||
*
|
||||
* ## Usage
|
||||
* ```typescript
|
||||
* import { spawnNpmPackage, spawnJsFile } from './spawnNoWindow';
|
||||
*
|
||||
* // Spawn an npm package by name
|
||||
* const child = spawnNpmPackage('mcp-proxy', ['proxy', '--port', '8080']);
|
||||
*
|
||||
* // Spawn a JS file directly
|
||||
* const child = spawnJsFile('/path/to/script.js', ['--arg']);
|
||||
* ```
|
||||
*
|
||||
* ## Reference
|
||||
* This approach is inspired by LobsterAI's coworkUtil.ts implementation.
|
||||
*/
|
||||
|
||||
import { spawn, ChildProcess, SpawnOptions, execSync } from "child_process";
|
||||
import * as path from "path";
|
||||
import * as fs from "fs";
|
||||
import log from "electron-log";
|
||||
import { getAppEnv, getNodeBinPath } from "../system/dependencies";
|
||||
|
||||
/**
|
||||
* Options for spawnNoWindow functions
|
||||
*/
|
||||
export interface SpawnNoWindowOptions extends Omit<SpawnOptions, "shell"> {
|
||||
/**
|
||||
* Custom Node.js executable path (defaults to process.execPath)
|
||||
*/
|
||||
nodePath?: string;
|
||||
/**
|
||||
* Extra environment variables
|
||||
*/
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the JS entry file path from an npm package's package.json
|
||||
*
|
||||
* @param packageDir - The package directory (containing package.json)
|
||||
* @param binName - The bin name to resolve (defaults to package name)
|
||||
* @returns The absolute path to the JS entry file, or null if not found
|
||||
*/
|
||||
export function resolveNpmPackageEntry(
|
||||
packageDir: string,
|
||||
binName?: string,
|
||||
): string | null {
|
||||
const pkgJsonPath = path.join(packageDir, "package.json");
|
||||
|
||||
if (!fs.existsSync(pkgJsonPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8"));
|
||||
|
||||
// Get bin path from package.json
|
||||
let binPath: string | undefined;
|
||||
if (typeof pkg.bin === "string") {
|
||||
binPath = pkg.bin;
|
||||
} else if (pkg.bin && typeof pkg.bin === "object") {
|
||||
const name = binName || pkg.name;
|
||||
const binValue = name ? pkg.bin[name] : Object.values(pkg.bin)[0];
|
||||
if (typeof binValue === "string") {
|
||||
binPath = binValue;
|
||||
}
|
||||
}
|
||||
|
||||
if (binPath) {
|
||||
// 处理相对路径(如 ./dist/bin.js)
|
||||
// path.join 不会自动规范化,需要手动处理
|
||||
let entryPath: string;
|
||||
if (binPath.startsWith("./")) {
|
||||
// 相对路径:直接拼接并规范化
|
||||
entryPath = path.normalize(path.join(packageDir, binPath));
|
||||
} else {
|
||||
entryPath = path.join(packageDir, binPath);
|
||||
}
|
||||
|
||||
if (fs.existsSync(entryPath)) {
|
||||
return entryPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try common entry file locations
|
||||
const fallbacks = [
|
||||
path.join(packageDir, "index.js"),
|
||||
path.join(packageDir, "cli.js"),
|
||||
path.join(packageDir, "dist", "index.js"),
|
||||
];
|
||||
|
||||
for (const p of fallbacks) {
|
||||
if (fs.existsSync(p)) return p;
|
||||
}
|
||||
} catch {
|
||||
// Failed to parse package.json
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a JS file using Node.js without console window on Windows
|
||||
* or without creating a new Dock icon on macOS.
|
||||
*
|
||||
* Platform-Specific Behavior:
|
||||
*
|
||||
* - **Windows**: Uses Electron's bundled Node.js with `ELECTRON_RUN_AS_NODE=1`
|
||||
* to bypass `.cmd` files that cause console popups.
|
||||
*
|
||||
* - **macOS/Linux**: Uses the system `node` executable (resolved from user's
|
||||
* shell PATH) to avoid creating a new Dock icon.
|
||||
*
|
||||
* @param jsFile - Absolute path to the JS file to execute
|
||||
* @param args - Arguments to pass to the JS file
|
||||
* @param options - Spawn options
|
||||
* @returns ChildProcess instance
|
||||
*/
|
||||
export function spawnJsFile(
|
||||
jsFile: string,
|
||||
args: string[] = [],
|
||||
options: SpawnNoWindowOptions = {},
|
||||
): ChildProcess {
|
||||
const { nodePath, env, ...spawnOptions } = options;
|
||||
|
||||
// Platform-specific node executable selection
|
||||
let node: string;
|
||||
let mergedEnv: Record<string, string | undefined>;
|
||||
|
||||
// 检测是否在 Electron 环境中运行
|
||||
// 测试环境中 app.getPath 不可用,需要使用回退方案
|
||||
const isElectron = typeof process.versions?.electron === "string";
|
||||
|
||||
// 与 Tauri 一致:调用方已传入完整 env(如 mcp-proxy 的 getAppEnv)时直接使用,不再二次合并 getAppEnv(),避免 PATH/UV_* 被覆盖或时机不一致
|
||||
const callerProvidedFullEnv =
|
||||
env &&
|
||||
typeof env === "object" &&
|
||||
"PATH" in env &&
|
||||
Object.keys(env).length > 5;
|
||||
if (env && !callerProvidedFullEnv && isElectron) {
|
||||
log.info(
|
||||
`[spawnNoWindow] Trace: not using full env (PATH=${!!env?.PATH}, keys=${Object.keys(env || {}).length}), will merge getAppEnv()`,
|
||||
);
|
||||
}
|
||||
|
||||
if (isWindows()) {
|
||||
node = nodePath || process.execPath;
|
||||
if (callerProvidedFullEnv && isElectron) {
|
||||
mergedEnv = { ...env, ELECTRON_RUN_AS_NODE: "1" } as Record<
|
||||
string,
|
||||
string | undefined
|
||||
>;
|
||||
const pathStr = mergedEnv.PATH || "";
|
||||
const pathWithUv = pathStr
|
||||
.split(";")
|
||||
.filter((p) => p && (p.includes("uv") || p.includes("qimingclaw")));
|
||||
log.info(
|
||||
`[spawnNoWindow] Windows: using caller-provided full env (${Object.keys(env).length} entries)`,
|
||||
);
|
||||
log.info(
|
||||
`[spawnNoWindow] Trace: uv segments in child PATH=${pathWithUv.length}, first 5=${pathWithUv.slice(0, 5).join(";") || "(none)"}`,
|
||||
);
|
||||
} else {
|
||||
const appEnv = isElectron ? getAppEnv() : null;
|
||||
mergedEnv = appEnv
|
||||
? { ...appEnv, ...env, ELECTRON_RUN_AS_NODE: "1" }
|
||||
: {
|
||||
...process.env,
|
||||
...env,
|
||||
ELECTRON_RUN_AS_NODE: "1",
|
||||
PATH: getEnhancedPath(),
|
||||
};
|
||||
}
|
||||
log.info(`[spawnNoWindow] Windows debug info (Electron: ${isElectron}):`);
|
||||
log.info(`[spawnNoWindow] - process.execPath: ${process.execPath}`);
|
||||
log.info(`[spawnNoWindow] - Using node: ${node}`);
|
||||
log.info(
|
||||
`[spawnNoWindow] - PATH first 5: ${(mergedEnv.PATH || "").split(";").slice(0, 5).join(";")}`,
|
||||
);
|
||||
} else {
|
||||
// Linux/macOS:必须走应用集成的 Node 24(resources/node/<platform-arch>/bin/node),避免容器内 /usr/bin/node 不存在导致 ENOENT
|
||||
// 调用方未传 nodePath 时,Electron 下优先 getNodeBinPath()(应用集成 Node 24),无集成 node 时才回退 findSystemNode()
|
||||
const integratedNode = isElectron ? getNodeBinPath() : null;
|
||||
node = nodePath || (integratedNode ?? findSystemNode());
|
||||
if (integratedNode && node === integratedNode) {
|
||||
log.info(
|
||||
`[spawnNoWindow] ${process.platform}: using bundled Node 24: ${node}`,
|
||||
);
|
||||
}
|
||||
if (callerProvidedFullEnv && isElectron) {
|
||||
mergedEnv = { ...env } as Record<string, string | undefined>;
|
||||
const pathStr = mergedEnv.PATH || "";
|
||||
const pathWithUv = pathStr
|
||||
.split(":")
|
||||
.filter((p) => p && (p.includes("uv") || p.includes("qimingclaw")));
|
||||
log.info(
|
||||
`[spawnNoWindow] ${process.platform}: using caller-provided full env (${Object.keys(env).length} entries)`,
|
||||
);
|
||||
log.info(
|
||||
`[spawnNoWindow] Trace: uv segments in child PATH=${pathWithUv.length}, first 5=${pathWithUv.slice(0, 5).join(":") || "(none)"}`,
|
||||
);
|
||||
} else {
|
||||
const appEnv = isElectron ? getAppEnv() : null;
|
||||
mergedEnv = appEnv
|
||||
? { ...appEnv, ...env }
|
||||
: {
|
||||
...process.env,
|
||||
...env,
|
||||
PATH: getEnhancedPath(),
|
||||
};
|
||||
}
|
||||
// 使用绝对路径的 node 时,将其所在目录插入 PATH 最前,确保子进程内再 spawn('node') 或 process.execPath 能解析到同一可执行文件(避免容器内 /usr/bin/node 不存在导致 ENOENT)
|
||||
if (node && path.isAbsolute(node)) {
|
||||
const nodeDir = path.dirname(node);
|
||||
const currentPath = mergedEnv.PATH || "";
|
||||
if (!currentPath.startsWith(nodeDir + path.delimiter)) {
|
||||
mergedEnv.PATH = currentPath
|
||||
? `${nodeDir}${path.delimiter}${currentPath}`
|
||||
: nodeDir;
|
||||
log.info(
|
||||
`[spawnNoWindow] ${process.platform}: prepended node dir to PATH front: ${nodeDir}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
log.info(
|
||||
`[spawnNoWindow] ${process.platform} debug info (Electron: ${isElectron}):`,
|
||||
);
|
||||
log.info(`[spawnNoWindow] - Using node: ${node}`);
|
||||
log.info(
|
||||
`[spawnNoWindow] - PATH first 5: ${(mergedEnv.PATH || "").split(":").slice(0, 5).join(":")}`,
|
||||
);
|
||||
}
|
||||
|
||||
return spawn(node, [jsFile, ...args], {
|
||||
...spawnOptions,
|
||||
env: mergedEnv,
|
||||
windowsHide: true,
|
||||
// Don't use shell - we're executing node directly
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn an npm package by resolving its entry file
|
||||
*
|
||||
* This is the recommended way to spawn npm packages on Windows
|
||||
* to avoid console popup windows.
|
||||
*
|
||||
* @param packageDir - The npm package directory
|
||||
* @param args - Arguments to pass to the package
|
||||
* @param options - Spawn options
|
||||
* @param binName - Optional bin name (if different from package name)
|
||||
* @returns ChildProcess instance, or null if entry file not found
|
||||
*/
|
||||
export function spawnNpmPackage(
|
||||
packageDir: string,
|
||||
args: string[] = [],
|
||||
options: SpawnNoWindowOptions = {},
|
||||
binName?: string,
|
||||
): ChildProcess | null {
|
||||
const entryFile = resolveNpmPackageEntry(packageDir, binName);
|
||||
|
||||
if (!entryFile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return spawnJsFile(entryFile, args, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we're running on Windows
|
||||
*/
|
||||
export function isWindows(): boolean {
|
||||
return process.platform === "win32";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we're running on macOS
|
||||
*/
|
||||
export function isMacOS(): boolean {
|
||||
return process.platform === "darwin";
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get the node executable path
|
||||
* On Electron, this returns the bundled Node.js path
|
||||
*/
|
||||
export function getNodePath(): string {
|
||||
return process.execPath;
|
||||
}
|
||||
|
||||
// ==================== System Node Resolution (macOS/Linux) ====================
|
||||
|
||||
/**
|
||||
* Cached user shell PATH. Resolved once and reused across calls.
|
||||
* On packaged Electron apps (macOS), the user's shell profile is not inherited,
|
||||
* so node/npm won't be in PATH unless we resolve it.
|
||||
*/
|
||||
let cachedUserShellPath: string | null | undefined;
|
||||
|
||||
/**
|
||||
* Cached system node path. Resolved once and reused across calls.
|
||||
*/
|
||||
let cachedSystemNodePath: string | undefined;
|
||||
|
||||
/**
|
||||
* Reset all internal caches.
|
||||
* Useful for testing or when PATH may have changed.
|
||||
*/
|
||||
export function resetCache(): void {
|
||||
cachedUserShellPath = undefined;
|
||||
cachedSystemNodePath = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the user's login shell PATH on macOS/Linux.
|
||||
*
|
||||
* Packaged Electron apps on macOS don't inherit the user's shell profile,
|
||||
* so node/npm and other tools won't be in PATH unless we resolve it.
|
||||
*
|
||||
* @returns The user's shell PATH, or null if not resolvable
|
||||
*/
|
||||
function resolveUserShellPath(): string | null {
|
||||
if (cachedUserShellPath !== undefined) return cachedUserShellPath;
|
||||
|
||||
if (isWindows()) {
|
||||
cachedUserShellPath = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const shell = process.env.SHELL || "/bin/bash";
|
||||
// Use login shell (-il) to source user's profile files
|
||||
const result = execSync(`${shell} -ilc 'echo __PATH__=$PATH'`, {
|
||||
encoding: "utf-8",
|
||||
timeout: 5000,
|
||||
env: { ...process.env },
|
||||
});
|
||||
const match = result.match(/__PATH__=(.+)/);
|
||||
cachedUserShellPath = match ? match[1].trim() : null;
|
||||
} catch (error) {
|
||||
console.warn("[spawnNoWindow] Failed to resolve user shell PATH:", error);
|
||||
cachedUserShellPath = null;
|
||||
}
|
||||
|
||||
return cachedUserShellPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the system `node` executable path on macOS/Linux.
|
||||
*
|
||||
* Uses the user's shell PATH to find node, which works with:
|
||||
* - Homebrew installed node (`/opt/homebrew/bin/node` or `/usr/local/bin/node`)
|
||||
* - nvm (`~/.nvm/versions/node/...`)
|
||||
* - volta (`~/.volta/bin/node`)
|
||||
* - fnm (`~/.fnm/...`)
|
||||
*
|
||||
* @returns The path to system node, or 'node' as fallback
|
||||
*/
|
||||
export function findSystemNode(): string {
|
||||
if (isWindows()) {
|
||||
// On Windows, we use Electron's bundled node with ELECTRON_RUN_AS_NODE
|
||||
return process.execPath;
|
||||
}
|
||||
|
||||
// Return cached result if available
|
||||
if (cachedSystemNodePath !== undefined) {
|
||||
return cachedSystemNodePath;
|
||||
}
|
||||
|
||||
// Try to get user's shell PATH
|
||||
const userPath = resolveUserShellPath();
|
||||
|
||||
if (userPath) {
|
||||
// Search for node in user's PATH
|
||||
const pathDirs = userPath.split(path.delimiter);
|
||||
for (const dir of pathDirs) {
|
||||
const nodePath = path.join(dir, "node");
|
||||
if (fs.existsSync(nodePath)) {
|
||||
cachedSystemNodePath = nodePath;
|
||||
return nodePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try common node installation paths
|
||||
const home = process.env.HOME || process.env.USERPROFILE || "";
|
||||
const commonPaths = [
|
||||
"/usr/local/bin/node",
|
||||
"/opt/homebrew/bin/node",
|
||||
"/usr/bin/node",
|
||||
path.join(home, ".nvm/versions/node/current/bin/node"),
|
||||
path.join(home, ".volta/bin/node"),
|
||||
path.join(home, ".fnm/current/bin/node"),
|
||||
];
|
||||
|
||||
for (const p of commonPaths) {
|
||||
if (fs.existsSync(p)) {
|
||||
cachedSystemNodePath = p;
|
||||
return p;
|
||||
}
|
||||
}
|
||||
|
||||
// Final fallback: assume 'node' is in PATH
|
||||
// Log warning in development mode
|
||||
if (process.env.NODE_ENV === "development" || !process.env.NODE_ENV) {
|
||||
console.warn(
|
||||
'[spawnNoWindow] Could not find system node, using "node" from PATH',
|
||||
);
|
||||
}
|
||||
|
||||
cachedSystemNodePath = "node";
|
||||
return "node";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the PATH environment value with user's shell PATH prepended.
|
||||
* Used for macOS/Linux to ensure subprocesses can find node and other tools.
|
||||
*/
|
||||
function getEnhancedPath(): string {
|
||||
const currentPath = process.env.PATH || "";
|
||||
const userPath = resolveUserShellPath();
|
||||
|
||||
if (userPath) {
|
||||
// Prepend user's shell PATH to get node, npm, etc.
|
||||
return `${userPath}${path.delimiter}${currentPath}`;
|
||||
}
|
||||
|
||||
return currentPath;
|
||||
}
|
||||
Reference in New Issue
Block a user