OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / scripts / install.sh

369 lines · bash ref:
#!/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