/**
 * olam doctor — pass/fail host-health command.
 *
 * Phase A3 of olam-operator-onboarding-parity (original 5 probes) +
 * Phase C / C2 of olam-host-cp-k3s-prereqs (engine + colima probes) +
 * Phase 2 Phase A A5 (probeNodeMemory — kubernetes-only, D29).
 *
 * Probe layout — Decision 16 positional contract for `--json` consumers:
 *
 *   On engine=docker (default):
 *     1. docker daemon                — probeDockerDaemon (fast-fail gate)
 *     2. images                       — probeImagePresence (per-image)
 *     3. host-cp /health              — probeHostCpHealth
 *     4. auth vault                   — probeAuthVault
 *     5. KG storage                   — probeKgStorage
 *     6. engine                       — probeEngine (NEW Phase C / C2)
 *     7. colima version               — probeColimaVersion (NEW Phase C / C2)
 *     8. bundle freshness             — probeBundleFreshness (NEW olam-world-bundle-freshness Phase A1)
 *        Position 8 in docker mode is docker-only (per-MODE positional contract;
 *        kubernetes mode keeps slot 8 = node memory). Non-gating: WARN when any
 *        world's `/opt/olam/agent-stream/dist/` differs from the host's freshly
 *        built `packages/intelligence/dist/agent-stream/`; PASS otherwise.
 *        Silent in kubernetes substrate mode (worlds-as-pods don't support
 *        `docker exec` reach-through; Phase C of the plan addresses cloud paths).
 *
 *   On engine=kubernetes:
 *     1. api server                   — probeK8sApiReachable     (slot replaced)
 *     2. images                       — probeK8sImagePresence    (slot replaced)
 *     3. host-cp /health              — probeHostCpHealth        (unchanged)
 *     4. auth vault                   — probeAuthVault           (unchanged)
 *     5. KG storage                   — probeKgStorage           (unchanged)
 *     6. engine                       — probeEngine              (unchanged)
 *     7. colima version               — probeColimaVersion       (unchanged)
 *     8. node memory                  — probeNodeMemory          (NEW Phase 2 A5, D29)
 *        Position 8 is kubernetes-only (D11 per-MODE positional contract).
 *        Non-gating: WARN when free RAM <4 GiB; PASS otherwise.
 *        Silent in docker/compose substrate mode.
 *
 * The `--json` payload's `probes[]` indexes are stable across engine swap:
 * consumers parsing `probes[0]` for "daemon-reachability" still see a
 * daemon-reachability probe; consumers parsing `probes[5]` for engine
 * identity see it regardless of which runtime is active.
 *
 * Exit code:
 *   - 0 on all PASS
 *   - 0 on PASS+WARN  (WARN doesn't block — Colima 0.10.x is a hint)
 *   - 1 on any FAIL
 *
 * Output format:
 *   - `[PASS]` / `[WARN]` / `[FAIL]` prefix per row.
 *   - Indented detail under WARN/FAIL.
 *   - `--json` emits the same probe array (canonical positional shape).
 */
import { probeDockerDaemon, probeComposePlugin, probeImagePresence, probeHostCpHealth, probeAuthVault, probeKgStorage, probeEngine, probeColimaVersion, probeK8sApiReachable, probeK8sImagePresence, defaultDetectK8sClusterType, buildDockerSocketRemedy, } from '../lib/health-probes.js';
import { probeBundleFreshness } from '../lib/bundle-freshness.js';
import { printError, printSuccess, printWarning, printHeader } from '../output.js';
import { kubectlWrap } from '../lib/kubectl-wrap.js';
import { PERIPHERALS } from '../lib/peripheral-registry.js';
import { statusHostSideProxy } from '../lib/host-side-proxy.js';
import { resolveK8sAssetsRoot } from '../lib/k8s-bootstrap.js';
import * as fs from 'node:fs';
import * as net from 'node:net';
import * as path from 'node:path';
import * as os from 'node:os';
import { OLAM_HOME, OLAM_CONFIG_PATH, readConfig } from '../lib/config.js';
import { HERMES_KG_HOOK_SENTINEL } from './hermes-kg-hook.js';
/** Default registry prefix for registry-tagged images. Mirrors bootstrap.ts's default. */
const DEFAULT_REGISTRY = 'ghcr.io/pleri';
/**
 * Canonical image set olam doctor verifies, in {bare, registry} pairs.
 *
 * upgrade.ts maintains a dual-tag invariant: each image is tagged BOTH
 * bare (`olam-host-cp:latest`) AND registry-prefixed
 * (`ghcr.io/pleri/olam-host-cp:latest`). The bare tag is used by
 * `docker run` paths; the registry tag is used by compose.yaml.
 * `docker image prune` can orphan one tag while leaving the other —
 * doctor must probe both to surface the failure that compose.yaml
 * would hit on next recreate (audit finding HIGH).
 *
 * `olam-auth:local` and `olam-mcp-auth:local` are bare-only by design
 * (they don't ship a registry-prefixed `:local` variant); they appear
 * in the bare set only.
 */
const REQUIRED_IMAGE_PAIRS = [
    { bare: 'olam-host-cp:latest', registryName: 'olam-host-cp:latest' },
    { bare: 'olam-auth:local' },
    { bare: 'olam-devbox:base', registryName: 'olam-devbox:base' },
    { bare: 'olam-mcp-auth:local' },
];
function buildRequiredImageRefs(registry) {
    const refs = [];
    for (const pair of REQUIRED_IMAGE_PAIRS) {
        refs.push(pair.bare);
        if (pair.registryName)
            refs.push(`${registry}/${pair.registryName}`);
    }
    return refs;
}
/**
 * Run probes against the host. Probe-1 (daemon / API) runs FIRST + ALONE.
 * If it fails, the remaining slot-1+2 probes are SKIPPED — image presence
 * probes against a paused daemon / unreachable cluster return confusing
 * errors that misdirect the operator's fix (Risk T5 mitigation per plan).
 *
 * On daemon-success, probes 2-7 run in parallel via Promise.all
 * (P1 budget: doctor < 2s on healthy host).
 *
 * Phase C / C2: engine-aware. When `engine=kubernetes` we substitute
 * positions 1 + 2 in-place to preserve the positional `--json` contract.
 */
export async function runDoctor(opts, deps = {}) {
    const dockerExec = deps.dockerExec;
    const fetchImpl = deps.fetchImpl;
    const olamHomeOverride = deps.olamHomeOverride;
    const hermesHomeOverride = deps.hermesHomeOverride;
    const registry = deps.registry ?? DEFAULT_REGISTRY;
    const engine = deps.engine ?? resolveEngine(deps.configPath);
    const isK8s = engine === 'kubernetes';
    // Slot 1: daemon / API reachability (fast-fail gate). Engine-aware.
    const slot1Result = isK8s
        ? await probeK8sApiReachable(dockerExec)
        : await probeDockerDaemon(dockerExec);
    const slot1Name = isK8s ? 'api server' : 'docker daemon';
    const rows = [{ name: slot1Name, result: slot1Result, position: 1 }];
    if (!slot1Result.ok) {
        // Fast-fail: skip per-image / host-cp / auth / kg probes when the
        // engine's daemon-equivalent is down. Engine + colima probes still
        // surface for diagnostic context, though, since they may hint at WHY
        // the daemon is unreachable (e.g. Colima 0.10.x kubernetes-mode bug).
        const [engineRes, colimaRes] = await Promise.all([
            probeEngine(fetchImpl),
            probeColimaVersion(dockerExec),
        ]);
        rows.push({ name: 'engine', result: engineRes, position: 6 }, { name: 'colima version', result: colimaRes, position: 7 });
        return emit(makeReport(rows), opts);
    }
    // Slots 2-7: parallel (independent; no shared state).
    const imageRefs = buildRequiredImageRefs(registry);
    const slot2Promise = isK8s
        ? probeK8sImagePresence(imageRefs, dockerExec)
        : probeImagePresence(imageRefs, dockerExec);
    const [imageResult, hostCpResult, authResult, kgResult, engineResult, colimaResult] = await Promise.all([
        slot2Promise,
        probeHostCpHealth(fetchImpl),
        probeAuthVault(olamHomeOverride),
        probeKgStorage(olamHomeOverride),
        probeEngine(fetchImpl),
        probeColimaVersion(dockerExec),
    ]);
    rows.push({ name: 'images', result: imageResult, position: 2 }, { name: 'host-cp /health', result: hostCpResult, position: 3 }, { name: 'auth vault', result: authResult, position: 4 }, { name: 'KG storage', result: kgResult, position: 5 }, { name: 'engine', result: engineResult, position: 6 }, { name: 'colima version', result: colimaResult, position: 7 });
    // Per-MODE positional contract (D11) — slot 8+ branches by engine.
    // docker mode: bundle freshness probe — olam-world-bundle-freshness Phase A1
    //   (non-gating WARN-or-PASS; informational signal for in-development
    //   agent-stream patches).
    // kubernetes mode: D2 peripheral suite (positions 14-25) + storage class
    //   (26) + node memory (27). Non-gating informational probes; silent in
    //   docker/compose substrate mode. Positions 8-13 reserved for future
    //   Phase 1b probes.
    if (isK8s) {
        const wrapImpl = deps.kubectlWrapImpl ?? kubectlWrap;
        const k8sCtx = deps.kubectlContext;
        const manifestsDir = deps.manifestsDir ?? path.join(OLAM_HOME, 'k8s', 'manifests');
        const readFileSyncImpl = deps.readFileSyncImpl ?? fs.readFileSync;
        // D11 positional contract — positions 8-13 reserved (Phase 1b D11).
        // These slots are intentionally empty in the kubernetes probe suite.
        // They are NOT emitted as rows; consumers must not assume contiguity.
        // Future probes for managed-k8s / cert-manager / ingress can occupy 9-13.
        // Positions 14-28: 5 peripherals × 3 probes in alphabetical order.
        // Alphabetical: auth-service, kg-service, mcp-auth-service, memory-service, plan-chat-service.
        const sortedPeripherals = [...PERIPHERALS].sort((a, b) => a.name.localeCompare(b.name));
        let peripheralPosition = 14;
        const peripheralProbeRows = await Promise.all(sortedPeripherals.map(async (peripheral) => {
            const startPos = peripheralPosition;
            peripheralPosition += 3;
            return runPeripheralProbes(peripheral, startPos, k8sCtx, wrapImpl);
        }));
        for (const probeRows of peripheralProbeRows) {
            rows.push(...probeRows);
        }
        // Position 29: probeStorageClassMatch (D24 — non-gating, WARN on mismatch).
        const storageClassResult = await probeStorageClassMatch(sortedPeripherals, k8sCtx, manifestsDir, wrapImpl, readFileSyncImpl);
        rows.push({ name: 'storage class match', result: storageClassResult, position: 29 });
        // Position 30: probeNodeMemory (D29 — already seeded in Phase A A5).
        const nodeMemResult = probeNodeMemory(dockerExec, k8sCtx);
        rows.push({ name: 'node memory', result: nodeMemResult, position: 30 });
        // Position 31: probeHostSideProxyReachability (olam-k3d-on-mac-substrate-
        // decision Phase C C1). Replaces probeDockerSocketBindMount (Phase B B3
        // retracted; host-cp no longer mounts docker.sock). Asserts the host-side
        // tecnativa/docker-socket-proxy container is running on the operator's
        // docker daemon; host-cp connects to it via the in-cluster ExternalName
        // Service `docker-socket-proxy.olam.svc.cluster.local:2375` (kube-dns
        // CNAME → host.k3d.internal). Non-gating: WARN when proxy is stopped or
        // status-unknown; PASS when running.
        const socketCheckImpl = deps.dockerSocketCheckImpl;
        // Distinguish 'undefined' (not injected — use real resolver) from 'null'
        // (test explicitly simulating bundle-not-found). `??` would collapse the
        // explicit-null case into the default; use a strict undefined check.
        const composePath = deps.hostSideProxyComposePathOverride !== undefined
            ? deps.hostSideProxyComposePathOverride
            : defaultResolveHostSideProxyComposePath();
        const socketResult = await probeHostSideProxyReachability(composePath, socketCheckImpl);
        rows.push({ name: 'host-side proxy', result: socketResult, position: 31 });
    }
    else {
        const bundleResult = await probeBundleFreshness({
            dockerExec,
            repoRootOverride: deps.repoRootOverride,
        });
        rows.push({ name: 'bundle freshness', result: bundleResult, position: 8 });
    }
    // Position 9 (docker) / 32 (kubernetes): Hermes integration probe.
    // Non-gating WARN-or-PASS. Only emitted when hermesHomeOverride is explicitly
    // provided (opt-in for tests + `registerDoctor` passes the real path on production).
    // This keeps the positional contract stable for existing --json consumers on
    // machines where ~/.hermes/ happens to exist.
    if (hermesHomeOverride !== undefined && fs.existsSync(hermesHomeOverride)) {
        const hermesResult = probeHermesIntegration(hermesHomeOverride);
        const hermesPosition = isK8s ? 32 : 9;
        rows.push({ name: 'hermes integration', result: hermesResult, position: hermesPosition });
    }
    return emit(makeReport(rows), opts);
}
// ── D2 Kubernetes peripheral probes (positions 14-28) ─────────────────────
const K8S_NAMESPACE = 'olam';
/**
 * Run 3 probes for one peripheral: Reachable (kubectl exec), PortForwardLiveness (TCP),
 * ImagePresent (kubectl get pod jsonpath). Returns 3 ProbeRows at consecutive positions.
 */
async function runPeripheralProbes(peripheral, startPosition, kubectlContext, wrapImpl) {
    const ctxArgs = kubectlContext ? ['--context', kubectlContext] : [];
    const rows = [];
    // Probe N: Reachable — kubectl exec into the pod and wget the health path.
    const reachableResult = await (async () => {
        const result = await wrapImpl([
            ...ctxArgs,
            'exec',
            `deploy/${peripheral.k8sResourceName}`,
            '-n', K8S_NAMESPACE,
            '--',
            'wget', '-qO-', `http://localhost:${peripheral.port}${peripheral.healthPath}`,
        ], { timeout: 10_000 });
        if (result.ok) {
            return { ok: true, message: `${peripheral.name} pod reachable (${peripheral.healthPath})` };
        }
        const errLine = result.stderr.split('\n')[0] ?? '';
        return {
            ok: false,
            message: `${peripheral.name} pod not reachable`,
            remedy: `Check pod logs: kubectl logs deploy/${peripheral.k8sResourceName} -n ${K8S_NAMESPACE}. stderr: ${errLine}`,
        };
    })();
    rows.push({ name: `${peripheral.name} reachable`, result: reachableResult, position: startPosition });
    // Probe N+1: PortForwardLiveness — TCP connect to 127.0.0.1:<port>.
    const pfLivenessResult = await (async () => {
        return new Promise((resolve) => {
            const socket = new net.Socket();
            let done = false;
            const finish = (alive) => {
                if (done)
                    return;
                done = true;
                socket.destroy();
                if (alive) {
                    resolve({ ok: true, message: `${peripheral.name} port-forward live on :${peripheral.port}` });
                }
                else {
                    resolve({
                        ok: true,
                        warn: true,
                        message: `${peripheral.name} port-forward not live on :${peripheral.port}`,
                        remedy: `Run \`olam upgrade\` to establish port-forwards for peripheral services.`,
                    });
                }
            };
            const timer = setTimeout(() => finish(false), 2_000);
            socket.once('connect', () => { clearTimeout(timer); finish(true); });
            socket.once('error', () => { clearTimeout(timer); finish(false); });
            socket.connect(peripheral.port, '127.0.0.1');
        });
    })();
    rows.push({ name: `${peripheral.name} port-forward`, result: pfLivenessResult, position: startPosition + 1 });
    // Probe N+2: ImagePresent — kubectl get pod jsonpath for container image.
    const imagePresentResult = await (async () => {
        const result = await wrapImpl([
            ...ctxArgs,
            'get', 'pod',
            '-n', K8S_NAMESPACE,
            '-l', `app=${peripheral.k8sResourceName}`,
            '-o', `jsonpath={.items[0].status.containerStatuses[0].image}`,
        ], { timeout: 10_000 });
        const image = result.stdout.trim();
        if (result.ok && image.length > 0) {
            return { ok: true, message: `${peripheral.name} image present: ${image}` };
        }
        return {
            ok: true,
            warn: true,
            message: `${peripheral.name} image not detected`,
            remedy: `Verify pod is running: kubectl get pods -n ${K8S_NAMESPACE} -l app=${peripheral.k8sResourceName}`,
        };
    })();
    rows.push({ name: `${peripheral.name} image`, result: imagePresentResult, position: startPosition + 2 });
    return rows;
}
/**
 * Position 26 — probeStorageClassMatch (D24).
 *
 * For each peripheral, reads the bound PVC's storageClassName from the cluster
 * and compares against the manifest declaration (45-pvc.yaml). WARN on mismatch.
 * Non-gating per D24 spec.
 */
async function probeStorageClassMatch(peripherals, kubectlContext, manifestsDir, wrapImpl, readFileSyncImpl) {
    const ctxArgs = kubectlContext ? ['--context', kubectlContext] : [];
    const mismatches = [];
    for (const peripheral of peripherals) {
        // Read declared storageClassName from the manifest PVC file.
        const pvcManifestPath = path.join(manifestsDir, peripheral.name, '45-pvc.yaml');
        let declaredClass;
        try {
            const raw = readFileSyncImpl(pvcManifestPath, 'utf8');
            // Simple regex parse — avoid bringing in yaml parse dep here.
            const match = /storageClassName:\s*(\S+)/.exec(raw);
            declaredClass = match?.[1];
        }
        catch {
            // Manifest not found — skip this peripheral (not deployed yet).
            continue;
        }
        // Read actual storageClassName from the cluster.
        const pvcName = `olam-${peripheral.name}-data`;
        const result = await wrapImpl([
            ...ctxArgs,
            'get', 'pvc', pvcName,
            '-n', K8S_NAMESPACE,
            '-o', `jsonpath={.spec.storageClassName}`,
        ], { timeout: 10_000 });
        if (!result.ok || !result.stdout.trim()) {
            // PVC not found — skip (peripheral not deployed yet).
            continue;
        }
        const actualClass = result.stdout.trim();
        if (declaredClass && actualClass !== declaredClass) {
            mismatches.push(`${peripheral.name}: declared=${declaredClass} actual=${actualClass}`);
        }
    }
    if (mismatches.length > 0) {
        return {
            ok: true,
            warn: true,
            message: `storage class mismatch on ${mismatches.length} PVC(s)`,
            remedy: `Mismatches: ${mismatches.join('; ')}. Re-provision PVCs or update manifest storageClassName.`,
        };
    }
    return { ok: true, message: 'storage class match: all PVCs match manifest declarations' };
}
/**
 * probeNodeMemory — Phase 2 A5, Decision D29.
 *
 * Reads free RAM on the k3d node via `kubectl top node --no-headers`.
 * The fifth column is "FREE" in Ki. Warns when free RAM is below 4 GiB
 * (T4 risk: peripheral service pods OOM before the operator notices).
 *
 * Non-gating (WARN, not FAIL). Only runs in engine=kubernetes mode.
 *
 * kubectl top requires metrics-server. If metrics-server is absent (fresh
 * k3d cluster), `kubectl top` exits non-zero. In that case we emit WARN
 * with a "metrics-server not installed" note rather than FAIL.
 */
function probeNodeMemory(dockerExec, kubectlContext) {
    if (!dockerExec) {
        // No exec injected — can't run kubectl. Skip gracefully.
        return { ok: true, message: 'node memory probe skipped (no exec)' };
    }
    const contextArgs = kubectlContext ? ['--context', kubectlContext] : [];
    const result = dockerExec('kubectl', [...contextArgs, 'top', 'node', '--no-headers']);
    if (result.status !== 0) {
        // metrics-server absent or cluster unreachable.
        const hint = (result.stderr ?? '').includes('not available') || (result.stderr ?? '').includes('Metrics')
            ? 'metrics-server not installed on cluster — install via: kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml'
            : `kubectl top node failed: ${(result.stderr ?? '').trim()}`;
        return {
            ok: true,
            warn: true,
            message: 'node memory unknown (kubectl top unavailable)',
            remedy: hint,
        };
    }
    // Parse the first node's output. Format:
    //   NAME   CPU(cores)   CPU%   MEMORY(bytes)   MEMORY%
    // We want total node capacity which isn't in `top` output directly;
    // use `kubectl get node -o json` to fetch allocatable memory and compare.
    const topLine = (result.stdout ?? '').trim().split('\n')[0];
    if (!topLine) {
        return {
            ok: true,
            warn: true,
            message: 'node memory unknown (empty kubectl top output)',
            remedy: 'Verify metrics-server is running: kubectl get pods -n kube-system | grep metrics-server',
        };
    }
    // Get allocatable memory from `kubectl get nodes -o jsonpath`.
    const allocResult = dockerExec('kubectl', [
        ...contextArgs,
        'get', 'nodes', '-o', 'jsonpath={.items[0].status.allocatable.memory}',
    ]);
    if (allocResult.status !== 0 || !(allocResult.stdout ?? '').trim()) {
        return {
            ok: true,
            warn: true,
            message: 'node memory unknown (could not read allocatable memory)',
            remedy: 'kubectl get nodes unreachable',
        };
    }
    // Parse allocatable memory (e.g. "8059228Ki" or "8Gi").
    const raw = (allocResult.stdout ?? '').trim();
    let allocKi;
    if (raw.endsWith('Ki')) {
        allocKi = parseInt(raw.slice(0, -2), 10);
    }
    else if (raw.endsWith('Mi')) {
        allocKi = parseInt(raw.slice(0, -2), 10) * 1024;
    }
    else if (raw.endsWith('Gi')) {
        allocKi = parseInt(raw.slice(0, -2), 10) * 1024 * 1024;
    }
    else {
        // Bytes (no suffix).
        allocKi = Math.floor(parseInt(raw, 10) / 1024);
    }
    // Parse used memory from kubectl top output (4th column: MEMORY(bytes) in Mi).
    const topCols = topLine.trim().split(/\s+/);
    const usedMiStr = topCols[3] ?? '0Mi';
    const usedKi = usedMiStr.endsWith('Mi')
        ? parseInt(usedMiStr.slice(0, -2), 10) * 1024
        : parseInt(usedMiStr, 10);
    const freeKi = allocKi - usedKi;
    const freeGiB = freeKi / (1024 * 1024);
    const WARN_THRESHOLD_GIB = 4;
    if (freeGiB < WARN_THRESHOLD_GIB) {
        return {
            ok: true,
            warn: true,
            message: `node free RAM ${freeGiB.toFixed(1)} GiB (below ${WARN_THRESHOLD_GIB} GiB threshold)`,
            remedy: `Free at least ${WARN_THRESHOLD_GIB} GiB of node RAM before deploying peripheral services to avoid OOM (T4 risk).`,
        };
    }
    return {
        ok: true,
        message: `node free RAM ${freeGiB.toFixed(1)} GiB (≥${WARN_THRESHOLD_GIB} GiB threshold)`,
    };
}
/**
 * Resolve the bundled docker-socket-proxy.compose.yaml path (Phase C C1).
 * Mirrors the upgrade-kubernetes.ts helper of the same name. Returns null
 * when the bundle layout is unexpected (dev contexts without a built
 * tarball); the probe treats null as a warning.
 */
function defaultResolveHostSideProxyComposePath() {
    const root = resolveK8sAssetsRoot();
    if (root === null)
        return null;
    const candidate = path.join(root, 'host-side', 'docker-socket-proxy.compose.yaml');
    return fs.existsSync(candidate) ? candidate : null;
}
// defaultDetectK8sClusterType, buildDockerSocketRemedy, DetectK8sClusterTypeFn
// are imported from health-probes.ts (canonical location). Re-exported here
// for back-compat with callers that imported them from doctor.ts. Their
// docker-socket-related codepath is unused since Phase B B3, but the symbols
// remain available for other consumers.
export { defaultDetectK8sClusterType, buildDockerSocketRemedy };
/**
 * Default implementation: invoke statusHostSideProxy from host-side-proxy.ts.
 * Returns 'running' / 'stopped' / 'unknown'.
 *
 * This replaces the previous pod-internal `test -S /var/run/docker.sock`
 * check (R3-A/R4-W2-E surface) — host-cp no longer mounts docker.sock; it
 * connects to the host-side proxy via TCP through the in-cluster
 * ExternalName Service. The probe now asks the more direct question:
 * "is the host-side proxy container actually running on the operator's
 * docker daemon?"
 */
async function defaultDockerSocketCheck(composePath) {
    const result = statusHostSideProxy(composePath);
    return result.status;
}
/**
 * probeHostSideProxyReachability — olam-k3d-on-mac-substrate-decision Phase C C1.
 *
 * Position 28 in the kubernetes probe suite (replaces probeDockerSocketBindMount).
 * Asserts the host-side `tecnativa/docker-socket-proxy` container is running
 * on the operator's docker daemon. host-cp's kubernetes-substrate codepath
 * connects to this proxy via TCP through the in-cluster `docker-socket-proxy`
 * ExternalName Service (kube-dns CNAME → host.k3d.internal:2375).
 *
 * PASS: statusHostSideProxy returns 'running' — proxy reachable on host
 *       (operator's `olam upgrade` Step 0.7 succeeded, OR Linux operator
 *       brought up the proxy manually per the install guide).
 * WARN: 'stopped' — proxy not running. Remedy: `olam upgrade` (macOS) OR
 *       `docker compose -f $(npm root -g)/@pleri/olam-cli/host-cp/k8s/
 *       host-side/docker-socket-proxy.compose.yaml up -d` (Linux).
 * WARN: 'unknown' — probe failure (docker compose plugin absent, daemon
 *       unreachable, malformed output). Remedy includes plugin-install hint.
 *
 * Non-gating (WARN, not FAIL). Only runs in engine=kubernetes mode.
 */
async function probeHostSideProxyReachability(composePath, checkImpl) {
    if (composePath === null) {
        return {
            ok: true,
            warn: true,
            message: 'host-side proxy compose yaml not found in CLI bundle',
            remedy: 'The bundled `docker-socket-proxy.compose.yaml` could not be resolved.\n' +
                'This is a packaging issue — try `npm install -g @pleri/olam-cli@latest`\n' +
                'to restore the bundled k8s assets.',
        };
    }
    const check = checkImpl ?? defaultDockerSocketCheck;
    const status = await check(composePath);
    if (status === 'running') {
        return {
            ok: true,
            message: 'host-side docker-socket-proxy running on operator docker daemon',
        };
    }
    if (status === 'stopped') {
        return {
            ok: true,
            warn: true,
            message: 'host-side docker-socket-proxy not running',
            remedy: 'Start the proxy on the operator host:\n' +
                `  - macOS: re-run \`olam upgrade\` (Step 0.7 auto-starts the proxy)\n` +
                `  - Linux: \`docker compose -f ${composePath} up -d\`\n` +
                'host-cp connects to docker through this proxy via the in-cluster\n' +
                'ExternalName Service `docker-socket-proxy.olam.svc.cluster.local:2375`.',
        };
    }
    return {
        ok: true,
        warn: true,
        message: 'host-side docker-socket-proxy status unknown',
        remedy: 'Probe failed before reaching the docker daemon. Check:\n' +
            '  - `docker compose version` succeeds (compose v2 plugin installed)\n' +
            '  - `docker context ls` shows your active context (colima for macOS)\n' +
            '  - `docker info` succeeds against that context',
    };
}
/**
 * runK8sPreflight — Issue #713 narrowed pre-deploy check for `olam substrate set kubernetes`.
 *
 * The full `runDoctor` kubernetes probe suite (probes 3-7: host-cp /health,
 * auth vault, KG storage, engine, colima) requires host-cp to be already deployed.
 * When the operator runs `substrate set kubernetes` on a fresh cluster BEFORE
 * `olam upgrade`, those probes fail with ok:false because host-cp doesn't exist yet.
 *
 * This narrowed preflight only checks gates the operator CAN satisfy BEFORE
 * `olam upgrade`:
 *   1. kubectl binary present + cluster reachable (probeK8sApiReachable)
 *
 * Exits 0 when the single gate passes; 1 when kubectl/cluster is unreachable.
 * The full `olam doctor --substrate=kubernetes` covers the complete post-deploy
 * suite — operators should run that AFTER `olam upgrade` to confirm health.
 *
 * Exported for injection into substrate.ts's handleSet via SubstrateDeps.runDoctor.
 */
export async function runK8sPreflight(opts, deps = {}) {
    const dockerExec = deps.dockerExec;
    const result = await probeK8sApiReachable(dockerExec);
    const rows = [{ name: 'api server', result, position: 1 }];
    return emit(makeReport(rows), opts);
}
/**
 * runComposePreflight — narrowed pre-deploy check for `olam substrate set compose`.
 *
 * Mirrors {@link runK8sPreflight} for the compose direction. The full
 * `runDoctor` compose suite (probes 3-5: host-cp /health, auth vault, KG storage)
 * requires host-cp to be already running under docker compose. When the operator
 * switches FROM kubernetes TO compose on a host that has never run compose
 * host-cp, those probes fail with ok:false because the container doesn't exist yet.
 *
 * This narrowed preflight only checks the gates the operator CAN satisfy BEFORE
 * `olam bootstrap` / `olam upgrade`:
 *   1. docker daemon reachable    (probeDockerDaemon)
 *   2. docker compose v2 plugin    (probeComposePlugin — B2: npm-only-via-Homebrew operators)
 *
 * Daemon comes first (fast-fail per Risk T5). If the daemon is down, the compose
 * plugin probe would emit a redundant / confusing remedy.
 *
 * Exits 0 when both probes pass; 1 when either fails. The full
 * `olam doctor` covers the complete post-deploy suite — operators run that
 * AFTER `olam bootstrap` to confirm host-cp health.
 *
 * Exported for injection into substrate.ts's handleSet via SubstrateDeps.runComposePreflightFn.
 */
export async function runComposePreflight(opts, deps = {}) {
    const dockerExec = deps.dockerExec;
    const daemonResult = await probeDockerDaemon(dockerExec);
    const rows = [{ name: 'docker daemon', result: daemonResult, position: 1 }];
    // Skip compose-plugin probe when daemon is down — the surfacing
    // remedy is "start docker first" and the second row would just clutter.
    if (daemonResult.ok) {
        const composeResult = await probeComposePlugin(dockerExec);
        rows.push({ name: 'docker compose plugin', result: composeResult, position: 2 });
    }
    return emit(makeReport(rows), opts);
}
function makeReport(rows) {
    const failureCount = rows.filter((r) => !r.result.ok).length;
    const warnCount = rows.filter((r) => isWarn(r.result)).length;
    const summary = failureCount === 0 ? 'OK' : 'FAILED';
    return { rows, summary, failureCount, warnCount };
}
/**
 * Resolve the engine substrate for `olam doctor`.
 *
 * Priority (D1 — Phase D config-file fallback):
 *   1. OLAM_HOST_CP_ENGINE env var (explicit override wins)
 *   2. KUBERNETES_SERVICE_HOST env var (in-pod auto-detect)
 *   3. host.substrate in ~/.olam/config.json (config-file fallback — D1 load-bearing fix)
 *   4. 'docker' default
 *
 * The config-file fallback (step 3) closes the round-1+2 ghost-shipped gap:
 * `olam doctor` running on the HOST (not inside a k8s pod) without env vars
 * previously defaulted to docker mode. With this change, operators who have
 * `host.substrate: kubernetes` in their config get the correct 28-probe walk.
 *
 * @param configPath - override for tests (default: OLAM_CONFIG_PATH = ~/.olam/config.json)
 */
function resolveEngine(configPath) {
    const explicit = process.env.OLAM_HOST_CP_ENGINE;
    if (explicit === 'kubernetes')
        return 'kubernetes';
    if (explicit === 'docker')
        return 'docker';
    // In-pod auto-detect (unchanged from pre-D1 behaviour).
    if (process.env.KUBERNETES_SERVICE_HOST)
        return 'kubernetes';
    // Phase D D1: config-file fallback — read host.substrate from ~/.olam/config.json.
    try {
        const cfg = readConfig({ configPath: configPath ?? OLAM_CONFIG_PATH });
        if (cfg.host.substrate === 'kubernetes')
            return 'kubernetes';
    }
    catch {
        // Config absent or unreadable; fall through to docker default.
    }
    return 'docker';
}
function isWarn(r) {
    return r.ok === true && r.warn === true;
}
function emit(report, opts) {
    if (opts.json) {
        process.stdout.write(JSON.stringify({
            summary: report.summary,
            failureCount: report.failureCount,
            warnCount: report.warnCount,
            probes: report.rows.map((r) => {
                const isFail = !r.result.ok;
                const warn = isWarn(r.result);
                const base = {
                    position: r.position,
                    name: r.name,
                    ok: r.result.ok,
                    message: r.result.message,
                };
                if (warn)
                    base.warn = true;
                if (isFail || warn)
                    base.remedy = r.result.remedy;
                return base;
            }),
        }, null, 2) + '\n');
    }
    else {
        renderHuman(report);
    }
    const exitCode = report.summary === 'OK' ? 0 : 1;
    return { exitCode, report };
}
function renderHuman(report) {
    printHeader('olam doctor');
    const namePad = Math.max(...report.rows.map((r) => r.name.length));
    for (const row of report.rows) {
        const label = row.name.padEnd(namePad);
        if (isWarn(row.result)) {
            printWarning(`[WARN] ${label}  ${row.result.message}`);
            const remedy = row.result.remedy;
            if (remedy)
                printWarning(`${''.padEnd(namePad + 7)}  remedy: ${remedy}`);
        }
        else if (row.result.ok) {
            printSuccess(`[PASS] ${label}  ${row.result.message}`);
        }
        else {
            printError(`[FAIL] ${label}  ${row.result.message}`);
            printWarning(`${''.padEnd(namePad + 7)}  remedy: ${row.result.remedy}`);
        }
    }
    process.stdout.write('\n');
    if (report.summary === 'OK') {
        const warnNote = report.warnCount > 0 ? ` (${report.warnCount} warn)` : '';
        printSuccess(`OK — all ${report.rows.length} probes green${warnNote}`);
    }
    else {
        printError(`FAILED — ${report.failureCount} of ${report.rows.length} probes failed`);
    }
}
// ── Hermes integration probe ───────────────────────────────────────────────────
/**
 * Non-gating WARN probe: surfaces when a hermes directory exists but olam is
 * not bootstrapped (missing mcp_servers.olam or kg-first hook).
 *
 * hermesDir: the Hermes home directory to inspect (typically ~/.hermes).
 * Callers that inject a non-existent path get PASS immediately (probe silent).
 */
export function probeHermesIntegration(hermesDir) {
    const configPath = path.join(hermesDir, 'config.yaml');
    const hookPath = path.join(hermesDir, 'hooks', 'kg-first.sh');
    const issues = [];
    // Check mcp_servers.olam in config.yaml
    if (!fs.existsSync(configPath)) {
        issues.push('~/.hermes/config.yaml not found');
    }
    else {
        try {
            const content = fs.readFileSync(configPath, 'utf-8');
            if (!content.includes('mcp_servers') || !content.includes('olam')) {
                issues.push('mcp_servers.olam not configured in ~/.hermes/config.yaml');
            }
        }
        catch {
            issues.push('could not read ~/.hermes/config.yaml');
        }
    }
    // Check kg-first hook
    if (!fs.existsSync(hookPath)) {
        issues.push('kg-first.sh hook not installed at ~/.hermes/hooks/kg-first.sh');
    }
    else {
        try {
            const content = fs.readFileSync(hookPath, 'utf-8');
            if (!content.includes(HERMES_KG_HOOK_SENTINEL)) {
                issues.push('~/.hermes/hooks/kg-first.sh exists but is not the olam-managed hook');
            }
        }
        catch {
            issues.push('could not read ~/.hermes/hooks/kg-first.sh');
        }
    }
    if (issues.length === 0) {
        return { ok: true, message: 'hermes integration configured (mcp + kg-first hook)' };
    }
    return {
        ok: true, // non-gating: WARN only
        message: `hermes integration incomplete: ${issues.join('; ')}`,
        warn: true,
        remedy: 'run `olam hermes bootstrap` to wire olam into Hermes',
    };
}
export function registerDoctor(program) {
    program
        .command('doctor')
        .description('Pass/fail host-health check. Exits 0 on a healthy host (including WARN-only); 1 on any FAIL probe. '
        + 'Auto-detects substrate from ~/.olam/config.yaml (host.substrate) — runs 28 probes on kubernetes, 7 on docker/compose. '
        + 'Override with OLAM_HOST_CP_ENGINE env var.')
        .option('--json', 'emit the report as JSON instead of a human-readable table')
        .action(async (opts) => {
        const hermesDir = path.join(os.homedir(), '.hermes');
        const r = await runDoctor(opts, {
            // Pass hermesHomeOverride so the probe fires when ~/.hermes/ exists.
            // When the dir is absent the probe is skipped; when present it WARNs
            // if olam is not wired into Hermes.
            hermesHomeOverride: hermesDir,
        });
        if (r.exitCode !== 0)
            process.exitCode = r.exitCode;
    });
}
//# sourceMappingURL=doctor.js.map