Skip to content

Commit 1db885c

Browse files
feat: native dependency patching (npm patch add/commit/update/ls/rm) (#9439)
Implements native dependency patching per [RFC #862](npm/rfcs#862): a first-class way to apply small, local modifications to an installed dependency and have them re-applied automatically on every install, with no external tooling or postinstall scripts. Patches are declared in a new `patchedDependencies` field of the root `package.json`, stored as plain unified diffs under `patches/`, and recorded with a content hash in `package-lock.json`. Because the patch is applied during the install itself, it works for transitive dependencies, across every `install-strategy`, and is **not** disabled by `--ignore-scripts`. ## The `npm patch` command A new command with five subcommands (and a bare `npm patch <pkg>` shorthand for `add`): - **`npm patch add <pkg>[@<version>]`** — extracts a clean copy of the resolved registry tarball into a temp directory outside `node_modules` and prints the path to edit. Ambiguous when multiple versions are installed; the error lists the exact selectors to retry with. - **`npm patch commit <edit-dir>`** — diffs the edited directory against a fresh copy of the original tarball, writes `<patches-dir>/<name>@<version>.patch`, adds the `patchedDependencies` entry, and reifies to apply the patch and record its integrity in the lockfile. `package.json` is excluded from the diff — Arborist resolves the pre-patch manifest, so a patched manifest would change resolution-affecting fields on disk without being honored (silent partial application); `commit` warns when an edit only touches it. - **`npm patch update <pkg>[@<old-version>] [--to <new-version>]`** — rebases an existing patch onto a new version. It reads the target from `--to` or the lockfile, 3-way-merges the existing patch onto the new tarball in a throwaway git repo, and rewrites `package.json` + `package-lock.json` **without touching `node_modules`** (so it works from a failed-install state). On conflict it leaves an edit dir with `<<<<<<<` markers, finalized by `npm patch commit`. Exact selectors are renamed; range/name-only selectors gain a new exact entry and keep the old one while it still wins another installed node. - **`npm patch ls`** — lists registered patches and how many installed nodes each matches (flagging overlapping range selectors that conflict on a node). - **`npm patch rm <pkg>[@<version>]`** — removes the matching entries, deletes the patch file when no other entry references it, and reifies to revert the files. ## Install-time apply pipeline Patch resolution and application live in Arborist so every install path honors them: - **`resolvePatchedDependencies`** resolves the root `patchedDependencies` map against the ideal tree, attaching `node.patched = { path, integrity }` to each matched node. Selector precedence is exact > range-subset > name-only, with ambiguous overlapping ranges surfaced as a hard error. - **reify** applies the diff after extraction and records the patched integrity in the lockfile. `diff.js` forces re-extraction when a node's patch integrity changes, and re-extracts to revert when a previously-patched node loses its selector (`patchRemoved`). - **`install-strategy=linked`** is supported via a content-addressed side-store: the store key is suffixed with the patch identity (`+patch`) so a patched and unpatched copy of the same version coexist without collision. A failed patch under linked strategy is always a hard error (the side-store cannot represent unpatched contents at a patched key without later installs silently trusting it). ## Lockfile Patches require `lockfileVersion: 4` so that older npm clients abort rather than silently installing unpatched code. When any node is patched, npm writes version 4 and **warns** if this upgrades a lower pinned `lockfile-version` (the safety gate cannot be honored otherwise). `npm ci` revalidates each patch's existence and integrity against the lockfile before installing. ## 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** relax flags cover one-off cases — `--allow-unused-patches` and `--ignore-patch-failures` — and are rejected in `npm ci` and when set anywhere other than the command line. ## Non-registry dependencies Patches need a stable registry tarball as their baseline, so a dependency reached through a non-registry consumer edge (`file:`, `git:`, `http(s):`) is rejected with `EPATCHNONREGISTRY`, both by `npm patch add` and at install time. The check is edge-based (the consuming spec's type), not node-based, so it does not falsely reject edgeless nodes such as linked-store entries or extraneous installs, which are still registry deps. `npm:` registry aliases are correctly classified as registry deps and are supported by the install engine; the `npm patch add <alias>` ergonomics will land in a fast-follow. ## Publish / pack `patchedDependencies` is stripped from the published **registry manifest** (libnpmpublish) so the field never leaks to the packument. Stripping it from the **tarball's own `package.json`** and excluding the `patches/` directory from the tarball is a coordinated follow-up in `pacote` + `npm-packlist` (those packages own the packed file list and the manifest written into the tarball, neither editable from the CLI) — see Follow-up work. ## Other surfaces - `npm ls` annotates patched dependencies in its output. - New config: `patches-dir`, `edit-dir`, `ignore-existing`, `keep-edit-dir`, plus the two relax flags. - New `npm-patch` man page and nav entry. ## Tests Unit and integration coverage for every subcommand (including `update`'s clean rebase, conflict→commit, and selector-rename/range-fork paths), the apply pipeline, selector matching, linked-strategy apply/removal, lockfile validation, publish stripping, and the relax flags. Arborist and CLI suites pass at 100% coverage. ## Follow-up work A few additive pieces are deliberately deferred — nothing in this PR depends on them. - **Tarball-side strip for publish/pack** — stripping `patchedDependencies` from the tarball's own `package.json` and excluding the `patches/` directory from the published tarball. This can't be done in the CLI: the tarball's file list and manifest come from `pacote` (packs the raw on-disk files) and `npm-packlist`, so it needs coordinated changes there. Raised in the RFC review; the registry-manifest strip in this PR already prevents the field from being honored or appearing in the packument. - **`npm patch add <alias>` ergonomics for `npm:` registry aliases** — the install engine already treats `npm:` aliases as registry dependencies and applies a hand-written `<alias>@<version>` selector correctly today. What remains is the `add`/`commit` convenience: resolving the alias to its real `name@version` tarball as the baseline and keying the written selector on the alias name. Currently `npm patch add <alias>` resolves the alias name as a real package and fails. - **Binary files** — patches are unified text diffs, so binary files (images, wasm, native addons) cannot be patched. This is a limitation of the whole feature (shared with `patch-package`), not a regression; a binary-aware path could be added later. ## References Implements npm/rfcs#862
1 parent fc80bb3 commit 1db885c

53 files changed

Lines changed: 4386 additions & 56 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

DEPENDENCIES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,6 +540,7 @@ graph LR;
540540
npmcli-arborist-->bin-links;
541541
npmcli-arborist-->cacache;
542542
npmcli-arborist-->common-ancestor-path;
543+
npmcli-arborist-->diff;
543544
npmcli-arborist-->gar-promise-retry["@gar/promise-retry"];
544545
npmcli-arborist-->hosted-git-info;
545546
npmcli-arborist-->isaacs-string-locale-compare["@isaacs/string-locale-compare"];
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
---
2+
title: npm-patch
3+
section: 1
4+
description: Apply local patches to installed dependencies
5+
---
6+
7+
### Synopsis
8+
9+
<!-- AUTOGENERATED USAGE DESCRIPTIONS -->
10+
11+
### Description
12+
13+
`npm patch` lets you apply small, local modifications to an installed
14+
dependency and have them re-applied automatically on every install. Patches
15+
are declared in the `patchedDependencies` field of your root `package.json`,
16+
stored as plain unified diffs under the `patches/` directory, and recorded with
17+
a content hash in `package-lock.json`.
18+
19+
Because patches are applied during the install itself, they work regardless of
20+
`install-strategy`, apply to transitive dependencies, and are **not** disabled
21+
by `--ignore-scripts`.
22+
23+
The bare form `npm patch <pkg>` is shorthand for `npm patch add <pkg>`. A
24+
package literally named like a subcommand must use the explicit form, e.g.
25+
`npm patch add add`.
26+
27+
* `npm patch add <pkg>[@<version>]`
28+
29+
Prepares a package for editing. npm extracts a clean copy of the resolved
30+
package tarball into a temporary directory outside `node_modules` and prints
31+
its path. Edit the files there, then run `npm patch commit`.
32+
33+
If more than one version of `<pkg>` is installed, re-run with an exact
34+
selector such as `npm patch add lodash@4.17.21`.
35+
36+
* `npm patch commit <edit-dir>`
37+
38+
Diffs the edited directory against a clean copy of the original tarball,
39+
writes the unified diff to `<patches-dir>/<name>@<version>.patch`, adds the
40+
entry to `patchedDependencies`, and updates `package-lock.json`.
41+
42+
* `npm patch ls`
43+
44+
Lists registered patches and how many installed nodes each one matches.
45+
46+
* `npm patch rm <pkg>[@<version>]`
47+
48+
Removes the matching entries from `patchedDependencies`, deletes the patch
49+
file when no other entry references it, and updates `package-lock.json`. If
50+
`<version>` is omitted, all entries for `<pkg>` are removed.
51+
52+
### Failure modes
53+
54+
By default any patch problem is a hard error that aborts the install: a patch
55+
that fails to apply, a registered patch that matches no installed package, a
56+
missing patch file, or a patch whose hash does not match the lockfile.
57+
58+
Two CLI-only flags relax this for one-off cases: `--allow-unused-patches` and
59+
`--ignore-patch-failures`.
60+
61+
### Configuration
62+
63+
<!-- AUTOGENERATED CONFIG DESCRIPTIONS -->
64+
## See Also
65+
66+
* [npm install](/commands/npm-install)
67+
* [npm ci](/commands/npm-ci)
68+
* [package-lock.json](/configuring-npm/package-lock-json)
69+
* [config](/commands/npm-config)

docs/lib/content/nav.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,9 @@
120120
- title: npm pack
121121
url: /commands/npm-pack
122122
description: Create a tarball from a package
123+
- title: npm patch
124+
url: /commands/npm-patch
125+
description: Apply local patches to installed dependencies
123126
- title: npm ping
124127
url: /commands/npm-ping
125128
description: Ping npm registry

lib/commands/audit.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const auditError = require('../utils/audit-error.js')
44
const { log, output } = require('proc-log')
55
const reifyFinish = require('../utils/reify-finish.js')
66
const resolveAllowScripts = require('../utils/resolve-allow-scripts.js')
7+
const { patchRelaxOpts } = require('../utils/cli-only-flag.js')
78
const VerifySignatures = require('../utils/verify-signatures.js')
89

910
class Audit extends ArboristWorkspaceCmd {
@@ -62,6 +63,8 @@ class Audit extends ArboristWorkspaceCmd {
6263
const { policy: allowScriptsPolicy } = await resolveAllowScripts(this.npm)
6364
const opts = {
6465
...this.npm.flatOptions,
66+
// audit fix reifies, so honor the cli-only patch relax flags
67+
...patchRelaxOpts(this.npm.config),
6568
audit: true,
6669
path: this.npm.prefix,
6770
reporter,

lib/commands/ci.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,15 @@ class CI extends ArboristWorkspaceCmd {
4444
})
4545
}
4646

47+
// npm ci is always strict about patches; the relax flags are not accepted
48+
for (const flag of ['allow-unused-patches', 'ignore-patch-failures']) {
49+
if (this.npm.config.find(flag) === 'cli') {
50+
throw Object.assign(new Error(`The --${flag} flag is not allowed with \`npm ci\`.`), {
51+
code: 'ECIPATCHFLAG',
52+
})
53+
}
54+
}
55+
4756
const dryRun = this.npm.config.get('dry-run')
4857
const ignoreScripts = this.npm.config.get('ignore-scripts')
4958
const where = this.npm.prefix

lib/commands/dedupe.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
const reifyFinish = require('../utils/reify-finish.js')
22
const resolveAllowScripts = require('../utils/resolve-allow-scripts.js')
3+
const { patchRelaxOpts } = require('../utils/cli-only-flag.js')
34
const ArboristWorkspaceCmd = require('../arborist-cmd.js')
45

56
// dedupe duplicated packages, or find them in the tree
@@ -47,6 +48,7 @@ class Dedupe extends ArboristWorkspaceCmd {
4748
save: false,
4849
workspaces: this.workspaceNames,
4950
allowScripts: allowScriptsPolicy,
51+
...patchRelaxOpts(this.npm.config),
5052
}
5153
const arb = new Arborist(opts)
5254
await arb.dedupe(opts)

lib/commands/install.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const checks = require('npm-install-checks')
77
const reifyFinish = require('../utils/reify-finish.js')
88
const resolveAllowScripts = require('../utils/resolve-allow-scripts.js')
99
const strictAllowScriptsPreflight = require('../utils/strict-allow-scripts-preflight.js')
10+
const { patchRelaxOpts } = require('../utils/cli-only-flag.js')
1011
const ArboristWorkspaceCmd = require('../arborist-cmd.js')
1112

1213
class Install extends ArboristWorkspaceCmd {
@@ -152,6 +153,8 @@ class Install extends ArboristWorkspaceCmd {
152153
add: args,
153154
workspaces: this.workspaceNames,
154155
allowScripts: allowScriptsPolicy,
156+
// patch relax flags are honored only when passed on the command line
157+
...patchRelaxOpts(this.npm.config),
155158
}
156159

157160
// 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.

lib/commands/link.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const pkgJson = require('@npmcli/package-json')
55
const semver = require('semver')
66
const reifyFinish = require('../utils/reify-finish.js')
77
const resolveAllowScripts = require('../utils/resolve-allow-scripts.js')
8+
const { patchRelaxOpts } = require('../utils/cli-only-flag.js')
89
const ArboristWorkspaceCmd = require('../arborist-cmd.js')
910

1011
class Link extends ArboristWorkspaceCmd {
@@ -70,6 +71,7 @@ class Link extends ArboristWorkspaceCmd {
7071
const Arborist = require('@npmcli/arborist')
7172
const globalOpts = {
7273
...this.npm.flatOptions,
74+
...patchRelaxOpts(this.npm.config),
7375
Arborist,
7476
path: globalTop,
7577
global: true,
@@ -119,6 +121,7 @@ class Link extends ArboristWorkspaceCmd {
119121
const { policy: allowScriptsPolicy } = await resolveAllowScripts(this.npm)
120122
const localArb = new Arborist({
121123
...this.npm.flatOptions,
124+
...patchRelaxOpts(this.npm.config),
122125
prune: false,
123126
path: this.npm.prefix,
124127
save,
@@ -145,6 +148,7 @@ class Link extends ArboristWorkspaceCmd {
145148
const Arborist = require('@npmcli/arborist')
146149
const arb = new Arborist({
147150
...this.npm.flatOptions,
151+
...patchRelaxOpts(this.npm.config),
148152
Arborist,
149153
path: globalTop,
150154
global: true,

lib/commands/ls.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,11 @@ const getHumanOutputItem = (node, { args, chalk, global, long }) => {
333333
? ' ' + chalk.dim('overridden')
334334
: ''
335335
) +
336+
(
337+
node.patched
338+
? ' ' + chalk.cyan(`[patched: ${node.patched.path}]`)
339+
: ''
340+
) +
336341
(isGitNode(node) ? ` (${node.resolved})` : '') +
337342
(node.isLink ? ` -> ${relativePrefix}${targetLocation}` : '') +
338343
(long ? `\n${node.package.description || ''}` : '')
@@ -389,6 +394,10 @@ const getJsonOutputItem = (node, { global, long }) => {
389394
item.invalid = node[_invalid]
390395
}
391396

397+
if (node.patched) {
398+
item.patched = node.patched.path
399+
}
400+
392401
if (node[_missing] && !isOptional(node)) {
393402
item.required = node[_required]
394403
item.missing = true

0 commit comments

Comments
 (0)