Skip to content
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]"
}
4 changes: 4 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
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
Original file line number Diff line number Diff line change
Expand Up @@ -440,9 +440,86 @@ export class WorkspaceInstallManager extends BaseInstallManager {
shrinkwrapIsUpToDate = false;
}

// Check if catalogsChecksum matches catalog's hash
let catalogsChecksum: string | undefined;
let existingCatalogsChecksum: string | undefined;
if (shrinkwrapFile) {
existingCatalogsChecksum = shrinkwrapFile.catalogsChecksum;
let catalogsChecksumAlgorithm: string | undefined;
if (existingCatalogsChecksum) {
const dashIndex: number = existingCatalogsChecksum.indexOf('-');
if (dashIndex !== -1) {
catalogsChecksumAlgorithm = existingCatalogsChecksum.substring(0, dashIndex);
}

if (catalogsChecksumAlgorithm && catalogsChecksumAlgorithm !== 'sha256') {
this._terminal.writeErrorLine(
`The existing catalogsChecksum algorithm "${catalogsChecksumAlgorithm}" is not supported. ` +
`This may indicate that the shrinkwrap was created with a newer version of PNPM than Rush supports.`
);
throw new AlreadyReportedError();
}
}

// Combine both catalog and catalogs into a single object for checksum calculation
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 (Object.keys(catalogData).length !== 0) {
if (catalogsChecksumAlgorithm) {
// In PNPM v10, the algorithm changed to SHA256 and the digest changed from hex to base64
catalogsChecksum = await createObjectChecksumAsync(catalogData);
} else {
catalogsChecksum = createObjectChecksumLegacy(catalogData);
}
}
}

const catalogsChecksumAreEqual: boolean = catalogsChecksum === existingCatalogsChecksum;

if (!catalogsChecksumAreEqual) {
shrinkwrapWarnings.push("The catalog hash doesn't match the current shrinkwrap.");
shrinkwrapIsUpToDate = false;
}

// 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
30 changes: 30 additions & 0 deletions libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,14 @@ export interface IPnpmOptionsJson extends IPackageManagerOptionsJsonBase {
* {@inheritDoc PnpmOptionsConfiguration.pnpmLockfilePolicies}
*/
pnpmLockfilePolicies?: IPnpmLockfilePolicies;
/**
* {@inheritDoc PnpmOptionsConfiguration.globalCatalog}
*/
globalCatalog?: Record<string, string>;
/**
* {@inheritDoc PnpmOptionsConfiguration.globalCatalogs}
*/
globalCatalogs?: Record<string, Record<string, string>>;
}

/**
Expand Down Expand Up @@ -421,6 +429,26 @@ 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 copied into the `pnpm.catalog` field of the `common/temp/package.json`
* file that is generated by Rush during installation.
*
* PNPM documentation: https://pnpm.io/catalogs
*/
public readonly globalCatalog: Record<string, string> | 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.
* The settings are copied into the `pnpm.catalogs` field of the `common/temp/package.json`
* file that is generated by Rush during installation.
*
* PNPM documentation: https://pnpm.io/catalogs
*/
public readonly globalCatalogs: Record<string, Record<string, string>> | undefined;

/**
* (GENERATED BY RUSH-PNPM PATCH-COMMIT) When modifying this property, make sure you know what you are doing.
*
Expand Down Expand Up @@ -465,6 +493,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 */
Expand Down
4 changes: 4 additions & 0 deletions libraries/rush-lib/src/logic/pnpm/PnpmShrinkwrapFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ export interface IPnpmShrinkwrapYaml extends Lockfile {
specifiers?: Record<string, string>;
/** URL of the registry which was used */
registry?: string;
/** The checksum for catalog definitions */
catalogsChecksum?: string;
}

export interface ILoadFromFileOptions {
Expand Down Expand Up @@ -310,6 +312,7 @@ export class PnpmShrinkwrapFile extends BaseShrinkwrapFile {
public readonly packages: ReadonlyMap<string, IPnpmShrinkwrapDependencyYaml>;
public readonly overrides: ReadonlyMap<string, string>;
public readonly packageExtensionsChecksum: undefined | string;
public readonly catalogsChecksum: undefined | string;
public readonly hash: string;

private readonly _shrinkwrapJson: IPnpmShrinkwrapYaml;
Expand Down Expand Up @@ -343,6 +346,7 @@ export class PnpmShrinkwrapFile extends BaseShrinkwrapFile {
this.packages = new Map(Object.entries(shrinkwrapJson.packages || {}));
this.overrides = new Map(Object.entries(shrinkwrapJson.overrides || {}));
this.packageExtensionsChecksum = shrinkwrapJson.packageExtensionsChecksum;
this.catalogsChecksum = shrinkwrapJson.catalogsChecksum;

// Lockfile v9 always has "." in importers filed.
this.isWorkspaceCompatible =
Expand Down
24 changes: 23 additions & 1 deletion libraries/rush-lib/src/logic/pnpm/PnpmWorkspaceFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Record<string, string>>;
}

export class PnpmWorkspaceFile extends BaseWorkspaceFile {
Expand All @@ -33,6 +40,7 @@ export class PnpmWorkspaceFile extends BaseWorkspaceFile {
public readonly workspaceFilename: string;

private _workspacePackages: Set<string>;
private _catalogs: Record<string, Record<string, string>> | undefined;

/**
* The PNPM workspace file is used to specify the location of workspaces relative to the root
Expand All @@ -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<string>();
this._catalogs = undefined;
}

/**
* Sets the catalog definitions for the workspace.
* @param catalogs - A map of catalog name to package versions
*/
public setCatalogs(catalogs: Record<string, Record<string, string>> | undefined): void {
this._catalogs = catalogs;
}

/** @override */
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
});
});
});
16 changes: 16 additions & 0 deletions libraries/rush-lib/src/logic/pnpm/test/PnpmShrinkwrapFile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,22 @@ describe(PnpmShrinkwrapFile.name, () => {
});
});
});

describe('Catalog checksum', () => {
it('reads catalogsChecksum from pnpm-lock.yaml', () => {
const pnpmShrinkwrapFile = getPnpmShrinkwrapFileFromFile(
`${__dirname}/yamlFiles/pnpm-lock-with-catalog.yaml`
);
expect(pnpmShrinkwrapFile.catalogsChecksum).toBe('1a2b3c4d5e6f7890abcdef1234567890');
});

it('returns undefined when catalogsChecksum is not present', () => {
const pnpmShrinkwrapFile = getPnpmShrinkwrapFileFromFile(
`${__dirname}/yamlFiles/pnpm-lock-v9/pnpm-lock-v9.yaml`
);
expect(pnpmShrinkwrapFile.catalogsChecksum).toBeUndefined();
});
});
});

function getPnpmShrinkwrapFileFromFile(filepath: string): PnpmShrinkwrapFile {
Expand Down
Loading