Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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