Is this project npm, pnpm, Yarn 1 or Yarn 4? How to tell, and why Yarn is the tricky one

# javascript# node# npm# tooling
Is this project npm, pnpm, Yarn 1 or Yarn 4? How to tell, and why Yarn is the tricky oneAlex

I maintain Slashi, a VS Code extension that, among other things, offers your project's commands in a...

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"
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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":

  1. packageManager says yarn@2 or higher. If it says yarn@1.22.22, it's Classic, end of story.
  2. Otherwise, a .yarnrc.yml exists. Yarn 1 reads .yarnrc (no .yml), Berry reads .yarnrc.yml.
  3. Otherwise, assume Classic.
export function isYarnBerry(pkg, hasYarnrcYml) {
  const major = /^yarn@(\d+)/.exec(pkg.packageManager ?? '')?.[1];
  if (major) return Number(major) >= 2;
  return hasYarnrcYml;
}
Enter fullscreen mode Exit fullscreen mode

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.yml and no packageManager field. 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.lock and a package-lock.json for 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.json and package.json needs 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.