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
10 changes: 10 additions & 0 deletions common/changes/@microsoft/rush/main_2026-01-31-22-28.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@microsoft/rush",
"comment": "add catalog support for 'rush-pnpm update'",
"type": "none"
}
],
"packageName": "@microsoft/rush"
}
1 change: 1 addition & 0 deletions common/reviews/api/rush-lib.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1176,6 +1176,7 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration
readonly resolutionMode: PnpmResolutionMode | undefined;
readonly strictPeerDependencies: boolean;
readonly unsupportedPackageJsonSettings: unknown | undefined;
updateGlobalCatalogs(catalogs: Record<string, Record<string, string>> | undefined): void;
updateGlobalOnlyBuiltDependencies(onlyBuiltDependencies: string[] | undefined): void;
updateGlobalPatchedDependencies(patchedDependencies: Record<string, string> | undefined): void;
readonly useWorkspaces: boolean;
Expand Down
24 changes: 24 additions & 0 deletions libraries/rush-lib/src/cli/RushPnpmCommandLineParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import type { IInstallManagerOptions } from '../logic/base/BaseInstallManagerTyp
import { Utilities } from '../utilities/Utilities';
import type { Subspace } from '../api/Subspace';
import type { PnpmOptionsConfiguration } from '../logic/pnpm/PnpmOptionsConfiguration';
import { PnpmWorkspaceFile } from '../logic/pnpm/PnpmWorkspaceFile';
import { EnvironmentVariableNames } from '../api/EnvironmentConfiguration';
import { initializeDotEnv } from '../logic/dotenv';

Expand Down Expand Up @@ -595,6 +596,29 @@ export class RushPnpmCommandLineParser {
}
break;
}
case 'up':
case 'update': {
const pnpmOptions: PnpmOptionsConfiguration | undefined = this._subspace.getPnpmOptions();
if (pnpmOptions === undefined) {
break;
}

const workspaceYamlFilename: string = path.join(subspaceTempFolder, 'pnpm-workspace.yaml');
const newCatalogs: Record<string, Record<string, string>> | undefined =
PnpmWorkspaceFile.loadCatalogsFromFile(workspaceYamlFilename);
const currentCatalogs: Record<string, Record<string, string>> | undefined =
pnpmOptions.globalCatalogs;

if (!Objects.areDeepEqual(currentCatalogs, newCatalogs)) {
pnpmOptions.updateGlobalCatalogs(newCatalogs);

this._terminal.writeWarningLine(
`Rush refreshed the ${RushConstants.pnpmConfigFilename} with updated catalog definitions.\n` +
` Run "rush update --recheck" to update the lockfile, then commit these changes to Git.`
);
}
break;
}
}
}

Expand Down
162 changes: 162 additions & 0 deletions libraries/rush-lib/src/cli/test/RushPnpmCommandLineParser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
// 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 { FileSystem, JsonFile } from '@rushstack/node-core-library';
import { TestUtilities } from '@rushstack/heft-config-file';
import { RushConfiguration } from '../../api/RushConfiguration';

describe('RushPnpmCommandLineParser', () => {
describe('catalog syncing', () => {
let testRepoPath: string;
let pnpmConfigPath: string;
let pnpmWorkspacePath: string;

beforeEach(() => {
testRepoPath = path.join(__dirname, 'temp', 'catalog-sync-test-repo');
FileSystem.ensureFolder(testRepoPath);

const rushJsonPath: string = path.join(testRepoPath, 'rush.json');
const rushJson = {
$schema: 'https://developer.microsoft.com/json-schemas/rush/v5/rush.schema.json',
rushVersion: '5.166.0',
pnpmVersion: '10.28.1',
nodeSupportedVersionRange: '>=18.0.0',
projects: []
};
JsonFile.save(rushJson, rushJsonPath, { ensureFolderExists: true });

const configDir: string = path.join(testRepoPath, 'common', 'config', 'rush');
FileSystem.ensureFolder(configDir);

pnpmConfigPath = path.join(configDir, 'pnpm-config.json');
const pnpmConfig = {
globalCatalogs: {
default: {
react: '^18.0.0',
'react-dom': '^18.0.0'
}
}
};
JsonFile.save(pnpmConfig, pnpmConfigPath);

const tempDir: string = path.join(testRepoPath, 'common', 'temp');
FileSystem.ensureFolder(tempDir);

pnpmWorkspacePath = path.join(tempDir, 'pnpm-workspace.yaml');
const workspaceYaml = `packages:
- '../../apps/*'
catalogs:
default:
react: ^18.0.0
react-dom: ^18.0.0
`;
FileSystem.writeFile(pnpmWorkspacePath, workspaceYaml);
});

afterEach(() => {
if (FileSystem.exists(testRepoPath)) {
FileSystem.deleteFolder(testRepoPath);
}
});

it('syncs updated catalogs from pnpm-workspace.yaml to pnpm-config.json', () => {
const updatedWorkspaceYaml = `packages:
- '../../apps/*'
catalogs:
default:
react: ^18.2.0
react-dom: ^18.2.0
typescript: ~5.3.0
frontend:
vue: ^3.4.0
`;
FileSystem.writeFile(pnpmWorkspacePath, updatedWorkspaceYaml);

const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile(
path.join(testRepoPath, 'rush.json')
);

const subspace = rushConfiguration.getSubspace('default');
const pnpmOptions = subspace.getPnpmOptions();

expect(TestUtilities.stripAnnotations(pnpmOptions?.globalCatalogs)).toEqual({
default: {
react: '^18.0.0',
'react-dom': '^18.0.0'
}
});

const { PnpmWorkspaceFile } = require('../../logic/pnpm/PnpmWorkspaceFile');
const newCatalogs = PnpmWorkspaceFile.loadCatalogsFromFile(pnpmWorkspacePath);

pnpmOptions?.updateGlobalCatalogs(newCatalogs);

const updatedRushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile(
path.join(testRepoPath, 'rush.json')
);
const updatedSubspace = updatedRushConfiguration.getSubspace('default');
const updatedPnpmOptions = updatedSubspace.getPnpmOptions();

expect(TestUtilities.stripAnnotations(updatedPnpmOptions?.globalCatalogs)).toEqual({
default: {
react: '^18.2.0',
'react-dom': '^18.2.0',
typescript: '~5.3.0'
},
frontend: {
vue: '^3.4.0'
}
});
});

it('does not update pnpm-config.json when catalogs are unchanged', () => {
const { PnpmWorkspaceFile } = require('../../logic/pnpm/PnpmWorkspaceFile');
const newCatalogs = PnpmWorkspaceFile.loadCatalogsFromFile(pnpmWorkspacePath);

const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile(
path.join(testRepoPath, 'rush.json')
);
const subspace = rushConfiguration.getSubspace('default');
const pnpmOptions = subspace.getPnpmOptions();

pnpmOptions?.updateGlobalCatalogs(newCatalogs);

const savedConfig = JsonFile.load(pnpmConfigPath);
expect(savedConfig.globalCatalogs).toEqual({
default: {
react: '^18.0.0',
'react-dom': '^18.0.0'
}
});
});

it('removes catalogs when pnpm-workspace.yaml has no catalogs', () => {
const workspaceWithoutCatalogs = `packages:
- '../../apps/*'
`;
FileSystem.writeFile(pnpmWorkspacePath, workspaceWithoutCatalogs);

const { PnpmWorkspaceFile } = require('../../logic/pnpm/PnpmWorkspaceFile');
const newCatalogs = PnpmWorkspaceFile.loadCatalogsFromFile(pnpmWorkspacePath);

expect(newCatalogs).toBeUndefined();

const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile(
path.join(testRepoPath, 'rush.json')
);
const subspace = rushConfiguration.getSubspace('default');
const pnpmOptions = subspace.getPnpmOptions();

pnpmOptions?.updateGlobalCatalogs(newCatalogs);

const updatedRushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile(
path.join(testRepoPath, 'rush.json')
);
const updatedSubspace = updatedRushConfiguration.getSubspace('default');
const updatedPnpmOptions = updatedSubspace.getPnpmOptions();

expect(updatedPnpmOptions?.globalCatalogs).toBeUndefined();
});
});
});
33 changes: 30 additions & 3 deletions libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -517,7 +517,12 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration
terminal,
jsonFilePath
);
pnpmConfigJson.$schema = pnpmOptionsConfigFile.getSchemaPropertyOriginalValue(pnpmConfigJson);
const schemaValue: string | undefined =
pnpmOptionsConfigFile.getSchemaPropertyOriginalValue(pnpmConfigJson);
// Only set $schema if it has a defined value, since JsonFile.save() will fail if any property is undefined
if (schemaValue !== undefined) {
pnpmConfigJson.$schema = schemaValue;
}
return new PnpmOptionsConfiguration(pnpmConfigJson || {}, commonTempFolder, jsonFilePath);
}

Expand All @@ -534,7 +539,11 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration
*/
public updateGlobalPatchedDependencies(patchedDependencies: Record<string, string> | undefined): void {
this._globalPatchedDependencies = patchedDependencies;
this._json.globalPatchedDependencies = patchedDependencies;
if (patchedDependencies === undefined) {
delete this._json.globalPatchedDependencies;
} else {
this._json.globalPatchedDependencies = patchedDependencies;
}
if (this.jsonFilename) {
JsonFile.save(this._json, this.jsonFilename, { updateExistingFile: true });
}
Expand All @@ -544,7 +553,25 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration
* Updates globalOnlyBuiltDependencies field of the PNPM options in the common/config/rush/pnpm-config.json file.
*/
public updateGlobalOnlyBuiltDependencies(onlyBuiltDependencies: string[] | undefined): void {
this._json.globalOnlyBuiltDependencies = onlyBuiltDependencies;
if (onlyBuiltDependencies === undefined) {
delete this._json.globalOnlyBuiltDependencies;
} else {
this._json.globalOnlyBuiltDependencies = onlyBuiltDependencies;
}
if (this.jsonFilename) {
JsonFile.save(this._json, this.jsonFilename, { updateExistingFile: true });
}
}

/**
* Updates globalCatalogs field of the PNPM options in the common/config/rush/pnpm-config.json file.
*/
public updateGlobalCatalogs(catalogs: Record<string, Record<string, string>> | undefined): void {
if (catalogs === undefined) {
delete this._json.globalCatalogs;
} else {
this._json.globalCatalogs = catalogs;
}
if (this.jsonFilename) {
JsonFile.save(this._json, this.jsonFilename, { updateExistingFile: true });
}
Expand Down
28 changes: 26 additions & 2 deletions libraries/rush-lib/src/logic/pnpm/PnpmWorkspaceFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import * as path from 'node:path';

import { Sort, Import, Path } from '@rushstack/node-core-library';
import { FileSystem, Sort, Import, Path } from '@rushstack/node-core-library';

import { BaseWorkspaceFile } from '../base/BaseWorkspaceFile';
import { PNPM_SHRINKWRAP_YAML_FORMAT } from './PnpmYamlCommon';
Expand All @@ -29,7 +29,7 @@ const globEscape: (unescaped: string) => string = require('glob-escape'); // No
interface IPnpmWorkspaceYaml {
/** The list of local package directories */
packages: string[];
/** Catalog definitions for centralized version management */
/** Named catalog definitions for centralized version management */
catalogs?: Record<string, Record<string, string>>;
}

Expand All @@ -56,6 +56,30 @@ export class PnpmWorkspaceFile extends BaseWorkspaceFile {
this._catalogs = undefined;
}

/**
* Loads and returns the catalogs section from an existing pnpm-workspace.yaml file.
* This method handles both the singular 'catalog' field (for the default catalog) and
* the plural 'catalogs' field (for named catalogs), merging them into a single object.
*
* @param workspaceYamlFilename - The path to the pnpm-workspace.yaml file
* @returns The catalogs object, or undefined if the file doesn't exist or has no catalogs
*/
public static loadCatalogsFromFile(
workspaceYamlFilename: string
): Record<string, Record<string, string>> | undefined {
if (!FileSystem.exists(workspaceYamlFilename)) {
return undefined;
}
const content: string = FileSystem.readFile(workspaceYamlFilename);
const parsed: IPnpmWorkspaceYaml | undefined = yamlModule.load(content) as IPnpmWorkspaceYaml | undefined;

if (!parsed || !parsed.catalogs) {
return undefined;
}

return parsed.catalogs;
}

/**
* Sets the catalog definitions for the workspace.
* @param catalogs - A map of catalog name to package versions
Expand Down
Loading