From 996284f8cc46db05f58422397ffc2d46648eed6b Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Fri, 20 Mar 2026 14:58:56 +0100 Subject: [PATCH] feat(approve-builds): positional args, !pkg deny syntax, and auto-populate allowBuilds (#11030) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### `pnpm approve-builds` positional arguments - `pnpm approve-builds foo` — approves `foo`, leaves everything else untouched - `pnpm approve-builds !bar` — denies `bar`, leaves everything else untouched - `pnpm approve-builds foo !bar` — approves `foo`, denies `bar` - Only mentioned packages are modified; unmentioned packages remain pending - `--all` cannot be combined with positional arguments - Contradictory arguments (`pkg !pkg`) are rejected ### Auto-populate `allowBuilds` during install - When `pnpm install` encounters packages with build scripts that aren't yet in `allowBuilds`, they are automatically written to `pnpm-workspace.yaml` with a `'set this to true or false'` placeholder - Users can then edit the config directly instead of running `approve-builds` - The placeholder behaves like a missing entry: builds are skipped and `strictDepBuilds` still fails - Existing `allowBuilds` entries are preserved (only new packages get placeholders) --- .changeset/approve-builds-positional-args.md | 11 + building/after-install/src/index.ts | 27 ++- building/commands/src/policy/approveBuilds.ts | 79 ++++++- .../test/policy/approveBuilds.test.ts | 192 ++++++++++++++++++ installing/commands/package.json | 2 + .../commands/src/handleIgnoredBuilds.ts | 51 +++++ installing/commands/src/installDeps.ts | 10 +- installing/commands/src/recursive.ts | 16 +- installing/commands/tsconfig.json | 3 + pnpm-lock.yaml | 6 + pnpm/test/install/lifecycleScripts.ts | 48 ++++- 11 files changed, 418 insertions(+), 27 deletions(-) create mode 100644 .changeset/approve-builds-positional-args.md create mode 100644 installing/commands/src/handleIgnoredBuilds.ts diff --git a/.changeset/approve-builds-positional-args.md b/.changeset/approve-builds-positional-args.md new file mode 100644 index 0000000000..2190d13414 --- /dev/null +++ b/.changeset/approve-builds-positional-args.md @@ -0,0 +1,11 @@ +--- +"@pnpm/building.after-install": patch +"@pnpm/building.commands": minor +"@pnpm/installing.deps-installer": patch +"@pnpm/installing.commands": minor +"pnpm": minor +--- + +Allow `pnpm approve-builds` to receive positional arguments for approving or denying packages without the interactive prompt. Prefix a package name with `!` to deny it. Only mentioned packages are affected; the rest are left untouched. + +During install, packages with ignored builds that are not yet listed in `allowBuilds` are automatically added with a placeholder value. This makes them visible in `pnpm-workspace.yaml` so users can manually change them to `true` or `false` without running `pnpm approve-builds`. diff --git a/building/after-install/src/index.ts b/building/after-install/src/index.ts index 67e29e7907..d65fd40db4 100644 --- a/building/after-install/src/index.ts +++ b/building/after-install/src/index.ts @@ -146,7 +146,7 @@ export async function buildSelectedPkgs ( hoistedDependencies: ctx.hoistedDependencies, hoistPattern: ctx.hoistPattern, included: ctx.include, - ignoredBuilds: ignoredPkgs, + ignoredBuilds: mergeIgnoredBuilds(ctx.modulesFile?.ignoredBuilds, ignoredPkgs, pkgs as DepPath[]), layoutVersion: LAYOUT_VERSION, packageManager: `${opts.packageManager.name}@${opts.packageManager.version}`, pendingBuilds: ctx.pendingBuilds, @@ -480,3 +480,28 @@ function binDirsInAllParentDirs (pkgRoot: string, lockfileDir: string): string[] binDirs.push(path.join(lockfileDir, 'node_modules/.bin')) return binDirs } + +/** + * Merge new ignoredBuilds from a selective rebuild with existing ones. + * Keeps existing entries for packages that weren't part of this rebuild. + */ +function mergeIgnoredBuilds ( + existing: IgnoredBuilds | undefined, + newIgnored: IgnoredBuilds, + rebuiltPkgs: DepPath[] +): IgnoredBuilds | undefined { + if (!existing?.size && !newIgnored.size) return undefined + const rebuiltSet = new Set(rebuiltPkgs) + const merged = new Set() + if (existing) { + for (const depPath of existing) { + if (!rebuiltSet.has(depPath)) { + merged.add(depPath) + } + } + } + for (const depPath of newIgnored) { + merged.add(depPath) + } + return merged.size ? merged : undefined +} diff --git a/building/commands/src/policy/approveBuilds.ts b/building/commands/src/policy/approveBuilds.ts index 2e91929aca..cc9bf9aff2 100644 --- a/building/commands/src/policy/approveBuilds.ts +++ b/building/commands/src/policy/approveBuilds.ts @@ -1,6 +1,7 @@ import { rebuild, type RebuildCommandOpts } from '@pnpm/building.commands' import type { Config } from '@pnpm/config.reader' import { writeSettings } from '@pnpm/config.writer' +import { parse } from '@pnpm/deps.path' import { PnpmError } from '@pnpm/error' import { type StrictModules, writeModulesManifest } from '@pnpm/installing.modules-yaml' import { globalInfo } from '@pnpm/logger' @@ -18,7 +19,10 @@ export const commandNames = ['approve-builds'] export function help (): string { return renderHelp({ description: 'Approve dependencies for running scripts during installation', - usages: [], + usages: [ + 'pnpm approve-builds', + 'pnpm approve-builds [ ...] [! ...]', + ], descriptionLists: [ { title: 'Options', @@ -45,7 +49,7 @@ export function rcOptionsTypes (): Record { return {} } -export async function handler (opts: ApproveBuildsCommandOpts & RebuildCommandOpts): Promise { +export async function handler (opts: ApproveBuildsCommandOpts & RebuildCommandOpts, params: string[] = []): Promise { if (opts.global) { throw new PnpmError( 'APPROVE_BUILDS_NOT_SUPPORTED_WITH_GLOBAL', @@ -56,6 +60,12 @@ export async function handler (opts: ApproveBuildsCommandOpts & RebuildCommandOp } ) } + if (opts.all && params.length) { + throw new PnpmError( + 'APPROVE_BUILDS_ALL_WITH_ARGS', + 'Cannot use --all with positional arguments' + ) + } const { automaticallyIgnoredBuilds, modulesDir, @@ -65,8 +75,36 @@ export async function handler (opts: ApproveBuildsCommandOpts & RebuildCommandOp globalInfo('There are no packages awaiting approval') return } + const denied: string[] = [] + const approved: string[] = [] + const unknown: string[] = [] + for (const p of params) { + const name = p.startsWith('!') ? p.slice(1) : p + if (!automaticallyIgnoredBuilds.includes(name)) { + unknown.push(name) + } else if (p.startsWith('!')) { + denied.push(name) + } else { + approved.push(name) + } + } + if (unknown.length) { + throw new PnpmError( + 'APPROVE_BUILDS_UNKNOWN_PACKAGES', + `The following packages are not awaiting approval: ${unknown.join(', ')}` + ) + } + const contradictions = approved.filter((p) => denied.includes(p)) + if (contradictions.length) { + throw new PnpmError( + 'APPROVE_BUILDS_CONTRADICTING_ARGS', + `The following packages are both approved and denied: ${contradictions.join(', ')}` + ) + } let buildPackages: string[] = [] - if (opts.all) { + if (params.length) { + buildPackages = sortUniqueStrings([...approved]) + } else if (opts.all) { buildPackages = sortUniqueStrings([...automaticallyIgnoredBuilds]) } else { const { result } = await enquirer.prompt({ @@ -107,19 +145,24 @@ export async function handler (opts: ApproveBuildsCommandOpts & RebuildCommandOp } as any) as any // eslint-disable-line @typescript-eslint/no-explicit-any buildPackages = result.map(({ value }: { value: string }) => value) } - const ignoredPackages = automaticallyIgnoredBuilds.filter((automaticallyIgnoredBuild) => !buildPackages.includes(automaticallyIgnoredBuild)) const allowBuilds: Record = { ...opts.allowBuilds } - if (ignoredPackages.length) { + if (params.length) { + for (const pkg of approved) { + allowBuilds[pkg] = true + } + for (const pkg of denied) { + allowBuilds[pkg] = false + } + } else { + const ignoredPackages = automaticallyIgnoredBuilds.filter((automaticallyIgnoredBuild) => !buildPackages.includes(automaticallyIgnoredBuild)) for (const pkg of ignoredPackages) { allowBuilds[pkg] = false } - } - if (buildPackages.length) { for (const pkg of buildPackages) { allowBuilds[pkg] = true } } - if (!opts.all) { + if (!opts.all && !params.length) { if (buildPackages.length) { const confirmed = await enquirer.prompt<{ build: boolean }>({ type: 'confirm', @@ -140,14 +183,28 @@ Do you approve?`, workspaceDir: opts.workspaceDir ?? opts.rootProjectManifestDir, updatedSettings: { allowBuilds }, }) + if (modulesManifest?.ignoredBuilds) { + if (params.length) { + const decided = new Set([...approved, ...denied]) + for (const depPath of Array.from(modulesManifest.ignoredBuilds)) { + const name = parse(depPath).name ?? depPath + if (decided.has(name)) { + modulesManifest.ignoredBuilds.delete(depPath) + } + } + if (!modulesManifest.ignoredBuilds.size) { + delete modulesManifest.ignoredBuilds + } + } else { + delete modulesManifest.ignoredBuilds + } + await writeModulesManifest(modulesDir, modulesManifest as StrictModules) + } if (buildPackages.length) { return rebuild.handler({ ...opts, allowBuilds, }, buildPackages) - } else if (modulesManifest) { - delete modulesManifest.ignoredBuilds - await writeModulesManifest(modulesDir, modulesManifest as StrictModules) } } diff --git a/building/commands/test/policy/approveBuilds.test.ts b/building/commands/test/policy/approveBuilds.test.ts index 840487a6ad..5ee58fd12e 100644 --- a/building/commands/test/policy/approveBuilds.test.ts +++ b/building/commands/test/policy/approveBuilds.test.ts @@ -218,6 +218,198 @@ test('approve all builds with --all flag', async () => { expect(fs.existsSync('node_modules/@pnpm.e2e/install-script-example/generated-by-install.js')).toBeTruthy() }) +test('approve builds via positional arguments', async () => { + prepare({ + dependencies: { + '@pnpm.e2e/pre-and-postinstall-scripts-example': '1.0.0', + '@pnpm.e2e/install-script-example': '*', + }, + }) + + await execPnpmInstall() + const config = await getApproveBuildsConfig() + + prompt.mockClear() + await approveBuilds.handler(config, ['@pnpm.e2e/pre-and-postinstall-scripts-example']) + + expect(prompt).not.toHaveBeenCalled() + + const workspaceManifest = readYamlFileSync(path.resolve('pnpm-workspace.yaml')) // eslint-disable-line + expect(workspaceManifest.allowBuilds).toStrictEqual({ + '@pnpm.e2e/pre-and-postinstall-scripts-example': true, + }) + + expect(fs.existsSync('node_modules/@pnpm.e2e/pre-and-postinstall-scripts-example/generated-by-preinstall.js')).toBeTruthy() + expect(fs.existsSync('node_modules/@pnpm.e2e/pre-and-postinstall-scripts-example/generated-by-postinstall.js')).toBeTruthy() + expect(fs.existsSync('node_modules/@pnpm.e2e/install-script-example/generated-by-install.js')).toBeFalsy() + + // Unmentioned package should still be in ignoredBuilds after rebuild + const modulesManifestAfter = await readModulesManifest(path.resolve('node_modules')) + expect(modulesManifestAfter?.ignoredBuilds).toBeDefined() +}) + +test('deny builds via !pkg positional arguments', async () => { + prepare({ + dependencies: { + '@pnpm.e2e/pre-and-postinstall-scripts-example': '1.0.0', + '@pnpm.e2e/install-script-example': '*', + }, + }) + + await execPnpmInstall() + const config = await getApproveBuildsConfig() + + prompt.mockClear() + await approveBuilds.handler(config, [ + '@pnpm.e2e/pre-and-postinstall-scripts-example', + '!@pnpm.e2e/install-script-example', + ]) + + expect(prompt).not.toHaveBeenCalled() + + const workspaceManifest = readYamlFileSync(path.resolve('pnpm-workspace.yaml')) // eslint-disable-line + expect(workspaceManifest.allowBuilds).toStrictEqual({ + '@pnpm.e2e/install-script-example': false, + '@pnpm.e2e/pre-and-postinstall-scripts-example': true, + }) + + expect(fs.existsSync('node_modules/@pnpm.e2e/pre-and-postinstall-scripts-example/generated-by-preinstall.js')).toBeTruthy() + expect(fs.existsSync('node_modules/@pnpm.e2e/install-script-example/generated-by-install.js')).toBeFalsy() +}) + +test('deny-only via !pkg keeps other builds pending', async () => { + prepare({ + dependencies: { + '@pnpm.e2e/pre-and-postinstall-scripts-example': '1.0.0', + '@pnpm.e2e/install-script-example': '*', + }, + }) + + await execPnpmInstall() + const config = await getApproveBuildsConfig() + + prompt.mockClear() + await approveBuilds.handler(config, [ + '!@pnpm.e2e/install-script-example', + ]) + + expect(prompt).not.toHaveBeenCalled() + + const workspaceManifest = readYamlFileSync(path.resolve('pnpm-workspace.yaml')) // eslint-disable-line + expect(workspaceManifest.allowBuilds).toStrictEqual({ + '@pnpm.e2e/install-script-example': false, + }) + + const modulesManifestAfter = await readModulesManifest(path.resolve('node_modules')) + const ignoredNames = Array.from(modulesManifestAfter?.ignoredBuilds ?? []).map(String) + // The denied package should be removed from ignoredBuilds + expect(ignoredNames.some((dp) => dp.includes('install-script-example'))).toBe(false) + // The other package should still be pending + expect(ignoredNames.some((dp) => dp.includes('pre-and-postinstall-scripts-example'))).toBe(true) +}) + +test('positional arguments with unknown package throws error', async () => { + prepare({ + dependencies: { + '@pnpm.e2e/pre-and-postinstall-scripts-example': '1.0.0', + }, + }) + + await execPnpmInstall() + const config = await getApproveBuildsConfig() + + await expect( + approveBuilds.handler(config, ['@pnpm.e2e/nonexistent-package']) + ).rejects.toThrow('not awaiting approval') +}) + +test('!pkg with unknown package throws error', async () => { + prepare({ + dependencies: { + '@pnpm.e2e/pre-and-postinstall-scripts-example': '1.0.0', + }, + }) + + await execPnpmInstall() + const config = await getApproveBuildsConfig() + + await expect( + approveBuilds.handler(config, ['!@pnpm.e2e/nonexistent-package']) + ).rejects.toThrow('not awaiting approval') +}) + +test('contradictory arguments throw error', async () => { + prepare({ + dependencies: { + '@pnpm.e2e/pre-and-postinstall-scripts-example': '1.0.0', + }, + }) + + await execPnpmInstall() + const config = await getApproveBuildsConfig() + + await expect( + approveBuilds.handler(config, [ + '@pnpm.e2e/pre-and-postinstall-scripts-example', + '!@pnpm.e2e/pre-and-postinstall-scripts-example', + ]) + ).rejects.toThrow('both approved and denied') +}) + +test('--all with positional arguments throws error', async () => { + prepare({ + dependencies: { + '@pnpm.e2e/pre-and-postinstall-scripts-example': '1.0.0', + }, + }) + + await execPnpmInstall() + const config = await getApproveBuildsConfig() + + await expect( + approveBuilds.handler({ ...config, all: true }, ['@pnpm.e2e/pre-and-postinstall-scripts-example']) + ).rejects.toThrow('Cannot use --all with positional arguments') +}) + +test('positional args preserve existing allowBuilds entries', async () => { + const temp = tempDir() + + prepare({ + dependencies: { + '@pnpm.e2e/pre-and-postinstall-scripts-example': '1.0.0', + '@pnpm.e2e/install-script-example': '*', + }, + }, { + tempDir: temp, + }) + + const workspaceManifestFile = path.join(temp, 'pnpm-workspace.yaml') + writeYamlFileSync(workspaceManifestFile, { + packages: ['packages/*'], + allowBuilds: { + '@pnpm.e2e/existing-package': true, + }, + }) + + await execPnpmInstall() + const config = await getApproveBuildsConfig() + + await approveBuilds.handler({ + ...config, + workspaceDir: temp, + rootProjectManifestDir: temp, + allowBuilds: { + '@pnpm.e2e/existing-package': true, + }, + }, ['@pnpm.e2e/pre-and-postinstall-scripts-example']) + + const manifest = readYamlFileSync(workspaceManifestFile) // eslint-disable-line + expect(manifest.allowBuilds['@pnpm.e2e/existing-package']).toBe(true) + expect(manifest.allowBuilds['@pnpm.e2e/pre-and-postinstall-scripts-example']).toBe(true) + // install-script-example should NOT be touched + expect(manifest.allowBuilds['@pnpm.e2e/install-script-example']).toBeUndefined() +}) + test('should retain existing allowBuilds entries when approving builds', async () => { const temp = tempDir() diff --git a/installing/commands/package.json b/installing/commands/package.json index 85bea4cd7c..eafeb472ab 100644 --- a/installing/commands/package.json +++ b/installing/commands/package.json @@ -45,6 +45,7 @@ "@pnpm/config.writer": "workspace:*", "@pnpm/constants": "workspace:*", "@pnpm/deps.inspection.outdated": "workspace:*", + "@pnpm/deps.path": "workspace:*", "@pnpm/deps.status": "workspace:*", "@pnpm/error": "workspace:*", "@pnpm/fs.graceful-fs": "workspace:*", @@ -64,6 +65,7 @@ "@pnpm/store.connection-manager": "workspace:*", "@pnpm/store.controller": "workspace:*", "@pnpm/types": "workspace:*", + "@pnpm/util.lex-comparator": "catalog:", "@pnpm/workspace.project-manifest-reader": "workspace:*", "@pnpm/workspace.project-manifest-writer": "workspace:*", "@pnpm/workspace.projects-filter": "workspace:*", diff --git a/installing/commands/src/handleIgnoredBuilds.ts b/installing/commands/src/handleIgnoredBuilds.ts new file mode 100644 index 0000000000..9e1feee09a --- /dev/null +++ b/installing/commands/src/handleIgnoredBuilds.ts @@ -0,0 +1,51 @@ +import { writeSettings } from '@pnpm/config.writer' +import { parse } from '@pnpm/deps.path' +import { + IgnoredBuildsError, +} from '@pnpm/installing.deps-installer' +import type { IgnoredBuilds } from '@pnpm/types' +import { lexCompare } from '@pnpm/util.lex-comparator' + +export interface HandleIgnoredBuildsOpts { + allowBuilds?: Record + rootProjectManifestDir?: string + workspaceDir?: string + strictDepBuilds?: boolean +} + +export async function handleIgnoredBuilds ( + opts: HandleIgnoredBuildsOpts, + ignoredBuilds: IgnoredBuilds | undefined +): Promise { + if (!ignoredBuilds?.size) return + await writeIgnoredBuildsToAllowBuilds(opts, ignoredBuilds) + if (opts.strictDepBuilds) { + throw new IgnoredBuildsError(ignoredBuilds) + } +} + +async function writeIgnoredBuildsToAllowBuilds ( + opts: Pick, + ignoredBuilds: IgnoredBuilds +): Promise { + const packageNames = packageNamesFromIgnoredBuilds(ignoredBuilds) + const newEntries: Record = {} + for (const name of packageNames) { + if (opts.allowBuilds?.[name] == null) { + newEntries[name] = 'set this to true or false' + } + } + if (Object.keys(newEntries).length && opts.rootProjectManifestDir) { + await writeSettings({ + rootProjectManifestDir: opts.rootProjectManifestDir, + workspaceDir: opts.workspaceDir ?? opts.rootProjectManifestDir, + updatedSettings: { + allowBuilds: { ...opts.allowBuilds, ...newEntries }, + }, + }) + } +} + +function packageNamesFromIgnoredBuilds (ignoredBuilds: IgnoredBuilds): string[] { + return Array.from(new Set(Array.from(ignoredBuilds).map((dp) => parse(dp).name ?? dp))).sort(lexCompare) +} diff --git a/installing/commands/src/installDeps.ts b/installing/commands/src/installDeps.ts index 9563a9f7bf..ff397ab4ac 100644 --- a/installing/commands/src/installDeps.ts +++ b/installing/commands/src/installDeps.ts @@ -10,7 +10,6 @@ import { checkDepsStatus } from '@pnpm/deps.status' import { PnpmError } from '@pnpm/error' import { arrayOfWorkspacePackagesToMap } from '@pnpm/installing.context' import { - IgnoredBuildsError, install, mutateModulesInSingleProject, type MutateModulesOptions, @@ -39,6 +38,7 @@ import { updateWorkspaceManifest } from '@pnpm/workspace.workspace-manifest-writ import { getPinnedVersion } from './getPinnedVersion.js' import { getSaveType } from './getSaveType.js' +import { handleIgnoredBuilds } from './handleIgnoredBuilds.js' import { type CommandFullName, createMatcher, @@ -355,9 +355,7 @@ when running add/update with the --workspace option') configDependencies: opts.configDependencies, }) } - if (opts.strictDepBuilds && ignoredBuilds?.size) { - throw new IgnoredBuildsError(ignoredBuilds) - } + await handleIgnoredBuilds(opts, ignoredBuilds) return } @@ -376,9 +374,7 @@ when running add/update with the --workspace option') }), ]) } - if (opts.strictDepBuilds && ignoredBuilds?.size) { - throw new IgnoredBuildsError(ignoredBuilds) - } + await handleIgnoredBuilds(opts, ignoredBuilds) if (opts.linkWorkspacePackages && opts.workspaceDir) { const { selectedProjectsGraph } = await filterProjectsBySelectorObjects(allProjects, [ diff --git a/installing/commands/src/recursive.ts b/installing/commands/src/recursive.ts index 64a6d820f9..468e1569ad 100755 --- a/installing/commands/src/recursive.ts +++ b/installing/commands/src/recursive.ts @@ -20,7 +20,6 @@ import { requireHooks } from '@pnpm/hooks.pnpmfile' import { arrayOfWorkspacePackagesToMap } from '@pnpm/installing.context' import { addDependenciesToPackage, - IgnoredBuildsError, install, type InstallOptions, type MutatedProject, @@ -35,6 +34,7 @@ import type { PreferredVersions } from '@pnpm/resolving.resolver-base' import { createStoreController, type CreateStoreControllerOptions } from '@pnpm/store.connection-manager' import type { StoreController } from '@pnpm/store.controller' import type { + DepPath, IgnoredBuilds, IncludedDependencies, PackageManifest, @@ -52,6 +52,7 @@ import pLimit from 'p-limit' import { getPinnedVersion } from './getPinnedVersion.js' import { getSaveType } from './getSaveType.js' +import { handleIgnoredBuilds } from './handleIgnoredBuilds.js' import { createWorkspaceSpecs, updateToWorkspacePackagesFromManifest } from './updateWorkspaceDependencies.js' export type RecursiveOptions = CreateStoreControllerOptions & Pick() const limitInstallation = pLimit(getWorkspaceConcurrency(opts.workspaceConcurrency)) await Promise.all(pkgPaths.map(async (rootDir) => limitInstallation(async () => { @@ -425,8 +425,10 @@ export async function recursive ( Object.assign(updatedCatalogs, newCatalogsAddition) } } - if (opts.strictDepBuilds && ignoredBuilds?.size) { - throw new IgnoredBuildsError(ignoredBuilds) + if (ignoredBuilds?.size) { + for (const depPath of ignoredBuilds) { + allIgnoredBuilds.add(depPath) + } } result[rootDir].status = 'passed' } catch (err: any) { // eslint-disable-line @@ -447,7 +449,7 @@ export async function recursive ( } }) )) - + await handleIgnoredBuilds(opts, allIgnoredBuilds.size ? allIgnoredBuilds : undefined) await updateWorkspaceManifest(opts.workspaceDir, { updatedCatalogs, cleanupUnusedCatalogs: opts.cleanupUnusedCatalogs, diff --git a/installing/commands/tsconfig.json b/installing/commands/tsconfig.json index b964f21b72..a10ceac266 100644 --- a/installing/commands/tsconfig.json +++ b/installing/commands/tsconfig.json @@ -66,6 +66,9 @@ { "path": "../../deps/inspection/outdated" }, + { + "path": "../../deps/path" + }, { "path": "../../deps/status" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d68dd98656..118292f73b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4778,6 +4778,9 @@ importers: '@pnpm/deps.inspection.outdated': specifier: workspace:* version: link:../../deps/inspection/outdated + '@pnpm/deps.path': + specifier: workspace:* + version: link:../../deps/path '@pnpm/deps.status': specifier: workspace:* version: link:../../deps/status @@ -4835,6 +4838,9 @@ importers: '@pnpm/types': specifier: workspace:* version: link:../../core/types + '@pnpm/util.lex-comparator': + specifier: 'catalog:' + version: 3.0.2 '@pnpm/workspace.project-manifest-reader': specifier: workspace:* version: link:../../workspace/project-manifest-reader diff --git a/pnpm/test/install/lifecycleScripts.ts b/pnpm/test/install/lifecycleScripts.ts index 74619c7cde..956b6f330d 100644 --- a/pnpm/test/install/lifecycleScripts.ts +++ b/pnpm/test/install/lifecycleScripts.ts @@ -154,7 +154,10 @@ test('selectively allow scripts in some dependencies by --allow-build flag', asy expect(fs.existsSync('node_modules/@pnpm.e2e/install-script-example/generated-by-install.js')).toBeTruthy() const modulesManifest = await readWorkspaceManifest(project.dir()) - expect(modulesManifest?.allowBuilds).toStrictEqual({ '@pnpm.e2e/install-script-example': true }) + expect(modulesManifest?.allowBuilds).toStrictEqual({ + '@pnpm.e2e/install-script-example': true, + '@pnpm.e2e/pre-and-postinstall-scripts-example': 'set this to true or false', + }) }) test('--allow-build flag should specify the package', async () => { @@ -253,6 +256,49 @@ test('the list of ignored builds is preserved after a repeat install', async () ]) }) +test('ignored builds are auto-populated as placeholders in allowBuilds', async () => { + prepare({}) + execPnpmSync(['add', '@pnpm.e2e/pre-and-postinstall-scripts-example@1.0.0']) + + const manifest = await readWorkspaceManifest(process.cwd()) + expect(manifest?.allowBuilds?.['@pnpm.e2e/pre-and-postinstall-scripts-example']).toBe('set this to true or false') +}) + +test('auto-populated placeholders are merged with existing allowBuilds', async () => { + prepare({}) + writeYamlFileSync('pnpm-workspace.yaml', { + allowBuilds: { + '@pnpm.e2e/install-script-example': true, + }, + }) + execPnpmSync(['add', '@pnpm.e2e/pre-and-postinstall-scripts-example@1.0.0']) + + const manifest = await readWorkspaceManifest(process.cwd()) + expect(manifest?.allowBuilds?.['@pnpm.e2e/install-script-example']).toBe(true) + expect(manifest?.allowBuilds?.['@pnpm.e2e/pre-and-postinstall-scripts-example']).toBe('set this to true or false') +}) + +test('selective rebuild preserves ignoredBuilds for packages not being rebuilt', async () => { + const project = prepare({}) + writeYamlFileSync('pnpm-workspace.yaml', { + allowBuilds: { + '@pnpm.e2e/pre-and-postinstall-scripts-example': true, + }, + }) + execPnpmSync(['add', '@pnpm.e2e/pre-and-postinstall-scripts-example@1.0.0', '@pnpm.e2e/install-script-example']) + + // install-script-example should be in ignoredBuilds + const beforeRebuild = project.readModulesManifest() + expect(beforeRebuild!.ignoredBuilds).toBeDefined() + + // Selectively rebuild only the approved package + execPnpmSync(['rebuild', '@pnpm.e2e/pre-and-postinstall-scripts-example']) + + // install-script-example should still be in ignoredBuilds after selective rebuild + const afterRebuild = project.readModulesManifest() + expect(afterRebuild!.ignoredBuilds).toBeDefined() +}) + test('git dependencies with preparation scripts should be installed when dangerouslyAllowAllBuilds is true', async () => { prepare({}) writeYamlFileSync('pnpm-workspace.yaml', { dangerouslyAllowAllBuilds: true })