From 0db2d515f1fa5cd4dff18c377d8a8c623fda1ef7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leosvel=20P=C3=A9rez=20Espinosa?= Date: Thu, 30 Apr 2026 12:55:39 +0200 Subject: [PATCH 01/13] feat(core): introduce --mode flag in nx migrate --- .../command-line/migrate/command-object.ts | 6 + .../src/command-line/migrate/migrate.spec.ts | 152 ++++++++++++++++++ .../nx/src/command-line/migrate/migrate.ts | 66 +++++++- 3 files changed, 223 insertions(+), 1 deletion(-) diff --git a/packages/nx/src/command-line/migrate/command-object.ts b/packages/nx/src/command-line/migrate/command-object.ts index 0f38700527699..472cc915eb1a0 100644 --- a/packages/nx/src/command-line/migrate/command-object.ts +++ b/packages/nx/src/command-line/migrate/command-object.ts @@ -84,6 +84,12 @@ function withMigrationOptions(yargs: Argv) { type: 'boolean', default: false, }) + .option('mode', { + describe: + "Restrict which packages to migrate. 'first-party' processes only the target package and the packages in its nx.packageGroup; 'all' processes everything.", + type: 'string', + choices: ['first-party', 'all'], + }) .check( ({ createCommits, commitPrefix, from, excludeAppliedMigrations }) => { if (!createCommits && commitPrefix !== defaultCommitPrefix) { diff --git a/packages/nx/src/command-line/migrate/migrate.spec.ts b/packages/nx/src/command-line/migrate/migrate.spec.ts index 707ce8f36836c..1857bcb10af00 100644 --- a/packages/nx/src/command-line/migrate/migrate.spec.ts +++ b/packages/nx/src/command-line/migrate/migrate.spec.ts @@ -1011,6 +1011,147 @@ describe('Migration', () => { }); }); + describe('--mode', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should keep first-party packages and drop third-party in mixed entries', async () => { + const migrator = new Migrator({ + packageJson: createPackageJson({ + dependencies: { + firstPartyChild: '1.0.0', + thirdPartyChild: '1.0.0', + }, + }), + getInstalledPackageVersion: () => '1.0.0', + fetch: (p) => { + if (p === 'mypackage') { + return Promise.resolve({ + version: '2.0.0', + packageJsonUpdates: { + mixed: { + version: '2.0.0', + packages: { + firstPartyChild: { version: '3.0.0' }, + thirdPartyChild: { version: '3.0.0' }, + }, + }, + }, + }); + } else if (p === 'firstPartyChild') { + return Promise.resolve({ version: '3.0.0' }); + } + return Promise.resolve(null); + }, + from: {}, + to: {}, + mode: 'first-party', + firstPartyPackages: new Set(['mypackage', 'firstPartyChild']), + }); + + const result = await migrator.migrate('mypackage', '2.0.0'); + + expect(result.packageUpdates).toEqual({ + mypackage: { version: '2.0.0', addToPackageJson: false }, + firstPartyChild: { version: '3.0.0', addToPackageJson: false }, + }); + expect(result.packageUpdates.thirdPartyChild).toBeUndefined(); + }); + + it('should drop entries that contain only third-party packages without firing their x-prompt', async () => { + mockPrompt.mockReturnValue(Promise.resolve({ shouldApply: true })); + const migrator = new Migrator({ + packageJson: createPackageJson({ + dependencies: { + thirdPartyA: '1.0.0', + thirdPartyB: '1.0.0', + }, + }), + getInstalledPackageVersion: () => '1.0.0', + fetch: (p) => { + if (p === 'mypackage') { + return Promise.resolve({ + version: '2.0.0', + packageJsonUpdates: { + thirdPartyOnly: { + version: '2.0.0', + 'x-prompt': 'Update third-party packages?', + packages: { + thirdPartyA: { version: '3.0.0' }, + thirdPartyB: { version: '3.0.0' }, + }, + }, + }, + }); + } + return Promise.resolve(null); + }, + from: {}, + to: {}, + interactive: true, + mode: 'first-party', + firstPartyPackages: new Set(['mypackage']), + }); + + const result = await migrator.migrate('mypackage', '2.0.0'); + + expect(result.packageUpdates).toEqual({ + mypackage: { version: '2.0.0', addToPackageJson: false }, + }); + expect(mockPrompt).not.toHaveBeenCalled(); + }); + + it('should source first-party gate from the provided set, not getNxPackageGroup', async () => { + // Sanity: a name commonly returned by getNxPackageGroup() that we + // deliberately exclude from the first-party set should be filtered out, + // and an arbitrary unrelated name that we include should be kept. + const migrator = new Migrator({ + packageJson: createPackageJson({ + dependencies: { + '@nx/react': '1.0.0', + 'not-in-nx-package-group': '1.0.0', + }, + }), + getInstalledPackageVersion: () => '1.0.0', + fetch: (p) => { + if (p === 'nx') { + return Promise.resolve({ + version: '2.0.0', + packageJsonUpdates: { + group: { + version: '2.0.0', + packages: { + '@nx/react': { version: '2.0.0' }, + 'not-in-nx-package-group': { version: '2.0.0' }, + }, + }, + }, + }); + } else if (p === 'not-in-nx-package-group') { + return Promise.resolve({ version: '2.0.0' }); + } + return Promise.resolve(null); + }, + from: {}, + to: {}, + mode: 'first-party', + firstPartyPackages: new Set(['nx', 'not-in-nx-package-group']), + }); + + const result = await migrator.migrate('nx', '2.0.0'); + + expect(result.packageUpdates).toEqual({ + nx: { version: '2.0.0', addToPackageJson: false }, + 'not-in-nx-package-group': { + version: '2.0.0', + addToPackageJson: false, + }, + }); + expect(result.packageUpdates['@nx/react']).toBeUndefined(); + }); + }); + describe('requirements', () => { beforeEach(() => { jest.clearAllMocks(); @@ -1971,6 +2112,17 @@ describe('Migration', () => { ).rejects.toThrow(`Incorrect 'to' section. Use --to="package@version"`); }); + it('should reject --mode combined with --run-migrations', async () => { + await expect(() => + parseMigrationsOptions({ + runMigrations: 'migrations.json', + mode: 'first-party', + }) + ).rejects.toThrow( + `Error: '--mode' cannot be combined with '--run-migrations'.` + ); + }); + it('should handle backslashes in package names', async () => { jest .spyOn(packageMgrUtils, 'resolvePackageVersionUsingRegistry') diff --git a/packages/nx/src/command-line/migrate/migrate.ts b/packages/nx/src/command-line/migrate/migrate.ts index 6022f09254072..9cfee437cc333 100644 --- a/packages/nx/src/command-line/migrate/migrate.ts +++ b/packages/nx/src/command-line/migrate/migrate.ts @@ -202,6 +202,14 @@ export interface MigratorOptions { to: { [pkg: string]: string }; interactive?: boolean; excludeAppliedMigrations?: boolean; + /** + * Restricts `packageJsonUpdates` filtering based on the value: + * - 'first-party' keeps only packages in `firstPartyPackages` + * - 'all' / undefined keeps all packages (no filtering) + */ + mode?: 'first-party' | 'all'; + /** First-party package names used by `mode` for filtering. */ + firstPartyPackages?: ReadonlySet; } export class Migrator { @@ -212,6 +220,8 @@ export class Migrator { private readonly to: MigratorOptions['to']; private readonly interactive: MigratorOptions['interactive']; private readonly excludeAppliedMigrations: MigratorOptions['excludeAppliedMigrations']; + private readonly mode: MigratorOptions['mode']; + private readonly firstPartyPackages: MigratorOptions['firstPartyPackages']; private readonly packageUpdates: Record = {}; private readonly collectedVersions: Record = {}; private readonly promptAnswers: Record = {}; @@ -227,6 +237,8 @@ export class Migrator { this.to = opts.to; this.interactive = opts.interactive; this.excludeAppliedMigrations = opts.excludeAppliedMigrations; + this.mode = opts.mode; + this.firstPartyPackages = opts.firstPartyPackages; } private async fetchMigrationConfig( @@ -563,6 +575,9 @@ export class Migrator { for (const [packageName, packageUpdate] of Object.entries( packageJsonUpdate.packages )) { + if (this.shouldExcludePackage(packageName)) { + continue; + } if ( this.shouldApplyPackageUpdate( packageUpdate, @@ -595,6 +610,16 @@ export class Migrator { return filteredPackageJsonUpdates; } + private shouldExcludePackage(packageName: string): boolean { + if (!this.firstPartyPackages) { + return false; + } + if (this.mode === 'first-party') { + return !this.firstPartyPackages.has(packageName); + } + return false; + } + private shouldApplyPackageUpdate( packageUpdate: PackageUpdate, packageName: string, @@ -832,6 +857,17 @@ const LEGACY_NRWL_PACKAGE_GROUP: ArrayPackageGroup = [ { package: '@nrwl/tao', version: '*' }, ]; +function resolveFirstPartyPackages( + targetPackage: string, + packageGroup: ArrayPackageGroup | undefined +): ReadonlySet { + const set = new Set([targetPackage]); + for (const { package: name } of packageGroup ?? []) { + set.add(name); + } + return set; +} + async function normalizeVersionWithTagCheck( pkg: string, version: string @@ -941,6 +977,7 @@ type GenerateMigrations = { to: { [k: string]: string }; interactive?: boolean; excludeAppliedMigrations?: boolean; + mode?: 'first-party' | 'all'; }; type RunMigrations = { @@ -956,6 +993,12 @@ export async function parseMigrationsOptions(options: { options.runMigrations = 'migrations.json'; } + if (options.mode && options.runMigrations) { + throw new Error( + `Error: '--mode' cannot be combined with '--run-migrations'.` + ); + } + if (!options.runMigrations) { const [from, to] = await Promise.all([ options.from @@ -976,6 +1019,7 @@ export async function parseMigrationsOptions(options: { to, interactive: options.interactive, excludeAppliedMigrations: options.excludeAppliedMigrations, + mode: options.mode, }; } else { return { @@ -1553,15 +1597,35 @@ async function generateMigrationsJsonAndUpdatePackageJson( logger.info(`Fetching meta data about packages.`); logger.info(`It may take a few minutes.`); + const fetch = createFetcher(); + let firstPartyPackages: ReadonlySet | undefined; + if (opts.mode === 'first-party') { + // `@nx/workspace` is version-synced with `nx` and declares an + // intentionally narrow `packageGroup` ({ nx, nx-cloud }) via its + // `ng-update` field, whereas `nx` declares the full @nx/* plugin + // fan-out. Their transitive first-party closures are equivalent, so + // when `@nx/workspace` is the target we source the set from `nx` + // directly to capture the full plugin set. + const sourcePackage = + opts.targetPackage === '@nx/workspace' ? 'nx' : opts.targetPackage; + const rootMetadata = await fetch(sourcePackage, opts.targetVersion); + firstPartyPackages = resolveFirstPartyPackages( + sourcePackage, + rootMetadata.packageGroup + ); + } + const migrator = new Migrator({ packageJson: originalPackageJson, nxInstallation: originalNxJson.installation, getInstalledPackageVersion: createInstalledPackageVersionsResolver(root), - fetch: createFetcher(), + fetch, from: opts.from, to: opts.to, interactive: opts.interactive && !isCI(), excludeAppliedMigrations: opts.excludeAppliedMigrations, + mode: opts.mode, + firstPartyPackages, }); const { migrations, packageUpdates, minVersionWithSkippedUpdates } = From a949b12a659b263bb49c5a4a76501f8b646d09ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leosvel=20P=C3=A9rez=20Espinosa?= Date: Thu, 30 Apr 2026 13:57:42 +0200 Subject: [PATCH 02/13] feat(core): prompt for --mode when not provided in nx migrate --- .../src/command-line/migrate/migrate.spec.ts | 69 +++++++++++++++++++ .../nx/src/command-line/migrate/migrate.ts | 27 +++++++- 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/packages/nx/src/command-line/migrate/migrate.spec.ts b/packages/nx/src/command-line/migrate/migrate.spec.ts index 1857bcb10af00..39f7efefefb86 100644 --- a/packages/nx/src/command-line/migrate/migrate.spec.ts +++ b/packages/nx/src/command-line/migrate/migrate.spec.ts @@ -15,6 +15,7 @@ import { normalizeVersion, parseMigrationsOptions, ResolvedMigrationConfiguration, + resolveMode, } from './migrate'; const createPackageJson = ( @@ -2248,6 +2249,74 @@ describe('Migration', () => { }); }); + describe('resolveMode', () => { + let originalCi: string | undefined; + let originalTty: boolean | undefined; + + beforeEach(() => { + originalCi = process.env.CI; + originalTty = process.stdin.isTTY; + jest.clearAllMocks(); + }); + + afterEach(() => { + if (originalCi === undefined) { + delete process.env.CI; + } else { + process.env.CI = originalCi; + } + Object.defineProperty(process.stdin, 'isTTY', { + value: originalTty, + configurable: true, + }); + }); + + it('should return the provided mode without prompting', async () => { + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + }); + process.env.CI = 'false'; + const result = await resolveMode('first-party'); + expect(result).toBe('first-party'); + expect(mockPrompt).not.toHaveBeenCalled(); + }); + + it('should default to "all" without prompting in non-TTY environments', async () => { + Object.defineProperty(process.stdin, 'isTTY', { + value: false, + configurable: true, + }); + process.env.CI = 'false'; + const result = await resolveMode(undefined); + expect(result).toBe('all'); + expect(mockPrompt).not.toHaveBeenCalled(); + }); + + it('should default to "all" without prompting when running in CI', async () => { + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + }); + process.env.CI = 'true'; + const result = await resolveMode(undefined); + expect(result).toBe('all'); + expect(mockPrompt).not.toHaveBeenCalled(); + }); + + it('should prompt and return the selection in interactive TTY', async () => { + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + }); + process.env.CI = 'false'; + mockPrompt.mockReturnValueOnce(Promise.resolve({ mode: 'first-party' })); + const result = await resolveMode(undefined); + expect(result).toBe('first-party'); + expect(mockPrompt).toHaveBeenCalled(); + }); + }); + describe('isNpmPeerDepsError', () => { it('should detect the npm 7-9 ERESOLVE code line', () => { const stderr = [ diff --git a/packages/nx/src/command-line/migrate/migrate.ts b/packages/nx/src/command-line/migrate/migrate.ts index 9cfee437cc333..65fbe99462dec 100644 --- a/packages/nx/src/command-line/migrate/migrate.ts +++ b/packages/nx/src/command-line/migrate/migrate.ts @@ -868,6 +868,27 @@ function resolveFirstPartyPackages( return set; } +export async function resolveMode( + mode: 'first-party' | 'all' | undefined +): Promise<'first-party' | 'all'> { + if (mode) { + return mode; + } + if (!process.stdin.isTTY || isCI()) { + return 'all'; + } + const { mode: selected } = await prompt<{ mode: 'first-party' | 'all' }>({ + type: 'select', + name: 'mode', + message: 'Which packages would you like to migrate?', + choices: [ + { name: 'first-party', message: 'First-party only' }, + { name: 'all', message: 'All' }, + ], + }); + return selected; +} + async function normalizeVersionWithTagCheck( pkg: string, version: string @@ -1594,12 +1615,14 @@ async function generateMigrationsJsonAndUpdatePackageJson( originalNxJson.installation?.version ?? readNxVersion(originalPackageJson, root); + const mode = await resolveMode(opts.mode); + logger.info(`Fetching meta data about packages.`); logger.info(`It may take a few minutes.`); const fetch = createFetcher(); let firstPartyPackages: ReadonlySet | undefined; - if (opts.mode === 'first-party') { + if (mode === 'first-party') { // `@nx/workspace` is version-synced with `nx` and declares an // intentionally narrow `packageGroup` ({ nx, nx-cloud }) via its // `ng-update` field, whereas `nx` declares the full @nx/* plugin @@ -1624,7 +1647,7 @@ async function generateMigrationsJsonAndUpdatePackageJson( to: opts.to, interactive: opts.interactive && !isCI(), excludeAppliedMigrations: opts.excludeAppliedMigrations, - mode: opts.mode, + mode, firstPartyPackages, }); From be9a3d10c78e6d211c46177f0efb5ef80d00226c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leosvel=20P=C3=A9rez=20Espinosa?= Date: Thu, 30 Apr 2026 16:02:41 +0200 Subject: [PATCH 03/13] feat(core): gate --mode to nx-equivalent targets in nx migrate --- .../src/command-line/migrate/migrate.spec.ts | 41 +++++++++++++++++-- .../nx/src/command-line/migrate/migrate.ts | 39 ++++++++++++++++-- 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/packages/nx/src/command-line/migrate/migrate.spec.ts b/packages/nx/src/command-line/migrate/migrate.spec.ts index 39f7efefefb86..c1c44308358a1 100644 --- a/packages/nx/src/command-line/migrate/migrate.spec.ts +++ b/packages/nx/src/command-line/migrate/migrate.spec.ts @@ -2124,6 +2124,28 @@ describe('Migration', () => { ); }); + it('should reject --mode for non-nx-equivalent target on modern versions', async () => { + await expect(() => + parseMigrationsOptions({ + packageAndVersion: '@nx/react@22.0.0', + mode: 'first-party', + }) + ).rejects.toThrow( + `Error: '--mode' requires the target to be 'nx' or '@nx/workspace'. Got '@nx/react@22.0.0'.` + ); + }); + + it('should reject --mode for non-nx-equivalent target on legacy versions', async () => { + await expect(() => + parseMigrationsOptions({ + packageAndVersion: 'nx@13.0.0', + mode: 'first-party', + }) + ).rejects.toThrow( + `Error: '--mode' requires the target to be '@nrwl/workspace' for Nx <14.0.0. Got 'nx@13.0.0'.` + ); + }); + it('should handle backslashes in package names', async () => { jest .spyOn(packageMgrUtils, 'resolvePackageVersionUsingRegistry') @@ -2277,7 +2299,7 @@ describe('Migration', () => { configurable: true, }); process.env.CI = 'false'; - const result = await resolveMode('first-party'); + const result = await resolveMode('first-party', 'nx', '22.0.0'); expect(result).toBe('first-party'); expect(mockPrompt).not.toHaveBeenCalled(); }); @@ -2288,7 +2310,7 @@ describe('Migration', () => { configurable: true, }); process.env.CI = 'false'; - const result = await resolveMode(undefined); + const result = await resolveMode(undefined, 'nx', '22.0.0'); expect(result).toBe('all'); expect(mockPrompt).not.toHaveBeenCalled(); }); @@ -2299,7 +2321,18 @@ describe('Migration', () => { configurable: true, }); process.env.CI = 'true'; - const result = await resolveMode(undefined); + const result = await resolveMode(undefined, 'nx', '22.0.0'); + expect(result).toBe('all'); + expect(mockPrompt).not.toHaveBeenCalled(); + }); + + it('should default to "all" without prompting for non-nx-equivalent target', async () => { + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + }); + process.env.CI = 'false'; + const result = await resolveMode(undefined, '@nx/react', '22.0.0'); expect(result).toBe('all'); expect(mockPrompt).not.toHaveBeenCalled(); }); @@ -2311,7 +2344,7 @@ describe('Migration', () => { }); process.env.CI = 'false'; mockPrompt.mockReturnValueOnce(Promise.resolve({ mode: 'first-party' })); - const result = await resolveMode(undefined); + const result = await resolveMode(undefined, 'nx', '22.0.0'); expect(result).toBe('first-party'); expect(mockPrompt).toHaveBeenCalled(); }); diff --git a/packages/nx/src/command-line/migrate/migrate.ts b/packages/nx/src/command-line/migrate/migrate.ts index 65fbe99462dec..39754dcbd0985 100644 --- a/packages/nx/src/command-line/migrate/migrate.ts +++ b/packages/nx/src/command-line/migrate/migrate.ts @@ -868,12 +868,27 @@ function resolveFirstPartyPackages( return set; } +function isNxEquivalentTarget( + targetPackage: string, + targetVersion: string +): boolean { + if (lt(targetVersion, '14.0.0-beta.0')) { + return targetPackage === '@nrwl/workspace'; + } + return targetPackage === 'nx' || targetPackage === '@nx/workspace'; +} + export async function resolveMode( - mode: 'first-party' | 'all' | undefined + mode: 'first-party' | 'all' | undefined, + targetPackage: string, + targetVersion: string ): Promise<'first-party' | 'all'> { if (mode) { return mode; } + if (!isNxEquivalentTarget(targetPackage, targetVersion)) { + return 'all'; + } if (!process.stdin.isTTY || isCI()) { return 'all'; } @@ -1032,9 +1047,23 @@ export async function parseMigrationsOptions(options: { const { targetPackage, targetVersion } = await parseTargetPackageAndVersion( options['packageAndVersion'] ); + const normalizedTargetPackage = normalizeSlashes(targetPackage); + if ( + options.mode && + !isNxEquivalentTarget(normalizedTargetPackage, targetVersion) + ) { + const isLegacy = lt(targetVersion, '14.0.0-beta.0'); + const validTargets = isLegacy + ? `'@nrwl/workspace'` + : `'nx' or '@nx/workspace'`; + const eraNote = isLegacy ? ' for Nx <14.0.0' : ''; + throw new Error( + `Error: '--mode' requires the target to be ${validTargets}${eraNote}. Got '${normalizedTargetPackage}@${targetVersion}'.` + ); + } return { type: 'generateMigrations', - targetPackage: normalizeSlashes(targetPackage), + targetPackage: normalizedTargetPackage, targetVersion, from, to, @@ -1615,7 +1644,11 @@ async function generateMigrationsJsonAndUpdatePackageJson( originalNxJson.installation?.version ?? readNxVersion(originalPackageJson, root); - const mode = await resolveMode(opts.mode); + const mode = await resolveMode( + opts.mode, + opts.targetPackage, + opts.targetVersion + ); logger.info(`Fetching meta data about packages.`); logger.info(`It may take a few minutes.`); From 78696ff74acfb63fb51cbbb57edea75abda46609 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leosvel=20P=C3=A9rez=20Espinosa?= Date: Thu, 30 Apr 2026 16:33:05 +0200 Subject: [PATCH 04/13] feat(core): default 'nx migrate' with no args to nx@latest --- packages/nx/src/command-line/migrate/migrate.spec.ts | 12 ++++++++++++ packages/nx/src/command-line/migrate/migrate.ts | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/nx/src/command-line/migrate/migrate.spec.ts b/packages/nx/src/command-line/migrate/migrate.spec.ts index c1c44308358a1..86186f124d12b 100644 --- a/packages/nx/src/command-line/migrate/migrate.spec.ts +++ b/packages/nx/src/command-line/migrate/migrate.spec.ts @@ -1964,6 +1964,18 @@ describe('Migration', () => { }); }); + it('should default to nx@latest when no packageAndVersion is provided', async () => { + jest + .spyOn(packageMgrUtils, 'resolvePackageVersionUsingRegistry') + .mockImplementation((pkg, version) => Promise.resolve(version)); + const r = await parseMigrationsOptions({}); + expect(r).toMatchObject({ + type: 'generateMigrations', + targetPackage: 'nx', + targetVersion: 'latest', + }); + }); + it('should handle different variations of the target package', async () => { const packageRegistryViewSpy = jest .spyOn(packageMgrUtils, 'resolvePackageVersionUsingRegistry') diff --git a/packages/nx/src/command-line/migrate/migrate.ts b/packages/nx/src/command-line/migrate/migrate.ts index 39754dcbd0985..9d7e6db404521 100644 --- a/packages/nx/src/command-line/migrate/migrate.ts +++ b/packages/nx/src/command-line/migrate/migrate.ts @@ -1045,7 +1045,7 @@ export async function parseMigrationsOptions(options: { : Promise.resolve({} as Record), ]); const { targetPackage, targetVersion } = await parseTargetPackageAndVersion( - options['packageAndVersion'] + options['packageAndVersion'] || 'latest' ); const normalizedTargetPackage = normalizeSlashes(targetPackage); if ( From 617e0f3596b93d94dc068fc4934b2b9ba3a964e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leosvel=20P=C3=A9rez=20Espinosa?= Date: Wed, 6 May 2026 15:21:56 +0200 Subject: [PATCH 05/13] feat(core): support --mode=third-party in nx migrate --- .../command-line/migrate/command-object.ts | 14 +- .../src/command-line/migrate/migrate.spec.ts | 390 ++++++++++++++++- .../nx/src/command-line/migrate/migrate.ts | 412 ++++++++++++++---- packages/nx/src/utils/installed-nx-version.ts | 76 +++- 4 files changed, 799 insertions(+), 93 deletions(-) diff --git a/packages/nx/src/command-line/migrate/command-object.ts b/packages/nx/src/command-line/migrate/command-object.ts index 472cc915eb1a0..e67a2578ef475 100644 --- a/packages/nx/src/command-line/migrate/command-object.ts +++ b/packages/nx/src/command-line/migrate/command-object.ts @@ -86,18 +86,24 @@ function withMigrationOptions(yargs: Argv) { }) .option('mode', { describe: - "Restrict which packages to migrate. 'first-party' processes only the target package and the packages in its nx.packageGroup; 'all' processes everything.", + "Restrict which packages to migrate. Only applies when migrating Nx itself. 'first-party' processes only Nx and its plugins (the target package plus its nx.packageGroup); 'third-party' processes only the third-party dependencies referenced by Nx packageJsonUpdates entries, catching up on any updates that may have been skipped previously; 'all' processes everything. Defaults to 'all' (or prompts in an interactive terminal when targeting Nx).", type: 'string', - choices: ['first-party', 'all'], + choices: ['first-party', 'third-party', 'all'], }) .check( - ({ createCommits, commitPrefix, from, excludeAppliedMigrations }) => { + ({ + createCommits, + commitPrefix, + from, + excludeAppliedMigrations, + mode, + }) => { if (!createCommits && commitPrefix !== defaultCommitPrefix) { throw new Error( 'Error: Providing a custom commit prefix requires --create-commits to be enabled' ); } - if (excludeAppliedMigrations && !from) { + if (excludeAppliedMigrations && !from && mode !== 'third-party') { throw new Error( 'Error: Excluding migrations that should have been previously applied requires --from to be set' ); diff --git a/packages/nx/src/command-line/migrate/migrate.spec.ts b/packages/nx/src/command-line/migrate/migrate.spec.ts index 86186f124d12b..4436c7662282e 100644 --- a/packages/nx/src/command-line/migrate/migrate.spec.ts +++ b/packages/nx/src/command-line/migrate/migrate.spec.ts @@ -1,10 +1,23 @@ const mocks = { prompt: jest.fn(), + getInstalledNxVersion: jest.fn(), + getInstalledNxPackageGroup: jest.fn(), + getInstalledLegacyNrwlWorkspaceVersion: jest.fn(), }; const mockPrompt = mocks.prompt; +const mockGetInstalledNxVersion = mocks.getInstalledNxVersion; +const mockGetInstalledNxPackageGroup = mocks.getInstalledNxPackageGroup; +const mockGetInstalledLegacyNrwlWorkspaceVersion = + mocks.getInstalledLegacyNrwlWorkspaceVersion; jest.mock('enquirer', () => ({ prompt: (...args: any[]) => mocks.prompt(...args), })); +jest.mock('../../utils/installed-nx-version', () => ({ + getInstalledNxVersion: () => mocks.getInstalledNxVersion(), + getInstalledNxPackageGroup: () => mocks.getInstalledNxPackageGroup(), + getInstalledLegacyNrwlWorkspaceVersion: () => + mocks.getInstalledLegacyNrwlWorkspaceVersion(), +})); import { PackageJson } from '../../utils/package-json'; import * as packageMgrUtils from '../../utils/package-manager'; @@ -15,6 +28,7 @@ import { normalizeVersion, parseMigrationsOptions, ResolvedMigrationConfiguration, + resolveCanonicalNxPackage, resolveMode, } from './migrate'; @@ -1151,6 +1165,70 @@ describe('Migration', () => { }); expect(result.packageUpdates['@nx/react']).toBeUndefined(); }); + + it('should drop first-party packages and keep third-party in mixed entries when mode is third-party', async () => { + const migrator = new Migrator({ + packageJson: createPackageJson({ + dependencies: { + firstPartyChild: '1.0.0', + thirdPartyChild: '1.0.0', + }, + }), + getInstalledPackageVersion: () => '1.0.0', + fetch: (p) => { + if (p === 'mypackage') { + return Promise.resolve({ + version: '2.0.0', + packageJsonUpdates: { + mixed: { + version: '2.0.0', + packages: { + firstPartyChild: { version: '3.0.0' }, + thirdPartyChild: { version: '3.0.0' }, + }, + }, + }, + }); + } else if (p === 'firstPartyChild' || p === 'thirdPartyChild') { + return Promise.resolve({ version: '3.0.0' }); + } + return Promise.resolve(null); + }, + from: {}, + to: {}, + mode: 'third-party', + firstPartyPackages: new Set(['mypackage', 'firstPartyChild']), + }); + + const result = await migrator.migrate('mypackage', '2.0.0'); + + expect(result.packageUpdates).toEqual({ + thirdPartyChild: { version: '3.0.0', addToPackageJson: false }, + }); + expect(result.packageUpdates.mypackage).toBeUndefined(); + expect(result.packageUpdates.firstPartyChild).toBeUndefined(); + }); + + it.each(['first-party', 'third-party'] as const)( + 'should throw when constructed with mode=%s but no firstPartyPackages', + (mode) => { + // Other required callbacks are unused — constructor rejects before any + // method runs — so stub them with the simplest valid shape. + expect( + () => + new Migrator({ + packageJson: createPackageJson({}), + getInstalledPackageVersion: () => '0.0.0', + fetch: () => Promise.resolve({ version: '0.0.0' }), + from: {}, + to: {}, + mode, + }) + ).toThrow( + `Error: 'firstPartyPackages' is required when 'mode' is '${mode}'.` + ); + } + ); }); describe('requirements', () => { @@ -1929,6 +2007,26 @@ describe('Migration', () => { }); describe('parseMigrationsOptions', () => { + beforeEach(() => { + mockGetInstalledNxVersion.mockReturnValue('22.0.0'); + mockGetInstalledNxPackageGroup.mockReturnValue( + new Set([ + 'nx', + 'nx-cloud', + 'create-nx-workspace', + '@nx/js', + '@nx/workspace', + '@nx/react', + ]) + ); + mockGetInstalledLegacyNrwlWorkspaceVersion.mockReturnValue(null); + }); + afterEach(() => { + mockGetInstalledNxVersion.mockReset(); + mockGetInstalledNxPackageGroup.mockReset(); + mockGetInstalledLegacyNrwlWorkspaceVersion.mockReset(); + }); + it('should work for generating migrations', async () => { jest .spyOn(packageMgrUtils, 'resolvePackageVersionUsingRegistry') @@ -1938,7 +2036,7 @@ describe('Migration', () => { from: '@myscope/a@12.3,@myscope/b@1.1.1', to: '@myscope/c@12.3.1', }); - expect(r).toEqual({ + expect(r).toMatchObject({ type: 'generateMigrations', targetPackage: '@nrwl/workspace', targetVersion: '8.12.0', @@ -2158,6 +2256,229 @@ describe('Migration', () => { ); }); + it('should reject --mode=third-party combined with --from', async () => { + await expect(() => + parseMigrationsOptions({ + packageAndVersion: 'nx@22.0.0', + mode: 'third-party', + from: 'nx@21.0.0', + }) + ).rejects.toThrow( + `Error: '--mode=third-party' cannot be combined with '--from'.` + ); + }); + + it('should reject --mode=third-party combined with --exclude-applied-migrations', async () => { + await expect(() => + parseMigrationsOptions({ + packageAndVersion: 'nx@22.0.0', + mode: 'third-party', + excludeAppliedMigrations: true, + }) + ).rejects.toThrow( + `Error: '--mode=third-party' cannot be combined with '--exclude-applied-migrations'.` + ); + }); + + it('should default bare --mode=third-party to nx@', async () => { + mockGetInstalledNxVersion.mockReturnValue('22.5.0'); + const r = await parseMigrationsOptions({ mode: 'third-party' }); + expect(r).toMatchObject({ + type: 'generateMigrations', + targetPackage: 'nx', + targetVersion: '22.5.0', + mode: 'third-party', + }); + }); + + it('should reject --mode=third-party when nx is not installed', async () => { + mockGetInstalledNxVersion.mockReturnValue(null); + await expect(() => + parseMigrationsOptions({ mode: 'third-party' }) + ).rejects.toThrow( + `Error: '--mode=third-party' requires 'nx' (or '@nrwl/workspace' on Nx <14) to be installed in your workspace.` + ); + }); + + it('should anchor bare --mode=third-party to legacy @nrwl/workspace canonical when only legacy is installed', async () => { + mockGetInstalledNxVersion.mockReturnValue(null); + mockGetInstalledLegacyNrwlWorkspaceVersion.mockReturnValue('13.5.0'); + const r = await parseMigrationsOptions({ mode: 'third-party' }); + expect(r).toMatchObject({ + type: 'generateMigrations', + targetPackage: '@nrwl/workspace', + targetVersion: '13.5.0', + mode: 'third-party', + }); + }); + + it('should anchor bare --mode=third-party to @nrwl/workspace canonical when installed nx is legacy (<14)', async () => { + mockGetInstalledNxVersion.mockReturnValue('13.5.0'); + const r = await parseMigrationsOptions({ mode: 'third-party' }); + expect(r).toMatchObject({ + type: 'generateMigrations', + targetPackage: '@nrwl/workspace', + targetVersion: '13.5.0', + mode: 'third-party', + }); + }); + + it('should reject --mode=third-party when target is higher than installed', async () => { + mockGetInstalledNxVersion.mockReturnValue('22.0.0'); + await expect(() => + parseMigrationsOptions({ + packageAndVersion: 'nx@23.0.0', + mode: 'third-party', + }) + ).rejects.toThrow( + `Error: '--mode=third-party' cannot migrate to a version higher than what is currently installed (got 'nx@23.0.0', installed 'nx@22.0.0').` + ); + }); + + it('should accept --mode=third-party when target is lower than installed', async () => { + mockGetInstalledNxVersion.mockReturnValue('22.5.0'); + const r = await parseMigrationsOptions({ + packageAndVersion: 'nx@22.0.0', + mode: 'third-party', + }); + expect(r).toMatchObject({ + type: 'generateMigrations', + targetPackage: 'nx', + targetVersion: '22.0.0', + mode: 'third-party', + }); + }); + + it('should accept --mode=third-party with @nx/workspace target, preserve typed target, and swap to nx canonical at walk time', async () => { + // `parseMigrationsOptions` preserves the typed target verbatim; the + // silent `@nx/workspace` → `nx` swap happens later in + // `generateMigrationsJsonAndUpdatePackageJson` via + // `resolveCanonicalNxPackage`. + mockGetInstalledNxVersion.mockReturnValue('22.0.0'); + const r = await parseMigrationsOptions({ + packageAndVersion: '@nx/workspace@22.0.0', + mode: 'third-party', + }); + expect(r).toMatchObject({ + type: 'generateMigrations', + targetPackage: '@nx/workspace', + targetVersion: '22.0.0', + mode: 'third-party', + }); + expect( + resolveCanonicalNxPackage( + (r as { targetVersion: string }).targetVersion + ) + ).toBe('nx'); + }); + + it('should reject --mode=third-party with --to canonical higher than installed', async () => { + mockGetInstalledNxVersion.mockReturnValue('22.0.0'); + await expect(() => + parseMigrationsOptions({ + packageAndVersion: 'nx@22.0.0', + mode: 'third-party', + to: 'nx@23.0.0', + }) + ).rejects.toThrow( + `Error: '--mode=third-party' cannot migrate to a version higher than what is currently installed (got '--to nx@23.0.0', installed 'nx@22.0.0').` + ); + }); + + it('should reject --mode=third-party with --to for first-party plugins higher than installed', async () => { + mockGetInstalledNxVersion.mockReturnValue('22.0.0'); + await expect(() => + parseMigrationsOptions({ + packageAndVersion: 'nx@22.0.0', + mode: 'third-party', + to: '@nx/js@22.6.4', + }) + ).rejects.toThrow( + `Error: '--mode=third-party' cannot migrate to a version higher than what is currently installed (got '--to @nx/js@22.6.4', installed 'nx@22.0.0').` + ); + }); + + it('should reject --mode=third-party with --to create-nx-workspace higher than installed', async () => { + mockGetInstalledNxVersion.mockReturnValue('22.0.0'); + await expect(() => + parseMigrationsOptions({ + packageAndVersion: 'nx@22.0.0', + mode: 'third-party', + to: 'create-nx-workspace@22.6.4', + }) + ).rejects.toThrow( + `Error: '--mode=third-party' cannot migrate to a version higher than what is currently installed (got '--to create-nx-workspace@22.6.4', installed 'nx@22.0.0').` + ); + }); + + it('should accept --mode=third-party with --to for non-canonical packages', async () => { + mockGetInstalledNxVersion.mockReturnValue('22.0.0'); + const r = await parseMigrationsOptions({ + packageAndVersion: 'nx@22.0.0', + mode: 'third-party', + to: 'react@18.0.0', + }); + expect(r).toMatchObject({ + type: 'generateMigrations', + mode: 'third-party', + to: { react: '18.0.0' }, + }); + }); + + it('should anchor --mode=third-party to installed when target has no version', async () => { + mockGetInstalledNxVersion.mockReturnValue('22.5.0'); + const r = await parseMigrationsOptions({ + packageAndVersion: 'nx', + mode: 'third-party', + }); + expect(r).toMatchObject({ + type: 'generateMigrations', + targetPackage: 'nx', + targetVersion: '22.5.0', + mode: 'third-party', + }); + }); + + it('should anchor bare-package-name --mode=third-party to legacy canonical when installed nx is <14', async () => { + mockGetInstalledNxVersion.mockReturnValue('13.5.0'); + const r = await parseMigrationsOptions({ + packageAndVersion: 'nx', + mode: 'third-party', + }); + expect(r).toMatchObject({ + type: 'generateMigrations', + targetPackage: '@nrwl/workspace', + targetVersion: '13.5.0', + mode: 'third-party', + }); + }); + + it('should reject --mode=third-party for legacy target when @nrwl/workspace is not installed', async () => { + mockGetInstalledLegacyNrwlWorkspaceVersion.mockReturnValue(null); + await expect(() => + parseMigrationsOptions({ + packageAndVersion: '@nrwl/workspace@13.0.0', + mode: 'third-party', + }) + ).rejects.toThrow( + `Error: '--mode=third-party' requires '@nrwl/workspace' to be installed in your workspace.` + ); + }); + + it('should accept --mode=third-party for legacy target when @nrwl/workspace is installed', async () => { + mockGetInstalledLegacyNrwlWorkspaceVersion.mockReturnValue('13.5.0'); + const r = await parseMigrationsOptions({ + packageAndVersion: '@nrwl/workspace@13.0.0', + mode: 'third-party', + }); + expect(r).toMatchObject({ + type: 'generateMigrations', + targetPackage: '@nrwl/workspace', + targetVersion: '13.0.0', + mode: 'third-party', + }); + }); + it('should handle backslashes in package names', async () => { jest .spyOn(packageMgrUtils, 'resolvePackageVersionUsingRegistry') @@ -2169,7 +2490,7 @@ describe('Migration', () => { from: '@myscope\\a@12.3,@myscope\\b@1.1.1', to: '@myscope\\c@12.3.1', }); - expect(r).toEqual({ + expect(r).toMatchObject({ type: 'generateMigrations', targetPackage: '@nx/workspace', targetVersion: '8.12.0', @@ -2360,6 +2681,71 @@ describe('Migration', () => { expect(result).toBe('first-party'); expect(mockPrompt).toHaveBeenCalled(); }); + + it('should include third-party in prompt choices by default', async () => { + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + }); + process.env.CI = 'false'; + mockPrompt.mockReturnValueOnce(Promise.resolve({ mode: 'all' })); + await resolveMode(undefined, 'nx', '22.0.0'); + const choices = mockPrompt.mock.calls[0][0].choices; + expect(choices.map((c: { name: string }) => c.name)).toEqual([ + 'first-party', + 'third-party', + 'all', + ]); + }); + + it('should hide third-party from prompt choices when --from is provided', async () => { + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + }); + process.env.CI = 'false'; + mockPrompt.mockReturnValueOnce(Promise.resolve({ mode: 'all' })); + await resolveMode(undefined, 'nx', '22.0.0', { + hasFrom: true, + hasExcludeAppliedMigrations: false, + }); + const choices = mockPrompt.mock.calls[0][0].choices; + expect(choices.map((c: { name: string }) => c.name)).toEqual([ + 'first-party', + 'all', + ]); + }); + + it('should hide third-party from prompt choices when --exclude-applied-migrations is provided', async () => { + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + }); + process.env.CI = 'false'; + mockPrompt.mockReturnValueOnce(Promise.resolve({ mode: 'all' })); + await resolveMode(undefined, 'nx', '22.0.0', { + hasFrom: false, + hasExcludeAppliedMigrations: true, + }); + const choices = mockPrompt.mock.calls[0][0].choices; + expect(choices.map((c: { name: string }) => c.name)).toEqual([ + 'first-party', + 'all', + ]); + }); + }); + + describe('resolveCanonicalNxPackage', () => { + it.each([ + ['22.0.0', 'nx'], + ['14.0.0-beta.0', 'nx'], + ['14.0.0', 'nx'], + ['13.999.999', '@nrwl/workspace'], + ['13.0.0', '@nrwl/workspace'], + ['8.12.0', '@nrwl/workspace'], + ] as const)('should resolve %s to %s', (version, expected) => { + expect(resolveCanonicalNxPackage(version)).toBe(expected); + }); }); describe('isNpmPeerDepsError', () => { diff --git a/packages/nx/src/command-line/migrate/migrate.ts b/packages/nx/src/command-line/migrate/migrate.ts index 9d7e6db404521..d38eea5d42d47 100644 --- a/packages/nx/src/command-line/migrate/migrate.ts +++ b/packages/nx/src/command-line/migrate/migrate.ts @@ -73,6 +73,11 @@ import { getNxInstallationPath, getNxRequirePaths, } from '../../utils/installation-directory'; +import { + getInstalledLegacyNrwlWorkspaceVersion, + getInstalledNxPackageGroup, + getInstalledNxVersion, +} from '../../utils/installed-nx-version'; import { readNxJson } from '../../config/configuration'; import { runNxSync } from '../../utils/child-process'; import { daemonClient } from '../../daemon/client/client'; @@ -205,9 +210,10 @@ export interface MigratorOptions { /** * Restricts `packageJsonUpdates` filtering based on the value: * - 'first-party' keeps only packages in `firstPartyPackages` + * - 'third-party' keeps only packages NOT in `firstPartyPackages` * - 'all' / undefined keeps all packages (no filtering) */ - mode?: 'first-party' | 'all'; + mode?: 'first-party' | 'third-party' | 'all'; /** First-party package names used by `mode` for filtering. */ firstPartyPackages?: ReadonlySet; } @@ -229,6 +235,14 @@ export class Migrator { private minVersionWithSkippedUpdates: string | undefined; constructor(opts: MigratorOptions) { + if ( + (opts.mode === 'first-party' || opts.mode === 'third-party') && + !opts.firstPartyPackages + ) { + throw new Error( + `Error: 'firstPartyPackages' is required when 'mode' is '${opts.mode}'.` + ); + } this.packageJson = opts.packageJson; this.nxInstallation = opts.nxInstallation; this.getInstalledPackageVersion = opts.getInstalledPackageVersion; @@ -259,6 +273,7 @@ export class Migrator { version: targetVersion, addToPackageJson: false, }); + this.applyModeFilter(); const migrations = await this.createMigrateJson(); return { @@ -620,6 +635,21 @@ export class Migrator { return false; } + private applyModeFilter(): void { + if (this.mode !== 'third-party') { + return; + } + // Cascade walks through first-party packages so cross-plugin third-party + // deps (e.g. typescript managed by @nx/js but used by @nx/angular) get + // surfaced. Drop the first-party set from the final result here so only + // third-party updates land in package.json. + for (const name of Object.keys(this.packageUpdates)) { + if (this.firstPartyPackages!.has(name)) { + delete this.packageUpdates[name]; + } + } + } + private shouldApplyPackageUpdate( packageUpdate: PackageUpdate, packageName: string, @@ -872,17 +902,38 @@ function isNxEquivalentTarget( targetPackage: string, targetVersion: string ): boolean { - if (lt(targetVersion, '14.0.0-beta.0')) { + // Non-semver values (e.g., the literal `'latest'` sentinel used during bare + // invocation before tag resolution, or in tests) are treated as modern era. + if (valid(targetVersion) && lt(targetVersion, '14.0.0-beta.0')) { return targetPackage === '@nrwl/workspace'; } return targetPackage === 'nx' || targetPackage === '@nx/workspace'; } +/** + * The canonical Nx package for a given target version: `@nrwl/workspace` for + * legacy (`< 14.0.0-beta.0`), `nx` otherwise. Non-semver inputs (e.g. the + * literal `'latest'` sentinel before tag resolution) resolve to modern era. + * Used by `--mode=third-party` to silently swap `@nx/workspace` → `nx` when + * walking the cascade. + */ +export function resolveCanonicalNxPackage( + targetVersion: string +): 'nx' | '@nrwl/workspace' { + return valid(targetVersion) && lt(targetVersion, '14.0.0-beta.0') + ? '@nrwl/workspace' + : 'nx'; +} + export async function resolveMode( - mode: 'first-party' | 'all' | undefined, + mode: 'first-party' | 'third-party' | 'all' | undefined, targetPackage: string, - targetVersion: string -): Promise<'first-party' | 'all'> { + targetVersion: string, + context: { hasFrom: boolean; hasExcludeAppliedMigrations: boolean } = { + hasFrom: false, + hasExcludeAppliedMigrations: false, + } +): Promise<'first-party' | 'third-party' | 'all'> { if (mode) { return mode; } @@ -892,14 +943,20 @@ export async function resolveMode( if (!process.stdin.isTTY || isCI()) { return 'all'; } - const { mode: selected } = await prompt<{ mode: 'first-party' | 'all' }>({ + const choices: { name: string; message: string }[] = [ + { name: 'first-party', message: 'First-party only' }, + ]; + if (!context.hasFrom && !context.hasExcludeAppliedMigrations) { + choices.push({ name: 'third-party', message: 'Third-party only' }); + } + choices.push({ name: 'all', message: 'All' }); + const { mode: selected } = await prompt<{ + mode: 'first-party' | 'third-party' | 'all'; + }>({ type: 'select', name: 'mode', message: 'Which packages would you like to migrate?', - choices: [ - { name: 'first-party', message: 'First-party only' }, - { name: 'all', message: 'All' }, - ], + choices, }); return selected; } @@ -1013,7 +1070,7 @@ type GenerateMigrations = { to: { [k: string]: string }; interactive?: boolean; excludeAppliedMigrations?: boolean; - mode?: 'first-party' | 'all'; + mode: 'first-party' | 'third-party' | 'all'; }; type RunMigrations = { @@ -1035,49 +1092,173 @@ export async function parseMigrationsOptions(options: { ); } - if (!options.runMigrations) { - const [from, to] = await Promise.all([ - options.from - ? versionOverrides(options.from as string, 'from') - : Promise.resolve({} as Record), - options.to - ? await versionOverrides(options.to as string, 'to') - : Promise.resolve({} as Record), - ]); - const { targetPackage, targetVersion } = await parseTargetPackageAndVersion( - options['packageAndVersion'] || 'latest' - ); - const normalizedTargetPackage = normalizeSlashes(targetPackage); - if ( - options.mode && - !isNxEquivalentTarget(normalizedTargetPackage, targetVersion) - ) { - const isLegacy = lt(targetVersion, '14.0.0-beta.0'); - const validTargets = isLegacy - ? `'@nrwl/workspace'` - : `'nx' or '@nx/workspace'`; - const eraNote = isLegacy ? ' for Nx <14.0.0' : ''; - throw new Error( - `Error: '--mode' requires the target to be ${validTargets}${eraNote}. Got '${normalizedTargetPackage}@${targetVersion}'.` - ); - } - return { - type: 'generateMigrations', - targetPackage: normalizedTargetPackage, - targetVersion, - from, - to, - interactive: options.interactive, - excludeAppliedMigrations: options.excludeAppliedMigrations, - mode: options.mode, - }; - } else { + if (options.runMigrations) { return { type: 'runMigrations', runMigrations: options.runMigrations as string, ifExists: options.ifExists as boolean, }; } + + if (options.mode === 'third-party') { + if (options.from) { + throw new Error( + `Error: '--mode=third-party' cannot be combined with '--from'.` + ); + } + if (options.excludeAppliedMigrations === true) { + throw new Error( + `Error: '--mode=third-party' cannot be combined with '--exclude-applied-migrations'.` + ); + } + } + + const [from, to] = await Promise.all([ + options.from + ? versionOverrides(options.from as string, 'from') + : Promise.resolve({} as Record), + options.to + ? await versionOverrides(options.to as string, 'to') + : Promise.resolve({} as Record), + ]); + + const positional = options['packageAndVersion'] as string | undefined; + let targetPackage: string | undefined; + let targetVersion: string | undefined; + if (positional) { + const parsed = await parseTargetPackageAndVersion(positional); + targetPackage = normalizeSlashes(parsed.targetPackage); + targetVersion = parsed.targetVersion; + } + + // Resolve mode before defaulting target so the default can depend on the + // resolved mode (third-party defaults to nx@; otherwise nx@latest). + // For bare invocation, `targetPackage='nx'` and `targetVersion='latest'` are + // safe sentinels: `isNxEquivalentTarget` treats the literal `'latest'` as + // modern era (semver `lt('latest', '14.0.0-beta.0')` is false). + const mode = await resolveMode( + options.mode, + targetPackage ?? 'nx', + targetVersion ?? 'latest', + { + hasFrom: Object.keys(from).length > 0, + hasExcludeAppliedMigrations: options.excludeAppliedMigrations === true, + } + ); + + let installedNxVersion: string | null | undefined; + // For third-party, anchor `targetPackage`/`targetVersion` to the installed + // canonical when the positional was either omitted or a bare package name + // (no semver). This keeps the era gate accepting legacy workspaces, the + // upper-bound gate meaningful, and downstream semver comparisons safe from + // the literal `'latest'` that `parseTargetPackageAndVersion` emits for bare + // package names. + if (mode === 'third-party' && (!positional || !valid(targetVersion!))) { + const installed = resolveInstalledCanonical(); + if (!installed) { + throw new Error( + `Error: '--mode=third-party' requires 'nx' (or '@nrwl/workspace' on Nx <14) to be installed in your workspace. Install dependencies first, then re-run.` + ); + } + installedNxVersion = installed.version; + targetPackage = installed.canonical; + targetVersion = installed.version; + } else if (!positional) { + const parsed = await parseTargetPackageAndVersion('latest'); + targetPackage = normalizeSlashes(parsed.targetPackage); + targetVersion = parsed.targetVersion; + } + + if (options.mode && !isNxEquivalentTarget(targetPackage!, targetVersion!)) { + const isLegacy = + valid(targetVersion!) && lt(targetVersion!, '14.0.0-beta.0'); + const validTargets = isLegacy + ? `'@nrwl/workspace'` + : `'nx' or '@nx/workspace'`; + const eraNote = isLegacy ? ' for Nx <14.0.0' : ''; + throw new Error( + `Error: '--mode' requires the target to be ${validTargets}${eraNote}. Got '${targetPackage}@${targetVersion}'.` + ); + } + + if (mode === 'third-party') { + const canonical = resolveCanonicalNxPackage(targetVersion!); + const isLegacy = canonical === '@nrwl/workspace'; + // Reuse the resolved installed version from the bare/no-version branch + // above when present (it's already era-aware via `resolveInstalledCanonical`). + // Otherwise fall back to the era-specific reader. + const installed = + installedNxVersion ?? + (isLegacy + ? getInstalledLegacyNrwlWorkspaceVersion() + : getInstalledNxVersion()); + if (!installed) { + throw new Error( + `Error: '--mode=third-party' requires '${canonical}' to be installed in your workspace. Install dependencies first, then re-run.` + ); + } + if (gt(targetVersion!, installed)) { + throw new Error( + `Error: '--mode=third-party' cannot migrate to a version higher than what is currently installed (got '${targetPackage}@${targetVersion}', installed '${canonical}@${installed}'). Either drop '--mode=third-party' or lower the target.` + ); + } + // Gate `--to` for any first-party package, not just the canonical. The + // third-party walk follows nx's `packageGroup` (e.g. `@nx/js`, + // `@nx/angular`), and `--to @` would expand the walk past + // the installed version and surface third-party bumps that only exist in + // the newer plugin's history. The first-party set is sourced from the + // installed nx package's declared `packageGroup` (authoritative for the + // user's current Nx universe). Legacy era falls back to the hardcoded + // `LEGACY_NRWL_PACKAGE_GROUP`. + const firstPartySet = isLegacy + ? new Set([ + '@nrwl/workspace', + ...LEGACY_NRWL_PACKAGE_GROUP.map((p) => p.package), + ]) + : getInstalledNxPackageGroup(); + for (const [pkg, version] of Object.entries(to)) { + if (firstPartySet.has(pkg) && gt(version, installed)) { + throw new Error( + `Error: '--mode=third-party' cannot migrate to a version higher than what is currently installed (got '--to ${pkg}@${version}', installed '${canonical}@${installed}'). Either drop '--mode=third-party' or lower the '--to' value.` + ); + } + } + } + + return { + type: 'generateMigrations', + targetPackage: targetPackage!, + targetVersion: targetVersion!, + from, + to, + interactive: options.interactive, + excludeAppliedMigrations: options.excludeAppliedMigrations, + mode, + }; +} + +/** + * Pick the canonical Nx package + version for `--mode=third-party` when the + * user didn't supply an explicit version. Returns `'nx'` for modern era, + * falls back to `'@nrwl/workspace'` (legacy era) when only that is installed + * or when the installed `nx` itself is `<14`. + */ +function resolveInstalledCanonical(): { + canonical: 'nx' | '@nrwl/workspace'; + version: string; +} | null { + const installedNx = getInstalledNxVersion(); + if (installedNx) { + if (lt(installedNx, '14.0.0-beta.0')) { + return { canonical: '@nrwl/workspace', version: installedNx }; + } + return { canonical: 'nx', version: installedNx }; + } + const installedLegacy = getInstalledLegacyNrwlWorkspaceVersion(); + if (installedLegacy) { + return { canonical: '@nrwl/workspace', version: installedLegacy }; + } + return null; } function createInstalledPackageVersionsResolver( @@ -1490,10 +1671,10 @@ async function createMigrationsFile( async function updatePackageJson( root: string, updatedPackages: Record -) { +): Promise { const packageJsonPath = join(root, 'package.json'); if (!existsSync(packageJsonPath)) { - return; + return false; } const parseOptions: JsonReadOptions = {}; @@ -1501,6 +1682,7 @@ async function updatePackageJson( const manager = getCatalogManager(root); const catalogUpdates = []; + let modified = false; Object.keys(updatedPackages).forEach((p) => { const existingVersion = json.dependencies?.[p] ?? json.devDependencies?.[p]; @@ -1519,25 +1701,36 @@ async function updatePackageJson( // Update non-catalog packages in package.json if (json.devDependencies?.[p]) { - json.devDependencies[p] = updatedPackages[p].version; + if (json.devDependencies[p] !== updatedPackages[p].version) { + json.devDependencies[p] = updatedPackages[p].version; + modified = true; + } return; } if (json.dependencies?.[p]) { - json.dependencies[p] = updatedPackages[p].version; + if (json.dependencies[p] !== updatedPackages[p].version) { + json.dependencies[p] = updatedPackages[p].version; + modified = true; + } return; } const dependencyType = updatedPackages[p].addToPackageJson; if (typeof dependencyType === 'string') { json[dependencyType] ??= {}; - json[dependencyType][p] = updatedPackages[p].version; + if (json[dependencyType][p] !== updatedPackages[p].version) { + json[dependencyType][p] = updatedPackages[p].version; + modified = true; + } } }); - await writeFormattedJsonFile(packageJsonPath, json, { - appendNewLine: parseOptions.endsWithNewline, - }); + if (modified) { + await writeFormattedJsonFile(packageJsonPath, json, { + appendNewLine: parseOptions.endsWithNewline, + }); + } // Update catalog definitions if (catalogUpdates.length) { @@ -1545,6 +1738,8 @@ async function updatePackageJson( manager!.updateCatalogVersions(root, catalogUpdates); await formatCatalogDefinitionFiles(manager!, root); } + + return modified || catalogUpdates.length > 0; } async function formatCatalogDefinitionFiles( @@ -1579,34 +1774,45 @@ async function formatCatalogDefinitionFiles( async function updateInstallationDetails( root: string, updatedPackages: Record -) { +): Promise { const nxJsonPath = join(root, 'nx.json'); const parseOptions: JsonReadOptions = {}; const nxJson = readJsonFile(nxJsonPath, parseOptions); if (!nxJson.installation) { - return; + return false; } + let modified = false; + const nxVersion = updatedPackages.nx?.version; - if (nxVersion) { + if (nxVersion && nxJson.installation.version !== nxVersion) { nxJson.installation.version = nxVersion; + modified = true; } if (nxJson.installation.plugins) { for (const dep in nxJson.installation.plugins) { const update = updatedPackages[dep]; if (update) { - nxJson.installation.plugins[dep] = valid(update.version) + const newVersion = valid(update.version) ? update.version : await resolvePackageVersionUsingRegistry(dep, update.version); + if (nxJson.installation.plugins[dep] !== newVersion) { + nxJson.installation.plugins[dep] = newVersion; + modified = true; + } } } } - await writeFormattedJsonFile(nxJsonPath, nxJson, { - appendNewLine: parseOptions.endsWithNewline, - }); + if (modified) { + await writeFormattedJsonFile(nxJsonPath, nxJson, { + appendNewLine: parseOptions.endsWithNewline, + }); + } + + return modified; } async function isMigratingToNewMajor(from: string, to: string) { @@ -1644,18 +1850,28 @@ async function generateMigrationsJsonAndUpdatePackageJson( originalNxJson.installation?.version ?? readNxVersion(originalPackageJson, root); - const mode = await resolveMode( - opts.mode, - opts.targetPackage, - opts.targetVersion - ); + const mode = opts.mode; + + let walkedTargetPackage = opts.targetPackage; + let fromOverrides = opts.from; + let excludeApplied = opts.excludeAppliedMigrations; + if (mode === 'third-party') { + // For third-party, walk the canonical Nx target so cross-plugin third-party + // dependencies (e.g. typescript managed by @nx/js but used by @nx/angular) + // stay consistent. Force a from-zero walk + exclude-applied so we surface + // any third-party updates that may have been skipped previously. + const canonical = resolveCanonicalNxPackage(opts.targetVersion); + walkedTargetPackage = canonical; + fromOverrides = { [canonical]: '0.0.0' }; + excludeApplied = true; + } logger.info(`Fetching meta data about packages.`); logger.info(`It may take a few minutes.`); const fetch = createFetcher(); let firstPartyPackages: ReadonlySet | undefined; - if (mode === 'first-party') { + if (mode === 'first-party' || mode === 'third-party') { // `@nx/workspace` is version-synced with `nx` and declares an // intentionally narrow `packageGroup` ({ nx, nx-cloud }) via its // `ng-update` field, whereas `nx` declares the full @nx/* plugin @@ -1663,11 +1879,21 @@ async function generateMigrationsJsonAndUpdatePackageJson( // when `@nx/workspace` is the target we source the set from `nx` // directly to capture the full plugin set. const sourcePackage = - opts.targetPackage === '@nx/workspace' ? 'nx' : opts.targetPackage; + walkedTargetPackage === '@nx/workspace' ? 'nx' : walkedTargetPackage; const rootMetadata = await fetch(sourcePackage, opts.targetVersion); + // Legacy `@nrwl/workspace<14` doesn't ship a complete `packageGroup` + // in its metadata; the Migrator's cascade injects + // `LEGACY_NRWL_PACKAGE_GROUP` for that case, and the post-build + // third-party filter must mirror that set or first-party `@nrwl/*` + // plugins slip past it. + const packageGroup = + sourcePackage === '@nrwl/workspace' && + lt(opts.targetVersion, '14.0.0-beta.0') + ? LEGACY_NRWL_PACKAGE_GROUP + : rootMetadata.packageGroup; firstPartyPackages = resolveFirstPartyPackages( sourcePackage, - rootMetadata.packageGroup + packageGroup ); } @@ -1676,19 +1902,22 @@ async function generateMigrationsJsonAndUpdatePackageJson( nxInstallation: originalNxJson.installation, getInstalledPackageVersion: createInstalledPackageVersionsResolver(root), fetch, - from: opts.from, + from: fromOverrides, to: opts.to, interactive: opts.interactive && !isCI(), - excludeAppliedMigrations: opts.excludeAppliedMigrations, + excludeAppliedMigrations: excludeApplied, mode, firstPartyPackages, }); const { migrations, packageUpdates, minVersionWithSkippedUpdates } = - await migrator.migrate(opts.targetPackage, opts.targetVersion); + await migrator.migrate(walkedTargetPackage, opts.targetVersion); - await updatePackageJson(root, packageUpdates); - await updateInstallationDetails(root, packageUpdates); + const wrotePackageJson = await updatePackageJson(root, packageUpdates); + const wroteNxJsonInstallation = await updateInstallationDetails( + root, + packageUpdates + ); if (migrations.length > 0) { await createMigrationsFile(root, [ @@ -1697,10 +1926,39 @@ async function generateMigrationsJsonAndUpdatePackageJson( ] as any); } + const modeLine = + mode === 'first-party' + ? `- Processed Nx first-party packages only (skipped third-party dependency bumps).` + : mode === 'third-party' + ? `- Processed third-party dependencies only (skipped Nx first-party package updates).` + : null; + + const noChanges = + !wrotePackageJson && !wroteNxJsonInstallation && migrations.length === 0; + + if (noChanges) { + output.success({ + title: `No updates were applied.`, + bodyLines: [ + ...(modeLine ? [modeLine] : []), + mode === 'third-party' + ? `- No third-party dependency bumps were found for the installed Nx version. Either your dependencies are already up to date, or this workspace doesn't manage them in a place 'nx migrate' writes to (e.g. non-JS workspaces only track Nx and its plugins).` + : `- No package updates or migrations were found.`, + ], + }); + // Nothing was applied; skip the "Next steps" guidance below — it would + // tell the user to inspect package.json changes that don't exist. + return; + } + output.success({ title: `The migrate command has run successfully.`, bodyLines: [ - `- package.json has been updated.`, + ...(modeLine ? [modeLine] : []), + ...(wrotePackageJson ? [`- package.json has been updated.`] : []), + ...(wroteNxJsonInstallation + ? [`- nx.json (installation) has been updated.`] + : []), migrations.length > 0 ? `- migrations.json has been generated.` : `- There are no migrations to run, so migrations.json has not been created.`, diff --git a/packages/nx/src/utils/installed-nx-version.ts b/packages/nx/src/utils/installed-nx-version.ts index 1421a4d5e42ee..17a2ae9099df2 100644 --- a/packages/nx/src/utils/installed-nx-version.ts +++ b/packages/nx/src/utils/installed-nx-version.ts @@ -1,26 +1,82 @@ import Module, { createRequire } from 'node:module'; import { readJsonFile } from './fileutils'; -import type { PackageJson } from './package-json'; +import { + normalizePackageGroup, + readModulePackageJson, + type PackageGroup, + type PackageJson, +} from './package-json'; import { workspaceRoot } from './workspace-root'; import { getNxRequirePaths } from './installation-directory'; +type InstalledNxPackageJson = PackageJson & { + 'ng-update'?: { packageGroup?: PackageGroup }; + 'nx-migrations'?: { packageGroup?: PackageGroup }; +}; + /** - * Resolve the workspace's installed `nx` version, or `null` if no installed - * `nx` can be located. Routed through a cache-shielded, self-reference-free - * `require.resolve` so the answer always reflects the workspace's - * `node_modules`/PnP store rather than whichever `nx` package happens to be - * loaded in the current process. See nrwl/nx#35444. + * Read the installed `nx` package.json via the cache-shielded resolver. The + * resolver always reflects the workspace's `node_modules`/PnP store rather + * than whichever `nx` package happens to be loaded in the current process + * (e.g. the temp `nx@latest` install used by the migrate bootstrap). See + * nrwl/nx#35444 and `resolvePackageJsonWithoutCachePollution` below. */ -export function getInstalledNxVersion(): string | null { - const nxPackageJsonPath = resolvePackageJsonWithoutCachePollution( +function readInstalledNxPackageJson(): InstalledNxPackageJson | null { + const path = resolvePackageJsonWithoutCachePollution( 'nx', getNxRequirePaths(workspaceRoot) ); - if (!nxPackageJsonPath) { + if (!path) { + return null; + } + try { + return readJsonFile(path); + } catch { return null; } +} + +/** + * Resolve the workspace's installed `nx` version, or `null` if no installed + * `nx` can be located. + */ +export function getInstalledNxVersion(): string | null { + return readInstalledNxPackageJson()?.version ?? null; +} + +/** + * Return the package names declared in the installed `nx` package's + * `ng-update.packageGroup` (or `nx-migrations.packageGroup`), plus `'nx'` + * itself. Returns an empty set when nx isn't installed or the metadata is + * missing — callers should handle that as "no first-party set known". + */ +export function getInstalledNxPackageGroup(): Set { + const set = new Set(['nx']); + const pkg = readInstalledNxPackageJson(); + if (!pkg) { + return set; + } + const declared = + pkg['ng-update']?.packageGroup ?? pkg['nx-migrations']?.packageGroup; + if (declared) { + for (const entry of normalizePackageGroup(declared)) { + set.add(entry.package); + } + } + return set; +} + +/** + * Resolve the workspace's installed `@nrwl/workspace` version (legacy-era + * fallback for `nx migrate --mode=third-party` targeting `< 14.0.0-beta.0`), + * or `null` if it cannot be resolved from the workspace require paths. + */ +export function getInstalledLegacyNrwlWorkspaceVersion(): string | null { try { - return readJsonFile(nxPackageJsonPath).version ?? null; + return ( + readModulePackageJson('@nrwl/workspace', getNxRequirePaths(workspaceRoot)) + .packageJson.version ?? null + ); } catch { return null; } From 05f6352f7b2e386c62247d68f0c573eff1a48f32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leosvel=20P=C3=A9rez=20Espinosa?= Date: Thu, 7 May 2026 11:35:40 +0200 Subject: [PATCH 06/13] fix(core): prevent nx migrate from downgrading dependencies Filter cascade-proposed package updates against the resolved version in node_modules and the package.json specifier floor. Drop entries that would either move the resolved version backward or rewrite a specifier that's already exact at the resolved version (no-op writes). Keep genuine upgrades and narrowing rewrites. The latent shouldApplyPackageUpdate gap surfaced as default behavior in 'nx migrate --mode=third-party''s from-zero walk, where stale historical pins for packages like react would otherwise overwrite manually-upgraded versions in package.json. --- .../src/command-line/migrate/migrate.spec.ts | 119 ++++++++++++++++++ .../nx/src/command-line/migrate/migrate.ts | 70 ++++++++++- 2 files changed, 185 insertions(+), 4 deletions(-) diff --git a/packages/nx/src/command-line/migrate/migrate.spec.ts b/packages/nx/src/command-line/migrate/migrate.spec.ts index 4436c7662282e..e2e403969827b 100644 --- a/packages/nx/src/command-line/migrate/migrate.spec.ts +++ b/packages/nx/src/command-line/migrate/migrate.spec.ts @@ -22,6 +22,7 @@ import { PackageJson } from '../../utils/package-json'; import * as packageMgrUtils from '../../utils/package-manager'; import { + filterDowngradedUpdates, formatCommandFailure, isNpmPeerDepsError, Migrator, @@ -2748,6 +2749,124 @@ describe('Migration', () => { }); }); + describe('filterDowngradedUpdates', () => { + it('should drop updates whose proposed version is lower than resolved', () => { + const result = filterDowngradedUpdates( + { + react: { version: '18.3.1', addToPackageJson: 'dependencies' }, + }, + createPackageJson({ dependencies: { react: '19.0.0' } }), + () => '19.0.0' + ); + + expect(result).toEqual({}); + }); + + it('should keep updates whose proposed version is strictly newer than resolved', () => { + const update = { + version: '20.0.0', + addToPackageJson: 'dependencies' as const, + }; + const result = filterDowngradedUpdates( + { react: update }, + createPackageJson({ dependencies: { react: '19.0.0' } }), + () => '19.0.0' + ); + + expect(result).toEqual({ react: update }); + }); + + it('should keep updates when the package is not installed', () => { + const update = { + version: '1.0.0', + addToPackageJson: 'dependencies' as const, + }; + const result = filterDowngradedUpdates( + { 'new-pkg': update }, + createPackageJson({}), + () => null + ); + + expect(result).toEqual({ 'new-pkg': update }); + }); + + it('should keep narrowing rewrites where the specifier covers a lower version than resolved', () => { + // user has `vite: ^6.0.0`, resolved 6.4.2. Cascade proposes `6.4.2` (exact). + // Specifier floor is 6.0.0 < resolved 6.4.2 → narrowing → keep. + const update = { + version: '6.4.2', + addToPackageJson: 'devDependencies' as const, + }; + const result = filterDowngradedUpdates( + { vite: update }, + createPackageJson({ devDependencies: { vite: '^6.0.0' } }), + () => '6.4.2' + ); + + expect(result).toEqual({ vite: update }); + }); + + it('should drop no-op rewrites where the specifier is already exact at resolved', () => { + // user has `react: 19.0.0` exact, resolved 19.0.0. Cascade proposes `19.0.0`. + // Specifier floor is 19.0.0 === resolved 19.0.0 → no-op → drop. + const result = filterDowngradedUpdates( + { + react: { version: '19.0.0', addToPackageJson: 'dependencies' }, + }, + createPackageJson({ dependencies: { react: '19.0.0' } }), + () => '19.0.0' + ); + + expect(result).toEqual({}); + }); + + it('should drop equal-version rewrites when the package has no specifier in package.json', () => { + // No specifier means there is nothing to narrow; the equal-version write + // would just add a new entry that the user never declared. Drop. + const result = filterDowngradedUpdates( + { + 'orphan-dep': { + version: '1.0.0', + addToPackageJson: 'dependencies', + }, + }, + createPackageJson({}), + () => '1.0.0' + ); + + expect(result).toEqual({}); + }); + + it('should drop downgrades even when the specifier covers the proposed version', () => { + // user has `vite: ^6.0.0`, resolved 6.4.2. Cascade proposes `6.2.0`. + // Specifier floor 6.0.0 covers 6.2.0, but resolved 6.4.2 > proposed. + // Real installed would regress → drop. + const result = filterDowngradedUpdates( + { + vite: { version: '6.2.0', addToPackageJson: 'devDependencies' }, + }, + createPackageJson({ devDependencies: { vite: '^6.0.0' } }), + () => '6.4.2' + ); + + expect(result).toEqual({}); + }); + + it('should compare via normalizeVersion so prerelease tags do not block bumps', () => { + const update = { + version: '1.0.0', + addToPackageJson: 'dependencies' as const, + }; + const result = filterDowngradedUpdates( + { pkg: update }, + createPackageJson({ dependencies: { pkg: '1.0.0-beta-next.2' } }), + () => '1.0.0-beta-next.2' + ); + + expect(result).toEqual({ pkg: update }); + }); + }); + describe('isNpmPeerDepsError', () => { it('should detect the npm 7-9 ERESOLVE code line', () => { const stderr = [ diff --git a/packages/nx/src/command-line/migrate/migrate.ts b/packages/nx/src/command-line/migrate/migrate.ts index d38eea5d42d47..f63265a9f65a4 100644 --- a/packages/nx/src/command-line/migrate/migrate.ts +++ b/packages/nx/src/command-line/migrate/migrate.ts @@ -13,6 +13,7 @@ import { lt, lte, major, + minVersion, parse, satisfies, valid, @@ -1668,6 +1669,51 @@ async function createMigrationsFile( await writeFormattedJsonFile(join(root, 'migrations.json'), { migrations }); } +/** + * Drop entries from `packageUpdates` that would either downgrade the package + * (move resolved version backward) or rewrite a specifier that is already + * exactly pinned to the resolved version (a no-op write). Keep genuine + * upgrades and narrowing rewrites where the user's range covers a version + * lower than what's resolved (the migrator's traditional "lock to recommended + * exact pin" behavior). + */ +export function filterDowngradedUpdates( + packageUpdates: Record, + packageJson: PackageJson | null, + getInstalledVersion: (packageName: string) => string | null +): Record { + const result: Record = {}; + for (const [name, update] of Object.entries(packageUpdates)) { + const resolved = getInstalledVersion(name); + if (!resolved) { + // Not installed; let downstream logic decide whether to add it. + result[name] = update; + continue; + } + const proposed = normalizeVersion(update.version); + const resolvedNorm = normalizeVersion(resolved); + if (gt(proposed, resolvedNorm)) { + result[name] = update; + continue; + } + if (lt(proposed, resolvedNorm)) { + continue; + } + // proposed === resolved: keep when narrowing a looser specifier to an + // exact pin; drop when the specifier is already exact at resolved. + const specifier = + packageJson?.dependencies?.[name] ?? packageJson?.devDependencies?.[name]; + if (!specifier) { + continue; + } + const floor = minVersion(specifier); + if (floor && lt(floor.version, resolvedNorm)) { + result[name] = update; + } + } + return result; +} + async function updatePackageJson( root: string, updatedPackages: Record @@ -1897,10 +1943,13 @@ async function generateMigrationsJsonAndUpdatePackageJson( ); } + const installedPackageVersions = + createInstalledPackageVersionsResolver(root); + const migrator = new Migrator({ packageJson: originalPackageJson, nxInstallation: originalNxJson.installation, - getInstalledPackageVersion: createInstalledPackageVersionsResolver(root), + getInstalledPackageVersion: installedPackageVersions, fetch, from: fromOverrides, to: opts.to, @@ -1913,15 +1962,28 @@ async function generateMigrationsJsonAndUpdatePackageJson( const { migrations, packageUpdates, minVersionWithSkippedUpdates } = await migrator.migrate(walkedTargetPackage, opts.targetVersion); - const wrotePackageJson = await updatePackageJson(root, packageUpdates); + // The cascade collects packageJsonUpdates entries against the cascade + // root's installed version, but inner per-package pins are only gated + // against the in-flight cascade tally — not against each inner package's + // installed version. A from-zero walk (e.g. `--mode=third-party`) can + // surface a stale historical pin that would write a lower version than + // the user already has. Drop those before writing; nx migrate is + // forward-only, never a downgrade. + const writableUpdates = filterDowngradedUpdates( + packageUpdates, + originalPackageJson, + installedPackageVersions + ); + + const wrotePackageJson = await updatePackageJson(root, writableUpdates); const wroteNxJsonInstallation = await updateInstallationDetails( root, - packageUpdates + writableUpdates ); if (migrations.length > 0) { await createMigrationsFile(root, [ - ...addSplitConfigurationMigrationIfAvailable(from, packageUpdates), + ...addSplitConfigurationMigrationIfAvailable(from, writableUpdates), ...migrations, ] as any); } From f32dc14f3b1fe6097f9b9727fa6ba3b8c080a0f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leosvel=20P=C3=A9rez=20Espinosa?= Date: Thu, 7 May 2026 14:51:54 +0200 Subject: [PATCH 07/13] feat(core): prompt for stepwise migration on multi-major nx jumps When 'nx migrate' would cross more than one Nx major boundary, surface the documented recommendation to update one major version at a time. In an interactive terminal with an inferred target, prompt the user to pick a stepwise target (latest in current major, latest in next major, or proceed directly). In non-interactive sessions or when the target is typed explicitly, emit a warning and proceed. Override per-invocation with --accept-multi-major-update or shell-wide with NX_ACCEPT_MULTI_MAJOR_UPDATE=true. Skipped for --mode=third-party (target is bounded by installed) and for legacy-installed workspaces. --- .../command-line/migrate/command-object.ts | 6 + .../src/command-line/migrate/migrate.spec.ts | 274 ++++++++++++++++++ .../nx/src/command-line/migrate/migrate.ts | 258 ++++++++++++++--- 3 files changed, 494 insertions(+), 44 deletions(-) diff --git a/packages/nx/src/command-line/migrate/command-object.ts b/packages/nx/src/command-line/migrate/command-object.ts index e67a2578ef475..05cc2a5f3a7e7 100644 --- a/packages/nx/src/command-line/migrate/command-object.ts +++ b/packages/nx/src/command-line/migrate/command-object.ts @@ -90,6 +90,12 @@ function withMigrationOptions(yargs: Argv) { type: 'string', choices: ['first-party', 'third-party', 'all'], }) + .option('acceptMultiMajorUpdate', { + describe: + 'Skip the multi-major migration prompt/warning and migrate directly to the target version even when it crosses more than one major boundary. The recommended process is to update one major version at a time. Equivalent env var: NX_ACCEPT_MULTI_MAJOR_UPDATE=true.', + type: 'boolean', + default: false, + }) .check( ({ createCommits, diff --git a/packages/nx/src/command-line/migrate/migrate.spec.ts b/packages/nx/src/command-line/migrate/migrate.spec.ts index e2e403969827b..98aa832b3656f 100644 --- a/packages/nx/src/command-line/migrate/migrate.spec.ts +++ b/packages/nx/src/command-line/migrate/migrate.spec.ts @@ -2914,4 +2914,278 @@ describe('Migration', () => { expect(isNpmPeerDepsError('some PREERESOLVED cache entry')).toBe(false); }); }); + + describe('multi-major migration prompt', () => { + let originalCi: string | undefined; + const originalTtyDescriptor = Object.getOwnPropertyDescriptor( + process.stdin, + 'isTTY' + ); + let originalAccept: string | undefined; + + beforeEach(() => { + originalCi = process.env.CI; + originalAccept = process.env.NX_ACCEPT_MULTI_MAJOR_UPDATE; + mockGetInstalledNxVersion.mockReturnValue('21.0.0'); + mockGetInstalledNxPackageGroup.mockReturnValue( + new Set(['nx', '@nx/js', '@nx/workspace']) + ); + mockGetInstalledLegacyNrwlWorkspaceVersion.mockReturnValue(null); + delete process.env.CI; + delete process.env.NX_ACCEPT_MULTI_MAJOR_UPDATE; + }); + + afterEach(() => { + mockGetInstalledNxVersion.mockReset(); + mockGetInstalledNxPackageGroup.mockReset(); + mockGetInstalledLegacyNrwlWorkspaceVersion.mockReset(); + mockPrompt.mockReset(); + if (originalCi === undefined) { + delete process.env.CI; + } else { + process.env.CI = originalCi; + } + if (originalAccept === undefined) { + delete process.env.NX_ACCEPT_MULTI_MAJOR_UPDATE; + } else { + process.env.NX_ACCEPT_MULTI_MAJOR_UPDATE = originalAccept; + } + if (originalTtyDescriptor) { + Object.defineProperty(process.stdin, 'isTTY', originalTtyDescriptor); + } else { + delete (process.stdin as { isTTY?: boolean }).isTTY; + } + jest.restoreAllMocks(); + }); + + function setTty(value: boolean) { + Object.defineProperty(process.stdin, 'isTTY', { + value, + configurable: true, + }); + } + + function mockRegistry(map: { latest?: string } & Record) { + jest + .spyOn(packageMgrUtils, 'resolvePackageVersionUsingRegistry') + .mockImplementation((_pkg, version) => { + const v = String(version); + if (v in map) return Promise.resolve(map[v]!); + const match = v.match(/^\^(\d+)\.0\.0$/); + if (match && map[match[1]]) return Promise.resolve(map[match[1]]!); + if (match) return Promise.reject(new Error('none')); + return Promise.resolve(v); + }); + } + + function spyWarn() { + return jest + .spyOn(require('../../utils/output').output, 'warn') + .mockImplementation(() => {}); + } + + it('should prompt and replace targetVersion with the chosen value (inferred target, TTY)', async () => { + setTty(true); + mockRegistry({ + latest: '23.1.0', + next: '23.1.0', + canary: '23.1.0', + '21': '21.5.3', + '22': '22.5.3', + }); + mockPrompt.mockResolvedValue({ chosen: '21.5.3' }); + + const r = await parseMigrationsOptions({ + packageAndVersion: 'next', + mode: 'all', + }); + + expect(mockPrompt).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'select', + name: 'chosen', + message: 'How would you like to proceed?', + choices: expect.arrayContaining([ + expect.objectContaining({ name: '21.5.3' }), + expect.objectContaining({ name: '22.5.3' }), + expect.objectContaining({ name: '23.1.0' }), + ]), + }) + ); + expect(r).toMatchObject({ + type: 'generateMigrations', + targetVersion: '21.5.3', + }); + }); + + it('should not include the current-major option when installed is already at latest of current major', async () => { + setTty(true); + mockGetInstalledNxVersion.mockReturnValue('21.5.3'); + mockRegistry({ + latest: '23.1.0', + '21': '21.5.3', + '22': '22.5.3', + }); + mockPrompt.mockResolvedValue({ chosen: '22.5.3' }); + + await parseMigrationsOptions({ + packageAndVersion: 'latest', + mode: 'all', + }); + + const promptArgs = mockPrompt.mock.calls[0][0]; + const choices = promptArgs.choices as { name: string }[]; + expect(choices.map((c) => c.name)).toEqual(['22.5.3', '23.1.0']); + }); + + it('should warn (not prompt) when target was explicitly typed as numeric semver', async () => { + setTty(true); + const warnSpy = spyWarn(); + + const r = await parseMigrationsOptions({ + packageAndVersion: 'nx@23.1.0', + mode: 'all', + }); + + expect(mockPrompt).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalled(); + expect(r).toMatchObject({ targetVersion: '23.1.0' }); + }); + + it('should warn (not prompt) in non-TTY environments', async () => { + setTty(false); + mockRegistry({ latest: '23.1.0' }); + const warnSpy = spyWarn(); + + const r = await parseMigrationsOptions({ + packageAndVersion: 'latest', + mode: 'all', + }); + + expect(mockPrompt).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalled(); + expect(r).toMatchObject({ targetVersion: '23.1.0' }); + }); + + it('should not prompt or warn when --accept-multi-major-update flag is set', async () => { + setTty(true); + mockRegistry({ latest: '23.1.0' }); + const warnSpy = spyWarn(); + + const r = await parseMigrationsOptions({ + packageAndVersion: 'latest', + mode: 'all', + acceptMultiMajorUpdate: true, + }); + + expect(mockPrompt).not.toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); + expect(r).toMatchObject({ targetVersion: '23.1.0' }); + }); + + it('should not prompt or warn when NX_ACCEPT_MULTI_MAJOR_UPDATE env var is set', async () => { + setTty(true); + process.env.NX_ACCEPT_MULTI_MAJOR_UPDATE = 'true'; + mockRegistry({ latest: '23.1.0' }); + const warnSpy = spyWarn(); + + const r = await parseMigrationsOptions({ + packageAndVersion: 'latest', + mode: 'all', + }); + + expect(mockPrompt).not.toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); + expect(r).toMatchObject({ targetVersion: '23.1.0' }); + }); + + it('should not prompt or warn when delta is exactly 1 major', async () => { + setTty(true); + mockRegistry({ latest: '22.5.3' }); + const warnSpy = spyWarn(); + + const r = await parseMigrationsOptions({ + packageAndVersion: 'latest', + mode: 'all', + }); + + expect(mockPrompt).not.toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); + expect(r).toMatchObject({ targetVersion: '22.5.3' }); + }); + + it('should not prompt or warn when installed is legacy-era (< 14)', async () => { + setTty(true); + mockGetInstalledNxVersion.mockReturnValue('13.10.0'); + mockRegistry({ latest: '23.1.0' }); + const warnSpy = spyWarn(); + + const r = await parseMigrationsOptions({ + packageAndVersion: 'latest', + mode: 'all', + }); + + expect(mockPrompt).not.toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); + expect(r).toMatchObject({ targetVersion: '23.1.0' }); + }); + + it('should not prompt or warn for --mode=third-party', async () => { + setTty(true); + const warnSpy = spyWarn(); + + const r = await parseMigrationsOptions({ mode: 'third-party' }); + + expect(mockPrompt).not.toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); + expect(r).toMatchObject({ mode: 'third-party' }); + }); + + it.each(['nx', '@nx/workspace'])( + 'should resolve bare-package-name positional `%s` and fire the prompt', + async (positional) => { + // Regression: `nx migrate nx` and `nx migrate @nx/workspace` previously + // bypassed the multi-major check because parseTargetPackageAndVersion + // leaves targetVersion as the literal 'latest' for bare-package-name + // input. + setTty(true); + mockRegistry({ + latest: '23.1.0', + '21': '21.5.3', + '22': '22.5.3', + }); + mockPrompt.mockResolvedValue({ chosen: '21.5.3' }); + + const r = await parseMigrationsOptions({ + packageAndVersion: positional, + mode: 'all', + }); + + expect(mockPrompt).toHaveBeenCalled(); + expect(r).toMatchObject({ + type: 'generateMigrations', + targetVersion: '21.5.3', + }); + } + ); + + it('should warn (not prompt) when next-major lookup fails and current-major option is unavailable', async () => { + setTty(true); + // Installed at latest of its major → current-major option dropped. + // Next-major lookup fails → next-major option dropped. Both + // unavailable → fall back to warn. + mockGetInstalledNxVersion.mockReturnValue('21.5.3'); + mockRegistry({ latest: '23.1.0', '21': '21.5.3' }); + const warnSpy = spyWarn(); + + const r = await parseMigrationsOptions({ + packageAndVersion: 'latest', + mode: 'all', + }); + + expect(mockPrompt).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalled(); + expect(r).toMatchObject({ targetVersion: '23.1.0' }); + }); + }); }); diff --git a/packages/nx/src/command-line/migrate/migrate.ts b/packages/nx/src/command-line/migrate/migrate.ts index f63265a9f65a4..8d450ba271ff2 100644 --- a/packages/nx/src/command-line/migrate/migrate.ts +++ b/packages/nx/src/command-line/migrate/migrate.ts @@ -926,6 +926,163 @@ export function resolveCanonicalNxPackage( : 'nx'; } +const DIST_TAGS = ['latest', 'next', 'canary'] as const; +type DistTag = (typeof DIST_TAGS)[number]; + +const STEPWISE_DOC_URL = + 'https://nx.dev/docs/guides/tips-n-tricks/advanced-update#one-major-version-at-a-time-small-steps'; +const ACCEPT_MULTI_MAJOR_UPDATE_ENV = 'NX_ACCEPT_MULTI_MAJOR_UPDATE'; + +// Caret-major (`^X.0.0`) excludes prereleases per semver, so +// `resolvePackageVersionUsingRegistry` returns the highest stable in major X. +async function resolveLatestStableInMajor( + packageName: string, + majorVersion: number +): Promise { + try { + const resolved = await resolvePackageVersionUsingRegistry( + packageName, + `^${majorVersion}.0.0` + ); + return valid(resolved) ? resolved : null; + } catch { + return null; + } +} + +const multiMajorHeader = (pkg: string, installed: string, target: string) => + `Migrating across multiple major versions: ${pkg}@${installed} → ${pkg}@${target}.`; + +const multiMajorBodyLines = [ + `The recommended process is to update one major version at a time, in small steps.`, + `See ${STEPWISE_DOC_URL}`, +]; + +function warnMultiMajorMigration( + targetPackage: string, + installed: string, + target: string +): void { + output.warn({ + title: multiMajorHeader(targetPackage, installed, target), + bodyLines: [ + ...multiMajorBodyLines, + `Pass --accept-multi-major-update or set ${ACCEPT_MULTI_MAJOR_UPDATE_ENV}=true to silence this warning.`, + ], + }); +} + +// Returns the chosen target version. Caller replaces `targetVersion` with it. +// At least one of `latestInCurrent`/`latestInNext` must be present. +async function promptMultiMajorMigration(args: { + targetPackage: string; + installed: string; + target: string; + latestInCurrent: string | null; + latestInNext: string | null; +}): Promise { + const choices: { name: string; message: string }[] = []; + if (args.latestInCurrent) { + choices.push({ + name: args.latestInCurrent, + message: `Migrate to ${args.targetPackage}@${args.latestInCurrent} (latest in current major)`, + }); + } + if (args.latestInNext) { + choices.push({ + name: args.latestInNext, + message: `Migrate to ${args.targetPackage}@${args.latestInNext} (next major)`, + }); + } + choices.push({ + name: args.target, + message: `Migrate directly to ${args.targetPackage}@${args.target}`, + }); + output.log({ + title: multiMajorHeader(args.targetPackage, args.installed, args.target), + bodyLines: multiMajorBodyLines, + }); + const { chosen } = await prompt<{ chosen: string }>({ + type: 'select', + name: 'chosen', + message: 'How would you like to proceed?', + choices, + }); + return chosen; +} + +// Flag wins over env var; both default to off. +function isMultiMajorUpdateAccepted(options: { + acceptMultiMajorUpdate?: boolean; +}): boolean { + if (options.acceptMultiMajorUpdate === true) return true; + const env = process.env[ACCEPT_MULTI_MAJOR_UPDATE_ENV]; + return env === 'true' || env === '1'; +} + +async function maybePromptOrWarnMultiMajorMigration(args: { + mode: 'first-party' | 'third-party' | 'all'; + options: { acceptMultiMajorUpdate?: boolean }; + positional: string | undefined; + targetPackage: string; + targetVersion: string; + installedNxVersion: string | null | undefined; + targetWasInferred: boolean; +}): Promise { + const { mode, options, targetPackage, targetWasInferred } = args; + let { targetVersion } = args; + if (mode === 'third-party') return targetVersion; + if (isMultiMajorUpdateAccepted(options)) return targetVersion; + if (!isNxEquivalentTarget(targetPackage, targetVersion)) return targetVersion; + // Bare-package-name positionals (e.g. `nx migrate nx`, `nx migrate + // @nx/workspace`) leave `targetVersion` as the literal `'latest'` because + // `parseTargetPackageAndVersion` only resolves dist-tags via the registry + // when they appear standalone or after `@`. Resolve here so the remaining + // semver gates (and the subsequent walk) see a concrete version. + if (DIST_TAGS.includes(targetVersion as DistTag)) { + try { + targetVersion = await normalizeVersionWithTagCheck( + targetPackage, + targetVersion + ); + } catch { + return targetVersion; + } + } + if (!valid(targetVersion) || lt(targetVersion, '14.0.0-beta.0')) { + return targetVersion; + } + const installed = args.installedNxVersion ?? getInstalledNxVersion(); + if (!installed || !valid(installed)) return targetVersion; + // Legacy-era installs are out of scope for the multi-major check. + if (lt(installed, '14.0.0-beta.0')) return targetVersion; + const installedMajor = major(installed); + if (major(targetVersion) - installedMajor < 2) return targetVersion; + + const interactive = !!process.stdin.isTTY && !isCI(); + if (interactive && targetWasInferred) { + const [latestInCurrent, latestInNext] = await Promise.all([ + resolveLatestStableInMajor(targetPackage, installedMajor), + resolveLatestStableInMajor(targetPackage, installedMajor + 1), + ]); + const showCurrent = + latestInCurrent && gt(latestInCurrent, installed) + ? latestInCurrent + : null; + if (showCurrent || latestInNext) { + return promptMultiMajorMigration({ + targetPackage, + installed, + target: targetVersion, + latestInCurrent: showCurrent, + latestInNext, + }); + } + } + warnMultiMajorMigration(targetPackage, installed, targetVersion); + return targetVersion; +} + export async function resolveMode( mode: 'first-party' | 'third-party' | 'all' | undefined, targetPackage: string, @@ -1003,9 +1160,11 @@ async function versionOverrides(overrides: string, param: string) { return res; } -async function parseTargetPackageAndVersion( - args: string -): Promise<{ targetPackage: string; targetVersion: string }> { +async function parseTargetPackageAndVersion(args: string): Promise<{ + targetPackage: string; + targetVersion: string; + wasInferred: boolean; +}> { if (!args) { throw new Error( `Provide the correct package name and version. E.g., my-package@9.0.0.` @@ -1015,52 +1174,48 @@ async function parseTargetPackageAndVersion( if (args.indexOf('@') > -1) { const i = args.lastIndexOf('@'); if (i === 0) { - const targetPackage = args.trim(); - const targetVersion = 'latest'; - return { targetPackage, targetVersion }; - } else { - const targetPackage = args.substring(0, i); - const maybeVersion = args.substring(i + 1); - if (!targetPackage || !maybeVersion) { - throw new Error( - `Provide the correct package name and version. E.g., my-package@9.0.0.` - ); - } - const targetVersion = await normalizeVersionWithTagCheck( - targetPackage, - maybeVersion - ); - return { targetPackage, targetVersion }; - } - } else { - if ( - args === 'latest' || - args === 'next' || - args === 'canary' || - valid(args) || - args.match(/^\d+(?:\.\d+)?(?:\.\d+)?$/) - ) { - // Passing `nx` here may seem wrong, but nx and @nrwl/workspace are synced in version. - // We could duplicate the ternary below, but its not necessary since they are equivalent - // on the registry - const targetVersion = await normalizeVersionWithTagCheck('nx', args); - const targetPackage = - !['latest', 'next', 'canary'].includes(args) && - lt(targetVersion, '14.0.0-beta.0') - ? '@nrwl/workspace' - : 'nx'; - - return { - targetPackage, - targetVersion, - }; - } else { return { - targetPackage: args, + targetPackage: args.trim(), targetVersion: 'latest', + wasInferred: true, }; } + const targetPackage = args.substring(0, i); + const maybeVersion = args.substring(i + 1); + if (!targetPackage || !maybeVersion) { + throw new Error( + `Provide the correct package name and version. E.g., my-package@9.0.0.` + ); + } + const targetVersion = await normalizeVersionWithTagCheck( + targetPackage, + maybeVersion + ); + return { + targetPackage, + targetVersion, + wasInferred: DIST_TAGS.includes(maybeVersion as DistTag), + }; } + + if ( + DIST_TAGS.includes(args as DistTag) || + valid(args) || + args.match(/^\d+(?:\.\d+)?(?:\.\d+)?$/) + ) { + // Passing `nx` here may seem wrong, but nx and @nrwl/workspace are synced in version. + // We could duplicate the ternary below, but its not necessary since they are equivalent + // on the registry + const targetVersion = await normalizeVersionWithTagCheck('nx', args); + const wasInferred = DIST_TAGS.includes(args as DistTag); + const targetPackage = + !wasInferred && lt(targetVersion, '14.0.0-beta.0') + ? '@nrwl/workspace' + : 'nx'; + return { targetPackage, targetVersion, wasInferred }; + } + + return { targetPackage: args, targetVersion: 'latest', wasInferred: true }; } type GenerateMigrations = { @@ -1126,10 +1281,12 @@ export async function parseMigrationsOptions(options: { const positional = options['packageAndVersion'] as string | undefined; let targetPackage: string | undefined; let targetVersion: string | undefined; + let targetWasInferred = !positional; if (positional) { const parsed = await parseTargetPackageAndVersion(positional); targetPackage = normalizeSlashes(parsed.targetPackage); targetVersion = parsed.targetVersion; + targetWasInferred = parsed.wasInferred; } // Resolve mode before defaulting target so the default can depend on the @@ -1182,6 +1339,19 @@ export async function parseMigrationsOptions(options: { ); } + // Spec §10: prompt or warn when crossing more than one major boundary. + // Each major's metadata may have pruned migrations from much-older versions, + // so jumping multiple majors at once can silently skip migrations. + targetVersion = await maybePromptOrWarnMultiMajorMigration({ + mode, + options, + positional, + targetPackage: targetPackage!, + targetVersion: targetVersion!, + installedNxVersion, + targetWasInferred, + }); + if (mode === 'third-party') { const canonical = resolveCanonicalNxPackage(targetVersion!); const isLegacy = canonical === '@nrwl/workspace'; From 3452c4a19da725523741c43c5318e5ec9e8ee25a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leosvel=20P=C3=A9rez=20Espinosa?= Date: Thu, 7 May 2026 17:57:08 +0200 Subject: [PATCH 08/13] chore(core): tidy nx migrate --mode helpers and tests Drop unused args from multi-major helper, fix packageGroup doc, simplify bare default branch, and consolidate redundant third-party anchor tests. No behavior change. --- .../src/command-line/migrate/migrate.spec.ts | 129 +++++++++--------- .../nx/src/command-line/migrate/migrate.ts | 11 +- packages/nx/src/utils/installed-nx-version.ts | 4 +- 3 files changed, 69 insertions(+), 75 deletions(-) diff --git a/packages/nx/src/command-line/migrate/migrate.spec.ts b/packages/nx/src/command-line/migrate/migrate.spec.ts index 98aa832b3656f..47ce662a34f56 100644 --- a/packages/nx/src/command-line/migrate/migrate.spec.ts +++ b/packages/nx/src/command-line/migrate/migrate.spec.ts @@ -2281,16 +2281,69 @@ describe('Migration', () => { ); }); - it('should default bare --mode=third-party to nx@', async () => { - mockGetInstalledNxVersion.mockReturnValue('22.5.0'); - const r = await parseMigrationsOptions({ mode: 'third-party' }); - expect(r).toMatchObject({ - type: 'generateMigrations', - targetPackage: 'nx', - targetVersion: '22.5.0', - mode: 'third-party', - }); - }); + it.each([ + { + desc: 'bare invocation, modern nx installed', + positional: undefined, + installedNx: '22.5.0', + installedLegacy: null, + expected: { targetPackage: 'nx', targetVersion: '22.5.0' }, + }, + { + desc: 'bare invocation, only legacy @nrwl/workspace installed', + positional: undefined, + installedNx: null, + installedLegacy: '13.5.0', + expected: { + targetPackage: '@nrwl/workspace', + targetVersion: '13.5.0', + }, + }, + { + desc: 'bare invocation, installed nx is legacy (<14)', + positional: undefined, + installedNx: '13.5.0', + installedLegacy: null, + expected: { + targetPackage: '@nrwl/workspace', + targetVersion: '13.5.0', + }, + }, + { + desc: 'bare-package-name positional `nx`, modern nx installed', + positional: 'nx', + installedNx: '22.5.0', + installedLegacy: null, + expected: { targetPackage: 'nx', targetVersion: '22.5.0' }, + }, + { + desc: 'bare-package-name positional `nx`, installed nx is legacy (<14)', + positional: 'nx', + installedNx: '13.5.0', + installedLegacy: null, + expected: { + targetPackage: '@nrwl/workspace', + targetVersion: '13.5.0', + }, + }, + ])( + 'should anchor --mode=third-party to installed canonical: $desc', + async ({ positional, installedNx, installedLegacy, expected }) => { + mockGetInstalledNxVersion.mockReturnValue(installedNx); + mockGetInstalledLegacyNrwlWorkspaceVersion.mockReturnValue( + installedLegacy + ); + const r = await parseMigrationsOptions({ + ...(positional ? { packageAndVersion: positional } : {}), + mode: 'third-party', + }); + expect(r).toMatchObject({ + type: 'generateMigrations', + mode: 'third-party', + ...expected, + }); + } + ); it('should reject --mode=third-party when nx is not installed', async () => { mockGetInstalledNxVersion.mockReturnValue(null); @@ -2301,29 +2354,6 @@ describe('Migration', () => { ); }); - it('should anchor bare --mode=third-party to legacy @nrwl/workspace canonical when only legacy is installed', async () => { - mockGetInstalledNxVersion.mockReturnValue(null); - mockGetInstalledLegacyNrwlWorkspaceVersion.mockReturnValue('13.5.0'); - const r = await parseMigrationsOptions({ mode: 'third-party' }); - expect(r).toMatchObject({ - type: 'generateMigrations', - targetPackage: '@nrwl/workspace', - targetVersion: '13.5.0', - mode: 'third-party', - }); - }); - - it('should anchor bare --mode=third-party to @nrwl/workspace canonical when installed nx is legacy (<14)', async () => { - mockGetInstalledNxVersion.mockReturnValue('13.5.0'); - const r = await parseMigrationsOptions({ mode: 'third-party' }); - expect(r).toMatchObject({ - type: 'generateMigrations', - targetPackage: '@nrwl/workspace', - targetVersion: '13.5.0', - mode: 'third-party', - }); - }); - it('should reject --mode=third-party when target is higher than installed', async () => { mockGetInstalledNxVersion.mockReturnValue('22.0.0'); await expect(() => @@ -2426,34 +2456,6 @@ describe('Migration', () => { }); }); - it('should anchor --mode=third-party to installed when target has no version', async () => { - mockGetInstalledNxVersion.mockReturnValue('22.5.0'); - const r = await parseMigrationsOptions({ - packageAndVersion: 'nx', - mode: 'third-party', - }); - expect(r).toMatchObject({ - type: 'generateMigrations', - targetPackage: 'nx', - targetVersion: '22.5.0', - mode: 'third-party', - }); - }); - - it('should anchor bare-package-name --mode=third-party to legacy canonical when installed nx is <14', async () => { - mockGetInstalledNxVersion.mockReturnValue('13.5.0'); - const r = await parseMigrationsOptions({ - packageAndVersion: 'nx', - mode: 'third-party', - }); - expect(r).toMatchObject({ - type: 'generateMigrations', - targetPackage: '@nrwl/workspace', - targetVersion: '13.5.0', - mode: 'third-party', - }); - }); - it('should reject --mode=third-party for legacy target when @nrwl/workspace is not installed', async () => { mockGetInstalledLegacyNrwlWorkspaceVersion.mockReturnValue(null); await expect(() => @@ -2738,12 +2740,9 @@ describe('Migration', () => { describe('resolveCanonicalNxPackage', () => { it.each([ - ['22.0.0', 'nx'], - ['14.0.0-beta.0', 'nx'], ['14.0.0', 'nx'], - ['13.999.999', '@nrwl/workspace'], + ['14.0.0-beta.0', 'nx'], ['13.0.0', '@nrwl/workspace'], - ['8.12.0', '@nrwl/workspace'], ] as const)('should resolve %s to %s', (version, expected) => { expect(resolveCanonicalNxPackage(version)).toBe(expected); }); diff --git a/packages/nx/src/command-line/migrate/migrate.ts b/packages/nx/src/command-line/migrate/migrate.ts index 8d450ba271ff2..e9e1f38a77481 100644 --- a/packages/nx/src/command-line/migrate/migrate.ts +++ b/packages/nx/src/command-line/migrate/migrate.ts @@ -1023,10 +1023,8 @@ function isMultiMajorUpdateAccepted(options: { async function maybePromptOrWarnMultiMajorMigration(args: { mode: 'first-party' | 'third-party' | 'all'; options: { acceptMultiMajorUpdate?: boolean }; - positional: string | undefined; targetPackage: string; targetVersion: string; - installedNxVersion: string | null | undefined; targetWasInferred: boolean; }): Promise { const { mode, options, targetPackage, targetWasInferred } = args; @@ -1052,7 +1050,7 @@ async function maybePromptOrWarnMultiMajorMigration(args: { if (!valid(targetVersion) || lt(targetVersion, '14.0.0-beta.0')) { return targetVersion; } - const installed = args.installedNxVersion ?? getInstalledNxVersion(); + const installed = getInstalledNxVersion(); if (!installed || !valid(installed)) return targetVersion; // Legacy-era installs are out of scope for the multi-major check. if (lt(installed, '14.0.0-beta.0')) return targetVersion; @@ -1322,9 +1320,8 @@ export async function parseMigrationsOptions(options: { targetPackage = installed.canonical; targetVersion = installed.version; } else if (!positional) { - const parsed = await parseTargetPackageAndVersion('latest'); - targetPackage = normalizeSlashes(parsed.targetPackage); - targetVersion = parsed.targetVersion; + targetPackage = 'nx'; + targetVersion = await normalizeVersionWithTagCheck('nx', 'latest'); } if (options.mode && !isNxEquivalentTarget(targetPackage!, targetVersion!)) { @@ -1345,10 +1342,8 @@ export async function parseMigrationsOptions(options: { targetVersion = await maybePromptOrWarnMultiMajorMigration({ mode, options, - positional, targetPackage: targetPackage!, targetVersion: targetVersion!, - installedNxVersion, targetWasInferred, }); diff --git a/packages/nx/src/utils/installed-nx-version.ts b/packages/nx/src/utils/installed-nx-version.ts index 17a2ae9099df2..d421a8171259d 100644 --- a/packages/nx/src/utils/installed-nx-version.ts +++ b/packages/nx/src/utils/installed-nx-version.ts @@ -47,8 +47,8 @@ export function getInstalledNxVersion(): string | null { /** * Return the package names declared in the installed `nx` package's * `ng-update.packageGroup` (or `nx-migrations.packageGroup`), plus `'nx'` - * itself. Returns an empty set when nx isn't installed or the metadata is - * missing — callers should handle that as "no first-party set known". + * itself. Returns a set containing only `'nx'` when nx isn't installed or + * the metadata is missing. */ export function getInstalledNxPackageGroup(): Set { const set = new Set(['nx']); From eb9adbd303df6cab96624f51a71937283ff2ae34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leosvel=20P=C3=A9rez=20Espinosa?= Date: Fri, 8 May 2026 11:09:50 +0200 Subject: [PATCH 09/13] chore(core): extract nx migrate helpers into sibling modules Split helpers introduced/extended by the recent --mode work out of the 3k-line migrate.ts into focused sibling files. Public-export surface (Migrator, parseMigrationsOptions, executeMigrations, normalizeVersion, filterDowngradedUpdates, etc.) is preserved, including the import paths used by migrate-ui-api, repair, and migrate.spec. - version-utils.ts: shared atoms with no migrate-specific deps (DIST_TAGS, normalizeVersion, normalizeVersionWithTagCheck, isNxEquivalentTarget). Imported by the other three sibling files; none of them import from migrate.ts (acyclic graph). - multi-major.ts: stepwise-migration prompt/warn helpers, exposing only maybePromptOrWarnMultiMajorMigration to migrate.ts. - update-filters.ts: filterDowngradedUpdates, re-exported from migrate.ts so existing test/external imports keep working. - parseMigrationsOptions split into three named private helpers (assertThirdPartyModeFlagCompatibility, resolveTargetAndMode, assertThirdPartyTargetBounds). Public signature unchanged. --- .../nx/src/command-line/migrate/migrate.ts | 465 +++++------------- .../src/command-line/migrate/multi-major.ts | 164 ++++++ .../command-line/migrate/update-filters.ts | 49 ++ .../src/command-line/migrate/version-utils.ts | 66 +++ 4 files changed, 413 insertions(+), 331 deletions(-) create mode 100644 packages/nx/src/command-line/migrate/multi-major.ts create mode 100644 packages/nx/src/command-line/migrate/update-filters.ts create mode 100644 packages/nx/src/command-line/migrate/version-utils.ts diff --git a/packages/nx/src/command-line/migrate/migrate.ts b/packages/nx/src/command-line/migrate/migrate.ts index e9e1f38a77481..23cba2e7dbc58 100644 --- a/packages/nx/src/command-line/migrate/migrate.ts +++ b/packages/nx/src/command-line/migrate/migrate.ts @@ -13,8 +13,6 @@ import { lt, lte, major, - minVersion, - parse, satisfies, valid, } from 'semver'; @@ -93,6 +91,17 @@ import { getNxPackageGroup, } from '../../utils/provenance'; import { type CatalogManager, getCatalogManager } from '../../utils/catalog'; +import { maybePromptOrWarnMultiMajorMigration } from './multi-major'; +import { filterDowngradedUpdates } from './update-filters'; +import { + DIST_TAGS, + type DistTag, + isNxEquivalentTarget, + normalizeVersion, + normalizeVersionWithTagCheck, +} from './version-utils'; + +export { normalizeVersion }; export interface ResolvedMigrationConfiguration extends MigrationsJson { packageGroup?: ArrayPackageGroup; @@ -151,40 +160,6 @@ function runOrReturnExitCode(run: () => void): number { } } -export function normalizeVersion(version: string) { - const [semver, ...prereleaseTagParts] = version.split('-'); - // Handle versions like 1.0.0-beta-next.2 - const prereleaseTag = prereleaseTagParts.join('-'); - - const [major, minor, patch] = semver.split('.'); - - const newSemver = `${major || 0}.${minor || 0}.${patch || 0}`; - - const newVersion = prereleaseTag - ? `${newSemver}-${prereleaseTag}` - : newSemver; - - const withoutPatch = `${major || 0}.${minor || 0}.0`; - const withoutPatchAndMinor = `${major || 0}.0.0`; - - const variationsToCheck = [ - newVersion, - newSemver, - withoutPatch, - withoutPatchAndMinor, - ]; - - for (const variation of variationsToCheck) { - try { - if (gt(variation, '0.0.0')) { - return variation; - } - } catch {} - } - - return '0.0.0'; -} - function cleanSemver(version: string) { return clean(version) ?? coerce(version); } @@ -899,18 +874,6 @@ function resolveFirstPartyPackages( return set; } -function isNxEquivalentTarget( - targetPackage: string, - targetVersion: string -): boolean { - // Non-semver values (e.g., the literal `'latest'` sentinel used during bare - // invocation before tag resolution, or in tests) are treated as modern era. - if (valid(targetVersion) && lt(targetVersion, '14.0.0-beta.0')) { - return targetPackage === '@nrwl/workspace'; - } - return targetPackage === 'nx' || targetPackage === '@nx/workspace'; -} - /** * The canonical Nx package for a given target version: `@nrwl/workspace` for * legacy (`< 14.0.0-beta.0`), `nx` otherwise. Non-semver inputs (e.g. the @@ -926,161 +889,6 @@ export function resolveCanonicalNxPackage( : 'nx'; } -const DIST_TAGS = ['latest', 'next', 'canary'] as const; -type DistTag = (typeof DIST_TAGS)[number]; - -const STEPWISE_DOC_URL = - 'https://nx.dev/docs/guides/tips-n-tricks/advanced-update#one-major-version-at-a-time-small-steps'; -const ACCEPT_MULTI_MAJOR_UPDATE_ENV = 'NX_ACCEPT_MULTI_MAJOR_UPDATE'; - -// Caret-major (`^X.0.0`) excludes prereleases per semver, so -// `resolvePackageVersionUsingRegistry` returns the highest stable in major X. -async function resolveLatestStableInMajor( - packageName: string, - majorVersion: number -): Promise { - try { - const resolved = await resolvePackageVersionUsingRegistry( - packageName, - `^${majorVersion}.0.0` - ); - return valid(resolved) ? resolved : null; - } catch { - return null; - } -} - -const multiMajorHeader = (pkg: string, installed: string, target: string) => - `Migrating across multiple major versions: ${pkg}@${installed} → ${pkg}@${target}.`; - -const multiMajorBodyLines = [ - `The recommended process is to update one major version at a time, in small steps.`, - `See ${STEPWISE_DOC_URL}`, -]; - -function warnMultiMajorMigration( - targetPackage: string, - installed: string, - target: string -): void { - output.warn({ - title: multiMajorHeader(targetPackage, installed, target), - bodyLines: [ - ...multiMajorBodyLines, - `Pass --accept-multi-major-update or set ${ACCEPT_MULTI_MAJOR_UPDATE_ENV}=true to silence this warning.`, - ], - }); -} - -// Returns the chosen target version. Caller replaces `targetVersion` with it. -// At least one of `latestInCurrent`/`latestInNext` must be present. -async function promptMultiMajorMigration(args: { - targetPackage: string; - installed: string; - target: string; - latestInCurrent: string | null; - latestInNext: string | null; -}): Promise { - const choices: { name: string; message: string }[] = []; - if (args.latestInCurrent) { - choices.push({ - name: args.latestInCurrent, - message: `Migrate to ${args.targetPackage}@${args.latestInCurrent} (latest in current major)`, - }); - } - if (args.latestInNext) { - choices.push({ - name: args.latestInNext, - message: `Migrate to ${args.targetPackage}@${args.latestInNext} (next major)`, - }); - } - choices.push({ - name: args.target, - message: `Migrate directly to ${args.targetPackage}@${args.target}`, - }); - output.log({ - title: multiMajorHeader(args.targetPackage, args.installed, args.target), - bodyLines: multiMajorBodyLines, - }); - const { chosen } = await prompt<{ chosen: string }>({ - type: 'select', - name: 'chosen', - message: 'How would you like to proceed?', - choices, - }); - return chosen; -} - -// Flag wins over env var; both default to off. -function isMultiMajorUpdateAccepted(options: { - acceptMultiMajorUpdate?: boolean; -}): boolean { - if (options.acceptMultiMajorUpdate === true) return true; - const env = process.env[ACCEPT_MULTI_MAJOR_UPDATE_ENV]; - return env === 'true' || env === '1'; -} - -async function maybePromptOrWarnMultiMajorMigration(args: { - mode: 'first-party' | 'third-party' | 'all'; - options: { acceptMultiMajorUpdate?: boolean }; - targetPackage: string; - targetVersion: string; - targetWasInferred: boolean; -}): Promise { - const { mode, options, targetPackage, targetWasInferred } = args; - let { targetVersion } = args; - if (mode === 'third-party') return targetVersion; - if (isMultiMajorUpdateAccepted(options)) return targetVersion; - if (!isNxEquivalentTarget(targetPackage, targetVersion)) return targetVersion; - // Bare-package-name positionals (e.g. `nx migrate nx`, `nx migrate - // @nx/workspace`) leave `targetVersion` as the literal `'latest'` because - // `parseTargetPackageAndVersion` only resolves dist-tags via the registry - // when they appear standalone or after `@`. Resolve here so the remaining - // semver gates (and the subsequent walk) see a concrete version. - if (DIST_TAGS.includes(targetVersion as DistTag)) { - try { - targetVersion = await normalizeVersionWithTagCheck( - targetPackage, - targetVersion - ); - } catch { - return targetVersion; - } - } - if (!valid(targetVersion) || lt(targetVersion, '14.0.0-beta.0')) { - return targetVersion; - } - const installed = getInstalledNxVersion(); - if (!installed || !valid(installed)) return targetVersion; - // Legacy-era installs are out of scope for the multi-major check. - if (lt(installed, '14.0.0-beta.0')) return targetVersion; - const installedMajor = major(installed); - if (major(targetVersion) - installedMajor < 2) return targetVersion; - - const interactive = !!process.stdin.isTTY && !isCI(); - if (interactive && targetWasInferred) { - const [latestInCurrent, latestInNext] = await Promise.all([ - resolveLatestStableInMajor(targetPackage, installedMajor), - resolveLatestStableInMajor(targetPackage, installedMajor + 1), - ]); - const showCurrent = - latestInCurrent && gt(latestInCurrent, installed) - ? latestInCurrent - : null; - if (showCurrent || latestInNext) { - return promptMultiMajorMigration({ - targetPackage, - installed, - target: targetVersion, - latestInCurrent: showCurrent, - latestInNext, - }); - } - } - warnMultiMajorMigration(targetPackage, installed, targetVersion); - return targetVersion; -} - export async function resolveMode( mode: 'first-party' | 'third-party' | 'all' | undefined, targetPackage: string, @@ -1117,21 +925,6 @@ export async function resolveMode( return selected; } -async function normalizeVersionWithTagCheck( - pkg: string, - version: string -): Promise { - // This doesn't seem like a valid version, lets check if its a tag on the registry. - if (version && !parse(version)) { - try { - return resolvePackageVersionUsingRegistry(pkg, version); - } catch { - // fall through to old logic - } - } - return normalizeVersion(version); -} - async function versionOverrides(overrides: string, param: string) { const res: Record = {}; const promises = overrides.split(',').map((p) => { @@ -1254,18 +1047,7 @@ export async function parseMigrationsOptions(options: { }; } - if (options.mode === 'third-party') { - if (options.from) { - throw new Error( - `Error: '--mode=third-party' cannot be combined with '--from'.` - ); - } - if (options.excludeAppliedMigrations === true) { - throw new Error( - `Error: '--mode=third-party' cannot be combined with '--exclude-applied-migrations'.` - ); - } - } + assertThirdPartyModeFlagCompatibility(options); const [from, to] = await Promise.all([ options.from @@ -1277,6 +1059,79 @@ export async function parseMigrationsOptions(options: { ]); const positional = options['packageAndVersion'] as string | undefined; + const resolved = await resolveTargetAndMode({ positional, from, options }); + const { mode, installedNxVersion } = resolved; + let { targetPackage, targetVersion, targetWasInferred } = resolved; + + // Spec §10: prompt or warn when crossing more than one major boundary. + // Each major's metadata may have pruned migrations from much-older versions, + // so jumping multiple majors at once can silently skip migrations. + targetVersion = await maybePromptOrWarnMultiMajorMigration({ + mode, + options, + targetPackage, + targetVersion, + targetWasInferred, + }); + + if (mode === 'third-party') { + assertThirdPartyTargetBounds({ + targetPackage, + targetVersion, + to, + installedNxVersion, + }); + } + + return { + type: 'generateMigrations', + targetPackage, + targetVersion, + from, + to, + interactive: options.interactive, + excludeAppliedMigrations: options.excludeAppliedMigrations, + mode, + }; +} + +function assertThirdPartyModeFlagCompatibility(options: { + mode?: string; + from?: string; + excludeAppliedMigrations?: boolean; +}): void { + if (options.mode !== 'third-party') return; + if (options.from) { + throw new Error( + `Error: '--mode=third-party' cannot be combined with '--from'.` + ); + } + if (options.excludeAppliedMigrations === true) { + throw new Error( + `Error: '--mode=third-party' cannot be combined with '--exclude-applied-migrations'.` + ); + } +} + +// Parses the positional, resolves `--mode`, defaults the target package and +// version when omitted (mode-aware: third-party anchors to the installed +// canonical, others to `nx@latest`), and enforces the era gate when `--mode` +// is explicit. +async function resolveTargetAndMode(args: { + positional: string | undefined; + from: Record; + options: { + mode?: 'first-party' | 'third-party' | 'all'; + excludeAppliedMigrations?: boolean; + }; +}): Promise<{ + targetPackage: string; + targetVersion: string; + targetWasInferred: boolean; + mode: 'first-party' | 'third-party' | 'all'; + installedNxVersion: string | null | undefined; +}> { + const { positional, from, options } = args; let targetPackage: string | undefined; let targetVersion: string | undefined; let targetWasInferred = !positional; @@ -1336,71 +1191,62 @@ export async function parseMigrationsOptions(options: { ); } - // Spec §10: prompt or warn when crossing more than one major boundary. - // Each major's metadata may have pruned migrations from much-older versions, - // so jumping multiple majors at once can silently skip migrations. - targetVersion = await maybePromptOrWarnMultiMajorMigration({ - mode, - options, + return { targetPackage: targetPackage!, targetVersion: targetVersion!, targetWasInferred, - }); + mode, + installedNxVersion, + }; +} - if (mode === 'third-party') { - const canonical = resolveCanonicalNxPackage(targetVersion!); - const isLegacy = canonical === '@nrwl/workspace'; - // Reuse the resolved installed version from the bare/no-version branch - // above when present (it's already era-aware via `resolveInstalledCanonical`). - // Otherwise fall back to the era-specific reader. - const installed = - installedNxVersion ?? - (isLegacy - ? getInstalledLegacyNrwlWorkspaceVersion() - : getInstalledNxVersion()); - if (!installed) { - throw new Error( - `Error: '--mode=third-party' requires '${canonical}' to be installed in your workspace. Install dependencies first, then re-run.` - ); - } - if (gt(targetVersion!, installed)) { +// `--mode=third-party` upper-bound gate. The third-party walk follows nx's +// `packageGroup` (e.g. `@nx/js`, `@nx/angular`); a target or `--to` above the +// installed version would expand the walk past it and surface third-party +// bumps that only exist in the newer plugin's history. The first-party set +// is sourced from the installed nx package's declared `packageGroup` +// (authoritative for the user's current Nx universe). Legacy era falls back +// to the hardcoded `LEGACY_NRWL_PACKAGE_GROUP`. +function assertThirdPartyTargetBounds(args: { + targetPackage: string; + targetVersion: string; + to: Record; + installedNxVersion: string | null | undefined; +}): void { + const { targetPackage, targetVersion, to, installedNxVersion } = args; + const canonical = resolveCanonicalNxPackage(targetVersion); + const isLegacy = canonical === '@nrwl/workspace'; + // Reuse the resolved installed version from `resolveTargetAndMode` when + // present (it's already era-aware via `resolveInstalledCanonical`). + // Otherwise fall back to the era-specific reader. + const installed = + installedNxVersion ?? + (isLegacy + ? getInstalledLegacyNrwlWorkspaceVersion() + : getInstalledNxVersion()); + if (!installed) { + throw new Error( + `Error: '--mode=third-party' requires '${canonical}' to be installed in your workspace. Install dependencies first, then re-run.` + ); + } + if (gt(targetVersion, installed)) { + throw new Error( + `Error: '--mode=third-party' cannot migrate to a version higher than what is currently installed (got '${targetPackage}@${targetVersion}', installed '${canonical}@${installed}'). Either drop '--mode=third-party' or lower the target.` + ); + } + const firstPartySet = isLegacy + ? new Set([ + '@nrwl/workspace', + ...LEGACY_NRWL_PACKAGE_GROUP.map((p) => p.package), + ]) + : getInstalledNxPackageGroup(); + for (const [pkg, version] of Object.entries(to)) { + if (firstPartySet.has(pkg) && gt(version, installed)) { throw new Error( - `Error: '--mode=third-party' cannot migrate to a version higher than what is currently installed (got '${targetPackage}@${targetVersion}', installed '${canonical}@${installed}'). Either drop '--mode=third-party' or lower the target.` + `Error: '--mode=third-party' cannot migrate to a version higher than what is currently installed (got '--to ${pkg}@${version}', installed '${canonical}@${installed}'). Either drop '--mode=third-party' or lower the '--to' value.` ); } - // Gate `--to` for any first-party package, not just the canonical. The - // third-party walk follows nx's `packageGroup` (e.g. `@nx/js`, - // `@nx/angular`), and `--to @` would expand the walk past - // the installed version and surface third-party bumps that only exist in - // the newer plugin's history. The first-party set is sourced from the - // installed nx package's declared `packageGroup` (authoritative for the - // user's current Nx universe). Legacy era falls back to the hardcoded - // `LEGACY_NRWL_PACKAGE_GROUP`. - const firstPartySet = isLegacy - ? new Set([ - '@nrwl/workspace', - ...LEGACY_NRWL_PACKAGE_GROUP.map((p) => p.package), - ]) - : getInstalledNxPackageGroup(); - for (const [pkg, version] of Object.entries(to)) { - if (firstPartySet.has(pkg) && gt(version, installed)) { - throw new Error( - `Error: '--mode=third-party' cannot migrate to a version higher than what is currently installed (got '--to ${pkg}@${version}', installed '${canonical}@${installed}'). Either drop '--mode=third-party' or lower the '--to' value.` - ); - } - } } - - return { - type: 'generateMigrations', - targetPackage: targetPackage!, - targetVersion: targetVersion!, - from, - to, - interactive: options.interactive, - excludeAppliedMigrations: options.excludeAppliedMigrations, - mode, - }; } /** @@ -1834,50 +1680,7 @@ async function createMigrationsFile( await writeFormattedJsonFile(join(root, 'migrations.json'), { migrations }); } -/** - * Drop entries from `packageUpdates` that would either downgrade the package - * (move resolved version backward) or rewrite a specifier that is already - * exactly pinned to the resolved version (a no-op write). Keep genuine - * upgrades and narrowing rewrites where the user's range covers a version - * lower than what's resolved (the migrator's traditional "lock to recommended - * exact pin" behavior). - */ -export function filterDowngradedUpdates( - packageUpdates: Record, - packageJson: PackageJson | null, - getInstalledVersion: (packageName: string) => string | null -): Record { - const result: Record = {}; - for (const [name, update] of Object.entries(packageUpdates)) { - const resolved = getInstalledVersion(name); - if (!resolved) { - // Not installed; let downstream logic decide whether to add it. - result[name] = update; - continue; - } - const proposed = normalizeVersion(update.version); - const resolvedNorm = normalizeVersion(resolved); - if (gt(proposed, resolvedNorm)) { - result[name] = update; - continue; - } - if (lt(proposed, resolvedNorm)) { - continue; - } - // proposed === resolved: keep when narrowing a looser specifier to an - // exact pin; drop when the specifier is already exact at resolved. - const specifier = - packageJson?.dependencies?.[name] ?? packageJson?.devDependencies?.[name]; - if (!specifier) { - continue; - } - const floor = minVersion(specifier); - if (floor && lt(floor.version, resolvedNorm)) { - result[name] = update; - } - } - return result; -} +export { filterDowngradedUpdates }; async function updatePackageJson( root: string, diff --git a/packages/nx/src/command-line/migrate/multi-major.ts b/packages/nx/src/command-line/migrate/multi-major.ts new file mode 100644 index 0000000000000..dabd31fc673aa --- /dev/null +++ b/packages/nx/src/command-line/migrate/multi-major.ts @@ -0,0 +1,164 @@ +import { prompt } from 'enquirer'; +import { gt, lt, major, valid } from 'semver'; +import { isCI } from '../../utils/is-ci'; +import { getInstalledNxVersion } from '../../utils/installed-nx-version'; +import { output } from '../../utils/output'; +import { resolvePackageVersionUsingRegistry } from '../../utils/package-manager'; +import { + DIST_TAGS, + type DistTag, + isNxEquivalentTarget, + normalizeVersionWithTagCheck, +} from './version-utils'; + +const STEPWISE_DOC_URL = + 'https://nx.dev/docs/guides/tips-n-tricks/advanced-update#one-major-version-at-a-time-small-steps'; +const ACCEPT_MULTI_MAJOR_UPDATE_ENV = 'NX_ACCEPT_MULTI_MAJOR_UPDATE'; + +// Caret-major (`^X.0.0`) excludes prereleases per semver, so +// `resolvePackageVersionUsingRegistry` returns the highest stable in major X. +async function resolveLatestStableInMajor( + packageName: string, + majorVersion: number +): Promise { + try { + const resolved = await resolvePackageVersionUsingRegistry( + packageName, + `^${majorVersion}.0.0` + ); + return valid(resolved) ? resolved : null; + } catch { + return null; + } +} + +const multiMajorHeader = (pkg: string, installed: string, target: string) => + `Migrating across multiple major versions: ${pkg}@${installed} → ${pkg}@${target}.`; + +const multiMajorBodyLines = [ + `The recommended process is to update one major version at a time, in small steps.`, + `See ${STEPWISE_DOC_URL}`, +]; + +function warnMultiMajorMigration( + targetPackage: string, + installed: string, + target: string +): void { + output.warn({ + title: multiMajorHeader(targetPackage, installed, target), + bodyLines: [ + ...multiMajorBodyLines, + `Pass --accept-multi-major-update or set ${ACCEPT_MULTI_MAJOR_UPDATE_ENV}=true to silence this warning.`, + ], + }); +} + +// Returns the chosen target version. Caller replaces `targetVersion` with it. +// At least one of `latestInCurrent`/`latestInNext` must be present. +async function promptMultiMajorMigration(args: { + targetPackage: string; + installed: string; + target: string; + latestInCurrent: string | null; + latestInNext: string | null; +}): Promise { + const choices: { name: string; message: string }[] = []; + if (args.latestInCurrent) { + choices.push({ + name: args.latestInCurrent, + message: `Migrate to ${args.targetPackage}@${args.latestInCurrent} (latest in current major)`, + }); + } + if (args.latestInNext) { + choices.push({ + name: args.latestInNext, + message: `Migrate to ${args.targetPackage}@${args.latestInNext} (next major)`, + }); + } + choices.push({ + name: args.target, + message: `Migrate directly to ${args.targetPackage}@${args.target}`, + }); + output.log({ + title: multiMajorHeader(args.targetPackage, args.installed, args.target), + bodyLines: multiMajorBodyLines, + }); + const { chosen } = await prompt<{ chosen: string }>({ + type: 'select', + name: 'chosen', + message: 'How would you like to proceed?', + choices, + }); + return chosen; +} + +// Flag wins over env var; both default to off. +function isMultiMajorUpdateAccepted(options: { + acceptMultiMajorUpdate?: boolean; +}): boolean { + if (options.acceptMultiMajorUpdate === true) return true; + const env = process.env[ACCEPT_MULTI_MAJOR_UPDATE_ENV]; + return env === 'true' || env === '1'; +} + +export async function maybePromptOrWarnMultiMajorMigration(args: { + mode: 'first-party' | 'third-party' | 'all'; + options: { acceptMultiMajorUpdate?: boolean }; + targetPackage: string; + targetVersion: string; + targetWasInferred: boolean; +}): Promise { + const { mode, options, targetPackage, targetWasInferred } = args; + let { targetVersion } = args; + if (mode === 'third-party') return targetVersion; + if (isMultiMajorUpdateAccepted(options)) return targetVersion; + if (!isNxEquivalentTarget(targetPackage, targetVersion)) return targetVersion; + // Bare-package-name positionals (e.g. `nx migrate nx`, `nx migrate + // @nx/workspace`) leave `targetVersion` as the literal `'latest'` because + // `parseTargetPackageAndVersion` only resolves dist-tags via the registry + // when they appear standalone or after `@`. Resolve here so the remaining + // semver gates (and the subsequent walk) see a concrete version. + if (DIST_TAGS.includes(targetVersion as DistTag)) { + try { + targetVersion = await normalizeVersionWithTagCheck( + targetPackage, + targetVersion + ); + } catch { + return targetVersion; + } + } + if (!valid(targetVersion) || lt(targetVersion, '14.0.0-beta.0')) { + return targetVersion; + } + const installed = getInstalledNxVersion(); + if (!installed || !valid(installed)) return targetVersion; + // Legacy-era installs are out of scope for the multi-major check. + if (lt(installed, '14.0.0-beta.0')) return targetVersion; + const installedMajor = major(installed); + if (major(targetVersion) - installedMajor < 2) return targetVersion; + + const interactive = !!process.stdin.isTTY && !isCI(); + if (interactive && targetWasInferred) { + const [latestInCurrent, latestInNext] = await Promise.all([ + resolveLatestStableInMajor(targetPackage, installedMajor), + resolveLatestStableInMajor(targetPackage, installedMajor + 1), + ]); + const showCurrent = + latestInCurrent && gt(latestInCurrent, installed) + ? latestInCurrent + : null; + if (showCurrent || latestInNext) { + return promptMultiMajorMigration({ + targetPackage, + installed, + target: targetVersion, + latestInCurrent: showCurrent, + latestInNext, + }); + } + } + warnMultiMajorMigration(targetPackage, installed, targetVersion); + return targetVersion; +} diff --git a/packages/nx/src/command-line/migrate/update-filters.ts b/packages/nx/src/command-line/migrate/update-filters.ts new file mode 100644 index 0000000000000..18d85c2e700c6 --- /dev/null +++ b/packages/nx/src/command-line/migrate/update-filters.ts @@ -0,0 +1,49 @@ +import { gt, lt, minVersion } from 'semver'; +import type { PackageJsonUpdateForPackage as PackageUpdate } from '../../config/misc-interfaces'; +import type { PackageJson } from '../../utils/package-json'; +import { normalizeVersion } from './version-utils'; + +/** + * Drop entries from `packageUpdates` that would either downgrade the package + * (move resolved version backward) or rewrite a specifier that is already + * exactly pinned to the resolved version (a no-op write). Keep genuine + * upgrades and narrowing rewrites where the user's range covers a version + * lower than what's resolved (the migrator's traditional "lock to recommended + * exact pin" behavior). + */ +export function filterDowngradedUpdates( + packageUpdates: Record, + packageJson: PackageJson | null, + getInstalledVersion: (packageName: string) => string | null +): Record { + const result: Record = {}; + for (const [name, update] of Object.entries(packageUpdates)) { + const resolved = getInstalledVersion(name); + if (!resolved) { + // Not installed; let downstream logic decide whether to add it. + result[name] = update; + continue; + } + const proposed = normalizeVersion(update.version); + const resolvedNorm = normalizeVersion(resolved); + if (gt(proposed, resolvedNorm)) { + result[name] = update; + continue; + } + if (lt(proposed, resolvedNorm)) { + continue; + } + // proposed === resolved: keep when narrowing a looser specifier to an + // exact pin; drop when the specifier is already exact at resolved. + const specifier = + packageJson?.dependencies?.[name] ?? packageJson?.devDependencies?.[name]; + if (!specifier) { + continue; + } + const floor = minVersion(specifier); + if (floor && lt(floor.version, resolvedNorm)) { + result[name] = update; + } + } + return result; +} diff --git a/packages/nx/src/command-line/migrate/version-utils.ts b/packages/nx/src/command-line/migrate/version-utils.ts new file mode 100644 index 0000000000000..3f3c7a4d72f35 --- /dev/null +++ b/packages/nx/src/command-line/migrate/version-utils.ts @@ -0,0 +1,66 @@ +import { gt, lt, parse, valid } from 'semver'; +import { resolvePackageVersionUsingRegistry } from '../../utils/package-manager'; + +export const DIST_TAGS = ['latest', 'next', 'canary'] as const; +export type DistTag = (typeof DIST_TAGS)[number]; + +export function normalizeVersion(version: string) { + const [semver, ...prereleaseTagParts] = version.split('-'); + // Handle versions like 1.0.0-beta-next.2 + const prereleaseTag = prereleaseTagParts.join('-'); + + const [major, minor, patch] = semver.split('.'); + + const newSemver = `${major || 0}.${minor || 0}.${patch || 0}`; + + const newVersion = prereleaseTag + ? `${newSemver}-${prereleaseTag}` + : newSemver; + + const withoutPatch = `${major || 0}.${minor || 0}.0`; + const withoutPatchAndMinor = `${major || 0}.0.0`; + + const variationsToCheck = [ + newVersion, + newSemver, + withoutPatch, + withoutPatchAndMinor, + ]; + + for (const variation of variationsToCheck) { + try { + if (gt(variation, '0.0.0')) { + return variation; + } + } catch {} + } + + return '0.0.0'; +} + +export async function normalizeVersionWithTagCheck( + pkg: string, + version: string +): Promise { + // This doesn't seem like a valid version, lets check if its a tag on the registry. + if (version && !parse(version)) { + try { + return resolvePackageVersionUsingRegistry(pkg, version); + } catch { + // fall through to old logic + } + } + return normalizeVersion(version); +} + +export function isNxEquivalentTarget( + targetPackage: string, + targetVersion: string +): boolean { + // Non-semver values (e.g., the literal `'latest'` sentinel used during bare + // invocation before tag resolution, or in tests) are treated as modern era. + if (valid(targetVersion) && lt(targetVersion, '14.0.0-beta.0')) { + return targetPackage === '@nrwl/workspace'; + } + return targetPackage === 'nx' || targetPackage === '@nx/workspace'; +} From 18a60d9acd2ab7d5a3e7a18df99ef60e5bd9f936 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leosvel=20P=C3=A9rez=20Espinosa?= Date: Fri, 8 May 2026 16:22:46 +0200 Subject: [PATCH 10/13] chore(core): tighten nx migrate multi-major prompt and version handling - Drop targetWasInferred from the multi-major prompt trigger; prompt fires for any TTY non-CI multi-major run. The "Migrate directly to " option still lets users keep explicit versions by selection. - Soften bare `nx migrate` default to a literal 'latest' sentinel instead of eagerly resolving via the registry. Multi-major resolves it (and bails on registry failure) and the cascade resolves it for the walk (honouring NX_MIGRATE_SKIP_REGISTRY_FETCH). Matches the resilience of `nx migrate nx`. - Drop dead try/catch in normalizeVersionWithTagCheck (return without await doesn't catch rejections) and update the misleading comment. --- .../src/command-line/migrate/migrate.spec.ts | 13 ++++--- .../nx/src/command-line/migrate/migrate.ts | 35 +++++++------------ .../src/command-line/migrate/multi-major.ts | 5 ++- .../src/command-line/migrate/version-utils.ts | 10 +++--- 4 files changed, 28 insertions(+), 35 deletions(-) diff --git a/packages/nx/src/command-line/migrate/migrate.spec.ts b/packages/nx/src/command-line/migrate/migrate.spec.ts index 47ce662a34f56..689912c1bf0d0 100644 --- a/packages/nx/src/command-line/migrate/migrate.spec.ts +++ b/packages/nx/src/command-line/migrate/migrate.spec.ts @@ -3037,8 +3037,13 @@ describe('Migration', () => { expect(choices.map((c) => c.name)).toEqual(['22.5.3', '23.1.0']); }); - it('should warn (not prompt) when target was explicitly typed as numeric semver', async () => { + it('should prompt (not warn) when target was explicitly typed as numeric semver', async () => { setTty(true); + mockRegistry({ + '21': '21.5.3', + '22': '22.5.3', + }); + mockPrompt.mockResolvedValue({ chosen: '21.5.3' }); const warnSpy = spyWarn(); const r = await parseMigrationsOptions({ @@ -3046,9 +3051,9 @@ describe('Migration', () => { mode: 'all', }); - expect(mockPrompt).not.toHaveBeenCalled(); - expect(warnSpy).toHaveBeenCalled(); - expect(r).toMatchObject({ targetVersion: '23.1.0' }); + expect(mockPrompt).toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); + expect(r).toMatchObject({ targetVersion: '21.5.3' }); }); it('should warn (not prompt) in non-TTY environments', async () => { diff --git a/packages/nx/src/command-line/migrate/migrate.ts b/packages/nx/src/command-line/migrate/migrate.ts index 23cba2e7dbc58..cb20a5807e935 100644 --- a/packages/nx/src/command-line/migrate/migrate.ts +++ b/packages/nx/src/command-line/migrate/migrate.ts @@ -954,7 +954,6 @@ async function versionOverrides(overrides: string, param: string) { async function parseTargetPackageAndVersion(args: string): Promise<{ targetPackage: string; targetVersion: string; - wasInferred: boolean; }> { if (!args) { throw new Error( @@ -965,11 +964,7 @@ async function parseTargetPackageAndVersion(args: string): Promise<{ if (args.indexOf('@') > -1) { const i = args.lastIndexOf('@'); if (i === 0) { - return { - targetPackage: args.trim(), - targetVersion: 'latest', - wasInferred: true, - }; + return { targetPackage: args.trim(), targetVersion: 'latest' }; } const targetPackage = args.substring(0, i); const maybeVersion = args.substring(i + 1); @@ -982,11 +977,7 @@ async function parseTargetPackageAndVersion(args: string): Promise<{ targetPackage, maybeVersion ); - return { - targetPackage, - targetVersion, - wasInferred: DIST_TAGS.includes(maybeVersion as DistTag), - }; + return { targetPackage, targetVersion }; } if ( @@ -998,15 +989,15 @@ async function parseTargetPackageAndVersion(args: string): Promise<{ // We could duplicate the ternary below, but its not necessary since they are equivalent // on the registry const targetVersion = await normalizeVersionWithTagCheck('nx', args); - const wasInferred = DIST_TAGS.includes(args as DistTag); + const isDistTag = DIST_TAGS.includes(args as DistTag); const targetPackage = - !wasInferred && lt(targetVersion, '14.0.0-beta.0') + !isDistTag && lt(targetVersion, '14.0.0-beta.0') ? '@nrwl/workspace' : 'nx'; - return { targetPackage, targetVersion, wasInferred }; + return { targetPackage, targetVersion }; } - return { targetPackage: args, targetVersion: 'latest', wasInferred: true }; + return { targetPackage: args, targetVersion: 'latest' }; } type GenerateMigrations = { @@ -1061,7 +1052,7 @@ export async function parseMigrationsOptions(options: { const positional = options['packageAndVersion'] as string | undefined; const resolved = await resolveTargetAndMode({ positional, from, options }); const { mode, installedNxVersion } = resolved; - let { targetPackage, targetVersion, targetWasInferred } = resolved; + let { targetPackage, targetVersion } = resolved; // Spec §10: prompt or warn when crossing more than one major boundary. // Each major's metadata may have pruned migrations from much-older versions, @@ -1071,7 +1062,6 @@ export async function parseMigrationsOptions(options: { options, targetPackage, targetVersion, - targetWasInferred, }); if (mode === 'third-party') { @@ -1127,19 +1117,16 @@ async function resolveTargetAndMode(args: { }): Promise<{ targetPackage: string; targetVersion: string; - targetWasInferred: boolean; mode: 'first-party' | 'third-party' | 'all'; installedNxVersion: string | null | undefined; }> { const { positional, from, options } = args; let targetPackage: string | undefined; let targetVersion: string | undefined; - let targetWasInferred = !positional; if (positional) { const parsed = await parseTargetPackageAndVersion(positional); targetPackage = normalizeSlashes(parsed.targetPackage); targetVersion = parsed.targetVersion; - targetWasInferred = parsed.wasInferred; } // Resolve mode before defaulting target so the default can depend on the @@ -1175,8 +1162,13 @@ async function resolveTargetAndMode(args: { targetPackage = installed.canonical; targetVersion = installed.version; } else if (!positional) { + // Bare invocation: default to `nx@latest` as a literal sentinel rather + // than resolving via the registry here. Multi-major resolves the dist-tag + // when needed (and bails gracefully on registry failure), and the cascade + // resolves it for the walk (honouring `NX_MIGRATE_SKIP_REGISTRY_FETCH`). + // This matches the resilience of `nx migrate nx`. targetPackage = 'nx'; - targetVersion = await normalizeVersionWithTagCheck('nx', 'latest'); + targetVersion = 'latest'; } if (options.mode && !isNxEquivalentTarget(targetPackage!, targetVersion!)) { @@ -1194,7 +1186,6 @@ async function resolveTargetAndMode(args: { return { targetPackage: targetPackage!, targetVersion: targetVersion!, - targetWasInferred, mode, installedNxVersion, }; diff --git a/packages/nx/src/command-line/migrate/multi-major.ts b/packages/nx/src/command-line/migrate/multi-major.ts index dabd31fc673aa..6bab9d942528c 100644 --- a/packages/nx/src/command-line/migrate/multi-major.ts +++ b/packages/nx/src/command-line/migrate/multi-major.ts @@ -107,9 +107,8 @@ export async function maybePromptOrWarnMultiMajorMigration(args: { options: { acceptMultiMajorUpdate?: boolean }; targetPackage: string; targetVersion: string; - targetWasInferred: boolean; }): Promise { - const { mode, options, targetPackage, targetWasInferred } = args; + const { mode, options, targetPackage } = args; let { targetVersion } = args; if (mode === 'third-party') return targetVersion; if (isMultiMajorUpdateAccepted(options)) return targetVersion; @@ -140,7 +139,7 @@ export async function maybePromptOrWarnMultiMajorMigration(args: { if (major(targetVersion) - installedMajor < 2) return targetVersion; const interactive = !!process.stdin.isTTY && !isCI(); - if (interactive && targetWasInferred) { + if (interactive) { const [latestInCurrent, latestInNext] = await Promise.all([ resolveLatestStableInMajor(targetPackage, installedMajor), resolveLatestStableInMajor(targetPackage, installedMajor + 1), diff --git a/packages/nx/src/command-line/migrate/version-utils.ts b/packages/nx/src/command-line/migrate/version-utils.ts index 3f3c7a4d72f35..2971a7e4f7230 100644 --- a/packages/nx/src/command-line/migrate/version-utils.ts +++ b/packages/nx/src/command-line/migrate/version-utils.ts @@ -42,13 +42,11 @@ export async function normalizeVersionWithTagCheck( pkg: string, version: string ): Promise { - // This doesn't seem like a valid version, lets check if its a tag on the registry. + // Treat non-parseable inputs (e.g. dist-tags like `latest`/`next`) as + // registry references and resolve them. Throws on registry failure; + // callers that need to tolerate registry outages must wrap. if (version && !parse(version)) { - try { - return resolvePackageVersionUsingRegistry(pkg, version); - } catch { - // fall through to old logic - } + return resolvePackageVersionUsingRegistry(pkg, version); } return normalizeVersion(version); } From 56d4dcc0cf9720b03be25e8bd0e1c29144a09e97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leosvel=20P=C3=A9rez=20Espinosa?= Date: Tue, 12 May 2026 11:56:55 +0200 Subject: [PATCH 11/13] chore(core): polish nx migrate prompt copy and stepwise option gate --- .../src/command-line/migrate/migrate.spec.ts | 20 +++++++++++++++++++ .../nx/src/command-line/migrate/migrate.ts | 15 +++++++++++--- .../src/command-line/migrate/multi-major.ts | 17 ++++++++++++---- 3 files changed, 45 insertions(+), 7 deletions(-) diff --git a/packages/nx/src/command-line/migrate/migrate.spec.ts b/packages/nx/src/command-line/migrate/migrate.spec.ts index 689912c1bf0d0..2beb8e4889ddd 100644 --- a/packages/nx/src/command-line/migrate/migrate.spec.ts +++ b/packages/nx/src/command-line/migrate/migrate.spec.ts @@ -3037,6 +3037,26 @@ describe('Migration', () => { expect(choices.map((c) => c.name)).toEqual(['22.5.3', '23.1.0']); }); + it('should not include the current-major option when installed is on the latest minor of the current major but behind on patch', async () => { + setTty(true); + mockGetInstalledNxVersion.mockReturnValue('21.5.0'); + mockRegistry({ + latest: '23.1.0', + '21': '21.5.3', + '22': '22.5.3', + }); + mockPrompt.mockResolvedValue({ chosen: '22.5.3' }); + + await parseMigrationsOptions({ + packageAndVersion: 'latest', + mode: 'all', + }); + + const promptArgs = mockPrompt.mock.calls[0][0]; + const choices = promptArgs.choices as { name: string }[]; + expect(choices.map((c) => c.name)).toEqual(['22.5.3', '23.1.0']); + }); + it('should prompt (not warn) when target was explicitly typed as numeric semver', async () => { setTty(true); mockRegistry({ diff --git a/packages/nx/src/command-line/migrate/migrate.ts b/packages/nx/src/command-line/migrate/migrate.ts index cb20a5807e935..b4f694b92a960 100644 --- a/packages/nx/src/command-line/migrate/migrate.ts +++ b/packages/nx/src/command-line/migrate/migrate.ts @@ -908,12 +908,21 @@ export async function resolveMode( return 'all'; } const choices: { name: string; message: string }[] = [ - { name: 'first-party', message: 'First-party only' }, + { + name: 'first-party', + message: 'First-party only (Nx and its official packages)', + }, ]; if (!context.hasFrom && !context.hasExcludeAppliedMigrations) { - choices.push({ name: 'third-party', message: 'Third-party only' }); + choices.push({ + name: 'third-party', + message: 'Third-party only (deps managed by Nx)', + }); } - choices.push({ name: 'all', message: 'All' }); + choices.push({ + name: 'all', + message: 'All (first-party and third-party)', + }); const { mode: selected } = await prompt<{ mode: 'first-party' | 'third-party' | 'all'; }>({ diff --git a/packages/nx/src/command-line/migrate/multi-major.ts b/packages/nx/src/command-line/migrate/multi-major.ts index 6bab9d942528c..1a0e02cd92904 100644 --- a/packages/nx/src/command-line/migrate/multi-major.ts +++ b/packages/nx/src/command-line/migrate/multi-major.ts @@ -1,5 +1,5 @@ import { prompt } from 'enquirer'; -import { gt, lt, major, valid } from 'semver'; +import { gt, lt, major, minor, valid } from 'semver'; import { isCI } from '../../utils/is-ci'; import { getInstalledNxVersion } from '../../utils/installed-nx-version'; import { output } from '../../utils/output'; @@ -64,16 +64,20 @@ async function promptMultiMajorMigration(args: { latestInNext: string | null; }): Promise { const choices: { name: string; message: string }[] = []; + let recommendedMarked = false; if (args.latestInCurrent) { choices.push({ name: args.latestInCurrent, - message: `Migrate to ${args.targetPackage}@${args.latestInCurrent} (latest in current major)`, + message: `Migrate to ${args.targetPackage}@${args.latestInCurrent} (latest in current major) [recommended]`, }); + recommendedMarked = true; } if (args.latestInNext) { choices.push({ name: args.latestInNext, - message: `Migrate to ${args.targetPackage}@${args.latestInNext} (next major)`, + message: `Migrate to ${args.targetPackage}@${args.latestInNext} (next major)${ + recommendedMarked ? '' : ' [recommended]' + }`, }); } choices.push({ @@ -144,8 +148,13 @@ export async function maybePromptOrWarnMultiMajorMigration(args: { resolveLatestStableInMajor(targetPackage, installedMajor), resolveLatestStableInMajor(targetPackage, installedMajor + 1), ]); + // Only suggest the current-major latest when there's at least a minor + // delta — a same-minor patch bump isn't a meaningful "step" in stepwise + // migration framing. const showCurrent = - latestInCurrent && gt(latestInCurrent, installed) + latestInCurrent && + gt(latestInCurrent, installed) && + minor(latestInCurrent) > minor(installed) ? latestInCurrent : null; if (showCurrent || latestInNext) { From b7c31a7b3fa7f8404024131fb64eb6c4edcdc450 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leosvel=20P=C3=A9rez=20Espinosa?= Date: Tue, 12 May 2026 13:52:31 +0200 Subject: [PATCH 12/13] feat(core): introduce --multi-major-mode flag in nx migrate --- .../command-line/migrate/command-object.ts | 8 +- .../src/command-line/migrate/migrate.spec.ts | 122 ++++++++++++++++-- .../src/command-line/migrate/multi-major.ts | 105 ++++++++++----- 3 files changed, 188 insertions(+), 47 deletions(-) diff --git a/packages/nx/src/command-line/migrate/command-object.ts b/packages/nx/src/command-line/migrate/command-object.ts index 05cc2a5f3a7e7..215274873c221 100644 --- a/packages/nx/src/command-line/migrate/command-object.ts +++ b/packages/nx/src/command-line/migrate/command-object.ts @@ -90,11 +90,11 @@ function withMigrationOptions(yargs: Argv) { type: 'string', choices: ['first-party', 'third-party', 'all'], }) - .option('acceptMultiMajorUpdate', { + .option('multiMajorMode', { describe: - 'Skip the multi-major migration prompt/warning and migrate directly to the target version even when it crosses more than one major boundary. The recommended process is to update one major version at a time. Equivalent env var: NX_ACCEPT_MULTI_MAJOR_UPDATE=true.', - type: 'boolean', - default: false, + "Skip the multi-major migration prompt/warning and pick how to handle the jump. 'direct' migrates straight to the requested target. 'gradual' migrates to the smallest recommended step (re-run `nx migrate` to continue toward the original target). Equivalent env var: NX_MULTI_MAJOR_MODE=direct|gradual.", + type: 'string', + choices: ['direct', 'gradual'], }) .check( ({ diff --git a/packages/nx/src/command-line/migrate/migrate.spec.ts b/packages/nx/src/command-line/migrate/migrate.spec.ts index 2beb8e4889ddd..35151915ff077 100644 --- a/packages/nx/src/command-line/migrate/migrate.spec.ts +++ b/packages/nx/src/command-line/migrate/migrate.spec.ts @@ -2920,18 +2920,18 @@ describe('Migration', () => { process.stdin, 'isTTY' ); - let originalAccept: string | undefined; + let originalMultiMajorMode: string | undefined; beforeEach(() => { originalCi = process.env.CI; - originalAccept = process.env.NX_ACCEPT_MULTI_MAJOR_UPDATE; + originalMultiMajorMode = process.env.NX_MULTI_MAJOR_MODE; mockGetInstalledNxVersion.mockReturnValue('21.0.0'); mockGetInstalledNxPackageGroup.mockReturnValue( new Set(['nx', '@nx/js', '@nx/workspace']) ); mockGetInstalledLegacyNrwlWorkspaceVersion.mockReturnValue(null); delete process.env.CI; - delete process.env.NX_ACCEPT_MULTI_MAJOR_UPDATE; + delete process.env.NX_MULTI_MAJOR_MODE; }); afterEach(() => { @@ -2944,10 +2944,10 @@ describe('Migration', () => { } else { process.env.CI = originalCi; } - if (originalAccept === undefined) { - delete process.env.NX_ACCEPT_MULTI_MAJOR_UPDATE; + if (originalMultiMajorMode === undefined) { + delete process.env.NX_MULTI_MAJOR_MODE; } else { - process.env.NX_ACCEPT_MULTI_MAJOR_UPDATE = originalAccept; + process.env.NX_MULTI_MAJOR_MODE = originalMultiMajorMode; } if (originalTtyDescriptor) { Object.defineProperty(process.stdin, 'isTTY', originalTtyDescriptor); @@ -3091,7 +3091,7 @@ describe('Migration', () => { expect(r).toMatchObject({ targetVersion: '23.1.0' }); }); - it('should not prompt or warn when --accept-multi-major-update flag is set', async () => { + it('should not prompt or warn when --multi-major-mode=direct is set', async () => { setTty(true); mockRegistry({ latest: '23.1.0' }); const warnSpy = spyWarn(); @@ -3099,7 +3099,7 @@ describe('Migration', () => { const r = await parseMigrationsOptions({ packageAndVersion: 'latest', mode: 'all', - acceptMultiMajorUpdate: true, + multiMajorMode: 'direct', }); expect(mockPrompt).not.toHaveBeenCalled(); @@ -3107,9 +3107,9 @@ describe('Migration', () => { expect(r).toMatchObject({ targetVersion: '23.1.0' }); }); - it('should not prompt or warn when NX_ACCEPT_MULTI_MAJOR_UPDATE env var is set', async () => { + it('should not prompt or warn when NX_MULTI_MAJOR_MODE=direct is set', async () => { setTty(true); - process.env.NX_ACCEPT_MULTI_MAJOR_UPDATE = 'true'; + process.env.NX_MULTI_MAJOR_MODE = 'direct'; mockRegistry({ latest: '23.1.0' }); const warnSpy = spyWarn(); @@ -3123,6 +3123,108 @@ describe('Migration', () => { expect(r).toMatchObject({ targetVersion: '23.1.0' }); }); + it('should pick the latest in current major and skip prompts/warns when --multi-major-mode=gradual is set', async () => { + setTty(true); + mockRegistry({ + latest: '23.1.0', + '21': '21.5.3', + '22': '22.5.3', + }); + const warnSpy = spyWarn(); + + const r = await parseMigrationsOptions({ + packageAndVersion: 'latest', + mode: 'all', + multiMajorMode: 'gradual', + }); + + expect(mockPrompt).not.toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); + expect(r).toMatchObject({ targetVersion: '21.5.3' }); + }); + + it('should fall back to the next major when the current-major option is filtered out under --multi-major-mode=gradual', async () => { + setTty(true); + mockGetInstalledNxVersion.mockReturnValue('21.5.3'); + mockRegistry({ + latest: '23.1.0', + '21': '21.5.3', + '22': '22.5.3', + }); + const warnSpy = spyWarn(); + + const r = await parseMigrationsOptions({ + packageAndVersion: 'latest', + mode: 'all', + multiMajorMode: 'gradual', + }); + + expect(mockPrompt).not.toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); + expect(r).toMatchObject({ targetVersion: '22.5.3' }); + }); + + it('should fall back silently to the requested target when --multi-major-mode=gradual has no stepwise option', async () => { + setTty(true); + // Installed at latest of its major → current-major option dropped. + // Next-major lookup fails → next-major option dropped. Both unavailable. + mockGetInstalledNxVersion.mockReturnValue('21.5.3'); + mockRegistry({ latest: '23.1.0', '21': '21.5.3' }); + const warnSpy = spyWarn(); + const logSpy = jest + .spyOn(require('../../utils/output').output, 'log') + .mockImplementation(() => {}); + + const r = await parseMigrationsOptions({ + packageAndVersion: 'latest', + mode: 'all', + multiMajorMode: 'gradual', + }); + + expect(mockPrompt).not.toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); + expect(logSpy).not.toHaveBeenCalled(); + expect(r).toMatchObject({ targetVersion: '23.1.0' }); + }); + + it('should honour NX_MULTI_MAJOR_MODE=gradual when no flag is set', async () => { + setTty(true); + process.env.NX_MULTI_MAJOR_MODE = 'gradual'; + mockRegistry({ + latest: '23.1.0', + '21': '21.5.3', + '22': '22.5.3', + }); + const warnSpy = spyWarn(); + + const r = await parseMigrationsOptions({ + packageAndVersion: 'latest', + mode: 'all', + }); + + expect(mockPrompt).not.toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); + expect(r).toMatchObject({ targetVersion: '21.5.3' }); + }); + + it('should let the flag win over the env var (flag=direct, env=gradual)', async () => { + setTty(true); + process.env.NX_MULTI_MAJOR_MODE = 'gradual'; + mockRegistry({ + latest: '23.1.0', + '21': '21.5.3', + '22': '22.5.3', + }); + + const r = await parseMigrationsOptions({ + packageAndVersion: 'latest', + mode: 'all', + multiMajorMode: 'direct', + }); + + expect(r).toMatchObject({ targetVersion: '23.1.0' }); + }); + it('should not prompt or warn when delta is exactly 1 major', async () => { setTty(true); mockRegistry({ latest: '22.5.3' }); diff --git a/packages/nx/src/command-line/migrate/multi-major.ts b/packages/nx/src/command-line/migrate/multi-major.ts index 1a0e02cd92904..ad6182a37ed06 100644 --- a/packages/nx/src/command-line/migrate/multi-major.ts +++ b/packages/nx/src/command-line/migrate/multi-major.ts @@ -13,7 +13,9 @@ import { const STEPWISE_DOC_URL = 'https://nx.dev/docs/guides/tips-n-tricks/advanced-update#one-major-version-at-a-time-small-steps'; -const ACCEPT_MULTI_MAJOR_UPDATE_ENV = 'NX_ACCEPT_MULTI_MAJOR_UPDATE'; +const MULTI_MAJOR_MODE_ENV = 'NX_MULTI_MAJOR_MODE'; + +export type MultiMajorMode = 'direct' | 'gradual'; // Caret-major (`^X.0.0`) excludes prereleases per semver, so // `resolvePackageVersionUsingRegistry` returns the highest stable in major X. @@ -49,7 +51,20 @@ function warnMultiMajorMigration( title: multiMajorHeader(targetPackage, installed, target), bodyLines: [ ...multiMajorBodyLines, - `Pass --accept-multi-major-update or set ${ACCEPT_MULTI_MAJOR_UPDATE_ENV}=true to silence this warning.`, + `Pass --multi-major-mode=direct (or =gradual) or set ${MULTI_MAJOR_MODE_ENV} to silence this warning.`, + ], + }); +} + +function logGradualStep( + targetPackage: string, + step: string, + target: string +): void { + output.log({ + title: `Migrating to ${targetPackage}@${step} (one step toward ${targetPackage}@${target}).`, + bodyLines: [ + `Re-run \`nx migrate\` after this completes to continue toward ${targetPackage}@${target}.`, ], }); } @@ -97,25 +112,32 @@ async function promptMultiMajorMigration(args: { return chosen; } -// Flag wins over env var; both default to off. -function isMultiMajorUpdateAccepted(options: { - acceptMultiMajorUpdate?: boolean; -}): boolean { - if (options.acceptMultiMajorUpdate === true) return true; - const env = process.env[ACCEPT_MULTI_MAJOR_UPDATE_ENV]; - return env === 'true' || env === '1'; +// Flag wins over env var; only the two literal values are honoured. +function resolveMultiMajorMode(options: { + multiMajorMode?: MultiMajorMode; +}): MultiMajorMode | undefined { + if ( + options.multiMajorMode === 'direct' || + options.multiMajorMode === 'gradual' + ) { + return options.multiMajorMode; + } + const env = process.env[MULTI_MAJOR_MODE_ENV]; + if (env === 'direct' || env === 'gradual') return env; + return undefined; } export async function maybePromptOrWarnMultiMajorMigration(args: { mode: 'first-party' | 'third-party' | 'all'; - options: { acceptMultiMajorUpdate?: boolean }; + options: { multiMajorMode?: MultiMajorMode }; targetPackage: string; targetVersion: string; }): Promise { const { mode, options, targetPackage } = args; let { targetVersion } = args; if (mode === 'third-party') return targetVersion; - if (isMultiMajorUpdateAccepted(options)) return targetVersion; + const multiMajorMode = resolveMultiMajorMode(options); + if (multiMajorMode === 'direct') return targetVersion; if (!isNxEquivalentTarget(targetPackage, targetVersion)) return targetVersion; // Bare-package-name positionals (e.g. `nx migrate nx`, `nx migrate // @nx/workspace`) leave `targetVersion` as the literal `'latest'` because @@ -143,29 +165,46 @@ export async function maybePromptOrWarnMultiMajorMigration(args: { if (major(targetVersion) - installedMajor < 2) return targetVersion; const interactive = !!process.stdin.isTTY && !isCI(); - if (interactive) { - const [latestInCurrent, latestInNext] = await Promise.all([ - resolveLatestStableInMajor(targetPackage, installedMajor), - resolveLatestStableInMajor(targetPackage, installedMajor + 1), - ]); - // Only suggest the current-major latest when there's at least a minor - // delta — a same-minor patch bump isn't a meaningful "step" in stepwise - // migration framing. - const showCurrent = - latestInCurrent && - gt(latestInCurrent, installed) && - minor(latestInCurrent) > minor(installed) - ? latestInCurrent - : null; - if (showCurrent || latestInNext) { - return promptMultiMajorMigration({ - targetPackage, - installed, - target: targetVersion, - latestInCurrent: showCurrent, - latestInNext, - }); + // Non-TTY without gradual opt-in stays on the warn-only path; avoid the + // registry round-trip used to resolve stepwise options. + if (!interactive && multiMajorMode !== 'gradual') { + warnMultiMajorMigration(targetPackage, installed, targetVersion); + return targetVersion; + } + + const [latestInCurrent, latestInNext] = await Promise.all([ + resolveLatestStableInMajor(targetPackage, installedMajor), + resolveLatestStableInMajor(targetPackage, installedMajor + 1), + ]); + // Only suggest the current-major latest when there's at least a minor + // delta — a same-minor patch bump isn't a meaningful "step" in stepwise + // migration framing. + const showCurrent = + latestInCurrent && + gt(latestInCurrent, installed) && + minor(latestInCurrent) > minor(installed) + ? latestInCurrent + : null; + + if (multiMajorMode === 'gradual') { + const step = showCurrent ?? latestInNext; + if (step) { + logGradualStep(targetPackage, step, targetVersion); + return step; } + // No stepwise option resolved → the requested target is effectively the + // stepwise pick. Honour `gradual` silently. + return targetVersion; + } + + if (interactive && (showCurrent || latestInNext)) { + return promptMultiMajorMigration({ + targetPackage, + installed, + target: targetVersion, + latestInCurrent: showCurrent, + latestInNext, + }); } warnMultiMajorMigration(targetPackage, installed, targetVersion); return targetVersion; From e933c790cf22f92cd02ad002622d813b53e16243 Mon Sep 17 00:00:00 2001 From: "nx-cloud[bot]" <71083854+nx-cloud[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 12:39:57 +0000 Subject: [PATCH 13/13] feat(core): introduce --multi-major-mode flag in nx migrate [Self-Healing CI Rerun]