Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 53 additions & 51 deletions lib/base-cmd.js
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <value> 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()
Expand Down
1 change: 1 addition & 0 deletions lib/commands/completion.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions lib/commands/doctor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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('] [')}]`]
Expand Down
1 change: 1 addition & 0 deletions lib/commands/help.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class Help extends BaseCommand {
static name = 'help'
static usage = ['<term> [<terms..>]']
static params = ['viewer']
static skipConfigValidation = true

static async completion (opts, npm) {
if (opts.conf.argv.remain.length > 2) {
Expand Down
1 change: 1 addition & 0 deletions lib/commands/version.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class Version extends BaseCommand {

static workspaces = true
static ignoreImplicitWorkspace = false
static skipConfigValidation = true

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

Expand Down
6 changes: 4 additions & 2 deletions lib/npm.js
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down
29 changes: 12 additions & 17 deletions tap-snapshots/test/lib/commands/config.js.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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

Expand Down
18 changes: 11 additions & 7 deletions tap-snapshots/test/lib/commands/diff.js.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand Down
Loading
Loading