#!/usr/bin/env bash # ============================================================================= # agentboard relay client — one-shot installer 🚀 # # - Detects OS (macOS / Linux) and CPU arch (arm64 / x64) # - Installs tmux (brew on macOS; native package manager on Linux) # - Downloads the matching prebuilt daemon binary # - Guides you through configuration with a bilingual (中文 / English) TUI # - Brings this machine online on the relay server # # Usage: # curl -fsSL https://download.zvj.cc/install.sh | bash # # or # bash install.sh # ============================================================================= set -euo pipefail # --- Constants --------------------------------------------------------------- DOWNLOAD_BASE="https://download.xclaude.cloud" # where the binaries live DEFAULT_RELAY_URL="https://xclaude.cloud" # default relay server DEFAULT_PORT=4050 DEFAULT_MODE="e2e" # Path to this installer on disk (empty when piped via `curl ... | bash`). SELF_SRC="${BASH_SOURCE[0]:-$0}" case "$SELF_SRC" in bash|sh|-bash|-sh|*/bash|*/sh) SELF_SRC="";; esac # --- Interactive I/O: always talk to the real terminal ----------------------- # (so it still works when run as `curl ... | bash`, where stdin is the script). TTY=/dev/tty [ -e "$TTY" ] && [ -r "$TTY" ] || TTY=/dev/stdin # --- Colors ------------------------------------------------------------------ if [ -t 1 ]; then C_RESET='\033[0m'; C_BOLD='\033[1m'; C_DIM='\033[2m' C_RED='\033[31m'; C_GREEN='\033[32m'; C_YELLOW='\033[33m' C_BLUE='\033[34m'; C_MAGENTA='\033[35m'; C_CYAN='\033[36m' else C_RESET=''; C_BOLD=''; C_DIM='' C_RED=''; C_GREEN=''; C_YELLOW=''; C_BLUE=''; C_MAGENTA=''; C_CYAN='' fi # --- Bilingual helper: L "中文" "English" prints the chosen language ---------- UI_LANG="en" L() { if [ "$UI_LANG" = "cn" ]; then printf '%b' "$1"; else printf '%b' "$2"; fi; } # --- Pretty printers --------------------------------------------------------- hr() { printf "${C_DIM} ──────────────────────────────────────────────────────────${C_RESET}\n"; } info() { printf " ${C_CYAN}ℹ️ %b${C_RESET}\n" "$1"; } ok() { printf " ${C_GREEN}✅ %b${C_RESET}\n" "$1"; } warn() { printf " ${C_YELLOW}⚠️ %b${C_RESET}\n" "$1"; } errln() { printf " ${C_RED}❌ %b${C_RESET}\n" "$1" >&2; } step() { printf "\n${C_BOLD}${C_BLUE}%b${C_RESET}\n" "$1"; } die() { errln "$1"; exit 1; } # --- Arrow-key menu: menu_select "prompt" default_idx opt1 opt2 ... ---------- # Result in $MENU_INDEX (0-based) and $MENU_VALUE. Falls back to j/k keys. MENU_INDEX=0; MENU_VALUE="" menu_select() { local prompt="$1"; shift local selected="$1"; shift local options=("$@") local n=${#options[@]} printf "%b\n" "$prompt" _draw() { local i for i in "${!options[@]}"; do if [ "$i" -eq "$selected" ]; then printf " ${C_CYAN}${C_BOLD}❯ %s${C_RESET}\n" "${options[$i]}" else printf " ${C_DIM}%s${C_RESET}\n" "${options[$i]}" fi done } printf '\033[?25l' # hide cursor _draw local key key2 while true; do IFS= read -rsn1 key < "$TTY" || true case "$key" in $'\033') IFS= read -rsn2 -t 1 key2 < "$TTY" || true # 整数超时:兼容 macOS 自带 bash 3.2(不支持小数 -t) case "$key2" in '[A'|'OA') selected=$(( (selected - 1 + n) % n ));; '[B'|'OB') selected=$(( (selected + 1) % n ));; esac ;; k|K) selected=$(( (selected - 1 + n) % n ));; j|J) selected=$(( (selected + 1) % n ));; ''|$'\n'|$'\r') break;; esac printf '\033[%dA' "$n" # move cursor up n lines, redraw _draw done printf '\033[?25h' # show cursor MENU_INDEX=$selected MENU_VALUE="${options[$selected]}" } # --- Text prompt with default: prompt_value "label" "default" -> $REPLY_VALUE - REPLY_VALUE="" prompt_value() { local label="$1" def="${2:-}" ans if [ -n "$def" ]; then printf " ${C_BOLD}%b${C_RESET} ${C_DIM}[%s]${C_RESET}: " "$label" "$def" > "$TTY" else printf " ${C_BOLD}%b${C_RESET}: " "$label" > "$TTY" fi IFS= read -r ans < "$TTY" || true [ -z "$ans" ] && ans="$def" REPLY_VALUE="$ans" } # --- Hidden (password) prompt ------------------------------------------------ prompt_secret() { local label="$1" ans printf " ${C_BOLD}%b${C_RESET}: " "$label" > "$TTY" IFS= read -rs ans < "$TTY" || true printf "\n" > "$TTY" REPLY_VALUE="$ans" } # --- Yes/No prompt (default No) ---------------------------------------------- confirm() { local label="$1" ans printf " ${C_BOLD}%b${C_RESET} ${C_DIM}[y/N]${C_RESET}: " "$label" > "$TTY" IFS= read -r ans < "$TTY" || true case "$ans" in y|Y|yes|YES) return 0;; *) return 1;; esac } # --- Port helpers ------------------------------------------------------------ port_free() { local p="$1" if command -v lsof >/dev/null 2>&1; then ! lsof -nP -iTCP:"$p" -sTCP:LISTEN >/dev/null 2>&1 elif command -v ss >/dev/null 2>&1; then ! ss -ltnH 2>/dev/null | awk '{print $4}' | grep -qE "[:.]$p\$" elif command -v nc >/dev/null 2>&1; then ! nc -z 127.0.0.1 "$p" >/dev/null 2>&1 else return 0 # cannot probe — assume free fi } find_free_port() { local p="${1:-$DEFAULT_PORT}" while ! port_free "$p"; do p=$((p + 1)) [ "$p" -gt 65000 ] && break done echo "$p" } # ============================================================================= # 0. Banner + language selection # ============================================================================= clear 2>/dev/null || true printf "${C_BOLD}${C_MAGENTA}" cat <<'BANNER' ╔══════════════════════════════════════════════════════════╗ ║ ║ ║ 🛰️ agentboard relay · installer ║ ║ ║ ╚══════════════════════════════════════════════════════════╝ BANNER printf "${C_RESET}\n" menu_select " ${C_BOLD}🌐 Select language / 选择语言${C_RESET}" 0 \ "English" "中文 (Chinese)" [ "$MENU_INDEX" -eq 1 ] && UI_LANG="cn" || UI_LANG="en" ok "$(L "已选择:中文" "Language: English")" # ============================================================================= # 1. Detect OS / arch # ============================================================================= step "$(L "🔎 步骤 1/5 · 检测系统与架构" "🔎 Step 1/5 · Detecting system & architecture")" case "$(uname -s)" in Darwin) OS="darwin"; OS_LABEL="macOS";; Linux) OS="linux"; OS_LABEL="Linux";; *) die "$(L "不支持的操作系统:$(uname -s)" "Unsupported OS: $(uname -s)")";; esac case "$(uname -m)" in arm64|aarch64) ARCH="arm64";; x86_64|amd64) ARCH="x64";; *) die "$(L "不支持的 CPU 架构:$(uname -m)" "Unsupported CPU arch: $(uname -m)")";; esac BIN_NAME="agentboard-${OS}-${ARCH}" ok "$(L "系统" "OS"): ${C_BOLD}${OS_LABEL}${C_RESET} $(L "架构" "Arch"): ${C_BOLD}${ARCH}${C_RESET}" info "$(L "将下载二进制" "Target binary"): ${C_CYAN}${BIN_NAME}${C_RESET}" # ============================================================================= # 2. Install tmux # ============================================================================= step "$(L "📦 步骤 2/5 · 安装 tmux" "📦 Step 2/5 · Installing tmux")" install_tmux_macos() { if ! command -v brew >/dev/null 2>&1; then warn "$(L "未检测到 Homebrew。" "Homebrew not found.")" if confirm "$(L "现在自动安装 Homebrew 吗?" "Install Homebrew now?")"; then /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" < "$TTY" # Make brew available in the current shell for both Apple-silicon and Intel. [ -x /opt/homebrew/bin/brew ] && eval "$(/opt/homebrew/bin/brew shellenv)" [ -x /usr/local/bin/brew ] && eval "$(/usr/local/bin/brew shellenv)" else die "$(L "请先安装 Homebrew:https://brew.sh" "Please install Homebrew first: https://brew.sh")" fi fi brew list tmux >/dev/null 2>&1 || brew install tmux } install_tmux_linux() { local SUDO="" if [ "$(id -u)" -ne 0 ]; then command -v sudo >/dev/null 2>&1 && SUDO="sudo" \ || die "$(L "需要 root 或 sudo 来安装 tmux" "Need root or sudo to install tmux")" fi local ID="" ID_LIKE="" if [ -r /etc/os-release ]; then # shellcheck disable=SC1091 . /etc/os-release fi info "$(L "发行版" "Distro"): ${C_BOLD}${ID:-unknown}${C_RESET}" if command -v apt-get >/dev/null 2>&1; then $SUDO apt-get update -y && $SUDO apt-get install -y tmux elif command -v dnf >/dev/null 2>&1; then $SUDO dnf install -y tmux elif command -v yum >/dev/null 2>&1; then $SUDO yum install -y tmux elif command -v pacman >/dev/null 2>&1; then $SUDO pacman -Sy --noconfirm tmux elif command -v zypper >/dev/null 2>&1; then $SUDO zypper install -y tmux elif command -v apk >/dev/null 2>&1; then $SUDO apk add tmux else die "$(L "无法识别包管理器,请手动安装 tmux" "Could not detect a package manager; please install tmux manually")" fi } if command -v tmux >/dev/null 2>&1; then ok "$(L "tmux 已安装" "tmux already installed") ($(tmux -V 2>/dev/null || echo tmux))" else if [ "$OS" = "darwin" ]; then install_tmux_macos; else install_tmux_linux; fi command -v tmux >/dev/null 2>&1 \ && ok "$(L "tmux 安装完成" "tmux installed") ($(tmux -V 2>/dev/null || echo tmux))" \ || die "$(L "tmux 安装失败" "tmux installation failed")" fi # ============================================================================= # 3. Configuration (bilingual TUI) # ============================================================================= step "$(L "⚙️ 步骤 3/5 · 配置" "⚙️ Step 3/5 · Configuration")" # 3.1 Relay URL prompt_value "$(L "🔗 中继服务器地址 (AGENTBOARD_RELAY_URL)" "🔗 Relay server URL (AGENTBOARD_RELAY_URL)")" "$DEFAULT_RELAY_URL" RELAY_URL="$REPLY_VALUE" # 3.2 Pairing code (REQUIRED) hr info "$(L \ "前往 ${C_BOLD}${DEFAULT_RELAY_URL}${C_RESET}${C_CYAN} 登录 → 「添加新 server / Add computer」获取配对码。" \ "Go to ${C_BOLD}${DEFAULT_RELAY_URL}${C_RESET}${C_CYAN} → log in → \"Add computer\" to get a pairing code.")" PAIR_CODE="" while [ -z "$PAIR_CODE" ]; do prompt_value "$(L "🔑 配对码 (AGENTBOARD_RELAY_PAIR_CODE,必填)" "🔑 Pairing code (AGENTBOARD_RELAY_PAIR_CODE, required)")" "" PAIR_CODE="$REPLY_VALUE" [ -z "$PAIR_CODE" ] && warn "$(L "配对码不能为空。" "Pairing code cannot be empty.")" done # 3.3 Mode (e2e default) — arrow-key selectable hr menu_select "$(L "🔐 连接模式 (AGENTBOARD_RELAY_MODE),↑/↓ 选择:" "🔐 Connection mode (AGENTBOARD_RELAY_MODE), use ↑/↓:")" 0 \ "e2e $(L "端到端加密,浏览器需输入连接密码(推荐)" "end-to-end encrypted, browser needs a connection password (recommended)")" \ "transparent $(L "透明转发,无连接密码" "transparent relay, no connection password")" [ "$MENU_INDEX" -eq 1 ] && MODE="transparent" || MODE="e2e" ok "$(L "模式" "Mode"): ${C_BOLD}${MODE}${C_RESET}" # 3.4 Connection password (required only for e2e) RELAY_PASSWORD="" if [ "$MODE" = "e2e" ]; then info "$(L "e2e 模式需要设置连接密码,浏览器端连接本机时需要输入它。" \ "e2e mode needs a connection password; the browser must enter it to connect.")" while [ -z "$RELAY_PASSWORD" ]; do prompt_secret "$(L "🔒 连接密码 (AGENTBOARD_RELAY_PASSWORD)" "🔒 Connection password (AGENTBOARD_RELAY_PASSWORD)")" RELAY_PASSWORD="$REPLY_VALUE" [ -z "$RELAY_PASSWORD" ] && { warn "$(L "密码不能为空。" "Password cannot be empty.")"; continue; } prompt_secret "$(L "🔒 再次确认密码" "🔒 Confirm password")" if [ "$RELAY_PASSWORD" != "$REPLY_VALUE" ]; then warn "$(L "两次输入不一致,请重试。" "Passwords do not match, try again.")" RELAY_PASSWORD="" fi done ok "$(L "连接密码已设置" "Connection password set")" fi # 3.5 Display name (default hostname) hr HOSTNAME_DEFAULT="$(hostname 2>/dev/null || echo agentboard)" prompt_value "$(L "🏷️ 显示名称 (AGENTBOARD_RELAY_NAME)" "🏷️ Display name (AGENTBOARD_RELAY_NAME)")" "$HOSTNAME_DEFAULT" RELAY_NAME="$REPLY_VALUE" # 3.6 Port auto-probe from 4050 PORT="$(find_free_port "$DEFAULT_PORT")" if [ "$PORT" = "$DEFAULT_PORT" ]; then ok "$(L "本地端口" "Local port"): ${C_BOLD}${PORT}${C_RESET}" else warn "$(L "端口 ${DEFAULT_PORT} 被占用,自动选用" "Port ${DEFAULT_PORT} is busy, using"): ${C_BOLD}${PORT}${C_RESET}" fi # 3.7 Data dir + log (defaults, but allow override) DATA_DIR="${AGENTBOARD_DATA_DIR:-$HOME/.agentboard-relay}" LOG="${AGENTBOARD_RELAY_LOG:-/tmp/agentboard-client-relay.log}" BIN_PATH="$DATA_DIR/bin/agentboard" # ============================================================================= # Configuration summary + confirm # ============================================================================= step "$(L "📋 配置确认" "📋 Configuration summary")" printf " ${C_DIM}%-22s${C_RESET} %s\n" "$(L "中继地址" "Relay URL")" "$RELAY_URL" printf " ${C_DIM}%-22s${C_RESET} %s\n" "$(L "模式" "Mode")" "$MODE" printf " ${C_DIM}%-22s${C_RESET} %s\n" "$(L "连接密码" "Password")" "$([ "$MODE" = e2e ] && echo '••••••••' || L '(不适用)' '(n/a)')" printf " ${C_DIM}%-22s${C_RESET} %s\n" "$(L "显示名称" "Name")" "$RELAY_NAME" printf " ${C_DIM}%-22s${C_RESET} %s\n" "$(L "本地端口" "Port")" "$PORT" printf " ${C_DIM}%-22s${C_RESET} %s\n" "$(L "配对码" "Pair code")" "$PAIR_CODE" printf " ${C_DIM}%-22s${C_RESET} %s\n" "$(L "数据目录" "Data dir")" "$DATA_DIR" printf " ${C_DIM}%-22s${C_RESET} %s\n" "$(L "二进制路径" "Binary")" "$BIN_PATH" printf " ${C_DIM}%-22s${C_RESET} %s\n" "$(L "日志" "Log")" "$LOG" hr confirm "$(L "确认并开始安装?" "Proceed with installation?")" \ || die "$(L "已取消。" "Aborted.")" # ============================================================================= # 4. Download the daemon binary # ============================================================================= step "$(L "⬇️ 步骤 4/5 · 下载守护进程二进制" "⬇️ Step 4/5 · Downloading daemon binary")" DOWNLOAD_URL="${DOWNLOAD_BASE}/${BIN_NAME}" mkdir -p "$(dirname "$BIN_PATH")" info "$DOWNLOAD_URL" if command -v curl >/dev/null 2>&1; then curl -fL --progress-bar -o "${BIN_PATH}.tmp" "$DOWNLOAD_URL" \ || die "$(L "下载失败" "Download failed"): $DOWNLOAD_URL" elif command -v wget >/dev/null 2>&1; then wget -q --show-progress -O "${BIN_PATH}.tmp" "$DOWNLOAD_URL" \ || die "$(L "下载失败" "Download failed"): $DOWNLOAD_URL" else die "$(L "需要 curl 或 wget" "curl or wget is required")" fi [ -s "${BIN_PATH}.tmp" ] || die "$(L "下载文件为空" "Downloaded file is empty")" chmod +x "${BIN_PATH}.tmp" mv "${BIN_PATH}.tmp" "$BIN_PATH" ok "$(L "已安装到" "Installed to"): ${C_BOLD}${BIN_PATH}${C_RESET}" # Optional convenience symlink into ~/.local/bin when it is on PATH. if [ -d "$HOME/.local/bin" ] && printf '%s' ":$PATH:" | grep -q ":$HOME/.local/bin:"; then ln -sf "$BIN_PATH" "$HOME/.local/bin/agentboard" 2>/dev/null \ && info "$(L "已软链到 ~/.local/bin/agentboard" "Symlinked to ~/.local/bin/agentboard")" fi # Write a restart helper so the user can bring this machine online again later # without re-running the installer (pair code is cached after first run). RESTART_SH="$DATA_DIR/restart.sh" # Generated in a restrictive-umask subshell: it embeds the plaintext connection # password, so it must never be group/other readable. ( umask 077 { echo '#!/usr/bin/env bash' echo '# Auto-generated by install.sh — restart the agentboard relay daemon.' echo 'set -euo pipefail' echo "PORT=\"$PORT\"" echo "export AGENTBOARD_DATA_DIR=\"$DATA_DIR\"" echo "export AGENTBOARD_RELAY_URL=\"$RELAY_URL\"" echo "export AGENTBOARD_RELAY_MODE=\"$MODE\"" echo "export AGENTBOARD_RELAY_NAME=\"$RELAY_NAME\"" echo "export AGENTBOARD_RELAY_PAIR_CODE=\"$PAIR_CODE\"" [ "$MODE" = "e2e" ] && echo "export AGENTBOARD_RELAY_PASSWORD=\"$RELAY_PASSWORD\"" echo "export PORT" echo "PID=\"\$(lsof -nP -iTCP:\"\$PORT\" -sTCP:LISTEN -t 2>/dev/null || true)\"" echo "[ -n \"\$PID\" ] && kill \"\$PID\" 2>/dev/null || true" echo "nohup \"$BIN_PATH\" > \"$LOG\" 2>&1 &" echo "echo \"agentboard relay restarted on :\$PORT (log: $LOG)\"" } > "$RESTART_SH" ) chmod 700 "$RESTART_SH" info "$(L "重启脚本" "Restart helper"): ${C_BOLD}${RESTART_SH}${C_RESET}" # Write an updater: fetch the server's SHA256SUMS, compare against the local # binary, and only re-download + restart when the hash differs. No secrets here, # so values are baked in plainly (700 just to match restart.sh). UPDATE_SH="$DATA_DIR/update.sh" { echo '#!/usr/bin/env bash' echo '# Auto-generated by install.sh — check the relay server for a newer daemon' echo '# binary (via SHA256SUMS) and update + restart in place only if it changed.' echo 'set -uo pipefail' echo "DOWNLOAD_BASE=\"$DOWNLOAD_BASE\"" echo "BIN_NAME=\"$BIN_NAME\"" echo "BIN_PATH=\"$BIN_PATH\"" echo "RESTART_SH=\"$RESTART_SH\"" echo "SVC_LABEL=\"$SVC_LABEL\"" cat <<'EOS' # Portable sha256 (sha256sum on Linux, shasum on macOS). sha256_of() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" 2>/dev/null | awk '{print $1}' elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" 2>/dev/null | awk '{print $1}' else echo ""; fi } # Portable download: dl dl() { if command -v curl >/dev/null 2>&1; then curl -fsSL -o "$2" "$1" elif command -v wget >/dev/null 2>&1; then wget -qO "$2" "$1" else echo "❌ need curl or wget" >&2; return 1; fi } echo "🔎 Checking for updates ($BIN_NAME) ..." SUMS="$(mktemp)" trap 'rm -f "$SUMS"' EXIT dl "$DOWNLOAD_BASE/SHA256SUMS" "$SUMS" || { echo "❌ cannot fetch $DOWNLOAD_BASE/SHA256SUMS"; exit 1; } # Pull this platform's expected hash out of SHA256SUMS (text or binary "*" form). REMOTE_HASH="$(awk -v f="$BIN_NAME" '$2==f || $2=="*"f {print $1; exit}' "$SUMS")" [ -z "$REMOTE_HASH" ] && { echo "❌ no entry for $BIN_NAME in SHA256SUMS"; exit 1; } LOCAL_HASH="$(sha256_of "$BIN_PATH")" echo " remote: $REMOTE_HASH" echo " local: ${LOCAL_HASH:-}" if [ "$REMOTE_HASH" = "$LOCAL_HASH" ]; then echo "✅ Already up to date." exit 0 fi echo "⬇️ Downloading new binary ..." TMP="${BIN_PATH}.new" dl "$DOWNLOAD_BASE/$BIN_NAME" "$TMP" || { echo "❌ download failed"; rm -f "$TMP"; exit 1; } NEW_HASH="$(sha256_of "$TMP")" if [ "$NEW_HASH" != "$REMOTE_HASH" ]; then echo "❌ hash mismatch after download (got ${NEW_HASH:-}); aborting"; rm -f "$TMP"; exit 1 fi chmod +x "$TMP" mv -f "$TMP" "$BIN_PATH" echo "✅ Binary updated." # Restart via whichever run method is active (launchd / systemd / nohup helper). PLIST="$HOME/Library/LaunchAgents/${SVC_LABEL}.plist" UNIT="/etc/systemd/system/${SVC_LABEL}.service" if [ -f "$PLIST" ]; then launchctl unload "$PLIST" 2>/dev/null || true launchctl load -w "$PLIST" 2>/dev/null || true echo "🔁 launchd service restarted." elif command -v systemctl >/dev/null 2>&1 && [ -f "$UNIT" ]; then SUDO=""; [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1 && SUDO="sudo" $SUDO systemctl restart "$SVC_LABEL" 2>/dev/null || true echo "🔁 systemd service restarted." elif [ -x "$RESTART_SH" ]; then bash "$RESTART_SH" else echo "ℹ️ Updated. Start the daemon manually: bash $RESTART_SH" fi echo "🎉 Update complete." EOS } > "$UPDATE_SH" chmod 700 "$UPDATE_SH" info "$(L "更新脚本" "Update helper"): ${C_BOLD}${UPDATE_SH}${C_RESET}" # ============================================================================= # Helpers used by step 5 (run methods + online verification) # ============================================================================= mkdir -p "$DATA_DIR" HOSTPORT="${RELAY_URL#*://}"; HOSTPORT="${HOSTPORT%%/*}" # strip scheme + path SVC_LABEL="sh.vibe-coding.agentboard-relay" # Stop whatever is currently listening on $PORT (restart semantics). kill_existing_port() { local pid pid="$(lsof -nP -iTCP:"$PORT" -sTCP:LISTEN -t 2>/dev/null || true)" if [ -n "$pid" ]; then info "$(L "停止占用 :$PORT 的旧进程 (pid $pid)" "Stopping existing process on :$PORT (pid $pid)")" kill $pid 2>/dev/null || true sleep 2 fi } # Build the full, copy-pasteable manual launch command (env-prefixed). build_cmdline() { CMDLINE="PORT=$(printf %q "$PORT")" CMDLINE="$CMDLINE AGENTBOARD_DATA_DIR=$(printf %q "$DATA_DIR")" CMDLINE="$CMDLINE AGENTBOARD_RELAY_URL=$(printf %q "$RELAY_URL")" CMDLINE="$CMDLINE AGENTBOARD_RELAY_MODE=$(printf %q "$MODE")" CMDLINE="$CMDLINE AGENTBOARD_RELAY_NAME=$(printf %q "$RELAY_NAME")" CMDLINE="$CMDLINE AGENTBOARD_RELAY_PAIR_CODE=$(printf %q "$PAIR_CODE")" [ "$MODE" = "e2e" ] && CMDLINE="$CMDLINE AGENTBOARD_RELAY_PASSWORD=$(printf %q "$RELAY_PASSWORD")" # 手动启动没有 stdout 重定向,靠 LOG_FILE 让 pino 直接把日志落到 $LOG。 CMDLINE="$CMDLINE LOG_FILE=$(printf %q "$LOG")" CMDLINE="$CMDLINE $(printf %q "$BIN_PATH")" } # Poll up to ~40s for the daemon to log the relay-connected marker → ONLINE=1. # The daemon writes a `relay_connected` event into $LOG once the WSS handshake to # /relay/daemon succeeds; we grep for it (with an ESTABLISHED-socket fallback for # older daemons that don't emit the marker). verify_online() { local pid="${1:-}" ONLINE=0 printf " " for _ in $(seq 1 20); do sleep 2 printf "${C_DIM}.${C_RESET}" if grep -qiE "pairing code invalid or expired|PAIR_CODE.*required|invalid.*pair" "$LOG" 2>/dev/null; then printf "\n" errln "$(L "配对码无效或已过期,请到网页重新获取。" "Pairing code invalid or expired — get a fresh one from the web UI.")" tail -n 8 "$LOG" >&2 || true exit 1 fi if grep -q "relay_connected" "$LOG" 2>/dev/null; then ONLINE=1; break fi # Fallback: older daemons without the marker — detect the live relay socket. if lsof -nP -iTCP@"$HOSTPORT" -sTCP:ESTABLISHED 2>/dev/null | grep -qi agentboard; then ONLINE=1; break fi if [ -n "$pid" ] && ! kill -0 "$pid" 2>/dev/null; then printf "\n" warn "$(L "守护进程启动后退出,查看日志末尾:" "Daemon exited during startup; last log lines:")" tail -n 10 "$LOG" >&2 || true return fi done printf "\n" } # --- Run method A: plain background process (nohup) --------------------------- run_background() { kill_existing_port : > "$LOG" PORT="$PORT" \ AGENTBOARD_DATA_DIR="$DATA_DIR" \ AGENTBOARD_RELAY_URL="$RELAY_URL" \ AGENTBOARD_RELAY_MODE="$MODE" \ AGENTBOARD_RELAY_NAME="$RELAY_NAME" \ AGENTBOARD_RELAY_PAIR_CODE="$PAIR_CODE" \ ${RELAY_PASSWORD:+AGENTBOARD_RELAY_PASSWORD="$RELAY_PASSWORD"} \ nohup "$BIN_PATH" > "$LOG" 2>&1 & verify_online "$!" } # Write an EnvironmentFile (chmod 600) shared by the service unit. Secrets stay # out of the world-readable unit/plist this way. ENV_FILE="$DATA_DIR/relay.env" write_env_file() { umask 077 { echo "PORT=$PORT" echo "AGENTBOARD_DATA_DIR=$DATA_DIR" echo "AGENTBOARD_RELAY_URL=$RELAY_URL" echo "AGENTBOARD_RELAY_MODE=$MODE" echo "AGENTBOARD_RELAY_NAME=$RELAY_NAME" echo "AGENTBOARD_RELAY_PAIR_CODE=$PAIR_CODE" [ "$MODE" = "e2e" ] && echo "AGENTBOARD_RELAY_PASSWORD=$RELAY_PASSWORD" # 让守护进程自己把日志写进 $LOG(pino 的 LOG_FILE)。systemd 经由 # EnvironmentFile 读取本文件,但它不像 nohup/launchd 那样把 stdout 重定向到 # $LOG,所以必须显式告诉 pino 写文件,否则 /tmp/...relay.log 永远是空的。 # 注意:不要再对 systemd 加 stdout 重定向到 $LOG —— pino 同时写 fd1 和文件, # 两者都指向 $LOG 会导致每行日志重复。 echo "LOG_FILE=$LOG" } > "$ENV_FILE" chmod 600 "$ENV_FILE" } # --- Run method B (Linux): systemd system service (autostart on boot) -------- install_systemd() { local SUDO="" if [ "$(id -u)" -ne 0 ]; then command -v sudo >/dev/null 2>&1 \ && SUDO="sudo" \ || die "$(L "需要 root 或 sudo 来安装系统服务" "Need root or sudo to install a system service")" fi command -v systemctl >/dev/null 2>&1 \ || die "$(L "未检测到 systemd,无法安装服务(可改用后台运行)" "systemd not found; cannot install a service (use background run instead)")" write_env_file kill_existing_port local unit="/etc/systemd/system/${SVC_LABEL}.service" local tmp; tmp="$(mktemp)" cat > "$tmp" </\>/g; s/"/\"/g'; } install_launchd() { write_env_file kill_existing_port local plist_dir="$HOME/Library/LaunchAgents" local plist="$plist_dir/${SVC_LABEL}.plist" mkdir -p "$plist_dir" umask 077 { echo '' echo '' echo '' echo " Label${SVC_LABEL}" echo ' ProgramArguments' echo " $(xml_escape "$BIN_PATH")" echo ' ' echo ' EnvironmentVariables' echo " PORT$(xml_escape "$PORT")" echo " AGENTBOARD_DATA_DIR$(xml_escape "$DATA_DIR")" echo " AGENTBOARD_RELAY_URL$(xml_escape "$RELAY_URL")" echo " AGENTBOARD_RELAY_MODE$(xml_escape "$MODE")" echo " AGENTBOARD_RELAY_NAME$(xml_escape "$RELAY_NAME")" echo " AGENTBOARD_RELAY_PAIR_CODE$(xml_escape "$PAIR_CODE")" [ "$MODE" = "e2e" ] && echo " AGENTBOARD_RELAY_PASSWORD$(xml_escape "$RELAY_PASSWORD")" echo ' ' echo ' RunAtLoad' echo ' KeepAlive' echo " StandardOutPath$(xml_escape "$LOG")" echo " StandardErrorPath$(xml_escape "$LOG")" echo '' } > "$plist" chmod 600 "$plist" launchctl unload "$plist" 2>/dev/null || true launchctl load -w "$plist" ok "$(L "launchd 服务已安装并启动" "launchd agent installed & started"): ${C_BOLD}${SVC_LABEL}${C_RESET}" info "$(L "管理命令" "Manage"): ${C_DIM}launchctl {load|unload} -w $plist${C_RESET}" verify_online "" } # ============================================================================= # 5. Choose how to run + start # ============================================================================= step "$(L "🚀 步骤 5/5 · 选择运行方式" "🚀 Step 5/5 · Choose how to run")" build_cmdline info "$(L "你也可以随时用以下完整命令手动启动:" "You can always start it manually with this full command:")" printf " ${C_GREEN}%s${C_RESET}\n\n" "$CMDLINE" if [ "$OS" = "darwin" ]; then SVC_DESC="$(L "安装为 launchd 服务(登录时自动启动)" "Install as a launchd service (auto-start at login)")" else SVC_DESC="$(L "安装为 systemd 服务(开机自启,需 sudo)" "Install as a systemd service (auto-start on boot, needs sudo)")" fi menu_select "$(L "请选择,↑/↓ 选择,回车确认:" "Pick one — use ↑/↓, Enter to confirm:")" 0 \ "$(L "现在后台启动(nohup)" "Run in background now (nohup)")" \ "$SVC_DESC" \ "$(L "我自己手动启动(只显示命令)" "I will start it myself (just show the command)")" RUN_CHOICE="$MENU_INDEX" ONLINE=0 case "$RUN_CHOICE" in 0) run_background;; 1) if [ "$OS" = "darwin" ]; then install_launchd; else install_systemd; fi;; 2) info "$(L "已跳过启动。用上面的命令、或运行:" "Skipped. Use the command above, or run:") ${C_BOLD}bash $RESTART_SH${C_RESET}";; esac # ============================================================================= # Last step · persist the installer + generate an uninstaller into $DATA_DIR # ============================================================================= INSTALL_COPY="$DATA_DIR/install.sh" UNINSTALL_SH="$DATA_DIR/uninstall.sh" # Keep a copy of this installer next to the data dir for easy re-runs. if [ -n "$SELF_SRC" ] && [ -f "$SELF_SRC" ] && [ -r "$SELF_SRC" ]; then cp "$SELF_SRC" "$INSTALL_COPY" && chmod +x "$INSTALL_COPY" \ && ok "$(L "已保存安装脚本" "Saved installer"): ${C_BOLD}${INSTALL_COPY}${C_RESET}" elif command -v curl >/dev/null 2>&1 && curl -fsSL "${DOWNLOAD_BASE}/install.sh" -o "$INSTALL_COPY" 2>/dev/null; then chmod +x "$INSTALL_COPY" ok "$(L "已保存安装脚本(重新下载)" "Saved installer (re-downloaded)"): ${C_BOLD}${INSTALL_COPY}${C_RESET}" else warn "$(L "无法保存 install.sh 副本(跳过)" "Could not save a copy of install.sh (skipped)")" fi # Generate a self-contained uninstaller (no secrets → values baked in). ( umask 077 { echo '#!/usr/bin/env bash' echo '# Auto-generated by install.sh — uninstall the agentboard relay client.' echo 'set -uo pipefail' echo "DATA_DIR=\"$DATA_DIR\"" echo "BIN_PATH=\"$BIN_PATH\"" echo "PORT=\"$PORT\"" echo "LOG=\"$LOG\"" echo "SVC_LABEL=\"$SVC_LABEL\"" cat <<'EOS' PLIST="$HOME/Library/LaunchAgents/${SVC_LABEL}.plist" UNIT="/etc/systemd/system/${SVC_LABEL}.service" LINK="$HOME/.local/bin/agentboard" echo "🧹 Uninstalling agentboard relay client..." # Stop + remove launchd agent (macOS) if [ -f "$PLIST" ]; then launchctl unload -w "$PLIST" 2>/dev/null || true rm -f "$PLIST" && echo " ✓ removed launchd agent" fi # Stop + remove systemd service (Linux) if command -v systemctl >/dev/null 2>&1 && [ -f "$UNIT" ]; then SUDO=""; [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1 && SUDO="sudo" $SUDO systemctl disable --now "$SVC_LABEL" 2>/dev/null || true $SUDO rm -f "$UNIT" $SUDO systemctl daemon-reload 2>/dev/null || true echo " ✓ removed systemd service" fi # Kill any remaining daemon on the port PID="$(lsof -nP -iTCP:"$PORT" -sTCP:LISTEN -t 2>/dev/null || true)" if [ -n "$PID" ]; then kill $PID 2>/dev/null || true; echo " ✓ stopped daemon (pid $PID)"; fi # Remove the convenience symlink only if it points at our binary if [ -L "$LINK" ] && [ "$(readlink "$LINK" 2>/dev/null)" = "$BIN_PATH" ]; then rm -f "$LINK" && echo " ✓ removed symlink $LINK" fi # Remove the log file rm -f "$LOG" # Optionally remove the whole data dir (binary, restart.sh, env, device token) printf "Remove data dir %s including the device registration? [y/N]: " "$DATA_DIR" read -r ans < /dev/tty 2>/dev/null || ans="" case "$ans" in y|Y|yes|YES) rm -rf "$DATA_DIR" && echo " ✓ removed $DATA_DIR";; *) echo " ✓ services stopped; kept $DATA_DIR (binary & device registration preserved)";; esac echo "✅ agentboard relay client uninstalled." EOS } > "$UNINSTALL_SH" ) chmod 700 "$UNINSTALL_SH" ok "$(L "已生成卸载脚本" "Generated uninstaller"): ${C_BOLD}${UNINSTALL_SH}${C_RESET}" # ============================================================================= # Final report # ============================================================================= printf "${C_BOLD}${C_MAGENTA}" cat <<'BANNER' ╔══════════════════════════════════════════════════════════╗ BANNER printf "${C_RESET}" if [ "$RUN_CHOICE" = "2" ]; then printf "${C_BOLD}${C_CYAN} ║ 📦 %-50b ║${C_RESET}\n" "$(L "安装完成,待你手动启动。" "Installed — start it whenever you like.")" elif [ "$ONLINE" = "1" ]; then printf "${C_BOLD}${C_GREEN} ║ 🎉 %-50b ║${C_RESET}\n" "$(L "安装完成,本机已上线!" "Done — this machine is ONLINE!")" else printf "${C_BOLD}${C_YELLOW} ║ ⚠️ %-49b ║${C_RESET}\n" "$(L "已启动,但未能确认 WSS 连接。" "Started, but could not confirm WSS yet.")" fi printf "${C_BOLD}${C_MAGENTA}" cat <<'BANNER' ╚══════════════════════════════════════════════════════════╝ BANNER printf "${C_RESET}\n" if [ -f "$DATA_DIR/relay-device.json" ]; then CID="$(grep -o '"computerId"[^,]*' "$DATA_DIR/relay-device.json" 2>/dev/null | head -1 | sed 's/.*: *"//; s/".*//')" printf " ${C_DIM}%-14s${C_RESET} %s\n" "computerId" "${CID:-?}" fi printf " ${C_DIM}%-14s${C_RESET} %s\n" "$(L "网页面板" "Web UI")" "$RELAY_URL" printf " ${C_DIM}%-14s${C_RESET} %s\n" "$(L "二进制" "Binary")" "$BIN_PATH" printf " ${C_DIM}%-14s${C_RESET} %s\n" "$(L "日志" "Log")" "$LOG" printf " ${C_DIM}%-14s${C_RESET} %s\n" "$(L "重启" "Restart")" "$RESTART_SH" printf " ${C_DIM}%-14s${C_RESET} %s\n" "$(L "更新" "Update")" "bash $UPDATE_SH" printf " ${C_DIM}%-14s${C_RESET} %s\n" "$(L "卸载" "Uninstall")" "bash $UNINSTALL_SH" echo if [ "$RUN_CHOICE" != "2" ] && [ "$ONLINE" != "1" ]; then info "$(L "可稍后重试:" "You can retry later with:") ${C_BOLD}bash $RESTART_SH${C_RESET}" fi