#!/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("结论:一切就绪。"); }