/** * zcode-env-sync 共享库:路径占位符、快照读写、密钥束加解密、git 同步。 * 纯 Node 内置模块,无第三方依赖。 */ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import crypto from "node:crypto"; import { execFileSync } from "node:child_process"; export const HOME = os.homedir(); export const USER = path.basename(HOME); export const HOSTNAME = os.hostname(); export const ZCODE_HOME = path.join(HOME, ".zcode"); export const DSH_HOME = path.join(HOME, ".dsh"); export const CLI_CONFIG = path.join(ZCODE_HOME, "cli", "config.json"); export const PLUGINS_HOME = path.join(ZCODE_HOME, "plugins"); export const PLUGIN_DATA = path.join(ZCODE_HOME, "cli", "plugins", "data"); export const MARKETPLACES = path.join(ZCODE_HOME, "cli", "plugins", "marketplaces"); export const REPO = process.env.ZCODE_SYNC_REPO || "root/zcode-env-sync"; export const GITEA_HOST = (process.env.ZCODE_SYNC_HOST || "https://gitea.mymoyu.top").replace(/\/+$/, ""); export const WORK = expandPath(process.env.ZCODE_SYNC_WORK || "~/.zcode-env-sync"); export const REPO_DIR = path.join(WORK, "repo"); export const BACKUP_ROOT = path.join(WORK, "backup"); export const V2_CONFIG = path.join(ZCODE_HOME, "v2", "config.json"); export const V2_SETTING = path.join(ZCODE_HOME, "v2", "setting.json"); /** UI 的分组/排序/模型级配置(providerOrder、group、personalModelIds、contextWindow 等) */ export const V2_PROVIDER_CONFIG = path.join(ZCODE_HOME, "v2", "provider_config.json"); /** 模型同步默认关闭,显式开启才同步(ZCODE_SYNC_WITH_MODELS=1) */ export const WITH_MODELS = process.env.ZCODE_SYNC_WITH_MODELS === "1"; /** 模型选中态(当前用哪个provider/model)默认不覆盖本机,显式开启才应用 */ export const APPLY_MODEL_SELECTION = process.env.ZCODE_SYNC_APPLY_MODEL_SELECTION === "1"; export function expandPath(p) { if (p.startsWith("~/")) return path.join(HOME, p.slice(2)); return p; } export function fwd(p) { return String(p).replace(/\\/g, "/"); } export function nowTag() { const d = new Date(), p = (x) => String(x).padStart(2, "0"); return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`; } export function readJson(f, fallback) { try { return JSON.parse(fs.readFileSync(f, "utf8")); } catch { return fallback; } } export function writeJson(f, obj) { fs.mkdirSync(path.dirname(f), { recursive: true }); fs.writeFileSync(f, JSON.stringify(obj, null, 2) + "\n"); } export function sha256File(f) { return crypto.createHash("sha256").update(fs.readFileSync(f)).digest("hex"); } /* ---------- 路径占位符(跨机器还原) ---------- */ function placeholderRules() { const zF = fwd(ZCODE_HOME), zW = zF.replace(/\//g, "\\"); const dF = fwd(DSH_HOME), dW = dF.replace(/\//g, "\\"); const hF = fwd(HOME), hW = hF.replace(/\//g, "\\"); return [ [zW, "${ZCODE_HOME_WIN}"], [zF, "${ZCODE_HOME}"], [dW, "${DSH_HOME_WIN}"], [dF, "${DSH_HOME}"], [`Users\\${USER}`, "${USERS_WIN}"], [`Users/${USER}`, "${USERS_POSIX}"], [hW, "${USER_HOME_WIN}"], [hF, "${USER_HOME}"], ]; } export function encodePaths(s) { let out = String(s); for (const [from, to] of placeholderRules()) out = out.split(from).join(to); return out; } /** * 对 JSON 结构逐值(含键)做占位符化。 * 必须按值处理,不能对 JSON 文本做字符串替换:文本里反斜杠是转义态(`\\`), * 含 `\` 的路径永远匹配不上,且还原时会拼出非法转义导致 JSON.parse 崩。 */ export function encodeJsonPaths(node) { return mapStrings(node, encodePaths); } export function decodeJsonPaths(node) { return mapStrings(node, decodePaths); } function mapStrings(node, fn) { if (typeof node === "string") return fn(node); if (Array.isArray(node)) return node.map((v) => mapStrings(v, fn)); if (node && typeof node === "object") { const out = {}; for (const [k, v] of Object.entries(node)) out[fn(k)] = mapStrings(v, fn); return out; } return node; } export function decodePaths(s) { const zF = fwd(ZCODE_HOME), zW = zF.replace(/\//g, "\\"); const dF = fwd(DSH_HOME), dW = dF.replace(/\//g, "\\"); const hF = fwd(HOME), hW = hF.replace(/\//g, "\\"); const map = { "${ZCODE_HOME_WIN}": zW, "${ZCODE_HOME}": zF, "${DSH_HOME_WIN}": dW, "${DSH_HOME}": dF, "${USERS_WIN}": `Users\\${USER}`, "${USERS_POSIX}": `Users/${USER}`, "${USER_HOME_WIN}": hW, "${USER_HOME}": hF, "${USER_NAME}": USER, }; let out = String(s); for (const [k, v] of Object.entries(map)) out = out.split(k).join(v); return out; } /* ---------- 官方插件清单 ---------- */ export function officialPlugins() { const list = []; if (!fs.existsSync(MARKETPLACES)) return list; for (const mp of fs.readdirSync(MARKETPLACES)) { for (const mf of ["marketplace.json", "bundled-marketplace.json"]) { const f = path.join(MARKETPLACES, mp, mf); const j = readJson(f, null); const arr = j?.plugins ?? j?.manifest?.plugins ?? null; if (!Array.isArray(arr)) continue; for (const p of arr) { if (p?.name) list.push({ marketplace: mp, name: p.name, version: p.version ?? "?" }); } } } const seen = new Set(), uniq = []; for (const p of list) { const k = `${p.marketplace}/${p.name}@${p.version}`; if (!seen.has(k)) { seen.add(k); uniq.push(p); } } return uniq; } /** data/ 目录即启用态: @ */ export function enabledPlugins() { if (!fs.existsSync(PLUGIN_DATA)) return []; return fs.readdirSync(PLUGIN_DATA).filter((d) => { try { return fs.statSync(path.join(PLUGIN_DATA, d)).isDirectory(); } catch { return false; } }); } /** 从插件目录读版本(自研/inline 插件的 plugin.json) */ export function pluginVersionFromDir(dir) { const m = readJson(path.join(dir, ".zcode-plugin", "plugin.json"), null); return m?.version ?? null; } /** * 启用态 + 版本。 * `enabled` 只是 `@<来源>` 字符串,`inline` 那批原本既无版本也无处可查, * 还原时根本不知道当时装的是哪版;这里把版本补齐(inline 读本机插件目录,市场查清单)。 */ export function enabledPluginsDetailed() { const market = new Map(); for (const p of officialPlugins()) market.set(`${p.name}@${p.marketplace}`, p.version); return enabledPlugins().map((entry) => { const at = entry.lastIndexOf("@"); const name = at > 0 ? entry.slice(0, at) : entry; const source = at > 0 ? entry.slice(at + 1) : "unknown"; let version = market.get(entry) ?? null; if (version === null && source === "inline") version = pluginVersionFromDir(path.join(PLUGINS_HOME, name)); return { entry, name, source, version: version ?? "?" }; }); } /* ---------- 自研插件 ---------- */ export const CUSTOM_EXCLUDES = new Set(["node_modules", ".git", ".hg", ".svn"]); /** 以 . 开头的目录不当插件:备份(.backup-*)、IDE/临时目录都在这类里 */ export function isHiddenEntry(name) { return name.startsWith("."); } export function customPlugins() { const out = []; if (!fs.existsSync(PLUGINS_HOME)) return out; for (const d of fs.readdirSync(PLUGINS_HOME)) { if (isHiddenEntry(d)) continue; const dir = path.join(PLUGINS_HOME, d); let st; try { st = fs.statSync(dir); } catch { continue; } if (!st.isDirectory()) continue; const meta = readJson(path.join(dir, ".zcode-plugin", "plugin.json"), {}); out.push({ name: meta.name || d, dir, version: meta.version || "?" }); } // 同名(manifest.name 撞车)会让快照里互相覆盖,直接拒绝而不是静默丢一个 const byName = new Map(); for (const c of out) { if (byName.has(c.name)) { throw new Error(`自研插件名冲突:${c.name} 同时来自 ${byName.get(c.name).dir} 与 ${c.dir},` + `快照里会互相覆盖。请改名或移走其中一个`); } byName.set(c.name, c); } return out; } export function copyDirFiltered(src, dst) { const s = path.resolve(src), d = path.resolve(dst); if (d === s || d.startsWith(s + path.sep)) { throw new Error(`拒绝复制:目标目录位于源目录内部(${d} 在 ${s} 里),会无限递归`); } fs.mkdirSync(dst, { recursive: true }); for (const e of fs.readdirSync(src, { withFileTypes: true })) { if (CUSTOM_EXCLUDES.has(e.name)) continue; const a = path.join(src, e.name), b = path.join(dst, e.name); if (e.isDirectory()) copyDirFiltered(a, b); else if (e.isFile()) fs.copyFileSync(a, b); } } export function hashDir(dir) { const h = crypto.createHash("sha256"); const walk = (d) => { let ents = []; try { ents = fs.readdirSync(d, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)); } catch { return; } for (const e of ents) { if (CUSTOM_EXCLUDES.has(e.name)) continue; const p = path.join(d, e.name); if (e.isDirectory()) walk(p); else if (e.isFile()) { h.update(path.relative(dir, p)); h.update(fs.readFileSync(p)); } } }; walk(dir); return h.digest("hex").slice(0, 16); } /* ---------- 密钥束 ---------- */ export function secretTargets() { const files = []; const sdir = path.join(DSH_HOME, "secrets"); if (fs.existsSync(sdir)) { for (const f of fs.readdirSync(sdir)) { const p = path.join(sdir, f); try { if (fs.statSync(p).isFile()) files.push({ rel: `DSH_SECRETS/${f}`, abs: p }); } catch { /* skip */ } } } const ssh = path.join(HOME, ".ssh", "ssh-profiles.json"); if (fs.existsSync(ssh)) files.push({ rel: "SSH/ssh-profiles.json", abs: ssh }); return files; } export function secretsManifest(files) { return files.map(({ rel, abs }) => ({ rel, sha256: sha256File(abs), bytes: fs.statSync(abs).size })); } export function buildSecretsBundle(files) { const bundle = { version: 1, createdAt: new Date().toISOString(), host: HOSTNAME, files: [] }; let total = 0; for (const { rel, abs } of files) { const buf = fs.readFileSync(abs); total += buf.length; bundle.files.push({ rel, contentB64: buf.toString("base64") }); } if (total > 10 * 1024 * 1024) throw new Error(`密钥束过大(${(total / 1048576).toFixed(1)}MB>10MB),请检查 secrets 目录`); return bundle; } export function encryptSecrets(bundle, passphrase) { const salt = crypto.randomBytes(16), key = crypto.scryptSync(passphrase, salt, 32); const iv = crypto.randomBytes(12); const c = crypto.createCipheriv("aes-256-gcm", key, iv); const data = Buffer.concat([c.update(JSON.stringify(bundle), "utf8"), c.final()]); return JSON.stringify({ alg: "aes-256-gcm", kdf: "scrypt", salt: salt.toString("base64"), iv: iv.toString("base64"), tag: c.getAuthTag().toString("base64"), data: data.toString("base64"), }); } export function decryptSecrets(encJson, passphrase) { const e = JSON.parse(encJson); if (e.alg !== "aes-256-gcm") throw new Error(`未知加密算法:${e.alg}`); const key = crypto.scryptSync(passphrase, Buffer.from(e.salt, "base64"), 32); const d = crypto.createDecipheriv("aes-256-gcm", key, Buffer.from(e.iv, "base64")); d.setAuthTag(Buffer.from(e.tag, "base64")); const raw = Buffer.concat([d.update(Buffer.from(e.data, "base64")), d.final()]).toString("utf8"); return JSON.parse(raw); } const SECRET_RESTORE = { "DSH_SECRETS/": path.join(DSH_HOME, "secrets") + path.sep, "SSH/": path.join(HOME, ".ssh") + path.sep }; export function restoreRelPath(rel) { for (const [prefix, base] of Object.entries(SECRET_RESTORE)) { if (rel.startsWith(prefix)) { const rest = rel.slice(prefix.length).replace(/\//g, path.sep); if (rest.split(path.sep).includes("..") || path.isAbsolute(rest) || /^[a-zA-Z]:/.test(rest)) { throw new Error(`非法密钥路径(越出目标目录):${rel}`); } return path.join(base, rest); } } throw new Error(`未知密钥前缀:${rel}`); } export function requirePassphrase() { const p = process.env.ZCODE_SYNC_PASSPHRASE; if (!p) throw new Error("未设置 ZCODE_SYNC_PASSPHRASE(团队口令,线下约定,不进仓),拒绝继续"); if (p.length < 8) throw new Error("ZCODE_SYNC_PASSPHRASE 太短(至少8位)"); return p; } /** 模型密钥清单(只记 provider/key/sha,不记值),供 manifest 与 status 对比 */ export function modelKeyManifest(modelKeys) { return (modelKeys ?? []).map(({ provider, key, value }) => ({ rel: `MODELS/${provider}/${key}`, sha256: crypto.createHash("sha256").update(String(value), "utf8").digest("hex"), })); } /** * 密钥跳过表:逗号分隔的 rel 精确匹配,导入时这些条目不落盘(只报告)。 * 例:ZCODE_SYNC_SKIP_SECRETS='DSH_SECRETS/gitea-token.txt,MODELS/zz/apiKey' * 团队场景:连接方式统一、身份各用各的(每人自己的 token 不被快照盖掉)。 */ export function skipSecretsSet() { return new Set(String(process.env.ZCODE_SYNC_SKIP_SECRETS || "") .split(",").map((s) => s.trim()).filter(Boolean)); } /* ---------- 模型同步(可选,默认关闭) ---------- */ const MODEL_KEY_RE = /(api[_-]?key|access[_-]?token|secret)$/i; export const MODEL_SECRET_REF = "${MODEL_SECRET_REF}"; /** 覆盖本机已有模型密钥前要求显式确认(ZCODE_SYNC_OVERWRITE_MODEL_SECRETS=1) */ export const OVERWRITE_MODEL_SECRETS = process.env.ZCODE_SYNC_OVERWRITE_MODEL_SECRETS === "1"; /** 抽取 provider options 里的真密钥(内存中处理,调用方不得打印) */ export function extractModelSecrets(v2cfg) { const out = []; for (const [id, pv] of Object.entries(v2cfg?.provider ?? {})) { const opts = pv?.options ?? {}; for (const [k, v] of Object.entries(opts)) { if (typeof v === "string" && MODEL_KEY_RE.test(k) && v.length >= 8) out.push({ provider: id, key: k, value: v }); } } return out; } export function isModelEntry(rel) { return String(rel).startsWith("MODELS/"); } /** MODELS// → { provider, key }(provider id 不含 /) */ export function parseModelRel(rel) { const rest = String(rel).slice("MODELS/".length); const i = rest.indexOf("/"); if (i <= 0 || i === rest.length - 1) throw new Error(`非法模型密钥路径:${rel}`); return { provider: rest.slice(0, i), key: rest.slice(i + 1) }; } /* ---------- provider_config.json:UI 分组/顺序/模型级配置 ---------- * config.json 只管「provider 能不能调」;UI 上看到的 9 个分组、拖拽顺序、 * 每条 provider 属于哪个 group、模型级参数(contextWindow 等)全在 provider_config.json。 * 早期版本只同步 config.json,于是只在 provider_config 里存在的分组整个丢失。 */ export const PROVIDER_CONFIG_PREFIX = "PROVIDER_CONFIG/"; export function isProviderConfigEntry(rel) { return String(rel).startsWith(PROVIDER_CONFIG_PREFIX); } /** PROVIDER_CONFIG// → { provider, key } */ export function parseProviderConfigRel(rel) { const rest = String(rel).slice(PROVIDER_CONFIG_PREFIX.length); const i = rest.indexOf("/"); if (i <= 0 || i === rest.length - 1) throw new Error(`非法 provider_config 密钥路径:${rel}`); return { provider: rest.slice(0, i), key: rest.slice(i + 1) }; } /** providerRules 里的真密钥(内存中处理,调用方不得打印) */ export function extractProviderConfigSecrets(pcfg) { const out = []; for (const r of pcfg?.config?.providerConfigRules?.providerRules ?? []) { const id = r?.providerId; if (!id) continue; for (const [k, v] of Object.entries(r?.config?.access ?? {})) { if (typeof v === "string" && MODEL_KEY_RE.test(k) && v.length >= 8) out.push({ provider: id, key: k, value: v }); } } return out; } /** 全量收集,供泄漏扫描比对 */ export function collectProviderConfigSecrets(pcfg) { const vals = new Set(); const walk = (node) => { if (Array.isArray(node)) return node.forEach(walk); if (node && typeof node === "object") { for (const [k, v] of Object.entries(node)) { if (typeof v === "string" && MODEL_KEY_RE.test(k) && v.length >= 8) vals.add(v); else walk(v); } } }; walk(pcfg?.config?.providerConfigRules?.providerRules ?? []); return [...vals]; } /** 脱敏:providerRules 里的密钥换成占位符;返回哪些 provider 带密钥(只记有无) */ export function sanitizeProviderConfig(pcfg) { const clean = JSON.parse(JSON.stringify(pcfg ?? {})); const withSecrets = []; for (const r of clean?.config?.providerConfigRules?.providerRules ?? []) { const id = r?.providerId; let hit = false; for (const [k, v] of Object.entries(r?.config?.access ?? {})) { if (typeof v === "string" && MODEL_KEY_RE.test(k) && v.length >= 8) { r.config.access[k] = MODEL_SECRET_REF; hit = true; } } if (hit && id) withSecrets.push(id); } return { config: clean, withSecrets }; } /** provider_config 密钥清单(只记 provider/key/sha) */ export function providerConfigKeyManifest(keys) { return (keys ?? []).map(({ provider, key, value }) => ({ rel: `${PROVIDER_CONFIG_PREFIX}${provider}/${key}`, sha256: crypto.createHash("sha256").update(String(value), "utf8").digest("hex"), })); } /* ---------- 脱敏扫描:明文区不得出现密钥值 ---------- */ /** 递归收集 v2 provider 里的敏感值(内存中比对,绝不打印) */ export function collectModelSecrets(v2cfg) { const vals = new Set(); const walk = (node) => { if (Array.isArray(node)) return node.forEach(walk); if (node && typeof node === "object") { for (const [k, v] of Object.entries(node)) { if (typeof v === "string" && /(api[_-]?key|access[_-]?token|secret)$/i.test(k) && v.length >= 8) vals.add(v); else walk(v); } } }; walk(v2cfg?.provider ?? {}); return [...vals]; } /** provider 脱敏:敏感值替换为占位符,记录哪些 provider 有密钥(只记有无,不记值) */ export function sanitizeProviders(v2cfg) { const clean = JSON.parse(JSON.stringify(v2cfg?.provider ?? {})); const withSecrets = []; const walk = (node) => { if (Array.isArray(node)) return node.forEach(walk); if (node && typeof node === "object") { for (const [k, v] of Object.entries(node)) { if (typeof v === "string" && /(api[_-]?key|access[_-]?token|secret)$/i.test(k) && v.length >= 8) { node[k] = "${MODEL_SECRET_REF}"; } else walk(v); } } }; for (const [id, pv] of Object.entries(clean)) { const before = JSON.stringify(pv); walk(pv); if (JSON.stringify(pv) !== before) withSecrets.push(id); } return { providers: clean, withSecrets }; } /** provider 摘要(进 manifest):id/name/kind/模型数/是否有密钥(只记有无) */ export function summarizeProviders(v2cfg) { return Object.entries(v2cfg?.provider ?? {}).map(([id, pv]) => ({ id, name: pv?.name ?? "?", kind: pv?.kind ?? "?", models: Object.keys(pv?.models ?? {}), hasSecret: JSON.stringify(pv?.options ?? {}).length > 0 && /(api[_-]?key|access[_-]?token|secret)/i.test(JSON.stringify(Object.keys(pv?.options ?? {}))), })); } /** 当前选中态(只读 setting.json 的选择字段,不含历史/窗口状态) */ export function modelSelection() { const st = readJson(V2_SETTING, {}); return { modelProviderFamilyModes: st.modelProviderFamilyModes ?? {}, modelProviderFamilySelectedKeys: st.modelProviderFamilySelectedKeys ?? {}, providerFamilyDomain: st.providerFamilyDomain ?? null, }; } /** * 命中判定:键名像密钥 + 值不是占位符引用。 * 不按“值里有没有 / \ $”来跳过——那等于放行 jwt、sk-xxx/yyy 这类真实密钥。 * 已知误报用 ZCODE_SYNC_ALLOW_SECRETS='$.mcp.servers.x.env.apiKey,...' 白名单。 */ const SECRET_KEY_RE = /(password|passwd|pwd|token|secret|api[_-]?key|access[_-]?key|private[_-]?key|credential|passphrase)$/i; const PLACEHOLDER_ONLY_RE = /^\$\{[A-Z_]+\}$/; export function scanForSecrets(obj) { const allow = new Set(String(process.env.ZCODE_SYNC_ALLOW_SECRETS || "").split(",").map((s) => s.trim()).filter(Boolean)); const hits = []; const walk = (node, trail) => { if (Array.isArray(node)) return node.forEach((v, i) => walk(v, `${trail}[${i}]`)); if (!node || typeof node !== "object") return; for (const [k, v] of Object.entries(node)) { const p = trail ? `${trail}.${k}` : k; if (typeof v === "string") { if (SECRET_KEY_RE.test(k) && !/_FILE$/i.test(k) && v.length >= 8 && !PLACEHOLDER_ONLY_RE.test(v) && !allow.has(p) && !allow.has(k)) { hits.push(p); } } else walk(v, p); } }; walk(obj, "$"); return hits; } /* ---------- git ---------- */ export function readToken() { try { return fs.readFileSync(path.join(DSH_HOME, "secrets", "gitea-token.txt"), "utf8").trim(); } catch { return ""; } } /** 干净 URL(不带凭据),写进 origin 给人看;网络操作另用带 token 的 URL,避免 token 落进 .git/config */ export function gitRemotePublic() { return `${GITEA_HOST}/${REPO}.git`; } export function gitRemoteAuth() { if (process.env.ZCODE_SYNC_GIT_REMOTE) return process.env.ZCODE_SYNC_GIT_REMOTE; const token = readToken(); const u = new URL(gitRemotePublic()); if (token) u.username = token; return u.toString(); } // stdio 全 pipe:探测型 git 调用失败时不往父进程 stderr 喷噪音(错误仍从 error.stderr 取) function run(args, opts) { return execFileSync("git", args, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], ...opts }); } function ensureOrigin() { let cur = ""; try { cur = run(["-C", REPO_DIR, "remote", "get-url", "origin"]).trim(); } catch { /* 无 origin */ } const want = gitRemotePublic(); if (!cur) { run(["-C", REPO_DIR, "remote", "add", "origin", want], { stdio: "pipe" }); return "已补 origin"; } if (cur !== want) { run(["-C", REPO_DIR, "remote", "set-url", "origin", want], { stdio: "pipe" }); return "已更新 origin"; } return ""; } /** * push/fetch 走显式 URL(避免 token 落进 .git/config),代价是 git 不会自动维护 * refs/remotes/origin/main。这里手动补上,否则 git status/log origin/main 全是残废状态。 */ function syncTrackingRef(sha) { try { run(["-C", REPO_DIR, "update-ref", "refs/remotes/origin/main", sha]); } catch { /* 非致命 */ } } /** * 快照是字节级归档,内容必须原样穿过 git。 * 不锁这个,Windows 上常见的 core.autocrlf=true 会在 add/checkout 时改写换行符, * 于是 manifest 里的 hash 与克隆下来的内容对不上,status 永远报"版本不同/漂移"。 */ export const GIT_ATTRIBUTES = [ "# 快照归档:禁止任何换行符转换,保证 manifest 里的 hash 与落地内容一致", "* -text", "", ].join("\n"); function ensureByteExactGit() { const f = path.join(REPO_DIR, ".gitattributes"); let cur = null; try { cur = fs.readFileSync(f, "utf8"); } catch { /* 首次写入 */ } if (cur !== GIT_ATTRIBUTES) fs.writeFileSync(f, GIT_ATTRIBUTES); // .gitattributes 已能兜底,这里再把本仓配置锁死一道(用户全局配置可能反过来) for (const [k, v] of [["core.autocrlf", "false"], ["core.safecrlf", "false"], ["core.eol", "lf"]]) { try { run(["-C", REPO_DIR, "config", "--local", k, v], { stdio: "pipe" }); } catch { /* 非致命 */ } } } export function ensureRepo() { fs.mkdirSync(WORK, { recursive: true }); let fresh = false; if (!fs.existsSync(path.join(REPO_DIR, ".git"))) { try { run(["clone", gitRemoteAuth(), REPO_DIR], { stdio: "pipe" }); } catch { run(["init", REPO_DIR], { stdio: "pipe" }); run(["-C", REPO_DIR, "checkout", "-B", "main"], { stdio: "pipe" }); fresh = true; } } ensureOrigin(); ensureByteExactGit(); if (!fresh) { try { run(["-C", REPO_DIR, "pull", "--ff-only", gitRemoteAuth(), "main"], { stdio: "pipe" }); syncTrackingRef(run(["-C", REPO_DIR, "rev-parse", "FETCH_HEAD"]).trim()); } catch { /* 远端尚无内容或离线,继续用本地 */ } } return { fresh }; } /** * 推送。 * `paths` 给定时只提交这些路径 —— 不做 `git add -A`。 * 原因:仓库里可能躺着别的会话/别的口令导出的未推送快照, * 一旦被 -A 扫进来就会连 latest.json 一起推上去,而那版 latest * 别人用团队口令根本解不开(团队仓里真实踩过的坑)。 */ export function gitPush(message, opts = {}) { if (process.env.ZCODE_SYNC_NO_PUSH === "1") return "跳过推送(ZCODE_SYNC_NO_PUSH=1)"; const paths = Array.isArray(opts.paths) ? opts.paths.filter(Boolean) : null; const strays = []; if (paths) { // git add 遇到不存在的 pathspec 会整体报错退出,先滤掉仓库里没有的 const real = [...new Set(paths.flatMap((p) => { if (fs.existsSync(path.join(REPO_DIR, p))) return [p]; // 删除场景:路径已不存在,但只要在 index 里有就得能提交删除 const tracked = run(["-C", REPO_DIR, "ls-files", "--", `${p}/`, p]).trim(); return tracked ? [p] : []; }))]; if (real.length) run(["-C", REPO_DIR, "add", "-A", "--", ...real], { stdio: "pipe" }); // 报告被刻意落下的东西,免得用户以为"怎么没同步过去" const stAll = run(["-C", REPO_DIR, "status", "--porcelain"]); for (const line of stAll.split("\n")) { const t = line.trim(); if (!t) continue; const p = t.slice(3).replace(/^"|"$/g, ""); if (!paths.includes(p) && !paths.some((x) => p.startsWith(`${x}/`))) strays.push(p); } } else { run(["-C", REPO_DIR, "add", "-A"], { stdio: "pipe" }); } // 判断暂存区是否有东西(没有 --cached 这种组合,得用 diff --cached) const staged = run(["-C", REPO_DIR, "diff", "--cached", "--name-only"]).trim(); if (!staged) return strays.length ? `无变更,无需推送(另有 ${strays.length} 项非本次内容未提交:${strays.slice(0, 3).join(", ")}${strays.length > 3 ? " …" : ""})` : "无变更,无需推送"; run(["-C", REPO_DIR, "-c", "user.name=zcode-env-sync", "-c", "user.email=zcode-env-sync@local", "commit", "-m", message], { stdio: "pipe" }); const head = run(["-C", REPO_DIR, "rev-parse", "HEAD"]).trim(); try { run(["-C", REPO_DIR, "push", gitRemoteAuth(), "HEAD:main"], { stdio: "pipe" }); } catch (e) { const hint = readToken() ? "" : "(未读到 ~/.dsh/secrets/gitea-token.txt,以匿名身份推送)"; throw new Error(`推送失败${hint}:确认私仓 ${gitRemotePublic()} 已建好且有写权限(或设 ZCODE_SYNC_GIT_REMOTE)。${String(e.stderr || e.message || e).split("\n").slice(-3).join(" ").trim()}`); } // 推送成功即远端 HEAD == 本地 HEAD,此时补追踪引用是准确的 syncTrackingRef(head); try { run(["-C", REPO_DIR, "branch", "--set-upstream-to=origin/main", "main"], { stdio: "pipe" }); } catch { /* 分支名不符时忽略 */ } const strayNote = strays.length ? `(另落下 ${strays.length} 项非本次内容未提交:${strays.slice(0, 3).join(", ")}${strays.length > 3 ? " …" : ""})` : ""; return `已推送:${message}${strayNote}`; } export function readSnapshotManifest(snapDir) { return readJson(path.join(snapDir, "manifest.json"), null); } export function latestSnapshotDir() { const latest = readJson(path.join(REPO_DIR, "latest.json"), null); if (latest?.snapshot) { const d = path.join(REPO_DIR, latest.snapshot); if (fs.existsSync(path.join(d, "manifest.json"))) return d; } return null; } /** latest.json 指向的快照名(用于 prune/import 定位"当前"快照) */ export function latestSnapshotName() { const latest = readJson(path.join(REPO_DIR, "latest.json"), null); return latest?.snapshot ? path.basename(latest.snapshot) : null; } /** 目录总字节数(用于快照体积统计与 prune 报告) */ export function dirSize(dir) { let total = 0; const walk = (d) => { let ents = []; try { ents = fs.readdirSync(d, { withFileTypes: true }); } catch { return; } for (const e of ents) { const p = path.join(d, e.name); if (e.isDirectory()) walk(p); else if (e.isFile()) { try { total += fs.statSync(p).size; } catch { /* 跳过读不到的 */ } } } }; walk(dir); return total; } export function humanSize(n) { if (!Number.isFinite(n)) return "?"; for (const [u, d] of [["GB", 1 << 30], ["MB", 1 << 20], ["KB", 1 << 10]]) { if (n >= d) return `${(n / d).toFixed(1)}${u}`; } return `${n}B`; } /** * 枚举仓库里的全部快照(新→旧)。 * manifest 读不出来的也列出来并标 invalid —— 半个快照比没有更值得警惕。 */ export function listSnapshots() { const root = path.join(REPO_DIR, "snapshots"); if (!fs.existsSync(root)) return []; const out = []; for (const name of fs.readdirSync(root)) { const dir = path.join(root, name); let st; try { st = fs.statSync(dir); } catch { continue; } if (!st.isDirectory()) continue; const m = readJson(path.join(dir, "manifest.json"), null); out.push({ name, dir, valid: !!m, host: m?.host ?? "?", createdAt: m?.createdAt ?? null, officialCount: (m?.official ?? []).length, enabledCount: (m?.enabled ?? []).length, custom: (m?.custom ?? []).map((c) => `${c.name}@${c.version}`), mcpCount: (m?.mcpServerNames ?? []).length, secretCount: (m?.secretFiles ?? []).length, modelProviderCount: (m?.modelProviders ?? []).length, bytes: dirSize(dir), }); } return out.sort((a, b) => { const ka = a.createdAt ?? a.name, kb = b.createdAt ?? b.name; return String(kb).localeCompare(String(ka)); }); } export function deepMerge(base, over) { if (Array.isArray(base) || Array.isArray(over)) return over === undefined ? base : over; if (base && typeof base === "object" && over && typeof over === "object") { const out = { ...base }; for (const [k, v] of Object.entries(over)) out[k] = deepMerge(base[k], v); return out; } return over === undefined ? base : over; } export function loadOverrides() { const cands = [ process.env.ZCODE_SYNC_OVERRIDES, path.join(WORK, "overrides.json"), path.join(PLUGINS_HOME, "zcode-env-sync", "local-overrides.json"), ].filter(Boolean); for (const f of cands) { if (fs.existsSync(f)) return { file: f, data: readJson(f, {}) }; } return { file: null, data: {} }; }