#!/bin/sh
# install.sh puts banz, the client, on this machine, and brings it to its
# channel's release when run again (docs/design/update-system.md, item 6;
# docs/ops/runbook.md, "Releases"). It is a bootstrap: it downloads the
# headless banz of the release LATEST names, checks it against that
# release's SHA256SUMS, and runs it as `banz install`, which puts the
# install in place as an update does: the channel's release (channels.json
# at the index), file by file from the index's store, checked whole and
# tested, in its version folder, and banz in DIR a link into it.
#
#   curl -fsSL https://HOST/PATH/install.sh | sh
#   curl -fsSL https://HOST/PATH/install.sh | sh -s -- --dir DIR
#   curl -fsSL https://HOST/PATH/install.sh | sh -s -- --headless
#   sh install.sh BASE_URL [DIR]
#
# BASE_URL is a release index: an https:// URL, a file:// URL or a local
# directory. curl is held to HTTPS (TLS 1.2 or later) on every redirect.
# Plain http:// is refused except to a private or loopback address (10/8,
# 172.16/12, 192.168/16, 127/8, localhost), fetched with no redirect
# followed: a team server reached only inside a VPN may serve its index
# there (ruled 2026-10-09, docs/contributing/rules.md). Piped from a
# published copy it is the index that copy was published with (release.sh
# writes it in, BANZ_RELEASE_INDEX); otherwise BANZ_INSTALL_BASE or the
# first argument. DIR, where the command banz is linked, defaults to
# ~/.local/bin, or /usr/local/bin for root. POSIX sh: it runs before
# anything else is installed. Everything runs from main, called on the
# last line, so a download cut short runs nothing.
#
# With no flag, and with --desktop, it installs the desktop app (on macOS
# banz.app in BANZ_INSTALL_MAC_APP_DIR, /Applications, or ~/Applications
# where that cannot be written); --headless the headless build, for a
# machine with no display. A banz a package manager installed (a .deb,
# .rpm or pacman package) is left to it, saying to remove it.
#
# The signature: with BANZ_INSTALL_KEY set to the release's minisign public
# key (its base64 line), SHA256SUMS must carry a valid signature by it; that
# needs minisign. A published copy carries the release's key too, checked
# when minisign is here, and given to banz install, which checks the index's
# channel file and manifests with it. When the signature is not checked it
# says so on stderr.
set -eu

# written by release.sh into the copy it publishes; empty in the checkout
published_index="https://service.emphar.com/releases"
published_key="RWTxo8VkkxPlyUHK/ODio/pzuvEORmDzJYRBBgqCOYlAZ2gDs3hFk2At"

summed() { # summed NAME: NAME's sum in the release's SHA256SUMS, or nothing
  awk -v n="$1" '$2 == n { print $1 }' "$tmp/SHA256SUMS"
}

check() { # check NAME: $tmp/NAME matches its sum, or nothing is installed
  want=$(summed "$1")
  [ -n "$want" ] || { echo "install: $1 is not in the release's SHA256SUMS" >&2; exit 1; }
  if command -v sha256sum >/dev/null 2>&1; then got=$(sha256sum "$tmp/$1" | awk '{print $1}')
  else got=$(shasum -a 256 "$tmp/$1" | awk '{print $1}'); fi
  [ "$got" = "$want" ] || { echo "install: $1 does not match its checksum (got $got, want $want); nothing installed" >&2; exit 1; }
}

fetch() { # fetch PATH: $base/PATH into $tmp/(its last part)
  out="$tmp/${1##*/}"
  case $base in
    https://*) curl --proto '=https' --tlsv1.2 -fsSL --max-time 300 -o "$out" "$base/$1" ;;
    http://*) curl --proto '=http' --max-redirs 0 -fsSL --max-time 300 -o "$out" "$base/$1" ;;
    file://*) cp "${base#file://}/$1" "$out" ;;
    *) cp "$base/$1" "$out" ;;
  esac
}

# private_http URL: an http:// URL whose host is localhost or a private or
# loopback IPv4 address; a name that only starts like one is not
private_http() {
  host=${1#http://}
  host=${host%%/*}
  host=${host%%:*}
  case $host in
    localhost) return 0 ;;
    ''|*[!0-9.]*) return 1 ;;
    10.*|127.*|192.168.*|172.1[6-9].*|172.2[0-9].*|172.3[01].*) return 0 ;;
  esac
  return 1
}

# is_version V: vMAJOR.MINOR.PATCH and an optional pre-release after - or .;
# after a dot it is not a bare number (v1.0.0.1 is no version), as `banz
# update` reads it
is_version() {
  case $1 in ''|*[!0-9A-Za-z.-]*) return 1 ;; esac
  printf '%s\n' "$1" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.]+)?$' &&
    ! printf '%s\n' "$1" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'
}

# signed REL: $tmp/SHA256SUMS, of the release at REL, is signed by the
# release's key; said, or why it is not checked
signed() {
  if [ -n "$key" ]; then
    if command -v minisign >/dev/null 2>&1; then
      fetch "${1}SHA256SUMS.minisig" 2>/dev/null || { echo "install: the release is not signed (no ${1}SHA256SUMS.minisig); nothing installed" >&2; exit 1; }
      minisign -Vq -P "$key" -m "$tmp/SHA256SUMS" >/dev/null 2>&1 ||
        { echo "install: ${1}SHA256SUMS is not signed by the release's key; nothing installed" >&2; exit 1; }
      echo "install: ${1}SHA256SUMS signed by the release's key"
    elif [ -n "$key_required" ]; then
      echo "install: BANZ_INSTALL_KEY is set but minisign is not installed; nothing installed" >&2; exit 1
    else
      unsigned "minisign is not installed"
    fi
  else
    unsigned "no key: BANZ_INSTALL_KEY names the release's public key, checked with minisign"
  fi
}
# get REL NAME: the release at REL's file NAME into $tmp/NAME, checked
# against $tmp/SHA256SUMS: the headless build gzipped, when the release
# carries it so and gzip is here, a third of the bytes, checked against its
# own sum, then unpacked and checked as the plain file
get() {
  if [ -n "$headless_get" ] && command -v gzip >/dev/null 2>&1 && [ -n "$(summed "$2.gz")" ] && fetch "$1$2.gz" 2>/dev/null; then
    check "$2.gz"
    gzip -dc "$tmp/$2.gz" > "$tmp/$2" 2>/dev/null || { echo "install: $2.gz does not unpack; nothing installed" >&2; exit 1; }
    rm -f "$tmp/$2.gz"
  else
    # the sums say whether the release carries it; a fetch that fails on
    # one it carries is a download cut short, not a build it lacks
    [ -n "$(summed "$2")" ] || { echo "install: ${1%/} has no headless build ($2) to install with" >&2; exit 1; }
    fetch "$1$2" || { echo "install: could not download $1$2 from $base (the error above); nothing installed, run it again" >&2; exit 1; }
  fi
  check "$2"
}
# unsigned WHY: the signature of SHA256SUMS is not checked; said on stderr
unsigned() {
  echo "install: WARNING: the signature of SHA256SUMS was NOT verified ($1)" >&2
  echo "install: its checksums come from the same place as the banz it installs with: they catch a damaged download, not a forged release" >&2
  verified=" (signature NOT verified)"
}

main() {
  base=${BANZ_INSTALL_BASE:-$published_index}
  dir=""
  desktop=""
  headless=""
  n=0
  while [ $# -gt 0 ]; do
    case $1 in
      --dir) dir=${2:?install: --dir needs a directory}; shift 2 ;;
      --base) base=${2:?install: --base needs a URL}; shift 2 ;;
      --desktop) desktop=1; shift ;;
      --headless) headless=1; shift ;;
      -h|--help) echo "usage: install.sh [BASE_URL] [DIR], or --base URL --dir DIR; --desktop the desktop app, the default; --headless the headless build, for a machine with no display"; exit 0 ;;
      -*) echo "install: unknown option $1" >&2; exit 2 ;;
      *) n=$((n + 1))
         case $n in 1) base=$1 ;; 2) dir=$1 ;; *) echo "install: too many arguments" >&2; exit 2 ;; esac
         shift ;;
    esac
  done
  [ -z "$desktop" ] || [ -z "$headless" ] || { echo "install: --desktop and --headless are two builds; give one" >&2; exit 2; }
  [ -n "$base" ] || { echo "install: give the release index: install.sh BASE_URL, or BANZ_INSTALL_BASE" >&2; exit 2; }
  base=${base%/}
  case $base in
    https://*) command -v curl >/dev/null 2>&1 || { echo "install: $base needs curl, which holds every redirect to HTTPS; install curl, or give the index as a local directory" >&2; exit 1; } ;;
    file://*) ;;
    http://*) private_http "$base" || { echo "install: $base is not HTTPS: give an https:// index (plain HTTP only on a private address), a file:// URL or a local directory; nothing installed" >&2; exit 2; }
      command -v curl >/dev/null 2>&1 || { echo "install: $base needs curl; install curl, or give the index as a local directory" >&2; exit 1; }
      echo "install: $base is plain HTTP on a private address (a VPN): the files are checked against their sums" ;;
    *://*) echo "install: $base is not HTTPS: give an https:// index (plain HTTP only on a private address), a file:// URL or a local directory; nothing installed" >&2; exit 2 ;;
  esac
  if [ -z "$dir" ]; then
    if [ "$(id -u)" = 0 ]; then dir=/usr/local/bin; else dir=$HOME/.local/bin; fi
  fi
  key=${BANZ_INSTALL_KEY:-}
  key_required=${BANZ_INSTALL_KEY:+1}
  [ -n "$key" ] || key=$published_key
  verified=""

  case $(uname -s) in
    Linux) os=linux ;;
    Darwin) os=darwin ;;
    *) echo "install: $(uname -s) is not supported here; on Windows, run install.ps1" >&2; exit 1 ;;
  esac
  case $(uname -m) in
    x86_64|amd64) arch=amd64 ;;
    aarch64|arm64) arch=arm64 ;;
    *) echo "install: no build for $(uname -m)" >&2; exit 1 ;;
  esac
  # Linux is built for x86-64 alone (owner, 2026-10-19)
  [ "$os/$arch" != linux/arm64 ] || { echo "install: the releases carry Linux for x86-64 only; there is no build for $(uname -m)" >&2; exit 1; }

  target="$dir/banz"
  current=$(command -v banz 2>/dev/null || true)
  owner=""
  if [ -n "$current" ] && [ "$current" != "$target" ]; then
    if command -v dpkg >/dev/null 2>&1 && dpkg -S "$current" 2>/dev/null | grep -q '^banz-desktop:'; then
      echo "install: banz at $current is the desktop app's .deb (banz-desktop); update it with apt" >&2
      exit 1
    elif command -v dpkg >/dev/null 2>&1 && dpkg -S "$current" >/dev/null 2>&1; then owner="dpkg: sudo apt remove banz"
    elif command -v pacman >/dev/null 2>&1 && pacman -Qqo "$current" >/dev/null 2>&1; then owner="pacman: sudo pacman -R banz"
    elif command -v rpm >/dev/null 2>&1 && rpm -qf "$current" >/dev/null 2>&1; then owner="rpm: sudo rpm -e banz"
    fi
  fi
  if [ -n "$owner" ]; then
    echo "install: banz at $current belongs to ${owner%%:*}; remove it first (${owner#*: }), then run this again" >&2
    exit 1
  fi
  # the build: asked for; else a headless banz kept headless; else the
  # desktop app (owner, 2026-10-23)
  if [ -z "$desktop$headless" ]; then
    if [ -x "$target" ] && [ "$("$target" version 2>/dev/null | awk '{print $3}')" = headless ]; then headless=1
    else desktop=1
    fi
  fi

  tmp=$(mktemp -d)
  trap 'rm -rf "$tmp"' EXIT INT TERM

  # the bootstrap: the headless banz of the release LATEST names, checked
  # curl says why a fetch failed (not found, unreachable, cut short): its
  # line stays above ours
  fetch LATEST || { echo "install: could not read $base/LATEST (the error above): is it a release index, and can this machine reach it?" >&2; exit 1; }
  version=$(tr -d ' \t\r\n' < "$tmp/LATEST")
  is_version "$version" || { echo "install: $base names $version in LATEST, which is not a version" >&2; exit 1; }
  fetch "$version/SHA256SUMS" || { echo "install: could not read $version/SHA256SUMS from $base (the error above); nothing installed" >&2; exit 1; }
  signed "$version/"
  boot="banz-$version-$os-$arch-headless"
  headless_get=1
  get "$version/" "$boot"
  chmod 0755 "$tmp/$boot"

  # banz install puts the install in place; it runs no host
  set -- install --index "$base" --command "$target"
  [ -z "$headless" ] || set -- "$@" --headless
  if [ -n "$desktop" ] && [ "$os" = darwin ]; then
    if [ -n "${BANZ_INSTALL_MAC_APP_DIR:-}" ]; then apps=$BANZ_INSTALL_MAC_APP_DIR
    elif [ -w /Applications ]; then apps=/Applications
    else apps=$HOME/Applications
    fi
    set -- "$@" --app-dir "$apps"
  fi
  mkdir -p "$dir"
  BANZ_UPDATE_KEY="$key" "$tmp/$boot" "$@" || { echo "install: banz install did not finish; nothing was switched" >&2; exit 1; }
  echo "install: banz in $target$verified"
  "$target" version 2>/dev/null || true
  if [ -n "$desktop" ] && [ "$os" = linux ]; then
    # the app's sandbox: a namespace of the user's own, or a setuid helper,
    # which a person's install cannot have
    if command -v unshare >/dev/null 2>&1 && ! unshare -Ur true >/dev/null 2>&1; then
      echo "install: this system lets no user make a sandbox of its own (unprivileged user namespaces are off), so the app cannot start; install the release's banz-desktop .deb instead (its sandbox helper is setuid)" >&2
    fi
    if [ -z "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ]; then
      echo "install: no display here: for a machine with none, install.sh --headless installs the headless build"
    fi
    # banz in the desktop's application menu; root's would be no person's menu
    if [ "$(id -u)" != 0 ]; then
      "$target" desktop --menu-entry || echo "install: banz is not in the application menu yet; banz desktop puts it there" >&2
    fi
  fi
  # the headless build: what a machine with no display does next
  if [ -n "$headless" ]; then
    service=boot
    [ "$os" = darwin ] && service=login
    echo "install: sign in with banz auth login --email EMAIL, and start the host with the system: banz config set host.service $service"
  fi
  case ":$PATH:" in
    *":$dir:"*) if [ -n "$current" ] && [ "$current" != "$target" ]; then echo "install: another banz comes first on PATH: $current"; fi ;;
    *) echo "install: $dir is not on PATH; add it to use banz" ;;
  esac
  echo "install: the first host start connects Claude Code and Codex to banz's tools; banz config set harness.connect off undoes it"
}

main "$@"
