From 2d23e16b1eb6659a3bf9531e5f1848215c0238ad Mon Sep 17 00:00:00 2001 From: Michael Kriese Date: Wed, 19 Jul 2023 07:30:13 +0200 Subject: [PATCH] feat(manager/npm): extract contraints again on post-update (#23131) --- lib/modules/manager/npm/extract/index.spec.ts | 47 +++++++++++++ lib/modules/manager/npm/extract/npm.ts | 2 +- .../manager/npm/post-update/lerna.spec.ts | 69 ++++++++----------- lib/modules/manager/npm/post-update/lerna.ts | 31 ++++++--- .../npm/post-update/node-version.spec.ts | 63 ++++++++++++----- .../manager/npm/post-update/node-version.ts | 31 ++++++--- .../manager/npm/post-update/npm.spec.ts | 32 ++++++--- lib/modules/manager/npm/post-update/npm.ts | 8 ++- lib/modules/manager/npm/post-update/pnpm.ts | 32 ++------- lib/modules/manager/npm/post-update/utils.ts | 39 +++++++++++ .../manager/npm/post-update/yarn.spec.ts | 2 +- lib/modules/manager/npm/post-update/yarn.ts | 9 ++- .../manager/npm/{extract => }/schema.ts | 19 ++++- lib/modules/manager/types.ts | 2 + 14 files changed, 261 insertions(+), 125 deletions(-) create mode 100644 lib/modules/manager/npm/post-update/utils.ts rename lib/modules/manager/npm/{extract => }/schema.ts (59%) diff --git a/lib/modules/manager/npm/extract/index.spec.ts b/lib/modules/manager/npm/extract/index.spec.ts index 8d596855174d19..9d1ebbb87611c3 100644 --- a/lib/modules/manager/npm/extract/index.spec.ts +++ b/lib/modules/manager/npm/extract/index.spec.ts @@ -880,6 +880,53 @@ describe('modules/manager/npm/extract/index', () => { }); }); + describe('.extractAllPackageFiles()', () => { + it('runs', async () => { + fs.readLocalFile.mockResolvedValueOnce(input02Content); + const res = await npmExtract.extractAllPackageFiles( + defaultExtractConfig, + ['package.json'] + ); + expect(res).toEqual([ + { + deps: [ + { + currentValue: '7.0.0', + datasource: 'npm', + depName: '@babel/core', + depType: 'dependencies', + prettyDepType: 'dependency', + }, + { + currentValue: '1.21.0', + datasource: 'npm', + depName: 'config', + depType: 'dependencies', + prettyDepType: 'dependency', + }, + ], + extractedConstraints: {}, + managerData: { + hasPackageManager: false, + lernaClient: undefined, + lernaJsonFile: undefined, + lernaPackages: undefined, + npmLock: undefined, + packageJsonName: 'renovate', + pnpmShrinkwrap: undefined, + workspacesPackages: undefined, + yarnLock: undefined, + yarnZeroInstall: false, + }, + npmrc: undefined, + packageFile: 'package.json', + packageFileVersion: '1.0.0', + skipInstalls: true, + }, + ]); + }); + }); + describe('.postExtract()', () => { it('runs', async () => { await expect(npmExtract.postExtract([])).resolves.not.toThrow(); diff --git a/lib/modules/manager/npm/extract/npm.ts b/lib/modules/manager/npm/extract/npm.ts index 862cabfcbe63ae..db86b5288115eb 100644 --- a/lib/modules/manager/npm/extract/npm.ts +++ b/lib/modules/manager/npm/extract/npm.ts @@ -1,6 +1,6 @@ import { logger } from '../../../../logger'; import { readLocalFile } from '../../../../util/fs'; -import { PackageLock } from './schema'; +import { PackageLock } from '../schema'; import type { LockFile } from './types'; export async function getNpmLock(filePath: string): Promise { diff --git a/lib/modules/manager/npm/post-update/lerna.spec.ts b/lib/modules/manager/npm/post-update/lerna.spec.ts index 96426a0f899ddf..f061b4344f78d1 100644 --- a/lib/modules/manager/npm/post-update/lerna.spec.ts +++ b/lib/modules/manager/npm/post-update/lerna.spec.ts @@ -1,5 +1,5 @@ import { envMock, mockExecAll } from '../../../../../test/exec-util'; -import { env, mockedFunction, partial } from '../../../../../test/util'; +import { env, fs, mockedFunction, partial } from '../../../../../test/util'; import { GlobalConfig } from '../../../../config/global'; import type { RepoGlobalConfig } from '../../../../config/types'; import type { PackageFileContent, PostUpdateConfig } from '../../types'; @@ -7,6 +7,7 @@ import * as lernaHelper from './lerna'; import { getNodeToolConstraint } from './node-version'; jest.mock('../../../../util/exec/env'); +jest.mock('../../../../util/fs'); jest.mock('./node-version'); jest.mock('../../../datasource'); @@ -14,20 +15,11 @@ process.env.CONTAINERBASE = 'true'; function lernaPkgFile(lernaClient: string): Partial { return { - deps: [{ depName: 'lerna', currentValue: '2.0.0' }], managerData: { lernaClient }, }; } -function lernaPkgFileWithoutLernaDep( - lernaClient: string -): Partial { - return { - managerData: { lernaClient }, - }; -} - -const config = partial(); +const config = partial({ constraints: { lerna: '2.0.0' } }); describe('modules/manager/npm/post-update/lerna', () => { const globalConfig: RepoGlobalConfig = { @@ -97,10 +89,13 @@ describe('modules/manager/npm/post-update/lerna', () => { it('generates yarn.lock files', async () => { const execSnapshots = mockExecAll(); + fs.readLocalFile.mockResolvedValueOnce( + '{"packageManager":"yarn@^1.10.0"}' + ); const res = await lernaHelper.generateLockFiles( lernaPkgFile('yarn'), 'some-dir', - { ...config, extractedConstraints: { yarn: '^1.10.0' } }, + { ...config }, {} ); expect(execSnapshots).toMatchSnapshot(); @@ -110,9 +105,9 @@ describe('modules/manager/npm/post-update/lerna', () => { it('defaults to latest and skips bootstrap if lerna version unspecified', async () => { const execSnapshots = mockExecAll(); const res = await lernaHelper.generateLockFiles( - lernaPkgFileWithoutLernaDep('npm'), + lernaPkgFile('npm'), 'some-dir', - config, + { ...config, constraints: null }, {} ); expect(res.error).toBeFalse(); @@ -125,7 +120,7 @@ describe('modules/manager/npm/post-update/lerna', () => { const res = await lernaHelper.generateLockFiles( lernaPkgFile('npm'), 'some-dir', - { ...config, constraints: { npm: '^6.0.0' } }, + { ...config, constraints: { ...config.constraints, npm: '^6.0.0' } }, {} ); expect(res.error).toBeFalse(); @@ -143,7 +138,7 @@ describe('modules/manager/npm/post-update/lerna', () => { const res = await lernaHelper.generateLockFiles( lernaPkgFile('npm'), 'some-dir', - { ...config, constraints: { npm: '6.0.0' } }, + { ...config, constraints: { ...config.constraints, npm: '6.0.0' } }, {} ); expect(execSnapshots).toMatchObject([ @@ -189,7 +184,7 @@ describe('modules/manager/npm/post-update/lerna', () => { const res = await lernaHelper.generateLockFiles( lernaPkgFile('npm'), 'some-dir', - { ...config, constraints: { npm: '6.0.0' } }, + { ...config, constraints: { ...config.constraints, npm: '6.0.0' } }, {} ); expect(res.error).toBeFalse(); @@ -222,42 +217,36 @@ describe('modules/manager/npm/post-update/lerna', () => { describe('getLernaVersion()', () => { it('returns specified version', () => { - const pkg = { - deps: [ - { depName: 'lerna', currentValue: '^2.0.0', currentVersion: '2.0.0' }, - ], - }; - expect(lernaHelper.getLernaVersion(pkg)).toBe('2.0.0'); + const pkg = {}; + expect( + lernaHelper.getLernaVersion(pkg, { engines: { lerna: '2.0.0' } }) + ).toBe('2.0.0'); }); it('returns specified range', () => { - const pkg = { - deps: [ - { depName: 'lerna', currentValue: '1.x || >=2.5.0 || 5.0.0 - 7.2.3' }, - ], - }; - expect(lernaHelper.getLernaVersion(pkg)).toBe( - '1.x || >=2.5.0 || 5.0.0 - 7.2.3' - ); + const pkg = {}; + expect( + lernaHelper.getLernaVersion(pkg, { + engines: { lerna: '1.x || >=2.5.0 || 5.0.0 - 7.2.3' }, + }) + ).toBe('1.x || >=2.5.0 || 5.0.0 - 7.2.3'); }); it('returns latest if no lerna dep is specified', () => { - const pkg = { - deps: [{ depName: 'something-else', currentValue: '1.2.3' }], - }; - expect(lernaHelper.getLernaVersion(pkg)).toBeNull(); + const pkg = {}; + expect(lernaHelper.getLernaVersion(pkg, {})).toBeNull(); }); it('returns latest if pkg has no deps at all', () => { const pkg = {}; - expect(lernaHelper.getLernaVersion(pkg)).toBeNull(); + expect(lernaHelper.getLernaVersion(pkg, {})).toBeNull(); }); it('returns latest if specified lerna version is not a valid semVer range', () => { - const pkg = { - deps: [{ depName: 'lerna', currentValue: '[a.b.c;' }], - }; - expect(lernaHelper.getLernaVersion(pkg)).toBeNull(); + const pkg = {}; + expect( + lernaHelper.getLernaVersion(pkg, { engines: { lerna: '[a.b.c;' } }) + ).toBeNull(); }); }); }); diff --git a/lib/modules/manager/npm/post-update/lerna.ts b/lib/modules/manager/npm/post-update/lerna.ts index b2f5bf21c02796..f65a052d477177 100644 --- a/lib/modules/manager/npm/post-update/lerna.ts +++ b/lib/modules/manager/npm/post-update/lerna.ts @@ -15,16 +15,19 @@ import type { PackageFileContent, PostUpdateConfig, } from '../../types'; +import type { PackageJsonSchema } from '../schema'; import type { NpmManagerData } from '../types'; import { getNodeToolConstraint } from './node-version'; import type { GenerateLockFileResult } from './types'; +import { getPackageManagerVersion, lazyLoadPackageJson } from './utils'; // Exported for testability export function getLernaVersion( - lernaPackageFile: Partial> + lernaPackageFile: Partial>, + lazyPgkJson: PackageJsonSchema ): string | null { - const lernaDep = lernaPackageFile.deps?.find((d) => d.depName === 'lerna'); - if (!lernaDep?.currentValue || !semver.validRange(lernaDep.currentValue)) { + const constraint = getPackageManagerVersion('lerna', lazyPgkJson); + if (!constraint || !semver.validRange(constraint)) { logger.warn( // TODO: types (#7154) // eslint-disable-next-line @typescript-eslint/restrict-template-expressions @@ -32,7 +35,7 @@ export function getLernaVersion( ); return null; } - return lernaDep.currentVersion ?? lernaDep.currentValue; + return constraint; } export async function generateLockFiles( @@ -48,19 +51,22 @@ export async function generateLockFiles( return { error: false }; } logger.debug(`Spawning lerna with ${lernaClient} to create lock files`); - const toolConstraints: ToolConstraint[] = [ - await getNodeToolConstraint(config, [], lockFileDir), - ]; + const cmd: string[] = []; let cmdOptions = ''; try { + const lazyPgkJson = lazyLoadPackageJson(lockFileDir); + const toolConstraints: ToolConstraint[] = [ + await getNodeToolConstraint(config, [], lockFileDir, lazyPgkJson), + ]; if (lernaClient === 'yarn') { const yarnTool: ToolConstraint = { toolName: 'yarn', constraint: '^1.22.18', // needs to be a v1 yarn, otherwise v2 will be installed }; const yarnCompatibility = - config.constraints?.yarn ?? config.extractedConstraints?.yarn; + config.constraints?.yarn ?? + getPackageManagerVersion('yarn', await lazyPgkJson.getValue()); if (semver.validRange(yarnCompatibility)) { yarnTool.constraint = yarnCompatibility; } @@ -73,7 +79,8 @@ export async function generateLockFiles( } else if (lernaClient === 'npm') { const npmTool: ToolConstraint = { toolName: 'npm' }; const npmCompatibility = - config.constraints?.npm ?? config.extractedConstraints?.npm; + config.constraints?.npm ?? + getPackageManagerVersion('npm', await lazyPgkJson.getValue()); if (semver.validRange(npmCompatibility)) { npmTool.constraint = npmCompatibility; } @@ -107,7 +114,9 @@ export async function generateLockFiles( extraEnv.NPM_AUTH = env.NPM_AUTH; extraEnv.NPM_EMAIL = env.NPM_EMAIL; } - const lernaVersion = getLernaVersion(lernaPackageFile); + const lernaVersion = + config.constraints?.lerna ?? + getLernaVersion(lernaPackageFile, await lazyPgkJson.getValue()); if ( !is.string(lernaVersion) || (semver.valid(lernaVersion) && semver.gte(lernaVersion, '7.0.0')) @@ -115,7 +124,7 @@ export async function generateLockFiles( logger.debug('Skipping lerna bootstrap'); cmd.push(`${lernaClient} install ${cmdOptions}`); } else { - logger.debug(`Using lerna version ${String(lernaVersion)}`); + logger.debug(`Using lerna version ${lernaVersion}`); toolConstraints.push({ toolName: 'lerna', constraint: lernaVersion }); cmd.push('lerna info || echo "Ignoring lerna info failure"'); cmd.push(`${lernaClient} install ${cmdOptions}`); diff --git a/lib/modules/manager/npm/post-update/node-version.spec.ts b/lib/modules/manager/npm/post-update/node-version.spec.ts index b8c95ddcb7ec1b..a951be3807fdd7 100644 --- a/lib/modules/manager/npm/post-update/node-version.spec.ts +++ b/lib/modules/manager/npm/post-update/node-version.spec.ts @@ -1,4 +1,5 @@ import { fs } from '../../../../../test/util'; +import { Lazy } from '../../../../util/lazy'; import { getNodeConstraint, getNodeToolConstraint, @@ -14,38 +15,60 @@ describe('modules/manager/npm/post-update/node-version', () => { }; describe('getNodeConstraint()', () => { - it('returns package.json range', async () => { - fs.readLocalFile.mockResolvedValueOnce(null as never); - fs.readLocalFile.mockResolvedValueOnce(null as never); - const res = await getNodeConstraint(config, ''); + it('returns from user constraints', async () => { + const res = await getNodeConstraint( + config, + [], + '', + new Lazy(() => Promise.resolve({})) + ); expect(res).toBe('^12.16.0'); + expect(fs.readLocalFile).not.toHaveBeenCalled(); }); it('returns .node-version value', async () => { - fs.readLocalFile.mockResolvedValueOnce(null as never); + fs.readLocalFile.mockResolvedValueOnce(null); fs.readLocalFile.mockResolvedValueOnce('12.16.1\n'); - const res = await getNodeConstraint(config, ''); + const res = await getNodeConstraint( + {}, + [], + '', + new Lazy(() => Promise.resolve({})) + ); expect(res).toBe('12.16.1'); }); it('returns .nvmrc value', async () => { fs.readLocalFile.mockResolvedValueOnce('12.16.2\n'); - const res = await getNodeConstraint(config, ''); + const res = await getNodeConstraint( + {}, + [], + '', + new Lazy(() => Promise.resolve({})) + ); expect(res).toBe('12.16.2'); }); it('ignores unusable ranges in dotfiles', async () => { fs.readLocalFile.mockResolvedValueOnce('latest'); fs.readLocalFile.mockResolvedValueOnce('lts'); - const res = await getNodeConstraint(config, ''); - expect(res).toBe('^12.16.0'); + const res = await getNodeConstraint( + {}, + [], + '', + new Lazy(() => Promise.resolve({})) + ); + expect(res).toBeNull(); }); - it('returns no constraint', async () => { - fs.readLocalFile.mockResolvedValueOnce(null as never); - fs.readLocalFile.mockResolvedValueOnce(null as never); - const res = await getNodeConstraint({ ...config, constraints: null }, ''); - expect(res).toBeNull(); + it('returns from package.json', async () => { + const res = await getNodeConstraint( + {}, + [], + '', + new Lazy(() => Promise.resolve({ engines: { node: '^12.16.3' } })) + ); + expect(res).toBe('^12.16.3'); }); }); @@ -67,7 +90,8 @@ describe('modules/manager/npm/post-update/node-version', () => { await getNodeToolConstraint( config, [{ depName: 'node', newValue: '16.15.0' }], - '' + '', + new Lazy(() => Promise.resolve({})) ) ).toEqual({ toolName: 'node', @@ -76,7 +100,14 @@ describe('modules/manager/npm/post-update/node-version', () => { }); it('returns getNodeConstraint', async () => { - expect(await getNodeToolConstraint(config, [], '')).toEqual({ + expect( + await getNodeToolConstraint( + config, + [], + '', + new Lazy(() => Promise.resolve({})) + ) + ).toEqual({ toolName: 'node', constraint: '^12.16.0', }); diff --git a/lib/modules/manager/npm/post-update/node-version.ts b/lib/modules/manager/npm/post-update/node-version.ts index 213d0dd615add1..4fb88b4cf3f21d 100644 --- a/lib/modules/manager/npm/post-update/node-version.ts +++ b/lib/modules/manager/npm/post-update/node-version.ts @@ -5,6 +5,7 @@ import type { ToolConstraint } from '../../../../util/exec/types'; import { readLocalFile } from '../../../../util/fs'; import { newlineRegex, regEx } from '../../../../util/regex'; import type { PostUpdateConfig, Upgrade } from '../../types'; +import type { LazyPackageJson } from './utils'; async function getNodeFile(filename: string): Promise { try { @@ -22,11 +23,10 @@ async function getNodeFile(filename: string): Promise { return null; } -function getPackageJsonConstraint( - config: Partial -): string | null { - const constraint: string = - config.constraints?.node ?? config.extractedConstraints?.node; +async function getPackageJsonConstraint( + pkg: LazyPackageJson +): Promise { + const constraint = (await pkg.getValue()).engines?.node; if (constraint && semver.validRange(constraint)) { logger.debug(`Using node constraint "${constraint}" from package.json`); return constraint; @@ -34,15 +34,19 @@ function getPackageJsonConstraint( return null; } +// export only for testing export async function getNodeConstraint( config: Partial, - lockFileDir: string + upgrades: Upgrade[], + lockFileDir: string, + pkg: LazyPackageJson ): Promise { - // TODO: fix types (#7154) const constraint = + getNodeUpdate(upgrades) ?? + config.constraints?.node ?? (await getNodeFile(upath.join(lockFileDir, '.nvmrc'))) ?? (await getNodeFile(upath.join(lockFileDir, '.node-version'))) ?? - getPackageJsonConstraint(config); + (await getPackageJsonConstraint(pkg)); if (!constraint) { logger.debug('No node constraint found - using latest'); } @@ -56,10 +60,15 @@ export function getNodeUpdate(upgrades: Upgrade[]): string | undefined { export async function getNodeToolConstraint( config: Partial, upgrades: Upgrade[], - lockFileDir: string + lockFileDir: string, + pkg: LazyPackageJson ): Promise { - const constraint = - getNodeUpdate(upgrades) ?? (await getNodeConstraint(config, lockFileDir)); + const constraint = await getNodeConstraint( + config, + upgrades, + lockFileDir, + pkg + ); return { toolName: 'node', diff --git a/lib/modules/manager/npm/post-update/npm.spec.ts b/lib/modules/manager/npm/post-update/npm.spec.ts index 833a7ad9b8c3b6..bebc23d7028609 100644 --- a/lib/modules/manager/npm/post-update/npm.spec.ts +++ b/lib/modules/manager/npm/post-update/npm.spec.ts @@ -26,6 +26,8 @@ describe('modules/manager/npm/post-update/npm', () => { it('generates lock files', async () => { const execSnapshots = mockExecAll(); + // package.json + fs.readLocalFile.mockResolvedValueOnce('{}'); fs.readLocalFile.mockResolvedValueOnce('package-lock-contents'); const skipInstalls = true; const postUpdateOptions = ['npmDedupe']; @@ -39,7 +41,7 @@ describe('modules/manager/npm/post-update/npm', () => { { skipInstalls, postUpdateOptions }, updates ); - expect(fs.readLocalFile).toHaveBeenCalledTimes(1); + expect(fs.readLocalFile).toHaveBeenCalledTimes(2); expect(res.error).toBeUndefined(); expect(res.lockFile).toBe('package-lock-contents'); expect(execSnapshots).toMatchSnapshot(); @@ -56,7 +58,7 @@ describe('modules/manager/npm/post-update/npm', () => { 'some-dir', {}, 'package-lock.json', - { skipInstalls }, + { skipInstalls, constraints: { npm: '^6.0.0' } }, updates ); expect(fs.readLocalFile).toHaveBeenCalledTimes(1); @@ -85,7 +87,7 @@ describe('modules/manager/npm/post-update/npm', () => { 'some-dir', {}, 'package-lock.json', - { skipInstalls }, + { skipInstalls, constraints: { npm: '^6.0.0' } }, updates ); expect(fs.readLocalFile).toHaveBeenCalledTimes(1); @@ -103,7 +105,7 @@ describe('modules/manager/npm/post-update/npm', () => { 'some-dir', {}, 'npm-shrinkwrap.json', - { skipInstalls } + { skipInstalls, constraints: { npm: '^6.0.0' } } ); expect(fs.renameLocalFile).toHaveBeenCalledTimes(1); expect(fs.renameLocalFile).toHaveBeenCalledWith( @@ -130,7 +132,7 @@ describe('modules/manager/npm/post-update/npm', () => { 'some-dir', {}, 'npm-shrinkwrap.json', - { skipInstalls } + { skipInstalls, constraints: { npm: '^6.0.0' } } ); expect(fs.renameLocalFile).toHaveBeenCalledTimes(0); expect(fs.readLocalFile).toHaveBeenCalledTimes(1); @@ -153,7 +155,7 @@ describe('modules/manager/npm/post-update/npm', () => { 'some-dir', {}, 'package-lock.json', - { skipInstalls, binarySource } + { skipInstalls, binarySource, constraints: { npm: '^6.0.0' } } ); expect(fs.readLocalFile).toHaveBeenCalledTimes(1); expect(res.error).toBeUndefined(); @@ -170,7 +172,7 @@ describe('modules/manager/npm/post-update/npm', () => { 'some-dir', {}, 'package-lock.json', - { binarySource }, + { binarySource, constraints: { npm: '^6.0.0' } }, [{ isRemediation: true }] ); expect(fs.readLocalFile).toHaveBeenCalledTimes(1); @@ -197,13 +199,15 @@ describe('modules/manager/npm/post-update/npm', () => { it('finds npm globally', async () => { const execSnapshots = mockExecAll(); + // package.json + fs.readLocalFile.mockResolvedValue('{}'); fs.readLocalFile.mockResolvedValue('package-lock-contents'); const res = await npmHelper.generateLockFile( 'some-dir', {}, 'package-lock.json' ); - expect(fs.readLocalFile).toHaveBeenCalledTimes(1); + expect(fs.readLocalFile).toHaveBeenCalledTimes(2); expect(res.lockFile).toBe('package-lock-contents'); // TODO: is that right? expect(execSnapshots).toEqual([]); @@ -226,6 +230,8 @@ describe('modules/manager/npm/post-update/npm', () => { it('performs lock file maintenance', async () => { const execSnapshots = mockExecAll(); + // package.json + fs.readLocalFile.mockResolvedValue('{}'); fs.readLocalFile.mockResolvedValue('package-lock-contents'); const res = await npmHelper.generateLockFile( 'some-dir', @@ -234,7 +240,7 @@ describe('modules/manager/npm/post-update/npm', () => { {}, [{ isLockFileMaintenance: true }] ); - expect(fs.readLocalFile).toHaveBeenCalledTimes(1); + expect(fs.readLocalFile).toHaveBeenCalledTimes(2); expect(fs.deleteLocalFile).toHaveBeenCalledTimes(1); expect(res.lockFile).toBe('package-lock-contents'); expect(execSnapshots).toMatchSnapshot(); @@ -403,6 +409,8 @@ describe('modules/manager/npm/post-update/npm', () => { it('workspace in sub-folder', async () => { const execSnapshots = mockExecAll(); + // package.json + fs.readLocalFile.mockResolvedValue('{}'); fs.readLocalFile.mockResolvedValueOnce('package-lock content'); const skipInstalls = true; const res = await npmHelper.generateLockFile( @@ -412,7 +420,7 @@ describe('modules/manager/npm/post-update/npm', () => { { skipInstalls }, updates ); - expect(fs.readLocalFile).toHaveBeenCalledTimes(1); + expect(fs.readLocalFile).toHaveBeenCalledTimes(2); expect(res.error).toBeUndefined(); expect(execSnapshots).toMatchObject([ { @@ -436,6 +444,8 @@ describe('modules/manager/npm/post-update/npm', () => { }; }); const execSnapshots = mockExecAll(); + // package.json + fs.readLocalFile.mockResolvedValue('{}'); fs.readLocalFile.mockResolvedValueOnce('package-lock content'); const skipInstalls = true; const res = await npmHelper.generateLockFile( @@ -445,7 +455,7 @@ describe('modules/manager/npm/post-update/npm', () => { { skipInstalls }, modifiedUpdates ); - expect(fs.readLocalFile).toHaveBeenCalledTimes(1); + expect(fs.readLocalFile).toHaveBeenCalledTimes(2); expect(res.error).toBeUndefined(); expect(execSnapshots).toMatchObject([ { diff --git a/lib/modules/manager/npm/post-update/npm.ts b/lib/modules/manager/npm/post-update/npm.ts index 499b7c6188d85b..2e417576043236 100644 --- a/lib/modules/manager/npm/post-update/npm.ts +++ b/lib/modules/manager/npm/post-update/npm.ts @@ -25,6 +25,7 @@ import type { PostUpdateConfig, Upgrade } from '../../types'; import { composeLockFile, parseLockFile } from '../utils'; import { getNodeToolConstraint } from './node-version'; import type { GenerateLockFileResult } from './types'; +import { getPackageManagerVersion, lazyLoadPackageJson } from './utils'; export async function generateLockFile( lockFileDir: string, @@ -41,9 +42,12 @@ export async function generateLockFile( let lockFile: string | null = null; try { + const lazyPgkJson = lazyLoadPackageJson(lockFileDir); const npmToolConstraint: ToolConstraint = { toolName: 'npm', - constraint: config.constraints?.npm ?? config.extractedConstraints?.npm, + constraint: + config.constraints?.npm ?? + getPackageManagerVersion('npm', await lazyPgkJson.getValue()), }; const commands: string[] = []; let cmdOptions = ''; @@ -67,7 +71,7 @@ export async function generateLockFile( cwdFile: lockFileName, extraEnv, toolConstraints: [ - await getNodeToolConstraint(config, upgrades, lockFileDir), + await getNodeToolConstraint(config, upgrades, lockFileDir, lazyPgkJson), npmToolConstraint, ], docker: {}, diff --git a/lib/modules/manager/npm/post-update/pnpm.ts b/lib/modules/manager/npm/post-update/pnpm.ts index f244637f0dc3a1..15ff68359615da 100644 --- a/lib/modules/manager/npm/post-update/pnpm.ts +++ b/lib/modules/manager/npm/post-update/pnpm.ts @@ -12,9 +12,9 @@ import type { } from '../../../../util/exec/types'; import { deleteLocalFile, readLocalFile } from '../../../../util/fs'; import type { PostUpdateConfig, Upgrade } from '../../types'; -import type { NpmPackage } from '../extract/types'; import { getNodeToolConstraint } from './node-version'; import type { GenerateLockFileResult, PnpmLockFile } from './types'; +import { getPackageManagerVersion, lazyLoadPackageJson } from './utils'; function getPnpmConstraintFromUpgrades(upgrades: Upgrade[]): string | null { for (const upgrade of upgrades) { @@ -38,12 +38,13 @@ export async function generateLockFile( let stderr: string | undefined; let cmd = 'pnpm'; try { + const lazyPgkJson = lazyLoadPackageJson(lockFileDir); const pnpmToolConstraint: ToolConstraint = { toolName: 'pnpm', constraint: getPnpmConstraintFromUpgrades(upgrades) ?? // if pnpm is being upgraded, it comes first config.constraints?.pnpm ?? // from user config or extraction - (await getPnpmConstraintFromPackageFile(lockFileDir)) ?? // look in package.json > packageManager or engines + getPackageManagerVersion('pnpm', await lazyPgkJson.getValue()) ?? // look in package.json > packageManager or engines (await getConstraintFromLockFile(lockFileName)), // use lockfileVersion to find pnpm version range }; @@ -56,7 +57,7 @@ export async function generateLockFile( extraEnv, docker: {}, toolConstraints: [ - await getNodeToolConstraint(config, upgrades, lockFileDir), + await getNodeToolConstraint(config, upgrades, lockFileDir, lazyPgkJson), pnpmToolConstraint, ], }; @@ -117,31 +118,6 @@ export async function generateLockFile( return { lockFile }; } -export async function getPnpmConstraintFromPackageFile( - lockFileDir: string -): Promise { - let constraint: string | undefined; - const rootPackageJson = upath.join(lockFileDir, 'package.json'); - const content = await readLocalFile(rootPackageJson, 'utf8'); - if (content) { - const packageJson: NpmPackage = JSON.parse(content); - const packageManager = packageJson?.packageManager; - if (packageManager?.includes('@')) { - const nameAndVersion = packageManager.split('@'); - const name = nameAndVersion[0]; - if (name === 'pnpm') { - constraint = nameAndVersion[1]; - } - } else { - const engines = packageJson?.engines; - if (engines) { - constraint = engines['pnpm']; - } - } - } - return constraint; -} - export async function getConstraintFromLockFile( lockFileName: string ): Promise { diff --git a/lib/modules/manager/npm/post-update/utils.ts b/lib/modules/manager/npm/post-update/utils.ts new file mode 100644 index 00000000000000..a52c4658b915c0 --- /dev/null +++ b/lib/modules/manager/npm/post-update/utils.ts @@ -0,0 +1,39 @@ +import upath from 'upath'; +import { readLocalFile } from '../../../../util/fs'; +import { Lazy } from '../../../../util/lazy'; +import { PackageJson, PackageJsonSchema } from '../schema'; + +export function lazyLoadPackageJson( + lockFileDir: string +): Lazy> { + return new Lazy(() => loadPackageJson(lockFileDir)); +} + +export type LazyPackageJson = ReturnType; + +export async function loadPackageJson( + lockFileDir: string +): Promise { + const json = await readLocalFile( + upath.join(lockFileDir, 'package.json'), + 'utf8' + ); + const res = PackageJson.safeParse(json); + if (res.success) { + return res.data; + } + return {}; +} + +export function getPackageManagerVersion( + name: string, + pkg: PackageJsonSchema +): string | null { + if (pkg.packageManager?.name === name) { + return pkg.packageManager.version; + } + if (pkg.engines?.[name]) { + return pkg.engines[name]; + } + return null; +} diff --git a/lib/modules/manager/npm/post-update/yarn.spec.ts b/lib/modules/manager/npm/post-update/yarn.spec.ts index e41cb4b4ca1ff5..b17e525e865d32 100644 --- a/lib/modules/manager/npm/post-update/yarn.spec.ts +++ b/lib/modules/manager/npm/post-update/yarn.spec.ts @@ -350,7 +350,7 @@ describe('modules/manager/npm/post-update/yarn', () => { Fixtures.mock({}); const execSnapshots = mockExecAll(new Error('some-error')); const res = await yarnHelper.generateLockFile('some-dir', {}); - expect(fs.readFile).toHaveBeenCalledTimes(1); + expect(fs.readFile).toHaveBeenCalledTimes(2); expect(res.error).toBeTrue(); expect(res.lockFile).toBeUndefined(); expect(fixSnapshots(execSnapshots)).toMatchSnapshot(); diff --git a/lib/modules/manager/npm/post-update/yarn.ts b/lib/modules/manager/npm/post-update/yarn.ts index 3b894e4c06e618..5652c1f5f90463 100644 --- a/lib/modules/manager/npm/post-update/yarn.ts +++ b/lib/modules/manager/npm/post-update/yarn.ts @@ -27,6 +27,7 @@ import type { PostUpdateConfig, Upgrade } from '../../types'; import type { NpmManagerData } from '../types'; import { getNodeToolConstraint } from './node-version'; import type { GenerateLockFileResult } from './types'; +import { getPackageManagerVersion, lazyLoadPackageJson } from './utils'; export async function checkYarnrc( lockFileDir: string @@ -98,16 +99,18 @@ export async function generateLockFile( logger.debug(`Spawning yarn install to create ${lockFileName}`); let lockFile: string | null = null; try { + const lazyPgkJson = lazyLoadPackageJson(lockFileDir); const toolConstraints: ToolConstraint[] = [ - await getNodeToolConstraint(config, upgrades, lockFileDir), + await getNodeToolConstraint(config, upgrades, lockFileDir, lazyPgkJson), ]; const yarnUpdate = upgrades.find(isYarnUpdate); const yarnCompatibility = yarnUpdate ? yarnUpdate.newValue - : config.constraints?.yarn ?? config.extractedConstraints?.yarn; + : config.constraints?.yarn ?? + getPackageManagerVersion('yarn', await lazyPgkJson.getValue()); const minYarnVersion = semver.validRange(yarnCompatibility) && - semver.minVersion(yarnCompatibility); + semver.minVersion(yarnCompatibility!); const isYarn1 = !minYarnVersion || minYarnVersion.major === 1; const isYarnDedupeAvailable = minYarnVersion && semver.gte(minYarnVersion, '2.2.0'); diff --git a/lib/modules/manager/npm/extract/schema.ts b/lib/modules/manager/npm/schema.ts similarity index 59% rename from lib/modules/manager/npm/extract/schema.ts rename to lib/modules/manager/npm/schema.ts index 96cde78ca64e19..98f50d5e96f154 100644 --- a/lib/modules/manager/npm/extract/schema.ts +++ b/lib/modules/manager/npm/schema.ts @@ -1,5 +1,22 @@ import { z } from 'zod'; -import { Json, LooseRecord } from '../../../../util/schema-utils'; +import { Json, LooseRecord } from '../../../util/schema-utils'; + +export const PackageManagerSchema = z + .string() + .transform((val) => val.split('@')) + .transform(([name, version]) => ({ name, version })); + +export const PackageJsonSchema = z.object({ + engines: LooseRecord(z.string()).optional(), + dependencies: LooseRecord(z.string()).optional(), + devDependencies: LooseRecord(z.string()).optional(), + peerDependencies: LooseRecord(z.string()).optional(), + packageManager: PackageManagerSchema.optional(), +}); + +export type PackageJsonSchema = z.infer; + +export const PackageJson = Json.pipe(PackageJsonSchema); export const PackageLockV3Schema = z.object({ lockfileVersion: z.literal(3), diff --git a/lib/modules/manager/types.ts b/lib/modules/manager/types.ts index de83d46ec6f272..15a6fd97421964 100644 --- a/lib/modules/manager/types.ts +++ b/lib/modules/manager/types.ts @@ -273,6 +273,8 @@ export interface ManagerApi extends ModuleApi { export interface PostUpdateConfig> extends Record, ManagerData { + // TODO: remove null + constraints?: Record | null; updatedPackageFiles?: FileChange[]; postUpdateOptions?: string[]; skipInstalls?: boolean | null;