Files
root 06891fa726 fix(env-sync): 同步 provider_config.json(UI 分组/顺序/模型级配置)
问题:只同步 v2/config.json 时,只在 v2/provider_config.json 里存在的
provider 分组到对面就是缺失的 —— 表现为「provider 数量对,但 UI 分组少几个」。
实测:9 个分组里有 4 个(GINKA API / zxcbug / zxcbug-p / d1)纯规则型,
密钥只存在 provider_config.json 的 config.access.apiKey 里,旧实现完全没收集。

修复:
- sync-core:新增 V2_PROVIDER_CONFIG、extract/sanitize/collectProviderConfigSecrets、
  providerConfigKeyManifest;密钥命名空间 PROVIDER_CONFIG/<providerId>/<key>
- export:导出 v2.provider-config.json(脱敏),密钥并入加密束,manifest 记摘要与哈希
- import:按 providerId 合并规则、回填密钥空位、按 (providerId,modelId) 合并模型级配置、
  providerOrder 以快照为准并保留本机特有分组;重复导入幂等
- import:provider_config 路径补「护住本机已有密钥 N 个」提示(原来只在 config.json 侧有)
- 测试:单元 6 项 + E2E 2 项(分组完整同步/强制覆盖),全量 72 项通过
2026-09-21 13:00:10 +08:00

303 lines
16 KiB
JavaScript

#!/usr/bin/env node
/**
* /sync-import:从私仓拉快照 → 还原到本机(先备份)。
* 用法: ZCODE_SYNC_PASSPHRASE='口令' node import.mjs [快照名]
* 快照名缺省用 latest.json 指向的最新快照。
* 环境变量: ZCODE_SYNC_APPLY_OFFICIAL=0 跳过官方插件安装(只还原三项)
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { createHash } from "node:crypto";
import {
HOME, CLI_CONFIG, PLUGINS_HOME, REPO_DIR, BACKUP_ROOT, V2_CONFIG, V2_SETTING, V2_PROVIDER_CONFIG,
WITH_MODELS, APPLY_MODEL_SELECTION, OVERWRITE_MODEL_SECRETS, MODEL_SECRET_REF,
nowTag, readJson, writeJson, decodeJsonPaths, encodeJsonPaths,
decryptSecrets, restoreRelPath, isModelEntry, parseModelRel, requirePassphrase,
ensureRepo, readSnapshotManifest, latestSnapshotDir,
deepMerge, loadOverrides,
isProviderConfigEntry, parseProviderConfigRel,
} from "./sync-core.mjs";
function fail(e) { console.error(`导入失败:${e?.message ?? e}`); process.exitCode = 1; }
const stamps = new Set();
function backup(abs) {
const stamp = nowTag();
const rel = path.relative(HOME, abs);
const dst = path.join(BACKUP_ROOT, stamp, ...rel.split(path.sep));
if (!fs.existsSync(abs)) return null;
stamps.add(stamp);
fs.mkdirSync(path.dirname(dst), { recursive: true });
const st = fs.statSync(abs);
if (st.isDirectory()) fs.cpSync(abs, dst, { recursive: true });
else fs.copyFileSync(abs, dst);
return dst;
}
try {
const pass = requirePassphrase();
ensureRepo();
const want = process.argv[2];
const snapDir = want ? path.join(REPO_DIR, "snapshots", want) : latestSnapshotDir();
if (!snapDir || !fs.existsSync(path.join(snapDir, "manifest.json"))) {
throw new Error(want ? `快照不存在:snapshots/${want}` : "远端尚无快照(先 /sync-export)");
}
const manifest = readSnapshotManifest(snapDir);
const snapName = path.basename(snapDir);
const report = [`# 导入快照:${snapName}`, `- 来源:${manifest.host} @ ${manifest.createdAt}`];
// 1) 自研插件源码
const cpSrc = path.join(snapDir, "custom-plugins");
const restored = [];
if (fs.existsSync(cpSrc)) {
for (const d of fs.readdirSync(cpSrc)) {
const s = path.join(cpSrc, d), t = path.join(PLUGINS_HOME, d);
if (!fs.statSync(s).isDirectory()) continue;
const b = backup(t); if (b) report.push(`- 备份自研插件 ${d} → ${b}`);
fs.rmSync(t, { recursive: true, force: true });
fs.cpSync(s, t, { recursive: true });
restored.push(d);
}
}
report.push(`- 自研插件还原:${restored.length} 项(${restored.join(", ") || "—"})`);
if (restored.includes("zcode-env-sync")) report.push(`- 注意:同步插件自身已随快照更新,重启 ZCode 后生效`);
// 2) MCP 配置 + plugins.dirs(占位符还原 + 本机覆盖合并)
const mcpSnap = decodeJsonPaths(readJson(path.join(snapDir, "mcp.servers.json"), {}));
const dirsSnap = decodeJsonPaths(readJson(path.join(snapDir, "plugins-dirs.json"), []));
const { file: overFile, data: over } = loadOverrides();
const merged = deepMerge({ servers: mcpSnap }, { servers: over?.mcp?.servers ?? over?.servers ?? {} });
const cfg = fs.existsSync(CLI_CONFIG) ? readJson(CLI_CONFIG, {}) : {};
const cfgBefore = JSON.stringify(cfg?.mcp?.servers ?? {});
const dirsBefore = JSON.stringify(cfg?.plugins?.dirs ?? []);
const b1 = backup(CLI_CONFIG);
cfg.mcp = cfg.mcp ?? {};
cfg.mcp.servers = merged.servers;
// plugins.dirs:快照路径 + 本机已有的去重合并(避免把本机特有插件挤掉)
const seen = new Set(), mergedDirs = [];
for (const d of [...(Array.isArray(dirsSnap) ? dirsSnap : []), ...(over?.plugins?.dirs ?? []), ...(cfg.plugins?.dirs ?? [])]) {
if (!seen.has(d)) { seen.add(d); mergedDirs.push(d); }
}
cfg.plugins = cfg.plugins ?? {};
cfg.plugins.dirs = mergedDirs;
if (JSON.stringify(cfg.mcp.servers) !== cfgBefore || JSON.stringify(cfg.plugins.dirs) !== dirsBefore) {
if (b1) report.push(`- 备份 cli/config.json → ${b1}`);
writeJson(CLI_CONFIG, cfg);
report.push(`- MCP 配置已更新并写入`);
} else {
report.push(`- MCP 配置与快照一致,未改动(无需备份)`);
}
report.push(`- MCP 还原:${Object.keys(merged.servers).join(", ")}${overFile ? ` (已合并本机覆盖 ${overFile})` : ""}`);
report.push(`- plugins.dirs 共 ${mergedDirs.length} 项`);
// 3) 密钥束解密还原
const enc = fs.readFileSync(path.join(snapDir, "secrets.enc"), "utf8");
let bundle;
try { bundle = decryptSecrets(enc, pass); }
catch { throw new Error("密钥束解密失败:口令不对或文件损坏"); }
const wantMap = new Map((manifest.secretFiles ?? []).map((f) => [f.rel, f.sha256]));
// MODELS/ 条目在本节只收集、不落盘(第 4 节统一写回 v2/config.json)
const { skipSecretsSet } = await import("./sync-core.mjs");
const skipSet = skipSecretsSet();
const skipped = [];
const modelKeys = [];
const pcKeys = [];
let n = 0;
for (const f of bundle.files ?? []) {
if (isModelEntry(f.rel)) { modelKeys.push(f); continue; }
if (isProviderConfigEntry(f.rel)) { pcKeys.push(f); continue; }
if (skipSet.has(f.rel)) { skipped.push(f.rel); continue; }
let dst;
try { dst = restoreRelPath(f.rel); }
catch (e) { report.push(`- ⚠ 跳过未知密钥条目 ${f.rel}(${e.message})`); continue; }
const b = backup(dst); if (b) report.push(`- 备份密钥 ${f.rel} → ${b}`);
fs.mkdirSync(path.dirname(dst), { recursive: true });
fs.writeFileSync(dst, Buffer.from(f.contentB64, "base64"), { mode: 0o600 });
try { fs.chmodSync(dst, 0o600); } catch { /* windows 忽略 */ }
n++;
if (wantMap.has(f.rel)) {
const got = createHash("sha256").update(fs.readFileSync(dst)).digest("hex");
if (got !== wantMap.get(f.rel)) report.push(`- ⚠ 密钥 ${f.rel} 哈希与清单不一致(可能被快照后改过,已按快照还原)`);
}
}
report.push(`- 密钥束还原:${n} 个文件(权限 600)`
+ (skipped.length ? `,跳过 ${skipped.length} 个(ZCODE_SYNC_SKIP_SECRETS):${skipped.join(", ")}` : ""));
// 4) 模型 provider:v2.providers.json(脱敏结构)+ 加密束 MODELS/ 真密钥
const provFile = path.join(snapDir, "v2.providers.json");
if (fs.existsSync(provFile) && (manifest.modelProviders ?? []).length) {
const provs = decodeJsonPaths(readJson(provFile, {}));
const ids = Object.keys(provs);
if (!WITH_MODELS) {
report.push(`- 模型 provider:快照含 ${ids.length} 个(${ids.join(", ")}),默认不覆盖本机(设 ZCODE_SYNC_WITH_MODELS=1 应用)`);
const needKey = manifest.modelProvidersWithSecrets ?? [];
if (needKey.length) report.push(` 其中带密钥 ${needKey.length} 个:${needKey.join(", ")}`);
} else {
const local2 = fs.existsSync(V2_CONFIG) ? readJson(V2_CONFIG, {}) : {};
const b2 = backup(V2_CONFIG); if (b2) report.push(`- 备份 v2/config.json → ${b2}`);
local2.provider = local2.provider ?? {};
const keyMap = new Map();
for (const f of modelKeys) {
const { provider, key } = parseModelRel(f.rel);
keyMap.set(`${provider}\n${key}`, Buffer.from(f.contentB64, "base64").toString("utf8"));
}
let addedProv = 0, filledKey = 0, skippedOverwrite = 0, skippedKey = 0;
for (const [id, snapPv] of Object.entries(provs)) {
if (!local2.provider[id]) { local2.provider[id] = JSON.parse(JSON.stringify(snapPv)); addedProv++; }
else {
// 已有 provider:只补缺的模型定义与非密钥字段,不碰本机已有密钥
const lp = local2.provider[id];
lp.models = { ...(snapPv.models ?? {}), ...(lp.models ?? {}) };
for (const [k, v] of Object.entries(snapPv.options ?? {})) {
if (v === MODEL_SECRET_REF) continue;
if (lp.options?.[k] === undefined) { lp.options = lp.options ?? {}; lp.options[k] = v; }
}
if (lp.name === undefined) lp.name = snapPv.name;
if (lp.kind === undefined) lp.kind = snapPv.kind;
}
// 密钥回填:快照结构里的占位符才填真值
const lp = local2.provider[id];
for (const [kk, vv] of keyMap) {
const [pid, k] = kk.split("\n");
if (pid !== id) continue;
if (skipSet.has(`MODELS/${pid}/${k}`)) { skippedKey++; continue; }
const cur = lp.options?.[k];
if (cur === MODEL_SECRET_REF || cur === undefined || cur === "" || (typeof cur === "string" && cur.length < 8)) {
lp.options = lp.options ?? {}; lp.options[k] = vv; filledKey++;
} else if (!OVERWRITE_MODEL_SECRETS) {
skippedOverwrite++;
} else {
lp.options[k] = vv; filledKey++;
}
}
}
writeJson(V2_CONFIG, local2);
report.push(`- 模型 provider:新增 ${addedProv} 个,密钥回填 ${filledKey} 个`
+ (skippedOverwrite ? `,跳过覆盖本机已有密钥 ${skippedOverwrite} 个(需覆盖设 ZCODE_SYNC_OVERWRITE_MODEL_SECRETS=1)` : "")
+ (skippedKey ? `,跳过 ${skippedKey} 个(ZCODE_SYNC_SKIP_SECRETS)` : ""));
// 选中态:默认不碰,显式开启才应用
if (APPLY_MODEL_SELECTION && manifest.modelSelection) {
const st = fs.existsSync(V2_SETTING) ? readJson(V2_SETTING, {}) : {};
const bs = backup(V2_SETTING); if (bs) report.push(`- 备份 v2/setting.json → ${bs}`);
Object.assign(st, manifest.modelSelection);
writeJson(V2_SETTING, st);
report.push(`- 模型选中态已应用(ZCODE_SYNC_APPLY_MODEL_SELECTION=1)`);
} else if (manifest.modelSelection) {
report.push(`- 模型选中态:快照有记录,未覆盖本机(需应用设 ZCODE_SYNC_APPLY_MODEL_SELECTION=1)`);
}
}
} else if (manifest.modelProviders?.length) {
report.push(`- 模型 provider:快照仅记录摘要 ${manifest.modelProviders.length} 个(未含配置本体)`);
} else {
report.push(`- 模型:快照未含模型(导出时未开 ZCODE_SYNC_WITH_MODELS=1)`);
}
// 4b) provider_config.json:UI 的分组/顺序/模型级配置
// config.json 决定「provider 能不能调」,这份决定「UI 怎么分组显示」。
// 只同步前者时,仅在 provider_config 里存在的分组到对面就是缺失的。
const pcFile = path.join(snapDir, "v2.provider-config.json");
if (fs.existsSync(pcFile) && (manifest.providerConfigRuleCount ?? 0) > 0) {
const snapPc = decodeJsonPaths(readJson(pcFile, {}));
if (!WITH_MODELS) {
report.push(`- UI 分组:快照含 ${(manifest.providerConfigOrder ?? []).length} 个分组,`
+ `默认不覆盖本机(设 ZCODE_SYNC_WITH_MODELS=1 应用)`);
} else {
const localPc = fs.existsSync(V2_PROVIDER_CONFIG) ? readJson(V2_PROVIDER_CONFIG, {}) : {};
const b3 = backup(V2_PROVIDER_CONFIG); if (b3) report.push(`- 备份 v2/provider_config.json → ${b3}`);
const keyMapPc = new Map();
for (const f of pcKeys) {
const { provider, key } = parseProviderConfigRel(f.rel);
keyMapPc.set(`${provider}\n${key}`, Buffer.from(f.contentB64, "base64").toString("utf8"));
}
const snapCfg = snapPc?.config ?? {};
const localCfg = localPc.config ?? (localPc.config = {});
const snapRules = snapCfg.providerConfigRules?.providerRules ?? [];
const snapModels = snapCfg.modelConfigRules?.providerModelRules ?? [];
const snapManual = snapCfg.modelConfigRules?.manualProviderModelRules ?? [];
localCfg.providerConfigRules = localCfg.providerConfigRules ?? {};
localCfg.modelConfigRules = localCfg.modelConfigRules ?? {};
const localRules = localCfg.providerConfigRules.providerRules ?? (localCfg.providerConfigRules.providerRules = []);
const localModels = localCfg.modelConfigRules.providerModelRules ?? (localCfg.modelConfigRules.providerModelRules = []);
const localManual = localCfg.modelConfigRules.manualProviderModelRules ?? (localCfg.modelConfigRules.manualProviderModelRules = []);
// 按 id 建索引,避免同一 provider 重复追加
const byId = new Map(localRules.map((r, i) => [r?.providerId, i]));
let addRule = 0, mergeRule = 0, filledPcKey = 0, skippedPcKey = 0, skippedPcOverwrite = 0, addModel = 0;
for (const sr of snapRules) {
const id = sr?.providerId;
if (!id) continue;
if (!byId.has(id)) { localRules.push(JSON.parse(JSON.stringify(sr))); byId.set(id, localRules.length - 1); addRule++; }
else {
// 已有:补缺失字段,不动本机已有的值(含密钥)
const lr = localRules[byId.get(id)];
lr.providerName = lr.providerName ?? sr.providerName;
lr.config = lr.config ?? {};
for (const [k, v] of Object.entries(sr.config ?? {})) {
if (k === "access") continue; // 密钥单独处理
if (lr.config[k] === undefined) lr.config[k] = JSON.parse(JSON.stringify(v));
}
lr.config.access = lr.config.access ?? {};
for (const [k, v] of Object.entries(sr.config?.access ?? {})) {
if (v === MODEL_SECRET_REF) continue;
if (lr.config.access[k] === undefined) lr.config.access[k] = v;
}
mergeRule++;
}
// 密钥回填:占位符/空/过短才填
const lr = localRules[byId.get(id)];
lr.config.access = lr.config.access ?? {};
for (const [kk, vv] of keyMapPc) {
const [pid, k] = kk.split("\n");
if (pid !== id) continue;
if (skipSet.has(`PROVIDER_CONFIG/${pid}/${k}`)) { skippedPcKey++; continue; }
const cur = lr.config.access[k];
if (cur === MODEL_SECRET_REF || cur === undefined || cur === "" || (typeof cur === "string" && cur.length < 8)) {
lr.config.access[k] = vv; filledPcKey++;
} else if (OVERWRITE_MODEL_SECRETS) {
lr.config.access[k] = vv; filledPcKey++;
} else {
skippedPcOverwrite++; // 本机已有非空密钥,默认护住
}
}
}
// 模型级配置:按 (providerId, modelId) 去重合并
const mKey = (r) => `${r?.providerId}\n${r?.modelId}`;
const haveModel = new Set(localModels.map(mKey));
for (const mr of snapModels) { if (!haveModel.has(mKey(mr))) { localModels.push(JSON.parse(JSON.stringify(mr))); haveModel.add(mKey(mr)); addModel++; } }
const haveManual = new Set(localManual.map(mKey));
for (const mr of snapManual) { if (!haveManual.has(mKey(mr))) { localManual.push(JSON.parse(JSON.stringify(mr))); haveManual.add(mKey(mr)); } }
// providerOrder:快照的分组顺序(合并本机多出来的,不让本机特有分组消失)
const snapOrder = snapCfg.providerOrder ?? [];
const merged = [...snapOrder];
for (const id of localCfg.providerOrder ?? []) if (!merged.includes(id)) merged.push(id);
localCfg.providerOrder = merged;
if (localPc.schemaVersion === undefined && snapPc.schemaVersion !== undefined) localPc.schemaVersion = snapPc.schemaVersion;
writeJson(V2_PROVIDER_CONFIG, localPc);
report.push(`- UI 分组:${merged.length} 个分组(新增规则 ${addRule},合并 ${mergeRule},密钥回填 ${filledPcKey}`
+ (skippedPcOverwrite ? `,护住本机已有密钥 ${skippedPcOverwrite} 个(需覆盖设 ZCODE_SYNC_OVERWRITE_MODEL_SECRETS=1)` : "")
+ (skippedPcKey ? `,跳过 ${skippedPcKey}(ZCODE_SYNC_SKIP_SECRETS)` : "") + `),模型级配置 +${addModel} 条`);
}
} else if (manifest.providerConfigRuleCount) {
report.push(`- UI 分组:快照仅记录摘要 ${manifest.providerConfigRuleCount} 条(未含配置本体)`);
}
// 5) 官方插件:只报告差异,不自动执行(去插件市场对照启用,重启生效,避免误装)
if (process.env.ZCODE_SYNC_APPLY_OFFICIAL !== "0") {
const { enabledPlugins } = await import("./sync-core.mjs");
const localEnabled = new Set(enabledPlugins());
const snapEnabled = manifest.enabled ?? [];
const needEnable = snapEnabled.filter((e) => !localEnabled.has(e));
const extra = [...localEnabled].filter((e) => !snapEnabled.includes(e));
report.push(`- 官方插件:快照启用 ${snapEnabled.length} 项,本机缺 ${needEnable.length} 项(${needEnable.join(", ") || "—"})`);
if (extra.length) report.push(` 本机多出 ${extra.length} 项(${extra.join(", ")}),保留不动`);
report.push(` 请在 ZCode 插件市场对照启用缺的项,官方版本见快照 manifest.official,重启生效`);
} else {
report.push(`- 官方插件:已跳过(ZCODE_SYNC_APPLY_OFFICIAL=0)`);
}
report.push(stamps.size
? `- 本机备份:${[...stamps].map((s) => path.join(BACKUP_ROOT, s)).join(", ")}`
: `- 本机备份:无覆盖,未产生备份`);
console.log(report.join("\n"));
} catch (e) { fail(e); }