mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-21 21:15:09 +00:00
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 onb65201b: 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 onc12013a: 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 on48e9408: 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:
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 ~5–10s blip on requests in flight.`);
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
}
|
||||
if (process.platform === "darwin") {
|
||||
exec(`launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null || true`, "restart");
|
||||
exec(`launchctl bootstrap gui/$(id -u) ${join(homedir(), "Library", "LaunchAgents", "dev.ocp.proxy.plist")}`, "restart");
|
||||
} else {
|
||||
exec(`systemctl --user restart ocp-proxy.service`, "restart");
|
||||
}
|
||||
|
||||
// phase 6: post-flight (10s budget; skipped under mockExec)
|
||||
if (!opts.mockExec) {
|
||||
const port = process.env.CLAUDE_PROXY_PORT || "3478";
|
||||
let ok = false;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
try {
|
||||
const out = execSync(`curl -sf --max-time 2 http://127.0.0.1:${port}/health`).toString();
|
||||
const body = JSON.parse(out);
|
||||
if (body.auth?.ok === true) { ok = true; break; }
|
||||
} catch { /* retry */ }
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
}
|
||||
if (!ok) {
|
||||
phases.push({ name: "post-flight", status: "fail", message: "health did not return auth.ok=true within 10s" });
|
||||
throw new Error("post-flight failed");
|
||||
}
|
||||
execSync(`curl -sf --max-time 3 http://127.0.0.1:${port}/v1/models > /dev/null`);
|
||||
phases.push({ name: "post-flight", status: "ok" });
|
||||
} else {
|
||||
phases.push({ name: "post-flight", status: "skipped-mock" });
|
||||
}
|
||||
|
||||
return { path: "upgrade", executed: true, changed: true, snapshotPath, phases };
|
||||
} catch (err) {
|
||||
if (snapshotPath && !err.snapshotPath) {
|
||||
Object.assign(err, {
|
||||
snapshotPath,
|
||||
phases,
|
||||
hint: "Working tree may be at new version. Run `ocp update --rollback` to restore from snapshot."
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function runFreshInstall({ doctor, opts }) {
|
||||
if (!opts.yes) {
|
||||
throw new Error("fresh_install requires --yes for non-interactive execution (or run interactively and answer y)");
|
||||
}
|
||||
const steps = [];
|
||||
for (const cmd of doctor.next_action.ai_executable) {
|
||||
if (opts.mockExec) {
|
||||
steps.push({ cmd, status: "skipped-mock" });
|
||||
} else {
|
||||
try {
|
||||
execSync(cmd, { stdio: "inherit" });
|
||||
steps.push({ cmd, status: "ok" });
|
||||
} catch (e) {
|
||||
const detail = e.stderr?.toString().trim() || e.message;
|
||||
steps.push({ cmd, status: "fail", error: String(detail) });
|
||||
throw Object.assign(new Error(`fresh_install step failed: ${cmd} — ${detail}`), { steps });
|
||||
}
|
||||
}
|
||||
}
|
||||
return { path: "fresh_install", executed: true, changed: true, steps };
|
||||
}
|
||||
|
||||
async function runRollback(opts) {
|
||||
const homeDir = opts.homeDir || homedir();
|
||||
const snapshots = opts.mockSnapshots ?? listSnapshots(homeDir);
|
||||
|
||||
if (opts.list) {
|
||||
return { path: "rollback-list", snapshots };
|
||||
}
|
||||
if (snapshots.length === 0) {
|
||||
throw new Error("no upgrade snapshots found in ~/.ocp/upgrade-snapshot-*");
|
||||
}
|
||||
|
||||
const target = opts.snapshotPath
|
||||
? snapshots.find(s => s.path === opts.snapshotPath)
|
||||
: snapshots[snapshots.length - 1];
|
||||
if (!target) throw new Error(`snapshot not found: ${opts.snapshotPath} (must be inside ~/.ocp/upgrade-snapshot-*)`);
|
||||
|
||||
const meta = opts.mockSnapshotMeta ?? readSnapshot(target.path);
|
||||
if (!meta.fromCommit) throw new Error(`snapshot ${target.path} has no from-commit.txt`);
|
||||
|
||||
const phases = [];
|
||||
if (opts.dryRun) {
|
||||
return {
|
||||
path: "rollback-dry-run",
|
||||
executed: false,
|
||||
target: target.path,
|
||||
plan: [
|
||||
`git checkout ${meta.fromCommit}`,
|
||||
`cp ${target.path}/plist ~/Library/LaunchAgents/dev.ocp.proxy.plist`,
|
||||
`cp ${target.path}/db.bak ~/.ocp/ocp.db`,
|
||||
`launchctl bootout/bootstrap`,
|
||||
`ocp doctor`
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
if (!opts.yes) throw new Error("rollback requires --yes for non-interactive execution");
|
||||
|
||||
const exec = (cmd, label) => {
|
||||
if (opts.mockExec) {
|
||||
phases.push({ name: label, cmd, status: "skipped-mock" });
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
execSync(cmd, { stdio: ["pipe", "pipe", "pipe"] });
|
||||
phases.push({ name: label, cmd, status: "ok" });
|
||||
} catch (err) {
|
||||
const detail = err.stderr?.toString().trim();
|
||||
phases.push({ name: label, cmd, status: "fail", stderr: detail });
|
||||
throw Object.assign(
|
||||
new Error(`rollback phase ${label} failed: ${detail || err.message}`),
|
||||
{ phases, target: target.path }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const ocpDir = opts.ocpDir || join(homedir(), "ocp");
|
||||
exec(`git -C ${ocpDir} checkout ${meta.fromCommit}`, "git-checkout");
|
||||
|
||||
if (!opts.mockExec) {
|
||||
const tryCopy = (src, dst) => {
|
||||
try {
|
||||
if (existsSync(src)) copyFileSync(src, dst);
|
||||
} catch (err) {
|
||||
console.error(`[rollback] warn: could not restore ${src} → ${dst} (${err.code || err.message})`);
|
||||
}
|
||||
};
|
||||
tryCopy(join(target.path, "plist"), join(homeDir, "Library", "LaunchAgents", "dev.ocp.proxy.plist"));
|
||||
tryCopy(join(target.path, "service"), join(homeDir, ".config", "systemd", "user", "ocp-proxy.service"));
|
||||
tryCopy(join(target.path, "db.bak"), join(homeDir, ".ocp", "ocp.db"));
|
||||
tryCopy(join(target.path, "admin-key"), join(homeDir, ".ocp", "admin-key"));
|
||||
phases.push({ name: "restore-files", status: "ok" });
|
||||
} else {
|
||||
phases.push({ name: "restore-files", status: "skipped-mock" });
|
||||
}
|
||||
|
||||
exec(`npm --prefix ${ocpDir} install --no-audit --no-fund`, "npm-install");
|
||||
|
||||
if (!opts.mockExec) {
|
||||
console.error(`[heads-up] restarting OCP service in 3s — expect ~5–10s blip on requests in flight.`);
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
}
|
||||
if (process.platform === "darwin") {
|
||||
exec(`launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null || true`, "restart");
|
||||
exec(`launchctl bootstrap gui/$(id -u) ${join(homedir(), "Library", "LaunchAgents", "dev.ocp.proxy.plist")}`, "restart");
|
||||
} else {
|
||||
exec(`systemctl --user restart ocp-proxy.service`, "restart");
|
||||
}
|
||||
|
||||
return { path: "rollback", executed: true, changed: true, target: target.path, phases };
|
||||
}
|
||||
|
||||
// CLI entrypoint — use fileURLToPath + realpath to handle symlinked install paths.
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { realpathSync } from "node:fs";
|
||||
function _isMain() {
|
||||
if (!process.argv[1]) return false;
|
||||
try {
|
||||
return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
|
||||
} catch { return false; }
|
||||
}
|
||||
if (_isMain()) {
|
||||
const args = process.argv.slice(2);
|
||||
const dryRun = args.includes("--dry-run");
|
||||
const yes = args.includes("--yes");
|
||||
const rollback = args.includes("--rollback");
|
||||
const list = args.includes("--list");
|
||||
const targetIdx = args.indexOf("--target");
|
||||
const target = targetIdx !== -1 ? args[targetIdx + 1] : undefined;
|
||||
// First non-flag positional after --rollback is the snapshot path
|
||||
let snapshotPath;
|
||||
if (rollback) {
|
||||
const rb = args.indexOf("--rollback");
|
||||
const cand = args[rb + 1];
|
||||
if (cand && !cand.startsWith("--")) snapshotPath = cand;
|
||||
}
|
||||
try {
|
||||
const result = await runUpgrade({ dryRun, yes, rollback, list, snapshotPath, target });
|
||||
if (result.plan) for (const line of result.plan) console.log(line);
|
||||
if (result.phases) for (const p of result.phases) console.log(`[${p.name}] ${p.status}${p.cmd ? `: ${p.cmd}` : ""}`);
|
||||
if (result.steps) for (const s of result.steps) console.log(` ${s.status === "ok" ? "✓" : s.status === "skipped-mock" ? "·" : "✗"} ${s.cmd}`);
|
||||
if (result.snapshots) {
|
||||
console.log(`Found ${result.snapshots.length} snapshots:`);
|
||||
for (const s of result.snapshots) console.log(` ${s.name}`);
|
||||
}
|
||||
process.exit(0);
|
||||
} catch (e) {
|
||||
console.error(`✗ ${e.message}`);
|
||||
if (e.snapshotPath) console.error(` snapshot: ${e.snapshotPath}`);
|
||||
if (e.target) console.error(` target: ${e.target}`);
|
||||
if (e.hint) console.error(` hint: ${e.hint}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user