diff --git a/scripts/collie-ctl.sh b/scripts/collie-ctl.sh index 4946164..0cd3674 100755 --- a/scripts/collie-ctl.sh +++ b/scripts/collie-ctl.sh @@ -44,7 +44,52 @@ SERVE_MODE="${COLLIE_SERVE_MODE:-https}" # Records the ONE `tailscale serve` root mount Collie published, so teardown can prove the mapping # it is about to remove is still the one it created. Format: `:||`. TAILSCALE_HANDLER_FILE="${CONFIG_DIR}/tailscale-managed-handler" -BUN="$(command -v bun || true)" +# Find Bun on PATH, then in the usual install locations. +# +# Herdr spawns plugin actions with a minimal environment — no login shell, so nothing has sourced the +# line `bun` puts in your profile and `~/.bun/bin` is simply absent from PATH. `update` therefore +# pulled the new commit and then failed its build, leaving the checkout AHEAD of the web/dist being +# served while every version string reported the new release. The systemd unit is written with an +# absolute ExecStart, so the running service kept working and the breakage was visible only in the +# plugin log — which is how it went unnoticed across four separate invocations. +# +# An empty result is still fine: callers already report "bun not found" and exit. +resolve_bun() { + local candidate + if candidate="$(command -v bun 2>/dev/null)"; then + printf '%s' "$candidate" + return 0 + fi + for candidate in \ + "${BUN_INSTALL:-${HOME}/.bun}/bin/bun" \ + "${HOME}/.bun/bin/bun" \ + "${HOME}/.local/bin/bun" \ + /usr/local/bin/bun \ + /opt/homebrew/bin/bun \ + /usr/bin/bun; do + if [ -x "$candidate" ]; then + printf '%s' "$candidate" + return 0 + fi + done + return 0 +} +BUN="$(resolve_bun)" +# Put it on PATH too, not just in $BUN: this script shells out to a bare `bun` (the Tailscale +# ownership probe), and `bun run build` spawns children that expect to find it themselves. +# +# ABSOLUTE paths only. `command -v` reports a shell function or alias as a bare word, and the plugin +# .env is sourced above us — so a `bun()` defined there would resolve to `bun`, whose dirname is `.`, +# and we'd prepend the CWD to the PATH used for every later `git` / `systemctl` / `tailscale`. +case "$BUN" in + /*) + BUN_DIR="$(dirname "$BUN")" + case ":${PATH}:" in + *":${BUN_DIR}:"*) ;; + *) PATH="${BUN_DIR}:${PATH}"; export PATH ;; + esac + ;; +esac WEB_DIST="${PLUGIN_ROOT}/web/dist/index.html" have_systemd() { command -v systemctl >/dev/null && systemctl --user show-environment >/dev/null 2>&1; } diff --git a/scripts/collie-ctl.test.sh b/scripts/collie-ctl.test.sh index 4986657..be779e7 100755 --- a/scripts/collie-ctl.test.sh +++ b/scripts/collie-ctl.test.sh @@ -321,10 +321,102 @@ EOF assert_contains "$(cat "${CASE_DIR}/start.out")" 'BANNER' } +# A bun that reports only how it was found: its own path, and the PATH it inherited. +install_fake_bun() { + local target="$1" calls="$2" + mkdir -p "$(dirname "$target")" + cat > "$target" < "$calls" +exit 0 +EOF + chmod +x "$target" +} + +# Herdr spawns plugin actions with a minimal environment — no login shell, so ~/.bun/bin is simply +# absent from PATH and resolving with \`command -v bun\` alone found nothing. Because \`update\` pulls +# BEFORE it builds, that left the checkout ahead of the web/dist still being served while every +# version string reported the new release. Pin both halves of the fix: which Bun gets chosen, and +# that its directory reaches child processes on PATH (the Tailscale ownership probe, and the children +# `bun run build` spawns, look up a bare `bun` themselves). +test_bun_resolution() { + setup_case bun-resolution + ln -s "$(command -v dirname)" "${BIN_DIR}/dirname" + local calls="${CASE_DIR}/calls" + + install_fake_bun "${HOME_DIR}/.bun/bin/bun" "$calls" + # PATH holds no bun at all — this IS the Herdr-action environment. Resolution has to reach into + # $HOME, and the fixture wins over any real bun in /usr/bin because ~/.bun/bin is tried first. + # $BUN_INSTALL is scrubbed: a developer running these tests from a shell where Bun's installer + # exported it would otherwise resolve their REAL bun and the fixture would never be consulted. + env -u BUN_INSTALL HOME="$HOME_DIR" HERDR_PLUGIN_CONFIG_DIR="$CONFIG_DIR" PATH="$BIN_DIR" \ + /bin/bash "$CTL" push-test + assert_eq "$(cut -d'|' -f1 "$calls")" "${HOME_DIR}/.bun/bin/bun" + case "$(cut -d'|' -f2- "$calls")" in + "${HOME_DIR}/.bun/bin:"*) ;; + *) fail "resolved Bun's directory never reached children on PATH" ;; + esac + + # $BUN_INSTALL is the operator's explicit choice, so it outranks the default ~/.bun. + install_fake_bun "${CASE_DIR}/alt/bin/bun" "$calls" + HOME="$HOME_DIR" HERDR_PLUGIN_CONFIG_DIR="$CONFIG_DIR" PATH="$BIN_DIR" \ + BUN_INSTALL="${CASE_DIR}/alt" /bin/bash "$CTL" push-test + assert_eq "$(cut -d'|' -f1 "$calls")" "${CASE_DIR}/alt/bin/bun" +} + +# `command -v` reports a function or alias as a BARE word, and the plugin .env is sourced before we +# resolve — so a `bun()` defined there yields dirname `.`, and prepending that would hand every later +# `git` / `systemctl` / `tailscale` a cwd-relative lookup. Only absolute paths reach PATH. +test_non_absolute_bun_never_reaches_path() { + setup_case bun-not-absolute + local harness="${CASE_DIR}/harness.sh" + cat > "$harness" < "${CASE_DIR}/path.out" 2>&1 || + fail "sourcing the script with a bun function failed" + case "$(cat "${CASE_DIR}/path.out")" in + *"PATH=.:"*|*":.:"*) fail "a non-absolute Bun put the CWD on PATH" ;; + esac +} + +# An empty resolution must still be reported and exit non-zero — that message is all an operator on a +# host genuinely without Bun gets, and it's what stops a build from half-finishing. +test_missing_bun_still_reports() { + setup_case bun-missing + local harness="${CASE_DIR}/harness.sh" + cat > "$harness" < "${CASE_DIR}/build.out" 2>&1 + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "cmd_build with no Bun reported success" + assert_contains "$(cat "${CASE_DIR}/build.out")" 'bun not found' +} + test_tailscale_cutovers_and_collisions test_missing_tailscale_cli test_state_delete_failures test_adopts_preexisting_collie_mount test_serve_failure_does_not_abort_start +test_bun_resolution +test_non_absolute_bun_never_reaches_path +test_missing_bun_still_reports echo "collie-ctl lifecycle tests: passed"