Skip to content
Open
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
2 changes: 2 additions & 0 deletions DEPENDENCIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ graph LR;
npmcli-arborist-->proggy;
npmcli-arborist-->semver;
npmcli-arborist-->ssri;
npmcli-arborist-->validate-npm-package-name;
npmcli-config-->ini;
npmcli-config-->nopt;
npmcli-config-->npmcli-eslint-config["@npmcli/eslint-config"];
Expand Down Expand Up @@ -567,6 +568,7 @@ graph LR;
npmcli-arborist-->tar-stream;
npmcli-arborist-->tcompare;
npmcli-arborist-->treeverse;
npmcli-arborist-->validate-npm-package-name;
npmcli-arborist-->walk-up-path;
npmcli-config-->ci-info;
npmcli-config-->ini;
Expand Down
60 changes: 60 additions & 0 deletions docs/lib/content/configuring-npm/package-json.md
Original file line number Diff line number Diff line change
Expand Up @@ -1018,6 +1018,66 @@ For example, to replace a transitive dependency with a fork:
}
```

### packageExtensions

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

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

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

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

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

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

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

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

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

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

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

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

### engines

You can specify the version of node that your stuff works on:
Expand Down
3 changes: 3 additions & 0 deletions lib/commands/ci.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const fs = require('node:fs/promises')
const path = require('node:path')
const { log, time } = require('proc-log')
const validateLockfile = require('../utils/validate-lockfile.js')
const { validatePackageExtensions } = require('../utils/validate-lockfile.js')
const ArboristWorkspaceCmd = require('../arborist-cmd.js')
const getWorkspaces = require('../utils/get-workspaces.js')

Expand Down Expand Up @@ -80,6 +81,8 @@ class CI extends ArboristWorkspaceCmd {

// Verifies that the packages from the ideal tree will match the same versions that are present in the virtual tree (lock file).
const errors = validateLockfile(virtualInventory, arb.idealTree.inventory)
// Verifies that the root packageExtensions state matches the lockfile and is still consistent with the locked tree.
errors.push(...validatePackageExtensions(virtualArb.virtualTree, arb.idealTree))
if (errors.length) {
throw this.usageError(
'`npm ci` can only install packages when your package.json and package-lock.json are in sync. ' +
Expand Down
24 changes: 24 additions & 0 deletions lib/commands/ls.js
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,21 @@ const augmentItemWithIncludeMetadata = (node, item) => {
return item
}

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

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

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

item[_name] = node.name

// special formatting for top-level package name
Expand Down
15 changes: 15 additions & 0 deletions lib/commands/publish.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ class Publish extends BaseCommand {
const spec = npa(args[0])
let manifest = await this.#getManifest(spec, opts)

// packageExtensions is root-only project policy and must never be published; fail fast so dry-run reports it too
this.#assertNoPackageExtensions(manifest)

// only run scripts for directory type publishes
if (spec.type === 'directory' && !ignoreScripts) {
await runScript({
Expand All @@ -122,6 +125,8 @@ class Publish extends BaseCommand {

// The purpose of re-reading the manifest is in case it changed, so that we send the latest and greatest thing to the registry note that publishConfig might have changed as well!
manifest = await this.#getManifest(spec, opts, true)
// re-check the authoritative manifest in case a lifecycle script introduced packageExtensions
this.#assertNoPackageExtensions(manifest)
const force = this.npm.config.get('force')
const isDefaultTag = this.npm.config.isDefault('tag') && !manifest.publishConfig?.tag

Expand Down Expand Up @@ -273,6 +278,16 @@ class Publish extends BaseCommand {
}
}

// packageExtensions is root-only project policy and must never reach the registry; private packages may keep it for local use
#assertNoPackageExtensions (manifest) {
if (!manifest.private && manifest.packageExtensions !== undefined) {
throw Object.assign(
new Error('packageExtensions is only honored at the project root and must not be published.'),
{ code: 'EPACKAGEEXTENSIONS' }
)
}
}

// if it's a directory, read it from the file system
// otherwise, get the full metadata from whatever it is
// XXX can't pacote read the manifest from a directory?
Expand Down
9 changes: 7 additions & 2 deletions lib/utils/explain-dep.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ const explainDependents = ({ dependents }, depth, chalk, seen) => {
}

const explainEdge = (
{ name, type, bundled, from, spec, rawSpec, overridden },
{ name, type, bundled, from, spec, rawSpec, overridden, packageExtensions },
depth, chalk, seen = new Set()
) => {
let dep = type === 'workspace'
Expand All @@ -88,9 +88,14 @@ const explainEdge = (

const fromMsg = ` from ${explainFrom(from, depth, chalk, seen)}`

// note an edge created by a root packageExtensions repair
const extMsg = packageExtensions
? chalk.dim(` (added by packageExtensions["${packageExtensions.selector}"].${packageExtensions.field}.${name})`)
: ''

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

const explainFrom = (from, depth, chalk, seen) => {
Expand Down
55 changes: 55 additions & 0 deletions lib/utils/validate-lockfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,59 @@ function validateLockfile (virtualTree, idealTree) {
return errors
}

// validates that the root packageExtensions state matches what the lockfile recorded, and that the locked tree is still consistent with the rule set.
// Returns an array of human-readable error strings, empty when valid.
function validatePackageExtensions (virtualTree, idealTree) {
const errors = []
const lockHash = virtualTree.meta?.packageExtensionsHash || null
const idealHash = idealTree.meta?.packageExtensionsHash || null

if (idealHash !== lockHash) {
if (idealHash && !lockHash) {
errors.push('Missing: packageExtensions state from lock file')
} else if (!idealHash && lockHash) {
errors.push('Invalid: lock file records packageExtensions state but package.json has none')
} else {
errors.push('Invalid: packageExtensions in package.json do not match the lock file')
}
// once the canonical hashes diverge, the deeper per-node checks are moot
return errors
}

// the hashes match, so validate the locked tree's own consistency against the rules
const { PackageExtensions } = require('@npmcli/arborist')
const root = idealTree.target || idealTree
let pe
try {
pe = new PackageExtensions(root.package?.packageExtensions)
} catch (err) {
return [`Invalid: ${err.message}`]
}

for (const node of virtualTree.inventory.values()) {
if (node.isProjectRoot || node.isWorkspace) {
continue
}
// selectors match the underlying package identity, which is the alias target for aliased installs
const name = node.packageName || node.name
// a locked package identity must not match more than one selector
try {
pe.match(name, node.version)
} catch (err) {
errors.push(`Invalid: ${err.message}`)
}
// recorded provenance must still correspond to a selector that matches the node
const applied = node.packageExtensionsApplied
if (applied) {
const sel = pe.selectors.find(s => s.key === applied.selector)
if (!sel || !pe.wouldMatch(name, node.version)) {
errors.push(
`Invalid: stale packageExtensions provenance for ${node.name}@${node.version} (selector "${applied.selector}")`)
}
}
}
return errors
}

module.exports = validateLockfile
module.exports.validatePackageExtensions = validatePackageExtensions
1 change: 1 addition & 0 deletions package-lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -14725,6 +14725,7 @@
"semver": "^7.3.7",
"ssri": "^13.0.0",
"treeverse": "^3.0.0",
"validate-npm-package-name": "^7.0.2",
"walk-up-path": "^4.0.0"
},
"bin": {
Expand Down
6 changes: 6 additions & 0 deletions tap-snapshots/test/lib/commands/ls.js.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,12 @@ exports[`test/lib/commands/ls.js TAP ls overridden dep w/ color > should contain

`

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

exports[`test/lib/commands/ls.js TAP ls print deduped symlinks > should output tree containing linked deps 1`] = `
print-deduped-symlinks@1.0.0 {CWD}/prefix
+-- a@1.0.0
Expand Down
24 changes: 24 additions & 0 deletions tap-snapshots/test/lib/utils/explain-dep.js.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,30 @@ overridden-root@1.0.0 overridden
node_modules/overridden-root
`

exports[`test/lib/utils/explain-dep.js TAP basic packageExtensions > explain color deep 1`] = `
bar@1.2.3
node_modules/bar
bar@"^1.0.0" from foo@1.0.0
node_modules/foo (added by packageExtensions["foo@1"].dependencies.bar)
`

exports[`test/lib/utils/explain-dep.js TAP basic packageExtensions > explain nocolor shallow 1`] = `
bar@1.2.3
node_modules/bar
bar@"^1.0.0" from foo@1.0.0
node_modules/foo (added by packageExtensions["foo@1"].dependencies.bar)
`

exports[`test/lib/utils/explain-dep.js TAP basic packageExtensions > print color 1`] = `
bar@1.2.3
node_modules/bar
`

exports[`test/lib/utils/explain-dep.js TAP basic packageExtensions > print nocolor 1`] = `
bar@1.2.3
node_modules/bar
`

exports[`test/lib/utils/explain-dep.js TAP basic peer > explain color deep 1`] = `
peer@1.0.0 peer
node_modules/peer
Expand Down
17 changes: 17 additions & 0 deletions test/lib/commands/ci.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,23 @@ t.test('reifies, audits, removes node_modules on repeat run', async t => {
t.equal(fs.existsSync(nmAbbrev), true, 'installs abbrev')
})

t.test('fails when packageExtensions are out of sync with the lock file', async t => {
const { npm } = await loadMockNpm(t, {
config: { audit: false },
prefixDir: {
abbrev,
// packageExtensions present in package.json but the lock file records no hash
'package.json': JSON.stringify({ ...packageJson, packageExtensions: {} }),
'package-lock.json': JSON.stringify(packageLock),
},
})
await t.rejects(
npm.exec('ci', []),
/packageExtensions state from lock file/,
'ci refuses to install with stale packageExtensions state'
)
})

t.test('--no-audit and --ignore-scripts', async t => {
const { npm, joinedOutput, registry } = await loadMockNpm(t, {
config: {
Expand Down
43 changes: 43 additions & 0 deletions test/lib/commands/ls.js
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,49 @@ t.test('ls', async t => {
t.matchSnapshot(cleanCwd(result()), 'should contain overridden output')
})

const packageExtensionsPrefix = {
'package.json': JSON.stringify({
name: 'test-package-extensions',
version: '1.0.0',
dependencies: { foo: '^1.0.0' },
packageExtensions: { 'foo@1': { dependencies: { bar: '^1.0.0' } } },
}),
node_modules: {
'.package-lock.json': JSON.stringify({
packages: {
'node_modules/foo': {
version: '1.0.0',
dependencies: { bar: '^1.0.0' },
packageExtensionsApplied: { selector: 'foo@1', dependencies: ['bar'] },
},
'node_modules/bar': { version: '1.0.0' },
},
}),
foo: {
'package.json': JSON.stringify({ name: 'foo', version: '1.0.0', dependencies: { bar: '^1.0.0' } }),
},
bar: { 'package.json': JSON.stringify({ name: 'bar', version: '1.0.0' }) },
},
}

t.test('packageExtensions dep', async t => {
const { npm, result, ls } = await mockLs(t, { config: {}, prefixDir: packageExtensionsPrefix })
touchHiddenPackageLock(npm.prefix)
await ls.exec([])
t.matchSnapshot(cleanCwd(result()), 'human output annotates the extended node')
})

t.test('packageExtensions dep --json', async t => {
const { npm, result, ls } = await mockLs(t, {
config: { json: true },
prefixDir: packageExtensionsPrefix,
})
touchHiddenPackageLock(npm.prefix)
await ls.exec([])
const applied = JSON.parse(result()).dependencies.foo.packageExtensionsApplied
t.match(applied, { selector: 'foo@1', dependencies: ['bar'] }, 'json output includes provenance')
})

t.test('with filter arg', async t => {
const config = {
color: 'always',
Expand Down
Loading
Loading