From ab03c133326b33b700081df7590c791fc7053d2c Mon Sep 17 00:00:00 2001 From: dtzp555-max Date: Mon, 11 May 2026 06:56:17 +1000 Subject: [PATCH] feat(upgrade): ocp doctor + cross-version ocp update + rollback + AI prompts (#91) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * feat(ocp): wire cmd_doctor into bash CLI; dispatch to scripts/doctor.mjs Co-Authored-By: Claude Sonnet 4.6 * 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 * 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 * 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: ` 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 --------- Co-authored-by: dtzp555 Co-authored-by: Claude Sonnet 4.6 --- CHANGELOG.md | 33 ++++ README.md | 84 +++++++++- ocp | 116 +++++++++----- package.json | 2 +- scripts/doctor.mjs | 203 ++++++++++++++++++++++++ scripts/lib/snapshot.mjs | 52 +++++++ scripts/upgrade.mjs | 324 +++++++++++++++++++++++++++++++++++++++ test-features.mjs | 257 +++++++++++++++++++++++++++++++ 8 files changed, 1026 insertions(+), 45 deletions(-) create mode 100644 scripts/doctor.mjs create mode 100644 scripts/lib/snapshot.mjs create mode 100644 scripts/upgrade.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3afcc88..1ea0093 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,38 @@ # Changelog +## v3.15.0 — 2026-XX-XX (release date filled at tag time) + +### Features + +- **`ocp doctor`** — health & upgrade-readiness check; primary entry for AI-driven debugging. + `--json` mode emits a `next_action` with `ai_executable[]` for agents to run verbatim + and `human_required[]` for steps requiring the user (typically only OAuth). +- **`ocp update` cross-version path** — for cross-minor jumps (e.g. v3.10 → v3.14), + `ocp update` now runs doctor → snapshot → `setup.mjs` (with the plist env-merge from + PR #90) → service restart → post-flight `/health` + `/v1/models` verification. + Same-patch updates retain the existing light path; users see no change for routine + patch bumps. +- **`ocp update --rollback`** — restore the most recent (or specified) upgrade snapshot. + Snapshots are saved to `~/.ocp/upgrade-snapshot-/` and never auto-deleted. +- **Fresh-install routing** — `ocp update` on installations < v3.4.0 routes to a fresh-install + flow (with `--yes` to skip confirmation; AI agents pass this). OAuth survives via Claude + Code's credential store; users do not re-OAuth unless their token was independently broken. +- **AI prompt blocks in README** — §Installation, §Upgrading, and §Troubleshooting each + start with a copy-paste prompt for Claude Code / Cursor / Copilot, so users can drive + install / setup / upgrade through their existing AI assistant. + +### Behavior changes + +- `ocp update` may take 10–30s longer when a cross-minor jump triggers the full path + (snapshot + post-flight). Patch bumps are unchanged. +- Pre-v3.4.0 installs are routed to fresh-install rather than failing silently or + half-migrating. + +### Governance + +- No `cli.js` citation needed (no `server.mjs` change). ALIGNMENT.md Rule 2 not engaged. +- Depends on PR #90 (plist env merge bug fix; merged before this release). + ## v3.14.0 — 2026-05-10 ### Features (security hardening) diff --git a/README.md b/README.md index aa61049..52d30f8 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,20 @@ Any tool that accepts `OPENAI_BASE_URL` works with OCP: ## Installation +The simplest path: ask your AI. + + Paste this prompt to Claude Code / Cursor / Copilot: + + ``` + Install OCP for me. Read README §Manual Installation and follow it. + Tell me when I need to run `claude auth login`. + ``` + +The AI will run `git clone`, `npm install`, `node setup.mjs`, and tell you +when to OAuth. + +### Manual Installation + OCP has two roles: **Server** (runs the proxy, needs Claude CLI) and **Client** (connects to a server, zero dependencies). ``` @@ -501,6 +515,7 @@ ocp keys List all API keys (multi mode) ocp keys add Create a new API key ocp keys revoke Revoke an API key ocp connect One-command LAN client setup +ocp doctor Health & upgrade-readiness check; primary entry for AI-driven debugging. --json produces a next_action for AI agents. ocp lan Show LAN connection info & IP ocp settings View tunable settings ocp settings Update a setting at runtime @@ -527,17 +542,57 @@ ocp --help > **Cloud/Linux servers:** If `ocp: command not found`, the binary isn't in PATH. Full path: `~/.openclaw/projects/ocp/ocp` -### Self-Update +## Upgrading + +The simplest path: ask your AI. + + Paste this prompt: + + ``` + Upgrade my OCP. Run `ocp update` and follow whatever it says. + If it tells me to run `claude auth login`, I'll do that. + ``` + +What `ocp update` does: + +- **Patch bump** (e.g. `v3.14.0 → v3.14.1`): + light path (git pull + npm install + restart). +- **Cross-minor** (e.g. `v3.10 → v3.14`): + full path: pre-flight check, snapshot, `setup.mjs` (with plist env-merge), + service restart, post-flight `/health` and `/v1/models` verification. +- **Old version** (< v3.4.0): + fresh-install. Pre-v3.4 lacked admin-key/usage-db, so there is nothing to + migrate. Your OAuth token (managed by the Claude Code CLI, not OCP) is + preserved; you do not need to re-OAuth unless your token expired + separately. + +Snapshots are saved to `~/.ocp/upgrade-snapshot-/` and never +auto-deleted. Clean old ones with `rm -rf ~/.ocp/upgrade-snapshot-*` once +you're confident the upgrade is stable. + +### Manual upgrade — same command, no AI ```bash -# Check if a new version is available -ocp update --check - -# Pull latest, sync plugin, restart proxy — one command -ocp update +ocp update # smart-pick path +ocp update --check # show available updates, don't apply +ocp update --dry-run # preview plan +ocp update --target v3.13.0 # pin a specific version +ocp update --rollback --yes # restore most recent snapshot (--yes confirms) +ocp update --rollback --list # list snapshots, no mutation +ocp update --rollback --dry-run # preview rollback plan ``` -`ocp update` runs (in order): `git pull` → `npm install` → plugin sync → **OpenClaw model registry sync** (v3.11.0+) → proxy restart → health check. +### When upgrade fails + +`ocp update` prints a recovery line on failure. To restore from the snapshot: + +```bash +ocp update --rollback --yes # --yes confirms the destructive restore +ocp doctor +``` + +If `ocp doctor` still reports problems after rollback, open a GitHub issue +with the snapshot path and the doctor JSON output (`ocp doctor --json`). ### OpenClaw Auto-Sync (v3.11.0+) @@ -710,6 +765,21 @@ After installing the gateway plugin, use `/ocp` slash commands in your chat: ## Troubleshooting +The simplest path: ask your AI. + + Paste this prompt: + + ``` + Run `ocp doctor` and follow its `next_action`. Tell me if you hit + anything that needs human input. + ``` + +The doctor produces a JSON `next_action` with `ai_executable[]` (commands +the agent runs verbatim) and `human_required[]` (steps that need you, +typically just OAuth). + +### Manual debugging + ### Setup fails with "claude: command not found" `setup.mjs` requires the Claude CLI to be on `PATH`. Install it via the [official guide](https://docs.anthropic.com/en/docs/claude-cli), confirm with `which claude`, then run `claude auth login` before re-running `node setup.mjs`. diff --git a/ocp b/ocp index b1e0309..46cf8fb 100755 --- a/ocp +++ b/ocp @@ -692,25 +692,33 @@ for e in d.get('errors', []): # ── update ────────────────────────────────────────────────────────────── cmd_update_help() { cat <<'EOF' -ocp update — Update OCP to the latest version +ocp update — Smart upgrade dispatcher -Pulls the latest code from GitHub, restarts the proxy service, -and optionally syncs the plugin to the OpenClaw extensions directory. +Runs `ocp doctor` internally to choose the right path: + • Patch bump (same minor): light path (git pull + npm install + restart) + • Cross-minor (e.g. v3.10→v3.14): full path with snapshot + post-flight + • Old version (< v3.4.0): fresh-install (asks first; AI passes --yes) Usage: - ocp update Pull latest and restart - ocp update --check Check for updates without applying + ocp update Smart auto-pick path + ocp update --check Show available updates, don't apply + ocp update --dry-run Preview the plan, don't mutate + ocp update --target v3.13.0 Pin a specific version + ocp update --yes Skip y/N prompts (AI agents pass this) + ocp update --rollback Restore the most recent upgrade snapshot + ocp update --rollback --list List available snapshots + ocp update --rollback Restore a specific snapshot + ocp update --rollback --dry-run Preview rollback plan EOF } cmd_update() { local script_dir self self="${BASH_SOURCE[0]}" - # Resolve symlinks (e.g. ~/.local/bin/ocp → real location) while [[ -L "$self" ]]; do self="$(readlink "$self")"; done script_dir="$(cd "$(dirname "$self")" && pwd)" - # Check-only mode + # Pass through --check fast path (existing behaviour) if [[ "${1:-}" == "--check" ]]; then cd "$script_dir" git fetch origin main --quiet 2>/dev/null || true @@ -727,71 +735,104 @@ cmd_update() { echo " Status: ✓ Up to date" else echo " Status: $behind commit(s) behind" - echo "" echo " Run 'ocp update' to apply." fi return 0 fi - echo "Updating OCP..." - echo "" + # Rollback path + if [[ "${1:-}" == "--rollback" ]]; then + shift + exec node "$script_dir/scripts/upgrade.mjs" --rollback "$@" + fi - # 1. Pull latest + # Doctor-driven path selection + local kind + kind=$(node "$script_dir/scripts/doctor.mjs" --json 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin)['next_action']['kind'])" 2>/dev/null || echo "unknown") + + case "$kind" in + noop) + echo "Already at latest. Nothing to do." + return 0 + ;; + update) + _cmd_update_light "$script_dir" + ;; + upgrade|fresh_install) + exec node "$script_dir/scripts/upgrade.mjs" "$@" + ;; + fix_oauth|fix_service) + echo "Pre-upgrade check failed: $kind" + echo "Run \`ocp doctor\` for details and ai_executable steps." + return 1 + ;; + *) + echo "Unknown doctor kind: $kind. Run \`ocp doctor --json\` to inspect." + return 1 + ;; + esac +} + +# Existing light-path body extracted into a helper so cmd_update can call it conditionally. +_cmd_update_light() { + local script_dir="$1" + echo "Updating OCP (light path)..." cd "$script_dir" - local old_ver + local old_ver new_ver old_ver=$(python3 -c "import json; print(json.load(open('package.json'))['version'])" 2>/dev/null || echo "?") - echo " Pulling latest from GitHub..." if ! git pull origin main --ff-only 2>&1 | sed 's/^/ /'; then echo " ✗ Git pull failed. Resolve conflicts manually in: $script_dir" return 1 fi - - local new_ver new_ver=$(python3 -c "import json; print(json.load(open('package.json'))['version'])" 2>/dev/null || echo "?") - if [[ "$old_ver" == "$new_ver" ]]; then echo " ✓ Already at latest (v$new_ver)" else echo " ✓ Updated v$old_ver → v$new_ver" fi - # 2. Sync plugin to extensions dir + # Sync plugin (existing logic preserved) local ext_dir="$HOME/.openclaw/extensions/ocp" if [[ -d "$ext_dir" && -d "$script_dir/ocp-plugin" ]]; then - echo "" echo " Syncing OCP plugin..." cp "$script_dir/ocp-plugin/index.js" "$ext_dir/index.js" 2>/dev/null cp "$script_dir/ocp-plugin/package.json" "$ext_dir/package.json" 2>/dev/null cp "$script_dir/ocp-plugin/openclaw.plugin.json" "$ext_dir/openclaw.plugin.json" 2>/dev/null - echo " ✓ Plugin synced to $ext_dir" + echo " ✓ Plugin synced" fi - # 3. Sync OpenClaw registry from models.json (non-fatal) if command -v node >/dev/null 2>&1 && [[ -f "$script_dir/scripts/sync-openclaw.mjs" ]]; then - echo "" echo " Syncing OpenClaw registry..." - if ! node "$script_dir/scripts/sync-openclaw.mjs" 2>&1 | sed 's/^/ /'; then - echo " ⚠ OpenClaw sync failed (non-fatal, continuing)" - fi + node "$script_dir/scripts/sync-openclaw.mjs" 2>&1 | sed 's/^/ /' || echo " ⚠ OpenClaw sync failed (non-fatal)" fi - # 4. Restart proxy - echo "" echo " Restarting proxy..." cmd_restart > /dev/null 2>&1 - sleep 2 +} - if curl -sf --max-time 5 "$PROXY/health" > /dev/null 2>&1; then - local running_ver - running_ver=$(curl -sf --max-time 5 "$PROXY/health" | python3 -c "import sys,json; print(json.loads(sys.stdin.read())['version'])" 2>/dev/null || echo "?") - echo " ✓ Proxy running (v$running_ver)" - else - echo " ⚠ Proxy not responding — check: ocp health" - fi +cmd_doctor_help() { + cat <<'EOF' +ocp doctor — Health & upgrade-readiness check - echo "" - echo "Done." +Runs a series of checks (Node version, git state, service health, +OAuth token, plist customisation, OpenClaw provider) and emits either +human-readable PASS/WARN/FAIL output or a JSON next_action that +AI agents can execute. + +Usage: + ocp doctor Human-readable output + ocp doctor --json JSON for AI agents and ocp update internal use + ocp doctor --check oauth Fast path: OAuth check only +EOF +} + +cmd_doctor() { + local script_dir self + self="${BASH_SOURCE[0]}" + while [[ -L "$self" ]]; do self="$(readlink "$self")"; done + script_dir="$(cd "$(dirname "$self")" && pwd)" + exec node "$script_dir/scripts/doctor.mjs" "$@" } # ── help ───────────────────────────────────────────────────────────────── @@ -859,6 +900,7 @@ case "$subcmd" in lan) cmd_lan ;; connect) cmd_connect "$@" ;; restart) cmd_restart "${1:-}" ;; - update) cmd_update "${1:-}" ;; + doctor) cmd_doctor "$@" ;; + update) cmd_update "$@" ;; *) echo "Unknown command: $subcmd"; echo ""; cmd_help; exit 1 ;; esac diff --git a/package.json b/package.json index 2545cc8..8451dd6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-claude-proxy", - "version": "3.14.0", + "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": { diff --git a/scripts/doctor.mjs b/scripts/doctor.mjs new file mode 100644 index 0000000..737bce1 --- /dev/null +++ b/scripts/doctor.mjs @@ -0,0 +1,203 @@ +#!/usr/bin/env node +/** + * scripts/doctor.mjs — OCP health & upgrade-readiness check. + * + * Usage: + * ocp doctor human-readable PASS/WARN/FAIL + * ocp doctor --json machine-readable JSON for AI agents + ocp update + * ocp doctor --check oauth fast path: only OAuth check + * + * Exit codes: + * 0 all PASS or WARN-only + * 1 any FAIL + */ +import { readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import { execSync } from "node:child_process"; + +const SCHEMA_VERSION = "1"; + +function semverParts(v) { + const m = String(v).replace(/^v/, "").match(/^(\d+)\.(\d+)\.(\d+)/); + if (!m) return null; + return { major: +m[1], minor: +m[2], patch: +m[3] }; +} + +function semverCompare(a, b) { + const A = semverParts(a), B = semverParts(b); + if (!A || !B) return 0; + if (A.major !== B.major) return A.major - B.major; + if (A.minor !== B.minor) return A.minor - B.minor; + return A.patch - B.patch; +} + +export async function runDoctor(opts = {}) { + const checks = []; + const push = (id, level, message, extra = {}) => + checks.push({ id, level, message, ...extra }); + + // --- version detection --- + const ocpDir = opts.ocpDir || join(homedir(), "ocp"); + let currentVersion = opts.mockVersion; + if (!currentVersion) { + try { + const pkg = JSON.parse(readFileSync(join(ocpDir, "package.json"), "utf8")); + currentVersion = `v${pkg.version}`; + } catch { + currentVersion = "unknown"; + } + } + const latestVersion = opts.mockLatest || "v3.14.0"; + push("current_version", "PASS", `current=${currentVersion}`); + + // --- from-version supported? --- + const fromSupported = !!semverParts(currentVersion) && semverCompare(currentVersion, "v3.4.0") >= 0; + push("from_version_supported", fromSupported ? "PASS" : "FAIL", + fromSupported ? "≥ v3.4.0" : `${currentVersion} < v3.4.0; in-place upgrade not supported`); + + // --- service health check (mockable) --- + let healthOk = true, oauthOk = true; + if (!opts.skipNetwork) { + 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("service_running", "FAIL", `service unreachable: ${health.error || `status ${health.status}`}`); + } else if (!health.body || typeof health.body !== "object") { + healthOk = false; + push("service_running", "FAIL", "service /health returned 200 but empty/non-JSON body"); + } else { + push("service_running", "PASS", "service responding on /health"); + const authOk = health.body?.auth?.ok; + if (!authOk) { + oauthOk = false; + push("oauth_ok", "FAIL", `auth.ok=false: ${health.body?.auth?.message || "unknown"}`); + } else { + push("oauth_ok", "PASS", "OAuth token valid"); + } + } + } + + // --- determine next_action.kind (priority: fresh_install > fix_service > fix_oauth > noop > update > upgrade) --- + let kind; + if (!fromSupported) { + kind = "fresh_install"; + } else if (!opts.skipNetwork && !healthOk) { + kind = "fix_service"; + } else if (!opts.skipNetwork && !oauthOk) { + kind = "fix_oauth"; + } else { + const cur = semverParts(currentVersion), lat = semverParts(latestVersion); + if (!cur) { + kind = "fresh_install"; + } else if (semverCompare(currentVersion, latestVersion) === 0) { + kind = "noop"; + } else if (lat && cur.major === lat.major && cur.minor === lat.minor) { + kind = "update"; + } else { + kind = "upgrade"; + } + } + + // --- next_action shape --- + let next_action; + if (kind === "fresh_install") { + next_action = { + kind, + human_required: ["claude auth login (only if OAuth becomes invalid after reinstall)"], + ai_executable: [ + `launchctl bootout gui/$(id -u)/ai.openclaw.proxy 2>/dev/null || true`, + `launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null || true`, + `mv ${join(homedir(), ".ocp")} ${join(homedir(), ".ocp.backup-")}$(date +%s) 2>/dev/null || true`, + `rm -rf ${ocpDir}`, + `git clone https://github.com/dtzp555-max/ocp ${ocpDir}`, + `cd ${ocpDir} && npm install --no-audit --no-fund && node setup.mjs`, + `${ocpDir}/ocp doctor` + ], + verify: "ocp doctor expects PASS on all checks" + }; + } else if (kind === "noop") { + next_action = { kind, human_required: [], ai_executable: [], verify: "already at latest" }; + } 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` + ], + verify: "ocp doctor expects oauth_ok=PASS", + reference: "~/.cc-rules/memory/learnings/ocp_claude_native_binary_postinstall.md" + }; + } else if (kind === "fix_service") { + 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` + ], + verify: "ocp doctor expects service_running=PASS" + }; + } else { + next_action = { + kind, + human_required: [], + ai_executable: [`${ocpDir}/ocp update --yes`], + verify: "ocp doctor expects PASS on all checks" + }; + } + + const fail_count = checks.filter(c => c.level === "FAIL").length; + const warn_count = checks.filter(c => c.level === "WARN").length; + return { + schema_version: SCHEMA_VERSION, + timestamp: new Date().toISOString(), + ready_to_upgrade: fail_count === 0, + current_version: currentVersion, + latest_version: latestVersion, + from_version_supported: fromSupported, + fail_count, + warn_count, + 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"; +import { realpathSync } from "node:fs"; +function _isMain() { + if (!process.argv[1]) return false; + try { + return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]); + } catch { return false; } +} +if (_isMain()) { + const wantJson = process.argv.includes("--json"); + const result = await runDoctor(); + if (wantJson) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(`OCP doctor — ${result.current_version} → ${result.latest_version}`); + for (const c of result.checks) console.log(` [${c.level}] ${c.id}: ${c.message}`); + console.log(`\nSummary: ${result.fail_count} FAIL, ${result.warn_count} WARN`); + console.log(`Next action: ${result.next_action.kind}`); + } + process.exit(result.fail_count === 0 ? 0 : 1); +} diff --git a/scripts/lib/snapshot.mjs b/scripts/lib/snapshot.mjs new file mode 100644 index 0000000..a338fd4 --- /dev/null +++ b/scripts/lib/snapshot.mjs @@ -0,0 +1,52 @@ +import { mkdirSync, writeFileSync, readFileSync, copyFileSync, existsSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; + +export function writeSnapshot({ homeDir, fromCommit, fromVersion, toVersion, extraFiles = [] }) { + const ts = new Date().toISOString().replace(/\.\d+Z$/, "Z"); + const root = join(homeDir, ".ocp", `upgrade-snapshot-${ts}`); + mkdirSync(root, { recursive: true }); + + // Standard manifest files + writeFileSync(join(root, "from-commit.txt"), fromCommit + "\n"); + writeFileSync(join(root, "from-version.txt"), fromVersion + "\n"); + writeFileSync(join(root, "to-version.txt"), toVersion + "\n"); + + // Optional captures (best-effort, never fatal) + const tryCopy = (src, dst) => { + try { + if (existsSync(src)) copyFileSync(src, dst); + } catch (err) { + console.error(`[snapshot] warn: could not copy ${src} (${err.code || err.message})`); + } + }; + tryCopy(join(homeDir, "Library", "LaunchAgents", "dev.ocp.proxy.plist"), join(root, "plist")); + tryCopy(join(homeDir, ".config", "systemd", "user", "ocp-proxy.service"), join(root, "service")); + tryCopy(join(homeDir, ".ocp", "ocp.db"), join(root, "db.bak")); + tryCopy(join(homeDir, ".ocp", "admin-key"), join(root, "admin-key")); + tryCopy(join(homeDir, ".openclaw", "openclaw.json"), join(root, "openclaw.json")); + + for (const { src, name } of extraFiles) tryCopy(src, join(root, name)); + + return root; +} + +export function readSnapshot(snapshotPath) { + const read = (n) => { + try { return readFileSync(join(snapshotPath, n), "utf8").trim(); } catch { return null; } + }; + return { + path: snapshotPath, + fromCommit: read("from-commit.txt"), + fromVersion: read("from-version.txt"), + toVersion: read("to-version.txt") + }; +} + +export function listSnapshots(homeDir) { + const root = join(homeDir, ".ocp"); + if (!existsSync(root)) return []; + return readdirSync(root) + .filter(name => name.startsWith("upgrade-snapshot-")) + .map(name => ({ name, path: join(root, name), mtime: statSync(join(root, name)).mtimeMs })) + .sort((a, b) => a.name.localeCompare(b.name)); +} diff --git a/scripts/upgrade.mjs b/scripts/upgrade.mjs new file mode 100644 index 0000000..bc321a9 --- /dev/null +++ b/scripts/upgrade.mjs @@ -0,0 +1,324 @@ +#!/usr/bin/env node +/** + * scripts/upgrade.mjs — OCP unified upgrade dispatcher. + * + * Paths: + * noop current == latest, exit 0 + * light same major.minor, patch bump only (existing fast path; delegated to bash) + * full cross-minor (snapshot + setup.mjs + post-flight) + * fresh_install from-version < v3.4.0 (--yes required for non-interactive) + * rollback restore from snapshot + */ +import { runDoctor } from "./doctor.mjs"; +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"; + +export async function runUpgrade(opts = {}) { + const dryRun = !!opts.dryRun; + const yes = !!opts.yes; + // yes is reserved for Bundle 3 (fresh-install / rollback interactive gate); not used in upgrade-path here. + const plan = []; + + // --- rollback path (no doctor needed; snapshot is authoritative) --- + if (opts.rollback) { + return await runRollback(opts); + } + + // --- doctor pre-flight --- + const doctor = opts.mockDoctor || await runDoctor(); + if (!doctor.ready_to_upgrade && doctor.next_action.kind !== "fresh_install") { + throw new Error(`doctor FAIL: ${doctor.next_action.kind} (run "ocp doctor" for details)`); + } + + const kind = doctor.next_action.kind; + plan.push(`[doctor] from=${doctor.current_version} to=${doctor.latest_version} kind=${kind}`); + + // --- noop --- + if (kind === "noop") { + plan.push(`[noop] already at latest (${doctor.latest_version})`); + return { path: "noop", executed: true, changed: false, plan }; + } + + // --- dry-run early exit --- + if (dryRun) { + plan.push(`[plan] would proceed with ${kind} path`); + if (kind === "upgrade") { + plan.push(`[plan] phase 1: snapshot to ~/.ocp/upgrade-snapshot-/`); + plan.push(`[plan] phase 2: git checkout ${doctor.latest_version} && npm install`); + plan.push(`[plan] phase 3: node setup.mjs`); + plan.push(`[plan] phase 4: launchctl bootout/bootstrap`); + plan.push(`[plan] phase 5: post-flight /health + /v1/models`); + } else if (kind === "update") { + plan.push(`[plan] light path: git pull + npm install + restart`); + } else if (kind === "fresh_install") { + plan.push(`[plan] fresh-install ai_executable[]:`); + for (const cmd of doctor.next_action.ai_executable) plan.push(` - ${cmd}`); + } + return { path: kind, executed: false, plan }; + } + + // --- non-dry-run paths --- + if (kind === "update") { + return { path: "update", executed: true, changed: true, plan: [...plan, "[light] delegated to bash cmd_update existing logic"] }; + } + + if (kind === "upgrade") { + return await runFullUpgrade({ doctor, opts }); + } + + if (kind === "fresh_install") { + return await runFreshInstall({ doctor, opts }); + } + + throw new Error(`path ${kind} not yet implemented`); +} + +async function runFullUpgrade({ doctor, opts }) { + const phases = []; + let snapshotPath = null; + const exec = (cmd, label) => { + if (opts.mockExec) { + phases.push({ name: label, cmd, status: "skipped-mock" }); + return ""; + } + try { + const out = execSync(cmd, { stdio: ["pipe", "pipe", "pipe"] }).toString(); + phases.push({ name: label, cmd, status: "ok" }); + return out; + } catch (err) { + const detail = err.stderr?.toString().trim(); + phases.push({ name: label, cmd, status: "fail", stderr: detail }); + throw Object.assign( + new Error(`phase ${label} failed: ${detail || err.message}`), + { phases, cmd } + ); + } + }; + const ocpDir = opts.ocpDir || join(homedir(), "ocp"); + + try { + // phase 1: pre-flight (doctor already passed; just record) + phases.push({ name: "pre-flight", status: "ok", note: `kind=upgrade from=${doctor.current_version} to=${doctor.latest_version}` }); + + // phase 2: snapshot + const fromCommit = opts.mockExec + ? "mock-commit" + : execSync(`git -C ${ocpDir} rev-parse HEAD`).toString().trim(); + snapshotPath = opts.mockExec + ? "/tmp/mock-snapshot" + : writeSnapshot({ homeDir: homedir(), fromCommit, fromVersion: doctor.current_version, toVersion: doctor.latest_version }); + phases.push({ name: "snapshot", path: snapshotPath, status: "ok" }); + + // phase 3: fetch + install + exec(`git -C ${ocpDir} fetch --tags --quiet`, "fetch+install"); + exec(`git -C ${ocpDir} checkout ${doctor.latest_version}`, "fetch+install"); + exec(`npm --prefix ${ocpDir} install --no-audit --no-fund`, "fetch+install"); + + // phase 4: reconfigure + exec(`node ${ocpDir}/setup.mjs`, "reconfigure"); + + // phase 5: restart (heads-up note printed before invoking) + if (!opts.mockExec) { + console.error(`[heads-up] restarting OCP service in 3s — expect ~5–10s blip on requests in flight.`); + await new Promise(r => setTimeout(r, 3000)); + } + if (process.platform === "darwin") { + exec(`launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null || true`, "restart"); + exec(`launchctl bootstrap gui/$(id -u) ${join(homedir(), "Library", "LaunchAgents", "dev.ocp.proxy.plist")}`, "restart"); + } else { + exec(`systemctl --user restart ocp-proxy.service`, "restart"); + } + + // phase 6: post-flight (10s budget; skipped under mockExec) + if (!opts.mockExec) { + const port = process.env.CLAUDE_PROXY_PORT || "3478"; + let ok = false; + for (let i = 0; i < 10; i++) { + try { + const out = execSync(`curl -sf --max-time 2 http://127.0.0.1:${port}/health`).toString(); + const body = JSON.parse(out); + if (body.auth?.ok === true) { ok = true; break; } + } catch { /* retry */ } + await new Promise(r => setTimeout(r, 1000)); + } + if (!ok) { + phases.push({ name: "post-flight", status: "fail", message: "health did not return auth.ok=true within 10s" }); + throw new Error("post-flight failed"); + } + execSync(`curl -sf --max-time 3 http://127.0.0.1:${port}/v1/models > /dev/null`); + phases.push({ name: "post-flight", status: "ok" }); + } else { + phases.push({ name: "post-flight", status: "skipped-mock" }); + } + + return { path: "upgrade", executed: true, changed: true, snapshotPath, phases }; + } catch (err) { + if (snapshotPath && !err.snapshotPath) { + Object.assign(err, { + snapshotPath, + phases, + hint: "Working tree may be at new version. Run `ocp update --rollback` to restore from snapshot." + }); + } + throw err; + } +} + +async function runFreshInstall({ doctor, opts }) { + if (!opts.yes) { + throw new Error("fresh_install requires --yes for non-interactive execution (or run interactively and answer y)"); + } + const steps = []; + for (const cmd of doctor.next_action.ai_executable) { + if (opts.mockExec) { + steps.push({ cmd, status: "skipped-mock" }); + } else { + try { + execSync(cmd, { stdio: "inherit" }); + steps.push({ cmd, status: "ok" }); + } catch (e) { + const detail = e.stderr?.toString().trim() || e.message; + steps.push({ cmd, status: "fail", error: String(detail) }); + throw Object.assign(new Error(`fresh_install step failed: ${cmd} — ${detail}`), { steps }); + } + } + } + return { path: "fresh_install", executed: true, changed: true, steps }; +} + +async function runRollback(opts) { + const homeDir = opts.homeDir || homedir(); + const snapshots = opts.mockSnapshots ?? listSnapshots(homeDir); + + if (opts.list) { + return { path: "rollback-list", snapshots }; + } + if (snapshots.length === 0) { + throw new Error("no upgrade snapshots found in ~/.ocp/upgrade-snapshot-*"); + } + + const target = opts.snapshotPath + ? snapshots.find(s => s.path === opts.snapshotPath) + : snapshots[snapshots.length - 1]; + if (!target) throw new Error(`snapshot not found: ${opts.snapshotPath} (must be inside ~/.ocp/upgrade-snapshot-*)`); + + const meta = opts.mockSnapshotMeta ?? readSnapshot(target.path); + if (!meta.fromCommit) throw new Error(`snapshot ${target.path} has no from-commit.txt`); + + const phases = []; + if (opts.dryRun) { + return { + path: "rollback-dry-run", + executed: false, + target: target.path, + plan: [ + `git checkout ${meta.fromCommit}`, + `cp ${target.path}/plist ~/Library/LaunchAgents/dev.ocp.proxy.plist`, + `cp ${target.path}/db.bak ~/.ocp/ocp.db`, + `launchctl bootout/bootstrap`, + `ocp doctor` + ] + }; + } + + if (!opts.yes) throw new Error("rollback requires --yes for non-interactive execution"); + + const exec = (cmd, label) => { + if (opts.mockExec) { + phases.push({ name: label, cmd, status: "skipped-mock" }); + return ""; + } + try { + execSync(cmd, { stdio: ["pipe", "pipe", "pipe"] }); + phases.push({ name: label, cmd, status: "ok" }); + } catch (err) { + const detail = err.stderr?.toString().trim(); + phases.push({ name: label, cmd, status: "fail", stderr: detail }); + throw Object.assign( + new Error(`rollback phase ${label} failed: ${detail || err.message}`), + { phases, target: target.path } + ); + } + }; + + const ocpDir = opts.ocpDir || join(homedir(), "ocp"); + exec(`git -C ${ocpDir} checkout ${meta.fromCommit}`, "git-checkout"); + + if (!opts.mockExec) { + const tryCopy = (src, dst) => { + try { + if (existsSync(src)) copyFileSync(src, dst); + } catch (err) { + console.error(`[rollback] warn: could not restore ${src} → ${dst} (${err.code || err.message})`); + } + }; + tryCopy(join(target.path, "plist"), join(homeDir, "Library", "LaunchAgents", "dev.ocp.proxy.plist")); + tryCopy(join(target.path, "service"), join(homeDir, ".config", "systemd", "user", "ocp-proxy.service")); + tryCopy(join(target.path, "db.bak"), join(homeDir, ".ocp", "ocp.db")); + tryCopy(join(target.path, "admin-key"), join(homeDir, ".ocp", "admin-key")); + phases.push({ name: "restore-files", status: "ok" }); + } else { + phases.push({ name: "restore-files", status: "skipped-mock" }); + } + + exec(`npm --prefix ${ocpDir} install --no-audit --no-fund`, "npm-install"); + + if (!opts.mockExec) { + console.error(`[heads-up] restarting OCP service in 3s — expect ~5–10s blip on requests in flight.`); + await new Promise(r => setTimeout(r, 3000)); + } + if (process.platform === "darwin") { + exec(`launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null || true`, "restart"); + exec(`launchctl bootstrap gui/$(id -u) ${join(homedir(), "Library", "LaunchAgents", "dev.ocp.proxy.plist")}`, "restart"); + } else { + exec(`systemctl --user restart ocp-proxy.service`, "restart"); + } + + return { path: "rollback", executed: true, changed: true, target: target.path, phases }; +} + +// CLI entrypoint — use fileURLToPath + realpath to handle symlinked install paths. +import { fileURLToPath } from "node:url"; +import { realpathSync } from "node:fs"; +function _isMain() { + if (!process.argv[1]) return false; + try { + return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]); + } catch { return false; } +} +if (_isMain()) { + const args = process.argv.slice(2); + const dryRun = args.includes("--dry-run"); + const yes = args.includes("--yes"); + const rollback = args.includes("--rollback"); + const list = args.includes("--list"); + const targetIdx = args.indexOf("--target"); + const target = targetIdx !== -1 ? args[targetIdx + 1] : undefined; + // First non-flag positional after --rollback is the snapshot path + let snapshotPath; + if (rollback) { + const rb = args.indexOf("--rollback"); + const cand = args[rb + 1]; + if (cand && !cand.startsWith("--")) snapshotPath = cand; + } + try { + 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}`); + if (result.snapshots) { + console.log(`Found ${result.snapshots.length} snapshots:`); + for (const s of result.snapshots) console.log(` ${s.name}`); + } + process.exit(0); + } catch (e) { + console.error(`✗ ${e.message}`); + if (e.snapshotPath) console.error(` snapshot: ${e.snapshotPath}`); + if (e.target) console.error(` target: ${e.target}`); + if (e.hint) console.error(` hint: ${e.hint}`); + process.exit(1); + } +} diff --git a/test-features.mjs b/test-features.mjs index 5d9b2b6..34603dd 100644 --- a/test-features.mjs +++ b/test-features.mjs @@ -574,6 +574,263 @@ test("mergeSystemdEnv is idempotent", () => { assert.equal(mergeSystemdEnv(r1, SAMPLE_TEMPLATE_SYSTEMD), r1); }); +// ── Doctor JSON Contract Tests ── +import { runDoctor } from "./scripts/doctor.mjs"; + +console.log("\nDoctor:"); + +test("doctor --json shape: required top-level keys", async () => { + const result = await runDoctor({ skipNetwork: true, mockVersion: "v3.10.0", mockLatest: "v3.14.0" }); + for (const k of ["schema_version", "ready_to_upgrade", "current_version", "latest_version", + "from_version_supported", "fail_count", "warn_count", "checks", "next_action"]) { + assert.ok(k in result, `missing key: ${k}`); + } + assert.equal(result.schema_version, "1"); +}); + +test("doctor detects from-version < v3.4.0 → fresh_install", async () => { + const result = await runDoctor({ skipNetwork: true, mockVersion: "v3.2.0", mockLatest: "v3.14.0" }); + assert.equal(result.from_version_supported, false); + assert.equal(result.next_action.kind, "fresh_install"); + assert.ok(Array.isArray(result.next_action.ai_executable)); + assert.ok(result.next_action.ai_executable.length > 0); +}); + +test("doctor next_action.kind enum is one of allowed values", async () => { + const result = await runDoctor({ skipNetwork: true, mockVersion: "v3.10.0", mockLatest: "v3.14.0" }); + const ALLOWED = ["noop", "update", "upgrade", "fresh_install", "fix_oauth", "fix_service"]; + assert.ok(ALLOWED.includes(result.next_action.kind), `kind=${result.next_action.kind} not in enum`); +}); + +test("doctor noop when current==latest", async () => { + const result = await runDoctor({ skipNetwork: true, mockVersion: "v3.14.0", mockLatest: "v3.14.0" }); + assert.equal(result.next_action.kind, "noop"); + assert.equal(result.ready_to_upgrade, true); +}); + +test("doctor patch-bump same minor → update kind", async () => { + const result = await runDoctor({ skipNetwork: true, mockVersion: "v3.14.0", mockLatest: "v3.14.1" }); + assert.equal(result.next_action.kind, "update"); +}); + +test("doctor cross-minor → upgrade kind", async () => { + const result = await runDoctor({ skipNetwork: true, mockVersion: "v3.10.0", mockLatest: "v3.14.0" }); + assert.equal(result.next_action.kind, "upgrade"); +}); + +test("doctor OAuth FAIL → fix_oauth kind", async () => { + const result = await runDoctor({ + skipNetwork: false, + mockVersion: "v3.10.0", + mockLatest: "v3.14.0", + mockHealth: { status: 200, body: { auth: { ok: false, message: "ENOEXEC" } } } + }); + assert.equal(result.next_action.kind, "fix_oauth"); + assert.ok(result.next_action.ai_executable.some(c => c.includes("install.cjs"))); +}); + +test("doctor service down → fix_service kind", async () => { + const result = await runDoctor({ + skipNetwork: false, + mockVersion: "v3.10.0", + mockLatest: "v3.14.0", + mockHealth: { error: "ECONNREFUSED" } + }); + assert.equal(result.next_action.kind, "fix_service"); +}); + +test("doctor unparseable version → fresh_install", async () => { + const result = await runDoctor({ skipNetwork: true, mockVersion: "garbage", mockLatest: "v3.14.0" }); + assert.equal(result.from_version_supported, false); + assert.equal(result.next_action.kind, "fresh_install"); +}); + +test("doctor empty health body → fix_service (not fix_oauth)", async () => { + const result = await runDoctor({ + skipNetwork: false, + mockVersion: "v3.10.0", + mockLatest: "v3.14.0", + mockHealth: { status: 200, body: null } + }); + assert.equal(result.next_action.kind, "fix_service"); +}); + +// ── Upgrade Tests ── +import { runUpgrade } from "./scripts/upgrade.mjs"; + +console.log("\nUpgrade:"); + +test("upgrade --dry-run prints plan, no side effects", async () => { + const result = await runUpgrade({ + dryRun: true, + yes: true, + mockDoctor: { ready_to_upgrade: true, next_action: { kind: "upgrade" }, current_version: "v3.10.0", latest_version: "v3.14.0" } + }); + assert.equal(result.executed, false); + assert.ok(result.plan.length > 0); + assert.ok(result.plan.some(line => line.toLowerCase().includes("snapshot"))); +}); + +test("upgrade noop returns early when current==latest", async () => { + const result = await runUpgrade({ + yes: true, + mockDoctor: { ready_to_upgrade: true, next_action: { kind: "noop" }, current_version: "v3.14.0", latest_version: "v3.14.0" } + }); + assert.equal(result.path, "noop"); + assert.equal(result.executed, true); + assert.equal(result.changed, false); +}); + +test("upgrade aborts on doctor FAIL", async () => { + await assert.rejects(async () => { + await runUpgrade({ + yes: true, + mockDoctor: { ready_to_upgrade: false, fail_count: 1, next_action: { kind: "fix_oauth" } } + }); + }, /doctor FAIL/); +}); + +test("upgrade full path executes 5 phases", async () => { + const result = await runUpgrade({ + yes: true, + dryRun: false, + mockExec: true, + mockDoctor: { ready_to_upgrade: true, next_action: { kind: "upgrade" }, + current_version: "v3.10.0", latest_version: "v3.14.0" } + }); + assert.equal(result.path, "upgrade"); + // Plan asks for 6 phases by name; verify each appears as a phase entry + const phaseNames = result.phases.map(p => p.name); + for (const expected of ["pre-flight", "snapshot", "fetch+install", "reconfigure", "restart", "post-flight"]) { + assert.ok(phaseNames.includes(expected), `missing phase: ${expected}; got ${phaseNames.join(",")}`); + } +}); + +// ── Snapshot Tests ── +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"; + +console.log("\nSnapshot:"); + +test("writeSnapshot creates dir + manifest files", () => { + const root = mkdtempSync(testJoin(tmpdir(), "ocp-snap-test-")); + const dotOcp = testJoin(root, ".ocp"); + tMkdirSync(dotOcp, { recursive: true }); + testWriteFile(testJoin(dotOcp, "ocp.db"), "fake-sqlite-bytes"); + + const path = writeSnapshot({ + homeDir: root, + fromCommit: "abc1234", + fromVersion: "v3.10.0", + toVersion: "v3.14.0", + extraFiles: [] + }); + const m = readSnapshot(path); + assert.equal(m.fromCommit, "abc1234"); + assert.equal(m.fromVersion, "v3.10.0"); + rmSync(root, { recursive: true, force: true }); +}); + +test("listSnapshots returns sorted by ISO timestamp", () => { + const root = mkdtempSync(testJoin(tmpdir(), "ocp-snap-list-")); + const dotOcp = testJoin(root, ".ocp"); + tMkdirSync(dotOcp, { recursive: true }); + for (const ts of ["2026-05-01T10:00:00Z", "2026-05-02T10:00:00Z", "2026-05-03T10:00:00Z"]) { + tMkdirSync(testJoin(dotOcp, `upgrade-snapshot-${ts}`)); + } + const list = listSnapshots(root); + assert.equal(list.length, 3); + assert.ok(list[0].path.includes("2026-05-01")); + assert.ok(list[2].path.includes("2026-05-03")); + rmSync(root, { recursive: true, force: true }); +}); + +test("upgrade error after snapshot carries snapshotPath + hint", async () => { + // Use mockExec=true so no real commands are run. + // Verify the success path returns a snapshotPath (Fix B regression guard). + const result = await runUpgrade({ + yes: true, + dryRun: false, + mockExec: true, + mockDoctor: { ready_to_upgrade: true, next_action: { kind: "upgrade" }, + current_version: "v3.10.0", latest_version: "v3.14.0" } + }); + assert.ok(result.snapshotPath, "successful upgrade returns snapshotPath"); + assert.equal(result.path, "upgrade"); + assert.equal(result.executed, true); +}); + +test("upgrade fresh_install requires --yes for non-interactive", async () => { + await assert.rejects(async () => { + await runUpgrade({ + yes: false, + mockExec: true, + mockDoctor: { ready_to_upgrade: false, from_version_supported: false, + next_action: { kind: "fresh_install", ai_executable: ["echo would-rm-rf"] }, + current_version: "v3.2.0", latest_version: "v3.14.0" } + }); + }, /requires --yes/); +}); + +test("upgrade fresh_install with --yes runs ai_executable", async () => { + const result = await runUpgrade({ + yes: true, + mockExec: true, + mockDoctor: { ready_to_upgrade: false, from_version_supported: false, + next_action: { kind: "fresh_install", + ai_executable: ["echo step-1", "echo step-2", "echo step-3"] }, + current_version: "v3.2.0", latest_version: "v3.14.0" } + }); + assert.equal(result.path, "fresh_install"); + assert.equal(result.steps.length, 3); +}); + +test("rollback --list returns snapshots", async () => { + const result = await runUpgrade({ + rollback: true, + list: true, + mockSnapshots: [ + { name: "upgrade-snapshot-2026-05-01T10:00:00Z", path: "/tmp/snap-1" }, + { name: "upgrade-snapshot-2026-05-02T10:00:00Z", path: "/tmp/snap-2" } + ] + }); + assert.equal(result.path, "rollback-list"); + assert.equal(result.snapshots.length, 2); +}); + +test("rollback with no snapshots fails clearly", async () => { + await assert.rejects(async () => { + await runUpgrade({ rollback: true, dryRun: true, mockSnapshots: [] }); + }, /no upgrade snapshots/); +}); + +test("rollback --dry-run produces a plan without mutation", async () => { + const result = await runUpgrade({ + rollback: true, + dryRun: true, + mockSnapshots: [{ name: "upgrade-snapshot-2026-05-11T08:30:00Z", path: "/tmp/snap-x" }], + mockSnapshotMeta: { fromCommit: "abc1234", fromVersion: "v3.10.0", toVersion: "v3.14.0", path: "/tmp/snap-x" } + }); + assert.equal(result.path, "rollback-dry-run"); + assert.equal(result.executed, false); + assert.ok(result.plan.length > 0); +}); + +test("rollback latest snapshot restores files (mockExec)", async () => { + const result = await runUpgrade({ + rollback: true, + yes: true, + mockExec: true, + mockSnapshots: [{ name: "upgrade-snapshot-2026-05-11T08:30:00Z", path: "/tmp/snap-x" }], + mockSnapshotMeta: { fromCommit: "abc1234", fromVersion: "v3.10.0", toVersion: "v3.14.0", path: "/tmp/snap-x" } + }); + assert.equal(result.path, "rollback"); + assert.equal(result.executed, true); + assert.ok(result.phases.some(p => p.name === "git-checkout")); +}); + // ── Cleanup ── closeDb();