#!/usr/bin/env sh
# pgh installer (#66 — bootstrap-via-store, flat /bootstrap layout)
#
# Usage:
# curl -sSfL https://7rnx.net/git/bootstrap/install.sh | sh
# sh install.sh --from-dir ./bootstrap # install from a local dir of tarballs
# sh install.sh --app pgh --from ./pgh-bootstrap-bare-linux-x64.tar.gz
# sh install.sh --prefix ~/pgh-testing --bin-name pgh-new
#
# Two modes (auto-detected; override with --recover / --fresh):
#
# FRESH (no ~/.config/pgh/config.json yet) — first install on a bare machine:
# 1. Detect OS + arch.
# 2. Fetch BOTH bare tarballs from a FLAT base (latest published — no version
# subdir): <base>/pgh-bootstrap-bare-<os>-<arch>.tar.gz + the sidecar's.
# 3. Extract each into <prefix>/share/pgh/<app>/ (own dir per app).
# 4. Symlink <prefix>/bin/pgh + <prefix>/bin/pgh-sidecar at their launchers.
# 5. Prompt `pgh init`.
#
# RECOVER (already ran `pgh init`) — re-install over HTTP on a MANAGED peer,
# e.g. a node stranded behind a hyper-overlay wire break that can no longer
# fetch the fix over the swarm. HTTP delivers the closure the swarm can't:
# 1-2. Same platform detect + HTTP fetch.
# 3. Extract each straight into the managed store/apps/<app>/ dir.
# 4. Adopt IN PLACE + cycle the running daemon (stop -> swap -> start) onto
# the new tree. NEVER touches ~/.hyperident, config, or store DATA.
# 5. The sidecar re-normalizes the closure over the now-compatible swarm on
# its next autoupdate tick.
#
# pgh needs pgh-sidecar's UDS to function, so a fresh machine MUST get both —
# bare ships them as SEPARATE tarballs, which is why we fetch both here.
#
# Flags:
# --recover force recover-in-place mode (managed peer)
# --fresh force fresh-machine mode (side-dir install + init prompt)
# --from-dir <dir> install from a local dir containing the tarballs
# --from <path> install a single local tarball (requires --app)
# --app <slug> install just one app (default: both pgh + pgh-sidecar)
# --prefix <dir> install prefix (default: ~/.local)
# --runtime <r> node | bare (default: bare)
# --bin-name <name> rename pgh's bin (sidecar follows as <name>-sidecar)
# --base <url> download base (default: $PGH_DOWNLOAD_BASE or
# https://7rnx.net/git/bootstrap)
# --no-modify-path don't add <prefix>/bin to your shell rc
# --no-path-warn skip the PATH-not-set warning (implies --no-modify-path)
#
# Nothing here requires root.
set -eu
DEFAULT_BASE="${PGH_DOWNLOAD_BASE:-https://7rnx.net/git/bootstrap}"
LOCAL_PREFIX="${PGH_PREFIX:-${HOME}/.local}"
BIN_NAME="${PGH_BIN_NAME:-pgh}"
RUNTIME="${PGH_RUNTIME:-bare}"
SOURCE="" # --from: single local tarball
FROM_DIR="" # --from-dir: local dir of tarballs
ONLY_APP="" # --app: install just one
BASE="$DEFAULT_BASE"
SKIP_PATH_WARN=0
MODIFY_PATH=1 # append BIN_DIR to the login shell's rc when it's missing
FORCE_MODE="" # --recover | --fresh | "" (auto-detect)
# Idempotency markers for the block we manage in the user's shell rc. Never
# change these — a rerun finds its own previous block by exact match.
PATH_MARKER_BEGIN='# >>> pgh PATH >>>'
PATH_MARKER_END='# <<< pgh PATH <<<'
BIN_NAME_SET=0 # did the caller pass --bin-name explicitly?
# Managed-peer recovery targets the pgh-managed tree store, not the fresh-
# machine side dir. Override the store root with PGH_STORE_ROOT (testing).
STORE_ROOT="${PGH_STORE_ROOT:-${HOME}/.local/share/store}"
STORE_APPS="${STORE_ROOT}/apps"
while [ $# -gt 0 ]; do
case "$1" in
--from) SOURCE="$2"; shift 2 ;;
--from-dir) FROM_DIR="$2"; shift 2 ;;
--app) ONLY_APP="$2"; shift 2 ;;
--prefix) LOCAL_PREFIX="$2"; shift 2 ;;
--runtime) RUNTIME="$2"; shift 2 ;;
--bin-name) BIN_NAME="$2"; BIN_NAME_SET=1; shift 2 ;;
--base) BASE="$2"; shift 2 ;;
--recover) FORCE_MODE="recover"; shift ;;
--fresh) FORCE_MODE="fresh"; shift ;;
--no-modify-path) MODIFY_PATH=0; shift ;;
# Historically the only "leave my PATH alone" flag, so it keeps that
# meaning in full: suppress the warning AND touch no rc file.
--no-path-warn) SKIP_PATH_WARN=1; MODIFY_PATH=0; shift ;;
-h|--help) sed -n '2,47p' "$0"; exit 0 ;;
*) echo "unknown flag: $1" >&2; exit 1 ;;
esac
done
if [ -n "$SOURCE" ] && [ -z "$ONLY_APP" ]; then
echo "--from <tarball> requires --app <slug> (which app the tarball is)" >&2
exit 1
fi
SHARE_DIR="${LOCAL_PREFIX}/share/pgh"
BIN_DIR="${LOCAL_PREFIX}/bin"
mkdir -p "$SHARE_DIR" "$BIN_DIR"
# ── Detect platform ───────────────────────────────────────────
case "$(uname -s)" in
Linux) OS="linux" ;;
Darwin) OS="darwin" ;;
*) echo "unsupported OS: $(uname -s)" >&2; exit 1 ;;
esac
case "$(uname -m)" in
x86_64|amd64) ARCH="x64" ;;
aarch64|arm64) ARCH="arm64" ;;
*) echo "unsupported arch: $(uname -m)" >&2; exit 1 ;;
esac
ARCH_LABEL="${OS}-${ARCH}"
# ── libc guard (glibc-only) ───────────────────────────────────
# The bundled bare runtime and native prebuilds (sodium-native, udx-native)
# are built against glibc. On a musl libc (Alpine, Void-musl) the bare binary
# can't load AT ALL — so `pgh init` never runs and its own fs preflight can
# never fire. This shell script is the only place that runs regardless of
# libc, so it's the only place that can give a clear message instead of a
# cryptic loader error. Override with PGH_ALLOW_MUSL=1 if you've supplied
# musl-built trees yourself.
if [ "$OS" = "linux" ] && [ "${PGH_ALLOW_MUSL:-0}" != "1" ]; then
is_musl=0
if [ -e /lib/ld-musl-x86_64.so.1 ] || [ -e /lib/ld-musl-aarch64.so.1 ]; then
is_musl=1
elif command -v ldd >/dev/null 2>&1 && ldd --version 2>&1 | grep -qi musl; then
is_musl=1
fi
if [ "$is_musl" -eq 1 ]; then
echo "ERROR: musl libc detected (Alpine / Void-musl)." >&2
echo "pgh ships glibc-only native prebuilds (bare-runtime, sodium-native," >&2
echo "udx-native) that cannot load under musl. Use a glibc system — Debian/" >&2
echo "Ubuntu/Fedora, or the glibc build of Void. (WSL2 is fine; its distro" >&2
echo "is glibc.) Override at your own risk with PGH_ALLOW_MUSL=1." >&2
exit 1
fi
fi
if [ "$RUNTIME" = "bare" ]; then INFIX="-bootstrap-bare"; else INFIX="-bootstrap"; fi
# ── Fresh vs. recover mode ────────────────────────────────────
# A machine that has already run `pgh init` is "managed": it has a tree store
# + an identity + (usually) a running pgh-sidecar. On such a peer we RECOVER in
# place — extract each app straight into its managed store/apps/<name> dir and
# adopt it IN PLACE (cmdAdopt takes its abs==appDir branch, no "not an adopted
# app" rejection), cycling the running daemon onto the new tree. This is the
# wire-agnostic escape hatch for a node stranded behind a hyper-overlay wire
# break: HTTP delivers the closure the swarm no longer can. Recover NEVER
# touches ~/.hyperident, config, or store DATA. Auto-detected; force with
# --recover, force fresh-machine behavior with --fresh.
MANAGED=0
if [ "$FORCE_MODE" = "recover" ]; then
MANAGED=1
elif [ "$FORCE_MODE" = "fresh" ]; then
MANAGED=0
elif [ -f "${HOME}/.config/pgh/config.json" ] && [ -d "$STORE_APPS" ] && command -v pgh >/dev/null 2>&1; then
MANAGED=1
fi
[ "$MANAGED" -eq 1 ] && echo "pgh installer: MANAGED peer detected — recovering in place (store=${STORE_ROOT})"
fetch () { # <url> <out>
if command -v curl >/dev/null 2>&1; then curl -sSfL "$1" -o "$2"
elif command -v wget >/dev/null 2>&1; then wget -qO "$2" "$1"
else echo "neither curl nor wget available" >&2; exit 1; fi
}
# install_one <slug> <bin-dst>
install_one () {
slug="$1"; bindst="$2"
tarball="${slug}${INFIX}-${ARCH_LABEL}.tar.gz"
if [ -n "$SOURCE" ]; then
src="$SOURCE"
elif [ -n "$FROM_DIR" ]; then
src="${FROM_DIR}/${tarball}"
else
src="$(mktemp -u -t "${tarball}.XXXXXX")"
url="${BASE}/${tarball}" # FLAT — no version subdir; serves latest
echo " downloading ${url}"
fetch "$url" "$src"
fi
if [ ! -f "$src" ]; then
echo " ${slug}: tarball not found: ${src}" >&2
return 1
fi
# Placement + lifecycle differ by mode:
# fresh → side dir ~/.local/share/pgh/<slug>, adopt it, prompt `pgh init`.
# recover → straight into managed store/apps/<slug>, adopt IN PLACE, and
# cycle the running daemon (stop→swap→start) onto the new tree.
# pgh itself is a CLI (no supervisor); every other app is a daemon.
is_daemon=1; [ "$slug" = "pgh" ] && is_daemon=0
if [ "$MANAGED" -eq 1 ]; then
dest="${STORE_APPS}/${slug}"
# Stop a running daemon FIRST so its tree can be swapped with no half-read
# and no held lock. `pgh app stop` is a local supervisor op (no sidecar UDS
# needed), so stopping pgh-sidecar itself here is safe.
if [ "$is_daemon" -eq 1 ] && [ -x "${BIN_DIR}/${BIN_NAME}" ]; then
echo " stopping managed ${slug}"
"${BIN_DIR}/${BIN_NAME}" app stop "$slug" >/dev/null 2>&1 || true
fi
else
dest="${SHARE_DIR}/${slug}"
fi
# Extract to a scratch sibling, hoist a single wrapper dir, then swap into
# place so a concurrent reader never sees a half-written tree.
echo " extracting ${slug} -> ${dest}"
scratch="${dest}.incoming.$$"
rm -rf "$scratch"; mkdir -p "$scratch"
tar -xzf "$src" -C "$scratch"
# Tarball wraps everything in a single top-level dir (name varies). Hoist
# its contents up if package.json isn't already at the root.
if [ ! -f "$scratch/package.json" ]; then
for d in "$scratch"/*/; do
[ -d "$d" ] || continue
if [ -f "${d}package.json" ]; then
( cd "$d" && find . -maxdepth 1 -mindepth 1 -exec mv {} "$scratch/" \; )
rmdir "$d" 2>/dev/null || true
break
fi
done
fi
mkdir -p "$(dirname "$dest")"; rm -rf "$dest"; mv "$scratch" "$dest"
# Link the launcher. bare builds name it after pkg.pgh.bin (== slug here);
# fall back to bin/<slug> then the single file under bin/.
launcher=""
for cand in "$dest/bin/$slug" "$dest/bin/pgh" "$dest/bin/pgh-sidecar"; do
[ -f "$cand" ] && { launcher="$cand"; break; }
done
if [ -z "$launcher" ]; then
echo " ${slug}: no launcher found in ${dest}/bin" >&2
return 1
fi
chmod +x "$launcher"
ln -sf "$launcher" "${BIN_DIR}/${bindst}"
echo " linked ${BIN_DIR}/${bindst} -> ${launcher}"
# Adopt so the app is managed (writes .pgh-install.json — a local op, no
# sidecar/init needed). In recover mode the adopt source IS store/apps/<slug>,
# so cmdAdopt takes its in-place branch (abs == appDir) and just rewrites the
# manifest — no "not an adopted app" rejection. Best-effort: needs the pgh
# CLI, linked by the time the FIRST install_one (pgh) finishes. adopt rewrites
# the bin launcher via resolveRuntimePath, so we re-link the BUNDLED launcher
# afterwards to keep the self-contained bare runtime that ships in the tarball.
if [ -x "${BIN_DIR}/${BIN_NAME}" ]; then
if "${BIN_DIR}/${BIN_NAME}" app adopt "$dest" --name "$slug" --bin "$bindst" >/dev/null 2>&1; then
echo " adopted ${slug} (visible in '${BIN_NAME} app status')"
ln -sf "$launcher" "${BIN_DIR}/${bindst}" # bundled launcher wins
else
echo " note: adopt ${slug} skipped — run '${BIN_NAME} app adopt ${dest} --name ${slug} --bin ${bindst}'" >&2
fi
fi
# Recover: bring the daemon back up on the new tree. (Fresh mode leaves it for
# `pgh init` / `pgh app boot`.) Once the sidecar runs the new wire, it
# re-normalizes the closure over the now-compatible swarm on its next
# autoupdate tick — converting this adopted install back to a managed one.
if [ "$MANAGED" -eq 1 ] && [ "$is_daemon" -eq 1 ] && [ -x "${BIN_DIR}/${BIN_NAME}" ]; then
echo " starting managed ${slug} on the new tree"
"${BIN_DIR}/${BIN_NAME}" app start "$slug" >/dev/null 2>&1 || true
fi
}
# ── Install ───────────────────────────────────────────────────
echo "pgh installer: runtime=${RUNTIME} ${ARCH_LABEL} base=${BASE}"
if [ -n "$ONLY_APP" ]; then
# Single app: its launcher defaults to the app's OWN name so recovering just
# (say) pgh-sidecar doesn't clobber ~/.local/bin/pgh — the very CLI that drives
# the adopt/stop/start here. --bin-name overrides for the intentional rename.
if [ "$BIN_NAME_SET" -eq 1 ]; then app_bin="$BIN_NAME"; else app_bin="$ONLY_APP"; fi
install_one "$ONLY_APP" "$app_bin"
else
# Default: the full stack — pgh + its sidecar (both required on bare).
install_one pgh "$BIN_NAME"
install_one pgh-sidecar "${BIN_NAME}-sidecar"
fi
# ── PATH ──────────────────────────────────────────────────────
# The launchers we just linked live in BIN_DIR. If it isn't on PATH then the
# next command of every documented quick start — `pgh init` — is "command not
# found", and `pgh init` CANNOT be the place that fixes it: you can't invoke
# what isn't found. So the installer owns PATH (rustup's model), unless
# --no-modify-path / --no-path-warn.
#
# Why this bites on a machine that looks fine: Debian/Ubuntu's stock ~/.profile
# adds ~/.local/bin only `if [ -d ... ]`, evaluated at LOGIN. We create that
# directory here (mkdir -p above), so a first-ever install on such a box has
# the dir but not the PATH entry until the next login.
#
# An rc edit never affects the ALREADY-RUNNING shell, so when PATH is missing
# the next-steps below print absolute paths — the sequence works verbatim
# either way.
PATH_OK=0
case ":$PATH:" in *":${BIN_DIR}:"*) PATH_OK=1 ;; esac
PATH_RC="" # rc file we appended to (empty = none)
if [ "$PATH_OK" -eq 0 ] && [ "$MODIFY_PATH" -eq 1 ] && [ "$MANAGED" -ne 1 ]; then
# Target the rc of the user's LOGIN shell, not whatever is running us (curl
# | sh means $0's shell is always sh).
case "$(basename "${SHELL:-/bin/sh}")" in
zsh) rc="${ZDOTDIR:-$HOME}/.zshrc" ;;
bash) rc="${HOME}/.bashrc" ;;
fish) rc="${HOME}/.config/fish/config.fish" ;;
*) rc="${HOME}/.profile" ;;
esac
if [ -f "$rc" ] && grep -qF "$PATH_MARKER_BEGIN" "$rc" 2>/dev/null; then
PATH_RC="$rc" # already managed by a previous run — idempotent, no-op
echo " PATH block already present in ${rc}"
else
mkdir -p "$(dirname "$rc")"
# The appended line is itself guarded, so re-sourcing the rc (or a login
# shell that already has BIN_DIR from elsewhere) never duplicates the entry.
{
echo ""
echo "$PATH_MARKER_BEGIN"
if [ "$(basename "${SHELL:-/bin/sh}")" = "fish" ]; then
echo "fish_add_path \"${BIN_DIR}\""
else
echo "case \":\$PATH:\" in *\":${BIN_DIR}:\"*) ;; *) export PATH=\"${BIN_DIR}:\$PATH\" ;; esac"
fi
echo "$PATH_MARKER_END"
} >> "$rc"
PATH_RC="$rc"
echo " added ${BIN_DIR} to PATH in ${rc}"
fi
fi
echo
if [ "$MANAGED" -eq 1 ]; then
echo "pgh recovered in place (managed peer) — identity, config, and store DATA"
echo "untouched. Daemons were cycled onto the new trees over HTTP; the sidecar"
echo "re-normalizes the closure over the swarm on its next autoupdate tick."
echo
echo "Verify: ${BIN_NAME} app status"
else
# Absolute paths whenever PATH is missing in THIS shell — including right
# after we edited an rc file, which only takes effect in new shells.
if [ "$PATH_OK" -eq 1 ]; then PGH_CMD="$BIN_NAME"; else PGH_CMD="${BIN_DIR}/${BIN_NAME}"; fi
echo "pgh installed. Next steps:"
echo
echo " ${PGH_CMD} init # identity + tree store + local registries"
echo " ${PGH_CMD} app boot # start pgh-sidecar (and any autostart apps)"
echo
echo "init sets things up but does NOT start the sidecar — 'app boot' does."
if [ "$PATH_OK" -eq 0 ] && [ "$SKIP_PATH_WARN" -ne 1 ]; then
echo
if [ -n "$PATH_RC" ]; then
echo "PATH: ${BIN_DIR} added to ${PATH_RC} — applies to NEW shells."
echo " For this one: export PATH=\"${BIN_DIR}:\$PATH\""
else
echo "WARNING: ${BIN_DIR} is not on your PATH. Add to your shell rc:"
echo " export PATH=\"${BIN_DIR}:\$PATH\""
fi
fi
fi
echo
exit 0