From b27de706a8db4af51a95dfdabbca66183f402f93 Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 5 Aug 2026 17:18:16 +0800 Subject: [PATCH] feat(pm): handle npm 12 blocked install scripts in create and approve-builds npm 12 skips dependency install scripts that the allowScripts field in package.json does not cover, and stops running scripts on approval: only npm rebuild executes previously skipped scripts. - Parse the npm 12 blocked-scripts install warning and surface gated direct dependencies in vp create like pnpm/bun/yarn, approving via vp pm approve-builds followed by vp pm rebuild - Version-gate the vp pm approve-builds npm note: npm >= 12 points at vp pm rebuild, npm 11.16 - 11.x keeps the advisory wording - Add the command_pm_approve_builds_npm12 PTY fixture (npm@12.0.2) and re-record the npm11 fixture for the reworded note - Document the npm 12 allowScripts flow and the allow-git/allow-remote resolution defaults in the create and install guides Closes #1823 --- .../command_pm_approve_builds_npm11.md | 6 +- .../package.json | 6 + .../snapshots.toml | 11 ++ .../command_pm_approve_builds_npm12.md | 53 +++++++ .../src/resolution/commands/approve_builds.rs | 67 ++++++++- docs/guide/create.md | 4 +- docs/guide/install.md | 13 ++ packages/cli/src/create/bin.ts | 4 +- .../utils/__tests__/approve-builds.spec.ts | 81 ++++++++++- packages/cli/src/utils/approve-builds.ts | 130 ++++++++++++++++-- packages/cli/src/utils/prompts.ts | 17 +-- 11 files changed, 357 insertions(+), 35 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_pm_approve_builds_npm12/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_pm_approve_builds_npm12/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_pm_approve_builds_npm12/snapshots/command_pm_approve_builds_npm12.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_pm_approve_builds_npm11/snapshots/command_pm_approve_builds_npm11.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_pm_approve_builds_npm11/snapshots/command_pm_approve_builds_npm11.md index 82cc486b6a..317bafbac8 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_pm_approve_builds_npm11/snapshots/command_pm_approve_builds_npm11.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_pm_approve_builds_npm11/snapshots/command_pm_approve_builds_npm11.md @@ -37,7 +37,7 @@ No packages with unreviewed install scripts. **Exit code:** 1 ``` -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. +note: npm's allowScripts policy is advisory in npm 11.x: install scripts still run; npm only warns about unreviewed packages at install time. npm 12 enforces the policy. npm error code ENOMATCH npm error No installed packages match: esbuild npm error A complete log of this run can be found in: /.npm/_logs/-debug-0.log @@ -50,7 +50,7 @@ deny-only -> npm deny-scripts core-js (advisory note) **Exit code:** 1 ``` -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. +note: npm's allowScripts policy is advisory in npm 11.x: install scripts still run; npm only warns about unreviewed packages at install time. npm 12 enforces the policy. npm error code ENOMATCH npm error No installed packages match: core-js npm error A complete log of this run can be found in: /.npm/_logs/-debug-0.log @@ -81,6 +81,6 @@ Pass package names as positionals (`vp pm approve-builds ...`), not after ` -> npm approve-scripts --all (advisory note) ``` -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. +note: npm's allowScripts policy is advisory in npm 11.x: install scripts still run; npm only warns about unreviewed packages at install time. npm 12 enforces the policy. No packages with unreviewed install scripts. ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_pm_approve_builds_npm12/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_pm_approve_builds_npm12/package.json new file mode 100644 index 0000000000..2fd7e92a73 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_pm_approve_builds_npm12/package.json @@ -0,0 +1,6 @@ +{ + "name": "command-pm-approve-builds-npm12", + "version": "1.0.0", + "private": true, + "packageManager": "npm@12.0.2" +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_pm_approve_builds_npm12/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_pm_approve_builds_npm12/snapshots.toml new file mode 100644 index 0000000000..449e352f0a --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_pm_approve_builds_npm12/snapshots.toml @@ -0,0 +1,11 @@ +[[case]] +name = "command_pm_approve_builds_npm12" +vp = "global" +skip-platforms = ["windows"] +steps = [ + { argv = ["vp", "pm", "approve-builds"], comment = "no args -> npm approve-scripts --allow-scripts-pending (lists pending)", continue-on-failure = true }, + { argv = ["vp", "pm", "approve-builds", "esbuild"], comment = "-> npm approve-scripts esbuild (npm 12 enforces allowScripts, so vp points at vp pm rebuild)", continue-on-failure = true }, + { argv = ["vp", "pm", "approve-builds", "!core-js"], comment = "deny-only -> npm deny-scripts core-js (denial keeps the enforced default, no note)", continue-on-failure = true }, + { argv = ["vp", "pm", "approve-builds", "esbuild", "!core-js"], comment = "mixed approve+deny -> rejected, exit non-zero", continue-on-failure = true }, + { argv = ["vp", "pm", "approve-builds", "--all"], comment = "-> npm approve-scripts --all (rebuild note)", continue-on-failure = true }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_pm_approve_builds_npm12/snapshots/command_pm_approve_builds_npm12.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_pm_approve_builds_npm12/snapshots/command_pm_approve_builds_npm12.md new file mode 100644 index 0000000000..f057965d87 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_pm_approve_builds_npm12/snapshots/command_pm_approve_builds_npm12.md @@ -0,0 +1,53 @@ +# command_pm_approve_builds_npm12 + +## `vp pm approve-builds` + +no args -> npm approve-scripts --allow-scripts-pending (lists pending) + +``` +No packages with unreviewed install scripts. +``` + +## `vp pm approve-builds esbuild` + +-> npm approve-scripts esbuild (npm 12 enforces allowScripts, so vp points at vp pm rebuild) + +**Exit code:** 1 + +``` +note: npm records the approval in the `allowScripts` field of package.json but does not run scripts a previous install skipped. Run `vp pm rebuild ` to execute them. +npm error code ENOMATCH +npm error No installed packages match: esbuild +npm error A complete log of this run can be found in: /.npm/_logs/-debug-0.log +``` + +## `vp pm approve-builds !core-js` + +deny-only -> npm deny-scripts core-js (denial keeps the enforced default, no note) + +**Exit code:** 1 + +``` +npm error code ENOMATCH +npm error No installed packages match: core-js +npm error A complete log of this run can be found in: /.npm/_logs/-debug-0.log +``` + +## `vp pm approve-builds esbuild !core-js` + +mixed approve+deny -> rejected, exit non-zero + +**Exit code:** 1 + +``` +npm manages approvals and denials separately. Run them as two invocations, e.g. `vp pm approve-builds ...` then `vp pm approve-builds !...`. +``` + +## `vp pm approve-builds --all` + +-> npm approve-scripts --all (rebuild note) + +``` +note: npm records the approval in the `allowScripts` field of package.json but does not run scripts a previous install skipped. Run `vp pm rebuild ` to execute them. +No packages with unreviewed install scripts. +``` diff --git a/crates/vp_pm_cli/src/resolution/commands/approve_builds.rs b/crates/vp_pm_cli/src/resolution/commands/approve_builds.rs index 986f5c176e..c54b6bfe41 100644 --- a/crates/vp_pm_cli/src/resolution/commands/approve_builds.rs +++ b/crates/vp_pm_cli/src/resolution/commands/approve_builds.rs @@ -5,7 +5,9 @@ use crate::resolution::{ PackageManagerDialect, Pnpm, Resolve, Yarn, }; -const NPM_ADVISORY_NOTE: &str = "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."; +const NPM_ADVISORY_NOTE: &str = "npm's allowScripts policy is advisory in npm 11.x: install scripts still run; npm only warns about unreviewed packages at install time. npm 12 enforces the policy."; + +const NPM_ENFORCED_NOTE: &str = "npm records the approval in the `allowScripts` field of package.json but does not run scripts a previous install skipped. Run `vp pm rebuild ` to execute them."; #[pm_args] #[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] @@ -150,7 +152,18 @@ impl Resolve for Npm { } } if writes_policy { - diag.note(DiagnosticKind::BehaviorChange, NPM_ADVISORY_NOTE); + // npm 12 enforces allowScripts (skipped scripts stay skipped until a + // rebuild); 11.16 - 11.x only warn. An unknown version is treated as + // current, matching the version-gate default above. + if self.version().is_none_or(|version| version_satisfies(version, ">=12.0.0")) { + // An approval takes effect on the next rebuild; a denial keeps + // the enforced default and needs no follow-up. + if !has_denies { + diag.note(DiagnosticKind::BehaviorChange, NPM_ENFORCED_NOTE); + } + } else { + diag.note(DiagnosticKind::BehaviorChange, NPM_ADVISORY_NOTE); + } } cmd.extend(args.pass_through_args.iter()); cmd.into() @@ -574,6 +587,56 @@ mod tests { assert_eq!(resolution.diagnostics[0].message, NPM_ADVISORY_NOTE); } + #[test] + fn npm_v12_approve_notes_rebuild() { + let resolution = resolve( + &npm("12.0.2"), + ApproveBuildsArgs { packages: vec!["esbuild".to_string()], ..Default::default() }, + ); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["approve-scripts", "esbuild"]); + assert_eq!(resolution.diagnostics[0].message, NPM_ENFORCED_NOTE); + } + + #[test] + fn npm_v12_all_notes_rebuild() { + let resolution = + resolve(&npm("12.0.2"), ApproveBuildsArgs { all: true, ..Default::default() }); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["approve-scripts", "--all"]); + assert_eq!(resolution.diagnostics[0].message, NPM_ENFORCED_NOTE); + } + + #[test] + fn npm_v12_deny_has_no_note() { + let resolution = resolve( + &npm("12.0.2"), + ApproveBuildsArgs { packages: vec!["!core-js".to_string()], ..Default::default() }, + ); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["deny-scripts", "core-js"]); + assert!(resolution.diagnostics.is_empty()); + } + + #[test] + fn npm_unknown_version_notes_rebuild() { + let resolution = resolve( + &Npm::unknown_version(), + ApproveBuildsArgs { packages: vec!["esbuild".to_string()], ..Default::default() }, + ); + + assert_eq!(resolution.diagnostics[0].message, NPM_ENFORCED_NOTE); + } + #[test] fn npm_v11_16_mixed_rejected() { let resolution = resolve( diff --git a/docs/guide/create.md b/docs/guide/create.md index cb044ba665..a632969c9b 100644 --- a/docs/guide/create.md +++ b/docs/guide/create.md @@ -59,13 +59,13 @@ Run `vp create --list` to see the built-in templates and the common shorthand te ### Dependency build scripts -For security, pnpm, bun, and yarn (Berry) do not run a dependency's build scripts (`install` / `postinstall`, e.g. native builds like `better-sqlite3`) until you approve them. When a template adds such a dependency directly, `vp create` surfaces it after installing instead of leaving the project in a half-built state: +For security, pnpm, bun, yarn (Berry), and npm (v12+) do not run a dependency's build scripts (`install` / `postinstall`, e.g. native builds like `better-sqlite3`) until you approve them. When a template adds such a dependency directly, `vp create` surfaces it after installing instead of leaving the project in a half-built state: - Interactive: you are asked which of those dependencies to approve and build (nothing is selected by default). - Non-interactive: a note lists them and points at `vp pm approve-builds`. - `--approve-builds`: approves and builds them automatically, so non-interactive runs (CI) can produce a ready-to-use project. -Approval is recorded the way each package manager expects: pnpm's `allowBuilds`, bun's `trustedDependencies`, or yarn's `dependenciesMeta..built` (in the workspace root manifest). Transitive build scripts you did not choose (e.g. `esbuild` pulled in by Vite) are left at the package manager's defaults and are not surfaced. npm runs build scripts by default, so there is nothing to approve there. +Approval is recorded the way each package manager expects: pnpm's `allowBuilds`, bun's `trustedDependencies`, npm's `allowScripts`, or yarn's `dependenciesMeta..built` (in the workspace root manifest). Transitive build scripts you did not choose (e.g. `esbuild` pulled in by Vite) are left at the package manager's defaults and are not surfaced. npm 11 and older run build scripts during install, so there is nothing to approve there. ## Template Options diff --git a/docs/guide/install.md b/docs/guide/install.md index 7eb21a015a..0e2930c517 100644 --- a/docs/guide/install.md +++ b/docs/guide/install.md @@ -167,6 +167,19 @@ vp rebuild -- --update-binary With pnpm v10+, bare `vp rebuild` only rebuilds packages whose build scripts are listed in `onlyBuiltDependencies` (or approved via `pnpm approve-builds`); name the package explicitly to force a rebuild that bypasses the approval gate. +#### Dependency build scripts (npm v12+) + +npm v12 skips dependency install scripts (`preinstall` / `install` / `postinstall`, including implicit `node-gyp` builds) unless the `allowScripts` field in package.json covers them; the install succeeds and npm warns about what it skipped. `vp pm approve-builds` manages that allowlist: + +- `vp pm approve-builds ` approves the named packages (`npm approve-scripts`) +- `vp pm approve-builds !` denies them (`npm deny-scripts`) +- `vp pm approve-builds --all` approves everything currently pending +- `vp pm approve-builds` lists the packages whose scripts are not yet covered + +Approval only records the allowlist: scripts an earlier install skipped do not run until you run `vp rebuild `. With npm 11.16 - 11.x the same commands work, but npm treats the allowlist as advisory and still runs scripts. + +npm v12 also stops resolving git dependencies (`github:`, `git+https:`) and remote tarball URLs by default; such installs fail with `EALLOWGIT` / `EALLOWREMOTE`. Opt back in per project with npm's `allow-git` / `allow-remote` config. + #### Advanced Use these when you need lower-level package-manager behavior. diff --git a/packages/cli/src/create/bin.ts b/packages/cli/src/create/bin.ts index 351767bb53..c21f793146 100644 --- a/packages/cli/src/create/bin.ts +++ b/packages/cli/src/create/bin.ts @@ -922,8 +922,8 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h }; // After a successful install, surface gated build scripts (native builds like - // better-sqlite3 the template added as a direct dependency) that pnpm or bun - // blocked, and let the user approve them. `projectPath` is the created package + // better-sqlite3 the template added as a direct dependency) that the package + // manager blocked, and let the user approve them. `projectPath` is the created package // whose direct deps decide what is worth prompting for; `installCwd` is where // the package manager (and `node_modules`) lives. // Gated builds reported by the ESLint/Prettier migration pre-install. yarn diff --git a/packages/cli/src/utils/__tests__/approve-builds.spec.ts b/packages/cli/src/utils/__tests__/approve-builds.spec.ts index 610779591b..fb3d435dd6 100644 --- a/packages/cli/src/utils/__tests__/approve-builds.spec.ts +++ b/packages/cli/src/utils/__tests__/approve-builds.spec.ts @@ -13,6 +13,7 @@ import { parseBunUntrusted, parseIgnoredBuilds, parseInstallGatedBuilds, + parseNpmBlockedScripts, parseYarnDisabledBuilds, pnpmSupportsPositionalApprove, resolveApproveBuildTargets, @@ -179,6 +180,62 @@ describe('parseYarnDisabledBuilds', () => { }); }); +describe('parseNpmBlockedScripts', () => { + it('parses the npm 12 blocked-scripts warning from real install output', () => { + // Captured from `npm install esbuild core-js` with npm 12.0.2. + const output = [ + '', + 'added 3 packages in 466ms', + 'npm warn install-scripts 2 packages had install scripts blocked because they are not covered by allowScripts:', + 'npm warn install-scripts core-js@3.49.0 (postinstall: node -e "try{require(\'./postinstall\')}catch(e){}")', + 'npm warn install-scripts esbuild@0.28.1 (postinstall: node install.js)', + 'npm warn install-scripts', + 'npm warn install-scripts Run `npm install-scripts ls` to review, or `npm install-scripts approve ` to allow.', + ].join('\n'); + expect(parseNpmBlockedScripts(output)).toEqual(['core-js', 'esbuild']); + }); + + it('parses the singular one-package form', () => { + const output = [ + 'npm warn install-scripts 1 package had install scripts blocked because they are not covered by allowScripts:', + 'npm warn install-scripts fsevents@2.3.3 (install: node-gyp rebuild)', + ].join('\n'); + expect(parseNpmBlockedScripts(output)).toEqual(['fsevents']); + }); + + it('parses scoped packages and dedupes multiple versions', () => { + const output = [ + 'npm warn install-scripts 3 packages had install scripts blocked because they are not covered by allowScripts:', + 'npm warn install-scripts @scope/native@1.0.0 (preinstall: node setup.js)', + 'npm warn install-scripts esbuild@0.25.0 (postinstall: node install.js)', + 'npm warn install-scripts esbuild@0.28.1 (postinstall: node install.js)', + ].join('\n'); + expect(parseNpmBlockedScripts(output)).toEqual(['@scope/native', 'esbuild']); + }); + + it('returns [] for the npm 11.x advisory warning (scripts still ran)', () => { + // npm 11.16 - 11.x warn about unreviewed packages but run their scripts. + const output = [ + 'npm warn install-scripts 1 package has install scripts not yet covered by allowScripts:', + 'npm warn install-scripts esbuild@0.28.1 (postinstall: node install.js)', + ].join('\n'); + expect(parseNpmBlockedScripts(output)).toEqual([]); + }); + + it('returns [] for the npm rebuild summary warning (no package list)', () => { + expect( + parseNpmBlockedScripts( + 'npm warn rebuild 1 package had install scripts blocked because they are not covered by allowScripts. Run `npm install-scripts ls` to review.', + ), + ).toEqual([]); + }); + + it('returns [] for a clean install log', () => { + expect(parseNpmBlockedScripts('added 87 packages in 39s')).toEqual([]); + expect(parseNpmBlockedScripts('')).toEqual([]); + }); +}); + describe('parseInstallGatedBuilds', () => { it('dispatches to the pnpm parser for pnpm', () => { expect( @@ -198,9 +255,18 @@ describe('parseInstallGatedBuilds', () => { ).toEqual(['core-js']); }); - it('returns [] for bun/npm (not parsed from install output)', () => { + it('dispatches to the npm parser for npm', () => { + expect( + parseInstallGatedBuilds( + 'npm warn install-scripts 1 package had install scripts blocked because they are not covered by allowScripts:\n' + + 'npm warn install-scripts better-sqlite3@11.0.0 (install: node-gyp rebuild)', + PackageManager.npm, + ), + ).toEqual(['better-sqlite3']); + }); + + it('returns [] for bun (not parsed from install output)', () => { expect(parseInstallGatedBuilds('whatever', PackageManager.bun)).toEqual([]); - expect(parseInstallGatedBuilds('whatever', PackageManager.npm)).toEqual([]); expect(parseInstallGatedBuilds('whatever', undefined)).toEqual([]); }); }); @@ -299,9 +365,16 @@ describe('resolveApproveBuildTargets', () => { ]); }); - it('returns [] for package managers that do not gate builds', () => { + it('returns direct-dep build targets for npm', () => { + writePkg({ dependencies: { 'better-sqlite3': '^11.0.0' } }); + expect( + resolveApproveBuildTargets(dir, ['better-sqlite3', 'esbuild'], PackageManager.npm), + ).toEqual(['better-sqlite3']); + }); + + it('returns [] when the package manager is unknown', () => { writePkg({ dependencies: { 'better-sqlite3': '^11.0.0' } }); - expect(resolveApproveBuildTargets(dir, ['better-sqlite3'], PackageManager.npm)).toEqual([]); + expect(resolveApproveBuildTargets(dir, ['better-sqlite3'], undefined)).toEqual([]); }); it('returns [] when there are no pending builds', () => { diff --git a/packages/cli/src/utils/approve-builds.ts b/packages/cli/src/utils/approve-builds.ts index b6406d76ca..351c1a52b9 100644 --- a/packages/cli/src/utils/approve-builds.ts +++ b/packages/cli/src/utils/approve-builds.ts @@ -136,6 +136,50 @@ export function parseBunUntrusted(output: string): string[] { }); } +/** + * npm >= 12 skips dependency install scripts that the `allowScripts` allowlist + * in package.json does not cover, and reports them on stderr: + * + * ``` + * npm warn install-scripts 2 packages had install scripts blocked because they are not covered by allowScripts: + * npm warn install-scripts core-js@3.49.0 (postinstall: node -e "try{require('./postinstall')}catch(e){}") + * npm warn install-scripts esbuild@0.28.1 (postinstall: node install.js) + * ``` + * + * npm 11.16 - 11.x print a similar warning ("install scripts not yet covered by + * allowScripts") but still run the scripts, so only the npm 12 "blocked" wording + * is parsed. + */ +const NPM_BLOCKED_SCRIPTS_MARKER = + 'install scripts blocked because they are not covered by allowScripts'; + +/** + * A blocked-package entry line: `npm warn install-scripts (: + * )`. Anchoring on the lifecycle name keeps the count line ("2 packages + * had install scripts blocked ...") and the trailing hint line from matching. + */ +const NPM_BLOCKED_ENTRY = /^npm warn install-scripts\s+(\S+) \((?:pre|post)?install:/u; + +/** + * Parse the package names npm >= 12 reports as having blocked install scripts + * from captured `npm install` / `npm ci` output. Strips version suffixes and + * dedupes while preserving first-seen order. Returns `[]` when the warning is + * absent (including the npm 11.x advisory variant, where scripts still run). + */ +export function parseNpmBlockedScripts(output: string): string[] { + if (!output) { + return []; + } + const clean = stripVTControlCharacters(output); + if (!clean.includes(NPM_BLOCKED_SCRIPTS_MARKER)) { + return []; + } + return dedupeNames(clean.split('\n'), (rawLine) => { + const match = rawLine.trim().match(NPM_BLOCKED_ENTRY); + return match ? stripPackageVersion(match[1]) : null; + }); +} + const YARN_DISABLED_BUILDS_MARKER = 'lists build scripts, but all build scripts have been disabled'; /** @@ -181,8 +225,9 @@ function yarnDescriptorName(descriptor: string): string { /** * Parse the gated build-script package names from an install log, dispatching on * the package manager: pnpm prints `Ignored build scripts:`, yarn prints - * `... build scripts have been disabled`. bun is not parsed here (its blocked - * packages are queried separately via `bun pm untrusted`). + * `... build scripts have been disabled`, npm >= 12 prints `... install scripts + * blocked ...`. bun is not parsed here (its blocked packages are queried + * separately via `bun pm untrusted`). */ export function parseInstallGatedBuilds( output: string, @@ -194,6 +239,9 @@ export function parseInstallGatedBuilds( if (packageManager === PackageManager.yarn) { return parseYarnDisabledBuilds(output); } + if (packageManager === PackageManager.npm) { + return parseNpmBlockedScripts(output); + } return []; } @@ -252,6 +300,7 @@ const GATED_BUILD_PACKAGE_MANAGERS: ReadonlySet = new Set([ PackageManager.pnpm, PackageManager.bun, PackageManager.yarn, + PackageManager.npm, ]); /** @@ -259,7 +308,7 @@ const GATED_BUILD_PACKAGE_MANAGERS: ReadonlySet = new Set([ * during `vp create`: packages the generated project depends on directly. * Transitive gated builds (e.g. `esbuild` pulled in by Vite) are noise the user * did not choose, so they are dropped. Returns `[]` for package managers that - * do not gate build scripts (npm, yarn classic), since there is nothing to + * do not gate build scripts (npm < 12, yarn classic), since there is nothing to * approve. */ export function resolveApproveBuildTargets( @@ -290,8 +339,8 @@ export function resolveApproveBuildTargets( * Enumerate the packages whose build scripts a package manager gated during the * install, as raw names (still unfiltered by direct dependency). * - * - pnpm and yarn report them in their install output, so the names are parsed - * there (see {@link parseInstallGatedBuilds}) and passed in via + * - pnpm, yarn, and npm >= 12 report them in their install output, so the names + * are parsed there (see {@link parseInstallGatedBuilds}) and passed in via * `pendingBuildsFromInstall`. * - bun exits 0 and only prints a count, so `bun pm untrusted` is queried here. * @@ -302,7 +351,11 @@ export async function detectGatedBuilds( packageManager: PackageManager | undefined, pendingBuildsFromInstall: string[] | undefined, ): Promise { - if (packageManager === PackageManager.pnpm || packageManager === PackageManager.yarn) { + if ( + packageManager === PackageManager.pnpm || + packageManager === PackageManager.yarn || + packageManager === PackageManager.npm + ) { return pendingBuildsFromInstall ?? []; } if (packageManager === PackageManager.bun) { @@ -340,10 +393,11 @@ function printApproveBuildsGuidance( return; } // bun's `pm approve-builds` is a no-op without explicit names (it just prints - // "requires package names"), so spell them out. pnpm's runs an interactive - // picker when called bare, so it doesn't need them. + // "requires package names"), and npm's bare form only lists what is pending, + // so spell them out. pnpm's runs an interactive picker when called bare, so it + // doesn't need them. const command = - packageManager === PackageManager.bun + packageManager === PackageManager.bun || packageManager === PackageManager.npm ? `vp pm approve-builds ${targets.join(' ')}` : 'vp pm approve-builds'; prompts.log.info( @@ -354,8 +408,9 @@ function printApproveBuildsGuidance( /** * Run a `vp` build/approval command and report the outcome through a spinner. - * On failure the approval has still been recorded (pnpm/bun config or yarn's - * `dependenciesMeta`), so the retry hint points back at `vp install`. Returns + * On failure the approval has still been recorded (pnpm/bun/npm config or + * yarn's `dependenciesMeta`), so the retry hint points back at `retryCommand` + * (`vp install` unless the package manager needs an explicit rebuild). Returns * `true` when the command succeeded, `false` when the build exited non-zero. */ async function runBuildAndReport( @@ -365,6 +420,7 @@ async function runBuildAndReport( interactive: boolean, silent: boolean, extraEnv?: Record, + retryCommand = 'vp install', ): Promise { const spinner = silent ? getSilentSpinner() : getSpinner(interactive); spinner.start(`Building ${packages.join(', ')}...`); @@ -385,11 +441,54 @@ async function runBuildAndReport( } prompts.log.warn( `Build scripts failed for ${accent(packages.join(', '))}. They were approved; fix the ` + - `build toolchain and run ${accent('vp install')} to retry.`, + `build toolchain and run ${accent(retryCommand)} to retry.`, ); return false; } +/** + * Approve gated install scripts for npm >= 12, then run them. npm's approval + * (`npm approve-scripts`, via `vp pm approve-builds`) only records the + * `allowScripts` allowlist in package.json: scripts the install already skipped + * do not run on approval, and a plain reinstall short-circuits on the + * up-to-date tree without running them either. `npm rebuild` is what executes + * the newly approved scripts, so approval and rebuild are two steps. + */ +async function approveNpmScripts( + installCwd: string, + packages: string[], + interactive: boolean, + silent: boolean, +): Promise { + const approveCommand = `vp pm approve-builds ${packages.join(' ')}`; + const { exitCode, stdout, stderr } = await runCommandSilently({ + command: process.env.VP_CLI_BIN ?? 'vp', + args: ['pm', 'approve-builds', ...packages], + cwd: installCwd, + envs: process.env, + }); + if (exitCode !== 0) { + const output = `${stdout.toString()}\n${stderr.toString()}`.trim(); + if (output) { + prompts.log.info(lastLines(output, 20)); + } + prompts.log.warn( + `Approving build scripts failed for ${accent(packages.join(', '))}. Run ` + + `${accent(approveCommand)} in the project to retry.`, + ); + return false; + } + return runBuildAndReport( + ['pm', 'rebuild', ...packages], + installCwd, + packages, + interactive, + silent, + undefined, + `vp pm rebuild ${packages.join(' ')}`, + ); +} + /** * Mark each package as build-allowed in yarn's `dependenciesMeta[].built`, * preserving existing metadata. Guards against a non-object container or @@ -467,8 +566,8 @@ export interface ApproveBuildsOptions { } /** - * Surface pnpm's gated build scripts after a `vp create` install and let the - * user act on them: + * Surface the package manager's gated build scripts after a `vp create` install + * and let the user act on them: * - `--approve-builds`: approve + build every target, no prompt. * - interactive: a default-off multiselect so each package is approved * individually (pnpm gates them for security, so nothing is opt-in by @@ -519,6 +618,9 @@ export async function approveBuilds(options: ApproveBuildsOptions): Promise= 11 `ERR_PNPM_IGNORED_BUILDS` exit is - * treated as a successful install (deps are on disk), and the gated package - * names are returned in `pendingBuilds`. + * Surface the package manager's gated build scripts instead of suppressing + * them. When set, the auto `--ignore-scripts` workaround is skipped (so + * pnpm records which packages need approval), a pnpm >= 11 + * `ERR_PNPM_IGNORED_BUILDS` exit is treated as a successful install (deps + * are on disk), and the gated package names (pnpm, yarn, npm >= 12) are + * returned in `pendingBuilds`. */ detectIgnoredBuilds?: boolean; },