Skip to content
Closed
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,10 @@
{
"changes": [
{
"packageName": "@microsoft/rush",
"comment": "Add support for pnpm catalogs",
"type": "none"
}
],
"packageName": "@microsoft/rush"
}
2 changes: 2 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,7 @@ export interface _IPnpmOptionsJson extends IPackageManagerOptionsJsonBase {
alwaysInjectDependenciesFromOtherSubspaces?: boolean;
autoInstallPeers?: boolean;
globalAllowedDeprecatedVersions?: Record<string, string>;
globalCatalogs?: Record<string, Record<string, string>>;
globalIgnoredOptionalDependencies?: string[];
globalNeverBuiltDependencies?: string[];
globalOverrides?: Record<string, string>;
Expand Down Expand Up @@ -1151,6 +1152,7 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration
readonly alwaysInjectDependenciesFromOtherSubspaces: boolean | undefined;
readonly autoInstallPeers: boolean | undefined;
readonly globalAllowedDeprecatedVersions: 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
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,40 @@
/*[LINE "HYPOTHETICAL"]*/ "example2": "npm:@company/example2@^1.0.0"
},

/**
* The "globalCatalogs" setting defines named catalogs for the PNPM workspace.
* Catalogs allow you to define reusable dependency version ranges that can be referenced
* in package.json files. Use the "default" catalog name for packages that should be
* referenced with "catalog:" (no name), or use custom catalog names for "catalog:<name>"
* references.
*
* For example, if you define a "default" catalog with `"lodash": "^4.17.21"`, projects can
* use `"lodash": "catalog:"`. If you define a "react18" catalog with `"react": "^18.2.0"`,
* projects can use `"react": "catalog:react18"` in their dependencies.
*
* This setting is written to the `catalogs` field in the generated `pnpm-workspace.yaml` file.
*
* (SUPPORTED ONLY IN PNPM 9.5.0 AND NEWER)
*
* PNPM documentation: https://pnpm.io/catalogs
*/
"globalCatalogs": {
/*[BEGIN "HYPOTHETICAL"]*/
"default": {
"lodash": "^4.17.21",
"typescript": "~5.0.0"
},
"react18": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"react19": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
/*[END "HYPOTHETICAL"]*/
},

/**
* The `globalPeerDependencyRules` setting provides various settings for suppressing validation errors
* that are reported during installation with `strictPeerDependencies=true`. The settings are copied
Expand Down
35 changes: 34 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:"` - uses the default catalog
* `"catalog:react18"` - uses the named catalog "react18"
*/
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 @@ -87,7 +95,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 @@ -121,14 +134,32 @@ export class DependencySpecifier {
*/
public readonly aliasTarget: DependencySpecifier | undefined;

/**
* If `specifierType` is `Catalog`, then this is the catalog name.
* For example, if version specifier is `"catalog:react18"` then this is `"react18"`.
* If version specifier is `"catalog:"` (default catalog), then this is `"default"`.
*/
public readonly catalogName: string | undefined;

public constructor(packageName: string, versionSpecifier: string) {
this.packageName = packageName;
this.versionSpecifier = versionSpecifier;

// Catalog protocol is a PNPM feature. Parse the catalog name.
const catalogMatch: RegExpExecArray | null = CATALOG_PREFIX_REGEX.exec(versionSpecifier);
if (catalogMatch?.groups) {
this.specifierType = DependencySpecifierType.Catalog;
// If no catalog name is provided, use "default"
this.catalogName = catalogMatch.groups.catalogName || 'default';
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);
if (workspaceSpecResult) {
this.catalogName = undefined;
this.specifierType = DependencySpecifierType.Workspace;
this.versionSpecifier = workspaceSpecResult.versionSpecifier;

Expand All @@ -145,6 +176,8 @@ export class DependencySpecifier {
return;
}

this.catalogName = undefined;

const result: npmPackageArg.Result = npmPackageArg.resolve(packageName, versionSpecifier);
this.specifierType = DependencySpecifier.getDependencySpecifierType(result.type);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,13 @@ export class WorkspaceInstallManager extends BaseInstallManager {
shrinkwrapIsUpToDate = false;
}

// Set catalog configuration from pnpmOptions if defined
// Catalogs allow defining reusable dependency version ranges that can be referenced
// in package.json files using the "catalog:" or "catalog:<name>" protocol
if (pnpmOptions.globalCatalogs) {
workspaceFile.setCatalogs(pnpmOptions.globalCatalogs);
}

// Write the common package.json
InstallHelpers.generateCommonPackageJson(this.rushConfiguration, subspace, undefined, this._terminal);

Expand Down
21 changes: 21 additions & 0 deletions libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ export interface IPnpmOptionsJson extends IPackageManagerOptionsJsonBase {
* {@inheritDoc PnpmOptionsConfiguration.globalOverrides}
*/
globalOverrides?: Record<string, string>;
/**
* {@inheritDoc PnpmOptionsConfiguration.globalCatalogs}
*/
globalCatalogs?: Record<string, Record<string, string>>;
/**
* {@inheritDoc PnpmOptionsConfiguration.globalPeerDependencyRules}
*/
Expand Down Expand Up @@ -319,6 +323,22 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration
*/
public readonly globalOverrides: Record<string, string> | undefined;

/**
* The "globalCatalogs" setting defines named catalogs for the PNPM workspace.
* Named catalogs allow you to organize dependency version ranges into logical groups
* that can be referenced using the "catalog:\<name\>" protocol. For example, if you define
* a "react18" catalog with `"react": "^18.2.0"`, projects can use `"react": "catalog:react18"`
* in their dependencies.
*
* This setting is written to the `catalogs` field in the generated `pnpm-workspace.yaml` file.
*
* @remarks
* (SUPPORTED ONLY IN PNPM 9.5.0 AND NEWER)
*
* PNPM documentation: https://pnpm.io/catalogs
*/
public readonly globalCatalogs: Record<string, Record<string, string>> | undefined;

/**
* The `globalPeerDependencyRules` setting provides various settings for suppressing validation errors
* that are reported during installation with `strictPeerDependencies=true`. The settings are copied
Expand Down Expand Up @@ -451,6 +471,7 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration
this.useWorkspaces = !!json.useWorkspaces;

this.globalOverrides = json.globalOverrides;
this.globalCatalogs = json.globalCatalogs;
this.globalPeerDependencyRules = json.globalPeerDependencyRules;
this.globalPackageExtensions = json.globalPackageExtensions;
this.globalNeverBuiltDependencies = json.globalNeverBuiltDependencies;
Expand Down
44 changes: 43 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,23 @@ const globEscape: (unescaped: string) => string = require('glob-escape'); // No
* {
* "packages": [
* "../../apps/project1"
* ]
* ],
* "catalogs": {
* "default": {
* "lodash": "^4.17.21"
* },
* "react18": {
* "react": "^18.2.0",
* "react-dom": "^18.2.0"
* }
* }
* }
*/
interface IPnpmWorkspaceYaml {
/** The list of local package directories */
packages: string[];
/** Named catalogs - maps catalog names to package version mappings */
catalogs?: Record<string, Record<string, string>>;
}

export class PnpmWorkspaceFile extends BaseWorkspaceFile {
Expand All @@ -33,6 +44,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 +57,7 @@ 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;
}

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

/**
* Set the named catalogs for the workspace.
* Catalogs allow defining reusable dependency version ranges that can be referenced
* in package.json files using the "catalog:" or "catalog:\<name\>" protocol.
* Use the "default" catalog name for packages that should be referenced with "catalog:"
* (no name), or use custom catalog names for "catalog:\<name\>" references.
*
* @param catalogs - A record mapping catalog names to package version mappings, or undefined to clear
*/
public setCatalogs(catalogs: Record<string, Record<string, string>> | undefined): void {
this._catalogs = catalogs;
}

/** @override */
protected serialize(): string {
// Ensure stable sort order when serializing
Expand All @@ -67,6 +93,22 @@ export class PnpmWorkspaceFile extends BaseWorkspaceFile {
const workspaceYaml: IPnpmWorkspaceYaml = {
packages: Array.from(this._workspacePackages)
};

// Add named catalogs if defined and non-empty
if (this._catalogs && Object.keys(this._catalogs).length > 0) {
// Sort the catalog names and entries for stable output
const sortedCatalogs: Record<string, Record<string, string>> = {};
for (const catalogName of Object.keys(this._catalogs).sort()) {
const catalog: Record<string, string> = this._catalogs[catalogName];
const sortedCatalog: Record<string, string> = {};
for (const key of Object.keys(catalog).sort()) {
sortedCatalog[key] = catalog[key];
}
sortedCatalogs[catalogName] = sortedCatalog;
}
workspaceYaml.catalogs = sortedCatalogs;
}

return yamlModule.dump(workspaceYaml, PNPM_SHRINKWRAP_YAML_FORMAT);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -86,4 +86,26 @@ describe(PnpmOptionsConfiguration.name, () => {
'@myorg/*'
]);
});

it('loads globalCatalogs', () => {
const pnpmConfiguration: PnpmOptionsConfiguration = PnpmOptionsConfiguration.loadFromJsonFileOrThrow(
`${__dirname}/jsonFiles/pnpm-config-catalogs.json`,
fakeCommonTempFolder
);

expect(TestUtilities.stripAnnotations(pnpmConfiguration.globalCatalogs)).toEqual({
default: {
lodash: '^4.17.21',
typescript: '~5.0.0'
},
react18: {
react: '^18.2.0',
'react-dom': '^18.2.0'
},
testing: {
jest: '^29.0.0',
mocha: '^10.0.0'
}
});
});
});
Loading