Skip to content

Commit 954d9b9

Browse files
committed
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.
1 parent e20424b commit 954d9b9

17 files changed

Lines changed: 497 additions & 418 deletions

File tree

lib/base-cmd.js

Lines changed: 53 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
const { log } = require('proc-log')
2-
const { definitions, shorthands } = require('@npmcli/config/lib/definitions')
2+
const { definitions } = require('@npmcli/config/lib/definitions')
33
const nopt = require('nopt')
44

55
class BaseCommand {
@@ -323,10 +323,9 @@ class BaseCommand {
323323
delete parsed.argv
324324
}
325325

326-
// Validate flags - only if command has definitions (new system)
327-
if (this.constructor.definitions && this.constructor.definitions.length > 0) {
328-
this.#validateFlags(parsed, commandDefinitions, remains)
329-
}
326+
// Validate unknown CLI flags/configs and unexpected positionals.
327+
// Runs for every command; command-specific flags are allow-listed here so they don't trip the global unknown-config collection from Config.loadCLI().
328+
this.validateCli(commandDefinitions, remains)
330329

331330
// Check for conflicts between main flags and their aliases
332331
// Also map aliases back to their main keys
@@ -363,64 +362,67 @@ class BaseCommand {
363362
return [{ ...defaults, ...filtered }, remains]
364363
}
365364

366-
// Validate flags and throw errors for unknown flags or unexpected positionals
367-
#validateFlags (parsed, commandDefinitions, remains) {
368-
// Build a set of all valid flag names (global + command-specific + shorthands)
369-
const validFlags = new Set([
370-
...Object.keys(definitions),
365+
// Unified CLI validation — runs for every command (definitions-based and legacy).
366+
// 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.
367+
// Also enforces extra-positional errors for commands that set a finite `static positionals`.
368+
// Shellout commands (run/exec/lifecycle) leave `static positionals = null` and are unaffected.
369+
// Commands that set `static skipConfigValidation = true` (config, help, doctor, completion, version) bypass both unknown-config checks so they can operate against a broken .npmrc.
370+
validateCli (commandDefinitions = this.constructor.definitions || [], remains = null) {
371+
const allowlist = new Set([
371372
...commandDefinitions.map(d => d.key),
372-
...Object.keys(shorthands), // Add global shorthands like 'verbose', 'dd', etc.
373+
...commandDefinitions.flatMap(d => Array.isArray(d.alias) ? d.alias : []),
373374
])
374375

375-
// Add aliases to valid flags
376-
for (const def of commandDefinitions) {
377-
if (def.alias && Array.isArray(def.alias)) {
378-
for (const alias of def.alias) {
379-
validFlags.add(alias)
380-
}
376+
if (!this.constructor.skipConfigValidation) {
377+
const cliUnknowns = this.npm.config.getUnknownConfigs('cli')
378+
.filter(u => !allowlist.has(u.key) && !allowlist.has(u.baseKey))
379+
if (cliUnknowns.length > 0) {
380+
const flagList = cliUnknowns
381+
.map(u => (u.baseKey ? `--${u.baseKey} (${u.key})` : `--${u.key}`))
382+
.join(', ')
383+
throw this.usageError(
384+
`Unknown cli config${cliUnknowns.length > 1 ? 's' : ''}: ${flagList}. ` +
385+
`Run \`npm help config\` for supported options.`
386+
)
381387
}
382-
}
383388

384-
// Check parsed flags against valid flags
385-
const unknownFlags = []
386-
for (const key of Object.keys(parsed)) {
387-
if (!validFlags.has(key)) {
388-
unknownFlags.push(key)
389+
const fileUnknowns = []
390+
for (const where of ['builtin', 'project', 'user', 'global']) {
391+
fileUnknowns.push(...this.npm.config.getUnknownConfigs(where))
389392
}
390-
}
391-
392-
// Throw error if unknown flags were found
393-
if (unknownFlags.length > 0) {
394-
const flagList = unknownFlags.map(f => `--${f}`).join(', ')
395-
throw this.usageError(`Unknown flag${unknownFlags.length > 1 ? 's' : ''}: ${flagList}`)
396-
}
397-
398-
// Remove warnings for command-specific definitions that npm's global config doesn't know about (these were queued as "unknown" during config.load())
399-
for (const def of commandDefinitions) {
400-
this.npm.config.removeWarning(def.key)
401-
if (def.alias && Array.isArray(def.alias)) {
402-
for (const alias of def.alias) {
403-
this.npm.config.removeWarning(alias)
404-
}
393+
if (fileUnknowns.length > 0) {
394+
const lines = fileUnknowns.map(u => {
395+
const display = u.baseKey ? `"${u.baseKey}" (${u.key})` : `"${u.key}"`
396+
return ` - ${u.where} config ${display} from ${u.source}`
397+
})
398+
const msg = [
399+
`Unknown npm configuration key${fileUnknowns.length > 1 ? 's' : ''}:`,
400+
...lines,
401+
'See `npm help npmrc` for supported config options.',
402+
].join('\n')
403+
throw Object.assign(new Error(msg), {
404+
code: 'EUNKNOWNCONFIG',
405+
unknownConfigs: fileUnknowns,
406+
})
405407
}
406408
}
407409

408-
// Remove warnings for unknown positionals that were actually consumed as flag values by command-specific definitions (e.g., --id <value> where --id is command-specific)
409-
const remainsSet = new Set(remains)
410-
for (const unknownPos of this.npm.config.getUnknownPositionals()) {
411-
if (!remainsSet.has(unknownPos)) {
412-
// This value was consumed as a flag value, not truly a positional
413-
this.npm.config.removeUnknownPositional(unknownPos)
410+
// 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.
411+
if (Array.isArray(remains)) {
412+
const remainsSet = new Set(remains)
413+
for (const unknownPos of this.npm.config.getUnknownPositionals()) {
414+
if (!remainsSet.has(unknownPos)) {
415+
this.npm.config.removeUnknownPositional(unknownPos)
416+
}
414417
}
415418
}
416419

417-
// Warn about extra positional arguments beyond what the command expects
418-
const expectedPositionals = this.constructor.positionals
419-
if (expectedPositionals !== null && remains.length > expectedPositionals) {
420-
const extraPositionals = remains.slice(expectedPositionals)
421-
for (const extra of extraPositionals) {
422-
throw new Error(`Unknown positional argument: ${extra}`)
423-
}
420+
const expected = this.constructor.positionals
421+
if (expected !== null && remains !== null && remains.length > expected) {
422+
const extra = remains.slice(expected)
423+
throw this.usageError(
424+
`Unknown positional argument${extra.length > 1 ? 's' : ''}: ${extra.join(', ')}`
425+
)
424426
}
425427

426428
this.npm.config.logWarnings()

lib/commands/completion.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ class Completion extends BaseCommand {
3737
static name = 'completion'
3838
// 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
3939
static definitions = []
40+
static skipConfigValidation = true
4041

4142
// completion for the completion command
4243
static async completion (opts) {

lib/commands/doctor.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ class Doctor extends BaseCommand {
9999
static name = 'doctor'
100100
static params = ['registry']
101101
static ignoreImplicitWorkspace = false
102+
static skipConfigValidation = true
102103
static usage = [`[${checks.flatMap(s => s.groups)
103104
.filter((value, index, self) => self.indexOf(value) === index && value !== 'ping')
104105
.join('] [')}]`]

lib/commands/help.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ class Help extends BaseCommand {
2525
static name = 'help'
2626
static usage = ['<term> [<terms..>]']
2727
static params = ['viewer']
28+
static skipConfigValidation = true
2829

2930
static async completion (opts, npm) {
3031
if (opts.conf.argv.remain.length > 2) {

lib/commands/version.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ class Version extends BaseCommand {
2323

2424
static workspaces = true
2525
static ignoreImplicitWorkspace = false
26+
static skipConfigValidation = true
2627

2728
static usage = ['[<newversion> | major | minor | patch | premajor | preminor | prepatch | prerelease | from-git]']
2829

lib/npm.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -291,8 +291,10 @@ class Npm {
291291
? commandInstance.execWorkspaces(positionalArgs, flags)
292292
: commandInstance.exec(positionalArgs, flags))
293293
} else {
294-
// Legacy commands without definitions
295-
this.config.logWarnings()
294+
// Legacy commands without definitions: still validate unknown CLI configs/flags and (when finite) extra positionals.
295+
if (typeof commandInstance.validateCli === 'function') {
296+
commandInstance.validateCli([], args)
297+
}
296298
return time.start(`command:${commandName}`, () =>
297299
execWorkspaces ? commandInstance.execWorkspaces(args) : commandInstance.exec(args))
298300
}

tap-snapshots/test/lib/commands/config.js.test.cjs

Lines changed: 12 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna
1010
"cache": "{CACHE}",
1111
"color": {COLOR},
1212
"json": true,
13-
"projectloaded": "yes",
14-
"userloaded": "yes",
15-
"globalloaded": "yes",
13+
"tag": "from-project",
14+
"init-author-name": "from-user",
15+
"init-license": "from-global",
1616
"access": null,
1717
"all": false,
1818
"allow-same-version": false,
@@ -74,9 +74,7 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna
7474
"include-workspace-root": false,
7575
"include-attestations": false,
7676
"init-author-email": "",
77-
"init-author-name": "",
7877
"init-author-url": "",
79-
"init-license": "ISC",
8078
"init-module": "{CWD}/home/.npm-init.js",
8179
"init-type": "commonjs",
8280
"init-version": "1.0.0",
@@ -164,7 +162,6 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna
164162
"sign-git-tag": false,
165163
"strict-peer-deps": false,
166164
"strict-ssl": true,
167-
"tag": "latest",
168165
"tag-version-prefix": "v",
169166
"timing": false,
170167
"umask": 0,
@@ -252,9 +249,9 @@ include-attestations = false
252249
include-staged = false
253250
include-workspace-root = false
254251
init-author-email = ""
255-
init-author-name = ""
252+
; init-author-name = "" ; overridden by user
256253
init-author-url = ""
257-
init-license = "ISC"
254+
; init-license = "ISC" ; overridden by global
258255
init-module = "{CWD}/home/.npm-init.js"
259256
init-private = false
260257
init-type = "commonjs"
@@ -342,7 +339,7 @@ sign-git-commit = false
342339
sign-git-tag = false
343340
strict-peer-deps = false
344341
strict-ssl = true
345-
tag = "latest"
342+
; tag = "latest" ; overridden by project
346343
tag-version-prefix = "v"
347344
timing = false
348345
token-description = null
@@ -363,15 +360,15 @@ yes = null
363360
364361
; "global" config from {CWD}/global/etc/npmrc
365362
366-
globalloaded = "yes"
363+
init-license = "from-global"
367364
368365
; "user" config from {CWD}/home/.npmrc
369366
370-
userloaded = "yes"
367+
init-author-name = "from-user"
371368
372369
; "project" config from {CWD}/prefix/.npmrc
373370
374-
projectloaded = "yes"
371+
tag = "from-project"
375372
376373
; "cli" config from command line options
377374
@@ -383,19 +380,17 @@ long = true
383380
exports[`test/lib/commands/config.js TAP config list > output matches snapshot 1`] = `
384381
; "global" config from {CWD}/global/etc/npmrc
385382
386-
globalloaded = "yes"
383+
init-license = "from-global"
387384
388385
; "user" config from {CWD}/home/.npmrc
389386
390387
_auth = (protected)
391388
//nerfdart:_auth = (protected)
392-
//nerfdart:auth = (protected)
393-
auth = (protected)
394-
userloaded = "yes"
389+
init-author-name = "from-user"
395390
396391
; "project" config from {CWD}/prefix/.npmrc
397392
398-
projectloaded = "yes"
393+
tag = "from-project"
399394
400395
; "cli" config from command line options
401396

tap-snapshots/test/lib/commands/diff.js.test.cjs

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -62,11 +62,13 @@ package.json
6262
`
6363

6464
exports[`test/lib/commands/diff.js TAP various options using diff option > must match snapshot 1`] = `
65-
diff --git a/index.js b/index.js
65+
diff --git bar/index.js foo/index.js
6666
index v2.0.0..v3.0.0 100644
67-
--- a/index.js
68-
+++ b/index.js
69-
@@ -18,7 +18,7 @@
67+
--- bar/index.js
68+
+++ foo/index.js
69+
@@ -16,11 +16,11 @@
70+
15
71+
16
7072
17
7173
18
7274
19
@@ -75,10 +77,12 @@ index v2.0.0..v3.0.0 100644
7577
21
7678
22
7779
23
78-
diff --git a/package.json b/package.json
80+
24
81+
25
82+
diff --git bar/package.json foo/package.json
7983
index v2.0.0..v3.0.0 100644
80-
--- a/package.json
81-
+++ b/package.json
84+
--- bar/package.json
85+
+++ foo/package.json
8286
@@ -1,4 +1,4 @@
8387
{
8488
"name": "bar",

test/lib/base-cmd.js

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,9 @@ t.test('flags() method with no definitions', async t => {
118118
})
119119

120120
t.test('flags() throws error for unknown flags', async t => {
121-
const { npm } = await loadMockNpm(t)
121+
const { npm } = await loadMockNpm(t, {
122+
npm: { argv: ['test-command', '--unknown-flag'] },
123+
})
122124

123125
class TestCommand extends BaseCommand {
124126
static name = 'test-command'
@@ -139,13 +141,10 @@ t.test('flags() throws error for unknown flags', async t => {
139141
}
140142
}
141143

142-
// Manually set config.argv to simulate command-line with unknown flag
143-
npm.config.argv = ['node', 'npm', 'test-command', '--unknown-flag']
144-
145144
const command = new TestCommand(npm)
146145
await t.rejects(
147146
command.exec(),
148-
{ message: /Unknown flag.*--unknown-flag/ },
147+
{ message: /Unknown cli config.*--unknown-flag/ },
149148
'throws error for unknown flag'
150149
)
151150
})
@@ -416,7 +415,9 @@ t.test('flags() returns defaults when argv is empty', async t => {
416415
})
417416

418417
t.test('flags() throws error for multiple unknown flags with pluralization', async t => {
419-
const { npm } = await loadMockNpm(t)
418+
const { npm } = await loadMockNpm(t, {
419+
npm: { argv: ['test-command', '--unknown-one', '--unknown-two'] },
420+
})
420421

421422
class TestCommand extends BaseCommand {
422423
static name = 'test-command'
@@ -442,7 +443,7 @@ t.test('flags() throws error for multiple unknown flags with pluralization', asy
442443
const command = new TestCommand(npm)
443444
await t.rejects(
444445
command.exec(),
445-
{ message: /Unknown flags:.*--unknown-one.*--unknown-two/ },
446+
{ message: /Unknown cli configs:.*--unknown-one.*--unknown-two/ },
446447
'throws error with pluralized "flags" for multiple unknown flags'
447448
)
448449
})
@@ -636,8 +637,8 @@ t.test('flags() throws error for extra positional arguments beyond expected coun
636637
// Should throw error for extra positional
637638
await t.rejects(
638639
command.exec(),
639-
{ message: 'Unknown positional argument: extra1' },
640-
'throws error for first extra positional'
640+
{ message: 'Unknown positional arguments: extra1, extra2' },
641+
'throws error for extra positionals'
641642
)
642643
})
643644

0 commit comments

Comments
 (0)