diff --git a/.changeset/twenty-donuts-cut.md b/.changeset/twenty-donuts-cut.md new file mode 100644 index 0000000000..30501bb750 --- /dev/null +++ b/.changeset/twenty-donuts-cut.md @@ -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. diff --git a/config/reader/src/localConfig.ts b/config/reader/src/localConfig.ts index c870f1b42d..42aa96bdda 100644 --- a/config/reader/src/localConfig.ts +++ b/config/reader/src/localConfig.ts @@ -46,19 +46,24 @@ const AUTH_CFG_KEYS = [ ] satisfies Array /** - * 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 +const CATALOGS_CFG_KEYS = [ + 'catalogs', +] satisfies Array + +const FETCH_CFG_KEYS = [ + 'fetchRetryFactor', + 'fetchRetryMaxtimeout', + 'fetchRetryMintimeout', + 'fetchRetries', + 'fetchTimeout', +] satisfies Array + 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).includes(cfgKey) } +function isCatalogsCfgKey (cfgKey: keyof Config): cfgKey is typeof CATALOGS_CFG_KEYS[number] { + return (CATALOGS_CFG_KEYS as Array).includes(cfgKey) +} + +function isFetchCfgKey (cfgKey: keyof Config): cfgKey is typeof FETCH_CFG_KEYS[number] { + return (FETCH_CFG_KEYS as Array).includes(cfgKey) +} + function pickRawAuthConfig> (rawLocalCfg: RawLocalCfg): Partial { const result: Partial = {} for (const key in rawLocalCfg) { @@ -131,7 +158,7 @@ function pickAuthConfig (localCfg: Partial): Partial { function pickDlxConfig (localCfg: Partial): Partial { const result: Record = {} 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) diff --git a/pnpm/test/dlx.ts b/pnpm/test/dlx.ts index 9a27ce227a..1d3f65c3da 100644 --- a/pnpm/test/dlx.ts +++ b/pnpm/test/dlx.ts @@ -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'], {