Install, Update, And Uninstall

Shared memory for your AI coding agents — one command, all local.

This page exists so you can read the installer before you run it. Nothing here pipes anything automatically — copy a line when you're ready.

1Install

macOS & Linux:

# macOS / Linux curl -fsSL https://get.theapiary.sh | sh

Windows (PowerShell):

# Windows PowerShell irm https://get.theapiary.sh/install.ps1 | iex

2Update

macOS & Linux:

# macOS / Linux curl -fsSL https://get.theapiary.sh/update | sh

Windows (PowerShell):

# Windows PowerShell irm https://get.theapiary.sh/update.ps1 | iex

3Uninstall

macOS & Linux:

# macOS / Linux curl -fsSL https://get.theapiary.sh/uninstall | sh

Windows (PowerShell):

# Windows PowerShell irm https://get.theapiary.sh/uninstall.ps1 | iex

4Verify before you run

Prefer to check the bytes first? Download the script and its checksum, verify, then run:

# download + verify + run (macOS / Linux) curl -fsSL -O https://get.theapiary.sh/install.sh curl -fsSL -O https://get.theapiary.sh/update.sh curl -fsSL -O https://get.theapiary.sh/uninstall.sh curl -fsSL -O https://get.theapiary.sh/SHA256SUMS sha256sum -c --ignore-missing SHA256SUMS # expect: install.sh: OK, update.sh: OK, uninstall.sh: OK sh install.sh

On macOS without sha256sum, use shasum -a 256 -c --ignore-missing SHA256SUMS. (--ignore-missing skips the checksum entries for scripts you did not download, such as the PowerShell twins.)

5Published checksums

These are the SHA-256 of the exact files this domain serves. They change only when a new release is deployed.

66a27aa73c561ed8e49ccf214ffb72ca439ceda42caa3289fae9f12b5f35bf3a install.sh 606bec733579bb3bd1c6b3c2899deb5b5ba29c0af1b9a5a9d62e796cc9324f46 install.ps1 3ec1c93f1c94b428fcbc81b897687952910e258f74473f47bff688bf5e0b5955 uninstall.sh 7b4ab64d1839bbba3d436ea05e0835870aeaa5e1ca4a55df48ed3dbe321105a4 uninstall.ps1 565c8a68dd3ab2d540185d926aa916bdb35fc0b7aaf3801b178f02f2084f656c update.sh 3bca3ca8740dfd1d2084a6f11f009604d787c01cad435ed73848db1a6dd21fe2 update.ps1

Raw files: /install.sh · /install.ps1 · /update · /update.sh · /update.ps1 · /uninstall · /uninstall.sh · /uninstall.ps1 · /SHA256SUMS · source on GitHub

6What install actually does

Everything runs locally on your machine — the daemon and dashboard bind to loopback. The installer is idempotent: re-running it is safe and skips anything already in place.
Advanced: choosing which products to install

By default this installs Honeycomb + the Doctor watchdog. Pass --products= to install more of the fleet, e.g. curl -fsSL https://get.theapiary.sh | sh -s -- --products=honeycomb,hive,nectar. Other flags: --profile=full (a named preset), --code=<code> (a product code resolving to a preset), --dry-run (preview only, changes nothing), and --no-doctor. Environment variables (HONEYCOMB_INSTALL_PRODUCTS, HONEYCOMB_INSTALL_PROFILE, HONEYCOMB_INSTALL_LICENSE, HONEYCOMB_INSTALL_CODE) and a ~/.honeycomb/install.conf file work the same way, for administrators pinning a fleet-wide deploy shape. See the source below for the full, documented precedence.

7Read the source

The full scripts are inline. This is byte-for-byte what the checksums above cover.

install.sh (POSIX sh)
#!/bin/sh
# Honeycomb one-command bootstrap installer (POSIX); PRD-050a, extended by the-apiary PRD-002
# (product loading + install-time telemetry, ADR-0002).
#
# Usage (the single line a brand-new user pastes):
#   curl -fsSL https://get.theapiary.sh | sh
#
# With product selection (PRD-002a):
#   curl -fsSL https://get.theapiary.sh | sh -s -- --products=honeycomb,hive,nectar
#
# Contract (PRD-050a a-AC-1..6): leave the user on a running dashboard, OR tell them in ONE plain
# sentence why not. It assumes the operator knows nothing; no Node, no npm, no idea what a daemon
# is. It is deliberately THIN and IDEMPOTENT: it detects what is already present, installs only what
# is missing, and re-running it is safe.
#
# This script owns the host-bootstrap half: detect/install Node+npm (via fnm + a pinned LTS), then
# `npm i -g @legioncodeinc/honeycomb`, plus (PRD-002b) any other SELECTED product from the fleet
# (doctor / hive / nectar), each resolved to its hive-release.json-pinned version. The
# moment a `honeycomb` bin exists it HANDS OFF to the `honeycomb install` CLI verb for the
# daemon-ensure + health-gate + dashboard-open; so that logic lives ONCE in TypeScript
# (src/commands/install.ts), not duplicated across two shell dialects.
#
# POSIX sh ONLY (no bashisms): this runs under `sh`, which may be dash/ash, not bash.
#
# ═══════════════════════════════════════════════════════════════════════════════════════════════
# PRD-002a; Product loading grammar (documented once, implemented identically in install.ps1)
# ═══════════════════════════════════════════════════════════════════════════════════════════════
#
# Flags (all optional):
#   --products=<slug,slug,...>   e.g. --products=honeycomb,hive,nectar
#                                 Slugs match hive-release.json's product keys exactly:
#                                 honeycomb | doctor | hive | nectar. The pre-rename tokens
#                                 (doctor / hive / hive / nectar) are accepted
#                                 as aliases and normalized, so older invocations keep working.
#   --profile=<name>              A named preset that expands to a --products= list when
#                                 --products= itself was not given. Built-in profiles:
#                                   default -> honeycomb,doctor      (today's fixed behavior)
#                                   full    -> honeycomb,doctor,hive,nectar
#   --license=<key>               An opaque license key, threaded into the resolved selection.
#                                 No backing entitlement service exists yet (ADR-0002 Non-Goal);
#                                 this flag only makes the value resolvable for that future system.
#                                 NEVER sent in telemetry (kept off the phone-home payload).
#   --code=<code>                 A product code that resolves (via the table in
#                                 resolve_code_products/resolve_code_profile below) to a
#                                 products+profile PRESET, standing in for a longer flag
#                                 combination. Example: --code=HONEY-FULL. Unknown/expired code:
#                                 a warning is printed and the code is IGNORED (soft-fail; a
#                                 bad code must never brick the one-line install).
#   --dry-run                     Resolve everything (flags/env/config/code/profile, the pinned
#                                 manifest versions) and PRINT what would happen; performs NO
#                                 mutation (no npm install, no Node bootstrap, no registry write,
#                                 no state write, no real telemetry POST). Added by PRD-002 to make
#                                 this script's resolution logic verifiable without a real install.
#   --no-doctor                   Opt out of the Doctor watchdog (PRD-064b). The pre-rename
#                                 spelling `--no-doctor` stays accepted as an alias.
#
# Environment variable equivalents (read when the matching flag is absent):
#   HONEYCOMB_INSTALL_PRODUCTS, HONEYCOMB_INSTALL_PROFILE, HONEYCOMB_INSTALL_LICENSE,
#   HONEYCOMB_INSTALL_CODE, HONEYCOMB_NO_DOCTOR (pre-rename alias: HONEYCOMB_NO_DOCTOR).
#
# Config file (read when neither the flag nor the env var is set):
#   ~/.honeycomb/install.conf; one `KEY=value` pair per line (no spaces around `=`), `#` comments
#   and blank lines allowed. Keys: PRODUCTS, PROFILE, LICENSE, CODE. Never sourced/executed; only
#   parsed as plain text; so a config file can never inject shell code. This is the seam a repo
#   administrator uses to pin a fleet-wide deploy shape without editing the pasted command.
#
# PRECEDENCE (documented once, applies per-field): explicit flag > environment variable > config
# file > (a --code=/--profile= PRESET fills the products/profile gap only if still unset) > the
# built-in default (`honeycomb,doctor`, i.e. today's behavior, unchanged for anyone who pipes
# this script with no flags at all). `honeycomb` itself is ALWAYS part of the effective product set
# regardless of --products=, because this script IS honeycomb's own bootstrap entry point (it hands
# off to `honeycomb install` for the daemon/dashboard); there is no meaningful "install without
# honeycomb" outcome through this entry point.
#
# Combo/alias URLs (PRD-002a a-AC-4) are OPTIONAL SUGAR handled at the install SITE
# (site/install/functions/index.js), which maps a `?combo=<name>` query parameter to the
# SAME environment-variable inputs this script already reads (HONEYCOMB_INSTALL_PRODUCTS/_PROFILE) ,
# never a separate/parallel resolution mechanism. See that file for the preset table.
#
# ═══════════════════════════════════════════════════════════════════════════════════════════════
# PRD-002c; Install-time telemetry (fired from THIS script, independent of the Node build key)
# ═══════════════════════════════════════════════════════════════════════════════════════════════
# `install_started` fires first (before any flag/env/config resolution; c-AC-1), and exactly one
# of `install_completed` / `install_failed` fires at the terminal state (c-AC-2), via `finish()`.
# Both use a PUBLIC PostHog project key baked into the install SITE (not the Node build key); see
# `phone_home()` below. A stable anonymous install id correlates the two (c-AC-4).
# ═══════════════════════════════════════════════════════════════════════════════════════════════

# `set -e` would abort on the FIRST non-zero command, surfacing a raw error. We instead handle every
# failure explicitly and print a plain-language line (parent AC-7); so `set -e` is intentionally OFF.
set -u

# ─────────────────────────────────────────────────────────────────────────────
# THE ONE PLACE TO BUMP NODE. The single pinned Node LTS the installer provisions
# via fnm. To upgrade the provisioned Node for every new user, change THIS line
# only. (Existing users with a working Node are left untouched; see step 1.)
# ─────────────────────────────────────────────────────────────────────────────
HONEYCOMB_NODE_VERSION="22"

# The published npm package the global install pulls (PRD-048 publishes it; this consumes it).
# PRD-002b: this is the FALLBACK package name only; `install_honeycomb` resolves the ACTUAL
# installed version from the hive-release.json manifest (`resolve_product_target`) when it is
# reachable, falling back to `@latest` only when the manifest itself cannot be resolved.
HONEYCOMB_NPM_PACKAGE="@legioncodeinc/honeycomb"

# Doctor (PRD-064b): a SECOND global package, the self-healing watchdog that keeps the
# primary daemon alive and registers itself with the OS so it survives crashes + reboots. Its
# lifecycle is deliberately INDEPENDENT of the Honeycomb tarball (OD-6: a second global), so it
# is installed here as its own `npm i -g` after the primary, then registers its OS service.
DOCTOR_NPM_PACKAGE="@legioncodeinc/doctor"

# Distribution base URL: the vanity domain that serves this installer surface (PRD-050a follow-up,
# now RESOLVED). get.theapiary.sh is a Cloudflare Pages site (site/install/) that content-negotiates:
# a shell client piping `/` gets this script as text/plain; a browser gets an "inspect before piping"
# page with the PUBLISHED SHA-256 checksums. `${BASE}/install.sh` and `${BASE}/install.ps1` always
# resolve to the raw, checksummed scripts. To verify before running: see https://get.theapiary.sh
HONEYCOMB_INSTALL_BASE_URL="https://get.theapiary.sh"

# ── PRD-001/PRD-002b: the fleet release manifest (the-apiary superproject's hive-release.json). ──
# This installer never hardcodes "latest" for a product it did not itself publish (b-AC-2): it
# resolves each selected product's exact pinned version from THIS manifest. Overridable via env
# purely for local testing against a fork/branch copy of the manifest.
#
# The manifest is served by the install site itself (site/install/build.mjs copies the
# superproject's hive-release.json into the deploy alongside the scripts): the-apiary is a
# PRIVATE repo, so the historical raw.githubusercontent.com URL returns 404 for anonymous
# users. That raw URL is kept below as HONEYCOMB_MANIFEST_FALLBACK_URL, tried once after a
# failed primary fetch (it starts working again if the repo ever goes public).
HONEYCOMB_MANIFEST_URL="${HONEYCOMB_MANIFEST_URL:-https://get.theapiary.sh/hive-release.json}"
HONEYCOMB_MANIFEST_FALLBACK_URL="https://raw.githubusercontent.com/legioncodeinc/the-apiary/main/hive-release.json"

# ── PRD-002c: telemetry destination. The key is EMPTY in source control by design; this exact
# `HONEYCOMB_INSTALL_POSTHOG_KEY=""` line is the one `site/install/build.mjs` patches (via an
# anchored regex on this literal line, never a blind find/replace over the whole file) at deploy
# time, injecting the real PostHog project key (mirrors ADR-0002: "a public PostHog project key
# baked into the install site"). An empty value (any un-built/local/dev copy of this script) makes
# `phone_home` a silent no-op; never a hard failure. See site/install/build.mjs for the build-time
# substitution and site/install/README.md for why a full-file text substitution was rejected (it
# would also corrupt this script's OWN "is telemetry configured" check).
HONEYCOMB_INSTALL_POSTHOG_KEY="phc_wjWdFZfMRtUATshcoBRkZ3FiSMmAKEuVuP6ftraTCiPz"
HONEYCOMB_INSTALL_POSTHOG_HOST="https://us.i.posthog.com"
HONEYCOMB_INSTALL_POSTHOG_PATH="/i/v0/e/"
HONEYCOMB_INSTALL_ID_FILE="${HOME}/.honeycomb/install-id"

# ── PRD-002a: admin config file + PRD-002b: this installer's own bookkeeping of the last-selected
# product set (used ONLY to detect a --products= narrowing between runs, so a removed product's
# doctor registry entry can be cleaned up; see reconcile_removed_products). ──
HONEYCOMB_INSTALL_CONFIG_FILE="${HOME}/.honeycomb/install.conf"
HONEYCOMB_INSTALL_STATE_FILE="${HOME}/.honeycomb/install-state.json"
HONEYCOMB_DOCTOR_REGISTRY_FILE="${HOME}/.honeycomb/doctor.daemons.json"
HIVE_ONBOARDING_DIR="${HOME}/.honeycomb/hive"
HIVE_ONBOARDING_TOKEN_FILE="${HIVE_ONBOARDING_DIR}/onboarding-token"
HIVE_ONBOARDING_BASE_URL="http://127.0.0.1:3853/onboarding"
HIVE_HEALTH_URL="http://127.0.0.1:3853/health"

# ── Globals populated during argv parsing / selection resolution. Declared up-front (all `set -u`
# safe) so every later `${VAR:-...}` reference is well-defined regardless of code path taken. ──
ARG_PRODUCTS=""
ARG_PROFILE=""
ARG_LICENSE=""
ARG_CODE=""
ARG_DRY_RUN=0
DRY_RUN=0
SEL_PRODUCTS=""
SEL_PROFILE=""
SEL_LICENSE=""
SEL_CODE=""
INSTALL_ID=""
IS_REPEAT_INSTALL="false"
EXTRA_PRODUCT_FAILED=0
# Comma list of SELECTED products that did NOT actually land this run (unpublished skip, npm
# install failure, registration failure, or the doctor opt-out). Consumed by
# phone_home_product_transitions so product_installed/product_updated never over-claims.
PRODUCTS_NOT_INSTALLED=""

# ── Friendly progress log: step lines to stdout, the single failure summary to stderr. ──
step()  { printf '→ %s\n' "$1"; }
ok()    { printf '✓ %s\n' "$1"; }
fail()  { printf 'Honeycomb install could not continue: %s\n' "$1" >&2; }

# `command -v` is the POSIX way to test for a binary (NOT `which`, which is not guaranteed present).
have()  { command -v "$1" >/dev/null 2>&1; }

# ═══════════════════════════════════════════════════════════════════════════════════════════════
# PRD-002c; anonymous install id + phone-home
# ═══════════════════════════════════════════════════════════════════════════════════════════════

# Best-effort UUID-shaped id, in decreasing order of "how real a UUID this actually is". This is an
# ANONYMOUS distinct_id (c-AC-4/c-AC-5-adjacent "no PII"): no hostname, username, or MAC address
# ever folds into it, at any fallback tier.
generate_uuid() {
	if have uuidgen; then uuidgen; return 0; fi
	if [ -r /proc/sys/kernel/random/uuid ]; then cat /proc/sys/kernel/random/uuid; return 0; fi
	if have od; then
		hex="$(od -An -N16 -tx1 /dev/urandom 2>/dev/null | tr -d ' \n')"
		if [ "${#hex}" -eq 32 ]; then
			printf '%s-%s-4%s-%s-%s\n' \
				"$(printf '%s' "$hex" | cut -c1-8)" \
				"$(printf '%s' "$hex" | cut -c9-12)" \
				"$(printf '%s' "$hex" | cut -c14-16)" \
				"$(printf '%s' "$hex" | cut -c17-20)" \
				"$(printf '%s' "$hex" | cut -c21-32)"
			return 0
		fi
	fi
	# Last-resort fallback (no uuidgen/od/procfs available): not a spec-compliant UUID, but still
	# unique-enough and carries no machine/user identity; better than losing correlation entirely.
	printf 'nohd-%s-%s\n' "$(date +%s 2>/dev/null || echo 0)" "$$"
}

# Resolve (or, outside --dry-run, mint + persist) the stable anonymous install id (c-AC-4). Sets
# INSTALL_ID and IS_REPEAT_INSTALL ("true" iff the id file already existed before this run). In
# --dry-run mode this NEVER writes: an ephemeral id is generated in-memory purely for the preview,
# so repeated dry-run invocations leave zero residue on disk.
resolve_install_id() {
	if [ -f "$HONEYCOMB_INSTALL_ID_FILE" ] && [ -s "$HONEYCOMB_INSTALL_ID_FILE" ]; then
		INSTALL_ID="$(cat "$HONEYCOMB_INSTALL_ID_FILE" 2>/dev/null | tr -d '\n')"
		IS_REPEAT_INSTALL="true"
		return 0
	fi
	INSTALL_ID="$(generate_uuid)"
	IS_REPEAT_INSTALL="false"
	if [ "$DRY_RUN" -ne 1 ]; then
		mkdir -p "$(dirname "$HONEYCOMB_INSTALL_ID_FILE")" 2>/dev/null
		printf '%s\n' "$INSTALL_ID" > "$HONEYCOMB_INSTALL_ID_FILE" 2>/dev/null || true
	fi
}

# Fire ONE PostHog capture event (c-AC-1/c-AC-2). FAIL-SOFT + BOUNDED (`--max-time 3`): a slow or
# unreachable ingest endpoint never hangs or breaks the install (ADR-0002 "never delays or breaks
# the install"). Uses the SAME capture endpoint + body shape as the Node-side chokepoint
# (`src/daemon/runtime/telemetry/emit.ts`: `{ api_key, event, distinct_id, properties }` posted to
# `${host}/i/v0/e/`) for consistency, but is otherwise fully INDEPENDENT of it; this only needs
# `curl`, so it fires even before Node/npm exist (the entire point of ADR-0002: the transport
# survives a keyless Node build AND an install that fails before the Node CLI ever runs).
#
# The payload is deliberately minimal and allow-list-shaped (no PII, and `--license=`/`--code=`
# values are NEVER included): products, profile, coarse OS family, repeat-vs-first, and the event
# name itself (doubling as a coarse terminal-status label).
# phone_home <event> [product]
# The optional second arg is the per-product transition payload field (product_installed /
# product_updated / product_removed each name the ONE product they describe); when present it is
# appended to the properties as `"product":"<slug>"` alongside the existing run-level fields.
phone_home() {
	event="$1"
	product="${2:-}"
	# --dry-run ALWAYS previews what would be sent, even against an un-substituted placeholder key
	# (a local/dev checkout); this is the one seam PRD-002's verification story depends on, so the
	# key-guard below only gates the REAL network send, never the dry-run preview.
	if [ "$DRY_RUN" -eq 1 ]; then
		if [ -n "$product" ]; then
			printf '[dry-run] would phone home: %s (product=%s, install_id=%s, repeat=%s, products=%s, profile=%s)\n' \
				"$event" "$product" "${INSTALL_ID:-unknown}" "$IS_REPEAT_INSTALL" "${SEL_PRODUCTS:-<unresolved>}" "${SEL_PROFILE:-<none>}"
		else
			printf '[dry-run] would phone home: %s (install_id=%s, repeat=%s, products=%s, profile=%s)\n' \
				"$event" "${INSTALL_ID:-unknown}" "$IS_REPEAT_INSTALL" "${SEL_PRODUCTS:-<unresolved>}" "${SEL_PROFILE:-<none>}"
		fi
		return 0
	fi
	[ -z "$HONEYCOMB_INSTALL_POSTHOG_KEY" ] && return 0
	have curl || return 0
	product_prop=""
	[ -n "$product" ] && product_prop="$(printf ',"product":"%s"' "$product")"
	body=$(printf '{"api_key":"%s","event":"%s","distinct_id":"%s","properties":{"products":"%s","profile":"%s","os":"%s","repeat_install":"%s"%s}}' \
		"$HONEYCOMB_INSTALL_POSTHOG_KEY" "$event" "${INSTALL_ID:-unknown}" "${SEL_PRODUCTS:-}" "${SEL_PROFILE:-}" \
		"$(uname -s 2>/dev/null || echo unknown)" "$IS_REPEAT_INSTALL" "$product_prop")
	curl -fsS --max-time 3 -H 'Content-Type: application/json' -d "$body" \
		"${HONEYCOMB_INSTALL_POSTHOG_HOST}${HONEYCOMB_INSTALL_POSTHOG_PATH}" >/dev/null 2>&1 || true
	return 0
}

# Record that a SELECTED product did not actually land this run (see PRODUCTS_NOT_INSTALLED).
mark_product_not_installed() {
	PRODUCTS_NOT_INSTALLED="${PRODUCTS_NOT_INSTALLED}${PRODUCTS_NOT_INSTALLED:+,}$1"
}

# ═══════════════════════════════════════════════════════════════════════════════════════════════
# PRD-002a; flag / env / config-file / code / profile resolution
# ═══════════════════════════════════════════════════════════════════════════════════════════════

# `--code=<code>` → a products PRESET (a-AC-2). Unrecognized code: caller treats a non-zero return
# as "ignore the code, warn, keep going" (soft-fail; never brick the one-line install over a typo).
resolve_code_products() {
	case "$1" in
		HONEY-FULL) printf '%s' "honeycomb,doctor,hive,nectar" ;;
		*) return 1 ;;
	esac
}

# `--code=<code>` → the profile name it implies (paired 1:1 with resolve_code_products above).
resolve_code_profile() {
	case "$1" in
		HONEY-FULL) printf '%s' "full" ;;
		*) return 1 ;;
	esac
}

# `--profile=<name>` → a products PRESET, used only to fill the products gap when --products=
# itself was not given by any higher-precedence source (flag/env/config).
resolve_profile_products() {
	case "$1" in
		default) printf '%s' "honeycomb,doctor" ;;
		full)    printf '%s' "honeycomb,doctor,hive,nectar" ;;
		*) return 1 ;;
	esac
}

# Normalize a single product token to its canonical slug. The July 2026 repository renames
# (doctor -> doctor, hive -> hive, nectar -> nectar) renamed the slugs with the repos;
# the pre-rename tokens stay accepted as aliases so every documented invocation, config file, and
# previously-written install-state.json keeps working across the rename.
normalize_product_token() {
	case "$1" in
		doctor) printf '%s' "doctor" ;;
		hive|hive) printf '%s' "hive" ;;
		nectar) printf '%s' "nectar" ;;
		*) printf '%s' "$1" ;;
	esac
}

# Normalize a comma list of product tokens (order-preserving; empty stays empty).
normalize_products_list() {
	list="$1"
	[ -n "$list" ] || return 0
	out=""
	old_ifs="$IFS"
	IFS=','
	for tok in $list; do
		IFS="$old_ifs"
		[ -n "$tok" ] || { IFS=','; continue; }
		out="${out}${out:+,}$(normalize_product_token "$tok")"
		IFS=','
	done
	IFS="$old_ifs"
	printf '%s' "$out"
}

# Read one `KEY=value` from the admin config file (a-AC-3). Plain-text parse ONLY; this file is
# NEVER sourced/executed, so it cannot inject shell code. Comments (`#`) and blank lines are
# ignored; the LAST matching `KEY=` line wins (a later line overrides an earlier one, ini-style).
read_config_value() {
	key="$1"
	[ -f "$HONEYCOMB_INSTALL_CONFIG_FILE" ] || return 0
	awk -F'=' -v k="$key" '
		/^[[:space:]]*#/ { next }
		$1 == k { val = substr($0, length($1) + 2) }
		END { if (val != "") print val }
	' "$HONEYCOMB_INSTALL_CONFIG_FILE" 2>/dev/null
}

# Scan argv for the installer-only flags this script consumes itself (a-AC-1/a-AC-5). Does NOT
# mutate or shift the caller's positional params (function-local $@); `--no-doctor` (and its
# pre-rename alias) is deliberately left alone here; `doctor_opted_out` re-scans the
# ORIGINAL "$@" directly, as it already did before PRD-002.
parse_args() {
	for a in "$@"; do
		case "$a" in
			--products=*) ARG_PRODUCTS="${a#--products=}" ;;
			--profile=*)  ARG_PROFILE="${a#--profile=}" ;;
			--license=*)  ARG_LICENSE="${a#--license=}" ;;
			--code=*)     ARG_CODE="${a#--code=}" ;;
			--dry-run)    ARG_DRY_RUN=1 ;;
			--help|-h)    print_usage; exit 0 ;;
		esac
	done
}

print_usage() {
	cat <<'USAGE'
Usage: install.sh [--products=<slug,slug,...>] [--profile=<name>] [--license=<key>]
                   [--code=<code>] [--dry-run] [--no-doctor]

  --products=honeycomb,hive,nectar          select exactly which products to install
  --profile=full                            a named products preset (default | full)
  --license=<key>                           thread a license key through (seam only, PRD-002a)
  --code=HONEY-FULL                         resolve a product code to a products+profile preset
  --dry-run                                 resolve + print, mutate nothing
  --no-doctor                               skip the Doctor watchdog (alias: --no-doctor)

Env equivalents: HONEYCOMB_INSTALL_PRODUCTS / _PROFILE / _LICENSE / _CODE, HONEYCOMB_NO_DOCTOR.
Config file: ~/.honeycomb/install.conf (KEY=value per line: PRODUCTS, PROFILE, LICENSE, CODE).
Precedence: flag > env > config file > code/profile preset (fills gaps only) > built-in default.
USAGE
}

# Any explicit product-selection signal routes to the legacy full-install path.
# PRD-009d seam: flags/env/config with products/profile/code/license => legacy path.
config_expresses_selection() {
	[ -f "$HONEYCOMB_INSTALL_CONFIG_FILE" ] || return 1
	while IFS= read -r line || [ -n "$line" ]; do
		case "$line" in
			''|'#'*) continue ;;
		esac
		key="${line%%=*}"
		case "$key" in
			PRODUCTS|PROFILE|CODE|LICENSE) return 0 ;;
		esac
	done < "$HONEYCOMB_INSTALL_CONFIG_FILE"
	return 1
}

selection_expressed() {
	for a in "$@"; do
		case "$a" in
			--products=*|--profile=*|--code=*|--license=*) return 0 ;;
		esac
	done
	[ -n "${HONEYCOMB_INSTALL_PRODUCTS:-}" ] && return 0
	[ -n "${HONEYCOMB_INSTALL_PROFILE:-}" ] && return 0
	[ -n "${HONEYCOMB_INSTALL_CODE:-}" ] && return 0
	[ -n "${HONEYCOMB_INSTALL_LICENSE:-}" ] && return 0
	config_expresses_selection && return 0
	return 1
}

# Resolve the effective selection (SEL_PRODUCTS/SEL_PROFILE/SEL_LICENSE/SEL_CODE) per the
# documented precedence (a-AC-3): flag > env > config file, then a --code=/--profile= preset fills
# the products gap only if still empty, then the built-in default, then honeycomb is force-included.
resolve_selection() {
	cfg_products="$(read_config_value PRODUCTS)"
	cfg_profile="$(read_config_value PROFILE)"
	cfg_license="$(read_config_value LICENSE)"
	cfg_code="$(read_config_value CODE)"

	SEL_PRODUCTS="${ARG_PRODUCTS:-${HONEYCOMB_INSTALL_PRODUCTS:-${cfg_products:-}}}"
	SEL_PROFILE="${ARG_PROFILE:-${HONEYCOMB_INSTALL_PROFILE:-${cfg_profile:-}}}"
	SEL_LICENSE="${ARG_LICENSE:-${HONEYCOMB_INSTALL_LICENSE:-${cfg_license:-}}}"
	SEL_CODE="${ARG_CODE:-${HONEYCOMB_INSTALL_CODE:-${cfg_code:-}}}"

	# A --code= resolves to a products+profile PRESET, but only FILLS GAPS: an explicit
	# products/profile from a higher-precedence source always wins over what the code implies.
	if [ -n "$SEL_CODE" ]; then
		if code_products="$(resolve_code_products "$SEL_CODE")"; then
			[ -z "$SEL_PRODUCTS" ] && SEL_PRODUCTS="$code_products"
			[ -z "$SEL_PROFILE" ] && SEL_PROFILE="$(resolve_code_profile "$SEL_CODE")"
		else
			printf 'note: unrecognized --code=%s (ignoring; falling back to products/profile/defaults).\n' "$SEL_CODE"
		fi
	fi

	# A --profile= resolves to a products PRESET, filling the gap only when still unset.
	if [ -z "$SEL_PRODUCTS" ] && [ -n "$SEL_PROFILE" ]; then
		if profile_products="$(resolve_profile_products "$SEL_PROFILE")"; then
			SEL_PRODUCTS="$profile_products"
		else
			printf 'note: unrecognized --profile=%s (ignoring; falling back to the default product set).\n' "$SEL_PROFILE"
		fi
	fi

	# Built-in default: today's fixed behavior, preserved byte-for-byte for anyone piping this
	# installer with no flags at all.
	[ -z "$SEL_PRODUCTS" ] && SEL_PRODUCTS="honeycomb,doctor"

	# Pre-rename tokens (doctor/hive/hive/nectar) normalize to the canonical slugs.
	SEL_PRODUCTS="$(normalize_products_list "$SEL_PRODUCTS")"

	# honeycomb is ALWAYS part of the effective set; see the header comment for why.
	case ",$SEL_PRODUCTS," in
		*,honeycomb,*) : ;;
		*) SEL_PRODUCTS="honeycomb,${SEL_PRODUCTS}" ;;
	esac
}

# ═══════════════════════════════════════════════════════════════════════════════════════════════
# PRD-002b; resolve a product's pinned version from hive-release.json
# ═══════════════════════════════════════════════════════════════════════════════════════════════

_MANIFEST_JSON=""
_MANIFEST_FETCHED=0

# Fetch the manifest ONCE per run (cached in `_MANIFEST_JSON`); a network failure is remembered
# too (so we don't retry per-field) and surfaces as "unresolved" to every caller.
fetch_manifest() {
	[ "$_MANIFEST_FETCHED" -eq 1 ] && { [ -n "$_MANIFEST_JSON" ]; return $?; }
	_MANIFEST_FETCHED=1
	have curl || return 1
	_MANIFEST_JSON="$(curl -fsSL --max-time 5 "$HONEYCOMB_MANIFEST_URL" 2>/dev/null)" || _MANIFEST_JSON=""
	# Fallback (tried ONCE): the historical raw GitHub URL. It 404s while the-apiary is private,
	# but costs one bounded request and starts working again if the repo ever goes public; it also
	# covers a transient install-site outage.
	if [ -z "$_MANIFEST_JSON" ]; then
		_MANIFEST_JSON="$(curl -fsSL --max-time 5 "$HONEYCOMB_MANIFEST_FALLBACK_URL" 2>/dev/null)" || _MANIFEST_JSON=""
	fi
	[ -n "$_MANIFEST_JSON" ] || return 1
	return 0
}

# manifest_field <slug> <field>; prints the field's value, or nothing (+ returns 1) if unresolved.
# Uses `node` (guaranteed available by the time any real, non-dry-run caller reaches this; Node is
# ensured before any product is installed) to parse JSON reliably rather than hand-rolling a
# sed/grep JSON parser for a document this script does not control the shape of byte-for-byte.
manifest_field() {
	slug="$1"; field="$2"
	fetch_manifest || return 1
	have node || return 1
	printf '%s' "$_MANIFEST_JSON" | node -e '
		let raw = "";
		process.stdin.on("data", (d) => { raw += d; });
		process.stdin.on("end", () => {
			try {
				const m = JSON.parse(raw);
				const p = m && m.products && m.products[process.argv[1]];
				if (!p || p[process.argv[2]] === undefined) process.exit(1);
				process.stdout.write(String(p[process.argv[2]]));
			} catch (e) { process.exit(1); }
		});
	' "$slug" "$field" 2>/dev/null
}

# SECURITY (security-review finding, medium): the manifest is an external input (a
# compromised repo, a MITM on a fetch, or a user-supplied HONEYCOMB_MANIFEST_URL override
# could all poison it). Even though this script already double-quotes every "$target"
# expansion (never vulnerable to the injection class the finding targeted on the
# PowerShell dialect, whose npm.cmd shim re-parses argument metacharacters via cmd.exe),
# validating the SHAPE of packageName/version here closes the vector at its source for
# BOTH dialects rather than relying solely on downstream quoting discipline.
#
# npm_package_name_is_safe: a conservative safe-character allowlist matching real npm
# package-name rules (lowercase, digits, `.`/`_`/`-`, optionally `@scope/name`); this
# alone makes it structurally impossible to smuggle a shell/cmd metacharacter through.
npm_package_name_is_safe() {
	case "$1" in
		@[a-z0-9]*/[a-z0-9]*)
			scope="${1%%/*}"; name="${1#*/}"
			case "$scope" in @*[!a-z0-9._-]*|@) return 1 ;; esac
			case "$name" in *[!a-z0-9._-]*|"") return 1 ;; esac
			return 0
			;;
		[a-z0-9]*)
			case "$1" in *[!a-z0-9._-]*) return 1 ;; esac
			return 0
			;;
		*) return 1 ;;
	esac
}

# semver_is_safe: digits.digits.digits with an optional -prerelease / +build suffix drawn
# from the same safe character set (never a metacharacter).
semver_is_safe() {
	case "$1" in
		[0-9]*.[0-9]*.[0-9]*)
			case "$1" in *[!0-9A-Za-z.+-]*) return 1 ;; esac
			return 0
			;;
		*) return 1 ;;
	esac
}

# Resolve the npm install target for a product slug (b-AC-2). Prints exactly one of:
#   "ok <pkg>@<version>"   -- install this manifest-pinned target
#   "unpublished <pkg>"    -- manifest declares published:false; do NOT attempt an npm install,
#                             this is the expected shape until a maintainer completes the one-time
#                             npm Trusted-Publisher bootstrap (PRD-001c); never a cryptic npm error
#   "unresolved <pkg>"     -- the manifest itself is unreachable/malformed, OR its packageName/
#                             version fields fail the safe-shape check above; fall back to
#                             <pkg>@latest with a printed warning (a manifest hiccup, or a
#                             tampered field, must never brick installing a product the user
#                             explicitly asked for, and must never reach npm/the shell unvalidated)
resolve_product_target() {
	slug="$1"; fallback_pkg="$2"
	pkg="$(manifest_field "$slug" packageName)"
	if [ -z "$pkg" ] || ! npm_package_name_is_safe "$pkg"; then pkg="$fallback_pkg"; fi
	version="$(manifest_field "$slug" version)"
	if [ -z "$version" ] || ! semver_is_safe "$version"; then
		printf 'unresolved %s\n' "$pkg"
		return 0
	fi
	published="$(manifest_field "$slug" published)"
	if [ "$published" = "false" ]; then
		printf 'unpublished %s\n' "$pkg"
		return 0
	fi
	printf 'ok %s@%s\n' "$pkg" "$version"
}

# Thin wrapper over resolve_product_target for the two ALWAYS-core products (honeycomb,
# doctor), which; unlike hive/nectar; are always expected to already be published
# (b-AC-2 applies to every installed product, not only the new ones). Collapses the 3-way
# ok/unpublished/unresolved result down to a single npm install target string: the manifest-pinned
# version when resolvable, else `<pkg>@latest` (never a hard failure over a manifest hiccup).
resolve_core_product_target() {
	slug="$1"; fallback_pkg="$2"
	resolved="$(resolve_product_target "$slug" "$fallback_pkg")"
	kind="${resolved%% *}"
	payload="${resolved#* }"
	case "$kind" in
		ok) printf '%s' "$payload" ;;
		*) printf '%s@latest' "$payload" ;;
	esac
}

# ─────────────────────────────────────────────────────────────────────────────
# Step 1; Node + npm. If both are present, use them. Else install fnm (NO elevation)
#          + the pinned Node LTS. fnm installs entirely under $HOME, so it never needs
#          sudo; that is exactly why it is the primary path over the official installer.
# ─────────────────────────────────────────────────────────────────────────────
ensure_node() {
  if have node && have npm; then
    ok "Node $(node --version) and npm $(npm --version) found."
    return 0
  fi

  step "Node/npm not found; installing a private copy via fnm (no admin rights needed)…"

  # fnm install is a curl|sh that writes ONLY under ~/.local/share/fnm + ~/.fnm; no elevation.
  if ! have fnm; then
    if ! have curl; then
      # We cannot fetch fnm without curl, and installing curl itself needs the OS package manager
      # (which needs elevation). Print the EXACT copy-paste and exit cleanly (a-AC-3).
      elevation_required_node
      return 1
    fi
    if ! curl -fsSL https://fnm.vercel.app/install | sh >/dev/null 2>&1; then
      # fnm's own installer failed (e.g. a locked-down $HOME it cannot write). Fall back to the
      # documented manual command + clean non-zero exit (a-AC-3); never a raw error dump.
      elevation_required_node
      return 1
    fi
  fi

  # Load fnm into THIS shell so `fnm`/`node`/`npm` resolve in-process (the install does not refresh
  # the current shell's env). fnm lives at ~/.local/share/fnm or ~/.fnm depending on the platform.
  FNM_DIR="${HOME}/.local/share/fnm"
  [ -d "$FNM_DIR" ] || FNM_DIR="${HOME}/.fnm"
  if [ -d "$FNM_DIR" ]; then
    PATH="${FNM_DIR}:${PATH}"
    export PATH
  fi
  if have fnm; then
    # `fnm env` exports the shims; evaluate them so node/npm are on PATH for the rest of this run.
    eval "$(fnm env 2>/dev/null)" || true
    if ! fnm install "$HONEYCOMB_NODE_VERSION" >/dev/null 2>&1; then
      elevation_required_node
      return 1
    fi
    fnm use "$HONEYCOMB_NODE_VERSION" >/dev/null 2>&1 || true
    eval "$(fnm env --use-on-cd 2>/dev/null)" || true
  fi

  if have node && have npm; then
    ok "Installed Node $(node --version) via fnm."
    return 0
  fi

  # fnm landed but node/npm still are not resolvable; surface the manual path, clean exit (a-AC-3).
  elevation_required_node
  return 1
}

# a-AC-3; print the EXACT copy-paste install command + a one-line WHY, then signal a clean
# non-zero exit. NEVER a raw error dump. The caller exits with this function's surfaced intent.
elevation_required_node() {
  fail "Honeycomb needs Node ${HONEYCOMB_NODE_VERSION} and could not install it automatically (your machine blocked the no-admin install)."
  printf '\nInstall Node %s yourself with ONE of these, then re-run this installer:\n\n' "$HONEYCOMB_NODE_VERSION"
  printf '  # macOS (Homebrew):\n'
  printf '  brew install node@%s\n\n' "$HONEYCOMB_NODE_VERSION"
  printf '  # Debian/Ubuntu:\n'
  printf '  curl -fsSL https://deb.nodesource.com/setup_%s.x | sudo -E bash - && sudo apt-get install -y nodejs\n\n' "$HONEYCOMB_NODE_VERSION"
  printf '  # Then re-run:\n'
  printf '  curl -fsSL %s/install.sh | sh\n\n' "$HONEYCOMB_INSTALL_BASE_URL"
}

# ─────────────────────────────────────────────────────────────────────────────
# Step 2; install @legioncodeinc/honeycomb globally. The embedding runtime
#          (@huggingface/transformers) is an OPTIONAL dependency of the package and
#          is pulled by npm during this install; its MODEL WEIGHTS are NOT fetched
#          here (that is the embed daemon's lazy warmup; 050b), so this stays fast.
# ─────────────────────────────────────────────────────────────────────────────
install_honeycomb() {
  # Idempotent: a re-run on a machine that already has `honeycomb` is a NO-OP; no npm mutation, no
  # network. This keeps the documented "safe to re-run" contract and lets a rerun succeed OFFLINE. Only
  # an absent install triggers the global npm install. (`resolve_honeycomb_bin` is defined below; POSIX sh
  # resolves functions at call time, so the forward reference is fine; both exist before `main` runs.)
  if existing_bin="$(resolve_honeycomb_bin 2>/dev/null)"; then
    ok "${HONEYCOMB_NPM_PACKAGE} already installed (${existing_bin})."
    return 0
  fi
  target="$(resolve_core_product_target "honeycomb" "$HONEYCOMB_NPM_PACKAGE")"
  step "installing ${target} globally…"
  if ! npm install -g "$target" >/dev/null 2>&1; then
    fail "the global install of ${target} failed."
    printf '\nTry it directly to see the npm error, then re-run this installer:\n\n  npm install -g %s\n\n' "$target"
    return 1
  fi
  ok "installed ${target}."
  return 0
}

# Resolve the ABSOLUTE path to the freshly-installed `honeycomb` bin. `npm i -g` does NOT refresh the
# CURRENT shell's PATH, so calling `honeycomb` by bare name in the same run can fail "command not
# found" (PRD-050a impl-note). Resolve `<npm prefix -g>/bin/honeycomb` and invoke THAT.
resolve_honeycomb_bin() {
  if have honeycomb; then
    command -v honeycomb
    return 0
  fi
  prefix="$(npm prefix -g 2>/dev/null)"
  if [ -n "$prefix" ] && [ -x "${prefix}/bin/honeycomb" ]; then
    printf '%s\n' "${prefix}/bin/honeycomb"
    return 0
  fi
  return 1
}

# ─────────────────────────────────────────────────────────────────────────────
# Step 3b: Doctor bootstrap (PRD-064b). After the primary is installed, install the
#           Doctor watchdog (a second global) and register its OS service, UNLESS the
#           user opted out with `--no-doctor` (the ONLY install-time switch, OD-5; pre-rename
#           alias `--no-doctor` still accepted) or the env equivalent HONEYCOMB_NO_DOCTOR=1
#           (alias HONEYCOMB_NO_DOCTOR=1). Idempotent: an existing doctor bin
#           is not reinstalled, and `doctor install-service` converges (it overwrites its
#           unit). FAIL-SOFT: a Doctor hiccup never fails the Honeycomb install, the user
#           still lands on a working dashboard (parent AC-10 spirit: opt-out is honest, and a
#           watchdog failure is not a primary-install failure).
# ─────────────────────────────────────────────────────────────────────────────

# True (returns 0) when the user opted OUT of Doctor via the flag or the env equivalent
# (canonical `--no-doctor` / HONEYCOMB_NO_DOCTOR, or the pre-rename alias spellings).
# Mirrors doctor/src/service/install-guard.ts (shouldBootstrapDoctor), keep in sync.
doctor_opted_out() {
  case " $* " in
    *" --no-doctor "*|*" --no-doctor "*) return 0 ;;
  esac
  case "${HONEYCOMB_NO_DOCTOR:-}" in
    1|true|TRUE|True) return 0 ;;
  esac
  case "${HONEYCOMB_NO_DOCTOR:-}" in
    1|true|TRUE|True) return 0 ;;
  esac
  return 1
}

# Install the Doctor global (idempotent) + register its OS service. All output is friendly;
# every failure is a soft note, never a hard exit (the primary install already succeeded).
install_doctor() {
  if have doctor; then
    ok "${DOCTOR_NPM_PACKAGE} already installed."
  else
    hd_target="$(resolve_core_product_target "doctor" "$DOCTOR_NPM_PACKAGE")"
    step "installing the Doctor watchdog (${hd_target})…"
    if ! npm install -g "$hd_target" >/dev/null 2>&1; then
      printf 'note: could not install %s (continuing, Honeycomb itself is installed).\n' "$hd_target"
      mark_product_not_installed doctor
      return 0
    fi
    ok "installed ${hd_target}."
  fi

  # Resolve + run `doctor install-service` to register the OS service (userland scope by
  # default; survives crash + reboot). `npm i -g` does not refresh THIS shell's PATH, so resolve
  # the absolute bin the same way we do for honeycomb.
  hd_bin=""
  if have doctor; then
    hd_bin="$(command -v doctor)"
  else
    hd_prefix="$(npm prefix -g 2>/dev/null)"
    if [ -n "$hd_prefix" ] && [ -x "${hd_prefix}/bin/doctor" ]; then
      hd_bin="${hd_prefix}/bin/doctor"
    fi
  fi
  if [ -n "$hd_bin" ]; then
    step "registering the Doctor OS service…"
    if "$hd_bin" install-service >/dev/null 2>&1; then
      ok "Doctor is watching (it will restart the daemon on crash and survive reboots)."
    else
      # IRD-192 AC-7: a non-zero exit now means the service manager rejected the unit. Do NOT claim
      # the watchdog is watching; name the actionable command so the user can see why. Non-fatal:
      # Honeycomb itself is already installed (parent AC-10 spirit).
      printf "note: Doctor installed but its service did not register (continuing). Run 'doctor install-service' to see why.\n"
    fi
  fi
  return 0
}

# ═══════════════════════════════════════════════════════════════════════════════════════════════
# PRD-002b; install + register hive / nectar when selected (the coverage-gap close)
# ═══════════════════════════════════════════════════════════════════════════════════════════════
#
# Generic across both products (small, deliberate duplication vs. one dense function): resolve the
# manifest-pinned target, npm-install it globally (idempotent, fail-soft), then run the product's
# OWN post-install verb. Both hive (`hive install-service`) and nectar (`nectar
# install`) ALREADY implement a doctor-registry writer internally (`hive/src/install/
# registry.ts` / `nectar/src/doctor-registry.ts`); this installer reuses THEIR verb rather
# than hand-rolling a second registry writer, so there is exactly one writer per product (b-AC-3).
install_extra_product() {
	display_name="$1"; slug="$2"; fallback_pkg="$3"; bin_name="$4"; post_install_verb="$5"

	resolved="$(resolve_product_target "$slug" "$fallback_pkg")"
	kind="${resolved%% *}"
	pkg="${resolved#* }"

	case "$kind" in
		unpublished)
			printf 'note: %s (%s) is not yet published to npm; skipping (a maintainer still needs to complete the one-time npm Trusted-Publisher bootstrap, PRD-001c). Re-run this installer after that lands.\n' "$display_name" "$pkg"
			mark_product_not_installed "$slug"
			return 0
			;;
		unresolved)
			printf 'note: could not resolve the pinned version for %s from the release manifest; falling back to %s@latest.\n' "$display_name" "$pkg"
			target="${pkg}@latest"
			;;
		*)
			target="$pkg"
			;;
	esac

	if [ "$DRY_RUN" -eq 1 ]; then
		printf '[dry-run] would run: npm install -g %s\n' "$target"
		printf '[dry-run] would run: %s %s\n' "$bin_name" "$post_install_verb"
		return 0
	fi

	if have "$bin_name"; then
		ok "${display_name} already installed ($(command -v "$bin_name"))."
	else
		step "installing ${display_name} (${target}) globally…"
		if ! npm install -g "$target" >/dev/null 2>&1; then
			printf 'note: could not install %s (continuing; the rest of the install still succeeded). Try: npm install -g %s\n' "$display_name" "$target"
			EXTRA_PRODUCT_FAILED=1
			mark_product_not_installed "$slug"
			return 0
		fi
		ok "installed ${display_name}."
	fi

	prod_bin=""
	if have "$bin_name"; then
		prod_bin="$(command -v "$bin_name")"
	else
		prod_prefix="$(npm prefix -g 2>/dev/null)"
		if [ -n "$prod_prefix" ] && [ -x "${prod_prefix}/bin/${bin_name}" ]; then
			prod_bin="${prod_prefix}/bin/${bin_name}"
		fi
	fi

	if [ -n "$prod_bin" ]; then
		step "registering ${display_name} with doctor…"
		if "$prod_bin" $post_install_verb >/dev/null 2>&1; then
			ok "${display_name} registered."
		else
			printf 'note: %s installed but its %s step did not complete (continuing). Run `%s %s` to see why.\n' "$display_name" "$post_install_verb" "$bin_name" "$post_install_verb"
			EXTRA_PRODUCT_FAILED=1
			# A registration failure keeps the product OUT of the transition events, matching the
			# install-state gate below (a failed selection is not recorded as installed).
			mark_product_not_installed "$slug"
		fi
	fi
	return 0
}

# ═══════════════════════════════════════════════════════════════════════════════════════════════
# PRD-002b; registration create/update/DELETE across lifecycle transitions
# ═══════════════════════════════════════════════════════════════════════════════════════════════
# CREATE/UPDATE are handled above (install_extra_product's post-install verb, plus honeycomb's own
# `writeDoctorRegistryEntry` inside `honeycomb install`). DELETE is the remaining transition:
# when a re-run's --products= NARROWS the set (a product previously installed is no longer
# selected), that product's doctor registry entry is removed so the registry stays an honest
# picture of the fleet (parent AC-6). This is the ONLY delete path this installer implements today
#; see the header note in reconcile_removed_products for the honest scope of what is NOT covered.

# Read the previous run's selected products (comma list), or empty if none/unreadable/first-run.
# Normalized through the same alias map as the live selection, so a state file written before the
# July 2026 slug rename diffs cleanly against a post-rename selection (no spurious remove+install).
read_previous_products() {
	[ -f "$HONEYCOMB_INSTALL_STATE_FILE" ] || return 0
	have node || return 0
	raw_previous="$(node -e '
		try {
			const fs = require("node:fs");
			const s = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
			if (typeof s.products === "string") process.stdout.write(s.products);
		} catch (e) { /* unreadable/malformed -> treated as "no prior state" */ }
	' "$HONEYCOMB_INSTALL_STATE_FILE" 2>/dev/null)"
	normalize_products_list "$raw_previous"
}

# Persist this run's selection as "the last thing this installer selected" (installer-owned
# bookkeeping; NOT the doctor registry contract itself, just this script's own diff baseline).
write_install_state() {
	have node || return 0
	node -e '
		const fs = require("node:fs");
		const path = require("node:path");
		const file = process.argv[1];
		const products = process.argv[2];
		fs.mkdirSync(path.dirname(file), { recursive: true });
		fs.writeFileSync(file, JSON.stringify({ products, updatedAt: new Date().toISOString() }, null, 2) + "\n", "utf8");
	' "$HONEYCOMB_INSTALL_STATE_FILE" "$SEL_PRODUCTS" 2>/dev/null || true
}

# Deregister one product's entry from doctor's static registry by name (the "delete"
# transition). Mirrors the SAME idempotent replace-by-name shape the TS writers use
# (`registerHoneycombWithDoctor` / `registerHiveWithDoctor`), just filtering the entry
# OUT instead of upserting it. Fail-soft: any read/parse/write hiccup is swallowed; a registry
# cleanup step must never fail the run that triggered it.
deregister_from_doctor() {
	name="$1"
	[ -f "$HONEYCOMB_DOCTOR_REGISTRY_FILE" ] || return 0
	have node || return 0
	node -e '
		const fs = require("node:fs");
		const file = process.argv[1];
		const name = process.argv[2];
		let doc;
		try { doc = JSON.parse(fs.readFileSync(file, "utf8")); } catch (e) { process.exit(0); }
		if (!doc || !Array.isArray(doc.daemons)) process.exit(0);
		const next = doc.daemons.filter((d) => !d || d.name !== name);
		if (next.length === doc.daemons.length) process.exit(0);
		const tmp = file + ".tmp-" + process.pid + "-" + Date.now();
		fs.writeFileSync(tmp, JSON.stringify(Object.assign({}, doc, { daemons: next }), null, 2) + "\n", "utf8");
		fs.renameSync(tmp, file);
	' "$HONEYCOMB_DOCTOR_REGISTRY_FILE" "$name" 2>/dev/null || true
}

# NOTE ON SCOPE (documented honestly, see the ledger too): this does NOT run `npm uninstall -g` ,
# removing a global package the user may still want for other reasons is a separate, more
# destructive decision this installer does not make on the user's behalf. It ONLY keeps
# doctor's registry honest. Also: neither honeycomb, hive, nor nectar ships a full
# "product uninstall" verb today (checked: hive's `uninstall-service` / nectar's
# `uninstall` only remove the OS service unit, they do NOT touch the doctor registry); so a
# --products= narrowing between two runs of THIS installer is the only delete trigger implemented.
# A real `honeycomb uninstall` (or per-product uninstall) command remains a documented gap.
reconcile_removed_products() {
	previous="$(read_previous_products)"
	[ -n "$previous" ] || return 0
	old_ifs="$IFS"
	IFS=','
	for p in $previous; do
		IFS="$old_ifs"
		case ",$SEL_PRODUCTS," in
			*",$p,"*) : ;; # still selected; nothing to do
			*)
				# Per-product transition telemetry: this product WAS in the last run's selection and
				# is gone now (the DELETE transition). Fire-and-forget like every phone_home call;
				# fires for EVERY dropped product, whether or not it has a deregistration branch below.
				phone_home product_removed "$p"
				case "$p" in
					hive|nectar)
						# The registry entry name is the product's own daemon name (what its TS
						# writer registered: hive/src/install/registry.ts HIVE_REGISTRY_NAME
						# = "hive", nectar/src/doctor-registry.ts NECTAR_DAEMON_NAME
						# = "nectar"); those runtime names deliberately did not change with
						# the slug rename, so map slug -> registry name here.
						case "$p" in
							hive)   registry_name="hive" ;;
							nectar) registry_name="nectar" ;;
						esac
						if [ "$DRY_RUN" -eq 1 ]; then
							printf '[dry-run] would deregister %s from doctor (no longer in --products=).\n' "$p"
						else
							step "deregistering ${p} from doctor (no longer in --products=)…"
							deregister_from_doctor "$registry_name"
						fi
						;;
					*) : ;; # honeycomb/doctor: no self-deregistration through this path
				esac
				;;
		esac
		IFS=','
	done
	IFS="$old_ifs"
}

# ─────────────────────────────────────────────────────────────────────────────
# Per-product transition telemetry: product_installed / product_updated (product_removed fires
# from reconcile_removed_products above). Diffs the PREVIOUS run's selection (install-state) vs
# this run's resolved selection, skipping any product that did not actually land
# (PRODUCTS_NOT_INSTALLED). Same posture as every phone_home call: fire-and-forget, silent no-op
# without a key, dry-run previews only, never affects the exit code. `repeat_install` covers the
# RUN; these events cover the PER-PRODUCT fact.
# ─────────────────────────────────────────────────────────────────────────────
phone_home_product_transitions() {
	previous="$(read_previous_products)"
	old_ifs="$IFS"
	IFS=','
	for p in $SEL_PRODUCTS; do
		IFS="$old_ifs"
		case ",$PRODUCTS_NOT_INSTALLED," in
			*",$p,"*) IFS=','; continue ;; # selected but did not land: no transition claim
		esac
		case ",$previous," in
			*",$p,"*) phone_home product_updated "$p" ;;   # already present last run: refreshed
			*)        phone_home product_installed "$p" ;; # newly added this run
		esac
		IFS=','
	done
	IFS="$old_ifs"
}

# ─────────────────────────────────────────────────────────────────────────────
# Terminal-state telemetry (c-AC-2): every exit from main() funnels through here so exactly one
# of install_completed/install_failed always fires, including a failure BEFORE the honeycomb CLI
# ever runs (the exact gap ADR-0002 exists to close).
# ─────────────────────────────────────────────────────────────────────────────
finish() {
	code="$1"
	if [ "$code" -eq 0 ]; then
		phone_home install_completed
	else
		phone_home install_failed
	fi
	exit "$code"
}

# ═══════════════════════════════════════════════════════════════════════════════════════════════
# PRD-009d thin bootstrap companion (bs-AC-1..8): bare invocation portal path
# ═══════════════════════════════════════════════════════════════════════════════════════════════

resolve_hive_bin() {
	if have hive; then
		command -v hive
		return 0
	fi
	prefix="$(npm prefix -g 2>/dev/null)"
	if [ -n "$prefix" ] && [ -x "${prefix}/bin/hive" ]; then
		printf '%s\n' "${prefix}/bin/hive"
		return 0
	fi
	return 1
}

resolve_hive_target_strict() {
	resolved="$(resolve_product_target "hive" "@legioncodeinc/hive")"
	kind="${resolved%% *}"
	payload="${resolved#* }"
	case "$kind" in
		ok)
			printf '%s' "$payload"
			return 0
			;;
		unpublished)
			fail "The pinned hive version from hive-release.json is not published yet, so the installer cannot continue."
			return 1
			;;
		*)
			fail "The installer could not read a valid hive version from hive-release.json, so it cannot continue."
			return 1
			;;
	esac
}

wait_for_hive_health() {
	have curl || return 1
	i=0
	while [ "$i" -lt 20 ]; do
		if curl -fsS --max-time 1 "$HIVE_HEALTH_URL" >/dev/null 2>&1; then
			return 0
		fi
		sleep 1
		i=$((i + 1))
	done
	return 1
}

mint_onboarding_token() {
	token=""
	if have od; then
		token="$(od -An -tx1 -N32 /dev/urandom 2>/dev/null | tr -d ' \n')"
	fi
	if [ "${#token}" -lt 64 ] && have uuidgen; then
		token="$(uuidgen 2>/dev/null | tr -d '-\n')$(uuidgen 2>/dev/null | tr -d '-\n')"
	fi
	[ -n "$token" ] || return 1

	old_umask="$(umask)"
	umask 077
	mkdir -p "$HIVE_ONBOARDING_DIR" 2>/dev/null || { umask "$old_umask"; return 1; }
	printf '%s' "$token" > "$HIVE_ONBOARDING_TOKEN_FILE" 2>/dev/null || { umask "$old_umask"; return 1; }
	umask "$old_umask"
	chmod 600 "$HIVE_ONBOARDING_TOKEN_FILE" >/dev/null 2>&1 || true
	printf '%s' "$token"
	return 0
}

open_onboarding_url() {
	token="$1"
	url="${HIVE_ONBOARDING_BASE_URL}?t=${token}"
	case "$(uname -s 2>/dev/null || echo unknown)" in
		Darwin)
			if have open; then open "$url" >/dev/null 2>&1 || true; fi
			;;
		Linux)
			if have xdg-open; then xdg-open "$url" >/dev/null 2>&1 || true; fi
			;;
	esac
}

run_portal_path() {
	parse_args "$@"
	[ "$ARG_DRY_RUN" -eq 1 ] && DRY_RUN=1
	SEL_PRODUCTS="hive"
	SEL_PROFILE=""

	resolve_install_id
	phone_home install_started

	if [ "$DRY_RUN" -eq 1 ]; then
		printf 'resolved path (--dry-run, nothing will be installed/registered/sent): portal bootstrap (hive only)\n'
		printf '  products = %s\n' "$SEL_PRODUCTS"
		printf '  profile  = <none>\n'
		printf '  install id = %s (repeat=%s)\n' "$INSTALL_ID" "$IS_REPEAT_INSTALL"
	fi

	if [ "$DRY_RUN" -eq 1 ]; then
		if have node && have npm; then
			ok "Node $(node --version) and npm $(npm --version) found (dry-run: no bootstrap attempted)."
		else
			printf 'note: node/npm not found (dry-run; a real run would attempt to install them via fnm).\n'
		fi
	else
		ensure_node || finish 1
	fi

	if [ "$DRY_RUN" -eq 1 ]; then
		preview_resolved="$(resolve_product_target "hive" "@legioncodeinc/hive")"
		preview_kind="${preview_resolved%% *}"
		preview_payload="${preview_resolved#* }"
		case "$preview_kind" in
			ok)
				hive_target="$preview_payload"
				;;
			unpublished)
				printf '[dry-run] would fail: pinned hive version from hive-release.json is not published yet.\n'
				finish 0
				;;
			*)
				printf '[dry-run] would fail: installer could not read a valid hive version from hive-release.json.\n'
				finish 0
				;;
		esac
		if resolve_hive_bin >/dev/null 2>&1; then
			printf '[dry-run] would skip npm install because hive is already installed.\n'
		else
			printf '[dry-run] would run: npm install -g %s\n' "$hive_target"
		fi
		printf '[dry-run] would mint onboarding token at %s (0600).\n' "$HIVE_ONBOARDING_TOKEN_FILE"
		printf '[dry-run] would run: hive install-service\n'
		printf '[dry-run] would poll: %s\n' "$HIVE_HEALTH_URL"
		printf '[dry-run] would run (if needed): hive start (detached)\n'
		printf '[dry-run] would open: %s?t=<token>\n' "$HIVE_ONBOARDING_BASE_URL"
		printf "[dry-run] would print: Click here if the portal doesn't open automatically: %s?t=<token>\n" "$HIVE_ONBOARDING_BASE_URL"
		finish 0
	fi

	hive_target="$(resolve_hive_target_strict)" || finish 1

	if hive_bin="$(resolve_hive_bin 2>/dev/null)"; then
		ok "hive is already installed (${hive_bin})."
	else
		step "installing ${hive_target} globally…"
		if ! npm install -g "$hive_target" >/dev/null 2>&1; then
			fail "The installer could not install the pinned hive version from hive-release.json."
			finish 1
		fi
		ok "installed ${hive_target}."
		hive_bin="$(resolve_hive_bin 2>/dev/null)" || {
			fail "The installer installed hive but could not locate the hive command."
			finish 1
		}
	fi

	token="$(mint_onboarding_token)" || {
		fail "The installer could not create a secure onboarding token file."
		finish 1
	}

	step "starting hive daemon for onboarding…"
	if ! "$hive_bin" install-service >/dev/null 2>&1; then
		printf 'note: hive install-service did not complete; continuing with direct startup.\n'
	fi

	if ! wait_for_hive_health; then
		nohup "$hive_bin" start >/dev/null 2>&1 &
		if ! wait_for_hive_health; then
			fail "The hive onboarding portal did not start on http://127.0.0.1:3853."
			finish 1
		fi
	fi

	open_onboarding_url "$token"
	# The fallback link MUST carry the one-time token: the onboarding screen refuses every
	# installer call without it (401), so a tokenless /onboarding visit can never proceed.
	printf "Click here if the portal doesn't open automatically: %s?t=%s\n" "$HIVE_ONBOARDING_BASE_URL" "$token"
	finish 0
}

# ─────────────────────────────────────────────────────────────────────────────
# Step 3; hand off to the CLI verb for the daemon-ensure + health-gate + dashboard
#          handling. The open logic lives ONCE in the CLI (src/commands/install.ts), not
#          here. The verb is idempotent + health-gated (a-AC-2 / a-AC-4), writes onboarding
#          "installed" (a-AC-5), and either opens the portal when reachable or prints one
#          plain sentence with the install command for Hive when it is not.
# ─────────────────────────────────────────────────────────────────────────────
legacy_main() {
  parse_args "$@"
  [ "$ARG_DRY_RUN" -eq 1 ] && DRY_RUN=1

  # c-AC-1: fires BEFORE any product resolution, using only `curl` (no Node/npm dependency).
  resolve_install_id
  phone_home install_started

  resolve_selection

  if [ "$DRY_RUN" -eq 1 ]; then
    printf 'resolved selection (--dry-run, nothing will be installed/registered/sent):\n'
    printf '  products = %s\n' "$SEL_PRODUCTS"
    printf '  profile  = %s\n' "${SEL_PROFILE:-<none>}"
    if [ -n "$SEL_LICENSE" ]; then printf '  license  = <redacted, %s chars>\n' "${#SEL_LICENSE}"; else printf '  license  = <none>\n'; fi
    printf '  code     = %s\n' "${SEL_CODE:-<none>}"
    printf '  install id = %s (repeat=%s)\n' "$INSTALL_ID" "$IS_REPEAT_INSTALL"
  fi

  if [ "$DRY_RUN" -eq 1 ]; then
    if have node && have npm; then
      ok "Node $(node --version) and npm $(npm --version) found (dry-run: no bootstrap attempted)."
    else
      printf 'note: node/npm not found (dry-run; a real run would attempt to install them via fnm).\n'
    fi
  else
    ensure_node || finish 1
  fi

  case ",$SEL_PRODUCTS," in
    *,honeycomb,*)
      if [ "$DRY_RUN" -eq 1 ]; then
        printf '[dry-run] would run: npm install -g %s\n' "$(resolve_core_product_target "honeycomb" "$HONEYCOMB_NPM_PACKAGE")"
      else
        install_honeycomb || finish 1
      fi
      ;;
  esac

  if [ "$DRY_RUN" -eq 1 ]; then
    bin=""
  else
    bin="$(resolve_honeycomb_bin)"
    if [ -z "$bin" ]; then
      fail "could not locate the installed 'honeycomb' command after the global install."
      printf '\nOpen a NEW terminal (so PATH refreshes) and run:\n\n  honeycomb install\n\n'
      finish 1
    fi
  fi

  # Doctor bootstrap (PRD-064b), now ALSO gated on doctor being in the resolved selection
  # (b-AC-5: an unselected product is never installed), in addition to the pre-existing opt-out.
  case ",$SEL_PRODUCTS," in
    *,doctor,*)
      if doctor_opted_out "$@"; then
        step "skipping Doctor (--no-doctor)."
        # Opted out: doctor stays selected but does not land, so it earns no transition event.
        mark_product_not_installed doctor
      elif [ "$DRY_RUN" -eq 1 ]; then
        printf '[dry-run] would install + register Doctor (%s).\n' "$(resolve_core_product_target "doctor" "$DOCTOR_NPM_PACKAGE")"
      else
        install_doctor
      fi
      ;;
    *) step "skipping Doctor (not in --products=)." ;;
  esac

  # PRD-002b: actually install hive / nectar when selected (the coverage-gap close). The bin
  # names (`hive` / `nectar`) are the products' own CLI bins and deliberately did not
  # change with the slug rename.
  case ",$SEL_PRODUCTS," in
    *,hive,*) install_extra_product "Hive" hive "@legioncodeinc/hive" hive install-service ;;
  esac
  case ",$SEL_PRODUCTS," in
    *,nectar,*) install_extra_product "Nectar" nectar "@legioncodeinc/nectar" nectar install ;;
  esac

  # PRD-002b DELETE transition: a --products= narrowing vs. the last run (fires product_removed).
  reconcile_removed_products
  # Per-product CREATE/UPDATE transitions: product_installed / product_updated. Must run BEFORE
  # write_install_state below (the diff baseline is the PREVIOUS run's recorded selection).
  phone_home_product_transitions
  # Persist the selected set ONLY when every selected extra product actually installed/registered:
  # a failed selection must not be recorded as "installed" (nor emit install_completed below).
  if [ "$DRY_RUN" -ne 1 ] && [ "$EXTRA_PRODUCT_FAILED" -eq 0 ]; then
    write_install_state
  fi

  if [ "$DRY_RUN" -eq 1 ]; then
    printf '[dry-run] would hand off to: honeycomb install (daemon-ensure + honest dashboard handling)\n'
    finish 0
  fi

  # The verb prints its own friendly step log (daemon up / onboarding marked / opening dashboard) and
  # returns a clean exit code; we forward it verbatim. A handled failure inside the verb is already a
  # plain-language line + non-zero exit; no raw stack reaches the user here. Forward the caller's args
  # MINUS every installer-only flag this script itself consumed (--no-doctor + its pre-rename
  # alias, and the PRD-002a flags), so a bootstrap `--ref <code>` (and any future verb flag) still
  # reaches the CLI's install verb. The positional rebuild preserves args that contain spaces
  # (string concatenation would not): append each KEPT arg, then drop the original leading args by
  # their count.
  _orig_count=$#
  for a in "$@"; do
    case "$a" in
      --no-doctor|--no-doctor|--products=*|--profile=*|--license=*|--code=*|--dry-run) continue ;;
    esac
    set -- "$@" "$a"
  done
  # Shift off the ORIGINAL args one at a time, leaving only the filtered copies we appended.
  _i=0
  while [ "$_i" -lt "$_orig_count" ]; do
    shift
    _i=$((_i + 1))
  done
  "$bin" install "$@"
  cli_status=$?
  # Propagate a selected extra-product failure into the terminal state: the run must not report
  # install_completed / exit 0 when a product the user explicitly selected failed to install or
  # register (its note was already printed by install_extra_product).
  if [ "$cli_status" -ne 0 ]; then
    finish "$cli_status"
  fi
  if [ "$EXTRA_PRODUCT_FAILED" -ne 0 ]; then
    fail "one of the selected products did not install/register (see the notes above); Honeycomb itself is installed."
    finish 1
  fi
  finish 0
}

main() {
	for a in "$@"; do
		case "$a" in
			--help|-h)
				legacy_main "$@"
				return
				;;
		esac
	done

	if selection_expressed "$@"; then
		legacy_main "$@"
	else
		run_portal_path "$@"
	fi
}

main "$@"
install.ps1 (Windows PowerShell)
# Honeycomb one-command bootstrap installer (Windows PowerShell) -- PRD-050a, extended by
# the-apiary PRD-002 (product loading + install-time telemetry, ADR-0002).
#
# Usage (the single line a brand-new Windows user pastes):
#   irm https://get.theapiary.sh/install.ps1 | iex
#
# With product selection (PRD-002a), pass args to the SCRIPT BLOCK explicitly (irm | iex has no
# script-level $args of its own, so a piped invocation cannot see flags -- run it as a saved file,
# or use the `& { ... } --products=...` invocation form documented on get.theapiary.sh):
#   powershell -c "& { $(irm https://get.theapiary.sh/install.ps1) } --products=honeycomb,hive"
#
# This is the FUNCTIONAL EQUIVALENT of install.sh (PRD-050a a-AC-5, PRD-002a a-AC-6): the SAME flag
# grammar, precedence, product-loading, registration, and telemetry behavior -- see install.sh's
# header comment for the full documented grammar (flags / env / config file / --code= / --profile=
# / precedence / telemetry payload shape). This file does not repeat that prose; it implements it.
#
# Thin + idempotent: detect what is present, install only what is missing, re-run safely.
#
# ASCII-only by design: this file is sourced via `irm | iex` and parsed by Windows PowerShell 5.1,
# which reads a non-BOM file as the system ANSI codepage -- so non-ASCII glyphs would corrupt the
# parse. The friendly progress GLYPHS the user sees come from the CLI verb's UTF-8 output; this
# script's own prefixes stay ASCII.

# Handle every failure explicitly + print a plain-language line (parent AC-7). We do NOT set
# $ErrorActionPreference='Stop' globally -- that would surface a raw PowerShell exception/trace.
$ErrorActionPreference = 'Continue'

# -----------------------------------------------------------------------------
# THE ONE PLACE TO BUMP NODE. The single pinned Node LTS the installer provisions
# via fnm. To upgrade the provisioned Node for every new user, change THIS line
# only. (Existing users with a working Node are left untouched -- see Ensure-Node.)
# -----------------------------------------------------------------------------
$HoneycombNodeVersion = '22'

# The published npm package the global install pulls (PRD-048 publishes it; this consumes it).
# PRD-002b: this is the FALLBACK package name only -- Install-Honeycomb resolves the ACTUAL
# installed version from hive-release.json (Resolve-ProductTarget) when reachable, falling back
# to @latest only when the manifest itself cannot be resolved.
$HoneycombNpmPackage = '@legioncodeinc/honeycomb'

# Doctor (PRD-064b): a SECOND global package -- the self-healing watchdog that keeps the
# primary daemon alive and registers itself with the OS (a per-user Scheduled Task on Windows,
# no admin / no UAC) so it survives crashes + reboots. Independent lifecycle (OD-6: a second
# global), installed after the primary unless the user opts out with -NoDoctor.
$DoctorNpmPackage = '@legioncodeinc/doctor'

# Distribution base URL: the vanity domain that serves this installer surface (PRD-050a follow-up,
# now RESOLVED). get.theapiary.sh is a Cloudflare Pages site (site/install/) that content-negotiates:
# a shell client piping `/` gets the POSIX install.sh as text/plain; a browser gets an "inspect before
# piping" page with the PUBLISHED SHA-256 checksums. `$HoneycombInstallBaseUrl/install.ps1` always
# resolves to the raw, checksummed script. To verify before running: see https://get.theapiary.sh
$HoneycombInstallBaseUrl = 'https://get.theapiary.sh'

# PRD-001/PRD-002b: the fleet release manifest (the-apiary superproject's hive-release.json). This
# installer never hardcodes "latest" for a product it did not itself publish (b-AC-2): it resolves
# each selected product's exact pinned version from THIS manifest.
#
# The manifest is served by the install site itself (site/install/build.mjs copies the
# superproject's hive-release.json into the deploy alongside the scripts): the-apiary is a
# PRIVATE repo, so the historical raw.githubusercontent.com URL returns 404 for anonymous
# users. That raw URL is kept below as the fallback, tried once after a failed primary fetch
# (it starts working again if the repo ever goes public).
$HoneycombManifestUrl = 'https://get.theapiary.sh/hive-release.json'
if ($env:HONEYCOMB_MANIFEST_URL) { $HoneycombManifestUrl = $env:HONEYCOMB_MANIFEST_URL }
$HoneycombManifestFallbackUrl = 'https://raw.githubusercontent.com/legioncodeinc/the-apiary/main/hive-release.json'

# PRD-002c: telemetry destination. The key is EMPTY in source control by design -- this exact
# `$HoneycombInstallPosthogKey = ''` line is the one site/install/build.mjs patches (via an
# anchored regex on this literal line, never a blind find/replace over the whole file) at deploy
# time, injecting the real PostHog project key (mirrors ADR-0002: "a public PostHog project key
# baked into the install site"). An empty value (any un-built/local/dev copy) makes Send-PhoneHome
# a silent no-op -- never a hard failure.
$HoneycombInstallPosthogKey = 'phc_wjWdFZfMRtUATshcoBRkZ3FiSMmAKEuVuP6ftraTCiPz'
$HoneycombInstallPosthogHost = 'https://us.i.posthog.com'
$HoneycombInstallPosthogPath = '/i/v0/e/'
$HoneycombInstallIdFile = Join-Path $HOME '.honeycomb\install-id'

# PRD-002a: admin config file. PRD-002b: this installer's own bookkeeping of the last-selected
# product set (used only to detect a --products= narrowing between runs).
$HoneycombInstallConfigFile = Join-Path $HOME '.honeycomb\install.conf'
$HoneycombInstallStateFile = Join-Path $HOME '.honeycomb\install-state.json'
$HoneycombDoctorRegistryFile = Join-Path $HOME '.honeycomb\doctor.daemons.json'
$HiveOnboardingDir = Join-Path $HOME '.honeycomb\hive'
$HiveOnboardingTokenFile = Join-Path $HiveOnboardingDir 'onboarding-token'
$HiveOnboardingBaseUrl = 'http://127.0.0.1:3853/onboarding'
$HiveHealthUrl = 'http://127.0.0.1:3853/health'

# Friendly progress log: step lines to the host, the single failure summary to the error stream.
function Write-Step([string]$m) { Write-Host "-> $m" }
function Write-Ok([string]$m)   { Write-Host "[ok] $m" }
function Write-Fail([string]$m) { [Console]::Error.WriteLine("Honeycomb install could not continue: $m") }

function Test-Have([string]$name) { return [bool](Get-Command $name -ErrorAction SilentlyContinue) }

# a-AC-3 -- print the EXACT copy-paste install command + a one-line WHY. NEVER a raw error dump.
function Show-NodeElevationHelp {
  Write-Fail "Honeycomb needs Node $HoneycombNodeVersion and could not install it automatically (your machine blocked the no-admin install)."
  Write-Host ''
  Write-Host "Install Node $HoneycombNodeVersion yourself with ONE of these, then re-run this installer:"
  Write-Host ''
  Write-Host '  # winget (recommended on Windows 10/11):'
  Write-Host '  winget install OpenJS.NodeJS.LTS'
  Write-Host ''
  Write-Host '  # or via the official MSI:'
  Write-Host '  https://nodejs.org/en/download'
  Write-Host ''
  Write-Host '  # Then re-run:'
  Write-Host "  irm $HoneycombInstallBaseUrl/install.ps1 | iex"
  Write-Host ''
}

function Show-Usage {
  Write-Host 'Usage: install.ps1 [--products=<slug,slug,...>] [--profile=<name>] [--license=<key>]'
  Write-Host '                   [--code=<code>] [--dry-run] [--no-doctor|-NoDoctor]'
  Write-Host ''
  Write-Host '  --products=honeycomb,hive,nectar          select exactly which products to install'
  Write-Host '  --profile=full                            a named products preset (default | full)'
  Write-Host '  --license=<key>                           thread a license key through (seam only)'
  Write-Host '  --code=HONEY-FULL                         resolve a product code to a preset'
  Write-Host '  --dry-run                                 resolve + print, mutate nothing'
  Write-Host '  --no-doctor / -NoDoctor                   skip the Doctor watchdog'
  Write-Host '                                            (aliases: --no-doctor / -NoDoctor)'
  Write-Host ''
  Write-Host 'Env equivalents: HONEYCOMB_INSTALL_PRODUCTS / _PROFILE / _LICENSE / _CODE, HONEYCOMB_NO_DOCTOR.'
  Write-Host 'Config file: ~\.honeycomb\install.conf (KEY=value per line: PRODUCTS, PROFILE, LICENSE, CODE).'
  Write-Host 'Precedence: flag > env > config file > code/profile preset (fills gaps only) > default.'
}

# -----------------------------------------------------------------------------
# PRD-002c -- anonymous install id + phone-home
# -----------------------------------------------------------------------------

function New-AnonInstallId {
  return [guid]::NewGuid().ToString()
}

# Resolve (or, outside -DryRun, mint + persist) the stable anonymous install id (c-AC-4). Returns
# a hashtable @{ Id = <string>; Repeat = <bool> }. In -DryRun mode this NEVER writes: an ephemeral
# id is generated purely for the preview, so repeated dry runs leave zero residue on disk.
function Resolve-InstallId([bool]$DryRun) {
  if ((Test-Path $HoneycombInstallIdFile) -and ((Get-Item $HoneycombInstallIdFile).Length -gt 0)) {
    $existing = (Get-Content $HoneycombInstallIdFile -Raw -ErrorAction SilentlyContinue)
    if ($existing) {
      return @{ Id = $existing.Trim(); Repeat = $true }
    }
  }
  $id = New-AnonInstallId
  if (-not $DryRun) {
    try {
      New-Item -ItemType Directory -Force -Path (Split-Path $HoneycombInstallIdFile) | Out-Null
      Set-Content -Path $HoneycombInstallIdFile -Value $id -NoNewline -ErrorAction SilentlyContinue
    } catch {
      # Fail-soft: a persistence hiccup must never abort the install.
    }
  }
  return @{ Id = $id; Repeat = $false }
}

# Fire ONE PostHog capture event (c-AC-1/c-AC-2). FAIL-SOFT + BOUNDED: a slow or unreachable
# ingest endpoint never hangs or breaks the install (ADR-0002). Uses the SAME capture endpoint +
# body shape as the Node-side chokepoint (src/daemon/runtime/telemetry/emit.ts) for consistency,
# but is otherwise fully independent of it. Payload is minimal + allow-list-shaped: products,
# profile, coarse OS family, repeat-vs-first -- NEVER --license=/--code= values (no PII).
# The optional -Product arg is the per-product transition payload field (product_installed /
# product_updated / product_removed each name the ONE product they describe); when present it is
# appended to the properties as `product = <slug>` alongside the existing run-level fields.
function Send-PhoneHome {
  param(
    [string]$EventName,
    [string]$Products,
    [string]$Profile,
    [string]$InstallId,
    [bool]$Repeat,
    [bool]$DryRun,
    [string]$Product = ''
  )
  if ($DryRun) {
    if ($Product) {
      Write-Host "[dry-run] would phone home: $EventName (product=$Product, install_id=$InstallId, repeat=$Repeat, products=$Products, profile=$Profile)"
    } else {
      Write-Host "[dry-run] would phone home: $EventName (install_id=$InstallId, repeat=$Repeat, products=$Products, profile=$Profile)"
    }
    return
  }

  if ([string]::IsNullOrEmpty($HoneycombInstallPosthogKey)) { return }

  $osFamily = 'windows'
  $props = @{
    products = $Products
    profile = $Profile
    os = $osFamily
    repeat_install = "$Repeat".ToLowerInvariant()
  }
  if ($Product) { $props.product = $Product }
  $body = @{
    api_key = $HoneycombInstallPosthogKey
    event = $EventName
    distinct_id = $InstallId
    properties = $props
  } | ConvertTo-Json -Compress

  try {
    Invoke-RestMethod -Method Post -Uri "$HoneycombInstallPosthogHost$HoneycombInstallPosthogPath" `
      -ContentType 'application/json' -Body $body -TimeoutSec 3 -ErrorAction Stop | Out-Null
  } catch {
    # Fail-soft: a dropped telemetry POST is acceptable; a hung/broken install is not.
  }
}

# Comma-free running list of SELECTED products that did NOT actually land this run (unpublished
# skip, npm install failure, registration failure, or the doctor opt-out). Consumed by
# Send-ProductTransitions so product_installed/product_updated never over-claims. Mirrors
# install.sh's PRODUCTS_NOT_INSTALLED (keep in parity).
$script:ProductsNotInstalled = @()

function Add-ProductNotInstalled([string]$Slug) {
  $script:ProductsNotInstalled += $Slug
}

# -----------------------------------------------------------------------------
# PRD-002a -- flag / env / config-file / code / profile resolution
# -----------------------------------------------------------------------------

# --code=<code> -> a products PRESET (a-AC-2). Returns $null when unrecognized (soft-fail: caller
# warns and ignores the code rather than failing the install over a typo).
function Resolve-CodeProducts([string]$Code) {
  switch ($Code) {
    'HONEY-FULL' { return 'honeycomb,doctor,hive,nectar' }
    default { return $null }
  }
}

function Resolve-CodeProfile([string]$Code) {
  switch ($Code) {
    'HONEY-FULL' { return 'full' }
    default { return $null }
  }
}

# --profile=<name> -> a products PRESET, used only to fill the products gap when --products=
# itself was not given by any higher-precedence source (flag/env/config).
function Resolve-ProfileProducts([string]$ProfileName) {
  switch ($ProfileName) {
    'default' { return 'honeycomb,doctor' }
    'full' { return 'honeycomb,doctor,hive,nectar' }
    default { return $null }
  }
}

# Normalize a comma list of product tokens to the canonical slugs. The July 2026 repository
# renames (doctor -> doctor, hive -> hive, nectar -> nectar) renamed the slugs with
# the repos; the pre-rename tokens stay accepted as aliases so every documented invocation,
# config file, and previously-written install-state.json keeps working across the rename.
# Mirrors install.sh's normalize_products_list (keep in parity).
function ConvertTo-CanonicalProducts([string]$Products) {
  if ([string]::IsNullOrEmpty($Products)) { return $Products }
  $normalized = foreach ($tok in ($Products.Split(',') | Where-Object { $_ -ne '' })) {
    switch ($tok) {
      'doctor' { 'doctor' }
      'hive' { 'hive' }
      'hive' { 'hive' }
      'nectar' { 'nectar' }
      default { $tok }
    }
  }
  return ($normalized -join ',')
}

# Read one KEY=value from the admin config file (a-AC-3). Plain-text parse ONLY -- this file is
# NEVER dot-sourced/executed, so it cannot inject PowerShell code. `#` comments and blank lines are
# ignored; the LAST matching KEY= line wins (ini-style override).
function Get-ConfigValue([string]$Key) {
  if (-not (Test-Path $HoneycombInstallConfigFile)) { return $null }
  $value = $null
  foreach ($line in Get-Content $HoneycombInstallConfigFile -ErrorAction SilentlyContinue) {
    $trimmed = $line.Trim()
    if ($trimmed -eq '' -or $trimmed.StartsWith('#')) { continue }
    $eq = $trimmed.IndexOf('=')
    if ($eq -lt 1) { continue }
    $k = $trimmed.Substring(0, $eq)
    if ($k -eq $Key) { $value = $trimmed.Substring($eq + 1) }
  }
  return $value
}

# Extract a raw `--flag=value` style token from the invocation args this script's own installer
# flags use (kept identical to install.sh's grammar, per a-AC-6, rather than adopting a
# PowerShell-native -Flag style that would diverge between the two dialects).
function Get-FlagValue([string[]]$InvocationArgs, [string]$Prefix) {
  if (-not $InvocationArgs) { return $null }
  foreach ($a in $InvocationArgs) {
    if ($a -and $a.StartsWith($Prefix)) { return $a.Substring($Prefix.Length) }
  }
  return $null
}

function Test-HasFlag([string[]]$InvocationArgs, [string]$Flag) {
  if (-not $InvocationArgs) { return $false }
  return ($InvocationArgs -contains $Flag)
}

# Any explicit product-selection signal routes to the legacy full-install path.
# PRD-009d seam: flags/env/config with products/profile/code/license => legacy path.
function Test-ConfigExpressesSelection {
  if (-not (Test-Path $HoneycombInstallConfigFile)) { return $false }
  foreach ($line in Get-Content $HoneycombInstallConfigFile -ErrorAction SilentlyContinue) {
    $trimmed = $line.Trim()
    if ($trimmed -eq '' -or $trimmed.StartsWith('#')) { continue }
    $eq = $trimmed.IndexOf('=')
    if ($eq -lt 1) { continue }
    $key = $trimmed.Substring(0, $eq)
    if ($key -in @('PRODUCTS', 'PROFILE', 'CODE', 'LICENSE')) { return $true }
  }
  return $false
}

function Test-SelectionExpressed([string[]]$InvocationArgs) {
  if ($InvocationArgs) {
    foreach ($a in $InvocationArgs) {
      if ($a -like '--products=*' -or $a -like '--profile=*' -or $a -like '--code=*' -or $a -like '--license=*') {
        return $true
      }
    }
  }
  if ($env:HONEYCOMB_INSTALL_PRODUCTS) { return $true }
  if ($env:HONEYCOMB_INSTALL_PROFILE) { return $true }
  if ($env:HONEYCOMB_INSTALL_CODE) { return $true }
  if ($env:HONEYCOMB_INSTALL_LICENSE) { return $true }
  if (Test-ConfigExpressesSelection) { return $true }
  return $false
}

# Resolve the effective selection per the documented precedence (a-AC-3, same as install.sh): flag
# > env > config file, then a --code=/--profile= preset fills the products gap only if still
# empty, then the built-in default, then honeycomb is force-included. Returns a hashtable.
function Resolve-Selection([string[]]$InvocationArgs) {
  $argProducts = Get-FlagValue $InvocationArgs '--products='
  $argProfile = Get-FlagValue $InvocationArgs '--profile='
  $argLicense = Get-FlagValue $InvocationArgs '--license='
  $argCode = Get-FlagValue $InvocationArgs '--code='

  $cfgProducts = Get-ConfigValue 'PRODUCTS'
  $cfgProfile = Get-ConfigValue 'PROFILE'
  $cfgLicense = Get-ConfigValue 'LICENSE'
  $cfgCode = Get-ConfigValue 'CODE'

  $selProducts = $argProducts
  if ([string]::IsNullOrEmpty($selProducts)) { $selProducts = $env:HONEYCOMB_INSTALL_PRODUCTS }
  if ([string]::IsNullOrEmpty($selProducts)) { $selProducts = $cfgProducts }

  $selProfile = $argProfile
  if ([string]::IsNullOrEmpty($selProfile)) { $selProfile = $env:HONEYCOMB_INSTALL_PROFILE }
  if ([string]::IsNullOrEmpty($selProfile)) { $selProfile = $cfgProfile }

  $selLicense = $argLicense
  if ([string]::IsNullOrEmpty($selLicense)) { $selLicense = $env:HONEYCOMB_INSTALL_LICENSE }
  if ([string]::IsNullOrEmpty($selLicense)) { $selLicense = $cfgLicense }

  $selCode = $argCode
  if ([string]::IsNullOrEmpty($selCode)) { $selCode = $env:HONEYCOMB_INSTALL_CODE }
  if ([string]::IsNullOrEmpty($selCode)) { $selCode = $cfgCode }

  if (-not [string]::IsNullOrEmpty($selCode)) {
    $codeProducts = Resolve-CodeProducts $selCode
    if ($codeProducts) {
      if ([string]::IsNullOrEmpty($selProducts)) { $selProducts = $codeProducts }
      if ([string]::IsNullOrEmpty($selProfile)) { $selProfile = Resolve-CodeProfile $selCode }
    } else {
      Write-Host "note: unrecognized --code=$selCode (ignoring; falling back to products/profile/defaults)."
    }
  }

  if ([string]::IsNullOrEmpty($selProducts) -and -not [string]::IsNullOrEmpty($selProfile)) {
    $profileProducts = Resolve-ProfileProducts $selProfile
    if ($profileProducts) {
      $selProducts = $profileProducts
    } else {
      Write-Host "note: unrecognized --profile=$selProfile (ignoring; falling back to the default product set)."
    }
  }

  if ([string]::IsNullOrEmpty($selProducts)) { $selProducts = 'honeycomb,doctor' }

  # Pre-rename tokens (doctor/hive/hive/nectar) normalize to the canonical slugs.
  $selProducts = ConvertTo-CanonicalProducts $selProducts

  # honeycomb is ALWAYS part of the effective set (see install.sh's header comment for why).
  $productList = $selProducts.Split(',') | Where-Object { $_ -ne '' }
  if ($productList -notcontains 'honeycomb') {
    $selProducts = "honeycomb,$selProducts"
  }

  return @{
    Products = $selProducts
    Profile = $selProfile
    License = $selLicense
    Code = $selCode
  }
}

# -----------------------------------------------------------------------------
# PRD-002b -- resolve a product's pinned version from hive-release.json
# -----------------------------------------------------------------------------

$script:ManifestObject = $null
$script:ManifestFetchAttempted = $false

function Get-Manifest {
  if ($script:ManifestFetchAttempted) { return $script:ManifestObject }
  $script:ManifestFetchAttempted = $true
  try {
    $script:ManifestObject = Invoke-RestMethod -Uri $HoneycombManifestUrl -TimeoutSec 5 -ErrorAction Stop
  } catch {
    # Fallback (tried ONCE): the historical raw GitHub URL. It 404s while the-apiary is private,
    # but costs one bounded request and starts working again if the repo ever goes public; it
    # also covers a transient install-site outage. Mirrors install.sh's fetch_manifest.
    try {
      $script:ManifestObject = Invoke-RestMethod -Uri $HoneycombManifestFallbackUrl -TimeoutSec 5 -ErrorAction Stop
    } catch {
      $script:ManifestObject = $null
    }
  }
  return $script:ManifestObject
}

# Resolve the npm install target for a product slug (b-AC-2). Returns a hashtable:
#   @{ Kind = 'ok'; Target = '<pkg>@<version>' }
#   @{ Kind = 'unpublished'; Pkg = '<pkg>' }   -- manifest says published:false, do NOT npm-install
#   @{ Kind = 'unresolved'; Pkg = '<pkg>' }    -- manifest unreachable/malformed, fall back to @latest
# SECURITY (security-review finding, medium): the manifest is an external input (a compromised
# repo, a MITM on a fetch, or a user-supplied HONEYCOMB_MANIFEST_URL override could all poison
# it). npm on Windows is a `.cmd` shim; invoking it with an unvalidated argument value can let a
# metacharacter WITHIN that value (`;`, `&`, `|`, backticks, `$()`, ...) be re-parsed by cmd.exe
# and inject an additional command, even though PowerShell itself passes $target as one token.
# These two validators enforce the SAME safe-shape allowlist as install.sh's
# npm_package_name_is_safe / semver_is_safe (kept in sync; see that file for the shared rationale)
# so a tampered field is rejected at the SOURCE rather than relying on downstream quoting alone.
function Test-SafePackageName([string]$Name) {
  if ([string]::IsNullOrEmpty($Name)) { return $false }
  return $Name -cmatch '^(@[a-z0-9][a-z0-9._-]*/)?[a-z0-9][a-z0-9._-]*$'
}

function Test-SafeSemver([string]$Version) {
  if ([string]::IsNullOrEmpty($Version)) { return $false }
  return $Version -cmatch '^[0-9]+\.[0-9]+\.[0-9]+([+.-][0-9A-Za-z.+-]+)?$'
}

function Resolve-ProductTarget([string]$Slug, [string]$FallbackPkg) {
  $manifest = Get-Manifest
  $pkg = $FallbackPkg
  if ($manifest -and $manifest.products -and $manifest.products.$Slug -and $manifest.products.$Slug.packageName) {
    $candidatePkg = $manifest.products.$Slug.packageName
    if (Test-SafePackageName $candidatePkg) { $pkg = $candidatePkg }
    # An unsafe-shaped packageName silently falls back to $FallbackPkg rather than being honored,
    # mirroring the resolution posture used for every other invalid/absent manifest field below.
  }
  if (-not $manifest -or -not $manifest.products -or -not $manifest.products.$Slug -or -not $manifest.products.$Slug.version) {
    return @{ Kind = 'unresolved'; Pkg = $pkg }
  }
  $entry = $manifest.products.$Slug
  if (-not (Test-SafeSemver $entry.version)) {
    # An unsafe-shaped (or non-semver) version is treated exactly like an unresolvable manifest
    # entry: never interpolated into an npm invocation, always the safe `@latest` fallback path.
    return @{ Kind = 'unresolved'; Pkg = $pkg }
  }
  $published = $true
  if ($null -ne $entry.published) { $published = [bool]$entry.published }
  if (-not $published) {
    return @{ Kind = 'unpublished'; Pkg = $pkg }
  }
  return @{ Kind = 'ok'; Target = "$pkg@$($entry.version)" }
}

# Thin wrapper over Resolve-ProductTarget for the two ALWAYS-core products (honeycomb,
# doctor) -- collapses the 3-way ok/unpublished/unresolved result to a single npm install
# target string: the manifest-pinned version when resolvable, else <pkg>@latest.
function Resolve-CoreProductTarget([string]$Slug, [string]$FallbackPkg) {
  $resolved = Resolve-ProductTarget $Slug $FallbackPkg
  if ($resolved.Kind -eq 'ok') { return $resolved.Target }
  return "$($resolved.Pkg)@latest"
}

# -----------------------------------------------------------------------------
# Step 1 -- Node + npm. If both present, use them. Else install fnm (NO elevation)
#           + the pinned Node LTS. fnm installs under the user profile, so it never
#           needs admin; that is why it is the primary path over the official MSI.
# -----------------------------------------------------------------------------
function Ensure-Node {
  if ((Test-Have 'node') -and (Test-Have 'npm')) {
    Write-Ok "Node $(node --version) and npm $(npm --version) found."
    return $true
  }

  Write-Step 'Node/npm not found -- installing a private copy via fnm (no admin rights needed)...'

  if (-not (Test-Have 'fnm')) {
    # Prefer winget (per-user, no elevation) to install fnm; fall back to the documented manual path.
    if (Test-Have 'winget') {
      winget install Schniz.fnm --accept-source-agreements --accept-package-agreements 2>$null | Out-Null
      # winget does NOT refresh THIS session's PATH, so a bare `fnm` lookup right after the install can
      # still miss even though the binary is on disk. Rebuild $env:Path from the machine + user
      # registry so the just-installed shim resolves in-process before we judge the install failed.
      try {
        $machinePath = [System.Environment]::GetEnvironmentVariable('Path', 'Machine')
        $userPath = [System.Environment]::GetEnvironmentVariable('Path', 'User')
        $env:Path = (@($machinePath, $userPath) | Where-Object { $_ }) -join ';'
      } catch {
        # Fail-soft: the `Test-Have 'fnm'` re-check below is the real gate; just surface why, don't abort.
        Write-Warning "Couldn't refresh PATH from the registry ($($_.Exception.Message)); continuing."
      }
    }
    if (-not (Test-Have 'fnm')) {
      # Could not install fnm without elevation -- surface the exact manual command + clean exit (a-AC-3).
      Show-NodeElevationHelp
      return $false
    }
  }

  # Load fnm into THIS session so node/npm resolve in-process (the install does not refresh the
  # current shell's PATH). `fnm env` emits the PowerShell shims; invoke them here.
  # Fail-soft on `fnm env`: the final `Test-Have 'node'/'npm'` gate below is the real decider; a failure
  # here must not abort the bootstrap, but the reason should be visible (not silently swallowed).
  try { fnm env --use-on-cd | Out-String | Invoke-Expression } catch {
    Write-Warning "fnm env (pre-install) didn't load into this session ($($_.Exception.Message)); continuing."
  }
  fnm install $HoneycombNodeVersion 2>$null | Out-Null
  fnm use $HoneycombNodeVersion 2>$null | Out-Null
  try { fnm env --use-on-cd | Out-String | Invoke-Expression } catch {
    Write-Warning "fnm env (post-install) didn't load into this session ($($_.Exception.Message)); continuing."
  }

  if ((Test-Have 'node') -and (Test-Have 'npm')) {
    Write-Ok "Installed Node $(node --version) via fnm."
    return $true
  }

  Show-NodeElevationHelp
  return $false
}

# -----------------------------------------------------------------------------
# Step 2 -- install @legioncodeinc/honeycomb globally. The embedding runtime is an
#           OPTIONAL dep pulled by npm here; its MODEL WEIGHTS are NOT fetched now
#           (lazy warmup -- 050b), so this stays fast.
# -----------------------------------------------------------------------------
function Install-Honeycomb {
  # Idempotent (mirrors install.sh's install_honeycomb): a re-run on a machine that already has
  # `honeycomb` is a NO-OP -- no npm mutation, no network -- so a rerun stays safe and succeeds
  # OFFLINE. Only an absent install triggers the global npm install.
  $existing = Resolve-HoneycombBin
  if ($existing) {
    Write-Ok "$HoneycombNpmPackage already installed ($existing)."
    return $true
  }
  $target = Resolve-CoreProductTarget 'honeycomb' $HoneycombNpmPackage
  Write-Step "installing $target globally..."
  npm install -g $target 2>$null | Out-Null
  if ($LASTEXITCODE -ne 0) {
    Write-Fail "the global install of $target failed."
    Write-Host ''
    Write-Host 'Try it directly to see the npm error, then re-run this installer:'
    Write-Host ''
    Write-Host "  npm install -g $target"
    Write-Host ''
    return $false
  }
  Write-Ok "installed $target."
  return $true
}

# Resolve the ABSOLUTE path to the freshly-installed honeycomb bin. `npm i -g` does NOT refresh the
# CURRENT session's PATH, so calling `honeycomb` by bare name in the same run can fail (PRD-050a
# impl-note). Resolve `%AppData%\npm\honeycomb.cmd` (the npm global bin shim on Windows).
function Resolve-HoneycombBin {
  $cmd = Get-Command 'honeycomb' -ErrorAction SilentlyContinue
  if ($cmd) { return $cmd.Source }
  $prefix = (npm prefix -g 2>$null)
  if ($prefix) {
    $candidate = Join-Path $prefix 'honeycomb.cmd'
    if (Test-Path $candidate) { return $candidate }
  }
  $appdataCmd = Join-Path $env:AppData 'npm\honeycomb.cmd'
  if (Test-Path $appdataCmd) { return $appdataCmd }
  return $null
}

# -----------------------------------------------------------------------------
# Step 3b -- Doctor bootstrap (PRD-064b). After the primary is installed, install the
#            Doctor watchdog (a second global) and register its per-user Scheduled Task,
#            UNLESS the user opted out. The opt-out is `-NoDoctor` / a bare `--no-doctor` in
#            $args (pre-rename aliases still accepted), or the env equivalent
#            $env:HONEYCOMB_NO_DOCTOR=1 (the ONLY install-time switch, OD-5). Idempotent +
#            FAIL-SOFT: a Doctor hiccup never fails the Honeycomb install -- the user still
#            lands on a working dashboard.
# -----------------------------------------------------------------------------

# True when the user opted OUT of Doctor (canonical --no-doctor / -NoDoctor /
# HONEYCOMB_NO_DOCTOR, or the pre-rename alias spellings). Mirrors
# doctor/src/service/install-guard.ts (shouldBootstrapDoctor) -- keep in sync. Reads the
# passed invocation args (the bare flag) + the env equivalent. Args are passed in explicitly
# because inside `irm | iex` there is no script-level $args to read.
function Test-DoctorOptedOut([string[]]$InvocationArgs) {
  $optOutFlags = @('--no-doctor', '-NoDoctor', '--no-doctor', '-NoDoctor')
  if ($InvocationArgs) {
    foreach ($flag in $optOutFlags) {
      if ($InvocationArgs -contains $flag) { return $true }
    }
  }
  foreach ($envVal in @($env:HONEYCOMB_NO_DOCTOR, $env:HONEYCOMB_NO_DOCTOR)) {
    if ($envVal) {
      $v = $envVal.Trim().ToLowerInvariant()
      if ($v -eq '1' -or $v -eq 'true') { return $true }
    }
  }
  return $false
}

# Resolve the absolute doctor bin shim (npm i -g does not refresh THIS session's PATH).
function Resolve-DoctorBin {
  $cmd = Get-Command 'doctor' -ErrorAction SilentlyContinue
  if ($cmd) { return $cmd.Source }
  $prefix = (npm prefix -g 2>$null)
  if ($prefix) {
    $candidate = Join-Path $prefix 'doctor.cmd'
    if (Test-Path $candidate) { return $candidate }
  }
  $appdataCmd = Join-Path $env:AppData 'npm\doctor.cmd'
  if (Test-Path $appdataCmd) { return $appdataCmd }
  return $null
}

# Install the Doctor global (idempotent) + register its per-user Scheduled Task. Every failure
# is a soft note, never a hard return -- the primary install already succeeded.
function Install-Doctor {
  if (Test-Have 'doctor') {
    Write-Ok "$DoctorNpmPackage already installed."
  } else {
    $hdTarget = Resolve-CoreProductTarget 'doctor' $DoctorNpmPackage
    Write-Step "installing the Doctor watchdog ($hdTarget)..."
    npm install -g $hdTarget 2>$null | Out-Null
    if ($LASTEXITCODE -ne 0) {
      Write-Host "note: could not install $hdTarget (continuing -- Honeycomb itself is installed)."
      Add-ProductNotInstalled 'doctor'
      return
    }
    Write-Ok "installed $hdTarget."
  }

  $hd = Resolve-DoctorBin
  if ($hd) {
    Write-Step 'registering the Doctor service (per-user Scheduled Task, no admin)...'
    & $hd install-service 2>$null | Out-Null
    if ($LASTEXITCODE -eq 0) {
      Write-Ok 'Doctor is watching (it will restart the daemon on crash and survive reboots).'
    } else {
      # IRD-192 AC-7: a non-zero exit now means the service manager rejected the unit (e.g. the old
      # invalid PT5S restart interval). Do NOT claim the watchdog is watching; name the actionable
      # command so the user can see why. Non-fatal: Honeycomb itself is already installed.
      Write-Host 'note: Doctor installed but its service did not register (continuing). Run ''doctor install-service'' to see why.'
    }
  }
}

# -----------------------------------------------------------------------------
# PRD-002b -- install + register hive / nectar when selected (the coverage-gap close).
# Generic across both products, mirroring install.sh's Install-ExtraProduct 1:1. Both hive
# (`hive install-service`) and nectar (`nectar install`) ALREADY implement a
# doctor-registry writer internally -- this installer reuses THEIR verb (exactly one writer
# per product, b-AC-3) rather than hand-rolling a second one here.
# -----------------------------------------------------------------------------
function Install-ExtraProduct {
  param(
    [string]$DisplayName,
    [string]$Slug,
    [string]$FallbackPkg,
    [string]$BinName,
    [string]$PostInstallVerb,
    [bool]$DryRun
  )

  $resolved = Resolve-ProductTarget $Slug $FallbackPkg

  if ($resolved.Kind -eq 'unpublished') {
    Write-Host "note: $DisplayName ($($resolved.Pkg)) is not yet published to npm -- skipping (a maintainer still needs to complete the one-time npm Trusted-Publisher bootstrap, PRD-001c). Re-run this installer after that lands."
    Add-ProductNotInstalled $Slug
    return $true
  }

  $target = $null
  if ($resolved.Kind -eq 'unresolved') {
    Write-Host "note: could not resolve the pinned version for $DisplayName from the release manifest -- falling back to $($resolved.Pkg)@latest."
    $target = "$($resolved.Pkg)@latest"
  } else {
    $target = $resolved.Target
  }

  if ($DryRun) {
    Write-Host "[dry-run] would run: npm install -g $target"
    Write-Host "[dry-run] would run: $BinName $PostInstallVerb"
    return $true
  }

  $ok = $true
  if (Test-Have $BinName) {
    Write-Ok "$DisplayName already installed ($((Get-Command $BinName).Source))."
  } else {
    Write-Step "installing $DisplayName ($target) globally..."
    npm install -g $target 2>$null | Out-Null
    if ($LASTEXITCODE -ne 0) {
      Write-Host "note: could not install $DisplayName (continuing -- the rest of the install still succeeded). Try: npm install -g $target"
      Add-ProductNotInstalled $Slug
      return $false
    }
    Write-Ok "installed $DisplayName."
  }

  $prodBin = $null
  $cmd = Get-Command $BinName -ErrorAction SilentlyContinue
  if ($cmd) {
    $prodBin = $cmd.Source
  } else {
    $prefix = (npm prefix -g 2>$null)
    if ($prefix) {
      $candidate = Join-Path $prefix "$BinName.cmd"
      if (Test-Path $candidate) { $prodBin = $candidate }
    }
  }

  if ($prodBin) {
    Write-Step "registering $DisplayName with doctor..."
    & $prodBin $PostInstallVerb 2>$null | Out-Null
    if ($LASTEXITCODE -eq 0) {
      Write-Ok "$DisplayName registered."
    } else {
      Write-Host "note: $DisplayName installed but its $PostInstallVerb step did not complete (continuing). Run '$BinName $PostInstallVerb' to see why."
      # A registration failure keeps the product OUT of the transition events, matching the
      # Set-InstallState gate (a failed selection is not recorded as installed).
      Add-ProductNotInstalled $Slug
      $ok = $false
    }
  }
  return $ok
}

# -----------------------------------------------------------------------------
# PRD-002b -- registration create/update/DELETE across lifecycle transitions. CREATE/UPDATE are
# handled above; DELETE is a --products= narrowing vs. the last run. See install.sh's
# Reconcile-RemovedProducts comment for the honest scope note (no npm uninstall, no full
# product-uninstall verb exists anywhere in this fleet yet).
# -----------------------------------------------------------------------------

# Normalized through the same alias map as the live selection, so a state file written before the
# July 2026 slug rename diffs cleanly against a post-rename selection (no spurious remove+install).
function Get-PreviousProducts {
  if (-not (Test-Path $HoneycombInstallStateFile)) { return $null }
  try {
    $state = Get-Content $HoneycombInstallStateFile -Raw | ConvertFrom-Json
    if ($state.products) { return (ConvertTo-CanonicalProducts ([string]$state.products)) }
  } catch {
    # Unreadable/malformed -> treated as "no prior state".
  }
  return $null
}

function Set-InstallState([string]$Products) {
  try {
    New-Item -ItemType Directory -Force -Path (Split-Path $HoneycombInstallStateFile) | Out-Null
    $state = @{ products = $Products; updatedAt = (Get-Date).ToString('o') } | ConvertTo-Json
    Set-Content -Path $HoneycombInstallStateFile -Value $state -ErrorAction SilentlyContinue
  } catch {
    # Fail-soft: bookkeeping only.
  }
}

# Deregister one product's entry from doctor's static registry by name (the "delete"
# transition). Fail-soft: any read/parse/write hiccup is swallowed.
function Remove-DoctorRegistryEntry([string]$Name) {
  if (-not (Test-Path $HoneycombDoctorRegistryFile)) { return }
  try {
    $doc = Get-Content $HoneycombDoctorRegistryFile -Raw | ConvertFrom-Json
    if (-not $doc.daemons) { return }
    $kept = @($doc.daemons | Where-Object { $_.name -ne $Name })
    if ($kept.Count -eq $doc.daemons.Count) { return }
    $doc.daemons = $kept
    $tmp = "$HoneycombDoctorRegistryFile.tmp-$PID-$(Get-Date -UFormat %s)"
    ($doc | ConvertTo-Json -Depth 10) | Set-Content -Path $tmp
    Move-Item -Force -Path $tmp -Destination $HoneycombDoctorRegistryFile
  } catch {
    # Fail-soft: a registry cleanup step must never fail the run that triggered it.
  }
}

function Resolve-RemovedProducts([string]$CurrentProducts, [string]$SelProfile, [string]$InstallId, [bool]$Repeat, [bool]$DryRun) {
  $previous = Get-PreviousProducts
  if (-not $previous) { return }
  $previousList = $previous.Split(',') | Where-Object { $_ -ne '' }
  $currentList = $CurrentProducts.Split(',') | Where-Object { $_ -ne '' }
  foreach ($p in $previousList) {
    if ($currentList -contains $p) { continue }
    # Per-product transition telemetry: this product WAS in the last run's selection and is gone
    # now (the DELETE transition). Fire-and-forget like every Send-PhoneHome call; fires for
    # EVERY dropped product, whether or not it has a deregistration branch below.
    Send-PhoneHome 'product_removed' $CurrentProducts $SelProfile $InstallId $Repeat $DryRun $p
    if ($p -eq 'hive' -or $p -eq 'nectar') {
      # The registry entry name is the product's own daemon name (hive registers as "hive",
      # nectar as "nectar"; those runtime names deliberately did not change with the slug
      # rename), so map slug -> registry name here. Mirrors install.sh (keep in parity).
      $registryName = if ($p -eq 'hive') { 'hive' } else { 'nectar' }
      if ($DryRun) {
        Write-Host "[dry-run] would deregister $p from doctor (no longer in --products=)."
      } else {
        Write-Step "deregistering $p from doctor (no longer in --products=)..."
        Remove-DoctorRegistryEntry $registryName
      }
    }
  }
}

# Per-product transition telemetry: product_installed / product_updated (product_removed fires
# from Resolve-RemovedProducts above). Diffs the PREVIOUS run's selection (install-state) vs this
# run's resolved selection, skipping any product that did not actually land
# ($script:ProductsNotInstalled). Same posture as every Send-PhoneHome call: fire-and-forget,
# silent no-op without a key, dry-run previews only, never affects the exit code. `repeat_install`
# covers the RUN; these events cover the PER-PRODUCT fact. Mirrors install.sh's
# phone_home_product_transitions (keep in parity).
function Send-ProductTransitions([string]$CurrentProducts, [string]$SelProfile, [string]$InstallId, [bool]$Repeat, [bool]$DryRun) {
  $previous = Get-PreviousProducts
  $previousList = @()
  if ($previous) { $previousList = $previous.Split(',') | Where-Object { $_ -ne '' } }
  $currentList = $CurrentProducts.Split(',') | Where-Object { $_ -ne '' }
  foreach ($p in $currentList) {
    if ($script:ProductsNotInstalled -contains $p) { continue } # selected but did not land: no claim
    if ($previousList -contains $p) {
      Send-PhoneHome 'product_updated' $CurrentProducts $SelProfile $InstallId $Repeat $DryRun $p
    } else {
      Send-PhoneHome 'product_installed' $CurrentProducts $SelProfile $InstallId $Repeat $DryRun $p
    }
  }
}

# -----------------------------------------------------------------------------
# PRD-009d thin bootstrap companion (bs-AC-1..8): bare invocation portal path
# -----------------------------------------------------------------------------
function Resolve-HiveBin {
  # Prefer the .cmd shim over the .ps1 one: Start-Process -WindowStyle Hidden is honored for a
  # .cmd (console app via CreateProcess), but a .ps1 launches through shell association, which
  # IGNORES the hidden window style and pops a visible PowerShell window when the daemon starts.
  $prefix = (npm prefix -g 2>$null)
  if ($prefix) {
    $candidate = Join-Path $prefix 'hive.cmd'
    if (Test-Path $candidate) { return $candidate }
  }
  $appdataCmd = Join-Path $env:AppData 'npm\hive.cmd'
  if (Test-Path $appdataCmd) { return $appdataCmd }
  $cmd = Get-Command 'hive' -ErrorAction SilentlyContinue
  if ($cmd) {
    # A .ps1 hit still gets swapped for its sibling .cmd when one exists (same npm bin dir).
    if ($cmd.Source -and $cmd.Source.ToLowerInvariant().EndsWith('.ps1')) {
      $sibling = [System.IO.Path]::ChangeExtension($cmd.Source, '.cmd')
      if (Test-Path $sibling) { return $sibling }
    }
    return $cmd.Source
  }
  return $null
}

function Resolve-HiveTargetStrict {
  $resolved = Resolve-ProductTarget 'hive' '@legioncodeinc/hive'
  if ($resolved.Kind -eq 'ok') { return $resolved.Target }
  if ($resolved.Kind -eq 'unpublished') {
    Write-Fail 'The pinned hive version from hive-release.json is not published yet, so the installer cannot continue.'
    return $null
  }
  Write-Fail 'The installer could not read a valid hive version from hive-release.json, so it cannot continue.'
  return $null
}

function Wait-HiveHealth {
  for ($i = 0; $i -lt 20; $i++) {
    try {
      Invoke-RestMethod -Uri $HiveHealthUrl -Method Get -TimeoutSec 1 -ErrorAction Stop | Out-Null
      return $true
    } catch {
      Start-Sleep -Seconds 1
    }
  }
  return $false
}

function New-OnboardingToken {
  $bytes = New-Object byte[] 32
  [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes)
  return (($bytes | ForEach-Object { $_.ToString('x2') }) -join '')
}

function Write-OnboardingToken {
  try {
    New-Item -ItemType Directory -Force -Path $HiveOnboardingDir | Out-Null
    $token = New-OnboardingToken
    Set-Content -Path $HiveOnboardingTokenFile -Value $token -NoNewline -Encoding ascii
    try {
      $acl = Get-Acl $HiveOnboardingTokenFile
      $inherit = [System.Security.AccessControl.InheritanceFlags]::None
      $propagation = [System.Security.AccessControl.PropagationFlags]::None
      $allow = [System.Security.AccessControl.AccessControlType]::Allow
      $currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
      $rule = New-Object System.Security.AccessControl.FileSystemAccessRule($currentUser, 'Read,Write', $inherit, $propagation, $allow)
      $acl.SetAccessRuleProtection($true, $false)
      foreach ($existing in @($acl.Access)) { $acl.RemoveAccessRule($existing) | Out-Null }
      $acl.AddAccessRule($rule)
      Set-Acl -Path $HiveOnboardingTokenFile -AclObject $acl
    } catch {
      # Best effort ACL tightening, never block install on ACL tooling variance.
    }
    return $token
  } catch {
    return $null
  }
}

function Open-OnboardingUrl([string]$Token) {
  $url = "${HiveOnboardingBaseUrl}?t=$Token"
  try { Start-Process $url -ErrorAction SilentlyContinue | Out-Null } catch { }
}

function Invoke-PortalMain([string[]]$InvocationArgs) {
  if ($InvocationArgs -and ($InvocationArgs -contains '--help' -or $InvocationArgs -contains '-h')) {
    Show-Usage
    return 0
  }

  $dryRun = Test-HasFlag $InvocationArgs '--dry-run'
  $installIdInfo = Resolve-InstallId $dryRun
  Send-PhoneHome 'install_started' 'hive' '' $installIdInfo.Id $installIdInfo.Repeat $dryRun

  if ($dryRun) {
    Write-Host 'resolved path (--dry-run, nothing will be installed/registered/sent): portal bootstrap (hive only)'
    Write-Host '  products = hive'
    Write-Host '  profile  = <none>'
    Write-Host "  install id = $($installIdInfo.Id) (repeat=$($installIdInfo.Repeat))"
  }

  $finish = {
    param([int]$Code)
    if ($Code -eq 0) {
      Send-PhoneHome 'install_completed' 'hive' '' $installIdInfo.Id $installIdInfo.Repeat $dryRun
    } else {
      Send-PhoneHome 'install_failed' 'hive' '' $installIdInfo.Id $installIdInfo.Repeat $dryRun
    }
    return $Code
  }

  if ($dryRun) {
    if ((Test-Have 'node') -and (Test-Have 'npm')) {
      Write-Ok "Node $(node --version) and npm $(npm --version) found (dry-run: no bootstrap attempted)."
    } else {
      Write-Host 'note: node/npm not found (dry-run, a real run would attempt to install them via fnm).'
    }
  } else {
    if (-not (Ensure-Node)) { return (& $finish 1) }
  }

  if ($dryRun) {
    $previewResolved = Resolve-ProductTarget 'hive' '@legioncodeinc/hive'
    if ($previewResolved.Kind -eq 'ok') {
      $hiveTarget = $previewResolved.Target
    } elseif ($previewResolved.Kind -eq 'unpublished') {
      Write-Host '[dry-run] would fail: pinned hive version from hive-release.json is not published yet.'
      return (& $finish 0)
    } else {
      Write-Host '[dry-run] would fail: installer could not read a valid hive version from hive-release.json.'
      return (& $finish 0)
    }
    if (Resolve-HiveBin) {
      Write-Host '[dry-run] would skip npm install because hive is already installed.'
    } else {
      Write-Host "[dry-run] would run: npm install -g $hiveTarget"
    }
    Write-Host "[dry-run] would mint onboarding token at $HiveOnboardingTokenFile (0600-equivalent ACL)."
    Write-Host '[dry-run] would run: hive install-service'
    Write-Host "[dry-run] would poll: $HiveHealthUrl"
    Write-Host '[dry-run] would run (if needed): hive start'
    Write-Host "[dry-run] would open: ${HiveOnboardingBaseUrl}?t=<token>"
    Write-Host "[dry-run] would print: Click here if the portal doesn't open automatically: ${HiveOnboardingBaseUrl}?t=<token>"
    return (& $finish 0)
  }

  $hiveTarget = Resolve-HiveTargetStrict
  if (-not $hiveTarget) { return (& $finish 1) }

  $hiveBin = Resolve-HiveBin
  if ($hiveBin) {
    Write-Ok "hive is already installed ($hiveBin)."
  } else {
    Write-Step "installing $hiveTarget globally..."
    npm install -g $hiveTarget 2>$null | Out-Null
    if ($LASTEXITCODE -ne 0) {
      Write-Fail 'The installer could not install the pinned hive version from hive-release.json.'
      return (& $finish 1)
    }
    Write-Ok "installed $hiveTarget."
    $hiveBin = Resolve-HiveBin
    if (-not $hiveBin) {
      Write-Fail 'The installer installed hive but could not locate the hive command.'
      return (& $finish 1)
    }
  }

  $token = Write-OnboardingToken
  if (-not $token) {
    Write-Fail 'The installer could not create a secure onboarding token file.'
    return (& $finish 1)
  }

  Write-Step 'starting hive daemon for onboarding...'
  & $hiveBin install-service 2>$null | Out-Null
  if ($LASTEXITCODE -ne 0) {
    Write-Host 'note: hive install-service did not complete, continuing with direct startup.'
  }

  if (-not (Wait-HiveHealth)) {
    Start-Process -WindowStyle Hidden -FilePath $hiveBin -ArgumentList 'start' | Out-Null
    if (-not (Wait-HiveHealth)) {
      Write-Fail 'The hive onboarding portal did not start on http://127.0.0.1:3853.'
      return (& $finish 1)
    }
  }

  Open-OnboardingUrl $token
  # The fallback link MUST carry the one-time token: the onboarding screen refuses every
  # installer call without it (401), so a tokenless /onboarding visit can never proceed.
  Write-Host "Click here if the portal doesn't open automatically: ${HiveOnboardingBaseUrl}?t=$token"
  return (& $finish 0)
}

# -----------------------------------------------------------------------------
# Step 3 -- hand off to the CLI verb for the daemon-ensure + health-gate + dashboard
#           handling. The verb is idempotent + health-gated (a-AC-2 / a-AC-4), writes
#           onboarding "installed" (a-AC-5), and either opens the portal when reachable or
#           prints one plain sentence with the install command for Hive when it is not.
# -----------------------------------------------------------------------------
# Returns a status CODE (never calls `exit`): in the documented `irm ... | iex` bootstrap, `exit`
# terminates the CALLER's PowerShell host and can close the user's terminal. The single process-exit
# handling lives at the entrypoint below, which sets `$global:LASTEXITCODE` from this return value.
function Invoke-LegacyMain([string[]]$InvocationArgs) {
  if ($InvocationArgs -and ($InvocationArgs -contains '--help' -or $InvocationArgs -contains '-h')) {
    Show-Usage
    return 0
  }

  $dryRun = Test-HasFlag $InvocationArgs '--dry-run'

  # c-AC-1: fires BEFORE any product resolution, using only Invoke-RestMethod (no Node/npm
  # dependency -- native to PowerShell).
  $installIdInfo = Resolve-InstallId $dryRun
  Send-PhoneHome 'install_started' '' '' $installIdInfo.Id $installIdInfo.Repeat $dryRun

  $selection = Resolve-Selection $InvocationArgs

  if ($dryRun) {
    Write-Host 'resolved selection (--dry-run, nothing will be installed/registered/sent):'
    Write-Host "  products = $($selection.Products)"
    $profileDisplay = $selection.Profile; if (-not $profileDisplay) { $profileDisplay = '<none>' }
    Write-Host "  profile  = $profileDisplay"
    if ($selection.License) { Write-Host "  license  = <redacted, $($selection.License.Length) chars>" } else { Write-Host '  license  = <none>' }
    $codeDisplay = $selection.Code; if (-not $codeDisplay) { $codeDisplay = '<none>' }
    Write-Host "  code     = $codeDisplay"
    Write-Host "  install id = $($installIdInfo.Id) (repeat=$($installIdInfo.Repeat))"
  }

  $finish = {
    param([int]$Code)
    if ($Code -eq 0) {
      Send-PhoneHome 'install_completed' $selection.Products $selection.Profile $installIdInfo.Id $installIdInfo.Repeat $dryRun
    } else {
      Send-PhoneHome 'install_failed' $selection.Products $selection.Profile $installIdInfo.Id $installIdInfo.Repeat $dryRun
    }
    return $Code
  }

  if ($dryRun) {
    if ((Test-Have 'node') -and (Test-Have 'npm')) {
      Write-Ok "Node $(node --version) and npm $(npm --version) found (dry-run: no bootstrap attempted)."
    } else {
      Write-Host 'note: node/npm not found (dry-run -- a real run would attempt to install them via fnm).'
    }
  } else {
    if (-not (Ensure-Node)) { return (& $finish 1) }
  }

  $productList = $selection.Products.Split(',') | Where-Object { $_ -ne '' }

  if ($productList -contains 'honeycomb') {
    if ($dryRun) {
      Write-Host "[dry-run] would run: npm install -g $(Resolve-CoreProductTarget 'honeycomb' $HoneycombNpmPackage)"
    } else {
      if (-not (Install-Honeycomb)) { return (& $finish 1) }
    }
  }

  $bin = $null
  if (-not $dryRun) {
    $bin = Resolve-HoneycombBin
    if (-not $bin) {
      Write-Fail "could not locate the installed 'honeycomb' command after the global install."
      Write-Host ''
      Write-Host 'Open a NEW terminal (so PATH refreshes) and run:'
      Write-Host ''
      Write-Host '  honeycomb install'
      Write-Host ''
      return (& $finish 1)
    }
  }

  # Doctor bootstrap (PRD-064b), now ALSO gated on doctor being in the resolved selection.
  if ($productList -contains 'doctor') {
    if (Test-DoctorOptedOut $InvocationArgs) {
      Write-Step 'skipping Doctor (--no-doctor).'
      # Opted out: doctor stays selected but does not land, so it earns no transition event.
      Add-ProductNotInstalled 'doctor'
    } elseif ($dryRun) {
      Write-Host "[dry-run] would install + register Doctor ($(Resolve-CoreProductTarget 'doctor' $DoctorNpmPackage))."
    } else {
      Install-Doctor
    }
  } else {
    Write-Step 'skipping Doctor (not in --products=).'
  }

  # PRD-002b: actually install hive / nectar when selected (the coverage-gap close). The bin
  # names (`hive` / `nectar`) are the products' own CLI bins and deliberately did not
  # change with the slug rename. Mirrors install.sh's EXTRA_PRODUCT_FAILED: a failed SELECTED
  # product must not be recorded as installed (Set-InstallState below) nor reported as
  # install_completed (the final exit path).
  $extraProductFailed = $false
  if ($productList -contains 'hive') {
    if (-not (Install-ExtraProduct 'Hive' 'hive' '@legioncodeinc/hive' 'hive' 'install-service' $dryRun)) {
      $extraProductFailed = $true
    }
  }
  if ($productList -contains 'nectar') {
    if (-not (Install-ExtraProduct 'Nectar' 'nectar' '@legioncodeinc/nectar' 'nectar' 'install' $dryRun)) {
      $extraProductFailed = $true
    }
  }

  # PRD-002b DELETE transition: a --products= narrowing vs. the last run (fires product_removed).
  Resolve-RemovedProducts $selection.Products $selection.Profile $installIdInfo.Id $installIdInfo.Repeat $dryRun
  # Per-product CREATE/UPDATE transitions: product_installed / product_updated. Must run BEFORE
  # Set-InstallState below (the diff baseline is the PREVIOUS run's recorded selection).
  Send-ProductTransitions $selection.Products $selection.Profile $installIdInfo.Id $installIdInfo.Repeat $dryRun
  if (-not $dryRun -and -not $extraProductFailed) { Set-InstallState $selection.Products }

  if ($dryRun) {
    Write-Host '[dry-run] would hand off to: honeycomb install (daemon-ensure + honest dashboard handling)'
    return (& $finish 0)
  }

  # The verb prints its own friendly step log and returns a clean exit code; forward it verbatim. A
  # handled failure inside the verb is already a plain-language line + non-zero exit -- no raw trace.
  # Forward the caller's args MINUS every installer-only flag this script itself consumed (mirrors
  # install.sh's filtering), so a bootstrap `--ref <code>` (and any future verb flag) still reaches
  # the CLI's install verb.
  $forwardArgs = @()
  if ($InvocationArgs) {
    foreach ($a in $InvocationArgs) {
      if ($a -eq '--no-doctor' -or $a -eq '-NoDoctor' -or $a -eq '--no-doctor' -or $a -eq '-NoDoctor' -or $a -eq '--dry-run') { continue }
      if ($a -like '--products=*' -or $a -like '--profile=*' -or $a -like '--license=*' -or $a -like '--code=*') { continue }
      $forwardArgs += $a
    }
  }
  & $bin install @forwardArgs
  $cliStatus = $LASTEXITCODE
  # Propagate a selected extra-product failure into the terminal state (mirrors install.sh): never
  # report install_completed / exit 0 when a product the user explicitly selected failed.
  if ($cliStatus -ne 0) { return (& $finish $cliStatus) }
  if ($extraProductFailed) {
    Write-Fail 'one of the selected products did not install/register (see the notes above); Honeycomb itself is installed.'
    return (& $finish 1)
  }
  return (& $finish 0)
}

# Entrypoint: route by selection seam (PRD-009d). Any explicit product-selection signal
# (flags/env/config with products/profile/code/license) keeps legacy behavior unchanged.
function Invoke-Main([string[]]$InvocationArgs) {
  if (Test-SelectionExpressed $InvocationArgs) {
    return (Invoke-LegacyMain $InvocationArgs)
  }
  return (Invoke-PortalMain $InvocationArgs)
}

# Set the exit code ONCE without tearing down the host (so `irm | iex` hands control back to the
# user's session instead of closing it).
$global:LASTEXITCODE = Invoke-Main $args
update.sh (POSIX sh)
#!/bin/sh
# Apiary one-command fleet UPDATE script (POSIX sh); the-apiary PRD-007.
#
# Usage (the single line a user pastes to move the installed fleet to the blessed set):
#   curl -fsSL https://get.theapiary.sh/update | sh
#
# Opt into the newest published bytes (bypasses the tested fleet set):
#   curl -fsSL https://get.theapiary.sh/update | sh -s -- --latest
#
# Preview only (resolve + print, mutate nothing, send no telemetry):
#   curl -fsSL https://get.theapiary.sh/update | sh -s -- --dry-run
#
# Contract (PRD-007 AC-1..AC-10): this is the THIRD lifecycle script beside install.sh and
# uninstall.sh. It detects which Apiary products are actually installed on the machine and, BY
# DEFAULT, moves each to its hive-release.json manifest-pinned (blessed) version -- matching the
# installer's deliberate no-`@latest` invariant. It is IDEMPOTENT (already-current machines make no
# npm mutation and restart nothing), FAIL-SOFT (one product's failure never blocks the rest), and
# NON-DESTRUCTIVE (it never `npm uninstall`s, never deletes state, and never leaves a daemon stopped
# without saying so). After a package moves it converges + restarts that product's service so the
# running daemon serves the new code, and (when the honeycomb package moved) refreshes the coding
# assistant plugin. It reports on the same anonymous install-site PostHog channel as the installer.
#
# POSIX sh ONLY (no bashisms): this runs under `sh`, which may be dash/ash, not bash.
#
# "mirror, don't share" (PRD-007 Decided): rather than source a shared fragment, this script carries
# its OWN copies of the installer's telemetry seam and manifest resolver, exactly as install.ps1
# mirrors install.sh. Each mirrored function is tagged `# SYNC: mirror of <file> <fn>` so a
# maintainer editing one copy knows to update the other.

# `set -e` would abort on the FIRST non-zero command, surfacing a raw error. We instead handle every
# failure explicitly and print a plain-language line (parent AC-9); so `set -e` is intentionally OFF.
set -u

# ─────────────────────────────────────────────────────────────────────────────
# The Node LTS the installer provisions (referenced only in the "Node is missing" copy below; the
# updater assumes a working Node/npm and never bootstraps one -- that is an installer concern).
# ─────────────────────────────────────────────────────────────────────────────
HONEYCOMB_NODE_VERSION="22"

# Distribution base URL (used only in the "re-run" copy of the Node-missing message).
HONEYCOMB_INSTALL_BASE_URL="https://get.theapiary.sh"

# ── The fleet release manifest (the-apiary superproject's hive-release.json). Same URL + raw-GitHub
# fallback as install.sh; the updater never hardcodes "latest" for a product it did not itself
# publish -- it resolves each installed product's exact pinned version from THIS manifest (unless
# --latest is passed, which bypasses it). Overridable via env purely for local testing. ──
HONEYCOMB_MANIFEST_URL="${HONEYCOMB_MANIFEST_URL:-https://get.theapiary.sh/hive-release.json}"
HONEYCOMB_MANIFEST_FALLBACK_URL="https://raw.githubusercontent.com/legioncodeinc/the-apiary/main/hive-release.json"

# ── Telemetry destination (PRD-007c). The key is EMPTY in source control BY DESIGN; this exact
# `HONEYCOMB_INSTALL_POSTHOG_KEY=""` line is the one site/install/build.mjs patches (via an anchored
# regex on this literal line) at deploy time, injecting the real PostHog project key. An empty value
# (any un-built/local/dev copy) makes `phone_home` a silent no-op; never a hard failure. The same
# public install-site channel, key seam, endpoint, and payload shape as the installer -- only the
# event NAMES differ (update_started / update_completed / update_failed, reusing product_updated). ──
HONEYCOMB_INSTALL_POSTHOG_KEY="phc_wjWdFZfMRtUATshcoBRkZ3FiSMmAKEuVuP6ftraTCiPz"
HONEYCOMB_INSTALL_POSTHOG_HOST="https://us.i.posthog.com"
HONEYCOMB_INSTALL_POSTHOG_PATH="/i/v0/e/"
HONEYCOMB_INSTALL_ID_FILE="${HOME}/.honeycomb/install-id"

# ── Globals (all `set -u` safe: declared up-front so every later reference is well-defined). ──
DRY_RUN=0
LATEST=0
INSTALL_ID=""
IS_REPEAT_INSTALL="false"
# phone_home reads SEL_PRODUCTS/SEL_PROFILE. On update, `products` is "the products that moved"
# (PRD-007c) and there is no profile concept, so SEL_PROFILE stays empty. SEL_PRODUCTS is set to the
# moved list just before the terminal telemetry fires.
SEL_PRODUCTS=""
SEL_PROFILE=""
# Comma list of product slugs that ACTUALLY moved this run (drives product_updated + the summary).
MOVED_PRODUCTS=""
MOVED_COUNT=0
# How many products were detected installed (distinguishes "nothing installed" from "all current").
INSTALLED_COUNT=0
# Set when any INSTALLED product that should have moved failed to update (mirrors install.sh's
# EXTRA_PRODUCT_FAILED): the run reports update_failed / exits non-zero, but only AFTER attempting
# every product (fail-soft, never aborts the loop).
ANY_FAILED=0
# Set when the plugin-bearing `honeycomb` package moved (gates the 007b harness/plugin refresh).
HONEYCOMB_MOVED=0
# Populated by detect_harnesses (007b): HARNESS_STATE in {detected,none,unknown}; HARNESS_OUT is the
# harness list printed when detected. Declared here so every reference is `set -u` safe.
HARNESS_STATE=""
HARNESS_OUT=""

# ── Friendly progress log: step lines to stdout, the single failure summary to stderr. ASCII-only
# prefixes (matching uninstall.sh) so a non-UTF-8 pipe never corrupts them. ──
step() { printf '%s\n' "-> $1"; }
ok()   { printf '[ok] %s\n' "$1"; }
warn() { printf '[warn] %s\n' "$1"; }
fail() { printf 'Apiary update could not continue: %s\n' "$1" >&2; }

# `command -v` is the POSIX way to test for a binary (NOT `which`, which is not guaranteed present).
have() { command -v "$1" >/dev/null 2>&1; }

# is_windows_shell: true under git-bash/MSYS/Cygwin, where pid files carry WINDOWS pids that
# `kill -0`/`ps` cannot see (the probe/kill go through PowerShell there).
# SYNC: mirror of uninstall.sh is_windows_shell
is_windows_shell() {
	case "$(uname -s 2>/dev/null || printf 'unknown')" in
		MINGW*|MSYS*|CYGWIN*) return 0 ;;
		*) return 1 ;;
	esac
}

print_usage() {
	cat <<'USAGE'
Usage: update.sh [--latest] [--dry-run] [--help]

  --latest    Update each installed product to its npm 'latest' dist-tag instead of the blessed
              (hive-release.json-pinned) version. Prints a warning; bypasses the tested fleet set.
  --dry-run   Resolve and print every product's current -> target decision and the services it
              would restart; mutate nothing and send no real telemetry (preview only).
  --help,-h   Show this help text.

By default (no flag) every INSTALLED Apiary product is moved to its blessed, manifest-pinned
version; a product that is not installed is left untouched (this is an update, not an installer).
Env equivalent for --latest: APIARY_UPDATE_LATEST=1.
USAGE
}

# Scan argv for the flags this script consumes. `--help`/`-h` is handled by main BEFORE any
# telemetry (a usage request is not a "run"); an unknown flag is a plain usage error (also
# pre-telemetry). Also reads the APIARY_UPDATE_LATEST env equivalent (a-AC-1b: --latest is strictly
# opt-in and never implied by any other flag or default).
parse_args() {
	case "${APIARY_UPDATE_LATEST:-}" in
		1|true|TRUE|True) LATEST=1 ;;
	esac
	for a in "$@"; do
		case "$a" in
			--dry-run) DRY_RUN=1 ;;
			--latest)  LATEST=1 ;;
			--help|-h) : ;;
			*)
				fail "Unknown flag: $a. Use --help to see supported flags."
				return 1
				;;
		esac
	done
	return 0
}

# ═══════════════════════════════════════════════════════════════════════════════════════════════
# PRD-007c; anonymous install id + phone-home (ported verbatim from install.sh: same id file, same
# endpoint, same body shape, same 3s timeout, same empty-key no-op -- only the event names differ).
# ═══════════════════════════════════════════════════════════════════════════════════════════════

# SYNC: mirror of install.sh generate_uuid
# Best-effort UUID-shaped id. ANONYMOUS (no hostname/username/MAC ever folds in, at any tier).
generate_uuid() {
	if have uuidgen; then uuidgen; return 0; fi
	if [ -r /proc/sys/kernel/random/uuid ]; then cat /proc/sys/kernel/random/uuid; return 0; fi
	if have od; then
		hex="$(od -An -N16 -tx1 /dev/urandom 2>/dev/null | tr -d ' \n')"
		if [ "${#hex}" -eq 32 ]; then
			printf '%s-%s-4%s-%s-%s\n' \
				"$(printf '%s' "$hex" | cut -c1-8)" \
				"$(printf '%s' "$hex" | cut -c9-12)" \
				"$(printf '%s' "$hex" | cut -c14-16)" \
				"$(printf '%s' "$hex" | cut -c17-20)" \
				"$(printf '%s' "$hex" | cut -c21-32)"
			return 0
		fi
	fi
	printf 'nohd-%s-%s\n' "$(date +%s 2>/dev/null || echo 0)" "$$"
}

# SYNC: mirror of install.sh resolve_install_id
# Resolve (or, outside --dry-run, mint + persist) the stable anonymous install id (c-AC-5). The
# updater READS the same ~/.honeycomb/install-id the installer wrote; IS_REPEAT_INSTALL is "true"
# iff the id file already existed (an update is inherently a repeat interaction, but the semantics
# stay identical to the installer's -- PRD-007c keeps the field as-is). In --dry-run this NEVER
# writes: an ephemeral id is generated in-memory purely for the preview.
resolve_install_id() {
	if [ -f "$HONEYCOMB_INSTALL_ID_FILE" ] && [ -s "$HONEYCOMB_INSTALL_ID_FILE" ]; then
		INSTALL_ID="$(cat "$HONEYCOMB_INSTALL_ID_FILE" 2>/dev/null | tr -d '\n')"
		IS_REPEAT_INSTALL="true"
		return 0
	fi
	INSTALL_ID="$(generate_uuid)"
	IS_REPEAT_INSTALL="false"
	if [ "$DRY_RUN" -ne 1 ]; then
		mkdir -p "$(dirname "$HONEYCOMB_INSTALL_ID_FILE")" 2>/dev/null
		printf '%s\n' "$INSTALL_ID" > "$HONEYCOMB_INSTALL_ID_FILE" 2>/dev/null || true
	fi
}

# SYNC: mirror of install.sh phone_home
# Fire ONE PostHog capture event. FAIL-SOFT + BOUNDED (--max-time 3): a slow/unreachable endpoint
# never hangs or breaks the update. Same endpoint + body shape as install.sh; only `curl` is needed,
# so it fires even before the honeycomb CLI is resolved. Allow-list-shaped payload (no PII; never a
# license/code value -- there are none in this script anyway): products, profile, coarse OS family,
# repeat-vs-first, and the event name (doubling as a coarse terminal-status label). The optional
# second arg is the per-product transition field appended as `"product":"<slug>"`.
# phone_home <event> [product]
phone_home() {
	event="$1"
	product="${2:-}"
	if [ "$DRY_RUN" -eq 1 ]; then
		if [ -n "$product" ]; then
			printf '[dry-run] would phone home: %s (product=%s, install_id=%s, repeat=%s, products=%s, profile=%s)\n' \
				"$event" "$product" "${INSTALL_ID:-unknown}" "$IS_REPEAT_INSTALL" "${SEL_PRODUCTS:-<none>}" "${SEL_PROFILE:-<none>}"
		else
			printf '[dry-run] would phone home: %s (install_id=%s, repeat=%s, products=%s, profile=%s)\n' \
				"$event" "${INSTALL_ID:-unknown}" "$IS_REPEAT_INSTALL" "${SEL_PRODUCTS:-<none>}" "${SEL_PROFILE:-<none>}"
		fi
		return 0
	fi
	[ -z "$HONEYCOMB_INSTALL_POSTHOG_KEY" ] && return 0
	have curl || return 0
	product_prop=""
	[ -n "$product" ] && product_prop="$(printf ',"product":"%s"' "$product")"
	body=$(printf '{"api_key":"%s","event":"%s","distinct_id":"%s","properties":{"products":"%s","profile":"%s","os":"%s","repeat_install":"%s"%s}}' \
		"$HONEYCOMB_INSTALL_POSTHOG_KEY" "$event" "${INSTALL_ID:-unknown}" "${SEL_PRODUCTS:-}" "${SEL_PROFILE:-}" \
		"$(uname -s 2>/dev/null || echo unknown)" "$IS_REPEAT_INSTALL" "$product_prop")
	curl -fsS --max-time 3 -H 'Content-Type: application/json' -d "$body" \
		"${HONEYCOMB_INSTALL_POSTHOG_HOST}${HONEYCOMB_INSTALL_POSTHOG_PATH}" >/dev/null 2>&1 || true
	return 0
}

# SYNC: mirror of install.sh finish (event names retargeted for update)
# Terminal-state telemetry (c-AC-6): every exit from main() funnels through here so exactly one of
# update_completed / update_failed always fires -- including a failure BEFORE the honeycomb CLI ever
# runs (e.g. Node missing).
finish() {
	code="$1"
	if [ "$code" -eq 0 ]; then
		phone_home update_completed
	else
		phone_home update_failed
	fi
	exit "$code"
}

# ═══════════════════════════════════════════════════════════════════════════════════════════════
# PRD-007a; manifest resolver (mirrored from install.sh; the security-critical safe-shape validation
# is kept intact so a tampered manifest field can never reach npm/the shell unvalidated).
# ═══════════════════════════════════════════════════════════════════════════════════════════════

_MANIFEST_JSON=""
_MANIFEST_FETCHED=0

# SYNC: mirror of install.sh fetch_manifest
# Fetch the manifest ONCE per run (cached in _MANIFEST_JSON); a network failure is remembered too
# (no per-field retry) and surfaces as "unresolved" to every caller.
fetch_manifest() {
	[ "$_MANIFEST_FETCHED" -eq 1 ] && { [ -n "$_MANIFEST_JSON" ]; return $?; }
	_MANIFEST_FETCHED=1
	have curl || return 1
	_MANIFEST_JSON="$(curl -fsSL --max-time 5 "$HONEYCOMB_MANIFEST_URL" 2>/dev/null)" || _MANIFEST_JSON=""
	if [ -z "$_MANIFEST_JSON" ]; then
		_MANIFEST_JSON="$(curl -fsSL --max-time 5 "$HONEYCOMB_MANIFEST_FALLBACK_URL" 2>/dev/null)" || _MANIFEST_JSON=""
	fi
	[ -n "$_MANIFEST_JSON" ] || return 1
	return 0
}

# SYNC: mirror of install.sh manifest_field
# manifest_field <slug> <field>; prints the field's value, or nothing (+ returns 1) if unresolved.
# Parses JSON via `node` (guaranteed present by the time a real caller reaches here) rather than a
# hand-rolled sed/grep parser for a document this script does not control byte-for-byte.
manifest_field() {
	slug="$1"; field="$2"
	fetch_manifest || return 1
	have node || return 1
	printf '%s' "$_MANIFEST_JSON" | node -e '
		let raw = "";
		process.stdin.on("data", (d) => { raw += d; });
		process.stdin.on("end", () => {
			try {
				const m = JSON.parse(raw);
				const p = m && m.products && m.products[process.argv[1]];
				if (!p || p[process.argv[2]] === undefined) process.exit(1);
				process.stdout.write(String(p[process.argv[2]]));
			} catch (e) { process.exit(1); }
		});
	' "$slug" "$field" 2>/dev/null
}

# SYNC: mirror of install.sh npm_package_name_is_safe
# A conservative safe-character allowlist matching real npm package-name rules (lowercase, digits,
# `.`/`_`/`-`, optionally `@scope/name`); makes it structurally impossible to smuggle a shell/cmd
# metacharacter through a manifest-sourced package name.
npm_package_name_is_safe() {
	case "$1" in
		@[a-z0-9]*/[a-z0-9]*)
			scope="${1%%/*}"; name="${1#*/}"
			case "$scope" in @*[!a-z0-9._-]*|@) return 1 ;; esac
			case "$name" in *[!a-z0-9._-]*|"") return 1 ;; esac
			return 0
			;;
		[a-z0-9]*)
			case "$1" in *[!a-z0-9._-]*) return 1 ;; esac
			return 0
			;;
		*) return 1 ;;
	esac
}

# SYNC: mirror of install.sh semver_is_safe
# digits.digits.digits with an optional -prerelease / +build suffix drawn from the same safe set.
semver_is_safe() {
	case "$1" in
		[0-9]*.[0-9]*.[0-9]*)
			case "$1" in *[!0-9A-Za-z.+-]*) return 1 ;; esac
			return 0
			;;
		*) return 1 ;;
	esac
}

# SYNC: mirror of install.sh resolve_product_target
# Resolve the manifest-pinned npm target for a product slug. Prints exactly one of:
#   "ok <pkg>@<version>"   -- the blessed target to install
#   "unpublished <pkg>"    -- manifest declares published:false; skip (never fall back to @latest)
#   "unresolved <pkg>"     -- manifest unreachable/malformed OR a field failed the safe-shape check;
#                             the updater treats this as "leave the product at its current version"
#                             in blessed mode (a-AC-4: never a silent @latest fallback here).
resolve_product_target() {
	slug="$1"; fallback_pkg="$2"
	pkg="$(manifest_field "$slug" packageName)"
	if [ -z "$pkg" ] || ! npm_package_name_is_safe "$pkg"; then pkg="$fallback_pkg"; fi
	version="$(manifest_field "$slug" version)"
	if [ -z "$version" ] || ! semver_is_safe "$version"; then
		printf 'unresolved %s\n' "$pkg"
		return 0
	fi
	published="$(manifest_field "$slug" published)"
	if [ "$published" = "false" ]; then
		printf 'unpublished %s\n' "$pkg"
		return 0
	fi
	printf 'ok %s@%s\n' "$pkg" "$version"
}

# ═══════════════════════════════════════════════════════════════════════════════════════════════
# Installed-product detection + version reads (npm ls -g, exactly uninstall.sh's probe).
# ═══════════════════════════════════════════════════════════════════════════════════════════════

# is_installed <pkg>: true when the global npm package is present (a-AC-3 authoritative signal).
is_installed() {
	npm ls -g "$1" --depth=0 >/dev/null 2>&1
}

# installed_version <pkg>: prints the currently-installed global version, or nothing. Reads
# `npm ls -g --json` and parses with `node` (present here, the same posture install.sh uses to parse
# the manifest) rather than grepping npm's human output.
installed_version() {
	have npm || return 1
	have node || return 1
	npm ls -g "$1" --depth=0 --json 2>/dev/null | node -e '
		let raw = "";
		process.stdin.on("data", (d) => { raw += d; });
		process.stdin.on("end", () => {
			try {
				const j = JSON.parse(raw);
				const deps = j && j.dependencies;
				const e = deps && deps[process.argv[1]];
				if (!e || !e.version) process.exit(1);
				process.stdout.write(String(e.version));
			} catch (err) { process.exit(1); }
		});
	' "$1" 2>/dev/null
}

# Resolve the ABSOLUTE path to an installed product bin. `npm i -g` does NOT refresh the CURRENT
# shell's PATH, so calling a bin by bare name in the same run can fail "command not found"; resolve
# `<npm prefix -g>/bin/<bin>` and invoke THAT (generalized from install.sh's resolve_honeycomb_bin).
resolve_bin() {
	if have "$1"; then command -v "$1"; return 0; fi
	rb_prefix="$(npm prefix -g 2>/dev/null)"
	if [ -n "$rb_prefix" ] && [ -x "${rb_prefix}/bin/$1" ]; then
		printf '%s\n' "${rb_prefix}/bin/$1"
		return 0
	fi
	return 1
}

# Record a product that actually moved (feeds product_updated telemetry + the completion summary).
record_moved() {
	MOVED_PRODUCTS="${MOVED_PRODUCTS}${MOVED_PRODUCTS:+,}$1"
	MOVED_COUNT=$((MOVED_COUNT + 1))
	[ "$1" = "honeycomb" ] && HONEYCOMB_MOVED=1
	return 0
}

# ═══════════════════════════════════════════════════════════════════════════════════════════════
# Service restart (PRD-007a Decided: converge-first, recycle-only-if-needed).
# ═══════════════════════════════════════════════════════════════════════════════════════════════

# SYNC: mirror of uninstall.sh pid_image_name
# Prints the pid's image name, or nothing when the pid is not alive.
pid_image_name() {
	pin_pid="$1"
	if is_windows_shell; then
		powershell -NoProfile -Command "(Get-CimInstance Win32_Process -Filter \"ProcessId=$pin_pid\" -ErrorAction SilentlyContinue).Name" 2>/dev/null | tr -d '\r\n '
		return 0
	fi
	if kill -0 "$pin_pid" >/dev/null 2>&1; then
		ps -p "$pin_pid" -o comm= 2>/dev/null | tr -d ' '
	fi
	return 0
}

# Recycle one product's running daemon by pid file so it reloads the new bytes. Verifies a LIVE NODE
# process before signalling (pid-reuse safe, a-AC-6). Returns 0 iff it actually stopped a daemon.
# SYNC: mirror of uninstall.sh stop_daemon_pidfile (recycle, not remove: the service manager/Doctor
# restarts the daemon after this; we re-converge afterwards to guarantee it comes back up).
recycle_one_pidfile() {
	rop_pidfile="$1"; rop_label="$2"
	[ -f "$rop_pidfile" ] || return 1
	rop_pid="$(head -c 32 "$rop_pidfile" 2>/dev/null | tr -cd '0-9')"
	[ -n "$rop_pid" ] || return 1
	rop_image="$(pid_image_name "$rop_pid")"
	if [ -z "$rop_image" ]; then
		step "no running $rop_label daemon (stale pid file)."
		return 1
	fi
	case "$rop_image" in
		node|node.exe|*/node) ;;
		*)
			warn "pid $rop_pid from the $rop_label pid file is not a node process ($rop_image); leaving it alone (pid reuse)."
			return 1
			;;
	esac
	if is_windows_shell; then
		powershell -NoProfile -Command "Stop-Process -Id $rop_pid -Force -ErrorAction SilentlyContinue" >/dev/null 2>&1 || true
	else
		kill "$rop_pid" >/dev/null 2>&1 || true
		sleep 1
		kill -0 "$rop_pid" >/dev/null 2>&1 && kill -9 "$rop_pid" >/dev/null 2>&1 || true
	fi
	ok "recycled the running $rop_label daemon (pid $rop_pid) so it restarts on the new version."
	return 0
}

# Converge a moved product's service onto the new bytes, then recycle its daemon only if it is still
# running old code (a-AC-5/6/7). Converge ALWAYS runs before any kill so Doctor cannot race-restart
# the old code. Signature: converge_and_recycle <display> <bin> <verb> [pidfile...] -- doctor passes
# no pid files (it is the watchdog; it converges last and is never recycled here).
converge_and_recycle() {
	cr_display="$1"; cr_bin="$2"; cr_verb="$3"
	shift 3

	cr_prodbin="$(resolve_bin "$cr_bin")"
	if [ -z "$cr_prodbin" ]; then
		warn "$cr_display updated but its '$cr_bin' command could not be located to restart its service. Open a new terminal (so PATH refreshes) and run: $cr_bin $cr_verb"
		return 0
	fi

	step "converging the $cr_display service ($cr_bin $cr_verb)..."
	# $cr_verb is a single trusted literal token (install / install-service); left unquoted to match
	# install.sh's post-install verb invocation.
	if "$cr_prodbin" $cr_verb >/dev/null 2>&1; then
		ok "$cr_display service converged on the new version."
	else
		warn "$cr_display updated but '$cr_bin $cr_verb' did not complete. Run '$cr_bin $cr_verb' to finish pointing its service at the new version."
		return 0
	fi

	# Recycle (only AFTER converge). "$@" preserves pid-file paths that contain spaces.
	cr_recycled=0
	for cr_pf in "$@"; do
		[ -n "$cr_pf" ] || continue
		if recycle_one_pidfile "$cr_pf" "$cr_display"; then
			cr_recycled=1
		fi
	done

	# If we recycled, re-run the idempotent converge verb so the daemon is brought back up on the new
	# bytes -- so this step never LEAVES a daemon stopped (AC-9), even without Doctor / a keepalive.
	if [ "$cr_recycled" -eq 1 ]; then
		if "$cr_prodbin" $cr_verb >/dev/null 2>&1; then
			ok "$cr_display daemon restarted on the new version."
		else
			warn "$cr_display daemon was recycled but did not restart automatically. Run '$cr_bin $cr_verb' to bring it back up."
		fi
	fi
	return 0
}

# Compute the fleet root for pid files: the default ~/.apiary, or an absolute APIARY_HOME override.
fleet_root() {
	fr_root="${HOME}/.apiary"
	case "${APIARY_HOME:-}" in
		/*) fr_root="${APIARY_HOME}" ;;
	esac
	printf '%s' "$fr_root"
}

# ═══════════════════════════════════════════════════════════════════════════════════════════════
# Per-product update: detect installed -> resolve target -> skip-if-current -> npm i -g -> converge.
# ═══════════════════════════════════════════════════════════════════════════════════════════════
# update_one_product <display> <slug> <pkg> <bin> <verb>
update_one_product() {
	uop_display="$1"; uop_slug="$2"; uop_pkg="$3"; uop_bin="$4"; uop_verb="$5"

	# a-AC-3 / AC-4: only INSTALLED products are touched. An absent product is never mentioned as
	# updated (this is an update, not an installer).
	if ! is_installed "$uop_pkg"; then
		step "$uop_display is not installed; skipping."
		return 0
	fi
	INSTALLED_COUNT=$((INSTALLED_COUNT + 1))

	uop_cur="$(installed_version "$uop_pkg")"

	# Resolve the target version + install target string per mode.
	if [ "$LATEST" -eq 1 ]; then
		# a-AC-1b: --latest bypasses the manifest; compare the installed version to `npm view` so the
		# idempotent no-op + single-product_updated-per-move properties still hold without a pin.
		uop_target_ver="$(npm view "$uop_pkg" version 2>/dev/null | tr -d '[:space:]')"
		if [ -z "$uop_target_ver" ] || ! semver_is_safe "$uop_target_ver"; then
			warn "could not resolve the npm latest version for $uop_display; leaving it at ${uop_cur:-its current version}."
			return 0
		fi
		uop_target="${uop_pkg}@latest"
	else
		uop_resolved="$(resolve_product_target "$uop_slug" "$uop_pkg")"
		uop_kind="${uop_resolved%% *}"
		uop_payload="${uop_resolved#* }"
		case "$uop_kind" in
			ok)
				uop_target="$uop_payload"
				# uop_payload is "<@scope/pkg>@<ver>"; the version is after the LAST '@'.
				uop_target_ver="${uop_payload##*@}"
				;;
			unpublished)
				# a-AC-4: never fall back to @latest in blessed mode; leave it and continue.
				step "could not resolve the blessed version for $uop_display (not yet published); leaving it at ${uop_cur:-its current version}."
				return 0
				;;
			*)
				step "could not resolve the blessed version for $uop_display; leaving it at ${uop_cur:-its current version}."
				return 0
				;;
		esac
	fi

	# a-AC-2 / a-AC-1b-2 (idempotent skip): installed already equals target -> no npm, no restart.
	if [ -n "$uop_cur" ] && [ "$uop_cur" = "$uop_target_ver" ]; then
		ok "$uop_display already current ($uop_cur)."
		return 0
	fi

	# a-AC-10 (--dry-run): resolve + print the move and the services it would restart; mutate nothing.
	if [ "$DRY_RUN" -eq 1 ]; then
		printf '[dry-run] %s: %s -> %s\n' "$uop_display" "${uop_cur:-unknown}" "$uop_target_ver"
		printf '[dry-run] would run: npm install -g %s\n' "$uop_target"
		printf '[dry-run] would converge the %s service: %s %s\n' "$uop_display" "$uop_bin" "$uop_verb"
		case "$uop_slug" in
			honeycomb|hive|nectar) printf '[dry-run] would recycle the %s daemon (pid file) after converge if it is still running old code\n' "$uop_display" ;;
		esac
		record_moved "$uop_slug"
		return 0
	fi

	# a-AC-1: move the package. Fail-soft per product (warn + mark failed, continue) -- never abort
	# the loop (AC-9), mirroring install_extra_product.
	step "updating $uop_display ($uop_cur -> $uop_target_ver)..."
	if ! npm install -g "$uop_target" >/dev/null 2>&1; then
		warn "could not update $uop_display (leaving it at ${uop_cur:-its current version}). Try: npm install -g $uop_target"
		ANY_FAILED=1
		return 0
	fi
	uop_new="$(installed_version "$uop_pkg")"
	ok "$uop_display updated ($uop_cur -> ${uop_new:-$uop_target_ver})."
	record_moved "$uop_slug"

	# a-AC-5/6/7: converge the service, then recycle its daemon (converge-first). doctor gets no pid
	# file (watchdog; never recycled here).
	uop_fr="$(fleet_root)"
	case "$uop_slug" in
		honeycomb) converge_and_recycle "$uop_display" "$uop_bin" "$uop_verb" "${uop_fr}/honeycomb/daemon.pid" "${HOME}/.honeycomb/daemon.pid" ;;
		hive)      converge_and_recycle "$uop_display" "$uop_bin" "$uop_verb" "${uop_fr}/hive/hive.pid" ;;
		nectar)    converge_and_recycle "$uop_display" "$uop_bin" "$uop_verb" "${uop_fr}/nectar/nectar.pid" ;;
		doctor)    converge_and_recycle "$uop_display" "$uop_bin" "$uop_verb" ;;
	esac
	return 0
}

# ═══════════════════════════════════════════════════════════════════════════════════════════════
# PRD-007b; harness detection + Claude Code plugin refresh (gated on the honeycomb package moving).
# The shell owns ORDERING + REPORTING; honeycomb owns the wiring -- we invoke its CLI, never
# re-implement detection or the connector in shell (no hardcoded ~/.claude paths).
# ═══════════════════════════════════════════════════════════════════════════════════════════════

# Detect installed harnesses via honeycomb's OWN CLI surface (b-AC-1/b-AC-6), never re-implemented
# in shell. The verb is `honeycomb harness status` (verified on the blessed v0.8.0 build; the older
# `honeycomb harnesses` does not exist and exits 1). PREFERS `honeycomb harness status --json`
# (PRD-006c/006d) so the "none detected" case is caught robustly even when a build emits an empty
# JSON array/object; falls back to the plain-text `honeycomb harness status` when --json exits
# non-zero, and treats a non-JSON body (this blessed build prints plain text for --json too) as the
# already-human-readable report. Sets two globals consumed by refresh_harnesses:
#   HARNESS_STATE = detected | none | unknown
#   HARNESS_OUT   = the harness list to print (when detected)
detect_harnesses() {
	dh_bin="$1"
	HARNESS_STATE="unknown"
	HARNESS_OUT=""

	dh_out="$("$dh_bin" harness status --json 2>/dev/null)"
	dh_st=$?
	if [ "$dh_st" -ne 0 ]; then
		# --json errored (an older pin without the flag): fall back to the plain-text form.
		dh_out="$("$dh_bin" harness status 2>/dev/null)"
		dh_st=$?
	fi
	if [ "$dh_st" -ne 0 ]; then
		HARNESS_STATE="unknown"
		return 0
	fi

	# Try to interpret the output as JSON so an empty array/object reliably reads as "none". A
	# non-JSON body (the current blessed build prints plain text for --json) makes `node` exit 2 and
	# falls through to the plain-text handling below.
	if have node; then
		dh_parsed="$(printf '%s' "$dh_out" | node -e '
			let raw = "";
			process.stdin.on("data", (d) => { raw += d; });
			process.stdin.on("end", () => {
				let j;
				try { j = JSON.parse(raw); } catch (e) { process.exit(2); }
				let items = [];
				if (Array.isArray(j)) items = j;
				else if (j && Array.isArray(j.harnesses)) items = j.harnesses;
				else if (j && typeof j === "object") items = Object.keys(j).map((k) => {
					const v = j[k];
					return (v && typeof v === "object") ? Object.assign({ name: k }, v) : { name: k, value: v };
				});
				for (const it of items) {
					if (it == null) continue;
					if (typeof it === "string") { process.stdout.write(it + "\n"); continue; }
					const name = it.name || it.harness || it.id || "";
					const status = it.status || it.state || "";
					let plugin = "";
					if (it.pluginEnabled !== undefined) plugin = "plugin " + (it.pluginEnabled ? "enabled" : "disabled");
					const detail = [status, plugin].filter(Boolean).join(", ");
					const line = detail ? (name + ": " + detail) : (name || JSON.stringify(it));
					process.stdout.write(line + "\n");
				}
				process.exit(0);
			});
		' 2>/dev/null)"
		dh_pst=$?
		if [ "$dh_pst" -eq 0 ]; then
			if [ -n "$dh_parsed" ]; then
				HARNESS_STATE="detected"
				HARNESS_OUT="$dh_parsed"
			else
				HARNESS_STATE="none"
			fi
			return 0
		fi
	fi

	# Not JSON: the plain-text status IS the human-readable report. Non-blank -> detected.
	dh_trim="$(printf '%s' "$dh_out" | tr -d '[:space:]')"
	if [ -n "$dh_trim" ]; then
		HARNESS_STATE="detected"
		HARNESS_OUT="$dh_out"
	else
		HARNESS_STATE="none"
	fi
	return 0
}

refresh_harnesses() {
	rh_bin="$(resolve_bin honeycomb)"
	if [ -z "$rh_bin" ]; then
		# b-AC-3: honeycomb CLI not on PATH -> print the exact next command, never claim success,
		# never fail the update.
		warn "Honeycomb updated, but the 'honeycomb' command is not on PATH yet, so the coding-assistant plugin could not be refreshed automatically."
		printf '\nOpen a new terminal (so PATH refreshes), then run:\n\n  honeycomb setup\n\n'
		printf 'Then restart Claude Code to load the updated plugin.\n'
		return 0
	fi

	if [ "$DRY_RUN" -eq 1 ]; then
		printf '[dry-run] would list installed harnesses: honeycomb harness status --json\n'
		printf '[dry-run] would refresh the Claude Code plugin: honeycomb setup\n'
		printf '[dry-run] would print: restart Claude Code to load the updated plugin\n'
		return 0
	fi

	# b-AC-1: report which harnesses are installed, via honeycomb's own detection surface (not
	# re-implemented in shell). detect_harnesses drives `honeycomb harness status` (prefers --json).
	detect_harnesses "$rh_bin"
	case "$HARNESS_STATE" in
		detected)
			step "detected coding assistants:"
			printf '%s\n' "$HARNESS_OUT"
			;;
		none)
			# b-AC-6: no harness installed -> clean no-op, not an error.
			ok "no coding assistants detected."
			ok "no coding assistants to refresh."
			return 0
			;;
		*)
			# Detection surface unavailable on this pin: guide to the real verb, never fail (b-AC-5).
			step "run 'honeycomb harness status' to see which coding assistants are installed."
			;;
	esac

	# b-AC-2: refresh the plugin via the confirmed-working `honeycomb setup` (PRD-006). Idempotent.
	step "refreshing the Claude Code plugin (honeycomb setup)..."
	if "$rh_bin" setup >/dev/null 2>&1; then
		ok "Claude Code plugin refreshed."
		# b-AC-4: a running session may not hot-reload; always name the one residual manual action.
		ok "restart Claude Code to load the updated plugin."
	else
		# b-AC-5: degrade to a clear message; NEVER fail the update over the harness result.
		warn "could not refresh the Claude Code plugin automatically."
		printf '\nOpen a new terminal (so PATH refreshes), then run:\n\n  honeycomb setup\n\n'
		printf 'Then restart Claude Code to load the updated plugin.\n'
	fi
	return 0
}

# ═══════════════════════════════════════════════════════════════════════════════════════════════
# Node/npm presence (a-AC-9/AC-10). The updater does NOT bootstrap Node (that is an installer
# concern); it requires a working Node/npm and reports plainly if either is missing, touching nothing.
# ═══════════════════════════════════════════════════════════════════════════════════════════════
ensure_node_present() {
	if have node && have npm; then
		ok "Node $(node --version) and npm $(npm --version) found."
		return 0
	fi
	# a-AC-9: mirror install.sh's elevation_required_node copy (adapted to the update entry point).
	fail "the update needs Node ${HONEYCOMB_NODE_VERSION} and npm, but neither was found on PATH."
	printf '\nInstall Node %s with ONE of these, then re-run the update:\n\n' "$HONEYCOMB_NODE_VERSION"
	printf '  # macOS (Homebrew):\n'
	printf '  brew install node@%s\n\n' "$HONEYCOMB_NODE_VERSION"
	printf '  # Debian/Ubuntu:\n'
	printf '  curl -fsSL https://deb.nodesource.com/setup_%s.x | sudo -E bash - && sudo apt-get install -y nodejs\n\n' "$HONEYCOMB_NODE_VERSION"
	printf '  # Then re-run:\n'
	printf '  curl -fsSL %s/update | sh\n\n' "$HONEYCOMB_INSTALL_BASE_URL"
	return 1
}

# One product_updated per product that ACTUALLY moved (c-AC-7); never for skipped/absent products.
# Dry-run previews only. Runs before the terminal funnel.
fire_product_updated_events() {
	[ -n "$MOVED_PRODUCTS" ] || return 0
	fpu_ifs="$IFS"
	IFS=','
	for fpu_p in $MOVED_PRODUCTS; do
		IFS="$fpu_ifs"
		[ -n "$fpu_p" ] && phone_home product_updated "$fpu_p"
		IFS=','
	done
	IFS="$fpu_ifs"
	return 0
}

print_summary() {
	if [ "$DRY_RUN" -eq 1 ]; then
		if [ "$MOVED_COUNT" -eq 0 ]; then
			if [ "$INSTALLED_COUNT" -eq 0 ]; then
				ok "[dry-run] no Apiary products are installed; nothing would be updated."
			else
				ok "[dry-run] already up to date; nothing would be updated."
			fi
		else
			ok "[dry-run] would update: ${MOVED_PRODUCTS}."
		fi
		return 0
	fi
	if [ "$INSTALLED_COUNT" -eq 0 ]; then
		ok "No Apiary products are installed; nothing to update."
		return 0
	fi
	# AC-3 / a-AC-8: whole fleet already at target -> no npm, no restart, "already up to date".
	if [ "$MOVED_COUNT" -eq 0 ]; then
		ok "already up to date."
		return 0
	fi
	ok "Update complete. Updated: ${MOVED_PRODUCTS}."
	if [ "$ANY_FAILED" -ne 0 ]; then
		printf '%s\n' "Some products could not be updated (see the notes above); nothing was removed."
	fi
	return 0
}

# ═══════════════════════════════════════════════════════════════════════════════════════════════
# main() -- ordered, funnels every terminal exit through finish() so exactly one update_completed /
# update_failed fires (c-AC-6).
# ═══════════════════════════════════════════════════════════════════════════════════════════════
main() {
	# --help is a usage request, not a run: handled before any id/telemetry (mirrors install.sh).
	for a in "$@"; do
		case "$a" in
			--help|-h) print_usage; return 0 ;;
		esac
	done

	# An unknown flag is a plain usage error, also before any telemetry.
	if ! parse_args "$@"; then
		print_usage
		return 1
	fi

	# c-AC-5: update_started fires FIRST, before any resolution work, using only `curl` (no Node).
	resolve_install_id
	phone_home update_started

	# a-AC-1b-1: one up-front warning when --latest bypasses the blessed set.
	if [ "$LATEST" -eq 1 ]; then
		warn "--latest bypasses the blessed fleet set; products may land on untested or mismatched versions."
	fi

	# a-AC-9 / AC-10: require a working Node/npm; in --dry-run, report presence but never stop.
	if [ "$DRY_RUN" -eq 1 ]; then
		if have node && have npm; then
			ok "Node $(node --version) and npm $(npm --version) found (dry-run: nothing will be changed)."
		else
			warn "node/npm not found (dry-run; a real run requires them and would stop here without touching anything)."
		fi
	else
		ensure_node_present || finish 1
	fi

	step "checking installed Apiary products (blessed = hive-release.json-pinned)..."
	# doctor is converged LAST (watchdog); the fixed slug order below guarantees that.
	update_one_product "Honeycomb" honeycomb "@legioncodeinc/honeycomb" honeycomb install
	update_one_product "Hive"      hive      "@legioncodeinc/hive"      hive      install-service
	update_one_product "Nectar"    nectar    "@legioncodeinc/nectar"    nectar    install
	update_one_product "Doctor"    doctor    "@legioncodeinc/doctor"    doctor    install-service

	# 007b: refresh the coding-assistant plugin ONLY when the plugin-bearing honeycomb package moved.
	if [ "$HONEYCOMB_MOVED" -eq 1 ]; then
		refresh_harnesses
	fi

	# c-AC-7: one product_updated per moved product. Set SEL_PRODUCTS to the moved list first so the
	# terminal event's `products` field is honest about what changed (PRD-007c).
	SEL_PRODUCTS="$MOVED_PRODUCTS"
	fire_product_updated_events

	print_summary

	# AC-9: a per-product failure is non-blocking but still surfaces as update_failed / non-zero exit
	# (mirrors install.sh's EXTRA_PRODUCT_FAILED posture); an all-clean run is update_completed / 0.
	if [ "$ANY_FAILED" -ne 0 ]; then
		finish 1
	fi
	finish 0
}

main "$@"
update.ps1 (Windows PowerShell)
# Apiary one-command fleet UPDATE script (Windows PowerShell) -- the-apiary PRD-007.
#
# Usage (the single line a user pastes to move the installed fleet to the blessed set):
#   irm https://get.theapiary.sh/update.ps1 | iex
#
# Opt into the newest published bytes (pass args to the script block explicitly, since `irm | iex`
# has no script-level $args of its own):
#   powershell -c "& { $(irm https://get.theapiary.sh/update.ps1) } --latest"
#   powershell -c "& { $(irm https://get.theapiary.sh/update.ps1) } --dry-run"
#
# This is the FUNCTIONAL EQUIVALENT of update.sh (PRD-007 AC-6): the SAME flag grammar, resolution,
# idempotency, restart, harness refresh, and telemetry behavior -- see update.sh's header for the
# full documented contract. It is the THIRD lifecycle script beside install.ps1 and uninstall.ps1.
#
# Thin + idempotent + non-destructive: detect what is installed, move only what is behind, converge
# + restart only what moved, never uninstall, never delete state.
#
# ASCII-only by design: sourced via `irm | iex` and parsed by Windows PowerShell 5.1, which reads a
# non-BOM file as the system ANSI codepage -- non-ASCII glyphs would corrupt the parse. Friendly
# UTF-8 glyphs come from the CLI verbs' output; this script's own prefixes stay ASCII.

# Handle every failure explicitly + print a plain-language line (parent AC-9). We do NOT set
# $ErrorActionPreference='Stop' globally -- that would surface a raw PowerShell exception/trace.
$ErrorActionPreference = 'Continue'

# The Node LTS the installer provisions (referenced only in the "Node is missing" copy; the updater
# assumes a working Node/npm and never bootstraps one -- that is an installer concern).
$HoneycombNodeVersion = '22'

# Distribution base URL (used only in the "re-run" copy of the Node-missing message).
$HoneycombInstallBaseUrl = 'https://get.theapiary.sh'

# The fleet release manifest (same URL + raw-GitHub fallback as install.ps1). The updater never
# hardcodes "latest" for a product it did not itself publish: it resolves each installed product's
# exact pinned version from THIS manifest (unless -Latest is passed, which bypasses it).
$HoneycombManifestUrl = 'https://get.theapiary.sh/hive-release.json'
if ($env:HONEYCOMB_MANIFEST_URL) { $HoneycombManifestUrl = $env:HONEYCOMB_MANIFEST_URL }
$HoneycombManifestFallbackUrl = 'https://raw.githubusercontent.com/legioncodeinc/the-apiary/main/hive-release.json'

# Telemetry destination (PRD-007c). The key is EMPTY in source control BY DESIGN -- this exact
# `$HoneycombInstallPosthogKey = ''` line is the one site/install/build.mjs patches (via an anchored
# regex on this literal line) at deploy time, injecting the real PostHog project key. An empty value
# (any un-built/local/dev copy) makes Send-PhoneHome a silent no-op -- never a hard failure. Same
# public install-site channel, key seam, endpoint, and payload shape as the installer; only the
# event NAMES differ (update_started / update_completed / update_failed, reusing product_updated).
$HoneycombInstallPosthogKey = 'phc_wjWdFZfMRtUATshcoBRkZ3FiSMmAKEuVuP6ftraTCiPz'
$HoneycombInstallPosthogHost = 'https://us.i.posthog.com'
$HoneycombInstallPosthogPath = '/i/v0/e/'
$HoneycombInstallIdFile = Join-Path $HOME '.honeycomb\install-id'

# Run-scoped state (avoids threading many params through every function, as update has no
# product-selection/profile grammar to resolve -- unlike install.ps1).
$script:DryRun = $false
$script:Latest = $false
$script:InstallId = ''
$script:Repeat = $false
# Slugs that ACTUALLY moved this run (drives product_updated + the `products` payload + the summary).
$script:MovedProducts = @()
$script:InstalledCount = 0
$script:AnyFailed = $false
$script:HoneycombMoved = $false

# Friendly progress log: step lines to the host, the single failure summary to the error stream.
function Write-Step([string]$m) { Write-Host "-> $m" }
function Write-Ok([string]$m)   { Write-Host "[ok] $m" }
function Write-Warn([string]$m) { Write-Host "[warn] $m" }
function Write-Fail([string]$m) { [Console]::Error.WriteLine("Apiary update could not continue: $m") }

function Test-Have([string]$name) { return [bool](Get-Command $name -ErrorAction SilentlyContinue) }

function Test-HasFlag([string[]]$InvocationArgs, [string]$Flag) {
  if (-not $InvocationArgs) { return $false }
  return ($InvocationArgs -contains $Flag)
}

function Test-IsAbsolutePath([string]$PathValue) {
  if ([string]::IsNullOrWhiteSpace($PathValue)) { return $false }
  if ($PathValue -match '^[A-Za-z]:\\') { return $true }
  if ($PathValue.StartsWith('\\')) { return $true }
  if ($PathValue.StartsWith('/')) { return $true }
  return $false
}

function Show-Usage {
  Write-Host 'Usage: update.ps1 [--latest|-Latest] [--dry-run|-DryRun] [--help|-h]'
  Write-Host ''
  Write-Host '  --latest / -Latest     Update each installed product to its npm ''latest'' dist-tag'
  Write-Host '                         instead of the blessed (hive-release.json-pinned) version.'
  Write-Host '                         Prints a warning; bypasses the tested fleet set.'
  Write-Host '  --dry-run / -DryRun    Resolve + print every product''s current -> target decision and'
  Write-Host '                         the services it would restart; mutate nothing, send no telemetry.'
  Write-Host '  --help / -h            Show this help text.'
  Write-Host ''
  Write-Host 'By default (no flag) every INSTALLED Apiary product is moved to its blessed,'
  Write-Host 'manifest-pinned version; a product that is not installed is left untouched.'
  Write-Host 'Env equivalent for --latest: APIARY_UPDATE_LATEST=1.'
}

# Parse flags (both the POSIX `--flag` spelling and the PowerShell-native `-Flag`, kept identical to
# install.ps1's dual grammar). Returns 0 (proceed), 1 (usage error), or 2 (help was shown). Reads
# the APIARY_UPDATE_LATEST env equivalent (a-AC-1b: --latest is strictly opt-in, never implied).
function Get-ArgumentStatus([string[]]$InvocationArgs) {
  if ((Test-HasFlag $InvocationArgs '--help') -or (Test-HasFlag $InvocationArgs '-h') -or (Test-HasFlag $InvocationArgs '-Help')) {
    Show-Usage
    return 2
  }
  if ((Test-HasFlag $InvocationArgs '--dry-run') -or (Test-HasFlag $InvocationArgs '-DryRun')) {
    $script:DryRun = $true
  }
  if ((Test-HasFlag $InvocationArgs '--latest') -or (Test-HasFlag $InvocationArgs '-Latest')) {
    $script:Latest = $true
  }
  if ($env:APIARY_UPDATE_LATEST) {
    $v = "$($env:APIARY_UPDATE_LATEST)".Trim().ToLowerInvariant()
    if ($v -eq '1' -or $v -eq 'true') { $script:Latest = $true }
  }
  if ($InvocationArgs) {
    foreach ($arg in $InvocationArgs) {
      if ($arg -in @('--help', '-h', '-Help', '--dry-run', '-DryRun', '--latest', '-Latest')) { continue }
      if ($arg.StartsWith('-') -or $arg.StartsWith('/')) {
        Write-Fail "Unknown flag: $arg. Use --help to see supported flags."
        return 1
      }
    }
  }
  return 0
}

# -----------------------------------------------------------------------------
# PRD-007c -- anonymous install id + phone-home (ported from install.ps1: same id file, endpoint,
# body shape, 3s timeout, empty-key no-op -- only the event names differ).
# -----------------------------------------------------------------------------

# SYNC: mirror of install.ps1 New-AnonInstallId
function New-AnonInstallId { return [guid]::NewGuid().ToString() }

# SYNC: mirror of install.ps1 Resolve-InstallId
# READ (or, outside -DryRun, mint + persist) the same ~/.honeycomb/install-id the installer wrote
# (c-AC-5). Sets $script:InstallId / $script:Repeat. In -DryRun this NEVER writes.
function Resolve-InstallId {
  if ((Test-Path $HoneycombInstallIdFile) -and ((Get-Item $HoneycombInstallIdFile).Length -gt 0)) {
    $existing = (Get-Content $HoneycombInstallIdFile -Raw -ErrorAction SilentlyContinue)
    if ($existing) {
      $script:InstallId = $existing.Trim()
      $script:Repeat = $true
      return
    }
  }
  $script:InstallId = New-AnonInstallId
  $script:Repeat = $false
  if (-not $script:DryRun) {
    try {
      New-Item -ItemType Directory -Force -Path (Split-Path $HoneycombInstallIdFile) | Out-Null
      Set-Content -Path $HoneycombInstallIdFile -Value $script:InstallId -NoNewline -ErrorAction SilentlyContinue
    } catch {
      # Fail-soft: a persistence hiccup must never abort the update.
    }
  }
}

# SYNC: mirror of install.ps1 Send-PhoneHome
# Fire ONE PostHog capture event. FAIL-SOFT + BOUNDED (TimeoutSec 3). Same endpoint + body shape as
# install.ps1. Allow-list-shaped payload (no PII; no license/code -- there are none here): products
# (the moved set), profile (empty on update), coarse OS family, repeat-vs-first. The optional
# -Product arg is the per-product transition field appended as `product = <slug>`.
function Send-PhoneHome {
  param(
    [string]$EventName,
    [string]$Product = ''
  )
  $productsField = ($script:MovedProducts -join ',')
  if ($script:DryRun) {
    if ($Product) {
      Write-Host "[dry-run] would phone home: $EventName (product=$Product, install_id=$($script:InstallId), repeat=$($script:Repeat), products=$productsField, profile=)"
    } else {
      Write-Host "[dry-run] would phone home: $EventName (install_id=$($script:InstallId), repeat=$($script:Repeat), products=$productsField, profile=)"
    }
    return
  }
  if ([string]::IsNullOrEmpty($HoneycombInstallPosthogKey)) { return }

  $props = @{
    products = $productsField
    profile = ''
    os = 'windows'
    repeat_install = "$($script:Repeat)".ToLowerInvariant()
  }
  if ($Product) { $props.product = $Product }
  $body = @{
    api_key = $HoneycombInstallPosthogKey
    event = $EventName
    distinct_id = $script:InstallId
    properties = $props
  } | ConvertTo-Json -Compress

  try {
    Invoke-RestMethod -Method Post -Uri "$HoneycombInstallPosthogHost$HoneycombInstallPosthogPath" `
      -ContentType 'application/json' -Body $body -TimeoutSec 3 -ErrorAction Stop | Out-Null
  } catch {
    # Fail-soft: a dropped telemetry POST is acceptable; a hung/broken update is not.
  }
}

# -----------------------------------------------------------------------------
# PRD-007a -- manifest resolver (mirrored from install.ps1; the security-critical safe-shape
# validators are kept intact so a tampered manifest field can never reach npm.cmd unvalidated).
# -----------------------------------------------------------------------------

$script:ManifestObject = $null
$script:ManifestFetchAttempted = $false

# SYNC: mirror of install.ps1 Get-Manifest
function Get-Manifest {
  if ($script:ManifestFetchAttempted) { return $script:ManifestObject }
  $script:ManifestFetchAttempted = $true
  try {
    $script:ManifestObject = Invoke-RestMethod -Uri $HoneycombManifestUrl -TimeoutSec 5 -ErrorAction Stop
  } catch {
    try {
      $script:ManifestObject = Invoke-RestMethod -Uri $HoneycombManifestFallbackUrl -TimeoutSec 5 -ErrorAction Stop
    } catch {
      $script:ManifestObject = $null
    }
  }
  return $script:ManifestObject
}

# SYNC: mirror of install.ps1 Test-SafePackageName
function Test-SafePackageName([string]$Name) {
  if ([string]::IsNullOrEmpty($Name)) { return $false }
  return $Name -cmatch '^(@[a-z0-9][a-z0-9._-]*/)?[a-z0-9][a-z0-9._-]*$'
}

# SYNC: mirror of install.ps1 Test-SafeSemver
function Test-SafeSemver([string]$Version) {
  if ([string]::IsNullOrEmpty($Version)) { return $false }
  return $Version -cmatch '^[0-9]+\.[0-9]+\.[0-9]+([+.-][0-9A-Za-z.+-]+)?$'
}

# SYNC: mirror of install.ps1 Resolve-ProductTarget
# Returns @{ Kind = 'ok'; Target = '<pkg>@<ver>'; Version = '<ver>' }
#      or @{ Kind = 'unpublished'; Pkg = '<pkg>' }   -- do NOT install; leave at current (a-AC-4)
#      or @{ Kind = 'unresolved'; Pkg = '<pkg>' }    -- manifest unreachable/malformed; leave (a-AC-4)
function Resolve-ProductTarget([string]$Slug, [string]$FallbackPkg) {
  $manifest = Get-Manifest
  $pkg = $FallbackPkg
  if ($manifest -and $manifest.products -and $manifest.products.$Slug -and $manifest.products.$Slug.packageName) {
    $candidatePkg = $manifest.products.$Slug.packageName
    if (Test-SafePackageName $candidatePkg) { $pkg = $candidatePkg }
  }
  if (-not $manifest -or -not $manifest.products -or -not $manifest.products.$Slug -or -not $manifest.products.$Slug.version) {
    return @{ Kind = 'unresolved'; Pkg = $pkg }
  }
  $entry = $manifest.products.$Slug
  if (-not (Test-SafeSemver $entry.version)) {
    return @{ Kind = 'unresolved'; Pkg = $pkg }
  }
  $published = $true
  if ($null -ne $entry.published) { $published = [bool]$entry.published }
  if (-not $published) {
    return @{ Kind = 'unpublished'; Pkg = $pkg }
  }
  return @{ Kind = 'ok'; Target = "$pkg@$($entry.version)"; Version = "$($entry.version)" }
}

# -----------------------------------------------------------------------------
# Installed-product detection + version reads (npm ls -g, exactly uninstall.ps1's probe).
# -----------------------------------------------------------------------------

# a-AC-3 authoritative "is this product installed?" signal.
function Test-Installed([string]$Pkg) {
  & npm ls -g $Pkg --depth=0 *> $null
  return ($LASTEXITCODE -eq 0)
}

# Prints the currently-installed global version, or $null. Parses `npm ls -g --json` natively.
function Get-InstalledVersion([string]$Pkg) {
  try {
    $json = (& npm ls -g $Pkg --depth=0 --json 2>$null | Out-String)
    if ([string]::IsNullOrWhiteSpace($json)) { return $null }
    $obj = $json | ConvertFrom-Json
    if (-not $obj.dependencies) { return $null }
    $dep = $obj.dependencies.PSObject.Properties | Where-Object { $_.Name -eq $Pkg } | Select-Object -First 1
    if ($dep -and $dep.Value.version) { return [string]$dep.Value.version }
  } catch {
    return $null
  }
  return $null
}

# Resolve the ABSOLUTE path to an installed product's .cmd shim. `npm i -g` does NOT refresh THIS
# session's PATH, so a bare-name call in the same run can fail (generalized from install.ps1's
# Resolve-HoneycombBin -- prefers the .cmd shim so no visible PowerShell window pops for a daemon).
function Resolve-Bin([string]$BinName) {
  $prefix = (npm prefix -g 2>$null)
  if ($prefix) {
    $candidate = Join-Path $prefix "$BinName.cmd"
    if (Test-Path $candidate) { return $candidate }
  }
  $appdataCmd = Join-Path $env:AppData "npm\$BinName.cmd"
  if (Test-Path $appdataCmd) { return $appdataCmd }
  $cmd = Get-Command $BinName -ErrorAction SilentlyContinue
  if ($cmd) {
    if ($cmd.Source -and $cmd.Source.ToLowerInvariant().EndsWith('.ps1')) {
      $sibling = [System.IO.Path]::ChangeExtension($cmd.Source, '.cmd')
      if (Test-Path $sibling) { return $sibling }
    }
    return $cmd.Source
  }
  return $null
}

# Record a product that actually moved (feeds product_updated + the summary).
function Add-MovedProduct([string]$Slug) {
  $script:MovedProducts += $Slug
  if ($Slug -eq 'honeycomb') { $script:HoneycombMoved = $true }
}

# -----------------------------------------------------------------------------
# Service restart (PRD-007a Decided: converge-first, recycle-only-if-needed).
# -----------------------------------------------------------------------------

function Get-FleetRoot {
  $root = Join-Path $HOME '.apiary'
  if ((-not [string]::IsNullOrWhiteSpace($env:APIARY_HOME)) -and (Test-IsAbsolutePath $env:APIARY_HOME)) {
    $root = $env:APIARY_HOME
  }
  return $root
}

# SYNC: mirror of uninstall.ps1 Stop-DaemonByPidFile (recycle, not remove: the service manager /
# Doctor restarts the daemon after this; the caller re-converges to guarantee it comes back up).
# Returns $true iff it actually stopped a live node daemon (pid-reuse safe, a-AC-6).
function Stop-DaemonByPidFile([string]$PidFilePath, [string]$Label) {
  if (-not (Test-Path -LiteralPath $PidFilePath)) { return $false }
  $raw = ''
  try { $raw = (Get-Content -LiteralPath $PidFilePath -TotalCount 1 -ErrorAction Stop) } catch { return $false }
  $pidText = ($raw -replace '[^0-9]', '')
  if ([string]::IsNullOrEmpty($pidText)) { return $false }
  $processId = 0
  if (-not [int]::TryParse($pidText, [ref]$processId)) { return $false }
  if ($processId -le 0) { return $false }
  $proc = Get-Process -Id $processId -ErrorAction SilentlyContinue
  if (-not $proc) {
    Write-Step "no running $Label daemon (stale pid file)."
    return $false
  }
  if ($proc.ProcessName -ne 'node') {
    Write-Warn "pid $processId from the $Label pid file is not a node process; leaving it alone (pid reuse)."
    return $false
  }
  Stop-Process -Id $processId -Force -ErrorAction SilentlyContinue
  Write-Ok "recycled the running $Label daemon (pid $processId) so it restarts on the new version."
  return $true
}

# Converge a moved product's service onto the new bytes, then recycle its daemon only if it is still
# running old code (a-AC-5/6/7). Converge ALWAYS runs before any kill so Doctor cannot race-restart
# old code. doctor passes no pid files (watchdog; converges last, never recycled here).
function Invoke-ConvergeAndRecycle([string]$Display, [string]$BinName, [string]$Verb, [string[]]$PidFiles) {
  $prodBin = Resolve-Bin $BinName
  if (-not $prodBin) {
    Write-Warn "$Display updated but its '$BinName' command could not be located to restart its service. Open a new terminal (so PATH refreshes) and run: $BinName $Verb"
    return
  }

  Write-Step "converging the $Display service ($BinName $Verb)..."
  & $prodBin $Verb 2>$null | Out-Null
  if ($LASTEXITCODE -eq 0) {
    Write-Ok "$Display service converged on the new version."
  } else {
    Write-Warn "$Display updated but '$BinName $Verb' did not complete. Run '$BinName $Verb' to finish pointing its service at the new version."
    return
  }

  $recycled = $false
  if ($PidFiles) {
    foreach ($pf in $PidFiles) {
      if ([string]::IsNullOrWhiteSpace($pf)) { continue }
      if (Stop-DaemonByPidFile $pf $Display) { $recycled = $true }
    }
  }

  # If we recycled, re-run the idempotent converge verb so the daemon is brought back up on the new
  # bytes -- so this step never LEAVES a daemon stopped (AC-9), even without Doctor / a keepalive.
  if ($recycled) {
    & $prodBin $Verb 2>$null | Out-Null
    if ($LASTEXITCODE -eq 0) {
      Write-Ok "$Display daemon restarted on the new version."
    } else {
      Write-Warn "$Display daemon was recycled but did not restart automatically. Run '$BinName $Verb' to bring it back up."
    }
  }
}

# -----------------------------------------------------------------------------
# Per-product update: detect installed -> resolve target -> skip-if-current -> npm i -g -> converge.
# -----------------------------------------------------------------------------
function Update-OneProduct([string]$Display, [string]$Slug, [string]$Pkg, [string]$BinName, [string]$Verb) {
  # a-AC-3 / AC-4: only INSTALLED products are touched.
  if (-not (Test-Installed $Pkg)) {
    Write-Step "$Display is not installed; skipping."
    return
  }
  $script:InstalledCount++

  $cur = Get-InstalledVersion $Pkg

  # Resolve the target version + install target string per mode.
  $target = $null
  $targetVer = $null
  if ($script:Latest) {
    # a-AC-1b: --latest bypasses the manifest; compare installed vs `npm view` so idempotency holds.
    $targetVer = (& npm view $Pkg version 2>$null | Out-String).Trim()
    if ([string]::IsNullOrWhiteSpace($targetVer) -or -not (Test-SafeSemver $targetVer)) {
      $leave = if ($cur) { $cur } else { 'its current version' }
      Write-Warn "could not resolve the npm latest version for $Display; leaving it at $leave."
      return
    }
    $target = "$Pkg@latest"
  } else {
    $resolved = Resolve-ProductTarget $Slug $Pkg
    if ($resolved.Kind -eq 'ok') {
      $target = $resolved.Target
      $targetVer = $resolved.Version
    } elseif ($resolved.Kind -eq 'unpublished') {
      # a-AC-4: never fall back to @latest in blessed mode; leave it and continue.
      $leave = if ($cur) { $cur } else { 'its current version' }
      Write-Step "could not resolve the blessed version for $Display (not yet published); leaving it at $leave."
      return
    } else {
      $leave = if ($cur) { $cur } else { 'its current version' }
      Write-Step "could not resolve the blessed version for $Display; leaving it at $leave."
      return
    }
  }

  # a-AC-2 / a-AC-1b-2 (idempotent skip): installed already equals target -> no npm, no restart.
  if ($cur -and ($cur -eq $targetVer)) {
    Write-Ok "$Display already current ($cur)."
    return
  }

  $curDisplay = if ($cur) { $cur } else { 'unknown' }

  # a-AC-10 (--dry-run): resolve + print; mutate nothing.
  if ($script:DryRun) {
    Write-Host "[dry-run] $Display`: $curDisplay -> $targetVer"
    Write-Host "[dry-run] would run: npm install -g $target"
    Write-Host "[dry-run] would converge the $Display service: $BinName $Verb"
    if ($Slug -in @('honeycomb', 'hive', 'nectar')) {
      Write-Host "[dry-run] would recycle the $Display daemon (pid file) after converge if it is still running old code"
    }
    Add-MovedProduct $Slug
    return
  }

  # a-AC-1: move the package. Fail-soft per product (warn + mark failed, continue) -- never abort.
  Write-Step "updating $Display ($curDisplay -> $targetVer)..."
  & npm install -g $target 2>$null | Out-Null
  if ($LASTEXITCODE -ne 0) {
    $leave = if ($cur) { $cur } else { 'its current version' }
    Write-Warn "could not update $Display (leaving it at $leave). Try: npm install -g $target"
    $script:AnyFailed = $true
    return
  }
  $newVer = Get-InstalledVersion $Pkg
  $newDisplay = if ($newVer) { $newVer } else { $targetVer }
  Write-Ok "$Display updated ($curDisplay -> $newDisplay)."
  Add-MovedProduct $Slug

  # a-AC-5/6/7: converge the service, then recycle its daemon (converge-first). doctor gets no pid
  # file (watchdog; never recycled here).
  $root = Get-FleetRoot
  switch ($Slug) {
    'honeycomb' { Invoke-ConvergeAndRecycle $Display $BinName $Verb @((Join-Path $root 'honeycomb\daemon.pid'), (Join-Path $HOME '.honeycomb\daemon.pid')) }
    'hive'      { Invoke-ConvergeAndRecycle $Display $BinName $Verb @((Join-Path $root 'hive\hive.pid')) }
    'nectar'    { Invoke-ConvergeAndRecycle $Display $BinName $Verb @((Join-Path $root 'nectar\nectar.pid')) }
    'doctor'    { Invoke-ConvergeAndRecycle $Display $BinName $Verb @() }
  }
}

# -----------------------------------------------------------------------------
# PRD-007b -- harness detection + Claude Code plugin refresh (gated on honeycomb moving). The shell
# owns ORDERING + REPORTING; honeycomb owns the wiring -- we invoke its CLI, never re-implement
# detection or the connector here (no hardcoded ~/.claude paths).
# -----------------------------------------------------------------------------

# Detect installed harnesses via honeycomb's OWN CLI surface (b-AC-1/b-AC-6). The verb is
# `honeycomb harness status` (verified on the blessed v0.8.0 build; the older `honeycomb harnesses`
# does not exist and exits 1). PREFERS `honeycomb harness status --json` (PRD-006c/006d) so an empty
# JSON array/object reliably reads as "none"; falls back to plain-text `honeycomb harness status`
# when --json exits non-zero, and treats a non-JSON body (this blessed build prints plain text for
# --json too) as the already-human-readable report. Returns @{ State = 'detected|none|unknown';
# Text = '<list>' }.
function Get-DetectedHarnesses([string]$HcBin) {
  $out = (& $HcBin harness status --json 2>$null | Out-String)
  $ok = ($LASTEXITCODE -eq 0)
  if (-not $ok) {
    $out = (& $HcBin harness status 2>$null | Out-String)
    $ok = ($LASTEXITCODE -eq 0)
  }
  if (-not $ok) { return @{ State = 'unknown'; Text = '' } }

  $trimmed = $out.Trim()
  if ([string]::IsNullOrWhiteSpace($trimmed)) { return @{ State = 'none'; Text = '' } }

  # Try JSON first (robust none-detection); a non-JSON body throws and is handled as plain text.
  try {
    $j = $trimmed | ConvertFrom-Json
    $items = @()
    if ($j -is [System.Array]) {
      $items = @($j)
    } elseif ($j -and ($j.PSObject.Properties.Name -contains 'harnesses')) {
      $items = @($j.harnesses)
    } elseif ($j -is [System.Management.Automation.PSCustomObject]) {
      $items = @($j.PSObject.Properties)
    }
    $lines = @()
    foreach ($it in $items) {
      if ($null -eq $it) { continue }
      if ($it -is [string]) { $lines += $it; continue }
      if ($it -is [System.Management.Automation.PSPropertyInfo]) {
        $name = $it.Name; $val = $it.Value
        $status = if ($val -and $val.status) { [string]$val.status } elseif ($val -and $val.state) { [string]$val.state } else { '' }
        $plugin = ''
        if ($val -and ($val.PSObject.Properties.Name -contains 'pluginEnabled')) { $plugin = 'plugin ' + $(if ([bool]$val.pluginEnabled) { 'enabled' } else { 'disabled' }) }
        $detail = (@($status, $plugin) | Where-Object { $_ }) -join ', '
        $lines += $(if ($detail) { "${name}: $detail" } else { "$name" })
        continue
      }
      $name = $it.name; if (-not $name) { $name = $it.harness }; if (-not $name) { $name = $it.id }
      $status = if ($it.status) { [string]$it.status } elseif ($it.state) { [string]$it.state } else { '' }
      $plugin = ''
      if ($it.PSObject.Properties.Name -contains 'pluginEnabled') { $plugin = 'plugin ' + $(if ([bool]$it.pluginEnabled) { 'enabled' } else { 'disabled' }) }
      $detail = (@($status, $plugin) | Where-Object { $_ }) -join ', '
      $lines += $(if ($detail) { "${name}: $detail" } elseif ($name) { "$name" } else { ($it | ConvertTo-Json -Compress) })
    }
    if ($lines.Count -gt 0) { return @{ State = 'detected'; Text = ($lines -join "`n") } }
    return @{ State = 'none'; Text = '' }
  } catch {
    # Not JSON: the plain text IS the already-human-readable report.
    return @{ State = 'detected'; Text = $out.TrimEnd() }
  }
}

function Update-Harnesses {
  $hcBin = Resolve-Bin 'honeycomb'
  if (-not $hcBin) {
    # b-AC-3: honeycomb CLI not on PATH -> print the exact next command; never claim success; never fail.
    Write-Warn "Honeycomb updated, but the 'honeycomb' command is not on PATH yet, so the coding-assistant plugin could not be refreshed automatically."
    Write-Host ''
    Write-Host 'Open a new terminal (so PATH refreshes), then run:'
    Write-Host ''
    Write-Host '  honeycomb setup'
    Write-Host ''
    Write-Host 'Then restart Claude Code to load the updated plugin.'
    return
  }

  if ($script:DryRun) {
    Write-Host '[dry-run] would list installed harnesses: honeycomb harness status --json'
    Write-Host '[dry-run] would refresh the Claude Code plugin: honeycomb setup'
    Write-Host '[dry-run] would print: restart Claude Code to load the updated plugin'
    return
  }

  # b-AC-1: report which harnesses are installed, via honeycomb's own detection surface (not
  # re-implemented here). Get-DetectedHarnesses drives `honeycomb harness status` (prefers --json).
  $detected = Get-DetectedHarnesses $hcBin
  if ($detected.State -eq 'detected') {
    Write-Step 'detected coding assistants:'
    Write-Host $detected.Text
  } elseif ($detected.State -eq 'none') {
    # b-AC-6: no harness installed -> clean no-op, not an error.
    Write-Ok 'no coding assistants detected.'
    Write-Ok 'no coding assistants to refresh.'
    return
  } else {
    # Detection surface unavailable on this pin: guide to the real verb, never fail (b-AC-5).
    Write-Step "run 'honeycomb harness status' to see which coding assistants are installed."
  }

  # b-AC-2: refresh the plugin via the confirmed-working `honeycomb setup` (PRD-006). Idempotent.
  Write-Step 'refreshing the Claude Code plugin (honeycomb setup)...'
  & $hcBin setup 2>$null | Out-Null
  if ($LASTEXITCODE -eq 0) {
    Write-Ok 'Claude Code plugin refreshed.'
    # b-AC-4: a running session may not hot-reload; always name the one residual manual action.
    Write-Ok 'restart Claude Code to load the updated plugin.'
  } else {
    # b-AC-5: degrade to a clear message; NEVER fail the update over the harness result.
    Write-Warn 'could not refresh the Claude Code plugin automatically.'
    Write-Host ''
    Write-Host 'Open a new terminal (so PATH refreshes), then run:'
    Write-Host ''
    Write-Host '  honeycomb setup'
    Write-Host ''
    Write-Host 'Then restart Claude Code to load the updated plugin.'
  }
}

# -----------------------------------------------------------------------------
# Node/npm presence (a-AC-9/AC-10). The updater does NOT bootstrap Node; it requires a working
# Node/npm and reports plainly if either is missing, touching nothing.
# -----------------------------------------------------------------------------
function Test-NodePresent {
  if ((Test-Have 'node') -and (Test-Have 'npm')) {
    Write-Ok "Node $(node --version) and npm $(npm --version) found."
    return $true
  }
  Write-Fail "the update needs Node $HoneycombNodeVersion and npm, but neither was found on PATH."
  Write-Host ''
  Write-Host "Install Node $HoneycombNodeVersion with ONE of these, then re-run the update:"
  Write-Host ''
  Write-Host '  # winget (recommended on Windows 10/11):'
  Write-Host '  winget install OpenJS.NodeJS.LTS'
  Write-Host ''
  Write-Host '  # or via the official MSI:'
  Write-Host '  https://nodejs.org/en/download'
  Write-Host ''
  Write-Host '  # Then re-run:'
  Write-Host "  irm $HoneycombInstallBaseUrl/update.ps1 | iex"
  Write-Host ''
  return $false
}

# One product_updated per product that ACTUALLY moved (c-AC-7); never for skipped/absent products.
# Dry-run previews only.
function Send-ProductUpdatedEvents {
  foreach ($p in $script:MovedProducts) {
    if ([string]::IsNullOrWhiteSpace($p)) { continue }
    Send-PhoneHome 'product_updated' $p
  }
}

function Write-Summary {
  $moved = ($script:MovedProducts -join ',')
  if ($script:DryRun) {
    if ($script:MovedProducts.Count -eq 0) {
      if ($script:InstalledCount -eq 0) {
        Write-Ok '[dry-run] no Apiary products are installed; nothing would be updated.'
      } else {
        Write-Ok '[dry-run] already up to date; nothing would be updated.'
      }
    } else {
      Write-Ok "[dry-run] would update: $moved."
    }
    return
  }
  if ($script:InstalledCount -eq 0) {
    Write-Ok 'No Apiary products are installed; nothing to update.'
    return
  }
  # AC-3 / a-AC-8: whole fleet already at target -> no npm, no restart, "already up to date".
  if ($script:MovedProducts.Count -eq 0) {
    Write-Ok 'already up to date.'
    return
  }
  Write-Ok "Update complete. Updated: $moved."
  if ($script:AnyFailed) {
    Write-Host 'Some products could not be updated (see the notes above); nothing was removed.'
  }
}

# -----------------------------------------------------------------------------
# Entrypoint. Returns a status CODE (never calls `exit` in the `irm | iex` bootstrap, which would
# terminate the CALLER's PowerShell host and can close the user's terminal). Funnels every terminal
# exit through the $finish scriptblock so exactly one update_completed / update_failed fires (c-AC-6).
# -----------------------------------------------------------------------------
function Invoke-Main([string[]]$InvocationArgs) {
  $parseStatus = Get-ArgumentStatus $InvocationArgs
  if ($parseStatus -eq 2) { return 0 }   # --help shown; no telemetry (a usage request is not a run)
  if ($parseStatus -ne 0) {              # unknown flag; usage error, no telemetry
    Show-Usage
    return 1
  }

  # c-AC-5: update_started fires FIRST, before any resolution work, using only Invoke-RestMethod
  # (no Node/npm dependency -- native to PowerShell).
  Resolve-InstallId
  Send-PhoneHome 'update_started'

  $finish = {
    param([int]$Code)
    if ($Code -eq 0) {
      Send-PhoneHome 'update_completed'
    } else {
      Send-PhoneHome 'update_failed'
    }
    return $Code
  }

  # a-AC-1b-1: one up-front warning when --latest bypasses the blessed set.
  if ($script:Latest) {
    Write-Warn '--latest bypasses the blessed fleet set; products may land on untested or mismatched versions.'
  }

  # a-AC-9 / AC-10: require a working Node/npm; in -DryRun, report presence but never stop.
  if ($script:DryRun) {
    if ((Test-Have 'node') -and (Test-Have 'npm')) {
      Write-Ok "Node $(node --version) and npm $(npm --version) found (dry-run: nothing will be changed)."
    } else {
      Write-Warn 'node/npm not found (dry-run; a real run requires them and would stop here without touching anything).'
    }
  } else {
    if (-not (Test-NodePresent)) { return (& $finish 1) }
  }

  Write-Step 'checking installed Apiary products (blessed = hive-release.json-pinned)...'
  # doctor is converged LAST (watchdog); the fixed slug order below guarantees that.
  Update-OneProduct 'Honeycomb' 'honeycomb' '@legioncodeinc/honeycomb' 'honeycomb' 'install'
  Update-OneProduct 'Hive'      'hive'      '@legioncodeinc/hive'      'hive'      'install-service'
  Update-OneProduct 'Nectar'    'nectar'    '@legioncodeinc/nectar'    'nectar'    'install'
  Update-OneProduct 'Doctor'    'doctor'    '@legioncodeinc/doctor'    'doctor'    'install-service'

  # 007b: refresh the coding-assistant plugin ONLY when the plugin-bearing honeycomb package moved.
  if ($script:HoneycombMoved) {
    Update-Harnesses
  }

  # c-AC-7: one product_updated per moved product. $script:MovedProducts is already the moved list,
  # so the terminal event's `products` field is honest about what changed (PRD-007c).
  Send-ProductUpdatedEvents

  Write-Summary

  # AC-9: a per-product failure is non-blocking but still surfaces as update_failed / non-zero exit
  # (mirrors install.ps1's extra-product-failed posture); an all-clean run is update_completed / 0.
  if ($script:AnyFailed) { return (& $finish 1) }
  return (& $finish 0)
}

# Set the exit code once and propagate process exit for -File runs only. Under the documented
# `irm ... | iex` bootstrap, calling `exit` would terminate the CALLER's PowerShell host (closing the
# user's terminal), so we only `exit` when this script is the top-level `-File` invocation -- exactly
# uninstall.ps1's IsTopLevelFileInvocation pattern, so `-File update.ps1` propagates its exit code.
$script:IsTopLevelFileInvocation = $false
if ($null -ne $MyInvocation -and $null -ne $MyInvocation.MyCommand) {
  $commandType = [string]$MyInvocation.MyCommand.CommandType
  $commandPath = [string]$MyInvocation.MyCommand.Path
  if (-not [string]::IsNullOrWhiteSpace($commandPath) -or $commandType -eq 'ExternalScript') {
    $script:IsTopLevelFileInvocation = $true
  }
}

$script:ExitCode = Invoke-Main $args
$global:LASTEXITCODE = $script:ExitCode
if ($script:IsTopLevelFileInvocation) {
  exit $script:ExitCode
}
uninstall.sh (POSIX sh)
#!/bin/sh
# Apiary one-command uninstall script (POSIX sh).
#
# Usage:
#   curl -fsSL https://get.theapiary.sh/uninstall | sh
#   curl -fsSL https://get.theapiary.sh/uninstall | sh -s -- --yes
#
# This script is self-contained by design. It does not call doctor purge.
# It removes only explicit allow-list targets from the frozen coverage inventory
# in library/ledger/EXECUTION_LEDGER-fleet-lifecycle.md.

set -u

YES=0
DRY_RUN=0
REMOVAL_COUNT=0
NOOP_COUNT=0
HAS_WARNINGS=0
NEEDS_MANUAL=0
NPM_UNFINISHED=""
MANUAL_COMMANDS=""

# Frozen coverage inventory (source of truth: EXECUTION_LEDGER-fleet-lifecycle.md).
NPM_PACKAGES='@legioncodeinc/honeycomb
@legioncodeinc/nectar
@legioncodeinc/hive
@legioncodeinc/doctor
@deeplake/hivemind'

LAUNCHD_CURRENT='com.legioncode.honeycomb
com.legioncode.nectar
com.legioncode.doctor
com.legioncode.hive'

LAUNCHD_LEGACY='ai.honeycomb.daemon
com.hivenectar.daemon
com.legioncode.hivedoctor
thehive'

SYSTEMD_CURRENT='honeycomb.service
nectar.service
doctor.service
hive.service'

SYSTEMD_LEGACY='ai.honeycomb.daemon.service
hivenectar.service
hivedoctor.service
thehive.service'

WINDOWS_TASKS_CURRENT='honeycomb
nectar
doctor
hive'

WINDOWS_TASKS_LEGACY='HoneycombDaemon
HivenectarDaemon
HiveDoctor
thehive'

# Markers identifying INSTALLED Apiary daemon processes by command line. Every
# globally-installed daemon runs as `node <...>/node_modules/<scope>/<pkg>/...`, so
# the scoped package segment appears verbatim in argv. A daemon running from a dev
# checkout (e.g. the-apiary/honeycomb/) does NOT contain these, so an active
# dev/test/editor session is never matched - this is the boundary between
# "uninstall the product" and "kill my editor".
DAEMON_PROCESS_MARKERS='@legioncodeinc/honeycomb
@legioncodeinc/nectar
@legioncodeinc/hive
@legioncodeinc/doctor
@deeplake/hivemind'

step() { printf '%s\n' "-> $1"; }
ok() { printf '[ok] %s\n' "$1"; }
warn() { printf '[warn] %s\n' "$1"; HAS_WARNINGS=1; }
fail() { printf 'Apiary uninstall could not continue: %s\n' "$1" >&2; }
have() { command -v "$1" >/dev/null 2>&1; }

# strip_trailing_slashes echoes the argument with every trailing slash removed
# ("/" and "///" both collapse to the empty string, which callers treat as the root).
strip_trailing_slashes() {
	_s="$1"
	while :; do
		case "$_s" in
			*/) _s="${_s%/}" ;;
			*) break ;;
		esac
	done
	printf '%s' "$_s"
}

# is_dangerous_root returns 0 (true) when a candidate deletion root is the filesystem root, a
# single-segment top-level directory (/etc, /usr, /home, /Users, /Library, ...), or the resolved
# HOME itself. A relocatable fleet root (APIARY_HOME) must never be one of these: wiping it wholesale
# would delete far outside the Apiary allow-list. This closes the "absolute is not the same as safe"
# gap - `case "$x" in /*)` accepts "/" and "/etc" as "absolute", which then reach `rm -rf`.
is_dangerous_root() {
	_p="$(strip_trailing_slashes "$1")"
	# Empty after stripping means the value was "/" (or only slashes): the filesystem root.
	[ -z "$_p" ] && return 0
	if [ -n "${HOME:-}" ]; then
		_h="$(strip_trailing_slashes "$HOME")"
		[ -n "$_h" ] && [ "$_p" = "$_h" ] && return 0
	fi
	case "$_p" in
		/*/*) return 1 ;;  # two or more segments below root (e.g. /srv/apiary): allowed
		/*) return 0 ;;    # exactly one segment below root (e.g. /etc, /home): dangerous
		*) return 0 ;;     # not absolute: never a valid deletion root
	esac
}

# validate_home refuses to run when HOME is unset, empty, non-absolute, or the filesystem root.
# EVERY deletion target is anchored on HOME, so an empty HOME would expand "$HOME/.deeplake" to
# "/.deeplake" and "$HOME/Library/LaunchAgents/..." to the system "/Library/LaunchAgents/...".
# `set -u` aborts on an *unset* HOME but NOT on an *empty* one, so this guard is required.
validate_home() {
	if [ -z "${HOME:-}" ]; then
		fail "HOME is unset or empty; every deletion target is anchored on it. Refusing to run."
		printf '%s\n' "Set HOME to your home directory and re-run."
		return 1
	fi
	case "$HOME" in
		/*) : ;;
		*)
			fail "HOME is not an absolute path (\"$HOME\"). Refusing to run."
			return 1
			;;
	esac
	if [ -z "$(strip_trailing_slashes "$HOME")" ]; then
		fail "HOME resolves to the filesystem root (\"$HOME\"). Refusing to anchor deletions at \"/\"."
		return 1
	fi
	return 0
}

add_manual_command() {
	if [ -n "$MANUAL_COMMANDS" ]; then
		MANUAL_COMMANDS="${MANUAL_COMMANDS}
$1"
	else
		MANUAL_COMMANDS="$1"
	fi
	NEEDS_MANUAL=1
}

add_npm_unfinished() {
	if [ -n "$NPM_UNFINISHED" ]; then
		NPM_UNFINISHED="${NPM_UNFINISHED} $1"
	else
		NPM_UNFINISHED="$1"
	fi
}

print_usage() {
	cat <<'USAGE'
Usage: uninstall.sh [--yes] [--dry-run] [--help]

  --yes      Skip the interactive destruction confirmation.
  --dry-run  Print what would be removed and perform no deletion.
  --help     Show this help text.

The interactive confirmation is required unless --yes is provided.
USAGE
}

parse_args() {
	for arg in "$@"; do
		case "$arg" in
			--yes) YES=1 ;;
			--dry-run) DRY_RUN=1 ;;
			--help|-h)
				print_usage
				return 2
				;;
			*)
				fail "Unknown flag: $arg. Use --help to see supported flags."
				return 1
				;;
		esac
	done
	return 0
}

confirm_destruction() {
	if [ "$YES" -eq 1 ]; then
		ok "Skipping confirmation because --yes was provided."
		return 0
	fi

	if [ ! -e /dev/tty ] || ! ( : </dev/tty ) 2>/dev/null; then
		fail "Refusing to run without confirmation in a non-interactive session."
		printf '%s\n' "Run with --yes, or download the script and run it interactively."
		return 1
	fi

	if ! cat >/dev/tty <<'PROMPT' 2>/dev/null
This will uninstall Apiary fleet artifacts from this machine.
It will remove service units, npm packages, and these state roots:
  - ~/.apiary (or APIARY_HOME when absolute)
  - ~/.deeplake (shared Deeplake credentials also used by standalone @deeplake/hivemind)
  - ~/.hivemind
  - ~/.honeycomb

Type uninstall to continue:
PROMPT
	then
		fail "Refusing to run without a usable TTY confirmation channel."
		printf '%s\n' "Run with --yes, or download the script and run it interactively."
		return 1
	fi
	printf '> ' >/dev/tty
	if ! IFS= read -r reply </dev/tty; then
		fail "Refusing to run without confirmation in a non-interactive session."
		printf '%s\n' "Run with --yes, or download the script and run it interactively."
		return 1
	fi
	if [ "$reply" != "uninstall" ]; then
		fail "Confirmation did not match. No changes were made."
		return 1
	fi
	ok "Destruction confirmed."
	return 0
}

remove_path_allowlisted() {
	path="$1"
	label="$2"

	if [ -L "$path" ]; then
		# Symlink safety: delete only the link itself, never traverse its target.
		if [ "$DRY_RUN" -eq 1 ]; then
			step "[dry-run] would remove symlink $label at $path"
			return 0
		fi
		if rm -f -- "$path" >/dev/null 2>&1; then
			ok "Removed symlink $label ($path)."
			REMOVAL_COUNT=$((REMOVAL_COUNT + 1))
		else
			warn "Failed to remove symlink $label ($path)."
		fi
		return 0
	fi

	if [ -d "$path" ]; then
		if [ "$DRY_RUN" -eq 1 ]; then
			step "[dry-run] would remove directory $label at $path"
			return 0
		fi
		if rm -rf -- "$path" >/dev/null 2>&1; then
			ok "Removed directory $label ($path)."
			REMOVAL_COUNT=$((REMOVAL_COUNT + 1))
		else
			warn "Failed to remove directory $label ($path)."
		fi
		return 0
	fi

	if [ -e "$path" ]; then
		if [ "$DRY_RUN" -eq 1 ]; then
			step "[dry-run] would remove file $label at $path"
			return 0
		fi
		if rm -f -- "$path" >/dev/null 2>&1; then
			ok "Removed file $label ($path)."
			REMOVAL_COUNT=$((REMOVAL_COUNT + 1))
		else
			warn "Failed to remove file $label ($path)."
		fi
		return 0
	fi

	NOOP_COUNT=$((NOOP_COUNT + 1))
	step "No $label at $path."
	return 0
}

remove_launchd_label() {
	label="$1"
	uid="$(id -u 2>/dev/null || printf '0')"
	user_plist="${HOME}/Library/LaunchAgents/${label}.plist"
	system_plist="/Library/LaunchDaemons/${label}.plist"

	if [ -e "$user_plist" ] || [ -L "$user_plist" ]; then
		if [ "$DRY_RUN" -eq 1 ]; then
			step "[dry-run] would bootout launchd user agent $label"
			step "[dry-run] would remove $user_plist"
		else
			if have launchctl; then
				launchctl bootout "gui/${uid}/${label}" >/dev/null 2>&1 || true
			fi
			if rm -f -- "$user_plist" >/dev/null 2>&1; then
				ok "Removed launchd user agent $label."
				REMOVAL_COUNT=$((REMOVAL_COUNT + 1))
			else
				warn "Failed to remove launchd user agent $label."
			fi
		fi
	else
		NOOP_COUNT=$((NOOP_COUNT + 1))
		step "No launchd user agent $label."
	fi

	if [ -e "$system_plist" ] || [ -L "$system_plist" ]; then
		warn "System launchd daemon exists for $label at $system_plist. Not removing without sudo."
		add_manual_command "sudo launchctl bootout system/${label} 2>/dev/null || true; sudo rm -f \"${system_plist}\""
	fi
}

SYSTEMD_RELOAD_NEEDED=0
remove_systemd_unit() {
	unit="$1"
	user_unit="${HOME}/.config/systemd/user/${unit}"
	system_unit="/etc/systemd/system/${unit}"

	if [ -e "$user_unit" ] || [ -L "$user_unit" ]; then
		if [ "$DRY_RUN" -eq 1 ]; then
			step "[dry-run] would disable and stop systemd user unit $unit"
			step "[dry-run] would remove $user_unit"
		else
			if have systemctl; then
				systemctl --user disable --now "$unit" >/dev/null 2>&1 || true
			fi
			if rm -f -- "$user_unit" >/dev/null 2>&1; then
				ok "Removed systemd user unit $unit."
				REMOVAL_COUNT=$((REMOVAL_COUNT + 1))
				SYSTEMD_RELOAD_NEEDED=1
			else
				warn "Failed to remove systemd user unit $unit."
			fi
		fi
	else
		NOOP_COUNT=$((NOOP_COUNT + 1))
		step "No systemd user unit $unit."
	fi

	if [ -e "$system_unit" ] || [ -L "$system_unit" ]; then
		warn "System systemd unit exists for $unit at $system_unit. Not removing without sudo."
		add_manual_command "sudo systemctl disable --now ${unit} 2>/dev/null || true; sudo rm -f \"${system_unit}\"; sudo systemctl daemon-reload"
	fi
}

is_windows_shell() {
	case "$(uname -s 2>/dev/null || printf 'unknown')" in
		MINGW*|MSYS*|CYGWIN*) return 0 ;;
		*) return 1 ;;
	esac
}

remove_windows_task() {
	task_name="$1"
	if ! have schtasks; then
		return 0
	fi

	if schtasks /Query /TN "$task_name" >/dev/null 2>&1; then
		if [ "$DRY_RUN" -eq 1 ]; then
			step "[dry-run] would end and delete Windows scheduled task $task_name"
			return 0
		fi
		schtasks /End /TN "$task_name" >/dev/null 2>&1 || true
		if schtasks /Delete /TN "$task_name" /F >/dev/null 2>&1; then
			ok "Removed Windows scheduled task $task_name."
			REMOVAL_COUNT=$((REMOVAL_COUNT + 1))
		else
			warn "Failed to remove Windows scheduled task $task_name."
		fi
	else
		NOOP_COUNT=$((NOOP_COUNT + 1))
		step "No Windows scheduled task $task_name."
	fi
}

remove_windows_service() {
	service_name="$1"
	if ! have sc; then
		return 0
	fi

	if sc query "$service_name" >/dev/null 2>&1; then
		if [ "$DRY_RUN" -eq 1 ]; then
			step "[dry-run] would stop and delete Windows service $service_name"
			return 0
		fi
		sc stop "$service_name" >/dev/null 2>&1 || true
		if sc delete "$service_name" >/dev/null 2>&1; then
			ok "Removed Windows service $service_name."
			REMOVAL_COUNT=$((REMOVAL_COUNT + 1))
		else
			warn "Failed to remove Windows service $service_name."
			add_manual_command "sc stop \"$service_name\" && sc delete \"$service_name\""
		fi
	else
		NOOP_COUNT=$((NOOP_COUNT + 1))
		step "No Windows service $service_name."
	fi
}

remove_services() {
	step "Removing service units and task registrations."

	while IFS= read -r label; do
		[ -n "$label" ] || continue
		remove_launchd_label "$label"
	done <<EOF
$LAUNCHD_CURRENT
EOF

	while IFS= read -r label; do
		[ -n "$label" ] || continue
		remove_launchd_label "$label"
	done <<EOF
$LAUNCHD_LEGACY
EOF

	while IFS= read -r unit; do
		[ -n "$unit" ] || continue
		remove_systemd_unit "$unit"
	done <<EOF
$SYSTEMD_CURRENT
EOF

	while IFS= read -r unit; do
		[ -n "$unit" ] || continue
		remove_systemd_unit "$unit"
	done <<EOF
$SYSTEMD_LEGACY
EOF

	if [ "$SYSTEMD_RELOAD_NEEDED" -eq 1 ] && [ "$DRY_RUN" -eq 0 ] && have systemctl; then
		systemctl --user daemon-reload >/dev/null 2>&1 || true
	fi

	if is_windows_shell; then
		while IFS= read -r task_name; do
			[ -n "$task_name" ] || continue
			remove_windows_task "$task_name"
			remove_windows_service "$task_name"
		done <<EOF
$WINDOWS_TASKS_CURRENT
EOF

		while IFS= read -r task_name; do
			[ -n "$task_name" ] || continue
			remove_windows_task "$task_name"
			remove_windows_service "$task_name"
		done <<EOF
$WINDOWS_TASKS_LEGACY
EOF
	fi
}

# -----------------------------------------------------------------------------
# Stop running daemon processes by pid file. Service deregistration only stops
# task/unit-managed instances; a daemon that was started DIRECTLY (for example
# the installer's direct-startup fallback) survives it and keeps squatting the
# loopback port with stale code. Each product writes a pid file inside its own
# state dir; read it, verify the pid is a LIVE NODE process (never kill a
# reused pid belonging to something else), then terminate it best-effort.
# -----------------------------------------------------------------------------
# Prints the pid's image name, or nothing when the pid is not alive. On Windows
# shells (git-bash/MSYS) the pid files carry WINDOWS pids that `kill -0`/`ps`
# cannot see, so the probe goes through PowerShell there.
pid_image_name() {
	pid="$1"
	if is_windows_shell; then
		powershell -NoProfile -Command "(Get-CimInstance Win32_Process -Filter \"ProcessId=$pid\" -ErrorAction SilentlyContinue).Name" 2>/dev/null | tr -d '\r\n '
		return 0
	fi
	if kill -0 "$pid" >/dev/null 2>&1; then
		ps -p "$pid" -o comm= 2>/dev/null | tr -d ' '
	fi
	return 0
}

stop_daemon_pidfile() {
	pidfile="$1"
	label="$2"

	if [ ! -f "$pidfile" ]; then
		return 0
	fi
	pid="$(head -c 32 "$pidfile" 2>/dev/null | tr -cd '0-9')"
	if [ -z "$pid" ]; then
		return 0
	fi
	image="$(pid_image_name "$pid")"
	if [ -z "$image" ]; then
		step "No running $label daemon (stale pid file)."
		return 0
	fi
	case "$image" in
		node|node.exe|*/node) ;;
		*)
			warn "Pid $pid from the $label pid file is not a node process ($image); leaving it alone (pid reuse)."
			return 0
			;;
	esac
	if [ "$DRY_RUN" -eq 1 ]; then
		step "[dry-run] would stop the running $label daemon (pid $pid)"
		return 0
	fi
	if is_windows_shell; then
		powershell -NoProfile -Command "Stop-Process -Id $pid -Force -ErrorAction SilentlyContinue" >/dev/null 2>&1 || true
	else
		kill "$pid" >/dev/null 2>&1 || true
		sleep 1
		kill -0 "$pid" >/dev/null 2>&1 && kill -9 "$pid" >/dev/null 2>&1 || true
	fi
	ok "Stopped the running $label daemon (pid $pid)."
	REMOVAL_COUNT=$((REMOVAL_COUNT + 1))
}

# -----------------------------------------------------------------------------
# Catch-all process scan. The pid-file pass above only reaches daemons that wrote
# a pid file in a known location; a daemon started DIRECTLY (installer fallback,
# `HONEYCOMB_DAEMON_SERVICE=spawn`, manual `hive start`, a leftover from a
# previous version with a different pid location) survives it and keeps squatting
# its loopback port with stale code. Scan every live process command line for one
# of the installed-package markers and terminate it. We deliberately match the
# scoped npm package segment (e.g. @legioncodeinc/honeycomb), which is present
# only for an INSTALLED daemon - a dev/test/editor session running from the repo
# checkout (the-apiary/honeycomb/) does not contain it and is left alone.
# -----------------------------------------------------------------------------
# stop_pid_if_node terminates a pid only after confirming it is a live node
# process, guarding against pid reuse between detection and kill.
stop_pid_if_node() {
	_pid="$1"
	_label="$2"
	_image="$(pid_image_name "$_pid")"
	if [ -z "$_image" ]; then
		return 0
	fi
	case "$_image" in
		node|node.exe|*/node) ;;
		*)
			warn "Pid $_pid ($_label) is not a node process ($_image); leaving it alone (pid reuse)."
			return 0
			;;
	esac
	if [ "$DRY_RUN" -eq 1 ]; then
		step "[dry-run] would stop running daemon pid $_pid ($_label)"
		return 0
	fi
	if is_windows_shell; then
		powershell -NoProfile -Command "Stop-Process -Id $_pid -Force -ErrorAction SilentlyContinue" >/dev/null 2>&1 || true
	else
		kill "$_pid" >/dev/null 2>&1 || true
		sleep 1
		kill -0 "$_pid" >/dev/null 2>&1 && kill -9 "$_pid" >/dev/null 2>&1 || true
	fi
	ok "Stopped running daemon pid $_pid ($_label)."
	REMOVAL_COUNT=$((REMOVAL_COUNT + 1))
}

# scan_daemon_pids_for_marker prints live node pids whose full command line
# contains the given marker. Uses pgrep where available (Linux/macOS/BSD),
# falls back to ps on Unix, and goes through PowerShell on Windows shells.
scan_daemon_pids_for_marker() {
	_marker="$1"
	if is_windows_shell; then
		# Win32_Process CommandLine is the full argv including node flags; normalize
		# backslashes to forward slashes so a single marker substring check matches
		# regardless of whether the bin path was recorded with \ or /.
		powershell -NoProfile -Command "\$ErrorActionPreference='SilentlyContinue'; Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" | Where-Object { (\$_.CommandLine -replace '\\\\','/') -like '*\${_marker}*' } | ForEach-Object { \$_.ProcessId }" 2>/dev/null | tr -d '\r'
		return 0
	fi
	if have pgrep; then
		pgrep -f "$_marker" 2>/dev/null
		return 0
	fi
	# POSIX ps fallback: list pid + full command and match the marker substring.
	ps -e -o pid= -o args= 2>/dev/null | while IFS= read -r _line; do
		_rest="${_line# }"
		_p="${_rest%% *}"
		[ -n "$_p" ] || continue
		case "$_line" in
			*"$_marker"*) printf '%s\n' "$_p" ;;
		esac
	done
}

stop_daemons_by_process_scan() {
	step "Scanning running processes for Apiary daemons (catch-all)."

	scan_killed=0
	# Walk the newline-separated markers list with a here-doc-fed loop so IFS does
	# not need to be disturbed (POSIX-safe, handles the @scope/name form correctly).
	while IFS= read -r marker; do
		[ -n "$marker" ] || continue
		pids="$(scan_daemon_pids_for_marker "$marker")"
		[ -z "$pids" ] && continue
		# De-duplicate: the same pid can match more than one marker (e.g. its argv
		# contains the package path in both the node argv and a copied env value).
		seen=""
		for pid in $pids; do
			[ -n "$pid" ] || continue
			case "$seen" in
				*"|$pid|"*) continue ;;
			esac
			seen="${seen}|${pid}|"
			before="$REMOVAL_COUNT"
			stop_pid_if_node "$pid" "$marker"
			if [ "$REMOVAL_COUNT" -ne "$before" ]; then
				scan_killed=$((scan_killed + 1))
			fi
		done
	done <<EOF
$DAEMON_PROCESS_MARKERS
EOF

	if [ "$scan_killed" -eq 0 ]; then
		step "No additional Apiary daemon processes found by scan."
	fi
}

stop_running_daemons() {
	step "Stopping running daemon processes (pid files)."

	roots="${HOME}/.apiary"
	apiary_home_env="${APIARY_HOME:-}"
	if [ -n "$apiary_home_env" ]; then
		case "$apiary_home_env" in
			/*)
				if ! is_dangerous_root "$apiary_home_env" && [ "$apiary_home_env" != "${HOME}/.apiary" ]; then
					roots="$roots
$apiary_home_env"
				fi
				;;
		esac
	fi

	while IFS= read -r root; do
		[ -n "$root" ] || continue
		stop_daemon_pidfile "${root}/hive/hive.pid" "hive"
		stop_daemon_pidfile "${root}/nectar/nectar.pid" "nectar"
		stop_daemon_pidfile "${root}/honeycomb/daemon.pid" "honeycomb"
	done <<EOF
$roots
EOF

	# Legacy pre-fleet-root location (honeycomb owned ~/.honeycomb before ADR-0003).
	stop_daemon_pidfile "${HOME}/.honeycomb/daemon.pid" "legacy honeycomb"

	# Catch-all: also kill any installed Apiary daemon still running that wrote no
	# pid file we know about (directly-started instances, leftover from prior
	# versions). Runs after service deregistration so nothing auto-restarts them.
	stop_daemons_by_process_scan
}

npm_is_usable() {
	if ! have npm; then
		return 1
	fi
	npm --version >/dev/null 2>&1 || return 1
	return 0
}

remove_npm_packages() {
	step "Removing npm global packages."

	if ! npm_is_usable; then
		warn "npm is unavailable or broken. Skipping npm package removals."
		while IFS= read -r pkg; do
			[ -n "$pkg" ] || continue
			add_npm_unfinished "$pkg"
		done <<EOF
$NPM_PACKAGES
EOF
		return 0
	fi

	while IFS= read -r pkg; do
		[ -n "$pkg" ] || continue
		if npm ls -g "$pkg" --depth=0 >/dev/null 2>&1; then
			if [ "$DRY_RUN" -eq 1 ]; then
				step "[dry-run] would uninstall npm package $pkg"
				continue
			fi
			if npm uninstall -g "$pkg" >/dev/null 2>&1; then
				ok "Removed npm package $pkg."
				REMOVAL_COUNT=$((REMOVAL_COUNT + 1))
			else
				warn "Failed to remove npm package $pkg."
				add_npm_unfinished "$pkg"
			fi
		else
			NOOP_COUNT=$((NOOP_COUNT + 1))
			step "No npm package $pkg."
		fi
	done <<EOF
$NPM_PACKAGES
EOF
}

remove_state_dirs() {
	step "Removing allow-list state directories."

	default_apiary_home="${HOME}/.apiary"
	remove_path_allowlisted "$default_apiary_home" "fleet root"

	apiary_home_env="${APIARY_HOME:-}"
	if [ -n "$apiary_home_env" ]; then
		case "$apiary_home_env" in
			/*)
				if is_dangerous_root "$apiary_home_env"; then
					# Absolute but unsafe: the filesystem root, a single top-level dir, or HOME
					# itself. Honoring it would `rm -rf` an entire tree outside the allow-list.
					warn "Ignoring APIARY_HOME because it points at a protected root: $apiary_home_env"
				elif [ "$apiary_home_env" != "$default_apiary_home" ]; then
					remove_path_allowlisted "$apiary_home_env" "APIARY_HOME fleet root"
				fi
				;;
			*)
				warn "Ignoring APIARY_HOME because it is not absolute: $apiary_home_env"
				;;
		esac
	fi

	remove_path_allowlisted "${HOME}/.deeplake" "Deeplake credentials directory"
	remove_path_allowlisted "${HOME}/.hivemind" "legacy Hivemind directory"
	remove_path_allowlisted "${HOME}/.honeycomb" "legacy Honeycomb directory"
}

print_manual_followups() {
	if [ -n "$NPM_UNFINISHED" ]; then
		warn "Some npm packages could not be removed automatically."
		printf '%s\n' "Run this command to finish npm cleanup:"
		printf '  npm uninstall -g %s\n' "$NPM_UNFINISHED"
	fi

	if [ "$NEEDS_MANUAL" -eq 1 ]; then
		warn "Manual removal is required for one or more system-scope services."
		printf '%s\n' "Run these commands:"
		printf '%s\n' "$MANUAL_COMMANDS" | while IFS= read -r cmd; do
			[ -n "$cmd" ] || continue
			printf '  %s\n' "$cmd"
		done
	fi
}

print_summary() {
	if [ "$DRY_RUN" -eq 1 ]; then
		ok "Dry run complete. No changes were made."
		return 0
	fi

	print_manual_followups

	if [ "$REMOVAL_COUNT" -eq 0 ] && [ "$NEEDS_MANUAL" -eq 0 ] && [ -z "$NPM_UNFINISHED" ]; then
		ok "No Apiary assets found. Nothing to remove."
		return 0
	fi

	ok "Uninstall run complete."
	printf '%s\n' "Removed items: $REMOVAL_COUNT"
	printf '%s\n' "Already absent items: $NOOP_COUNT"
	if [ "$HAS_WARNINGS" -eq 1 ]; then
		printf '%s\n' "Warnings were reported above."
	fi
	return 0
}

main() {
	parse_args "$@"
	parse_status=$?
	if [ "$parse_status" -eq 2 ]; then
		return 0
	fi
	if [ "$parse_status" -ne 0 ]; then
		return 1
	fi

	validate_home || return 1

	confirm_destruction || return 1

	remove_services
	# After deregistration (so nothing auto-restarts what we stop), kill daemons that
	# were started directly and therefore survive task/unit removal.
	stop_running_daemons
	remove_npm_packages
	remove_state_dirs
	print_summary
	return 0
}

main "$@"
uninstall.ps1 (Windows PowerShell)
# Apiary one-command uninstall script (Windows PowerShell).
#
# Usage:
#   irm https://get.theapiary.sh/uninstall.ps1 | iex
#   powershell -NoProfile -File .\uninstall.ps1 -Yes
#
# This script is self-contained by design. It does not call doctor purge.
# It removes only explicit allow-list targets from the frozen coverage inventory
# in library/ledger/EXECUTION_LEDGER-fleet-lifecycle.md.
#
# ASCII-only file: this script is intended for Windows PowerShell 5.1 via irm | iex.

$ErrorActionPreference = 'Continue'

$script:Yes = $false
$script:DryRun = $false
$script:RemovalCount = 0
$script:NoopCount = 0
$script:HasWarnings = $false
$script:NeedsManual = $false
$script:NpmUnfinished = @()
$script:ManualCommands = @()

# Frozen coverage inventory (source of truth: EXECUTION_LEDGER-fleet-lifecycle.md).
$script:NpmPackages = @(
  '@legioncodeinc/honeycomb',
  '@legioncodeinc/nectar',
  '@legioncodeinc/hive',
  '@legioncodeinc/doctor',
  '@deeplake/hivemind'
)

$script:LaunchdCurrent = @(
  'com.legioncode.honeycomb',
  'com.legioncode.nectar',
  'com.legioncode.doctor',
  'com.legioncode.hive'
)

$script:LaunchdLegacy = @(
  'ai.honeycomb.daemon',
  'com.hivenectar.daemon',
  'com.legioncode.hivedoctor',
  'thehive'
)

$script:SystemdCurrent = @(
  'honeycomb.service',
  'nectar.service',
  'doctor.service',
  'hive.service'
)

$script:SystemdLegacy = @(
  'ai.honeycomb.daemon.service',
  'hivenectar.service',
  'hivedoctor.service',
  'thehive.service'
)

$script:WindowsTasksCurrent = @('honeycomb', 'nectar', 'doctor', 'hive')
$script:WindowsTasksLegacy = @('HoneycombDaemon', 'HivenectarDaemon', 'HiveDoctor', 'thehive')
$script:SystemdReloadNeeded = $false

# Markers identifying INSTALLED Apiary daemon processes by command line. Every
# globally-installed daemon runs as `node <...>/node_modules/<scope>/<pkg>/...`, so
# the scoped package segment appears verbatim in argv. A daemon running from a dev
# checkout (e.g. the-apiary/honeycomb/) does NOT contain these, so an active
# dev/test/editor session is never matched - this is the boundary between
# "uninstall the product" and "kill my editor".
$script:DaemonProcessMarkers = @(
  '@legioncodeinc/honeycomb',
  '@legioncodeinc/nectar',
  '@legioncodeinc/hive',
  '@legioncodeinc/doctor',
  '@deeplake/hivemind'
)

function Write-Step([string]$Message) { Write-Host "-> $Message" }
function Write-Ok([string]$Message) { Write-Host "[ok] $Message" }
function Write-Warn([string]$Message) { Write-Host "[warn] $Message"; $script:HasWarnings = $true }
function Write-Fail([string]$Message) { [Console]::Error.WriteLine("Apiary uninstall could not continue: $Message") }

function Test-Have([string]$Name) {
  return [bool](Get-Command $Name -ErrorAction SilentlyContinue)
}

function Test-HasFlag([string[]]$InvocationArgs, [string]$Flag) {
  if (-not $InvocationArgs) { return $false }
  return ($InvocationArgs -contains $Flag)
}

function Get-FlagValue([string[]]$InvocationArgs, [string]$Prefix) {
  if (-not $InvocationArgs) { return $null }
  foreach ($arg in $InvocationArgs) {
    if ($arg -and $arg.StartsWith($Prefix)) {
      return $arg.Substring($Prefix.Length)
    }
  }
  return $null
}

function Show-Usage {
  Write-Host 'Usage: uninstall.ps1 [--yes|-Yes] [--dry-run|-DryRun] [--help|-h]'
  Write-Host ''
  Write-Host '  --yes / -Yes            Skip the interactive destruction confirmation.'
  Write-Host '  --dry-run / -DryRun     Print what would be removed and perform no deletion.'
  Write-Host '  --help / -h             Show this help text.'
  Write-Host ''
  Write-Host 'The interactive confirmation is required unless --yes or -Yes is provided.'
}

function Add-ManualCommand([string]$CommandText) {
  $script:ManualCommands += $CommandText
  $script:NeedsManual = $true
}

function Add-NpmUnfinished([string]$PackageName) {
  $script:NpmUnfinished += $PackageName
}

function Test-IsAbsolutePath([string]$PathValue) {
  if ([string]::IsNullOrWhiteSpace($PathValue)) { return $false }
  if ($PathValue -match '^[A-Za-z]:\\') { return $true }
  if ($PathValue.StartsWith('\\')) { return $true }
  if ($PathValue.StartsWith('/')) { return $true }
  return $false
}

# Test-IsDangerousRoot returns $true when a candidate deletion root is the filesystem/drive root,
# a single top-level segment (C:\Windows, /etc), a UNC share root, or the resolved home itself.
# "Absolute" is necessary but NOT sufficient for a relocatable fleet root: APIARY_HOME='C:\' or '/'
# would otherwise reach Remove-Item -Recurse -Force and wipe an entire tree outside the allow-list.
function Test-IsDangerousRoot([string]$PathValue, [string]$HomePath) {
  if ([string]::IsNullOrWhiteSpace($PathValue)) { return $true }
  $p = $PathValue.TrimEnd('\', '/')
  if ([string]::IsNullOrWhiteSpace($p)) { return $true }   # was "/" or "\" only
  if ($p -match '^[A-Za-z]:$') { return $true }            # drive root, e.g. C:
  if (-not [string]::IsNullOrWhiteSpace($HomePath)) {
    if ($p -eq $HomePath.TrimEnd('\', '/')) { return $true }
  }
  # Count path segments below the root; fewer than two means a top-level dir or a bare root.
  $segments = $p -split '[\\/]+' | Where-Object { $_ -ne '' -and $_ -notmatch '^[A-Za-z]:$' }
  if ($segments.Count -lt 2) { return $true }
  return $false
}

function Get-ArgumentStatus([string[]]$InvocationArgs) {
  if ((Test-HasFlag $InvocationArgs '--help') -or (Test-HasFlag $InvocationArgs '-h')) {
    Show-Usage
    return 2
  }

  if ((Test-HasFlag $InvocationArgs '--yes') -or (Test-HasFlag $InvocationArgs '-Yes')) {
    $script:Yes = $true
  }
  if ((Test-HasFlag $InvocationArgs '--dry-run') -or (Test-HasFlag $InvocationArgs '-DryRun')) {
    $script:DryRun = $true
  }

  foreach ($arg in $InvocationArgs) {
    if ($arg -in @('--help', '-h', '--yes', '-Yes', '--dry-run', '-DryRun')) {
      continue
    }
    if ($arg -like '--yes=*' -or $arg -like '--dry-run=*') {
      Write-Fail "Unsupported flag format: $arg"
      return 1
    }
    if ($arg.StartsWith('-') -or $arg.StartsWith('/')) {
      Write-Fail "Unknown flag: $arg. Use --help to see supported flags."
      return 1
    }
  }

  return 0
}

function Test-IsInteractiveHost {
  try {
    if ($null -eq $Host) { return $false }
    if ($null -eq $Host.UI) { return $false }
    if ($null -eq $Host.UI.RawUI) { return $false }
    if ([Console]::IsInputRedirected) { return $false }
    return $true
  } catch {
    return $false
  }
}

function Confirm-Destruction {
  if ($script:Yes) {
    Write-Ok 'Skipping confirmation because --yes/-Yes was provided.'
    return $true
  }

  if (-not (Test-IsInteractiveHost)) {
    Write-Fail 'Refusing to run without confirmation in a non-interactive session.'
    Write-Host 'Run with --yes (or -Yes), or download the script and run it interactively.'
    return $false
  }

  Write-Host 'This will uninstall Apiary fleet artifacts from this machine.'
  Write-Host 'It will remove service units, npm packages, and these state roots:'
  Write-Host '  - ~/.apiary (or APIARY_HOME when absolute)'
  Write-Host '  - ~/.deeplake (shared Deeplake credentials also used by standalone @deeplake/hivemind)'
  Write-Host '  - ~/.hivemind'
  Write-Host '  - ~/.honeycomb'
  $reply = Read-Host 'Type uninstall to continue'
  if ($reply -ne 'uninstall') {
    Write-Fail 'Confirmation did not match. No changes were made.'
    return $false
  }
  Write-Ok 'Destruction confirmed.'
  return $true
}

function Get-HomeDirectory {
  if (Test-IsAbsolutePath $env:APIARY_UNINSTALL_HOME) {
    return $env:APIARY_UNINSTALL_HOME
  }
  if (Test-IsAbsolutePath $env:HOME) {
    return $env:HOME
  }
  if (Test-IsAbsolutePath $env:USERPROFILE) {
    return $env:USERPROFILE
  }
  return $HOME
}

function Remove-AllowlistedPath {
  param(
    [string]$PathValue,
    [string]$Label
  )

  if (-not (Test-Path -LiteralPath $PathValue)) {
    $script:NoopCount++
    Write-Step "No $Label at $PathValue."
    return
  }

  $item = Get-Item -LiteralPath $PathValue -Force -ErrorAction SilentlyContinue
  if ($null -eq $item) {
    Write-Warn "Could not inspect $Label at $PathValue."
    return
  }

  $isSymlink = $item.Attributes.ToString().Contains('ReparsePoint')
  if ($isSymlink) {
    # Symlink safety: delete only the link itself, never traverse its target.
    if ($script:DryRun) {
      Write-Step "[dry-run] would remove symlink $Label at $PathValue"
      return
    }
    try {
      Remove-Item -LiteralPath $PathValue -Force -ErrorAction Stop
      Write-Ok "Removed symlink $Label ($PathValue)."
      $script:RemovalCount++
    } catch {
      Write-Warn "Failed to remove symlink $Label ($PathValue)."
    }
    return
  }

  if ($script:DryRun) {
    Write-Step "[dry-run] would remove $Label at $PathValue"
    return
  }

  try {
    if ($item.PSIsContainer) {
      Remove-Item -LiteralPath $PathValue -Recurse -Force -ErrorAction Stop
    } else {
      Remove-Item -LiteralPath $PathValue -Force -ErrorAction Stop
    }
    Write-Ok "Removed $Label ($PathValue)."
    $script:RemovalCount++
  } catch {
    Write-Warn "Failed to remove $Label ($PathValue)."
  }
}

function Remove-LaunchdLabel([string]$Label, [string]$HomePath) {
  $userPlist = Join-Path $HomePath "Library/LaunchAgents/$Label.plist"
  $systemPlist = "/Library/LaunchDaemons/$Label.plist"

  if (Test-Path -LiteralPath $userPlist) {
    if ($script:DryRun) {
      Write-Step "[dry-run] would bootout launchd user agent $Label"
      Write-Step "[dry-run] would remove $userPlist"
    } else {
      if (Test-Have 'launchctl') {
        $uid = ''
        if (Test-Have 'id') { $uid = (& id -u 2>$null) }
        if (-not [string]::IsNullOrWhiteSpace($uid)) {
          & launchctl bootout "gui/$uid/$Label" *> $null
        }
      }
      try {
        Remove-Item -LiteralPath $userPlist -Force -ErrorAction Stop
        Write-Ok "Removed launchd user agent $Label."
        $script:RemovalCount++
      } catch {
        Write-Warn "Failed to remove launchd user agent $Label."
      }
    }
  } else {
    $script:NoopCount++
    Write-Step "No launchd user agent $Label."
  }

  if (Test-Path -LiteralPath $systemPlist) {
    Write-Warn "System launchd daemon exists for $Label at $systemPlist. Not removing without sudo."
    Add-ManualCommand "sudo launchctl bootout system/$Label 2>/dev/null || true; sudo rm -f `"$systemPlist`""
  }
}

function Remove-SystemdUnit([string]$Unit, [string]$HomePath) {
  $userUnit = Join-Path $HomePath ".config/systemd/user/$Unit"
  $systemUnit = "/etc/systemd/system/$Unit"

  if (Test-Path -LiteralPath $userUnit) {
    if ($script:DryRun) {
      Write-Step "[dry-run] would disable and stop systemd user unit $Unit"
      Write-Step "[dry-run] would remove $userUnit"
    } else {
      if (Test-Have 'systemctl') {
        & systemctl --user disable --now $Unit *> $null
      }
      try {
        Remove-Item -LiteralPath $userUnit -Force -ErrorAction Stop
        Write-Ok "Removed systemd user unit $Unit."
        $script:RemovalCount++
        $script:SystemdReloadNeeded = $true
      } catch {
        Write-Warn "Failed to remove systemd user unit $Unit."
      }
    }
  } else {
    $script:NoopCount++
    Write-Step "No systemd user unit $Unit."
  }

  if (Test-Path -LiteralPath $systemUnit) {
    Write-Warn "System systemd unit exists for $Unit at $systemUnit. Not removing without sudo."
    Add-ManualCommand "sudo systemctl disable --now $Unit 2>/dev/null || true; sudo rm -f `"$systemUnit`"; sudo systemctl daemon-reload"
  }
}

function Remove-WindowsTask([string]$TaskName) {
  if (-not (Test-Have 'schtasks')) { return }

  & schtasks /Query /TN $TaskName *> $null
  if ($LASTEXITCODE -ne 0) {
    $script:NoopCount++
    Write-Step "No Windows scheduled task $TaskName."
    return
  }

  if ($script:DryRun) {
    Write-Step "[dry-run] would end and delete Windows scheduled task $TaskName"
    return
  }

  & schtasks /End /TN $TaskName *> $null
  & schtasks /Delete /TN $TaskName /F *> $null
  if ($LASTEXITCODE -eq 0) {
    Write-Ok "Removed Windows scheduled task $TaskName."
    $script:RemovalCount++
  } else {
    Write-Warn "Failed to remove Windows scheduled task $TaskName."
  }
}

function Remove-WindowsService([string]$ServiceName) {
  if (-not (Test-Have 'sc.exe')) { return }

  & sc.exe query $ServiceName *> $null
  if ($LASTEXITCODE -ne 0) {
    $script:NoopCount++
    Write-Step "No Windows service $ServiceName."
    return
  }

  if ($script:DryRun) {
    Write-Step "[dry-run] would stop and delete Windows service $ServiceName"
    return
  }

  & sc.exe stop $ServiceName *> $null
  & sc.exe delete $ServiceName *> $null
  if ($LASTEXITCODE -eq 0) {
    Write-Ok "Removed Windows service $ServiceName."
    $script:RemovalCount++
  } else {
    Write-Warn "Failed to remove Windows service $ServiceName."
    Add-ManualCommand "sc.exe stop `"$ServiceName`" && sc.exe delete `"$ServiceName`""
  }
}

function Remove-Services([string]$HomePath) {
  Write-Step 'Removing service units and task registrations.'

  foreach ($label in $script:LaunchdCurrent) { Remove-LaunchdLabel $label $HomePath }
  foreach ($label in $script:LaunchdLegacy) { Remove-LaunchdLabel $label $HomePath }

  foreach ($unit in $script:SystemdCurrent) { Remove-SystemdUnit $unit $HomePath }
  foreach ($unit in $script:SystemdLegacy) { Remove-SystemdUnit $unit $HomePath }

  if ($script:SystemdReloadNeeded -and -not $script:DryRun -and (Test-Have 'systemctl')) {
    & systemctl --user daemon-reload *> $null
  }

  foreach ($taskName in $script:WindowsTasksCurrent) {
    Remove-WindowsTask $taskName
    Remove-WindowsService $taskName
  }
  foreach ($taskName in $script:WindowsTasksLegacy) {
    Remove-WindowsTask $taskName
    Remove-WindowsService $taskName
  }
}

# Stop running daemon processes by pid file. Service deregistration only stops
# task-managed instances; a daemon started DIRECTLY (for example the installer's
# direct-startup fallback) survives it and keeps squatting the loopback port with
# stale code. Each product writes a pid file inside its own state dir; verify the
# pid is a LIVE NODE process (never kill a reused pid) before terminating it.
function Stop-DaemonByPidFile([string]$PidFilePath, [string]$Label) {
  if (-not (Test-Path -LiteralPath $PidFilePath)) { return }
  $raw = ''
  try { $raw = (Get-Content -LiteralPath $PidFilePath -TotalCount 1 -ErrorAction Stop) } catch { return }
  $pidText = ($raw -replace '[^0-9]', '')
  if ([string]::IsNullOrEmpty($pidText)) { return }
  $processId = 0
  if (-not [int]::TryParse($pidText, [ref]$processId)) { return }
  if ($processId -le 0) { return }
  $proc = Get-Process -Id $processId -ErrorAction SilentlyContinue
  if (-not $proc) {
    Write-Step "No running $Label daemon (stale pid file)."
    return
  }
  if ($proc.ProcessName -ne 'node') {
    Write-Warn "Pid $processId from the $Label pid file is not a node process; leaving it alone (pid reuse)."
    return
  }
  if ($script:DryRun) {
    Write-Step "[dry-run] would stop the running $Label daemon (pid $processId)"
    return
  }
  Stop-Process -Id $processId -Force -ErrorAction SilentlyContinue
  Write-Ok "Stopped the running $Label daemon (pid $processId)."
  $script:RemovalCount++
}

# Catch-all process scan. The pid-file pass above only reaches daemons that wrote
# a pid file in a known location; a daemon started DIRECTLY (installer fallback,
# `HONEYCOMB_DAEMON_SERVICE=spawn`, manual `hive start`, a leftover from a
# previous version with a different pid location) survives it and keeps squatting
# its loopback port with stale code. Scan every live node.exe command line for one
# of the installed-package markers and terminate it. We deliberately match the
# scoped npm package segment (e.g. @legioncodeinc/honeycomb), which is present
# only for an INSTALLED daemon - a dev/test/editor session running from the repo
# checkout (the-apiary/honeycomb/) does not contain it and is left alone.
function Stop-DaemonsByProcessScan {
  Write-Step 'Scanning running processes for Apiary daemons (catch-all).'

  $nodeProcesses = @()
  try {
    $nodeProcesses = @(Get-CimInstance Win32_Process -Filter "Name='node.exe'" -ErrorAction SilentlyContinue)
  } catch {
    Write-Warn 'Could not enumerate running node.exe processes; skipping process scan.'
    return
  }
  if ($nodeProcesses.Count -eq 0) {
    Write-Step 'No running node.exe processes found by scan.'
    return
  }

  $scanKilled = 0
  $seen = @{}
  foreach ($proc in $nodeProcesses) {
    $cmdLine = $proc.CommandLine
    if ([string]::IsNullOrWhiteSpace($cmdLine)) { continue }
    # Normalize backslashes to forward slashes so a single marker substring match
    # works regardless of whether the bin path was recorded with \ or /.
    $cmdLineNorm = $cmdLine -replace '\\', '/'
    $matchedMarker = $null
    foreach ($marker in $script:DaemonProcessMarkers) {
      if ($cmdLineNorm -like "*$marker*") { $matchedMarker = $marker; break }
    }
    if (-not $matchedMarker) { continue }

    $processId = $proc.ProcessId
    if ($seen.ContainsKey($processId)) { continue }
    $seen[$processId] = $true

    if ($script:DryRun) {
      Write-Step "[dry-run] would stop running daemon pid $processId ($matchedMarker)"
      continue
    }
    Stop-Process -Id $processId -Force -ErrorAction SilentlyContinue
    Write-Ok "Stopped running daemon pid $processId ($matchedMarker)."
    $script:RemovalCount++
    $scanKilled++
  }

  if ($scanKilled -eq 0 -and -not $script:DryRun) {
    Write-Step 'No additional Apiary daemon processes found by scan.'
  }
}

function Stop-RunningDaemons([string]$HomePath) {
  Write-Step 'Stopping running daemon processes (pid files).'

  $roots = @((Join-Path $HomePath '.apiary'))
  $apiaryHomeEnv = $env:APIARY_HOME
  if (-not [string]::IsNullOrEmpty($apiaryHomeEnv) -and (Test-IsAbsolutePath $apiaryHomeEnv) -and
      -not (Test-IsDangerousRoot $apiaryHomeEnv $HomePath) -and ($apiaryHomeEnv -ne (Join-Path $HomePath '.apiary'))) {
    $roots += $apiaryHomeEnv
  }

  foreach ($root in $roots) {
    Stop-DaemonByPidFile (Join-Path $root 'hive\hive.pid') 'hive'
    Stop-DaemonByPidFile (Join-Path $root 'nectar\nectar.pid') 'nectar'
    Stop-DaemonByPidFile (Join-Path $root 'honeycomb\daemon.pid') 'honeycomb'
  }

  # Legacy pre-fleet-root location (honeycomb owned ~/.honeycomb before ADR-0003).
  Stop-DaemonByPidFile (Join-Path $HomePath '.honeycomb\daemon.pid') 'legacy honeycomb'

  # Catch-all: also kill any installed Apiary daemon still running that wrote no
  # pid file we know about (directly-started instances, leftover from prior
  # versions). Runs after service deregistration so nothing auto-restarts them.
  Stop-DaemonsByProcessScan
}

function Test-NpmUsable {
  if (-not (Test-Have 'npm')) { return $false }
  & npm --version *> $null
  return ($LASTEXITCODE -eq 0)
}

function Remove-NpmPackages {
  Write-Step 'Removing npm global packages.'

  if (-not (Test-NpmUsable)) {
    Write-Warn 'npm is unavailable or broken. Skipping npm package removals.'
    foreach ($pkg in $script:NpmPackages) { Add-NpmUnfinished $pkg }
    return
  }

  foreach ($pkg in $script:NpmPackages) {
    & npm ls -g $pkg --depth=0 *> $null
    if ($LASTEXITCODE -ne 0) {
      $script:NoopCount++
      Write-Step "No npm package $pkg."
      continue
    }

    if ($script:DryRun) {
      Write-Step "[dry-run] would uninstall npm package $pkg"
      continue
    }

    & npm uninstall -g $pkg *> $null
    if ($LASTEXITCODE -eq 0) {
      Write-Ok "Removed npm package $pkg."
      $script:RemovalCount++
    } else {
      Write-Warn "Failed to remove npm package $pkg."
      Add-NpmUnfinished $pkg
    }
  }
}

function Remove-StateDirectories([string]$HomePath) {
  Write-Step 'Removing allow-list state directories.'

  $defaultApiary = Join-Path $HomePath '.apiary'
  Remove-AllowlistedPath $defaultApiary 'fleet root'

  if (-not [string]::IsNullOrWhiteSpace($env:APIARY_HOME)) {
    if (-not (Test-IsAbsolutePath $env:APIARY_HOME)) {
      Write-Warn "Ignoring APIARY_HOME because it is not absolute: $($env:APIARY_HOME)"
    } elseif (Test-IsDangerousRoot $env:APIARY_HOME $HomePath) {
      # Absolute but unsafe: a drive/filesystem root, a single top-level dir, or the home itself.
      # Honoring it would Remove-Item -Recurse an entire tree outside the Apiary allow-list.
      Write-Warn "Ignoring APIARY_HOME because it points at a protected root: $($env:APIARY_HOME)"
    } elseif ($env:APIARY_HOME -ne $defaultApiary) {
      Remove-AllowlistedPath $env:APIARY_HOME 'APIARY_HOME fleet root'
    }
  }

  Remove-AllowlistedPath (Join-Path $HomePath '.deeplake') 'Deeplake credentials directory'
  Remove-AllowlistedPath (Join-Path $HomePath '.hivemind') 'legacy Hivemind directory'
  Remove-AllowlistedPath (Join-Path $HomePath '.honeycomb') 'legacy Honeycomb directory'
}

function Write-ManualFollowups {
  if ($script:NpmUnfinished.Count -gt 0) {
    Write-Warn 'Some npm packages could not be removed automatically.'
    Write-Host 'Run this command to finish npm cleanup:'
    Write-Host ('  npm uninstall -g ' + ($script:NpmUnfinished -join ' '))
  }

  if ($script:NeedsManual -and $script:ManualCommands.Count -gt 0) {
    Write-Warn 'Manual removal is required for one or more system-scope services.'
    Write-Host 'Run these commands:'
    foreach ($cmd in $script:ManualCommands) {
      Write-Host "  $cmd"
    }
  }
}

function Write-Summary {
  if ($script:DryRun) {
    Write-Ok 'Dry run complete. No changes were made.'
    return 0
  }

  Write-ManualFollowups

  if ($script:RemovalCount -eq 0 -and -not $script:NeedsManual -and $script:NpmUnfinished.Count -eq 0) {
    Write-Ok 'No Apiary assets found. Nothing to remove.'
    return 0
  }

  Write-Ok 'Uninstall run complete.'
  Write-Host "Removed items: $($script:RemovalCount)"
  Write-Host "Already absent items: $($script:NoopCount)"
  if ($script:HasWarnings) {
    Write-Host 'Warnings were reported above.'
  }
  return 0
}

function Invoke-Main([string[]]$InvocationArgs) {
  $parseStatus = Get-ArgumentStatus $InvocationArgs
  if ($parseStatus -eq 2) { return 0 }
  if ($parseStatus -ne 0) { return 1 }

  if (-not (Confirm-Destruction)) { return 1 }

  $homePath = Get-HomeDirectory
  # Every deletion target is anchored on the resolved home. Refuse to run if it is empty,
  # non-absolute, or a bare drive/filesystem root (which would anchor deletions at the root).
  if ([string]::IsNullOrWhiteSpace($homePath) -or -not (Test-IsAbsolutePath $homePath) -or
      ($homePath.TrimEnd('\', '/') -eq '') -or ($homePath -match '^[A-Za-z]:$')) {
    Write-Fail "Home directory resolved to an unsafe value (`"$homePath`"). Refusing to run."
    return 1
  }
  Remove-Services $homePath
  # After deregistration (so nothing auto-restarts what we stop), kill daemons that
  # were started directly and therefore survive task removal.
  Stop-RunningDaemons $homePath
  Remove-NpmPackages
  Remove-StateDirectories $homePath
  return (Write-Summary)
}

# Set the exit code once and propagate process exit for -File runs only.
$script:IsTopLevelFileInvocation = $false
if ($null -ne $MyInvocation -and $null -ne $MyInvocation.MyCommand) {
  $commandType = [string]$MyInvocation.MyCommand.CommandType
  $commandPath = [string]$MyInvocation.MyCommand.Path
  if (-not [string]::IsNullOrWhiteSpace($commandPath) -or $commandType -eq 'ExternalScript') {
    $script:IsTopLevelFileInvocation = $true
  }
}

$script:ExitCode = Invoke-Main $args
$global:LASTEXITCODE = $script:ExitCode
if ($script:IsTopLevelFileInvocation) {
  exit $script:ExitCode
}