From 6b5536b499c5ffa77dd7cf01782b26712cee0a66 Mon Sep 17 00:00:00 2001 From: reggi Date: Mon, 13 Apr 2026 11:43:08 -0400 Subject: [PATCH] feat: per-command configs --- docs/lib/content/configuring-npm/npmrc.md | 38 +++ lib/commands/config.js | 36 +- lib/npm.js | 3 + .../test/lib/commands/config.js.test.cjs | 2 + .../test/lib/commands/install.js.test.cjs | 12 +- tap-snapshots/test/lib/docs.js.test.cjs | 21 +- .../config/lib/definitions/definitions.js | 11 + workspaces/config/lib/index.js | 149 ++++++++ workspaces/config/test/index.js | 317 ++++++++++++++++++ 9 files changed, 577 insertions(+), 12 deletions(-) diff --git a/docs/lib/content/configuring-npm/npmrc.md b/docs/lib/content/configuring-npm/npmrc.md index b1e7d16be10c7..f10f351d3be11 100644 --- a/docs/lib/content/configuring-npm/npmrc.md +++ b/docs/lib/content/configuring-npm/npmrc.md @@ -184,6 +184,44 @@ export ELECTRON_CUSTOM_DIR="{{ version }}" Environment variables are the most portable approach and work regardless of `.npmrc` format. +### Command-specific configuration + +You can scope configuration to a specific npm command using INI section +headers. For example, to use a different registry only when publishing: + +```ini +registry=http://localhost:1337/npm + +[publish] +registry=https://registry.npmjs.org/ +``` + +Any npm configuration option can be placed inside a command section. The +section name must match an npm command name (e.g., `[install]`, `[publish]`, +`[audit]`). + +Command-specific configs follow the standard priority cascade +(builtin < global < user < project), but **command sections always override +non-sectioned values from any file layer**. CLI flags (`--registry=...`) +and environment variables (`npm_config_registry=...`) still take the +highest precedence. + +You can manage command-scoped configs using the `--command` flag: + +```bash +# Set a registry only for publish +npm config set registry=https://registry.npmjs.org/ --command=publish + +# Get the publish-scoped registry value +npm config get registry --command=publish + +# Delete a command-scoped value +npm config delete registry --command=publish +``` + +Command sections work in all `.npmrc` files: per-project, per-user, +global, and builtin. + ### See also * [npm folders](/configuring-npm/folders) diff --git a/lib/commands/config.js b/lib/commands/config.js index 015850c48304a..a049e8b20c073 100644 --- a/lib/commands/config.js +++ b/lib/commands/config.js @@ -85,6 +85,7 @@ class Config extends BaseCommand { 'editor', 'location', 'long', + 'command', ] static ignoreImplicitWorkspace = false @@ -163,6 +164,8 @@ class Config extends BaseCommand { } const where = this.npm.flatOptions.location + const commandScope = this.npm.config.get('command') + for (const [key, val] of Object.entries(keyValues(args))) { log.info('config', 'set %j %j', key, val) const baseKey = key.split(':').pop() @@ -176,10 +179,18 @@ class Config extends BaseCommand { ) } - if (val === '') { - this.npm.config.delete(key, where) + if (commandScope) { + if (val === '') { + this.npm.config.deleteCommandConfig(commandScope, key, where) + } else { + this.npm.config.setCommandConfig(commandScope, key, val, where) + } } else { - this.npm.config.set(key, val, where) + if (val === '') { + this.npm.config.delete(key, where) + } else { + this.npm.config.set(key, val, where) + } } if (!this.npm.config.validate(where)) { @@ -195,9 +206,18 @@ class Config extends BaseCommand { return this.list() } + const commandScope = this.npm.config.get('command') const out = [] for (const key of keys) { - const val = this.npm.config.get(key) + let val + if (commandScope) { + val = this.npm.config.getCommandConfig(commandScope, key) + if (val === undefined) { + val = this.npm.config.get(key) + } + } else { + val = this.npm.config.get(key) + } if (isPrivate(key, val)) { throw new Error(`The ${key} option is protected, and cannot be retrieved in this way`) } @@ -214,8 +234,14 @@ class Config extends BaseCommand { } const where = this.npm.flatOptions.location + const commandScope = this.npm.config.get('command') + for (const key of keys) { - this.npm.config.delete(key, where) + if (commandScope) { + this.npm.config.deleteCommandConfig(commandScope, key, where) + } else { + this.npm.config.delete(key, where) + } } await this.npm.config.save(where) } diff --git a/lib/npm.js b/lib/npm.js index b2ab377e95d71..ff6c07701e42d 100644 --- a/lib/npm.js +++ b/lib/npm.js @@ -110,6 +110,9 @@ class Npm { // This is the actual name of the command that will be run or undefined if deref could not find a match const command = deref(commandArg) + // Apply command-specific configs from [command] sections in .npmrc files + this.config.applyCommand(command) + await this.#display.load({ command, loglevel: this.config.get('loglevel'), diff --git a/tap-snapshots/test/lib/commands/config.js.test.cjs b/tap-snapshots/test/lib/commands/config.js.test.cjs index 6617b3a0827f7..5a6a5adee5cee 100644 --- a/tap-snapshots/test/lib/commands/config.js.test.cjs +++ b/tap-snapshots/test/lib/commands/config.js.test.cjs @@ -32,6 +32,7 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna "call": "", "cert": null, "cidr": null, + "command": null, "commit-hooks": true, "cpu": null, "depth": null, @@ -211,6 +212,7 @@ call = "" cert = null cidr = null ; color = {COLOR} +command = null commit-hooks = true cpu = null depth = null diff --git a/tap-snapshots/test/lib/commands/install.js.test.cjs b/tap-snapshots/test/lib/commands/install.js.test.cjs index dd618ee3688f1..8bba0ffb8db62 100644 --- a/tap-snapshots/test/lib/commands/install.js.test.cjs +++ b/tap-snapshots/test/lib/commands/install.js.test.cjs @@ -135,8 +135,8 @@ verbose stack Error: The developer of this package has specified the following t verbose stack Invalid devEngines.runtime verbose stack Invalid name "nondescript" does not match "node" for "runtime" verbose stack at Install.checkDevEngines ({CWD}/lib/base-cmd.js:247:27) -verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:292:7) -verbose stack at MockNpm.exec ({CWD}/lib/npm.js:193:9) +verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:295:7) +verbose stack at MockNpm.exec ({CWD}/lib/npm.js:196:9) error code EBADDEVENGINES error EBADDEVENGINES The developer of this package has specified the following through devEngines error EBADDEVENGINES Invalid devEngines.runtime @@ -200,8 +200,8 @@ verbose stack Error: The developer of this package has specified the following t verbose stack Invalid devEngines.runtime verbose stack Invalid name "nondescript" does not match "node" for "runtime" verbose stack at Install.checkDevEngines ({CWD}/lib/base-cmd.js:247:27) -verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:292:7) -verbose stack at MockNpm.exec ({CWD}/lib/npm.js:193:9) +verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:295:7) +verbose stack at MockNpm.exec ({CWD}/lib/npm.js:196:9) error code EBADDEVENGINES error EBADDEVENGINES The developer of this package has specified the following through devEngines error EBADDEVENGINES Invalid devEngines.runtime @@ -226,8 +226,8 @@ verbose stack Error: The developer of this package has specified the following t verbose stack Invalid devEngines.runtime verbose stack Invalid name "nondescript" does not match "node" for "runtime" verbose stack at Install.checkDevEngines ({CWD}/lib/base-cmd.js:247:27) -verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:292:7) -verbose stack at MockNpm.exec ({CWD}/lib/npm.js:193:9) +verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:295:7) +verbose stack at MockNpm.exec ({CWD}/lib/npm.js:196:9) error code EBADDEVENGINES error EBADDEVENGINES The developer of this package has specified the following through devEngines error EBADDEVENGINES Invalid devEngines.runtime diff --git a/tap-snapshots/test/lib/docs.js.test.cjs b/tap-snapshots/test/lib/docs.js.test.cjs index 2f8891f886263..489281ec14cc6 100644 --- a/tap-snapshots/test/lib/docs.js.test.cjs +++ b/tap-snapshots/test/lib/docs.js.test.cjs @@ -414,6 +414,19 @@ true, then only prints color codes for tty file descriptors. +#### \`command\` + +* Default: null +* Type: null or String + +When used with \`npm config set\`, \`npm config get\`, or \`npm config delete\`, +specifies the command section to target. For example, \`npm config set +registry=https://registry.npmjs.org/ --command=publish\` will set the +registry only for the \`publish\` command by writing it under a \`[publish]\` +section in the .npmrc file. + + + #### \`commit-hooks\` * Default: true @@ -2276,6 +2289,7 @@ Array [ "cert", "cidr", "color", + "command", "commit-hooks", "cpu", "depth", @@ -2578,6 +2592,7 @@ Array [ exports[`test/lib/docs.js TAP config > keys that are not flattened 1`] = ` Array [ + "command", "expect-result-count", "expect-results", "init-author-email", @@ -3169,7 +3184,7 @@ npm config fix Options: [--json] [-g|--global] [--editor ] [-L|--location ] -[-l|--long] +[-l|--long] [--command ] --json Whether or not to output JSON data, rather than the normal output. @@ -3186,6 +3201,9 @@ Options: -l|--long Show extended information in \`ls\`, \`search\`, and \`help-search\`. + --command + When used with \`npm config set\`, \`npm config get\`, or + alias: c @@ -3209,6 +3227,7 @@ Note: This command is unaware of workspaces. #### \`editor\` #### \`location\` #### \`long\` +#### \`command\` ` exports[`test/lib/docs.js TAP usage dedupe > must match snapshot 1`] = ` diff --git a/workspaces/config/lib/definitions/definitions.js b/workspaces/config/lib/definitions/definitions.js index 7c5b2ce170d89..d0e299fed2c70 100644 --- a/workspaces/config/lib/definitions/definitions.js +++ b/workspaces/config/lib/definitions/definitions.js @@ -466,6 +466,17 @@ const definitions = { : !!process.stderr.isTTY }, }), + command: new Definition('command', { + default: null, + type: [null, String], + description: ` + When used with \`npm config set\`, \`npm config get\`, or + \`npm config delete\`, specifies the command section to target. + For example, \`npm config set registry=https://registry.npmjs.org/ + --command=publish\` will set the registry only for the \`publish\` + command by writing it under a \`[publish]\` section in the .npmrc file. + `, + }), 'commit-hooks': new Definition('commit-hooks', { default: true, type: Boolean, diff --git a/workspaces/config/lib/index.js b/workspaces/config/lib/index.js index a1acb7969b29f..9359c4b8dcf3b 100644 --- a/workspaces/config/lib/index.js +++ b/workspaces/config/lib/index.js @@ -46,20 +46,32 @@ const confFileTypes = new Set([ 'project', ]) +// file types that can contain [command] sections +const confSectionTypes = new Set([ + 'builtin', + ...confFileTypes, +]) + const confTypes = new Set([ 'default', 'builtin', ...confFileTypes, + 'command', 'env', 'cli', ]) +const unsafeKeys = new Set(['__proto__', 'constructor', 'prototype']) + class Config { #loaded = false #flatten // populated the first time we flatten the object #flatOptions = null #warnings = [] + // stores per-command config sections from .npmrc files + // Map> + #commandConfigs = new Map() static get typeDefs () { return typeDefs @@ -238,6 +250,123 @@ class Config { } } + // Apply command-specific configs from [command] sections in .npmrc files. + // Call this after the command name is resolved but before reading config values. + applyCommand (commandName) { + if (!commandName) { + return + } + if (!this.loaded) { + throw new Error('call config.load() before applying command configs') + } + + const commandConf = this.data.get('command') + commandConf.source = `command-specific configs for "${commandName}"` + this.sources.set(commandConf.source, 'command') + + // Walk file-based layers in cascade order (builtin < global < user < project) + // and overlay matching command sections onto the command layer + for (const where of ['builtin', 'global', 'user', 'project']) { + const whereCommands = this.#commandConfigs.get(where) + if (!whereCommands) { + continue + } + const sectionData = whereCommands.get(commandName) + if (!sectionData) { + continue + } + for (const [key, value] of Object.entries(sectionData)) { + const k = envReplace(key, this.env) + const v = this.parseField(value, k) + this.#checkDeprecated(k) + commandConf.data[k] = v + } + } + + // invalidate flat options so they're regenerated with command configs + this.#flatOptions = null + } + + // Set a config value within a command section for a given file layer. + setCommandConfig (commandName, key, val, where) { + if (!this.loaded) { + throw new Error('call config.load() before setting command configs') + } + if (unsafeKeys.has(commandName)) { + throw new Error('invalid command name: ' + commandName) + } + if (!confFileTypes.has(where) && where !== 'builtin') { + throw new Error('invalid config location for command config: ' + where) + } + if (!this.#commandConfigs.has(where)) { + this.#commandConfigs.set(where, new Map()) + } + const whereCommands = this.#commandConfigs.get(where) + if (!whereCommands.has(commandName)) { + whereCommands.set(commandName, {}) + } + whereCommands.get(commandName)[key] = val + + // Also update the raw data so it saves correctly + const conf = this.data.get(where) + if (!hasOwnProperty(conf.raw, commandName)) { + conf.raw[commandName] = {} + } + conf.raw[commandName][key] = val + + this.#flatOptions = null + } + + // Delete a config value from a command section for a given file layer. + deleteCommandConfig (commandName, key, where) { + if (!this.loaded) { + throw new Error('call config.load() before deleting command configs') + } + if (unsafeKeys.has(commandName)) { + throw new Error('invalid command name: ' + commandName) + } + const whereCommands = this.#commandConfigs.get(where) + if (whereCommands?.has(commandName)) { + const sectionData = whereCommands.get(commandName) + delete sectionData[key] + if (Object.keys(sectionData).length === 0) { + whereCommands.delete(commandName) + } + } + + // Also update raw data + const conf = this.data.get(where) + if (hasOwnProperty(conf.raw, commandName)) { + delete conf.raw[commandName][key] + if (Object.keys(conf.raw[commandName]).length === 0) { + delete conf.raw[commandName] + } + } + + this.#flatOptions = null + } + + // Get the command-specific value for a key, if set in any [command] section. + getCommandConfig (commandName, key, where = null) { + if (!this.loaded) { + throw new Error('call config.load() before getting command configs') + } + if (where) { + const whereCommands = this.#commandConfigs.get(where) + const sectionData = whereCommands?.get(commandName) + return sectionData?.[key] + } + // Walk in reverse priority order to find the highest-priority value + for (const w of ['project', 'user', 'global', 'builtin']) { + const whereCommands = this.#commandConfigs.get(w) + const sectionData = whereCommands?.get(commandName) + if (sectionData && key in sectionData) { + return sectionData[key] + } + } + return undefined + } + async load () { if (this.loaded) { throw new Error('attempting to load npm config multiple times') @@ -583,6 +712,26 @@ class Config { } else { conf.raw = obj outer: for (const [key, value] of Object.entries(obj)) { + // Detect INI sections: object values from ini.parse() represent [section] headers + if (confSectionTypes.has(where) && value !== null && typeof value === 'object' && !Array.isArray(value)) { + // Skip unsafe keys (ini package already filters __proto__ but be safe) + if (unsafeKeys.has(key)) { + continue + } + // Store command-specific configs separately + if (!this.#commandConfigs.has(where)) { + this.#commandConfigs.set(where, new Map()) + } + const whereCommands = this.#commandConfigs.get(where) + whereCommands.set(key, value) + + // Validate keys inside the section against known config definitions + for (const sectionKey of Object.keys(value)) { + this.checkUnknown(`${where}:[${key}]`, sectionKey) + } + continue + } + const k = envReplace(key, this.env) const v = this.parseField(value, k) if (where !== 'default') { diff --git a/workspaces/config/test/index.js b/workspaces/config/test/index.js index fea502d38f767..6d3020c87a78f 100644 --- a/workspaces/config/test/index.js +++ b/workspaces/config/test/index.js @@ -1868,3 +1868,320 @@ t.test('before and min-release-age', async t => { // Simple gut check to make sure we didn't do + instead of - t.ok(config.flat.before < Date.now(), 'before date is in the past not the future') }) + +t.test('command-specific configs via INI sections', async t => { + t.test('parses [command] sections and applies them', async t => { + const path = t.testdir({ + project: { + 'package.json': '{"name":"test"}', + '.npmrc': [ + 'registry=http://localhost:1337/npm', + '', + '[publish]', + 'registry=https://registry.npmjs.org/', + ].join('\n'), + }, + }) + + const config = new Config({ + npmPath: `${path}/npm`, + env: {}, + argv: [process.execPath, __filename], + cwd: `${path}/project`, + definitions, + shorthands, + flatten, + }) + + await config.load() + t.equal(config.get('registry'), 'http://localhost:1337/npm', + 'before applyCommand, registry is the default') + + config.applyCommand('publish') + t.equal(config.get('registry'), 'https://registry.npmjs.org/', + 'after applyCommand("publish"), registry is from [publish] section') + }) + + t.test('non-matching command does not override config', async t => { + const path = t.testdir({ + project: { + 'package.json': '{"name":"test"}', + '.npmrc': [ + 'registry=http://localhost:1337/npm', + '', + '[publish]', + 'registry=https://registry.npmjs.org/', + ].join('\n'), + }, + }) + + const config = new Config({ + npmPath: `${path}/npm`, + env: {}, + argv: [process.execPath, __filename], + cwd: `${path}/project`, + definitions, + shorthands, + flatten, + }) + + await config.load() + config.applyCommand('install') + t.equal(config.get('registry'), 'http://localhost:1337/npm', + 'install command does not get publish-scoped registry') + }) + + t.test('CLI flags override command sections', async t => { + const path = t.testdir({ + project: { + 'package.json': '{"name":"test"}', + '.npmrc': [ + 'registry=http://localhost:1337/npm', + '', + '[publish]', + 'registry=https://registry.npmjs.org/', + ].join('\n'), + }, + }) + + const config = new Config({ + npmPath: `${path}/npm`, + env: {}, + argv: [process.execPath, __filename, '--registry=http://cli-override/'], + cwd: `${path}/project`, + definitions, + shorthands, + flatten, + }) + + await config.load() + config.applyCommand('publish') + t.equal(config.get('registry'), 'http://cli-override/', + 'CLI --registry overrides [publish] section') + }) + + t.test('command sections from multiple layers cascade correctly', async t => { + const path = t.testdir({ + global: { + etc: { + npmrc: [ + '[publish]', + 'registry=http://global-publish/', + ].join('\n'), + }, + }, + project: { + 'package.json': '{"name":"test"}', + '.npmrc': [ + '[publish]', + 'registry=http://project-publish/', + ].join('\n'), + }, + }) + + const config = new Config({ + npmPath: `${path}/npm`, + env: {}, + argv: [ + process.execPath, __filename, + `--globalconfig=${path}/global/etc/npmrc`, + `--userconfig=${path}/noexist`, + ], + cwd: `${path}/project`, + definitions, + shorthands, + flatten, + }) + + await config.load() + config.applyCommand('publish') + t.equal(config.get('registry'), 'http://project-publish/', + 'project [publish] overrides global [publish]') + }) + + t.test('command sections beat non-sectioned values from higher layers', async t => { + const path = t.testdir({ + user: { + '.npmrc': [ + '[publish]', + 'registry=http://user-publish/', + ].join('\n'), + }, + project: { + 'package.json': '{"name":"test"}', + '.npmrc': 'registry=http://project-default/', + }, + }) + + const config = new Config({ + npmPath: `${path}/npm`, + env: {}, + argv: [ + process.execPath, __filename, + `--userconfig=${path}/user/.npmrc`, + `--globalconfig=${path}/noexist`, + ], + cwd: `${path}/project`, + definitions, + shorthands, + flatten, + }) + + await config.load() + config.applyCommand('publish') + t.equal(config.get('registry'), 'http://user-publish/', + '[publish] from user .npmrc beats non-sectioned registry from project .npmrc') + }) + + t.test('save round-trips sections correctly', async t => { + const path = t.testdir({ + project: { + 'package.json': '{"name":"test"}', + '.npmrc': [ + 'registry=http://localhost:1337/npm', + '', + '[publish]', + 'registry=https://registry.npmjs.org/', + ].join('\n'), + }, + }) + + const config = new Config({ + npmPath: `${path}/npm`, + env: {}, + argv: [process.execPath, __filename], + cwd: `${path}/project`, + definitions, + shorthands, + flatten, + }) + + await config.load() + await config.save('project') + + const saved = readFileSync(`${path}/project/.npmrc`, 'utf8') + t.match(saved, /registry=http:\/\/localhost:1337\/npm/) + t.match(saved, /\[publish\]/) + t.match(saved, /registry=https:\/\/registry\.npmjs\.org\//) + }) + + t.test('setCommandConfig adds a new section', async t => { + const path = t.testdir({ + project: { + 'package.json': '{"name":"test"}', + '.npmrc': 'registry=http://localhost:1337/npm', + }, + }) + + const config = new Config({ + npmPath: `${path}/npm`, + env: {}, + argv: [process.execPath, __filename], + cwd: `${path}/project`, + definitions, + shorthands, + flatten, + }) + + await config.load() + config.setCommandConfig('publish', 'registry', 'https://registry.npmjs.org/', 'project') + await config.save('project') + + const saved = readFileSync(`${path}/project/.npmrc`, 'utf8') + t.match(saved, /registry=http:\/\/localhost:1337\/npm/) + t.match(saved, /\[publish\]/) + t.match(saved, /registry=https:\/\/registry\.npmjs\.org\//) + }) + + t.test('deleteCommandConfig removes a section key', async t => { + const path = t.testdir({ + project: { + 'package.json': '{"name":"test"}', + '.npmrc': [ + 'registry=http://localhost:1337/npm', + '', + '[publish]', + 'registry=https://registry.npmjs.org/', + ].join('\n'), + }, + }) + + const config = new Config({ + npmPath: `${path}/npm`, + env: {}, + argv: [process.execPath, __filename], + cwd: `${path}/project`, + definitions, + shorthands, + flatten, + }) + + await config.load() + config.deleteCommandConfig('publish', 'registry', 'project') + await config.save('project') + + const saved = readFileSync(`${path}/project/.npmrc`, 'utf8') + t.match(saved, /registry=http:\/\/localhost:1337\/npm/) + t.notMatch(saved, /\[publish\]/) + }) + + t.test('getCommandConfig returns scoped value', async t => { + const path = t.testdir({ + project: { + 'package.json': '{"name":"test"}', + '.npmrc': [ + 'registry=http://localhost:1337/npm', + '', + '[publish]', + 'registry=https://registry.npmjs.org/', + ].join('\n'), + }, + }) + + const config = new Config({ + npmPath: `${path}/npm`, + env: {}, + argv: [process.execPath, __filename], + cwd: `${path}/project`, + definitions, + shorthands, + flatten, + }) + + await config.load() + t.equal(config.getCommandConfig('publish', 'registry'), + 'https://registry.npmjs.org/', + 'getCommandConfig returns section value') + t.equal(config.getCommandConfig('install', 'registry'), + undefined, + 'getCommandConfig returns undefined for non-matching command') + t.equal(config.getCommandConfig('publish', 'registry', 'project'), + 'https://registry.npmjs.org/', + 'getCommandConfig with where returns section value from that layer') + }) + + t.test('applyCommand with null/undefined is a no-op', async t => { + const path = t.testdir({ + project: { + 'package.json': '{"name":"test"}', + '.npmrc': 'registry=http://localhost:1337/npm', + }, + }) + + const config = new Config({ + npmPath: `${path}/npm`, + env: {}, + argv: [process.execPath, __filename], + cwd: `${path}/project`, + definitions, + shorthands, + flatten, + }) + + await config.load() + config.applyCommand(null) + config.applyCommand(undefined) + t.equal(config.get('registry'), 'http://localhost:1337/npm', + 'config unchanged after null/undefined applyCommand') + }) +})