Skip to content
Merged
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
2 changes: 2 additions & 0 deletions docs/lib/content/commands/npm-version.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ The exact order of execution is as follows:
6. Run the `postversion` script.
Use it to clean up the file system or automatically push the commit and/or tag.

For the `preversion`, `version` and `postversion` scripts, npm also sets the [environment variables](/using-npm/scripts#environment) `npm_old_version` and `npm_new_version`.

Take the following example:

```json
Expand Down
22 changes: 17 additions & 5 deletions docs/lib/content/using-npm/scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,33 +111,38 @@ It is run AFTER the changes have been applied and the `package.json` and `packag

#### [`npm ci`](/commands/npm-ci)

* `preinstall`
* `preinstall` (before dependencies are installed)
* `install`
* `postinstall`
* `prepublish`
* `preprepare`
* `prepare`
* `postprepare`

These all run after the actual installation of modules into
`node_modules`, in order, with no internal actions happening in between
`preinstall` runs before any dependencies are fetched or unpacked into `node_modules`, so scripts can prepare the environment (for example, setting up authentication for a private registry) before tarballs are fetched. For `npm ci`, `preinstall` fires *after* the lockfile has been validated against `package.json`, so it cannot influence dependency resolution — that remains locked to `package-lock.json`. The remaining scripts run after the installation of modules into `node_modules`, in order, with no internal actions happening in between.

Because `preinstall` runs before reify, scripts cannot rely on packages from `node_modules`. `npm ci` wipes `node_modules` before `preinstall` fires, so `require()` of a dependency will always fail. Use `install` or `postinstall` for setup that depends on installed packages.

#### [`npm diff`](/commands/npm-diff)

* `prepare`

#### [`npm install`](/commands/npm-install)

These also run when you run `npm install -g <pkg-name>`
These run on a bare `npm install` in a local project (no package arguments).

* `preinstall`
* `preinstall` (before dependencies are installed)
* `install`
* `postinstall`
* `prepublish`
* `preprepare`
* `prepare`
* `postprepare`

`preinstall` runs before any dependencies are fetched or unpacked into `node_modules`, so scripts can prepare the environment (for example, setting up authentication for a private registry) before resolution begins. The remaining scripts run after installation has completed.

Because `preinstall` runs before reify, scripts cannot rely on packages from `node_modules`. On a fresh checkout, `require()` of a dependency will fail. On a repeat `npm install` against an existing `node_modules/`, it may incidentally succeed because the previously-installed tree is still on disk, but the version available is whatever was previously installed and may be removed or replaced by the upcoming install. Use `install` or `postinstall` for setup that depends on installed packages.

If there is a `binding.gyp` file in the root of your package and you haven't defined your own `install` or `preinstall` scripts, npm will default the `install` command to compile using node-gyp via `node-gyp rebuild`

These are run from the scripts of `<pkg-name>`
Expand Down Expand Up @@ -290,6 +295,13 @@ For example, if you had `{"name":"foo", "version":"1.2.5"}` in your package.json

See [`package.json`](/configuring-npm/package-json) for more on package configs.

#### versioning variables

For versioning scripts (`preversion`, `version`, `postversion`), npm sets these environment variables:

* `npm_old_version` - The version before being bumped
* `npm_new_version` – The version after being bumped

#### current lifecycle event

Lastly, the `npm_lifecycle_event` environment variable is set to whichever stage of the cycle is being executed.
Expand Down
110 changes: 60 additions & 50 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,75 @@ 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))

// 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 (cliUnknowns.length > 0 || fileUnknowns.length > 0) {
const sections = []
if (cliUnknowns.length > 0) {
const lines = cliUnknowns.map(u =>
u.baseKey ? ` - --${u.baseKey} (${u.key})` : ` - --${u.key}`
)
sections.push(
`Unknown cli config${cliUnknowns.length > 1 ? 's' : ''}:`,
...lines,
'Run `npm help config` for supported options.'
)
}
if (fileUnknowns.length > 0) {
if (sections.length > 0) {
sections.push('')
}
const lines = fileUnknowns.map(u => {
const display = u.baseKey ? `"${u.baseKey}" (${u.key})` : `"${u.key}"`
return ` - ${u.where} config ${display} from ${u.source}`
})
sections.push(
`Unknown npm configuration key${fileUnknowns.length > 1 ? 's' : ''}:`,
...lines,
'See `npm help npmrc` for supported config options.'
)
}
throw Object.assign(new Error(sections.join('\n')), {
code: 'EUNKNOWNCONFIG',
unknownConfigs: [...cliUnknowns, ...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
29 changes: 17 additions & 12 deletions lib/commands/ci.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,28 +99,33 @@ class CI extends ArboristWorkspaceCmd {
})
}

// Root lifecycle scripts for `npm ci` mirror those run by `npm install`. `preinstall` runs *before* reify so that scripts can bootstrap the environment (e.g. private-registry auth) before any dependency is fetched or unpacked. The remaining scripts run after reify as they did before.
const scriptShell = this.npm.config.get('script-shell') || undefined
const runRootScript = (event) => runScript({
path: where,
args: [],
scriptShell,
stdio: 'inherit',
event,
})

if (!ignoreScripts) {
await runRootScript('preinstall')
}

await arb.reify(opts)

// run the same set of scripts that `npm install` runs.
if (!ignoreScripts) {
const scripts = [
'preinstall',
const postReifyScripts = [
'install',
'postinstall',
'prepublish', // XXX should we remove this finally??
'preprepare',
'prepare',
'postprepare',
]
const scriptShell = this.npm.config.get('script-shell') || undefined
for (const event of scripts) {
await runScript({
path: where,
args: [],
scriptShell,
stdio: 'inherit',
event,
})
for (const event of postReifyScripts) {
await runRootScript(event)
}
}
await reifyFinish(this.npm, arb)
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
6 changes: 3 additions & 3 deletions lib/commands/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const { EOL } = require('node:os')
const localeCompare = require('@isaacs/string-locale-compare')('en')
const pkgJson = require('@npmcli/package-json')
const { defaults, definitions, nerfDarts, proxyEnv } = require('@npmcli/config/lib/definitions')
const { log, output } = require('proc-log')
const { log, output, input } = require('proc-log')
const BaseCommand = require('../base-cmd.js')
const { redact } = require('@npmcli/redact')

Expand Down Expand Up @@ -266,7 +266,7 @@ ${defData}
`.split('\n').join(EOL)
await mkdir(dirname(file), { recursive: true })
await writeFile(file, tmpData, 'utf8')
await new Promise((res, rej) => {
await input.start(() => new Promise((res, rej) => {
const [bin, ...args] = e.split(/\s+/)
const editor = spawn(bin, [...args, file], { stdio: 'inherit' })
editor.on('exit', (code) => {
Expand All @@ -275,7 +275,7 @@ ${defData}
}
return res()
})
})
}))
}

async fix () {
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
10 changes: 6 additions & 4 deletions lib/commands/edit.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const { resolve } = require('node:path')
const { lstat } = require('node:fs/promises')
const cp = require('node:child_process')
const { input } = require('proc-log')
const completion = require('../utils/installed-shallow.js')
const BaseCommand = require('../base-cmd.js')

Expand Down Expand Up @@ -46,16 +47,17 @@ class Edit extends BaseCommand {
const dir = resolve(this.npm.dir, path)

await lstat(dir)
await new Promise((res, rej) => {
await input.start(() => new Promise((res, rej) => {
const [bin, ...spawnArgs] = this.npm.config.get('editor').split(/\s+/)
const editor = cp.spawn(bin, [...spawnArgs, dir], { stdio: 'inherit' })
editor.on('exit', async (code) => {
editor.on('exit', (code) => {
if (code) {
return rej(new Error(`editor process exited with code: ${code}`))
}
await this.npm.exec('rebuild', [dir]).then(res).catch(rej)
res()
})
})
}))
await this.npm.exec('rebuild', [dir])
}
}

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
30 changes: 19 additions & 11 deletions lib/commands/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -145,27 +145,35 @@ class Install extends ArboristWorkspaceCmd {
add: args,
workspaces: this.workspaceNames,
}

// Root lifecycle scripts only run for a bare `npm install` in a local project. `preinstall` runs *before* Arborist touches the filesystem so that scripts can bootstrap the environment (e.g. set up private-registry auth, generate files consumed during resolution) before dependencies are fetched or unpacked. The remaining scripts run after reify as they did before.
const runRootLifecycle = !args.length && !isGlobalInstall && !ignoreScripts
const runRootScript = (event) => runScript({
path: where,
args: [],
scriptShell,
stdio: 'inherit',
event,
})

if (runRootLifecycle) {
await runRootScript('preinstall')
}

const arb = new Arborist(opts)
await arb.reify(opts)

if (!args.length && !isGlobalInstall && !ignoreScripts) {
const scripts = [
'preinstall',
if (runRootLifecycle) {
const postReifyScripts = [
'install',
'postinstall',
'prepublish', // XXX(npm9) should we remove this finally??
'preprepare',
'prepare',
'postprepare',
]
for (const event of scripts) {
await runScript({
path: where,
args: [],
scriptShell,
stdio: 'inherit',
event,
})
for (const event of postReifyScripts) {
await runRootScript(event)
}
}
await reifyFinish(this.npm, arb)
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
Loading
Loading