chore: initialize qiming workspace repository
This commit is contained in:
@@ -0,0 +1,683 @@
|
||||
/**
|
||||
* 单元测试: dependencies
|
||||
*
|
||||
* 测试依赖管理相关函数
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import path from "path";
|
||||
|
||||
// Mock electron
|
||||
vi.mock("electron", () => ({
|
||||
app: {
|
||||
getPath: vi.fn((name: string) => {
|
||||
if (name === "home") return "/mock/home";
|
||||
return "/mock/appdata";
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock electron-log
|
||||
vi.mock("electron-log", () => ({
|
||||
default: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock fs - will be configured in each test as needed
|
||||
const mockExistsSync = vi.fn(() => true);
|
||||
const mockReaddirSync = vi.fn(() => []);
|
||||
vi.mock("fs", () => ({
|
||||
existsSync: (...args: unknown[]) => mockExistsSync(...args),
|
||||
readdirSync: (...args: unknown[]) => mockReaddirSync(...args),
|
||||
}));
|
||||
|
||||
describe("dependencies", () => {
|
||||
const { app } = require("electron");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset module cache to get fresh module state
|
||||
vi.resetModules();
|
||||
|
||||
// Reset fs mocks to default behavior
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockReturnValue([]);
|
||||
|
||||
// Set up test environment
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: "darwin",
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
process.env.HOME = "/mock/home";
|
||||
process.env.USER = "testuser";
|
||||
process.env.USERNAME = "testuser";
|
||||
process.env.LANG = "en_US.UTF-8";
|
||||
process.env.PATH =
|
||||
"/usr/bin:/bin:/usr/local/bin:/mock/home/.nvm/versions/node/v20.0.0/bin";
|
||||
process.env.NVM_DIR = undefined;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.HOME;
|
||||
delete process.env.USER;
|
||||
delete process.env.USERNAME;
|
||||
delete process.env.LANG;
|
||||
delete process.env.PATH;
|
||||
delete process.env.NVM_DIR;
|
||||
});
|
||||
|
||||
describe("Mirror Config", () => {
|
||||
it("should return default mirror config", async () => {
|
||||
const { getMirrorConfig } = await import("../system/dependencies");
|
||||
const config = getMirrorConfig();
|
||||
|
||||
expect(config).toEqual({
|
||||
npmRegistry: "https://registry.npmmirror.com/",
|
||||
uvIndexUrl: "https://mirrors.aliyun.com/pypi/simple/",
|
||||
});
|
||||
});
|
||||
|
||||
it("should update npm registry", async () => {
|
||||
const { setMirrorConfig, getMirrorConfig } =
|
||||
await import("../system/dependencies");
|
||||
setMirrorConfig({ npmRegistry: "https://registry.npmjs.org/" });
|
||||
const config = getMirrorConfig();
|
||||
|
||||
expect(config.npmRegistry).toBe("https://registry.npmjs.org/");
|
||||
});
|
||||
|
||||
it("should update uv index url", async () => {
|
||||
const { setMirrorConfig, getMirrorConfig } =
|
||||
await import("../system/dependencies");
|
||||
setMirrorConfig({ uvIndexUrl: "https://pypi.org/simple/" });
|
||||
const config = getMirrorConfig();
|
||||
|
||||
expect(config.uvIndexUrl).toBe("https://pypi.org/simple/");
|
||||
});
|
||||
|
||||
it("should update both registry configs", async () => {
|
||||
const { setMirrorConfig, getMirrorConfig } =
|
||||
await import("../system/dependencies");
|
||||
setMirrorConfig({
|
||||
npmRegistry: "https://npm.taobao.org/",
|
||||
uvIndexUrl: "https://pypi.tuna.tsinghua.edu.cn/simple/",
|
||||
});
|
||||
const config = getMirrorConfig();
|
||||
|
||||
expect(config.npmRegistry).toBe("https://npm.taobao.org/");
|
||||
expect(config.uvIndexUrl).toBe(
|
||||
"https://pypi.tuna.tsinghua.edu.cn/simple/",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAppEnv", () => {
|
||||
it("should include app-specific paths in PATH", async () => {
|
||||
const { getAppEnv } = await import("../system/dependencies");
|
||||
const env = getAppEnv();
|
||||
|
||||
// 与实现一致:getAppEnv 用 process.platform 决定分隔符(darwin 在 beforeEach 已 mock)
|
||||
const pathSep = process.platform === "win32" ? ";" : ":";
|
||||
const pathEntries = env.PATH?.split(pathSep) || [];
|
||||
const home = path.join("/mock", "home");
|
||||
const appData = path.join(home, ".qimingclaw");
|
||||
expect(pathEntries).toContain(path.join(appData, "node_modules", ".bin"));
|
||||
expect(pathEntries).toContain(path.join(appData, "bin"));
|
||||
});
|
||||
|
||||
it("should set NODE_PATH to app node_modules", async () => {
|
||||
const { getAppEnv } = await import("../system/dependencies");
|
||||
const env = getAppEnv();
|
||||
|
||||
const expected = path.join("/mock", "home", ".qimingclaw", "node_modules");
|
||||
expect(env.NODE_PATH).toBe(expected);
|
||||
});
|
||||
|
||||
it("should set npm config registry", async () => {
|
||||
const { getAppEnv } = await import("../system/dependencies");
|
||||
const env = getAppEnv();
|
||||
|
||||
expect(env.NPM_CONFIG_REGISTRY).toBe("https://registry.npmmirror.com/");
|
||||
});
|
||||
|
||||
it("should set npm userconfig to app directory", async () => {
|
||||
const { getAppEnv } = await import("../system/dependencies");
|
||||
const env = getAppEnv();
|
||||
|
||||
const expected = path.join("/mock", "home", ".qimingclaw", ".npmrc");
|
||||
expect(env.NPM_CONFIG_USERCONFIG).toBe(expected);
|
||||
});
|
||||
|
||||
it("should disable npm update notifier", async () => {
|
||||
const { getAppEnv } = await import("../system/dependencies");
|
||||
const env = getAppEnv();
|
||||
|
||||
expect(env.NO_UPDATE_NOTIFIER).toBe("true");
|
||||
});
|
||||
|
||||
it("should set UV_INDEX_URL to mirror config", async () => {
|
||||
const { getAppEnv } = await import("../system/dependencies");
|
||||
const env = getAppEnv();
|
||||
|
||||
expect(env.UV_INDEX_URL).toBe("https://mirrors.aliyun.com/pypi/simple/");
|
||||
});
|
||||
|
||||
it("should disable uv auto-install", async () => {
|
||||
const { getAppEnv } = await import("../system/dependencies");
|
||||
const env = getAppEnv();
|
||||
|
||||
expect(env.UV_NO_INSTALL).toBe("1");
|
||||
});
|
||||
|
||||
it("should preserve HOME environment variable", async () => {
|
||||
const { getAppEnv } = await import("../system/dependencies");
|
||||
const env = getAppEnv();
|
||||
|
||||
expect(env.HOME).toBe("/mock/home");
|
||||
});
|
||||
|
||||
it("should not include undefined values", async () => {
|
||||
const { getAppEnv } = await import("../system/dependencies");
|
||||
const env = getAppEnv();
|
||||
|
||||
// All values should be strings, no undefined
|
||||
Object.values(env).forEach((val) => {
|
||||
expect(typeof val).toBe("string");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSetupRequiredDependencies", () => {
|
||||
it("should have uv as bundled dependency", async () => {
|
||||
const { getSetupRequiredDependencies } =
|
||||
await import("../system/dependencies");
|
||||
const deps = getSetupRequiredDependencies();
|
||||
const uvDep = deps.find((d) => d.name === "uv");
|
||||
|
||||
expect(uvDep).toBeDefined();
|
||||
expect(uvDep?.type).toBe("bundled");
|
||||
expect(uvDep?.required).toBe(true);
|
||||
expect(uvDep?.minVersion).toBe("0.5.0");
|
||||
});
|
||||
|
||||
it("should have qiming-file-server as npm-local dependency", async () => {
|
||||
const { getSetupRequiredDependencies } =
|
||||
await import("../system/dependencies");
|
||||
const deps = getSetupRequiredDependencies();
|
||||
const fileServerDep = deps.find((d) => d.name === "qiming-file-server");
|
||||
|
||||
expect(fileServerDep).toBeDefined();
|
||||
expect(fileServerDep?.type).toBe("npm-local");
|
||||
expect(fileServerDep?.required).toBe(true);
|
||||
expect(fileServerDep?.binName).toBe("qiming-file-server");
|
||||
});
|
||||
|
||||
it("should have qimingcode as bundled dependency", async () => {
|
||||
const { getSetupRequiredDependencies } =
|
||||
await import("../system/dependencies");
|
||||
const deps = getSetupRequiredDependencies();
|
||||
const agentDep = deps.find((d) => d.name === "qimingcode");
|
||||
|
||||
expect(agentDep).toBeDefined();
|
||||
expect(agentDep?.type).toBe("bundled");
|
||||
expect(agentDep?.required).toBe(true);
|
||||
});
|
||||
|
||||
it("should not require qiming-mcp-stdio-proxy as npm-local dependency", async () => {
|
||||
const { getSetupRequiredDependencies } =
|
||||
await import("../system/dependencies");
|
||||
const deps = getSetupRequiredDependencies();
|
||||
const mcpDep = deps.find((d) => d.name === "qiming-mcp-stdio-proxy");
|
||||
|
||||
expect(mcpDep).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should have all required dependencies", async () => {
|
||||
const { getSetupRequiredDependencies } =
|
||||
await import("../system/dependencies");
|
||||
const deps = getSetupRequiredDependencies();
|
||||
const requiredDeps = deps.filter((d) => d.required);
|
||||
|
||||
expect(requiredDeps.length).toBeGreaterThan(0);
|
||||
requiredDeps.forEach((dep) => {
|
||||
expect(dep.required).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSystemPaths", () => {
|
||||
it("should filter out project-level node_modules paths", async () => {
|
||||
process.env.PATH = "/usr/bin:/project/node_modules/.bin:/usr/local/bin";
|
||||
|
||||
const { getAppEnv } = await import("../system/dependencies");
|
||||
const env = getAppEnv();
|
||||
const pathEntries = env.PATH?.split(":") || [];
|
||||
|
||||
// Should not contain project node_modules
|
||||
expect(
|
||||
pathEntries.find(
|
||||
(p) => p.includes("node_modules/.bin") && !p.includes(".qimingclaw"),
|
||||
),
|
||||
).toBeUndefined();
|
||||
// Should contain system paths
|
||||
expect(
|
||||
pathEntries.some((p) => p === "/usr/bin" || p === "/usr/local/bin"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// macOS-specific test - skip on Windows
|
||||
const testOnMacOS = process.platform === "darwin" ? it : it.skip;
|
||||
|
||||
testOnMacOS(
|
||||
"should include NVM node versions in PATH on macOS",
|
||||
async () => {
|
||||
process.env.PATH = "/usr/bin";
|
||||
process.env.NVM_DIR = "/mock/home/.nvm";
|
||||
|
||||
// Mock NVM versions directory
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (p.includes(".nvm/versions/node")) return true;
|
||||
if (p.includes("v20.10.0/bin")) return true;
|
||||
return true;
|
||||
});
|
||||
mockReaddirSync.mockImplementation((p: string) => {
|
||||
if (p.includes(".nvm/versions/node")) {
|
||||
return ["v18.0.0", "v20.10.0", "v16.5.0"];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const { getAppEnv } = await import("../system/dependencies");
|
||||
const env = getAppEnv();
|
||||
const pathEntries = env.PATH?.split(":") || [];
|
||||
|
||||
// Should include the latest NVM version bin path
|
||||
expect(pathEntries.some((p) => p.includes("v20.10.0/bin"))).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
testOnMacOS(
|
||||
"should include fnm node versions in PATH on macOS",
|
||||
async () => {
|
||||
process.env.PATH = "/usr/bin";
|
||||
|
||||
// Mock fnm directory
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (p.includes(".fnm")) return true;
|
||||
if (p.includes("node-installations")) return true;
|
||||
if (p.includes("v22.0.0/installation/bin")) return true;
|
||||
return true;
|
||||
});
|
||||
mockReaddirSync.mockImplementation((p: string) => {
|
||||
if (p.includes("node-installations")) {
|
||||
return ["v20.0.0", "v22.0.0"];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const { getAppEnv } = await import("../system/dependencies");
|
||||
const env = getAppEnv();
|
||||
const pathEntries = env.PATH?.split(":") || [];
|
||||
|
||||
// Should include the latest fnm version bin path
|
||||
expect(
|
||||
pathEntries.some((p) => p.includes("v22.0.0/installation/bin")),
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("should include Homebrew paths on macOS", async () => {
|
||||
process.env.PATH = "/usr/bin";
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockReturnValue([]);
|
||||
|
||||
const { getAppEnv } = await import("../system/dependencies");
|
||||
const env = getAppEnv();
|
||||
const pathEntries = env.PATH?.split(":") || [];
|
||||
|
||||
// Should include common Homebrew paths
|
||||
expect(pathEntries).toContain("/usr/local/bin");
|
||||
expect(pathEntries).toContain("/opt/homebrew/bin");
|
||||
});
|
||||
|
||||
it("should include Windows npm path on Windows", async () => {
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: "win32",
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
process.env.PATH = "C:\\Windows\\System32";
|
||||
process.env.USERPROFILE = "C:\\Users\\testuser";
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockReturnValue([]);
|
||||
|
||||
const { getAppEnv } = await import("../system/dependencies");
|
||||
const env = getAppEnv();
|
||||
const pathEntries = env.PATH?.split(";") || [];
|
||||
|
||||
// Should include Windows npm path
|
||||
expect(
|
||||
pathEntries.some(
|
||||
(p) =>
|
||||
p.includes("AppData\\Roaming\\npm") ||
|
||||
p.includes("AppData/Roaming/npm"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should preserve system npm paths (not filter out NVM/fnm)", async () => {
|
||||
process.env.PATH =
|
||||
"/usr/bin:/mock/home/.nvm/versions/node/v20.0.0/bin:/mock/home/.fnm/node-installations/v18.0.0/installation/bin";
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockReturnValue([]);
|
||||
|
||||
const { getAppEnv } = await import("../system/dependencies");
|
||||
const env = getAppEnv();
|
||||
const pathEntries = env.PATH?.split(":") || [];
|
||||
|
||||
// NVM and fnm paths should be preserved (not filtered out)
|
||||
expect(pathEntries.some((p) => p.includes(".nvm/versions"))).toBe(true);
|
||||
expect(pathEntries.some((p) => p.includes(".fnm"))).toBe(true);
|
||||
});
|
||||
|
||||
it("should not include paths that do not exist", async () => {
|
||||
process.env.PATH = "/usr/bin";
|
||||
|
||||
// Only some paths exist
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (p === "/usr/local/bin") return false;
|
||||
if (p === "/opt/homebrew/bin") return false;
|
||||
return true;
|
||||
});
|
||||
mockReaddirSync.mockReturnValue([]);
|
||||
|
||||
const { getAppEnv } = await import("../system/dependencies");
|
||||
const env = getAppEnv();
|
||||
const pathEntries = env.PATH?.split(":") || [];
|
||||
|
||||
// Non-existent paths should not be included
|
||||
expect(pathEntries).not.toContain("/usr/local/bin");
|
||||
expect(pathEntries).not.toContain("/opt/homebrew/bin");
|
||||
});
|
||||
|
||||
testOnMacOS(
|
||||
"should select latest semantic version from NVM versions",
|
||||
async () => {
|
||||
process.env.PATH = "/usr/bin";
|
||||
process.env.NVM_DIR = "/mock/home/.nvm";
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((p: string) => {
|
||||
if (p.includes(".nvm/versions/node")) {
|
||||
// Test version sorting: v10.0.0 should not be selected over v9.0.0 by string sort
|
||||
return ["v9.0.0", "v10.0.0", "v8.15.0"];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const { getAppEnv } = await import("../system/dependencies");
|
||||
const env = getAppEnv();
|
||||
const pathEntries = env.PATH?.split(":") || [];
|
||||
|
||||
// Should select v10.0.0 (highest version), not v9.0.0 (alphabetically last)
|
||||
expect(pathEntries.some((p) => p.includes("v10.0.0/bin"))).toBe(true);
|
||||
expect(pathEntries.some((p) => p.includes("v9.0.0/bin"))).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("should cache system paths result", async () => {
|
||||
process.env.PATH = "/usr/bin";
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockReturnValue([]);
|
||||
|
||||
// Import module twice
|
||||
const module1 = await import("../system/dependencies");
|
||||
const env1 = module1.getAppEnv();
|
||||
|
||||
const module2 = await import("../system/dependencies");
|
||||
const env2 = module2.getAppEnv();
|
||||
|
||||
// Both should return the same cached result
|
||||
expect(env1.PATH).toBe(env2.PATH);
|
||||
});
|
||||
});
|
||||
|
||||
describe("installNpmPackage queue serialization", () => {
|
||||
// Track spawn calls and allow controlling process behavior per call
|
||||
let spawnCalls: Array<{ command: string; args: string[] }>;
|
||||
let spawnResolvers: Array<{ resolve: (code: number) => void }>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
spawnCalls = [];
|
||||
spawnResolvers = [];
|
||||
|
||||
// Mock child_process.spawn to return controllable fake processes
|
||||
vi.doMock("child_process", () => {
|
||||
const { EventEmitter } = require("events");
|
||||
return {
|
||||
spawn: vi.fn((command: string, args: string[]) => {
|
||||
spawnCalls.push({ command, args });
|
||||
const proc = new EventEmitter();
|
||||
proc.stderr = new EventEmitter();
|
||||
proc.stdin = null;
|
||||
proc.stdout = new EventEmitter();
|
||||
const entry = {
|
||||
resolve: (code: number) => {
|
||||
proc.emit("close", code);
|
||||
},
|
||||
};
|
||||
spawnResolvers.push(entry);
|
||||
return proc;
|
||||
}),
|
||||
execSync: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Extend existing fs mock with methods needed by installNpmPackage
|
||||
vi.doMock("fs", () => ({
|
||||
existsSync: (...args: unknown[]) => mockExistsSync(...args),
|
||||
readdirSync: (...args: unknown[]) => mockReaddirSync(...args),
|
||||
mkdirSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
readFileSync: vi.fn(() => "{}"),
|
||||
rmSync: vi.fn(),
|
||||
}));
|
||||
});
|
||||
|
||||
it("should serialize concurrent installNpmPackage calls", async () => {
|
||||
const { installNpmPackage } = await import("../system/dependencies");
|
||||
|
||||
// Fire 3 installs concurrently
|
||||
const p1 = installNpmPackage("pkg-a");
|
||||
const p2 = installNpmPackage("pkg-b");
|
||||
const p3 = installNpmPackage("pkg-c");
|
||||
|
||||
// Only the first spawn should have been called (queue serializes)
|
||||
await vi.waitFor(() => expect(spawnCalls.length).toBe(1));
|
||||
expect(spawnCalls[0].args).toContain("pkg-a");
|
||||
|
||||
// pkg-b and pkg-c should NOT have spawned yet
|
||||
expect(spawnCalls.length).toBe(1);
|
||||
|
||||
// Complete first install
|
||||
spawnResolvers[0].resolve(0);
|
||||
await vi.waitFor(() => expect(spawnCalls.length).toBe(2));
|
||||
expect(spawnCalls[1].args).toContain("pkg-b");
|
||||
|
||||
// Complete second install
|
||||
spawnResolvers[1].resolve(0);
|
||||
await vi.waitFor(() => expect(spawnCalls.length).toBe(3));
|
||||
expect(spawnCalls[2].args).toContain("pkg-c");
|
||||
|
||||
// Complete third install
|
||||
spawnResolvers[2].resolve(0);
|
||||
|
||||
const [r1, r2, r3] = await Promise.all([p1, p2, p3]);
|
||||
expect(r1.success).toBe(true);
|
||||
expect(r2.success).toBe(true);
|
||||
expect(r3.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should continue queue after a failed install", async () => {
|
||||
const { installNpmPackage } = await import("../system/dependencies");
|
||||
|
||||
const p1 = installNpmPackage("fail-pkg");
|
||||
const p2 = installNpmPackage("ok-pkg");
|
||||
|
||||
await vi.waitFor(() => expect(spawnCalls.length).toBe(1));
|
||||
|
||||
// Fail the first install (non-zero exit code)
|
||||
spawnResolvers[0].resolve(1);
|
||||
|
||||
// Second install should still proceed
|
||||
await vi.waitFor(() => expect(spawnCalls.length).toBe(2));
|
||||
expect(spawnCalls[1].args).toContain("ok-pkg");
|
||||
|
||||
// Complete second install successfully
|
||||
spawnResolvers[1].resolve(0);
|
||||
|
||||
const [r1, r2] = await Promise.all([p1, p2]);
|
||||
expect(r1.success).toBe(false);
|
||||
expect(r2.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bundled resources", () => {
|
||||
const mockLog = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockReturnValue([]);
|
||||
});
|
||||
|
||||
describe("getNodeBinPath", () => {
|
||||
// 归一化路径分隔符,使断言在 Windows CI(反斜杠)与 macOS/Linux(正斜杠)下均通过
|
||||
const normalized = (p: string | null) => (p ?? "").replace(/\\/g, "/");
|
||||
|
||||
it("should return correct path on macOS x64", async () => {
|
||||
vi.resetModules();
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: "darwin",
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(process, "arch", {
|
||||
value: "x64",
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const { getNodeBinPath } = await import("../system/dependencies");
|
||||
const result = getNodeBinPath();
|
||||
|
||||
// Use toContain so both relative path (app) and absolute path (CI) pass
|
||||
expect(result).toContain("darwin-x64");
|
||||
expect(normalized(result)).toContain("bin/node");
|
||||
expect(normalized(result)).not.toContain("node.exe");
|
||||
});
|
||||
|
||||
it("should return correct path on macOS arm64", async () => {
|
||||
vi.resetModules();
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: "darwin",
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(process, "arch", {
|
||||
value: "arm64",
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const { getNodeBinPath } = await import("../system/dependencies");
|
||||
const result = getNodeBinPath();
|
||||
|
||||
// Use toContain so both relative path (app) and absolute path (CI) pass
|
||||
expect(result).toContain("darwin-arm64");
|
||||
expect(normalized(result)).toContain("bin/node");
|
||||
});
|
||||
|
||||
it("should return correct path on Windows x64", async () => {
|
||||
vi.resetModules();
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: "win32",
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(process, "arch", {
|
||||
value: "x64",
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const { getNodeBinPath } = await import("../system/dependencies");
|
||||
const result = getNodeBinPath();
|
||||
|
||||
// Use toContain so both relative path (app) and absolute path (CI) pass
|
||||
expect(result).toContain("win32-x64");
|
||||
expect(normalized(result)).toContain("bin/node.exe");
|
||||
});
|
||||
|
||||
it("should return correct path on Linux x64", async () => {
|
||||
vi.resetModules();
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: "linux",
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(process, "arch", {
|
||||
value: "x64",
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const { getNodeBinPath } = await import("../system/dependencies");
|
||||
const result = getNodeBinPath();
|
||||
|
||||
expect(result).toContain("linux-x64");
|
||||
expect(normalized(result)).toContain("bin/node");
|
||||
});
|
||||
|
||||
it("should return null when node binary does not exist", async () => {
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
|
||||
const { getNodeBinPath } = await import("../system/dependencies");
|
||||
const result = getNodeBinPath();
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should log warning when node binary does not exist", async () => {
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
|
||||
const { getNodeBinPath } = await import("../system/dependencies");
|
||||
// Mock electron-log after importing
|
||||
const log = await import("electron-log");
|
||||
Object.assign(log.default, mockLog);
|
||||
|
||||
getNodeBinPath();
|
||||
|
||||
expect(mockLog.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Bundled Node.js not found"),
|
||||
);
|
||||
expect(mockLog.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("prepare:node"),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* 单元测试: deviceId
|
||||
*
|
||||
* 测试设备 ID 生成、缓存与异常回退逻辑
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
// Mock electron-log
|
||||
vi.mock('electron-log', () => ({
|
||||
default: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock node-machine-id
|
||||
const mockMachineIdSync = vi.fn();
|
||||
vi.mock('node-machine-id', () => ({
|
||||
machineIdSync: (...args: unknown[]) => mockMachineIdSync(...args),
|
||||
}));
|
||||
|
||||
// Mock os.hostname
|
||||
const mockHostname = vi.fn(() => 'mock-hostname');
|
||||
vi.mock('os', () => ({
|
||||
hostname: () => mockHostname(),
|
||||
}));
|
||||
|
||||
describe('deviceId', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
mockMachineIdSync.mockReturnValue('mock-machine-id');
|
||||
mockHostname.mockReturnValue('mock-hostname');
|
||||
});
|
||||
|
||||
it('should return a 64-char hex string', async () => {
|
||||
const { getDeviceId } = await import('./deviceId');
|
||||
const id = getDeviceId();
|
||||
|
||||
expect(id).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it('should hash machineId + salt with SHA-256', async () => {
|
||||
const { getDeviceId } = await import('./deviceId');
|
||||
const id = getDeviceId();
|
||||
|
||||
const expected = createHash('sha256')
|
||||
.update('mock-machine-id' + 'qiming-agent')
|
||||
.digest('hex');
|
||||
expect(id).toBe(expected);
|
||||
});
|
||||
|
||||
it('should call machineIdSync with original=true', async () => {
|
||||
const { getDeviceId } = await import('./deviceId');
|
||||
getDeviceId();
|
||||
|
||||
expect(mockMachineIdSync).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should cache the result on subsequent calls', async () => {
|
||||
const { getDeviceId } = await import('./deviceId');
|
||||
const first = getDeviceId();
|
||||
const second = getDeviceId();
|
||||
|
||||
expect(first).toBe(second);
|
||||
expect(mockMachineIdSync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should log the deviceId on first call', async () => {
|
||||
const { getDeviceId } = await import('./deviceId');
|
||||
const log = (await import('electron-log')).default;
|
||||
const id = getDeviceId();
|
||||
|
||||
expect(log.info).toHaveBeenCalledWith(`[DeviceId] ${id}`);
|
||||
});
|
||||
|
||||
it('should fallback to hostname when machineIdSync throws', async () => {
|
||||
mockMachineIdSync.mockImplementation(() => {
|
||||
throw new Error('no machine-id');
|
||||
});
|
||||
|
||||
const { getDeviceId } = await import('./deviceId');
|
||||
const log = (await import('electron-log')).default;
|
||||
const id = getDeviceId();
|
||||
|
||||
const expected = createHash('sha256')
|
||||
.update('mock-hostname' + 'qiming-agent')
|
||||
.digest('hex');
|
||||
expect(id).toBe(expected);
|
||||
expect(log.warn).toHaveBeenCalledWith(
|
||||
'[DeviceId] Failed to read machineId, using hostname fallback:',
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
|
||||
it('should produce different IDs for different machineIds', async () => {
|
||||
mockMachineIdSync.mockReturnValue('machine-a');
|
||||
const mod1 = await import('./deviceId');
|
||||
const idA = mod1.getDeviceId();
|
||||
|
||||
vi.resetModules();
|
||||
mockMachineIdSync.mockReturnValue('machine-b');
|
||||
const mod2 = await import('./deviceId');
|
||||
const idB = mod2.getDeviceId();
|
||||
|
||||
expect(idA).not.toBe(idB);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { machineIdSync } from "node-machine-id";
|
||||
import { createHash } from "crypto";
|
||||
import * as os from "os";
|
||||
import log from "electron-log";
|
||||
|
||||
const APP_SALT = "qiming-agent";
|
||||
let cachedDeviceId: string | null = null;
|
||||
|
||||
export function getDeviceId(): string {
|
||||
if (cachedDeviceId) return cachedDeviceId;
|
||||
|
||||
let raw: string;
|
||||
try {
|
||||
raw = machineIdSync(true);
|
||||
} catch (e) {
|
||||
log.warn(
|
||||
"[DeviceId] Failed to read machineId, using hostname fallback:",
|
||||
e,
|
||||
);
|
||||
raw = os.hostname();
|
||||
}
|
||||
|
||||
cachedDeviceId = createHash("sha256")
|
||||
.update(raw + APP_SALT)
|
||||
.digest("hex");
|
||||
|
||||
log.info(`[DeviceId] ${cachedDeviceId}`);
|
||||
return cachedDeviceId;
|
||||
}
|
||||
|
||||
export function logSystemInfo(): void {
|
||||
// 固定字段尽量保持稳定输出,便于跨平台日志分析与检索。
|
||||
const baseInfo = {
|
||||
platform: process.platform,
|
||||
arch: os.arch(),
|
||||
release: os.release(),
|
||||
nodeVersion: process.versions.node,
|
||||
electronVersion: process.versions.electron,
|
||||
chromeVersion: process.versions.chrome,
|
||||
// 保留毫秒级时间戳,方便日志系统或外部工具做数值排序/聚合分析。
|
||||
timestamp: Date.now(),
|
||||
// 额外输出 UTC 时间字符串(ISO 8601),便于人工阅读与跨时区排查问题。
|
||||
// 例如:2026-04-09T08:12:34.567Z,其中 `Z` 代表 UTC(不受本地时区影响)。
|
||||
timestampUtc: new Date().toISOString(),
|
||||
};
|
||||
|
||||
try {
|
||||
// `electron` 在极少数受限环境/初始化早期可能不可用;由外层 catch 统一降级。
|
||||
const electron = require("electron");
|
||||
const app = electron?.app;
|
||||
const screen = electron?.screen;
|
||||
const primary = screen?.getPrimaryDisplay?.();
|
||||
const screenInfo =
|
||||
primary?.size?.width && primary?.size?.height && primary?.scaleFactor
|
||||
? `${primary.size.width}x${primary.size.height}@${primary.scaleFactor}x`
|
||||
: "unknown";
|
||||
const timezone =
|
||||
Intl.DateTimeFormat().resolvedOptions().timeZone || "unknown";
|
||||
const cpuCount = os.cpus()?.length ?? 0;
|
||||
const totalMemoryGB = Math.round(os.totalmem() / 1024 / 1024 / 1024);
|
||||
const freeMemoryGB = Math.round(os.freemem() / 1024 / 1024 / 1024);
|
||||
|
||||
log.info("[System]", {
|
||||
...baseInfo,
|
||||
version: app?.getVersion?.() ?? "unknown",
|
||||
locale: app?.getLocale?.() ?? "unknown",
|
||||
timezone,
|
||||
cpuCount,
|
||||
totalMemoryGB,
|
||||
freeMemoryGB,
|
||||
screen: screenInfo,
|
||||
});
|
||||
} catch (e) {
|
||||
// 系统信息采集仅用于诊断,任何异常都不应影响客户端启动和核心功能。
|
||||
log.warn("[System] Failed to collect system info:", e);
|
||||
// 即使进入降级路径,也要输出固定字段,避免日志结构缺失。
|
||||
log.info("[System]", {
|
||||
...baseInfo,
|
||||
version: "unknown",
|
||||
locale: "unknown",
|
||||
timezone: "unknown",
|
||||
cpuCount: 0,
|
||||
totalMemoryGB: 0,
|
||||
freeMemoryGB: 0,
|
||||
screen: "unknown",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Main process system services
|
||||
export { getDeviceId } from './deviceId';
|
||||
export { getAppEnv, setMirrorConfig } from './dependencies';
|
||||
export {
|
||||
getAppDataDir,
|
||||
getWorkspacesDir,
|
||||
getDefaultWorkspace,
|
||||
getSessionWorkspace,
|
||||
deleteSessionWorkspace,
|
||||
getSessionFiles,
|
||||
saveWorkspaceConfig,
|
||||
getWorkspaceConfig,
|
||||
listWorkspaces,
|
||||
cleanupOldWorkspaces,
|
||||
} from './workspaceManager';
|
||||
@@ -0,0 +1,116 @@
|
||||
import * as fs from "fs";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createPlatformAdapter,
|
||||
getCurrentPlatform,
|
||||
isSupportedPlatform,
|
||||
} from "./platformAdapter";
|
||||
|
||||
describe("platformAdapter", () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: originalPlatform,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should expose Windows probe and sandbox traits", () => {
|
||||
const adapter = createPlatformAdapter("win32");
|
||||
|
||||
expect(adapter.isWindows).toBe(true);
|
||||
expect(adapter.pathDelimiter).toBe(";");
|
||||
expect(adapter.sandboxHelperName).toBe("qiming-sandbox-helper.exe");
|
||||
expect(adapter.getCommandProbe("git")).toEqual({
|
||||
command: "where",
|
||||
args: ["git"],
|
||||
});
|
||||
expect(adapter.getRecommendedSandboxBackend()).toBe("windows-sandbox");
|
||||
expect(adapter.getRecommendedSandboxType()).toBe("windows-sandbox");
|
||||
});
|
||||
|
||||
it("should expose Unix probe and seatbelt traits for macOS", () => {
|
||||
const adapter = createPlatformAdapter("darwin");
|
||||
|
||||
expect(adapter.isMacOS).toBe(true);
|
||||
expect(adapter.pathDelimiter).toBe(":");
|
||||
expect(adapter.sandboxHelperName).toBe("qiming-sandbox-helper");
|
||||
expect(adapter.getCommandProbe("git")).toEqual({
|
||||
command: "which",
|
||||
args: ["git"],
|
||||
});
|
||||
expect(adapter.getSeatbeltPath()).toBe("/usr/bin/sandbox-exec");
|
||||
expect(adapter.getRecommendedSandboxBackend()).toBe("macos-seatbelt");
|
||||
});
|
||||
|
||||
it("should resolve Linux bundled bwrap path from candidates", () => {
|
||||
const adapter = createPlatformAdapter("linux");
|
||||
const resourcesRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "platform-adapter-test-"),
|
||||
);
|
||||
const bundledBwrap = path.join(
|
||||
resourcesRoot,
|
||||
"sandbox-runtime",
|
||||
"linux",
|
||||
"bwrap",
|
||||
);
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(bundledBwrap), { recursive: true });
|
||||
fs.writeFileSync(bundledBwrap, "");
|
||||
|
||||
const resolved = adapter.resolveBundledLinuxBwrapPath(resourcesRoot);
|
||||
expect(resolved).toBe(bundledBwrap);
|
||||
} finally {
|
||||
fs.rmSync(resourcesRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("should include Windows helper candidates with windows-specific path priority", () => {
|
||||
const adapter = createPlatformAdapter("win32");
|
||||
const candidates =
|
||||
adapter.getBundledSandboxHelperCandidates("/mock/resources");
|
||||
|
||||
expect(candidates).toEqual([
|
||||
path.join(
|
||||
"/mock/resources",
|
||||
"sandbox-runtime",
|
||||
"bin",
|
||||
"qiming-sandbox-helper.exe",
|
||||
),
|
||||
path.join(
|
||||
"/mock/resources",
|
||||
"sandbox-runtime",
|
||||
"windows",
|
||||
"qiming-sandbox-helper.exe",
|
||||
),
|
||||
path.join(
|
||||
"/mock/resources",
|
||||
"sandbox-helper",
|
||||
"qiming-sandbox-helper.exe",
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should classify supported platforms and fallback current platform", () => {
|
||||
expect(isSupportedPlatform("darwin")).toBe(true);
|
||||
expect(isSupportedPlatform("linux")).toBe(true);
|
||||
expect(isSupportedPlatform("win32")).toBe(true);
|
||||
expect(isSupportedPlatform("freebsd")).toBe(false);
|
||||
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: "freebsd",
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
expect(getCurrentPlatform()).toBe("linux");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import type {
|
||||
Platform as SandboxPlatform,
|
||||
SandboxBackend,
|
||||
SandboxType,
|
||||
} from "@shared/types/sandbox";
|
||||
|
||||
export type Platform = SandboxPlatform;
|
||||
|
||||
type CommandProbe = {
|
||||
command: string;
|
||||
args: string[];
|
||||
};
|
||||
|
||||
export interface PlatformAdapter {
|
||||
readonly platform: Platform;
|
||||
readonly isWindows: boolean;
|
||||
readonly isMacOS: boolean;
|
||||
readonly isLinux: boolean;
|
||||
readonly pathDelimiter: ":" | ";";
|
||||
readonly commandLocator: "which" | "where";
|
||||
readonly sandboxHelperName: string;
|
||||
getCommandProbe(command: string): CommandProbe;
|
||||
getRecommendedSandboxBackend(): SandboxBackend;
|
||||
getRecommendedSandboxType(): SandboxType;
|
||||
getSeatbeltPath(): string | null;
|
||||
getBundledLinuxBwrapCandidates(resourcesPath: string): string[];
|
||||
resolveBundledLinuxBwrapPath(resourcesPath: string): string | null;
|
||||
getBundledSandboxHelperCandidates(resourcesPath: string): string[];
|
||||
resolveBundledSandboxHelperPath(resourcesPath: string): string | null;
|
||||
}
|
||||
|
||||
export function isSupportedPlatform(value: string): value is Platform {
|
||||
return value === "darwin" || value === "linux" || value === "win32";
|
||||
}
|
||||
|
||||
export function getCurrentPlatform(): Platform {
|
||||
return isSupportedPlatform(process.platform) ? process.platform : "linux";
|
||||
}
|
||||
|
||||
class RuntimePlatformAdapter implements PlatformAdapter {
|
||||
readonly isWindows: boolean;
|
||||
readonly isMacOS: boolean;
|
||||
readonly isLinux: boolean;
|
||||
readonly pathDelimiter: ":" | ";";
|
||||
readonly commandLocator: "which" | "where";
|
||||
readonly sandboxHelperName: string;
|
||||
|
||||
constructor(readonly platform: Platform) {
|
||||
this.isWindows = platform === "win32";
|
||||
this.isMacOS = platform === "darwin";
|
||||
this.isLinux = platform === "linux";
|
||||
this.pathDelimiter = this.isWindows ? ";" : ":";
|
||||
this.commandLocator = this.isWindows ? "where" : "which";
|
||||
this.sandboxHelperName = this.isWindows
|
||||
? "qiming-sandbox-helper.exe"
|
||||
: "qiming-sandbox-helper";
|
||||
}
|
||||
|
||||
getCommandProbe(command: string): CommandProbe {
|
||||
return {
|
||||
command: this.commandLocator,
|
||||
args: [command],
|
||||
};
|
||||
}
|
||||
|
||||
getRecommendedSandboxBackend(): SandboxBackend {
|
||||
if (this.isWindows) return "windows-sandbox";
|
||||
if (this.isMacOS) return "macos-seatbelt";
|
||||
return "linux-bwrap";
|
||||
}
|
||||
|
||||
getRecommendedSandboxType(): SandboxType {
|
||||
if (this.isWindows) return "windows-sandbox";
|
||||
if (this.isMacOS) return "macos-seatbelt";
|
||||
return "linux-bwrap";
|
||||
}
|
||||
|
||||
getSeatbeltPath(): string | null {
|
||||
return this.isMacOS ? "/usr/bin/sandbox-exec" : null;
|
||||
}
|
||||
|
||||
getBundledLinuxBwrapCandidates(resourcesPath: string): string[] {
|
||||
const runtimeDir = path.join(resourcesPath, "sandbox-runtime");
|
||||
return [
|
||||
path.join(runtimeDir, "bin", "bwrap"),
|
||||
path.join(runtimeDir, "linux", "bwrap"),
|
||||
];
|
||||
}
|
||||
|
||||
resolveBundledLinuxBwrapPath(resourcesPath: string): string | null {
|
||||
for (const candidate of this.getBundledLinuxBwrapCandidates(
|
||||
resourcesPath,
|
||||
)) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getBundledSandboxHelperCandidates(resourcesPath: string): string[] {
|
||||
const runtimeDir = path.join(resourcesPath, "sandbox-runtime");
|
||||
const helperRoot = path.join(resourcesPath, "sandbox-helper");
|
||||
const candidates = [
|
||||
path.join(runtimeDir, "bin", this.sandboxHelperName),
|
||||
path.join(helperRoot, this.sandboxHelperName),
|
||||
];
|
||||
|
||||
if (this.isWindows) {
|
||||
candidates.splice(
|
||||
1,
|
||||
0,
|
||||
path.join(runtimeDir, "windows", this.sandboxHelperName),
|
||||
);
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
resolveBundledSandboxHelperPath(resourcesPath: string): string | null {
|
||||
for (const candidate of this.getBundledSandboxHelperCandidates(
|
||||
resourcesPath,
|
||||
)) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function createPlatformAdapter(
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): PlatformAdapter {
|
||||
const resolvedPlatform = isSupportedPlatform(platform) ? platform : "linux";
|
||||
return new RuntimePlatformAdapter(resolvedPlatform);
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
/**
|
||||
* 单元测试: ProcessRegistry
|
||||
*
|
||||
* 测试进程注册、注销、定期扫描和孤儿清理逻辑
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Mock electron-log
|
||||
vi.mock("electron-log", () => ({
|
||||
default: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock killProcessTreeGraceful
|
||||
const mockKillProcessTreeGraceful = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mock("../utils/processTree", () => ({
|
||||
killProcessTreeGraceful: (...args: unknown[]) =>
|
||||
mockKillProcessTreeGraceful(...args),
|
||||
}));
|
||||
|
||||
// Mock process.kill for isProcessAlive checks
|
||||
const originalProcessKill = process.kill;
|
||||
|
||||
describe("ProcessRegistry", () => {
|
||||
let processRegistry: Awaited<
|
||||
typeof import("./processRegistry")
|
||||
>["processRegistry"];
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
vi.useFakeTimers();
|
||||
|
||||
// Re-import to get a fresh singleton
|
||||
const mod = await import("./processRegistry");
|
||||
processRegistry = mod.processRegistry;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Ensure sweep timers are stopped
|
||||
processRegistry.stopPeriodicSweep();
|
||||
vi.useRealTimers();
|
||||
process.kill = originalProcessKill;
|
||||
});
|
||||
|
||||
// === Register / Unregister ===
|
||||
|
||||
describe("register", () => {
|
||||
it("should register a process and increment size", () => {
|
||||
expect(processRegistry.size).toBe(0);
|
||||
|
||||
processRegistry.register(1234, {
|
||||
engineId: "acp-123-abc",
|
||||
engineType: "claude-code",
|
||||
purpose: "engine",
|
||||
});
|
||||
|
||||
expect(processRegistry.size).toBe(1);
|
||||
});
|
||||
|
||||
it("should overwrite an existing entry for the same PID", () => {
|
||||
processRegistry.register(1234, {
|
||||
engineId: "acp-1",
|
||||
engineType: "claude-code",
|
||||
purpose: "engine",
|
||||
});
|
||||
processRegistry.register(1234, {
|
||||
engineId: "acp-2",
|
||||
engineType: "qimingcode",
|
||||
purpose: "engine",
|
||||
});
|
||||
|
||||
expect(processRegistry.size).toBe(1);
|
||||
});
|
||||
|
||||
it("should register multiple different PIDs", () => {
|
||||
processRegistry.register(100, {
|
||||
engineId: "acp-1",
|
||||
engineType: "claude-code",
|
||||
purpose: "engine",
|
||||
});
|
||||
processRegistry.register(200, {
|
||||
engineId: "acp-2",
|
||||
engineType: "qimingcode",
|
||||
purpose: "engine",
|
||||
});
|
||||
|
||||
expect(processRegistry.size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unregister", () => {
|
||||
it("should remove a registered process", () => {
|
||||
processRegistry.register(1234, {
|
||||
engineId: "acp-1",
|
||||
engineType: "claude-code",
|
||||
purpose: "engine",
|
||||
});
|
||||
expect(processRegistry.size).toBe(1);
|
||||
|
||||
processRegistry.unregister(1234);
|
||||
expect(processRegistry.size).toBe(0);
|
||||
});
|
||||
|
||||
it("should be idempotent for non-existent PID", () => {
|
||||
processRegistry.unregister(9999);
|
||||
expect(processRegistry.size).toBe(0);
|
||||
});
|
||||
|
||||
it("should be idempotent when called twice", () => {
|
||||
processRegistry.register(1234, {
|
||||
engineId: "acp-1",
|
||||
engineType: "claude-code",
|
||||
purpose: "engine",
|
||||
});
|
||||
|
||||
processRegistry.unregister(1234);
|
||||
processRegistry.unregister(1234);
|
||||
expect(processRegistry.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// === sweepNow ===
|
||||
|
||||
describe("sweepNow", () => {
|
||||
it("should return zeroes when registry is empty", async () => {
|
||||
const result = await processRegistry.sweepNow();
|
||||
expect(result).toEqual({ scanned: 0, orphans: 0, killed: 0 });
|
||||
});
|
||||
|
||||
it("should skip processes within grace period", async () => {
|
||||
processRegistry.register(1234, {
|
||||
engineId: "acp-1",
|
||||
engineType: "claude-code",
|
||||
purpose: "engine",
|
||||
});
|
||||
|
||||
// Don't advance time — still within grace period
|
||||
const result = await processRegistry.sweepNow();
|
||||
expect(result.scanned).toBe(1);
|
||||
expect(result.orphans).toBe(0);
|
||||
expect(processRegistry.size).toBe(1);
|
||||
});
|
||||
|
||||
it("should remove dead processes after grace period", async () => {
|
||||
processRegistry.register(1234, {
|
||||
engineId: "acp-1",
|
||||
engineType: "claude-code",
|
||||
purpose: "engine",
|
||||
});
|
||||
|
||||
// Advance past grace period
|
||||
vi.advanceTimersByTime(31_000);
|
||||
|
||||
// Mock process as dead
|
||||
process.kill = vi.fn(() => {
|
||||
throw new Error("ESRCH");
|
||||
}) as unknown as typeof process.kill;
|
||||
|
||||
const result = await processRegistry.sweepNow();
|
||||
expect(result.scanned).toBe(1);
|
||||
expect(result.orphans).toBe(0); // Dead, not orphan
|
||||
expect(processRegistry.size).toBe(0); // Removed
|
||||
});
|
||||
|
||||
it("should detect and kill orphan processes", async () => {
|
||||
processRegistry.register(1234, {
|
||||
engineId: "acp-1",
|
||||
engineType: "claude-code",
|
||||
purpose: "engine",
|
||||
});
|
||||
|
||||
// Advance past grace period
|
||||
vi.advanceTimersByTime(31_000);
|
||||
|
||||
// Mock process as alive
|
||||
process.kill = vi.fn() as unknown as typeof process.kill;
|
||||
|
||||
// No active PIDs bound (or bound with empty set)
|
||||
processRegistry.bindActivePidsFn(() => new Set());
|
||||
|
||||
const result = await processRegistry.sweepNow();
|
||||
expect(result.scanned).toBe(1);
|
||||
expect(result.orphans).toBe(1);
|
||||
expect(result.killed).toBe(1);
|
||||
expect(mockKillProcessTreeGraceful).toHaveBeenCalledWith(1234, 5000);
|
||||
expect(processRegistry.size).toBe(0);
|
||||
});
|
||||
|
||||
it("should not kill active processes", async () => {
|
||||
processRegistry.register(1234, {
|
||||
engineId: "acp-1",
|
||||
engineType: "claude-code",
|
||||
purpose: "engine",
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(31_000);
|
||||
|
||||
// Mock process as alive
|
||||
process.kill = vi.fn() as unknown as typeof process.kill;
|
||||
|
||||
// PID 1234 is active
|
||||
processRegistry.bindActivePidsFn(() => new Set([1234]));
|
||||
|
||||
const result = await processRegistry.sweepNow();
|
||||
expect(result.scanned).toBe(1);
|
||||
expect(result.orphans).toBe(0);
|
||||
expect(result.killed).toBe(0);
|
||||
expect(mockKillProcessTreeGraceful).not.toHaveBeenCalled();
|
||||
expect(processRegistry.size).toBe(1); // Still registered
|
||||
});
|
||||
|
||||
it("should handle killProcessTreeGraceful failure gracefully", async () => {
|
||||
processRegistry.register(1234, {
|
||||
engineId: "acp-1",
|
||||
engineType: "claude-code",
|
||||
purpose: "engine",
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(31_000);
|
||||
process.kill = vi.fn() as unknown as typeof process.kill;
|
||||
processRegistry.bindActivePidsFn(() => new Set());
|
||||
|
||||
mockKillProcessTreeGraceful.mockRejectedValueOnce(
|
||||
new Error("kill failed"),
|
||||
);
|
||||
|
||||
const result = await processRegistry.sweepNow();
|
||||
expect(result.scanned).toBe(1);
|
||||
expect(result.orphans).toBe(1);
|
||||
expect(result.killed).toBe(0); // Failed to kill
|
||||
expect(processRegistry.size).toBe(0); // Still removed from registry
|
||||
});
|
||||
});
|
||||
|
||||
// === killOrphans (no grace period) ===
|
||||
|
||||
describe("killOrphans", () => {
|
||||
it("should skip grace period and kill orphans immediately", async () => {
|
||||
processRegistry.register(1234, {
|
||||
engineId: "acp-1",
|
||||
engineType: "claude-code",
|
||||
purpose: "engine",
|
||||
});
|
||||
|
||||
// Do NOT advance time — process was just registered
|
||||
process.kill = vi.fn() as unknown as typeof process.kill;
|
||||
processRegistry.bindActivePidsFn(() => new Set());
|
||||
|
||||
const result = await processRegistry.killOrphans();
|
||||
expect(result.orphans).toBe(1);
|
||||
expect(result.killed).toBe(1);
|
||||
expect(mockKillProcessTreeGraceful).toHaveBeenCalledWith(1234, 5000);
|
||||
});
|
||||
|
||||
it("should not kill active processes even without grace period", async () => {
|
||||
processRegistry.register(1234, {
|
||||
engineId: "acp-1",
|
||||
engineType: "claude-code",
|
||||
purpose: "engine",
|
||||
});
|
||||
|
||||
process.kill = vi.fn() as unknown as typeof process.kill;
|
||||
processRegistry.bindActivePidsFn(() => new Set([1234]));
|
||||
|
||||
const result = await processRegistry.killOrphans();
|
||||
expect(result.orphans).toBe(0);
|
||||
expect(processRegistry.size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// === killAll ===
|
||||
|
||||
describe("killAll", () => {
|
||||
it("should do nothing when registry is empty", async () => {
|
||||
await processRegistry.killAll();
|
||||
expect(mockKillProcessTreeGraceful).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should kill all registered processes", async () => {
|
||||
processRegistry.register(100, {
|
||||
engineId: "acp-1",
|
||||
engineType: "claude-code",
|
||||
purpose: "engine",
|
||||
});
|
||||
processRegistry.register(200, {
|
||||
engineId: "acp-2",
|
||||
engineType: "qimingcode",
|
||||
purpose: "engine",
|
||||
});
|
||||
|
||||
// Mock both as alive
|
||||
process.kill = vi.fn() as unknown as typeof process.kill;
|
||||
|
||||
await processRegistry.killAll();
|
||||
|
||||
expect(mockKillProcessTreeGraceful).toHaveBeenCalledTimes(2);
|
||||
expect(mockKillProcessTreeGraceful).toHaveBeenCalledWith(100, 3000);
|
||||
expect(mockKillProcessTreeGraceful).toHaveBeenCalledWith(200, 3000);
|
||||
expect(processRegistry.size).toBe(0);
|
||||
});
|
||||
|
||||
it("should skip already-dead processes", async () => {
|
||||
processRegistry.register(100, {
|
||||
engineId: "acp-1",
|
||||
engineType: "claude-code",
|
||||
purpose: "engine",
|
||||
});
|
||||
|
||||
// Mock process as dead
|
||||
process.kill = vi.fn(() => {
|
||||
throw new Error("ESRCH");
|
||||
}) as unknown as typeof process.kill;
|
||||
|
||||
await processRegistry.killAll();
|
||||
|
||||
expect(mockKillProcessTreeGraceful).not.toHaveBeenCalled();
|
||||
expect(processRegistry.size).toBe(0); // Still cleared
|
||||
});
|
||||
});
|
||||
|
||||
// === Periodic Sweep ===
|
||||
|
||||
describe("startPeriodicSweep / stopPeriodicSweep", () => {
|
||||
it("should not start duplicate timers", () => {
|
||||
processRegistry.startPeriodicSweep(60_000);
|
||||
processRegistry.startPeriodicSweep(60_000); // Should be a no-op
|
||||
processRegistry.stopPeriodicSweep();
|
||||
});
|
||||
|
||||
it("should run sweep on interval", async () => {
|
||||
// Register an orphan process past grace period
|
||||
processRegistry.register(1234, {
|
||||
engineId: "acp-1",
|
||||
engineType: "claude-code",
|
||||
purpose: "engine",
|
||||
});
|
||||
processRegistry.bindActivePidsFn(() => new Set());
|
||||
|
||||
// Mock process as alive
|
||||
process.kill = vi.fn() as unknown as typeof process.kill;
|
||||
|
||||
processRegistry.startPeriodicSweep(1000);
|
||||
|
||||
// Advance past grace period + one sweep interval
|
||||
vi.advanceTimersByTime(31_000);
|
||||
|
||||
// Let async sweep complete
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
expect(mockKillProcessTreeGraceful).toHaveBeenCalledWith(1234, 5000);
|
||||
});
|
||||
|
||||
it("stopPeriodicSweep should be safe when no sweep is running", () => {
|
||||
processRegistry.stopPeriodicSweep(); // Should not throw
|
||||
});
|
||||
});
|
||||
|
||||
// === bindActivePidsFn ===
|
||||
|
||||
describe("bindActivePidsFn", () => {
|
||||
it("should use empty set when no function is bound", async () => {
|
||||
processRegistry.register(1234, {
|
||||
engineId: "acp-1",
|
||||
engineType: "claude-code",
|
||||
purpose: "engine",
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(31_000);
|
||||
process.kill = vi.fn() as unknown as typeof process.kill;
|
||||
|
||||
// No bindActivePidsFn called — should treat all as orphans
|
||||
const result = await processRegistry.sweepNow();
|
||||
expect(result.orphans).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* ProcessRegistry — 进程注册中心 + 孤儿进程清理
|
||||
*
|
||||
* Layer 1: Register/Unregister — ACP 进程 spawn 时注册 PID,destroy 时注销
|
||||
* Layer 2: Periodic Sweep — 每 60s 扫描注册表,对比活跃引擎,杀掉孤儿进程
|
||||
*/
|
||||
|
||||
import log from "electron-log";
|
||||
import { killProcessTreeGraceful } from "../utils/processTree";
|
||||
|
||||
export interface RegisteredProcess {
|
||||
pid: number;
|
||||
engineId: string;
|
||||
engineType: "claude-code" | "qimingcode";
|
||||
purpose: "engine";
|
||||
registeredAt: number;
|
||||
}
|
||||
|
||||
export interface SweepResult {
|
||||
scanned: number;
|
||||
orphans: number;
|
||||
killed: number;
|
||||
}
|
||||
|
||||
class ProcessRegistry {
|
||||
private registry = new Map<number, RegisteredProcess>();
|
||||
private sweepTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private getActivePids: (() => Set<number>) | null = null;
|
||||
|
||||
/** Grace period: skip processes registered less than this many ms ago */
|
||||
private static readonly GRACE_PERIOD_MS = 30_000;
|
||||
|
||||
// === Register / Unregister ===
|
||||
|
||||
register(
|
||||
pid: number,
|
||||
info: Omit<RegisteredProcess, "pid" | "registeredAt">,
|
||||
): void {
|
||||
const entry: RegisteredProcess = {
|
||||
...info,
|
||||
pid,
|
||||
registeredAt: Date.now(),
|
||||
};
|
||||
this.registry.set(pid, entry);
|
||||
log.info(
|
||||
`[ProcessRegistry] Registered pid=${pid} engine=${info.engineId} type=${info.engineType} purpose=${info.purpose}`,
|
||||
);
|
||||
}
|
||||
|
||||
unregister(pid: number): void {
|
||||
const existed = this.registry.delete(pid);
|
||||
if (existed) {
|
||||
log.info(`[ProcessRegistry] Unregistered pid=${pid}`);
|
||||
}
|
||||
}
|
||||
|
||||
// === Active PID binding ===
|
||||
|
||||
bindActivePidsFn(fn: () => Set<number>): void {
|
||||
this.getActivePids = fn;
|
||||
}
|
||||
|
||||
// === Periodic Sweep ===
|
||||
|
||||
startPeriodicSweep(intervalMs = 300_000): void {
|
||||
if (this.sweepTimer) return;
|
||||
this.sweepTimer = setInterval(() => {
|
||||
this.sweepNow().catch((e) => {
|
||||
log.warn("[ProcessRegistry] Sweep error:", e);
|
||||
});
|
||||
}, intervalMs);
|
||||
log.info(
|
||||
`[ProcessRegistry] Periodic sweep started (interval=${intervalMs}ms)`,
|
||||
);
|
||||
}
|
||||
|
||||
stopPeriodicSweep(): void {
|
||||
if (this.sweepTimer) {
|
||||
clearInterval(this.sweepTimer);
|
||||
this.sweepTimer = null;
|
||||
log.info("[ProcessRegistry] Periodic sweep stopped");
|
||||
}
|
||||
}
|
||||
|
||||
async sweepNow(skipGracePeriod = false): Promise<SweepResult> {
|
||||
const activePids = this.getActivePids?.() ?? new Set<number>();
|
||||
const now = Date.now();
|
||||
let scanned = 0;
|
||||
let orphans = 0;
|
||||
let killed = 0;
|
||||
const toRemove: number[] = [];
|
||||
|
||||
for (const [pid, entry] of this.registry) {
|
||||
scanned++;
|
||||
|
||||
// Grace period: skip recently registered processes (unless explicitly skipped)
|
||||
if (
|
||||
!skipGracePeriod &&
|
||||
now - entry.registeredAt < ProcessRegistry.GRACE_PERIOD_MS
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if process is still alive
|
||||
if (!isProcessAlive(pid)) {
|
||||
toRemove.push(pid);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Alive but not in active set → orphan
|
||||
if (!activePids.has(pid)) {
|
||||
orphans++;
|
||||
log.warn(
|
||||
`[ProcessRegistry] Orphan detected: pid=${pid} engine=${entry.engineId} type=${entry.engineType} purpose=${entry.purpose}`,
|
||||
);
|
||||
try {
|
||||
await killProcessTreeGraceful(pid, 5000);
|
||||
killed++;
|
||||
log.info(`[ProcessRegistry] Killed orphan pid=${pid}`);
|
||||
} catch (e) {
|
||||
log.warn(`[ProcessRegistry] Failed to kill orphan pid=${pid}:`, e);
|
||||
}
|
||||
toRemove.push(pid);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up dead / killed entries
|
||||
for (const pid of toRemove) {
|
||||
this.registry.delete(pid);
|
||||
}
|
||||
|
||||
if (orphans > 0) {
|
||||
log.info(
|
||||
`[ProcessRegistry] Sweep complete: scanned=${scanned}, orphans=${orphans}, killed=${killed}`,
|
||||
);
|
||||
} else {
|
||||
log.debug(
|
||||
`[ProcessRegistry] Sweep complete: scanned=${scanned}, orphans=0`,
|
||||
);
|
||||
}
|
||||
|
||||
return { scanned, orphans, killed };
|
||||
}
|
||||
|
||||
// === Kill All (app exit) ===
|
||||
|
||||
async killAll(): Promise<void> {
|
||||
const pids = [...this.registry.keys()];
|
||||
if (pids.length === 0) return;
|
||||
|
||||
log.info(
|
||||
`[ProcessRegistry] killAll: cleaning up ${pids.length} registered processes`,
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
pids.map(async (pid) => {
|
||||
if (isProcessAlive(pid)) {
|
||||
try {
|
||||
await killProcessTreeGraceful(pid, 3000);
|
||||
log.info(`[ProcessRegistry] killAll: killed pid=${pid}`);
|
||||
} catch (e) {
|
||||
log.warn(
|
||||
`[ProcessRegistry] killAll: failed to kill pid=${pid}:`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
this.registry.clear();
|
||||
log.info("[ProcessRegistry] killAll: registry cleared");
|
||||
}
|
||||
|
||||
/** Kill orphans only (registered but not in active set, no grace period) */
|
||||
async killOrphans(): Promise<SweepResult> {
|
||||
return this.sweepNow(true);
|
||||
}
|
||||
|
||||
/** Get the number of registered processes (for diagnostics) */
|
||||
get size(): number {
|
||||
return this.registry.size;
|
||||
}
|
||||
}
|
||||
|
||||
function isProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const processRegistry = new ProcessRegistry();
|
||||
@@ -0,0 +1,471 @@
|
||||
/**
|
||||
* Shell Environment Manager - 跨平台 Shell 环境管理
|
||||
*
|
||||
* 功能:
|
||||
* - 检测可用 shell
|
||||
* - 工具路径管理
|
||||
* - PATH 配置
|
||||
* - Windows/macOS/Linux 兼容
|
||||
*/
|
||||
|
||||
import * as path from "path";
|
||||
import * as fs from "fs";
|
||||
import { spawn } from "child_process";
|
||||
import log from "electron-log";
|
||||
import {
|
||||
createPlatformAdapter,
|
||||
getCurrentPlatform,
|
||||
type Platform,
|
||||
} from "./platformAdapter";
|
||||
|
||||
// ==================== Types ====================
|
||||
export type { Platform } from "./platformAdapter";
|
||||
|
||||
export interface ShellInfo {
|
||||
name: string;
|
||||
path: string;
|
||||
version?: string;
|
||||
isAvailable: boolean;
|
||||
}
|
||||
|
||||
export interface ToolInfo {
|
||||
name: string;
|
||||
path?: string;
|
||||
version?: string;
|
||||
isAvailable: boolean;
|
||||
}
|
||||
|
||||
export interface ShellEnvironment {
|
||||
platform: Platform;
|
||||
shell: ShellInfo;
|
||||
tools: Map<string, ToolInfo>;
|
||||
path: string[];
|
||||
homeDir: string;
|
||||
}
|
||||
|
||||
// ==================== Platform Detection ====================
|
||||
|
||||
/**
|
||||
* 获取当前平台
|
||||
*/
|
||||
export function getPlatform(): Platform {
|
||||
return getCurrentPlatform();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测是否为 Windows
|
||||
*/
|
||||
export function isWindows(): boolean {
|
||||
return createPlatformAdapter().isWindows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测是否为 macOS
|
||||
*/
|
||||
export function isMacOS(): boolean {
|
||||
return createPlatformAdapter().isMacOS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测是否为 Linux
|
||||
*/
|
||||
export function isLinux(): boolean {
|
||||
return createPlatformAdapter().isLinux;
|
||||
}
|
||||
|
||||
// ==================== Shell Detection ====================
|
||||
|
||||
/**
|
||||
* 检测可用的 shell
|
||||
*/
|
||||
export async function detectShell(): Promise<ShellInfo> {
|
||||
const platform = getPlatform();
|
||||
const adapter = createPlatformAdapter(platform);
|
||||
|
||||
const shellCandidates: Array<{
|
||||
name: string;
|
||||
path: string;
|
||||
args?: string[];
|
||||
}> = [];
|
||||
|
||||
if (adapter.isMacOS) {
|
||||
// macOS: zsh (默认), bash
|
||||
shellCandidates.push(
|
||||
{ name: "zsh", path: "/bin/zsh", args: ["--version"] },
|
||||
{ name: "bash", path: "/bin/bash", args: ["--version"] },
|
||||
);
|
||||
} else if (adapter.isLinux) {
|
||||
// Linux: bash
|
||||
shellCandidates.push(
|
||||
{ name: "bash", path: "/bin/bash", args: ["--version"] },
|
||||
{ name: "sh", path: "/bin/sh" },
|
||||
);
|
||||
} else if (adapter.isWindows) {
|
||||
// Windows: PowerShell, cmd, Git Bash
|
||||
shellCandidates.push(
|
||||
{
|
||||
name: "powershell",
|
||||
path: "powershell.exe",
|
||||
args: ["-Command", "$PSVersionTable.PSVersion.ToString()"],
|
||||
},
|
||||
{ name: "cmd", path: "cmd.exe", args: ["/c", "ver"] },
|
||||
);
|
||||
|
||||
// 检查 Git Bash
|
||||
const gitBashPath = "C:\\Program Files\\Git\\bin\\bash.exe";
|
||||
if (fs.existsSync(gitBashPath)) {
|
||||
shellCandidates.push({
|
||||
name: "bash",
|
||||
path: gitBashPath,
|
||||
args: ["--version"],
|
||||
});
|
||||
}
|
||||
|
||||
// 检查 WSL
|
||||
try {
|
||||
const wslResult = await checkCommand("wsl.exe");
|
||||
if (wslResult) {
|
||||
shellCandidates.push({ name: "wsl", path: "wsl.exe" });
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// 尝试找到可用的 shell
|
||||
for (const shell of shellCandidates) {
|
||||
try {
|
||||
const version = await getCommandVersion(shell.path, shell.args);
|
||||
return {
|
||||
name: shell.name,
|
||||
path: shell.path,
|
||||
version: version ?? undefined,
|
||||
isAvailable: true,
|
||||
};
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 返回默认
|
||||
return {
|
||||
name: adapter.isWindows ? "powershell" : "bash",
|
||||
path: "",
|
||||
isAvailable: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测命令是否存在
|
||||
*/
|
||||
export async function checkCommand(cmd: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const adapter = createPlatformAdapter();
|
||||
const { command: checkCmd, args } = adapter.getCommandProbe(cmd);
|
||||
|
||||
const proc = spawn(checkCmd, args, {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
shell: true,
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
resolve(code === 0);
|
||||
});
|
||||
|
||||
proc.on("error", () => resolve(false));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取命令版本
|
||||
*/
|
||||
export async function getCommandVersion(
|
||||
cmd: string,
|
||||
args?: string[],
|
||||
): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn(cmd, args || ["--version"], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
shell: true,
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
|
||||
proc.stdout?.on("data", (data: Buffer) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
proc.on("close", () => {
|
||||
const match = stdout.match(/(\d+\.\d+\.\d+[\d.]*)/);
|
||||
resolve(match ? match[1] : stdout.trim() || null);
|
||||
});
|
||||
|
||||
proc.on("error", () => resolve(null));
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Essential Tools ====================
|
||||
|
||||
/**
|
||||
* 必需的工具列表
|
||||
*/
|
||||
export const ESSENTIAL_TOOLS = [
|
||||
// 文件操作
|
||||
"ls",
|
||||
"cd",
|
||||
"pwd",
|
||||
"mkdir",
|
||||
"rm",
|
||||
"cp",
|
||||
"mv",
|
||||
"cat",
|
||||
"echo",
|
||||
// 文本处理
|
||||
"grep",
|
||||
"sed",
|
||||
"awk",
|
||||
"sort",
|
||||
"uniq",
|
||||
"head",
|
||||
"tail",
|
||||
"wc",
|
||||
// 进程/网络
|
||||
"ps",
|
||||
"kill",
|
||||
"find",
|
||||
"xargs",
|
||||
"curl",
|
||||
"wget",
|
||||
// Git (可选)
|
||||
"git",
|
||||
// Node.js
|
||||
"node",
|
||||
"npm",
|
||||
"npx",
|
||||
];
|
||||
|
||||
const ESSENTIAL_TOOLS_WINDOWS = [
|
||||
"dir",
|
||||
"cd",
|
||||
"type",
|
||||
"echo",
|
||||
"mkdir",
|
||||
"del",
|
||||
"copy",
|
||||
"move",
|
||||
"findstr", // Windows grep
|
||||
"where",
|
||||
];
|
||||
|
||||
/**
|
||||
* 检测必需工具
|
||||
*/
|
||||
export async function detectTools(): Promise<Map<string, ToolInfo>> {
|
||||
const adapter = createPlatformAdapter();
|
||||
const tools = new Map<string, ToolInfo>();
|
||||
const toolsToCheck = adapter.isWindows
|
||||
? ESSENTIAL_TOOLS_WINDOWS
|
||||
: ESSENTIAL_TOOLS;
|
||||
|
||||
for (const tool of toolsToCheck) {
|
||||
const isAvailable = await checkCommand(tool);
|
||||
|
||||
let version: string | undefined;
|
||||
if (isAvailable) {
|
||||
version = (await getCommandVersion(tool)) || undefined;
|
||||
}
|
||||
|
||||
tools.set(tool, {
|
||||
name: tool,
|
||||
isAvailable,
|
||||
version,
|
||||
});
|
||||
}
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
// ==================== PATH Management ====================
|
||||
|
||||
/**
|
||||
* 获取系统 PATH
|
||||
*/
|
||||
export function getSystemPath(): string[] {
|
||||
const adapter = createPlatformAdapter();
|
||||
const pathEnv = process.env.PATH || "";
|
||||
return pathEnv.split(adapter.pathDelimiter).filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户 Home 目录
|
||||
*/
|
||||
export function getHomeDir(): string {
|
||||
return process.env.HOME || process.env.USERPROFILE || "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取默认工作目录
|
||||
*/
|
||||
export function getDefaultWorkspace(): string {
|
||||
const home = getHomeDir();
|
||||
return path.join(home, "QimingAgent", "workspace");
|
||||
}
|
||||
|
||||
// ==================== Environment Builder ====================
|
||||
|
||||
/**
|
||||
* 构建 Agent 运行环境
|
||||
*/
|
||||
export async function buildAgentEnvironment(options?: {
|
||||
workspaceDir?: string;
|
||||
includeTools?: boolean;
|
||||
}): Promise<{
|
||||
env: Record<string, string>;
|
||||
shell: ShellInfo;
|
||||
workspace: string;
|
||||
}> {
|
||||
const platform = getPlatform();
|
||||
const adapter = createPlatformAdapter(platform);
|
||||
const home = getHomeDir();
|
||||
const workspace = options?.workspaceDir || getDefaultWorkspace();
|
||||
|
||||
// 确保工作目录存在
|
||||
if (!fs.existsSync(workspace)) {
|
||||
fs.mkdirSync(workspace, { recursive: true });
|
||||
}
|
||||
|
||||
// 检测 shell
|
||||
const shell = await detectShell();
|
||||
|
||||
// 构建环境变量
|
||||
const env: Record<string, string> = {
|
||||
...process.env,
|
||||
|
||||
// Home 目录
|
||||
HOME: home,
|
||||
|
||||
// 工作目录
|
||||
WORKSPACE: workspace,
|
||||
PWD: workspace,
|
||||
|
||||
// 语言环境
|
||||
LANG: "en_US.UTF-8",
|
||||
LC_ALL: "en_US.UTF-8",
|
||||
};
|
||||
|
||||
// 平台特定配置
|
||||
if (adapter.isMacOS) {
|
||||
// macOS
|
||||
env.SHELL = "/bin/zsh";
|
||||
env.EDITOR = "vim";
|
||||
env.VISUAL = "vim";
|
||||
} else if (adapter.isLinux) {
|
||||
// Linux
|
||||
env.SHELL = "/bin/bash";
|
||||
env.EDITOR = "vim";
|
||||
env.VISUAL = "vim";
|
||||
} else if (adapter.isWindows) {
|
||||
// Windows
|
||||
env.SHELL = "powershell.exe";
|
||||
env.EDITOR = "notepad";
|
||||
}
|
||||
|
||||
// 可选: 检测并添加工具路径
|
||||
if (options?.includeTools) {
|
||||
const tools = await detectTools();
|
||||
|
||||
// 添加找到的工具路径
|
||||
const toolPaths: string[] = [];
|
||||
for (const [name, info] of tools) {
|
||||
if (info.isAvailable && info.path) {
|
||||
const dir = path.dirname(info.path);
|
||||
if (!toolPaths.includes(dir)) {
|
||||
toolPaths.push(dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加到 PATH 前面
|
||||
if (toolPaths.length > 0) {
|
||||
const currentPath = env.PATH || "";
|
||||
env.PATH =
|
||||
toolPaths.join(adapter.pathDelimiter) +
|
||||
adapter.pathDelimiter +
|
||||
currentPath;
|
||||
}
|
||||
}
|
||||
|
||||
log.info(
|
||||
`[Shell] Agent environment: platform=${platform}, shell=${shell.name}, workspace=${workspace}`,
|
||||
);
|
||||
|
||||
return { env, shell, workspace };
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查环境就绪状态
|
||||
*/
|
||||
export async function checkEnvironmentReady(): Promise<{
|
||||
ready: boolean;
|
||||
issues: string[];
|
||||
}> {
|
||||
const issues: string[] = [];
|
||||
const adapter = createPlatformAdapter();
|
||||
|
||||
// 检查 shell
|
||||
const shell = await detectShell();
|
||||
if (!shell.isAvailable) {
|
||||
issues.push(`Shell not found`);
|
||||
}
|
||||
|
||||
// 检查核心工具
|
||||
const criticalTools = adapter.isWindows
|
||||
? ["where", "dir", "type"]
|
||||
: ["ls", "cat", "grep"];
|
||||
|
||||
for (const tool of criticalTools) {
|
||||
const isAvailable = await checkCommand(tool);
|
||||
if (!isAvailable) {
|
||||
issues.push(`Missing tool: ${tool}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 检查工作目录
|
||||
const workspace = getDefaultWorkspace();
|
||||
if (!fs.existsSync(workspace)) {
|
||||
try {
|
||||
fs.mkdirSync(workspace, { recursive: true });
|
||||
} catch (error) {
|
||||
issues.push(`Cannot create workspace: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ready: issues.length === 0,
|
||||
issues,
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
// Platform
|
||||
getPlatform,
|
||||
isWindows,
|
||||
isMacOS,
|
||||
isLinux,
|
||||
|
||||
// Shell
|
||||
detectShell,
|
||||
checkCommand,
|
||||
getCommandVersion,
|
||||
|
||||
// Tools
|
||||
detectTools,
|
||||
ESSENTIAL_TOOLS,
|
||||
|
||||
// PATH
|
||||
getSystemPath,
|
||||
getHomeDir,
|
||||
getDefaultWorkspace,
|
||||
|
||||
// Environment
|
||||
buildAgentEnvironment,
|
||||
checkEnvironmentReady,
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Webview / iframe 浏览器策略统一管理
|
||||
*
|
||||
* 集中处理:
|
||||
* 1. 权限请求(剪贴板、媒体、全屏等)
|
||||
* 2. window.open 拦截(统一由系统浏览器打开)
|
||||
* 3. 文件下载(导出等场景,支持进度条)
|
||||
*/
|
||||
|
||||
import {
|
||||
app,
|
||||
session as electronSession,
|
||||
shell,
|
||||
BrowserWindow,
|
||||
} from "electron";
|
||||
import log from "electron-log";
|
||||
|
||||
// ---------- 权限白名单 ----------
|
||||
|
||||
const ALLOWED_PERMISSIONS = new Set([
|
||||
"clipboard-read",
|
||||
"clipboard-sanitized-write",
|
||||
"media",
|
||||
"mediaKeySystem",
|
||||
"notifications",
|
||||
"fullscreen",
|
||||
"pointerLock",
|
||||
"openExternal",
|
||||
]);
|
||||
|
||||
// ---------- 权限 ----------
|
||||
|
||||
function setupPermissions(): void {
|
||||
electronSession.defaultSession.setPermissionRequestHandler(
|
||||
(_webContents, permission, callback) => {
|
||||
if (ALLOWED_PERMISSIONS.has(permission)) {
|
||||
callback(true);
|
||||
} else {
|
||||
log.warn(`[WebviewPolicy] Denied permission request: ${permission}`);
|
||||
callback(false);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
electronSession.defaultSession.setPermissionCheckHandler(
|
||||
(_webContents, permission) => {
|
||||
return ALLOWED_PERMISSIONS.has(permission);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- window.open ----------
|
||||
|
||||
function isHttpUrl(url: string): boolean {
|
||||
return url.startsWith("http:") || url.startsWith("https:");
|
||||
}
|
||||
|
||||
function setupWindowOpen(): void {
|
||||
app.on("web-contents-created", (_event, contents) => {
|
||||
// <webview> tag 内部的 window.open
|
||||
contents.on("did-attach-webview", (_event, webContents) => {
|
||||
webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (url && isHttpUrl(url)) {
|
||||
shell.openExternal(url);
|
||||
}
|
||||
return { action: "deny" };
|
||||
});
|
||||
|
||||
// Webview captures keyboard events — they don't bubble to the host page.
|
||||
// Intercept Ctrl/Cmd+Shift+I here to open webview DevTools.
|
||||
webContents.on("before-input-event", (event, input) => {
|
||||
if (
|
||||
input.type === "keyDown" &&
|
||||
input.shift &&
|
||||
(input.control || input.meta) &&
|
||||
input.key.toLowerCase() === "i"
|
||||
) {
|
||||
event.preventDefault();
|
||||
webContents.openDevTools();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// BrowserWindow 内部的 window.open(独立 webview 窗口等)
|
||||
if (contents.getType() === "window") {
|
||||
contents.setWindowOpenHandler(({ url }) => {
|
||||
if (url && isHttpUrl(url)) {
|
||||
shell.openExternal(url);
|
||||
}
|
||||
return { action: "deny" };
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 文件下载 ----------
|
||||
|
||||
function setupDownloads(getMainWindow: () => BrowserWindow | null): void {
|
||||
electronSession.defaultSession.on("will-download", (_event, item) => {
|
||||
const filename = item.getFilename();
|
||||
log.info(
|
||||
`[WebviewPolicy] Download started: ${filename} (${item.getTotalBytes()} bytes)`,
|
||||
);
|
||||
|
||||
item.on("updated", (_event, state) => {
|
||||
if (state === "progressing" && !item.isPaused()) {
|
||||
const received = item.getReceivedBytes();
|
||||
const total = item.getTotalBytes();
|
||||
if (total > 0) {
|
||||
getMainWindow()?.setProgressBar(received / total);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
item.once("done", (_event, state) => {
|
||||
getMainWindow()?.setProgressBar(-1);
|
||||
if (state === "completed") {
|
||||
log.info(
|
||||
`[WebviewPolicy] Download completed: ${filename} → ${item.getSavePath()}`,
|
||||
);
|
||||
} else {
|
||||
log.warn(`[WebviewPolicy] Download failed: ${filename} (${state})`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 统一入口 ----------
|
||||
|
||||
/**
|
||||
* 初始化 webview / iframe 浏览器策略。
|
||||
* 应在 app.whenReady() 且 createWindow() 之后调用。
|
||||
*/
|
||||
export function initWebviewPolicy(
|
||||
getMainWindow: () => BrowserWindow | null,
|
||||
): void {
|
||||
setupPermissions();
|
||||
setupWindowOpen();
|
||||
setupDownloads(getMainWindow);
|
||||
log.info("[WebviewPolicy] Initialized (permissions, window.open, downloads)");
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* Workspace Manager - Session 与 Workspace 映射
|
||||
*
|
||||
* 每个会话对应一个独立的工作目录
|
||||
* 与 Tauri 客户端保持一致
|
||||
*/
|
||||
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import log from 'electron-log';
|
||||
import { APP_DATA_DIR_NAME } from '../constants';
|
||||
|
||||
// ==================== Paths ====================
|
||||
|
||||
/**
|
||||
* 获取应用数据目录
|
||||
*/
|
||||
export function getAppDataDir(): string {
|
||||
const home = process.env.HOME || process.env.USERPROFILE || '';
|
||||
return path.join(home, APP_DATA_DIR_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取工作空间根目录
|
||||
*/
|
||||
export function getWorkspacesDir(): string {
|
||||
return path.join(getAppDataDir(), 'workspaces');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取默认工作目录 (Setup 中配置)
|
||||
*/
|
||||
export function getDefaultWorkspace(): string {
|
||||
// 从设置中读取
|
||||
// TODO: 从 SQLite 读取
|
||||
return path.join(getAppDataDir(), 'default-workspace');
|
||||
}
|
||||
|
||||
// ==================== Session Workspace ====================
|
||||
|
||||
/**
|
||||
* 获取会话的工作目录
|
||||
*
|
||||
* 规则:
|
||||
* - 用户指定的工作目录 → 使用用户指定的目录
|
||||
* - 无指定 → 无工作目录
|
||||
*
|
||||
* 注意: 工作目录由用户在创建会话时指定,不自动生成
|
||||
*/
|
||||
export function getSessionWorkspace(sessionId: string, userSpecifiedDir?: string): string {
|
||||
// 用户指定的工作目录优先
|
||||
if (userSpecifiedDir) {
|
||||
return userSpecifiedDir;
|
||||
}
|
||||
|
||||
// 没有指定则返回空或默认目录
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证工作目录是否存在
|
||||
*/
|
||||
export function validateWorkspaceDir(dir: string): {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
} {
|
||||
if (!dir) {
|
||||
return { valid: false, error: 'No workspace directory specified' };
|
||||
}
|
||||
|
||||
if (!fs.existsSync(dir)) {
|
||||
return { valid: false, error: 'Directory does not exist' };
|
||||
}
|
||||
|
||||
if (!fs.statSync(dir).isDirectory()) {
|
||||
return { valid: false, error: 'Path is not a directory' };
|
||||
}
|
||||
|
||||
// 检查读写权限
|
||||
try {
|
||||
fs.accessSync(dir, fs.constants.R_OK | fs.constants.W_OK);
|
||||
} catch {
|
||||
return { valid: false, error: 'No read/write permission' };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保工作目录有效 (可选创建子目录)
|
||||
*/
|
||||
export async function ensureWorkspaceReady(workspaceDir: string, createSubDirs: boolean = false): Promise<{
|
||||
ready: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
const validation = validateWorkspaceDir(workspaceDir);
|
||||
if (!validation.valid) {
|
||||
return { ready: false, error: validation.error };
|
||||
}
|
||||
|
||||
// 可选创建子目录
|
||||
if (createSubDirs) {
|
||||
const subDirs = ['files', 'logs', 'temp'];
|
||||
for (const subDir of subDirs) {
|
||||
const dir = path.join(workspaceDir, subDir);
|
||||
if (!fs.existsSync(dir)) {
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
} catch (error) {
|
||||
return { ready: false, error: `Failed to create ${subDir}: ${error}` };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ready: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除会话工作目录
|
||||
*/
|
||||
export async function deleteSessionWorkspace(sessionId: string): Promise<void> {
|
||||
const workspace = path.join(getWorkspacesDir(), sessionId);
|
||||
|
||||
if (fs.existsSync(workspace)) {
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
log.info(`[Workspace] Deleted session workspace: ${workspace}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话工作目录中的文件
|
||||
*/
|
||||
export function getSessionFiles(sessionId: string): string[] {
|
||||
const workspace = path.join(getWorkspacesDir(), sessionId, 'files');
|
||||
|
||||
if (!fs.existsSync(workspace)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
return fs.readdirSync(workspace);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Workspace Config ====================
|
||||
|
||||
export interface WorkspaceConfig {
|
||||
sessionId: string;
|
||||
projectDir?: string; // 自定义项目目录
|
||||
engine?: string; // 使用的引擎
|
||||
model?: string; // 使用的模型
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存会话工作空间配置
|
||||
*/
|
||||
export function saveWorkspaceConfig(sessionId: string, config: Partial<WorkspaceConfig>): void {
|
||||
const configPath = path.join(getWorkspacesDir(), sessionId, 'config.json');
|
||||
const workspace = path.join(getWorkspacesDir(), sessionId);
|
||||
|
||||
if (!fs.existsSync(workspace)) {
|
||||
fs.mkdirSync(workspace, { recursive: true });
|
||||
}
|
||||
|
||||
let existingConfig: WorkspaceConfig = {
|
||||
sessionId,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
if (fs.existsSync(configPath)) {
|
||||
try {
|
||||
existingConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const newConfig: WorkspaceConfig = {
|
||||
...existingConfig,
|
||||
...config,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
fs.writeFileSync(configPath, JSON.stringify(newConfig, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取会话工作空间配置
|
||||
*/
|
||||
export function getWorkspaceConfig(sessionId: string): WorkspaceConfig | null {
|
||||
const configPath = path.join(getWorkspacesDir(), sessionId, 'config.json');
|
||||
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== List Workspaces ====================
|
||||
|
||||
/**
|
||||
* 列出所有会话工作空间
|
||||
*/
|
||||
export function listWorkspaces(): Array<{
|
||||
sessionId: string;
|
||||
path: string;
|
||||
hasProject: boolean;
|
||||
lastUsed: number;
|
||||
}> {
|
||||
const workspacesDir = getWorkspacesDir();
|
||||
|
||||
if (!fs.existsSync(workspacesDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const workspaces: Array<{
|
||||
sessionId: string;
|
||||
path: string;
|
||||
hasProject: boolean;
|
||||
lastUsed: number;
|
||||
}> = [];
|
||||
|
||||
try {
|
||||
const entries = fs.readdirSync(workspacesDir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const sessionId = entry.name;
|
||||
const workspacePath = path.join(workspacesDir, sessionId);
|
||||
const config = getWorkspaceConfig(sessionId);
|
||||
|
||||
workspaces.push({
|
||||
sessionId,
|
||||
path: workspacePath,
|
||||
hasProject: !!config?.projectDir,
|
||||
lastUsed: config?.updatedAt || 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('[Workspace] Failed to list workspaces:', error);
|
||||
}
|
||||
|
||||
// 按最后使用时间排序
|
||||
workspaces.sort((a, b) => b.lastUsed - a.lastUsed);
|
||||
|
||||
return workspaces;
|
||||
}
|
||||
|
||||
// ==================== Cleanup ====================
|
||||
|
||||
/**
|
||||
* 清理过期的工作空间
|
||||
*/
|
||||
export function cleanupOldWorkspaces(maxAgeDays: number = 30): number {
|
||||
const workspaces = listWorkspaces();
|
||||
const now = Date.now();
|
||||
const maxAge = maxAgeDays * 24 * 60 * 60 * 1000;
|
||||
|
||||
let deleted = 0;
|
||||
|
||||
for (const workspace of workspaces) {
|
||||
if (now - workspace.lastUsed > maxAge) {
|
||||
try {
|
||||
fs.rmSync(workspace.path, { recursive: true, force: true });
|
||||
deleted++;
|
||||
log.info(`[Workspace] Deleted old workspace: ${workspace.sessionId}`);
|
||||
} catch (error) {
|
||||
log.error(`[Workspace] Failed to delete workspace:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
export default {
|
||||
getAppDataDir,
|
||||
getWorkspacesDir,
|
||||
getDefaultWorkspace,
|
||||
getSessionWorkspace,
|
||||
deleteSessionWorkspace,
|
||||
getSessionFiles,
|
||||
saveWorkspaceConfig,
|
||||
getWorkspaceConfig,
|
||||
listWorkspaces,
|
||||
cleanupOldWorkspaces,
|
||||
};
|
||||
Reference in New Issue
Block a user