#!/bin/sh
# Tenax host installer.
#   curl -fsSL https://tenax.jackolab.com/install.sh | sh
#   curl -fsSL https://tenax.jackolab.com/install.sh | sh -s v0.1.21   # pin a release
#
# Downloads the `tenax` binary for this computer from GitHub
# Releases, verifies it — the release .sha256 and, unless a staging
# base is used, the SHA-256 recorded in the Ed25519-signed release
# feed (latest.json, the same key install.ps1 and the host updater
# trust) — and installs it. On macOS the
# signed Tenax.app bundle is preferred (#1578): it lands in
# /Applications (or ~/Applications) and `tenax` links to the bundled
# binary, so the service plist and menu app point into the app.
# At a terminal it then runs `tenax service install` itself — the
# sign-in code that prints is the confirmation (approve it, or
# Ctrl-C). Otherwise:
#   tenax service install   # sign this host in, run it as a service
#
# Args:
#   [TAG]           release tag to install — `sh -s v0.1.21` (default:
#                   the latest release in the signed feed)
# Env:
#   TENAX_FEED_BASE   release feed base — test override (default:
#                     https://tenax.jackolab.com/releases)
#   TENAX_INSTALL_DIR override install dir (default: ~/.local/bin;
#                     must be user-writable — the updater stages beside
#                     the binary, so a system dir would never update)
#   TENAX_NO_SERVICE=1  install the binary only — print the next step
#                     instead of running service install (default when
#                     no terminal is attached: CI, piped automation)
#   (a staged mux lands side by side via `tenax install-mux` —
#   sessions are never restarted; #2291)
#   TENAX_INSTALL_ALLOW_SUDO=1  permit running under sudo (default:
#                     refuse — $HOME resolves to root's and the user
#                     shell never sees the install)
#   GITHUB_TOKEN      optional — helps with API rate limits; not required
#   TENAX_RELEASE_BASE fetch the same-named asset files from this
#                     directory URL instead of GitHub Releases
#                     (staging/proof installs; requires the TAG arg;
#                     the signed feed is not consulted)
#   TENAX_FEED_PUBKEY feed signing key as an 'ssh-ed25519 <base64>'
#                     line — test override (default: the release key)
#   TENAX_APP_DIR   macOS app-bundle dir override (default:
#                     /Applications, else ~/Applications) — test and
#                     staging installs
set -eu

# Public dist repo: release assets + issues only; source stays private (#515).
REPO="revolt3r/tenax"
# The signed release feed names the tag to install (#1572) — GitHub's
# releases/latest can trail it while a publish is mid-flight.
FEED="${TENAX_FEED_BASE:-https://tenax.jackolab.com/releases}"
# #1632: the feed key as an ssh-ed25519 pubkey line — the same key as
# FEED_PUBKEY (hex) in install.ps1/update.rs. ssh-keygen -Y verify reads
# it from an allowed_signers file; the openssl path decodes the base64
# blob's trailing 32 bytes into a DER SPKI.
PUB_SSH="${TENAX_FEED_PUBKEY:-ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMHLoNjdjVsN9l1mrXA4jh/E6QFhkj7xiSSRooapMv3x}"
PUB_NS="tenax-release"   # sshsig namespace + allowed_signers principal

say() { printf '%s\n' "$*"; }
err() { say "install.sh: $*" >&2; exit 1; }

# `sh -s TAG` pins that release; bare install tracks the feed's latest.
PIN="${1:-}"
[ $# -le 1 ] || err "usage: install.sh [TAG]"

fetch() { # fetch URL [OUTFILE] [ACCEPT]
    url="$1"; out="${2:-}"; accept="${3:-}"
    if command -v curl >/dev/null 2>&1; then
        set -- -fsSL
        [ -n "${GITHUB_TOKEN:-}" ] && set -- "$@" -H "Authorization: token $GITHUB_TOKEN"
        [ -n "$accept" ] && set -- "$@" -H "Accept: $accept"
        if [ -n "$out" ]; then curl "$@" -o "$out" "$url"; else curl "$@" "$url"; fi
    elif command -v wget >/dev/null 2>&1; then
        set -- -q
        [ -n "${GITHUB_TOKEN:-}" ] && set -- "$@" --header="Authorization: token $GITHUB_TOKEN"
        [ -n "$accept" ] && set -- "$@" --header="Accept: $accept"
        if [ -n "$out" ]; then wget "$@" -O "$out" "$url"; else wget "$@" -O - "$url"; fi
    else
        err "need curl or wget"
    fi
}

# The feed is our own site — GITHUB_TOKEN must never go in its headers.
fetch_feed() { # fetch_feed URL
    url="$1"
    if command -v curl >/dev/null 2>&1; then
        curl -fsSL "$url"
    elif command -v wget >/dev/null 2>&1; then
        wget -q -O - "$url"
    else
        err "need curl or wget"
    fi
}

detect() {
    os="$(uname -s)"
    arch="$(uname -m)"
    case "$os" in
        Linux) plat=linux ;;
        Darwin) plat=darwin ;;
        *) err "unsupported OS: $os" ;;
    esac
    case "$arch" in
        x86_64|amd64) arch="x86_64" ;;
        aarch64|arm64) arch="aarch64" ;;
        *) err "unsupported arch: $arch" ;;
    esac
    ASSET="tenax-${TAG}-${plat}-${arch}"
}

bad_tag() { err "bad release tag: $1"; }

verify_feed() { # verify_feed FILE — rc 0 verified, 1 rejected, 2 no usable verifier
    m="$1"
    # ssh-keygen -Y verify (OpenSSH 8.1+) is the only ed25519 check on
    # stock macOS — its openssl is LibreSSL — and works on nearly every
    # Linux. A failed verify when it ran is a bad signature: stop.
    if [ -f "$m.sshsig" ] && command -v ssh-keygen >/dev/null 2>&1 \
        && ssh-keygen -? 2>&1 | grep -q -- '-Y verify'; then
        key="${PUB_SSH#ssh-ed25519 }"; key="${key%% *}"
        printf '%s %s %s\n' "$PUB_NS" ssh-ed25519 "$key" >"$m.allowed"
        ssh-keygen -Y verify -f "$m.allowed" -I "$PUB_NS" \
            -n "$PUB_NS" -s "$m.sshsig" <"$m" >/dev/null 2>&1
        return
    fi
    # OpenSSL 3+ (not LibreSSL — it has no ed25519; not 1.1.1 — pkeyutl
    # has no -rawin there) verifies the raw .sig.
    if [ -f "$m.sig" ] && command -v openssl >/dev/null 2>&1 \
        && openssl version 2>/dev/null | grep -q '^OpenSSL [3-9]'; then
        # DER SubjectPublicKeyInfo wrapper around the raw 32-byte key;
        # PUB_SSH may carry a comment, so take field 2 only.
        key="${PUB_SSH#ssh-ed25519 }"; key="${key%% *}"
        { printf '\060\052\060\005\006\003\053\145\160\003\041\000'
          printf '%s\n' "$key" | openssl base64 -d 2>/dev/null \
              | tail -c 32; } >"$m.pub.der"
        openssl pkeyutl -verify -pubin -inkey "$m.pub.der" -keyform DER \
            -rawin -in "$m" -sigfile "$m.sig" >/dev/null 2>&1
        return
    fi
    return 2
}

resolve_feed() { # fetch + verify the signed manifest; sets TAG, MANIFEST
    if [ -n "${TENAX_RELEASE_BASE:-}" ]; then
        [ -n "$PIN" ] \
            || err "TENAX_RELEASE_BASE needs a TAG arg — a staging dir has no 'latest'"
        TAG="$PIN"
        case "$TAG" in
            v[0-9]*) ;;
            *) bad_tag "$TAG" ;;
        esac
        case "$TAG" in
            *[!A-Za-z0-9._-]*) bad_tag "$TAG" ;;
        esac
        MANIFEST=""; return
    fi
    man="latest.json"
    if [ -n "$PIN" ]; then
        # The tag becomes a URL path — check it before it is one.
        case "$PIN" in
            v[0-9]*) ;;
            *) bad_tag "$PIN" ;;
        esac
        case "$PIN" in
            *[!A-Za-z0-9._-]*) bad_tag "$PIN" ;;
        esac
        man="$PIN.json"   # publish ships <tag>.json for pins
    fi
    # #2295: the versioned dir swaps manifest+sig as one unit — read it
    # when the feed has it; the flat pair stays for older publishes.
    if [ -z "$PIN" ] && fetch_feed "$FEED/latest/latest.json" >"$tmp/manifest.json" 2>/dev/null; then
        man="latest/latest.json"
    else
        fetch_feed "$FEED/$man" >"$tmp/manifest.json" \
            || if [ -n "$PIN" ]; then
                   err "release feed has no $man (only published releases verify)"
               else
                   err "could not read the release feed ($FEED/$man) — pass a TAG arg to pin a release"
               fi
    fi
    for s in sshsig sig; do
        fetch_feed "$FEED/$man.$s" >"$tmp/manifest.json.$s" 2>/dev/null \
            || rm -f "$tmp/manifest.json.$s"
    done
    rc=0; verify_feed "$tmp/manifest.json" || rc=$?
    case "$rc" in
        0) ;;
        2) err "cannot verify the release feed signature — need OpenSSH 8.1+ (ssh-keygen -Y) or OpenSSL" ;;
        *) err "the release feed signature is invalid — refusing to install" ;;
    esac
    TAG="$(sed -n 's/.*"tag": *"\([^"]*\)".*/\1/p' "$tmp/manifest.json" | head -1)"
    [ -n "$TAG" ] || err "release feed manifest has no tag"
    [ -z "$PIN" ] || [ "$TAG" = "$PIN" ] \
        || err "release feed manifest is for $TAG, not $PIN"
    # The tag becomes a URL path — refuse anything that can escape it.
    case "$TAG" in
        v[0-9]*) ;;
        *) bad_tag "$TAG" ;;
    esac
    case "$TAG" in
        *[!A-Za-z0-9._-]*) bad_tag "$TAG" ;;
    esac
    MANIFEST="$(cat "$tmp/manifest.json")"
}

digest() { # digest DIR FILE — prints FILE's lowercase sha256
    out="$(cd "$1" && {
        if command -v sha256sum >/dev/null 2>&1; then sha256sum "$2";
        elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$2";
        else exit 1; fi
    })" || return 1
    printf '%s' "$out" | sed 's/ .*//' | tr 'A-F' 'a-f'
}

check() { # check DIR FILE — status only, no error message
    # Digest-compare, not `-c`: Apple's sha256sum -c exits 0 on a
    # malformed checksum line (#1621), so the exit status of either
    # tool's checker cannot be trusted — the computed hex is compared
    # to the file's first field instead.
    want="$(sed -n 's/^\([0-9a-fA-F]\{64\}\)[ *].*/\1/p' "$1/$2.sha256" \
        | head -1 | tr 'A-F' 'a-f')"
    [ -n "$want" ] || return 1
    [ "$(digest "$1" "$2")" = "$want" ]
}

# The signed feed's `assets` map keys on the asset name minus the
# tenax-<tag>- prefix and the .exe/.zip suffix — release-publish.sh's
# `plat` derivation.
feed_key() { # feed_key NAME — prints the manifest key
    key="${1#tenax-"$TAG"-}"
    [ "$key" != "$1" ] || return 1
    key="${key%.exe}"; key="${key%.zip}"
    printf '%s\n' "$key"
}
feed_sha() { # feed_sha KEY — the manifest's sha256, empty when unlisted
    printf '%s' "$MANIFEST" \
        | sed -n 's/.*"'"$1"'"[^{]*{[^}]*"sha256": *"\([0-9a-fA-F]*\)".*/\1/p' \
        | head -1 | tr 'A-F' 'a-f'
}
feed_check() { # feed_check DIR FILE — rc 0 match, 1 mismatch, 2 unlisted
    [ -n "$MANIFEST" ] || return 2
    key="$(feed_key "$2")" || return 2
    want="$(feed_sha "$key")"
    [ -n "$want" ] || return 2
    [ "$(digest "$1" "$2")" = "$want" ]
}
verify() { # verify DIR FILE — bad .sha256 or feed entry is fatal
    check "$@" || err "checksum verification failed for $2"
    [ -n "$MANIFEST" ] || return 0
    rc=0; feed_check "$@" || rc=$?
    case "$rc" in
        0) ;;
        2) err "the signed release feed has no entry for $2" ;;
        *) err "$2 does not match the signed release feed" ;;
    esac
}

note_path() { # note_path DIR — the "not on PATH" hint
    case ":$PATH:" in *":$1:"*) ;; *)
        say "note: $1 is not on PATH — add this line to your shell profile:"
        say "  export PATH=\"$1:\$PATH\""
    ;; esac
}

install_bin() { # install_bin SRC NAME — NAME is the installed basename
    src="$1"; name="$2"
    dir="${TENAX_INSTALL_DIR:-}"
    # Default lands under $HOME — the host runs as the user and never
    # needs root (Claude's installer shape). #2292: a requested dir is
    # installed only when the user can write it — the updater stages
    # beside the binary as the same user, so a sudo-planted system-dir
    # install could never update itself. Refuse instead of installing
    # a host the feed cannot reach.
    if [ -z "$dir" ]; then
        dir="$HOME/.local/bin"
    fi
    [ -w "$dir" ] || mkdir -p "$dir" 2>/dev/null || true
    if [ -w "$dir" ]; then
        install -m 0755 "$src" "$dir/$name"
    elif [ -n "${TENAX_INSTALL_DIR:-}" ]; then
        err "cannot write $dir — updates stage beside the binary as your user; pick a user-writable TENAX_INSTALL_DIR such as \$HOME/.local/bin"
    else
        err "cannot write $dir (set TENAX_INSTALL_DIR)"
    fi
    say "installed $dir/$name ($TAG)"
    BIN="$dir/$name"   # absolute — $dir may not be on this shell's PATH
    DIR="$dir"
    note_path "$dir"
}

install_sibling() { # install_sibling SRC NAME — tenax's dir; soft-fail
    src="$1"; name="$2"
    # $DIR is user-writable by construction (#2292) — no sudo path.
    if [ -w "$DIR" ]; then
        install -m 0755 "$src" "$DIR/$name" || return 1
    else
        return 1
    fi
    say "installed $DIR/$name ($TAG)"
}

# #1578: on macOS the release carries Tenax.app — tenax, the menu-bar
# app and a bundled tmux in one signed bundle. Unpack into a staging
# dir inside the app dir (same volume, so the final rename is atomic),
# verify the signature, swap the old bundle aside, then link the CLI.
install_app() { # install_app ZIP
    zip="$1"
    command -v ditto >/dev/null 2>&1 || err "need ditto"
    command -v codesign >/dev/null 2>&1 || err "need codesign"
    if [ -n "${TENAX_APP_DIR:-}" ]; then
        appdir="$TENAX_APP_DIR"
        mkdir -p "$appdir" || err "cannot create $appdir"
    elif [ -w /Applications ]; then
        appdir=/Applications
    else
        appdir="$HOME/Applications"
        mkdir -p "$appdir" || err "cannot create $appdir"
    fi
    stage="$appdir/.tenax-install.$$"
    mkdir "$stage" || err "cannot stage into $appdir"
    if ! ditto -x -k "$zip" "$stage"; then
        rm -rf "$stage"; err "could not unpack the app zip"
    fi
    if [ ! -x "$stage/Tenax.app/Contents/MacOS/tenax" ]; then
        rm -rf "$stage"; err "the app zip has no Tenax.app/Contents/MacOS/tenax"
    fi
    if ! codesign --verify --strict "$stage/Tenax.app"; then
        rm -rf "$stage"; err "Tenax.app signature check failed"
    fi
    # A running host keeps its inode, so the old bundle can move and
    # be deleted; if the swap fails, put it back.
    old=""
    if [ -e "$appdir/Tenax.app" ]; then
        old="$appdir/.Tenax.app.old.$$"
        mv "$appdir/Tenax.app" "$old" \
            || { rm -rf "$stage"; err "cannot move $appdir/Tenax.app aside"; }
    fi
    if mv "$stage/Tenax.app" "$appdir/Tenax.app"; then
        [ -z "$old" ] || rm -rf "$old"
    else
        [ -z "$old" ] || mv "$old" "$appdir/Tenax.app"
        rm -rf "$stage"; err "could not install Tenax.app into $appdir"
    fi
    rm -rf "$stage"
    say "installed $appdir/Tenax.app ($TAG)"
    BIN="$appdir/Tenax.app/Contents/MacOS/tenax"

    # Same link-dir rule as install_bin (#2292): $HOME/.local/bin by
    # default, a requested dir only when the user can write it. The
    # link lands atomically, replacing an old flat binary or link.
    dir="${TENAX_INSTALL_DIR:-$HOME/.local/bin}"
    [ -w "$dir" ] || mkdir -p "$dir" 2>/dev/null || true
    if [ -w "$dir" ]; then
        ln -s "$BIN" "$dir/.tenax.link.$$" \
            && mv -f "$dir/.tenax.link.$$" "$dir/tenax" \
            || err "cannot link $dir/tenax"
    elif [ -n "${TENAX_INSTALL_DIR:-}" ]; then
        err "cannot write $dir — updates stage beside the binary as your user; pick a user-writable TENAX_INSTALL_DIR such as \$HOME/.local/bin"
    else
        err "cannot write $dir (set TENAX_INSTALL_DIR)"
    fi
    say "linked $dir/tenax -> $BIN"
    DIR="$dir"
    note_path "$dir"
    if [ -e "$appdir/Tenax.app/Contents/MacOS/tenax-tmux" ]; then
        bundled=1
        # The bundle carries its own tmux — an old flat sibling goes
        # away. Without one in the bundle a working flat tmux stays:
        # deleting it would strand the host.
        if [ -e "$dir/tenax-tmux" ] && [ ! -L "$dir/tenax-tmux" ]; then
            rm -f "$dir/tenax-tmux" 2>/dev/null || true
        fi
    fi
}

next() { # the manual service step, when we do not run it ourselves
    # #896: release binaries carry the directory, so one bare
    # command signs the host in (a browser code) and installs the
    # service — a launchd user agent on macOS (#906), a systemd
    # user unit else.
    say "next:  tenax service install"
    say "         # approve the sign-in code it prints in a browser; the host then"
    say "         # runs as a service and shows up in the Tenax app"
}

dl_asset() { # dl_asset NAME OUTFILE
    name="$1"; out="$2"
    # #1457: a staged dir holds the same-named files without being a
    # release — the aarch64 proof installed this way before the asset
    # could sit in one.
    if [ -n "${TENAX_RELEASE_BASE:-}" ]; then
        fetch "${TENAX_RELEASE_BASE%/}/$name" "$out"
        return
    fi
    if [ -n "${GITHUB_TOKEN:-}" ]; then
        id="$(printf '%s' "$RELEASE_JSON" | tr -d '\n' \
            | sed -n 's/.*"id": *\([0-9]*\)[^}]*"name": *"'"$name"'".*/\1/p' | tail -1)"
        [ -n "$id" ] || return 1
        fetch "https://api.github.com/repos/$REPO/releases/assets/$id" "$out" \
            "application/octet-stream"
    else
        fetch "https://github.com/$REPO/releases/download/$TAG/$name" "$out"
    fi
}

main() {
    # Under `sudo sh` $HOME is root's — the install lands where the
    # user's shell can't see it. Refuse, like Claude's installer.
    if [ -n "${SUDO_USER:-}" ] && [ "$(id -u)" = "0" ] \
        && [ -z "${TENAX_INSTALL_ALLOW_SUDO:-}" ]; then
        err "running under sudo installs into root's home — rerun as your user:
  curl -fsSL https://tenax.jackolab.com/install.sh | sh
(set TENAX_INSTALL_ALLOW_SUDO=1 to override)"
    fi
    tmp="$(mktemp -d)"; chmod 700 "$tmp"; trap 'rm -rf "$tmp"' EXIT
    resolve_feed   # before detect(): the signed feed names the tag
    detect
    if [ -z "${TENAX_RELEASE_BASE:-}" ] && [ -n "${GITHUB_TOKEN:-}" ]; then
        RELEASE_JSON="$(fetch "https://api.github.com/repos/$REPO/releases/tags/$TAG")" \
            || err "could not fetch release $TAG (check GITHUB_TOKEN scopes)"
    fi
    bundled=""
    appdone=""
    # #1578: on macOS the app bundle is preferred — one signed zip
    # carries tenax, the menu app and tmux. A release without it
    # (older than the bundle asset) falls through to the flat pair.
    if [ "$plat" = darwin ]; then
        APP_ASSET="tenax-$TAG-$plat-$arch-app.zip"
        if dl_asset "$APP_ASSET" "$tmp/$APP_ASSET"; then
            say "downloaded $APP_ASSET ($TAG)"
            dl_asset "$APP_ASSET.sha256" "$tmp/$APP_ASSET.sha256" \
                || err "checksum file missing for $APP_ASSET"
            verify "$tmp" "$APP_ASSET"
            install_app "$tmp/$APP_ASSET"  # sets BIN, DIR, bundled
            appdone=1
        fi
    fi
    if [ -z "$appdone" ]; then
        say "downloading $ASSET ($TAG)"
        if ! dl_asset "$ASSET" "$tmp/$ASSET"; then
            # A release can predate a platform's first asset — say so
            # plainly instead of a bare download error. darwin landed with
            # #910; linux-aarch64 ships from v0.1.21 (#1457).
            case "$plat-$arch" in
                darwin-*) err "no $ASSET in release $TAG — macOS $arch hosts arrive in a later release (or the download failed)" ;;
                linux-aarch64) err "no $ASSET in release $TAG — Linux ARM64 hosts are not in that release yet (or the download failed)" ;;
                *) err "download failed (does release $TAG exist on $REPO?)" ;;
            esac
        fi
        dl_asset "$ASSET.sha256" "$tmp/$ASSET.sha256" || err "checksum file missing for $ASSET"
        verify "$tmp" "$ASSET"
        install_bin "$tmp/$ASSET" tenax
        # #1877: like install.ps1 — a stale stage from an interrupted
        # run must not reach the gate below when this run lands none.
        rm -f "$DIR/tenax-tmux.update" 2>/dev/null || :
        # #1274: the release carries a static tmux sibling; installed next
        # to tenax as `tenax-tmux`, the host prefers system tmux >=3.7b
        # and falls back to this. Older releases lack the asset — the
        # tmux note below stays for them. #1877: it lands as the
        # `.update` stage — `tenax install-mux` below runs the same
        # Rust land `tenax update` and the app run. #2291: the land is
        # side by side — the old binary parks as `tenax-tmux-<digest>`
        # for its live server — so sessions are never restarted.
        TMUX_ASSET="tenax-$TAG-$plat-$arch-tmux"
        if dl_asset "$TMUX_ASSET" "$tmp/$TMUX_ASSET" \
            && dl_asset "$TMUX_ASSET.sha256" "$tmp/$TMUX_ASSET.sha256" \
            && check "$tmp" "$TMUX_ASSET"; then
            # #1632: like the main asset, a feed entry wins over the
            # adjacent .sha256. Feeds that predate the tmux asset have
            # no entry — rc 2 — and stay on the .sha256 check.
            rc=0; feed_check "$tmp" "$TMUX_ASSET" || rc=$?
            [ "$rc" != 1 ] || err "$TMUX_ASSET does not match the signed release feed"
            if install_sibling "$tmp/$TMUX_ASSET" tenax-tmux.update; then
                bundled=1
            fi
        fi
        # #1877 (owner 12:31): the verdict on landing the staged mux is
        # the binary's, never this script's — the same Rust land `tenax
        # update` and the app run. #2291: the land is side by side and
        # never restarts sessions, so nothing is asked. #2292: $DIR is
        # user-writable by construction — the gate runs as the user,
        # seeing the user's own mux.
        if [ -e "$DIR/tenax-tmux.update" ]; then
            muxe="$BIN install-mux"
            muxrun() { "$BIN" install-mux; }
            mrc=0
            muxrun </dev/null || mrc=$?
            [ "$mrc" = 0 ] || err "install-mux failed (exit $mrc) — sessions stay on the current mux; rerun \`$muxe\`"
        fi
    fi
    if [ -z "$bundled" ] && ! command -v tmux >/dev/null 2>&1; then
        say ""
        say "note: the host needs tmux — sessions live in it (#90)."
        if [ "$plat" = darwin ]; then
            say "      install it first: brew install tmux"
        else
            say "      install it first, e.g. sudo apt install tmux"
            say "      ('tenax service install' also offers your package manager)"
        fi
    fi
    say ""
    # #1046: one step — under `curl | sh` stdin is the script, but a
    # terminal on stdout + a readable /dev/tty is the interactive
    # case, so run the service install right away with the tty as its
    # stdin. The sign-in code it prints is the confirmation. A failed
    # or refused install (Ctrl-C, missing tmux) does not fail the
    # binary install — the next-step line prints instead.
    if [ -z "${TENAX_NO_SERVICE:-}" ] && [ -t 1 ] && [ -r /dev/tty ]; then
        if "$BIN" service install </dev/tty; then
            say "done — the host runs as a service and shows up in the Tenax app"
        else
            next
        fi
    else
        next
    fi
}

main "$@"
