From 6ed5dda847a8e8f0f532dc5beb66e464bef08fcd Mon Sep 17 00:00:00 2001 From: root <1291454411@qq.com> Date: Sat, 12 Sep 2026 14:20:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20zcode=20thinking-mode=200.3.0=EF=BC=88s?= =?UTF-8?q?tate=E5=8E=86=E5=8F=B2+rollback+=E5=88=86=E5=86=99+=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E6=94=B6=E7=B4=A7+34=E7=94=A8=E4=BE=8B=E5=85=A8?= =?UTF-8?q?=E7=BB=BF+=E6=96=87=E6=A1=A3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 10 + README.md | 83 +++ audit-plugins.mjs | 179 +++++ deploy.mjs | 192 ++++++ package.json | 20 + tests/tools.test.mjs | 375 ++++++++++ zcode-env-sync/.gitignore | 6 + zcode-env-sync/.zcode-plugin/plugin.json | 23 + zcode-env-sync/README.md | 217 ++++++ zcode-env-sync/commands/sync-doctor.md | 30 + zcode-env-sync/commands/sync-export.md | 32 + zcode-env-sync/commands/sync-import.md | 32 + zcode-env-sync/commands/sync-list.md | 19 + zcode-env-sync/commands/sync-prune.md | 27 + zcode-env-sync/commands/sync-status.md | 20 + zcode-env-sync/commands/sync-verify.md | 31 + zcode-env-sync/package.json | 22 + zcode-env-sync/scripts/doctor.mjs | 191 ++++++ zcode-env-sync/scripts/export.mjs | 239 +++++++ zcode-env-sync/scripts/import.mjs | 211 ++++++ zcode-env-sync/scripts/prune.mjs | 128 ++++ zcode-env-sync/scripts/status.mjs | 131 ++++ zcode-env-sync/scripts/sync-core.mjs | 615 +++++++++++++++++ zcode-env-sync/scripts/verify.mjs | 224 ++++++ zcode-env-sync/tests/core.test.mjs | 324 +++++++++ zcode-env-sync/tests/e2e.test.mjs | 642 ++++++++++++++++++ zcode-env-sync/tests/live-roundtrip.mjs | 148 ++++ zcode-env-sync/tests/tools.test.mjs | 440 ++++++++++++ zcode-thinking-mode/.gitignore | 2 + zcode-thinking-mode/.zcode-plugin/plugin.json | 33 + zcode-thinking-mode/README.md | 113 +++ zcode-thinking-mode/commands/thinking.md | 39 ++ .../commands/thinking/defaults.md | 31 + .../commands/thinking/doctor.md | 31 + .../commands/thinking/history.md | 19 + .../commands/thinking/rollback.md | 22 + zcode-thinking-mode/commands/thinking/set.md | 26 + .../commands/thinking/status.md | 16 + zcode-thinking-mode/package.json | 18 + zcode-thinking-mode/scripts/thinking.mjs | 620 +++++++++++++++++ zcode-thinking-mode/tests/thinking.test.mjs | 632 +++++++++++++++++ 41 files changed, 6213 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 audit-plugins.mjs create mode 100644 deploy.mjs create mode 100644 package.json create mode 100644 tests/tools.test.mjs create mode 100644 zcode-env-sync/.gitignore create mode 100644 zcode-env-sync/.zcode-plugin/plugin.json create mode 100644 zcode-env-sync/README.md create mode 100644 zcode-env-sync/commands/sync-doctor.md create mode 100644 zcode-env-sync/commands/sync-export.md create mode 100644 zcode-env-sync/commands/sync-import.md create mode 100644 zcode-env-sync/commands/sync-list.md create mode 100644 zcode-env-sync/commands/sync-prune.md create mode 100644 zcode-env-sync/commands/sync-status.md create mode 100644 zcode-env-sync/commands/sync-verify.md create mode 100644 zcode-env-sync/package.json create mode 100644 zcode-env-sync/scripts/doctor.mjs create mode 100644 zcode-env-sync/scripts/export.mjs create mode 100644 zcode-env-sync/scripts/import.mjs create mode 100644 zcode-env-sync/scripts/prune.mjs create mode 100644 zcode-env-sync/scripts/status.mjs create mode 100644 zcode-env-sync/scripts/sync-core.mjs create mode 100644 zcode-env-sync/scripts/verify.mjs create mode 100644 zcode-env-sync/tests/core.test.mjs create mode 100644 zcode-env-sync/tests/e2e.test.mjs create mode 100644 zcode-env-sync/tests/live-roundtrip.mjs create mode 100644 zcode-env-sync/tests/tools.test.mjs create mode 100644 zcode-thinking-mode/.gitignore create mode 100644 zcode-thinking-mode/.zcode-plugin/plugin.json create mode 100644 zcode-thinking-mode/README.md create mode 100644 zcode-thinking-mode/commands/thinking.md create mode 100644 zcode-thinking-mode/commands/thinking/defaults.md create mode 100644 zcode-thinking-mode/commands/thinking/doctor.md create mode 100644 zcode-thinking-mode/commands/thinking/history.md create mode 100644 zcode-thinking-mode/commands/thinking/rollback.md create mode 100644 zcode-thinking-mode/commands/thinking/set.md create mode 100644 zcode-thinking-mode/commands/thinking/status.md create mode 100644 zcode-thinking-mode/package.json create mode 100644 zcode-thinking-mode/scripts/thinking.mjs create mode 100644 zcode-thinking-mode/tests/thinking.test.mjs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6600a24 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# 依赖与本地产物 +node_modules/ +*.log + +# 本机覆盖(不进仓):机器特有的 host/port/command +local-overrides.json + +# 测试与本地产物 +tests/*.local.mjs +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..c900b9c --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# ZCode 自研插件工作区 + +自研 ZCode 插件的源码在这里改,`~/.zcode/plugins/` 是**运行时读的安装副本**。 +两份并存意味着一个陷阱:改了源码没重装,跑的还是旧代码 —— 而报错会指向你刚改好的 +那个文件,排查全跑偏。这个工作区用 `deploy.mjs` + `audit-plugins.mjs` 把这件事钉死。 + +## 目录 + +```text +zcode/ + deploy.mjs 源码 → ~/.zcode/plugins,备份 + 测试 + 审计 + audit-plugins.mjs 源码 vs 安装副本 漂移检测 + 文档/语法检查 + tests/tools.test.mjs 上面两个工具自己的测试(含"能不能真发现漂移") + package.json 一键入口 + zcode-env-sync/ 插件:团队环境同步(导出/导入/校验/清理/诊断) + zcode-thinking-mode/ 插件:思考档位切换(查看/切换/默认值/自检) +``` + +## 常用命令 + +```bash +npm run deploy:dry # 预演:会改哪些文件,不落盘 +npm run deploy # 备份 → 同步 → 跑测试 → 审计 +npm run audit # 只看源码与安装副本是否一致 +npm run audit:drift # 只要漂移结论,跳过文档/语法检查 +npm run test:all # 三个项目全测(工具 24 + env-sync 63 + thinking 28) +``` + +只想部署一个插件: + +```bash +node deploy.mjs zcode-thinking-mode +node deploy.mjs zcode-thinking-mode --no-test # 只同步 +node deploy.mjs --no-deploy # 只测+审计,不动文件 +``` + +## 漂移检测 + +```bash +$ npm run audit +### zcode-env-sync v0.3.0 + ✗ 副本与源码有差异: + 内容不同:scripts/export.mjs + → 重装:cp -r ".../zcode-env-sync/." ".../zcode/plugins/zcode-env-sync/" +``` + +判定口径: + +| 情况 | 判定 | +|---|---| +| 源码有、副本没有(新增文件没部署) | ✗ 失败 | +| 两边都有但内容不同 | ✗ 失败 | +| 版本号不一致 | ✗ 失败 | +| **源码已删、副本还在**(`scripts/` `commands/` 等源码管理位置) | ✗ 失败 —— 幽灵代码仍会被加载 | +| 根目录 / 自定义子目录的本机文件(`local-state.json` 等) | ⚠ 只提示 —— 这是有意的本地产物 | +| 命令文档缺 `description` / 引用脚本不存在 / 脚本语法错 | ✗ 失败 | + +**残留判定只认源码管理的位置**(`scripts/`、`commands/`、`.zcode-plugin/`,以及根目录的 `.md` +与 `package.json`)。不按扩展名一刀切 —— 否则根目录的 `local-state.json` 会被报成 +"残留代码建议删除",把一个只读审计工具变成删数据的推手。 + +## 为什么 deploy 只增改不删 + +副本里可能有你有意放的本机文件(`local-state.json`、本机专用配置)。 +整目录替换会把它们一起抹掉,所以 deploy 逐文件写,只在有差异时动对应文件。 + +源码删掉的文件会留成**幽灵代码**继续被加载 —— deploy 会检测出来并打印 `rm` 命令, +但**不自动删**:删除不可逆,备份虽有,但"删了才发现还想留"的代价比多敲一行高。 + +## 部署后生效 + +插件、命令、hooks 都在启动时读取,**重启 ZCode 生效**。 +当前会话里想立刻验证,直接跑脚本本体: + +```bash +node ~/.zcode/plugins/zcode-thinking-mode/scripts/thinking.mjs doctor +node ~/.zcode/plugins/zcode-env-sync/scripts/doctor.mjs +``` + +## 每个插件的文档 + +- [zcode-env-sync/README.md](zcode-env-sync/README.md) —— 快照结构、路径占位符、密钥束、七条命令 +- [zcode-thinking-mode/README.md](zcode-thinking-mode/README.md) —— 双写原理、默认值文件、自检 diff --git a/audit-plugins.mjs b/audit-plugins.mjs new file mode 100644 index 0000000..86fb4ed --- /dev/null +++ b/audit-plugins.mjs @@ -0,0 +1,179 @@ +/** + * 插件源码 ↔ 安装副本 一致性审计。 + * + * 背景:这些插件是"工作区源码 + ~/.zcode/plugins 安装副本"两份并存的手工流程, + * 副本滞后不会被任何东西发现 —— 改了源码没重装,跑的还是旧代码,而且报错会指向 + * 一个你刚改好的文件。这个脚本就是拿来看这件事的。 + * + * node audit-plugins.mjs # 审计工作区里每个插件(源码 vs 安装副本 + 文档/语法) + * node audit-plugins.mjs --drift # 只报漂移,不跑语法检查 + * node audit-plugins.mjs <名字>... # 只审计指定插件(不传=全部) + * + * 环境变量: + * ZCODE_AUDIT_WORKSPACE 工作区路径(默认本脚本所在目录;测试用) + * ZCODE_PLUGINS_DIR 安装目录(默认 ~/.zcode/plugins;测试用) + * + * 只读,不改任何东西。退出码:有问题为 1。 + */ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; + +const WORKSPACE = process.env.ZCODE_AUDIT_WORKSPACE || import.meta.dirname; +const PLUGINS = process.env.ZCODE_PLUGINS_DIR || path.join(os.homedir(), ".zcode", "plugins"); +const DRIFT_ONLY = process.argv.includes("--drift"); +/** 位置参数 = 只审计这些插件;不传则全部 */ +const WANTED = process.argv.slice(2).filter((a) => !a.startsWith("--")); + +/** 建议忽略的条目:本地产物,不属于"该同步的内容" */ +const IGNORE = new Set(["node_modules", ".git", "package-lock.json"]); + +/** + * 哪些路径算"源码管理的" —— 只有这些位置的文件从源码删掉才算残留。 + * + * 不能用扩展名一刀切:根目录的 local-state.json 是操作员放的本机状态, + * 按扩展名判会把它报成"残留代码建议删除",把一个只读审计工具变成删数据的推手。 + * 目录归属才是可靠信号:scripts/ 与 commands/ 完全是源码产物,根目录只认 .md 和 package.json。 + */ +function isSourceManaged(rel) { + const r = rel.split(path.sep).join("/"); + if (r.startsWith("scripts/") || r.startsWith("commands/") || r.startsWith(".zcode-plugin/")) return true; + if (r.includes("/")) return false; // 别的子目录:来源不明,不当残留 + return /\.(md)$/i.test(r) || r === "package.json"; // 根目录只认这两种 +} + +const out = []; +let problems = 0; +let warns = 0; + +function walkFiles(root, base = root, acc = []) { + for (const e of fs.readdirSync(base, { withFileTypes: true })) { + if (IGNORE.has(e.name)) continue; + const p = path.join(base, e.name); + if (e.isDirectory()) walkFiles(root, p, acc); + else if (e.isFile()) acc.push(path.relative(root, p).split(path.sep).join("/")); + } + return acc; +} + +/** 源码有而副本没有 / 两边内容不同 / 副本有而源码没有 */ +function diffTrees(srcRoot, dstRoot) { + const a = new Set(walkFiles(srcRoot)), b = new Set(walkFiles(dstRoot)); + const missing = [...a].filter((f) => !b.has(f)).sort(); // 没部署过去 + const extra = [...b].filter((f) => !a.has(f)).sort(); // 副本里多出来的 + const changed = [...a].filter((f) => b.has(f) + && fs.readFileSync(path.join(srcRoot, f), "utf8") !== fs.readFileSync(path.join(dstRoot, f), "utf8")).sort(); + return { missing, extra, changed }; +} + +const sources = fs.readdirSync(WORKSPACE, { withFileTypes: true }) + .filter((e) => e.isDirectory() && fs.existsSync(path.join(WORKSPACE, e.name, ".zcode-plugin", "plugin.json"))) + .map((e) => e.name) + .filter((n) => !WANTED.length || WANTED.includes(n)) + .sort(); + +if (!sources.length) { + console.log(WANTED.length + ? `# 没找到插件:${WANTED.join(", ")}\n- 工作区:${WORKSPACE}` + : `# 未在工作区找到插件(需要 .zcode-plugin/plugin.json)\n- 工作区:${WORKSPACE}`); + process.exit(1); +} + +out.push(`# 插件审计`); +out.push(`- 工作区:${WORKSPACE}`); +out.push(`- 安装目录:${PLUGINS}`); + +for (const name of sources) { + const src = path.join(WORKSPACE, name); + const dst = path.join(PLUGINS, name); + const srcVer = JSON.parse(fs.readFileSync(path.join(src, ".zcode-plugin", "plugin.json"), "utf8")).version ?? "?"; + + out.push(`\n### ${name} v${srcVer}`); + + if (!fs.existsSync(dst)) { + out.push(` ✗ 未安装 —— 装一下才能生效`); + problems++; + continue; + } + + const dstVer = JSON.parse(fs.readFileSync(path.join(dst, ".zcode-plugin", "plugin.json"), "utf8")).version ?? "?"; + if (dstVer !== srcVer) { + out.push(` ✗ 版本不一致:源码 v${srcVer} / 安装 v${dstVer}`); + problems++; + } + + const { missing, extra, changed } = diffTrees(src, dst); + if (!missing.length && !changed.length && !extra.length) { + out.push(` ✓ 与安装副本逐字节一致`); + } else { + // 未部署/内容不同 = 真漂移,失败。 + // 副本独有分两种:本机 state 类(有意保留,只警告)vs 源码删掉的旧文件(残留,必须报)。 + const deleted = extra.filter(isSourceManaged); + const harmless = extra.filter((f) => !isSourceManaged(f)); + if (missing.length || changed.length || deleted.length) problems++; + if (harmless.length) warns++; + out.push(` ${missing.length || changed.length || deleted.length ? "✗" : "⚠"} 副本与源码有差异:`); + for (const f of missing) out.push(` 未部署:${f}`); + for (const f of changed) out.push(` 内容不同:${f}`); + for (const f of deleted) out.push(` 源码已删除但副本还在(残留代码仍会被加载):${f}`); + for (const f of harmless) out.push(` 本地产物(有意保留,不影响):${f}`); + if (missing.length || changed.length) out.push(` → 重装:cp -r "${src}/." "${dst}/"`); + if (deleted.length) out.push(` → 残留文件需手工删除:rm "${dst}/${deleted[0]}"${deleted.length > 1 ? ` (另 ${deleted.length - 1} 个)` : ""}`); + } + + if (DRIFT_ONLY) continue; + + // manifest 合规 + const mf = JSON.parse(fs.readFileSync(path.join(src, ".zcode-plugin", "plugin.json"), "utf8")); + if (!/^[a-z0-9][a-z0-9._-]{0,127}$/.test(mf.name)) { out.push(` ✗ name 不合规:${mf.name}`); problems++; } + + // 命令文档:front-matter + 引用脚本存在性 + const cmdDir = path.join(src, "commands"); + const docs = []; + if (fs.existsSync(cmdDir)) { + const walk = (d) => { + for (const e of fs.readdirSync(d, { withFileTypes: true })) { + const p = path.join(d, e.name); + if (e.isDirectory()) walk(p); + else if (e.name.endsWith(".md")) docs.push(p); + } + }; + walk(cmdDir); + } + for (const d of docs.sort()) { + const rel = path.relative(src, d).split(path.sep).join("/"); + const body = fs.readFileSync(d, "utf8"); + const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(body); + const desc = fm ? /description:\s*(.+)/.exec(fm[1])?.[1] : null; + const refs = [...body.matchAll(/-name\s+(\S+\.mjs)/g)].map((m) => m[1]); + const miss = refs.filter((r) => !fs.existsSync(path.join(src, "scripts", r))); + const ok = !!fm && !!desc && miss.length === 0; + if (!ok) problems++; + out.push(` ${ok ? "✓" : "✗"} ${rel}${desc ? "" : " (缺 description)"}${miss.length ? ` 引用脚本缺失:${miss.join(",")}` : ""}`); + } + + // 脚本语法自检(--check 只解析不执行) + const scripts = fs.existsSync(path.join(src, "scripts")) ? fs.readdirSync(path.join(src, "scripts")) : []; + for (const s of scripts.filter((x) => x.endsWith(".mjs"))) { + try { + execFileSync(process.execPath, ["--check", path.join(src, "scripts", s)], { stdio: "pipe" }); + } catch (e) { + out.push(` ✗ 语法 ${s}:${String(e.stderr ?? e).split("\n").find((l) => l.includes("Error")) ?? ""}`); + problems++; + } + } + const hasTests = fs.existsSync(path.join(src, "tests")); + out.push(` ${hasTests ? "✓" : "⚠"} 文档 ${docs.length} / 脚本 ${scripts.filter((x) => x.endsWith(".mjs")).length} / 测试 ${hasTests ? "有" : "无"}`); +} + +out.push(""); +if (problems) { + out.push(`结论:${problems} 处需要处理(见上面 ✗)。`); +} else if (warns) { + out.push(`结论:源码与安装副本一致,结构完整;${warns} 处提示不影响运行。`); +} else { + out.push(`结论:源码与安装副本一致,结构完整。`); +} +console.log(out.filter(Boolean).join("\n")); +process.exit(problems ? 1 : 0); diff --git a/deploy.mjs b/deploy.mjs new file mode 100644 index 0000000..8076e2e --- /dev/null +++ b/deploy.mjs @@ -0,0 +1,192 @@ +/** + * 插件部署:工作区源码 → ~/.zcode/plugins 安装副本。 + * + * 手工 cp 的问题不是麻烦,是**悄悄不同步**:改了源码没重装,跑的还是旧代码, + * 而报错会指向一个你刚改好的文件,排查全跑偏。这个脚本把流程固定成: + * + * 备份现有副本 → 逐文件同步 → 跑该插件的测试 → 审计一致性 + * + * node deploy.mjs # 部署全部插件 + * node deploy.mjs zcode-thinking-mode # 只部署指定插件 + * node deploy.mjs --dry-run # 只看会改哪些文件,不落盘 + * node deploy.mjs --no-test # 跳过测试(只同步+审计) + * node deploy.mjs --no-deploy # 只跑现有测试与审计 + * + * 只增改,不删副本里的额外文件(那些可能是你有意放的本地产物)。 + * 退出码:同步后测试或审计失败为 1。 + * + * 环境变量: + * ZCODE_DEPLOY_WORKSPACE 工作区路径(默认本脚本所在目录;测试用) + * ZCODE_PLUGINS_DIR 安装目录(默认 ~/.zcode/plugins;测试用) + * ZCODE_DEPLOY_BACKUP_DIR 备份根目录(默认 ~/.zcode;测试用) + */ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; + +const WORKSPACE = process.env.ZCODE_DEPLOY_WORKSPACE || import.meta.dirname; +const PLUGINS = process.env.ZCODE_PLUGINS_DIR || path.join(os.homedir(), ".zcode", "plugins"); +const BACKUP_ROOT = process.env.ZCODE_DEPLOY_BACKUP_DIR || path.join(os.homedir(), ".zcode"); +/** 工具脚本自己所在的目录 —— 与 WORKSPACE 是两回事:工作区放插件,这里放 audit/deploy 脚本本身 */ +const TOOLDIR = import.meta.dirname; + +const argv = process.argv.slice(2); +const DRY = argv.includes("--dry-run"); +const NO_TEST = argv.includes("--no-test"); +const NO_DEPLOY = argv.includes("--no-deploy"); +const wanted = argv.filter((a) => !a.startsWith("--")); + +/** 本地产物,不属于"该同步的内容" */ +const IGNORE = new Set(["node_modules", ".git"]); + +const out = []; +let failed = 0; +const log = (s) => { out.push(s); console.log(s); }; + +function walkFiles(root, base = root, acc = []) { + for (const e of fs.readdirSync(base, { withFileTypes: true })) { + if (IGNORE.has(e.name)) continue; + const p = path.join(base, e.name); + if (e.isDirectory()) walkFiles(root, p, acc); + else if (e.isFile()) acc.push(path.relative(root, p).split(path.sep).join("/")); + } + return acc; +} +const stamp = () => { + 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())}`; +}; + +const sources = fs.readdirSync(WORKSPACE, { withFileTypes: true }) + .filter((e) => e.isDirectory() && fs.existsSync(path.join(WORKSPACE, e.name, ".zcode-plugin", "plugin.json"))) + .map((e) => e.name) + .filter((n) => !wanted.length || wanted.includes(n)) + .sort(); + +if (!sources.length) { + console.error(wanted.length ? `没找到插件:${wanted.join(", ")}` : `工作区里没找到插件:${WORKSPACE}`); + process.exit(1); +} + +log(`# 插件部署${DRY ? " (预演)" : ""}`); +log(`- 工作区:${WORKSPACE}`); +log(`- 目标:${PLUGINS}`); +log(`- 插件:${sources.join(", ")}`); + +/* ---- 1) 备份 ---- */ +let backupDir = null; +if (!DRY && !NO_DEPLOY) { + backupDir = path.join(BACKUP_ROOT, `.plugin-backups-deploy-${stamp()}`); + fs.mkdirSync(backupDir, { recursive: true }); + for (const name of sources) { + const dst = path.join(PLUGINS, name); + if (fs.existsSync(dst)) fs.cpSync(dst, path.join(backupDir, name), { recursive: true }); + } + log(`\n## 备份`); + log(`- ${backupDir}`); +} + +/* ---- 2) 同步 ---- */ +/** + * 源码删掉的文件在副本里会留成"幽灵代码"继续被加载,检测出来但不自动删。 + * 只认源码管理的位置(scripts/ commands/ .zcode-plugin/ 与根目录的 .md/package.json), + * 根目录的 local-state.json 这类本机文件不该被误报成残留。 + */ +const isSourceManaged = (rel) => { + const r = rel.split(path.sep).join("/"); + if (r.startsWith("scripts/") || r.startsWith("commands/") || r.startsWith(".zcode-plugin/")) return true; + if (r.includes("/")) return false; + return /\.md$/i.test(r) || r === "package.json"; +}; +function orphanFiles(src, dst) { + const srcSet = new Set(walkFiles(src)); + return walkFiles(dst).filter((f) => !srcSet.has(f) && isSourceManaged(f)); +} + +log(`\n## 同步`); +const orphansAll = []; +for (const name of sources) { + const src = path.join(WORKSPACE, name); + const dst = path.join(PLUGINS, name); + const srcFiles = walkFiles(src); + const changed = [], added = []; + for (const f of srcFiles) { + const s = path.join(src, f), d = path.join(dst, f); + if (!fs.existsSync(d)) added.push(f); + else if (fs.readFileSync(s, "utf8") !== fs.readFileSync(d, "utf8")) changed.push(f); + } + const orphans = fs.existsSync(dst) ? orphanFiles(src, dst) : []; + if (!added.length && !changed.length && !orphans.length) { + log(`- ${name}:已是最新,${srcFiles.length} 个文件无需改动`); + continue; + } + log(`- ${name}:新增 ${added.length} / 更新 ${changed.length} / 残留 ${orphans.length}`); + for (const f of added) log(` + ${f}`); + for (const f of changed) log(` ~ ${f}`); + for (const f of orphans) log(` - ${f} ← 源码已删,副本还在`); + if (DRY) continue; + // 逐文件写:整目录替换会连带删掉副本里的本地产物 + for (const f of [...added, ...changed]) { + const d = path.join(dst, f); + fs.mkdirSync(path.dirname(d), { recursive: true }); + fs.copyFileSync(path.join(src, f), d); + } + if (orphans.length) orphansAll.push({ name, dst, orphans }); +} + +// 删除是破坏性的且不可逆(副本可能有人在里面放东西),所以不自动删 —— 打印可执行命令让操作者确认。 +// 备份里已有副本快照,但"删了才发现还想留"的成本比多敲一行高。 +if (orphansAll.length && !DRY) { + log(`\n## 残留文件(源码已删,副本仍在,会被继续加载)`); + log(`- 这些文件不在源码里,同步不会碰它们;确认无用后手工删除:`); + for (const { name, dst, orphans } of orphansAll) { + for (const f of orphans) log(` rm "${path.join(dst, f)}" # ${name}`); + } +} + +if (DRY) { + log(`\n预演结束,未改动任何文件。去掉 --dry-run 才真同步。`); + process.exit(0); +} + +/* ---- 3) 测试 ---- */ +if (!NO_TEST) { + log(`\n## 测试`); + for (const name of sources) { + const dir = path.join(PLUGINS, name); + const pkg = path.join(dir, "package.json"); + if (!fs.existsSync(pkg)) { log(`- ${name}:无 package.json,跳过`); continue; } + const script = JSON.parse(fs.readFileSync(pkg, "utf8")).scripts?.test; + if (!script) { log(`- ${name}:package.json 里没有 test 脚本,跳过`); continue; } + const r = spawnSync("npm", ["test", "--silent"], { cwd: dir, encoding: "utf8", shell: true, timeout: 900000 }); + const txt = `${r.stdout ?? ""}${r.stderr ?? ""}`; + const line = txt.split("\n").find((l) => /^ℹ (tests|pass|fail)/.test(l)); + const tests = /ℹ tests (\d+)/.exec(txt)?.[1]; + const pass = /ℹ pass (\d+)/.exec(txt)?.[1]; + const fail = /ℹ fail (\d+)/.exec(txt)?.[1]; + const ok = r.status === 0 && fail === "0"; + if (!ok) failed++; + log(`- ${name}:${ok ? "✓" : "✗"} ${tests ?? "?"} 项,通过 ${pass ?? "?"},失败 ${fail ?? "?"}`); + if (!ok && r.status !== 0) { + for (const l of txt.split("\n").filter((x) => /✖|Error|assert/.test(x)).slice(0, 8)) log(` ${l.trim()}`); + } + void line; + } +} + +/* ---- 4) 审计 ---- */ +// 只审计本次动过的插件:别的插件漂移不该算到这次部署头上(要点名独立审计) +log(`\n## 审计`); +const audit = spawnSync(process.execPath, [path.join(TOOLDIR, "audit-plugins.mjs"), ...sources], { + encoding: "utf8", + cwd: WORKSPACE, + env: { ...process.env, ZCODE_AUDIT_WORKSPACE: WORKSPACE, ZCODE_PLUGINS_DIR: PLUGINS }, +}); +const auditOut = `${audit.stdout ?? ""}${audit.stderr ?? ""}`; +for (const line of auditOut.split("\n").filter((l) => /✗|结论/.test(l))) log(line); +if (audit.status !== 0) failed++; + +log(`\n结论:${failed === 0 ? "部署完成,测试与审计均通过。" : `${failed} 项失败,见上面 ✗。`}`); +if (backupDir) log(`回滚:cp -r "${backupDir}/<插件名>/." "${PLUGINS}/<插件名>/"`); +process.exit(failed ? 1 : 0); diff --git a/package.json b/package.json new file mode 100644 index 0000000..847b671 --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "zcode-plugin-workspace", + "private": true, + "type": "module", + "description": "ZCode 自研插件工作区:源码在这里改,deploy.mjs 同步到 ~/.zcode/plugins 并跑测试+审计。", + "scripts": { + "deploy": "node deploy.mjs", + "deploy:dry": "node deploy.mjs --dry-run", + "audit": "node audit-plugins.mjs", + "audit:drift": "node audit-plugins.mjs --drift", + "test": "node --test \"tests/*.test.mjs\"", + "test:tools": "node --test tests/tools.test.mjs", + "test:env-sync": "npm test --prefix zcode-env-sync", + "test:thinking": "npm test --prefix zcode-thinking-mode", + "test:all": "npm run test:tools && npm run test:env-sync && npm run test:thinking" + }, + "engines": { + "node": ">=22.5" + } +} diff --git a/tests/tools.test.mjs b/tests/tools.test.mjs new file mode 100644 index 0000000..c2e05e2 --- /dev/null +++ b/tests/tools.test.mjs @@ -0,0 +1,375 @@ +/** + * audit-plugins.mjs / deploy.mjs 的测试。 + * + * 两个工具都在操作"工作区 ↔ 安装副本",测试必须证明它们**真能发现漂移** —— + * 一个永远报"一致"的审计工具比没有更糟。 + * + * 全程用 ZCODE_AUDIT_WORKSPACE / ZCODE_PLUGINS_DIR / ZCODE_DEPLOY_* 重定向到临时目录, + * 不碰真实工作区与真实插件。 + * + * 跑法: node --test "tests/*.test.mjs" + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; + +const TOOLS = path.resolve(import.meta.dirname, ".."); +const AUDIT = path.join(TOOLS, "audit-plugins.mjs"); +const DEPLOY = path.join(TOOLS, "deploy.mjs"); + +/* ---------- 造台 ---------- */ + +function sandbox() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "zpa-")); + const ws = path.join(root, "workspace"); + const plugins = path.join(root, "plugins"); + const backups = path.join(root, "backups"); + fs.mkdirSync(ws, { recursive: true }); + fs.mkdirSync(plugins, { recursive: true }); + fs.mkdirSync(backups, { recursive: true }); + return { root, ws, plugins, backups }; +} + +/** 在工作区造一个插件(可指定版本与文件内容) */ +function makePlugin(ws, name, { version = "1.0.0", body = "export const v=1;\n", desc = true } = {}) { + const dir = path.join(ws, name); + fs.mkdirSync(path.join(dir, ".zcode-plugin"), { recursive: true }); + fs.mkdirSync(path.join(dir, "scripts"), { recursive: true }); + fs.mkdirSync(path.join(dir, "commands"), { recursive: true }); + fs.writeFileSync(path.join(dir, ".zcode-plugin", "plugin.json"), + JSON.stringify({ name, version, commands: "commands" }, null, 2)); + fs.writeFileSync(path.join(dir, "scripts", "main.mjs"), body); + fs.writeFileSync(path.join(dir, "commands", `${name}.md`), + `---\n${desc ? `description: ${name} 的命令\n` : ""}---\n\n\`\`\`bash\nnode "\${P:-$(find ~/.zcode/plugins -maxdepth 3 -name main.mjs -path '*${name}*' 2>/dev/null | head -1)}"\n\`\`\`\n`); + fs.writeFileSync(path.join(dir, "README.md"), `# ${name}\n`); + return dir; +} + +function env(sb, extra = {}) { + return { + ...process.env, + ZCODE_AUDIT_WORKSPACE: sb.ws, + ZCODE_DEPLOY_WORKSPACE: sb.ws, + ZCODE_PLUGINS_DIR: sb.plugins, + ZCODE_DEPLOY_BACKUP_DIR: sb.backups, + ...extra, + }; +} +function run(script, sb, args = [], extra = {}) { + const r = spawnSync(process.execPath, [script, ...args], + { env: env(sb, extra), encoding: "utf8", timeout: 300000 }); + return { code: r.status, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; +} + +/* ---------- audit:一致 / 漂移 ---------- */ + +test("audit:源码与副本一致时全绿退出 0", () => { + const sb = sandbox(); + makePlugin(sb.ws, "plug-a"); + fs.cpSync(path.join(sb.ws, "plug-a"), path.join(sb.plugins, "plug-a"), { recursive: true }); + + const r = run(AUDIT, sb); + assert.equal(r.code, 0, r.out); + assert.match(r.out, /✓ 与安装副本逐字节一致/); + assert.match(r.out, /结论:源码与安装副本一致/); +}); + +test("audit:改了源码没重装 → 报漂移并给出重装命令", () => { + const sb = sandbox(); + makePlugin(sb.ws, "plug-a"); + fs.cpSync(path.join(sb.ws, "plug-a"), path.join(sb.plugins, "plug-a"), { recursive: true }); + + // 源码改了内容,副本还是旧的 —— 这正是最容易踩的坑 + fs.writeFileSync(path.join(sb.ws, "plug-a", "scripts", "main.mjs"), "export const v=999;\n"); + + const r = run(AUDIT, sb); + assert.equal(r.code, 1, "有漂移必须退出 1"); + assert.match(r.out, /✗ 副本与源码有差异/); + assert.match(r.out, /内容不同:scripts\/main\.mjs/); + assert.match(r.out, /→ 重装:cp -r/); +}); + +test("audit:源码新增文件但没部署 → 报未部署", () => { + const sb = sandbox(); + makePlugin(sb.ws, "plug-a"); + fs.cpSync(path.join(sb.ws, "plug-a"), path.join(sb.plugins, "plug-a"), { recursive: true }); + fs.writeFileSync(path.join(sb.ws, "plug-a", "scripts", "extra.mjs"), "export const x=1;\n"); + + const r = run(AUDIT, sb); + assert.equal(r.code, 1); + assert.match(r.out, /未部署:scripts\/extra\.mjs/); +}); + +test("audit:版本号不一致单独报出来", () => { + const sb = sandbox(); + makePlugin(sb.ws, "plug-a", { version: "2.0.0" }); + fs.cpSync(path.join(sb.ws, "plug-a"), path.join(sb.plugins, "plug-a"), { recursive: true }); + // 只改副本的版本(模拟装的是老版) + const mf = path.join(sb.plugins, "plug-a", ".zcode-plugin", "plugin.json"); + const j = JSON.parse(fs.readFileSync(mf, "utf8")); + fs.writeFileSync(mf, JSON.stringify({ ...j, version: "1.0.0" }, null, 2)); + + const r = run(AUDIT, sb); + assert.equal(r.code, 1); + assert.match(r.out, /版本不一致:源码 v2\.0\.0 \/ 安装 v1\.0\.0/); +}); + +test("audit:工作区有、安装目录没有 → 报未安装", () => { + const sb = sandbox(); + makePlugin(sb.ws, "plug-a"); + + const r = run(AUDIT, sb); + assert.equal(r.code, 1); + assert.match(r.out, /✗ 未安装/); +}); + +test("audit:副本独有文件只警告,不判失败(可能是有意放的本地产物)", () => { + const sb = sandbox(); + makePlugin(sb.ws, "plug-a"); + fs.cpSync(path.join(sb.ws, "plug-a"), path.join(sb.plugins, "plug-a"), { recursive: true }); + fs.writeFileSync(path.join(sb.plugins, "plug-a", "local-note.txt"), "本机专用\n"); + + const r = run(AUDIT, sb); + assert.equal(r.code, 0, `副本独有不该判失败\n${r.out}`); + assert.match(r.out, /⚠ 副本与源码有差异/); + assert.match(r.out, /本地产物\(有意保留,不影响\):local-note\.txt/); +}); + +test("audit:命令文档缺 description → 报错", () => { + const sb = sandbox(); + makePlugin(sb.ws, "plug-a", { desc: false }); + fs.cpSync(path.join(sb.ws, "plug-a"), path.join(sb.plugins, "plug-a"), { recursive: true }); + + const r = run(AUDIT, sb); + assert.equal(r.code, 1); + assert.match(r.out, /缺 description/); +}); + +test("audit:文档引用了不存在的脚本 → 报错", () => { + const sb = sandbox(); + makePlugin(sb.ws, "plug-a"); + fs.writeFileSync(path.join(sb.ws, "plug-a", "commands", "plug-a.md"), + '---\ndescription: x\n---\n\n```bash\nnode "$(find ~/.zcode/plugins -name ghost.mjs -path \'*plug-a*\' | head -1)"\n```\n'); + fs.cpSync(path.join(sb.ws, "plug-a"), path.join(sb.plugins, "plug-a"), { recursive: true }); + + const r = run(AUDIT, sb); + assert.equal(r.code, 1); + assert.match(r.out, /引用脚本缺失:ghost\.mjs/); +}); + +test("audit:脚本语法错误 → 报语法失败(而不是等到运行时才炸)", () => { + const sb = sandbox(); + makePlugin(sb.ws, "plug-a", { body: "export const v = ;;;\n" }); + fs.cpSync(path.join(sb.ws, "plug-a"), path.join(sb.plugins, "plug-a"), { recursive: true }); + + const r = run(AUDIT, sb); + assert.equal(r.code, 1); + assert.match(r.out, /✗ 语法 main\.mjs/); +}); + +test("audit:--drift 只报漂移,不跑文档与语法检查", () => { + const sb = sandbox(); + makePlugin(sb.ws, "plug-a", { desc: false }); // 文档缺 description,全量模式会报错 + fs.cpSync(path.join(sb.ws, "plug-a"), path.join(sb.plugins, "plug-a"), { recursive: true }); + + const full = run(AUDIT, sb); + assert.equal(full.code, 1, "全量模式应因缺 description 失败"); + + const drift = run(AUDIT, sb, ["--drift"]); + assert.equal(drift.code, 0, `副本一致时 --drift 不该报错\n${drift.out}`); + assert.doesNotMatch(drift.out, /缺 description/); +}); + +test("audit:工作区没有插件 → 退出 1 并说明需要什么", () => { + const sb = sandbox(); + const r = run(AUDIT, sb); + assert.equal(r.code, 1); + assert.match(r.out, /未在工作区找到插件/); + assert.match(r.out, /\.zcode-plugin\/plugin\.json/); +}); + +/* ---------- deploy ---------- */ + +test("deploy --dry-run:只列将改的文件,一个字节都不落盘", () => { + const sb = sandbox(); + makePlugin(sb.ws, "plug-a"); + fs.cpSync(path.join(sb.ws, "plug-a"), path.join(sb.plugins, "plug-a"), { recursive: true }); + fs.writeFileSync(path.join(sb.ws, "plug-a", "scripts", "new.mjs"), "export const n=1;\n"); + const before = fs.readFileSync(path.join(sb.plugins, "plug-a", "scripts", "main.mjs"), "utf8"); + + const r = run(DEPLOY, sb, ["--dry-run"]); + assert.equal(r.code, 0, r.out); + assert.match(r.out, /预演/); + assert.match(r.out, /新增 1/); + assert.ok(!fs.existsSync(path.join(sb.plugins, "plug-a", "scripts", "new.mjs")), "预演不得落盘"); + assert.equal(fs.readFileSync(path.join(sb.plugins, "plug-a", "scripts", "main.mjs"), "utf8"), before); +}); + +test("deploy:真同步把漂移消掉,并留备份", () => { + const sb = sandbox(); + makePlugin(sb.ws, "plug-a"); + fs.cpSync(path.join(sb.ws, "plug-a"), path.join(sb.plugins, "plug-a"), { recursive: true }); + fs.writeFileSync(path.join(sb.ws, "plug-a", "scripts", "main.mjs"), "export const v=42;\n"); + + const r = run(DEPLOY, sb, ["--no-test"]); + assert.equal(r.code, 0, r.out); + assert.match(r.out, /## 备份/); + assert.match(r.out, /更新 1/); + assert.equal(fs.readFileSync(path.join(sb.plugins, "plug-a", "scripts", "main.mjs"), "utf8"), "export const v=42;\n"); + + // 审计应转为一致 + const after = run(AUDIT, sb); + assert.equal(after.code, 0, after.out); + + // 备份里应是改前的版本,可据此回滚 + const bks = fs.readdirSync(sb.backups).filter((d) => d.startsWith(".plugin-backups-deploy-")); + assert.equal(bks.length, 1, "应产生一个备份批次"); + const old = fs.readFileSync(path.join(sb.backups, bks[0], "plug-a", "scripts", "main.mjs"), "utf8"); + assert.equal(old, "export const v=1;\n", "备份必须是改动前的原文"); +}); + +test("deploy:只在最新时如实说无需改动,不谎报同步", () => { + const sb = sandbox(); + makePlugin(sb.ws, "plug-a"); + fs.cpSync(path.join(sb.ws, "plug-a"), path.join(sb.plugins, "plug-a"), { recursive: true }); + + const r = run(DEPLOY, sb, ["--no-test"]); + assert.equal(r.code, 0, r.out); + assert.match(r.out, /已是最新,4 个文件无需改动/); +}); + +test("deploy:不删副本里的本地产物(只增改)", () => { + const sb = sandbox(); + makePlugin(sb.ws, "plug-a"); + fs.cpSync(path.join(sb.ws, "plug-a"), path.join(sb.plugins, "plug-a"), { recursive: true }); + const keep = path.join(sb.plugins, "plug-a", "local-state.json"); + fs.writeFileSync(keep, '{"device":"this-box"}\n'); + + const r = run(DEPLOY, sb, ["--no-test"]); + assert.equal(r.code, 0, r.out); + assert.ok(fs.existsSync(keep), "副本里的本地产物不该被同步删掉"); + assert.equal(fs.readFileSync(keep, "utf8"), '{"device":"this-box"}\n'); +}); + +test("deploy:指定插件名只处理那一个", () => { + const sb = sandbox(); + makePlugin(sb.ws, "plug-a"); + makePlugin(sb.ws, "plug-b"); + for (const n of ["plug-a", "plug-b"]) fs.cpSync(path.join(sb.ws, n), path.join(sb.plugins, n), { recursive: true }); + fs.writeFileSync(path.join(sb.ws, "plug-a", "scripts", "main.mjs"), "export const v='a2';\n"); + fs.writeFileSync(path.join(sb.ws, "plug-b", "scripts", "main.mjs"), "export const v='b2';\n"); + + const r = run(DEPLOY, sb, ["plug-a", "--no-test"]); + assert.equal(r.code, 0, r.out); + assert.doesNotMatch(r.out, /plug-b.*更新/, "不该动 plug-b"); + assert.equal(fs.readFileSync(path.join(sb.plugins, "plug-b", "scripts", "main.mjs"), "utf8"), "export const v=1;\n"); +}); + +test("deploy:插件名不存在 → 明确报错", () => { + const sb = sandbox(); + makePlugin(sb.ws, "plug-a"); + const r = run(DEPLOY, sb, ["没有这个插件"]); + assert.equal(r.code, 1); + assert.match(r.out, /没找到插件:没有这个插件/); +}); + +test("deploy:测试失败时退出 1 且不谎报成功", () => { + const sb = sandbox(); + makePlugin(sb.ws, "plug-a"); + fs.cpSync(path.join(sb.ws, "plug-a"), path.join(sb.plugins, "plug-a"), { recursive: true }); + // 给副本加一个必然失败的测试(工作区没有,所以不会触发同步) + fs.mkdirSync(path.join(sb.plugins, "plug-a", "tests"), { recursive: true }); + fs.writeFileSync(path.join(sb.plugins, "plug-a", "tests", "boom.test.mjs"), + 'import {test} from "node:test";\nimport assert from "node:assert/strict";\ntest("boom", () => { assert.equal(1, 2); });\n'); + fs.writeFileSync(path.join(sb.plugins, "plug-a", "package.json"), + JSON.stringify({ name: "plug-a", type: "module", scripts: { test: 'node --test "tests/*.test.mjs"' } }, null, 2)); + + const r = run(DEPLOY, sb, [], { ZCODE_DEPLOY_BACKUP_DIR: sb.backups }); + assert.equal(r.code, 1, "测试失败必须退出 1"); + assert.match(r.out, /plug-a:✗/); + assert.match(r.out, /失败 1 项|项失败/); +}); + +/* ---------- 残留文件(源码删了、副本还在) ---------- */ + +test("audit:源码删掉的脚本在副本里残留 → 报错退出 1", () => { + const sb = sandbox(); + makePlugin(sb.ws, "plug-a"); + fs.cpSync(path.join(sb.ws, "plug-a"), path.join(sb.plugins, "plug-a"), { recursive: true }); + // 副本里多一个源码没有的脚本 —— 幽灵代码,仍会被加载 + fs.writeFileSync(path.join(sb.plugins, "plug-a", "scripts", "ghost.mjs"), "export const g=1;\n"); + + const r = run(AUDIT, sb); + assert.equal(r.code, 1, "残留脚本必须报错"); + assert.match(r.out, /源码已删除但副本还在\(残留代码仍会被加载\):scripts\/ghost\.mjs/); + assert.match(r.out, /→ 残留文件需手工删除:rm/); +}); + +test("audit:根目录的本机 state 文件不当作残留(只警告)", () => { + const sb = sandbox(); + makePlugin(sb.ws, "plug-a"); + fs.cpSync(path.join(sb.ws, "plug-a"), path.join(sb.plugins, "plug-a"), { recursive: true }); + // 操作员放的本机状态:按扩展名一刀切会被误判成"残留代码建议删除" + fs.writeFileSync(path.join(sb.plugins, "plug-a", "local-state.json"), '{"device":"this-box"}\n'); + + const r = run(AUDIT, sb); + assert.equal(r.code, 0, `本机 state 不该判失败\n${r.out}`); + assert.match(r.out, /本地产物\(有意保留,不影响\):local-state\.json/); + assert.doesNotMatch(r.out, /残留文件需手工删除/); +}); + +test("audit:自定义子目录里的本机文件不算残留", () => { + const sb = sandbox(); + makePlugin(sb.ws, "plug-a"); + fs.cpSync(path.join(sb.ws, "plug-a"), path.join(sb.plugins, "plug-a"), { recursive: true }); + fs.mkdirSync(path.join(sb.plugins, "plug-a", "data"), { recursive: true }); + fs.writeFileSync(path.join(sb.plugins, "plug-a", "data", "cache.json"), "{}\n"); + + const r = run(AUDIT, sb); + assert.equal(r.code, 0, `子目录本机文件不该判失败\n${r.out}`); + assert.match(r.out, /本地产物/); +}); + +test("audit:文档被删也算残留(命令菜单里会留个死命令)", () => { + const sb = sandbox(); + makePlugin(sb.ws, "plug-a"); + fs.cpSync(path.join(sb.ws, "plug-a"), path.join(sb.plugins, "plug-a"), { recursive: true }); + fs.rmSync(path.join(sb.ws, "plug-a", "commands", "plug-a.md")); + fs.cpSync(path.join(sb.ws, "plug-a"), path.join(sb.plugins, "plug-a"), { recursive: true, force: true }); + + const r = run(AUDIT, sb); + assert.equal(r.code, 1); + assert.match(r.out, /源码已删除但副本还在.*commands\/plug-a\.md/); +}); + +test("deploy:报告残留并给出删除命令,但不自动删(避免误删本机文件)", () => { + const sb = sandbox(); + makePlugin(sb.ws, "plug-a"); + fs.cpSync(path.join(sb.ws, "plug-a"), path.join(sb.plugins, "plug-a"), { recursive: true }); + const ghost = path.join(sb.plugins, "plug-a", "scripts", "ghost.mjs"); + fs.writeFileSync(ghost, "export const g=1;\n"); + + const r = run(DEPLOY, sb, ["--no-test"]); + assert.match(r.out, /残留 1/); + assert.match(r.out, /## 残留文件/); + assert.match(r.out, /rm .*ghost\.mjs/); + assert.ok(fs.existsSync(ghost), "不自动删 —— 交给操作者确认"); +}); + +test("deploy:同步不会碰根目录的本机文件", () => { + const sb = sandbox(); + makePlugin(sb.ws, "plug-a"); + fs.cpSync(path.join(sb.ws, "plug-a"), path.join(sb.plugins, "plug-a"), { recursive: true }); + const local = path.join(sb.plugins, "plug-a", "local-state.json"); + fs.writeFileSync(local, '{"device":"this-box"}\n'); + fs.writeFileSync(path.join(sb.ws, "plug-a", "scripts", "main.mjs"), "export const v=99;\n"); + + const r = run(DEPLOY, sb, ["--no-test"]); + assert.equal(r.code, 0, r.out); + assert.ok(fs.existsSync(local)); + assert.equal(fs.readFileSync(local, "utf8"), '{"device":"this-box"}\n'); + assert.doesNotMatch(r.out, /local-state\.json.*←.*源码已删/, "本机文件不该被报成残留"); +}); diff --git a/zcode-env-sync/.gitignore b/zcode-env-sync/.gitignore new file mode 100644 index 0000000..1b850cf --- /dev/null +++ b/zcode-env-sync/.gitignore @@ -0,0 +1,6 @@ +# 本地覆盖(不进仓):机器特有的 host/port/command +local-overrides.json +# 测试与本地产物 +tests/*.local.mjs +node_modules/ +.DS_Store diff --git a/zcode-env-sync/.zcode-plugin/plugin.json b/zcode-env-sync/.zcode-plugin/plugin.json new file mode 100644 index 0000000..b639e5f --- /dev/null +++ b/zcode-env-sync/.zcode-plugin/plugin.json @@ -0,0 +1,23 @@ +{ + "name": "zcode-env-sync", + "version": "0.3.0", + "description": "团队共享 ZCode 环境同步:导出/导入官方插件清单、自研插件源码、MCP 配置与加密密钥束,Gitea 私仓做快照后端。命令 /sync-export /sync-import /sync-status /sync-list /sync-verify /sync-prune /sync-doctor。", + "description_i18n": { + "en": "Team-share ZCode env sync: export/import official plugin list, custom plugin sources, MCP config and encrypted secrets bundle, backed by a private Gitea snapshot repo. Commands /sync-export /sync-import /sync-status /sync-list /sync-verify /sync-prune /sync-doctor." + }, + "author": { + "name": "local" + }, + "license": "MIT", + "keywords": [ + "sync", + "plugins", + "mcp", + "gitea", + "team", + "snapshot", + "backup", + "secrets" + ], + "commands": "commands" +} diff --git a/zcode-env-sync/README.md b/zcode-env-sync/README.md new file mode 100644 index 0000000..ddacdf6 --- /dev/null +++ b/zcode-env-sync/README.md @@ -0,0 +1,217 @@ +# ZCode 环境同步插件(zcode-env-sync) + +团队共享场景:一键导出 / 导入本机 ZCode 环境,默认四项全量: + +1. **官方插件清单** — marketplace 版本 + 已启用列表(`data/` 目录即启用态) +2. **自研插件源码** — `~/.zcode/plugins/*` 整体按字节打包:**你自己写的插件全在里面** + (如本插件自身、`zcode-tps`、`zcode-usage`,以后新增的也自动带上); + `node_modules`/`.git` 排除,以 `.` 开头的备份目录不算插件, + 两个插件 manifest 同名直接报错(否则快照里互相覆盖) +3. **MCP 配置** — `cli/config.json` 的 `mcp.servers` + `plugins.dirs`,路径占位符化 + (gitea 的 `-H` 地址、`GITEA_ACCESS_TOKEN_FILE` 指针都在这,身份各用各的见下) +4. **密钥束** — `~/.dsh/secrets/*` + `~/.ssh/ssh-profiles.json`,AES-256-GCM 加密后才进仓 + (含 `gitea-token.txt` 本体) + +可选第 5 项(`ZCODE_SYNC_WITH_MODELS=1`):**模型 provider 脱敏快照**,见下。 + +后端:Gitea 私仓 `root/zcode-env-sync`。 + +## 安装 + +```bash +cp -r <本目录> ~/.zcode/plugins/zcode-env-sync +# 把该路径追加进 ~/.zcode/cli/config.json 的 plugins.dirs,然后重启 ZCode +``` + +## 用法 + +| 命令 | 动作 | +|---|---| +| `/sync-export` | 导出本机快照 → 推 Gitea 私仓 | +| `/sync-import` | 从私仓拉快照 → 还原到本机(先自动备份) | +| `/sync-status` | 对比本机与远端快照差异(只读) | +| `/sync-list` | 列出远端全部快照,标出 `latest` 指向哪个(只读) | +| `/sync-verify` | 校验快照完整性:hash / 密钥束 / 占位符(只读,只比哈希不落盘) | +| `/sync-prune` | 清理远端旧快照(默认只预演,`--apply` 才真删) | +| `/sync-doctor` | 自诊断:一次查完前置条件,告诉你同步为什么不能用(只读) | + +**排查顺序**:`/sync-doctor` → `/sync-verify` → `/sync-status`。 +先说"同步不工作"就跑 doctor(缺口令、latest 解不开、追踪引用分叉都在这层); +怀疑快照坏了跑 verify(它会用当前口令试解 `secrets.enc`); +只想看差异跑 status。 + +七个命令统一用 `SYNC_PLUGIN` 自动定位脚本,**命令文档里不写死绝对路径**—— +快照要跨机器还原,写死的路径到了别的机器就是废的: + +```bash +SYNC_PLUGIN="${ZCODE_SYNC_PLUGIN:-$(find ~/.zcode/plugins -maxdepth 3 -name export.mjs -path '*env-sync*' | head -1)}" +node "$SYNC_PLUGIN" +``` + +口令:环境变量 `ZCODE_SYNC_PASSPHRASE`(团队线下约定,**不进仓**)。 +未设置或短于 8 位时脚本直接报错退出,不会用弱默认口令。 + +### 快照保留与清理 + +```bash +node prune.mjs # 预演:列出将删哪几个、各多大、共释放多少 +node prune.mjs --keep 5 # 预演,只保留最近 5 个 +node prune.mjs --keep 5 --apply # 真删并推送 +node prune.mjs --exclude <快照名> # 钉住某个快照(可重复) +``` + +永远不动:`latest.json` 指向的那个、最新的那个、`--exclude` 钉住的。 +删完若 `latest` 指向被删的快照会自动重指到存活的一个。`ZCODE_SYNC_KEEP` 设默认保留数。 + +### 快照校验 + +```bash +node verify.mjs # 校验 latest 指向的那个 +node verify.mjs <快照名> # 校验指定快照 +node verify.mjs --all # 扫全部:逐个标「结构OK / 可解密 / 口令不符」,给出能用的是哪个 +node verify.mjs --strict # 有警告也算失败(退出码 1) +``` + +`--all` 用在**团队仓里混着不同口令导出的快照**时:GCM 认证失败无法区分"口令错"与"密文损坏", +它把每个都拿当前口令试一遍,直接告诉你 `/sync-import <哪个>` 能用。 + + +## 快照结构 + +```text +snapshots/-/ + manifest.json # 清单:官方插件/自研插件 hash/MCP 名/密钥指针/provider 摘要 + mcp.servers.json # 占位符化后的 MCP 配置(明文) + plugins-dirs.json # plugins.dirs 占位符化(明文) + v2.providers.json # 仅 WITH_MODELS=1:provider 脱敏版(密钥位置是占位符) + custom-plugins/ # 自研插件源码(明文,团队内私仓) + secrets.enc # 密钥束 AES-256-GCM 密文 + secrets.manifest.json # 密钥清单(只有路径+sha256,无值) +latest.json # 指向最新快照 +``` + +## 跨机器路径还原 + +导出时把本机绝对路径替换为占位符,导入时按本机实际路径反向替换: + +| 占位符 | 含义 | +|---|---| +| `${ZCODE_HOME}` / `${ZCODE_HOME_WIN}` | `~/.zcode`(正/反斜杠两种写法) | +| `${DSH_HOME}` / `${DSH_HOME_WIN}` | `~/.dsh` | +| `${USER_HOME}` / `${USER_HOME_WIN}` | 用户主目录 | +| `${USERS_POSIX}` / `${USERS_WIN}` | `Users/<用户名>` 片段 | + +替换按 **JSON 值**逐个进行,不是对序列化文本做字符串替换—— +文本里反斜杠是转义态(`\\Users\\`),对文本替换会整片漏掉。 + +机器特有的 host/port/command 可写 `~/.zcode-env-sync/overrides.json`(**不进仓**), +导入时自动深度合并覆盖。 + +## 模型 provider(默认关) + +`~/.zcode/v2/config.json` 里通常是**活的 apiKey**,所以默认完全不碰(快照里无模型)。 +需要同步时导出导入两边都显式开: + +```bash +ZCODE_SYNC_WITH_MODELS=1 node export.mjs # 导出:脱敏结构明文 + 真密钥进加密束 +ZCODE_SYNC_WITH_MODELS=1 node import.mjs # 导入:结构合并 + 密钥回填 +``` + +导出:`v2.providers.json` 脱敏版明文(密钥位置是 `${MODEL_SECRET_REF}`), +真密钥以 `MODELS//` 条目并入 `secrets.enc`; +manifest 只记摘要 + 密钥哈希(`modelKeys`),不记值。 + +导入(`ZCODE_SYNC_WITH_MODELS=1` 时才动 `v2/config.json`,先备份): + +- 快照有、本机无的 provider:整体新增 +- 已有 provider:只补缺的模型定义与非密钥字段,不碰本机已有密钥 +- 密钥回填:只填**空位**(占位符/空/过短);本机已有密钥默认跳过保护, + 确需覆盖再加 `ZCODE_SYNC_OVERWRITE_MODEL_SECRETS=1` +- 选中态(当前用哪个 provider/model)默认不动, + 加 `ZCODE_SYNC_APPLY_MODEL_SELECTION=1` 才应用(同样先备份 `v2/setting.json`) + +| 变量 | 说明 | +|---|---| +| `ZCODE_SYNC_OVERWRITE_MODEL_SECRETS=1` | 允许用快照密钥覆盖本机已有密钥(默认跳过保护) | + +## 内置闸门 + +导出过程有数道自检,命中即**中断且不推送**: + +| 闸门 | 行为 | +|---|---| +| 明文区出现真密钥值 | 硬失败(扫全部非 `secrets.enc` 文件) | +| 配置区残留未占位符化的绝对路径 | 硬失败(按解析后的值判断,不限于本机用户名) | +| provider 脱敏不彻底 | 硬失败 | +| 自研插件 manifest 同名 | 硬失败(否则快照里互相覆盖) | +| 自研插件源码里的硬编码路径 | 警告并列出文件(源码按字节归档,不擅自改写) | + +放行开关:`ZCODE_SYNC_ALLOW_SECRETS`(逗号分隔路径白名单)、`ZCODE_SYNC_ALLOW_PATH_LEAK=1`。 + +## 环境变量 + +| 变量 | 默认 | 说明 | +|---|---|---| +| `ZCODE_SYNC_PASSPHRASE` | — | **必填**,加密/解密密钥束 | +| `ZCODE_SYNC_REPO` | `root/zcode-env-sync` | 私仓坐标 | +| `ZCODE_SYNC_HOST` | `https://gitea.mymoyu.top` | 托管地址 | +| `ZCODE_SYNC_WORK` | `~/.zcode-env-sync` | 本地工作区(repo clone + backup) | +| `ZCODE_SYNC_WITH_MODELS` | 关 | `1` 时连带同步 provider(脱敏) | +| `ZCODE_SYNC_APPLY_MODEL_SELECTION` | 关 | `1` 时导入应用模型选中态 | +| `ZCODE_SYNC_SKIP_SECRETS` | 空 | 逗号分隔的密钥 rel,导入时跳过不落盘(只报告)。团队场景:连接方式统一、身份各用各的。例:`ZCODE_SYNC_SKIP_SECRETS='DSH_SECRETS/gitea-token.txt'`(每人保留自己的 gitea token,只同步 `-H` 地址等配置);模型密钥用 `MODELS//` 格式 | +| `ZCODE_SYNC_NO_PUSH` | 关 | `1` 只落本地,不推远端 | +| `ZCODE_SYNC_GIT_REMOTE` | 自动 | 覆盖远端地址(测试用) | +| `ZCODE_SYNC_APPLY_OFFICIAL` | 开 | `0` 跳过官方插件差异报告 | +| `ZCODE_SYNC_KEEP` | `10` | `/sync-prune` 默认保留数量 | +| `ZCODE_SYNC_VERIFY_STRICT` | 关 | `1` 等价于 `verify.mjs --strict`(有警告即失败) | +| `ZCODE_SYNC_ALLOW_PATH_LEAK` | 关 | `1` 放行路径残留自检(确认无害时才用) | + +## 换行符与字节一致性 + +快照是**字节级归档**:仓库里锁了 `.gitattributes`(`* -text`)并把本仓 +`core.autocrlf` 设为 `false`。否则 Windows 上常见的 `core.autocrlf=true` +会在 add/checkout 时改写 CRLF,导致 manifest 里的 hash 与克隆下来的内容对不上, +`/sync-status` 会永久报「版本不同」。 + +## 测试 + +```bash +npm test # 63 项:单元 + 沙箱 E2E + 新命令(prune/verify/doctor/list) +npm run test:core # 纯函数单测(35 项) +npm run test:e2e # 沙箱端到端(11 项,伪造两台机器 + 本地裸仓) +npm run test:tools # 新命令(17 项:prune/verify/doctor/list/推送隔离) +node tests/live-roundtrip.mjs # 真机往返(需私仓已建 + 口令) +``` + +E2E 用 `USERPROFILE` 把 HOME 重定向到临时目录、用本地裸仓冒充 Gitea, +全程不碰真实 HOME、不走网络。覆盖:跨机路径还原、密钥解密落地、覆盖前备份、 +密钥/路径泄漏闸门、CRLF 字节完整性、provider 脱敏与密钥跨机回填; +tools 那组覆盖 prune 保留策略与 latest 重指、verify 的 hash 篡改检测与口令不符分支、 +doctor 的三种 fail 场景、status --list、以及**推送隔离** +(`git add` 只加本次快照,不把别的会话未推送的快照连带推上去)。 + +## 故障排查 + +**先说一句**:跑 `/sync-doctor`,它把这页大部分症状直接指出来并给修复命令。 + +| 现象 | 原因与处理 | +|---|---| +| `未设置 ZCODE_SYNC_PASSPHRASE` | 先 `export ZCODE_SYNC_PASSPHRASE='...'`(≥8 位) | +| `密钥束解密失败:口令不对或文件损坏` | 双方口令不一致,或 `secrets.enc` 被改过。先 `node verify.mjs --all` 看当前口令能用哪版 | +| `推送失败` | 私仓没建 / token 无写权限;token 读 `~/.dsh/secrets/gitea-token.txt` | +| token 权限不足建仓 | Gitea 个人建仓需 `write:user`;管理员可用 `POST /api/v1/admin/users//repos` | +| `自研插件名冲突` | 两个插件的 `plugin.json` 里 `name` 相同,改名或移走一个 | +| status 一直报「版本不同」 | 检查仓库 `.gitattributes` 是否为 `* -text`、`core.autocrlf` 是否为 `false` | +| `配置区残留未占位符化的绝对路径` | 配置里有写死的绝对路径,改成占位符或确认后设 `ZCODE_SYNC_ALLOW_PATH_LEAK=1` | +| `HEAD != origin/main` | 本地与远端分叉,先 `/sync-status` 看差异再决定推还是拉 | +| 快照太多占空间 | `node prune.mjs --keep 10` 预演,确认后加 `--apply` | +| 导出报告「落下 N 项非本次内容」 | 正常:仓库里有别人的未推送内容,`git add` 只加本次快照。那些文件仍在本地,不会丢 | +| `latest` 指向的快照解不开 | 该快照可能用了别的口令。`node verify.mjs --all` 找出当前口令能用的版本,再 `/sync-import <快照名>` | + +## 安全说明 + +- 除 `secrets.enc` 外不含任何密钥明文;导出时有脱敏扫描,命中即中断 +- git 网络操作使用带 token 的临时 URL,`origin` 只存干净地址,token **不落** `.git/config` +- 密钥还原时拒绝目录穿越与绝对路径,权限设为 `600` +- 私仓保持 **private**;换口令即换 `ZCODE_SYNC_PASSPHRASE` 后重导 +- 导入前自动备份被覆盖文件到 `~/.zcode-env-sync/backup/<时间>/`,报告里给出实际路径 diff --git a/zcode-env-sync/commands/sync-doctor.md b/zcode-env-sync/commands/sync-doctor.md new file mode 100644 index 0000000..3dc1858 --- /dev/null +++ b/zcode-env-sync/commands/sync-doctor.md @@ -0,0 +1,30 @@ +--- +description: 自诊断 —— 一次查完所有前置条件,定位同步为什么不能用 +--- + +用 Bash(Windows 用 git-bash)运行诊断脚本(只读,不改任何东西): + +```bash +export ZCODE_SYNC_PASSPHRASE='团队口令' # 给了才能验证"latest 能否解密" +SYNC_PLUGIN="${ZCODE_SYNC_PLUGIN:-$(find ~/.zcode/plugins -maxdepth 3 -name doctor.mjs -path '*env-sync*' 2>/dev/null | head -1)}" +node "$SYNC_PLUGIN" +``` + +二十多项检查(条数随环境变化),每项给 `✓ ok` / `⚠ warn` / `✗ fail`,失败项附**可执行的修复建议**: + +- 运行时:Node 版本、git 是否可用 +- 凭据:同步口令是否设置且够长、Gitea token 是否读到 +- 仓库:工作区是否已克隆、`origin` 是否内嵌凭据(会明文留在 `.git/config`)、 + 换行符锁定(`.gitattributes: * -text` + `core.autocrlf=false`,否则 hash 会漂移)、 + `HEAD` 与 `origin/main` 是否一致、有无未提交改动 +- 快照:总数与总体积、manifest 是否损坏、`latest` 指向是否有效、**当前口令能否解开 latest** +- 环境:启用插件数、自研插件、本插件版本、MCP server 数、模型 provider(几个带密钥)、备份目录 + +退出码:有 `fail` 时为 1。 + +用法场景:**用户说"同步不工作/导入失败/推送失败/status 报版本不同"时,先跑这个**, +再把报告里的 `fail` 项与修复建议照实告诉用户,不要自己猜。 + +**把脚本输出原样展示给用户**。 + +用户附加要求:$ARGUMENTS diff --git a/zcode-env-sync/commands/sync-export.md b/zcode-env-sync/commands/sync-export.md new file mode 100644 index 0000000..2320912 --- /dev/null +++ b/zcode-env-sync/commands/sync-export.md @@ -0,0 +1,32 @@ +--- +description: 导出本机 ZCode 环境快照并推送到 Gitea 私仓(root/zcode-env-sync) +--- + +用 Bash(Windows 用 git-bash)运行导出脚本。口令必须先设置(团队线下约定,**不要进仓**): + +```bash +export ZCODE_SYNC_PASSPHRASE='团队口令' +SYNC_PLUGIN="${ZCODE_SYNC_PLUGIN:-$(find ~/.zcode/plugins -maxdepth 3 -name export.mjs -path '*env-sync*' 2>/dev/null | head -1)}" +node "$SYNC_PLUGIN" $ARGUMENTS +``` + +`SYNC_PLUGIN` 从本机插件目录自动定位,不写死路径(快照要跨机器还原, +写死路径到了别的机器就是废的)。也可以直接设 `ZCODE_SYNC_PLUGIN=/绝对/路径/export.mjs`。 + +可选参数(环境变量): + +- `ZCODE_SYNC_REPO=root/zcode-env-sync` 私仓坐标 +- `ZCODE_SYNC_HOST=https://gitea.mymoyu.top` 托管地址 +- `ZCODE_SYNC_WORK=~/.zcode-env-sync` 本地工作区(含 repo clone 与备份) +- `ZCODE_SYNC_WITH_MODELS=1` 连带同步模型 provider(默认关;开启时**只写脱敏版**, + 真密钥走加密束) —— 覆盖默认 4 项 +- `ZCODE_SYNC_NO_PUSH=1` 只落本地不推远端(离线排查用) +- `ZCODE_SYNC_ALLOW_SECRETS='$.路径'` 逗号分隔,放行已知误报的密钥扫描 +- `ZCODE_SYNC_ALLOW_PATH_LEAK=1` 放行路径残留自检(确认无害时才用) + +导出过程自带两道闸门,命中即中断且**不推送**:明文区出现真密钥值、明文区残留本机路径。 + +**把脚本输出原样展示给用户**,包含快照名、推送结果、各项统计。 +失败时把报错和缺什么说清楚,不要自己编快照名。 + +用户附加要求:$ARGUMENTS diff --git a/zcode-env-sync/commands/sync-import.md b/zcode-env-sync/commands/sync-import.md new file mode 100644 index 0000000..20ce41b --- /dev/null +++ b/zcode-env-sync/commands/sync-import.md @@ -0,0 +1,32 @@ +--- +description: 从 Gitea 私仓拉快照还原到本机(先自动备份) +--- + +用 Bash(Windows 用 git-bash)运行导入脚本。口令必须先设置(解密密钥束用): + +```bash +export ZCODE_SYNC_PASSPHRASE='团队口令' +SYNC_PLUGIN="${ZCODE_SYNC_PLUGIN:-$(find ~/.zcode/plugins -maxdepth 3 -name import.mjs -path '*env-sync*' 2>/dev/null | head -1)}" +node "$SYNC_PLUGIN" [快照名] $ARGUMENTS +``` + +`SYNC_PLUGIN` 从本机插件目录自动定位,不写死路径。 +也可以直接设 `ZCODE_SYNC_PLUGIN=/绝对/路径/import.mjs`。 + +- 快照名缺省用远端 `latest.json` 指向的最新快照 +- 可用快照名形如 `主机名-20260911-153000`,先 `/sync-status` 看远端有哪些 +- 还原前自动备份被覆盖文件到 `~/.zcode-env-sync/backup/<时间>/`,报告里给出实际路径 +- 官方插件只报告差异(缺几项、分别是什么),**不自动安装**,去插件市场对照启用后重启 +- 机器特有路径(host/port/command)可写 `~/.zcode-env-sync/overrides.json`(不进仓),导入自动合并 +- 每人身份不同(token 各用各的)时加 `ZCODE_SYNC_SKIP_SECRETS='DSH_SECRETS/gitea-token.txt'`, + 只同步连接配置不盖 token;模型密钥同理用 `MODELS//` 格式 +- 模型 provider 只报告不覆盖本机 `v2/config.json`(里面有活的 apiKey); + 确需应用要显式开 `ZCODE_SYNC_WITH_MODELS=1`,此时:脱敏结构合并(只增不减)、 + 密钥按 `MODELS//` 回填(只填空位,本机已有密钥默认跳过保护; + 确需覆盖再加 `ZCODE_SYNC_OVERWRITE_MODEL_SECRETS=1`);选中态(当前用哪个模型) + 默认不动,加 `ZCODE_SYNC_APPLY_MODEL_SELECTION=1` 才应用 +- `ZCODE_SYNC_APPLY_OFFICIAL=0` 可跳过官方插件差异报告 + +**把脚本输出原样展示给用户**。失败时把报错和缺什么说清楚。 + +用户附加要求:$ARGUMENTS diff --git a/zcode-env-sync/commands/sync-list.md b/zcode-env-sync/commands/sync-list.md new file mode 100644 index 0000000..5970568 --- /dev/null +++ b/zcode-env-sync/commands/sync-list.md @@ -0,0 +1,19 @@ +--- +description: 列出远端全部快照(只读),含体积/内容概要与 latest 标记 +--- + +用 Bash(Windows 用 git-bash)运行状态脚本加 `--list`(只读,不改本机,不需要口令): + +```bash +SYNC_PLUGIN="${ZCODE_SYNC_PLUGIN:-$(find ~/.zcode/plugins -maxdepth 3 -name status.mjs -path '*env-sync*' 2>/dev/null | head -1)}" +node "$SYNC_PLUGIN" --list +``` + +输出每个快照的:名字、来源主机与时间、体积、官方启用数/自研插件(含版本)/MCP 数/密钥数/模型 provider 数。 +`*` 标出 `latest.json` 指向的那个 —— `/sync-import` 不带参数时用的就是它。 + +用户问"有哪些快照/能回到哪个版本"时用这条;要看**本机与最新快照的差异**用 `/sync-status`(不带 `--list`)。 + +**把脚本输出原样展示给用户**。 + +用户附加要求:$ARGUMENTS diff --git a/zcode-env-sync/commands/sync-prune.md b/zcode-env-sync/commands/sync-prune.md new file mode 100644 index 0000000..d0dab68 --- /dev/null +++ b/zcode-env-sync/commands/sync-prune.md @@ -0,0 +1,27 @@ +--- +description: 清理远端旧快照(默认只预演,加 --apply 才真删) +--- + +用 Bash(Windows 用 git-bash)运行清理脚本: + +```bash +SYNC_PLUGIN="${ZCODE_SYNC_PLUGIN:-$(find ~/.zcode/plugins -maxdepth 3 -name prune.mjs -path '*env-sync*' 2>/dev/null | head -1)}" +node "$SYNC_PLUGIN" [--keep N] [--apply] [--exclude <快照名>] +``` + +**默认只预演(dry-run),不删任何东西**;要真删必须显式加 `--apply`。 + +- `--keep N` 保留最近 N 个(默认 10,也可用 `ZCODE_SYNC_KEEP` 设) +- `--exclude <快照名>` 额外钉住某个快照(可重复);`--exclude=X` 亦可 +- 永远不动:`latest.json` 指向的那个、最新的那个、你钉住的那些 +- 删完若 `latest.json` 指向被删的快照,会自动重指到一个还活着的 +- `ZCODE_SYNC_NO_PUSH=1` 只本地删不推远端(排查用) + +**先跑一次不带 `--apply` 的,把预演结果给用户确认**(会列出删哪几个、各多大、共释放多少), +用户确认后再加 `--apply` 重跑。不要不问就删。 + +清理会真的删掉远端历史,**这是不可逆的**;用户若没明确说"删",就只跑预演并告诉他加上 `--apply` 才会删。 + +**把脚本输出原样展示给用户**。 + +用户附加要求:$ARGUMENTS diff --git a/zcode-env-sync/commands/sync-status.md b/zcode-env-sync/commands/sync-status.md new file mode 100644 index 0000000..2c39890 --- /dev/null +++ b/zcode-env-sync/commands/sync-status.md @@ -0,0 +1,20 @@ +--- +description: 对比本机与远端最新快照差异(只读) +--- + +用 Bash(Windows 用 git-bash)运行状态脚本(只读,不改本机,不需要口令): + +```bash +SYNC_PLUGIN="${ZCODE_SYNC_PLUGIN:-$(find ~/.zcode/plugins -maxdepth 3 -name status.mjs -path '*env-sync*' 2>/dev/null | head -1)}" +node "$SYNC_PLUGIN" $ARGUMENTS +``` + +`SYNC_PLUGIN` 从本机插件目录自动定位,不写死路径。 + +输出各项对比:官方插件启用差异、自研插件版本差异、MCP server 名差异、密钥束清单差异。 +若快照带模型 provider,还会有一行 provider 差异(只比 id/模型清单)。 +密钥只比清单 hash,不读值、不解密。 + +**把脚本输出原样展示给用户**。 + +用户附加要求:$ARGUMENTS diff --git a/zcode-env-sync/commands/sync-verify.md b/zcode-env-sync/commands/sync-verify.md new file mode 100644 index 0000000..97d5002 --- /dev/null +++ b/zcode-env-sync/commands/sync-verify.md @@ -0,0 +1,31 @@ +--- +description: 校验快照完整性(hash/密钥束/占位符),导入前确认没坏 +--- + +用 Bash(Windows 用 git-bash)运行校验脚本: + +```bash +export ZCODE_SYNC_PASSPHRASE='团队口令' # 要解密密钥束就得给 +SYNC_PLUGIN="${ZCODE_SYNC_PLUGIN:-$(find ~/.zcode/plugins -maxdepth 3 -name verify.mjs -path '*env-sync*' 2>/dev/null | head -1)}" +node "$SYNC_PLUGIN" [快照名] [--all] [--strict] [--quiet] +``` + +参数: + +- 不带参数 → 校验 `latest.json` 指向的快照 +- `[快照名]` → 校验指定快照 +- `--all` → 扫全部快照,逐个标出「结构OK / 可解密 / 口令不符」。**团队仓里混着不同口令导出的快照时用这个**——它会告诉你当前口令能用的是哪几个,并给出 `/sync-import <可用快照名>` +- `--strict` → 有警告也算失败(退出码 1);等价于 `ZCODE_SYNC_VERIFY_STRICT=1` +- `--quiet` → 只打汇总,不列通过明细 + +校验内容:必备文件是否齐全、自研插件目录 hash 与 manifest 是否一致、密钥束能否用当前口令解密、 +密钥清单 hash 是否对得上、配置区有没有残留未占位符化的绝对路径、manifest 自洽性。 + +**只比哈希、不落地、不打印任何密钥值**。退出码:有致命错误为 1。 + +导入前跑一次最稳;`/sync-import` 失败(尤其"解密失败")时也先跑这个定位。 +`--all` 与 `--strict` 会让脚本退出码非 0,那是**校验结果**,不是脚本坏了 —— 照报告里的建议做。 + +**把脚本输出原样展示给用户**。 + +用户附加要求:$ARGUMENTS diff --git a/zcode-env-sync/package.json b/zcode-env-sync/package.json new file mode 100644 index 0000000..f89d733 --- /dev/null +++ b/zcode-env-sync/package.json @@ -0,0 +1,22 @@ +{ + "name": "zcode-env-sync", + "version": "0.3.0", + "private": true, + "type": "module", + "description": "团队共享 ZCode 环境同步:官方插件清单 / 自研插件源码 / MCP 配置 / 加密密钥束,Gitea 私仓做快照后端。", + "scripts": { + "test": "node --test \"tests/*.test.mjs\"", + "test:core": "node --test tests/core.test.mjs", + "test:e2e": "node --test tests/e2e.test.mjs", + "test:tools": "node --test tests/tools.test.mjs", + "export": "node scripts/export.mjs", + "import": "node scripts/import.mjs", + "status": "node scripts/status.mjs", + "verify": "node scripts/verify.mjs", + "prune": "node scripts/prune.mjs", + "doctor": "node scripts/doctor.mjs" + }, + "engines": { + "node": ">=18" + } +} diff --git a/zcode-env-sync/scripts/doctor.mjs b/zcode-env-sync/scripts/doctor.mjs new file mode 100644 index 0000000..4bc034d --- /dev/null +++ b/zcode-env-sync/scripts/doctor.mjs @@ -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("结论:一切就绪。"); +} diff --git a/zcode-env-sync/scripts/export.mjs b/zcode-env-sync/scripts/export.mjs new file mode 100644 index 0000000..ec8211e --- /dev/null +++ b/zcode-env-sync/scripts/export.mjs @@ -0,0 +1,239 @@ +#!/usr/bin/env node +/** + * /sync-export:导出本机快照 → 推 Gitea 私仓。 + * 用法: ZCODE_SYNC_PASSPHRASE='口令' node export.mjs + */ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + HOME, USER, HOSTNAME, CLI_CONFIG, PLUGINS_HOME, REPO_DIR, V2_CONFIG, + nowTag, readJson, writeJson, encodeJsonPaths, encodePaths, fwd, + officialPlugins, enabledPlugins, enabledPluginsDetailed, customPlugins, copyDirFiltered, hashDir, + secretTargets, secretsManifest, buildSecretsBundle, encryptSecrets, + requirePassphrase, scanForSecrets, ensureRepo, gitPush, dirSize, humanSize, + collectModelSecrets, sanitizeProviders, summarizeProviders, modelSelection, + extractModelSecrets, modelKeyManifest, WITH_MODELS, APPLY_MODEL_SELECTION, +} from "./sync-core.mjs"; + +function fail(e) { console.error(`导出失败:${e?.message ?? e}`); process.exitCode = 1; } + +try { + const pass = requirePassphrase(); + ensureRepo(); + + const official = officialPlugins(); + const enabled = enabledPlugins(); + const enabledDetailed = enabledPluginsDetailed(); + const custom = customPlugins(); + const cfg = readJson(CLI_CONFIG, {}); + const mcpServers = cfg?.mcp?.servers ?? {}; + const pluginsDirs = cfg?.plugins?.dirs ?? []; + + // 路径占位符化(跨机器)——按值处理,反斜杠路径才不会被漏掉 + const mcpEncoded = encodeJsonPaths(mcpServers); + const dirsEncoded = encodeJsonPaths(pluginsDirs); + + // 脱敏扫描:明文区不得出现疑似密钥值 + const hits = scanForSecrets({ mcpServers: mcpEncoded }); + if (hits.length) { + throw new Error(`明文区疑似含密钥(${hits.slice(0, 8).join(",")}),已中断。请把该 server 的敏感值改走 *_FILE/密钥文件引用,或先处理再重导`); + } + + // 占位符化完整性自检:编码后不应再残留本机主目录/用户名(否则跨机器还原会指到别人家) + const txt = JSON.stringify(mcpEncoded) + JSON.stringify(dirsEncoded); + const leaks = []; + if (txt.includes(`Users/${USER}`) || txt.includes(`Users\\${USER}`)) leaks.push(`用户名 ${USER}`); + const homeF = fwd(path.join(os.homedir())); + if (homeF && txt.includes(homeF)) leaks.push(`主目录 ${homeF}`); + if (leaks.length) { + const msg = `占位符化不完整,明文区仍含 ${leaks.join("、")}(该快照还原到别的机器会指错路径)`; + if (process.env.ZCODE_SYNC_ALLOW_PATH_LEAK === "1") console.warn(`⚠ ${msg}(ZCODE_SYNC_ALLOW_PATH_LEAK=1 已忽略)`); + else throw new Error(`${msg}。确认这些字段是路径残留后,可设 ZCODE_SYNC_ALLOW_PATH_LEAK=1 强制导出`); + } + + // 密钥束(文件类先装,模型密钥随后并入,最后统一加密) + const secrets = secretTargets(); + const bundle = buildSecretsBundle(secrets); + + /* + * 模型 provider:v2/config.json 里全是活的 apiKey,默认绝不整体复制。 + * 开启 ZCODE_SYNC_WITH_MODELS=1 时:脱敏结构写明文,真值以 MODELS// + * 条目并入加密束;关闭时模型完全不进快照。导出后还有「密钥零残留」自检兜底。 + */ + const v2cfg = WITH_MODELS ? readJson(V2_CONFIG, null) : null; + if (WITH_MODELS && !v2cfg) throw new Error(`ZCODE_SYNC_WITH_MODELS=1 但读不到 ${V2_CONFIG}`); + const providerSummary = v2cfg ? summarizeProviders(v2cfg) : []; + const providerSelection = v2cfg ? modelSelection() : null; + const providerSecrets = v2cfg ? collectModelSecrets(v2cfg) : []; + const providerSanitized = v2cfg ? sanitizeProviders(v2cfg) : { providers: {}, withSecrets: [] }; + const modelKeys = v2cfg ? extractModelSecrets(v2cfg) : []; + const modelKeysManifest = modelKeyManifest(modelKeys); + let modelKeyCount = 0; + if (v2cfg) { + // 脱敏结果里不允许再有真密钥(helper 漏了就直接停,别把明文推上去) + const left = scanForSecrets({ providers: providerSanitized.providers }); + if (left.length) throw new Error(`provider 脱敏不彻底(${left.slice(0, 5).join(",")}),已中断`); + for (const { provider, key, value } of modelKeys) { + bundle.files.push({ + rel: `MODELS/${provider}/${key}`, + contentB64: Buffer.from(String(value), "utf8").toString("base64"), + }); + modelKeyCount++; + } + } + const enc = encryptSecrets(bundle, pass); + + const tag = `${HOSTNAME}-${nowTag()}`; + const snapDir = path.join(REPO_DIR, "snapshots", tag); + fs.mkdirSync(snapDir, { recursive: true }); + + const manifest = { + version: 1, snapshot: `snapshots/${tag}`, + host: HOSTNAME, createdAt: new Date().toISOString(), + // 占位符而不是本机绝对路径:manifest 是明文进仓,不该带出用户名 + zcodeHomeTemplate: "${ZCODE_HOME}", + zcodeHomeExportedFrom: encodePaths(fwd(path.join(os.homedir(), ".zcode"))), + official, enabled, + // inline 插件原本查不到版本,这里补全(市场插件查清单,inline 读本机插件目录) + enabledDetailed, + custom: custom.map((c) => ({ name: c.name, version: c.version, hash: hashDir(c.dir) })), + mcpServerNames: Object.keys(mcpServers), + secretFiles: secretsManifest(secrets).map(({ rel, sha256, bytes }) => ({ rel, sha256, bytes })), + }; + if (v2cfg) { + Object.assign(manifest, { + modelProviders: providerSummary, + modelSelection: providerSelection, + modelSelectionApplied: APPLY_MODEL_SELECTION, + // 只记哪些 provider 带密钥+密钥哈希,不记值;密钥值在加密束 MODELS/ 条目里 + modelProvidersWithSecrets: providerSanitized.withSecrets, + modelKeys: modelKeysManifest, + modelKeyCount, + }); + } + // manifest 与 latest.json 都留到自检通过后再落盘:失败时不留半个快照 + writeJson(path.join(snapDir, "mcp.servers.json"), mcpEncoded); + writeJson(path.join(snapDir, "plugins-dirs.json"), dirsEncoded); + if (v2cfg) writeJson(path.join(snapDir, "v2.providers.json"), encodeJsonPaths(providerSanitized.providers)); + fs.writeFileSync(path.join(snapDir, "secrets.enc"), enc); + writeJson(path.join(snapDir, "secrets.manifest.json"), manifest.secretFiles); + + const cpRoot = path.join(snapDir, "custom-plugins"); + for (const c of custom) copyDirFiltered(c.dir, path.join(cpRoot, c.name)); + + /* ---- 写后自检:明文区不得出现任何真实密钥值或本机路径 ---- */ + const walkFiles = (dir, acc = []) => { + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, e.name); + if (e.isDirectory()) walkFiles(p, acc); + else if (e.name !== "secrets.enc") acc.push(p); // 密文不必扫 + } + return acc; + }; + const snapFiles = walkFiles(snapDir); + const readAll = (files) => files.map((f) => { try { return fs.readFileSync(f, "utf8"); } catch { return ""; } }).join("\n"); + // manifest 还没落盘,单独把它的内容并入扫描范围(它同样是明文进仓) + const pendingManifestText = JSON.stringify(manifest); + + // 密钥:全覆盖,命中即硬失败 + const plainText = readAll(snapFiles) + "\n" + pendingManifestText; + const leakedSecrets = [...providerSecrets, ...secrets.map((s) => { + try { return fs.readFileSync(s.abs, "utf8").trim(); } catch { return ""; } + })].filter((v) => v && v.length >= 8 && plainText.includes(v)); + if (leakedSecrets.length) { + throw new Error(`明文区检测到 ${leakedSecrets.length} 个真实密钥值(已中断,快照未推送)。` + + `密钥只能出现在 secrets.enc 里,请检查脱敏逻辑`); + } + + // 口令本体:上面那轮只按「密钥文件的内容」比对,而口令来自环境变量、不在密钥清单里, + // 于是源码里写死的口令字面量会整个漏网 —— 钥匙跟着保险箱一起进仓,加密就白做了。 + // 这条不设放行开关:命中说明该口令已等于公开值,正确处置是换口令,不是忽略。 + if (plainText.includes(pass)) { + throw new Error(`明文区检测到同步口令本体(已中断,快照未推送)。` + + `口令是 secrets.enc 的钥匙,随快照进仓等于加密失效,且 git 历史删不干净。` + + `请把写死该口令的文件改成从 ZCODE_SYNC_PASSPHRASE 读取,并更换团队口令后重导`); + } + + // 路径:配置区(本应占位符化)残留即硬失败;插件源码是字节级归档,只警告 + // 不限于本机用户名:任何未占位符化的 `Users/<某用户>` 到了别的机器都是错路径。 + // 注意按「解析后的值」判断:JSON 里反斜杠是转义态(`\\Users\\`),对文本做正则会漏。 + const USER_PATH_RE = /[\\/]Users[\\/][^\\/"\s]+/; + const HOME_ABS = fwd(path.join(os.homedir(), ".zcode")); + const leakyStrings = (node, out = []) => { + if (typeof node === "string") { out.push(node); return out; } + if (Array.isArray(node)) return node.forEach((v) => leakyStrings(v, out)), out; + if (node && typeof node === "object") { + for (const [k, v] of Object.entries(node)) { leakyStrings(k, out); leakyStrings(v, out); } + } + return out; + }; + /** 一段文本里是否还有未占位符化的绝对路径 */ + const textLeaks = (body, isJson) => { + let strs; + if (isJson) { + try { strs = leakyStrings(JSON.parse(body)); } catch { strs = [body]; } // 解析不了就退回文本判断 + } else { + strs = [body]; + } + return strs.some((s) => USER_PATH_RE.test(s) || fwd(s).includes(HOME_ABS)); + }; + /** 文件里是否还有未占位符化的绝对路径 */ + const fileLeaks = (f) => { + let body; try { body = fs.readFileSync(f, "utf8"); } catch { return false; } + return textLeaks(body, f.endsWith(".json")); + }; + const cfgFiles = snapFiles.filter((f) => !f.includes(`${path.sep}custom-plugins${path.sep}`)); + const cfgHits = cfgFiles.filter(fileLeaks).concat( + textLeaks(pendingManifestText, true) ? ["manifest.json"] : []); + if (cfgHits.length && process.env.ZCODE_SYNC_ALLOW_PATH_LEAK !== "1") { + throw new Error(`配置区残留未占位符化的绝对路径(已中断,快照未推送):` + + `${cfgHits.map((f) => (f.startsWith("manifest") ? f : path.relative(snapDir, f))).slice(0, 5).join(", ")}。` + + `这些路径还原到别的机器会指错位置,确认无害可设 ZCODE_SYNC_ALLOW_PATH_LEAK=1`); + } + const srcFiles = snapFiles.filter((f) => f.includes(`${path.sep}custom-plugins${path.sep}`)); + const pathWarnings = srcFiles.filter(fileLeaks).map((f) => path.relative(snapDir, f)); + + /* ---- 自检通过,现在才落 manifest + latest.json ---- */ + // 两遍算体积:第二遍把 manifest 自身算进去,prune 才能拿到准数 + manifest.bytes = dirSize(snapDir); + writeJson(path.join(snapDir, "manifest.json"), manifest); + manifest.bytes = dirSize(snapDir); + writeJson(path.join(snapDir, "manifest.json"), manifest); + + const ex = path.join(REPO_DIR, "local-overrides.example.json"); + if (!fs.existsSync(ex)) { + writeJson(ex, { + _comment: "本机覆盖(不进仓):拷到 ~/.zcode-env-sync/overrides.json,导入时合并", + mcp: { servers: { "": { "command": "本机 node 路径", "args": ["..."] } } }, + }); + } + writeJson(path.join(REPO_DIR, "latest.json"), { + snapshot: `snapshots/${tag}`, host: HOSTNAME, createdAt: manifest.createdAt, + bytes: manifest.bytes, enabledCount: enabled.length, customCount: custom.length, + }); + // 只提交本次快照 + latest.json:不做 add -A,免得把别的会话未推送的快照连带推上去 + const pushMsg = gitPush( + `sync-export ${tag} (official:${official.length} custom:${custom.length} mcp:${Object.keys(mcpServers).length} secrets:${secrets.length}${v2cfg ? ` providers:${providerSummary.length}` : ""})`, + { paths: [path.posix.join("snapshots", tag), "latest.json", ".gitattributes", ".gitignore", "local-overrides.example.json"] }); + + console.log(`# 导出成功`); + console.log(`- 快照:${tag}`); + console.log(`- 官方插件:${official.length} 项(清单),已启用:${enabled.length} 项(${enabled.join(", ") || "—"})`); + console.log(`- 自研插件:${custom.length} 项(${custom.map((c) => `${c.name}@${c.version}`).join(", ") || "—"})`); + console.log(`- MCP:${Object.keys(mcpServers).join(", ") || "—"}`); + console.log(`- 密钥束:${secrets.length} 个文件(已加密)`); + if (v2cfg) { + console.log(`- 模型 provider:${providerSummary.length} 个(脱敏结构明文),密钥 ${modelKeyCount} 个已入加密束`); + console.log(`- 模型选中态:${APPLY_MODEL_SELECTION ? "已随快照应用" : "仅记录,导入时不覆盖本机"}`); + } + console.log(`- 明文区自检:无密钥泄漏、配置区无本机路径残留`); + console.log(`- 快照体积:${humanSize(manifest.bytes)}`); + if (pathWarnings.length) { + console.log(`- ⚠ 自研插件源码里有 ${pathWarnings.length} 个文件含本机绝对路径(源码按字节归档不改动,` + + `但这些路径到了别的机器会失效,建议改成动态定位):`); + for (const w of pathWarnings.slice(0, 6)) console.log(` ${w}`); + if (pathWarnings.length > 6) console.log(` …另 ${pathWarnings.length - 6} 个`); + } + console.log(`- 推送:${pushMsg}`); +} catch (e) { fail(e); } diff --git a/zcode-env-sync/scripts/import.mjs b/zcode-env-sync/scripts/import.mjs new file mode 100644 index 0000000..7745a5b --- /dev/null +++ b/zcode-env-sync/scripts/import.mjs @@ -0,0 +1,211 @@ +#!/usr/bin/env node +/** + * /sync-import:从私仓拉快照 → 还原到本机(先备份)。 + * 用法: ZCODE_SYNC_PASSPHRASE='口令' node import.mjs [快照名] + * 快照名缺省用 latest.json 指向的最新快照。 + * 环境变量: ZCODE_SYNC_APPLY_OFFICIAL=0 跳过官方插件安装(只还原三项) + */ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createHash } from "node:crypto"; +import { + HOME, CLI_CONFIG, PLUGINS_HOME, REPO_DIR, BACKUP_ROOT, V2_CONFIG, V2_SETTING, + WITH_MODELS, APPLY_MODEL_SELECTION, OVERWRITE_MODEL_SECRETS, MODEL_SECRET_REF, + nowTag, readJson, writeJson, decodeJsonPaths, encodeJsonPaths, + decryptSecrets, restoreRelPath, isModelEntry, parseModelRel, requirePassphrase, + ensureRepo, readSnapshotManifest, latestSnapshotDir, + deepMerge, loadOverrides, +} from "./sync-core.mjs"; + +function fail(e) { console.error(`导入失败:${e?.message ?? e}`); process.exitCode = 1; } +const stamps = new Set(); +function backup(abs) { + const stamp = nowTag(); + const rel = path.relative(HOME, abs); + const dst = path.join(BACKUP_ROOT, stamp, ...rel.split(path.sep)); + if (!fs.existsSync(abs)) return null; + stamps.add(stamp); + fs.mkdirSync(path.dirname(dst), { recursive: true }); + const st = fs.statSync(abs); + if (st.isDirectory()) fs.cpSync(abs, dst, { recursive: true }); + else fs.copyFileSync(abs, dst); + return dst; +} + +try { + const pass = requirePassphrase(); + ensureRepo(); + const want = process.argv[2]; + const snapDir = want ? path.join(REPO_DIR, "snapshots", want) : latestSnapshotDir(); + if (!snapDir || !fs.existsSync(path.join(snapDir, "manifest.json"))) { + throw new Error(want ? `快照不存在:snapshots/${want}` : "远端尚无快照(先 /sync-export)"); + } + const manifest = readSnapshotManifest(snapDir); + const snapName = path.basename(snapDir); + + const report = [`# 导入快照:${snapName}`, `- 来源:${manifest.host} @ ${manifest.createdAt}`]; + + // 1) 自研插件源码 + const cpSrc = path.join(snapDir, "custom-plugins"); + const restored = []; + if (fs.existsSync(cpSrc)) { + for (const d of fs.readdirSync(cpSrc)) { + const s = path.join(cpSrc, d), t = path.join(PLUGINS_HOME, d); + if (!fs.statSync(s).isDirectory()) continue; + const b = backup(t); if (b) report.push(`- 备份自研插件 ${d} → ${b}`); + fs.rmSync(t, { recursive: true, force: true }); + fs.cpSync(s, t, { recursive: true }); + restored.push(d); + } + } + report.push(`- 自研插件还原:${restored.length} 项(${restored.join(", ") || "—"})`); + if (restored.includes("zcode-env-sync")) report.push(`- 注意:同步插件自身已随快照更新,重启 ZCode 后生效`); + + // 2) MCP 配置 + plugins.dirs(占位符还原 + 本机覆盖合并) + const mcpSnap = decodeJsonPaths(readJson(path.join(snapDir, "mcp.servers.json"), {})); + const dirsSnap = decodeJsonPaths(readJson(path.join(snapDir, "plugins-dirs.json"), [])); + const { file: overFile, data: over } = loadOverrides(); + const merged = deepMerge({ servers: mcpSnap }, { servers: over?.mcp?.servers ?? over?.servers ?? {} }); + const cfg = fs.existsSync(CLI_CONFIG) ? readJson(CLI_CONFIG, {}) : {}; + const cfgBefore = JSON.stringify(cfg?.mcp?.servers ?? {}); + const dirsBefore = JSON.stringify(cfg?.plugins?.dirs ?? []); + const b1 = backup(CLI_CONFIG); + cfg.mcp = cfg.mcp ?? {}; + cfg.mcp.servers = merged.servers; + // plugins.dirs:快照路径 + 本机已有的去重合并(避免把本机特有插件挤掉) + const seen = new Set(), mergedDirs = []; + for (const d of [...(Array.isArray(dirsSnap) ? dirsSnap : []), ...(over?.plugins?.dirs ?? []), ...(cfg.plugins?.dirs ?? [])]) { + if (!seen.has(d)) { seen.add(d); mergedDirs.push(d); } + } + cfg.plugins = cfg.plugins ?? {}; + cfg.plugins.dirs = mergedDirs; + if (JSON.stringify(cfg.mcp.servers) !== cfgBefore || JSON.stringify(cfg.plugins.dirs) !== dirsBefore) { + if (b1) report.push(`- 备份 cli/config.json → ${b1}`); + writeJson(CLI_CONFIG, cfg); + report.push(`- MCP 配置已更新并写入`); + } else { + report.push(`- MCP 配置与快照一致,未改动(无需备份)`); + } + report.push(`- MCP 还原:${Object.keys(merged.servers).join(", ")}${overFile ? ` (已合并本机覆盖 ${overFile})` : ""}`); + report.push(`- plugins.dirs 共 ${mergedDirs.length} 项`); + + // 3) 密钥束解密还原 + const enc = fs.readFileSync(path.join(snapDir, "secrets.enc"), "utf8"); + let bundle; + try { bundle = decryptSecrets(enc, pass); } + catch { throw new Error("密钥束解密失败:口令不对或文件损坏"); } + const wantMap = new Map((manifest.secretFiles ?? []).map((f) => [f.rel, f.sha256])); + // MODELS/ 条目在本节只收集、不落盘(第 4 节统一写回 v2/config.json) + const { skipSecretsSet } = await import("./sync-core.mjs"); + const skipSet = skipSecretsSet(); + const skipped = []; + const modelKeys = []; + let n = 0; + for (const f of bundle.files ?? []) { + if (isModelEntry(f.rel)) { modelKeys.push(f); continue; } + if (skipSet.has(f.rel)) { skipped.push(f.rel); continue; } + let dst; + try { dst = restoreRelPath(f.rel); } + catch (e) { report.push(`- ⚠ 跳过未知密钥条目 ${f.rel}(${e.message})`); continue; } + const b = backup(dst); if (b) report.push(`- 备份密钥 ${f.rel} → ${b}`); + fs.mkdirSync(path.dirname(dst), { recursive: true }); + fs.writeFileSync(dst, Buffer.from(f.contentB64, "base64"), { mode: 0o600 }); + try { fs.chmodSync(dst, 0o600); } catch { /* windows 忽略 */ } + n++; + if (wantMap.has(f.rel)) { + const got = createHash("sha256").update(fs.readFileSync(dst)).digest("hex"); + if (got !== wantMap.get(f.rel)) report.push(`- ⚠ 密钥 ${f.rel} 哈希与清单不一致(可能被快照后改过,已按快照还原)`); + } + } + report.push(`- 密钥束还原:${n} 个文件(权限 600)` + + (skipped.length ? `,跳过 ${skipped.length} 个(ZCODE_SYNC_SKIP_SECRETS):${skipped.join(", ")}` : "")); + + // 4) 模型 provider:v2.providers.json(脱敏结构)+ 加密束 MODELS/ 真密钥 + const provFile = path.join(snapDir, "v2.providers.json"); + if (fs.existsSync(provFile) && (manifest.modelProviders ?? []).length) { + const provs = decodeJsonPaths(readJson(provFile, {})); + const ids = Object.keys(provs); + if (!WITH_MODELS) { + report.push(`- 模型 provider:快照含 ${ids.length} 个(${ids.join(", ")}),默认不覆盖本机(设 ZCODE_SYNC_WITH_MODELS=1 应用)`); + const needKey = manifest.modelProvidersWithSecrets ?? []; + if (needKey.length) report.push(` 其中带密钥 ${needKey.length} 个:${needKey.join(", ")}`); + } else { + const local2 = fs.existsSync(V2_CONFIG) ? readJson(V2_CONFIG, {}) : {}; + const b2 = backup(V2_CONFIG); if (b2) report.push(`- 备份 v2/config.json → ${b2}`); + local2.provider = local2.provider ?? {}; + const keyMap = new Map(); + for (const f of modelKeys) { + const { provider, key } = parseModelRel(f.rel); + keyMap.set(`${provider}\n${key}`, Buffer.from(f.contentB64, "base64").toString("utf8")); + } + let addedProv = 0, filledKey = 0, skippedOverwrite = 0, skippedKey = 0; + for (const [id, snapPv] of Object.entries(provs)) { + if (!local2.provider[id]) { local2.provider[id] = JSON.parse(JSON.stringify(snapPv)); addedProv++; } + else { + // 已有 provider:只补缺的模型定义与非密钥字段,不碰本机已有密钥 + const lp = local2.provider[id]; + lp.models = { ...(snapPv.models ?? {}), ...(lp.models ?? {}) }; + for (const [k, v] of Object.entries(snapPv.options ?? {})) { + if (v === MODEL_SECRET_REF) continue; + if (lp.options?.[k] === undefined) { lp.options = lp.options ?? {}; lp.options[k] = v; } + } + if (lp.name === undefined) lp.name = snapPv.name; + if (lp.kind === undefined) lp.kind = snapPv.kind; + } + // 密钥回填:快照结构里的占位符才填真值 + const lp = local2.provider[id]; + for (const [kk, vv] of keyMap) { + const [pid, k] = kk.split("\n"); + if (pid !== id) continue; + if (skipSet.has(`MODELS/${pid}/${k}`)) { skippedKey++; continue; } + const cur = lp.options?.[k]; + if (cur === MODEL_SECRET_REF || cur === undefined || cur === "" || (typeof cur === "string" && cur.length < 8)) { + lp.options = lp.options ?? {}; lp.options[k] = vv; filledKey++; + } else if (!OVERWRITE_MODEL_SECRETS) { + skippedOverwrite++; + } else { + lp.options[k] = vv; filledKey++; + } + } + } + writeJson(V2_CONFIG, local2); + report.push(`- 模型 provider:新增 ${addedProv} 个,密钥回填 ${filledKey} 个` + + (skippedOverwrite ? `,跳过覆盖本机已有密钥 ${skippedOverwrite} 个(需覆盖设 ZCODE_SYNC_OVERWRITE_MODEL_SECRETS=1)` : "") + + (skippedKey ? `,跳过 ${skippedKey} 个(ZCODE_SYNC_SKIP_SECRETS)` : "")); + // 选中态:默认不碰,显式开启才应用 + if (APPLY_MODEL_SELECTION && manifest.modelSelection) { + const st = fs.existsSync(V2_SETTING) ? readJson(V2_SETTING, {}) : {}; + const bs = backup(V2_SETTING); if (bs) report.push(`- 备份 v2/setting.json → ${bs}`); + Object.assign(st, manifest.modelSelection); + writeJson(V2_SETTING, st); + report.push(`- 模型选中态已应用(ZCODE_SYNC_APPLY_MODEL_SELECTION=1)`); + } else if (manifest.modelSelection) { + report.push(`- 模型选中态:快照有记录,未覆盖本机(需应用设 ZCODE_SYNC_APPLY_MODEL_SELECTION=1)`); + } + } + } else if (manifest.modelProviders?.length) { + report.push(`- 模型 provider:快照仅记录摘要 ${manifest.modelProviders.length} 个(未含配置本体)`); + } else { + report.push(`- 模型:快照未含模型(导出时未开 ZCODE_SYNC_WITH_MODELS=1)`); + } + + // 5) 官方插件:只报告差异,不自动执行(去插件市场对照启用,重启生效,避免误装) + if (process.env.ZCODE_SYNC_APPLY_OFFICIAL !== "0") { + const { enabledPlugins } = await import("./sync-core.mjs"); + const localEnabled = new Set(enabledPlugins()); + const snapEnabled = manifest.enabled ?? []; + const needEnable = snapEnabled.filter((e) => !localEnabled.has(e)); + const extra = [...localEnabled].filter((e) => !snapEnabled.includes(e)); + report.push(`- 官方插件:快照启用 ${snapEnabled.length} 项,本机缺 ${needEnable.length} 项(${needEnable.join(", ") || "—"})`); + if (extra.length) report.push(` 本机多出 ${extra.length} 项(${extra.join(", ")}),保留不动`); + report.push(` 请在 ZCode 插件市场对照启用缺的项,官方版本见快照 manifest.official,重启生效`); + } else { + report.push(`- 官方插件:已跳过(ZCODE_SYNC_APPLY_OFFICIAL=0)`); + } + + report.push(stamps.size + ? `- 本机备份:${[...stamps].map((s) => path.join(BACKUP_ROOT, s)).join(", ")}` + : `- 本机备份:无覆盖,未产生备份`); + console.log(report.join("\n")); +} catch (e) { fail(e); } diff --git a/zcode-env-sync/scripts/prune.mjs b/zcode-env-sync/scripts/prune.mjs new file mode 100644 index 0000000..46af840 --- /dev/null +++ b/zcode-env-sync/scripts/prune.mjs @@ -0,0 +1,128 @@ +#!/usr/bin/env node +/** + * /sync-prune:按保留策略清理远端旧快照。 + * 用法: node prune.mjs [--keep N] [--apply] [--exclude <快照名>]... + * + * 默认 dry-run:只打印将要删除什么,不实际执行。加 --apply 才真删并推送。 + * 永远保留:最新的一个快照、latest.json 指向的那个、以及 --exclude 指定的。 + * 环境变量: ZCODE_SYNC_KEEP=N 保留数量(默认 10), ZCODE_SYNC_NO_PUSH=1 只本地删不推。 + */ +import fs from "node:fs"; +import path from "node:path"; +import { + REPO_DIR, nowTag, readJson, writeJson, ensureRepo, gitPush, + listSnapshots, latestSnapshotName, humanSize, +} from "./sync-core.mjs"; + +function fail(e) { console.error(`清理失败:${e?.message ?? e}`); + if (process.env.DEBUG_PRUNE) console.error(e.stack); + process.exitCode = 1; } + +/** 解析参数:--keep N / --apply / --exclude NAME / --help */ +function parseArgs(argv) { + const out = { keep: Number(process.env.ZCODE_SYNC_KEEP ?? 10), apply: false, exclude: [] }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--apply") out.apply = true; + else if (a === "--keep") { + const v = Number(argv[++i]); + if (!Number.isInteger(v) || v < 1) throw new Error(`--keep 需要 ≥1 的整数,收到:${argv[i]}`); + out.keep = v; + } else if (a.startsWith("--keep=")) { + const v = Number(a.slice(7)); + if (!Number.isInteger(v) || v < 1) throw new Error(`--keep 需要 ≥1 的整数,收到:${a.slice(7)}`); + out.keep = v; + } else if (a === "--exclude") { + const v = argv[++i]; + if (!v) throw new Error("--exclude 需要一个快照名"); + out.exclude.push(v); + } else if (a.startsWith("--exclude=")) out.exclude.push(a.slice(10)); + else if (a === "--help" || a === "-h") out.help = true; + else throw new Error(`未知参数:${a}(支持 --keep N / --apply / --exclude NAME)`); + } + return out; +} + +try { + const args = parseArgs(process.argv.slice(2)); + if (args.help) { + console.log(`# /sync-prune 用法 +node prune.mjs [--keep N] [--apply] [--exclude <快照名>]... + +默认只预演(dry-run),不删任何东西;加 --apply 才真删并推送。 +保留规则:最新一个 + latest.json 指向的 + --exclude 指定的,永远不动。 +环境变量 ZCODE_SYNC_KEEP 可设默认保留数(当前 ${args.keep})。`); + process.exit(0); + } + + ensureRepo(); + const all = listSnapshots(); + if (!all.length) { + console.log("# 远端尚无快照\n- 先 /sync-export 推第一版"); + process.exit(0); + } + + const latestName = latestSnapshotName(); + const pinned = new Set(args.exclude); + if (latestName) pinned.add(latestName); + + // 列表已是新→旧;按时间序决定保留 + const keepSet = new Set(); + const mustKeep = (s) => pinned.has(s.name); + const keepTarget = Math.max(args.keep, pinned.size); + + for (const s of all) if (mustKeep(s)) keepSet.add(s.name); + for (const s of all) { + if (keepSet.size >= keepTarget) break; + keepSet.add(s.name); + } + + const doomed = all.filter((s) => !keepSet.has(s.name)); + const invalid = all.filter((s) => !s.valid); + + const out = [ + `# 快照清理${args.apply ? "" : "(预演,未删除)"}`, + `- 现有 ${all.length} 个,保留 ${keepSet.size} 个,${args.apply ? "删除" : "将删除"} ${doomed.length} 个`, + `- 保留策略:keep=${args.keep}${pinned.size ? `,钉住 ${[...pinned].join(", ")}` : ""}`, + ]; + if (invalid.length) out.push(`- ⚠ 其中 ${invalid.length} 个快照的 manifest 读不出来:${invalid.map((s) => s.name).join(", ")}`); + if (!doomed.length) { + out.push(`- 无需清理`); + } else { + out.push(`- ${args.apply ? "已删除" : "待删除"}:`); + for (const s of doomed) { + out.push(` ${s.name} ${humanSize(s.bytes)} ${s.createdAt ?? "?"}` + + ` (官方${s.enabledCount} 自研${s.custom.length} MCP${s.mcpCount} 密钥${s.secretCount})`); + } + const freed = doomed.reduce((n, s) => n + s.bytes, 0); + out.push(`- ${args.apply ? "释放" : "将释放"}:${humanSize(freed)}`); + } + + if (args.apply && doomed.length) { + const removed = []; + for (const s of doomed) { + try { fs.rmSync(s.dir, { recursive: true, force: true }); removed.push(s.name); } + catch (e) { out.push(`- ⚠ 删除失败 ${s.name}:${e.message}`); } + } + // latest.json 指向被删的快照时要重指,否则后续 /sync-status、/sync-import 会找不到 + if (latestName && !keepSet.has(latestName)) { + const nxt = all.find((s) => keepSet.has(s.name) && s.valid); + if (nxt) { + writeJson(path.join(REPO_DIR, "latest.json"), { + snapshot: `snapshots/${nxt.name}`, host: nxt.host, createdAt: nxt.createdAt, bytes: nxt.bytes, + }); + out.push(`- latest.json 已重指 → ${nxt.name}`); + } + } + out.push(`- 实际删除 ${removed.length} 个`); + const stamp = nowTag(); + // 限在 snapshots/ 与 latest.json:删除动作需要 -A 才能被捕获, + // 但路径限定能挡住别的会话未推送的内容被顺带提交 + out.push(`- 推送:${gitPush(`sync-prune ${stamp} (removed:${removed.length} kept:${keepSet.size})`, + { paths: ["snapshots", "latest.json"] })}`); + } else if (!args.apply && doomed.length) { + out.push(`- 确认无误后重跑并加 --apply 才会真正删除`); + } + + console.log(out.join("\n")); +} catch (e) { fail(e); } diff --git a/zcode-env-sync/scripts/status.mjs b/zcode-env-sync/scripts/status.mjs new file mode 100644 index 0000000..35472bd --- /dev/null +++ b/zcode-env-sync/scripts/status.mjs @@ -0,0 +1,131 @@ +#!/usr/bin/env node +/** + * /sync-status:对比本机与远端最新快照差异(只读,不改本机)。 + * 用法: node status.mjs + */ +import fs from "node:fs"; +import path from "node:path"; +import { + CLI_CONFIG, readJson, officialPlugins, enabledPlugins, + customPlugins, hashDir, secretTargets, secretsManifest, + ensureRepo, latestSnapshotDir, latestSnapshotName, readSnapshotManifest, + extractModelSecrets, modelKeyManifest, listSnapshots, humanSize, +} from "./sync-core.mjs"; + +function fail(e) { console.error(`状态检查失败:${e?.message ?? e}`); process.exitCode = 1; } + +/** + * --list:列出远端全部快照。 + * sync-import 的文档让用户"先 /sync-status 看远端有哪些(快照名)", + * 但这里原本只有"本机 vs 最新一个"的对比,列不出来 —— 补上这条。 + */ +function listAll() { + ensureRepo(); + const all = listSnapshots(); + if (!all.length) { console.log("# 远端尚无快照\n- 先 /sync-export 推第一版"); return; } + const latest = latestSnapshotName(); + const out = [`# 远端快照(${all.length} 个,合计 ${humanSize(all.reduce((a, s) => a + s.bytes, 0))})`]; + for (const s of all) { + const mark = s.name === latest ? "*" : " "; + out.push(`${mark} ${s.name}`); + out.push(` ${s.host} @ ${s.createdAt ?? "?"} ${humanSize(s.bytes)}` + + (s.valid ? "" : " ⚠ manifest 损坏")); + if (s.valid) { + out.push(` 官方启用 ${s.enabledCount} / 自研 ${s.custom.length}${s.custom.length ? `(${s.custom.join(", ")})` : ""}` + + ` / MCP ${s.mcpCount} / 密钥 ${s.secretCount}` + + (s.modelProviderCount ? ` / 模型 provider ${s.modelProviderCount}` : "")); + } + } + out.push(`- 标 * 的是 latest.json 指向的快照(/sync-import 不带参数时用的就是它)`); + out.push(`- 导入指定版本:/sync-import <上面任意快照名>`); + console.log(out.join("\n")); +} + +try { + if (process.argv.includes("--list")) { listAll(); process.exit(0); } + ensureRepo(); + const snapDir = latestSnapshotDir(); + if (!snapDir) { + console.log("# 远端尚无快照\n- 先 /sync-export 推第一版"); + process.exit(0); + } + const m = readSnapshotManifest(snapDir); + const out = [`# 本机 vs ${path.basename(snapDir)}`, `- 快照来源:${m.host} @ ${m.createdAt}`]; + + // 官方启用差异 + const localEn = new Set(enabledPlugins()); + const snapEn = new Set(m.enabled ?? []); + const miss = [...snapEn].filter((e) => !localEn.has(e)); + const extra = [...localEn].filter((e) => !snapEn.has(e)); + out.push(`- 官方插件:快照启用 ${(m.enabled ?? []).length} 项,本机启用 ${localEn.size} 项,缺 ${miss.length}(${miss.join(", ") || "—"}),多 ${extra.length}(${extra.join(", ") || "—"})`); + + // 自研插件差异(按 hash) + const localCp = new Map(customPlugins().map((c) => [c.name, hashDir(c.dir)])); + const snapCp = new Map((m.custom ?? []).map((c) => [c.name, c.hash])); + const diffCp = []; + for (const [name, h] of snapCp) { + if (!localCp.has(name)) diffCp.push(`${name}(快照有/本机无)`); + else if (localCp.get(name) !== h) diffCp.push(`${name}(版本不同)`); + } + for (const name of localCp.keys()) if (!snapCp.has(name)) diffCp.push(`${name}(本机有/快照无)`); + out.push(`- 自研插件:${diffCp.length ? `差异 ${diffCp.length}:` + diffCp.join("; ") : "一致"}`); + + // MCP server 名差异 + const cfg = readJson(CLI_CONFIG, {}); + const localMcp = new Set(Object.keys(cfg?.mcp?.servers ?? {})); + const snapMcp = new Set(m.mcpServerNames ?? []); + const mMiss = [...snapMcp].filter((s) => !localMcp.has(s)); + const mExtra = [...localMcp].filter((s) => !snapMcp.has(s)); + out.push(`- MCP:${mMiss.length || mExtra.length ? `缺 ${mMiss.join(", ") || "—"};多 ${mExtra.join(", ") || "—"}` : "server 名一致"}`); + + // 密钥文件差异(只比清单 hash,不读值) + const localSec = new Map(secretsManifest(secretTargets()).map((f) => [f.rel, f.sha256])); + const snapSec = new Map((m.secretFiles ?? []).map((f) => [f.rel, f.sha256])); + const sDiff = []; + for (const [rel, h] of snapSec) { + if (!localSec.has(rel)) sDiff.push(`${rel}(快照有/本机无)`); + else if (localSec.get(rel) !== h) sDiff.push(`${rel}(内容不同)`); + } + for (const rel of localSec.keys()) if (!snapSec.has(rel)) sDiff.push(`${rel}(本机有/快照无)`); + out.push(`- 密钥束:${sDiff.length ? `差异 ${sDiff.length}:` + sDiff.join("; ") : "清单一致"}`); + + // 模型 provider 差异(按 id 比对,只比结构和模型数,不读任何密钥值) + if ((m.modelProviders ?? []).length) { + let localProvs = []; + try { + const { summarizeProviders, V2_CONFIG } = await import("./sync-core.mjs"); + localProvs = summarizeProviders(readJson(V2_CONFIG, {})); + } catch { /* 读不到就当本机没有 */ } + const snapP = new Map(m.modelProviders.map((p) => [p.id, p])); + const locP = new Map(localProvs.map((p) => [p.id, p])); + const pd = []; + for (const [id, sp] of snapP) { + const lp = locP.get(id); + if (!lp) pd.push(`${id}(快照有/本机无)`); + else if (JSON.stringify(lp.models) !== JSON.stringify(sp.models)) pd.push(`${id}(模型不同)`); + } + for (const id of locP.keys()) if (!snapP.has(id)) pd.push(`${id}(本机有/快照无)`); + const needKey = m.modelProvidersWithSecrets ?? []; + out.push(`- 模型 provider:${pd.length ? `差异 ${pd.length}:` + pd.join("; ") : `${snapP.size} 个一致`}` + + (needKey.length ? `;带密钥 ${needKey.length} 个` : "")); + // 模型密钥清单对比(只比哈希,不读不打值) + if ((m.modelKeys ?? []).length) { + let localKeys = []; + try { + const { V2_CONFIG } = await import("./sync-core.mjs"); + localKeys = modelKeyManifest(extractModelSecrets(readJson(V2_CONFIG, {}))); + } catch { /* 读不到就当本机没有 */ } + const snapK = new Map((m.modelKeys ?? []).map((k) => [k.rel, k.sha256])); + const locK = new Map(localKeys.map((k) => [k.rel, k.sha256])); + const kd = []; + for (const [rel, h] of snapK) { + if (!locK.has(rel)) kd.push(`${rel}(快照有/本机无)`); + else if (locK.get(rel) !== h) kd.push(`${rel}(值不同)`); + } + for (const rel of locK.keys()) if (!snapK.has(rel)) kd.push(`${rel}(本机有/快照无)`); + out.push(`- 模型密钥:${kd.length ? `差异 ${kd.length}:` + kd.join("; ") : `${locK.size} 个一致`}`); + } + } + + console.log(out.join("\n")); +} catch (e) { fail(e); } diff --git a/zcode-env-sync/scripts/sync-core.mjs b/zcode-env-sync/scripts/sync-core.mjs new file mode 100644 index 0000000..3a6f639 --- /dev/null +++ b/zcode-env-sync/scripts/sync-core.mjs @@ -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/ 目录即启用态: @ */ +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` 只是 `@<来源>` 字符串,`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 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: {} }; +} diff --git a/zcode-env-sync/scripts/verify.mjs b/zcode-env-sync/scripts/verify.mjs new file mode 100644 index 0000000..7b7230a --- /dev/null +++ b/zcode-env-sync/scripts/verify.mjs @@ -0,0 +1,224 @@ +#!/usr/bin/env node +/** + * /sync-verify:校验快照完整性 —— hash 是否对得上、密钥束能否解密、占位符是否还在。 + * 用法: node verify.mjs [快照名] 缺省校验 latest.json 指向的那个 + * + * 会真的解密密钥束(需 ZCODE_SYNC_PASSPHRASE),但只比哈希、不落地、不打印任何密钥值。 + * 用途:导入前确认快照没坏;或怀疑"status 常说版本不同"时定位是内容问题还是 git 换行符问题。 + * 环境变量: ZCODE_SYNC_VERIFY_STRICT=1 时,任何 warning 也算失败(退出码 1)。 + */ +import fs from "node:fs"; +import path from "node:path"; +import crypto from "node:crypto"; +import { + REPO_DIR, readJson, ensureRepo, latestSnapshotDir, listSnapshots, latestSnapshotName, + hashDir, decryptSecrets, requirePassphrase, humanSize, dirSize, + parseModelRel, isModelEntry, scanForSecrets, +} from "./sync-core.mjs"; + +/** Buffer 的 sha256(manifest 里的密钥 hash 就是这么算的) */ +const sha256Buf = (buf) => crypto.createHash("sha256").update(buf).digest("hex"); +/** 体积容错读取 */ +const dirSizeSafe = (d) => { try { return dirSize(d); } catch { return 0; } }; + +/** + * --all:扫全部快照,回答"哪个我能用"。 + * 团队仓里可能混着不同口令导出的快照(GCM 认证失败无法与损坏区分), + * 这个模式把它们逐个用当前口令试一遍,比一个个人肉试快得多。 + */ +function sweepAll() { + ensureRepo(); + const all = listSnapshots(); + if (!all.length) { console.log("# 远端尚无快照\n- 先 /sync-export 推第一版"); return; } + let pass = null; + try { pass = requirePassphrase(); } catch { /* 无口令:只报结构 */ } + const latestName = latestSnapshotName(); + const out = [`# 全部快照扫描(${all.length} 个)`, `- 口令:${pass ? "已提供" : "未提供(只校验结构,不解密)"}`]; + const usable = []; + for (const s of all) { + const mark = s.name === latestName ? "*" : " "; + const tag = [`${s.valid ? "结构OK" : "结构损坏"}`]; + if (pass && s.valid) { + const enc = path.join(s.dir, "secrets.enc"); + if (!fs.existsSync(enc)) { tag.push("无密钥束"); } + else { + try { decryptSecrets(fs.readFileSync(enc, "utf8"), pass); tag.push("可解密"); } + catch { tag.push("口令不符/损坏"); } + } + } + const good = s.valid && (!pass || tag.includes("可解密") || tag.includes("无密钥束")); + if (good) usable.push(s.name); + out.push(`${mark} ${s.name} ${humanSize(s.bytes).padStart(8)} ${s.host} ${tag.join(" / ")}`); + } + out.push(`- 标 * 的是 latest.json 指向的快照`); + out.push(`- 当前口令可用:${usable.length} 个${usable.length ? `(最新的:${usable[0]})` : ""}`); + if (pass && !usable.includes(latestName) && latestName) { + out.push(`- ⚠ latest.json 指向的 ${latestName} 用当前口令不可用 —— /sync-import 会失败。`); + if (usable.length) out.push(` 改用可用的那版:/sync-import ${usable[0]}`); + } + console.log(out.join("\n")); +} + +function fail(e) { console.error(`校验失败:${e?.message ?? e}`); process.exitCode = 1; } + +try { + if (process.argv.includes("--all")) { sweepAll(); process.exit(0); } + ensureRepo(); + // 只看用户参数:argv[0]=node, argv[1]=脚本路径,不能把 node.exe 当成快照名 + const FLAGS = new Set(["--all", "--strict", "--quiet"]); + const userRaw = process.argv.slice(2); + const badFlag = userRaw.find((a) => a.startsWith("--") && !FLAGS.has(a)); + if (badFlag) throw new Error(`未知参数:${badFlag}(支持 [快照名] / --all / --strict / --quiet)`); + const want = userRaw.find((a) => !a.startsWith("--")); + const snapDir = want ? path.join(REPO_DIR, "snapshots", want) : latestSnapshotDir(); + if (!snapDir || !fs.existsSync(path.join(snapDir, "manifest.json"))) { + throw new Error(want ? `快照不存在或无 manifest:snapshots/${want}` : "远端尚无快照(先 /sync-export)"); + } + const snapName = path.basename(snapDir); + const m = readJson(path.join(snapDir, "manifest.json"), null); + if (!m) throw new Error(`manifest.json 读不出来(JSON 损坏?)`); + + const errs = [], warns = [], oks = []; + const rel = (p) => path.relative(snapDir, p); + + /* 1) 结构:必备文件在不在 */ + for (const f of ["mcp.servers.json", "plugins-dirs.json", "secrets.enc", "secrets.manifest.json"]) { + const p = path.join(snapDir, f); + if (!fs.existsSync(p)) errs.push(`缺少必备文件 ${f}`); + else oks.push(`${f} 存在(${humanSize(fs.statSync(p).size)})`); + } + + /* 2) 自研插件:逐目录重算 hash,与 manifest 对照 */ + const cpRoot = path.join(snapDir, "custom-plugins"); + for (const c of m.custom ?? []) { + const dir = path.join(cpRoot, c.name); + if (!fs.existsSync(dir)) { errs.push(`自研插件目录缺失:${c.name}`); continue; } + const got = hashDir(dir); + if (got !== c.hash) errs.push(`自研插件 ${c.name} 内容 hash 不符(manifest=${c.hash} 实际=${got})`); + else oks.push(`自研插件 ${c.name}@${c.version} hash 一致`); + } + // 目录里有 manifest 没记录的 = 快照不一致 + if (fs.existsSync(cpRoot)) { + const onDisk = fs.readdirSync(cpRoot, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name); + const inManifest = new Set((m.custom ?? []).map((c) => c.name)); + for (const n of onDisk) if (!inManifest.has(n)) warns.push(`快照里有 manifest 未记录的插件目录:${n}`); + for (const n of inManifest) if (!onDisk.includes(n)) warns.push(`manifest 记录了但快照里没有:${n}`); + } + + /* 3) 密钥束:能否解密 + 清单 hash 是否对得上 */ + let bundle = null; + const encPath = path.join(snapDir, "secrets.enc"); + if (fs.existsSync(encPath)) { + let pass = null; + try { pass = requirePassphrase(); } + catch (e) { warns.push(`未提供口令,跳过密钥束解密(${e.message})`); } + if (pass) { + try { + bundle = decryptSecrets(fs.readFileSync(encPath, "utf8"), pass); + oks.push(`密钥束可解密(${bundle.files?.length ?? 0} 个条目)`); + } catch (e) { + // GCM 认证失败无法区分"口令错"和"密文被改" —— 但两种情况里口令错远比损坏常见, + // 尤其是团队仓里另一台机器/换过口令的快照,所以把提示往那边引。 + errs.push(`密钥束解不开:口令不对(该快照可能用了别的口令,比如换口令前导出的,或来自别的工作区)` + + `,或 secrets.enc 被改过。加密层报错:${e.message}`); + } + } + } + // 密钥清单 hash 对照(解不开时就只能靠清单自证) + const manPath = path.join(snapDir, "secrets.manifest.json"); + if (bundle) { + const listed = new Map((m.secretFiles ?? []).map((f) => [f.rel, f.sha256])); + const got = new Map(); + for (const f of bundle.files ?? []) { + const buf = Buffer.from(f.contentB64 ?? "", "base64"); + got.set(f.rel, sha256Buf(buf)); + } + for (const [r, h] of listed) { + if (isModelEntry(r)) continue; // 模型条目在 modelKeys 里单独记 + if (!got.has(r)) errs.push(`清单列了密钥 ${r},加密束里没有`); + else if (got.get(r) !== h) errs.push(`密钥 ${r} 内容与清单 hash 不符`); + else oks.push(`密钥 ${r} hash 一致`); + } + for (const r of got.keys()) { + if (!isModelEntry(r) && !listed.has(r)) warns.push(`加密束里有清单未记录的条目:${r}`); + } + // 模型条目:形状是否正确 + for (const f of bundle.files ?? []) { + if (!isModelEntry(f.rel)) continue; + try { parseModelRel(f.rel); oks.push(`模型条目 ${f.rel} 形状正确`); } + catch (e) { errs.push(`模型条目非法:${f.rel}(${e.message})`); } + } + } + if (fs.existsSync(manPath)) { + const sm = readJson(manPath, null); + if (!Array.isArray(sm)) errs.push("secrets.manifest.json 不是数组"); + else oks.push(`密钥清单 ${sm.length} 条`); + } + + /* 4) 占位符:配置区不该再有本机路径(说明导出时占位符化生效了) */ + const USER_PATH_RE = /[\\/]Users[\\/][^\\/"\s]+/; + const stringsOf = (node, out = []) => { + if (typeof node === "string") { out.push(node); return out; } + if (Array.isArray(node)) { node.forEach((v) => stringsOf(v, out)); return out; } + if (node && typeof node === "object") for (const [k, v] of Object.entries(node)) { stringsOf(k, out); stringsOf(v, out); } + return out; + }; + let phCount = 0, leakCount = 0; + for (const f of ["mcp.servers.json", "plugins-dirs.json", "v2.providers.json"]) { + const p = path.join(snapDir, f); + if (!fs.existsSync(p)) continue; + let strs; + try { strs = stringsOf(JSON.parse(fs.readFileSync(p, "utf8"))); } + catch { errs.push(`${f} 不是合法 JSON`); continue; } + for (const s of strs) { + if (/\$\{[A-Z_]+\}/.test(s)) phCount++; + if (USER_PATH_RE.test(s)) { leakCount++; errs.push(`${f} 里残留未占位符化的绝对路径:${s.slice(0, 60)}`); } + } + } + oks.push(`占位符 ${phCount} 处,未占位符化路径 ${leakCount} 处`); + + /* 5) 明文区密钥泄漏复检(含 manifest 自身) */ + const hit = scanForSecrets({ + mcp: readJson(path.join(snapDir, "mcp.servers.json"), {}), + dirs: readJson(path.join(snapDir, "plugins-dirs.json"), []), + manifest: { modelProviders: m.modelProviders ?? [] }, + }); + for (const h of hit) { + // 市场清单里可能带 baseURL 之类,只对"疑似真值"报警 + warns.push(`明文区扫描命中疑似密钥字段:${h}`); + } + + /* 6) manifest 自洽性 */ + if (!m.snapshot || path.basename(m.snapshot) !== snapName) { + warns.push(`manifest.snapshot 与实际目录名不一致(${m.snapshot} vs ${snapName})`); + } + if (m.bytes && Math.abs(dirSizeSafe(snapDir) - m.bytes) > 4096) { + warns.push(`manifest.bytes=${m.bytes} 与实际体积差得较多(可能被手工改过)`); + } + if ((m.enabled ?? []).length && !(m.enabledDetailed ?? []).length) { + warns.push(`manifest 无 enabledDetailed(inline 插件版本缺失,建议重新导出)`); + } + + /* ---- 汇总 ---- */ + const strict = process.env.ZCODE_SYNC_VERIFY_STRICT === "1" || process.argv.includes("--strict"); + const out = [`# 快照校验:${snapName}`, `- 来源:${m.host} @ ${m.createdAt}`, `- 体积:${humanSize(m.bytes ?? dirSizeSafe(snapDir))}`]; + // 通过项也列出来:只报"通过 10 项"用户不知道到底验了什么,而警告/失败却有明细 + const verbose = !process.argv.includes("--quiet"); + out.push(`- 通过 ${oks.length} 项${verbose ? ":" : ""}`); + if (verbose) for (const o of oks) out.push(` ✓ ${o}`); + if (warns.length) out.push(`- 警告 ${warns.length} 项:`, ...warns.map((w) => ` ⚠ ${w}`)); + if (errs.length) out.push(`- 失败 ${errs.length} 项:`, ...errs.map((e) => ` ✗ ${e}`)); + console.log(out.join("\n")); + + if (errs.length) { + process.exitCode = 1; + console.log(`\n结论:快照不可用(${errs.length} 项错误)。重新导出该机器的环境,或改校验别的快照。`); + } else if (warns.length && strict) { + process.exitCode = 1; + console.log(`\n结论:无致命错误,但有 ${warns.length} 项警告(严格模式视为失败)。`); + } else { + console.log(warns.length + ? `\n结论:快照可用,${warns.length} 项警告不影响导入。` + : `\n结论:快照完好。`); + } +} catch (e) { fail(e); } diff --git a/zcode-env-sync/tests/core.test.mjs b/zcode-env-sync/tests/core.test.mjs new file mode 100644 index 0000000..5fd76d2 --- /dev/null +++ b/zcode-env-sync/tests/core.test.mjs @@ -0,0 +1,324 @@ +/** + * zcode-env-sync 单元测试(node:test,零第三方依赖) + * 跑法: node --test tests/ + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import fs from "node:fs"; +import os from "node:os"; +import crypto from "node:crypto"; +import { + HOME, USER, ZCODE_HOME, DSH_HOME, + fwd, encodePaths, decodePaths, encodeJsonPaths, decodeJsonPaths, + scanForSecrets, encryptSecrets, decryptSecrets, requirePassphrase, + deepMerge, restoreRelPath, hashDir, sha256File, nowTag, + isHiddenEntry, copyDirFiltered, + collectModelSecrets, sanitizeProviders, summarizeProviders, + extractModelSecrets, isModelEntry, parseModelRel, +} from "../scripts/sync-core.mjs"; + +/* ---------------- 路径占位符 ---------------- */ + +test("encodePaths/decodePaths:正斜杠路径往返一致", () => { + const p = fwd(path.join(ZCODE_HOME, "cli", "config.json")); + const enc = encodePaths(p); + assert.match(enc, /\$\{ZCODE_HOME\}/, "应命中 ZCODE_HOME 占位符"); + assert.equal(decodePaths(enc), p, "还原后应与原值完全一致"); +}); + +test("encodePaths/decodePaths:反斜杠路径往返一致", () => { + const p = ZCODE_HOME.replace(/\//g, "\\") + "\\cli\\config.json"; + const enc = encodePaths(p); + assert.match(enc, /\$\{ZCODE_HOME_WIN\}/, "反斜杠路径应命中 WIN 占位符"); + assert.equal(decodePaths(enc), p); +}); + +test("encodePaths:DSH_HOME 优先于通配的 Users 规则", () => { + const enc = encodePaths(fwd(path.join(DSH_HOME, "secrets", "gitea-token.txt"))); + assert.match(enc, /\$\{DSH_HOME\}/); + assert.doesNotMatch(enc, /\$\{USERS_/, "不应退化成 USERS_* 通配"); +}); + +test("encodeJsonPaths:反斜杠 JSON 值是活的占位符(旧实现此处全失效)", () => { + const cfg = { mcp: { servers: { s: { command: "C:\\Users\\12914\\AppData\\node.exe", args: ["C:\\Users\\12914\\.dsh\\secrets\\x.mjs"] } } } }; + const enc = encodeJsonPaths(cfg); + const text = JSON.stringify(enc); + assert.match(text, /\$\{USER_HOME_WIN\}|\$\{USERS_WIN\}|\$\{DSH_HOME_WIN\}/, "反斜杠路径必须被占位符化"); + assert.doesNotMatch(text, /AppData\\node\.exe/, "原始主目录片段不得残留"); +}); + +test("decodeJsonPaths:WIN 占位符还原后仍是合法 JSON(旧实现在文本上还原会拼出非法转义)", () => { + const cfg = { s: { command: "C:\\Users\\12914\\AppData\\node.exe" } }; + const round = decodeJsonPaths(encodeJsonPaths(cfg)); + assert.doesNotThrow(() => JSON.parse(JSON.stringify(round))); + assert.equal(round.s.command, cfg.s.command, "还原值应与原值一致"); + assert.equal(round.s.command.split("\\").length, cfg.s.command.split("\\").length); +}); + +test("encodeJsonPaths/decodeJsonPaths:嵌套数组与键名一并处理,且完全往返", () => { + const cfg = { + "C:/Users/12914/.dsh/secrets/redis-mcp-launch.mjs": { + env: { SSH_PROFILES_FILE: "C:/Users/12914/.ssh/ssh-profiles.json" }, + args: ["--db", "C:/Users/12914/.dsh/x.db", 8080, true, null], + }, + }; + const enc = encodeJsonPaths(cfg); + const k0 = Object.keys(cfg)[0]; + assert.equal(Object.keys(enc).length, 1, "键数量不变"); + assert.match(Object.keys(enc)[0], /\$\{DSH_HOME\}/, "键名里的路径也要占位符化"); + assert.equal(enc[Object.keys(enc)[0]].args.length, cfg[k0].args.length, "数组长度不变"); + assert.match(JSON.stringify(enc), /\$\{/, "产生了占位符"); + assert.deepEqual(decodeJsonPaths(enc), cfg, "往返必须完全相等"); +}); + +test("encodeJsonPaths:非字符串原样保留", () => { + const cfg = { n: 1, b: true, z: null, o: { a: [1, "C:/Users/12914/.zcode/x"] } }; + const enc = encodeJsonPaths(cfg); + assert.equal(enc.n, 1); assert.equal(enc.b, true); assert.equal(enc.z, null); + assert.match(enc.o.a[1], /\$\{ZCODE_HOME\}/); +}); + +/* ---------------- 脱敏扫描 ---------------- */ + +test("scanForSecrets:抓到明文密钥", () => { + assert.deepEqual(scanForSecrets({ apiKey: "supersecret123" }), ["$.apiKey"]); + assert.deepEqual(scanForSecrets({ env: { PASSWORD: "hunter2hunter2" } }), ["$.env.PASSWORD"]); +}); + +test("scanForSecrets:含 / \\ $ 的密钥不得漏检(旧实现最大漏洞)", () => { + for (const v of ["sk-proj/abcdefghijklmnop", "eyJhbGciOi/JIUzI1NiJ9.payload", "ghp_AbC$1234567890", "tok\\with\\slash1234"]) { + assert.equal(scanForSecrets({ apiKey: v }).length, 1, `必须抓到:${v}`); + } +}); + +test("scanForSecrets:跳过 _FILE 引用、占位符、短值", () => { + assert.deepEqual(scanForSecrets({ GITEA_ACCESS_TOKEN_FILE: "C:/Users/x/.dsh/secrets/gitea-token.txt" }), []); + assert.deepEqual(scanForSecrets({ apiKey: "${SOME_PLACEHOLDER}" }), []); + assert.deepEqual(scanForSecrets({ token: "short" }), []); +}); + +test("scanForSecrets:ZCODE_SYNC_ALLOW_SECRETS 白名单生效", () => { + const obj = { mcp: { servers: { x: { env: { apiKey: "realsecretvalue" } } } } }; + assert.equal(scanForSecrets(obj).length, 1); + process.env.ZCODE_SYNC_ALLOW_SECRETS = "$.mcp.servers.x.env.apiKey"; + try { assert.deepEqual(scanForSecrets(obj), []); } + finally { delete process.env.ZCODE_SYNC_ALLOW_SECRETS; } +}); + +/* ---------------- 密钥束加解密 ---------------- */ + +test("encryptSecrets/decryptSecrets:往返一致", () => { + const bundle = { version: 1, files: [{ rel: "SSH/ssh-profiles.json", contentB64: Buffer.from("hello").toString("base64") }] }; + const enc = encryptSecrets(bundle, "test-passphrase-123"); + assert.deepEqual(decryptSecrets(enc, "test-passphrase-123"), bundle); +}); + +test("decryptSecrets:错误口令必定失败(GCM 认证)", () => { + const enc = encryptSecrets({ a: 1 }, "right-passphrase"); + assert.throws(() => decryptSecrets(enc, "wrong-passphrase")); +}); + +test("decryptSecrets:篡改密文必定失败", () => { + const e = JSON.parse(encryptSecrets({ a: 1 }, "passphrase-abc")); + const buf = Buffer.from(e.data, "base64"); buf[0] ^= 0xff; + e.data = buf.toString("base64"); + assert.throws(() => decryptSecrets(JSON.stringify(e), "passphrase-abc")); +}); + +test("encryptSecrets:同一明文两次加密密文不同(随机 salt/iv)", () => { + const a = encryptSecrets({ x: 1 }, "same-passphrase"); + const b = encryptSecrets({ x: 1 }, "same-passphrase"); + assert.notEqual(a, b); +}); + +test("requirePassphrase:未设置/过短都要拒绝", () => { + const old = process.env.ZCODE_SYNC_PASSPHRASE; + try { + delete process.env.ZCODE_SYNC_PASSPHRASE; + assert.throws(() => requirePassphrase(), /未设置/); + process.env.ZCODE_SYNC_PASSPHRASE = "short"; + assert.throws(() => requirePassphrase(), /太短/); + process.env.ZCODE_SYNC_PASSPHRASE = "long-enough-pass"; + assert.equal(requirePassphrase(), "long-enough-pass"); + } finally { + if (old === undefined) delete process.env.ZCODE_SYNC_PASSPHRASE; + else process.env.ZCODE_SYNC_PASSPHRASE = old; + } +}); + +/* ---------------- 路径还原安全 ---------------- */ + +test("restoreRelPath:正常路径映射到 HOME 下", () => { + const p = restoreRelPath("DSH_SECRETS/gitea-token.txt"); + assert.equal(p, path.join(HOME, ".dsh", "secrets", "gitea-token.txt")); + const s = restoreRelPath("SSH/ssh-profiles.json"); + assert.equal(s, path.join(HOME, ".ssh", "ssh-profiles.json")); +}); + +test("restoreRelPath:拒绝目录穿越与绝对路径", () => { + for (const bad of ["DSH_SECRETS/../../../evil.txt", "SSH/../../x", "DSH_SECRETS/C:/Windows/evil.txt", "DSH_SECRETS//etc/passwd"]) { + assert.throws(() => restoreRelPath(bad), /非法密钥路径/, `必须拒绝:${bad}`); + } +}); + +test("restoreRelPath:拒绝未知前缀", () => { + assert.throws(() => restoreRelPath("OTHER/x.txt"), /未知密钥前缀/); +}); + +/* ---------------- 结构合并 ---------------- */ + +test("deepMerge:深度合并 + 数组整体替换 + 不污染源对象", () => { + const base = { mcp: { servers: { a: { command: "x", args: [1, 2] } } }, plugins: { dirs: ["p1"] } }; + const over = { mcp: { servers: { a: { args: ["local"] }, b: { command: "y" } } } }; + const out = deepMerge(base, over); + assert.equal(out.mcp.servers.a.command, "x", "未覆盖字段保留"); + assert.deepEqual(out.mcp.servers.a.args, ["local"], "数组整体替换"); + assert.equal(out.mcp.servers.b.command, "y", "新键并入"); + assert.deepEqual(base.mcp.servers.a.args, [1, 2], "源对象不得被改"); +}); + +test("deepMerge:undefined 不覆盖已有值", () => { + assert.equal(deepMerge({ a: 1 }, { a: undefined }).a, 1); +}); + +/* ---------------- 目录哈希 ---------------- */ + +test("hashDir:内容变则哈希变,且确定性可复现", () => { + const d = fs.mkdtempSync(path.join(os.tmpdir(), "hashdir-")); + try { + fs.writeFileSync(path.join(d, "a.txt"), "1"); + const h1 = hashDir(d), h2 = hashDir(d); + assert.equal(h1, h2, "同内容必须同哈希"); + fs.writeFileSync(path.join(d, "a.txt"), "2"); + assert.notEqual(hashDir(d), h1, "内容变哈希必须变"); + } finally { fs.rmSync(d, { recursive: true, force: true }); } +}); + +test("hashDir:排除 node_modules/.git", () => { + const d = fs.mkdtempSync(path.join(os.tmpdir(), "hashdir2-")); + try { + fs.writeFileSync(path.join(d, "a.txt"), "1"); + const before = hashDir(d); + fs.mkdirSync(path.join(d, "node_modules", "x"), { recursive: true }); + fs.writeFileSync(path.join(d, "node_modules", "x", "big.js"), "noise"); + fs.mkdirSync(path.join(d, ".git"), { recursive: true }); + fs.writeFileSync(path.join(d, ".git", "HEAD"), "ref"); + assert.equal(hashDir(d), before, "排除目录不得影响哈希"); + } finally { fs.rmSync(d, { recursive: true, force: true }); } +}); + +/* ---------------- 自研插件扫描 ---------------- */ + +test("isHiddenEntry:备份/隐藏目录不算插件", () => { + assert.equal(isHiddenEntry(".backup-env-sync-20260911"), true); + assert.equal(isHiddenEntry(".git"), true); + assert.equal(isHiddenEntry("zcode-tps"), false); +}); + +test("copyDirFiltered:拒绝把目录复制进自身(防无限递归)", () => { + const d = fs.mkdtempSync(path.join(os.tmpdir(), "copyself-")); + try { + fs.writeFileSync(path.join(d, "a.txt"), "1"); + assert.throws(() => copyDirFiltered(d, path.join(d, "sub")), /无限递归/); + assert.throws(() => copyDirFiltered(d, d), /无限递归/); + // 同级目录仍可正常复制 + const dst = path.join(path.dirname(d), `copyself-out-${Date.now()}`); + copyDirFiltered(d, dst); + assert.ok(fs.existsSync(path.join(dst, "a.txt"))); + fs.rmSync(dst, { recursive: true, force: true }); + } finally { fs.rmSync(d, { recursive: true, force: true }); } +}); + +/* ---------------- 模型 provider 脱敏 ---------------- */ + +test("collectModelSecrets:收集到全部 provider 密钥值", () => { + const cfg = { provider: { + a: { options: { apiKey: "sk-aaaaaaaaaaaaaaaa" } }, + b: { options: { apiKey: "sk-bbbbbbbbbbbbbbbb", apiKeyRequired: true } }, + c: { options: {} }, + } }; + const got = collectModelSecrets(cfg); + assert.equal(got.length, 2, `应收集 2 个密钥,实际 ${got.length}`); + assert.ok(got.includes("sk-aaaaaaaaaaaaaaaa")); + assert.ok(got.includes("sk-bbbbbbbbbbbbbbbb")); +}); + +test("collectModelSecrets:短值和无关字段不误收", () => { + const cfg = { provider: { a: { options: { apiKey: "short", baseURL: "https://example.invalid/v1" } } } }; + assert.deepEqual(collectModelSecrets(cfg), [], "短值/非密钥字段不得收集"); +}); + +test("sanitizeProviders:密钥被替换,结构保留,且名单只记 id", () => { + const cfg = { provider: { + a: { name: "A", kind: "anthropic", models: { m1: {} }, options: { apiKey: "sk-realvalue123456" } }, + b: { name: "B", kind: "openai-compatible", models: { m2: {}, m3: {} }, options: { baseURL: "https://x.invalid" } }, + } }; + const { providers, withSecrets } = sanitizeProviders(cfg); + assert.deepEqual(withSecrets, ["a"], "只有 a 带密钥"); + assert.equal(providers.a.options.apiKey, "${MODEL_SECRET_REF}", "应替换为占位符"); + assert.equal(providers.a.kind, "anthropic", "非密钥字段保留"); + assert.deepEqual(Object.keys(providers.a.models), ["m1"], "模型结构保留"); + assert.equal(providers.b.options.baseURL, "https://x.invalid", "无密钥者不受影响"); + // 脱敏后不得再有真密钥 + assert.doesNotMatch(JSON.stringify(providers), /sk-realvalue123456/); + assert.doesNotMatch(JSON.stringify(providers), /sk-/); + // 源对象不得被改 + assert.equal(cfg.provider.a.options.apiKey, "sk-realvalue123456", "源配置不得被修改"); +}); + +test("summarizeProviders:只输出结构摘要,不含任何密钥值", () => { + const cfg = { provider: { + p1: { name: "P1", kind: "anthropic", models: { m1: {}, m2: {} }, options: { apiKey: "sk-secret123456789" } }, + } }; + const sum = summarizeProviders(cfg); + assert.equal(sum.length, 1); + assert.equal(sum[0].id, "p1"); + assert.equal(sum[0].kind, "anthropic"); + assert.deepEqual(sum[0].models, ["m1", "m2"]); + assert.equal(sum[0].hasSecret, true, "应标记带密钥"); + assert.doesNotMatch(JSON.stringify(sum), /sk-secret123456789/, "摘要里绝不能出现密钥值"); +}); + +test("extractModelSecrets/parseModelRel:MODELS 条目可往返定位", () => { + const cfg = { provider: { "builtin:x": { options: { apiKey: "sk-abcdefgh12345678" } } } }; + const ex = extractModelSecrets(cfg); + assert.equal(ex.length, 1); + assert.equal(ex[0].provider, "builtin:x"); + assert.equal(ex[0].key, "apiKey"); + const rel = `MODELS/${ex[0].provider}/${ex[0].key}`; + assert.equal(isModelEntry(rel), true); + assert.deepEqual(parseModelRel(rel), { provider: "builtin:x", key: "apiKey" }, "定位符应能还原出 provider/key"); +}); + +test("isModelEntry:普通密钥文件路径不得被误判为模型条目", () => { + for (const rel of ["DSH_SECRETS/gitea-token.txt", "SSH/ssh-profiles.json"]) { + assert.equal(isModelEntry(rel), false, `${rel} 不是模型条目`); + } +}); + +test("parseModelRel:非法路径要报错而不是静默产出错 provider", () => { + for (const bad of ["MODELS/", "MODELS/onlyprovider", "MODELS//key", "MODELS/provider/"]) { + assert.throws(() => parseModelRel(bad), /非法模型密钥路径/, `必须拒绝:${bad}`); + } +}); + +/* ---------------- 杂项 ---------------- */ + +test("nowTag:格式 yyyyMMdd-HHmmss", () => { + assert.match(nowTag(), /^\d{8}-\d{6}$/); +}); + +test("sha256File:与 crypto 直接计算结果一致", () => { + const f = path.join(os.tmpdir(), `sha-${Date.now()}.txt`); + fs.writeFileSync(f, "content"); + try { + const want = crypto.createHash("sha256").update(fs.readFileSync(f)).digest("hex"); + assert.equal(sha256File(f), want); + } finally { fs.rmSync(f, { force: true }); } +}); + +test("HOME/USER 常量与 os 一致", () => { + assert.equal(HOME, os.homedir()); + assert.equal(USER, path.basename(os.homedir())); +}); diff --git a/zcode-env-sync/tests/e2e.test.mjs b/zcode-env-sync/tests/e2e.test.mjs new file mode 100644 index 0000000..ee23659 --- /dev/null +++ b/zcode-env-sync/tests/e2e.test.mjs @@ -0,0 +1,642 @@ +/** + * zcode-env-sync 端到端沙箱测试。 + * + * 用 USERPROFILE 把 HOME 重定向到临时目录,造两台互不相干的"假机器" A/B, + * 用一个本地裸仓冒充 Gitea,跑完整链路: + * + * A: 造环境 → export → commit → push + * B: clone → import → 校验路径已被还原成 B 自己的、密钥已解密落地、备份已生成 + * + * 全程不碰真实 HOME,不碰网络。 + * 跑法: node --test tests/ + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import crypto from "node:crypto"; +import { execFileSync, spawnSync } from "node:child_process"; +import { hashDir } from "../scripts/sync-core.mjs"; +import { readSnapshotManifest } from "../scripts/sync-core.mjs"; + +const SCRIPTS = path.resolve(import.meta.dirname, "..", "scripts"); +const PASS = "team-passphrase-for-tests"; +const GIT_ENV = { GIT_CONFIG_NOSYSTEM: "1", GIT_TERMINAL_PROMPT: "0" }; + +/* ---------- 工具 ---------- */ + +function sandboxRoot() { + return fs.mkdtempSync(path.join(os.tmpdir(), "zes-e2e-")); +} + +/** 造一台假机器的 home 骨架 */ +function makeMachine(root, name) { + const home = path.join(root, name); + const dirs = [ + ".zcode/cli/plugins/data", + ".zcode/cli/plugins/marketplaces/zcode-plugins-official", + ".zcode/plugins", + ".dsh/secrets", + ".ssh", + ]; + for (const d of dirs) fs.mkdirSync(path.join(home, d), { recursive: true }); + return home; +} + +/** 造这台机器的 ZCode 环境(插件/MCP/密钥),路径全部指向本机 home */ +function seedMachine(home, { user, mcpExtra = {} } = {}) { + const z = path.join(home, ".zcode"), d = path.join(home, ".dsh"); + + // 官方插件清单(市场远端的) + fs.writeFileSync(path.join(z, "cli/plugins/marketplaces/zcode-plugins-official/marketplace.json"), + JSON.stringify({ plugins: [ + { name: "browser-use", version: "0.4.2" }, + { name: "document-skills", version: "0.1.4" }, + { name: "computer-use", version: "0.5.14" }, + ] }, null, 2)); + + // 启用态 = data/ 目录 + for (const n of ["browser-use@zcode-plugins-official", "document-skills@zcode-plugins-official", "zcode-tps@inline"]) { + fs.mkdirSync(path.join(z, "cli/plugins/data", n), { recursive: true }); + } + + // 自研插件(带 .zcode-plugin/plugin.json);含 CRLF 文件,用于验证 git 不改写换行符 + for (const [nm, ver, files] of [["zcode-tps", "0.4.1", { "scripts/rate.mjs": "export const v=1;\r\n// CRLF 文件:git 若做换行符转换,hash 就会与 manifest 对不上\r\n" }], + ["zcode-usage", "1.0.0", { "scripts/u.mjs": "export const v=2;\n" }]]) { + const dir = path.join(z, "plugins", nm); + fs.mkdirSync(path.join(dir, ".zcode-plugin"), { recursive: true }); + fs.writeFileSync(path.join(dir, ".zcode-plugin/plugin.json"), JSON.stringify({ name: nm, version: ver })); + for (const [rel, body] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true }); + fs.writeFileSync(path.join(dir, rel), body); + } + } + + // cli/config.json —— MCP 全用本机绝对路径,含反斜杠与正斜杠两种写法 + const nf = (p) => p.replace(/\\/g, "/"); // 正斜杠风格(本机真实风格) + const nb = (p) => p.replace(/\//g, "\\"); // 反斜杠风格(WM 上常见) + const cfg = { + mcp: { servers: { + context7: { type: "stdio", command: nb(path.join(home, "tools", "node.exe")), args: [nf(path.join(home, "tools", "ctx7", "dist", "index.js"))] }, + gitea: { type: "stdio", command: nb(path.join(d, "bin", "gitea-mcp.exe")), args: ["-H", "https://gitea.example.invalid"], + env: { GITEA_ACCESS_TOKEN_FILE: nf(path.join(d, "secrets", "gitea-token.txt")) } }, + mysql: { type: "stdio", command: nb(path.join(home, "tools", "node.exe")), args: [nf(path.join(d, "secrets", "mysql-mcp-launch.mjs"))] }, + ssh: { type: "stdio", command: nb(path.join(home, "tools", "node.exe")), args: [nf(path.join(home, "tools", "ssh-mcp", "index.js"))], + env: { SSH_PROFILES_FILE: nf(path.join(home, ".ssh", "ssh-profiles.json")) } }, + ...mcpExtra, + } }, + plugins: { enabled: true, dirs: [ + nf(path.join(z, "plugins", "zcode-tps")), + nf(path.join(z, "plugins", "zcode-usage")), + ] }, + }; + fs.writeFileSync(path.join(z, "cli", "config.json"), JSON.stringify(cfg, null, 2)); + + // 密钥 + fs.writeFileSync(path.join(d, "secrets", "gitea-token.txt"), `tok-${user}-${"x".repeat(28)}\n`); + fs.writeFileSync(path.join(d, "secrets", "mysql-root-password.txt"), `pw-${user}-mysql`); + fs.writeFileSync(path.join(d, "secrets", "mysql-mcp-launch.mjs"), `// launcher for ${user}\nexport default 1;\n`); + fs.writeFileSync(path.join(home, ".ssh", "ssh-profiles.json"), + JSON.stringify({ profiles: { box1: { host: `${user}.example.invalid`, port: 22, user } } }, null, 2)); + + return cfg; +} + +/** 子进程环境:把 HOME 指向假机器。extra 后应用,可覆盖任何继承值 */ +function envFor(home, extra = {}) { + const env = { ...process.env, ...GIT_ENV, USERPROFILE: home, HOME: home }; + delete env.HOMEDRIVE; + delete env.HOMEPATH; + delete env.ZCODE_SYNC_ALLOW_SECRETS; + return { ...env, ...extra }; +} + +function runScript(script, { home, args = [], env = {} }) { + const r = spawnSync(process.execPath, [path.join(SCRIPTS, script), ...args], { + env: envFor(home, env), encoding: "utf8", timeout: 120000, + }); + return { code: r.status, out: r.stdout ?? "", err: r.stderr ?? "" }; +} + +function git(args, opts = {}) { + return execFileSync("git", args, { encoding: "utf8", ...GIT_ENV, ...opts }); +} +function readJson(f) { return JSON.parse(fs.readFileSync(f, "utf8")); } +function sha256File(f) { return crypto.createHash("sha256").update(fs.readFileSync(f)).digest("hex"); } +/** 比较路径时归一化分隔符:还原保留各字段原本的正/反斜杠风格,不该强求统一 */ +function norm(p) { return String(p).replace(/\\/g, "/").replace(/\/+$/, ""); } + +/* ---------- 主流程(一次搭台,多个断言) ---------- */ + +test("E2E:A 机导出 → 裸仓 → B 机导入,跨机器路径正确还原", { timeout: 180000 }, () => { + const root = sandboxRoot(); + const bare = path.join(root, "gitea.git"); + git(["init", "--bare", "-b", "main", bare]); + + const homeA = makeMachine(root, "machineA"), homeB = makeMachine(root, "machineB"); + seedMachine(homeA, { user: "alice" }); + seedMachine(homeB, { user: "bob" }); + + const NSYNC = path.join(root, "nsync"); + const commonEnv = { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: path.join(NSYNC, "work") }; + + /* ---- A 机导出并推送 ---- */ + const ex = runScript("export.mjs", { home: homeA, env: commonEnv }); + assert.equal(ex.code, 0, `导出应成功\nstdout=${ex.out}\nstderr=${ex.err}`); + assert.match(ex.out, /# 导出成功/); + assert.match(ex.out, /已推送/, `应真的推到裸仓\n${ex.out}`); + + const repoA = path.join(NSYNC, "work", "repo"); + const latest = readJson(path.join(repoA, "latest.json")); + const snap = path.join(repoA, latest.snapshot); + + // 快照结构齐全 + for (const f of ["manifest.json", "mcp.servers.json", "plugins-dirs.json", "secrets.enc", "secrets.manifest.json"]) { + assert.ok(fs.existsSync(path.join(snap, f)), `快照缺 ${f}`); + } + assert.ok(fs.existsSync(path.join(snap, "custom-plugins", "zcode-tps", ".zcode-plugin", "plugin.json")), "自研插件应被带进快照"); + + /* ---- 关键断言:明文区不含 A 机路径,且占位符是活的 ---- */ + const mcpText = fs.readFileSync(path.join(snap, "mcp.servers.json"), "utf8"); + const dirsText = fs.readFileSync(path.join(snap, "plugins-dirs.json"), "utf8"); + assert.doesNotMatch(mcpText, /machineA/, "MCP 明文不得残留 A 机路径(含反斜杠写法)"); + assert.doesNotMatch(dirsText, /machineA/, "plugins.dirs 不得残留 A 机路径"); + assert.match(mcpText, /\$\{USER_HOME_WIN\}|\$\{USER_HOME\}/, "应使用主目录占位符"); + assert.match(mcpText, /\$\{DSH_HOME_WIN\}|\$\{DSH_HOME\}/, "应使用 DSH 占位符"); + assert.doesNotMatch(mcpText, /AppData|machineA/i); + + // 明文区无密钥值(只有 *_FILE 引用) + const mcpObj = JSON.parse(mcpText); + assert.ok(mcpObj.gitea.env.GITEA_ACCESS_TOKEN_FILE.includes("gitea-token.txt"), "密钥应以文件引用形式出现"); + + // secrets.manifest 只有路径+hash,没有值 + const secMan = readJson(path.join(snap, "secrets.manifest.json")); + const secText = JSON.stringify(secMan); + assert.doesNotMatch(secText, /tok-alice|pw-alice/, "清单里不得出现密钥明文"); + assert.ok(secMan.some((f) => f.rel === "DSH_SECRETS/gitea-token.txt")); + + // manifest 全文(明文进仓)不得带出本机绝对路径/用户名 + const manText = fs.readFileSync(path.join(snap, "manifest.json"), "utf8"); + assert.doesNotMatch(manText, /machineA/, "manifest 不得残留本机 home 路径"); + assert.match(JSON.parse(manText).zcodeHomeTemplate, /\$\{ZCODE_HOME\}/, "应存占位符模板而非绝对路径"); + + // 配置区(MCP/dirs/manifest)不得残留本机路径;插件源码里的硬编码只警告不中断 + assert.doesNotMatch(mcpText, /machineA/, "MCP 配置区不得残留"); + assert.doesNotMatch(dirsText, /machineA/, "plugins.dirs 不得残留"); + assert.match(ex.out, /明文区自检:无密钥泄漏/, "应报告自检通过"); + + // 密文里不得出现明文密钥 + const encText = fs.readFileSync(path.join(snap, "secrets.enc"), "utf8"); + assert.doesNotMatch(encText, /tok-alice|pw-alice/, "密钥束必须是密文"); + + /* ---- git:origin 已补上、commit 已产生、追踪引用健康 ---- */ + const remoteUrl = git(["-C", repoA, "remote", "get-url", "origin"]).trim(); + assert.ok(remoteUrl.length > 0, "origin 必须存在(旧实现克隆失败后不补 origin)"); + assert.doesNotMatch(remoteUrl, /@/, "origin 明文里不得内嵌 token(凭据只在内存里用)"); + const cfgText = fs.readFileSync(path.join(repoA, ".git", "config"), "utf8"); + assert.doesNotMatch(cfgText, /tok-alice|ghp_|@gitea|@.*\.git/, ".git/config 不得落 token"); + assert.match(git(["-C", repoA, "log", "--oneline"]), /sync-export/, "应有导出 commit"); + assert.match(git(["--git-dir", bare, "log", "--oneline", "main"]), /sync-export/, "裸仓应收到提交"); + // 追踪引用必须可用,否则 git status/log origin/main 全是残废状态 + const headA = git(["-C", repoA, "rev-parse", "HEAD"]).trim(); + assert.equal(git(["-C", repoA, "rev-parse", "origin/main"]).trim(), headA, "origin/main 应指向已推送的提交"); + assert.equal(git(["-C", repoA, "rev-parse", "main@{upstream}"]).trim(), headA, "main 的 upstream 应已绑定"); + + /* ---- 字节完整性:克隆一份远端,校验内容 hash 与 manifest 一致 ---- */ + // Windows 上 core.autocrlf 默认可能是 true,会改写换行符让 hash 永久漂移 + assert.equal(git(["-C", repoA, "config", "--local", "core.autocrlf"]).trim(), "false", "本仓应禁用 autocrlf"); + assert.equal(fs.readFileSync(path.join(repoA, ".gitattributes"), "utf8").includes("* -text"), true, "应有 .gitattributes 锁死换行符转换"); + + const cloneDir = path.join(root, "verify-clone"); + git(["clone", "-q", bare, cloneDir]); + const manifestA = readSnapshotManifest(path.join(cloneDir, latest.snapshot)); + for (const c of manifestA.custom) { + const got = hashDir(path.join(cloneDir, latest.snapshot, "custom-plugins", c.name)); + assert.equal(got, c.hash, `克隆回来的 ${c.name} 内容 hash 必须与 manifest 一致(实际 ${got} vs ${c.hash})`); + } + // CRLF 文件必须原样穿过 git + const crlfFile = path.join(cloneDir, latest.snapshot, "custom-plugins", "zcode-tps", "scripts", "rate.mjs"); + assert.match(fs.readFileSync(crlfFile, "utf8"), /\r\n/, "CRLF 不得被 git 改写成 LF"); + assert.equal(fs.readFileSync(crlfFile, "utf8"), "export const v=1;\r\n// CRLF 文件:git 若做换行符转换,hash 就会与 manifest 对不上\r\n", "内容必须逐字节一致"); + + /* ---- B 机导入 ---- */ + const im = runScript("import.mjs", { home: homeB, env: commonEnv }); + assert.equal(im.code, 0, `导入应成功\nstdout=${im.out}\nstderr=${im.err}`); + assert.match(im.out, /# 导入快照/); + assert.match(im.out, /自研插件还原:2 项/); + assert.match(im.out, /密钥束还原:4 个文件/); + + /* ---- 核心:路径已还原成 B 机的 ---- */ + const cfgB = readJson(path.join(homeB, ".zcode", "cli", "config.json")); + const s = cfgB.mcp.servers; + const hB = norm(homeB); + assert.ok(s.context7.command.endsWith("node.exe")); + assert.ok(norm(s.context7.command).startsWith(hB), `command 应指向 B 机主目录,实际=${s.context7.command}`); + assert.ok(norm(s.mysql.args[0]).startsWith(hB), `mysql 启动脚本应指向 B 机,实际=${s.mysql.args[0]}`); + assert.ok(norm(s.ssh.env.SSH_PROFILES_FILE).startsWith(hB), "SSH_PROFILES_FILE 应指向 B 机"); + assert.ok(norm(s.gitea.env.GITEA_ACCESS_TOKEN_FILE).startsWith(hB), "token 文件应指向 B 机"); + assert.equal(norm(s.context7.args[0]), `${hB}/tools/ctx7/dist/index.js`, + "嵌套 args 路径应精确还原"); + assert.doesNotMatch(JSON.stringify(cfgB), /machineA/, "B 机配置里不得残留 A 机路径"); + assert.doesNotMatch(JSON.stringify(cfgB), /\$\{/, "B 机配置里不得残留未还原的占位符"); + // 密钥文件引用字段:风格保持原样(反斜杠写法不应被强行转成正斜杠) + assert.ok(s.gitea.env.GITEA_ACCESS_TOKEN_FILE.includes("/"), "正斜杠风格应保留"); + + // plugins.dirs:B 机自己的 + 快照里的(A 机的已还原成 B 机),且去重 + const dirsB = cfgB.plugins.dirs; + assert.deepEqual(new Set(dirsB).size, dirsB.length, "dirs 不得重复"); + for (const d of dirsB) assert.match(d, /machineB/, `dirs 应全部指向 B 机:${d}`); + + // 自研插件已落地 + assert.ok(fs.existsSync(path.join(homeB, ".zcode/plugins/zcode-tps/scripts/rate.mjs")), "自研插件文件应还原"); + + /* ---- 密钥已解密落地,且 hash 与清单一致 ---- */ + const giteaTok = path.join(homeB, ".dsh", "secrets", "gitea-token.txt"); + assert.ok(fs.existsSync(giteaTok), "密钥文件应还原"); + const want = new Map(secMan.map((f) => [f.rel, f.sha256])); + assert.equal(sha256File(giteaTok), want.get("DSH_SECRETS/gitea-token.txt"), "还原内容应与 A 机快照一致"); + assert.match(fs.readFileSync(giteaTok, "utf8"), /tok-alice/, "密钥内容应原样带过来(跨机器共享凭据)"); + assert.equal(sha256File(path.join(homeB, ".ssh", "ssh-profiles.json")), want.get("SSH/ssh-profiles.json")); + + /* ---- 覆盖前已备份 ---- */ + const bkRoot = path.join(NSYNC, "work", "backup"); + assert.ok(fs.existsSync(bkRoot), "应产生备份目录"); + const stamps = fs.readdirSync(bkRoot); + assert.ok(stamps.length >= 1, "应至少有一个备份批次"); + const bFiles = stamps.flatMap((st) => { + const walk = (d) => fs.readdirSync(d, { withFileTypes: true }).flatMap((e) => + e.isDirectory() ? walk(path.join(d, e.name)) : [path.join(d, e.name)]); + return walk(path.join(bkRoot, st)); + }); + assert.ok(bFiles.some((f) => f.endsWith("config.json")), "被覆盖的 config.json 应有备份"); + assert.ok(bFiles.some((f) => f.endsWith("gitea-token.txt")), "被覆盖的密钥应有备份"); + const bkCfg = bFiles.find((f) => f.endsWith("config.json")); + assert.match(fs.readFileSync(bkCfg, "utf8"), /machineB/, "备份的应是 B 机导入前的旧文件"); + + /* ---- B 机再跑一次 status,应报告与本机一致 ---- */ + const st = runScript("status.mjs", { home: homeB, env: commonEnv }); + assert.equal(st.code, 0, `status 应成功\n${st.err}`); + assert.match(st.out, /自研插件:一致/); + assert.match(st.out, /密钥束:清单一致/); + + fs.rmSync(root, { recursive: true, force: true }); +}); + +test("E2E:plugins/ 下的隐藏备份目录不得被当插件(旧实现会虚报并互相覆盖)", { timeout: 120000 }, () => { + const root = sandboxRoot(); + const home = makeMachine(root, "machineF"); + const cfg = seedMachine(home, { user: "frank" }); + const z = path.join(home, ".zcode"); + + // 伪造一个插件备份目录:内容与 zcode-tps 同名,但版本不同 + const bk = path.join(z, "plugins", ".backup-zcode-tps-20260911"); + fs.mkdirSync(path.join(bk, ".zcode-plugin"), { recursive: true }); + fs.writeFileSync(path.join(bk, ".zcode-plugin", "plugin.json"), + JSON.stringify({ name: "zcode-tps", version: "0.0.0-old" })); + + const w = path.join(root, "w"); + const r = runScript("export.mjs", { home, env: { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_NO_PUSH: "1", ZCODE_SYNC_WORK: w } }); + assert.equal(r.code, 0, `导出应成功(隐藏目录被跳过)\n${r.err}`); + assert.doesNotMatch(r.out, /0\.0\.0-old/, "备份目录版本不得出现在报告里"); + + const latest = readJson(path.join(w, "repo", "latest.json")); + const m = readJson(path.join(w, "repo", latest.snapshot, "manifest.json")); + const names = m.custom.map((c) => c.name); + assert.deepEqual(new Set(names).size, names.length, `manifest 不得有重名:${names.join(",")}`); + assert.equal(names.filter((n) => n === "zcode-tps").length, 1, "zcode-tps 只应出现一次"); + assert.equal(m.custom.find((c) => c.name === "zcode-tps").version, "0.4.1", "应是真插件版本"); + + // 快照里的实际目录数应与 manifest 一致 + const cpDir = path.join(w, "repo", latest.snapshot, "custom-plugins"); + assert.deepEqual(fs.readdirSync(cpDir).sort(), names.sort(), "快照目录必须与 manifest 一致"); + + // 真插件内容被复制,而不是备份版本 + const rj = readJson(path.join(cpDir, "zcode-tps", ".zcode-plugin", "plugin.json")); + assert.equal(rj.version, "0.4.1"); + + fs.rmSync(root, { recursive: true, force: true }); +}); + +test("E2E:两个插件 manifest 同名 → 导出明确报错,不静默丢一个", { timeout: 120000 }, () => { + const root = sandboxRoot(); + const home = makeMachine(root, "machineG"); + seedMachine(home, { user: "grace" }); + const z = path.join(home, ".zcode"); + + // dir 名不同,但 plugin.json 里的 name 相同 + const clash = path.join(z, "plugins", "zcode-tps-fork"); + fs.mkdirSync(path.join(clash, ".zcode-plugin"), { recursive: true }); + fs.writeFileSync(path.join(clash, ".zcode-plugin", "plugin.json"), JSON.stringify({ name: "zcode-tps", version: "9.9.9" })); + + const w = path.join(root, "w"); + const r = runScript("export.mjs", { home, env: { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_NO_PUSH: "1", ZCODE_SYNC_WORK: w } }); + assert.notEqual(r.code, 0, "重名必须中断导出"); + assert.match(r.err, /名字冲突|冲突/); + assert.ok(!fs.existsSync(path.join(w, "repo", "latest.json")), "中断时不得推进 latest.json"); + + fs.rmSync(root, { recursive: true, force: true }); +}); + +test("E2E:开启模型同步 → provider 脱敏进快照,真密钥一个字节都不许留", { timeout: 120000 }, () => { + const root = sandboxRoot(); + const home = makeMachine(root, "machineH"); + seedMachine(home, { user: "henry" }); + + // 造一份带真密钥的 v2/config.json + const SECRET_A = "sk-providerAAAAsecret9999", SECRET_B = "sk-providerBBBBsecret8888"; + const v2 = path.join(home, ".zcode", "v2"); + fs.mkdirSync(v2, { recursive: true }); + fs.writeFileSync(path.join(v2, "config.json"), JSON.stringify({ provider: { + "builtin:x": { name: "X", kind: "anthropic", models: { m1: {}, m2: {} }, options: { apiKey: SECRET_A } }, + "custom:y": { name: "Y", kind: "openai-compatible", models: { m3: {} }, options: { apiKey: SECRET_B, baseURL: "https://api.example.invalid/v1" } }, + } }, null, 2)); + fs.writeFileSync(path.join(v2, "setting.json"), JSON.stringify({ + modelProviderFamilyModes: { a: "auto" }, + modelProviderFamilySelectedKeys: { a: "builtin:x" }, + providerFamilyDomain: "example.invalid", + windowState: { should: "not-be-synced" }, + }, null, 2)); + + const w = path.join(root, "w"); + const r = runScript("export.mjs", { home, env: { + ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_NO_PUSH: "1", ZCODE_SYNC_WORK: w, ZCODE_SYNC_WITH_MODELS: "1", + } }); + assert.equal(r.code, 0, `导出应成功\n${r.err}`); + assert.match(r.out, /模型 provider:2 个/, `应报告 2 个 provider → ${(r.out.match(/模型 provider:[^\n]*/) || ["—"])[0]}`); + assert.match(r.out, /明文区自检:无密钥泄漏/); + + const latest = readJson(path.join(w, "repo", "latest.json")); + const snap = path.join(w, "repo", latest.snapshot); + const m = readJson(path.join(snap, "manifest.json")); + + // manifest 只记结构 + assert.equal(m.modelProviders.length, 2); + assert.deepEqual(m.modelProvidersWithSecrets.sort(), ["builtin:x", "custom:y"], "应标记哪些 provider 带密钥"); + assert.doesNotMatch(JSON.stringify(m), /sk-provider/, "manifest 不得含密钥值"); + assert.deepEqual(m.modelProviders.find((p) => p.id === "builtin:x").models, ["m1", "m2"], "应记录模型清单"); + assert.equal(m.modelSelection.providerFamilyDomain, "example.invalid"); + assert.ok(!("windowState" in (m.modelSelection ?? {})), "窗口状态之类不得进 manifest"); + + // 脱敏配置本体:结构在、密钥没了 + const provs = readJson(path.join(snap, "v2.providers.json")); + assert.equal(provs["builtin:x"].kind, "anthropic", "非密钥字段保留"); + assert.deepEqual(Object.keys(provs["builtin:x"].models), ["m1", "m2"]); + assert.equal(provs["builtin:x"].options.apiKey, "${MODEL_SECRET_REF}", "密钥位置应为占位符"); + assert.equal(provs["custom:y"].options.baseURL, "https://api.example.invalid/v1", "非密钥选项保留"); + + // 全快照明文扫描:真密钥一个都不许出现(secrets.enc 是密文,单独放行) + const walkFiles = (d, acc = []) => { + for (const e of fs.readdirSync(d, { withFileTypes: true })) { + const p = path.join(d, e.name); + if (e.isDirectory()) walkFiles(p, acc); else acc.push(p); + } + return acc; + }; + for (const f of walkFiles(snap)) { + if (path.basename(f) === "secrets.enc") continue; + const body = fs.readFileSync(f, "utf8"); + assert.doesNotMatch(body, /sk-providerAAAAsecret9999|sk-providerBBBBsecret8888/, `密钥泄漏进 ${path.relative(snap, f)}`); + } + + // 导入侧:报告 provider,但默认不覆盖本机 v2/config.json + const im = runScript("import.mjs", { home, env: { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_NO_PUSH: "1", ZCODE_SYNC_WORK: w } }); + assert.equal(im.code, 0, `导入应成功\n${im.err}`); + assert.match(im.out, /模型 provider:快照含 2 个/, `→ ${(im.out.match(/模型 provider:[^\n]*/) || ["—"])[0]}`); + const localV2 = JSON.parse(fs.readFileSync(path.join(v2, "config.json"), "utf8")); + assert.equal(localV2.provider["builtin:x"].options.apiKey, SECRET_A, "默认不得改动本机 v2 配置"); + + // 显式开启后:密钥应从加密束回填(同一台机已有真值 → 受覆盖保护,不覆盖) + const im2 = runScript("import.mjs", { home, env: { + ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_NO_PUSH: "1", ZCODE_SYNC_WORK: w, ZCODE_SYNC_WITH_MODELS: "1", + } }); + assert.equal(im2.code, 0, `开启模型同步的导入应成功\n${im2.err}`); + assert.match(im2.out, /模型 provider:新增 \d+ 个,密钥回填 \d+ 个/, `→ ${(im2.out.match(/模型 provider:[^\n]*/) || ["—"])[0]}`); + const afterV2 = JSON.parse(fs.readFileSync(path.join(v2, "config.json"), "utf8")); + assert.equal(afterV2.provider["builtin:x"].options.apiKey, SECRET_A, "本机已有真值应受覆盖保护"); + assert.ok(/备份 v2\/config\.json/.test(im2.out), "改 v2 配置前应先备份"); + assert.ok(afterV2.provider["builtin:x"].options.apiKey.length >= 8, "密钥必须仍是可用值"); + + fs.rmSync(root, { recursive: true, force: true }); +}); + +test("E2E:空密钥的本机 → 模型密钥从加密束回填成功", { timeout: 120000 }, () => { + const root = sandboxRoot(); + const homeA = makeMachine(root, "machineK1"), homeB = makeMachine(root, "machineK2"); + seedMachine(homeA, { user: "kim" }); + seedMachine(homeB, { user: "leo" }); + + const SECRET = "sk-roundtripsecret777777"; + const v2A = path.join(homeA, ".zcode", "v2"); + fs.mkdirSync(v2A, { recursive: true }); + fs.writeFileSync(path.join(v2A, "config.json"), JSON.stringify({ provider: { + "builtin:q": { name: "Q", kind: "anthropic", models: { m1: {} }, options: { apiKey: SECRET } }, + } }, null, 2)); + + const bare = path.join(root, "bare.git"); + git(["init", "--bare", "-b", "main", bare]); + const W = path.join(root, "work"); + const env = { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: W }; + + // A 机导出(带模型) + const ex = runScript("export.mjs", { home: homeA, env: { ...env, ZCODE_SYNC_WITH_MODELS: "1" } }); + assert.equal(ex.code, 0, `A 机导出应成功\n${ex.err}`); + // 快照明文里不得有密钥 + const latest = readJson(path.join(W, "repo", "latest.json")); + const snap = path.join(W, "repo", latest.snapshot); + for (const f of fs.readdirSync(snap)) { + if (f === "secrets.enc") continue; + const p = path.join(snap, f); + if (fs.statSync(p).isFile()) { + assert.doesNotMatch(fs.readFileSync(p, "utf8"), /sk-roundtripsecret/, `密钥泄漏进 ${f}`); + } + } + + // B 机导入:B 机 v2 里没有这个 provider → 应新建并回填密钥 + const im = runScript("import.mjs", { home: homeB, env: { ...env, ZCODE_SYNC_WITH_MODELS: "1" } }); + assert.equal(im.code, 0, `B 机导入应成功\n${im.err}`); + const v2B = JSON.parse(fs.readFileSync(path.join(homeB, ".zcode", "v2", "config.json"), "utf8")); + assert.ok(v2B.provider?.["builtin:q"], "B 机应新建该 provider"); + assert.equal(v2B.provider["builtin:q"].options.apiKey, SECRET, "密钥应跨机回填成功"); + assert.equal(v2B.provider["builtin:q"].kind, "anthropic", "结构字段应一并还原"); + + fs.rmSync(root, { recursive: true, force: true }); +}); + +test("E2E:未开启模型同步时,provider 密钥绝不进快照", { timeout: 120000 }, () => { + const root = sandboxRoot(); + const home = makeMachine(root, "machineI"); + seedMachine(home, { user: "ivan" }); + const v2 = path.join(home, ".zcode", "v2"); + fs.mkdirSync(v2, { recursive: true }); + fs.writeFileSync(path.join(v2, "config.json"), JSON.stringify({ provider: { + "builtin:z": { name: "Z", kind: "anthropic", models: { m: {} }, options: { apiKey: "sk-must-never-appear123" } }, + } }, null, 2)); + + const w = path.join(root, "w"); + const r = runScript("export.mjs", { home, env: { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_NO_PUSH: "1", ZCODE_SYNC_WORK: w } }); + assert.equal(r.code, 0, `导出应成功\n${r.err}`); + assert.doesNotMatch(r.out, /模型 provider/, "默认不应报告 provider"); + const latest = readJson(path.join(w, "repo", "latest.json")); + const snap = path.join(w, "repo", latest.snapshot); + assert.ok(!fs.existsSync(path.join(snap, "v2.providers.json")), "默认不得写 provider 文件"); + assert.doesNotMatch(fs.readFileSync(path.join(snap, "manifest.json"), "utf8"), /sk-must-never/, "manifest 不得含密钥"); + assert.doesNotMatch(fs.readFileSync(path.join(snap, "manifest.json"), "utf8"), /modelProviders/, "默认不应记录 provider 摘要"); + + fs.rmSync(root, { recursive: true, force: true }); +}); + +test("E2E:ZCODE_SYNC_SKIP_SECRETS 跳过指定密钥(连接统一、身份各用各的)", { timeout: 120000 }, () => { + const root = sandboxRoot(); + const homeA = makeMachine(root, "machineS1"), homeB = makeMachine(root, "machineS2"); + seedMachine(homeA, { user: "sam" }); + seedMachine(homeB, { user: "tina" }); + + const bare = path.join(root, "bare.git"); + git(["init", "--bare", "-b", "main", bare]); + const W = path.join(root, "work"); + const env = { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: W }; + + const ex = runScript("export.mjs", { home: homeA, env }); + assert.equal(ex.code, 0, `A 机导出应成功\n${ex.err}`); + + // B 机导入时跳过 gitea-token:本机 token 必须原样保留 + const beforeTok = fs.readFileSync(path.join(homeB, ".dsh", "secrets", "gitea-token.txt"), "utf8"); + const im = runScript("import.mjs", { home: homeB, env: { ...env, ZCODE_SYNC_SKIP_SECRETS: "DSH_SECRETS/gitea-token.txt" } }); + assert.equal(im.code, 0, `B 机导入应成功\n${im.err}`); + assert.match(im.out, /跳过 1 个\(ZCODE_SYNC_SKIP_SECRETS\):DSH_SECRETS\/gitea-token.txt/); + const afterTok = fs.readFileSync(path.join(homeB, ".dsh", "secrets", "gitea-token.txt"), "utf8"); + assert.equal(afterTok, beforeTok, "被跳过的 token 不得被快照覆盖"); + // 没被跳过的照常还原 + const latest = readJson(path.join(W, "repo", "latest.json")); + assert.ok(latest.snapshot, "快照应存在"); + + fs.rmSync(root, { recursive: true, force: true }); +}); + +test("E2E:配置区残留本机路径 → 导出中断(旧实现会静默推出去)", { timeout: 120000 }, () => { + const root = sandboxRoot(); + const home = makeMachine(root, "machineJ"); + seedMachine(home, { user: "judy" }); + const w = path.join(root, "w"); + const env = { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_NO_PUSH: "1", ZCODE_SYNC_WORK: w }; + + // 塞一条「别的用户名」的绝对路径:占位符规则只认本机用户名,这条必然漏网, + // 但到了别人机器上就是错的 —— 正是自检要拦的东西 + const cfgPath = path.join(home, ".zcode", "cli", "config.json"); + const cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8")); + cfg.mcp.servers.weird = { type: "stdio", command: "C:\\Users\\someoneelse\\custom\\dir\\tool.exe", args: ["--x"] }; + fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2)); + + const r = runScript("export.mjs", { home, env }); + assert.notEqual(r.code, 0, "配置区含未占位符化的绝对路径必须中断"); + assert.match(r.err, /未占位符化的绝对路径|残留本机路径|占位符化不完整/); + assert.ok(!fs.existsSync(path.join(w, "repo", "latest.json")), "中断时不得推进 latest.json"); + + // 显式放行后应能导出 + const r2 = runScript("export.mjs", { home, env: { ...env, ZCODE_SYNC_WORK: path.join(root, "w2"), ZCODE_SYNC_ALLOW_PATH_LEAK: "1" } }); + assert.equal(r2.code, 0, `放行后应成功\n${r2.err}`); + + fs.rmSync(root, { recursive: true, force: true }); +}); + +/* ---------- 失败路径 ---------- */ + +test("E2E:口令错 → 导出直接失败,且不产生半截快照", { timeout: 120000 }, () => { + const root = sandboxRoot(); + const home = makeMachine(root, "machineC"); + seedMachine(home, { user: "carol" }); + const bare = path.join(root, "bare.git"); + git(["init", "--bare", "-b", "main", bare]); + + // 口令缺失 + const noPass = runScript("export.mjs", { home, env: { ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: path.join(root, "w1"), ZCODE_SYNC_PASSPHRASE: "" } }); + assert.notEqual(noPass.code, 0, "无口令必须失败"); + assert.match(noPass.err, /未设置|ZCODE_SYNC_PASSPHRASE/); + + // 口令过短 + const short = runScript("export.mjs", { home, env: { ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: path.join(root, "w2"), ZCODE_SYNC_PASSPHRASE: "abc" } }); + assert.notEqual(short.code, 0, "口令过短必须失败"); + assert.match(short.err, /太短/); + + // 导入方口令错 → 解密失败 + const w3 = path.join(root, "w3"); + const env3 = { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: w3 }; + const ok = runScript("export.mjs", { home, env: env3 }); + assert.equal(ok.code, 0, `导出应成功\n${ok.out}\n${ok.err}`); + const bad = runScript("import.mjs", { home, env: { ...env3, ZCODE_SYNC_PASSPHRASE: "totally-wrong-pass" } }); + assert.notEqual(bad.code, 0, "错误口令导入必须失败"); + assert.match(bad.err, /解密失败/); + + fs.rmSync(root, { recursive: true, force: true }); +}); + +test("E2E:明文区出现真密钥 → 导出中断(不落盘)", { timeout: 120000 }, () => { + const root = sandboxRoot(); + const home = makeMachine(root, "machineD"); + const bare = path.join(root, "bare.git"); + git(["init", "--bare", "-b", "main", bare]); + // 故意把一个真密钥写进 MCP env(而不是 *_FILE 引用) + seedMachine(home, { user: "dave", mcpExtra: { + leaky: { type: "stdio", command: "node", env: { GITEA_TOKEN: "ghp_realtokenvalue12345" } }, + } }); + const w = path.join(root, "w"); + const r = runScript("export.mjs", { home, env: { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: w } }); + assert.notEqual(r.code, 0, "含明文密钥必须中断导出"); + assert.match(r.err, /疑似含密钥/); + assert.ok(!fs.existsSync(path.join(w, "repo", "latest.json")), "中断时不得推进 latest.json"); + + // 白名单放行 + const r2 = runScript("export.mjs", { home, env: { + ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: path.join(root, "w2"), + ZCODE_SYNC_ALLOW_SECRETS: "$.mcpServers.leaky.env.GITEA_TOKEN", + } }); + assert.equal(r2.code, 0, `白名单应放行\n${r2.err}`); + assert.match(r2.out, /# 导出成功/); + + fs.rmSync(root, { recursive: true, force: true }); +}); + +test("E2E:自研插件源码里写死同步口令 → 导出中断(钥匙不许跟保险箱同仓)", { timeout: 120000 }, () => { + const root = sandboxRoot(); + const home = makeMachine(root, "machineK"); + seedMachine(home, { user: "kim" }); + // 模拟 live-roundtrip.mjs 曾经的样子:测试文件回退到写死口令, + // 而它作为自研插件源码会被整体归档进快照 —— 加密就形同虚设 + fs.writeFileSync(path.join(home, ".zcode", "plugins", "zcode-tps", "scripts", "pass.mjs"), + `const PASS = process.env.ZCODE_SYNC_PASSPHRASE || "${PASS}";\nexport default PASS;\n`); + const w = path.join(root, "w"); + const r = runScript("export.mjs", { home, env: { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_NO_PUSH: "1", ZCODE_SYNC_WORK: w } }); + assert.notEqual(r.code, 0, "源码含口令字面量必须中断导出"); + assert.match(r.err, /同步口令本体/); + assert.ok(!fs.existsSync(path.join(w, "repo", "latest.json")), "中断时不得推进 latest.json"); + + // 改成从环境变量读取后,同一个插件应当能正常导出 + fs.writeFileSync(path.join(home, ".zcode", "plugins", "zcode-tps", "scripts", "pass.mjs"), + "const PASS = process.env.ZCODE_SYNC_PASSPHRASE;\nexport default PASS;\n"); + const r2 = runScript("export.mjs", { home, env: { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_NO_PUSH: "1", ZCODE_SYNC_WORK: path.join(root, "w2") } }); + assert.equal(r2.code, 0, `去掉字面量后应成功\n${r2.err}`); + + fs.rmSync(root, { recursive: true, force: true }); +}); + +test("E2E:无推送模式只落本地,不碰远端", { timeout: 120000 }, () => { + const root = sandboxRoot(); + const home = makeMachine(root, "machineE"); + seedMachine(home, { user: "erin" }); + const w = path.join(root, "w"); + const r = runScript("export.mjs", { home, env: { + ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_NO_PUSH: "1", ZCODE_SYNC_WORK: w, + ZCODE_SYNC_GIT_REMOTE: path.join(root, "nonexistent.git"), + } }); + assert.equal(r.code, 0, `无推送应成功\n${r.err}`); + assert.match(r.out, /跳过推送/); + assert.ok(fs.existsSync(path.join(w, "repo", "latest.json")), "本地快照应落盘"); + + // status 在无远端快照时给友好提示而非报错 + const st = runScript("status.mjs", { home, env: { ZCODE_SYNC_WORK: path.join(root, "w2"), ZCODE_SYNC_GIT_REMOTE: path.join(root, "none2.git") } }); + assert.equal(st.code, 0, `status 应优雅退出\n${st.err}`); + assert.match(st.out, /远端尚无快照/); + + fs.rmSync(root, { recursive: true, force: true }); +}); diff --git a/zcode-env-sync/tests/live-roundtrip.mjs b/zcode-env-sync/tests/live-roundtrip.mjs new file mode 100644 index 0000000..1c871bf --- /dev/null +++ b/zcode-env-sync/tests/live-roundtrip.mjs @@ -0,0 +1,148 @@ +/** + * 真机往返验证:用假 home 走真实 Gitea 私仓(不发任何本地配置改动)。 + * - 从 token 文件读凭据(验证 readToken 路径) + * - 真 clone 私仓 + * - 解密密钥束 + * - 把路径还原到"假机器"上 + * 跑法: node tests/live-roundtrip.mjs + */ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import crypto from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; + +const SCRIPTS = path.resolve(import.meta.dirname, "..", "scripts"); +// 不回退到写死口令:本文件会作为自研插件源码被归档进快照,字面量一旦落在这里, +// 就等于把 secrets.enc 的钥匙连同快照一起推进仓库(export 的自检现在也会拦)。 +const PASS = process.env.ZCODE_SYNC_PASSPHRASE; +if (!PASS) { + console.error("未设置 ZCODE_SYNC_PASSPHRASE:本测试要用真实口令走真实私仓,拒绝使用写死的默认口令。"); + console.error("用法: ZCODE_SYNC_PASSPHRASE='团队口令' node tests/live-roundtrip.mjs"); + process.exit(1); +} +const REAL_HOME = os.homedir(); +const TOKEN_SRC = path.join(REAL_HOME, ".dsh", "secrets", "gitea-token.txt"); + +// 假机器建在 C:/ 根下,而不是真实用户目录里: +// 1) 路径里不含导出机的用户名,才能真正验证「跨机器不残留」 +// 2) C:/Users/<你> 这类字段会按 ${USERS_*} 还原成 C:/Users/<假机器名>,与真实跨机行为一致 +const root = fs.mkdtempSync("C:/zes-live-"); +const fake = path.join(root, "fakemachine"); +const FAKE_USER = path.basename(fake); +/** 跨机器还原后路径应有的两种形态 */ +const HOME_CLASS = fake.replace(/\\/g, "/"); // ${ZCODE_HOME} ${DSH_HOME} ${USER_HOME} +const USER_CLASS = `C:/Users/${FAKE_USER}`; // ${USERS_POSIX} ${USERS_WIN} +const log = []; +const ok = (c, m) => { log.push(`${c ? " ok " : " FAIL "} ${m}`); if (!c) process.exitCode = 1; }; + +function run(script, args = []) { + const env = { ...process.env, USERPROFILE: fake, HOME: fake, ZCODE_SYNC_PASSPHRASE: PASS, + ZCODE_SYNC_WORK: path.join(root, "work") }; + delete env.HOMEDRIVE; delete env.HOMEPATH; delete env.ZCODE_SYNC_GIT_REMOTE; + delete env.ZCODE_SYNC_NO_PUSH; delete env.ZCODE_SYNC_ALLOW_SECRETS; + const r = spawnSync(process.execPath, [path.join(SCRIPTS, script), ...args], { env, encoding: "utf8", timeout: 120000 }); + return { code: r.status, out: r.stdout ?? "", err: r.stderr ?? "" }; +} + +// 造假机器骨架 + 真实 token(让脚本走 readToken 分支) +fs.mkdirSync(path.join(fake, ".dsh", "secrets"), { recursive: true }); +fs.mkdirSync(path.join(fake, ".ssh"), { recursive: true }); +fs.mkdirSync(path.join(fake, ".zcode", "cli", "plugins", "data"), { recursive: true }); +fs.mkdirSync(path.join(fake, ".zcode", "plugins"), { recursive: true }); +fs.copyFileSync(TOKEN_SRC, path.join(fake, ".dsh", "secrets", "gitea-token.txt")); +ok(true, `假机器:${fake}`); + +// 放一份"本机将被覆盖"的旧配置,验证备份 +fs.writeFileSync(path.join(fake, ".dsh", "secrets", "gitea-token.txt"), fs.readFileSync(TOKEN_SRC)); +fs.writeFileSync(path.join(fake, ".dsh", "secrets", "mysql-root-password.txt"), "OLD-FAKE-PASSWORD"); +fs.writeFileSync(path.join(fake, ".ssh", "ssh-profiles.json"), JSON.stringify({ profiles: { old: { host: "old.invalid" } } })); +fs.writeFileSync(path.join(fake, ".zcode", "cli", "config.json"), JSON.stringify({ mcp: { servers: { stale: { command: "stale" } } }, plugins: { dirs: [] } }, null, 2)); + +/* ---- 1. 真 clone + 还原 ---- */ +const im = run("import.mjs"); +ok(im.code === 0, `import 退出码 0(实际 ${im.code})`); +if (im.code !== 0) console.log(im.err); +ok(/# 导入快照/.test(im.out), "输出含「# 导入快照」"); +ok(/自研插件还原:3 项/.test(im.out), `自研插件还原 3 项 → ${(im.out.match(/自研插件还原:[^\n]*/) || ["—"])[0]}`); +ok(/密钥束还原:8 个文件/.test(im.out), `密钥束还原 8 个文件 → ${(im.out.match(/密钥束还原:[^\n]*/) || ["—"])[0]}`); +ok(/本机备份:/.test(im.out), "报告了备份位置"); + +const repo = path.join(root, "work", "repo"); +ok(fs.existsSync(path.join(repo, ".git")), "从真 Gitea clone 成功"); +ok(fs.existsSync(path.join(repo, "latest.json")), "拿到 latest.json"); +const cfgText = fs.readFileSync(path.join(repo, ".git", "config"), "utf8"); +ok(!/@/.test(cfgText.match(/url = .*/)?.[0] ?? ""), `.git/config 里 origin 不含 token(${(cfgText.match(/url = .*/) || ["?"])[0]})`); + +/* ---- 2. 核心:路径还原到假机器 ---- */ +const cfg = JSON.parse(fs.readFileSync(path.join(fake, ".zcode", "cli", "config.json"), "utf8")); +const servers = cfg.mcp.servers; +ok(!("stale" in servers), "旧 MCP server 已被快照内容替换"); +ok(Object.keys(servers).length === 14, `MCP server 共 14 个(实际 ${Object.keys(servers).length})`); + +const all = JSON.stringify(cfg); +ok(!/12914/.test(all), "还原后配置里不得残留导出机的用户名 12914"); +ok(!/\$\{/.test(all), "还原后不得残留未替换的占位符"); + +const pathFields = []; +/** 只挑真正像文件系统路径的值:URL、开关、端口之类不参与重定向断言 */ +const looksLikePath = (v) => typeof v === "string" && !/^[a-z][a-z0-9+.-]*:\/\//i.test(v) && /(^[a-zA-Z]:[\\/]|^\/)/.test(v); +for (const [nm, s] of Object.entries(servers)) { + if (looksLikePath(s.command)) pathFields.push([`${nm}.command`, s.command]); + (s.args ?? []).forEach((a, i) => { if (looksLikePath(a)) pathFields.push([`${nm}.args[${i}]`, a]); }); + for (const [k, v] of Object.entries(s.env ?? {})) if (looksLikePath(v)) pathFields.push([`${nm}.env.${k}`, v]); +} +const wrong = pathFields.filter(([, v]) => { + const n = v.replace(/\\/g, "/"); + return !n.startsWith(HOME_CLASS) && !n.startsWith(USER_CLASS); +}); +ok(wrong.length === 0, `全部 ${pathFields.length} 条路径字段都已重定向(本机 home 或 ${USER_CLASS})${wrong.length ? " 例外:" + JSON.stringify(wrong.slice(0, 3)) : ""}`); + +const mysqlLaunch = servers.mysql.args[0].replace(/\\/g, "/"); +ok(mysqlLaunch === `${HOME_CLASS}/.dsh/secrets/mysql-mcp-launch.mjs`, `mysql 启动脚本精确还原 → ${mysqlLaunch}`); +// C:/Users/<你>/... 这类字段按 ${USERS_*} 还原成 C:/Users/<本机用户名>,与真实跨机行为一致 +ok(servers.ssh.env.SSH_PROFILES_FILE.replace(/\\/g, "/") === `${USER_CLASS}/.ssh/ssh-profiles.json`, + `SSH_PROFILES_FILE 精确还原 → ${servers.ssh.env.SSH_PROFILES_FILE}`); + +/* ---- 3. 密钥:解密 + 内容 + 备份 ---- */const tok = fs.readFileSync(path.join(fake, ".dsh", "secrets", "gitea-token.txt"), "utf8").trim(); +ok(tok.length === 40, `gitea token 已解密(长度 ${tok.length})`); +const realTok = fs.readFileSync(TOKEN_SRC, "utf8").trim(); +ok(tok === realTok, "token 与导出机一致"); +const pw = fs.readFileSync(path.join(fake, ".dsh", "secrets", "mysql-root-password.txt"), "utf8"); +ok(pw !== "OLD-FAKE-PASSWORD", "旧密码已被快照版本覆盖"); +const sp = JSON.parse(fs.readFileSync(path.join(fake, ".ssh", "ssh-profiles.json"), "utf8")); +ok(!sp.profiles?.old, "旧 ssh-profiles 已被覆盖"); + +const bkRoot = path.join(root, "work", "backup"); +const stamps = fs.readdirSync(bkRoot); +ok(stamps.length >= 1, `产生了备份批次(${stamps.join(",")})`); +const walk = (d) => fs.readdirSync(d, { withFileTypes: true }).flatMap((e) => e.isDirectory() ? walk(path.join(d, e.name)) : [path.join(d, e.name)]); +const bFiles = stamps.flatMap((s) => walk(path.join(bkRoot, s))); +ok(bFiles.some((f) => f.endsWith("mysql-root-password.txt")), "被覆盖的密码有备份"); +const bPw = bFiles.find((f) => f.endsWith("mysql-root-password.txt")); +ok(fs.readFileSync(bPw, "utf8") === "OLD-FAKE-PASSWORD", "备份内容确是覆盖前的旧值"); + +/* ---- 4. status 复查 ---- */ +const st = run("status.mjs"); +ok(st.code === 0, `status 退出码 0(实际 ${st.code})`); +ok(/自研插件:一致/.test(st.out), `自研插件与快照一致 → ${(st.out.match(/自研插件:[^\n]*/) || ["—"])[0]}`); +ok(/密钥束:清单一致/.test(st.out), `密钥清单与快照一致 → ${(st.out.match(/密钥束:[^\n]*/) || ["—"])[0]}`); +if (/差异/.test(st.out)) { console.log("\n--- status 原始输出 ---\n" + st.out + "\n-----------------------"); } + +// 逐插件哈希对照,漂移时能直接看出是哪个 +if (/自研插件:差异/.test(st.out)) { + const { hashDir } = await import(pathToFileURL(path.join(SCRIPTS, "sync-core.mjs")).href); + const latest = JSON.parse(fs.readFileSync(path.join(repo, "latest.json"), "utf8")); + const m = JSON.parse(fs.readFileSync(path.join(repo, latest.snapshot, "manifest.json"), "utf8")); + for (const c of m.custom) { + const snapH = hashDir(path.join(repo, latest.snapshot, "custom-plugins", c.name)); + const locH = (() => { try { return hashDir(path.join(fake, ".zcode", "plugins", c.name)); } catch { return "ERR"; } })(); + log.push(` · ${c.name}: manifest=${c.hash} 快照=${snapH} 本机=${locH}${c.hash === locH ? "" : " <-- 漂移"}`); + } +} + +console.log(`\n=== 真机往返验证 (${log.filter((l) => l.startsWith(" ok")).length}/${log.length}) ===`); +console.log(log.join("\n")); +fs.rmSync(root, { recursive: true, force: true }); +console.log(process.exitCode ? "\n❌ 有失败项" : "\n✅ 全部通过"); diff --git a/zcode-env-sync/tests/tools.test.mjs b/zcode-env-sync/tests/tools.test.mjs new file mode 100644 index 0000000..041163d --- /dev/null +++ b/zcode-env-sync/tests/tools.test.mjs @@ -0,0 +1,440 @@ +/** + * 新命令测试:prune / verify / doctor / status --list + * 用 USERPROFILE 重定向 HOME + 本地裸仓,不碰真实环境。 + * 跑法: node --test tests/ + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync, spawnSync } from "node:child_process"; +import { decryptSecrets } from "../scripts/sync-core.mjs"; + +const SCRIPTS = path.resolve(import.meta.dirname, "..", "scripts"); +const PASS = "team-passphrase-for-tests"; +const GIT_ENV = { GIT_CONFIG_NOSYSTEM: "1", GIT_TERMINAL_PROMPT: "0" }; + +function sandboxRoot() { return fs.mkdtempSync(path.join(os.tmpdir(), "zes-tools-")); } + +function makeMachine(root, name) { + const home = path.join(root, name); + for (const d of [".zcode/cli/plugins/data", ".zcode/cli/plugins/marketplaces/zcode-plugins-official", + ".zcode/plugins", ".dsh/secrets", ".ssh"]) { + fs.mkdirSync(path.join(home, d), { recursive: true }); + } + fs.writeFileSync(path.join(home, ".zcode/cli/plugins/marketplaces/zcode-plugins-official/marketplace.json"), + JSON.stringify({ plugins: [{ name: "browser-use", version: "0.4.2" }] })); + fs.mkdirSync(path.join(home, ".zcode/cli/plugins/data/browser-use@zcode-plugins-official"), { recursive: true }); + const pdir = path.join(home, ".zcode/plugins/zcode-tps"); + fs.mkdirSync(path.join(pdir, ".zcode-plugin"), { recursive: true }); + fs.writeFileSync(path.join(pdir, ".zcode-plugin/plugin.json"), JSON.stringify({ name: "zcode-tps", version: "0.4.1" })); + fs.writeFileSync(path.join(pdir, "run.mjs"), "export const v=1;\n"); + fs.writeFileSync(path.join(home, ".zcode/cli/config.json"), + JSON.stringify({ mcp: { servers: { s1: { type: "stdio", command: "node" } } }, plugins: { dirs: [] } }, null, 2)); + fs.writeFileSync(path.join(home, ".dsh/secrets/gitea-token.txt"), `tok-${name}-${"x".repeat(28)}\n`); + fs.writeFileSync(path.join(home, ".ssh/ssh-profiles.json"), JSON.stringify({ profiles: {} })); + return home; +} + +function envFor(home, extra = {}) { + const env = { ...process.env, ...GIT_ENV, USERPROFILE: home, HOME: home }; + delete env.HOMEDRIVE; delete env.HOMEPATH; + delete env.ZCODE_SYNC_ALLOW_SECRETS; + return { ...env, ...extra }; +} +function run(script, { home, args = [], env = {} }) { + const r = spawnSync(process.execPath, [path.join(SCRIPTS, script), ...args], + { env: envFor(home, env), encoding: "utf8", timeout: 120000 }); + return { code: r.status, out: r.stdout ?? "", err: r.stderr ?? "" }; +} +function git(args, opts = {}) { return execFileSync("git", args, { encoding: "utf8", ...GIT_ENV, ...opts }); } + +/** 造 N 个快照:每个改一处插件内容,保证 hash 不同、是可区分的历史 */ +function seedSnapshots(home, bare, work, n) { + const env = { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: work }; + const names = []; + for (let i = 0; i < n; i++) { + fs.writeFileSync(path.join(home, ".zcode/plugins/zcode-tps/run.mjs"), `export const v=${i};\n`); + const r = run("export.mjs", { home, env }); + assert.equal(r.code, 0, `第 ${i} 次导出差错:\n${r.err}`); + const latest = JSON.parse(fs.readFileSync(path.join(work, "repo/latest.json"), "utf8")); + const name = path.basename(latest.snapshot); + names.push(name); + // 同一秒内连跑会撞名 → 撞了就等一秒 + if (i < n - 1) execFileSync(process.execPath, ["-e", "setTimeout(()=>{},1100)"]); + } + return names; +} + +/* ---------------- prune ---------------- */ + +test("prune:默认预演不删,保留 keep 个 + latest 钉住", { timeout: 180000 }, () => { + const root = sandboxRoot(); + const home = makeMachine(root, "m1"), bare = path.join(root, "bare.git"), work = path.join(root, "w"); + git(["init", "--bare", "-b", "main", bare]); + const names = seedSnapshots(home, bare, work, 4); + const env = { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: work }; + + // 预演:keep=2 → 保留最新 2 个,不删任何东西 + const dry = run("prune.mjs", { home, args: ["--keep", "2"], env }); + assert.equal(dry.code, 0, `预演应成功\n${dry.err}`); + assert.match(dry.out, /预演,未删除/); + assert.match(dry.out, /现有 4 个,保留 2 个,将删除 2 个/); + const snapRoot = path.join(work, "repo/snapshots"); + assert.equal(fs.readdirSync(snapRoot).length, 4, "预演不得真删"); + + // 实删 + const app = run("prune.mjs", { home, args: ["--keep", "2", "--apply"], env }); + assert.equal(app.code, 0, `实删应成功\n${app.err}`); + assert.match(app.out, /实际删除 2 个/); + assert.match(app.out, /已推送/); + const left = fs.readdirSync(snapRoot); + assert.equal(left.length, 2, `应剩 2 个,实际 ${left.length}`); + // 保留的必须是最新的两个 + for (const keep of names.slice(-2)) assert.ok(left.includes(keep), `应保留 ${keep}`); + // latest.json 指向的必须还在(否则后续 status/import 会找不到) + const latest = JSON.parse(fs.readFileSync(path.join(work, "repo/latest.json"), "utf8")); + assert.ok(left.includes(path.basename(latest.snapshot)), "latest 指向的快照不得被删"); + // 远端也少了 + git(["--git-dir", bare, "log", "--oneline", "main"]); + + fs.rmSync(root, { recursive: true, force: true }); +}); + +test("prune:latest 指向旧快照时,删完要重指到一个还活着的", { timeout: 180000 }, () => { + const root = sandboxRoot(); + const home = makeMachine(root, "m2"), bare = path.join(root, "bare.git"), work = path.join(root, "w"); + git(["init", "--bare", "-b", "main", bare]); + const names = seedSnapshots(home, bare, work, 4); + const env = { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: work }; + + // 手工把 latest.json 指回最老那个,再 keep=1 → 最老那个会被钉住不删 + const snapRoot = path.join(work, "repo/snapshots"); + const oldest = names[0]; + fs.writeFileSync(path.join(work, "repo/latest.json"), + JSON.stringify({ snapshot: `snapshots/${oldest}`, host: "x", createdAt: "2020-01-01T00:00:00Z" })); + + const r = run("prune.mjs", { home, args: ["--keep", "1", "--apply"], env }); + assert.equal(r.code, 0, `应成功\n${r.err}`); + const left = fs.readdirSync(snapRoot); + assert.ok(left.includes(oldest), "被 latest 钉住的快照不得被删"); + const latest = JSON.parse(fs.readFileSync(path.join(work, "repo/latest.json"), "utf8")); + assert.ok(left.includes(path.basename(latest.snapshot)), "latest 必须指向还存在的快照"); + + fs.rmSync(root, { recursive: true, force: true }); +}); + +test("prune:--exclude 钉住指定快照;参数非法要报错", { timeout: 180000 }, () => { + const root = sandboxRoot(); + const home = makeMachine(root, "m3"), bare = path.join(root, "bare.git"), work = path.join(root, "w"); + git(["init", "--bare", "-b", "main", bare]); + const names = seedSnapshots(home, bare, work, 3); + const env = { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: work }; + + const r = run("prune.mjs", { home, args: ["--keep", "1", "--exclude", names[0], "--apply"], env }); + assert.equal(r.code, 0, `应成功\n${r.err}`); + assert.ok(fs.readdirSync(path.join(work, "repo/snapshots")).includes(names[0]), "--exclude 钉住的应保留"); + + // 非法参数 + for (const bad of [["--keep", "0"], ["--keep", "abc"], ["--nope"]]) { + const b = run("prune.mjs", { home, args: bad, env }); + assert.notEqual(b.code, 0, `应拒绝:${bad.join(" ")}`); + } + const h = run("prune.mjs", { home, args: ["--help"], env }); + assert.equal(h.code, 0, "--help 应成功"); + assert.match(h.out, /用法/); + + fs.rmSync(root, { recursive: true, force: true }); +}); + +test("prune:快照为空时优雅退出,不报错", { timeout: 120000 }, () => { + const root = sandboxRoot(); + const home = makeMachine(root, "m4"), bare = path.join(root, "bare.git"), work = path.join(root, "w"); + git(["init", "--bare", "-b", "main", bare]); + const r = run("prune.mjs", { home, env: { ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: work } }); + assert.equal(r.code, 0, `应优雅退出\n${r.err}`); + assert.match(r.out, /远端尚无快照/); + fs.rmSync(root, { recursive: true, force: true }); +}); + +/* ---------------- verify ---------------- */ + +test("verify:完好的快照通过校验", { timeout: 180000 }, () => { + const root = sandboxRoot(); + const home = makeMachine(root, "m5"), bare = path.join(root, "bare.git"), work = path.join(root, "w"); + git(["init", "--bare", "-b", "main", bare]); + seedSnapshots(home, bare, work, 1); + const env = { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: work }; + + const r = run("verify.mjs", { home, env }); + assert.equal(r.code, 0, `校验应通过\n${r.out}\n${r.err}`); + assert.match(r.out, /# 快照校验:/); + assert.match(r.out, /密钥束可解密/); + assert.match(r.out, /自研插件 zcode-tps@0.4.1 hash 一致/); + assert.match(r.out, /结论:快照完好|结论:快照可用/); + assert.doesNotMatch(r.out, /✗/, "不应有失败项"); + + fs.rmSync(root, { recursive: true, force: true }); +}); + +test("verify:内容被篡改 → 报 hash 不符并退出 1", { timeout: 180000 }, () => { + const root = sandboxRoot(); + const home = makeMachine(root, "m6"), bare = path.join(root, "bare.git"), work = path.join(root, "w"); + git(["init", "--bare", "-b", "main", bare]); + seedSnapshots(home, bare, work, 1); + const env = { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: work }; + + const snapRoot = path.join(work, "repo/snapshots"); + const snap = path.join(snapRoot, fs.readdirSync(snapRoot)[0]); + // 篡改插件内容,manifest 里的 hash 就不对了 + fs.writeFileSync(path.join(snap, "custom-plugins/zcode-tps/run.mjs"), "export const v=999; // tampered\n"); + + const r = run("verify.mjs", { home, env }); + assert.equal(r.code, 1, "篡改必须导致退出码 1"); + assert.match(r.out, /内容 hash 不符|hash 不符/); + assert.match(r.out, /结论:快照不可用/); + + fs.rmSync(root, { recursive: true, force: true }); +}); + +test("verify:口令不对 → 明确报错,且不打印密钥值", { timeout: 180000 }, () => { + const root = sandboxRoot(); + const home = makeMachine(root, "m7"), bare = path.join(root, "bare.git"), work = path.join(root, "w"); + git(["init", "--bare", "-b", "main", bare]); + seedSnapshots(home, bare, work, 1); + const env = { ZCODE_SYNC_PASSPHRASE: "totally-wrong-passphrase", ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: work }; + + const r = run("verify.mjs", { home, env }); + assert.equal(r.code, 1, "解不开必须退出 1"); + assert.match(r.out, /密钥束解不开/); + assert.match(r.out, /口令不对/); + // 绝不能把密文或密钥值打出来 + assert.doesNotMatch(r.out, /tok-m7/, "不得打印密钥明文"); + const enc = fs.readFileSync(path.join(work, "repo/snapshots", fs.readdirSync(path.join(work, "repo/snapshots"))[0], "secrets.enc"), "utf8"); + assert.ok(!r.out.includes(enc.slice(20, 60)), "不得把密文片段打进输出"); + + fs.rmSync(root, { recursive: true, force: true }); +}); + +test("verify --all:区分'可解密'与'口令不符',并给出可用版本", { timeout: 240000 }, () => { + const root = sandboxRoot(); + const homeA = makeMachine(root, "m8a"), homeB = makeMachine(root, "m8b"); + const bare = path.join(root, "bare.git"), work = path.join(root, "w"); + git(["init", "--bare", "-b", "main", bare]); + + // A 机用口令 1 导一个 + const P1 = "passphrase-one-1111", P2 = "passphrase-two-2222"; + const envA = { ZCODE_SYNC_PASSPHRASE: P1, ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: work }; + assert.equal(run("export.mjs", { home: homeA, env: envA }).code, 0); + execFileSync(process.execPath, ["-e", "setTimeout(()=>{},1100)"]); + // A 机改用口令 2 再导一个(模拟换口令) + const envA2 = { ZCODE_SYNC_PASSPHRASE: P2, ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: work }; + assert.equal(run("export.mjs", { home: homeA, env: envA2 }).code, 0); + + // 用口令 1 扫全部:应有一个可解、一个不可解 + const r = run("verify.mjs", { home: homeA, args: ["--all"], env: envA }); + assert.equal(r.code, 0, `扫描应成功\n${r.err}`); + assert.match(r.out, /全部快照扫描\(2 个\)/); + assert.match(r.out, /可解密/, "应有一个可解密"); + assert.match(r.out, /口令不符\/损坏/, "应有一个解不开"); + assert.match(r.out, /当前口令可用:1 个/); + assert.match(r.out, /latest.json 指向的 .* 用当前口令不可用/, "应提示 latest 不可用"); + assert.match(r.out, /改用可用的那版:\/sync-import /, "应给出可用版本与命令"); + + fs.rmSync(root, { recursive: true, force: true }); +}); + +/* ---------------- 推送隔离 ---------------- */ + +test("export:只推本次快照,不把别的会话未推送的快照连带推上去", { timeout: 180000 }, () => { + const root = sandboxRoot(); + const home = makeMachine(root, "m15"), bare = path.join(root, "bare.git"), work = path.join(root, "w"); + git(["init", "--bare", "-b", "main", bare]); + seedSnapshots(home, bare, work, 1); + const env = { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: work }; + const repo = path.join(work, "repo"); + + // 冒充"另一个会话留下的、未推送的、用别的口令导出的快照" + const stray = path.join(repo, "snapshots", "stray-20200101-000000"); + fs.mkdirSync(stray, { recursive: true }); + fs.writeFileSync(path.join(stray, "manifest.json"), JSON.stringify({ version: 1, host: "other", createdAt: "2020-01-01T00:00:00Z" })); + fs.writeFileSync(path.join(stray, "secrets.enc"), JSON.stringify({ alg: "aes-256-gcm", kdf: "scrypt", salt: "x", iv: "y", tag: "z", data: "q" })); + + // 再导一次 + const r = run("export.mjs", { home, env }); + assert.equal(r.code, 0, `导出应成功\n${r.err}`); + + // 远端不该有这个 stray 快照 + const remoteFiles = git(["--git-dir", bare, "ls-tree", "-r", "--name-only", "main"]); + assert.doesNotMatch(remoteFiles, /stray-20200101-000000/, "别的会话的未推送快照不得被连带推上去"); + assert.match(remoteFiles, /latest\.json/, "本次 latest.json 应推上去"); + // 本地应留下它,并在输出里说明"落下了什么" + assert.ok(fs.existsSync(stray), "本地那个快照不该被删(不是我们的东西)"); + assert.match(r.out, /落下 \d+ 项非本次内容/, "应告知用户有内容被刻意落下"); + assert.match(r.out, /stray-20200101-000000/, "应点名落下的是哪个"); + + fs.rmSync(root, { recursive: true, force: true }); +}); + +test("export:远端 latest 指向的快照,其密钥束必须能用当前口令解开", { timeout: 180000 }, () => { + const root = sandboxRoot(); + const home = makeMachine(root, "m16"), bare = path.join(root, "bare.git"), work = path.join(root, "w"); + git(["init", "--bare", "-b", "main", bare]); + seedSnapshots(home, bare, work, 1); + const env = { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: work }; + const repo = path.join(work, "repo"); + + // 从远端克隆一份,模拟"队友拿到最新状态" + const clone = path.join(root, "teammate"); + git(["clone", "-q", bare, clone]); + const latest = JSON.parse(fs.readFileSync(path.join(clone, "latest.json"), "utf8")); + const snap = path.join(clone, latest.snapshot); + assert.ok(fs.existsSync(path.join(snap, "manifest.json")), "latest 指向的快照必须存在"); + assert.ok(fs.existsSync(path.join(snap, "secrets.enc")), "latest 指向的快照必须有密钥束"); + + // 用团队口令应能解开 + assert.doesNotThrow(() => decryptSecrets(fs.readFileSync(path.join(snap, "secrets.enc"), "utf8"), PASS), + "队友用团队口令必须能解开 latest 的密钥束"); + + fs.rmSync(root, { recursive: true, force: true }); +}); + +test("prune:删除也限定在 snapshots/ 范围内", { timeout: 180000 }, () => { + const root = sandboxRoot(); + const home = makeMachine(root, "m17"), bare = path.join(root, "bare.git"), work = path.join(root, "w"); + git(["init", "--bare", "-b", "main", bare]); + seedSnapshots(home, bare, work, 3); + const env = { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: work }; + const repo = path.join(work, "repo"); + + // 放一个仓库外的杂项文件,不该被 prune 的提交带上去 + fs.writeFileSync(path.join(repo, "scratch-note.txt"), "someone's temp file\n"); + + const r = run("prune.mjs", { home, args: ["--keep", "1", "--apply"], env }); + assert.equal(r.code, 0, `清理应成功\n${r.err}`); + const remoteFiles = git(["--git-dir", bare, "ls-tree", "-r", "--name-only", "main"]); + assert.doesNotMatch(remoteFiles, /scratch-note\.txt/, "无关文件不得被连带提交"); + assert.ok(fs.existsSync(path.join(repo, "scratch-note.txt")), "无关文件不该被删"); + + fs.rmSync(root, { recursive: true, force: true }); +}); + +/* ---------------- doctor ---------------- */ + +test("doctor:环境就绪时全绿", { timeout: 180000 }, () => { + const root = sandboxRoot(); + const home = makeMachine(root, "m9"), bare = path.join(root, "bare.git"), work = path.join(root, "w"); + git(["init", "--bare", "-b", "main", bare]); + seedSnapshots(home, bare, work, 1); + const env = { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: work }; + + const r = run("doctor.mjs", { home, env }); + assert.equal(r.code, 0, `应全绿\n${r.out}\n${r.err}`); + assert.match(r.out, /# zcode-env-sync 自诊断/); + assert.match(r.out, /✓ Node 版本/); + assert.match(r.out, /✓ git 可用/); + assert.match(r.out, /✓ 同步口令/); + assert.match(r.out, /✓ 换行符锁定/); + assert.match(r.out, /✓ core\.autocrlf:false/); + assert.match(r.out, /✓ latest 可解密/); + assert.match(r.out, /结论:一切就绪|结论:可正常工作/); + assert.doesNotMatch(r.out, /✗/, "不应有 fail"); + + fs.rmSync(root, { recursive: true, force: true }); +}); + +test("doctor:缺口令 → fail 并给修复建议,退出码 1", { timeout: 120000 }, () => { + const root = sandboxRoot(); + const home = makeMachine(root, "m10"), bare = path.join(root, "bare.git"), work = path.join(root, "w"); + git(["init", "--bare", "-b", "main", bare]); + const r = run("doctor.mjs", { home, env: { ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: work } }); + assert.equal(r.code, 1, "缺口令应退出 1"); + assert.match(r.out, /✗ 同步口令:未设置/); + assert.match(r.out, /ZCODE_SYNC_PASSPHRASE/, "应给出修复建议"); + assert.match(r.out, /结论:有 1 项|结论:有 \d+ 项必须先修/); + + fs.rmSync(root, { recursive: true, force: true }); +}); + +test("doctor:口令解不开 latest → fail 并指向 verify --all", { timeout: 180000 }, () => { + const root = sandboxRoot(); + const home = makeMachine(root, "m11"), bare = path.join(root, "bare.git"), work = path.join(root, "w"); + git(["init", "--bare", "-b", "main", bare]); + seedSnapshots(home, bare, work, 1); + const r = run("doctor.mjs", { home, env: { + ZCODE_SYNC_PASSPHRASE: "some-other-passphrase", ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: work } }); + assert.equal(r.code, 1, "解不开应退出 1"); + assert.match(r.out, /✗ latest 可解密/); + assert.match(r.out, /verify\.mjs --all/, "应指向 verify --all 去找可用版本"); + + fs.rmSync(root, { recursive: true, force: true }); +}); + +test("doctor:origin 内嵌凭据 → 警告", { timeout: 180000 }, () => { + const root = sandboxRoot(); + const home = makeMachine(root, "m12"), bare = path.join(root, "bare.git"), work = path.join(root, "w"); + git(["init", "--bare", "-b", "main", bare]); + seedSnapshots(home, bare, work, 1); + // 手工把 token 塞进 origin,模拟历史遗留的不卫生配置 + git(["-C", path.join(work, "repo"), "remote", "set-url", "origin", "https://sometoken@gitea.example.invalid/x.git"]); + + const r = run("doctor.mjs", { home, env: { + ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: work } }); + assert.match(r.out, /⚠ origin 地址/); + assert.match(r.out, /内嵌了凭据/); + + fs.rmSync(root, { recursive: true, force: true }); +}); + +/* ---------------- status --list ---------------- */ + +test("status --list:列出全部快照并标出 latest", { timeout: 180000 }, () => { + const root = sandboxRoot(); + const home = makeMachine(root, "m13"), bare = path.join(root, "bare.git"), work = path.join(root, "w"); + git(["init", "--bare", "-b", "main", bare]); + const names = seedSnapshots(home, bare, work, 3); + const env = { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: work }; + + const r = run("status.mjs", { home, args: ["--list"], env }); + assert.equal(r.code, 0, `应成功\n${r.err}`); + assert.match(r.out, /# 远端快照\(3 个/); + for (const n of names) assert.ok(r.out.includes(n), `应列出 ${n}`); + assert.match(r.out, /\* .*latest|标 \* 的是 latest/, "应标出 latest"); + assert.match(r.out, /导入指定版本:\/sync-import/, "应告诉用户怎么导入指定版本"); + + // 无快照时优雅提示:换一个空裸仓(同一个仓 clone 下来仍有快照) + const emptyBare = path.join(root, "empty.git"); + git(["init", "--bare", "-b", "main", emptyBare]); + const r2 = run("status.mjs", { home, args: ["--list"], + env: { ...env, ZCODE_SYNC_GIT_REMOTE: emptyBare, ZCODE_SYNC_WORK: path.join(root, "w2") } }); + assert.equal(r2.code, 0, `应优雅退出\n${r2.err}`); + assert.match(r2.out, /远端尚无快照/); + + fs.rmSync(root, { recursive: true, force: true }); +}); + +/* ---------------- manifest 体积与 enabledDetailed ---------------- */ + +test("export:manifest 记录体积与 inline 插件版本", { timeout: 180000 }, () => { + const root = sandboxRoot(); + const home = makeMachine(root, "m14"), bare = path.join(root, "bare.git"), work = path.join(root, "w"); + git(["init", "--bare", "-b", "main", bare]); + seedSnapshots(home, bare, work, 1); + const env = { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: work }; + + const latest = JSON.parse(fs.readFileSync(path.join(work, "repo/latest.json"), "utf8")); + assert.ok(latest.bytes > 0, "latest.json 应记录体积"); + const m = JSON.parse(fs.readFileSync(path.join(work, "repo", latest.snapshot, "manifest.json"), "utf8")); + assert.ok(m.bytes > 0, "manifest 应记录体积"); + // 体积应对得上(容一个小误差,manifest 自身写入会变大小) + assert.ok(Math.abs(m.bytes - latest.bytes) < 4096, "manifest 与 latest 的体积应一致"); + assert.ok(Array.isArray(m.enabledDetailed), "应记录 enabledDetailed"); + assert.ok(m.enabledDetailed.length > 0, "enabledDetailed 不应为空"); + const bu = m.enabledDetailed.find((e) => e.name === "browser-use"); + assert.ok(bu, "应含 browser-use"); + assert.equal(bu.version, "0.4.2", "市场插件版本应从清单查到"); + assert.equal(bu.source, "zcode-plugins-official"); + + fs.rmSync(root, { recursive: true, force: true }); +}); diff --git a/zcode-thinking-mode/.gitignore b/zcode-thinking-mode/.gitignore new file mode 100644 index 0000000..f7abf3a --- /dev/null +++ b/zcode-thinking-mode/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +*.bak-* diff --git a/zcode-thinking-mode/.zcode-plugin/plugin.json b/zcode-thinking-mode/.zcode-plugin/plugin.json new file mode 100644 index 0000000..84e7262 --- /dev/null +++ b/zcode-thinking-mode/.zcode-plugin/plugin.json @@ -0,0 +1,33 @@ +{ + "name": "zcode-thinking-mode", + "version": "0.3.0", + "description": "思考模式切换:查看/切换模型的 reasoning 档位(low/max/high),双写热档位库与模型配置,新会话直接生效,当前会话补一行 /effort 即热切。命令 /thinking、/thinking:status、/thinking:set、/thinking:history、/thinking:rollback、/thinking:defaults、/thinking:doctor。", + "description_i18n": { + "en": "Thinking-mode switcher: inspect/switch reasoning variants (low/max/high); writes both the hot level store and model config, hot for new sessions, plus /effort for the current one. Commands /thinking, /thinking:status, /thinking:set, /thinking:history, /thinking:rollback, /thinking:defaults, /thinking:doctor." + }, + "author": { + "name": "local" + }, + "license": "MIT", + "keywords": [ + "thinking", + "reasoning", + "model-config", + "variant", + "effort", + "diagnostics" + ], + "commands": "commands", + "userConfig": { + "default_variant": { + "type": "string", + "default": "max", + "description": "思考模式默认档位(low/max/high)。宿主的 ${user_config.*} 只插值到 MCP/hook 字段,命令正文拿不到,所以这里填了不会自动生效 —— 用 /thinking:defaults set 写落地文件才有实际作用。" + }, + "target_model": { + "type": "string", + "default": "", + "description": "默认作用的模型名(空=全部带 reasoning 的模型),例如 GLM-5.3。同上:实际生效要写落地文件,用 /thinking:defaults set --model <名>。" + } + } +} diff --git a/zcode-thinking-mode/README.md b/zcode-thinking-mode/README.md new file mode 100644 index 0000000..37681b3 --- /dev/null +++ b/zcode-thinking-mode/README.md @@ -0,0 +1,113 @@ +# zcode-thinking-mode + +思考模式切换插件:查看/切换 ZCode 模型配置里的思考强度档位(`reasoning.defaultVariant`:low / max / high)。 + +## 命令 + +| 命令 | 作用 | +|---|---| +| `/thinking` | 不带参数=看现状;带 `low\|max\|high`=直接切换 | +| `/thinking:status` | 看各模型的当前默认档位与可选档位 | +| `/thinking:set <档位> [--model 模型名] [--hot-only\|--persist-only]` | 切换档位;不加 `--model` 则改全部带 reasoning 的模型 | +| `/thinking:history` | 看切换历史(时间/旧值→新值/分写标记/备份) | +| `/thinking:rollback [--steps N]` | 回滚到上次切换之前(热档位与持久默认都写回去) | +| `/thinking:defaults [set\|clear]` | 默认档位/默认模型(不带参数时 `set` 用哪档、`status` 看哪个模型) | +| `/thinking:doctor` | 自检:为什么切不了(只读,不改任何东西) | + +示例: + +``` +/thinking:status +/thinking:set high +/thinking:set low --model GLM-5.3-Flash +/thinking:set high --hot-only --model GLM-5.3-Flash # 只写热档位,不碰模型配置 +/thinking:set low --persist-only # 只写模型配置,不碰热档位 +/thinking:history +/thinking:rollback +/thinking:defaults set --variant high --model GLM-5.3 +/thinking:doctor +``` + +## 原理(双写:热 + 持久) + +1. session 库 `~/.zcode/cli/db/db.sqlite` → `local_setting(user/default/model/reasoningLevel)`—— 全局**热档位**,新会话/新 turn 直接按它解析,内置 `/effort` 切的也是这一格。 +2. `~/.zcode/v2/config.json` 各模型 `reasoning.defaultVariant` —— 模型配置里的**持久默认**。 +3. v2/config 写前自动备份(`config.json.bak-<时间戳>`,同秒撞名补 `.1`/`.2`);sqlite 单格 upsert,旧值会打印。 +4. 切换历史 `~/.zcode/zcode-thinking-mode-state.json`(`ZCODE_THINKING_STATE` 可改路径)—— 每次 `set` 成功追加一条(旧热值/持久旧值/分写标记/备份路径,保留最近 50 条);`rollback` 按最新一条把两边写回去,不删历史。 + +生效:新会话直接按新档位;**当前这轮会话要立刻生效,再敲一行内置命令 `/effort <档位>`**(热切,不重启)。 + +## 默认值(为什么需要落地文件) + +`plugin.json` 的 `userConfig`(设置 → 插件详情 → Advanced)在界面上能填,但宿主只把 +`${user_config.*}` 插值进 **MCP server 字段与 hook**,**命令正文拿不到** —— 所以填在那里的 +`default_variant` / `target_model` 不会自动生效。脚本改为自己读一份落地文件: + +```bash +node scripts/thinking.mjs defaults set --variant high --model GLM-5.3 +node scripts/thinking.mjs defaults # 看当前默认与来源 +node scripts/thinking.mjs defaults clear # 回到内置兜底 +``` + +文件:`~/.zcode/zcode-thinking-mode.json`(`ZCODE_THINKING_CONFIG` 可改路径)。 + +优先级: + +``` +命令行参数 > 环境变量(ZCODE_THINKING_VARIANT / ZCODE_THINKING_MODEL) > 默认值文件 > 内置兜底 +``` + +设了之后:`set` 不带档位就用默认档位(`set` 两处都没有会**明确报错**,不会瞎猜一个档位就改配置); +`status` / `variants` 不带 `--model` 就用默认模型。 + +## 安全性 + +- **整批校验**:目标里只要有一个模型不支持该档位,整批中止,不做部分切换 +- **校验收紧**:档位不在全部目标的 variants 并集里(如 `ultra`)直接拒绝,两边都不写 +- **分写开关**:`--hot-only` 只写热档位,`--persist-only` 只写模型配置;两者不能同时用 +- **先热后持久**:热档位写失败时模型配置原样不动,报错里明说"模型配置未动" +- **改写前备份**:每次真改模型配置都留一份 `config.json.bak-*`,同秒连续切换也不互相覆盖 +- **只读自检**:`/thinking:doctor` 不改任何东西(连备份探测文件都当场删掉) + +## 配置项(设置 → 插件详情 → Advanced) + +- `default_variant`:文档默认引用的档位(默认 max)。**实际生效请用 `/thinking:defaults set`** +- `target_model`:默认作用的模型名(空=全部),命令参数优先。同上 + +## 测试 + +```bash +npm test # 34 项:档位读取/切换/备份/默认值/自检/分写/历史/回滚/收紧校验,含失败路径 +npm run doctor # 真机自检 +``` + +测试用 `ZCODE_V2_CONFIG` + `ZCODE_SESSION_DB` + `ZCODE_THINKING_CONFIG` + `ZCODE_THINKING_STATE` +把四个读写目标全部重定向到临时目录,不碰真实模型配置、真实 session 库、真实默认值文件与真实历史。 + +覆盖:热档位与持久默认的双写、大小写归一、`--model` 只动目标、默认值优先级、 +整批校验中止不留半截改动、非法档位收紧拒绝、`--hot-only`/`--persist-only` 分写、 +`history`/`rollback` 写回两边、无历史回滚报错、无效 session 库、配置缺失、备份撞名不覆盖、doctor 的各种 fail 分支。 + +## 环境变量 + +| 变量 | 默认 | 说明 | +|---|---|---| +| `ZCODE_V2_CONFIG` | `~/.zcode/v2/config.json` | 模型配置路径 | +| `ZCODE_SESSION_DB` | `~/.zcode/cli/db/db.sqlite` | 热档位库路径 | +| `ZCODE_THINKING_CONFIG` | `~/.zcode/zcode-thinking-mode.json` | 默认值文件路径 | +| `ZCODE_THINKING_STATE` | 与默认值文件同目录 `zcode-thinking-mode-state.json` | 切换历史文件路径 | +| `ZCODE_THINKING_VARIANT` | — | 覆盖默认档位(优先于文件) | +| `ZCODE_THINKING_MODEL` | — | 覆盖默认模型(优先于文件) | +| `ZCODE_THINKING_SCRIPT` | 自动定位 | 命令文档里指定脚本路径用 | + +## 故障排查 + +先跑 `/thinking:doctor`。它直接指出问题并给修复命令,常见的有: + +| 现象 | 处理 | +|---|---| +| `切不了,提示 node:sqlite 导入失败` | Node 需 ≥22.5;升 Node,否则只能改模型配置这一半 | +| `热档位库结构:缺 local_setting 表` | `ZCODE_SESSION_DB` 指错了,或该文件不是 ZCode 的 session 库 | +| `模型配置:读不到 / JSON 解析失败` | 检查路径;坏了就从同目录 `config.json.bak-*` 拷回 | +| `可切换模型:0 个带 reasoning` | 模型没声明 `reasoning.variants`,档位切不了,先在模型配置里加上 | +| `切完当前会话没变化` | 新会话才自动生效;当前轮再敲一行 `/effort <档位>` 热切 | diff --git a/zcode-thinking-mode/commands/thinking.md b/zcode-thinking-mode/commands/thinking.md new file mode 100644 index 0000000..357fb5d --- /dev/null +++ b/zcode-thinking-mode/commands/thinking.md @@ -0,0 +1,39 @@ +--- +description: 思考模式总览与切换(查看当前档位,或加参数直接切换:low/max/high) +argument-hint: [low|max|high] [--model 模型名] +--- + +用 Bash(Windows 用 git-bash)运行思考档位脚本。先定位脚本(插件安装位置自动找,不写死路径;自定义可用 `ZCODE_THINKING_SCRIPT` 指定): + +```bash +THINK_SCRIPT="${ZCODE_THINKING_SCRIPT:-$(find ~/.zcode/plugins -maxdepth 4 -name thinking.mjs -path '*thinking-mode*' 2>/dev/null | head -1)}" +[ -n "$THINK_SCRIPT" ] || THINK_SCRIPT="$(find ~/.zcode -maxdepth 8 -type f -path '*thinking-mode/scripts/thinking.mjs' 2>/dev/null | head -1)" +[ -n "$THINK_SCRIPT" ] || { echo "找不到 thinking.mjs:插件未安装或不在默认位置,可用 ZCODE_THINKING_SCRIPT 指定路径"; exit 1; } +``` + +不带参数 → 显示现状(等价于 `/thinking:status`): + +```bash +node "$THINK_SCRIPT" status +``` + +带档位参数 → 直接切换(等价于 `/thinking:set`): + +```bash +node "$THINK_SCRIPT" set $ARGUMENTS +``` + +其它相关命令: + +| 命令 | 作用 | +|---|---| +| `/thinking:set` | 切换档位(`low`/`max`/`high`,可加 `--model`/`--hot-only`/`--persist-only`) | +| `/thinking:status` | 看各模型当前档位与可选档位 | +| `/thinking:history` | 看切换历史(时间/旧值→新值/分写标记/备份) | +| `/thinking:rollback [--steps N]` | 回滚到上次切换之前(热+持久都写回去) | +| `/thinking:defaults` | 默认档位/默认模型(不带参数时 `set` 用哪档、`status` 看哪个模型) | +| `/thinking:doctor` | 自检:为什么切不了(只读) | + +**把脚本输出原样展示给用户**,不要改写档位名、不要省略模型行。注意输出里的"当前会话补一行 `/effort <档位>`"提示要保留——那是当前这轮立即生效的热切入口。 + +用户附加要求:$ARGUMENTS diff --git a/zcode-thinking-mode/commands/thinking/defaults.md b/zcode-thinking-mode/commands/thinking/defaults.md new file mode 100644 index 0000000..6e9806a --- /dev/null +++ b/zcode-thinking-mode/commands/thinking/defaults.md @@ -0,0 +1,31 @@ +--- +description: 思考模式默认值(查看/设置/清除:不带参数时 set 用哪个档位、status 用哪个模型) +argument-hint: [set --variant <档位>] [--model <模型名>] | clear +--- + +用 Bash(Windows 用 git-bash)运行: + +```bash +THINK_SCRIPT="${ZCODE_THINKING_SCRIPT:-$(find ~/.zcode/plugins -maxdepth 4 -name thinking.mjs -path '*thinking-mode*' 2>/dev/null | head -1)}" +[ -n "$THINK_SCRIPT" ] || THINK_SCRIPT="$(find ~/.zcode -maxdepth 8 -type f -path '*thinking-mode/scripts/thinking.mjs' 2>/dev/null | head -1)" +[ -n "$THINK_SCRIPT" ] || { echo "找不到 thinking.mjs:插件未安装或不在默认位置,可用 ZCODE_THINKING_SCRIPT 指定路径"; exit 1; } +node "$THINK_SCRIPT" defaults $ARGUMENTS +``` + +三种用法: + +- `defaults`(不带参数)→ 看当前默认档位/默认模型,以及它们的来源 +- `defaults set --variant high [--model GLM-5.3]` → 写入落地文件 +- `defaults clear` → 删掉落地文件,回到内置兜底 + +**为什么要这个命令**:`plugin.json` 里的 `userConfig` 在设置界面能填,但宿主只把 +`${user_config.*}` 插值进 MCP server 字段与 hook,**命令正文拿不到** —— 所以脚本自己读 +`~/.zcode/zcode-thinking-mode.json` 这份落地文件,那两个配置项才有实际作用。 + +优先级:**命令行参数 > 环境变量(`ZCODE_THINKING_VARIANT` / `ZCODE_THINKING_MODEL`) > 默认值文件 > 内置兜底**。 + +设了之后:`set` 不带档位就用默认档位;`status`/`variants` 不带 `--model` 就用默认模型。 + +**把脚本输出原样展示给用户**,包含写入的文件路径与内容。 + +用户附加要求:$ARGUMENTS diff --git a/zcode-thinking-mode/commands/thinking/doctor.md b/zcode-thinking-mode/commands/thinking/doctor.md new file mode 100644 index 0000000..130f317 --- /dev/null +++ b/zcode-thinking-mode/commands/thinking/doctor.md @@ -0,0 +1,31 @@ +--- +description: 自检:安装是否完整、两个写入目标是否可写、node:sqlite 是否可用(只读) +--- + +用 Bash(Windows 用 git-bash)运行(**只读,不改任何东西,不写档位**): + +```bash +THINK_SCRIPT="${ZCODE_THINKING_SCRIPT:-$(find ~/.zcode/plugins -maxdepth 4 -name thinking.mjs -path '*thinking-mode*' 2>/dev/null | head -1)}" +[ -n "$THINK_SCRIPT" ] || THINK_SCRIPT="$(find ~/.zcode -maxdepth 8 -type f -path '*thinking-mode/scripts/thinking.mjs' 2>/dev/null | head -1)" +[ -n "$THINK_SCRIPT" ] || { echo "找不到 thinking.mjs:插件未安装或不在默认位置,可用 ZCODE_THINKING_SCRIPT 指定路径"; exit 1; } +node "$THINK_SCRIPT" doctor $ARGUMENTS +``` + +检查项(每项 `✓ ok` / `⚠ warn` / `✗ fail`,失败项附可执行的修复建议): + +- **Node 版本 / node:sqlite** — 热档位那一半依赖 `node:sqlite`(需 Node ≥22.5); + 导入失败时热档位写不了,只能改模型配置 +- **模型配置** — 能否读到、JSON 是否合法、有几个模型声明了 `reasoning` +- **热档位库** — 文件在不在、能否打开、有没有 `local_setting` 表(缺表说明不是有效的 session 库) +- **当前热档位** — 库里现在的值;没有这行也会如实说明,不假装读到 +- **默认值** — 默认档位/默认模型及其来源(环境变量/文件/未设) +- **写入能力** — 模型配置是否可写、能否在配置目录写备份、默认值目录是否存在 + +退出码:有 `fail` 时为 1。 + +用法场景:**用户说"切不了/切了没效果/报错"时先跑这个**,再把 `fail` 项与修复建议照实告诉他, +不要自己猜。想知道"怎么回滚"也可以先跑它,备份可写性就在检查项里。 + +**把脚本输出原样展示给用户**。 + +用户附加要求:$ARGUMENTS diff --git a/zcode-thinking-mode/commands/thinking/history.md b/zcode-thinking-mode/commands/thinking/history.md new file mode 100644 index 0000000..7408668 --- /dev/null +++ b/zcode-thinking-mode/commands/thinking/history.md @@ -0,0 +1,19 @@ +--- +description: 查看思考模式切换历史(时间/旧值→新值/分写标记/备份路径) +--- + +用 Bash(Windows 用 git-bash)运行: + +```bash +THINK_SCRIPT="${ZCODE_THINKING_SCRIPT:-$(find ~/.zcode/plugins -maxdepth 4 -name thinking.mjs -path '*thinking-mode*' 2>/dev/null | head -1)}" +[ -n "$THINK_SCRIPT" ] || THINK_SCRIPT="$(find ~/.zcode -maxdepth 8 -type f -path '*thinking-mode/scripts/thinking.mjs' 2>/dev/null | head -1)" +[ -n "$THINK_SCRIPT" ] || { echo "找不到 thinking.mjs:插件未安装或不在默认位置,可用 ZCODE_THINKING_SCRIPT 指定路径"; exit 1; } +node "$THINK_SCRIPT" history $ARGUMENTS +``` + +每次 `set` 成功都会在历史文件(`~/.zcode/zcode-thinking-mode-state.json`,保留最近 50 条) +追加一条:时间戳、旧热值→新热值、分写标记(`仅热`/`仅持久`/`热+持久`)、作用模型、备份路径。 + +**把脚本输出原样展示给用户**。 + +用户附加要求:$ARGUMENTS diff --git a/zcode-thinking-mode/commands/thinking/rollback.md b/zcode-thinking-mode/commands/thinking/rollback.md new file mode 100644 index 0000000..ae8a8cf --- /dev/null +++ b/zcode-thinking-mode/commands/thinking/rollback.md @@ -0,0 +1,22 @@ +--- +description: 回滚到上次思考模式切换之前(热档位与持久默认都写回去) +argument-hint: [--steps N] +--- + +用 Bash(Windows 用 git-bash)运行: + +```bash +THINK_SCRIPT="${ZCODE_THINKING_SCRIPT:-$(find ~/.zcode/plugins -maxdepth 4 -name thinking.mjs -path '*thinking-mode*' 2>/dev/null | head -1)}" +[ -n "$THINK_SCRIPT" ] || THINK_SCRIPT="$(find ~/.zcode -maxdepth 8 -type f -path '*thinking-mode/scripts/thinking.mjs' 2>/dev/null | head -1)" +[ -n "$THINK_SCRIPT" ] || { echo "找不到 thinking.mjs:插件未安装或不在默认位置,可用 ZCODE_THINKING_SCRIPT 指定路径"; exit 1; } +node "$THINK_SCRIPT" rollback $ARGUMENTS +``` + +- 按历史文件最新一条把**全局热档位**与**持久默认**(仅历史里记录过的模型)写回去 +- 回滚本身不删历史,可多次回滚;`--steps N` 可一次退 N 步 +- 写模型配置前同样留备份;热档位写失败时模型配置不动 + +**把脚本输出原样展示给用户**——含回滚目标时间、写回的两边值、备份路径与 +"当前会话补一行 `/effort <档位>` 即热切"提示,不要省略。 + +用户附加要求:$ARGUMENTS diff --git a/zcode-thinking-mode/commands/thinking/set.md b/zcode-thinking-mode/commands/thinking/set.md new file mode 100644 index 0000000..b30914e --- /dev/null +++ b/zcode-thinking-mode/commands/thinking/set.md @@ -0,0 +1,26 @@ +--- +description: 切换思考模式档位(low/max/high,可加 --model 只改单个模型,可加 --hot-only/--persist-only 分写) +argument-hint: [--model 模型名] [--hot-only|--persist-only] +--- + +用 Bash(Windows 用 git-bash)运行: + +```bash +THINK_SCRIPT="${ZCODE_THINKING_SCRIPT:-$(find ~/.zcode/plugins -maxdepth 4 -name thinking.mjs -path '*thinking-mode*' 2>/dev/null | head -1)}" +[ -n "$THINK_SCRIPT" ] || THINK_SCRIPT="$(find ~/.zcode -maxdepth 8 -type f -path '*thinking-mode/scripts/thinking.mjs' 2>/dev/null | head -1)" +[ -n "$THINK_SCRIPT" ] || { echo "找不到 thinking.mjs:插件未安装或不在默认位置,可用 ZCODE_THINKING_SCRIPT 指定路径"; exit 1; } +node "$THINK_SCRIPT" set $ARGUMENTS +``` + +- 不带 `--model` → 改**全部**带 reasoning 的模型(或默认值文件里指定的那个) +- 不带档位参数 → 用默认值文件里的默认档位;两处都没有会明确报错,不会瞎猜一个档位就改配置 +- 校验是整批收紧的:档位不在全部目标的 variants 并集里(如 `ultra`)直接拒绝,两边都不写; + 目标里只要有一个模型不支持该档位,**整批中止**,不做部分切换 +- `--hot-only` 只写热档位(不碰模型配置);`--persist-only` 只写模型配置(不碰热档位);两者不能同时用 +- 改写模型配置前会在同目录留备份 `config.json.bak-`; + 回滚用 `/thinking:rollback`(按切换历史把两边写回去),或把备份拷回去 + +**把脚本输出原样展示给用户**——含热档位切换结果、持久默认改动、备份路径与"当前会话补一行 `/effort <档位>` 即热切"提示,不要省略。 +失败时把报错原文带上(尤其"模型 X 不支持档位 Y(可选:…)"),它已经说明了哪个模型、支持哪些档位。 + +用户附加要求:$ARGUMENTS diff --git a/zcode-thinking-mode/commands/thinking/status.md b/zcode-thinking-mode/commands/thinking/status.md new file mode 100644 index 0000000..57dbd44 --- /dev/null +++ b/zcode-thinking-mode/commands/thinking/status.md @@ -0,0 +1,16 @@ +--- +description: 查看各模型的思考模式现状(当前默认档位与可选档位) +--- + +用 Bash(Windows 用 git-bash)运行: + +```bash +THINK_SCRIPT="${ZCODE_THINKING_SCRIPT:-$(find ~/.zcode/plugins -maxdepth 4 -name thinking.mjs -path '*thinking-mode*' 2>/dev/null | head -1)}" +[ -n "$THINK_SCRIPT" ] || THINK_SCRIPT="$(find ~/.zcode -maxdepth 8 -type f -path '*thinking-mode/scripts/thinking.mjs' 2>/dev/null | head -1)" +[ -n "$THINK_SCRIPT" ] || { echo "找不到 thinking.mjs:插件未安装或不在默认位置,可用 ZCODE_THINKING_SCRIPT 指定路径"; exit 1; } +node "$THINK_SCRIPT" status $ARGUMENTS +``` + +**把脚本输出原样展示给用户**。 + +用户附加要求:$ARGUMENTS diff --git a/zcode-thinking-mode/package.json b/zcode-thinking-mode/package.json new file mode 100644 index 0000000..ddf4e7a --- /dev/null +++ b/zcode-thinking-mode/package.json @@ -0,0 +1,18 @@ +{ + "name": "zcode-thinking-mode", + "version": "0.3.0", + "private": true, + "type": "module", + "description": "思考模式切换:查看/切换模型的 reasoning 档位(low/max/high),双写热档位库与模型配置,带默认值文件与自检。", + "scripts": { + "test": "node --test \"tests/*.test.mjs\"", + "thinking": "node scripts/thinking.mjs", + "status": "node scripts/thinking.mjs status", + "variants": "node scripts/thinking.mjs variants", + "defaults": "node scripts/thinking.mjs defaults", + "doctor": "node scripts/thinking.mjs doctor" + }, + "engines": { + "node": ">=22.5" + } +} diff --git a/zcode-thinking-mode/scripts/thinking.mjs b/zcode-thinking-mode/scripts/thinking.mjs new file mode 100644 index 0000000..b01960a --- /dev/null +++ b/zcode-thinking-mode/scripts/thinking.mjs @@ -0,0 +1,620 @@ +#!/usr/bin/env node +/** + * thinking.mjs —— ZCode 思考档位(双写:热 + 持久)。 + * + * node thinking.mjs status [--json] [--model ] + * node thinking.mjs set [] [--model ] [--json] [--hot-only | --persist-only] + * node thinking.mjs rollback [--json] [--steps N] + * node thinking.mjs history [--json] [--limit N] + * node thinking.mjs variants [--model ] [--json] + * node thinking.mjs defaults [--json] | defaults set [--variant X] [--model Y] | defaults clear + * node thinking.mjs doctor [--json] + * + * 环境变量: + * ZCODE_V2_CONFIG v2/config.json 路径(默认 ~/.zcode/v2/config.json) + * ZCODE_SESSION_DB session 库路径(默认 ~/.zcode/cli/db/db.sqlite) + * ZCODE_THINKING_CONFIG 本插件的默认值文件(默认 ~/.zcode/zcode-thinking-mode.json) + * ZCODE_THINKING_STATE 切换历史 state 文件(默认与默认值文件同目录 zcode-thinking-mode-state.json) + * ZCODE_THINKING_VARIANT / ZCODE_THINKING_MODEL 临时覆盖默认值(优先于文件) + * + * 双写说明: + * 1) session 库 local_setting(user/default/model/reasoningLevel)—— 全局热档位, + * 新会话/新 turn 模型解析直接读它;/effort 切的也是这一格。 + * 2) v2/config.json 各模型 reasoning.defaultVariant —— 模型配置里的持久默认。 + * v2/config 写前自动备份: config.json.bak-(同秒撞名时补 .1/.2)。 + * sqlite 单格 upsert,旧值会打印出来(回滚就是再 set 回去)。 + * + * 默认值文件说明: + * plugin.json 里的 userConfig(default_variant / target_model)在设置界面能填, + * 但宿主只把 ${user_config.*} 插值到 MCP server 字段与 hook,不会插到命令正文里, + * 所以脚本必须自己读一份落地文件才有意义 —— 就是这个 defaults 文件。 + * 优先级:命令行参数 > 环境变量 > 默认值文件 > 内置兜底。 + * + * 生效: + * 新会话/重启后按新档位;**当前会话要立刻生效,再敲一行内置命令 /effort <档位>**(热切,不重启)。 + */ + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const V2_CONFIG = process.env.ZCODE_V2_CONFIG + || path.join(os.homedir(), ".zcode", "v2", "config.json"); +const SESSION_DB = process.env.ZCODE_SESSION_DB + || path.join(os.homedir(), ".zcode", "cli", "db", "db.sqlite"); +const DEFAULTS_FILE = process.env.ZCODE_THINKING_CONFIG + || path.join(os.homedir(), ".zcode", "zcode-thinking-mode.json"); +const STATE_FILE = process.env.ZCODE_THINKING_STATE + || path.join(path.dirname(DEFAULTS_FILE), "zcode-thinking-mode-state.json"); + +/** 没有默认值文件、也没有环境变量时的兜底 */ +const BUILTIN_DEFAULTS = { variant: null, model: null }; + +// ---------------------------------------------------------------- 小工具 + +function fail(msg, json, extra) { + if (json) console.log(JSON.stringify({ error: msg, v2config: V2_CONFIG, sessionDb: SESSION_DB, defaultsFile: DEFAULTS_FILE, stateFile: STATE_FILE, ...(extra ?? {}) }, null, 2)); + else { + console.log(`思考模式切换失败:${msg}`); + console.log(`模型配置:${V2_CONFIG}`); + console.log(`热档位库:${SESSION_DB}(可用 ZCODE_V2_CONFIG / ZCODE_SESSION_DB 指定)`); + } + process.exitCode = 1; +} + +function loadConfig() { + return JSON.parse(fs.readFileSync(V2_CONFIG, "utf8")); +} + +/* ---------------------------------------------------------------- 默认值 + * plugin.json 的 userConfig 在设置界面能填,但宿主只把 ${user_config.*} 插值进 + * MCP server 字段与 hook,命令正文拿不到 —— 所以脚本自己读这份落地文件, + * 否则那两个配置项就是摆设(填了也不生效)。 + */ + +function readDefaults() { + let file = {}; + try { file = JSON.parse(fs.readFileSync(DEFAULTS_FILE, "utf8")) ?? {}; } catch { /* 无文件/坏文件都当空 */ } + const pick = (...vs) => vs.find((v) => typeof v === "string" && v.trim() !== "") ?? null; + return { + file, + variant: pick(process.env.ZCODE_THINKING_VARIANT, file.variant, BUILTIN_DEFAULTS.variant), + model: pick(process.env.ZCODE_THINKING_MODEL, file.model, BUILTIN_DEFAULTS.model), + source: { + variant: pick(process.env.ZCODE_THINKING_VARIANT) ? "env" : (pick(file.variant) ? "file" : "none"), + model: pick(process.env.ZCODE_THINKING_MODEL) ? "env" : (pick(file.model) ? "file" : "none"), + }, + }; +} +function writeDefaults(patch) { + const cur = readDefaults().file ?? {}; + for (const [k, v] of Object.entries(patch)) { + if (v === null || v === undefined || v === "") delete cur[k]; + else cur[k] = v; + } + fs.mkdirSync(path.dirname(DEFAULTS_FILE), { recursive: true }); + fs.writeFileSync(DEFAULTS_FILE, JSON.stringify(cur, null, 2) + "\n", "utf8"); + return cur; +} +const DEFAULTS_HINT = "默认值文件:~/.zcode/zcode-thinking-mode.json(可用 `defaults set` 写、`defaults clear` 清)"; + +function defaultsReport(asJson) { + const d = readDefaults(); + if (asJson) { + console.log(JSON.stringify({ + file: DEFAULTS_FILE, exists: fs.existsSync(DEFAULTS_FILE), content: d.file, + effective: { variant: d.variant, model: d.model }, source: d.source, + builtin: BUILTIN_DEFAULTS, + }, null, 2)); + return 0; + } + console.log("## 思考模式默认值"); + console.log(""); + console.log(`- 默认档位:\`${d.variant ?? "(未设)"}\`${d.variant ? ` (来源:${d.source.variant === "env" ? "环境变量" : "默认值文件"})` : ""}`); + console.log(`- 默认模型:\`${d.model ?? "(全部)"}\`${d.model ? ` (来源:${d.source.model === "env" ? "环境变量" : "默认值文件"})` : ""}`); + console.log(`- 文件:${DEFAULTS_FILE}${fs.existsSync(DEFAULTS_FILE) ? "" : "(尚未创建)"}`); + if (fs.existsSync(DEFAULTS_FILE)) console.log(`- 内容:${JSON.stringify(d.file)}`); + console.log(""); + console.log("优先级:命令行参数 > 环境变量(ZCODE_THINKING_VARIANT / ZCODE_THINKING_MODEL) > 默认值文件 > 内置兜底"); + console.log(""); + console.log("set 不带档位参数时会用这里的默认档位;status/variants 不带 --model 时用这里的默认模型。"); + return 0; +} + +/* ---------------------------------------------------------------- doctor + * 自检:安装是否完整、路径能否定位、两个写入目标是否可写、node:sqlite 是否可用。 + * 只读检查,不改任何东西;有 fail 时退出码 1。 + */ + +async function doctor(asJson) { + const checks = []; + const add = (level, name, detail, fix) => checks.push({ level, name, detail, fix }); + const exists = (p) => { try { return fs.existsSync(p); } catch { return false; } }; + + const major = Number(process.versions.node.split(".")[0]); + add(major >= 22 ? "ok" : "warn", "Node 版本", `v${process.versions.node}`, + major >= 22 ? null : "热档位读写需要 node:sqlite(Node ≥22.5),否则只能改模型配置"); + + let sqlite = null; + try { ({ DatabaseSync: sqlite } = await import("node:sqlite")); } catch { /* 下面报 */ } + add(sqlite ? "ok" : "fail", "node:sqlite", sqlite ? "可用" : "导入失败", + sqlite ? null : "升级 Node 到 ≥22.5,否则热档位那一半写不了"); + + // 模型配置 + if (!exists(V2_CONFIG)) add("fail", "模型配置", `读不到 ${V2_CONFIG}`, "确认已配置过模型;或设 ZCODE_V2_CONFIG 指向实际路径"); + else { + let doc = null; + try { doc = loadConfig(); } catch (e) { add("fail", "模型配置", `JSON 解析失败(${e.message})`, "修好 JSON 或从 .bak-* 恢复"); } + if (doc) { + const withReasoning = collectModels(doc).filter((m) => m.reasoning); + add(withReasoning.length ? "ok" : "warn", "可切换模型", `${withReasoning.length} 个带 reasoning`, + withReasoning.length ? null : "没有任何模型声明 reasoning.variants,档位切不了"); + add("ok", "模型配置解析", V2_CONFIG, null); + } + } + + // 热档位库 + if (!exists(SESSION_DB)) add("fail", "热档位库", `读不到 ${SESSION_DB}`, "确认 ZCode 跑过至少一次;或设 ZCODE_SESSION_DB"); + else if (sqlite) { + try { + const db = new sqlite(SESSION_DB, { readOnly: true }); + const tbl = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='local_setting'").get(); + db.close(); + add(tbl ? "ok" : "fail", "热档位库结构", tbl ? "有 local_setting 表" : "缺 local_setting 表", + tbl ? null : "这不是有效的 session 库,热档位写不进去"); + } catch (e) { + add("fail", "热档位库", `打开失败(${e.message})`, "检查文件权限/是否被独占"); + } + } + // 热档位当前值与库是否一致 + const hot = await readGlobalLevel(); + add(hot.error ? "warn" : "ok", "当前热档位", hot.error ? hot.error : (hot.level ?? `(未设:${hot.note ?? "无此行"})`), null); + + // 默认值文件 + const d = readDefaults(); + add("ok", "默认值", `档位=${d.variant ?? "(未设)"} 模型=${d.model ?? "(全部)"}`, + `来源:${d.source.variant}/${d.source.model};${DEFAULTS_HINT}`); + + // 写入能力 + const dir = path.dirname(V2_CONFIG); + add(exists(dir) ? "ok" : "warn", "配置目录", dir, exists(dir) ? null : "目录不存在,切换时会创建失败"); + if (exists(V2_CONFIG)) { + try { fs.accessSync(V2_CONFIG, fs.constants.W_OK); add("ok", "配置可写", "有写权限", null); } + catch { add("fail", "配置可写", "无写权限", "检查文件是否为只读或属主不对"); } + } + add(exists(path.dirname(DEFAULTS_FILE)) ? "ok" : "warn", "默认值目录", path.dirname(DEFAULTS_FILE), + exists(path.dirname(DEFAULTS_FILE)) ? null : "目录不存在,`defaults set` 时会自动创建"); + + // 备份可写 + const bakProbe = `${V2_CONFIG}.bak-probe`; + let backupWritable = false; + try { fs.writeFileSync(bakProbe, ""); fs.unlinkSync(bakProbe); backupWritable = true; } catch { /* 下面报 */ } + add(backupWritable ? "ok" : "fail", "备份可写", backupWritable ? "能在配置目录写备份" : "写不了备份文件", + backupWritable ? null : "配置目录不可写,切换时会因无法备份而中止"); + + const fails = checks.filter((c) => c.level === "fail"); + const warns = checks.filter((c) => c.level === "warn"); + if (asJson) { + console.log(JSON.stringify({ v2config: V2_CONFIG, sessionDb: SESSION_DB, defaultsFile: DEFAULTS_FILE, checks, fails: fails.length, warns: warns.length }, null, 2)); + } else { + const icon = { ok: "✓", warn: "⚠", fail: "✗" }; + console.log("# zcode-thinking-mode 自检"); + console.log(`- ${checks.length} 项:${checks.length - fails.length - warns.length} ok / ${warns.length} warn / ${fails.length} fail`); + console.log(""); + for (const c of checks) { + console.log(`${icon[c.level]} ${c.name}:${c.detail}`); + if (c.fix) console.log(` → ${c.fix}`); + } + console.log(""); + console.log(fails.length ? `结论:有 ${fails.length} 项必须先修,否则切换会失败。` + : warns.length ? `结论:可正常工作,${warns.length} 项警告建议处理。` : "结论:一切就绪。"); + } + return fails.length ? 1 : 0; +} + +/** + * 备份名精确到秒,同一秒内连切两次会撞名 —— 后一份会覆盖前一份, + * 于是"回滚到改动前"实际退回的是倒数第二次的中间态。撞名时补序号。 + */ +function saveConfig(doc) { + const ts = new Date(); + const p = (x) => String(x).padStart(2, "0"); + const stamp = `${ts.getFullYear()}${p(ts.getMonth() + 1)}${p(ts.getDate())}-${p(ts.getHours())}${p(ts.getMinutes())}${p(ts.getSeconds())}`; + let bak = `${V2_CONFIG}.bak-${stamp}`; + for (let i = 1; fs.existsSync(bak); i++) bak = `${V2_CONFIG}.bak-${stamp}.${i}`; + fs.copyFileSync(V2_CONFIG, bak); + fs.writeFileSync(V2_CONFIG, JSON.stringify(doc, null, 2), "utf8"); + return bak; +} + +function collectModels(doc) { + const out = []; + const providers = doc?.provider ?? {}; + for (const [pid, p] of Object.entries(providers)) { + const models = p?.models ?? {}; + for (const [mname, m] of Object.entries(models)) { + out.push({ + providerId: pid, + providerName: p?.name ?? pid, + model: mname, + enabled: p?.enabled !== false, + reasoning: m?.reasoning ?? null, + }); + } + } + return out; +} + +function filterByModel(list, name) { + if (!name) return list; + const n = name.toLowerCase(); + return list.filter((m) => m.model.toLowerCase() === n); +} + +// ---------------------------------------------------------------- 热档位(sqlite) + +async function readGlobalLevel() { + let DatabaseSync; + try { + ({ DatabaseSync } = await import("node:sqlite")); + } catch { + return { error: "当前 node 不支持 node:sqlite(需 Node 22.5+),热档位读不到" }; + } + let db; + try { + db = new DatabaseSync(SESSION_DB, { readOnly: true }); + const row = db.prepare( + "SELECT value FROM local_setting WHERE scope='user' AND scope_id='default' AND namespace='model' AND key='reasoningLevel'" + ).get(); + if (!row) return { level: null, note: "库里没有 reasoningLevel 行(从未用 /effort 切过)" }; + const v = JSON.parse(row.value); + return { level: typeof v?.level === "string" ? v.level : null }; + } catch (e) { + return { error: `读热档位库失败(${e?.message ?? e})` }; + } finally { + try { db?.close(); } catch { /* ignore */ } + } +} + +async function writeGlobalLevel(level) { + let DatabaseSync; + try { + ({ DatabaseSync } = await import("node:sqlite")); + } catch { + throw new Error("当前 node 不支持 node:sqlite(需 Node 22.5+),热档位写不了(模型配置仍可写)"); + } + const db = new DatabaseSync(SESSION_DB); + try { + db.exec("PRAGMA busy_timeout = 5000"); + const tbl = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='local_setting'").get(); + if (!tbl) throw new Error("库里没有 local_setting 表,不是有效的 session 库"); + const prev = db.prepare( + "SELECT value FROM local_setting WHERE scope='user' AND scope_id='default' AND namespace='model' AND key='reasoningLevel'" + ).get(); + const now = Date.now(); + db.prepare(` + INSERT INTO local_setting (scope, scope_id, namespace, key, value, schema_version, time_created, time_updated) + VALUES ('user', 'default', 'model', 'reasoningLevel', ?, 1, ?, ?) + ON CONFLICT(scope, scope_id, namespace, key) DO UPDATE SET + value = excluded.value, + schema_version = excluded.schema_version, + time_updated = excluded.time_updated + `).run(JSON.stringify({ level }), now, now); + let previous = null; + try { previous = prev ? JSON.parse(prev.value)?.level ?? null : null; } catch { /* ignore */ } + return { previous }; + } finally { + try { db?.close(); } catch { /* ignore */ } + } +} + +// ---------------------------------------------------------------- state 历史 +// 每次 set 成功后追加一条:{ts, variant, model, hotOnly, persistOnly, +// hotPrevious, hotLevel, persistPrevious:[{provider,model,from,to}], persistCurrent, +// backup}。rollback 按最新一条把热档位与持久默认写回去,不删历史(可多次回滚)。 + +function readState() { + try { + const raw = fs.readFileSync(STATE_FILE, "utf8"); + const j = JSON.parse(raw); + return Array.isArray(j?.entries) ? j.entries : []; + } catch { return []; } +} +function appendState(entry) { + const entries = readState(); + entries.push({ ts: new Date().toISOString(), ...entry }); + // 只保留最近 50 条,防止无限增长 + const kept = entries.slice(-50); + fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true }); + fs.writeFileSync(STATE_FILE, JSON.stringify({ entries: kept }, null, 2) + "\n", "utf8"); + return kept; +} +function historyReport(asJson, limit) { + const entries = readState().slice(-(limit ?? 10)); + if (asJson) { + console.log(JSON.stringify({ stateFile: STATE_FILE, count: entries.length, entries }, null, 2)); + return 0; + } + console.log("## 思考模式切换历史"); + console.log(""); + console.log(`- 文件:${STATE_FILE}${fs.existsSync(STATE_FILE) ? "" : "(尚无记录)"}`); + if (!entries.length) { console.log("- 暂无切换记录"); return 0; } + for (const e of entries) { + const scope = e.hotOnly ? "仅热" : e.persistOnly ? "仅持久" : "热+持久"; + console.log(`- ${e.ts} ${e.hotPrevious ?? "—"} → \`${e.hotLevel}\` [${scope}]${e.model ? ` (模型:${e.model})` : ""}${e.backup ? ` 备份:${e.backup}` : ""}`); + } + console.log(""); + console.log(`回滚:node scripts/thinking.mjs rollback(把最近一次的改动写回去)`); + return 0; +} +async function doRollback(asJson, steps) { + const entries = readState(); + if (!entries.length) { fail("没有切换历史可回滚", asJson, { stateFile: STATE_FILE }); process.exit(1); } + const n = Math.max(1, Math.min(steps ?? 1, entries.length)); + const target = entries[entries.length - n]; + // 1) 热档位写回 + if (target.hotPrevious !== undefined && target.hotPrevious !== null) { + try { await writeGlobalLevel(target.hotPrevious); } + catch (e) { fail(`回滚热档位失败(${e?.message ?? e})——模型配置未动`, asJson); process.exit(1); } + } + // 2) 持久默认逐个写回(只动历史里记录过的模型) + let doc; + try { doc = loadConfig(); } catch (e) { fail(`读模型配置失败(${e?.message ?? e})`, asJson); process.exit(1); } + const restored = []; + for (const p of target.persistPrevious ?? []) { + const m = doc?.provider?.[p.provider]?.models?.[p.model]; + if (m?.reasoning && m.reasoning.defaultVariant !== p.from) { + m.reasoning.defaultVariant = p.from; + restored.push({ provider: p.provider, model: p.model, defaultVariant: p.from }); + } + } + let bak = null; + if (restored.length) { + try { bak = saveConfig(doc); } + catch (e) { fail(`热档位已回滚到 ${target.hotPrevious},但写模型配置失败(${e?.message ?? e})`, asJson); process.exit(1); } + } + if (asJson) { + console.log(JSON.stringify({ + stateFile: STATE_FILE, rolledBack: target.ts, hotLevel: target.hotPrevious, + restored, backup: bak, + }, null, 2)); + } else { + console.log("## 已回滚到上次切换之前"); + console.log(""); + console.log(`- 回滚目标:${target.ts} (${target.hotPrevious ?? "—"} → \`${target.hotLevel}\`)`); + console.log(`- 全局热档位 → \`${target.hotPrevious ?? "(历史未记录,不动)"}\``); + if (restored.length) { + for (const r of restored) console.log(`- **${r.model}** (provider \`${r.provider}\`)持久默认 → \`${r.defaultVariant}\``); + console.log(`备份:${bak}`); + } else console.log("- 模型配置持久默认:与历史一致,未改写"); + console.log(""); + console.log(`当前会话要立刻生效,再敲一行内置命令:\`/effort ${target.hotPrevious}\`(热切,不重启)`); + } + process.exit(0); +} + +// ---------------------------------------------------------------- CLI + +const argv = process.argv.slice(2); +const asJson = argv.includes("--json"); +const ACTIONS = ["status", "set", "variants", "defaults", "doctor", "rollback", "history"]; +const action = argv.find((a) => ACTIONS.includes(a)) ?? "status"; + +function opt(name) { + const i = argv.indexOf(name); + return i >= 0 ? argv[i + 1] : undefined; +} +/** --model 优先;没给就用默认值文件/环境变量里的,再没有就是 null(全部) */ +const defaults = readDefaults(); +const modelOpt = opt("--model") ?? defaults.model ?? undefined; + +if (action === "doctor") { process.exit(await doctor(asJson)); } + +if (action === "history") { + const lim = opt("--limit") ?? opt("--steps"); + process.exit(historyReport(asJson, lim ? Number(lim) : 10)); +} + +if (action === "rollback") { + const steps = opt("--steps"); + await doRollback(asJson, steps ? Number(steps) : 1); +} + +if (action === "defaults") { + // defaults / defaults set / defaults clear + const sub = argv[argv.indexOf("defaults") + 1]; + if (sub === "set") { + const v = opt("--variant"); + const m = opt("--model"); + if (v === undefined && m === undefined) { + fail("用法: node thinking.mjs defaults set [--variant ] [--model ] [--json]", asJson); + process.exit(1); + } + const content = writeDefaults({ variant: v ?? undefined, model: m ?? undefined }); + if (asJson) console.log(JSON.stringify({ file: DEFAULTS_FILE, content }, null, 2)); + else { + console.log("## 默认值已写入"); + console.log(""); + console.log(`- 文件:\`${DEFAULTS_FILE}\``); + console.log(`- 内容:${JSON.stringify(content)}`); + console.log(""); + console.log("之后 `set` 不带档位就用这个默认档位;`status`/`variants` 不带 --model 就用这个默认模型。"); + } + process.exit(0); + } + if (sub === "clear") { + try { fs.rmSync(DEFAULTS_FILE, { force: true }); } catch (e) { fail(`删除默认值文件失败(${e?.message ?? e})`, asJson); process.exit(1); } + if (asJson) console.log(JSON.stringify({ file: DEFAULTS_FILE, removed: true }, null, 2)); + else console.log(`## 默认值已清空\n\n- 已删除 \`${DEFAULTS_FILE}\`\n- 之后回到内置兜底(档位不预设、模型=全部)`); + process.exit(0); + } + process.exit(defaultsReport(asJson)); +} + +const hot = await readGlobalLevel(); + +if (action === "variants") { + let doc; + try { doc = loadConfig(); } catch (e) { fail(`读模型配置失败(${e?.message ?? e})`, asJson); process.exit(1); } + const list = filterByModel(collectModels(doc).filter((m) => m.reasoning), modelOpt); + if (asJson) { + console.log(JSON.stringify({ + v2config: V2_CONFIG, sessionDb: SESSION_DB, hotLevel: hot.level ?? null, + models: list.map((m) => ({ provider: m.providerId, model: m.model, variants: m.reasoning.variants ?? [], defaultVariant: m.reasoning.defaultVariant ?? null })), + }, null, 2)); + } else if (!list.length) { + console.log("没有带 reasoning 的模型(或 --model 名称不对)。"); + } else { + console.log(`全局热档位:${hot.level ?? "(读失败:" + (hot.error ?? hot.note) + ")"}`); + console.log(""); + console.log("## 可选思考档位"); + console.log(""); + for (const m of list) { + console.log(`- **${m.model}** (provider \`${m.providerId}\`):可选 \`${(m.reasoning.variants ?? []).join(" / ") || "—"}\`,持久默认 \`${m.reasoning.defaultVariant ?? "—"}\``); + } + } + process.exit(0); +} + +if (action === "status") { + let doc; + try { doc = loadConfig(); } catch (e) { fail(`读模型配置失败(${e?.message ?? e})`, asJson); process.exit(1); } + const list = filterByModel(collectModels(doc), modelOpt); + if (asJson) { + console.log(JSON.stringify({ + v2config: V2_CONFIG, sessionDb: SESSION_DB, + hotLevel: hot.level ?? null, hotError: hot.error ?? hot.note ?? null, + models: list.map((m) => ({ + provider: m.providerId, model: m.model, enabled: m.enabled, + reasoning: m.reasoning ? { enabled: m.reasoning.enabled ?? null, variants: m.reasoning.variants ?? [], defaultVariant: m.reasoning.defaultVariant ?? null } : null, + })), + }, null, 2)); + } else if (!list.length) { + console.log("模型配置里没找到模型(或 --model 名称不对)。"); + } else { + console.log("## 思考模式现状"); + console.log(""); + if (hot.level) console.log(`全局热档位:\`${hot.level}\`(新会话直接按它;当前会话要立刻生效再敲 \`/effort ${hot.level}\`)`); + else console.log(`全局热档位:读失败(${(hot.error ?? hot.note ?? "未知")})`); + console.log(`模型配置:\`${V2_CONFIG}\``); + console.log(""); + for (const m of list) { + if (!m.reasoning) console.log(`- **${m.model}** (provider \`${m.providerId}\`):无 reasoning 配置(不支持切换档位)`); + else console.log(`- **${m.model}** (provider \`${m.providerId}\`):持久默认 \`${m.reasoning.defaultVariant ?? "—"}\`,可选 \`${(m.reasoning.variants ?? []).join(" / ") || "—"}\``); + } + console.log(""); + console.log("切换示例:`/thinking:set high` 或 `/thinking:set low --model GLM-5.3-Flash`"); + } + process.exit(0); +} + +// --- set --- +const setIdx = argv.indexOf("set"); +const variantArg = setIdx >= 0 ? argv[setIdx + 1] : undefined; +// 不带档位时用默认值文件里的档位;两处都没有才报用法错(不再硬性要求手打) +const variant = (variantArg && !variantArg.startsWith("--")) ? variantArg : defaults.variant; +const variantFromDefault = !(variantArg && !variantArg.startsWith("--")); +const hotOnly = argv.includes("--hot-only"); +const persistOnly = argv.includes("--persist-only"); +if (hotOnly && persistOnly) { + fail("用法:--hot-only 与 --persist-only 不能同时用(只写热档位,或只写模型配置)", asJson); + process.exit(1); +} +if (!variant) { + fail("没给档位,默认值文件里也没设。用法: node thinking.mjs set [--model ] [--json] [--hot-only | --persist-only]\n" + + " 或先定个默认:`node thinking.mjs defaults set --variant high`", asJson); + process.exit(1); +} + +let doc; +try { doc = loadConfig(); } catch (e) { fail(`读模型配置失败(${e?.message ?? e})`, asJson); process.exit(1); } +const targets = filterByModel(collectModels(doc).filter((m) => m.reasoning), modelOpt); +if (!targets.length) { + fail(modelOpt ? `没找到带 reasoning 的模型:${modelOpt}` : "模型配置里没有带 reasoning 的模型", asJson); + process.exit(1); +} +// 校验收紧:① 档位必须在任一目标的 variants 并集里(防 ultra 之类非法档位两边都不写); +// ② 之后仍做逐模型整批校验,有一个不支持就整批中止。 +const unionVariants = [...new Set(targets.flatMap((t) => (t.reasoning.variants ?? []).map((v) => String(v).toLowerCase())))]; +if (unionVariants.length && !unionVariants.includes(String(variant).toLowerCase())) { + fail(`非法档位 ${variant}(全部目标的可选:${unionVariants.join("/")})——两边都没写`, asJson); + process.exit(1); +} +for (const t of targets) { + const vs = t.reasoning.variants ?? []; + if (vs.length && !vs.map((v) => String(v).toLowerCase()).includes(variant.toLowerCase())) { + fail(`模型 ${t.model} 不支持档位 ${variant}(可选:${vs.join("/")})——整批中止,两边都没写`, asJson); + process.exit(1); + } +} +// 规范大小写(跟模型自带的 variants 一致) +const canonicalOf = (t) => (t.reasoning.variants ?? []).find((v) => String(v).toLowerCase() === variant.toLowerCase()) ?? variant; +const canonical = canonicalOf(targets[0]); +const persistPrevious = targets.map((t) => ({ + provider: t.providerId, model: t.model, from: t.reasoning.defaultVariant ?? null, +})); + +// 1) 热档位(默认写;--persist-only 时跳过) +let hotPrev = null; +if (!persistOnly) { + try { + ({ previous: hotPrev } = await writeGlobalLevel(canonical)); + } catch (e) { + fail(`写热档位库失败(${e?.message ?? e})——模型配置未动`, asJson); + process.exit(1); + } +} else { + try { hotPrev = (await readGlobalLevel()).level ?? null; } catch { hotPrev = null; } +} + +// 2) 模型配置持久默认(默认写;--hot-only 时跳过) +const changed = []; +if (!hotOnly) { + for (const t of targets) { + const m = doc.provider[t.providerId].models[t.model]; + const c = canonicalOf(t); + if (m?.reasoning && m.reasoning.defaultVariant !== c) { + m.reasoning.defaultVariant = c; + changed.push({ provider: t.providerId, model: t.model, defaultVariant: c }); + } + } +} +let bak = null; +if (changed.length) { + try { bak = saveConfig(doc); } + catch (e) { fail(`热档位已切到 ${canonical},但写模型配置失败(${e?.message ?? e})`, asJson, { hotLevel: canonical }); process.exit(1); } +} +// set 成功才记历史(含分写标记与持久旧值,供 rollback 用) +appendState({ + variant: canonical, model: modelOpt ?? null, hotOnly, persistOnly, + hotPrevious: hotOnly ? hotPrev : hotPrev, hotLevel: canonical, + persistPrevious, persistCurrent: canonical, backup: bak, +}); + +const scopeLabel = hotOnly ? "仅热" : persistOnly ? "仅持久" : "热 + 持久"; +if (asJson) { + console.log(JSON.stringify({ + v2config: V2_CONFIG, sessionDb: SESSION_DB, stateFile: STATE_FILE, + backup: bak, hotPrevious: hotPrev, hotLevel: canonical, + hotOnly, persistOnly, + variantFromDefault, modelFromDefault: !opt("--model") && !!defaults.model, changed, + }, null, 2)); +} else { + console.log(`## 思考模式已切换(${scopeLabel})`); + console.log(""); + if (variantFromDefault) console.log(`- 档位来自默认值(${defaults.source.variant === "env" ? "环境变量" : "默认值文件"}):\`${canonical}\``); + if (!persistOnly) console.log(`- 全局热档位:${hotPrev ?? "—"} → \`${canonical}\`(新会话直接生效)`); + else console.log(`- 全局热档位:本次仅持久,未动(当前 \`${hotPrev ?? "—"}\`)`); + if (!opt("--model") && defaults.model) console.log(`- 作用模型来自默认值(未给 --model):\`${defaults.model}\``); + if (hotOnly) console.log("- 模型配置持久默认:本次仅热,未改写"); + else if (changed.length) { + for (const c of changed) console.log(`- **${c.model}** (provider \`${c.provider}\`)持久默认 → \`${c.defaultVariant}\``); + console.log(""); + console.log(`备份:${bak}(回滚:node scripts/thinking.mjs rollback,或把它拷回 ${V2_CONFIG})`); + } else { + console.log("- 模型配置持久默认:本来就是目标档位,未改写"); + } + console.log(""); + console.log(`历史已记:${STATE_FILE}`); + if (!persistOnly) console.log(`当前这轮会话要立刻生效,再敲一行内置命令:\`/effort ${canonical}\`(热切,不重启)`); +} diff --git a/zcode-thinking-mode/tests/thinking.test.mjs b/zcode-thinking-mode/tests/thinking.test.mjs new file mode 100644 index 0000000..80c2d12 --- /dev/null +++ b/zcode-thinking-mode/tests/thinking.test.mjs @@ -0,0 +1,632 @@ +/** + * zcode-thinking-mode 测试:档位读取 / 切换 / 备份 / 边界与失败路径。 + * + * 用 ZCODE_V2_CONFIG + ZCODE_SESSION_DB 把两个写入目标都重定向到临时目录, + * 全程不碰真实 ~/.zcode 与真实模型配置。 + * + * 跑法: node --test tests/ + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { DatabaseSync } from "node:sqlite"; + +const SCRIPT = path.resolve(import.meta.dirname, "..", "scripts", "thinking.mjs"); + +/* ---------- 沙箱 ---------- */ + +function sandbox() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ztm-")); + const cfg = path.join(root, "v2", "config.json"); + const db = path.join(root, "db.sqlite"); + fs.mkdirSync(path.dirname(cfg), { recursive: true }); + return { root, cfg, db, defaults: path.join(root, "thinking-defaults.json"), state: path.join(root, "thinking-state.json") }; +} + +/** 一份典型模型配置:两个 provider,三个带 reasoning 的模型 + 一个不带的 */ +function configFixture() { + return { + provider: { + "builtin:bigmodel": { + name: "BigModel", + kind: "builtin", + enabled: true, + models: { + "GLM-5.3": { reasoning: { enabled: true, variants: ["low", "max", "high"], defaultVariant: "max" } }, + "GLM-5.3-Flash": { reasoning: { enabled: true, variants: ["low", "max", "high"], defaultVariant: "max" } }, + "qwen3-flash": {}, + }, + }, + "custom-a": { + name: "CustomA", + kind: "custom", + models: { + "deepseek-v4": { reasoning: { enabled: true, variants: ["low", "high"], defaultVariant: "low" } }, + }, + }, + }, + }; +} + +function writeConfig(sb, doc) { + fs.mkdirSync(path.dirname(sb.cfg), { recursive: true }); + fs.writeFileSync(sb.cfg, JSON.stringify(doc, null, 2)); +} +function readConfig(sb) { return JSON.parse(fs.readFileSync(sb.cfg, "utf8")); } + +/** 造 session 库;level 为 null 时建表但不插 reasoningLevel 行 */ +function makeDb(dbPath, level = null) { + const db = new DatabaseSync(dbPath); + db.exec(`CREATE TABLE local_setting ( + scope TEXT NOT NULL, scope_id TEXT NOT NULL, namespace TEXT NOT NULL, key TEXT NOT NULL, + value TEXT NOT NULL, schema_version INTEGER, time_created INTEGER, time_updated INTEGER, + PRIMARY KEY (scope, scope_id, namespace, key))`); + if (level !== null) { + const now = Date.now(); + db.prepare("INSERT INTO local_setting VALUES ('user','default','model','reasoningLevel',?,1,?,?)") + .run(JSON.stringify({ level }), now, now); + } + db.close(); +} +/** 只建一个无关表:模拟"这不是有效的 session 库" */ +function makeAlienDb(dbPath) { + const db = new DatabaseSync(dbPath); + db.exec("CREATE TABLE whatever (id INTEGER)"); + db.close(); +} +function readHot(dbPath) { + const db = new DatabaseSync(dbPath, { readOnly: true }); + try { + const row = db.prepare( + "SELECT value FROM local_setting WHERE scope='user' AND scope_id='default' AND namespace='model' AND key='reasoningLevel'" + ).get(); + return row ? JSON.parse(row.value).level : null; + } finally { db.close(); } +} +function backups(sb) { + const dir = path.dirname(sb.cfg); + return fs.readdirSync(dir).filter((f) => f.startsWith(path.basename(sb.cfg) + ".bak-")); +} + +function run(sb, args, env = {}) { + const r = spawnSync(process.execPath, [SCRIPT, ...args], { + env: { + ...process.env, + ZCODE_V2_CONFIG: sb.cfg, + ZCODE_SESSION_DB: sb.db, + // 必须重定向:否则脚本会读真实 ~/.zcode/zcode-thinking-mode.json,测试就不隔离了 + ZCODE_THINKING_CONFIG: sb.defaults, + ZCODE_THINKING_STATE: sb.state, + ...env, + }, + encoding: "utf8", + timeout: 60000, + }); + return { code: r.status, out: r.stdout ?? "", err: r.stderr ?? "" }; +} + +/* ---------- status / variants ---------- */ + +test("status:列出带 reasoning 的模型与可选档位,并标出无 reasoning 的", () => { + const sb = sandbox(); + writeConfig(sb, configFixture()); + makeDb(sb.db, "high"); + + const r = run(sb, ["status"]); + assert.equal(r.code, 0, r.err); + assert.match(r.out, /## 思考模式现状/); + assert.match(r.out, /全局热档位:`high`/, "应显示库里读到的热档位"); + assert.match(r.out, /\*\*GLM-5\.3\*\* \(provider `builtin:bigmodel`\):持久默认 `max`,可选 `low \/ max \/ high`/); + assert.match(r.out, /\*\*deepseek-v4\*\* \(provider `custom-a`\):持久默认 `low`,可选 `low \/ high`/); + assert.match(r.out, /qwen3-flash.*无 reasoning 配置/, "不支持的模型也要列出并标注"); + assert.match(r.out, /\/thinking:set high/, "应给出切换示例"); +}); + +test("status --json:结构化输出,含热档位与每个模型的档位信息", () => { + const sb = sandbox(); + writeConfig(sb, configFixture()); + makeDb(sb.db, "max"); + + const r = run(sb, ["status", "--json"]); + assert.equal(r.code, 0, r.err); + const j = JSON.parse(r.out); + assert.equal(j.hotLevel, "max"); + assert.equal(j.v2config, sb.cfg); + assert.equal(j.sessionDb, sb.db); + const glm = j.models.find((m) => m.model === "GLM-5.3"); + assert.deepEqual(glm.reasoning.variants, ["low", "max", "high"]); + assert.equal(glm.reasoning.defaultVariant, "max"); + const qwen = j.models.find((m) => m.model === "qwen3-flash"); + assert.equal(qwen.reasoning, null); +}); + +test("status:库里没有 reasonleLevel 行时如实说明,而不是假装读到档位", () => { + const sb = sandbox(); + writeConfig(sb, configFixture()); + makeDb(sb.db, null); + + const r = run(sb, ["status"]); + assert.equal(r.code, 0, r.err); + assert.match(r.out, /全局热档位:读失败\(库里没有 reasoningLevel 行/); + const j = JSON.parse(run(sb, ["status", "--json"]).out); + assert.equal(j.hotLevel, null); + assert.match(String(j.hotError), /没有 reasoningLevel 行/); +}); + +test("variants:只列带 reasoning 的模型,不带的不出现", () => { + const sb = sandbox(); + writeConfig(sb, configFixture()); + makeDb(sb.db, "low"); + + const r = run(sb, ["variants"]); + assert.equal(r.code, 0, r.err); + assert.match(r.out, /## 可选思考档位/); + assert.match(r.out, /GLM-5\.3/); + assert.match(r.out, /deepseek-v4/); + assert.doesNotMatch(r.out, /qwen3-flash/, "无 reasoning 的模型不该出现在可选档位表里"); + + const j = JSON.parse(run(sb, ["variants", "--json"]).out); + assert.equal(j.hotLevel, "low"); + assert.deepEqual(j.models.map((m) => m.model).sort(), ["GLM-5.3", "GLM-5.3-Flash", "deepseek-v4"]); +}); + +test("status --model:名称不对时给明确提示,而不是静默空表", () => { + const sb = sandbox(); + writeConfig(sb, configFixture()); + makeDb(sb.db, "max"); + + const r = run(sb, ["status", "--model", "不存在的模型"]); + assert.equal(r.code, 0, r.err); + assert.match(r.out, /没找到模型\(或 --model 名称不对\)/); +}); + +/* ---------- set:正常路径 ---------- */ + +test("set high:热档位与持久默认同时改,并留备份", () => { + const sb = sandbox(); + writeConfig(sb, configFixture()); + makeDb(sb.db, "low"); + + const r = run(sb, ["set", "high"]); + assert.equal(r.code, 0, r.err); + assert.match(r.out, /## 思考模式已切换\(热 \+ 持久\)/); + assert.match(r.out, /全局热档位:low → `high`/); + assert.match(r.out, /\/effort high/, "要保留当前会话热切入口提示"); + + assert.equal(readHot(sb.db), "high", "热档位库应被写成 high"); + const doc = readConfig(sb); + for (const [pid, name] of [["builtin:bigmodel", "GLM-5.3"], ["builtin:bigmodel", "GLM-5.3-Flash"], ["custom-a", "deepseek-v4"]]) { + assert.equal(doc.provider[pid].models[name].reasoning.defaultVariant, "high", `${name} 持久默认应改成 high`); + } + assert.equal(backups(sb).length, 1, "改写模型配置前必须留一份备份"); +}); + +test("set:本来就是目标档位 → 不改写配置、不产生多余备份", () => { + const sb = sandbox(); + const doc = configFixture(); + for (const p of Object.values(doc.provider)) for (const m of Object.values(p.models)) { + if (m.reasoning) m.reasoning.defaultVariant = "high"; + } + writeConfig(sb, doc); + makeDb(sb.db, "low"); + + const r = run(sb, ["set", "high"]); + assert.equal(r.code, 0, r.err); + assert.match(r.out, /模型配置持久默认:本来就是目标档位,未改写/); + assert.equal(backups(sb).length, 0, "没有改动就不该留备份"); + assert.equal(readHot(sb.db), "high", "热档位仍应更新(它本来就是 low)"); +}); + +test("set --model:只改指定模型,其它模型纹丝不动", () => { + const sb = sandbox(); + writeConfig(sb, configFixture()); + makeDb(sb.db, "max"); + + const r = run(sb, ["set", "low", "--model", "GLM-5.3-Flash"]); + assert.equal(r.code, 0, r.err); + const doc = readConfig(sb); + assert.equal(doc.provider["builtin:bigmodel"].models["GLM-5.3-Flash"].reasoning.defaultVariant, "low"); + assert.equal(doc.provider["builtin:bigmodel"].models["GLM-5.3"].reasoning.defaultVariant, "max", "同 provider 的另一个模型不该被动"); + assert.equal(doc.provider["custom-a"].models["deepseek-v4"].reasoning.defaultVariant, "low", "别的 provider 不该被动"); + // --model 只影响持久默认;热档位是全局一格,仍按请求的档位写 + assert.equal(readHot(sb.db), "low"); +}); + +test("set:HIGH 大小写归一成模型自带写法,而不是写回大写", () => { + const sb = sandbox(); + writeConfig(sb, configFixture()); + makeDb(sb.db, "max"); + + const r = run(sb, ["set", "HIGH"]); + assert.equal(r.code, 0, r.err); + assert.equal(readHot(sb.db), "high"); + assert.equal(readConfig(sb).provider["builtin:bigmodel"].models["GLM-5.3"].reasoning.defaultVariant, "high"); +}); + +test("set --json:给出旧档位与改动清单,便于脚本化与回滚", () => { + const sb = sandbox(); + writeConfig(sb, configFixture()); + makeDb(sb.db, "max"); + + // fixture 里 deepseek-v4 本来就是 low,切 low 时它不该出现在改动清单里 + const r = run(sb, ["set", "low", "--json"]); + assert.equal(r.code, 0, r.err); + const j = JSON.parse(r.out); + assert.equal(j.hotPrevious, "max"); + assert.equal(j.hotLevel, "low"); + assert.equal(j.changed.length, 2, "只有真正变了的模型才该进 changed"); + assert.ok(j.changed.every((c) => c.defaultVariant === "low")); + assert.ok(!j.changed.some((c) => c.model === "deepseek-v4"), "原本就是 low 的模型不算改动"); + assert.ok(j.backup && fs.existsSync(j.backup), "应给出真实存在的备份路径"); +}); + +/* ---------- set:失败路径(关键:失败时不许留下半截改动) ---------- */ + +test("set:模型不支持该档位 → 拒绝,且配置与热档位都不动", () => { + const sb = sandbox(); + writeConfig(sb, configFixture()); + makeDb(sb.db, "low"); + const before = fs.readFileSync(sb.cfg, "utf8"); + + // deepseek-v4 只支持 low/high,没有 max —— 收紧校验先拦下(非法档位,两边都不写) + const r = run(sb, ["set", "max", "--model", "deepseek-v4"]); + assert.equal(r.code, 1); + assert.match(r.out, /非法档位 max\(全部目标的可选:low\/high\)/); + assert.equal(fs.readFileSync(sb.cfg, "utf8"), before, "校验失败不得改写配置"); + assert.equal(readHot(sb.db), "low", "校验失败不得改热档位"); + assert.equal(backups(sb).length, 0); +}); + +test("set:多个目标里有一个不支持 → 整批中止,不做部分切换", () => { + const sb = sandbox(); + writeConfig(sb, configFixture()); + makeDb(sb.db, "low"); + const before = fs.readFileSync(sb.cfg, "utf8"); + + // 不带 --model:GLM 支持 max,deepseek-v4 不支持 —— 不能出现"改了一半" + const r = run(sb, ["set", "max"]); + assert.equal(r.code, 1); + assert.match(r.out, /模型 deepseek-v4 不支持档位 max/); + assert.equal(fs.readFileSync(sb.cfg, "utf8"), before, "整批中止时配置必须原样"); + assert.equal(readHot(sb.db), "low", "整批中止时热档位必须原样"); +}); + +test("set:模型名不存在 → 明确报错并退出 1", () => { + const sb = sandbox(); + writeConfig(sb, configFixture()); + makeDb(sb.db, "max"); + + const r = run(sb, ["set", "high", "--model", "压根没有"]); + assert.equal(r.code, 1); + assert.match(r.out, /没找到带 reasoning 的模型:压根没有/); + assert.equal(readHot(sb.db), "max"); +}); + +test("session 库不是有效 session 库 → 热档位写失败,模型配置不动", () => { + const sb = sandbox(); + writeConfig(sb, configFixture()); + makeAlienDb(sb.db); + const before = fs.readFileSync(sb.cfg, "utf8"); + + const r = run(sb, ["set", "high"]); + assert.equal(r.code, 1); + assert.match(r.out, /写热档位库失败/); + assert.match(r.out, /模型配置未动/, "报错要明确告诉用户配置没被改"); + assert.equal(fs.readFileSync(sb.cfg, "utf8"), before); + assert.equal(backups(sb).length, 0); +}); + +test("缺模型配置 → 退出 1,并指出配置路径", () => { + const sb = sandbox(); + makeDb(sb.db, "max"); + // 不写 config.json + + for (const args of [["status"], ["variants"], ["set", "high"]]) { + const r = run(sb, args); + assert.equal(r.code, 1, `${args.join(" ")} 应失败`); + assert.match(r.out, /思考模式切换失败:读模型配置失败/); + assert.match(r.out, new RegExp(sb.cfg.replace(/\\/g, "\\\\")), "应指出配置路径"); + } +}); + +test("set:缺档位参数 / 档位被当成选项 → 用法报错,不误改", () => { + const sb = sandbox(); + writeConfig(sb, configFixture()); + makeDb(sb.db, "max"); + const before = fs.readFileSync(sb.cfg, "utf8"); + + for (const args of [["set"], ["set", "--json"], ["set", "--model", "GLM-5.3"]]) { + const r = run(sb, args); + assert.equal(r.code, 1, `${args.join(" ")} 应报用法错误`); + assert.match(r.out, /用法: node thinking\.mjs set/); + } + assert.equal(fs.readFileSync(sb.cfg, "utf8"), before); + assert.equal(readHot(sb.db), "max"); +}); + +test("set:覆盖本机已有热档位后,旧值可从输出读回(回滚入口)", () => { + const sb = sandbox(); + writeConfig(sb, configFixture()); + makeDb(sb.db, "low"); + + // 用 high:fixture 里三个带 reasoning 的模型都支持(deepseek-v4 只有 low/high) + const up = run(sb, ["set", "high"]); + assert.equal(up.code, 0, `${up.out}\n${up.err}`); + assert.match(up.out, /全局热档位:low → `high`/); + + const down = run(sb, ["set", "low"]); + assert.equal(down.code, 0, `${down.out}\n${down.err}`); + assert.match(down.out, /全局热档位:high → `low`/, "第二次切换应报出上一次的值"); + assert.equal(readHot(sb.db), "low"); +}); + +test("set:反复切换不留垃圾,备份按时间戳递增", () => { + const sb = sandbox(); + writeConfig(sb, configFixture()); + makeDb(sb.db, "low"); + + assert.equal(run(sb, ["set", "high"]).code, 0); + assert.equal(run(sb, ["set", "low"]).code, 0); + assert.equal(run(sb, ["set", "high"]).code, 0); + + const bks = backups(sb); + assert.ok(bks.length >= 2, `每次改写配置都该留备份,实际 ${bks.length} 个(同一秒内连切也不许撞名覆盖)`); + for (const b of bks) assert.match(b, /\.bak-\d{8}-\d{6}(\.\d+)?$/, `备份名应带时间戳:${b}`); + // 备份内容应是改写前的原文,可据此回滚 + assert.equal(JSON.parse(fs.readFileSync(path.join(path.dirname(sb.cfg), bks[0]), "utf8")).provider["custom-a"].models["deepseek-v4"].reasoning.defaultVariant !== undefined, true); +}); + +/* ---------- 默认值文件(userConfig 的落地形态) ---------- */ + +test("defaults:未设时如实报未设,并说明优先级", () => { + const sb = sandbox(); + writeConfig(sb, configFixture()); + makeDb(sb.db, "max"); + + const r = run(sb, ["defaults"]); + assert.equal(r.code, 0, r.err); + assert.match(r.out, /默认档位:`\(未设\)`/); + assert.match(r.out, /默认模型:`\(全部\)`/); + assert.match(r.out, /尚未创建/); + assert.match(r.out, /命令行参数 > 环境变量.*> 默认值文件 > 内置兜底/); + + const j = JSON.parse(run(sb, ["defaults", "--json"]).out); + assert.equal(j.exists, false); + assert.equal(j.effective.variant, null); + assert.equal(j.effective.model, null); +}); + +test("defaults set:写入后可读回,且默认模型会作用于 status/set", () => { + const sb = sandbox(); + writeConfig(sb, configFixture()); + makeDb(sb.db, "low"); + + const w = run(sb, ["defaults", "set", "--variant", "high", "--model", "GLM-5.3-Flash"]); + assert.equal(w.code, 0, w.err); + assert.match(w.out, /## 默认值已写入/); + assert.ok(fs.existsSync(sb.defaults), "应真的落盘"); + assert.deepEqual(JSON.parse(fs.readFileSync(sb.defaults, "utf8")), { variant: "high", model: "GLM-5.3-Flash" }); + + // set 不带档位 → 用默认档位;不带 --model → 用默认模型 + const r = run(sb, ["set"]); + assert.equal(r.code, 0, `${r.out}\n${r.err}`); + assert.match(r.out, /档位来自默认值.*`high`/); + assert.match(r.out, /作用模型来自默认值.*`GLM-5\.3-Flash`/); + + const doc = readConfig(sb); + assert.equal(doc.provider["builtin:bigmodel"].models["GLM-5.3-Flash"].reasoning.defaultVariant, "high"); + assert.equal(doc.provider["builtin:bigmodel"].models["GLM-5.3"].reasoning.defaultVariant, "max", "默认模型之外的模型不该被动"); + assert.equal(readHot(sb.db), "high"); +}); + +test("defaults:环境变量优先于文件,命令行又优先于环境变量", () => { + const sb = sandbox(); + writeConfig(sb, configFixture()); + makeDb(sb.db, "max"); + fs.writeFileSync(sb.defaults, JSON.stringify({ variant: "low", model: "GLM-5.3" })); + + const envRun = run(sb, ["set"], { ZCODE_THINKING_VARIANT: "high" }); + assert.equal(envRun.code, 0, `${envRun.out}\n${envRun.err}`); + assert.match(envRun.out, /档位来自默认值\(环境变量\):`high`/); + assert.equal(readHot(sb.db), "high"); + + const cliRun = run(sb, ["set", "low"], { ZCODE_THINKING_VARIANT: "high" }); + assert.equal(cliRun.code, 0, `${cliRun.out}\n${cliRun.err}`); + assert.doesNotMatch(cliRun.out, /档位来自默认值/, "命令行给了档位就不该再提默认值"); + assert.equal(readHot(sb.db), "low"); +}); + +test("defaults clear:删除文件后回到内置兜底", () => { + const sb = sandbox(); + writeConfig(sb, configFixture()); + makeDb(sb.db, "max"); + fs.writeFileSync(sb.defaults, JSON.stringify({ variant: "high" })); + + const c = run(sb, ["defaults", "clear"]); + assert.equal(c.code, 0, c.err); + assert.match(c.out, /默认值已清空/); + assert.equal(fs.existsSync(sb.defaults), false, "文件应被删掉"); + + // 兜底:没档位可用必须明确报错,不能猜一个档位就改配置 + const before = fs.readFileSync(sb.cfg, "utf8"); + const r = run(sb, ["set"]); + assert.equal(r.code, 1); + assert.match(r.out, /没给档位,默认值文件里也没设/); + assert.equal(fs.readFileSync(sb.cfg, "utf8"), before); +}); + +test("defaults set:不给任何键时报用法错,不写空文件", () => { + const sb = sandbox(); + writeConfig(sb, configFixture()); + makeDb(sb.db, "max"); + + const r = run(sb, ["defaults", "set"]); + assert.equal(r.code, 1); + assert.match(r.out, /用法: node thinking\.mjs defaults set/); + assert.equal(fs.existsSync(sb.defaults), false, "参数不合法不该留下文件"); +}); + +test("defaults:文件坏了(非法 JSON)按未设处理,不崩", () => { + const sb = sandbox(); + writeConfig(sb, configFixture()); + makeDb(sb.db, "max"); + fs.writeFileSync(sb.defaults, "{ 这不是 json"); + + const r = run(sb, ["defaults"]); + assert.equal(r.code, 0, r.err); + assert.match(r.out, /默认档位:`\(未设\)`/); +}); + +/* ---------- doctor ---------- */ + +test("doctor:环境就绪时无 fail", () => { + const sb = sandbox(); + writeConfig(sb, configFixture()); + makeDb(sb.db, "max"); + + const r = run(sb, ["doctor"]); + assert.equal(r.code, 0, `${r.out}\n${r.err}`); + assert.match(r.out, /# zcode-thinking-mode 自检/); + assert.match(r.out, /✓ 可切换模型:3 个带 reasoning/); + assert.match(r.out, /✓ 配置可写/); + assert.doesNotMatch(r.out, /✗/, "就绪环境不该有 fail 项"); + + const j = JSON.parse(run(sb, ["doctor", "--json"]).out); + assert.equal(j.fails, 0); + assert.equal(j.v2config, sb.cfg); + assert.equal(j.defaultsFile, sb.defaults); +}); + +test("doctor:缺模型配置 → fail 并给修复建议,退出码 1", () => { + const sb = sandbox(); + makeDb(sb.db, "max"); + + const r = run(sb, ["doctor"]); + assert.equal(r.code, 1); + assert.match(r.out, /✗ 模型配置:读不到/); + assert.match(r.out, /ZCODE_V2_CONFIG/); + assert.match(r.out, /结论:有 .* 项必须先修/); +}); + +test("doctor:热档位库缺 local_setting 表 → fail", () => { + const sb = sandbox(); + writeConfig(sb, configFixture()); + makeAlienDb(sb.db); + + const r = run(sb, ["doctor"]); + assert.equal(r.code, 1); + assert.match(r.out, /✗ 热档位库结构:缺 local_setting 表/); +}); + +test("doctor:没有模型声明 reasoning → warn 但不算失败", () => { + const sb = sandbox(); + writeConfig(sb, { provider: { p1: { models: { "plain-model": {} } } } }); + makeDb(sb.db, "max"); + + const r = run(sb, ["doctor"]); + assert.equal(r.code, 0, `只有 warn 时不该失败\n${r.out}`); + assert.match(r.out, /⚠ 可切换模型:0 个带 reasoning/); + assert.match(r.out, /没有任何模型声明 reasoning\.variants/); +}); + +/* ---------- 0.3.0:分写 / 历史 / 回滚 / 校验收紧 ---------- */ + +function readState(sb) { + if (!fs.existsSync(sb.state)) return []; + return JSON.parse(fs.readFileSync(sb.state, "utf8")).entries ?? []; +} + +test("set --hot-only:只写热档位,模型配置不动但仍记历史", () => { + const sb = sandbox(); + writeConfig(sb, configFixture()); + makeDb(sb.db, "low"); + const before = fs.readFileSync(sb.cfg, "utf8"); + + const r = run(sb, ["set", "high", "--hot-only"]); + assert.equal(r.code, 0, `${r.out}\n${r.err}`); + assert.match(r.out, /仅热/); + assert.equal(readHot(sb.db), "high"); + assert.equal(fs.readFileSync(sb.cfg, "utf8"), before, "仅热时配置必须原样"); + assert.equal(backups(sb).length, 0); + const st = readState(sb); + assert.equal(st.length, 1); + assert.equal(st[0].hotOnly, true); + assert.equal(st[0].hotLevel, "high"); +}); + +test("set --persist-only:只写模型配置,热档位不动", () => { + const sb = sandbox(); + writeConfig(sb, configFixture()); + makeDb(sb.db, "low"); + + const r = run(sb, ["set", "high", "--persist-only"]); + assert.equal(r.code, 0, `${r.out}\n${r.err}`); + assert.match(r.out, /仅持久/); + assert.equal(readHot(sb.db), "low", "仅持久时热档位必须原样"); + assert.equal(readConfig(sb).provider["builtin:bigmodel"].models["GLM-5.3"].reasoning.defaultVariant, "high"); + const st = readState(sb); + assert.equal(st[0].persistOnly, true); +}); + +test("set:两个分写开关同时用 → 用法报错,两边都不动", () => { + const sb = sandbox(); + writeConfig(sb, configFixture()); + makeDb(sb.db, "low"); + const before = fs.readFileSync(sb.cfg, "utf8"); + + const r = run(sb, ["set", "high", "--hot-only", "--persist-only"]); + assert.equal(r.code, 1); + assert.match(r.out, /不能同时用/); + assert.equal(fs.readFileSync(sb.cfg, "utf8"), before); + assert.equal(readHot(sb.db), "low"); +}); + +test("set:非法档位 ultra → 收紧校验拒绝,两边都不写", () => { + const sb = sandbox(); + writeConfig(sb, configFixture()); + makeDb(sb.db, "low"); + const before = fs.readFileSync(sb.cfg, "utf8"); + + const r = run(sb, ["set", "ultra"]); + assert.equal(r.code, 1); + assert.match(r.out, /非法档位 ultra/); + assert.equal(fs.readFileSync(sb.cfg, "utf8"), before); + assert.equal(readHot(sb.db), "low"); + assert.equal(backups(sb).length, 0); +}); + +test("history:列出切换记录;rollback:热+持久都写回去", () => { + const sb = sandbox(); + writeConfig(sb, configFixture()); + makeDb(sb.db, "low"); + + assert.equal(run(sb, ["set", "high"]).code, 0); + const h = run(sb, ["history"]); + assert.equal(h.code, 0, h.err); + assert.match(h.out, /## 思考模式切换历史/); + assert.match(h.out, /low → `high`/); + + const hj = JSON.parse(run(sb, ["history", "--json"]).out); + assert.equal(hj.count, 1); + assert.equal(hj.entries[0].hotLevel, "high"); + + const rb = run(sb, ["rollback"]); + assert.equal(rb.code, 0, `${rb.out}\n${rb.err}`); + assert.match(rb.out, /已回滚/); + assert.equal(readHot(sb.db), "low", "热档位应回到 low"); + assert.equal(readConfig(sb).provider["builtin:bigmodel"].models["GLM-5.3"].reasoning.defaultVariant, "max", + "持久默认应回到 fixture 的 max"); +}); + +test("rollback:无历史时明确报错退出 1", () => { + const sb = sandbox(); + writeConfig(sb, configFixture()); + makeDb(sb.db, "low"); + + const r = run(sb, ["rollback"]); + assert.equal(r.code, 1); + assert.match(r.out, /没有切换历史可回滚/); +});