fix(deploy): monorepo-aware lock file and node_modules resolution#122
fix(deploy): monorepo-aware lock file and node_modules resolution#122harrisrobin wants to merge 1 commit intocloudflare:mainfrom
Conversation
…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.
|
/bigbonk review please |
commit: |
| // 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; |
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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:
| 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 { |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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.
| 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; |
There was a problem hiding this comment.
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.
|
Review posted. Here's a summary of my feedback: Overall: Clean PR. The Comments posted (5):
|
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
a548b93 to
1fba5ae
Compare
Problem
vinext deployfails in monorepos in two distinct but related ways, both caused by the same assumption: that the project's lock file andnode_modulesare always in the app directory rather than potentially hoisted to a workspace root.1. Wrong package manager detected →
npm error Invalid Version:detectPackageManageronly checked forbun.lockb(legacy binary format), missingbun.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 tonpm install -D, producing:2. Wrangler binary not found →
ENOENTdetectProjectcheckedhasCloudflarePlugin,hasRscPlugin, andhasWrangleronly in{root}/node_modules, producing false negatives for all three when packages are hoisted.runWranglerDeployhardcoded the same path, crashing with: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:
Both features are then built on top of it:
findInNodeModulesreplaces all four hardcodedfs.existsSync(path.join(root, "node_modules", ...))calls indeploy.ts, and provides the resolved binary path torunWranglerDeploy.Tests
12 new unit tests:
Supersedes #119 and #121 (closing those).
/bonk