#!/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); }