问题:只同步 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 项通过
681 lines
30 KiB
JavaScript
681 lines
30 KiB
JavaScript
/**
|
|
* 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/ 目录即启用态: <name>@<marketplace|inline> */
|
|
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` 只是 `<name>@<来源>` 字符串,`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, 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/<providerId>/<key> → { 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: {} };
|
|
}
|