From fd9faf7934b1aa415a0b2578731af904ab33825e Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Thu, 23 Apr 2026 13:29:36 -0700 Subject: [PATCH] feat!: error on unknown configs, flags, and abbreviations BREAKING CHANGE: unknown configs in .npmrc, unknown CLI flags, abbreviated flags, and single-hyphen multi-char shorthands now throw instead of warning. --- lib/base-cmd.js | 104 +++-- lib/commands/completion.js | 1 + lib/commands/doctor.js | 1 + lib/commands/help.js | 1 + lib/commands/version.js | 1 + lib/npm.js | 6 +- .../test/lib/commands/config.js.test.cjs | 29 +- .../test/lib/commands/diff.js.test.cjs | 18 +- test/lib/base-cmd.js | 132 +++++- test/lib/commands/config.js | 46 +- test/lib/commands/diff.js | 8 +- test/lib/commands/login.js | 5 +- test/lib/commands/logout.js | 20 +- test/lib/commands/set.js | 8 +- workspaces/config/lib/index.js | 109 +++-- .../tap-snapshots/test/index.js.test.cjs | 100 +--- workspaces/config/test/index.js | 439 ++++++++++++------ 17 files changed, 610 insertions(+), 418 deletions(-) diff --git a/lib/base-cmd.js b/lib/base-cmd.js index 089ec680c7425..2fa3813b2edb4 100644 --- a/lib/base-cmd.js +++ b/lib/base-cmd.js @@ -1,5 +1,5 @@ const { log } = require('proc-log') -const { definitions, shorthands } = require('@npmcli/config/lib/definitions') +const { definitions } = require('@npmcli/config/lib/definitions') const nopt = require('nopt') class BaseCommand { @@ -323,10 +323,9 @@ class BaseCommand { delete parsed.argv } - // Validate flags - only if command has definitions (new system) - if (this.constructor.definitions && this.constructor.definitions.length > 0) { - this.#validateFlags(parsed, commandDefinitions, remains) - } + // Validate unknown CLI flags/configs and unexpected positionals. + // Runs for every command; command-specific flags are allow-listed here so they don't trip the global unknown-config collection from Config.loadCLI(). + this.validateCli(commandDefinitions, remains) // Check for conflicts between main flags and their aliases // Also map aliases back to their main keys @@ -363,64 +362,67 @@ class BaseCommand { return [{ ...defaults, ...filtered }, remains] } - // Validate flags and throw errors for unknown flags or unexpected positionals - #validateFlags (parsed, commandDefinitions, remains) { - // Build a set of all valid flag names (global + command-specific + shorthands) - const validFlags = new Set([ - ...Object.keys(definitions), + // Unified CLI validation — runs for every command (definitions-based and legacy). + // Reads collected unknown configs from Config (they were collected, not thrown, during Config.load()), subtracts any command-specific definitions, and throws a single aggregated error. + // Also enforces extra-positional errors for commands that set a finite `static positionals`. + // Shellout commands (run/exec/lifecycle) leave `static positionals = null` and are unaffected. + // Commands that set `static skipConfigValidation = true` (config, help, doctor, completion, version) bypass both unknown-config checks so they can operate against a broken .npmrc. + validateCli (commandDefinitions = this.constructor.definitions || [], remains = null) { + const allowlist = new Set([ ...commandDefinitions.map(d => d.key), - ...Object.keys(shorthands), // Add global shorthands like 'verbose', 'dd', etc. + ...commandDefinitions.flatMap(d => Array.isArray(d.alias) ? d.alias : []), ]) - // Add aliases to valid flags - for (const def of commandDefinitions) { - if (def.alias && Array.isArray(def.alias)) { - for (const alias of def.alias) { - validFlags.add(alias) - } + if (!this.constructor.skipConfigValidation) { + const cliUnknowns = this.npm.config.getUnknownConfigs('cli') + .filter(u => !allowlist.has(u.key) && !allowlist.has(u.baseKey)) + if (cliUnknowns.length > 0) { + const flagList = cliUnknowns + .map(u => (u.baseKey ? `--${u.baseKey} (${u.key})` : `--${u.key}`)) + .join(', ') + throw this.usageError( + `Unknown cli config${cliUnknowns.length > 1 ? 's' : ''}: ${flagList}. ` + + `Run \`npm help config\` for supported options.` + ) } - } - // Check parsed flags against valid flags - const unknownFlags = [] - for (const key of Object.keys(parsed)) { - if (!validFlags.has(key)) { - unknownFlags.push(key) + const fileUnknowns = [] + for (const where of ['builtin', 'project', 'user', 'global']) { + fileUnknowns.push(...this.npm.config.getUnknownConfigs(where)) } - } - - // Throw error if unknown flags were found - if (unknownFlags.length > 0) { - const flagList = unknownFlags.map(f => `--${f}`).join(', ') - throw this.usageError(`Unknown flag${unknownFlags.length > 1 ? 's' : ''}: ${flagList}`) - } - - // Remove warnings for command-specific definitions that npm's global config doesn't know about (these were queued as "unknown" during config.load()) - for (const def of commandDefinitions) { - this.npm.config.removeWarning(def.key) - if (def.alias && Array.isArray(def.alias)) { - for (const alias of def.alias) { - this.npm.config.removeWarning(alias) - } + if (fileUnknowns.length > 0) { + const lines = fileUnknowns.map(u => { + const display = u.baseKey ? `"${u.baseKey}" (${u.key})` : `"${u.key}"` + return ` - ${u.where} config ${display} from ${u.source}` + }) + const msg = [ + `Unknown npm configuration key${fileUnknowns.length > 1 ? 's' : ''}:`, + ...lines, + 'See `npm help npmrc` for supported config options.', + ].join('\n') + throw Object.assign(new Error(msg), { + code: 'EUNKNOWNCONFIG', + unknownConfigs: fileUnknowns, + }) } } - // Remove warnings for unknown positionals that were actually consumed as flag values by command-specific definitions (e.g., --id where --id is command-specific) - const remainsSet = new Set(remains) - for (const unknownPos of this.npm.config.getUnknownPositionals()) { - if (!remainsSet.has(unknownPos)) { - // This value was consumed as a flag value, not truly a positional - this.npm.config.removeUnknownPositional(unknownPos) + // Positionals consumed as flag values by command-specific definitions were queued as "unknown positional" warnings by Config.unknownHandler; drop those since they're actually flag arguments. + if (Array.isArray(remains)) { + const remainsSet = new Set(remains) + for (const unknownPos of this.npm.config.getUnknownPositionals()) { + if (!remainsSet.has(unknownPos)) { + this.npm.config.removeUnknownPositional(unknownPos) + } } } - // Warn about extra positional arguments beyond what the command expects - const expectedPositionals = this.constructor.positionals - if (expectedPositionals !== null && remains.length > expectedPositionals) { - const extraPositionals = remains.slice(expectedPositionals) - for (const extra of extraPositionals) { - throw new Error(`Unknown positional argument: ${extra}`) - } + const expected = this.constructor.positionals + if (expected !== null && remains !== null && remains.length > expected) { + const extra = remains.slice(expected) + throw this.usageError( + `Unknown positional argument${extra.length > 1 ? 's' : ''}: ${extra.join(', ')}` + ) } this.npm.config.logWarnings() diff --git a/lib/commands/completion.js b/lib/commands/completion.js index 499afcb5c68bc..712cf7358832f 100644 --- a/lib/commands/completion.js +++ b/lib/commands/completion.js @@ -37,6 +37,7 @@ class Completion extends BaseCommand { static name = 'completion' // Completion command uses args differently - they represent the command line being completed, not actual arguments to this command, so we use an empty definitions object to prevent flag validation static definitions = [] + static skipConfigValidation = true // completion for the completion command static async completion (opts) { diff --git a/lib/commands/doctor.js b/lib/commands/doctor.js index f01b05bead27a..203f6d156c5b8 100644 --- a/lib/commands/doctor.js +++ b/lib/commands/doctor.js @@ -99,6 +99,7 @@ class Doctor extends BaseCommand { static name = 'doctor' static params = ['registry'] static ignoreImplicitWorkspace = false + static skipConfigValidation = true static usage = [`[${checks.flatMap(s => s.groups) .filter((value, index, self) => self.indexOf(value) === index && value !== 'ping') .join('] [')}]`] diff --git a/lib/commands/help.js b/lib/commands/help.js index a684667e32411..d520dd1cceb45 100644 --- a/lib/commands/help.js +++ b/lib/commands/help.js @@ -25,6 +25,7 @@ class Help extends BaseCommand { static name = 'help' static usage = [' []'] static params = ['viewer'] + static skipConfigValidation = true static async completion (opts, npm) { if (opts.conf.argv.remain.length > 2) { diff --git a/lib/commands/version.js b/lib/commands/version.js index fe70322fd7cb9..67b17cfbf3967 100644 --- a/lib/commands/version.js +++ b/lib/commands/version.js @@ -23,6 +23,7 @@ class Version extends BaseCommand { static workspaces = true static ignoreImplicitWorkspace = false + static skipConfigValidation = true static usage = ['[ | major | minor | patch | premajor | preminor | prepatch | prerelease | from-git]'] diff --git a/lib/npm.js b/lib/npm.js index 2b4b2843e9218..b7317970771d1 100644 --- a/lib/npm.js +++ b/lib/npm.js @@ -291,8 +291,10 @@ class Npm { ? commandInstance.execWorkspaces(positionalArgs, flags) : commandInstance.exec(positionalArgs, flags)) } else { - // Legacy commands without definitions - this.config.logWarnings() + // Legacy commands without definitions: still validate unknown CLI configs/flags and (when finite) extra positionals. + if (typeof commandInstance.validateCli === 'function') { + commandInstance.validateCli([], args) + } return time.start(`command:${commandName}`, () => execWorkspaces ? commandInstance.execWorkspaces(args) : commandInstance.exec(args)) } diff --git a/tap-snapshots/test/lib/commands/config.js.test.cjs b/tap-snapshots/test/lib/commands/config.js.test.cjs index 3446ea8617461..221683f397784 100644 --- a/tap-snapshots/test/lib/commands/config.js.test.cjs +++ b/tap-snapshots/test/lib/commands/config.js.test.cjs @@ -10,9 +10,9 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna "cache": "{CACHE}", "color": {COLOR}, "json": true, - "projectloaded": "yes", - "userloaded": "yes", - "globalloaded": "yes", + "tag": "from-project", + "init-author-name": "from-user", + "init-license": "from-global", "access": null, "all": false, "allow-same-version": false, @@ -74,9 +74,7 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna "include-workspace-root": false, "include-attestations": false, "init-author-email": "", - "init-author-name": "", "init-author-url": "", - "init-license": "ISC", "init-module": "{CWD}/home/.npm-init.js", "init-type": "commonjs", "init-version": "1.0.0", @@ -164,7 +162,6 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna "sign-git-tag": false, "strict-peer-deps": false, "strict-ssl": true, - "tag": "latest", "tag-version-prefix": "v", "timing": false, "umask": 0, @@ -252,9 +249,9 @@ include-attestations = false include-staged = false include-workspace-root = false init-author-email = "" -init-author-name = "" +; init-author-name = "" ; overridden by user init-author-url = "" -init-license = "ISC" +; init-license = "ISC" ; overridden by global init-module = "{CWD}/home/.npm-init.js" init-private = false init-type = "commonjs" @@ -342,7 +339,7 @@ sign-git-commit = false sign-git-tag = false strict-peer-deps = false strict-ssl = true -tag = "latest" +; tag = "latest" ; overridden by project tag-version-prefix = "v" timing = false token-description = null @@ -363,15 +360,15 @@ yes = null ; "global" config from {CWD}/global/etc/npmrc -globalloaded = "yes" +init-license = "from-global" ; "user" config from {CWD}/home/.npmrc -userloaded = "yes" +init-author-name = "from-user" ; "project" config from {CWD}/prefix/.npmrc -projectloaded = "yes" +tag = "from-project" ; "cli" config from command line options @@ -383,19 +380,17 @@ long = true exports[`test/lib/commands/config.js TAP config list > output matches snapshot 1`] = ` ; "global" config from {CWD}/global/etc/npmrc -globalloaded = "yes" +init-license = "from-global" ; "user" config from {CWD}/home/.npmrc _auth = (protected) //nerfdart:_auth = (protected) -//nerfdart:auth = (protected) -auth = (protected) -userloaded = "yes" +init-author-name = "from-user" ; "project" config from {CWD}/prefix/.npmrc -projectloaded = "yes" +tag = "from-project" ; "cli" config from command line options diff --git a/tap-snapshots/test/lib/commands/diff.js.test.cjs b/tap-snapshots/test/lib/commands/diff.js.test.cjs index e87086d7d9b8f..0eb91154b2417 100644 --- a/tap-snapshots/test/lib/commands/diff.js.test.cjs +++ b/tap-snapshots/test/lib/commands/diff.js.test.cjs @@ -62,11 +62,13 @@ package.json ` exports[`test/lib/commands/diff.js TAP various options using diff option > must match snapshot 1`] = ` -diff --git a/index.js b/index.js +diff --git bar/index.js foo/index.js index v2.0.0..v3.0.0 100644 ---- a/index.js -+++ b/index.js -@@ -18,7 +18,7 @@ +--- bar/index.js ++++ foo/index.js +@@ -16,11 +16,11 @@ + 15 + 16 17 18 19 @@ -75,10 +77,12 @@ index v2.0.0..v3.0.0 100644 21 22 23 -diff --git a/package.json b/package.json + 24 + 25 +diff --git bar/package.json foo/package.json index v2.0.0..v3.0.0 100644 ---- a/package.json -+++ b/package.json +--- bar/package.json ++++ foo/package.json @@ -1,4 +1,4 @@ { "name": "bar", diff --git a/test/lib/base-cmd.js b/test/lib/base-cmd.js index 41bade7298b90..ff4e74cf941aa 100644 --- a/test/lib/base-cmd.js +++ b/test/lib/base-cmd.js @@ -118,7 +118,9 @@ t.test('flags() method with no definitions', async t => { }) t.test('flags() throws error for unknown flags', async t => { - const { npm } = await loadMockNpm(t) + const { npm } = await loadMockNpm(t, { + npm: { argv: ['test-command', '--unknown-flag'] }, + }) class TestCommand extends BaseCommand { static name = 'test-command' @@ -139,13 +141,10 @@ t.test('flags() throws error for unknown flags', async t => { } } - // Manually set config.argv to simulate command-line with unknown flag - npm.config.argv = ['node', 'npm', 'test-command', '--unknown-flag'] - const command = new TestCommand(npm) await t.rejects( command.exec(), - { message: /Unknown flag.*--unknown-flag/ }, + { message: /Unknown cli config.*--unknown-flag/ }, 'throws error for unknown flag' ) }) @@ -416,7 +415,9 @@ t.test('flags() returns defaults when argv is empty', async t => { }) t.test('flags() throws error for multiple unknown flags with pluralization', async t => { - const { npm } = await loadMockNpm(t) + const { npm } = await loadMockNpm(t, { + npm: { argv: ['test-command', '--unknown-one', '--unknown-two'] }, + }) class TestCommand extends BaseCommand { static name = 'test-command' @@ -442,7 +443,7 @@ t.test('flags() throws error for multiple unknown flags with pluralization', asy const command = new TestCommand(npm) await t.rejects( command.exec(), - { message: /Unknown flags:.*--unknown-one.*--unknown-two/ }, + { message: /Unknown cli configs:.*--unknown-one.*--unknown-two/ }, 'throws error with pluralized "flags" for multiple unknown flags' ) }) @@ -636,8 +637,8 @@ t.test('flags() throws error for extra positional arguments beyond expected coun // Should throw error for extra positional await t.rejects( command.exec(), - { message: 'Unknown positional argument: extra1' }, - 'throws error for first extra positional' + { message: 'Unknown positional arguments: extra1, extra2' }, + 'throws error for extra positionals' ) }) @@ -677,3 +678,116 @@ t.test('flags() does not throw when positionals is null (unlimited)', async t => t.same(remains, ['pkg1', 'extra1', 'extra2'], 'all positionals are in remains') t.equal(flags.id, null, 'id flag uses default') }) + +t.test('validateCli throws aggregated error for unknown file configs', async t => { + const { npm } = await loadMockNpm(t, { + homeDir: { + '.npmrc': [ + 'bogus-user-key=yes', + '@scope:bogus-scoped=no', + ].join('\n'), + }, + }) + + class TestCommand extends BaseCommand { + static name = 'test-command' + static description = 'Test command' + } + + const command = new TestCommand(npm) + await t.rejects( + (async () => command.validateCli())(), + { + code: 'EUNKNOWNCONFIG', + message: /Unknown npm configuration keys:[\s\S]*bogus-user-key[\s\S]*bogus-scoped[\s\S]*npm help npmrc/, + }, + 'throws EUNKNOWNCONFIG aggregating plain and scoped keys' + ) +}) + +t.test('validateCli uses singular "key" when only one unknown file config', async t => { + const { npm } = await loadMockNpm(t, { + homeDir: { + '.npmrc': 'only-one-bad-key=yes', + }, + }) + + class TestCommand extends BaseCommand { + static name = 'test-command' + static description = 'Test command' + } + + const command = new TestCommand(npm) + await t.rejects( + (async () => command.validateCli())(), + { + code: 'EUNKNOWNCONFIG', + message: /Unknown npm configuration key:\n/, + }, + 'singular phrasing when only one unknown key' + ) +}) + +t.test('validateCli bypasses checks when skipConfigValidation is set', async t => { + const { npm } = await loadMockNpm(t, { + homeDir: { + '.npmrc': 'bogus-user-key=yes', + }, + }) + + class TestCommand extends BaseCommand { + static name = 'test-command' + static description = 'Test command' + static skipConfigValidation = true + } + + const command = new TestCommand(npm) + t.doesNotThrow(() => command.validateCli(), 'skipConfigValidation bypasses unknown-file-config check') +}) + +t.test('flags() throws with scoped (nerfdart) display for unknown cli config', async t => { + const { npm } = await loadMockNpm(t, { + argv: ['--@scope:bogus=yes'], + }) + + class TestCommand extends BaseCommand { + static name = 'test-command' + static description = 'Test command' + + async exec () { + return this.flags() + } + } + + npm.config.argv = ['node', 'npm', 'test-command', '--@scope:bogus=yes'] + const command = new TestCommand(npm) + await t.rejects( + command.exec(), + { message: /Unknown cli config:.*--bogus \(@scope:bogus\)/ }, + 'scoped display form uses baseKey (key) layout' + ) +}) + +t.test('flags() throws singular "argument" for a single extra positional', async t => { + const { npm } = await loadMockNpm(t, { + argv: ['pkg1', 'extra1'], + }) + + class TestCommand extends BaseCommand { + static name = 'test-command' + static description = 'Test command' + static positionals = 1 + + async exec () { + return this.flags() + } + } + + npm.config.argv = ['node', 'npm', 'test-command', 'pkg1', 'extra1'] + const command = new TestCommand(npm) + await t.rejects( + command.exec(), + { message: 'Unknown positional argument: extra1' }, + 'singular phrasing when only one extra positional' + ) +}) diff --git a/test/lib/commands/config.js b/test/lib/commands/config.js index fbcc58ba40153..3bfd7757c0df8 100644 --- a/test/lib/commands/config.js +++ b/test/lib/commands/config.js @@ -72,19 +72,17 @@ t.test('config ignores workspaces', async t => { t.test('config list', async t => { const { npm, joinedOutput } = await loadMockNpm(t, { prefixDir: { - '.npmrc': 'projectloaded=yes', + '.npmrc': 'tag=from-project', }, globalPrefixDir: { etc: { - npmrc: 'globalloaded=yes', + npmrc: 'init-license=from-global', }, }, homeDir: { '.npmrc': [ - 'userloaded=yes', - 'auth=bad', + 'init-author-name=from-user', '_auth=bad', - '//nerfdart:auth=bad', '//nerfdart:_auth=bad', ].join('\n'), }, @@ -94,9 +92,9 @@ t.test('config list', async t => { const output = joinedOutput() - t.match(output, 'projectloaded = "yes"') - t.match(output, 'globalloaded = "yes"') - t.match(output, 'userloaded = "yes"') + t.match(output, 'tag = "from-project"') + t.match(output, 'init-license = "from-global"') + t.match(output, 'init-author-name = "from-user"') t.matchSnapshot(output, 'output matches snapshot') }) @@ -130,7 +128,7 @@ t.test('config list with proxy environment variables', async t => { const { npm, joinedOutput } = await loadMockNpm(t, { prefixDir: { - '.npmrc': 'test=value', + '.npmrc': 'tag=value', }, }) @@ -147,15 +145,15 @@ t.test('config list with proxy environment variables', async t => { t.test('config list --long', async t => { const { npm, joinedOutput } = await loadMockNpm(t, { prefixDir: { - '.npmrc': 'projectloaded=yes', + '.npmrc': 'tag=from-project', }, globalPrefixDir: { etc: { - npmrc: 'globalloaded=yes', + npmrc: 'init-license=from-global', }, }, homeDir: { - '.npmrc': 'userloaded=yes', + '.npmrc': 'init-author-name=from-user', }, config: { long: true, @@ -166,9 +164,9 @@ t.test('config list --long', async t => { const output = joinedOutput() - t.match(output, 'projectloaded = "yes"') - t.match(output, 'globalloaded = "yes"') - t.match(output, 'userloaded = "yes"') + t.match(output, 'tag = "from-project"') + t.match(output, 'init-license = "from-global"') + t.match(output, 'init-author-name = "from-user"') t.matchSnapshot(output, 'output matches snapshot') }) @@ -176,15 +174,15 @@ t.test('config list --long', async t => { t.test('config list --json', async t => { const { npm, joinedOutput } = await loadMockNpm(t, { prefixDir: { - '.npmrc': 'projectloaded=yes', + '.npmrc': 'tag=from-project', }, globalPrefixDir: { etc: { - npmrc: 'globalloaded=yes', + npmrc: 'init-license=from-global', }, }, homeDir: { - '.npmrc': 'userloaded=yes', + '.npmrc': 'init-author-name=from-user', }, config: { json: true, @@ -195,9 +193,9 @@ t.test('config list --json', async t => { const output = joinedOutput() - t.match(output, '"projectloaded": "yes",') - t.match(output, '"globalloaded": "yes",') - t.match(output, '"userloaded": "yes",') + t.match(output, '"tag": "from-project"') + t.match(output, '"init-license": "from-global"') + t.match(output, '"init-author-name": "from-user"') t.matchSnapshot(output, 'output matches snapshot') }) @@ -569,9 +567,7 @@ t.test('config edit', async t => { homeDir: { '.npmrc': 'foo=bar\nbar=baz', }, - config: { - editor: EDITOR, - }, + npm: { argv: ['config', 'edit', '--editor=' + EDITOR] }, }) await npm.exec('config', ['edit']) @@ -637,6 +633,7 @@ t.test('config fix', (t) => { homeDir: { '.npmrc': '_authtoken=thisisinvalid\n_auth=beef', }, + npm: { argv: ['config', 'fix'] }, }) const registry = `//registry.npmjs.org/` @@ -680,6 +677,7 @@ t.test('config fix', (t) => { config: { location: 'user', }, + npm: { argv: ['config', 'fix', '--location=user'] }, }) const registry = `//registry.npmjs.org/` diff --git a/test/lib/commands/diff.js b/test/lib/commands/diff.js index 3d55cd7879b7a..0e30ac9b71cec 100644 --- a/test/lib/commands/diff.js +++ b/test/lib/commands/diff.js @@ -943,11 +943,11 @@ t.test('various options', async t => { t.test('using diff option', async t => { const { output } = await mockOptions(t, { - 'diff-context': 5, - 'diff-ignore-whitespace': true, + 'diff-unified': 5, + 'diff-ignore-all-space': true, 'diff-no-prefix': false, - 'diff-drc-prefix': 'foo/', - 'diff-fst-prefix': 'bar/', + 'diff-dst-prefix': 'foo/', + 'diff-src-prefix': 'bar/', 'diff-text': true, }) diff --git a/test/lib/commands/login.js b/test/lib/commands/login.js index 55568edd09f9d..f855fe683bd9f 100644 --- a/test/lib/commands/login.js +++ b/test/lib/commands/login.js @@ -49,7 +49,6 @@ t.test('legacy', t => { homeDir: { '.npmrc': [ '//registry.npmjs.org/:_authToken=user', - '//registry.npmjs.org/:always-auth=user', '//registry.npmjs.org/:email=test-email-old@npmjs.org', ].join('\n'), }, @@ -63,8 +62,8 @@ t.test('legacy', t => { t.same(npm.config.get('//registry.npmjs.org/:_authToken'), 'npm_test-token') t.same(rc(), { '//registry.npmjs.org/:_authToken': 'npm_test-token', - email: 'test-email-old@npmjs.org', - }, 'should only have token and un-nerfed old email') + '//registry.npmjs.org/:email': 'test-email-old@npmjs.org', + }, 'should only have token and nerfed email') }) t.test('scoped login default registry', async t => { diff --git a/test/lib/commands/logout.js b/test/lib/commands/logout.js index 840c92274bad0..b18b84ca48325 100644 --- a/test/lib/commands/logout.js +++ b/test/lib/commands/logout.js @@ -9,7 +9,7 @@ t.test('token logout - user config', async t => { homeDir: { '.npmrc': [ '//registry.npmjs.org/:_authToken=@foo/', - 'other-config=true', + 'fund=true', ].join('\n'), }, }) @@ -23,7 +23,7 @@ t.test('token logout - user config', async t => { 'should log message with correct registry' ) const userRc = await fs.readFile(join(home, '.npmrc'), 'utf-8') - t.equal(userRc.trim(), 'other-config=true') + t.equal(userRc.trim(), 'fund=true') }) t.test('token scoped logout - user config', async t => { @@ -60,7 +60,7 @@ t.test('user/pass logout - user config', async t => { '.npmrc': [ '//registry.npmjs.org/:username=foo', '//registry.npmjs.org/:_password=bar', - 'other-config=true', + 'fund=true', ].join('\n'), }, }) @@ -73,7 +73,7 @@ t.test('user/pass logout - user config', async t => { ) const userRc = await fs.readFile(join(home, '.npmrc'), 'utf-8') - t.equal(userRc.trim(), 'other-config=true') + t.equal(userRc.trim(), 'fund=true') }) t.test('missing credentials', async t => { @@ -95,7 +95,7 @@ t.test('ignore invalid scoped registry config', async t => { homeDir: { '.npmrc': [ '//registry.npmjs.org/:_authToken=@foo/', - 'other-config=true', + 'fund=true', ].join('\n'), }, @@ -111,7 +111,7 @@ t.test('ignore invalid scoped registry config', async t => { 'should log message with correct registry' ) const userRc = await fs.readFile(join(home, '.npmrc'), 'utf-8') - t.equal(userRc.trim(), 'other-config=true') + t.equal(userRc.trim(), 'fund=true') }) t.test('token logout - project config', async t => { @@ -119,13 +119,13 @@ t.test('token logout - project config', async t => { homeDir: { '.npmrc': [ '//registry.npmjs.org/:_authToken=@foo/', - 'other-config=true', + 'fund=true', ].join('\n'), }, prefixDir: { '.npmrc': [ '//registry.npmjs.org/:_authToken=@bar/', - 'other-config=true', + 'fund=true', ].join('\n'), }, }) @@ -142,7 +142,7 @@ t.test('token logout - project config', async t => { const userRc = await fs.readFile(join(home, '.npmrc'), 'utf-8') t.equal(userRc.trim(), [ '//registry.npmjs.org/:_authToken=@foo/', - 'other-config=true', + 'fund=true', ].join('\n'), 'leaves user config alone') t.equal( logs.verbose.byTitle('logout')[0], @@ -150,5 +150,5 @@ t.test('token logout - project config', async t => { 'should log message with correct registry' ) const projectRc = await fs.readFile(join(prefix, '.npmrc'), 'utf-8') - t.equal(projectRc.trim(), 'other-config=true', 'removes project config') + t.equal(projectRc.trim(), 'fund=true', 'removes project config') }) diff --git a/test/lib/commands/set.js b/test/lib/commands/set.js index 8574b80345e5c..fbb46571ecd1d 100644 --- a/test/lib/commands/set.js +++ b/test/lib/commands/set.js @@ -18,13 +18,13 @@ t.test('no args', async t => { t.test('test-config-item', async t => { const { npm, home, joinedOutput } = await mockNpm(t, { homeDir: { - '.npmrc': 'original-config-test=original value', + '.npmrc': 'tag=beta', }, }) t.equal( - npm.config.get('original-config-test'), - 'original value', + npm.config.get('tag'), + 'beta', 'original config is set from npmrc' ) @@ -46,7 +46,7 @@ t.test('test-config-item', async t => { t.equal( cleanNewlines(await fs.readFile(join(home, '.npmrc'), 'utf-8')), [ - 'original-config-test=original value', + 'tag=beta', 'fund=true', '', ].join('\n'), diff --git a/workspaces/config/lib/index.js b/workspaces/config/lib/index.js index a1acb7969b29f..6df865776716d 100644 --- a/workspaces/config/lib/index.js +++ b/workspaces/config/lib/index.js @@ -60,6 +60,13 @@ class Config { // populated the first time we flatten the object #flatOptions = null #warnings = [] + // Structured tracking of unknown configs by source. + // Each entry: { where, key, source, hint, baseKey? }. + // CLI entries are thrown by base-cmd after command/subcommand flag resolution. + // File entries (builtin/project/user/global) are aggregated and thrown at the end of load(). + // Env entries warn (inherited-env carve-out for npm-invoked-npm, via setEnvs()/loadEnv() round-trip). + // Planned to error in npm 13. + #unknownConfigs = [] static get typeDefs () { return typeDefs @@ -359,7 +366,10 @@ class Config { loadCLI () { for (const s of Object.keys(this.shorthands)) { if (s.length > 1 && this.argv.includes(`-${s}`)) { - log.warn(`-${s} is not a valid single-hyphen cli flag and will be removed in the future`) + throw Object.assign( + new Error(`-${s} is not a valid single-hyphen cli flag. Did you mean --${s}?`), + { code: 'EUNKNOWNCONFIG' } + ) } } nopt.invalidHandler = (k, val, type) => @@ -535,7 +545,10 @@ class Config { } abbrevHandler (short, long) { - log.warn(`Expanding --${short} to --${long}. This will stop working in the next major version of npm.`) + throw Object.assign( + new Error(`Invalid abbreviated flag "--${short}". Did you mean "--${long}"?`), + { code: 'EUNKNOWNCONFIG' } + ) } unknownHandler (key, next) { @@ -603,30 +616,65 @@ class Config { } } if (where !== 'default' || key === 'npm-version') { - this.checkUnknown(where, key) + this.checkUnknown(where, key, source) } conf.data[k] = v } } } - checkUnknown (where, key) { - if (!this.definitions[key]) { - if (internalEnv.includes(key)) { - return - } - const hint = where !== 'cli' - ? ' See `npm help npmrc` for supported config options.' - : '' - if (!key.includes(':')) { - this.queueWarning(key, `Unknown ${where} config "${where === 'cli' ? '--' : ''}${key}". This will stop working in the next major version of npm.${hint}`) + checkUnknown (where, key, source = null) { + if (this.definitions[key]) { + return + } + if (internalEnv.includes(key)) { + return + } + const scoped = key.includes(':') + let baseKey = null + if (scoped) { + baseKey = key.split(':').pop() + if (this.definitions[baseKey] || this.nerfDarts.includes(baseKey)) { return } - const baseKey = key.split(':').pop() - if (!this.definitions[baseKey] && !this.nerfDarts.includes(baseKey)) { - this.queueWarning(baseKey, `Unknown ${where} config "${baseKey}" (${key}). This will stop working in the next major version of npm.${hint}`) - } } + + const entry = { where, key, baseKey, source: source ?? this.data.get(where)?.source ?? null } + this.#unknownConfigs.push(entry) + + // publishConfig is handled by publish/unpublish/config commands and is out of scope for the npm 12 breaking change (tracked separately). + // Keep it as a queued warning so existing behavior is preserved. + if (where === 'publishConfig') { + const hint = ' See `npm help npmrc` for supported config options.' + const msg = scoped + ? `Unknown ${where} config "${baseKey}" (${key}). This will stop working in the next major version of npm.${hint}` + : `Unknown ${where} config "${key}". This will stop working in the next major version of npm.${hint}` + this.queueWarning(scoped ? baseKey : key, msg) + return + } + + // env unknowns are an explicit carve-out for npm 12. + // setEnvs() exports npm_config_* for child processes and loadEnv() re-ingests them; erroring here would break npm-invoked-npm and many CI setups. + // Keep as warning. Planned to error in npm 13. + if (where === 'env') { + const hint = ' See `npm help npmrc` for supported config options.' + const msg = scoped + ? `Unknown ${where} config "${baseKey}" (${key}). This will error in a future major version of npm.${hint}` + : `Unknown ${where} config "${key}". This will error in a future major version of npm.${hint}` + this.queueWarning(scoped ? baseKey : key, msg) + } + + // cli + file sources (builtin/project/user/global): collected only. + // File unknowns are thrown as a single aggregated error at the end of load(). + // CLI unknowns are thrown by base-cmd after command and subcommand flag resolution so subcommand-specific flags can be allow-listed before we error. + } + + // Returns unknown-config entries for a given source ('cli', 'builtin', 'project', 'user', 'global') or all non-env entries when omitted. + getUnknownConfigs (where) { + if (where) { + return this.#unknownConfigs.filter(u => u.where === where) + } + return this.#unknownConfigs.filter(u => u.where !== 'env' && u.where !== 'publishConfig') } #checkDeprecated (key) { @@ -782,13 +830,8 @@ class Config { conf[_loadError] = null if (where === 'user') { - // if email is nerfed, then we want to de-nerf it - const nerfed = nerfDart(this.get('registry')) - const email = this.get(`${nerfed}:email`, 'user') - if (email) { - this.delete(`${nerfed}:email`, 'user') - this.set('email', email, 'user') - } + // Historically, save('user') would "de-nerf" email — move a scoped `:email` into a top-level `email` key — because npm used to warn on nerfed email. + // In npm 12 the top-level `email` key is a hard error, so we keep email in its scoped form. } // We need the actual raw data before we called parseField so that we are @@ -816,11 +859,7 @@ class Config { this.delete(`_auth`, level) this.delete(`_password`, level) this.delete(`username`, level) - // de-nerf email if it's nerfed to the default registry - const email = this.get(`${nerfed}:email`, level) - if (email) { - this.set('email', email, level) - } + // In npm 12, top-level `email` is a hard error — don't de-nerf it here either. } this.delete(`${nerfed}:_authToken`, level) this.delete(`${nerfed}:_auth`, level) @@ -839,7 +878,9 @@ class Config { // send auth if we have it, only to the URIs under the nerf dart. this.delete(`${nerfed}:always-auth`, 'user') - this.delete(`${nerfed}:email`, 'user') + // NOTE: we intentionally do NOT delete `${nerfed}:email` here anymore. + // In npm 11 and earlier, top-level `email` was the canonical form (with getCredentialsByURI copying scoped email up to top-level on read), and setCredentialsByURI cleared the scoped copy to enforce that invariant. + // In npm 12 top-level `email` is a hard error and the scoped nerfdart form is canonical, so preserve any existing scoped email across login. if (certfile && keyfile) { this.set(`${nerfed}:certfile`, certfile, 'user') this.set(`${nerfed}:keyfile`, keyfile, 'user') @@ -870,17 +911,13 @@ class Config { // this has to be a bit more complicated to support legacy data of all forms getCredentialsByURI (uri) { const nerfed = nerfDart(uri) - const def = nerfDart(this.get('registry')) const creds = {} - // email is handled differently, it used to always be nerfed and now it never should be - // if it's set nerfed to the default registry, then we copy it to the unnerfed key + // email is handled differently, it used to always be nerfed and now it never should be. + // In npm 12 the top-level `email` key is a hard error, so we stop copying scoped email back to the top-level here. // TODO: evaluate removing 'email' from the credentials object returned here const email = this.get(`${nerfed}:email`) || this.get('email') if (email) { - if (nerfed === def) { - this.set('email', email, 'user') - } creds.email = email } diff --git a/workspaces/config/tap-snapshots/test/index.js.test.cjs b/workspaces/config/tap-snapshots/test/index.js.test.cjs index eccdbee3abced..935f328ae5659 100644 --- a/workspaces/config/tap-snapshots/test/index.js.test.cjs +++ b/workspaces/config/tap-snapshots/test/index.js.test.cjs @@ -5,26 +5,6 @@ * Make sure to inspect the output below. Do not ignore changes! */ 'use strict' -exports[`test/index.js TAP credentials management def_auth > default registry 1`] = ` -Object { - "auth": "aGVsbG86d29ybGQ=", - "password": "world", - "username": "hello", -} -` - -exports[`test/index.js TAP credentials management def_auth > default registry after set 1`] = ` -Object { - "auth": "aGVsbG86d29ybGQ=", - "password": "world", - "username": "hello", -} -` - -exports[`test/index.js TAP credentials management def_auth > other registry 1`] = ` -Object {} -` - exports[`test/index.js TAP credentials management def_authEnv > default registry 1`] = ` Object { "auth": "\${PATH}", @@ -37,54 +17,6 @@ exports[`test/index.js TAP credentials management def_authEnv > other registry 1 Object {} ` -exports[`test/index.js TAP credentials management def_passNoUser > default registry 1`] = ` -Object { - "email": "i@izs.me", -} -` - -exports[`test/index.js TAP credentials management def_passNoUser > other registry 1`] = ` -Object { - "email": "i@izs.me", -} -` - -exports[`test/index.js TAP credentials management def_userNoPass > default registry 1`] = ` -Object { - "email": "i@izs.me", -} -` - -exports[`test/index.js TAP credentials management def_userNoPass > other registry 1`] = ` -Object { - "email": "i@izs.me", -} -` - -exports[`test/index.js TAP credentials management def_userpass > default registry 1`] = ` -Object { - "auth": "aGVsbG86d29ybGQ=", - "email": "i@izs.me", - "password": "world", - "username": "hello", -} -` - -exports[`test/index.js TAP credentials management def_userpass > default registry after set 1`] = ` -Object { - "auth": "aGVsbG86d29ybGQ=", - "email": "i@izs.me", - "password": "world", - "username": "hello", -} -` - -exports[`test/index.js TAP credentials management def_userpass > other registry 1`] = ` -Object { - "email": "i@izs.me", -} -` - exports[`test/index.js TAP credentials management nerfed_auth > default registry 1`] = ` Object { "auth": "aGVsbG86d29ybGQ=", @@ -174,7 +106,6 @@ exports[`test/index.js TAP credentials management nerfed_mtlsUserPass > default Object { "auth": "aGVsbG86d29ybGQ=", "certfile": "/path/to/cert", - "email": "i@izs.me", "keyfile": "/path/to/key", "password": "world", "username": "hello", @@ -182,9 +113,7 @@ Object { ` exports[`test/index.js TAP credentials management nerfed_mtlsUserPass > other registry 1`] = ` -Object { - "email": "i@izs.me", -} +Object {} ` exports[`test/index.js TAP credentials management nerfed_userpass > default registry 1`] = ` @@ -199,31 +128,12 @@ Object { exports[`test/index.js TAP credentials management nerfed_userpass > default registry after set 1`] = ` Object { "auth": "aGVsbG86d29ybGQ=", - "email": "i@izs.me", "password": "world", "username": "hello", } ` exports[`test/index.js TAP credentials management nerfed_userpass > other registry 1`] = ` -Object { - "email": "i@izs.me", -} -` - -exports[`test/index.js TAP credentials management none_authToken > default registry 1`] = ` -Object { - "token": "0bad1de4", -} -` - -exports[`test/index.js TAP credentials management none_authToken > default registry after set 1`] = ` -Object { - "token": "0bad1de4", -} -` - -exports[`test/index.js TAP credentials management none_authToken > other registry 1`] = ` Object {} ` @@ -235,14 +145,6 @@ exports[`test/index.js TAP credentials management none_emptyConfig > other regis Object {} ` -exports[`test/index.js TAP credentials management none_lcAuthToken > default registry 1`] = ` -Object {} -` - -exports[`test/index.js TAP credentials management none_lcAuthToken > other registry 1`] = ` -Object {} -` - exports[`test/index.js TAP credentials management none_noConfig > default registry 1`] = ` Object {} ` diff --git a/workspaces/config/test/index.js b/workspaces/config/test/index.js index fea502d38f767..6ff15a0388486 100644 --- a/workspaces/config/test/index.js +++ b/workspaces/config/test/index.js @@ -45,7 +45,26 @@ const fsMocks = { 'node:fs': mockFs, } -const { definitions, shorthands, nerfDarts, flatten } = t.mock('../lib/definitions/index.js', fsMocks) +const { definitions: realDefinitions, shorthands, nerfDarts, flatten } = + t.mock('../lib/definitions/index.js', fsMocks) + +// Extend the real definitions with stub entries for fake config keys used +// in the shared fixture below. Treating them as known lets merge-order, +// env-override, and similar tests exercise Config.load() without tripping +// the npm 12 unknown-config throw. Tests that want to exercise unknown +// behavior use keys NOT in this stub list (e.g. "totally-unknown-key"). +const stubDef = (key) => ({ key, type: [String, Boolean] }) +const definitions = { + ...realDefinitions, + 'builtin-config': stubDef('builtin-config'), + 'global-config': stubDef('global-config'), + 'user-config-from-builtin': stubDef('user-config-from-builtin'), + 'default-user-config-in-home': stubDef('default-user-config-in-home'), + 'project-config': stubDef('project-config'), + 'cli-config': stubDef('cli-config'), + foo: stubDef('foo'), + bAr: stubDef('bAr'), +} const Config = t.mock('../', fsMocks) // because we used t.mock above, the require cache gets blown and we lose our direct equality @@ -138,6 +157,7 @@ prefix = ${path}/global `, '.npmrc-from-builtin': ` user-config-from-builtin = true +default-user-config-in-home = true foo = from-custom-userconfig globalconfig = ${path}/global/etc/npmrc `, @@ -186,6 +206,7 @@ loglevel = yolo cwd: join(`${path}/project`), shorthands, definitions, + nerfDarts, }) await t.rejects(() => config.load(), { message: `double-loading config "${resolve(path, 'npm/npmrc')}" as "user",` + @@ -201,6 +222,7 @@ loglevel = yolo cwd: join(`${path}/project`), shorthands, definitions, + nerfDarts, }) await config.load() @@ -217,6 +239,7 @@ loglevel = yolo cwd: join(`${path}/project`), shorthands, definitions, + nerfDarts, }) await config.load() @@ -238,6 +261,7 @@ loglevel = yolo cwd: path, shorthands, definitions, + nerfDarts, }) logs.length = 0 await config.load() @@ -288,10 +312,10 @@ loglevel = yolo t.equal(config.get('global', 'env'), undefined, 'empty env is missing') t.equal(config.get('global'), false, 'empty env is missing') - config.set('asdf', 'quux', 'global') + config.set('foo', 'quux', 'global') await config.save('global') const gres = readFileSync(`${path}/global/etc/npmrc`, 'utf8') - t.match(gres, 'asdf=quux') + t.match(gres, 'foo=quux') const cliData = config.data.get('cli') t.throws(() => cliData.loadError = true, { @@ -381,8 +405,6 @@ loglevel = yolo // warn logs are emitted as a side effect of validate config.validate() t.strictSame(logs.filter(l => l[0] === 'warn'), [ - ['warn', 'Unknown builtin config "builtin-config". This will stop working in the next major version of npm. See `npm help npmrc` for supported config options.'], - ['warn', 'Unknown builtin config "foo". This will stop working in the next major version of npm. See `npm help npmrc` for supported config options.'], ['warn', 'invalid config', 'registry="hello"', 'set in command line options'], ['warn', 'invalid config', 'Must be', 'full url with "http://"'], ['warn', 'invalid config', 'proxy="hello"', 'set in command line options'], @@ -399,13 +421,6 @@ loglevel = yolo ['warn', 'invalid config', 'prefix=true', 'set in command line options'], ['warn', 'invalid config', 'Must be', 'valid filesystem path'], ['warn', 'config', 'also', 'Please use --include=dev instead.'], - ['warn', 'Unknown env config "foo". This will stop working in the next major version of npm. See `npm help npmrc` for supported config options.'], - ['warn', 'Unknown project config "project-config". This will stop working in the next major version of npm. See `npm help npmrc` for supported config options.'], - ['warn', 'Unknown project config "foo". This will stop working in the next major version of npm. See `npm help npmrc` for supported config options.'], - ['warn', 'Unknown user config "user-config-from-builtin". This will stop working in the next major version of npm. See `npm help npmrc` for supported config options.'], - ['warn', 'Unknown user config "foo". This will stop working in the next major version of npm. See `npm help npmrc` for supported config options.'], - ['warn', 'Unknown global config "global-config". This will stop working in the next major version of npm. See `npm help npmrc` for supported config options.'], - ['warn', 'Unknown global config "foo". This will stop working in the next major version of npm. See `npm help npmrc` for supported config options.'], ['warn', 'invalid config', 'loglevel="yolo"', `set in ${resolve(path, 'project/.npmrc')}`], ['warn', 'invalid config', 'Must be one of:', ['silent', 'error', 'warn', 'notice', 'http', 'info', 'verbose', 'silly'].join(', '), @@ -458,6 +473,7 @@ loglevel = yolo shorthands, definitions, + nerfDarts, }) await config.load() @@ -491,6 +507,7 @@ loglevel = yolo shorthands, definitions, + nerfDarts, }) await config.load() @@ -600,12 +617,6 @@ loglevel = yolo ['warn', 'invalid config', 'prefix=true', 'set in command line options'], ['warn', 'invalid config', 'Must be', 'valid filesystem path'], ['warn', 'config', 'also', 'Please use --include=dev instead.'], - ['warn', 'Unknown env config "foo". This will stop working in the next major version of npm. See `npm help npmrc` for supported config options.'], - ['warn', 'Unknown user config "default-user-config-in-home". This will stop working in the next major version of npm. See `npm help npmrc` for supported config options.'], - ['warn', 'Unknown user config "foo". This will stop working in the next major version of npm. See `npm help npmrc` for supported config options.'], - ['warn', 'Unknown global config "global-config". This will stop working in the next major version of npm. See `npm help npmrc` for supported config options.'], - ['warn', 'Unknown global config "foo". This will stop working in the next major version of npm. See `npm help npmrc` for supported config options.'], - ['warn', 'Unknown global config "asdf". This will stop working in the next major version of npm. See `npm help npmrc` for supported config options.'], ]) logs.length = 0 }) @@ -627,6 +638,7 @@ t.test('cafile loads as ca (and some saving tests)', async t => { const config = new Config({ shorthands, definitions, + nerfDarts, npmPath: __dirname, env: { HOME: dir, PREFIX: dir }, flatten, @@ -665,6 +677,7 @@ fakey mc fakerson const config = new Config({ shorthands, definitions, + nerfDarts, npmPath: __dirname, env: { HOME: dir, @@ -686,6 +699,7 @@ t.test('ignore cafile if it does not load', async t => { const config = new Config({ shorthands, definitions, + nerfDarts, npmPath: __dirname, env: { HOME: dir }, }) @@ -704,6 +718,7 @@ t.test('raise error if reading ca file error other than ENOENT', async t => { const config = new Config({ shorthands, definitions, + nerfDarts, npmPath: __dirname, env: { HOME: dir }, flatten, @@ -718,8 +733,7 @@ t.test('credentials management', async t => { nerfed_userpass: { '.npmrc': `//registry.example/:username = hello //registry.example/:_password = ${Buffer.from('world').toString('base64')} -//registry.example/:email = i@izs.me -//registry.example/:always-auth = "false"`, +//registry.example/:email = i@izs.me`, }, nerfed_auth: { // note: does not load, because we don't do _auth per reg '.npmrc': `//registry.example/:_auth = ${Buffer.from('hello:world').toString('base64')}`, @@ -734,7 +748,6 @@ t.test('credentials management', async t => { nerfed_mtlsUserPass: { '.npmrc': `//registry.example/:username = hello //registry.example/:_password = ${Buffer.from('world').toString('base64')} //registry.example/:email = i@izs.me -//registry.example/:always-auth = "false" //registry.example/:certfile = /path/to/cert //registry.example/:keyfile = /path/to/key`, }, @@ -773,17 +786,40 @@ always-auth = true`, const defReg = 'https://registry.example/' const otherReg = 'https://other.registry/' + // Cases whose fixtures include top-level legacy auth keys that are no + // longer tolerated in npm 12 — Config collects them as unknowns; the + // error is raised by base-cmd.validateCli when the command runs. + const mustCollect = new Set([ + 'def_userpass', + 'def_userNoPass', + 'def_passNoUser', + 'def_auth', + 'none_authToken', + 'none_lcAuthToken', + ]) for (const testCase of Object.keys(fixtures)) { t.test(testCase, async t => { const c = new Config({ npmPath: path, shorthands, definitions, + nerfDarts, env: { HOME: resolve(path, testCase) }, argv: ['node', 'file', '--registry', defReg], }) + await c.load() + if (mustCollect.has(testCase)) { + const unknowns = [ + ...c.getUnknownConfigs('user'), + ...c.getUnknownConfigs('project'), + ...c.getUnknownConfigs('global'), + ] + t.ok(unknowns.length > 0, 'unknown top-level legacy auth keys collected') + return + } + // only have to do this the first time, it's redundant otherwise if (testCase === 'none_noConfig') { t.throws(() => c.setCredentialsByURI('http://x.com', { @@ -801,25 +837,20 @@ always-auth = true`, }) } - // the def_ and none_ prefixed cases have unscoped auth values and should throw - if (testCase.startsWith('def_') || - testCase === 'none_authToken' || - testCase === 'none_lcAuthToken') { + // def_authEnv still needs validate+repair (uses defined `_auth` at top + // level, which loads fine but validate flags as incomplete auth). + if (testCase === 'def_authEnv') { try { c.validate() - // validate should throw, fail the test here if it doesn't t.fail('validate should have thrown') } catch (err) { if (err.code !== 'ERR_INVALID_AUTH') { throw err } - - // we got our expected invalid auth error, so now repair it c.repair(err.problems) t.ok(c.valid, 'config is valid') } } else { - // validate won't throw for these ones, so let's prove it and repair are no-ops c.validate() c.repair() } @@ -878,6 +909,7 @@ t.test('finding the global prefix', t => { }, shorthands, definitions, + nerfDarts, npmPath, }) c.loadGlobalPrefix() @@ -893,6 +925,7 @@ t.test('finding the global prefix', t => { execPath: '/path/to/nodejs/node.exe', shorthands, definitions, + nerfDarts, npmPath, }) c.loadGlobalPrefix() @@ -905,6 +938,7 @@ t.test('finding the global prefix', t => { execPath: '/path/to/nodejs/bin/node', shorthands, definitions, + nerfDarts, npmPath, }) c.loadGlobalPrefix() @@ -918,6 +952,7 @@ t.test('finding the global prefix', t => { env: { DESTDIR: '/some/dest/dir' }, shorthands, definitions, + nerfDarts, npmPath, }) c.loadGlobalPrefix() @@ -943,6 +978,7 @@ t.test('finding the local prefix', t => { argv: [process.execPath, __filename, '-C', path], shorthands, definitions, + nerfDarts, npmPath: path, }) await c.load() @@ -953,6 +989,7 @@ t.test('finding the local prefix', t => { cwd: join(`${path}/hasNM/x/y/z`), shorthands, definitions, + nerfDarts, npmPath: path, }) await c.load() @@ -963,6 +1000,7 @@ t.test('finding the local prefix', t => { cwd: join(`${path}/hasPJ/x/y/z`), shorthands, definitions, + nerfDarts, npmPath: path, }) await c.load() @@ -973,6 +1011,7 @@ t.test('finding the local prefix', t => { cwd: join('/this/path/does/not/exist/x/y/z'), shorthands, definitions, + nerfDarts, npmPath: path, }) await c.load() @@ -992,26 +1031,29 @@ t.test('setting basic auth creds and email', async t => { definitions: { registry: { default: registry }, }, - npmPath: process.cwd(), + nerfDarts, + cwd: path, + excludeNpmCwd: true, + npmPath: path, } const c = new Config(opts) await c.load() - c.set('email', 'name@example.com', 'user') - t.equal(c.get('email', 'user'), 'name@example.com', 'email was set') + c.setCredentialsByURI(registry, { + username: 'admin', + password: 'admin', + }) + c.set('//registry.npmjs.org/:email', 'name@example.com', 'user') await c.save('user') - t.equal(c.get('email', 'user'), 'name@example.com', 'email still top level') - t.strictSame(c.getCredentialsByURI(registry), { email: 'name@example.com' }) + t.strictSame(c.getCredentialsByURI(registry), { + email: 'name@example.com', + username: 'admin', + password: 'admin', + auth: _auth, + }) const d = new Config(opts) await d.load() - t.strictSame(d.getCredentialsByURI(registry), { email: 'name@example.com' }) - d.set('_auth', _auth, 'user') - t.equal(d.get('_auth', 'user'), _auth, '_auth was set') - d.repair() - await d.save('user') - const e = new Config(opts) - await e.load() - t.equal(e.get('_auth', 'user'), undefined, 'un-nerfed _auth deleted') - t.strictSame(e.getCredentialsByURI(registry), { + t.equal(d.get('_auth', 'user'), undefined, 'un-nerfed _auth not present') + t.strictSame(d.getCredentialsByURI(registry), { email: 'name@example.com', username: 'admin', password: 'admin', @@ -1029,26 +1071,25 @@ t.test('setting username/password/email individually', async t => { definitions: { registry: { default: registry }, }, - npmPath: process.cwd(), + nerfDarts, + cwd: path, + excludeNpmCwd: true, + npmPath: path, } const c = new Config(opts) await c.load() - c.set('email', 'name@example.com', 'user') - t.equal(c.get('email'), 'name@example.com') - c.set('username', 'admin', 'user') - t.equal(c.get('username'), 'admin') - c.set('_password', Buffer.from('admin').toString('base64'), 'user') - t.equal(c.get('_password'), Buffer.from('admin').toString('base64')) + c.setCredentialsByURI(registry, { + username: 'admin', + password: 'admin', + }) + c.set('//registry.npmjs.org/:email', 'name@example.com', 'user') + t.equal(c.get('//registry.npmjs.org/:email'), 'name@example.com') + t.equal(c.get('//registry.npmjs.org/:username'), 'admin') t.equal(c.get('_auth'), undefined) - c.repair() await c.save('user') const d = new Config(opts) await d.load() - t.equal(d.get('email'), 'name@example.com') - t.equal(d.get('username'), undefined) - t.equal(d.get('_password'), undefined) - t.equal(d.get('_auth'), undefined) t.strictSame(d.getCredentialsByURI(registry), { email: 'name@example.com', username: 'admin', @@ -1057,7 +1098,7 @@ t.test('setting username/password/email individually', async t => { }) }) -t.test('nerfdart auths set at the top level into the registry', async t => { +t.test('nerfdart auths set at the top level now throw in npm 12', async t => { const registry = 'https://registry.npmjs.org/' const _auth = Buffer.from('admin:admin').toString('base64') const username = 'admin' @@ -1065,90 +1106,180 @@ t.test('nerfdart auths set at the top level into the registry', async t => { const email = 'i@izs.me' const _authToken = 'deadbeefblahblah' - // name: [ini, expect, wontThrow] - const cases = { - '_auth only, no email': [`_auth=${_auth}`, { - '//registry.npmjs.org/:_auth': _auth, - }], - '_auth with email': [`_auth=${_auth}\nemail=${email}`, { - '//registry.npmjs.org/:_auth': _auth, - email, - }], - '_authToken alone': [`_authToken=${_authToken}`, { - '//registry.npmjs.org/:_authToken': _authToken, - }], - '_authToken and email': [`_authToken=${_authToken}\nemail=${email}`, { - '//registry.npmjs.org/:_authToken': _authToken, - email, - }], - 'username and _password': [`username=${username}\n_password=${_password}`, { - '//registry.npmjs.org/:username': username, - '//registry.npmjs.org/:_password': _password, - }], - 'username, password, email': [`username=${username}\n_password=${_password}\nemail=${email}`, { - '//registry.npmjs.org/:username': username, - '//registry.npmjs.org/:_password': _password, - email, - }], - // handled invalid/legacy cases - 'username, no _password': [`username=${username}`, {}], - '_password, no username': [`_password=${_password}`, {}], - '_authtoken instead of _authToken': [`_authtoken=${_authToken}`, {}], - '-authtoken instead of _authToken': [`-authtoken=${_authToken}`, {}], - // de-nerfdart the email, if present in that way - 'nerf-darted email': [`//registry.npmjs.org/:email=${email}`, { - email, - }, true], + // All these legacy fixtures place auth-like keys at the top level of an + // .npmrc. In npm 11 these produced warnings and `repair()` migrated them + // into a nerfdart section. In npm 12 they are collected as unknown + // configs; base-cmd.validateCli throws on them when the command runs. + const throwingCases = { + '_auth only, no email': `_auth=${_auth}`, + '_auth with email': `_auth=${_auth}\nemail=${email}`, + '_authToken alone': `_authToken=${_authToken}`, + '_authToken and email': `_authToken=${_authToken}\nemail=${email}`, + 'username and _password': `username=${username}\n_password=${_password}`, + 'username, password, email': + `username=${username}\n_password=${_password}\nemail=${email}`, + 'username, no _password': `username=${username}`, + '_password, no username': `_password=${_password}`, + '_authtoken instead of _authToken': `_authtoken=${_authToken}`, + '-authtoken instead of _authToken': `-authtoken=${_authToken}`, } - const logs = [] - const logHandler = (...args) => logs.push(args) - process.on('log', logHandler) - t.teardown(() => { - process.removeListener('log', logHandler) - }) - const cwd = process.cwd() - for (const [name, [ini, expect, wontThrow]] of Object.entries(cases)) { + for (const [name, ini] of Object.entries(throwingCases)) { t.test(name, async t => { - t.teardown(() => { - process.chdir(cwd) - logs.length = 0 - }) const path = t.testdir({ '.npmrc': ini, 'package.json': JSON.stringify({}), }) - process.chdir(path) - const argv = [ - 'node', - __filename, - `--prefix=${path}`, - `--userconfig=${path}/.npmrc`, - `--globalconfig=${path}/etc/npmrc`, - ] const opts = { shorthands: {}, - argv, + argv: [ + 'node', + __filename, + `--prefix=${path}`, + `--userconfig=${path}/.npmrc`, + `--globalconfig=${path}/etc/npmrc`, + ], env: {}, definitions: { registry: { default: registry }, }, - npmPath: process.cwd(), + cwd: path, + excludeNpmCwd: true, + npmPath: path, } - const c = new Config(opts) await c.load() + const unknowns = [ + ...c.getUnknownConfigs('user'), + ...c.getUnknownConfigs('project'), + ...c.getUnknownConfigs('global'), + ...c.getUnknownConfigs('builtin'), + ] + t.ok(unknowns.length > 0, 'legacy top-level auth keys collected as unknown') + t.throws( + () => c.validate(), + { code: 'ERR_INVALID_AUTH' }, + 'validate() throws ErrInvalidAuth with a message describing each problem' + ) + }) + } - if (!wontThrow) { - t.throws(() => c.validate(), { code: 'ERR_INVALID_AUTH' }) - } + t.test('nerf-darted email still loads', async t => { + const path = t.testdir({ + '.npmrc': `//registry.npmjs.org/:email=${email}`, + 'package.json': JSON.stringify({}), + }) + const opts = { + shorthands: {}, + argv: [ + 'node', + __filename, + `--prefix=${path}`, + `--userconfig=${path}/.npmrc`, + `--globalconfig=${path}/etc/npmrc`, + ], + env: {}, + definitions: { + registry: { default: registry }, + }, + nerfDarts, + cwd: path, + excludeNpmCwd: true, + npmPath: path, + } + const c = new Config(opts) + await c.load() + c.repair() + await c.save('user') + t.same(c.data.get('user').data, { '//registry.npmjs.org/:email': email }) + }) +}) + +t.test('checkUnknown and repair carve-outs', async t => { + const registry = 'https://registry.npmjs.org/' + const _authToken = 'deadbeef' - // now we go ahead and do the repair, and save - c.repair() - await c.save('user') - t.same(c.data.get('user').data, expect) + t.test('repair() with no args validates and fixes auth problems', async t => { + const path = t.testdir({ + '.npmrc': `_authtoken=${_authToken}`, + 'package.json': JSON.stringify({}), }) - } + const c = new Config({ + shorthands: {}, + argv: [ + 'node', + __filename, + `--prefix=${path}`, + `--userconfig=${path}/.npmrc`, + `--globalconfig=${path}/etc/npmrc`, + ], + env: {}, + definitions: { registry: { default: registry } }, + cwd: path, + excludeNpmCwd: true, + npmPath: path, + }) + await c.load() + t.throws(() => c.validate(), { code: 'ERR_INVALID_AUTH' }, 'pre-repair validate throws') + c.repair() + t.equal(c.get('_authtoken', 'user'), undefined, 'deleted from user config') + t.doesNotThrow(() => c.validate(), 'post-repair validate passes') + }) + + t.test('publishConfig unknown warns but does not error', async t => { + const path = t.testdir({ 'package.json': JSON.stringify({}) }) + const c = new Config({ + shorthands: {}, + argv: ['node', __filename, `--prefix=${path}`], + env: {}, + definitions: { registry: { default: registry } }, + cwd: path, + excludeNpmCwd: true, + npmPath: path, + warn: false, + }) + await c.load() + c.checkUnknown('publishConfig', 'bogus-pub-key') + c.checkUnknown('publishConfig', '@scope:bogus-scoped-pub') + const pubUnknowns = c.getUnknownConfigs('publishConfig') + t.equal(pubUnknowns.length, 2, 'publishConfig unknowns collected') + t.notOk( + c.getUnknownConfigs().some(u => u.where === 'publishConfig'), + 'publishConfig unknowns excluded from default getUnknownConfigs()' + ) + }) + + t.test('getUnknownConfigs() returns all file + cli entries', async t => { + const path = t.testdir({ + '.npmrc': 'bogus-user-key=yes', + 'package.json': JSON.stringify({}), + }) + const c = new Config({ + shorthands: {}, + argv: [ + 'node', + __filename, + `--prefix=${path}`, + `--userconfig=${path}/.npmrc`, + `--globalconfig=${path}/etc/npmrc`, + ], + env: { + npm_config_bogus_env_key: 'x', + 'npm_config_@scope:bogus-scoped-env': 'y', + }, + definitions: { registry: { default: registry } }, + cwd: path, + excludeNpmCwd: true, + npmPath: path, + }) + await c.load() + const all = c.getUnknownConfigs() + t.ok(all.some(u => u.key === 'bogus-user-key' && u.where === 'user'), 'includes user file key') + t.notOk(all.some(u => u.where === 'env'), 'excludes env entries') + const envUnknowns = c.getUnknownConfigs('env') + t.ok(envUnknowns.some(u => !u.baseKey), 'plain env unknown collected') + t.ok(envUnknowns.some(u => u.baseKey), 'scoped env unknown collected') + }) }) t.test('workspaces', async (t) => { @@ -1199,6 +1330,7 @@ t.test('workspaces', async (t) => { cwd: join(`${path}/workspaces/one`), shorthands, definitions, + nerfDarts, }) await config.load() @@ -1222,6 +1354,7 @@ t.test('workspaces', async (t) => { cwd: join(`${path}/workspaces/one`), shorthands, definitions, + nerfDarts, }) await config.load() @@ -1478,6 +1611,7 @@ t.test('exclusive env option is skipped when sibling is set via CLI', async t => const path = t.testdir() const config = new Config({ env: { + HOME: path, npm_config_truth: 'true', }, npmPath: __dirname, @@ -1487,6 +1621,7 @@ t.test('exclusive env option is skipped when sibling is set via CLI', async t => '--lie=true', ], cwd: join(`${path}/project`), + excludeNpmCwd: true, shorthands, definitions: { ...definitions, @@ -1503,6 +1638,7 @@ t.test('exclusive env option is skipped when sibling is set via CLI', async t => exclusive: ['truth'], }), }, + nerfDarts, flatten, }) // should not throw — env `truth` is skipped because `lie` was set via CLI @@ -1519,8 +1655,12 @@ t.test('env-replaced config from files is not clobbered when saving', async (t) env: { TEST: 'test value' }, definitions: { registry: { default: 'https://registry.npmjs.org/' }, + test: { default: '' }, + other: { default: '' }, }, - npmPath: process.cwd(), + cwd: path, + excludeNpmCwd: true, + npmPath: path, } const c = new Config(opts) await c.load() @@ -1548,6 +1688,7 @@ t.test('umask', async t => { cwd: join(`${path}/project`), shorthands, definitions, + nerfDarts, flatten, }) await config.load() @@ -1582,6 +1723,7 @@ t.test('catch project config prefix error', async t => { cwd: join(`${path}/project`), shorthands, definitions, + nerfDarts, }) const logs = [] const logHandler = (...args) => logs.push(args) @@ -1596,12 +1738,8 @@ t.test('catch project config prefix error', async t => { ]], 'Expected error logged') }) -t.test('invalid single hyphen warnings', async t => { +t.test('invalid single hyphen errors', async t => { const path = t.testdir() - const logs = [] - const logHandler = (...args) => logs.push(args) - process.on('log', logHandler) - t.teardown(() => process.off('log', logHandler)) const config = new Config({ npmPath: `${path}/npm`, env: {}, @@ -1611,15 +1749,13 @@ t.test('invalid single hyphen warnings', async t => { definitions, nerfDarts, }) - await config.load() - const filtered = logs.filter(l => l[0] === 'warn') - t.match(filtered, [ - ['warn', '-iwr is not a valid single-hyphen cli flag and will be removed in the future'], - ['warn', '-ws is not a valid single-hyphen cli flag and will be removed in the future'], - ], 'Warns about single hyphen configs') + await t.rejects(config.load(), { + code: 'EUNKNOWNCONFIG', + message: /single-hyphen/, + }, 'Throws on invalid single-hyphen flag') }) -t.test('positional arg warnings', async t => { +t.test('positional arg is collected as unknown cli config', async t => { const path = t.testdir() const logs = [] const logHandler = (...args) => logs.push(args) @@ -1634,20 +1770,20 @@ t.test('positional arg warnings', async t => { definitions, nerfDarts, }) + // Unknown CLI flags are collected silently during load(); base-cmd throws + // later once command-scoped definitions are known. await config.load() const filtered = logs.filter(l => l[0] === 'warn') t.match(filtered, [ ['warn', '"extra" is being parsed as a normal command line argument.'], - ['warn', 'Unknown cli config "--something". This will stop working in the next major version of npm.'], - ], 'Warns about positional cli arg') + ], 'Still warns about positional cli arg being parsed as positional') + const unknowns = config.getUnknownConfigs('cli') + t.ok(unknowns.some(u => u.key === 'something'), + 'unknown cli flag collected for later validation') }) -t.test('abbreviation expansion warnings', async t => { +t.test('abbreviation expansion errors', async t => { const path = t.testdir() - const logs = [] - const logHandler = (...args) => logs.push(args) - process.on('log', logHandler) - t.teardown(() => process.off('log', logHandler)) const config = new Config({ npmPath: `${path}/npm`, env: {}, @@ -1657,11 +1793,10 @@ t.test('abbreviation expansion warnings', async t => { definitions, nerfDarts, }) - await config.load() - const filtered = logs.filter(l => l[0] === 'warn') - t.match(filtered, [ - ['warn', 'Expanding --bef to --before. This will stop working in the next major version of npm'], - ], 'Warns about expanded abbreviations') + await t.rejects(config.load(), { + code: 'EUNKNOWNCONFIG', + message: /--bef.*--before/, + }, 'Throws on abbreviation expansion') }) t.test('warning suppression and logging', async t => { @@ -1706,7 +1841,7 @@ t.test('warning suppression and logging', async t => { t.equal(finalWarnings.length, warningCount, 'no duplicate warnings after second logWarnings()') }) -t.test('warn false with invalid flag and warning removal', async t => { +t.test('warn false with unknown env flag and warning removal', async t => { const path = t.testdir() const logs = [] const logHandler = (...args) => logs.push(args) @@ -1715,8 +1850,8 @@ t.test('warn false with invalid flag and warning removal', async t => { const config = new Config({ npmPath: `${path}/npm`, - env: {}, - argv: [process.execPath, __filename, '--invalid-flag', 'value'], + env: { npm_config_invalid_flag: 'value' }, + argv: [process.execPath, __filename], cwd: path, shorthands, definitions, @@ -1726,18 +1861,17 @@ t.test('warn false with invalid flag and warning removal', async t => { config.warn = false await config.load() - // First logWarnings call - should log the queued warning + // First logWarnings call - should log the queued env-unknown warning const logsBeforeFirst = logs.filter(l => l[0] === 'warn').length config.logWarnings() const logsAfterFirst = logs.filter(l => l[0] === 'warn') - // Check we have warnings and the invalid-flag warning is there t.ok(logsAfterFirst.length > logsBeforeFirst, 'warnings were logged') const invalidFlagWarnings = logsAfterFirst.filter(w => w[1] && w[1].includes('invalid-flag')) t.ok(invalidFlagWarnings.length > 0, 'invalid-flag warning present') // Trigger the same warning again - config.checkUnknown('cli', 'invalid-flag') + config.checkUnknown('env', 'invalid-flag') // Remove the warning config.removeWarning('invalid-flag') @@ -1861,6 +1995,7 @@ t.test('before and min-release-age', async t => { argv: [process.execPath, __filename, '--min-release-age', '30'], cwd: path, definitions, + nerfDarts, shorthands, flatten, })