#!/bin/sh # polyrouter one-line self-host installer (#22, spec §13). # # curl -fsSL https://raw.githubusercontent.com/izzoa/polyrouter/main/install.sh | sh # # # Pin to an exact commit (recommended — reproducible, supply-chain-safe): # curl -fsSL https://raw.githubusercontent.com/izzoa/polyrouter/main/install.sh \ # | POLYROUTER_REF= sh # # # Skip the local build and run a published image instead: # curl -fsSL .../install.sh | POLYROUTER_IMAGE=ghcr.io/izzoa/polyrouter:0.8.1 sh # # Verifies docker + Compose v2, fetches ONE source archive at a single ref (the # compose file comes from inside the archive, so compose and build context are # always the same commit), generates the required secrets into a mode-600 .env # that lives OUTSIDE the replaceable source tree and is NEVER overwritten, then # builds (or pulls) and boots the stack. Re-running is safe: it refreshes the # source and re-applies the stack, preserving .env. # # Run `./install.sh --help` for flags and environment overrides. Prefer to # inspect first? Clone the repo and run `./install.sh` from the checkout — it # uses your working tree and downloads nothing. set -eu # --- configuration ------------------------------------------------------------- # The canonical repository. Override with POLYROUTER_REPO=/ to install # from a fork (a checkout needs no download and ignores this). REPO="${POLYROUTER_REPO:-izzoa/polyrouter}" REF="${POLYROUTER_REF:-main}" PROJECT="polyrouter-selfhost" DIR="polyrouter" # A durable, polyrouter-specific marker written into a fetch-install root. A re-run # is recognized ONLY by this marker (plus .env), so the installer never mistakes an # unrelated directory that merely happens to contain `src/docker-compose.yml` for one # of its own installs and refreshes (replaces) that directory's `src/`. MARKER=".polyrouter-install" # --- output (color only on a TTY; honors NO_COLOR — https://no-color.org) ------ if [ -z "${NO_COLOR:-}" ] && [ -t 1 ]; then C_G="$(printf '\033[32m')"; C_Y="$(printf '\033[33m')"; C_DIM="$(printf '\033[2m')"; C_OFF="$(printf '\033[0m')" else C_G=''; C_Y=''; C_DIM=''; C_OFF='' fi if [ -z "${NO_COLOR:-}" ] && [ -t 2 ]; then C_R="$(printf '\033[31m')"; C_ROFF="$(printf '\033[0m')"; else C_R=''; C_ROFF=''; fi say() { printf '%s\n' "$*"; } ok() { printf '%s%s%s\n' "$C_G" "$*" "$C_OFF"; } warn() { printf '%swarning:%s %s\n' "$C_Y" "$C_OFF" "$*"; } fail() { printf '%serror:%s %s\n' "$C_R" "$C_ROFF" "$*" >&2; exit 1; } usage() { cat <<'EOF' polyrouter self-host installer Usage: ./install.sh install / refresh and start the stack ./install.sh --uninstall stop & remove the stack (KEEPS data volumes) ./install.sh --uninstall --purge also DELETE the data volumes (Postgres+Redis) [--yes] skip the confirmation prompt ./install.sh --help show this help Environment overrides: POLYROUTER_REPO source repo for fetch installs (default: izzoa/polyrouter) POLYROUTER_REF branch, tag, or commit SHA to pin (default: main) POLYROUTER_IMAGE pull this published image instead of building from source, e.g. ghcr.io/izzoa/polyrouter:0.8.1 — skips the local build POLYROUTER_HOST host interface to publish on (default: 127.0.0.1) POLYROUTER_PORT host port (default: 3001) NO_COLOR disable colored output Docs: https://github.com/izzoa/polyrouter#self-hosting EOF } # Run docker compose against this install's project/env/compose file. Omits # --env-file when .env is absent (e.g. an --uninstall after .env was removed). compose() { if [ -f "$ENV_FILE" ]; then docker compose -p "$PROJECT" --env-file "$ENV_FILE" -f "$SRC/docker-compose.yml" --project-directory "$SRC" "$@" else docker compose -p "$PROJECT" -f "$SRC/docker-compose.yml" --project-directory "$SRC" "$@" fi } # Best-effort: is a host TCP port already listening? Returns non-zero (assume # free) when no probing tool is available — Docker will still surface a conflict. port_in_use() { if command -v lsof >/dev/null 2>&1; then lsof -nP -iTCP:"$1" -sTCP:LISTEN >/dev/null 2>&1 elif command -v nc >/dev/null 2>&1; then nc -z 127.0.0.1 "$1" >/dev/null 2>&1 else return 1 fi } # Best-effort low-disk warning. NOTE: measures the CURRENT filesystem, not # necessarily Docker's storage (Linux /var/lib/docker, or the Desktop VM on # macOS), so it only warns — never blocks. check_disk() { avail_kb="$(df -Pk . 2>/dev/null | awk 'NR==2 {print $4}')" case "${avail_kb:-}" in '' | *[!0-9]*) return 0 ;; # unparseable — skip silently esac [ "$avail_kb" -lt 3145728 ] && warn "low disk space here (~$((avail_kb / 1024)) MB free) — a build + images + Postgres volume needs a few GB." return 0 } # Confirm a data-destroying action against the terminal even when stdin is a # pipe (curl | sh). No terminal → demand an explicit --yes instead of guessing. confirm_destructive() { if [ -r /dev/tty ]; then printf 'Type the project name "%s" to confirm data deletion: ' "$1" >/dev/tty IFS= read -r _ans /dev/null 2>&1 || fail "tar is required" say "fetching polyrouter source ($REPO@$REF)..." STAGE="$(mktemp -d "${TMPDIR:-/tmp}/polyrouter-src.XXXXXX")" trap 'rm -rf "$STAGE" "$STAGE.tgz"' EXIT url="https://codeload.github.com/$REPO/tar.gz/$REF" # bare form accepts branch, tag, or SHA # Download to a file THEN extract: POSIX sh has no `pipefail`, so a piped # `curl | tar` reports only tar's status and can mask a failed or truncated # download. Retry transient network failures; fall back to wget if no curl. if command -v curl >/dev/null 2>&1; then curl -fsSL --retry 3 --retry-connrefused --retry-delay 2 --connect-timeout 20 --max-time 300 \ -o "$STAGE.tgz" "$url" || fail "could not download $REPO@$REF" elif command -v wget >/dev/null 2>&1; then wget -q --tries=3 --timeout=30 -O "$STAGE.tgz" "$url" || fail "could not download $REPO@$REF" else fail "need curl or wget to download the source (or clone the repo and run ./install.sh from the checkout)" fi tar -xzf "$STAGE.tgz" -C "$STAGE" --strip-components=1 || fail "could not extract $REPO@$REF (corrupt or truncated archive)" rm -f "$STAGE.tgz" # Stage → swap, keeping the previous tree as `src.old` until the new one is in # place, so an interrupted swap can be recovered rather than losing `src` (the # steps are explicit, not `&&`-chained, since POSIX errexit does not fire on the # left of `&&`). `.env` lives outside `src/` and is never touched here. rm -rf src.new mv "$STAGE" src.new || fail "could not stage refreshed source" trap - EXIT rm -rf src.old if [ -d src ]; then mv src src.old || fail "could not set aside the previous source" fi if mv src.new src; then rm -rf src.old else [ -d src.old ] && mv src.old src # restore the working tree on a failed swap fail "could not install refreshed source" fi } # An incomplete install (our marker but no .env — e.g. the first fetch failed # before secrets were written) must NEVER be silently completed by regenerating # secrets (that would rotate keys against any volumes and orphan credentials) nor # nested over. Refuse and tell the operator. Used at both the cwd and the ./$DIR # entry points below. require_env_or_bail() { [ -f .env ] || fail "'$(pwd)' is a polyrouter install directory ($MARKER present) but .env is missing — restore it from backup, or delete this directory to start fresh; refusing to regenerate secrets (that would orphan stored credentials)." } gen_hex() { if command -v openssl >/dev/null 2>&1; then openssl rand -hex 32 else od -vN 32 -An -tx1 /dev/urandom | tr -d ' \n' fi } # --- argument dispatch --------------------------------------------------------- case "${1:-}" in -h | --help | help) usage; exit 0 ;; --uninstall | '') : ;; # empty = normal install; --uninstall handled post-preflight -*) fail "unknown option: $1 (try --help)" ;; esac # --- preflight ----------------------------------------------------------------- command -v docker >/dev/null 2>&1 || fail "docker is not installed (https://docs.docker.com/get-docker/)" docker info >/dev/null 2>&1 || fail "the docker daemon is not reachable (is Docker running? do you need sudo?)" docker compose version >/dev/null 2>&1 || fail "docker Compose v2 is required ('docker compose', not the legacy docker-compose binary)" if [ "${1:-}" = "--uninstall" ]; then shift do_uninstall "$@" # exits fi check_disk # --- locate or fetch the source tree ------------------------------------------ # .env deliberately lives NEXT TO the source tree, never inside it: the source # is replaced wholesale on re-runs and must never take the secrets with it. if [ -f docker-compose.yml ] && [ -f Dockerfile ] && [ -f package.json ]; then # A working-tree checkout — build from here, download nothing. say "using this checkout as the build context" SRC="." ENV_FILE="./.env" elif [ -f "$MARKER" ]; then # A re-run from INSIDE a prior fetch install (identified by OUR marker, never a # bare src/docker-compose.yml — see MARKER). Reuse the existing .env and refresh # src/ IN PLACE — do NOT mkdir/cd, which would nest polyrouter/polyrouter/, miss # the .env, and rotate secrets against the live volumes (a fresh POSTGRES_PASSWORD # can't auth an already-initialized database; a rotated PROVIDER_CREDENTIAL_KEY # orphans every stored credential). Keyed on the marker (not a present src/), so a # swap interrupted mid-refresh still recovers here instead of nesting. require_env_or_bail say "reusing this install (refreshing src/, keeping the existing .env)" ENV_FILE="./.env" fetch_src SRC="src" else # A fresh fetch install into ./$DIR. Refuse to adopt an EXISTING directory of that # name that is not one of OUR installs (no marker) — fetch_src would replace its # src/. An existing dir that IS ours is entered and refreshed (idempotent). if [ -e "$DIR" ] && [ ! -f "$DIR/$MARKER" ]; then fail "a '$DIR' directory already exists here and is not a polyrouter install (no $MARKER marker) — remove it or run from another directory" fi mkdir -p "$DIR" || fail "could not create the '$DIR' directory" cd "$DIR" || fail "could not enter the '$DIR' directory" ENV_FILE="./.env" if [ -f "$MARKER" ]; then require_env_or_bail # entering an existing install of ours — keep its .env else printf 'polyrouter fetch install — re-run marker; do not delete\n' >"$MARKER" fi fetch_src SRC="src" fi # --- secrets (.env) — generated ONCE, never rotated ---------------------------- # A mode-independent sentinel recording that secrets were once generated HERE. # Guards both fetch and checkout modes: if it exists but .env does not, .env was # removed after a real install — regenerating would rotate keys against the # existing volumes (breaking DB auth, orphaning stored credentials), so we refuse. SECRETS_SENTINEL="$(dirname "$ENV_FILE")/.polyrouter-secrets-created" if [ -f "$ENV_FILE" ]; then FRESH_INSTALL=0 say "keeping the existing .env (secrets are never rotated: rotating the" say "encryption keys would orphan stored provider/channel credentials)" else FRESH_INSTALL=1 [ -f "$SECRETS_SENTINEL" ] && fail ".env is missing but secrets were previously generated here — restore .env from backup rather than regenerate (new keys would break DB auth and orphan stored credentials against the existing volumes). To deliberately start over, delete $SECRETS_SENTINEL (and the stack's volumes)." say "generating secrets into $ENV_FILE (600) ..." umask 077 # Temp file BESIDE the destination so the rename is atomic on one filesystem. TMP_ENV="$ENV_FILE.tmp.$$" trap 'rm -f "$TMP_ENV"' EXIT { printf '# polyrouter self-host secrets — generated %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" printf '# NEVER commit this file. Key-rotation caveat: see README (Self-hosting).\n' printf 'BETTER_AUTH_SECRET=%s\n' "$(gen_hex)" printf 'API_KEY_HMAC_SECRET=%s\n' "$(gen_hex)" printf 'PROVIDER_CREDENTIAL_KEY=%s\n' "$(gen_hex)" printf 'NOTIFY_CREDENTIALS_SECRET=%s\n' "$(gen_hex)" printf 'POSTGRES_PASSWORD=%s\n' "$(gen_hex)" printf '\n# Exposure (claim the admin account BEFORE exposing):\n' printf '# POLYROUTER_HOST=0.0.0.0\n' printf '# POLYROUTER_PORT=3001\n' printf '# APP_URL=https://polyrouter.example.com\n' } >"$TMP_ENV" mv "$TMP_ENV" "$ENV_FILE" trap - EXIT # Record that secrets exist here, so a later run with a deleted .env refuses to # regenerate rather than rotating keys against the live volumes. : >"$SECRETS_SENTINEL" # .env must never end up in a fork/PR by accident. [ -f .gitignore ] || printf '.env\n.polyrouter-install\n.polyrouter-secrets-created\npolyrouter/\nsrc/\n' >.gitignore fi # --- port preflight ------------------------------------------------------------ # POLYROUTER_PORT wins if exported; else the .env value; else the default. PORT="${POLYROUTER_PORT:-$(grep -E '^POLYROUTER_PORT=' "$ENV_FILE" 2>/dev/null | cut -d= -f2 || true)}" PORT="${PORT:-3001}" MANAGE="docker compose -p $PROJECT --env-file $ENV_FILE -f $SRC/docker-compose.yml --project-directory $SRC" # Skip the check when our own stack is already running (it legitimately holds the # port); otherwise a bound port means another process — fail early and clearly. STACK_UP="$(docker ps -q --filter "label=com.docker.compose.project=$PROJECT" 2>/dev/null || true)" if [ -z "$STACK_UP" ] && port_in_use "$PORT"; then fail "port $PORT is already in use on this host (no polyrouter stack is running) — set POLYROUTER_PORT to a free port, or stop what's using it." fi # --- boot ---------------------------------------------------------------------- if [ -n "${POLYROUTER_IMAGE:-}" ]; then say "pulling published image $POLYROUTER_IMAGE (skipping the local build) ..." docker pull "$POLYROUTER_IMAGE" || fail "could not pull $POLYROUTER_IMAGE (is the package public? you may need 'docker login ghcr.io')" compose up -d else say "building and starting the stack (first build takes a few minutes) ..." compose up -d --build fi # --- health check -------------------------------------------------------------- # Confirm the app actually answers before declaring success — a container that # crash-loops on boot must not be reported as a clean start. (Skipped without curl.) if command -v curl >/dev/null 2>&1; then printf 'waiting for polyrouter to answer on http://localhost:%s ' "$PORT" i=0 until curl -fs -o /dev/null "http://localhost:$PORT/api/health" 2>/dev/null; do i=$((i + 1)) if [ "$i" -ge 40 ]; then say "" warn "not answering after ~2m — it may still be migrating, or failing to boot." say "container status:" compose ps || true say "logs: $MANAGE logs -f app" break fi printf '.' sleep 3 done if [ "$i" -lt 40 ]; then say "" ok "polyrouter answered — it's up." fi fi # --- done ---------------------------------------------------------------------- say "" ok "polyrouter: http://localhost:$PORT" if [ "$FRESH_INSTALL" -eq 1 ]; then say "the FIRST account you sign up becomes the admin — claim it now." else say "refreshed and restarted — your admin account and data are unchanged." fi say "${C_DIM}manage:${C_OFF} $MANAGE " say "${C_DIM}remove:${C_OFF} re-run this installer with --uninstall (add --purge to delete data)"