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()