feat(upgrade): ocp doctor + cross-version ocp update + rollback + AI prompts (#91)

* 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>

* feat(ocp): wire cmd_doctor into bash CLI; dispatch to scripts/doctor.mjs

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

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dtzp555-max
2026-05-11 06:56:17 +10:00
committed by GitHub
co-authored by taodeng Claude Sonnet 4.6
parent 55c576bbb1
commit ab03c13332
8 changed files with 1026 additions and 45 deletions
+33
View File
@@ -1,5 +1,38 @@
# Changelog # 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-<ISO-ts>/` 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 1030s 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 ## v3.14.0 — 2026-05-10
### Features (security hardening) ### Features (security hardening)
+77 -7
View File
@@ -69,6 +69,20 @@ Any tool that accepts `OPENAI_BASE_URL` works with OCP:
## Installation ## 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). 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 <name> Create a new API key ocp keys add <name> Create a new API key
ocp keys revoke <name> Revoke an API key ocp keys revoke <name> Revoke an API key
ocp connect <ip> One-command LAN client setup ocp connect <ip> 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 lan Show LAN connection info & IP
ocp settings View tunable settings ocp settings View tunable settings
ocp settings <k> <v> Update a setting at runtime ocp settings <k> <v> 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` > **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-<ISO-ts>/` 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 ```bash
# Check if a new version is available ocp update # smart-pick path
ocp update --check ocp update --check # show available updates, don't apply
ocp update --dry-run # preview plan
# Pull latest, sync plugin, restart proxy — one command ocp update --target v3.13.0 # pin a specific version
ocp update 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+) ### OpenClaw Auto-Sync (v3.11.0+)
@@ -710,6 +765,21 @@ After installing the gateway plugin, use `/ocp` slash commands in your chat:
## Troubleshooting ## 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 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`. `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`.
+79 -37
View File
@@ -692,25 +692,33 @@ for e in d.get('errors', []):
# ── update ────────────────────────────────────────────────────────────── # ── update ──────────────────────────────────────────────────────────────
cmd_update_help() { cmd_update_help() {
cat <<'EOF' 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, Runs `ocp doctor` internally to choose the right path:
and optionally syncs the plugin to the OpenClaw extensions directory. • 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: Usage:
ocp update Pull latest and restart ocp update Smart auto-pick path
ocp update --check Check for updates without applying 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 <path> Restore a specific snapshot
ocp update --rollback --dry-run Preview rollback plan
EOF EOF
} }
cmd_update() { cmd_update() {
local script_dir self local script_dir self
self="${BASH_SOURCE[0]}" self="${BASH_SOURCE[0]}"
# Resolve symlinks (e.g. ~/.local/bin/ocp → real location)
while [[ -L "$self" ]]; do self="$(readlink "$self")"; done while [[ -L "$self" ]]; do self="$(readlink "$self")"; done
script_dir="$(cd "$(dirname "$self")" && pwd)" script_dir="$(cd "$(dirname "$self")" && pwd)"
# Check-only mode # Pass through --check fast path (existing behaviour)
if [[ "${1:-}" == "--check" ]]; then if [[ "${1:-}" == "--check" ]]; then
cd "$script_dir" cd "$script_dir"
git fetch origin main --quiet 2>/dev/null || true git fetch origin main --quiet 2>/dev/null || true
@@ -727,71 +735,104 @@ cmd_update() {
echo " Status: ✓ Up to date" echo " Status: ✓ Up to date"
else else
echo " Status: $behind commit(s) behind" echo " Status: $behind commit(s) behind"
echo ""
echo " Run 'ocp update' to apply." echo " Run 'ocp update' to apply."
fi fi
return 0 return 0
fi fi
echo "Updating OCP..." # Rollback path
echo "" 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" 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 "?") old_ver=$(python3 -c "import json; print(json.load(open('package.json'))['version'])" 2>/dev/null || echo "?")
echo " Pulling latest from GitHub..." echo " Pulling latest from GitHub..."
if ! git pull origin main --ff-only 2>&1 | sed 's/^/ /'; then if ! git pull origin main --ff-only 2>&1 | sed 's/^/ /'; then
echo " ✗ Git pull failed. Resolve conflicts manually in: $script_dir" echo " ✗ Git pull failed. Resolve conflicts manually in: $script_dir"
return 1 return 1
fi fi
local new_ver
new_ver=$(python3 -c "import json; print(json.load(open('package.json'))['version'])" 2>/dev/null || echo "?") new_ver=$(python3 -c "import json; print(json.load(open('package.json'))['version'])" 2>/dev/null || echo "?")
if [[ "$old_ver" == "$new_ver" ]]; then if [[ "$old_ver" == "$new_ver" ]]; then
echo " ✓ Already at latest (v$new_ver)" echo " ✓ Already at latest (v$new_ver)"
else else
echo " ✓ Updated v$old_ver → v$new_ver" echo " ✓ Updated v$old_ver → v$new_ver"
fi fi
# 2. Sync plugin to extensions dir # Sync plugin (existing logic preserved)
local ext_dir="$HOME/.openclaw/extensions/ocp" local ext_dir="$HOME/.openclaw/extensions/ocp"
if [[ -d "$ext_dir" && -d "$script_dir/ocp-plugin" ]]; then if [[ -d "$ext_dir" && -d "$script_dir/ocp-plugin" ]]; then
echo ""
echo " Syncing OCP plugin..." echo " Syncing OCP plugin..."
cp "$script_dir/ocp-plugin/index.js" "$ext_dir/index.js" 2>/dev/null 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/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 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 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 if command -v node >/dev/null 2>&1 && [[ -f "$script_dir/scripts/sync-openclaw.mjs" ]]; then
echo ""
echo " Syncing OpenClaw registry..." echo " Syncing OpenClaw registry..."
if ! node "$script_dir/scripts/sync-openclaw.mjs" 2>&1 | sed 's/^/ /'; then node "$script_dir/scripts/sync-openclaw.mjs" 2>&1 | sed 's/^/ /' || echo " ⚠ OpenClaw sync failed (non-fatal)"
echo " ⚠ OpenClaw sync failed (non-fatal, continuing)"
fi
fi fi
# 4. Restart proxy
echo ""
echo " Restarting proxy..." echo " Restarting proxy..."
cmd_restart > /dev/null 2>&1 cmd_restart > /dev/null 2>&1
sleep 2 }
if curl -sf --max-time 5 "$PROXY/health" > /dev/null 2>&1; then cmd_doctor_help() {
local running_ver cat <<'EOF'
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 "?") ocp doctor — Health & upgrade-readiness check
echo " ✓ Proxy running (v$running_ver)"
else
echo " ⚠ Proxy not responding — check: ocp health"
fi
echo "" Runs a series of checks (Node version, git state, service health,
echo "Done." 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 ───────────────────────────────────────────────────────────────── # ── help ─────────────────────────────────────────────────────────────────
@@ -859,6 +900,7 @@ case "$subcmd" in
lan) cmd_lan ;; lan) cmd_lan ;;
connect) cmd_connect "$@" ;; connect) cmd_connect "$@" ;;
restart) cmd_restart "${1:-}" ;; restart) cmd_restart "${1:-}" ;;
update) cmd_update "${1:-}" ;; doctor) cmd_doctor "$@" ;;
update) cmd_update "$@" ;;
*) echo "Unknown command: $subcmd"; echo ""; cmd_help; exit 1 ;; *) echo "Unknown command: $subcmd"; echo ""; cmd_help; exit 1 ;;
esac esac
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "open-claude-proxy", "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.", "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": {
+203
View File
@@ -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);
}
+52
View File
@@ -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));
}
+324
View File
@@ -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-<ts>/`);
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 ~510s 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 ~510s 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);
}
}
+257
View File
@@ -574,6 +574,263 @@ test("mergeSystemdEnv is idempotent", () => {
assert.equal(mergeSystemdEnv(r1, SAMPLE_TEMPLATE_SYSTEMD), r1); 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 ── // ── Cleanup ──
closeDb(); closeDb();