From 7a69d72886412cb0c3ea48107768d7a9f0575175 Mon Sep 17 00:00:00 2001 From: dtzp555-max Date: Mon, 11 May 2026 07:33:13 +1000 Subject: [PATCH] feat(snapshot): gcSnapshots + ocp update --rollback --gc + auto-GC; v3.16.0 (#94) Adds snapshot garbage collection with retention policy: keep last 5, keep snapshots within 30 days, always keep the most recent. Configurable via keepCount / keepDays opts. Wire-up: - ocp update --rollback --gc (manual trigger; --dry-run supported) - runFullUpgrade auto-GC after successful upgrade (best-effort, swallows errors) 4 unit tests: keepCount enforcement, keepDays override, never-delete- most-recent safety, dry-run mode. Bumps to v3.16.0 (bundles PR #93 --check oauth + this GC feature). No cli.js citation needed: this is OCP-internal snapshot lifecycle with no corresponding cli.js operation. Co-authored-by: dtzp555 Co-authored-by: Claude Sonnet 4.6 --- CHANGELOG.md | 23 ++++++++++++++ ocp | 2 ++ package.json | 2 +- scripts/lib/snapshot.mjs | 65 +++++++++++++++++++++++++++++++++++++++- scripts/upgrade.mjs | 24 +++++++++++++-- test-features.mjs | 61 +++++++++++++++++++++++++++++++++++-- 6 files changed, 171 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 946243a..d7d7fb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## v3.16.0 — 2026-XX-XX (release date filled at tag time) + +### Features + +- **`ocp doctor --check oauth`** (PR #93) — fast path that runs only the OAuth check, skipping + version detection / from-version / git operations / models endpoint. ~50ms vs. full doctor's + ~200-500ms. Use cases: AI agent repair loops, post-`claude auth login` verify, quick health + gates. Help text in `cmd_doctor_help` now reflects working behaviour. +- **`ocp update --rollback --gc`** — manually garbage-collect old upgrade snapshots. + Retention policy: keep last 5 snapshots OR snapshots newer than 30 days OR the single most + recent (always-keep safety net). `--dry-run` previews. Successful `ocp update` runs auto-GC + at the end of the full path; light path does not (no snapshot created there). + +### Behavior changes + +- After a successful cross-minor `ocp update`, the auto-GC emits `[gc] removed N old snapshots` + to stderr if any were collected. Safe to ignore; manual gc is `ocp update --rollback --gc`. + +### Governance + +- No `cli.js` citation needed (no `server.mjs` change). ALIGNMENT.md Rule 2 not engaged. +- PR #93 (--check oauth) merged separately; this release bundles it with the GC feature. + ## v3.15.1 — 2026-05-10 ### Fixes diff --git a/ocp b/ocp index 46cf8fb..dbf4f48 100755 --- a/ocp +++ b/ocp @@ -709,6 +709,8 @@ Usage: ocp update --rollback --list List available snapshots ocp update --rollback Restore a specific snapshot ocp update --rollback --dry-run Preview rollback plan + ocp update --rollback --gc Delete old snapshots (keep last 5, or <30 days) + ocp update --rollback --gc --dry-run Preview what would be deleted EOF } diff --git a/package.json b/package.json index fe69e2a..6f01a6b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-claude-proxy", - "version": "3.15.1", + "version": "3.16.0", "description": "OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.", "type": "module", "bin": { diff --git a/scripts/lib/snapshot.mjs b/scripts/lib/snapshot.mjs index a338fd4..2630192 100644 --- a/scripts/lib/snapshot.mjs +++ b/scripts/lib/snapshot.mjs @@ -1,4 +1,4 @@ -import { mkdirSync, writeFileSync, readFileSync, copyFileSync, existsSync, readdirSync, statSync } from "node:fs"; +import { mkdirSync, writeFileSync, readFileSync, copyFileSync, existsSync, readdirSync, statSync, rmSync } from "node:fs"; import { join } from "node:path"; export function writeSnapshot({ homeDir, fromCommit, fromVersion, toVersion, extraFiles = [] }) { @@ -50,3 +50,66 @@ export function listSnapshots(homeDir) { .map(name => ({ name, path: join(root, name), mtime: statSync(join(root, name)).mtimeMs })) .sort((a, b) => a.name.localeCompare(b.name)); } + +/** + * Garbage-collect old upgrade snapshots. + * + * Retention rule (a snapshot is KEPT if any of these is true): + * - It is among the last `keepCount` snapshots (sorted oldest→newest) + * - Its timestamp is within `keepDays` of `now` + * - It is the single most-recent snapshot (always-keep safety net) + * + * @param {string} homeDir - Root containing ~/.ocp/ + * @param {object} opts + * @param {number} [opts.keepCount=5] - Minimum count to keep + * @param {number} [opts.keepDays=30] - Keep snapshots newer than N days + * @param {boolean} [opts.dryRun=false] - If true, report plan but don't delete + * @param {Date} [opts.now=new Date()] - Override clock for testing + * @returns {{kept: Array, removed: Array, dryRun: boolean}} + */ +export function gcSnapshots(homeDir, opts = {}) { + const keepCount = opts.keepCount ?? 5; + const keepDays = opts.keepDays ?? 30; + const dryRun = !!opts.dryRun; + const now = opts.now || new Date(); + + const all = listSnapshots(homeDir); // sorted oldest→newest + if (all.length === 0) return { kept: [], removed: [], dryRun }; + if (all.length === 1) return { kept: all, removed: [], dryRun }; // always keep most recent + + const cutoffMs = now.getTime() - keepDays * 24 * 60 * 60 * 1000; + const lastN = new Set(all.slice(-keepCount).map(s => s.path)); + + const kept = [], removed = []; + for (let i = 0; i < all.length; i++) { + const s = all[i]; + const isMostRecent = i === all.length - 1; + const isInLastN = lastN.has(s.path); + const isWithinDays = parseSnapshotTimestamp(s.name) >= cutoffMs; + if (isMostRecent || isInLastN || isWithinDays) { + kept.push(s); + } else { + removed.push(s); + } + } + + if (!dryRun) { + for (const s of removed) { + try { + rmSync(s.path, { recursive: true, force: true }); + } catch (err) { + console.error(`[snapshot] warn: could not remove ${s.path} (${err.code || err.message})`); + } + } + } + + return { kept, removed, dryRun }; +} + +function parseSnapshotTimestamp(name) { + // upgrade-snapshot-2026-05-11T08:30:00Z → epoch ms + const m = name.match(/upgrade-snapshot-(.+)$/); + if (!m) return 0; + const t = Date.parse(m[1]); + return Number.isFinite(t) ? t : 0; +} diff --git a/scripts/upgrade.mjs b/scripts/upgrade.mjs index bc321a9..7213509 100644 --- a/scripts/upgrade.mjs +++ b/scripts/upgrade.mjs @@ -14,7 +14,7 @@ import { execSync } from "node:child_process"; import { homedir } from "node:os"; import { join } from "node:path"; import { existsSync, copyFileSync } from "node:fs"; -import { writeSnapshot, listSnapshots, readSnapshot } from "./lib/snapshot.mjs"; +import { writeSnapshot, listSnapshots, readSnapshot, gcSnapshots } from "./lib/snapshot.mjs"; export async function runUpgrade(opts = {}) { const dryRun = !!opts.dryRun; @@ -154,6 +154,16 @@ async function runFullUpgrade({ doctor, opts }) { phases.push({ name: "post-flight", status: "skipped-mock" }); } + // Auto-GC old snapshots after successful upgrade (best-effort, never throws). + try { + const gc = gcSnapshots(homedir(), { keepCount: 5, keepDays: 30 }); + if (gc.removed.length > 0) { + console.error(`[gc] removed ${gc.removed.length} old snapshots; kept ${gc.kept.length}`); + } + } catch (e) { + console.error(`[gc] warn: snapshot GC failed: ${e.message}`); + } + return { path: "upgrade", executed: true, changed: true, snapshotPath, phases }; } catch (err) { if (snapshotPath && !err.snapshotPath) { @@ -193,6 +203,11 @@ async function runRollback(opts) { const homeDir = opts.homeDir || homedir(); const snapshots = opts.mockSnapshots ?? listSnapshots(homeDir); + if (opts.gc) { + const result = gcSnapshots(homeDir, { dryRun: opts.dryRun }); + return { path: opts.dryRun ? "rollback-gc-dry-run" : "rollback-gc", ...result }; + } + if (opts.list) { return { path: "rollback-list", snapshots }; } @@ -295,6 +310,7 @@ if (_isMain()) { const yes = args.includes("--yes"); const rollback = args.includes("--rollback"); const list = args.includes("--list"); + const gc = args.includes("--gc"); const targetIdx = args.indexOf("--target"); const target = targetIdx !== -1 ? args[targetIdx + 1] : undefined; // First non-flag positional after --rollback is the snapshot path @@ -305,7 +321,7 @@ if (_isMain()) { if (cand && !cand.startsWith("--")) snapshotPath = cand; } try { - const result = await runUpgrade({ dryRun, yes, rollback, list, snapshotPath, target }); + const result = await runUpgrade({ dryRun, yes, rollback, list, gc, snapshotPath, target }); if (result.plan) for (const line of result.plan) console.log(line); if (result.phases) for (const p of result.phases) console.log(`[${p.name}] ${p.status}${p.cmd ? `: ${p.cmd}` : ""}`); if (result.steps) for (const s of result.steps) console.log(` ${s.status === "ok" ? "✓" : s.status === "skipped-mock" ? "·" : "✗"} ${s.cmd}`); @@ -313,6 +329,10 @@ if (_isMain()) { console.log(`Found ${result.snapshots.length} snapshots:`); for (const s of result.snapshots) console.log(` ${s.name}`); } + if (result.removed && result.kept) { + console.log(`Snapshots: kept ${result.kept.length}, ${result.dryRun ? "would remove" : "removed"} ${result.removed.length}`); + for (const s of result.removed) console.log(` - ${s.name}`); + } process.exit(0); } catch (e) { console.error(`✗ ${e.message}`); diff --git a/test-features.mjs b/test-features.mjs index d7fa038..0664851 100644 --- a/test-features.mjs +++ b/test-features.mjs @@ -719,8 +719,8 @@ test("upgrade full path executes 5 phases", async () => { }); // ── Snapshot Tests ── -import { writeSnapshot, readSnapshot, listSnapshots } from "./scripts/lib/snapshot.mjs"; -import { mkdtempSync, rmSync, mkdirSync as tMkdirSync, writeFileSync as testWriteFile } from "node:fs"; +import { writeSnapshot, readSnapshot, listSnapshots, gcSnapshots } from "./scripts/lib/snapshot.mjs"; +import { mkdtempSync, rmSync, mkdirSync as tMkdirSync, writeFileSync as testWriteFile, existsSync as testExistsSync } from "node:fs"; import { tmpdir } from "node:os"; import { join as testJoin } from "node:path"; @@ -843,6 +843,63 @@ test("rollback latest snapshot restores files (mockExec)", async () => { assert.ok(result.phases.some(p => p.name === "git-checkout")); }); +test("gcSnapshots keeps last N regardless of age", () => { + const root = mkdtempSync(testJoin(tmpdir(), "ocp-gc-test-")); + const dotOcp = testJoin(root, ".ocp"); + tMkdirSync(dotOcp, { recursive: true }); + for (const ts of ["2026-04-01T10:00:00Z", "2026-04-15T10:00:00Z", "2026-04-30T10:00:00Z", "2026-05-01T10:00:00Z", "2026-05-10T10:00:00Z"]) { + tMkdirSync(testJoin(dotOcp, `upgrade-snapshot-${ts}`)); + } + const result = gcSnapshots(root, { keepCount: 3, keepDays: 0, now: new Date("2026-05-11T00:00:00Z") }); + assert.equal(result.kept.length, 3); + assert.equal(result.removed.length, 2); + assert.ok(result.kept[0].name.includes("2026-04-30")); + assert.ok(result.kept[2].name.includes("2026-05-10")); + rmSync(root, { recursive: true, force: true }); +}); + +test("gcSnapshots keeps snapshots newer than keepDays regardless of count", () => { + const root = mkdtempSync(testJoin(tmpdir(), "ocp-gc-days-")); + const dotOcp = testJoin(root, ".ocp"); + tMkdirSync(dotOcp, { recursive: true }); + for (const ts of ["2026-04-01T10:00:00Z", "2026-04-15T10:00:00Z", "2026-04-30T10:00:00Z", "2026-05-01T10:00:00Z", "2026-05-10T10:00:00Z"]) { + tMkdirSync(testJoin(dotOcp, `upgrade-snapshot-${ts}`)); + } + // keepCount=1 but keepDays=15 means anything from after 2026-04-26 is kept too + const result = gcSnapshots(root, { keepCount: 1, keepDays: 15, now: new Date("2026-05-11T00:00:00Z") }); + // Kept: 2026-04-30 (within 15 days), 2026-05-01 (within 15 days), 2026-05-10 (within 15 days) + assert.ok(result.kept.length >= 3); + // Removed: 2026-04-01, 2026-04-15 + assert.ok(result.removed.some(s => s.name.includes("2026-04-01"))); +}); + +test("gcSnapshots never deletes the most recent snapshot", () => { + const root = mkdtempSync(testJoin(tmpdir(), "ocp-gc-recent-")); + const dotOcp = testJoin(root, ".ocp"); + tMkdirSync(dotOcp, { recursive: true }); + tMkdirSync(testJoin(dotOcp, "upgrade-snapshot-2026-01-01T10:00:00Z")); + // Even with keepCount=0 and keepDays=0, the most recent must survive + const result = gcSnapshots(root, { keepCount: 0, keepDays: 0, now: new Date("2026-05-11T00:00:00Z") }); + assert.equal(result.kept.length, 1); + assert.equal(result.removed.length, 0); + rmSync(root, { recursive: true, force: true }); +}); + +test("gcSnapshots --dry-run reports plan without deleting", () => { + const root = mkdtempSync(testJoin(tmpdir(), "ocp-gc-dryrun-")); + const dotOcp = testJoin(root, ".ocp"); + tMkdirSync(dotOcp, { recursive: true }); + for (const ts of ["2026-04-01T10:00:00Z", "2026-04-15T10:00:00Z", "2026-05-10T10:00:00Z"]) { + tMkdirSync(testJoin(dotOcp, `upgrade-snapshot-${ts}`)); + } + const result = gcSnapshots(root, { keepCount: 1, keepDays: 0, dryRun: true, now: new Date("2026-05-11T00:00:00Z") }); + assert.equal(result.dryRun, true); + assert.equal(result.removed.length, 2); + // Files still exist + assert.ok(testExistsSync(testJoin(dotOcp, "upgrade-snapshot-2026-04-01T10:00:00Z"))); + rmSync(root, { recursive: true, force: true }); +}); + // ── Doctor --check oauth fast path tests ── console.log("\nDoctor --check oauth:");