diff --git a/common/changes/@microsoft/rush/sennyeya-pnpm-catalog_2025-11-25-18-38.json b/common/changes/@microsoft/rush/sennyeya-pnpm-catalog_2025-11-25-18-38.json new file mode 100644 index 00000000000..afcb7b99bb0 --- /dev/null +++ b/common/changes/@microsoft/rush/sennyeya-pnpm-catalog_2025-11-25-18-38.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "Add support for defining pnpm catalog config.", + "type": "none", + "packageName": "@microsoft/rush" + } + ], + "packageName": "@microsoft/rush", + "email": "aramissennyeydd@users.noreply.github.com" +} \ 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..6237c53a526 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -740,6 +740,8 @@ export interface _IPnpmOptionsJson extends IPackageManagerOptionsJsonBase { alwaysInjectDependenciesFromOtherSubspaces?: boolean; autoInstallPeers?: boolean; globalAllowedDeprecatedVersions?: Record; + globalCatalog?: Record; + globalCatalogs?: Record>; globalIgnoredOptionalDependencies?: string[]; globalNeverBuiltDependencies?: string[]; globalOverrides?: Record; @@ -1151,6 +1153,8 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration readonly alwaysInjectDependenciesFromOtherSubspaces: boolean | undefined; readonly autoInstallPeers: boolean | undefined; readonly globalAllowedDeprecatedVersions: Record | undefined; + readonly globalCatalog: Record | undefined; + readonly globalCatalogs: Record> | undefined; readonly globalIgnoredOptionalDependencies: string[] | undefined; readonly globalNeverBuiltDependencies: string[] | undefined; readonly globalOverrides: Record | undefined; @@ -1203,6 +1207,7 @@ export class RepoStateFile { get isValid(): boolean; static loadFromFile(jsonFilename: string): RepoStateFile; get packageJsonInjectedDependenciesHash(): string | undefined; + get pnpmCatalogsHash(): string | undefined; get pnpmShrinkwrapHash(): string | undefined; get preferredVersionsHash(): string | undefined; refreshState(rushConfiguration: RushConfiguration, subspace: Subspace | undefined, variant?: string): boolean; @@ -1566,6 +1571,7 @@ export class Subspace { getCommonVersionsFilePath(variant?: string): string; // @beta getPackageJsonInjectedDependenciesHash(variant?: string): string | undefined; + getPnpmCatalogsHash(): string | undefined; // @beta getPnpmConfigFilePath(): string; // @beta diff --git a/libraries/rush-lib/src/api/Subspace.ts b/libraries/rush-lib/src/api/Subspace.ts index 741240d5f36..f272f103958 100644 --- a/libraries/rush-lib/src/api/Subspace.ts +++ b/libraries/rush-lib/src/api/Subspace.ts @@ -409,6 +409,34 @@ export class Subspace { this._projects.push(project); } + /** + * Computes a hash of the PNPM catalog definitions for this subspace. + * Returns undefined if no catalogs are defined. + */ + public getPnpmCatalogsHash(): string | undefined { + const pnpmOptions: PnpmOptionsConfiguration | undefined = this.getPnpmOptions(); + if (!pnpmOptions) { + return undefined; + } + + const catalogData: Record = {}; + if (pnpmOptions.globalCatalog && Object.keys(pnpmOptions.globalCatalog).length !== 0) { + catalogData.default = pnpmOptions.globalCatalog; + } + if (pnpmOptions.globalCatalogs && Object.keys(pnpmOptions.globalCatalogs).length !== 0) { + Object.assign(catalogData, pnpmOptions.globalCatalogs); + } + + // If no catalogs are defined, return undefined + if (Object.keys(catalogData).length === 0) { + return undefined; + } + + const hash: crypto.Hash = crypto.createHash('sha1'); + hash.update(JSON.stringify(catalogData)); + return hash.digest('hex'); + } + /** * Returns hash value of injected dependencies in related package.json. * @beta diff --git a/libraries/rush-lib/src/api/test/Subspace.test.ts b/libraries/rush-lib/src/api/test/Subspace.test.ts new file mode 100644 index 00000000000..b7e01689519 --- /dev/null +++ b/libraries/rush-lib/src/api/test/Subspace.test.ts @@ -0,0 +1,77 @@ +// 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 { RushConfiguration } from '../RushConfiguration'; +import { Subspace } from '../Subspace'; + +describe(Subspace.name, () => { + describe('getPnpmCatalogsHash', () => { + it('returns undefined when no catalogs are defined', () => { + const rushJsonFilename: string = path.resolve(__dirname, 'repo', 'rush-pnpm.json'); + const rushConfiguration: RushConfiguration = + RushConfiguration.loadFromConfigurationFile(rushJsonFilename); + const defaultSubspace: Subspace = rushConfiguration.defaultSubspace; + + const catalogsHash: string | undefined = defaultSubspace.getPnpmCatalogsHash(); + expect(catalogsHash).toBeUndefined(); + }); + + it('returns undefined for non-pnpm package manager', () => { + const rushJsonFilename: string = path.resolve(__dirname, 'repo', 'rush-npm.json'); + const rushConfiguration: RushConfiguration = + RushConfiguration.loadFromConfigurationFile(rushJsonFilename); + const defaultSubspace: Subspace = rushConfiguration.defaultSubspace; + + const catalogsHash: string | undefined = defaultSubspace.getPnpmCatalogsHash(); + expect(catalogsHash).toBeUndefined(); + }); + + it('computes hash when catalogs are defined', () => { + const rushJsonFilename: string = path.resolve(__dirname, 'repoCatalogs', 'rush.json'); + const rushConfiguration: RushConfiguration = + RushConfiguration.loadFromConfigurationFile(rushJsonFilename); + const defaultSubspace: Subspace = rushConfiguration.defaultSubspace; + + const catalogsHash: string | undefined = defaultSubspace.getPnpmCatalogsHash(); + expect(catalogsHash).toBeDefined(); + expect(typeof catalogsHash).toBe('string'); + expect(catalogsHash).toHaveLength(40); // SHA1 hash is 40 characters + }); + + it('computes consistent hash for same catalog data', () => { + const rushJsonFilename: string = path.resolve(__dirname, 'repoCatalogs', 'rush.json'); + const rushConfiguration: RushConfiguration = + RushConfiguration.loadFromConfigurationFile(rushJsonFilename); + const defaultSubspace: Subspace = rushConfiguration.defaultSubspace; + + const hash1: string | undefined = defaultSubspace.getPnpmCatalogsHash(); + const hash2: string | undefined = defaultSubspace.getPnpmCatalogsHash(); + + expect(hash1).toBeDefined(); + expect(hash1).toBe(hash2); + }); + + it('computes different hashes for different catalog data', () => { + // Configuration without catalogs + const rushJsonWithoutCatalogs: string = path.resolve(__dirname, 'repo', 'rush-pnpm.json'); + const rushConfigWithoutCatalogs: RushConfiguration = + RushConfiguration.loadFromConfigurationFile(rushJsonWithoutCatalogs); + const subspaceWithoutCatalogs: Subspace = rushConfigWithoutCatalogs.defaultSubspace; + + // Configuration with catalogs + const rushJsonWithCatalogs: string = path.resolve(__dirname, 'repoCatalogs', 'rush.json'); + const rushConfigWithCatalogs: RushConfiguration = + RushConfiguration.loadFromConfigurationFile(rushJsonWithCatalogs); + const subspaceWithCatalogs: Subspace = rushConfigWithCatalogs.defaultSubspace; + + const hashWithoutCatalogs: string | undefined = subspaceWithoutCatalogs.getPnpmCatalogsHash(); + const hashWithCatalogs: string | undefined = subspaceWithCatalogs.getPnpmCatalogsHash(); + + // One should be undefined (no catalogs) and one should have a hash + expect(hashWithoutCatalogs).toBeUndefined(); + expect(hashWithCatalogs).toBeDefined(); + }); + }); +}); diff --git a/libraries/rush-lib/src/api/test/repoCatalogs/common/config/rush/pnpm-config.json b/libraries/rush-lib/src/api/test/repoCatalogs/common/config/rush/pnpm-config.json new file mode 100644 index 00000000000..38133cb190f --- /dev/null +++ b/libraries/rush-lib/src/api/test/repoCatalogs/common/config/rush/pnpm-config.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/pnpm-config.schema.json", + "globalCatalog": { + "react": "^18.0.0", + "react-dom": "^18.0.0", + "typescript": "~5.3.0" + }, + "globalCatalogs": { + "internal": { + "lodash": "^4.17.21", + "axios": "^1.6.0" + } + } +} diff --git a/libraries/rush-lib/src/api/test/repoCatalogs/project1/package.json b/libraries/rush-lib/src/api/test/repoCatalogs/project1/package.json new file mode 100644 index 00000000000..31cf82c13cf --- /dev/null +++ b/libraries/rush-lib/src/api/test/repoCatalogs/project1/package.json @@ -0,0 +1,4 @@ +{ + "name": "project1", + "version": "1.0.0" +} diff --git a/libraries/rush-lib/src/api/test/repoCatalogs/rush.json b/libraries/rush-lib/src/api/test/repoCatalogs/rush.json new file mode 100644 index 00000000000..a045354bbc2 --- /dev/null +++ b/libraries/rush-lib/src/api/test/repoCatalogs/rush.json @@ -0,0 +1,13 @@ +{ + "pnpmVersion": "9.5.0", + "rushVersion": "5.46.1", + "projectFolderMinDepth": 1, + "projectFolderMaxDepth": 99, + + "projects": [ + { + "packageName": "project1", + "projectFolder": "project1" + } + ] +} diff --git a/libraries/rush-lib/src/logic/DependencySpecifier.ts b/libraries/rush-lib/src/logic/DependencySpecifier.ts index 52eefceb0dc..f43e4110f9a 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:"` - references the default catalog + * `"catalog:catalogName"` - references a named catalog + */ +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) @@ -40,6 +48,29 @@ class WorkspaceSpec { } } +/** + * resolve catalog protocol. + * Used by pnpm for centralized version management via catalogs. + */ +class CatalogSpec { + public readonly catalogName: string; + + public constructor(catalogName: string) { + this.catalogName = catalogName; + } + + public static tryParse(pref: string): CatalogSpec | undefined { + const parts: RegExpExecArray | null = CATALOG_PREFIX_REGEX.exec(pref); + if (parts?.groups !== undefined) { + return new CatalogSpec(parts.groups.catalogName); + } + } + + public toString(): `catalog:${string}` { + return `catalog:${this.catalogName}`; + } +} + /** * The parsed format of a provided version specifier. */ @@ -87,7 +118,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(); @@ -125,6 +161,15 @@ export class DependencySpecifier { this.packageName = packageName; this.versionSpecifier = versionSpecifier; + // Catalog protocol is a feature from PNPM for centralized version management + const catalogSpecResult: CatalogSpec | undefined = CatalogSpec.tryParse(versionSpecifier); + if (catalogSpecResult) { + this.specifierType = DependencySpecifierType.Catalog; + this.versionSpecifier = catalogSpecResult.catalogName; + 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); diff --git a/libraries/rush-lib/src/logic/RepoStateFile.ts b/libraries/rush-lib/src/logic/RepoStateFile.ts index 074609fa363..0bfd0f2e82e 100644 --- a/libraries/rush-lib/src/logic/RepoStateFile.ts +++ b/libraries/rush-lib/src/logic/RepoStateFile.ts @@ -15,7 +15,8 @@ import type { Subspace } from '../api/Subspace'; * { * "pnpmShrinkwrapHash": "...", * "preferredVersionsHash": "...", - * "packageJsonInjectedDependenciesHash": "..." + * "packageJsonInjectedDependenciesHash": "...", + * "pnpmCatalogsHash": "..." * } */ interface IRepoStateJson { @@ -31,6 +32,10 @@ interface IRepoStateJson { * A hash of the injected dependencies in related package.json */ packageJsonInjectedDependenciesHash?: string; + /** + * A hash of the PNPM catalog definitions + */ + pnpmCatalogsHash?: string; } /** @@ -45,6 +50,7 @@ export class RepoStateFile { private _pnpmShrinkwrapHash: string | undefined; private _preferredVersionsHash: string | undefined; private _packageJsonInjectedDependenciesHash: string | undefined; + private _pnpmCatalogsHash: string | undefined; private _isValid: boolean; private _modified: boolean = false; @@ -61,6 +67,7 @@ export class RepoStateFile { this._pnpmShrinkwrapHash = repoStateJson.pnpmShrinkwrapHash; this._preferredVersionsHash = repoStateJson.preferredVersionsHash; this._packageJsonInjectedDependenciesHash = repoStateJson.packageJsonInjectedDependenciesHash; + this._pnpmCatalogsHash = repoStateJson.pnpmCatalogsHash; } } @@ -85,6 +92,13 @@ export class RepoStateFile { return this._packageJsonInjectedDependenciesHash; } + /** + * The hash of the PNPM catalog definitions at the end of the last update. + */ + public get pnpmCatalogsHash(): string | undefined { + return this._pnpmCatalogsHash; + } + /** * If false, the repo-state.json file is not valid and its values cannot be relied upon */ @@ -219,6 +233,16 @@ export class RepoStateFile { this._packageJsonInjectedDependenciesHash = undefined; this._modified = true; } + + // Track catalog hash to detect when catalog definitions change + const pnpmCatalogsHash: string | undefined = subspace.getPnpmCatalogsHash(); + if (pnpmCatalogsHash && pnpmCatalogsHash !== this._pnpmCatalogsHash) { + this._pnpmCatalogsHash = pnpmCatalogsHash; + this._modified = true; + } else if (!pnpmCatalogsHash && this._pnpmCatalogsHash) { + this._pnpmCatalogsHash = undefined; + this._modified = true; + } } // Now that the file has been refreshed, we know its contents are valid @@ -255,6 +279,9 @@ export class RepoStateFile { if (this._packageJsonInjectedDependenciesHash) { repoStateJson.packageJsonInjectedDependenciesHash = this._packageJsonInjectedDependenciesHash; } + if (this._pnpmCatalogsHash) { + repoStateJson.pnpmCatalogsHash = this._pnpmCatalogsHash; + } return JsonFile.stringify(repoStateJson, { newlineConversion: NewlineKind.Lf }); } diff --git a/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts b/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts index 9411c549e43..94b9b3490ba 100644 --- a/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts +++ b/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts @@ -443,6 +443,36 @@ export class WorkspaceInstallManager extends BaseInstallManager { // Write the common package.json InstallHelpers.generateCommonPackageJson(this.rushConfiguration, subspace, undefined, this._terminal); + // Set catalog definitions in the workspace file if specified + if (pnpmOptions.globalCatalog || pnpmOptions.globalCatalogs) { + if ( + this.rushConfiguration.rushConfigurationJson.pnpmVersion !== undefined && + semver.lt(this.rushConfiguration.rushConfigurationJson.pnpmVersion, '9.5.0') + ) { + this._terminal.writeWarningLine( + Colorize.yellow( + `Your version of pnpm (${this.rushConfiguration.rushConfigurationJson.pnpmVersion}) ` + + `doesn't support the "globalCatalog" or "globalCatalogs" fields in ` + + `${this.rushConfiguration.commonRushConfigFolder}/${RushConstants.pnpmConfigFilename}. ` + + 'Remove these fields or upgrade to pnpm 9.5.0 or newer.' + ) + ); + } + + const catalogs: Record> = {}; + + if (pnpmOptions.globalCatalog) { + // https://pnpm.io/catalogs#default-catalog, basically `catalog` is an alias for `catalogs.default` in pnpm. + catalogs.default = pnpmOptions.globalCatalog; + } + + if (pnpmOptions.globalCatalogs) { + Object.assign(catalogs, pnpmOptions.globalCatalogs); + } + + workspaceFile.setCatalogs(catalogs); + } + // Save the generated workspace file. Don't update the file timestamp unless the content has changed, // since "rush install" will consider this timestamp workspaceFile.save(workspaceFile.workspaceFilename, { onlyIfChanged: true }); diff --git a/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts b/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts index 4b249e7a883..33d0361c2a4 100644 --- a/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts +++ b/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts @@ -158,6 +158,14 @@ export interface IPnpmOptionsJson extends IPackageManagerOptionsJsonBase { * {@inheritDoc PnpmOptionsConfiguration.pnpmLockfilePolicies} */ pnpmLockfilePolicies?: IPnpmLockfilePolicies; + /** + * {@inheritDoc PnpmOptionsConfiguration.globalCatalog} + */ + globalCatalog?: Record; + /** + * {@inheritDoc PnpmOptionsConfiguration.globalCatalogs} + */ + globalCatalogs?: Record>; } /** @@ -421,6 +429,28 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration /*[LINE "DEMO"]*/ public readonly alwaysFullInstall: boolean | undefined; + /** + * The `globalCatalog` setting provides a centralized way to define dependency versions + * that can be referenced using the `catalog:` protocol in package.json files. + * The settings are written to the `catalogs.default` field of the `pnpm-workspace.yaml` + * file that is generated by Rush during installation. + * + * This is syntactic sugar for `globalCatalogs.default`. + * + * PNPM documentation: https://pnpm.io/catalogs + */ + public readonly globalCatalog: Record | undefined; + + /** + * The `globalCatalogs` setting provides named catalogs for organizing dependency versions. + * Each catalog can be referenced using the `catalog:catalogName` protocol in package.json files + * (e.g., `catalog:react18`). The settings are written to the `catalogs` field of the + * `pnpm-workspace.yaml` file that is generated by Rush during installation. + * + * PNPM documentation: https://pnpm.io/catalogs + */ + public readonly globalCatalogs: Record> | undefined; + /** * (GENERATED BY RUSH-PNPM PATCH-COMMIT) When modifying this property, make sure you know what you are doing. * @@ -465,6 +495,8 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration this.alwaysInjectDependenciesFromOtherSubspaces = json.alwaysInjectDependenciesFromOtherSubspaces; this.alwaysFullInstall = json.alwaysFullInstall; this.pnpmLockfilePolicies = json.pnpmLockfilePolicies; + this.globalCatalog = json.globalCatalog; + this.globalCatalogs = json.globalCatalogs; } /** @internal */ diff --git a/libraries/rush-lib/src/logic/pnpm/PnpmWorkspaceFile.ts b/libraries/rush-lib/src/logic/pnpm/PnpmWorkspaceFile.ts index 8ecccd04c0f..f2d86e24dc5 100644 --- a/libraries/rush-lib/src/logic/pnpm/PnpmWorkspaceFile.ts +++ b/libraries/rush-lib/src/logic/pnpm/PnpmWorkspaceFile.ts @@ -18,12 +18,19 @@ const globEscape: (unescaped: string) => string = require('glob-escape'); // No * { * "packages": [ * "../../apps/project1" - * ] + * ], + * "catalogs": { + * "default": { + * "react": "^18.0.0" + * } + * } * } */ interface IPnpmWorkspaceYaml { /** The list of local package directories */ packages: string[]; + /** Catalog definitions for centralized version management */ + catalogs?: Record>; } export class PnpmWorkspaceFile extends BaseWorkspaceFile { @@ -33,6 +40,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 +53,15 @@ 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; + } + + /** + * Sets the catalog definitions for the workspace. + * @param catalogs - A map of catalog name to package versions + */ + public setCatalogs(catalogs: Record> | undefined): void { + this._catalogs = catalogs; } /** @override */ @@ -67,6 +84,11 @@ export class PnpmWorkspaceFile extends BaseWorkspaceFile { const workspaceYaml: IPnpmWorkspaceYaml = { packages: Array.from(this._workspacePackages) }; + + if (this._catalogs && Object.keys(this._catalogs).length > 0) { + workspaceYaml.catalogs = this._catalogs; + } + 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..372771f54fe 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,28 @@ describe(PnpmOptionsConfiguration.name, () => { '@myorg/*' ]); }); + + it('loads catalog and catalogs', () => { + const pnpmConfiguration: PnpmOptionsConfiguration = PnpmOptionsConfiguration.loadFromJsonFileOrThrow( + `${__dirname}/jsonFiles/pnpm-config-catalog.json`, + fakeCommonTempFolder + ); + + expect(TestUtilities.stripAnnotations(pnpmConfiguration.globalCatalog)).toEqual({ + react: '^18.0.0', + 'react-dom': '^18.0.0', + typescript: '~5.3.0' + }); + + expect(TestUtilities.stripAnnotations(pnpmConfiguration.globalCatalogs)).toEqual({ + frontend: { + vue: '^3.4.0', + 'vue-router': '^4.2.0' + }, + backend: { + express: '^4.18.0', + fastify: '^4.26.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..d8cdb149a88 --- /dev/null +++ b/libraries/rush-lib/src/logic/pnpm/test/PnpmWorkspaceFile.test.ts @@ -0,0 +1,183 @@ +// 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 { FileSystem } from '@rushstack/node-core-library'; +import { PnpmWorkspaceFile } from '../PnpmWorkspaceFile'; + +describe(PnpmWorkspaceFile.name, () => { + const tempDir: string = path.join(__dirname, 'temp'); + const workspaceFilePath: string = path.join(tempDir, 'pnpm-workspace.yaml'); + const projectsDir: string = path.join(tempDir, 'projects'); + + let mockWriteFile: jest.SpyInstance; + let mockReadFile: jest.SpyInstance; + let mockExists: jest.SpyInstance; + let writtenContent: string | undefined; + + beforeEach(() => { + writtenContent = undefined; + + // Mock FileSystem.writeFile to capture content instead of writing to disk + mockWriteFile = jest + .spyOn(FileSystem, 'writeFile') + .mockImplementation((filePath: string, contents: string | Buffer) => { + void filePath; // Unused parameter + writtenContent = typeof contents === 'string' ? contents : contents.toString(); + }); + + // Mock FileSystem.readFile to return the written content + mockReadFile = jest.spyOn(FileSystem, 'readFile').mockImplementation(() => { + if (writtenContent === undefined) { + throw new Error('File not found'); + } + return writtenContent; + }); + + // Mock FileSystem.exists to return true if content was written + mockExists = jest.spyOn(FileSystem, 'exists').mockImplementation(() => { + return writtenContent !== undefined; + }); + }); + + afterEach(() => { + mockWriteFile.mockRestore(); + mockReadFile.mockRestore(); + mockExists.mockRestore(); + }); + + describe('basic functionality', () => { + it('generates workspace file with packages only', () => { + const workspaceFile: PnpmWorkspaceFile = new PnpmWorkspaceFile(workspaceFilePath); + workspaceFile.addPackage(path.join(projectsDir, 'app1')); + workspaceFile.addPackage(path.join(projectsDir, 'app2')); + + workspaceFile.save(workspaceFilePath, { onlyIfChanged: true }); + + const content: string = FileSystem.readFile(workspaceFilePath); + expect(content).toMatchSnapshot(); + }); + + it('escapes special characters in package paths', () => { + const workspaceFile: PnpmWorkspaceFile = new PnpmWorkspaceFile(workspaceFilePath); + workspaceFile.addPackage(path.join(projectsDir, '[app-with-brackets]')); + + workspaceFile.save(workspaceFilePath, { onlyIfChanged: true }); + + const content: string = FileSystem.readFile(workspaceFilePath); + expect(content).toContain('\\[app-with-brackets\\]'); + }); + }); + + describe('catalog functionality', () => { + it('generates workspace file with default catalog only', () => { + const workspaceFile: PnpmWorkspaceFile = new PnpmWorkspaceFile(workspaceFilePath); + workspaceFile.addPackage(path.join(projectsDir, 'app1')); + + workspaceFile.setCatalogs({ + default: { + react: '^18.0.0', + 'react-dom': '^18.0.0', + typescript: '~5.3.0' + } + }); + + workspaceFile.save(workspaceFilePath, { onlyIfChanged: true }); + + const content: string = FileSystem.readFile(workspaceFilePath); + expect(content).toMatchSnapshot(); + }); + + it('generates workspace file with named catalogs', () => { + const workspaceFile: PnpmWorkspaceFile = new PnpmWorkspaceFile(workspaceFilePath); + workspaceFile.addPackage(path.join(projectsDir, 'app1')); + + workspaceFile.setCatalogs({ + default: { + typescript: '~5.3.0' + }, + frontend: { + vue: '^3.4.0', + 'vue-router': '^4.2.0' + }, + backend: { + express: '^4.18.0', + fastify: '^4.26.0' + } + }); + + workspaceFile.save(workspaceFilePath, { onlyIfChanged: true }); + + const content: string = FileSystem.readFile(workspaceFilePath); + expect(content).toMatchSnapshot(); + }); + + it('handles empty catalog object', () => { + const workspaceFile: PnpmWorkspaceFile = new PnpmWorkspaceFile(workspaceFilePath); + workspaceFile.addPackage(path.join(projectsDir, 'app1')); + + workspaceFile.setCatalogs({}); + + workspaceFile.save(workspaceFilePath, { onlyIfChanged: true }); + + const content: string = FileSystem.readFile(workspaceFilePath); + expect(content).toMatchSnapshot(); + }); + + it('handles undefined catalog', () => { + const workspaceFile: PnpmWorkspaceFile = new PnpmWorkspaceFile(workspaceFilePath); + workspaceFile.addPackage(path.join(projectsDir, 'app1')); + + workspaceFile.setCatalogs(undefined); + + workspaceFile.save(workspaceFilePath, { onlyIfChanged: true }); + + const content: string = FileSystem.readFile(workspaceFilePath); + expect(content).toMatchSnapshot(); + }); + + it('handles scoped packages in catalogs', () => { + const workspaceFile: PnpmWorkspaceFile = new PnpmWorkspaceFile(workspaceFilePath); + workspaceFile.addPackage(path.join(projectsDir, 'app1')); + + workspaceFile.setCatalogs({ + default: { + '@types/node': '~22.9.4', + '@types/cookies': '^0.7.7', + '@rushstack/node-core-library': '~5.0.0' + } + }); + + workspaceFile.save(workspaceFilePath, { onlyIfChanged: true }); + + const content: string = FileSystem.readFile(workspaceFilePath); + expect(content).toMatchSnapshot(); + }); + + it('can update catalogs after initial creation', () => { + const workspaceFile: PnpmWorkspaceFile = new PnpmWorkspaceFile(workspaceFilePath); + workspaceFile.addPackage(path.join(projectsDir, 'app1')); + + workspaceFile.setCatalogs({ + default: { + react: '^18.0.0' + } + }); + + workspaceFile.save(workspaceFilePath, { onlyIfChanged: true }); + + // Update catalogs + workspaceFile.setCatalogs({ + default: { + react: '^18.2.0', + 'react-dom': '^18.2.0' + } + }); + + workspaceFile.save(workspaceFilePath, { onlyIfChanged: true }); + + const content: string = FileSystem.readFile(workspaceFilePath); + expect(content).toMatchSnapshot(); + }); + }); +}); diff --git a/libraries/rush-lib/src/logic/pnpm/test/__snapshots__/PnpmWorkspaceFile.test.ts.snap b/libraries/rush-lib/src/logic/pnpm/test/__snapshots__/PnpmWorkspaceFile.test.ts.snap new file mode 100644 index 00000000000..88be9e3823a --- /dev/null +++ b/libraries/rush-lib/src/logic/pnpm/test/__snapshots__/PnpmWorkspaceFile.test.ts.snap @@ -0,0 +1,67 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PnpmWorkspaceFile basic functionality generates workspace file with packages only 1`] = ` +"packages: + - projects/app1 + - projects/app2 +" +`; + +exports[`PnpmWorkspaceFile catalog functionality can update catalogs after initial creation 1`] = ` +"catalogs: + default: + react: ^18.2.0 + react-dom: ^18.2.0 +packages: + - projects/app1 +" +`; + +exports[`PnpmWorkspaceFile catalog functionality generates workspace file with default catalog only 1`] = ` +"catalogs: + default: + react: ^18.0.0 + react-dom: ^18.0.0 + typescript: ~5.3.0 +packages: + - projects/app1 +" +`; + +exports[`PnpmWorkspaceFile catalog functionality generates workspace file with named catalogs 1`] = ` +"catalogs: + backend: + express: ^4.18.0 + fastify: ^4.26.0 + default: + typescript: ~5.3.0 + frontend: + vue: ^3.4.0 + vue-router: ^4.2.0 +packages: + - projects/app1 +" +`; + +exports[`PnpmWorkspaceFile catalog functionality handles empty catalog object 1`] = ` +"packages: + - projects/app1 +" +`; + +exports[`PnpmWorkspaceFile catalog functionality handles scoped packages in catalogs 1`] = ` +"catalogs: + default: + '@rushstack/node-core-library': ~5.0.0 + '@types/cookies': ^0.7.7 + '@types/node': ~22.9.4 +packages: + - projects/app1 +" +`; + +exports[`PnpmWorkspaceFile catalog functionality handles undefined catalog 1`] = ` +"packages: + - projects/app1 +" +`; diff --git a/libraries/rush-lib/src/logic/pnpm/test/jsonFiles/pnpm-config-catalog.json b/libraries/rush-lib/src/logic/pnpm/test/jsonFiles/pnpm-config-catalog.json new file mode 100644 index 00000000000..eb8b11abe41 --- /dev/null +++ b/libraries/rush-lib/src/logic/pnpm/test/jsonFiles/pnpm-config-catalog.json @@ -0,0 +1,17 @@ +{ + "globalCatalog": { + "react": "^18.0.0", + "react-dom": "^18.0.0", + "typescript": "~5.3.0" + }, + "globalCatalogs": { + "frontend": { + "vue": "^3.4.0", + "vue-router": "^4.2.0" + }, + "backend": { + "express": "^4.18.0", + "fastify": "^4.26.0" + } + } +} diff --git a/libraries/rush-lib/src/logic/pnpm/test/yamlFiles/pnpm-lock-with-catalog.yaml b/libraries/rush-lib/src/logic/pnpm/test/yamlFiles/pnpm-lock-with-catalog.yaml new file mode 100644 index 00000000000..143be44f5b0 --- /dev/null +++ b/libraries/rush-lib/src/logic/pnpm/test/yamlFiles/pnpm-lock-with-catalog.yaml @@ -0,0 +1,34 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +catalogs: + default: + react: 18.2.0 + typescript: 5.3.0 + frontend: + vue: 3.4.0 + +importers: + .: + dependencies: + react: + specifier: 'catalog:default' + version: 18.2.0 + typescript: + specifier: 'catalog:default' + version: 5.3.0 + +packages: + react@18.2.0: + resolution: { integrity: sha512-abc123 } + + typescript@5.3.0: + resolution: { integrity: sha512-def456 } + +snapshots: + react@18.2.0: {} + + typescript@5.3.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..9ec30a66024 100644 --- a/libraries/rush-lib/src/logic/test/DependencySpecifier.test.ts +++ b/libraries/rush-lib/src/logic/test/DependencySpecifier.test.ts @@ -140,6 +140,32 @@ DependencySpecifier { }); }); + describe('Catalog protocol', () => { + it('correctly parses a "catalog:" version (default catalog)', () => { + const specifier = new DependencySpecifier('dep', 'catalog:'); + expect(specifier).toMatchInlineSnapshot(` +DependencySpecifier { + "aliasTarget": undefined, + "packageName": "dep", + "specifierType": "Catalog", + "versionSpecifier": "", +} +`); + }); + + it('correctly parses a "catalog:catalogName" version (named catalog)', () => { + const specifier = new DependencySpecifier('dep', 'catalog:react18'); + expect(specifier).toMatchInlineSnapshot(` +DependencySpecifier { + "aliasTarget": undefined, + "packageName": "dep", + "specifierType": "Catalog", + "versionSpecifier": "react18", +} +`); + }); + }); + describe(DependencySpecifier.parseWithCache.name, () => { it('returns a cached instance for the same input', () => { const specifier1 = DependencySpecifier.parseWithCache('dep', '1.2.3'); diff --git a/libraries/rush-lib/src/logic/test/pnpmConfig/common/config/rush/pnpm-config.json b/libraries/rush-lib/src/logic/test/pnpmConfig/common/config/rush/pnpm-config.json index b5d6f9baba8..8053f6cb05c 100644 --- a/libraries/rush-lib/src/logic/test/pnpmConfig/common/config/rush/pnpm-config.json +++ b/libraries/rush-lib/src/logic/test/pnpmConfig/common/config/rush/pnpm-config.json @@ -13,6 +13,15 @@ } }, "globalNeverBuiltDependencies": ["fsevents", "level"], + "globalCatalog": { + "react": "^18.0.0", + "lodash": "^4.17.21" + }, + "globalCatalogs": { + "test": { + "jest": "^29.0.0" + } + }, "unsupportedPackageJsonSettings": { "pnpm": { "overrides": { diff --git a/libraries/rush-lib/src/schemas/pnpm-config.schema.json b/libraries/rush-lib/src/schemas/pnpm-config.schema.json index 0501173b754..996f84e3d90 100644 --- a/libraries/rush-lib/src/schemas/pnpm-config.schema.json +++ b/libraries/rush-lib/src/schemas/pnpm-config.schema.json @@ -237,6 +237,28 @@ "required": ["enabled", "exemptPackageVersions"] } } + }, + + "globalCatalog": { + "description": "The \"globalCatalog\" setting provides a centralized way to define dependency versions that can be referenced using the `catalog:` protocol in package.json files. This allows for consistent version management across all projects in the monorepo. The settings are written to the `catalogs.default` field of the `pnpm-workspace.yaml` file that is generated by Rush during installation.\n\nThis is syntactic sugar for \"globalCatalogs.default\".\n\nPNPM documentation: https://pnpm.io/catalogs", + "type": "object", + "additionalProperties": { + "description": "Specify the version for a package in the catalog", + "type": "string" + } + }, + + "globalCatalogs": { + "description": "The \"globalCatalogs\" setting provides named catalogs for organizing dependency versions. Each catalog can be referenced using the `catalog:catalogName` protocol in package.json files (e.g., `catalog:react18`). The settings are written to the `catalogs` field of the `pnpm-workspace.yaml` file that is generated by Rush during installation.\n\nPNPM documentation: https://pnpm.io/catalogs", + "type": "object", + "additionalProperties": { + "description": "A named catalog containing package versions", + "type": "object", + "additionalProperties": { + "description": "Specify the version for a package in this catalog", + "type": "string" + } + } } } } diff --git a/libraries/rush-lib/src/schemas/repo-state.schema.json b/libraries/rush-lib/src/schemas/repo-state.schema.json index 563fa791b51..d14c1de3ac4 100644 --- a/libraries/rush-lib/src/schemas/repo-state.schema.json +++ b/libraries/rush-lib/src/schemas/repo-state.schema.json @@ -20,6 +20,10 @@ "packageJsonInjectedDependenciesHash": { "description": "A hash of the injected dependencies in related package.json. This hash is used to determine whether or not the shrinkwrap needs to updated prior to install.", "type": "string" + }, + "pnpmCatalogsHash": { + "description": "A hash of the PNPM catalog definitions for the repository. This hash is used to determine whether or not the catalog has been modified prior to install.", + "type": "string" } }, "additionalProperties": false