I maintain Slashi, a VS Code extension that, among other things, offers your project's commands in a popup: run a script, install dependencies, add a package. To do that it has to answer a question every editor plugin, CI script and onboarding doc eventually runs into: which package manager does this project use?
I assumed it was a lockfile check. It mostly is. The part that bit me came later, as a bug report: a Yarn 1 project where Slashi suggested yarn dlx, which doesn't exist in Yarn 1.
This post is the detection order I ended up with, the Yarn split that matters, and the cases it still gets wrong.
Step 1: trust packageManager first
If package.json has a packageManager field, it's the most explicit statement a project can make. Corepack reads it, and so should your tool.
{
"name": "my-app",
"packageManager": "pnpm@9.12.0"
}
Only the prefix before @ matters for picking the tool. The version matters later, for Yarn.
Step 2: fall back to lockfiles and workspace files
No field? Look at what's on disk, in the package's own folder first and then at the workspace root (in a monorepo, the lockfile usually lives only at the root):
| File | Package manager |
|---|---|
pnpm-lock.yaml, pnpm-workspace.yaml
|
pnpm |
yarn.lock, .yarnrc.yml
|
Yarn |
bun.lock, bun.lockb
|
Bun |
package-lock.json, npm-shrinkwrap.json
|
npm |
| nothing | npm (the default that ships with Node) |
Here's that logic as a standalone function (Node 18+, no dependencies):
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
const LOCKFILES = [
['pnpm', ['pnpm-lock.yaml', 'pnpm-workspace.yaml']],
['yarn', ['yarn.lock', '.yarnrc.yml']],
['bun', ['bun.lock', 'bun.lockb']],
['npm', ['package-lock.json', 'npm-shrinkwrap.json']],
];
export function detectPackageManager(dir, workspaceRoot = dir) {
const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
const field = typeof pkg.packageManager === 'string' ? pkg.packageManager : '';
for (const [pm] of LOCKFILES) if (field.startsWith(pm)) return pm;
for (const folder of [dir, workspaceRoot]) {
for (const [pm, files] of LOCKFILES) {
if (files.some((f) => existsSync(join(folder, f)))) return pm;
}
}
return 'npm';
}
console.log(detectPackageManager(process.cwd())); // e.g. "pnpm"
The order of that table is a decision, not a fact. A repo with both pnpm-lock.yaml and a stale package-lock.json is common after someone switches tools and forgets to delete the old file. Checking pnpm, Yarn and Bun before npm means the leftover npm lockfile loses, which matched every case I've seen. It's still a guess.
Step 3: if it's Yarn, find out which Yarn
"Yarn" is two tools with one name. Yarn 1 (Classic) is frozen; Yarn 2 and later (Berry, now Yarn 4) is a rewrite with different commands. Getting the generation wrong produces commands that fail outright.
These are the commands Slashi now picks for each, from the detection code (Slashi 0.6.0, October 2026):
| Task | Yarn 1 (Classic) | Yarn 2+ (Berry) |
|---|---|---|
| Install from the lockfile only | yarn install --frozen-lockfile |
yarn install --immutable |
| Update packages | yarn upgrade |
yarn up <packages> |
| List outdated | yarn outdated |
yarn upgrade-interactive |
| Security audit | yarn audit |
yarn npm audit |
| List top-level dependencies | yarn list --depth=0 |
yarn info --name-only |
| Run a package without installing it | npx |
yarn dlx |
The signals I use for "this is Berry":
-
packageManagersaysyarn@2or higher. If it saysyarn@1.22.22, it's Classic, end of story. - Otherwise, a
.yarnrc.ymlexists. Yarn 1 reads.yarnrc(no.yml), Berry reads.yarnrc.yml. - Otherwise, assume Classic.
export function isYarnBerry(pkg, hasYarnrcYml) {
const major = /^yarn@(\d+)/.exec(pkg.packageManager ?? '')?.[1];
if (major) return Number(major) >= 2;
return hasYarnrcYml;
}
One more difference that isn't Yarn's
Passing extra arguments to a script works differently too. pnpm run test --watch, yarn test --watch and bun run test --watch all hand --watch to the script. npm needs a separator: npm run test -- --watch. Without the --, npm treats --watch as its own option. If your tool appends arguments, add the -- for npm only.
Where this still guesses wrong
-
A Berry project with no
.yarnrc.ymland nopackageManagerfield. It's possible, and my rules call it Classic. As far as I can tell it's rare, because Berry projects almost always commit.yarnrc.yml, but it isn't impossible. -
Two current lockfiles on purpose. Some repos keep both a
yarn.lockand apackage-lock.jsonfor different consumers. The fixed order picks pnpm, then Yarn, then Bun, then npm, which may not be what the maintainers intend. -
Deno, and Bun's npm compatibility. A project with both
deno.jsonandpackage.jsonneeds its own rules. I treat Deno tasks separately and don't try to pick between them. - The installed version. All of this reads files. It doesn't check that the right package manager is installed, which Corepack is better at.
If your tool has to get this right, read packageManager first, use lockfiles second, and treat "Yarn" as two different tools.
I'm the author of Slashi, which uses this detection to show a project's scripts and package-manager commands in VS Code. It's free and MIT-licensed: Marketplace · Open VSX · GitHub.
Top comments (0)