Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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": "aramissennyeydd@users.noreply.github.com"
}
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