#!/bin/sh
# -----------------------------------------------------------------------------
# install.sh — the ONE Whisper installer (POSIX).   curl get.whisper.online | sh
#
#   curl -H "X-API-Key: whisper_live_xxx" https://get.whisper.online | sh   # zero prompts
#   curl https://get.whisper.online | sh -s -- whisper_live_xxx              # key as first arg
#   curl https://get.whisper.online | sh                                     # then: whisper
#
# This is the SAME installer that get.whisper.online serves — it is published here,
# in the public whisper-cli repo, so the entire install path is inspectable. By
# default it fetches the SIGNED binary straight from this repo's GitHub Releases:
#
#   https://github.com/whisper-sec/whisper-cli/releases/latest/download/whisper-<os>-<arch>
#
# Its job is SMALL and it does exactly three things, then hands off to the binary:
#   1. get the right `whisper` binary onto disk, sha256-VERIFIED (and PGP-checked
#      when gpg is present), atomically;
#   2. put it on PATH for real — future shells (rc files) AND this one (live PATH);
#   3. exec `whisper` (the guided flow owns login / agent / connect / verify).
#
# No tiers, no proxychains, no package manager, no python3, no DoH prose — `connect`
# is the binary's job. Requires only: curl (or wget) + sh + a sha256 tool.
#
# Output contract: ALL status to stderr, prefixed `whisper: `. Happy path = at most
# two lines, then it hands off:
#     whisper: installing…
#     whisper: installed ✓  (run: whisper)
#
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 viaGraph B.V. (Whisper Security)
# -----------------------------------------------------------------------------
set -eu

# --- output: every line to stderr, prefixed; the door speaks plain language ------
say()  { printf 'whisper: %s\n' "$*" >&2; }
# die: ONE friendly sentence, then exit 1. The only fatal on this path is "no key";
# everything else degrades. Never a stack trace, never a wall of text.
die()  { printf 'whisper: %s\n' "$*" >&2; exit 1; }
vsay() { [ "${VERBOSE:-0}" = "1" ] && printf 'whisper: %s\n' "$*" >&2 || true; }

# --- defaults (all overridable; zero-config by default) --------------------------
# CLI_BASE is where the binary + sha256 + asc live. DEFAULT = this repo's GitHub
# Releases ("latest"), so the install is public, signed and verifiable. The box can
# still serve as a fallback mirror by exporting WHISPER_CLI_BASE.
CLI_BASE="${WHISPER_CLI_BASE:-https://github.com/whisper-sec/whisper-cli/releases/latest/download}"
# The AS219419 release-signing key fingerprint + where its public key is published.
PGP_FPR="${WHISPER_PGP_FPR:-EFF1663D992539682106A5EAD0F70908CF3B7929}"
PGP_KEY_URL="${WHISPER_PGP_KEY_URL:-https://as219419.net/whisper-release.asc}"
# Fallback key mirror served by the Whisper gateway itself, used if the canonical as219419.net copy is unreachable.
PGP_KEY_FALLBACK="${WHISPER_PGP_KEY_FALLBACK:-https://cli.whisper.online/dl/whisper-pgp.asc}"
DEST_DIR="${WHISPER_DIR:-$HOME/.local/bin}"
NO_PATH=0
FORCE_SHELL=""
ARG_KEY=""

# --- flags (liberal: any order; unknown ⇒ warn+ignore; first bare token = key) ---
# Every read/shift is guarded — a trailing `--dir` with no value must NOT `shift 2`
# past the end (a fatal error under `set -eu` in dash). Degrade, never crash.
while [ $# -gt 0 ]; do
  case "$1" in
    --key)        if [ $# -ge 2 ]; then ARG_KEY="$2"; shift 2; else shift; fi ;;
    --key=*)      ARG_KEY="${1#--key=}"; shift ;;
    --dir)        if [ $# -ge 2 ]; then DEST_DIR="$2"; shift 2; else shift; fi ;;
    --dir=*)      DEST_DIR="${1#--dir=}"; shift ;;
    --no-path)    NO_PATH=1; shift ;;
    --shell)      if [ $# -ge 2 ]; then FORCE_SHELL="$2"; shift 2; else shift; fi ;;
    --shell=*)    FORCE_SHELL="${1#--shell=}"; shift ;;
    --verbose)    VERBOSE=1; shift ;;
    --shell-cli)  shift ;;   # accepted + ignored: clean-slate has no POSIX-shell CLI
    --)           shift ;;
    -*)           say "ignoring unknown flag: $1"; shift ;;
    *)            [ -n "$ARG_KEY" ] || ARG_KEY="$1"; shift ;;
  esac
done

DEST="$DEST_DIR/whisper"

# --- require curl or wget, and a sha256 tool ------------------------------------
DL=""
if command -v curl >/dev/null 2>&1; then DL=curl
elif command -v wget >/dev/null 2>&1; then DL=wget
else die "needs curl or wget to download — install one and re-run."; fi

# fetch URL OUTFILE → 0 on a 2xx download, 1 otherwise (never throws). Postel: HTTPS is
# PINNED for production (the default github.com base is https — a real one-liner is
# always https, so a downgrade can't be forced on a user). We relax the pin ONLY when
# the URL is EXPLICITLY http:// — the only http base is a local/CI test gateway, by hand.
# GitHub release downloads 302-redirect to objects.githubusercontent.com, so we follow
# redirects (-L / wget default) while keeping every hop https.
fetch() {
  case "$1" in
    http://*)   # local / CI test gateway only — no TLS to pin
      if [ "$DL" = curl ]; then curl -fsSL "$1" -o "$2"
      else                      wget -q -O "$2" "$1"; fi ;;
    *)          # https:// (production) and anything else — TLS-pinned
      if [ "$DL" = curl ]; then curl -fsSL --proto '=https' --tlsv1.2 "$1" -o "$2"
      else                      wget -q --https-only --secure-protocol=TLSv1_2 -O "$2" "$1"; fi ;;
  esac
}
# sha256_of FILE → hex digest on stdout, or empty if no tool is available.
sha256_of() {
  if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1
  elif command -v shasum   >/dev/null 2>&1; then shasum -a 256 "$1" | cut -d' ' -f1
  else printf ''; fi
}

# --- OS / arch detect (the SINGLE copy; mirrors platforms.txt) -------------------
detect_platform() {  # → sets OS + ARCH, or dies with a kind message
  _os="$(uname -s 2>/dev/null || echo unknown)"
  _arch="$(uname -m 2>/dev/null || echo unknown)"
  case "$_os" in
    Linux)  OS=linux ;;
    Darwin) OS=darwin ;;
    *)      die "no Whisper binary for your system ($_os) yet — we ship Linux and macOS. Tell us: hello@whisper.security" ;;
  esac
  case "$_arch" in
    x86_64|amd64)   ARCH=amd64 ;;
    aarch64|arm64)  ARCH=arm64 ;;
    *)              die "no Whisper binary for your CPU ($_arch) yet — we ship amd64 and arm64. Tell us: hello@whisper.security" ;;
  esac
}

# --- best-effort PGP verify of the .asc detached signature -----------------------
# sha256 is the HARD gate (a mismatch is fatal). The PGP check is an EXTRA layer that
# proves the bytes were signed by the AS219419 release key — but it is FAIL-SOFT: if
# gpg is absent, or the .asc isn't published yet, or the key can't be fetched, we warn
# and continue (the sha256 already proved integrity against the release manifest). When
# gpg IS present and the signature IS present, a BAD signature is fatal (refuse).
# Args: $1 = binary file, $2 = the binary's download URL (we derive "$url.asc").
pgp_verify() {  # → 0 = verified or skipped-cleanly; dies on a present-but-BAD signature
  command -v gpg >/dev/null 2>&1 || { vsay "gpg not found — skipping PGP verification (sha256 already verified)"; return 0; }
  _bin="$1"; _ascurl="$2.asc"
  _asc="$_bin.asc"
  if ! fetch "$_ascurl" "$_asc" 2>/dev/null; then
    vsay "no PGP signature published for this asset — skipping (sha256 already verified)"
    rm -f "$_asc" 2>/dev/null || true
    return 0
  fi
  # Import the release public key into an EPHEMERAL keyring so we never touch the
  # user's real ~/.gnupg. Fetch the key from as219419.net; skip-soft if unreachable.
  _gnupghome="$(mktemp -d 2>/dev/null || echo "${TMPDIR:-/tmp}/whisper-gpg.$$")"
  mkdir -p "$_gnupghome" 2>/dev/null || true
  chmod 700 "$_gnupghome" 2>/dev/null || true
  _keyfile="$_gnupghome/whisper-release.asc"
  if ! fetch "$PGP_KEY_URL" "$_keyfile" 2>/dev/null; then
    # Canonical as219419.net copy unreachable — fall back to the gateway-served mirror before skipping.
    if [ -n "$PGP_KEY_FALLBACK" ] && fetch "$PGP_KEY_FALLBACK" "$_keyfile" 2>/dev/null; then
      vsay "fetched the AS219419 release key from the fallback mirror ($PGP_KEY_FALLBACK)"
    else
      vsay "couldn't fetch the AS219419 release key from $PGP_KEY_URL or the fallback — skipping PGP (sha256 already verified)"
      rm -rf "$_gnupghome" "$_asc" 2>/dev/null || true
      return 0
    fi
  fi
  if ! GNUPGHOME="$_gnupghome" gpg --batch --quiet --import "$_keyfile" 2>/dev/null; then
    vsay "couldn't import the release key — skipping PGP (sha256 already verified)"
    rm -rf "$_gnupghome" "$_asc" 2>/dev/null || true
    return 0
  fi
  if GNUPGHOME="$_gnupghome" gpg --batch --status-fd 1 --verify "$_asc" "$_bin" 2>/dev/null \
       | grep -q "VALIDSIG.*$PGP_FPR"; then
    vsay "PGP signature verified (AS219419 key $PGP_FPR)"
    rm -rf "$_gnupghome" "$_asc" 2>/dev/null || true
    return 0
  fi
  # A signature WAS present but did not verify against the expected key → refuse.
  rm -rf "$_gnupghome" "$_asc" "$_bin" 2>/dev/null || true
  die "the download's PGP signature did not verify against the AS219419 release key — refusing to install."
}

# --- download + verify + atomic install -----------------------------------------
install_binary() {
  detect_platform
  url="$CLI_BASE/whisper-$OS-$ARCH"
  mkdir -p "$DEST_DIR" || die "couldn't create $DEST_DIR — pick another with --dir."
  tmp="$DEST.tmp.$$"
  vsay "downloading $url"
  fetch "$url" "$tmp" || { rm -f "$tmp"; die "couldn't download the whisper binary — check your internet and try again."; }
  # Verify BEFORE we trust it. No sha tool ⇒ refuse (conservative in what we run).
  if ! fetch "$url.sha256" "$tmp.sha256"; then
    rm -f "$tmp" "$tmp.sha256"; die "couldn't fetch the checksum to verify the download safely — try again."
  fi
  want="$(cut -d' ' -f1 "$tmp.sha256")"
  got="$(sha256_of "$tmp")"
  if [ -z "$got" ]; then
    rm -f "$tmp" "$tmp.sha256"; die "can't verify the download safely on this machine (no sha256 tool) — install coreutils and re-run."
  fi
  if [ "$want" != "$got" ]; then
    rm -f "$tmp" "$tmp.sha256"; die "the download didn't verify (checksum mismatch) — refusing to install. Try again."
  fi
  rm -f "$tmp.sha256"
  # Extra layer: best-effort PGP verify of the detached .asc (fail-soft; bad sig = fatal).
  pgp_verify "$tmp" "$url"
  chmod 0755 "$tmp" 2>/dev/null || true
  mv -f "$tmp" "$DEST" || { rm -f "$tmp"; die "couldn't write $DEST — pick another dir with --dir, or check permissions."; }
  # macOS Gatekeeper: clear the quarantine xattr + ad-hoc sign so first run isn't blocked.
  if [ "$OS" = darwin ]; then
    xattr -d com.apple.quarantine "$DEST" 2>/dev/null || true
    codesign -s - "$DEST" 2>/dev/null || true
  fi
}

# =================================================================================
# THE PATH FIX (§2.5) — never just print a tip.
#   (a) future shells: edit the RIGHT rc files idempotently (removable marker block);
#   (b) this shell now: mutate the live PATH + print the exact reactivation line;
#   (c) self-verify: a fresh login+interactive shell of each type must resolve whisper.
# =================================================================================
MARK_BEGIN='# >>> whisper >>>'
MARK_END='# <<< whisper <<<'

# block_for FILE → the marker block to write into FILE. fish gets fish syntax; every
# other (POSIX) file gets a guarded `export PATH=…` (only prepends if not already on
# PATH, so sourcing it twice is a no-op — no churn, idempotent at runtime too).
block_for() {
  case "$1" in
    *config.fish)
      printf '%s\n%s\n%s\n' "$MARK_BEGIN" "fish_add_path -p $DEST_DIR" "$MARK_END" ;;
    *)
      printf '%s\ncase ":$PATH:" in *":%s:"*) ;; *) export PATH="%s:$PATH" ;; esac\n%s\n' \
        "$MARK_BEGIN" "$DEST_DIR" "$DEST_DIR" "$MARK_END" ;;
  esac
}

# edit_rc FILE — write/replace the marker block in FILE, idempotently. If a block is
# already present it is REPLACED (never a second appended); otherwise it is appended.
# Creates the file (and its parent dir) if missing. Liberal: a read-only HOME just
# warns and degrades — the live-PATH + exec path still works (the door never fails).
edit_rc() {
  _f="$1"
  _dir="$(dirname "$_f")"
  mkdir -p "$_dir" 2>/dev/null || { vsay "can't create $_dir — skipping $_f"; return 0; }
  _new="$(block_for "$_f")"
  if [ -f "$_f" ] && grep -qF "$MARK_BEGIN" "$_f" 2>/dev/null; then
    # Replace the existing block (between the markers, inclusive) with the fresh one.
    _t="$_f.whisper.$$"
    if awk -v b="$MARK_BEGIN" -v e="$MARK_END" -v repl="$_new" '
      $0==b {inb=1; print repl; next}
      inb && $0==e {inb=0; next}
      !inb {print}
    ' "$_f" > "$_t" 2>/dev/null && mv -f "$_t" "$_f" 2>/dev/null; then
      EDITED="$EDITED $_f"
    else
      rm -f "$_t" 2>/dev/null || true; vsay "couldn't update $_f (read-only?) — skipping"
    fi
  else
    if { printf '\n%s\n' "$_new" >> "$_f"; } 2>/dev/null; then
      EDITED="$EDITED $_f"
    else
      vsay "couldn't write $_f (read-only?) — skipping"
    fi
  fi
}

# login_shell_name → the user's login shell basename (sh/bash/zsh/fish/dash/…). Honors
# --shell, then $SHELL, then the passwd entry (getent on Linux, dscl on macOS).
login_shell_name() {
  if [ -n "$FORCE_SHELL" ]; then printf '%s' "$FORCE_SHELL"; return; fi
  _s="${SHELL:-}"
  if [ -z "$_s" ] && command -v getent >/dev/null 2>&1; then
    _s="$(getent passwd "$(id -un 2>/dev/null)" 2>/dev/null | cut -d: -f7)"
  fi
  if [ -z "$_s" ] && command -v dscl >/dev/null 2>&1; then
    _s="$(dscl . -read "/Users/$(id -un 2>/dev/null)" UserShell 2>/dev/null | awk '{print $2}')"
  fi
  printf '%s' "$(basename "${_s:-sh}")"
}

# zsh_dir → honor $ZDOTDIR for zsh rc files (falls back to $HOME).
zsh_dir() { printf '%s' "${ZDOTDIR:-$HOME}"; }

# fix_path — edit the right rc set for the detected shell (and any shell whose config
# already exists), always including ~/.profile as a universal floor.
fix_path() {
  EDITED=""
  [ "$NO_PATH" = "1" ] && { say "skipping PATH edits (--no-path)."; return 0; }

  # We deliberately do NOT short-circuit when DEST_DIR is already on the CURRENT PATH.
  # Being on the installer's PATH never implies a brand-new terminal will inherit it.
  # The marker block is idempotent, so always writing it is correct (every future shell
  # of every type resolves) and harmless (a re-run just rewrites it).

  _sh="$(login_shell_name)"
  _zdir="$(zsh_dir)"

  # 1) the universal POSIX floor.
  edit_rc "$HOME/.profile"

  # 2) EVERY common interactive shell — UNCONDITIONALLY, creating the rc file if absent.
  # A user's NEXT shell is often NOT their login shell (a bash-login user opens zsh or
  # fish; a macOS-default-zsh user opens fish), and zsh/fish do NOT read ~/.profile.
  # Gating on the login shell, or on "config already exists", leaves those shells
  # stranded. So cover bash + zsh + fish for EVERYONE. The marker block makes every edit
  # idempotent, so over-covering a shell the user never opens is free and harmless.
  edit_rc "$HOME/.bashrc"
  edit_rc "$HOME/.bash_profile"
  edit_rc "$_zdir/.zshrc"
  edit_rc "$_zdir/.zprofile"
  edit_rc "$HOME/.config/fish/config.fish"
  return 0
}

# reactivation_line → the exact one-liner to make THIS terminal see whisper now.
reactivation_line() {
  case "$(login_shell_name)" in
    fish) printf 'fish_add_path -p %s' "$DEST_DIR" ;;
    *)    printf 'export PATH="%s:$PATH"' "$DEST_DIR" ;;
  esac
}

# _probe SHELLPATH FLAG CMD — spawn the shell ENV-SCRUBBED (DEST_DIR removed from PATH)
# as a fresh login+interactive shell and run CMD. Succeeds ONLY if the installer's rc
# edits put whisper back — never via an inherited live PATH. $_scrub is set by self_verify.
_probe() {
  if [ -n "${ZDOTDIR:-}" ]; then
    env -i HOME="$HOME" TERM="${TERM:-xterm}" ZDOTDIR="$ZDOTDIR" PATH="$_scrub" "$1" "$2" "$3" >/dev/null 2>&1
  else
    env -i HOME="$HOME" TERM="${TERM:-xterm}" PATH="$_scrub" "$1" "$2" "$3" >/dev/null 2>&1
  fi
}

# self_verify — spawn a FRESH login+interactive shell of each available type, with the
# environment SCRUBBED of DEST_DIR, and assert `whisper` resolves. Returns 0 only if every
# spawned shell finds it. (The env-scrub is essential: without it the probe inherits
# main's live PATH and false-passes.)
self_verify() {
  _ok=0   # 0 = success (shell convention); set to 1 the moment any spawned shell fails
  _sh="$(login_shell_name)"
  FAILED_SHELL=""
  # The PATH the probes inherit, with DEST_DIR removed — only an rc edit can re-add it.
  _scrub="$(printf '%s' ":$PATH:" | sed "s|:$DEST_DIR:|:|g; s|^:*||; s|:*\$||")"
  [ -n "$_scrub" ] || _scrub="/usr/local/bin:/usr/bin:/bin"
  _checked=""
  for cand in "$_sh" bash zsh fish sh; do
    case " $_checked " in *" $cand "*) continue ;; esac
    _checked="$_checked $cand"
    _shbin="$(command -v "$cand" 2>/dev/null)" || continue
    [ -n "$_shbin" ] || continue
    case "$cand" in
      fish)        if _probe "$_shbin" -lc 'type -q whisper';        then vsay "verified in fish";  else _ok=1; FAILED_SHELL="$FAILED_SHELL $cand"; fi ;;
      sh|dash|ash) if _probe "$_shbin" -lc 'command -v whisper';     then vsay "verified in $cand"; else _ok=1; FAILED_SHELL="$FAILED_SHELL $cand"; fi ;;
      *)           if _probe "$_shbin" -lic 'command -v whisper';    then vsay "verified in $cand"; else _ok=1; FAILED_SHELL="$FAILED_SHELL $cand"; fi ;;
    esac
  done
  return "$_ok"
}

# --- key handoff: write a valid key to the config so the binary picks it up -------
# Ladder: server-injected WHISPER_KEY > --key/first-token > $WHISPER_API_KEY > existing.
# The installer NEVER prompts for a key — the binary's guided flow does (device-flow too).
# Path: ~/.config/whisper/key is where the binary's own key ladder reads (the 2026-08
# brand rename; the binary migrates a legacy ~/.config/whisper-ns only when the new dir
# is absent, so writing the legacy path here would strand the key on a host that already
# has ~/.config/whisper - we write the real path the binary reads).
save_key() {
  _k="${WHISPER_KEY:-}"
  [ -n "$_k" ] || _k="$ARG_KEY"
  [ -n "$_k" ] || _k="${WHISPER_API_KEY:-}"
  if [ -n "$_k" ]; then
    ( umask 077; mkdir -p "$HOME/.config/whisper" && printf '%s' "$_k" > "$HOME/.config/whisper/key" ) \
      2>/dev/null || vsay "couldn't save the key file — the binary will ask."
    # Belt-and-braces (#562): mirror the key to the legacy pre-rename path too, so an
    # endpoint that ends up with an OLDER cached binary (whose ladder reads only
    # ~/.config/whisper-ns/key) still resolves it from disk with no env present -
    # e.g. the documented no-env `curl get.whisper.online/uninstall | sh` revoke.
    # uninstall.sh removes both copies; drop this once the renamed release is universal.
    ( umask 077; mkdir -p "$HOME/.config/whisper-ns" && printf '%s' "$_k" > "$HOME/.config/whisper-ns/key" ) \
      2>/dev/null || true
  fi
}

# =================================================================================
# FLEET AUTO-BIND (#562) - silent /128 identity for RMM / fleet deploys.
#
# When a fleet tool (NinjaOne, Intune, Jamf, plain SSH-for-loops) runs this installer
# with a key and no terminal, the endpoint should come up with its routable Whisper
# /128 identity bound - zero clicks, zero prompts. Opt-in ladder (liberal in what we
# accept, conservative in what we do):
#
#   WHISPER_AUTOBIND=1|yes|true|on   bind (register-then-identity ladder, any TTY state)
#   WHISPER_AUTOBIND=register        bind via op:register ONLY  (a per-endpoint agent)
#   WHISPER_AUTOBIND=identity        bind via op:identity ONLY  (this key's own /128)
#   WHISPER_AUTOBIND=0|no|off|false  never bind, not even inferred
#   (unset)                          INFERRED only when stdout is NOT a tty AND the
#                                    key was INJECTED on THIS run as WHISPER_KEY (the
#                                    RMM script-variable / X-API-Key header shape).
#                                    A persistent WHISPER_API_KEY export or a --key
#                                    argument NEVER infers a bind - a Dockerfile RUN
#                                    or CI job with the documented env convention
#                                    must not silently mint a routable /128 it never
#                                    asked for. Those callers opt in explicitly with
#                                    WHISPER_AUTOBIND=1. An interactive `curl | sh`
#                                    (tty stdout) is untouched either way.
#
# Semantics: the default ladder first mints a PER-ENDPOINT agent (op:register via
# `whisper create --register`, labelled with this host's name) so a whole site can
# share ONE RMM-injected site key and still get distinct per-endpoint /128s; if the
# key may not register (scope-limited per-device keys), it falls back to the key's own
# op:identity /128 (idempotent server-side: one key = one /128, reused). Idempotent
# across re-runs: a successful bind records a marker (~/.config/whisper/bound) and a
# re-run re-prints the same line without touching the control plane. Fail-soft: any
# bind failure warns and leaves the CLI installed - never a hard fail.
# =================================================================================

# json_field JSON KEY → the value of KEY (or empty). A deliberately tiny, dependency-
# free extractor (no jq on a fleet endpoint) that understands BOTH envelope shapes the
# control plane emits: object records (`"address":"2a04:…"`) and the columnar result
# (`"columns":["agent","address",…],"rows":[["…","2a04:…",…]]` - the shape op:register/
# op:identity return today). The values we pull (address / fqdn / ptr / agent) never
# contain commas, quotes or escapes, so plain comma-splitting the row is sound.
json_field() {
  printf '%s\n' "$1" | awk -v k="$2" '
    { json = json $0 }
    END {
      # Shape 1: an object record - "k":"v" anywhere.
      pat = "\"" k "\"[[:space:]]*:[[:space:]]*\"[^\"]*\""
      if (match(json, pat)) {
        s = substr(json, RSTART, RLENGTH)
        sub(/^"[^"]*"[[:space:]]*:[[:space:]]*"/, "", s)
        sub(/"$/, "", s)
        print s
        exit
      }
      # Shape 2: columnar - find the "columns":[…] array that CONTAINS k (envelopes
      # nest an outer op/ok/status columns array we must skip), then read the first
      # row of the "rows":[[…]] that follows it and print the value at k'\''s index.
      rest = json
      while (match(rest, /"columns"[[:space:]]*:[[:space:]]*\[[^]]*\]/)) {
        cols = substr(rest, RSTART, RLENGTH)
        rest = substr(rest, RSTART + RLENGTH)
        if (index(cols, "\"" k "\"") == 0) continue
        if (!match(rest, /"rows"[[:space:]]*:[[:space:]]*\[\[[^]]*\]/)) continue
        row = substr(rest, RSTART, RLENGTH)
        sub(/^"columns"[[:space:]]*:[[:space:]]*\[/, "", cols)
        sub(/\]$/, "", cols)
        sub(/^"rows"[[:space:]]*:[[:space:]]*\[\[/, "", row)
        sub(/\]$/, "", row)
        nc = split(cols, C, ",")
        split(row, R, ",")
        for (i = 1; i <= nc; i++) {
          c = C[i]; gsub(/^[[:space:]]*"/, "", c); gsub(/"[[:space:]]*$/, "", c)
          if (c == k) {
            v = R[i]; gsub(/^[[:space:]]*"/, "", v); gsub(/"[[:space:]]*$/, "", v)
            if (v != "null") print v
            exit
          }
        }
        exit
      }
    }'
}

# autobind_wanted → 0 when this run should silently bind a /128, else 1.
autobind_wanted() {
  # Liberal-accept (Postel): case-fold the token so WHISPER_AUTOBIND=Yes / TRUE / ON
  # (a common env-var convention) is honoured, not silently ignored.
  case "$(printf '%s' "${WHISPER_AUTOBIND:-}" | tr '[:upper:]' '[:lower:]')" in
    0|no|off|false)                  return 1 ;;
    1|yes|true|on|register|identity) return 0 ;;
  esac
  # Unset ⇒ infer the fleet shape: output captured (stdout not a tty) + the key
  # INJECTED on this run as WHISPER_KEY (RMM variable / gateway header bake). The
  # persistent $WHISPER_API_KEY convention and --key/first-token deliberately do
  # NOT infer - conservative in what we do: no surprise identities from a
  # Dockerfile RUN or a redirected install (explicit WHISPER_AUTOBIND=1 covers those).
  [ ! -t 1 ] || return 1
  [ -n "${WHISPER_KEY:-}" ] || return 1
  return 0
}

# fleet_autobind - bind this endpoint's /128 via the binary's own non-interactive
# enroll (`whisper create`), then print ONE line:  bound <addr>  (<fcrdns-name>).
# Sets BOUND=1 on success (including the already-bound re-run). Never fails the install.
fleet_autobind() {
  _cfgdir="$HOME/.config/whisper"
  _marker="$_cfgdir/bound"
  # Idempotent re-run: a prior bind is recorded in the marker - re-print it, zero calls.
  if [ -f "$_marker" ]; then
    _addr="$(sed -n 's/^address=//p' "$_marker" | head -n 1)"
    _name="$(sed -n 's/^name=//p' "$_marker" | head -n 1)"
    if [ -n "$_addr" ]; then
      say "bound $_addr  (${_name:-no name}) [already bound]"
      BOUND=1
      return 0
    fi
  fi
  [ -x "$DEST" ] || { say "auto-bind skipped: no whisper binary at $DEST"; return 0; }
  # The agent's human name (§3.2): this endpoint's hostname, with a safe fallback.
  _host="$(hostname 2>/dev/null || uname -n 2>/dev/null || printf 'endpoint')"
  [ -n "$_host" ] || _host="endpoint"
  # Bind ladder. The binary resolves the key itself (env WHISPER_KEY/WHISPER_API_KEY,
  # else the file save_key just wrote) - the key never appears on argv.
  _out=""
  _mode=""
  # Bind-mode ladder. Compute the mode list with a STANDALONE case, never a `case` inside
  # $(...): macOS /bin/sh is bash 3.2, whose command-substitution parser misreads the `)` in
  # the case patterns and dies with "syntax error near `;;`" (#625). A plain var + word-split
  # for-loop is POSIX-portable to every shell (bash 3.2, dash, busybox ash, zsh).
  case "${WHISPER_AUTOBIND:-}" in
    register) _modes='register' ;;
    identity) _modes='identity' ;;
    *)        _modes='register identity' ;;
  esac
  # #661: --reuse makes the register bind IDEMPOTENT on the endpoint name. A re-run
  # of this installer (a sensor/binary upgrade, a re-imaged box, a lost marker, a
  # different $HOME than the first bind) re-finds the agent already registered under
  # this hostname and returns its EXISTING /128, instead of minting a duplicate that
  # strands the old identity and moves the telemetry. A genuinely fresh endpoint
  # still mints; the identity mode below is server-side idempotent already (one key,
  # one /128).
  for _m in $_modes; do
    case "$_m" in
      register) _out="$("$DEST" create --register --reuse --name "$_host" --json 2>/dev/null)" || _out="" ;;
      identity) _out="$("$DEST" create --name "$_host" --json 2>/dev/null)" || _out="" ;;
    esac
    _addr="$(json_field "$_out" address)"
    [ -z "$_addr" ] && _addr="$(json_field "$_out" addr128)"
    if [ -n "$_addr" ]; then _mode="$_m"; break; fi
  done
  if [ -z "${_addr:-}" ]; then
    say "couldn't bind a /128 identity (key refused or control plane unreachable) - whisper is installed; bind later with: whisper create --name $_host"
    return 0
  fi
  # FCrDNS name: the forward fqdn (which is also the PTR's target - dig -x <addr>
  # answers exactly this name); the envelope's `ptr` field is the ip6.arpa OWNER name.
  _fcrdns="$(json_field "$_out" fqdn)"
  [ -n "$_fcrdns" ] || _fcrdns="$(json_field "$_out" ptr)"
  _fcrdns="${_fcrdns%.}"
  _agent="$(json_field "$_out" agent)"
  # NOTE: an op:register envelope also carries the minted agent's own api_key (shown
  # once). It lives only in this shell's memory: we parse what we need and drop it -
  # never printed, never persisted (the endpoint keeps ONLY the key the RMM injected).
  _out=""
  # Record the bind (no secrets) so a re-run is idempotent and uninstall can revoke.
  ( umask 077; mkdir -p "$_cfgdir" && printf 'mode=%s\naddress=%s\nname=%s\nagent=%s\nbound_at=%s\n' \
      "$_mode" "$_addr" "$_fcrdns" "$_agent" "$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date)" \
      > "$_marker" ) 2>/dev/null || vsay "couldn't write the bind marker - re-runs will re-check with the server"
  # Pin the binary's chosen agent to THIS identity (so a later `whisper connect` binds
  # it with zero config) - but never clobber an agent a human already picked.
  if [ -n "$_agent" ] && [ ! -f "$_cfgdir/agent" ]; then
    ( umask 077; printf '%s' "$_agent" > "$_cfgdir/agent" ) 2>/dev/null || true
  fi
  say "bound $_addr  (${_fcrdns:-no reverse name yet})"
  BOUND=1
  return 0
}

# =================================================================================
# main
# =================================================================================
say "installing…"
install_binary
save_key
fix_path

# Make THIS shell's child (the exec below) see DEST_DIR even before any rc runs.
case ":$PATH:" in *":$DEST_DIR:"*) ;; *) PATH="$DEST_DIR:$PATH"; export PATH ;; esac

# Self-verify in a fresh shell BEFORE we claim success (the headline-bug oracle).
if [ "${NO_PATH:-0}" != "1" ] && [ "${ALREADY_ON_PATH:-0}" != "1" ]; then
  if ! self_verify; then
    say "installed to $DEST, but a fresh ${FAILED_SHELL:-shell} didn't see it on PATH."
    say "to use it in THIS terminal now, run:  $(reactivation_line)"
    say "new terminals should already work; if not, re-run the installer."
    # Not fatal: the binary still works by absolute path; the door never fails.
  fi
fi

# Success line + handoff. On a TTY, exec the guided flow (by ABSOLUTE path so it never
# depends on PATH). No TTY ⇒ print the one-line next step.
say "installed ✓  (run: whisper)"

# Fleet auto-bind (#562): silent, idempotent, fail-soft - see the section above. Runs
# before the TTY handoff so an explicit WHISPER_AUTOBIND=1 binds even on a terminal
# (the guided flow then simply finds its identity already in place).
BOUND=0
if autobind_wanted; then
  fleet_autobind || true
fi

# Host sensor (#603) - OPT-IN ONLY, never default-on. The bare one-liner mints
# identity + resolver and NOTHING more; observing a host is an explicit choice,
# never a side effect of minting a /128, so this is deliberately NOT inferred
# from WHISPER_AUTOBIND or the fleet shape. Set WHISPER_SENSOR=1 (or yes/true/on)
# to ALSO install + enable + start the separate whisper-sensor service via the
# binary's own installer. Fail-soft: a sensor failure warns with the one command
# to run later and NEVER fails the install.
case "$(printf '%s' "${WHISPER_SENSOR:-}" | tr '[:upper:]' '[:lower:]')" in
  1|yes|true|on)
    # One identity for mint → sensor. The sensor ships its telemetry under this
    # endpoint's OWN /128 (keyless-on-/128, else keyed-fallback), so a bound /128
    # is a PRECONDITION. If this run has not already bound one (an interactive
    # one-liner, where autobind_wanted is false), bind it now: enabling the sensor
    # is an explicit opt-in, so binding the identity it needs is a precondition,
    # not a surprise. fleet_autobind is idempotent and fail-soft.
    if [ "$BOUND" != 1 ]; then
      fleet_autobind || true
    fi
    if [ -x "$DEST" ] && "$DEST" service install --now --sensor >/dev/null 2>&1; then
      say "sensor service installed + running (whisper-sensor)"
    else
      say "couldn't enable the sensor service - whisper is installed; enable later with: whisper service install --now --sensor"
    fi
    ;;
esac

if [ -t 0 ] && [ -t 1 ] && [ -x "$DEST" ]; then
  exec "$DEST"
elif [ "$BOUND" != 1 ]; then
  say "installed to $DEST — run: whisper"
fi
