From 6610047353d0b5d1821dc46717903a6936a1cabc Mon Sep 17 00:00:00 2001 From: Bharat Middha <5100938+bmiddha@users.noreply.github.com> Date: Sat, 29 Nov 2025 16:23:27 -0800 Subject: [PATCH] add support for pnpm catalogs --- ...middha-pnpm-catalogs_2025-11-30-00-23.json | 10 ++ common/reviews/api/rush-lib.api.md | 2 + .../common/config/rush/pnpm-config.json | 34 +++++ .../rush-lib/src/logic/DependencySpecifier.ts | 35 ++++- .../installManager/WorkspaceInstallManager.ts | 7 + .../logic/pnpm/PnpmOptionsConfiguration.ts | 21 +++ .../src/logic/pnpm/PnpmWorkspaceFile.ts | 44 +++++- .../test/PnpmOptionsConfiguration.test.ts | 22 +++ .../logic/pnpm/test/PnpmWorkspaceFile.test.ts | 131 ++++++++++++++++++ .../test/jsonFiles/pnpm-config-catalogs.json | 16 +++ .../logic/test/DependencySpecifier.test.ts | 40 ++++++ .../pnpmCatalogs/a/config/rush-project.json | 9 ++ .../logic/test/pnpmCatalogs/a/package.json | 11 ++ .../pnpmCatalogs/b/config/rush-project.json | 9 ++ .../logic/test/pnpmCatalogs/b/package.json | 11 ++ .../common/config/rush/pnpm-config.json | 21 +++ .../src/logic/test/pnpmCatalogs/rush.json | 15 ++ .../src/schemas/pnpm-config.schema.json | 13 ++ 18 files changed, 449 insertions(+), 2 deletions(-) create mode 100644 common/changes/@microsoft/rush/bmiddha-pnpm-catalogs_2025-11-30-00-23.json create mode 100644 libraries/rush-lib/src/logic/pnpm/test/PnpmWorkspaceFile.test.ts create mode 100644 libraries/rush-lib/src/logic/pnpm/test/jsonFiles/pnpm-config-catalogs.json create mode 100644 libraries/rush-lib/src/logic/test/pnpmCatalogs/a/config/rush-project.json create mode 100644 libraries/rush-lib/src/logic/test/pnpmCatalogs/a/package.json create mode 100644 libraries/rush-lib/src/logic/test/pnpmCatalogs/b/config/rush-project.json create mode 100644 libraries/rush-lib/src/logic/test/pnpmCatalogs/b/package.json create mode 100644 libraries/rush-lib/src/logic/test/pnpmCatalogs/common/config/rush/pnpm-config.json create mode 100644 libraries/rush-lib/src/logic/test/pnpmCatalogs/rush.json diff --git a/common/changes/@microsoft/rush/bmiddha-pnpm-catalogs_2025-11-30-00-23.json b/common/changes/@microsoft/rush/bmiddha-pnpm-catalogs_2025-11-30-00-23.json new file mode 100644 index 00000000000..2db3c046784 --- /dev/null +++ b/common/changes/@microsoft/rush/bmiddha-pnpm-catalogs_2025-11-30-00-23.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Add support for pnpm catalogs", + "type": "none" + } + ], + "packageName": "@microsoft/rush" +} \ No newline at end of file diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index cf0b56b9abb..46abf802ba5 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -740,6 +740,7 @@ export interface _IPnpmOptionsJson extends IPackageManagerOptionsJsonBase { alwaysInjectDependenciesFromOtherSubspaces?: boolean; autoInstallPeers?: boolean; globalAllowedDeprecatedVersions?: Record; + globalCatalogs?: Record>; globalIgnoredOptionalDependencies?: string[]; globalNeverBuiltDependencies?: string[]; globalOverrides?: Record; @@ -1151,6 +1152,7 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration readonly alwaysInjectDependenciesFromOtherSubspaces: boolean | undefined; readonly autoInstallPeers: boolean | undefined; readonly globalAllowedDeprecatedVersions: Record | undefined; + readonly globalCatalogs: Record> | undefined; readonly globalIgnoredOptionalDependencies: string[] | undefined; readonly globalNeverBuiltDependencies: string[] | undefined; readonly globalOverrides: Record | undefined; diff --git a/libraries/rush-lib/assets/rush-init/common/config/rush/pnpm-config.json b/libraries/rush-lib/assets/rush-init/common/config/rush/pnpm-config.json index d677fa2179f..125a9848711 100644 --- a/libraries/rush-lib/assets/rush-init/common/config/rush/pnpm-config.json +++ b/libraries/rush-lib/assets/rush-init/common/config/rush/pnpm-config.json @@ -240,6 +240,40 @@ /*[LINE "HYPOTHETICAL"]*/ "example2": "npm:@company/example2@^1.0.0" }, + /** + * The "globalCatalogs" setting defines named catalogs for the PNPM workspace. + * Catalogs allow you to define reusable dependency version ranges that can be referenced + * in package.json files. Use the "default" catalog name for packages that should be + * referenced with "catalog:" (no name), or use custom catalog names for "catalog:" + * references. + * + * For example, if you define a "default" catalog with `"lodash": "^4.17.21"`, projects can + * use `"lodash": "catalog:"`. If you define a "react18" catalog with `"react": "^18.2.0"`, + * projects can use `"react": "catalog:react18"` in their dependencies. + * + * This setting is written to the `catalogs` field in the generated `pnpm-workspace.yaml` file. + * + * (SUPPORTED ONLY IN PNPM 9.5.0 AND NEWER) + * + * PNPM documentation: https://pnpm.io/catalogs + */ + "globalCatalogs": { + /*[BEGIN "HYPOTHETICAL"]*/ + "default": { + "lodash": "^4.17.21", + "typescript": "~5.0.0" + }, + "react18": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "react19": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + /*[END "HYPOTHETICAL"]*/ + }, + /** * The `globalPeerDependencyRules` setting provides various settings for suppressing validation errors * that are reported during installation with `strictPeerDependencies=true`. The settings are copied diff --git a/libraries/rush-lib/src/logic/DependencySpecifier.ts b/libraries/rush-lib/src/logic/DependencySpecifier.ts index 52eefceb0dc..d26561ec54e 100644 --- a/libraries/rush-lib/src/logic/DependencySpecifier.ts +++ b/libraries/rush-lib/src/logic/DependencySpecifier.ts @@ -13,6 +13,14 @@ import { InternalError } from '@rushstack/node-core-library'; */ const WORKSPACE_PREFIX_REGEX: RegExp = /^workspace:((?[^._/][^@]*)@)?(?.*)$/; +/** + * match catalog protocol in dependencies value declaration in `package.json` + * example: + * `"catalog:"` - uses the default catalog + * `"catalog:react18"` - uses the named catalog "react18" + */ +const CATALOG_PREFIX_REGEX: RegExp = /^catalog:(?.*)$/; + /** * resolve workspace protocol(from `@pnpm/workspace.spec-parser`). * used by pnpm. see [pkgs-graph](https://github.com/pnpm/pnpm/blob/27c33f0319f86c45c1645d064cd9c28aada80780/workspace/pkgs-graph/src/index.ts#L49) @@ -87,7 +95,12 @@ export enum DependencySpecifierType { /** * A package specified using workspace protocol, e.g. "workspace:^1.2.3" */ - Workspace = 'Workspace' + Workspace = 'Workspace', + + /** + * A package specified using catalog protocol, e.g. "catalog:" or "catalog:react18" + */ + Catalog = 'Catalog' } const dependencySpecifierParseCache: Map = new Map(); @@ -121,14 +134,32 @@ export class DependencySpecifier { */ public readonly aliasTarget: DependencySpecifier | undefined; + /** + * If `specifierType` is `Catalog`, then this is the catalog name. + * For example, if version specifier is `"catalog:react18"` then this is `"react18"`. + * If version specifier is `"catalog:"` (default catalog), then this is `"default"`. + */ + public readonly catalogName: string | undefined; + public constructor(packageName: string, versionSpecifier: string) { this.packageName = packageName; this.versionSpecifier = versionSpecifier; + // Catalog protocol is a PNPM feature. Parse the catalog name. + const catalogMatch: RegExpExecArray | null = CATALOG_PREFIX_REGEX.exec(versionSpecifier); + if (catalogMatch?.groups) { + this.specifierType = DependencySpecifierType.Catalog; + // If no catalog name is provided, use "default" + this.catalogName = catalogMatch.groups.catalogName || 'default'; + this.aliasTarget = undefined; + return; + } + // Workspace ranges are a feature from PNPM and Yarn. Set the version specifier // to the trimmed version range. const workspaceSpecResult: WorkspaceSpec | undefined = WorkspaceSpec.tryParse(versionSpecifier); if (workspaceSpecResult) { + this.catalogName = undefined; this.specifierType = DependencySpecifierType.Workspace; this.versionSpecifier = workspaceSpecResult.versionSpecifier; @@ -145,6 +176,8 @@ export class DependencySpecifier { return; } + this.catalogName = undefined; + const result: npmPackageArg.Result = npmPackageArg.resolve(packageName, versionSpecifier); this.specifierType = DependencySpecifier.getDependencySpecifierType(result.type); diff --git a/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts b/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts index 9411c549e43..8159adee6f2 100644 --- a/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts +++ b/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts @@ -440,6 +440,13 @@ export class WorkspaceInstallManager extends BaseInstallManager { shrinkwrapIsUpToDate = false; } + // Set catalog configuration from pnpmOptions if defined + // Catalogs allow defining reusable dependency version ranges that can be referenced + // in package.json files using the "catalog:" or "catalog:" protocol + if (pnpmOptions.globalCatalogs) { + workspaceFile.setCatalogs(pnpmOptions.globalCatalogs); + } + // Write the common package.json InstallHelpers.generateCommonPackageJson(this.rushConfiguration, subspace, undefined, this._terminal); diff --git a/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts b/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts index 4b249e7a883..74053a31a9d 100644 --- a/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts +++ b/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts @@ -102,6 +102,10 @@ export interface IPnpmOptionsJson extends IPackageManagerOptionsJsonBase { * {@inheritDoc PnpmOptionsConfiguration.globalOverrides} */ globalOverrides?: Record; + /** + * {@inheritDoc PnpmOptionsConfiguration.globalCatalogs} + */ + globalCatalogs?: Record>; /** * {@inheritDoc PnpmOptionsConfiguration.globalPeerDependencyRules} */ @@ -319,6 +323,22 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration */ public readonly globalOverrides: Record | undefined; + /** + * The "globalCatalogs" setting defines named catalogs for the PNPM workspace. + * Named catalogs allow you to organize dependency version ranges into logical groups + * that can be referenced using the "catalog:\" protocol. For example, if you define + * a "react18" catalog with `"react": "^18.2.0"`, projects can use `"react": "catalog:react18"` + * in their dependencies. + * + * This setting is written to the `catalogs` field in the generated `pnpm-workspace.yaml` file. + * + * @remarks + * (SUPPORTED ONLY IN PNPM 9.5.0 AND NEWER) + * + * PNPM documentation: https://pnpm.io/catalogs + */ + public readonly globalCatalogs: Record> | undefined; + /** * The `globalPeerDependencyRules` setting provides various settings for suppressing validation errors * that are reported during installation with `strictPeerDependencies=true`. The settings are copied @@ -451,6 +471,7 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration this.useWorkspaces = !!json.useWorkspaces; this.globalOverrides = json.globalOverrides; + this.globalCatalogs = json.globalCatalogs; this.globalPeerDependencyRules = json.globalPeerDependencyRules; this.globalPackageExtensions = json.globalPackageExtensions; this.globalNeverBuiltDependencies = json.globalNeverBuiltDependencies; diff --git a/libraries/rush-lib/src/logic/pnpm/PnpmWorkspaceFile.ts b/libraries/rush-lib/src/logic/pnpm/PnpmWorkspaceFile.ts index 8ecccd04c0f..31152312fad 100644 --- a/libraries/rush-lib/src/logic/pnpm/PnpmWorkspaceFile.ts +++ b/libraries/rush-lib/src/logic/pnpm/PnpmWorkspaceFile.ts @@ -18,12 +18,23 @@ const globEscape: (unescaped: string) => string = require('glob-escape'); // No * { * "packages": [ * "../../apps/project1" - * ] + * ], + * "catalogs": { + * "default": { + * "lodash": "^4.17.21" + * }, + * "react18": { + * "react": "^18.2.0", + * "react-dom": "^18.2.0" + * } + * } * } */ interface IPnpmWorkspaceYaml { /** The list of local package directories */ packages: string[]; + /** Named catalogs - maps catalog names to package version mappings */ + catalogs?: Record>; } export class PnpmWorkspaceFile extends BaseWorkspaceFile { @@ -33,6 +44,7 @@ export class PnpmWorkspaceFile extends BaseWorkspaceFile { public readonly workspaceFilename: string; private _workspacePackages: Set; + private _catalogs: Record> | undefined; /** * The PNPM workspace file is used to specify the location of workspaces relative to the root @@ -45,6 +57,7 @@ export class PnpmWorkspaceFile extends BaseWorkspaceFile { // Ignore any existing file since this file is generated and we need to handle deleting packages // If we need to support manual customization, that should be an additional parameter for "base file" this._workspacePackages = new Set(); + this._catalogs = undefined; } /** @override */ @@ -59,6 +72,19 @@ export class PnpmWorkspaceFile extends BaseWorkspaceFile { this._workspacePackages.add(globEscape(globPath)); } + /** + * Set the named catalogs for the workspace. + * Catalogs allow defining reusable dependency version ranges that can be referenced + * in package.json files using the "catalog:" or "catalog:\" protocol. + * Use the "default" catalog name for packages that should be referenced with "catalog:" + * (no name), or use custom catalog names for "catalog:\" references. + * + * @param catalogs - A record mapping catalog names to package version mappings, or undefined to clear + */ + public setCatalogs(catalogs: Record> | undefined): void { + this._catalogs = catalogs; + } + /** @override */ protected serialize(): string { // Ensure stable sort order when serializing @@ -67,6 +93,22 @@ export class PnpmWorkspaceFile extends BaseWorkspaceFile { const workspaceYaml: IPnpmWorkspaceYaml = { packages: Array.from(this._workspacePackages) }; + + // Add named catalogs if defined and non-empty + if (this._catalogs && Object.keys(this._catalogs).length > 0) { + // Sort the catalog names and entries for stable output + const sortedCatalogs: Record> = {}; + for (const catalogName of Object.keys(this._catalogs).sort()) { + const catalog: Record = this._catalogs[catalogName]; + const sortedCatalog: Record = {}; + for (const key of Object.keys(catalog).sort()) { + sortedCatalog[key] = catalog[key]; + } + sortedCatalogs[catalogName] = sortedCatalog; + } + workspaceYaml.catalogs = sortedCatalogs; + } + return yamlModule.dump(workspaceYaml, PNPM_SHRINKWRAP_YAML_FORMAT); } } diff --git a/libraries/rush-lib/src/logic/pnpm/test/PnpmOptionsConfiguration.test.ts b/libraries/rush-lib/src/logic/pnpm/test/PnpmOptionsConfiguration.test.ts index 75b9e3e874f..c0198100a84 100644 --- a/libraries/rush-lib/src/logic/pnpm/test/PnpmOptionsConfiguration.test.ts +++ b/libraries/rush-lib/src/logic/pnpm/test/PnpmOptionsConfiguration.test.ts @@ -86,4 +86,26 @@ describe(PnpmOptionsConfiguration.name, () => { '@myorg/*' ]); }); + + it('loads globalCatalogs', () => { + const pnpmConfiguration: PnpmOptionsConfiguration = PnpmOptionsConfiguration.loadFromJsonFileOrThrow( + `${__dirname}/jsonFiles/pnpm-config-catalogs.json`, + fakeCommonTempFolder + ); + + expect(TestUtilities.stripAnnotations(pnpmConfiguration.globalCatalogs)).toEqual({ + default: { + lodash: '^4.17.21', + typescript: '~5.0.0' + }, + react18: { + react: '^18.2.0', + 'react-dom': '^18.2.0' + }, + testing: { + jest: '^29.0.0', + mocha: '^10.0.0' + } + }); + }); }); diff --git a/libraries/rush-lib/src/logic/pnpm/test/PnpmWorkspaceFile.test.ts b/libraries/rush-lib/src/logic/pnpm/test/PnpmWorkspaceFile.test.ts new file mode 100644 index 00000000000..5ea5b633f7f --- /dev/null +++ b/libraries/rush-lib/src/logic/pnpm/test/PnpmWorkspaceFile.test.ts @@ -0,0 +1,131 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; +import * as os from 'node:os'; +import * as fs from 'node:fs'; +import { FileSystem } from '@rushstack/node-core-library'; +import { PnpmWorkspaceFile } from '../PnpmWorkspaceFile'; + +describe(PnpmWorkspaceFile.name, () => { + let tempDir: string; + + beforeEach(() => { + // Create a temporary directory for test files + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pnpm-workspace-test-')); + }); + + afterEach(() => { + // Clean up the temporary directory + FileSystem.deleteFolder(tempDir); + }); + + it('serializes workspace without catalogs', () => { + const workspaceFile: PnpmWorkspaceFile = new PnpmWorkspaceFile( + path.join(tempDir, 'pnpm-workspace.yaml') + ); + workspaceFile.addPackage('packages/project-a'); + workspaceFile.addPackage('packages/project-b'); + + workspaceFile.save(workspaceFile.workspaceFilename, { ensureFolderExists: true }); + + const content: string = FileSystem.readFile(workspaceFile.workspaceFilename); + expect(content).toContain('packages:'); + expect(content).toContain('packages/project-a'); + expect(content).toContain('packages/project-b'); + expect(content).not.toContain('catalogs:'); + }); + + it('serializes workspace with named catalogs', () => { + const workspaceFile: PnpmWorkspaceFile = new PnpmWorkspaceFile( + path.join(tempDir, 'pnpm-workspace.yaml') + ); + workspaceFile.addPackage('packages/project-a'); + workspaceFile.setCatalogs({ + default: { + lodash: '^4.17.21', + typescript: '~5.0.0' + }, + react18: { + react: '^18.2.0', + 'react-dom': '^18.2.0' + }, + testing: { + jest: '^29.0.0', + mocha: '^10.0.0' + } + }); + + workspaceFile.save(workspaceFile.workspaceFilename, { ensureFolderExists: true }); + + const content: string = FileSystem.readFile(workspaceFile.workspaceFilename); + expect(content).toContain('packages:'); + expect(content).toContain('catalogs:'); + expect(content).toContain('default:'); + expect(content).toContain('lodash:'); + expect(content).toContain('^4.17.21'); + expect(content).toContain('react18:'); + expect(content).toContain('react:'); + expect(content).toContain('^18.2.0'); + expect(content).toContain('testing:'); + expect(content).toContain('jest:'); + expect(content).toContain('^29.0.0'); + }); + + it('does not include empty catalogs', () => { + const workspaceFile: PnpmWorkspaceFile = new PnpmWorkspaceFile( + path.join(tempDir, 'pnpm-workspace.yaml') + ); + workspaceFile.addPackage('packages/project-a'); + workspaceFile.setCatalogs({}); + + workspaceFile.save(workspaceFile.workspaceFilename, { ensureFolderExists: true }); + + const content: string = FileSystem.readFile(workspaceFile.workspaceFilename); + expect(content).toContain('packages:'); + expect(content).not.toContain('catalogs:'); + }); + + it('sorts catalog entries alphabetically', () => { + const workspaceFile: PnpmWorkspaceFile = new PnpmWorkspaceFile( + path.join(tempDir, 'pnpm-workspace.yaml') + ); + workspaceFile.addPackage('packages/project-a'); + workspaceFile.setCatalogs({ + default: { + zod: '^3.0.0', + axios: '^1.0.0', + lodash: '^4.17.21' + } + }); + + workspaceFile.save(workspaceFile.workspaceFilename, { ensureFolderExists: true }); + + const content: string = FileSystem.readFile(workspaceFile.workspaceFilename); + const axiosIndex: number = content.indexOf('axios:'); + const lodashIndex: number = content.indexOf('lodash:'); + const zodIndex: number = content.indexOf('zod:'); + + expect(axiosIndex).toBeLessThan(lodashIndex); + expect(lodashIndex).toBeLessThan(zodIndex); + }); + + it('clears catalogs when set to undefined', () => { + const workspaceFile: PnpmWorkspaceFile = new PnpmWorkspaceFile( + path.join(tempDir, 'pnpm-workspace.yaml') + ); + workspaceFile.addPackage('packages/project-a'); + workspaceFile.setCatalogs({ + default: { + lodash: '^4.17.21' + } + }); + workspaceFile.setCatalogs(undefined); + + workspaceFile.save(workspaceFile.workspaceFilename, { ensureFolderExists: true }); + + const content: string = FileSystem.readFile(workspaceFile.workspaceFilename); + expect(content).not.toContain('catalogs:'); + expect(content).not.toContain('lodash:'); + }); +}); diff --git a/libraries/rush-lib/src/logic/pnpm/test/jsonFiles/pnpm-config-catalogs.json b/libraries/rush-lib/src/logic/pnpm/test/jsonFiles/pnpm-config-catalogs.json new file mode 100644 index 00000000000..4ce38812996 --- /dev/null +++ b/libraries/rush-lib/src/logic/pnpm/test/jsonFiles/pnpm-config-catalogs.json @@ -0,0 +1,16 @@ +{ + "globalCatalogs": { + "default": { + "lodash": "^4.17.21", + "typescript": "~5.0.0" + }, + "react18": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "testing": { + "jest": "^29.0.0", + "mocha": "^10.0.0" + } + } +} diff --git a/libraries/rush-lib/src/logic/test/DependencySpecifier.test.ts b/libraries/rush-lib/src/logic/test/DependencySpecifier.test.ts index 1d738bf9b0d..94f59c1e42e 100644 --- a/libraries/rush-lib/src/logic/test/DependencySpecifier.test.ts +++ b/libraries/rush-lib/src/logic/test/DependencySpecifier.test.ts @@ -13,6 +13,7 @@ describe(DependencySpecifier.name, () => { expect(specifier).toMatchInlineSnapshot(` DependencySpecifier { "aliasTarget": undefined, + "catalogName": undefined, "packageName": "dep", "specifierType": "Version", "versionSpecifier": "1.2.3", @@ -25,6 +26,7 @@ DependencySpecifier { expect(specifier).toMatchInlineSnapshot(` DependencySpecifier { "aliasTarget": undefined, + "catalogName": undefined, "packageName": "dep", "specifierType": "Range", "versionSpecifier": "^1.2.3", @@ -38,10 +40,12 @@ DependencySpecifier { DependencySpecifier { "aliasTarget": DependencySpecifier { "aliasTarget": undefined, + "catalogName": undefined, "packageName": "alias-target", "specifierType": "Version", "versionSpecifier": "1.2.3", }, + "catalogName": undefined, "packageName": "dep", "specifierType": "Alias", "versionSpecifier": "npm:alias-target@1.2.3", @@ -54,6 +58,7 @@ DependencySpecifier { expect(specifier).toMatchInlineSnapshot(` DependencySpecifier { "aliasTarget": undefined, + "catalogName": undefined, "packageName": "dep", "specifierType": "Git", "versionSpecifier": "git+https://github.com/user/foo", @@ -66,6 +71,7 @@ DependencySpecifier { expect(specifier).toMatchInlineSnapshot(` DependencySpecifier { "aliasTarget": undefined, + "catalogName": undefined, "packageName": "dep", "specifierType": "File", "versionSpecifier": "file:foo.tar.gz", @@ -78,6 +84,7 @@ DependencySpecifier { expect(specifier).toMatchInlineSnapshot(` DependencySpecifier { "aliasTarget": undefined, + "catalogName": undefined, "packageName": "dep", "specifierType": "Directory", "versionSpecifier": "file:../foo/bar/", @@ -90,6 +97,7 @@ DependencySpecifier { expect(specifier).toMatchInlineSnapshot(` DependencySpecifier { "aliasTarget": undefined, + "catalogName": undefined, "packageName": "dep", "specifierType": "Remote", "versionSpecifier": "https://example.com/foo.tgz", @@ -103,6 +111,7 @@ DependencySpecifier { expect(specifier).toMatchInlineSnapshot(` DependencySpecifier { "aliasTarget": undefined, + "catalogName": undefined, "packageName": "dep", "specifierType": "Workspace", "versionSpecifier": "*", @@ -115,6 +124,7 @@ DependencySpecifier { expect(specifier).toMatchInlineSnapshot(` DependencySpecifier { "aliasTarget": undefined, + "catalogName": undefined, "packageName": "dep", "specifierType": "Workspace", "versionSpecifier": "^1.0.0", @@ -128,10 +138,12 @@ DependencySpecifier { DependencySpecifier { "aliasTarget": DependencySpecifier { "aliasTarget": undefined, + "catalogName": undefined, "packageName": "alias-target", "specifierType": "Range", "versionSpecifier": "*", }, + "catalogName": undefined, "packageName": "dep", "specifierType": "Workspace", "versionSpecifier": "alias-target@*", @@ -158,4 +170,32 @@ DependencySpecifier { expect(specifier1).not.toBe(specifier2); }); }); + + describe('catalog: specifier', () => { + it('parses default catalog (catalog:)', () => { + const specifier = new DependencySpecifier('lodash', 'catalog:'); + expect(specifier).toMatchInlineSnapshot(` +DependencySpecifier { + "aliasTarget": undefined, + "catalogName": "default", + "packageName": "lodash", + "specifierType": "Catalog", + "versionSpecifier": "catalog:", +} +`); + }); + + it('parses named catalog (catalog:react18)', () => { + const specifier = new DependencySpecifier('react', 'catalog:react18'); + expect(specifier).toMatchInlineSnapshot(` +DependencySpecifier { + "aliasTarget": undefined, + "catalogName": "react18", + "packageName": "react", + "specifierType": "Catalog", + "versionSpecifier": "catalog:react18", +} +`); + }); + }); }); diff --git a/libraries/rush-lib/src/logic/test/pnpmCatalogs/a/config/rush-project.json b/libraries/rush-lib/src/logic/test/pnpmCatalogs/a/config/rush-project.json new file mode 100644 index 00000000000..103f06da2e0 --- /dev/null +++ b/libraries/rush-lib/src/logic/test/pnpmCatalogs/a/config/rush-project.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/rush-project.schema.json", + "operationSettings": [ + { + "operationName": "build", + "parameterNamesToIgnore": ["--production"] + } + ] +} diff --git a/libraries/rush-lib/src/logic/test/pnpmCatalogs/a/package.json b/libraries/rush-lib/src/logic/test/pnpmCatalogs/a/package.json new file mode 100644 index 00000000000..4b4feb8acbf --- /dev/null +++ b/libraries/rush-lib/src/logic/test/pnpmCatalogs/a/package.json @@ -0,0 +1,11 @@ +{ + "name": "a", + "version": "1.0.0", + "scripts": { + "build": "echo building a" + }, + "dependencies": { + "react": "catalog:react18", + "jest": "catalog:testing" + } +} diff --git a/libraries/rush-lib/src/logic/test/pnpmCatalogs/b/config/rush-project.json b/libraries/rush-lib/src/logic/test/pnpmCatalogs/b/config/rush-project.json new file mode 100644 index 00000000000..e6f27ab1857 --- /dev/null +++ b/libraries/rush-lib/src/logic/test/pnpmCatalogs/b/config/rush-project.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/rush-project.schema.json", + "operationSettings": [ + { + "operationName": "build", + "parameterNamesToIgnore": ["--verbose", "--config", "--mode", "--tags"] + } + ] +} diff --git a/libraries/rush-lib/src/logic/test/pnpmCatalogs/b/package.json b/libraries/rush-lib/src/logic/test/pnpmCatalogs/b/package.json new file mode 100644 index 00000000000..adb4cfd8dce --- /dev/null +++ b/libraries/rush-lib/src/logic/test/pnpmCatalogs/b/package.json @@ -0,0 +1,11 @@ +{ + "name": "b", + "version": "1.0.0", + "scripts": { + "build": "echo building b" + }, + "dependencies": { + "react": "catalog:react19", + "jest": "catalog:testing" + } +} diff --git a/libraries/rush-lib/src/logic/test/pnpmCatalogs/common/config/rush/pnpm-config.json b/libraries/rush-lib/src/logic/test/pnpmCatalogs/common/config/rush/pnpm-config.json new file mode 100644 index 00000000000..e9b1667b390 --- /dev/null +++ b/libraries/rush-lib/src/logic/test/pnpmCatalogs/common/config/rush/pnpm-config.json @@ -0,0 +1,21 @@ +{ + "useWorkspaces": true, + "globalCatalogs": { + "default": { + "lodash": "^4.17.21", + "typescript": "~5.0.0" + }, + "react19": { + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "react18": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "testing": { + "jest": "^29.0.0", + "mocha": "^10.0.0" + } + } +} \ No newline at end of file diff --git a/libraries/rush-lib/src/logic/test/pnpmCatalogs/rush.json b/libraries/rush-lib/src/logic/test/pnpmCatalogs/rush.json new file mode 100644 index 00000000000..b153f51c15e --- /dev/null +++ b/libraries/rush-lib/src/logic/test/pnpmCatalogs/rush.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/rush.schema.json", + "pnpmVersion": "10.24.0", + "rushVersion": "5.58.0", + "projects": [ + { + "packageName": "a", + "projectFolder": "a" + }, + { + "packageName": "b", + "projectFolder": "b" + } + ] +} diff --git a/libraries/rush-lib/src/schemas/pnpm-config.schema.json b/libraries/rush-lib/src/schemas/pnpm-config.schema.json index 0501173b754..8351a93fcf4 100644 --- a/libraries/rush-lib/src/schemas/pnpm-config.schema.json +++ b/libraries/rush-lib/src/schemas/pnpm-config.schema.json @@ -67,6 +67,19 @@ } }, + "globalCatalogs": { + "description": "The \"globalCatalogs\" setting defines named catalogs for the PNPM workspace. Catalogs allow you to define reusable dependency version ranges that can be referenced in package.json files. Use the \"default\" catalog name for packages that should be referenced with \"catalog:\" (no name), or use custom catalog names for \"catalog:\" references.\n\nFor example, if you define a \"default\" catalog with `\"lodash\": \"^4.17.21\"`, projects can use `\"lodash\": \"catalog:\"`. If you define a \"react18\" catalog with `\"react\": \"^18.2.0\"`, projects can use `\"react\": \"catalog:react18\"`.\n\nThis setting is written to the `catalogs` field in the generated `pnpm-workspace.yaml` file.\n\n(SUPPORTED ONLY IN PNPM 9.5.0 AND NEWER)\n\nPNPM documentation: https://pnpm.io/catalogs", + "type": "object", + "additionalProperties": { + "description": "A named catalog mapping package names to version ranges.", + "type": "object", + "additionalProperties": { + "description": "The version range for the package.", + "type": "string" + } + } + }, + "globalPeerDependencyRules": { "description": "The `globalPeerDependencyRules` setting provides various settings for suppressing validation errors that are reported during installation with `strictPeerDependencies=true`. The settings are copied into the `pnpm.peerDependencyRules` field of the `common/temp/package.json` file that is generated by Rush during installation.\n\nOrder of precedence: `.pnpmfile.cjs` has the highest precedence, followed by `unsupportedPackageJsonSettings`, `globalPeerDependencyRules`, `globalPackageExtensions`, and `globalOverrides` has lowest precedence.\n\nhttps://pnpm.io/package_json#pnpmpeerdependencyrules", "type": "object",