diff --git a/common/changes/@microsoft/rush/main_2026-01-31-22-28.json b/common/changes/@microsoft/rush/main_2026-01-31-22-28.json new file mode 100644 index 00000000000..8f3529a2aa7 --- /dev/null +++ b/common/changes/@microsoft/rush/main_2026-01-31-22-28.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Add catalog support to `rush-pnpm update`.", + "type": "none" + } + ], + "packageName": "@microsoft/rush" +} \ No newline at end of file diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index 24c5a6606ac..6ab1bfce0e5 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -1209,7 +1209,8 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration readonly trustPolicyIgnoreAfterMinutes: number | undefined; readonly unsupportedPackageJsonSettings: unknown | undefined; updateGlobalAllowBuilds(allowBuilds: Record | undefined): void; - updateGlobalOnlyBuiltDependencies(onlyBuiltDependencies: string[] | undefined): void; + updateGlobalCatalogsAsync(catalogs: Record> | undefined): Promise; + updateGlobalOnlyBuiltDependenciesAsync(onlyBuiltDependencies: string[] | undefined): Promise; updateGlobalPatchedDependencies(patchedDependencies: Record | undefined): void; readonly useWorkspaces: boolean; } diff --git a/libraries/rush-lib/config/heft.json b/libraries/rush-lib/config/heft.json index d361c6eb55b..4f76fe45dcf 100644 --- a/libraries/rush-lib/config/heft.json +++ b/libraries/rush-lib/config/heft.json @@ -30,7 +30,7 @@ { "sourcePath": "src/cli/test", "destinationFolders": ["lib-intermediate-commonjs/cli/test"], - "fileExtensions": [".js"] + "fileExtensions": [".js", ".yaml"] }, { "sourcePath": "src/logic/pnpm/test", diff --git a/libraries/rush-lib/src/cli/RushPnpmCommandLineParser.ts b/libraries/rush-lib/src/cli/RushPnpmCommandLineParser.ts index c71c3735c02..e83245a0fd1 100644 --- a/libraries/rush-lib/src/cli/RushPnpmCommandLineParser.ts +++ b/libraries/rush-lib/src/cli/RushPnpmCommandLineParser.ts @@ -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(); @@ -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//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> } = (yamlModule.load( + workspaceYamlContent + ) ?? {}) as { catalogs?: Record> }; + const newGlobalCatalogs: Record> | undefined = workspaceYaml?.catalogs; + const currentGlobalCatalogs: Record> | 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; + } } } diff --git a/libraries/rush-lib/src/cli/test/RushPnpmCommandLineParser.test.ts b/libraries/rush-lib/src/cli/test/RushPnpmCommandLineParser.test.ts index 966866c8383..53649908988 100644 --- a/libraries/rush-lib/src/cli/test/RushPnpmCommandLineParser.test.ts +++ b/libraries/rush-lib/src/cli/test/RushPnpmCommandLineParser.test.ts @@ -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 { @@ -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; + _postExecuteAsync(): Promise; + } + + 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> } = + 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(); + }); +}); diff --git a/libraries/rush-lib/src/cli/test/catalogSyncTestRepo/common/.gitignore b/libraries/rush-lib/src/cli/test/catalogSyncTestRepo/common/.gitignore new file mode 100644 index 00000000000..1eb00e19c6a --- /dev/null +++ b/libraries/rush-lib/src/cli/test/catalogSyncTestRepo/common/.gitignore @@ -0,0 +1 @@ +!temp diff --git a/libraries/rush-lib/src/cli/test/catalogSyncTestRepo/common/config/rush/pnpm-config.json b/libraries/rush-lib/src/cli/test/catalogSyncTestRepo/common/config/rush/pnpm-config.json new file mode 100644 index 00000000000..afa5c0bf4c8 --- /dev/null +++ b/libraries/rush-lib/src/cli/test/catalogSyncTestRepo/common/config/rush/pnpm-config.json @@ -0,0 +1,8 @@ +{ + "globalCatalogs": { + "default": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + } +} diff --git a/libraries/rush-lib/src/cli/test/catalogSyncTestRepo/common/temp/pnpm-workspace.yaml b/libraries/rush-lib/src/cli/test/catalogSyncTestRepo/common/temp/pnpm-workspace.yaml new file mode 100644 index 00000000000..c807b81d31e --- /dev/null +++ b/libraries/rush-lib/src/cli/test/catalogSyncTestRepo/common/temp/pnpm-workspace.yaml @@ -0,0 +1,6 @@ +packages: + - '../../apps/*' +catalogs: + default: + react: ^18.0.0 + react-dom: ^18.0.0 diff --git a/libraries/rush-lib/src/cli/test/catalogSyncTestRepo/rush.json b/libraries/rush-lib/src/cli/test/catalogSyncTestRepo/rush.json new file mode 100644 index 00000000000..90cd8a844c4 --- /dev/null +++ b/libraries/rush-lib/src/cli/test/catalogSyncTestRepo/rush.json @@ -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": [] +} diff --git a/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts b/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts index dbaf9027c02..6a95a51c152 100644 --- a/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts +++ b/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts @@ -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 | 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 { 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> | undefined + ): Promise { + this._json.globalCatalogs = catalogs; + await JsonFile.saveAsync(this._json, this._getJsonFilenameOrThrow(), { + updateExistingFile: true, + ignoreUndefinedValues: true + }); } /** diff --git a/libraries/rush-lib/src/logic/pnpm/test/PnpmOptionsConfiguration.test.ts b/libraries/rush-lib/src/logic/pnpm/test/PnpmOptionsConfiguration.test.ts index eb546fdc357..3877c6118db 100644 --- a/libraries/rush-lib/src/logic/pnpm/test/PnpmOptionsConfiguration.test.ts +++ b/libraries/rush-lib/src/logic/pnpm/test/PnpmOptionsConfiguration.test.ts @@ -2,12 +2,20 @@ // See LICENSE in the project root for license information. import * as path from 'node:path'; +import { FileSystem, JsonFile } from '@rushstack/node-core-library'; import { PnpmOptionsConfiguration } from '../PnpmOptionsConfiguration'; import { TestUtilities } from '@rushstack/heft-config-file'; -const fakeCommonTempFolder: string = path.join(__dirname, 'common', 'temp'); +const PACKAGE_ROOT: string = path.resolve(__dirname, '../../../..'); +const TEST_TEMP_FOLDER: string = `${PACKAGE_ROOT}/temp/pnpm-config-update-test`; + +const fakeCommonTempFolder: string = `${__dirname}/common/temp`; describe(PnpmOptionsConfiguration.name, () => { + afterEach(async () => { + await FileSystem.deleteFolderAsync(TEST_TEMP_FOLDER); + }); + it('throw error if pnpm-config.json does not exist', () => { expect(() => { PnpmOptionsConfiguration.loadFromJsonFileOrThrow( @@ -169,4 +177,171 @@ describe(PnpmOptionsConfiguration.name, () => { } }); }); + + describe('updateGlobalCatalogs', () => { + it('updates and saves globalCatalogs to pnpm-config.json', async () => { + const testConfigPath: string = `${TEST_TEMP_FOLDER}/pnpm-config-update-test.json`; + + const initialConfig = { + globalCatalogs: { + default: { + react: '^18.0.0' + } + } + }; + await JsonFile.saveAsync(initialConfig, testConfigPath, { ensureFolderExists: true }); + + const pnpmConfiguration: PnpmOptionsConfiguration = PnpmOptionsConfiguration.loadFromJsonFileOrThrow( + testConfigPath, + fakeCommonTempFolder + ); + + const updatedCatalogs = { + default: { + react: '^18.2.0', + 'react-dom': '^18.2.0' + }, + frontend: { + vue: '^3.4.0' + } + }; + await pnpmConfiguration.updateGlobalCatalogsAsync(updatedCatalogs); + + const reloadedConfig: PnpmOptionsConfiguration = PnpmOptionsConfiguration.loadFromJsonFileOrThrow( + testConfigPath, + fakeCommonTempFolder + ); + + expect(TestUtilities.stripAnnotations(reloadedConfig.globalCatalogs)).toEqual(updatedCatalogs); + }); + + it('handles undefined catalogs', async () => { + const testConfigPath: string = `${TEST_TEMP_FOLDER}/pnpm-config-undefined-test.json`; + + const initialConfig = { + globalCatalogs: { + default: { + react: '^18.0.0' + } + } + }; + await JsonFile.saveAsync(initialConfig, testConfigPath, { ensureFolderExists: true }); + + const pnpmConfiguration: PnpmOptionsConfiguration = PnpmOptionsConfiguration.loadFromJsonFileOrThrow( + testConfigPath, + fakeCommonTempFolder + ); + + await pnpmConfiguration.updateGlobalCatalogsAsync(undefined); + + const reloadedConfig: PnpmOptionsConfiguration = PnpmOptionsConfiguration.loadFromJsonFileOrThrow( + testConfigPath, + fakeCommonTempFolder + ); + + expect(reloadedConfig.globalCatalogs).toBeUndefined(); + }); + }); + + describe('$schema handling', () => { + it('does not fail when $schema is undefined', async () => { + const testConfigPath: string = `${TEST_TEMP_FOLDER}/pnpm-config-no-schema.json`; + + const configWithoutSchema = { + globalCatalogs: { + default: { + react: '^18.0.0' + } + } + }; + await JsonFile.saveAsync(configWithoutSchema, testConfigPath, { ensureFolderExists: true }); + + const pnpmConfiguration: PnpmOptionsConfiguration = PnpmOptionsConfiguration.loadFromJsonFileOrThrow( + testConfigPath, + fakeCommonTempFolder + ); + + const updatedCatalogs = { + default: { + react: '^18.2.0' + } + }; + + await expect(pnpmConfiguration.updateGlobalCatalogsAsync(updatedCatalogs)).resolves.not.toThrow(); + + const reloadedConfig: PnpmOptionsConfiguration = PnpmOptionsConfiguration.loadFromJsonFileOrThrow( + testConfigPath, + fakeCommonTempFolder + ); + + expect(TestUtilities.stripAnnotations(reloadedConfig.globalCatalogs)).toEqual(updatedCatalogs); + }); + + it('preserves $schema when it exists', async () => { + const testConfigPath: string = `${TEST_TEMP_FOLDER}/pnpm-config-with-schema.json`; + + const configWithSchema = { + $schema: 'https://developer.microsoft.com/json-schemas/rush/v5/pnpm-config.schema.json', + globalCatalogs: { + default: { + react: '^18.0.0' + } + } + }; + await JsonFile.saveAsync(configWithSchema, testConfigPath, { ensureFolderExists: true }); + + const pnpmConfiguration: PnpmOptionsConfiguration = PnpmOptionsConfiguration.loadFromJsonFileOrThrow( + testConfigPath, + fakeCommonTempFolder + ); + + const updatedCatalogs = { + default: { + react: '^18.2.0' + } + }; + await pnpmConfiguration.updateGlobalCatalogsAsync(updatedCatalogs); + + const savedConfig = await JsonFile.loadAsync(testConfigPath); + expect(savedConfig.$schema).toBe( + 'https://developer.microsoft.com/json-schemas/rush/v5/pnpm-config.schema.json' + ); + }); + + it('handles undefined in updateGlobalOnlyBuiltDependenciesAsync', async () => { + const testConfigPath: string = `${TEST_TEMP_FOLDER}/pnpm-config-undefined-test.json`; + + const initialConfig = { + $schema: 'https://developer.microsoft.com/json-schemas/rush/v5/pnpm-config.schema.json', + globalOnlyBuiltDependencies: ['node-gyp', 'esbuild'] + }; + await JsonFile.saveAsync(initialConfig, testConfigPath, { ensureFolderExists: true }); + + const pnpmConfiguration: PnpmOptionsConfiguration = PnpmOptionsConfiguration.loadFromJsonFileOrThrow( + testConfigPath, + fakeCommonTempFolder + ); + + expect(TestUtilities.stripAnnotations(pnpmConfiguration.globalOnlyBuiltDependencies)).toEqual([ + 'node-gyp', + 'esbuild' + ]); + + await expect( + pnpmConfiguration.updateGlobalOnlyBuiltDependenciesAsync(undefined) + ).resolves.not.toThrow(); + + const reloadedConfig: PnpmOptionsConfiguration = PnpmOptionsConfiguration.loadFromJsonFileOrThrow( + testConfigPath, + fakeCommonTempFolder + ); + expect(reloadedConfig.globalOnlyBuiltDependencies).toBeUndefined(); + + const savedConfigJson = await JsonFile.loadAsync(testConfigPath); + expect(savedConfigJson.$schema).toBe( + 'https://developer.microsoft.com/json-schemas/rush/v5/pnpm-config.schema.json' + ); + expect(savedConfigJson.globalOnlyBuiltDependencies).toBeUndefined(); + }); + }); });