diff --git a/LaunchAgents/README.md b/LaunchAgents/README.md index 528c136..15aa3a2 100644 --- a/LaunchAgents/README.md +++ b/LaunchAgents/README.md @@ -94,6 +94,8 @@ Agents that only make sense on a specific platform live in a subdirectory whose To add a new gate, add a predicate to `functions.sh` and a matching loop in `symlinks.sh`. Drop plists in the corresponding subdirectory; nothing else changes. +Moving a plist between gates (e.g. top-level -> `arm64-macos/`) leaves behind a dangling `~/Library/LaunchAgents` symlink pointing at the old path. `symlinks.sh` detects these and repoints them to the plist's new location, then force-reloads via `launchctl unload`/`load`. This reload is necessary because a reboot alone isn't enough: launchd caches the already-loaded job in memory and won't notice the plist moved until its next full re-scan, which can itself silently fail if the symlink is still dangling at that point. + Manual installation: ```bash diff --git a/functions.sh b/functions.sh index 48c6347..3b65a0c 100755 --- a/functions.sh +++ b/functions.sh @@ -153,6 +153,47 @@ link() { fi } +# Detect ~/Library/LaunchAgents symlinks that are dangling because their repo +# plist moved (e.g. a platform-gating move like LaunchAgents/foo.plist -> +# LaunchAgents/arm64-macos/foo.plist). Repoint them and force launchd to +# reload, since launchd runs an already-loaded job from memory and won't +# notice the plist moved until the next full re-scan (reboot). +repoint_dangling_launchagents() { + local agents_dir="$HOME/Library/LaunchAgents" + [ -d "$agents_dir" ] || return 0 + + for target in "$agents_dir"/*.plist; do + [ -e "$target" ] && continue # not dangling (valid symlink, real file, or no match) + [ -L "$target" ] || continue # unmatched glob literal / not a symlink at all + + local name + local current_repo_path="" + name="$(basename "$target")" + + if running_arm64_macos && [ -d "$DIR/LaunchAgents/arm64-macos" ]; then + current_repo_path="$(find "$DIR/LaunchAgents/arm64-macos" -maxdepth 1 -name "$name" -type f)" + fi + if [ -z "$current_repo_path" ]; then + current_repo_path="$(find "$DIR/LaunchAgents" -maxdepth 1 -name "$name" -type f)" + fi + + if [ -z "$current_repo_path" ]; then + echo "⚠️ $target -> dangling, no applicable repo plist found (skipping)" + continue + fi + + local relative="${current_repo_path#"$DIR"/}" + echo "🔧 $target -> dangling, repo plist now at $relative" + link "$relative" "$target" + + if [ "$(readlink "$target")" = "$current_repo_path" ]; then + echo "🔄 reloading $name" + launchctl unload "$target" 2> /dev/null || true + launchctl load "$target" 2> /dev/null || true + fi + done +} + brew_bundle() { echo "🍻 running brew bundle" cat Brewfile "Brewfile.${DOTPICKLES_ROLE}" 2> /dev/null | brew bundle --file=- 2>&1 | sed 's/^/ → /' diff --git a/scripts/test-repoint-dangling-launchagents.sh b/scripts/test-repoint-dangling-launchagents.sh new file mode 100755 index 0000000..29aa0a5 --- /dev/null +++ b/scripts/test-repoint-dangling-launchagents.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +# Manual verification for repoint_dangling_launchagents() (functions.sh). +# +# Uses a temp $HOME/Library/LaunchAgents and a temp fake $DIR (repo root) so no +# real LaunchAgents state is touched. Stubs launchctl for the same reason. +# +# Usage: +# ./scripts/test-repoint-dangling-launchagents.sh + +set -e + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# Auto-yes: this harness is non-interactive and link() prompts on a +# repointed/wrong symlink. +export DOTPICKLES_YES=1 + +# Stub launchctl so the harness never touches real launchd state. +launchctl() { + echo " (stub) launchctl $*" + return 0 +} + +# shellcheck source=../functions.sh +source "$REPO_ROOT/functions.sh" + +TEST_DIR="$(mktemp -d)" +trap 'rm -rf "$TEST_DIR"' EXIT + +echo "=== Test directory: $TEST_DIR ===" +echo + +# --- Test 1: dangling symlink, matching plist still at the top level -> repointed --- +echo "--- Test 1: dangling symlink, plist found at top level ---" +export DIR="$TEST_DIR/repo1" +export HOME="$TEST_DIR/home1" +mkdir -p "$DIR/LaunchAgents" "$HOME/Library/LaunchAgents" +echo "" > "$DIR/LaunchAgents/com.example.foo.plist" +ln -s "$DIR/LaunchAgents/old-location/com.example.foo.plist" "$HOME/Library/LaunchAgents/com.example.foo.plist" + +repoint_dangling_launchagents + +resolved="$(readlink "$HOME/Library/LaunchAgents/com.example.foo.plist")" +if [ "$resolved" = "$DIR/LaunchAgents/com.example.foo.plist" ]; then + echo "PASS: dangling symlink repointed to current repo plist" +else + echo "FAIL: expected repoint to $DIR/LaunchAgents/com.example.foo.plist, got $resolved" +fi +echo + +# --- Test 2: dangling symlink, plist only under arm64-macos/ -> repointed via fallback --- +echo "--- Test 2: dangling symlink, plist found via arm64-macos fallback ---" +export DIR="$TEST_DIR/repo2" +export HOME="$TEST_DIR/home2" +mkdir -p "$DIR/LaunchAgents/arm64-macos" "$HOME/Library/LaunchAgents" +echo "" > "$DIR/LaunchAgents/arm64-macos/com.example.bar.plist" +ln -s "$DIR/LaunchAgents/com.example.bar.plist" "$HOME/Library/LaunchAgents/com.example.bar.plist" + +# Force the arm64 branch regardless of the machine actually running this +# harness -- running_arm64_macos shells out to `uname -m`, which we can't +# fake portably, so override the function itself in a subshell, scoped to +# this one test only. +( + # shellcheck disable=SC2329 # invoked indirectly, from repoint_dangling_launchagents + running_arm64_macos() { return 0; } + repoint_dangling_launchagents +) + +resolved="$(readlink "$HOME/Library/LaunchAgents/com.example.bar.plist")" +if [ "$resolved" = "$DIR/LaunchAgents/arm64-macos/com.example.bar.plist" ]; then + echo "PASS: dangling symlink repointed via arm64-macos fallback" +else + echo "FAIL: expected repoint to $DIR/LaunchAgents/arm64-macos/com.example.bar.plist, got $resolved" +fi +echo + +# --- Test 3: dangling symlink, no matching plist anywhere -> left alone --- +echo "--- Test 3: dangling symlink, no matching repo plist ---" +export DIR="$TEST_DIR/repo3" +export HOME="$TEST_DIR/home3" +mkdir -p "$DIR/LaunchAgents" "$HOME/Library/LaunchAgents" +ln -s "$DIR/LaunchAgents/gone/com.example.baz.plist" "$HOME/Library/LaunchAgents/com.example.baz.plist" + +output="$(repoint_dangling_launchagents 2>&1)" + +if [ -L "$HOME/Library/LaunchAgents/com.example.baz.plist" ] && [ ! -e "$HOME/Library/LaunchAgents/com.example.baz.plist" ]; then + echo "PASS: unmatched dangling symlink left in place (still dangling, not deleted)" +else + echo "FAIL: unmatched dangling symlink was modified or removed" +fi + +if echo "$output" | grep -q "no applicable repo plist found"; then + echo "PASS: warning printed for unmatched dangling symlink" +else + echo "FAIL: expected warning about no applicable repo plist, got: $output" +fi +echo + +# --- Test 4: dangling symlink, plist exists at BOTH top level and arm64-macos/ -> arm64-macos wins --- +echo "--- Test 4: dangling symlink, plist in both locations, arm64-macos takes precedence ---" +export DIR="$TEST_DIR/repo4" +export HOME="$TEST_DIR/home4" +mkdir -p "$DIR/LaunchAgents/arm64-macos" "$HOME/Library/LaunchAgents" +echo "" > "$DIR/LaunchAgents/com.example.qux.plist" +echo "" > "$DIR/LaunchAgents/arm64-macos/com.example.qux.plist" +ln -s "$DIR/LaunchAgents/old-location/com.example.qux.plist" "$HOME/Library/LaunchAgents/com.example.qux.plist" + +# Force the arm64 branch, same technique as Test 2. +( + # shellcheck disable=SC2329 # invoked indirectly, from repoint_dangling_launchagents + running_arm64_macos() { return 0; } + repoint_dangling_launchagents +) + +resolved="$(readlink "$HOME/Library/LaunchAgents/com.example.qux.plist")" +if [ "$resolved" = "$DIR/LaunchAgents/arm64-macos/com.example.qux.plist" ]; then + echo "PASS: dangling symlink repointed to arm64-macos plist (matches symlinks.sh link order)" +else + echo "FAIL: expected repoint to $DIR/LaunchAgents/arm64-macos/com.example.qux.plist, got $resolved" +fi +echo + +# --- Test 5: two dangling symlinks in one invocation, matched then unmatched --- +# Regression test for a bug where `local name current_repo_path` (bare +# re-declaration, no assignment) failed to reset current_repo_path between +# loop iterations, so the second (unmatched) symlink silently inherited the +# first (matched) symlink's resolved path instead of being reported as +# unmatched. Glob order matters here: com.example.aaa sorts before +# com.example.zzz, so aaa (matched) is processed first and zzz (unmatched) +# second -- the exact order that exposed the bug. +echo "--- Test 5: multiple dangling symlinks, matched then unmatched, in one call ---" +export DIR="$TEST_DIR/repo5" +export HOME="$TEST_DIR/home5" +mkdir -p "$DIR/LaunchAgents" "$HOME/Library/LaunchAgents" +echo "" > "$DIR/LaunchAgents/com.example.aaa.plist" +ln -s "$DIR/LaunchAgents/old-location/com.example.aaa.plist" "$HOME/Library/LaunchAgents/com.example.aaa.plist" +ln -s "$DIR/LaunchAgents/gone/com.example.zzz.plist" "$HOME/Library/LaunchAgents/com.example.zzz.plist" + +output="$(repoint_dangling_launchagents 2>&1)" + +resolved_aaa="$(readlink "$HOME/Library/LaunchAgents/com.example.aaa.plist")" +if [ "$resolved_aaa" = "$DIR/LaunchAgents/com.example.aaa.plist" ]; then + echo "PASS: matched symlink (aaa) repointed to its own repo plist" +else + echo "FAIL: expected aaa repoint to $DIR/LaunchAgents/com.example.aaa.plist, got $resolved_aaa" +fi + +resolved_zzz="$(readlink "$HOME/Library/LaunchAgents/com.example.zzz.plist")" +if [ "$resolved_zzz" = "$DIR/LaunchAgents/gone/com.example.zzz.plist" ] && [ ! -e "$HOME/Library/LaunchAgents/com.example.zzz.plist" ]; then + echo "PASS: unmatched symlink (zzz) left dangling, not repointed to aaa's target" +else + echo "FAIL: unmatched symlink (zzz) was repointed (got $resolved_zzz), expected to remain dangling at its original target" +fi + +if echo "$output" | grep -q "com.example.zzz.plist -> dangling, no applicable repo plist found"; then + echo "PASS: warning printed for the unmatched symlink (zzz)" +else + echo "FAIL: expected warning about zzz having no applicable repo plist, got: $output" +fi +echo + +echo "=== Done ===" diff --git a/symlinks.sh b/symlinks.sh index c15a211..c786f55 100755 --- a/symlinks.sh +++ b/symlinks.sh @@ -33,6 +33,7 @@ link_directory_contents config # Link LaunchAgents if on macOS if running_macos; then mkdir -p "$HOME/Library/LaunchAgents" + repoint_dangling_launchagents echo "🚀 linking LaunchAgents" for agent in LaunchAgents/*.plist; do if [ -f "$agent" ]; then