Skip to content

Commit 8543be7

Browse files
authored
feat(pm): support npm approve-scripts/deny-scripts in approve-builds (#1733)
## Summary npm 11.16.0 ([npm/cli#9360](npm/cli#9360), "Phase 1 of `allowScripts` opt-in install-script policy") adds `npm approve-scripts` and `npm deny-scripts`, which manage an advisory `allowScripts` field in `package.json`. This is the npm equivalent of `pnpm approve-builds` / `bun pm trust`. `vp pm approve-builds` previously warned and exited 0 (no-op) on npm. It now forwards to npm's real commands when the detected npm is `>= 11.16.0`. ## Mapping (npm >= 11.16.0) | `vp pm approve-builds` invocation | npm command | | ----------------------------------- | --------------------------------------------- | | `<pkg>...` (approves) | `npm approve-scripts <pkg>...` | | `--all` | `npm approve-scripts --all` | | (no args) | `npm approve-scripts --allow-scripts-pending` (read-only list) | | `!<pkg>...` (denies, `!` stripped) | `npm deny-scripts <pkg>...` | | mixed approves + `!denies` | rejected with an actionable error | | npm < 11.16.0 | warn + exit 0 (no-op), advise upgrade | ## Notes - **Mixed approve+deny is rejected** rather than silently split: npm separates approve vs. deny into two commands, so `vp pm approve-builds esbuild !core-js` returns a clear message asking the user to run the two operations separately (pnpm handles the mixed case in one command). This keeps the single-command return type intact. - **Advisory caveat surfaced:** npm 11.x's `allowScripts` is advisory only (install scripts still run; npm just warns about unreviewed packages). A one-line note is shown after an approve/deny write so users aren't misled. Not shown on the read-only `--allow-scripts-pending` listing. - Version gating reuses the existing `version_satisfies`/`node_semver` pattern (`npm_supports_allow_scripts` = `>=11.16.0`), matching pnpm's prerelease semantics. - Help text for the deny prefix and `--all` updated from "pnpm only" to reflect pnpm + npm support. ## Tests - 9 new unit tests in `approve_builds.rs` (approve-by-name, `--all`, pending-list, deny-only, multi-deny, mixed-rejected, pass-through, below-gate no-op, prerelease no-op). The `Option` return type is unchanged, so existing tests are untouched. - New global snap test `command-pm-approve-builds-npm11/` (npm@11.16.0) exercising the real npm commands end-to-end. - 4 existing approve-builds snaps regenerated for the help-text wording change and the updated npm warn message. ## Validation - `cargo test -p vite_install -p vite_pm_cli` (510 passed) - `just check` - `cargo clippy -p vite_install -p vite_pm_cli -- -D warnings` - `pnpm bootstrap-cli` + local/global approve-builds snap tests regenerated and reviewed <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Changes are localized to PM command resolution and user messaging; npm below 11.16.0 and yarn/pnpm/bun paths stay the same aside from help text. > > **Overview** > **`vp pm approve-builds` now forwards to npm on npm ≥ 11.16.0** instead of always warning and no-op’ing. Older npm still gets the legacy warn + exit 0, with copy that mentions upgrading to 11.16.0. > > For supported npm versions, invocations map to **`npm approve-scripts`** (packages, `--all`, or no-args → `--allow-scripts-pending` pending list) and **`npm deny-scripts`** when only `!pkg` tokens are passed (`!` stripped). Mixed approve + deny in one call is **rejected** with guidance to run two separate commands. Package names passed only after `--` on the pending-list path are also rejected. > > After writes that change **`allowScripts`**, a **note** explains npm 11.x policy is advisory (scripts still run; enforcement is future). Pass-through args are forwarded on the npm path like pnpm/bun. > > CLI help and the approve-builds RFC are updated for pnpm + npm parity on `!pkg`, `--all`, and no-args behavior. Coverage adds many npm 11.16 unit tests, a global snap fixture for npm@11.16.0, and regenerated snaps for help/warn text. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 2a34ce3. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent a1a2bbd commit 8543be7

10 files changed

Lines changed: 343 additions & 41 deletions

File tree

crates/vite_install/src/commands/approve_builds.rs

Lines changed: 251 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ use crate::package_manager::{
1010
PackageManager, PackageManagerType, ResolveCommandResult, format_path_env,
1111
};
1212

13+
/// Note shown after an npm `approve-scripts`/`deny-scripts` write, so users
14+
/// aren't misled into thinking lifecycle scripts are now blocked.
15+
const NPM_ADVISORY_NOTE: &str = "npm's allowScripts policy is advisory in npm 11.x: install scripts still run; npm only \
16+
warns about unreviewed packages at install time. Enforcement is planned for a future \
17+
npm release.";
18+
1319
/// Options for the approve-builds command.
1420
#[derive(Debug, Default)]
1521
pub struct ApproveBuildsCommandOptions<'a> {
@@ -24,7 +30,7 @@ pub struct ApproveBuildsCommandOptions<'a> {
2430
impl PackageManager {
2531
/// Run the approve-builds command with the package manager.
2632
/// Returns `ExitStatus` with success (0) when the command is a no-op
27-
/// (npm, yarn, or bun with only deny tokens / no positionals).
33+
/// (npm < 11.16.0, yarn, or bun with only deny tokens / no positionals).
2834
pub async fn run_approve_builds_command(
2935
&self,
3036
options: &ApproveBuildsCommandOptions<'_>,
@@ -38,9 +44,10 @@ impl PackageManager {
3844

3945
/// Resolve the approve-builds command.
4046
/// Returns `None` when the command is a no-op for the detected PM
41-
/// (npm/yarn warn; bun with no approve-tokens prints a contextual hint).
42-
/// Returns `Err(Error::InvalidArgument)` when `--all` or `!pkg` is requested
43-
/// on a pnpm version that does not support it.
47+
/// (npm < 11.16.0 / yarn warn; bun with no approve-tokens prints a contextual hint).
48+
/// npm >= 11.16.0 forwards to `npm approve-scripts` / `deny-scripts`.
49+
/// Returns `Err(Error::InvalidArgument)` when `--all` or `!pkg` is requested on a
50+
/// pnpm version that does not support it, or when approves and denies are mixed on npm.
4451
pub fn resolve_approve_builds_command(
4552
&self,
4653
options: &ApproveBuildsCommandOptions,
@@ -121,13 +128,79 @@ impl PackageManager {
121128
args.extend(approves.into_iter().cloned());
122129
}
123130
PackageManagerType::Npm => {
124-
output::warn(
125-
"npm runs lifecycle scripts by default. To restrict them, set \
126-
`ignore-scripts=true` in .npmrc and rebuild approved packages with \
127-
`vp pm rebuild <package>`.",
128-
);
129-
warn_dropped_pass_through(options.pass_through_args);
130-
return Ok(None);
131+
// npm < 11.16.0 has no approve-scripts/deny-scripts: keep the legacy warn
132+
// no-op (scripts run by default; advise restricting via .npmrc), enhanced to
133+
// point at the upgrade. `vp pm approve-builds` always runs in a project
134+
// context, so npm's global-only EGLOBAL error is never hit.
135+
if !npm_supports_allow_scripts(&self.version) {
136+
output::warn(
137+
"npm runs lifecycle scripts by default. Upgrade to npm >= 11.16.0 for \
138+
`npm approve-scripts`/`deny-scripts`, or set `ignore-scripts=true` in \
139+
.npmrc and rebuild approved packages with `vp pm rebuild <package>`.",
140+
);
141+
warn_dropped_pass_through(options.pass_through_args);
142+
return Ok(None);
143+
}
144+
145+
// npm splits approve vs. deny into two separate subcommands. vp accepts both
146+
// in one invocation (pnpm runs them as one command); reject the mixed case
147+
// rather than widen the single-command return type. Mirror the bun branch's
148+
// partition idiom.
149+
let (denies, approves): (Vec<&String>, Vec<&String>) =
150+
options.packages.iter().partition(|p| p.starts_with('!'));
151+
let has_denies = !denies.is_empty();
152+
let has_approves = !approves.is_empty();
153+
154+
if has_approves && has_denies {
155+
return Err(Error::InvalidArgument(
156+
"npm manages approvals and denials separately. Run them as two \
157+
invocations, e.g. `vp pm approve-builds <approve-pkg>...` then \
158+
`vp pm approve-builds !<deny-pkg>...`."
159+
.into(),
160+
));
161+
}
162+
163+
bin_name = "npm".into();
164+
// Every branch except the read-only pending listing writes the
165+
// allowScripts policy and warrants the advisory note.
166+
let writes_policy;
167+
if has_denies {
168+
// `deny-scripts <pkg...>` — strip the leading `!`, like the bun branch.
169+
args.push("deny-scripts".into());
170+
args.extend(
171+
denies.into_iter().map(|p| p.strip_prefix('!').unwrap_or(p).to_string()),
172+
);
173+
writes_policy = true;
174+
} else {
175+
args.push("approve-scripts".into());
176+
if options.all {
177+
args.push("--all".into());
178+
writes_policy = true;
179+
} else if has_approves {
180+
args.extend(approves.into_iter().cloned());
181+
writes_policy = true;
182+
} else if options
183+
.pass_through_args
184+
.is_some_and(|extras| extras.iter().any(is_positional_arg))
185+
{
186+
// npm's read-only `--allow-scripts-pending` listing rejects
187+
// positionals; a package name passed via `--` was almost
188+
// certainly meant to be approved.
189+
return Err(Error::InvalidArgument(
190+
"Pass package names as positionals \
191+
(`vp pm approve-builds <pkg>...`), not after `--`."
192+
.into(),
193+
));
194+
} else {
195+
// No args, no --all: npm has no interactive picker — list
196+
// pending (read-only). Nothing is written.
197+
args.push("--allow-scripts-pending".into());
198+
writes_policy = false;
199+
}
200+
}
201+
if writes_policy {
202+
output::note(NPM_ADVISORY_NOTE);
203+
}
131204
}
132205
PackageManagerType::Yarn => {
133206
// Yarn 1 (Classic) runs lifecycle scripts by default; Berry (2+) blocks them.
@@ -148,7 +221,8 @@ impl PackageManager {
148221
}
149222
}
150223

151-
// Append pass-through args to the underlying PM (pnpm/bun branches only).
224+
// Append pass-through args to the underlying PM (pnpm, npm, and bun reach here;
225+
// yarn and npm < 11.16.0 returned early above).
152226
if let Some(extra) = options.pass_through_args {
153227
args.extend_from_slice(extra);
154228
}
@@ -173,6 +247,13 @@ fn pnpm_supports_deny_syntax(version: &str) -> bool {
173247
version_satisfies(version, ">=11.0.0")
174248
}
175249

250+
fn npm_supports_allow_scripts(version: &str) -> bool {
251+
// `npm approve-scripts` / `deny-scripts` / `--allow-scripts-pending` shipped in npm
252+
// v11.16.0 (npm/cli #9360, Phase 1). Same npm prerelease semantics as the pnpm gates:
253+
// an `11.16.0-rc.x` tag does NOT satisfy `>=11.16.0`.
254+
version_satisfies(version, ">=11.16.0")
255+
}
256+
176257
fn version_satisfies(version: &str, range: &'static str) -> bool {
177258
// Static range strings always parse; unparsable user-supplied versions
178259
// are treated as not-satisfying (strict), since the production path
@@ -505,6 +586,7 @@ mod tests {
505586

506587
#[test]
507588
fn npm_warns_and_noop() {
589+
// npm < 11.16.0 has no approve-scripts/deny-scripts: legacy warn no-op.
508590
let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0");
509591
let packages = vec!["esbuild".to_string()];
510592
let result = pm
@@ -516,6 +598,163 @@ mod tests {
516598
assert!(result.is_none());
517599
}
518600

601+
#[test]
602+
fn npm_below_11_16_warns_noop() {
603+
// The release immediately before approve-scripts/deny-scripts shipped.
604+
let pm = create_mock_package_manager(PackageManagerType::Npm, "11.15.0");
605+
let packages = vec!["esbuild".to_string()];
606+
let result = pm
607+
.resolve_approve_builds_command(&ApproveBuildsCommandOptions {
608+
packages: &packages,
609+
..Default::default()
610+
})
611+
.expect("resolves");
612+
assert!(result.is_none());
613+
}
614+
615+
#[test]
616+
fn npm_11_16_prerelease_noop() {
617+
// npm semver convention: `11.16.0-rc.0` does not satisfy `>=11.16.0`, so it
618+
// falls back to the legacy no-op (mirrors pnpm_all_rejects_v11_prerelease).
619+
let pm = create_mock_package_manager(PackageManagerType::Npm, "11.16.0-rc.0");
620+
let packages = vec!["esbuild".to_string()];
621+
let result = pm
622+
.resolve_approve_builds_command(&ApproveBuildsCommandOptions {
623+
packages: &packages,
624+
..Default::default()
625+
})
626+
.expect("resolves");
627+
assert!(result.is_none());
628+
}
629+
630+
#[test]
631+
fn npm_v11_16_approve_by_name() {
632+
let pm = create_mock_package_manager(PackageManagerType::Npm, "11.16.0");
633+
let packages = vec!["esbuild".to_string(), "fsevents".to_string()];
634+
let result = pm
635+
.resolve_approve_builds_command(&ApproveBuildsCommandOptions {
636+
packages: &packages,
637+
..Default::default()
638+
})
639+
.expect("resolves")
640+
.expect("supported");
641+
assert_eq!(result.bin_path, "npm");
642+
assert_eq!(result.args, vec!["approve-scripts", "esbuild", "fsevents"]);
643+
}
644+
645+
#[test]
646+
fn npm_v11_16_all() {
647+
let pm = create_mock_package_manager(PackageManagerType::Npm, "11.16.0");
648+
let result = pm
649+
.resolve_approve_builds_command(&ApproveBuildsCommandOptions {
650+
all: true,
651+
..Default::default()
652+
})
653+
.expect("resolves")
654+
.expect("supported");
655+
assert_eq!(result.args, vec!["approve-scripts", "--all"]);
656+
}
657+
658+
#[test]
659+
fn npm_v11_16_no_args_lists_pending() {
660+
let pm = create_mock_package_manager(PackageManagerType::Npm, "11.16.0");
661+
let result = pm
662+
.resolve_approve_builds_command(&ApproveBuildsCommandOptions::default())
663+
.expect("resolves")
664+
.expect("supported");
665+
assert_eq!(result.args, vec!["approve-scripts", "--allow-scripts-pending"]);
666+
}
667+
668+
#[test]
669+
fn npm_v11_16_pending_forwards_flags() {
670+
// Flags passed via `--` are still forwarded to the read-only listing.
671+
let pm = create_mock_package_manager(PackageManagerType::Npm, "11.16.0");
672+
let extra = vec!["--json".to_string()];
673+
let result = pm
674+
.resolve_approve_builds_command(&ApproveBuildsCommandOptions {
675+
pass_through_args: Some(&extra),
676+
..Default::default()
677+
})
678+
.expect("resolves")
679+
.expect("supported");
680+
assert_eq!(result.args, vec!["approve-scripts", "--allow-scripts-pending", "--json"]);
681+
}
682+
683+
#[test]
684+
fn npm_v11_16_pending_rejects_positional_pass_through() {
685+
// npm's `--allow-scripts-pending` listing rejects positionals; a package name
686+
// slipped in via `--` (with no leading positionals) is rejected up-front with a
687+
// clean message instead of building an invalid `npm approve-scripts` command.
688+
let pm = create_mock_package_manager(PackageManagerType::Npm, "11.16.0");
689+
let extra = vec!["esbuild".to_string()];
690+
let err = pm
691+
.resolve_approve_builds_command(&ApproveBuildsCommandOptions {
692+
pass_through_args: Some(&extra),
693+
..Default::default()
694+
})
695+
.expect_err("a positional via `--` on the pending path should be rejected");
696+
assert!(matches!(err, Error::InvalidArgument(_)));
697+
}
698+
699+
#[test]
700+
fn npm_v11_16_deny_only() {
701+
let pm = create_mock_package_manager(PackageManagerType::Npm, "11.16.0");
702+
let packages = vec!["!core-js".to_string()];
703+
let result = pm
704+
.resolve_approve_builds_command(&ApproveBuildsCommandOptions {
705+
packages: &packages,
706+
..Default::default()
707+
})
708+
.expect("resolves")
709+
.expect("supported");
710+
// The leading `!` is stripped for npm deny-scripts.
711+
assert_eq!(result.args, vec!["deny-scripts", "core-js"]);
712+
}
713+
714+
#[test]
715+
fn npm_v11_16_multiple_denies() {
716+
let pm = create_mock_package_manager(PackageManagerType::Npm, "11.16.0");
717+
let packages = vec!["!core-js".to_string(), "!esbuild".to_string()];
718+
let result = pm
719+
.resolve_approve_builds_command(&ApproveBuildsCommandOptions {
720+
packages: &packages,
721+
..Default::default()
722+
})
723+
.expect("resolves")
724+
.expect("supported");
725+
assert_eq!(result.args, vec!["deny-scripts", "core-js", "esbuild"]);
726+
}
727+
728+
#[test]
729+
fn npm_v11_16_mixed_rejected() {
730+
// npm splits approve vs deny into two commands; vp rejects a mixed invocation.
731+
let pm = create_mock_package_manager(PackageManagerType::Npm, "11.16.0");
732+
let packages = vec!["esbuild".to_string(), "!core-js".to_string()];
733+
let err = pm
734+
.resolve_approve_builds_command(&ApproveBuildsCommandOptions {
735+
packages: &packages,
736+
..Default::default()
737+
})
738+
.expect_err("mixed approve+deny should be rejected on npm");
739+
assert!(matches!(err, Error::InvalidArgument(_)));
740+
}
741+
742+
#[test]
743+
fn npm_v11_16_appends_pass_through() {
744+
let pm = create_mock_package_manager(PackageManagerType::Npm, "11.16.0");
745+
let packages = vec!["esbuild".to_string()];
746+
let extra = vec!["--silent".to_string()];
747+
let result = pm
748+
.resolve_approve_builds_command(&ApproveBuildsCommandOptions {
749+
packages: &packages,
750+
pass_through_args: Some(&extra),
751+
..Default::default()
752+
})
753+
.expect("resolves")
754+
.expect("supported");
755+
assert_eq!(result.args, vec!["approve-scripts", "esbuild", "--silent"]);
756+
}
757+
519758
#[test]
520759
fn yarn_berry_warns_and_noop() {
521760
let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0");

crates/vite_pm_cli/src/cli.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -565,11 +565,11 @@ pub enum PmCommands {
565565
/// Approve dependency lifecycle scripts (install/postinstall) to run
566566
#[command(name = "approve-builds")]
567567
ApproveBuilds {
568-
/// Packages to approve. Prefix with `!` to deny (pnpm >= 11.0.0 only).
569-
/// Omit to launch interactive mode (pnpm only).
568+
/// Packages to approve. Prefix with `!` to deny (pnpm >= 11.0.0, npm >= 11.16.0).
569+
/// Omit to launch interactive mode (pnpm) or list pending packages (npm >= 11.16.0).
570570
packages: Vec<String>,
571571

572-
/// Approve every package currently pending approval (pnpm >= 10.32.0).
572+
/// Approve every package currently pending approval (pnpm >= 10.32.0, npm >= 11.16.0).
573573
/// Mutually exclusive with positional packages.
574574
#[arg(long, conflicts_with = "packages")]
575575
all: bool,

packages/cli/snap-tests-global/command-pm-approve-builds-bun/snap.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@ Usage: vp pm approve-builds [OPTIONS] [PACKAGES]... [-- <PASS_THROUGH_ARGS>...]
44
Approve dependency lifecycle scripts (install/postinstall) to run
55

66
Arguments:
7-
[PACKAGES]... Packages to approve. Prefix with `!` to deny (pnpm >= <semver> only). Omit to launch interactive mode (pnpm only)
7+
[PACKAGES]... Packages to approve. Prefix with `!` to deny (pnpm >= <semver>, npm >= <semver>). Omit to launch interactive mode (pnpm) or list pending packages (npm >= <semver>)
88
[PASS_THROUGH_ARGS]... Additional arguments to pass through to the package manager
99

1010
Options:
11-
--all Approve every package currently pending approval (pnpm >= <semver>). Mutually exclusive with positional packages
11+
--all Approve every package currently pending approval (pnpm >= <semver>, npm >= <semver>). Mutually exclusive with positional packages
1212
-h, --help Print help
1313

1414
Documentation: https://viteplus.dev/guide/install
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"name": "command-pm-approve-builds-npm11",
3+
"version": "1.0.0",
4+
"private": true,
5+
"packageManager": "npm@11.16.0"
6+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
> vp pm approve-builds --help # should show help with pnpm/npm deny + --all caveats
2+
Usage: vp pm approve-builds [OPTIONS] [PACKAGES]... [-- <PASS_THROUGH_ARGS>...]
3+
4+
Approve dependency lifecycle scripts (install/postinstall) to run
5+
6+
Arguments:
7+
[PACKAGES]... Packages to approve. Prefix with `!` to deny (pnpm >= <semver>, npm >= <semver>). Omit to launch interactive mode (pnpm) or list pending packages (npm >= <semver>)
8+
[PASS_THROUGH_ARGS]... Additional arguments to pass through to the package manager
9+
10+
Options:
11+
--all Approve every package currently pending approval (pnpm >= <semver>, npm >= <semver>). Mutually exclusive with positional packages
12+
-h, --help Print help
13+
14+
Documentation: https://viteplus.dev/guide/install
15+
16+
17+
> vp pm approve-builds # no args -> npm approve-scripts --allow-scripts-pending (lists pending)
18+
No packages with unreviewed install scripts.
19+
20+
[1]> vp pm approve-builds esbuild # -> npm approve-scripts esbuild (advisory note)
21+
note: npm's allowScripts policy is advisory in npm 11.x: install scripts still run; npm only warns about unreviewed packages at install time. Enforcement is planned for a future npm release.
22+
npm error code ENOMATCH
23+
npm error No installed packages match: esbuild
24+
npm error A complete log of this run can be found in: <homedir>/.npm/_logs/<timestamp>-debug.log
25+
26+
[1]> vp pm approve-builds '!core-js' # deny-only -> npm deny-scripts core-js (advisory note)
27+
note: npm's allowScripts policy is advisory in npm 11.x: install scripts still run; npm only warns about unreviewed packages at install time. Enforcement is planned for a future npm release.
28+
npm error code ENOMATCH
29+
npm error No installed packages match: core-js
30+
npm error A complete log of this run can be found in: <homedir>/.npm/_logs/<timestamp>-debug.log
31+
32+
[1]> vp pm approve-builds esbuild '!core-js' # mixed approve+deny -> rejected, exit non-zero
33+
npm manages approvals and denials separately. Run them as two invocations, e.g. `vp pm approve-builds <approve-pkg>...` then `vp pm approve-builds !<deny-pkg>...`.
34+
35+
[1]> vp pm approve-builds -- esbuild # positional via -- on the pending path -> rejected, exit non-zero
36+
Pass package names as positionals (`vp pm approve-builds <pkg>...`), not after `--`.
37+
38+
> vp pm approve-builds --all # -> npm approve-scripts --all (advisory note)
39+
note: npm's allowScripts policy is advisory in npm 11.x: install scripts still run; npm only warns about unreviewed packages at install time. Enforcement is planned for a future npm release.
40+
No packages with unreviewed install scripts.

0 commit comments

Comments
 (0)