/** * 插件部署:工作区源码 → ~/.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);