Summary
The launcher's node pin (install-runtime.json → nodePath) records the fully-versioned Homebrew keg path, e.g. /opt/homebrew/Cellar/node/26.0.0/bin/node. Homebrew treats versioned kegs as disposable — brew upgrade node creates a new keg and brew cleanup deletes old ones — so the pin's target routinely disappears or, worse, silently rots. Suggestion: on Homebrew installs, pin the stable opt symlink /opt/homebrew/opt/node/bin/node instead, and rely on an ABI check for safety.
What happened (real-world failure, macOS arm64, almanac 0.2.x)
- Almanac was installed under Homebrew node 26.0.0;
install-runtime.json pinned /opt/homebrew/Cellar/node/26.0.0/bin/node (ABI 147).
- Later,
brew upgrade installed node 26.5.0_1 and upgraded ada-url 3.4.4 → 4.0.0 (dylib major bump: libada.3.dylib → libada.4.dylib), then cleanup removed the old ada-url keg — but not the old node keg.
- Result: the pinned binary still exists and is executable, so
isUsablePinnedNode() passes — but it can no longer run, because its dynamic dependency is gone. Homebrew only guarantees dependency compatibility for the current keg of a formula; an orphaned old keg is outside that guarantee.
- Every
almanac invocation then dies with a raw dyld error instead of the friendly formatMissingPinnedNodeMessage repair hint:
dyld[48946]: Library not loaded: /opt/homebrew/opt/ada-url/lib/libada.3.dylib
Referenced from: /opt/homebrew/Cellar/node/26.0.0/bin/node
Reason: tried: '/opt/homebrew/opt/ada-url/lib/libada.3.dylib' (no such file), ...
This is easy to misdiagnose as a globally broken node install, when in fact node on PATH (26.5.0_1) worked fine the whole time. The actual repair (npm install -g codealmanac@latest) is non-obvious from the error.
The existence check also means the other common sequence (brew upgrade node + brew cleanup, which deletes the pinned keg) breaks almanac on every node upgrade cycle, even though the new node is ABI-compatible (node keeps one ABI per major, so every 26.x is ABI 147).
Suggestion
When the install-time process.execPath resolves under a Homebrew Cellar (/opt/homebrew/Cellar/node/<ver>/... or /usr/local/Cellar/node/<ver>/...), pin the corresponding stable symlink instead:
/opt/homebrew/opt/node/bin/node (arm64) / /usr/local/opt/node/bin/node (intel)
That path survives upgrades and cleanups, and always points at the keg whose dependencies Homebrew currently guarantees. To keep the safety property the pin exists for (protecting the better-sqlite3 native binding), record nodeAbi as today and verify it at launch — e.g. spawn <pinned> -p process.versions.modules (or check process.versions.modules after re-exec) and emit the existing "repair: npm install -g codealmanac@latest" message on mismatch. ABI mismatch through the opt symlink can only happen on a node major bump, which is exactly when a rebuild is genuinely needed — versus today, where the pin breaks on every minor/patch keg rotation.
The same reasoning applies to other version-manager layouts (nvm's versions/node/vX.Y.Z/bin/node is similarly disposable), but Homebrew is the case where the failure mode is both routine and misleading.
Happy to PR this if the approach sounds right.
Summary
The launcher's node pin (
install-runtime.json→nodePath) records the fully-versioned Homebrew keg path, e.g./opt/homebrew/Cellar/node/26.0.0/bin/node. Homebrew treats versioned kegs as disposable —brew upgrade nodecreates a new keg andbrew cleanupdeletes old ones — so the pin's target routinely disappears or, worse, silently rots. Suggestion: on Homebrew installs, pin the stable opt symlink/opt/homebrew/opt/node/bin/nodeinstead, and rely on an ABI check for safety.What happened (real-world failure, macOS arm64, almanac 0.2.x)
install-runtime.jsonpinned/opt/homebrew/Cellar/node/26.0.0/bin/node(ABI 147).brew upgradeinstalled node 26.5.0_1 and upgradedada-url3.4.4 → 4.0.0 (dylib major bump:libada.3.dylib→libada.4.dylib), then cleanup removed the old ada-url keg — but not the old node keg.isUsablePinnedNode()passes — but it can no longer run, because its dynamic dependency is gone. Homebrew only guarantees dependency compatibility for the current keg of a formula; an orphaned old keg is outside that guarantee.almanacinvocation then dies with a raw dyld error instead of the friendlyformatMissingPinnedNodeMessagerepair hint:This is easy to misdiagnose as a globally broken node install, when in fact
nodeon PATH (26.5.0_1) worked fine the whole time. The actual repair (npm install -g codealmanac@latest) is non-obvious from the error.The existence check also means the other common sequence (
brew upgrade node+brew cleanup, which deletes the pinned keg) breaks almanac on every node upgrade cycle, even though the new node is ABI-compatible (node keeps one ABI per major, so every 26.x is ABI 147).Suggestion
When the install-time
process.execPathresolves under a Homebrew Cellar (/opt/homebrew/Cellar/node/<ver>/...or/usr/local/Cellar/node/<ver>/...), pin the corresponding stable symlink instead:/opt/homebrew/opt/node/bin/node(arm64) //usr/local/opt/node/bin/node(intel)That path survives upgrades and cleanups, and always points at the keg whose dependencies Homebrew currently guarantees. To keep the safety property the pin exists for (protecting the
better-sqlite3native binding), recordnodeAbias today and verify it at launch — e.g. spawn<pinned> -p process.versions.modules(or checkprocess.versions.modulesafter re-exec) and emit the existing "repair: npm install -g codealmanac@latest" message on mismatch. ABI mismatch through the opt symlink can only happen on a node major bump, which is exactly when a rebuild is genuinely needed — versus today, where the pin breaks on every minor/patch keg rotation.The same reasoning applies to other version-manager layouts (nvm's
versions/node/vX.Y.Z/bin/nodeis similarly disposable), but Homebrew is the case where the failure mode is both routine and misleading.Happy to PR this if the approach sounds right.