#!/usr/bin/env bash # deploy.sh — Deploy Aether Native Launcher to onsite Mac laptops # # USAGE: # ./deploy.sh [num ...] Deploy to one or more laptops (e.g. 03 04 05) # ./deploy.sh all Deploy to all laptops in devices.conf # ./deploy.sh --seed-only Update seed.json only — skip .app copy # ./deploy.sh --seed-only all # ./deploy.sh --build [num ...] Build first (npm run package:mac), then deploy # ./deploy.sh --build all # ./deploy.sh --fix-accessibility [num ...] Re-grant macOS Accessibility permission after .app update # ./deploy.sh --fix-accessibility all (requires NOPASSWD sudo for sqlite3 — see README) # # REQUIRES: # event.env — copy from event.env.example and fill in AETHER_API_KEY # builds/ — pre-built, or use --build to build before deploying # # SSH keys must already be installed on each target (run ssh-copy-id once per laptop). set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" DEVICES_FILE="$SCRIPT_DIR/devices.conf" EVENT_ENV="$SCRIPT_DIR/event.env" SSH_USER="speaker ready" BUILD_DIR="$SCRIPT_DIR/../builds" # ── Argument parsing ────────────────────────────────────────────────────────── SEED_ONLY=false BUILD_FIRST=false FIX_ACCESSIBILITY=false TARGETS=() # Bundle ID as embedded in Info.plist by electron-packager (no --app-bundle-id override) BUNDLE_ID="com.electron.aetherlauncher" usage() { grep '^#' "$0" | grep -v '^#!/' | sed 's/^# \{0,1\}//' exit 1 } if [[ $# -eq 0 ]]; then usage; fi while [[ $# -gt 0 ]]; do case "$1" in --seed-only) SEED_ONLY=true ;; --build) BUILD_FIRST=true ;; --fix-accessibility) FIX_ACCESSIBILITY=true ;; --help|-h) usage ;; all) TARGETS+=("all") ;; *) TARGETS+=("$1") ;; esac shift done if [[ ${#TARGETS[@]} -eq 0 ]]; then echo "ERROR: No targets specified." usage fi # ── Load event config ───────────────────────────────────────────────────────── if [[ ! -f "$EVENT_ENV" ]]; then echo "ERROR: $EVENT_ENV not found." echo " Copy event.env.example → event.env and fill in AETHER_API_KEY." exit 1 fi # shellcheck source=/dev/null source "$EVENT_ENV" : "${AETHER_API_KEY:?event.env must set AETHER_API_KEY}" : "${PRIMARY_API_BASE_URL:?event.env must set PRIMARY_API_BASE_URL}" : "${BACKUP_API_BASE_URL:?event.env must set BACKUP_API_BASE_URL}" # Render onsite URL as JSON null or quoted string if [[ -n "${ONSITE_API_BASE_URL:-}" ]]; then ONSITE_JSON="\"$ONSITE_API_BASE_URL\"" else ONSITE_JSON="null" fi # ── Device list helpers ─────────────────────────────────────────────────────── # Returns "ip device_id" for a laptop number, or empty if not found lookup_device() { local num="$1" grep -v '^[[:space:]]*#' "$DEVICES_FILE" \ | grep -E "^[[:space:]]*${num}[[:space:]]" \ | awk '{print $2, $3}' \ | head -1 } # Returns all laptop numbers from devices.conf (first column, non-comment lines) all_device_nums() { grep -v '^[[:space:]]*#' "$DEVICES_FILE" \ | grep -v '^[[:space:]]*$' \ | awk '{print $1}' } # ── Deploy one laptop ───────────────────────────────────────────────────────── deploy_laptop() { local num="$1" local info info=$(lookup_device "$num") if [[ -z "$info" ]]; then echo " ERROR: Laptop $num not found in devices.conf" return 1 fi local ip device_id ip=$(echo "$info" | awk '{print $1}') device_id=$(echo "$info" | awk '{print $2}') echo "" echo "══════════════════════════════════════════════" echo " Laptop $num · $ip · $device_id" echo "══════════════════════════════════════════════" # ── Copy .app ────────────────────────────────────────────────────────── if [[ "$SEED_ONLY" != "true" ]]; then echo " Detecting architecture..." local arch arch=$(ssh "$SSH_USER@$ip" "uname -m" 2>/dev/null) || { echo " ERROR: SSH failed for $ip — is the laptop on the network?" return 1 } local bundle case "$arch" in x86_64) bundle="$BUILD_DIR/aether_launcher-darwin-x64/aether_launcher.app" ;; arm64) bundle="$BUILD_DIR/aether_launcher-darwin-arm64/aether_launcher.app" ;; *) echo " ERROR: Unknown arch '$arch' on $ip" return 1 ;; esac if [[ ! -d "$bundle" ]]; then echo " ERROR: Build not found: $bundle" echo " Run: npm run package:mac" return 1 fi echo " Arch: $arch → syncing $(basename "$bundle")..." # rsync --delete syncs contents in-place without removing the top-level .app dir. # This preserves the inode so macOS Aliases and Desktop shortcuts keep working. rsync -a --delete -e ssh "$bundle/" "$SSH_USER@$ip:/Applications/aether_launcher.app/" || { echo " ERROR: rsync failed." return 1 } echo " .app synced." else echo " (--seed-only: skipping .app copy)" fi # ── Write seed.json ──────────────────────────────────────────────────── echo " Writing seed.json..." ssh "$SSH_USER@$ip" "cat > ~/seed.json" </dev/null; then echo " tccutil reset OK." else echo " WARNING: tccutil reset failed (non-fatal)." fi echo " Granting Accessibility via TCC database (requires NOPASSWD sudo)..." # shellcheck disable=SC2016 TCC_SQL="INSERT OR REPLACE INTO access(service,client,client_type,auth_value,auth_reason,auth_version) VALUES('kTCCServiceAccessibility','$BUNDLE_ID',0,2,4,1);" if ssh "$SSH_USER@$ip" "sudo sqlite3 '/Library/Application Support/com.apple.TCC/TCC.db' \"$TCC_SQL\"" 2>/dev/null; then echo " ✓ Accessibility granted." else echo " WARNING: TCC grant failed — manual re-authorization required." echo " See README: macOS Accessibility Permission." fi fi # ── Verify ───────────────────────────────────────────────────────────── echo " Verifying..." ssh "$SSH_USER@$ip" "cat ~/seed.json" if [[ "$SEED_ONLY" != "true" ]]; then ssh "$SSH_USER@$ip" \ "test -d /Applications/aether_launcher.app && echo ' ✓ .app present' || echo ' ✗ .app NOT found'" fi echo " ✓ Laptop $num done." return 0 } # ── Build if requested ─────────────────────────────────────────────────────── if [[ "$BUILD_FIRST" == "true" ]]; then echo "══════════════════════════════════════════════" echo " Building: npm run package:mac" echo "══════════════════════════════════════════════" (cd "$SCRIPT_DIR/.." && npm run package:mac) || { echo "ERROR: Build failed. Aborting deploy." exit 1 } echo "" fi # ── Expand "all" target ─────────────────────────────────────────────────────── EXPANDED_TARGETS=() for t in "${TARGETS[@]}"; do if [[ "$t" == "all" ]]; then while IFS= read -r num; do EXPANDED_TARGETS+=("$num") done < <(all_device_nums) else EXPANDED_TARGETS+=("$t") fi done # ── Run deploys ─────────────────────────────────────────────────────────────── FAILED=() for num in "${EXPANDED_TARGETS[@]}"; do if ! deploy_laptop "$num"; then FAILED+=("$num") fi done echo "" echo "══════════════════════════════════════════════" if [[ ${#FAILED[@]} -eq 0 ]]; then echo " All done. ✓" else echo " FAILED: ${FAILED[*]}" echo " Re-run with just those numbers to retry." exit 1 fi