Skip to content

fix(scripts): find Bun when Herdr invokes a plugin action - #52

Merged
AltanS merged 2 commits into
AltanS:mainfrom
konpyl:upstream-pr/bun-path
Jul 29, 2026
Merged

fix(scripts): find Bun when Herdr invokes a plugin action#52
AltanS merged 2 commits into
AltanS:mainfrom
konpyl:upstream-pr/bun-path

Conversation

@konpyl

@konpyl konpyl commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

herdr plugin action invoke update fails with error: bun not found on PATH.

Herdr spawns plugin actions with a minimal environment — no login shell, so nothing has sourced the line Bun's installer adds to your profile and ~/.bun/bin isn't on PATH. collie-ctl.sh:47 resolves Bun with command -v bun alone, so every action that needs it dies.

Why this is worth fixing rather than documenting

It's not that it fails, it's how:

  • The systemd unit is written with an absolute ExecStart, so the running bridge is unaffected. Only plugin-invoked actions break — update, restart, start.

  • update pulls before it builds:

    cmd_update() {
      git -C "$PLUGIN_ROOT" pull --ff-only
      exec bash "${PLUGIN_ROOT}/scripts/collie-ctl.sh" _apply-update   # ← dies here
    }

    So a failed run still fast-forwards the checkout. You end up with new code on disk, the old web/dist still being served, and both the manifest and /api/config reporting the new version. A half-succeeded deploy is indistinguishable from a working one unless you go read the plugin log.

On my deployment it failed this way four times before I noticed — I only caught it because I checked the built bundle rather than the version string.

The change

Resolve Bun from PATH first, then $BUN_INSTALL, ~/.bun/bin, ~/.local/bin, /usr/local/bin, /opt/homebrew/bin, /usr/bin.

Its directory is prepended to PATH, not just stored in $BUN: the script shells out to a bare bun for the Tailscale ownership probe (collie-ctl.sh:94), and bun run build spawns children that expect to find it themselves.

An empty result stays non-fatal — the existing [ -n "$BUN" ] || { echo "error: bun not found on PATH"; exit 1; } guards still report and exit, so a host genuinely without Bun gets the same message as before.

Verification

  • Reproduced the failing environment with env -i HOME=$HOME PATH=/usr/bin:/bin: resolves and puts Bun on PATH.
  • bash -n clean; bun run test (474 tests incl. the collie-ctl lifecycle suite) passes on top of main.
  • End to end on a real deployment: the same action that had been failing now completes. It repairs itself on the first run, since cmd_update re-execs the just-pulled script.

One commit, one file, no version bump or CHANGELOG entry — this comes from a fork with its own numbering lane, so slotting it into your release is yours to do.

🤖 Generated with Claude Code

konpyl and others added 2 commits July 29, 2026 23:16
`herdr plugin action invoke update` fails with "bun not found on PATH".

Herdr spawns plugin actions with a minimal environment: no login shell, so
nothing has sourced the line Bun's installer adds to your profile, and
~/.bun/bin is not on PATH. collie-ctl.sh resolved Bun with `command -v bun`
alone, so every action needing it died.

What makes this easy to miss is how it fails rather than that it fails. The
systemd unit is written with an ABSOLUTE ExecStart, so the running bridge is
unaffected — only plugin-invoked actions break. And `update` pulls BEFORE it
builds, so a failed run still fast-forwards the checkout: you end up with new
code on disk, the OLD web/dist still being served, and both the manifest and
/api/config reporting the new version. A half-succeeded deploy is
indistinguishable from a working one unless you read the plugin log. It failed
this way four times on my deployment before I noticed.

Bun is now resolved from PATH first, then $BUN_INSTALL, ~/.bun/bin,
~/.local/bin, /usr/local/bin, /opt/homebrew/bin and /usr/bin, and its directory
is prepended to PATH rather than only remembered in $BUN — this script shells
out to a bare `bun` for the Tailscale ownership probe, and `bun run build`
spawns children that expect to find it themselves. An empty result stays
non-fatal, so the existing "bun not found" messages still report and exit.

Verified in a reproduction of the failing environment (`env -i` with
PATH=/usr/bin:/bin), and end to end on a real deployment: the same action that
had been failing now completes, because `update` re-execs the just-pulled
script and so repairs itself on the first run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… tests

Review fixes on top of the Bun resolution fix.

`command -v` reports a shell function or alias as a bare word, and the plugin .env is sourced
before we resolve — so a `bun()` left in there resolved to `bun`, whose dirname is `.`. Prepending
that handed every later `git` / `systemctl` / `tailscale` a cwd-relative lookup. Restrict the
prepend to absolute paths; an empty or bare result now falls through to the existing guards
unchanged.

The control script's resolution had no coverage at all — the lifecycle suite stubs `BUN=/bin/true`
everywhere — which is why a missing Bun could fail four deploys unnoticed. Three tests:
resolution reaching into $HOME when PATH holds no bun (and $BUN_INSTALL outranking it), the
directory reaching children on PATH, the CWD never doing so, and the empty case still reporting
and exiting non-zero. They fail against the unpatched script with the original
`error: bun not found on PATH`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@AltanS
AltanS merged commit 6071de4 into AltanS:main Jul 29, 2026
1 check passed
@AltanS

AltanS commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Shipped in 0.20.2. Added the absolute-path guard on the PATH prepend (command -v reports a bun() from the .env as a bare word, so the CWD was reaching git/systemctl) and the resolution's first test coverage. The write-up is what made this quick to confirm — thank you.

konpyl added a commit to konpyl/collie that referenced this pull request Jul 30, 2026
Cherry-pick of upstream 4841e37, which the maintainer wrote reviewing the Bun
resolution fix this fork contributed (merged as PR AltanS#52). It is a real bug in
code this fork has been running since 0.23.1 and deployed again at 0.27.0.

`command -v` reports a shell function or alias as a BARE WORD, and the plugin's
.env is sourced before we resolve — so a `bun()` left in there resolves to
`bun`, whose dirname is `.`. My version prepended that unconditionally, handing
every later `git` / `systemctl` / `tailscale` in the script a cwd-relative
lookup. Reproduced before applying the fix:

    bun() { :; }
    command -v bun  -> 'bun'
    dirname         -> '.'
    old: PATH now starts with .
    new: PATH now starts with /home/kon/.bun/bin

The prepend is now restricted to absolute paths; an empty or bare result falls
through to the existing "bun not found" guards unchanged.

It also brings the three tests the resolution never had — the lifecycle suite
stubs BUN=/bin/true everywhere, which is exactly why a missing Bun could fail
four deploys unnoticed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
darieldatoon added a commit to darieldatoon/collie that referenced this pull request Jul 30, 2026
Closes AltanS#55.

ARCHITECTURE.md §3 already describes the macOS deployment as a "launchd agent … starts at login,
restarts on failure, survives Herdr restarts". No agent existed: `launchd` appeared exactly once in
the repo, in that sentence. On macOS `have_systemd()` is false, so `cmd_start` fell through to the
unsupervised `nohup` + pidfile branch — nothing restarted a crashed bridge and nothing brought it
back after a reboot, on a host the README lists as supported. From a phone that is indistinguishable
from a sleeping host, because the `tailscale serve` mapping keeps resolving.

`cmd_start`, `cmd_stop`, `cmd_uninstall` and `print_status_banner` now branch systemd → launchd →
nohup. `write_agent()` mirrors `write_unit()` key for key: RunAtLoad for WantedBy=default.target,
KeepAlive/SuccessfulExit=false for Restart=on-failure, ThrottleInterval for RestartSec.
StartLimitIntervalSec has no analogue — launchd has no start limit to lift. NoNewPrivileges and
PrivateTmp are not representable, so the macOS agent is less confined than the Linux unit; that is
called out in the comment rather than left to be discovered.

The plist carries paths only, never config values. launchd has no EnvironmentFile=, so the obvious
port bakes the sourced .env into EnvironmentVariables — but .env is mode 600 and may hold
COLLIE_VAPID_PRIVATE, while a LaunchAgent plist has to stay readable. Baking would copy a Web Push
signing key into a readable file, and would freeze config the README documents as re-read on
restart. Instead the agent re-enters the script as the internal `_exec-bridge` step, which sources
.env itself and `exec`s Bun so launchd supervises the bridge pid directly rather than a wrapper.
HERDR_PLUGIN_CONFIG_DIR is passed in the plist so resolve_config_dir() short-circuits: at login the
Herdr server may not be up, and shelling out to `herdr plugin config-dir` would hang or resolve the
fallback dir.

`start` boots the label out before bootstrapping, so it is idempotent — bootstrap on a loaded label
is an error, and quietly starting a second bridge is the failure this branch removes. `stop` boots
the job out of the domain, so KeepAlive cannot resurrect what an operator just stopped.

Interpolated paths go through xml_escape: a checkout path containing `&` would otherwise emit a
plist launchd refuses to parse.

Tests: the suite gained a fake `launchctl` on the scratch PATH for every case, because without it a
macOS run bootstrapped a real job into the developer's own gui/<uid> domain, pointed at a temp dir
the suite then deleted — it crash-looped after the tests reported success. The lifecycle test stubs
have_launchd rather than trusting `uname`, since CI is ubuntu-latest and a test that silently skips
its own branch is worse than no test. Two assertions carry the weight: a .env seeded with a fake
COLLIE_VAPID_PRIVATE must leave no trace in the plist, and the plist must survive `plutil -lint` —
one launchd cannot parse means the agent silently never starts, which no substring check would
notice. The lint is guarded on `command -v plutil`, so it no-ops on the ubuntu runner and covers
every macOS dev machine. The banner's launchd line is covered separately, because the lifecycle test
stubs print_status_banner and therefore read none of it — a first cut printed the pid twice and every
lifecycle assertion still passed. The serve-failure test now pins have_launchd off, as the nohup
fallback is what it exists to cover.

Verified on macOS 26.5.2 (arm64) under the system bash 3.2 the plist actually invokes, against a real
herdr herd: start bootstraps and serves, a second start leaves exactly one bridge, SIGKILL is
recovered in ~1s, `stop` boots the job out and it stays down, and the generated plist round-trips
through `plutil` with a config dir containing `&` and `<`.

Version deliberately left at 0.20.2 with no CHANGELOG entry, matching AltanS#52 — the release commit is
the maintainer's to cut.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
darieldatoon added a commit to darieldatoon/collie that referenced this pull request Jul 30, 2026
Closes AltanS#55.

ARCHITECTURE.md §3 already describes the macOS deployment as a "launchd agent … starts at login,
restarts on failure, survives Herdr restarts". No agent existed: `launchd` appeared exactly once in
the repo, in that sentence. On macOS `have_systemd()` is false, so `cmd_start` fell through to the
unsupervised `nohup` + pidfile branch — nothing restarted a crashed bridge and nothing brought it
back after a reboot, on a host the README lists as supported. From a phone that is indistinguishable
from a sleeping host, because the `tailscale serve` mapping keeps resolving.

`cmd_start`, `cmd_stop`, `cmd_uninstall` and `print_status_banner` now branch systemd → launchd →
nohup. `write_agent()` mirrors `write_unit()` key for key: RunAtLoad for WantedBy=default.target,
KeepAlive/SuccessfulExit=false for Restart=on-failure, ThrottleInterval for RestartSec.
StartLimitIntervalSec has no analogue — launchd has no start limit to lift. NoNewPrivileges and
PrivateTmp are not representable, so the macOS agent is less confined than the Linux unit; that is
called out in the comment rather than left to be discovered.

The plist carries paths only, never config values. launchd has no EnvironmentFile=, so the obvious
port bakes the sourced .env into EnvironmentVariables — but .env is mode 600 and may hold
COLLIE_VAPID_PRIVATE, while a LaunchAgent plist has to stay readable. Baking would copy a Web Push
signing key into a readable file, and would freeze config the README documents as re-read on
restart. Instead the agent re-enters the script as the internal `_exec-bridge` step, which sources
.env itself and `exec`s Bun so launchd supervises the bridge pid directly rather than a wrapper.
HERDR_PLUGIN_CONFIG_DIR is passed in the plist so resolve_config_dir() short-circuits: at login the
Herdr server may not be up, and shelling out to `herdr plugin config-dir` would hang or resolve the
fallback dir.

`start` boots the label out before bootstrapping, so it is idempotent — bootstrap on a loaded label
is an error, and quietly starting a second bridge is the failure this branch removes. `stop` pairs
`disable` with `bootout`: bootout alone stops only the current process, and because LaunchAgents load
at login, RunAtLoad would then resurrect a bridge the operator had deliberately stopped — `disable`
is what makes `stop` persist, matching `systemctl --user disable --now`. `start` re-`enable`s, so a
stopped agent is not wedged.

macOS installs predating this change are running the nohup fallback, whose bridge still owns the port
when the updated script first bootstraps an agent, so `start` and `stop` release it first. Only a
numeric, non-system pid that `ps` still shows running our own `bridge/index.ts` is signalled: the
pidfile outlives its process (SIGKILL, a panic, a reboot) and the OS recycles pids, and this runs on
the `start` path, so a stale record must not kill an unrelated process during a routine start.

Interpolated paths go through xml_escape: a checkout path containing `&` would otherwise emit a
plist launchd refuses to parse.

Tests: the suite gained a fake `launchctl` on the scratch PATH for every case, because without it a
macOS run bootstrapped a real job into the developer's own gui/<uid> domain, pointed at a temp dir
the suite then deleted — it crash-looped after the tests reported success. The lifecycle test stubs
have_launchd rather than trusting `uname`, since CI is ubuntu-latest and a test that silently skips
its own branch is worse than no test. Its load-bearing assertions are negative: a .env seeded with a
fake COLLIE_VAPID_PRIVATE must leave no trace in the plist; a pidfile naming a recycled pid must be
discarded without being signalled while a pid still holding the bridge is; and the plist must survive
`plutil -lint`, since one launchd cannot parse means the agent silently never starts and no substring
check would notice. The lint is guarded on `command -v plutil`, so it no-ops on the ubuntu runner and
covers every macOS dev machine. The banner's launchd line is covered separately, because the
lifecycle test stubs print_status_banner and therefore reads none of it — a first cut printed the pid
twice and every lifecycle assertion still passed. The serve-failure test now pins have_launchd off,
as the nohup fallback is what it exists to cover.

Verified on macOS 26.5.2 (arm64) under the system bash 3.2 the plist actually invokes, against a real
herdr herd: start bootstraps and serves, a second start leaves exactly one bridge, SIGKILL is
recovered in ~1s, `disable` + `bootout` genuinely blocks a re-bootstrap until `enable` (so `stop`
survives a re-login), the ownership guard matches a live bridge's command line and not `launchd(1)`,
and the generated plist round-trips through `plutil` with a config dir containing `&` and `<`.

Version deliberately left at 0.20.2 with no CHANGELOG entry, matching AltanS#52 — the release commit is
the maintainer's to cut.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
darieldatoon added a commit to darieldatoon/collie that referenced this pull request Jul 30, 2026
Closes AltanS#55.

ARCHITECTURE.md §3 already describes the macOS deployment as a "launchd agent … starts at login,
restarts on failure, survives Herdr restarts". No agent existed: `launchd` appeared exactly once in
the repo, in that sentence. On macOS `have_systemd()` is false, so `cmd_start` fell through to the
unsupervised `nohup` + pidfile branch — nothing restarted a crashed bridge and nothing brought it
back after a reboot, on a host the README lists as supported. From a phone that is indistinguishable
from a sleeping host, because the `tailscale serve` mapping keeps resolving.

`cmd_start`, `cmd_stop`, `cmd_uninstall` and `print_status_banner` now branch systemd → launchd →
nohup. `write_agent()` mirrors `write_unit()` key for key: RunAtLoad for WantedBy=default.target,
KeepAlive/SuccessfulExit=false for Restart=on-failure, ThrottleInterval for RestartSec.
StartLimitIntervalSec has no analogue — launchd has no start limit to lift. NoNewPrivileges and
PrivateTmp are not representable, so the macOS agent is less confined than the Linux unit; that is
called out in the comment rather than left to be discovered.

The plist carries paths only, never config values. launchd has no EnvironmentFile=, so the obvious
port bakes the sourced .env into EnvironmentVariables — but .env is mode 600 and may hold
COLLIE_VAPID_PRIVATE, while a LaunchAgent plist has to stay readable. Baking would copy a Web Push
signing key into a readable file, and would freeze config the README documents as re-read on
restart. Instead the agent re-enters the script as the internal `_exec-bridge` step, which sources
.env itself and `exec`s Bun so launchd supervises the bridge pid directly rather than a wrapper.
HERDR_PLUGIN_CONFIG_DIR is passed in the plist so resolve_config_dir() short-circuits: at login the
Herdr server may not be up, and shelling out to `herdr plugin config-dir` would hang or resolve the
fallback dir.

`start` boots the label out before bootstrapping, so it is idempotent — bootstrap on a loaded label
is an error, and quietly starting a second bridge is the failure this branch removes. `stop` pairs
`disable` with `bootout`: bootout alone stops only the current process, and because LaunchAgents load
at login, RunAtLoad would then resurrect a bridge the operator had deliberately stopped — `disable`
is what makes `stop` persist, matching `systemctl --user disable --now`. `start` re-`enable`s, so a
stopped agent is not wedged.

That `disable` is a record in launchd's per-user database, and it outlives the plist, so `uninstall`
clears it — otherwise the inverse of `start` would leave a label a later reinstall inherits as
disabled. It removes the plist first, since an enabled label whose plist is still on disk is one
login away from loading again. `enable` is the only supported inverse: it resets the state rather
than deleting the record, so a benign "enabled" entry can remain (deleting that needs root).

macOS installs predating this change are running the nohup fallback, whose bridge still owns the port
when the updated script first bootstraps an agent, so `start` and `stop` release it first. Only a
numeric, non-system pid that `ps` still shows running our own `bridge/index.ts` is signalled: the
pidfile outlives its process (SIGKILL, a panic, a reboot) and the OS recycles pids, and this runs on
the `start` path, so a stale record must not kill an unrelated process during a routine start.

Interpolated paths go through xml_escape: a checkout path containing `&` would otherwise emit a
plist launchd refuses to parse.

Tests: the suite gained a fake `launchctl` on the scratch PATH for every case, because without it a
macOS run bootstrapped a real job into the developer's own gui/<uid> domain, pointed at a temp dir
the suite then deleted — it crash-looped after the tests reported success. The lifecycle test stubs
have_launchd rather than trusting `uname`, since CI is ubuntu-latest and a test that silently skips
its own branch is worse than no test. Its load-bearing assertions are negative: a .env seeded with a
fake COLLIE_VAPID_PRIVATE must leave no trace in the plist; a pidfile naming a recycled pid must be
discarded without being signalled while a pid still holding the bridge is; and the plist must survive
`plutil -lint`, since one launchd cannot parse means the agent silently never starts and no substring
check would notice. The lint is guarded on `command -v plutil`, so it no-ops on the ubuntu runner and
covers every macOS dev machine. The uninstall assertions run against a truncated call log, because
`start` already records an `enable` and asserting on the whole log would pass either way. The
banner's launchd line is covered separately, because the lifecycle test stubs print_status_banner and
therefore reads none of it — a first cut printed the pid twice and every lifecycle assertion still
passed. The serve-failure test now pins have_launchd off, as the nohup fallback is what it exists to
cover.

Verified on macOS 26.5.2 (arm64) under the system bash 3.2 the plist actually invokes, against a real
herdr herd: start bootstraps and serves, a second start leaves exactly one bridge, SIGKILL is
recovered in ~1s, `disable` + `bootout` genuinely blocks a re-bootstrap until `enable` (so `stop`
survives a re-login), a start → stop → uninstall cycle against real `launchctl` moves the override
enabled → disabled → enabled with the plist gone and the job unloaded, the ownership guard matches a
live bridge's command line and not `launchd(1)`, and the generated plist round-trips through `plutil`
with a config dir containing `&` and `<`.

Version deliberately left at 0.20.2 with no CHANGELOG entry, matching AltanS#52 — the release commit is
the maintainer's to cut.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
darieldatoon added a commit to darieldatoon/collie that referenced this pull request Jul 30, 2026
Closes AltanS#55.

ARCHITECTURE.md §3 already describes the macOS deployment as a "launchd agent … starts at login,
restarts on failure" — and `launchd` appeared exactly once in the repo, in that sentence. On macOS
`have_systemd()` is false, so `cmd_start` fell through to the unsupervised nohup + pidfile branch:
nothing restarted a crashed bridge and nothing brought it back after a reboot, on a host the README
lists as supported. From a phone that is indistinguishable from a sleeping host, because the
`tailscale serve` mapping keeps resolving.

`cmd_start`, `cmd_stop`, `cmd_uninstall` and `print_status_banner` now branch systemd → launchd →
nohup, with `write_agent()` mirroring `write_unit()` key for key — mapping in the comment there.
No ProcessType: Background throttles CPU and I/O, which the unit never does to the bridge.

Two decisions worth stating here. The plist holds paths only: launchd has no `EnvironmentFile=`, and
.env is mode 600 and may hold COLLIE_VAPID_PRIVATE, so baking config into a readable plist would copy
a Web Push signing key out of a protected file — the agent re-enters this script as `_exec-bridge`
and sources .env itself. And `stop` pairs `disable` with `bootout`, because bootout alone leaves
RunAtLoad to resurrect a bridge the operator stopped; `start` re-enables, and `uninstall` clears that
override so it stays the true inverse of `start`.

Installs predating this still own the port through the nohup fallback, so `start` and `stop` release
it — signalling only a pid `ps` still shows running our own bridge, since the pidfile outlives its
process and pids get recycled.

Tests: a fake `launchctl` on the scratch PATH for every case. Without it a macOS run bootstrapped a
real job into the developer's own gui/<uid> domain, aimed at a temp dir the suite then deleted, and it
crash-looped after the tests reported success. `have_launchd` is stubbed rather than trusted, since CI
is ubuntu-latest. The assertions that matter are negative: a seeded COLLIE_VAPID_PRIVATE leaves no
trace in the plist, a recycled pid is discarded rather than signalled, and the plist survives
`plutil -lint` (guarded on the binary, so it no-ops on CI). The banner is covered separately — a first
cut printed the pid twice and every other assertion still passed.

Verified on macOS 26.5.2 under the system bash 3.2 the plist invokes, against a real herd: start
serves and is idempotent, SIGKILL recovers in ~1s, disable + bootout blocks a re-bootstrap until
enable, and start → stop → uninstall moves the override enabled → disabled → enabled with the plist
gone. Not covered: an actual logout/login cycle.

Version left at 0.20.2 with no CHANGELOG entry, matching AltanS#52 — the release commit is yours to cut.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@konpyl
konpyl deleted the upstream-pr/bun-path branch July 31, 2026 10:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants