#!/usr/bin/env bash
# ==============================================================================
#  Edge Compute — one-line node installer
#
#  Turns this machine into an AI compute node that earns USDC per inference.
#
#  QUICK START (the single line a user runs):
#     curl -fsSL https://get.chainlore.dev | bash
#
#  Or, from a local checkout:
#     bash install.sh
#
#  Env overrides (all optional):
#     EDGE_MODEL       LLM to serve         (default: llama3.2:1b; ultralight: qwen2.5:0.5b)
#     EDGE_REPO_URL    git repo to clone    (default: the public repo below)
#     EDGE_HOME        install dir          (default: $HOME/.edge-compute)
#     COORDINATOR_URL  coordinator to join  (default: http://127.0.0.1:8787)
#     NODE_PAYTO       your Base wallet     (USDC payouts land here; prompted if unset)
#     EDGE_NO_START=1  set up but don't start the daemon
#     EDGE_NO_PARALLEL=1  run every download strictly one-at-a-time (slower; for debugging a wedged install)
#
#  Target: macOS (Intel first). Idempotent — safe to re-run.
# ==============================================================================
set -euo pipefail

# ── pretty output ───────────────────────────────────────────────────────────
if [ -t 1 ]; then B=$'\033[1m'; G=$'\033[32m'; Y=$'\033[33m'; R=$'\033[31m'; C=$'\033[36m'; N=$'\033[0m'; else B=; G=; Y=; R=; C=; N=; fi

# ── Round-25: TIME EVERY STEP. ────────────────────────────────────────────────
# Two reasons, and the second is the one that matters.
#
#  1. UX. This install is minutes long and dominated by two big downloads. An operator staring at a silent
#     terminal assumes it has hung and kills it. `⏱ 38s` next to each step is the cheapest possible reassurance.
#
#  2. So we optimise the thing that is actually slow, instead of the thing that FEELS slow. The obvious
#     candidate is the ~1.3 GB model, and the obvious "fix" is to bundle the weights in the release — which
#     moves the same bytes from one CDN to another, saves nothing, couples us to Ollama's on-disk format, and
#     makes us a redistributor of Llama weights. Before believing any of that, measure it. This prints the
#     breakdown at the end so the answer is data, not intuition.
INSTALL_T0=$(date +%s)
STEP_T0=$INSTALL_T0
STEP_NAME=""
STEP_LOG=()
PARALLEL_SECS=0   # Round-33: seconds of work that ran CONCURRENTLY with the steps above. See the par_* runner.
_flush_step() {
  [ -n "$STEP_NAME" ] || return 0
  local now dur
  now=$(date +%s); dur=$((now - STEP_T0))
  STEP_LOG+=("$(printf '%4ds  %s' "$dur" "$STEP_NAME")")
  printf "  ${C}⏱ %ss${N}\n" "$dur"
  STEP_NAME=""
}
step() { _flush_step; STEP_NAME="$1"; STEP_T0=$(date +%s); printf "\n${B}${C}▸ %s${N}\n" "$1"; }

# Print where the time actually went. Called on every exit path (including the `exec` into the daemon).
timings() {
  _flush_step
  local total=$(( $(date +%s) - INSTALL_T0 ))
  printf "\n${B}Where the time went${N} (total ${B}%dm%02ds${N}):\n" $((total / 60)) $((total % 60))
  local line
  for line in "${STEP_LOG[@]}"; do printf "  %s\n" "$line"; done
  # ── Round-33: DO NOT LET THE BREAKDOWN LIE. ─────────────────────────────────
  # This table is the instrument that found every install-time win we have — it is the reason we knew the
  # TypeScript compile, and not the 1.3 GB model, was the removable cost. The moment work moves into the
  # BACKGROUND, a naive table starts MISATTRIBUTING it: the 1.4 GB engine download would vanish out of "Ollama
  # engine" and silently reappear as a mysteriously slow "Node.js", and the next person to read this would go
  # and optimise the wrong step. An instrument that lies is worse than no instrument.
  # So: parallel work gets its own `∥` line carrying its REAL duration, and we say plainly that the column no
  # longer sums to the total. `PARALLEL_SECS` is also the machine-readable proof that the overlap actually
  # happened — scripts/linux-install-test.sh asserts it is non-zero (probe A8).
  if [ "$PARALLEL_SECS" -gt 0 ]; then
    printf "  ${C}∥ %ss of the above ran in PARALLEL with other steps${N} — so the column sums to MORE than the total.\n" "$PARALLEL_SECS"
    printf "  ${C}%s${N}\n" "  (that is the saving: those seconds never landed on the operator's clock. EDGE_NO_PARALLEL=1 to serialise.)"
  fi
}
ok()   { printf "  ${G}✓ %s${N}\n" "$1"; }
warn() { printf "  ${Y}! %s${N}\n" "$1"; }
die()  { printf "\n${R}✗ %s${N}\n" "$1" >&2; exit 1; }
have() { command -v "$1" >/dev/null 2>&1; }

# ── Round-25: FAIL LOUD, AND PROVE THE POSTCONDITION ──────────────────────────
# `set -e` does NOT abort on a failing command that is the LEFT side of an `&&` list (POSIX: -e is suspended
# for every command of an AND-OR list except the last). So the old
#
#     ( cd "$APP_DIR" && pnpm -r build ) && ok "built"
#
# swallowed a failed build COMPLETELY — no message, and the script still exited 0. The installer printed
# "Done." and handed the operator a node whose CLI dies with `edge-node: build missing`. That is this
# project's signature bug — the money path breaks while every surface reports success — reproduced inside the
# installer itself, which is the one component that runs BEFORE any of our defences exist.
#
# `must` runs a step and dies naming it. `expect_file` asserts what the step was supposed to PRODUCE, because
# a command exiting 0 is not evidence that it produced anything.
must()        { local what="$1"; shift; "$@" || die "$what failed  (command: $*)"; }
expect_file() { [ -s "$2" ] || die "$1 — expected file missing or empty: $2"; }
expect_dir()  { [ -d "$2" ] || die "$1 — expected directory missing: $2"; }

# ── Round-33: OVERLAP ONLY WHAT DOES NOT COMPETE — and never silently. ────────
# MEASURED on a bare ubuntu:22.04 BEFORE any of this was written, because the whole point of the timing table
# is to stop us optimising by intuition:
#   · every download here runs at ~30 MB/s. The engine (1.44 GB) + model (1.3 GB) + guard (1.6 GB) = 4.3 GB, so
#     ~145s of a 203s install is simply bytes crossing one pipe. NO scheduling removes them. (Same lesson as
#     "just bundle the model weights": moving the same bytes around is not an optimisation.)
#   · so running two DOWNLOADS at once buys little: pulling both models serially took 88s, concurrently 72s.
#     Real, but small — they are splitting one pipe. Do not expect miracles from it, and do not add more.
#   · the win that actually pays is overlapping a BANDWIDTH-bound step with a CPU-bound one. The Node.js install
#     is ~30s of apt/dpkg unpacking that barely touches the network, and it sat idle while the engine downloaded.
#
# This tiny job runner exists to make backgrounding SAFE, because a background job is the perfect hiding place
# for this project's signature bug: the work fails, nobody waits for it, and the installer prints "Done."
# Therefore every job:
#   · has its REAL exit code collected via `wait`. A job that vanished (killed, never started) scores 127 —
#     FAILED. There is no path on which "we never heard back" is read as success.
#   · has its ENTIRE log printed on failure. A backgrounded error nobody reads is worse than no check at all.
#   · is STILL followed by the caller asserting the postcondition (the binary exists; the tag is really in
#     Ollama). par_wait returning 0 only means "the command exited 0" — which this codebase has learned the
#     hard way is not evidence that it produced anything.
#   · is killed on every exit path (trap), so a Ctrl-C cannot leave a stray curl eating the operator's bandwidth.
#
# ⚠ A JOB RUNS IN A SUBSHELL — nothing it assigns or exports reaches the parent. Jobs do FILESYSTEM work ONLY;
#   the `export PATH`, the health check and every assertion happen in the PARENT after par_wait. Backgrounding
#   the old Ollama branch wholesale would have silently lost its `export PATH` and left `ollama` not found.
#
# EDGE_NO_PARALLEL=1 runs every job inline, in order, through the SAME log/rc/postcondition path — one code
# path, so the serial escape hatch cannot rot away from the parallel one.
#
# The block between the two markers below is EXTRACTED VERBATIM AND EXECUTED by
# packages/node-client/test/installer-contract.test.ts. That test does not re-implement this runner — it runs
# THIS code, so the guarantees above (a vanished job is a failure; a failed job prints its log; the serial path
# behaves identically) are proven against what actually ships, and cannot rot into a comment. Keep the markers.
# >>> par-runner (extracted verbatim by test/installer-contract.test.ts) >>>
EDGE_NO_PARALLEL="${EDGE_NO_PARALLEL:-0}"
PAR_DIR=""
PAR_NAMES=""
par_cleanup() {
  [ -n "$PAR_DIR" ] || return 0
  local n p
  for n in $PAR_NAMES; do
    p="$(cat "$PAR_DIR/$n.pid" 2>/dev/null)" && [ -n "$p" ] || continue   # no pid file ⇒ nothing to stop
    # A kill that finds nothing is the EXPECTED case — the job finished normally. That is why this branches on
    # a known-benign outcome (`kill -0`) instead of silencing a real one with `|| true`: if we ask a live
    # process to stop and it refuses, that is news, and it gets said out loud.
    if kill -0 "$p" 2>/dev/null; then
      kill "$p" 2>/dev/null || warn "could not stop background job '$n' (pid $p) — it may still be downloading"
    fi
  done
  rm -rf "$PAR_DIR"
}
trap par_cleanup EXIT

# par_start <name> <command…> — begin a job (backgrounded, or inline when EDGE_NO_PARALLEL=1).
par_start() {
  local name="$1"; shift
  [ -n "$PAR_DIR" ] || PAR_DIR="$(mktemp -d)"
  PAR_NAMES="$PAR_NAMES $name"
  date +%s >"$PAR_DIR/$name.t0"
  printf '%s' "${STEP_NAME:-}" >"$PAR_DIR/$name.step"   # which step launched it — see par_wait's overlap rule
  if [ "$EDGE_NO_PARALLEL" = "1" ]; then
    local rc=0
    # `|| rc=$?` and not a bare call: set -e must not kill us BEFORE we have recorded the code we came for.
    ( "$@" ) >"$PAR_DIR/$name.log" 2>&1 || rc=$?
    printf '%s' "$rc" >"$PAR_DIR/$name.rc"
  else
    ( "$@" ) >"$PAR_DIR/$name.log" 2>&1 &
    printf '%s' "$!" >"$PAR_DIR/$name.pid"
  fi
}

# par_wait <name> → the job's REAL exit code. Prints the job's log if it failed, and records its true duration
# for the timing table. It never dies on its own: the CALLER decides whether a job is fatal (the engine is) or
# degradable (the content guard is — R31: a failed guard pull must warn loudly, never brick the install).
par_wait() {
  local name="$1" rc=0 p t0 dur started_in
  # No pid file is a NORMAL state, not a swallowed error: it means EDGE_NO_PARALLEL ran the job inline and left
  # an rc file instead. Said as `|| p=""` rather than `|| true` so the empty string reads as the state it is.
  p="$(cat "$PAR_DIR/$name.pid" 2>/dev/null)" || p=""
  if [ -n "$p" ]; then
    wait "$p" || rc=$?
    # ⚠ A PID NUMBER IS NOT AN IDENTITY — round-25's reboot killer, and it applies here too. The moment `wait`
    # returns, this pid is REAPED and the kernel may hand the same number to somebody else's process. If we left
    # the pid file behind, par_cleanup would later `kill` that number on the way out and SIGTERM a stranger.
    # Retiring the file the instant the job is collected means cleanup can only ever signal jobs still running.
    rm -f "$PAR_DIR/$name.pid"
  else
    # No pid ⇒ the inline (EDGE_NO_PARALLEL) path wrote an rc file. No pid AND no rc ⇒ the job never ran or was
    # killed ⇒ 127 ⇒ failure. "We never heard back" must never be indistinguishable from "it worked".
    rc="$(cat "$PAR_DIR/$name.rc" 2>/dev/null || echo 127)"
  fi
  t0="$(cat "$PAR_DIR/$name.t0" 2>/dev/null || date +%s)"
  dur=$(( $(date +%s) - t0 ))
  # ── THE OVERLAP RULE, and it is narrower than it looks. ─────────────────────
  # A job counts as overlapping ONLY if it outlived the step that started it (started under one `step` heading,
  # collected under another) — like the engine, which streams across the whole Node.js install. Its seconds
  # really did happen off the wall clock, so they belong on a `∥` line.
  #
  # A job started AND collected inside the SAME step — the two model pulls — is overlapping nothing else. Its
  # cost IS that step's duration, which the table already reports. Giving it a `∥` line too would count the same
  # 72s three times (once for the step, once per pull), inflate PARALLEL_SECS to ~216s, and turn the one
  # instrument we trust into a machine for manufacturing imaginary savings. The concurrency is still real and
  # still measured — it shows up as a SHORTER "Pulling models" step, which is exactly where it should show up.
  started_in="$(cat "$PAR_DIR/$name.step" 2>/dev/null || echo "")"
  if [ "$EDGE_NO_PARALLEL" != "1" ] && [ "$started_in" != "${STEP_NAME:-}" ]; then
    STEP_LOG+=("$(printf '%4ds  ∥ %s (ran in parallel with the steps above)' "$dur" "$name")")
    PARALLEL_SECS=$((PARALLEL_SECS + dur))
  fi
  if [ "$rc" -ne 0 ]; then
    printf "\n${R}✗ background job '%s' FAILED (exit %s) — its complete output follows:${N}\n" "$name" "$rc" >&2
    # An empty log is not nothing to report — it is the clue that the job died before it could say anything
    # (OOM-killed, or the trap reaped it). Saying so beats printing an unexplained blank.
    if [ -s "$PAR_DIR/$name.log" ]; then
      sed 's/^/    /' "$PAR_DIR/$name.log" >&2
    else
      printf "    (the job produced no output at all — it was killed, or never started)\n" >&2
    fi
  fi
  return "$rc"
}
# <<< par-runner <<<

# EXPORTED, here, at the top — not 400 lines later. Every `edge-node` subcommand this script runs (wallet:init,
# payout:prove, doctor, start) resolves its state dir from $EDGE_HOME and silently falls back to
# ~/.edge-compute when it is unset. That fallback happens to equal our default, which is the only reason the
# un-exported version ever worked; with EDGE_HOME=/opt/edge it would have written the payout key to one
# directory and the config naming it to another, and the node would have come up unable to prove it owns its
# own wallet. Do not let this be a coincidence.
export EDGE_HOME="${EDGE_HOME:-$HOME/.edge-compute}"
# Did the OPERATOR explicitly choose these, or are we about to invent a default? Capture that BEFORE the `:-`
# defaults erase the distinction — on a re-run we must not silently overwrite settings they edited by hand,
# but when they DO pass one we must honour it. (`${VAR+1}` = "was it set at all", empty string included.)
EDGE_MODEL_GIVEN="${EDGE_MODEL+1}"
COORDINATOR_URL_GIVEN="${COORDINATOR_URL+1}"

EDGE_MODEL="${EDGE_MODEL:-llama3.2:1b}"
# Round 31 — the content guard (operator liability). A buyer's prompt runs on THIS machine at THIS IP, and the
# ComputeProof guarantees nothing about safety. We pull Llama Guard 3 1B alongside the main model and turn it ON
# by default (it screens the prompt AND the answer; anything flagged is refused, the buyer is not charged).
# `EDGE_CONTENT_GUARD=off` skips it entirely; `EDGE_GUARD_MODEL` overrides the model.
EDGE_CONTENT_GUARD="${EDGE_CONTENT_GUARD:-on}"
EDGE_GUARD_MODEL="${EDGE_GUARD_MODEL:-llama-guard3:1b}"
GUARD_PULLED=0
# ── Round-33: SELF-HOSTED DISTRIBUTION. The repo is PRIVATE and stays private. ──
# Exactly three files are public, served from our own domain (not GitHub): install.sh, the prebuilt
# edge-node.mjs, and a source tarball built from an allowlist (see scripts/publish-release.sh).
#
# `EDGE_REPO_URL` is now UNSET by default and no longer a fallback an operator can hit: a private repo cannot be
# cloned by a stranger. Set it explicitly to use a git checkout (developers; the harness's bare-repo test).
EDGE_REPO_URL="${EDGE_REPO_URL:-}"
EDGE_SRC_URL="${EDGE_SRC_URL:-https://get.chainlore.dev/edge-compute-src.tar.gz}"
# ── Round-32: the PREBUILT CLI BUNDLE (kills the ~35s TypeScript compile on the operator's box) ──
# The single biggest REMOVABLE install cost was `pnpm install` + four `tsc` builds recompiling byte-identical
# TypeScript on every operator's machine. We now compile it ONCE (esbuild → a single `edge-node.mjs` with no
# node_modules; scripts/build-bundle.mjs) and FETCH that instead. The installer prefers the bundle and falls
# back to clone+build automatically if it can't be fetched or run (or if you force source). The default URL is
# the GitHub release asset; the install/persistence harnesses point it at a locally-built file:// bundle.
#   EDGE_BUNDLE_URL=…            override the bundle location (http(s):// or file://)
#   EDGE_SRC_URL=…               override the source-tarball fallback location
#   EDGE_REPO_URL=…              use a git clone instead of the tarball (developers only; repo is private)
#   EDGE_BUILD_FROM_SOURCE=1     skip the bundle entirely; fetch source + pnpm install + tsc (the pre-R32 path)
EDGE_BUNDLE_URL="${EDGE_BUNDLE_URL:-https://get.chainlore.dev/edge-node.mjs}"
EDGE_BUILD_FROM_SOURCE="${EDGE_BUILD_FROM_SOURCE:-0}"
INSTALL_MODE=""   # decided below: "bundle" (fetched, no compile) | "source" (clone/checkout + build)
EDGE_ENTRY=""     # the JS file the installed `edge-node` wrapper will exec (bundle .mjs, or the source shim)
# Production coordinator (live on GCP, HTTPS/WSS). Operators join this by default; override for local dev.
COORDINATOR_URL="${COORDINATOR_URL:-https://coordinator.chainlore.dev}"

# ── SAY WHAT YOU ARE ABOUT TO DO, BEFORE YOU DO IT ────────────────────────────
# We are asking a stranger to pipe a script from the internet into bash, let it generate a PRIVATE KEY that
# will hold their money, and leave a daemon running on their machine. Trust for that is not earned by a
# spinner; it is earned by saying, up front, exactly what will happen and how long it takes — and then doing
# only that. The honest number matters most: two big downloads (the Ollama engine, and a ~1.3 GB model)
# dominate the run. An operator who is not told that will Ctrl-C at minute three and never come back.
printf '%s\n' "${B}Edge Compute node installer${N}"
printf '%s\n' \
  "  This turns this machine into an AI compute node that earns USDC per inference." \
  "" \
  "  It will:" \
  "    1. install Node.js and the Ollama inference engine (~1.4 GB — the biggest single download), and fetch" \
  "       the prebuilt node software (into ${C}$EDGE_HOME${N})" \
  "    2. download ${C}$EDGE_MODEL${N} (~1.3 GB) + a safety screen (Llama Guard 3 1B, ~1.6 GB)" \
  "    3. create a ${B}payout wallet${N} and print its address (the private key stays on THIS machine)" \
  "    4. verify the whole money path, and refuse to claim success it cannot prove" \
  "    5. start your node as a service, so it survives logout and reboot" \
  "" \
  "  Typical time: ${B}2–5 minutes${N} — ~4.3 GB of downloads, which is nearly all of it. Nothing is sent anywhere." \
  "  Re-running this is safe: your wallet, identity and earnings are backed up and never overwritten." \
  ""
printf "  home=%s  model=%s  coordinator=%s\n" "$EDGE_HOME" "$EDGE_MODEL" "$COORDINATOR_URL"

# ── 0. platform detection ─────────────────────────────────────────────────────
# Round-20: macOS AND Linux. This used to `warn "best-effort"` on a non-Darwin host and then unconditionally
# call `brew` — i.e. it did not merely lack Linux support, it FAILED on Linux while claiming to try. The whole
# point of the project is that a spare machine earns money, and most spare machines are Linux boxes.
step "Checking platform"
OS="$(uname -s)"; ARCH="$(uname -m)"
PKG=""            # the system package manager we'll use on Linux
case "$OS" in
  Darwin) ok "macOS ($ARCH)" ;;
  Linux)
    # Detect the distro family by its package manager rather than by parsing /etc/os-release names —
    # derivatives (Mint, Pop!_OS, Rocky, Manjaro…) outnumber the distros they descend from.
    if   have apt-get; then PKG=apt
    elif have dnf;     then PKG=dnf
    elif have yum;     then PKG=yum
    elif have pacman;  then PKG=pacman
    elif have apk;     then PKG=apk
    else die "Linux detected but no supported package manager found (need apt/dnf/yum/pacman/apk)."
    fi
    ok "Linux ($ARCH, $PKG)"
    ;;
  *) die "Unsupported OS '$OS'. This installer supports macOS and Linux." ;;
esac
case "$ARCH" in
  x86_64|amd64|arm64|aarch64) ;;
  *) warn "arch '$ARCH' is not one Ollama ships prebuilt binaries for — this is unlikely to work." ;;
esac

# `sudo` only where a system package manager genuinely needs it — and only if we aren't already root
# (containers usually are, and calling sudo there fails because it isn't installed).
SUDO=""
if [ "$OS" = "Linux" ] && [ "$(id -u)" -ne 0 ]; then
  have sudo || die "Need root or sudo to install system packages."
  SUDO="sudo"
fi

# Run a command with root privileges — or plainly, if we already ARE root.
# NOTE: this must be a function, not a bare `$SUDO cmd`. When SUDO is empty (the common case in a container,
# where you are already root), `$SUDO -E bash -` expands to `-E bash -` — an invalid command. That failed with
# the wonderfully unhelpful `curl: (23) Failed writing body`, because the pipe's reader had died. Caught by
# actually running the installer in a container instead of reasoning about it.
as_root() {
  if [ -n "$SUDO" ]; then sudo -E "$@"; else "$@"; fi
}

# ── Round-33: refresh the package index ONCE per run. ─────────────────────────
# `pkg_install` refreshed the apt index on EVERY call — and it is called for base tools, for nodejs, and for
# zstd — while NodeSource's setup_20.x runs `apt update -y` itself, for a fourth refresh. MEASURED on a bare
# ubuntu:22.04: the first refresh costs 8s, every repeat 2-3s. ~6s of a 203s install spent re-reading package
# lists we had just read. Small, but it is pure waste and removing it is free.
#
# The thing that makes memoising safe is being able to UNDO it: adding an apt repo (NodeSource) genuinely
# invalidates the index, so that path calls `apt_index_stale` and the next install refreshes for real. A cache
# with no invalidation is just a bug waiting for the distro that notices. Note the last line is the assignment,
# NOT `[ … ] && …`: as a function's FINAL command that idiom returns 1 when the test is false and takes the
# whole installer down with it under `set -e` (verified in test/installer-contract.test.ts).
APT_FRESH=0
apt_update_once() {
  [ "$APT_FRESH" = "1" ] && return 0
  as_root apt-get update -qq
  APT_FRESH=1
}
apt_index_stale() { APT_FRESH=0; }

pkg_install() { # $@ = package names
  case "$PKG" in
    apt)    apt_update_once && DEBIAN_FRONTEND=noninteractive as_root apt-get install -y -qq "$@" ;;
    dnf)    as_root dnf install -y -q "$@" ;;
    yum)    as_root yum install -y -q "$@" ;;
    pacman) as_root pacman -Sy --noconfirm --needed "$@" ;;
    apk)    as_root apk add --quiet "$@" ;;
  esac
}

# ── 0b. PROTECT WHAT CANNOT BE REGENERATED ────────────────────────────────────
# The installer is advertised as idempotent and safe to re-run — so it WILL be re-run, on a box that is
# already earning. Four files under $EDGE_HOME are irreplaceable, and losing any of them loses money:
#
#   wallet.json       the payout PRIVATE KEY. Lose it and the USDC already paid to that address is gone.
#   identity.key      the node's ed25519 identity. Lose it and the coordinator cannot attribute a single
#                     future leaf to you — a brand-new nodeId starts from zero earnings.
#   payout-proofs.json the ownership proofs a strict coordinator demands before it credits the rail at all.
#   outbox.json       leaves that are EARNED BUT NOT YET CREDITED (R21). This is literally money in flight.
#
# Nothing below intends to overwrite these (wallet:init refuses to; .env is `[ ! -f ]`-guarded). But "nothing
# intends to" is exactly the assurance that failed in every previous round. So we do not rely on intent: we
# take a copy FIRST, before any step runs, and we tell the operator where it is. Cheap, and it means no future
# edit to this script — or a half-finished run, or a Ctrl-C — can ever silently cost someone their key.
if [ -d "$EDGE_HOME" ]; then
  step "Protecting existing node state"
  _snap="$EDGE_HOME/backups/$(date -u +%Y%m%dT%H%M%SZ)"
  _saved=0
  for f in wallet.json identity.key payout-proofs.json outbox.json .env; do
    if [ -f "$EDGE_HOME/$f" ]; then
      mkdir -p "$_snap"
      cp -p "$EDGE_HOME/$f" "$_snap/$f" || die "could not back up $EDGE_HOME/$f — refusing to continue and risk it"
      _saved=$((_saved + 1))
    fi
  done
  if [ "$_saved" -gt 0 ]; then
    chmod -R go-rwx "$_snap" 2>/dev/null || true   # it contains private keys
    ok "backed up $_saved existing file(s) → $_snap"
  else
    ok "no prior state to protect (fresh install)"
  fi
fi

# ── 1. Package manager (macOS only) ────────────────────────────────────────────
if [ "$OS" = "Darwin" ]; then
  step "Homebrew"
  if have brew; then ok "brew present ($(brew --version | head -1))"
  else
    warn "Homebrew not found — installing (may prompt for your password)…"
    /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
    # add to PATH for this session (Intel prefix)
    eval "$(/usr/local/bin/brew shellenv 2>/dev/null || /opt/homebrew/bin/brew shellenv)"
    ok "Homebrew installed"
  fi
else
  step "Base tools"
  # curl/ca-certificates/tar are assumed on macOS but genuinely absent from minimal Linux images.
  #
  # Round-23: `git` MUST be here. The advertised install is `curl -fsSL … | bash`, and under a pipe
  # `BASH_SOURCE[0]` is unset — so SCRIPT_DIR resolves to the user's CWD, the local-checkout test below fails,
  # and we take the `git clone` path. debian-slim / ubuntu / fedora-minimal ship no git → "git: command not
  # found" → `set -e` → the one-liner dies on the very first thing it does. It worked on macOS only because
  # Xcode CLT provides git. This is the whole reason the container run had to be finished.
  #
  # Round-33: `zstd` joins this list, and that is not cosmetic. The Ollama engine download now runs as a
  # BACKGROUND job (it streams `curl | zstd -dc | tar`) while the Node.js install runs in the foreground. The
  # job therefore must never call the package manager itself: Node's apt transaction holds the dpkg lock, and
  # two apt runs at once is a deadlock we would have shipped to every operator. Pulling zstd into this single
  # foreground transaction means the background job needs nothing but curl and the network.
  pkg_install curl ca-certificates tar git zstd >/dev/null 2>&1 || warn "could not pre-install base tools — continuing"
  ok "base tools present"
fi

# ── 2. THE ENGINE DOWNLOAD — STARTED NOW, IN THE BACKGROUND ───────────────────
# The Ollama engine is 1.44 GB. MEASURED, and worth saying out loud because it contradicts what this installer
# used to tell people: it is the LARGEST single download in the install — bigger than the 1.3 GB model everyone
# (including our own banner) assumed was the slow part. It is pure network and touches no CPU worth the name.
#
# The very next step, Node.js, is ~30s of apt/dpkg unpacking: pure CPU and disk, almost no network. Run one
# after the other they cost ~65s. Started together they cost ~35s, because they are not contending for the same
# resource. That is the entire trick, and it is the only overlap in this installer that genuinely pays.
#
# What we deliberately did NOT do: overlap two downloads with each other. Measured, the two model pulls go 88s
# serial → 72s concurrent, because they just split the same ~30 MB/s pipe. Bytes are bytes.
#
# `ollama_fetch` is the JOB — a subshell. It does filesystem work and nothing else; PATH, the health check and
# every assertion happen in the parent after par_wait, further down.
ollama_fetch() {
  mkdir -p "$EDGE_HOME/bin"
  if [ "$OS" = "Darwin" ]; then
    local tmpzip obin
    tmpzip="$(mktemp -d)/ollama.zip"
    curl -fsSL https://ollama.com/download/Ollama-darwin.zip -o "$tmpzip" || die "ollama download failed"
    rm -rf "$EDGE_HOME/ollama-app"                    # clear any partial/immutable prior extract (idempotent)
    unzip -oq "$tmpzip" -d "$EDGE_HOME/ollama-app" || die "ollama unzip failed"
    obin="$(find "$EDGE_HOME/ollama-app" -type f -name ollama | head -1)"
    [ -n "$obin" ] || die "could not locate the ollama binary in the download"
    ln -sf "$obin" "$EDGE_HOME/bin/ollama"           # symlink keeps sibling runner resources resolvable
  else
    local oarch
    case "$ARCH" in
      x86_64|amd64)   oarch=amd64 ;;
      arm64|aarch64)  oarch=arm64 ;;
      *) die "no prebuilt Ollama for arch '$ARCH'" ;;
    esac
    # We fetch the release asset directly rather than piping Ollama's install.sh to a shell: their script
    # assumes root, wants to create a system user and a systemd unit, and would fail (or over-reach) in a
    # container or for an unprivileged operator. We only want the binary; we manage the service ourselves.
    # The asset is `.tar.zst` (zstd), NOT `.tar.gz`, and it lives on the GitHub release, not ollama.com/download
    # (which 404s for these names) — verified against the releases API rather than guessed.
    #
    # ── Round-33: STREAM it, do not land it. ──
    # This was `curl -o file` and THEN `zstd -dc file | tar -x`: two serial passes over 1.44 GB — write the
    # whole compressed archive to disk, then read every byte back to unpack it. Streaming decompresses as the
    # bytes arrive (exactly what Ollama's own installer does) and never stores the archive at all.
    # MEASURED on ubuntu:22.04: 35s streamed vs ~45s download-then-extract, and 1.4 GB less disk churn.
    #
    # `set -o pipefail` (top of this file) is LOAD-BEARING here. Without it a pipeline's status is the LAST
    # command's — tar's — so a truncated download or an HTML error page from the CDN would "unpack" into a
    # broken engine and this function would return 0. With pipefail, curl's failure is the pipeline's failure.
    # The parent still asserts bin/ollama exists AND runs, because an exit code is not evidence.
    #
    # The archive lays out bin/ollama + lib/ (the CPU/GPU runners). Extract them TOGETHER and keep them
    # together: the binary resolves its runners relative to itself, so moving the binary alone silently
    # breaks inference. Do not "tidy" this into extracting bin/ only.
    curl -fsSL "https://github.com/ollama/ollama/releases/latest/download/ollama-linux-${oarch}.tar.zst" \
      | zstd -dc | tar -xf - -C "$EDGE_HOME" \
      || die "ollama download/extract failed (network, bad URL, or a truncated archive)"
  fi
}

step "Ollama engine (1.4 GB — starts now, downloads while Node.js installs)"
OLLAMA_JOB=0
if have ollama; then
  ok "ollama present ($(ollama --version 2>&1 | head -1))"
elif [ -x "$EDGE_HOME/bin/ollama" ]; then
  # Already installed by a previous run under $EDGE_HOME (not on the global PATH). Reuse it — do NOT
  # re-download/re-extract: the prebuilt .app bundle's framework files are immutable, so a second `unzip -o`
  # fails ("Operation not permitted"). This makes the step idempotent (AC4.4).
  export PATH="$EDGE_HOME/bin:$PATH"
  ok "ollama present at $EDGE_HOME/bin/ollama"
else
  # zstd MUST exist before the job starts — the job streams through it and is forbidden from calling the
  # package manager (Node's apt transaction holds the dpkg lock alongside it). Base tools installs it above;
  # this is the assertion that we really have it, plus a last-chance install for a host that skipped that path.
  if [ "$OS" != "Darwin" ]; then
    have zstd || pkg_install zstd >/dev/null 2>&1 || warn "package manager could not install zstd — checking anyway"
    have zstd || die "need 'zstd' to unpack the Ollama Linux build — install it and re-run (apt install zstd)."
  fi
  warn "Ollama not found — fetching the official prebuilt build in the background…"
  par_start ollama-engine ollama_fetch
  OLLAMA_JOB=1
  ok "engine download running in the background — installing Node.js while it streams"
fi

# ── 3. Node.js ──────────────────────────────────────────────────────────────────
# Node is ALWAYS required — it runs the CLI (bundle or source). pnpm is NOT: it exists only to install deps and
# compile TypeScript, which the prebuilt-bundle path skips entirely. So we install Node here and defer pnpm to
# `ensure_pnpm`, called only if we end up building from source (the second removable cost this round — R31's
# guard-model pull moved install time UP; skipping a whole toolchain on the fast path moves it back down).
step "Node.js"
if have node && [ "$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null)" -ge 20 ] 2>/dev/null; then
  ok "node $(node --version)"
elif [ "$OS" = "Darwin" ]; then
  warn "Node >=20 not found — installing via brew…"; brew install node; ok "node $(node --version)"
else
  # Distro node packages are routinely years old (Debian 12 ships 18), and we need >=20. NodeSource is the
  # official upstream repo and is what the coordinator VM's own startup script already uses.
  warn "Node >=20 not found — installing Node 20 from NodeSource…"
  ns="$(mktemp)"
  # `apt_index_stale` after the NodeSource script, and be precise about WHY — the tempting explanation is wrong.
  # Their setup_20.x writes its repo and then runs `apt update -y` ITSELF (verified by reading it), so the
  # on-disk index really is fresh here and skipping our refresh would happen to work today. We invalidate anyway:
  # APT_FRESH is our BELIEF about an index that a third party's script just rewrote, and keeping that belief
  # would mean our memoisation silently depends on a step inside someone else's code that we do not control and
  # are not notified about. The rule is "anything outside this script touches apt's sources ⇒ our belief is
  # void". It costs one ~3s refresh and buys not having to re-read NodeSource's source before every release.
  case "$PKG" in
    apt)        curl -fsSL https://deb.nodesource.com/setup_20.x -o "$ns" && as_root bash "$ns" >/dev/null 2>&1 && apt_index_stale && pkg_install nodejs ;;
    dnf|yum)    curl -fsSL https://rpm.nodesource.com/setup_20.x -o "$ns" && as_root bash "$ns" >/dev/null 2>&1 && apt_index_stale && pkg_install nodejs ;;
    pacman|apk) pkg_install nodejs npm ;;   # both track current node closely
  esac
  rm -f "$ns"
  have node || die "Node install failed. Install Node >=20 manually and re-run."
  # ASSERT THE VERSION WE ASKED FOR, not merely that *a* node exists. The `have node && >=20` test above only
  # decides whether to install; nothing re-checked afterwards. So if NodeSource's repo failed to take and apt
  # fell back to the DISTRO's package (Debian 12 ships 18), this printed a cheerful green `node v18.19.0` and
  # carried on — and the operator meets it later as a bundle that will not load. Same shape as every bug in this
  # file's history: the step "succeeded" and the claim was never checked against the requirement.
  node -e 'process.exit(+process.versions.node.split(".")[0] >= 20 ? 0 : 1)' 2>/dev/null \
    || die "installed node is $(node --version), but this node client needs >=20.
     The NodeSource repo did not take, so apt served your distro's older package. Install Node >=20 and re-run."
  ok "node $(node --version)"
fi

# Install pnpm on demand — invoked ONLY on the build-from-source path (the bundle path never needs it).
ensure_pnpm() {
  if have pnpm; then ok "pnpm $(pnpm --version)"; return 0; fi
  if have corepack; then as_root corepack enable >/dev/null 2>&1 && corepack prepare pnpm@latest --activate >/dev/null 2>&1
  else as_root npm install -g pnpm >/dev/null 2>&1; fi
  have pnpm || die "pnpm install failed."
  ok "pnpm $(pnpm --version)"
}

# ── 3a. PREFER THE PREBUILT BUNDLE (the fast path) ────────────────────────────
# Fetch the single-file CLI and PROVE it runs on this node before trusting it — a downloaded file is not a
# working program (a truncation, a CDN error page, or a too-old Node all have to fail HERE, loudly, not tomorrow
# when the operator runs a command). `--help` exercises every top-level import in the bundle. On any failure we
# fall through to build-from-source, so this is strictly an optimisation and can never leave the operator stuck.
step "Fetching prebuilt node bundle (skips the ~35s TypeScript compile)"
mkdir -p "$EDGE_HOME"
if [ "$EDGE_BUILD_FROM_SOURCE" = "1" ]; then
  warn "EDGE_BUILD_FROM_SOURCE=1 — skipping the prebuilt bundle; will clone + compile from source."
else
  BUNDLE_OUT="$EDGE_HOME/edge-node.mjs"
  # The smoke file MUST end in `.mjs`, NOT a bare `mktemp` name. The bundle is ESM; run `node <extension-less
  # file>` on the Node the installer provisions (NodeSource 20 on Ubuntu) and it throws ERR_UNKNOWN_FILE_EXTENSION
  # — the smoke "fails", and we silently fall back to source, rejecting a perfectly good bundle over a filename.
  # (A dev box on Node 23 masks it by auto-detecting module syntax; REPRODUCED + fixed in a node-20 container:
  #  extension-less → ERR_UNKNOWN_FILE_EXTENSION/exit 1, `.mjs` → exit 0.) The final runtime always uses the .mjs
  # path below, so operators were never at risk — only this pre-trust smoke was.
  tmpd="$(mktemp -d)"; tmpb="$tmpd/edge-node.mjs"
  # curl handles both https:// (the GitHub release asset) and file:// (the harness's locally-built bundle).
  if curl -fsSL "$EDGE_BUNDLE_URL" -o "$tmpb" 2>/dev/null && [ -s "$tmpb" ] && node "$tmpb" --help >/dev/null 2>&1; then
    mv "$tmpb" "$BUNDLE_OUT"; chmod +x "$BUNDLE_OUT"
    INSTALL_MODE="bundle"; EDGE_ENTRY="$BUNDLE_OUT"
    ok "prebuilt bundle verified + installed → $BUNDLE_OUT (no clone, no pnpm, no compile)"
  else
    warn "no runnable bundle at $EDGE_BUNDLE_URL — falling back to build-from-source (this is the slow path)."
  fi
  rm -rf "$tmpd"   # `rm -rf` exits 0 whether or not the temp dir survived — no `|| true` (the money path is never silenced)
fi

# ── 4. Collect the engine ──────────────────────────────────────────────────────
# macOS: we deliberately do NOT `brew install ollama` — on older macOS/Intel there may be no prebuilt bottle,
#   so brew compiles it (and cmake) from source, which can take 20+ minutes. ollama_fetch takes the prebuilt zip.
# Linux: we take the official release tarball and manage the service ourselves.
#
# The download was started before the Node.js step and has been streaming ever since. Everything below runs in
# the PARENT for one non-negotiable reason: the job was a subshell, so its `export PATH` would have evaporated.
if [ "$OLLAMA_JOB" = "1" ]; then
  step "Ollama engine (collecting the background download)"
  par_wait ollama-engine || die "the Ollama engine download failed — its complete log is above. A node with no
     inference engine still registers, advertises models, accepts paid jobs and fails every one, so we stop here."
  # THE POSTCONDITION. par_wait returning 0 says only "the commands exited 0". This says we actually HAVE an
  # engine — the exact class of assertion whose absence let this project print "Done." over a broken node.
  [ -x "$EDGE_HOME/bin/ollama" ] || die "the Ollama download exited 0 but produced no $EDGE_HOME/bin/ollama"
  export PATH="$EDGE_HOME/bin:$PATH"   # ← must be here, in the parent: the job could not do this for us.
  have ollama || die "ollama is still not on PATH after a 'successful' install — refusing to continue"
  # Deliberately no `ollama --version` here: the server is not up yet, so Ollama leads with "could not connect
  # to a running Ollama server" — which `head -1` would splice into a green success line. A success message that
  # reads like an error teaches operators to ignore errors. The three assertions above are the real proof.
  ok "ollama installed → $EDGE_HOME/bin/ollama"
  warn "add to your shell profile:  export PATH=\"$EDGE_HOME/bin:\$PATH\""
fi

# make sure the server is up (idempotent)
#
# Round-25: remember WHETHER WE STARTED IT. We need this at the end: `install-service --enable` installs an
# `edge-ollama` unit whose ExecStart also binds :11434. If our throwaway background `ollama serve` is still
# holding the port, systemd's copy cannot bind, the unit crash-loops (Restart=always), `systemctl enable --now`
# reports failure, and the installer falls back to a foreground node — i.e. **boot persistence would silently
# fail to install on every single fresh install**, which is exactly the class of bug this round exists to kill.
# If Ollama was ALREADY running when we arrived, it is someone else's (the operator's own service): we did not
# start it, so we do not stop it, and we do not fight it for the port.
OLLAMA_STARTED_BY_US=0
if ! curl -s --max-time 2 http://127.0.0.1:11434/api/tags >/dev/null 2>&1; then
  warn "Starting ollama server in the background…"
  ( ollama serve >"$EDGE_HOME/ollama.log" 2>&1 & ) 2>/dev/null || nohup ollama serve >"$EDGE_HOME/ollama.log" 2>&1 &
  OLLAMA_STARTED_BY_US=1
  for _ in $(seq 1 30); do curl -s --max-time 1 http://127.0.0.1:11434/api/tags >/dev/null 2>&1 && break; sleep 1; done
fi
if curl -s --max-time 2 http://127.0.0.1:11434/api/tags >/dev/null 2>&1; then ok "ollama API up on :11434"; else die "ollama API did not come up — see $EDGE_HOME/ollama.log"; fi

# ── VERIFY THE EXACT TAG, not the family. ─────────────────────────────────────
# This was `grep -q "${EDGE_MODEL%%:*}"` — it stripped everything after the colon and matched the model FAMILY.
# So with `EDGE_MODEL=llama3.2:3b` it searched Ollama's tag list for "llama3.2", found the `llama3.2:1b` you
# already had, and declared "model ready: llama3.2:3b". The node then advertises a tag it cannot serve, and
# every buyer who pays for it gets an error. The node-client matches on the FULL tag (`ollama.ts tags()`), so
# the check must too. `ollama list` is the same source of truth the node reads.
model_installed() { # exact-tag membership, with Ollama's bare-name → :latest normalisation
  local want="$1"
  case "$want" in *:*) ;; *) want="$want:latest" ;; esac
  ollama list 2>/dev/null | awk 'NR>1{print $1}' | grep -qxF "$want"
}
# ── 4a. The models — both pulls at once, both asserted separately ─────────────
# The main model (~1.3 GB) and the content guard (~1.6 GB) are independent downloads into the same Ollama.
# MEASURED: 88s serial → 72s concurrent. Modest, because they are splitting one ~30 MB/s pipe rather than
# finding new bandwidth — but it is free now that the job runner exists, and 16s is 16s.
#
# They are pulled TOGETHER and verified SEPARATELY, because their failure policies are deliberately opposite
# (R31, and this asymmetry is load-bearing — do not "simplify" the two branches into one):
#   · no main model            → FATAL. The node would advertise a model it cannot serve and fail paid jobs.
#   · no content-guard model   → a LOUD WARNING, never fatal. install.sh only enables the guard when the model
#                                verifiably landed; an explicit enable with a missing model makes the node
#                                REFUSE TO BOOT, so a failed pull here must degrade, not brick the install.
_models="$EDGE_MODEL"
GUARD_JOB=0
if [ "$EDGE_CONTENT_GUARD" != "off" ]; then _models="$_models + $EDGE_GUARD_MODEL (safety screen)"; fi
step "Pulling models: $_models"
par_start model-main ollama pull "$EDGE_MODEL"
if [ "$EDGE_CONTENT_GUARD" != "off" ]; then
  par_start model-guard ollama pull "$EDGE_GUARD_MODEL"
  GUARD_JOB=1
else
  warn "EDGE_CONTENT_GUARD=off — buyers' prompts will run UNSCREENED on your machine. You accept that liability."
fi

par_wait model-main || die "could not download the model '$EDGE_MODEL' (its complete log is above)."
model_installed "$EDGE_MODEL" \
  || die "model '$EDGE_MODEL' is NOT in Ollama after the pull — the node would advertise a model it cannot serve.
     Ollama currently has: $(ollama list 2>/dev/null | awk 'NR>1{print $1}' | paste -sd' ' -)"
ok "model ready: $EDGE_MODEL (exact tag verified in Ollama)"

# The content guard (Round 31 — OPERATOR LIABILITY). A buyer's prompt runs on THIS machine, at THIS IP. Llama
# Guard 3 1B screens both the prompt and the answer; anything it flags is refused (the buyer is not charged,
# and it never leaves the box). Enabled in the config below ONLY if the model verifiably landed.
if [ "$GUARD_JOB" = "1" ]; then
  if par_wait model-guard && model_installed "$EDGE_GUARD_MODEL"; then
    GUARD_PULLED=1
    ok "content guard model ready: $EDGE_GUARD_MODEL (exact tag verified)"
  else
    warn "could NOT pull $EDGE_GUARD_MODEL — your node will run WITHOUT the content guard until you fix it."
    warn "  activate later:  ollama pull $EDGE_GUARD_MODEL   (the node turns the guard ON once the model is present)"
  fi
fi

# ── 4b. Tunnel (cloudflared) — bundle it so a home node is reachable by remote buyers ──────────────
# Defect-3: a node behind a home router has no inbound port. cloudflared dials OUT and publishes a
# public https URL that forwards ONLY to the node's local Express port (never Ollama's 11434). Best-effort:
# the tunnel is opt-in at runtime (NODE_TUNNEL), so a failed install here never blocks a local node.
step "Tunnel runtime (cloudflared)"
if have cloudflared; then ok "cloudflared present ($(cloudflared --version 2>&1 | head -1))"
elif [ -x "$EDGE_HOME/bin/cloudflared" ]; then export PATH="$EDGE_HOME/bin:$PATH"; ok "cloudflared present at $EDGE_HOME/bin/cloudflared"
elif [ "$OS" = "Darwin" ]; then
  if brew install cloudflared >/dev/null 2>&1; then ok "cloudflared installed (brew)"
  else warn "cloudflared not installed (tunnel optional). Install later: brew install cloudflared"; fi
else
  # Round-20: Cloudflare publishes a static binary per arch — no repo, no root needed. Best-effort: the
  # tunnel is opt-in at runtime (NODE_TUNNEL), so failing here must never block a working local node.
  case "$ARCH" in
    x86_64|amd64)  CF_ARCH=amd64 ;;
    arm64|aarch64) CF_ARCH=arm64 ;;
    *)             CF_ARCH="" ;;
  esac
  # Round-23: $EDGE_HOME/bin is only created inside the Ollama-INSTALL branches. If Ollama was already on the
  # PATH (anyone who ran Ollama's own installer, or any GPU box) that directory does not exist here, `curl -o`
  # fails, and cloudflared is silently skipped — while step 5 still writes NODE_TUNNEL=cloudflare. The node
  # then boots, registers, and is unreachable by buyers: it earns nothing, which is the entire product.
  mkdir -p "$EDGE_HOME/bin"
  if [ -n "$CF_ARCH" ] && curl -fsSL "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${CF_ARCH}" \
       -o "$EDGE_HOME/bin/cloudflared" 2>/dev/null; then
    chmod +x "$EDGE_HOME/bin/cloudflared"
    export PATH="$EDGE_HOME/bin:$PATH"
    ok "cloudflared installed → $EDGE_HOME/bin/cloudflared"
  else
    rm -f "$EDGE_HOME/bin/cloudflared"   # never leave a truncated/0-byte binary on the PATH
    warn "cloudflared not installed (tunnel optional). Set NODE_PUBLIC_URL instead, or install it later."
  fi
fi

# ── 5. Get the code — ONLY when the prebuilt bundle wasn't used ───────────────
# The whole clone + pnpm install + tsc dance below is the ~35s the bundle path skips. If we already have a
# verified bundle, EDGE_ENTRY is set and we jump straight past it.
if [ "$INSTALL_MODE" = "bundle" ]; then
  ok "using prebuilt bundle → $EDGE_ENTRY (skipping clone + dependency install + build)"
else
  INSTALL_MODE="source"
  step "Fetching node software (source)"
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd || echo "")"
  if [ -n "$SCRIPT_DIR" ] && [ -d "$SCRIPT_DIR/packages/node-client" ]; then
    APP_DIR="$SCRIPT_DIR"; ok "using local checkout: $APP_DIR"
  else
    mkdir -p "$EDGE_HOME"
    # ── Round-33: a SOURCE TARBALL, not a git clone. ──
    # The repository is private, so `git clone` cannot work for an operator — it would prompt for credentials
    # they do not have and hang, or fail with an auth error that looks like our bug. What ships instead is a
    # tarball built by scripts/publish-release.sh from an ALLOWLIST: the two packages a node actually needs
    # (@edge/shared + node-client) and nothing else. No coordinator, no docs, no git history.
    #
    # `EDGE_REPO_URL` still wins if you set it explicitly — that keeps `git clone` available for developers and
    # for the harness's local bare-repo test, without making a private clone the default an operator hits.
    if [ -n "${EDGE_REPO_URL:-}" ]; then
      if [ -d "$EDGE_HOME/app/.git" ]; then
        must "git pull" git -C "$EDGE_HOME/app" pull --ff-only
        ok "updated existing checkout"
      else
        must "git clone $EDGE_REPO_URL" git clone --depth 1 "$EDGE_REPO_URL" "$EDGE_HOME/app"
        ok "cloned $EDGE_REPO_URL"
      fi
    else
      # Fetch + unpack in one stream. `set -o pipefail` (top of this file) is load-bearing: without it the
      # pipeline's status is tar's, so a 404 HTML page piped into tar would "succeed" and leave a broken tree.
      rm -rf "$EDGE_HOME/app"; mkdir -p "$EDGE_HOME/app"
      curl -fsSL "$EDGE_SRC_URL" | tar -xz -C "$EDGE_HOME/app" --strip-components=1 \
        || die "could not fetch the node source from $EDGE_SRC_URL
     The prebuilt bundle was unavailable and this is the fallback, so there is nothing left to try.
     · check for an outage, then re-run the one-liner
     · or build from a checkout you already have:  bash install.sh   (from inside the repo)"
      ok "fetched + unpacked $EDGE_SRC_URL"
    fi
    APP_DIR="$EDGE_HOME/app"
  fi
  # The fetch "succeeding" is not evidence that we have the code. A wrong EDGE_REPO_URL clones a repo that is
  # simply not this one, and every step after here would fail somewhere far less obvious.
  expect_dir "the fetched tree is not an edge-compute checkout" "$APP_DIR/packages/node-client"

  step "Installing dependencies + building (the slowest step — a few minutes)"
  ensure_pnpm   # deferred from step 2 — needed ONLY on this build-from-source path
  cd "$APP_DIR"
  if pnpm install --frozen-lockfile; then
    ok "deps installed (locked)"
  else
    warn "frozen-lockfile install failed (lockfile out of sync with package.json?) — retrying with a resolving install"
    must "pnpm install" pnpm install
    ok "deps installed"
  fi
  must "pnpm -r build" pnpm -r build
  # THE POSTCONDITION. `pnpm -r build` exiting 0 is not evidence that it emitted anything, and this exact file is
  # the one `bin/edge-node.js` refuses to run without ("edge-node: build missing — expected …/dist/cli.js").
  # Asserting it here turns that late, cryptic, post-install death into an immediate, named install failure.
  CLI_JS="$APP_DIR/packages/node-client/dist/cli.js"
  expect_file "the build produced no CLI" "$CLI_JS"
  EDGE_ENTRY="$APP_DIR/packages/node-client/bin/edge-node.js"
  ok "built (verified $CLI_JS)"
fi

# ── Defect-1 fix: put `edge-node` on PATH. The installer used to tell users to run `edge-node status`,
# but nothing ever created that command. Write a tiny wrapper into $EDGE_HOME/bin and ensure that dir is
# on PATH via the user's shell profile (idempotent). Now `edge-node <cmd>` works in any new shell.
step "Installing the 'edge-node' command"
mkdir -p "$EDGE_HOME/bin"
# EDGE_ENTRY is the single-file bundle ($EDGE_HOME/edge-node.mjs) on the fast path, or the source shim
# ($APP_DIR/.../bin/edge-node.js) on the build path. Either way the wrapper is `node <entry>` — and this is the
# exact argv[1] `install-service` bakes into the systemd/launchd ExecStart, so a bundle-installed node's boot
# unit runs the self-contained .mjs with no checkout dependency.
[ -n "$EDGE_ENTRY" ] || die "internal: no CLI entrypoint resolved (neither bundle nor source build produced one)"
cat >"$EDGE_HOME/bin/edge-node" <<WRAP
#!/usr/bin/env bash
# edge-node wrapper (generated by install.sh) — runs the CLI ($INSTALL_MODE build) with the right EDGE_HOME.
export EDGE_HOME="\${EDGE_HOME:-$EDGE_HOME}"
exec node "$EDGE_ENTRY" "\$@"
WRAP
chmod +x "$EDGE_HOME/bin/edge-node"
ok "edge-node → $EDGE_HOME/bin/edge-node"
export PATH="$EDGE_HOME/bin:$PATH"
# Persist PATH in the user's shell rc (zsh is the macOS default; also handle bash). Idempotent guard line.
PATH_LINE="export PATH=\"$EDGE_HOME/bin:\$PATH\"  # edge-compute"
for rc in "$HOME/.zshrc" "$HOME/.bash_profile" "$HOME/.bashrc"; do
  if [ ! -e "$rc" ]; then
    # Only create the zsh rc (macOS default shell); don't manufacture bash files that don't exist.
    if [ "$rc" = "$HOME/.zshrc" ]; then touch "$rc"; else continue; fi
  fi
  grep -qF "# edge-compute" "$rc" 2>/dev/null || printf '\n%s\n' "$PATH_LINE" >>"$rc"
done
ok "added $EDGE_HOME/bin to PATH (open a NEW terminal, or: export PATH=\"$EDGE_HOME/bin:\$PATH\")"

# ── 6. Config + payout wallet ──────────────────────────────────────────────────
step "Configuration"
mkdir -p "$EDGE_HOME"
ENV_FILE="$EDGE_HOME/.env"
SEEDED=0
if [ ! -f "$ENV_FILE" ]; then
  # Seed the config template. The bundle path has NO checkout to copy `.env.example` from, so the template
  # travels INSIDE the CLI (embedded-assets.ts) and `edge-node config:template` prints it — byte-identical to
  # the file on the source path (guarded by embedded-assets.test.ts). Either way we ASSERT it produced a
  # non-empty file: a node configured from nothing at all was a real past failure.
  if [ "$INSTALL_MODE" = "bundle" ]; then
    edge-node config:template > "$ENV_FILE" || die "could not seed $ENV_FILE from the bundle's embedded config template"
    expect_file "config:template produced no config" "$ENV_FILE"
    ok "seeded $ENV_FILE from the embedded config template"
  else
    # `cp … 2>/dev/null || :` meant that if .env.example was missing, the operator got a node configured from
    # nothing at all — silently. Assert the source exists, and let the copy fail loudly if it fails.
    expect_file "this checkout has no .env.example to seed your config from" "$APP_DIR/.env.example"
    must "seeding $ENV_FILE" cp "$APP_DIR/.env.example" "$ENV_FILE"
    ok "seeded $ENV_FILE from .env.example"
  fi
  chmod 600 "$ENV_FILE"   # it will hold NODE_PAYTO and may hold COORD_AUTH_TOKEN
  SEEDED=1                # ← this file's values are TEMPLATE PLACEHOLDERS, not the operator's choices.
else
  ok "keeping your existing $ENV_FILE (never overwritten)"
fi

# payout address: use env, else prompt (interactive), else guide the user
PAYTO="${NODE_PAYTO:-}"
if [ -z "$PAYTO" ] && [ -t 0 ]; then
  printf "  Enter your Base wallet address to receive USDC payouts (or leave blank to auto-generate a receive-only wallet):\n  > "
  read -r PAYTO || true
fi
if [ -z "$PAYTO" ]; then
  # `edge-node wallet:init` generates a local receive-only key and prints the address (Story 1.4). It writes
  # $EDGE_HOME/wallet.json and refuses to overwrite an existing one, so this is safe on a re-run.
  # We invoke it through the WRAPPER we just installed — the same entrypoint the operator will use — so a
  # broken wrapper or a missing build is caught here, by us, rather than by them tomorrow.
  wout="$(mktemp)"
  if edge-node wallet:init >"$wout" 2>&1; then
    PAYTO="$(grep -Eo '0x[0-9a-fA-F]{40}' "$wout" | head -1)"
    ok "payout wallet: $PAYTO  (key in $EDGE_HOME/wallet.json — BACK IT UP)"
  else
    printf '%s\n' "$(cat "$wout")" >&2
  fi
  rm -f "$wout"
fi

# A node with no payout address serves inference for FREE. That is not a degraded install, it is a node that
# donates its electricity — the precise silent-zero this project keeps having to hunt down, except here we
# would have CHOSEN it. So: refuse. An operator who genuinely wants a free node must say so out loud.
if ! printf '%s' "${PAYTO:-}" | grep -qE '^0x[0-9a-fA-F]{40}$'; then
  if [ "${EDGE_ALLOW_FREE:-0}" = "1" ]; then
    warn "no payout address — this node will serve inference FOR FREE (EDGE_ALLOW_FREE=1)"
  else
    die "No valid payout address, so this node would earn nothing.
     Fix it one of these ways, then re-run:
       • pass one:      NODE_PAYTO=0xYourBaseWallet bash install.sh
       • generate one:  edge-node wallet:init
       • genuinely want a free node:  EDGE_ALLOW_FREE=1 bash install.sh"
  fi
fi
# write NODE_PAYTO into env file (portable in-place edit)
if [ -n "$PAYTO" ]; then
  if grep -q '^NODE_PAYTO=' "$ENV_FILE" 2>/dev/null; then
    tmp="$(mktemp)"; sed "s|^NODE_PAYTO=.*|NODE_PAYTO=$PAYTO|" "$ENV_FILE" >"$tmp" && mv "$tmp" "$ENV_FILE"
  else printf "NODE_PAYTO=%s\n" "$PAYTO" >>"$ENV_FILE"; fi
fi
upsert_env() { # key value → replace-or-append in $ENV_FILE
  if grep -q "^$1=" "$ENV_FILE" 2>/dev/null; then
    tmp="$(mktemp)"; sed "s|^$1=.*|$1=$2|" "$ENV_FILE" >"$tmp" && mv "$tmp" "$ENV_FILE"
  else printf "%s=%s\n" "$1" "$2" >>"$ENV_FILE"; fi
}

# ── Round-25: WRITE THE SETTINGS THE OPERATOR ACTUALLY CHOSE. ──────────────────
# These two lines used to be:
#
#     grep -q '^COORDINATOR_URL=' "$ENV_FILE" || printf "COORDINATOR_URL=%s\n" "$COORDINATOR_URL" >>"$ENV_FILE"
#     grep -q '^DEFAULT_MODEL='   "$ENV_FILE" || printf "DEFAULT_MODEL=%s\n"   "$EDGE_MODEL"      >>"$ENV_FILE"
#
# — "only write it if it isn't there." The intent was to avoid clobbering an operator's hand-edited config. But
# the file we are writing into was seeded from `.env.example` SECONDS EARLIER, and `.env.example` **already
# defines both keys** (`COORDINATOR_URL=http://127.0.0.1:8787`, `DEFAULT_MODEL=llama3.2:1b`). So the key is
# never absent, the `||` never fires, and the operator's choice is silently thrown away. Two silent zeros:
#
#   · `EDGE_MODEL=qwen2.5:0.5b bash install.sh` → downloads qwen, then configures the node for llama3.2:1b.
#     The node advertises a model it does not have and serves nothing. (Caught by the new doctor `model` check.)
#   · Worse, and for EVERY DEFAULT INSTALL: the installer's coordinator default is the PRODUCTION
#     `https://coordinator.chainlore.dev`, but the seeded .env says `http://127.0.0.1:8787`. So a fresh node
#     points at a coordinator on its own localhost that isn't there, never registers, and earns zero — forever.
#
# The distinction the old code was missing: a value that came from the TEMPLATE is a placeholder and must be
# overwritten; a value in an operator's PRE-EXISTING .env is a decision and must be preserved — unless they
# explicitly passed a new one on this run, in which case they meant it.
config_set() { # key value was_given_by_operator
  if [ "$SEEDED" = "1" ] || [ -n "$3" ]; then upsert_env "$1" "$2"; fi
}
config_set COORDINATOR_URL "$COORDINATOR_URL" "$COORDINATOR_URL_GIVEN"
config_set DEFAULT_MODEL   "$EDGE_MODEL"      "$EDGE_MODEL_GIVEN"
# Round 31 — turn the content guard ON explicitly, but ONLY when its model verifiably landed (GUARD_PULLED=1),
# so an installed node never refuses to boot over an enable it cannot honour. `off` ⇒ explicit opt-out. A FAILED
# pull leaves NODE_CONTENT_GUARD unset on purpose: the node code's exposure-default still lights the guard up —
# and warns — the moment the operator pulls the model, without bricking the install now.
if [ "$EDGE_CONTENT_GUARD" = "off" ]; then
  config_set NODE_CONTENT_GUARD false ""
elif [ "$GUARD_PULLED" = "1" ]; then
  config_set NODE_CONTENT_GUARD true ""
fi

# ── CLOSE THE LOOP: pulled == configured == servable. ─────────────────────────
# Three separate facts that everyone assumes agree, and which nothing checked:
#   (a) the model we DOWNLOADED          ($EDGE_MODEL)
#   (b) the model the node will ADVERTISE (DEFAULT_MODEL, as it ends up in the .env)
#   (c) the models Ollama can actually SERVE (its tag list)
# When (a) ≠ (b) the node advertises a model it does not have. It is never routed a job — or worse, it IS, and
# fails a call the buyer already paid for. Either way it earns nothing and NOTHING is red: there is no error,
# because there is no request. This is the single most invisible failure in the whole product, so we read the
# value back OUT of the file we just wrote — never from the variable we think we wrote — and check it serves.
CONFIGURED_MODEL="$(sed -n 's/^DEFAULT_MODEL=\([^#]*\).*/\1/p' "$ENV_FILE" | head -1 | tr -d ' \t')"
[ -n "$CONFIGURED_MODEL" ] || die "no DEFAULT_MODEL in $ENV_FILE — the node would advertise nothing and earn nothing."
if [ "$CONFIGURED_MODEL" != "$EDGE_MODEL" ]; then
  # An operator re-running with a hand-edited .env can legitimately land here. Do not silently 'fix' their
  # file — but do NOT let them leave with a node configured for a model this box cannot serve, either.
  warn "your .env serves DEFAULT_MODEL=$CONFIGURED_MODEL, but this run downloaded $EDGE_MODEL"
  model_installed "$CONFIGURED_MODEL" \
    || die "…and $CONFIGURED_MODEL is NOT installed in Ollama, so your node would advertise a model it cannot serve
     and earn ZERO with no error anywhere. Either:
       • pull it:            ollama pull $CONFIGURED_MODEL
       • or serve what you downloaded:  EDGE_MODEL=$EDGE_MODEL bash install.sh   (this rewrites DEFAULT_MODEL)"
fi
model_installed "$CONFIGURED_MODEL" || die "DEFAULT_MODEL=$CONFIGURED_MODEL is not installed in Ollama."
ok "model verified end-to-end: downloaded = configured = servable ($CONFIGURED_MODEL)"

# ── Defect-0 fix: turn payments ON by default so a completed install actually EARNS. ──
# Previously X402_ENABLED was never written, so the node served FREE inference. We enable it only when a
# valid payout address exists (otherwise the x402 gate fails fast at start). Idempotent upsert.
if printf '%s' "${PAYTO:-}" | grep -qE '^0x[0-9a-fA-F]{40}$'; then
  upsert_env X402_ENABLED true
  ok "payments ENABLED (X402_ENABLED=true) — you earn ${EDGE_PRICE:-\$0.001} USDC per call → $PAYTO"
else
  upsert_env X402_ENABLED false
  warn "payments left OFF (no valid payout address). After setting one: edge-node payments on"
fi

# Join the coordinator with per-node ed25519 identity auth — the production coordinator REQUIRES it (nodes
# sign a challenge to register; no shared secret needed). This setting is NOT harmless everywhere: it must
# MATCH the coordinator you point at. Against a LOCAL dev coordinator running with identity auth OFF, a node
# set to true never registers (it waits for a challenge that is never sent) — override with
# COORD_IDENTITY_AUTH=false. Since round-13 either mismatch fails LOUDLY: the node prints
# "[node] NOT registered: …" naming this exact variable, instead of hanging quietly or claiming success.
upsert_env COORD_IDENTITY_AUTH true

# ── Defect-3 fix: reachability defaults. Stable port; auto-tunnel when joining a REMOTE coordinator. ──
grep -q '^NODE_PORT=' "$ENV_FILE" 2>/dev/null || printf "NODE_PORT=8801\n" >>"$ENV_FILE"
# EDGE_TUNNEL=auto (default): enable the zero-config tunnel only when the coordinator is NOT local — a
# local dev coordinator needs no tunnel, but a real remote coordinator means remote buyers must reach us.
EDGE_TUNNEL="${EDGE_TUNNEL:-auto}"
case "$EDGE_TUNNEL" in
  on|cloudflare) want_tunnel=1 ;;
  off) want_tunnel=0 ;;
  auto) case "$COORDINATOR_URL" in *127.0.0.1*|*localhost*) want_tunnel=0 ;; *) want_tunnel=1 ;; esac ;;
  *) want_tunnel=0 ;;
esac
if [ "$want_tunnel" = "1" ]; then upsert_env NODE_TUNNEL cloudflare; else upsert_env NODE_TUNNEL off; fi
ok "config written: $ENV_FILE"

# ── 6b. MINT THE PAYOUT-OWNERSHIP PROOF ────────────────────────────────────────
# Round-25. Round-17 built the guard (COORD_REQUIRE_PAYOUT_PROOF: a coordinator credits a rail only if the node
# proved it controls the wallet it is paid at). Round-23 fixed the guard to sign for the RIGHT address. Round-25
# fixed it to READ THE RIGHT KEY FILE (wallet.json, not the dev-only operator.key).
#
# And after all three rounds, no installed node had a proof — because NOTHING EVER CALLED IT. `payout:prove` was
# a command an operator had to know existed and choose to run. The moment the coordinator turns the flag on,
# every one of those nodes silently earns zero. "A guard that is never invoked is not a guard" — we wrote that
# rule in round 23 about the guard's internals, and then left the guard uninvoked at the only place that
# matters: the install. This is that fix.
step "Proving you own your payout wallet"
if [ "${EDGE_ALLOW_FREE:-0}" = "1" ] && ! printf '%s' "${PAYTO:-}" | grep -qE '^0x[0-9a-fA-F]{40}$'; then
  warn "skipped (free node, no payout address)"
elif edge-node payout:prove; then
  expect_file "payout:prove reported success but wrote no proof" "$EDGE_HOME/payout-proofs.json"
  ok "payout ownership proved → $EDGE_HOME/payout-proofs.json"
else
  # Not fatal: a coordinator with the flag OFF still pays this node. But it is the difference between earning
  # and earning nothing the day it is turned on, so it must never scroll by quietly.
  warn "could not prove payout-wallet ownership. Your node still runs, but a coordinator running with"
  warn "COORD_REQUIRE_PAYOUT_PROOF=true will NOT credit this rail. Diagnose with: edge-node doctor"
fi

# ── 7. VERIFY — the installer does not get to claim a success it cannot prove ──────────────────────
# This is the step whose absence made every previous bug possible. install.sh used to run its commands and
# then simply ASSERT, in green text, that it had worked. It printed "Done." over a node that could not boot
# (NODE_PORT=NaN), over a node with no build at all, and over a node that could never prove it owned its
# wallet. The output was identical in all three cases.
#
# `edge-node doctor` is the project's adversarial audit of the money path: it tries to prove each link is
# BROKEN and reports what it could not prove works. Running it here — through the wrapper, against the real
# $EDGE_HOME we just wrote — is the difference between an installer that reports what it DID and one that
# reports what is TRUE.
step "Verifying the install"
DOCTOR_RC=0
edge-node doctor || DOCTOR_RC=$?
if [ "$DOCTOR_RC" -ne 0 ]; then
  warn "doctor reported FAILING checks (above) — this node may run but earn nothing."
  warn "Nothing is hidden: fix the ✗ lines, then re-run 'edge-node doctor'."
  # EDGE_STRICT=1 → treat any FAIL as an install failure. This is what CI and the container proof use, so a
  # regression in ANY money-path link breaks the build instead of shipping quietly.
  [ "${EDGE_STRICT:-0}" = "1" ] && die "EDGE_STRICT=1 and doctor found failing check(s) — install NOT complete."
fi

# ── 8. Start the node — AND KEEP IT RUNNING ────────────────────────────────────
# This step used to be `exec edge-node start`: a FOREGROUND process. Think about who actually runs this. It is
# a stranger pasting a one-liner into an SSH session on a spare machine. They watch it come up, they close the
# laptop — and the session ends, the node dies, and Ollama (a bare background job) dies with it. They come back
# to zero earnings and a machine that looks perfectly healthy. Nothing anywhere says why.
#
# Boot persistence existed the whole time (`edge-node install-service`) — but it only PRINTED three commands
# for the operator to run themselves, one of them `loginctl enable-linger`, which nobody who hasn't
# administered systemd user services has heard of. A capability you must know to ask for is a capability most
# people don't have. That is a DEFAULT problem, not a docs problem.
# HAND THE PORT OVER, but be ready to take it back. Our background `ollama serve` (started earlier, and ONLY if
# nothing was already listening) is a throwaway that existed so we could pull the model. systemd's edge-ollama
# unit binds the same :11434, so the throwaway must be gone before we enable the service — otherwise that unit
# crash-loops (Restart=always), `systemctl enable --now` reports failure, and **boot persistence would silently
# fail to install on every fresh install**. We only ever kill the process WE started; an operator's own Ollama
# daemon is left strictly alone.
#
# And if the enable then FAILS, we must put it back: the fallback path runs a foreground node, and a node with
# no inference engine registers, advertises its models, accepts paid jobs and fails every one — the exact
# round-23 disaster. Handing over the port is only safe if we can hand it back.
stop_our_ollama() {
  [ "$OLLAMA_STARTED_BY_US" = "1" ] || return 0
  # Two attempts, because argv[0] of the process we launched is the bare word `ollama` (that is how it was
  # invoked), NOT the resolved path — so a `pkill -f <abs-path> serve` alone silently matches nothing.
  # If BOTH miss, say so rather than swallowing it: something else is holding the port and the service enable
  # below is about to fail for a reason we would otherwise have hidden from ourselves.
  pkill -f "$(command -v ollama) serve" 2>/dev/null \
    || pkill -x ollama 2>/dev/null \
    || warn "no background ollama found to stop — if :11434 is still held, the edge-ollama service cannot bind"
  for _ in $(seq 1 15); do curl -s --max-time 1 http://127.0.0.1:11434/api/tags >/dev/null 2>&1 || return 0; sleep 1; done
  warn ":11434 is STILL serving after we stopped our own ollama — the edge-ollama unit will fail to bind it"
}
restart_our_ollama() {
  curl -s --max-time 2 http://127.0.0.1:11434/api/tags >/dev/null 2>&1 && return 0   # something serves it already
  ( ollama serve >>"$EDGE_HOME/ollama.log" 2>&1 & ) 2>/dev/null || nohup ollama serve >>"$EDGE_HOME/ollama.log" 2>&1 &
  for _ in $(seq 1 30); do curl -s --max-time 1 http://127.0.0.1:11434/api/tags >/dev/null 2>&1 && return 0; sleep 1; done
  return 1
}

step "Making your node survive logout and reboot"
if [ "${EDGE_NO_START:-0}" = "1" ]; then
  warn "EDGE_NO_START=1 — not starting, and not installing services."
elif [ "${EDGE_SERVICE:-1}" = "0" ]; then
  warn "EDGE_SERVICE=0 — running in the foreground. Your node stops when this terminal closes."
  timings
  exec edge-node start
else
  stop_our_ollama
  if edge-node install-service --enable; then
    ok "your node is running as a service, and will restart itself on reboot"
  else
    # No systemd/launchd (a container, some WSL setups). Do NOT pretend. Restore the engine, run in the
    # foreground, and say plainly what the operator is getting.
    warn "No service manager available — falling back to a FOREGROUND node."
    restart_our_ollama || die "could not restart Ollama after the failed service install — a node with no
     inference engine would accept paid jobs and fail every one. Start it yourself ('ollama serve') and re-run."
    warn "This node will STOP when this terminal closes and will NOT return after a reboot."
    warn "Supervise it yourself (docker restart policy, supervisord, cron @reboot), or re-run: edge-node install-service --enable"
    ok "Starting node (Ctrl-C to stop)…"
    timings
    exec edge-node start
  fi
fi

timings

printf '%s\n' "" "${G}${B}Done.${N} Open a NEW terminal, then manage your node:"
printf '%s\n' \
  "  ${B}edge-node status${N}     — running? registered? earning? reachable?" \
  "  ${B}edge-node payments${N}   — on | off | status  (USDC per call)" \
  "  ${B}edge-node tunnel${N}     — on | off | status  (make reachable by remote buyers)" \
  "  ${B}edge-node earnings${N}   — on-chain USDC balance + per-rail ledger" \
  "  ${B}edge-node stop${N}       — stop the daemon"
