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..43e54a7908bec --- /dev/null +++ b/docs/generated/devkit/getDependencyVersionFromPackageJson.md @@ -0,0 +1,137 @@ +# Function: getDependencyVersionFromPackageJson + +▸ **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. 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. + +#### Parameters + +| 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 any of the specified sections + +**`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 and section +const version = getDependencyVersionFromPackageJson( + 'react', + '/path/to/workspace', + 'apps/my-app/package.json', + ['dependencies'] +); +``` + +▸ **getDependencyVersionFromPackageJson**(`tree`, `packageName`, `packageJson?`, `dependencyLookup?`): `string` \| `null` + +#### Parameters + +| Name | Type | +| :------------------ | :-------------------------------------------------- | +| `tree` | [`Tree`](/reference/core-api/devkit/documents/Tree) | +| `packageName` | `string` | +| `packageJson?` | `PackageJson` | +| `dependencyLookup?` | `PackageJsonDependencySection`[] | + +#### Returns + +`string` \| `null` + +▸ **getDependencyVersionFromPackageJson**(`packageName`, `workspaceRootPath?`, `packageJsonPath?`, `dependencyLookup?`): `string` \| `null` + +#### Parameters + +| Name | Type | +| :------------------- | :------------------------------- | +| `packageName` | `string` | +| `workspaceRootPath?` | `string` | +| `packageJsonPath?` | `string` | +| `dependencyLookup?` | `PackageJsonDependencySection`[] | + +#### Returns + +`string` \| `null` + +▸ **getDependencyVersionFromPackageJson**(`packageName`, `workspaceRootPath?`, `packageJson?`, `dependencyLookup?`): `string` \| `null` + +#### Parameters + +| Name | Type | +| :------------------- | :------------------------------- | +| `packageName` | `string` | +| `workspaceRootPath?` | `string` | +| `packageJson?` | `PackageJson` | +| `dependencyLookup?` | `PackageJsonDependencySection`[] | + +#### 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/e2e/nx/src/misc.test.ts b/e2e/nx/src/misc.test.ts index 6727a942b8f91..bf98edb64b482 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, @@ -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'}); } @@ -903,6 +913,171 @@ describe('migrate', () => { ], }); }); + + if (getSelectedPackageManager() === 'pnpm') { + 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. + 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/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..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,12 +22,19 @@ 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('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.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/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/index.ts b/packages/devkit/src/utils/catalog/index.ts new file mode 100644 index 0000000000000..db307c104d859 --- /dev/null +++ b/packages/devkit/src/utils/catalog/index.ts @@ -0,0 +1,46 @@ +import { readJson, type Tree } from 'nx/src/devkit-exports'; +import { getCatalogManager } from './manager-factory'; +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; + } + + 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; +} 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..e9b09f3cce7a2 --- /dev/null +++ b/packages/devkit/src/utils/catalog/manager-factory.ts @@ -0,0 +1,19 @@ +import { detectPackageManager } from 'nx/src/devkit-exports'; +import type { CatalogManager } from './manager'; +import { PnpmCatalogManager } from './pnpm-manager'; + +/** + * Factory function to get the appropriate catalog manager based on the package manager + */ +export function getCatalogManager( + workspaceRoot: string +): CatalogManager | null { + const packageManager = detectPackageManager(workspaceRoot); + + switch (packageManager) { + case 'pnpm': + return new PnpmCatalogManager(); + default: + return null; + } +} diff --git a/packages/devkit/src/utils/catalog/manager.ts b/packages/devkit/src/utils/catalog/manager.ts new file mode 100644 index 0000000000000..c9170219a5ede --- /dev/null +++ b/packages/devkit/src/utils/catalog/manager.ts @@ -0,0 +1,68 @@ +import type { Tree } from 'nx/src/devkit-exports'; +import type { PnpmWorkspaceYaml } from 'nx/src/utils/pnpm-workspace'; +import type { CatalogReference } from './types'; + +/** + * Interface for catalog managers that handle package manager-specific catalog implementations. + */ +export interface CatalogManager { + readonly name: string; + + 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 + ): void; + validateCatalogReference( + tree: Tree, + packageName: string, + version: string + ): void; + + /** + * 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..508cdb0f40c7a --- /dev/null +++ b/packages/devkit/src/utils/catalog/pnpm-manager.ts @@ -0,0 +1,334 @@ +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 CatalogReference } from './types'; + +/** + * PNPM-specific catalog manager implementation + */ +export class PnpmCatalogManager implements CatalogManager { + readonly name = 'pnpm'; + readonly catalogProtocol = 'catalog:'; + + 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); + // Normalize both "catalog:" and "catalog:default" to the same representation + const isDefault = !catalogName || catalogName === 'default'; + + return { + catalogName: isDefault ? undefined : catalogName, + isDefaultCatalog: isDefault, + }; + } + + 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) { + // Check both locations for default catalog + catalogToUse = + workspaceConfig.catalog ?? workspaceConfig.catalogs?.default; + } else if (catalogRef.catalogName) { + catalogToUse = workspaceConfig.catalogs?.[catalogRef.catalogName]; + } + + return catalogToUse?.[packageName] || null; + } + + validateCatalogReference( + treeOrRoot: Tree | string, + packageName: string, + version: string + ): void { + const catalogRef = this.parseCatalogReference(version); + if (!catalogRef) { + throw new Error( + `Invalid catalog reference syntax: "${version}". Expected format: "catalog:" or "catalog:name"` + ); + } + + const workspaceConfig = this.getCatalogDefinitions(treeOrRoot); + if (!workspaceConfig) { + 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; + + if (catalogRef.isDefaultCatalog) { + const hasCatalog = !!workspaceConfig.catalog; + const hasCatalogsDefault = !!workspaceConfig.catalogs?.default; + + // Error if both defined (matches pnpm behavior) + if (hasCatalog && hasCatalogsDefault) { + throw new Error( + "The 'default' catalog was defined multiple times. Use the 'catalog' field or 'catalogs.default', but not both." + ); + } + + catalogToUse = + workspaceConfig.catalog ?? workspaceConfig.catalogs?.default; + 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(', ')}` + ); + } + + throw new Error( + formatCatalogError( + '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 || {} + ).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', + ]; + if (availableCatalogs.length > 0) { + suggestions.push( + `Or select from the available named catalogs: ${availableCatalogs + .map((c) => `"catalog:${c}"`) + .join(', ')}` + ); + } + if (defaultCatalog) { + suggestions.push(`Or use the default catalog ("${defaultCatalog}")`); + } + + throw new Error( + formatCatalogError( + `Catalog "${catalogRef.catalogName}" not found in pnpm-workspace.yaml`, + suggestions + ) + ); + } + } + + if (!catalogToUse![packageName]) { + 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 = [ + `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(', ')}` + ); + } + + throw new Error( + formatCatalogError( + `Package "${packageName}" not found in ${catalogName}`, + suggestions + ) + ); + } + } + + 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; + const normalizedCatalogName = + catalogName === 'default' ? undefined : catalogName; + + let targetCatalog: PnpmCatalogEntry; + 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[normalizedCatalogName] ??= {}; + targetCatalog = workspaceData.catalogs[normalizedCatalogName]; + } + + 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; + } +} + +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 new file mode 100644 index 0000000000000..f7a804dcb5705 --- /dev/null +++ b/packages/devkit/src/utils/catalog/types.ts @@ -0,0 +1,4 @@ +export interface CatalogReference { + catalogName?: string; + isDefaultCatalog: boolean; +} diff --git a/packages/devkit/src/utils/package-json.spec.ts b/packages/devkit/src/utils/package-json.spec.ts index 2ec5f7d36cc07..adf4bcaed5c81 100644 --- a/packages/devkit/src/utils/package-json.spec.ts +++ b/packages/devkit/src/utils/package-json.spec.ts @@ -1,7 +1,26 @@ +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'; + +// 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 +488,200 @@ describe('addDependenciesToPackageJson', () => { foo: '1.0.0', }); }); + + describe('catalog support', () => { + beforeEach(() => { + jest.spyOn(devkitExports, 'detectPackageManager').mockReturnValue('pnpm'); + 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', () => { + jest.spyOn(devkitExports, 'detectPackageManager').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('ensurePackage', () => { diff --git a/packages/devkit/src/utils/package-json.ts b/packages/devkit/src/utils/package-json.ts index 7da4001005bd3..6903a66257eed 100644 --- a/packages/devkit/src/utils/package-json.ts +++ b/packages/devkit/src/utils/package-json.ts @@ -1,15 +1,26 @@ -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, + 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'; -import { Module } from 'module'; +import { + getCatalogDependenciesFromPackageJson, + getCatalogManager, +} from './catalog'; const UNIDENTIFIED_VERSION = 'UNIDENTIFIED_VERSION'; const NON_SEMVER_TAGS = { @@ -21,6 +32,207 @@ 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. 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 and section + * const version = getDependencyVersionFromPackageJson( + * 'react', + * '/path/to/workspace', + * 'apps/my-app/package.json', + * ['dependencies'] + * ); + * ``` + * + * @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, + dependencyLookup?: PackageJsonDependencySection[] +): string | null; +export function getDependencyVersionFromPackageJson( + packageName: string, + workspaceRootPath?: string, + 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; + } + + 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 + const manager = getCatalogManager(tree.root); + 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', + dependencyLookup: PackageJsonDependencySection[] = [ + 'dependencies', + 'devDependencies', + ] +): 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; + } + } + + 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 + const manager = getCatalogManager(root); + if (version && manager?.isCatalogReference(version)) { + version = manager.resolveCatalogReference(packageName, version, root); + } + + return version; +} + function filterExistingDependencies( dependencies: Record, existingAltDependencies: Record @@ -34,23 +246,60 @@ 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)) { + 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 +318,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 +338,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 +363,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 +416,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 +454,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 +498,143 @@ 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); + if (!manager) { + return { + catalogUpdates: [], + directDependencies: filteredDependencies, + directDevDependencies: filteredDevDependencies, + }; + } + + const existingCatalogDeps = getCatalogDependenciesFromPackageJson( + tree, + packageJsonPath, + manager + ); + if (!existingCatalogDeps.size) { + return { + catalogUpdates: [], + directDependencies: filteredDependencies, + directDevDependencies: filteredDevDependencies, + }; + } + + // Check filtered results for catalog references or existing catalog dependencies + for (const [packageName, version] of Object.entries(allFilteredUpdates)) { + if (!existingCatalogDeps.has(packageName)) { + continue; + } + + let catalogName = existingCatalogDeps.get(packageName)!; + const catalogRef = catalogName ? `catalog:${catalogName}` : 'catalog:'; + + try { + manager.validateCatalogReference(tree, packageName, catalogRef); + + 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: 'Invalid catalog reference', + bodyLines: [ + `Invalid catalog reference "${catalogRef}" for package "${packageName}".`, + error.message, + ], + }); + throw new Error( + `Could not update "${packageName}" to version "${version}". See above for more details.` + ); + } + } + + 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 +721,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 +739,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 +766,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 +956,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.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 89bde9d662189..1c33604decd93 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 { 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; + let newVersion = + typeof treeOrPkgName === 'string' ? pkgNameOrVersion : version!; + + const manager = getCatalogManager(root); + 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${error.message}` + ); + } + + const resolvedVersion = tree + ? manager.resolveCatalogReference(tree, pkgName, newVersion) + : manager.resolveCatalogReference(root, pkgName, newVersion); + if (!resolvedVersion) { + throw new Error( + `Could not resolve catalog reference for package ${pkgName}@${newVersion}.` + ); + } + + newVersion = resolvedVersion; + } if (valid(newVersion)) { return newVersion; } - if (version.startsWith('~') || version.startsWith('^')) { - newVersion = version.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 - (${version})` + `The package.json lists a version of ${pkgName} that Nx is unable to validate - (${newVersion})` ); } 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 0a22469e792d4..2ed5d7ff85652 100644 --- a/packages/eslint-plugin/src/rules/dependency-checks.ts +++ b/packages/eslint-plugin/src/rules/dependency-checks.ts @@ -1,4 +1,5 @@ import { NX_VERSION, normalizePath, workspaceRoot } from '@nx/devkit'; +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'; @@ -35,7 +36,8 @@ export type MessageIds = | 'missingDependency' | 'obsoleteDependency' | 'versionMismatch' - | 'missingDependencySection'; + | 'missingDependencySection' + | 'invalidCatalogReference'; export const RULE_NAME = 'dependency-checks'; @@ -72,6 +74,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 +208,38 @@ export default ESLintUtils.RuleCreator( } } + function validateCatalogReferenceForPackage( + node: AST.JSONProperty, + packageName: string, + packageRange: string + ) { + const manager = getCatalogManager(workspaceRoot); + if (!manager) { + return; + } + + if (!manager.isCatalogReference(packageRange)) { + return; + } + + try { + manager.validateCatalogReference( + workspaceRoot, + packageName, + packageRange + ); + } catch (error) { + context.report({ + node: node as any, + messageId: 'invalidCatalogReference', + data: { + packageName: packageName, + error: error.message, + }, + }); + } + } + function validateVersionMatchesInstalled( node: AST.JSONProperty, packageName: string, @@ -213,19 +248,35 @@ export default ESLintUtils.RuleCreator( if (!checkVersionMismatches) { return; } + + // Resolve catalog references before validation + let resolvedPackageRange = packageRange; + const manager = getCatalogManager(workspaceRoot); + + if (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, }) ) { @@ -359,6 +410,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/js/src/release/version-actions.ts b/packages/js/src/release/version-actions.ts index 402434736b8a5..144be6053dc96 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,19 @@ export default class JsVersionActions extends VersionActions { break; } } + + // Resolve catalog references if needed + if (currentVersion) { + const catalogManager = getCatalogManager(tree.root); + if (catalogManager?.isCatalogReference(currentVersion)) { + currentVersion = catalogManager.resolveCatalogReference( + tree, + dependencyPackageName, + currentVersion + ); + } + } + return { currentVersion, dependencyCollection, @@ -201,6 +215,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 +253,21 @@ 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 ( + if (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 +312,18 @@ 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 is guaranteed to be defined when there are catalog updates + catalogManager!.updateCatalogVersions(tree, catalogUpdates); + + const catalogText = catalogUpdates.length === 1 ? 'entry' : 'entries'; + logMessages.push( + `✍️ Updated ${catalogUpdates.length} catalog ${catalogText} in pnpm-workspace.yaml` + ); + } + return logMessages; } 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..f50cca1602941 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,25 @@ async function updatePackageJson( const parseOptions: JsonReadOptions = {}; const json = readJsonFile(packageJsonPath, parseOptions); + const manager = getCatalogManager(root); + const catalogUpdates = []; + Object.keys(updatedPackages).forEach((p) => { + const existingVersion = json.dependencies?.[p] ?? json.devDependencies?.[p]; + + if (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 +1254,12 @@ async function updatePackageJson( await writeFormattedJsonFile(packageJsonPath, json, { appendNewLine: parseOptions.endsWithNewline, }); + + // Update catalog definitions + if (catalogUpdates.length) { + // manager is guaranteed to be defined when there are catalog updates + manager!.updateCatalogVersions(root, catalogUpdates); + } } async function updateInstallationDetails( @@ -1281,14 +1307,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 +1328,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..71dd6e2ea05b2 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..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 @@ -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..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') { @@ -585,6 +587,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 +618,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..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'; @@ -45,10 +48,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 +70,7 @@ export function createPackageJson( version: '0.0.1', }; const projectPackageJsonPath = join( - options.root ?? workspaceRoot, + root, projectNode.data.root, 'package.json' ); @@ -99,11 +101,31 @@ export function createPackageJson( version: string, section: 'devDependencies' | 'dependencies' ) => { - return ( - packageJson[section][packageName] || - (isLibrary && rootPackageJson[section]?.[packageName]) || - version + // Try project package.json first (single section) + const projectVersion = getDependencyVersionFromPackageJson( + packageName, + root, + packageJson, + [section] ); + if (projectVersion) { + return projectVersion; + } + + // 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; }; Object.entries(npmDeps.dependencies).forEach(([packageName, version]) => { 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..3b40d625b98f9 --- /dev/null +++ b/packages/nx/src/utils/catalog/index.spec.ts @@ -0,0 +1,102 @@ +import { createTreeWithEmptyWorkspace } from '../../generators/testing-utils/create-tree-with-empty-workspace'; +import type { Tree } from '../../generators/tree'; +import { writeJson } from '../../generators/utils/json'; +import { getCatalogDependenciesFromPackageJson } from './index'; +import type { CatalogManager } from './manager'; +import { PnpmCatalogManager } from './pnpm-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 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'], + ]) + ); + }); + }); +}); diff --git a/packages/nx/src/utils/catalog/index.ts b/packages/nx/src/utils/catalog/index.ts new file mode 100644 index 0000000000000..c8b4d39079ba0 --- /dev/null +++ b/packages/nx/src/utils/catalog/index.ts @@ -0,0 +1,47 @@ +import type { Tree } from '../../generators/tree'; +import { readJson } from '../../generators/utils/json'; +import type { CatalogManager } from './manager'; +import { getCatalogManager } from './manager-factory'; + +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; + } + + 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; +} 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..b29ed46f2fc65 --- /dev/null +++ b/packages/nx/src/utils/catalog/manager-factory.ts @@ -0,0 +1,19 @@ +import { detectPackageManager } from '../package-manager'; +import type { CatalogManager } from './manager'; +import { PnpmCatalogManager } from './pnpm-manager'; + +/** + * Factory function to get the appropriate catalog manager based on the package manager + */ +export function getCatalogManager( + workspaceRoot: string +): CatalogManager | null { + const packageManager = detectPackageManager(workspaceRoot); + + switch (packageManager) { + case 'pnpm': + return new PnpmCatalogManager(); + default: + return null; + } +} diff --git a/packages/nx/src/utils/catalog/manager.ts b/packages/nx/src/utils/catalog/manager.ts new file mode 100644 index 0000000000000..40e29b1fd0935 --- /dev/null +++ b/packages/nx/src/utils/catalog/manager.ts @@ -0,0 +1,68 @@ +import type { Tree } from '../../generators/tree'; +import type { PnpmWorkspaceYaml } from '../pnpm-workspace'; +import type { CatalogReference } from './types'; + +/** + * Interface for catalog managers that handle package manager-specific catalog implementations. + */ +export interface CatalogManager { + readonly name: string; + + 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 + ): void; + validateCatalogReference( + tree: Tree, + packageName: string, + version: string + ): void; + + /** + * 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..ec07b3d795ed5 --- /dev/null +++ b/packages/nx/src/utils/catalog/pnpm-manager.spec.ts @@ -0,0 +1,555 @@ +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'; + +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 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'); + + 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 from top-level catalog field', () => { + 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 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', + ` +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', () => { + 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', () => { + expect(() => + manager.validateCatalogReference(tree, 'react', 'catalog:') + ).toThrow( + 'Cannot get Pnpm catalog definitions. No pnpm-workspace.yaml found in workspace root.' + ); + }); + + 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 +` + ); + + expect(() => + manager.validateCatalogReference(tree, 'react', 'catalog:') + ).toThrow( + "The 'default' catalog was defined multiple times. Use the 'catalog' field or 'catalogs.default', but not both." + ); + }); + + 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 +` + ); + + 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." + ); + }); + + it('should return invalid when default catalog not found', () => { + tree.write( + 'pnpm-workspace.yaml', + ` +packages: + - packages/* +` + ); + + expect(() => + manager.validateCatalogReference(tree, 'react', 'catalog:') + ).toThrow('No default catalog defined in pnpm-workspace.yaml'); + }); + + it('should return invalid when named catalog not found', () => { + tree.write( + 'pnpm-workspace.yaml', + ` +packages: + - packages/* +` + ); + + 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', () => { + tree.write( + 'pnpm-workspace.yaml', + ` +packages: + - packages/* +catalog: + react: ^18.0.0 +` + ); + + 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', () => { + tree.write( + 'pnpm-workspace.yaml', + ` +catalog: + react: ^18.0.0 +` + ); + + expect(() => + manager.validateCatalogReference(tree, 'react', 'catalog:') + ).not.toThrow(); + }); + + it('should validate catalog:default with top-level catalog field', () => { + tree.write( + 'pnpm-workspace.yaml', + ` +catalog: + react: ^18.0.0 +` + ); + + expect(() => + manager.validateCatalogReference(tree, 'react', 'catalog:default') + ).not.toThrow(); + }); + + it('should validate catalog: with catalogs.default field', () => { + tree.write( + 'pnpm-workspace.yaml', + ` +catalogs: + default: + react: ^18.0.0 +` + ); + + expect(() => + manager.validateCatalogReference(tree, 'react', 'catalog:') + ).not.toThrow(); + }); + + it('should validate catalog:default with catalogs.default field', () => { + tree.write( + 'pnpm-workspace.yaml', + ` +catalogs: + default: + react: ^18.0.0 +` + ); + + expect(() => + manager.validateCatalogReference(tree, 'react', 'catalog:default') + ).not.toThrow(); + }); + }); + + 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 new file mode 100644 index 0000000000000..7087497f690b2 --- /dev/null +++ b/packages/nx/src/utils/catalog/pnpm-manager.ts @@ -0,0 +1,332 @@ +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 { CatalogReference } from './types'; + +/** + * PNPM-specific catalog manager implementation + */ +export class PnpmCatalogManager implements CatalogManager { + readonly name = 'pnpm'; + readonly catalogProtocol = 'catalog:'; + + 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); + // Normalize both "catalog:" and "catalog:default" to the same representation + const isDefault = !catalogName || catalogName === 'default'; + + return { + catalogName: isDefault ? undefined : catalogName, + isDefaultCatalog: isDefault, + }; + } + + 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) { + // Check both locations for default catalog + catalogToUse = + workspaceConfig.catalog ?? workspaceConfig.catalogs?.default; + } else if (catalogRef.catalogName) { + catalogToUse = workspaceConfig.catalogs?.[catalogRef.catalogName]; + } + + return catalogToUse?.[packageName] || null; + } + + validateCatalogReference( + treeOrRoot: Tree | string, + packageName: string, + version: string + ): void { + const catalogRef = this.parseCatalogReference(version); + if (!catalogRef) { + throw new Error( + `Invalid catalog reference syntax: "${version}". Expected format: "catalog:" or "catalog:name"` + ); + } + + const workspaceConfig = this.getCatalogDefinitions(treeOrRoot); + if (!workspaceConfig) { + 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; + + if (catalogRef.isDefaultCatalog) { + const hasCatalog = !!workspaceConfig.catalog; + const hasCatalogsDefault = !!workspaceConfig.catalogs?.default; + + // Error if both defined (matches pnpm behavior) + if (hasCatalog && hasCatalogsDefault) { + throw new Error( + "The 'default' catalog was defined multiple times. Use the 'catalog' field or 'catalogs.default', but not both." + ); + } + + catalogToUse = + workspaceConfig.catalog ?? workspaceConfig.catalogs?.default; + 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(', ')}` + ); + } + + throw new Error( + formatCatalogError( + '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 || {} + ).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', + ]; + if (availableCatalogs.length > 0) { + suggestions.push( + `Or select from the available named catalogs: ${availableCatalogs + .map((c) => `"catalog:${c}"`) + .join(', ')}` + ); + } + if (defaultCatalog) { + suggestions.push(`Or use the default catalog ("${defaultCatalog}")`); + } + + throw new Error( + formatCatalogError( + `Catalog "${catalogRef.catalogName}" not found in pnpm-workspace.yaml`, + suggestions + ) + ); + } + } + + if (!catalogToUse![packageName]) { + 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 = [ + `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(', ')}` + ); + } + + throw new Error( + formatCatalogError( + `Package "${packageName}" not found in ${catalogName}`, + suggestions + ) + ); + } + } + + 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; + const normalizedCatalogName = + catalogName === 'default' ? undefined : catalogName; + + let targetCatalog: PnpmCatalogEntry; + 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[normalizedCatalogName] ??= {}; + targetCatalog = workspaceData.catalogs[normalizedCatalogName]; + } + + 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; + } +} + +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 new file mode 100644 index 0000000000000..f7a804dcb5705 --- /dev/null +++ b/packages/nx/src/utils/catalog/types.ts @@ -0,0 +1,4 @@ +export interface CatalogReference { + catalogName?: string; + isDefaultCatalog: boolean; +} 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 8c3f775bfd4c3..55c7066f142ad 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 } from 'path'; +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; @@ -386,6 +396,207 @@ 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. 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 and section + * const version = getDependencyVersionFromPackageJson( + * 'react', + * '/path/to/workspace', + * 'apps/my-app/package.json', + * ['dependencies'] + * ); + * ``` + * + * @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, + dependencyLookup?: PackageJsonDependencySection[] +): string | null; +export function getDependencyVersionFromPackageJson( + packageName: string, + workspaceRootPath?: string, + 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; + } + + 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 + const manager = getCatalogManager(tree.root); + 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', + dependencyLookup: PackageJsonDependencySection[] = [ + 'dependencies', + 'devDependencies', + ] +): 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; + } + } + + 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 + const manager = getCatalogManager(root); + 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..e5ea2367ea612 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.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'); + }); +}); 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 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); } }