Files
pnpm/pkg-manager/plugin-commands-installation/src/recursive.ts
Fotis Papadogeorgopoulos c7eefdd082 fix: pnpm update --filter --latest should only change relevant packages and projects, with dedupe-peer-dependents=true (#8905)
* test(update): add failing tests for update with dedupe-peer-dependents=true

Relates to https://github.com/pnpm/pnpm/issues/8877

* fix: update --filter --latest should work with dedupe-peer-dependents

Fixes https://github.com/pnpm/pnpm/issues/8877, whereby
`update --filter --latest` with `dedupe-peer-dependents` would end up
updating all available dependencies for all projects.

* test(pnpm): more accurate dedupePeers filtered install case

* docs: add changeset for updateToLatest moving to projects/importers

* docs: add changesets for pnpm and plugin-commands-installation

* chore: fix tsc issue by removing unknown bound resolver property

This unknown property was accepted by tsc prior to adding updateToLatest
in toResovleImporter options, but now it was erroring out. This is
likely a tsc quirk about the shape of the object; regardless that
property is not defined, and should not be present.

* test: keep only pnpm/test/monorepo/dedupePeers.test.ts

There was duplicate coverage of the pnpm update --filter --latest
command between two tests, so this keeps only the one dedicated
to testing the dedupe-peer-dependents feature.

* chore: fix unused import error
2024-12-29 17:30:36 +01:00

509 lines
17 KiB
TypeScript
Executable File

import { promises as fs } from 'fs'
import path from 'path'
import {
type RecursiveSummary,
throwOnCommandFail,
} from '@pnpm/cli-utils'
import { type Config, getOptionsFromRootManifest, readLocalConfig } from '@pnpm/config'
import { PnpmError } from '@pnpm/error'
import { arrayOfWorkspacePackagesToMap } from '@pnpm/get-context'
import { logger } from '@pnpm/logger'
import { filterDependenciesByType } from '@pnpm/manifest-utils'
import { createMatcherWithIndex } from '@pnpm/matcher'
import { rebuild } from '@pnpm/plugin-commands-rebuild'
import { requireHooks } from '@pnpm/pnpmfile'
import { sortPackages } from '@pnpm/sort-packages'
import { createOrConnectStoreController, type CreateStoreControllerOptions } from '@pnpm/store-connection-manager'
import {
type IncludedDependencies,
type PackageManifest,
type Project,
type ProjectManifest,
type ProjectsGraph,
type ProjectRootDir,
type ProjectRootDirRealPath,
} from '@pnpm/types'
import {
addDependenciesToPackage,
install,
type InstallOptions,
type MutatedProject,
mutateModules,
type ProjectOptions,
type UpdateMatchingFunction,
type WorkspacePackages,
} from '@pnpm/core'
import isSubdir from 'is-subdir'
import mem from 'mem'
import pFilter from 'p-filter'
import pLimit from 'p-limit'
import { createWorkspaceSpecs, updateToWorkspacePackagesFromManifest } from './updateWorkspaceDependencies'
import { getSaveType } from './getSaveType'
import { getPinnedVersion } from './getPinnedVersion'
import { type PreferredVersions } from '@pnpm/resolver-base'
export type RecursiveOptions = CreateStoreControllerOptions & Pick<Config,
| 'bail'
| 'dedupePeerDependents'
| 'depth'
| 'globalPnpmfile'
| 'hoistPattern'
| 'hooks'
| 'ignorePnpmfile'
| 'ignoreScripts'
| 'linkWorkspacePackages'
| 'lockfileDir'
| 'lockfileOnly'
| 'modulesDir'
| 'pnpmfile'
| 'rawLocalConfig'
| 'registries'
| 'rootProjectManifest'
| 'rootProjectManifestDir'
| 'save'
| 'saveDev'
| 'saveExact'
| 'saveOptional'
| 'savePeer'
| 'savePrefix'
| 'saveProd'
| 'saveWorkspaceProtocol'
| 'lockfileIncludeTarballUrl'
| 'sharedWorkspaceLockfile'
| 'tag'
> & {
include?: IncludedDependencies
includeDirect?: IncludedDependencies
latest?: boolean
pending?: boolean
workspace?: boolean
allowNew?: boolean
forceHoistPattern?: boolean
forcePublicHoistPattern?: boolean
ignoredPackages?: Set<string>
update?: boolean
updatePackageManifest?: boolean
updateMatching?: UpdateMatchingFunction
useBetaCli?: boolean
allProjectsGraph: ProjectsGraph
selectedProjectsGraph: ProjectsGraph
preferredVersions?: PreferredVersions
pruneDirectDependencies?: boolean
} & Partial<
Pick<Config,
| 'sort'
| 'workspaceConcurrency'
>
> & Required<
Pick<Config, 'workspaceDir'>
>
export type CommandFullName = 'install' | 'add' | 'remove' | 'update' | 'import'
export async function recursive (
allProjects: Project[],
params: string[],
opts: RecursiveOptions,
cmdFullName: CommandFullName
): Promise<boolean | string> {
if (allProjects.length === 0) {
// It might make sense to throw an exception in this case
return false
}
const pkgs = Object.values(opts.selectedProjectsGraph).map((wsPkg) => wsPkg.package)
if (pkgs.length === 0) {
return false
}
const manifestsByPath = getManifestsByPath(allProjects)
const throwOnFail = throwOnCommandFail.bind(null, `pnpm recursive ${cmdFullName}`)
const store = await createOrConnectStoreController(opts)
const workspacePackages: WorkspacePackages = arrayOfWorkspacePackagesToMap(allProjects) as WorkspacePackages
const targetDependenciesField = getSaveType(opts)
const rootManifestDir = (opts.lockfileDir ?? opts.dir) as ProjectRootDir
const installOpts = Object.assign(opts, {
...getOptionsFromRootManifest(rootManifestDir, manifestsByPath[rootManifestDir]?.manifest ?? {}),
allProjects: getAllProjects(manifestsByPath, opts.allProjectsGraph, opts.sort),
linkWorkspacePackagesDepth: opts.linkWorkspacePackages === 'deep' ? Infinity : opts.linkWorkspacePackages ? 0 : -1,
ownLifecycleHooksStdio: 'pipe',
peer: opts.savePeer,
pruneLockfileImporters: ((opts.ignoredPackages == null) || opts.ignoredPackages.size === 0) &&
pkgs.length === allProjects.length,
storeController: store.ctrl,
storeDir: store.dir,
targetDependenciesField,
workspacePackages,
forceHoistPattern: typeof opts.rawLocalConfig?.['hoist-pattern'] !== 'undefined' || typeof opts.rawLocalConfig?.['hoist'] !== 'undefined',
forceShamefullyHoist: typeof opts.rawLocalConfig?.['shamefully-hoist'] !== 'undefined',
}) as InstallOptions
const result: RecursiveSummary = {}
const memReadLocalConfig = mem(readLocalConfig)
const updateToLatest = opts.update && opts.latest
const includeDirect = opts.includeDirect ?? {
dependencies: true,
devDependencies: true,
optionalDependencies: true,
}
let updateMatch: UpdateDepsMatcher | null
if (cmdFullName === 'update') {
if (params.length === 0) {
const ignoreDeps = manifestsByPath[opts.workspaceDir as ProjectRootDir]?.manifest?.pnpm?.updateConfig?.ignoreDependencies
if (ignoreDeps?.length) {
params = makeIgnorePatterns(ignoreDeps)
}
}
updateMatch = params.length ? createMatcher(params) : null
} else {
updateMatch = null
}
// For a workspace with shared lockfile
if (opts.lockfileDir && ['add', 'install', 'remove', 'update', 'import'].includes(cmdFullName)) {
let importers = getImporters(opts)
const calculatedRepositoryRoot = await fs.realpath(calculateRepositoryRoot(opts.workspaceDir, importers.map(x => x.rootDir)))
const isFromWorkspace = isSubdir.bind(null, calculatedRepositoryRoot)
importers = await pFilter(importers, async ({ rootDirRealPath }) => isFromWorkspace(rootDirRealPath))
if (importers.length === 0) return true
let mutation!: string
switch (cmdFullName) {
case 'remove':
mutation = 'uninstallSome'
break
case 'import':
mutation = 'install'
break
default:
mutation = (params.length === 0 && !updateToLatest ? 'install' : 'installSome')
break
}
const mutatedImporters = [] as MutatedProject[]
await Promise.all(importers.map(async ({ rootDir }) => {
const localConfig = await memReadLocalConfig(rootDir)
const modulesDir = localConfig.modulesDir ?? opts.modulesDir
const { manifest } = manifestsByPath[rootDir]
let currentInput = [...params]
if (updateMatch != null) {
currentInput = matchDependencies(updateMatch, manifest, includeDirect)
if ((currentInput.length === 0) && (typeof opts.depth === 'undefined' || opts.depth <= 0)) {
installOpts.pruneLockfileImporters = false
return
}
}
if (updateToLatest && (!params || (params.length === 0))) {
currentInput = Object.keys(filterDependenciesByType(manifest, includeDirect))
}
if (opts.workspace) {
if (!currentInput || (currentInput.length === 0)) {
currentInput = updateToWorkspacePackagesFromManifest(manifest, includeDirect, workspacePackages)
} else {
currentInput = createWorkspaceSpecs(currentInput, workspacePackages)
}
}
switch (mutation) {
case 'uninstallSome':
mutatedImporters.push({
dependencyNames: currentInput,
modulesDir,
mutation,
rootDir,
targetDependenciesField,
} as MutatedProject)
return
case 'installSome':
mutatedImporters.push({
allowNew: cmdFullName === 'install' || cmdFullName === 'add',
dependencySelectors: currentInput,
modulesDir,
mutation,
peer: opts.savePeer,
pinnedVersion: getPinnedVersion({
saveExact: typeof localConfig.saveExact === 'boolean' ? localConfig.saveExact : opts.saveExact,
savePrefix: typeof localConfig.savePrefix === 'string' ? localConfig.savePrefix : opts.savePrefix,
}),
rootDir,
targetDependenciesField,
update: opts.update,
updateMatching: opts.updateMatching,
updatePackageManifest: opts.updatePackageManifest,
updateToLatest: opts.latest,
} as MutatedProject)
return
case 'install':
mutatedImporters.push({
modulesDir,
mutation,
pruneDirectDependencies: opts.pruneDirectDependencies,
rootDir,
update: opts.update,
updateMatching: opts.updateMatching,
updatePackageManifest: opts.updatePackageManifest,
updateToLatest: opts.latest,
} as MutatedProject)
}
}))
if (!opts.selectedProjectsGraph[opts.workspaceDir as ProjectRootDir] && manifestsByPath[opts.workspaceDir as ProjectRootDir] != null) {
mutatedImporters.push({
mutation: 'install',
rootDir: opts.workspaceDir as ProjectRootDir,
})
}
if ((mutatedImporters.length === 0) && cmdFullName === 'update' && opts.depth === 0) {
throw new PnpmError('NO_PACKAGE_IN_DEPENDENCIES',
'None of the specified packages were found in the dependencies of any of the projects.')
}
const { updatedProjects: mutatedPkgs } = await mutateModules(mutatedImporters, {
...installOpts,
storeController: store.ctrl,
})
if (opts.save !== false) {
await Promise.all(
mutatedPkgs
.map(async ({ originalManifest, manifest, rootDir }) => {
return manifestsByPath[rootDir].writeProjectManifest(originalManifest ?? manifest)
})
)
}
return true
}
const pkgPaths = (Object.keys(opts.selectedProjectsGraph) as ProjectRootDir[]).sort()
const limitInstallation = pLimit(opts.workspaceConcurrency ?? 4)
await Promise.all(pkgPaths.map(async (rootDir) =>
limitInstallation(async () => {
const hooks = opts.ignorePnpmfile
? {}
: (() => {
const pnpmfileHooks = requireHooks(rootDir, opts)
return {
...opts.hooks,
...pnpmfileHooks,
afterAllResolved: [...(pnpmfileHooks.afterAllResolved ?? []), ...(opts.hooks?.afterAllResolved ?? [])],
readPackage: [...(pnpmfileHooks.readPackage ?? []), ...(opts.hooks?.readPackage ?? [])],
}
})()
try {
if (opts.ignoredPackages?.has(rootDir)) {
return
}
result[rootDir] = { status: 'running' }
const { manifest, writeProjectManifest } = manifestsByPath[rootDir]
let currentInput = [...params]
if (updateMatch != null) {
currentInput = matchDependencies(updateMatch, manifest, includeDirect)
if (currentInput.length === 0) return
}
if (updateToLatest && (!params || (params.length === 0))) {
currentInput = Object.keys(filterDependenciesByType(manifest, includeDirect))
}
if (opts.workspace) {
if (!currentInput || (currentInput.length === 0)) {
currentInput = updateToWorkspacePackagesFromManifest(manifest, includeDirect, workspacePackages)
} else {
currentInput = createWorkspaceSpecs(currentInput, workspacePackages)
}
}
let action!: any // eslint-disable-line @typescript-eslint/no-explicit-any
switch (cmdFullName) {
case 'remove':
action = async (manifest: PackageManifest, opts: any) => { // eslint-disable-line @typescript-eslint/no-explicit-any
const mutationResult = await mutateModules([
{
dependencyNames: currentInput,
mutation: 'uninstallSome',
rootDir,
},
], opts)
return mutationResult.updatedProjects[0].manifest
}
break
default:
action = currentInput.length === 0
? install
: async (manifest: PackageManifest, opts: any) => addDependenciesToPackage(manifest, currentInput, opts) // eslint-disable-line @typescript-eslint/no-explicit-any
break
}
const localConfig = await memReadLocalConfig(rootDir)
const newManifest = await action(
manifest,
{
...installOpts,
...localConfig,
...getOptionsFromRootManifest(rootDir, manifest),
...opts.allProjectsGraph[rootDir]?.package,
bin: path.join(rootDir, 'node_modules', '.bin'),
dir: rootDir,
hooks,
ignoreScripts: true,
pinnedVersion: getPinnedVersion({
saveExact: typeof localConfig.saveExact === 'boolean' ? localConfig.saveExact : opts.saveExact,
savePrefix: typeof localConfig.savePrefix === 'string' ? localConfig.savePrefix : opts.savePrefix,
}),
rawConfig: {
...installOpts.rawConfig,
...localConfig,
},
storeController: store.ctrl,
}
)
if (opts.save !== false) {
await writeProjectManifest(newManifest)
}
result[rootDir].status = 'passed'
} catch (err: any) { // eslint-disable-line
logger.info(err)
if (!opts.bail) {
result[rootDir] = {
status: 'failure',
error: err,
message: err.message,
prefix: rootDir,
}
return
}
err['prefix'] = rootDir
throw err
}
})
))
if (
!opts.lockfileOnly && !opts.ignoreScripts && (
cmdFullName === 'add' ||
cmdFullName === 'install' ||
cmdFullName === 'update'
)
) {
await rebuild.handler({
...opts,
pending: opts.pending === true,
skipIfHasSideEffectsCache: true,
}, [])
}
throwOnFail(result)
if (!Object.values(result).filter(({ status }) => status === 'passed').length && cmdFullName === 'update' && opts.depth === 0) {
throw new PnpmError('NO_PACKAGE_IN_DEPENDENCIES',
'None of the specified packages were found in the dependencies of any of the projects.')
}
return true
}
function calculateRepositoryRoot (
workspaceDir: string,
projectDirs: string[]
): string {
// assume repo root is workspace dir
let relativeRepoRoot = '.'
for (const rootDir of projectDirs) {
const relativePartRegExp = new RegExp(`^(\\.\\.\\${path.sep})+`)
const relativePartMatch = relativePartRegExp.exec(path.relative(workspaceDir, rootDir))
if (relativePartMatch != null) {
const relativePart = relativePartMatch[0]
if (relativePart.length > relativeRepoRoot.length) {
relativeRepoRoot = relativePart
}
}
}
return path.resolve(workspaceDir, relativeRepoRoot)
}
export function matchDependencies (
match: (input: string) => string | null,
manifest: ProjectManifest,
include: IncludedDependencies
): string[] {
const deps = Object.keys(filterDependenciesByType(manifest, include))
const matchedDeps = []
for (const dep of deps) {
const spec = match(dep)
if (spec === null) continue
matchedDeps.push(spec ? `${dep}@${spec}` : dep)
}
return matchedDeps
}
export type UpdateDepsMatcher = (input: string) => string | null
export function createMatcher (params: string[]): UpdateDepsMatcher {
const patterns: string[] = []
const specs: string[] = []
for (const param of params) {
const { pattern, versionSpec } = parseUpdateParam(param)
patterns.push(pattern)
specs.push(versionSpec ?? '')
}
const matcher = createMatcherWithIndex(patterns)
return (depName: string) => {
const index = matcher(depName)
if (index === -1) return null
return specs[index]
}
}
export function parseUpdateParam (param: string): { pattern: string, versionSpec: string | undefined } {
const atIndex = param.indexOf('@', param[0] === '!' ? 2 : 1)
if (atIndex === -1) {
return {
pattern: param,
versionSpec: undefined,
}
}
return {
pattern: param.slice(0, atIndex),
versionSpec: param.slice(atIndex + 1),
}
}
export function makeIgnorePatterns (ignoredDependencies: string[]): string[] {
return ignoredDependencies.map(depName => `!${depName}`)
}
function getAllProjects (manifestsByPath: ManifestsByPath, allProjectsGraph: ProjectsGraph, sort?: boolean): ProjectOptions[] {
const chunks = sort !== false
? sortPackages(allProjectsGraph)
: [(Object.keys(allProjectsGraph) as ProjectRootDir[]).sort()]
return chunks.map((prefixes, buildIndex) => prefixes.map((rootDir) => {
const { rootDirRealPath, modulesDir } = allProjectsGraph[rootDir].package
return {
buildIndex,
manifest: manifestsByPath[rootDir].manifest,
rootDir,
rootDirRealPath,
modulesDir,
}
})).flat()
}
interface ManifestsByPath { [dir: string]: Omit<Project, 'rootDir' | 'rootDirRealPath'> }
function getManifestsByPath (projects: Project[]): Record<ProjectRootDir, Omit<Project, 'rootDir' | 'rootDirRealPath'>> {
const manifestsByPath: Record<string, Omit<Project, 'rootDir' | 'rootDirRealPath'>> = {}
for (const { rootDir, manifest, writeProjectManifest } of projects) {
manifestsByPath[rootDir] = { manifest, writeProjectManifest }
}
return manifestsByPath
}
function getImporters (opts: Pick<RecursiveOptions, 'selectedProjectsGraph' | 'ignoredPackages'>): Array<{ rootDir: ProjectRootDir, rootDirRealPath: ProjectRootDirRealPath }> {
let rootDirs = Object.keys(opts.selectedProjectsGraph) as ProjectRootDir[]
if (opts.ignoredPackages != null) {
rootDirs = rootDirs.filter((rootDir) => !opts.ignoredPackages!.has(rootDir))
}
return rootDirs.map((rootDir) => ({ rootDir, rootDirRealPath: opts.selectedProjectsGraph[rootDir].package.rootDirRealPath }))
}