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
3 changes: 3 additions & 0 deletions DEPENDENCIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ graph LR;
npmcli-arborist-->proggy;
npmcli-arborist-->semver;
npmcli-arborist-->ssri;
npmcli-arborist-->validate-npm-package-name;
npmcli-config-->ini;
npmcli-config-->nopt;
npmcli-config-->npmcli-eslint-config["@npmcli/eslint-config"];
Expand Down Expand Up @@ -540,6 +541,7 @@ graph LR;
npmcli-arborist-->bin-links;
npmcli-arborist-->cacache;
npmcli-arborist-->common-ancestor-path;
npmcli-arborist-->diff;
npmcli-arborist-->gar-promise-retry["@gar/promise-retry"];
npmcli-arborist-->hosted-git-info;
npmcli-arborist-->isaacs-string-locale-compare["@isaacs/string-locale-compare"];
Expand Down Expand Up @@ -578,6 +580,7 @@ graph LR;
npmcli-arborist-->tar-stream;
npmcli-arborist-->tcompare;
npmcli-arborist-->treeverse;
npmcli-arborist-->validate-npm-package-name;
npmcli-arborist-->walk-up-path;
npmcli-config-->ci-info;
npmcli-config-->ini;
Expand Down
69 changes: 69 additions & 0 deletions docs/lib/content/commands/npm-patch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
---
title: npm-patch
section: 1
description: Apply local patches to installed dependencies
---

### Synopsis

<!-- AUTOGENERATED USAGE DESCRIPTIONS -->

### Description

`npm patch` lets you apply small, local modifications to an installed
dependency and have them re-applied automatically on every install. Patches
are declared in the `patchedDependencies` field of your root `package.json`,
stored as plain unified diffs under the `patches/` directory, and recorded with
a content hash in `package-lock.json`.

Because patches are applied during the install itself, they work regardless of
`install-strategy`, apply to transitive dependencies, and are **not** disabled
by `--ignore-scripts`.

The bare form `npm patch <pkg>` is shorthand for `npm patch add <pkg>`. A
package literally named like a subcommand must use the explicit form, e.g.
`npm patch add add`.

* `npm patch add <pkg>[@<version>]`

Prepares a package for editing. npm extracts a clean copy of the resolved
package tarball into a temporary directory outside `node_modules` and prints
its path. Edit the files there, then run `npm patch commit`.

If more than one version of `<pkg>` is installed, re-run with an exact
selector such as `npm patch add lodash@4.17.21`.

* `npm patch commit <edit-dir>`

Diffs the edited directory against a clean copy of the original tarball,
writes the unified diff to `<patches-dir>/<name>@<version>.patch`, adds the
entry to `patchedDependencies`, and updates `package-lock.json`.

* `npm patch ls`

Lists registered patches and how many installed nodes each one matches.

* `npm patch rm <pkg>[@<version>]`

Removes the matching entries from `patchedDependencies`, deletes the patch
file when no other entry references it, and updates `package-lock.json`. If
`<version>` is omitted, all entries for `<pkg>` are removed.

### Failure modes

By default any patch problem is a hard error that aborts the install: a patch
that fails to apply, a registered patch that matches no installed package, a
missing patch file, or a patch whose hash does not match the lockfile.

Two CLI-only flags relax this for one-off cases: `--allow-unused-patches` and
`--ignore-patch-failures`.

### Configuration

<!-- AUTOGENERATED CONFIG DESCRIPTIONS -->
## See Also

* [npm install](/commands/npm-install)
* [npm ci](/commands/npm-ci)
* [package-lock.json](/configuring-npm/package-lock-json)
* [config](/commands/npm-config)
60 changes: 60 additions & 0 deletions docs/lib/content/configuring-npm/package-json.md
Original file line number Diff line number Diff line change
Expand Up @@ -1033,6 +1033,66 @@ For example, to replace a transitive dependency with a fork:
}
```

### packageExtensions

`packageExtensions` lets a project apply small, declarative repairs to the manifests of third-party dependencies before npm resolves the dependency tree.
Use it to add a missing `dependencies`, `optionalDependencies`, or `peerDependencies` entry, or to correct `peerDependencies` and `peerDependenciesMeta`, while you wait for the upstream package to publish a fix.

This is especially useful with [`install-strategy=linked`](/using-npm/config#install-strategy), where dependencies are fully isolated and a package only sees what it actually declared.
A package that worked under a hoisted layout because a dependency happened to be hoisted above it can fail under `linked`; `packageExtensions` records the missing edge as explicit, reviewable, root-owned policy.

`packageExtensions` complements [`overrides`](#overrides): `overrides` changes what an existing dependency edge resolves to, while `packageExtensions` adds or corrects the dependency metadata that creates the edge in the first place.
For changing the resolved version of a dependency that is already declared, use `overrides`.

Like `overrides`, `packageExtensions` is only honored in the root `package.json` of a project (the workspace root in a workspace).
The field in installed dependencies and in non-root workspace packages is ignored.
Because it is root-only project policy, npm refuses to publish a non-private package that contains `packageExtensions`; it remains available to private packages and unpublished local projects.

Each key is a package selector: a package name with an optional semver range.

```json
{
"packageExtensions": {
"broken-package@1": {
"dependencies": {
"missing-runtime-dep": "^2.0.0"
}
},
"typescript-plugin@4.3.0": {
"peerDependencies": {
"typescript": ">=5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"@scope/uses-types@2": {
"dependencies": {
"@types/node": "^22.0.0"
}
}
}
}
```

- `"foo"` matches all versions of `foo`.
- `"foo@1"` matches versions satisfying `1`.
- `"@scope/foo@^2.3.0"` matches versions satisfying `^2.3.0`.

Selectors match a candidate package's own `name` and `version`. They do not accept dist-tags, git, file, directory, URL, or `npm:` alias specs. For aliases, the selector matches the underlying package name. At most one selector may match a given package; overlapping selectors that both match the same package fail the install.

Only `dependencies`, `optionalDependencies`, `peerDependencies`, and `peerDependenciesMeta` may be extended. The merge rules are:

- `dependencies` and `optionalDependencies` entries add a missing dependency only. Adding a name that the package already declares in either field is an error; use `overrides` to change a version.
- `peerDependencies` entries are merged by name, replacing an existing range.
- `peerDependenciesMeta` entries are merged by name and then by key, so you can add `optional: true` without dropping other metadata. Every `peerDependenciesMeta` entry must correspond to a `peerDependencies` entry.

Deletion is not supported; a `null`, `false`, or `"-"` value is an error.

`packageExtensions` does not rewrite the installed package's `package.json` on disk and does not modify `bundleDependencies`. Affected packages are recorded in `package-lock.json` and surfaced by [`npm explain`](/commands/npm-explain) and [`npm ls`](/commands/npm-ls), so each repair is easy to audit and to remove once upstream is fixed.

### engines

You can specify the version of node that your stuff works on:
Expand Down
3 changes: 3 additions & 0 deletions docs/lib/content/nav.yml
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@
- title: npm pack
url: /commands/npm-pack
description: Create a tarball from a package
- title: npm patch
url: /commands/npm-patch
description: Apply local patches to installed dependencies
- title: npm ping
url: /commands/npm-ping
description: Ping npm registry
Expand Down
3 changes: 3 additions & 0 deletions lib/commands/audit.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const auditError = require('../utils/audit-error.js')
const { log, output } = require('proc-log')
const reifyFinish = require('../utils/reify-finish.js')
const resolveAllowScripts = require('../utils/resolve-allow-scripts.js')
const { patchRelaxOpts } = require('../utils/cli-only-flag.js')
const VerifySignatures = require('../utils/verify-signatures.js')

class Audit extends ArboristWorkspaceCmd {
Expand Down Expand Up @@ -62,6 +63,8 @@ class Audit extends ArboristWorkspaceCmd {
const { policy: allowScriptsPolicy } = await resolveAllowScripts(this.npm)
const opts = {
...this.npm.flatOptions,
// audit fix reifies, so honor the cli-only patch relax flags
...patchRelaxOpts(this.npm.config),
audit: true,
path: this.npm.prefix,
reporter,
Expand Down
12 changes: 12 additions & 0 deletions lib/commands/ci.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const fs = require('node:fs/promises')
const path = require('node:path')
const { log, time } = require('proc-log')
const validateLockfile = require('../utils/validate-lockfile.js')
const { validatePackageExtensions } = require('../utils/validate-lockfile.js')
const ArboristWorkspaceCmd = require('../arborist-cmd.js')
const getWorkspaces = require('../utils/get-workspaces.js')

Expand Down Expand Up @@ -44,6 +45,15 @@ class CI extends ArboristWorkspaceCmd {
})
}

// npm ci is always strict about patches; the relax flags are not accepted
for (const flag of ['allow-unused-patches', 'ignore-patch-failures']) {
if (this.npm.config.find(flag) === 'cli') {
throw Object.assign(new Error(`The --${flag} flag is not allowed with \`npm ci\`.`), {
code: 'ECIPATCHFLAG',
})
}
}

const dryRun = this.npm.config.get('dry-run')
const ignoreScripts = this.npm.config.get('ignore-scripts')
const where = this.npm.prefix
Expand Down Expand Up @@ -80,6 +90,8 @@ class CI extends ArboristWorkspaceCmd {

// Verifies that the packages from the ideal tree will match the same versions that are present in the virtual tree (lock file).
const errors = validateLockfile(virtualInventory, arb.idealTree.inventory)
// Verifies that the root packageExtensions state matches the lockfile and is still consistent with the locked tree.
errors.push(...validatePackageExtensions(virtualArb.virtualTree, arb.idealTree))
if (errors.length) {
throw this.usageError(
'`npm ci` can only install packages when your package.json and package-lock.json are in sync. ' +
Expand Down
2 changes: 2 additions & 0 deletions lib/commands/dedupe.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const reifyFinish = require('../utils/reify-finish.js')
const resolveAllowScripts = require('../utils/resolve-allow-scripts.js')
const { patchRelaxOpts } = require('../utils/cli-only-flag.js')
const ArboristWorkspaceCmd = require('../arborist-cmd.js')

// dedupe duplicated packages, or find them in the tree
Expand Down Expand Up @@ -47,6 +48,7 @@ class Dedupe extends ArboristWorkspaceCmd {
save: false,
workspaces: this.workspaceNames,
allowScripts: allowScriptsPolicy,
...patchRelaxOpts(this.npm.config),
}
const arb = new Arborist(opts)
await arb.dedupe(opts)
Expand Down
3 changes: 3 additions & 0 deletions lib/commands/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const checks = require('npm-install-checks')
const reifyFinish = require('../utils/reify-finish.js')
const resolveAllowScripts = require('../utils/resolve-allow-scripts.js')
const strictAllowScriptsPreflight = require('../utils/strict-allow-scripts-preflight.js')
const { patchRelaxOpts } = require('../utils/cli-only-flag.js')
const ArboristWorkspaceCmd = require('../arborist-cmd.js')

class Install extends ArboristWorkspaceCmd {
Expand Down Expand Up @@ -152,6 +153,8 @@ class Install extends ArboristWorkspaceCmd {
add: args,
workspaces: this.workspaceNames,
allowScripts: allowScriptsPolicy,
// patch relax flags are honored only when passed on the command line
...patchRelaxOpts(this.npm.config),
}

// 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.
Expand Down
4 changes: 4 additions & 0 deletions lib/commands/link.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const pkgJson = require('@npmcli/package-json')
const semver = require('semver')
const reifyFinish = require('../utils/reify-finish.js')
const resolveAllowScripts = require('../utils/resolve-allow-scripts.js')
const { patchRelaxOpts } = require('../utils/cli-only-flag.js')
const ArboristWorkspaceCmd = require('../arborist-cmd.js')

class Link extends ArboristWorkspaceCmd {
Expand Down Expand Up @@ -70,6 +71,7 @@ class Link extends ArboristWorkspaceCmd {
const Arborist = require('@npmcli/arborist')
const globalOpts = {
...this.npm.flatOptions,
...patchRelaxOpts(this.npm.config),
Arborist,
path: globalTop,
global: true,
Expand Down Expand Up @@ -119,6 +121,7 @@ class Link extends ArboristWorkspaceCmd {
const { policy: allowScriptsPolicy } = await resolveAllowScripts(this.npm)
const localArb = new Arborist({
...this.npm.flatOptions,
...patchRelaxOpts(this.npm.config),
prune: false,
path: this.npm.prefix,
save,
Expand All @@ -145,6 +148,7 @@ class Link extends ArboristWorkspaceCmd {
const Arborist = require('@npmcli/arborist')
const arb = new Arborist({
...this.npm.flatOptions,
...patchRelaxOpts(this.npm.config),
Arborist,
path: globalTop,
global: true,
Expand Down
33 changes: 33 additions & 0 deletions lib/commands/ls.js
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,21 @@ const augmentItemWithIncludeMetadata = (node, item) => {
return item
}

// Render a node's packageExtensions provenance as a short "field.name" list, empty when none.
const formatPackageExtensions = (applied) => {
if (!applied) {
return ''
}
const fields = ['dependencies', 'optionalDependencies', 'peerDependencies', 'peerDependenciesMeta']
const parts = []
for (const field of fields) {
for (const name of applied[field] || []) {
parts.push(`${field}.${name}`)
}
}
return parts.join(', ')
}

const getHumanOutputItem = (node, { args, chalk, global, long }) => {
const { pkgid, path } = node
const workspacePkgId = chalk.blueBright(pkgid)
Expand Down Expand Up @@ -333,6 +348,16 @@ const getHumanOutputItem = (node, { args, chalk, global, long }) => {
? ' ' + chalk.dim('overridden')
: ''
) +
(
node.patched
? ' ' + chalk.cyan(`[patched: ${node.patched.path}]`)
: ''
) +
(
formatPackageExtensions(node.packageExtensionsApplied)
? ' ' + chalk.dim(`packageExtensions: ${formatPackageExtensions(node.packageExtensionsApplied)}`)
: ''
) +
(isGitNode(node) ? ` (${node.resolved})` : '') +
(node.isLink ? ` -> ${relativePrefix}${targetLocation}` : '') +
(long ? `\n${node.package.description || ''}` : '')
Expand All @@ -357,6 +382,10 @@ const getJsonOutputItem = (node, { global, long }) => {
item.overridden = node.overridden
}

if (node.packageExtensionsApplied) {
item.packageExtensionsApplied = node.packageExtensionsApplied
}

item[_name] = node.name

// special formatting for top-level package name
Expand Down Expand Up @@ -389,6 +418,10 @@ const getJsonOutputItem = (node, { global, long }) => {
item.invalid = node[_invalid]
}

if (node.patched) {
item.patched = node.patched.path
}

if (node[_missing] && !isOptional(node)) {
item.required = node[_required]
item.missing = true
Expand Down
Loading
Loading