#!/bin/sh
# code-index installer AND updater. One script, both directions.
#
#   curl -sSfL https://git.h-dv.de/h-dv/code-index/raw/branch/master/install.sh | sh
#   sh install.sh --check                 # what is installed, what is published, install nothing
#   sh install.sh --tag v0.28.0           # a specific release
#   sh install.sh --prefix "$HOME/.local" # somewhere else
#
# WHY A SCRIPT AND NOT `code-index self-update`
#
# A self-update cannot install the FIRST copy, so it can only ever be
# the second half of a mechanism. This is the whole mechanism: the
# install path and the update path are the same code, which is why an
# update cannot rot while installs keep working.
#
# WHAT IT REFUSES TO DO, and each refusal is a measured hazard:
#
#   * Install bytes it has not verified. The `.sha256` sidecar
#     published beside every archive is downloaded and checked. If
#     NEITHER `sha256sum` NOR `shasum` is on PATH the script REFUSES —
#     it does not skip the check and carry on, because a verification
#     that silently does not run is worse than none: it reads as done.
#   * Guess a platform. An unrecognised `uname` refuses and prints what
#     it saw. macOS refuses by NAME, citing #59, because no macOS
#     archive is published and a Linux binary placed on a Mac fails
#     later, somewhere less obvious.
#   * Copy a partial set. All four binaries are confirmed present in
#     the unpacked archive BEFORE the first one is installed, so a
#     truncated archive cannot leave three new binaries beside one old.
#   * Touch a running daemon. It reports one if it finds one and tells
#     you the command; it never signals a process it did not start.
#
# POSIX sh on purpose: this runs on whatever the machine has, including
# an Alpine image with no bash.

set -eu

REPO_URL="https://git.h-dv.de/h-dv/code-index"
API_URL="https://git.h-dv.de/api/v1/repos/h-dv/code-index"
BINARIES="code-index code-index-daemon code-index-mcp code-index-plugin-host"

TAG=""
PREFIX=""
CHECK_ONLY=0
# A seam, not test scaffolding: it also serves a mirror or an
# air-gapped copy of the release assets. The e2e suite points it at a
# local directory, which is the only way to grade the TAMPERED-archive
# and missing-checksum-tool refusals without a network.
BASE_URL=""
WITH_PLUGINS=""

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

usage() {
    cat <<EOF
code-index installer and updater.

  --tag <vX.Y.Z>   install this release (default: the latest published)
  --prefix <dir>   install into <dir>/bin (default: /usr/local if
                   writable, else \$HOME/.local)
  --check          report installed vs published and exit; writes nothing
  --with-plugin <id> install this plugin from the registry into your
                   package store. It GRANTS NOTHING and enables nothing;
                   the "code-index plugin add <id>" line printed at the
                   end is the human decision this cannot make for you
  --base-url <url> fetch assets from here instead of the release store
  -h, --help       this text

Installs: $BINARIES
EOF
}

# An EMPTY value is not "not given", and the difference matters: with
# `--tag ""` the emptiness test below could not tell the two apart and
# silently installed whatever was latest, which is the opposite of what
# a caller passing `--tag "$VER"` with `VER` unset is asking for.
need() { [ -n "$2" ] || die "$1 was given an EMPTY value. That is not the same as
  omitting it — omitting $1 uses the default, and an empty value is
  almost always an unset variable in the calling script. Refusing
  rather than guessing which you meant."; }

while [ $# -gt 0 ]; do
    case "$1" in
        --tag)      [ $# -ge 2 ] || die "--tag needs a value"; need --tag "$2"; TAG="$2"; shift 2 ;;
        --prefix)   [ $# -ge 2 ] || die "--prefix needs a value"; need --prefix "$2"; PREFIX="$2"; shift 2 ;;
        --base-url) [ $# -ge 2 ] || die "--base-url needs a value"; need --base-url "$2"; BASE_URL="$2"; shift 2 ;;
        --with-plugin) [ $# -ge 2 ] || die "--with-plugin needs a value"; need --with-plugin "$2"; WITH_PLUGINS="${WITH_PLUGINS:+$WITH_PLUGINS }$2"; shift 2 ;;
        --check)    CHECK_ONLY=1; shift ;;
        -h|--help)  usage; exit 0 ;;
        *)          die "unknown argument \`$1\`. --help lists what this takes." ;;
    esac
done

# ── the platform table ────────────────────────────────────────────────
#
# THIS TABLE IS GRADED. `crates/cli/tests/installer_targets.rs` derives
# the PUBLISHED set from the matrices in `.forgejo/workflows/release.yml`
# and the INSTALLABLE set from the `slug=` lines below. So a FIFTH target
# added to the release is red until it is accounted for here, and a
# target dropped from the release is red until it leaves — with no list
# anywhere for the two to agree with each other about.
#
# The rule is not equality: THIS script installs three of the four
# published archives. The fourth, `windows-x86_64`, is installed by
# `install.ps1` — a POSIX `sh` has no business half-unpacking a `.zip`
# onto a machine whose shell it is not. So the gate's INSTALLABLE set is
# the union of BOTH scripts' `slug=` assignments, parsed by one parser,
# and this file's Windows arm refuses by pointing at the other script
# rather than at a manual download.
#
# `NOT_INSTALLABLE` USED TO LIVE HERE and is deliberately gone. It was
# the declared waiver for `windows-x86_64`, the one target nothing
# installed; with a native installer published, a slug that is both
# installed and waived is a table that contradicts itself, and
# `installer_targets.rs` has an arm that says so. The waiver list is not
# stubbed out to an empty string either: an empty declaration nothing
# reads is the dead text the arm exists to prevent. The parser still
# looks for `NOT_INSTALLABLE=`, so re-declaring one is a one-line change
# the day a fifth target is published and refused.
#
# The parser reads the EXECUTABLE lines: `slug=<os>-<arch>; ext=<ext>`
# assignments, here and in `install.ps1`, and `NOT_INSTALLABLE` if one
# is ever declared again. All match release.yml's
# `ARCHIVE_NAME="code-index-${TAG}-${os}-${arch}"` plus `.<ext>`.

detect_platform() {
    _os=$(uname -s 2>/dev/null || echo unknown)
    _arch=$(uname -m 2>/dev/null || echo unknown)

    case "$_os" in
        Linux) ;;
        Darwin)
            die "macOS is not published. There is no macOS archive and no macOS
  job in CI — deliberately, see $REPO_URL/issues/59. The source
  carries macOS-only paths (the FSEvents watcher, the daemon's stale-PID
  and flock handling) that nothing compiles or runs, so a build is not a
  build anyone has stood behind. Installing a Linux binary here would
  fail later and less clearly. Build from source if you need it today:
  \`cargo build --release\`." ;;
        MINGW*|MSYS*|CYGWIN*|Windows_NT)
            die "this is a POSIX script and Windows ships a .zip, not a .tar.gz.
  USE THE NATIVE INSTALLER — it is published now, and it does on Windows
  exactly what this script does here (install AND update, verified
  against the .sha256 sidecar, all four binaries or none):

    irm $REPO_URL/raw/branch/master/install.ps1 | iex

  To pass arguments, save it first and run it with the same flags this
  script takes, spelled PowerShell-style:

    irm $REPO_URL/raw/branch/master/install.ps1 -OutFile install.ps1
    powershell -ExecutionPolicy Bypass -File .\\install.ps1 -Check

  This refusal stays because a POSIX shell is still the wrong place to
  unpack that archive; what changed is that there is somewhere right to
  send you. If you are here from WSL and meant to install the LINUX
  build, run this script from inside the WSL distribution instead — a
  \`uname\` of MINGW/MSYS/CYGWIN means the Windows side." ;;
        *)
            die "unrecognised operating system \`$_os\`. Published: linux
  (x86_64, x86_64-musl, aarch64) and windows (x86_64). Nothing was
  installed — a guess here installs a binary that cannot run." ;;
    esac

    case "$_arch" in
        x86_64|amd64)
            # A glibc binary on a musl system fails at exec with a
            # message about a missing interpreter, which reads like a
            # corrupt download. Decide it HERE, where we can say why.
            if is_musl; then
                slug=linux-x86_64-musl; ext=tar.gz
                LIBC_NOTE="musl detected ($LIBC_EVIDENCE), using the static build"
            else
                slug=linux-x86_64; ext=tar.gz
                LIBC_NOTE="glibc assumed ($LIBC_EVIDENCE)"
            fi ;;
        aarch64|arm64)
            slug=linux-aarch64; ext=tar.gz ;;
        *)
            die "unrecognised architecture \`$_arch\` on Linux. Published:
  x86_64 (glibc and musl) and aarch64." ;;
    esac
}

# Set by `is_musl` to the probe that actually decided, so the header can
# tell a MEASUREMENT from a GUESS. Only the musl branch used to say
# anything, which left the documented no-evidence fallback — "glibc is
# the larger population" — completely invisible: on a musl box with
# neither `ldd` nor `getconf` it downloaded the glibc archive and said
# nothing about why.
LIBC_EVIDENCE="not probed"

is_musl() {
    # ASK WHAT THIS SYSTEM LINKS, NOT WHAT IS INSTALLED ON IT.
    #
    # The obvious probe — look for `/lib/ld-musl-*` — is WRONG, and it
    # was wrong here before it was fixed: that file is present on any
    # machine carrying the musl cross toolchain, which includes an
    # ordinary glibc dev box with `musl-tools` installed AND this
    # project's own release container (release.yml installs `musl-tools`
    # to cross-build the static archive). It detects a toolchain, not a
    # libc, and it announced "musl detected" on a glibc Ubuntu host.
    #
    # So the probe reads the interpreter of a binary the system must
    # have and must have linked its own way. On glibc that names
    # `ld-linux-*`; on Alpine, `ld-musl-*`.
    _sh=$(command -v sh 2>/dev/null || echo /bin/sh)
    if (ldd "$_sh" 2>&1 || true) | grep -q 'ld-musl\|musl libc'; then
        LIBC_EVIDENCE="ldd $_sh names a musl interpreter"
        return 0
    fi
    if (ldd "$_sh" 2>&1 || true) | grep -q 'ld-linux\|libc\.so\.6'; then
        LIBC_EVIDENCE="ldd $_sh names a glibc interpreter"
        return 1
    fi
    if (ldd --version 2>&1 || true) | head -n 1 | grep -qi musl; then
        LIBC_EVIDENCE="ldd --version says musl"
        return 0
    fi
    # A working `getconf GNU_LIBC_VERSION` is positive evidence OF glibc,
    # so it settles the question in the other direction.
    if command -v getconf >/dev/null 2>&1 && getconf GNU_LIBC_VERSION >/dev/null 2>&1; then
        LIBC_EVIDENCE="getconf GNU_LIBC_VERSION answered"
        return 1
    fi
    # No evidence either way. glibc is the larger population, and this
    # guess now SAYS it is a guess — the staged-binary check will catch
    # it before anything is replaced.
    LIBC_EVIDENCE="NO EVIDENCE — neither ldd nor getconf answered; this is a GUESS"
    return 1
}

# ── fetching ──────────────────────────────────────────────────────────

have() { command -v "$1" >/dev/null 2>&1; }

fetch() {
    # fetch <url> <dest>. Fails loudly; never leaves a partial dest.
    _url="$1"; _dest="$2"
    if have curl; then
        curl -sSfL "$_url" -o "$_dest" || return 1
    elif have wget; then
        wget -q -O "$_dest" "$_url" || return 1
    else
        die "neither curl nor wget is on PATH, so nothing can be downloaded."
    fi
}

fetch_stdout() {
    _url="$1"
    if have curl; then
        curl -sSfL "$_url"
    elif have wget; then
        wget -q -O - "$_url"
    else
        die "neither curl nor wget is on PATH, so nothing can be downloaded."
    fi
}

latest_tag() {
    # jq is not assumed. The field is extracted with sed, and an
    # unparseable answer REFUSES rather than yielding an empty tag —
    # an empty tag builds a URL that 404s, and a 404 three steps later
    # is a much worse error message than this one.
    _json=$(fetch_stdout "$API_URL/releases/latest" 2>/dev/null || true)
    [ -n "$_json" ] || die "could not reach $API_URL/releases/latest.
  Pass --tag <vX.Y.Z> to name a release directly."
    # `sed 's/.*"tag_name"…'` is GREEDY and takes the LAST occurrence on
    # the line, so a release body quoting `"tag_name":"…"` after the real
    # field would win. Matching the field itself and taking the FIRST one
    # removes that.
    _tag=$(printf '%s' "$_json" \
        | grep -o '"tag_name" *: *"[^"]*"' \
        | head -n 1 \
        | sed 's/.*"\([^"]*\)"$/\1/')
    [ -n "$_tag" ] || die "the release API answered, but no \`tag_name\` could be read
  out of it. Refusing to continue with an empty tag. Pass
  --tag <vX.Y.Z> to name a release directly."
    printf '%s' "$_tag"
}

verify_sha256() {
    # verify_sha256 <file> <sidecar>. The sidecar is sha256sum's own
    # format: "<hex>  <name>". We compare the HEX ONLY, because the
    # name in the sidecar is the name at publish time and the local
    # file may sit in a temp directory under a different one — a
    # `sha256sum -c` here fails on the path and reads as a corrupt
    # download.
    _file="$1"; _side="$2"
    _want=$(sed -n 's/^\([0-9a-fA-F]\{64\}\).*/\1/p' "$_side" | head -n 1)
    [ -n "$_want" ] || die "the published .sha256 sidecar does not START with a 64-character
  hex digest, so there is nothing to verify against. Refusing.
  Expected \`sha256sum\` format — \`<64 hex>  <filename>\` — which is what
  this project publishes and what \`shasum -a 256\` prints by default.
  A BSD tagged line (\`SHA256 (file) = <hex>\`) carries the digest in a
  place this does not read; re-generate the sidecar, or verify by hand.
  What the file starts with: \`$(head -c 80 "$_side" | tr -d '\\n')\`"

    if have sha256sum; then
        _got=$(sha256sum "$_file" | cut -d' ' -f1)
    elif have shasum; then
        _got=$(shasum -a 256 "$_file" | cut -d' ' -f1)
    else
        die "neither sha256sum nor shasum is on PATH, so the archive CANNOT be
  verified. Refusing to install unverified bytes — a check that
  silently does not run is worse than none, because it reads as
  having run. Install coreutils (or perl's shasum) and re-run."
    fi

    # Case-fold both sides: BSD tools have printed upper-case hex.
    _want=$(printf '%s' "$_want" | tr 'A-F' 'a-f')
    _got=$(printf '%s' "$_got"  | tr 'A-F' 'a-f')
    [ "$_want" = "$_got" ] || die "THE ARCHIVE DOES NOT MATCH ITS PUBLISHED CHECKSUM.
  published $_want
  downloaded $_got
  Nothing was installed. Re-run to rule out a truncated download; if
  it persists, do not install these bytes."
    say "sha256 verified: $_got"
}

installed_version() {
    # The version of the code-index already on PATH at the prefix, or
    # empty. `--version` prints `code-index X.Y.Z (hash)`.
    _bin="$1"
    [ -x "$_bin" ] || return 0
    "$_bin" --version 2>/dev/null | awk '{print $2}' | head -n 1
}

# ── main ──────────────────────────────────────────────────────────────

detect_platform

if [ -z "$PREFIX" ]; then
    # /usr/local when we can actually write it, which is the difference
    # between "root" and "a sudo the operator has not granted us".
    if [ -w /usr/local/bin ] 2>/dev/null || { [ -w /usr/local ] && [ ! -e /usr/local/bin ]; }; then
        PREFIX=/usr/local
    else
        PREFIX="$HOME/.local"
    fi
fi
# An ABSOLUTE prefix, because the `export PATH=` line at the end is
# advice the operator pastes into a different shell in a different
# directory, where `rp/bin` means nothing.
case "$PREFIX" in
    /*) ;;
    *)  PREFIX="$(pwd)/$PREFIX" ;;
esac
BINDIR="$PREFIX/bin"

if [ -z "$TAG" ]; then
    # `latest_tag` asks the release API, and that URL is NOT derived from
    # `--base-url`. So a mirror or air-gapped copy could serve every
    # asset and this step would still reach out to git.h-dv.de — measured:
    # `--base-url file://…` with no `--tag` resolved the real latest tag
    # over the network. Refusing says so instead of doing it quietly.
    [ -z "$BASE_URL" ] || die "--base-url was given without --tag.
  The archive would come from your base-url but the LATEST TAG would
  still be looked up at $API_URL, which defeats the point of a mirror
  and reaches the network on a machine that may have none. Name the
  release you want: --tag vX.Y.Z"
    TAG=$(latest_tag)
fi
# The tag reaches a URL, the `-o` destination and the unpacked directory
# name. `case v*` checked only the FIRST CHARACTER, so `v../../../x` was
# accepted and libcurl silently normalised the `..` out of the URL —
# leaving the release directory. Nothing was writable outside $TMP in
# practice, but a value that reaches three different interpolations is
# not somewhere to rely on a coincidence.
case "$TAG" in
    *[!A-Za-z0-9.+_-]*)
        die "--tag \`$TAG\` contains a character that is not a letter, digit,
  dot, plus, underscore or hyphen. It is interpolated into a download
  URL, a local file path and a directory name, so anything else is
  refused rather than normalised by whichever layer sees it first." ;;
esac
case "$TAG" in
    *..*)
        die "--tag \`$TAG\` contains \`..\`. Refusing: it would traverse out of
  the release directory in the URL and out of the temporary directory
  on disk." ;;
esac
case "$TAG" in
    v*) ;;
    *)  die "--tag wants the TAG, which carries a leading \`v\` (\`v0.28.0\`).
  Got \`$TAG\`. The archive name embeds the tag verbatim, while the
  plugin asset path segment strips the \`v\` — the two are
  deliberately different and are measured in README.md." ;;
esac

ARCHIVE="code-index-${TAG}-${slug}.${ext}"
if [ -n "$BASE_URL" ]; then
    ASSET_BASE="$BASE_URL"
else
    ASSET_BASE="$REPO_URL/releases/download/$TAG"
fi

HAVE=$(installed_version "$BINDIR/code-index")
WANT=${TAG#v}

say "code-index installer"
say "  platform   $slug"
[ -z "${LIBC_NOTE:-}" ] || say "  libc       $LIBC_NOTE"
say "  release    $TAG"
say "  prefix     $BINDIR"
if [ -n "$HAVE" ]; then
    say "  installed  $HAVE"
else
    say "  installed  (nothing at $BINDIR/code-index)"
fi

if [ "$CHECK_ONLY" -eq 1 ]; then
    if [ "$HAVE" = "$WANT" ]; then
        say "up to date."
    elif [ -z "$HAVE" ]; then
        say "not installed. Re-run without --check to install $WANT."
    else
        say "an update is available: $HAVE -> $WANT."
        say "Re-run without --check to install it."
    fi
    exit 0
fi

if [ "$HAVE" = "$WANT" ]; then
    say "already at $WANT — nothing to do."
    say "(\`--tag\` installs a specific release, including an older one.)"
    exit 0
fi

TMP=$(mktemp -d 2>/dev/null || mktemp -d -t code-index)
# Cleans up on success, failure and interrupt alike, so a refused
# install leaves nothing behind to be found later and trusted.
trap 'rm -rf "$TMP"' EXIT HUP INT TERM

say "downloading $ARCHIVE"
fetch "$ASSET_BASE/$ARCHIVE"        "$TMP/$ARCHIVE"        || die "could not download $ASSET_BASE/$ARCHIVE"
fetch "$ASSET_BASE/$ARCHIVE.sha256" "$TMP/$ARCHIVE.sha256" || die "the archive downloaded but its .sha256 sidecar did not
  ($ASSET_BASE/$ARCHIVE.sha256). Refusing to install bytes that
  cannot be verified."

verify_sha256 "$TMP/$ARCHIVE" "$TMP/$ARCHIVE.sha256"

say "unpacking"
( cd "$TMP" && tar -xzf "$ARCHIVE" ) || die "the archive verified but could not be unpacked."

# The archive holds `code-index-<tag>-<slug>/`, per release.yml.
SRC="$TMP/code-index-${TAG}-${slug}"
[ -d "$SRC" ] || die "the archive unpacked but does not contain the expected
  directory \`code-index-${TAG}-${slug}/\`. Nothing was installed."

# ALL FOUR, BEFORE ANY — and a MEMBER MUST BE A REGULAR FILE.
#
# `-f` alone FOLLOWS SYMLINKS, so an archive whose `code-index-mcp` is a
# symlink to `/etc/passwd` passed this check and then installed the
# host's own bytes as a mode-755 file on PATH. The default prefix is
# /usr/local, so that is a root-owned file. `! -L` is the whole fix and
# it is checked FIRST, because `-f` on a dangling symlink is false for
# the wrong reason.
for b in $BINARIES; do
    if [ -L "$SRC/$b" ]; then
        die "the archive's \`$b\` is a SYMLINK, not a regular file. Refusing: a
  symlink is resolved against THIS machine, so it would install local
  bytes under a name that is supposed to be ours. Nothing was
  installed."
    fi
    [ -f "$SRC/$b" ] || die "the archive is missing \`$b\`. Expected all four of:
  $BINARIES
  Nothing was installed."
done

mkdir -p "$BINDIR" || die "could not create $BINDIR."

# ── STAGE, VERIFY, THEN COMMIT ──────────────────────────────────────
#
# THE PREVIOUS SHAPE COULD LEAVE A MIXED SET, and its comment claimed
# otherwise. Checking the ARCHIVE's four members says nothing about the
# four WRITES: with the third write failing, two new binaries were
# measured sitting beside two old ones, which is precisely the state the
# comment said was impossible.
#
# It also rested on a false claim about the tools. MEASURED on coreutils
# 9.4: `install` UNLINKS AND CREATES when the target is running (inode
# changes) and TRUNCATES IN PLACE when it is not — never temp+rename —
# so a disk-full mid-write leaves a truncated, still-executable binary.
# And the `cp` fallback fails outright with `Text file busy` against a
# running binary, which for this product is the ORDINARY state: the
# daemon is what the script warns about at the end.
#
# So the four binaries are staged in a directory INSIDE $BINDIR — same
# filesystem, by construction — verified there, and only then renamed
# into place. `mv` within one directory is a rename: it cannot fail for
# want of space, it replaces a RUNNING binary safely (the old inode
# stays alive for processes already using it), and the directory check
# below removes the one case that can fail.
STAGE="$BINDIR/.code-index-install.$$"
rm -rf "$STAGE"
mkdir -p "$STAGE" || die "could not create a staging directory in $BINDIR.
  Nothing was installed and nothing was changed."
# Extend the cleanup: a refusal below must leave no staging directory
# behind for someone to find later and trust.
trap 'rm -rf "$TMP" "$STAGE"' EXIT HUP INT TERM

for b in $BINARIES; do
    cp "$SRC/$b" "$STAGE/$b" || die "could not stage $b in $BINDIR.
  NOTHING was installed: the staging directory is removed and any
  existing install is untouched."
    chmod 755 "$STAGE/$b" || die "could not make the staged $b executable.
  NOTHING was installed."
done

# VERIFY BEFORE COMMITTING, not after. Running the STAGED binary means a
# build that cannot execute here — the wrong libc being the case this
# script tries hardest to get right — is caught while the existing
# install is still whole. The old shape ran this check after
# overwriting all four, so its only outcome was a bricked install with
# no rollback, and it blamed PATH shadowing for a failure it had just
# invoked by absolute path.
STAGED=$("$STAGE/code-index" --version 2>/dev/null | awk '{print $2}' | head -n 1)
if [ "$STAGED" != "$WANT" ]; then
    die "the downloaded code-index does not run here, so NOTHING was installed
  and your existing install is untouched.
    \`$STAGE/code-index --version\` said: \`${STAGED:-<no output>}\`
    expected: \`$WANT\`
  The archive verified against its published checksum, so these are the
  right bytes for \`$slug\` — which points at the platform choice
  rather than the download. If this machine's libc is not what
  \`$slug\` assumes, install the static build explicitly:
    sh install.sh --tag $TAG --prefix $PREFIX   # on a musl system, see --help"
fi

# A DESTINATION THAT IS A DIRECTORY, checked before the first rename.
# `install`/`cp` treat `dst` as a directory and write INSIDE it, which
# reported four successful installs and exit 0 while leaving the MCP
# server unreachable at `$BINDIR/code-index-mcp/code-index-mcp`. It is
# also the only way the renames below can fail, so checking it here is
# what makes the commit loop all-or-nothing in practice.
for b in $BINARIES; do
    [ -d "$BINDIR/$b" ] && die "$BINDIR/$b is a DIRECTORY, not a file. Refusing: writing
  into it would leave \`$b\` unreachable while reporting success.
  Remove it and re-run. Nothing was installed."
done

for b in $BINARIES; do
    mv -f "$STAGE/$b" "$BINDIR/$b" || die "could not move the staged $b into place.
  Some binaries may already have been replaced — re-run this installer
  to finish, or reinstall the previous release with --tag."
    say "  installed $b"
done
rmdir "$STAGE" 2>/dev/null || true

# A TRIPWIRE, AND IT IS UNGRADED — said plainly rather than left to
# look like a tested guard. The staged copy already answered correctly a
# moment ago, so the only thing this can catch is something replacing
# the binary between the rename and now: a concurrent installer, or a
# package manager. No test in `installer_e2e.rs` reproduces that, and
# one that faked it would be grading the fake. It stays because it costs
# one exec and the alternative is reporting a version nobody confirmed.
NOW=$(installed_version "$BINDIR/code-index")
[ "$NOW" = "$WANT" ] || die "the four binaries were installed, but
  \`$BINDIR/code-index --version\` now reports \`${NOW:-<no output>}\` rather
  than \`$WANT\`. The staged copy answered correctly moments ago, so
  something replaced it between staging and now."

say ""
say "code-index $NOW installed into $BINDIR"

case ":$PATH:" in
    *":$BINDIR:"*) ;;
    *) say ""
       say "NOTE: $BINDIR is not on your PATH. Add it:"
       say "  export PATH=\"$BINDIR:\$PATH\"" ;;
esac

# A daemon started by the OLD binary keeps answering until it is
# stopped, and its replies fall through wire-skew shims. The product
# discloses that itself (`answer_provenance.daemon_build`), so this is
# a pointer, not a duplicate claim — and we never signal a process we
# did not start.
# `$HOME` UNBOUND ABORTS ONLY THE SUBSHELL, so `set -u` never fired
# here: measured, the script printed `HOME: parameter not set` to stderr
# BETWEEN two success lines and exited 0, with this whole disclosure
# silently skipped. A container or systemd unit with no HOME is the
# ordinary case for that. `${HOME:-}` makes the absence a value, and an
# empty one means "nowhere to look", which is a different sentence from
# "no daemon".
if [ -z "${HOME:-}" ]; then
    say ""
    say "NOTE: \$HOME is not set, so this installer could not check for a running"
    say "daemon. An older build may still be answering; \`code-index doctor\` names it."
elif [ -n "$(find "$HOME" -maxdepth 4 -name 'daemon.lock' -path '*/.code-index/*' -print -quit 2>/dev/null)" ]; then
    say ""
    say "A daemon lockfile exists, so an older build may still be answering."
    say "Its replies carry a \`daemon_build\` skew disclosure until it restarts."
    say "  code-index doctor    # names the pid and the build"
fi

# ── --with-plugin: INSTALL, AND GRANT NOTHING (I066 D15/decision 14) ──
#
# It used to run `plugin add "$p" --grant requested --yes`, and that was
# three defects in one line.
#
#   1. `--yes` REQUIRES `--grant`, so this was a real capability grant —
#      the FIRST one on a fresh machine — made non-interactively inside a
#      `curl | sh`. Issue #269 section 5.2 keeps that a human decision.
#      (Without `--grant` a closed pipe answers No, which is the default
#      this installer should never have overridden.)
#   2. `plugin add` is PROJECT-SCOPED. It approves a package FOR the
#      project it detects from the working directory — whatever directory
#      the installer happened to be run from — and its activation builds
#      that project's index under `.code-index/`. A `curl | sh` has no
#      business choosing a project.
#   3. There was no `|| die`. Under `set -eu` a failure here aborts the
#      script AFTER the four binaries are installed, printing nothing
#      about the state the machine is now in.
#
# `plugin install` is the command that closes all three: it writes bytes
# into the USER-SCOPED store, needs no project, and grants nothing. The
# `plugin add` line below is printed for the human rather than run.
if [ -n "$WITH_PLUGINS" ]; then
    say ""
    say "Installing requested plugin(s) into your package store (no grant is made)..."
    for p in $WITH_PLUGINS; do
        say "  installing $p..."
        "$BINDIR/code-index" plugin install "$p" || die "\`code-index plugin install $p\` failed.
  The four binaries ARE installed in $BINDIR and are answering — this
  failure is about the PLUGIN only, and nothing was granted to any
  project. Re-run just this step once the cause is fixed:
    $BINDIR/code-index plugin install $p"
    done
    say ""
    say "Installed, and GRANTED NOTHING. A package in the store runs no code until a"
    say "project approves it, and approving is yours to do — from inside the project:"
    for p in $WITH_PLUGINS; do
        say "  code-index plugin add $p"
    done
fi

say ""
say "next:  code-index init && code-index index && code-index doctor"
