p4ni.

Tutorial

pnpm exec Ignores Your cd, and Wrangler Deployed the Wrong Worker

· 6 min read

On this page

Four posts on this site returned 404 on the morning of August 19. Nothing had been deleted, no DNS had changed, and the build that produced those pages was sitting in dist/ on my laptop, correct and complete. What I had done was deploy a small analytics Worker from its own subdirectory, the way you would in any repo with more than one deploy target:

cd workers/hn-proxy
pnpm exec wrangler deploy

Wrangler printed a success message. It just deployed the wrong thing — the site itself, from a dist/ that was two days stale. The four posts published on the 18th and 19th did not exist in that build, so they became 404s.

The interesting part is not that I made a mistake. It is that two separate tools each walk up the directory tree looking for something, and my cd was only obeyed by one of them. Below is the measurement, because I did not believe the explanation until I had it in a terminal.

What the repo looks like

The root of the repo is the site: an Astro build deployed to Cloudflare Workers static assets. Its config sits at the root and claims the production hostname.

// wrangler.jsonc  (repo root)
{
  "name": "astro-p4ni",
  "compatibility_date": "2026-07-27",
  "assets": { "directory": "./dist", "not_found_handling": "404-page" },
  "routes": [{ "pattern": "astro.p4ni.com", "custom_domain": true }]
}

Alongside it, workers/ holds a couple of tiny Workers that have nothing to do with the site — they pull numbers out of the GA4 Data API and Search Console for my own automation. The one I was deploying that morning has since been retired in favour of a GitHub Action, so every example below uses its surviving sibling, which has the identical shape and reproduces the identical failure. Each has its own config:

// workers/gsc-stats/wrangler.jsonc
{
  "name": "p4ni-gsc-stats",
  "main": "index.js",
  "compatibility_date": "2026-08-19",
  "workers_dev": true
}

Different name, different entry point, different route. There is no ambiguity in the files. The ambiguity is in which directory the command runs from.

pnpm exec does not run where you are standing

This is the whole bug in one measurement. I built a throwaway tree — a root package.json, a sub/ directory with no package.json, and a sub-pkg/ directory with one — then asked each runner to report its working directory from each location.

pnpm exec node -e 'console.log(process.cwd())'
npx --no-install node -e 'console.log(process.cwd())'

With pnpm 10.22.0 and npm 11.4.2 on Node 24.4.1:

Shell is inpnpm exec runs innpx / npm exec runs in
repo root (has package.json)repo rootrepo root
sub/ (no package.json)repo rootsub/
sub-pkg/ (has package.json)sub-pkg/sub-pkg/

That middle row is the accident. pnpm exec resolves the nearest enclosing package — the closest ancestor directory containing a package.json — and runs the command there, not where your shell is. My workers/gsc-stats/ has no package.json, so the nearest package is the repo root, so pnpm exec quietly relocated the process to the repo root before wrangler ever started.

npx and npm exec do not do this. They add the nearest node_modules/.bin to PATH and leave the working directory alone. Worth noting for anyone who greps for it: pnpm exec also leaves INIT_CWD unset, while npx sets it to the shell’s directory. There is no environment variable carrying your original location into the child process — the information is simply gone by the time wrangler runs.

Wrangler walks up too

Wrangler looks for wrangler.jsonc or wrangler.toml starting at its working directory and continuing up the tree. That behavior is correct and useful on its own: it is why you can run wrangler deploy from a nested src/ folder and still hit your project config.

Combine the two rules and the failure writes itself:

  1. cd workers/gsc-stats — my shell moves.
  2. pnpm exec moves the process back to the repo root, because that is the nearest package.
  3. Wrangler, now standing at the repo root, searches upward for a config and finds the root wrangler.jsonc on the first try.
  4. That config says: deploy ./dist to astro.p4ni.com.

Neither tool did anything undocumented. Each one, in isolation, behaved sensibly. The composition is what bites, and composition is exactly what nobody tests.

Why nothing errored

A missing config is loud — wrangler tells you it cannot find one. A wrong config is silent, because from wrangler’s point of view nothing is wrong at all. It found a valid config, it found the assets directory that config named, and it uploaded them to the Worker that config named. The deploy is a success by every check wrangler can make.

The only signal was in the output I did not read: the Worker name in the success line was astro-p4ni, not p4ni-gsc-stats. It is one word, on a line you have seen a hundred times, and it is the difference between deploying an analytics endpoint and republishing your entire site from a stale build directory.

The stale part matters as much as the wrong-target part. dist/ is build output — untracked, whatever the last build left behind. Mine was from the 17th. Everything published since then vanished. If dist/ had been empty the site would have gone fully blank; if I had run a build first, the accidental deploy would have been a no-op and I would never have noticed the bug at all.

What actually fixes it

Five options, in the order I would reach for them.

Pass --config explicitly. This is what I adopted, because it is the one that cannot be defeated by a directory change:

pnpm exec wrangler deploy --config workers/gsc-stats/wrangler.jsonc

The path is relative to wherever the process ends up, and since pnpm exec reliably ends up at the repo root, a repo-root-relative path is stable. Run it from anywhere in the repo and you get the same deploy.

Or pass --cwd. Wrangler 4.119 has a --cwd flag: “Run as if Wrangler was started in the specified directory.” It reintroduces the directory that pnpm exec threw away:

pnpm exec wrangler --cwd workers/gsc-stats deploy

Or stop using pnpm exec for this. npx wrangler deploy and ./node_modules/.bin/wrangler deploy both respect your shell’s directory, so cd means what it says. The cost is losing pnpm’s resolution — fine for a one-off, worse as a documented team command.

Or give the subdirectory a package.json. Look again at the third row of the table: once sub-pkg/ had its own package.json, pnpm exec stopped walking and ran there. A two-line package.json in workers/gsc-stats/ makes cd mean what everyone assumes it means, and it is the right move if those Workers are heading toward being real workspace packages anyway.

And know that pnpm -C fails loudly. I tried pnpm -C workers/gsc-stats exec ... expecting it to be a clean fix. It is not — it errors with ERR_PNPM_RECURSIVE_EXEC_NO_PACKAGE, because there is no package there. That is a good failure. It refuses rather than guessing, which is precisely the property the plain pnpm exec path lacks.

The guardrail I shipped

I wrote the rule into the repo’s agent instructions, next to the directory layout, in the imperative: always pass --config; running cd workers/<name> && pnpm exec wrangler deploy overwrites the live site with a stale dist/. A comment explaining the mechanism would not have saved me — I needed the command line with the flag already in it, sitting where I copy commands from.

Two more things worth doing if you have this shape of repo. Put the deploys in package.json scripts so the flag is never retyped, and make the site’s own deploy always build first — mine is astro build && wrangler deploy behind pnpm run deploy, which is why recovery took one command and the daily scheduled deploy would have healed it within hours regardless.

The general lesson generalizes past pnpm and wrangler. When a tool searches upward for context and a runner rewrites the directory you thought you were in, the two are not composable, and the failure mode is not a crash. It is a success message about the wrong target.