Back to core workflows Fix dependency resolution Tune package metadata Validate before publishing

Enforcing a Single Package Manager with only-allow

A pnpm repository that someone installs with npm install ends up with a package-lock.json next to pnpm-lock.yaml, a flat node_modules that hides phantom dependencies, and — if the stray lockfile is committed — a CI pipeline that silently starts using npm. Pinning the version with packageManager does not stop a contributor from running the wrong tool entirely. A preinstall guard does: it fails the install immediately with a message naming the right tool. This guide sets up only-allow, covers its edge cases in workspaces and CI, and adds the complementary checks that catch what a guard cannot.

Exact symptoms of a mixed-tool repository

The damage from running the wrong package manager is quiet and cumulative:

$ git status
Untracked files:
  package-lock.json
  node_modules/.package-lock.json

$ pnpm install --frozen-lockfile
 WARN  Moving @babel/core that was installed by a different package manager to "node_modules/.ignored"
 WARN  Moving eslint that was installed by a different package manager to "node_modules/.ignored"

If the stray package-lock.json gets committed, tools that detect the package manager from lockfiles — some CI templates, deployment platforms, Dependabot — may start using npm, producing a different tree than developers use. Phantom imports that pnpm would reject pass under npm's hoisting, as described in Fixing Phantom Dependencies After Switching to pnpm.

How the guard works

Every major package manager runs the root package's preinstall script before installing dependencies. only-allow inspects the npm_config_user_agent environment variable — which each tool sets to a string like pnpm/9.15.4 npm/? node/v22.12.0 linux x64 — and exits non-zero if the tool is not the allowed one.

How only-allow blocks the wrong package manager npm install runs the preinstall script; only-allow reads npm_config_user_agent, sees npm instead of pnpm, prints an error and exits non-zero, aborting the install. npm install contributor uses the wrong tool preinstall npx only-allow pnpm check user agent npm/10.9.2 ... is not pnpm exit 1 install aborted, nothing written
The guard runs before any dependency is written, so the wrong tool never creates a lockfile or node_modules.

Setting it up

{
  "name": "acme-monorepo",
  "private": true,
  "packageManager": "pnpm@9.15.4",
  "scripts": {
    "preinstall": "npx only-allow pnpm"
  }
}

The wrong tool now fails immediately:

$ npm install

> acme-monorepo@0.0.0 preinstall
> npx only-allow pnpm

╔═════════════════════════════════════════════════════════════╗
║                                                             ║
║   Use "pnpm install" for installation in this project.     ║
║                                                             ║
║   If you don't have pnpm, install it via "npm i -g pnpm".  ║
║                                                             ║
╚═════════════════════════════════════════════════════════════╝
npm error code 1

The allowed values are npm, pnpm, yarn and bun. For Yarn, only-allow yarn accepts both Classic and Berry. Because the check happens in preinstall, it also runs for npm ci, npm install <package> and npm update in the repository root, so every route by which npm could write a lockfile is covered by the same one-line script.

Avoiding the npx download

npx only-allow downloads the package on first use, which is slow and needs network access. Two alternatives avoid it:

{
  "scripts": {
    "preinstall": "node -e \"if(!/^pnpm\\//.test(process.env.npm_config_user_agent||'')){console.error('Use pnpm install in this repository.');process.exit(1)}\""
  }
}

The inline check does the same thing with no dependency. Alternatively, engines combined with engine-strict covers most cases without any script:

{
  "engines": {
    "npm": "please-use-pnpm",
    "yarn": "please-use-pnpm",
    "pnpm": ">=9.15.4"
  }
}
# .npmrc
engine-strict=true

npm checks engines.npm against its own version; an unsatisfiable range such as please-use-pnpm makes npm install fail with an EBADENGINE error that names the field — crude but dependency-free. Yarn Classic checks engines.yarn similarly. pnpm checks engines.pnpm and, with engine-strict, refuses to run an older version.

Ways to block the wrong package manager Compares only-allow via npx, an inline node check, engines with engine-strict, and packageManager with Corepack strict mode on coverage, dependencies and error clarity. blocks npm in pnpm repo extra dependency clear message npx only-allow pnpm yes downloaded by npx very clear inline node check yes none your wording engines + engine-strict yes none EBADENGINE text Corepack strict mode only via shims none clear
Use a preinstall guard for a clear message, and engines plus Corepack for defence in depth.

Edge cases in workspaces and CI

Only the root preinstall runs for the whole workspace install, so the guard belongs in the root package.json. Adding it to every workspace package is unnecessary and can break publishing: consumers who install your published package with npm would run your preinstall and be told to use pnpm. Never put an only-allow guard in a package you publish.

Scripts are skipped with --ignore-scripts. A contributor or CI job running npm install --ignore-scripts bypasses the guard. Add a CI check for stray lockfiles as a second layer (below).

Global and one-off commands. npx some-cli inside the repository does not trigger preinstall, and neither does pnpm dlx; the guard only protects installs.

Tools that call npm internally. Some tools run npm install in temporary directories, such as generators and older release tools. If they run inside the repository they hit the guard. Configure them to use the repository's package manager, or run them outside the repository.

pnpm's own preinstall ordering. pnpm runs the root preinstall before resolving dependencies, as npm does, so the guard works identically.

Detecting the package manager in your own scripts

The same npm_config_user_agent variable that only-allow reads is useful in your own tooling. Release scripts, code generators and setup helpers often need to run follow-up commands with the same package manager the user invoked — calling npm run build from a script that was started with pnpm run release mixes tools and can bypass pnpm's workspace awareness. A small helper keeps them consistent:

// scripts/pm.mjs
export function packageManager() {
  const ua = process.env.npm_config_user_agent ?? '';
  const name = ua.split('/')[0];
  return ['pnpm', 'yarn', 'bun', 'npm'].includes(name) ? name : 'npm';
}

Scripts can then spawn ${packageManager()} run build. The variable is set whenever a script runs through a package manager, so it is reliable inside package.json scripts; when a script is run directly with node, it is absent and the helper falls back to npm, which you may prefer to turn into an error in a pnpm-only repository.

Rolling the guard out to existing repositories

Adding a guard to a repository that already has mixed usage needs a small cleanup first, or the guard will block people halfway through a fix. Start by removing every stray lockfile from the repository and from .gitignore exceptions, and run a clean install with the intended tool so the committed lockfile is authoritative. Then check CI configuration, Dockerfiles and deployment settings for hard-coded npm ci or yarn install commands and change them to the intended tool — the guard would fail those jobs otherwise, which is the correct outcome but a confusing way to find them. Finally, merge the guard together with a short note in the contributing guide and the pull request description, so contributors who hit the message understand why. Deployment platforms deserve special attention: several detect the package manager from lockfiles or from packageManager, and a platform still configured for npm will fail at the guard on its next deploy.

A CI check for stray lockfiles

The guard prevents most accidents; a CI assertion catches the rest, including lockfiles created with scripts disabled:

- name: Only pnpm lockfile allowed
  run: |
    stray=$(git ls-files | grep -E '(^|/)(package-lock\.json|yarn\.lock|bun\.lockb?|npm-shrinkwrap\.json)$' || true)
    if [ -n "$stray" ]; then
      echo "Found lockfiles for other package managers:"; echo "$stray"; exit 1
    fi

Add the same patterns to .gitignore so they are never committed accidentally:

package-lock.json
yarn.lock
bun.lockb
bun.lock

Worked example: a documentation contributor's first pull request

An occasional contributor fixes a typo in the docs, runs npm install out of habit to preview the site, and opens a pull request that includes a 20,000-line package-lock.json. Reviewers catch it, but the next contributor does the same. The maintainers add "preinstall": "npx only-allow pnpm", add the other lockfile names to .gitignore, and add the stray-lockfile CI check. The next contributor who runs npm install sees the boxed message telling them to use pnpm, installs it, and the pull request contains only the typo fix. The contributing guide gains one line: "This repository uses pnpm; corepack enable sets it up."

Defence in depth against mixed package managers Four layers from packageManager and Corepack, through the preinstall guard and engines, to gitignore and a CI stray-lockfile check. 1 packageManager + Corepack the right tool runs by default 2 preinstall guard wrong tool fails before writing anything 3 engines + engine-strict catches wrong versions and wrong tools without scripts 4 .gitignore + CI check stray lockfiles cannot be committed or merged
Each layer catches what the one above misses; together they make a stray lockfile nearly impossible to merge.

Prevention and CI/CD guardrails

  • Put the guard in the root manifest only, never in published packages.
  • Ignore and check for other tools' lockfiles in .gitignore and CI.
  • Document the package manager in the README and contributing guide, with corepack enable as the setup step.
  • Combine with packageManager, so the correct tool is also the correct version.

Frequently Asked Questions

Does only-allow slow down every install? Only the first npx download takes noticeable time; afterwards it is cached. The inline node -e check has no overhead at all.

Can I allow two package managers? only-allow accepts one. If you genuinely need to support two — for example, npm for a documentation folder that is not part of the workspace — scope that folder with its own package.json and guard.

Will the guard affect people installing my published package? Only if you put it in the published package's manifest. Keep it in the private workspace root, which is never published.

What about Bun's lockfile and Deno? Bun sets its own user agent, so only-allow recognises it and a Bun install in a pnpm repository is blocked like npm. Deno's npm compatibility layer does not always run lifecycle scripts, so rely on the stray-lockfile CI check for deno.lock if contributors might use Deno.

Does the guard work when dependencies are installed by a deployment platform? Yes, if the platform uses the package manager's normal install command, because preinstall runs there too. That is useful: a platform misconfigured to use npm fails fast with a clear message instead of deploying a differently resolved tree.

Related

Package Manager Version Management