Skip to content

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

@blimmer

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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions