From c96939266cc0d29e121e40340fa4659c2949e3f7 Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Tue, 5 May 2026 16:16:51 +0200 Subject: [PATCH] fix: accept uppercase PNPM_CONFIG_* env vars (#11468) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../npmrc-auth-file-env-var-load-order.md | 6 ++ .changeset/uppercase-pnpm-config-env-vars.md | 6 ++ config/reader/src/env.ts | 32 +++++-- config/reader/src/index.ts | 17 +++- config/reader/test/env.test.ts | 6 +- config/reader/test/index.ts | 93 +++++++++++++++++++ 6 files changed, 147 insertions(+), 13 deletions(-) create mode 100644 .changeset/npmrc-auth-file-env-var-load-order.md create mode 100644 .changeset/uppercase-pnpm-config-env-vars.md diff --git a/.changeset/npmrc-auth-file-env-var-load-order.md b/.changeset/npmrc-auth-file-env-var-load-order.md new file mode 100644 index 0000000000..5df679fbfd --- /dev/null +++ b/.changeset/npmrc-auth-file-env-var-load-order.md @@ -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). diff --git a/.changeset/uppercase-pnpm-config-env-vars.md b/.changeset/uppercase-pnpm-config-env-vars.md new file mode 100644 index 0000000000..7e57827ad7 --- /dev/null +++ b/.changeset/uppercase-pnpm-config-env-vars.md @@ -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). diff --git a/config/reader/src/env.ts b/config/reader/src/env.ts index 58c0033531..64f6de1484 100644 --- a/config/reader/src/env.ts +++ b/config/reader/src/env.ts @@ -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)) } diff --git a/config/reader/src/index.ts b/config/reader/src/index.ts index 381ffefd5f..99dc431c78 100644 --- a/config/reader/src/index.ts +++ b/config/reader/src/index.ts @@ -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_` 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) diff --git a/config/reader/test/env.test.ts b/config/reader/test/env.test.ts index 4cddfc5d98..6256d51087 100644 --- a/config/reader/test/env.test.ts +++ b/config/reader/test/env.test.ts @@ -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', }) }) diff --git a/config/reader/test/index.ts b/config/reader/test/index.ts index 32f542da03..ce76763996 100644 --- a/config/reader/test/index.ts +++ b/config/reader/test/index.ts @@ -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: {