#!/usr/bin/env node /** * /sync-export:导出本机快照 → 推 Gitea 私仓。 * 用法: ZCODE_SYNC_PASSPHRASE='口令' node export.mjs */ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { HOME, USER, HOSTNAME, CLI_CONFIG, PLUGINS_HOME, REPO_DIR, V2_CONFIG, nowTag, readJson, writeJson, encodeJsonPaths, encodePaths, fwd, officialPlugins, enabledPlugins, enabledPluginsDetailed, customPlugins, copyDirFiltered, hashDir, secretTargets, secretsManifest, buildSecretsBundle, encryptSecrets, requirePassphrase, scanForSecrets, ensureRepo, gitPush, dirSize, humanSize, collectModelSecrets, sanitizeProviders, summarizeProviders, modelSelection, extractModelSecrets, modelKeyManifest, WITH_MODELS, APPLY_MODEL_SELECTION, } from "./sync-core.mjs"; function fail(e) { console.error(`导出失败:${e?.message ?? e}`); process.exitCode = 1; } try { const pass = requirePassphrase(); ensureRepo(); const official = officialPlugins(); const enabled = enabledPlugins(); const enabledDetailed = enabledPluginsDetailed(); const custom = customPlugins(); const cfg = readJson(CLI_CONFIG, {}); const mcpServers = cfg?.mcp?.servers ?? {}; const pluginsDirs = cfg?.plugins?.dirs ?? []; // 路径占位符化(跨机器)——按值处理,反斜杠路径才不会被漏掉 const mcpEncoded = encodeJsonPaths(mcpServers); const dirsEncoded = encodeJsonPaths(pluginsDirs); // 脱敏扫描:明文区不得出现疑似密钥值 const hits = scanForSecrets({ mcpServers: mcpEncoded }); if (hits.length) { throw new Error(`明文区疑似含密钥(${hits.slice(0, 8).join(",")}),已中断。请把该 server 的敏感值改走 *_FILE/密钥文件引用,或先处理再重导`); } // 占位符化完整性自检:编码后不应再残留本机主目录/用户名(否则跨机器还原会指到别人家) const txt = JSON.stringify(mcpEncoded) + JSON.stringify(dirsEncoded); const leaks = []; if (txt.includes(`Users/${USER}`) || txt.includes(`Users\\${USER}`)) leaks.push(`用户名 ${USER}`); const homeF = fwd(path.join(os.homedir())); if (homeF && txt.includes(homeF)) leaks.push(`主目录 ${homeF}`); if (leaks.length) { const msg = `占位符化不完整,明文区仍含 ${leaks.join("、")}(该快照还原到别的机器会指错路径)`; if (process.env.ZCODE_SYNC_ALLOW_PATH_LEAK === "1") console.warn(`⚠ ${msg}(ZCODE_SYNC_ALLOW_PATH_LEAK=1 已忽略)`); else throw new Error(`${msg}。确认这些字段是路径残留后,可设 ZCODE_SYNC_ALLOW_PATH_LEAK=1 强制导出`); } // 密钥束(文件类先装,模型密钥随后并入,最后统一加密) const secrets = secretTargets(); const bundle = buildSecretsBundle(secrets); /* * 模型 provider:v2/config.json 里全是活的 apiKey,默认绝不整体复制。 * 开启 ZCODE_SYNC_WITH_MODELS=1 时:脱敏结构写明文,真值以 MODELS// * 条目并入加密束;关闭时模型完全不进快照。导出后还有「密钥零残留」自检兜底。 */ const v2cfg = WITH_MODELS ? readJson(V2_CONFIG, null) : null; if (WITH_MODELS && !v2cfg) throw new Error(`ZCODE_SYNC_WITH_MODELS=1 但读不到 ${V2_CONFIG}`); const providerSummary = v2cfg ? summarizeProviders(v2cfg) : []; const providerSelection = v2cfg ? modelSelection() : null; const providerSecrets = v2cfg ? collectModelSecrets(v2cfg) : []; const providerSanitized = v2cfg ? sanitizeProviders(v2cfg) : { providers: {}, withSecrets: [] }; const modelKeys = v2cfg ? extractModelSecrets(v2cfg) : []; const modelKeysManifest = modelKeyManifest(modelKeys); let modelKeyCount = 0; if (v2cfg) { // 脱敏结果里不允许再有真密钥(helper 漏了就直接停,别把明文推上去) const left = scanForSecrets({ providers: providerSanitized.providers }); if (left.length) throw new Error(`provider 脱敏不彻底(${left.slice(0, 5).join(",")}),已中断`); for (const { provider, key, value } of modelKeys) { bundle.files.push({ rel: `MODELS/${provider}/${key}`, contentB64: Buffer.from(String(value), "utf8").toString("base64"), }); modelKeyCount++; } } const enc = encryptSecrets(bundle, pass); const tag = `${HOSTNAME}-${nowTag()}`; const snapDir = path.join(REPO_DIR, "snapshots", tag); fs.mkdirSync(snapDir, { recursive: true }); const manifest = { version: 1, snapshot: `snapshots/${tag}`, host: HOSTNAME, createdAt: new Date().toISOString(), // 占位符而不是本机绝对路径:manifest 是明文进仓,不该带出用户名 zcodeHomeTemplate: "${ZCODE_HOME}", zcodeHomeExportedFrom: encodePaths(fwd(path.join(os.homedir(), ".zcode"))), official, enabled, // inline 插件原本查不到版本,这里补全(市场插件查清单,inline 读本机插件目录) enabledDetailed, custom: custom.map((c) => ({ name: c.name, version: c.version, hash: hashDir(c.dir) })), mcpServerNames: Object.keys(mcpServers), secretFiles: secretsManifest(secrets).map(({ rel, sha256, bytes }) => ({ rel, sha256, bytes })), }; if (v2cfg) { Object.assign(manifest, { modelProviders: providerSummary, modelSelection: providerSelection, modelSelectionApplied: APPLY_MODEL_SELECTION, // 只记哪些 provider 带密钥+密钥哈希,不记值;密钥值在加密束 MODELS/ 条目里 modelProvidersWithSecrets: providerSanitized.withSecrets, modelKeys: modelKeysManifest, modelKeyCount, }); } // manifest 与 latest.json 都留到自检通过后再落盘:失败时不留半个快照 writeJson(path.join(snapDir, "mcp.servers.json"), mcpEncoded); writeJson(path.join(snapDir, "plugins-dirs.json"), dirsEncoded); if (v2cfg) writeJson(path.join(snapDir, "v2.providers.json"), encodeJsonPaths(providerSanitized.providers)); fs.writeFileSync(path.join(snapDir, "secrets.enc"), enc); writeJson(path.join(snapDir, "secrets.manifest.json"), manifest.secretFiles); const cpRoot = path.join(snapDir, "custom-plugins"); for (const c of custom) copyDirFiltered(c.dir, path.join(cpRoot, c.name)); /* ---- 写后自检:明文区不得出现任何真实密钥值或本机路径 ---- */ const walkFiles = (dir, acc = []) => { for (const e of fs.readdirSync(dir, { withFileTypes: true })) { const p = path.join(dir, e.name); if (e.isDirectory()) walkFiles(p, acc); else if (e.name !== "secrets.enc") acc.push(p); // 密文不必扫 } return acc; }; const snapFiles = walkFiles(snapDir); const readAll = (files) => files.map((f) => { try { return fs.readFileSync(f, "utf8"); } catch { return ""; } }).join("\n"); // manifest 还没落盘,单独把它的内容并入扫描范围(它同样是明文进仓) const pendingManifestText = JSON.stringify(manifest); // 密钥:全覆盖,命中即硬失败 const plainText = readAll(snapFiles) + "\n" + pendingManifestText; const leakedSecrets = [...providerSecrets, ...secrets.map((s) => { try { return fs.readFileSync(s.abs, "utf8").trim(); } catch { return ""; } })].filter((v) => v && v.length >= 8 && plainText.includes(v)); if (leakedSecrets.length) { throw new Error(`明文区检测到 ${leakedSecrets.length} 个真实密钥值(已中断,快照未推送)。` + `密钥只能出现在 secrets.enc 里,请检查脱敏逻辑`); } // 口令本体:上面那轮只按「密钥文件的内容」比对,而口令来自环境变量、不在密钥清单里, // 于是源码里写死的口令字面量会整个漏网 —— 钥匙跟着保险箱一起进仓,加密就白做了。 // 这条不设放行开关:命中说明该口令已等于公开值,正确处置是换口令,不是忽略。 if (plainText.includes(pass)) { throw new Error(`明文区检测到同步口令本体(已中断,快照未推送)。` + `口令是 secrets.enc 的钥匙,随快照进仓等于加密失效,且 git 历史删不干净。` + `请把写死该口令的文件改成从 ZCODE_SYNC_PASSPHRASE 读取,并更换团队口令后重导`); } // 路径:配置区(本应占位符化)残留即硬失败;插件源码是字节级归档,只警告 // 不限于本机用户名:任何未占位符化的 `Users/<某用户>` 到了别的机器都是错路径。 // 注意按「解析后的值」判断:JSON 里反斜杠是转义态(`\\Users\\`),对文本做正则会漏。 const USER_PATH_RE = /[\\/]Users[\\/][^\\/"\s]+/; const HOME_ABS = fwd(path.join(os.homedir(), ".zcode")); const leakyStrings = (node, out = []) => { if (typeof node === "string") { out.push(node); return out; } if (Array.isArray(node)) return node.forEach((v) => leakyStrings(v, out)), out; if (node && typeof node === "object") { for (const [k, v] of Object.entries(node)) { leakyStrings(k, out); leakyStrings(v, out); } } return out; }; /** 一段文本里是否还有未占位符化的绝对路径 */ const textLeaks = (body, isJson) => { let strs; if (isJson) { try { strs = leakyStrings(JSON.parse(body)); } catch { strs = [body]; } // 解析不了就退回文本判断 } else { strs = [body]; } return strs.some((s) => USER_PATH_RE.test(s) || fwd(s).includes(HOME_ABS)); }; /** 文件里是否还有未占位符化的绝对路径 */ const fileLeaks = (f) => { let body; try { body = fs.readFileSync(f, "utf8"); } catch { return false; } return textLeaks(body, f.endsWith(".json")); }; const cfgFiles = snapFiles.filter((f) => !f.includes(`${path.sep}custom-plugins${path.sep}`)); const cfgHits = cfgFiles.filter(fileLeaks).concat( textLeaks(pendingManifestText, true) ? ["manifest.json"] : []); if (cfgHits.length && process.env.ZCODE_SYNC_ALLOW_PATH_LEAK !== "1") { throw new Error(`配置区残留未占位符化的绝对路径(已中断,快照未推送):` + `${cfgHits.map((f) => (f.startsWith("manifest") ? f : path.relative(snapDir, f))).slice(0, 5).join(", ")}。` + `这些路径还原到别的机器会指错位置,确认无害可设 ZCODE_SYNC_ALLOW_PATH_LEAK=1`); } const srcFiles = snapFiles.filter((f) => f.includes(`${path.sep}custom-plugins${path.sep}`)); const pathWarnings = srcFiles.filter(fileLeaks).map((f) => path.relative(snapDir, f)); /* ---- 自检通过,现在才落 manifest + latest.json ---- */ // 两遍算体积:第二遍把 manifest 自身算进去,prune 才能拿到准数 manifest.bytes = dirSize(snapDir); writeJson(path.join(snapDir, "manifest.json"), manifest); manifest.bytes = dirSize(snapDir); writeJson(path.join(snapDir, "manifest.json"), manifest); const ex = path.join(REPO_DIR, "local-overrides.example.json"); if (!fs.existsSync(ex)) { writeJson(ex, { _comment: "本机覆盖(不进仓):拷到 ~/.zcode-env-sync/overrides.json,导入时合并", mcp: { servers: { "": { "command": "本机 node 路径", "args": ["..."] } } }, }); } writeJson(path.join(REPO_DIR, "latest.json"), { snapshot: `snapshots/${tag}`, host: HOSTNAME, createdAt: manifest.createdAt, bytes: manifest.bytes, enabledCount: enabled.length, customCount: custom.length, }); // 只提交本次快照 + latest.json:不做 add -A,免得把别的会话未推送的快照连带推上去 const pushMsg = gitPush( `sync-export ${tag} (official:${official.length} custom:${custom.length} mcp:${Object.keys(mcpServers).length} secrets:${secrets.length}${v2cfg ? ` providers:${providerSummary.length}` : ""})`, { paths: [path.posix.join("snapshots", tag), "latest.json", ".gitattributes", ".gitignore", "local-overrides.example.json"] }); console.log(`# 导出成功`); console.log(`- 快照:${tag}`); console.log(`- 官方插件:${official.length} 项(清单),已启用:${enabled.length} 项(${enabled.join(", ") || "—"})`); console.log(`- 自研插件:${custom.length} 项(${custom.map((c) => `${c.name}@${c.version}`).join(", ") || "—"})`); console.log(`- MCP:${Object.keys(mcpServers).join(", ") || "—"}`); console.log(`- 密钥束:${secrets.length} 个文件(已加密)`); if (v2cfg) { console.log(`- 模型 provider:${providerSummary.length} 个(脱敏结构明文),密钥 ${modelKeyCount} 个已入加密束`); console.log(`- 模型选中态:${APPLY_MODEL_SELECTION ? "已随快照应用" : "仅记录,导入时不覆盖本机"}`); } console.log(`- 明文区自检:无密钥泄漏、配置区无本机路径残留`); console.log(`- 快照体积:${humanSize(manifest.bytes)}`); if (pathWarnings.length) { console.log(`- ⚠ 自研插件源码里有 ${pathWarnings.length} 个文件含本机绝对路径(源码按字节归档不改动,` + `但这些路径到了别的机器会失效,建议改成动态定位):`); for (const w of pathWarnings.slice(0, 6)) console.log(` ${w}`); if (pathWarnings.length > 6) console.log(` …另 ${pathWarnings.length - 6} 个`); } console.log(`- 推送:${pushMsg}`); } catch (e) { fail(e); }