#!/bin/bash set -euo pipefail # Cerver Local Compute Installer # Usage: curl -fsSL https://cerver.ai/install.sh | bash BOLD='\033[1m' ACCENT='\033[38;2;99;102;241m' SUCCESS='\033[38;2;34;197;94m' ERROR='\033[38;2;239;68;68m' MUTED='\033[38;2;107;114;128m' NC='\033[0m' REPO_URL="https://github.com/eyal-gor/p_69_cerver_relay.git" DEFAULT_CERVER_URL="${CERVER_GATEWAY_URL:-https://gateway.cerver.ai}" # Defensive terminal reset. A previous install that exited mid-curses # (or was Ctrl+C'd while the relay TUI was running) can leave stdin in # raw mode, the cursor parked mid-screen, and ANSI state pointing to # odd colors. Without this, the nickname prompt below renders at a # random column and `read` sees garbled keypresses. `stty sane` fixes # the line discipline; `\033c` is the full-terminal reset that also # returns the cursor to row 0 col 0. stty sane /dev/null || true printf "\033c" /dev/null || true echo "" echo -e "${ACCENT}${BOLD}Cerver Local Compute${NC}" echo -e "${MUTED}Registers this machine as private compute on Cerver${NC}" echo "" # Ask for a nickname DEFAULT_NAME="$(hostname -s 2>/dev/null || hostname)" printf "${BOLD}Machine nickname${NC} [${MUTED}${DEFAULT_NAME}${NC}]: " read -r MACHINE_NAME /dev/null; then echo -e "${ERROR}Python 3 required. Install from python.org${NC}" exit 1 fi if ! command -v uvx &>/dev/null; then echo -e "${MUTED}Installing uv...${NC}" curl -LsSf https://astral.sh/uv/install.sh | sh export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH" fi if ! command -v uvx &>/dev/null; then echo -e "${ERROR}uv installation failed${NC}" exit 1 fi echo -e " Cerver ${SUCCESS}${DEFAULT_CERVER_URL}${NC}" echo "" # ─── cerver CLI binary ───────────────────────────────────────────── # Install the `cerver` Go binary up front so it's available even if a # later step (login, secrets, relay) bails out. The CLI's go.mod uses # 3-component version directives (e.g. `go 1.26.3`), which only Go # ≥1.21 can parse — older Go bails immediately with "invalid go # version". When Go is missing or too old we auto-install via brew # (same pattern used for uv above) and capture the real build error # instead of swallowing stderr, so users can act on failures. CERVER_DIR="$HOME/.cerver" mkdir -p "$CERVER_DIR" chmod 700 "$CERVER_DIR" go_ok=0 if command -v go &>/dev/null; then # Parse `go version go1.26.3 darwin/amd64` -> minor=26. `cut` after # stripping the leading `go` gives the X in 1.X.Y; we need ≥21 for # the go.mod parser to accept the CLI's directive at all, and ≥26 # for the toolchain to actually build it. Latest brew Go covers both. go_minor=$(go version 2>/dev/null | awk '{print $3}' | sed 's/^go//' | cut -d. -f2) if [ -n "$go_minor" ] && [ "$go_minor" -ge 21 ] 2>/dev/null; then go_ok=1 fi fi if [ "$go_ok" -eq 0 ] && command -v brew &>/dev/null; then echo -e "${MUTED}Installing Go (needed to build cerver CLI)…${NC}" if brew install go >/tmp/cerver-go-install.log 2>&1; then # brew puts go on /opt/homebrew/bin (Apple silicon) or /usr/local/bin # (Intel). `brew --prefix`/bin handles both. Prepend so brew's go # shadows any legacy /usr/local/go install on PATH. export PATH="$(brew --prefix)/bin:$PATH" go_ok=1 else echo -e "${MUTED} brew install go failed — see /tmp/cerver-go-install.log${NC}" fi fi if [ "$go_ok" -eq 1 ]; then cli_log=$(mktemp) if GOBIN="$CERVER_DIR/bin" go install github.com/eyal-gor/p_71_cerver_cli/cmd/cerver@latest 2>"$cli_log"; then # PATH hint — only add to shell rc if not already present, so we # don't pile up duplicate exports every install. SHELL_RC="$HOME/.zshrc" [ -f "$HOME/.bash_profile" ] && [ ! -f "$HOME/.zshrc" ] && SHELL_RC="$HOME/.bash_profile" if [ -f "$SHELL_RC" ] && ! grep -q '.cerver/bin' "$SHELL_RC"; then echo 'export PATH="$HOME/.cerver/bin:$PATH"' >> "$SHELL_RC" fi export PATH="$CERVER_DIR/bin:$PATH" echo -e " CLI ${SUCCESS}cerver installed → ~/.cerver/bin/cerver${NC}" else echo -e " CLI ${ERROR}go install failed${NC}" # Indent the first few error lines so they sit under the status # column rather than wrapping into the next section header. sed 's/^/ /' "$cli_log" | head -5 fi rm -f "$cli_log" else echo -e " CLI ${MUTED}skipped (need Go ≥1.21 — install from https://go.dev/dl/ or run brew install go)${NC}" fi echo "" # ─── Infisical CLI binary ────────────────────────────────────────── # Infisical is cerver's default secrets backend, so we ship the CLI # alongside `cerver` rather than asking the user to find and install # it themselves. Direct binary download (no brew/Go dependency) keeps # this working on machines with stale Xcode CLT — same dir as the # cerver binary so the existing PATH wiring covers both. Pinned to a # known-good version; bump as needed when Infisical ships breaking # changes. Best-effort: failure (e.g. offline install) is non-fatal. INFISICAL_VER="0.43.84" case "$(uname -s)_$(uname -m)" in Darwin_arm64) INFISICAL_ASSET="cli_${INFISICAL_VER}_darwin_arm64.tar.gz" ;; Darwin_x86_64) INFISICAL_ASSET="cli_${INFISICAL_VER}_darwin_amd64.tar.gz" ;; Linux_x86_64) INFISICAL_ASSET="cli_${INFISICAL_VER}_linux_amd64.tar.gz" ;; Linux_aarch64) INFISICAL_ASSET="cli_${INFISICAL_VER}_linux_arm64.tar.gz" ;; *) INFISICAL_ASSET="" ;; esac if [ -x "$CERVER_DIR/bin/infisical" ]; then echo -e " Infisical ${SUCCESS}CLI already installed${NC}" elif [ -n "$INFISICAL_ASSET" ]; then INF_URL="https://github.com/Infisical/cli/releases/download/v${INFISICAL_VER}/${INFISICAL_ASSET}" INF_TMP=$(mktemp -d) if curl -fsSL "$INF_URL" -o "$INF_TMP/inf.tar.gz" 2>/dev/null \ && tar -xzf "$INF_TMP/inf.tar.gz" -C "$INF_TMP" 2>/dev/null \ && mv "$INF_TMP/infisical" "$CERVER_DIR/bin/infisical" 2>/dev/null; then chmod +x "$CERVER_DIR/bin/infisical" echo -e " Infisical ${SUCCESS}CLI installed → ~/.cerver/bin/infisical${NC}" else echo -e " Infisical ${MUTED}CLI download skipped (network or release unavailable)${NC}" fi rm -rf "$INF_TMP" else echo -e " Infisical ${MUTED}CLI skipped (unsupported platform $(uname -s)/$(uname -m))${NC}" fi echo "" # ─── Cerver account ──────────────────────────────────────────────── # Email-only signup/login: /v2/auth/login creates the account on first # call and returns the API key. Skips the relay's browser handoff so # the whole install stays in the terminal. CERVER_DIR="$HOME/.cerver" CERVER_ENV_FILE="$CERVER_DIR/cerver.env" mkdir -p "$CERVER_DIR" chmod 700 "$CERVER_DIR" if [ -f "$CERVER_ENV_FILE" ]; then set -a; source "$CERVER_ENV_FILE"; set +a fi if [ -z "${CERVER_API_KEY:-}" ]; then printf "${BOLD}Email${NC} ${MUTED}(used to create or log into your cerver account)${NC}: " read -r CERVER_EMAIL /dev/null || echo "") CERVER_API_KEY=$(python3 -c "import json,sys; d=json.loads(sys.stdin.read() or '{}'); print(d.get('api_key',''))" <<<"$AUTH_RESP") IS_NEW=$(python3 -c "import json,sys; d=json.loads(sys.stdin.read() or '{}'); print('true' if d.get('is_new') else 'false')" <<<"$AUTH_RESP") AUTH_NEEDS_DEVICE=$(python3 -c "import json,sys; d=json.loads(sys.stdin.read() or '{}'); e=(d.get('error') or '') + (d.get('detail') or ''); print('true' if 'already has an account' in e.lower() or 'magic' in e.lower() else 'false')" <<<"$AUTH_RESP") if [ -z "$CERVER_API_KEY" ] && [ "$AUTH_NEEDS_DEVICE" = "true" ]; then # Existing account — pivot to the device-code flow. Same auth path # `cerver login` uses: the user opens a URL, signs in via magic # link if needed, approves the user_code, and we poll for the # access_token. Stays in-terminal; no follow-up CLI invocation # required after install completes. echo -e "${MUTED} This email already has an account — opening browser sign-in instead.${NC}" MACHINE_NAME=$(hostname 2>/dev/null || echo "cerver-installer") DEVICE_RESP=$(curl -s -X POST "${DEFAULT_CERVER_URL}/v2/auth/device" \ -H "Content-Type: application/json" \ -d "{\"machine_name\":\"${MACHINE_NAME}\"}" 2>/dev/null || echo "") DEVICE_CODE=$(python3 -c "import json,sys; d=json.loads(sys.stdin.read() or '{}'); print(d.get('device_code',''))" <<<"$DEVICE_RESP") USER_CODE=$(python3 -c "import json,sys; d=json.loads(sys.stdin.read() or '{}'); print(d.get('user_code',''))" <<<"$DEVICE_RESP") VERIFY_URI=$(python3 -c "import json,sys; d=json.loads(sys.stdin.read() or '{}'); print(d.get('verification_uri',''))" <<<"$DEVICE_RESP") EXPIRES_IN=$(python3 -c "import json,sys; d=json.loads(sys.stdin.read() or '{}'); print(d.get('expires_in',900))" <<<"$DEVICE_RESP") INTERVAL=$(python3 -c "import json,sys; d=json.loads(sys.stdin.read() or '{}'); print(min(max(int(d.get('interval',5)),3),10))" <<<"$DEVICE_RESP") if [ -z "$DEVICE_CODE" ] || [ -z "$VERIFY_URI" ]; then echo -e "${ERROR}✗ Could not start device login.${NC}" echo -e "${MUTED}Response: ${DEVICE_RESP}${NC}" exit 1 fi echo "" echo -e " Open this URL in your browser to authorize this machine:" echo -e " ${SUCCESS}${VERIFY_URI}${NC}" echo -e " Or enter the code manually at ${SUCCESS}https://cerver.ai/approve${NC}:" echo -e " code: ${BOLD}${USER_CODE}${NC}" # Best-effort browser open (macOS / linux). Failure is fine — the # URL is already printed. if command -v open >/dev/null 2>&1; then open "$VERIFY_URI" >/dev/null 2>&1 || true; fi if command -v xdg-open >/dev/null 2>&1; then xdg-open "$VERIFY_URI" >/dev/null 2>&1 || true; fi echo "" echo -e "${MUTED} Waiting for approval (expires in ~$((EXPIRES_IN/60))m)…${NC}" DEADLINE=$(( $(date +%s) + EXPIRES_IN )) while [ "$(date +%s)" -lt "$DEADLINE" ]; do sleep "$INTERVAL" POLL_RESP=$(curl -s "${DEFAULT_CERVER_URL}/v2/auth/device?device_code=${DEVICE_CODE}" 2>/dev/null || echo "") CERVER_API_KEY=$(python3 -c "import json,sys; d=json.loads(sys.stdin.read() or '{}'); print(d.get('access_token','') if d.get('status')=='approved' else '')" <<<"$POLL_RESP") POLL_ERR=$(python3 -c "import json,sys; d=json.loads(sys.stdin.read() or '{}'); print(d.get('error',''))" <<<"$POLL_RESP") if [ -n "$CERVER_API_KEY" ]; then break; fi case "$POLL_ERR" in expired_token) echo -e "${ERROR}✗ Sign-in code expired before approval. Re-run the installer.${NC}" exit 1 ;; access_denied) echo -e "${ERROR}✗ Approval denied in the browser.${NC}" exit 1 ;; esac done fi if [ -z "$CERVER_API_KEY" ]; then echo -e "${ERROR}✗ Login failed.${NC}" echo -e "${MUTED}Response: ${AUTH_RESP}${NC}" if [ -x "$CERVER_DIR/bin/cerver" ]; then echo -e "${MUTED}The cerver CLI is installed — run ${BOLD}cerver login${NC}${MUTED} to retry with a browser sign-in, then re-run this script.${NC}" fi exit 1 fi cat > "$CERVER_ENV_FILE" < "$QUICK_ENV_FILE" chmod 600 "$QUICK_ENV_FILE" for KEY_NAME in ANTHROPIC_API_KEY OPENAI_API_KEY; do printf " %-22s (hidden, blank to skip): " "$KEY_NAME" read -rs KEY_VAL > "$QUICK_ENV_FILE" export "${KEY_NAME}=${KEY_VAL}" fi done echo "" echo -e "${SUCCESS}✓ Quick-start secrets saved${NC} ${MUTED}(${QUICK_ENV_FILE}, chmod 600)${NC}" echo "" echo -e "${BOLD}Ready for production?${NC} ${MUTED}Infisical is much better long-term:${NC}" echo -e "${MUTED} • rotate keys without touching every machine${NC}" echo -e "${MUTED} • audit log of who fetched what, when${NC}" echo -e "${MUTED} • share secrets across teammates and computes${NC}" echo -e "${MUTED}To migrate: ${NC}rm ${QUICK_ENV_FILE}${MUTED} and re-run this installer.${NC}" echo "" elif [ "$MODE_CHOICE" = "2" ]; then # CLI-login Infisical flow. We shipped the binary in the earlier # install step, so this whole thing is: log the user in via their # browser, let them pick a project interactively (`infisical init`), # ask for the environment slug, and persist only a marker file. No # Client IDs, no Client Secrets, nothing for the user to copy-paste. # The relay launch at the bottom wraps with `infisical run` which # injects vault secrets as env vars into the relay process. INFISICAL_BIN="$CERVER_DIR/bin/infisical" if [ ! -x "$INFISICAL_BIN" ]; then echo -e "${ERROR}✗ Infisical CLI not found at $INFISICAL_BIN${NC}" echo -e "${MUTED} The earlier install step should have downloaded it. Re-run this installer.${NC}" exit 1 fi export PATH="$CERVER_DIR/bin:$PATH" echo "" echo -e "${BOLD}Sign in to Infisical${NC}" echo -e "${MUTED}A browser window opens. Approve, return here. The CLI keeps your${NC}" echo -e "${MUTED}session in ~/.infisical/; cerver never sees your password.${NC}" echo "" if ! "$INFISICAL_BIN" login "$INF_ENV_FILE" </dev/null; then echo -e " Skill ${SUCCESS}/cerver installed for Claude Code${NC}" else echo -e " Skill ${MUTED}skipped (couldn't fetch SKILL.md)${NC}" fi fi # ─── Run the relay ───────────────────────────────────────────────── # Start the relay backgrounded so install.sh can poll cerver for the # registration to land. Relay output goes to a log so the terminal # stays clean for our success message — otherwise the relay's TUI # redraws the screen on a 1s tick and clobbers everything install.sh # prints. After the success block, we hand the terminal over to a # fresh foreground relay so the user gets the dashboard cleanly with # no interleaved output. RELAY_LOG="$HOME/.cerver/relay.log" echo -e "${BOLD}Starting relay…${NC}" # --no-tui is critical: without it the relay opens /dev/tty directly # and starts drawing its dashboard, which our >"$RELAY_LOG" redirect # can't intercept (stdout redirect doesn't catch direct tty writes). # The exec'd foreground relay at the bottom of this script omits # --no-tui so the dashboard does show up at the end. # # When Infisical was set up via the CLI-login path, wrap the launch # with `infisical run` so the relay process inherits vault secrets as # env vars. cd into ~/.cerver first so .infisical.json (the project # context written by `infisical init`) is found. if [ "${INFISICAL_LOGIN_VIA:-}" = "cli" ]; then cd "$CERVER_DIR" "$CERVER_DIR/bin/infisical" run --env="${INFISICAL_ENV:-dev}" -- \ uvx --from "git+${REPO_URL}" cerver-relay \ --cerver-only \ --no-tui \ --cerver-url "${DEFAULT_CERVER_URL}" \ --name "${MACHINE_NAME}" \ "$@" "$RELAY_LOG" 2>&1 & else uvx --from "git+${REPO_URL}" cerver-relay \ --cerver-only \ --no-tui \ --cerver-url "${DEFAULT_CERVER_URL}" \ --name "${MACHINE_NAME}" \ "$@" "$RELAY_LOG" 2>&1 & fi RELAY_PID=$! # Stop polling if the user Ctrl+Cs out — the relay's own SIGINT handling # will then surface the exit. cleanup() { kill -TERM "$RELAY_PID" 2>/dev/null || true; } trap cleanup INT TERM # Poll /v2/computes for our newly-registered machine. Bound the wait # so a network glitch doesn't hang the installer forever. COMPUTE_ID="" for _ in $(seq 1 30); do sleep 1 COMPUTES=$(curl -s "${DEFAULT_CERVER_URL}/v2/computes" \ -H "Authorization: Bearer ${CERVER_API_KEY}" 2>/dev/null || echo "") COMPUTE_ID=$(MACHINE_NAME="$MACHINE_NAME" COMPUTES_JSON="$COMPUTES" python3 -c ' import json, os name = os.environ.get("MACHINE_NAME", "") try: data = json.loads(os.environ.get("COMPUTES_JSON", "") or "{}") except Exception: raise SystemExit(0) items = data if isinstance(data, list) else data.get("computes", []) or data.get("items", []) for c in items: if (c.get("label") or "") == name and c.get("scope") == "private": print(c.get("compute_id", "")) break ' 2>/dev/null || true) [ -n "$COMPUTE_ID" ] && break done echo "" if [ -n "$COMPUTE_ID" ]; then echo -e "${SUCCESS}✓ Compute registered as ${BOLD}${COMPUTE_ID}${NC}" echo "" echo -e "${BOLD}Try a session:${NC}" echo "" echo -e " ${MUTED}# already exported in this shell:${NC}" echo -e " ${MUTED}# CERVER_API_KEY, CERVER_GATEWAY_URL${NC}" echo "" echo -e " curl -X POST \$CERVER_GATEWAY_URL/v2/sessions \\" echo -e " -H \"Authorization: Bearer \$CERVER_API_KEY\" \\" echo -e " -H \"Content-Type: application/json\" \\" echo -e " -d '{\"harness\":\"openai\",\"compute\":{\"compute_id\":\"${COMPUTE_ID}\"}}'" echo "" echo -e "${MUTED}Want hosted online mode with no machine? Use compute: { provider: \"online\" }.${NC}" else echo -e "${MUTED}(Registration didn't show up in 30s — check the relay log above.${NC}" echo -e "${MUTED} You can still list computes manually:${NC}" echo -e " curl ${DEFAULT_CERVER_URL}/v2/computes -H \"Authorization: Bearer \$CERVER_API_KEY\"" fi echo "" echo -e "${MUTED}Relay log: ${RELAY_LOG}${NC}" echo -e "${MUTED}Launching live dashboard in 2s — Ctrl+C to stop.${NC}" sleep 2 # Hand the terminal off cleanly. Kill the bg relay (registration is # already persisted on cerver, so the next launch re-attaches under # the same compute_id) and replace this shell with the foreground # relay so its TUI owns stdout from the start. `clear` before exec so # the dashboard draws on a fresh screen instead of underneath our # success message — the success scrolls back into history if the user # scrolls up. kill -TERM "$RELAY_PID" 2>/dev/null || true wait "$RELAY_PID" 2>/dev/null || true clear # Same wrapper logic as the backgrounded relay above. The foreground # exec is what the user actually sees, so the project context cd must # happen here too in case the script was sourced from elsewhere. if [ "${INFISICAL_LOGIN_VIA:-}" = "cli" ]; then cd "$CERVER_DIR" exec "$CERVER_DIR/bin/infisical" run --env="${INFISICAL_ENV:-dev}" -- \ uvx --from "git+${REPO_URL}" cerver-relay \ --cerver-only \ --cerver-url "${DEFAULT_CERVER_URL}" \ --name "${MACHINE_NAME}" \ "$@"