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.
macOS & Linux:
Windows (PowerShell):
macOS & Linux:
Windows (PowerShell):
macOS & Linux:
Windows (PowerShell):
Prefer to check the bytes first? Download the script and its checksum, verify, then run:
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.)
These are the SHA-256 of the exact files this domain serves. They change only when a new release is deployed.
Raw files: /install.sh · /install.ps1 · /update · /update.sh · /update.ps1 · /uninstall · /uninstall.sh · /uninstall.ps1 · /SHA256SUMS · source on GitHub
@legioncodeinc/honeycomb globally.
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.
The full scripts are inline. This is byte-for-byte what the checksums above cover.
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 "$@"
# 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
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 "$@"
# 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
}
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 "$@"
# 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
}