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 in | pnpm exec runs in | npx / npm exec runs in |
|---|---|---|
repo root (has package.json) | repo root | repo root |
sub/ (no package.json) | repo root | sub/ |
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:
cd workers/gsc-stats— my shell moves.pnpm execmoves the process back to the repo root, because that is the nearest package.- Wrangler, now standing at the repo root, searches upward for a config and finds the root
wrangler.jsoncon the first try. - That config says: deploy
./disttoastro.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.