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 to `rush-pnpm update`.",
"type": "none"
}
],
"packageName": "@microsoft/rush"
}
3 changes: 2 additions & 1 deletion common/reviews/api/rush-lib.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1209,7 +1209,8 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration
readonly trustPolicyIgnoreAfterMinutes: number | undefined;
readonly unsupportedPackageJsonSettings: unknown | undefined;
updateGlobalAllowBuilds(allowBuilds: Record<string, boolean> | undefined): void;
updateGlobalOnlyBuiltDependencies(onlyBuiltDependencies: string[] | undefined): void;
updateGlobalCatalogsAsync(catalogs: Record<string, Record<string, string>> | undefined): Promise<void>;
updateGlobalOnlyBuiltDependenciesAsync(onlyBuiltDependencies: string[] | undefined): Promise<void>;
updateGlobalPatchedDependencies(patchedDependencies: Record<string, string> | undefined): void;
readonly useWorkspaces: boolean;
}
Expand Down
2 changes: 1 addition & 1 deletion libraries/rush-lib/config/heft.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
{
"sourcePath": "src/cli/test",
"destinationFolders": ["lib-intermediate-commonjs/cli/test"],
"fileExtensions": [".js"]
"fileExtensions": [".js", ".yaml"]
},
{
"sourcePath": "src/logic/pnpm/test",
Expand Down
34 changes: 33 additions & 1 deletion libraries/rush-lib/src/cli/RushPnpmCommandLineParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -633,7 +633,7 @@ export class RushPnpmCommandLineParser {

if (!Objects.areDeepEqual(currentGlobalOnlyBuiltDependencies, newGlobalOnlyBuiltDependencies)) {
// Update onlyBuiltDependencies to pnpm configuration file
pnpmOptions?.updateGlobalOnlyBuiltDependencies(newGlobalOnlyBuiltDependencies);
await pnpmOptions?.updateGlobalOnlyBuiltDependenciesAsync(newGlobalOnlyBuiltDependencies);

// Rerun installation to update
await this._doRushUpdateAsync();
Expand All @@ -646,6 +646,38 @@ export class RushPnpmCommandLineParser {
}
break;
}
case 'update':
case 'up': {
// When "pnpm up" / "pnpm update" runs, PNPM writes any updated catalog versions to the
// generated "catalogs" section of common/temp/<subspace>/pnpm-workspace.yaml. That file is
// regenerated on every install, so the updated versions must be synced back to the
// "globalCatalogs" field of pnpm-config.json for the change to be persisted.
const pnpmOptions: PnpmOptionsConfiguration | undefined = this._subspace.getPnpmOptions();
if (pnpmOptions === undefined) {
break;
}

const workspaceYamlFilename: string = `${subspaceTempFolder}/pnpm-workspace.yaml`;
const yamlModule: typeof import('js-yaml') = await import('js-yaml');
const workspaceYamlContent: string = await FileSystem.readFileAsync(workspaceYamlFilename);
const workspaceYaml: { catalogs?: Record<string, Record<string, string>> } = (yamlModule.load(
workspaceYamlContent
) ?? {}) as { catalogs?: Record<string, Record<string, string>> };
const newGlobalCatalogs: Record<string, Record<string, string>> | undefined = workspaceYaml?.catalogs;
const currentGlobalCatalogs: Record<string, Record<string, string>> | undefined =
pnpmOptions.globalCatalogs;

if (!Objects.areDeepEqual(currentGlobalCatalogs, newGlobalCatalogs)) {
await pnpmOptions.updateGlobalCatalogsAsync(newGlobalCatalogs);
await this._doRushUpdateAsync();

this._terminal.writeWarningLine(
`Rush refreshed the ${RushConstants.pnpmConfigFilename} and shrinkwrap file.\n` +
' Please commit this change to Git.'
);
}
break;
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
// 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 { RushConfiguration } from '../../api/RushConfiguration';
import type { Subspace } from '../../api/Subspace';
import { RushPnpmCommandLineParser } from '../RushPnpmCommandLineParser';

interface IRushPnpmCommandLineParserInternals {
Expand Down Expand Up @@ -34,3 +40,95 @@ describe(RushPnpmCommandLineParser.name, () => {
await expect(validatePnpmArgsAsync(['outdated', '--global'])).resolves.toEqual(['outdated', '--global']);
});
});

describe(`${RushPnpmCommandLineParser.name} catalog sync`, () => {
const PACKAGE_ROOT: string = path.resolve(__dirname, '../../..');
const TEST_TEMP_FOLDER: string = `${PACKAGE_ROOT}/temp/rush-pnpm-catalog-sync-test`;
const FIXTURE_FOLDER: string = `${__dirname}/catalogSyncTestRepo`;

interface IRushPnpmCommandLineParserCatalogInternals {
_commandName: string;
_subspace: Subspace;
_terminal: { writeWarningLine(message: string): void };
_doRushUpdateAsync(): Promise<void>;
_postExecuteAsync(): Promise<void>;
}

function createParserForCommand(
repoFolder: string,
commandName: string
): { parser: IRushPnpmCommandLineParserCatalogInternals; pnpmConfigFilename: string } {
const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile(
`${repoFolder}/rush.json`
);
const subspace: Subspace = rushConfiguration.defaultSubspace;

const parser: IRushPnpmCommandLineParserCatalogInternals = Object.create(
RushPnpmCommandLineParser.prototype
);
parser._commandName = commandName;
parser._subspace = subspace;
parser._terminal = { writeWarningLine: () => {} };
// Avoid triggering a real "rush update"
parser._doRushUpdateAsync = async () => {};

return {
parser,
pnpmConfigFilename: `${repoFolder}/common/config/rush/pnpm-config.json`
};
}

beforeEach(async () => {
await FileSystem.deleteFolderAsync(TEST_TEMP_FOLDER);
await FileSystem.copyFilesAsync({
sourcePath: FIXTURE_FOLDER,
destinationPath: TEST_TEMP_FOLDER
});
});

afterEach(async () => {
await FileSystem.deleteFolderAsync(TEST_TEMP_FOLDER);
});

it('writes updated catalog versions from pnpm-workspace.yaml back to pnpm-config.json', async () => {
// Simulate "pnpm up" having bumped a catalog entry in the generated workspace file
const workspaceYamlFilename: string = `${TEST_TEMP_FOLDER}/common/temp/pnpm-workspace.yaml`;
const bumpedWorkspaceYaml: string = [
'packages:',
" - '../../apps/*'",
'catalogs:',
' default:',
' react: ^18.2.0',
' react-dom: ^18.2.0',
''
].join('\n');
await FileSystem.writeFileAsync(workspaceYamlFilename, bumpedWorkspaceYaml);

const { parser, pnpmConfigFilename } = createParserForCommand(TEST_TEMP_FOLDER, 'up');
await parser._postExecuteAsync();

const updatedConfig: { globalCatalogs?: Record<string, Record<string, string>> } =
await JsonFile.loadAsync(pnpmConfigFilename);
expect(updatedConfig.globalCatalogs).toEqual({
default: {
react: '^18.2.0',
'react-dom': '^18.2.0'
}
});
});

it('does not modify pnpm-config.json when the catalog is unchanged', async () => {
const { parser, pnpmConfigFilename } = createParserForCommand(TEST_TEMP_FOLDER, 'up');

const originalContent: string = await FileSystem.readFileAsync(pnpmConfigFilename);
const doRushUpdateSpy: jest.SpyInstance = jest
.spyOn(parser, '_doRushUpdateAsync')
.mockResolvedValue(undefined);

await parser._postExecuteAsync();

// The fixture's pnpm-workspace.yaml already matches pnpm-config.json, so nothing should change
expect(await FileSystem.readFileAsync(pnpmConfigFilename)).toEqual(originalContent);
expect(doRushUpdateSpy).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
!temp
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"globalCatalogs": {
"default": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
packages:
- '../../apps/*'
catalogs:
default:
react: ^18.0.0
react-dom: ^18.0.0
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"$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": []
}
36 changes: 29 additions & 7 deletions libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -634,25 +634,47 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration
return new PnpmOptionsConfiguration(json, commonTempFolder);
}

private _getJsonFilenameOrThrow(): string {
if (!this.jsonFilename) {
throw new Error('Cannot save pnpm-config.json because no jsonFilename was provided.');
}

return this.jsonFilename;
}

/**
* Updates patchedDependencies field of the PNPM options in the common/config/rush/pnpm-config.json file.
*/
public updateGlobalPatchedDependencies(patchedDependencies: Record<string, string> | undefined): void {
this._globalPatchedDependencies = patchedDependencies;
this._json.globalPatchedDependencies = patchedDependencies;
if (this.jsonFilename) {
JsonFile.save(this._json, this.jsonFilename, { updateExistingFile: true });
}
JsonFile.save(this._json, this._getJsonFilenameOrThrow(), { updateExistingFile: true });
}

/**
* Updates globalOnlyBuiltDependencies field of the PNPM options in the common/config/rush/pnpm-config.json file.
*/
public updateGlobalOnlyBuiltDependencies(onlyBuiltDependencies: string[] | undefined): void {
public async updateGlobalOnlyBuiltDependenciesAsync(
onlyBuiltDependencies: string[] | undefined
): Promise<void> {
this._json.globalOnlyBuiltDependencies = onlyBuiltDependencies;
if (this.jsonFilename) {
JsonFile.save(this._json, this.jsonFilename, { updateExistingFile: true });
}
await JsonFile.saveAsync(this._json, this._getJsonFilenameOrThrow(), {
updateExistingFile: true,
ignoreUndefinedValues: true
});
}

/**
* Updates globalCatalogs field of the PNPM options in the common/config/rush/pnpm-config.json file.
*/
public async updateGlobalCatalogsAsync(
catalogs: Record<string, Record<string, string>> | undefined
): Promise<void> {
this._json.globalCatalogs = catalogs;
await JsonFile.saveAsync(this._json, this._getJsonFilenameOrThrow(), {
updateExistingFile: true,
ignoreUndefinedValues: true
});
}

/**
Expand Down
Loading