212 lines
11 KiB
JavaScript
212 lines
11 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,
|
|
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,
|
|
} 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 = [];
|
|
let n = 0;
|
|
for (const f of bundle.files ?? []) {
|
|
if (isModelEntry(f.rel)) { modelKeys.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)`);
|
|
}
|
|
|
|
// 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); }
|