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
1 change: 1 addition & 0 deletions docs/lib/build.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -107,6 +107,7 @@ const generateNav = async (contentPath, navPath) => {
'/configuring-npm/npmrc',
'/configuring-npm/package-json',
'/configuring-npm/package-lock-json',
'/configuring-npm/npm-extension',
]

// Hardcoded order for using-npm section (only urls - title/description come from frontmatter)
Expand Down
90 changes: 90 additions & 0 deletions docs/lib/content/configuring-npm/npm-extension.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
---
title: .npm-extension
section: 5
description: Imperative, root-owned manifest repairs
---

### Description

A root-owned `.npm-extension.mjs` or `.npm-extension.cjs` file lets a project imperatively repair the manifests of third-party dependencies before npm resolves the dependency tree. It exports a `transformManifest(pkg, context)` function that receives a candidate dependency manifest and returns the effective manifest npm should use.

`.npm-extension` is the imperative counterpart to the declarative [`packageExtensions`](/configuring-npm/package-json#packageextensions) field, and runs in the same pre-resolution phase, **before** `packageExtensions`. Prefer `packageExtensions` for simple, data-only repairs; reach for `.npm-extension` when you need comments and links explaining a repair, conditional logic, repeated repairs expressed as code, deletion or range rewrites, stale-repair guards, or a policy location outside `package.json`.

### Example

```js
// .npm-extension.mjs
export function transformManifest (pkg, context) {
if (pkg.name === 'foo' && pkg.version.startsWith('1.')) {
pkg.dependencies = { ...pkg.dependencies, bar: '^2.0.0' }
context.log(`added bar to ${pkg.name}@${pkg.version}`)
}
return pkg
}
```

The `.cjs` form uses CommonJS exports instead:

```js
// .npm-extension.cjs
module.exports = {
transformManifest (pkg, context) {
return pkg
},
}
```

### The `transformManifest` function

`transformManifest(pkg, context)` receives a deeply isolated copy of a candidate dependency manifest. It may mutate and return that copy, or return a new manifest object. It **must** return a manifest object synchronously; returning `null`, `undefined`, a primitive, an array, or a promise fails the install.

The `context` argument is intentionally small:

* `context.log(message)` writes an npm debug log message.
* `context.root` is the absolute path to the project root.
* `context.extensionPoint` is the string `"transformManifest"`.

npm provides no registry, fetch, lockfile, or extraction helpers. Keep the extension file self-contained or limited to Node builtins; npm does not guarantee that project dependencies are available when the file is loaded.

### Supported mutations

Only the four resolution-affecting fields may change:

* `dependencies`
* `optionalDependencies`
* `peerDependencies`
* `peerDependenciesMeta`

Within those fields you may add, replace, or delete entries. Changing any other field (such as `scripts`, `bin`, `engines`, `os`, `cpu`, `exports`, or `main`) is rejected, and the install fails with an error naming `.npm-extension` and the package being processed. The package tarball and the installed `node_modules/<pkg>/package.json` are never rewritten.

### Discovery and `extension-file`

npm looks for a single `.npm-extension.mjs` or `.npm-extension.cjs` at the project root (the workspace root in a workspace project). Having both files present is an error. A `.npm-extension` file in a dependency or in a non-root workspace is ignored; a non-root workspace file produces a warning.

The [`extension-file`](/using-npm/config#extension-file) config selects a different project-local file. It must resolve inside the project root and use a `.mjs` or `.cjs` extension, and it is honored only from project config or the command line — never from user, global, or builtin config.

### Interaction with `packageExtensions` and `overrides`

When both are present, `transformManifest` runs first and `packageExtensions` is applied to its output. Avoid targeting the same package with both unless you intend to rely on that ordering. `overrides` still controls the final resolution target of any edge, including edges created by `transformManifest`.

### Lockfile and `npm ci`

A lockfile influenced by `.npm-extension` records an `npmExtensionHash` (a digest of the selected file's bytes and module format) on its root entry, and minimal `npmExtensionApplied` provenance on each affected package entry. Extension state requires `lockfileVersion: 4`.

Changing the file's contents makes `npm install` re-resolve the affected packages. `npm ci` does **not** import or execute `.npm-extension`; it verifies the recorded hash against the file and reifies the locked graph, failing if the file and lockfile disagree (or if one has extension state and the other does not).

The hash proves only that the install uses the same extension file bytes that generated the lockfile. It does not make arbitrary JavaScript deterministic: extension output that depends on environment variables, the network, the clock, or files imported by the extension can still produce non-reproducible installs. Treat `.npm-extension` as trusted, deterministic project code, and only enable it in repositories you trust.

### Disabling

Set [`ignore-extension`](/using-npm/config#ignore-extension) to skip importing and executing `.npm-extension`. [`ignore-scripts`](/using-npm/config#ignore-scripts) implies `ignore-extension`, since both disable root-owned install-time code. `npm ci` still verifies the file hash even when execution is disabled.

### Publishing

`.npm-extension.mjs` and `.npm-extension.cjs` are project configuration, not package contents. npm excludes the root file from the package tarball produced by `npm pack` and `npm publish`, even when the package's `files` list would include it, so a public package can keep `.npm-extension` in its repository for local use without publishing it.

### See also

* [package.json `packageExtensions`](/configuring-npm/package-json#packageextensions)
* [package-lock.json](/configuring-npm/package-lock-json)
* [config](/using-npm/config)
3 changes: 3 additions & 0 deletions docs/lib/content/nav.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -232,6 +232,9 @@
- title: package-lock.json
url: /configuring-npm/package-lock-json
description: A manifestation of the manifest
- title: .npm-extension
url: /configuring-npm/npm-extension
description: Imperative, root-owned manifest repairs
- title: Using npm
shortName: Using
url: /using-npm
Expand Down
15 changes: 14 additions & 1 deletion lib/commands/ci.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,7 +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 { validatePackageExtensions, validateNpmExtension } = require('../utils/validate-lockfile.js')
const ArboristWorkspaceCmd = require('../arborist-cmd.js')
const getWorkspaces = require('../utils/get-workspaces.js')

Expand DownExpand Up@@ -66,6 +66,9 @@ class CI extends ArboristWorkspaceCmd {
save: false, // npm ci should never modify the lockfile or package.json
workspaces: this.workspaceNames,
allowScripts: allowScriptsPolicy,
// npm ci reifies the locked graph, which already carries extension-influenced edges, so it must never import or execute .npm-extension.
// The extension file hash is still validated below, independent of execution.
ignoreExtension: true,
}

// generate an inventory from the virtual tree in the lockfile
Expand All@@ -92,6 +95,16 @@ class CI extends ArboristWorkspaceCmd {
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))
// Verifies that the root .npm-extension file matches the lockfile hash.
// The hash comes from discovering the file (no import or execution), so this holds even under ignore-extension/ignore-scripts.
const { NpmExtension } = require('@npmcli/arborist')
let fileHash = null
try {
fileHash = new NpmExtension({ root: where, extensionFile: opts.extensionFile }).hash
} catch (err) {
errors.push(`Invalid: ${err.message}`)
}
errors.push(...validateNpmExtension(virtualArb.virtualTree, fileHash))
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
17 changes: 13 additions & 4 deletions lib/commands/ls.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -278,8 +278,8 @@ const augmentItemWithIncludeMetadata = (node, item) => {
return item
}

// Render a node's packageExtensions provenance as a short "field.name" list, empty when none.
const formatPackageExtensions = (applied) => {
// Render a manifest-extension provenance object as a short "field.name" list, empty when none.
const formatExtensionApplied = (applied) => {
if (!applied) {
return ''
}
Expand DownExpand Up@@ -354,8 +354,13 @@ const getHumanOutputItem = (node, { args, chalk, global, long }) => {
: ''
) +
(
formatPackageExtensions(node.packageExtensionsApplied)
? ' ' + chalk.dim(`packageExtensions: ${formatPackageExtensions(node.packageExtensionsApplied)}`)
formatExtensionApplied(node.packageExtensionsApplied)
? ' ' + chalk.dim(`packageExtensions: ${formatExtensionApplied(node.packageExtensionsApplied)}`)
: ''
) +
(
formatExtensionApplied(node.npmExtensionApplied)
? ' ' + chalk.dim(`.npm-extension: ${formatExtensionApplied(node.npmExtensionApplied)}`)
: ''
) +
(isGitNode(node) ? ` (${node.resolved})` : '') +
Expand DownExpand Up@@ -386,6 +391,10 @@ const getJsonOutputItem = (node, { global, long }) => {
item.packageExtensionsApplied = node.packageExtensionsApplied
}

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

item[_name] = node.name

// special formatting for top-level package name
Expand Down
13 changes: 13 additions & 0 deletions lib/npm.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,6 +118,19 @@ class Npm {
return { exec: false }
}

// extension-file selects root-owned install-time code, so it is only honored from project config or the command line.
// This is checked after #display.load() so the error is surfaced to the user instead of being swallowed during early config loading.
const extensionFile = this.config.get('extension-file')
if (extensionFile != null) {
const where = this.config.find('extension-file')
if (!['cli', 'project', 'default'].includes(where)) {
throw Object.assign(
new Error(`\`extension-file\` may only be set in project config or on the command line, not from ${where} config`),
{ code: 'ENPMEXTENSIONCONFIG' }
)
}
}

// mkdir this separately since the logs dir can be set to a different location.
// if this fails, then we don't have a cache dir, but we don't want to fail immediately since the command might not need a cache dir (like `npm --version`)
await time.start('npm:load:mkdirpcache', () =>
Expand Down
9 changes: 7 additions & 2 deletions lib/utils/explain-dep.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,7 +76,7 @@ const explainDependents = ({ dependents }, depth, chalk, seen) => {
}

const explainEdge = (
{ name, type, bundled, from, spec, rawSpec, overridden, packageExtensions },
{ name, type, bundled, from, spec, rawSpec, overridden, packageExtensions, npmExtension },
depth, chalk, seen = new Set()
) => {
let dep = type === 'workspace'
Expand All@@ -93,9 +93,14 @@ const explainEdge = (
? chalk.dim(` (added by packageExtensions["${packageExtensions.selector}"].${packageExtensions.field}.${name})`)
: ''

// note an edge created or changed by a root .npm-extension repair
const npmExtMsg = npmExtension
? chalk.dim(` (changed by .npm-extension ${npmExtension.extensionPoint} ${npmExtension.field}.${name})`)
: ''

return (type === 'prod' ? '' : `${colorType(type, chalk)} `) +
(bundled ? `${colorType('bundled', chalk)} ` : '') +
`${dep}${fromMsg}${extMsg}`
`${dep}${fromMsg}${extMsg}${npmExtMsg}`
}

const explainFrom = (from, depth, chalk, seen) => {
Expand Down
28 changes: 28 additions & 0 deletions lib/utils/validate-lockfile.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -98,5 +98,33 @@ function validatePackageExtensions (virtualTree, idealTree) {
return errors
}

// validates that the .npm-extension state recorded in the lockfile still matches the selected extension file.
// Validation is hash-based: arbitrary code has no selector to re-check, so a matching hash is trusted and a mismatch fails.
// fileHash is computed from the on-disk file (discovery only, no execution), so this holds even under ignore-extension/ignore-scripts.
// The lockfile carries extension state if it records a root hash or any per-package npmExtensionApplied provenance.
// Returns an array of human-readable error strings, empty when valid.
function validateNpmExtension (virtualTree, fileHash) {
const lockHash = virtualTree?.meta?.npmExtensionHash || null
const hasProvenance = !!virtualTree &&
[...virtualTree.inventory.values()].some(node => node.npmExtensionApplied)
fileHash = fileHash || null

if (fileHash) {
if (!lockHash) {
return ['Missing: .npm-extension state from lock file']
}
if (lockHash !== fileHash) {
return ['Invalid: .npm-extension file does not match the lock file']
}
return []
}
// no extension file present
if (lockHash || hasProvenance) {
return ['Invalid: lock file records .npm-extension state but no .npm-extension file is present']
}
return []
}

module.exports = validateLockfile
module.exports.validatePackageExtensions = validatePackageExtensions
module.exports.validateNpmExtension = validateNpmExtension
4 changes: 4 additions & 0 deletions tap-snapshots/test/lib/commands/config.js.test.cjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,6 +58,7 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna
"expect-result-count": null,
"expect-results": null,
"expires": null,
"extension-file": null,
"fetch-retries": 2,
"fetch-retry-factor": 10,
"fetch-retry-maxtimeout": 60000,
Expand All@@ -76,6 +77,7 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna
"heading": "npm",
"https-proxy": null,
"if-present": false,
"ignore-extension": false,
"ignore-scripts": false,
"include": [],
"include-staged": false,
Expand DownExpand Up@@ -254,6 +256,7 @@ engine-strict = false
expect-result-count = null
expect-results = null
expires = null
extension-file = null
fetch-retries = 2
fetch-retry-factor = 10
fetch-retry-maxtimeout = 60000
Expand All@@ -273,6 +276,7 @@ heading = "npm"
https-proxy = null
if-present = false
ignore-existing = false
ignore-extension = false
ignore-patch-failures = false
ignore-scripts = false
include = []
Expand Down
12 changes: 6 additions & 6 deletions tap-snapshots/test/lib/commands/install.js.test.cjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -135,8 +135,8 @@ verbose stack Error: The developer of this package has specified the following t
verbose stack Invalid devEngines.runtime
verbose stack Invalid name "nondescript" does not match "node" for "runtime"
verbose stack at Install.checkDevEngines ({CWD}/lib/base-cmd.js:249:27)
verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:281:7)
verbose stack at MockNpm.exec ({CWD}/lib/npm.js:181:9)
verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:294:7)
verbose stack at MockNpm.exec ({CWD}/lib/npm.js:194:9)
error code EBADDEVENGINES
error EBADDEVENGINES The developer of this package has specified the following through devEngines
error EBADDEVENGINES Invalid devEngines.runtime
Expand DownExpand Up@@ -200,8 +200,8 @@ verbose stack Error: The developer of this package has specified the following t
verbose stack Invalid devEngines.runtime
verbose stack Invalid name "nondescript" does not match "node" for "runtime"
verbose stack at Install.checkDevEngines ({CWD}/lib/base-cmd.js:249:27)
verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:281:7)
verbose stack at MockNpm.exec ({CWD}/lib/npm.js:181:9)
verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:294:7)
verbose stack at MockNpm.exec ({CWD}/lib/npm.js:194:9)
error code EBADDEVENGINES
error EBADDEVENGINES The developer of this package has specified the following through devEngines
error EBADDEVENGINES Invalid devEngines.runtime
Expand All@@ -226,8 +226,8 @@ verbose stack Error: The developer of this package has specified the following t
verbose stack Invalid devEngines.runtime
verbose stack Invalid name "nondescript" does not match "node" for "runtime"
verbose stack at Install.checkDevEngines ({CWD}/lib/base-cmd.js:249:27)
verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:281:7)
verbose stack at MockNpm.exec ({CWD}/lib/npm.js:181:9)
verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:294:7)
verbose stack at MockNpm.exec ({CWD}/lib/npm.js:194:9)
error code EBADDEVENGINES
error EBADDEVENGINES The developer of this package has specified the following through devEngines
error EBADDEVENGINES Invalid devEngines.runtime
Expand Down
6 changes: 6 additions & 0 deletions tap-snapshots/test/lib/commands/ls.js.test.cjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -315,6 +315,12 @@ test-npm-ls@1.0.0 {CWD}/prefix
\`-- dog@2.0.0
`

exports[`test/lib/commands/ls.js TAP ls .npm-extension dep > human output annotates the transformed node 1`] = `
test-npm-extension@1.0.0 {CWD}/prefix
\`-- foo@1.0.0 .npm-extension: dependencies.bar
\`-- bar@1.0.0
`

exports[`test/lib/commands/ls.js TAP ls broken resolved field > should NOT print git refs in output tree 1`] = `
npm-broken-resolved-field-test@1.0.0 {CWD}/prefix
\`-- a@1.0.1
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
1 change: 1 addition & 0 deletions docs/lib/build.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -107,6 +107,7 @@ const generateNav = async (contentPath, navPath) => {
'/configuring-npm/npmrc',
'/configuring-npm/package-json',
'/configuring-npm/package-lock-json',
'/configuring-npm/npm-extension',
]

// Hardcoded order for using-npm section (only urls - title/description come from frontmatter)
Expand Down
90 changes: 90 additions & 0 deletions docs/lib/content/configuring-npm/npm-extension.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
---
title: .npm-extension
section: 5
description: Imperative, root-owned manifest repairs
---

### Description

A root-owned `.npm-extension.mjs` or `.npm-extension.cjs` file lets a project imperatively repair the manifests of third-party dependencies before npm resolves the dependency tree. It exports a `transformManifest(pkg, context)` function that receives a candidate dependency manifest and returns the effective manifest npm should use.

`.npm-extension` is the imperative counterpart to the declarative [`packageExtensions`](/configuring-npm/package-json#packageextensions) field, and runs in the same pre-resolution phase, **before** `packageExtensions`. Prefer `packageExtensions` for simple, data-only repairs; reach for `.npm-extension` when you need comments and links explaining a repair, conditional logic, repeated repairs expressed as code, deletion or range rewrites, stale-repair guards, or a policy location outside `package.json`.

### Example

```js
// .npm-extension.mjs
export function transformManifest (pkg, context) {
if (pkg.name === 'foo' && pkg.version.startsWith('1.')) {
pkg.dependencies = { ...pkg.dependencies, bar: '^2.0.0' }
context.log(`added bar to ${pkg.name}@${pkg.version}`)
}
return pkg
}
```

The `.cjs` form uses CommonJS exports instead:

```js
// .npm-extension.cjs
module.exports = {
transformManifest (pkg, context) {
return pkg
},
}
```

### The `transformManifest` function

`transformManifest(pkg, context)` receives a deeply isolated copy of a candidate dependency manifest. It may mutate and return that copy, or return a new manifest object. It **must** return a manifest object synchronously; returning `null`, `undefined`, a primitive, an array, or a promise fails the install.

The `context` argument is intentionally small:

* `context.log(message)` writes an npm debug log message.
* `context.root` is the absolute path to the project root.
* `context.extensionPoint` is the string `"transformManifest"`.

npm provides no registry, fetch, lockfile, or extraction helpers. Keep the extension file self-contained or limited to Node builtins; npm does not guarantee that project dependencies are available when the file is loaded.

### Supported mutations

Only the four resolution-affecting fields may change:

* `dependencies`
* `optionalDependencies`
* `peerDependencies`
* `peerDependenciesMeta`

Within those fields you may add, replace, or delete entries. Changing any other field (such as `scripts`, `bin`, `engines`, `os`, `cpu`, `exports`, or `main`) is rejected, and the install fails with an error naming `.npm-extension` and the package being processed. The package tarball and the installed `node_modules/<pkg>/package.json` are never rewritten.

### Discovery and `extension-file`

npm looks for a single `.npm-extension.mjs` or `.npm-extension.cjs` at the project root (the workspace root in a workspace project). Having both files present is an error. A `.npm-extension` file in a dependency or in a non-root workspace is ignored; a non-root workspace file produces a warning.

The [`extension-file`](/using-npm/config#extension-file) config selects a different project-local file. It must resolve inside the project root and use a `.mjs` or `.cjs` extension, and it is honored only from project config or the command line — never from user, global, or builtin config.

### Interaction with `packageExtensions` and `overrides`

When both are present, `transformManifest` runs first and `packageExtensions` is applied to its output. Avoid targeting the same package with both unless you intend to rely on that ordering. `overrides` still controls the final resolution target of any edge, including edges created by `transformManifest`.

### Lockfile and `npm ci`

A lockfile influenced by `.npm-extension` records an `npmExtensionHash` (a digest of the selected file's bytes and module format) on its root entry, and minimal `npmExtensionApplied` provenance on each affected package entry. Extension state requires `lockfileVersion: 4`.

Changing the file's contents makes `npm install` re-resolve the affected packages. `npm ci` does **not** import or execute `.npm-extension`; it verifies the recorded hash against the file and reifies the locked graph, failing if the file and lockfile disagree (or if one has extension state and the other does not).

The hash proves only that the install uses the same extension file bytes that generated the lockfile. It does not make arbitrary JavaScript deterministic: extension output that depends on environment variables, the network, the clock, or files imported by the extension can still produce non-reproducible installs. Treat `.npm-extension` as trusted, deterministic project code, and only enable it in repositories you trust.

### Disabling

Set [`ignore-extension`](/using-npm/config#ignore-extension) to skip importing and executing `.npm-extension`. [`ignore-scripts`](/using-npm/config#ignore-scripts) implies `ignore-extension`, since both disable root-owned install-time code. `npm ci` still verifies the file hash even when execution is disabled.

### Publishing

`.npm-extension.mjs` and `.npm-extension.cjs` are project configuration, not package contents. npm excludes the root file from the package tarball produced by `npm pack` and `npm publish`, even when the package's `files` list would include it, so a public package can keep `.npm-extension` in its repository for local use without publishing it.

### See also

* [package.json `packageExtensions`](/configuring-npm/package-json#packageextensions)
* [package-lock.json](/configuring-npm/package-lock-json)
* [config](/using-npm/config)
3 changes: 3 additions & 0 deletions docs/lib/content/nav.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -232,6 +232,9 @@
- title: package-lock.json
url: /configuring-npm/package-lock-json
description: A manifestation of the manifest
- title: .npm-extension
url: /configuring-npm/npm-extension
description: Imperative, root-owned manifest repairs
- title: Using npm
shortName: Using
url: /using-npm
Expand Down
15 changes: 14 additions & 1 deletion lib/commands/ci.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,7 +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 { validatePackageExtensions, validateNpmExtension } = require('../utils/validate-lockfile.js')
const ArboristWorkspaceCmd = require('../arborist-cmd.js')
const getWorkspaces = require('../utils/get-workspaces.js')

Expand DownExpand Up@@ -66,6 +66,9 @@ class CI extends ArboristWorkspaceCmd {
save: false, // npm ci should never modify the lockfile or package.json
workspaces: this.workspaceNames,
allowScripts: allowScriptsPolicy,
// npm ci reifies the locked graph, which already carries extension-influenced edges, so it must never import or execute .npm-extension.
// The extension file hash is still validated below, independent of execution.
ignoreExtension: true,
}

// generate an inventory from the virtual tree in the lockfile
Expand All@@ -92,6 +95,16 @@ class CI extends ArboristWorkspaceCmd {
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))
// Verifies that the root .npm-extension file matches the lockfile hash.
// The hash comes from discovering the file (no import or execution), so this holds even under ignore-extension/ignore-scripts.
const { NpmExtension } = require('@npmcli/arborist')
let fileHash = null
try {
fileHash = new NpmExtension({ root: where, extensionFile: opts.extensionFile }).hash
} catch (err) {
errors.push(`Invalid: ${err.message}`)
}
errors.push(...validateNpmExtension(virtualArb.virtualTree, fileHash))
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
17 changes: 13 additions & 4 deletions lib/commands/ls.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -278,8 +278,8 @@ const augmentItemWithIncludeMetadata = (node, item) => {
return item
}

// Render a node's packageExtensions provenance as a short "field.name" list, empty when none.
const formatPackageExtensions = (applied) => {
// Render a manifest-extension provenance object as a short "field.name" list, empty when none.
const formatExtensionApplied = (applied) => {
if (!applied) {
return ''
}
Expand DownExpand Up@@ -354,8 +354,13 @@ const getHumanOutputItem = (node, { args, chalk, global, long }) => {
: ''
) +
(
formatPackageExtensions(node.packageExtensionsApplied)
? ' ' + chalk.dim(`packageExtensions: ${formatPackageExtensions(node.packageExtensionsApplied)}`)
formatExtensionApplied(node.packageExtensionsApplied)
? ' ' + chalk.dim(`packageExtensions: ${formatExtensionApplied(node.packageExtensionsApplied)}`)
: ''
) +
(
formatExtensionApplied(node.npmExtensionApplied)
? ' ' + chalk.dim(`.npm-extension: ${formatExtensionApplied(node.npmExtensionApplied)}`)
: ''
) +
(isGitNode(node) ? ` (${node.resolved})` : '') +
Expand DownExpand Up@@ -386,6 +391,10 @@ const getJsonOutputItem = (node, { global, long }) => {
item.packageExtensionsApplied = node.packageExtensionsApplied
}

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

item[_name] = node.name

// special formatting for top-level package name
Expand Down
13 changes: 13 additions & 0 deletions lib/npm.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,6 +118,19 @@ class Npm {
return { exec: false }
}

// extension-file selects root-owned install-time code, so it is only honored from project config or the command line.
// This is checked after #display.load() so the error is surfaced to the user instead of being swallowed during early config loading.
const extensionFile = this.config.get('extension-file')
if (extensionFile != null) {
const where = this.config.find('extension-file')
if (!['cli', 'project', 'default'].includes(where)) {
throw Object.assign(
new Error(`\`extension-file\` may only be set in project config or on the command line, not from ${where} config`),
{ code: 'ENPMEXTENSIONCONFIG' }
)
}
}

// mkdir this separately since the logs dir can be set to a different location.
// if this fails, then we don't have a cache dir, but we don't want to fail immediately since the command might not need a cache dir (like `npm --version`)
await time.start('npm:load:mkdirpcache', () =>
Expand Down
9 changes: 7 additions & 2 deletions lib/utils/explain-dep.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,7 +76,7 @@ const explainDependents = ({ dependents }, depth, chalk, seen) => {
}

const explainEdge = (
{ name, type, bundled, from, spec, rawSpec, overridden, packageExtensions },
{ name, type, bundled, from, spec, rawSpec, overridden, packageExtensions, npmExtension },
depth, chalk, seen = new Set()
) => {
let dep = type === 'workspace'
Expand All@@ -93,9 +93,14 @@ const explainEdge = (
? chalk.dim(` (added by packageExtensions["${packageExtensions.selector}"].${packageExtensions.field}.${name})`)
: ''

// note an edge created or changed by a root .npm-extension repair
const npmExtMsg = npmExtension
? chalk.dim(` (changed by .npm-extension ${npmExtension.extensionPoint} ${npmExtension.field}.${name})`)
: ''

return (type === 'prod' ? '' : `${colorType(type, chalk)} `) +
(bundled ? `${colorType('bundled', chalk)} ` : '') +
`${dep}${fromMsg}${extMsg}`
`${dep}${fromMsg}${extMsg}${npmExtMsg}`
}

const explainFrom = (from, depth, chalk, seen) => {
Expand Down
28 changes: 28 additions & 0 deletions lib/utils/validate-lockfile.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -98,5 +98,33 @@ function validatePackageExtensions (virtualTree, idealTree) {
return errors
}

// validates that the .npm-extension state recorded in the lockfile still matches the selected extension file.
// Validation is hash-based: arbitrary code has no selector to re-check, so a matching hash is trusted and a mismatch fails.
// fileHash is computed from the on-disk file (discovery only, no execution), so this holds even under ignore-extension/ignore-scripts.
// The lockfile carries extension state if it records a root hash or any per-package npmExtensionApplied provenance.
// Returns an array of human-readable error strings, empty when valid.
function validateNpmExtension (virtualTree, fileHash) {
const lockHash = virtualTree?.meta?.npmExtensionHash || null
const hasProvenance = !!virtualTree &&
[...virtualTree.inventory.values()].some(node => node.npmExtensionApplied)
fileHash = fileHash || null

if (fileHash) {
if (!lockHash) {
return ['Missing: .npm-extension state from lock file']
}
if (lockHash !== fileHash) {
return ['Invalid: .npm-extension file does not match the lock file']
}
return []
}
// no extension file present
if (lockHash || hasProvenance) {
return ['Invalid: lock file records .npm-extension state but no .npm-extension file is present']
}
return []
}

module.exports = validateLockfile
module.exports.validatePackageExtensions = validatePackageExtensions
module.exports.validateNpmExtension = validateNpmExtension
4 changes: 4 additions & 0 deletions tap-snapshots/test/lib/commands/config.js.test.cjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,6 +58,7 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna
"expect-result-count": null,
"expect-results": null,
"expires": null,
"extension-file": null,
"fetch-retries": 2,
"fetch-retry-factor": 10,
"fetch-retry-maxtimeout": 60000,
Expand All@@ -76,6 +77,7 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna
"heading": "npm",
"https-proxy": null,
"if-present": false,
"ignore-extension": false,
"ignore-scripts": false,
"include": [],
"include-staged": false,
Expand DownExpand Up@@ -254,6 +256,7 @@ engine-strict = false
expect-result-count = null
expect-results = null
expires = null
extension-file = null
fetch-retries = 2
fetch-retry-factor = 10
fetch-retry-maxtimeout = 60000
Expand All@@ -273,6 +276,7 @@ heading = "npm"
https-proxy = null
if-present = false
ignore-existing = false
ignore-extension = false
ignore-patch-failures = false
ignore-scripts = false
include = []
Expand Down
12 changes: 6 additions & 6 deletions tap-snapshots/test/lib/commands/install.js.test.cjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -135,8 +135,8 @@ verbose stack Error: The developer of this package has specified the following t
verbose stack Invalid devEngines.runtime
verbose stack Invalid name "nondescript" does not match "node" for "runtime"
verbose stack at Install.checkDevEngines ({CWD}/lib/base-cmd.js:249:27)
verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:281:7)
verbose stack at MockNpm.exec ({CWD}/lib/npm.js:181:9)
verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:294:7)
verbose stack at MockNpm.exec ({CWD}/lib/npm.js:194:9)
error code EBADDEVENGINES
error EBADDEVENGINES The developer of this package has specified the following through devEngines
error EBADDEVENGINES Invalid devEngines.runtime
Expand DownExpand Up@@ -200,8 +200,8 @@ verbose stack Error: The developer of this package has specified the following t
verbose stack Invalid devEngines.runtime
verbose stack Invalid name "nondescript" does not match "node" for "runtime"
verbose stack at Install.checkDevEngines ({CWD}/lib/base-cmd.js:249:27)
verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:281:7)
verbose stack at MockNpm.exec ({CWD}/lib/npm.js:181:9)
verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:294:7)
verbose stack at MockNpm.exec ({CWD}/lib/npm.js:194:9)
error code EBADDEVENGINES
error EBADDEVENGINES The developer of this package has specified the following through devEngines
error EBADDEVENGINES Invalid devEngines.runtime
Expand All@@ -226,8 +226,8 @@ verbose stack Error: The developer of this package has specified the following t
verbose stack Invalid devEngines.runtime
verbose stack Invalid name "nondescript" does not match "node" for "runtime"
verbose stack at Install.checkDevEngines ({CWD}/lib/base-cmd.js:249:27)
verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:281:7)
verbose stack at MockNpm.exec ({CWD}/lib/npm.js:181:9)
verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:294:7)
verbose stack at MockNpm.exec ({CWD}/lib/npm.js:194:9)
error code EBADDEVENGINES
error EBADDEVENGINES The developer of this package has specified the following through devEngines
error EBADDEVENGINES Invalid devEngines.runtime
Expand Down
6 changes: 6 additions & 0 deletions tap-snapshots/test/lib/commands/ls.js.test.cjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -315,6 +315,12 @@ test-npm-ls@1.0.0 {CWD}/prefix
\`-- dog@2.0.0
`

exports[`test/lib/commands/ls.js TAP ls .npm-extension dep > human output annotates the transformed node 1`] = `
test-npm-extension@1.0.0 {CWD}/prefix
\`-- foo@1.0.0 .npm-extension: dependencies.bar
\`-- bar@1.0.0
`

exports[`test/lib/commands/ls.js TAP ls broken resolved field > should NOT print git refs in output tree 1`] = `
npm-broken-resolved-field-test@1.0.0 {CWD}/prefix
\`-- a@1.0.1
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
1 change: 1 addition & 0 deletions docs/lib/build.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -107,6 +107,7 @@ const generateNav = async (contentPath, navPath) => {
'/configuring-npm/npmrc',
'/configuring-npm/package-json',
'/configuring-npm/package-lock-json',
'/configuring-npm/npm-extension',
]

// Hardcoded order for using-npm section (only urls - title/description come from frontmatter)
Expand Down
90 changes: 90 additions & 0 deletions docs/lib/content/configuring-npm/npm-extension.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
---
title: .npm-extension
section: 5
description: Imperative, root-owned manifest repairs
---

### Description

A root-owned `.npm-extension.mjs` or `.npm-extension.cjs` file lets a project imperatively repair the manifests of third-party dependencies before npm resolves the dependency tree. It exports a `transformManifest(pkg, context)` function that receives a candidate dependency manifest and returns the effective manifest npm should use.

`.npm-extension` is the imperative counterpart to the declarative [`packageExtensions`](/configuring-npm/package-json#packageextensions) field, and runs in the same pre-resolution phase, **before** `packageExtensions`. Prefer `packageExtensions` for simple, data-only repairs; reach for `.npm-extension` when you need comments and links explaining a repair, conditional logic, repeated repairs expressed as code, deletion or range rewrites, stale-repair guards, or a policy location outside `package.json`.

### Example

```js
// .npm-extension.mjs
export function transformManifest (pkg, context) {
if (pkg.name === 'foo' && pkg.version.startsWith('1.')) {
pkg.dependencies = { ...pkg.dependencies, bar: '^2.0.0' }
context.log(`added bar to ${pkg.name}@${pkg.version}`)
}
return pkg
}
```

The `.cjs` form uses CommonJS exports instead:

```js
// .npm-extension.cjs
module.exports = {
transformManifest (pkg, context) {
return pkg
},
}
```

### The `transformManifest` function

`transformManifest(pkg, context)` receives a deeply isolated copy of a candidate dependency manifest. It may mutate and return that copy, or return a new manifest object. It **must** return a manifest object synchronously; returning `null`, `undefined`, a primitive, an array, or a promise fails the install.

The `context` argument is intentionally small:

* `context.log(message)` writes an npm debug log message.
* `context.root` is the absolute path to the project root.
* `context.extensionPoint` is the string `"transformManifest"`.

npm provides no registry, fetch, lockfile, or extraction helpers. Keep the extension file self-contained or limited to Node builtins; npm does not guarantee that project dependencies are available when the file is loaded.

### Supported mutations

Only the four resolution-affecting fields may change:

* `dependencies`
* `optionalDependencies`
* `peerDependencies`
* `peerDependenciesMeta`

Within those fields you may add, replace, or delete entries. Changing any other field (such as `scripts`, `bin`, `engines`, `os`, `cpu`, `exports`, or `main`) is rejected, and the install fails with an error naming `.npm-extension` and the package being processed. The package tarball and the installed `node_modules/<pkg>/package.json` are never rewritten.

### Discovery and `extension-file`

npm looks for a single `.npm-extension.mjs` or `.npm-extension.cjs` at the project root (the workspace root in a workspace project). Having both files present is an error. A `.npm-extension` file in a dependency or in a non-root workspace is ignored; a non-root workspace file produces a warning.

The [`extension-file`](/using-npm/config#extension-file) config selects a different project-local file. It must resolve inside the project root and use a `.mjs` or `.cjs` extension, and it is honored only from project config or the command line — never from user, global, or builtin config.

### Interaction with `packageExtensions` and `overrides`

When both are present, `transformManifest` runs first and `packageExtensions` is applied to its output. Avoid targeting the same package with both unless you intend to rely on that ordering. `overrides` still controls the final resolution target of any edge, including edges created by `transformManifest`.

### Lockfile and `npm ci`

A lockfile influenced by `.npm-extension` records an `npmExtensionHash` (a digest of the selected file's bytes and module format) on its root entry, and minimal `npmExtensionApplied` provenance on each affected package entry. Extension state requires `lockfileVersion: 4`.

Changing the file's contents makes `npm install` re-resolve the affected packages. `npm ci` does **not** import or execute `.npm-extension`; it verifies the recorded hash against the file and reifies the locked graph, failing if the file and lockfile disagree (or if one has extension state and the other does not).

The hash proves only that the install uses the same extension file bytes that generated the lockfile. It does not make arbitrary JavaScript deterministic: extension output that depends on environment variables, the network, the clock, or files imported by the extension can still produce non-reproducible installs. Treat `.npm-extension` as trusted, deterministic project code, and only enable it in repositories you trust.

### Disabling

Set [`ignore-extension`](/using-npm/config#ignore-extension) to skip importing and executing `.npm-extension`. [`ignore-scripts`](/using-npm/config#ignore-scripts) implies `ignore-extension`, since both disable root-owned install-time code. `npm ci` still verifies the file hash even when execution is disabled.

### Publishing

`.npm-extension.mjs` and `.npm-extension.cjs` are project configuration, not package contents. npm excludes the root file from the package tarball produced by `npm pack` and `npm publish`, even when the package's `files` list would include it, so a public package can keep `.npm-extension` in its repository for local use without publishing it.

### See also

* [package.json `packageExtensions`](/configuring-npm/package-json#packageextensions)
* [package-lock.json](/configuring-npm/package-lock-json)
* [config](/using-npm/config)
3 changes: 3 additions & 0 deletions docs/lib/content/nav.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -232,6 +232,9 @@
- title: package-lock.json
url: /configuring-npm/package-lock-json
description: A manifestation of the manifest
- title: .npm-extension
url: /configuring-npm/npm-extension
description: Imperative, root-owned manifest repairs
- title: Using npm
shortName: Using
url: /using-npm
Expand Down
15 changes: 14 additions & 1 deletion lib/commands/ci.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,7 +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 { validatePackageExtensions, validateNpmExtension } = require('../utils/validate-lockfile.js')
const ArboristWorkspaceCmd = require('../arborist-cmd.js')
const getWorkspaces = require('../utils/get-workspaces.js')

Expand DownExpand Up@@ -66,6 +66,9 @@ class CI extends ArboristWorkspaceCmd {
save: false, // npm ci should never modify the lockfile or package.json
workspaces: this.workspaceNames,
allowScripts: allowScriptsPolicy,
// npm ci reifies the locked graph, which already carries extension-influenced edges, so it must never import or execute .npm-extension.
// The extension file hash is still validated below, independent of execution.
ignoreExtension: true,
}

// generate an inventory from the virtual tree in the lockfile
Expand All@@ -92,6 +95,16 @@ class CI extends ArboristWorkspaceCmd {
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))
// Verifies that the root .npm-extension file matches the lockfile hash.
// The hash comes from discovering the file (no import or execution), so this holds even under ignore-extension/ignore-scripts.
const { NpmExtension } = require('@npmcli/arborist')
let fileHash = null
try {
fileHash = new NpmExtension({ root: where, extensionFile: opts.extensionFile }).hash
} catch (err) {
errors.push(`Invalid: ${err.message}`)
}
errors.push(...validateNpmExtension(virtualArb.virtualTree, fileHash))
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
17 changes: 13 additions & 4 deletions lib/commands/ls.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -278,8 +278,8 @@ const augmentItemWithIncludeMetadata = (node, item) => {
return item
}

// Render a node's packageExtensions provenance as a short "field.name" list, empty when none.
const formatPackageExtensions = (applied) => {
// Render a manifest-extension provenance object as a short "field.name" list, empty when none.
const formatExtensionApplied = (applied) => {
if (!applied) {
return ''
}
Expand DownExpand Up@@ -354,8 +354,13 @@ const getHumanOutputItem = (node, { args, chalk, global, long }) => {
: ''
) +
(
formatPackageExtensions(node.packageExtensionsApplied)
? ' ' + chalk.dim(`packageExtensions: ${formatPackageExtensions(node.packageExtensionsApplied)}`)
formatExtensionApplied(node.packageExtensionsApplied)
? ' ' + chalk.dim(`packageExtensions: ${formatExtensionApplied(node.packageExtensionsApplied)}`)
: ''
) +
(
formatExtensionApplied(node.npmExtensionApplied)
? ' ' + chalk.dim(`.npm-extension: ${formatExtensionApplied(node.npmExtensionApplied)}`)
: ''
) +
(isGitNode(node) ? ` (${node.resolved})` : '') +
Expand DownExpand Up@@ -386,6 +391,10 @@ const getJsonOutputItem = (node, { global, long }) => {
item.packageExtensionsApplied = node.packageExtensionsApplied
}

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

item[_name] = node.name

// special formatting for top-level package name
Expand Down
13 changes: 13 additions & 0 deletions lib/npm.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,6 +118,19 @@ class Npm {
return { exec: false }
}

// extension-file selects root-owned install-time code, so it is only honored from project config or the command line.
// This is checked after #display.load() so the error is surfaced to the user instead of being swallowed during early config loading.
const extensionFile = this.config.get('extension-file')
if (extensionFile != null) {
const where = this.config.find('extension-file')
if (!['cli', 'project', 'default'].includes(where)) {
throw Object.assign(
new Error(`\`extension-file\` may only be set in project config or on the command line, not from ${where} config`),
{ code: 'ENPMEXTENSIONCONFIG' }
)
}
}

// mkdir this separately since the logs dir can be set to a different location.
// if this fails, then we don't have a cache dir, but we don't want to fail immediately since the command might not need a cache dir (like `npm --version`)
await time.start('npm:load:mkdirpcache', () =>
Expand Down
9 changes: 7 additions & 2 deletions lib/utils/explain-dep.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,7 +76,7 @@ const explainDependents = ({ dependents }, depth, chalk, seen) => {
}

const explainEdge = (
{ name, type, bundled, from, spec, rawSpec, overridden, packageExtensions },
{ name, type, bundled, from, spec, rawSpec, overridden, packageExtensions, npmExtension },
depth, chalk, seen = new Set()
) => {
let dep = type === 'workspace'
Expand All@@ -93,9 +93,14 @@ const explainEdge = (
? chalk.dim(` (added by packageExtensions["${packageExtensions.selector}"].${packageExtensions.field}.${name})`)
: ''

// note an edge created or changed by a root .npm-extension repair
const npmExtMsg = npmExtension
? chalk.dim(` (changed by .npm-extension ${npmExtension.extensionPoint} ${npmExtension.field}.${name})`)
: ''

return (type === 'prod' ? '' : `${colorType(type, chalk)} `) +
(bundled ? `${colorType('bundled', chalk)} ` : '') +
`${dep}${fromMsg}${extMsg}`
`${dep}${fromMsg}${extMsg}${npmExtMsg}`
}

const explainFrom = (from, depth, chalk, seen) => {
Expand Down
28 changes: 28 additions & 0 deletions lib/utils/validate-lockfile.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -98,5 +98,33 @@ function validatePackageExtensions (virtualTree, idealTree) {
return errors
}

// validates that the .npm-extension state recorded in the lockfile still matches the selected extension file.
// Validation is hash-based: arbitrary code has no selector to re-check, so a matching hash is trusted and a mismatch fails.
// fileHash is computed from the on-disk file (discovery only, no execution), so this holds even under ignore-extension/ignore-scripts.
// The lockfile carries extension state if it records a root hash or any per-package npmExtensionApplied provenance.
// Returns an array of human-readable error strings, empty when valid.
function validateNpmExtension (virtualTree, fileHash) {
const lockHash = virtualTree?.meta?.npmExtensionHash || null
const hasProvenance = !!virtualTree &&
[...virtualTree.inventory.values()].some(node => node.npmExtensionApplied)
fileHash = fileHash || null

if (fileHash) {
if (!lockHash) {
return ['Missing: .npm-extension state from lock file']
}
if (lockHash !== fileHash) {
return ['Invalid: .npm-extension file does not match the lock file']
}
return []
}
// no extension file present
if (lockHash || hasProvenance) {
return ['Invalid: lock file records .npm-extension state but no .npm-extension file is present']
}
return []
}

module.exports = validateLockfile
module.exports.validatePackageExtensions = validatePackageExtensions
module.exports.validateNpmExtension = validateNpmExtension
4 changes: 4 additions & 0 deletions tap-snapshots/test/lib/commands/config.js.test.cjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,6 +58,7 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna
"expect-result-count": null,
"expect-results": null,
"expires": null,
"extension-file": null,
"fetch-retries": 2,
"fetch-retry-factor": 10,
"fetch-retry-maxtimeout": 60000,
Expand All@@ -76,6 +77,7 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna
"heading": "npm",
"https-proxy": null,
"if-present": false,
"ignore-extension": false,
"ignore-scripts": false,
"include": [],
"include-staged": false,
Expand DownExpand Up@@ -254,6 +256,7 @@ engine-strict = false
expect-result-count = null
expect-results = null
expires = null
extension-file = null
fetch-retries = 2
fetch-retry-factor = 10
fetch-retry-maxtimeout = 60000
Expand All@@ -273,6 +276,7 @@ heading = "npm"
https-proxy = null
if-present = false
ignore-existing = false
ignore-extension = false
ignore-patch-failures = false
ignore-scripts = false
include = []
Expand Down
12 changes: 6 additions & 6 deletions tap-snapshots/test/lib/commands/install.js.test.cjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -135,8 +135,8 @@ verbose stack Error: The developer of this package has specified the following t
verbose stack Invalid devEngines.runtime
verbose stack Invalid name "nondescript" does not match "node" for "runtime"
verbose stack at Install.checkDevEngines ({CWD}/lib/base-cmd.js:249:27)
verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:281:7)
verbose stack at MockNpm.exec ({CWD}/lib/npm.js:181:9)
verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:294:7)
verbose stack at MockNpm.exec ({CWD}/lib/npm.js:194:9)
error code EBADDEVENGINES
error EBADDEVENGINES The developer of this package has specified the following through devEngines
error EBADDEVENGINES Invalid devEngines.runtime
Expand DownExpand Up@@ -200,8 +200,8 @@ verbose stack Error: The developer of this package has specified the following t
verbose stack Invalid devEngines.runtime
verbose stack Invalid name "nondescript" does not match "node" for "runtime"
verbose stack at Install.checkDevEngines ({CWD}/lib/base-cmd.js:249:27)
verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:281:7)
verbose stack at MockNpm.exec ({CWD}/lib/npm.js:181:9)
verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:294:7)
verbose stack at MockNpm.exec ({CWD}/lib/npm.js:194:9)
error code EBADDEVENGINES
error EBADDEVENGINES The developer of this package has specified the following through devEngines
error EBADDEVENGINES Invalid devEngines.runtime
Expand All@@ -226,8 +226,8 @@ verbose stack Error: The developer of this package has specified the following t
verbose stack Invalid devEngines.runtime
verbose stack Invalid name "nondescript" does not match "node" for "runtime"
verbose stack at Install.checkDevEngines ({CWD}/lib/base-cmd.js:249:27)
verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:281:7)
verbose stack at MockNpm.exec ({CWD}/lib/npm.js:181:9)
verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:294:7)
verbose stack at MockNpm.exec ({CWD}/lib/npm.js:194:9)
error code EBADDEVENGINES
error EBADDEVENGINES The developer of this package has specified the following through devEngines
error EBADDEVENGINES Invalid devEngines.runtime
Expand Down
6 changes: 6 additions & 0 deletions tap-snapshots/test/lib/commands/ls.js.test.cjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -315,6 +315,12 @@ test-npm-ls@1.0.0 {CWD}/prefix
\`-- dog@2.0.0
`

exports[`test/lib/commands/ls.js TAP ls .npm-extension dep > human output annotates the transformed node 1`] = `
test-npm-extension@1.0.0 {CWD}/prefix
\`-- foo@1.0.0 .npm-extension: dependencies.bar
\`-- bar@1.0.0
`

exports[`test/lib/commands/ls.js TAP ls broken resolved field > should NOT print git refs in output tree 1`] = `
npm-broken-resolved-field-test@1.0.0 {CWD}/prefix
\`-- a@1.0.1
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
1 change: 1 addition & 0 deletions docs/lib/build.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -107,6 +107,7 @@ const generateNav = async (contentPath, navPath) => {
'/configuring-npm/npmrc',
'/configuring-npm/package-json',
'/configuring-npm/package-lock-json',
'/configuring-npm/npm-extension',
]

// Hardcoded order for using-npm section (only urls - title/description come from frontmatter)
Expand Down
90 changes: 90 additions & 0 deletions docs/lib/content/configuring-npm/npm-extension.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
---
title: .npm-extension
section: 5
description: Imperative, root-owned manifest repairs
---

### Description

A root-owned `.npm-extension.mjs` or `.npm-extension.cjs` file lets a project imperatively repair the manifests of third-party dependencies before npm resolves the dependency tree. It exports a `transformManifest(pkg, context)` function that receives a candidate dependency manifest and returns the effective manifest npm should use.

`.npm-extension` is the imperative counterpart to the declarative [`packageExtensions`](/configuring-npm/package-json#packageextensions) field, and runs in the same pre-resolution phase, **before** `packageExtensions`. Prefer `packageExtensions` for simple, data-only repairs; reach for `.npm-extension` when you need comments and links explaining a repair, conditional logic, repeated repairs expressed as code, deletion or range rewrites, stale-repair guards, or a policy location outside `package.json`.

### Example

```js
// .npm-extension.mjs
export function transformManifest (pkg, context) {
if (pkg.name === 'foo' && pkg.version.startsWith('1.')) {
pkg.dependencies = { ...pkg.dependencies, bar: '^2.0.0' }
context.log(`added bar to ${pkg.name}@${pkg.version}`)
}
return pkg
}
```

The `.cjs` form uses CommonJS exports instead:

```js
// .npm-extension.cjs
module.exports = {
transformManifest (pkg, context) {
return pkg
},
}
```

### The `transformManifest` function

`transformManifest(pkg, context)` receives a deeply isolated copy of a candidate dependency manifest. It may mutate and return that copy, or return a new manifest object. It **must** return a manifest object synchronously; returning `null`, `undefined`, a primitive, an array, or a promise fails the install.

The `context` argument is intentionally small:

* `context.log(message)` writes an npm debug log message.
* `context.root` is the absolute path to the project root.
* `context.extensionPoint` is the string `"transformManifest"`.

npm provides no registry, fetch, lockfile, or extraction helpers. Keep the extension file self-contained or limited to Node builtins; npm does not guarantee that project dependencies are available when the file is loaded.

### Supported mutations

Only the four resolution-affecting fields may change:

* `dependencies`
* `optionalDependencies`
* `peerDependencies`
* `peerDependenciesMeta`

Within those fields you may add, replace, or delete entries. Changing any other field (such as `scripts`, `bin`, `engines`, `os`, `cpu`, `exports`, or `main`) is rejected, and the install fails with an error naming `.npm-extension` and the package being processed. The package tarball and the installed `node_modules/<pkg>/package.json` are never rewritten.

### Discovery and `extension-file`

npm looks for a single `.npm-extension.mjs` or `.npm-extension.cjs` at the project root (the workspace root in a workspace project). Having both files present is an error. A `.npm-extension` file in a dependency or in a non-root workspace is ignored; a non-root workspace file produces a warning.

The [`extension-file`](/using-npm/config#extension-file) config selects a different project-local file. It must resolve inside the project root and use a `.mjs` or `.cjs` extension, and it is honored only from project config or the command line — never from user, global, or builtin config.

### Interaction with `packageExtensions` and `overrides`

When both are present, `transformManifest` runs first and `packageExtensions` is applied to its output. Avoid targeting the same package with both unless you intend to rely on that ordering. `overrides` still controls the final resolution target of any edge, including edges created by `transformManifest`.

### Lockfile and `npm ci`

A lockfile influenced by `.npm-extension` records an `npmExtensionHash` (a digest of the selected file's bytes and module format) on its root entry, and minimal `npmExtensionApplied` provenance on each affected package entry. Extension state requires `lockfileVersion: 4`.

Changing the file's contents makes `npm install` re-resolve the affected packages. `npm ci` does **not** import or execute `.npm-extension`; it verifies the recorded hash against the file and reifies the locked graph, failing if the file and lockfile disagree (or if one has extension state and the other does not).

The hash proves only that the install uses the same extension file bytes that generated the lockfile. It does not make arbitrary JavaScript deterministic: extension output that depends on environment variables, the network, the clock, or files imported by the extension can still produce non-reproducible installs. Treat `.npm-extension` as trusted, deterministic project code, and only enable it in repositories you trust.

### Disabling

Set [`ignore-extension`](/using-npm/config#ignore-extension) to skip importing and executing `.npm-extension`. [`ignore-scripts`](/using-npm/config#ignore-scripts) implies `ignore-extension`, since both disable root-owned install-time code. `npm ci` still verifies the file hash even when execution is disabled.

### Publishing

`.npm-extension.mjs` and `.npm-extension.cjs` are project configuration, not package contents. npm excludes the root file from the package tarball produced by `npm pack` and `npm publish`, even when the package's `files` list would include it, so a public package can keep `.npm-extension` in its repository for local use without publishing it.

### See also

* [package.json `packageExtensions`](/configuring-npm/package-json#packageextensions)
* [package-lock.json](/configuring-npm/package-lock-json)
* [config](/using-npm/config)
3 changes: 3 additions & 0 deletions docs/lib/content/nav.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -232,6 +232,9 @@
- title: package-lock.json
url: /configuring-npm/package-lock-json
description: A manifestation of the manifest
- title: .npm-extension
url: /configuring-npm/npm-extension
description: Imperative, root-owned manifest repairs
- title: Using npm
shortName: Using
url: /using-npm
Expand Down
15 changes: 14 additions & 1 deletion lib/commands/ci.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,7 +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 { validatePackageExtensions, validateNpmExtension } = require('../utils/validate-lockfile.js')
const ArboristWorkspaceCmd = require('../arborist-cmd.js')
const getWorkspaces = require('../utils/get-workspaces.js')

Expand DownExpand Up@@ -66,6 +66,9 @@ class CI extends ArboristWorkspaceCmd {
save: false, // npm ci should never modify the lockfile or package.json
workspaces: this.workspaceNames,
allowScripts: allowScriptsPolicy,
// npm ci reifies the locked graph, which already carries extension-influenced edges, so it must never import or execute .npm-extension.
// The extension file hash is still validated below, independent of execution.
ignoreExtension: true,
}

// generate an inventory from the virtual tree in the lockfile
Expand All@@ -92,6 +95,16 @@ class CI extends ArboristWorkspaceCmd {
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))
// Verifies that the root .npm-extension file matches the lockfile hash.
// The hash comes from discovering the file (no import or execution), so this holds even under ignore-extension/ignore-scripts.
const { NpmExtension } = require('@npmcli/arborist')
let fileHash = null
try {
fileHash = new NpmExtension({ root: where, extensionFile: opts.extensionFile }).hash
} catch (err) {
errors.push(`Invalid: ${err.message}`)
}
errors.push(...validateNpmExtension(virtualArb.virtualTree, fileHash))
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
17 changes: 13 additions & 4 deletions lib/commands/ls.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -278,8 +278,8 @@ const augmentItemWithIncludeMetadata = (node, item) => {
return item
}

// Render a node's packageExtensions provenance as a short "field.name" list, empty when none.
const formatPackageExtensions = (applied) => {
// Render a manifest-extension provenance object as a short "field.name" list, empty when none.
const formatExtensionApplied = (applied) => {
if (!applied) {
return ''
}
Expand DownExpand Up@@ -354,8 +354,13 @@ const getHumanOutputItem = (node, { args, chalk, global, long }) => {
: ''
) +
(
formatPackageExtensions(node.packageExtensionsApplied)
? ' ' + chalk.dim(`packageExtensions: ${formatPackageExtensions(node.packageExtensionsApplied)}`)
formatExtensionApplied(node.packageExtensionsApplied)
? ' ' + chalk.dim(`packageExtensions: ${formatExtensionApplied(node.packageExtensionsApplied)}`)
: ''
) +
(
formatExtensionApplied(node.npmExtensionApplied)
? ' ' + chalk.dim(`.npm-extension: ${formatExtensionApplied(node.npmExtensionApplied)}`)
: ''
) +
(isGitNode(node) ? ` (${node.resolved})` : '') +
Expand DownExpand Up@@ -386,6 +391,10 @@ const getJsonOutputItem = (node, { global, long }) => {
item.packageExtensionsApplied = node.packageExtensionsApplied
}

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

item[_name] = node.name

// special formatting for top-level package name
Expand Down
13 changes: 13 additions & 0 deletions lib/npm.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,6 +118,19 @@ class Npm {
return { exec: false }
}

// extension-file selects root-owned install-time code, so it is only honored from project config or the command line.
// This is checked after #display.load() so the error is surfaced to the user instead of being swallowed during early config loading.
const extensionFile = this.config.get('extension-file')
if (extensionFile != null) {
const where = this.config.find('extension-file')
if (!['cli', 'project', 'default'].includes(where)) {
throw Object.assign(
new Error(`\`extension-file\` may only be set in project config or on the command line, not from ${where} config`),
{ code: 'ENPMEXTENSIONCONFIG' }
)
}
}

// mkdir this separately since the logs dir can be set to a different location.
// if this fails, then we don't have a cache dir, but we don't want to fail immediately since the command might not need a cache dir (like `npm --version`)
await time.start('npm:load:mkdirpcache', () =>
Expand Down
9 changes: 7 additions & 2 deletions lib/utils/explain-dep.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,7 +76,7 @@ const explainDependents = ({ dependents }, depth, chalk, seen) => {
}

const explainEdge = (
{ name, type, bundled, from, spec, rawSpec, overridden, packageExtensions },
{ name, type, bundled, from, spec, rawSpec, overridden, packageExtensions, npmExtension },
depth, chalk, seen = new Set()
) => {
let dep = type === 'workspace'
Expand All@@ -93,9 +93,14 @@ const explainEdge = (
? chalk.dim(` (added by packageExtensions["${packageExtensions.selector}"].${packageExtensions.field}.${name})`)
: ''

// note an edge created or changed by a root .npm-extension repair
const npmExtMsg = npmExtension
? chalk.dim(` (changed by .npm-extension ${npmExtension.extensionPoint} ${npmExtension.field}.${name})`)
: ''

return (type === 'prod' ? '' : `${colorType(type, chalk)} `) +
(bundled ? `${colorType('bundled', chalk)} ` : '') +
`${dep}${fromMsg}${extMsg}`
`${dep}${fromMsg}${extMsg}${npmExtMsg}`
}

const explainFrom = (from, depth, chalk, seen) => {
Expand Down
28 changes: 28 additions & 0 deletions lib/utils/validate-lockfile.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -98,5 +98,33 @@ function validatePackageExtensions (virtualTree, idealTree) {
return errors
}

// validates that the .npm-extension state recorded in the lockfile still matches the selected extension file.
// Validation is hash-based: arbitrary code has no selector to re-check, so a matching hash is trusted and a mismatch fails.
// fileHash is computed from the on-disk file (discovery only, no execution), so this holds even under ignore-extension/ignore-scripts.
// The lockfile carries extension state if it records a root hash or any per-package npmExtensionApplied provenance.
// Returns an array of human-readable error strings, empty when valid.
function validateNpmExtension (virtualTree, fileHash) {
const lockHash = virtualTree?.meta?.npmExtensionHash || null
const hasProvenance = !!virtualTree &&
[...virtualTree.inventory.values()].some(node => node.npmExtensionApplied)
fileHash = fileHash || null

if (fileHash) {
if (!lockHash) {
return ['Missing: .npm-extension state from lock file']
}
if (lockHash !== fileHash) {
return ['Invalid: .npm-extension file does not match the lock file']
}
return []
}
// no extension file present
if (lockHash || hasProvenance) {
return ['Invalid: lock file records .npm-extension state but no .npm-extension file is present']
}
return []
}

module.exports = validateLockfile
module.exports.validatePackageExtensions = validatePackageExtensions
module.exports.validateNpmExtension = validateNpmExtension
4 changes: 4 additions & 0 deletions tap-snapshots/test/lib/commands/config.js.test.cjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,6 +58,7 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna
"expect-result-count": null,
"expect-results": null,
"expires": null,
"extension-file": null,
"fetch-retries": 2,
"fetch-retry-factor": 10,
"fetch-retry-maxtimeout": 60000,
Expand All@@ -76,6 +77,7 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna
"heading": "npm",
"https-proxy": null,
"if-present": false,
"ignore-extension": false,
"ignore-scripts": false,
"include": [],
"include-staged": false,
Expand DownExpand Up@@ -254,6 +256,7 @@ engine-strict = false
expect-result-count = null
expect-results = null
expires = null
extension-file = null
fetch-retries = 2
fetch-retry-factor = 10
fetch-retry-maxtimeout = 60000
Expand All@@ -273,6 +276,7 @@ heading = "npm"
https-proxy = null
if-present = false
ignore-existing = false
ignore-extension = false
ignore-patch-failures = false
ignore-scripts = false
include = []
Expand Down
12 changes: 6 additions & 6 deletions tap-snapshots/test/lib/commands/install.js.test.cjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -135,8 +135,8 @@ verbose stack Error: The developer of this package has specified the following t
verbose stack Invalid devEngines.runtime
verbose stack Invalid name "nondescript" does not match "node" for "runtime"
verbose stack at Install.checkDevEngines ({CWD}/lib/base-cmd.js:249:27)
verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:281:7)
verbose stack at MockNpm.exec ({CWD}/lib/npm.js:181:9)
verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:294:7)
verbose stack at MockNpm.exec ({CWD}/lib/npm.js:194:9)
error code EBADDEVENGINES
error EBADDEVENGINES The developer of this package has specified the following through devEngines
error EBADDEVENGINES Invalid devEngines.runtime
Expand DownExpand Up@@ -200,8 +200,8 @@ verbose stack Error: The developer of this package has specified the following t
verbose stack Invalid devEngines.runtime
verbose stack Invalid name "nondescript" does not match "node" for "runtime"
verbose stack at Install.checkDevEngines ({CWD}/lib/base-cmd.js:249:27)
verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:281:7)
verbose stack at MockNpm.exec ({CWD}/lib/npm.js:181:9)
verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:294:7)
verbose stack at MockNpm.exec ({CWD}/lib/npm.js:194:9)
error code EBADDEVENGINES
error EBADDEVENGINES The developer of this package has specified the following through devEngines
error EBADDEVENGINES Invalid devEngines.runtime
Expand All@@ -226,8 +226,8 @@ verbose stack Error: The developer of this package has specified the following t
verbose stack Invalid devEngines.runtime
verbose stack Invalid name "nondescript" does not match "node" for "runtime"
verbose stack at Install.checkDevEngines ({CWD}/lib/base-cmd.js:249:27)
verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:281:7)
verbose stack at MockNpm.exec ({CWD}/lib/npm.js:181:9)
verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:294:7)
verbose stack at MockNpm.exec ({CWD}/lib/npm.js:194:9)
error code EBADDEVENGINES
error EBADDEVENGINES The developer of this package has specified the following through devEngines
error EBADDEVENGINES Invalid devEngines.runtime
Expand Down
6 changes: 6 additions & 0 deletions tap-snapshots/test/lib/commands/ls.js.test.cjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -315,6 +315,12 @@ test-npm-ls@1.0.0 {CWD}/prefix
\`-- dog@2.0.0
`

exports[`test/lib/commands/ls.js TAP ls .npm-extension dep > human output annotates the transformed node 1`] = `
test-npm-extension@1.0.0 {CWD}/prefix
\`-- foo@1.0.0 .npm-extension: dependencies.bar
\`-- bar@1.0.0
`

exports[`test/lib/commands/ls.js TAP ls broken resolved field > should NOT print git refs in output tree 1`] = `
npm-broken-resolved-field-test@1.0.0 {CWD}/prefix
\`-- a@1.0.1
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
1 change: 1 addition & 0 deletions docs/lib/build.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -107,6 +107,7 @@ const generateNav = async (contentPath, navPath) => {
'/configuring-npm/npmrc',
'/configuring-npm/package-json',
'/configuring-npm/package-lock-json',
'/configuring-npm/npm-extension',
]

// Hardcoded order for using-npm section (only urls - title/description come from frontmatter)
Expand Down
90 changes: 90 additions & 0 deletions docs/lib/content/configuring-npm/npm-extension.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
---
title: .npm-extension
section: 5
description: Imperative, root-owned manifest repairs
---

### Description

A root-owned `.npm-extension.mjs` or `.npm-extension.cjs` file lets a project imperatively repair the manifests of third-party dependencies before npm resolves the dependency tree. It exports a `transformManifest(pkg, context)` function that receives a candidate dependency manifest and returns the effective manifest npm should use.

`.npm-extension` is the imperative counterpart to the declarative [`packageExtensions`](/configuring-npm/package-json#packageextensions) field, and runs in the same pre-resolution phase, **before** `packageExtensions`. Prefer `packageExtensions` for simple, data-only repairs; reach for `.npm-extension` when you need comments and links explaining a repair, conditional logic, repeated repairs expressed as code, deletion or range rewrites, stale-repair guards, or a policy location outside `package.json`.

### Example

```js
// .npm-extension.mjs
export function transformManifest (pkg, context) {
if (pkg.name === 'foo' && pkg.version.startsWith('1.')) {
pkg.dependencies = { ...pkg.dependencies, bar: '^2.0.0' }
context.log(`added bar to ${pkg.name}@${pkg.version}`)
}
return pkg
}
```

The `.cjs` form uses CommonJS exports instead:

```js
// .npm-extension.cjs
module.exports = {
transformManifest (pkg, context) {
return pkg
},
}
```

### The `transformManifest` function

`transformManifest(pkg, context)` receives a deeply isolated copy of a candidate dependency manifest. It may mutate and return that copy, or return a new manifest object. It **must** return a manifest object synchronously; returning `null`, `undefined`, a primitive, an array, or a promise fails the install.

The `context` argument is intentionally small:

* `context.log(message)` writes an npm debug log message.
* `context.root` is the absolute path to the project root.
* `context.extensionPoint` is the string `"transformManifest"`.

npm provides no registry, fetch, lockfile, or extraction helpers. Keep the extension file self-contained or limited to Node builtins; npm does not guarantee that project dependencies are available when the file is loaded.

### Supported mutations

Only the four resolution-affecting fields may change:

* `dependencies`
* `optionalDependencies`
* `peerDependencies`
* `peerDependenciesMeta`

Within those fields you may add, replace, or delete entries. Changing any other field (such as `scripts`, `bin`, `engines`, `os`, `cpu`, `exports`, or `main`) is rejected, and the install fails with an error naming `.npm-extension` and the package being processed. The package tarball and the installed `node_modules/<pkg>/package.json` are never rewritten.

### Discovery and `extension-file`

npm looks for a single `.npm-extension.mjs` or `.npm-extension.cjs` at the project root (the workspace root in a workspace project). Having both files present is an error. A `.npm-extension` file in a dependency or in a non-root workspace is ignored; a non-root workspace file produces a warning.

The [`extension-file`](/using-npm/config#extension-file) config selects a different project-local file. It must resolve inside the project root and use a `.mjs` or `.cjs` extension, and it is honored only from project config or the command line — never from user, global, or builtin config.

### Interaction with `packageExtensions` and `overrides`

When both are present, `transformManifest` runs first and `packageExtensions` is applied to its output. Avoid targeting the same package with both unless you intend to rely on that ordering. `overrides` still controls the final resolution target of any edge, including edges created by `transformManifest`.

### Lockfile and `npm ci`

A lockfile influenced by `.npm-extension` records an `npmExtensionHash` (a digest of the selected file's bytes and module format) on its root entry, and minimal `npmExtensionApplied` provenance on each affected package entry. Extension state requires `lockfileVersion: 4`.

Changing the file's contents makes `npm install` re-resolve the affected packages. `npm ci` does **not** import or execute `.npm-extension`; it verifies the recorded hash against the file and reifies the locked graph, failing if the file and lockfile disagree (or if one has extension state and the other does not).

The hash proves only that the install uses the same extension file bytes that generated the lockfile. It does not make arbitrary JavaScript deterministic: extension output that depends on environment variables, the network, the clock, or files imported by the extension can still produce non-reproducible installs. Treat `.npm-extension` as trusted, deterministic project code, and only enable it in repositories you trust.

### Disabling

Set [`ignore-extension`](/using-npm/config#ignore-extension) to skip importing and executing `.npm-extension`. [`ignore-scripts`](/using-npm/config#ignore-scripts) implies `ignore-extension`, since both disable root-owned install-time code. `npm ci` still verifies the file hash even when execution is disabled.

### Publishing

`.npm-extension.mjs` and `.npm-extension.cjs` are project configuration, not package contents. npm excludes the root file from the package tarball produced by `npm pack` and `npm publish`, even when the package's `files` list would include it, so a public package can keep `.npm-extension` in its repository for local use without publishing it.

### See also

* [package.json `packageExtensions`](/configuring-npm/package-json#packageextensions)
* [package-lock.json](/configuring-npm/package-lock-json)
* [config](/using-npm/config)
3 changes: 3 additions & 0 deletions docs/lib/content/nav.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -232,6 +232,9 @@
- title: package-lock.json
url: /configuring-npm/package-lock-json
description: A manifestation of the manifest
- title: .npm-extension
url: /configuring-npm/npm-extension
description: Imperative, root-owned manifest repairs
- title: Using npm
shortName: Using
url: /using-npm
Expand Down
15 changes: 14 additions & 1 deletion lib/commands/ci.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,7 +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 { validatePackageExtensions, validateNpmExtension } = require('../utils/validate-lockfile.js')
const ArboristWorkspaceCmd = require('../arborist-cmd.js')
const getWorkspaces = require('../utils/get-workspaces.js')

Expand DownExpand Up@@ -66,6 +66,9 @@ class CI extends ArboristWorkspaceCmd {
save: false, // npm ci should never modify the lockfile or package.json
workspaces: this.workspaceNames,
allowScripts: allowScriptsPolicy,
// npm ci reifies the locked graph, which already carries extension-influenced edges, so it must never import or execute .npm-extension.
// The extension file hash is still validated below, independent of execution.
ignoreExtension: true,
}

// generate an inventory from the virtual tree in the lockfile
Expand All@@ -92,6 +95,16 @@ class CI extends ArboristWorkspaceCmd {
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))
// Verifies that the root .npm-extension file matches the lockfile hash.
// The hash comes from discovering the file (no import or execution), so this holds even under ignore-extension/ignore-scripts.
const { NpmExtension } = require('@npmcli/arborist')
let fileHash = null
try {
fileHash = new NpmExtension({ root: where, extensionFile: opts.extensionFile }).hash
} catch (err) {
errors.push(`Invalid: ${err.message}`)
}
errors.push(...validateNpmExtension(virtualArb.virtualTree, fileHash))
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
17 changes: 13 additions & 4 deletions lib/commands/ls.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -278,8 +278,8 @@ const augmentItemWithIncludeMetadata = (node, item) => {
return item
}

// Render a node's packageExtensions provenance as a short "field.name" list, empty when none.
const formatPackageExtensions = (applied) => {
// Render a manifest-extension provenance object as a short "field.name" list, empty when none.
const formatExtensionApplied = (applied) => {
if (!applied) {
return ''
}
Expand DownExpand Up@@ -354,8 +354,13 @@ const getHumanOutputItem = (node, { args, chalk, global, long }) => {
: ''
) +
(
formatPackageExtensions(node.packageExtensionsApplied)
? ' ' + chalk.dim(`packageExtensions: ${formatPackageExtensions(node.packageExtensionsApplied)}`)
formatExtensionApplied(node.packageExtensionsApplied)
? ' ' + chalk.dim(`packageExtensions: ${formatExtensionApplied(node.packageExtensionsApplied)}`)
: ''
) +
(
formatExtensionApplied(node.npmExtensionApplied)
? ' ' + chalk.dim(`.npm-extension: ${formatExtensionApplied(node.npmExtensionApplied)}`)
: ''
) +
(isGitNode(node) ? ` (${node.resolved})` : '') +
Expand DownExpand Up@@ -386,6 +391,10 @@ const getJsonOutputItem = (node, { global, long }) => {
item.packageExtensionsApplied = node.packageExtensionsApplied
}

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

item[_name] = node.name

// special formatting for top-level package name
Expand Down
13 changes: 13 additions & 0 deletions lib/npm.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,6 +118,19 @@ class Npm {
return { exec: false }
}

// extension-file selects root-owned install-time code, so it is only honored from project config or the command line.
// This is checked after #display.load() so the error is surfaced to the user instead of being swallowed during early config loading.
const extensionFile = this.config.get('extension-file')
if (extensionFile != null) {
const where = this.config.find('extension-file')
if (!['cli', 'project', 'default'].includes(where)) {
throw Object.assign(
new Error(`\`extension-file\` may only be set in project config or on the command line, not from ${where} config`),
{ code: 'ENPMEXTENSIONCONFIG' }
)
}
}

// mkdir this separately since the logs dir can be set to a different location.
// if this fails, then we don't have a cache dir, but we don't want to fail immediately since the command might not need a cache dir (like `npm --version`)
await time.start('npm:load:mkdirpcache', () =>
Expand Down
9 changes: 7 additions & 2 deletions lib/utils/explain-dep.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,7 +76,7 @@ const explainDependents = ({ dependents }, depth, chalk, seen) => {
}

const explainEdge = (
{ name, type, bundled, from, spec, rawSpec, overridden, packageExtensions },
{ name, type, bundled, from, spec, rawSpec, overridden, packageExtensions, npmExtension },
depth, chalk, seen = new Set()
) => {
let dep = type === 'workspace'
Expand All@@ -93,9 +93,14 @@ const explainEdge = (
? chalk.dim(` (added by packageExtensions["${packageExtensions.selector}"].${packageExtensions.field}.${name})`)
: ''

// note an edge created or changed by a root .npm-extension repair
const npmExtMsg = npmExtension
? chalk.dim(` (changed by .npm-extension ${npmExtension.extensionPoint} ${npmExtension.field}.${name})`)
: ''

return (type === 'prod' ? '' : `${colorType(type, chalk)} `) +
(bundled ? `${colorType('bundled', chalk)} ` : '') +
`${dep}${fromMsg}${extMsg}`
`${dep}${fromMsg}${extMsg}${npmExtMsg}`
}

const explainFrom = (from, depth, chalk, seen) => {
Expand Down
28 changes: 28 additions & 0 deletions lib/utils/validate-lockfile.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -98,5 +98,33 @@ function validatePackageExtensions (virtualTree, idealTree) {
return errors
}

// validates that the .npm-extension state recorded in the lockfile still matches the selected extension file.
// Validation is hash-based: arbitrary code has no selector to re-check, so a matching hash is trusted and a mismatch fails.
// fileHash is computed from the on-disk file (discovery only, no execution), so this holds even under ignore-extension/ignore-scripts.
// The lockfile carries extension state if it records a root hash or any per-package npmExtensionApplied provenance.
// Returns an array of human-readable error strings, empty when valid.
function validateNpmExtension (virtualTree, fileHash) {
const lockHash = virtualTree?.meta?.npmExtensionHash || null
const hasProvenance = !!virtualTree &&
[...virtualTree.inventory.values()].some(node => node.npmExtensionApplied)
fileHash = fileHash || null

if (fileHash) {
if (!lockHash) {
return ['Missing: .npm-extension state from lock file']
}
if (lockHash !== fileHash) {
return ['Invalid: .npm-extension file does not match the lock file']
}
return []
}
// no extension file present
if (lockHash || hasProvenance) {
return ['Invalid: lock file records .npm-extension state but no .npm-extension file is present']
}
return []
}

module.exports = validateLockfile
module.exports.validatePackageExtensions = validatePackageExtensions
module.exports.validateNpmExtension = validateNpmExtension
4 changes: 4 additions & 0 deletions tap-snapshots/test/lib/commands/config.js.test.cjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,6 +58,7 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna
"expect-result-count": null,
"expect-results": null,
"expires": null,
"extension-file": null,
"fetch-retries": 2,
"fetch-retry-factor": 10,
"fetch-retry-maxtimeout": 60000,
Expand All@@ -76,6 +77,7 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna
"heading": "npm",
"https-proxy": null,
"if-present": false,
"ignore-extension": false,
"ignore-scripts": false,
"include": [],
"include-staged": false,
Expand DownExpand Up@@ -254,6 +256,7 @@ engine-strict = false
expect-result-count = null
expect-results = null
expires = null
extension-file = null
fetch-retries = 2
fetch-retry-factor = 10
fetch-retry-maxtimeout = 60000
Expand All@@ -273,6 +276,7 @@ heading = "npm"
https-proxy = null
if-present = false
ignore-existing = false
ignore-extension = false
ignore-patch-failures = false
ignore-scripts = false
include = []
Expand Down
12 changes: 6 additions & 6 deletions tap-snapshots/test/lib/commands/install.js.test.cjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -135,8 +135,8 @@ verbose stack Error: The developer of this package has specified the following t
verbose stack Invalid devEngines.runtime
verbose stack Invalid name "nondescript" does not match "node" for "runtime"
verbose stack at Install.checkDevEngines ({CWD}/lib/base-cmd.js:249:27)
verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:281:7)
verbose stack at MockNpm.exec ({CWD}/lib/npm.js:181:9)
verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:294:7)
verbose stack at MockNpm.exec ({CWD}/lib/npm.js:194:9)
error code EBADDEVENGINES
error EBADDEVENGINES The developer of this package has specified the following through devEngines
error EBADDEVENGINES Invalid devEngines.runtime
Expand DownExpand Up@@ -200,8 +200,8 @@ verbose stack Error: The developer of this package has specified the following t
verbose stack Invalid devEngines.runtime
verbose stack Invalid name "nondescript" does not match "node" for "runtime"
verbose stack at Install.checkDevEngines ({CWD}/lib/base-cmd.js:249:27)
verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:281:7)
verbose stack at MockNpm.exec ({CWD}/lib/npm.js:181:9)
verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:294:7)
verbose stack at MockNpm.exec ({CWD}/lib/npm.js:194:9)
error code EBADDEVENGINES
error EBADDEVENGINES The developer of this package has specified the following through devEngines
error EBADDEVENGINES Invalid devEngines.runtime
Expand All@@ -226,8 +226,8 @@ verbose stack Error: The developer of this package has specified the following t
verbose stack Invalid devEngines.runtime
verbose stack Invalid name "nondescript" does not match "node" for "runtime"
verbose stack at Install.checkDevEngines ({CWD}/lib/base-cmd.js:249:27)
verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:281:7)
verbose stack at MockNpm.exec ({CWD}/lib/npm.js:181:9)
verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:294:7)
verbose stack at MockNpm.exec ({CWD}/lib/npm.js:194:9)
error code EBADDEVENGINES
error EBADDEVENGINES The developer of this package has specified the following through devEngines
error EBADDEVENGINES Invalid devEngines.runtime
Expand Down
6 changes: 6 additions & 0 deletions tap-snapshots/test/lib/commands/ls.js.test.cjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -315,6 +315,12 @@ test-npm-ls@1.0.0 {CWD}/prefix
\`-- dog@2.0.0
`

exports[`test/lib/commands/ls.js TAP ls .npm-extension dep > human output annotates the transformed node 1`] = `
test-npm-extension@1.0.0 {CWD}/prefix
\`-- foo@1.0.0 .npm-extension: dependencies.bar
\`-- bar@1.0.0
`

exports[`test/lib/commands/ls.js TAP ls broken resolved field > should NOT print git refs in output tree 1`] = `
npm-broken-resolved-field-test@1.0.0 {CWD}/prefix
\`-- a@1.0.1
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
1 change: 1 addition & 0 deletions docs/lib/build.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -107,6 +107,7 @@ const generateNav = async (contentPath, navPath) => {
'/configuring-npm/npmrc',
'/configuring-npm/package-json',
'/configuring-npm/package-lock-json',
'/configuring-npm/npm-extension',
]

// Hardcoded order for using-npm section (only urls - title/description come from frontmatter)
Expand Down
90 changes: 90 additions & 0 deletions docs/lib/content/configuring-npm/npm-extension.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
---
title: .npm-extension
section: 5
description: Imperative, root-owned manifest repairs
---

### Description

A root-owned `.npm-extension.mjs` or `.npm-extension.cjs` file lets a project imperatively repair the manifests of third-party dependencies before npm resolves the dependency tree. It exports a `transformManifest(pkg, context)` function that receives a candidate dependency manifest and returns the effective manifest npm should use.

`.npm-extension` is the imperative counterpart to the declarative [`packageExtensions`](/configuring-npm/package-json#packageextensions) field, and runs in the same pre-resolution phase, **before** `packageExtensions`. Prefer `packageExtensions` for simple, data-only repairs; reach for `.npm-extension` when you need comments and links explaining a repair, conditional logic, repeated repairs expressed as code, deletion or range rewrites, stale-repair guards, or a policy location outside `package.json`.

### Example

```js
// .npm-extension.mjs
export function transformManifest (pkg, context) {
if (pkg.name === 'foo' && pkg.version.startsWith('1.')) {
pkg.dependencies = { ...pkg.dependencies, bar: '^2.0.0' }
context.log(`added bar to ${pkg.name}@${pkg.version}`)
}
return pkg
}
```

The `.cjs` form uses CommonJS exports instead:

```js
// .npm-extension.cjs
module.exports = {
transformManifest (pkg, context) {
return pkg
},
}
```

### The `transformManifest` function

`transformManifest(pkg, context)` receives a deeply isolated copy of a candidate dependency manifest. It may mutate and return that copy, or return a new manifest object. It **must** return a manifest object synchronously; returning `null`, `undefined`, a primitive, an array, or a promise fails the install.

The `context` argument is intentionally small:

* `context.log(message)` writes an npm debug log message.
* `context.root` is the absolute path to the project root.
* `context.extensionPoint` is the string `"transformManifest"`.

npm provides no registry, fetch, lockfile, or extraction helpers. Keep the extension file self-contained or limited to Node builtins; npm does not guarantee that project dependencies are available when the file is loaded.

### Supported mutations

Only the four resolution-affecting fields may change:

* `dependencies`
* `optionalDependencies`
* `peerDependencies`
* `peerDependenciesMeta`

Within those fields you may add, replace, or delete entries. Changing any other field (such as `scripts`, `bin`, `engines`, `os`, `cpu`, `exports`, or `main`) is rejected, and the install fails with an error naming `.npm-extension` and the package being processed. The package tarball and the installed `node_modules/<pkg>/package.json` are never rewritten.

### Discovery and `extension-file`

npm looks for a single `.npm-extension.mjs` or `.npm-extension.cjs` at the project root (the workspace root in a workspace project). Having both files present is an error. A `.npm-extension` file in a dependency or in a non-root workspace is ignored; a non-root workspace file produces a warning.

The [`extension-file`](/using-npm/config#extension-file) config selects a different project-local file. It must resolve inside the project root and use a `.mjs` or `.cjs` extension, and it is honored only from project config or the command line — never from user, global, or builtin config.

### Interaction with `packageExtensions` and `overrides`

When both are present, `transformManifest` runs first and `packageExtensions` is applied to its output. Avoid targeting the same package with both unless you intend to rely on that ordering. `overrides` still controls the final resolution target of any edge, including edges created by `transformManifest`.

### Lockfile and `npm ci`

A lockfile influenced by `.npm-extension` records an `npmExtensionHash` (a digest of the selected file's bytes and module format) on its root entry, and minimal `npmExtensionApplied` provenance on each affected package entry. Extension state requires `lockfileVersion: 4`.

Changing the file's contents makes `npm install` re-resolve the affected packages. `npm ci` does **not** import or execute `.npm-extension`; it verifies the recorded hash against the file and reifies the locked graph, failing if the file and lockfile disagree (or if one has extension state and the other does not).

The hash proves only that the install uses the same extension file bytes that generated the lockfile. It does not make arbitrary JavaScript deterministic: extension output that depends on environment variables, the network, the clock, or files imported by the extension can still produce non-reproducible installs. Treat `.npm-extension` as trusted, deterministic project code, and only enable it in repositories you trust.

### Disabling

Set [`ignore-extension`](/using-npm/config#ignore-extension) to skip importing and executing `.npm-extension`. [`ignore-scripts`](/using-npm/config#ignore-scripts) implies `ignore-extension`, since both disable root-owned install-time code. `npm ci` still verifies the file hash even when execution is disabled.

### Publishing

`.npm-extension.mjs` and `.npm-extension.cjs` are project configuration, not package contents. npm excludes the root file from the package tarball produced by `npm pack` and `npm publish`, even when the package's `files` list would include it, so a public package can keep `.npm-extension` in its repository for local use without publishing it.

### See also

* [package.json `packageExtensions`](/configuring-npm/package-json#packageextensions)
* [package-lock.json](/configuring-npm/package-lock-json)
* [config](/using-npm/config)
3 changes: 3 additions & 0 deletions docs/lib/content/nav.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -232,6 +232,9 @@
- title: package-lock.json
url: /configuring-npm/package-lock-json
description: A manifestation of the manifest
- title: .npm-extension
url: /configuring-npm/npm-extension
description: Imperative, root-owned manifest repairs
- title: Using npm
shortName: Using
url: /using-npm
Expand Down
15 changes: 14 additions & 1 deletion lib/commands/ci.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,7 +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 { validatePackageExtensions, validateNpmExtension } = require('../utils/validate-lockfile.js')
const ArboristWorkspaceCmd = require('../arborist-cmd.js')
const getWorkspaces = require('../utils/get-workspaces.js')

Expand DownExpand Up@@ -66,6 +66,9 @@ class CI extends ArboristWorkspaceCmd {
save: false, // npm ci should never modify the lockfile or package.json
workspaces: this.workspaceNames,
allowScripts: allowScriptsPolicy,
// npm ci reifies the locked graph, which already carries extension-influenced edges, so it must never import or execute .npm-extension.
// The extension file hash is still validated below, independent of execution.
ignoreExtension: true,
}

// generate an inventory from the virtual tree in the lockfile
Expand All@@ -92,6 +95,16 @@ class CI extends ArboristWorkspaceCmd {
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))
// Verifies that the root .npm-extension file matches the lockfile hash.
// The hash comes from discovering the file (no import or execution), so this holds even under ignore-extension/ignore-scripts.
const { NpmExtension } = require('@npmcli/arborist')
let fileHash = null
try {
fileHash = new NpmExtension({ root: where, extensionFile: opts.extensionFile }).hash
} catch (err) {
errors.push(`Invalid: ${err.message}`)
}
errors.push(...validateNpmExtension(virtualArb.virtualTree, fileHash))
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
17 changes: 13 additions & 4 deletions lib/commands/ls.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -278,8 +278,8 @@ const augmentItemWithIncludeMetadata = (node, item) => {
return item
}

// Render a node's packageExtensions provenance as a short "field.name" list, empty when none.
const formatPackageExtensions = (applied) => {
// Render a manifest-extension provenance object as a short "field.name" list, empty when none.
const formatExtensionApplied = (applied) => {
if (!applied) {
return ''
}
Expand DownExpand Up@@ -354,8 +354,13 @@ const getHumanOutputItem = (node, { args, chalk, global, long }) => {
: ''
) +
(
formatPackageExtensions(node.packageExtensionsApplied)
? ' ' + chalk.dim(`packageExtensions: ${formatPackageExtensions(node.packageExtensionsApplied)}`)
formatExtensionApplied(node.packageExtensionsApplied)
? ' ' + chalk.dim(`packageExtensions: ${formatExtensionApplied(node.packageExtensionsApplied)}`)
: ''
) +
(
formatExtensionApplied(node.npmExtensionApplied)
? ' ' + chalk.dim(`.npm-extension: ${formatExtensionApplied(node.npmExtensionApplied)}`)
: ''
) +
(isGitNode(node) ? ` (${node.resolved})` : '') +
Expand DownExpand Up@@ -386,6 +391,10 @@ const getJsonOutputItem = (node, { global, long }) => {
item.packageExtensionsApplied = node.packageExtensionsApplied
}

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

item[_name] = node.name

// special formatting for top-level package name
Expand Down
13 changes: 13 additions & 0 deletions lib/npm.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,6 +118,19 @@ class Npm {
return { exec: false }
}

// extension-file selects root-owned install-time code, so it is only honored from project config or the command line.
// This is checked after #display.load() so the error is surfaced to the user instead of being swallowed during early config loading.
const extensionFile = this.config.get('extension-file')
if (extensionFile != null) {
const where = this.config.find('extension-file')
if (!['cli', 'project', 'default'].includes(where)) {
throw Object.assign(
new Error(`\`extension-file\` may only be set in project config or on the command line, not from ${where} config`),
{ code: 'ENPMEXTENSIONCONFIG' }
)
}
}

// mkdir this separately since the logs dir can be set to a different location.
// if this fails, then we don't have a cache dir, but we don't want to fail immediately since the command might not need a cache dir (like `npm --version`)
await time.start('npm:load:mkdirpcache', () =>
Expand Down
9 changes: 7 additions & 2 deletions lib/utils/explain-dep.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,7 +76,7 @@ const explainDependents = ({ dependents }, depth, chalk, seen) => {
}

const explainEdge = (
{ name, type, bundled, from, spec, rawSpec, overridden, packageExtensions },
{ name, type, bundled, from, spec, rawSpec, overridden, packageExtensions, npmExtension },
depth, chalk, seen = new Set()
) => {
let dep = type === 'workspace'
Expand All@@ -93,9 +93,14 @@ const explainEdge = (
? chalk.dim(` (added by packageExtensions["${packageExtensions.selector}"].${packageExtensions.field}.${name})`)
: ''

// note an edge created or changed by a root .npm-extension repair
const npmExtMsg = npmExtension
? chalk.dim(` (changed by .npm-extension ${npmExtension.extensionPoint} ${npmExtension.field}.${name})`)
: ''

return (type === 'prod' ? '' : `${colorType(type, chalk)} `) +
(bundled ? `${colorType('bundled', chalk)} ` : '') +
`${dep}${fromMsg}${extMsg}`
`${dep}${fromMsg}${extMsg}${npmExtMsg}`
}

const explainFrom = (from, depth, chalk, seen) => {
Expand Down
28 changes: 28 additions & 0 deletions lib/utils/validate-lockfile.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -98,5 +98,33 @@ function validatePackageExtensions (virtualTree, idealTree) {
return errors
}

// validates that the .npm-extension state recorded in the lockfile still matches the selected extension file.
// Validation is hash-based: arbitrary code has no selector to re-check, so a matching hash is trusted and a mismatch fails.
// fileHash is computed from the on-disk file (discovery only, no execution), so this holds even under ignore-extension/ignore-scripts.
// The lockfile carries extension state if it records a root hash or any per-package npmExtensionApplied provenance.
// Returns an array of human-readable error strings, empty when valid.
function validateNpmExtension (virtualTree, fileHash) {
const lockHash = virtualTree?.meta?.npmExtensionHash || null
const hasProvenance = !!virtualTree &&
[...virtualTree.inventory.values()].some(node => node.npmExtensionApplied)
fileHash = fileHash || null

if (fileHash) {
if (!lockHash) {
return ['Missing: .npm-extension state from lock file']
}
if (lockHash !== fileHash) {
return ['Invalid: .npm-extension file does not match the lock file']
}
return []
}
// no extension file present
if (lockHash || hasProvenance) {
return ['Invalid: lock file records .npm-extension state but no .npm-extension file is present']
}
return []
}

module.exports = validateLockfile
module.exports.validatePackageExtensions = validatePackageExtensions
module.exports.validateNpmExtension = validateNpmExtension
4 changes: 4 additions & 0 deletions tap-snapshots/test/lib/commands/config.js.test.cjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,6 +58,7 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna
"expect-result-count": null,
"expect-results": null,
"expires": null,
"extension-file": null,
"fetch-retries": 2,
"fetch-retry-factor": 10,
"fetch-retry-maxtimeout": 60000,
Expand All@@ -76,6 +77,7 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna
"heading": "npm",
"https-proxy": null,
"if-present": false,
"ignore-extension": false,
"ignore-scripts": false,
"include": [],
"include-staged": false,
Expand DownExpand Up@@ -254,6 +256,7 @@ engine-strict = false
expect-result-count = null
expect-results = null
expires = null
extension-file = null
fetch-retries = 2
fetch-retry-factor = 10
fetch-retry-maxtimeout = 60000
Expand All@@ -273,6 +276,7 @@ heading = "npm"
https-proxy = null
if-present = false
ignore-existing = false
ignore-extension = false
ignore-patch-failures = false
ignore-scripts = false
include = []
Expand Down
12 changes: 6 additions & 6 deletions tap-snapshots/test/lib/commands/install.js.test.cjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -135,8 +135,8 @@ verbose stack Error: The developer of this package has specified the following t
verbose stack Invalid devEngines.runtime
verbose stack Invalid name "nondescript" does not match "node" for "runtime"
verbose stack at Install.checkDevEngines ({CWD}/lib/base-cmd.js:249:27)
verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:281:7)
verbose stack at MockNpm.exec ({CWD}/lib/npm.js:181:9)
verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:294:7)
verbose stack at MockNpm.exec ({CWD}/lib/npm.js:194:9)
error code EBADDEVENGINES
error EBADDEVENGINES The developer of this package has specified the following through devEngines
error EBADDEVENGINES Invalid devEngines.runtime
Expand DownExpand Up@@ -200,8 +200,8 @@ verbose stack Error: The developer of this package has specified the following t
verbose stack Invalid devEngines.runtime
verbose stack Invalid name "nondescript" does not match "node" for "runtime"
verbose stack at Install.checkDevEngines ({CWD}/lib/base-cmd.js:249:27)
verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:281:7)
verbose stack at MockNpm.exec ({CWD}/lib/npm.js:181:9)
verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:294:7)
verbose stack at MockNpm.exec ({CWD}/lib/npm.js:194:9)
error code EBADDEVENGINES
error EBADDEVENGINES The developer of this package has specified the following through devEngines
error EBADDEVENGINES Invalid devEngines.runtime
Expand All@@ -226,8 +226,8 @@ verbose stack Error: The developer of this package has specified the following t
verbose stack Invalid devEngines.runtime
verbose stack Invalid name "nondescript" does not match "node" for "runtime"
verbose stack at Install.checkDevEngines ({CWD}/lib/base-cmd.js:249:27)
verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:281:7)
verbose stack at MockNpm.exec ({CWD}/lib/npm.js:181:9)
verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:294:7)
verbose stack at MockNpm.exec ({CWD}/lib/npm.js:194:9)
error code EBADDEVENGINES
error EBADDEVENGINES The developer of this package has specified the following through devEngines
error EBADDEVENGINES Invalid devEngines.runtime
Expand Down
6 changes: 6 additions & 0 deletions tap-snapshots/test/lib/commands/ls.js.test.cjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -315,6 +315,12 @@ test-npm-ls@1.0.0 {CWD}/prefix
\`-- dog@2.0.0
`

exports[`test/lib/commands/ls.js TAP ls .npm-extension dep > human output annotates the transformed node 1`] = `
test-npm-extension@1.0.0 {CWD}/prefix
\`-- foo@1.0.0 .npm-extension: dependencies.bar
\`-- bar@1.0.0
`

exports[`test/lib/commands/ls.js TAP ls broken resolved field > should NOT print git refs in output tree 1`] = `
npm-broken-resolved-field-test@1.0.0 {CWD}/prefix
\`-- a@1.0.1
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
1 change: 1 addition & 0 deletions docs/lib/build.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -107,6 +107,7 @@ const generateNav = async (contentPath, navPath) => {
'/configuring-npm/npmrc',
'/configuring-npm/package-json',
'/configuring-npm/package-lock-json',
'/configuring-npm/npm-extension',
]

// Hardcoded order for using-npm section (only urls - title/description come from frontmatter)
Expand Down
90 changes: 90 additions & 0 deletions docs/lib/content/configuring-npm/npm-extension.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
---
title: .npm-extension
section: 5
description: Imperative, root-owned manifest repairs
---

### Description

A root-owned `.npm-extension.mjs` or `.npm-extension.cjs` file lets a project imperatively repair the manifests of third-party dependencies before npm resolves the dependency tree. It exports a `transformManifest(pkg, context)` function that receives a candidate dependency manifest and returns the effective manifest npm should use.

`.npm-extension` is the imperative counterpart to the declarative [`packageExtensions`](/configuring-npm/package-json#packageextensions) field, and runs in the same pre-resolution phase, **before** `packageExtensions`. Prefer `packageExtensions` for simple, data-only repairs; reach for `.npm-extension` when you need comments and links explaining a repair, conditional logic, repeated repairs expressed as code, deletion or range rewrites, stale-repair guards, or a policy location outside `package.json`.

### Example

```js
// .npm-extension.mjs
export function transformManifest (pkg, context) {
if (pkg.name === 'foo' && pkg.version.startsWith('1.')) {
pkg.dependencies = { ...pkg.dependencies, bar: '^2.0.0' }
context.log(`added bar to ${pkg.name}@${pkg.version}`)
}
return pkg
}
```

The `.cjs` form uses CommonJS exports instead:

```js
// .npm-extension.cjs
module.exports = {
transformManifest (pkg, context) {
return pkg
},
}
```

### The `transformManifest` function

`transformManifest(pkg, context)` receives a deeply isolated copy of a candidate dependency manifest. It may mutate and return that copy, or return a new manifest object. It **must** return a manifest object synchronously; returning `null`, `undefined`, a primitive, an array, or a promise fails the install.

The `context` argument is intentionally small:

* `context.log(message)` writes an npm debug log message.
* `context.root` is the absolute path to the project root.
* `context.extensionPoint` is the string `"transformManifest"`.

npm provides no registry, fetch, lockfile, or extraction helpers. Keep the extension file self-contained or limited to Node builtins; npm does not guarantee that project dependencies are available when the file is loaded.

### Supported mutations

Only the four resolution-affecting fields may change:

* `dependencies`
* `optionalDependencies`
* `peerDependencies`
* `peerDependenciesMeta`

Within those fields you may add, replace, or delete entries. Changing any other field (such as `scripts`, `bin`, `engines`, `os`, `cpu`, `exports`, or `main`) is rejected, and the install fails with an error naming `.npm-extension` and the package being processed. The package tarball and the installed `node_modules/<pkg>/package.json` are never rewritten.

### Discovery and `extension-file`

npm looks for a single `.npm-extension.mjs` or `.npm-extension.cjs` at the project root (the workspace root in a workspace project). Having both files present is an error. A `.npm-extension` file in a dependency or in a non-root workspace is ignored; a non-root workspace file produces a warning.

The [`extension-file`](/using-npm/config#extension-file) config selects a different project-local file. It must resolve inside the project root and use a `.mjs` or `.cjs` extension, and it is honored only from project config or the command line — never from user, global, or builtin config.

### Interaction with `packageExtensions` and `overrides`

When both are present, `transformManifest` runs first and `packageExtensions` is applied to its output. Avoid targeting the same package with both unless you intend to rely on that ordering. `overrides` still controls the final resolution target of any edge, including edges created by `transformManifest`.

### Lockfile and `npm ci`

A lockfile influenced by `.npm-extension` records an `npmExtensionHash` (a digest of the selected file's bytes and module format) on its root entry, and minimal `npmExtensionApplied` provenance on each affected package entry. Extension state requires `lockfileVersion: 4`.

Changing the file's contents makes `npm install` re-resolve the affected packages. `npm ci` does **not** import or execute `.npm-extension`; it verifies the recorded hash against the file and reifies the locked graph, failing if the file and lockfile disagree (or if one has extension state and the other does not).

The hash proves only that the install uses the same extension file bytes that generated the lockfile. It does not make arbitrary JavaScript deterministic: extension output that depends on environment variables, the network, the clock, or files imported by the extension can still produce non-reproducible installs. Treat `.npm-extension` as trusted, deterministic project code, and only enable it in repositories you trust.

### Disabling

Set [`ignore-extension`](/using-npm/config#ignore-extension) to skip importing and executing `.npm-extension`. [`ignore-scripts`](/using-npm/config#ignore-scripts) implies `ignore-extension`, since both disable root-owned install-time code. `npm ci` still verifies the file hash even when execution is disabled.

### Publishing

`.npm-extension.mjs` and `.npm-extension.cjs` are project configuration, not package contents. npm excludes the root file from the package tarball produced by `npm pack` and `npm publish`, even when the package's `files` list would include it, so a public package can keep `.npm-extension` in its repository for local use without publishing it.

### See also

* [package.json `packageExtensions`](/configuring-npm/package-json#packageextensions)
* [package-lock.json](/configuring-npm/package-lock-json)
* [config](/using-npm/config)
3 changes: 3 additions & 0 deletions docs/lib/content/nav.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -232,6 +232,9 @@
- title: package-lock.json
url: /configuring-npm/package-lock-json
description: A manifestation of the manifest
- title: .npm-extension
url: /configuring-npm/npm-extension
description: Imperative, root-owned manifest repairs
- title: Using npm
shortName: Using
url: /using-npm
Expand Down
15 changes: 14 additions & 1 deletion lib/commands/ci.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,7 +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 { validatePackageExtensions, validateNpmExtension } = require('../utils/validate-lockfile.js')
const ArboristWorkspaceCmd = require('../arborist-cmd.js')
const getWorkspaces = require('../utils/get-workspaces.js')

Expand DownExpand Up@@ -66,6 +66,9 @@ class CI extends ArboristWorkspaceCmd {
save: false, // npm ci should never modify the lockfile or package.json
workspaces: this.workspaceNames,
allowScripts: allowScriptsPolicy,
// npm ci reifies the locked graph, which already carries extension-influenced edges, so it must never import or execute .npm-extension.
// The extension file hash is still validated below, independent of execution.
ignoreExtension: true,
}

// generate an inventory from the virtual tree in the lockfile
Expand All@@ -92,6 +95,16 @@ class CI extends ArboristWorkspaceCmd {
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))
// Verifies that the root .npm-extension file matches the lockfile hash.
// The hash comes from discovering the file (no import or execution), so this holds even under ignore-extension/ignore-scripts.
const { NpmExtension } = require('@npmcli/arborist')
let fileHash = null
try {
fileHash = new NpmExtension({ root: where, extensionFile: opts.extensionFile }).hash
} catch (err) {
errors.push(`Invalid: ${err.message}`)
}
errors.push(...validateNpmExtension(virtualArb.virtualTree, fileHash))
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
17 changes: 13 additions & 4 deletions lib/commands/ls.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -278,8 +278,8 @@ const augmentItemWithIncludeMetadata = (node, item) => {
return item
}

// Render a node's packageExtensions provenance as a short "field.name" list, empty when none.
const formatPackageExtensions = (applied) => {
// Render a manifest-extension provenance object as a short "field.name" list, empty when none.
const formatExtensionApplied = (applied) => {
if (!applied) {
return ''
}
Expand DownExpand Up@@ -354,8 +354,13 @@ const getHumanOutputItem = (node, { args, chalk, global, long }) => {
: ''
) +
(
formatPackageExtensions(node.packageExtensionsApplied)
? ' ' + chalk.dim(`packageExtensions: ${formatPackageExtensions(node.packageExtensionsApplied)}`)
formatExtensionApplied(node.packageExtensionsApplied)
? ' ' + chalk.dim(`packageExtensions: ${formatExtensionApplied(node.packageExtensionsApplied)}`)
: ''
) +
(
formatExtensionApplied(node.npmExtensionApplied)
? ' ' + chalk.dim(`.npm-extension: ${formatExtensionApplied(node.npmExtensionApplied)}`)
: ''
) +
(isGitNode(node) ? ` (${node.resolved})` : '') +
Expand DownExpand Up@@ -386,6 +391,10 @@ const getJsonOutputItem = (node, { global, long }) => {
item.packageExtensionsApplied = node.packageExtensionsApplied
}

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

item[_name] = node.name

// special formatting for top-level package name
Expand Down
13 changes: 13 additions & 0 deletions lib/npm.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,6 +118,19 @@ class Npm {
return { exec: false }
}

// extension-file selects root-owned install-time code, so it is only honored from project config or the command line.
// This is checked after #display.load() so the error is surfaced to the user instead of being swallowed during early config loading.
const extensionFile = this.config.get('extension-file')
if (extensionFile != null) {
const where = this.config.find('extension-file')
if (!['cli', 'project', 'default'].includes(where)) {
throw Object.assign(
new Error(`\`extension-file\` may only be set in project config or on the command line, not from ${where} config`),
{ code: 'ENPMEXTENSIONCONFIG' }
)
}
}

// mkdir this separately since the logs dir can be set to a different location.
// if this fails, then we don't have a cache dir, but we don't want to fail immediately since the command might not need a cache dir (like `npm --version`)
await time.start('npm:load:mkdirpcache', () =>
Expand Down
9 changes: 7 additions & 2 deletions lib/utils/explain-dep.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,7 +76,7 @@ const explainDependents = ({ dependents }, depth, chalk, seen) => {
}

const explainEdge = (
{ name, type, bundled, from, spec, rawSpec, overridden, packageExtensions },
{ name, type, bundled, from, spec, rawSpec, overridden, packageExtensions, npmExtension },
depth, chalk, seen = new Set()
) => {
let dep = type === 'workspace'
Expand All@@ -93,9 +93,14 @@ const explainEdge = (
? chalk.dim(` (added by packageExtensions["${packageExtensions.selector}"].${packageExtensions.field}.${name})`)
: ''

// note an edge created or changed by a root .npm-extension repair
const npmExtMsg = npmExtension
? chalk.dim(` (changed by .npm-extension ${npmExtension.extensionPoint} ${npmExtension.field}.${name})`)
: ''

return (type === 'prod' ? '' : `${colorType(type, chalk)} `) +
(bundled ? `${colorType('bundled', chalk)} ` : '') +
`${dep}${fromMsg}${extMsg}`
`${dep}${fromMsg}${extMsg}${npmExtMsg}`
}

const explainFrom = (from, depth, chalk, seen) => {
Expand Down
28 changes: 28 additions & 0 deletions lib/utils/validate-lockfile.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -98,5 +98,33 @@ function validatePackageExtensions (virtualTree, idealTree) {
return errors
}

// validates that the .npm-extension state recorded in the lockfile still matches the selected extension file.
// Validation is hash-based: arbitrary code has no selector to re-check, so a matching hash is trusted and a mismatch fails.
// fileHash is computed from the on-disk file (discovery only, no execution), so this holds even under ignore-extension/ignore-scripts.
// The lockfile carries extension state if it records a root hash or any per-package npmExtensionApplied provenance.
// Returns an array of human-readable error strings, empty when valid.
function validateNpmExtension (virtualTree, fileHash) {
const lockHash = virtualTree?.meta?.npmExtensionHash || null
const hasProvenance = !!virtualTree &&
[...virtualTree.inventory.values()].some(node => node.npmExtensionApplied)
fileHash = fileHash || null

if (fileHash) {
if (!lockHash) {
return ['Missing: .npm-extension state from lock file']
}
if (lockHash !== fileHash) {
return ['Invalid: .npm-extension file does not match the lock file']
}
return []
}
// no extension file present
if (lockHash || hasProvenance) {
return ['Invalid: lock file records .npm-extension state but no .npm-extension file is present']
}
return []
}

module.exports = validateLockfile
module.exports.validatePackageExtensions = validatePackageExtensions
module.exports.validateNpmExtension = validateNpmExtension
4 changes: 4 additions & 0 deletions tap-snapshots/test/lib/commands/config.js.test.cjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,6 +58,7 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna
"expect-result-count": null,
"expect-results": null,
"expires": null,
"extension-file": null,
"fetch-retries": 2,
"fetch-retry-factor": 10,
"fetch-retry-maxtimeout": 60000,
Expand All@@ -76,6 +77,7 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna
"heading": "npm",
"https-proxy": null,
"if-present": false,
"ignore-extension": false,
"ignore-scripts": false,
"include": [],
"include-staged": false,
Expand DownExpand Up@@ -254,6 +256,7 @@ engine-strict = false
expect-result-count = null
expect-results = null
expires = null
extension-file = null
fetch-retries = 2
fetch-retry-factor = 10
fetch-retry-maxtimeout = 60000
Expand All@@ -273,6 +276,7 @@ heading = "npm"
https-proxy = null
if-present = false
ignore-existing = false
ignore-extension = false
ignore-patch-failures = false
ignore-scripts = false
include = []
Expand Down
12 changes: 6 additions & 6 deletions tap-snapshots/test/lib/commands/install.js.test.cjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -135,8 +135,8 @@ verbose stack Error: The developer of this package has specified the following t
verbose stack Invalid devEngines.runtime
verbose stack Invalid name "nondescript" does not match "node" for "runtime"
verbose stack at Install.checkDevEngines ({CWD}/lib/base-cmd.js:249:27)
verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:281:7)
verbose stack at MockNpm.exec ({CWD}/lib/npm.js:181:9)
verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:294:7)
verbose stack at MockNpm.exec ({CWD}/lib/npm.js:194:9)
error code EBADDEVENGINES
error EBADDEVENGINES The developer of this package has specified the following through devEngines
error EBADDEVENGINES Invalid devEngines.runtime
Expand DownExpand Up@@ -200,8 +200,8 @@ verbose stack Error: The developer of this package has specified the following t
verbose stack Invalid devEngines.runtime
verbose stack Invalid name "nondescript" does not match "node" for "runtime"
verbose stack at Install.checkDevEngines ({CWD}/lib/base-cmd.js:249:27)
verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:281:7)
verbose stack at MockNpm.exec ({CWD}/lib/npm.js:181:9)
verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:294:7)
verbose stack at MockNpm.exec ({CWD}/lib/npm.js:194:9)
error code EBADDEVENGINES
error EBADDEVENGINES The developer of this package has specified the following through devEngines
error EBADDEVENGINES Invalid devEngines.runtime
Expand All@@ -226,8 +226,8 @@ verbose stack Error: The developer of this package has specified the following t
verbose stack Invalid devEngines.runtime
verbose stack Invalid name "nondescript" does not match "node" for "runtime"
verbose stack at Install.checkDevEngines ({CWD}/lib/base-cmd.js:249:27)
verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:281:7)
verbose stack at MockNpm.exec ({CWD}/lib/npm.js:181:9)
verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:294:7)
verbose stack at MockNpm.exec ({CWD}/lib/npm.js:194:9)
error code EBADDEVENGINES
error EBADDEVENGINES The developer of this package has specified the following through devEngines
error EBADDEVENGINES Invalid devEngines.runtime
Expand Down
6 changes: 6 additions & 0 deletions tap-snapshots/test/lib/commands/ls.js.test.cjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -315,6 +315,12 @@ test-npm-ls@1.0.0 {CWD}/prefix
\`-- dog@2.0.0
`

exports[`test/lib/commands/ls.js TAP ls .npm-extension dep > human output annotates the transformed node 1`] = `
test-npm-extension@1.0.0 {CWD}/prefix
\`-- foo@1.0.0 .npm-extension: dependencies.bar
\`-- bar@1.0.0
`

exports[`test/lib/commands/ls.js TAP ls broken resolved field > should NOT print git refs in output tree 1`] = `
npm-broken-resolved-field-test@1.0.0 {CWD}/prefix
\`-- a@1.0.1
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
1 change: 1 addition & 0 deletions docs/lib/build.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -107,6 +107,7 @@ const generateNav = async (contentPath, navPath) => {
'/configuring-npm/npmrc',
'/configuring-npm/package-json',
'/configuring-npm/package-lock-json',
'/configuring-npm/npm-extension',
]

// Hardcoded order for using-npm section (only urls - title/description come from frontmatter)
Expand Down
90 changes: 90 additions & 0 deletions docs/lib/content/configuring-npm/npm-extension.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
---
title: .npm-extension
section: 5
description: Imperative, root-owned manifest repairs
---

### Description

A root-owned `.npm-extension.mjs` or `.npm-extension.cjs` file lets a project imperatively repair the manifests of third-party dependencies before npm resolves the dependency tree. It exports a `transformManifest(pkg, context)` function that receives a candidate dependency manifest and returns the effective manifest npm should use.

`.npm-extension` is the imperative counterpart to the declarative [`packageExtensions`](/configuring-npm/package-json#packageextensions) field, and runs in the same pre-resolution phase, **before** `packageExtensions`. Prefer `packageExtensions` for simple, data-only repairs; reach for `.npm-extension` when you need comments and links explaining a repair, conditional logic, repeated repairs expressed as code, deletion or range rewrites, stale-repair guards, or a policy location outside `package.json`.

### Example

```js
// .npm-extension.mjs
export function transformManifest (pkg, context) {
if (pkg.name === 'foo' && pkg.version.startsWith('1.')) {
pkg.dependencies = { ...pkg.dependencies, bar: '^2.0.0' }
context.log(`added bar to ${pkg.name}@${pkg.version}`)
}
return pkg
}
```

The `.cjs` form uses CommonJS exports instead:

```js
// .npm-extension.cjs
module.exports = {
transformManifest (pkg, context) {
return pkg
},
}
```

### The `transformManifest` function

`transformManifest(pkg, context)` receives a deeply isolated copy of a candidate dependency manifest. It may mutate and return that copy, or return a new manifest object. It **must** return a manifest object synchronously; returning `null`, `undefined`, a primitive, an array, or a promise fails the install.

The `context` argument is intentionally small:

* `context.log(message)` writes an npm debug log message.
* `context.root` is the absolute path to the project root.
* `context.extensionPoint` is the string `"transformManifest"`.

npm provides no registry, fetch, lockfile, or extraction helpers. Keep the extension file self-contained or limited to Node builtins; npm does not guarantee that project dependencies are available when the file is loaded.

### Supported mutations

Only the four resolution-affecting fields may change:

* `dependencies`
* `optionalDependencies`
* `peerDependencies`
* `peerDependenciesMeta`

Within those fields you may add, replace, or delete entries. Changing any other field (such as `scripts`, `bin`, `engines`, `os`, `cpu`, `exports`, or `main`) is rejected, and the install fails with an error naming `.npm-extension` and the package being processed. The package tarball and the installed `node_modules/<pkg>/package.json` are never rewritten.

### Discovery and `extension-file`

npm looks for a single `.npm-extension.mjs` or `.npm-extension.cjs` at the project root (the workspace root in a workspace project). Having both files present is an error. A `.npm-extension` file in a dependency or in a non-root workspace is ignored; a non-root workspace file produces a warning.

The [`extension-file`](/using-npm/config#extension-file) config selects a different project-local file. It must resolve inside the project root and use a `.mjs` or `.cjs` extension, and it is honored only from project config or the command line — never from user, global, or builtin config.

### Interaction with `packageExtensions` and `overrides`

When both are present, `transformManifest` runs first and `packageExtensions` is applied to its output. Avoid targeting the same package with both unless you intend to rely on that ordering. `overrides` still controls the final resolution target of any edge, including edges created by `transformManifest`.

### Lockfile and `npm ci`

A lockfile influenced by `.npm-extension` records an `npmExtensionHash` (a digest of the selected file's bytes and module format) on its root entry, and minimal `npmExtensionApplied` provenance on each affected package entry. Extension state requires `lockfileVersion: 4`.

Changing the file's contents makes `npm install` re-resolve the affected packages. `npm ci` does **not** import or execute `.npm-extension`; it verifies the recorded hash against the file and reifies the locked graph, failing if the file and lockfile disagree (or if one has extension state and the other does not).

The hash proves only that the install uses the same extension file bytes that generated the lockfile. It does not make arbitrary JavaScript deterministic: extension output that depends on environment variables, the network, the clock, or files imported by the extension can still produce non-reproducible installs. Treat `.npm-extension` as trusted, deterministic project code, and only enable it in repositories you trust.

### Disabling

Set [`ignore-extension`](/using-npm/config#ignore-extension) to skip importing and executing `.npm-extension`. [`ignore-scripts`](/using-npm/config#ignore-scripts) implies `ignore-extension`, since both disable root-owned install-time code. `npm ci` still verifies the file hash even when execution is disabled.

### Publishing

`.npm-extension.mjs` and `.npm-extension.cjs` are project configuration, not package contents. npm excludes the root file from the package tarball produced by `npm pack` and `npm publish`, even when the package's `files` list would include it, so a public package can keep `.npm-extension` in its repository for local use without publishing it.

### See also

* [package.json `packageExtensions`](/configuring-npm/package-json#packageextensions)
* [package-lock.json](/configuring-npm/package-lock-json)
* [config](/using-npm/config)
3 changes: 3 additions & 0 deletions docs/lib/content/nav.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -232,6 +232,9 @@
- title: package-lock.json
url: /configuring-npm/package-lock-json
description: A manifestation of the manifest
- title: .npm-extension
url: /configuring-npm/npm-extension
description: Imperative, root-owned manifest repairs
- title: Using npm
shortName: Using
url: /using-npm
Expand Down
15 changes: 14 additions & 1 deletion lib/commands/ci.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,7 +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 { validatePackageExtensions, validateNpmExtension } = require('../utils/validate-lockfile.js')
const ArboristWorkspaceCmd = require('../arborist-cmd.js')
const getWorkspaces = require('../utils/get-workspaces.js')

Expand DownExpand Up@@ -66,6 +66,9 @@ class CI extends ArboristWorkspaceCmd {
save: false, // npm ci should never modify the lockfile or package.json
workspaces: this.workspaceNames,
allowScripts: allowScriptsPolicy,
// npm ci reifies the locked graph, which already carries extension-influenced edges, so it must never import or execute .npm-extension.
// The extension file hash is still validated below, independent of execution.
ignoreExtension: true,
}

// generate an inventory from the virtual tree in the lockfile
Expand All@@ -92,6 +95,16 @@ class CI extends ArboristWorkspaceCmd {
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))
// Verifies that the root .npm-extension file matches the lockfile hash.
// The hash comes from discovering the file (no import or execution), so this holds even under ignore-extension/ignore-scripts.
const { NpmExtension } = require('@npmcli/arborist')
let fileHash = null
try {
fileHash = new NpmExtension({ root: where, extensionFile: opts.extensionFile }).hash
} catch (err) {
errors.push(`Invalid: ${err.message}`)
}
errors.push(...validateNpmExtension(virtualArb.virtualTree, fileHash))
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
17 changes: 13 additions & 4 deletions lib/commands/ls.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -278,8 +278,8 @@ const augmentItemWithIncludeMetadata = (node, item) => {
return item
}

// Render a node's packageExtensions provenance as a short "field.name" list, empty when none.
const formatPackageExtensions = (applied) => {
// Render a manifest-extension provenance object as a short "field.name" list, empty when none.
const formatExtensionApplied = (applied) => {
if (!applied) {
return ''
}
Expand DownExpand Up@@ -354,8 +354,13 @@ const getHumanOutputItem = (node, { args, chalk, global, long }) => {
: ''
) +
(
formatPackageExtensions(node.packageExtensionsApplied)
? ' ' + chalk.dim(`packageExtensions: ${formatPackageExtensions(node.packageExtensionsApplied)}`)
formatExtensionApplied(node.packageExtensionsApplied)
? ' ' + chalk.dim(`packageExtensions: ${formatExtensionApplied(node.packageExtensionsApplied)}`)
: ''
) +
(
formatExtensionApplied(node.npmExtensionApplied)
? ' ' + chalk.dim(`.npm-extension: ${formatExtensionApplied(node.npmExtensionApplied)}`)
: ''
) +
(isGitNode(node) ? ` (${node.resolved})` : '') +
Expand DownExpand Up@@ -386,6 +391,10 @@ const getJsonOutputItem = (node, { global, long }) => {
item.packageExtensionsApplied = node.packageExtensionsApplied
}

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

item[_name] = node.name

// special formatting for top-level package name
Expand Down
13 changes: 13 additions & 0 deletions lib/npm.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,6 +118,19 @@ class Npm {
return { exec: false }
}

// extension-file selects root-owned install-time code, so it is only honored from project config or the command line.
// This is checked after #display.load() so the error is surfaced to the user instead of being swallowed during early config loading.
const extensionFile = this.config.get('extension-file')
if (extensionFile != null) {
const where = this.config.find('extension-file')
if (!['cli', 'project', 'default'].includes(where)) {
throw Object.assign(
new Error(`\`extension-file\` may only be set in project config or on the command line, not from ${where} config`),
{ code: 'ENPMEXTENSIONCONFIG' }
)
}
}

// mkdir this separately since the logs dir can be set to a different location.
// if this fails, then we don't have a cache dir, but we don't want to fail immediately since the command might not need a cache dir (like `npm --version`)
await time.start('npm:load:mkdirpcache', () =>
Expand Down
9 changes: 7 additions & 2 deletions lib/utils/explain-dep.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,7 +76,7 @@ const explainDependents = ({ dependents }, depth, chalk, seen) => {
}

const explainEdge = (
{ name, type, bundled, from, spec, rawSpec, overridden, packageExtensions },
{ name, type, bundled, from, spec, rawSpec, overridden, packageExtensions, npmExtension },
depth, chalk, seen = new Set()
) => {
let dep = type === 'workspace'
Expand All@@ -93,9 +93,14 @@ const explainEdge = (
? chalk.dim(` (added by packageExtensions["${packageExtensions.selector}"].${packageExtensions.field}.${name})`)
: ''

// note an edge created or changed by a root .npm-extension repair
const npmExtMsg = npmExtension
? chalk.dim(` (changed by .npm-extension ${npmExtension.extensionPoint} ${npmExtension.field}.${name})`)
: ''

return (type === 'prod' ? '' : `${colorType(type, chalk)} `) +
(bundled ? `${colorType('bundled', chalk)} ` : '') +
`${dep}${fromMsg}${extMsg}`
`${dep}${fromMsg}${extMsg}${npmExtMsg}`
}

const explainFrom = (from, depth, chalk, seen) => {
Expand Down
28 changes: 28 additions & 0 deletions lib/utils/validate-lockfile.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -98,5 +98,33 @@ function validatePackageExtensions (virtualTree, idealTree) {
return errors
}

// validates that the .npm-extension state recorded in the lockfile still matches the selected extension file.
// Validation is hash-based: arbitrary code has no selector to re-check, so a matching hash is trusted and a mismatch fails.
// fileHash is computed from the on-disk file (discovery only, no execution), so this holds even under ignore-extension/ignore-scripts.
// The lockfile carries extension state if it records a root hash or any per-package npmExtensionApplied provenance.
// Returns an array of human-readable error strings, empty when valid.
function validateNpmExtension (virtualTree, fileHash) {
const lockHash = virtualTree?.meta?.npmExtensionHash || null
const hasProvenance = !!virtualTree &&
[...virtualTree.inventory.values()].some(node => node.npmExtensionApplied)
fileHash = fileHash || null

if (fileHash) {
if (!lockHash) {
return ['Missing: .npm-extension state from lock file']
}
if (lockHash !== fileHash) {
return ['Invalid: .npm-extension file does not match the lock file']
}
return []
}
// no extension file present
if (lockHash || hasProvenance) {
return ['Invalid: lock file records .npm-extension state but no .npm-extension file is present']
}
return []
}

module.exports = validateLockfile
module.exports.validatePackageExtensions = validatePackageExtensions
module.exports.validateNpmExtension = validateNpmExtension
4 changes: 4 additions & 0 deletions tap-snapshots/test/lib/commands/config.js.test.cjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,6 +58,7 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna
"expect-result-count": null,
"expect-results": null,
"expires": null,
"extension-file": null,
"fetch-retries": 2,
"fetch-retry-factor": 10,
"fetch-retry-maxtimeout": 60000,
Expand All@@ -76,6 +77,7 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna
"heading": "npm",
"https-proxy": null,
"if-present": false,
"ignore-extension": false,
"ignore-scripts": false,
"include": [],
"include-staged": false,
Expand DownExpand Up@@ -254,6 +256,7 @@ engine-strict = false
expect-result-count = null
expect-results = null
expires = null
extension-file = null
fetch-retries = 2
fetch-retry-factor = 10
fetch-retry-maxtimeout = 60000
Expand All@@ -273,6 +276,7 @@ heading = "npm"
https-proxy = null
if-present = false
ignore-existing = false
ignore-extension = false
ignore-patch-failures = false
ignore-scripts = false
include = []
Expand Down
12 changes: 6 additions & 6 deletions tap-snapshots/test/lib/commands/install.js.test.cjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -135,8 +135,8 @@ verbose stack Error: The developer of this package has specified the following t
verbose stack Invalid devEngines.runtime
verbose stack Invalid name "nondescript" does not match "node" for "runtime"
verbose stack at Install.checkDevEngines ({CWD}/lib/base-cmd.js:249:27)
verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:281:7)
verbose stack at MockNpm.exec ({CWD}/lib/npm.js:181:9)
verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:294:7)
verbose stack at MockNpm.exec ({CWD}/lib/npm.js:194:9)
error code EBADDEVENGINES
error EBADDEVENGINES The developer of this package has specified the following through devEngines
error EBADDEVENGINES Invalid devEngines.runtime
Expand DownExpand Up@@ -200,8 +200,8 @@ verbose stack Error: The developer of this package has specified the following t
verbose stack Invalid devEngines.runtime
verbose stack Invalid name "nondescript" does not match "node" for "runtime"
verbose stack at Install.checkDevEngines ({CWD}/lib/base-cmd.js:249:27)
verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:281:7)
verbose stack at MockNpm.exec ({CWD}/lib/npm.js:181:9)
verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:294:7)
verbose stack at MockNpm.exec ({CWD}/lib/npm.js:194:9)
error code EBADDEVENGINES
error EBADDEVENGINES The developer of this package has specified the following through devEngines
error EBADDEVENGINES Invalid devEngines.runtime
Expand All@@ -226,8 +226,8 @@ verbose stack Error: The developer of this package has specified the following t
verbose stack Invalid devEngines.runtime
verbose stack Invalid name "nondescript" does not match "node" for "runtime"
verbose stack at Install.checkDevEngines ({CWD}/lib/base-cmd.js:249:27)
verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:281:7)
verbose stack at MockNpm.exec ({CWD}/lib/npm.js:181:9)
verbose stack at MockNpm.execCommandClass ({CWD}/lib/npm.js:294:7)
verbose stack at MockNpm.exec ({CWD}/lib/npm.js:194:9)
error code EBADDEVENGINES
error EBADDEVENGINES The developer of this package has specified the following through devEngines
error EBADDEVENGINES Invalid devEngines.runtime
Expand Down
6 changes: 6 additions & 0 deletions tap-snapshots/test/lib/commands/ls.js.test.cjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -315,6 +315,12 @@ test-npm-ls@1.0.0 {CWD}/prefix
\`-- dog@2.0.0
`

exports[`test/lib/commands/ls.js TAP ls .npm-extension dep > human output annotates the transformed node 1`] = `
test-npm-extension@1.0.0 {CWD}/prefix
\`-- foo@1.0.0 .npm-extension: dependencies.bar
\`-- bar@1.0.0
`

exports[`test/lib/commands/ls.js TAP ls broken resolved field > should NOT print git refs in output tree 1`] = `
npm-broken-resolved-field-test@1.0.0 {CWD}/prefix
\`-- a@1.0.1
Expand Down
Loading
Loading