Skip to content

Commit 6610047

Browse files
committed
add support for pnpm catalogs
1 parent f1bdedd commit 6610047

18 files changed

Lines changed: 449 additions & 2 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@microsoft/rush",
5+
"comment": "Add support for pnpm catalogs",
6+
"type": "none"
7+
}
8+
],
9+
"packageName": "@microsoft/rush"
10+
}

common/reviews/api/rush-lib.api.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -740,6 +740,7 @@ export interface _IPnpmOptionsJson extends IPackageManagerOptionsJsonBase {
740740
alwaysInjectDependenciesFromOtherSubspaces?: boolean;
741741
autoInstallPeers?: boolean;
742742
globalAllowedDeprecatedVersions?: Record<string, string>;
743+
globalCatalogs?: Record<string, Record<string, string>>;
743744
globalIgnoredOptionalDependencies?: string[];
744745
globalNeverBuiltDependencies?: string[];
745746
globalOverrides?: Record<string, string>;
@@ -1151,6 +1152,7 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration
11511152
readonly alwaysInjectDependenciesFromOtherSubspaces: boolean | undefined;
11521153
readonly autoInstallPeers: boolean | undefined;
11531154
readonly globalAllowedDeprecatedVersions: Record<string, string> | undefined;
1155+
readonly globalCatalogs: Record<string, Record<string, string>> | undefined;
11541156
readonly globalIgnoredOptionalDependencies: string[] | undefined;
11551157
readonly globalNeverBuiltDependencies: string[] | undefined;
11561158
readonly globalOverrides: Record<string, string> | undefined;

libraries/rush-lib/assets/rush-init/common/config/rush/pnpm-config.json

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,40 @@
240240
/*[LINE "HYPOTHETICAL"]*/ "example2": "npm:@company/example2@^1.0.0"
241241
},
242242

243+
/**
244+
* The "globalCatalogs" setting defines named catalogs for the PNPM workspace.
245+
* Catalogs allow you to define reusable dependency version ranges that can be referenced
246+
* in package.json files. Use the "default" catalog name for packages that should be
247+
* referenced with "catalog:" (no name), or use custom catalog names for "catalog:<name>"
248+
* references.
249+
*
250+
* For example, if you define a "default" catalog with `"lodash": "^4.17.21"`, projects can
251+
* use `"lodash": "catalog:"`. If you define a "react18" catalog with `"react": "^18.2.0"`,
252+
* projects can use `"react": "catalog:react18"` in their dependencies.
253+
*
254+
* This setting is written to the `catalogs` field in the generated `pnpm-workspace.yaml` file.
255+
*
256+
* (SUPPORTED ONLY IN PNPM 9.5.0 AND NEWER)
257+
*
258+
* PNPM documentation: https://pnpm.io/catalogs
259+
*/
260+
"globalCatalogs": {
261+
/*[BEGIN "HYPOTHETICAL"]*/
262+
"default": {
263+
"lodash": "^4.17.21",
264+
"typescript": "~5.0.0"
265+
},
266+
"react18": {
267+
"react": "^18.2.0",
268+
"react-dom": "^18.2.0"
269+
},
270+
"react19": {
271+
"react": "^19.0.0",
272+
"react-dom": "^19.0.0"
273+
}
274+
/*[END "HYPOTHETICAL"]*/
275+
},
276+
243277
/**
244278
* The `globalPeerDependencyRules` setting provides various settings for suppressing validation errors
245279
* that are reported during installation with `strictPeerDependencies=true`. The settings are copied

libraries/rush-lib/src/logic/DependencySpecifier.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@ import { InternalError } from '@rushstack/node-core-library';
1313
*/
1414
const WORKSPACE_PREFIX_REGEX: RegExp = /^workspace:((?<alias>[^._/][^@]*)@)?(?<version>.*)$/;
1515

16+
/**
17+
* match catalog protocol in dependencies value declaration in `package.json`
18+
* example:
19+
* `"catalog:"` - uses the default catalog
20+
* `"catalog:react18"` - uses the named catalog "react18"
21+
*/
22+
const CATALOG_PREFIX_REGEX: RegExp = /^catalog:(?<catalogName>.*)$/;
23+
1624
/**
1725
* resolve workspace protocol(from `@pnpm/workspace.spec-parser`).
1826
* 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 {
8795
/**
8896
* A package specified using workspace protocol, e.g. "workspace:^1.2.3"
8997
*/
90-
Workspace = 'Workspace'
98+
Workspace = 'Workspace',
99+
100+
/**
101+
* A package specified using catalog protocol, e.g. "catalog:" or "catalog:react18"
102+
*/
103+
Catalog = 'Catalog'
91104
}
92105

93106
const dependencySpecifierParseCache: Map<string, DependencySpecifier> = new Map();
@@ -121,14 +134,32 @@ export class DependencySpecifier {
121134
*/
122135
public readonly aliasTarget: DependencySpecifier | undefined;
123136

137+
/**
138+
* If `specifierType` is `Catalog`, then this is the catalog name.
139+
* For example, if version specifier is `"catalog:react18"` then this is `"react18"`.
140+
* If version specifier is `"catalog:"` (default catalog), then this is `"default"`.
141+
*/
142+
public readonly catalogName: string | undefined;
143+
124144
public constructor(packageName: string, versionSpecifier: string) {
125145
this.packageName = packageName;
126146
this.versionSpecifier = versionSpecifier;
127147

148+
// Catalog protocol is a PNPM feature. Parse the catalog name.
149+
const catalogMatch: RegExpExecArray | null = CATALOG_PREFIX_REGEX.exec(versionSpecifier);
150+
if (catalogMatch?.groups) {
151+
this.specifierType = DependencySpecifierType.Catalog;
152+
// If no catalog name is provided, use "default"
153+
this.catalogName = catalogMatch.groups.catalogName || 'default';
154+
this.aliasTarget = undefined;
155+
return;
156+
}
157+
128158
// Workspace ranges are a feature from PNPM and Yarn. Set the version specifier
129159
// to the trimmed version range.
130160
const workspaceSpecResult: WorkspaceSpec | undefined = WorkspaceSpec.tryParse(versionSpecifier);
131161
if (workspaceSpecResult) {
162+
this.catalogName = undefined;
132163
this.specifierType = DependencySpecifierType.Workspace;
133164
this.versionSpecifier = workspaceSpecResult.versionSpecifier;
134165

@@ -145,6 +176,8 @@ export class DependencySpecifier {
145176
return;
146177
}
147178

179+
this.catalogName = undefined;
180+
148181
const result: npmPackageArg.Result = npmPackageArg.resolve(packageName, versionSpecifier);
149182
this.specifierType = DependencySpecifier.getDependencySpecifierType(result.type);
150183

libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,13 @@ export class WorkspaceInstallManager extends BaseInstallManager {
440440
shrinkwrapIsUpToDate = false;
441441
}
442442

443+
// Set catalog configuration from pnpmOptions if defined
444+
// Catalogs allow defining reusable dependency version ranges that can be referenced
445+
// in package.json files using the "catalog:" or "catalog:<name>" protocol
446+
if (pnpmOptions.globalCatalogs) {
447+
workspaceFile.setCatalogs(pnpmOptions.globalCatalogs);
448+
}
449+
443450
// Write the common package.json
444451
InstallHelpers.generateCommonPackageJson(this.rushConfiguration, subspace, undefined, this._terminal);
445452

libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,10 @@ export interface IPnpmOptionsJson extends IPackageManagerOptionsJsonBase {
102102
* {@inheritDoc PnpmOptionsConfiguration.globalOverrides}
103103
*/
104104
globalOverrides?: Record<string, string>;
105+
/**
106+
* {@inheritDoc PnpmOptionsConfiguration.globalCatalogs}
107+
*/
108+
globalCatalogs?: Record<string, Record<string, string>>;
105109
/**
106110
* {@inheritDoc PnpmOptionsConfiguration.globalPeerDependencyRules}
107111
*/
@@ -319,6 +323,22 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration
319323
*/
320324
public readonly globalOverrides: Record<string, string> | undefined;
321325

326+
/**
327+
* The "globalCatalogs" setting defines named catalogs for the PNPM workspace.
328+
* Named catalogs allow you to organize dependency version ranges into logical groups
329+
* that can be referenced using the "catalog:\<name\>" protocol. For example, if you define
330+
* a "react18" catalog with `"react": "^18.2.0"`, projects can use `"react": "catalog:react18"`
331+
* in their dependencies.
332+
*
333+
* This setting is written to the `catalogs` field in the generated `pnpm-workspace.yaml` file.
334+
*
335+
* @remarks
336+
* (SUPPORTED ONLY IN PNPM 9.5.0 AND NEWER)
337+
*
338+
* PNPM documentation: https://pnpm.io/catalogs
339+
*/
340+
public readonly globalCatalogs: Record<string, Record<string, string>> | undefined;
341+
322342
/**
323343
* The `globalPeerDependencyRules` setting provides various settings for suppressing validation errors
324344
* that are reported during installation with `strictPeerDependencies=true`. The settings are copied
@@ -451,6 +471,7 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration
451471
this.useWorkspaces = !!json.useWorkspaces;
452472

453473
this.globalOverrides = json.globalOverrides;
474+
this.globalCatalogs = json.globalCatalogs;
454475
this.globalPeerDependencyRules = json.globalPeerDependencyRules;
455476
this.globalPackageExtensions = json.globalPackageExtensions;
456477
this.globalNeverBuiltDependencies = json.globalNeverBuiltDependencies;

libraries/rush-lib/src/logic/pnpm/PnpmWorkspaceFile.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,23 @@ const globEscape: (unescaped: string) => string = require('glob-escape'); // No
1818
* {
1919
* "packages": [
2020
* "../../apps/project1"
21-
* ]
21+
* ],
22+
* "catalogs": {
23+
* "default": {
24+
* "lodash": "^4.17.21"
25+
* },
26+
* "react18": {
27+
* "react": "^18.2.0",
28+
* "react-dom": "^18.2.0"
29+
* }
30+
* }
2231
* }
2332
*/
2433
interface IPnpmWorkspaceYaml {
2534
/** The list of local package directories */
2635
packages: string[];
36+
/** Named catalogs - maps catalog names to package version mappings */
37+
catalogs?: Record<string, Record<string, string>>;
2738
}
2839

2940
export class PnpmWorkspaceFile extends BaseWorkspaceFile {
@@ -33,6 +44,7 @@ export class PnpmWorkspaceFile extends BaseWorkspaceFile {
3344
public readonly workspaceFilename: string;
3445

3546
private _workspacePackages: Set<string>;
47+
private _catalogs: Record<string, Record<string, string>> | undefined;
3648

3749
/**
3850
* 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 {
4557
// Ignore any existing file since this file is generated and we need to handle deleting packages
4658
// If we need to support manual customization, that should be an additional parameter for "base file"
4759
this._workspacePackages = new Set<string>();
60+
this._catalogs = undefined;
4861
}
4962

5063
/** @override */
@@ -59,6 +72,19 @@ export class PnpmWorkspaceFile extends BaseWorkspaceFile {
5972
this._workspacePackages.add(globEscape(globPath));
6073
}
6174

75+
/**
76+
* Set the named catalogs for the workspace.
77+
* Catalogs allow defining reusable dependency version ranges that can be referenced
78+
* in package.json files using the "catalog:" or "catalog:\<name\>" protocol.
79+
* Use the "default" catalog name for packages that should be referenced with "catalog:"
80+
* (no name), or use custom catalog names for "catalog:\<name\>" references.
81+
*
82+
* @param catalogs - A record mapping catalog names to package version mappings, or undefined to clear
83+
*/
84+
public setCatalogs(catalogs: Record<string, Record<string, string>> | undefined): void {
85+
this._catalogs = catalogs;
86+
}
87+
6288
/** @override */
6389
protected serialize(): string {
6490
// Ensure stable sort order when serializing
@@ -67,6 +93,22 @@ export class PnpmWorkspaceFile extends BaseWorkspaceFile {
6793
const workspaceYaml: IPnpmWorkspaceYaml = {
6894
packages: Array.from(this._workspacePackages)
6995
};
96+
97+
// Add named catalogs if defined and non-empty
98+
if (this._catalogs && Object.keys(this._catalogs).length > 0) {
99+
// Sort the catalog names and entries for stable output
100+
const sortedCatalogs: Record<string, Record<string, string>> = {};
101+
for (const catalogName of Object.keys(this._catalogs).sort()) {
102+
const catalog: Record<string, string> = this._catalogs[catalogName];
103+
const sortedCatalog: Record<string, string> = {};
104+
for (const key of Object.keys(catalog).sort()) {
105+
sortedCatalog[key] = catalog[key];
106+
}
107+
sortedCatalogs[catalogName] = sortedCatalog;
108+
}
109+
workspaceYaml.catalogs = sortedCatalogs;
110+
}
111+
70112
return yamlModule.dump(workspaceYaml, PNPM_SHRINKWRAP_YAML_FORMAT);
71113
}
72114
}

libraries/rush-lib/src/logic/pnpm/test/PnpmOptionsConfiguration.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,4 +86,26 @@ describe(PnpmOptionsConfiguration.name, () => {
8686
'@myorg/*'
8787
]);
8888
});
89+
90+
it('loads globalCatalogs', () => {
91+
const pnpmConfiguration: PnpmOptionsConfiguration = PnpmOptionsConfiguration.loadFromJsonFileOrThrow(
92+
`${__dirname}/jsonFiles/pnpm-config-catalogs.json`,
93+
fakeCommonTempFolder
94+
);
95+
96+
expect(TestUtilities.stripAnnotations(pnpmConfiguration.globalCatalogs)).toEqual({
97+
default: {
98+
lodash: '^4.17.21',
99+
typescript: '~5.0.0'
100+
},
101+
react18: {
102+
react: '^18.2.0',
103+
'react-dom': '^18.2.0'
104+
},
105+
testing: {
106+
jest: '^29.0.0',
107+
mocha: '^10.0.0'
108+
}
109+
});
110+
});
89111
});

0 commit comments

Comments
 (0)