From 734cd5bf65fc15abf211e28e00a6719532aa1e84 Mon Sep 17 00:00:00 2001 From: Chris Wood <45291962+cpcwood@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:51:05 +0100 Subject: [PATCH] feat(config): allow nodeDownloadMirrors in the global config and env (#13610) node-download-mirrors was never registered in pnpmTypes. Both the pnpm_config_* environment pass and the global config file allowlist work off that schema, so the setting was invisible to both. nodeDownloadMirrors could only be read from a pnpm-workspace.yaml, which is per-project config, so a Node.js download mirror could not easily be configured once for a machine. This commit registers the key, updates the env parser to accept a JSON object, and adds it to the global config file allowlist. The Rust stack already read this setting from both the environment and the global config file. To keep parity, this commit gives its config_types.rs mirror of pnpmConfigFileKeys the same entry, so the Rust `pnpm config set -g` honours the key. --------- Co-authored-by: Zoltan Kochan --- ...node-download-mirrors-config-set-global.md | 5 + .../node-download-mirrors-global-and-env.md | 10 ++ pnpm/crates/config/src/config_types.rs | 1 + pnpm/crates/config/src/config_types/tests.rs | 1 + pnpm11/config/reader/src/configFileKey.ts | 1 + pnpm11/config/reader/src/env.ts | 12 +++ .../reader/src/getOptionsFromRootManifest.ts | 10 ++ pnpm11/config/reader/src/types.ts | 1 + pnpm11/config/reader/test/env.test.ts | 31 ++++++ .../test/getOptionsFromRootManifest.test.ts | 28 ++++++ pnpm11/config/reader/test/index.ts | 97 +++++++++++++++++++ 11 files changed, 197 insertions(+) create mode 100644 .changeset/node-download-mirrors-config-set-global.md create mode 100644 .changeset/node-download-mirrors-global-and-env.md diff --git a/.changeset/node-download-mirrors-config-set-global.md b/.changeset/node-download-mirrors-config-set-global.md new file mode 100644 index 0000000000..37753b3a66 --- /dev/null +++ b/.changeset/node-download-mirrors-config-set-global.md @@ -0,0 +1,5 @@ +--- +"pacquet": patch +--- + +`pnpm config set --global node-download-mirrors` no longer rejects the key. The global config file already accepted `nodeDownloadMirrors`, but the command refused to write it [#13611](https://github.com/pnpm/pnpm/issues/13611). diff --git a/.changeset/node-download-mirrors-global-and-env.md b/.changeset/node-download-mirrors-global-and-env.md new file mode 100644 index 0000000000..9274ec81dd --- /dev/null +++ b/.changeset/node-download-mirrors-global-and-env.md @@ -0,0 +1,10 @@ +--- +"@pnpm/config.reader": minor +"pnpm": minor +--- + +`nodeDownloadMirrors` can now be set in the global config file (`config.yaml`) and through the `PNPM_CONFIG_NODE_DOWNLOAD_MIRRORS` environment variable, so a Node.js download mirror can be configured once for a machine instead of in every workspace [#12124](https://github.com/pnpm/pnpm/issues/12124), [#13611](https://github.com/pnpm/pnpm/issues/13611). + +```sh +PNPM_CONFIG_NODE_DOWNLOAD_MIRRORS='{"release":"https://npmmirror.com/mirrors/node/"}' +``` diff --git a/pnpm/crates/config/src/config_types.rs b/pnpm/crates/config/src/config_types.rs index 1a9e7b2579..7c6bf97d7e 100644 --- a/pnpm/crates/config/src/config_types.rs +++ b/pnpm/crates/config/src/config_types.rs @@ -276,6 +276,7 @@ const PNPM_CONFIG_FILE_KEYS: &[&str] = &[ "minimum-release-age-ignore-missing-time", "minimum-release-age-strict", "network-concurrency", + "node-download-mirrors", "node-experimental-package-map", "node-package-map-type", "noproxy", diff --git a/pnpm/crates/config/src/config_types/tests.rs b/pnpm/crates/config/src/config_types/tests.rs index 714f0f70c4..fd6056a535 100644 --- a/pnpm/crates/config/src/config_types/tests.rs +++ b/pnpm/crates/config/src/config_types/tests.rs @@ -49,6 +49,7 @@ fn config_file_keys() { assert!(is_config_file_key("store-dir")); assert!(is_config_file_key("fetch-timeout")); assert!(is_config_file_key("cache-dir")); + assert!(is_config_file_key("node-download-mirrors")); assert!(is_config_file_key("virtual-store-type")); assert!(is_config_file_key("enable-global-virtual-store")); // npm-compatible, not excluded diff --git a/pnpm11/config/reader/src/configFileKey.ts b/pnpm11/config/reader/src/configFileKey.ts index fdba433ac3..b0c1ed2155 100644 --- a/pnpm11/config/reader/src/configFileKey.ts +++ b/pnpm11/config/reader/src/configFileKey.ts @@ -43,6 +43,7 @@ export const pnpmConfigFileKeys = [ 'minimum-release-age-ignore-missing-time', 'minimum-release-age-strict', 'network-concurrency', + 'node-download-mirrors', 'node-experimental-package-map', 'node-package-map-type', 'noproxy', diff --git a/pnpm11/config/reader/src/env.ts b/pnpm11/config/reader/src/env.ts index 64f6de1484..ca1c2ea772 100644 --- a/pnpm11/config/reader/src/env.ts +++ b/pnpm11/config/reader/src/env.ts @@ -11,6 +11,7 @@ export type ValueConstructor = | ArrayConstructor | BooleanConstructor | NumberConstructor + | ObjectConstructor | StringConstructor export type ModuleSchema = @@ -127,6 +128,11 @@ function parseValueByConstructor (schema: ValueConstructor, envVar: string): unk return isNaN(value) ? undefined : value } + if (schema === Object) { + const value = tryParseObjectOrArray(envVar) + return isStringRecord(value) ? value : undefined + } + if (schema === String) { return envVar } @@ -173,6 +179,12 @@ function tryParseObjectOrArray (envVar: string): object | unknown[] | undefined : result } +function isStringRecord (value: object | unknown[] | undefined): value is Record { + return value != null && + !Array.isArray(value) && + Object.values(value).every(item => typeof item === 'string') +} + /** * 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). diff --git a/pnpm11/config/reader/src/getOptionsFromRootManifest.ts b/pnpm11/config/reader/src/getOptionsFromRootManifest.ts index 6ab38902f4..8609a5639e 100644 --- a/pnpm11/config/reader/src/getOptionsFromRootManifest.ts +++ b/pnpm11/config/reader/src/getOptionsFromRootManifest.ts @@ -102,6 +102,9 @@ export function getOptionsFromPnpmSettings ( settings.patchedDependencies[dep] = path.join(manifestDir, patchFile) } } + if (pnpmSettings.nodeDownloadMirrors != null) { + assertStringRecord(pnpmSettings.nodeDownloadMirrors, 'nodeDownloadMirrors') + } translateRegistrySettings(settings) translateUpdateSettings(pnpmSettings, settings) translateAuditSettings(pnpmSettings, settings) @@ -639,6 +642,13 @@ function assertString (value: unknown, settingName: string): asserts value is st } } +function assertStringRecord (value: unknown, settingName: string): void { + assertObjectSetting(value, settingName) + for (const [key, item] of Object.entries(value as Record)) { + assertString(item, `${settingName}.${key}`) + } +} + // Not an `asserts` guard on purpose: it only rejects malformed shapes at // runtime, without narrowing away the section's declared type at the call site. function assertObjectSetting (value: unknown, settingName: string): void { diff --git a/pnpm11/config/reader/src/types.ts b/pnpm11/config/reader/src/types.ts index 493a6b2444..ddc286e9fd 100644 --- a/pnpm11/config/reader/src/types.ts +++ b/pnpm11/config/reader/src/types.ts @@ -75,6 +75,7 @@ export const pnpmTypes = { 'minimum-release-age-strict': Boolean, 'modules-dir': String, 'network-concurrency': Number, + 'node-download-mirrors': Object, 'node-experimental-package-map': Boolean, 'node-package-map-type': ['standard', 'loose'], 'node-linker': ['pnp', 'isolated', 'hoisted'], diff --git a/pnpm11/config/reader/test/env.test.ts b/pnpm11/config/reader/test/env.test.ts index 6256d51087..1652f174df 100644 --- a/pnpm11/config/reader/test/env.test.ts +++ b/pnpm11/config/reader/test/env.test.ts @@ -86,6 +86,37 @@ test('parseEnvVars works with arrays', () => { }) }) +test('parseEnvVars works with objects', () => { + expect(pairsToObject(parseEnvVars(alwaysSchema(Object), { + HOME: '/home/fake-user', + PATH: '/bin:/usr/bin:/usr/local/bin:/home/fake-user/.bin:/home/fake-user/share/local/bin', + pnpm_config_valid_object: '{"release": "https://mirror.example.com/release/"}', + pnpm_config_empty_object: '{}', + pnpm_config_not_json: 'not an object', + pnpm_config_json_array: '["an", "array"]', + pnpm_config_json_null: 'null', + pnpm_config_json_number: '1', + pnpm_config_json_boolean: 'true', + pnpm_config_json_string: '"text"', + pnpm_config_number_member: '{"release": 42}', + pnpm_config_null_member: '{"release": null}', + pnpm_config_array_member: '{"release": ["https://mirror.example.com/release/"]}', + pnpm_config_undefined_somehow: undefined, + }))).toStrictEqual({ + validObject: { release: 'https://mirror.example.com/release/' }, + emptyObject: {}, + notJson: undefined, + jsonArray: undefined, + jsonNull: undefined, + jsonNumber: undefined, + jsonBoolean: undefined, + jsonString: undefined, + numberMember: undefined, + nullMember: undefined, + arrayMember: undefined, + }) +}) + test('parseEnvVars works with paths', () => { expect(pairsToObject(parseEnvVars(alwaysSchema(path), { HOME: '/home/fake-user', diff --git a/pnpm11/config/reader/test/getOptionsFromRootManifest.test.ts b/pnpm11/config/reader/test/getOptionsFromRootManifest.test.ts index 9df34f83e5..77c73b7ef1 100644 --- a/pnpm11/config/reader/test/getOptionsFromRootManifest.test.ts +++ b/pnpm11/config/reader/test/getOptionsFromRootManifest.test.ts @@ -269,6 +269,34 @@ test('getOptionsFromPnpmSettings() rejects non-object overrides values', () => { })) }) +test('getOptionsFromPnpmSettings() accepts nodeDownloadMirrors with string values', () => { + expect(() => getOptionsFromPnpmSettings(process.cwd(), { + nodeDownloadMirrors: { + release: 'https://mirror.example.com/release/', + }, + })).not.toThrow() +}) + +test('getOptionsFromPnpmSettings() rejects non-string nodeDownloadMirrors values', () => { + expect(() => getOptionsFromPnpmSettings(process.cwd(), { + nodeDownloadMirrors: { + release: 42, + } as unknown as Record, + })).toThrow(expect.objectContaining({ + code: 'ERR_PNPM_INVALID_SETTING', + message: 'The "nodeDownloadMirrors.release" setting should be a string, but got number', + })) +}) + +test('getOptionsFromPnpmSettings() rejects non-object nodeDownloadMirrors', () => { + expect(() => getOptionsFromPnpmSettings(process.cwd(), { + nodeDownloadMirrors: [] as unknown as Record, + })).toThrow(expect.objectContaining({ + code: 'ERR_PNPM_INVALID_SETTING', + message: 'The "nodeDownloadMirrors" setting should be an object, but got array', + })) +}) + test('getOptionsFromPnpmSettings() rejects a non-string range in packageExtensions', () => { expect(() => getOptionsFromPnpmSettings(process.cwd(), { packageExtensions: { diff --git a/pnpm11/config/reader/test/index.ts b/pnpm11/config/reader/test/index.ts index 242e5020f6..8e2bba421e 100644 --- a/pnpm11/config/reader/test/index.ts +++ b/pnpm11/config/reader/test/index.ts @@ -4742,6 +4742,51 @@ test.each([ expect(config.verifyDepsBeforeRun).toBe(expectedValue) }) +test('loads nodeDownloadMirrors from environment variable pnpm_config_node_download_mirrors', async () => { + prepareEmpty() + + writeYamlFileSync('pnpm-workspace.yaml', { + nodeDownloadMirrors: { + release: 'https://yaml.example.com/release/', + }, + }) + + async function getNodeDownloadMirrors (env: NodeJS.ProcessEnv, cliOptions: Record = {}): Promise | undefined> { + const { config } = await getConfig({ + cliOptions, + env, + packageManager: { + name: 'pnpm', + version: '1.0.0', + }, + workspaceDir: process.cwd(), + }) + return config.nodeDownloadMirrors + } + + expect(await getNodeDownloadMirrors({})).toStrictEqual({ + release: 'https://yaml.example.com/release/', + }) + expect(await getNodeDownloadMirrors({ + pnpm_config_node_download_mirrors: '{"release":"https://mirror.example.com/release/","rc":"https://mirror.example.com/rc/"}', + })).toStrictEqual({ + release: 'https://mirror.example.com/release/', + rc: 'https://mirror.example.com/rc/', + }) + expect(await getNodeDownloadMirrors({ + PNPM_CONFIG_NODE_DOWNLOAD_MIRRORS: '{"release":"https://upper.example.com/release/"}', + })).toStrictEqual({ + release: 'https://upper.example.com/release/', + }) + expect(await getNodeDownloadMirrors({ + PNPM_CONFIG_NODE_DOWNLOAD_MIRRORS: '{"release":"https://upper.example.com/release/"}', + }, { + nodeDownloadMirrors: { release: 'https://cli.example.com/release/' }, + })).toStrictEqual({ + release: 'https://cli.example.com/release/', + }) +}) + test('environment variable pnpm_config_* should override pnpm-workspace.yaml', async () => { prepareEmpty() @@ -5156,6 +5201,58 @@ describe('global config.yaml', () => { expect(config.scriptShell).toBe('./env-shell.sh') }) + test('reads nodeDownloadMirrors from global config.yaml', async () => { + prepareEmpty() + + fs.mkdirSync('.config/pnpm', { recursive: true }) + writeYamlFileSync('.config/pnpm/config.yaml', { + nodeDownloadMirrors: { + release: 'https://mirror.example.com/release/', + }, + }) + + process.env.XDG_CONFIG_HOME = path.resolve('.config') + + const { config, warnings } = await getConfig({ + cliOptions: {}, + packageManager: { + name: 'pnpm', + version: '1.0.0', + }, + workspaceDir: process.cwd(), + }) + + expect(config.nodeDownloadMirrors).toStrictEqual({ + release: 'https://mirror.example.com/release/', + }) + expect(warnings.find((w) => w.includes('global config file'))).toBeUndefined() + }) + + test('rejects a non-string nodeDownloadMirrors value in global config.yaml', async () => { + prepareEmpty() + + fs.mkdirSync('.config/pnpm', { recursive: true }) + writeYamlFileSync('.config/pnpm/config.yaml', { + nodeDownloadMirrors: { + release: 42, + }, + }) + + process.env.XDG_CONFIG_HOME = path.resolve('.config') + + await expect(getConfig({ + cliOptions: {}, + packageManager: { + name: 'pnpm', + version: '1.0.0', + }, + workspaceDir: process.cwd(), + })).rejects.toThrow(expect.objectContaining({ + code: 'ERR_PNPM_INVALID_SETTING', + message: 'The "nodeDownloadMirrors.release" setting should be a string, but got number', + })) + }) + test('warns when global config.yaml contains settings that are not allowed in the global config', async () => { prepareEmpty()