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
15 changes: 14 additions & 1 deletion docs/lib/content/commands/npm-install-scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,15 @@ contexts, use the `--allow-scripts` flag at install time (for example
`npm install -g --allow-scripts=canvas,sharp`) or persist the setting with
`npm config set allow-scripts=canvas,sharp --location=user`.

There are three subcommands:
There are four subcommands:

```bash
npm install-scripts approve <pkg> [<pkg> ...]
npm install-scripts approve --all
npm install-scripts deny <pkg> [<pkg> ...]
npm install-scripts deny --all
npm install-scripts ls
npm install-scripts prune
```

`approve` allows install scripts for the named packages. `<pkg>` matches
Expand All @@ -53,6 +54,14 @@ unreviewed install scripts.
`ls` is read-only: it lists every package whose install scripts are not yet
covered by `allowScripts`, without modifying `package.json`.

`prune` removes `allowScripts` entries that no longer match an installed
package with an install script, either because the package is no longer
installed (a transitive dependency changed, or a pinned `pkg@1.2.3` was
upgraded) or because it no longer has an install script. Both approvals
(`true`) and denials (`false`) are removed. It edits only the `allowScripts`
field in `package.json`, never `.npmrc` or `--allow-scripts`. Pass `--dry-run`
to preview without writing. Unparseable keys are left alone.

`approve` honours the asymmetric pin rule: if you re-approve a package whose
installed version has changed, the existing pin is rewritten to track the new
installed version. Multi-version statements (`pkg@1 || 2`) are left alone,
Expand All @@ -78,6 +87,10 @@ npm install-scripts deny telemetry-pkg

# Preview which packages still need review
npm install-scripts ls

# Preview stale allowScripts entries, then remove them
npm install-scripts prune --dry-run
npm install-scripts prune
```

### Configuration
Expand Down
14 changes: 9 additions & 5 deletions lib/commands/install-scripts.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
const AllowScriptsCmd = require('../utils/allow-scripts-cmd.js')

// Namespaced front-end for managing install-script approvals.
// `approve` and `deny` write the `allowScripts` policy; `ls` lists packages with unreviewed install scripts.
// The standalone `npm approve-scripts` and `npm deny-scripts` commands remain as aliases for `approve` and `deny`.
// Namespaced front-end for install-script approvals.
// `approve`/`deny` write the `allowScripts` policy, `ls` lists unreviewed packages,
// `prune` drops entries that no longer match an installed package with an install script.
// `npm approve-scripts` / `npm deny-scripts` are aliases for `approve` / `deny`.
class InstallScripts extends AllowScriptsCmd {
static description = 'Manage install-script approvals for dependencies'
static name = 'install-scripts'
Expand All @@ -12,13 +13,14 @@ class InstallScripts extends AllowScriptsCmd {
'deny <pkg> [<pkg> ...]',
'deny --all',
'ls',
'prune',
]

static params = ['all', 'allow-scripts-pin', 'json']
static params = ['all', 'allow-scripts-pin', 'dry-run', 'json']

static async completion (opts) {
const argv = opts.conf.argv.remain
const subcommands = ['approve', 'deny', 'ls']
const subcommands = ['approve', 'deny', 'ls', 'prune']
if (argv.length === 2) {
return subcommands
}
Expand All @@ -38,6 +40,8 @@ class InstallScripts extends AllowScriptsCmd {
case 'ls':
case 'list':
return this.runMode('list', rest)
case 'prune':
return this.runMode('prune', rest)
default:
throw this.usageError(
sub ? `\`${sub}\` is not a recognized subcommand.` : undefined
Expand Down
82 changes: 81 additions & 1 deletion lib/utils/allow-scripts-cmd.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ const npa = require('npm-package-arg')
const semver = require('semver')
const pkgJson = require('@npmcli/package-json')
const { trustedDisplay } = require('@npmcli/arborist/lib/script-allowed.js')
const getInstallScripts = require('@npmcli/arborist/lib/install-scripts.js')
const checkAllowScripts = require('./check-allow-scripts.js')
const resolveAllowScripts = require('./resolve-allow-scripts.js')
const {
applyApprovalForPackage,
applyDenyForPackage,
nameKeyFor,
} = require('./allow-scripts-writer.js')
const { classifyUnusedEntries } = require('./allow-scripts-prune.js')
const BaseCommand = require('../base-cmd.js')

// Parse a positional arg into a name and an optional version range. A bare
Expand Down Expand Up @@ -50,7 +52,7 @@ class AllowScriptsCmd extends BaseCommand {
static ignoreImplicitWorkspace = false

// Mode of the current run, set by runMode.
// One of 'approve', 'deny', or 'list'.
// One of 'approve', 'deny', 'list', or 'prune'.
#mode = null

// verb drives the writers and summaries, which only run in the two write modes, so it is never read while listing.
Expand All @@ -73,6 +75,12 @@ class AllowScriptsCmd extends BaseCommand {
)
}

// `prune` has its own flow: it reads the literal package.json#allowScripts
// map, not the resolved policy.
if (mode === 'prune') {
return this.runPrune(args)
}

// `--allow-scripts-pending` is only honored by commands that declare it; the namespace lists via `ls` instead.
const pending = this.constructor.params.includes('allow-scripts-pending') &&
!!this.npm.config.get('allow-scripts-pending')
Expand Down Expand Up @@ -318,6 +326,78 @@ class AllowScriptsCmd extends BaseCommand {
output.standard(`Nothing to ${this.verb}; allowScripts unchanged.`)
}
}

// `npm install-scripts prune`: drop package.json#allowScripts entries that no
// longer match an installed package with an install script. Edits only
// package.json (never `.npmrc`/CLI policy); `--dry-run` reports without writing.
async runPrune (args) {
const all = !!this.npm.config.get('all')
if (args.length > 0 || all) {
throw this.usageError(
'`npm install-scripts prune` cannot be combined with positional arguments or `--all`.'
)
}

const dryRun = !!this.npm.config.get('dry-run')
const pkg = await pkgJson.load(this.npm.prefix)
const existing = pkg.content.allowScripts && typeof pkg.content.allowScripts === 'object'
? pkg.content.allowScripts
: {}

let removed = []
if (Object.keys(existing).length > 0) {
const Arborist = require('@npmcli/arborist')
const arb = new Arborist({
...this.npm.flatOptions,
path: this.npm.prefix,
})
await arb.loadActual()

// Candidate install nodes (mirrors collectUnreviewedScripts), tagged with
// whether each has install scripts so the classifier can tell "gone" from
// "no longer has scripts".
const nodes = []
for (const node of arb.actualTree.inventory.values()) {
if (node.isProjectRoot || node.isWorkspace || node.isLink || node.inBundle || node.inert) {
continue
}
const scripts = await getInstallScripts(node)
nodes.push({ node, hasScripts: Object.keys(scripts).length > 0 })
}

const { remaining, removed: unused } = classifyUnusedEntries(existing, nodes)
removed = unused

if (removed.length > 0 && !dryRun) {
// Drop the field entirely when nothing is left rather than leaving `{}`.
pkg.update({
allowScripts: Object.keys(remaining).length > 0 ? remaining : undefined,
})
await pkg.save()
}
}

this.printPruneSummary({ removed, dryRun })
}

printPruneSummary ({ removed, dryRun }) {
if (this.npm.flatOptions.json) {
output.buffer({ allowScripts: { removed, dryRun } })
return
}
if (removed.length === 0) {
output.standard('No unused allowScripts entries.')
return
}
const entry = removed.length === 1 ? 'entry' : 'entries'
output.standard(
`${dryRun ? 'Would remove' : 'Removed'} ${removed.length} unused allowScripts ${entry}:`
)
for (const { key, reason } of removed) {
const text = reason === 'not-installed' ? 'package not installed' : 'no install scripts'
output.standard(` ${key} (${text})`)
}
}
}

module.exports = AllowScriptsCmd
47 changes: 47 additions & 0 deletions lib/utils/allow-scripts-prune.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
const npa = require('npm-package-arg')
const { matches } = require('@npmcli/arborist/lib/script-allowed.js')

// Pure classifier behind `npm install-scripts prune`.
//
// Splits the `package.json#allowScripts` map into entries to keep (`remaining`)
// and unused ones to drop (`removed`). `nodes` is the install candidates as
// `{ node, hasScripts }`; the caller gathers them so this stays sync.
//
// An entry is unused (version-aware, by trusted identity) when:
// - no installed node matches the key -> reason 'not-installed'
// - it matches but none have scripts -> reason 'no-scripts'
//
// Unparseable keys are kept (prune never drops what it can't parse). Both
// `true` (approve) and `false` (deny) entries are pruned.
const classifyUnusedEntries = (allowScripts, nodes) => {
const remaining = {}
const removed = []

for (const [key, value] of Object.entries(allowScripts || {})) {
let parseable = true
try {
npa(key)
} catch {
parseable = false
}
if (!parseable) {
remaining[key] = value
continue
}

const matching = nodes.filter(({ node }) => matches(node, key))
if (matching.length === 0) {
removed.push({ key, value, reason: 'not-installed' })
continue
}
if (!matching.some(({ hasScripts }) => hasScripts)) {
removed.push({ key, value, reason: 'no-scripts' })
continue
}
remaining[key] = value
}

return { remaining, removed }
}

module.exports = { classifyUnusedEntries }
8 changes: 7 additions & 1 deletion tap-snapshots/test/lib/docs.js.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -4597,16 +4597,20 @@ npm install-scripts approve --all
npm install-scripts deny <pkg> [<pkg> ...]
npm install-scripts deny --all
npm install-scripts ls
npm install-scripts prune

Options:
[-a|--all] [--no-allow-scripts-pin] [--json]
[-a|--all] [--no-allow-scripts-pin] [--dry-run] [--json]

-a|--all
Show or act on all packages, not just the ones your project directly

--allow-scripts-pin
Write pinned (\`pkg@version\`) entries when approving install scripts.

--dry-run
Indicates that you don't want npm to make any changes and that it should

--json
Whether or not to output JSON data, rather than the normal output.

Expand All @@ -4619,12 +4623,14 @@ npm install-scripts approve --all
npm install-scripts deny <pkg> [<pkg> ...]
npm install-scripts deny --all
npm install-scripts ls
npm install-scripts prune
\`\`\`

Note: This command is unaware of workspaces.

#### \`all\`
#### \`allow-scripts-pin\`
#### \`dry-run\`
#### \`json\`
`

Expand Down
Loading
Loading