#!/usr/bin/env bash # ----------------------------------------------------------------------------- # Tx OS — host-side first-time setup helper for macOS & Linux. # # What it does (idempotent — safe to re-run): # 1. Verifies Docker + Docker Compose v2 are installed. # 2. Verifies / hints how to install mkcert (does NOT install it for you). # 3. Prompts for LOCAL_DOMAIN + LOCAL_IP (with sensible defaults). # 4. Provisions ./certs/local-cert.pem + ./certs/local-key.pem via mkcert, # reusing the existing cert when its SANs already cover the requested # hostnames. # 5. Bootstraps .env from .env.example if missing, then upserts # LOCAL_DOMAIN / LOCAL_IP / BASE_URL / PUBLIC_BASE_URL / ALLOWED_ORIGINS # preserving every other key. # 6. Prints `mkcert -CAROOT` so you can install the root CA on your phones. # 7. Runs `docker compose up -d --build`. # # What it does NOT do: # - Install packages on your behalf. # - Touch the database, volumes, or `.env` keys it doesn't own. # - Configure DNS / mDNS / Tailscale. # # Usage: ./scripts/local-setup.sh # ----------------------------------------------------------------------------- set -euo pipefail # Allow the test harness to stub `mkcert` and skip docker/compose checks. LOCAL_SETUP_DRY_RUN="${LOCAL_SETUP_DRY_RUN:-0}" # Resolve project root (parent of scripts/). SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" cd "$PROJECT_ROOT" CERTS_DIR="$PROJECT_ROOT/certs" CERT_FILE="$CERTS_DIR/local-cert.pem" KEY_FILE="$CERTS_DIR/local-key.pem" # Cross-platform `sed -i` (BSD vs GNU). if [[ "${OSTYPE:-}" == "darwin"* ]]; then SED_I=(sed -i '') else SED_I=(sed -i) fi log() { printf "==> %s\n" "$*"; } warn() { printf "WARN: %s\n" "$*" >&2; } fatal() { printf "ERROR: %s\n" "$*" >&2; exit 1; } # --------------------------------------------------------------------------- # 1. Tooling checks # --------------------------------------------------------------------------- if [[ "$LOCAL_SETUP_DRY_RUN" != "1" ]]; then if ! command -v docker >/dev/null 2>&1; then fatal "Docker not found. Install Docker Desktop from https://www.docker.com/products/docker-desktop/" fi if ! docker compose version >/dev/null 2>&1; then fatal "Docker Compose v2 not found. Update Docker Desktop." fi fi if ! command -v mkcert >/dev/null 2>&1; then warn "mkcert is not installed." if [[ "${OSTYPE:-}" == "darwin"* ]]; then echo " Install with: brew install mkcert nss" else if command -v apt-get >/dev/null 2>&1; then echo " Install with: sudo apt-get install -y mkcert libnss3-tools" elif command -v dnf >/dev/null 2>&1; then echo " Install with: sudo dnf install -y mkcert nss-tools" elif command -v pacman >/dev/null 2>&1; then echo " Install with: sudo pacman -S mkcert nss" elif command -v zypper >/dev/null 2>&1; then echo " Install with: sudo zypper install mkcert mozilla-nss-tools" elif command -v apk >/dev/null 2>&1; then echo " Install with: sudo apk add mkcert nss-tools" else echo " See https://github.com/FiloSottile/mkcert#installation" fi fi if [[ "$LOCAL_SETUP_DRY_RUN" != "1" ]]; then fatal "Re-run this script after installing mkcert." fi fi # --------------------------------------------------------------------------- # 2. Bootstrap .env from .env.example # --------------------------------------------------------------------------- if [ ! -f "$PROJECT_ROOT/.env" ]; then if [ ! -f "$PROJECT_ROOT/.env.example" ]; then fatal ".env.example missing — cannot bootstrap .env" fi log "Creating .env from .env.example ..." cp "$PROJECT_ROOT/.env.example" "$PROJECT_ROOT/.env" # Generate a SESSION_SECRET if openssl is available. if command -v openssl >/dev/null 2>&1; then SECRET="$(openssl rand -hex 32)" "${SED_I[@]}" "s|^SESSION_SECRET=.*|SESSION_SECRET=${SECRET}|" "$PROJECT_ROOT/.env" fi fi # Helper: read a key from .env, defaulting to argument 2. env_get() { local key="$1" local default="${2-}" local val val=$(grep -E "^${key}=" "$PROJECT_ROOT/.env" | head -n1 | cut -d= -f2- | tr -d '\r' || true) if [ -z "$val" ]; then printf "%s" "$default" else printf "%s" "$val" fi } # Helper: upsert KEY=VALUE in .env (insert if absent, replace if present). # Uses a tmp file so we never lose data on a partially-failed sed. env_set() { local key="$1" local value="$2" local file="$PROJECT_ROOT/.env" if grep -qE "^${key}=" "$file"; then local tmp tmp="$(mktemp)" awk -v k="$key" -v v="$value" 'BEGIN{FS=OFS="="} { if ($1 == k) { print k"="v } else { print $0 } }' "$file" > "$tmp" mv "$tmp" "$file" else printf "%s=%s\n" "$key" "$value" >> "$file" fi } # --------------------------------------------------------------------------- # 3. Prompt for LOCAL_DOMAIN + LOCAL_IP # --------------------------------------------------------------------------- detect_lan_ip() { if [[ "${OSTYPE:-}" == "darwin"* ]]; then local iface ip iface=$(route -n get default 2>/dev/null | awk '/interface:/ {print $2}' | head -n1 || true) if [ -n "$iface" ]; then ip=$(ipconfig getifaddr "$iface" 2>/dev/null || true) [ -n "$ip" ] && { printf "%s" "$ip"; return; } fi else if command -v ip >/dev/null 2>&1; then local ip ip=$(ip route get 1.1.1.1 2>/dev/null | awk '/src/ {for(i=1;i<=NF;i++) if($i=="src") print $(i+1)}' | head -n1 || true) [ -n "$ip" ] && { printf "%s" "$ip"; return; } fi fi printf "%s" "127.0.0.1" } CURRENT_DOMAIN="$(env_get LOCAL_DOMAIN tx.local)" CURRENT_IP="$(env_get LOCAL_IP "$(detect_lan_ip)")" if [[ "${LOCAL_SETUP_NONINTERACTIVE:-0}" == "1" ]]; then LOCAL_DOMAIN="$CURRENT_DOMAIN" LOCAL_IP="$CURRENT_IP" else read -r -p "LOCAL_DOMAIN [$CURRENT_DOMAIN]: " LOCAL_DOMAIN || true LOCAL_DOMAIN="${LOCAL_DOMAIN:-$CURRENT_DOMAIN}" read -r -p "LOCAL_IP [$CURRENT_IP]: " LOCAL_IP || true LOCAL_IP="${LOCAL_IP:-$CURRENT_IP}" fi log "Using LOCAL_DOMAIN=$LOCAL_DOMAIN LOCAL_IP=$LOCAL_IP" # --------------------------------------------------------------------------- # 4. Generate / refresh mkcert certificate # --------------------------------------------------------------------------- mkdir -p "$CERTS_DIR" cert_covers_sans() { # Returns 0 if cert at $CERT_FILE includes ALL of: LOCAL_DOMAIN, localhost, # 127.0.0.1, LOCAL_IP. Returns non-zero otherwise (or if openssl missing). command -v openssl >/dev/null 2>&1 || return 1 local out out=$(openssl x509 -in "$CERT_FILE" -noout -ext subjectAltName 2>/dev/null || true) [ -z "$out" ] && return 1 for needle in "$LOCAL_DOMAIN" "localhost" "127.0.0.1" "$LOCAL_IP"; do if ! grep -q -F "$needle" <<<"$out"; then return 1 fi done return 0 } if [ -f "$CERT_FILE" ] && [ -f "$KEY_FILE" ] && cert_covers_sans; then log "Existing cert already covers $LOCAL_DOMAIN / $LOCAL_IP — skipping regeneration." else log "Generating local certificate via mkcert ..." mkcert -cert-file "$CERT_FILE" -key-file "$KEY_FILE" \ "$LOCAL_DOMAIN" localhost 127.0.0.1 "$LOCAL_IP" fi # Always print the mkcert root CA location so an operator running this # script (first time or repeat) can find rootCA.pem to install on # phones / other devices. if command -v mkcert >/dev/null 2>&1; then CAROOT="$(mkcert -CAROOT 2>/dev/null || true)" if [ -n "$CAROOT" ]; then echo echo " Root CA stored in: $CAROOT" echo " To trust HTTPS on phones / other devices, install rootCA.pem" echo " from that directory." echo fi fi # --------------------------------------------------------------------------- # 5. Persist values back to .env (idempotent — preserves all other keys) # --------------------------------------------------------------------------- env_set LOCAL_DOMAIN "$LOCAL_DOMAIN" env_set LOCAL_IP "$LOCAL_IP" env_set BASE_URL "https://$LOCAL_DOMAIN" env_set PUBLIC_BASE_URL "https://$LOCAL_DOMAIN" # Merge — never overwrite — ALLOWED_ORIGINS so operator-added origins # (e.g. a Tailscale name, an extra LAN IP) are preserved across re-runs. EXISTING_ORIGINS="$(env_get ALLOWED_ORIGINS)" NEW_ORIGINS="https://$LOCAL_DOMAIN,https://$LOCAL_IP" MERGED_ORIGINS="$NEW_ORIGINS" if [ -n "$EXISTING_ORIGINS" ]; then IFS=',' read -r -a _existing_arr <<< "$EXISTING_ORIGINS" for o in "${_existing_arr[@]}"; do o="$(printf "%s" "$o" | sed -e 's/^ *//' -e 's/ *$//')" [ -z "$o" ] && continue case ",$MERGED_ORIGINS," in *",$o,"*) ;; # already present *) MERGED_ORIGINS="$MERGED_ORIGINS,$o" ;; esac done fi env_set ALLOWED_ORIGINS "$MERGED_ORIGINS" # Default HTTPS_MODE to "local" only if not already set (preserves byo/skip). if [ -z "$(env_get HTTPS_MODE)" ]; then env_set HTTPS_MODE "local" fi log ".env updated." # --------------------------------------------------------------------------- # 6. Bring the stack up # --------------------------------------------------------------------------- if [[ "$LOCAL_SETUP_DRY_RUN" == "1" ]]; then log "Dry-run mode — skipping 'docker compose up'." exit 0 fi log "Building & starting containers (first run can take 5-10 minutes) ..." docker compose up -d --build cat <