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
+191
View File
@@ -0,0 +1,191 @@
#!/usr/bin/env node
/**
* /sync-doctor:自诊断 —— 一次跑完所有前置条件,告诉你"为什么同步不工作"。
* 用法: node doctor.mjs
*
* 只读检查,不改任何东西。每项给出 ok/warn/fail 与可执行的修复建议。
* 退出码:有 fail 时为 1(可接进 CI 或脚本)。
*/
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { execFileSync } from "node:child_process";
import {
HOME, USER, HOSTNAME, ZCODE_HOME, DSH_HOME, CLI_CONFIG, PLUGINS_HOME,
PLUGIN_DATA, MARKETPLACES, REPO, GITEA_HOST, WORK, REPO_DIR, BACKUP_ROOT,
V2_CONFIG, readJson, pluginVersionFromDir, listSnapshots, latestSnapshotName,
decryptSecrets, humanSize, dirSize,
} from "./sync-core.mjs";
const checks = [];
const add = (level, name, detail, fix) => checks.push({ level, name, detail, fix });
/** 跑 git 并返回 {ok, out};失败不抛 */
function git(args) {
try { return { ok: true, out: execFileSync("git", args, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim() }; }
catch (e) { return { ok: false, out: String(e.stderr || e.message || e).trim() }; }
}
const exists = (p) => { try { return fs.existsSync(p); } catch { return false; } };
/* ---------- 1. 运行时 ---------- */
const nodeMajor = Number(process.versions.node.split(".")[0]);
add(nodeMajor >= 18 ? "ok" : "fail", "Node 版本", `v${process.versions.node}`,
nodeMajor >= 18 ? null : "需要 Node ≥18(fetch/结构化克隆等)");
const g = git(["--version"]);
add(g.ok ? "ok" : "fail", "git 可用", g.ok ? g.out : "未找到 git",
g.ok ? null : "装 git 并确保在 PATH 里(Windows 用 Git for Windows)");
/* ---------- 2. 身份与路径 ---------- */
add("ok", "当前机器", `host=${HOSTNAME} user=${USER} home=${HOME}`, null);
add("ok", "ZCode 目录", `${ZCODE_HOME}${exists(ZCODE_HOME) ? "" : "(不存在)"}`,
exists(ZCODE_HOME) ? null : "确认 ZCode 装好并至少跑过一次");
/* ---------- 3. 口令 ---------- */
const pass = process.env.ZCODE_SYNC_PASSPHRASE;
if (!pass) add("fail", "同步口令", "未设置 ZCODE_SYNC_PASSPHRASE", "export ZCODE_SYNC_PASSPHRASE='团队口令'(≥8 位,线下约定)");
else if (pass.length < 8) add("fail", "同步口令", `太短(${pass.length} 位)`, "换 ≥8 位的口令");
else add("ok", "同步口令", `已设置(${pass.length} 位)`, null);
/* ---------- 4. Gitea 凭据 ---------- */
const tokenFile = path.join(DSH_HOME, "secrets", "gitea-token.txt");
if (!exists(tokenFile)) add("warn", "Gitea token", `未找到 ${tokenFile}`, "写入 access token;否则只能匿名推送(通常会被拒)");
else {
const tok = fs.readFileSync(tokenFile, "utf8").trim();
if (!tok) add("warn", "Gitea token", "文件存在但为空", "写入有效 token");
else add("ok", "Gitea token", `已读到(${tok.length} 字符)`, null);
}
add("ok", "私仓坐标", `${GITEA_HOST}/${REPO}`, null);
/* ---------- 5. 工作区 ---------- */
add(exists(REPO_DIR) ? "ok" : "warn", "本地工作区", `${WORK}${exists(REPO_DIR) ? "(已克隆)" : "(尚未克隆,首次运行会拉)"}`,
exists(REPO_DIR) ? null : "首次 /sync-export 或 /sync-status 会自动克隆");
if (exists(REPO_DIR)) {
const cfg = path.join(REPO_DIR, ".git", "config");
if (exists(cfg)) {
const body = fs.readFileSync(cfg, "utf8");
const url = (body.match(/url\s*=\s*(\S+)/) ?? [])[1] ?? "?";
const hasCred = /:\/\/[^/@]+@/.test(url);
add(hasCred ? "warn" : "ok", "origin 地址", url,
hasCred ? "origin 里内嵌了凭据(会明文留在 .git/config),建议去掉,凭据走 token 文件" : null);
}
const attr = path.join(REPO_DIR, ".gitattributes");
if (!exists(attr)) add("warn", "换行符锁定", "无 .gitattributes", "跑一次 /sync-export 会自动补上");
else if (!/\*\s+-text/.test(fs.readFileSync(attr, "utf8")))
add("warn", "换行符锁定", ".gitattributes 里没有 `* -text`", "跑一次 /sync-export 会自动修正");
else add("ok", "换行符锁定", ".gitattributes 有 `* -text`", null);
const ac = git(["-C", REPO_DIR, "config", "--local", "core.autocrlf"]);
add(ac.ok && ac.out === "false" ? "ok" : "warn", "core.autocrlf",
ac.ok ? ac.out || "(未设置)" : "读不到",
ac.ok && ac.out === "false" ? null : "应为 false,否则 hash 会漂移;跑一次 /sync-export 会自动修正");
const head = git(["-C", REPO_DIR, "rev-parse", "HEAD"]);
const origin = git(["-C", REPO_DIR, "rev-parse", "origin/main"]);
if (head.ok && origin.ok) {
add(head.out === origin.out ? "ok" : "warn", "追踪引用",
head.out === origin.out ? `HEAD == origin/main (${head.out.slice(0, 8)})` : `HEAD(${head.out.slice(0, 8)}) != origin/main(${origin.out.slice(0, 8)})`,
head.out === origin.out ? null : "本地与远端分叉,先 /sync-status 确认再操作");
} else if (head.ok) add("warn", "追踪引用", "无 origin/main(本地尚未推送过)", "跑一次 /sync-export");
else add("warn", "追踪引用", "仓库还没有提交", "跑一次 /sync-export");
const dirty = git(["-C", REPO_DIR, "status", "--porcelain"]);
const n = dirty.ok && dirty.out ? dirty.out.split("\n").length : 0;
add(n ? "warn" : "ok", "工作区状态", n ? `${n} 个未提交改动` : "干净",
n ? "确认这些改动是否需要;通常下次 /sync-export 会一并提交" : null);
/* 快照健康度 */
const snaps = listSnapshots();
if (!snaps.length) add("warn", "快照", "仓里还没有快照", "先 /sync-export");
else {
const latestName = latestSnapshotName();
const invalid = snaps.filter((s) => !s.valid);
add(invalid.length ? "warn" : "ok", "快照总数",
`${snaps.length} 个,合计 ${humanSize(snaps.reduce((a, s) => a + s.bytes, 0))}`
+ (invalid.length ? `;${invalid.length} 个 manifest 损坏` : ""),
invalid.length ? `损坏的:${invalid.map((s) => s.name).join(", ")};可用 /sync-prune 清理` : null);
if (!latestName) add("fail", "latest.json", "缺失或指向的快照不存在", "跑一次 /sync-export 重建");
else {
const hit = snaps.find((s) => s.name === latestName);
add(hit?.valid ? "ok" : "fail", "latest 指向", latestName,
hit?.valid ? null : "指向的快照不存在或损坏,重新导出或用 --keep 之外的快照");
// 口令是否能解开 latest:这是 /sync-import 真会踩的坑
if (pass && hit?.valid) {
const enc = path.join(hit.dir, "secrets.enc");
if (!exists(enc)) add("warn", "latest 密钥束", "快照里没有 secrets.enc", "重新导出该机器环境");
else {
try { decryptSecrets(fs.readFileSync(enc, "utf8"), pass); add("ok", "latest 可解密", "当前口令能解开密钥束", null); }
catch {
add("fail", "latest 可解密", "当前口令解不开 latest 的密钥束",
"该快照可能用了别的口令(换口令前导出的,或别的机器导的);用 `node verify.mjs --all` 找当前口令能用的那版");
}
}
}
}
const big = snaps.reduce((a, s) => a + s.bytes, 0);
if (snaps.length > 20) add("warn", "快照数量", `${snaps.length} 个,合计 ${humanSize(big)}`,
`数量偏多,建议 /sync-prune --keep 10 保留最近 10 个`);
else add("ok", "快照体积", `${snaps.length} 个,合计 ${humanSize(big)}`, null);
}
}
/* ---------- 6. 备份目录 ---------- */
const bks = exists(BACKUP_ROOT)
? fs.readdirSync(BACKUP_ROOT, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name)
: [];
add(bks.length ? "ok" : "ok", "本机备份", bks.length
? `${bks.length} 个批次(最近:${bks.sort().at(-1)}),共 ${humanSize(dirSize(BACKUP_ROOT))}`
: "无(尚未覆盖过任何文件)", null);
/* ---------- 7. 本机 ZCode 环境 ---------- */
const en = exists(PLUGIN_DATA) ? fs.readdirSync(PLUGIN_DATA).filter((d) => { try { return fs.statSync(path.join(PLUGIN_DATA, d)).isDirectory(); } catch { return false; } }) : [];
add(en.length ? "ok" : "warn", "启用插件", `${en.length} 项`, en.length ? null : "data/ 目录为空,确认 ZCode 装好");
const custom = exists(PLUGINS_HOME)
? fs.readdirSync(PLUGINS_HOME, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name)
: [];
add("ok", "自研插件", custom.length ? `${custom.length} 项(${custom.join(", ")})` : "无",
null);
// 自身版本,便于对齐"我装的是哪版"
const selfVer = pluginVersionFromDir(PLUGINS_HOME + path.sep + "zcode-env-sync");
add("ok", "本插件版本", selfVer ? `v${selfVer}` : "?(plugin.json 读不到)", null);
const mcpCount = Object.keys(readJson(CLI_CONFIG, {})?.mcp?.servers ?? {}).length;
add("ok", "MCP server", `${mcpCount} 个`, null);
/* ---------- 8. 模型同步(可选) ---------- */
if (exists(V2_CONFIG)) {
const v2 = readJson(V2_CONFIG, {});
const n = Object.keys(v2?.provider ?? {}).length;
const withKey = Object.entries(v2?.provider ?? {}).filter(([, p]) =>
Object.keys(p?.options ?? {}).some((k) => /(api[_-]?key|secret|token)$/i.test(k) && typeof p.options[k] === "string" && p.options[k].length >= 8)).length;
add("ok", "模型 provider", `${n} 个,其中 ${withKey} 个带密钥`,
withKey ? `要连带同步需显式开 ZCODE_SYNC_WITH_MODELS=1(默认不同步,避免密钥出本机)` : null);
} else {
add("ok", "模型 provider", "无 v2/config.json(未配置模型)", null);
}
/* ---------- 9. 明文卫生 ---------- */
add(os.homedir() === HOME ? "ok" : "warn", "HOME 解析", HOME,
os.homedir() === HOME ? null : "HOME 与 os.homedir() 不一致,可能是沙箱/重定向环境");
/* ---------- 输出 ---------- */
const icon = { ok: "✓", warn: "⚠", fail: "✗" };
const fails = checks.filter((c) => c.level === "fail");
const warns = checks.filter((c) => c.level === "warn");
console.log(`# zcode-env-sync 自诊断`);
console.log(`- ${checks.length} 项检查:${checks.length - fails.length - warns.length} ok / ${warns.length} warn / ${fails.length} fail\n`);
for (const c of checks) {
console.log(`${icon[c.level]} ${c.name}:${c.detail}`);
if (c.fix) console.log(` → ${c.fix}`);
}
console.log("");
if (fails.length) {
console.log(`结论:有 ${fails.length} 项必须先修,否则同步会失败。`);
process.exitCode = 1;
} else if (warns.length) {
console.log(`结论:可正常工作,${warns.length} 项警告建议处理。`);
} else {
console.log("结论:一切就绪。");
}