@keepur/hive 0.1.10 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # =============================================================================
5
+ # Deploy check — per-instance version compare against npm
6
+ # =============================================================================
7
+ #
8
+ # Reads each instance's pinned tag from instances.conf. For each instance whose
9
+ # installed version (from .hive/package.json) differs from the tag it's pinned to,
10
+ # invokes deploy.sh --instance=<id> --tag=<tag>.
11
+ #
12
+ # Pinned "latest" compares against the npm `latest` dist-tag — so unpinned
13
+ # instances autoupgrade whenever a new @keepur/hive is published.
14
+ # =============================================================================
15
+
16
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
17
+ BUILD_DIR="${BUILD_DIR:-$HOME/build/hive}"
18
+ DEPLOY_DIR="${DEPLOY_DIR:-$HOME/services/hive}"
19
+ INSTANCES_CONF="$SCRIPT_DIR/instances.conf"
20
+
21
+ cd "$BUILD_DIR"
22
+ [[ "$(git branch --show-current)" == "deploy" ]] || { echo "ERROR: Build dir not on deploy branch"; exit 1; }
23
+
24
+ echo "Checking for updates on deploy branch..."
25
+ git fetch origin deploy --quiet
26
+
27
+ # --- Load instances ---
28
+ declare -a INSTANCES=()
29
+ while IFS='|' read -r id config _agents _label logs_dir ports engine_tag; do
30
+ [[ "$id" =~ ^[[:space:]]*# ]] && continue
31
+ [[ -z "$id" ]] && continue
32
+ id=$(echo "$id" | xargs)
33
+ engine_tag=$(echo "${engine_tag:-}" | xargs)
34
+ INSTANCES+=("$id|${engine_tag:-latest}")
35
+ done < "$INSTANCES_CONF"
36
+
37
+ UPDATES_NEEDED=()
38
+ for inst in "${INSTANCES[@]}"; do
39
+ IFS='|' read -r id tag <<< "$inst"
40
+ version="${tag#v}"
41
+ # Per-instance root: $DEPLOY_DIR/<id> if it exists (post-Phase-5 layout),
42
+ # else $DEPLOY_DIR (today's shared-dir primary layout).
43
+ if [[ -d "$DEPLOY_DIR/$id" ]]; then
44
+ instance_root="$DEPLOY_DIR/$id"
45
+ else
46
+ instance_root="$DEPLOY_DIR"
47
+ fi
48
+ installed=$(jq -r .version < "$instance_root/.hive/package.json" 2>/dev/null || echo "unknown")
49
+ if [[ "$version" == "latest" ]]; then
50
+ target=$(npm view @keepur/hive version 2>/dev/null || echo "unknown")
51
+ else
52
+ target=$(npm view "@keepur/hive@$version" version 2>/dev/null || echo "unknown")
53
+ fi
54
+ if [[ "$target" == "unknown" ]]; then
55
+ echo " [$id] could not resolve target version (pinned: $tag). Skipping."
56
+ continue
57
+ fi
58
+ if [[ "$installed" == "$target" ]]; then
59
+ echo " [$id] up to date ($installed)."
60
+ else
61
+ echo " [$id] $installed → $target (pinned: $tag)"
62
+ UPDATES_NEEDED+=("$id|$target")
63
+ fi
64
+ done
65
+
66
+ if [[ ${#UPDATES_NEEDED[@]} -eq 0 ]]; then
67
+ echo "All instances up to date. Nothing to deploy."
68
+ exit 0
69
+ fi
70
+
71
+ echo ""
72
+ echo "Updates needed:"
73
+ for u in "${UPDATES_NEEDED[@]}"; do
74
+ IFS='|' read -r id target <<< "$u"
75
+ echo " - $id → $target"
76
+ done
77
+
78
+ # Deploy each one. deploy.sh handles build once per invocation, so we loop.
79
+ # Rationale for one-at-a-time: build-phase is shared, but the build is cheap
80
+ # to re-run and keeping it inside deploy.sh keeps one script responsible for
81
+ # the full flow. If this becomes a performance issue, split build into a
82
+ # separate phase invoked once up front.
83
+ for u in "${UPDATES_NEEDED[@]}"; do
84
+ IFS='|' read -r id target <<< "$u"
85
+ echo ""
86
+ echo "=== Deploying $id → $target ==="
87
+ "$SCRIPT_DIR/deploy.sh" --instance="$id" --tag="$target"
88
+ done
@@ -0,0 +1,476 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # =============================================================================
5
+ # Hive Deploy — build once, deploy to all instances
6
+ # =============================================================================
7
+
8
+ # --- Configuration ---
9
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
10
+ BUILD_DIR="${BUILD_DIR:-$HOME/build/hive}"
11
+ DEPLOY_DIR="${DEPLOY_DIR:-$HOME/services/hive}"
12
+ INSTANCES_CONF="$SCRIPT_DIR/instances.conf"
13
+
14
+ # --- Flags ---
15
+ DRY_RUN=false
16
+ ROLLBACK=false
17
+ FILTER_INSTANCE=""
18
+ OVERRIDE_TAG=""
19
+ for arg in "$@"; do
20
+ case "$arg" in
21
+ --dry-run) DRY_RUN=true ;;
22
+ --rollback) ROLLBACK=true ;;
23
+ --instance=*) FILTER_INSTANCE="${arg#--instance=}" ;;
24
+ --tag=*) OVERRIDE_TAG="${arg#--tag=}" ;;
25
+ *)
26
+ echo "ERROR: unknown arg: $arg" >&2
27
+ echo "Usage: deploy.sh [--dry-run] [--rollback] [--instance=<id>] [--tag=<tag>]" >&2
28
+ exit 2
29
+ ;;
30
+ esac
31
+ done
32
+
33
+ # --- Notification config (from dodi's .env — the primary instance) ---
34
+ # shellcheck source=/dev/null
35
+ source "$DEPLOY_DIR/.env"
36
+ : "${SLACK_BOT_TOKEN:?SLACK_BOT_TOKEN not set in .env}"
37
+ : "${DEVOPS_CHANNEL_ID:?DEVOPS_CHANNEL_ID not set in .env}"
38
+
39
+ # --- Load instances ---
40
+ declare -a INSTANCES=()
41
+ while IFS='|' read -r id config _agents_path label logs_dir ports engine_tag; do
42
+ [[ "$id" =~ ^[[:space:]]*# ]] && continue # skip comments
43
+ [[ -z "$id" ]] && continue # skip blank lines
44
+ # Trim whitespace
45
+ id=$(echo "$id" | xargs)
46
+ config=$(echo "$config" | xargs)
47
+ label=$(echo "$label" | xargs)
48
+ logs_dir=$(echo "$logs_dir" | xargs)
49
+ ports=$(echo "$ports" | xargs)
50
+ engine_tag=$(echo "${engine_tag:-}" | xargs)
51
+ INSTANCES+=("$id|$config|$label|$logs_dir|$ports|$engine_tag")
52
+ done < "$INSTANCES_CONF"
53
+
54
+ if [[ ${#INSTANCES[@]} -eq 0 ]]; then
55
+ echo "ERROR: No instances found in $INSTANCES_CONF"
56
+ exit 1
57
+ fi
58
+
59
+ echo "=== Hive Deploy (${#INSTANCES[@]} instances) ==="
60
+ for inst in "${INSTANCES[@]}"; do
61
+ echo " - $(echo "$inst" | cut -d'|' -f1)"
62
+ done
63
+
64
+ # --- Helpers ---
65
+ run_cmd() {
66
+ if $DRY_RUN; then
67
+ echo "[DRY RUN] $*"
68
+ else
69
+ "$@"
70
+ fi
71
+ }
72
+
73
+ notify() {
74
+ local message="$1"
75
+ if $DRY_RUN; then
76
+ echo "[DRY RUN] notify: $message"
77
+ return
78
+ fi
79
+ local payload
80
+ payload=$(jq -n --arg channel "$DEVOPS_CHANNEL_ID" --arg text "$message" \
81
+ '{channel: $channel, text: $text}')
82
+ curl -s -X POST https://slack.com/api/chat.postMessage \
83
+ -H "Authorization: Bearer $SLACK_BOT_TOKEN" \
84
+ -H "Content-Type: application/json" \
85
+ -d "$payload" \
86
+ > /dev/null
87
+ }
88
+
89
+ health_check() {
90
+ local log_file="$1"
91
+ if $DRY_RUN; then
92
+ echo "[DRY RUN] health_check: would check $log_file"
93
+ return 0
94
+ fi
95
+ for _ in $(seq 1 30); do
96
+ sleep 1
97
+ if tail -5 "$log_file" 2>/dev/null | grep -q '"Hive is running"'; then
98
+ return 0
99
+ fi
100
+ done
101
+ return 1
102
+ }
103
+
104
+ kill_ports() {
105
+ local ports_str="$1"
106
+ if $DRY_RUN; then
107
+ echo "[DRY RUN] kill_ports: would scan $ports_str"
108
+ return
109
+ fi
110
+ for port in $ports_str; do
111
+ local pids
112
+ pids=$(lsof -i :"$port" -t 2>/dev/null || true)
113
+ if [[ -n "$pids" ]]; then
114
+ echo " Killing stale process(es) on port $port: $pids"
115
+ echo "$pids" | xargs kill -9 2>/dev/null || true
116
+ fi
117
+ done
118
+ sleep 1
119
+ }
120
+
121
+ # --- Engine fetch/swap/rollback (KPR-53 / Phase 3) ---
122
+
123
+ # Resolve a tag to an npm-facing bare-semver version string.
124
+ # "v0.2.0" → "0.2.0"; "latest" → "latest"; "0.2.0" → "0.2.0"
125
+ _normalize_tag() {
126
+ local tag="${1:-latest}"
127
+ if [[ "$tag" == "latest" ]]; then
128
+ echo "latest"
129
+ else
130
+ echo "${tag#v}"
131
+ fi
132
+ }
133
+
134
+ # _instance_root <id>
135
+ # Returns the instance root dir: $DEPLOY_DIR/<id> if that dir exists (post-Phase-5
136
+ # per-instance layout), else $DEPLOY_DIR (today's primary-shared-dir layout).
137
+ # Lets deploy.sh be per-instance-dir aware now so Phase 5's migration is a no-op
138
+ # at the script level.
139
+ _instance_root() {
140
+ local id="$1"
141
+ if [[ -d "$DEPLOY_DIR/$id" ]]; then
142
+ echo "$DEPLOY_DIR/$id"
143
+ else
144
+ echo "$DEPLOY_DIR"
145
+ fi
146
+ }
147
+
148
+ # fetch_engine <instance_dir> <tag>
149
+ # Populates <instance_dir>/.hive.next/ with the tag's contents.
150
+ # Primary path: npm pack @keepur/hive@<version> + tar -xzf --strip-components=1.
151
+ # Fallback: rsync from $BUILD_DIR (developer-ergonomics path), only when npm pack
152
+ # actually fails (not merely when the tag isn't in the registry — transient
153
+ # registry errors should surface, not silently swap to rsync).
154
+ fetch_engine() {
155
+ local instance_dir="$1"
156
+ local tag="$2"
157
+ local version
158
+ version=$(_normalize_tag "$tag")
159
+
160
+ if $DRY_RUN; then
161
+ echo "[DRY RUN] fetch_engine: would populate $instance_dir/.hive.next/ from @keepur/hive@$version"
162
+ return 0
163
+ fi
164
+
165
+ rm -rf "$instance_dir/.hive.next"
166
+ mkdir -p "$instance_dir/.hive.next"
167
+
168
+ local packdir
169
+ packdir=$(mktemp -d)
170
+ local tarball
171
+ # npm pack prints the tarball filename on the last line of stdout.
172
+ # Run from a temp dir so the tarball doesn't litter $PWD.
173
+ echo " fetch_engine: npm pack @keepur/hive@$version"
174
+ tarball=$(cd "$packdir" && npm pack "@keepur/hive@$version" 2>/dev/null | tail -n1)
175
+
176
+ if [[ -n "$tarball" && -f "$packdir/$tarball" ]]; then
177
+ tar -xzf "$packdir/$tarball" --strip-components=1 -C "$instance_dir/.hive.next/"
178
+ rm -rf "$packdir"
179
+ else
180
+ rm -rf "$packdir"
181
+ echo " fetch_engine: npm pack failed; falling back to rsync from $BUILD_DIR" >&2
182
+ local src="$BUILD_DIR"
183
+ if [[ ! -f "$src/pkg/server.min.js" ]]; then
184
+ rm -rf "$instance_dir/.hive.next"
185
+ echo "ERROR: npm pack @keepur/hive@$version failed and fallback needs $src/pkg/server.min.js — run 'npm run bundle' in $src" >&2
186
+ return 1
187
+ fi
188
+ rsync -a --delete "$src/pkg/" "$instance_dir/.hive.next/pkg/"
189
+ rsync -a --delete "$src/seeds/" "$instance_dir/.hive.next/seeds/"
190
+ rsync -a --delete "$src/templates/" "$instance_dir/.hive.next/templates/"
191
+ rsync -a --delete "$src/install/" "$instance_dir/.hive.next/install/"
192
+ rsync -a --delete "$src/service/" "$instance_dir/.hive.next/service/"
193
+ # scripts/honeypot is a single binary, not the whole scripts/ dir
194
+ mkdir -p "$instance_dir/.hive.next/scripts"
195
+ [[ -f "$src/scripts/honeypot" ]] && cp "$src/scripts/honeypot" "$instance_dir/.hive.next/scripts/honeypot"
196
+ cp "$src/package.json" "$instance_dir/.hive.next/"
197
+ fi
198
+
199
+ # Sanity check — if the tarball/rsync was broken, catch it before the swap.
200
+ if [[ ! -f "$instance_dir/.hive.next/pkg/server.min.js" ]]; then
201
+ rm -rf "$instance_dir/.hive.next"
202
+ echo "ERROR: .hive.next/pkg/server.min.js missing after fetch_engine" >&2
203
+ return 1
204
+ fi
205
+ }
206
+
207
+ # install_engine_deps <instance_dir>
208
+ # Runs `npm install --omit=dev` inside .hive.next/ so the bundle's runtime
209
+ # externals (native modules, large SDKs, asset loaders — 14 of them) resolve
210
+ # from .hive/pkg/. Node walks up to .hive/node_modules/ to find them.
211
+ # Mirrors src/setup/populate-engine.ts so `hive init` and `hive update`
212
+ # produce byte-identical .hive/ layouts.
213
+ install_engine_deps() {
214
+ local instance_dir="$1"
215
+ if $DRY_RUN; then
216
+ echo "[DRY RUN] install_engine_deps: would npm install --omit=dev in $instance_dir/.hive.next/"
217
+ return 0
218
+ fi
219
+ if [[ ! -f "$instance_dir/.hive.next/package.json" ]]; then
220
+ echo "ERROR: install_engine_deps needs $instance_dir/.hive.next/package.json" >&2
221
+ return 1
222
+ fi
223
+ echo " install_engine_deps: npm install --omit=dev in .hive.next/"
224
+ (cd "$instance_dir/.hive.next" && npm install --omit=dev --no-audit --no-fund --no-progress >&2)
225
+ }
226
+
227
+ # swap_engine <instance_dir>
228
+ # Rotates: old .hive.prev → dropped; live .hive → .hive.prev; .hive.next → .hive.
229
+ # Assumes the service is already stopped. The ~50ms window where .hive/ doesn't
230
+ # exist is covered by the service being down.
231
+ swap_engine() {
232
+ local instance_dir="$1"
233
+ if $DRY_RUN; then
234
+ echo "[DRY RUN] swap_engine: would rotate $instance_dir/.hive{,.prev,.next}"
235
+ return 0
236
+ fi
237
+ if [[ ! -d "$instance_dir/.hive.next" ]]; then
238
+ echo "ERROR: swap_engine called but $instance_dir/.hive.next does not exist" >&2
239
+ return 1
240
+ fi
241
+ if [[ -d "$instance_dir/.hive" ]]; then
242
+ rm -rf "$instance_dir/.hive.prev" # drop older backup
243
+ mv "$instance_dir/.hive" "$instance_dir/.hive.prev"
244
+ fi
245
+ mv "$instance_dir/.hive.next" "$instance_dir/.hive"
246
+ # Clear any .hive.broken/ from a previous failed rollback — this is a successful deploy.
247
+ rm -rf "$instance_dir/.hive.broken"
248
+ }
249
+
250
+ # rollback_engine <instance_dir>
251
+ # Manual rollback OR auto-rollback after health-check failure.
252
+ # Moves the failed engine to .hive.broken/ (preserved for inspection) and
253
+ # restores .hive.prev → .hive.
254
+ rollback_engine() {
255
+ local instance_dir="$1"
256
+ if $DRY_RUN; then
257
+ if [[ ! -d "$instance_dir/.hive.prev" ]]; then
258
+ echo "[DRY RUN] rollback_engine: would fail — no $instance_dir/.hive.prev"
259
+ return 1
260
+ fi
261
+ echo "[DRY RUN] rollback_engine: would swap $instance_dir/.hive ↔ .hive.prev (failed engine → .hive.broken)"
262
+ return 0
263
+ fi
264
+ if [[ ! -d "$instance_dir/.hive.prev" ]]; then
265
+ echo "ERROR: no previous engine at $instance_dir/.hive.prev — rollback unavailable" >&2
266
+ return 1
267
+ fi
268
+ rm -rf "$instance_dir/.hive.broken"
269
+ if [[ -d "$instance_dir/.hive" ]]; then
270
+ mv "$instance_dir/.hive" "$instance_dir/.hive.broken"
271
+ fi
272
+ mv "$instance_dir/.hive.prev" "$instance_dir/.hive"
273
+ }
274
+
275
+ # --- Short-circuit: --rollback mode ---
276
+ # Rollback is per-instance; requires --instance=<id> so we know which to roll.
277
+ # No build phase, no notify until after the swap.
278
+ if $ROLLBACK; then
279
+ if [[ -z "$FILTER_INSTANCE" ]]; then
280
+ echo "ERROR: --rollback requires --instance=<id>" >&2
281
+ exit 2
282
+ fi
283
+ # Find the matching instance row so we know its logs dir + label.
284
+ ROLLBACK_ROW=""
285
+ for inst in "${INSTANCES[@]}"; do
286
+ IFS='|' read -r id _config _label _logs _ports _tag <<< "$inst"
287
+ if [[ "$id" == "$FILTER_INSTANCE" ]]; then
288
+ ROLLBACK_ROW="$inst"
289
+ break
290
+ fi
291
+ done
292
+ if [[ -z "$ROLLBACK_ROW" ]]; then
293
+ echo "ERROR: no instance '$FILTER_INSTANCE' in $INSTANCES_CONF" >&2
294
+ exit 2
295
+ fi
296
+ IFS='|' read -r id _config label logs_dir ports _tag <<< "$ROLLBACK_ROW"
297
+ instance_root=$(_instance_root "$id")
298
+ echo "--- Rolling back $id (root: $instance_root) ---"
299
+ # Stop LaunchAgent BEFORE rotating .hive/. Without -kp, launchd auto-respawns
300
+ # mid-rollback and the restart picks up a partial state. Mirrors the deploy
301
+ # loop's stop sequence at the main branch.
302
+ echo " Stopping $label..."
303
+ run_cmd launchctl kickstart -kp "gui/$(id -u)/$label" 2>/dev/null || true
304
+ kill_ports "$ports"
305
+ if ! rollback_engine "$instance_root"; then
306
+ notify "Rollback FAILED for \`$id\`: no previous engine (.hive.prev missing)."
307
+ exit 1
308
+ fi
309
+ run_cmd launchctl kickstart -k "gui/$(id -u)/$label"
310
+ if health_check "$instance_root/$logs_dir/hive.log"; then
311
+ rollback_version=$(jq -r .version < "$instance_root/.hive/package.json" 2>/dev/null || echo "unknown")
312
+ notify "Rollback succeeded for \`$id\` → \`$rollback_version\`."
313
+ echo "Rollback complete."
314
+ exit 0
315
+ else
316
+ notify "Rollback succeeded but health check failed for \`$id\`. Check logs."
317
+ exit 1
318
+ fi
319
+ fi
320
+
321
+ # --- Guard: shared instance_root with diverging pins ---
322
+ # When multiple instances resolve to the same instance_root (today: dodi+personal
323
+ # both share $DEPLOY_DIR until Phase 5 migrates them into per-instance dirs),
324
+ # they share a single .hive/ — their ENGINE_TAG pins MUST agree. Otherwise
325
+ # per-instance deploys silently clobber each other and deploy-check.sh
326
+ # oscillates between versions on every poll. Compares the configured pins
327
+ # (not the effective tag for this run) so even an explicit --tag override
328
+ # can't sneak past a misconfigured pinning state. Fail fast before any work.
329
+ declare -a _seen_roots=()
330
+ declare -a _seen_pins=()
331
+ for inst in "${INSTANCES[@]}"; do
332
+ IFS='|' read -r _id _config _label _logs _ports _engine_tag <<< "$inst"
333
+ _root=$(_instance_root "$_id")
334
+ _pin="${_engine_tag:-latest}"
335
+ for i in "${!_seen_roots[@]}"; do
336
+ if [[ "${_seen_roots[$i]}" == "$_root" && "${_seen_pins[$i]}" != "$_pin" ]]; then
337
+ echo "ERROR: instances share root '$_root' but pin different ENGINE_TAGs ('${_seen_pins[$i]}' vs '$_pin')." >&2
338
+ echo " Set the same ENGINE_TAG for all instances under one root, or migrate them to per-instance dirs (Phase 5)." >&2
339
+ exit 2
340
+ fi
341
+ done
342
+ _seen_roots+=("$_root")
343
+ _seen_pins+=("$_pin")
344
+ done
345
+ unset _seen_roots _seen_pins _id _config _label _logs _ports _engine_tag _root _pin i
346
+
347
+ # =============================================================================
348
+ # Phase 1: Build (once, in $BUILD_DIR)
349
+ # =============================================================================
350
+
351
+ echo ""
352
+ echo "--- Phase 1: Build ---"
353
+ # Per-instance current versions are reported in Phase 2 after each health check.
354
+
355
+ echo "Pulling latest in build dir..."
356
+ cd "$BUILD_DIR"
357
+ [[ "$(git branch --show-current)" == "deploy" ]] || { echo "ERROR: Build dir not on deploy branch"; exit 1; }
358
+ run_cmd git pull --ff-only
359
+
360
+ DEPLOY_SHA=$(git rev-parse --short HEAD)
361
+ DEPLOY_MSG=$(git log -1 --pretty=%s)
362
+
363
+ echo "Installing dependencies..."
364
+ if ! run_cmd npm install; then
365
+ notify "Deploy aborted. \`npm install\` failed. Commit: \`$DEPLOY_SHA\`."
366
+ exit 1
367
+ fi
368
+
369
+ echo "Running checks..."
370
+ if ! run_cmd npm run check; then
371
+ notify "Deploy aborted. \`npm run check\` failed. Commit: \`$DEPLOY_SHA\`."
372
+ exit 1
373
+ fi
374
+
375
+ echo "Building..."
376
+ if ! run_cmd npm run build; then
377
+ notify "Deploy aborted. Build failed. Commit: \`$DEPLOY_SHA\`."
378
+ exit 1
379
+ fi
380
+
381
+ echo "Bundling..."
382
+ if ! run_cmd npm run bundle; then
383
+ notify "Deploy aborted. Bundle failed. Commit: \`$DEPLOY_SHA\`."
384
+ exit 1
385
+ fi
386
+
387
+ # =============================================================================
388
+ # Phase 2: Deploy each instance
389
+ # =============================================================================
390
+
391
+ FAILED_INSTANCES=()
392
+
393
+ for inst in "${INSTANCES[@]}"; do
394
+ IFS='|' read -r id config label logs_dir ports engine_tag <<< "$inst"
395
+
396
+ # --instance=<id> filter
397
+ if [[ -n "$FILTER_INSTANCE" && "$FILTER_INSTANCE" != "$id" ]]; then
398
+ echo ""
399
+ echo "--- Skipping '$id' (filtered: --instance=$FILTER_INSTANCE) ---"
400
+ continue
401
+ fi
402
+
403
+ # --tag override > per-instance engine_tag > "latest"
404
+ tag="${OVERRIDE_TAG:-${engine_tag:-latest}}"
405
+ instance_root=$(_instance_root "$id")
406
+
407
+ echo ""
408
+ echo "--- Phase 2: Deploy instance '$id' @ $tag (root: $instance_root) ---"
409
+ echo " config=$config label=$label logs=$logs_dir ports=$ports"
410
+
411
+ mkdir -p "$instance_root/$logs_dir"
412
+
413
+ echo " Stopping $label..."
414
+ run_cmd launchctl kickstart -kp "gui/$(id -u)/$label" 2>/dev/null || true
415
+ kill_ports "$ports"
416
+
417
+ echo " Fetching engine..."
418
+ if ! fetch_engine "$instance_root" "$tag"; then
419
+ notify "Deploy FAILED for \`$id\`: fetch_engine errored at tag \`$tag\`."
420
+ FAILED_INSTANCES+=("$id")
421
+ run_cmd launchctl kickstart -k "gui/$(id -u)/$label" || true # bring old engine back up
422
+ continue
423
+ fi
424
+
425
+ echo " Installing engine deps..."
426
+ if ! install_engine_deps "$instance_root"; then
427
+ notify "Deploy FAILED for \`$id\`: install_engine_deps errored at tag \`$tag\`."
428
+ FAILED_INSTANCES+=("$id")
429
+ rm -rf "$instance_root/.hive.next"
430
+ run_cmd launchctl kickstart -k "gui/$(id -u)/$label" || true # bring old engine back up
431
+ continue
432
+ fi
433
+
434
+ echo " Swapping engine..."
435
+ swap_engine "$instance_root"
436
+
437
+ echo " Restarting $label..."
438
+ run_cmd launchctl kickstart -k "gui/$(id -u)/$label"
439
+
440
+ echo " Checking health..."
441
+ if ! health_check "$instance_root/$logs_dir/hive.log"; then
442
+ echo " Health check FAILED for $id — rolling back"
443
+ if rollback_engine "$instance_root"; then
444
+ run_cmd launchctl kickstart -k "gui/$(id -u)/$label"
445
+ notify "Deploy rolled back for \`$id\`: \`$tag\` failed health check, restored previous version."
446
+ else
447
+ notify "Deploy FAILED for \`$id\` and auto-rollback unavailable (.hive.prev missing). Manual intervention required."
448
+ fi
449
+ FAILED_INSTANCES+=("$id")
450
+ continue
451
+ fi
452
+
453
+ new_version=$(jq -r .version < "$instance_root/.hive/package.json" 2>/dev/null || echo "unknown")
454
+ echo " Instance '$id' is healthy at version $new_version."
455
+ done
456
+
457
+ # =============================================================================
458
+ # Phase 3: Report
459
+ # =============================================================================
460
+
461
+ echo ""
462
+ echo "--- Phase 3: Report ---"
463
+
464
+ if [[ ${#FAILED_INSTANCES[@]} -gt 0 ]]; then
465
+ failed_list=$(printf ", %s" "${FAILED_INSTANCES[@]}")
466
+ failed_list=${failed_list:2}
467
+ notify "Deploy partial. Build commit \`$DEPLOY_SHA\`: $DEPLOY_MSG. Failed instances: $failed_list."
468
+ echo "Deploy completed with failures: $failed_list"
469
+ exit 1
470
+ else
471
+ # Count actual deploy targets (respecting --instance filter)
472
+ deployed=${#INSTANCES[@]}
473
+ [[ -n "$FILTER_INSTANCE" ]] && deployed=1
474
+ notify "Deploy succeeded ($deployed instance(s)). Build commit \`$DEPLOY_SHA\`: $DEPLOY_MSG."
475
+ echo "Deploy complete. $deployed instance(s) running."
476
+ fi