Jelajahi Sumber

feat(core): 迁移旧服务密钥 id

fanghanjun 6 bulan lalu
induk
melakukan
490351e9e9

+ 71 - 0
packages/core/src/__tests__/secrets-migration.test.ts

@@ -0,0 +1,71 @@
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { loadSecrets } from "../llm/secrets.js";
+
+describe("loadSecrets legacy service id migration", () => {
+  let root: string;
+
+  beforeEach(async () => {
+    root = await mkdtemp(join(tmpdir(), "inkos-secrets-mig-"));
+    await mkdir(join(root, ".inkos"), { recursive: true });
+  });
+
+  afterEach(async () => {
+    await rm(root, { recursive: true, force: true });
+  });
+
+  async function seedSecrets(data: unknown): Promise<void> {
+    await writeFile(join(root, ".inkos", "secrets.json"), JSON.stringify(data, null, 2), "utf-8");
+  }
+
+  async function readSecretsRaw(): Promise<any> {
+    return JSON.parse(await readFile(join(root, ".inkos", "secrets.json"), "utf-8"));
+  }
+
+  it("siliconflow -> siliconcloud 重命名(目标 id 不存在)", async () => {
+    await seedSecrets({ services: { siliconflow: { apiKey: "sk-legacy" } } });
+    const result = await loadSecrets(root);
+    expect(result.services.siliconcloud).toEqual({ apiKey: "sk-legacy" });
+    expect(result.services.siliconflow).toBeUndefined();
+
+    const onDisk = await readSecretsRaw();
+    expect(onDisk.services.siliconcloud).toEqual({ apiKey: "sk-legacy" });
+    expect(onDisk.services.siliconflow).toBeUndefined();
+  });
+
+  it("目标 id 已存在时不迁移(幂等)", async () => {
+    await seedSecrets({
+      services: {
+        siliconflow: { apiKey: "sk-legacy" },
+        siliconcloud: { apiKey: "sk-new" },
+      },
+    });
+    const result = await loadSecrets(root);
+    expect(result.services.siliconcloud).toEqual({ apiKey: "sk-new" });
+    expect(result.services.siliconflow).toEqual({ apiKey: "sk-legacy" });
+  });
+
+  it("无迁移时不重写磁盘", async () => {
+    await seedSecrets({ services: { openai: { apiKey: "sk-openai" } } });
+    const before = await readFile(join(root, ".inkos", "secrets.json"), "utf-8");
+    await loadSecrets(root);
+    const after = await readFile(join(root, ".inkos", "secrets.json"), "utf-8");
+    expect(after).toBe(before);
+  });
+
+  it("secrets 文件不存在时返回空 services,不报错", async () => {
+    await rm(join(root, ".inkos", "secrets.json"), { force: true });
+    const result = await loadSecrets(root);
+    expect(result).toEqual({ services: {} });
+  });
+
+  it("重复调用 loadSecrets 是幂等的", async () => {
+    await seedSecrets({ services: { siliconflow: { apiKey: "sk-legacy" } } });
+    await loadSecrets(root);
+    const r2 = await loadSecrets(root);
+    expect(r2.services.siliconcloud).toEqual({ apiKey: "sk-legacy" });
+    expect(r2.services.siliconflow).toBeUndefined();
+  });
+});

+ 29 - 2
packages/core/src/llm/secrets.ts

@@ -8,18 +8,45 @@ export interface SecretsFile {
 const SECRETS_DIR = ".inkos";
 const SECRETS_FILE = "secrets.json";
 
-export async function loadSecrets(projectRoot: string): Promise<SecretsFile> {
+const LEGACY_SERVICE_ID_REMAP: Record<string, string> = {
+  siliconflow: "siliconcloud",
+};
+
+function migrateLegacyServiceIds(secrets: SecretsFile): { data: SecretsFile; changed: boolean } {
+  let changed = false;
+  for (const [oldId, newId] of Object.entries(LEGACY_SERVICE_ID_REMAP)) {
+    if (secrets.services[oldId] && !secrets.services[newId]) {
+      secrets.services[newId] = secrets.services[oldId];
+      delete secrets.services[oldId];
+      changed = true;
+    }
+  }
+  return { data: secrets, changed };
+}
+
+async function readSecretsRaw(projectRoot: string): Promise<SecretsFile> {
   try {
     const raw = await readFile(
       join(projectRoot, SECRETS_DIR, SECRETS_FILE),
       "utf-8",
     );
-    return JSON.parse(raw) as SecretsFile;
+    const parsed = JSON.parse(raw) as SecretsFile;
+    if (!parsed || typeof parsed !== "object" || !parsed.services) {
+      return { services: {} };
+    }
+    return parsed;
   } catch {
     return { services: {} };
   }
 }
 
+export async function loadSecrets(projectRoot: string): Promise<SecretsFile> {
+  const raw = await readSecretsRaw(projectRoot);
+  const { data, changed } = migrateLegacyServiceIds(raw);
+  if (changed) await saveSecrets(projectRoot, data);
+  return data;
+}
+
 export async function saveSecrets(
   projectRoot: string,
   secrets: SecretsFile,