#!/usr/bin/env bash # # Appstrate installer — get.appstrate.dev # # curl -fsSL https://get.appstrate.dev | bash # # Env overrides: # APPSTRATE_VERSION Pin a version (default: bundled stable) # APPSTRATE_DIR Install dir (default: $HOME/.appstrate) # APPSTRATE_PORT HTTP port (default: 3000, auto-fallback if busy) set -euo pipefail # Restrict default file creation to owner-only (secrets in .env). umask 077 # ─── Constants (APPSTRATE_VERSION rewritten by publish-installer.yml) ───────── APPSTRATE_VERSION="${APPSTRATE_VERSION:-v1.0.0-alpha.48}" if [[ "$APPSTRATE_VERSION" == __* ]]; then echo "Error: APPSTRATE_VERSION was not set by the publish pipeline." >&2 echo "Use: APPSTRATE_VERSION=v1.0.0 bash install.sh" >&2 exit 1 fi # Validate version format to prevent sed injection and ensure safe interpolation. # Accepts: v1.0.0, v1.0.0-beta.1, v1.0.0-rc1, "local", "latest" (for CI/dev). if [[ ! "$APPSTRATE_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9._]+)?$ ]] && [[ "$APPSTRATE_VERSION" != "local" ]] && [[ "$APPSTRATE_VERSION" != "latest" ]]; then echo "Error: Invalid APPSTRATE_VERSION format: $APPSTRATE_VERSION" >&2 echo "Expected: vMAJOR.MINOR.PATCH[-prerelease] (e.g. v1.0.0, v1.2.3-beta.1)" >&2 exit 1 fi # Docker image tags are published without the 'v' prefix (semver pattern in # release workflow), but git refs / asset URLs use 'v'. Keep both forms. APPSTRATE_GIT_REF="$APPSTRATE_VERSION" APPSTRATE_IMAGE_TAG="${APPSTRATE_VERSION#v}" # Local testing: copy assets from this dir instead of curl. When unset, fetch from GitHub. APPSTRATE_ASSETS_DIR="${APPSTRATE_ASSETS_DIR:-}" APPSTRATE_DIR="${APPSTRATE_DIR:-$HOME/.appstrate}" APPSTRATE_PORT="${APPSTRATE_PORT:-3000}" # When 1, suppress the banner and the "Next steps" trailer (used by test harness). APPSTRATE_QUIET="${APPSTRATE_QUIET:-0}" GITHUB_REPO="appstrate/appstrate" GITHUB_RAW="https://raw.githubusercontent.com/${GITHUB_REPO}" COMPOSE_PATH="examples/self-hosting/docker-compose.yml" ENV_EXAMPLE_PATH="examples/self-hosting/.env.example" MIN_DOCKER_MAJOR=20 MIN_DISK_KB=524288 # 512 MB INSTALL_START=$(date +%s) LOG_FILE="" INSTALL_MODE="" PREVIOUS_VERSION="" # ─── Output helpers ─────────────────────────────────────────────────────────── if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then C_RESET="\033[0m" C_DIM="\033[2m" C_RED="\033[0;31m" C_GREEN="\033[0;32m" C_YELLOW="\033[0;33m" C_CYAN="\033[0;36m" C_BOLD="\033[1m" else C_RESET="" C_DIM="" C_RED="" C_GREEN="" C_YELLOW="" C_CYAN="" C_BOLD="" fi log() { printf "%b→%b %s\n" "$C_CYAN" "$C_RESET" "$*"; } ok() { printf "%b✓%b %s\n" "$C_GREEN" "$C_RESET" "$*"; } warn() { printf "%b⚠%b %s\n" "$C_YELLOW" "$C_RESET" "$*" >&2; } err() { printf "%b✗%b %s\n" "$C_RED" "$C_RESET" "$*" >&2; } fatal() { err "$*" exit 1 } on_error() { local line=$1 err "Installation failed at line $line" [ -n "$LOG_FILE" ] && err "Logs: $LOG_FILE" err "Help: https://appstrate.dev/docs/install#troubleshooting" err " https://github.com/${GITHUB_REPO}/issues" exit 1 } trap 'on_error $LINENO' ERR cleanup_tmp() { [ -n "${APPSTRATE_DIR:-}" ] && rm -rf "$APPSTRATE_DIR/.tmp" 2>/dev/null || true } trap cleanup_tmp EXIT command_exists() { command -v "$1" >/dev/null 2>&1; } # ─── Sections ───────────────────────────────────────────────────────────────── print_banner() { cat <<'EOF' █████╗ ██████╗ ██████╗ ███████╗████████╗██████╗ █████╗ ████████╗███████╗ ██╔══██╗██╔══██╗██╔══██╗██╔════╝╚══██╔══╝██╔══██╗██╔══██╗╚══██╔══╝██╔════╝ ███████║██████╔╝██████╔╝███████╗ ██║ ██████╔╝███████║ ██║ █████╗ ██╔══██║██╔═══╝ ██╔═══╝ ╚════██║ ██║ ██╔══██╗██╔══██║ ██║ ██╔══╝ ██║ ██║██║ ██║ ███████║ ██║ ██║ ██║██║ ██║ ██║ ███████╗ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚══════╝ EOF printf "%bInstalling Appstrate %s into %s%b\n\n" "$C_BOLD" "$APPSTRATE_VERSION" "$APPSTRATE_DIR" "$C_RESET" } detect_environment() { log "Checking environment" # OS local os arch os=$(uname -s 2>/dev/null || echo unknown) arch=$(uname -m 2>/dev/null || echo unknown) case "$arch" in x86_64 | amd64) arch="amd64" ;; aarch64 | arm64) arch="arm64" ;; *) fatal "Unsupported architecture: $arch (need amd64 or arm64)" ;; esac ok "$os $arch detected" # Docker if ! command_exists docker; then err "Docker is not installed" case "$os" in Linux) err "Install: curl -fsSL https://get.docker.com | sh" ;; Darwin) err "Install Docker Desktop, OrbStack, or Colima" ;; *) err "Install Docker for your platform: https://docs.docker.com/get-docker/" ;; esac exit 1 fi if ! docker info >/dev/null 2>&1; then err "Docker is installed but not accessible" err " → Is the daemon running? (Docker Desktop, OrbStack, or 'systemctl start docker')" err " → Are you in the docker group? ('sudo usermod -aG docker \$USER' then re-login)" exit 1 fi local docker_version major docker_version=$(docker version --format '{{.Server.Version}}' 2>/dev/null || echo "0") major=$(echo "$docker_version" | cut -d. -f1) if [ "$major" -lt "$MIN_DOCKER_MAJOR" ] 2>/dev/null; then fatal "Docker $docker_version detected, need $MIN_DOCKER_MAJOR.10 or higher" fi ok "Docker $docker_version detected" # Compose v2 plugin if ! docker compose version >/dev/null 2>&1; then err "Docker Compose v2 plugin not found" err " → Update Docker, or install: https://docs.docker.com/compose/install/" exit 1 fi local compose_version compose_version=$(docker compose version --short 2>/dev/null || echo "?") ok "docker compose v$compose_version detected" # curl + openssl command_exists curl || fatal "curl not found" command_exists openssl || fatal "openssl not found" # Port (auto-fallback in non-interactive mode = always for piped install) if port_in_use "$APPSTRATE_PORT"; then local original=$APPSTRATE_PORT for p in 3001 3002 3003 3010 8080; do if ! port_in_use "$p"; then APPSTRATE_PORT=$p break fi done if [ "$APPSTRATE_PORT" = "$original" ]; then fatal "Port $original is busy and no fallback port available. Set APPSTRATE_PORT=." fi warn "Port $original busy → using $APPSTRATE_PORT instead" else ok "Port $APPSTRATE_PORT available" fi } port_in_use() { local p=$1 if command_exists lsof; then lsof -iTCP:"$p" -sTCP:LISTEN -Pn >/dev/null 2>&1 elif command_exists ss; then ss -lnt "sport = :$p" 2>/dev/null | grep -q LISTEN else # Neither lsof nor ss available — cannot check, assume port is free. warn "Cannot check port availability (install lsof or ss for port detection)" return 1 fi } acquire_lock() { if ! mkdir "$APPSTRATE_DIR/.install.lock" 2>/dev/null; then fatal "Another install is already running in $APPSTRATE_DIR (remove $APPSTRATE_DIR/.install.lock if stale)" fi # Release lock on exit (appended to existing EXIT trap chain via subshell wrapper) # shellcheck disable=SC2154 trap 'rmdir "$APPSTRATE_DIR/.install.lock" 2>/dev/null || true; cleanup_tmp' EXIT } prepare_workdir() { mkdir -p "$APPSTRATE_DIR" LOG_FILE="$APPSTRATE_DIR/install-$(date +%Y%m%d-%H%M%S).log" : >"$LOG_FILE" chmod 600 "$LOG_FILE" # Rotate old logs — keep last 5 # shellcheck disable=SC2012 ls -t "$APPSTRATE_DIR"/install-*.log 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true log "Working directory: $APPSTRATE_DIR" # Disk space check local avail_kb avail_kb=$(df -k "$APPSTRATE_DIR" 2>/dev/null | awk 'NR==2 {print $4}') if [ -n "$avail_kb" ] && [ "$avail_kb" -lt "$MIN_DISK_KB" ] 2>/dev/null; then fatal "Less than 512MB disk space available in $APPSTRATE_DIR (${avail_kb}KB free)" fi } determine_install_mode() { if [ ! -f "$APPSTRATE_DIR/.env" ]; then INSTALL_MODE="fresh" return fi if [ -f "$APPSTRATE_DIR/.version" ]; then PREVIOUS_VERSION=$(cat "$APPSTRATE_DIR/.version" 2>/dev/null || echo "") fi if [ "$PREVIOUS_VERSION" = "$APPSTRATE_VERSION" ]; then INSTALL_MODE="noop" else INSTALL_MODE="upgrade" fi } download_assets() { local tmpdir tmpdir="$APPSTRATE_DIR/.tmp" rm -rf "$tmpdir" mkdir -p "$tmpdir" if [ -n "$APPSTRATE_ASSETS_DIR" ]; then log "Copying assets from $APPSTRATE_ASSETS_DIR (local mode)" cp "$APPSTRATE_ASSETS_DIR/docker-compose.yml" "$tmpdir/docker-compose.yml" cp "$APPSTRATE_ASSETS_DIR/.env.example" "$tmpdir/.env.example" else log "Downloading docker-compose.yml ($APPSTRATE_VERSION)" curl_fetch "$GITHUB_RAW/$APPSTRATE_GIT_REF/$COMPOSE_PATH" "$tmpdir/docker-compose.yml" curl_fetch "$GITHUB_RAW/$APPSTRATE_GIT_REF/$ENV_EXAMPLE_PATH" "$tmpdir/.env.example" fi # Backup existing compose on upgrade (keep last 3) if [ "$INSTALL_MODE" = "upgrade" ] && [ -f "$APPSTRATE_DIR/docker-compose.yml" ]; then local ts ts=$(date +%Y%m%d-%H%M%S) cp "$APPSTRATE_DIR/docker-compose.yml" "$APPSTRATE_DIR/docker-compose.yml.bak-$ts" # shellcheck disable=SC2012 ls -t "$APPSTRATE_DIR"/docker-compose.yml.bak-* 2>/dev/null | tail -n +4 | xargs rm -f 2>/dev/null || true fi mv "$tmpdir/docker-compose.yml" "$APPSTRATE_DIR/docker-compose.yml" mv "$tmpdir/.env.example" "$APPSTRATE_DIR/.env.example" rm -rf "$tmpdir" 2>/dev/null || true } curl_fetch() { local url=$1 out=$2 if ! curl -fsSL --retry 3 --retry-delay 2 --max-time 30 -o "$out" "$url"; then fatal "Failed to download $url" fi } handle_env_file() { case "$INSTALL_MODE" in fresh) log "Generating .env with fresh secrets" generate_fresh_env ;; upgrade) log "Merging new .env keys (preserving existing values)" local ts ts=$(date +%Y%m%d-%H%M%S) cp "$APPSTRATE_DIR/.env" "$APPSTRATE_DIR/.env.bak-$ts" merge_env chmod 600 "$APPSTRATE_DIR/.env" # Rotate old .env backups — keep last 3 # shellcheck disable=SC2012 ls -t "$APPSTRATE_DIR"/.env.bak-* 2>/dev/null | tail -n +4 | xargs rm -f 2>/dev/null || true ;; noop) : ;; esac } generate_fresh_env() { local pg auth runtok enckey miniopw uploadsig docker_gid pg=$(rand_hex 16) auth=$(rand_hex 32) runtok=$(rand_hex 32) enckey=$(rand_b64 32) miniopw=$(rand_hex 16) uploadsig=$(rand_hex 32) docker_gid=$(detect_docker_gid) # Write to temp file, then move atomically — avoids partial .env on Ctrl+C. cat >"$APPSTRATE_DIR/.env.tmp" </dev/null || stat -f '%g' /var/run/docker.sock 2>/dev/null || echo "") fi if [ -z "$gid" ]; then if ! gid=$(docker run --rm \ -v /var/run/docker.sock:/var/run/docker.sock:ro \ alpine:3 stat -c '%g' /var/run/docker.sock 2>/dev/null); then warn "Could not probe Docker socket GID — defaulting to 0 (override DOCKER_GID in .env if needed)" gid=0 fi fi echo "${gid:-0}" } merge_env() { # Add any keys present in .env.example but missing from .env (no overwrite) local key while IFS= read -r line; do case "$line" in '' | \#*) continue ;; esac key=${line%%=*} [ -z "$key" ] && continue if ! grep -qF "${key}=" "$APPSTRATE_DIR/.env" 2>/dev/null; then # New key — needs a value case "$key" in BETTER_AUTH_SECRET | RUN_TOKEN_SECRET | UPLOAD_SIGNING_SECRET) echo "$key=$(rand_hex 32)" >>"$APPSTRATE_DIR/.env" ;; CONNECTION_ENCRYPTION_KEY) echo "$key=$(rand_b64 32)" >>"$APPSTRATE_DIR/.env" ;; POSTGRES_PASSWORD | MINIO_ROOT_PASSWORD) echo "$key=$(rand_hex 16)" >>"$APPSTRATE_DIR/.env" ;; *) echo "$line" >>"$APPSTRATE_DIR/.env" ;; esac fi done <"$APPSTRATE_DIR/.env.example" # Pin new APPSTRATE_VERSION if grep -qF "APPSTRATE_VERSION=" "$APPSTRATE_DIR/.env"; then sed_inplace "s|^APPSTRATE_VERSION=.*|APPSTRATE_VERSION=$APPSTRATE_IMAGE_TAG|" "$APPSTRATE_DIR/.env" else echo "APPSTRATE_VERSION=$APPSTRATE_IMAGE_TAG" >>"$APPSTRATE_DIR/.env" fi } sed_inplace() { local expr=$1 file=$2 if sed --version >/dev/null 2>&1; then sed -i "$expr" "$file" else sed -i '' "$expr" "$file"; fi } rand_hex() { openssl rand -hex "$1"; } rand_b64() { openssl rand -base64 "$1" | tr -d '\n'; } pull_images() { if [ "$INSTALL_MODE" = "noop" ]; then return; fi if [ -n "$APPSTRATE_ASSETS_DIR" ]; then log "Skipping pull (local mode — using locally-tagged images, base images pulled on up)" return fi log "Pulling images (this may take a minute)" (cd "$APPSTRATE_DIR" && COMPOSE_PROGRESS=plain docker compose pull) >>"$LOG_FILE" 2>&1 } start_services() { if [ "$INSTALL_MODE" = "noop" ]; then log "Already at $APPSTRATE_VERSION — ensuring services are running" fi # Validate compose config before starting (catches bad downloads or env mismatches) if ! (cd "$APPSTRATE_DIR" && docker compose config --quiet) >>"$LOG_FILE" 2>&1; then fatal "docker-compose.yml validation failed — see $LOG_FILE" fi log "Starting services" (cd "$APPSTRATE_DIR" && docker compose up -d) >>"$LOG_FILE" 2>&1 } wait_for_health() { log "Waiting for services to become healthy" local deadline=$(($(date +%s) + 120)) local url="http://localhost:$APPSTRATE_PORT/" while [ "$(date +%s)" -lt "$deadline" ]; do if curl -fs -o /dev/null --max-time 3 "$url" 2>>"$LOG_FILE"; then ok "appstrate is responding on $url" # Atomic write of .version (temp + mv) echo "$APPSTRATE_VERSION" >"$APPSTRATE_DIR/.version.tmp" mv "$APPSTRATE_DIR/.version.tmp" "$APPSTRATE_DIR/.version" return fi sleep 2 done err "appstrate did not become healthy within 120s" err " → cd $APPSTRATE_DIR && docker compose logs -f" err " → Logs: $LOG_FILE" exit 1 } print_next_steps() { local elapsed=$(($(date +%s) - INSTALL_START)) local mode_label case "$INSTALL_MODE" in fresh) mode_label="Installed" ;; upgrade) mode_label="Upgraded ($PREVIOUS_VERSION → $APPSTRATE_VERSION)" ;; noop) mode_label="Already at $APPSTRATE_VERSION" ;; esac printf "\n%b✓ %s in %ds%b\n" "$C_GREEN$C_BOLD" "$mode_label" "$elapsed" "$C_RESET" printf "%b────────────────────────────────────────────%b\n" "$C_DIM" "$C_RESET" cat <