Files
ocp/scripts/upgrade.mjs
T
ab03c13332 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>
2026-05-11 06:56:17 +10:00

325 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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);
}
}