#!/bin/sh # Superatom one-line installer: curl -fsSL https://install.superatom.ai | sh # # New install: asks for an install token (super-admin console → project → "Generate # install token"), redeems it, downloads the code, then runs ./setup.sh --docker with # the returned config pre-filled. Update: re-run the command and pick the install; # the project's API key (already in backend/.env) authenticates the download. # # POSIX sh on purpose (piped into dash on Ubuntu). Server responses are KEY=value # lines, so nothing beyond curl + tar is needed on a fresh VM. # # Overrides (testing): SA_INSTALL_URL (default https://install.superatom.ai), # SA_INSTALL_ROOT (default /opt/superatom) set -eu BASE_URL="${SA_INSTALL_URL:-https://dev.install.superatom.ai}" INSTALL_ROOT="${SA_INSTALL_ROOT:-/opt/superatom}" MARKER=".superatom-install" # per install: project id, installed commit MANIFEST=".superatom-files" # per install: files that came from the tarball PREFILL=".install.env" # redeemed config; setup.sh deletes it once used if [ -t 1 ]; then RED=$(printf '\033[0;31m'); GREEN=$(printf '\033[0;32m'); YELLOW=$(printf '\033[1;33m') CYAN=$(printf '\033[0;36m'); BLUE=$(printf '\033[0;34m'); NC=$(printf '\033[0m') else RED=""; GREEN=""; YELLOW=""; CYAN=""; BLUE=""; NC="" fi step() { printf '%s➤ %s%s\n' "$CYAN" "$1" "$NC"; } ok() { printf '%s✓ %s%s\n' "$GREEN" "$1" "$NC"; } warn() { printf '%s⚠ %s%s\n' "$YELLOW" "$1" "$NC"; } die() { printf '%s✗ %s%s\n' "$RED" "$1" "$NC" >&2; exit 1; } TMP_DIR="" cleanup() { [ -n "$TMP_DIR" ] && rm -rf "$TMP_DIR"; } trap cleanup EXIT INT TERM # Reads a line from the terminal (stdin is this script when piped into sh). ask() { printf '%s%s%s' "$CYAN" "$1" "$NC" > /dev/tty IFS= read -r REPLY < /dev/tty || REPLY="" } ask_secret() { printf '%s%s%s' "$CYAN" "$1" "$NC" > /dev/tty stty -echo < /dev/tty 2>/dev/null || true IFS= read -r REPLY < /dev/tty || REPLY="" stty echo < /dev/tty 2>/dev/null || true printf '\n' > /dev/tty } check_system() { case "$(uname -s)" in Linux) ;; Darwin) warn "macOS: needs Docker Desktop running. Linux is the supported target for clients." ;; MINGW*|MSYS*|CYGWIN*) die "Windows shells are not supported. Use WSL2 (Ubuntu) on Windows Server 2022+, or a Linux VM." ;; *) die "Unsupported OS: $(uname -s)" ;; esac for cmd in curl tar bash; do command -v "$cmd" > /dev/null 2>&1 || die "'$cmd' is required. Install it and re-run." done [ -r /dev/tty ] || die "Run this from an interactive terminal (it asks for an install token)." } # POSTs a JSON body; server replies with KEY=value lines (or an error message). # Usage: api_post [bearer] api_post() { if [ -n "${4:-}" ]; then status=$(curl -sS -o "$3" -w '%{http_code}' -X POST -H 'Content-Type: application/json' \ -H "Authorization: Bearer $4" --data "$2" "$BASE_URL$1") || die "Cannot reach $BASE_URL" else status=$(curl -sS -o "$3" -w '%{http_code}' -X POST -H 'Content-Type: application/json' \ --data "$2" "$BASE_URL$1") || die "Cannot reach $BASE_URL" fi if [ "$status" != "200" ]; then die "Server refused ($status): $(head -c 300 "$3" | tr -d '\r' | head -n 1)" fi } # Loads allowlisted KEY=value lines from a server response into R_ variables. parse_response() { while IFS= read -r line || [ -n "$line" ]; do case "$line" in ''|'#'*) continue ;; *=*) ;; *) continue ;; esac key=${line%%=*} val=${line#*=} case "$key" in INSTALL_NAME|CODE_URL|CODE_COMMIT|SUPERATOM_API_KEY|SUPERATOM_PROJECT_ID|\ SA_API_URL|SA_ORG_ID|SA_ORG_SLUG|SA_WEBSOCKET_URL|OPENROUTER_API_KEY|SA_INTERNAL_SERVICE_TOKEN|RESEND_API_KEY) ;; *) continue ;; esac # Values land in env files and shell variables: refuse anything that could be interpreted. case "$val" in *[\"\'\`\$\\\ ]*) die "Unexpected characters in server value for $key" ;; esac eval "R_$key=\$val" done < "$1" } valid_name() { case "$1" in ''|-*|*[!a-z0-9_-]*) return 1 ;; esac } ensure_root_dir() { [ -d "$INSTALL_ROOT" ] && [ -w "$INSTALL_ROOT" ] && return 0 if mkdir -p "$INSTALL_ROOT" 2>/dev/null && [ -w "$INSTALL_ROOT" ]; then return 0; fi step "Creating $INSTALL_ROOT (needs sudo)..." sudo mkdir -p "$INSTALL_ROOT" sudo chown "$(id -u):$(id -g)" "$INSTALL_ROOT" } # Downloads the code tarball into $TMP_DIR/code (top directory stripped). download_code() { step "Downloading code (commit ${2:-unknown})..." curl -fsSL -o "$TMP_DIR/code.tar.gz" "$1" || die "Code download failed" mkdir -p "$TMP_DIR/code" tar -xzf "$TMP_DIR/code.tar.gz" -C "$TMP_DIR/code" --strip-components=1 || die "The code archive is corrupt" [ -f "$TMP_DIR/code/setup.sh" ] && [ -f "$TMP_DIR/code/docker-compose.yml" ] \ || die "The downloaded code has no Docker setup (setup.sh / docker-compose.yml)." (cd "$TMP_DIR/code" && find . -type f | sed 's|^\./||' | LC_ALL=C sort) > "$TMP_DIR/files.new" } # Copies the downloaded code into $1; removes files a previous version had but this one dropped. # Config (.env files) and Docker volumes are never in the archive, so they are untouched. place_code() { if [ -f "$1/$MANIFEST" ]; then LC_ALL=C sort "$1/$MANIFEST" > "$TMP_DIR/files.old" LC_ALL=C comm -23 "$TMP_DIR/files.old" "$TMP_DIR/files.new" | while IFS= read -r gone; do case "$gone" in ''|/*|*..*) continue ;; esac rm -f "$1/$gone" done fi cp -a "$TMP_DIR/code/." "$1/" cp "$TMP_DIR/files.new" "$1/$MANIFEST" } write_marker() { { printf 'PROJECT_ID=%s\n' "$2" printf 'CODE_COMMIT=%s\n' "$3" printf 'UPDATED_AT=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" } > "$1/$MARKER" } marker_value() { grep -E "^$2=" "$1" 2>/dev/null | tail -n 1 | cut -d '=' -f 2- || true } run_setup() { cd "$1" step "Running ./setup.sh --docker in $1" echo "" if [ -f "$PREFILL" ]; then exec bash ./setup.sh --docker --config "$PREFILL" < /dev/tty fi exec bash ./setup.sh --docker < /dev/tty } new_install() { ask_secret "Install token: " token=$(printf '%s' "$REPLY" | tr -d '[:space:]') case "$token" in ''|*[!A-Za-z0-9_-]*) die "That doesn't look like an install token." ;; esac step "Redeeming install token..." api_post "/api/redeem" "{\"token\":\"$token\"}" "$TMP_DIR/redeem.txt" R_INSTALL_NAME=""; R_CODE_URL=""; R_CODE_COMMIT=""; R_SUPERATOM_PROJECT_ID="" parse_response "$TMP_DIR/redeem.txt" valid_name "$R_INSTALL_NAME" || die "Server returned an invalid install name." [ -n "$R_CODE_URL" ] && [ -n "$R_SUPERATOM_PROJECT_ID" ] || die "Incomplete response from the server." ok "Project $R_INSTALL_NAME · code ${R_CODE_COMMIT:-?}" ask "Install folder [$INSTALL_ROOT/$R_INSTALL_NAME]: " dir=${REPLY:-$INSTALL_ROOT/$R_INSTALL_NAME} case "$dir" in /*) ;; *) dir="$(pwd)/$dir" ;; esac if [ -e "$dir" ] && [ -n "$(ls -A "$dir" 2>/dev/null)" ]; then die "$dir already exists and is not empty. Choose another folder." fi case "$dir" in "$INSTALL_ROOT"/*) ensure_root_dir ;; esac mkdir -p "$dir" || die "Cannot create $dir" download_code "$R_CODE_URL" "$R_CODE_COMMIT" place_code "$dir" # Only the keys setup.sh understands; it removes this file after writing backend/.env. umask 077 { printf '# From install token redemption; deleted by setup.sh once backend/.env is written.\n' printf 'PROJECT_NAME=%s\n' "$R_INSTALL_NAME" for key in SUPERATOM_API_KEY SUPERATOM_PROJECT_ID SA_API_URL SA_ORG_ID SA_ORG_SLUG SA_WEBSOCKET_URL \ OPENROUTER_API_KEY SA_INTERNAL_SERVICE_TOKEN RESEND_API_KEY; do eval "val=\${R_$key:-}" [ -n "$val" ] && printf '%s=%s\n' "$key" "$val" done } > "$dir/$PREFILL" umask 022 write_marker "$dir" "$R_SUPERATOM_PROJECT_ID" "$R_CODE_COMMIT" ok "Code installed in $dir" run_setup "$dir" } update_install() { dir=$1 env_file="$dir/backend/.env" api_key=$(marker_value "$env_file" SUPERATOM_API_KEY) project_id=$(marker_value "$env_file" SUPERATOM_PROJECT_ID) [ -n "$api_key" ] && [ -n "$project_id" ] || die "No API key / project id in $env_file; cannot update." case "$project_id" in *[!A-Za-z0-9-]*) die "Unexpected project id in $env_file" ;; esac step "Checking for updates..." api_post "/api/update" "{\"projectId\":\"$project_id\"}" "$TMP_DIR/update.txt" "$api_key" R_CODE_URL=""; R_CODE_COMMIT="" parse_response "$TMP_DIR/update.txt" [ -n "$R_CODE_URL" ] || die "Incomplete response from the server." current=$(marker_value "$dir/$MARKER" CODE_COMMIT) if [ -n "$R_CODE_COMMIT" ] && [ "$R_CODE_COMMIT" = "$current" ]; then ok "Already on the latest code ($current)." ask "Rebuild and restart anyway? [y/N]: " case "$REPLY" in [Yy]*) run_setup "$dir" ;; *) exit 0 ;; esac fi download_code "$R_CODE_URL" "$R_CODE_COMMIT" place_code "$dir" write_marker "$dir" "$project_id" "$R_CODE_COMMIT" ok "Updated ${current:-?} → ${R_CODE_COMMIT:-?}" run_setup "$dir" } main() { printf '\n%sSuperatom installer%s\n\n' "$BLUE" "$NC" check_system TMP_DIR=$(mktemp -d) # Existing installs under the root: update, resume, or install another project. found="" n=0 if [ -d "$INSTALL_ROOT" ]; then for d in "$INSTALL_ROOT"/*/; do [ -f "$d$MARKER" ] || continue n=$((n + 1)) found="$found${d%/} " done fi if [ "$n" -gt 0 ]; then echo "Existing installs:" i=0 printf '%s' "$found" | while IFS= read -r d; do i=$((i + 1)) state="commit $(marker_value "$d/$MARKER" CODE_COMMIT)" [ -f "$d/$PREFILL" ] && [ ! -f "$d/backend/.env" ] && state="setup not finished" printf ' %s) %s (%s)\n' "$i" "$d" "$state" done echo " n) New install" ask "Choose [n]: " choice=${REPLY:-n} case "$choice" in n|N) new_install ;; *[!0-9]*) die "Invalid choice." ;; *) d=$(printf '%s' "$found" | sed -n "${choice}p") [ -n "$d" ] || die "Invalid choice." if [ -f "$d/$PREFILL" ] && [ ! -f "$d/backend/.env" ]; then run_setup "$d" # redeemed earlier but setup didn't finish: resume it fi update_install "$d" ;; esac else new_install fi } main "$@"