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