Skip to content

Commit 98d7c1a

Browse files
feat: add support for pnpm catalogs (#5467)
* feat: add support for pnpm catalogs Signed-off-by: Aramis Sennyey <aramissennyeydd@users.noreply.github.com> * add changeset Signed-off-by: Aramis Sennyey <aramissennyeydd@users.noreply.github.com> * move to pnpm-workspace file to let catalog: specifier be picked up Signed-off-by: Aramis Sennyey <aramissennyeydd@users.noreply.github.com> * remove catalog checksum Signed-off-by: Aramis Sennyey <aramissennyeydd@users.noreply.github.com> * add back checksum Signed-off-by: Aramis Sennyey <aramissennyeydd@users.noreply.github.com> * make test directory agnostic Signed-off-by: Aramis Sennyey <aramissennyeydd@users.noreply.github.com> * write to repo-state file with catalogsHash Signed-off-by: Aramis Sennyey <aramissennyeydd@users.noreply.github.com> * remove catalogsChecksum from the lockfile Signed-off-by: Aramis Sennyey <aramissennyeydd@users.noreply.github.com> * fix schema description Signed-off-by: Aramis Sennyey <aramissennyeydd@users.noreply.github.com> * mock FileSystem and move to separate file snapshots Signed-off-by: Aramis Sennyey <aramissennyeydd@users.noreply.github.com> * update pnpm-config schema again Signed-off-by: Aramis Sennyey <aramissennyeydd@users.noreply.github.com> * remove globalCatalog Signed-off-by: Aramis Sennyey <aramissennyeydd@users.noreply.github.com> --------- Signed-off-by: Aramis Sennyey <aramissennyeydd@users.noreply.github.com> Co-authored-by: Aramis Sennyey <aramissennyeydd@users.noreply.github.com>
1 parent 262192e commit 98d7c1a

File tree

21 files changed

+661
-3
lines changed

21 files changed

+661
-3
lines changed
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"changes": [
3+
{
4+
"comment": "Add support for defining pnpm catalog config.",
5+
"type": "none",
6+
"packageName": "@microsoft/rush"
7+
}
8+
],
9+
"packageName": "@microsoft/rush",
10+
"email": "aramissennyeydd@users.noreply.github.com"
11+
}

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

Lines changed: 4 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;
@@ -1203,6 +1205,7 @@ export class RepoStateFile {
12031205
get isValid(): boolean;
12041206
static loadFromFile(jsonFilename: string): RepoStateFile;
12051207
get packageJsonInjectedDependenciesHash(): string | undefined;
1208+
get pnpmCatalogsHash(): string | undefined;
12061209
get pnpmShrinkwrapHash(): string | undefined;
12071210
get preferredVersionsHash(): string | undefined;
12081211
refreshState(rushConfiguration: RushConfiguration, subspace: Subspace | undefined, variant?: string): boolean;
@@ -1566,6 +1569,7 @@ export class Subspace {
15661569
getCommonVersionsFilePath(variant?: string): string;
15671570
// @beta
15681571
getPackageJsonInjectedDependenciesHash(variant?: string): string | undefined;
1572+
getPnpmCatalogsHash(): string | undefined;
15691573
// @beta
15701574
getPnpmConfigFilePath(): string;
15711575
// @beta

libraries/rush-lib/src/api/Subspace.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,31 @@ export class Subspace {
409409
this._projects.push(project);
410410
}
411411

412+
/**
413+
* Computes a hash of the PNPM catalog definitions for this subspace.
414+
* Returns undefined if no catalogs are defined.
415+
*/
416+
public getPnpmCatalogsHash(): string | undefined {
417+
const pnpmOptions: PnpmOptionsConfiguration | undefined = this.getPnpmOptions();
418+
if (!pnpmOptions) {
419+
return undefined;
420+
}
421+
422+
const catalogData: Record<string, unknown> = {};
423+
if (pnpmOptions.globalCatalogs && Object.keys(pnpmOptions.globalCatalogs).length !== 0) {
424+
Object.assign(catalogData, pnpmOptions.globalCatalogs);
425+
}
426+
427+
// If no catalogs are defined, return undefined
428+
if (Object.keys(catalogData).length === 0) {
429+
return undefined;
430+
}
431+
432+
const hash: crypto.Hash = crypto.createHash('sha1');
433+
hash.update(JSON.stringify(catalogData));
434+
return hash.digest('hex');
435+
}
436+
412437
/**
413438
* Returns hash value of injected dependencies in related package.json.
414439
* @beta
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
2+
// See LICENSE in the project root for license information.
3+
4+
import * as path from 'node:path';
5+
6+
import { RushConfiguration } from '../RushConfiguration';
7+
import { Subspace } from '../Subspace';
8+
9+
describe(Subspace.name, () => {
10+
describe('getPnpmCatalogsHash', () => {
11+
it('returns undefined when no catalogs are defined', () => {
12+
const rushJsonFilename: string = path.resolve(__dirname, 'repo', 'rush-pnpm.json');
13+
const rushConfiguration: RushConfiguration =
14+
RushConfiguration.loadFromConfigurationFile(rushJsonFilename);
15+
const defaultSubspace: Subspace = rushConfiguration.defaultSubspace;
16+
17+
const catalogsHash: string | undefined = defaultSubspace.getPnpmCatalogsHash();
18+
expect(catalogsHash).toBeUndefined();
19+
});
20+
21+
it('returns undefined for non-pnpm package manager', () => {
22+
const rushJsonFilename: string = path.resolve(__dirname, 'repo', 'rush-npm.json');
23+
const rushConfiguration: RushConfiguration =
24+
RushConfiguration.loadFromConfigurationFile(rushJsonFilename);
25+
const defaultSubspace: Subspace = rushConfiguration.defaultSubspace;
26+
27+
const catalogsHash: string | undefined = defaultSubspace.getPnpmCatalogsHash();
28+
expect(catalogsHash).toBeUndefined();
29+
});
30+
31+
it('computes hash when catalogs are defined', () => {
32+
const rushJsonFilename: string = path.resolve(__dirname, 'repoCatalogs', 'rush.json');
33+
const rushConfiguration: RushConfiguration =
34+
RushConfiguration.loadFromConfigurationFile(rushJsonFilename);
35+
const defaultSubspace: Subspace = rushConfiguration.defaultSubspace;
36+
37+
const catalogsHash: string | undefined = defaultSubspace.getPnpmCatalogsHash();
38+
expect(catalogsHash).toBeDefined();
39+
expect(typeof catalogsHash).toBe('string');
40+
expect(catalogsHash).toHaveLength(40); // SHA1 hash is 40 characters
41+
});
42+
43+
it('computes consistent hash for same catalog data', () => {
44+
const rushJsonFilename: string = path.resolve(__dirname, 'repoCatalogs', 'rush.json');
45+
const rushConfiguration: RushConfiguration =
46+
RushConfiguration.loadFromConfigurationFile(rushJsonFilename);
47+
const defaultSubspace: Subspace = rushConfiguration.defaultSubspace;
48+
49+
const hash1: string | undefined = defaultSubspace.getPnpmCatalogsHash();
50+
const hash2: string | undefined = defaultSubspace.getPnpmCatalogsHash();
51+
52+
expect(hash1).toBeDefined();
53+
expect(hash1).toBe(hash2);
54+
});
55+
56+
it('computes different hashes for different catalog data', () => {
57+
// Configuration without catalogs
58+
const rushJsonWithoutCatalogs: string = path.resolve(__dirname, 'repo', 'rush-pnpm.json');
59+
const rushConfigWithoutCatalogs: RushConfiguration =
60+
RushConfiguration.loadFromConfigurationFile(rushJsonWithoutCatalogs);
61+
const subspaceWithoutCatalogs: Subspace = rushConfigWithoutCatalogs.defaultSubspace;
62+
63+
// Configuration with catalogs
64+
const rushJsonWithCatalogs: string = path.resolve(__dirname, 'repoCatalogs', 'rush.json');
65+
const rushConfigWithCatalogs: RushConfiguration =
66+
RushConfiguration.loadFromConfigurationFile(rushJsonWithCatalogs);
67+
const subspaceWithCatalogs: Subspace = rushConfigWithCatalogs.defaultSubspace;
68+
69+
const hashWithoutCatalogs: string | undefined = subspaceWithoutCatalogs.getPnpmCatalogsHash();
70+
const hashWithCatalogs: string | undefined = subspaceWithCatalogs.getPnpmCatalogsHash();
71+
72+
// One should be undefined (no catalogs) and one should have a hash
73+
expect(hashWithoutCatalogs).toBeUndefined();
74+
expect(hashWithCatalogs).toBeDefined();
75+
});
76+
});
77+
});
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"$schema": "https://developer.microsoft.com/json-schemas/rush/v5/pnpm-config.schema.json",
3+
"globalCatalogs": {
4+
"default": {
5+
"react": "^18.0.0",
6+
"react-dom": "^18.0.0",
7+
"typescript": "~5.3.0"
8+
},
9+
"internal": {
10+
"lodash": "^4.17.21",
11+
"axios": "^1.6.0"
12+
}
13+
}
14+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"name": "project1",
3+
"version": "1.0.0"
4+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"pnpmVersion": "9.5.0",
3+
"rushVersion": "5.46.1",
4+
"projectFolderMinDepth": 1,
5+
"projectFolderMaxDepth": 99,
6+
7+
"projects": [
8+
{
9+
"packageName": "project1",
10+
"projectFolder": "project1"
11+
}
12+
]
13+
}

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

Lines changed: 46 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:"` - references the default catalog
20+
* `"catalog:catalogName"` - references a named catalog
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)
@@ -40,6 +48,29 @@ class WorkspaceSpec {
4048
}
4149
}
4250

51+
/**
52+
* resolve catalog protocol.
53+
* Used by pnpm for centralized version management via catalogs.
54+
*/
55+
class CatalogSpec {
56+
public readonly catalogName: string;
57+
58+
public constructor(catalogName: string) {
59+
this.catalogName = catalogName;
60+
}
61+
62+
public static tryParse(pref: string): CatalogSpec | undefined {
63+
const parts: RegExpExecArray | null = CATALOG_PREFIX_REGEX.exec(pref);
64+
if (parts?.groups !== undefined) {
65+
return new CatalogSpec(parts.groups.catalogName);
66+
}
67+
}
68+
69+
public toString(): `catalog:${string}` {
70+
return `catalog:${this.catalogName}`;
71+
}
72+
}
73+
4374
/**
4475
* The parsed format of a provided version specifier.
4576
*/
@@ -87,7 +118,12 @@ export enum DependencySpecifierType {
87118
/**
88119
* A package specified using workspace protocol, e.g. "workspace:^1.2.3"
89120
*/
90-
Workspace = 'Workspace'
121+
Workspace = 'Workspace',
122+
123+
/**
124+
* A package specified using catalog protocol, e.g. "catalog:" or "catalog:react18"
125+
*/
126+
Catalog = 'Catalog'
91127
}
92128

93129
const dependencySpecifierParseCache: Map<string, DependencySpecifier> = new Map();
@@ -125,6 +161,15 @@ export class DependencySpecifier {
125161
this.packageName = packageName;
126162
this.versionSpecifier = versionSpecifier;
127163

164+
// Catalog protocol is a feature from PNPM for centralized version management
165+
const catalogSpecResult: CatalogSpec | undefined = CatalogSpec.tryParse(versionSpecifier);
166+
if (catalogSpecResult) {
167+
this.specifierType = DependencySpecifierType.Catalog;
168+
this.versionSpecifier = catalogSpecResult.catalogName;
169+
this.aliasTarget = undefined;
170+
return;
171+
}
172+
128173
// Workspace ranges are a feature from PNPM and Yarn. Set the version specifier
129174
// to the trimmed version range.
130175
const workspaceSpecResult: WorkspaceSpec | undefined = WorkspaceSpec.tryParse(versionSpecifier);

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

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ import type { Subspace } from '../api/Subspace';
1515
* {
1616
* "pnpmShrinkwrapHash": "...",
1717
* "preferredVersionsHash": "...",
18-
* "packageJsonInjectedDependenciesHash": "..."
18+
* "packageJsonInjectedDependenciesHash": "...",
19+
* "pnpmCatalogsHash": "..."
1920
* }
2021
*/
2122
interface IRepoStateJson {
@@ -31,6 +32,10 @@ interface IRepoStateJson {
3132
* A hash of the injected dependencies in related package.json
3233
*/
3334
packageJsonInjectedDependenciesHash?: string;
35+
/**
36+
* A hash of the PNPM catalog definitions
37+
*/
38+
pnpmCatalogsHash?: string;
3439
}
3540

3641
/**
@@ -45,6 +50,7 @@ export class RepoStateFile {
4550
private _pnpmShrinkwrapHash: string | undefined;
4651
private _preferredVersionsHash: string | undefined;
4752
private _packageJsonInjectedDependenciesHash: string | undefined;
53+
private _pnpmCatalogsHash: string | undefined;
4854
private _isValid: boolean;
4955
private _modified: boolean = false;
5056

@@ -61,6 +67,7 @@ export class RepoStateFile {
6167
this._pnpmShrinkwrapHash = repoStateJson.pnpmShrinkwrapHash;
6268
this._preferredVersionsHash = repoStateJson.preferredVersionsHash;
6369
this._packageJsonInjectedDependenciesHash = repoStateJson.packageJsonInjectedDependenciesHash;
70+
this._pnpmCatalogsHash = repoStateJson.pnpmCatalogsHash;
6471
}
6572
}
6673

@@ -85,6 +92,13 @@ export class RepoStateFile {
8592
return this._packageJsonInjectedDependenciesHash;
8693
}
8794

95+
/**
96+
* The hash of the PNPM catalog definitions at the end of the last update.
97+
*/
98+
public get pnpmCatalogsHash(): string | undefined {
99+
return this._pnpmCatalogsHash;
100+
}
101+
88102
/**
89103
* If false, the repo-state.json file is not valid and its values cannot be relied upon
90104
*/
@@ -219,6 +233,16 @@ export class RepoStateFile {
219233
this._packageJsonInjectedDependenciesHash = undefined;
220234
this._modified = true;
221235
}
236+
237+
// Track catalog hash to detect when catalog definitions change
238+
const pnpmCatalogsHash: string | undefined = subspace.getPnpmCatalogsHash();
239+
if (pnpmCatalogsHash && pnpmCatalogsHash !== this._pnpmCatalogsHash) {
240+
this._pnpmCatalogsHash = pnpmCatalogsHash;
241+
this._modified = true;
242+
} else if (!pnpmCatalogsHash && this._pnpmCatalogsHash) {
243+
this._pnpmCatalogsHash = undefined;
244+
this._modified = true;
245+
}
222246
}
223247

224248
// Now that the file has been refreshed, we know its contents are valid
@@ -255,6 +279,9 @@ export class RepoStateFile {
255279
if (this._packageJsonInjectedDependenciesHash) {
256280
repoStateJson.packageJsonInjectedDependenciesHash = this._packageJsonInjectedDependenciesHash;
257281
}
282+
if (this._pnpmCatalogsHash) {
283+
repoStateJson.pnpmCatalogsHash = this._pnpmCatalogsHash;
284+
}
258285

259286
return JsonFile.stringify(repoStateJson, { newlineConversion: NewlineKind.Lf });
260287
}

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,31 @@ export class WorkspaceInstallManager extends BaseInstallManager {
443443
// Write the common package.json
444444
InstallHelpers.generateCommonPackageJson(this.rushConfiguration, subspace, undefined, this._terminal);
445445

446+
// Set catalog definitions in the workspace file if specified
447+
if (pnpmOptions.globalCatalogs) {
448+
if (
449+
this.rushConfiguration.rushConfigurationJson.pnpmVersion !== undefined &&
450+
semver.lt(this.rushConfiguration.rushConfigurationJson.pnpmVersion, '9.5.0')
451+
) {
452+
this._terminal.writeWarningLine(
453+
Colorize.yellow(
454+
`Your version of pnpm (${this.rushConfiguration.rushConfigurationJson.pnpmVersion}) ` +
455+
`doesn't support the "globalCatalogs" fields in ` +
456+
`${this.rushConfiguration.commonRushConfigFolder}/${RushConstants.pnpmConfigFilename}. ` +
457+
'Remove these fields or upgrade to pnpm 9.5.0 or newer.'
458+
)
459+
);
460+
}
461+
462+
const catalogs: Record<string, Record<string, string>> = {};
463+
464+
if (pnpmOptions.globalCatalogs) {
465+
Object.assign(catalogs, pnpmOptions.globalCatalogs);
466+
}
467+
468+
workspaceFile.setCatalogs(catalogs);
469+
}
470+
446471
// Save the generated workspace file. Don't update the file timestamp unless the content has changed,
447472
// since "rush install" will consider this timestamp
448473
workspaceFile.save(workspaceFile.workspaceFilename, { onlyIfChanged: true });

0 commit comments

Comments
 (0)