Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
629a417
feat(arborist): apply patchedDependencies during reify
manzoorwanijk May 29, 2026
59c2f4e
feat(config): add patches-dir and patch relax flags
manzoorwanijk May 29, 2026
3398a38
feat(arborist): re-extract on patch change and validate patch hash in…
manzoorwanijk May 29, 2026
e2d659b
feat(patch): add npm patch command (add/commit/ls/rm)
manzoorwanijk May 29, 2026
d1d4f53
test(arborist): unit tests for patch apply and selector matching
manzoorwanijk May 29, 2026
f1a2d48
fix(arborist): clear stale patch records when a selector is removed
manzoorwanijk May 29, 2026
e5fe1b1
test(patch): integration tests for command, reify apply, and selectors
manzoorwanijk May 29, 2026
196e81c
fix(patch): harden apply pipeline and tighten selector handling
manzoorwanijk May 29, 2026
e909449
fix(patch): contain patches-dir writes, reject non-registry version m…
manzoorwanijk May 29, 2026
011beed
fix(patch): clear node.patched on ignored failure, contain rm deletes…
manzoorwanijk May 29, 2026
eba8010
test(patch): make full arborist suite pass at 100% coverage
manzoorwanijk May 29, 2026
b898aba
fix(arborist): revalidate patch file existence and integrity in reify
manzoorwanijk May 29, 2026
4b75126
fix(arborist): fail loudly on optional patch errors and reject patche…
manzoorwanijk May 29, 2026
f4edeab
fix(arborist): seal linked-strategy patch guard at reify and re-code …
manzoorwanijk May 29, 2026
b6dc1c2
feat(publish): strip patchedDependencies from the published registry …
manzoorwanijk May 29, 2026
ebc2c92
feat(ls): annotate patched dependencies in npm ls output
manzoorwanijk May 29, 2026
dfc30ff
feat(patch): enforce allow-unused-patches and ignore-patch-failures a…
manzoorwanijk May 29, 2026
c7a20db
feat(arborist): apply patches under install-strategy=linked via a con…
manzoorwanijk May 30, 2026
ea3babb
fix(patch): honor relax flags across all reify commands, fail loudly …
manzoorwanijk May 30, 2026
16d2286
refactor(patch): drop unused diffDirs exclude option and dedupe the p…
manzoorwanijk May 30, 2026
d149f6c
fix(arborist): re-extract a dependency when its patch is removed so t…
manzoorwanijk May 30, 2026
3e16b2e
test(arborist): cover patch removal under install-strategy=linked
manzoorwanijk May 30, 2026
10b51f7
fix(patch): patch a registry dep even when a consumer node is edgeles…
manzoorwanijk May 30, 2026
924cf7c
feat(arborist): warn when patchedDependencies upgrades the lockfile t…
manzoorwanijk May 30, 2026
d296b73
fix(arborist): use the edge-based registry check on the install path …
manzoorwanijk May 30, 2026
e4eaf2a
test(smoke): add patch to the no-args command-list snapshot
manzoorwanijk May 30, 2026
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
1 change: 1 addition & 0 deletions DEPENDENCIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,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
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)
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
9 changes: 9 additions & 0 deletions lib/commands/ci.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,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
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 @@ -151,6 +152,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
9 changes: 9 additions & 0 deletions lib/commands/ls.js
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,11 @@ const getHumanOutputItem = (node, { args, chalk, global, long }) => {
? ' ' + chalk.dim('overridden')
: ''
) +
(
node.patched
? ' ' + chalk.cyan(`[patched: ${node.patched.path}]`)
: ''
) +
(isGitNode(node) ? ` (${node.resolved})` : '') +
(node.isLink ? ` -> ${relativePrefix}${targetLocation}` : '') +
(long ? `\n${node.package.description || ''}` : '')
Expand Down Expand Up @@ -389,6 +394,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