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
4992648
feat(arborist): apply patchedDependencies during reify
manzoorwanijk May 29, 2026
b4b8a06
feat(config): add patches-dir and patch relax flags
manzoorwanijk May 29, 2026
f4ce3ac
feat(arborist): re-extract on patch change and validate patch hash in…
manzoorwanijk May 29, 2026
6af5b82
feat(patch): add npm patch command (add/commit/ls/rm)
manzoorwanijk May 29, 2026
9beca46
test(arborist): unit tests for patch apply and selector matching
manzoorwanijk May 29, 2026
31ea567
fix(arborist): clear stale patch records when a selector is removed
manzoorwanijk May 29, 2026
9cfcee9
test(patch): integration tests for command, reify apply, and selectors
manzoorwanijk May 29, 2026
0f99da2
fix(patch): harden apply pipeline and tighten selector handling
manzoorwanijk May 29, 2026
889bb8d
fix(patch): contain patches-dir writes, reject non-registry version m…
manzoorwanijk May 29, 2026
f4f4f19
fix(patch): clear node.patched on ignored failure, contain rm deletes…
manzoorwanijk May 29, 2026
366e925
test(patch): make full arborist suite pass at 100% coverage
manzoorwanijk May 29, 2026
df0a4f1
fix(arborist): revalidate patch file existence and integrity in reify
manzoorwanijk May 29, 2026
e6ca8cf
fix(arborist): fail loudly on optional patch errors and reject patche…
manzoorwanijk May 29, 2026
e1403ce
fix(arborist): seal linked-strategy patch guard at reify and re-code …
manzoorwanijk May 29, 2026
66e61eb
feat(publish): strip patchedDependencies from the published registry …
manzoorwanijk May 29, 2026
33d130f
feat(ls): annotate patched dependencies in npm ls output
manzoorwanijk May 29, 2026
491a738
feat(patch): enforce allow-unused-patches and ignore-patch-failures a…
manzoorwanijk May 29, 2026
8c999f3
feat(arborist): apply patches under install-strategy=linked via a con…
manzoorwanijk May 30, 2026
c819d92
fix(patch): honor relax flags across all reify commands, fail loudly …
manzoorwanijk May 30, 2026
a854a1e
refactor(patch): drop unused diffDirs exclude option and dedupe the p…
manzoorwanijk May 30, 2026
55b3552
fix(arborist): re-extract a dependency when its patch is removed so t…
manzoorwanijk May 30, 2026
87367d3
test(arborist): cover patch removal under install-strategy=linked
manzoorwanijk May 30, 2026
78bf984
fix(patch): patch a registry dep even when a consumer node is edgeles…
manzoorwanijk May 30, 2026
bf3a5cc
feat(arborist): warn when patchedDependencies upgrades the lockfile t…
manzoorwanijk May 30, 2026
5011b8d
fix(arborist): use the edge-based registry check on the install path …
manzoorwanijk May 30, 2026
da64dd3
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 @@ -3,6 +3,7 @@ const ArboristWorkspaceCmd = require('../arborist-cmd.js')
const auditError = require('../utils/audit-error.js')
const { log, output } = require('proc-log')
const reifyFinish = require('../utils/reify-finish.js')
const { patchRelaxOpts } = require('../utils/cli-only-flag.js')
const VerifySignatures = require('../utils/verify-signatures.js')

class Audit extends ArboristWorkspaceCmd {
Expand Down Expand Up @@ -60,6 +61,8 @@ class Audit extends ArboristWorkspaceCmd {
const Arborist = require('@npmcli/arborist')
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,4 +1,5 @@
const reifyFinish = require('../utils/reify-finish.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 @@ -44,6 +45,7 @@ class Dedupe extends ArboristWorkspaceCmd {
// In order to reduce potential confusion we set this to false.
save: false,
workspaces: this.workspaceNames,
...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 @@ -4,6 +4,7 @@ const npa = require('npm-package-arg')
const pkgJson = require('@npmcli/package-json')
const semver = require('semver')
const reifyFinish = require('../utils/reify-finish.js')
const { patchRelaxOpts } = require('../utils/cli-only-flag.js')
const ArboristWorkspaceCmd = require('../arborist-cmd.js')

class Link extends ArboristWorkspaceCmd {
Expand Down Expand Up @@ -69,6 +70,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 @@ -117,6 +119,7 @@ class Link extends ArboristWorkspaceCmd {
// reify all the pending names as symlinks there
const localArb = new Arborist({
...this.npm.flatOptions,
...patchRelaxOpts(this.npm.config),
prune: false,
path: this.npm.prefix,
save,
Expand All @@ -141,6 +144,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