Compare commits

...
Author SHA1 Message Date
7a69d72886 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 <dtzp555@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 07:33:13 +10:00
a8601a6d30 feat(doctor): --check oauth fast path (#93)
* feat(doctor): --check oauth fast path

Implements the --check oauth fast path documented in cmd_doctor_help
but previously unimplemented. Skips version detection, from-version
check, git operations, and models endpoint — runs only the curl
against /health + auth.ok extraction.

Use cases:
- After `claude auth login`, fast verify OCP can spawn cli.js
- After a known service blip, quick health gate before larger ops
- AI agent's setup-repair loop: ./ocp doctor --check oauth in a
  retry-after-fix step

3 unit tests cover: PASS path, OAuth FAIL → fix_oauth, service down →
fix_service.

No cli.js citation needed: this is OCP-internal doctor logic with no
corresponding cli.js operation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(doctor): nit fixes for --check oauth (N3 body=null test + N4 skipped sentinel comment)

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 07:25:40 +10:00
7 changed files with 308 additions and 7 deletions
+23
View File
@@ -1,5 +1,28 @@
# Changelog # 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 ## v3.15.1 — 2026-05-10
### Fixes ### Fixes
+2
View File
@@ -709,6 +709,8 @@ Usage:
ocp update --rollback --list List available snapshots ocp update --rollback --list List available snapshots
ocp update --rollback <path> Restore a specific snapshot ocp update --rollback <path> Restore a specific snapshot
ocp update --rollback --dry-run Preview rollback plan 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 EOF
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "open-claude-proxy", "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.", "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", "type": "module",
"bin": { "bin": {
+86 -1
View File
@@ -37,6 +37,11 @@ export async function runDoctor(opts = {}) {
const push = (id, level, message, extra = {}) => const push = (id, level, message, extra = {}) =>
checks.push({ id, level, message, ...extra }); checks.push({ id, level, message, ...extra });
// --- fast path: --check oauth ---
if (opts.checkOnly === "oauth") {
return runOauthOnly(opts, checks, push);
}
// --- version detection --- // --- version detection ---
const ocpDir = opts.ocpDir || join(homedir(), "ocp"); const ocpDir = opts.ocpDir || join(homedir(), "ocp");
let currentVersion = opts.mockVersion; let currentVersion = opts.mockVersion;
@@ -190,6 +195,84 @@ export async function runDoctor(opts = {}) {
}; };
} }
function runOauthOnly(opts, checks, push) {
let healthOk = true, oauthOk = true;
let health;
if (opts.mockHealth !== undefined) {
health = opts.mockHealth;
} else {
try {
const port = process.env.CLAUDE_PROXY_PORT || "3478";
const out = execSync(`curl -sf --max-time 3 http://127.0.0.1:${port}/health`, { stdio: ["pipe", "pipe", "pipe"] }).toString();
health = { status: 200, body: JSON.parse(out) };
} catch (e) {
health = { error: String(e.message || e) };
}
}
if (health.error || health.status !== 200) {
healthOk = false;
push("oauth_ok", "FAIL", `service unreachable: ${health.error || `status ${health.status}`}`);
} else if (!health.body || typeof health.body !== "object") {
healthOk = false;
push("oauth_ok", "FAIL", "service /health returned 200 but empty/non-JSON body");
} else if (!health.body?.auth?.ok) {
oauthOk = false;
push("oauth_ok", "FAIL", `auth.ok=false: ${health.body?.auth?.message || "unknown"}`);
} else {
push("oauth_ok", "PASS", "OAuth token valid");
}
const kind = !healthOk ? "fix_service" : !oauthOk ? "fix_oauth" : "noop";
let next_action;
const ocpDir = opts.ocpDir || join(homedir(), "ocp");
if (kind === "noop") {
next_action = { kind, human_required: [], ai_executable: [], verify: "OAuth healthy" };
} else if (kind === "fix_oauth") {
next_action = {
kind,
human_required: [],
ai_executable: [
`cd "$(npm root -g)/@anthropic-ai/claude-code" && node install.cjs`,
`launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null || true`,
`launchctl bootstrap gui/$(id -u) ${join(homedir(), "Library", "LaunchAgents", "dev.ocp.proxy.plist")}`,
`${ocpDir}/ocp doctor --check oauth`
],
verify: "ocp doctor --check oauth expects PASS",
reference: "~/.cc-rules/memory/learnings/ocp_claude_native_binary_postinstall.md"
};
} else {
next_action = {
kind,
human_required: [],
ai_executable: [
`launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null || true`,
`launchctl bootstrap gui/$(id -u) ${join(homedir(), "Library", "LaunchAgents", "dev.ocp.proxy.plist")}`,
`${ocpDir}/ocp doctor --check oauth`
],
verify: "ocp doctor --check oauth expects service_running=PASS"
};
}
const fail_count = checks.filter(c => c.level === "FAIL").length;
// "skipped" = --check oauth fast path intentionally omits version detection.
// AI agents should NOT semver-compare against current_version/latest_version when
// either equals "skipped"; the full path provides those fields when needed.
return {
schema_version: SCHEMA_VERSION,
timestamp: new Date().toISOString(),
ready_to_upgrade: fail_count === 0,
current_version: opts.mockVersion || "skipped",
latest_version: opts.mockLatest || "skipped",
from_version_supported: true,
fail_count,
warn_count: 0,
checks,
next_action
};
}
// CLI entrypoint — use fileURLToPath + realpath to handle symlinked install paths // CLI entrypoint — use fileURLToPath + realpath to handle symlinked install paths
// (e.g. /tmp/ → /private/tmp/ on macOS would otherwise miss the guard). // (e.g. /tmp/ → /private/tmp/ on macOS would otherwise miss the guard).
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@@ -202,7 +285,9 @@ function _isMain() {
} }
if (_isMain()) { if (_isMain()) {
const wantJson = process.argv.includes("--json"); const wantJson = process.argv.includes("--json");
const result = await runDoctor(); const checkIdx = process.argv.indexOf("--check");
const checkOnly = checkIdx !== -1 ? process.argv[checkIdx + 1] : undefined;
const result = await runDoctor({ checkOnly });
if (wantJson) { if (wantJson) {
console.log(JSON.stringify(result, null, 2)); console.log(JSON.stringify(result, null, 2));
} else { } else {
+64 -1
View File
@@ -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"; import { join } from "node:path";
export function writeSnapshot({ homeDir, fromCommit, fromVersion, toVersion, extraFiles = [] }) { 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 })) .map(name => ({ name, path: join(root, name), mtime: statSync(join(root, name)).mtimeMs }))
.sort((a, b) => a.name.localeCompare(b.name)); .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;
}
+22 -2
View File
@@ -14,7 +14,7 @@ import { execSync } from "node:child_process";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { existsSync, copyFileSync } from "node:fs"; 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 = {}) { export async function runUpgrade(opts = {}) {
const dryRun = !!opts.dryRun; const dryRun = !!opts.dryRun;
@@ -154,6 +154,16 @@ async function runFullUpgrade({ doctor, opts }) {
phases.push({ name: "post-flight", status: "skipped-mock" }); 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 }; return { path: "upgrade", executed: true, changed: true, snapshotPath, phases };
} catch (err) { } catch (err) {
if (snapshotPath && !err.snapshotPath) { if (snapshotPath && !err.snapshotPath) {
@@ -193,6 +203,11 @@ async function runRollback(opts) {
const homeDir = opts.homeDir || homedir(); const homeDir = opts.homeDir || homedir();
const snapshots = opts.mockSnapshots ?? listSnapshots(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) { if (opts.list) {
return { path: "rollback-list", snapshots }; return { path: "rollback-list", snapshots };
} }
@@ -295,6 +310,7 @@ if (_isMain()) {
const yes = args.includes("--yes"); const yes = args.includes("--yes");
const rollback = args.includes("--rollback"); const rollback = args.includes("--rollback");
const list = args.includes("--list"); const list = args.includes("--list");
const gc = args.includes("--gc");
const targetIdx = args.indexOf("--target"); const targetIdx = args.indexOf("--target");
const target = targetIdx !== -1 ? args[targetIdx + 1] : undefined; const target = targetIdx !== -1 ? args[targetIdx + 1] : undefined;
// First non-flag positional after --rollback is the snapshot path // First non-flag positional after --rollback is the snapshot path
@@ -305,7 +321,7 @@ if (_isMain()) {
if (cand && !cand.startsWith("--")) snapshotPath = cand; if (cand && !cand.startsWith("--")) snapshotPath = cand;
} }
try { 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.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.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}`); 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:`); console.log(`Found ${result.snapshots.length} snapshots:`);
for (const s of result.snapshots) console.log(` ${s.name}`); 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); process.exit(0);
} catch (e) { } catch (e) {
console.error(`${e.message}`); console.error(`${e.message}`);
+110 -2
View File
@@ -719,8 +719,8 @@ test("upgrade full path executes 5 phases", async () => {
}); });
// ── Snapshot Tests ── // ── Snapshot Tests ──
import { writeSnapshot, readSnapshot, listSnapshots } from "./scripts/lib/snapshot.mjs"; import { writeSnapshot, readSnapshot, listSnapshots, gcSnapshots } from "./scripts/lib/snapshot.mjs";
import { mkdtempSync, rmSync, mkdirSync as tMkdirSync, writeFileSync as testWriteFile } from "node:fs"; import { mkdtempSync, rmSync, mkdirSync as tMkdirSync, writeFileSync as testWriteFile, existsSync as testExistsSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join as testJoin } from "node:path"; import { join as testJoin } from "node:path";
@@ -843,6 +843,114 @@ test("rollback latest snapshot restores files (mockExec)", async () => {
assert.ok(result.phases.some(p => p.name === "git-checkout")); 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:");
await asyncTest("doctor --check oauth runs only oauth check (skips version/from-version)", async () => {
const result = await runDoctor({
checkOnly: "oauth",
mockVersion: "v3.10.0",
mockLatest: "v3.14.0",
mockHealth: { status: 200, body: { auth: { ok: true, message: "authenticated" } } }
});
// Should still produce a valid result object
assert.equal(result.schema_version, "1");
// checks[] should only contain oauth_ok (no current_version, no from_version_supported)
const ids = result.checks.map(c => c.id);
assert.deepEqual(ids, ["oauth_ok"]);
assert.equal(result.next_action.kind, "noop");
});
await asyncTest("doctor --check oauth + OAuth FAIL → fix_oauth", async () => {
const result = await runDoctor({
checkOnly: "oauth",
mockHealth: { status: 200, body: { auth: { ok: false, message: "ENOEXEC" } } }
});
const ids = result.checks.map(c => c.id);
assert.deepEqual(ids, ["oauth_ok"]);
assert.equal(result.next_action.kind, "fix_oauth");
assert.equal(result.fail_count, 1);
});
await asyncTest("doctor --check oauth + service down → fix_service", async () => {
const result = await runDoctor({
checkOnly: "oauth",
mockHealth: { error: "ECONNREFUSED" }
});
const ids = result.checks.map(c => c.id);
assert.deepEqual(ids, ["oauth_ok"]);
assert.equal(result.next_action.kind, "fix_service");
assert.equal(result.fail_count, 1);
});
await asyncTest("doctor --check oauth + 200 with null body → fix_service", async () => {
const result = await runDoctor({
checkOnly: "oauth",
mockHealth: { status: 200, body: null }
});
const ids = result.checks.map(c => c.id);
assert.deepEqual(ids, ["oauth_ok"]);
assert.equal(result.next_action.kind, "fix_service");
assert.equal(result.fail_count, 1);
});
// ── Cleanup ── // ── Cleanup ──
closeDb(); closeDb();