On Windows, pnpm 12.3.4 through 12.4.1 kills pnpm install with ERR_PNPM_PACKAGE_MANAGER_REMOVE_MODULES_DIR Failed to remove modules directory contents: Access is denied. (os error 5) while clearing node_modules. By the end of this you will know which of four causes produced that string on your machine, the upgrade that fixes the common one, and the unlink sequence for a host you cannot upgrade today.
Overview
The short version: Windows reports a junction as a symlink rather than a directory, so pnpm's Rust purge path took the file branch and called DeleteFileW, which refuses directory links and returns ERROR_ACCESS_DENIED. Upgrade to pnpm 12.4.2, released 15 September 2026. Everything after step 4 is for readers stuck on an older binary, or readers who upgraded and still see the string, because three other conditions print it.
Two details make this worth writing down rather than guessing at. The break and the fix both landed inside a ten-day window in September 2026, and the reason a junction escapes the directory branch is visible only in the merged patch, not in any published doc. If you have been working through the pnpm 12 upgrade traps, treat this as one more Rust-CLI regression in that family. The shape also rhymes with uv refusing to install into a symlinked directory: a package manager rewritten in Rust, a link type the old implementation tolerated, a new one that does not.
Prerequisites
- Windows 10 or 11, or a
windows-latestGitHub Actions runner. Linux and macOS never hit the junction path, sinceremove_fileunlinks a symlink there without complaint. - pnpm 12.x, checked with
pnpm --version. Affected releases run from v12.3.4 (4 September 2026) to v12.4.1 (10 September 2026). The reporter in pnpm issue #14790 confirmed 12.3.4 and 12.4.0. - Node.js 20 or newer, and a shell from which you can run PowerShell and
cmd.exe. - Write access to
package.jsonfor thepackageManagerpin, and to your continuous integration (CI) workflow file.
One assumption I am making: the purge itself is legitimate. pnpm clears node_modules when the settings that shaped the existing tree no longer match the settings for the install about to run, and step 2 is how you confirm that rather than assume it.
Step-by-step
1. Read the string before you touch anything
pnpm install 2>&1 | tail -20
Two error codes carry the words "modules dir" and they want opposite fixes. ERR_PNPM_PACKAGE_MANAGER_REMOVE_MODULES_DIR means pnpm tried to purge and the filesystem refused. ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY means pnpm never tried, because there was no terminal attached to confirm on, which is the case described in issue #11562. Step 6 covers that second one; the rest of this page is about the first.
2. Find out why pnpm wants to purge
cat node_modules/.modules.yaml | grep -E "nodeLinker|virtualStoreDir|hoistPattern|publicHoistPattern"
pnpm writes the settings your current tree was built with into node_modules/.modules.yaml and compares them against the settings for this run. A changed nodeLinker is the usual trigger, and it is the exact reproduction in issue #14790: add a pnpm-workspace.yaml containing nodeLinker: hoisted after a default install. The default is isolated, per the node-modules settings reference. A drifting virtualStoreDir is the other one. Issue #12307 shows .modules.yaml written twice with conflicting values when enableGlobalVirtualStore: true and the install runs from inside a workspace package, which re-purges on every install even though nothing changed.
3. List the directory links pnpm is choking on
Get-ChildItem -Force node_modules |
Where-Object { $_.LinkType } |
Select-Object Name, LinkType, Target
Every row with a LinkType of Junction or SymbolicLink is a candidate. From cmd.exe, dir /A:L node_modules gives the same picture, and fsutil reparsepoint query node_modules\is-odd confirms one specific entry is a reparse point. An isolated install creates one such link per direct dependency, so a plain pnpm install on a project with a single dependency is enough to reproduce the failure.
4. Upgrade to 12.4.2, then pin it
pnpm self-update
pnpm --version # expect 12.4.2 or newer
PR #14798, merged 14 September 2026, replaces the std::fs::remove_file call in crates/package-manager/src/install/prepare_modules_state/purge.rs with pnpm_fs::remove_dirent, which routes directory links through remove_symlink_dir and retries transient locks. The v12.4.2 release notes state it plainly: pnpm install on Windows no longer fails with this code when clearing a node_modules that contains linked dependencies, "such as when changing nodeLinker."
Pin the version so a stale global binary on one laptop or one runner does not walk you back into it:
{
"packageManager": "pnpm@12.4.2"
}
5. If you cannot upgrade today, unlink before you install
for /d %d in (node_modules\*) do rmdir "%d" 2>nul
rmdir /s /q node_modules
rmdir against a junction removes the link and leaves the target alone, which is the call the old code path skipped. Run it from cmd.exe, not from Windows PowerShell 5.1, for the reason in the pitfalls below. Then run pnpm install normally.
6. Make CI answer the purge question instead of aborting
- run: pnpm install --config.confirmModulesPurge=false
env:
CI: "true"
confirmModulesPurge appears in neither the settings reference nor the pnpm install CLI page; the error text is its documentation, and issue #6778 is where the --config. prefix form is confirmed. Setting CI=true has the same effect. Apply this only to the NO_TTY variant, since it does nothing at all for os error 5.
Verify it works
Reproduce the original trigger deliberately in a throwaway directory:
mkdir pnpm-purge-check && cd pnpm-purge-check
pnpm init
pnpm add is-odd@3.0.1
printf 'nodeLinker: hoisted\n' > pnpm-workspace.yaml
pnpm install
On 12.3.4 through 12.4.1 that last pnpm install exits non-zero with Failed to remove modules directory contents: Access is denied. (os error 5). On 12.4.2 it completes on the first attempt, and the word "first" is load-bearing: the broken versions often succeed on a second run, because the failed pass removed enough of the tree to change what the next pass encounters.
Then confirm the tree really flipped linker:
Select-String -Path node_modules\.modules.yaml -Pattern "nodeLinker"
Get-ChildItem -Force node_modules | Where-Object { $_.LinkType } | Measure-Object
A hoisted tree reports a nodeLinker: hoisted line and a link count at or near zero.
Common pitfalls
Filing it as flaky CI. The retry-succeeds pattern looks exactly like a race, and the usual response is a retry: 2 on the job. The failure is a deterministic type check, and the retry buys silence until the first pass fails somewhere less convenient, such as a release build.
Remove-Item -Recurse in Windows PowerShell 5.1. It has a long history of walking through a junction instead of deleting it, which turns a cleanup step into deleting the contents of your pnpm content-addressable store. Use cmd /c rmdir, or PowerShell 7.
Reaching for confirmModulesPurge=false first. It suppresses the confirmation prompt, and the removal still runs. On the Windows bug the purge proceeds and fails anyway; on a repo whose node_modules should never have been purged, it deletes the tree without asking.
Assuming 12.4.2 ends every occurrence. A genuinely locked entry produces the same code: an antivirus (AV) scan, the Windows Search indexer, a node.exe still running, an editor holding a file open. On 12.4.2 the message is worth reading, because PR #14605, merged 8 September 2026, changed the output from a bare OS error to Failed to remove C:\...\node_modules\is-odd from the modules directory, naming the entry. Open the path, close whatever holds it.
A symlinked node_modules on a deploy host. Capistrano-style layouts that point node_modules at a shared cache take a full purge on every deploy. Issue #9973 has been open since September 2025 with no maintainer answer, so treat a symlinked node_modules as unsupported until one arrives.
Wrap-up
You have the version boundary (12.3.4 to 12.4.1 broken, 12.4.2 fixed), the mechanism (junction reported as a symlink, routed to DeleteFileW, refused), a PowerShell one-liner that lists the offending links, and a separating test for each of the three causes an upgrade will not touch.
The follow-up worth doing this week: pin packageManager in every repo and put that pinned version in your Windows runner matrix, so the next Rust-CLI regression surfaces in one job instead of on one engineer's laptop at 5pm. The pnpm deploy symlink escape on 11.19 is the same family of bug one layer up. For the npm-side version of one crash line covering several broken trees, see the edgesOut crash and the TypeScript 7 ESLint break.
Comments
Be the first to comment.