chore: initialize qiming workspace repository

This commit is contained in:
Codex
2026-05-29 14:22:48 +08:00
commit bfd67a0f2c
10750 changed files with 1885711 additions and 0 deletions

View File

@@ -0,0 +1,713 @@
/**
* 单元测试: logConfig (日志按日分割 + 大小轮转)
*
* 覆盖:
* 1. todayDateStr — 返回 YYYY-MM-DD 格式
* 2. isArchiveLogName — 归档文件名判定(当日活跃、旧格式、轮转序号等)
* 3. resolvePathFn — 按日路径 + 跨午夜切换
* 4. archiveLogFn — 按序号轮转
* 5. updateLatestLog / updateLatestLogWithRetry — 符号链接/硬链接
* 6. migrateOldMainLog — 旧 main.log 一次性迁移
* 7. cleanupOldLogs — TTL 清理(不删当日活跃日志)
* 8. initLogging — 完整初始化流程
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import * as path from "path";
// ── Mocks ──────────────────────────────────────────────────
vi.mock("electron", () => ({
app: {
getPath: vi.fn(() => "/mock/home"),
isPackaged: false,
},
}));
const mockLog = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
create: vi.fn(() => ({
transports: {
file: {
resolvePathFn: null as null | ((...args: unknown[]) => string),
level: "info",
maxSize: 0,
},
console: {
level: "debug",
},
},
})),
transports: {
file: {
resolvePathFn: null as null | ((...args: unknown[]) => string),
archiveLogFn: null as
| null
| ((oldLogFile: { path: string; crop?: (n: number) => void }) => void),
level: "info",
maxSize: 0,
},
console: {
level: "debug",
},
},
};
vi.mock("electron-log", () => ({ default: mockLog }));
const mockExistsSync = vi.fn(() => false);
const mockMkdirSync = vi.fn();
const mockRenameSync = vi.fn();
const mockUnlinkSync = vi.fn();
const mockSymlinkSync = vi.fn();
const mockLinkSync = vi.fn();
const mockStatSync = vi.fn();
const mockLstatSync = vi.fn();
const mockReaddirSync = vi.fn((..._args: unknown[]) => [] as unknown[]);
vi.mock("fs", () => ({
existsSync: (...args: unknown[]) => mockExistsSync(...(args as [string])),
mkdirSync: (...args: unknown[]) => mockMkdirSync(...args),
renameSync: (...args: unknown[]) => mockRenameSync(...args),
unlinkSync: (...args: unknown[]) => mockUnlinkSync(...args),
symlinkSync: (...args: unknown[]) => mockSymlinkSync(...args),
linkSync: (...args: unknown[]) => mockLinkSync(...args),
statSync: (...args: unknown[]) => mockStatSync(...args),
lstatSync: (...args: unknown[]) => mockLstatSync(...args),
readdirSync: (...args: unknown[]) => mockReaddirSync(...args),
}));
// ── Helpers ────────────────────────────────────────────────
/** 冻结 Date.now 和 new Date() 到指定日期 */
function freezeDate(dateStr: string) {
const frozen = new Date(dateStr + "T12:00:00");
vi.useFakeTimers();
vi.setSystemTime(frozen);
}
const LOG_DIR = path.join("/mock/home", ".qimingclaw", "logs");
// ── Tests ──────────────────────────────────────────────────
describe("logConfig", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
// Reset mock log transports
mockLog.transports.file.resolvePathFn = null;
mockLog.transports.file.archiveLogFn = null;
mockLog.transports.file.level = "info";
mockLog.transports.file.maxSize = 0;
mockLog.transports.console.level = "debug";
});
afterEach(() => {
vi.useRealTimers();
});
// ── isArchiveLogName (通过 cleanupOldLogs 间接测试) ──
describe("isArchiveLogName (归档文件名判定)", () => {
/**
* isArchiveLogName 是模块内部函数,通过 initLogging → cleanupOldLogs 间接验证。
* 构造一个包含各种文件名的目录,验证哪些被删除(归档)、哪些保留。
*/
it("应将非当日的 main.YYYY-MM-DD.log 视为归档", async () => {
freezeDate("2026-03-16");
const now = Date.now();
const oldMtime = now - 40 * 24 * 60 * 60 * 1000; // 40 天前,超过 TTL
// logDir 存在
mockExistsSync.mockReturnValue(true);
// 模拟目录内容
mockReaddirSync.mockReturnValue([
{ name: "main.2026-03-16.log", isFile: () => true }, // 当日活跃 → 不删
{ name: "main.2026-03-10.log", isFile: () => true }, // 非当日 → 归档
{ name: "main.2026-03-10.1.log", isFile: () => true }, // 轮转文件 → 归档
{ name: "main.2026-03-10.2.log", isFile: () => true }, // 轮转文件 → 归档
{ name: "main.2026-02-01-143022.log", isFile: () => true }, // 旧格式 → 归档
{ name: "main.2026-02-01.legacy.log", isFile: () => true }, // 迁移文件 → 归档
{ name: "main.log", isFile: () => true }, // 旧格式残留 → 归档
{ name: "main.old.log", isFile: () => true }, // 旧 .old → 归档
{ name: "renderer.log", isFile: () => true }, // 当前 renderer → 不删
{ name: "latest.log", isFile: () => false }, // symlink → isFile=false → 跳过
{ name: "mcp-proxy.log", isFile: () => true }, // 不以 main./renderer. 开头 → 不删
]);
// statSync 返回超过 TTL 的 mtime
mockStatSync.mockReturnValue({ mtimeMs: oldMtime });
const { initLogging } = await import("./logConfig");
initLogging();
// 应删除的文件(归档 + 超 TTL
const deletedFiles = mockUnlinkSync.mock.calls.map((call) =>
path.basename(call[0] as string),
);
expect(deletedFiles).toContain("main.2026-03-10.log");
expect(deletedFiles).toContain("main.2026-03-10.1.log");
expect(deletedFiles).toContain("main.2026-03-10.2.log");
expect(deletedFiles).toContain("main.2026-02-01-143022.log");
expect(deletedFiles).toContain("main.2026-02-01.legacy.log");
expect(deletedFiles).toContain("main.log");
expect(deletedFiles).toContain("main.old.log");
// 不应删除的文件(排除 updateLatestLog 中对 latest.log 的 unlink
const cleanupDeleted = deletedFiles.filter((f) => f !== "latest.log");
expect(cleanupDeleted).not.toContain("main.2026-03-16.log");
expect(cleanupDeleted).not.toContain("renderer.log");
expect(deletedFiles).not.toContain("mcp-proxy.log");
});
it("不应删除未超过 TTL 的归档文件", async () => {
freezeDate("2026-03-16");
mockExistsSync.mockReturnValue(true);
mockReaddirSync.mockReturnValue([
{ name: "main.2026-03-15.log", isFile: () => true },
]);
// mtime = 1 天前,未超过 30 天 TTLdev 模式)
const recentMtime = Date.now() - 1 * 24 * 60 * 60 * 1000;
mockStatSync.mockReturnValue({ mtimeMs: recentMtime });
const { initLogging } = await import("./logConfig");
initLogging();
// statSync 被调用了(说明识别为归档),但未删除(未超 TTL
const deletedFiles = mockUnlinkSync.mock.calls.map((call) =>
path.basename(call[0] as string),
);
expect(deletedFiles).not.toContain("main.2026-03-15.log");
});
});
// ── resolvePathFn (按日路径) ──
describe("resolvePathFn (按日路径)", () => {
it("应返回 main.YYYY-MM-DD.log 路径", async () => {
freezeDate("2026-03-16");
mockExistsSync.mockReturnValue(true);
mockReaddirSync.mockReturnValue([]);
const { initLogging } = await import("./logConfig");
initLogging();
const resolvePathFn = mockLog.transports.file.resolvePathFn!;
expect(resolvePathFn).toBeDefined();
const result = resolvePathFn();
expect(result).toBe(path.join(LOG_DIR, "main.2026-03-16.log"));
});
it("跨午夜应切换到新日期文件", async () => {
freezeDate("2026-03-16");
mockExistsSync.mockReturnValue(true);
mockReaddirSync.mockReturnValue([]);
const { initLogging } = await import("./logConfig");
initLogging();
const resolvePathFn = mockLog.transports.file.resolvePathFn!;
// 第一次调用2026-03-16
const result1 = resolvePathFn();
expect(result1).toBe(path.join(LOG_DIR, "main.2026-03-16.log"));
// 模拟跨午夜
vi.setSystemTime(new Date("2026-03-17T00:00:01"));
const result2 = resolvePathFn();
expect(result2).toBe(path.join(LOG_DIR, "main.2026-03-17.log"));
});
});
// ── archiveLogFn (按序号轮转) ──
describe("archiveLogFn (大小轮转)", () => {
it("首次轮转应生成序号 1", async () => {
freezeDate("2026-03-16");
mockExistsSync.mockReturnValue(true);
// 目录中没有已有的轮转文件
mockReaddirSync.mockReturnValue([]);
const { initLogging } = await import("./logConfig");
initLogging();
const archiveLogFn = mockLog.transports.file.archiveLogFn!;
expect(archiveLogFn).toBeDefined();
const oldPath = path.join(LOG_DIR, "main.2026-03-16.log");
archiveLogFn({ path: oldPath });
expect(mockRenameSync).toHaveBeenCalledWith(
oldPath,
path.join(LOG_DIR, "main.2026-03-16.1.log"),
);
});
it("已有序号 1 和 2 时,应生成序号 3", async () => {
freezeDate("2026-03-16");
mockExistsSync.mockReturnValue(true);
const dirFiles = [
"main.2026-03-16.1.log",
"main.2026-03-16.2.log",
"main.2026-03-16.log",
];
mockReaddirSync.mockImplementation((...args: unknown[]) => {
const opts = args[1];
// cleanupOldLogs 调用 readdirSync(dir, { withFileTypes: true })
if (
opts &&
typeof opts === "object" &&
(opts as Record<string, unknown>).withFileTypes
) {
return dirFiles.map((name) => ({ name, isFile: () => true }));
}
// archiveLogFn 调用 readdirSync(dir)(返回字符串数组)
return dirFiles;
});
mockStatSync.mockReturnValue({ mtimeMs: Date.now() });
const { initLogging } = await import("./logConfig");
initLogging();
const archiveLogFn = mockLog.transports.file.archiveLogFn!;
const oldPath = path.join(LOG_DIR, "main.2026-03-16.log");
archiveLogFn({ path: oldPath });
expect(mockRenameSync).toHaveBeenCalledWith(
oldPath,
path.join(LOG_DIR, "main.2026-03-16.3.log"),
);
});
it("不应混淆其他日期的序号", async () => {
freezeDate("2026-03-16");
mockExistsSync.mockReturnValue(true);
const dirFiles = [
"main.2026-03-15.5.log", // 昨天的序号 5不应影响今天
"main.2026-03-16.log",
];
mockReaddirSync.mockImplementation((...args: unknown[]) => {
const opts = args[1];
if (
opts &&
typeof opts === "object" &&
(opts as Record<string, unknown>).withFileTypes
) {
return dirFiles.map((name) => ({ name, isFile: () => true }));
}
return dirFiles;
});
mockStatSync.mockReturnValue({ mtimeMs: Date.now() });
const { initLogging } = await import("./logConfig");
initLogging();
const archiveLogFn = mockLog.transports.file.archiveLogFn!;
const oldPath = path.join(LOG_DIR, "main.2026-03-16.log");
archiveLogFn({ path: oldPath });
// 今天从序号 1 开始,不受昨天的序号 5 影响
expect(mockRenameSync).toHaveBeenCalledWith(
oldPath,
path.join(LOG_DIR, "main.2026-03-16.1.log"),
);
});
it("rename 失败时应调用 crop 截断", async () => {
freezeDate("2026-03-16");
mockExistsSync.mockReturnValue(true);
mockReaddirSync.mockReturnValue([]);
mockRenameSync.mockImplementation(() => {
throw new Error("EPERM");
});
const { initLogging } = await import("./logConfig");
initLogging();
// 清掉 initLogging 阶段的 renameSync 调用migrateOldMainLog 等)
mockRenameSync.mockClear();
mockRenameSync.mockImplementation(() => {
throw new Error("EPERM");
});
const archiveLogFn = mockLog.transports.file.archiveLogFn!;
const cropFn = vi.fn();
const oldPath = path.join(LOG_DIR, "main.2026-03-16.log");
archiveLogFn({ path: oldPath, crop: cropFn });
expect(cropFn).toHaveBeenCalled();
expect(mockLog.warn).toHaveBeenCalledWith(
expect.stringContaining("Rotation failed"),
expect.anything(),
);
});
it("重入时应直接 crop 截断,不尝试 rename防止死循环", async () => {
freezeDate("2026-03-16");
mockExistsSync.mockReturnValue(true);
mockReaddirSync.mockReturnValue([]);
mockRenameSync.mockImplementation(() => {
throw new Error("ENOENT");
});
const { initLogging } = await import("./logConfig");
initLogging();
// 清掉 initLogging 阶段的调用
mockRenameSync.mockClear();
mockRenameSync.mockImplementation(() => {
throw new Error("ENOENT");
});
mockLog.warn.mockClear();
const archiveLogFn = mockLog.transports.file.archiveLogFn!;
const reentrantCropFn = vi.fn();
// 模拟真实场景log.warn 触发新的日志写入 → 再次调用 archiveLogFn重入
mockLog.warn.mockImplementation(() => {
archiveLogFn({
path: path.join(LOG_DIR, "main.2026-03-16.log"),
crop: reentrantCropFn,
});
});
const cropFn = vi.fn();
archiveLogFn({
path: path.join(LOG_DIR, "main.2026-03-16.log"),
crop: cropFn,
});
// 首次调用rename 失败 → 走 catch → log.warn → 重入
expect(cropFn).toHaveBeenCalled();
// 重入调用isArchiving=true → 直接 crop(256KB),不再尝试 rename
expect(reentrantCropFn).toHaveBeenCalledWith(256 * 1024);
// renameSync 只被首次调用调用 1 次,重入时不应再调用
expect(mockRenameSync).toHaveBeenCalledTimes(1);
});
});
// ── updateLatestLog (符号链接) ──
describe("updateLatestLog (latest.log 链接)", () => {
it("macOS/Linux 应创建指向当日文件的符号链接", async () => {
freezeDate("2026-03-16");
// 保存并模拟 platform
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", {
value: "darwin",
configurable: true,
});
mockExistsSync.mockImplementation((p: unknown) => {
const s = p as string;
// main.2026-03-16.log 存在
if (s.endsWith("main.2026-03-16.log")) return true;
// latest.log 不存在
if (s.endsWith("latest.log")) return false;
// logDir 存在
return true;
});
mockReaddirSync.mockReturnValue([]);
const { initLogging } = await import("./logConfig");
initLogging();
expect(mockSymlinkSync).toHaveBeenCalledWith(
"main.2026-03-16.log",
path.join(LOG_DIR, "latest.log"),
"file",
);
Object.defineProperty(process, "platform", {
value: originalPlatform,
configurable: true,
});
});
it("Windows 应创建硬链接", async () => {
freezeDate("2026-03-16");
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", {
value: "win32",
configurable: true,
});
mockExistsSync.mockImplementation((p: unknown) => {
const s = p as string;
if (s.endsWith("main.2026-03-16.log")) return true;
if (s.endsWith("latest.log")) return false;
return true;
});
mockReaddirSync.mockReturnValue([]);
const { initLogging } = await import("./logConfig");
initLogging();
expect(mockLinkSync).toHaveBeenCalledWith(
path.join(LOG_DIR, "main.2026-03-16.log"),
path.join(LOG_DIR, "latest.log"),
);
Object.defineProperty(process, "platform", {
value: originalPlatform,
configurable: true,
});
});
it("应先删除已有的 latest.log 再创建", async () => {
freezeDate("2026-03-16");
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", {
value: "darwin",
configurable: true,
});
mockExistsSync.mockImplementation((p: unknown) => {
const s = p as string;
if (s.endsWith("main.2026-03-16.log")) return true;
if (s.endsWith("latest.log")) return true; // 已存在
return true;
});
mockReaddirSync.mockReturnValue([]);
const { initLogging } = await import("./logConfig");
initLogging();
// 应先 unlink 旧的 latest.log
const unlinkCalls = mockUnlinkSync.mock.calls.map((c) => c[0] as string);
expect(unlinkCalls).toContain(path.join(LOG_DIR, "latest.log"));
// 再创建新的符号链接
expect(mockSymlinkSync).toHaveBeenCalledWith(
"main.2026-03-16.log",
path.join(LOG_DIR, "latest.log"),
"file",
);
Object.defineProperty(process, "platform", {
value: originalPlatform,
configurable: true,
});
});
it("dangling symlink 应被正确替换", async () => {
freezeDate("2026-03-16");
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", {
value: "darwin",
configurable: true,
});
// existsSync 对 dangling symlink 返回 false目标不存在
// lstatSync 对 dangling symlink 成功返回(链接本身存在)
mockExistsSync.mockImplementation((p: unknown) => {
const s = p as string;
if (s.endsWith("main.2026-03-16.log")) return true;
if (s.endsWith("latest.log")) return false; // dangling: 目标不存在
return true;
});
mockLstatSync.mockImplementation((p: unknown) => {
const s = p as string;
if (s.endsWith("latest.log")) return {}; // lstat 成功 → 链接本身存在
return {};
});
mockReaddirSync.mockReturnValue([]);
const { initLogging } = await import("./logConfig");
initLogging();
// lstatSync 发现链接存在 → unlinkSync 删除 dangling symlink
const unlinkCalls = mockUnlinkSync.mock.calls.map((c) => c[0] as string);
expect(unlinkCalls).toContain(path.join(LOG_DIR, "latest.log"));
// 再创建新的符号链接
expect(mockSymlinkSync).toHaveBeenCalledWith(
"main.2026-03-16.log",
path.join(LOG_DIR, "latest.log"),
"file",
);
Object.defineProperty(process, "platform", {
value: originalPlatform,
configurable: true,
});
});
});
// ── migrateOldMainLog (旧 main.log 迁移) ──
describe("migrateOldMainLog (旧日志迁移)", () => {
it("应将旧 main.log 按 mtime 重命名为 legacy 文件", async () => {
freezeDate("2026-03-16");
// main.log 存在
mockExistsSync.mockImplementation((p: unknown) => {
const s = p as string;
if (s === path.join(LOG_DIR, "main.log")) return true;
return true;
});
// main.log 的 mtime 是 2026-03-10
const migrateMtime = new Date("2026-03-10T15:30:00");
mockStatSync.mockImplementation((p: unknown) => {
const s = p as string;
if (s === path.join(LOG_DIR, "main.log")) {
return { mtime: migrateMtime, mtimeMs: migrateMtime.getTime() };
}
return { mtimeMs: Date.now() };
});
mockReaddirSync.mockReturnValue([]);
const { initLogging } = await import("./logConfig");
initLogging();
expect(mockRenameSync).toHaveBeenCalledWith(
path.join(LOG_DIR, "main.log"),
path.join(LOG_DIR, "main.2026-03-10.legacy.log"),
);
});
it("旧 main.log 不存在时不应执行迁移", async () => {
freezeDate("2026-03-16");
mockExistsSync.mockImplementation((p: unknown) => {
const s = p as string;
if (s === path.join(LOG_DIR, "main.log")) return false;
return true;
});
mockReaddirSync.mockReturnValue([]);
const { initLogging } = await import("./logConfig");
initLogging();
// renameSync 不应被调用来迁移 main.log
const renameCalls = mockRenameSync.mock.calls.map((c) =>
path.basename(c[0] as string),
);
expect(renameCalls).not.toContain("main.log");
});
it("迁移应在 TTL 清理之前执行", async () => {
freezeDate("2026-03-16");
const callOrder: string[] = [];
// main.log 存在
mockExistsSync.mockReturnValue(true);
mockStatSync.mockReturnValue({
mtime: new Date("2026-01-01T00:00:00"),
mtimeMs: new Date("2026-01-01").getTime(),
});
// 追踪 renameSync迁移和 readdirSync清理调用顺序
mockRenameSync.mockImplementation((...args: unknown[]) => {
const src = path.basename(args[0] as string);
if (src === "main.log") {
callOrder.push("migrate");
}
});
mockReaddirSync.mockImplementation((...args: unknown[]) => {
const dirPath = args[0] as string;
if (dirPath === LOG_DIR && args[1]) {
// readdirSync with withFileTypes → cleanupOldLogs
callOrder.push("cleanup");
}
return [];
});
const { initLogging } = await import("./logConfig");
initLogging();
const migrateIdx = callOrder.indexOf("migrate");
const cleanupIdx = callOrder.indexOf("cleanup");
expect(migrateIdx).toBeGreaterThanOrEqual(0);
expect(cleanupIdx).toBeGreaterThanOrEqual(0);
expect(migrateIdx).toBeLessThan(cleanupIdx);
});
});
// ── initLogging (完整初始化) ──
describe("initLogging (完整初始化)", () => {
it("应创建日志目录(如不存在)", async () => {
freezeDate("2026-03-16");
mockExistsSync.mockImplementation((p: unknown) => {
const s = p as string;
if (s === LOG_DIR) return false; // logDir 不存在
return false;
});
mockReaddirSync.mockReturnValue([]);
const { initLogging } = await import("./logConfig");
initLogging();
expect(mockMkdirSync).toHaveBeenCalledWith(LOG_DIR, { recursive: true });
});
it("应设置正确的 maxSize", async () => {
freezeDate("2026-03-16");
mockExistsSync.mockReturnValue(true);
mockReaddirSync.mockReturnValue([]);
const { initLogging } = await import("./logConfig");
initLogging();
expect(mockLog.transports.file.maxSize).toBe(100 * 1024 * 1024);
});
it("应设置 file level 为 debug开发模式", async () => {
freezeDate("2026-03-16");
mockExistsSync.mockReturnValue(true);
mockReaddirSync.mockReturnValue([]);
const { initLogging } = await import("./logConfig");
initLogging();
// app.isPackaged = false → dev mode → debug
expect(mockLog.transports.file.level).toBe("debug");
});
it("应输出初始化日志", async () => {
freezeDate("2026-03-16");
mockExistsSync.mockReturnValue(true);
mockReaddirSync.mockReturnValue([]);
const { initLogging } = await import("./logConfig");
initLogging();
expect(mockLog.info).toHaveBeenCalledWith(
"[LogConfig] Logging initialized",
"(development)",
"fileLevel=",
"debug",
"maxSize=",
"100MB",
"ttlDays=",
30,
);
});
});
});

View File

@@ -0,0 +1,329 @@
/**
* 主进程日志配置按日分割、大小轮转、TTL 清理,开发/正式环境区分
*
* - 按日分割:每日一个日志文件 main.YYYY-MM-DD.log
* - 大小轮转:单日文件超 maxSize 时按序号轮转为 main.YYYY-MM-DD.N.log
* - TTL启动时删除 logs 目录下超过有效期的归档文件
* - latest.log符号链接或 Windows 硬链接)指向当日活跃日志
* - 开发:文件级别 debug、更长保留期正式info、更短保留期
*/
import log from "electron-log";
import * as path from "path";
import * as fs from "fs";
import { app } from "electron";
import {
APP_DATA_DIR_NAME,
LOGS_DIR_NAME,
PERF_LOG_FILENAME_PREFIX,
} from "../services/constants";
/** 开发环境:未打包或 NODE_ENV=development */
function isDev(): boolean {
return process.env.NODE_ENV === "development" || !app.isPackaged;
}
/** 单文件最大字节数:默认 100MB */
const MAX_SIZE_DEV = 100 * 1024 * 1024;
const MAX_SIZE_PROD = 100 * 1024 * 1024;
// 验证轮转时可将上面两行临时改为 50 * 102450KB重启后打日志即可触发轮转验证完改回
/** 归档日志保留时间(毫秒):开发 30 天,正式 7 天 */
const TTL_MS_DEV = 30 * 24 * 60 * 60 * 1000;
const TTL_MS_PROD = 7 * 24 * 60 * 60 * 1000;
/** 返回当天日期字符串 YYYY-MM-DD */
function todayDateStr(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
/** 转义正则特殊字符 */
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/** 追踪当前日期,用于检测跨午夜切换 */
let lastDateStr = "";
/**
* 视为归档的日志文件名模式,用于 TTL 清理:
* - main.YYYY-MM-DD.log非当日
* - main.YYYY-MM-DD.N.log大小轮转
* - main.YYYY-MM-DD-HHmmss.log旧格式兼容
* - main.YYYY-MM-DD.legacy.log旧 main.log 迁移)
* - main.old.log / renderer.old.log
* - main.log旧格式迁移后残留
*
* 不视为归档:
* - main.{todayDateStr()}.log当日活跃日志
* - renderer.log
* - latest.log
* - mcp-proxy-*.log
*/
function isArchiveLogName(name: string): boolean {
const n = name.toLowerCase();
// 旧格式 .old.log
if (n === "main.old.log" || n === "renderer.old.log") return true;
if (!n.endsWith(".log")) return false;
// 当前 renderer 不归档
if (n === "renderer.log") return false;
// 旧 main.log迁移后残留视为归档
if (n === "main.log") return true;
// 当日活跃日志不归档
const today = todayDateStr();
if (n === `main.${today}.log`) return false;
if (n === `${PERF_LOG_FILENAME_PREFIX}.${today}.log`) return false;
// main.* / perf.* 开头的其他日志都视为归档
return (
n.startsWith("main.") ||
n.startsWith("renderer.") ||
n.startsWith(`${PERF_LOG_FILENAME_PREFIX}.`)
);
}
const LATEST_LOG_FILENAME = "latest.log";
/**
* 使 latest.log 指向当日活跃日志 main.YYYY-MM-DD.log
* - macOS/Linux符号链接相对路径
* - Windows硬链接
*/
function updateLatestLog(logDir: string): void {
const dateStr = todayDateStr();
const mainName = `main.${dateStr}.log`;
const mainPath = path.join(logDir, mainName);
if (!fs.existsSync(mainPath)) return;
const latestPath = path.join(logDir, LATEST_LOG_FILENAME);
try {
// 用 lstatSync 检测existsSync 对 dangling symlink 返回 false导致无法删除旧链接
try {
fs.lstatSync(latestPath);
fs.unlinkSync(latestPath);
} catch {
/* 不存在 */
}
if (process.platform === "win32") {
fs.linkSync(mainPath, latestPath);
} else {
fs.symlinkSync(mainName, latestPath, "file");
}
} catch (e) {
log.warn("[LogConfig] latest.log create/update failed:", e);
}
}
/**
* 带重试的 updateLatestLog确保日志文件已创建后再建立链接
* Windows 平台electron-log 轮转后异步创建新文件,可能需要等待
*/
function updateLatestLogWithRetry(
logDir: string,
retries = 20,
delayMs = 100,
): void {
const dateStr = todayDateStr();
const mainPath = path.join(logDir, `main.${dateStr}.log`);
if (fs.existsSync(mainPath)) {
updateLatestLog(logDir);
return;
}
if (retries > 0) {
setTimeout(
() => updateLatestLogWithRetry(logDir, retries - 1, delayMs),
delayMs,
);
} else {
log.warn(
`[LogConfig] latest.log update failed: main.${dateStr}.log does not exist, retries exhausted`,
);
}
}
/**
* 删除 logDir 下过期的归档日志
*/
function cleanupOldLogs(logDir: string, maxAgeMs: number): void {
if (!fs.existsSync(logDir)) return;
const now = Date.now();
const entries = fs.readdirSync(logDir, { withFileTypes: true });
for (const e of entries) {
if (!e.isFile()) continue;
if (!isArchiveLogName(e.name)) continue;
const fullPath = path.join(logDir, e.name);
try {
const stat = fs.statSync(fullPath);
if (now - stat.mtimeMs > maxAgeMs) {
fs.unlinkSync(fullPath);
log.info("[LogConfig] Deleted expired log:", e.name);
}
} catch (err) {
log.warn("[LogConfig] Failed to clean log:", e.name, err);
}
}
}
/**
* 旧 main.log 一次性迁移:按 mtime 重命名为 main.YYYY-MM-DD.legacy.log
*/
function migrateOldMainLog(logDir: string): void {
const oldMainPath = path.join(logDir, "main.log");
if (!fs.existsSync(oldMainPath)) return;
try {
const stat = fs.statSync(oldMainPath);
const mtime = stat.mtime;
const dateStr = `${mtime.getFullYear()}-${String(mtime.getMonth() + 1).padStart(2, "0")}-${String(mtime.getDate()).padStart(2, "0")}`;
const legacyName = `main.${dateStr}.legacy.log`;
const legacyPath = path.join(logDir, legacyName);
fs.renameSync(oldMainPath, legacyPath);
log.info("[LogConfig] Old main.log migrated to:", legacyName);
} catch (e) {
log.warn("[LogConfig] Old main.log migration failed:", e);
}
}
// ==================== PERF 专用日志 ====================
let _perfLogger: ReturnType<typeof log.create> | null = null;
/**
* 初始化 PERF 专用 logger写入 perf.YYYY-MM-DD.log
* 由 initLogging() 内部调用logDir 已保证存在
*/
function initPerfLogging(logDir: string): void {
_perfLogger = log.create({ logId: "perf" });
_perfLogger.transports.file.resolvePathFn = () => {
const dateStr = todayDateStr();
return path.join(logDir, `${PERF_LOG_FILENAME_PREFIX}.${dateStr}.log`);
};
// perf 日志无论开发/正式均写 info 级别(性能数据本身有价值)
_perfLogger.transports.file.level = "info";
// 不重复打到控制台main logger 已输出electron-log v5 level=false 禁用该 transport
(_perfLogger.transports.console as any).level = false;
_perfLogger.transports.file.maxSize = 50 * 1024 * 1024;
}
/**
* 获取 PERF 专用 loggermain 进程使用)
* 若 initLogging() 未调用(如测试环境),返回默认 log 作为降级
*/
export function getPerfLogger(): ReturnType<typeof log.create> {
return _perfLogger ?? log;
}
/**
* 初始化 electron-log 文件输出按日分割、大小轮转、TTL 清理
*/
export function initLogging(): void {
const dev = isDev();
const qimingHome = path.join(app.getPath("home"), APP_DATA_DIR_NAME);
const logDir = path.join(qimingHome, LOGS_DIR_NAME);
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true });
}
// 按日分割:写入 main.YYYY-MM-DD.log跨午夜自动切换
log.transports.file.resolvePathFn = () => {
const dateStr = todayDateStr();
if (dateStr !== lastDateStr) {
lastDateStr = dateStr;
setImmediate(() => updateLatestLogWithRetry(logDir));
}
return path.join(logDir, `main.${dateStr}.log`);
};
// 开发:文件打 debug正式文件打 info。控制台始终可看 debug
log.transports.file.level = dev ? "debug" : "info";
log.transports.console.level = "debug";
const maxSize = dev ? MAX_SIZE_DEV : MAX_SIZE_PROD;
const ttlMs = dev ? TTL_MS_DEV : TTL_MS_PROD;
log.transports.file.maxSize = maxSize;
// 大小轮转:当日文件超 maxSize 时,扫描目录找最大序号 N重命名为 main.YYYY-MM-DD.(N+1).log
let isArchiving = false;
log.transports.file.archiveLogFn = (oldLogFile: {
path: string;
crop?: (n: number) => void;
}) => {
// 防止重入catch 中的日志写入会再次触发 archiveLogFn导致无限递归
if (isArchiving) {
oldLogFile.crop?.(256 * 1024);
return;
}
isArchiving = true;
try {
const oldPath = oldLogFile.path;
const parsed = path.parse(oldPath);
// 从当前文件名提取日期main.YYYY-MM-DD.log → YYYY-MM-DD
const baseMatch = parsed.name.match(/^main\.(\d{4}-\d{2}-\d{2})$/);
const dateStr = baseMatch ? baseMatch[1] : todayDateStr();
// 扫描目录找当日最大序号
let maxSeq = 0;
try {
const seqPattern = new RegExp(
`^main\\.${escapeRegExp(dateStr)}\\.(\\d+)\\.log$`,
);
const files = fs.readdirSync(parsed.dir);
for (const f of files) {
const m = f.match(seqPattern);
if (m) {
const seq = parseInt(m[1], 10);
if (seq > maxSeq) maxSeq = seq;
}
}
} catch {
// 目录读取失败,从 1 开始
}
const newSeq = maxSeq + 1;
const archiveName = `main.${dateStr}.${newSeq}.log`;
const archivePath = path.join(parsed.dir, archiveName);
try {
fs.renameSync(oldPath, archivePath);
log.info("[LogConfig] Log rotated:", archiveName);
// Windows 硬链接指向 inode轮转后需重新让 latest.log 指向新文件
if (process.platform === "win32") {
updateLatestLogWithRetry(parsed.dir);
}
} catch (e) {
log.warn("[LogConfig] Rotation failed, attempting truncation:", e);
const quarter = Math.round(maxSize / 4);
oldLogFile.crop?.(Math.min(quarter, 256 * 1024));
}
} finally {
isArchiving = false;
}
};
// 旧 main.log 一次性迁移(必须在 TTL 清理之前,避免旧文件被直接删除)
migrateOldMainLog(logDir);
// 启动时按 TTL 清理过期归档
cleanupOldLogs(logDir, ttlMs);
log.info(
"[LogConfig] Logging initialized",
dev ? "(development)" : "(production)",
"fileLevel=",
log.transports.file.level,
"maxSize=",
Math.round(maxSize / 1024 / 1024) + "MB",
"ttlDays=",
Math.round(ttlMs / (24 * 60 * 60 * 1000)),
);
// 首次写入后让 latest.log 指向当日日志
updateLatestLogWithRetry(logDir);
// 初始化 perf 专用日志
initPerfLogging(logDir);
}
/** 供 IPC/客户端解析:优先读取的日志入口文件名(始终为当前主进程日志) */
export const LATEST_LOG_BASENAME = LATEST_LOG_FILENAME;

View File

@@ -0,0 +1,278 @@
/**
* 单元测试: migrate (数据目录迁移)
*
* 覆盖迁移路径:
* 1. .qiming-agent → .qimingclaw (优先级最高)
* 2. .qimingbot → .qimingclaw
* 3. 无旧目录 → 跳过
* 4. 新目录已存在但 DB 为空 → 从旧目录导入 DB
* 5. 新目录已存在且 DB 有数据 → 跳过
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as path from 'path';
vi.mock('electron', () => ({
app: { getPath: vi.fn(() => '/mock/home') },
}));
vi.mock('electron-log', () => ({
default: { info: vi.fn(), error: vi.fn(), warn: vi.fn() },
}));
const mockExistsSync = vi.fn(() => false);
const mockRenameSync = vi.fn();
const mockCopyFileSync = vi.fn();
vi.mock('fs', () => ({
existsSync: (p: string) => mockExistsSync(p),
renameSync: (o: string, n: string) => mockRenameSync(o, n),
copyFileSync: (o: string, n: string) => mockCopyFileSync(o, n),
}));
const mockReadSetting = vi.fn(() => null);
const mockWriteSetting = vi.fn();
vi.mock('../db', () => ({
readSetting: (...args: unknown[]) => mockReadSetting(...args),
writeSetting: (...args: unknown[]) => mockWriteSetting(...args),
}));
// Mock better-sqlite3 for isDbEmpty checks
const mockDbPrepare = vi.fn();
const mockDbClose = vi.fn();
vi.mock('better-sqlite3', () => ({
default: vi.fn(() => ({
prepare: mockDbPrepare,
close: mockDbClose,
})),
}));
describe('migrateDataDir', () => {
beforeEach(() => {
vi.clearAllMocks();
// Default: isDbEmpty returns true (count=0)
mockDbPrepare.mockReturnValue({ get: () => ({ count: 0 }) });
});
it('should skip when no legacy directory exists', async () => {
mockExistsSync.mockReturnValue(false);
const { migrateDataDir } = await import('./migrate');
migrateDataDir();
expect(mockRenameSync).not.toHaveBeenCalled();
expect(mockCopyFileSync).not.toHaveBeenCalled();
});
it('should skip when new directory exists and DB has data', async () => {
// new dir exists, new DB exists and has data
mockExistsSync.mockImplementation((p: string) => {
if (p.includes('.qimingclaw')) return true;
return false;
});
// DB has data (count > 0)
mockDbPrepare.mockReturnValue({ get: () => ({ count: 5 }) });
const { migrateDataDir } = await import('./migrate');
migrateDataDir();
expect(mockRenameSync).not.toHaveBeenCalled();
expect(mockCopyFileSync).not.toHaveBeenCalled();
});
it('should import legacy DB when new dir exists but DB is empty', async () => {
const newDb = path.join('/mock/home', '.qimingclaw', 'qimingclaw.db');
const oldDb = path.join('/mock/home', '.qimingbot', 'qimingbot.db');
mockExistsSync.mockImplementation((p: string) => {
if (p === path.join('/mock/home', '.qimingclaw')) return true;
if (p === newDb) return true;
if (p === oldDb) return true;
if (p.endsWith('-wal') || p.endsWith('-shm')) return false;
return false;
});
// First call: new DB is empty (count=0), second call: old DB has data (count=5)
mockDbPrepare
.mockReturnValueOnce({ get: () => ({ count: 0 }) })
.mockReturnValueOnce({ get: () => ({ count: 5 }) });
const { migrateDataDir } = await import('./migrate');
migrateDataDir();
expect(mockCopyFileSync).toHaveBeenCalledWith(oldDb, newDb);
expect(mockRenameSync).not.toHaveBeenCalled();
});
it('should migrate .qiming-agent → .qimingclaw (priority 1)', async () => {
mockExistsSync.mockImplementation((p: string) => {
if (p === path.join('/mock/home', '.qimingclaw')) return false;
if (p === path.join('/mock/home', '.qiming-agent')) return true;
if (p.endsWith('qiming-agent.db')) return true;
if (p.endsWith('qimingclaw.db')) return false;
if (p.endsWith('-wal') || p.endsWith('-shm')) return false;
return false;
});
const { migrateDataDir } = await import('./migrate');
migrateDataDir();
// Directory rename
expect(mockRenameSync).toHaveBeenCalledWith(
path.join('/mock/home', '.qiming-agent'),
path.join('/mock/home', '.qimingclaw'),
);
// DB rename
expect(mockRenameSync).toHaveBeenCalledWith(
path.join('/mock/home', '.qimingclaw', 'qiming-agent.db'),
path.join('/mock/home', '.qimingclaw', 'qimingclaw.db'),
);
});
it('should migrate .qimingbot → .qimingclaw (priority 2)', async () => {
mockExistsSync.mockImplementation((p: string) => {
if (p === path.join('/mock/home', '.qimingclaw')) return false;
if (p === path.join('/mock/home', '.qiming-agent')) return false;
if (p === path.join('/mock/home', '.qimingbot')) return true;
if (p.endsWith('qimingbot.db')) return true;
if (p.endsWith('qimingclaw.db')) return false;
if (p.endsWith('qimingbot.json')) return true;
if (p.endsWith('qimingclaw.json')) return false;
if (p.endsWith('-wal') || p.endsWith('-shm')) return false;
return false;
});
const { migrateDataDir } = await import('./migrate');
migrateDataDir();
// Directory rename
expect(mockRenameSync).toHaveBeenCalledWith(
path.join('/mock/home', '.qimingbot'),
path.join('/mock/home', '.qimingclaw'),
);
// DB rename
expect(mockRenameSync).toHaveBeenCalledWith(
path.join('/mock/home', '.qimingclaw', 'qimingbot.db'),
path.join('/mock/home', '.qimingclaw', 'qimingclaw.db'),
);
// Config rename
expect(mockRenameSync).toHaveBeenCalledWith(
path.join('/mock/home', '.qimingclaw', 'qimingbot.json'),
path.join('/mock/home', '.qimingclaw', 'qimingclaw.json'),
);
});
it('should also rename WAL and SHM files if they exist', async () => {
mockExistsSync.mockImplementation((p: string) => {
if (p === path.join('/mock/home', '.qimingclaw')) return false;
if (p === path.join('/mock/home', '.qiming-agent')) return true;
if (p.endsWith('qiming-agent.db')) return true;
if (p.endsWith('qimingclaw.db')) return false;
if (p.endsWith('qiming-agent.db-wal')) return true;
if (p.endsWith('qiming-agent.db-shm')) return true;
return false;
});
const { migrateDataDir } = await import('./migrate');
migrateDataDir();
expect(mockRenameSync).toHaveBeenCalledWith(
path.join('/mock/home', '.qimingclaw', 'qiming-agent.db-wal'),
path.join('/mock/home', '.qimingclaw', 'qimingclaw.db-wal'),
);
expect(mockRenameSync).toHaveBeenCalledWith(
path.join('/mock/home', '.qimingclaw', 'qiming-agent.db-shm'),
path.join('/mock/home', '.qimingclaw', 'qimingclaw.db-shm'),
);
});
it('should prefer .qiming-agent over .qimingbot when both exist', async () => {
mockExistsSync.mockImplementation((p: string) => {
if (p === path.join('/mock/home', '.qimingclaw')) return false;
if (p === path.join('/mock/home', '.qiming-agent')) return true;
if (p === path.join('/mock/home', '.qimingbot')) return true;
if (p.endsWith('qiming-agent.db')) return true;
if (p.endsWith('qimingclaw.db')) return false;
return false;
});
const { migrateDataDir } = await import('./migrate');
migrateDataDir();
// Should rename .qiming-agent, NOT .qimingbot
expect(mockRenameSync).toHaveBeenCalledWith(
path.join('/mock/home', '.qiming-agent'),
path.join('/mock/home', '.qimingclaw'),
);
expect(mockRenameSync).not.toHaveBeenCalledWith(
path.join('/mock/home', '.qimingbot'),
expect.anything(),
);
});
});
describe('migrateSettingsPaths', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should skip when step1_config is null', async () => {
mockReadSetting.mockReturnValue(null);
const { migrateSettingsPaths } = await import('./migrate');
migrateSettingsPaths();
expect(mockWriteSetting).not.toHaveBeenCalled();
});
it('should skip when workspaceDir is not a string', async () => {
mockReadSetting.mockReturnValue({ serverHost: 'example.com' });
const { migrateSettingsPaths } = await import('./migrate');
migrateSettingsPaths();
expect(mockWriteSetting).not.toHaveBeenCalled();
});
it('should skip when workspaceDir does not match legacy prefix', async () => {
mockReadSetting.mockReturnValue({ workspaceDir: '/Users/user/projects' });
const { migrateSettingsPaths } = await import('./migrate');
migrateSettingsPaths();
expect(mockWriteSetting).not.toHaveBeenCalled();
});
it('should update workspaceDir from .qimingbot prefix', async () => {
mockReadSetting.mockReturnValue({
workspaceDir: path.join('/mock/home', '.qimingbot', 'workspace'),
serverHost: 'example.com',
});
const { migrateSettingsPaths } = await import('./migrate');
migrateSettingsPaths();
expect(mockWriteSetting).toHaveBeenCalledWith('step1_config', {
workspaceDir: path.join('/mock/home', '.qimingclaw', 'workspace'),
serverHost: 'example.com',
});
});
it('should update workspaceDir from .qiming-agent prefix', async () => {
mockReadSetting.mockReturnValue({
workspaceDir: path.join('/mock/home', '.qiming-agent', 'workspace'),
});
const { migrateSettingsPaths } = await import('./migrate');
migrateSettingsPaths();
expect(mockWriteSetting).toHaveBeenCalledWith('step1_config', {
workspaceDir: path.join('/mock/home', '.qimingclaw', 'workspace'),
});
});
});

View File

@@ -0,0 +1,181 @@
/**
* 数据目录迁移:~/.qiming-agent 或 ~/.qimingbot → ~/.qimingclaw
*
* 必须在 initDatabase() 之前同步执行,确保 DB 从新路径打开。
*
* 两种场景:
* 1. 新目录不存在 → 整体 rename 旧目录
* 2. 新目录已存在但 DB 为空(依赖安装等先创建了目录)→ 从旧目录复制 DB
*
* 优先级:.qiming-agent > .qimingbot找到第一个存在的旧目录即迁移
*/
import * as fs from 'fs';
import * as path from 'path';
import { app } from 'electron';
import log from 'electron-log';
import Database from 'better-sqlite3';
import { APP_NAME_IDENTIFIER } from '@shared/constants';
import { readSetting, writeSetting } from '../db';
interface LegacySource {
dirName: string;
dbName: string;
configName: string | null;
}
const LEGACY_SOURCES: LegacySource[] = [
{ dirName: '.qiming-agent', dbName: 'qiming-agent.db', configName: null },
{ dirName: '.qimingbot', dbName: 'qimingbot.db', configName: 'qimingbot.json' },
];
/**
* 检查 DB 文件是否包含有效的 settings 数据
*/
function isDbEmpty(dbPath: string): boolean {
if (!fs.existsSync(dbPath)) return true;
try {
const db = new Database(dbPath, { readonly: true });
const row = db.prepare('SELECT COUNT(*) as count FROM settings').get() as { count: number };
db.close();
return row.count === 0;
} catch {
return true;
}
}
/**
* 复制 DB 文件(主文件 + WAL/SHM
*/
function copyDbFiles(oldDb: string, newDb: string): void {
fs.copyFileSync(oldDb, newDb);
for (const suffix of ['-wal', '-shm']) {
const oldAux = oldDb + suffix;
const newAux = newDb + suffix;
if (fs.existsSync(oldAux)) {
fs.copyFileSync(oldAux, newAux);
}
}
}
/**
* 重命名 DB 文件(主文件 + WAL/SHM
*/
function renameDbFiles(oldDb: string, newDb: string): void {
fs.renameSync(oldDb, newDb);
for (const suffix of ['-wal', '-shm']) {
const oldAux = oldDb + suffix;
const newAux = newDb + suffix;
if (fs.existsSync(oldAux)) {
fs.renameSync(oldAux, newAux);
}
}
}
export function migrateDataDir(): void {
const home = app.getPath('home');
const newDir = path.join(home, `.${APP_NAME_IDENTIFIER}`);
const newDbName = `${APP_NAME_IDENTIFIER}.db`;
const newDb = path.join(newDir, newDbName);
if (fs.existsSync(newDir)) {
// 新目录已存在 — 若 DB 为空,仍需从旧目录导入数据
if (!isDbEmpty(newDb)) {
return; // DB 有数据,无需迁移
}
log.info('[Migrate] New dir exists but DB is empty, importing from legacy DB...');
importLegacyDb(home, newDb);
return;
}
// 新目录不存在 → 整体 rename 旧目录
for (const source of LEGACY_SOURCES) {
const oldDir = path.join(home, source.dirName);
if (!fs.existsSync(oldDir)) {
continue;
}
log.info(`[Migrate] Found legacy data dir: ${oldDir}, renaming → ${newDir}`);
try {
fs.renameSync(oldDir, newDir);
} catch (e) {
log.error('[Migrate] Failed to rename data directory:', e);
return;
}
// 重命名 DB 文件
const oldDb = path.join(newDir, source.dbName);
if (fs.existsSync(oldDb) && !fs.existsSync(newDb)) {
try {
renameDbFiles(oldDb, newDb);
log.info(`[Migrate] Renamed DB: ${source.dbName}${newDbName}`);
} catch (e) {
log.error('[Migrate] Failed to rename database file:', e);
}
}
// 重命名 config 文件(如果存在)
if (source.configName) {
const newConfigName = `${APP_NAME_IDENTIFIER}.json`;
const oldConfig = path.join(newDir, source.configName);
const newConfig = path.join(newDir, newConfigName);
if (fs.existsSync(oldConfig) && !fs.existsSync(newConfig)) {
try {
fs.renameSync(oldConfig, newConfig);
log.info(`[Migrate] Renamed config: ${source.configName}${newConfigName}`);
} catch (e) {
log.error('[Migrate] Failed to rename config file:', e);
}
}
}
log.info('[Migrate] Data directory migration completed');
return; // 只迁移第一个找到的旧目录
}
}
/**
* 新目录已存在但 DB 为空时,从旧目录复制 DB 文件
*/
function importLegacyDb(home: string, newDb: string): void {
for (const source of LEGACY_SOURCES) {
const oldDir = path.join(home, source.dirName);
const oldDb = path.join(oldDir, source.dbName);
if (!fs.existsSync(oldDb) || isDbEmpty(oldDb)) {
continue;
}
try {
copyDbFiles(oldDb, newDb);
log.info(`[Migrate] Imported legacy DB: ${oldDb}${newDb}`);
} catch (e) {
log.error('[Migrate] Failed to import legacy DB:', e);
}
return; // 只导入第一个有效的旧 DB
}
}
/**
* 修补 DB 中 step1_config.workspaceDir 的旧路径引用
*
* 当用户手动选择的工作空间目录包含旧数据目录前缀时,替换为新前缀。
* 必须在 initDatabase() 之后调用。
*/
export function migrateSettingsPaths(): void {
const home = app.getPath('home');
const newPrefix = path.join(home, `.${APP_NAME_IDENTIFIER}`);
const LEGACY_DIR_NAMES = ['.qiming-agent', '.qimingbot'];
const step1Config = readSetting('step1_config') as Record<string, unknown> | null;
if (!step1Config || typeof step1Config.workspaceDir !== 'string') return;
for (const legacyName of LEGACY_DIR_NAMES) {
const oldPrefix = path.join(home, legacyName);
if (step1Config.workspaceDir.startsWith(oldPrefix)) {
step1Config.workspaceDir = newPrefix + step1Config.workspaceDir.slice(oldPrefix.length);
writeSetting('step1_config', step1Config);
log.info(`[Migrate] Updated step1_config.workspaceDir → ${step1Config.workspaceDir}`);
return;
}
}
}

View File

@@ -0,0 +1,351 @@
import path from 'node:path';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// --- Mocks ---
vi.mock('electron-log', () => ({
default: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
const mockExistsSync = vi.fn(() => false);
const mockReadFileSync = vi.fn(() => '');
vi.mock('node:fs', () => ({
default: {
existsSync: (...args: unknown[]) => mockExistsSync(...args),
readFileSync: (...args: unknown[]) => mockReadFileSync(...args),
},
existsSync: (...args: unknown[]) => mockExistsSync(...args),
readFileSync: (...args: unknown[]) => mockReadFileSync(...args),
}));
vi.mock('node:os', () => ({
default: { homedir: () => '/mock/home' },
homedir: () => '/mock/home',
}));
// Env vars to clean up after each test
const QUICK_INIT_ENV_KEYS = [
'QIMING_SERVER_HOST',
'QIMING_SAVED_KEY',
'QIMING_USER_NAME',
'QIMING_AGENT_PORT',
'QIMING_FILE_SERVER_PORT',
'QIMING_WORKSPACE_DIR',
];
describe('readQuickInitConfig', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
mockExistsSync.mockReturnValue(false);
mockReadFileSync.mockReturnValue('');
// Clean env vars
for (const key of QUICK_INIT_ENV_KEYS) {
delete process.env[key];
}
});
afterEach(() => {
for (const key of QUICK_INIT_ENV_KEYS) {
delete process.env[key];
}
});
/** Helper: dynamic import (cache resets via vi.resetModules) */
async function loadReader() {
const mod = await import('./quickInit');
return mod.readQuickInitConfig;
}
// ==================== 无配置 ====================
it('should return null when no JSON file and no env vars', async () => {
const readQuickInitConfig = await loadReader();
expect(readQuickInitConfig()).toBeNull();
});
// ==================== JSON 配置 ====================
it('should read config from JSON with all fields', async () => {
const json = {
quickInit: {
serverHost: 'https://agent.qiming.com',
savedKey: 'key-123',
username: 'user@test.com',
agentPort: 9001,
fileServerPort: 9002,
workspaceDir: '/custom/workspace',
},
};
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue(JSON.stringify(json));
const readQuickInitConfig = await loadReader();
const config = readQuickInitConfig();
expect(config).toEqual({
serverHost: 'https://agent.qiming.com',
savedKey: 'key-123',
username: 'user@test.com',
agentPort: 9001,
fileServerPort: 9002,
workspaceDir: '/custom/workspace',
});
});
it('should fill defaults for optional JSON fields', async () => {
const json = {
quickInit: {
serverHost: 'https://agent.qiming.com',
savedKey: 'key-123',
},
};
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue(JSON.stringify(json));
const readQuickInitConfig = await loadReader();
const config = readQuickInitConfig();
expect(config).not.toBeNull();
expect(config!.serverHost).toBe('https://agent.qiming.com');
expect(config!.savedKey).toBe('key-123');
expect(config!.username).toBe('');
expect(config!.agentPort).toBe(60006);
expect(config!.fileServerPort).toBe(60005);
expect(config!.workspaceDir).toBe(path.join('/mock/home', '.qimingclaw', 'workspace'));
});
it('should return null when JSON has enabled: false', async () => {
const json = {
quickInit: {
enabled: false,
serverHost: 'https://agent.qiming.com',
savedKey: 'key-123',
},
};
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue(JSON.stringify(json));
const readQuickInitConfig = await loadReader();
expect(readQuickInitConfig()).toBeNull();
});
it('should NOT disable when enabled is true', async () => {
const json = {
quickInit: {
enabled: true,
serverHost: 'https://agent.qiming.com',
savedKey: 'key-123',
},
};
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue(JSON.stringify(json));
const readQuickInitConfig = await loadReader();
expect(readQuickInitConfig()).not.toBeNull();
});
it('should return null when JSON has no quickInit scope', async () => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue(JSON.stringify({ otherConfig: {} }));
const readQuickInitConfig = await loadReader();
expect(readQuickInitConfig()).toBeNull();
});
it('should return null when JSON quickInit missing required fields', async () => {
const json = { quickInit: { username: 'user' } };
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue(JSON.stringify(json));
const readQuickInitConfig = await loadReader();
expect(readQuickInitConfig()).toBeNull();
});
it('should return null when JSON is malformed', async () => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue('{ broken json');
const readQuickInitConfig = await loadReader();
expect(readQuickInitConfig()).toBeNull();
});
// ==================== 环境变量 ====================
it('should read config from env vars when no JSON', async () => {
process.env.QIMING_SERVER_HOST = 'https://env.qiming.com';
process.env.QIMING_SAVED_KEY = 'env-key-456';
const readQuickInitConfig = await loadReader();
const config = readQuickInitConfig();
expect(config).not.toBeNull();
expect(config!.serverHost).toBe('https://env.qiming.com');
expect(config!.savedKey).toBe('env-key-456');
expect(config!.agentPort).toBe(60006);
expect(config!.fileServerPort).toBe(60005);
expect(config!.workspaceDir).toBe(path.join('/mock/home', '.qimingclaw', 'workspace'));
});
it('should read optional env vars', async () => {
process.env.QIMING_SERVER_HOST = 'https://env.qiming.com';
process.env.QIMING_SAVED_KEY = 'env-key';
process.env.QIMING_USER_NAME = 'envuser';
process.env.QIMING_AGENT_PORT = '7001';
process.env.QIMING_FILE_SERVER_PORT = '7002';
process.env.QIMING_WORKSPACE_DIR = '/env/workspace';
const readQuickInitConfig = await loadReader();
const config = readQuickInitConfig();
expect(config).toEqual({
serverHost: 'https://env.qiming.com',
savedKey: 'env-key',
username: 'envuser',
agentPort: 7001,
fileServerPort: 7002,
workspaceDir: '/env/workspace',
});
});
it('should return null when only QIMING_SERVER_HOST set (no savedKey)', async () => {
process.env.QIMING_SERVER_HOST = 'https://env.qiming.com';
const readQuickInitConfig = await loadReader();
expect(readQuickInitConfig()).toBeNull();
});
it('should return null when only QIMING_SAVED_KEY set (no serverHost)', async () => {
process.env.QIMING_SAVED_KEY = 'env-key';
const readQuickInitConfig = await loadReader();
expect(readQuickInitConfig()).toBeNull();
});
it('should ignore invalid QIMING_AGENT_PORT', async () => {
process.env.QIMING_SERVER_HOST = 'https://env.qiming.com';
process.env.QIMING_SAVED_KEY = 'env-key';
process.env.QIMING_AGENT_PORT = 'notanumber';
const readQuickInitConfig = await loadReader();
const config = readQuickInitConfig();
expect(config!.agentPort).toBe(60006); // fallback to default
});
// ==================== 优先级: JSON > env > default ====================
it('should prefer JSON fields over env vars', async () => {
const json = {
quickInit: {
serverHost: 'https://json.qiming.com',
savedKey: 'json-key',
agentPort: 8001,
},
};
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue(JSON.stringify(json));
process.env.QIMING_SERVER_HOST = 'https://env.qiming.com';
process.env.QIMING_SAVED_KEY = 'env-key';
process.env.QIMING_AGENT_PORT = '9999';
const readQuickInitConfig = await loadReader();
const config = readQuickInitConfig();
expect(config!.serverHost).toBe('https://json.qiming.com');
expect(config!.savedKey).toBe('json-key');
expect(config!.agentPort).toBe(8001);
});
it('should fill missing JSON fields from env vars', async () => {
const json = {
quickInit: {
serverHost: 'https://json.qiming.com',
savedKey: 'json-key',
// agentPort missing — should come from env
// workspaceDir missing — should come from env
},
};
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue(JSON.stringify(json));
process.env.QIMING_AGENT_PORT = '7777';
process.env.QIMING_WORKSPACE_DIR = '/env/ws';
process.env.QIMING_USER_NAME = 'envuser';
const readQuickInitConfig = await loadReader();
const config = readQuickInitConfig();
expect(config!.serverHost).toBe('https://json.qiming.com');
expect(config!.agentPort).toBe(7777);
expect(config!.workspaceDir).toBe('/env/ws');
expect(config!.username).toBe('envuser');
});
it('should use defaults when neither JSON nor env provides optional fields', async () => {
const json = {
quickInit: {
serverHost: 'https://json.qiming.com',
savedKey: 'json-key',
},
};
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue(JSON.stringify(json));
const readQuickInitConfig = await loadReader();
const config = readQuickInitConfig();
expect(config!.agentPort).toBe(60006);
expect(config!.fileServerPort).toBe(60005);
expect(config!.workspaceDir).toBe(path.join('/mock/home', '.qimingclaw', 'workspace'));
expect(config!.username).toBe('');
});
// ==================== enabled: false 阻断环境变量 ====================
it('should NOT fall through to env vars when JSON has enabled: false', async () => {
const json = {
quickInit: {
enabled: false,
serverHost: 'https://json.qiming.com',
savedKey: 'json-key',
},
};
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue(JSON.stringify(json));
process.env.QIMING_SERVER_HOST = 'https://env.qiming.com';
process.env.QIMING_SAVED_KEY = 'env-key';
const readQuickInitConfig = await loadReader();
expect(readQuickInitConfig()).toBeNull();
});
// ==================== 缓存 ====================
it('should cache result and return same value on second call', async () => {
process.env.QIMING_SERVER_HOST = 'https://env.qiming.com';
process.env.QIMING_SAVED_KEY = 'env-key';
const readQuickInitConfig = await loadReader();
const first = readQuickInitConfig();
const second = readQuickInitConfig();
expect(first).toBe(second); // same reference
// fs should not be called (no JSON file)
expect(mockExistsSync).toHaveBeenCalledTimes(1);
});
it('should cache null result', async () => {
const readQuickInitConfig = await loadReader();
const first = readQuickInitConfig();
const second = readQuickInitConfig();
expect(first).toBeNull();
expect(second).toBeNull();
expect(mockExistsSync).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,140 @@
/**
* Quick Init — Main 进程读取快捷初始化配置
*
* 配置优先级per-field: qimingclaw.json (quickInit scope) → 环境变量 → 客户端默认值
*
* 环境变量:
* QIMING_SERVER_HOST → serverHost (必填)
* QIMING_SAVED_KEY → savedKey (必填)
* QIMING_AGENT_PORT → agentPort (可选, 默认 60006)
* QIMING_FILE_SERVER_PORT → fileServerPort (可选, 默认 60005)
* QIMING_WORKSPACE_DIR → workspaceDir (可选, 默认 ~/.qimingclaw/workspace)
* QIMING_USER_NAME → username (可选, 默认 '')
*
* 最低必填: serverHost + savedKey来自任意源其余用客户端默认值
* agentPort → DEFAULT_AGENT_RUNNER_PORT (60006)
* fileServerPort → DEFAULT_FILE_SERVER_PORT (60005)
* workspaceDir → ~/.qimingclaw/workspace
* username → ''
*/
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import log from "electron-log";
import { APP_DATA_DIR_NAME } from "../services/constants";
import {
DEFAULT_AGENT_RUNNER_PORT,
DEFAULT_FILE_SERVER_PORT,
} from "@shared/constants";
import { type QuickInitConfig } from "@shared/types/quickInit";
let cachedConfig: QuickInitConfig | null | undefined;
/** 读取环境变量,返回各字段(未设置的为 undefined */
function readEnvVars() {
const port = parseInt(process.env.QIMING_AGENT_PORT || "", 10);
const fsPort = parseInt(process.env.QIMING_FILE_SERVER_PORT || "", 10);
return {
serverHost: process.env.QIMING_SERVER_HOST || undefined,
savedKey: process.env.QIMING_SAVED_KEY || undefined,
username: process.env.QIMING_USER_NAME || undefined,
agentPort: port > 0 ? port : undefined,
fileServerPort: fsPort > 0 ? fsPort : undefined,
workspaceDir: process.env.QIMING_WORKSPACE_DIR || undefined,
};
}
/** 取第一个有效的 string 值 */
function pickStr(...values: unknown[]): string {
for (const v of values) {
if (typeof v === "string" && v.length > 0) return v;
}
return "";
}
/** 取第一个有效的正整数值 */
function pickPort(...values: unknown[]): number {
for (const v of values) {
if (typeof v === "number" && v > 0) return v;
}
return 0;
}
/**
* 读取快捷初始化配置
*
* 优先级per-field: qimingclaw.json → 环境变量 → 默认值
* 结果缓存,每次启动只读一次
*/
export function readQuickInitConfig(): QuickInitConfig | null {
if (cachedConfig !== undefined) return cachedConfig;
const appDataDir = path.join(os.homedir(), APP_DATA_DIR_NAME);
const defaultWorkspace = path.join(appDataDir, "workspace");
const filePath = path.join(appDataDir, "qimingclaw.json");
// --- 读取 qimingclaw.json (quickInit scope) ---
let json: Record<string, unknown> | null = null;
try {
if (fs.existsSync(filePath)) {
const raw = fs.readFileSync(filePath, "utf-8");
const parsed = JSON.parse(raw);
const scope = parsed?.quickInit;
if (scope && typeof scope === "object") {
if (scope.enabled === false) {
log.info("[QuickInit] Config disabled (enabled: false)");
cachedConfig = null;
return null;
}
json = scope;
}
}
} catch (error) {
log.warn("[QuickInit] Failed to read JSON config:", error);
}
// --- 读取环境变量 ---
const env = readEnvVars();
// --- 合并: JSON > env > default ---
const serverHost = pickStr(json?.serverHost, env.serverHost);
const savedKey = pickStr(json?.savedKey, env.savedKey);
if (!serverHost || !savedKey) {
log.info(
"[QuickInit] No quick config detected (missing serverHost or savedKey)",
);
cachedConfig = null;
return null;
}
cachedConfig = {
serverHost,
savedKey,
username: pickStr(json?.username, env.username),
agentPort:
pickPort(json?.agentPort, env.agentPort) || DEFAULT_AGENT_RUNNER_PORT,
fileServerPort:
pickPort(json?.fileServerPort, env.fileServerPort) ||
DEFAULT_FILE_SERVER_PORT,
workspaceDir:
pickStr(json?.workspaceDir, env.workspaceDir) || defaultWorkspace,
};
const source = json
? env.serverHost || env.savedKey
? "JSON + env"
: "JSON"
: "env";
log.info(`[QuickInit] Config loaded (${source}):`, {
serverHost: cachedConfig.serverHost,
username: cachedConfig.username || "(empty)",
agentPort: cachedConfig.agentPort,
fileServerPort: cachedConfig.fileServerPort,
workspaceDir: cachedConfig.workspaceDir,
});
return cachedConfig;
}

View File

@@ -0,0 +1,151 @@
import log from "electron-log";
import { app, BrowserWindow } from "electron";
import { getDb, readSetting } from "../db";
import { startComputerServer } from "../services/computerServer";
import {
mcpProxyManager,
DEFAULT_MCP_PROXY_CONFIG,
} from "../services/packages/mcp";
import { getConfiguredPorts } from "../services/startupPorts";
import { DEPS_SYNC_TIMEOUT } from "@shared/constants";
import {
startSandboxService,
stopSandboxService,
} from "../services/sandbox/serviceBootstrap";
/** 依赖同步是否正在进行 */
let _depsSyncInProgress = false;
export function isDepsSyncInProgress(): boolean {
return _depsSyncInProgress;
}
export async function runStartupTasks(): Promise<void> {
// 从 SQLite 恢复镜像配置
const {
setMirrorConfig,
getInitDepsState,
syncInitDependencies,
getSetupRequiredDependencies,
} = await import("../services/system/dependencies");
const mirrorConfig = readSetting("mirror_config");
if (mirrorConfig) {
try {
setMirrorConfig(mirrorConfig);
} catch (e) {
log.warn("[Mirror] Failed to apply mirror config:", e);
}
}
// 尽早启动 Computer HTTP Server对齐 rcoder /computer/* API端口来自聚合配置
{
const { agent: agentPort } = getConfiguredPorts();
startComputerServer(agentPort).then((r) => {
if (r.success)
log.info(`[Init] Computer HTTP server listening on port ${agentPort}`);
else log.warn(`[Init] Computer HTTP server failed: ${r.error}`);
});
}
// 初始化 MCP Proxy 配置(从数据库加载)
try {
const db = getDb();
const savedConfig = db
?.prepare("SELECT value FROM settings WHERE key = ?")
.get("mcp_proxy_config") as { value: string } | undefined;
if (savedConfig) {
try {
const parsed = JSON.parse(savedConfig.value);
// 合并默认服务器(如 chrome-devtools确保内置 MCP 服务始终存在
const merged = {
...parsed,
mcpServers: {
...DEFAULT_MCP_PROXY_CONFIG.mcpServers,
...(parsed.mcpServers || {}),
},
};
mcpProxyManager.setConfig(merged);
} catch (e) {
log.warn("[McpProxy] Init config parse failed:", e);
}
}
log.info("[McpProxy] Config loaded");
} catch (e) {
log.warn("[McpProxy] Init config failed:", e);
}
// 初始化沙箱服务(后台启动,不阻塞主流程)
setImmediate(async () => {
try {
await startSandboxService();
log.info("[Sandbox] Sandbox service started");
} catch (e) {
log.warn("[Sandbox] Sandbox service start failed (non-fatal):", e);
// 沙箱服务失败不阻塞应用,只是功能降级
}
});
// 客户端升级后:若 appVersion 或 installVersion 变化,后台同步初始化依赖到写死版本
// 同步检查是否需要 dep sync提前设置标志避免 renderer 在 setImmediate 之前
// 读到 syncInProgress=false 而过早启动服务(竞态条件)
const state = getInitDepsState();
const currentVersion = app.getVersion();
const versionChanged = !state || state.appVersion !== currentVersion;
let packagesChanged = false;
if (state?.packages) {
for (const dep of getSetupRequiredDependencies()) {
if (!dep.installVersion) continue;
if (state.packages[dep.name] !== dep.installVersion) {
packagesChanged = true;
break;
}
}
} else {
packagesChanged = true;
}
const needsSync = versionChanged || packagesChanged;
if (needsSync) {
_depsSyncInProgress = true;
}
setImmediate(async () => {
if (!needsSync) return;
try {
try {
let syncTimer: ReturnType<typeof setTimeout>;
const { updated } = await Promise.race([
syncInitDependencies(),
new Promise<never>((_, reject) => {
syncTimer = setTimeout(
() =>
reject(
new Error(
`Dependency sync timeout after ${DEPS_SYNC_TIMEOUT}ms`,
),
),
DEPS_SYNC_TIMEOUT,
);
}),
]).finally(() => clearTimeout(syncTimer!));
if (updated.length > 0)
log.info("[Init] Init dependencies synced:", updated);
} finally {
_depsSyncInProgress = false;
// 通知所有渲染进程依赖同步完成,重新检测
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) {
win.webContents.send("deps:syncCompleted");
}
}
}
} catch (e) {
_depsSyncInProgress = false;
// 同步失败也通知渲染进程重新检测实际状态
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) {
win.webContents.send("deps:syncCompleted");
}
}
log.warn("[Init] Init dependencies sync failed:", e);
}
});
}