fix: accept uppercase PNPM_CONFIG_* env vars (#11468)

* fix(config): accept uppercase PNPM_CONFIG_* env vars

Env vars are case-sensitive on macOS/Linux, so PNPM_CONFIG_USERCONFIG —
the rename suggested by the v11 migration guide — was silently ignored
because parseEnvVars only matched lowercase pnpm_config_*. Also wire the
env var into the early npmrcAuthFile lookup so it actually decides which
user-level .npmrc gets read.

Closes #11465

* chore: add changeset for npmrc auth file env-var load order

* test: cover lowercase pnpm_config_npmrc_auth_file env var

Matches the exact env var name reported in #11465. Without the early
env-var lookup before loadNpmrcConfig, this case is parsed too late
to actually load the custom .npmrc.

* test: lock precedence when both lowercase and uppercase env vars are set
This commit is contained in:
Zoltan Kochan
2026-05-05 16:16:51 +02:00
parent 3fd440bc7f
commit c96939266c
6 changed files with 147 additions and 13 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/config.reader": patch
"pnpm": patch
---
Fix `pnpm_config_npmrc_auth_file` and `pnpm_config_userconfig` env vars not actually loading the custom `.npmrc`. The env vars were parsed and assigned to the resolved config, but only after `loadNpmrcConfig` had already read the default `~/.npmrc` — so the custom file path was set but never read. The relevant env vars are now consulted before the user-level `.npmrc` is loaded [#11465](https://github.com/pnpm/pnpm/issues/11465).

View File

@@ -0,0 +1,6 @@
---
"@pnpm/config.reader": patch
"pnpm": patch
---
Accept `PNPM_CONFIG_*` (uppercase) environment variables in addition to `pnpm_config_*`. Previously, only the lowercase form was honored, so env vars renamed per the v11 migration guide (e.g. `PNPM_CONFIG_USERCONFIG`) silently had no effect on case-sensitive systems like macOS and Linux [#11465](https://github.com/pnpm/pnpm/issues/11465).

View File

@@ -5,6 +5,7 @@ import camelcase from 'camelcase'
import kebabCase from 'lodash.kebabcase'
const PREFIX = 'pnpm_config_'
const PREFIX_UPPER = PREFIX.toUpperCase()
export type ValueConstructor =
| ArrayConstructor
@@ -173,19 +174,30 @@ function tryParseObjectOrArray (envVar: string): object | unknown[] | undefined
}
/**
* Return the suffix if {@link envKey} starts with {@link PREFIX} and is fully lower_snake_case.
* Return the lowercase suffix if {@link envKey} starts with {@link PREFIX} or
* {@link PREFIX_UPPER} and the suffix is fully snake_case (in matching case).
* Otherwise, return `undefined`.
*
* Both lowercase (`pnpm_config_*`) and uppercase (`PNPM_CONFIG_*`) prefixes
* are accepted because shell convention favors uppercase env vars and the v11
* migration guide tells users to rename `NPM_CONFIG_*` to `PNPM_CONFIG_*`.
*/
function getEnvKeySuffix (envKey: string): string | undefined {
if (!envKey.startsWith(PREFIX)) return undefined
const suffix = envKey.slice(PREFIX.length)
if (!isEnvKeySuffix(suffix)) return undefined
return suffix
if (envKey.startsWith(PREFIX)) {
const suffix = envKey.slice(PREFIX.length)
return isLowerSnakeCase(suffix) ? suffix : undefined
}
if (envKey.startsWith(PREFIX_UPPER)) {
const suffix = envKey.slice(PREFIX_UPPER.length)
return isUpperSnakeCase(suffix) ? suffix.toLowerCase() : undefined
}
return undefined
}
/**
* A valid env key suffix is lower_snake_case without redundant underscore characters.
*/
function isEnvKeySuffix (envKeySuffix: string): boolean {
return envKeySuffix.split('_').every(segment => /^[a-z0-9]+$/.test(segment))
function isLowerSnakeCase (s: string): boolean {
return s.length > 0 && s.split('_').every(segment => /^[a-z0-9]+$/.test(segment))
}
function isUpperSnakeCase (s: string): boolean {
return s.length > 0 && s.split('_').every(segment => /^[A-Z0-9]+$/.test(segment))
}

View File

@@ -215,10 +215,16 @@ export async function getConfig (opts: {
const configDir = getConfigDir(process)
// Read npmrcAuthFile early from global config.yaml (before loading .npmrc files)
// Read npmrcAuthFile early from global config.yaml (before loading .npmrc files).
// The general env var loop runs later (after .npmrc files are loaded), so we
// also have to peek at the relevant env vars here in order for
// PNPM_CONFIG_NPMRC_AUTH_FILE / PNPM_CONFIG_USERCONFIG (and their lowercase
// equivalents) to actually decide which user-level .npmrc gets read.
const globalYamlConfigForNpmrcAuthFile = await readWorkspaceManifest(configDir, GLOBAL_CONFIG_YAML_FILENAME)
const npmrcAuthFile = cliOptions['npmrc-auth-file'] as string | undefined
?? cliOptions.userconfig as string | undefined
?? readEnvVar(env, 'npmrc_auth_file')
?? readEnvVar(env, 'userconfig')
?? globalYamlConfigForNpmrcAuthFile?.npmrcAuthFile
const npmrcResult = loadNpmrcConfig({
@@ -674,6 +680,15 @@ function getProcessEnv (env: string): string | undefined {
process.env[env.toLowerCase()]
}
// Look up a `pnpm_config_<key>` env var, accepting both lowercase and
// uppercase forms. Used for env vars that need to be read before the
// general parseEnvVars pass, such as those that affect which .npmrc file
// is loaded.
function readEnvVar (env: NodeJS.ProcessEnv, key: string): string | undefined {
const value = env[`pnpm_config_${key}`] ?? env[`PNPM_CONFIG_${key.toUpperCase()}`]
return value !== '' ? value : undefined
}
function getWantedPackageManager (manifest: ProjectManifest): { pm?: WantedPackageManager, warnings: string[] } {
const warnings: string[] = []
const pmFromDevEngines = parseDevEnginesPackageManager(manifest.devEngines)

View File

@@ -209,16 +209,18 @@ test('parseEnvVars skips npm_config_*', () => {
}))).toStrictEqual({})
})
test('parseEnvVars only reads lower snake case keys', () => {
test('parseEnvVars reads fully lowercase or fully uppercase snake_case keys', () => {
expect(pairsToObject(parseEnvVars(alwaysSchema(String), {
PNPM_CONFIG_UPPER_SNAKE_CASE_KEY: 'whole key in upper snake case',
pnpmConfigCamelCaseKey: 'whole key in snake case',
'pnpm-config-kebab-case': 'whole key in kebab case',
pnpm_config_UPPER_SNAKE_CASE_SUFFIX: 'suffix in upper snake case',
pnpm_config_UPPER_SNAKE_CASE_SUFFIX: 'mixed case suffix',
PNPM_CONFIG_lower_snake_case_suffix: 'mixed case suffix',
pnpm_config_camelCaseSuffix: 'suffix in camel case',
'pnpm_config_kebab-case-suffix': 'suffix in kebab case',
pnpm_config_lower_snake_case_key: 'whole key in lower snake case',
}))).toStrictEqual({
upperSnakeCaseKey: 'whole key in upper snake case',
lowerSnakeCaseKey: 'whole key in lower snake case',
})
})

View File

@@ -1105,6 +1105,99 @@ test('getConfig() returns the userconfig even when overridden locally', async ()
expect(config.userConfig).toEqual({ registry: 'https://registry.example.test' })
})
test('getConfig() reads userconfig from PNPM_CONFIG_USERCONFIG env var', async () => {
prepareEmpty()
fs.mkdirSync('user-home')
fs.writeFileSync(path.resolve('user-home', '.npmrc'), 'registry = https://registry.example.test', 'utf-8')
const { config } = await getConfig({
cliOptions: {},
env: {
...env,
PNPM_CONFIG_USERCONFIG: path.resolve('user-home', '.npmrc'),
},
packageManager: {
name: 'pnpm',
version: '1.0.0',
},
})
expect(config.userConfig).toEqual({ registry: 'https://registry.example.test' })
})
test('getConfig() reads userconfig from pnpm_config_userconfig env var', async () => {
prepareEmpty()
fs.mkdirSync('user-home')
fs.writeFileSync(path.resolve('user-home', '.npmrc'), 'registry = https://registry.example.test', 'utf-8')
const { config } = await getConfig({
cliOptions: {},
env: {
...env,
pnpm_config_userconfig: path.resolve('user-home', '.npmrc'),
},
packageManager: {
name: 'pnpm',
version: '1.0.0',
},
})
expect(config.userConfig).toEqual({ registry: 'https://registry.example.test' })
})
test('getConfig() reads userconfig from PNPM_CONFIG_NPMRC_AUTH_FILE env var', async () => {
prepareEmpty()
fs.mkdirSync('user-home')
fs.writeFileSync(path.resolve('user-home', '.npmrc'), 'registry = https://registry.example.test', 'utf-8')
const { config } = await getConfig({
cliOptions: {},
env: {
...env,
PNPM_CONFIG_NPMRC_AUTH_FILE: path.resolve('user-home', '.npmrc'),
},
packageManager: {
name: 'pnpm',
version: '1.0.0',
},
})
expect(config.userConfig).toEqual({ registry: 'https://registry.example.test' })
})
test('getConfig() reads userconfig from pnpm_config_npmrc_auth_file env var', async () => {
prepareEmpty()
fs.mkdirSync('user-home')
fs.writeFileSync(path.resolve('user-home', '.npmrc'), 'registry = https://registry.example.test', 'utf-8')
const { config } = await getConfig({
cliOptions: {},
env: {
...env,
pnpm_config_npmrc_auth_file: path.resolve('user-home', '.npmrc'),
},
packageManager: {
name: 'pnpm',
version: '1.0.0',
},
})
expect(config.userConfig).toEqual({ registry: 'https://registry.example.test' })
})
// Locks in the precedence so future refactors don't accidentally flip it.
test('getConfig() prefers pnpm_config_userconfig over PNPM_CONFIG_USERCONFIG when both are set', async () => {
prepareEmpty()
fs.mkdirSync('user-home')
fs.writeFileSync(path.resolve('user-home', 'upper.npmrc'), 'registry = https://upper.example.test', 'utf-8')
fs.writeFileSync(path.resolve('user-home', 'lower.npmrc'), 'registry = https://lower.example.test', 'utf-8')
const { config } = await getConfig({
cliOptions: {},
env: {
...env,
PNPM_CONFIG_USERCONFIG: path.resolve('user-home', 'upper.npmrc'),
pnpm_config_userconfig: path.resolve('user-home', 'lower.npmrc'),
},
packageManager: {
name: 'pnpm',
version: '1.0.0',
},
})
expect(config.userConfig).toEqual({ registry: 'https://lower.example.test' })
})
test('getConfig() sets sideEffectsCacheRead and sideEffectsCacheWrite when side-effects-cache is set', async () => {
const { config } = await getConfig({
cliOptions: {