Skip to content
Open
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"changes": [
{
"comment": "Add support for defining pnpm catalog config.",
"type": "none",
"packageName": "@microsoft/rush"
}
],
"packageName": "@microsoft/rush",
"email": "[email protected]"
}
6 changes: 6 additions & 0 deletions common/reviews/api/rush-lib.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -740,6 +740,8 @@ export interface _IPnpmOptionsJson extends IPackageManagerOptionsJsonBase {
alwaysInjectDependenciesFromOtherSubspaces?: boolean;
autoInstallPeers?: boolean;
globalAllowedDeprecatedVersions?: Record<string, string>;
globalCatalog?: Record<string, string>;
globalCatalogs?: Record<string, Record<string, string>>;
globalIgnoredOptionalDependencies?: string[];
globalNeverBuiltDependencies?: string[];
globalOverrides?: Record<string, string>;
Expand Down Expand Up @@ -1151,6 +1153,8 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration
readonly alwaysInjectDependenciesFromOtherSubspaces: boolean | undefined;
readonly autoInstallPeers: boolean | undefined;
readonly globalAllowedDeprecatedVersions: Record<string, string> | undefined;
readonly globalCatalog: Record<string, string> | undefined;
readonly globalCatalogs: Record<string, Record<string, string>> | undefined;
readonly globalIgnoredOptionalDependencies: string[] | undefined;
readonly globalNeverBuiltDependencies: string[] | undefined;
readonly globalOverrides: Record<string, string> | undefined;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1566,6 +1571,7 @@ export class Subspace {
getCommonVersionsFilePath(variant?: string): string;
// @beta
getPackageJsonInjectedDependenciesHash(variant?: string): string | undefined;
getPnpmCatalogsHash(): string | undefined;
// @beta
getPnpmConfigFilePath(): string;
// @beta
Expand Down
28 changes: 28 additions & 0 deletions libraries/rush-lib/src/api/Subspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {};
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
Expand Down
77 changes: 77 additions & 0 deletions libraries/rush-lib/src/api/test/Subspace.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "project1",
"version": "1.0.0"
}
13 changes: 13 additions & 0 deletions libraries/rush-lib/src/api/test/repoCatalogs/rush.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"pnpmVersion": "9.5.0",
"rushVersion": "5.46.1",
"projectFolderMinDepth": 1,
"projectFolderMaxDepth": 99,

"projects": [
{
"packageName": "project1",
"projectFolder": "project1"
}
]
}
47 changes: 46 additions & 1 deletion libraries/rush-lib/src/logic/DependencySpecifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ import { InternalError } from '@rushstack/node-core-library';
*/
const WORKSPACE_PREFIX_REGEX: RegExp = /^workspace:((?<alias>[^._/][^@]*)@)?(?<version>.*)$/;

/**
* 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:(?<catalogName>.*)$/;

/**
* 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)
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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<string, DependencySpecifier> = new Map();
Expand Down Expand Up @@ -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);
Expand Down
29 changes: 28 additions & 1 deletion libraries/rush-lib/src/logic/RepoStateFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import type { Subspace } from '../api/Subspace';
* {
* "pnpmShrinkwrapHash": "...",
* "preferredVersionsHash": "...",
* "packageJsonInjectedDependenciesHash": "..."
* "packageJsonInjectedDependenciesHash": "...",
* "pnpmCatalogsHash": "..."
* }
*/
interface IRepoStateJson {
Expand All @@ -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;
}

/**
Expand All @@ -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;

Expand All @@ -61,6 +67,7 @@ export class RepoStateFile {
this._pnpmShrinkwrapHash = repoStateJson.pnpmShrinkwrapHash;
this._preferredVersionsHash = repoStateJson.preferredVersionsHash;
this._packageJsonInjectedDependenciesHash = repoStateJson.packageJsonInjectedDependenciesHash;
this._pnpmCatalogsHash = repoStateJson.pnpmCatalogsHash;
}
}

Expand All @@ -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
*/
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 });
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Record<string, string>> = {};

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 });
Expand Down
Loading