#!/bin/bash
# Cortiva Node Installer
# https://install.cortiva.dev
#
# Usage:
#   curl -fsSL https://install.cortiva.dev | bash
#   curl -fsSL https://install.cortiva.dev | bash -s -- --key ctv_node_xxx
#   curl -fsSL https://install.cortiva.dev | bash -s -- --dry-run
#
# shellcheck disable=SC2059
# ^ We embed ANSI colour constants ($RED, $RESET, etc.) into printf format
# strings throughout. The colours are static, internal, never user-provided
# — the SC2059 risk (format-string injection) doesn't apply. Refactoring
# 40+ printf calls to use %s placeholders just for the linter buys nothing.
#
# Robustness notes:
#   - set -eE is on globally. Any non-zero exit prints a diagnostic via
#     the ERR trap (which line, which command, which step we were on)
#     so operators never see a silent abort.
#   - INSTALLER_DONE flips to true at the very end. The EXIT trap uses
#     this to tell success from failure when set -e has already fired.
#   - Every step() call updates INSTALLER_STEP so the trap can say
#     where we were when the wheels came off.
set -eE
INSTALLER_STEP="starting up"
INSTALLER_DONE=false

# --- Args ---

DRY_RUN=false
PAIRING_KEY=""
TOKEN_ID=""
# Optional API key passed in by the curl command — gets written to
# ~/.cortiva/.env so the agent runtime can call Claude (via the
# claude_code_deep_think skill) without further operator action.
# Hands-off install: never prompt, never ask the operator to edit a
# file post-install. Required keys come in via --flag args.
ANTHROPIC_API_KEY=""
# Update mode: triggered by `cortiva node update` (HQ's Upgrade button).
# Skips first-install-only steps (workspace prompt, pairing, new Neo4j
# password). Idempotently re-runs everything else so install.sh
# additions (env file, brew claude, shell PATH, import verify) actually
# reach existing nodes. Without this flag, Upgrade only re-pulls the
# cortiva-hq wheel — install.sh changes get stranded.
UPDATE_MODE=false
while [ $# -gt 0 ]; do
    case "$1" in
        --dry-run) DRY_RUN=true; shift ;;
        --key) PAIRING_KEY="$2"; shift 2 ;;
        --key=*) PAIRING_KEY="${1#*=}"; shift ;;
        --token-id) TOKEN_ID="$2"; shift 2 ;;
        --token-id=*) TOKEN_ID="${1#*=}"; shift ;;
        --anthropic-key) ANTHROPIC_API_KEY="$2"; shift 2 ;;
        --anthropic-key=*) ANTHROPIC_API_KEY="${1#*=}"; shift ;;
        --update) UPDATE_MODE=true; shift ;;
        *) shift ;;
    esac
done

# --- Colours & helpers ---

BOLD="\033[1m"
DIM="\033[2m"
RESET="\033[0m"
GREEN="\033[32m"
RED="\033[31m"
YELLOW="\033[33m"
CYAN="\033[36m"
MAGENTA="\033[35m"

ok()   { printf "  ${GREEN}✓${RESET} %s\n" "$*"; }
fail() { printf "  ${RED}✗${RESET} %s\n" "$*"; }
warn() { printf "  ${YELLOW}○${RESET} %s\n" "$*"; }
info() { printf "  ${DIM}%s${RESET}\n" "$*"; }

step() {
    INSTALLER_STEP="$*"
    printf "\n${BOLD}${CYAN}▸ %s${RESET}\n" "$*"
}

# ERR fires before EXIT under `set -e`. Print everything the operator
# needs to file a bug or retry: which step, which line, which command,
# the exit code, where any partial logs are, and where to get help.
on_installer_error() {
    # Disable the trap immediately so anything in this handler (a failing
    # ls, printf to a dead pipe, etc.) doesn't recursively re-fire it.
    trap - ERR
    local code=$1
    local line=$2
    local cmd=$3
    printf "\n  ${RED}✗ Installer aborted${RESET}\n"
    printf "  ${DIM}Step:    %s${RESET}\n" "${INSTALLER_STEP:-unknown}"
    printf "  ${DIM}Line:    %s${RESET}\n" "$line"
    printf "  ${DIM}Command: %s${RESET}\n" "$cmd"
    printf "  ${DIM}Exit:    %s${RESET}\n" "$code"
    # Surface any spin_install logs left lying around — those are the
    # detailed output we kept from the most-recent dependency install.
    local logs
    # shellcheck disable=SC2012
    # The glob `cortiva-install-*.*` is internally generated by mktemp;
    # no path-traversal or shell-special chars to worry about. ls -t is
    # the most readable way to get newest-first here.
    logs=$(ls -t "${TMPDIR:-/tmp}"/cortiva-install-*.* 2>/dev/null | head -3)
    if [ -n "$logs" ]; then
        printf "\n  ${DIM}── Recent install logs (most recent first) ──${RESET}\n"
        printf "%s\n" "$logs" | sed "s|^|    |"
    fi
    printf "\n  Re-run with ${BOLD}bash -x${RESET} for a full trace.\n"
    printf "  If this looks like a bug, paste this whole block to support.\n"
    # Best-effort tell HQ what step we died on so the portal can show it.
    report_progress "failed" "step=${INSTALLER_STEP:-unknown} line=$line cmd=$cmd code=$code" 2>/dev/null || true
}
trap 'on_installer_error $? $LINENO "$BASH_COMMAND"' ERR

# EXIT fires whether we succeeded, errored, or were Ctrl-C'd. Use it to
# (a) tear down the sudo keepalive subshell, and (b) print a clear
# "succeeded" or "exited without finishing" line so the operator never
# has to guess whether the script ran to completion.
on_installer_exit() {
    local code=$?
    if [ -n "${SUDO_KEEPALIVE_PID:-}" ]; then
        kill "$SUDO_KEEPALIVE_PID" 2>/dev/null || true
    fi
    if [ "$INSTALLER_DONE" = true ]; then
        return 0
    fi
    # If on_installer_error already printed context, don't double-print.
    if [ "$code" -ne 0 ]; then
        return $code
    fi
    # Exit 0 but INSTALLER_DONE never flipped — early `exit` or stdin
    # closed mid-script. Tell the operator clearly.
    printf "\n  ${YELLOW}○ Installer exited without reaching the end${RESET}\n"
    printf "  ${DIM}Last step: %s${RESET}\n" "${INSTALLER_STEP:-unknown}"
    printf "  Re-run with ${BOLD}bash -x${RESET} for a full trace.\n"
}
trap 'on_installer_exit' EXIT

run_or_dry() {
    if [ "$DRY_RUN" = true ]; then
        info "[dry run] $*"
    else
        # shellcheck disable=SC2294
        # Intentional: callers pass single-string commands with embedded
        # $() and quotes — eval is what makes that work.
        eval "$@"
    fi
}

HQ_BASE="https://api.cortiva.dev"

report_progress() {
    local step_name="$1"
    local error_msg="${2:-}"
    [ -z "$TOKEN_ID" ] && return
    [ "$DRY_RUN" = true ] && return
    local payload="{\"token_id\":\"$TOKEN_ID\",\"step\":\"$step_name\""
    payload="$payload,\"hostname\":\"$(hostname -s 2>/dev/null || echo unknown)\""
    payload="$payload,\"os\":\"$OS\",\"arch\":\"$ARCH\""
    [ -n "$CPU_MODEL" ] && payload="$payload,\"cpu_model\":\"$CPU_MODEL\""
    [ -n "$CPU_CORES" ] && payload="$payload,\"cpu_cores\":$CPU_CORES"
    [ -n "$GPU_MODEL" ] && payload="$payload,\"gpu_model\":\"$GPU_MODEL\""
    [ -n "$error_msg" ] && payload="$payload,\"error\":\"$error_msg\""
    payload="$payload}"
    curl -fsS -X POST "$HQ_BASE/api/nodes/install-progress" \
        -H "Content-Type: application/json" \
        -d "$payload" >/dev/null 2>&1 || true
}

spin_install() {
    local name="$1"
    local check_cmd="$2"
    local install_cmd="$3"
    local version_cmd="$4"

    if eval "$check_cmd" &>/dev/null; then
        local version
        version=$(eval "$version_cmd" 2>/dev/null || echo "")
        if [ -n "$version" ]; then
            printf "  ${GREEN}✓${RESET} %s ${DIM}(%s)${RESET}\n" "$name" "$version"
        else
            ok "$name"
        fi
    else
        if [ "$DRY_RUN" = true ]; then
            warn "$name — would install"
        else
            printf "  ${YELLOW}◌${RESET} %s — installing..." "$name"
            # Capture install output instead of dropping it on the floor —
            # when something fails the user needs to know *why*. Surface the
            # tail of the log on failure and tell them where the full log is.
            local log_file
            log_file=$(mktemp -t "cortiva-install-${name// /_}.XXXXXX")
            if eval "$install_cmd" >"$log_file" 2>&1; then
                local version
                version=$(eval "$version_cmd" 2>/dev/null || echo "")
                printf "\r  ${GREEN}✓${RESET} %s ${DIM}(%s)${RESET}          \n" "$name" "$version"
                rm -f "$log_file"
            else
                printf "\r  ${RED}✗${RESET} %s — install failed          \n" "$name"
                printf "  ${DIM}── last 20 lines of install output ──${RESET}\n"
                tail -n 20 "$log_file" 2>/dev/null | sed "s|^|    |"
                printf "  ${DIM}── full log: %s ──${RESET}\n" "$log_file"
                return 1
            fi
        fi
    fi
}

# Test seam: bats tests source this script to exercise the helper
# functions (spin_install, step, on_installer_error, …) without firing
# the install flow. Setting INSTALLER_TEST_MODE=1 makes us return here.
# Tests install their own traps if they want to verify trap behaviour —
# clear ours first so the EXIT trap doesn't fire on source-return.
if [ -n "${INSTALLER_TEST_MODE:-}" ]; then
    trap - ERR EXIT
    # shellcheck disable=SC2317
    # `return` works when sourced (the test case); `exit` is the fallback
    # if someone ever runs `INSTALLER_TEST_MODE=1 bash install.sh` directly.
    return 0 2>/dev/null || exit 0
fi

# --- Banner ---

printf "\n"
printf "  ${MAGENTA}${BOLD}cortiva${RESET}\n"
printf "  ${DIM}Node Installer${RESET}\n"
printf "  ${DIM}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}\n"

if [ "$DRY_RUN" = true ]; then
    printf "\n  ${YELLOW}${BOLD}DRY RUN${RESET} ${DIM}— no changes will be made${RESET}\n"
fi

# --- Platform detection ---

step "Checking platform"

OS="$(uname -s)"
ARCH="$(uname -m)"

case "$OS" in
    Darwin)
        MACOS_VERSION=$(sw_vers -productVersion 2>/dev/null || echo "0")
        MACOS_MAJOR=$(echo "$MACOS_VERSION" | cut -d. -f1)
        if [ "$MACOS_MAJOR" -lt 13 ] 2>/dev/null; then
            fail "macOS $MACOS_VERSION — minimum required: macOS 13 (Ventura)"
            exit 1
        fi
        ok "macOS $MACOS_VERSION ($ARCH)"
        ;;
    Linux)
        if [ -f /etc/os-release ]; then
            # shellcheck source=/dev/null
            DISTRO=$(. /etc/os-release && echo "$PRETTY_NAME")
        else
            DISTRO="Linux"
        fi
        ok "$DISTRO ($ARCH)"
        ;;
    *)
        fail "Unsupported OS: $OS"
        info "See https://docs.cortiva.dev/getting-started/install"
        exit 1
        ;;
esac

# Disk space
FREE_KB=$(df -k / | tail -1 | awk '{print $4}')
FREE_GB=$((FREE_KB / 1024 / 1024))
if [ "$FREE_GB" -lt 10 ]; then
    fail "Only ${FREE_GB}GB free — minimum 10GB required"
    exit 1
fi
ok "${FREE_GB}GB free disk space"

# Detect CPU
CPU_MODEL=""
CPU_CORES=""
if [ "$OS" = "Darwin" ]; then
    CPU_MODEL=$(sysctl -n machdep.cpu.brand_string 2>/dev/null || echo "")
    CPU_CORES=$(sysctl -n hw.ncpu 2>/dev/null || echo "")
elif [ "$OS" = "Linux" ]; then
    CPU_MODEL=$(grep -m1 'model name' /proc/cpuinfo 2>/dev/null | cut -d: -f2 | xargs || echo "")
    CPU_CORES=$(nproc 2>/dev/null || echo "")
fi
if [ -n "$CPU_MODEL" ]; then
    printf "  ${GREEN}✓${RESET} %s ${DIM}(%s cores)${RESET}\n" "$CPU_MODEL" "$CPU_CORES"
fi

# Detect GPU
GPU_MODEL=""
GPU_VRAM=""
if [ "$OS" = "Darwin" ]; then
    GPU_INFO=$(system_profiler SPDisplaysDataType 2>/dev/null | grep 'Chipset Model' | head -1 | cut -d: -f2 | xargs || echo "")
    GPU_VRAM_RAW=$(system_profiler SPDisplaysDataType 2>/dev/null | grep 'VRAM' | head -1 | cut -d: -f2 | xargs || echo "")
    if [ -n "$GPU_INFO" ]; then
        GPU_MODEL="$GPU_INFO"
        [ -n "$GPU_VRAM_RAW" ] && GPU_VRAM="$GPU_VRAM_RAW"
    fi
elif [ "$OS" = "Linux" ]; then
    if command -v nvidia-smi &>/dev/null; then
        GPU_MODEL=$(nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null | head -1 || echo "")
        GPU_VRAM=$(nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits 2>/dev/null | head -1 || echo "")
        [ -n "$GPU_VRAM" ] && GPU_VRAM="${GPU_VRAM} MB"
    elif command -v lspci &>/dev/null; then
        GPU_MODEL=$(lspci 2>/dev/null | grep -i 'vga\|3d\|display' | head -1 | sed 's/.*: //' || echo "")
    fi
fi
if [ -n "$GPU_MODEL" ]; then
    if [ -n "$GPU_VRAM" ]; then
        printf "  ${GREEN}✓${RESET} %s ${DIM}(%s)${RESET}\n" "$GPU_MODEL" "$GPU_VRAM"
    else
        ok "$GPU_MODEL"
    fi
fi

report_progress "platform"

# --- Dependencies ---

step "Installing dependencies"

report_progress "dependencies"

if [ "$OS" = "Darwin" ]; then
    # Put an already-installed Homebrew on PATH BEFORE the checks below.
    # Non-interactive runs — the HQ `update` relay and ssh-piped installs —
    # don't source the user's shell profile, so /opt/homebrew/bin is absent
    # from PATH and `command -v brew` fails even when Homebrew is installed.
    # That sent every --update down the first-time-install path and into a
    # `sudo -v` prompt which, with no TTY, aborts at "Installing
    # dependencies" — the silent reason the fleet never actually updated.
    if [ -x /opt/homebrew/bin/brew ]; then
        eval "$(/opt/homebrew/bin/brew shellenv)"
    elif [ -x /usr/local/bin/brew ]; then
        eval "$(/usr/local/bin/brew shellenv)"
    fi

    spin_install \
        "Xcode CLT" \
        "xcode-select -p" \
        "xcode-select --install 2>/dev/null; echo 'Complete the Xcode CLT install dialog, then press Enter'; read" \
        "xcode-select --version"

    # Homebrew's NONINTERACTIVE installer needs sudo to chown /opt/homebrew
    # on the first run. Under `NONINTERACTIVE=1` it won't prompt, so without
    # a cached sudo credential it exits silently — that's the source of the
    # "Homebrew — install failed" with no further context. Prime sudo here
    # so the rest of the install runs without surprises. (The global EXIT
    # trap installed near the top of this script cleans up the keepalive.)
    if ! command -v brew &>/dev/null && [ "$DRY_RUN" = false ]; then
        info "Homebrew install needs sudo (writes to /opt/homebrew). Caching your password now."
        if ! sudo -v; then
            fail "Sudo declined; cannot install Homebrew. Re-run after granting sudo."
            exit 1
        fi
        # Keep sudo alive while the install runs (brew + casks can take a while).
        ( while true; do sudo -n true; sleep 60; kill -0 "$$" || exit; done ) 2>/dev/null &
        SUDO_KEEPALIVE_PID=$!
    fi

    # shellcheck disable=SC2016
    # The `$()` inside the single-quoted install string is intentional —
    # we want bash -c to evaluate it at install time, not this shell.
    spin_install \
        "Homebrew" \
        "command -v brew" \
        'NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"' \
        "brew --version | head -1"

    # Always install Python 3.13 via Homebrew — system Python (3.14+) is too new
    # for many packages to have wheels, causing pip resolution failures
    spin_install \
        "Python 3.13" \
        "/opt/homebrew/opt/python@3.13/bin/python3.13 -c 'import sys; assert sys.version_info >= (3, 11)' 2>/dev/null" \
        "HOMEBREW_NO_AUTO_UPDATE=1 brew install python@3.13 && eval \"\$(/opt/homebrew/bin/brew shellenv)\"" \
        "/opt/homebrew/opt/python@3.13/bin/python3.13 --version"

    # Force use of Homebrew Python 3.13 for the rest of the script
    export PATH="/opt/homebrew/opt/python@3.13/libexec/bin:/opt/homebrew/opt/python@3.13/bin:/opt/homebrew/bin:$PATH"

    # Pin EVERY python op to the absolute 3.13 interpreter — NOT bare `python3`.
    # Why: `pip install` stamps each console-script shebang (e.g.
    # /opt/homebrew/bin/cortiva-hq) with the absolute path of the interpreter
    # that ran pip. If that's bare `python3` and Homebrew has since linked
    # /opt/homebrew/bin/python3 to a newer python (3.14 arrived as a dependency
    # of some other formula), the shebangs point at a python with NO cortiva
    # packages — and the launchd job, which execs the console script directly,
    # crash-loops `ModuleNotFoundError` on the next restart while the whole
    # stack (cortiva, cortiva_hq, mlx) stays under 3.13. Pinning the absolute
    # path makes the shebangs version-stable regardless of what `python3`
    # resolves to. (2026-06-17 incident — Mini-2 control plane went down.)
    PY="/opt/homebrew/opt/python@3.13/bin/python3.13"

    spin_install \
        "Node.js" \
        "command -v node" \
        "HOMEBREW_NO_AUTO_UPDATE=1 brew install node" \
        "node --version"

    spin_install \
        "Ollama" \
        "command -v ollama" \
        "HOMEBREW_NO_AUTO_UPDATE=1 brew install ollama" \
        "ollama --version 2>&1 | head -1"

    spin_install \
        "Neo4j" \
        "command -v neo4j" \
        "HOMEBREW_NO_AUTO_UPDATE=1 brew install neo4j" \
        "neo4j --version 2>&1 | head -1"

    # gh CLI — agents use it for GitHub issues / project boards / wikis
    # when the employer grants the GitHub integration (auth comes from
    # the per-agent GH_TOKEN delivered by HQ, not `gh auth login`).
    spin_install \
        "GitHub CLI" \
        "command -v gh" \
        "HOMEBREW_NO_AUTO_UPDATE=1 brew install gh" \
        "gh --version 2>&1 | head -1"

elif [ "$OS" = "Linux" ]; then
    if command -v apt-get &>/dev/null; then
        PKG_MGR="apt"
        PKG_INSTALL="sudo apt-get install -y -qq"
    elif command -v dnf &>/dev/null; then
        PKG_MGR="dnf"
        PKG_INSTALL="sudo dnf install -y -q"
    elif command -v pacman &>/dev/null; then
        PKG_MGR="pacman"
        PKG_INSTALL="sudo pacman -S --noconfirm --quiet"
    else
        fail "No supported package manager found (apt, dnf, pacman)"
        exit 1
    fi
    ok "Package manager: $PKG_MGR"

    # Linux has a single system python3 (no brew version juggling) — the shebang
    # pin that matters on macOS isn't a concern here.
    PY="python3"

    spin_install \
        "Python 3.11+" \
        "python3 -c 'import sys; assert sys.version_info >= (3, 11)'" \
        "$PKG_INSTALL python3 python3-pip python3-venv" \
        "python3 --version"

    spin_install \
        "Node.js" \
        "command -v node" \
        "$PKG_INSTALL nodejs npm" \
        "node --version"

    spin_install \
        "Ollama" \
        "command -v ollama" \
        "curl -fsSL https://ollama.ai/install.sh | sh" \
        "ollama --version 2>&1 | head -1"

    # Add Neo4j repo and install
    if [ "$PKG_MGR" = "apt" ]; then
        spin_install \
            "Neo4j" \
            "command -v neo4j" \
            "curl -fsSL https://debian.neo4j.com/neotechnology.gpg.key | sudo gpg --dearmor -o /usr/share/keyrings/neo4j.gpg && echo 'deb [signed-by=/usr/share/keyrings/neo4j.gpg] https://debian.neo4j.com stable latest' | sudo tee /etc/apt/sources.list.d/neo4j.list && sudo apt-get update -qq && sudo apt-get install -y -qq neo4j" \
            "neo4j --version 2>&1 | head -1"
    fi

    # gh CLI — agents use it for GitHub issues / project boards / wikis
    # when the employer grants the GitHub integration.
    spin_install \
        "GitHub CLI" \
        "command -v gh" \
        "$PKG_INSTALL gh" \
        "gh --version 2>&1 | head -1"
fi

# Claude Code CLI — required by the `claude_code_deep_think` skill that
# CPO / PO agents use for deep reasoning. On macOS we prefer brew (atomic
# upgrades, no Node version drift); on Linux we fall back to npm since
# brew isn't standard there.
#
# The version check is watchdog'd: a stale `claude` binary can wedge on exec
# (the realpath exec-wedge that recurs after days of uptime), and `claude
# --version` then blocks FOREVER. Because spin_install runs the version cmd via
# command substitution, that one hang silently stalled the ENTIRE `--update`
# run — so a node with a wedged claude could never pull a new build. Run the
# probe as a child with a hard 10s timeout instead (perl is always present;
# the ALRM fires in the non-exec'd parent so it's reliable, unlike `timeout`
# which macOS lacks). A wedged claude now just yields an empty version string,
# and the update proceeds.
claude_version_safe() {
    perl -e '$p=fork()||exec(@ARGV); $SIG{ALRM}=sub{kill 9,$p}; alarm 10; waitpid $p,0' \
        claude --version 2>/dev/null | head -1
}
if [ "$OS" = "Darwin" ]; then
    spin_install \
        "Claude Code CLI" \
        "command -v claude" \
        "HOMEBREW_NO_AUTO_UPDATE=1 brew install --cask claude-code" \
        "claude_version_safe"
else
    spin_install \
        "Claude Code CLI" \
        "command -v claude" \
        "npm install -g @anthropic-ai/claude-code" \
        "claude_version_safe"
fi

# --- Configure Neo4j ---

step "Configuring Neo4j"

report_progress "neo4j"

# Configure Neo4j for low-memory operation
BREW_PREFIX="$(brew --prefix 2>/dev/null || echo "")"
if [ -d "$BREW_PREFIX/etc/neo4j" ] || [ -d "/etc/neo4j" ]; then
    if [ -n "$BREW_PREFIX" ]; then
        CORTIVA_CONF="$BREW_PREFIX/etc/neo4j/neo4j.conf.d/cortiva.conf"
    else
        CORTIVA_CONF="/etc/neo4j/neo4j.conf.d/cortiva.conf"
    fi
    run_or_dry "mkdir -p '$(dirname "$CORTIVA_CONF")'"
    if [ "$DRY_RUN" = false ]; then
        cat > "$CORTIVA_CONF" <<CONF
# Cortiva Myelin — Neo4j tuning for agent memory
server.memory.heap.initial_size=512m
server.memory.heap.max_size=2g
server.memory.pagecache.size=1g
server.default_listen_address=127.0.0.1
CONF
        ok "Neo4j configured (512m-2g heap, local only)"
    fi
fi

# Generate Neo4j password — but in update mode, read the existing one
# from cortiva.yaml so it still matches what Neo4j was configured with
# on first install. Without this the .env file lands with a password
# the database doesn't accept.
EXISTING_CONFIG="$HOME/.cortiva/workspace/cortiva.yaml"
NEO4J_PASSWORD=""
if [ "$UPDATE_MODE" = true ] && [ -f "$EXISTING_CONFIG" ]; then
    # The cortiva.yaml has (post-Myelin-wiring-fix, nested under config:):
    #   memory:
    #     adapter: myelin
    #     config:
    #       neo4j_auth:
    #         - neo4j           ← username (first item after neo4j_auth:)
    #         - "the_password"  ← password (second item, what we want)
    # `grep -A 2` returns the neo4j_auth: line + the 2 lines after.
    # tail -n 1 takes the password line. Two earlier awk-based attempts
    # each shipped a different bug — the test suite under
    # tests/installer/test_update_mode.bats now pins this behaviour.
    # Indentation depth doesn't matter to grep — the same extraction
    # works whether neo4j_auth lives at the top of memory: or under
    # memory.config:.
    NEO4J_PASSWORD=$(
        grep -A 2 "neo4j_auth:" "$EXISTING_CONFIG" \
            | tail -n 1 \
            | sed 's/^[[:space:]]*-[[:space:]]*//;s/^"//;s/"$//' \
            | tr -d '[:space:]'
    )
    if [ -n "$NEO4J_PASSWORD" ] && [ "$NEO4J_PASSWORD" != "neo4j" ]; then
        ok "Update mode: preserved Neo4j password from $EXISTING_CONFIG"
    else
        NEO4J_PASSWORD=""
        warn "Update mode: could not parse Neo4j password from $EXISTING_CONFIG; regenerating"
    fi
fi
if [ -z "$NEO4J_PASSWORD" ]; then
    NEO4J_PASSWORD="cortiva_$(openssl rand -hex 8)"
fi

# Set password (different methods for different Neo4j versions)
if command -v neo4j-admin &>/dev/null; then
    run_or_dry "neo4j-admin dbms set-initial-password '$NEO4J_PASSWORD' 2>/dev/null || true"
fi

# Start Neo4j service. Must be idempotent: on an --update of a node where
# Neo4j is already running, `brew services start` fails with
# "Bootstrap failed: 5: Input/output error" (the launchd job is already
# bootstrapped) and, under set -e + the ERR trap, that aborted the whole
# update before the wheel install. Tolerate an already-running service —
# the readiness probe just below is the real gate.
if [ "$OS" = "Darwin" ]; then
    run_or_dry "brew services start neo4j 2>/dev/null || brew services restart neo4j 2>/dev/null || true"
else
    run_or_dry "sudo systemctl enable --now neo4j || true"
fi

# Wait for Neo4j to be ready
if [ "$DRY_RUN" = false ]; then
    printf "  ${YELLOW}◌${RESET} Waiting for Neo4j..."
    NEO4J_READY=false
    for _ in $(seq 1 30); do
        if curl -sf http://127.0.0.1:7474 >/dev/null 2>&1; then
            printf "\r  ${GREEN}✓${RESET} Neo4j ready          \n"
            NEO4J_READY=true
            break
        fi
        sleep 1
    done
    if [ "$NEO4J_READY" = false ]; then
        printf "\r  ${YELLOW}○${RESET} Neo4j not yet responding — check 'neo4j status'          \n"
    fi
fi

# --- Install Cortiva framework + HQ ---

step "Installing Cortiva"

report_progress "installing"

CORTIVA_REPO="https://github.com/Innovology/cortiva.git"
CORTIVA_HQ_WHEEL="https://install.cortiva.dev/packages/cortiva_hq-0.1.0-py3-none-any.whl"

# Pin pip to the exact interpreter ($PY: absolute python@3.13 on macOS,
# python3 on Linux) — NOT bare `python3`, whose Homebrew symlink can flip to a
# newer python and strand the console-script shebangs (see the $PY definition).
# --no-cache-dir is REQUIRED: the cortiva-hq wheel filename never changes
# (always cortiva_hq-0.1.0-...whl), so pip's HTTP cache serves the STALE wheel
# and --force-reinstall just reinstalls the cached copy — node-side code
# silently lags behind every push. --no-cache-dir forces a fresh download.
PY="${PY:-python3}"
PIP_INSTALL="$PY -m pip install --break-system-packages --force-reinstall --no-cache-dir -q"

# Install cortiva with adapter extras (anthropic + ollama for consciousness layers)
printf "  ${YELLOW}◌${RESET} cortiva (framework + adapters) — installing..."
if run_or_dry "$PIP_INSTALL \"cortiva[anthropic,ollama] @ git+${CORTIVA_REPO}\""; then
    if [ "$DRY_RUN" = false ]; then
        fwk_ver=$($PY -m pip show cortiva 2>/dev/null | grep Version | cut -d' ' -f2)
        printf "\r  ${GREEN}✓${RESET} cortiva ${DIM}($fwk_ver)${RESET}          \n"
    else
        printf "\n"
    fi
else
    printf "\r  ${RED}✗${RESET} cortiva install failed          \n"
    report_progress "error" "Failed to install cortiva framework"
    fail "Check your network connection and try again"
    exit 1
fi

# Install cortiva-hq (commercial layer) from pre-built wheel
printf "  ${YELLOW}◌${RESET} cortiva-hq — installing..."
if run_or_dry "$PIP_INSTALL \"${CORTIVA_HQ_WHEEL}\""; then
    if [ "$DRY_RUN" = false ]; then
        hq_ver=$($PY -m pip show cortiva-hq 2>/dev/null | grep Version | cut -d' ' -f2)
        if [ -z "$hq_ver" ]; then
            printf "\r  ${RED}✗${RESET} cortiva-hq install failed          \n"
            report_progress "error" "cortiva-hq wheel download failed"
            fail "Check your network connection and try again"
            exit 1
        fi
        printf "\r  ${GREEN}✓${RESET} cortiva-hq ${DIM}($hq_ver)${RESET}          \n"
    else
        printf "\n"
    fi
else
    printf "\r  ${RED}✗${RESET} cortiva-hq install failed          \n"
    report_progress "error" "Failed to install cortiva-hq"
    fail "Check your network connection and try again"
    exit 1
fi

# The cortiva-hq wheel is installed from a direct URL, which can't pull
# optional extras — so the Myelin memory backend's Neo4j driver (the
# `graph` extra) must be installed explicitly. Without it, any node that
# hosts an agent fails to reach Neo4j (ModuleNotFoundError: neo4j) even
# though the Neo4j *server* was provisioned above.
printf "  ${YELLOW}◌${RESET} Neo4j driver (Myelin memory)..."
if run_or_dry "$PIP_INSTALL 'neo4j>=5.28'"; then
    printf "\r  ${GREEN}✓${RESET} Neo4j driver (Myelin memory)          \n"
else
    printf "\r  ${RED}✗${RESET} Neo4j driver install failed          \n"
    report_progress "error" "Failed to install Neo4j Python driver"
fi

# Apple Silicon: install mlx-lm so the MLX runtime is usable out of the box.
# Without this, deployments with runtime=mlx will fail with "mlx_lm not installed".
#
# PINNED (2026-07-07 Mini-2 outage): an unpinned install pulls transformers 5.13+,
# whose auto_factory.register bug breaks `import mlx_lm` entirely — the node then
# can't (re)start its model server and every agent freezes in planning while
# port/heartbeat checks still pass. transformers<5 is NOT the fix either: it
# forces mlx-lm down to 0.29.x, which can't load qwen3_5_moe models. The proven
# combo is mlx-lm 0.31.3 + transformers 5.12.x. Revisit the pin only after
# verifying `import mlx_lm` AND a qwen3_5_moe load with the new versions.
if [ "$OS" = "Darwin" ] && [ "$ARCH" = "arm64" ]; then
    printf "  ${YELLOW}◌${RESET} mlx-lm (Apple Silicon MLX runtime) — installing..."
    if run_or_dry "$PIP_INSTALL 'mlx-lm==0.31.3' 'transformers<5.13'"; then
        if [ "$DRY_RUN" = false ]; then
            mlx_ver=$($PY -m pip show mlx-lm 2>/dev/null | grep Version | cut -d' ' -f2)
            printf "\r  ${GREEN}✓${RESET} mlx-lm ${DIM}($mlx_ver)${RESET}          \n"
        fi
        if [ "$DRY_RUN" = true ]; then
            printf "\n"
        fi
        # Belt-and-braces: a "successful" pip run has burned us before. Verify
        # the import actually works and SAY SO if it doesn't — a broken mlx_lm
        # import means a dead model server and a fully idle node.
        if [ "$DRY_RUN" = false ] && ! $PY -c "import mlx_lm" 2>/dev/null; then
            printf "  ${RED}✗${RESET} mlx_lm IMPORT BROKEN after install — MLX runtime will NOT start\n"
            report_progress "error" "mlx_lm import broken after install (MLX runtime dead)"
        fi
    else
        # Non-fatal — Ollama still works. User just can't use runtime=mlx.
        printf "\r  ${YELLOW}!${RESET} mlx-lm install failed (MLX runtime unavailable; Ollama still works)\n"
        report_progress "error" "mlx-lm install failed (MLX runtime unavailable)"
    fi
fi

# --- Pull default Ollama model ---

step "Preparing local model"

report_progress "model"

OLLAMA_MODEL="qwen3.5:latest"

# Start Ollama service if not already running
if command -v ollama &>/dev/null; then
    if ! curl -sf http://localhost:11434/api/tags >/dev/null 2>&1; then
        if [ "$DRY_RUN" = false ]; then
            info "Starting Ollama service..."
            if [ "$OS" = "Darwin" ]; then
                open -a Ollama 2>/dev/null || ollama serve &>/dev/null &
            else
                ollama serve &>/dev/null &
            fi
            # Wait for Ollama to be ready
            for _ in $(seq 1 15); do
                if curl -sf http://localhost:11434/api/tags >/dev/null 2>&1; then
                    break
                fi
                sleep 1
            done
        fi
    fi

    # Check if model already pulled
    if curl -sf http://localhost:11434/api/tags 2>/dev/null | grep -q "qwen3.5"; then
        ok "Ollama model ${DIM}($OLLAMA_MODEL — already available)${RESET}"
    else
        printf "  ${YELLOW}◌${RESET} Pulling %s — this may take a few minutes..." "$OLLAMA_MODEL"
        if run_or_dry "ollama pull $OLLAMA_MODEL 2>/dev/null"; then
            printf "\r  ${GREEN}✓${RESET} %s — ready          \n" "$OLLAMA_MODEL"
        else
            printf "\r  ${YELLOW}○${RESET} %s — pull failed (agents will use API-only mode)          \n" "$OLLAMA_MODEL"
        fi
    fi

    # Embedding model — the routine adapter matches tasks against
    # learned procedures via /api/embed; chat models 501 on that
    # endpoint, which silently degrades routine assessment (every
    # task defers). Small (~270MB), so always ensure it.
    EMBED_MODEL="nomic-embed-text"
    if curl -sf http://localhost:11434/api/tags 2>/dev/null | grep -q "$EMBED_MODEL"; then
        ok "Embedding model ${DIM}($EMBED_MODEL — already available)${RESET}"
    else
        printf "  ${YELLOW}◌${RESET} Pulling %s (embeddings)..." "$EMBED_MODEL"
        if run_or_dry "ollama pull $EMBED_MODEL 2>/dev/null"; then
            printf "\r  ${GREEN}✓${RESET} %s — ready          \n" "$EMBED_MODEL"
        else
            printf "\r  ${YELLOW}○${RESET} %s — pull failed (procedure matching degraded)          \n" "$EMBED_MODEL"
        fi
    fi
else
    warn "Ollama not available — agents will use API-only consciousness"
fi

# --- Create Cortiva workspace ---

step "Creating workspace"

report_progress "workspace"

CORTIVA_HOME="$HOME/.cortiva"
WORKSPACE="$CORTIVA_HOME/workspace"
CONFIG_FILE="$WORKSPACE/cortiva.yaml"

run_or_dry "mkdir -p '$CORTIVA_HOME/logs'"
run_or_dry "mkdir -p '$WORKSPACE/agents'"

HOSTNAME_SHORT=$(hostname -s 2>/dev/null || echo "node")

if [ -f "$CONFIG_FILE" ]; then
    if [ "$UPDATE_MODE" = true ]; then
        # Hands-off: never prompt during update. Always keep existing
        # workspace; install.sh additions land via the .env step.
        ok "Update mode: keeping existing workspace at $WORKSPACE"
        choice=k
        # Config migration: nodes installed before the terminal adapter
        # shipped have no `terminal:` section, so agents can't execute
        # hands-on work (gh/GitHub, files) — append it once.
        if [ -f "$CONFIG_FILE" ] && ! grep -q "^terminal:" "$CONFIG_FILE"; then
            printf '\nterminal:\n  adapter: claude-code\n  config: {}\n' >> "$CONFIG_FILE"
            ok "Update mode: added terminal adapter (claude-code) to cortiva.yaml"
        fi
        # The routine adapter needs an EMBEDDING model; earlier installs
        # wrote the chat model there, which 501s on /api/embed and turns
        # every routine assessment into a silent deferral.
        if [ -f "$CONFIG_FILE" ] && grep -A2 "^routine:" "$CONFIG_FILE" | grep -q "model: .*qwen"; then
            if [ "$OS" = "Darwin" ]; then
                sed -i "" "/^routine:/,/^[a-z]/ s|^  model: .*|  model: nomic-embed-text|" "$CONFIG_FILE"
            else
                sed -i "/^routine:/,/^[a-z]/ s|^  model: .*|  model: nomic-embed-text|" "$CONFIG_FILE"
            fi
            ok "Update mode: routine adapter now uses nomic-embed-text for embeddings"
        fi
        # The channel adapter must be `internal` (the in-process agent-to-
        # agent bus). Some nodes drifted to `adapter: slack` — but slack-sdk
        # isn't installed (it's an unshipped optional extra), so every agent
        # wake throws "slack-sdk is not installed" in channel.receive and no
        # agent on the node can run. Repair the drift to the shipped default.
        if [ -f "$CONFIG_FILE" ] && grep -A1 "^channel:" "$CONFIG_FILE" | grep -q "adapter: slack"; then
            if [ "$OS" = "Darwin" ]; then
                sed -i "" "/^channel:/,/^[a-z]/ s|^  adapter: slack|  adapter: internal|" "$CONFIG_FILE"
            else
                sed -i "/^channel:/,/^[a-z]/ s|^  adapter: slack|  adapter: internal|" "$CONFIG_FILE"
            fi
            ok "Update mode: channel adapter repaired slack -> internal (slack-sdk unshipped)"
        fi
    elif [ "$DRY_RUN" = true ]; then
        info "[dry run] Would prompt: keep/fresh/skip"
    else
        warn "Existing workspace found at $WORKSPACE"
        echo ""
        info "Would you like to:"
        info "  [k] Keep existing workspace and re-register with HQ"
        info "  [f] Fresh workspace (backs up existing)"
        info "  [s] Skip workspace setup"
        read -rp "    Choice [k/f/s]: " choice

        case "$choice" in
            f)
                backup_dir="$CORTIVA_HOME/backup-$(date +%Y%m%d-%H%M%S)"
                mkdir -p "$backup_dir"
                cp -r "$WORKSPACE" "$backup_dir/"
                ok "Workspace backed up to $backup_dir"
                ;;
            s)
                ok "Keeping existing workspace"
                ;;
        esac
    fi
fi

# Generate cortiva.yaml (the framework's config format)
if [ ! -f "$CONFIG_FILE" ] || [ "${choice:-k}" = "f" ]; then
    if [ "$DRY_RUN" = true ]; then
        info "[dry run] Would generate $CONFIG_FILE"
    else
        cat > "$CONFIG_FILE" <<YAML
# Cortiva Node Configuration
# Generated by install.cortiva.dev on $(date -u +%Y-%m-%dT%H:%M:%SZ)

fabric:
  name: "$HOSTNAME_SHORT"
  heartbeat_interval: 30

memory:
  adapter: myelin
  # Nested under 'config:' so the cortiva framework's adapter loader
  # forwards these as kwargs to MyelinMemoryAdapter. Top-level keys
  # are dropped by vendor/cortiva/.../core/config.py:201-202
  # (mem_kwargs = dict(mem_section.get("config", {}))), which is the
  # bug that left Neo4j empty even when the rest of the chain worked.
  config:
    neo4j_uri: bolt://127.0.0.1:7687
    neo4j_auth:
      - neo4j
      - "$NEO4J_PASSWORD"

consciousness:
  # Design-aligned default: routine consciousness loop runs on the
  # local MLX-served Qwen model. Frontier (Claude / GPT) is reached
  # ONLY through the `claude_code_deep_think` skill, which subshells
  # to the operator's claude-code install. This keeps token spend
  # off the critical path and confidential traffic local. Override
  # per-agent via the agent's own deploy.yaml if a specific role
  # needs frontier reasoning by default.
  provider: openai-compatible
  base_url: http://127.0.0.1:9100/v1
  model: mlx-community/Qwen3.6-35B-A3B-6bit
  budget:
    daily_limit: 1000
    per_agent_default: 50

routine:
  # Embedding-based procedure matching. This must be an EMBEDDING
  # model — chat models 501 on Ollama's /api/embed and every task
  # silently falls through to "Routine deferred task".
  adapter: ollama
  model: nomic-embed-text

channel:
  # Default to the cortiva-hq `internal` adapter — in-process
  # message bus for agent-to-agent communication PLUS a built-in
  # warning that there's no outbound channel for human-team
  # contact (Slack/Discord/Teams/email). The warning surfaces as a
  # system message in the agent's inbox every cycle, so the agent
  # naturally puts "configure outbound channel adapter" into its
  # plan as a blocker — operator sees it in HQ.
  #
  # To enable real outbound, switch `adapter` to `slack` (or any
  # other supported adapter) and provide credentials in `config:`.
  # See docs at hq.cortiva.dev/settings/channels.
  adapter: internal
  config: {}

terminal:
  # Hands-on execution path: tasks that touch the outside world
  # (GitHub issues / project boards / wikis via gh, code, files)
  # subshell to the claude CLI in the agent's own persistent session,
  # cwd'd to the agent's workspace, with the agent's delegated
  # credentials (credentials.json) injected into the subprocess env.
  # Auth is the subscription OAuth token (CLAUDE_CODE_OAUTH_TOKEN),
  # delivered by HQ to ~/.cortiva/.claude_oauth_token — the fabric is a
  # background LaunchAgent and cannot read claude's macOS keychain item,
  # so the explicit token is what keeps headless claude from hanging.
  adapter: claude-code
  config: {}

agents:
  directory: ./agents

# Cortiva HQ connection (managed remotely)
hq:
  portal_url: $HQ_BASE
  node_token: ""
  token_id: ""
YAML
        ok "Workspace created at $WORKSPACE"
    fi
fi

ok "Config: $CONFIG_FILE"

# --- Environment file + shell PATH ---
#
# Hands-off install: the runtime needs API keys and Neo4j credentials in
# its env, the operator must NOT have to write a file after install, and
# `python3 -c "import cortiva"` must work in their interactive shell
# without sourcing anything. This step handles all three.

step "Configuring environment"

# 1. ~/.cortiva/.env — the canonical secret store for this node. Owned
#    by the operator (chmod 600). Only created if absent — re-runs of
#    install.sh never overwrite a real key with a placeholder.
ENV_FILE="$CORTIVA_HOME/.env"
if [ "$DRY_RUN" = false ]; then
    if [ -f "$ENV_FILE" ]; then
        # Update keys we control without touching others. Use sed -i ''
        # for macOS, sed -i for Linux.
        if [ "$OS" = "Darwin" ]; then SED_INPLACE=(-i ""); else SED_INPLACE=(-i); fi
        if grep -q "^NEO4J_PASSWORD=" "$ENV_FILE"; then
            sed "${SED_INPLACE[@]}" "s|^NEO4J_PASSWORD=.*|NEO4J_PASSWORD=$NEO4J_PASSWORD|" "$ENV_FILE"
        else
            printf "\nNEO4J_PASSWORD=%s\n" "$NEO4J_PASSWORD" >> "$ENV_FILE"
        fi
        if [ -n "$ANTHROPIC_API_KEY" ]; then
            if grep -q "^ANTHROPIC_API_KEY=" "$ENV_FILE"; then
                sed "${SED_INPLACE[@]}" "s|^ANTHROPIC_API_KEY=.*|ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY|" "$ENV_FILE"
            else
                printf "ANTHROPIC_API_KEY=%s\n" "$ANTHROPIC_API_KEY" >> "$ENV_FILE"
            fi
        else
            # A placeholder key is worse than no key: the claude CLI
            # prefers ANTHROPIC_API_KEY over its own logged-in
            # (subscription) credentials, so a stale placeholder makes
            # every terminal task 401 even on a machine where the CLI
            # is fully authenticated. Strip it.
            if grep -q "^ANTHROPIC_API_KEY=sk-ant-REPLACE-ME$" "$ENV_FILE"; then
                sed "${SED_INPLACE[@]}" "/^ANTHROPIC_API_KEY=sk-ant-REPLACE-ME$/d" "$ENV_FILE"
                ok "Removed placeholder ANTHROPIC_API_KEY (claude CLI login takes over)"
            fi
        fi
        ok "Updated $ENV_FILE (preserved existing keys)"
    else
        cat > "$ENV_FILE" <<EOF
# Cortiva node environment — created by install.sh.
# Loaded by cortiva-hq at startup; do not commit to source control.

# Neo4j password (auto-generated by the installer at first install).
NEO4J_PASSWORD=$NEO4J_PASSWORD

# OpenAI-compatible adapter pointing at the local mlx_lm.server.
# mlx_lm.server does not validate the key but the adapter expects
# something present. Real OpenAI use should override this.
OPENAI_API_KEY=placeholder

# Used by the claude_code_deep_think skill and the claude-code
# terminal adapter. Pass at install time via --anthropic-key — when
# unset, NO line is written and the claude CLI's own login
# (subscription) auth applies. Never write a placeholder: the CLI
# prefers the env var over its login and would 401 on every call.
${ANTHROPIC_API_KEY:+ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY}
EOF
        chmod 600 "$ENV_FILE"
        ok "Created $ENV_FILE (chmod 600)"
    fi
    if [ -z "$ANTHROPIC_API_KEY" ] && ! grep -q "^ANTHROPIC_API_KEY=" "$ENV_FILE" 2>/dev/null; then
        info "No ANTHROPIC_API_KEY set — the claude CLI's own login auth applies (run 'claude' once on this machine to sign in)."
    fi
fi

# 2. brew shellenv in ~/.zshrc / ~/.bashrc on macOS so the operator's
#    interactive `python3` resolves to the brew Python that has the
#    cortiva framework installed. Without this, `python3 -c "import
#    cortiva"` fails for the user even though `which cortiva` works.
if [ "$OS" = "Darwin" ] && [ "$DRY_RUN" = false ]; then
    # shellcheck disable=SC2016
    # Intentional single quotes — these strings are written verbatim
    # into the user's shell config and evaluated at *their* shell
    # startup, not this script's.
    BREW_LINE='eval "$(/opt/homebrew/bin/brew shellenv)"'
    # shellcheck disable=SC2016
    PYTHON_LINE='export PATH="/opt/homebrew/opt/python@3.13/libexec/bin:$PATH"'
    for rc in "$HOME/.zshrc" "$HOME/.bashrc"; do
        [ -f "$rc" ] || touch "$rc"
        if ! grep -qF "$BREW_LINE" "$rc"; then
            printf '\n# Added by Cortiva installer — brew env on interactive shells\n%s\n' "$BREW_LINE" >> "$rc"
        fi
        if ! grep -qF "$PYTHON_LINE" "$rc"; then
            printf '# Added by Cortiva installer — brew python on PATH before system\n%s\n' "$PYTHON_LINE" >> "$rc"
        fi
    done
    ok "Shell PATH wired: $HOME/.zshrc and $HOME/.bashrc"
fi

# 3. Verify the framework imports under brew python — loud failure if
#    not. Catches the class of "everything looks installed but the
#    operator's shell can't import cortiva" we hit on Mini-2.
if [ "$DRY_RUN" = false ]; then
    if /opt/homebrew/opt/python@3.13/bin/python3.13 -c "import cortiva, cortiva_hq" 2>/dev/null; then
        ok "Verified: import cortiva + cortiva_hq from brew python"
    else
        fail "Could not import cortiva and/or cortiva_hq from brew python — install corrupt"
        exit 1
    fi
fi

# --- Pairing with HQ ---

if [ "$UPDATE_MODE" = true ]; then
    INSTALLER_STEP="Pairing (skipped — update mode)"
    ok "Update mode: skipping pairing (node already registered)"
else

step "Pairing with Cortiva HQ"

report_progress "pairing"

if [ -z "$PAIRING_KEY" ]; then
    echo ""
    read -rp "  Enter your pairing key: " PAIRING_KEY
fi

if [ -n "$PAIRING_KEY" ]; then
    if [ "$DRY_RUN" = true ]; then
        info "[dry run] Would save pairing key to $CONFIG_FILE"
    else
        # Update the HQ section in cortiva.yaml with the pairing key
        if command -v python3 &>/dev/null; then
            python3 -c "
import yaml
from pathlib import Path

config_path = Path('$CONFIG_FILE')
config = yaml.safe_load(config_path.read_text())
config.setdefault('hq', {})
config['hq']['node_token'] = '$PAIRING_KEY'
config['hq']['token_id'] = '$TOKEN_ID'
config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False))
"
        fi
        ok "Paired with Cortiva HQ"
    fi
fi

fi  # end of `if [ "$UPDATE_MODE" = true ]; ... else` from line 828

# --- Start Cortiva fabric daemon ---

step "Starting Cortiva"

report_progress "starting"

if [ "$DRY_RUN" = true ]; then
    info "[dry run] Would start cortiva fabric daemon"
    info "[dry run] Would start cortiva-hq node agent"
    report_progress "done"
else
    # Start the fabric daemon (runs agents)
    printf "  ${YELLOW}◌${RESET} Starting fabric daemon..."
    cd "$WORKSPACE"

    if cortiva status &>/dev/null; then
        printf "\r  ${GREEN}✓${RESET} Fabric daemon already running          \n"
    else
        nohup cortiva start >> "$CORTIVA_HOME/logs/fabric.log" 2>&1 &
        FABRIC_PID=$!
        sleep 2

        if kill -0 "$FABRIC_PID" 2>/dev/null; then
            printf "\r  ${GREEN}✓${RESET} Fabric daemon started ${DIM}(PID $FABRIC_PID)${RESET}          \n"
        else
            printf "\r  ${YELLOW}○${RESET} Fabric daemon — check $CORTIVA_HOME/logs/fabric.log          \n"
        fi
    fi

    # Start the HQ node agent (connects to portal for remote management)
    printf "  ${YELLOW}◌${RESET} Connecting to Cortiva HQ..."
    # Truncate any previous log so the "wait for Node online" grep below
    # only sees output from this run. `:` is a no-op command; without it
    # the lone `>` is a syntactic redirect-without-command (SC2188).
    : > "$CORTIVA_HOME/logs/node.log"
    nohup cortiva-hq node connect --config "$CONFIG_FILE" \
        >> "$CORTIVA_HOME/logs/node.log" 2>&1 &
    NODE_PID=$!
    sleep 2

    if ! kill -0 "$NODE_PID" 2>/dev/null; then
        printf "\r  ${RED}✗${RESET} Node agent failed to start          \n"
        info "Check $CORTIVA_HOME/logs/node.log for details"
        report_progress "error" "Node agent failed to start"
    else
        # Wait for connection (up to 30s)
        CONNECTED=false
        for _ in $(seq 1 30); do
            if grep -q "Node online" "$CORTIVA_HOME/logs/node.log" 2>/dev/null; then
                CONNECTED=true
                break
            fi
            if grep -q "Auth failed" "$CORTIVA_HOME/logs/node.log" 2>/dev/null; then
                break
            fi
            sleep 1
        done

        if [ "$CONNECTED" = true ]; then
            HOSTNAME_DETECTED=$(grep "Node online" "$CORTIVA_HOME/logs/node.log" | head -1 | sed 's/.*Node online: \([^ ]*\).*/\1/')
            printf "\r  ${GREEN}✓${RESET} Connected as ${BOLD}%s${RESET}          \n" "$HOSTNAME_DETECTED"
        else
            printf "\r  ${YELLOW}○${RESET} Node agent running — will connect in background          \n"
            info "Check $CORTIVA_HOME/logs/node.log for details"
        fi
    fi

    report_progress "done"
fi

# --- Register as system service ---

step "Registering system service"

# Use the same homebrew python the import-verify above passed against.
# `which python3` resolves against the script's PATH which often points
# at /usr/bin/python3 (system 3.9) where cortiva isn't installed; that's
# what caused install.sh --update to bail at "Registering system service"
# with `ModuleNotFoundError: No module named 'cortiva_hq_agent'`.
BREW_PYTHON="/opt/homebrew/opt/python@3.13/bin/python3.13"
if [ "$OS" = "Darwin" ] && [ -x "$BREW_PYTHON" ]; then
    PYTHON_PATH="$BREW_PYTHON"
else
    PYTHON_PATH=$(which python3 2>/dev/null || echo "/usr/bin/python3")
fi
CORTIVA_HQ_PATH=$(which cortiva-hq 2>/dev/null || echo "cortiva-hq")

if [ "$OS" = "Darwin" ]; then
    PLIST_PATH="$HOME/Library/LaunchAgents/com.cortiva.fabric.plist"
    if [ "$DRY_RUN" = true ]; then
        info "[dry run] Would install launchd plist at $PLIST_PATH"
    else
        if "$PYTHON_PATH" -c "
from cortiva_hq_agent.launchd import install_plist
install_plist(
    config_path='$CONFIG_FILE',
    cortiva_hq_path='$CORTIVA_HQ_PATH',
    working_dir='$WORKSPACE',
    log_dir='$CORTIVA_HOME/logs',
)
"; then
            ok "launchd service installed (reload scheduled — live in ~5s)"
        else
            warn "launchd registration skipped — start manually with 'cortiva-hq node connect --config $CONFIG_FILE'"
        fi
    fi
elif [ "$OS" = "Linux" ]; then
    if [ "$DRY_RUN" = true ]; then
        info "[dry run] Would install systemd service"
    else
        if "$PYTHON_PATH" -c "
from cortiva_hq_agent.systemd import install_unit
install_unit(
    python_path='$PYTHON_PATH',
    working_dir='$WORKSPACE',
)
"; then
            ok "systemd service installed (sudo systemctl enable cortiva)"
        else
            warn "systemd registration skipped — start manually with 'cortiva start'"
        fi
    fi
fi

# --- Summary ---

printf "\n"
printf "  ${DIM}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}\n"
if [ "$DRY_RUN" = true ]; then
    printf "\n  ${YELLOW}Dry run complete.${RESET} No changes were made.\n"
else
    if [ "${CONNECTED:-false}" = true ]; then
        printf "\n  ${GREEN}${BOLD}✓ Node is live${RESET}\n"
        printf "  ${DIM}Visible in the portal now. Agents will be deployed from HQ.${RESET}\n"
    else
        printf "\n  ${GREEN}${BOLD}✓ Cortiva installed${RESET}\n"
        printf "  ${DIM}Your node will appear in the portal once it connects.${RESET}\n"
    fi
    printf "\n"
    printf "  ${DIM}Workspace${RESET}  $WORKSPACE\n"
    printf "  ${DIM}Config${RESET}     $CONFIG_FILE\n"
    printf "  ${DIM}Logs${RESET}       $CORTIVA_HOME/logs/\n"
    printf "\n"
    printf "  ${DIM}Commands:${RESET}\n"
    printf "    cortiva status          ${DIM}Show agent status${RESET}\n"
    printf "    cortiva agent list      ${DIM}List deployed agents${RESET}\n"
    printf "    neo4j status            ${DIM}Check Neo4j status${RESET}\n"
    printf "    cortiva-hq license status  ${DIM}Check license${RESET}\n"
fi
printf "\n"

# Last line: tell the EXIT trap we finished cleanly so it doesn't print
# "exited without reaching the end". Anything after this is decoration.
INSTALLER_STEP="finished"
INSTALLER_DONE=true
