Skip to content

fix(cli): prefer project lockfile over invocation package manager#1340

Merged
sorccu merged 4 commits into
mainfrom
simo/red-565-cli-package-manager-detection-can-pick-the-wrong-package
Jun 3, 2026
Merged

fix(cli): prefer project lockfile over invocation package manager#1340
sorccu merged 4 commits into
mainfrom
simo/red-565-cli-package-manager-detection-can-pick-the-wrong-package

Conversation

@sorccu
Copy link
Copy Markdown
Member

@sorccu sorccu commented Jun 3, 2026

Problem

detectPackageManager consulted ambient signals — the npm_config_user_agent env var and the JS runtime — before the project's lockfile. These describe how the CLI was launched, not what the project uses, so:

  • Running npx checkly in a pnpm repo reported an npm user agent → we detected npm → project parsing then failed to find an npm lockfile.
  • Internally, pnpm vitest leaks a pnpm user agent into the CLI process, so tests detected pnpm even when the fixture used a different package manager.

Fix

Detection now runs a lockfile-required pass first: a user-agent/runtime signal only counts when that package manager has a backing lockfile in the directory lineage. When no lockfile exists anywhere (e.g. running outside a repo), it falls back to the previous un-gated order, ultimately defaulting to npm. Behavior is therefore unchanged when there's no lockfile, and the lockfile wins whenever one is present.

cnpm

cnpm shares npm's package-lock.json. CNpmDetector now claims it so cnpm stays detectable, and:

  • a bare package-lock.json defaults to npm;
  • a cnpm user agent alongside one resolves to cnpm;
  • running cnpm inside a pnpm repo still resolves to pnpm (no cross-package-manager regression).

Tie-breaking by priority (not detector order)

Detection tie-breaks previously depended on the order detectors were supplied in. Most visibly, the executable-on-PATH tier returned the first detector whose binary was found — and since npm is almost always installed, it beat cnpm even though its presence is no signal.

Each detector now carries an intrinsic priority(method) (higher wins), and every tier selects the highest-priority match rather than the first by position, so input order no longer affects the result. npm deprioritizes its executable (so an installed cnpm/pnpm/yarn wins the executable tier) while keeping its normal priority elsewhere — it still wins the package-lock.json lockfile tie over cnpm and remains the ultimate fallback.

Tests

Unit suite (package-manager.spec.ts, 35 cases) covering every detection tier hermetically (temp dirs, bounded lineage, restricted detectors, spied executables/runtime, saved/restored user agent): lockfile-beats-ambient, no-lockfile fallback, lineage walking, co-located lockfiles, the cnpm cases, executable-tier priority (cnpm beats npm; npm alone still wins), config-file priority, and order-independence (same detectors in different orders yield the same result).

Other changes

  • Removed the now-redundant skipUserAgent workaround in boilerplate.ts.
  • Consolidated lockfile and config-file detection into single detectNearestLockfiles / detectNearestConfigFiles finders (plural, collect-all), removing the nondeterministic Promise.any-based singular variants. Both tiers now select by priority(method), so they're deterministic and order-independent.

🤖 Generated with Claude Code

sorccu and others added 4 commits June 3, 2026 22:54
detectPackageManager consulted ambient signals (npm_config_user_agent,
JS runtime) before the project's lockfile, so `npx checkly` in a pnpm
repo detected npm and later failed to find an npm lockfile, and tests
run via `pnpm vitest` inherited a pnpm user agent that overrode the
fixture's actual lockfile.

Detection now runs a lockfile-required pass first: a user-agent/runtime
signal only counts when that package manager has a backing lockfile in
the lineage. When no lockfile exists anywhere (e.g. outside a repo), it
falls back to the previous un-gated order, ultimately npm.

cnpm shares npm's package-lock.json, so CNpmDetector now claims it and
npm is ordered ahead of cnpm in knownPackageManagers: a bare
package-lock.json defaults to npm, while a cnpm user agent alongside one
resolves to cnpm. This keeps cnpm detectable without reintroducing the
cross-package-manager bug.

Also drops the skipUserAgent workaround in boilerplate.ts, now redundant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…iles

Remove the singular, Promise.any-based detectNearestLockfile (which was
nondeterministic for multi-detector calls and only used by initWorkspace)
and rename detectAllNearestLockfiles to detectNearestLockfiles as the sole
lockfile finder. initWorkspace takes the first result of its single-detector
lookup, which is equivalent to the old behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Detection tie-breaks depended on the order detectors were supplied in (the
knownPackageManagers default or a caller's custom array). Most visibly, the
executable-on-PATH tier returned the first detector whose binary was found, and
since npm is almost always installed it beat cnpm — npm's presence is no signal,
yet it always won.

Each detector now carries an intrinsic priority(method) (higher wins), and every
tier selects the highest-priority match instead of the first by array position,
so input order no longer affects the result. npm deprioritizes its executable
(priority 0) so an installed cnpm/pnpm/yarn wins the executable tier, while npm
keeps its normal priority elsewhere and still wins the package-lock.json lockfile
tie over cnpm and remains the ultimate fallback. The config tier is unchanged
(config files are mutually exclusive across package managers).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…iles

Mirror the detectNearestLockfiles design for config files: replace the
singular, Promise.any-based detectNearestConfigFile (nondeterministic when a
directory has more than one config) with a plural detectNearestConfigFiles that
collects all co-located config files, and have the detection config tier pick
the highest priority('configFile') among them. initWorkspace takes the first
result of its single-detector lookup.

No behavioral change in practice — only pnpm and deno declare a config file and
they never co-occur — but it makes the config tier priority-driven and
order-independent, consistent with every other tier.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sorccu sorccu merged commit a372cda into main Jun 3, 2026
8 checks passed
@sorccu sorccu deleted the simo/red-565-cli-package-manager-detection-can-pick-the-wrong-package branch June 3, 2026 19:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant