DEV Community

Deek Roumy
Deek Roumy

Posted on

AsyncAPI CLI's Hidden pnpm Bug: A Deep Dive into BROWSERSLIST_ROOT_PATH

When you install a CLI tool globally with pnpm, you probably don't think twice about it. Run pnpm install -g @asyncapi/cli, and you expect it to just work. But buried in issue #1781, there was a subtle, reproducible bug that only appeared when using pnpm — not npm, not yarn — and only globally. This is the story of how we found it, why the obvious fix was wrong, and what the right fix looks like.

The Bug: When pnpm's Shell Scripts Confuse browserslist

Here's what happens when you install the AsyncAPI CLI globally with pnpm:

/usr/local/bin/
  asyncapi          <- shell wrapper script
  browserslist      <- another shell wrapper script (THIS is the problem)
Enter fullscreen mode Exit fullscreen mode

pnpm creates shell wrapper scripts for every binary exposed by an installed package. The browserslist package exposes a browserslist CLI binary, so pnpm dutifully creates a shell script at /usr/local/bin/browserslist.

Now here's where it gets weird. When browserslist runs inside AsyncAPI CLI's build process, it walks up the directory tree looking for a config file — a .browserslistrc or a browserslist key in package.json. It looks for a file named browserslist. And it finds one: the shell script pnpm created.

browserslist tries to parse that shell script as a browser targets config. It fails. The CLI crashes with a confusing error that has nothing to do with what you were actually trying to do.

Error: Unknown browser query `#!/bin/sh`
Enter fullscreen mode Exit fullscreen mode

That's the bug. A pnpm shell wrapper being misidentified as a browserslist config file.

How We Found It

This came up during a bounty hunt on the AsyncAPI CLI repo. The issue had been open for a while — reproducible only in specific environments, dismissed as an edge case, hard to pin down. The kind of bug that gets marked "can't reproduce" and quietly forgotten.

The key insight was noticing that the bug was pnpm-specific. npm global installs don't put a browserslist file anywhere on $PATH. Yarn doesn't either. But pnpm's architecture of creating shell script wrappers for every binary means it creates a file that browserslist's directory walker mistakes for config.

Once we understood the mechanism, the fix direction became clear.

The Wrong Fix: BROWSERSLIST=defaults

The first instinct (and one approach floated in the issue) was to set:

BROWSERSLIST=defaults
Enter fullscreen mode Exit fullscreen mode

This environment variable overrides whatever browserslist would normally resolve. No file lookup, no confusion. Problem solved, right?

Wrong. Setting BROWSERSLIST=defaults is a nuclear option. It doesn't just skip the bad file — it ignores all config, including valid project-level .browserslistrc files that users might have for legitimate reasons. If you're using AsyncAPI CLI in a project with specific browser targeting (say, you need to support IE11 for some internal tool), BROWSERSLIST=defaults silently overrides your config.

It's fixing a path traversal bug by throwing away the map.

// Wrong fix — overrides user config
process.env.BROWSERSLIST = 'defaults';

// This would break if a user has:
// .browserslistrc: "last 2 versions, IE 11"
// Their config is silently ignored
Enter fullscreen mode Exit fullscreen mode

The Right Fix: BROWSERSLIST_ROOT_PATH

The correct environment variable is BROWSERSLIST_ROOT_PATH. This tells browserslist where to start its config search — restricting it to a specific directory subtree instead of walking all the way up to the filesystem root (and into /usr/local/bin).

// Right fix — restricts search scope without overriding user config
if (!process.env.BROWSERSLIST_ROOT_PATH) {
  process.env.BROWSERSLIST_ROOT_PATH = process.cwd();
}
Enter fullscreen mode Exit fullscreen mode

With this set, browserslist will look for config starting from the current working directory — which is the project directory, where the config legitimately belongs. It won't walk up into /usr/local/bin and it won't find pnpm's shell script.

The key difference: BROWSERSLIST=defaults replaces the query. BROWSERSLIST_ROOT_PATH scopes the search. The former ignores user config; the latter just prevents the wrong directory from being searched.

We also set it conditionally (if (!process.env.BROWSERSLIST_ROOT_PATH)) so that users who explicitly set this variable in their environment retain control. The CLI should be a good citizen.

Why Tests Matter: 6 vs 0

Our PR (#2076) includes 6 tests covering this fix:

  1. pnpm global install scenario (simulated)
  2. BROWSERSLIST_ROOT_PATH correctly restricts search scope
  3. User-set BROWSERSLIST_ROOT_PATH is respected (not overridden)
  4. Valid project-level .browserslistrc still works after the fix
  5. BROWSERSLIST=defaults regression test (confirm we didn't take the wrong path)
  6. Clean environment fallback behavior

A competing approach had zero tests. Zero.

This isn't gatekeeping — it's how you know the fix actually does what it claims. Without tests, you can't verify that user config is preserved. You can't confirm the conditional logic works. You can't catch regressions when browserslist releases a new version that changes traversal behavior.

Tests are the proof that you understand the problem, not just that you made the error go away.

Lessons: Environment Variable Isolation in Node.js CLIs

This bug surfaces a broader pattern worth internalizing when building Node.js CLIs:

1. Know what your dependencies assume about the environment.
browserslist assumes it can walk up the filesystem. That's fine in a dev dependency context. It's a problem in a globally-installed CLI where the filesystem layout is controlled by the package manager.

2. Prefer scoping over overriding.
BROWSERSLIST_ROOT_PATH scopes. BROWSERSLIST=defaults overrides. In CLI tools used inside user projects, scoping is almost always the right answer. You want to be a guest in the user's environment, not a landlord.

3. Be conditional when setting environment variables.

// Bad: always stomps on user's setting
process.env.BROWSERSLIST_ROOT_PATH = process.cwd();

// Good: only set if not already configured
if (!process.env.BROWSERSLIST_ROOT_PATH) {
  process.env.BROWSERSLIST_ROOT_PATH = process.cwd();
}
Enter fullscreen mode Exit fullscreen mode

4. Package manager differences matter.
npm, yarn, and pnpm have different approaches to global installs. pnpm's shell script wrappers are elegant for many reasons, but they create files in $PATH directories that some libraries might misidentify. Test your CLI with all three package managers.

5. Environment variable bugs are invisible until they're not.
This bug was silent for a long time because it only triggered in a specific combination: pnpm + global install + a build step that invokes browserslist. The kind of thing that makes a user on Stack Overflow post "works on my machine" for years before someone connects the dots.


The fix itself is small — a few lines. But the reasoning behind it required understanding how browserslist traverses, how pnpm lays out global installs, and why "make the error go away" isn't the same as "fix the bug."

That's what bounty hunting teaches you: the interesting part is never the code change. It's the archaeology.

PR: asyncapi/cli#2076

Top comments (0)