#!/bin/sh # Squadem Enterprise Self-Hosted Installer # # NOTE: Self-hosted / air-gapped deployment requires an Enterprise license. # Free and Pro tiers are cloud-hosted only. Contact sales@squadem.com for # Enterprise pricing, or visit https://squadem.com/pricing # # Two components ship from the same release; pick what this host should run: # # • core — Squadem control plane (dashboard, AI proxy, plugin # manager, in-process Agent IDE / GPU manager). This is # what you install on the box that operators log into. # # • gpu-agent — Headless companion that turns a remote NVIDIA / Apple # Silicon machine into a GPU node for an existing core. # No dashboard, no license prompt — it joins your core # with a registration token. # # Selection order: --component= flag → SQUADEM_COMPONENT env → # interactive prompt → defaults to "core". # # Examples: # # Core, native binary (interactive license prompt) # curl -fsSL https://get.squadem.com/install.sh | sh # # # Core, with license key (scripted / CI) # curl -fsSL https://get.squadem.com/install.sh | \ # SQUADEM_LICENSE_KEY=SQD-XXXX-XXXX-XXXX sh # # # Core, dockerized # curl -fsSL https://get.squadem.com/install.sh | \ # SQUADEM_LICENSE_KEY=SQD-XXXX-XXXX-XXXX sh -s -- --mode=docker # # # GPU agent on a remote box (interactive prompts) # curl -fsSL https://get.squadem.com/install.sh | \ # SQUADEM_LICENSE_KEY=SQD-XXXX-XXXX-XXXX sh -s -- --gpu-agent # # # GPU agent, fully scripted (no prompts) # curl -fsSL https://get.squadem.com/install.sh | \ # SQUADEM_LICENSE_KEY=SQD-XXXX-XXXX-XXXX \ # SQUADEM_CENTRAL_URL=http://core.lan:8081 \ # SQUADEM_AGENT_TOKEN=sqd-... sh -s -- --gpu-agent # # Plugins (RAG, meetings, training, automation, SSO, adapters) ship as # containers but are managed by core. After install, open the dashboard # → Plugins, or run: squadem compose extract && docker compose --profile rag up -d # # Override knobs (env vars): # SQUADEM_COMPONENT core | gpu-agent (default: prompt, then core) # SQUADEM_VERSION release tag to pin (default: latest) # SQUADEM_DIR install dir (default: ~/.squadem) # SQUADEM_LICENSE_KEY core only — Enterprise license key (self-hosted requires Enterprise) # SQUADEM_CENTRAL_URL gpu-agent only — URL of the core (e.g. http://core:8081) # SQUADEM_AGENT_TOKEN gpu-agent only — registration token from core dashboard # SQUADEM_AGENT_PORT gpu-agent only — listening port (default: 9400) # (Binaries are fetched via signed S3 URLs after license validation) # SQUADEM_DOCKER_IMAGE core+docker only — image (default squadem/squadem-core:) # SQUADEM_MODE core only — binary | docker (default: binary) set -e SQUADEM_VERSION="${SQUADEM_VERSION:-latest}" SQUADEM_DIR="${SQUADEM_DIR:-$HOME/.squadem}" SQUADEM_DOCKER_IMAGE="${SQUADEM_DOCKER_IMAGE:-squadem/squadem-core:${SQUADEM_VERSION}}" LICENSE_API="https://squadem.com/api/license" # License key is required for self-hosted/Enterprise downloads SQUADEM_LICENSE_KEY="${SQUADEM_LICENSE_KEY:-}" COMPONENT="${SQUADEM_COMPONENT:-}" MODE="${SQUADEM_MODE:-binary}" for arg in "$@"; do case "$arg" in --component=core|--core) COMPONENT="core" ;; --component=gpu-agent|--gpu-agent) COMPONENT="gpu-agent" ;; --mode=binary|--binary) MODE="binary" ;; --mode=docker|--docker) MODE="docker" ;; -h|--help) # Dump the leading comment block. Stop at the first non-comment # line so the help text auto-grows when we extend the header. awk 'NR==1{next} /^[^#]/{exit} {sub(/^# ?/,""); print}' "$0" exit 0 ;; esac done # ── Pretty output ── # We resolve the ESC byte once via printf so the color vars hold a # real 0x1B character, not the literal four-byte string "\033". This # makes `cat <&2; exit 1; } # ── License-gated download URLs ──────────────────────────────────── # Self-hosted installations require an Enterprise license. We call the # license server to validate the key and get signed download URLs. # This ensures binaries are not publicly accessible. DOWNLOAD_URLS_JSON="" fetch_download_urls() { local component="$1" local platform="$2" if [ -z "$SQUADEM_LICENSE_KEY" ]; then if [ -r /dev/tty ]; then echo "" echo " ${BOLD}Self-hosted installation requires an Enterprise license.${NC}" echo " Enter your license key (or get one at https://squadem.com/pricing)" echo "" printf " License key: " read -r SQUADEM_LICENSE_KEY < /dev/tty || SQUADEM_LICENSE_KEY="" echo "" fi if [ -z "$SQUADEM_LICENSE_KEY" ]; then error "License key required. Set SQUADEM_LICENSE_KEY or use cloud-hosted version." fi fi info "Validating license and fetching download URLs..." local payload payload=$(cat </dev/null 2>&1; then response=$(curl -fsSL -X POST \ -H "Content-Type: application/json" \ -d "$payload" \ "${LICENSE_API}/download-urls" 2>&1) || { # Try to extract error message from response if echo "$response" | grep -q '"error"'; then local err_msg err_msg=$(echo "$response" | sed 's/.*"error":"\([^"]*\)".*/\1/') error "License validation failed: $err_msg" else error "Failed to contact license server. Check your internet connection." fi } elif command -v wget >/dev/null 2>&1; then response=$(wget -qO- --post-data="$payload" \ --header="Content-Type: application/json" \ "${LICENSE_API}/download-urls" 2>&1) || { error "Failed to contact license server. Check your internet connection." } else error "curl or wget is required" fi # Check for error in response if echo "$response" | grep -q '"error"'; then local err_msg err_msg=$(echo "$response" | sed 's/.*"error":"\([^"]*\)".*/\1/') error "License validation failed: $err_msg" fi DOWNLOAD_URLS_JSON="$response" info "License validated - download URLs obtained" } # Extract URL from JSON response (simple grep-based parsing for POSIX sh) get_download_url() { local filename="$1" echo "$DOWNLOAD_URLS_JSON" | grep -o "\"$filename\":\"[^\"]*\"" | sed 's/.*:"\([^"]*\)"/\1/' } cat <&1) || \ error "License validation failed (offline or invalid key). Contact sales at https://squadem.com/pricing" echo "$AUTH_RESPONSE" | grep -q '"success":\s*true' || \ error "License validation failed: $(echo "$AUTH_RESPONSE" | grep -o '"error":"[^"]*"' | cut -d'"' -f4)" LICENSE_TIER=$(echo "$AUTH_RESPONSE" | grep -o '"tier":"[^"]*"' | cut -d'"' -f4 || echo "unknown") # Self-hosted deployment requires Enterprise license if [ "$LICENSE_TIER" != "enterprise" ]; then error "Self-hosted deployment requires an Enterprise license. Your license is '${LICENSE_TIER}'. Contact sales at https://squadem.com/pricing to upgrade." fi info "License valid (${LICENSE_TIER} plan)" # ── Layout + minimal .env ───────────────────────────────────────── DATA_DIR="${SQUADEM_DATA_DIR:-$SQUADEM_DIR/data}" mkdir -p "$SQUADEM_DIR" "$DATA_DIR" # Single-source-of-truth .env. The binary fills in everything else # (passwords, plugin URLs, host hints) at first boot or via the # dashboard's Settings page — install.sh stays thin on purpose. if [ ! -f "$ENV_FILE" ]; then cat > "$ENV_FILE" </dev/null 2>&1; then xattr -d com.apple.quarantine "$BIN_PATH" 2>/dev/null || true fi if [ "$OS" = "Linux" ]; then UNIT_PATH="$HOME/.config/systemd/user/squadem.service" mkdir -p "$(dirname "$UNIT_PATH")" # Build a PATH that includes the standard system bin dirs plus the # locations Docker / docker-ce installs the CLI on most distros. # systemd --user otherwise inherits a stripped PATH that omits # /usr/local/bin and /snap/bin, which breaks every downstream # `docker ...` exec the binary issues for plugins, adapters, agent # containers, and GPU model deployments. cat > "$UNIT_PATH" </dev/null || true systemctl --user enable --now squadem.service 2>/dev/null || \ warn "Run manually: systemctl --user enable --now squadem.service" loginctl enable-linger "$USER" >/dev/null 2>&1 || true SVC_HINT="systemctl --user {status|restart|stop} squadem • journalctl --user -u squadem -f" else # macOS PLIST_PATH="$HOME/Library/LaunchAgents/com.squadem.core.plist" mkdir -p "$(dirname "$PLIST_PATH")" # launchd hands child processes a stripped PATH (/usr/bin:/bin:/usr/sbin:/sbin) # that omits /usr/local/bin (x86 Homebrew + manual docker), /opt/homebrew/bin # (Apple-Silicon Homebrew), and Docker Desktop's own CLI dir. Without an # explicit PATH here every `docker ...` exec issued by the binary # (plugins, adapters, agent containers, GPU model deployments) # fails with "executable file not found in \$PATH". The binary # also runs EnsureDockerOnPATH() at startup as a belt-and-suspenders # fallback, but baking the right PATH into the plist is what makes # the first boot work cleanly. cat > "$PLIST_PATH" < Labelcom.squadem.core ProgramArguments ${BIN_PATH}--data-dir=${DATA_DIR} EnvironmentVariables SQUADEM_ENV_FILE${ENV_FILE} PATH/usr/local/bin:/opt/homebrew/bin:/Applications/Docker.app/Contents/Resources/bin:/usr/bin:/bin:/usr/sbin:/sbin WorkingDirectory${SQUADEM_DIR} RunAtLoad KeepAlive StandardOutPath${SQUADEM_DIR}/squadem.log StandardErrorPath${SQUADEM_DIR}/squadem.log PLIST launchctl unload "$PLIST_PATH" 2>/dev/null || true launchctl load "$PLIST_PATH" 2>/dev/null || \ warn "Run manually: launchctl load $PLIST_PATH" SVC_HINT="launchctl {load|unload} ${PLIST_PATH} • tail -f ${SQUADEM_DIR}/squadem.log" fi fi # ══════════════════════════════════════════════════════════════════ # OPTION 2 — Same binary, in a Docker container # ══════════════════════════════════════════════════════════════════ if [ "$MODE" = "docker" ]; then command -v docker >/dev/null 2>&1 || \ error "Docker is not installed. Get it at https://docker.com — or rerun without --mode=docker for the native binary." docker info >/dev/null 2>&1 || error "Docker daemon is not running. Start Docker and try again." info "Pulling ${SQUADEM_DOCKER_IMAGE}..." docker pull "$SQUADEM_DOCKER_IMAGE" || error "Pull failed for $SQUADEM_DOCKER_IMAGE" # Replace any previous container of the same name. We mount the # host docker socket so the in-process Agent IDE sandbox can spawn # ephemeral containers, and we expose only the user-facing ports # (dashboard, AI proxy, REST API, plugin manager). Transparent / # gateway listeners (DNS:53, transparent 8180/443) are configured # later from the dashboard if needed. docker rm -f squadem-core >/dev/null 2>&1 || true docker run -d \ --name squadem-core \ --restart unless-stopped \ --env-file "$ENV_FILE" \ -v "$DATA_DIR:/data" \ -v /var/run/docker.sock:/var/run/docker.sock \ --add-host=host.docker.internal:host-gateway \ -p 8080:8080 \ -p 8081:8081 \ -p 8085:8085 \ -p 8088:8088 \ -p 8093:8093 \ -p 8200:8200 \ "$SQUADEM_DOCKER_IMAGE" >/dev/null SVC_HINT="docker {logs|restart|stop} squadem-core" fi fi # end COMPONENT=core # ══════════════════════════════════════════════════════════════════ # GPU-AGENT PATH — companion binary that joins an existing core # ══════════════════════════════════════════════════════════════════ if [ "$COMPONENT" = "gpu-agent" ]; then AGENT_ENV_FILE="$SQUADEM_DIR/gpu-agent.env" mkdir -p "$SQUADEM_DIR" # Hydrate prior values so a re-run is a real upgrade (preserve URL + # token without re-prompting). Env vars passed on the current run # always win — that's how `SQUADEM_AGENT_TOKEN=… sh` rotates secrets. if [ -f "$AGENT_ENV_FILE" ]; then SQUADEM_CENTRAL_URL="${SQUADEM_CENTRAL_URL:-$(grep -E '^SQUADEM_CENTRAL_URL=' "$AGENT_ENV_FILE" | cut -d'=' -f2- | tr -d '"' || true)}" SQUADEM_AGENT_TOKEN="${SQUADEM_AGENT_TOKEN:-$(grep -E '^SQUADEM_AGENT_TOKEN=' "$AGENT_ENV_FILE" | cut -d'=' -f2- | tr -d '"' || true)}" SQUADEM_AGENT_PORT="${SQUADEM_AGENT_PORT:-$(grep -E '^SQUADEM_AGENT_PORT=' "$AGENT_ENV_FILE" | cut -d'=' -f2- | tr -d '"' || true)}" fi SQUADEM_AGENT_PORT="${SQUADEM_AGENT_PORT:-9400}" if [ -z "$SQUADEM_CENTRAL_URL" ]; then echo "" echo " ${BOLD}Squadem core URL${NC}" echo " Where this GPU agent should register (e.g. http://core.lan:8081)." echo "" printf " Central URL: " read -r SQUADEM_CENTRAL_URL < /dev/tty echo "" fi [ -z "$SQUADEM_CENTRAL_URL" ] && error "No central URL provided" if [ -z "$SQUADEM_AGENT_TOKEN" ]; then echo " ${BOLD}Registration token${NC}" echo " Generate one in the dashboard: ${BOLD}Settings → GPU → Add agent${NC}" echo "" printf " Token: " read -r SQUADEM_AGENT_TOKEN < /dev/tty echo "" fi [ -z "$SQUADEM_AGENT_TOKEN" ] && error "No registration token provided" cat > "$AGENT_ENV_FILE" </dev/null 2>&1; then info "Stopping existing GPU agent service for upgrade..." systemctl --user stop sq-gpu-agent.service 2>/dev/null || true STOPPED_SERVICE="systemd" elif [ "$OS" = "Darwin" ]; then PLIST_PATH="$HOME/Library/LaunchAgents/com.squadem.gpu-agent.plist" if launchctl list | grep -q com.squadem.gpu-agent 2>/dev/null; then info "Stopping existing GPU agent service for upgrade..." launchctl unload "$PLIST_PATH" 2>/dev/null || true STOPPED_SERVICE="launchd" fi fi # Kill any stray sq-gpu-agent processes not managed by the service if pgrep -x sq-gpu-agent >/dev/null 2>&1; then info "Killing existing sq-gpu-agent processes..." pkill -x sq-gpu-agent 2>/dev/null || true sleep 1 # Force kill if still running pkill -9 -x sq-gpu-agent 2>/dev/null || true fi # Download to a temp file first, then move (handles locked binary edge case) BIN_TMP="${BIN_PATH}.new" info "Downloading sq-gpu-agent ${SQUADEM_VERSION}..." if command -v curl >/dev/null 2>&1; then curl -fL --progress-bar "$BIN_URL" -o "$BIN_TMP" || error "Download failed" elif command -v wget >/dev/null 2>&1; then wget -q --show-progress "$BIN_URL" -O "$BIN_TMP" || error "Download failed" else error "curl or wget is required" fi mv -f "$BIN_TMP" "$BIN_PATH" # Checksum — SHA256SUMS is included in the signed download URLs SUMS_URL=$(get_download_url "SHA256SUMS") if command -v sha256sum >/dev/null 2>&1; then SHA_TOOL="sha256sum"; elif command -v shasum >/dev/null 2>&1; then SHA_TOOL="shasum -a 256"; else SHA_TOOL=""; fi if [ -n "$SHA_TOOL" ] && [ -n "$SUMS_URL" ]; then SUMS_TMP="$(mktemp)" if curl -fsSL "$SUMS_URL" -o "$SUMS_TMP" 2>/dev/null; then EXPECTED=$(grep " ${AGENT_FILE}\$" "$SUMS_TMP" | awk '{print $1}' | head -1) if [ -n "$EXPECTED" ]; then ACTUAL=$($SHA_TOOL "$BIN_PATH" | awk '{print $1}') if [ "$EXPECTED" != "$ACTUAL" ]; then rm -f "$SUMS_TMP" error "SHA256 mismatch — refusing to install. Expected $EXPECTED, got $ACTUAL" fi info "Checksum verified" else warn "SHA256SUMS missing entry for ${AGENT_FILE} — skipping verification" fi else warn "SHA256SUMS unavailable — skipping verification" fi rm -f "$SUMS_TMP" fi chmod +x "$BIN_PATH" if [ "$OS" = "Darwin" ] && command -v xattr >/dev/null 2>&1; then xattr -d com.apple.quarantine "$BIN_PATH" 2>/dev/null || true fi if [ "$OS" = "Linux" ]; then UNIT_PATH="$HOME/.config/systemd/user/sq-gpu-agent.service" mkdir -p "$(dirname "$UNIT_PATH")" # Pull cred values from EnvironmentFile rather than baking them # into the unit file itself so token rotation is one `sed` away # without rewriting the unit (and so `journalctl` doesn't leak the # token in the ExecStart line on `systemctl status`). cat > "$UNIT_PATH" </dev/null || true systemctl --user enable --now sq-gpu-agent.service 2>/dev/null || \ warn "Run manually: systemctl --user enable --now sq-gpu-agent.service" loginctl enable-linger "$USER" >/dev/null 2>&1 || true SVC_HINT="systemctl --user {status|restart|stop} sq-gpu-agent • journalctl --user -u sq-gpu-agent -f" else # macOS PLIST_PATH="$HOME/Library/LaunchAgents/com.squadem.gpu-agent.plist" mkdir -p "$(dirname "$PLIST_PATH")" cat > "$PLIST_PATH" < Labelcom.squadem.gpu-agent ProgramArguments ${BIN_PATH} --central=${SQUADEM_CENTRAL_URL} --token=${SQUADEM_AGENT_TOKEN} --port=${SQUADEM_AGENT_PORT} WorkingDirectory${SQUADEM_DIR} RunAtLoad KeepAlive StandardOutPath${SQUADEM_DIR}/sq-gpu-agent.log StandardErrorPath${SQUADEM_DIR}/sq-gpu-agent.log PLIST launchctl unload "$PLIST_PATH" 2>/dev/null || true launchctl load "$PLIST_PATH" 2>/dev/null || \ warn "Run manually: launchctl load $PLIST_PATH" SVC_HINT="launchctl {load|unload} ${PLIST_PATH} • tail -f ${SQUADEM_DIR}/sq-gpu-agent.log" fi fi # end COMPONENT=gpu-agent # ── Wait for health ─────────────────────────────────────────────── if [ "$COMPONENT" = "core" ]; then HEALTH_URL="http://localhost:8200/health"; READY_LABEL="Squadem" else HEALTH_URL="http://localhost:${SQUADEM_AGENT_PORT}/health"; READY_LABEL="GPU agent" fi info "Waiting for ${READY_LABEL} to be ready..." TIMEOUT=60; ELAPSED=0; READY=false while [ $ELAPSED -lt $TIMEOUT ]; do if curl -sf "$HEALTH_URL" >/dev/null 2>&1; then READY=true; break; fi sleep 2; ELAPSED=$((ELAPSED + 2)) done if [ "$READY" = "true" ]; then info "${READY_LABEL} is healthy" else warn "Not responding at ${HEALTH_URL} yet. Check service logs (see commands below)." fi # ── Best-effort install ping ────────────────────────────────────── curl -s -X POST "${LICENSE_API}/track" \ -H "Content-Type: application/json" \ -d "{\"event\":\"install\",\"platform\":\"${OS}-${ARCH}\",\"component\":\"${COMPONENT}\",\"mode\":\"${MODE}\"}" \ >/dev/null 2>&1 || true # ── Done ────────────────────────────────────────────────────────── echo "" echo "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" echo "" if [ "$COMPONENT" = "core" ]; then echo " ${GREEN}${BOLD}Squadem is running${NC} (${LICENSE_TIER} plan, ${MODE} mode)" echo "" echo " ${BOLD}Dashboard${NC} http://localhost:8200" echo " ${BOLD}AI Proxy${NC} http://localhost:8080" echo " ${BOLD}Config${NC} ${ENV_FILE}" echo " ${BOLD}Data${NC} ${DATA_DIR}" echo " ${BOLD}Service${NC} ${SVC_HINT}" echo "" echo " Open the dashboard to finish setup (create your admin account)." else echo " ${GREEN}${BOLD}Squadem GPU agent is running${NC}" echo "" echo " ${BOLD}Local API${NC} http://localhost:${SQUADEM_AGENT_PORT}" echo " ${BOLD}Joining${NC} ${SQUADEM_CENTRAL_URL}" echo " ${BOLD}Config${NC} ${AGENT_ENV_FILE}" echo " ${BOLD}Service${NC} ${SVC_HINT}" echo "" echo " In the core dashboard the new node should appear under" echo " ${BOLD}Settings → GPU${NC} within a few seconds." fi echo "" echo "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" echo ""