Skip to content

bug: post-update harness refresh fails with ENOENT after brew upgrade #63

Description

@blimmer

What happened?

contextbridge update's post-update harness refresh (added in #52) silently no-ops for every Homebrew-installed user because the spawn target path was deleted by brew before the spawn fires.

runUpdate in packages/cli/src/commands/update.ts calls commandRunner.run(process.execPath, ['install', '<harness-id>', '--scope', '<scope>']) to re-run the new binary's per-harness installer. But process.execPath is captured at process start — for brew-installed binaries this is the cellar path of the pre-upgrade version (e.g., /opt/homebrew/Caskroom/cli@alpha/0.3.0-alpha.1/contextbridge). Brew's --cask upgrade flow then runs Purging files for version 0.3.0-alpha.1, deleting that whole cellar directory. By the time runUpdate reaches the spawn, the path is gone — Node fails with ENOENT.

The refresh-failure logging from #52 correctly downgrades this to a warning so update itself still exits 0 with ✓ update complete., but the actual per-harness re-install never runs. The post-update refresh feature — the whole reason #52 was filed — is effectively dead for brew users.

Discovered live during alpha.1 → alpha.2 upgrade testing of #62.

contextbridge version

0.3.0-alpha.2 (and 0.3.0-alpha.1; the failure is in the upgrade transition itself, observable on every brew-driven version bump)

Operating system

macOS

Steps to reproduce

  1. Install contextbridge via the brew tap (e.g., brew install contextbridge/tap/cli@alpha).
  2. Run contextbridge install claude --yes so a managed harness exists for the refresh to act on.
  3. Wait for (or simulate) a newer release on the same channel.
  4. Run LOG_LEVEL=trace contextbridge update.
  5. Observe ENOENT warnings for each managed harness while update exits 0.

What did you expect to happen?

After the binary swap, runUpdate re-runs the new binary's install <harness> for each managed scope. Trace should show the per-harness install shellouts firing against the post-upgrade binary path.

What actually happened?

Brew swaps the binary and purges the old cellar directory. runUpdate then tries to posix_spawn against the captured-at-startup process.execPath — which is the now-deleted old path. Each managed harness logs post-update harness refresh failed with ENOENT. Update reports success and exits 0, but no refresh happens.

Relevant logs or stack trace

$ LOG_LEVEL=trace contextbridge update
A new version is available: v0.3.0-alpha.2 (you're on v0.3.0-alpha.1).
...
==> Upgrading cli@alpha
  0.3.0-alpha.1 -> 0.3.0-alpha.2
==> Unlinking Binary '/opt/homebrew/bin/contextbridge'
==> Linking Binary 'contextbridge' to '/opt/homebrew/bin/contextbridge'
==> Purging files for version 0.3.0-alpha.1 of Cask cli@alpha
🍺  cli@alpha was successfully upgraded!

{"level":40,"msg":"post-update harness refresh failed","err":{"type":"Error","message":"ENOENT: no such file or directory, posix_spawn '/opt/homebrew/Caskroom/cli@alpha/0.3.0-alpha.1/contextbridge'","code":"ENOENT","path":"/opt/homebrew/Caskroom/cli@alpha/0.3.0-alpha.1/contextbridge","syscall":"posix_spawn","errno":-2},"harness":"claude","scope":"user"}
{"level":40,"msg":"post-update harness refresh failed","err":{"type":"Error","message":"ENOENT: no such file or directory, posix_spawn '/opt/homebrew/Caskroom/cli@alpha/0.3.0-alpha.1/contextbridge'","code":"ENOENT","path":"/opt/homebrew/Caskroom/cli@alpha/0.3.0-alpha.1/contextbridge","syscall":"posix_spawn","errno":-2},"harness":"codex","scope":"user"}
✓ update complete.

which contextbridge immediately after returns /opt/homebrew/bin/contextbridge (the stable symlink), which readlink resolves to …/0.3.0-alpha.2/contextbridge. The new binary is in place and would have worked if it had been the spawn target.

Anything else

  • Discovered while testing fix: refresh Claude marketplace cache during install #62 (the marketplace-cache-refresh fix). fix: refresh Claude marketplace cache during install #62 itself is unaffected by this bug — they're separate code paths. Linking for context.
  • Introduced in feat!: rename Claude plugin to planbridge@contextbridge; refresh plugins on update #52 (refresh plugins on update) — runUpdate walks ALL_INSTALLERS and spawns process.execPath install <harness-id> for each managed scope.
  • Why the unit tests in packages/cli/src/commands/update.test.ts don't catch this: FakeCommandRunner happily resolves stubs registered against process.execPath, so executed → refresh fires → install spawns looks fine in tests. The cellar-purge happens outside the test process.
  • Affected scope: every brew-installed user (likely the majority of macOS users).
  • Install-script users may be unaffected if the install script writes the binary to a stable path that survives upgrades — worth verifying, but the brew path is definitely broken.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions