/**
 * `olam upgrade` — self-upgrade the local Olam dev stack.
 *
 * Runs the full manual sequence in one command:
 *   git fetch + git pull --ff-only
 *   npm install
 *   npm run build
 *   vite build (SPA)
 *   bash build-host-cp.sh (image)
 *   docker compose up -d --force-recreate
 *   wait for /health
 */
import * as fs from 'node:fs';
import * as path from 'node:path';
import { spawnSync } from 'node:child_process';
import ora from 'ora';
import pc from 'picocolors';
import { printError, printSuccess, printInfo, printWarning, printHeader } from '../output.js';
import { buildComposeEnv, captureGhToken, findComposeFile, readAuthSecret, runCompose } from './host-cp.js';
import { acquireLock, releaseLock, formatRefusalMessage, LOCK_FILE_PATH } from './upgrade-lock.js';
import { appendUpgradeLog } from './upgrade-log.js';
import { handleHistory, parseHistoryOpts } from './upgrade-history.js';
import { AuthContainerController } from '@olam/core/src/auth/index.js';
import { loadImageDigests, pullImageWithRetry, realDocker, } from './bootstrap.js';
import { EXIT_BOOTSTRAP_PULL_FAILED, EXIT_GENERIC_ERROR, EXIT_PROTOCOL_MISMATCH, } from '../exit-codes.js';
import { checkProtocolOverlap, parseProtocolVersionsLabel } from '../protocol-version.js';
import { isDevMode, MissingBuildScriptError } from '../install-root.js';
import { readConfig } from '../lib/config.js';
import { runUpgradeKubernetes } from '../lib/upgrade-kubernetes.js';
const AUTH_HEALTH_URL = 'http://127.0.0.1:9999/health';
/**
 * Check whether node_modules is in sync with package-lock.json.
 *
 * npm writes node_modules/.package-lock.json after every successful install.
 * If its mtime is >= package-lock.json's mtime, the last install ran after
 * the last lockfile change — node_modules is current.
 *
 * Returns true  → skip install (node_modules reflects the current lockfile).
 * Returns false → run install (lockfile changed or marker absent).
 */
export function isNodeModulesInSync(cwd) {
    const lockPath = path.join(cwd, 'package-lock.json');
    const markerPath = path.join(cwd, 'node_modules', '.package-lock.json');
    if (!fs.existsSync(lockPath) || !fs.existsSync(markerPath))
        return false;
    try {
        const lockStat = fs.statSync(lockPath);
        const markerStat = fs.statSync(markerPath);
        return markerStat.mtimeMs >= lockStat.mtimeMs;
    }
    catch {
        return false;
    }
}
/**
 * Decide whether the npm install step should be skipped.
 *
 * Priority:
 *   1. --skip-install flag → always skip
 *   2. node_modules in sync with lockfile → skip (log reason)
 *   3. otherwise → run install
 */
export function shouldSkipInstall(opts, cwd) {
    if (opts.skipInstall) {
        return { skip: true, reason: '--skip-install flag set' };
    }
    if (isNodeModulesInSync(cwd)) {
        return { skip: true, reason: 'node_modules in sync with package-lock.json' };
    }
    return { skip: false };
}
/** Check cwd looks like the olam repo root. */
export function validateRepoRoot(cwd) {
    const marker = path.join(cwd, 'packages/host-cp/compose.yaml');
    if (!fs.existsSync(marker)) {
        return {
            ok: false,
            error: `Not an olam repo root (expected ${marker}).\n` +
                'Run `olam upgrade` from the root of your olam checkout.',
        };
    }
    return { ok: true };
}
/** Normalise raw Commander option object into typed opts. */
export function parseUpgradeOpts(raw) {
    const rawN = raw.n;
    const historyN = typeof rawN === 'number'
        ? rawN
        : typeof rawN === 'string'
            ? Number.parseInt(rawN, 10)
            : 10;
    return {
        yes: raw.yes === true,
        skipImage: raw.skipImage === true,
        skipInstall: raw.skipInstall === true,
        branch: raw.branch ?? null,
        rollback: raw.rollback === true,
        force: raw.force === true,
        noCache: raw.noCache === true,
        history: raw.history === true,
        historyN: Number.isFinite(historyN) && historyN > 0 ? historyN : 10,
        historyJson: raw.json === true,
        fromSource: raw.fromSource === true,
        forceRefreshManifests: raw.forceRefreshManifests === true,
        acceptSecurityRegression: raw.acceptSecurityRegression === true,
    };
}
/**
 * Extract the JS bundle hash from index.html.
 * Looks for the canonical Vite output pattern: `/assets/index-<hash>.js`.
 */
export function extractBundleHash(indexHtml) {
    const match = indexHtml.match(/\/assets\/index-([A-Za-z0-9_-]+)\.js/);
    return match ? (match[1] ?? null) : null;
}
function runStep(label, cmd, args, opts = {}) {
    const start = Date.now();
    process.stdout.write(`  ${pc.dim(label.padEnd(34))}`);
    const result = spawnSync(cmd, [...args], {
        encoding: 'utf-8',
        stdio: ['ignore', 'pipe', 'pipe'],
        cwd: opts.cwd ?? process.cwd(),
        env: { ...process.env, ...(opts.env ?? {}) },
    });
    const durationMs = Date.now() - start;
    const ok = result.status === 0 && result.error === undefined;
    const dur = `${(durationMs / 1000).toFixed(1)}s`;
    process.stdout.write(`${ok ? pc.green('✓') : pc.red('✗')} ${dur}\n`);
    return {
        ok,
        stdout: result.stdout ?? '',
        stderr: result.stderr ?? '',
        durationMs,
    };
}
function isGitDirty(cwd) {
    const result = spawnSync('git', ['status', '--porcelain'], {
        encoding: 'utf-8',
        stdio: ['ignore', 'pipe', 'pipe'],
        cwd,
    });
    return (result.stdout ?? '').trim().length > 0;
}
function hasGitUpstream(cwd) {
    const result = spawnSync('git', ['rev-parse', '--abbrev-ref', '@{u}'], {
        encoding: 'utf-8',
        stdio: ['ignore', 'pipe', 'pipe'],
        cwd,
    });
    return result.status === 0;
}
/**
 * Capture HEAD SHA via `git rev-parse HEAD`. Returns null on failure.
 *
 * Phase 2a — A2: must be invoked AFTER `git pull --ff-only` so the captured
 * SHA reflects the state we're upgrading TO (not the pre-pull state). The
 * pull's whole purpose is to advance HEAD; capturing before would refuse the
 * CLI's own pull as drift at A6's swap-boundary check.
 *
 * The returned SHA is sticky for the rest of the run (no per-step re-reads);
 * A6 / B4 re-read once at the swap boundary to detect operator-driven mid-flight
 * `git checkout` / `git reset` that happen DURING the build window.
 */
export function captureHeadSha(cwd) {
    const result = spawnSync('git', ['rev-parse', 'HEAD'], {
        encoding: 'utf-8',
        stdio: ['ignore', 'pipe', 'pipe'],
        cwd,
    });
    if (result.status !== 0)
        return null;
    const sha = (result.stdout ?? '').trim();
    // git rev-parse HEAD returns 40-char lowercase hex; defensive validation.
    if (!/^[0-9a-f]{40}$/.test(sha))
        return null;
    return sha;
}
/** Abbreviate a 40-char SHA to 8 chars for human-readable output. */
export function abbreviateSha(sha) {
    return sha.slice(0, 8);
}
/**
 * Check whether a docker image tag exists locally (Phase 2b — B1).
 *
 * Uses `docker image inspect` which exits 0 only when ALL specified
 * images exist locally. Single-image variant for the rollback pre-flight.
 */
export function imageExists(tag) {
    try {
        const result = spawnSync('docker', ['image', 'inspect', '--format', '{{.Id}}', tag], {
            encoding: 'utf-8',
            stdio: ['ignore', 'pipe', 'ignore'],
        });
        return result.status === 0;
    }
    catch {
        return false;
    }
}
/**
 * Pre-flight check for `olam upgrade --rollback` (Phase 2b — B1).
 *
 * Verifies that all three `:olam-rollback` tags exist. Returns an error
 * message naming the missing image(s) when any are absent — typically
 * the first-upgrade case where no prior canonical existed for one or
 * more components, leaving the rollback set incoherent (see audit A6-001).
 *
 * Returns null when all three are present (rollback is safe to proceed).
 */
export function checkRollbackSetExists(plan) {
    const missing = plan.filter((p) => !imageExists(p.rollback)).map((p) => p.rollback);
    if (missing.length === 0)
        return null;
    return missing.join(', ');
}
/**
 * Run docker create + docker inspect for a single image.
 *
 * Returns ok=true when:
 *   - `docker create <image>` exits 0 (image manifest valid, layers downloadable).
 *   - `docker inspect <image> --format '{{index .Config.Labels "olam.build.sha"}}'`
 *     returns the expected `targetSha`.
 *
 * The container created by `docker create` is removed via `docker rm` even on
 * failure paths (best-effort cleanup; orphans are harmless and pruned by the
 * daemon's GC eventually).
 */
// Cap each docker shellout. The smoke path expects the image to already
// be present locally (post-pull), so docker create / inspect / rm are
// sub-second on the happy path. The cap protects two real-world hangs:
// (1) a wedged dockerd not responding, and (2) a missing image where
// docker tries to pull from Docker Hub and the registry's 404 is slow
// enough to blow vitest's default test budget. 30s is generous for the
// happy path and bounded enough for production callers.
const SMOKE_DOCKER_TIMEOUT_MS = 30_000;
export function smokeImage(image, targetSha) {
    // 1. docker create — allocates the container; doesn't start the entrypoint.
    const createResult = spawnSync('docker', ['create', '--name', `olam-smoke-${Date.now()}`, image], {
        encoding: 'utf-8',
        stdio: ['ignore', 'pipe', 'pipe'],
        timeout: SMOKE_DOCKER_TIMEOUT_MS,
    });
    if (createResult.status !== 0) {
        return {
            image,
            ok: false,
            bakedSha: null,
            error: `docker create failed: ${(createResult.stderr ?? '').trim()}`,
        };
    }
    const containerId = (createResult.stdout ?? '').trim();
    // 2. docker inspect — read the OLAM_BUILD_SHA label.
    const inspectResult = spawnSync('docker', ['inspect', '--format', '{{index .Config.Labels "olam.build.sha"}}', image], {
        encoding: 'utf-8',
        stdio: ['ignore', 'pipe', 'pipe'],
        timeout: SMOKE_DOCKER_TIMEOUT_MS,
    });
    // 3. Cleanup — best-effort; ignore exit code.
    if (containerId.length > 0) {
        spawnSync('docker', ['rm', '-f', containerId], {
            encoding: 'utf-8',
            stdio: ['ignore', 'ignore', 'ignore'],
            timeout: SMOKE_DOCKER_TIMEOUT_MS,
        });
    }
    if (inspectResult.status !== 0) {
        return {
            image,
            ok: false,
            bakedSha: null,
            error: `docker inspect failed: ${(inspectResult.stderr ?? '').trim()}`,
        };
    }
    const bakedSha = (inspectResult.stdout ?? '').trim();
    // Empty output means the label is absent — that's a build-corrupt signal.
    if (bakedSha.length === 0) {
        return {
            image,
            ok: false,
            bakedSha: null,
            error: 'olam.build.sha label is missing or empty',
        };
    }
    // Allow either full 40-char SHA or "unknown" (build-host-cp.sh writes
    // "unknown" when git rev-parse fails). Match against targetSha for
    // success.
    if (bakedSha !== targetSha) {
        return {
            image,
            ok: false,
            bakedSha,
            error: `baked SHA ${abbreviateSha(bakedSha)} ≠ target SHA ${abbreviateSha(targetSha)}`,
        };
    }
    return { image, ok: true, bakedSha };
}
export const PRODUCTION_SWAP_PLAN = [
    { transient: 'olam-auth:olam-next', canonical: 'olam-auth:local', rollback: 'olam-auth:olam-rollback' },
    { transient: 'olam-devbox:olam-next', canonical: 'olam-devbox:latest', rollback: 'olam-devbox:olam-rollback' },
    { transient: 'olam-host-cp:olam-next', canonical: 'olam-host-cp:latest', rollback: 'olam-host-cp:olam-rollback' },
    { transient: 'olam-mcp-auth:olam-next', canonical: 'olam-mcp-auth:local', rollback: 'olam-mcp-auth:olam-rollback' },
    { transient: 'olam-kg-service:olam-next', canonical: 'olam-kg-service:local', rollback: 'olam-kg-service:olam-rollback' },
];
/**
 * Run `docker tag <source> <dest>`. Returns ok=false with stderr trimmed
 * on failure (e.g. source image absent). No retry — caller decides.
 *
 * Per audit A6-003: spawnSync may throw synchronously under fork pressure
 * (libuv clone(2) failures). The try/catch ensures performAtomicSwap can
 * always proceed to its summary phase — a thrown exception escaping
 * dockerTag would leak the upgrade lock and produce no SwapResult, which
 * confuses both the operator AND Phase 2b's --rollback recovery path.
 */
export function dockerTag(source, dest) {
    try {
        const result = spawnSync('docker', ['tag', source, dest], {
            encoding: 'utf-8',
            stdio: ['ignore', 'ignore', 'pipe'],
        });
        if (result.status === 0 && result.error === undefined)
            return { ok: true };
        return {
            ok: false,
            error: (result.stderr ?? '').trim() || result.error?.message || 'docker tag failed',
        };
    }
    catch (err) {
        return {
            ok: false,
            error: err instanceof Error ? `spawnSync threw: ${err.message}` : 'spawnSync threw',
        };
    }
}
/**
 * Atomic-ish 3-image set swap.
 *
 * Six sequential `docker tag` ops in two phases:
 *
 *   Phase 1 (rollback-save):
 *     1. canonical → :olam-rollback (image 1)
 *     2. canonical → :olam-rollback (image 2)
 *     3. canonical → :olam-rollback (image 3)
 *
 *   Phase 2 (canonical-advance):
 *     4. :olam-next → canonical (image 1)
 *     5. :olam-next → canonical (image 2)
 *     6. :olam-next → canonical (image 3)
 *
 * Invariants:
 *
 * - **First-upgrade tolerance**: any of steps 1-3 may fail with "no such
 *   image" if the operator has never had a canonical-tagged image. Those
 *   failures are NON-FATAL (recorded in rollbackError but not aborted) —
 *   `:olam-rollback` simply doesn't exist for that image; Phase 2b's
 *   `--rollback` pre-flight detects this and refuses.
 *
 * - **Canonical-advance fatality**: any failure in steps 4-6 is fatal.
 *   The swap is partially advanced; canonical tags are now mixed (some
 *   at SHA-Y, some at SHA-X). Operator runs `olam upgrade --rollback`
 *   (Phase 2b) which uses the FULL `:olam-rollback` set written in Phase 1
 *   to restore coherent prior state.
 *
 * - **SIGKILL recovery**: if killed during Phase 1, partial `:olam-rollback`
 *   exists but canonical is intact — operator's next `olam upgrade` succeeds
 *   normally (the partial `:olam-rollback` is overwritten by the next
 *   successful run). If killed during Phase 2, canonical is mixed —
 *   operator must `olam upgrade --rollback` to recover.
 *
 * The "atomic-ish" qualifier: `docker tag` is per-image atomic (POSIX rename
 * of a symbolic name), but the SET of 3 canonical tags is updated sequentially
 * across ~1s wall-clock. Sub-second window is acceptable for solo-dev/dogfood
 * per the plan's local-dev/dogfood priority axis.
 */
export function performAtomicSwap(plan) {
    const steps = plan.map((p) => ({
        image: p.canonical,
        rollbackSaved: false,
        canonicalAdvanced: false,
    }));
    // Phase 1: preserve previous-good as :olam-rollback (steps 1-3).
    // Non-fatal failures: missing canonical (first-upgrade) is acceptable.
    for (let i = 0; i < plan.length; i++) {
        const p = plan[i];
        const r = dockerTag(p.canonical, p.rollback);
        steps[i] = {
            ...steps[i],
            rollbackSaved: r.ok,
            ...(r.error !== undefined && { rollbackError: r.error }),
        };
    }
    // Phase 2: advance canonical to :olam-next (steps 4-6).
    // FATAL failure: canonical is mixed. Recovery via `olam upgrade --rollback`.
    let advanceFailed = false;
    let firstFailureIdx = -1;
    for (let i = 0; i < plan.length; i++) {
        const p = plan[i];
        if (advanceFailed) {
            // Skip remaining advances after first failure — leaves canonical mixed
            // but caller's recovery path is `olam upgrade --rollback`, not
            // continue-and-hope.
            steps[i] = { ...steps[i], canonicalAdvanced: false };
            continue;
        }
        const r = dockerTag(p.transient, p.canonical);
        steps[i] = {
            ...steps[i],
            canonicalAdvanced: r.ok,
            ...(r.error !== undefined && { canonicalError: r.error }),
        };
        if (!r.ok) {
            advanceFailed = true;
            firstFailureIdx = i;
        }
    }
    const allAdvanced = steps.every((s) => s.canonicalAdvanced);
    const noneAdvanced = steps.every((s) => !s.canonicalAdvanced);
    const partialAdvance = !allAdvanced && !noneAdvanced;
    const rollbackCoherent = steps.every((s) => s.rollbackSaved);
    let summary;
    if (allAdvanced) {
        const rollbacks = steps.filter((s) => s.rollbackSaved).length;
        summary = `Swapped ${plan.length} canonical tags; ${rollbacks} :olam-rollback preserved`;
    }
    else if (partialAdvance) {
        const advanced = steps.filter((s) => s.canonicalAdvanced).length;
        const failedStep = steps[firstFailureIdx];
        // Audit A6-001: only recommend --rollback when the rollback set is COHERENT.
        // Otherwise the operator would either partially restore or hit Phase 2b's
        // pre-flight refusal — misleading either way.
        const recoveryHint = rollbackCoherent
            ? `Run \`olam upgrade --rollback\` to restore coherent prior state.`
            : `Rollback set INCOHERENT (${steps.filter((s) => s.rollbackSaved).length} of ${plan.length} :olam-rollback tags written). Manual recovery required: inspect images and re-tag canonical from a known-good source.`;
        summary = `PARTIAL: ${advanced} of ${plan.length} canonical tags advanced before failure on ${failedStep?.image}: ${failedStep?.canonicalError}. ${recoveryHint}`;
    }
    else {
        const failedStep = steps[firstFailureIdx];
        summary = `Failed on first canonical-advance (${failedStep?.image}): ${failedStep?.canonicalError}. Canonical tags untouched.`;
    }
    return {
        ok: allAdvanced,
        steps,
        partialAdvance,
        rollbackCoherent,
        summary,
    };
}
/**
 * Inverse of performAtomicSwap — restore canonical from :olam-rollback
 * (Phase 2b — B1). Three sequential `docker tag` ops:
 *
 *   docker tag olam-auth:olam-rollback     olam-auth:local
 *   docker tag olam-devbox:olam-rollback   olam-devbox:latest
 *   docker tag olam-host-cp:olam-rollback  olam-host-cp:latest
 *
 * No two-phase ceremony — the source `:olam-rollback` set is already a
 * coherent prior-good captured by a previous successful `olam upgrade`,
 * so we don't need to preserve current canonical (it's known-broken,
 * which is why we're rolling back).
 *
 * Caller MUST pre-flight via `checkRollbackSetExists()` before invoking.
 * Behavior on missing source is per-image fatal (returns ok=false +
 * error naming the missing image).
 */
export function performRollbackSwap(plan) {
    const results = [];
    for (const p of plan) {
        const r = dockerTag(p.rollback, p.canonical);
        results.push({
            image: p.canonical,
            ok: r.ok,
            ...(r.error !== undefined && { error: r.error }),
        });
    }
    const allOk = results.every((r) => r.ok);
    const summary = allOk
        ? `Rolled back ${plan.length} canonical tags from :olam-rollback`
        : `PARTIAL rollback: ${results.filter((r) => r.ok).length} of ${plan.length} succeeded; failed: ${results.filter((r) => !r.ok).map((r) => r.image).join(', ')}`;
    return { ok: allOk, results, summary };
}
async function confirm(message) {
    if (!process.stdin.isTTY)
        return true;
    const { createInterface } = await import('node:readline');
    const rl = createInterface({ input: process.stdin, output: process.stdout });
    return new Promise((resolve) => {
        rl.question(`${message} [y/N] `, (answer) => {
            rl.close();
            resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
        });
    });
}
async function waitForHealth(timeoutMs = 10_000) {
    const deadline = Date.now() + timeoutMs;
    while (Date.now() < deadline) {
        try {
            const res = await fetch('http://127.0.0.1:19000/health', {
                signal: AbortSignal.timeout(2000),
            });
            if (res.ok)
                return true;
        }
        catch {
            // not up yet
        }
        await new Promise((r) => setTimeout(r, 500));
    }
    return false;
}
/**
 * Poll /api/version/status until all three component `.running` SHAs match
 * `targetSha`, or until `timeoutMs` elapses. Returns the final snapshot.
 *
 * Phase 2a — A8: this is the success criterion for the entire upgrade.
 * After A6's atomic swap + A7's recreate, the new images should report
 * the new SHA via OLAM_BUILD_SHA baked at build time. Round-trip through
 * Phase 1's detection path closes the loop.
 *
 * Returns:
 *   - { matched: true, snapshot } when all three SHAs equal targetSha within timeout.
 *   - { matched: false, snapshot } when timeout expires; caller decides
 *     whether to warn (recreate succeeded but propagation slow) or error.
 *   - { matched: false, snapshot: null } when /api/version/status is
 *     unreachable for the entire timeout (host-cp didn't come back up).
 */
export async function waitForVersionMatch(targetSha, timeoutMs = 90_000, pollIntervalMs = 1_000) {
    const deadline = Date.now() + timeoutMs;
    let lastSnapshot = null;
    while (Date.now() < deadline) {
        try {
            const res = await fetch('http://127.0.0.1:19000/api/version/status', {
                signal: AbortSignal.timeout(2_000),
            });
            if (res.ok) {
                const snapshot = (await res.json());
                lastSnapshot = snapshot;
                if (snapshot.hostCp?.running === targetSha &&
                    snapshot.authService?.running === targetSha &&
                    snapshot.devbox?.running === targetSha) {
                    return { matched: true, snapshot };
                }
            }
        }
        catch {
            // host-cp not yet ready or transient network blip
        }
        await new Promise((r) => setTimeout(r, pollIntervalMs));
    }
    return { matched: false, snapshot: lastSnapshot };
}
/**
 * Format a version-snapshot mismatch into a readable per-component diff
 * (Phase 2a — A8). Used in the timeout-warn path so the operator sees
 * which component is lagging.
 */
export function formatVersionMismatch(targetSha, snapshot) {
    if (!snapshot)
        return 'No /api/version/status response received within timeout.';
    const lines = [];
    for (const [name, comp] of [
        ['host-cp', snapshot.hostCp],
        ['auth-service', snapshot.authService],
        ['devbox', snapshot.devbox],
    ]) {
        const match = comp?.running === targetSha;
        lines.push(`  ${match ? '✓' : '✗'} ${name}: running=${abbreviateSha(comp?.running ?? 'unknown')} target=${abbreviateSha(targetSha)}`);
    }
    return lines.join('\n');
}
/**
 * Block until auth-service /health responds or timeout expires (Phase 2a — A7).
 *
 * Mirrors auth-upgrade.ts's waitForAuthHealth — kept inline to avoid a
 * circular dep. When auth-upgrade.ts is refactored later (Phase G+),
 * extract a shared helper.
 */
/**
 * Default 60s. The auth-service container's first /health response after
 * a recreate can lag 10–30s on a cold sqlite migration + Express handler
 * boot, observed during PR #311's upgrade-trigger dogfood. 15s (the
 * previous default) timed out the upgrader pipeline at the recreate-
 * verification step on cold-cache machines. 60s gives the cold-boot path
 * comfortable headroom; the warn message still tells the operator what
 * to inspect if it actually exceeds 60s.
 */
const AUTH_HEALTH_TIMEOUT_MS = 60_000;
async function waitForAuthHealthLocal(timeoutMs = AUTH_HEALTH_TIMEOUT_MS) {
    const deadline = Date.now() + timeoutMs;
    while (Date.now() < deadline) {
        try {
            const res = await fetch(AUTH_HEALTH_URL, { signal: AbortSignal.timeout(2000) });
            if (res.ok)
                return true;
        }
        catch {
            // not up yet
        }
        await new Promise((r) => setTimeout(r, 500));
    }
    return false;
}
/**
 * Recreate the auth-service container against the freshly-tagged
 * `olam-auth:local` image (Phase 2a — A7).
 *
 * Mirrors auth-upgrade.ts:237-275: docker stop → docker rm →
 * AuthContainerController.start(). Auth-service is NOT in compose.yaml
 * (it runs via the controller's docker run with secret injection), so
 * we cannot reuse `docker compose --force-recreate auth-service` —
 * compose would fail with "no such service: auth-service" (verified
 * during pass-2 review F2 audit).
 *
 * Errors:
 *   - docker stop / docker rm errors are swallowed (container may not
 *     be running or may not exist; both are recoverable states).
 *   - AuthContainerController.start() throws on real failures (image
 *     missing, port conflict, secret missing); caller catches and
 *     reports.
 *
 * Returns true on successful recreate + /health response within 15s.
 */
async function recreateAuthService() {
    const start = Date.now();
    try {
        // Step 1: stop + remove. Errors swallowed — container may be absent/stopped.
        spawnSync('docker', ['stop', 'olam-auth'], {
            encoding: 'utf-8',
            stdio: ['ignore', 'ignore', 'ignore'],
        });
        spawnSync('docker', ['rm', 'olam-auth'], {
            encoding: 'utf-8',
            stdio: ['ignore', 'ignore', 'ignore'],
        });
        // Step 2: start the new container via the controller (handles secret
        // injection; reads OLAM_AUTH_SECRET from env or ~/.olam/auth-secret).
        const controller = new AuthContainerController();
        controller.start();
        // Step 3: wait for /health.
        const healthy = await waitForAuthHealthLocal();
        const durationMs = Date.now() - start;
        if (!healthy) {
            return {
                ok: false,
                durationMs,
                error: `auth-service /health did not respond within ${AUTH_HEALTH_TIMEOUT_MS / 1000}s after recreate`,
            };
        }
        return { ok: true, durationMs };
    }
    catch (err) {
        return {
            ok: false,
            durationMs: Date.now() - start,
            error: err instanceof Error ? err.message : String(err),
        };
    }
}
function readBundleHash(cwd) {
    const indexPath = path.join(cwd, 'packages/control-plane/public/index.html');
    if (!fs.existsSync(indexPath))
        return null;
    return extractBundleHash(fs.readFileSync(indexPath, 'utf-8'));
}
export async function runUpgradePullByDigest(deps = {}) {
    const docker = deps.docker ?? realDocker;
    const digests = deps.digests ?? loadImageDigests();
    const registry = deps.registry ?? digests.$registry ?? 'ghcr.io/pleri';
    printHeader('olam upgrade (pull-by-digest)');
    // Step 1 — daemon smoke. Re-uses bootstrap's docker.info().
    const infoSpinner = ora('Checking docker daemon').start();
    const info = await docker.info();
    if (info.exitCode !== 0) {
        infoSpinner.fail('docker daemon not reachable');
        process.stderr.write(`${pc.red('error')} docker info exited with ${info.exitCode}.\n` +
            '  Ensure Docker Desktop / Colima / Rancher is running, then retry.\n');
        return { exitCode: EXIT_GENERIC_ERROR, summary: 'docker daemon not reachable' };
    }
    infoSpinner.succeed('docker daemon reachable');
    // Step 2 — parallel pull images by digest. mcp-auth is conditional on
    // the digest being present in the manifest (older tarballs don't have
    // it; pre-mcp-auth releases shipped 3 images, post-mcp-auth ship 4).
    const hasMcpAuthDigest = typeof digests['mcp-auth'] === 'string' && digests['mcp-auth'].length > 0;
    const hasKgServiceDigest = typeof digests['kg-service'] === 'string' && digests['kg-service'].length > 0;
    // devbox-slim Phase A: pull :base alongside :browser-aliased :latest so
    // the new DEFAULT_DEVBOX_IMAGE = olam-devbox:base resolves locally on a
    // fresh `olam upgrade -y` host. Older releases without devbox-base in
    // image-digests.json still upgrade cleanly — devbox-base is gated on
    // its presence the same way mcp-auth is.
    const hasDevboxBaseDigest = typeof digests['devbox-base'] === 'string' && digests['devbox-base'].length > 0;
    const baseRefs = [
        { name: 'host-cp', ref: `${registry}/olam-host-cp@${digests['host-cp']}` },
        { name: 'auth', ref: `${registry}/olam-auth@${digests.auth}` },
        { name: 'devbox', ref: `${registry}/olam-devbox@${digests.devbox}` },
    ];
    if (hasDevboxBaseDigest) {
        baseRefs.push({
            name: 'devbox-base',
            ref: `${registry}/olam-devbox@${digests['devbox-base']}`,
        });
    }
    if (hasMcpAuthDigest) {
        baseRefs.push({
            name: 'mcp-auth',
            ref: `${registry}/olam-mcp-auth@${digests['mcp-auth']}`,
        });
    }
    // kg-service joined the publish DAG in v0.1.139 (release.yml fix #565).
    // Gated on digest presence so older tarballs (≤ v0.1.138) without the
    // entry still upgrade cleanly — same pattern as mcp-auth + devbox-base.
    if (hasKgServiceDigest) {
        baseRefs.push({
            name: 'kg-service',
            ref: `${registry}/olam-kg-service@${digests['kg-service']}`,
        });
    }
    const imageRefs = baseRefs;
    const pullSpinner = ora(`Pulling images (${imageRefs.length} parallel)`).start();
    const pullStart = Date.now();
    const pullResults = await Promise.all(imageRefs.map(async ({ name, ref }) => ({
        name,
        ref,
        result: await pullImageWithRetry(ref, docker),
    })));
    const pullElapsed = ((Date.now() - pullStart) / 1000).toFixed(1);
    const failed = pullResults.filter((r) => r.result.exitCode !== 0);
    if (failed.length > 0) {
        pullSpinner.fail(`Pull failed for ${failed.map((r) => r.name).join(', ')}`);
        for (const f of failed) {
            process.stderr.write(`  ${pc.red(f.name)} (${f.ref}):\n` +
                `    exit=${f.result.exitCode}\n` +
                `    stderr: ${f.result.stderr.split('\n')[0] ?? '(empty)'}\n`);
        }
        process.stderr.write('\n  Remedy: re-run after resolving network / GHCR availability.\n' +
            '  For monorepo dev, `OLAM_DEV=1 olam upgrade --from-source` builds locally.\n');
        return {
            exitCode: EXIT_BOOTSTRAP_PULL_FAILED,
            summary: `pull failed: ${failed.map((r) => r.name).join(', ')}`,
        };
    }
    pullSpinner.succeed(`Pulled ${imageRefs.length} images in ${pullElapsed}s`);
    // Step 3 — protocol-version handshake on every image.
    const handshakeSpinner = ora('Verifying olam.protocol.versions handshake').start();
    for (const { name, ref } of imageRefs) {
        const inspect = await docker.inspectLabel(ref, 'olam.protocol.versions');
        if (inspect.exitCode !== 0) {
            handshakeSpinner.fail(`Could not inspect ${name}`);
            process.stderr.write(`${pc.red('error')} docker inspect ${ref} failed: ${inspect.stderr}\n`);
            return { exitCode: EXIT_GENERIC_ERROR, summary: `inspect failed: ${name}` };
        }
        const labelValue = (inspect.stdout || '').trim();
        const versions = parseProtocolVersionsLabel(labelValue === '<no value>' ? '' : labelValue);
        const decision = checkProtocolOverlap(versions);
        if (!decision.compatible) {
            handshakeSpinner.fail(`Protocol mismatch on ${name}`);
            process.stderr.write(`${pc.red('error')} ${decision.remedy}\n`);
            return {
                exitCode: EXIT_PROTOCOL_MISMATCH,
                summary: `protocol mismatch: ${name}`,
            };
        }
    }
    handshakeSpinner.succeed(`Protocol handshake passed (all ${imageRefs.length} images)`);
    // Step 4 — re-tag pulled-by-digest images to the canonical local tags
    // that compose.yaml + AuthContainerController expect. Pulling by
    // digest only writes a content-addressed reference; compose / the
    // controller key on `olam-host-cp:latest` and `olam-auth:local`
    // respectively, so we tag explicitly.
    // Compose.yaml uses `image: ghcr.io/pleri/olam-host-cp:latest` (registry-
    // prefixed), NOT bare `olam-host-cp:latest`. Docker treats the two refs as
    // distinct local tags. Pre-fix, the upgrade retagged ONLY the bare-name
    // tag, so the registry-prefixed tag (used by compose) kept pointing at
    // whatever the previous pull put there — leaving compose's recreate to
    // pick the OLD image despite the new one being in cache. End state:
    // `olam upgrade` reports "Re-tagged 3 images to canonical refs" + "host-cp
    // recreated" + "Upgrade complete (running …NEW…)" but the actually-
    // running container stayed on the old digest. Repro: compare
    // `docker inspect <ghcr-prefixed>:latest` vs `docker inspect <bare>:latest`
    // mid-upgrade — they diverge.
    // Fix: retag BOTH names. The bare-name tag is kept for legacy callers
    // (e.g. `docker run olam-host-cp:latest` in scripts); the registry-
    // prefixed tag is what compose + AuthContainerController actually use.
    // Look up by NAME rather than positional index — devbox-slim Phase A
    // inserted devbox-base into the array, so positional indices for
    // mcp-auth shifted. Name-based lookup is order-independent.
    const refByName = (n) => {
        const found = imageRefs.find((r) => r.name === n);
        if (!found)
            throw new Error(`upgrade: missing imageRefs entry for "${n}"`);
        return found.ref;
    };
    const tagPlanBase = [
        { from: refByName('host-cp'), to: 'olam-host-cp:latest', name: 'host-cp (bare)' },
        { from: refByName('host-cp'), to: `${registry}/olam-host-cp:latest`, name: 'host-cp (registry)' },
        { from: refByName('auth'), to: 'olam-auth:local', name: 'auth (bare)' },
        { from: refByName('auth'), to: `${registry}/olam-auth:latest`, name: 'auth (registry)' },
        { from: refByName('devbox'), to: 'olam-devbox:latest', name: 'devbox (bare)' },
        { from: refByName('devbox'), to: `${registry}/olam-devbox:latest`, name: 'devbox (registry)' },
    ];
    // devbox-slim Phase A: tag the :base variant so DEFAULT_DEVBOX_IMAGE
    // = olam-devbox:base resolves locally after upgrade. Tag both bare
    // and registry-prefixed for parity with :latest above.
    if (hasDevboxBaseDigest) {
        tagPlanBase.push({ from: refByName('devbox-base'), to: 'olam-devbox:base', name: 'devbox-base (bare)' }, { from: refByName('devbox-base'), to: `${registry}/olam-devbox:base`, name: 'devbox-base (registry)' });
    }
    if (hasMcpAuthDigest) {
        tagPlanBase.push({ from: refByName('mcp-auth'), to: 'olam-mcp-auth:local', name: 'mcp-auth (bare)' }, { from: refByName('mcp-auth'), to: `${registry}/olam-mcp-auth:latest`, name: 'mcp-auth (registry)' });
    }
    // kg-service: canonical bare tag `:local` matches
    // KG_SERVICE_LOCAL_TAG in kg-service-container.ts. Operators running
    // `olam services up` after upgrade pick this up.
    if (hasKgServiceDigest) {
        tagPlanBase.push({ from: refByName('kg-service'), to: 'olam-kg-service:local', name: 'kg-service (bare)' }, { from: refByName('kg-service'), to: `${registry}/olam-kg-service:latest`, name: 'kg-service (registry)' });
    }
    const tagPlan = tagPlanBase;
    const tagSpinner = ora('Tagging digests → canonical local refs').start();
    const tagger = deps.tagImpl ?? dockerTag;
    for (const t of tagPlan) {
        const r = tagger(t.from, t.to);
        if (!r.ok) {
            tagSpinner.fail(`docker tag failed for ${t.name}`);
            process.stderr.write(`${pc.red('error')} ${r.error ?? 'docker tag failed'}\n`);
            return { exitCode: EXIT_GENERIC_ERROR, summary: `tag failed: ${t.name}` };
        }
    }
    tagSpinner.succeed(`Re-tagged ${tagPlan.length} image refs to canonical names`);
    // Step 5 — recreate the docker-socket-proxy + host-cp via docker
    // compose. Auth-service is NOT in compose.yaml (runs via
    // AuthContainerController.start), so we recreate it separately in
    // step 6.
    const composeFile = deps.composeFile ?? findComposeFile();
    const authSecret = deps.authSecret ?? readAuthSecret();
    const composeRunner = deps.runComposeImpl ?? runCompose;
    // Step 5a — recreate docker-socket-proxy FIRST. Prior to this
    // change we skipped the sidecar entirely (`--no-deps host-cp` only),
    // on the assumption "proxy doesn't change between releases." That
    // broke when PR #460 shipped a new compose.yaml that enabled
    // IMAGES=1 on the proxy: `olam upgrade -y` pulled new images and
    // recreated host-cp, but the running proxy stayed on the old
    // (IMAGES=0) config — so the new /api/version/status comparator's
    // GET /images/<ref>/json calls stayed denied until the operator
    // manually `docker compose up -d --force-recreate
    // docker-socket-proxy`. Any future compose.yaml tweak to the
    // sidecar would silently no-op the same way.
    //
    // The original PR #311 concern (recreating the proxy collides with
    // host-cp's upgrade-trigger spawning the upgrader inside the same
    // compose project) doesn't apply here: we recreate the proxy
    // BEFORE host-cp, and `--no-deps` keeps compose from cascading.
    // Brief proxy disconnect (~5s) is invisible to the operator CLI
    // and tolerated by host-cp's docker client (retries on next poll).
    const proxySpinner = ora('docker compose recreate docker-socket-proxy').start();
    const proxyResult = composeRunner(['up', '-d', '--force-recreate', '--no-deps', 'docker-socket-proxy'], composeFile, buildComposeEnv(authSecret, captureGhToken()));
    if (!proxyResult.ok) {
        proxySpinner.fail('docker-socket-proxy recreate failed');
        process.stderr.write(`${pc.red('error')} docker compose up --force-recreate docker-socket-proxy failed:\n` +
            `  ${proxyResult.stderr.split('\n').slice(0, 3).join('\n  ')}\n`);
        return { exitCode: EXIT_GENERIC_ERROR, summary: 'socket-proxy recreate failed' };
    }
    proxySpinner.succeed('docker-socket-proxy recreated');
    // Step 5b-pre — clean up pre-rename name-collision orphan. PR #596/#597
    // renamed the compose service host-cp → olam-host-cp; the container_name
    // was already olam-host-cp, so a pre-existing container from before the
    // rename carries labels `com.docker.compose.service=host-cp` and
    // `com.docker.compose.project=host-cp` AND occupies the `olam-host-cp`
    // container name the new service expects. `--force-recreate` cannot
    // reclaim a container whose service label belongs to a different
    // (now non-existent) compose service, so the recreate aborts with
    //   "Conflict. The container name "/olam-host-cp" is already in use".
    // Force-remove the orphan first; no-op when no orphan exists or the
    // container already belongs to the new service.
    const inspectContainer = deps.inspectContainerImpl ?? defaultInspectContainerLabels;
    const removeContainer = deps.removeContainerImpl ?? defaultRemoveContainer;
    const orphanCheck = inspectContainer('olam-host-cp');
    if (!orphanCheck.ok) {
        process.stderr.write(`${pc.red('error')} docker inspect olam-host-cp failed:\n` +
            `  ${orphanCheck.stderr.split('\n').slice(0, 3).join('\n  ')}\n`);
        return { exitCode: EXIT_GENERIC_ERROR, summary: 'orphan inspect failed' };
    }
    if (orphanCheck.exists && orphanCheck.service !== 'olam-host-cp') {
        process.stderr.write(`${pc.yellow('info')} Removing pre-rename orphan container olam-host-cp ` +
            `(old project=${orphanCheck.project || '<none>'}, old service=${orphanCheck.service || '<none>'})\n`);
        const rm = removeContainer('olam-host-cp');
        if (!rm.ok) {
            process.stderr.write(`${pc.red('error')} docker rm -f olam-host-cp failed:\n` +
                `  ${rm.stderr.split('\n').slice(0, 3).join('\n  ')}\n`);
            return { exitCode: EXIT_GENERIC_ERROR, summary: 'orphan cleanup failed' };
        }
    }
    // Step 5b — recreate host-cp. `--no-deps` keeps compose from
    // touching the proxy AGAIN (we just did it in step 5a) and from
    // touching auth-service (which isn't a compose service).
    const composeSpinner = ora('docker compose recreate olam-host-cp').start();
    const composeResult = composeRunner(['up', '-d', '--force-recreate', '--no-deps', 'olam-host-cp'], composeFile, buildComposeEnv(authSecret, captureGhToken()));
    if (!composeResult.ok) {
        composeSpinner.fail('compose recreate failed');
        process.stderr.write(`${pc.red('error')} docker compose up --force-recreate olam-host-cp failed:\n` +
            `  ${composeResult.stderr.split('\n').slice(0, 3).join('\n  ')}\n`);
        return { exitCode: EXIT_GENERIC_ERROR, summary: 'compose recreate failed' };
    }
    composeSpinner.succeed('olam-host-cp recreated');
    // Step 6 — recreate auth-service.
    const authSpinner = ora('recreate auth-service').start();
    const authRecreate = deps.recreateAuth ?? defaultRecreateAuthForUpgrade;
    const authResult = await authRecreate();
    if (!authResult.ok) {
        authSpinner.fail('auth-service recreate failed');
        process.stderr.write(`${pc.red('error')} ${authResult.error ?? 'unknown failure'}\n`);
        return { exitCode: EXIT_GENERIC_ERROR, summary: 'auth recreate failed' };
    }
    authSpinner.succeed('auth-service running on new image');
    // Step 7 — recreate mcp-auth-service when its digest is in the manifest.
    // Older tarballs (pre-services-bringup) skip this step entirely.
    if (hasMcpAuthDigest) {
        const mcpSpinner = ora('recreate mcp-auth-service').start();
        const mcpRecreate = deps.recreateMcpAuth ?? defaultRecreateMcpAuthForUpgrade;
        const mcpResult = await mcpRecreate();
        if (!mcpResult.ok) {
            mcpSpinner.fail('mcp-auth-service recreate failed');
            process.stderr.write(`${pc.red('error')} ${mcpResult.error ?? 'unknown failure'}\n`);
            return { exitCode: EXIT_GENERIC_ERROR, summary: 'mcp-auth recreate failed' };
        }
        mcpSpinner.succeed('mcp-auth-service running on new image');
    }
    printSuccess('Upgrade complete (pull-by-digest)');
    printInfo('host-cp', `running (${digests['host-cp'].slice(0, 19)}…)`);
    printInfo('auth', `running (${digests.auth.slice(0, 19)}…)`);
    printInfo('devbox', `pulled (${digests.devbox.slice(0, 19)}…)`);
    if (hasMcpAuthDigest) {
        printInfo('mcp-auth', `running (${digests['mcp-auth'].slice(0, 19)}…)`);
    }
    return { exitCode: 0, summary: 'stack upgraded' };
}
/**
 * Default mcp-auth-service recreate driver for the pull-by-digest upgrade
 * path. Stops the existing container, removes it, then starts a fresh one
 * via the new image. Mirrors `defaultRecreateAuthForUpgrade` shape.
 *
 * Implementation lives in @olam/core's container controllers — but for the
 * upgrade path we just docker-rm + docker-run because the controller
 * encapsulates start-with-image semantics that match what we want here.
 */
async function defaultRecreateMcpAuthForUpgrade() {
    // Reuse the existing recreateAuthService logic as a template — the
    // mcp-auth service follows the same lifecycle (atomic-rename store,
    // health-checked start). When the McpAuthContainerController lands as
    // a sibling, this function should switch to controller.start().
    try {
        const { spawnSync: ss } = await import('node:child_process');
        ss('docker', ['stop', 'olam-mcp-auth'], { stdio: 'ignore' });
        ss('docker', ['rm', '-f', 'olam-mcp-auth'], { stdio: 'ignore' });
        const startResult = ss('docker', [
            'run', '-d', '--name', 'olam-mcp-auth',
            '-p', '9998:9998',
            '-v', 'olam-mcp-auth-data:/mcp-auth-data',
            '--restart', 'unless-stopped',
            'olam-mcp-auth:local',
        ], { stdio: 'pipe' });
        if (startResult.status !== 0) {
            return { ok: false, error: `docker run failed: ${startResult.stderr?.toString() ?? 'unknown'}` };
        }
        return { ok: true };
    }
    catch (err) {
        return { ok: false, error: err instanceof Error ? err.message : String(err) };
    }
}
/**
 * Default auth-recreate driver for the pull-by-digest upgrade path.
 * Mirrors recreateAuthService() defined later in this file but trimmed
 * to a UpgradePullDeps.recreateAuth-shaped result.
 */
async function defaultRecreateAuthForUpgrade() {
    try {
        spawnSync('docker', ['stop', 'olam-auth'], {
            encoding: 'utf-8',
            stdio: ['ignore', 'ignore', 'ignore'],
        });
        spawnSync('docker', ['rm', 'olam-auth'], {
            encoding: 'utf-8',
            stdio: ['ignore', 'ignore', 'ignore'],
        });
        const controller = new AuthContainerController();
        controller.start();
        const healthy = await waitForAuthHealthLocal();
        if (!healthy) {
            return {
                ok: false,
                error: `auth-service /health did not respond within ${AUTH_HEALTH_TIMEOUT_MS / 1000}s`,
            };
        }
        return { ok: true };
    }
    catch (err) {
        return {
            ok: false,
            error: err instanceof Error ? err.message : String(err),
        };
    }
}
/**
 * Default `docker inspect` driver for the Step 5b-pre orphan guard.
 * Reads the container's `com.docker.compose.project` and
 * `com.docker.compose.service` labels in a single inspect call. Distinguishes
 * "container does not exist" (treated as ok+exists=false) from real
 * inspect failures (daemon down, permission errors — propagated as ok=false).
 */
function defaultInspectContainerLabels(containerName) {
    const result = spawnSync('docker', [
        'inspect',
        '--format',
        '{{index .Config.Labels "com.docker.compose.project"}}|{{index .Config.Labels "com.docker.compose.service"}}',
        containerName,
    ], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
    const stderr = result.stderr ?? '';
    if (result.status === 0) {
        const [project = '', service = ''] = (result.stdout ?? '').trim().split('|');
        return { ok: true, exists: true, project, service, stderr: '' };
    }
    // Docker returns exit 1 with "No such object: <name>" when the container
    // is absent — that's the happy "no collision" case, not an error.
    if (/No such (object|container)/i.test(stderr)) {
        return { ok: true, exists: false, project: '', service: '', stderr: '' };
    }
    // spawn failed before docker could run (e.g. ENOENT — docker not in
    // PATH). Step 1 (daemon smoke) would already have failed in that case,
    // so by the time we get here we know docker is reachable. Treating
    // status=null as "container absent" lets unit tests exercise the
    // recreate flow without a docker daemon and without an inspect mock.
    if (result.status === null) {
        return { ok: true, exists: false, project: '', service: '', stderr: '' };
    }
    return { ok: false, exists: false, project: '', service: '', stderr };
}
/**
 * Default `docker rm -f` driver for the Step 5b-pre orphan guard.
 */
function defaultRemoveContainer(containerName) {
    const result = spawnSync('docker', ['rm', '-f', containerName], {
        encoding: 'utf-8',
        stdio: ['ignore', 'pipe', 'pipe'],
    });
    return { ok: result.status === 0, stderr: result.stderr ?? '' };
}
// ── Main handler ──────────────────────────────────────────────────
async function handleUpgrade(opts) {
    const cwd = process.cwd();
    // 1. Verify repo root.
    const rootCheck = validateRepoRoot(cwd);
    if (!rootCheck.ok) {
        printError(rootCheck.error);
        process.exitCode = 1;
        return;
    }
    // Phase 2c — C2: --history reads the audit log; dispatched BEFORE the
    // upgrade header so the log table prints clean, without the "olam upgrade"
    // / Steps prelude.
    if (opts.history) {
        handleHistory(parseHistoryOpts({ n: opts.historyN, json: opts.historyJson }));
        return;
    }
    // 2. Print plan.
    printHeader('olam upgrade');
    const steps = [
        'git fetch origin --prune',
        'git pull --ff-only',
        'npm install',
        'npm run build (TS workspaces)',
        'vite build (SPA)',
        ...(opts.skipImage ? [] : [
            'bash build-auth.sh (auth-service image)',
            'bash build-devbox.sh (devbox image)',
            'bash build-host-cp.sh (host-cp image)',
            'smoke (docker create + inspect)',
            'atomic 6-tag swap (canonical -> :olam-rollback; :olam-next -> canonical)',
            'docker compose --force-recreate olam-host-cp + AuthContainerController.start auth',
            'poll /api/version/status until SHAs match',
        ]),
    ];
    printInfo('Steps', steps.join(', '));
    if (opts.skipImage)
        printInfo('Mode', '--skip-image: no docker rebuild');
    if (opts.skipInstall)
        printInfo('Mode', '--skip-install: npm install will be skipped');
    if (opts.branch)
        printInfo('Branch', opts.branch);
    process.stdout.write('\n');
    // 3. Confirm unless --yes.
    if (!opts.yes) {
        const proceed = await confirm('Proceed?');
        if (!proceed) {
            printInfo('Aborted', 'no changes made');
            return;
        }
    }
    // Phase 2b — B1: rollback path. Branches at the top so --rollback skips
    // the entire git-pull/build/swap sequence and only retags + recreates.
    if (opts.rollback) {
        return await handleRollback();
    }
    // 3b. Acquire CLI lock (Phase 2a — A1). Refuses if a live upgrade is in flight;
    //     auto-recovers stale locks (parse-error / empty / dead-pid / >5 min).
    const lock = acquireLock();
    if (!lock.acquired) {
        printError(formatRefusalMessage(lock, LOCK_FILE_PATH));
        process.exitCode = 1;
        return;
    }
    // SIGINT / SIGTERM handler — release the lock before terminating so the
    // operator's next invocation doesn't have to wait for stale-recovery
    // (audit A1-005). `process.once` so a second Ctrl-C terminates immediately.
    let signalReleased = false;
    const releaseOnSignal = (signal) => {
        if (signalReleased)
            return;
        signalReleased = true;
        try {
            releaseLock();
        }
        catch {
            // best-effort
        }
        // Standard shell exit code for signal-induced termination: 128 + signal-number.
        process.exit(signal === 'SIGINT' ? 130 : 143);
    };
    process.once('SIGINT', releaseOnSignal);
    process.once('SIGTERM', releaseOnSignal);
    // Phase 2c — C1: collect a JSONL log row. Mutated as the upgrade progresses;
    // appended once on the way out (success OR failure) so --history surfaces
    // every attempt, not just successful ones.
    const logRow = {
        started_at: Date.now(),
        durations_ms: {},
        sha_target: '',
        failed_step: null,
        status: 'failed', // default; flipped to 'success' on clean exit
    };
    try {
        await runUpgradeStepsWithLockHeld(opts, cwd, logRow);
        if (process.exitCode !== 1)
            logRow.status = 'success';
    }
    finally {
        const ended_at = Date.now();
        const row = {
            ts: new Date(ended_at).toISOString(),
            started_at: logRow.started_at,
            ended_at,
            sha_target: logRow.sha_target,
            status: logRow.status,
            failed_step: logRow.failed_step,
            durations_ms: logRow.durations_ms,
        };
        appendUpgradeLog(row);
        releaseLock();
        process.removeListener('SIGINT', releaseOnSignal);
        process.removeListener('SIGTERM', releaseOnSignal);
    }
}
/**
 * Phase 2b — B1: handle `olam upgrade --rollback`.
 *
 * Pre-flights all three :olam-rollback tags exist; refuses with exit 1 if
 * any missing. Else atomically retags :olam-rollback → canonical for all
 * three images, then recreates host-cp (compose) + auth-service (controller).
 * No git pull, no build, no smoke — the rollback target is a known-good
 * image set captured by a previous successful upgrade.
 *
 * Acquires the same upgrade lock as the regular path so concurrent
 * --rollback + --normal-upgrade refuse at the file-mutex layer.
 */
async function handleRollback() {
    printHeader('olam upgrade --rollback');
    // 1. Pre-flight — verify rollback set exists.
    const missing = checkRollbackSetExists(PRODUCTION_SWAP_PLAN);
    if (missing !== null) {
        printError(`No rollback-set available — missing :olam-rollback tag(s): ${missing}\n\n` +
            'A rollback-set is created by the FIRST successful `olam upgrade`. If this\n' +
            'is your first install, run `olam upgrade` to populate the rollback set.\n' +
            'If a previous upgrade was incomplete, the rollback set may be partial;\n' +
            'manually inspect images with `docker images olam-*:olam-rollback`.');
        process.exitCode = 1;
        return;
    }
    // 2. Acquire lock (same primitive as the upgrade path).
    const lock = acquireLock();
    if (!lock.acquired) {
        printError(formatRefusalMessage(lock, LOCK_FILE_PATH));
        process.exitCode = 1;
        return;
    }
    let signalReleased = false;
    const releaseOnSignal = (signal) => {
        if (signalReleased)
            return;
        signalReleased = true;
        try {
            releaseLock();
        }
        catch {
            /* best-effort */
        }
        process.exit(signal === 'SIGINT' ? 130 : 143);
    };
    process.once('SIGINT', releaseOnSignal);
    process.once('SIGTERM', releaseOnSignal);
    try {
        // 3. Inverse swap: 3 docker tag ops.
        process.stdout.write(`  ${pc.dim('rollback retag (3 ops)'.padEnd(34))}`);
        const swapStart = Date.now();
        const swapResult = performRollbackSwap(PRODUCTION_SWAP_PLAN);
        const swapDur = `${((Date.now() - swapStart) / 1000).toFixed(1)}s`;
        process.stdout.write(`${swapResult.ok ? pc.green('✓') : pc.red('✗')} ${swapDur}\n`);
        if (!swapResult.ok) {
            printError(`Rollback retag failed: ${swapResult.summary}`);
            process.exitCode = 1;
            return;
        }
        printInfo('Rollback', swapResult.summary);
        // 4. Recreate containers (host-cp via compose, auth via controller).
        const composeFile = findComposeFile();
        const authSecret = readAuthSecret();
        process.stdout.write(`  ${pc.dim('docker compose recreate olam-host-cp'.padEnd(34))}`);
        const composeStart = Date.now();
        // `--no-deps` matches the forward-upgrade path (line ~1025) so the
        // rollback recreate doesn't also bounce docker-socket-proxy.
        const composeResult = runCompose(['up', '-d', '--force-recreate', '--no-deps', 'olam-host-cp'], composeFile, buildComposeEnv(authSecret, captureGhToken()));
        const composeDur = `${((Date.now() - composeStart) / 1000).toFixed(1)}s`;
        process.stdout.write(`${composeResult.ok ? pc.green('✓') : pc.red('✗')} ${composeDur}\n`);
        if (!composeResult.ok) {
            printError(`Rollback compose recreate failed:\n${composeResult.stderr}\n` +
                'Canonical tags are at :olam-rollback (good); container restart pending. ' +
                'Manually: `docker compose -f packages/host-cp/compose.yaml up -d --force-recreate olam-host-cp`.');
            process.exitCode = 1;
            return;
        }
        process.stdout.write(`  ${pc.dim('recreate auth-service'.padEnd(34))}`);
        const authResult = await recreateAuthService();
        const authDur = `${(authResult.durationMs / 1000).toFixed(1)}s`;
        process.stdout.write(`${authResult.ok ? pc.green('✓') : pc.red('✗')} ${authDur}\n`);
        if (!authResult.ok) {
            printError(`Auth-service recreate failed: ${authResult.error ?? 'unknown'}`);
            process.exitCode = 1;
            return;
        }
        process.stdout.write('\n');
        printSuccess('Rollback complete — canonical tags restored from :olam-rollback');
    }
    finally {
        releaseLock();
        process.removeListener('SIGINT', releaseOnSignal);
        process.removeListener('SIGTERM', releaseOnSignal);
    }
}
/**
 * Internal — runs all state-changing upgrade steps inside the lock.
 * Extracted so handleUpgrade can wrap in try/finally without indenting the body.
 */
async function runUpgradeStepsWithLockHeld(opts, cwd, logRow) {
    // 4a. Branch switch (--branch).
    if (opts.branch !== null) {
        if (isGitDirty(cwd)) {
            printError(`Working tree is dirty. Stash changes first:\n  git stash\nThen retry with --branch ${opts.branch}`);
            process.exitCode = 1;
            return;
        }
        const switchResult = runStep(`git checkout ${opts.branch}`, 'git', ['checkout', opts.branch], { cwd });
        if (!switchResult.ok) {
            printError(`Failed to switch to branch ${opts.branch}:\n${switchResult.stderr}`);
            process.exitCode = 1;
            return;
        }
    }
    // 4b. Dirty check before pull (skip if we just switched).
    if (opts.branch === null && isGitDirty(cwd)) {
        printError('Working tree is dirty. Stash or commit changes first:\n  git stash\nThen re-run `olam upgrade`.');
        process.exitCode = 1;
        return;
    }
    // 4c. Upstream check.
    if (!hasGitUpstream(cwd)) {
        printError('Current branch has no upstream remote. Set one first:\n  git branch --set-upstream-to=origin/<branch>\nThen re-run `olam upgrade`.');
        process.exitCode = 1;
        return;
    }
    const timings = [];
    // Step a: git fetch.
    const fetchResult = runStep('git fetch origin --prune', 'git', ['fetch', 'origin', '--prune'], { cwd });
    timings.push({ label: 'git fetch', durationMs: fetchResult.durationMs });
    if (!fetchResult.ok) {
        printError(`git fetch failed:\n${fetchResult.stderr}`);
        process.exitCode = 1;
        return;
    }
    // Step b: git pull --ff-only.
    const pullResult = runStep('git pull --ff-only', 'git', ['pull', '--ff-only'], { cwd });
    timings.push({ label: 'git pull', durationMs: pullResult.durationMs });
    if (!pullResult.ok) {
        printError(`git pull --ff-only failed:\n${pullResult.stderr}\n` +
            'If there are conflicts, resolve them manually then re-run `olam upgrade`.');
        process.exitCode = 1;
        return;
    }
    // Phase 2a — A2: capture HEAD SHA AFTER pull (sticky for the run).
    // The pull is what we're upgrading to; capturing before would self-refuse
    // at A6's swap-boundary drift check. _targetSha is consumed by A6 (atomic
    // swap) and B4 (drift refusal). Phase 2a A2 just stashes it; A6/B4 land it
    // load-bearing.
    const _targetSha = captureHeadSha(cwd);
    logRow.sha_target = _targetSha ?? '';
    if (_targetSha === null) {
        logRow.failed_step = 'capture HEAD SHA';
        printError('Failed to capture HEAD SHA via `git rev-parse HEAD`. Aborting upgrade.\n' +
            'Re-run from a clean git checkout; ensure `git rev-parse HEAD` returns a 40-char SHA.');
        process.exitCode = 1;
        return;
    }
    printInfo('Target SHA', abbreviateSha(_targetSha));
    // Step c: npm install (skip when in sync or --skip-install).
    const installDecision = shouldSkipInstall(opts, cwd);
    if (installDecision.skip) {
        printInfo('npm install', `skipped — ${installDecision.reason}`);
        timings.push({ label: 'npm install', durationMs: 0 });
    }
    else {
        const installResult = runStep('npm install', 'npm', ['install', '--silent', '--no-audit', '--no-fund'], { cwd });
        timings.push({ label: 'npm install', durationMs: installResult.durationMs });
        if (!installResult.ok) {
            printError(`npm install failed:\n${installResult.stderr}\n\n` +
                'Recovery options:\n' +
                '  • If a native module (e.g. better-sqlite3) failed to compile on your Node version:\n' +
                '      olam upgrade --skip-install\n' +
                '    This skips npm install and uses the existing node_modules.\n' +
                '  • To fix the native module, upgrade it in the relevant package.json,\n' +
                '    run `npm install` manually, then retry `olam upgrade --skip-install`.');
            process.exitCode = 1;
            return;
        }
    }
    // Step d: npm run build (TS workspaces).
    const buildResult = runStep('npm run build', 'npm', ['run', 'build'], { cwd });
    timings.push({ label: 'npm run build', durationMs: buildResult.durationMs });
    if (!buildResult.ok) {
        printError(`npm run build failed:\n${buildResult.stderr}`);
        process.exitCode = 1;
        return;
    }
    // Step e: vite build (SPA).
    const authSecret = readAuthSecret();
    const spaDir = path.join(cwd, 'packages/control-plane/app');
    const spaResult = runStep('vite build (SPA)', 'npx', ['vite', 'build'], { cwd: spaDir, env: buildComposeEnv(authSecret, captureGhToken()) });
    timings.push({ label: 'vite build (SPA)', durationMs: spaResult.durationMs });
    if (!spaResult.ok) {
        printError(`vite build failed:\n${spaResult.stderr}`);
        process.exitCode = 1;
        return;
    }
    if (opts.skipImage) {
        // Summary — no docker steps.
        process.stdout.write('\n');
        printSuccess('Source rebuilt (--skip-image: docker not touched)');
        const hash = readBundleHash(cwd);
        if (hash)
            printInfo('Bundle hash', hash);
        printTimings(timings);
        return;
    }
    // Note: A4-A8 step durations are captured in `timings`; the per-step
    // durations_ms snapshot on the log row reflects the full timings array
    // at the end of the run (logRow.durations_ms is updated below at each
    // significant boundary so a mid-run failure is recorded with what we know).
    // Phase 2a — A4: sequential build invocation (auth → devbox → host-cp)
    // with OLAM_TAG=olam-next so each script tags its image transiently per
    // A3's retag block. Order is load-bearing: auth first minimises P3's
    // in-flight 401 window when the recreate (A7) restarts auth before
    // host-cp. Devbox uses inherit-stdio (live tee) per audit F13 since
    // its cold-cache build dominates the 12-22 min budget and silent
    // capture is indistinguishable from a hang.
    // Phase 2b — B3: --no-cache passes through to all three build scripts.
    // The build scripts honor DOCKER_BUILD_NO_CACHE via the build-arg env mechanism
    // documented in their shell. (B3 implementation: forward the env; build
    // scripts treat unset as default cache enabled.)
    const olamTagEnv = { OLAM_TAG: 'olam-next' };
    if (opts.noCache) {
        olamTagEnv.DOCKER_BUILD_NO_CACHE = '1';
    }
    const buildScripts = [
        { label: 'bash build-auth.sh', relPath: 'packages/adapters/src/docker/build-auth.sh', tee: false },
        { label: 'bash build-devbox.sh', relPath: 'packages/adapters/src/docker/build-devbox.sh', tee: true },
        { label: 'bash build-host-cp.sh', relPath: 'packages/adapters/src/docker/build-host-cp.sh', tee: false },
    ];
    // Phase B6: resolve each build script via installRoot + dev-mode check.
    // Outside dev mode, surface the named remedy and exit cleanly. The
    // pull-by-digest default path (no --from-source flag) lands in C6.
    const { resolveBuildScript, MissingBuildScriptError } = await import('../install-root.js');
    for (const step of buildScripts) {
        let scriptPath;
        try {
            scriptPath = resolveBuildScript({ scriptRelPath: step.relPath });
        }
        catch (err) {
            if (err instanceof MissingBuildScriptError) {
                printError(err.message);
                process.exitCode = 1;
                return;
            }
            throw err;
        }
        if (step.tee) {
            // Live-tee variant: stdio: 'inherit' so docker build's apt/bundle/npm
            // progress reaches the operator's terminal in real-time. No stdout
            // capture means we can't include stderr in the failure message —
            // operator already saw the failure inline.
            process.stdout.write(`  ${pc.dim(step.label.padEnd(34))}\n`);
            const start = Date.now();
            const result = spawnSync('bash', [scriptPath], {
                stdio: 'inherit',
                cwd,
                env: { ...process.env, ...olamTagEnv },
            });
            const durationMs = Date.now() - start;
            const ok = result.status === 0 && result.error === undefined;
            const dur = `${(durationMs / 1000).toFixed(1)}s`;
            process.stdout.write(`  ${pc.dim(step.label.padEnd(34))}${ok ? pc.green('✓') : pc.red('✗')} ${dur}\n`);
            timings.push({ label: step.label, durationMs });
            if (!ok) {
                printError(`${step.label} failed (see output above for details).`);
                process.exitCode = 1;
                return;
            }
        }
        else {
            const result = runStep(step.label, 'bash', [scriptPath], {
                cwd,
                env: olamTagEnv,
            });
            timings.push({ label: step.label, durationMs: result.durationMs });
            logRow.durations_ms[step.label] = result.durationMs;
            if (!result.ok) {
                logRow.failed_step = step.label;
                printError(`${step.label} failed:\n${result.stderr.split('\n').slice(-3).join('\n')}`);
                process.exitCode = 1;
                return;
            }
        }
    }
    // Snapshot durations to logRow so a later-step failure preserves what we know.
    for (const t of timings)
        logRow.durations_ms[t.label] = t.durationMs;
    // Phase 2a — A5: smoke each :olam-next image via docker create + inspect.
    // Catches build-corrupt cases (manifest invalid, OLAM_BUILD_SHA label
    // missing, baked SHA != target SHA) before A6's atomic swap touches
    // canonical tags. Sub-second per image; no port bind.
    const smokeStart = Date.now();
    process.stdout.write(`  ${pc.dim('smoke (docker create + inspect)'.padEnd(34))}`);
    const smokeImages = [
        'olam-auth:olam-next',
        'olam-devbox:olam-next',
        'olam-host-cp:olam-next',
    ];
    const smokeResults = smokeImages.map((img) => smokeImage(img, _targetSha));
    const smokeFailures = smokeResults.filter((r) => !r.ok);
    const smokeDurationMs = Date.now() - smokeStart;
    const smokeDur = `${(smokeDurationMs / 1000).toFixed(1)}s`;
    process.stdout.write(`${smokeFailures.length === 0 ? pc.green('✓') : pc.red('✗')} ${smokeDur}\n`);
    timings.push({ label: 'smoke', durationMs: smokeDurationMs });
    if (smokeFailures.length > 0) {
        printError(`Smoke failed for ${smokeFailures.length} of ${smokeResults.length} images:\n` +
            smokeFailures.map((r) => `  - ${r.image}: ${r.error}`).join('\n') +
            '\nCanonical tags (`:latest`/`:local`) untouched. Investigate the failed image(s),' +
            ' then re-run `olam upgrade` (--no-cache if cache-poisoning suspected).');
        process.exitCode = 1;
        return;
    }
    // Phase 2b — B4: SHA drift check at swap boundary.
    // Re-read HEAD via `git rev-parse HEAD` and compare to A2's captured
    // _targetSha. If different, refuse the swap unless --force.
    const swapBoundarySha = captureHeadSha(cwd);
    if (swapBoundarySha !== null && swapBoundarySha !== _targetSha && !opts.force) {
        printError(`HEAD drifted during build window:\n` +
            `  captured (after pull): ${abbreviateSha(_targetSha)}\n` +
            `  current at swap:       ${abbreviateSha(swapBoundarySha)}\n\n` +
            'Operator-driven `git checkout` or `git reset` triggered drift.\n' +
            'Recovery options:\n' +
            '  • Re-run `olam upgrade` (will rebuild against current HEAD).\n' +
            '  • Pass `--force` to swap anyway (canonical advances to the\n' +
            '    captured-at-pull SHA, NOT current HEAD).');
        process.exitCode = 1;
        return;
    }
    // Phase 2a — A6: atomic 6-tag swap.
    // Phase 1 of swap: preserve previous-good as :olam-rollback (3 ops).
    // Phase 2 of swap: advance canonical to :olam-next (3 ops).
    // Sub-second wall-clock; SIGKILL during Phase 2 is recoverable via
    // `olam upgrade --rollback` (Phase 2b) since :olam-rollback is fully
    // populated before any canonical tag is touched.
    process.stdout.write(`  ${pc.dim('atomic 6-tag swap'.padEnd(34))}`);
    const swapStart = Date.now();
    const swapResult = performAtomicSwap(PRODUCTION_SWAP_PLAN);
    const swapDurationMs = Date.now() - swapStart;
    const swapDur = `${(swapDurationMs / 1000).toFixed(1)}s`;
    process.stdout.write(`${swapResult.ok ? pc.green('✓') : pc.red('✗')} ${swapDur}\n`);
    timings.push({ label: 'atomic swap', durationMs: swapDurationMs });
    if (!swapResult.ok) {
        printError(`Atomic swap failed: ${swapResult.summary}`);
        process.exitCode = 1;
        return;
    }
    printInfo('Swap', swapResult.summary);
    // Step g: docker compose up -d --force-recreate.
    const composeFile = findComposeFile();
    process.stdout.write(`  ${pc.dim('docker compose recreate'.padEnd(34))}`);
    const composeStart = Date.now();
    const composeResult = runCompose(['up', '-d', '--force-recreate'], composeFile, buildComposeEnv(authSecret, captureGhToken()));
    const composeDurationMs = Date.now() - composeStart;
    const composeOk = composeResult.ok;
    const composeDur = `${(composeDurationMs / 1000).toFixed(1)}s`;
    process.stdout.write(`${composeOk ? pc.green('✓') : pc.red('✗')} ${composeDur}\n`);
    timings.push({ label: 'container recreate', durationMs: composeDurationMs });
    if (!composeOk) {
        // Audit A6-002: at this point canonical tags are at NEW SHA but the stack
        // failed to start. Operator needs to know --rollback is one command away.
        printError(`docker compose up --force-recreate failed:\n${composeResult.stderr}\n\n` +
            'Canonical tags advanced to new SHA but the stack failed to start.\n' +
            'Recovery options:\n' +
            '  • Run `olam upgrade --rollback` to restore the prior :olam-rollback set, then investigate.\n' +
            '  • Manually `docker logs olam-host-cp` to diagnose; if recoverable, retry recreate without rollback.');
        process.exitCode = 1;
        return;
    }
    // Phase 2a — A7: recreate auth-service via AuthContainerController.
    // Auth is NOT in compose.yaml; reusing the auth-upgrade.ts recreate pattern
    // (docker stop → docker rm → controller.start() → wait /health). The 25s
    // in-flight 401 window for active world API calls during this recreate is
    // documented in the operator's confirmation prompt (P3 mitigation).
    process.stdout.write(`  ${pc.dim('recreate auth-service'.padEnd(34))}`);
    const authResult = await recreateAuthService();
    const authDur = `${(authResult.durationMs / 1000).toFixed(1)}s`;
    process.stdout.write(`${authResult.ok ? pc.green('✓') : pc.red('✗')} ${authDur}\n`);
    timings.push({ label: 'auth recreate', durationMs: authResult.durationMs });
    if (!authResult.ok) {
        printError(`Auth-service recreate failed: ${authResult.error ?? 'unknown'}\n\n` +
            'Canonical tags advanced to new SHA; host-cp recreated but auth-service is broken.\n' +
            'Recovery options:\n' +
            '  • Run `olam upgrade --rollback` to restore the prior :olam-rollback set + working stack.\n' +
            '  • Manually: `docker logs olam-auth` to diagnose; `olam auth up` to restart.');
        process.exitCode = 1;
        return;
    }
    // Step h: wait for /health (host-cp readiness probe).
    process.stdout.write(`  ${pc.dim('waiting for /health'.padEnd(34))}`);
    const healthStart = Date.now();
    const healthy = await waitForHealth(10_000);
    const healthDurationMs = Date.now() - healthStart;
    const healthDur = `${(healthDurationMs / 1000).toFixed(1)}s`;
    process.stdout.write(`${healthy ? pc.green('✓') : pc.yellow('?')} ${healthDur}\n`);
    timings.push({ label: '/health', durationMs: healthDurationMs });
    if (!healthy) {
        printWarning('Host CP started but /health did not respond within 10s.\n' +
            '  • Check: docker logs olam-host-cp\n' +
            '  • If the new SHA is broken: `olam upgrade --rollback` restores the prior set in <30s.');
    }
    // Phase 2a — A8: poll /api/version/status until all three SHAs match
    // captured target. This is the success criterion for the entire upgrade —
    // round-trips through Phase 1's detection path so the SPA banner clears
    // automatically once the polling loop succeeds.
    //
    // Phase 2.5 polish: 60s wasn't enough on first dogfood — host-cp's /health
    // came back instantly but /api/version/status needed >60s to start serving
    // (cold-boot of express handler chain + first registry-stat reads). 90s
    // gives reliable headroom without making the warn-path feel slow when
    // host-cp is genuinely stuck.
    process.stdout.write(`  ${pc.dim('verify /version/status round-trip'.padEnd(34))}`);
    const versionStart = Date.now();
    const versionMatch = await waitForVersionMatch(_targetSha, 90_000);
    const versionDurationMs = Date.now() - versionStart;
    const versionDur = `${(versionDurationMs / 1000).toFixed(1)}s`;
    process.stdout.write(`${versionMatch.matched ? pc.green('✓') : pc.yellow('?')} ${versionDur}\n`);
    timings.push({ label: '/version/status round-trip', durationMs: versionDurationMs });
    if (!versionMatch.matched) {
        // Non-fatal — recreate succeeded; SHA propagation may be slow on cold
        // host-cp boot. Operator gets diagnostic output + can decide whether to
        // re-run, wait, or roll back.
        printWarning(`Version round-trip incomplete after ${(versionDurationMs / 1000).toFixed(0)}s:\n` +
            formatVersionMismatch(_targetSha, versionMatch.snapshot) + '\n' +
            '  • Banner may still show UPDATE AVAILABLE until host-cp\'s next ' +
            'poll cycle (~60s).\n' +
            '  • If the mismatch persists, `olam upgrade --rollback` restores ' +
            'the prior :olam-rollback set.');
    }
    // 5. Summary.
    process.stdout.write('\n');
    printSuccess('Upgrade complete');
    const hash = readBundleHash(cwd);
    if (hash)
        printInfo('Bundle hash', hash);
    printTimings(timings);
}
function printTimings(timings) {
    const total = timings.reduce((s, t) => s + t.durationMs, 0);
    process.stdout.write('\n');
    for (const t of timings) {
        printInfo(t.label, `${(t.durationMs / 1000).toFixed(1)}s`);
    }
    printInfo('total', `${(total / 1000).toFixed(1)}s`);
}
// ── Register ──────────────────────────────────────────────────────
export function registerUpgrade(program) {
    program
        .command('upgrade')
        .description('Upgrade the local Olam stack to the CLI\'s pinned image digests. ' +
        'Default: pull pre-built images from ghcr.io and recreate containers. ' +
        'Use --from-source for the legacy monorepo build path (requires OLAM_DEV=1).')
        .option('-y, --yes', 'Skip the confirmation prompt')
        .option('--skip-image', 'Skip docker image rebuild + container recreate (source rebuild only)')
        .option('--skip-install', 'Skip npm install entirely (use existing node_modules as-is). ' +
        'Useful when a native-module build failure blocks the normal upgrade path.')
        .option('--branch <name>', 'Switch to this branch before pulling (refuses if working tree is dirty)')
        .option('--rollback', 'Restore canonical tags from the :olam-rollback set (created by the prior successful upgrade).\n' +
        '                              No git pull, no build, no smoke — just retag + recreate.')
        .option('--force', 'Bypass HEAD-drift refusal at the swap boundary. Swap advances canonical to the\n' +
        '                              captured-at-pull SHA even if current HEAD differs.')
        .option('--no-cache', 'Pass --no-cache to all three build scripts (DOCKER_BUILD_NO_CACHE=1).\n' +
        '                              Useful when retrying after a cache-poisoning failure.')
        .option('--history', 'Print the upgrade history (~/.olam/upgrade.log) and exit.\n' +
        '                              No upgrade is performed.')
        .option('-n <count>', 'Number of history rows to print (default 10)', '10')
        .option('--json', 'Emit history as JSONL instead of a table')
        .option('--from-source', 'Build host-cp + auth + devbox from monorepo source (legacy path).\n' +
        '                              Requires OLAM_DEV=1 + a `packages/` sibling at the install root.\n' +
        '                              Default (no flag): pull pre-built images from ghcr.io by digest.')
        .option('--force-refresh-manifests', '[kubernetes substrate only] Re-apply manifests from ~/.olam/k8s/manifests/ before\n' +
        '                              the rollout. Security-sensitive field diffs require\n' +
        '                              --accept-security-regression to proceed.')
        .option('--accept-security-regression', '[kubernetes substrate only] Accept security-sensitive field changes in manifests\n' +
        '                              when using --force-refresh-manifests. Writes an audit\n' +
        '                              entry to ~/.olam/state/manifest-refresh-audit.jsonl.')
        .option('--rotate-secrets', '[kubernetes substrate only — B5] Regenerate every rendered Secret value.\n' +
        '                              WARNING: rotation breaks worlds that have cached prior\n' +
        '                              tokens. Default behaviour reuses values from\n' +
        '                              ~/.olam/k8s-secrets-state.json so reapply is non-destructive.')
        .option('--skip-k8s-bootstrap', '[kubernetes substrate only — B4] Skip the namespace + RBAC + secret bootstrap step.\n' +
        '                              Use when these are managed out-of-band (e.g. GitOps).')
        .action(async (opts) => {
        const parsed = parseUpgradeOpts(opts);
        // Phase 1b — C2: substrate-aware routing.
        // Read host.substrate from ~/.olam/config.json BEFORE any other branching.
        // Compose path: unchanged (byte-identical to prior behaviour).
        // Kubernetes path: 8-step flow in upgrade-kubernetes.ts.
        const cfg = readConfig();
        if (cfg.host.substrate === 'kubernetes') {
            try {
                const result = await runUpgradeKubernetes({
                    forceRefreshManifests: parsed.forceRefreshManifests,
                    acceptSecurityRegression: parsed.acceptSecurityRegression,
                    rotateSecrets: opts.rotateSecrets === true,
                    // B4: bootstrap defaults ON for the CLI entry point so npm-only
                    // operators get namespace + RBAC + secrets auto-applied. Opt-out
                    // via --skip-k8s-bootstrap (GitOps-managed clusters).
                    runK8sBootstrap: opts.skipK8sBootstrap !== true,
                });
                process.exitCode = result.exitCode;
            }
            catch (err) {
                printError(err instanceof Error ? err.message : String(err));
                process.exitCode = EXIT_GENERIC_ERROR;
            }
            return;
        }
        // compose path — unchanged below this line ────────────────────
        // Phase C — C6: --from-source gates the legacy build path. Default
        // pull-by-digest path lands installed-CLI operators on a fresh
        // image set without a monorepo clone.
        if (parsed.fromSource) {
            if (!isDevMode()) {
                printError(new MissingBuildScriptError('packages/adapters/src/docker/build-*.sh').message);
                process.exitCode = 1;
                return;
            }
            await handleUpgrade(parsed);
            return;
        }
        // --history is read-only; honour it on the default path too so
        // operators don't have to remember --from-source just to read the log.
        if (parsed.history) {
            handleHistory(parseHistoryOpts({ n: parsed.historyN, json: parsed.historyJson }));
            return;
        }
        try {
            const result = await runUpgradePullByDigest();
            process.exitCode = result.exitCode;
        }
        catch (err) {
            printError(err instanceof Error ? err.message : String(err));
            process.exitCode = EXIT_GENERIC_ERROR;
        }
    });
}
//# sourceMappingURL=upgrade.js.map