diff --git a/lib/modules/manager/npm/extract/__snapshots__/monorepo.spec.ts.snap b/lib/modules/manager/npm/extract/__snapshots__/monorepo.spec.ts.snap index 7c1028ec4471797060b18476daab598a717795c1..70e2789482f224259b8f20260cfafc68cab78017 100644 --- a/lib/modules/manager/npm/extract/__snapshots__/monorepo.spec.ts.snap +++ b/lib/modules/manager/npm/extract/__snapshots__/monorepo.spec.ts.snap @@ -42,11 +42,11 @@ exports[`modules/manager/npm/extract/monorepo .extractPackageFile() updates inte ], "managerData": { "hasPackageManager": undefined, - "hasWorkspaces": false, "lernaClient": undefined, "lernaJsonFile": "lerna.json", "npmLock": undefined, "packageJsonName": "@org/a", + "workspacesPackages": undefined, "yarnLock": undefined, "yarnZeroInstall": undefined, }, @@ -57,11 +57,11 @@ exports[`modules/manager/npm/extract/monorepo .extractPackageFile() updates inte { "managerData": { "hasPackageManager": undefined, - "hasWorkspaces": false, "lernaClient": undefined, "lernaJsonFile": "lerna.json", "npmLock": undefined, "packageJsonName": "@org/b", + "workspacesPackages": undefined, "yarnLock": undefined, "yarnZeroInstall": undefined, }, @@ -114,11 +114,11 @@ exports[`modules/manager/npm/extract/monorepo .extractPackageFile() uses lerna p ], "managerData": { "hasPackageManager": undefined, - "hasWorkspaces": false, "lernaClient": undefined, "lernaJsonFile": "lerna.json", "npmLock": undefined, "packageJsonName": "@org/a", + "workspacesPackages": undefined, "yarnLock": undefined, "yarnZeroInstall": undefined, }, @@ -129,11 +129,11 @@ exports[`modules/manager/npm/extract/monorepo .extractPackageFile() uses lerna p { "managerData": { "hasPackageManager": undefined, - "hasWorkspaces": false, "lernaClient": undefined, "lernaJsonFile": "lerna.json", "npmLock": undefined, "packageJsonName": "@org/b", + "workspacesPackages": undefined, "yarnLock": undefined, "yarnZeroInstall": undefined, }, @@ -162,11 +162,13 @@ exports[`modules/manager/npm/extract/monorepo .extractPackageFile() uses yarn wo { "managerData": { "hasPackageManager": undefined, - "hasWorkspaces": true, "lernaClient": "yarn", "lernaJsonFile": "lerna.json", "npmLock": undefined, "packageJsonName": "@org/a", + "workspacesPackages": [ + "packages/*", + ], "yarnLock": undefined, "yarnZeroInstall": undefined, }, @@ -177,11 +179,13 @@ exports[`modules/manager/npm/extract/monorepo .extractPackageFile() uses yarn wo { "managerData": { "hasPackageManager": undefined, - "hasWorkspaces": true, "lernaClient": "yarn", "lernaJsonFile": "lerna.json", "npmLock": undefined, "packageJsonName": "@org/b", + "workspacesPackages": [ + "packages/*", + ], "yarnLock": undefined, "yarnZeroInstall": undefined, }, diff --git a/lib/modules/manager/npm/extract/monorepo.ts b/lib/modules/manager/npm/extract/monorepo.ts index 2f3c2cc3c4a54453b7ba2a1a0bd9bb1d8278ad3a..d296c28af8d8468350650c2a65f6ca867508a76e 100644 --- a/lib/modules/manager/npm/extract/monorepo.ts +++ b/lib/modules/manager/npm/extract/monorepo.ts @@ -59,7 +59,7 @@ export async function detectMonorepos( subPackage.managerData.yarnLock ??= yarnLock; subPackage.managerData.npmLock ??= npmLock; subPackage.skipInstalls = skipInstalls && subPackage.skipInstalls; // skip if both are true - subPackage.managerData.hasWorkspaces = !!workspacesPackages; + subPackage.managerData.workspacesPackages = workspacesPackages; subPackage.npmrc ??= npmrc; if (p.extractedConstraints) { diff --git a/lib/modules/manager/npm/post-update/index.ts b/lib/modules/manager/npm/post-update/index.ts index 9bb9e793f9ee4cd52a4e0185ddbe4b1dce9337ec..5fa20722a8b18bfa07404126c6f80b3e46c12307 100644 --- a/lib/modules/manager/npm/post-update/index.ts +++ b/lib/modules/manager/npm/post-update/index.ts @@ -119,7 +119,7 @@ export function determineLockFileDirs( } else if ( packageFile.managerData?.lernaJsonFile && packageFile.managerData.yarnLock && - !packageFile.managerData.hasWorkspaces + !packageFile.managerData.workspacesPackages?.length ) { lernaJsonFiles.push(packageFile.managerData.lernaJsonFile); } else { diff --git a/lib/modules/manager/npm/post-update/npm.spec.ts b/lib/modules/manager/npm/post-update/npm.spec.ts index 3ecda1373e87a034c10df9d1b9e850883dc6a098..42d4851b9781e686735cf7f14b229b39cf1b7876 100644 --- a/lib/modules/manager/npm/post-update/npm.spec.ts +++ b/lib/modules/manager/npm/post-update/npm.spec.ts @@ -30,7 +30,7 @@ describe('modules/manager/npm/post-update/npm', () => { const skipInstalls = true; const postUpdateOptions = ['npmDedupe']; const updates = [ - { depName: 'some-dep', newVersion: '1.0.1', isLockfileUpdate: false }, + { packageName: 'some-dep', newVersion: '1.0.1', isLockfileUpdate: false }, ]; const res = await npmHelper.generateLockFile( 'some-dir', @@ -50,7 +50,7 @@ describe('modules/manager/npm/post-update/npm', () => { fs.readLocalFile.mockResolvedValueOnce('package-lock-contents'); const skipInstalls = true; const updates = [ - { depName: 'some-dep', newVersion: '1.0.1', isLockfileUpdate: true }, + { packageName: 'some-dep', newVersion: '1.0.1', isLockfileUpdate: true }, ]; const res = await npmHelper.generateLockFile( 'some-dir', @@ -73,11 +73,12 @@ describe('modules/manager/npm/post-update/npm', () => { const skipInstalls = true; const updates = [ { - depName: 'postcss', + packageName: 'postcss', depType: 'dependencies', newVersion: '8.4.8', newValue: '^8.0.0', isLockfileUpdate: true, + managerData: {}, // intentional: edge-case test for workspaces }, ]; const res = await npmHelper.generateLockFile( @@ -307,4 +308,261 @@ describe('modules/manager/npm/post-update/npm', () => { }, ]); }); + + describe('installs workspace only packages separately', () => { + const updates = [ + { + packageFile: 'some-dir/docs/a/package.json', + packageName: 'abbrev', + depType: 'dependencies', + newVersion: '1.1.0', + newValue: '^1.0.0', + isLockfileUpdate: true, + managerData: { + workspacesPackages: ['docs/*', 'web/*'], + }, + }, + { + packageFile: 'some-dir/web/b/package.json', + packageName: 'xmldoc', + depType: 'dependencies', + newVersion: '2.2.0', + newValue: '^2.0.0', + isLockfileUpdate: true, + managerData: { + workspacesPackages: ['docs/*', 'web/*'], + }, + }, + { + packageFile: 'some-dir/docs/a/package.json', + packageName: 'postcss', + depType: 'dependencies', + newVersion: '8.4.8', + newValue: '^8.0.0', + isLockfileUpdate: true, + managerData: { + workspacesPackages: ['docs/*', 'web/*'], + }, + }, + { + packageFile: 'some-dir/package.json', + packageName: 'chalk', + depType: 'dependencies', + newVersion: '9.4.8', + newValue: '^9.0.0', + isLockfileUpdate: true, + managerData: { + workspacesPackages: ['docs/*', 'web/*'], + }, + }, + { + packageFile: 'some-dir/web/b/package.json', + packageName: 'postcss', + depType: 'dependencies', + newVersion: '8.4.8', + newValue: '^8.0.0', + isLockfileUpdate: true, + managerData: { + workspacesPackages: ['docs/*', 'web/*'], + }, + }, + { + packageFile: 'some-dir/package.json', + packageName: 'postcss', + depType: 'dependencies', + newVersion: '8.4.8', + newValue: '^8.0.0', + isLockfileUpdate: true, + managerData: { + workspacesPackages: ['docs/*', 'web/*'], + }, + }, + { + packageFile: 'some-dir/web/b/package.json', + packageName: 'hello', + depType: 'dependencies', + newVersion: '1.1.1', + newValue: '^1.0.0', + isLockfileUpdate: true, + managerData: { + workspacesPackages: ['docs/*', 'web/*'], + }, + }, + { + packageFile: 'some-dir/docs/a/package.json', + packageName: 'hello', + depType: 'dependencies', + newVersion: '1.1.1', + newValue: '^1.0.0', + isLockfileUpdate: true, + managerData: { + workspacesPackages: ['docs/*', 'web/*'], + }, + }, + ]; + + it('workspace in sub-folder', async () => { + const execSnapshots = mockExecAll(); + fs.readLocalFile.mockResolvedValueOnce('package-lock content'); + const skipInstalls = true; + const res = await npmHelper.generateLockFile( + 'some-dir', + {}, + 'package-lock.json', + { skipInstalls }, + updates + ); + expect(fs.readLocalFile).toHaveBeenCalledTimes(1); + expect(res.error).toBeUndefined(); + expect(execSnapshots).toMatchObject([ + { + cmd: 'npm install --package-lock-only --no-audit --ignore-scripts --workspace=docs/a abbrev@1.1.0 hello@1.1.1', + }, + { + cmd: 'npm install --package-lock-only --no-audit --ignore-scripts --workspace=web/b xmldoc@2.2.0 hello@1.1.1', + }, + + { + cmd: 'npm install --package-lock-only --no-audit --ignore-scripts chalk@9.4.8 postcss@8.4.8', + }, + ]); + }); + + it('workspace in root folder', async () => { + const modifiedUpdates = updates.map((update) => { + return { + ...update, + packageFile: update.packageFile.replace('some-dir/', ''), + }; + }); + const execSnapshots = mockExecAll(); + fs.readLocalFile.mockResolvedValueOnce('package-lock content'); + const skipInstalls = true; + const res = await npmHelper.generateLockFile( + '.', + {}, + 'package-lock.json', + { skipInstalls }, + modifiedUpdates + ); + expect(fs.readLocalFile).toHaveBeenCalledTimes(1); + expect(res.error).toBeUndefined(); + expect(execSnapshots).toMatchObject([ + { + cmd: 'npm install --package-lock-only --no-audit --ignore-scripts --workspace=docs/a abbrev@1.1.0 hello@1.1.1', + }, + { + cmd: 'npm install --package-lock-only --no-audit --ignore-scripts --workspace=web/b xmldoc@2.2.0 hello@1.1.1', + }, + + { + cmd: 'npm install --package-lock-only --no-audit --ignore-scripts chalk@9.4.8 postcss@8.4.8', + }, + ]); + expect( + npmHelper.divideWorkspaceAndRootDeps('.', modifiedUpdates) + ).toMatchObject({ + lockRootUpdates: [ + { + packageFile: 'package.json', + packageName: 'chalk', + depType: 'dependencies', + newVersion: '9.4.8', + newValue: '^9.0.0', + isLockfileUpdate: true, + managerData: { + workspacesPackages: ['docs/*', 'web/*'], + }, + }, + { + packageFile: 'package.json', + packageName: 'postcss', + depType: 'dependencies', + newVersion: '8.4.8', + newValue: '^8.0.0', + isLockfileUpdate: true, + managerData: { + workspacesPackages: ['docs/*', 'web/*'], + }, + }, + ], + lockWorkspacesUpdates: [ + { + packageFile: 'docs/a/package.json', + packageName: 'abbrev', + depType: 'dependencies', + newVersion: '1.1.0', + newValue: '^1.0.0', + isLockfileUpdate: true, + managerData: { + workspacesPackages: ['docs/*', 'web/*'], + }, + workspace: 'docs/a', + }, + { + packageFile: 'web/b/package.json', + packageName: 'xmldoc', + depType: 'dependencies', + newVersion: '2.2.0', + newValue: '^2.0.0', + isLockfileUpdate: true, + managerData: { + workspacesPackages: ['docs/*', 'web/*'], + }, + workspace: 'web/b', + }, + { + packageFile: 'docs/a/package.json', + packageName: 'postcss', + depType: 'dependencies', + newVersion: '8.4.8', + newValue: '^8.0.0', + isLockfileUpdate: true, + managerData: { + workspacesPackages: ['docs/*', 'web/*'], + }, + workspace: 'docs/a', + }, + { + packageFile: 'web/b/package.json', + packageName: 'postcss', + depType: 'dependencies', + newVersion: '8.4.8', + newValue: '^8.0.0', + isLockfileUpdate: true, + managerData: { + workspacesPackages: ['docs/*', 'web/*'], + }, + workspace: 'web/b', + }, + { + packageFile: 'web/b/package.json', + packageName: 'hello', + depType: 'dependencies', + newVersion: '1.1.1', + newValue: '^1.0.0', + isLockfileUpdate: true, + managerData: { + workspacesPackages: ['docs/*', 'web/*'], + }, + workspace: 'web/b', + }, + { + packageFile: 'docs/a/package.json', + packageName: 'hello', + depType: 'dependencies', + newVersion: '1.1.1', + newValue: '^1.0.0', + isLockfileUpdate: true, + managerData: { + workspacesPackages: ['docs/*', 'web/*'], + }, + workspace: 'docs/a', + }, + ], + workspaces: new Set(['docs/a', 'web/b']), + rootDeps: new Set(['chalk@9.4.8', 'postcss@8.4.8']), + }); + }); + }); }); diff --git a/lib/modules/manager/npm/post-update/npm.ts b/lib/modules/manager/npm/post-update/npm.ts index f53bf63a0bea34b89dfd444fde375e8acba8911e..fa41fdad02601a577d56a9f40ce2ed50184ec2bd 100644 --- a/lib/modules/manager/npm/post-update/npm.ts +++ b/lib/modules/manager/npm/post-update/npm.ts @@ -1,3 +1,6 @@ +// TODO: types (#7154) +import is from '@sindresorhus/is'; +import minimatch from 'minimatch'; import upath from 'upath'; import { GlobalConfig } from '../../../../config/global'; import { @@ -17,6 +20,7 @@ import { readLocalFile, renameLocalFile, } from '../../../../util/fs'; +import { trimSlashes } from '../../../../util/url'; import type { PostUpdateConfig, Upgrade } from '../../types'; import { composeLockFile, parseLockFile } from '../utils'; import { getNodeToolConstraint } from './node-version'; @@ -81,15 +85,35 @@ export async function generateLockFile( // rangeStrategy = update-lockfile const lockUpdates = upgrades.filter((upgrade) => upgrade.isLockfileUpdate); - if (lockUpdates.length) { + + // divide the deps in two categories: workspace and root + const { lockRootUpdates, lockWorkspacesUpdates, workspaces, rootDeps } = + divideWorkspaceAndRootDeps(lockFileDir, lockUpdates); + + if (workspaces.size && lockWorkspacesUpdates.length) { + logger.debug('Performing lockfileUpdate (npm-workspaces)'); + for (const workspace of workspaces) { + const currentWorkspaceUpdates = lockWorkspacesUpdates + .filter((update) => update.workspace === workspace) + .map((update) => update.managerData?.packageKey) + .filter((packageKey) => !rootDeps.has(packageKey)); + + if (currentWorkspaceUpdates.length) { + const updateCmd = `npm install ${cmdOptions} --workspace=${workspace} ${currentWorkspaceUpdates.join( + ' ' + )}`; + commands.push(updateCmd); + } + } + } + + if (lockRootUpdates.length) { logger.debug('Performing lockfileUpdate (npm)'); const updateCmd = - `npm install ${cmdOptions}` + - lockUpdates - // TODO: types (#7154) - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - .map((update) => ` ${update.depName}@${update.newVersion}`) - .join(''); + `npm install ${cmdOptions} ` + + lockRootUpdates + .map((update) => update.managerData?.packageKey) + .join(' '); commands.push(updateCmd); } @@ -150,8 +174,10 @@ export async function generateLockFile( | 'optionalDependencies'; // TODO #7154 - if (lockFileParsed.packages?.['']?.[depType]?.[lockUpdate.depName!]) { - lockFileParsed.packages[''][depType]![lockUpdate.depName!] = + if ( + lockFileParsed.packages?.['']?.[depType]?.[lockUpdate.packageName!] + ) { + lockFileParsed.packages[''][depType]![lockUpdate.packageName!] = lockUpdate.newValue!; } }); @@ -176,3 +202,69 @@ export async function generateLockFile( } return { lockFile }; } + +export function divideWorkspaceAndRootDeps( + lockFileDir: string, + lockUpdates: Upgrade[] +): { + lockRootUpdates: Upgrade[]; + lockWorkspacesUpdates: Upgrade[]; + workspaces: Set<string>; + rootDeps: Set<string>; +} { + const lockRootUpdates: Upgrade[] = []; // stores all upgrades which are present in root package.json + const lockWorkspacesUpdates: Upgrade[] = []; // stores all upgrades which are present in workspaces package.json + const workspaces = new Set<string>(); // name of all workspaces + const rootDeps = new Set<string>(); // packageName of all upgrades in root package.json (makes it check duplicate deps in root) + + // divide the deps in two categories: workspace and root + for (const upgrade of lockUpdates) { + upgrade.managerData ??= {}; + upgrade.managerData.packageKey = generatePackageKey( + upgrade.packageName!, + upgrade.newVersion! + ); + if ( + upgrade.managerData.workspacesPackages?.length && + is.string(upgrade.packageFile) + ) { + const workspacePatterns = upgrade.managerData.workspacesPackages; // glob pattern or directory name/path + const packageFileDir = trimSlashes( + upgrade.packageFile.replace('package.json', '') + ); + + // workspaceDir = packageFileDir - lockFileDir + const workspaceDir = trimSlashes(packageFileDir.replace(lockFileDir, '')); + + if (is.nonEmptyString(workspaceDir)) { + let workspaceName: string | undefined; + // compare workspaceDir to workspace patterns + // stop when the first match is found and + // add workspaceDir to workspaces set and upgrade object + for (const workspacePattern of workspacePatterns ?? []) { + if (minimatch(workspaceDir, workspacePattern)) { + workspaceName = workspaceDir; + break; + } + } + if ( + workspaceName && + !rootDeps.has(upgrade.managerData.packageKey) // prevent same dep from existing in root and workspace + ) { + workspaces.add(workspaceName); + upgrade.workspace = workspaceName; + lockWorkspacesUpdates.push(upgrade); + } + continue; + } + } + lockRootUpdates.push(upgrade); + rootDeps.add(upgrade.managerData.packageKey); + } + + return { lockRootUpdates, lockWorkspacesUpdates, workspaces, rootDeps }; +} + +function generatePackageKey(packageName: string, version: string): string { + return `${packageName}@${version}`; +} diff --git a/lib/modules/manager/npm/types.ts b/lib/modules/manager/npm/types.ts index 9fa430cd8d74307724705801a0de42adb674832d..526afafbf17382550f53358f8c14acb92c29b797 100644 --- a/lib/modules/manager/npm/types.ts +++ b/lib/modules/manager/npm/types.ts @@ -81,7 +81,6 @@ export interface NpmLockFiles { export interface NpmManagerData extends NpmLockFiles, Record<string, any> { hasPackageManager?: boolean; - hasWorkspaces?: boolean; lernaClient?: string; lernaJsonFile?: string; lernaPackages?: string[]; diff --git a/lib/modules/manager/types.ts b/lib/modules/manager/types.ts index c81b729d1c8470c5a09e2a22a3419d711339df61..6e26451238eff879ac66fd878d5eb5670212be88 100644 --- a/lib/modules/manager/types.ts +++ b/lib/modules/manager/types.ts @@ -155,6 +155,7 @@ export interface PackageDependency<T = Record<string, any>> } export interface Upgrade<T = Record<string, any>> extends PackageDependency<T> { + workspace?: string; isLockfileUpdate?: boolean; currentRawValue?: any; depGroup?: string; diff --git a/lib/util/url.spec.ts b/lib/util/url.spec.ts index 27652dc9c515f648b06ab308e9ffea5781c8a76e..b9a5b723a5be62a2e8bcec35a93ddbe5b0821c81 100644 --- a/lib/util/url.spec.ts +++ b/lib/util/url.spec.ts @@ -8,6 +8,7 @@ import { parseUrl, replaceUrlPath, resolveBaseUrl, + trimSlashes, trimTrailingSlash, validateUrl, } from './url'; @@ -122,6 +123,17 @@ describe('util/url', () => { expect(trimTrailingSlash('foo//////')).toBe('foo'); }); + it('trimSlashes', () => { + expect(trimSlashes('foo')).toBe('foo'); + expect(trimSlashes('/foo')).toBe('foo'); + expect(trimSlashes('foo/')).toBe('foo'); + expect(trimSlashes('//////foo//////')).toBe('foo'); + expect(trimSlashes('foo/bar')).toBe('foo/bar'); + expect(trimSlashes('/foo/bar')).toBe('foo/bar'); + expect(trimSlashes('foo/bar/')).toBe('foo/bar'); + expect(trimSlashes('/foo/bar/')).toBe('foo/bar'); + }); + it('ensureTrailingSlash', () => { expect(ensureTrailingSlash('')).toBe('/'); expect(ensureTrailingSlash('/')).toBe('/'); diff --git a/lib/util/url.ts b/lib/util/url.ts index 7d4c1551d819d2a6b676d1c3c87ecd4784497c7d..a082ba8aebdb87e1e19e3e7c975ef961df180527 100644 --- a/lib/util/url.ts +++ b/lib/util/url.ts @@ -30,6 +30,10 @@ export function trimLeadingSlash(path: string): string { return path.replace(/^\/+/, ''); } +export function trimSlashes(path: string): string { + return trimLeadingSlash(trimTrailingSlash(path)); +} + /** * Resolves an input path against a base URL *