129 lines
5.2 KiB
JavaScript
129 lines
5.2 KiB
JavaScript
#!/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); }
|