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

This commit is contained in:
2026-09-21 09:47:20 +08:00
commit 6ed5dda847
41 changed files with 6213 additions and 0 deletions
+192
View File
@@ -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);