feat: zcode thinking-mode 0.3.0(state历史+rollback+分写+校验收紧+34用例全绿+文档)
This commit is contained in:
@@ -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("结论:一切就绪。");
|
||||
}
|
||||
Reference in New Issue
Block a user