From 2ac7a8f2be119761c001c9ca3a02bcc2a7807294 Mon Sep 17 00:00:00 2001 From: Ben Keen Date: Sat, 31 Jan 2026 14:07:33 -0800 Subject: [PATCH] Add 'rush-pnpm up' support for catalogs --- .../rush/main_2026-01-31-22-28.json | 10 + libraries/rush-lib/config/heft.json | 2 +- .../catalogSyncTestRepo/common/.gitignore | 1 + .../common/config/rush/pnpm-config.json | 8 + .../common/temp/pnpm-workspace.yaml | 6 + .../cli/test/catalogSyncTestRepo/rush.json | 7 + .../logic/pnpm/PnpmOptionsConfiguration.ts | 32 +++- .../test/PnpmOptionsConfiguration.test.ts | 177 +++++++++++++++++- 8 files changed, 237 insertions(+), 6 deletions(-) create mode 100644 common/changes/@microsoft/rush/main_2026-01-31-22-28.json create mode 100644 libraries/rush-lib/src/cli/test/catalogSyncTestRepo/common/.gitignore create mode 100644 libraries/rush-lib/src/cli/test/catalogSyncTestRepo/common/config/rush/pnpm-config.json create mode 100644 libraries/rush-lib/src/cli/test/catalogSyncTestRepo/common/temp/pnpm-workspace.yaml create mode 100644 libraries/rush-lib/src/cli/test/catalogSyncTestRepo/rush.json 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/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/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..6f1f6619dcc 100644 --- a/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts +++ b/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts @@ -640,16 +640,40 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration 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.jsonFilename as string, { 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; + await JsonFile.saveAsync(this._json, this.jsonFilename as string, { + 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.jsonFilename as string, { + updateExistingFile: true, + ignoreUndefinedValues: true + }); + } + + /** + * Updates globalAllowBuilds field of the PNPM options in the common/config/rush/pnpm-config.json file. + */ + public updateGlobalAllowBuilds(allowBuilds: Record | undefined): void { + this._json.globalAllowBuilds = allowBuilds; if (this.jsonFilename) { JsonFile.save(this._json, this.jsonFilename, { updateExistingFile: 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(); + }); + }); });