Files

193 lines
8.2 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 插件部署:工作区源码 → ~/.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);