Two fields in package.json both claim to pin your package manager. They can't both be satisfied, the tools that read them are moving in opposite directions, and picking wrong can lock you out of your own repo.
This is a snapshot of a moving target — Node 24/25, npm 10/11, pnpm 11, as of mid-2026. The trajectory matters more than the version numbers; verify the current state before acting on the details.
Locked out by your own package.json
I created a project with pnpm init, added a couple of dependencies, and then — out of muscle memory — typed npm pkg set to tweak a field. Node came back with this:
npm error code EBADDEVENGINES
npm error EBADDEVENGINES Invalid devEngines.packageManager
npm error EBADDEVENGINES Invalid name "pnpm" does not match "npm" for "packageManager"
npm error EBADDEVENGINES {
npm error EBADDEVENGINES current: { name: 'npm', version: '10.9.8' },
npm error EBADDEVENGINES required: { name: 'pnpm', version: '^11.5.1', onFail: 'download' }
npm error EBADDEVENGINES }
Fair enough: the project says "this is a pnpm repo", and npm refuses to touch it. The trap is what comes next. The usual fix for a broken devEngines block is npm pkg delete devEngines — which is an npm command, which devEngines blocks. You need node -e or a text editor to get out.
That error is a symptom of something bigger: there are two competing fields for pinning your package manager, and the tools that read them are moving in different directions. This post is about the disagreement, the three consequences that actually bite, and the decision it forces.
Two fields, two philosophies
The two fields answer different questions:packageManager answers "which exact binary do I execute right now", while devEngines answers "what does this project require, and what should happen when reality disagrees" — and no single version string can answer both.
packageManager arrived with corepack, the shim that shipped inside Node from 16.9 as an experimental feature. Corepack puts stand-in pnpm and yarn binaries on your PATH (the shell's executable search path); when you call one, it reads the field, downloads that exact build, verifies the hash, and runs it[1]:
{ "packageManager": "pnpm@11.5.1+sha224.953c8233f7a92884eee2de69a1b92d1f2ec1655e66d08071ba9a02fa" }
The version must be exact — no ranges, because a range doesn't name a binary. The optional hash makes it a supply-chain guarantee: byte-identical tooling on every machine, or the command fails.
devEngines is the newer field, specified through the OpenJS Foundation's package-metadata interoperability working group[2] and implemented in npm since v10[3]. It declares requirements for people working on the project — runtime, package manager, platform — as semantic-versioning (semver) ranges, and each entry carries an onFail policy: error, warn, ignore, or download:
{
"devEngines": {
"runtime": { "name": "node", "version": "^24.4.0", "onFail": "download" },
"packageManager": { "name": "pnpm", "version": "^11.5.1", "onFail": "download" }
}
}
That onFail is the genuinely new capability: packageManager can only say what; devEngines also says what to do about it. npm implements the strict half — that's the EBADDEVENGINES above — and pnpm implements download. (Don't confuse either with the old engines field: that one is metadata for consumers of a published package, and says nothing about how the repo itself is developed.)
Here's who reads what:
Corepack does read devEngines.packageManager as a fallback — but even there it demands a concrete version. Hand it ^11.5.1 and it fails with expected a semver version[4]. The exact-vs-range split is a genuine design disagreement, not a formatting detail.
The three consequences that bite
A disagreement between two optional fields would be trivia — except three moves in the ecosystem turned it into something you have to take a position on.pnpm 11 switched sides
pnpm init now writes devEngines.packageManager instead of packageManager, and pnpm's docs call packageManager the legacy field[5]. So the default output of a fresh pnpm project is a package.json that a corepack-managed setup can't work with — which is exactly the collision people started reporting[4].
Keeping both fields buys drift, not peace
The obvious hedge — one field for corepack, one for everything else — is shut down explicitly by pnpm:
WARN Cannot use both "packageManager" and "devEngines.packageManager" in package.json.
"packageManager" will be ignored
That leaves two version numbers read by different tools with no mechanism keeping them in sync. The failure mode isn't a crash; it's drift — your laptop on 11.5.1, CI (continuous integration) on 11.7.2, and a lockfile diff nobody can explain.
Corepack is leaving Node
The Node.js Technical Steering Committee (TSC) voted to stop distributing corepack: it stays in Node 24 and earlier as an experimental feature, and is no longer bundled from Node 25 onwards[6]. The reasoning was blunt — package managers are packages, so a package-manager version manager inside the runtime is redundant, and improvements belong in the existing tooling, devEngines included. Corepack still exists as a standalone install; it just isn't the thing that happens to be on your machine anymore. Which means packageManager has lost its default enforcer.
Pick a lane
Since the fields don't compose, the real decision is which single mechanism owns your toolchain — and then making everything else defer to it.| Corepack lane | pnpm-native lane | External tool lane | |
|---|---|---|---|
| Field | packageManager (exact) | devEngines (range) | tool config; devEngines as a hint |
| Reproducibility | |||
| Pins Node too | |||
| After Node 25 | |||
| Best for | multi-package-manager orgs, hash-verified toolchains | pnpm-only projects | polyglot repos (Go, Python, Node) |
The pnpm-native lane — what I'd do today
{
"engines": { "node": ">=22.13" },
"devEngines": {
"runtime": { "name": "node", "version": "^24.4.0", "onFail": "download" },
"packageManager": { "name": "pnpm", "version": "^11.5.1", "onFail": "download" }
}
}
corepack disable # get the shims off your PATH
# then install pnpm however you like: brew, mise, standalone script
pnpm install
Two reasons this is now the pragmatic default. First, corepack is no longer a free ride — after Node 25 it's a dependency you install and maintain deliberately. Second, the reproducibility objection has been answered: pnpm resolves the range once and records the resolved version in pnpm-lock.yaml under packageManagerDependencies, reusing it as long as it satisfies the range[5]. Range in the manifest, exact version in the lockfile — the same model as every other dependency. As a bonus, devEngines.runtime manages Node itself, so .nvmrc and .node-version collapse into the same entry.
If you stay with corepack
Legitimate reasons exist: you want hash-verified tooling, you run repos on both yarn and pnpm and want one mechanism, or your CI already keys off packageManager. Then commit fully — drop devEngines.packageManager, keep an exact version with a hash, and make corepack an explicit setup step in CI rather than assuming Node ships it.
And if npm is already locked out by a devEngines block, this is the escape hatch:
node -e "const fs=require('fs');const j=JSON.parse(fs.readFileSync('package.json'));delete j.devEngines;j.packageManager='pnpm@11.5.1';fs.writeFileSync('package.json',JSON.stringify(j,null,2)+'\n')"
If mise or Volta owns your versions
Keep devEngines as documentation, but tell pnpm not to act on it, so two tools aren't both in charge:
# pnpm-workspace.yaml
pmOnFail: ignore
Note that pnpm 11 collapsed managePackageManagerVersions, packageManagerStrict and packageManagerStrictVersion into that single pmOnFail setting, and stopped honouring corepack's COREPACK_ENABLE_STRICT variable[7]. If you're carrying any of those in an .npmrc, they're dead config.
Rules that save you an hour
Whichever lane you pick, the failures all come from mixing lanes — so the rules are about not mixing.- In a pnpm repo, never type
npm. Usepnpm pkg setinstead ofnpm pkg set,pnpm dlxinstead ofnpx. If a coding agent works in the repo, denyBash(npm:*)andBash(npx:*)in its settings — otherwise it will cheerfully trigger the lockout and then try to fix it with more npm. - One field, not two. If a tool forces you to keep both, treat the pair as a single unit in review and add a CI check that they agree.
- Pin exactly in CI regardless of lane.
--frozen-lockfileand a pinned setup action. Ranges are for humans, not build agents. - Check your CI actions and Docker images before switching lanes. They grew up reading
packageManager;devEnginessupport is arriving unevenly, and bots like Renovate and Dependabot read these fields too. If automation starts failing right after you touch this, the field change is the first suspect.
Where this lands
My read: devEngines wins the declaration layer, and exact pinning moves into the lockfile — dissolving the exact-vs-range argument the same way it was dissolved for every other dependency. devEngines is the only contender designed as an interoperability standard rather than one tool's implementation detail, the only one covering runtime and package manager and platform, and the only one with a policy dimension; npm, pnpm, corepack and the version managers all read it already. packageManager becomes what .nvmrc is today — still everywhere, quietly legacy. The transition will take years, and the thing that actually hurts is standing in both lanes. Pick one field, commit to it, and make CI enforce it.
References
- corepack — README, nodejs/corepack
- devEngines field proposal — OpenJS Foundation package-metadata-interoperability working group
- package.json
devEngines— npm docs - Invalid package manager specification… expected a semver version — pnpm#11388
- pnpm 11 release notes — pnpm.io
- deps: remove corepack — nodejs/node#51981
- pnpm migration guide — pnpm.io
