From c12013a09419c84eadcc96e8e9177012bb807c30 Mon Sep 17 00:00:00 2001 From: dtzp555 Date: Mon, 11 May 2026 03:54:15 +1000 Subject: [PATCH] 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 --- scripts/lib/snapshot.mjs | 50 +++++++++++++ scripts/upgrade.mjs | 153 +++++++++++++++++++++++++++++++++++++++ test-features.mjs | 92 +++++++++++++++++++++++ 3 files changed, 295 insertions(+) create mode 100644 scripts/lib/snapshot.mjs create mode 100644 scripts/upgrade.mjs diff --git a/scripts/lib/snapshot.mjs b/scripts/lib/snapshot.mjs new file mode 100644 index 0000000..e5e1eae --- /dev/null +++ b/scripts/lib/snapshot.mjs @@ -0,0 +1,50 @@ +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 { /* swallow */ } + }; + 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..aa37ea9 --- /dev/null +++ b/scripts/upgrade.mjs @@ -0,0 +1,153 @@ +#!/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 } from "node:fs"; +import { writeSnapshot } from "./lib/snapshot.mjs"; + +export async function runUpgrade(opts = {}) { + const dryRun = !!opts.dryRun; + const yes = !!opts.yes; + const plan = []; + + // --- 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 }); + } + + // fresh_install + rollback land in Bundle 3. + throw new Error(`path ${kind} not yet implemented`); +} + +async function runFullUpgrade({ doctor, opts }) { + const phases = []; + const exec = (cmd, label) => { + if (opts.mockExec) { + phases.push({ name: label, cmd, status: "skipped-mock" }); + return ""; + } + const out = execSync(cmd, { stdio: ["pipe", "pipe", "pipe"] }).toString(); + phases.push({ name: label, cmd, status: "ok" }); + return out; + }; + const ocpDir = opts.ocpDir || join(homedir(), "ocp"); + + // 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(); + const 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 1s — expect ~5–10s blip on requests in flight.`); + await new Promise(r => setTimeout(r, 1000)); + } + 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 Object.assign(new Error("post-flight failed; run `ocp update --rollback`"), { phases, snapshotPath }); + } + 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 }; +} + +// CLI entrypoint +if (import.meta.url === `file://${process.argv[1]}`) { + const dryRun = process.argv.includes("--dry-run"); + const yes = process.argv.includes("--yes"); + try { + const result = await runUpgrade({ dryRun, yes }); + 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}` : ""}`); + process.exit(0); + } catch (e) { + console.error(`✗ ${e.message}`); + process.exit(1); + } +} diff --git a/test-features.mjs b/test-features.mjs index a67c3ec..4ccab95 100644 --- a/test-features.mjs +++ b/test-features.mjs @@ -655,6 +655,98 @@ test("doctor empty health body → fix_service (not fix_oauth)", async () => { 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 }); +}); + // ── Cleanup ── closeDb();