/**
 * health-probes.ts — Shared host-health probe module.
 *
 * Phase A1 of olam-operator-onboarding-parity (plan
 * ~/.claude/plans/olam-operator-onboarding-parity.md).
 *
 * The Seam (Decision 3): `olam doctor` + `olam diagnose` both consume
 * these probes. No probe writes to disk; no probe spawns long-running
 * processes; each is independently safe to run in parallel.
 *
 * Probe contract:
 *   - Each returns ProbeResult: { ok: true, message } | { ok: false, message, remedy }
 *   - Each is async; can run in parallel via Promise.all
 *   - Each accepts injectable deps (docker exec / fetch / OLAM_HOME) for testability
 *
 * Cap thresholds (probeKgStorage) mirror packages/cli/src/commands/kg-status.ts:
 *   - 1.5 GB soft-cap → ok:true with "soft-warn" message
 *   - 5.0 GB hard-cap → ok:false with prune remedy
 *
 * Daemon-reachability rule (Risk T5): callers that probe per-image
 * presence MUST call probeDockerDaemon first and fast-fail on its
 * failure. Image probes throw confusing "404 no such image" errors
 * when the daemon is paused mid-check.
 */
import { spawnSync } from 'node:child_process';
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { homedir } from 'node:os';
import path from 'node:path';
import { SOFT_CAP_BYTES, HARD_CAP_BYTES } from './kg-caps.js';
const HEALTH_TIMEOUT_MS = 5_000;
// ── Phase C / C2: Colima version probe ────────────────────────────────
//
// Decision (plan Goal — VERBATIM): when Colima version is in the 0.10.x
// range, surface a WARN with the literal remediation text below. Any
// drift from this string is a regression — operators copy-paste it.
//
// The matcher is intentionally strict (`^v?0\.10\.\d+$`) — a future 0.11
// or 0.9 release MUST NOT match.
export const COLIMA_010_WARN_TEXT = 
// eslint-disable-next-line @typescript-eslint/quotes
"Colima 0.10.1's --kubernetes mode is broken upstream. Either switch to OLAM_HOST_CP_ENGINE=docker (recommended) or downgrade Colima to 0.9.x. Track abiosoft/colima issues for fix.";
const COLIMA_010_RANGE = /^v?0\.10\.\d+$/;
/**
 * Default Docker exec implementation. Consumers MUST inject their own
 * `exec` parameter in tests — this default reaches real
 * `node:child_process.spawnSync` and will shell out to a real `docker`
 * binary on the test runner (green on dev laptops with Docker running;
 * red on CI runners without daemon). Audit finding HIGH-2.
 */
const defaultDockerExec = (cmd, args) => {
    const r = spawnSync(cmd, [...args], {
        encoding: 'utf-8',
        stdio: ['ignore', 'pipe', 'pipe'],
    });
    return {
        status: r.status,
        stdout: r.stdout ?? '',
        stderr: r.stderr ?? '',
    };
};
/**
 * Default fetch implementation. Consumers MUST inject their own
 * `fetchImpl` parameter in tests — this default reaches the global
 * `fetch` and will make real network calls on the test runner.
 * Audit finding HIGH-2.
 */
const defaultFetch = (input, init) => fetch(input, init);
// ── Probes ─────────────────────────────────────────────────────────────
/**
 * Verify docker daemon is reachable via `docker info --format`.
 *
 * MUST be called first by callers that probe per-image presence; image
 * probes throw confusing "404 no such image" errors when the daemon is
 * paused mid-check (Risk T5 mitigation).
 */
export async function probeDockerDaemon(dockerExec = defaultDockerExec) {
    const r = dockerExec('docker', ['info', '--format', '{{.ServerVersion}}']);
    if (r.status === 0 && r.stdout.trim().length > 0) {
        return { ok: true, message: `docker ${r.stdout.trim()} reachable` };
    }
    return {
        ok: false,
        message: 'docker daemon not reachable',
        remedy: 'Start Docker Desktop (or your docker daemon) and re-run.',
    };
}
/**
 * Verify the `docker compose` v2 plugin is installed.
 *
 * Symptom this guards against (B2 / npm-only operator on macOS via Homebrew):
 * `brew install docker` does NOT ship the compose v2 plugin. `docker compose pull …`
 * fails — depending on docker version — with either "is not a docker command"
 * (newer docker) or "unknown shorthand flag" (bare docker mis-parsing the
 * remainder of the compose argv). The cryptic flag error misdirects operators
 * away from the real fix.
 *
 * Returns:
 *   - PASS when `docker compose version` exits 0 with v2.x in stdout.
 *   - FAIL with macOS-aware remedy when docker is present but the plugin isn't.
 *   - FAIL with "install docker" remedy when the docker binary is missing entirely.
 *
 * Detection: relies on stderr / exit status of `docker compose version`. The
 * exact stderr text varies by docker version, so we match a small set of
 * substrings rather than a single regex.
 */
export async function probeComposePlugin(dockerExec = defaultDockerExec) {
    const r = dockerExec('docker', ['compose', 'version']);
    if (r.status === 0 && r.stdout.trim().length > 0) {
        // Successful invocation. stdout is something like "Docker Compose version v2.29.7".
        const version = r.stdout.trim().split('\n')[0] ?? r.stdout.trim();
        return { ok: true, message: `${version}` };
    }
    // Distinguish "docker binary missing" from "docker present but compose plugin missing".
    // status === null happens when spawn itself failed (binary not found).
    if (r.status === null) {
        return {
            ok: false,
            message: 'docker not installed (`docker` binary not found)',
            remedy: 'Install Docker. On macOS: `brew install docker docker-compose` (CLI only) or install Docker Desktop. On Linux follow https://docs.docker.com/engine/install/.',
        };
    }
    const stderrText = `${r.stdout}\n${r.stderr}`.toLowerCase();
    const isPluginMissing = stderrText.includes('is not a docker command') ||
        stderrText.includes('unknown command') ||
        stderrText.includes('unknown shorthand flag') ||
        stderrText.includes('no such file or directory');
    if (isPluginMissing) {
        return {
            ok: false,
            message: 'docker-compose-v2 plugin not installed',
            remedy: 'Install the compose v2 plugin. macOS: `brew install docker-compose`. Linux: install the `docker-compose-plugin` package (apt/dnf) or follow https://docs.docker.com/compose/install/linux/.',
        };
    }
    // Unrecognized failure — probably docker daemon down or another preflight class.
    // Surface what we can; `probeDockerDaemon` will give the more specific remedy.
    return {
        ok: false,
        message: `docker compose probe failed (status ${r.status})`,
        remedy: 'Check `docker compose version` manually. If docker itself is down, start Docker Desktop or your docker daemon and re-run.',
    };
}
/**
 * Verify each ref is present locally via `docker image inspect`.
 *
 * Fast-fail: returns the first missing image; subsequent refs NOT
 * probed. Caller decides ordering of refs.
 */
export async function probeImagePresence(refs, dockerExec = defaultDockerExec) {
    for (const ref of refs) {
        const r = dockerExec('docker', ['image', 'inspect', '--format', '{{.Id}}', ref]);
        if (r.status !== 0) {
            return {
                ok: false,
                message: `image ${ref} not present locally`,
                remedy: 'Run `olam bootstrap` to pull the published image, or build locally.',
            };
        }
    }
    return { ok: true, message: `${refs.length} image(s) present` };
}
/**
 * GET http://127.0.0.1:19000/health with 5s timeout; expect HTTP 200
 * AND a JSON envelope matching the host-cp shape (defends against
 * false-positives from a port-collision with some other 19000-bound
 * process — audit finding HIGH body-shape).
 *
 * Real envelope (per `packages/host-cp/src/server.mjs:665-688`):
 *   { status: 'ok', phase, uptime_seconds: number, cache: {...},
 *     mode: { deployment, world_host }, registry: {...}, auth: {...} }
 *
 * Probe asserts three orthogonal fields (`phase` + `cache` object +
 * `mode.deployment`) that no random web server would carry together.
 * Substring-on-the-token-path was an earlier fragile heuristic; this
 * version pins to actual server contract.
 *
 * Aborts via AbortController on timeout; failure mode is captured in
 * the error path with reason ("timeout" / "ECONNREFUSED" / etc.).
 *
 * Always consumes the response body before returning (avoids undici
 * socket leak when the probe is invoked in a loop).
 */
export async function probeHostCpHealth(fetchImpl = defaultFetch) {
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS);
    try {
        const res = await fetchImpl('http://127.0.0.1:19000/health', { signal: controller.signal });
        const body = await res.text().catch(() => '');
        if (res.status !== 200) {
            return {
                ok: false,
                message: `host-cp /health returned ${res.status}`,
                remedy: 'Restart host-cp via `olam host-cp start`; verify port 19000 is bound to 127.0.0.1.',
            };
        }
        if (isHostCpHealthEnvelope(body)) {
            return { ok: true, message: 'host-cp /health returned 200' };
        }
        return {
            ok: false,
            message: 'host-cp /health returned 200 but body did not match the host-cp envelope (possible port collision on :19000)',
            remedy: 'Check `lsof -iTCP:19000`; another process may have bound the port. Restart host-cp via `olam host-cp start`.',
        };
    }
    catch (err) {
        const e = err;
        const reason = e.name === 'AbortError' ? `timeout (${HEALTH_TIMEOUT_MS}ms)` : e.message;
        return {
            ok: false,
            message: `host-cp /health unreachable: ${reason}`,
            remedy: 'Restart host-cp via `olam host-cp start`; verify port 19000 is bound to 127.0.0.1.',
        };
    }
    finally {
        clearTimeout(timeout);
    }
}
/**
 * Match three orthogonal fields from host-cp's real /health envelope.
 * Returns true only when ALL match (a random 19000-bound web server is
 * very unlikely to ship all three in one response by accident).
 */
function isHostCpHealthEnvelope(body) {
    let parsed;
    try {
        parsed = JSON.parse(body);
    }
    catch {
        return false;
    }
    if (!parsed || typeof parsed !== 'object')
        return false;
    const obj = parsed;
    if (typeof obj.phase !== 'string')
        return false;
    if (!obj.cache || typeof obj.cache !== 'object')
        return false;
    const mode = obj.mode;
    if (!mode || typeof mode !== 'object')
        return false;
    if (typeof mode.deployment !== 'string')
        return false;
    return true;
}
/**
 * Read the canonical credentials vault at `<OLAM_HOME>/auth-data/accounts.json`
 * (matching `packages/auth-service/src/store.ts:LOCAL_DATA_DIR` and
 * `scripts/audit-credentials.mjs:resolveAccountsPath`); count active
 * `claude` credentials.
 *
 * Vault shape (per packages/auth-service/src/types.ts): a flat JSON array
 * of account objects. Each account has:
 *   - provider: 'claude' | 'codex' | ...
 *   - state?: 'active' | 'cooldown' | 'disabled'
 *   - rateLimited?: boolean  (legacy fallback for state)
 *   - expiresAt?: number (epoch ms)
 *   - rateLimitResetsAt?: string (ISO 8601)
 *
 * Predicate mirrors `effectiveState()` from scripts/audit-credentials.mjs
 * inline (the canonical helper is a .mjs script that we can't import from
 * this TS module). If the canonical predicate evolves (new fields, new
 * state literals), this mirror MUST be updated.
 *
 * Override: `OLAM_AUTH_DATA_PATH` env var lets CI/test fixtures point
 * at an alternate accounts.json. Matches the canonical helper.
 */
export async function probeAuthVault(olamHomeOverride) {
    const vaultPath = resolveAccountsPath(olamHomeOverride);
    if (!existsSync(vaultPath)) {
        return {
            ok: false,
            message: `auth vault missing at ${vaultPath}`,
            remedy: 'Run `olam auth up && olam auth login` to provision the vault.',
        };
    }
    let parsed;
    try {
        parsed = JSON.parse(readFileSync(vaultPath, 'utf-8'));
    }
    catch (err) {
        return {
            ok: false,
            message: `auth vault malformed: ${err.message}`,
            remedy: 'Inspect the accounts.json file; restore from backup or re-run `olam auth up`.',
        };
    }
    if (!Array.isArray(parsed)) {
        return {
            ok: false,
            message: 'auth vault malformed: expected JSON array at top level',
            remedy: 'Restore the vault from backup or re-run `olam auth up && olam auth login`.',
        };
    }
    const accounts = parsed.filter((a) => a !== null && typeof a === 'object');
    const now = Date.now();
    const activeClaude = accounts.filter((a) => a.provider === 'claude' && effectiveState(a, now) === 'active');
    if (activeClaude.length === 0) {
        return {
            ok: false,
            message: `no active claude credentials (vault has ${accounts.length} account(s))`,
            remedy: 'Run `olam auth login` to add a credential, or wait for the next reset.',
        };
    }
    return { ok: true, message: `${activeClaude.length} active claude credential(s)` };
}
function resolveAccountsPath(olamHomeOverride) {
    // OLAM_AUTH_DATA_PATH is the explicit override (matches the canonical
    // helper at scripts/audit-credentials.mjs). Honoured first for CI fixtures.
    const explicit = process.env.OLAM_AUTH_DATA_PATH;
    if (explicit)
        return explicit;
    const olamHome = olamHomeOverride ?? process.env.OLAM_HOME ?? path.join(homedir(), '.olam');
    return path.join(olamHome, 'auth-data', 'accounts.json');
}
/**
 * Inline mirror of `effectiveState()` from scripts/audit-credentials.mjs.
 *
 * IMPORTANT: keep this in sync with the canonical helper. If the helper
 * changes, this copy MUST change too (no compile-time link between them).
 */
function effectiveState(account, now) {
    const persisted = account.state ?? (account.rateLimited ? 'cooldown' : 'active');
    if (persisted === 'disabled')
        return 'disabled';
    if (typeof account.expiresAt === 'number' && account.expiresAt <= now)
        return 'expired';
    if (persisted === 'cooldown') {
        const reset = account.rateLimitResetsAt ? new Date(account.rateLimitResetsAt).getTime() : 0;
        if (reset > 0 && reset <= now)
            return 'active';
    }
    return persisted;
}
/**
 * Cumulative KG storage under OLAM_HOME (pristines + overlays).
 * Soft warn at 1.5 GB; hard fail at 5 GB.
 *
 * Layout (mirrors packages/cli/src/commands/kg-status.ts):
 *   - Pristines: ~/.olam/kg/<workspace>/graphify-out/
 *   - Overlays:  ~/.olam/worlds/<world>/<clone>/graphify-out/
 *
 * Cap constants duplicated rather than imported from kg-status.ts to
 * keep this module self-contained (the Seam decision — see plan
 * decision 3). A future PR may extract them to a single source if
 * a third consumer lands.
 */
export async function probeKgStorage(olamHomeOverride) {
    const olamHome = olamHomeOverride ?? process.env.OLAM_HOME ?? path.join(homedir(), '.olam');
    const kgRoot = path.join(olamHome, 'kg');
    const worldsRoot = path.join(olamHome, 'worlds');
    const kgBytes = enumerateGraphifyOut(kgRoot, 'pristine');
    const overlayBytes = enumerateGraphifyOut(worldsRoot, 'overlay');
    const totalBytes = kgBytes + overlayBytes;
    if (totalBytes >= HARD_CAP_BYTES) {
        return {
            ok: false,
            message: `KG storage ${formatBytes(totalBytes)} exceeds 5 GB hard cap`,
            remedy: 'Prune stale pristines: `rm -rf ~/.olam/kg/<workspace>` after operator review.',
        };
    }
    if (totalBytes >= SOFT_CAP_BYTES) {
        return {
            ok: true,
            message: `KG storage ${formatBytes(totalBytes)} (soft-warn — exceeds 1.5 GB; consider pruning)`,
        };
    }
    return { ok: true, message: `KG storage ${formatBytes(totalBytes)} (under cap)` };
}
function enumerateGraphifyOut(root, layout) {
    if (!existsSync(root))
        return 0;
    let total = 0;
    let entries;
    try {
        entries = readdirSync(root, { withFileTypes: true });
    }
    catch {
        return 0;
    }
    for (const entry of entries) {
        if (!entry.isDirectory())
            continue;
        if (layout === 'pristine') {
            total += dirSizeBytes(path.join(root, entry.name, 'graphify-out'));
        }
        else {
            const worldDir = path.join(root, entry.name);
            let clones;
            try {
                clones = readdirSync(worldDir, { withFileTypes: true });
            }
            catch {
                continue;
            }
            for (const clone of clones) {
                if (!clone.isDirectory())
                    continue;
                total += dirSizeBytes(path.join(worldDir, clone.name, 'graphify-out'));
            }
        }
    }
    return total;
}
/**
 * Recursive byte sum over a directory.
 *
 * Symlink policy: short-circuited via `entry.isSymbolicLink()` in the
 * common case. KNOWN FALSE-POSITIVE CLASS (audit finding HIGH-3): on
 * filesystems that don't populate `d_type` (some network mounts, FUSE,
 * exotic overlay FS), `Dirent` flags report `UV_DIRENT_UNKNOWN` and
 * both `isSymbolicLink()` and `isDirectory()` return `false`. Such an
 * entry is then `statSync`'d as a file; if it's actually a symlink to
 * a giant file, the target's bytes are counted. Probability is low on
 * APFS/ext4; impact is "doctor false-fail on a healthy host with
 * exotic FS." Mirrors the same shape in kg-status.ts:240.
 */
function dirSizeBytes(dir) {
    if (!existsSync(dir))
        return 0;
    let total = 0;
    const stack = [dir];
    while (stack.length > 0) {
        const cur = stack.pop();
        let entries;
        try {
            entries = readdirSync(cur, { withFileTypes: true });
        }
        catch {
            continue;
        }
        for (const entry of entries) {
            const full = path.join(cur, entry.name);
            if (entry.isSymbolicLink())
                continue;
            if (entry.isDirectory()) {
                stack.push(full);
                continue;
            }
            try {
                total += statSync(full).size;
            }
            catch {
                // race: file disappeared between readdir + stat
            }
        }
    }
    return total;
}
function formatBytes(n) {
    if (n < 1024)
        return `${n} B`;
    if (n < 1024 * 1024)
        return `${(n / 1024).toFixed(1)} KB`;
    if (n < 1024 * 1024 * 1024)
        return `${(n / 1024 / 1024).toFixed(1)} MB`;
    return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
// ── Phase C / C2: engine + colima + K8s probes ─────────────────────────
/**
 * Probe: detect the active container engine via host-cp's `/health`
 * X-Olam-Engine header (Phase A landed the header in #614). When host-cp
 * is unreachable, the probe FAILs with the same remedy as probeHostCpHealth
 * — operators with a stopped host-cp see one canonical fix, not two.
 *
 * Returns the engine name as part of the PASS message. The doctor row
 * label is engine-agnostic (`engine`); the body surfaces the name.
 * No kubectl jargon — for `engine=kubernetes` we say "kubernetes", not
 * "kubectl context k3d-olam" or similar.
 */
export async function probeEngine(fetchImpl = defaultFetch) {
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS);
    try {
        const res = await fetchImpl('http://127.0.0.1:19000/health', { signal: controller.signal });
        await res.text().catch(() => '');
        if (res.status !== 200) {
            return {
                ok: false,
                message: `host-cp /health returned ${res.status}; engine unknown`,
                remedy: 'Restart host-cp via `olam host-cp start`; verify port 19000 is bound to 127.0.0.1.',
            };
        }
        const engineHeader = res.headers.get('x-olam-engine') ?? res.headers.get('X-Olam-Engine');
        if (!engineHeader) {
            return {
                ok: true,
                message: 'engine unknown (X-Olam-Engine header absent — older host-cp?)',
            };
        }
        return {
            ok: true,
            message: `engine ${engineHeader}`,
            engineName: engineHeader,
        };
    }
    catch (err) {
        const e = err;
        const reason = e.name === 'AbortError' ? `timeout (${HEALTH_TIMEOUT_MS}ms)` : e.message;
        return {
            ok: false,
            message: `engine probe unreachable: ${reason}`,
            remedy: 'Restart host-cp via `olam host-cp start`; verify port 19000 is bound to 127.0.0.1.',
        };
    }
    finally {
        clearTimeout(timeout);
    }
}
/**
 * Probe: Colima version. On macOS / Linux dev hosts using Colima as the
 * Docker daemon, the 0.10.x release line ships a broken
 * `--kubernetes` flag (upstream issue). When detected, surface a WARN
 * with the plan-specced verbatim remediation text.
 *
 * Behaviour matrix:
 *   - `colima` binary not on PATH → PASS (most operators don't run Colima)
 *   - `colima version` returns non-0.10.x → PASS with the detected version
 *   - `colima version` returns 0.10.x      → WARN with verbatim remedy
 *   - `colima version` errors (binary present, daemon issue, etc.) → PASS
 *       with a soft message (don't fail doctor on a tool-specific quirk)
 */
export async function probeColimaVersion(dockerExec = defaultDockerExec) {
    // Probe-which: does the binary exist? Spawn `colima --version` so we get
    // both an "is it there?" check and the version in one call. If the
    // first arg is unsupported we fall back to `version`.
    const r = dockerExec('colima', ['version']);
    if (r.status === null || (r.status !== 0 && /not found|ENOENT|not.found/i.test(r.stderr))) {
        // Binary absent — most operators use Docker Desktop / native Docker.
        return { ok: true, message: 'colima not installed (not in use)' };
    }
    if (r.status !== 0) {
        return {
            ok: true,
            message: `colima present but version probe failed: ${(r.stderr || '').trim()}`,
        };
    }
    // `colima version` output starts with e.g. "colima version 0.10.1" or
    // "0.10.1" depending on the build. Tokenise + find the first semver.
    const tokens = r.stdout.split(/\s+/);
    const version = tokens.find((t) => /^v?\d+\.\d+\.\d+/.test(t)) ?? '';
    if (!version) {
        return {
            ok: true,
            message: `colima present but version unrecognised: ${r.stdout.trim().slice(0, 80)}`,
        };
    }
    if (COLIMA_010_RANGE.test(version)) {
        return {
            ok: true,
            warn: true,
            message: `colima ${version} detected — known kubernetes-mode regression`,
            remedy: COLIMA_010_WARN_TEXT,
        };
    }
    return { ok: true, message: `colima ${version} (clear)` };
}
/**
 * Probe: K8s API reachable via `kubectl version --output=json --request-timeout=2s`.
 *
 * Replaces probeDockerDaemon at position 1 of the doctor probe array when
 * the active engine is kubernetes (Decision 16 positional contract — the
 * `--json` consumer index stability is load-bearing).
 *
 * No kubectl jargon in the doctor output — this probe's row label stays
 * canonical ("api server") and the message hides the underlying
 * implementation detail.
 */
export async function probeK8sApiReachable(exec = defaultDockerExec) {
    // `kubectl version --short` was removed upstream; --output=json + parse.
    const r = exec('kubectl', ['version', '--output=json', '--request-timeout=2s']);
    if (r.status === 0 && r.stdout.length > 0) {
        let parsed;
        try {
            parsed = JSON.parse(r.stdout);
        }
        catch {
            parsed = null;
        }
        const obj = parsed && typeof parsed === 'object' ? parsed : {};
        const serverInfo = obj.serverVersion;
        const gitVersion = typeof serverInfo?.gitVersion === 'string' ? serverInfo.gitVersion : 'unknown';
        return { ok: true, message: `api server ${gitVersion} reachable` };
    }
    return {
        ok: false,
        message: 'api server not reachable',
        remedy: 'Confirm the cluster is up (e.g. `k3d cluster list`) and your context points at it.',
    };
}
/**
 * Probe: required olam images are pullable on the K8s engine.
 *
 * Replaces probeImagePresence at position 2 of the doctor probe array when
 * the active engine is kubernetes (Decision 16 positional contract).
 *
 * Implementation note: on k3d / minikube, image presence is per-node and
 * checked via `kubectl get nodes -o json` + inspecting the `status.images`
 * arrays. We probe the host-cp image (`olam-host-cp:latest`) — the canonical
 * minimum-set. If absent, point the operator at `k3d image import` (or the
 * cluster's equivalent) without exposing kubectl in the remedy.
 */
export async function probeK8sImagePresence(refs, exec = defaultDockerExec) {
    const r = exec('kubectl', ['get', 'nodes', '-o', 'json', '--request-timeout=2s']);
    if (r.status !== 0) {
        return {
            ok: false,
            message: 'unable to list cluster nodes for image presence check',
            remedy: 'Confirm the cluster is up and your context points at it; re-run `olam doctor`.',
        };
    }
    let parsed;
    try {
        parsed = JSON.parse(r.stdout);
    }
    catch {
        return {
            ok: false,
            message: 'cluster node list returned malformed output',
            remedy: 'Restart your cluster; if persistent, re-create it.',
        };
    }
    const nodes = parsed && typeof parsed === 'object'
        ? parsed.items
        : undefined;
    if (!Array.isArray(nodes) || nodes.length === 0) {
        return {
            ok: false,
            message: 'cluster has no nodes',
            remedy: 'Start the cluster and try again.',
        };
    }
    const presentImages = new Set();
    for (const node of nodes) {
        const status = node.status;
        const images = status?.images;
        if (!Array.isArray(images))
            continue;
        for (const img of images) {
            const names = img.names;
            if (!Array.isArray(names))
                continue;
            for (const n of names)
                presentImages.add(n);
        }
    }
    for (const ref of refs) {
        if (!hasImageRef(presentImages, ref)) {
            return {
                ok: false,
                message: `image ${ref} not present on any cluster node`,
                remedy: 'Run `olam bootstrap` to import the published image into the cluster.',
            };
        }
    }
    return { ok: true, message: `${refs.length} image(s) present on cluster` };
}
function hasImageRef(present, ref) {
    if (present.has(ref))
        return true;
    // k3d / containerd often prefix with `docker.io/library/` for bare names.
    const variants = [`docker.io/library/${ref}`, `docker.io/${ref}`];
    return variants.some((v) => present.has(v));
}
/**
 * Default implementation: checks the provided context name (or reads
 * `kubectl config current-context` when omitted) against well-known prefixes.
 *
 * Heuristic (fail-open → falls back to k3d remedy as safe default):
 *   - context starts with 'colima' → 'colima'
 *   - context starts with 'k3d-'   → 'k3d'
 *   - else                         → 'unknown'
 */
export function defaultDetectK8sClusterType(kubectlContext) {
    const ctx = kubectlContext ?? readCurrentKubectlContext();
    if (!ctx)
        return 'unknown';
    if (ctx.startsWith('colima'))
        return 'colima';
    if (ctx.startsWith('k3d-'))
        return 'k3d';
    return 'unknown';
}
function readCurrentKubectlContext() {
    const r = spawnSync('kubectl', ['config', 'current-context'], {
        encoding: 'utf-8',
        stdio: ['ignore', 'pipe', 'pipe'],
    });
    if (r.status !== 0)
        return undefined;
    return r.stdout?.trim() || undefined;
}
// ── Setup wizard probes (binary-presence checks, not cluster connectivity) ─────
/**
 * Probe: `kubectl` binary on PATH.
 *
 * Checks for the binary via `kubectl version --client --output=json`.
 * Does NOT require a live cluster — client-only check. Used by the
 * kubernetes substrate path of `olam setup` Phase 1 system check.
 */
export async function probeKubectl(exec = defaultDockerExec) {
    const r = exec('kubectl', ['version', '--client', '--output=json']);
    if (r.status === 0 && r.stdout.length > 0) {
        let clientVersion = 'unknown';
        try {
            const parsed = JSON.parse(r.stdout);
            const clientInfo = parsed.clientVersion;
            if (typeof clientInfo?.gitVersion === 'string') {
                clientVersion = clientInfo.gitVersion;
            }
        }
        catch {
            // version string unavailable; probe still passes
        }
        return { ok: true, message: `kubectl ${clientVersion} on PATH` };
    }
    return {
        ok: false,
        message: 'kubectl not found on PATH',
        remedy: 'Install kubectl: macOS: `brew install kubectl`; Linux: https://kubernetes.io/docs/tasks/tools/install-kubectl-linux/',
    };
}
/**
 * Probe: `k3d` binary on PATH (macOS kubernetes substrate).
 *
 * Used by `olam setup --substrate=kubernetes` Phase 1 on macOS to
 * distinguish "k3d missing" (defer to Phase 1.5 install) from
 * "k3d present". Returns ok:false so Phase 1.5 knows to install.
 */
export async function probeK3d(exec = defaultDockerExec) {
    const r = exec('k3d', ['version']);
    if (r.status === 0 && r.stdout.length > 0) {
        const versionLine = r.stdout.trim().split('\n')[0] ?? '';
        return { ok: true, message: `k3d ${versionLine} on PATH` };
    }
    return {
        ok: false,
        message: 'k3d not found on PATH',
        remedy: 'Install k3d: `brew install k3d` (macOS) or curl -s https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | bash',
    };
}
/**
 * Build the doctor/upgrade remedy text for a missing docker socket bind-mount.
 * Colima operators need `colima start --kubernetes` with the socket mount flag;
 * k3d operators need `k3d cluster create` with the --volume flag.
 * Issue #713 fix: no longer k3d-only.
 */
export function buildDockerSocketRemedy(clusterType) {
    if (clusterType === 'colima') {
        return ('Recreate the Colima k3s cluster with the docker socket bind-mount:\n' +
            '  colima stop\n' +
            '  colima start --kubernetes \\\n' +
            '    --mount /var/run/docker.sock:/var/run/docker.sock:w \\\n' +
            '    --mount ~/.config/gh:/host/.config/gh:r\n' +
            'Then run: olam upgrade');
    }
    // k3d or unknown — emit the k3d command as the safe default.
    return ('Recreate the k3d cluster with the docker socket bind-mount:\n' +
        '  k3d cluster create olam-host \\\n' +
        '    --volume /var/run/docker.sock:/var/run/docker.sock@server:* \\\n' +
        '    --volume ~/.config/gh:/host/.config/gh \\\n' +
        '    --wait --timeout 90s\n' +
        'Then run: olam upgrade');
}
//# sourceMappingURL=health-probes.js.map