Compare commits

..
Author SHA1 Message Date
taodengandClaude Opus 4.7 ea86f2a304 fix(scripts): CLI entrypoint guard resilient to symlinked install paths
Bug found via integration test on MacBook Pro (macOS /tmp → /private/tmp):
`import.meta.url === \`file://\${process.argv[1]}\`` evaluates false when
the install path traverses a symlink, because import.meta.url is canonicalised
but process.argv[1] is not. Result: ./ocp doctor (and ./ocp update via
upgrade.mjs) exit silently with code 0 and no output, instead of running.

Fix: use fileURLToPath + realpathSync on both sides of the comparison.
Affects any install at a symlinked path (/tmp, NFS mounts, /var/ paths,
docker bind mounts, etc.). Normal ~/ocp installs were unaffected.

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 06:54:12 +10:00
taodengandClaude Opus 4.7 2a3376d237 docs(readme): show --yes in rollback usage examples
Per Iron Rule 10 reviewer nit on PR #91: live rollback requires --yes
even for interactive humans. Update §Upgrading examples to show the
canonical human form. (AI agents pass --yes by convention; humans were
hitting a confusing "requires --yes" error following the prior README.)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 04:18:30 +10:00
taodengandClaude Sonnet 4.6 95d70d5865 docs(release): v3.15.0 — README AI prompt blocks + Upgrading rewrite + CHANGELOG
§Installation, §Upgrading, §Troubleshooting each start with a copy-paste
AI prompt block for Claude Code / Cursor / Copilot. The Upgrading section
explains the three paths (light / full / fresh-install) and rollback usage.

All Commands table gains an `ocp doctor` row.

package.json bumped to 3.15.0.

CHANGELOG.md gains the v3.15.0 entry covering doctor, the cross-version
update path, --rollback, fresh-install routing, and AI prompt blocks.
Notes the dependency on PR #90 (plist env merge bug fix, already merged).

No cli.js citation needed: docs + version bump only, no server.mjs change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 04:11:55 +10:00
taodengandClaude Opus 4.7 2105ae8779 fix(ocp): forward all args to cmd_update so multi-flag invocations work
Bug found via runtime smoke test:
  ./ocp update --rollback --list → "no snapshots" (wrong; should list)

Root cause: dispatch was `cmd_update "\${1:-}"` (only first arg). When
user typed `--rollback --list`, cmd_update only received `--rollback`,
the shift left $@ empty, and exec node ... --rollback got no flags.
Other commands using "\${1:-}" don't need multi-arg, but cmd_update now
does (--rollback --list, --rollback --dry-run, --target X --yes, etc.).

Change: dispatch is now `cmd_update "\$@"`. cmd_update internals already
handle multi-arg correctly (\$1 == --check fast path; \$1 == --rollback
shift+forward; otherwise doctor-driven).

Verified:
  ./ocp update --check         → existing behaviour preserved
  ./ocp update --rollback --list → "Found 0 snapshots:" exit 0
  ./ocp update --rollback --dry-run → no-snapshot error exit 1

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 04:08:37 +10:00
taodengandClaude Sonnet 4.6 42fcf88fa5 refactor(ocp): cmd_update dispatches via doctor; --rollback added; light path preserved
cmd_update now calls scripts/doctor.mjs to determine which path to take:
  noop          → "already at latest" exit 0
  update        → existing light path (git pull + npm install + restart),
                  extracted into _cmd_update_light helper to keep the daily
                  case fast and shell-only
  upgrade       → exec node scripts/upgrade.mjs (full path with snapshot
                  + post-flight)
  fresh_install → exec node scripts/upgrade.mjs (gated by --yes)
  fix_oauth/fix_service → print error referring user to `ocp doctor`

cmd_update --rollback path: exec node scripts/upgrade.mjs --rollback "$@"
forwards remaining args (--list, --dry-run, optional snapshot path).

cmd_update_help expanded to document new flags.

cmd_update --check fast path is preserved exactly (no doctor call there).

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 04:06:42 +10:00
taodengandClaude Opus 4.7 6ef6b7c9d8 chore(upgrade): nit fixes from Bundle 3 code-quality review
3 micro-fixes on 48e9408:
1. Remove unused mkdirSync import
2. snapshot-not-found error message hints "must be inside ~/.ocp/upgrade-snapshot-*"
3. runFreshInstall failure now includes e.stderr (or e.message fallback) in the
   thrown error and steps[].error so non-interactive callers see the actual reason

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 04:04:29 +10:00
taodengandClaude Sonnet 4.6 48e9408def feat(upgrade): fresh-install + rollback paths
Implements the two missing branches of runUpgrade dispatcher:

- runFreshInstall: gated by --yes, runs doctor.next_action.ai_executable
  steps in order, fails fast on first error, attaches steps[] to thrown
  errors. Accepts mockExec for unit tests.
- runRollback: locates latest or named snapshot in ~/.ocp/, reads
  from-commit.txt, restores plist + db + admin-key + service file (with
  per-file warn lines on copy failure), git-checkouts the from-commit,
  npm installs at that revision, restarts the service. --list shows all
  snapshots; --dry-run prints the plan without mutation.

Both paths use the same exec() error-wrap pattern as runFullUpgrade
(stderr capture, phases attached to thrown errors, restart heads-up).

CLI entrypoint extended to parse --rollback / --list / --target / and
optional positional snapshot path after --rollback.

6 unit tests cover: --yes gate, fresh_install ai_executable run,
--rollback --list, no-snapshots error, --rollback --dry-run, mock-exec
restore.

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 04:02:30 +10:00
taodengandClaude Sonnet 4.6 7959f25801 fix(upgrade): error path completeness + observability
5 issues raised by code-quality reviewer on c12013a:

A. exec() wrapper now captures stderr from execSync failures and
   re-throws with `phase X failed: <stderr>` instead of the terse
   "Command failed: ..." default. Operators see the actual git/npm
   error.
B. runFullUpgrade body wrapped in try/catch; any error after phase 2
   (snapshot written) carries snapshotPath + phases + hint pointing
   at `ocp update --rollback`. Aligns with the post-flight failure
   pattern.
C. CLI entrypoint now prints snapshotPath + hint on error.

Plus minor:
- snapshot.mjs tryCopy logs a [snapshot] warn line instead of silently
  swallowing copy errors (e.g. permission-denied admin-key)
- heads-up window 1s → 3s, more operable per the policy intent
- opts.yes intent comment added (Bundle 3 will use)

One regression test added.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 03:58:32 +10:00
taodengandClaude Sonnet 4.6 c12013a094 feat(upgrade): add scripts/upgrade.mjs + scripts/lib/snapshot.mjs
Implements the upgrade dispatcher (noop / dry-run / light delegation /
full path) and the snapshot writer/reader/list module. Full path snapshots
plist + db + admin-key + openclaw.json before mutating, runs the 6 phases
(pre-flight, snapshot, fetch+install, reconfigure, restart, post-flight),
and emits a heads-up before launchctl bootout per
notify_before_prod_service_restart.md policy.

mockExec/mockDoctor injection points let tests verify the phase ordering
without touching the real shell. fresh_install + rollback paths are
deferred to Bundle 3.

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 03:54:15 +10:00
taodengandClaude Sonnet 4.6 c4e5374046 fix(doctor): handle unparseable version + empty health body
Three issues raised by code-quality reviewer on b65201b:

1. semverCompare returned 0 for unparseable input, causing fromSupported=true
   and kind=noop for an install with unreadable package.json. Now treats
   unparseable currentVersion as fresh_install candidate.
2. mockHealth: { status: 200, body: null } routed to fix_oauth (because
   health.body?.auth?.ok was undefined → falsy). 200 with empty body is
   server-broken, not OAuth-broken; now routes to fix_service.
3. Removed unused KIND_ENUM declaration (dead code).

Two regression tests added.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 03:51:03 +10:00
taodengandClaude Sonnet 4.6 414b473eb7 feat(ocp): wire cmd_doctor into bash CLI; dispatch to scripts/doctor.mjs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 03:46:11 +10:00
taodengandClaude Sonnet 4.6 b65201b395 feat(doctor): add ocp doctor with --json + next_action contract
Implements scripts/doctor.mjs with semver-aware path selection
(noop/update/upgrade/fresh_install/fix_oauth/fix_service) and the JSON
contract documented in the design spec.

Service health + OAuth checks integrated; mockable via opts.mockHealth
for unit tests. 8 unit tests cover the kind dispatch tree and the
next_action shape for each kind.

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 03:45:34 +10:00
10 changed files with 15 additions and 389 deletions
+1 -62
View File
@@ -1,67 +1,6 @@
# Changelog
## v3.16.2 — 2026-05-12
### Fixes — corrects v3.16.1
The v3.16.1 fix was directionally correct (plugin now reads env first, falls back to a hardcoded default) but **the narrative and the hardcoded default were both wrong**.
What v3.16.1 said: "OCP server moved to 3478 default in v3.14+; plugin lagged at 3456."
What is actually true:
- **OCP server source default has been `3456` since `593d0dc` (initial release) and has never changed.** Every line in `server.mjs`, `setup.mjs`, and the `ocp` CLI still uses `3456` as the documented and code-level default.
- The single OCP installation observed on `3478` is the maintainer's Mac mini, whose plist was rewritten with `--port 3478` during a PR #71 dogfood smoke-test accident on 2026-05-08 (see `~/.cc-rules/memory/learnings/subagent_setup_mjs_prod_host_collision.md`). The plist drift was never reconciled back to source default, and v3.16.1 incorrectly canonised the post-accident value as if it had been a release decision.
This release:
- Restores the plugin fallback to `http://127.0.0.1:3456` to match server source default.
- Updates `openclaw.plugin.json` `configSchema.proxyUrl.default` back to `3456`.
- Restores README §"Environment Variables" `CLAUDE_PROXY_PORT` default to `3456`.
- Plugin reads `OCP_PROXY_URL` env (full URL) first, then `CLAUDE_PROXY_PORT` env (port only), then falls back to `3456`. Hosts whose OCP plist injects a non-default port must also inject the same `CLAUDE_PROXY_PORT` into the OpenClaw plist for the plugin to follow.
- Maintainer's Mac mini plist was reverted from `3478` to `3456` as part of this release deploy (no source change reflects this; it was a one-host correction).
### Governance
- No `cli.js` citation needed (no `server.mjs` change). ALIGNMENT.md Rule 2 not engaged.
## v3.16.1 — 2026-05-12 (superseded — narrative incorrect; see v3.16.2 erratum)
### Fixes (as shipped — note erratum above)
- **OCP plugin port lag** — `ocp-plugin/index.js` hard-coded `http://127.0.0.1:3456`. ~~While OCP server moved to 3478 in v3.14+,~~ **(corrected v3.16.2: no such move ever happened.)** The Mac mini's plist was on `3478` only as residue from a dogfood accident. Result: `/ocp` slash commands from the home Telegram bot returned "OCP error: fetch failed". v3.16.1 changed the plugin default to `3478` (wrong direction; v3.16.2 reverts to `3456`).
### Governance
- No `cli.js` citation needed (no `server.mjs` change). ALIGNMENT.md Rule 2 not engaged.
## v3.16.0 — 2026-05-10
### 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
- **doctor: dynamic `latest_version` from `origin/main:package.json`** — v3.15.0 doctor used a hard-coded `latest = "v3.14.0"` fallback, which made any v3.15.0+ install report `kind = upgrade` (against a stale value). `ocp update` would then attempt `git checkout v3.14.0` — a downgrade. Doctor now fetches `git -C ~/ocp show origin/main:package.json` to determine the actual latest version; on failure (offline, fresh clone with no remote), falls back to `currentVersion` so `kind = noop` instead of recommending a downgrade.
## v3.15.0 — 2026-05-10
## v3.15.0 — 2026-XX-XX (release date filled at tag time)
### Features
+1 -2
View File
@@ -855,8 +855,7 @@ Future `ocp update` invocations sync automatically.
| Variable | Default | Description |
|----------|---------|-------------|
| `CLAUDE_PROXY_PORT` | `3456` | Listen port (server-side). Also consumed by the OpenClaw `ocp-plugin` to dial the local proxy. |
| `OCP_PROXY_URL` | *(unset)* | Plugin-side full URL override (e.g. `http://10.0.0.5:3456`). Wins over `CLAUDE_PROXY_PORT` when both are set. Read by `ocp-plugin/index.js` only — server ignores it. |
| `CLAUDE_PROXY_PORT` | `3456` | Listen port |
| `CLAUDE_BIND` | `127.0.0.1` | Bind address (`0.0.0.0` for LAN access) |
| `CLAUDE_AUTH_MODE` | `none` | Auth mode: `none`, `shared`, or `multi` |
| `OCP_ADMIN_KEY` | *(unset)* | Admin key for key management (multi mode) |
-2
View File
@@ -709,8 +709,6 @@ Usage:
ocp update --rollback --list List available snapshots
ocp update --rollback <path> 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
}
+3 -13
View File
@@ -1,19 +1,9 @@
/**
* OCP Plugin — registers /ocp as a native slash command in OpenClaw gateway.
* Calls the local claude-proxy and formats the response.
*
* Port resolution (in priority order):
* 1. OCP_PROXY_URL env (full URL, e.g. http://10.0.0.5:3456)
* 2. CLAUDE_PROXY_PORT env (port only; localhost assumed)
* 3. Fallback: http://127.0.0.1:3456 (OCP server source default since v1.0)
*
* If a particular host's OCP plist injects a non-default CLAUDE_PROXY_PORT,
* the OpenClaw launchd plist for that host must also inject the same
* CLAUDE_PROXY_PORT into the plugin's env, or the plugin will fall back to
* 3456 and miss the server.
* Calls the local claude-proxy at http://127.0.0.1:3456 and formats the response.
*/
const PROXY = process.env.OCP_PROXY_URL
|| (process.env.CLAUDE_PROXY_PORT ? `http://127.0.0.1:${process.env.CLAUDE_PROXY_PORT}` : "http://127.0.0.1:3456");
const PROXY = "http://127.0.0.1:3456";
// Wrap output in monospace code block for Telegram/Discord alignment
function mono(text) { return "```\n" + text + "\n```"; }
+2 -2
View File
@@ -2,7 +2,7 @@
"id": "ocp",
"name": "OCP Commands",
"description": "Slash commands for the OpenClaw Proxy — /ocp usage, /ocp settings, /ocp health, etc.",
"version": "3.16.2",
"version": "3.12.0",
"configSchema": {
"type": "object",
"additionalProperties": false,
@@ -10,7 +10,7 @@
"proxyUrl": {
"type": "string",
"default": "http://127.0.0.1:3456",
"description": "URL of the Claude proxy. Overridable via OCP_PROXY_URL or CLAUDE_PROXY_PORT env."
"description": "URL of the Claude proxy"
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "open-claude-proxy",
"version": "3.16.2",
"version": "3.15.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": {
+2 -99
View File
@@ -37,11 +37,6 @@ export async function runDoctor(opts = {}) {
const 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 ---
const ocpDir = opts.ocpDir || join(homedir(), "ocp");
let currentVersion = opts.mockVersion;
@@ -53,19 +48,7 @@ export async function runDoctor(opts = {}) {
currentVersion = "unknown";
}
}
// Resolve latest from origin/main (cheap: `git show origin/main:package.json`).
// Falls back to current_version when network/git unavailable, so kind = noop instead
// of recommending a downgrade against a stale hardcoded value.
let latestVersion = opts.mockLatest;
if (!latestVersion) {
try {
const out = execSync(`git -C ${ocpDir} show origin/main:package.json 2>/dev/null`, { stdio: ["pipe", "pipe", "pipe"] }).toString();
const remotePkg = JSON.parse(out);
latestVersion = `v${remotePkg.version}`;
} catch {
latestVersion = currentVersion;
}
}
const latestVersion = opts.mockLatest || "v3.14.0";
push("current_version", "PASS", `current=${currentVersion}`);
// --- from-version supported? ---
@@ -195,84 +178,6 @@ 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
// (e.g. /tmp/ → /private/tmp/ on macOS would otherwise miss the guard).
import { fileURLToPath } from "node:url";
@@ -285,9 +190,7 @@ function _isMain() {
}
if (_isMain()) {
const wantJson = process.argv.includes("--json");
const checkIdx = process.argv.indexOf("--check");
const checkOnly = checkIdx !== -1 ? process.argv[checkIdx + 1] : undefined;
const result = await runDoctor({ checkOnly });
const result = await runDoctor();
if (wantJson) {
console.log(JSON.stringify(result, null, 2));
} else {
+1 -64
View File
@@ -1,4 +1,4 @@
import { mkdirSync, writeFileSync, readFileSync, copyFileSync, existsSync, readdirSync, statSync, rmSync } from "node:fs";
import { mkdirSync, writeFileSync, readFileSync, copyFileSync, existsSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
export function writeSnapshot({ homeDir, fromCommit, fromVersion, toVersion, extraFiles = [] }) {
@@ -50,66 +50,3 @@ 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;
}
+2 -22
View File
@@ -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, gcSnapshots } from "./lib/snapshot.mjs";
import { writeSnapshot, listSnapshots, readSnapshot } from "./lib/snapshot.mjs";
export async function runUpgrade(opts = {}) {
const dryRun = !!opts.dryRun;
@@ -154,16 +154,6 @@ 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) {
@@ -203,11 +193,6 @@ 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 };
}
@@ -310,7 +295,6 @@ 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
@@ -321,7 +305,7 @@ if (_isMain()) {
if (cand && !cand.startsWith("--")) snapshotPath = cand;
}
try {
const result = await runUpgrade({ dryRun, yes, rollback, list, gc, snapshotPath, target });
const result = await runUpgrade({ dryRun, yes, rollback, list, 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}`);
@@ -329,10 +313,6 @@ 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}`);
+2 -122
View File
@@ -655,18 +655,6 @@ test("doctor empty health body → fix_service (not fix_oauth)", async () => {
assert.equal(result.next_action.kind, "fix_service");
});
test("doctor falls back to currentVersion when origin/main unreachable (no stale latest)", async () => {
// Use a non-existent ocpDir so git command fails; without the fix this would still
// hard-code v3.14.0 as latest and recommend a downgrade for a future v3.15.0+ user.
const result = await runDoctor({
skipNetwork: true,
mockVersion: "v3.15.0",
ocpDir: "/nonexistent-ocp-dir-for-test"
});
assert.equal(result.latest_version, "v3.15.0");
assert.equal(result.next_action.kind, "noop");
});
// ── Upgrade Tests ──
import { runUpgrade } from "./scripts/upgrade.mjs";
@@ -719,8 +707,8 @@ test("upgrade full path executes 5 phases", async () => {
});
// ── Snapshot Tests ──
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 { writeSnapshot, readSnapshot, listSnapshots } from "./scripts/lib/snapshot.mjs";
import { mkdtempSync, rmSync, mkdirSync as tMkdirSync, writeFileSync as testWriteFile } from "node:fs";
import { tmpdir } from "node:os";
import { join as testJoin } from "node:path";
@@ -843,114 +831,6 @@ 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:");
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 ──
closeDb();