npm run build is green on your laptop. You push, and Vercel, Netlify, or GitHub Actions go red with Cannot find module 'some-lib', SyntaxError: Unexpected token, or error TS2307. This is the single most common “environment drift” bug in AI-assisted coding: Claude Code or Cursor installed a dependency locally but didn’t commit the lockfile, used a Node feature your CI image is too old for, or relied on a file your machine has and the runner doesn’t.
Fastest fix (works ~70% of the time): run the exact CI Node version with npm ci locally to reproduce, then make sure the lockfile is in your latest commit. Both checks take about two minutes:
node -v # compare to the first lines of your CI log
git log --name-only -1 # did package.json change but the lockfile didn't?
If those don’t explain it, work down the diagnosis table below. The whole loop is a 5-minute reproduce-and-pin flow, not a guessing game.
First, which bucket are you in?
Match your red CI line to the most likely cause, then jump to the matching cause section. Ordered by how often each one is the culprit.
| CI error you see | Most likely cause | Jump to |
|---|---|---|
npm ci “out of sync”, ERR_PNPM_OUTDATED_LOCKFILE, “lockfile not up to date” | Lockfile not committed (or pnpm version drift) | Cause 1 |
SyntaxError: Unexpected token, “is not a function” on a built-in | CI Node older than your local Node | Cause 2 |
command not found: tsx / vite / esbuild | Tool was global locally, not in devDependencies | Cause 3 |
Cannot find module './button' but the file clearly exists | Filename casing mismatch (Linux is case-sensitive) | Cause 4 |
Value is undefined at build, or a PUBLIC_*/NEXT_PUBLIC_* var is empty | Env var exists locally, missing in CI | Cause 5 |
ENOENT: no such file or directory reading a config or fixture | Dev-only mock or local-only file referenced in config | Cause 6 |
Common causes
1. Lockfile not committed (most common)
The agent ran npm install some-lib and committed package.json, but not package-lock.json / pnpm-lock.yaml / yarn.lock. CI runs npm ci (or pnpm install --frozen-lockfile), which refuses to touch an out-of-sync lockfile:
npm error code EUSAGE
npm error `npm ci` can only install packages when your package.json and
npm error package-lock.json or npm-shrinkwrap.json are in sync.
npm error Missing: some-lib@2.3.0 from lock file
pnpm fails the same way with a different string:
ERR_PNPM_OUTDATED_LOCKFILE Cannot install with "frozen-lockfile" because
pnpm-lock.yaml is not up to date with package.json
If your CI runs a plain npm install instead of npm ci, it may silently resolve a different version than your laptop, which then breaks at build or runtime instead. That is harder to spot, which is exactly why CI should use npm ci.
A subtler variant for pnpm: the lockfile is committed, but it was generated by a different pnpm major than the one CI runs, so the format no longer matches. Pin the pnpm version too (see Step 3).
How to spot it: git log --name-only -1 on the latest commit. If package.json changed but the lockfile didn’t, this is it.
2. Local Node version is newer than CI
You’re on Node 24, the runner is on Node 20 (or older). The agent used a recent API such as Array.prototype.toSorted() (Node 20+), Object.groupBy() (Node 21+), or the built-in fetch/structuredClone globals, and CI throws. Stale boilerplate that still pins Node 18 is a frequent offender: Node 18 reached end of life in April 2025 and most platforms have moved off it, so an old .nvmrc or workflow can leave you on an unsupported runtime that behaves differently from your machine.
SyntaxError: Unexpected token 'await'
at wrapSafe (node:internal/modules/cjs/loader:1378:18)
Current platform defaults as of June 2026, useful when nothing is pinned:
| Platform | Default Node | Notes |
|---|---|---|
| Vercel | 24.x | Default since Feb 2026; major-version only |
| Netlify | 22 | Default since Feb 2025 |
GitHub Actions setup-node | whatever you set | No safe default; an unset version is a trap |
How to spot it: run node -v locally and compare to the first lines of the CI log (where the runner prints its Node version). A major-version gap, or a CI that prints Node 18, is highly suspicious.
3. AI relied on a globally installed tool
The agent put tsx, esbuild, vite, or tsc directly in a build script, assuming it’s on PATH. You installed it globally once and forgot; CI starts from a clean image with only what’s in your lockfile, so it fails:
sh: 1: tsx: command not found
The fix is to add the tool to devDependencies and call it via npx or an npm script so it resolves from node_modules/.bin.
How to spot it: reproduce in a clean container (Step 5). The fastest signal is that a clean npm ci then npm run build fails locally too.
4. Filename casing mismatch
macOS and Windows are case-insensitive by default; Linux (every cloud CI image) is strict. The agent wrote import Button from './button' on macOS where the file is actually Button.tsx. Local resolves, CI fails:
Cannot find module './button' or its corresponding type declarations.
This also bites when a file was renamed only by case (button.tsx to Button.tsx): git may not record the change unless core.ignorecase is handled, so CI still sees the old name.
How to spot it: git ls-files | grep -i button shows the exact on-disk casing git is tracking. Compare it character-for-character with the import.
5. Env var exists locally, missing in CI
The agent added process.env.SOME_API_KEY. Your local .env has it; the deploy platform doesn’t. At build time it resolves to undefined, and if it was inlined into client code you get a runtime crash like undefined is not a function. Astro, Next.js, and Vite read env at build time, so CI usually fails immediately rather than at runtime. Client-exposed vars also need the right prefix or the bundler drops them.
How to spot it: grep the recent diff for every process.env.X and cross-check the deploy platform’s environment-variable panel.
6. AI left in a dev-only mock or local-only file
While debugging, the agent pointed next.config.js or astro.config.mjs at a fixture, a local JSON file, or a path that only exists on your disk. The reference is committed but the file isn’t, so the build can’t find it:
Error: ENOENT: no such file or directory, open '/vercel/path0/mocks/data.json'
How to spot it: git diff main -- '*.config.*' to review every config change, and check whether any path it references is actually committed (git ls-files | grep data.json).
Shortest path to fix
Ordered by ROI. Steps 1-2 fix the large majority of cases.
Step 1: Reproduce locally with CI’s exact Node version and npm ci
This is the highest-value action because it turns a slow push-and-pray loop into a local one:
# Read the runner's Node version from the first lines of the CI log, e.g. 22.14.0
nvm install 22.14.0
nvm use 22.14.0
rm -rf node_modules
npm ci # ci, not install — this honors the lockfile exactly like CI
npm run build
If it reproduces, iterate locally until green. If local still passes, the gap is platform env vars or the CI image itself, so jump to Step 4.
Step 2: Check the lockfile is in the most recent commit
git log --name-only -5 | grep -E '(package-lock|pnpm-lock|yarn.lock)'
If the last few commits touched package.json but never the lockfile, fix it now:
npm install # regenerate the lockfile from package.json
git add package.json package-lock.json
git commit -m "chore: sync lockfile after AI dependency add"
For pnpm use pnpm install and git add pnpm-lock.yaml; for yarn, yarn install and git add yarn.lock.
Step 3: Pin the Node (and pnpm) version everywhere
The clean way to avoid four copies of the version drifting apart is one source of truth: a .nvmrc file that CI reads.
echo "22.14.0" > .nvmrc
GitHub Actions, read that same file instead of hard-coding a version:
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'npm'
Also declare the supported range in package.json so a wrong local Node fails loudly:
{
"engines": {
"node": ">=22.0.0 <23.0.0",
"npm": ">=10.0.0"
}
}
Vercel: Settings -> Build and Deployment -> Node.js Version, pick the major (e.g. 22.x); Vercel only exposes major versions and applies minor/patch security updates itself. Note that an engines.node major in package.json overrides this dropdown. Netlify: set NODE_VERSION in netlify.toml, which beats the UI:
[build.environment]
NODE_VERSION = "22"
If you use pnpm, also pin it via the packageManager field so CI uses the same major that wrote the lockfile:
{ "packageManager": "pnpm@9.15.0" }
Step 4: Reconcile env vars
List every process.env.X in code and diff it against the deploy platform:
grep -rEo 'process\.env\.[A-Z0-9_]+' src/ | sort -u
Add anything the platform is missing, then redeploy. Client-visible vars need the right prefix or the bundler strips them at compile time: NEXT_PUBLIC_* (Next.js), PUBLIC_* (Astro), VITE_* (Vite). A wrong prefix reads as undefined.
Step 5: Reproduce in a clean container
The ultimate reproduction: a fresh image with no global tools and a strict Node version, just like the runner.
docker run --rm -it -v "$(pwd):/app" -w /app node:22.14.0 sh -c "
npm ci &&
npm run build
"
If this passes but CI still fails, the remaining difference is almost always platform env vars (Step 4) or a stale build cache, so clear the cache and redeploy.
How to confirm it’s fixed
You’re done when all three are true:
- A clean local run reproduces CI exactly:
rm -rf node_modules && npm ci && npm run buildis green on the pinned Node version. git statusshows the lockfile committed alongside anypackage.jsonchange (nothing staged or dirty after the build).- The next CI run is green on the same Node version your
.nvmrcdeclares (check the version line in the log).
Prevention
- One source of truth for Node: a
.nvmrcread by CI vianode-version-file, mirrored byengines.nodeinpackage.jsonand the platform dropdown. Fewer copies, less drift. - Always run
npm ci(notnpm install) in CI so lockfile drift fails loudly instead of silently resolving a different version. - Add to
CLAUDE.md/.cursorrules: “Installing a dependency requires committing the lockfile in the same commit aspackage.json.” - Pre-commit hook: if
package.jsonchanged, require the lockfile to have changed too. - Enforce case-exact import paths: enable ESLint
import/no-unresolvedwith a case-sensitive resolver, oreslint-plugin-importcasing checks. - Commit a
.env.exampleand have CI verify every required env var is set before building. - Pin the package manager itself (
packageManagerfield) so pnpm/yarn lockfile format stays consistent across machines.
FAQ
Why does npm ci fail when npm install succeeds?
npm install will quietly update the lockfile to match package.json; npm ci refuses to and errors out if they disagree. That strictness is the point: CI should fail when the lockfile is stale rather than silently install a different dependency tree. Commit the regenerated lockfile and the error goes away.
My CI log doesn’t print the Node version. How do I find what it used?
On GitHub Actions, the setup-node step logs the resolved version; without that step the runner uses its image default, which changes over time. Add a one-line - run: node -v step to make it explicit, then pin it. On Vercel and Netlify the version appears near the top of the build log.
I pinned Node 18 a year ago and it still “works” locally. Should I bump it? Yes. Node 18 reached end of life in April 2025, so it gets no security patches and platforms are retiring it (Vercel defaults to 24.x, Netlify to 22 as of June 2026). Move to an active LTS such as 22 or 24, run your full build on it locally first, and pin the new version in all the same places.
It only fails on CI, never locally, even with npm ci. What’s left?
That points away from dependencies and toward the environment: a missing env var (Step 4), filename casing that only Linux enforces (Cause 4), or a file referenced in config that wasn’t committed (Cause 6). The Docker reproduction in Step 5 is the fastest way to surface casing and missing-file issues; env vars are the residual once Docker passes.
Can I just delete the lockfile to make the error stop? No. Deleting it makes every install non-reproducible, so CI and your laptop can drift to different versions on any run. The fix is to commit an up-to-date lockfile, not to remove the check.
Related
- AI code broke build
- AI package lock conflicts
- AI pre-commit review workflow
- Claude Code SEO audit
- AI dependency upgrade workflow
- AI Agent Loops Without Making Progress
- AI Generated TypeScript Errors
- AI Added a Route That Bypasses Auth Middleware
- AI Invented a Wrong API Signature That Does Not Exist
- AI-Generated SQL Locks a Hot Table for Minutes
- AI Keeps Using Deprecated Syntax Despite Lint Errors
- AI Uses npm Commands in a pnpm or Yarn Project
Tags: #AI coding #Debug #Troubleshooting