feat: zcode thinking-mode 0.3.0(state历史+rollback+分写+校验收紧+34用例全绿+文档)

This commit is contained in:
2026-09-21 09:47:20 +08:00
commit 6ed5dda847
41 changed files with 6213 additions and 0 deletions
+615
View File
@@ -0,0 +1,615 @@
/**
* 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");
/** 模型同步默认关闭,显式开启才同步(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) };
}
/* ---------- 脱敏扫描:明文区不得出现密钥值 ---------- */
/** 递归收集 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: {} };
}