From c15a7fe1972cd607bbdf609a65dcde13ee26bf29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leosvel=20P=C3=A9rez=20Espinosa?= Date: Mon, 6 Oct 2025 18:01:22 +0200 Subject: [PATCH 1/8] feat(core): add support for pnpm catalogs --- docs/generated/devkit/README.md | 1 + .../getDependencyVersionFromPackageJson.md | 110 ++++ .../packages/devkit/documents/nx_devkit.md | 1 + packages/angular/src/generators/init/init.ts | 4 +- .../lib/normalize-options.ts | 3 +- .../ngrx-root-store/lib/normalize-options.ts | 3 +- .../generators/ngrx/lib/normalize-options.ts | 3 +- .../setup-ssr/lib/add-dependencies.ts | 9 +- .../setup-ssr/lib/generate-files.ts | 8 +- .../lib/detect-tailwind-installed-version.ts | 2 +- .../utils/ensure-angular-dependencies.ts | 19 +- .../src/generators/utils/version-utils.ts | 27 +- .../update-16-4-0/update-angular-cli.ts | 30 +- .../update-16-7-0/update-angular-cli.ts | 30 +- .../update-17-1-0/update-angular-cli.ts | 30 +- .../add-autoprefixer-dependency.ts | 7 +- .../add-browser-sync-dependency.ts | 7 +- .../update-17-3-0/update-angular-cli.ts | 30 +- .../update-18-1-0/update-angular-cli.ts | 30 +- .../update-18-2-0/update-angular-cli.ts | 30 +- .../update-19-1-0/update-angular-cli.ts | 30 +- .../add-typescript-eslint-utils.ts | 8 +- .../update-19-5-0/update-angular-cli.ts | 30 +- .../update-19-6-0/update-angular-cli.ts | 30 +- .../update-20-2-0/update-angular-cli.ts | 30 +- .../update-20-4-0/update-angular-cli.ts | 30 +- .../update-20-5-0/update-angular-cli.ts | 30 +- .../update-21-2-0/update-angular-cli.ts | 30 +- .../update-21-3-0/update-angular-cli.ts | 30 +- .../update-21-5-0/update-angular-cli.ts | 30 +- .../update-21-6-1/update-angular-cli.ts | 30 +- packages/cypress/src/utils/versions.ts | 6 +- packages/devkit/package.json | 1 + packages/devkit/public-api.ts | 1 + packages/devkit/src/utils/catalog/errors.ts | 17 + packages/devkit/src/utils/catalog/index.ts | 64 +++ .../src/utils/catalog/manager-factory.ts | 29 + packages/devkit/src/utils/catalog/manager.ts | 72 +++ .../devkit/src/utils/catalog/pnpm-manager.ts | 311 +++++++++++ packages/devkit/src/utils/catalog/types.ts | 19 + .../src/utils/catalog/unsupported-manager.ts | 71 +++ .../devkit/src/utils/package-json.spec.ts | 370 ++++++++++++- packages/devkit/src/utils/package-json.ts | 524 ++++++++++++++++-- packages/devkit/src/utils/semver.ts | 51 +- .../src/rules/dependency-checks.ts | 38 +- packages/eslint/src/utils/version-utils.ts | 28 +- packages/jest/src/utils/versions.ts | 6 +- packages/js/src/generators/init/init.ts | 15 +- .../src/utils/package-json.ts | 8 +- packages/module-federation/src/utils/share.ts | 19 +- packages/next/src/utils/version-utils.ts | 15 +- .../init/implementation/angular/index.ts | 26 +- .../nx/src/command-line/migrate/migrate.ts | 43 +- packages/nx/src/devkit-internals.ts | 1 + .../nx/src/plugins/js/lock-file/lock-file.ts | 7 +- .../plugins/js/lock-file/pnpm-parser.spec.ts | 63 ++- .../src/plugins/js/lock-file/pnpm-parser.ts | 32 +- .../js/lock-file/project-graph-pruning.ts | 49 +- .../package-json/create-package-json.spec.ts | 6 + .../js/package-json/create-package-json.ts | 32 +- packages/nx/src/utils/catalog/errors.ts | 17 + packages/nx/src/utils/catalog/index.spec.ts | 137 +++++ packages/nx/src/utils/catalog/index.ts | 65 +++ .../src/utils/catalog/manager-factory.spec.ts | 55 ++ .../nx/src/utils/catalog/manager-factory.ts | 29 + packages/nx/src/utils/catalog/manager.ts | 72 +++ .../nx/src/utils/catalog/pnpm-manager.spec.ts | 268 +++++++++ packages/nx/src/utils/catalog/pnpm-manager.ts | 309 +++++++++++ packages/nx/src/utils/catalog/types.ts | 19 + .../src/utils/catalog/unsupported-manager.ts | 71 +++ packages/nx/src/utils/package-json.ts | 73 ++- packages/nx/src/utils/package-manager.ts | 62 ++- packages/nx/src/utils/pnpm-workspace.ts | 9 + packages/react/src/utils/version-utils.ts | 21 +- .../application/application.impl.ts | 11 +- .../src/generators/application/lib/add-e2e.ts | 6 +- .../application/lib/ignore-vite-temp-files.ts | 4 +- packages/remix/src/utils/versions.ts | 11 +- .../src/generators/vitest/vitest-generator.ts | 18 +- packages/vite/src/utils/version-utils.ts | 22 +- pnpm-lock.yaml | 3 + 81 files changed, 3407 insertions(+), 491 deletions(-) create mode 100644 docs/generated/devkit/getDependencyVersionFromPackageJson.md create mode 100644 packages/devkit/src/utils/catalog/errors.ts create mode 100644 packages/devkit/src/utils/catalog/index.ts create mode 100644 packages/devkit/src/utils/catalog/manager-factory.ts create mode 100644 packages/devkit/src/utils/catalog/manager.ts create mode 100644 packages/devkit/src/utils/catalog/pnpm-manager.ts create mode 100644 packages/devkit/src/utils/catalog/types.ts create mode 100644 packages/devkit/src/utils/catalog/unsupported-manager.ts create mode 100644 packages/nx/src/utils/catalog/errors.ts create mode 100644 packages/nx/src/utils/catalog/index.spec.ts create mode 100644 packages/nx/src/utils/catalog/index.ts create mode 100644 packages/nx/src/utils/catalog/manager-factory.spec.ts create mode 100644 packages/nx/src/utils/catalog/manager-factory.ts create mode 100644 packages/nx/src/utils/catalog/manager.ts create mode 100644 packages/nx/src/utils/catalog/pnpm-manager.spec.ts create mode 100644 packages/nx/src/utils/catalog/pnpm-manager.ts create mode 100644 packages/nx/src/utils/catalog/types.ts create mode 100644 packages/nx/src/utils/catalog/unsupported-manager.ts create mode 100644 packages/nx/src/utils/pnpm-workspace.ts diff --git a/docs/generated/devkit/README.md b/docs/generated/devkit/README.md index ad5e999e7e0e3..3cffad1870d4f 100644 --- a/docs/generated/devkit/README.md +++ b/docs/generated/devkit/README.md @@ -126,6 +126,7 @@ It only uses language primitives and immutable objects - [extractLayoutDirectory](/reference/core-api/devkit/documents/extractLayoutDirectory) - [formatFiles](/reference/core-api/devkit/documents/formatFiles) - [generateFiles](/reference/core-api/devkit/documents/generateFiles) +- [getDependencyVersionFromPackageJson](/reference/core-api/devkit/documents/getDependencyVersionFromPackageJson) - [getOutputsForTargetAndConfiguration](/reference/core-api/devkit/documents/getOutputsForTargetAndConfiguration) - [getPackageManagerCommand](/reference/core-api/devkit/documents/getPackageManagerCommand) - [getPackageManagerVersion](/reference/core-api/devkit/documents/getPackageManagerVersion) diff --git a/docs/generated/devkit/getDependencyVersionFromPackageJson.md b/docs/generated/devkit/getDependencyVersionFromPackageJson.md new file mode 100644 index 0000000000000..011e4742b696c --- /dev/null +++ b/docs/generated/devkit/getDependencyVersionFromPackageJson.md @@ -0,0 +1,110 @@ +# Function: getDependencyVersionFromPackageJson + +▸ **getDependencyVersionFromPackageJson**(`tree`, `packageName`, `packageJsonPath?`): `string` \| `null` + +Get the resolved version of a dependency from package.json. + +Retrieves a package version and automatically resolves PNPM catalog references +(e.g., "catalog:default") to their actual version strings. Searches `dependencies` +first, then falls back to `devDependencies`. + +**Tree-based usage** (generators and migrations): +Use when you have a `Tree` object, which is typical in Nx generators and migrations. + +**Filesystem-based usage** (CLI commands and scripts): +Use when reading directly from the filesystem without a `Tree` object. + +#### Parameters + +| Name | Type | +| :----------------- | :-------------------------------------------------- | +| `tree` | [`Tree`](/reference/core-api/devkit/documents/Tree) | +| `packageName` | `string` | +| `packageJsonPath?` | `string` | + +#### Returns + +`string` \| `null` + +The resolved version string, or `null` if the package is not found in either dependencies or devDependencies + +**`Example`** + +```typescript +// Tree-based - from root package.json +const reactVersion = getDependencyVersionFromPackageJson(tree, 'react'); +// Returns: "^18.0.0" (resolves "catalog:default" if present) + +// Tree-based - from specific package.json +const version = getDependencyVersionFromPackageJson( + tree, + '@my/lib', + 'packages/my-lib/package.json' +); + +// Tree-based - with pre-loaded package.json +const packageJson = readJson(tree, 'package.json'); +const version = getDependencyVersionFromPackageJson(tree, 'react', packageJson); +``` + +**`Example`** + +```typescript +// Filesystem-based - from current directory +const reactVersion = getDependencyVersionFromPackageJson('react'); + +// Filesystem-based - with workspace root +const version = getDependencyVersionFromPackageJson( + 'react', + '/path/to/workspace' +); + +// Filesystem-based - with specific package.json +const version = getDependencyVersionFromPackageJson( + 'react', + '/path/to/workspace', + 'apps/my-app/package.json' +); +``` + +▸ **getDependencyVersionFromPackageJson**(`tree`, `packageName`, `packageJson?`): `string` \| `null` + +#### Parameters + +| Name | Type | +| :------------- | :-------------------------------------------------- | +| `tree` | [`Tree`](/reference/core-api/devkit/documents/Tree) | +| `packageName` | `string` | +| `packageJson?` | `PackageJson` | + +#### Returns + +`string` \| `null` + +▸ **getDependencyVersionFromPackageJson**(`packageName`, `workspaceRootPath?`, `packageJsonPath?`): `string` \| `null` + +#### Parameters + +| Name | Type | +| :------------------- | :------- | +| `packageName` | `string` | +| `workspaceRootPath?` | `string` | +| `packageJsonPath?` | `string` | + +#### Returns + +`string` \| `null` + +▸ **getDependencyVersionFromPackageJson**(`packageName`, `workspaceRootPath?`, `packageJson?`): `string` \| `null` + +#### Parameters + +| Name | Type | +| :------------------- | :------------ | +| `packageName` | `string` | +| `workspaceRootPath?` | `string` | +| `packageJson?` | `PackageJson` | + +#### Returns + +`string` \| `null` diff --git a/docs/generated/packages/devkit/documents/nx_devkit.md b/docs/generated/packages/devkit/documents/nx_devkit.md index ad5e999e7e0e3..3cffad1870d4f 100644 --- a/docs/generated/packages/devkit/documents/nx_devkit.md +++ b/docs/generated/packages/devkit/documents/nx_devkit.md @@ -126,6 +126,7 @@ It only uses language primitives and immutable objects - [extractLayoutDirectory](/reference/core-api/devkit/documents/extractLayoutDirectory) - [formatFiles](/reference/core-api/devkit/documents/formatFiles) - [generateFiles](/reference/core-api/devkit/documents/generateFiles) +- [getDependencyVersionFromPackageJson](/reference/core-api/devkit/documents/getDependencyVersionFromPackageJson) - [getOutputsForTargetAndConfiguration](/reference/core-api/devkit/documents/getOutputsForTargetAndConfiguration) - [getPackageManagerCommand](/reference/core-api/devkit/documents/getPackageManagerCommand) - [getPackageManagerVersion](/reference/core-api/devkit/documents/getPackageManagerVersion) diff --git a/packages/angular/src/generators/init/init.ts b/packages/angular/src/generators/init/init.ts index 95dfb65bd0958..7c149c8406572 100755 --- a/packages/angular/src/generators/init/init.ts +++ b/packages/angular/src/generators/init/init.ts @@ -4,6 +4,7 @@ import { ensurePackage, formatFiles, type GeneratorCallback, + getDependencyVersionFromPackageJson, logger, readNxJson, type Tree, @@ -13,7 +14,6 @@ import { assertNotUsingTsSolutionSetup } from '@nx/js/src/utils/typescript/ts-so import { createNodesV2 } from '../../plugins/plugin'; import { getInstalledAngularDevkitVersion, - getInstalledPackageVersion, versions, } from '../utils/version-utils'; import { Schema } from './schema'; @@ -57,7 +57,7 @@ function installAngularDevkitCoreIfMissing( tree: Tree, options: Schema ): GeneratorCallback { - const packageVersion = getInstalledPackageVersion( + const packageVersion = getDependencyVersionFromPackageJson( tree, '@angular-devkit/core' ); diff --git a/packages/angular/src/generators/ngrx-feature-store/lib/normalize-options.ts b/packages/angular/src/generators/ngrx-feature-store/lib/normalize-options.ts index f1106362c3e8a..577f883fd4d99 100644 --- a/packages/angular/src/generators/ngrx-feature-store/lib/normalize-options.ts +++ b/packages/angular/src/generators/ngrx-feature-store/lib/normalize-options.ts @@ -20,11 +20,12 @@ export function normalizeOptions( let rxjsVersion: string; try { rxjsVersion = checkAndCleanWithSemver( + tree, 'rxjs', readJson(tree, 'package.json').dependencies['rxjs'] ); } catch { - rxjsVersion = checkAndCleanWithSemver('rxjs', defaultRxjsVersion); + rxjsVersion = checkAndCleanWithSemver(tree, 'rxjs', defaultRxjsVersion); } const rxjsMajorVersion = major(rxjsVersion); diff --git a/packages/angular/src/generators/ngrx-root-store/lib/normalize-options.ts b/packages/angular/src/generators/ngrx-root-store/lib/normalize-options.ts index 3696a9228d201..c19d6262b58b9 100644 --- a/packages/angular/src/generators/ngrx-root-store/lib/normalize-options.ts +++ b/packages/angular/src/generators/ngrx-root-store/lib/normalize-options.ts @@ -23,11 +23,12 @@ export function normalizeOptions( let rxjsVersion: string; try { rxjsVersion = checkAndCleanWithSemver( + tree, 'rxjs', readJson(tree, 'package.json').dependencies['rxjs'] ); } catch { - rxjsVersion = checkAndCleanWithSemver('rxjs', defaultRxjsVersion); + rxjsVersion = checkAndCleanWithSemver(tree, 'rxjs', defaultRxjsVersion); } const project = readProjectConfiguration(tree, options.project); diff --git a/packages/angular/src/generators/ngrx/lib/normalize-options.ts b/packages/angular/src/generators/ngrx/lib/normalize-options.ts index 57cfdb142dc42..fc05e91503a5f 100644 --- a/packages/angular/src/generators/ngrx/lib/normalize-options.ts +++ b/packages/angular/src/generators/ngrx/lib/normalize-options.ts @@ -18,11 +18,12 @@ export function normalizeOptions( let rxjsVersion: string; try { rxjsVersion = checkAndCleanWithSemver( + tree, 'rxjs', readJson(tree, 'package.json').dependencies['rxjs'] ); } catch { - rxjsVersion = checkAndCleanWithSemver('rxjs', defaultRxjsVersion); + rxjsVersion = checkAndCleanWithSemver(tree, 'rxjs', defaultRxjsVersion); } const rxjsMajorVersion = major(rxjsVersion); diff --git a/packages/angular/src/generators/setup-ssr/lib/add-dependencies.ts b/packages/angular/src/generators/setup-ssr/lib/add-dependencies.ts index 63bdc28d9c428..8878c4d2147e5 100644 --- a/packages/angular/src/generators/setup-ssr/lib/add-dependencies.ts +++ b/packages/angular/src/generators/setup-ssr/lib/add-dependencies.ts @@ -1,8 +1,11 @@ -import { addDependenciesToPackageJson, type Tree } from '@nx/devkit'; +import { + addDependenciesToPackageJson, + getDependencyVersionFromPackageJson, + type Tree, +} from '@nx/devkit'; import { getInstalledAngularDevkitVersion, getInstalledAngularVersionInfo, - getInstalledPackageVersion, versions, } from '../../utils/version-utils'; @@ -14,7 +17,7 @@ export function addDependencies( const dependencies: Record = { '@angular/platform-server': - getInstalledPackageVersion(tree, '@angular/platform-server') ?? + getDependencyVersionFromPackageJson(tree, '@angular/platform-server') ?? pkgVersions.angularVersion, express: pkgVersions.expressVersion, }; diff --git a/packages/angular/src/generators/setup-ssr/lib/generate-files.ts b/packages/angular/src/generators/setup-ssr/lib/generate-files.ts index 976f4707a4466..5fea879736130 100644 --- a/packages/angular/src/generators/setup-ssr/lib/generate-files.ts +++ b/packages/angular/src/generators/setup-ssr/lib/generate-files.ts @@ -1,6 +1,7 @@ import type { Tree } from '@nx/devkit'; import { generateFiles, + getDependencyVersionFromPackageJson, joinPathFragments, readProjectConfiguration, } from '@nx/devkit'; @@ -12,10 +13,7 @@ import { getComponentType, getModuleTypeSeparator, } from '../../utils/artifact-types'; -import { - getInstalledAngularVersionInfo, - getInstalledPackageVersion, -} from '../../utils/version-utils'; +import { getInstalledAngularVersionInfo } from '../../utils/version-utils'; import type { NormalizedGeneratorOptions } from '../schema'; export function generateSSRFiles( @@ -65,7 +63,7 @@ export function generateSSRFiles( const sourceRoot = getProjectSourceRoot(project, tree); - const ssrVersion = getInstalledPackageVersion(tree, '@angular/ssr'); + const ssrVersion = getDependencyVersionFromPackageJson(tree, '@angular/ssr'); const cleanedSsrVersion = ssrVersion ? clean(ssrVersion) ?? coerce(ssrVersion).version : null; diff --git a/packages/angular/src/generators/setup-tailwind/lib/detect-tailwind-installed-version.ts b/packages/angular/src/generators/setup-tailwind/lib/detect-tailwind-installed-version.ts index a2aa5a70981ba..998203d8a9c05 100644 --- a/packages/angular/src/generators/setup-tailwind/lib/detect-tailwind-installed-version.ts +++ b/packages/angular/src/generators/setup-tailwind/lib/detect-tailwind-installed-version.ts @@ -13,7 +13,7 @@ export function detectTailwindInstalledVersion( return undefined; } - const version = checkAndCleanWithSemver('tailwindcss', tailwindVersion); + const version = checkAndCleanWithSemver(tree, 'tailwindcss', tailwindVersion); if (lt(version, '2.0.0')) { throw new Error( `The Tailwind CSS version "${tailwindVersion}" is not supported. Please upgrade to v2.0.0 or higher.` diff --git a/packages/angular/src/generators/utils/ensure-angular-dependencies.ts b/packages/angular/src/generators/utils/ensure-angular-dependencies.ts index 3d93aabe97768..4b85d1eb92be0 100644 --- a/packages/angular/src/generators/utils/ensure-angular-dependencies.ts +++ b/packages/angular/src/generators/utils/ensure-angular-dependencies.ts @@ -1,12 +1,14 @@ import { addDependenciesToPackageJson, + getDependencyVersionFromPackageJson, + readJson, type GeneratorCallback, type Tree, } from '@nx/devkit'; +import type { PackageJson } from 'nx/src/utils/package-json'; import { getInstalledAngularDevkitVersion, getInstalledAngularVersionInfo, - getInstalledPackageVersion, versions, } from './version-utils'; @@ -15,9 +17,11 @@ export function ensureAngularDependencies(tree: Tree): GeneratorCallback { const devDependencies: Record = {}; const pkgVersions = versions(tree); - const installedAngularCoreVersion = getInstalledPackageVersion( + const packageJson = readJson(tree, 'package.json'); + const installedAngularCoreVersion = getDependencyVersionFromPackageJson( tree, - '@angular/core' + '@angular/core', + packageJson ); if (!installedAngularCoreVersion) { /** @@ -28,11 +32,14 @@ export function ensureAngularDependencies(tree: Tree): GeneratorCallback { */ const angularVersion = pkgVersions.angularVersion; const rxjsVersion = - getInstalledPackageVersion(tree, 'rxjs') ?? pkgVersions.rxjsVersion; + getDependencyVersionFromPackageJson(tree, 'rxjs', packageJson) ?? + pkgVersions.rxjsVersion; const tsLibVersion = - getInstalledPackageVersion(tree, 'tslib') ?? pkgVersions.tsLibVersion; + getDependencyVersionFromPackageJson(tree, 'tslib', packageJson) ?? + pkgVersions.tsLibVersion; const zoneJsVersion = - getInstalledPackageVersion(tree, 'zone.js') ?? pkgVersions.zoneJsVersion; + getDependencyVersionFromPackageJson(tree, 'zone.js', packageJson) ?? + pkgVersions.zoneJsVersion; dependencies['@angular/common'] = angularVersion; dependencies['@angular/compiler'] = angularVersion; diff --git a/packages/angular/src/generators/utils/version-utils.ts b/packages/angular/src/generators/utils/version-utils.ts index 2036e7aa24857..b66188a710d58 100644 --- a/packages/angular/src/generators/utils/version-utils.ts +++ b/packages/angular/src/generators/utils/version-utils.ts @@ -1,4 +1,4 @@ -import { readJson, type Tree } from '@nx/devkit'; +import { getDependencyVersionFromPackageJson, type Tree } from '@nx/devkit'; import { clean, coerce, major } from 'semver'; import { backwardCompatibleVersions, @@ -10,15 +10,18 @@ import { angularVersion } from '../../utils/versions'; export function getInstalledAngularDevkitVersion(tree: Tree): string | null { return ( - getInstalledPackageVersion(tree, '@angular-devkit/build-angular') ?? - getInstalledPackageVersion(tree, '@angular/build') + getDependencyVersionFromPackageJson( + tree, + '@angular-devkit/build-angular' + ) ?? getDependencyVersionFromPackageJson(tree, '@angular/build') ); } export function getInstalledAngularVersion(tree: Tree): string { - const pkgJson = readJson(tree, 'package.json'); - const installedAngularVersion = - pkgJson.dependencies && pkgJson.dependencies['@angular/core']; + const installedAngularVersion = getDependencyVersionFromPackageJson( + tree, + '@angular/core' + ); if ( !installedAngularVersion || @@ -46,18 +49,8 @@ export function getInstalledAngularVersionInfo(tree: Tree) { }; } -export function getInstalledPackageVersion( - tree: Tree, - pkgName: string -): string | null { - const { dependencies, devDependencies } = readJson(tree, 'package.json'); - const version = dependencies?.[pkgName] ?? devDependencies?.[pkgName]; - - return version; -} - export function getInstalledPackageVersionInfo(tree: Tree, pkgName: string) { - const version = getInstalledPackageVersion(tree, pkgName); + const version = getDependencyVersionFromPackageJson(tree, pkgName); return version ? { major: major(coerce(version)), version } : null; } diff --git a/packages/angular/src/migrations/update-16-4-0/update-angular-cli.ts b/packages/angular/src/migrations/update-16-4-0/update-angular-cli.ts index 75ab78daffa8b..5f9efd011dc4b 100644 --- a/packages/angular/src/migrations/update-16-4-0/update-angular-cli.ts +++ b/packages/angular/src/migrations/update-16-4-0/update-angular-cli.ts @@ -1,23 +1,23 @@ -import { formatFiles, Tree, updateJson } from '@nx/devkit'; +import { + addDependenciesToPackageJson, + formatFiles, + readJson, + Tree, +} from '@nx/devkit'; export const angularCliVersion = '~16.1.0'; export default async function (tree: Tree) { - let shouldFormat = false; + const { devDependencies, dependencies } = readJson(tree, 'package.json'); + const hasAngularCli = + devDependencies?.['@angular/cli'] || dependencies?.['@angular/cli']; - updateJson(tree, 'package.json', (json) => { - if (json.devDependencies?.['@angular/cli']) { - json.devDependencies['@angular/cli'] = angularCliVersion; - shouldFormat = true; - } else if (json.dependencies?.['@angular/cli']) { - json.dependencies['@angular/cli'] = angularCliVersion; - shouldFormat = true; - } - - return json; - }); - - if (shouldFormat) { + if (hasAngularCli) { + addDependenciesToPackageJson( + tree, + {}, + { '@angular/cli': angularCliVersion } + ); await formatFiles(tree); } } diff --git a/packages/angular/src/migrations/update-16-7-0/update-angular-cli.ts b/packages/angular/src/migrations/update-16-7-0/update-angular-cli.ts index 9beb91a91ef0d..b3df71fe1ade1 100644 --- a/packages/angular/src/migrations/update-16-7-0/update-angular-cli.ts +++ b/packages/angular/src/migrations/update-16-7-0/update-angular-cli.ts @@ -1,23 +1,23 @@ -import { formatFiles, Tree, updateJson } from '@nx/devkit'; +import { + addDependenciesToPackageJson, + formatFiles, + readJson, + Tree, +} from '@nx/devkit'; export const angularCliVersion = '~16.2.0'; export default async function (tree: Tree) { - let shouldFormat = false; + const { devDependencies, dependencies } = readJson(tree, 'package.json'); + const hasAngularCli = + devDependencies?.['@angular/cli'] || dependencies?.['@angular/cli']; - updateJson(tree, 'package.json', (json) => { - if (json.devDependencies?.['@angular/cli']) { - json.devDependencies['@angular/cli'] = angularCliVersion; - shouldFormat = true; - } else if (json.dependencies?.['@angular/cli']) { - json.dependencies['@angular/cli'] = angularCliVersion; - shouldFormat = true; - } - - return json; - }); - - if (shouldFormat) { + if (hasAngularCli) { + addDependenciesToPackageJson( + tree, + {}, + { '@angular/cli': angularCliVersion } + ); await formatFiles(tree); } } diff --git a/packages/angular/src/migrations/update-17-1-0/update-angular-cli.ts b/packages/angular/src/migrations/update-17-1-0/update-angular-cli.ts index 1927a931bdeb4..14975d70644b4 100644 --- a/packages/angular/src/migrations/update-17-1-0/update-angular-cli.ts +++ b/packages/angular/src/migrations/update-17-1-0/update-angular-cli.ts @@ -1,23 +1,23 @@ -import { formatFiles, Tree, updateJson } from '@nx/devkit'; +import { + addDependenciesToPackageJson, + formatFiles, + readJson, + Tree, +} from '@nx/devkit'; export const angularCliVersion = '~17.0.0'; export default async function (tree: Tree) { - let shouldFormat = false; + const { devDependencies, dependencies } = readJson(tree, 'package.json'); + const hasAngularCli = + devDependencies?.['@angular/cli'] || dependencies?.['@angular/cli']; - updateJson(tree, 'package.json', (json) => { - if (json.devDependencies?.['@angular/cli']) { - json.devDependencies['@angular/cli'] = angularCliVersion; - shouldFormat = true; - } else if (json.dependencies?.['@angular/cli']) { - json.dependencies['@angular/cli'] = angularCliVersion; - shouldFormat = true; - } - - return json; - }); - - if (shouldFormat) { + if (hasAngularCli) { + addDependenciesToPackageJson( + tree, + {}, + { '@angular/cli': angularCliVersion } + ); await formatFiles(tree); } } diff --git a/packages/angular/src/migrations/update-17-3-0/add-autoprefixer-dependency.ts b/packages/angular/src/migrations/update-17-3-0/add-autoprefixer-dependency.ts index 90ffb20e39a5d..301ae255c2ae1 100644 --- a/packages/angular/src/migrations/update-17-3-0/add-autoprefixer-dependency.ts +++ b/packages/angular/src/migrations/update-17-3-0/add-autoprefixer-dependency.ts @@ -1,13 +1,16 @@ import { addDependenciesToPackageJson, formatFiles, + getDependencyVersionFromPackageJson, getProjects, type Tree, } from '@nx/devkit'; -import { getInstalledPackageVersion } from '../../generators/utils/version-utils'; export default async function (tree: Tree) { - const autprefixerVersion = getInstalledPackageVersion(tree, 'autoprefixer'); + const autprefixerVersion = getDependencyVersionFromPackageJson( + tree, + 'autoprefixer' + ); if (autprefixerVersion) { return; } diff --git a/packages/angular/src/migrations/update-17-3-0/add-browser-sync-dependency.ts b/packages/angular/src/migrations/update-17-3-0/add-browser-sync-dependency.ts index 153de28b51301..87b1d499895b3 100644 --- a/packages/angular/src/migrations/update-17-3-0/add-browser-sync-dependency.ts +++ b/packages/angular/src/migrations/update-17-3-0/add-browser-sync-dependency.ts @@ -1,13 +1,16 @@ import { addDependenciesToPackageJson, formatFiles, + getDependencyVersionFromPackageJson, getProjects, type Tree, } from '@nx/devkit'; -import { getInstalledPackageVersion } from '../../generators/utils/version-utils'; export default async function (tree: Tree) { - const browserSyncVersion = getInstalledPackageVersion(tree, 'browser-sync'); + const browserSyncVersion = getDependencyVersionFromPackageJson( + tree, + 'browser-sync' + ); if (browserSyncVersion) { return; } diff --git a/packages/angular/src/migrations/update-17-3-0/update-angular-cli.ts b/packages/angular/src/migrations/update-17-3-0/update-angular-cli.ts index c5d8df11d2439..ad72c29291947 100644 --- a/packages/angular/src/migrations/update-17-3-0/update-angular-cli.ts +++ b/packages/angular/src/migrations/update-17-3-0/update-angular-cli.ts @@ -1,23 +1,23 @@ -import { formatFiles, Tree, updateJson } from '@nx/devkit'; +import { + addDependenciesToPackageJson, + formatFiles, + readJson, + Tree, +} from '@nx/devkit'; export const angularCliVersion = '~17.1.0'; export default async function (tree: Tree) { - let shouldFormat = false; + const { devDependencies, dependencies } = readJson(tree, 'package.json'); + const hasAngularCli = + devDependencies?.['@angular/cli'] || dependencies?.['@angular/cli']; - updateJson(tree, 'package.json', (json) => { - if (json.devDependencies?.['@angular/cli']) { - json.devDependencies['@angular/cli'] = angularCliVersion; - shouldFormat = true; - } else if (json.dependencies?.['@angular/cli']) { - json.dependencies['@angular/cli'] = angularCliVersion; - shouldFormat = true; - } - - return json; - }); - - if (shouldFormat) { + if (hasAngularCli) { + addDependenciesToPackageJson( + tree, + {}, + { '@angular/cli': angularCliVersion } + ); await formatFiles(tree); } } diff --git a/packages/angular/src/migrations/update-18-1-0/update-angular-cli.ts b/packages/angular/src/migrations/update-18-1-0/update-angular-cli.ts index e9be524b0a990..b13553234a67b 100644 --- a/packages/angular/src/migrations/update-18-1-0/update-angular-cli.ts +++ b/packages/angular/src/migrations/update-18-1-0/update-angular-cli.ts @@ -1,23 +1,23 @@ -import { formatFiles, Tree, updateJson } from '@nx/devkit'; +import { + addDependenciesToPackageJson, + formatFiles, + readJson, + Tree, +} from '@nx/devkit'; export const angularCliVersion = '~17.2.0'; export default async function (tree: Tree) { - let shouldFormat = false; + const { devDependencies, dependencies } = readJson(tree, 'package.json'); + const hasAngularCli = + devDependencies?.['@angular/cli'] || dependencies?.['@angular/cli']; - updateJson(tree, 'package.json', (json) => { - if (json.devDependencies?.['@angular/cli']) { - json.devDependencies['@angular/cli'] = angularCliVersion; - shouldFormat = true; - } else if (json.dependencies?.['@angular/cli']) { - json.dependencies['@angular/cli'] = angularCliVersion; - shouldFormat = true; - } - - return json; - }); - - if (shouldFormat) { + if (hasAngularCli) { + addDependenciesToPackageJson( + tree, + {}, + { '@angular/cli': angularCliVersion } + ); await formatFiles(tree); } } diff --git a/packages/angular/src/migrations/update-18-2-0/update-angular-cli.ts b/packages/angular/src/migrations/update-18-2-0/update-angular-cli.ts index 70fd8bd4f3369..dc9c37b992caa 100644 --- a/packages/angular/src/migrations/update-18-2-0/update-angular-cli.ts +++ b/packages/angular/src/migrations/update-18-2-0/update-angular-cli.ts @@ -1,23 +1,23 @@ -import { formatFiles, Tree, updateJson } from '@nx/devkit'; +import { + addDependenciesToPackageJson, + formatFiles, + readJson, + Tree, +} from '@nx/devkit'; export const angularCliVersion = '~17.3.0'; export default async function (tree: Tree) { - let shouldFormat = false; + const { devDependencies, dependencies } = readJson(tree, 'package.json'); + const hasAngularCli = + devDependencies?.['@angular/cli'] || dependencies?.['@angular/cli']; - updateJson(tree, 'package.json', (json) => { - if (json.devDependencies?.['@angular/cli']) { - json.devDependencies['@angular/cli'] = angularCliVersion; - shouldFormat = true; - } else if (json.dependencies?.['@angular/cli']) { - json.dependencies['@angular/cli'] = angularCliVersion; - shouldFormat = true; - } - - return json; - }); - - if (shouldFormat) { + if (hasAngularCli) { + addDependenciesToPackageJson( + tree, + {}, + { '@angular/cli': angularCliVersion } + ); await formatFiles(tree); } } diff --git a/packages/angular/src/migrations/update-19-1-0/update-angular-cli.ts b/packages/angular/src/migrations/update-19-1-0/update-angular-cli.ts index bb2aca2cc613c..f95876f922726 100644 --- a/packages/angular/src/migrations/update-19-1-0/update-angular-cli.ts +++ b/packages/angular/src/migrations/update-19-1-0/update-angular-cli.ts @@ -1,23 +1,23 @@ -import { formatFiles, Tree, updateJson } from '@nx/devkit'; +import { + addDependenciesToPackageJson, + formatFiles, + readJson, + Tree, +} from '@nx/devkit'; export const angularCliVersion = '~18.0.0'; export default async function (tree: Tree) { - let shouldFormat = false; + const { devDependencies, dependencies } = readJson(tree, 'package.json'); + const hasAngularCli = + devDependencies?.['@angular/cli'] || dependencies?.['@angular/cli']; - updateJson(tree, 'package.json', (json) => { - if (json.devDependencies?.['@angular/cli']) { - json.devDependencies['@angular/cli'] = angularCliVersion; - shouldFormat = true; - } else if (json.dependencies?.['@angular/cli']) { - json.dependencies['@angular/cli'] = angularCliVersion; - shouldFormat = true; - } - - return json; - }); - - if (shouldFormat) { + if (hasAngularCli) { + addDependenciesToPackageJson( + tree, + {}, + { '@angular/cli': angularCliVersion } + ); await formatFiles(tree); } } diff --git a/packages/angular/src/migrations/update-19-2-1/add-typescript-eslint-utils.ts b/packages/angular/src/migrations/update-19-2-1/add-typescript-eslint-utils.ts index 58fbcfe8af01b..54468a81c0fcc 100644 --- a/packages/angular/src/migrations/update-19-2-1/add-typescript-eslint-utils.ts +++ b/packages/angular/src/migrations/update-19-2-1/add-typescript-eslint-utils.ts @@ -1,17 +1,15 @@ import { addDependenciesToPackageJson, formatFiles, + getDependencyVersionFromPackageJson, type Tree, } from '@nx/devkit'; -import { - getInstalledPackageVersion, - getInstalledPackageVersionInfo, -} from '../../generators/utils/version-utils'; +import { getInstalledPackageVersionInfo } from '../../generators/utils/version-utils'; export const typescriptEslintUtilsVersion = '^7.16.0'; export default async function (tree: Tree) { - if (getInstalledPackageVersion(tree, '@typescript-eslint/utils')) { + if (getDependencyVersionFromPackageJson(tree, '@typescript-eslint/utils')) { return; } diff --git a/packages/angular/src/migrations/update-19-5-0/update-angular-cli.ts b/packages/angular/src/migrations/update-19-5-0/update-angular-cli.ts index b996b31041ed8..825e4c0fbe100 100644 --- a/packages/angular/src/migrations/update-19-5-0/update-angular-cli.ts +++ b/packages/angular/src/migrations/update-19-5-0/update-angular-cli.ts @@ -1,23 +1,23 @@ -import { formatFiles, Tree, updateJson } from '@nx/devkit'; +import { + addDependenciesToPackageJson, + formatFiles, + readJson, + Tree, +} from '@nx/devkit'; export const angularCliVersion = '~18.1.0'; export default async function (tree: Tree) { - let shouldFormat = false; + const { devDependencies, dependencies } = readJson(tree, 'package.json'); + const hasAngularCli = + devDependencies?.['@angular/cli'] || dependencies?.['@angular/cli']; - updateJson(tree, 'package.json', (json) => { - if (json.devDependencies?.['@angular/cli']) { - json.devDependencies['@angular/cli'] = angularCliVersion; - shouldFormat = true; - } else if (json.dependencies?.['@angular/cli']) { - json.dependencies['@angular/cli'] = angularCliVersion; - shouldFormat = true; - } - - return json; - }); - - if (shouldFormat) { + if (hasAngularCli) { + addDependenciesToPackageJson( + tree, + {}, + { '@angular/cli': angularCliVersion } + ); await formatFiles(tree); } } diff --git a/packages/angular/src/migrations/update-19-6-0/update-angular-cli.ts b/packages/angular/src/migrations/update-19-6-0/update-angular-cli.ts index cc79aada2c25d..bdec4501548aa 100644 --- a/packages/angular/src/migrations/update-19-6-0/update-angular-cli.ts +++ b/packages/angular/src/migrations/update-19-6-0/update-angular-cli.ts @@ -1,23 +1,23 @@ -import { formatFiles, Tree, updateJson } from '@nx/devkit'; +import { + addDependenciesToPackageJson, + formatFiles, + readJson, + Tree, +} from '@nx/devkit'; export const angularCliVersion = '~18.2.0'; export default async function (tree: Tree) { - let shouldFormat = false; + const { devDependencies, dependencies } = readJson(tree, 'package.json'); + const hasAngularCli = + devDependencies?.['@angular/cli'] || dependencies?.['@angular/cli']; - updateJson(tree, 'package.json', (json) => { - if (json.devDependencies?.['@angular/cli']) { - json.devDependencies['@angular/cli'] = angularCliVersion; - shouldFormat = true; - } else if (json.dependencies?.['@angular/cli']) { - json.dependencies['@angular/cli'] = angularCliVersion; - shouldFormat = true; - } - - return json; - }); - - if (shouldFormat) { + if (hasAngularCli) { + addDependenciesToPackageJson( + tree, + {}, + { '@angular/cli': angularCliVersion } + ); await formatFiles(tree); } } diff --git a/packages/angular/src/migrations/update-20-2-0/update-angular-cli.ts b/packages/angular/src/migrations/update-20-2-0/update-angular-cli.ts index 574293b47733a..86413330122db 100644 --- a/packages/angular/src/migrations/update-20-2-0/update-angular-cli.ts +++ b/packages/angular/src/migrations/update-20-2-0/update-angular-cli.ts @@ -1,23 +1,23 @@ -import { formatFiles, Tree, updateJson } from '@nx/devkit'; +import { + addDependenciesToPackageJson, + formatFiles, + readJson, + Tree, +} from '@nx/devkit'; export const angularCliVersion = '~19.0.0'; export default async function (tree: Tree) { - let shouldFormat = false; + const { devDependencies, dependencies } = readJson(tree, 'package.json'); + const hasAngularCli = + devDependencies?.['@angular/cli'] || dependencies?.['@angular/cli']; - updateJson(tree, 'package.json', (json) => { - if (json.devDependencies?.['@angular/cli']) { - json.devDependencies['@angular/cli'] = angularCliVersion; - shouldFormat = true; - } else if (json.dependencies?.['@angular/cli']) { - json.dependencies['@angular/cli'] = angularCliVersion; - shouldFormat = true; - } - - return json; - }); - - if (shouldFormat) { + if (hasAngularCli) { + addDependenciesToPackageJson( + tree, + {}, + { '@angular/cli': angularCliVersion } + ); await formatFiles(tree); } } diff --git a/packages/angular/src/migrations/update-20-4-0/update-angular-cli.ts b/packages/angular/src/migrations/update-20-4-0/update-angular-cli.ts index b5a09f8a71815..608bf95f807af 100644 --- a/packages/angular/src/migrations/update-20-4-0/update-angular-cli.ts +++ b/packages/angular/src/migrations/update-20-4-0/update-angular-cli.ts @@ -1,23 +1,23 @@ -import { formatFiles, Tree, updateJson } from '@nx/devkit'; +import { + addDependenciesToPackageJson, + formatFiles, + readJson, + Tree, +} from '@nx/devkit'; export const angularCliVersion = '~19.1.0'; export default async function (tree: Tree) { - let shouldFormat = false; + const { devDependencies, dependencies } = readJson(tree, 'package.json'); + const hasAngularCli = + devDependencies?.['@angular/cli'] || dependencies?.['@angular/cli']; - updateJson(tree, 'package.json', (json) => { - if (json.devDependencies?.['@angular/cli']) { - json.devDependencies['@angular/cli'] = angularCliVersion; - shouldFormat = true; - } else if (json.dependencies?.['@angular/cli']) { - json.dependencies['@angular/cli'] = angularCliVersion; - shouldFormat = true; - } - - return json; - }); - - if (shouldFormat) { + if (hasAngularCli) { + addDependenciesToPackageJson( + tree, + {}, + { '@angular/cli': angularCliVersion } + ); await formatFiles(tree); } } diff --git a/packages/angular/src/migrations/update-20-5-0/update-angular-cli.ts b/packages/angular/src/migrations/update-20-5-0/update-angular-cli.ts index ccccdaa87b03a..0a58e3b8d34c5 100644 --- a/packages/angular/src/migrations/update-20-5-0/update-angular-cli.ts +++ b/packages/angular/src/migrations/update-20-5-0/update-angular-cli.ts @@ -1,23 +1,23 @@ -import { formatFiles, Tree, updateJson } from '@nx/devkit'; +import { + addDependenciesToPackageJson, + formatFiles, + readJson, + Tree, +} from '@nx/devkit'; export const angularCliVersion = '~19.2.0'; export default async function (tree: Tree) { - let shouldFormat = false; + const { devDependencies, dependencies } = readJson(tree, 'package.json'); + const hasAngularCli = + devDependencies?.['@angular/cli'] || dependencies?.['@angular/cli']; - updateJson(tree, 'package.json', (json) => { - if (json.devDependencies?.['@angular/cli']) { - json.devDependencies['@angular/cli'] = angularCliVersion; - shouldFormat = true; - } else if (json.dependencies?.['@angular/cli']) { - json.dependencies['@angular/cli'] = angularCliVersion; - shouldFormat = true; - } - - return json; - }); - - if (shouldFormat) { + if (hasAngularCli) { + addDependenciesToPackageJson( + tree, + {}, + { '@angular/cli': angularCliVersion } + ); await formatFiles(tree); } } diff --git a/packages/angular/src/migrations/update-21-2-0/update-angular-cli.ts b/packages/angular/src/migrations/update-21-2-0/update-angular-cli.ts index a7ffaa0326dda..5e6d7ce2dc2b4 100644 --- a/packages/angular/src/migrations/update-21-2-0/update-angular-cli.ts +++ b/packages/angular/src/migrations/update-21-2-0/update-angular-cli.ts @@ -1,23 +1,23 @@ -import { formatFiles, Tree, updateJson } from '@nx/devkit'; +import { + addDependenciesToPackageJson, + formatFiles, + readJson, + Tree, +} from '@nx/devkit'; export const angularCliVersion = '~20.0.0'; export default async function (tree: Tree) { - let shouldFormat = false; + const { devDependencies, dependencies } = readJson(tree, 'package.json'); + const hasAngularCli = + devDependencies?.['@angular/cli'] || dependencies?.['@angular/cli']; - updateJson(tree, 'package.json', (json) => { - if (json.devDependencies?.['@angular/cli']) { - json.devDependencies['@angular/cli'] = angularCliVersion; - shouldFormat = true; - } else if (json.dependencies?.['@angular/cli']) { - json.dependencies['@angular/cli'] = angularCliVersion; - shouldFormat = true; - } - - return json; - }); - - if (shouldFormat) { + if (hasAngularCli) { + addDependenciesToPackageJson( + tree, + {}, + { '@angular/cli': angularCliVersion } + ); await formatFiles(tree); } } diff --git a/packages/angular/src/migrations/update-21-3-0/update-angular-cli.ts b/packages/angular/src/migrations/update-21-3-0/update-angular-cli.ts index d9e056444d5bd..f74db5160451a 100644 --- a/packages/angular/src/migrations/update-21-3-0/update-angular-cli.ts +++ b/packages/angular/src/migrations/update-21-3-0/update-angular-cli.ts @@ -1,23 +1,23 @@ -import { formatFiles, Tree, updateJson } from '@nx/devkit'; +import { + addDependenciesToPackageJson, + formatFiles, + readJson, + Tree, +} from '@nx/devkit'; export const angularCliVersion = '~20.1.0'; export default async function (tree: Tree) { - let shouldFormat = false; + const { devDependencies, dependencies } = readJson(tree, 'package.json'); + const hasAngularCli = + devDependencies?.['@angular/cli'] || dependencies?.['@angular/cli']; - updateJson(tree, 'package.json', (json) => { - if (json.devDependencies?.['@angular/cli']) { - json.devDependencies['@angular/cli'] = angularCliVersion; - shouldFormat = true; - } else if (json.dependencies?.['@angular/cli']) { - json.dependencies['@angular/cli'] = angularCliVersion; - shouldFormat = true; - } - - return json; - }); - - if (shouldFormat) { + if (hasAngularCli) { + addDependenciesToPackageJson( + tree, + {}, + { '@angular/cli': angularCliVersion } + ); await formatFiles(tree); } } diff --git a/packages/angular/src/migrations/update-21-5-0/update-angular-cli.ts b/packages/angular/src/migrations/update-21-5-0/update-angular-cli.ts index ca1884faa5ed7..13485dce57d79 100644 --- a/packages/angular/src/migrations/update-21-5-0/update-angular-cli.ts +++ b/packages/angular/src/migrations/update-21-5-0/update-angular-cli.ts @@ -1,23 +1,23 @@ -import { formatFiles, Tree, updateJson } from '@nx/devkit'; +import { + addDependenciesToPackageJson, + formatFiles, + readJson, + Tree, +} from '@nx/devkit'; export const angularCliVersion = '~20.2.0'; export default async function (tree: Tree) { - let shouldFormat = false; + const { devDependencies, dependencies } = readJson(tree, 'package.json'); + const hasAngularCli = + devDependencies?.['@angular/cli'] || dependencies?.['@angular/cli']; - updateJson(tree, 'package.json', (json) => { - if (json.devDependencies?.['@angular/cli']) { - json.devDependencies['@angular/cli'] = angularCliVersion; - shouldFormat = true; - } else if (json.dependencies?.['@angular/cli']) { - json.dependencies['@angular/cli'] = angularCliVersion; - shouldFormat = true; - } - - return json; - }); - - if (shouldFormat) { + if (hasAngularCli) { + addDependenciesToPackageJson( + tree, + {}, + { '@angular/cli': angularCliVersion } + ); await formatFiles(tree); } } diff --git a/packages/angular/src/migrations/update-21-6-1/update-angular-cli.ts b/packages/angular/src/migrations/update-21-6-1/update-angular-cli.ts index 6e998f0db8316..209c61293b089 100644 --- a/packages/angular/src/migrations/update-21-6-1/update-angular-cli.ts +++ b/packages/angular/src/migrations/update-21-6-1/update-angular-cli.ts @@ -1,23 +1,23 @@ -import { formatFiles, Tree, updateJson } from '@nx/devkit'; +import { + addDependenciesToPackageJson, + formatFiles, + readJson, + Tree, +} from '@nx/devkit'; export const angularCliVersion = '~20.3.0'; export default async function (tree: Tree) { - let shouldFormat = false; + const { devDependencies, dependencies } = readJson(tree, 'package.json'); + const hasAngularCli = + devDependencies?.['@angular/cli'] || dependencies?.['@angular/cli']; - updateJson(tree, 'package.json', (json) => { - if (json.devDependencies?.['@angular/cli']) { - json.devDependencies['@angular/cli'] = angularCliVersion; - shouldFormat = true; - } else if (json.dependencies?.['@angular/cli']) { - json.dependencies['@angular/cli'] = angularCliVersion; - shouldFormat = true; - } - - return json; - }); - - if (shouldFormat) { + if (hasAngularCli) { + addDependenciesToPackageJson( + tree, + {}, + { '@angular/cli': angularCliVersion } + ); await formatFiles(tree); } } diff --git a/packages/cypress/src/utils/versions.ts b/packages/cypress/src/utils/versions.ts index 01aa031e46883..665da39cb8fc0 100644 --- a/packages/cypress/src/utils/versions.ts +++ b/packages/cypress/src/utils/versions.ts @@ -1,4 +1,4 @@ -import { readJson, type Tree } from '@nx/devkit'; +import { getDependencyVersionFromPackageJson, type Tree } from '@nx/devkit'; import type { PackageJson } from 'nx/src/utils/package-json'; import { clean, coerce, major } from 'semver'; @@ -92,9 +92,7 @@ export function assertMinimumCypressVersion( } function getCypressVersionFromTree(tree: Tree): string | null { - const packageJson = readJson(tree, 'package.json'); - const installedVersion = - packageJson.devDependencies?.cypress ?? packageJson.dependencies?.cypress; + const installedVersion = getDependencyVersionFromPackageJson(tree, 'cypress'); if (!installedVersion) { return null; diff --git a/packages/devkit/package.json b/packages/devkit/package.json index 0fb121f6a5f9d..2a303d0464cdc 100644 --- a/packages/devkit/package.json +++ b/packages/devkit/package.json @@ -28,6 +28,7 @@ }, "homepage": "https://nx.dev", "dependencies": { + "@zkochan/js-yaml": "0.0.7", "ejs": "^3.1.7", "tslib": "^2.3.0", "semver": "^7.5.3", diff --git a/packages/devkit/public-api.ts b/packages/devkit/public-api.ts index 0d6c4fc4d16ac..c903c0bd725e9 100644 --- a/packages/devkit/public-api.ts +++ b/packages/devkit/public-api.ts @@ -58,6 +58,7 @@ export { addDependenciesToPackageJson, removeDependenciesFromPackageJson, ensurePackage, + getDependencyVersionFromPackageJson, NX_VERSION, } from './src/utils/package-json'; diff --git a/packages/devkit/src/utils/catalog/errors.ts b/packages/devkit/src/utils/catalog/errors.ts new file mode 100644 index 0000000000000..4bd4495616c3a --- /dev/null +++ b/packages/devkit/src/utils/catalog/errors.ts @@ -0,0 +1,17 @@ +import type { CatalogError } from './types'; + +export class CatalogValidationError extends Error { + constructor(public readonly catalogError: CatalogError, message?: string) { + super(message || catalogError.message); + this.name = 'CatalogValidationError'; + } +} + +export class CatalogUnsupportedError extends Error { + constructor(public readonly packageManager: string, operation: string) { + super( + `Tried to ${operation} but Nx doesn't support catalogs for the current package manager (${packageManager})` + ); + this.name = 'CatalogUnsupportedError'; + } +} diff --git a/packages/devkit/src/utils/catalog/index.ts b/packages/devkit/src/utils/catalog/index.ts new file mode 100644 index 0000000000000..3f8a30025168f --- /dev/null +++ b/packages/devkit/src/utils/catalog/index.ts @@ -0,0 +1,64 @@ +import { readJson, type Tree } from 'nx/src/devkit-exports'; +import { getCatalogManager } from './manager-factory'; +import type { CatalogError } from './types'; +import type { CatalogManager } from './manager'; + +export { getCatalogManager }; + +/** + * Detects which packages in a package.json use catalog references + * Returns Map of package name -> catalog name (undefined for default catalog) + */ +export function getCatalogDependenciesFromPackageJson( + tree: Tree, + packageJsonPath: string, + manager: CatalogManager +): Map { + const catalogDeps = new Map(); + + if (!tree.exists(packageJsonPath)) { + return catalogDeps; + } + + if (!manager.supportsCatalogs()) { + return catalogDeps; + } + + try { + const packageJson = readJson(tree, packageJsonPath); + const allDependencies: Record = { + ...packageJson.dependencies, + ...packageJson.devDependencies, + ...packageJson.peerDependencies, + ...packageJson.optionalDependencies, + }; + + for (const [packageName, version] of Object.entries( + allDependencies || {} + )) { + if (manager.isCatalogReference(version)) { + const catalogRef = manager.parseCatalogReference(version); + if (catalogRef) { + catalogDeps.set(packageName, catalogRef.catalogName); + } + } + } + } catch (error) { + // If we can't read the package.json, return empty map + } + + return catalogDeps; +} + +export function formatCatalogError(error: CatalogError): string { + let message = error.message; + + if (error.suggestions && error.suggestions.length > 0) { + message += '\n\nSuggestions:'; + error.suggestions.forEach((suggestion) => { + message += `\n • ${suggestion}`; + }); + } + + return message; +} diff --git a/packages/devkit/src/utils/catalog/manager-factory.ts b/packages/devkit/src/utils/catalog/manager-factory.ts new file mode 100644 index 0000000000000..9a883d1655d3c --- /dev/null +++ b/packages/devkit/src/utils/catalog/manager-factory.ts @@ -0,0 +1,29 @@ +import { detectPackageManager } from 'nx/src/devkit-exports'; +import type { CatalogManager } from './manager'; +import { PnpmCatalogManager } from './pnpm-manager'; +import { + BunCatalogManager, + NpmCatalogManager, + UnknownCatalogManager, + YarnCatalogManager, +} from './unsupported-manager'; + +/** + * Factory function to get the appropriate catalog manager based on the package manager + */ +export function getCatalogManager(workspaceRoot: string): CatalogManager { + const packageManager = detectPackageManager(workspaceRoot); + + switch (packageManager) { + case 'pnpm': + return new PnpmCatalogManager(); + case 'npm': + return new NpmCatalogManager(); + case 'yarn': + return new YarnCatalogManager(); + case 'bun': + return new BunCatalogManager(); + default: + return new UnknownCatalogManager(); + } +} diff --git a/packages/devkit/src/utils/catalog/manager.ts b/packages/devkit/src/utils/catalog/manager.ts new file mode 100644 index 0000000000000..412a7c1bf43dc --- /dev/null +++ b/packages/devkit/src/utils/catalog/manager.ts @@ -0,0 +1,72 @@ +import type { Tree } from 'nx/src/devkit-exports'; +import type { PnpmWorkspaceYaml } from 'nx/src/utils/pnpm-workspace'; +import type { CatalogError, CatalogReference } from './types'; + +/** + * Interface for catalog managers that handle package manager-specific catalog implementations. + */ +export interface CatalogManager { + readonly name: string; + /** + * Check if this package manager supports catalogs. + */ + supportsCatalogs(): boolean; + + isCatalogReference(version: string): boolean; + + parseCatalogReference(version: string): CatalogReference | null; + + /** + * Get catalog definitions from the workspace. + */ + getCatalogDefinitions(workspaceRoot: string): PnpmWorkspaceYaml | null; + getCatalogDefinitions(tree: Tree): PnpmWorkspaceYaml | null; + + /** + * Resolve a catalog reference to an actual version. + */ + resolveCatalogReference( + workspaceRoot: string, + packageName: string, + version: string + ): string | null; + resolveCatalogReference( + tree: Tree, + packageName: string, + version: string + ): string | null; + + /** + * Check that a catalog reference is valid. + */ + validateCatalogReference( + workspaceRoot: string, + packageName: string, + version: string + ): { isValid: boolean; error?: CatalogError }; + validateCatalogReference( + tree: Tree, + packageName: string, + version: string + ): { isValid: boolean; error?: CatalogError }; + + /** + * Updates catalog definitions for specified packages in their respective catalogs. + */ + updateCatalogVersions( + tree: Tree, + updates: Array<{ + packageName: string; + version: string; + catalogName?: string; + }> + ): void; + updateCatalogVersions( + workspaceRoot: string, + updates: Array<{ + packageName: string; + version: string; + catalogName?: string; + }> + ): void; +} diff --git a/packages/devkit/src/utils/catalog/pnpm-manager.ts b/packages/devkit/src/utils/catalog/pnpm-manager.ts new file mode 100644 index 0000000000000..f82cc8b2c642d --- /dev/null +++ b/packages/devkit/src/utils/catalog/pnpm-manager.ts @@ -0,0 +1,311 @@ +import { dump, load } from '@zkochan/js-yaml'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { output, type Tree } from 'nx/src/devkit-exports'; +import { readYamlFile } from 'nx/src/devkit-internals'; +import type { + PnpmCatalogEntry, + PnpmWorkspaceYaml, +} from 'nx/src/utils/pnpm-workspace'; +import type { CatalogManager } from './manager'; +import { + type CatalogError, + CatalogErrorType, + type CatalogReference, +} from './types'; + +/** + * PNPM-specific catalog manager implementation + */ +export class PnpmCatalogManager implements CatalogManager { + readonly name = 'pnpm'; + readonly catalogProtocol = 'catalog:'; + + supportsCatalogs(): boolean { + return true; + } + + isCatalogReference(version: string): boolean { + return version.startsWith(this.catalogProtocol); + } + + parseCatalogReference(version: string): CatalogReference | null { + if (!this.isCatalogReference(version)) { + return null; + } + + const catalogName = version.substring(this.catalogProtocol.length); + + return { + catalogName: catalogName || undefined, + isDefaultCatalog: catalogName === '', + }; + } + + getCatalogDefinitions(treeOrRoot: Tree | string): PnpmWorkspaceYaml | null { + if (typeof treeOrRoot === 'string') { + const pnpmWorkspacePath = join(treeOrRoot, 'pnpm-workspace.yaml'); + if (!existsSync(pnpmWorkspacePath)) { + return null; + } + return readYamlFileFromFs(pnpmWorkspacePath); + } else { + if (!treeOrRoot.exists('pnpm-workspace.yaml')) { + return null; + } + return readYamlFileFromTree(treeOrRoot, 'pnpm-workspace.yaml'); + } + } + + resolveCatalogReference( + treeOrRoot: Tree | string, + packageName: string, + version: string + ): string | null { + const catalogRef = this.parseCatalogReference(version); + if (!catalogRef) { + return null; + } + + const workspaceConfig = this.getCatalogDefinitions(treeOrRoot); + if (!workspaceConfig) { + return null; + } + + let catalogToUse: PnpmCatalogEntry | undefined; + if (catalogRef.isDefaultCatalog) { + catalogToUse = workspaceConfig.catalog; + } else if (catalogRef.catalogName) { + catalogToUse = workspaceConfig.catalogs?.[catalogRef.catalogName]; + } + + return catalogToUse?.[packageName] || null; + } + + validateCatalogReference( + treeOrRoot: Tree | string, + packageName: string, + version: string + ): { isValid: boolean; error?: CatalogError } { + const catalogRef = this.parseCatalogReference(version); + if (!catalogRef) { + return { + isValid: false, + error: { + type: CatalogErrorType.INVALID_SYNTAX, + message: `Invalid catalog reference syntax: "${version}". Expected format: "catalog:" or "catalog:name"`, + }, + }; + } + + const workspaceConfig = this.getCatalogDefinitions(treeOrRoot); + if (!workspaceConfig) { + return { + isValid: false, + error: { + type: CatalogErrorType.WORKSPACE_NOT_FOUND, + message: 'No pnpm-workspace.yaml found in workspace root', + suggestions: [ + 'Create a pnpm-workspace.yaml file in your workspace root', + ], + }, + }; + } + + let catalogToUse: PnpmCatalogEntry | undefined; + + if (catalogRef.isDefaultCatalog) { + catalogToUse = workspaceConfig.catalog; + if (!catalogToUse) { + const availableCatalogs = Object.keys(workspaceConfig.catalogs || {}); + + const suggestions = [ + 'Define a default catalog in pnpm-workspace.yaml under the "catalog" key', + ]; + if (availableCatalogs.length > 0) { + suggestions.push( + `Or select from the available named catalogs: ${availableCatalogs + .map((c) => `"catalog:${c}"`) + .join(', ')}` + ); + } + + return { + isValid: false, + error: { + type: CatalogErrorType.CATALOG_NOT_FOUND, + message: 'No default catalog defined in pnpm-workspace.yaml', + suggestions, + }, + }; + } + } else if (catalogRef.catalogName) { + catalogToUse = workspaceConfig.catalogs?.[catalogRef.catalogName]; + if (!catalogToUse) { + const availableCatalogs = Object.keys(workspaceConfig.catalogs || {}); + const hasDefaultCatalog = !!workspaceConfig.catalog; + + const suggestions = [ + 'Define the catalog in pnpm-workspace.yaml under the "catalogs" key', + ]; + if (availableCatalogs.length > 0) { + suggestions.push( + `Or select from the available named catalogs: ${availableCatalogs + .map((c) => `"catalog:${c}"`) + .join(', ')}` + ); + } + if (hasDefaultCatalog) { + suggestions.push('Or use the default catalog: "catalog:"'); + } + + return { + isValid: false, + error: { + type: CatalogErrorType.CATALOG_NOT_FOUND, + message: `Catalog "${catalogRef.catalogName}" not found in pnpm-workspace.yaml`, + catalogName: catalogRef.catalogName, + suggestions, + }, + }; + } + } + + if (!catalogToUse![packageName]) { + const catalogName = catalogRef.isDefaultCatalog + ? 'default catalog' + : `catalog '${catalogRef.catalogName}'`; + + const availablePackages = Object.keys(catalogToUse!); + const suggestions = [ + `Add "${packageName}" to ${catalogName} in pnpm-workspace.yaml`, + ]; + if (availablePackages.length > 0) { + suggestions.push( + `Or select from the available packages in ${catalogName}: ${availablePackages + .map((p) => `"${p}"`) + .join(', ')}` + ); + } + + return { + isValid: false, + error: { + type: CatalogErrorType.PACKAGE_NOT_FOUND, + message: `Package "${packageName}" not found in ${catalogName}`, + packageName, + catalogName: catalogRef.catalogName, + suggestions, + }, + }; + } + + return { isValid: true }; + } + + updateCatalogVersions( + treeOrRoot: Tree | string, + updates: Array<{ + packageName: string; + version: string; + catalogName?: string; + }> + ): void { + let checkExists: () => boolean; + let readYaml: () => string; + let writeYaml: (content: string) => void; + + if (typeof treeOrRoot === 'string') { + const workspaceYamlPath = join(treeOrRoot, 'pnpm-workspace.yaml'); + checkExists = () => existsSync(workspaceYamlPath); + readYaml = () => readFileSync(workspaceYamlPath, 'utf-8'); + writeYaml = (content) => + writeFileSync(workspaceYamlPath, content, 'utf-8'); + } else { + checkExists = () => treeOrRoot.exists('pnpm-workspace.yaml'); + readYaml = () => treeOrRoot.read('pnpm-workspace.yaml', 'utf-8'); + writeYaml = (content) => treeOrRoot.write('pnpm-workspace.yaml', content); + } + + if (!checkExists()) { + output.warn({ + title: 'No pnpm-workspace.yaml found', + bodyLines: [ + 'Cannot update catalog versions without a pnpm-workspace.yaml file.', + 'Create a pnpm-workspace.yaml file to use catalogs.', + ], + }); + return; + } + + try { + const workspaceContent = readYaml(); + const workspaceData = load(workspaceContent) || {}; + + let hasChanges = false; + for (const update of updates) { + const { packageName, version, catalogName } = update; + + let targetCatalog: PnpmCatalogEntry; + if (catalogName) { + // Named catalog + workspaceData.catalogs ??= {}; + workspaceData.catalogs[catalogName] ??= {}; + targetCatalog = workspaceData.catalogs[catalogName]; + } else { + // Default catalog + workspaceData.catalog ??= {}; + targetCatalog = workspaceData.catalog; + } + + if (targetCatalog[packageName] !== version) { + targetCatalog[packageName] = version; + hasChanges = true; + } + } + + if (hasChanges) { + writeYaml( + dump(workspaceData, { + indent: 2, + quotingType: '"', + forceQuotes: true, + }) + ); + } + } catch (error) { + output.error({ + title: 'Failed to update catalog versions', + bodyLines: [error instanceof Error ? error.message : String(error)], + }); + throw error; + } + } +} + +function readYamlFileFromFs(path: string): PnpmWorkspaceYaml | null { + try { + return readYamlFile(path); + } catch (error) { + output.warn({ + title: 'Unable to parse pnpm-workspace.yaml', + bodyLines: [error.toString()], + }); + return null; + } +} + +function readYamlFileFromTree(tree: Tree, path: string): PnpmWorkspaceYaml { + const content = tree.read(path, 'utf-8'); + const { load } = require('@zkochan/js-yaml'); + + try { + return load(content, { filename: path }) as PnpmWorkspaceYaml; + } catch (error) { + output.warn({ + title: 'Unable to parse pnpm-workspace.yaml', + bodyLines: [error.toString()], + }); + return null; + } +} diff --git a/packages/devkit/src/utils/catalog/types.ts b/packages/devkit/src/utils/catalog/types.ts new file mode 100644 index 0000000000000..6e51ae1ad89ba --- /dev/null +++ b/packages/devkit/src/utils/catalog/types.ts @@ -0,0 +1,19 @@ +export interface CatalogReference { + catalogName?: string; + isDefaultCatalog: boolean; +} + +export enum CatalogErrorType { + INVALID_SYNTAX = 'INVALID_SYNTAX', + WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND', + CATALOG_NOT_FOUND = 'CATALOG_NOT_FOUND', + PACKAGE_NOT_FOUND = 'PACKAGE_NOT_FOUND', +} + +export interface CatalogError { + type: CatalogErrorType; + message: string; + catalogName?: string; + packageName?: string; + suggestions?: string[]; +} diff --git a/packages/devkit/src/utils/catalog/unsupported-manager.ts b/packages/devkit/src/utils/catalog/unsupported-manager.ts new file mode 100644 index 0000000000000..64ee99d078963 --- /dev/null +++ b/packages/devkit/src/utils/catalog/unsupported-manager.ts @@ -0,0 +1,71 @@ +import type { Tree } from 'nx/src/devkit-exports'; +import type { PnpmWorkspaceYaml } from 'nx/src/utils/pnpm-workspace'; +import { CatalogUnsupportedError } from './errors'; +import type { CatalogManager } from './manager'; +import type { CatalogError, CatalogReference } from './types'; + +/** + * Base catalog manager for package managers that don't support catalogs + */ +abstract class UnsupportedCatalogManager implements CatalogManager { + abstract readonly name: string; + + supportsCatalogs(): boolean { + return false; + } + + isCatalogReference(_version: string): boolean { + return false; + } + + parseCatalogReference(_version: string): CatalogReference | null { + return null; + } + + getCatalogDefinitions(_treeOrRoot: Tree | string): PnpmWorkspaceYaml | null { + throw new CatalogUnsupportedError(this.name, 'get catalog definitions'); + } + + resolveCatalogReference( + _treeOrRoot: Tree | string, + _packageName: string, + _version: string + ): string | null { + throw new CatalogUnsupportedError(this.name, 'resolve catalog references'); + } + + validateCatalogReference( + _treeOrRoot: Tree | string, + _packageName: string, + _version: string + ): { isValid: boolean; error?: CatalogError } { + throw new CatalogUnsupportedError(this.name, 'validate catalog references'); + } + + updateCatalogVersions( + _treeOrRoot: Tree | string, + _updates: Array<{ + packageName: string; + version: string; + catalogName?: string; + }> + ): void { + throw new CatalogUnsupportedError(this.name, 'update catalog versions'); + } +} + +export class YarnCatalogManager extends UnsupportedCatalogManager { + readonly name = 'yarn'; +} + +export class BunCatalogManager extends UnsupportedCatalogManager { + readonly name = 'bun'; +} + +export class NpmCatalogManager extends UnsupportedCatalogManager { + readonly name = 'npm'; +} + +export class UnknownCatalogManager extends UnsupportedCatalogManager { + readonly name = 'unknown'; +} diff --git a/packages/devkit/src/utils/package-json.spec.ts b/packages/devkit/src/utils/package-json.spec.ts index 2ec5f7d36cc07..913f9582ead9f 100644 --- a/packages/devkit/src/utils/package-json.spec.ts +++ b/packages/devkit/src/utils/package-json.spec.ts @@ -1,7 +1,31 @@ +import * as devkitExports from 'nx/src/devkit-exports'; +import { createTree } from 'nx/src/generators/testing-utils/create-tree'; import type { Tree } from 'nx/src/generators/tree'; import { readJson, writeJson } from 'nx/src/generators/utils/json'; -import { addDependenciesToPackageJson, ensurePackage } from './package-json'; -import { createTree } from 'nx/src/generators/testing-utils/create-tree'; +import type { PackageJson } from 'nx/src/utils/package-json'; +import { + addDependenciesToPackageJson, + ensurePackage, + getDependencyVersionFromPackageJson, +} from './package-json'; + +// Mock fs for catalog tests +jest.mock('fs', () => require('memfs').fs); +jest.mock('node:fs', () => require('memfs').fs); + +// Mock yaml reading functions +jest.mock('nx/src/devkit-internals', () => ({ + ...jest.requireActual('nx/src/devkit-internals'), + readYamlFile: jest.fn((path: string) => { + const { vol } = require('memfs'); + try { + const content = vol.readFileSync(path, 'utf8'); + return require('@zkochan/js-yaml').load(content); + } catch (error) { + throw new Error(`Cannot read YAML file at ${path}`); + } + }), +})); describe('addDependenciesToPackageJson', () => { let tree: Tree; @@ -469,6 +493,348 @@ describe('addDependenciesToPackageJson', () => { foo: '1.0.0', }); }); + + describe('catalog support', () => { + const mockDetectPackageManager = jest.fn(); + + beforeEach(() => { + mockDetectPackageManager.mockReturnValue('pnpm'); + + const packageManager = require('nx/src/devkit-exports'); + jest + .spyOn(packageManager, 'detectPackageManager') + .mockImplementation(mockDetectPackageManager); + + tree.root = '/test-workspace'; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should update existing catalog dependencies in pnpm workspace', () => { + tree.write( + 'pnpm-workspace.yaml', + ` +packages: + - packages/* +catalog: + react: ^18.0.0 +` + ); + + writeJson(tree, 'package.json', { + dependencies: { react: 'catalog:' }, + }); + + addDependenciesToPackageJson(tree, { react: '^18.2.0' }, {}); + + const result = readJson(tree, 'package.json'); + expect(result.dependencies).toEqual({ react: 'catalog:' }); + + const workspace = tree.read('pnpm-workspace.yaml', 'utf-8'); + expect(workspace).toContain('react: "^18.2.0"'); + }); + + it('should add new dependencies as regular dependencies when no existing catalog reference', () => { + writeJson(tree, 'package.json', { dependencies: {} }); + + addDependenciesToPackageJson(tree, { lodash: '^4.17.21' }, {}); + + const result = readJson(tree, 'package.json'); + expect(result.dependencies).toEqual({ lodash: '^4.17.21' }); + }); + + it('should use direct dependencies with unsupported package managers', () => { + mockDetectPackageManager.mockReturnValue('npm'); + writeJson(tree, 'package.json', { + dependencies: { react: 'catalog:' }, + }); + + addDependenciesToPackageJson(tree, { react: '^18.0.0' }, {}); + + const result = readJson(tree, 'package.json'); + expect(result.dependencies).toEqual({ react: '^18.0.0' }); + }); + + it('should handle mixed catalog and direct dependencies', () => { + tree.write( + 'pnpm-workspace.yaml', + `packages:\n - packages/*\ncatalog:\n react: ^18.0.0` + ); + + writeJson(tree, 'package.json', { + dependencies: { react: 'catalog:', lodash: '^4.17.20' }, + }); + + addDependenciesToPackageJson( + tree, + { react: '^18.2.0', lodash: '^4.17.21' }, + {} + ); + + const result = readJson(tree, 'package.json'); + expect(result.dependencies).toEqual({ + react: 'catalog:', + lodash: '^4.17.21', + }); + + const workspace = tree.read('pnpm-workspace.yaml', 'utf-8'); + expect(workspace).toContain('react: "^18.2.0"'); + }); + + it('should preserve existing catalog references when updating with direct versions', () => { + tree.write( + 'pnpm-workspace.yaml', + `packages:\n - packages/*\ncatalog:\n react: ^18.0.0` + ); + + writeJson(tree, 'package.json', { + dependencies: { react: 'catalog:' }, + }); + + addDependenciesToPackageJson(tree, { react: '^18.2.0' }, {}); + + const result = readJson(tree, 'package.json'); + expect(result.dependencies).toEqual({ react: 'catalog:' }); + + const workspace = tree.read('pnpm-workspace.yaml', 'utf-8'); + expect(workspace).toContain('react: "^18.2.0"'); + }); + + it('should update only the specific catalog when package exists in multiple catalogs', () => { + tree.write( + 'pnpm-workspace.yaml', + `packages:\n - packages/*\ncatalog:\n react: ^18.0.0\ncatalogs:\n dev:\n react: ^17.0.0` + ); + + writeJson(tree, 'package.json', { + dependencies: { react: 'catalog:dev' }, + }); + + addDependenciesToPackageJson(tree, { react: '^18.2.0' }, {}); + + const result = readJson(tree, 'package.json'); + expect(result.dependencies).toEqual({ react: 'catalog:dev' }); + + const workspace = tree.read('pnpm-workspace.yaml', 'utf-8'); + expect(workspace).toMatch(/catalogs:\s*dev:\s*react: "?\^18\.2\.0"?/); + expect(workspace).toMatch(/catalog:\s*react: "?\^18\.0\.0"?/); + }); + + it('should filter catalog dependencies using version comparison logic', () => { + tree.write( + 'pnpm-workspace.yaml', + `packages:\n - packages/*\ncatalog:\n react: ^18.2.0` + ); + writeJson(tree, 'package.json', { + dependencies: { react: 'catalog:' }, + }); + + addDependenciesToPackageJson(tree, { react: '^18.1.0' }, {}); + + const result = readJson(tree, 'package.json'); + expect(result.dependencies).toEqual({ react: 'catalog:' }); + + const workspace = tree.read('pnpm-workspace.yaml', 'utf-8'); + expect(workspace).toContain('react: ^18.2.0'); + }); + + it('should handle named catalog references', () => { + tree.write( + 'pnpm-workspace.yaml', + `packages:\n - packages/*\ncatalogs:\n dev:\n jest: ^28.0.0` + ); + + writeJson(tree, 'package.json', { + devDependencies: { jest: 'catalog:dev' }, + }); + + addDependenciesToPackageJson(tree, {}, { jest: '^29.0.0' }); + + const result = readJson(tree, 'package.json'); + expect(result.devDependencies).toEqual({ jest: 'catalog:dev' }); + + const workspace = tree.read('pnpm-workspace.yaml', 'utf-8'); + expect(workspace).toContain('jest: "^29.0.0"'); + }); + + it('should resolve catalog references for version comparison', () => { + tree.write( + 'pnpm-workspace.yaml', + `packages:\n - packages/*\ncatalog:\n react: ^18.2.0` + ); + + writeJson(tree, 'package.json', { + dependencies: { react: 'catalog:' }, + }); + + addDependenciesToPackageJson(tree, { react: '^18.1.0' }, {}); + + const result = readJson(tree, 'package.json'); + expect(result.dependencies).toEqual({ react: 'catalog:' }); + + const workspace = tree.read('pnpm-workspace.yaml', 'utf-8'); + expect(workspace).toContain('react: ^18.2.0'); + }); + + it('should throw an error for invalid catalog references', () => { + tree.write( + 'pnpm-workspace.yaml', + `packages:\n - packages/*\ncatalog: {}` + ); + + writeJson(tree, 'package.json', { + dependencies: { react: 'catalog:nonexistent' }, + }); + + expect(() => + addDependenciesToPackageJson(tree, { react: '^18.2.0' }, {}) + ).toThrow( + "Failed to resolve catalog reference 'catalog:nonexistent' for package 'react'" + ); + }); + }); +}); + +describe('getDependencyVersionFromPackageJson', () => { + let tree: Tree; + + beforeEach(() => { + tree = createTree(); + }); + + it('should get single package version from root package.json', () => { + writeJson(tree, 'package.json', { + dependencies: { react: '^18.2.0' }, + devDependencies: { jest: '^29.0.0' }, + }); + + const reactVersion = getDependencyVersionFromPackageJson(tree, 'react'); + const jestVersion = getDependencyVersionFromPackageJson(tree, 'jest'); + + expect(reactVersion).toBe('^18.2.0'); + expect(jestVersion).toBe('^29.0.0'); + }); + + it('should return null for non-existent package', () => { + writeJson(tree, 'package.json', { + dependencies: { react: '^18.2.0' }, + }); + + const version = getDependencyVersionFromPackageJson(tree, 'non-existent'); + expect(version).toBeNull(); + }); + + it('should prioritize dependencies over devDependencies', () => { + writeJson(tree, 'package.json', { + dependencies: { react: '^18.0.0' }, + devDependencies: { react: '^18.2.0' }, + }); + + const version = getDependencyVersionFromPackageJson(tree, 'react'); + expect(version).toBe('^18.0.0'); + }); + + it('should read from specific package.json path', () => { + writeJson(tree, 'packages/my-lib/package.json', { + dependencies: { '@my/util': '^1.0.0' }, + }); + + const version = getDependencyVersionFromPackageJson( + tree, + '@my/util', + 'packages/my-lib/package.json' + ); + expect(version).toBe('^1.0.0'); + }); + + it('should work with pre-loaded package.json object', () => { + const packageJson: PackageJson = { + name: 'test', + version: '1.0.0', + dependencies: { react: '^18.2.0' }, + devDependencies: { jest: '^29.0.0' }, + }; + writeJson(tree, 'package.json', packageJson); + + const reactVersion = getDependencyVersionFromPackageJson( + tree, + 'react', + packageJson + ); + const jestVersion = getDependencyVersionFromPackageJson( + tree, + 'jest', + packageJson + ); + + expect(reactVersion).toBe('^18.2.0'); + expect(jestVersion).toBe('^29.0.0'); + }); + + describe('with catalog references', () => { + beforeEach(() => { + jest.spyOn(devkitExports, 'detectPackageManager').mockReturnValue('pnpm'); + tree.write( + 'pnpm-workspace.yaml', + ` +packages: + - packages/* +catalog: + react: "^18.2.0" + lodash: "^4.17.21" +catalogs: + frontend: + vue: "^3.3.0" +` + ); + }); + + it('should resolve catalog reference for single package', () => { + writeJson(tree, 'package.json', { + dependencies: { react: 'catalog:' }, + }); + + const version = getDependencyVersionFromPackageJson(tree, 'react'); + expect(version).toBe('^18.2.0'); + }); + + it('should resolve named catalog reference', () => { + writeJson(tree, 'package.json', { + dependencies: { vue: 'catalog:frontend' }, + }); + + const version = getDependencyVersionFromPackageJson(tree, 'vue'); + expect(version).toBe('^3.3.0'); + }); + + it('should return null when catalog reference cannot be resolved', () => { + writeJson(tree, 'package.json', { + dependencies: { unknown: 'catalog:' }, + }); + + const version = getDependencyVersionFromPackageJson(tree, 'unknown'); + expect(version).toBeNull(); + }); + + it('should work with pre-loaded package.json', () => { + const packageJson: PackageJson = { + name: 'test', + version: '1.0.0', + dependencies: { react: 'catalog:' }, + }; + writeJson(tree, 'package.json', packageJson); + + const version = getDependencyVersionFromPackageJson( + tree, + 'react', + packageJson + ); + + expect(version).toBe('^18.2.0'); + }); + }); }); describe('ensurePackage', () => { diff --git a/packages/devkit/src/utils/package-json.ts b/packages/devkit/src/utils/package-json.ts index 7da4001005bd3..3f0122ebb9513 100644 --- a/packages/devkit/src/utils/package-json.ts +++ b/packages/devkit/src/utils/package-json.ts @@ -1,15 +1,23 @@ -import { clean, coerce, gt } from 'semver'; +import { existsSync } from 'fs'; +import { Module } from 'module'; import { - GeneratorCallback, + type GeneratorCallback, + output, readJson, - Tree, + readJsonFile, + type Tree, updateJson, workspaceRoot, } from 'nx/src/devkit-exports'; import { installPackageToTmp } from 'nx/src/devkit-internals'; -import { join } from 'path'; +import type { PackageJson } from 'nx/src/utils/package-json'; +import { join, resolve } from 'path'; +import { clean, coerce, gt } from 'semver'; import { installPackagesTask } from '../tasks/install-packages-task'; -import { Module } from 'module'; +import { + getCatalogDependenciesFromPackageJson, + getCatalogManager, +} from './catalog'; const UNIDENTIFIED_VERSION = 'UNIDENTIFIED_VERSION'; const NON_SEMVER_TAGS = { @@ -21,6 +29,162 @@ const NON_SEMVER_TAGS = { legacy: -2, }; +/** + * Get the resolved version of a dependency from package.json. + * + * Retrieves a package version and automatically resolves PNPM catalog references + * (e.g., "catalog:default") to their actual version strings. Searches `dependencies` + * first, then falls back to `devDependencies`. + * + * **Tree-based usage** (generators and migrations): + * Use when you have a `Tree` object, which is typical in Nx generators and migrations. + * + * **Filesystem-based usage** (CLI commands and scripts): + * Use when reading directly from the filesystem without a `Tree` object. + * + * @example + * ```typescript + * // Tree-based - from root package.json + * const reactVersion = getDependencyVersionFromPackageJson(tree, 'react'); + * // Returns: "^18.0.0" (resolves "catalog:default" if present) + * + * // Tree-based - from specific package.json + * const version = getDependencyVersionFromPackageJson( + * tree, + * '@my/lib', + * 'packages/my-lib/package.json' + * ); + * + * // Tree-based - with pre-loaded package.json + * const packageJson = readJson(tree, 'package.json'); + * const version = getDependencyVersionFromPackageJson(tree, 'react', packageJson); + * ``` + * + * @example + * ```typescript + * // Filesystem-based - from current directory + * const reactVersion = getDependencyVersionFromPackageJson('react'); + * + * // Filesystem-based - with workspace root + * const version = getDependencyVersionFromPackageJson('react', '/path/to/workspace'); + * + * // Filesystem-based - with specific package.json + * const version = getDependencyVersionFromPackageJson( + * 'react', + * '/path/to/workspace', + * 'apps/my-app/package.json' + * ); + * ``` + * + * @returns The resolved version string, or `null` if the package is not found in either dependencies or devDependencies + */ +export function getDependencyVersionFromPackageJson( + tree: Tree, + packageName: string, + packageJsonPath?: string +): string | null; +export function getDependencyVersionFromPackageJson( + tree: Tree, + packageName: string, + packageJson?: PackageJson +): string | null; +export function getDependencyVersionFromPackageJson( + packageName: string, + workspaceRootPath?: string, + packageJsonPath?: string +): string | null; +export function getDependencyVersionFromPackageJson( + packageName: string, + workspaceRootPath?: string, + packageJson?: PackageJson +): string | null; +export function getDependencyVersionFromPackageJson( + treeOrPackageName: Tree | string, + packageNameOrRoot?: string, + packageJsonPathOrObjectOrRoot?: string | PackageJson +): string | null { + if (typeof treeOrPackageName !== 'string') { + return getDependencyVersionFromPackageJsonFromTree( + treeOrPackageName, + packageNameOrRoot!, + packageJsonPathOrObjectOrRoot + ); + } else { + return getDependencyVersionFromPackageJsonFromFileSystem( + treeOrPackageName, + packageNameOrRoot, + packageJsonPathOrObjectOrRoot + ); + } +} + +/** + * Tree-based implementation for getDependencyVersionFromPackageJson + */ +function getDependencyVersionFromPackageJsonFromTree( + tree: Tree, + packageName: string, + packageJsonPathOrObject: string | PackageJson = 'package.json' +): string | null { + let packageJson: PackageJson; + if (typeof packageJsonPathOrObject === 'object') { + packageJson = packageJsonPathOrObject; + } else if (tree.exists(packageJsonPathOrObject)) { + packageJson = readJson(tree, packageJsonPathOrObject); + } else { + return null; + } + + const manager = getCatalogManager(tree.root); + + let version = + packageJson.dependencies?.[packageName] ?? + packageJson.devDependencies?.[packageName] ?? + null; + + // Resolve catalog reference if needed + if (version && manager.isCatalogReference(version)) { + version = manager.resolveCatalogReference(tree, packageName, version); + } + + return version; +} + +/** + * Filesystem-based implementation for getDependencyVersionFromPackageJson + */ +function getDependencyVersionFromPackageJsonFromFileSystem( + packageName: string, + root: string = workspaceRoot, + packageJsonPathOrObject: string | PackageJson = 'package.json' +): string | null { + let packageJson: PackageJson; + if (typeof packageJsonPathOrObject === 'object') { + packageJson = packageJsonPathOrObject; + } else { + const packageJsonPath = resolve(root, packageJsonPathOrObject); + if (existsSync(packageJsonPath)) { + packageJson = readJsonFile(packageJsonPath); + } else { + return null; + } + } + + const manager = getCatalogManager(root); + + let version = + packageJson.dependencies?.[packageName] ?? + packageJson.devDependencies?.[packageName] ?? + null; + + // Resolve catalog reference if needed + if (version && manager.isCatalogReference(version)) { + version = manager.resolveCatalogReference(packageName, version, root); + } + + return version; +} + function filterExistingDependencies( dependencies: Record, existingAltDependencies: Record @@ -34,23 +198,65 @@ function filterExistingDependencies( .reduce((acc, d) => ({ ...acc, [d]: dependencies[d] }), {}); } -function cleanSemver(version: string) { +function cleanSemver(tree: Tree, version: string, packageName: string) { + const manager = getCatalogManager(tree.root); + if (manager.isCatalogReference(version)) { + const resolvedVersion = manager.resolveCatalogReference( + tree, + packageName, + version + ); + if (!resolvedVersion) { + throw new Error( + `Failed to resolve catalog reference '${version}' for package '${packageName}'` + ); + } + return clean(resolvedVersion) ?? coerce(resolvedVersion); + } return clean(version) ?? coerce(version); } function isIncomingVersionGreater( + tree: Tree, incomingVersion: string, - existingVersion: string + existingVersion: string, + packageName: string ) { + // the existing version might be a catalog reference, so we need to resolve + // it if that's the case + let resolvedExistingVersion = existingVersion; + const manager = getCatalogManager(tree.root); + if (manager.isCatalogReference(existingVersion)) { + if (!manager.supportsCatalogs()) { + // If catalog is unsupported, we assume the incoming version is newer + return true; + } + + const resolved = manager.resolveCatalogReference( + tree, + packageName, + existingVersion + ); + if (!resolved) { + // catalog is supported, but failed to resolve, we throw an error + throw new Error( + `Failed to resolve catalog reference '${existingVersion}' for package '${packageName}'` + ); + } + resolvedExistingVersion = resolved; + } + // if version is in the format of "latest", "next" or similar - keep it, otherwise try to parse it const incomingVersionCompareBy = incomingVersion in NON_SEMVER_TAGS ? incomingVersion - : cleanSemver(incomingVersion)?.toString() ?? UNIDENTIFIED_VERSION; + : cleanSemver(tree, incomingVersion, packageName)?.toString() ?? + UNIDENTIFIED_VERSION; const existingVersionCompareBy = - existingVersion in NON_SEMVER_TAGS - ? existingVersion - : cleanSemver(existingVersion)?.toString() ?? UNIDENTIFIED_VERSION; + resolvedExistingVersion in NON_SEMVER_TAGS + ? resolvedExistingVersion + : cleanSemver(tree, resolvedExistingVersion, packageName)?.toString() ?? + UNIDENTIFIED_VERSION; if ( incomingVersionCompareBy in NON_SEMVER_TAGS && @@ -69,12 +275,17 @@ function isIncomingVersionGreater( return true; } - return gt(cleanSemver(incomingVersion), cleanSemver(existingVersion)); + return gt( + cleanSemver(tree, incomingVersion, packageName), + cleanSemver(tree, resolvedExistingVersion, packageName) + ); } function updateExistingAltDependenciesVersion( + tree: Tree, dependencies: Record, - existingAltDependencies: Record + existingAltDependencies: Record, + workspaceRootPath: string ) { return Object.keys(existingAltDependencies || {}) .filter((d) => { @@ -84,14 +295,21 @@ function updateExistingAltDependenciesVersion( const incomingVersion = dependencies[d]; const existingVersion = existingAltDependencies[d]; - return isIncomingVersionGreater(incomingVersion, existingVersion); + return isIncomingVersionGreater( + tree, + incomingVersion, + existingVersion, + d + ); }) .reduce((acc, d) => ({ ...acc, [d]: dependencies[d] }), {}); } function updateExistingDependenciesVersion( + tree: Tree, dependencies: Record, - existingDependencies: Record = {} + existingDependencies: Record = {}, + workspaceRootPath: string ) { return Object.keys(dependencies) .filter((d) => { @@ -102,7 +320,12 @@ function updateExistingDependenciesVersion( const incomingVersion = dependencies[d]; const existingVersion = existingDependencies[d]; - return isIncomingVersionGreater(incomingVersion, existingVersion); + return isIncomingVersionGreater( + tree, + incomingVersion, + existingVersion, + d + ); }) .reduce((acc, d) => ({ ...acc, [d]: dependencies[d] }), {}); } @@ -150,22 +373,30 @@ export function addDependenciesToPackageJson( // - specified dependencies of the other type that have greater version and are already installed as current type filteredDependencies = { ...updateExistingDependenciesVersion( + tree, filteredDependencies, - currentPackageJson.dependencies + currentPackageJson.dependencies, + tree.root ), ...updateExistingAltDependenciesVersion( + tree, devDependencies, - currentPackageJson.dependencies + currentPackageJson.dependencies, + tree.root ), }; filteredDevDependencies = { ...updateExistingDependenciesVersion( + tree, filteredDevDependencies, - currentPackageJson.devDependencies + currentPackageJson.devDependencies, + tree.root ), ...updateExistingAltDependenciesVersion( + tree, dependencies, - currentPackageJson.devDependencies + currentPackageJson.devDependencies, + tree.root ), }; @@ -180,38 +411,42 @@ export function addDependenciesToPackageJson( ); } else { filteredDependencies = removeLowerVersions( + tree, filteredDependencies, - currentPackageJson.dependencies + currentPackageJson.dependencies, + tree.root ); filteredDevDependencies = removeLowerVersions( + tree, filteredDevDependencies, - currentPackageJson.devDependencies + currentPackageJson.devDependencies, + tree.root ); } if ( requiresAddingOfPackages( + tree, currentPackageJson, filteredDependencies, - filteredDevDependencies + filteredDevDependencies, + tree.root ) ) { - updateJson(tree, packageJsonPath, (json) => { - json.dependencies = { - ...(json.dependencies || {}), - ...filteredDependencies, - }; - - json.devDependencies = { - ...(json.devDependencies || {}), - ...filteredDevDependencies, - }; - - json.dependencies = sortObjectByKeys(json.dependencies); - json.devDependencies = sortObjectByKeys(json.devDependencies); - - return json; - }); + const { catalogUpdates, directDependencies, directDevDependencies } = + splitDependenciesByCatalogType( + tree, + filteredDependencies, + filteredDevDependencies, + packageJsonPath + ); + writeCatalogDependencies(tree, catalogUpdates); + writeDirectDependencies( + tree, + packageJsonPath, + directDependencies, + directDevDependencies + ); return (): void => { installPackagesTask(tree); @@ -220,17 +455,184 @@ export function addDependenciesToPackageJson( return () => {}; } +interface DependencySplit { + catalogUpdates: Array<{ + packageName: string; + version: string; + catalogName?: string; + }>; + directDependencies: Record; + directDevDependencies: Record; +} + +function splitDependenciesByCatalogType( + tree: Tree, + filteredDependencies: Record, + filteredDevDependencies: Record, + packageJsonPath: string +): DependencySplit { + const allFilteredUpdates = { + ...filteredDependencies, + ...filteredDevDependencies, + }; + const catalogUpdates: Array<{ + packageName: string; + version: string; + catalogName?: string; + }> = []; + let directDependencies = { ...filteredDependencies }; + let directDevDependencies = { ...filteredDevDependencies }; + + const manager = getCatalogManager(tree.root); + const existingCatalogDeps = getCatalogDependenciesFromPackageJson( + tree, + packageJsonPath, + manager + ); + if (!existingCatalogDeps.size) { + return { + catalogUpdates: [], + directDependencies: filteredDependencies, + directDevDependencies: filteredDevDependencies, + }; + } + + const supportsCatalogs = manager.supportsCatalogs(); + + // Check filtered results for catalog references or existing catalog dependencies + for (const [packageName, version] of Object.entries(allFilteredUpdates)) { + if (!existingCatalogDeps.has(packageName)) { + continue; + } + + let shouldUseCatalog = false; + let catalogName: string | undefined; + + if (!supportsCatalogs) { + // we're trying to update the version of a package that has a catalog reference + // but Nx does not support catalogs for this package manager, we warn the user + // and update the dependencies directly to package.json to keep the existing + // behavior + output.warn({ + title: 'Nx does not support catalogs for this package manager', + bodyLines: [ + 'Dependencies will be added directly to package.json and might override catalog dependencies.', + ], + }); + + // bail out early since we'll add the dependencies directly to package.json + return { + catalogUpdates: [], + directDependencies: filteredDependencies, + directDevDependencies: filteredDevDependencies, + }; + } + + catalogName = existingCatalogDeps.get(packageName)!; + const catalogRef = catalogName ? `catalog:${catalogName}` : 'catalog:'; + + try { + const manager = getCatalogManager(tree.root); + const { isValid, error } = manager.validateCatalogReference( + tree, + packageName, + catalogRef + ); + + if (isValid) { + shouldUseCatalog = true; + } else { + output.error({ + title: 'Invalid catalog reference', + bodyLines: [ + `Invalid catalog reference "${catalogRef}" for package "${packageName}".`, + ...(error?.message ? [error.message] : []), + ...(error?.suggestions || []), + ], + }); + throw new Error( + `Could not update "${packageName}" to version "${version}". See above for more details.` + ); + } + } catch (error) { + output.error({ + title: 'Could not update catalog dependency', + bodyLines: [ + `Unexpected error while updating catalog reference "${catalogRef}" for package "${packageName}".`, + ...(error?.message ? [error.message] : []), + ...(error?.stack ? [error.stack] : []), + ], + }); + throw new Error( + `Could not update "${packageName}" to version "${version}". See above for more details.` + ); + } + + if (shouldUseCatalog) { + catalogUpdates.push({ packageName, version, catalogName }); + + // Remove from direct updates since this will be handled via catalog + delete directDependencies[packageName]; + delete directDevDependencies[packageName]; + } + } + + return { catalogUpdates, directDependencies, directDevDependencies }; +} + +function writeCatalogDependencies( + tree: Tree, + catalogUpdates: Array<{ + packageName: string; + version: string; + catalogName?: string; + }> +): void { + if (!catalogUpdates.length) { + return; + } + + const manager = getCatalogManager(tree.root); + manager.updateCatalogVersions(tree, catalogUpdates); +} + +function writeDirectDependencies( + tree: Tree, + packageJsonPath: string, + dependencies: Record, + devDependencies: Record +): void { + updateJson(tree, packageJsonPath, (json) => { + json.dependencies = { + ...(json.dependencies || {}), + ...dependencies, + }; + + json.devDependencies = { + ...(json.devDependencies || {}), + ...devDependencies, + }; + + json.dependencies = sortObjectByKeys(json.dependencies); + json.devDependencies = sortObjectByKeys(json.devDependencies); + + return json; + }); +} + /** * @returns The the incoming dependencies that are higher than the existing verions **/ function removeLowerVersions( + tree: Tree, incomingDeps: Record, - existingDeps: Record + existingDeps: Record, + workspaceRootPath: string ) { return Object.keys(incomingDeps).reduce((acc, d) => { if ( !existingDeps?.[d] || - isIncomingVersionGreater(incomingDeps[d], existingDeps[d]) + isIncomingVersionGreater(tree, incomingDeps[d], existingDeps[d], d) ) { acc[d] = incomingDeps[d]; } @@ -317,7 +719,13 @@ function sortObjectByKeys(obj: T): T { * Verifies whether the given packageJson dependencies require an update * given the deps & devDeps passed in */ -function requiresAddingOfPackages(packageJsonFile, deps, devDeps): boolean { +function requiresAddingOfPackages( + tree: Tree, + packageJsonFile: PackageJson, + deps: Record, + devDeps: Record, + workspaceRootPath: string +): boolean { let needsDepsUpdate = false; let needsDevDepsUpdate = false; @@ -329,12 +737,22 @@ function requiresAddingOfPackages(packageJsonFile, deps, devDeps): boolean { const incomingVersion = deps[entry]; if (packageJsonFile.dependencies[entry]) { const existingVersion = packageJsonFile.dependencies[entry]; - return isIncomingVersionGreater(incomingVersion, existingVersion); + return isIncomingVersionGreater( + tree, + incomingVersion, + existingVersion, + entry + ); } if (packageJsonFile.devDependencies[entry]) { const existingVersion = packageJsonFile.devDependencies[entry]; - return isIncomingVersionGreater(incomingVersion, existingVersion); + return isIncomingVersionGreater( + tree, + incomingVersion, + existingVersion, + entry + ); } return true; @@ -346,12 +764,22 @@ function requiresAddingOfPackages(packageJsonFile, deps, devDeps): boolean { const incomingVersion = devDeps[entry]; if (packageJsonFile.devDependencies[entry]) { const existingVersion = packageJsonFile.devDependencies[entry]; - return isIncomingVersionGreater(incomingVersion, existingVersion); + return isIncomingVersionGreater( + tree, + incomingVersion, + existingVersion, + entry + ); } if (packageJsonFile.dependencies[entry]) { const existingVersion = packageJsonFile.dependencies[entry]; - return isIncomingVersionGreater(incomingVersion, existingVersion); + return isIncomingVersionGreater( + tree, + incomingVersion, + existingVersion, + entry + ); } return true; @@ -526,11 +954,11 @@ function addToNodePath(dir: string) { process.env.NODE_PATH = paths.join(delimiter); } -function getPackageVersion(pkg: string): string { +function getInstalledPackageModuleVersion(pkg: string): string { return require(join(pkg, 'package.json')).version; } /** * @description The version of Nx used by the workspace. Returns null if no version is found. */ -export const NX_VERSION = getPackageVersion('nx'); +export const NX_VERSION = getInstalledPackageModuleVersion('nx'); diff --git a/packages/devkit/src/utils/semver.ts b/packages/devkit/src/utils/semver.ts index 89bde9d662189..697b73dac74b9 100644 --- a/packages/devkit/src/utils/semver.ts +++ b/packages/devkit/src/utils/semver.ts @@ -1,22 +1,65 @@ +import { workspaceRoot, type Tree } from 'nx/src/devkit-exports'; import { valid } from 'semver'; +import { formatCatalogError, getCatalogManager } from './catalog'; export function checkAndCleanWithSemver( pkgName: string, version: string +): string; +export function checkAndCleanWithSemver( + tree: Tree, + pkgName: string, + version: string +): string; +export function checkAndCleanWithSemver( + treeOrPkgName: Tree | string, + pkgNameOrVersion: string, + version?: string ): string { - let newVersion = version; + const tree = typeof treeOrPkgName === 'string' ? undefined : treeOrPkgName; + const root = tree?.root ?? workspaceRoot; + const pkgName = + typeof treeOrPkgName === 'string' ? treeOrPkgName : pkgNameOrVersion; + const actualVersion = + typeof treeOrPkgName === 'string' ? pkgNameOrVersion : version!; + let newVersion = actualVersion; + + const manager = getCatalogManager(root); + if (manager.isCatalogReference(actualVersion)) { + const validation = tree + ? manager.validateCatalogReference(tree, pkgName, actualVersion) + : manager.validateCatalogReference(root, pkgName, actualVersion); + if (!validation.isValid) { + throw new Error( + `The catalog reference for ${pkgName} is invalid - (${actualVersion})\n${formatCatalogError( + validation.error! + )}` + ); + } + + const resolvedVersion = tree + ? manager.resolveCatalogReference(tree, pkgName, actualVersion) + : manager.resolveCatalogReference(root, pkgName, actualVersion); + if (!resolvedVersion) { + throw new Error( + `Could not resolve catalog reference for package ${pkgName}@${actualVersion}.` + ); + } + + newVersion = resolvedVersion; + } if (valid(newVersion)) { return newVersion; } - if (version.startsWith('~') || version.startsWith('^')) { - newVersion = version.substring(1); + if (actualVersion.startsWith('~') || actualVersion.startsWith('^')) { + newVersion = actualVersion.substring(1); } if (!valid(newVersion)) { throw new Error( - `The package.json lists a version of ${pkgName} that Nx is unable to validate - (${version})` + `The package.json lists a version of ${pkgName} that Nx is unable to validate - (${actualVersion})` ); } diff --git a/packages/eslint-plugin/src/rules/dependency-checks.ts b/packages/eslint-plugin/src/rules/dependency-checks.ts index 0a22469e792d4..4d3adea98e9e4 100644 --- a/packages/eslint-plugin/src/rules/dependency-checks.ts +++ b/packages/eslint-plugin/src/rules/dependency-checks.ts @@ -1,4 +1,8 @@ import { NX_VERSION, normalizePath, workspaceRoot } from '@nx/devkit'; +import { + formatCatalogError, + getCatalogManager, +} from '@nx/devkit/src/utils/catalog'; import { findNpmDependencies } from '@nx/js/src/utils/find-npm-dependencies'; import { ESLintUtils } from '@typescript-eslint/utils'; import { AST } from 'jsonc-eslint-parser'; @@ -35,7 +39,8 @@ export type MessageIds = | 'missingDependency' | 'obsoleteDependency' | 'versionMismatch' - | 'missingDependencySection'; + | 'missingDependencySection' + | 'invalidCatalogReference'; export const RULE_NAME = 'dependency-checks'; @@ -72,6 +77,7 @@ export default ESLintUtils.RuleCreator( obsoleteDependency: `The "{{packageName}}" package is not used by "{{projectName}}" project.`, versionMismatch: `The version specifier does not contain the installed version of "{{packageName}}" package: {{version}}.`, missingDependencySection: `Dependency sections are missing from the "package.json" but following dependencies were detected:{{dependencies}}`, + invalidCatalogReference: `Invalid catalog reference for "{{packageName}}": {{error}}`, }, }, defaultOptions: [ @@ -205,6 +211,34 @@ export default ESLintUtils.RuleCreator( } } + function validateCatalogReferenceForPackage( + node: AST.JSONProperty, + packageName: string, + packageRange: string + ) { + const manager = getCatalogManager(workspaceRoot); + if (!manager.isCatalogReference(packageRange)) { + return; + } + + const validationResult = manager.validateCatalogReference( + packageName, + packageRange, + workspaceRoot + ); + + if (!validationResult.isValid) { + context.report({ + node: node as any, + messageId: 'invalidCatalogReference', + data: { + packageName: packageName, + error: formatCatalogError(validationResult.error!), + }, + }); + } + } + function validateVersionMatchesInstalled( node: AST.JSONProperty, packageName: string, @@ -359,6 +393,8 @@ export default ESLintUtils.RuleCreator( return; } + validateCatalogReferenceForPackage(node, packageName, packageRange); + if (expectedDependencyNames.includes(packageName)) { validateVersionMatchesInstalled(node, packageName, packageRange); } else { diff --git a/packages/eslint/src/utils/version-utils.ts b/packages/eslint/src/utils/version-utils.ts index f34c607a7ee22..811cc9e99b8e3 100644 --- a/packages/eslint/src/utils/version-utils.ts +++ b/packages/eslint/src/utils/version-utils.ts @@ -1,4 +1,8 @@ -import { readJson, readJsonFile, type Tree } from '@nx/devkit'; +import { + getDependencyVersionFromPackageJson, + readJsonFile, + type Tree, +} from '@nx/devkit'; import { checkAndCleanWithSemver } from '@nx/devkit/src/utils/semver'; import { readModulePackageJson } from 'nx/src/devkit-internals'; @@ -13,12 +17,18 @@ export function getInstalledPackageVersion( // the package is not installed on disk, it could be in the package.json // but waiting to be installed - const rootPackageJson = tree - ? readJson(tree, 'package.json') - : readJsonFile('package.json'); - const pkgVersionInRootPackageJson = - rootPackageJson.devDependencies?.[pkgName] ?? - rootPackageJson.dependencies?.[pkgName]; + let pkgVersionInRootPackageJson: string | null; + if (tree) { + pkgVersionInRootPackageJson = getDependencyVersionFromPackageJson( + tree, + pkgName + ); + } else { + const rootPackageJson = readJsonFile('package.json'); + pkgVersionInRootPackageJson = + rootPackageJson.devDependencies?.[pkgName] ?? + rootPackageJson.dependencies?.[pkgName]; + } if (!pkgVersionInRootPackageJson) { // the package is not installed @@ -27,7 +37,9 @@ export function getInstalledPackageVersion( try { // try to parse and return the version - return checkAndCleanWithSemver(pkgName, pkgVersionInRootPackageJson); + return tree + ? checkAndCleanWithSemver(tree, pkgName, pkgVersionInRootPackageJson) + : checkAndCleanWithSemver(pkgName, pkgVersionInRootPackageJson); } catch {} // we could not resolve the version diff --git a/packages/jest/src/utils/versions.ts b/packages/jest/src/utils/versions.ts index 62153c31a1d24..7e6e75241f4b5 100644 --- a/packages/jest/src/utils/versions.ts +++ b/packages/jest/src/utils/versions.ts @@ -1,4 +1,4 @@ -import { readJson, type Tree } from '@nx/devkit'; +import { getDependencyVersionFromPackageJson, type Tree } from '@nx/devkit'; import { clean, coerce, major } from 'semver'; const nxVersion = require('../../package.json').version; @@ -109,9 +109,7 @@ export function validateInstalledJestVersion(tree?: Tree): void { } function getJestVersionFromTree(tree: Tree): string | null { - const packageJson = readJson(tree, 'package.json'); - const installedVersion = - packageJson.devDependencies?.jest ?? packageJson.dependencies?.jest; + const installedVersion = getDependencyVersionFromPackageJson(tree, 'jest'); if (!installedVersion) { return null; diff --git a/packages/js/src/generators/init/init.ts b/packages/js/src/generators/init/init.ts index 0616ddfc96fc5..ec8760b29d392 100644 --- a/packages/js/src/generators/init/init.ts +++ b/packages/js/src/generators/init/init.ts @@ -5,6 +5,7 @@ import { formatFiles, generateFiles, GeneratorCallback, + getDependencyVersionFromPackageJson, readJson, readNxJson, runTasksInSerial, @@ -37,10 +38,10 @@ import { InitSchema } from './schema'; async function getInstalledTypescriptVersion( tree: Tree ): Promise { - const rootPackageJson = readJson(tree, 'package.json'); - const tsVersionInRootPackageJson = - rootPackageJson.devDependencies?.['typescript'] ?? - rootPackageJson.dependencies?.['typescript']; + const tsVersionInRootPackageJson = getDependencyVersionFromPackageJson( + tree, + 'typescript' + ); if (!tsVersionInRootPackageJson) { return null; @@ -64,7 +65,11 @@ async function getInstalledTypescriptVersion( return installedTsVersion; } } finally { - return checkAndCleanWithSemver('typescript', tsVersionInRootPackageJson); + return checkAndCleanWithSemver( + tree, + 'typescript', + tsVersionInRootPackageJson + ); } } diff --git a/packages/module-federation/src/utils/package-json.ts b/packages/module-federation/src/utils/package-json.ts index 52e64b7fb983c..5835e23681c64 100644 --- a/packages/module-federation/src/utils/package-json.ts +++ b/packages/module-federation/src/utils/package-json.ts @@ -1,10 +1,8 @@ +import { joinPathFragments, readJsonFile, workspaceRoot } from '@nx/devkit'; import { existsSync } from 'fs'; -import { workspaceRoot, readJsonFile, joinPathFragments } from '@nx/devkit'; +import type { PackageJson } from 'nx/src/utils/package-json'; -export function readRootPackageJson(): { - dependencies?: { [key: string]: string }; - devDependencies?: { [key: string]: string }; -} { +export function readRootPackageJson(): PackageJson { const pkgJsonPath = joinPathFragments(workspaceRoot, 'package.json'); if (!existsSync(pkgJsonPath)) { throw new Error( diff --git a/packages/module-federation/src/utils/share.ts b/packages/module-federation/src/utils/share.ts index ce88b46da5b7f..9782038c87c6f 100644 --- a/packages/module-federation/src/utils/share.ts +++ b/packages/module-federation/src/utils/share.ts @@ -17,6 +17,7 @@ import { logger, readJsonFile, joinPathFragments, + getDependencyVersionFromPackageJson, } from '@nx/devkit'; import { existsSync } from 'fs'; import type { PackageJson } from 'nx/src/utils/package-json'; @@ -112,7 +113,13 @@ export function shareWorkspaceLibraries( } return pathMappings.reduce((libraries, library) => { // Check to see if the library version is declared in the app's package.json - let version = pkgJson?.dependencies?.[library.name]; + let version = pkgJson + ? getDependencyVersionFromPackageJson( + library.name, + workspaceRoot, + pkgJson + ) + : null; if (!version && workspaceLibs.length > 0) { const workspaceLib = workspaceLibs.find( (lib) => lib.importKey === library.name @@ -215,8 +222,11 @@ export function sharePackages( const pkgJson = readRootPackageJson(); const allPackages: { name: string; version: string }[] = []; packages.forEach((pkg) => { - const pkgVersion = - pkgJson.dependencies?.[pkg] ?? pkgJson.devDependencies?.[pkg]; + const pkgVersion = getDependencyVersionFromPackageJson( + pkg, + workspaceRoot, + pkgJson + ); allPackages.push({ name: pkg, version: pkgVersion }); collectPackageSecondaryEntryPoints(pkg, pkgVersion, allPackages); }); @@ -302,8 +312,7 @@ function addStringDependencyToSharedConfig( const pkgJson = readRootPackageJson(); const config = getNpmPackageSharedConfig( dependency, - pkgJson.dependencies?.[dependency] ?? - pkgJson.devDependencies?.[dependency] + getDependencyVersionFromPackageJson(dependency, workspaceRoot, pkgJson) ); if (!config) { diff --git a/packages/next/src/utils/version-utils.ts b/packages/next/src/utils/version-utils.ts index 24472a912050d..48244068c3e33 100644 --- a/packages/next/src/utils/version-utils.ts +++ b/packages/next/src/utils/version-utils.ts @@ -1,6 +1,10 @@ -import { type Tree, readJson, createProjectGraphAsync } from '@nx/devkit'; +import { + type Tree, + createProjectGraphAsync, + getDependencyVersionFromPackageJson, +} from '@nx/devkit'; import { clean, coerce, major } from 'semver'; -import { nextVersion, next14Version } from './versions'; +import { next14Version, nextVersion } from './versions'; type NextDependenciesVersions = { next: string; @@ -30,9 +34,10 @@ export async function isNext14(tree: Tree) { } export function getInstalledNextVersion(tree: Tree): string { - const pkgJson = readJson(tree, 'package.json'); - const installedNextVersion = - pkgJson.dependencies && pkgJson.dependencies['next']; + const installedNextVersion = getDependencyVersionFromPackageJson( + tree, + 'next' + ); if ( !installedNextVersion || diff --git a/packages/nx/src/command-line/init/implementation/angular/index.ts b/packages/nx/src/command-line/init/implementation/angular/index.ts index 30bab7d202103..43a0118fc2ba4 100644 --- a/packages/nx/src/command-line/init/implementation/angular/index.ts +++ b/packages/nx/src/command-line/init/implementation/angular/index.ts @@ -4,7 +4,10 @@ import { readJsonFile, writeJsonFile } from '../../../../utils/fileutils'; import { nxVersion } from '../../../../utils/versions'; import { sortObjectByKeys } from '../../../../utils/object-sort'; import { output } from '../../../../utils/output'; -import type { PackageJson } from '../../../../utils/package-json'; +import { + getDependencyVersionFromPackageJson, + type PackageJson, +} from '../../../../utils/package-json'; import { addDepsToPackageJson, initCloud, @@ -125,12 +128,21 @@ function addPluginDependencies(): void { '@schematics/angular', ]; const angularCliVersion = - packageJson.devDependencies['@angular/cli'] ?? - packageJson.dependencies?.['@angular/cli'] ?? - packageJson.devDependencies['@angular-devkit/build-angular'] ?? - packageJson.dependencies?.['@angular-devkit/build-angular'] ?? - packageJson.devDependencies['@angular/build'] ?? - packageJson.dependencies?.['@angular/build']; + getDependencyVersionFromPackageJson( + '@angular/cli', + repoRoot, + packageJson + ) ?? + getDependencyVersionFromPackageJson( + '@angular-devkit/build-angular', + repoRoot, + packageJson + ) ?? + getDependencyVersionFromPackageJson( + '@angular/build', + repoRoot, + packageJson + ); for (const dep of peerDepsToInstall) { if (!packageJson.devDependencies[dep] && !packageJson.dependencies?.[dep]) { diff --git a/packages/nx/src/command-line/migrate/migrate.ts b/packages/nx/src/command-line/migrate/migrate.ts index 37b33180fae25..bf1a94aad735a 100644 --- a/packages/nx/src/command-line/migrate/migrate.ts +++ b/packages/nx/src/command-line/migrate/migrate.ts @@ -41,6 +41,7 @@ import { logger } from '../../utils/logger'; import { commitChanges } from '../../utils/git-utils'; import { ArrayPackageGroup, + getDependencyVersionFromPackageJson, NxMigrationsConfiguration, PackageJson, readModulePackageJson, @@ -83,6 +84,7 @@ import { ensurePackageHasProvenance, getNxPackageGroup, } from '../../utils/provenance'; +import { getCatalogManager } from '../../utils/catalog'; export interface ResolvedMigrationConfiguration extends MigrationsJson { packageGroup?: ArrayPackageGroup; @@ -1213,7 +1215,30 @@ async function updatePackageJson( const parseOptions: JsonReadOptions = {}; const json = readJsonFile(packageJsonPath, parseOptions); + const manager = getCatalogManager(root); + const supportsCatalogs = manager.supportsCatalogs(); + const catalogUpdates = []; + Object.keys(updatedPackages).forEach((p) => { + const existingVersion = json.dependencies?.[p] ?? json.devDependencies?.[p]; + + if ( + supportsCatalogs && + existingVersion && + manager.isCatalogReference(existingVersion) + ) { + const { catalogName } = manager.parseCatalogReference(existingVersion); + catalogUpdates.push({ + packageName: p, + version: updatedPackages[p].version, + catalogName, + }); + + // don't overwrite the catalog reference with the new version + return; + } + + // Update non-catalog packages in package.json if (json.devDependencies?.[p]) { json.devDependencies[p] = updatedPackages[p].version; return; @@ -1234,6 +1259,11 @@ async function updatePackageJson( await writeFormattedJsonFile(packageJsonPath, json, { appendNewLine: parseOptions.endsWithNewline, }); + + // Update catalog definitions + if (catalogUpdates.length && supportsCatalogs) { + manager.updateCatalogVersions(root, catalogUpdates); + } } async function updateInstallationDetails( @@ -1281,14 +1311,11 @@ async function isMigratingToNewMajor(from: string, to: string) { return major(from) < major(to); } -function readNxVersion(packageJson: PackageJson) { +function readNxVersion(packageJson: PackageJson, root: string) { return ( - packageJson?.devDependencies?.['nx'] ?? - packageJson?.dependencies?.['nx'] ?? - packageJson?.devDependencies?.['@nx/workspace'] ?? - packageJson?.dependencies?.['@nx/workspace'] ?? - packageJson?.devDependencies?.['@nrwl/workspace'] ?? - packageJson?.dependencies?.['@nrwl/workspace'] + getDependencyVersionFromPackageJson('nx', root, packageJson) ?? + getDependencyVersionFromPackageJson('@nx/workspace', root, packageJson) ?? + getDependencyVersionFromPackageJson('@nrwl/workspace', root, packageJson) ); } @@ -1305,7 +1332,7 @@ async function generateMigrationsJsonAndUpdatePackageJson( const originalNxJson = readNxJson(); const from = originalNxJson.installation?.version ?? - readNxVersion(originalPackageJson); + readNxVersion(originalPackageJson, root); logger.info(`Fetching meta data about packages.`); logger.info(`It may take a few minutes.`); diff --git a/packages/nx/src/devkit-internals.ts b/packages/nx/src/devkit-internals.ts index 4fab76acef989..1c284fae30aaf 100644 --- a/packages/nx/src/devkit-internals.ts +++ b/packages/nx/src/devkit-internals.ts @@ -42,3 +42,4 @@ export { registerTsProject } from './plugins/js/utils/register'; export { interpolate } from './tasks-runner/utils'; export { isCI } from './utils/is-ci'; export { isUsingPrettierInTree } from './utils/is-using-prettier'; +export { readYamlFile } from './utils/fileutils'; diff --git a/packages/nx/src/plugins/js/lock-file/lock-file.ts b/packages/nx/src/plugins/js/lock-file/lock-file.ts index daa525110c5dd..f1dbcce76b5c4 100644 --- a/packages/nx/src/plugins/js/lock-file/lock-file.ts +++ b/packages/nx/src/plugins/js/lock-file/lock-file.ts @@ -252,7 +252,12 @@ export function createLockFile( } if (packageManager === 'pnpm') { const prunedGraph = pruneProjectGraph(graph, packageJson); - return stringifyPnpmLockfile(prunedGraph, content, normalizedPackageJson); + return stringifyPnpmLockfile( + prunedGraph, + content, + normalizedPackageJson, + workspaceRoot + ); } if (packageManager === 'npm') { const prunedGraph = pruneProjectGraph(graph, packageJson); diff --git a/packages/nx/src/plugins/js/lock-file/pnpm-parser.spec.ts b/packages/nx/src/plugins/js/lock-file/pnpm-parser.spec.ts index 5ae7edf0fb9e5..4fc169e084a84 100644 --- a/packages/nx/src/plugins/js/lock-file/pnpm-parser.spec.ts +++ b/packages/nx/src/plugins/js/lock-file/pnpm-parser.spec.ts @@ -197,7 +197,12 @@ describe('pnpm LockFile utility', () => { // this should not fail expect(() => - stringifyPnpmLockfile(prunedGraph, lockFile, appPackageJson) + stringifyPnpmLockfile( + prunedGraph, + lockFile, + appPackageJson, + '/virtual' + ) ).not.toThrow(); }); }); @@ -308,7 +313,12 @@ describe('pnpm LockFile utility', () => { // this should not fail expect(() => - stringifyPnpmLockfile(prunedGraph, appLockFile, appPackageJson) + stringifyPnpmLockfile( + prunedGraph, + appLockFile, + appPackageJson, + '/virtual' + ) ).not.toThrow(); }); }); @@ -503,7 +513,8 @@ describe('pnpm LockFile utility', () => { const result = stringifyPnpmLockfile( prunedGraph, lockFile, - prunedPackageJson + prunedPackageJson, + '/virtual' ); // we replace the dev: true with dev: false because the lock file is generated with dev: false // this does not break the intallation, despite being inaccurate @@ -731,7 +742,8 @@ describe('pnpm LockFile utility', () => { const result = stringifyPnpmLockfile( prunedGraph, lockFile, - typescriptPackageJson + typescriptPackageJson, + '/virtual' ); expect(result).toEqual( require(joinPathFragments( @@ -750,7 +762,8 @@ describe('pnpm LockFile utility', () => { const result = stringifyPnpmLockfile( prunedGraph, lockFile, - multiPackageJson + multiPackageJson, + '/virtual' ); expect(result).toEqual( require(joinPathFragments( @@ -830,7 +843,8 @@ describe('pnpm LockFile utility', () => { const result = stringifyPnpmLockfile( prunedGraph, lockFile, - typescriptPackageJson + typescriptPackageJson, + '/virtual' ); expect(result).toEqual( require(joinPathFragments( @@ -849,7 +863,8 @@ describe('pnpm LockFile utility', () => { const result = stringifyPnpmLockfile( prunedGraph, lockFile, - multiPackageJson + multiPackageJson, + '/virtual' ); expect(result).toEqual( require(joinPathFragments( @@ -929,7 +944,8 @@ describe('pnpm LockFile utility', () => { const result = stringifyPnpmLockfile( prunedGraph, lockFile, - typescriptPackageJson + typescriptPackageJson, + '/virtual' ); expect(result).toEqual( require(joinPathFragments( @@ -948,7 +964,8 @@ describe('pnpm LockFile utility', () => { const result = stringifyPnpmLockfile( prunedGraph, lockFile, - multiPackageJson + multiPackageJson, + '/virtual' ); expect(result).toEqual( require(joinPathFragments( @@ -1259,7 +1276,12 @@ describe('pnpm LockFile utility', () => { `); const prunedGraph = pruneProjectGraph(graph, packageJson); - const result = stringifyPnpmLockfile(prunedGraph, lockFile, packageJson); + const result = stringifyPnpmLockfile( + prunedGraph, + lockFile, + packageJson, + '/virtual' + ); expect(result).toEqual(lockFile); }); @@ -1507,7 +1529,12 @@ describe('pnpm LockFile utility', () => { `); const prunedGraph = pruneProjectGraph(graph, packageJson); - const result = stringifyPnpmLockfile(prunedGraph, lockFile, packageJson); + const result = stringifyPnpmLockfile( + prunedGraph, + lockFile, + packageJson, + '/virtual' + ); expect(result).toEqual(lockFile); }); }); @@ -1579,7 +1606,12 @@ describe('pnpm LockFile utility', () => { graph = builder.getUpdatedProjectGraph(); const prunedGraph = pruneProjectGraph(graph, packageJson); - const result = stringifyPnpmLockfile(prunedGraph, lockFile, packageJson); + const result = stringifyPnpmLockfile( + prunedGraph, + lockFile, + packageJson, + '/virtual' + ); expect(result).toEqual(prunedLockFile); }); }); @@ -1643,7 +1675,12 @@ describe('pnpm LockFile utility', () => { }; const prunedGraph = pruneProjectGraph(graph, packageJson); - const result = stringifyPnpmLockfile(prunedGraph, lockFile, packageJson); + const result = stringifyPnpmLockfile( + prunedGraph, + lockFile, + packageJson, + '/virtual' + ); expect(result).toEqual(expectedPrunedLockFile); }); diff --git a/packages/nx/src/plugins/js/lock-file/pnpm-parser.ts b/packages/nx/src/plugins/js/lock-file/pnpm-parser.ts index cfd868a54eae0..45c50c02ce635 100644 --- a/packages/nx/src/plugins/js/lock-file/pnpm-parser.ts +++ b/packages/nx/src/plugins/js/lock-file/pnpm-parser.ts @@ -26,6 +26,7 @@ import { } from '../../../config/project-graph'; import { hashArray } from '../../../hasher/file-hasher'; import { CreateDependenciesContext } from '../../../project-graph/plugins'; +import { getCatalogManager } from '../../../utils/catalog'; import { findNodeMatchingVersion } from './project-graph-pruning'; import { join } from 'path'; import { getWorkspacePackagesFromGraph } from '../utils/get-workspace-packages-from-graph'; @@ -403,13 +404,21 @@ function parseBaseVersion(rawVersion: string, isV5: boolean): string { export function stringifyPnpmLockfile( graph: ProjectGraph, rootLockFileContent: string, - packageJson: NormalizedPackageJson + packageJson: NormalizedPackageJson, + workspaceRoot: string ): string { const data = parseAndNormalizePnpmLockfile(rootLockFileContent); const { lockfileVersion, packages, importers } = data; const { snapshot: rootSnapshot, importers: requiredImporters } = - mapRootSnapshot(packageJson, importers, packages, graph, +lockfileVersion); + mapRootSnapshot( + packageJson, + importers, + packages, + graph, + +lockfileVersion, + workspaceRoot + ); const snapshots = mapSnapshots( data.packages, graph.externalNodes, @@ -577,7 +586,8 @@ function mapRootSnapshot( rootImporters: Record, packages: PackageSnapshots, graph: ProjectGraph, - lockfileVersion: number + lockfileVersion: number, + workspaceRoot: string ) { const workspaceModules = getWorkspacePackagesFromGraph(graph); const snapshot: ProjectSnapshot = { specifiers: {} }; @@ -590,7 +600,21 @@ function mapRootSnapshot( ].forEach((depType) => { if (packageJson[depType]) { Object.keys(packageJson[depType]).forEach((packageName) => { - const version = packageJson[depType][packageName]; + let version = packageJson[depType][packageName]; + const manager = getCatalogManager(workspaceRoot); + if (manager.isCatalogReference(version)) { + version = manager.resolveCatalogReference( + packageName, + version, + workspaceRoot + ); + if (!version) { + throw new Error( + `Could not resolve catalog reference for package ${packageName}@${version}.` + ); + } + } + if (workspaceModules.has(packageName)) { for (const [importerPath, importerSnapshot] of Object.entries( rootImporters diff --git a/packages/nx/src/plugins/js/lock-file/project-graph-pruning.ts b/packages/nx/src/plugins/js/lock-file/project-graph-pruning.ts index 0d755e804a4ef..89f14c539e98e 100644 --- a/packages/nx/src/plugins/js/lock-file/project-graph-pruning.ts +++ b/packages/nx/src/plugins/js/lock-file/project-graph-pruning.ts @@ -1,12 +1,14 @@ +import { gte, satisfies } from 'semver'; import { ProjectGraph, ProjectGraphExternalNode, ProjectGraphProjectNode, } from '../../../config/project-graph'; -import { satisfies, gte } from 'semver'; -import { PackageJson } from '../../../utils/package-json'; -import { ProjectGraphBuilder } from '../../../project-graph/project-graph-builder'; import { reverse } from '../../../project-graph/operators'; +import { ProjectGraphBuilder } from '../../../project-graph/project-graph-builder'; +import { getCatalogManager } from '../../../utils/catalog'; +import { PackageJson } from '../../../utils/package-json'; +import { workspaceRoot } from '../../../utils/workspace-root'; import { getWorkspacePackagesFromGraph } from '../utils/get-workspace-packages-from-graph'; /** @@ -15,14 +17,16 @@ import { getWorkspacePackagesFromGraph } from '../utils/get-workspace-packages-f */ export function pruneProjectGraph( graph: ProjectGraph, - prunedPackageJson: PackageJson + prunedPackageJson: PackageJson, + workspaceRootPath: string = workspaceRoot ): ProjectGraph { const builder = new ProjectGraphBuilder(); const workspacePackages = getWorkspacePackagesFromGraph(graph); const combinedDependencies = normalizeDependencies( prunedPackageJson, graph, - workspacePackages + workspacePackages, + workspaceRootPath ); addNodesAndDependencies( @@ -49,7 +53,8 @@ export function pruneProjectGraph( function normalizeDependencies( packageJson: PackageJson, graph: ProjectGraph, - workspacePackages: Map + workspacePackages: Map, + workspaceRootPath: string ) { const { dependencies, @@ -67,25 +72,47 @@ function normalizeDependencies( Object.entries(combinedDependencies).forEach( ([packageName, versionRange]) => { - if (graph.externalNodes[`npm:${packageName}@${versionRange}`]) { + let resolvedVersionRange = versionRange; + const manager = getCatalogManager(workspaceRootPath); + if (manager.isCatalogReference(versionRange)) { + const resolvedVersionRange = manager.resolveCatalogReference( + packageName, + versionRange, + workspaceRootPath + ); + if (!resolvedVersionRange) { + throw new Error( + `Could not resolve catalog reference for ${packageName}@${versionRange}.` + ); + } + } + + if (graph.externalNodes[`npm:${packageName}@${resolvedVersionRange}`]) { + combinedDependencies[packageName] = resolvedVersionRange; return; } if ( graph.externalNodes[`npm:${packageName}`] && - graph.externalNodes[`npm:${packageName}`].data.version === versionRange + graph.externalNodes[`npm:${packageName}`].data.version === + resolvedVersionRange ) { + combinedDependencies[packageName] = resolvedVersionRange; return; } // otherwise we need to find the correct version - const node = findNodeMatchingVersion(graph, packageName, versionRange); + const node = findNodeMatchingVersion( + graph, + packageName, + resolvedVersionRange + ); if (node) { combinedDependencies[packageName] = node.data.version; } else if (workspacePackages.has(packageName)) { // workspace module, leave as is - combinedDependencies[packageName] = versionRange; + combinedDependencies[packageName] = resolvedVersionRange; } else { throw new Error( - `Pruned lock file creation failed. The following package was not found in the root lock file: ${packageName}@${versionRange}` + `Pruned lock file creation failed. The following package was not found in the root lock file: ${packageName}@${resolvedVersionRange}` ); } } diff --git a/packages/nx/src/plugins/js/package-json/create-package-json.spec.ts b/packages/nx/src/plugins/js/package-json/create-package-json.spec.ts index 640ccbf425b1c..63cf81d7125ed 100644 --- a/packages/nx/src/plugins/js/package-json/create-package-json.spec.ts +++ b/packages/nx/src/plugins/js/package-json/create-package-json.spec.ts @@ -585,6 +585,7 @@ describe('createPackageJson', () => { }); it('should use range versions when creating package json for libs', () => { + spies.push(jest.spyOn(configModule, 'readNxJson').mockReturnValue({})); spies.push( jest .spyOn(fileutilsModule, 'readJsonFile') @@ -615,6 +616,11 @@ describe('createPackageJson', () => { }); it('should override range versions with local ranges when creating package json for libs', () => { + spies.push( + jest + .spyOn(configModule, 'readNxJson') + .mockReturnValue({ cli: { packageManager: 'pnpm' } }) + ); spies.push( jest.spyOn(fs, 'existsSync').mockImplementation((path) => { if (path === 'libs/lib1/package.json') { diff --git a/packages/nx/src/plugins/js/package-json/create-package-json.ts b/packages/nx/src/plugins/js/package-json/create-package-json.ts index b61f6ee7d2e3b..46b13706acd26 100644 --- a/packages/nx/src/plugins/js/package-json/create-package-json.ts +++ b/packages/nx/src/plugins/js/package-json/create-package-json.ts @@ -17,6 +17,7 @@ import { getTargetInputs, } from '../../../hasher/task-hasher'; import { output } from '../../../utils/output'; +import { getCatalogManager } from '../../../utils/catalog'; interface NpmDeps { readonly dependencies: Record; @@ -45,10 +46,9 @@ export function createPackageJson( ): PackageJson { const projectNode = graph.nodes[projectName]; const isLibrary = projectNode.type === 'lib'; + const root = options.root ?? workspaceRoot; - const rootPackageJson: PackageJson = readJsonFile( - join(options.root ?? workspaceRoot, 'package.json') - ); + const rootPackageJson: PackageJson = readJsonFile(join(root, 'package.json')); const npmDeps = findProjectsNpmDependencies( projectNode, @@ -68,7 +68,7 @@ export function createPackageJson( version: '0.0.1', }; const projectPackageJsonPath = join( - options.root ?? workspaceRoot, + root, projectNode.data.root, 'package.json' ); @@ -99,11 +99,25 @@ export function createPackageJson( version: string, section: 'devDependencies' | 'dependencies' ) => { - return ( - packageJson[section][packageName] || - (isLibrary && rootPackageJson[section]?.[packageName]) || - version - ); + const projectVersion = packageJson[section]?.[packageName]; + if (projectVersion) { + const manager = getCatalogManager(root); + return manager.isCatalogReference(projectVersion) + ? manager.resolveCatalogReference(packageName, projectVersion, root) ?? + version + : projectVersion; + } + + if (isLibrary && rootPackageJson[section]?.[packageName]) { + const rootVersion = rootPackageJson[section][packageName]; + const manager = getCatalogManager(root); + return manager.isCatalogReference(rootVersion) + ? manager.resolveCatalogReference(packageName, rootVersion, root) ?? + version + : rootVersion; + } + + return version; }; Object.entries(npmDeps.dependencies).forEach(([packageName, version]) => { diff --git a/packages/nx/src/utils/catalog/errors.ts b/packages/nx/src/utils/catalog/errors.ts new file mode 100644 index 0000000000000..4bd4495616c3a --- /dev/null +++ b/packages/nx/src/utils/catalog/errors.ts @@ -0,0 +1,17 @@ +import type { CatalogError } from './types'; + +export class CatalogValidationError extends Error { + constructor(public readonly catalogError: CatalogError, message?: string) { + super(message || catalogError.message); + this.name = 'CatalogValidationError'; + } +} + +export class CatalogUnsupportedError extends Error { + constructor(public readonly packageManager: string, operation: string) { + super( + `Tried to ${operation} but Nx doesn't support catalogs for the current package manager (${packageManager})` + ); + this.name = 'CatalogUnsupportedError'; + } +} diff --git a/packages/nx/src/utils/catalog/index.spec.ts b/packages/nx/src/utils/catalog/index.spec.ts new file mode 100644 index 0000000000000..80ca1e3813070 --- /dev/null +++ b/packages/nx/src/utils/catalog/index.spec.ts @@ -0,0 +1,137 @@ +import { createTreeWithEmptyWorkspace } from '../../generators/testing-utils/create-tree-with-empty-workspace'; +import type { Tree } from '../../generators/tree'; +import { writeJson } from '../../generators/utils/json'; +import { + formatCatalogError, + getCatalogDependenciesFromPackageJson, +} from './index'; +import type { CatalogManager } from './manager'; +import { PnpmCatalogManager } from './pnpm-manager'; +import { CatalogErrorType } from './types'; +import { NpmCatalogManager } from './unsupported-manager'; + +describe('package manager catalogs', () => { + let tree: Tree; + + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + }); + + describe('getCatalogDependenciesFromPackageJson', () => { + let manager: CatalogManager; + + beforeEach(() => { + manager = new PnpmCatalogManager(); + }); + + it('should return empty map when package.json does not exist', () => { + const result = getCatalogDependenciesFromPackageJson( + tree, + 'package.json', + manager + ); + + expect(result).toStrictEqual(new Map()); + }); + + it('should return empty map when manager does not support catalogs', () => { + const result = getCatalogDependenciesFromPackageJson( + tree, + 'package.json', + new NpmCatalogManager() + ); + + expect(result).toStrictEqual(new Map()); + }); + + it('should return empty map when package.json cannot be read', () => { + tree.write('package.json', 'invalid: json: content: ['); + + const result = getCatalogDependenciesFromPackageJson( + tree, + 'package.json', + manager + ); + + expect(result).toStrictEqual(new Map()); + }); + + it('should return empty map when package.json has no dependencies', () => { + tree.write('package.json', '{}'); + + const result = getCatalogDependenciesFromPackageJson( + tree, + 'package.json', + manager + ); + + expect(result).toStrictEqual(new Map()); + }); + + it('should return empty map when package.json has no catalog dependencies', () => { + writeJson(tree, 'package.json', { dependencies: { lodash: '^4.17.21' } }); + + const result = getCatalogDependenciesFromPackageJson( + tree, + 'package.json', + manager + ); + + expect(result).toStrictEqual(new Map()); + }); + + it('should return map with catalog dependencies', () => { + tree.write( + 'pnpm-workspace.yaml', + ` +packages: + - packages/* +catalog: + react: ^18.0.0 +catalogs: + react17: + react: ^17.0.0 + react-dom: ^17.0.0 + other: + lodash: ^4.17.21 +` + ); + writeJson(tree, 'package.json', { + dependencies: { react: 'catalog:', lodash: 'catalog:other' }, + }); + + const result = getCatalogDependenciesFromPackageJson( + tree, + 'package.json', + manager + ); + + expect(result).toStrictEqual( + new Map([ + ['react', undefined], + ['lodash', 'other'], + ]) + ); + }); + }); + + describe('formatCatalogError', () => { + it('should format error with suggestions', () => { + const error = { + type: CatalogErrorType.CATALOG_NOT_FOUND, + message: 'Catalog not found', + suggestions: ['Try catalog:dev', 'Try catalog:prod'], + }; + + const result = formatCatalogError(error); + + expect(result).toMatchInlineSnapshot(` + "Catalog not found + + Suggestions: + • Try catalog:dev + • Try catalog:prod" + `); + }); + }); +}); diff --git a/packages/nx/src/utils/catalog/index.ts b/packages/nx/src/utils/catalog/index.ts new file mode 100644 index 0000000000000..a248cd72935a1 --- /dev/null +++ b/packages/nx/src/utils/catalog/index.ts @@ -0,0 +1,65 @@ +import type { Tree } from '../../generators/tree'; +import { readJson } from '../../generators/utils/json'; +import type { CatalogManager } from './manager'; +import { getCatalogManager } from './manager-factory'; +import type { CatalogError } from './types'; + +export { getCatalogManager }; + +/** + * Detects which packages in a package.json use catalog references + * Returns Map of package name -> catalog name (undefined for default catalog) + */ +export function getCatalogDependenciesFromPackageJson( + tree: Tree, + packageJsonPath: string, + manager: CatalogManager +): Map { + const catalogDeps = new Map(); + + if (!tree.exists(packageJsonPath)) { + return catalogDeps; + } + + if (!manager.supportsCatalogs()) { + return catalogDeps; + } + + try { + const packageJson = readJson(tree, packageJsonPath); + const allDependencies: Record = { + ...packageJson.dependencies, + ...packageJson.devDependencies, + ...packageJson.peerDependencies, + ...packageJson.optionalDependencies, + }; + + for (const [packageName, version] of Object.entries( + allDependencies || {} + )) { + if (manager.isCatalogReference(version)) { + const catalogRef = manager.parseCatalogReference(version); + if (catalogRef) { + catalogDeps.set(packageName, catalogRef.catalogName); + } + } + } + } catch (error) { + // If we can't read the package.json, return empty map + } + + return catalogDeps; +} + +export function formatCatalogError(error: CatalogError): string { + let message = error.message; + + if (error.suggestions && error.suggestions.length > 0) { + message += '\n\nSuggestions:'; + error.suggestions.forEach((suggestion) => { + message += `\n • ${suggestion}`; + }); + } + + return message; +} diff --git a/packages/nx/src/utils/catalog/manager-factory.spec.ts b/packages/nx/src/utils/catalog/manager-factory.spec.ts new file mode 100644 index 0000000000000..2a501640073c9 --- /dev/null +++ b/packages/nx/src/utils/catalog/manager-factory.spec.ts @@ -0,0 +1,55 @@ +import * as packageManager from '../package-manager'; +import { getCatalogManager } from './manager-factory'; +import { PnpmCatalogManager } from './pnpm-manager'; +import { + BunCatalogManager, + NpmCatalogManager, + YarnCatalogManager, +} from './unsupported-manager'; + +describe('getCatalogManager', () => { + const mockDetectPackageManager = jest.spyOn( + packageManager, + 'detectPackageManager' + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return PnpmCatalogManager for pnpm', () => { + mockDetectPackageManager.mockReturnValue('pnpm'); + + const manager = getCatalogManager('/test'); + + expect(manager).toBeInstanceOf(PnpmCatalogManager); + expect(manager.supportsCatalogs()).toBe(true); + }); + + it('should return NpmCatalogManager for npm', () => { + mockDetectPackageManager.mockReturnValue('npm'); + + const manager = getCatalogManager('/test'); + + expect(manager).toBeInstanceOf(NpmCatalogManager); + expect(manager.supportsCatalogs()).toBe(false); + }); + + it('should return YarnCatalogManager for yarn', () => { + mockDetectPackageManager.mockReturnValue('yarn'); + + const manager = getCatalogManager('/test'); + + expect(manager).toBeInstanceOf(YarnCatalogManager); + expect(manager.supportsCatalogs()).toBe(false); + }); + + it('should return BunCatalogManager for bun', () => { + mockDetectPackageManager.mockReturnValue('bun'); + + const manager = getCatalogManager('/test'); + + expect(manager).toBeInstanceOf(BunCatalogManager); + expect(manager.supportsCatalogs()).toBe(false); + }); +}); diff --git a/packages/nx/src/utils/catalog/manager-factory.ts b/packages/nx/src/utils/catalog/manager-factory.ts new file mode 100644 index 0000000000000..f585c7d01668a --- /dev/null +++ b/packages/nx/src/utils/catalog/manager-factory.ts @@ -0,0 +1,29 @@ +import { detectPackageManager } from '../package-manager'; +import type { CatalogManager } from './manager'; +import { PnpmCatalogManager } from './pnpm-manager'; +import { + BunCatalogManager, + NpmCatalogManager, + UnknownCatalogManager, + YarnCatalogManager, +} from './unsupported-manager'; + +/** + * Factory function to get the appropriate catalog manager based on the package manager + */ +export function getCatalogManager(workspaceRoot: string): CatalogManager { + const packageManager = detectPackageManager(workspaceRoot); + + switch (packageManager) { + case 'pnpm': + return new PnpmCatalogManager(); + case 'npm': + return new NpmCatalogManager(); + case 'yarn': + return new YarnCatalogManager(); + case 'bun': + return new BunCatalogManager(); + default: + return new UnknownCatalogManager(); + } +} diff --git a/packages/nx/src/utils/catalog/manager.ts b/packages/nx/src/utils/catalog/manager.ts new file mode 100644 index 0000000000000..f002690eac368 --- /dev/null +++ b/packages/nx/src/utils/catalog/manager.ts @@ -0,0 +1,72 @@ +import type { Tree } from '../../generators/tree'; +import type { PnpmWorkspaceYaml } from '../pnpm-workspace'; +import type { CatalogError, CatalogReference } from './types'; + +/** + * Interface for catalog managers that handle package manager-specific catalog implementations. + */ +export interface CatalogManager { + readonly name: string; + /** + * Check if this package manager supports catalogs. + */ + supportsCatalogs(): boolean; + + isCatalogReference(version: string): boolean; + + parseCatalogReference(version: string): CatalogReference | null; + + /** + * Get catalog definitions from the workspace. + */ + getCatalogDefinitions(workspaceRoot: string): PnpmWorkspaceYaml | null; + getCatalogDefinitions(tree: Tree): PnpmWorkspaceYaml | null; + + /** + * Resolve a catalog reference to an actual version. + */ + resolveCatalogReference( + workspaceRoot: string, + packageName: string, + version: string + ): string | null; + resolveCatalogReference( + tree: Tree, + packageName: string, + version: string + ): string | null; + + /** + * Check that a catalog reference is valid. + */ + validateCatalogReference( + workspaceRoot: string, + packageName: string, + version: string + ): { isValid: boolean; error?: CatalogError }; + validateCatalogReference( + tree: Tree, + packageName: string, + version: string + ): { isValid: boolean; error?: CatalogError }; + + /** + * Updates catalog definitions for specified packages in their respective catalogs. + */ + updateCatalogVersions( + tree: Tree, + updates: Array<{ + packageName: string; + version: string; + catalogName?: string; + }> + ): void; + updateCatalogVersions( + workspaceRoot: string, + updates: Array<{ + packageName: string; + version: string; + catalogName?: string; + }> + ): void; +} diff --git a/packages/nx/src/utils/catalog/pnpm-manager.spec.ts b/packages/nx/src/utils/catalog/pnpm-manager.spec.ts new file mode 100644 index 0000000000000..9516fa00b5fdd --- /dev/null +++ b/packages/nx/src/utils/catalog/pnpm-manager.spec.ts @@ -0,0 +1,268 @@ +import type { Tree } from '../../generators/tree'; +import { createTreeWithEmptyWorkspace } from '../../generators/testing-utils/create-tree-with-empty-workspace'; +import { PnpmCatalogManager } from './pnpm-manager'; +import { CatalogErrorType } from './types'; + +describe('PnpmCatalogManager', () => { + let tree: Tree; + let manager: PnpmCatalogManager; + + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + manager = new PnpmCatalogManager(); + }); + + describe('isCatalogReference', () => { + it('should return true for catalog references', () => { + expect(manager.isCatalogReference('catalog:')).toBe(true); + expect(manager.isCatalogReference('catalog:react18')).toBe(true); + }); + + it('should return false for non-catalog references', () => { + expect(manager.isCatalogReference('^18.0.0')).toBe(false); + expect(manager.isCatalogReference('latest')).toBe(false); + expect(manager.isCatalogReference('catalog')).toBe(false); + }); + }); + + describe('parseCatalogReference', () => { + it('should parse default catalog reference', () => { + const result = manager.parseCatalogReference('catalog:'); + + expect(result).toStrictEqual({ + catalogName: undefined, + isDefaultCatalog: true, + }); + }); + + it('should parse named catalog reference', () => { + const result = manager.parseCatalogReference('catalog:react18'); + + expect(result).toStrictEqual({ + catalogName: 'react18', + isDefaultCatalog: false, + }); + }); + + it('should return null for non-catalog reference', () => { + const result = manager.parseCatalogReference('^18.0.0'); + + expect(result).toBe(null); + }); + }); + + describe('getCatalogDefinitions', () => { + it('should return null when no pnpm-workspace.yaml exists', () => { + const result = manager.getCatalogDefinitions(tree); + + expect(result).toBe(null); + }); + + it('should parse catalog definitions from pnpm-workspace.yaml', () => { + tree.write( + 'pnpm-workspace.yaml', + ` +packages: + - 'packages/*' +catalog: + react: ^18.0.0 + lodash: ^4.17.21 +catalogs: + react17: + react: ^17.0.0 + react-dom: ^17.0.0 + react18: + react: ^18.0.0 + react-dom: ^18.0.0 +` + ); + + const result = manager.getCatalogDefinitions(tree); + + expect(result).toStrictEqual({ + packages: ['packages/*'], + catalog: { + react: '^18.0.0', + lodash: '^4.17.21', + }, + catalogs: { + react17: { + react: '^17.0.0', + 'react-dom': '^17.0.0', + }, + react18: { + react: '^18.0.0', + 'react-dom': '^18.0.0', + }, + }, + }); + }); + + it('should handle parsing errors gracefully', () => { + tree.write('pnpm-workspace.yaml', 'invalid: yaml: content: ['); + + const result = manager.getCatalogDefinitions(tree); + + expect(result).toBe(null); + }); + }); + + describe('resolveCatalogReference', () => { + it('should resolve default catalog reference', () => { + tree.write( + 'pnpm-workspace.yaml', + ` +catalog: + react: ^18.0.0 +` + ); + + const result = manager.resolveCatalogReference(tree, 'react', 'catalog:'); + + expect(result).toBe('^18.0.0'); + }); + + it('should resolve named catalog reference', () => { + tree.write( + 'pnpm-workspace.yaml', + ` +catalogs: + react17: + react: ^17.0.0 +` + ); + + const result = manager.resolveCatalogReference( + tree, + 'react', + 'catalog:react17' + ); + + expect(result).toBe('^17.0.0'); + }); + + it('should return null for non-catalog references', () => { + const result = manager.resolveCatalogReference(tree, 'react', '^18.0.0'); + + expect(result).toBe(null); + }); + + it('should return null for missing catalog', () => { + const result = manager.resolveCatalogReference( + tree, + 'react', + 'catalog:nonexistent' + ); + + expect(result).toBe(null); + }); + + it('should return null for missing package in catalog', () => { + const result = manager.resolveCatalogReference( + tree, + 'nonexistent', + 'catalog:' + ); + + expect(result).toBe(null); + }); + }); + + describe('validateCatalogReference', () => { + it('should return invalid for non-catalog syntax', () => { + const result = manager.validateCatalogReference(tree, 'react', '^18.0.0'); + + expect(result.isValid).toBe(false); + expect(result.error?.type).toBe(CatalogErrorType.INVALID_SYNTAX); + }); + + it('should return invalid when workspace file not found', () => { + const result = manager.validateCatalogReference( + tree, + 'react', + 'catalog:' + ); + + expect(result.isValid).toBe(false); + expect(result.error?.type).toBe(CatalogErrorType.WORKSPACE_NOT_FOUND); + }); + + it('should return invalid when default catalog not found', () => { + tree.write( + 'pnpm-workspace.yaml', + ` +packages: + - packages/* +` + ); + + const result = manager.validateCatalogReference( + tree, + 'react', + 'catalog:' + ); + + expect(result.isValid).toBe(false); + expect(result.error?.type).toBe(CatalogErrorType.CATALOG_NOT_FOUND); + }); + + it('should return invalid when named catalog not found', () => { + tree.write( + 'pnpm-workspace.yaml', + ` +packages: + - packages/* +` + ); + + const result = manager.validateCatalogReference( + tree, + 'react', + 'catalog:non-existent' + ); + + expect(result.isValid).toBe(false); + expect(result.error?.type).toBe(CatalogErrorType.CATALOG_NOT_FOUND); + }); + + it('should return invalid for a missing package in catalog', () => { + tree.write( + 'pnpm-workspace.yaml', + ` +packages: + - packages/* +catalog: + react: ^18.0.0 +` + ); + + const result = manager.validateCatalogReference( + tree, + 'lodash', + 'catalog:' + ); + + expect(result.isValid).toBe(false); + expect(result.error?.type).toBe(CatalogErrorType.PACKAGE_NOT_FOUND); + }); + + it('should return valid for existing catalog entry', () => { + tree.write( + 'pnpm-workspace.yaml', + ` +catalog: + react: ^18.0.0 +` + ); + + const result = manager.validateCatalogReference( + tree, + 'react', + 'catalog:' + ); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + }); +}); diff --git a/packages/nx/src/utils/catalog/pnpm-manager.ts b/packages/nx/src/utils/catalog/pnpm-manager.ts new file mode 100644 index 0000000000000..a050a044fabb7 --- /dev/null +++ b/packages/nx/src/utils/catalog/pnpm-manager.ts @@ -0,0 +1,309 @@ +import { dump, load } from '@zkochan/js-yaml'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { Tree } from '../../generators/tree'; +import { readYamlFile } from '../fileutils'; +import { output } from '../output'; +import type { PnpmCatalogEntry, PnpmWorkspaceYaml } from '../pnpm-workspace'; +import type { CatalogManager } from './manager'; +import { + type CatalogError, + CatalogErrorType, + type CatalogReference, +} from './types'; + +/** + * PNPM-specific catalog manager implementation + */ +export class PnpmCatalogManager implements CatalogManager { + readonly name = 'pnpm'; + readonly catalogProtocol = 'catalog:'; + + supportsCatalogs(): boolean { + return true; + } + + isCatalogReference(version: string): boolean { + return version.startsWith(this.catalogProtocol); + } + + parseCatalogReference(version: string): CatalogReference | null { + if (!this.isCatalogReference(version)) { + return null; + } + + const catalogName = version.substring(this.catalogProtocol.length); + + return { + catalogName: catalogName || undefined, + isDefaultCatalog: catalogName === '', + }; + } + + getCatalogDefinitions(treeOrRoot: Tree | string): PnpmWorkspaceYaml | null { + if (typeof treeOrRoot === 'string') { + const pnpmWorkspacePath = join(treeOrRoot, 'pnpm-workspace.yaml'); + if (!existsSync(pnpmWorkspacePath)) { + return null; + } + return readYamlFileFromFs(pnpmWorkspacePath); + } else { + if (!treeOrRoot.exists('pnpm-workspace.yaml')) { + return null; + } + return readYamlFileFromTree(treeOrRoot, 'pnpm-workspace.yaml'); + } + } + + resolveCatalogReference( + treeOrRoot: Tree | string, + packageName: string, + version: string + ): string | null { + const catalogRef = this.parseCatalogReference(version); + if (!catalogRef) { + return null; + } + + const workspaceConfig = this.getCatalogDefinitions(treeOrRoot); + if (!workspaceConfig) { + return null; + } + + let catalogToUse: PnpmCatalogEntry | undefined; + if (catalogRef.isDefaultCatalog) { + catalogToUse = workspaceConfig.catalog; + } else if (catalogRef.catalogName) { + catalogToUse = workspaceConfig.catalogs?.[catalogRef.catalogName]; + } + + return catalogToUse?.[packageName] || null; + } + + validateCatalogReference( + treeOrRoot: Tree | string, + packageName: string, + version: string + ): { isValid: boolean; error?: CatalogError } { + const catalogRef = this.parseCatalogReference(version); + if (!catalogRef) { + return { + isValid: false, + error: { + type: CatalogErrorType.INVALID_SYNTAX, + message: `Invalid catalog reference syntax: "${version}". Expected format: "catalog:" or "catalog:name"`, + }, + }; + } + + const workspaceConfig = this.getCatalogDefinitions(treeOrRoot); + if (!workspaceConfig) { + return { + isValid: false, + error: { + type: CatalogErrorType.WORKSPACE_NOT_FOUND, + message: 'No pnpm-workspace.yaml found in workspace root', + suggestions: [ + 'Create a pnpm-workspace.yaml file in your workspace root', + ], + }, + }; + } + + let catalogToUse: PnpmCatalogEntry | undefined; + + if (catalogRef.isDefaultCatalog) { + catalogToUse = workspaceConfig.catalog; + if (!catalogToUse) { + const availableCatalogs = Object.keys(workspaceConfig.catalogs || {}); + + const suggestions = [ + 'Define a default catalog in pnpm-workspace.yaml under the "catalog" key', + ]; + if (availableCatalogs.length > 0) { + suggestions.push( + `Or select from the available named catalogs: ${availableCatalogs + .map((c) => `"catalog:${c}"`) + .join(', ')}` + ); + } + + return { + isValid: false, + error: { + type: CatalogErrorType.CATALOG_NOT_FOUND, + message: 'No default catalog defined in pnpm-workspace.yaml', + suggestions, + }, + }; + } + } else if (catalogRef.catalogName) { + catalogToUse = workspaceConfig.catalogs?.[catalogRef.catalogName]; + if (!catalogToUse) { + const availableCatalogs = Object.keys(workspaceConfig.catalogs || {}); + const hasDefaultCatalog = !!workspaceConfig.catalog; + + const suggestions = [ + 'Define the catalog in pnpm-workspace.yaml under the "catalogs" key', + ]; + if (availableCatalogs.length > 0) { + suggestions.push( + `Or select from the available named catalogs: ${availableCatalogs + .map((c) => `"catalog:${c}"`) + .join(', ')}` + ); + } + if (hasDefaultCatalog) { + suggestions.push('Or use the default catalog: "catalog:"'); + } + + return { + isValid: false, + error: { + type: CatalogErrorType.CATALOG_NOT_FOUND, + message: `Catalog "${catalogRef.catalogName}" not found in pnpm-workspace.yaml`, + catalogName: catalogRef.catalogName, + suggestions, + }, + }; + } + } + + if (!catalogToUse![packageName]) { + const catalogName = catalogRef.isDefaultCatalog + ? 'default catalog' + : `catalog '${catalogRef.catalogName}'`; + + const availablePackages = Object.keys(catalogToUse!); + const suggestions = [ + `Add "${packageName}" to ${catalogName} in pnpm-workspace.yaml`, + ]; + if (availablePackages.length > 0) { + suggestions.push( + `Or select from the available packages in ${catalogName}: ${availablePackages + .map((p) => `"${p}"`) + .join(', ')}` + ); + } + + return { + isValid: false, + error: { + type: CatalogErrorType.PACKAGE_NOT_FOUND, + message: `Package "${packageName}" not found in ${catalogName}`, + packageName, + catalogName: catalogRef.catalogName, + suggestions, + }, + }; + } + + return { isValid: true }; + } + + updateCatalogVersions( + treeOrRoot: Tree | string, + updates: Array<{ + packageName: string; + version: string; + catalogName?: string; + }> + ): void { + let checkExists: () => boolean; + let readYaml: () => string; + let writeYaml: (content: string) => void; + + if (typeof treeOrRoot === 'string') { + const workspaceYamlPath = join(treeOrRoot, 'pnpm-workspace.yaml'); + checkExists = () => existsSync(workspaceYamlPath); + readYaml = () => readFileSync(workspaceYamlPath, 'utf-8'); + writeYaml = (content) => + writeFileSync(workspaceYamlPath, content, 'utf-8'); + } else { + checkExists = () => treeOrRoot.exists('pnpm-workspace.yaml'); + readYaml = () => treeOrRoot.read('pnpm-workspace.yaml', 'utf-8'); + writeYaml = (content) => treeOrRoot.write('pnpm-workspace.yaml', content); + } + + if (!checkExists()) { + output.warn({ + title: 'No pnpm-workspace.yaml found', + bodyLines: [ + 'Cannot update catalog versions without a pnpm-workspace.yaml file.', + 'Create a pnpm-workspace.yaml file to use catalogs.', + ], + }); + return; + } + + try { + const workspaceContent = readYaml(); + const workspaceData = load(workspaceContent) || {}; + + let hasChanges = false; + for (const update of updates) { + const { packageName, version, catalogName } = update; + + let targetCatalog: PnpmCatalogEntry; + if (catalogName) { + // Named catalog + workspaceData.catalogs ??= {}; + workspaceData.catalogs[catalogName] ??= {}; + targetCatalog = workspaceData.catalogs[catalogName]; + } else { + // Default catalog + workspaceData.catalog ??= {}; + targetCatalog = workspaceData.catalog; + } + + if (targetCatalog[packageName] !== version) { + targetCatalog[packageName] = version; + hasChanges = true; + } + } + + if (hasChanges) { + writeYaml( + dump(workspaceData, { + indent: 2, + quotingType: '"', + forceQuotes: true, + }) + ); + } + } catch (error) { + output.error({ + title: 'Failed to update catalog versions', + bodyLines: [error instanceof Error ? error.message : String(error)], + }); + throw error; + } + } +} + +function readYamlFileFromFs(path: string): PnpmWorkspaceYaml | null { + try { + return readYamlFile(path); + } catch (error) { + output.warn({ + title: 'Unable to parse pnpm-workspace.yaml', + bodyLines: [error.toString()], + }); + return null; + } +} + +function readYamlFileFromTree(tree: Tree, path: string): PnpmWorkspaceYaml { + const content = tree.read(path, 'utf-8'); + const { load } = require('@zkochan/js-yaml'); + + try { + return load(content, { filename: path }) as PnpmWorkspaceYaml; + } catch (error) { + output.warn({ + title: 'Unable to parse pnpm-workspace.yaml', + bodyLines: [error.toString()], + }); + return null; + } +} diff --git a/packages/nx/src/utils/catalog/types.ts b/packages/nx/src/utils/catalog/types.ts new file mode 100644 index 0000000000000..6e51ae1ad89ba --- /dev/null +++ b/packages/nx/src/utils/catalog/types.ts @@ -0,0 +1,19 @@ +export interface CatalogReference { + catalogName?: string; + isDefaultCatalog: boolean; +} + +export enum CatalogErrorType { + INVALID_SYNTAX = 'INVALID_SYNTAX', + WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND', + CATALOG_NOT_FOUND = 'CATALOG_NOT_FOUND', + PACKAGE_NOT_FOUND = 'PACKAGE_NOT_FOUND', +} + +export interface CatalogError { + type: CatalogErrorType; + message: string; + catalogName?: string; + packageName?: string; + suggestions?: string[]; +} diff --git a/packages/nx/src/utils/catalog/unsupported-manager.ts b/packages/nx/src/utils/catalog/unsupported-manager.ts new file mode 100644 index 0000000000000..9815f37d2fb70 --- /dev/null +++ b/packages/nx/src/utils/catalog/unsupported-manager.ts @@ -0,0 +1,71 @@ +import type { Tree } from '../../generators/tree'; +import type { PnpmWorkspaceYaml } from '../pnpm-workspace'; +import { CatalogUnsupportedError } from './errors'; +import type { CatalogManager } from './manager'; +import type { CatalogError, CatalogReference } from './types'; + +/** + * Base catalog manager for package managers that don't support catalogs + */ +abstract class UnsupportedCatalogManager implements CatalogManager { + abstract readonly name: string; + + supportsCatalogs(): boolean { + return false; + } + + isCatalogReference(_version: string): boolean { + return false; + } + + parseCatalogReference(_version: string): CatalogReference | null { + return null; + } + + getCatalogDefinitions(_treeOrRoot: Tree | string): PnpmWorkspaceYaml | null { + throw new CatalogUnsupportedError(this.name, 'get catalog definitions'); + } + + resolveCatalogReference( + _treeOrRoot: Tree | string, + _packageName: string, + _version: string + ): string | null { + throw new CatalogUnsupportedError(this.name, 'resolve catalog references'); + } + + validateCatalogReference( + _treeOrRoot: Tree | string, + _packageName: string, + _version: string + ): { isValid: boolean; error?: CatalogError } { + throw new CatalogUnsupportedError(this.name, 'validate catalog references'); + } + + updateCatalogVersions( + _treeOrRoot: Tree | string, + _updates: Array<{ + packageName: string; + version: string; + catalogName?: string; + }> + ): void { + throw new CatalogUnsupportedError(this.name, 'update catalog versions'); + } +} + +export class YarnCatalogManager extends UnsupportedCatalogManager { + readonly name = 'yarn'; +} + +export class BunCatalogManager extends UnsupportedCatalogManager { + readonly name = 'bun'; +} + +export class NpmCatalogManager extends UnsupportedCatalogManager { + readonly name = 'npm'; +} + +export class UnknownCatalogManager extends UnsupportedCatalogManager { + readonly name = 'unknown'; +} diff --git a/packages/nx/src/utils/package-json.ts b/packages/nx/src/utils/package-json.ts index 8c3f775bfd4c3..770c839ac4a19 100644 --- a/packages/nx/src/utils/package-json.ts +++ b/packages/nx/src/utils/package-json.ts @@ -1,5 +1,5 @@ import { existsSync, writeFileSync } from 'fs'; -import { dirname, join } from 'path'; +import { dirname, join, resolve } from 'path'; import { NxJsonConfiguration } from '../config/nx-json'; import { ProjectConfiguration, @@ -386,6 +386,77 @@ export function installPackageToTmp( }; } +/** + * Get the resolved version of a dependency from package.json. + * + * Retrieves a package version and automatically resolves PNPM catalog references + * (e.g., "catalog:default") to their actual version strings. Searches `dependencies` + * first, then falls back to `devDependencies`. + * + * **Filesystem-based usage** (CLI commands and scripts): + * Use when reading directly from the filesystem without a `Tree` object. + * + * @example + * ```typescript + * // Filesystem-based - from current directory + * const reactVersion = getDependencyVersionFromPackageJson('react'); + * + * // Filesystem-based - with workspace root + * const version = getDependencyVersionFromPackageJson('react', '/path/to/workspace'); + * + * // Filesystem-based - with specific package.json + * const version = getDependencyVersionFromPackageJson( + * 'react', + * '/path/to/workspace', + * 'apps/my-app/package.json' + * ); + * ``` + * + * @returns The resolved version string, or `null` if the package is not found in either dependencies or devDependencies + */ +export function getDependencyVersionFromPackageJson( + packageName: string, + workspaceRootPath?: string, + packageJsonPath?: string +): string | null; +export function getDependencyVersionFromPackageJson( + packageName: string, + workspaceRootPath?: string, + packageJson?: PackageJson +): string | null; +export function getDependencyVersionFromPackageJson( + packageName: string, + root: string = process.cwd(), + packageJsonPathOrObject: string | PackageJson = 'package.json' +): string | null { + let packageJson: PackageJson; + if (typeof packageJsonPathOrObject === 'object') { + packageJson = packageJsonPathOrObject; + } else { + const packageJsonPath = resolve(root, packageJsonPathOrObject); + if (existsSync(packageJsonPath)) { + packageJson = readJsonFile(packageJsonPath); + } else { + return null; + } + } + + const { getCatalogManager } = require('./catalog'); + const manager = getCatalogManager(root); + + let version = + packageJson.dependencies?.[packageName] ?? + packageJson.devDependencies?.[packageName] ?? + null; + + // Resolve catalog reference if needed + if (version && manager.isCatalogReference(version)) { + version = manager.resolveCatalogReference(packageName, version, root); + } + + return version; +} + /** * Generates necessary files needed for the package manager to work * and for the node_modules to be accessible. diff --git a/packages/nx/src/utils/package-manager.ts b/packages/nx/src/utils/package-manager.ts index 01dee13473b7f..c2715d7a2b1ef 100644 --- a/packages/nx/src/utils/package-manager.ts +++ b/packages/nx/src/utils/package-manager.ts @@ -1,22 +1,22 @@ import { exec, execFile, execSync } from 'child_process'; import { copyFileSync, existsSync, readFileSync, writeFileSync } from 'fs'; +import { rm } from 'node:fs/promises'; +import { dirname, join, relative } from 'path'; +import { gte, lt, parse, satisfies } from 'semver'; +import { dirSync } from 'tmp'; +import { promisify } from 'util'; import { Pair, ParsedNode, parseDocument, - stringify as YAMLStringify, + Scalar, YAMLMap, YAMLSeq, - Scalar, + stringify as YAMLStringify, } from 'yaml'; -import { rm } from 'node:fs/promises'; -import { dirname, join, relative } from 'path'; -import { gte, lt, parse, satisfies } from 'semver'; -import { dirSync } from 'tmp'; -import { promisify } from 'util'; - import { readNxJson } from '../config/configuration'; import { readPackageJson } from '../project-graph/file-utils'; +import { getCatalogManager } from './catalog'; import { readFileIfExisting, readJsonFile, @@ -458,10 +458,31 @@ export async function resolvePackageVersionUsingRegistry( version: string ): Promise { try { - const result = await packageRegistryView(packageName, version, 'version'); + let resolvedVersion = version; + const manager = getCatalogManager(workspaceRoot); + if (manager.isCatalogReference(version)) { + resolvedVersion = manager.resolveCatalogReference( + packageName, + version, + workspaceRoot + ); + if (!resolvedVersion) { + throw new Error( + `Unable to resolve catalog reference ${packageName}@${version}.` + ); + } + } + + const result = await packageRegistryView( + packageName, + resolvedVersion, + 'version' + ); if (!result) { - throw new Error(`Unable to resolve version ${packageName}@${version}.`); + throw new Error( + `Unable to resolve version ${packageName}@${resolvedVersion}.` + ); } const lines = result.split('\n'); @@ -476,13 +497,13 @@ export async function resolvePackageVersionUsingRegistry( * * @ '' */ - const resolvedVersion = lines + const finalResolvedVersion = lines .map((line) => line.split(' ')[1]) .sort() .pop() .replace(/'/g, ''); - return resolvedVersion; + return finalResolvedVersion; } catch { throw new Error(`Unable to resolve version ${packageName}@${version}.`); } @@ -500,8 +521,23 @@ export async function resolvePackageVersionUsingInstallation( const { dir, cleanup } = createTempNpmDirectory(); try { + let resolvedVersion = version; + const manager = getCatalogManager(workspaceRoot); + if (manager.isCatalogReference(version)) { + resolvedVersion = manager.resolveCatalogReference( + packageName, + version, + workspaceRoot + ); + if (!resolvedVersion) { + throw new Error( + `Unable to resolve catalog reference ${packageName}@${version}.` + ); + } + } + const pmc = getPackageManagerCommand(); - await execAsync(`${pmc.add} ${packageName}@${version}`, { + await execAsync(`${pmc.add} ${packageName}@${resolvedVersion}`, { cwd: dir, windowsHide: true, }); diff --git a/packages/nx/src/utils/pnpm-workspace.ts b/packages/nx/src/utils/pnpm-workspace.ts new file mode 100644 index 0000000000000..bd73cfec8cd90 --- /dev/null +++ b/packages/nx/src/utils/pnpm-workspace.ts @@ -0,0 +1,9 @@ +export interface PnpmCatalogEntry { + [packageName: string]: string; +} + +export interface PnpmWorkspaceYaml { + packages?: string[]; + catalog?: PnpmCatalogEntry; + catalogs?: Record; +} diff --git a/packages/react/src/utils/version-utils.ts b/packages/react/src/utils/version-utils.ts index b45875a705a7e..7d863043ea5bf 100644 --- a/packages/react/src/utils/version-utils.ts +++ b/packages/react/src/utils/version-utils.ts @@ -1,18 +1,22 @@ -import { type Tree, readJson, createProjectGraphAsync } from '@nx/devkit'; +import { + type Tree, + createProjectGraphAsync, + getDependencyVersionFromPackageJson, +} from '@nx/devkit'; import { clean, coerce, major } from 'semver'; import { reactDomV18Version, + reactDomVersion, reactIsV18Version, + reactIsVersion, reactV18Version, reactVersion, typesReactDomV18Version, + typesReactDomVersion, typesReactIsV18Version, + typesReactIsVersion, typesReactV18Version, - reactDomVersion, - reactIsVersion, typesReactVersion, - typesReactDomVersion, - typesReactIsVersion, } from './versions'; type ReactDependenciesVersions = { @@ -57,9 +61,10 @@ export async function isReact18(tree: Tree) { } export function getInstalledReactVersion(tree: Tree): string { - const pkgJson = readJson(tree, 'package.json'); - const installedReactVersion = - pkgJson.dependencies && pkgJson.dependencies['react']; + const installedReactVersion = getDependencyVersionFromPackageJson( + tree, + 'react' + ); if ( !installedReactVersion || diff --git a/packages/remix/src/generators/application/application.impl.ts b/packages/remix/src/generators/application/application.impl.ts index eabf3fdccf2e1..cd7bca535ecdf 100644 --- a/packages/remix/src/generators/application/application.impl.ts +++ b/packages/remix/src/generators/application/application.impl.ts @@ -23,8 +23,8 @@ import { import { updateJestTestMatch } from '../../utils/testing-config-utils'; import { eslintVersion, - getPackageVersion, isbotVersion, + nxVersion, reactDomVersion, reactVersion, remixVersion, @@ -191,7 +191,7 @@ export async function remixApplicationGeneratorInternal( if (options.unitTestRunner === 'vitest') { const { vitestGenerator, createOrEditViteConfig } = ensurePackage< typeof import('@nx/vite') - >('@nx/vite', getPackageVersion(tree, 'nx')); + >('@nx/vite', nxVersion); const vitestTask = await vitestGenerator(tree, { uiFramework: 'react', project: options.projectName, @@ -219,10 +219,7 @@ export async function remixApplicationGeneratorInternal( tasks.push(vitestTask); } else { const { configurationGenerator: jestConfigurationGenerator } = - ensurePackage( - '@nx/jest', - getPackageVersion(tree, 'nx') - ); + ensurePackage('@nx/jest', nxVersion); const jestTask = await jestConfigurationGenerator(tree, { project: options.projectName, setupFile: 'none', @@ -258,7 +255,7 @@ export async function remixApplicationGeneratorInternal( if (options.linter !== 'none') { const { lintProjectGenerator } = ensurePackage( '@nx/eslint', - getPackageVersion(tree, 'nx') + nxVersion ); const { addIgnoresToLintConfig } = await import( '@nx/eslint/src/generators/utils/eslint-file' diff --git a/packages/remix/src/generators/application/lib/add-e2e.ts b/packages/remix/src/generators/application/lib/add-e2e.ts index 23cfd2cfde1ff..3456d813906eb 100644 --- a/packages/remix/src/generators/application/lib/add-e2e.ts +++ b/packages/remix/src/generators/application/lib/add-e2e.ts @@ -8,7 +8,7 @@ import { writeJson, } from '@nx/devkit'; import { type NormalizedSchema } from './normalize-options'; -import { getPackageVersion } from '../../../utils/versions'; +import { nxVersion } from '../../../utils/versions'; import { getE2EWebServerInfo } from '@nx/devkit/src/generators/e2e-web-server-info-utils'; import type { PackageJson } from 'nx/src/utils/package-json'; @@ -32,7 +32,7 @@ export async function addE2E( if (options.e2eTestRunner === 'cypress') { const { configurationGenerator } = ensurePackage< typeof import('@nx/cypress') - >('@nx/cypress', getPackageVersion(tree, 'nx')); + >('@nx/cypress', nxVersion); const packageJson: PackageJson = { name: options.e2eProjectName, @@ -82,7 +82,7 @@ export async function addE2E( } else if (options.e2eTestRunner === 'playwright') { const { configurationGenerator } = ensurePackage< typeof import('@nx/playwright') - >('@nx/playwright', getPackageVersion(tree, 'nx')); + >('@nx/playwright', nxVersion); const packageJson: PackageJson = { name: options.e2eProjectName, diff --git a/packages/remix/src/generators/application/lib/ignore-vite-temp-files.ts b/packages/remix/src/generators/application/lib/ignore-vite-temp-files.ts index 767a380efa1eb..5d93fd2a13532 100644 --- a/packages/remix/src/generators/application/lib/ignore-vite-temp-files.ts +++ b/packages/remix/src/generators/application/lib/ignore-vite-temp-files.ts @@ -1,5 +1,5 @@ import { ensurePackage, readJson, stripIndents, type Tree } from '@nx/devkit'; -import { getPackageVersion } from '../../../utils/versions'; +import { nxVersion } from '../../../utils/versions'; export async function ignoreViteTempFiles( tree: Tree, @@ -34,7 +34,7 @@ async function ignoreViteTempFilesInEslintConfig( return; } - ensurePackage('@nx/eslint', getPackageVersion(tree, 'nx')); + ensurePackage('@nx/eslint', nxVersion); const { addIgnoresToLintConfig, isEslintConfigSupported } = await import( '@nx/eslint/src/generators/utils/eslint-file' ); diff --git a/packages/remix/src/utils/versions.ts b/packages/remix/src/utils/versions.ts index eff916633fdb3..505d2d01d2019 100644 --- a/packages/remix/src/utils/versions.ts +++ b/packages/remix/src/utils/versions.ts @@ -1,4 +1,4 @@ -import { readJson, Tree } from '@nx/devkit'; +import { getDependencyVersionFromPackageJson, Tree } from '@nx/devkit'; export const nxVersion = require('../../package.json').version; @@ -21,14 +21,7 @@ export const testingLibraryUserEventsVersion = '^14.5.2'; export const viteVersion = '^5.0.0'; export function getRemixVersion(tree: Tree): string { - return getPackageVersion(tree, '@remix-run/dev') ?? remixVersion; -} - -export function getPackageVersion(tree: Tree, packageName: string) { - const packageJsonContents = readJson(tree, 'package.json'); return ( - packageJsonContents?.['devDependencies']?.[packageName] ?? - packageJsonContents?.['dependencies']?.[packageName] ?? - null + getDependencyVersionFromPackageJson(tree, '@remix-run/dev') ?? remixVersion ); } diff --git a/packages/vite/src/generators/vitest/vitest-generator.ts b/packages/vite/src/generators/vitest/vitest-generator.ts index bcea44a7861a4..8defa4d9cdeeb 100644 --- a/packages/vite/src/generators/vitest/vitest-generator.ts +++ b/packages/vite/src/generators/vitest/vitest-generator.ts @@ -3,11 +3,11 @@ import { formatFiles, generateFiles, GeneratorCallback, + getDependencyVersionFromPackageJson, joinPathFragments, logger, offsetFromRoot, ProjectType, - readJson, readNxJson, readProjectConfiguration, runTasksInSerial, @@ -70,11 +70,10 @@ export async function vitestGeneratorInternal( tasks.push(await jsInitGenerator(tree, { ...schema, skipFormat: true })); - const pkgJson = readJson(tree, 'package.json'); - const useViteV5 = - major(coerce(pkgJson.devDependencies['vite']) ?? '7.0.0') === 5; - const useViteV6 = - major(coerce(pkgJson.devDependencies['vite']) ?? '7.0.0') === 6; + const viteVersion = + getDependencyVersionFromPackageJson(tree, 'vite') ?? '7.0.0'; + const useViteV5 = major(coerce(viteVersion)) === 5; + const useViteV6 = major(coerce(viteVersion)) === 6; const initTask = await initGenerator(tree, { projectRoot: root, skipFormat: true, @@ -405,9 +404,10 @@ function tryFindSetupFile(tree: Tree, projectRoot: string) { } function isAngularV20(tree: Tree) { - const { dependencies, devDependencies } = readJson(tree, 'package.json'); - const angularVersion = - dependencies?.['@angular/core'] ?? devDependencies?.['@angular/core']; + const angularVersion = getDependencyVersionFromPackageJson( + tree, + '@angular/core' + ); if (!angularVersion) { // assume the latest version will be installed, which will be 20 or later diff --git a/packages/vite/src/utils/version-utils.ts b/packages/vite/src/utils/version-utils.ts index 5817590346112..1834c939d2d6c 100644 --- a/packages/vite/src/utils/version-utils.ts +++ b/packages/vite/src/utils/version-utils.ts @@ -1,14 +1,17 @@ +import { + createProjectGraphAsync, + getDependencyVersionFromPackageJson, +} from '@nx/devkit'; import type { Tree } from 'nx/src/generators/tree'; +import { clean, coerce, major } from 'semver'; import { - vitestVersion, - vitestV1Version, - vitestCoverageV8Version, - vitestV1CoverageV8Version, vitestCoverageIstanbulVersion, + vitestCoverageV8Version, vitestV1CoverageIstanbulVersion, + vitestV1CoverageV8Version, + vitestV1Version, + vitestVersion, } from './versions'; -import { clean, coerce, major } from 'semver'; -import { readJson, createProjectGraphAsync } from '@nx/devkit'; type VitestDependenciesVersions = { vitest: string; @@ -43,9 +46,10 @@ export async function isVitestV1(tree: Tree) { } export function getInstalledVitestVersion(tree: Tree): string { - const pkgJson = readJson(tree, 'package.json'); - const installedVitestVersion = - pkgJson.dependencies && pkgJson.dependencies['vitest']; + const installedVitestVersion = getDependencyVersionFromPackageJson( + tree, + 'vitest' + ); if ( !installedVitestVersion || diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c62082e987a73..f1d1cab4a406e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2715,6 +2715,9 @@ importers: packages/devkit: dependencies: + '@zkochan/js-yaml': + specifier: 0.0.7 + version: 0.0.7 ejs: specifier: ^3.1.7 version: 3.1.10 From 3b605ee04469eedeb4697c4fc361cc2c36116b66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leosvel=20P=C3=A9rez=20Espinosa?= Date: Tue, 7 Oct 2025 18:02:53 +0200 Subject: [PATCH 2/8] fix(devkit): fix issue in semver util and add tests --- e2e/nx/src/misc.test.ts | 179 +++++++++++++++++- .../generators/utils/version-utils.spec.ts | 60 +++++- .../devkit/src/utils/package-json.spec.ts | 12 +- packages/devkit/src/utils/semver.spec.ts | 66 +++++++ packages/devkit/src/utils/semver.ts | 23 ++- .../react/src/utils/version-utils.spec.ts | 83 +++++++- 6 files changed, 396 insertions(+), 27 deletions(-) create mode 100644 packages/devkit/src/utils/semver.spec.ts diff --git a/e2e/nx/src/misc.test.ts b/e2e/nx/src/misc.test.ts index 6727a942b8f91..23f57770b2353 100644 --- a/e2e/nx/src/misc.test.ts +++ b/e2e/nx/src/misc.test.ts @@ -5,6 +5,7 @@ import { e2eCwd, getPackageManagerCommand, getPublishedVersion, + getSelectedPackageManager, isNotWindows, killProcessAndPorts, newProject, @@ -447,7 +448,7 @@ describe('Nx Commands', () => { // TODO(colum): Change the fetcher to allow incremental migrations over multiple versions, allowing for beforeAll describe('migrate', () => { beforeEach(() => { - newProject({ packages: [] }); + newProject(); updateFile( `./node_modules/migrate-parent-package/package.json`, @@ -537,6 +538,9 @@ describe('migrate', () => { 'migrate-child-package-3': {version: '9.0.0', addToPackageJson: false}, 'migrate-child-package-4': {version: '9.0.0', addToPackageJson: 'dependencies'}, 'migrate-child-package-5': {version: '9.0.0', addToPackageJson: 'devDependencies'}, + 'react': {version: '18.2.0', addToPackageJson: false}, + 'react-dom': {version: '18.2.0', addToPackageJson: false}, + 'lodash': {version: '4.17.21', addToPackageJson: false}, }}, } }); @@ -549,6 +553,12 @@ describe('migrate', () => { } } }); + } else if (packageName === 'react') { + return Promise.resolve({version: '18.2.0'}); + } else if (packageName === 'react-dom') { + return Promise.resolve({version: '18.2.0'}); + } else if (packageName === 'lodash') { + return Promise.resolve({version: '4.17.21'}); } else { return Promise.resolve({version: '9.0.0'}); } @@ -756,7 +766,7 @@ describe('migrate', () => { expect(output).toContain(`Migrations file 'migrations.json' doesn't exist`); }); - it('should handle Nx tokens correctly in Angular CLI migration schematics', () => { + it('hhhhhhshould handle Nx tokens correctly in Angular CLI migration schematics', () => { const app1 = uniq('app1'); updateFile( @@ -903,6 +913,171 @@ describe('migrate', () => { ], }); }); + + if (getSelectedPackageManager() === 'pnpm') { + it('hhhhhhshould handle pnpm catalog references and update catalog definitions during migration', () => { + // Setup pnpm-workspace.yaml with both default and named catalogs. Include + // packages that WILL be updated and packages that SHOULD remain unchanged + // to test both scenarios. + updateFile( + 'pnpm-workspace.yaml', + ` +packages: + - packages/* + +catalog: + migrate-parent-package: ^1.0.0 + migrate-child-package: ^1.0.0 + typescript: ^5.3.0 + +catalogs: + react17: + react: ^17.0.2 + react-dom: ^17.0.2 + + tools: + eslint: ^8.0.0 + prettier: ^3.0.0 +` + ); + // Update package.json to use MIXED catalog references and explicit versions + updateJson('package.json', (json) => { + json.dependencies = { + 'migrate-parent-package': 'catalog:', + react: 'catalog:react17', + 'react-dom': 'catalog:react17', + typescript: 'catalog:', + eslint: 'catalog:tools', + lodash: '^4.17.0', // explicit version that WILL be updated + axios: '^1.6.0', // explicit version that SHOULD stay unchanged + }; + json.devDependencies = { + 'migrate-child-package': 'catalog:', + prettier: 'catalog:tools', + }; + return json; + }); + // Create mock node_modules with RESOLVED versions for packages that will be updated + updateFile( + `./node_modules/react/package.json`, + JSON.stringify({ + name: 'react', + version: '17.0.2', + }) + ); + updateFile( + `./node_modules/react-dom/package.json`, + JSON.stringify({ + name: 'react-dom', + version: '17.0.2', + }) + ); + // Create mock node_modules for packages that should stay unchanged + updateFile( + `./node_modules/typescript/package.json`, + JSON.stringify({ + name: 'typescript', + version: '5.3.0', + }) + ); + updateFile( + `./node_modules/eslint/package.json`, + JSON.stringify({ + name: 'eslint', + version: '8.0.0', + }) + ); + updateFile( + `./node_modules/prettier/package.json`, + JSON.stringify({ + name: 'prettier', + version: '3.0.0', + }) + ); + // Create mock node_modules for explicit version packages + updateFile( + `./node_modules/lodash/package.json`, + JSON.stringify({ + name: 'lodash', + version: '4.17.0', + }) + ); + updateFile( + `./node_modules/axios/package.json`, + JSON.stringify({ + name: 'axios', + version: '1.6.0', + }) + ); + + // Run the migration + runCLI( + 'migrate migrate-parent-package@2.0.0 --from="migrate-parent-package@1.0.0"', + { + env: { + NX_MIGRATE_SKIP_INSTALL: 'true', + NX_MIGRATE_USE_LOCAL: 'true', + }, + } + ); + + // Verify ALL catalog references are PRESERVED in package.json + const packageJson = readJson('package.json'); + expect(packageJson.dependencies['migrate-parent-package']).toEqual( + 'catalog:' + ); + expect(packageJson.devDependencies['migrate-child-package']).toEqual( + 'catalog:' + ); + expect(packageJson.dependencies['typescript']).toEqual('catalog:'); + expect(packageJson.dependencies['react']).toEqual('catalog:react17'); + expect(packageJson.dependencies['react-dom']).toEqual('catalog:react17'); + expect(packageJson.dependencies['eslint']).toEqual('catalog:tools'); + expect(packageJson.devDependencies['prettier']).toEqual('catalog:tools'); + + // Verify catalog definitions in pnpm-workspace.yaml + const workspaceYaml = readFile('pnpm-workspace.yaml'); + // UPDATED packages (no ^ prefix as migrations provide resolved versions) + expect(workspaceYaml).toContain('migrate-parent-package: "2.0.0"'); + expect(workspaceYaml).toContain('migrate-child-package: "9.0.0"'); + expect(workspaceYaml).toContain('react: "18.2.0"'); + expect(workspaceYaml).toContain('react-dom: "18.2.0"'); + // PRESERVED packages (retain original format with ^ prefix) + expect(workspaceYaml).toContain('typescript: "^5.3.0"'); + expect(workspaceYaml).toContain('eslint: "^8.0.0"'); + expect(workspaceYaml).toContain('prettier: "^3.0.0"'); + + // Verify explicit version packages: updated and preserved + expect(packageJson.dependencies['lodash']).toEqual('4.17.21'); + expect(packageJson.dependencies['axios']).toEqual('^1.6.0'); + + // Verify migrations.json was created correctly + const migrationsJson = readJson('migrations.json'); + expect(migrationsJson.migrations).toEqual([ + { + package: 'migrate-parent-package', + version: '1.1.0', + name: 'run11', + }, + { + package: 'migrate-parent-package', + version: '2.0.0', + name: 'run20', + cli: 'nx', + }, + ]); + + // Run migrations to ensure they execute successfully + runCLI('migrate --run-migrations=migrations.json', { + env: { + NX_MIGRATE_SKIP_INSTALL: 'true', + NX_MIGRATE_USE_LOCAL: 'true', + }, + }); + + expect(readFile('file-20')).toEqual('content20'); + }); + } }); describe('global installation', () => { diff --git a/packages/angular/src/generators/utils/version-utils.spec.ts b/packages/angular/src/generators/utils/version-utils.spec.ts index cc8df006c6122..da8142572abe5 100644 --- a/packages/angular/src/generators/utils/version-utils.spec.ts +++ b/packages/angular/src/generators/utils/version-utils.spec.ts @@ -1,5 +1,6 @@ +import { updateJson, type Tree } from '@nx/devkit'; +import { TempFs } from '@nx/devkit/internal-testing-utils'; import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; -import { updateJson } from '@nx/devkit'; import { getInstalledAngularMajorVersion, getInstalledAngularVersion, @@ -47,4 +48,61 @@ describe('angularVersionUtils', () => { // ASSERT expect(angularVersion).toEqual(expectedVersion); }); + + describe('with catalog references', () => { + let tempFs: TempFs; + let tree: Tree; + + beforeEach(() => { + tempFs = new TempFs('angular-version-test'); + tree = createTreeWithEmptyWorkspace(); + tree.root = tempFs.tempDir; + // force `detectPackageManager` to return `pnpm` + tempFs.createFileSync('pnpm-lock.yaml', 'lockfileVersion: 9.0'); + + tree.write( + 'pnpm-workspace.yaml', + ` +packages: + - packages/* + +catalog: + "@angular/core": ~18.0.0 + react: ^18.2.0 + +catalogs: + angular17: + "@angular/core": ~17.3.0 +` + ); + }); + + afterEach(() => { + tempFs.cleanup(); + }); + + it('should get installed Angular version from default catalog reference', () => { + updateJson(tree, 'package.json', (json) => ({ + ...json, + dependencies: { + '@angular/core': 'catalog:', + }, + })); + + expect(getInstalledAngularMajorVersion(tree)).toBe(18); + expect(getInstalledAngularVersion(tree)).toBe('18.0.0'); + }); + + it('should get installed Angular version from named catalog reference', () => { + updateJson(tree, 'package.json', (json) => ({ + ...json, + dependencies: { + '@angular/core': 'catalog:angular17', + }, + })); + + expect(getInstalledAngularVersion(tree)).toBe('17.3.0'); + expect(getInstalledAngularMajorVersion(tree)).toBe(17); + }); + }); }); diff --git a/packages/devkit/src/utils/package-json.spec.ts b/packages/devkit/src/utils/package-json.spec.ts index 913f9582ead9f..15fa85fc74577 100644 --- a/packages/devkit/src/utils/package-json.spec.ts +++ b/packages/devkit/src/utils/package-json.spec.ts @@ -495,16 +495,8 @@ describe('addDependenciesToPackageJson', () => { }); describe('catalog support', () => { - const mockDetectPackageManager = jest.fn(); - beforeEach(() => { - mockDetectPackageManager.mockReturnValue('pnpm'); - - const packageManager = require('nx/src/devkit-exports'); - jest - .spyOn(packageManager, 'detectPackageManager') - .mockImplementation(mockDetectPackageManager); - + jest.spyOn(devkitExports, 'detectPackageManager').mockReturnValue('pnpm'); tree.root = '/test-workspace'; }); @@ -546,7 +538,7 @@ catalog: }); it('should use direct dependencies with unsupported package managers', () => { - mockDetectPackageManager.mockReturnValue('npm'); + jest.spyOn(devkitExports, 'detectPackageManager').mockReturnValue('npm'); writeJson(tree, 'package.json', { dependencies: { react: 'catalog:' }, }); diff --git a/packages/devkit/src/utils/semver.spec.ts b/packages/devkit/src/utils/semver.spec.ts new file mode 100644 index 0000000000000..7f7089a040393 --- /dev/null +++ b/packages/devkit/src/utils/semver.spec.ts @@ -0,0 +1,66 @@ +import type { Tree } from 'nx/src/generators/tree'; +import { TempFs } from '../../internal-testing-utils'; +import { createTreeWithEmptyWorkspace } from '../../testing'; +import { checkAndCleanWithSemver } from './semver'; + +describe('checkAndCleanWithSemver', () => { + let tree: Tree; + let tempFs: TempFs; + + beforeEach(() => { + tempFs = new TempFs('semver-test'); + tree = createTreeWithEmptyWorkspace(); + tree.root = tempFs.tempDir; + tempFs.createFileSync('pnpm-lock.yaml', 'lockfileVersion: 9.0'); + }); + + afterEach(() => { + tempFs.cleanup(); + }); + + it('should validate and clean semver versions', () => { + // Test with caret prefix + expect(checkAndCleanWithSemver('package', '^1.2.3')).toBe('1.2.3'); + // Test with tilde prefix + expect(checkAndCleanWithSemver('package', '~1.2.3')).toBe('1.2.3'); + // Test with valid semver + expect(checkAndCleanWithSemver('package', '1.2.3')).toBe('1.2.3'); + // Test invalid version throws error + expect(() => checkAndCleanWithSemver('package', 'invalid')).toThrow( + 'The package.json lists a version of package that Nx is unable to validate' + ); + }); + + it('should resolve catalog references before validating semver', () => { + const yamlContent = ` +packages: + - packages/* + +catalog: + react: ^18.2.0 + lodash: ~4.17.21 + +catalogs: + testing: + jest: ^29.0.0 +`; + tree.write('pnpm-workspace.yaml', yamlContent); + + // Test default catalog reference + expect(checkAndCleanWithSemver(tree, 'react', 'catalog:')).toBe('18.2.0'); + // Test default catalog with tilde prefix + expect(checkAndCleanWithSemver(tree, 'lodash', 'catalog:')).toBe('4.17.21'); + // Test named catalog reference + expect(checkAndCleanWithSemver(tree, 'jest', 'catalog:testing')).toBe( + '29.0.0' + ); + // Test invalid catalog reference throws error + expect(() => + checkAndCleanWithSemver(tree, 'nonexistent', 'catalog:') + ).toThrow('The catalog reference for nonexistent is invalid'); + // Test invalid named catalog throws error + expect(() => + checkAndCleanWithSemver(tree, 'package', 'catalog:nonexistent') + ).toThrow('The catalog reference for package is invalid'); + }); +}); diff --git a/packages/devkit/src/utils/semver.ts b/packages/devkit/src/utils/semver.ts index 697b73dac74b9..372f3d6fb304d 100644 --- a/packages/devkit/src/utils/semver.ts +++ b/packages/devkit/src/utils/semver.ts @@ -20,29 +20,28 @@ export function checkAndCleanWithSemver( const root = tree?.root ?? workspaceRoot; const pkgName = typeof treeOrPkgName === 'string' ? treeOrPkgName : pkgNameOrVersion; - const actualVersion = + let newVersion = typeof treeOrPkgName === 'string' ? pkgNameOrVersion : version!; - let newVersion = actualVersion; const manager = getCatalogManager(root); - if (manager.isCatalogReference(actualVersion)) { + if (manager.isCatalogReference(newVersion)) { const validation = tree - ? manager.validateCatalogReference(tree, pkgName, actualVersion) - : manager.validateCatalogReference(root, pkgName, actualVersion); + ? manager.validateCatalogReference(tree, pkgName, newVersion) + : manager.validateCatalogReference(root, pkgName, newVersion); if (!validation.isValid) { throw new Error( - `The catalog reference for ${pkgName} is invalid - (${actualVersion})\n${formatCatalogError( + `The catalog reference for ${pkgName} is invalid - (${newVersion})\n${formatCatalogError( validation.error! )}` ); } const resolvedVersion = tree - ? manager.resolveCatalogReference(tree, pkgName, actualVersion) - : manager.resolveCatalogReference(root, pkgName, actualVersion); + ? manager.resolveCatalogReference(tree, pkgName, newVersion) + : manager.resolveCatalogReference(root, pkgName, newVersion); if (!resolvedVersion) { throw new Error( - `Could not resolve catalog reference for package ${pkgName}@${actualVersion}.` + `Could not resolve catalog reference for package ${pkgName}@${newVersion}.` ); } @@ -53,13 +52,13 @@ export function checkAndCleanWithSemver( return newVersion; } - if (actualVersion.startsWith('~') || actualVersion.startsWith('^')) { - newVersion = actualVersion.substring(1); + if (newVersion.startsWith('~') || newVersion.startsWith('^')) { + newVersion = newVersion.substring(1); } if (!valid(newVersion)) { throw new Error( - `The package.json lists a version of ${pkgName} that Nx is unable to validate - (${actualVersion})` + `The package.json lists a version of ${pkgName} that Nx is unable to validate - (${newVersion})` ); } diff --git a/packages/react/src/utils/version-utils.spec.ts b/packages/react/src/utils/version-utils.spec.ts index f1ba61210708b..b2385fc2a5080 100644 --- a/packages/react/src/utils/version-utils.spec.ts +++ b/packages/react/src/utils/version-utils.spec.ts @@ -1,6 +1,10 @@ +import type { ProjectGraph, Tree } from '@nx/devkit'; +import { TempFs } from '@nx/devkit/internal-testing-utils'; import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; -import { getReactDependenciesVersionsToInstall } from './version-utils'; -import { type ProjectGraph } from '@nx/devkit'; +import { + getInstalledReactVersion, + getReactDependenciesVersionsToInstall, +} from './version-utils'; import { reactDomV18Version, reactDomVersion, @@ -113,3 +117,78 @@ describe('getReactDependenciesVersionsToInstall', () => { }); }); }); + +describe('getInstalledReactVersion', () => { + let tempFs: TempFs; + let tree: Tree; + + beforeEach(() => { + tempFs = new TempFs('react-version-test'); + tree = createTreeWithEmptyWorkspace(); + tree.root = tempFs.tempDir; + // force `detectPackageManager` to return `pnpm` + tempFs.createFileSync('pnpm-lock.yaml', 'lockfileVersion: 9.0'); + + tree.write( + 'pnpm-workspace.yaml', + ` +packages: + - packages/* + +catalog: + react: ^18.2.0 + lodash: ^4.17.0 + +catalogs: + react17: + react: ^17.0.2 +` + ); + }); + + afterEach(() => { + tempFs.cleanup(); + }); + + it('should get installed React version from default catalog reference', () => { + tree.write( + 'package.json', + JSON.stringify({ + name: 'test', + dependencies: { + react: 'catalog:', + }, + }) + ); + + expect(getInstalledReactVersion(tree)).toBe('18.2.0'); + }); + + it('should get installed React version from named catalog reference', () => { + tree.write( + 'package.json', + JSON.stringify({ + name: 'test', + dependencies: { + react: 'catalog:react17', + }, + }) + ); + + expect(getInstalledReactVersion(tree)).toBe('17.0.2'); + }); + + it('should get installed React version from regular semver version', () => { + tree.write( + 'package.json', + JSON.stringify({ + name: 'test', + dependencies: { + react: '^18.2.0', + }, + }) + ); + + expect(getInstalledReactVersion(tree)).toBe('18.2.0'); + }); +}); From 955dd980221b1eb12dc9f0feaf3edfb71e696e92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leosvel=20P=C3=A9rez=20Espinosa?= Date: Tue, 7 Oct 2025 18:05:21 +0200 Subject: [PATCH 3/8] fix(js): handle catalog references in version action --- packages/js/src/release/version-actions.ts | 53 +++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/packages/js/src/release/version-actions.ts b/packages/js/src/release/version-actions.ts index 402434736b8a5..4a9a7adfe465b 100644 --- a/packages/js/src/release/version-actions.ts +++ b/packages/js/src/release/version-actions.ts @@ -7,6 +7,7 @@ import { updateJson, workspaceRoot, } from '@nx/devkit'; +import { getCatalogManager } from '@nx/devkit/src/utils/catalog'; import { exec } from 'node:child_process'; import { join } from 'node:path'; import { AfterAllProjectsVersioned, VersionActions } from 'nx/release'; @@ -167,6 +168,22 @@ export default class JsVersionActions extends VersionActions { break; } } + + // Resolve catalog references if needed + if (currentVersion) { + const catalogManager = getCatalogManager(tree.root); + if ( + catalogManager.supportsCatalogs() && + catalogManager.isCatalogReference(currentVersion) + ) { + currentVersion = catalogManager.resolveCatalogReference( + tree, + dependencyPackageName, + currentVersion + ); + } + } + return { currentVersion, dependencyCollection, @@ -201,6 +218,13 @@ export default class JsVersionActions extends VersionActions { } const logMessages: string[] = []; + const catalogUpdates: Array<{ + packageName: string; + version: string; + catalogName?: string; + }> = []; + const catalogManager = getCatalogManager(tree.root); + for (const manifestToUpdate of this.manifestsToUpdate) { updateJson(tree, manifestToUpdate.manifestPath, (json) => { const dependencyTypes = [ @@ -232,8 +256,24 @@ export default class JsVersionActions extends VersionActions { } const currentVersion = json[depType][packageName]; if (currentVersion) { - // Check if the local dependency protocol should be preserved or not if ( + catalogManager.supportsCatalogs() && + catalogManager.isCatalogReference(currentVersion) + ) { + // collect the catalog updates so we can update the catalog definitions later + const catalogRef = + catalogManager.parseCatalogReference(currentVersion)!; + catalogUpdates.push({ + packageName, + version, + catalogName: catalogRef.catalogName, + }); + + numDependenciesToUpdate--; + continue; + } + // Check if other local dependency protocols should be preserved + else if ( manifestToUpdate.preserveLocalDependencyProtocols && this.isLocalDependencyProtocol(currentVersion) ) { @@ -278,6 +318,17 @@ export default class JsVersionActions extends VersionActions { `✍️ Updated ${numDependenciesToUpdate} ${depText} in manifest: ${manifestToUpdate.manifestPath}` ); } + + // Update catalog definitions in pnpm-workspace.yaml + if (catalogUpdates.length > 0) { + catalogManager.updateCatalogVersions(tree, catalogUpdates); + + const catalogText = catalogUpdates.length === 1 ? 'entry' : 'entries'; + logMessages.push( + `✍️ Updated ${catalogUpdates.length} catalog ${catalogText} in pnpm-workspace.yaml` + ); + } + return logMessages; } From 38d80885a52b233bb083f4e11cf2b4ef10dca111 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leosvel=20P=C3=A9rez=20Espinosa?= Date: Wed, 8 Oct 2025 08:58:16 +0200 Subject: [PATCH 4/8] chore(angular): update angular cli migration template --- .../files/angular-cli-upgrade-migration.ts | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/scripts/angular-support-upgrades/files/angular-cli-upgrade-migration.ts b/scripts/angular-support-upgrades/files/angular-cli-upgrade-migration.ts index a1f4f46a6528a..05d99c554bffb 100644 --- a/scripts/angular-support-upgrades/files/angular-cli-upgrade-migration.ts +++ b/scripts/angular-support-upgrades/files/angular-cli-upgrade-migration.ts @@ -1,26 +1,26 @@ export const getAngularCliMigrationGenerator = ( version: string, isPrerelease: boolean -) => `import { formatFiles, Tree, updateJson } from '@nx/devkit'; +) => `import { + addDependenciesToPackageJson, + formatFiles, + readJson, + type Tree, +} from '@nx/devkit'; export const angularCliVersion = '${isPrerelease ? version : `~${version}`}'; export default async function (tree: Tree) { - let shouldFormat = false; - - updateJson(tree, 'package.json', (json) => { - if (json.devDependencies?.['@angular/cli']) { - json.devDependencies['@angular/cli'] = angularCliVersion; - shouldFormat = true; - } else if (json.dependencies?.['@angular/cli']) { - json.dependencies['@angular/cli'] = angularCliVersion; - shouldFormat = true; - } - - return json; - }); - - if (shouldFormat) { + const { devDependencies, dependencies } = readJson(tree, 'package.json'); + const hasAngularCli = + devDependencies?.['@angular/cli'] || dependencies?.['@angular/cli']; + + if (hasAngularCli) { + addDependenciesToPackageJson( + tree, + {}, + { '@angular/cli': angularCliVersion } + ); await formatFiles(tree); } } From d187f2acf248b312d45acf48214c6fdf00d56ead Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leosvel=20P=C3=A9rez=20Espinosa?= Date: Tue, 14 Oct 2025 11:04:06 +0200 Subject: [PATCH 5/8] fix(linter): handle pnpm catalogs in dependency-checks eslint rule --- .../src/rules/dependency-checks.spec.ts | 586 ++++++++++++++++-- .../src/rules/dependency-checks.ts | 43 +- 2 files changed, 562 insertions(+), 67 deletions(-) diff --git a/packages/eslint-plugin/src/rules/dependency-checks.spec.ts b/packages/eslint-plugin/src/rules/dependency-checks.spec.ts index 4c1a10c22b673..62c3dbdcab812 100644 --- a/packages/eslint-plugin/src/rules/dependency-checks.spec.ts +++ b/packages/eslint-plugin/src/rules/dependency-checks.spec.ts @@ -1,21 +1,21 @@ import 'nx/src/internal-testing-utils/mock-fs'; -import dependencyChecks, { - Options, - RULE_NAME as dependencyChecksRuleName, -} from './dependency-checks'; -import * as jsoncParser from 'jsonc-eslint-parser'; -import { createProjectRootMappings } from 'nx/src/project-graph/utils/find-project-for-path'; - -import { vol } from 'memfs'; -import { +import type { FileData, ProjectFileMap, ProjectGraph, ProjectGraphExternalNode, } from '@nx/devkit'; import { Linter } from 'eslint'; -import { FileDataDependency } from 'nx/src/config/project-graph'; +import * as jsoncParser from 'jsonc-eslint-parser'; +import { vol } from 'memfs'; +import type { FileDataDependency } from 'nx/src/config/project-graph'; +import { createProjectRootMappings } from 'nx/src/project-graph/utils/find-project-for-path'; +import * as packageManager from 'nx/src/utils/package-manager'; +import dependencyChecks, { + Options, + RULE_NAME as dependencyChecksRuleName, +} from './dependency-checks'; jest.mock('@nx/devkit', () => ({ ...jest.requireActual('@nx/devkit'), @@ -1708,61 +1708,537 @@ describe('Dependency checks (eslint)', () => { expect(failures.length).toEqual(0); }); - it('should not report catalog: protocol', () => { - const packageJson = { - name: '@mycompany/liba', - dependencies: { - external1: 'catalog:', - external2: 'catalog:nx', - }, - }; + describe('pnpm catalogs', () => { + beforeEach(() => { + jest + .spyOn(packageManager, 'detectPackageManager') + .mockReturnValue('pnpm'); + }); - const fileSys = { - './libs/liba/package.json': JSON.stringify(packageJson, null, 2), - './libs/liba/src/index.ts': '', - './package.json': JSON.stringify(rootPackageJson, null, 2), - }; - vol.fromJSON(fileSys, '/root'); + afterEach(() => { + jest.clearAllMocks(); + }); - const failures = runRule( - {}, - `/root/libs/liba/package.json`, - JSON.stringify(packageJson, null, 2), - { - nodes: { - liba: { - name: 'liba', - type: 'lib', - data: { - root: 'libs/liba', - targets: { - build: {}, + it('should report error for catalog references without pnpm-workspace.yaml', () => { + const packageJson = { + name: '@mycompany/liba', + dependencies: { + external1: 'catalog:', + external2: 'catalog:nx', + }, + }; + + const fileSys = { + './libs/liba/package.json': JSON.stringify(packageJson, null, 2), + './libs/liba/src/index.ts': '', + './package.json': JSON.stringify(rootPackageJson, null, 2), + // Note: No pnpm-workspace.yaml file + }; + vol.fromJSON(fileSys, '/root'); + + const failures = runRule( + {}, + `/root/libs/liba/package.json`, + JSON.stringify(packageJson, null, 2), + { + nodes: { + liba: { + name: 'liba', + type: 'lib', + data: { + root: 'libs/liba', + targets: { + build: {}, + }, }, }, }, + externalNodes, + dependencies: { + liba: [ + { source: 'liba', target: 'npm:external1', type: 'static' }, + { source: 'liba', target: 'npm:external2', type: 'static' }, + ], + }, }, - externalNodes, + { + liba: [ + createFile(`libs/liba/src/main.ts`, [ + 'npm:external1', + 'npm:external2', + ]), + createFile(`libs/liba/package.json`, [ + 'npm:external1', + 'npm:external2', + ]), + ], + } + ); + + // With pnpm detected but no workspace file, should report invalid catalog references + expect(failures.length).toEqual(2); + expect(failures[0].message).toContain('Invalid catalog reference'); + expect(failures[0].message).toContain('external1'); + expect(failures[1].message).toContain('Invalid catalog reference'); + expect(failures[1].message).toContain('external2'); + }); + + it('should not report error for valid default catalog reference', () => { + const packageJson = { + name: '@mycompany/liba', dependencies: { + external1: 'catalog:', + }, + }; + + const fileSys = { + './libs/liba/package.json': JSON.stringify(packageJson, null, 2), + './libs/liba/src/index.ts': '', + './package.json': JSON.stringify(rootPackageJson, null, 2), + './pnpm-workspace.yaml': `catalog:\n external1: '^16.0.0'\n`, + }; + vol.fromJSON(fileSys, '/root'); + + const failures = runRule( + {}, + `/root/libs/liba/package.json`, + JSON.stringify(packageJson, null, 2), + { + nodes: { + liba: { + name: 'liba', + type: 'lib', + data: { + root: 'libs/liba', + targets: { + build: {}, + }, + }, + }, + }, + externalNodes, + dependencies: { + liba: [{ source: 'liba', target: 'npm:external1', type: 'static' }], + }, + }, + { liba: [ - { source: 'liba', target: 'npm:external1', type: 'static' }, - { source: 'liba', target: 'npm:external2', type: 'static' }, + createFile(`libs/liba/src/main.ts`, ['npm:external1']), + createFile(`libs/liba/package.json`, ['npm:external1']), ], + } + ); + + expect(failures.length).toEqual(0); + }); + + it('should not report error for valid named catalog reference', () => { + const packageJson = { + name: '@mycompany/liba', + dependencies: { + external1: 'catalog:nx', }, - }, - { - liba: [ - createFile(`libs/liba/src/main.ts`, [ - 'npm:external1', - 'npm:external2', - ]), - createFile(`libs/liba/package.json`, [ - 'npm:external1', - 'npm:external2', - ]), - ], - } - ); - expect(failures.length).toEqual(0); + }; + + const fileSys = { + './libs/liba/package.json': JSON.stringify(packageJson, null, 2), + './libs/liba/src/index.ts': '', + './package.json': JSON.stringify(rootPackageJson, null, 2), + './pnpm-workspace.yaml': `catalogs:\n nx:\n external1: '~16.1.2'\n`, + }; + vol.fromJSON(fileSys, '/root'); + + const failures = runRule( + {}, + `/root/libs/liba/package.json`, + JSON.stringify(packageJson, null, 2), + { + nodes: { + liba: { + name: 'liba', + type: 'lib', + data: { + root: 'libs/liba', + targets: { + build: {}, + }, + }, + }, + }, + externalNodes, + dependencies: { + liba: [{ source: 'liba', target: 'npm:external1', type: 'static' }], + }, + }, + { + liba: [ + createFile(`libs/liba/src/main.ts`, ['npm:external1']), + createFile(`libs/liba/package.json`, ['npm:external1']), + ], + } + ); + + expect(failures.length).toEqual(0); + }); + + it('should report version mismatch after resolving catalog reference', () => { + const packageJson = { + name: '@mycompany/liba', + dependencies: { + external1: 'catalog:', + }, + }; + + const fileSys = { + './libs/liba/package.json': JSON.stringify(packageJson, null, 2), + './libs/liba/src/index.ts': '', + './package.json': JSON.stringify(rootPackageJson, null, 2), + './pnpm-workspace.yaml': `catalog:\n external1: '^15.0.0'\n`, + }; + vol.fromJSON(fileSys, '/root'); + + const failures = runRule( + {}, + `/root/libs/liba/package.json`, + JSON.stringify(packageJson, null, 2), + { + nodes: { + liba: { + name: 'liba', + type: 'lib', + data: { + root: 'libs/liba', + targets: { + build: {}, + }, + }, + }, + }, + externalNodes, + dependencies: { + liba: [{ source: 'liba', target: 'npm:external1', type: 'static' }], + }, + }, + { + liba: [ + createFile(`libs/liba/src/main.ts`, ['npm:external1']), + createFile(`libs/liba/package.json`, ['npm:external1']), + ], + } + ); + + expect(failures.length).toEqual(1); + expect(failures[0].message).toContain( + 'version specifier does not contain' + ); + expect(failures[0].message).toContain('external1'); + expect(failures[0].message).toContain('16.1.8'); + }); + + it('should report error when package not in catalog', () => { + const packageJson = { + name: '@mycompany/liba', + dependencies: { + external1: 'catalog:', + }, + }; + + const fileSys = { + './libs/liba/package.json': JSON.stringify(packageJson, null, 2), + './libs/liba/src/index.ts': '', + './package.json': JSON.stringify(rootPackageJson, null, 2), + './pnpm-workspace.yaml': `catalog:\n external2: '^5.2.0'\n`, + }; + vol.fromJSON(fileSys, '/root'); + + const failures = runRule( + {}, + `/root/libs/liba/package.json`, + JSON.stringify(packageJson, null, 2), + { + nodes: { + liba: { + name: 'liba', + type: 'lib', + data: { + root: 'libs/liba', + targets: { + build: {}, + }, + }, + }, + }, + externalNodes, + dependencies: { + liba: [{ source: 'liba', target: 'npm:external1', type: 'static' }], + }, + }, + { + liba: [ + createFile(`libs/liba/src/main.ts`, ['npm:external1']), + createFile(`libs/liba/package.json`, ['npm:external1']), + ], + } + ); + + expect(failures.length).toEqual(1); + expect(failures[0].message).toContain('Invalid catalog reference'); + expect(failures[0].message).toContain('external1'); + }); + + it('should report error when named catalog does not exist', () => { + const packageJson = { + name: '@mycompany/liba', + dependencies: { + external1: 'catalog:nonexistent', + }, + }; + + const fileSys = { + './libs/liba/package.json': JSON.stringify(packageJson, null, 2), + './libs/liba/src/index.ts': '', + './package.json': JSON.stringify(rootPackageJson, null, 2), + './pnpm-workspace.yaml': `catalog:\n external1: '^16.0.0'\n`, + }; + vol.fromJSON(fileSys, '/root'); + + const failures = runRule( + {}, + `/root/libs/liba/package.json`, + JSON.stringify(packageJson, null, 2), + { + nodes: { + liba: { + name: 'liba', + type: 'lib', + data: { + root: 'libs/liba', + targets: { + build: {}, + }, + }, + }, + }, + externalNodes, + dependencies: { + liba: [{ source: 'liba', target: 'npm:external1', type: 'static' }], + }, + }, + { + liba: [ + createFile(`libs/liba/src/main.ts`, ['npm:external1']), + createFile(`libs/liba/package.json`, ['npm:external1']), + ], + } + ); + + expect(failures.length).toEqual(1); + expect(failures[0].message).toContain('Invalid catalog reference'); + expect(failures[0].message).toContain('external1'); + }); + + it('should resolve catalog reference when fixing missing dependency', () => { + const packageJson = { + name: '@mycompany/liba', + dependencies: { + external1: '^16.0.0', + }, + }; + + const rootPkgJsonWithCatalog = { + ...rootPackageJson, + dependencies: { + ...rootPackageJson.dependencies, + external2: 'catalog:', + }, + }; + + const fileSys = { + './libs/liba/package.json': JSON.stringify(packageJson, null, 2), + './libs/liba/src/index.ts': '', + './package.json': JSON.stringify(rootPkgJsonWithCatalog, null, 2), + './pnpm-workspace.yaml': `catalog:\n external2: '^5.2.0'\n`, + }; + vol.fromJSON(fileSys, '/root'); + + const failures = runRule( + {}, + `/root/libs/liba/package.json`, + JSON.stringify(packageJson, null, 2), + { + nodes: { + liba: { + name: 'liba', + type: 'lib', + data: { + root: 'libs/liba', + targets: { + build: {}, + }, + }, + }, + }, + externalNodes, + dependencies: { + liba: [ + { source: 'liba', target: 'npm:external1', type: 'static' }, + { source: 'liba', target: 'npm:external2', type: 'static' }, + ], + }, + }, + { + liba: [ + createFile(`libs/liba/src/main.ts`, [ + 'npm:external1', + 'npm:external2', + ]), + createFile(`libs/liba/package.json`, ['npm:external1']), + ], + } + ); + + expect(failures.length).toEqual(1); + expect(failures[0].message).toContain('external2'); + + // Apply fix and verify it keeps the catalog reference + const content = JSON.stringify(packageJson, null, 2); + const result = + content.slice(0, failures[0].fix!.range[0]) + + failures[0].fix!.text + + content.slice(failures[0].fix!.range[1]); + + expect(result).toContain('"external2": "catalog:"'); + }); + + it('should resolve catalog reference when fixing version mismatch', () => { + const packageJson = { + name: '@mycompany/liba', + dependencies: { + external1: '^15.0.0', + }, + }; + + const rootPkgJsonWithCatalog = { + ...rootPackageJson, + dependencies: { + ...rootPackageJson.dependencies, + external1: 'catalog:', + }, + }; + + const fileSys = { + './libs/liba/package.json': JSON.stringify(packageJson, null, 2), + './libs/liba/src/index.ts': '', + './package.json': JSON.stringify(rootPkgJsonWithCatalog, null, 2), + './pnpm-workspace.yaml': `catalog:\n external1: '^16.0.0'\n`, + }; + vol.fromJSON(fileSys, '/root'); + + const failures = runRule( + {}, + `/root/libs/liba/package.json`, + JSON.stringify(packageJson, null, 2), + { + nodes: { + liba: { + name: 'liba', + type: 'lib', + data: { + root: 'libs/liba', + targets: { + build: {}, + }, + }, + }, + }, + externalNodes, + dependencies: { + liba: [{ source: 'liba', target: 'npm:external1', type: 'static' }], + }, + }, + { + liba: [ + createFile(`libs/liba/src/main.ts`, ['npm:external1']), + createFile(`libs/liba/package.json`, ['npm:external1']), + ], + } + ); + + expect(failures.length).toEqual(1); + expect(failures[0].message).toContain( + 'version specifier does not contain' + ); + + // Apply fix and verify it keeps the catalog reference + const content = JSON.stringify(packageJson, null, 2); + const result = + content.slice(0, failures[0].fix!.range[0]) + + failures[0].fix!.text + + content.slice(failures[0].fix!.range[1]); + + expect(result).toContain('"external1": "catalog:"'); + }); + + it('should resolve catalog reference when fixing missing dependency section', () => { + const packageJson = { + name: '@mycompany/liba', + }; + + const rootPkgJsonWithCatalog = { + ...rootPackageJson, + dependencies: { + ...rootPackageJson.dependencies, + external1: 'catalog:', + }, + }; + + const fileSys = { + './libs/liba/package.json': JSON.stringify(packageJson, null, 2), + './libs/liba/src/index.ts': '', + './package.json': JSON.stringify(rootPkgJsonWithCatalog, null, 2), + './pnpm-workspace.yaml': `catalog:\n external1: '^16.0.0'\n`, + }; + vol.fromJSON(fileSys, '/root'); + + const failures = runRule( + {}, + `/root/libs/liba/package.json`, + JSON.stringify(packageJson, null, 2), + { + nodes: { + liba: { + name: 'liba', + type: 'lib', + data: { + root: 'libs/liba', + targets: { + build: {}, + }, + }, + }, + }, + externalNodes, + dependencies: { + liba: [{ source: 'liba', target: 'npm:external1', type: 'static' }], + }, + }, + { + liba: [createFile(`libs/liba/src/main.ts`, ['npm:external1'])], + } + ); + + expect(failures.length).toEqual(1); + expect(failures[0].message).toContain('Dependency sections are missing'); + + // Apply fix and verify it keeps the catalog reference + const content = JSON.stringify(packageJson, null, 2); + const result = + content.slice(0, failures[0].fix!.range[0]) + + failures[0].fix!.text + + content.slice(failures[0].fix!.range[1]); + + expect(result).toContain('"external1": "catalog:"'); + }); }); it('should require swc if @nx/js:swc executor', () => { diff --git a/packages/eslint-plugin/src/rules/dependency-checks.ts b/packages/eslint-plugin/src/rules/dependency-checks.ts index 4d3adea98e9e4..7aa20a10d0a77 100644 --- a/packages/eslint-plugin/src/rules/dependency-checks.ts +++ b/packages/eslint-plugin/src/rules/dependency-checks.ts @@ -222,9 +222,9 @@ export default ESLintUtils.RuleCreator( } const validationResult = manager.validateCatalogReference( + workspaceRoot, packageName, - packageRange, - workspaceRoot + packageRange ); if (!validationResult.isValid) { @@ -247,19 +247,38 @@ export default ESLintUtils.RuleCreator( if (!checkVersionMismatches) { return; } + + // Resolve catalog references before validation + let resolvedPackageRange = packageRange; + const manager = getCatalogManager(workspaceRoot); + + if ( + manager.supportsCatalogs() && + manager.isCatalogReference(packageRange) + ) { + const resolved = manager.resolveCatalogReference( + workspaceRoot, + packageName, + packageRange + ); + + if (!resolved) { + // Catalog resolution failed - this shouldn't happen because + // validateCatalogReferenceForPackage should have caught it earlier + // But if it does, skip validation gracefully + return; + } + + resolvedPackageRange = resolved; + } + if ( npmDependencies[packageName].startsWith('file:') || - packageRange.startsWith('file:') || + resolvedPackageRange.startsWith('file:') || npmDependencies[packageName] === '*' || - packageRange === '*' || - packageRange.startsWith('workspace:') || - /** - * Catalogs can be named, or left unnamed - * So just checking up until the : will catch both cases - * e.g. catalog:some-catalog or catalog: - */ - packageRange.startsWith('catalog:') || - satisfies(npmDependencies[packageName], packageRange, { + resolvedPackageRange === '*' || + resolvedPackageRange.startsWith('workspace:') || + satisfies(npmDependencies[packageName], resolvedPackageRange, { includePrerelease: true, }) ) { From a9e7790b9d26db9899a4e6837b3e08e37705b106 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leosvel=20P=C3=A9rez=20Espinosa?= Date: Wed, 15 Oct 2025 11:07:00 +0200 Subject: [PATCH 6/8] cleanup(misc): clean up leftover changes in e2e tests --- e2e/nx/src/misc.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/e2e/nx/src/misc.test.ts b/e2e/nx/src/misc.test.ts index 23f57770b2353..bf98edb64b482 100644 --- a/e2e/nx/src/misc.test.ts +++ b/e2e/nx/src/misc.test.ts @@ -448,7 +448,7 @@ describe('Nx Commands', () => { // TODO(colum): Change the fetcher to allow incremental migrations over multiple versions, allowing for beforeAll describe('migrate', () => { beforeEach(() => { - newProject(); + newProject({ packages: [] }); updateFile( `./node_modules/migrate-parent-package/package.json`, @@ -766,7 +766,7 @@ describe('migrate', () => { expect(output).toContain(`Migrations file 'migrations.json' doesn't exist`); }); - it('hhhhhhshould handle Nx tokens correctly in Angular CLI migration schematics', () => { + it('should handle Nx tokens correctly in Angular CLI migration schematics', () => { const app1 = uniq('app1'); updateFile( @@ -915,7 +915,7 @@ describe('migrate', () => { }); if (getSelectedPackageManager() === 'pnpm') { - it('hhhhhhshould handle pnpm catalog references and update catalog definitions during migration', () => { + it('should handle pnpm catalog references and update catalog definitions during migration', () => { // Setup pnpm-workspace.yaml with both default and named catalogs. Include // packages that WILL be updated and packages that SHOULD remain unchanged // to test both scenarios. From 3edfe1e5e079fc0103b88d3158b8d2541f7c450f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leosvel=20P=C3=A9rez=20Espinosa?= Date: Wed, 15 Oct 2025 16:02:44 +0200 Subject: [PATCH 7/8] feat(core): support `catalogs.default` definition and `catalog:default` reference --- .../getDependencyVersionFromPackageJson.md | 95 +++-- .../ngrx-root-store/lib/normalize-options.ts | 10 +- .../devkit/src/utils/catalog/pnpm-manager.ts | 79 +++- packages/devkit/src/utils/catalog/types.ts | 1 + .../devkit/src/utils/package-json.spec.ts | 147 +------- packages/devkit/src/utils/package-json.ts | 106 ++++-- .../package-json/create-package-json.spec.ts | 2 + .../js/package-json/create-package-json.ts | 38 +- .../nx/src/utils/catalog/pnpm-manager.spec.ts | 357 +++++++++++++++++- packages/nx/src/utils/catalog/pnpm-manager.ts | 79 +++- packages/nx/src/utils/catalog/types.ts | 1 + packages/nx/src/utils/package-json.spec.ts | 303 ++++++++++++++- packages/nx/src/utils/package-json.ts | 174 ++++++++- 13 files changed, 1106 insertions(+), 286 deletions(-) diff --git a/docs/generated/devkit/getDependencyVersionFromPackageJson.md b/docs/generated/devkit/getDependencyVersionFromPackageJson.md index 011e4742b696c..43e54a7908bec 100644 --- a/docs/generated/devkit/getDependencyVersionFromPackageJson.md +++ b/docs/generated/devkit/getDependencyVersionFromPackageJson.md @@ -1,12 +1,12 @@ # Function: getDependencyVersionFromPackageJson -▸ **getDependencyVersionFromPackageJson**(`tree`, `packageName`, `packageJsonPath?`): `string` \| `null` +▸ **getDependencyVersionFromPackageJson**(`tree`, `packageName`, `packageJsonPath?`, `dependencyLookup?`): `string` \| `null` Get the resolved version of a dependency from package.json. Retrieves a package version and automatically resolves PNPM catalog references -(e.g., "catalog:default") to their actual version strings. Searches `dependencies` -first, then falls back to `devDependencies`. +(e.g., "catalog:default") to their actual version strings. By default, searches +`dependencies` first, then falls back to `devDependencies`. **Tree-based usage** (generators and migrations): Use when you have a `Tree` object, which is typical in Nx generators and migrations. @@ -16,35 +16,58 @@ Use when reading directly from the filesystem without a `Tree` object. #### Parameters -| Name | Type | -| :----------------- | :-------------------------------------------------- | -| `tree` | [`Tree`](/reference/core-api/devkit/documents/Tree) | -| `packageName` | `string` | -| `packageJsonPath?` | `string` | +| Name | Type | Description | +| :------------------ | :-------------------------------------------------- | :---------------------------------------------------------------------------------------------- | +| `tree` | [`Tree`](/reference/core-api/devkit/documents/Tree) | - | +| `packageName` | `string` | - | +| `packageJsonPath?` | `string` | - | +| `dependencyLookup?` | `PackageJsonDependencySection`[] | Array of dependency sections to check in order. Defaults to ['dependencies', 'devDependencies'] | #### Returns `string` \| `null` -The resolved version string, or `null` if the package is not found in either dependencies or devDependencies +The resolved version string, or `null` if the package is not found in any of the specified sections **`Example`** ```typescript -// Tree-based - from root package.json +// Tree-based - from root package.json (checks dependencies then devDependencies) const reactVersion = getDependencyVersionFromPackageJson(tree, 'react'); // Returns: "^18.0.0" (resolves "catalog:default" if present) -// Tree-based - from specific package.json +// Tree-based - check only dependencies section const version = getDependencyVersionFromPackageJson( tree, - '@my/lib', - 'packages/my-lib/package.json' + 'react', + 'package.json', + ['dependencies'] +); + +// Tree-based - check only devDependencies section +const version = getDependencyVersionFromPackageJson( + tree, + 'jest', + 'package.json', + ['devDependencies'] +); + +// Tree-based - custom lookup order +const version = getDependencyVersionFromPackageJson( + tree, + 'pkg', + 'package.json', + ['devDependencies', 'dependencies', 'peerDependencies'] ); // Tree-based - with pre-loaded package.json const packageJson = readJson(tree, 'package.json'); -const version = getDependencyVersionFromPackageJson(tree, 'react', packageJson); +const version = getDependencyVersionFromPackageJson( + tree, + 'react', + packageJson, + ['dependencies'] +); ``` **`Example`** @@ -59,51 +82,55 @@ const version = getDependencyVersionFromPackageJson( '/path/to/workspace' ); -// Filesystem-based - with specific package.json +// Filesystem-based - with specific package.json and section const version = getDependencyVersionFromPackageJson( 'react', '/path/to/workspace', - 'apps/my-app/package.json' + 'apps/my-app/package.json', + ['dependencies'] ); ``` -▸ **getDependencyVersionFromPackageJson**(`tree`, `packageName`, `packageJson?`): `string` \| `null` +▸ **getDependencyVersionFromPackageJson**(`tree`, `packageName`, `packageJson?`, `dependencyLookup?`): `string` \| `null` #### Parameters -| Name | Type | -| :------------- | :-------------------------------------------------- | -| `tree` | [`Tree`](/reference/core-api/devkit/documents/Tree) | -| `packageName` | `string` | -| `packageJson?` | `PackageJson` | +| Name | Type | +| :------------------ | :-------------------------------------------------- | +| `tree` | [`Tree`](/reference/core-api/devkit/documents/Tree) | +| `packageName` | `string` | +| `packageJson?` | `PackageJson` | +| `dependencyLookup?` | `PackageJsonDependencySection`[] | #### Returns `string` \| `null` -▸ **getDependencyVersionFromPackageJson**(`packageName`, `workspaceRootPath?`, `packageJsonPath?`): `string` \| `null` +▸ **getDependencyVersionFromPackageJson**(`packageName`, `workspaceRootPath?`, `packageJsonPath?`, `dependencyLookup?`): `string` \| `null` #### Parameters -| Name | Type | -| :------------------- | :------- | -| `packageName` | `string` | -| `workspaceRootPath?` | `string` | -| `packageJsonPath?` | `string` | +| Name | Type | +| :------------------- | :------------------------------- | +| `packageName` | `string` | +| `workspaceRootPath?` | `string` | +| `packageJsonPath?` | `string` | +| `dependencyLookup?` | `PackageJsonDependencySection`[] | #### Returns `string` \| `null` -▸ **getDependencyVersionFromPackageJson**(`packageName`, `workspaceRootPath?`, `packageJson?`): `string` \| `null` +▸ **getDependencyVersionFromPackageJson**(`packageName`, `workspaceRootPath?`, `packageJson?`, `dependencyLookup?`): `string` \| `null` #### Parameters -| Name | Type | -| :------------------- | :------------ | -| `packageName` | `string` | -| `workspaceRootPath?` | `string` | -| `packageJson?` | `PackageJson` | +| Name | Type | +| :------------------- | :------------------------------- | +| `packageName` | `string` | +| `workspaceRootPath?` | `string` | +| `packageJson?` | `PackageJson` | +| `dependencyLookup?` | `PackageJsonDependencySection`[] | #### Returns diff --git a/packages/angular/src/generators/ngrx-root-store/lib/normalize-options.ts b/packages/angular/src/generators/ngrx-root-store/lib/normalize-options.ts index c19d6262b58b9..b321ca2cd361d 100644 --- a/packages/angular/src/generators/ngrx-root-store/lib/normalize-options.ts +++ b/packages/angular/src/generators/ngrx-root-store/lib/normalize-options.ts @@ -1,8 +1,8 @@ import type { Tree } from '@nx/devkit'; import { + getDependencyVersionFromPackageJson, joinPathFragments, names, - readJson, readProjectConfiguration, } from '@nx/devkit'; import { checkAndCleanWithSemver } from '@nx/devkit/src/utils/semver'; @@ -22,10 +22,16 @@ export function normalizeOptions( ): NormalizedNgRxRootStoreGeneratorOptions { let rxjsVersion: string; try { + const rxjsVersionFromPackageJson = getDependencyVersionFromPackageJson( + tree, + 'rxjs', + 'package.json', + ['dependencies'] + ); rxjsVersion = checkAndCleanWithSemver( tree, 'rxjs', - readJson(tree, 'package.json').dependencies['rxjs'] + rxjsVersionFromPackageJson ); } catch { rxjsVersion = checkAndCleanWithSemver(tree, 'rxjs', defaultRxjsVersion); diff --git a/packages/devkit/src/utils/catalog/pnpm-manager.ts b/packages/devkit/src/utils/catalog/pnpm-manager.ts index f82cc8b2c642d..f17161cf669ae 100644 --- a/packages/devkit/src/utils/catalog/pnpm-manager.ts +++ b/packages/devkit/src/utils/catalog/pnpm-manager.ts @@ -35,10 +35,12 @@ export class PnpmCatalogManager implements CatalogManager { } const catalogName = version.substring(this.catalogProtocol.length); + // Normalize both "catalog:" and "catalog:default" to the same representation + const isDefault = !catalogName || catalogName === 'default'; return { - catalogName: catalogName || undefined, - isDefaultCatalog: catalogName === '', + catalogName: isDefault ? undefined : catalogName, + isDefaultCatalog: isDefault, }; } @@ -74,7 +76,9 @@ export class PnpmCatalogManager implements CatalogManager { let catalogToUse: PnpmCatalogEntry | undefined; if (catalogRef.isDefaultCatalog) { - catalogToUse = workspaceConfig.catalog; + // Check both locations for default catalog + catalogToUse = + workspaceConfig.catalog ?? workspaceConfig.catalogs?.default; } else if (catalogRef.catalogName) { catalogToUse = workspaceConfig.catalogs?.[catalogRef.catalogName]; } @@ -115,7 +119,24 @@ export class PnpmCatalogManager implements CatalogManager { let catalogToUse: PnpmCatalogEntry | undefined; if (catalogRef.isDefaultCatalog) { - catalogToUse = workspaceConfig.catalog; + const hasCatalog = !!workspaceConfig.catalog; + const hasCatalogsDefault = !!workspaceConfig.catalogs?.default; + + // Error if both defined (matches pnpm behavior) + if (hasCatalog && hasCatalogsDefault) { + return { + isValid: false, + error: { + type: CatalogErrorType.INVALID_CATALOGS_CONFIGURATION, + message: + "The 'default' catalog was defined multiple times. Use the 'catalog' field or 'catalogs.default', but not both.", + suggestions: [], + }, + }; + } + + catalogToUse = + workspaceConfig.catalog ?? workspaceConfig.catalogs?.default; if (!catalogToUse) { const availableCatalogs = Object.keys(workspaceConfig.catalogs || {}); @@ -142,8 +163,14 @@ export class PnpmCatalogManager implements CatalogManager { } else if (catalogRef.catalogName) { catalogToUse = workspaceConfig.catalogs?.[catalogRef.catalogName]; if (!catalogToUse) { - const availableCatalogs = Object.keys(workspaceConfig.catalogs || {}); - const hasDefaultCatalog = !!workspaceConfig.catalog; + const availableCatalogs = Object.keys( + workspaceConfig.catalogs || {} + ).filter((c) => c !== 'default'); + const defaultCatalog = !!workspaceConfig.catalog + ? 'catalog' + : !workspaceConfig.catalogs?.default + ? 'catalogs.default' + : null; const suggestions = [ 'Define the catalog in pnpm-workspace.yaml under the "catalogs" key', @@ -155,8 +182,8 @@ export class PnpmCatalogManager implements CatalogManager { .join(', ')}` ); } - if (hasDefaultCatalog) { - suggestions.push('Or use the default catalog: "catalog:"'); + if (defaultCatalog) { + suggestions.push(`Or use the default catalog ("${defaultCatalog}")`); } return { @@ -172,9 +199,16 @@ export class PnpmCatalogManager implements CatalogManager { } if (!catalogToUse![packageName]) { - const catalogName = catalogRef.isDefaultCatalog - ? 'default catalog' - : `catalog '${catalogRef.catalogName}'`; + let catalogName: string; + if (catalogRef.isDefaultCatalog) { + // Context-aware messaging based on which location exists + const hasCatalog = !!workspaceConfig.catalog; + catalogName = hasCatalog + ? 'default catalog ("catalog")' + : 'default catalog ("catalogs.default")'; + } else { + catalogName = `catalog '${catalogRef.catalogName}'`; + } const availablePackages = Object.keys(catalogToUse!); const suggestions = [ @@ -245,17 +279,26 @@ export class PnpmCatalogManager implements CatalogManager { let hasChanges = false; for (const update of updates) { const { packageName, version, catalogName } = update; + const normalizedCatalogName = + catalogName === 'default' ? undefined : catalogName; let targetCatalog: PnpmCatalogEntry; - if (catalogName) { + if (!normalizedCatalogName) { + // Default catalog - update whichever exists, prefer catalog over catalogs.default + if (workspaceData.catalog) { + targetCatalog = workspaceData.catalog; + } else if (workspaceData.catalogs?.default) { + targetCatalog = workspaceData.catalogs.default; + } else { + // Neither exists, create catalog (shorthand syntax) + workspaceData.catalog ??= {}; + targetCatalog = workspaceData.catalog; + } + } else { // Named catalog workspaceData.catalogs ??= {}; - workspaceData.catalogs[catalogName] ??= {}; - targetCatalog = workspaceData.catalogs[catalogName]; - } else { - // Default catalog - workspaceData.catalog ??= {}; - targetCatalog = workspaceData.catalog; + workspaceData.catalogs[normalizedCatalogName] ??= {}; + targetCatalog = workspaceData.catalogs[normalizedCatalogName]; } if (targetCatalog[packageName] !== version) { diff --git a/packages/devkit/src/utils/catalog/types.ts b/packages/devkit/src/utils/catalog/types.ts index 6e51ae1ad89ba..2b42cfff4e2d6 100644 --- a/packages/devkit/src/utils/catalog/types.ts +++ b/packages/devkit/src/utils/catalog/types.ts @@ -8,6 +8,7 @@ export enum CatalogErrorType { WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND', CATALOG_NOT_FOUND = 'CATALOG_NOT_FOUND', PACKAGE_NOT_FOUND = 'PACKAGE_NOT_FOUND', + INVALID_CATALOGS_CONFIGURATION = 'INVALID_CATALOGS_CONFIGURATION', } export interface CatalogError { diff --git a/packages/devkit/src/utils/package-json.spec.ts b/packages/devkit/src/utils/package-json.spec.ts index 15fa85fc74577..adf4bcaed5c81 100644 --- a/packages/devkit/src/utils/package-json.spec.ts +++ b/packages/devkit/src/utils/package-json.spec.ts @@ -2,12 +2,7 @@ import * as devkitExports from 'nx/src/devkit-exports'; import { createTree } from 'nx/src/generators/testing-utils/create-tree'; import type { Tree } from 'nx/src/generators/tree'; import { readJson, writeJson } from 'nx/src/generators/utils/json'; -import type { PackageJson } from 'nx/src/utils/package-json'; -import { - addDependenciesToPackageJson, - ensurePackage, - getDependencyVersionFromPackageJson, -} from './package-json'; +import { addDependenciesToPackageJson, ensurePackage } from './package-json'; // Mock fs for catalog tests jest.mock('fs', () => require('memfs').fs); @@ -689,146 +684,6 @@ catalog: }); }); -describe('getDependencyVersionFromPackageJson', () => { - let tree: Tree; - - beforeEach(() => { - tree = createTree(); - }); - - it('should get single package version from root package.json', () => { - writeJson(tree, 'package.json', { - dependencies: { react: '^18.2.0' }, - devDependencies: { jest: '^29.0.0' }, - }); - - const reactVersion = getDependencyVersionFromPackageJson(tree, 'react'); - const jestVersion = getDependencyVersionFromPackageJson(tree, 'jest'); - - expect(reactVersion).toBe('^18.2.0'); - expect(jestVersion).toBe('^29.0.0'); - }); - - it('should return null for non-existent package', () => { - writeJson(tree, 'package.json', { - dependencies: { react: '^18.2.0' }, - }); - - const version = getDependencyVersionFromPackageJson(tree, 'non-existent'); - expect(version).toBeNull(); - }); - - it('should prioritize dependencies over devDependencies', () => { - writeJson(tree, 'package.json', { - dependencies: { react: '^18.0.0' }, - devDependencies: { react: '^18.2.0' }, - }); - - const version = getDependencyVersionFromPackageJson(tree, 'react'); - expect(version).toBe('^18.0.0'); - }); - - it('should read from specific package.json path', () => { - writeJson(tree, 'packages/my-lib/package.json', { - dependencies: { '@my/util': '^1.0.0' }, - }); - - const version = getDependencyVersionFromPackageJson( - tree, - '@my/util', - 'packages/my-lib/package.json' - ); - expect(version).toBe('^1.0.0'); - }); - - it('should work with pre-loaded package.json object', () => { - const packageJson: PackageJson = { - name: 'test', - version: '1.0.0', - dependencies: { react: '^18.2.0' }, - devDependencies: { jest: '^29.0.0' }, - }; - writeJson(tree, 'package.json', packageJson); - - const reactVersion = getDependencyVersionFromPackageJson( - tree, - 'react', - packageJson - ); - const jestVersion = getDependencyVersionFromPackageJson( - tree, - 'jest', - packageJson - ); - - expect(reactVersion).toBe('^18.2.0'); - expect(jestVersion).toBe('^29.0.0'); - }); - - describe('with catalog references', () => { - beforeEach(() => { - jest.spyOn(devkitExports, 'detectPackageManager').mockReturnValue('pnpm'); - tree.write( - 'pnpm-workspace.yaml', - ` -packages: - - packages/* -catalog: - react: "^18.2.0" - lodash: "^4.17.21" -catalogs: - frontend: - vue: "^3.3.0" -` - ); - }); - - it('should resolve catalog reference for single package', () => { - writeJson(tree, 'package.json', { - dependencies: { react: 'catalog:' }, - }); - - const version = getDependencyVersionFromPackageJson(tree, 'react'); - expect(version).toBe('^18.2.0'); - }); - - it('should resolve named catalog reference', () => { - writeJson(tree, 'package.json', { - dependencies: { vue: 'catalog:frontend' }, - }); - - const version = getDependencyVersionFromPackageJson(tree, 'vue'); - expect(version).toBe('^3.3.0'); - }); - - it('should return null when catalog reference cannot be resolved', () => { - writeJson(tree, 'package.json', { - dependencies: { unknown: 'catalog:' }, - }); - - const version = getDependencyVersionFromPackageJson(tree, 'unknown'); - expect(version).toBeNull(); - }); - - it('should work with pre-loaded package.json', () => { - const packageJson: PackageJson = { - name: 'test', - version: '1.0.0', - dependencies: { react: 'catalog:' }, - }; - writeJson(tree, 'package.json', packageJson); - - const version = getDependencyVersionFromPackageJson( - tree, - 'react', - packageJson - ); - - expect(version).toBe('^18.2.0'); - }); - }); -}); - describe('ensurePackage', () => { let tree: Tree; diff --git a/packages/devkit/src/utils/package-json.ts b/packages/devkit/src/utils/package-json.ts index 3f0122ebb9513..0b03c3d46ef27 100644 --- a/packages/devkit/src/utils/package-json.ts +++ b/packages/devkit/src/utils/package-json.ts @@ -10,7 +10,10 @@ import { workspaceRoot, } from 'nx/src/devkit-exports'; import { installPackageToTmp } from 'nx/src/devkit-internals'; -import type { PackageJson } from 'nx/src/utils/package-json'; +import type { + PackageJson, + PackageJsonDependencySection, +} from 'nx/src/utils/package-json'; import { join, resolve } from 'path'; import { clean, coerce, gt } from 'semver'; import { installPackagesTask } from '../tasks/install-packages-task'; @@ -33,8 +36,8 @@ const NON_SEMVER_TAGS = { * Get the resolved version of a dependency from package.json. * * Retrieves a package version and automatically resolves PNPM catalog references - * (e.g., "catalog:default") to their actual version strings. Searches `dependencies` - * first, then falls back to `devDependencies`. + * (e.g., "catalog:default") to their actual version strings. By default, searches + * `dependencies` first, then falls back to `devDependencies`. * * **Tree-based usage** (generators and migrations): * Use when you have a `Tree` object, which is typical in Nx generators and migrations. @@ -44,20 +47,42 @@ const NON_SEMVER_TAGS = { * * @example * ```typescript - * // Tree-based - from root package.json + * // Tree-based - from root package.json (checks dependencies then devDependencies) * const reactVersion = getDependencyVersionFromPackageJson(tree, 'react'); * // Returns: "^18.0.0" (resolves "catalog:default" if present) * - * // Tree-based - from specific package.json + * // Tree-based - check only dependencies section * const version = getDependencyVersionFromPackageJson( * tree, - * '@my/lib', - * 'packages/my-lib/package.json' + * 'react', + * 'package.json', + * ['dependencies'] + * ); + * + * // Tree-based - check only devDependencies section + * const version = getDependencyVersionFromPackageJson( + * tree, + * 'jest', + * 'package.json', + * ['devDependencies'] + * ); + * + * // Tree-based - custom lookup order + * const version = getDependencyVersionFromPackageJson( + * tree, + * 'pkg', + * 'package.json', + * ['devDependencies', 'dependencies', 'peerDependencies'] * ); * * // Tree-based - with pre-loaded package.json * const packageJson = readJson(tree, 'package.json'); - * const version = getDependencyVersionFromPackageJson(tree, 'react', packageJson); + * const version = getDependencyVersionFromPackageJson( + * tree, + * 'react', + * packageJson, + * ['dependencies'] + * ); * ``` * * @example @@ -68,52 +93,61 @@ const NON_SEMVER_TAGS = { * // Filesystem-based - with workspace root * const version = getDependencyVersionFromPackageJson('react', '/path/to/workspace'); * - * // Filesystem-based - with specific package.json + * // Filesystem-based - with specific package.json and section * const version = getDependencyVersionFromPackageJson( * 'react', * '/path/to/workspace', - * 'apps/my-app/package.json' + * 'apps/my-app/package.json', + * ['dependencies'] * ); * ``` * - * @returns The resolved version string, or `null` if the package is not found in either dependencies or devDependencies + * @param dependencyLookup Array of dependency sections to check in order. Defaults to ['dependencies', 'devDependencies'] + * @returns The resolved version string, or `null` if the package is not found in any of the specified sections */ export function getDependencyVersionFromPackageJson( tree: Tree, packageName: string, - packageJsonPath?: string + packageJsonPath?: string, + dependencyLookup?: PackageJsonDependencySection[] ): string | null; export function getDependencyVersionFromPackageJson( tree: Tree, packageName: string, - packageJson?: PackageJson + packageJson?: PackageJson, + dependencyLookup?: PackageJsonDependencySection[] ): string | null; export function getDependencyVersionFromPackageJson( packageName: string, workspaceRootPath?: string, - packageJsonPath?: string + packageJsonPath?: string, + dependencyLookup?: PackageJsonDependencySection[] ): string | null; export function getDependencyVersionFromPackageJson( packageName: string, workspaceRootPath?: string, - packageJson?: PackageJson + packageJson?: PackageJson, + dependencyLookup?: PackageJsonDependencySection[] ): string | null; export function getDependencyVersionFromPackageJson( treeOrPackageName: Tree | string, packageNameOrRoot?: string, - packageJsonPathOrObjectOrRoot?: string | PackageJson + packageJsonPathOrObjectOrRoot?: string | PackageJson, + dependencyLookup?: PackageJsonDependencySection[] ): string | null { if (typeof treeOrPackageName !== 'string') { return getDependencyVersionFromPackageJsonFromTree( treeOrPackageName, packageNameOrRoot!, - packageJsonPathOrObjectOrRoot + packageJsonPathOrObjectOrRoot, + dependencyLookup ); } else { return getDependencyVersionFromPackageJsonFromFileSystem( treeOrPackageName, packageNameOrRoot, - packageJsonPathOrObjectOrRoot + packageJsonPathOrObjectOrRoot, + dependencyLookup ); } } @@ -124,7 +158,11 @@ export function getDependencyVersionFromPackageJson( function getDependencyVersionFromPackageJsonFromTree( tree: Tree, packageName: string, - packageJsonPathOrObject: string | PackageJson = 'package.json' + packageJsonPathOrObject: string | PackageJson = 'package.json', + dependencyLookup: PackageJsonDependencySection[] = [ + 'dependencies', + 'devDependencies', + ] ): string | null { let packageJson: PackageJson; if (typeof packageJsonPathOrObject === 'object') { @@ -137,10 +175,14 @@ function getDependencyVersionFromPackageJsonFromTree( const manager = getCatalogManager(tree.root); - let version = - packageJson.dependencies?.[packageName] ?? - packageJson.devDependencies?.[packageName] ?? - null; + let version: string | null = null; + for (const section of dependencyLookup) { + const foundVersion = packageJson[section]?.[packageName]; + if (foundVersion) { + version = foundVersion; + break; + } + } // Resolve catalog reference if needed if (version && manager.isCatalogReference(version)) { @@ -156,7 +198,11 @@ function getDependencyVersionFromPackageJsonFromTree( function getDependencyVersionFromPackageJsonFromFileSystem( packageName: string, root: string = workspaceRoot, - packageJsonPathOrObject: string | PackageJson = 'package.json' + packageJsonPathOrObject: string | PackageJson = 'package.json', + dependencyLookup: PackageJsonDependencySection[] = [ + 'dependencies', + 'devDependencies', + ] ): string | null { let packageJson: PackageJson; if (typeof packageJsonPathOrObject === 'object') { @@ -172,10 +218,14 @@ function getDependencyVersionFromPackageJsonFromFileSystem( const manager = getCatalogManager(root); - let version = - packageJson.dependencies?.[packageName] ?? - packageJson.devDependencies?.[packageName] ?? - null; + let version: string | null = null; + for (const section of dependencyLookup) { + const foundVersion = packageJson[section]?.[packageName]; + if (foundVersion) { + version = foundVersion; + break; + } + } // Resolve catalog reference if needed if (version && manager.isCatalogReference(version)) { diff --git a/packages/nx/src/plugins/js/package-json/create-package-json.spec.ts b/packages/nx/src/plugins/js/package-json/create-package-json.spec.ts index 63cf81d7125ed..7bee26579f7f8 100644 --- a/packages/nx/src/plugins/js/package-json/create-package-json.spec.ts +++ b/packages/nx/src/plugins/js/package-json/create-package-json.spec.ts @@ -515,6 +515,7 @@ describe('createPackageJson', () => { }); it('should use fixed versions when creating package json for apps', () => { + spies.push(jest.spyOn(configModule, 'readNxJson').mockReturnValue({})); spies.push( jest.spyOn(fs, 'existsSync').mockImplementation((path) => { if (path === 'apps/app1/package.json') { @@ -543,6 +544,7 @@ describe('createPackageJson', () => { }); it('should override fixed versions with local ranges when creating package json for apps', () => { + spies.push(jest.spyOn(configModule, 'readNxJson').mockReturnValue({})); spies.push( jest.spyOn(fs, 'existsSync').mockImplementation((path) => { if (path === 'apps/app1/package.json') { diff --git a/packages/nx/src/plugins/js/package-json/create-package-json.ts b/packages/nx/src/plugins/js/package-json/create-package-json.ts index 46b13706acd26..fec2ad6cd50a1 100644 --- a/packages/nx/src/plugins/js/package-json/create-package-json.ts +++ b/packages/nx/src/plugins/js/package-json/create-package-json.ts @@ -6,7 +6,10 @@ import { ProjectGraph, ProjectGraphProjectNode, } from '../../../config/project-graph'; -import { PackageJson } from '../../../utils/package-json'; +import { + getDependencyVersionFromPackageJson, + PackageJson, +} from '../../../utils/package-json'; import { existsSync } from 'fs'; import { workspaceRoot } from '../../../utils/workspace-root'; import { readNxJson } from '../../../config/configuration'; @@ -17,7 +20,6 @@ import { getTargetInputs, } from '../../../hasher/task-hasher'; import { output } from '../../../utils/output'; -import { getCatalogManager } from '../../../utils/catalog'; interface NpmDeps { readonly dependencies: Record; @@ -99,22 +101,28 @@ export function createPackageJson( version: string, section: 'devDependencies' | 'dependencies' ) => { - const projectVersion = packageJson[section]?.[packageName]; + // Try project package.json first (single section) + const projectVersion = getDependencyVersionFromPackageJson( + packageName, + root, + packageJson, + [section] + ); if (projectVersion) { - const manager = getCatalogManager(root); - return manager.isCatalogReference(projectVersion) - ? manager.resolveCatalogReference(packageName, projectVersion, root) ?? - version - : projectVersion; + return projectVersion; } - if (isLibrary && rootPackageJson[section]?.[packageName]) { - const rootVersion = rootPackageJson[section][packageName]; - const manager = getCatalogManager(root); - return manager.isCatalogReference(rootVersion) - ? manager.resolveCatalogReference(packageName, rootVersion, root) ?? - version - : rootVersion; + // For libraries, fall back to root package.json (single section) + if (isLibrary) { + const rootVersion = getDependencyVersionFromPackageJson( + packageName, + root, + rootPackageJson, + [section] + ); + if (rootVersion) { + return rootVersion; + } } return version; diff --git a/packages/nx/src/utils/catalog/pnpm-manager.spec.ts b/packages/nx/src/utils/catalog/pnpm-manager.spec.ts index 9516fa00b5fdd..6da9ec73a8ba2 100644 --- a/packages/nx/src/utils/catalog/pnpm-manager.spec.ts +++ b/packages/nx/src/utils/catalog/pnpm-manager.spec.ts @@ -1,5 +1,6 @@ -import type { Tree } from '../../generators/tree'; +import { load } from '@zkochan/js-yaml'; import { createTreeWithEmptyWorkspace } from '../../generators/testing-utils/create-tree-with-empty-workspace'; +import type { Tree } from '../../generators/tree'; import { PnpmCatalogManager } from './pnpm-manager'; import { CatalogErrorType } from './types'; @@ -35,6 +36,15 @@ describe('PnpmCatalogManager', () => { }); }); + it('should normalize catalog:default to default catalog reference', () => { + const result = manager.parseCatalogReference('catalog:default'); + + expect(result).toStrictEqual({ + catalogName: undefined, + isDefaultCatalog: true, + }); + }); + it('should parse named catalog reference', () => { const result = manager.parseCatalogReference('catalog:react18'); @@ -108,7 +118,7 @@ catalogs: }); describe('resolveCatalogReference', () => { - it('should resolve default catalog reference', () => { + it('should resolve default catalog reference from top-level catalog field', () => { tree.write( 'pnpm-workspace.yaml', ` @@ -122,6 +132,58 @@ catalog: expect(result).toBe('^18.0.0'); }); + it('should resolve catalog:default from top-level catalog field', () => { + tree.write( + 'pnpm-workspace.yaml', + ` +catalog: + react: ^18.0.0 +` + ); + + const result = manager.resolveCatalogReference( + tree, + 'react', + 'catalog:default' + ); + + expect(result).toBe('^18.0.0'); + }); + + it('should resolve catalog: from catalogs.default field', () => { + tree.write( + 'pnpm-workspace.yaml', + ` +catalogs: + default: + react: ^18.0.0 +` + ); + + const result = manager.resolveCatalogReference(tree, 'react', 'catalog:'); + + expect(result).toBe('^18.0.0'); + }); + + it('should resolve catalog:default from catalogs.default field', () => { + tree.write( + 'pnpm-workspace.yaml', + ` +catalogs: + default: + react: ^18.0.0 +` + ); + + const result = manager.resolveCatalogReference( + tree, + 'react', + 'catalog:default' + ); + + expect(result).toBe('^18.0.0'); + }); + it('should resolve named catalog reference', () => { tree.write( 'pnpm-workspace.yaml', @@ -173,7 +235,7 @@ catalogs: const result = manager.validateCatalogReference(tree, 'react', '^18.0.0'); expect(result.isValid).toBe(false); - expect(result.error?.type).toBe(CatalogErrorType.INVALID_SYNTAX); + expect(result.error!.type).toBe(CatalogErrorType.INVALID_SYNTAX); }); it('should return invalid when workspace file not found', () => { @@ -184,7 +246,61 @@ catalogs: ); expect(result.isValid).toBe(false); - expect(result.error?.type).toBe(CatalogErrorType.WORKSPACE_NOT_FOUND); + expect(result.error!.type).toBe(CatalogErrorType.WORKSPACE_NOT_FOUND); + }); + + it('should return error for catalog: when both definitions exist', () => { + tree.write( + 'pnpm-workspace.yaml', + ` +catalog: + react: ^18.0.0 +catalogs: + default: + lodash: ^4.17.21 +` + ); + + const result = manager.validateCatalogReference( + tree, + 'react', + 'catalog:' + ); + + expect(result.isValid).toBe(false); + expect(result.error!.type).toBe( + CatalogErrorType.INVALID_CATALOGS_CONFIGURATION + ); + expect(result.error!.message).toContain( + "The 'default' catalog was defined multiple times" + ); + }); + + it('should return error for catalog:default when both definitions exist', () => { + tree.write( + 'pnpm-workspace.yaml', + ` +catalog: + react: ^18.0.0 +catalogs: + default: + lodash: ^4.17.21 +` + ); + + const result = manager.validateCatalogReference( + tree, + 'react', + 'catalog:default' + ); + + expect(result.isValid).toBe(false); + expect(result.error!.type).toBe( + CatalogErrorType.INVALID_CATALOGS_CONFIGURATION + ); + expect(result.error!.message).toContain( + "The 'default' catalog was defined multiple times" + ); }); it('should return invalid when default catalog not found', () => { @@ -203,7 +319,7 @@ packages: ); expect(result.isValid).toBe(false); - expect(result.error?.type).toBe(CatalogErrorType.CATALOG_NOT_FOUND); + expect(result.error!.type).toBe(CatalogErrorType.CATALOG_NOT_FOUND); }); it('should return invalid when named catalog not found', () => { @@ -222,7 +338,7 @@ packages: ); expect(result.isValid).toBe(false); - expect(result.error?.type).toBe(CatalogErrorType.CATALOG_NOT_FOUND); + expect(result.error!.type).toBe(CatalogErrorType.CATALOG_NOT_FOUND); }); it('should return invalid for a missing package in catalog', () => { @@ -243,10 +359,10 @@ catalog: ); expect(result.isValid).toBe(false); - expect(result.error?.type).toBe(CatalogErrorType.PACKAGE_NOT_FOUND); + expect(result.error!.type).toBe(CatalogErrorType.PACKAGE_NOT_FOUND); }); - it('should return valid for existing catalog entry', () => { + it('should return valid for existing catalog entry in top-level catalog', () => { tree.write( 'pnpm-workspace.yaml', ` @@ -264,5 +380,230 @@ catalog: expect(result.isValid).toBe(true); expect(result.error).toBeUndefined(); }); + + it('should validate catalog:default with top-level catalog field', () => { + tree.write( + 'pnpm-workspace.yaml', + ` +catalog: + react: ^18.0.0 +` + ); + + const result = manager.validateCatalogReference( + tree, + 'react', + 'catalog:default' + ); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('should validate catalog: with catalogs.default field', () => { + tree.write( + 'pnpm-workspace.yaml', + ` +catalogs: + default: + react: ^18.0.0 +` + ); + + const result = manager.validateCatalogReference( + tree, + 'react', + 'catalog:' + ); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('should validate catalog:default with catalogs.default field', () => { + tree.write( + 'pnpm-workspace.yaml', + ` +catalogs: + default: + react: ^18.0.0 +` + ); + + const result = manager.validateCatalogReference( + tree, + 'react', + 'catalog:default' + ); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + }); + + describe('updateCatalogVersions', () => { + it('should update existing top-level catalog field', () => { + tree.write( + 'pnpm-workspace.yaml', + ` +catalog: + react: ^18.0.0 + lodash: ^4.17.21 +` + ); + + manager.updateCatalogVersions(tree, [ + { packageName: 'react', version: '^18.3.0' }, + ]); + + const content = tree.read('pnpm-workspace.yaml', 'utf-8'); + const result = load(content); + expect(result.catalog.react).toBe('^18.3.0'); + expect(result.catalog.lodash).toBe('^4.17.21'); + }); + + it('should update existing catalogs.default field', () => { + tree.write( + 'pnpm-workspace.yaml', + ` +catalogs: + default: + react: ^18.0.0 + lodash: ^4.17.21 +` + ); + + manager.updateCatalogVersions(tree, [ + { packageName: 'react', version: '^18.3.0' }, + ]); + + const content = tree.read('pnpm-workspace.yaml', 'utf-8'); + const result = load(content); + expect(result.catalogs.default.react).toBe('^18.3.0'); + expect(result.catalogs.default.lodash).toBe('^4.17.21'); + }); + + it('should update existing top-level catalog field with catalogName: "default"', () => { + tree.write( + 'pnpm-workspace.yaml', + ` +catalog: + react: ^18.0.0 + lodash: ^4.17.21 +` + ); + + manager.updateCatalogVersions(tree, [ + { packageName: 'react', version: '^18.3.0', catalogName: 'default' }, + ]); + + const content = tree.read('pnpm-workspace.yaml', 'utf-8'); + const result = load(content); + expect(result.catalog.react).toBe('^18.3.0'); + expect(result.catalog.lodash).toBe('^4.17.21'); + }); + + it('should update existing catalogs.default field with catalogName: "default"', () => { + tree.write( + 'pnpm-workspace.yaml', + ` +catalogs: + default: + react: ^18.0.0 + lodash: ^4.17.21 +` + ); + + manager.updateCatalogVersions(tree, [ + { packageName: 'react', version: '^18.3.0', catalogName: 'default' }, + ]); + + const content = tree.read('pnpm-workspace.yaml', 'utf-8'); + const result = load(content); + expect(result.catalogs.default.react).toBe('^18.3.0'); + expect(result.catalogs.default.lodash).toBe('^4.17.21'); + }); + + it('should create catalog field when neither exists', () => { + tree.write( + 'pnpm-workspace.yaml', + ` +packages: + - 'packages/*' +` + ); + + manager.updateCatalogVersions(tree, [ + { packageName: 'react', version: '^18.0.0' }, + ]); + + const content = tree.read('pnpm-workspace.yaml', 'utf-8'); + const result = load(content); + expect(result.catalog.react).toBe('^18.0.0'); + expect(result.catalogs).toBeUndefined(); + }); + + it('should update named catalog', () => { + tree.write( + 'pnpm-workspace.yaml', + ` +catalogs: + react18: + react: ^18.0.0 + react-dom: ^18.0.0 +` + ); + + manager.updateCatalogVersions(tree, [ + { packageName: 'react', version: '^18.3.0', catalogName: 'react18' }, + ]); + + const content = tree.read('pnpm-workspace.yaml', 'utf-8'); + const result = load(content); + expect(result.catalogs.react18.react).toBe('^18.3.0'); + expect(result.catalogs.react18['react-dom']).toBe('^18.0.0'); + }); + + it('should handle multiple updates at once', () => { + tree.write( + 'pnpm-workspace.yaml', + ` +catalog: + react: ^18.0.0 +catalogs: + react17: + react: ^17.0.0 +` + ); + + manager.updateCatalogVersions(tree, [ + { packageName: 'react', version: '^18.3.0' }, + { packageName: 'react', version: '^17.0.2', catalogName: 'react17' }, + ]); + + const content = tree.read('pnpm-workspace.yaml', 'utf-8'); + const result = load(content); + expect(result.catalog.react).toBe('^18.3.0'); + expect(result.catalogs.react17.react).toBe('^17.0.2'); + }); + + it('should add new packages to existing catalog', () => { + tree.write( + 'pnpm-workspace.yaml', + ` +catalog: + react: ^18.0.0 +` + ); + + manager.updateCatalogVersions(tree, [ + { packageName: 'lodash', version: '^4.17.21' }, + ]); + + const content = tree.read('pnpm-workspace.yaml', 'utf-8'); + const result = load(content); + expect(result.catalog.react).toBe('^18.0.0'); + expect(result.catalog.lodash).toBe('^4.17.21'); + }); }); }); diff --git a/packages/nx/src/utils/catalog/pnpm-manager.ts b/packages/nx/src/utils/catalog/pnpm-manager.ts index a050a044fabb7..63c3a56c44637 100644 --- a/packages/nx/src/utils/catalog/pnpm-manager.ts +++ b/packages/nx/src/utils/catalog/pnpm-manager.ts @@ -33,10 +33,12 @@ export class PnpmCatalogManager implements CatalogManager { } const catalogName = version.substring(this.catalogProtocol.length); + // Normalize both "catalog:" and "catalog:default" to the same representation + const isDefault = !catalogName || catalogName === 'default'; return { - catalogName: catalogName || undefined, - isDefaultCatalog: catalogName === '', + catalogName: isDefault ? undefined : catalogName, + isDefaultCatalog: isDefault, }; } @@ -72,7 +74,9 @@ export class PnpmCatalogManager implements CatalogManager { let catalogToUse: PnpmCatalogEntry | undefined; if (catalogRef.isDefaultCatalog) { - catalogToUse = workspaceConfig.catalog; + // Check both locations for default catalog + catalogToUse = + workspaceConfig.catalog ?? workspaceConfig.catalogs?.default; } else if (catalogRef.catalogName) { catalogToUse = workspaceConfig.catalogs?.[catalogRef.catalogName]; } @@ -113,7 +117,24 @@ export class PnpmCatalogManager implements CatalogManager { let catalogToUse: PnpmCatalogEntry | undefined; if (catalogRef.isDefaultCatalog) { - catalogToUse = workspaceConfig.catalog; + const hasCatalog = !!workspaceConfig.catalog; + const hasCatalogsDefault = !!workspaceConfig.catalogs?.default; + + // Error if both defined (matches pnpm behavior) + if (hasCatalog && hasCatalogsDefault) { + return { + isValid: false, + error: { + type: CatalogErrorType.INVALID_CATALOGS_CONFIGURATION, + message: + "The 'default' catalog was defined multiple times. Use the 'catalog' field or 'catalogs.default', but not both.", + suggestions: [], + }, + }; + } + + catalogToUse = + workspaceConfig.catalog ?? workspaceConfig.catalogs?.default; if (!catalogToUse) { const availableCatalogs = Object.keys(workspaceConfig.catalogs || {}); @@ -140,8 +161,14 @@ export class PnpmCatalogManager implements CatalogManager { } else if (catalogRef.catalogName) { catalogToUse = workspaceConfig.catalogs?.[catalogRef.catalogName]; if (!catalogToUse) { - const availableCatalogs = Object.keys(workspaceConfig.catalogs || {}); - const hasDefaultCatalog = !!workspaceConfig.catalog; + const availableCatalogs = Object.keys( + workspaceConfig.catalogs || {} + ).filter((c) => c !== 'default'); + const defaultCatalog = !!workspaceConfig.catalog + ? 'catalog' + : !workspaceConfig.catalogs?.default + ? 'catalogs.default' + : null; const suggestions = [ 'Define the catalog in pnpm-workspace.yaml under the "catalogs" key', @@ -153,8 +180,8 @@ export class PnpmCatalogManager implements CatalogManager { .join(', ')}` ); } - if (hasDefaultCatalog) { - suggestions.push('Or use the default catalog: "catalog:"'); + if (defaultCatalog) { + suggestions.push(`Or use the default catalog ("${defaultCatalog}")`); } return { @@ -170,9 +197,16 @@ export class PnpmCatalogManager implements CatalogManager { } if (!catalogToUse![packageName]) { - const catalogName = catalogRef.isDefaultCatalog - ? 'default catalog' - : `catalog '${catalogRef.catalogName}'`; + let catalogName: string; + if (catalogRef.isDefaultCatalog) { + // Context-aware messaging based on which location exists + const hasCatalog = !!workspaceConfig.catalog; + catalogName = hasCatalog + ? 'default catalog ("catalog")' + : 'default catalog ("catalogs.default")'; + } else { + catalogName = `catalog '${catalogRef.catalogName}'`; + } const availablePackages = Object.keys(catalogToUse!); const suggestions = [ @@ -243,17 +277,26 @@ export class PnpmCatalogManager implements CatalogManager { let hasChanges = false; for (const update of updates) { const { packageName, version, catalogName } = update; + const normalizedCatalogName = + catalogName === 'default' ? undefined : catalogName; let targetCatalog: PnpmCatalogEntry; - if (catalogName) { + if (!normalizedCatalogName) { + // Default catalog - update whichever exists, prefer catalog over catalogs.default + if (workspaceData.catalog) { + targetCatalog = workspaceData.catalog; + } else if (workspaceData.catalogs?.default) { + targetCatalog = workspaceData.catalogs.default; + } else { + // Neither exists, create catalog (shorthand syntax) + workspaceData.catalog ??= {}; + targetCatalog = workspaceData.catalog; + } + } else { // Named catalog workspaceData.catalogs ??= {}; - workspaceData.catalogs[catalogName] ??= {}; - targetCatalog = workspaceData.catalogs[catalogName]; - } else { - // Default catalog - workspaceData.catalog ??= {}; - targetCatalog = workspaceData.catalog; + workspaceData.catalogs[normalizedCatalogName] ??= {}; + targetCatalog = workspaceData.catalogs[normalizedCatalogName]; } if (targetCatalog[packageName] !== version) { diff --git a/packages/nx/src/utils/catalog/types.ts b/packages/nx/src/utils/catalog/types.ts index 6e51ae1ad89ba..2b42cfff4e2d6 100644 --- a/packages/nx/src/utils/catalog/types.ts +++ b/packages/nx/src/utils/catalog/types.ts @@ -8,6 +8,7 @@ export enum CatalogErrorType { WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND', CATALOG_NOT_FOUND = 'CATALOG_NOT_FOUND', PACKAGE_NOT_FOUND = 'PACKAGE_NOT_FOUND', + INVALID_CATALOGS_CONFIGURATION = 'INVALID_CATALOGS_CONFIGURATION', } export interface CatalogError { diff --git a/packages/nx/src/utils/package-json.spec.ts b/packages/nx/src/utils/package-json.spec.ts index 5fc0316b18932..c2a0d4ea1bd83 100644 --- a/packages/nx/src/utils/package-json.spec.ts +++ b/packages/nx/src/utils/package-json.spec.ts @@ -1,13 +1,18 @@ import { join } from 'path'; -import { workspaceRoot } from './workspace-root'; +import { createTreeWithEmptyWorkspace } from '../generators/testing-utils/create-tree-with-empty-workspace'; +import type { Tree } from '../generators/tree'; +import { writeJson } from '../generators/utils/json'; import { readJsonFile } from './fileutils'; import { buildTargetFromScript, + getDependencyVersionFromPackageJson, PackageJson, readModulePackageJson, readTargetsFromPackageJson, } from './package-json'; +import * as pacakgeManager from './package-manager'; import { getPackageManagerCommand } from './package-manager'; +import { workspaceRoot } from './workspace-root'; describe('buildTargetFromScript', () => { it('should use nx:run-script', () => { @@ -421,3 +426,299 @@ describe('readModulePackageJson', () => { } ); }); + +describe('getDependencyVersionFromPackageJson', () => { + let tree: Tree; + + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + }); + + it('should get single package version from root package.json', () => { + writeJson(tree, 'package.json', { + dependencies: { react: '^18.2.0' }, + devDependencies: { jest: '^29.0.0' }, + }); + + const reactVersion = getDependencyVersionFromPackageJson(tree, 'react'); + const jestVersion = getDependencyVersionFromPackageJson(tree, 'jest'); + + expect(reactVersion).toBe('^18.2.0'); + expect(jestVersion).toBe('^29.0.0'); + }); + + it('should return null for non-existent package', () => { + writeJson(tree, 'package.json', { + dependencies: { react: '^18.2.0' }, + }); + + const version = getDependencyVersionFromPackageJson(tree, 'non-existent'); + expect(version).toBeNull(); + }); + + it('should prioritize dependencies over devDependencies', () => { + writeJson(tree, 'package.json', { + dependencies: { react: '^18.0.0' }, + devDependencies: { react: '^18.2.0' }, + }); + + const version = getDependencyVersionFromPackageJson(tree, 'react'); + expect(version).toBe('^18.0.0'); + }); + + it('should read from specific package.json path', () => { + writeJson(tree, 'packages/my-lib/package.json', { + dependencies: { '@my/util': '^1.0.0' }, + }); + + const version = getDependencyVersionFromPackageJson( + tree, + '@my/util', + 'packages/my-lib/package.json' + ); + expect(version).toBe('^1.0.0'); + }); + + it('should work with pre-loaded package.json object', () => { + const packageJson: PackageJson = { + name: 'test', + version: '1.0.0', + dependencies: { react: '^18.2.0' }, + devDependencies: { jest: '^29.0.0' }, + }; + writeJson(tree, 'package.json', packageJson); + + const reactVersion = getDependencyVersionFromPackageJson( + tree, + 'react', + packageJson + ); + const jestVersion = getDependencyVersionFromPackageJson( + tree, + 'jest', + packageJson + ); + + expect(reactVersion).toBe('^18.2.0'); + expect(jestVersion).toBe('^29.0.0'); + }); + + it('should check only dependencies section when specified', () => { + writeJson(tree, 'package.json', { + dependencies: { react: '^18.0.0' }, + devDependencies: { react: '^17.0.0' }, + }); + + const version = getDependencyVersionFromPackageJson( + tree, + 'react', + 'package.json', + ['dependencies'] + ); + expect(version).toBe('^18.0.0'); + }); + + it('should check only devDependencies section when specified', () => { + writeJson(tree, 'package.json', { + dependencies: { jest: '^28.0.0' }, + devDependencies: { jest: '^29.0.0' }, + }); + + const version = getDependencyVersionFromPackageJson( + tree, + 'jest', + 'package.json', + ['devDependencies'] + ); + expect(version).toBe('^29.0.0'); + }); + + it('should return null when package not in specified section', () => { + writeJson(tree, 'package.json', { + dependencies: { react: '^18.0.0' }, + devDependencies: { jest: '^29.0.0' }, + }); + + const version = getDependencyVersionFromPackageJson( + tree, + 'react', + 'package.json', + ['devDependencies'] + ); + expect(version).toBeNull(); + }); + + it('should respect custom lookup order', () => { + writeJson(tree, 'package.json', { + dependencies: { pkg: '^1.0.0' }, + devDependencies: { pkg: '^2.0.0' }, + }); + + const version = getDependencyVersionFromPackageJson( + tree, + 'pkg', + 'package.json', + ['devDependencies', 'dependencies'] + ); + expect(version).toBe('^2.0.0'); + }); + + it('should check peerDependencies when specified', () => { + writeJson(tree, 'package.json', { + dependencies: { react: '^18.0.0' }, + peerDependencies: { react: '^17.0.0' }, + }); + + const version = getDependencyVersionFromPackageJson( + tree, + 'react', + 'package.json', + ['peerDependencies'] + ); + expect(version).toBe('^17.0.0'); + }); + + it('should check optionalDependencies when specified', () => { + writeJson(tree, 'package.json', { + dependencies: { fsevents: '^2.3.0' }, + optionalDependencies: { fsevents: '^2.3.2' }, + }); + + const version = getDependencyVersionFromPackageJson( + tree, + 'fsevents', + 'package.json', + ['optionalDependencies'] + ); + expect(version).toBe('^2.3.2'); + }); + + it('should check multiple sections in order', () => { + writeJson(tree, 'package.json', { + devDependencies: { jest: '^29.0.0' }, + peerDependencies: { react: '^18.0.0' }, + }); + + const jestVersion = getDependencyVersionFromPackageJson( + tree, + 'jest', + 'package.json', + ['dependencies', 'devDependencies', 'peerDependencies'] + ); + const reactVersion = getDependencyVersionFromPackageJson( + tree, + 'react', + 'package.json', + ['dependencies', 'devDependencies', 'peerDependencies'] + ); + + expect(jestVersion).toBe('^29.0.0'); + expect(reactVersion).toBe('^18.0.0'); + }); + + it('should work with pre-loaded package.json object', () => { + const packageJson: PackageJson = { + name: 'test', + version: '1.0.0', + dependencies: { react: '^18.0.0' }, + devDependencies: { react: '^17.0.0' }, + }; + writeJson(tree, 'package.json', packageJson); + + const version = getDependencyVersionFromPackageJson( + tree, + 'react', + packageJson, + ['devDependencies'] + ); + expect(version).toBe('^17.0.0'); + }); + + describe('with catalog references', () => { + beforeEach(() => { + jest + .spyOn(pacakgeManager, 'detectPackageManager') + .mockReturnValue('pnpm'); + tree.write( + 'pnpm-workspace.yaml', + ` +packages: + - packages/* +catalog: + react: "^18.2.0" + lodash: "^4.17.21" +catalogs: + frontend: + vue: "^3.3.0" +` + ); + }); + + it('should resolve catalog reference for single package', () => { + writeJson(tree, 'package.json', { + dependencies: { react: 'catalog:' }, + }); + + const version = getDependencyVersionFromPackageJson(tree, 'react'); + expect(version).toBe('^18.2.0'); + }); + + it('should resolve named catalog reference', () => { + writeJson(tree, 'package.json', { + dependencies: { vue: 'catalog:frontend' }, + }); + + const version = getDependencyVersionFromPackageJson(tree, 'vue'); + expect(version).toBe('^3.3.0'); + }); + + it('should return null when catalog reference cannot be resolved', () => { + writeJson(tree, 'package.json', { + dependencies: { unknown: 'catalog:' }, + }); + + const version = getDependencyVersionFromPackageJson(tree, 'unknown'); + expect(version).toBeNull(); + }); + + it('should work with pre-loaded package.json', () => { + const packageJson: PackageJson = { + name: 'test', + version: '1.0.0', + dependencies: { react: 'catalog:' }, + }; + writeJson(tree, 'package.json', packageJson); + + const version = getDependencyVersionFromPackageJson( + tree, + 'react', + packageJson + ); + + expect(version).toBe('^18.2.0'); + }); + + it('should resolve catalog reference with section-specific lookup', () => { + writeJson(tree, 'package.json', { + dependencies: { react: 'catalog:' }, + devDependencies: { lodash: 'catalog:' }, + }); + + const reactVersion = getDependencyVersionFromPackageJson( + tree, + 'react', + 'package.json', + ['dependencies'] + ); + const lodashVersion = getDependencyVersionFromPackageJson( + tree, + 'lodash', + 'package.json', + ['devDependencies'] + ); + + expect(reactVersion).toBe('^18.2.0'); + expect(lodashVersion).toBe('^4.17.21'); + }); + }); +}); diff --git a/packages/nx/src/utils/package-json.ts b/packages/nx/src/utils/package-json.ts index 770c839ac4a19..68a6870b729b7 100644 --- a/packages/nx/src/utils/package-json.ts +++ b/packages/nx/src/utils/package-json.ts @@ -1,12 +1,17 @@ +import { execSync } from 'child_process'; import { existsSync, writeFileSync } from 'fs'; import { dirname, join, resolve } from 'path'; +import { dirSync } from 'tmp'; import { NxJsonConfiguration } from '../config/nx-json'; import { ProjectConfiguration, ProjectMetadata, TargetConfiguration, } from '../config/workspace-json-project-json'; +import type { Tree } from '../generators/tree'; +import { readJson } from '../generators/utils/json'; import { mergeTargetConfigurations } from '../project-graph/utils/project-configuration-utils'; +import { getCatalogManager } from './catalog'; import { readJsonFile } from './fileutils'; import { getNxRequirePaths } from './installation-directory'; import { @@ -17,8 +22,7 @@ import { PackageManager, PackageManagerCommands, } from './package-manager'; -import { dirSync } from 'tmp'; -import { execSync } from 'child_process'; +import { workspaceRoot } from './workspace-root'; export interface NxProjectPackageJsonConfiguration extends Partial { @@ -31,6 +35,12 @@ export type MixedPackageGroup = | Record; export type PackageGroup = MixedPackageGroup | ArrayPackageGroup; +export type PackageJsonDependencySection = + | 'dependencies' + | 'devDependencies' + | 'peerDependencies' + | 'optionalDependencies'; + export interface NxMigrationsConfiguration { migrations?: string; packageGroup?: PackageGroup; @@ -390,44 +400,173 @@ export function installPackageToTmp( * Get the resolved version of a dependency from package.json. * * Retrieves a package version and automatically resolves PNPM catalog references - * (e.g., "catalog:default") to their actual version strings. Searches `dependencies` - * first, then falls back to `devDependencies`. + * (e.g., "catalog:default") to their actual version strings. By default, searches + * `dependencies` first, then falls back to `devDependencies`. + * + * **Tree-based usage** (generators and migrations): + * Use when you have a `Tree` object, which is typical in Nx generators and migrations. * * **Filesystem-based usage** (CLI commands and scripts): * Use when reading directly from the filesystem without a `Tree` object. * * @example * ```typescript + * // Tree-based - from root package.json (checks dependencies then devDependencies) + * const reactVersion = getDependencyVersionFromPackageJson(tree, 'react'); + * // Returns: "^18.0.0" (resolves "catalog:default" if present) + * + * // Tree-based - check only dependencies section + * const version = getDependencyVersionFromPackageJson( + * tree, + * 'react', + * 'package.json', + * ['dependencies'] + * ); + * + * // Tree-based - check only devDependencies section + * const version = getDependencyVersionFromPackageJson( + * tree, + * 'jest', + * 'package.json', + * ['devDependencies'] + * ); + * + * // Tree-based - custom lookup order + * const version = getDependencyVersionFromPackageJson( + * tree, + * 'pkg', + * 'package.json', + * ['devDependencies', 'dependencies', 'peerDependencies'] + * ); + * + * // Tree-based - with pre-loaded package.json + * const packageJson = readJson(tree, 'package.json'); + * const version = getDependencyVersionFromPackageJson( + * tree, + * 'react', + * packageJson, + * ['dependencies'] + * ); + * ``` + * + * @example + * ```typescript * // Filesystem-based - from current directory * const reactVersion = getDependencyVersionFromPackageJson('react'); * * // Filesystem-based - with workspace root * const version = getDependencyVersionFromPackageJson('react', '/path/to/workspace'); * - * // Filesystem-based - with specific package.json + * // Filesystem-based - with specific package.json and section * const version = getDependencyVersionFromPackageJson( * 'react', * '/path/to/workspace', - * 'apps/my-app/package.json' + * 'apps/my-app/package.json', + * ['dependencies'] * ); * ``` * - * @returns The resolved version string, or `null` if the package is not found in either dependencies or devDependencies + * @param dependencyLookup Array of dependency sections to check in order. Defaults to ['dependencies', 'devDependencies'] + * @returns The resolved version string, or `null` if the package is not found in any of the specified sections */ +export function getDependencyVersionFromPackageJson( + tree: Tree, + packageName: string, + packageJsonPath?: string, + dependencyLookup?: PackageJsonDependencySection[] +): string | null; +export function getDependencyVersionFromPackageJson( + tree: Tree, + packageName: string, + packageJson?: PackageJson, + dependencyLookup?: PackageJsonDependencySection[] +): string | null; export function getDependencyVersionFromPackageJson( packageName: string, workspaceRootPath?: string, - packageJsonPath?: string + packageJsonPath?: string, + dependencyLookup?: PackageJsonDependencySection[] ): string | null; export function getDependencyVersionFromPackageJson( packageName: string, workspaceRootPath?: string, - packageJson?: PackageJson + packageJson?: PackageJson, + dependencyLookup?: PackageJsonDependencySection[] ): string | null; export function getDependencyVersionFromPackageJson( + treeOrPackageName: Tree | string, + packageNameOrRoot?: string, + packageJsonPathOrObjectOrRoot?: string | PackageJson, + dependencyLookup?: PackageJsonDependencySection[] +): string | null { + if (typeof treeOrPackageName !== 'string') { + return getDependencyVersionFromPackageJsonFromTree( + treeOrPackageName, + packageNameOrRoot!, + packageJsonPathOrObjectOrRoot, + dependencyLookup + ); + } else { + return getDependencyVersionFromPackageJsonFromFileSystem( + treeOrPackageName, + packageNameOrRoot, + packageJsonPathOrObjectOrRoot, + dependencyLookup + ); + } +} + +/** + * Tree-based implementation for getDependencyVersionFromPackageJson + */ +function getDependencyVersionFromPackageJsonFromTree( + tree: Tree, + packageName: string, + packageJsonPathOrObject: string | PackageJson = 'package.json', + dependencyLookup: PackageJsonDependencySection[] = [ + 'dependencies', + 'devDependencies', + ] +): string | null { + let packageJson: PackageJson; + if (typeof packageJsonPathOrObject === 'object') { + packageJson = packageJsonPathOrObject; + } else if (tree.exists(packageJsonPathOrObject)) { + packageJson = readJson(tree, packageJsonPathOrObject); + } else { + return null; + } + + const manager = getCatalogManager(tree.root); + + let version: string | null = null; + for (const section of dependencyLookup) { + const foundVersion = packageJson[section]?.[packageName]; + if (foundVersion) { + version = foundVersion; + break; + } + } + + // Resolve catalog reference if needed + if (version && manager.isCatalogReference(version)) { + version = manager.resolveCatalogReference(tree, packageName, version); + } + + return version; +} + +/** + * Filesystem-based implementation for getDependencyVersionFromPackageJson + */ +function getDependencyVersionFromPackageJsonFromFileSystem( packageName: string, - root: string = process.cwd(), - packageJsonPathOrObject: string | PackageJson = 'package.json' + root: string = workspaceRoot, + packageJsonPathOrObject: string | PackageJson = 'package.json', + dependencyLookup: PackageJsonDependencySection[] = [ + 'dependencies', + 'devDependencies', + ] ): string | null { let packageJson: PackageJson; if (typeof packageJsonPathOrObject === 'object') { @@ -441,13 +580,16 @@ export function getDependencyVersionFromPackageJson( } } - const { getCatalogManager } = require('./catalog'); const manager = getCatalogManager(root); - let version = - packageJson.dependencies?.[packageName] ?? - packageJson.devDependencies?.[packageName] ?? - null; + let version: string | null = null; + for (const section of dependencyLookup) { + const foundVersion = packageJson[section]?.[packageName]; + if (foundVersion) { + version = foundVersion; + break; + } + } // Resolve catalog reference if needed if (version && manager.isCatalogReference(version)) { From 41fddb4894ccd387e715eb7b0d48aac98c0199a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leosvel=20P=C3=A9rez=20Espinosa?= Date: Wed, 15 Oct 2025 18:42:59 +0200 Subject: [PATCH 8/8] fix(core): cleanup and simplify solution --- packages/devkit/src/utils/catalog/errors.ts | 17 --- packages/devkit/src/utils/catalog/index.ts | 18 --- .../src/utils/catalog/manager-factory.ts | 18 +-- packages/devkit/src/utils/catalog/manager.ts | 10 +- .../devkit/src/utils/catalog/pnpm-manager.ts | 110 ++++++--------- packages/devkit/src/utils/catalog/types.ts | 16 --- .../src/utils/catalog/unsupported-manager.ts | 71 ---------- packages/devkit/src/utils/package-json.ts | 96 ++++--------- packages/devkit/src/utils/semver.ts | 19 +-- .../src/rules/dependency-checks.ts | 30 ++-- packages/js/src/release/version-actions.ts | 13 +- .../nx/src/command-line/migrate/migrate.ts | 12 +- .../src/plugins/js/lock-file/pnpm-parser.ts | 2 +- .../js/lock-file/project-graph-pruning.ts | 2 +- packages/nx/src/utils/catalog/errors.ts | 17 --- packages/nx/src/utils/catalog/index.spec.ts | 37 +---- packages/nx/src/utils/catalog/index.ts | 18 --- .../src/utils/catalog/manager-factory.spec.ts | 55 -------- .../nx/src/utils/catalog/manager-factory.ts | 18 +-- packages/nx/src/utils/catalog/manager.ts | 10 +- .../nx/src/utils/catalog/pnpm-manager.spec.ts | 130 +++++------------- packages/nx/src/utils/catalog/pnpm-manager.ts | 110 ++++++--------- packages/nx/src/utils/catalog/types.ts | 16 --- .../src/utils/catalog/unsupported-manager.ts | 71 ---------- packages/nx/src/utils/package-json.ts | 10 +- packages/nx/src/utils/package-manager.ts | 2 +- 26 files changed, 206 insertions(+), 722 deletions(-) delete mode 100644 packages/devkit/src/utils/catalog/errors.ts delete mode 100644 packages/devkit/src/utils/catalog/unsupported-manager.ts delete mode 100644 packages/nx/src/utils/catalog/errors.ts delete mode 100644 packages/nx/src/utils/catalog/manager-factory.spec.ts delete mode 100644 packages/nx/src/utils/catalog/unsupported-manager.ts diff --git a/packages/devkit/src/utils/catalog/errors.ts b/packages/devkit/src/utils/catalog/errors.ts deleted file mode 100644 index 4bd4495616c3a..0000000000000 --- a/packages/devkit/src/utils/catalog/errors.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { CatalogError } from './types'; - -export class CatalogValidationError extends Error { - constructor(public readonly catalogError: CatalogError, message?: string) { - super(message || catalogError.message); - this.name = 'CatalogValidationError'; - } -} - -export class CatalogUnsupportedError extends Error { - constructor(public readonly packageManager: string, operation: string) { - super( - `Tried to ${operation} but Nx doesn't support catalogs for the current package manager (${packageManager})` - ); - this.name = 'CatalogUnsupportedError'; - } -} diff --git a/packages/devkit/src/utils/catalog/index.ts b/packages/devkit/src/utils/catalog/index.ts index 3f8a30025168f..db307c104d859 100644 --- a/packages/devkit/src/utils/catalog/index.ts +++ b/packages/devkit/src/utils/catalog/index.ts @@ -1,6 +1,5 @@ import { readJson, type Tree } from 'nx/src/devkit-exports'; import { getCatalogManager } from './manager-factory'; -import type { CatalogError } from './types'; import type { CatalogManager } from './manager'; export { getCatalogManager }; @@ -20,10 +19,6 @@ export function getCatalogDependenciesFromPackageJson( return catalogDeps; } - if (!manager.supportsCatalogs()) { - return catalogDeps; - } - try { const packageJson = readJson(tree, packageJsonPath); const allDependencies: Record = { @@ -49,16 +44,3 @@ export function getCatalogDependenciesFromPackageJson( return catalogDeps; } - -export function formatCatalogError(error: CatalogError): string { - let message = error.message; - - if (error.suggestions && error.suggestions.length > 0) { - message += '\n\nSuggestions:'; - error.suggestions.forEach((suggestion) => { - message += `\n • ${suggestion}`; - }); - } - - return message; -} diff --git a/packages/devkit/src/utils/catalog/manager-factory.ts b/packages/devkit/src/utils/catalog/manager-factory.ts index 9a883d1655d3c..e9b09f3cce7a2 100644 --- a/packages/devkit/src/utils/catalog/manager-factory.ts +++ b/packages/devkit/src/utils/catalog/manager-factory.ts @@ -1,29 +1,19 @@ import { detectPackageManager } from 'nx/src/devkit-exports'; import type { CatalogManager } from './manager'; import { PnpmCatalogManager } from './pnpm-manager'; -import { - BunCatalogManager, - NpmCatalogManager, - UnknownCatalogManager, - YarnCatalogManager, -} from './unsupported-manager'; /** * Factory function to get the appropriate catalog manager based on the package manager */ -export function getCatalogManager(workspaceRoot: string): CatalogManager { +export function getCatalogManager( + workspaceRoot: string +): CatalogManager | null { const packageManager = detectPackageManager(workspaceRoot); switch (packageManager) { case 'pnpm': return new PnpmCatalogManager(); - case 'npm': - return new NpmCatalogManager(); - case 'yarn': - return new YarnCatalogManager(); - case 'bun': - return new BunCatalogManager(); default: - return new UnknownCatalogManager(); + return null; } } diff --git a/packages/devkit/src/utils/catalog/manager.ts b/packages/devkit/src/utils/catalog/manager.ts index 412a7c1bf43dc..c9170219a5ede 100644 --- a/packages/devkit/src/utils/catalog/manager.ts +++ b/packages/devkit/src/utils/catalog/manager.ts @@ -1,16 +1,12 @@ import type { Tree } from 'nx/src/devkit-exports'; import type { PnpmWorkspaceYaml } from 'nx/src/utils/pnpm-workspace'; -import type { CatalogError, CatalogReference } from './types'; +import type { CatalogReference } from './types'; /** * Interface for catalog managers that handle package manager-specific catalog implementations. */ export interface CatalogManager { readonly name: string; - /** - * Check if this package manager supports catalogs. - */ - supportsCatalogs(): boolean; isCatalogReference(version: string): boolean; @@ -43,12 +39,12 @@ export interface CatalogManager { workspaceRoot: string, packageName: string, version: string - ): { isValid: boolean; error?: CatalogError }; + ): void; validateCatalogReference( tree: Tree, packageName: string, version: string - ): { isValid: boolean; error?: CatalogError }; + ): void; /** * Updates catalog definitions for specified packages in their respective catalogs. diff --git a/packages/devkit/src/utils/catalog/pnpm-manager.ts b/packages/devkit/src/utils/catalog/pnpm-manager.ts index f17161cf669ae..508cdb0f40c7a 100644 --- a/packages/devkit/src/utils/catalog/pnpm-manager.ts +++ b/packages/devkit/src/utils/catalog/pnpm-manager.ts @@ -8,11 +8,7 @@ import type { PnpmWorkspaceYaml, } from 'nx/src/utils/pnpm-workspace'; import type { CatalogManager } from './manager'; -import { - type CatalogError, - CatalogErrorType, - type CatalogReference, -} from './types'; +import { type CatalogReference } from './types'; /** * PNPM-specific catalog manager implementation @@ -21,10 +17,6 @@ export class PnpmCatalogManager implements CatalogManager { readonly name = 'pnpm'; readonly catalogProtocol = 'catalog:'; - supportsCatalogs(): boolean { - return true; - } - isCatalogReference(version: string): boolean { return version.startsWith(this.catalogProtocol); } @@ -90,30 +82,22 @@ export class PnpmCatalogManager implements CatalogManager { treeOrRoot: Tree | string, packageName: string, version: string - ): { isValid: boolean; error?: CatalogError } { + ): void { const catalogRef = this.parseCatalogReference(version); if (!catalogRef) { - return { - isValid: false, - error: { - type: CatalogErrorType.INVALID_SYNTAX, - message: `Invalid catalog reference syntax: "${version}". Expected format: "catalog:" or "catalog:name"`, - }, - }; + throw new Error( + `Invalid catalog reference syntax: "${version}". Expected format: "catalog:" or "catalog:name"` + ); } const workspaceConfig = this.getCatalogDefinitions(treeOrRoot); if (!workspaceConfig) { - return { - isValid: false, - error: { - type: CatalogErrorType.WORKSPACE_NOT_FOUND, - message: 'No pnpm-workspace.yaml found in workspace root', - suggestions: [ - 'Create a pnpm-workspace.yaml file in your workspace root', - ], - }, - }; + throw new Error( + formatCatalogError( + 'Cannot get Pnpm Catalog definitions. No pnpm-workspace.yaml found in workspace root.', + ['Create a pnpm-workspace.yaml file in your workspace root'] + ) + ); } let catalogToUse: PnpmCatalogEntry | undefined; @@ -124,15 +108,9 @@ export class PnpmCatalogManager implements CatalogManager { // Error if both defined (matches pnpm behavior) if (hasCatalog && hasCatalogsDefault) { - return { - isValid: false, - error: { - type: CatalogErrorType.INVALID_CATALOGS_CONFIGURATION, - message: - "The 'default' catalog was defined multiple times. Use the 'catalog' field or 'catalogs.default', but not both.", - suggestions: [], - }, - }; + throw new Error( + "The 'default' catalog was defined multiple times. Use the 'catalog' field or 'catalogs.default', but not both." + ); } catalogToUse = @@ -151,14 +129,12 @@ export class PnpmCatalogManager implements CatalogManager { ); } - return { - isValid: false, - error: { - type: CatalogErrorType.CATALOG_NOT_FOUND, - message: 'No default catalog defined in pnpm-workspace.yaml', - suggestions, - }, - }; + throw new Error( + formatCatalogError( + 'No default catalog defined in pnpm-workspace.yaml', + suggestions + ) + ); } } else if (catalogRef.catalogName) { catalogToUse = workspaceConfig.catalogs?.[catalogRef.catalogName]; @@ -186,15 +162,12 @@ export class PnpmCatalogManager implements CatalogManager { suggestions.push(`Or use the default catalog ("${defaultCatalog}")`); } - return { - isValid: false, - error: { - type: CatalogErrorType.CATALOG_NOT_FOUND, - message: `Catalog "${catalogRef.catalogName}" not found in pnpm-workspace.yaml`, - catalogName: catalogRef.catalogName, - suggestions, - }, - }; + throw new Error( + formatCatalogError( + `Catalog "${catalogRef.catalogName}" not found in pnpm-workspace.yaml`, + suggestions + ) + ); } } @@ -222,19 +195,13 @@ export class PnpmCatalogManager implements CatalogManager { ); } - return { - isValid: false, - error: { - type: CatalogErrorType.PACKAGE_NOT_FOUND, - message: `Package "${packageName}" not found in ${catalogName}`, - packageName, - catalogName: catalogRef.catalogName, - suggestions, - }, - }; + throw new Error( + formatCatalogError( + `Package "${packageName}" not found in ${catalogName}`, + suggestions + ) + ); } - - return { isValid: true }; } updateCatalogVersions( @@ -352,3 +319,16 @@ function readYamlFileFromTree(tree: Tree, path: string): PnpmWorkspaceYaml { return null; } } + +function formatCatalogError(error: string, suggestions: string[]): string { + let message = error; + + if (suggestions && suggestions.length > 0) { + message += '\n\nSuggestions:'; + suggestions.forEach((suggestion) => { + message += `\n • ${suggestion}`; + }); + } + + return message; +} diff --git a/packages/devkit/src/utils/catalog/types.ts b/packages/devkit/src/utils/catalog/types.ts index 2b42cfff4e2d6..f7a804dcb5705 100644 --- a/packages/devkit/src/utils/catalog/types.ts +++ b/packages/devkit/src/utils/catalog/types.ts @@ -2,19 +2,3 @@ export interface CatalogReference { catalogName?: string; isDefaultCatalog: boolean; } - -export enum CatalogErrorType { - INVALID_SYNTAX = 'INVALID_SYNTAX', - WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND', - CATALOG_NOT_FOUND = 'CATALOG_NOT_FOUND', - PACKAGE_NOT_FOUND = 'PACKAGE_NOT_FOUND', - INVALID_CATALOGS_CONFIGURATION = 'INVALID_CATALOGS_CONFIGURATION', -} - -export interface CatalogError { - type: CatalogErrorType; - message: string; - catalogName?: string; - packageName?: string; - suggestions?: string[]; -} diff --git a/packages/devkit/src/utils/catalog/unsupported-manager.ts b/packages/devkit/src/utils/catalog/unsupported-manager.ts deleted file mode 100644 index 64ee99d078963..0000000000000 --- a/packages/devkit/src/utils/catalog/unsupported-manager.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { Tree } from 'nx/src/devkit-exports'; -import type { PnpmWorkspaceYaml } from 'nx/src/utils/pnpm-workspace'; -import { CatalogUnsupportedError } from './errors'; -import type { CatalogManager } from './manager'; -import type { CatalogError, CatalogReference } from './types'; - -/** - * Base catalog manager for package managers that don't support catalogs - */ -abstract class UnsupportedCatalogManager implements CatalogManager { - abstract readonly name: string; - - supportsCatalogs(): boolean { - return false; - } - - isCatalogReference(_version: string): boolean { - return false; - } - - parseCatalogReference(_version: string): CatalogReference | null { - return null; - } - - getCatalogDefinitions(_treeOrRoot: Tree | string): PnpmWorkspaceYaml | null { - throw new CatalogUnsupportedError(this.name, 'get catalog definitions'); - } - - resolveCatalogReference( - _treeOrRoot: Tree | string, - _packageName: string, - _version: string - ): string | null { - throw new CatalogUnsupportedError(this.name, 'resolve catalog references'); - } - - validateCatalogReference( - _treeOrRoot: Tree | string, - _packageName: string, - _version: string - ): { isValid: boolean; error?: CatalogError } { - throw new CatalogUnsupportedError(this.name, 'validate catalog references'); - } - - updateCatalogVersions( - _treeOrRoot: Tree | string, - _updates: Array<{ - packageName: string; - version: string; - catalogName?: string; - }> - ): void { - throw new CatalogUnsupportedError(this.name, 'update catalog versions'); - } -} - -export class YarnCatalogManager extends UnsupportedCatalogManager { - readonly name = 'yarn'; -} - -export class BunCatalogManager extends UnsupportedCatalogManager { - readonly name = 'bun'; -} - -export class NpmCatalogManager extends UnsupportedCatalogManager { - readonly name = 'npm'; -} - -export class UnknownCatalogManager extends UnsupportedCatalogManager { - readonly name = 'unknown'; -} diff --git a/packages/devkit/src/utils/package-json.ts b/packages/devkit/src/utils/package-json.ts index 0b03c3d46ef27..6903a66257eed 100644 --- a/packages/devkit/src/utils/package-json.ts +++ b/packages/devkit/src/utils/package-json.ts @@ -173,8 +173,6 @@ function getDependencyVersionFromPackageJsonFromTree( return null; } - const manager = getCatalogManager(tree.root); - let version: string | null = null; for (const section of dependencyLookup) { const foundVersion = packageJson[section]?.[packageName]; @@ -185,7 +183,8 @@ function getDependencyVersionFromPackageJsonFromTree( } // Resolve catalog reference if needed - if (version && manager.isCatalogReference(version)) { + const manager = getCatalogManager(tree.root); + if (version && manager?.isCatalogReference(version)) { version = manager.resolveCatalogReference(tree, packageName, version); } @@ -216,8 +215,6 @@ function getDependencyVersionFromPackageJsonFromFileSystem( } } - const manager = getCatalogManager(root); - let version: string | null = null; for (const section of dependencyLookup) { const foundVersion = packageJson[section]?.[packageName]; @@ -228,7 +225,8 @@ function getDependencyVersionFromPackageJsonFromFileSystem( } // Resolve catalog reference if needed - if (version && manager.isCatalogReference(version)) { + const manager = getCatalogManager(root); + if (version && manager?.isCatalogReference(version)) { version = manager.resolveCatalogReference(packageName, version, root); } @@ -250,7 +248,7 @@ function filterExistingDependencies( function cleanSemver(tree: Tree, version: string, packageName: string) { const manager = getCatalogManager(tree.root); - if (manager.isCatalogReference(version)) { + if (manager?.isCatalogReference(version)) { const resolvedVersion = manager.resolveCatalogReference( tree, packageName, @@ -276,12 +274,7 @@ function isIncomingVersionGreater( // it if that's the case let resolvedExistingVersion = existingVersion; const manager = getCatalogManager(tree.root); - if (manager.isCatalogReference(existingVersion)) { - if (!manager.supportsCatalogs()) { - // If catalog is unsupported, we assume the incoming version is newer - return true; - } - + if (manager?.isCatalogReference(existingVersion)) { const resolved = manager.resolveCatalogReference( tree, packageName, @@ -534,6 +527,14 @@ function splitDependenciesByCatalogType( let directDevDependencies = { ...filteredDevDependencies }; const manager = getCatalogManager(tree.root); + if (!manager) { + return { + catalogUpdates: [], + directDependencies: filteredDependencies, + directDevDependencies: filteredDevDependencies, + }; + } + const existingCatalogDeps = getCatalogDependenciesFromPackageJson( tree, packageJsonPath, @@ -547,84 +548,35 @@ function splitDependenciesByCatalogType( }; } - const supportsCatalogs = manager.supportsCatalogs(); - // Check filtered results for catalog references or existing catalog dependencies for (const [packageName, version] of Object.entries(allFilteredUpdates)) { if (!existingCatalogDeps.has(packageName)) { continue; } - let shouldUseCatalog = false; - let catalogName: string | undefined; - - if (!supportsCatalogs) { - // we're trying to update the version of a package that has a catalog reference - // but Nx does not support catalogs for this package manager, we warn the user - // and update the dependencies directly to package.json to keep the existing - // behavior - output.warn({ - title: 'Nx does not support catalogs for this package manager', - bodyLines: [ - 'Dependencies will be added directly to package.json and might override catalog dependencies.', - ], - }); - - // bail out early since we'll add the dependencies directly to package.json - return { - catalogUpdates: [], - directDependencies: filteredDependencies, - directDevDependencies: filteredDevDependencies, - }; - } - - catalogName = existingCatalogDeps.get(packageName)!; + let catalogName = existingCatalogDeps.get(packageName)!; const catalogRef = catalogName ? `catalog:${catalogName}` : 'catalog:'; try { - const manager = getCatalogManager(tree.root); - const { isValid, error } = manager.validateCatalogReference( - tree, - packageName, - catalogRef - ); + manager.validateCatalogReference(tree, packageName, catalogRef); - if (isValid) { - shouldUseCatalog = true; - } else { - output.error({ - title: 'Invalid catalog reference', - bodyLines: [ - `Invalid catalog reference "${catalogRef}" for package "${packageName}".`, - ...(error?.message ? [error.message] : []), - ...(error?.suggestions || []), - ], - }); - throw new Error( - `Could not update "${packageName}" to version "${version}". See above for more details.` - ); - } + catalogUpdates.push({ packageName, version, catalogName }); + + // Remove from direct updates since this will be handled via catalog + delete directDependencies[packageName]; + delete directDevDependencies[packageName]; } catch (error) { output.error({ - title: 'Could not update catalog dependency', + title: 'Invalid catalog reference', bodyLines: [ - `Unexpected error while updating catalog reference "${catalogRef}" for package "${packageName}".`, - ...(error?.message ? [error.message] : []), - ...(error?.stack ? [error.stack] : []), + `Invalid catalog reference "${catalogRef}" for package "${packageName}".`, + error.message, ], }); throw new Error( `Could not update "${packageName}" to version "${version}". See above for more details.` ); } - - if (shouldUseCatalog) { - catalogUpdates.push({ packageName, version, catalogName }); - - // Remove from direct updates since this will be handled via catalog - delete directDependencies[packageName]; - delete directDevDependencies[packageName]; - } } return { catalogUpdates, directDependencies, directDevDependencies }; diff --git a/packages/devkit/src/utils/semver.ts b/packages/devkit/src/utils/semver.ts index 372f3d6fb304d..1c33604decd93 100644 --- a/packages/devkit/src/utils/semver.ts +++ b/packages/devkit/src/utils/semver.ts @@ -1,6 +1,6 @@ import { workspaceRoot, type Tree } from 'nx/src/devkit-exports'; import { valid } from 'semver'; -import { formatCatalogError, getCatalogManager } from './catalog'; +import { getCatalogManager } from './catalog'; export function checkAndCleanWithSemver( pkgName: string, @@ -24,15 +24,16 @@ export function checkAndCleanWithSemver( typeof treeOrPkgName === 'string' ? pkgNameOrVersion : version!; const manager = getCatalogManager(root); - if (manager.isCatalogReference(newVersion)) { - const validation = tree - ? manager.validateCatalogReference(tree, pkgName, newVersion) - : manager.validateCatalogReference(root, pkgName, newVersion); - if (!validation.isValid) { + if (manager?.isCatalogReference(newVersion)) { + try { + if (tree) { + manager.validateCatalogReference(tree, pkgName, newVersion); + } else { + manager.validateCatalogReference(root, pkgName, newVersion); + } + } catch (error) { throw new Error( - `The catalog reference for ${pkgName} is invalid - (${newVersion})\n${formatCatalogError( - validation.error! - )}` + `The catalog reference for ${pkgName} is invalid - (${newVersion})\n${error.message}` ); } diff --git a/packages/eslint-plugin/src/rules/dependency-checks.ts b/packages/eslint-plugin/src/rules/dependency-checks.ts index 7aa20a10d0a77..2ed5d7ff85652 100644 --- a/packages/eslint-plugin/src/rules/dependency-checks.ts +++ b/packages/eslint-plugin/src/rules/dependency-checks.ts @@ -1,8 +1,5 @@ import { NX_VERSION, normalizePath, workspaceRoot } from '@nx/devkit'; -import { - formatCatalogError, - getCatalogManager, -} from '@nx/devkit/src/utils/catalog'; +import { getCatalogManager } from '@nx/devkit/src/utils/catalog'; import { findNpmDependencies } from '@nx/js/src/utils/find-npm-dependencies'; import { ESLintUtils } from '@typescript-eslint/utils'; import { AST } from 'jsonc-eslint-parser'; @@ -217,23 +214,27 @@ export default ESLintUtils.RuleCreator( packageRange: string ) { const manager = getCatalogManager(workspaceRoot); - if (!manager.isCatalogReference(packageRange)) { + if (!manager) { return; } - const validationResult = manager.validateCatalogReference( - workspaceRoot, - packageName, - packageRange - ); + if (!manager.isCatalogReference(packageRange)) { + return; + } - if (!validationResult.isValid) { + try { + manager.validateCatalogReference( + workspaceRoot, + packageName, + packageRange + ); + } catch (error) { context.report({ node: node as any, messageId: 'invalidCatalogReference', data: { packageName: packageName, - error: formatCatalogError(validationResult.error!), + error: error.message, }, }); } @@ -252,10 +253,7 @@ export default ESLintUtils.RuleCreator( let resolvedPackageRange = packageRange; const manager = getCatalogManager(workspaceRoot); - if ( - manager.supportsCatalogs() && - manager.isCatalogReference(packageRange) - ) { + if (manager?.isCatalogReference(packageRange)) { const resolved = manager.resolveCatalogReference( workspaceRoot, packageName, diff --git a/packages/js/src/release/version-actions.ts b/packages/js/src/release/version-actions.ts index 4a9a7adfe465b..144be6053dc96 100644 --- a/packages/js/src/release/version-actions.ts +++ b/packages/js/src/release/version-actions.ts @@ -172,10 +172,7 @@ export default class JsVersionActions extends VersionActions { // Resolve catalog references if needed if (currentVersion) { const catalogManager = getCatalogManager(tree.root); - if ( - catalogManager.supportsCatalogs() && - catalogManager.isCatalogReference(currentVersion) - ) { + if (catalogManager?.isCatalogReference(currentVersion)) { currentVersion = catalogManager.resolveCatalogReference( tree, dependencyPackageName, @@ -256,10 +253,7 @@ export default class JsVersionActions extends VersionActions { } const currentVersion = json[depType][packageName]; if (currentVersion) { - if ( - catalogManager.supportsCatalogs() && - catalogManager.isCatalogReference(currentVersion) - ) { + if (catalogManager?.isCatalogReference(currentVersion)) { // collect the catalog updates so we can update the catalog definitions later const catalogRef = catalogManager.parseCatalogReference(currentVersion)!; @@ -321,7 +315,8 @@ export default class JsVersionActions extends VersionActions { // Update catalog definitions in pnpm-workspace.yaml if (catalogUpdates.length > 0) { - catalogManager.updateCatalogVersions(tree, catalogUpdates); + // catalogManager is guaranteed to be defined when there are catalog updates + catalogManager!.updateCatalogVersions(tree, catalogUpdates); const catalogText = catalogUpdates.length === 1 ? 'entry' : 'entries'; logMessages.push( diff --git a/packages/nx/src/command-line/migrate/migrate.ts b/packages/nx/src/command-line/migrate/migrate.ts index bf1a94aad735a..f50cca1602941 100644 --- a/packages/nx/src/command-line/migrate/migrate.ts +++ b/packages/nx/src/command-line/migrate/migrate.ts @@ -1216,17 +1216,12 @@ async function updatePackageJson( const json = readJsonFile(packageJsonPath, parseOptions); const manager = getCatalogManager(root); - const supportsCatalogs = manager.supportsCatalogs(); const catalogUpdates = []; Object.keys(updatedPackages).forEach((p) => { const existingVersion = json.dependencies?.[p] ?? json.devDependencies?.[p]; - if ( - supportsCatalogs && - existingVersion && - manager.isCatalogReference(existingVersion) - ) { + if (existingVersion && manager?.isCatalogReference(existingVersion)) { const { catalogName } = manager.parseCatalogReference(existingVersion); catalogUpdates.push({ packageName: p, @@ -1261,8 +1256,9 @@ async function updatePackageJson( }); // Update catalog definitions - if (catalogUpdates.length && supportsCatalogs) { - manager.updateCatalogVersions(root, catalogUpdates); + if (catalogUpdates.length) { + // manager is guaranteed to be defined when there are catalog updates + manager!.updateCatalogVersions(root, catalogUpdates); } } diff --git a/packages/nx/src/plugins/js/lock-file/pnpm-parser.ts b/packages/nx/src/plugins/js/lock-file/pnpm-parser.ts index 45c50c02ce635..71dd6e2ea05b2 100644 --- a/packages/nx/src/plugins/js/lock-file/pnpm-parser.ts +++ b/packages/nx/src/plugins/js/lock-file/pnpm-parser.ts @@ -602,7 +602,7 @@ function mapRootSnapshot( Object.keys(packageJson[depType]).forEach((packageName) => { let version = packageJson[depType][packageName]; const manager = getCatalogManager(workspaceRoot); - if (manager.isCatalogReference(version)) { + if (manager?.isCatalogReference(version)) { version = manager.resolveCatalogReference( packageName, version, diff --git a/packages/nx/src/plugins/js/lock-file/project-graph-pruning.ts b/packages/nx/src/plugins/js/lock-file/project-graph-pruning.ts index 89f14c539e98e..f41bf03ec85a9 100644 --- a/packages/nx/src/plugins/js/lock-file/project-graph-pruning.ts +++ b/packages/nx/src/plugins/js/lock-file/project-graph-pruning.ts @@ -74,7 +74,7 @@ function normalizeDependencies( ([packageName, versionRange]) => { let resolvedVersionRange = versionRange; const manager = getCatalogManager(workspaceRootPath); - if (manager.isCatalogReference(versionRange)) { + if (manager?.isCatalogReference(versionRange)) { const resolvedVersionRange = manager.resolveCatalogReference( packageName, versionRange, diff --git a/packages/nx/src/utils/catalog/errors.ts b/packages/nx/src/utils/catalog/errors.ts deleted file mode 100644 index 4bd4495616c3a..0000000000000 --- a/packages/nx/src/utils/catalog/errors.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { CatalogError } from './types'; - -export class CatalogValidationError extends Error { - constructor(public readonly catalogError: CatalogError, message?: string) { - super(message || catalogError.message); - this.name = 'CatalogValidationError'; - } -} - -export class CatalogUnsupportedError extends Error { - constructor(public readonly packageManager: string, operation: string) { - super( - `Tried to ${operation} but Nx doesn't support catalogs for the current package manager (${packageManager})` - ); - this.name = 'CatalogUnsupportedError'; - } -} diff --git a/packages/nx/src/utils/catalog/index.spec.ts b/packages/nx/src/utils/catalog/index.spec.ts index 80ca1e3813070..3b40d625b98f9 100644 --- a/packages/nx/src/utils/catalog/index.spec.ts +++ b/packages/nx/src/utils/catalog/index.spec.ts @@ -1,14 +1,9 @@ import { createTreeWithEmptyWorkspace } from '../../generators/testing-utils/create-tree-with-empty-workspace'; import type { Tree } from '../../generators/tree'; import { writeJson } from '../../generators/utils/json'; -import { - formatCatalogError, - getCatalogDependenciesFromPackageJson, -} from './index'; +import { getCatalogDependenciesFromPackageJson } from './index'; import type { CatalogManager } from './manager'; import { PnpmCatalogManager } from './pnpm-manager'; -import { CatalogErrorType } from './types'; -import { NpmCatalogManager } from './unsupported-manager'; describe('package manager catalogs', () => { let tree: Tree; @@ -34,16 +29,6 @@ describe('package manager catalogs', () => { expect(result).toStrictEqual(new Map()); }); - it('should return empty map when manager does not support catalogs', () => { - const result = getCatalogDependenciesFromPackageJson( - tree, - 'package.json', - new NpmCatalogManager() - ); - - expect(result).toStrictEqual(new Map()); - }); - it('should return empty map when package.json cannot be read', () => { tree.write('package.json', 'invalid: json: content: ['); @@ -114,24 +99,4 @@ catalogs: ); }); }); - - describe('formatCatalogError', () => { - it('should format error with suggestions', () => { - const error = { - type: CatalogErrorType.CATALOG_NOT_FOUND, - message: 'Catalog not found', - suggestions: ['Try catalog:dev', 'Try catalog:prod'], - }; - - const result = formatCatalogError(error); - - expect(result).toMatchInlineSnapshot(` - "Catalog not found - - Suggestions: - • Try catalog:dev - • Try catalog:prod" - `); - }); - }); }); diff --git a/packages/nx/src/utils/catalog/index.ts b/packages/nx/src/utils/catalog/index.ts index a248cd72935a1..c8b4d39079ba0 100644 --- a/packages/nx/src/utils/catalog/index.ts +++ b/packages/nx/src/utils/catalog/index.ts @@ -2,7 +2,6 @@ import type { Tree } from '../../generators/tree'; import { readJson } from '../../generators/utils/json'; import type { CatalogManager } from './manager'; import { getCatalogManager } from './manager-factory'; -import type { CatalogError } from './types'; export { getCatalogManager }; @@ -21,10 +20,6 @@ export function getCatalogDependenciesFromPackageJson( return catalogDeps; } - if (!manager.supportsCatalogs()) { - return catalogDeps; - } - try { const packageJson = readJson(tree, packageJsonPath); const allDependencies: Record = { @@ -50,16 +45,3 @@ export function getCatalogDependenciesFromPackageJson( return catalogDeps; } - -export function formatCatalogError(error: CatalogError): string { - let message = error.message; - - if (error.suggestions && error.suggestions.length > 0) { - message += '\n\nSuggestions:'; - error.suggestions.forEach((suggestion) => { - message += `\n • ${suggestion}`; - }); - } - - return message; -} diff --git a/packages/nx/src/utils/catalog/manager-factory.spec.ts b/packages/nx/src/utils/catalog/manager-factory.spec.ts deleted file mode 100644 index 2a501640073c9..0000000000000 --- a/packages/nx/src/utils/catalog/manager-factory.spec.ts +++ /dev/null @@ -1,55 +0,0 @@ -import * as packageManager from '../package-manager'; -import { getCatalogManager } from './manager-factory'; -import { PnpmCatalogManager } from './pnpm-manager'; -import { - BunCatalogManager, - NpmCatalogManager, - YarnCatalogManager, -} from './unsupported-manager'; - -describe('getCatalogManager', () => { - const mockDetectPackageManager = jest.spyOn( - packageManager, - 'detectPackageManager' - ); - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('should return PnpmCatalogManager for pnpm', () => { - mockDetectPackageManager.mockReturnValue('pnpm'); - - const manager = getCatalogManager('/test'); - - expect(manager).toBeInstanceOf(PnpmCatalogManager); - expect(manager.supportsCatalogs()).toBe(true); - }); - - it('should return NpmCatalogManager for npm', () => { - mockDetectPackageManager.mockReturnValue('npm'); - - const manager = getCatalogManager('/test'); - - expect(manager).toBeInstanceOf(NpmCatalogManager); - expect(manager.supportsCatalogs()).toBe(false); - }); - - it('should return YarnCatalogManager for yarn', () => { - mockDetectPackageManager.mockReturnValue('yarn'); - - const manager = getCatalogManager('/test'); - - expect(manager).toBeInstanceOf(YarnCatalogManager); - expect(manager.supportsCatalogs()).toBe(false); - }); - - it('should return BunCatalogManager for bun', () => { - mockDetectPackageManager.mockReturnValue('bun'); - - const manager = getCatalogManager('/test'); - - expect(manager).toBeInstanceOf(BunCatalogManager); - expect(manager.supportsCatalogs()).toBe(false); - }); -}); diff --git a/packages/nx/src/utils/catalog/manager-factory.ts b/packages/nx/src/utils/catalog/manager-factory.ts index f585c7d01668a..b29ed46f2fc65 100644 --- a/packages/nx/src/utils/catalog/manager-factory.ts +++ b/packages/nx/src/utils/catalog/manager-factory.ts @@ -1,29 +1,19 @@ import { detectPackageManager } from '../package-manager'; import type { CatalogManager } from './manager'; import { PnpmCatalogManager } from './pnpm-manager'; -import { - BunCatalogManager, - NpmCatalogManager, - UnknownCatalogManager, - YarnCatalogManager, -} from './unsupported-manager'; /** * Factory function to get the appropriate catalog manager based on the package manager */ -export function getCatalogManager(workspaceRoot: string): CatalogManager { +export function getCatalogManager( + workspaceRoot: string +): CatalogManager | null { const packageManager = detectPackageManager(workspaceRoot); switch (packageManager) { case 'pnpm': return new PnpmCatalogManager(); - case 'npm': - return new NpmCatalogManager(); - case 'yarn': - return new YarnCatalogManager(); - case 'bun': - return new BunCatalogManager(); default: - return new UnknownCatalogManager(); + return null; } } diff --git a/packages/nx/src/utils/catalog/manager.ts b/packages/nx/src/utils/catalog/manager.ts index f002690eac368..40e29b1fd0935 100644 --- a/packages/nx/src/utils/catalog/manager.ts +++ b/packages/nx/src/utils/catalog/manager.ts @@ -1,16 +1,12 @@ import type { Tree } from '../../generators/tree'; import type { PnpmWorkspaceYaml } from '../pnpm-workspace'; -import type { CatalogError, CatalogReference } from './types'; +import type { CatalogReference } from './types'; /** * Interface for catalog managers that handle package manager-specific catalog implementations. */ export interface CatalogManager { readonly name: string; - /** - * Check if this package manager supports catalogs. - */ - supportsCatalogs(): boolean; isCatalogReference(version: string): boolean; @@ -43,12 +39,12 @@ export interface CatalogManager { workspaceRoot: string, packageName: string, version: string - ): { isValid: boolean; error?: CatalogError }; + ): void; validateCatalogReference( tree: Tree, packageName: string, version: string - ): { isValid: boolean; error?: CatalogError }; + ): void; /** * Updates catalog definitions for specified packages in their respective catalogs. diff --git a/packages/nx/src/utils/catalog/pnpm-manager.spec.ts b/packages/nx/src/utils/catalog/pnpm-manager.spec.ts index 6da9ec73a8ba2..ec07b3d795ed5 100644 --- a/packages/nx/src/utils/catalog/pnpm-manager.spec.ts +++ b/packages/nx/src/utils/catalog/pnpm-manager.spec.ts @@ -2,7 +2,6 @@ import { load } from '@zkochan/js-yaml'; import { createTreeWithEmptyWorkspace } from '../../generators/testing-utils/create-tree-with-empty-workspace'; import type { Tree } from '../../generators/tree'; import { PnpmCatalogManager } from './pnpm-manager'; -import { CatalogErrorType } from './types'; describe('PnpmCatalogManager', () => { let tree: Tree; @@ -232,21 +231,19 @@ catalogs: describe('validateCatalogReference', () => { it('should return invalid for non-catalog syntax', () => { - const result = manager.validateCatalogReference(tree, 'react', '^18.0.0'); - - expect(result.isValid).toBe(false); - expect(result.error!.type).toBe(CatalogErrorType.INVALID_SYNTAX); + expect(() => + manager.validateCatalogReference(tree, 'react', '^18.0.0') + ).toThrow( + 'Invalid catalog reference syntax: "^18.0.0". Expected format: "catalog:" or "catalog:name"' + ); }); it('should return invalid when workspace file not found', () => { - const result = manager.validateCatalogReference( - tree, - 'react', - 'catalog:' + expect(() => + manager.validateCatalogReference(tree, 'react', 'catalog:') + ).toThrow( + 'Cannot get Pnpm catalog definitions. No pnpm-workspace.yaml found in workspace root.' ); - - expect(result.isValid).toBe(false); - expect(result.error!.type).toBe(CatalogErrorType.WORKSPACE_NOT_FOUND); }); it('should return error for catalog: when both definitions exist', () => { @@ -261,18 +258,10 @@ catalogs: ` ); - const result = manager.validateCatalogReference( - tree, - 'react', - 'catalog:' - ); - - expect(result.isValid).toBe(false); - expect(result.error!.type).toBe( - CatalogErrorType.INVALID_CATALOGS_CONFIGURATION - ); - expect(result.error!.message).toContain( - "The 'default' catalog was defined multiple times" + expect(() => + manager.validateCatalogReference(tree, 'react', 'catalog:') + ).toThrow( + "The 'default' catalog was defined multiple times. Use the 'catalog' field or 'catalogs.default', but not both." ); }); @@ -288,18 +277,10 @@ catalogs: ` ); - const result = manager.validateCatalogReference( - tree, - 'react', - 'catalog:default' - ); - - expect(result.isValid).toBe(false); - expect(result.error!.type).toBe( - CatalogErrorType.INVALID_CATALOGS_CONFIGURATION - ); - expect(result.error!.message).toContain( - "The 'default' catalog was defined multiple times" + expect(() => + manager.validateCatalogReference(tree, 'react', 'catalog:default') + ).toThrow( + "The 'default' catalog was defined multiple times. Use the 'catalog' field or 'catalogs.default', but not both." ); }); @@ -312,14 +293,9 @@ packages: ` ); - const result = manager.validateCatalogReference( - tree, - 'react', - 'catalog:' - ); - - expect(result.isValid).toBe(false); - expect(result.error!.type).toBe(CatalogErrorType.CATALOG_NOT_FOUND); + expect(() => + manager.validateCatalogReference(tree, 'react', 'catalog:') + ).toThrow('No default catalog defined in pnpm-workspace.yaml'); }); it('should return invalid when named catalog not found', () => { @@ -331,14 +307,9 @@ packages: ` ); - const result = manager.validateCatalogReference( - tree, - 'react', - 'catalog:non-existent' - ); - - expect(result.isValid).toBe(false); - expect(result.error!.type).toBe(CatalogErrorType.CATALOG_NOT_FOUND); + expect(() => + manager.validateCatalogReference(tree, 'react', 'catalog:non-existent') + ).toThrow('Catalog "non-existent" not found in pnpm-workspace.yaml'); }); it('should return invalid for a missing package in catalog', () => { @@ -352,14 +323,9 @@ catalog: ` ); - const result = manager.validateCatalogReference( - tree, - 'lodash', - 'catalog:' - ); - - expect(result.isValid).toBe(false); - expect(result.error!.type).toBe(CatalogErrorType.PACKAGE_NOT_FOUND); + expect(() => + manager.validateCatalogReference(tree, 'lodash', 'catalog:') + ).toThrow('Package "lodash" not found in default catalog ("catalog")'); }); it('should return valid for existing catalog entry in top-level catalog', () => { @@ -371,14 +337,9 @@ catalog: ` ); - const result = manager.validateCatalogReference( - tree, - 'react', - 'catalog:' - ); - - expect(result.isValid).toBe(true); - expect(result.error).toBeUndefined(); + expect(() => + manager.validateCatalogReference(tree, 'react', 'catalog:') + ).not.toThrow(); }); it('should validate catalog:default with top-level catalog field', () => { @@ -390,14 +351,9 @@ catalog: ` ); - const result = manager.validateCatalogReference( - tree, - 'react', - 'catalog:default' - ); - - expect(result.isValid).toBe(true); - expect(result.error).toBeUndefined(); + expect(() => + manager.validateCatalogReference(tree, 'react', 'catalog:default') + ).not.toThrow(); }); it('should validate catalog: with catalogs.default field', () => { @@ -410,14 +366,9 @@ catalogs: ` ); - const result = manager.validateCatalogReference( - tree, - 'react', - 'catalog:' - ); - - expect(result.isValid).toBe(true); - expect(result.error).toBeUndefined(); + expect(() => + manager.validateCatalogReference(tree, 'react', 'catalog:') + ).not.toThrow(); }); it('should validate catalog:default with catalogs.default field', () => { @@ -430,14 +381,9 @@ catalogs: ` ); - const result = manager.validateCatalogReference( - tree, - 'react', - 'catalog:default' - ); - - expect(result.isValid).toBe(true); - expect(result.error).toBeUndefined(); + expect(() => + manager.validateCatalogReference(tree, 'react', 'catalog:default') + ).not.toThrow(); }); }); diff --git a/packages/nx/src/utils/catalog/pnpm-manager.ts b/packages/nx/src/utils/catalog/pnpm-manager.ts index 63c3a56c44637..7087497f690b2 100644 --- a/packages/nx/src/utils/catalog/pnpm-manager.ts +++ b/packages/nx/src/utils/catalog/pnpm-manager.ts @@ -6,11 +6,7 @@ import { readYamlFile } from '../fileutils'; import { output } from '../output'; import type { PnpmCatalogEntry, PnpmWorkspaceYaml } from '../pnpm-workspace'; import type { CatalogManager } from './manager'; -import { - type CatalogError, - CatalogErrorType, - type CatalogReference, -} from './types'; +import type { CatalogReference } from './types'; /** * PNPM-specific catalog manager implementation @@ -19,10 +15,6 @@ export class PnpmCatalogManager implements CatalogManager { readonly name = 'pnpm'; readonly catalogProtocol = 'catalog:'; - supportsCatalogs(): boolean { - return true; - } - isCatalogReference(version: string): boolean { return version.startsWith(this.catalogProtocol); } @@ -88,30 +80,22 @@ export class PnpmCatalogManager implements CatalogManager { treeOrRoot: Tree | string, packageName: string, version: string - ): { isValid: boolean; error?: CatalogError } { + ): void { const catalogRef = this.parseCatalogReference(version); if (!catalogRef) { - return { - isValid: false, - error: { - type: CatalogErrorType.INVALID_SYNTAX, - message: `Invalid catalog reference syntax: "${version}". Expected format: "catalog:" or "catalog:name"`, - }, - }; + throw new Error( + `Invalid catalog reference syntax: "${version}". Expected format: "catalog:" or "catalog:name"` + ); } const workspaceConfig = this.getCatalogDefinitions(treeOrRoot); if (!workspaceConfig) { - return { - isValid: false, - error: { - type: CatalogErrorType.WORKSPACE_NOT_FOUND, - message: 'No pnpm-workspace.yaml found in workspace root', - suggestions: [ - 'Create a pnpm-workspace.yaml file in your workspace root', - ], - }, - }; + throw new Error( + formatCatalogError( + 'Cannot get Pnpm catalog definitions. No pnpm-workspace.yaml found in workspace root.', + ['Create a pnpm-workspace.yaml file in your workspace root'] + ) + ); } let catalogToUse: PnpmCatalogEntry | undefined; @@ -122,15 +106,9 @@ export class PnpmCatalogManager implements CatalogManager { // Error if both defined (matches pnpm behavior) if (hasCatalog && hasCatalogsDefault) { - return { - isValid: false, - error: { - type: CatalogErrorType.INVALID_CATALOGS_CONFIGURATION, - message: - "The 'default' catalog was defined multiple times. Use the 'catalog' field or 'catalogs.default', but not both.", - suggestions: [], - }, - }; + throw new Error( + "The 'default' catalog was defined multiple times. Use the 'catalog' field or 'catalogs.default', but not both." + ); } catalogToUse = @@ -149,14 +127,12 @@ export class PnpmCatalogManager implements CatalogManager { ); } - return { - isValid: false, - error: { - type: CatalogErrorType.CATALOG_NOT_FOUND, - message: 'No default catalog defined in pnpm-workspace.yaml', - suggestions, - }, - }; + throw new Error( + formatCatalogError( + 'No default catalog defined in pnpm-workspace.yaml', + suggestions + ) + ); } } else if (catalogRef.catalogName) { catalogToUse = workspaceConfig.catalogs?.[catalogRef.catalogName]; @@ -184,15 +160,12 @@ export class PnpmCatalogManager implements CatalogManager { suggestions.push(`Or use the default catalog ("${defaultCatalog}")`); } - return { - isValid: false, - error: { - type: CatalogErrorType.CATALOG_NOT_FOUND, - message: `Catalog "${catalogRef.catalogName}" not found in pnpm-workspace.yaml`, - catalogName: catalogRef.catalogName, - suggestions, - }, - }; + throw new Error( + formatCatalogError( + `Catalog "${catalogRef.catalogName}" not found in pnpm-workspace.yaml`, + suggestions + ) + ); } } @@ -220,19 +193,13 @@ export class PnpmCatalogManager implements CatalogManager { ); } - return { - isValid: false, - error: { - type: CatalogErrorType.PACKAGE_NOT_FOUND, - message: `Package "${packageName}" not found in ${catalogName}`, - packageName, - catalogName: catalogRef.catalogName, - suggestions, - }, - }; + throw new Error( + formatCatalogError( + `Package "${packageName}" not found in ${catalogName}`, + suggestions + ) + ); } - - return { isValid: true }; } updateCatalogVersions( @@ -350,3 +317,16 @@ function readYamlFileFromTree(tree: Tree, path: string): PnpmWorkspaceYaml { return null; } } + +function formatCatalogError(error: string, suggestions: string[]): string { + let message = error; + + if (suggestions && suggestions.length > 0) { + message += '\n\nSuggestions:'; + suggestions.forEach((suggestion) => { + message += `\n • ${suggestion}`; + }); + } + + return message; +} diff --git a/packages/nx/src/utils/catalog/types.ts b/packages/nx/src/utils/catalog/types.ts index 2b42cfff4e2d6..f7a804dcb5705 100644 --- a/packages/nx/src/utils/catalog/types.ts +++ b/packages/nx/src/utils/catalog/types.ts @@ -2,19 +2,3 @@ export interface CatalogReference { catalogName?: string; isDefaultCatalog: boolean; } - -export enum CatalogErrorType { - INVALID_SYNTAX = 'INVALID_SYNTAX', - WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND', - CATALOG_NOT_FOUND = 'CATALOG_NOT_FOUND', - PACKAGE_NOT_FOUND = 'PACKAGE_NOT_FOUND', - INVALID_CATALOGS_CONFIGURATION = 'INVALID_CATALOGS_CONFIGURATION', -} - -export interface CatalogError { - type: CatalogErrorType; - message: string; - catalogName?: string; - packageName?: string; - suggestions?: string[]; -} diff --git a/packages/nx/src/utils/catalog/unsupported-manager.ts b/packages/nx/src/utils/catalog/unsupported-manager.ts deleted file mode 100644 index 9815f37d2fb70..0000000000000 --- a/packages/nx/src/utils/catalog/unsupported-manager.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { Tree } from '../../generators/tree'; -import type { PnpmWorkspaceYaml } from '../pnpm-workspace'; -import { CatalogUnsupportedError } from './errors'; -import type { CatalogManager } from './manager'; -import type { CatalogError, CatalogReference } from './types'; - -/** - * Base catalog manager for package managers that don't support catalogs - */ -abstract class UnsupportedCatalogManager implements CatalogManager { - abstract readonly name: string; - - supportsCatalogs(): boolean { - return false; - } - - isCatalogReference(_version: string): boolean { - return false; - } - - parseCatalogReference(_version: string): CatalogReference | null { - return null; - } - - getCatalogDefinitions(_treeOrRoot: Tree | string): PnpmWorkspaceYaml | null { - throw new CatalogUnsupportedError(this.name, 'get catalog definitions'); - } - - resolveCatalogReference( - _treeOrRoot: Tree | string, - _packageName: string, - _version: string - ): string | null { - throw new CatalogUnsupportedError(this.name, 'resolve catalog references'); - } - - validateCatalogReference( - _treeOrRoot: Tree | string, - _packageName: string, - _version: string - ): { isValid: boolean; error?: CatalogError } { - throw new CatalogUnsupportedError(this.name, 'validate catalog references'); - } - - updateCatalogVersions( - _treeOrRoot: Tree | string, - _updates: Array<{ - packageName: string; - version: string; - catalogName?: string; - }> - ): void { - throw new CatalogUnsupportedError(this.name, 'update catalog versions'); - } -} - -export class YarnCatalogManager extends UnsupportedCatalogManager { - readonly name = 'yarn'; -} - -export class BunCatalogManager extends UnsupportedCatalogManager { - readonly name = 'bun'; -} - -export class NpmCatalogManager extends UnsupportedCatalogManager { - readonly name = 'npm'; -} - -export class UnknownCatalogManager extends UnsupportedCatalogManager { - readonly name = 'unknown'; -} diff --git a/packages/nx/src/utils/package-json.ts b/packages/nx/src/utils/package-json.ts index 68a6870b729b7..55c7066f142ad 100644 --- a/packages/nx/src/utils/package-json.ts +++ b/packages/nx/src/utils/package-json.ts @@ -537,8 +537,6 @@ function getDependencyVersionFromPackageJsonFromTree( return null; } - const manager = getCatalogManager(tree.root); - let version: string | null = null; for (const section of dependencyLookup) { const foundVersion = packageJson[section]?.[packageName]; @@ -549,7 +547,8 @@ function getDependencyVersionFromPackageJsonFromTree( } // Resolve catalog reference if needed - if (version && manager.isCatalogReference(version)) { + const manager = getCatalogManager(tree.root); + if (version && manager?.isCatalogReference(version)) { version = manager.resolveCatalogReference(tree, packageName, version); } @@ -580,8 +579,6 @@ function getDependencyVersionFromPackageJsonFromFileSystem( } } - const manager = getCatalogManager(root); - let version: string | null = null; for (const section of dependencyLookup) { const foundVersion = packageJson[section]?.[packageName]; @@ -592,7 +589,8 @@ function getDependencyVersionFromPackageJsonFromFileSystem( } // Resolve catalog reference if needed - if (version && manager.isCatalogReference(version)) { + const manager = getCatalogManager(root); + if (version && manager?.isCatalogReference(version)) { version = manager.resolveCatalogReference(packageName, version, root); } diff --git a/packages/nx/src/utils/package-manager.ts b/packages/nx/src/utils/package-manager.ts index c2715d7a2b1ef..e5ea2367ea612 100644 --- a/packages/nx/src/utils/package-manager.ts +++ b/packages/nx/src/utils/package-manager.ts @@ -460,7 +460,7 @@ export async function resolvePackageVersionUsingRegistry( try { let resolvedVersion = version; const manager = getCatalogManager(workspaceRoot); - if (manager.isCatalogReference(version)) { + if (manager?.isCatalogReference(version)) { resolvedVersion = manager.resolveCatalogReference( packageName, version,