Skip to content

fix: put node-gyp on PATH for dependency build scripts - #10554

Merged
zkochan merged 5 commits into
teambit:masterfrom
zkochan:fix-node-gyp-on-path
Aug 2, 2026
Merged

fix: put node-gyp on PATH for dependency build scripts#10554
zkochan merged 5 commits into
teambit:masterfrom
zkochan:fix-node-gyp-on-path

Conversation

@zkochan

@zkochan zkochan commented Aug 2, 2026

Copy link
Copy Markdown
Member

Summary

bit install fails on any dependency that shells out to node-gyp:

.../node_modules/bufferutil install$ node-gyp-build
│ Error: spawn node-gyp ENOENT
bufferutil@4.0.3 install: `node-gyp-build` exited with exit status: 1

The pnpm engine spawns dependency lifecycle scripts with the PATH of the bit process plus the relevant node_modules/.bin dirs, and ships no node-gyp of its own (pacquet's node_gyp_bin / node_gyp_path lifecycle options exist, but every caller passes None). Before #10508 Bit inherited a node-gyp wrapper from @pnpm/npm-lifecycle, which depends on node-gyp and prepends its wrapper dir to PATH for every script it spawns — the old lockfile had node-gyp@11.2.0 through it, the new one has none.

So anything that falls back to node-gyp rebuildnode-gyp-build with no matching prebuild, node-pre-gyp, or a plain "install": "node-gyp rebuild" — now dies with spawn node-gyp ENOENT. It shows up on platforms a package has no prebuild for: bufferutil@4.0.3 ships no darwin-arm64 prebuild, so every Apple Silicon install of it fails.

Changes

  • node-gyp-bin.ts (new) — writes an npm-style node-gyp wrapper into <bit-cache>/node-gyp-bin/<key> and appends it to process.env.PATH. Appended rather than prepended, so a node-gyp the user installed themselves still wins. The directory is keyed by a hash of the node and node-gyp.js paths baked into the wrapper, so a Bit or Node upgrade gets a fresh directory instead of rewriting a script a concurrent install may be executing; wrappers are written temp-file-then-rename. process.env is the only lever available here — InstallOptions exposes no extraBinPaths (only PackOptions does).
  • lynx.ts — call it once in install(), which covers both nodeApi.install and the returned rebuild.
  • workspace.jsoncnode-gyp: 11.5.0 in the root policy and in a new variant for scopes/dependencies/pnpm. The variant entry is required: nothing imports node-gyp, so dependency detection would never add it to @teambit/pnpm's package.json. 11.x rather than 13.x because node-gyp 13 requires Node ^22.22.2 and bvm ships 22.22.0.

The complete fix belongs upstream — pacquet already has the node_gyp_bin plumbing, it just has nothing to point it at — but Bit needs to ship a node-gyp either way.

Test plan

  • npm run lint
  • bit test scopes/dependencies/pnpm/node-gyp-bin.spec.ts — 3/3 passing
  • Scratch workspace with bufferutil@1.2.1 ("install": "node-gyp rebuild"): released bitsh: line 1: node-gyp: command not found, exit 127. Local build → node-gyp runs (node-gyp -v v11.5.0, invoked as <node> <…>/node-gyp.js); the compile then fails on its own merits, since that 2016 nan addon does not build against Node 22.
  • Same workspace with bufferutil@4.0.9 and npm_config_build_from_source=true to force the gyp path: bit install completes green and produces a real build/Release/bufferutil.node.

🤖 Generated with Claude Code

The pnpm engine spawns dependency lifecycle scripts with the PATH of the
bit process plus the relevant node_modules/.bin dirs, and ships no
node-gyp of its own. A native package that shells out to `node-gyp
rebuild` — node-gyp-build falling back, node-pre-gyp, or a plain
`"install": "node-gyp rebuild"` — therefore fails with `spawn node-gyp
ENOENT`, e.g. bufferutil on darwin-arm64, which has no prebuild.

Depend on node-gyp and expose it the way npm does: a wrapper script in a
cache directory that is put on PATH before the install runs. The
directory is appended rather than prepended, so a node-gyp the user
installed themselves still wins, and it is keyed by a hash of the node
and node-gyp paths baked into the wrapper, so an upgrade gets a fresh
directory instead of rewriting a script a concurrent install may be
executing.

node-gyp has to be declared in workspace.jsonc for the aspect, since
nothing imports it and dependency detection would never pick it up. 11.x
rather than 13.x: node-gyp 13 requires Node ^22.22.2 and bvm ships
22.22.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Fix pnpm dependency builds by adding a node-gyp shim to PATH

🐞 Bug fix 🧪 Tests ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Add a cached node-gyp wrapper directory and append it to Bit’s PATH.
• Ensure pnpm lifecycle/build scripts can invoke node-gyp during install/rebuild.
• Declare and lock node-gyp 11.5.0 in workspace policy and pnpm lockfile.
Diagram

graph TD
  A["lynx.install()"] --> B["addNodeGypToPath()"] --> C[("<bit-cache>/node-gyp-bin/<key>")]
  C --> D["PATH (process.env)"] --> E["pnpm engine"] --> F{{"dep build scripts"}} --> G["node-gyp shim"] --> H["node-gyp@11.5.0"]
  subgraph Legend
    direction LR
    _mod["Module/Function"] ~~~ _cache[("Cache dir")] ~~~ _step{{"Script/Step"}}
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Wire pacquet’s node_gyp_bin/node_gyp_path options end-to-end
  • ➕ Keeps node-gyp handling within the lifecycle runner abstraction instead of mutating process.env
  • ➕ Potentially scopes node-gyp availability to lifecycle subprocesses only
  • ➖ Requires changes upstream/in pacquet callers to stop passing None and to supply correct paths
  • ➖ Still needs Bit to provide a node-gyp binary/path to point at
2. Bundle a dedicated node-gyp wrapper with pnpm lifecycle tooling
  • ➕ Matches historical behavior from @pnpm/npm-lifecycle (less custom code)
  • ➕ Centralizes lifecycle-script environment setup
  • ➖ Reintroduces or adds more transitive/tooling dependencies
  • ➖ May not integrate cleanly with the current Rust/Node engine boundary
3. Expose an explicit extraBinPaths option on InstallOptions
  • ➕ Cleaner API than global PATH mutation; easier to reason about scope
  • ➕ Allows multiple tool shims (node-gyp, python, etc.) in a controlled way
  • ➖ Requires API changes in the pnpm engine bindings and all callers
  • ➖ Longer lead time than a targeted fix for current install failures

Recommendation: The PR’s approach (create an npm-style node-gyp shim in Bit cache and append it to PATH before install/rebuild) is the most pragmatic fix within current constraints: it works with the pnpm engine’s PATH inheritance model, avoids overriding user-provided node-gyp by appending (not prepending), and is concurrency-safe via temp-file-then-rename. Longer-term, pushing this through pacquet/nodeApi options or adding an explicit extraBinPaths API would reduce reliance on process.env mutation, but those require broader upstream changes.

Files changed (5) +341 / -1

Bug fix (2) +87 / -0
lynx.tsEnsure node-gyp shim PATH is set before pnpm install/rebuild +4/-0

Ensure node-gyp shim PATH is set before pnpm install/rebuild

• Imports and invokes addNodeGypToPath() at the start of install(), ensuring both nodeApi.install and the returned rebuild() run with a PATH that can resolve node-gyp.

scopes/dependencies/pnpm/lynx.ts

node-gyp-bin.tsCreate cached node-gyp wrapper scripts and append to PATH +83/-0

Create cached node-gyp wrapper scripts and append to PATH

• Implements an npm-style node-gyp shim directory under the Bit cache, keyed by a hash of the Node binary and node-gyp.js path. Writes shims atomically (temp file + rename), supports Windows via node-gyp.cmd, and appends the shim directory to process.env.PATH without overriding user-installed node-gyp.

scopes/dependencies/pnpm/node-gyp-bin.ts

Tests (1) +39 / -0
node-gyp-bin.spec.tsAdd tests for PATH shim creation and behavior +39/-0

Add tests for PATH shim creation and behavior

• Verifies addNodeGypToPath() appends exactly one directory, creates a runnable node-gyp shim (non-Windows), and is idempotent across multiple calls.

scopes/dependencies/pnpm/node-gyp-bin.spec.ts

Other (2) +215 / -1
pnpm-lock.yamlAdd node-gyp@11.5.0 to lockfile and snapshots +201/-1

Add node-gyp@11.5.0 to lockfile and snapshots

• Locks node-gyp@11.5.0 (and its transitive dependencies) into the workspace and pnpm aspect importers. Updates the Bit lockfile extension block to record a populated depsRequiringBuild list.

pnpm-lock.yaml

workspace.jsoncDeclare node-gyp dependency in root policy and pnpm aspect variant +14/-0

Declare node-gyp dependency in root policy and pnpm aspect variant

• Adds node-gyp@11.5.0 to the root dependency policy and explicitly declares it for scopes/dependencies/pnpm so it is included even without direct imports.

workspace.jsonc

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Aug 2, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. PATH override not portable 🐞 Bug ☼ Reliability
Description
The new node-gyp e2e test only reads/overrides PATH, but CommandHelper.runCmd() builds the
child-process env by spreading process.env into a plain object and does not normalize/remove
alternate-cased path keys, so an existing Path entry (common on Windows) may remain unfiltered
alongside the new PATH. This can make the test’s “no node-gyp on PATH” guarantee unreliable on
Windows, potentially hiding regressions.
Code

e2e/harmony/dependencies/node-gyp.e2e.ts[R46-48]

+    helper.command.install('@pnpm.e2e/has-binding-gyp', undefined, undefined, {
+      envVariables: { PATH: pathWithoutNodeGyp() },
+    });
Evidence
The test only derives the filtered PATH from process.env.PATH and only passes an uppercase PATH
override, while the e2e command runner constructs the spawned process environment by spreading
process.env into a plain object (preserving whatever key casing exists there) and the codebase
already accounts for PATH/Path/path being distinct keys when reading.

e2e/harmony/dependencies/node-gyp.e2e.ts[14-18]
e2e/harmony/dependencies/node-gyp.e2e.ts[46-48]
components/legacy/e2e-helper/e2e-command-helper.ts[93-96]
components/legacy/e2e-helper/e2e-command-helper.ts[119-123]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new e2e test tries to remove all existing `node-gyp` locations from the PATH before running `bit install`, but it only reads `process.env.PATH` and only passes `envVariables: { PATH: ... }`. On Windows, the effective path variable may be represented as `Path` (different casing), and the helper builds an `env` plain object from `process.env` + overrides without normalizing/deleting alternate casings.
### Issue Context
This can cause the test to still run with an unfiltered PATH (e.g., `Path` preserved) even though `PATH` was overridden, making the test nondeterministic on Windows and potentially allowing a false pass when Bit fails to supply node-gyp.
### Fix Focus Areas
- e2e/harmony/dependencies/node-gyp.e2e.ts[14-18]
- e2e/harmony/dependencies/node-gyp.e2e.ts[46-48]
### Suggested fix
1. Update `pathWithoutNodeGyp()` to read from `process.env.PATH || process.env.Path || process.env.path || ''`.
2. When invoking `helper.command.install(...)`, set all common casings to the same filtered value, e.g.:
- `envVariables: { PATH: filtered, Path: filtered, path: filtered }`
This keeps the fix local to the new test and makes it robust across platforms.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. PATH restore not symmetric ✓ Resolved 🐞 Bug ☼ Reliability
Description
In node-gyp-bin.spec.ts, the after-hook always restores PATH via assignment even when the original
PATH was undefined, which can leak an altered PATH state into later specs running in the same mocha
process. Other tests in this repo restore env vars by deleting them when the original value was
undefined.
Code

scopes/dependencies/pnpm/node-gyp-bin.spec.ts[R18-20]

+  after(() => {
+    process.env.PATH = originalPath;
+    // The wrapper lands in the real Bit cache — writing it there is the
Evidence
The new spec captures PATH as possibly undefined and restores it unconditionally via assignment;
another existing spec demonstrates the repo’s established pattern of deleting an env var when the
original value was undefined to avoid cross-test leakage.

scopes/dependencies/pnpm/node-gyp-bin.spec.ts[8-25]
scopes/harmony/modules/feature-toggle/feature-toggle.spec.ts[6-22]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`scopes/dependencies/pnpm/node-gyp-bin.spec.ts` stores `originalPath` as `string | undefined`, but always restores it via `process.env.PATH = originalPath`. When the original value was `undefined`, this does not restore the prior "unset" state; tests elsewhere in this repo avoid this by deleting the env var when the original value was `undefined`.
### Issue Context
Mocha commonly runs multiple spec files in the same process, so leaking env changes can affect unrelated test suites.
### Fix Focus Areas
- scopes/dependencies/pnpm/node-gyp-bin.spec.ts[11-25]
### Suggested change
In the `after()` hook:
- If `originalPath === undefined`, do `delete process.env.PATH`
- Else assign it back: `process.env.PATH = originalPath`

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Dry-run has side effects ✓ Resolved 🐞 Bug ☼ Reliability
Description
install() calls addNodeGypToPath() before checking options.dryRun, so dry-run mode still
attempts to write the shim under CACHE_ROOT and may mutate process.env.PATH. Write failures are
swallowed (warn+return), but this still violates the documented “skip installation” dry-run behavior
and adds unexpected filesystem/env mutation during dry runs.
Code

scopes/dependencies/pnpm/lynx.ts[R425-428]

+  // Both the install and the `rebuild` below run dependency build scripts, and
+  // they inherit this process's PATH to do it.
+  addNodeGypToPath(logger);
if (!options.dryRun) {
Evidence
install() invokes addNodeGypToPath() before the dryRun guard, and dryRun is explicitly
documented as skipping installation. addNodeGypToPath() calls writeShims(), which writes files
under CACHE_ROOT and may mutate process.env.PATH, so the side effects happen even when install
is skipped.

scopes/dependencies/pnpm/lynx.ts[422-431]
scopes/dependencies/dependency-resolver/package-manager.ts[103-108]
scopes/dependencies/pnpm/node-gyp-bin.ts[22-37]
scopes/dependencies/pnpm/node-gyp-bin.ts[40-66]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`addNodeGypToPath(logger)` is executed unconditionally in `install()` before the `dryRun` guard. This causes dry-run executions to still attempt shim creation in the global cache and potentially modify `process.env.PATH`.
## Issue Context
`dryRun` is documented as skipping installation while still calculating options and returning a rebuild function (used as a performance optimization). Side effects like writing to `CACHE_ROOT` and mutating `PATH` in dry-run are surprising and can cause noisy warnings in restricted environments.
## Fix
- Only set up node-gyp shims when an operation that will actually run lifecycle scripts is about to run.
- Move `addNodeGypToPath(logger)` inside the `!options.dryRun` block before `nodeApi.install(...)`.
- Also call `addNodeGypToPath(logger)` at the beginning of the returned `rebuild` function (before `nodeApi.rebuild(...)`) so rebuild remains covered.
- If there are other entry points that run lifecycle scripts, ensure they call shim setup as well.
## Fix Focus Areas
- scopes/dependencies/pnpm/lynx.ts[422-488]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (2)
4. Global shim race ✓ Resolved 🐞 Bug ☼ Reliability
Description
The shim is written to a single shared directory (CACHE_ROOT/node-gyp-bin) while hardcoding
process.execPath and the resolved node-gyp.js path, so concurrent Bit processes can overwrite
each other’s wrapper and cause dependency scripts to run node-gyp with the wrong Node/node-gyp. This
can lead to intermittent native-build failures that depend on timing and what else is running on the
machine.
Code

scopes/dependencies/pnpm/node-gyp-bin.ts[R41-44]

+  const nodeGypJs = require.resolve('node-gyp/bin/node-gyp.js');
+  const node = process.execPath;
+  const dir = join(CACHE_ROOT, 'node-gyp-bin');
+  // Written on Windows too: the default shell there is cmd.exe, but a
Evidence
The wrapper directory is fixed (CACHE_ROOT/node-gyp-bin), and its contents are generated from the
current process’s Node binary and resolved node-gyp.js path. The write path uses rename-based
replacement, so any process can overwrite the shared shim, changing what later lifecycle scripts
will execute.

scopes/dependencies/pnpm/node-gyp-bin.ts[40-50]
scopes/dependencies/pnpm/node-gyp-bin.ts[56-70]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The node-gyp wrapper is placed in a single global directory (`CACHE_ROOT/node-gyp-bin`) but embeds process-specific absolute paths (`process.execPath` and `require.resolve('node-gyp/bin/node-gyp.js')`). If multiple Bit processes run concurrently with different Node binaries and/or different Bit installations, they can overwrite the same wrapper and affect each other’s child lifecycle scripts.
## Issue Context
The current implementation uses atomic temp-file + rename to avoid partial writes, but it still allows a later process to replace the wrapper contents, and other processes may then invoke the replaced wrapper.
## Fix
Choose one approach:
1) **Per-invocation (preferred)**: create a unique temp shim directory (e.g., `fs.mkdtempSync(...)` under `os.tmpdir()`), write the shims there, temporarily adjust `process.env.PATH` only around `nodeApi.install(...)` / `nodeApi.rebuild(...)`, then restore PATH and clean up the temp dir.
2) **Runtime-keyed directory**: store shims under a directory keyed by `(process.execPath, nodeGypJsPath)` and ensure the directory added to PATH is the correct one for the running process (also ensure PATH precedence is correct across multiple Bit-added shim dirs).
## Fix Focus Areas
- scopes/dependencies/pnpm/node-gyp-bin.ts[39-71]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Cached shim setup failure ✓ Resolved 🐞 Bug ☼ Reliability
Description
If creating the shim directory fails, getShimDir() caches the failure (shimDir=null) and future
addNodeGypToPath() calls become no-ops for the rest of the process, so dependency scripts may still
fail to find node-gyp. The only signal is a debug-level log (and only when a logger is provided),
which makes the resulting build failures hard to diagnose at normal log levels.
Code

scopes/dependencies/pnpm/node-gyp-bin.ts[R38-41]

+    } catch (err: any) {
+      shimDir = null;
+      logger?.debug(`failed to set up the node-gyp shim, native packages may fail to build: ${err.message}`);
+    }
Evidence
The shim creation error is caught and converted into a cached null result, and only a debug log is
emitted, so subsequent calls won’t attempt to set up the shim again. This matters because
lynx.install() relies on addNodeGypToPath() to ensure pnpm’s lifecycle scripts can find
node-gyp via the inherited PATH.

scopes/dependencies/pnpm/node-gyp-bin.ts[31-44]
scopes/dependencies/pnpm/lynx.ts[422-429]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`getShimDir()` swallows shim-creation errors, caches the failure for the lifetime of the Node process, and only emits a debug message. This can leave native dependency installs/rebuilds failing with `spawn node-gyp ENOENT` without a clear explanation, and prevents recovery if the underlying issue becomes transiently resolvable later in the same process.
### Issue Context
`lynx.install()` calls `addNodeGypToPath()` specifically because pnpm lifecycle scripts inherit the current process PATH.
### Fix Focus Areas
- Make shim setup failures visible at normal log levels (warn/error) and include the full error object (not just `err.message`).
- Avoid permanently caching the failure (`shimDir = null`) if the error could be transient; consider retrying on subsequent calls (possibly with backoff) or re-attempting once per install.
- Optionally: if node-gyp is expected to always be present in Bit’s runtime, consider failing fast (throw) instead of silently proceeding.
- scopes/dependencies/pnpm/node-gyp-bin.ts[34-44]
- scopes/dependencies/pnpm/lynx.ts[422-428]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

6. Fragile shim file read ✓ Resolved 🐞 Bug ☼ Reliability
Description
readIfExists() uses existsSync() followed by readFileSync() without handling errors; if the file
disappears between the check and the read, the exception bubbles up and addNodeGypToPath() logs a
warning and skips adding the shim dir to PATH. This is rare but makes shim setup less resilient
under concurrent cache modification.
Code

scopes/dependencies/pnpm/node-gyp-bin.ts[R79-80]

+function readIfExists(target: string): string | undefined {
+  return existsSync(target) ? readFileSync(target, 'utf8') : undefined;
Evidence
The shim write path calls readIfExists during write/verification; any exception thrown propagates
into addNodeGypToPath’s catch, which returns without updating PATH.

scopes/dependencies/pnpm/node-gyp-bin.ts[23-38]
scopes/dependencies/pnpm/node-gyp-bin.ts[61-76]
scopes/dependencies/pnpm/node-gyp-bin.ts[79-81]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`readIfExists()` does a check-then-read (`existsSync` then `readFileSync`) without a try/catch. If the file is removed between the two operations (or is otherwise unreadable), the throw can abort shim setup for that call.
### Issue Context
`addNodeGypToPath()` wraps shim creation in a try/catch and returns early on any error, so a transient read error prevents adding the shim dir to PATH for that run.
### Fix Focus Areas
- scopes/dependencies/pnpm/node-gyp-bin.ts[61-81]
### Suggested change
- Replace `existsSync()+readFileSync()` with a single `try { return readFileSync(...) } catch (e) { return undefined }`, optionally only swallowing `ENOENT`.
- Reuse the same safe reader for the post-rename verification read in `writeShim()`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. Tests write to user cache ✓ Resolved 🐞 Bug ⚙ Maintainability
Description
node-gyp-bin.spec.ts calls addNodeGypToPath() which writes shims into the real CACHE_ROOT and
the test only restores PATH, leaving global-cache artifacts behind. This reduces test isolation
and can fail in environments where the global Bit cache is not writable (failure may surface as
assertions rather than a thrown setup error).
Code

scopes/dependencies/pnpm/node-gyp-bin.spec.ts[R11-16]

+  before(() => {
+    originalPath = process.env.PATH;
+    addNodeGypToPath();
+    const before = (originalPath ?? '').split(delimiter);
+    addedDirs = (process.env.PATH ?? '').split(delimiter).filter((dir) => !before.includes(dir));
+  });
Evidence
The test calls addNodeGypToPath() in before(). The implementation writes wrapper files under
join(CACHE_ROOT, 'node-gyp-bin') via writeFileSync/chmodSync/renameSync, so running the unit
test mutates the user/global cache and does not clean it up.

scopes/dependencies/pnpm/node-gyp-bin.spec.ts[11-20]
scopes/dependencies/pnpm/node-gyp-bin.ts[40-50]
scopes/dependencies/pnpm/node-gyp-bin.ts[53-66]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The unit test invokes production shim-writing logic that targets `CACHE_ROOT`, which is the user/global Bit cache directory. The test does not isolate this location or clean it up.
## Issue Context
Leaving artifacts in a shared cache makes unit tests less hermetic and can cause failures on systems with restricted home/cache permissions.
## Fix
- In the test, redirect the cache root to a temporary directory:
- Set `process.env.BIT_GLOBALS_DIR` to a temp path **before importing** the module that reads `CACHE_ROOT`.
- Import `addNodeGypToPath` dynamically after setting the env var (or clear module cache and re-require).
- Alternatively, refactor `addNodeGypToPath`/`writeShims` to accept an optional base directory for shims (defaulting to `CACHE_ROOT`) and pass a temp dir from the test.
- Optionally remove the created temp directory in `after()`.
## Fix Focus Areas
- scopes/dependencies/pnpm/node-gyp-bin.spec.ts[1-39]
- scopes/dependencies/pnpm/node-gyp-bin.ts[40-66]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


8. Stale shim dirs accumulate ✓ Resolved 🐞 Bug ⚙ Maintainability
Description
The shim directory is keyed by a hash of the Node and node-gyp.js paths, and new keys create new
directories under the global cache without any pruning. Over multiple Bit/Node upgrades, this can
accumulate stale node-gyp-bin directories in CACHE_ROOT.
Code

scopes/dependencies/pnpm/node-gyp-bin.ts[R52-54]

+  const key = createHash('sha1').update(`${node}\n${nodeGypJs}`).digest('hex').slice(0, 12);
+  const dir = join(CACHE_ROOT, 'node-gyp-bin', key);
+  // Written on Windows too: the default shell there is cmd.exe, but a
Evidence
The code derives a short hash key from process.execPath and the resolved node-gyp.js path and
places shims under CACHE_ROOT/node-gyp-bin/, but there is no code path that removes old key
directories. CACHE_ROOT is a per-user global cache location, so these directories can persist
across upgrades.

scopes/dependencies/pnpm/node-gyp-bin.ts[46-61]
components/legacy/constants/constants.ts[19-34]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`createShimDir()` creates an immutable per-(node, node-gyp.js path) directory under `CACHE_ROOT/node-gyp-bin/<key>` and never prunes old keys. This can slowly grow the global cache with stale directories.
### Issue Context
The PR intentionally avoids rewriting an existing shim directory to reduce concurrency hazards; any pruning must therefore be concurrency-safe (don’t delete a directory another process might be using).
### Fix Focus Areas
- Add a small, safe cleanup strategy (e.g., keep last N keys by mtime, or delete entries older than X days) with conservative guards.
- Ensure cleanup never deletes the currently selected `key` directory.
- scopes/dependencies/pnpm/node-gyp-bin.ts[46-61]
- components/legacy/constants/constants.ts[19-34]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Qodo Logo

Comment thread scopes/dependencies/pnpm/node-gyp-bin.ts Outdated
Comment thread scopes/dependencies/pnpm/node-gyp-bin.ts
Two things would have let the e2e test pass without the fix, so it
guards against both. `npm run` puts the repo's node_modules/.bin — which
now carries a node-gyp bin link — on PATH, and dependency build scripts
inherit it, so the install runs with a PATH stripped of every directory
holding a node-gyp. And the pnpm store's side-effects cache reproduces
the build output of a package built by an earlier install without ever
running node-gyp, so the workspace gets a store of its own.

Also address review feedback on the wrapper itself: report a setup
failure at warning level rather than only in the debug log, drop the
memoized failure so a later install retries, and write the wrapper to a
fixed directory that is atomically replaced when it goes stale, instead
of a new hash-keyed directory per Bit or Node upgrade.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@zkochan

zkochan commented Aug 2, 2026

Copy link
Copy Markdown
Member Author

Pushed an e2e test plus the two review fixes.

e2e test — e2e/harmony/dependencies/node-gyp.e2e.ts

Installs @pnpm.e2e/has-binding-gyp (a registry-mock fixture whose install script is node-gyp rebuild, and whose binding.gyp runs a gyp action that writes generated.js — so no C++ toolchain is needed, only node-gyp itself) and asserts generated.js exists.

Two things would have made that test pass without the fix, and it now defeats both:

  1. node_modules/.bin on PATH. npm run puts the repo's node_modules/.bin on PATH, and that now carries a node-gyp bin link, which build scripts inherit. The install runs with a PATH stripped of every directory holding a node-gyp.
  2. The store's side-effects cache. Once any install has built the package, the store replays the build output — including generated.js — without ever running node-gyp. This is what made my first attempt at the test pass against the released bit. The workspace now gets a store of its own via pnpm-workspace.yaml.

Verified both directions:

bit result
released 2.0.59 (no fix) @pnpm.e2e/has-binding-gyp@1.0.0 install: node-gyp rebuild exited with exit status: 127
this branch ✓ passing

Review comments

1. Cached shim setup failure — fixed. The failure is no longer memoized, so each install retries, and it is reported with logger.consoleWarning (plus the full error to the debug log) instead of a debug-only line. Not made fatal: a missing wrapper only matters for a dependency that actually builds with node-gyp, and it may still find one elsewhere on PATH.

2. Stale shim dirs accumulate — fixed at the source rather than with pruning. The wrapper now lives in one fixed <cache>/node-gyp-bin, rewritten only when its content is stale. The write is still temp-file-then-rename, which is what made the hash-keyed directory unnecessary: the rename replaces the directory entry atomically and a script already executing the old wrapper keeps reading the inode it opened, so there is nothing to race and nothing to accumulate.

Comment thread scopes/dependencies/pnpm/lynx.ts Outdated
Comment thread scopes/dependencies/pnpm/node-gyp-bin.ts
Comment thread scopes/dependencies/pnpm/node-gyp-bin.spec.ts
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit ea9f3b9

A dry run is documented as skipping the installation, so it has no
business writing the wrapper or touching PATH. Move the setup inside the
guard, and into `rebuild`, which is reachable without an install of its
own.

Restore the wrapper directory keyed by the Node and node-gyp paths it
hardcodes. A single shared directory made two Bit installs overwrite
each other, pointing a concurrent install at the wrong node-gyp — and it
rewrote the wrapper on every install when two Bit versions are used side
by side, which is the normal state of this repo (`bit` and `bbit`). What
accumulates instead is two ~100-byte files per distinct pair.

The unit test now removes the wrapper it wrote to the real cache.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@zkochan

zkochan commented Aug 2, 2026

Copy link
Copy Markdown
Member Author

All three addressed in 40f74a4.

1. Dry-run has side effects — fixed. addNodeGypToPath moved inside the !options.dryRun guard, and added to the top of the returned rebuild, which is reachable without an install of its own.

2. Global shim race — fixed, by reverting the change I made for the previous round's "stale shim dirs accumulate" comment. The two are in direct conflict: the wrappers hardcode process.execPath and the resolved node-gyp.js path, so a single shared directory is only safe if every Bit process resolves the same two paths.

Going back to the keyed directory (their option 2) is the right trade. The race isn't hypothetical — it's this repo's normal state, where bit (bvm release) and bbit (source) both run against the same cache and would rewrite the wrapper on every install, each pointing the other's dependency scripts at the wrong node-gyp. Accumulation, by contrast, is two ~100-byte files per distinct (node, node-gyp) pair — a handful over the life of an install, and never a correctness problem. I did not take the per-invocation temp-dir option: it leaks a directory whenever Bit is killed mid-install, and buys isolation the key already provides.

3. Tests write to user cache — the test now removes the directory it wrote. Not redirected via BIT_GLOBALS_DIR: CACHE_ROOT is resolved when @teambit/legacy.constants is first loaded, so redirecting it means re-importing that module before anything else in a shared mocha process touches it — fragile, and it would stub out the very path the test is asserting. Removal is safe: an install that wants the wrapper writes it again, and a script already executing one keeps the inode it opened.

Re-verified after the changes — unit spec 3/3 (and leaves the cache as it found it), e2e passing on this branch and still failing with exit 127 against released 2.0.59.

Comment thread scopes/dependencies/pnpm/node-gyp-bin.spec.ts
Comment thread scopes/dependencies/pnpm/node-gyp-bin.ts Outdated
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 40f74a4

Read the wrapper and handle its absence, rather than testing for it
first: another Bit process replacing it can remove the file between the
two calls, and the resulting ENOENT would skip the whole setup.

Delete PATH in the spec's after-hook when it was unset to begin with,
so a later spec in the same process does not see the string "undefined".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@zkochan

zkochan commented Aug 2, 2026

Copy link
Copy Markdown
Member Author

Both open threads addressed in aaf8797.

1. Path restore not symmetric — fixed. The after-hook now deletes process.env.PATH when it was unset to begin with, instead of assigning undefined and leaving the literal string "undefined" for whatever spec runs next in the same process.

2. Fragile shim file read — fixed, and it removes the check rather than guarding it: readIfExists now reads and treats ENOENT as absent, instead of existsSync followed by readFileSync. That closes the window entirely — with the check-then-read there was nothing to catch, since another Bit process replacing the wrapper legitimately removes the file between the two calls.

Re-verified: unit spec 3/3, e2e passing.

@zkochan
zkochan enabled auto-merge (squash) August 2, 2026 10:55
Comment on lines +46 to +48
helper.command.install('@pnpm.e2e/has-binding-gyp', undefined, undefined, {
envVariables: { PATH: pathWithoutNodeGyp() },
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

1. Path override not portable 🐞 Bug ☼ Reliability

The new node-gyp e2e test only reads/overrides PATH, but CommandHelper.runCmd() builds the
child-process env by spreading process.env into a plain object and does not normalize/remove
alternate-cased path keys, so an existing Path entry (common on Windows) may remain unfiltered
alongside the new PATH. This can make the test’s “no node-gyp on PATH” guarantee unreliable on
Windows, potentially hiding regressions.
Agent Prompt
### Issue description
The new e2e test tries to remove all existing `node-gyp` locations from the PATH before running `bit install`, but it only reads `process.env.PATH` and only passes `envVariables: { PATH: ... }`. On Windows, the effective path variable may be represented as `Path` (different casing), and the helper builds an `env` plain object from `process.env` + overrides without normalizing/deleting alternate casings.

### Issue Context
This can cause the test to still run with an unfiltered PATH (e.g., `Path` preserved) even though `PATH` was overridden, making the test nondeterministic on Windows and potentially allowing a false pass when Bit fails to supply node-gyp.

### Fix Focus Areas
- e2e/harmony/dependencies/node-gyp.e2e.ts[14-18]
- e2e/harmony/dependencies/node-gyp.e2e.ts[46-48]

### Suggested fix
1. Update `pathWithoutNodeGyp()` to read from `process.env.PATH || process.env.Path || process.env.path || ''`.
2. When invoking `helper.command.install(...)`, set all common casings to the same filtered value, e.g.:
   - `envVariables: { PATH: filtered, Path: filtered, path: filtered }`

This keeps the fix local to the new test and makes it robust across platforms.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit aaf8797

The command helper spreads process.env into a plain object, which drops
Node's case-insensitive env lookup. A `PATH` override would then sit
next to the `Path` Windows actually uses rather than replacing it, and
the test would install with node-gyp still reachable — passing whether
or not Bit supplies one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@zkochan

zkochan commented Aug 2, 2026

Copy link
Copy Markdown
Member Author

Fixed in e0b54ee.

Path override not portable — real: CommandHelper.runCmd spreads process.env into a plain object, which drops Node's case-insensitive env lookup on Windows, so a PATH override would sit next to the Path the OS actually uses instead of replacing it. The test would then install with node-gyp still reachable and pass whether or not Bit supplies one — exactly the false pass this test exists to rule out.

Fixed slightly differently than suggested: rather than setting PATH, Path, and path unconditionally, the test reads the casings already present in process.env and overrides those. Same effect on Windows, without inventing two extra environment variables on POSIX, where path is a distinct variable rather than an alias.

Re-verified both directions after the change — passing on this branch, still failing with exit 127 against released 2.0.59.

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit e0b54ee

@zkochan
zkochan merged commit 220cd66 into teambit:master Aug 2, 2026
13 checks passed
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