The pnpm logo, a white-framed square split into a 3x3 grid of orange and dark grey squares with one square left blank, beside the pnpm wordmark in white on a near-black card

pnpm and Windows broke this blog's build twice — a shadowed Chocolatey install, then a blocked symlink

pnpm's version switcher corrupted its cache behind a shadowed Chocolatey install, and Windows separately blocked a build from symlinking its dependencies.

Updated

On this page

Introduction

While normalizing a new thumbnail image for a different article in this repo, pnpm thumbnails:fix broke completely: not a normal command failure, but pnpm refusing to run at all, on any command, in any shell. That blocked everything downstream, including the verification gates this blog’s own pipeline requires before a draft counts as checked. The obvious first guess was a stale or broken install, and that guess was wrong in an instructive way: the actual cause was pnpm’s own self-managed version-switching feature failing to finish installing itself, compounded by a second, forgotten pnpm install sitting earlier in one shell’s PATH than the copy that had just been repaired. This article covers what that self-switch failure looks like, the evidence that it is a known upstream bug rather than anything specific to this machine, how the shadowed install turned a one-line fix into a multi-step diagnosis, why that fix did not survive into the next session, and a second, unrelated Windows failure that hit later on the same machine while publishing a different article: a build blocked from symlinking its own dependencies.

The switcher broke itself

pnpm thumbnails:fix failed before running a single line of the underlying script:

[WARN] Unsupported engine: wanted: {"node":">=24"} (current: {"node":"v22.12.0","pnpm":"11.3.0"})
ERROR Failed to switch pnpm to v11.3.0. Looks like pnpm CLI is missing at "C:\Users\<user>\AppData\Local\pnpm\.tools\@pnpm+win-x64\11.3.0\bin" or is incorrect
spawnSync C:\Users\<user>\AppData\Local\pnpm\.tools\@pnpm+win-x64\11.3.0\bin\pnpm ENOENT

This repo’s package.json pins "packageManager": "pnpm@11.3.0". Modern pnpm reads that field itself and, if the exact pinned version is not already sitting in its per-user tool cache, downloads and installs it there on the fly before re-executing into it. That cache lives at PNPM_HOME/.tools/@pnpm+win-x64/<version>/, and this is pnpm managing its own version, not a separate tool like Corepack doing it.

The cache folder for 11.3.0 existed. It just did not contain a bin subfolder:

total 15
drwxr-xr-x 1 <user> 197609 0 Aug 22 10:11 .
drwxr-xr-x 1 <user> 197609 0 Aug 22 10:11 ..
drwxr-xr-x 1 <user> 197609 0 Aug 22 10:11 node_modules
-rw-r--r-- 1 <user> 197609 43 Aug 22 10:11 package.json
-rw-r--r-- 1 <user> 197609 438 Aug 22 10:11 pnpm-lock.yaml
-rw-r--r-- 1 <user> 197609 39 Aug 22 10:11 pnpm-workspace.yaml

Everything a self-install writes before placing the executable was there; the one file that makes the install usable was not. Retrying pnpm --version reproduced the identical error, which ruled out a one-off network blip: the self-managed switcher had left its own cache in a state it could not recover from on its own, and every subsequent pnpm invocation, regardless of which physical pnpm binary triggered it, hit the same missing path.

Not a fluke, and not this machine’s fault

The instinct at this point was to blame the install itself. A quick search before acting on that instinct changed the diagnosis: this exact failure, word for word, shows up across environments that share nothing with each other. Renovate’s Docker images, Render.com deployments, and a handful of standalone projects all report the identical Failed to switch pnpm to v<X>. Looks like pnpm CLI is missing... ENOENT message, tracked in open pnpm issues going back across several point releases.

pnpm’s self-managed version switcher is the fragile part, not any particular way of installing pnpm. A Docker image, a CI runner, and a personal Windows machine share no install method, no OS, and no history, yet they hit the same failure with the same wording, because they all share the one thing that matters: the switcher’s own logic for downloading and unpacking a pinned version.

The fix, and the shadow it didn’t clear

When the agent started experimenting with environment-variable workarounds to route around the broken state without explaining them first, I stopped it and asked why. That question changed the direction of the fix: instead of working around the broken pnpm, I asked the agent to reinstall pnpm properly, and approved running the official installer directly:

Terminal window
iwr https://get.pnpm.io/install.ps1 -useb | iex

That produced a fresh shim and a clean PNPM_HOME. In a PowerShell session with the updated PATH loaded, pnpm --version printed 11.3.0 cleanly. The fix worked, in that shell.

pnpm install, run next in a separate Bash session, failed with the exact same ENOENT error, referencing the exact same broken cache path, as if nothing had been reinstalled at all. The reflex at that point is to suspect the reinstall did not take, or that the cache had broken itself a second time. Neither was true:

Terminal window
which pnpm
# /c/ProgramData/chocolatey/bin/pnpm

Bash was not running the pnpm that had just been fixed. It was running a completely different, older install via Chocolatey, sitting earlier in Bash’s PATH than the fresh one, and old enough to still be exposed to the same broken-switcher behaviour. This evidence had already appeared once, in the very first diagnostic pass, in a which pnpm check run alongside the original error. It read past unnoticed then, because the loud ENOENT block was the obvious thing to react to and a single unremarkable file path sitting next to it was not.

Why PowerShell and Bash resolved pnpm to two different binariesTwo cards side by side. The left card, PowerShell, lists its PATH search order with the fresh pnpm install folder first and the Chocolatey folder after it, resolving pnpm to the fresh install with a working 11.3.0 cache. The right card, Bash, lists the same two folders in the opposite order, with the Chocolatey folder first, resolving pnpm to the older Chocolatey install and its broken version-switch cache. Both shells searched the same two folders; only the order differed, and that order decided which pnpm binary each shell actually ran.Same PATH variable, different search orderPowerShell$env:Path search order...\pnpm\bin (fresh install)...\chocolatey\binpnpm resolves toFresh install, 11.3.0 OKBash (Git Bash)$PATH search order/c/ProgramData/chocolatey/bin.../pnpm/bin (fresh install)pnpm resolves toChocolatey install, broken cache
Both shells searched the same two folders on PATH. Only the order differed, and that order is what decided which pnpm binary each one actually ran.

The fix, once the cause was visible, was one line: put the right install’s bin folder ahead of Chocolatey’s in PATH for that shell.

Terminal window
export PATH="/c/Users/<user>/AppData/Local/pnpm/bin:$PATH"
which pnpm
# /c/Users/<user>/AppData/Local/pnpm/bin/pnpm

Verification

With the right binary resolved, pnpm install completed and populated node_modules for the first time on this machine, and pnpm thumbnails:fix ran the normalization script instead of failing before it started. pnpm --version in both shells now agrees: 11.3.0, from the same install, every time.

A second, unrelated Windows failure: builds blocked from symlinking their own dependencies

A separate failure surfaced later, on the same machine, while publishing an unrelated article: pnpm build’s Vercel adapter crashed partway through, after the server bundle itself had already built successfully:

EPERM: operation not permitted, symlink '.pnpm\sharp@0.35.3_@types+node@24.13.3\node_modules\sharp' -> 'C:\repositories\personal\oharu-tech-blog\.vercel\output\functions\_render.func\node_modules\sharp'

The adapter bundles a serverless function’s dependencies by symlinking them out of pnpm’s content-addressed store, and creating a symlink is exactly the operation Windows blocks for a standard account by default. The fix is Developer Mode, not an elevated shell: Settings → Privacy & security → For developers → Developer Mode grants the SeCreateSymbolicLinkPrivilege a non-admin session otherwise lacks, and turning it on there (without running anything as Administrator) was enough. The registry key it sets, HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock\AllowDevelopmentWithoutDevLicense, is worth checking before assuming a symlink failure needs a full elevated session: absent or 0 means Developer Mode is off, and that alone reproduced this build.

The same build printed a second, easy-to-conflate warning right before the crash:

The local Node.js version (26) is not supported by Vercel Serverless Functions.
Your project will use Node.js 24 as the runtime instead.
Consider switching your local version to 24.

Node 26 is this machine’s Current release line, not the LTS line Vercel’s functions actually run on. The warning did not stop the build; the EPERM error immediately after it did, which made it easy to read the two as one failure when they are unrelated. The Node mismatch is a runtime-support warning Astro’s Vercel adapter downgrades around; the symlink error is a Windows permission gap with nothing to do with which Node version is running.

The fix that didn’t hold

The export that fixed the shadowed install earlier fixed the shell it ran in and nothing beyond it. A shell-scoped environment variable does not persist to a new terminal, so the next day, a fresh session hit the identical error again:

ERROR Failed to switch pnpm to v11.3.0. Looks like pnpm CLI is missing at "C:\Users\<user>\AppData\Local\pnpm\.tools\@pnpm+win-x64\11.3.0\bin" or is incorrect
spawnSync C:\Users\<user>\AppData\Local\pnpm\.tools\@pnpm+win-x64\11.3.0\bin\pnpm ENOENT

The agent re-ran the earlier diagnosis from scratch: delete the broken .tools\@pnpm+win-x64\11.3.0 folder and retry, to check whether this was a fresh interrupted download or the same deterministic failure. It was the same failure. where pnpm, in a shell carrying no override, again listed the Chocolatey binary first, and invoking the AppData install directly by its full path switched to 11.3.0 cleanly. The working install had never been broken. It had only ever been outranked, and the earlier fix had only changed the ranking for one shell.

This time the fix targeted the competing install itself: choco uninstall pnpm -y. That failed on the first attempt too, with UnauthorizedAccessException: Access to the path 'C:\ProgramData\chocolatey\bin\pnpm.exe' is denied — Chocolatey’s files live under C:\ProgramData, and removing them needs an elevated shell the agent cannot open on its own. I ran the uninstall myself from PowerShell opened as Administrator. With the Chocolatey package gone, where pnpm now returns exactly one path, in every shell, with no export to remember to carry forward.

Summary

The account of “a stale Chocolatey install” was wrong, and worth being wrong about out loud. What actually broke was pnpm’s own self-managed version switcher failing to finish installing a pinned version, a bug documented across unrelated environments with the identical error text. What made the fix take two passes instead of one was a second, older pnpm sitting earlier in one shell’s PATH, evidence for which had already surfaced in the first diagnostic command and gone unread. Reinstalling pnpm fixed the switcher. Checking which pnpm first, before trusting the version number a shell reports, would have caught the shadowed install on the first pass instead of the second. A PATH reorder is scoped to the shell that ran it, so it was never going to be the durable fix; removing the shadowing install outright, in an elevated shell, was.

References

Share this article