fix(config): align scoped registry resolution between config get and publish (#11494)

Fixes [#11492](https://github.com/pnpm/pnpm/issues/11492).

In pnpm v11 a scoped registry resolved to different URLs depending on which command read it:

- `pnpm config get @<scope>:registry` returned the value from `.npmrc`
- `pnpm publish` used the value from `pnpm-workspace.yaml`'s `registries` block

When the two sources disagreed, `pnpm publish` silently targeted the URL from `pnpm-workspace.yaml`, even though `pnpm config get` reported a different (and seemingly authoritative) URL — so users could publish to the wrong registry without any indication.

This PR makes `pnpm config get @<scope>:registry` read from the merged `Config.registries` map (the same map `publish` and the resolvers use) before falling back to `authConfig`. Both commands now report and use the same URL.
This commit is contained in:
Zoltan Kochan
2026-05-06 17:06:18 +02:00
parent 12313f1ac4
commit 3e2df55ed4
5 changed files with 62 additions and 2 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/config.commands": patch
"pnpm": patch
---
`pnpm config get @<scope>:registry` now reports the same URL that `pnpm publish` and the resolvers actually use. Previously, `config get` only consulted `.npmrc`, while `publish`/install used the merged map that includes `pnpm-workspace.yaml`'s `registries` block — so the two could diverge silently and a publish could go to the wrong registry [#11492](https://github.com/pnpm/pnpm/issues/11492).

View File

@@ -21,6 +21,18 @@ interface Found<Value> {
function lookupConfig (opts: ConfigCommandOptions, key: string, isScopedKey: boolean): Found<unknown> | undefined {
if (isScopedKey) {
// Scoped registry keys (e.g. `@scope:registry`) can be set in two places:
// `.npmrc` (which lands in authConfig) or pnpm-workspace.yaml's
// `registries` block (which lands in the merged Config.registries map).
// Prefer the merged map so this command reports the same value that
// `pnpm publish` and the resolvers actually use.
if (key.endsWith(':registry')) {
const scope = key.slice(0, key.length - ':registry'.length)
const merged = opts._config.registries?.[scope]
if (merged !== undefined) {
return { value: merged }
}
}
return { value: opts.authConfig[key] }
}
const kebabKey = isCamelCase(key) ? kebabCase(key) : key

View File

@@ -234,6 +234,27 @@ test('config get with scoped registry key (global: true)', async () => {
expect(getOutputString(getResult)).toBe('https://custom-registry.example.com/')
})
test('config get with scoped registry returns the merged value from pnpm-workspace.yaml (#11492)', async () => {
// .npmrc set the scope to one URL, but pnpm-workspace.yaml's `registries`
// block overrides it. `pnpm config get` must report the URL that
// resolvers/publish actually use, not the raw .npmrc value.
const getResult = await config.handler(createConfigCommandOpts({
dir: process.cwd(),
cliOptions: {},
configDir: process.cwd(),
global: false,
authConfig: {
'@scope:registry': 'https://from-npmrc.example.com/',
},
registries: {
default: 'https://registry.npmjs.org/',
'@scope': 'https://from-workspace-yaml.example.com/',
},
}), ['get', '@scope:registry'])
expect(getOutputString(getResult)).toBe('https://from-workspace-yaml.example.com/')
})
test('config get with scoped registry key that does not exist', async () => {
const getResult = await config.handler(createConfigCommandOpts({
dir: process.cwd(),

View File

@@ -561,6 +561,25 @@ test('registries in current directory\'s .npmrc have bigger priority then global
})
})
test('pnpm-workspace.yaml registries override the same scope from .npmrc (#11492)', async () => {
prepareEmpty()
fs.writeFileSync('.npmrc', '@my-org:registry=https://from-npmrc.example.com/', 'utf8')
writeYamlFileSync('pnpm-workspace.yaml', {
registries: {
'@my-org': 'https://from-workspace-yaml.example.com/',
},
})
const { config } = await getConfig({
cliOptions: {},
packageManager: { name: 'pnpm', version: '1.0.0' },
workspaceDir: process.cwd(),
})
expect(config.registries['@my-org']).toBe('https://from-workspace-yaml.example.com/')
})
test('auth tokens from pnpm auth file override ~/.npmrc', async () => {
prepareEmpty()

View File

@@ -27,14 +27,16 @@ test('pnpm config get reads npm options but ignores other settings from .npmrc',
'packages[]=qux',
].join('\n'))
// `config get @<scope>:registry` reports the merged (normalized) URL —
// the same one `pnpm publish` and the resolvers use — see #11492.
{
const { stdout } = execPnpmSync(['config', 'get', '@my-org:registry'], { expectSuccess: true })
expect(stdout.toString().trim()).toBe('https://my-org.registry.example.com')
expect(stdout.toString().trim()).toBe('https://my-org.registry.example.com/')
}
{
const { stdout } = execPnpmSync(['config', 'get', '@jsr:registry'], { expectSuccess: true })
expect(stdout.toString().trim()).toBe('https://not-actually-jsr.example.com')
expect(stdout.toString().trim()).toBe('https://not-actually-jsr.example.com/')
}
{