mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-19 09:44:07 +00:00
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>
This commit is contained in:
+144
-6
@@ -13,8 +13,8 @@ import { runDoctor } from "./doctor.mjs";
|
|||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync, copyFileSync, mkdirSync } from "node:fs";
|
||||||
import { writeSnapshot } from "./lib/snapshot.mjs";
|
import { writeSnapshot, listSnapshots, readSnapshot } from "./lib/snapshot.mjs";
|
||||||
|
|
||||||
export async function runUpgrade(opts = {}) {
|
export async function runUpgrade(opts = {}) {
|
||||||
const dryRun = !!opts.dryRun;
|
const dryRun = !!opts.dryRun;
|
||||||
@@ -22,6 +22,11 @@ export async function runUpgrade(opts = {}) {
|
|||||||
// yes is reserved for Bundle 3 (fresh-install / rollback interactive gate); not used in upgrade-path here.
|
// yes is reserved for Bundle 3 (fresh-install / rollback interactive gate); not used in upgrade-path here.
|
||||||
const plan = [];
|
const plan = [];
|
||||||
|
|
||||||
|
// --- rollback path (no doctor needed; snapshot is authoritative) ---
|
||||||
|
if (opts.rollback) {
|
||||||
|
return await runRollback(opts);
|
||||||
|
}
|
||||||
|
|
||||||
// --- doctor pre-flight ---
|
// --- doctor pre-flight ---
|
||||||
const doctor = opts.mockDoctor || await runDoctor();
|
const doctor = opts.mockDoctor || await runDoctor();
|
||||||
if (!doctor.ready_to_upgrade && doctor.next_action.kind !== "fresh_install") {
|
if (!doctor.ready_to_upgrade && doctor.next_action.kind !== "fresh_install") {
|
||||||
@@ -64,7 +69,10 @@ export async function runUpgrade(opts = {}) {
|
|||||||
return await runFullUpgrade({ doctor, opts });
|
return await runFullUpgrade({ doctor, opts });
|
||||||
}
|
}
|
||||||
|
|
||||||
// fresh_install + rollback land in Bundle 3.
|
if (kind === "fresh_install") {
|
||||||
|
return await runFreshInstall({ doctor, opts });
|
||||||
|
}
|
||||||
|
|
||||||
throw new Error(`path ${kind} not yet implemented`);
|
throw new Error(`path ${kind} not yet implemented`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,18 +167,148 @@ async function runFullUpgrade({ doctor, opts }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
steps.push({ cmd, status: "fail", error: String(e.message || e) });
|
||||||
|
throw Object.assign(new Error(`fresh_install step failed: ${cmd}`), { 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}`);
|
||||||
|
|
||||||
|
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
|
// CLI entrypoint
|
||||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||||
const dryRun = process.argv.includes("--dry-run");
|
const args = process.argv.slice(2);
|
||||||
const yes = process.argv.includes("--yes");
|
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 {
|
try {
|
||||||
const result = await runUpgrade({ dryRun, yes });
|
const result = await runUpgrade({ dryRun, yes, rollback, list, snapshotPath, target });
|
||||||
if (result.plan) for (const line of result.plan) console.log(line);
|
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.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);
|
process.exit(0);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`✗ ${e.message}`);
|
console.error(`✗ ${e.message}`);
|
||||||
if (e.snapshotPath) console.error(` snapshot: ${e.snapshotPath}`);
|
if (e.snapshotPath) console.error(` snapshot: ${e.snapshotPath}`);
|
||||||
|
if (e.target) console.error(` target: ${e.target}`);
|
||||||
if (e.hint) console.error(` hint: ${e.hint}`);
|
if (e.hint) console.error(` hint: ${e.hint}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -762,6 +762,75 @@ test("upgrade error after snapshot carries snapshotPath + hint", async () => {
|
|||||||
assert.equal(result.executed, true);
|
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();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user