feat(deploy): add deploy script and per-device config files

deploy/deploy.sh — automated deploy: arch detection, scp .app, write
seed.json, verify. Supports single laptop, list, or "all"; --seed-only
flag skips .app copy for key-rotation runs.

deploy/devices.conf — all 19 laptops (num / IP / event_device_id).
deploy/event.env.example — template for gitignored event.env (API key).

README updated: deploy/ table, script usage, manual steps moved to
reference section.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-04-20 15:02:15 -04:00
parent b8b7b253bb
commit 3feaf1bbc3
5 changed files with 305 additions and 68 deletions

1
.gitignore vendored
View File

@@ -27,3 +27,4 @@ aether_native_app_config.json
# package-lock.json
builds/
resources/seed_config.json
event.env

128
README.md
View File

@@ -18,6 +18,15 @@ This application serves as the "Native Mode" runtime for Aether podiums and devi
SSH user on all laptops: **`speaker ready`**
IP pattern: `192.168.32.1XX` (XX = zero-padded laptop number, e.g. 03 → `.103`). Find/replace this prefix for other onsite environments.
Deploy files live in `deploy/`:
| File | Purpose |
| --- | --- |
| `deploy/deploy.sh` | Deploy script — handles arch detection, scp, and seed.json |
| `deploy/devices.conf` | Laptop list: number, IP, `event_device_id` |
| `deploy/event.env` | **Gitignored** — per-event API key and URLs (create from example) |
| `deploy/event.env.example` | Template for `event.env` |
### Step 1 — Build the app (workstation)
```bash
@@ -29,35 +38,62 @@ npm run package:mac
Only rebuild if source code has changed. The `.app` bundle is identical for all Intel laptops —
only `~/seed.json` differs per device.
### Step 2 — Determine target architecture and copy the .app
### Step 2 — Create event.env
Check the target Mac's CPU if unsure:
```bash
ssh "speaker ready"@192.168.32.103 "uname -m"
# x86_64 → use aether_launcher-darwin-x64 (MacBook Air 2018 and all current Intel Macs)
# arm64 → use aether_launcher-darwin-arm64 (Apple Silicon M1/M2/M3/M4)
cp deploy/event.env.example deploy/event.env
# Edit deploy/event.env — fill in AETHER_API_KEY
```
Copy from the workstation (replace `103` with the target IP last octet):
Create the API key in the Aether admin panel before the show (Core → Accounts or Events →
Devices API key section). All laptops share one key per event. Delete it after the show.
### Step 3 — Run the deploy script
```bash
# Intel (current hardware):
# Deploy specific laptops:
./deploy/deploy.sh 01 02 03
# Deploy all laptops in devices.conf:
./deploy/deploy.sh all
# Update seed.json only (no .app copy — e.g. when rotating the API key):
./deploy/deploy.sh --seed-only all
```
The script auto-detects each Mac's CPU architecture, copies the correct `.app` build, writes
`seed.json`, and verifies. One SSH connection failure won't abort the batch — it logs and
continues, then reports which laptops need a retry.
### Step 4 — Verify and launch
After the script completes, launch the app on each laptop and confirm it connects and shows
the correct device name in the Launcher UI.
### Adding SSH key to a new laptop (first time only)
```bash
ssh-copy-id "speaker ready"@192.168.32.1XX
```
Run once per laptop before deploying.
---
### Manual deploy reference
The script covers the normal case. For one-off fixes or if the script isn't available:
```bash
# Detect arch
ssh "speaker ready"@192.168.32.103 "uname -m"
# x86_64 → darwin-x64 | arm64 → darwin-arm64
# Copy .app (Intel example):
scp -r builds/aether_launcher-darwin-x64/aether_launcher.app \
"speaker ready"@192.168.32.103:/Applications/aether_launcher.app
# Apple Silicon (future hardware):
scp -r builds/aether_launcher-darwin-arm64/aether_launcher.app \
"speaker ready"@192.168.32.103:/Applications/aether_launcher.app
```
If `/Applications/aether_launcher.app` already exists, `scp -r` overwrites it. No need to
remove the old version first.
### Step 3 — Write seed.json on the target laptop
The seed file lives at `~/seed.json` (`/Users/speaker ready/seed.json`) on each Mac.
It is intentionally outside the app bundle so it can be updated without redeploying.
```bash
# Write seed.json:
ssh "speaker ready"@192.168.32.103 "cat > ~/seed.json" << 'EOF'
{
"event_device_id": "DEVICE_ID_FOR_THIS_LAPTOP",
@@ -67,57 +103,13 @@ ssh "speaker ready"@192.168.32.103 "cat > ~/seed.json" << 'EOF'
"onsite_api_base_url": null
}
EOF
# Verify:
ssh "speaker ready"@192.168.32.103 "cat ~/seed.json"
```
`event_device_id` values by laptop — see the **Device Reference** table below.
### Step 4 — Verify
```bash
# Confirm seed.json landed correctly
ssh "speaker ready"@192.168.32.103 "cat ~/seed.json"
# Confirm the .app is present
ssh "speaker ready"@192.168.32.103 "ls /Applications/aether_launcher.app"
```
Then launch the app on the laptop and confirm it connects and shows the correct device name
in the Launcher UI.
### Updating seed.json only (no app reinstall)
If only the device config needs updating (e.g. changing `event_device_id` or `onsite_api_base_url`):
```bash
ssh "speaker ready"@192.168.32.103 "cat > ~/seed.json" << 'EOF'
{ ... }
EOF
```
No need to re-copy the `.app`. Restart the Electron app after writing the new seed.
### Deploy to multiple laptops at once
Repeat Steps 23 for each laptop, or use a loop:
```bash
for OCTET in 103 104 105 106; do
echo "=== Deploying to 192.168.32.$OCTET ==="
scp -r builds/aether_launcher-darwin-x64/aether_launcher.app \
"speaker ready"@192.168.32.$OCTET:/Applications/aether_launcher.app
done
```
`seed.json` must still be written per-device (each has a unique `event_device_id`).
### Adding SSH key to a new laptop (first time only)
```bash
ssh-copy-id "speaker ready"@192.168.32.1XX
```
Run once per laptop before attempting any of the deploy steps above.
---
## 📋 Device Reference

201
deploy/deploy.sh Executable file
View File

@@ -0,0 +1,201 @@
#!/usr/bin/env bash
# deploy.sh — Deploy Aether Native Launcher to onsite Mac laptops
#
# USAGE:
# ./deploy.sh <num> [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 <num> Update seed.json only — skip .app copy
# ./deploy.sh --seed-only all
#
# REQUIRES:
# event.env — copy from event.env.example and fill in AETHER_API_KEY
# builds/ — run "npm run package:mac" first if .app is missing
#
# 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
TARGETS=()
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 ;;
--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 -E "^[[:space:]]*${num}[[:space:]]" "$DEVICES_FILE" \
| grep -v '^[[: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 → copying $(basename "$bundle")..."
scp -r "$bundle" "$SSH_USER@$ip:/Applications/aether_launcher.app" || {
echo " ERROR: scp failed."
return 1
}
echo " .app copied."
else
echo " (--seed-only: skipping .app copy)"
fi
# ── Write seed.json ────────────────────────────────────────────────────
echo " Writing seed.json..."
ssh "$SSH_USER@$ip" "cat > ~/seed.json" <<EOF
{
"event_device_id": "$device_id",
"aether_api_key": "$AETHER_API_KEY",
"primary_api_base_url": "$PRIMARY_API_BASE_URL",
"backup_api_base_url": "$BACKUP_API_BASE_URL",
"onsite_api_base_url": $ONSITE_JSON
}
EOF
# ── 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
}
# ── 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

28
deploy/devices.conf Normal file
View File

@@ -0,0 +1,28 @@
# Aether Native Launcher — Device List
# Fields: laptop_num ip_address event_device_id [notes]
# Blank lines and lines starting with # are ignored.
#
# IP pattern: 192.168.32.1XX (XX = zero-padded laptop number)
# SSH user: "speaker ready" on all laptops
# Find/replace 192.168.32 for other venue network prefixes.
#
# num ip event_device_id notes
01 192.168.32.101 tFLL1fLQfnk
02 192.168.32.102 rpbfunVPEzw
03 192.168.32.103 1EPfPX8kfw8
04 192.168.32.104 zvgyLM5yieU
05 192.168.32.105 QOc046GoeSc
06 192.168.32.106 2o8j6eb0L6s
07 192.168.32.107 Oa1tlxPEVSQ
08 192.168.32.108 fY4yznpUZ48
09 192.168.32.109 YlgGCyjo9bY
10 192.168.32.110 GcTnFsp1mHI
11 192.168.32.111 6z88m9oEZio
12 192.168.32.112 EggJqL2kWkA
13 192.168.32.113 O11eckHFdVE
14 192.168.32.114 reI0SecUEhI
15 192.168.32.115 crozxT8mA44
16 192.168.32.116 0nP4VZsvr2Q
17 192.168.32.117 Gm2gNqPGzLA
19 192.168.32.119 6tpukvRVugU
x20 192.168.32.120 rwLYnKUNd1M old 04, spare/retired

15
deploy/event.env.example Normal file
View File

@@ -0,0 +1,15 @@
# event.env — Per-event deployment config for deploy.sh
# Copy this file to event.env and fill in the values before deploying.
# event.env is gitignored — never commit it (contains the API key).
#
# AETHER_API_KEY: shared across all laptops for this event deployment.
# Create in Aether admin (Core → Accounts or Events → Devices API key section)
# before the show. Delete after the show.
#
# ONSITE_API_BASE_URL: set to the local onsite API if running one (e.g.
# http://192.168.32.1/api). Leave blank to use null (cloud-only mode).
AETHER_API_KEY="your_api_key_here"
PRIMARY_API_BASE_URL="https://api.oneskyit.com"
BACKUP_API_BASE_URL="https://bak-api.oneskyit.com"
ONSITE_API_BASE_URL=""