From 5d2b5ee641e0c59962bff750e293aecca0e8a5e4 Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Thu, 17 Feb 2022 11:14:17 +0200 Subject: [PATCH] feat: onlyBuiltDependencies (#4014) close #4001 Co-authored-by: Jack Works --- .changeset/dry-flowers-grab.md | 13 +++ .changeset/large-games-hug.md | 8 ++ .changeset/serious-frogs-yell.md | 5 ++ .../core/src/install/extendInstallOptions.ts | 4 + packages/core/src/install/index.ts | 52 +++++++++-- .../core/test/install/lifecycleScripts.ts | 89 ++++++++++++++++++- .../lockfile-file/src/sortLockfileKeys.ts | 2 + packages/lockfile-file/src/write.ts | 3 + .../test/normalizeLockfile.test.ts | 3 + packages/lockfile-types/src/index.ts | 1 + .../src/getOptionsFromRootManifest.ts | 3 + packages/resolve-dependencies/src/index.ts | 7 +- .../src/resolveDependencies.ts | 14 +-- .../src/resolveDependencyTree.ts | 4 +- packages/types/src/package.ts | 1 + 15 files changed, 193 insertions(+), 16 deletions(-) create mode 100644 .changeset/dry-flowers-grab.md create mode 100644 .changeset/large-games-hug.md create mode 100644 .changeset/serious-frogs-yell.md diff --git a/.changeset/dry-flowers-grab.md b/.changeset/dry-flowers-grab.md new file mode 100644 index 00000000000..405c5cbd370 --- /dev/null +++ b/.changeset/dry-flowers-grab.md @@ -0,0 +1,13 @@ +--- +"pnpm": minor +--- + +A new setting is supported in the `pnpm` section of the `package.json` file [#4001](https://github.com/pnpm/pnpm/issues/4001). `onlyBuiltDependencies` is an array of package names that are allowed to be executed during installation. If this field exists, only mentioned packages will be able to run install scripts. + +```json +{ + "pnpm": { + "onlyBuiltDependencies": ["fsevents"] + } +} +``` diff --git a/.changeset/large-games-hug.md b/.changeset/large-games-hug.md new file mode 100644 index 00000000000..7370c2369af --- /dev/null +++ b/.changeset/large-games-hug.md @@ -0,0 +1,8 @@ +--- +"@pnpm/core": minor +"@pnpm/lockfile-file": minor +"@pnpm/lockfile-types": minor +"@pnpm/types": minor +--- + +New optional field supported: `onlyBuiltDependencies`. diff --git a/.changeset/serious-frogs-yell.md b/.changeset/serious-frogs-yell.md new file mode 100644 index 00000000000..72e5c509411 --- /dev/null +++ b/.changeset/serious-frogs-yell.md @@ -0,0 +1,5 @@ +--- +"@pnpm/resolve-dependencies": major +--- + +Removed the `neverBuiltDependencies` option. In order to ignore scripts of some dependencies, use the new `allowBuild`. `allowBuild` is a function that accepts the package name and returns `true` if the package should be allowed to build. diff --git a/packages/core/src/install/extendInstallOptions.ts b/packages/core/src/install/extendInstallOptions.ts index 392afcf48f3..54def5f119e 100644 --- a/packages/core/src/install/extendInstallOptions.ts +++ b/packages/core/src/install/extendInstallOptions.ts @@ -49,6 +49,7 @@ export interface StrictInstallOptions { verifyStoreIntegrity: boolean engineStrict: boolean neverBuiltDependencies: string[] + onlyBuiltDependencies?: string[] nodeExecPath?: string nodeLinker?: 'isolated' | 'hoisted' | 'pnp' nodeVersion: string @@ -180,6 +181,9 @@ export default async ( } } } + if (opts.onlyBuiltDependencies && opts.neverBuiltDependencies) { + throw new PnpmError('CONFIG_CONFLICT_BUILT_DEPENDENCIES', 'Cannot have both neverBuiltDependencies and onlyBuiltDependencies') + } const defaultOpts = await defaults(opts) const extendedOpts = { ...defaultOpts, diff --git a/packages/core/src/install/index.ts b/packages/core/src/install/index.ts index 138131a94fd..c7921de38d7 100644 --- a/packages/core/src/install/index.ts +++ b/packages/core/src/install/index.ts @@ -223,14 +223,18 @@ export async function mutateModules ( ) } const packageExtensionsChecksum = isEmpty(opts.packageExtensions ?? {}) ? undefined : createObjectChecksum(opts.packageExtensions!) - let needsFullResolution = !maybeOpts.ignorePackageManifest && ( - !equals(ctx.wantedLockfile.overrides ?? {}, opts.overrides ?? {}) || - !equals((ctx.wantedLockfile.neverBuiltDependencies ?? []).sort(), (opts.neverBuiltDependencies ?? []).sort()) || - ctx.wantedLockfile.packageExtensionsChecksum !== packageExtensionsChecksum) || + let needsFullResolution = !maybeOpts.ignorePackageManifest && + lockfileIsUpToDate(ctx.wantedLockfile, { + overrides: opts.overrides, + neverBuiltDependencies: opts.neverBuiltDependencies, + onlyBuiltDependencies: opts.onlyBuiltDependencies, + packageExtensionsChecksum, + }) || opts.fixLockfile if (needsFullResolution) { ctx.wantedLockfile.overrides = opts.overrides ctx.wantedLockfile.neverBuiltDependencies = opts.neverBuiltDependencies + ctx.wantedLockfile.onlyBuiltDependencies = opts.onlyBuiltDependencies ctx.wantedLockfile.packageExtensionsChecksum = packageExtensionsChecksum } const frozenLockfile = opts.frozenLockfile || @@ -467,6 +471,25 @@ export async function mutateModules ( } } +function lockfileIsUpToDate ( + lockfile: Lockfile, + { + neverBuiltDependencies, + onlyBuiltDependencies, + overrides, + packageExtensionsChecksum, + }: { + neverBuiltDependencies?: string[] + onlyBuiltDependencies?: string[] + overrides?: Record + packageExtensionsChecksum?: string + }) { + return !equals(lockfile.overrides ?? {}, overrides ?? {}) || + !equals((lockfile.neverBuiltDependencies ?? []).sort(), (neverBuiltDependencies ?? []).sort()) || + !equals(onlyBuiltDependencies?.sort(), lockfile.onlyBuiltDependencies) || + lockfile.packageExtensionsChecksum !== packageExtensionsChecksum +} + export function createObjectChecksum (obj: Object) { const s = JSON.stringify(obj) return crypto.createHash('md5').update(s).digest('hex') @@ -608,7 +631,8 @@ type InstallFunction = ( opts: StrictInstallOptions & { makePartialCurrentLockfile: boolean needsFullResolution: boolean - neverBuiltDependencies: string[] + neverBuiltDependencies?: string[] + onlyBuiltDependencies?: string[] overrides?: Record updateLockfileMinorVersion: boolean preferredVersions?: PreferredVersions @@ -705,6 +729,7 @@ const _installInContext: InstallFunction = async (projects, ctx, opts) => { } = await resolveDependencies( projects, { + allowBuild: createAllowBuildFunction(opts), currentLockfile: ctx.currentLockfile, defaultUpdateDepth: (opts.update || (opts.updateMatching != null)) ? opts.depth : -1, dryRun: opts.lockfileOnly, @@ -714,7 +739,6 @@ const _installInContext: InstallFunction = async (projects, ctx, opts) => { hooks: opts.hooks, linkWorkspacePackagesDepth: opts.linkWorkspacePackagesDepth ?? (opts.saveWorkspaceProtocol ? 0 : -1), lockfileDir: opts.lockfileDir, - neverBuiltDependencies: new Set(opts.neverBuiltDependencies), nodeVersion: opts.nodeVersion, pnpmVersion: opts.packageManager.name === 'pnpm' ? opts.packageManager.version : '', preferWorkspacePackages: opts.preferWorkspacePackages, @@ -985,6 +1009,22 @@ const _installInContext: InstallFunction = async (projects, ctx, opts) => { } } +function createAllowBuildFunction ( + opts: { + neverBuiltDependencies?: string[] + onlyBuiltDependencies?: string[] + } +): undefined | ((pkgName: string) => boolean) { + if (opts.neverBuiltDependencies != null && opts.neverBuiltDependencies.length > 0) { + const neverBuiltDependencies = new Set(opts.neverBuiltDependencies) + return (pkgName) => !neverBuiltDependencies.has(pkgName) + } else if (opts.onlyBuiltDependencies != null) { + const onlyBuiltDependencies = new Set(opts.onlyBuiltDependencies) + return (pkgName) => onlyBuiltDependencies.has(pkgName) + } + return undefined +} + const installInContext: InstallFunction = async (projects, ctx, opts) => { try { if (opts.nodeLinker === 'hoisted' && !opts.lockfileOnly) { diff --git a/packages/core/test/install/lifecycleScripts.ts b/packages/core/test/install/lifecycleScripts.ts index 278ba70756d..8ae8b852e01 100644 --- a/packages/core/test/install/lifecycleScripts.ts +++ b/packages/core/test/install/lifecycleScripts.ts @@ -443,7 +443,7 @@ test('scripts have access to unlisted bins when hoisting is used', async () => { expect(project.requireModule('pkg-that-calls-unlisted-dep-in-hooks/output.json')).toStrictEqual(['Hello world!']) }) -test('selectively ignore scripts in some dependencies', async () => { +test('selectively ignore scripts in some dependencies by neverBuiltDependencies', async () => { const project = prepareEmpty() const neverBuiltDependencies = ['pre-and-postinstall-scripts-example'] const manifest = await addDependenciesToPackage({}, @@ -469,6 +469,44 @@ test('selectively ignore scripts in some dependencies', async () => { expect(await exists('node_modules/install-script-example/generated-by-install.js')).toBeTruthy() }) +test('throw an exception when both neverBuiltDependencies and onlyBuiltDependencies are used', async () => { + prepareEmpty() + + await expect( + addDependenciesToPackage( + {}, + ['pre-and-postinstall-scripts-example'], + await testDefaults({ onlyBuiltDependencies: ['foo'], neverBuiltDependencies: ['bar'] }) + ) + ).rejects.toThrow(/Cannot have both/) +}) + +test('selectively allow scripts in some dependencies by onlyBuiltDependencies', async () => { + const project = prepareEmpty() + const onlyBuiltDependencies = ['install-script-example'] + const manifest = await addDependenciesToPackage({}, + ['pre-and-postinstall-scripts-example', 'install-script-example'], + await testDefaults({ fastUnpack: false, onlyBuiltDependencies }) + ) + + expect(await exists('node_modules/pre-and-postinstall-scripts-example/generated-by-preinstall.js')).toBeFalsy() + expect(await exists('node_modules/pre-and-postinstall-scripts-example/generated-by-postinstall.js')).toBeFalsy() + expect(await exists('node_modules/install-script-example/generated-by-install.js')).toBeTruthy() + + const lockfile = await project.readLockfile() + expect(lockfile.onlyBuiltDependencies).toStrictEqual(onlyBuiltDependencies) + expect(lockfile.packages['/pre-and-postinstall-scripts-example/1.0.0'].requiresBuild).toBe(undefined) + expect(lockfile.packages['/install-script-example/1.0.0'].requiresBuild).toBe(true) + + await rimraf('node_modules') + + await install(manifest, await testDefaults({ fastUnpack: false, frozenLockfile: true, onlyBuiltDependencies })) + + expect(await exists('node_modules/pre-and-postinstall-scripts-example/generated-by-preinstall.js')).toBeFalsy() + expect(await exists('node_modules/pre-and-postinstall-scripts-example/generated-by-postinstall.js')).toBeFalsy() + expect(await exists('node_modules/install-script-example/generated-by-install.js')).toBeTruthy() +}) + test('lockfile is updated if neverBuiltDependencies is changed', async () => { const project = prepareEmpty() const manifest = await addDependenciesToPackage({}, @@ -501,6 +539,55 @@ test('lockfile is updated if neverBuiltDependencies is changed', async () => { } }) +test('lockfile is updated if onlyBuiltDependencies is changed', async () => { + const project = prepareEmpty() + const manifest = await addDependenciesToPackage({}, + ['pre-and-postinstall-scripts-example', 'install-script-example'], + await testDefaults({ fastUnpack: false }) + ) + + { + const lockfile = await project.readLockfile() + expect(lockfile.onlyBuiltDependencies).toBeFalsy() + expect(lockfile.packages['/pre-and-postinstall-scripts-example/1.0.0'].requiresBuild).toBeTruthy() + expect(lockfile.packages['/install-script-example/1.0.0'].requiresBuild).toBeTruthy() + } + + const onlyBuiltDependencies: string[] = [] + await mutateModules([ + { + buildIndex: 0, + manifest, + mutation: 'install', + rootDir: process.cwd(), + }, + ], await testDefaults({ onlyBuiltDependencies })) + + { + const lockfile = await project.readLockfile() + expect(lockfile.onlyBuiltDependencies).toStrictEqual(onlyBuiltDependencies) + expect(lockfile.packages['/pre-and-postinstall-scripts-example/1.0.0'].requiresBuild).toBe(undefined) + expect(lockfile.packages['/install-script-example/1.0.0'].requiresBuild).toBe(undefined) + } + + onlyBuiltDependencies.push('pre-and-postinstall-scripts-example') + await mutateModules([ + { + buildIndex: 0, + manifest, + mutation: 'install', + rootDir: process.cwd(), + }, + ], await testDefaults({ onlyBuiltDependencies })) + + { + const lockfile = await project.readLockfile() + expect(lockfile.onlyBuiltDependencies).toStrictEqual(onlyBuiltDependencies) + expect(lockfile.packages['/pre-and-postinstall-scripts-example/1.0.0'].requiresBuild).toBe(true) + expect(lockfile.packages['/install-script-example/1.0.0'].requiresBuild).toBe(undefined) + } +}) + test('lifecycle scripts have access to package\'s own binary by binary name', async () => { const project = prepareEmpty() await addDependenciesToPackage({}, diff --git a/packages/lockfile-file/src/sortLockfileKeys.ts b/packages/lockfile-file/src/sortLockfileKeys.ts index a097cdc4263..ea5533e7b13 100644 --- a/packages/lockfile-file/src/sortLockfileKeys.ts +++ b/packages/lockfile-file/src/sortLockfileKeys.ts @@ -31,7 +31,9 @@ const ORDERED_KEYS = { const ROOT_KEYS_ORDER = { lockfileVersion: 1, + // only and never are conflict options. neverBuiltDependencies: 2, + onlyBuiltDependencies: 2, overrides: 3, packageExtensionsChecksum: 4, specifiers: 10, diff --git a/packages/lockfile-file/src/write.ts b/packages/lockfile-file/src/write.ts index 44cb122c206..ec7b662c60a 100644 --- a/packages/lockfile-file/src/write.ts +++ b/packages/lockfile-file/src/write.ts @@ -126,6 +126,9 @@ export function normalizeLockfile (lockfile: Lockfile, forceSharedFormat: boolea lockfileToSave.neverBuiltDependencies = lockfileToSave.neverBuiltDependencies.sort() } } + if (lockfileToSave.onlyBuiltDependencies != null) { + lockfileToSave.onlyBuiltDependencies = lockfileToSave.onlyBuiltDependencies.sort() + } if (!lockfileToSave.packageExtensionsChecksum) { delete lockfileToSave.packageExtensionsChecksum } diff --git a/packages/lockfile-file/test/normalizeLockfile.test.ts b/packages/lockfile-file/test/normalizeLockfile.test.ts index 1cdbdf9437a..ed2727357fd 100644 --- a/packages/lockfile-file/test/normalizeLockfile.test.ts +++ b/packages/lockfile-file/test/normalizeLockfile.test.ts @@ -4,6 +4,8 @@ import { normalizeLockfile } from '@pnpm/lockfile-file/lib/write' test('empty overrides and neverBuiltDependencies are removed during lockfile normalization', () => { expect(normalizeLockfile({ lockfileVersion: LOCKFILE_VERSION, + // but this should be preserved. + onlyBuiltDependencies: [], overrides: {}, neverBuiltDependencies: [], packages: {}, @@ -19,6 +21,7 @@ test('empty overrides and neverBuiltDependencies are removed during lockfile nor }, }, false)).toStrictEqual({ lockfileVersion: LOCKFILE_VERSION, + onlyBuiltDependencies: [], importers: { foo: { dependencies: { diff --git a/packages/lockfile-types/src/index.ts b/packages/lockfile-types/src/index.ts index 0656affad4a..92c6d14a90f 100644 --- a/packages/lockfile-types/src/index.ts +++ b/packages/lockfile-types/src/index.ts @@ -5,6 +5,7 @@ export interface Lockfile { lockfileVersion: number packages?: PackageSnapshots neverBuiltDependencies?: string[] + onlyBuiltDependencies?: string[] overrides?: Record packageExtensionsChecksum?: string } diff --git a/packages/plugin-commands-installation/src/getOptionsFromRootManifest.ts b/packages/plugin-commands-installation/src/getOptionsFromRootManifest.ts index bd3446873ab..b1c25745130 100644 --- a/packages/plugin-commands-installation/src/getOptionsFromRootManifest.ts +++ b/packages/plugin-commands-installation/src/getOptionsFromRootManifest.ts @@ -7,6 +7,7 @@ import { export default function getOptionsFromRootManifest (manifest: ProjectManifest): { overrides?: Record neverBuiltDependencies?: string[] + onlyBuiltDependencies?: string[] packageExtensions?: Record peerDependencyRules?: PeerDependencyRules } { @@ -15,11 +16,13 @@ export default function getOptionsFromRootManifest (manifest: ProjectManifest): // so we cannot call it resolutions const overrides = manifest.pnpm?.overrides ?? manifest.resolutions const neverBuiltDependencies = manifest.pnpm?.neverBuiltDependencies ?? [] + const onlyBuiltDependencies = manifest.pnpm?.onlyBuiltDependencies const packageExtensions = manifest.pnpm?.packageExtensions const peerDependencyRules = manifest.pnpm?.peerDependencyRules return { overrides, neverBuiltDependencies, + onlyBuiltDependencies, packageExtensions, peerDependencyRules, } diff --git a/packages/resolve-dependencies/src/index.ts b/packages/resolve-dependencies/src/index.ts index 6b471fc2e90..d56e5add376 100644 --- a/packages/resolve-dependencies/src/index.ts +++ b/packages/resolve-dependencies/src/index.ts @@ -195,7 +195,12 @@ export default async function ( if (opts.forceFullResolution && opts.wantedLockfile != null) { for (const [depPath, pkg] of Object.entries(dependenciesGraph)) { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - if (opts.neverBuiltDependencies?.has(pkg.name) || opts.wantedLockfile.packages?.[depPath] == null || pkg.requiresBuild) continue + if ( + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + (opts.allowBuild != null && !opts.allowBuild(pkg.name)) || + (opts.wantedLockfile.packages?.[depPath] == null) || + pkg.requiresBuild + ) continue pendingRequiresBuilds.push(depPath) } } diff --git a/packages/resolve-dependencies/src/resolveDependencies.ts b/packages/resolve-dependencies/src/resolveDependencies.ts index 561b7627614..af969a451c2 100644 --- a/packages/resolve-dependencies/src/resolveDependencies.ts +++ b/packages/resolve-dependencies/src/resolveDependencies.ts @@ -116,6 +116,7 @@ export interface ChildrenByParentDepPath { } export interface ResolutionContext { + allowBuild?: (pkgName: string) => boolean updatedSet: Set defaultTag: string dryRun: boolean @@ -128,7 +129,6 @@ export interface ResolutionContext { currentLockfile: Lockfile linkWorkspacePackagesDepth: number lockfileDir: string - neverBuiltDependencies: Set storeController: StoreController // the IDs of packages that are not installable skipped: Set @@ -784,11 +784,11 @@ async function resolveDependency ( }) ctx.resolvedPackagesByDepPath[depPath] = getResolvedPackage({ + allowBuild: ctx.allowBuild, dependencyLockfile: currentPkg.dependencyLockfile, depPath, force: ctx.force, hasBin, - neverBuiltDependencies: ctx.neverBuiltDependencies, pkg, pkgResponse, prepare, @@ -843,11 +843,11 @@ function pkgIsLeaf (pkg: PackageManifest) { function getResolvedPackage ( options: { + allowBuild?: (pkgName: string) => boolean dependencyLockfile?: PackageSnapshot depPath: string force: boolean hasBin: boolean - neverBuiltDependencies: Set pkg: PackageManifest pkgResponse: PackageResponse prepare: boolean @@ -856,6 +856,10 @@ function getResolvedPackage ( ) { const peerDependencies = peerDependenciesWithoutOwn(options.pkg) + const requiresBuild = (options.allowBuild == null || options.allowBuild(options.pkg.name)) + ? ((options.dependencyLockfile != null) ? Boolean(options.dependencyLockfile.requiresBuild) : undefined) + : false + return { additionalInfo: { bundledDependencies: options.pkg.bundledDependencies, @@ -881,9 +885,7 @@ function getResolvedPackage ( peerDependenciesMeta: options.pkg.peerDependenciesMeta, prepare: options.prepare, prod: !options.wantedDependency.dev && !options.wantedDependency.optional, - requiresBuild: options.neverBuiltDependencies.has(options.pkg.name) - ? false - : ((options.dependencyLockfile != null) ? Boolean(options.dependencyLockfile.requiresBuild) : undefined), + requiresBuild, resolution: options.pkgResponse.body.resolution, version: options.pkg.version, } diff --git a/packages/resolve-dependencies/src/resolveDependencyTree.ts b/packages/resolve-dependencies/src/resolveDependencyTree.ts index 5991e20eeb0..0627bfbc63e 100644 --- a/packages/resolve-dependencies/src/resolveDependencyTree.ts +++ b/packages/resolve-dependencies/src/resolveDependencyTree.ts @@ -52,6 +52,7 @@ export interface ImporterToResolveGeneric extends Importer { } export interface ResolveDependenciesOptions { + allowBuild?: (pkgName: string) => boolean currentLockfile: Lockfile dryRun: boolean engineStrict: boolean @@ -60,7 +61,6 @@ export interface ResolveDependenciesOptions { hooks: { readPackage?: ReadPackageHook } - neverBuiltDependencies?: Set nodeVersion: string registries: Registries pnpmVersion: string @@ -84,6 +84,7 @@ export default async function ( const wantedToBeSkippedPackageIds = new Set() const ctx = { + allowBuild: opts.allowBuild, childrenByParentDepPath: {} as ChildrenByParentDepPath, currentLockfile: opts.currentLockfile, defaultTag: opts.tag, @@ -94,7 +95,6 @@ export default async function ( forceFullResolution: opts.forceFullResolution, linkWorkspacePackagesDepth: opts.linkWorkspacePackagesDepth ?? -1, lockfileDir: opts.lockfileDir, - neverBuiltDependencies: opts.neverBuiltDependencies ?? new Set(), nodeVersion: opts.nodeVersion, outdatedDependencies: {} as {[pkgId: string]: string}, pendingNodes: [] as PendingNode[], diff --git a/packages/types/src/package.ts b/packages/types/src/package.ts index c2815d62049..f4312b4b2f2 100644 --- a/packages/types/src/package.ts +++ b/packages/types/src/package.ts @@ -105,6 +105,7 @@ export interface PeerDependencyRules { export type ProjectManifest = BaseManifest & { pnpm?: { neverBuiltDependencies?: string[] + onlyBuiltDependencies?: string[] overrides?: Record packageExtensions?: Record peerDependencyRules?: PeerDependencyRules