The ghost node_modules that hijacked every build — how an npm install in ~/ broke 4 projects
Discoveries·

The ghost node_modules that hijacked every build — how an npm install in ~/ broke 4 projects

The error that made no sense

It was 20:10 on 2026-08-05. I was fixing the LifeLog theme animation — just adjusting some View Transition CSS rules. Local build, push, CI. Routine.

The build failed with:

Named export 'parseCookie' not found.
The requested module 'cookie' is a CommonJS module...

Weird. The cookie package in the lockfile was 2.0.1 (ESM, with parseCookie). The CI from the last commit had passed. Nobody touched dependencies. Why did it break locally?

The context: CI passes, local breaks

This is the most infuriating kind of error. The CI (GitHub Actions) builds in a clean environment — everything works. Locally, same code, same dependencies, same Node — breaks.

# Check 1: is the lockfile identical to CI's?
git show HEAD:pnpm-lock.yaml | grep -c "cookie@2.0.1"  # 2 — same

# Check 2: does the package exist in node_modules?
ls node_modules/.pnpm/cookie@2.0.1/node_modules/cookie/dist/index.js
# It does! And it exports parseCookie.

# Check 3: what does Node actually resolve?
node --input-type=module -e "import('cookie').then(m => console.log(Object.keys(m)))"
# [ 'default', 'parse', 'serialize' ]  —  parseCookie missing!

Node was resolving a different cookie than what was in the project’s node_modules. Something in the directory tree was hijacking resolution.

The struggle: following Node Module Resolution’s trail

Node resolves modules by climbing the directory tree: first the local node_modules, then the parent’s, all the way to root. If a package with the same name exists at any level above, it wins.

# Where is Node resolving 'cookie'?
node --input-type=module -e "console.log(import.meta.resolve('cookie'))"
# file://~/projetos/node_modules/cookie/index.js  ← WHAT?

~/projetos/node_modules/. That shouldn’t exist.

ls -la ~/projetos/node_modules/ | head -5
# total 253M
# drwxr-xr-x  cookies@0.7.2/

253MB of node_modules in HOME. With cookie@0.7.2 — a CJS package from 2016 that only exports parse and serialize. Astro (which uses cookie@2.0.1) imports parseCookie from cookie — Node climbed up to home, found v0.7.2, and the named export failed.

The question remained: who installed this in ~/?

cat ~/projetos/package.json
# {
#  "dependencies": {
#  "better-sqlite3": "^12.11.1",
#  "camofox-browser": "^2.4.6",
#  "puppeteer": "^25.1.0",
#  "vercel": "^34.0.0"
#  }
# }

Someone (probably a setup script or another agent) ran npm install in home on 2026-08-04 at 18:33 — and nobody noticed. better-sqlite3 pulled 453 packages, including cookie@0.7.2 as a transitive dependency. A 253MB global node_modules, silent, hijacking every Node build on the WSL.

The resolution: mv + backup

# 1. Move to backup (never rm -rf 253MB directly)
mkdir -p ~/.npm-home-backup-20260805
mv ~/node_modules ~/package.json ~/package-lock.json ~/.npm-home-backup-20260805/

# 2. Verify resolution is now correct
node --input-type=module -e "console.log(import.meta.resolve('cookie'))"
# file://~/projetos/projetos/lifelog/node_modules/.pnpm/cookie@2.0.1/...

# 3. Build
pnpm run build
#  130 page(s) built in 25.62s

Build passed. The error simply vanished.

Metrics

Metric Value
Ghost node_modules size 253 MB
Packages installed 453
Hijacking cookie v0.7.2 (2016, CJS)
Real project cookie v2.0.1 (2026, ESM)
Projects affected All on WSL (LifeLog, LEVE LAVANDA, etc.)
Time lost debugging ~5 min (found it fast by following the resolve trail)

Lessons learned

  1. import.meta.resolve() is Node’s most underrated debugging tool — when an import breaks and the package exists in node_modules, the first thing to ask is where Node is resolving it. import.meta.resolve('package') shows the REAL path. Takes 2 seconds and saves hours of debugging.

  2. A node_modules in home is NEVER intentional — if one appears, it’s trash. Node module resolution climbs the tree all the way to the filesystem root. A node_modules in /home/user/ affects every project on the machine. It cannot exist.

  3. CI vs Local: always suspect the local environment — when CI passes and local breaks, 90% of the time it’s environment contamination (another node_modules, different PATH, different Node version). The lockfile being identical proves nothing — the resolution is what matters.

  4. Automated scripts can install things without you knowing — the npm install in home wasn’t done by me. It could have been a setup script, an automated agent, or a command run in the wrong directory. Knowing what’s being installed and where is part of environment hygiene.

~/lifelog — bash
$cat about.txt
╔══════════════════════════════════════╗
║  Samuel Medeiros                    ║
║  Senior Software Engineer           ║
║  Stack: Python · TypeScript · Rust  ║
║  Projetos: Arachne, Dogwalk,        ║
║            Capivara, TatuEngine      ║
╚══════════════════════════════════════╝
      
$