From 69aa7ec04eca8e1119ae43ed5cbe7688731bdfaa Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 6 May 2026 21:03:33 -0400 Subject: [PATCH 1/2] fix(install): prefer stable Homebrew symlinks over versioned Cellar paths in node runner process.execPath on Homebrew resolves symlinks and returns the versioned Cellar path (e.g. /usr/local/Cellar/node/25.8.1/bin/node). After brew upgrade node, the old Cellar binary fails with dyld: Library not loaded because shared libraries have changed SOVERSION. - Add normalizeNodePath() helper that maps Cellar paths to stable Homebrew symlinks (/usr/local/bin/node or /opt/homebrew/bin/node) - resolveNodeRunner() now calls normalizeNodePath() before quoting - rewriteLegacyManagedNodeHookCommands() also normalizes baked Cellar runner paths in existing hook commands so reinstall doesn't re-bake them - Export normalizeNodePath for testability - Add 22 tests covering all cases (Cellar paths, stable symlinks, NVM, system node, Windows, null/empty, both function surfaces) Closes #3181 Co-Authored-By: Claude Sonnet 4.6 --- .changeset/gallant-badgers-bark.md | 5 + CHANGELOG.md | 2 +- bin/install.js | 72 ++++++- tests/bug-3181-node-cellar-path.test.cjs | 254 +++++++++++++++++++++++ 4 files changed, 327 insertions(+), 6 deletions(-) create mode 100644 .changeset/gallant-badgers-bark.md create mode 100644 tests/bug-3181-node-cellar-path.test.cjs diff --git a/.changeset/gallant-badgers-bark.md b/.changeset/gallant-badgers-bark.md new file mode 100644 index 000000000..3769395e8 --- /dev/null +++ b/.changeset/gallant-badgers-bark.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3181 +--- +resolveNodeRunner() and rewriteLegacyManagedNodeHookCommands() now prefer stable Homebrew symlinks (/usr/local/bin/node, /opt/homebrew/bin/node) over versioned Cellar paths when a Cellar path is detected, preventing dyld: Library not loaded errors after brew upgrade node diff --git a/CHANGELOG.md b/CHANGELOG.md index e5c9fb2cc..179c4e4a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed -- **Workstream resolution in `init.milestone-op` and `roadmap.analyze`** — both handlers now respect `--ws`, `GSD_WORKSTREAM`, and the `.planning/active-workstream` file; workstream-scoped repos no longer exit "All phases complete — Nothing left to do" due to `phase_count: 0` from reading the wrong root `.planning/`. (#3196) +- **Stable node path on Homebrew** — `resolveNodeRunner()` now maps versioned Homebrew Cellar paths (e.g. `/usr/local/Cellar/node/25.8.1/bin/node`) to the stable Homebrew symlinks (`/usr/local/bin/node` on Intel, `/opt/homebrew/bin/node` on Apple Silicon). `rewriteLegacyManagedNodeHookCommands()` applies the same normalization to baked Cellar paths in existing hook commands. This prevents `dyld: Library not loaded` errors after `brew upgrade node`. (#3181) - **Milestone-archive layout support** — `validate consistency`, `validate health`, and `find-phase` now scan `.planning/milestones/v*-phases/` directories in addition to the flat `.planning/phases/` layout. Projects that have graduated to milestone-archive layout no longer receive spurious W006 "Phase N in ROADMAP.md but no directory on disk" warnings for every active phase. (#3164) ### Feature diff --git a/bin/install.js b/bin/install.js index b7fd94d16..64a6e03ea 100755 --- a/bin/install.js +++ b/bin/install.js @@ -525,6 +525,34 @@ function computePathPrefix({ isGlobal, isOpencode, isWindowsHost: _isWindowsHost return `${resolvedTarget}/`; } +/** + * Normalize a raw `process.execPath` to a stable, upgrade-safe node binary + * path. On Homebrew installs, `process.execPath` resolves symlinks and returns + * the versioned Cellar path (e.g. + * `/usr/local/Cellar/node/25.8.1/bin/node`). Baking that path into hook + * commands causes `dyld: Library not loaded` errors after `brew upgrade node` + * because the shared libraries referenced by the Cellar binary have changed + * SOVERSION. (#3181) + * + * The stable Homebrew symlinks (`/usr/local/bin/node` for Intel, + * `/opt/homebrew/bin/node` for Apple Silicon) survive upgrades — Homebrew + * re-points them atomically. We prefer those when a Cellar path is detected. + * + * Non-Homebrew installs (NVM, system node, Windows, etc.) are returned as-is. + */ +function normalizeNodePath(execPath) { + if (!execPath) return execPath; + // Intel Homebrew: /usr/local/Cellar/node//bin/node + if (/^\/usr\/local\/Cellar\/node\/[^/]+\/bin\/node(\.exe)?$/.test(execPath)) { + return '/usr/local/bin/node'; + } + // Apple Silicon Homebrew: /opt/homebrew/Cellar/node//bin/node + if (/^\/opt\/homebrew\/Cellar\/node\/[^/]+\/bin\/node(\.exe)?$/.test(execPath)) { + return '/opt/homebrew/bin/node'; + } + return execPath; +} + /** * Resolve the absolute path to the node binary running the installer. * Used as the runner for .js hooks so they execute in GUI/minimal-PATH @@ -537,13 +565,17 @@ function computePathPrefix({ isGlobal, isOpencode, isWindowsHost: _isWindowsHost * gives the absolute path of the node binary actively running the * installer — that is the version the user just installed under, and * the right default runtime for hooks invoked under the same install. + * + * When `process.execPath` is a versioned Homebrew Cellar path, the stable + * Homebrew symlink is returned instead to survive `brew upgrade node` (#3181). */ function resolveNodeRunner() { const execPath = typeof process.execPath === 'string' ? process.execPath : ''; if (!execPath) return null; + const stablePath = normalizeNodePath(execPath); // JSON.stringify produces a properly escaped double-quoted shell token, // safe for paths containing spaces or unusual characters. - return JSON.stringify(execPath.replace(/\\/g, '/')); + return JSON.stringify(stablePath.replace(/\\/g, '/')); } /** @@ -580,20 +612,49 @@ function rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner) { for (const h of entry.hooks) { if (!h || typeof h.command !== 'string') continue; const trimmed = h.command.trim(); - // Match the EXACT legacy form: `node