fix: dlx catalogs not found (#11308)

Fixes #10594, catalogs not being read from the workspace when using the `catalog:` protocol with the `pnpm dlx` / `pnpx` command, resulting in a catalog entry not found error.

Added e2e tests to check if the workspace config is actually loaded. Also added that pnpm dlx reads the retry options from the workspace (Could potentially put that in a separate PR)

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **Bug Fixes**
  * Fixed catalog resolution when using the `catalog:` protocol with `pnpm dlx` / `pnpx` so catalogs are correctly read from the workspace.

* **New Features**
  * `dlx` now inherits workspace catalog and fetch retry/timeout settings so CLI runs respect those local configs.

* **Tests**
  * Added tests validating catalog inheritance and failure cases for `dlx` catalog resolution.

* **Chores**
  * Updated changeset metadata to mark related packages for patch releases.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Maikel van Dort
2026-05-05 13:35:05 +02:00
committed by Zoltan Kochan
parent ab6c42d99e
commit 817b1b4c3e
3 changed files with 103 additions and 8 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/config.reader": patch
"pnpm": patch
---
Fixes #10594, catalogs not being read from the workspace when using the `catalog:` protocol with the `pnpm dlx` / `pnpx` command, resulting in a catalog entry not found error.

View File

@@ -46,19 +46,24 @@ const AUTH_CFG_KEYS = [
] satisfies Array<keyof Config>
/**
* Security policy config keys.
* Config key categories inherited by `pnpm dlx`.
*
* ## Principle
*
* `pnpm dlx` runs packages in isolation from the current project. It must not
* read project-structural settings (hoisting, linking, workspace layout, etc.)
* from local config. However, two categories of local settings DO apply:
* from local config. However, several categories of local settings DO apply:
*
* 1. **Registry & auth:** needed to reach the same package sources
* (registries, tokens, certificates).
* 2. **Security & trust policy:** these reflect the user's or organization's
* security posture and must apply regardless of how a package is installed.
* A setting that answers "what am I allowed to download?" belongs here.
* 3. **Catalogs:** the `catalog:` protocol resolves package versions through
* workspace catalog entries; without them, `pnpm dlx pkg@catalog:...` cannot
* look up the requested version.
* 4. **Fetch retry/timeout:** governs how the client talks to the registry.
* These reflect the same network environment as a regular install.
*
* Other settings are intentionally excluded. These are the ones that control
* how downloaded packages are arranged in `node_modules` (hoisting, linking,
@@ -70,6 +75,8 @@ const AUTH_CFG_KEYS = [
* |--------------------------------|--------------------|--------------------------------------------------|
* | Registry & auth | Yes | registry, _authToken, ca |
* | Security & trust policy | Yes | minimumReleaseAge, trustPolicy |
* | Catalogs | Yes | catalogs |
* | Fetch retry/timeout | Yes | fetchRetries, fetchTimeout |
* | Installation structure | No | shamefully-hoist, node-linker, hoist-pattern |
* | Workspace settings | No | link-workspace-packages, shared-workspace-lockfile|
* | Resolution strategy | No | resolution-mode, dedupe-peers |
@@ -84,6 +91,18 @@ const SECURITY_POLICY_CFG_KEYS = [
'trustPolicyIgnoreAfter',
] satisfies Array<keyof Config>
const CATALOGS_CFG_KEYS = [
'catalogs',
] satisfies Array<keyof Config>
const FETCH_CFG_KEYS = [
'fetchRetryFactor',
'fetchRetryMaxtimeout',
'fetchRetryMintimeout',
'fetchRetries',
'fetchTimeout',
] satisfies Array<keyof Config>
const NPM_AUTH_SETTINGS = [
...RAW_AUTH_CFG_KEYS,
'_auth',
@@ -108,6 +127,14 @@ function isSecurityPolicyCfgKey (cfgKey: keyof Config): cfgKey is typeof SECURIT
return (SECURITY_POLICY_CFG_KEYS as Array<keyof Config>).includes(cfgKey)
}
function isCatalogsCfgKey (cfgKey: keyof Config): cfgKey is typeof CATALOGS_CFG_KEYS[number] {
return (CATALOGS_CFG_KEYS as Array<keyof Config>).includes(cfgKey)
}
function isFetchCfgKey (cfgKey: keyof Config): cfgKey is typeof FETCH_CFG_KEYS[number] {
return (FETCH_CFG_KEYS as Array<keyof Config>).includes(cfgKey)
}
function pickRawAuthConfig<RawLocalCfg extends Record<string, unknown>> (rawLocalCfg: RawLocalCfg): Partial<RawLocalCfg> {
const result: Partial<RawLocalCfg> = {}
for (const key in rawLocalCfg) {
@@ -131,7 +158,7 @@ function pickAuthConfig (localCfg: Partial<Config>): Partial<Config> {
function pickDlxConfig (localCfg: Partial<Config>): Partial<Config> {
const result: Record<string, unknown> = {}
for (const key in localCfg) {
if (isAuthCfgKey(key as keyof Config) || isSecurityPolicyCfgKey(key as keyof Config)) {
if (isAuthCfgKey(key as keyof Config) || isSecurityPolicyCfgKey(key as keyof Config) || isCatalogsCfgKey(key as keyof Config) || isFetchCfgKey(key as keyof Config)) {
result[key] = localCfg[key as keyof Config]
}
}
@@ -143,12 +170,14 @@ export function inheritAuthConfig (target: InheritableConfigPair, src: Inheritab
}
/**
* Inherits both auth/registry settings and security/trust policy settings
* from a local config source into the target config.
* Inherits the categories listed above (auth/registry, security & trust
* policy, catalogs, and fetch retry/timeout) from a local config source
* into the target config.
*
* Used by `pnpm dlx` and `pnpm create` so that these commands respect
* the local project's registry authentication and security policies
* while ignoring project-structural settings.
* the local project's registry authentication, security policies,
* catalog definitions, and fetch behavior, while ignoring
* project-structural settings.
*/
export function inheritDlxConfig (target: InheritableConfigPair, src: InheritableConfigPair): void {
inheritPickedConfig(target, src, pickDlxConfig, pickRawAuthConfig)

View File

@@ -60,7 +60,7 @@ test('silent dlx prints the output of the child process only', async () => {
expect(result.stdout.toString().trim()).toBe('hi')
})
test('dlx ignores configuration in pnpm-workspace.yaml', async () => {
test('dlx ignores patchedDependencies in pnpm-workspace.yaml', async () => {
prepare()
// Write a pnpm-workspace.yaml with a patchedDependencies that doesn't exist
// dlx should ignore this and succeed
@@ -175,6 +175,66 @@ skipOnWindows('pnpm create respects minimumReleaseAge from pnpm-workspace.yaml',
expect(result.stderr.toString()).toMatch(/does not meet the minimumReleaseAge constraint/)
})
describe('catalogs inherited from pnpm-workspace.yaml', () => {
test('dlx succeeds when in default catalog', () => {
prepare()
writeYamlFileSync('pnpm-workspace.yaml', {
catalog: {
shx: '0.3.4',
},
})
execPnpmSync([
'dlx', 'shx@catalog:', 'echo', 'hi',
], { expectSuccess: true, omitEnvDefaults: ['pnpm_config_minimum_release_age'] })
})
test('dlx fails when not in default catalog', () => {
prepare()
writeYamlFileSync('pnpm-workspace.yaml', {
catalog: {},
})
const result = execPnpmSync([
'dlx', 'shx@catalog:', 'echo', 'hi',
], { omitEnvDefaults: ['pnpm_config_minimum_release_age'] })
expect(result.status).toBe(1)
expect(result.stderr.toString()).toMatch(/No catalog entry 'shx' was found for catalog 'default'/)
})
test('dlx succeeds when in named catalog', () => {
prepare()
writeYamlFileSync('pnpm-workspace.yaml', {
catalogs: {
shx034: {
shx: '0.3.4',
},
},
})
execPnpmSync([
'dlx', 'shx@catalog:shx034', 'echo', 'hi',
], { expectSuccess: true, omitEnvDefaults: ['pnpm_config_minimum_release_age'] })
})
test('dlx fails when not in named catalog', () => {
prepare()
writeYamlFileSync('pnpm-workspace.yaml', {
catalogs: {
shx034: {},
},
})
const result = execPnpmSync([
'dlx', 'shx@catalog:shx034', 'echo', 'hi',
], { omitEnvDefaults: ['pnpm_config_minimum_release_age'] })
expect(result.status).toBe(1)
expect(result.stderr.toString()).toMatch(/No catalog entry 'shx' was found for catalog 'shx034'/)
})
})
test('dlx should work with pnpm_config_save_dev env variable', async () => {
prepareEmpty()
execPnpmSync(['dlx', '@foo/touch-file-one-bin@latest'], {