Skip to content

fix(deploy): monorepo-aware lock file and node_modules resolution#122

Open
harrisrobin wants to merge 1 commit intocloudflare:mainfrom
harrisrobin:fix/monorepo-deploy-support
Open

fix(deploy): monorepo-aware lock file and node_modules resolution#122
harrisrobin wants to merge 1 commit intocloudflare:mainfrom
harrisrobin:fix/monorepo-deploy-support

Conversation

@harrisrobin
Copy link
Contributor

Problem

vinext deploy fails in monorepos in two distinct but related ways, both caused by the same assumption: that the project's lock file and node_modules are always in the app directory rather than potentially hoisted to a workspace root.

1. Wrong package manager detected → npm error Invalid Version:

detectPackageManager only checked for bun.lockb (legacy binary format), missing bun.lock (text format, introduced in Bun v1.0). It also only searched the immediate project directory, so any monorepo where the lock file lives at the workspace root fell through to npm install -D, producing:

npm error Invalid Version:
Error: Command failed: npm install -D @cloudflare/vite-plugin@latest ...
    at installDeps (dist/deploy.js:534:5)

2. Wrangler binary not found → ENOENT

detectProject checked hasCloudflarePlugin, hasRscPlugin, and hasWrangler only in {root}/node_modules, producing false negatives for all three when packages are hoisted. runWranglerDeploy hardcoded the same path, crashing with:

Error: spawnSync .../apps/web-next/node_modules/.bin/wrangler ENOENT
    at runWranglerDeploy (dist/deploy.js:622:20)

Solution

Both problems share the same root fix: walk up ancestor directories until the relevant file is found, the same way npm/pnpm/yarn themselves resolve workspace roots.

Rather than two separate ad-hoc loops, this PR introduces a single shared primitive:

// utils/project.ts
function walkUpUntil<T>(start: string, check: (dir: string) => T | null): T | null

Both features are then built on top of it:

// Lock file detection — now also recognises bun.lock (Bun v1.0+ text format)
detectPackageManager(root)     // uses walkUpUntil + checkLockFiles
detectPackageManagerName(root) // uses walkUpUntil + checkLockFiles

// node_modules resolution
findInNodeModules(start, subPath) // walks up to find node_modules/{subPath}

findInNodeModules replaces all four hardcoded fs.existsSync(path.join(root, "node_modules", ...)) calls in deploy.ts, and provides the resolved binary path to runWranglerDeploy.

Tests

12 new unit tests:

detectPackageManager
  ✓ detects pnpm from pnpm-lock.yaml
  ✓ detects yarn from yarn.lock
  ✓ detects bun from bun.lock (text format, Bun v1.0+)
  ✓ detects bun from bun.lockb (legacy binary format)
  ✓ falls back to npm when no lock file is found
  ✓ walks up to parent directory to find lock file (monorepo root)
  ✓ prefers the closest lock file when both child and parent have one

findInNodeModules
  ✓ finds a package in the immediate node_modules
  ✓ finds a binary in node_modules/.bin
  ✓ returns null when not found anywhere
  ✓ walks up to find package in monorepo root node_modules
  ✓ prefers the closest node_modules when both app and root have the package

Supersedes #119 and #121 (closing those).

/bonk

harrisrobin added a commit to harrisrobin/vinext that referenced this pull request Feb 26, 2026
…factored)

Replaces the individual cloudflare#119 and cloudflare#121 fixes with the combined version from
PR cloudflare#122. Introduces walkUpUntil<T> as a shared primitive, with
detectPackageManager and findInNodeModules both built on top of it.
@southpolesteve
Copy link
Collaborator

/bigbonk review please

@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 5, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@122

commit: 53ab3fb

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

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

Clean, well-structured PR. The walkUpUntil primitive is a good abstraction, the tests are thorough, and the bun.lock gap is a real fix. A few things to consider below — one potentially user-facing issue, one scope question, and a couple of nits.

// bun.lock = text format (Bun v1.0+); bun.lockb = legacy binary format
if (fs.existsSync(path.join(dir, "bun.lock"))) return { pm: "bun", installCmd: "bun add -D" };
if (fs.existsSync(path.join(dir, "bun.lockb"))) return { pm: "bun", installCmd: "bun add -D" };
return null;
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: package-lock.json is never checked, so npm projects that do have a lock file will still fall through to the fallback. This isn't a regression (the previous code didn't check it either), but since we're already touching this logic and adding bun.lock, it would be nice to also recognise package-lock.json so the npm detection is explicit rather than accidental.

Suggested change
return null;
if (fs.existsSync(path.join(dir, "bun.lockb"))) return { pm: "bun", installCmd: "bun add -D" };
if (fs.existsSync(path.join(dir, "package-lock.json"))) return { pm: "npm", installCmd: "npm install -D" };
return null;

This matters for monorepos: without it, a project at apps/web with a package-lock.json two levels up will hit return null at every directory, and the ?? "npm" fallback kicks in — correct by coincidence, but walk-up stops early for every other PM. Worth being explicit.

// Walk up ancestor directories so the binary is found even when node_modules
// is hoisted to the workspace root in a monorepo.
const wranglerBin = _findInNodeModules(root, ".bin/wrangler") ??
path.join(root, "node_modules", ".bin", "wrangler"); // fallback for error message clarity
Copy link
Contributor

Choose a reason for hiding this comment

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

The fallback is a nice touch for error-message clarity, but there's a subtle issue: if findInNodeModules returns null (wrangler truly isn't installed), we silently construct a path that doesn't exist and pass it to execFileSync, which will throw a confusing ENOENT on a path that was never real.

Consider failing early with a clear message instead:

Suggested change
path.join(root, "node_modules", ".bin", "wrangler"); // fallback for error message clarity
const wranglerBin = _findInNodeModules(root, ".bin/wrangler");
if (!wranglerBin) {
throw new Error(
`Could not find wrangler binary. Run your package manager's install command and try again.`,
);
}

The current code works, but the error would say ENOENT .../node_modules/.bin/wrangler which could mislead someone into thinking the local path is the only place it looked. A custom error makes the failure mode obvious.

* node_modules resolution to support monorepo layouts where the relevant
* files live at the workspace root rather than inside each app package.
*/
function walkUpUntil<T>(start: string, check: (dir: string) => T | null): T | null {
Copy link
Contributor

Choose a reason for hiding this comment

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

Minor: walkUpUntil will walk all the way up to / (the filesystem root). In practice this is harmless, but something to be aware of — on a deep path it could perform many fs.existsSync calls. Not a real perf concern today, just flagging.

If this ever becomes an issue, a reasonable guard would be to stop at the first directory that doesn't contain a package.json (i.e., you've left the monorepo), or to cap the depth. Not needed now.

// Check node_modules for installed packages.
// Walk up ancestor directories so that monorepo-hoisted packages are found
// even when node_modules lives at the workspace root rather than app root.
const hasCloudflarePlugin = _findInNodeModules(root, "@cloudflare/vite-plugin") !== null;
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: The extra spaces for vertical alignment (hasRscPlugin =) will drift if any of these lines are edited later. Consider using standard single-space formatting for lower maintenance cost.

Suggested change
const hasCloudflarePlugin = _findInNodeModules(root, "@cloudflare/vite-plugin") !== null;
const hasCloudflarePlugin = _findInNodeModules(root, "@cloudflare/vite-plugin") !== null;
const hasRscPlugin = _findInNodeModules(root, "@vitejs/plugin-rsc") !== null;
const hasWrangler = _findInNodeModules(root, ".bin/wrangler") !== null;

// Check node_modules for installed packages.
// Walk up ancestor directories so that monorepo-hoisted packages are found
// even when node_modules lives at the workspace root rather than app root.
const hasCloudflarePlugin = _findInNodeModules(root, "@cloudflare/vite-plugin") !== null;
Copy link
Contributor

Choose a reason for hiding this comment

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

Out-of-scope for this PR, but worth noting: cli.ts:305-306 has the same hardcoded node_modules/.bin pattern for oxlint and eslint detection. Would be good to follow up with findInNodeModules there too for consistency.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 5, 2026

Review posted. Here's a summary of my feedback:

Overall: Clean PR. The walkUpUntil primitive is well-designed, the tests cover the key scenarios (flat, monorepo, closest-wins), and the bun.lock fix addresses a real gap.

Comments posted (5):

  1. checkLockFiles — add package-lock.json (project.ts:108): npm is currently detected only by fallback (no lock file matched). Since we're already adding bun.lock, it's worth making npm detection explicit with package-lock.json so the walk-up behavior is consistent across all package managers.

  2. runWranglerDeploy fallback path (deploy.ts:749): When findInNodeModules returns null, the code constructs a fake path and passes it to execFileSync, producing a misleading ENOENT. Suggested throwing a clear error message instead.

  3. walkUpUntil traversal depth (project.ts:86): Informational — the function walks to filesystem root. Not a real issue today but worth being aware of.

  4. Alignment whitespace nit (deploy.ts:146): The extra spaces for vertical alignment will drift on future edits. Suggested standard single-space.

  5. Out-of-scope follow-up (deploy.ts:146): cli.ts:305-306 has the same hardcoded node_modules/.bin pattern for oxlint/eslint — would benefit from findInNodeModules in a follow-up.

github run

vinext deploy failed in monorepos in two ways:

1. Wrong package manager detected — detectPackageManager only checked for
   bun.lockb (legacy binary format), missing bun.lock (text format, Bun
   v1.0+). It also only searched the immediate project directory, so any
   monorepo where the lock file lives at the workspace root fell through to
   npm, causing a confusing "npm error Invalid Version:" failure.

2. Dependency detection and wrangler binary path hard-coded to the app
   directory — hasCloudflarePlugin, hasRscPlugin, hasWrangler, hasMdxRollup
   all checked {root}/node_modules only, producing false negatives for
   hoisted packages. runWranglerDeploy then crashed with ENOENT because
   the wrangler binary was at the workspace root, not the app root.

Changes:
- Add walkUpUntil<T>(start, check) to utils/project.ts — a shared primitive
  that walks ancestor directories until check() returns non-null. Both the
  lock-file and node_modules walkers are built on top of this.
- Rewrite detectPackageManager / detectPackageManagerName using walkUpUntil;
  add bun.lock (text) check alongside existing bun.lockb (binary).
- Add findInNodeModules(start, subPath) using walkUpUntil — walks up to find
  node_modules/{subPath}, returning the absolute path or null.
- Use findInNodeModules in detectProject (hasCloudflarePlugin, hasRscPlugin,
  hasWrangler), getMissingDeps (hasMdxRollup), and runWranglerDeploy.

Tests (12 new):
- detectPackageManager: pnpm, yarn, bun.lock, bun.lockb, npm fallback,
  monorepo root traversal, closest-wins precedence
- findInNodeModules: package lookup, binary lookup, null fallback,
  monorepo root traversal, closest-wins precedence
@southpolesteve southpolesteve force-pushed the fix/monorepo-deploy-support branch from a548b93 to 1fba5ae Compare March 5, 2026 00:52
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