Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,22 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html)

## [Unreleased]

### Fix: XACA-0512 — Rework `aiteamforge-migrate.sh::update_launchagents` to render from templates

- **`libexec/commands/aiteamforge-migrate.sh`** — Replaced the in-place `sed -i.bak` / `sed -i.bak2` path-rewrite loop in `update_launchagents()` with a render-from-template flow that mirrors `install-kanban.sh` (and the XACA-0510 fix in `upgrade.sh`). The old code never rendered from canonical templates — it patched whatever plist already existed at `~/Library/LaunchAgents/<agent>`, leaving `.bak`/`.bak2` files behind and silently failing to recover from any drift or hand-editing of the on-disk plist.
- **Three-agent, per-template-family dispatch.** The agent set spans two template directories with incompatible substitution maps, so a single `sed` expression can't serve all three (the XACA-0510 wrinkle that doesn't apply to `upgrade.sh`):
- `com.aiteamforge.kanban-backup.plist` → `share/templates/kanban/backup-plist.template` via `_render_kanban_template()` — substitutes `{{USER_HOME}}`, `{{AITEAMFORGE_DIR}}`, `{{BACKUP_INTERVAL}}`, `{{PYTHON3_PATH}}`.
- `com.aiteamforge.lcars-health.plist` → `share/templates/kanban/lcars-health-plist.template` via the same kanban renderer (harmless extras prevent sibling-drift bugs).
- `com.aiteamforge.fleet-monitor.plist` → `share/templates/fleet-monitor/fleet-launchagent.template.plist` via `_render_fleet_template()` — substitutes `{{NODE_PATH}}`, `{{FLEET_SERVER_PATH}}`, `{{LOG_DIR}}`, `{{HOMEBREW_PREFIX}}`, `{{HOME_DIR}}`, `{{FLEET_PORT}}`, `{{AITEAMFORGE_DIR}}`.
- **Migrate-specific semantic:** `{{AITEAMFORGE_DIR}}` resolves to `${NEW_DATA_DIR}` (the migration's destination), not `${WORKING_DIR}` as in `upgrade.sh`. This is what makes the rendered plist correct after a migration — the rewritten plist now points to the migrated data location.
- **Defaults consolidated for sibling-drift consolidation (XACA-0516):** module-level `readonly XACA_0512_KANBAN_BACKUP_INTERVAL_DEFAULT=900` (mirrors `install-kanban.sh:16`) and `XACA_0512_FLEET_MONITOR_PORT_DEFAULT=3000` (mirrors `install-fleet-monitor.sh:15`). Honors `KANBAN_BACKUP_INTERVAL` / `FLEET_MONITOR_PORT` env overrides at render time.
- **Semantics preserved:** `FORCE=true` re-renders even when target is up to date; `DRY_RUN=true` writes nothing and prints `[DRY RUN] Would update`; `launchctl unload` runs before `mv`, `launchctl load` runs after (both tolerate failure via `2>/dev/null || true`); agents absent from `~/Library/LaunchAgents/` are skipped — migrate must not silently materialise agents the user opted out of at install time.
- **`LAUNCHAGENTS_DIR` seam** — function respects `LAUNCHAGENTS_DIR` env var (defaults to `$HOME/Library/LaunchAgents`) so tests can inject a sandbox path without touching the real user LaunchAgents dir (M3Pro tap-install ban). Same seam pattern as XACA-0510.
- **`_cleanup_migrate_tmpfiles()` + `RETURN` trap** — any `*.new` tempfile created during the render loop is cleaned up on early return or interrupt. No `*.new` leakage on no-op, DRY_RUN, or missing-template paths.
- **`tests/test-xaca-0512-migrate-launchagent-render.sh`** — 17 test cases covering: all three targets absent → all skipped; explicit `.bak`/`.bak2` regression assertion (the old in-place `sed -i.bak` behavior must be gone); kanban + fleet-monitor render with no `{{…}}` placeholders; rendered kanban plist contains resolved `NEW_DATA_DIR` (not `WORKING_DIR`); fleet-monitor renders all seven fleet-specific placeholders and resolves the `FLEET_PORT` default; selective opt-in (kanban present, fleet absent → fleet not materialised); second run no-op (mtime unchanged, no tempfile leak); `FORCE=true` re-renders; `DRY_RUN=true` preserves sentinel content with no tempfile leak on *both* the kanban renderer (3 cases) and the fleet-monitor renderer (1 case, added per PR #32 [Review] subitem XACA-0512-002); missing template warns without crashing; all-three-present rendered cleanly; DRY_RUN + no-change reports "All LaunchAgents up to date".
- **Audit-only on `aiteamforge-migrate-check.sh`** — that script's `analyze_launchagents()` (line 381) has the same `EXPECTED_AGENTS` list but is read-only (presence + `launchctl list` check, no render logic). No change required; documented for completeness.
- **Sibling-heuristic drift, third datapoint:** XACA-0476 (missing `share/` prefix) → XACA-0510 (no template render in `upgrade.sh`) → XACA-0512 (no template render in `migrate.sh`). All three sites in the launchagent-render surface are now consistent.

### Fix: XACA-0510 — Rework `update_launchagents` to render from templates

- **`libexec/commands/aiteamforge-upgrade.sh`** — Added `_render_launchagent_template()` private helper that applies the full `sed` substitution map (`USER_HOME`, `AITEAMFORGE_DIR`, `BACKUP_INTERVAL`, `PYTHON3_PATH`) to any plist template. All four substitutions are applied to every template — harmless extras prevent sibling-drift bugs (per the pattern catalogued in XACA-0501).
Expand Down
171 changes: 144 additions & 27 deletions libexec/commands/aiteamforge-migrate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ ROLLBACK_FROM=""
MIGRATION_STATE_FILE="${HOME}/.aiteamforge/migration-state.json"
MIGRATION_LOG="${HOME}/.aiteamforge/migration.log"

# NOTE: Defaults mirror install-kanban.sh:16 (KANBAN_BACKUP_INTERVAL) and
# install-fleet-monitor.sh:15 (FLEET_MONITOR_PORT). If you change one,
# change the other. Sibling-drift consolidation tracked in XACA-0516.
readonly XACA_0512_KANBAN_BACKUP_INTERVAL_DEFAULT=900
readonly XACA_0512_FLEET_MONITOR_PORT_DEFAULT=3000

# Usage
usage() {
cat <<EOF
Expand Down Expand Up @@ -496,51 +502,162 @@ migrate_user_data() {
echo ""
}

# Cleanup helper invoked by update_launchagents' RETURN trap.
# Removes any *.new tempfiles that survived an interrupt or early return.
# _migrate_tmpfiles is populated by update_launchagents before each render.
_migrate_tmpfiles=()
# shellcheck disable=SC2329 # called indirectly via trap; not a dead function
_cleanup_migrate_tmpfiles() {
local f
for f in "${_migrate_tmpfiles[@]}"; do
rm -f "$f"
done
}

# Render a kanban-family LaunchAgent template to a tempfile.
# Substitution map matches install-kanban.sh:771-775 + the XACA-0510
# extension (PYTHON3_PATH) so every kanban template gets every substitution.
# Usage: _render_kanban_template <template_path> <dest_path>
# Returns: 0 on success, 1 if template not found.
_render_kanban_template() {
local template="$1"
local dest="$2"

if [ ! -f "$template" ]; then
warning "LaunchAgent template not found: $template"
return 1
fi

local kanban_backup_interval="${KANBAN_BACKUP_INTERVAL:-$XACA_0512_KANBAN_BACKUP_INTERVAL_DEFAULT}"
local python3_path
# NOTE: PYTHON3_PATH is re-resolved on every migrate — PATH changes between
# original install and migrate will re-pin the plist interpreter. See XACA-0510.
python3_path="$(command -v python3 2>/dev/null || echo "/usr/bin/python3")"

sed -e "s|{{USER_HOME}}|$HOME|g" \
-e "s|{{AITEAMFORGE_DIR}}|${NEW_DATA_DIR}|g" \
-e "s|{{BACKUP_INTERVAL}}|${kanban_backup_interval}|g" \
-e "s|{{PYTHON3_PATH}}|${python3_path}|g" \
"$template" > "$dest"
}

# Render the fleet-monitor LaunchAgent template to a tempfile.
# Substitution map mirrors install-fleet-monitor.sh:569-577.
# Usage: _render_fleet_template <template_path> <dest_path>
# Returns: 0 on success, 1 if template not found or node missing.
_render_fleet_template() {
local template="$1"
local dest="$2"

if [ ! -f "$template" ]; then
warning "LaunchAgent template not found: $template"
return 1
fi

local node_path
if command -v node >/dev/null 2>&1; then
node_path="$(command -v node)"
elif [ -x "/opt/homebrew/bin/node" ]; then
node_path="/opt/homebrew/bin/node"
else
warning "Node.js not found — skipping fleet-monitor LaunchAgent render"
return 1
fi

local fleet_port="${FLEET_MONITOR_PORT:-$XACA_0512_FLEET_MONITOR_PORT_DEFAULT}"
local homebrew_prefix
homebrew_prefix="$(brew --prefix 2>/dev/null || echo '/opt/homebrew')"

sed -e "s|{{NODE_PATH}}|${node_path}|g" \
-e "s|{{FLEET_SERVER_PATH}}|${NEW_DATA_DIR}/fleet-monitor/server|g" \
-e "s|{{LOG_DIR}}|${NEW_DATA_DIR}/logs|g" \
-e "s|{{HOMEBREW_PREFIX}}|${homebrew_prefix}|g" \
-e "s|{{HOME_DIR}}|$HOME|g" \
-e "s|{{FLEET_PORT}}|${fleet_port}|g" \
-e "s|{{AITEAMFORGE_DIR}}|${NEW_DATA_DIR}|g" \
"$template" > "$dest"
}

update_launchagents() {
section "Updating LaunchAgents"

LAUNCHAGENT_DIR="${HOME}/Library/LaunchAgents"
AGENTS_UPDATED=0
# Resolve framework dir (where templates ship) lazily — get_framework_dir
# is provided by lib/config.sh, sourced at script load.
local framework_dir="${FRAMEWORK_DIR:-$(get_framework_dir)}"

# Allow tests to inject a sandbox path instead of the real LaunchAgents dir.
local launchagents_dir="${LAUNCHAGENTS_DIR:-$HOME/Library/LaunchAgents}"

EXPECTED_AGENTS=(
"com.aiteamforge.kanban-backup.plist"
"com.aiteamforge.lcars-health.plist"
"com.aiteamforge.fleet-monitor.plist"
# Pairs of "plist-filename|render-fn|template-relpath". The render function
# selects which substitution map to apply — kanban vs fleet-monitor have
# different placeholder sets and cannot share a single sed expression.
local agents=(
"com.aiteamforge.kanban-backup.plist|_render_kanban_template|share/templates/kanban/backup-plist.template"
"com.aiteamforge.lcars-health.plist|_render_kanban_template|share/templates/kanban/lcars-health-plist.template"
"com.aiteamforge.fleet-monitor.plist|_render_fleet_template|share/templates/fleet-monitor/fleet-launchagent.template.plist"
)

for agent in "${EXPECTED_AGENTS[@]}"; do
AGENT_PATH="${LAUNCHAGENT_DIR}/${agent}"
local agents_updated=0
# Reset module-level tempfile tracker; RETURN trap cleans up any survivors.
_migrate_tmpfiles=()
trap '_cleanup_migrate_tmpfiles' RETURN

for entry in "${agents[@]}"; do
local agent="${entry%%|*}"
local rest="${entry#*|}"
local render_fn="${rest%%|*}"
local tmpl_relpath="${rest#*|}"
local template="${framework_dir}/${tmpl_relpath}"
local target="${launchagents_dir}/${agent}"
local tmpfile="${target}.new"
_migrate_tmpfiles+=("$tmpfile")

# Opt-in guard: only re-render agents the user already has installed.
# Migrate must not silently materialise agents that weren't there.
if [[ ! -f "${target}" ]]; then
continue
fi

log "Rendering ${agent} from template..."

if [[ -f "${AGENT_PATH}" ]]; then
log "Updating ${agent}..."
# Dispatch to the per-agent render function. Failures (missing template,
# missing node, etc.) are reported by the render fn and we move on.
if ! "$render_fn" "$template" "$tmpfile"; then
continue
fi

# Diff rendered output against live target. If unchanged, no-op.
if ! diff -q "$tmpfile" "$target" >/dev/null 2>&1 || [[ "${FORCE}" == "true" ]]; then
if [[ "${DRY_RUN}" == "true" ]]; then
echo "[DRY RUN] Would update paths in: ${agent}"
echo "[DRY RUN] Would update: ${agent}"
rm -f "$tmpfile"
agents_updated=$((agents_updated + 1))
continue
fi

# Unload first
launchctl unload "${AGENT_PATH}" 2>/dev/null || true

# Update paths in plist
sed -i.bak "s|${HOME}/aiteamforge|${NEW_DATA_DIR}|g" "${AGENT_PATH}"
# Unload current agent (ignore failure — may not be loaded)
launchctl unload "${target}" 2>/dev/null || true

# Also update to use new framework location
sed -i.bak2 "s|${HOME}/aiteamforge/|/opt/homebrew/opt/aiteamforge/libexec/|g" "${AGENT_PATH}"
# Atomically replace with rendered version
mv "$tmpfile" "${target}"

# Reload
launchctl load "${AGENT_PATH}" 2>/dev/null || true
# Reload agent (ignore failure — may need user session context)
launchctl load "${target}" 2>/dev/null || true

AGENTS_UPDATED=$((AGENTS_UPDATED + 1))
success "Updated ${agent}"
agents_updated=$((agents_updated + 1))
else
# No change — clean up tempfile, count as no-op
rm -f "$tmpfile"
fi
done

if [[ "${DRY_RUN}" != "true" ]]; then
if [[ "${AGENTS_UPDATED}" -gt 0 ]]; then
success "LaunchAgents updated: ${AGENTS_UPDATED}"
else
info "No LaunchAgents to update"
fi
if [[ "${agents_updated}" -eq 0 ]]; then
success "All LaunchAgents up to date"
elif [[ "${DRY_RUN}" == "true" ]]; then
success "Would update ${agents_updated} LaunchAgent(s)"
else
success "LaunchAgents updated: ${agents_updated}"
fi

echo ""
Expand Down
Loading
Loading