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..6721a364f5e --- /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 for '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 22ee65f07da..aa21487f8e3 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -1176,6 +1176,7 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration readonly resolutionMode: PnpmResolutionMode | undefined; readonly strictPeerDependencies: boolean; readonly unsupportedPackageJsonSettings: unknown | undefined; + updateGlobalCatalogs(catalogs: Record> | undefined): void; updateGlobalOnlyBuiltDependencies(onlyBuiltDependencies: string[] | undefined): void; updateGlobalPatchedDependencies(patchedDependencies: Record | undefined): void; readonly useWorkspaces: boolean; diff --git a/libraries/rush-lib/src/cli/RushPnpmCommandLineParser.ts b/libraries/rush-lib/src/cli/RushPnpmCommandLineParser.ts index 1980c57f085..fd159df9339 100644 --- a/libraries/rush-lib/src/cli/RushPnpmCommandLineParser.ts +++ b/libraries/rush-lib/src/cli/RushPnpmCommandLineParser.ts @@ -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'; @@ -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> | undefined = + PnpmWorkspaceFile.loadCatalogsFromFile(workspaceYamlFilename); + const currentCatalogs: Record> | 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; + } } } diff --git a/libraries/rush-lib/src/cli/test/RushPnpmCommandLineParser.test.ts b/libraries/rush-lib/src/cli/test/RushPnpmCommandLineParser.test.ts new file mode 100644 index 00000000000..cdb8803ec1e --- /dev/null +++ b/libraries/rush-lib/src/cli/test/RushPnpmCommandLineParser.test.ts @@ -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(); + }); + }); +}); diff --git a/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts b/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts index 026ecd29339..4b4970d08f4 100644 --- a/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts +++ b/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts @@ -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); } @@ -534,7 +539,11 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration */ public updateGlobalPatchedDependencies(patchedDependencies: Record | 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 }); } @@ -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> | 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 }); } diff --git a/libraries/rush-lib/src/logic/pnpm/PnpmWorkspaceFile.ts b/libraries/rush-lib/src/logic/pnpm/PnpmWorkspaceFile.ts index f2d86e24dc5..e4fc4e1200f 100644 --- a/libraries/rush-lib/src/logic/pnpm/PnpmWorkspaceFile.ts +++ b/libraries/rush-lib/src/logic/pnpm/PnpmWorkspaceFile.ts @@ -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'; @@ -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>; } @@ -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> | 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 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 369054709ad..5b7d4e131cb 100644 --- a/libraries/rush-lib/src/logic/pnpm/test/PnpmOptionsConfiguration.test.ts +++ b/libraries/rush-lib/src/logic/pnpm/test/PnpmOptionsConfiguration.test.ts @@ -2,6 +2,7 @@ // 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'; @@ -122,4 +123,219 @@ describe(PnpmOptionsConfiguration.name, () => { } }); }); + + describe('updateGlobalCatalogs', () => { + it('updates and saves globalCatalogs to pnpm-config.json', () => { + const testConfigPath: string = path.join(__dirname, 'temp', 'pnpm-config-update-test.json'); + + const tempDir: string = path.dirname(testConfigPath); + FileSystem.ensureFolder(tempDir); + + try { + const initialConfig = { + globalCatalogs: { + default: { + react: '^18.0.0' + } + } + }; + JsonFile.save(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' + } + }; + pnpmConfiguration.updateGlobalCatalogs(updatedCatalogs); + + const reloadedConfig: PnpmOptionsConfiguration = PnpmOptionsConfiguration.loadFromJsonFileOrThrow( + testConfigPath, + fakeCommonTempFolder + ); + + expect(TestUtilities.stripAnnotations(reloadedConfig.globalCatalogs)).toEqual(updatedCatalogs); + } finally { + // Clean up + if (FileSystem.exists(testConfigPath)) { + FileSystem.deleteFile(testConfigPath); + } + } + }); + + it('handles undefined catalogs', () => { + const testConfigPath: string = path.join(__dirname, 'temp', 'pnpm-config-undefined-test.json'); + + const tempDir: string = path.dirname(testConfigPath); + FileSystem.ensureFolder(tempDir); + + try { + const initialConfig = { + globalCatalogs: { + default: { + react: '^18.0.0' + } + } + }; + JsonFile.save(initialConfig, testConfigPath, { ensureFolderExists: true }); + + const pnpmConfiguration: PnpmOptionsConfiguration = PnpmOptionsConfiguration.loadFromJsonFileOrThrow( + testConfigPath, + fakeCommonTempFolder + ); + + pnpmConfiguration.updateGlobalCatalogs(undefined); + + const reloadedConfig: PnpmOptionsConfiguration = PnpmOptionsConfiguration.loadFromJsonFileOrThrow( + testConfigPath, + fakeCommonTempFolder + ); + + expect(reloadedConfig.globalCatalogs).toBeUndefined(); + } finally { + if (FileSystem.exists(testConfigPath)) { + FileSystem.deleteFile(testConfigPath); + } + } + }); + }); + + describe('$schema handling', () => { + it('does not fail when $schema is undefined', () => { + const testConfigPath: string = path.join(__dirname, 'temp', 'pnpm-config-no-schema.json'); + + const tempDir: string = path.dirname(testConfigPath); + FileSystem.ensureFolder(tempDir); + + try { + const configWithoutSchema = { + globalCatalogs: { + default: { + react: '^18.0.0' + } + } + }; + JsonFile.save(configWithoutSchema, testConfigPath, { ensureFolderExists: true }); + + const pnpmConfiguration: PnpmOptionsConfiguration = PnpmOptionsConfiguration.loadFromJsonFileOrThrow( + testConfigPath, + fakeCommonTempFolder + ); + + const updatedCatalogs = { + default: { + react: '^18.2.0' + } + }; + + expect(() => { + pnpmConfiguration.updateGlobalCatalogs(updatedCatalogs); + }).not.toThrow(); + + const reloadedConfig: PnpmOptionsConfiguration = PnpmOptionsConfiguration.loadFromJsonFileOrThrow( + testConfigPath, + fakeCommonTempFolder + ); + + expect(TestUtilities.stripAnnotations(reloadedConfig.globalCatalogs)).toEqual(updatedCatalogs); + } finally { + if (FileSystem.exists(testConfigPath)) { + FileSystem.deleteFile(testConfigPath); + } + } + }); + + it('preserves $schema when it exists', () => { + const testConfigPath: string = path.join(__dirname, 'temp', 'pnpm-config-with-schema.json'); + + const tempDir: string = path.dirname(testConfigPath); + FileSystem.ensureFolder(tempDir); + + try { + // Create config with $schema + const configWithSchema = { + $schema: 'https://developer.microsoft.com/json-schemas/rush/v5/pnpm-config.schema.json', + globalCatalogs: { + default: { + react: '^18.0.0' + } + } + }; + JsonFile.save(configWithSchema, testConfigPath, { ensureFolderExists: true }); + + const pnpmConfiguration: PnpmOptionsConfiguration = PnpmOptionsConfiguration.loadFromJsonFileOrThrow( + testConfigPath, + fakeCommonTempFolder + ); + + const updatedCatalogs = { + default: { + react: '^18.2.0' + } + }; + pnpmConfiguration.updateGlobalCatalogs(updatedCatalogs); + + const savedConfig = JsonFile.load(testConfigPath); + expect(savedConfig.$schema).toBe( + 'https://developer.microsoft.com/json-schemas/rush/v5/pnpm-config.schema.json' + ); + } finally { + if (FileSystem.exists(testConfigPath)) { + FileSystem.deleteFile(testConfigPath); + } + } + }); + + it('handles undefined in updateGlobalOnlyBuiltDependencies', () => { + const testConfigPath: string = path.join(__dirname, 'temp', 'pnpm-config-undefined-test.json'); + const tempDir: string = path.dirname(testConfigPath); + FileSystem.ensureFolder(tempDir); + + try { + const initialConfig = { + $schema: 'https://developer.microsoft.com/json-schemas/rush/v5/pnpm-config.schema.json', + globalOnlyBuiltDependencies: ['node-gyp', 'esbuild'] + }; + JsonFile.save(initialConfig, testConfigPath, { ensureFolderExists: true }); + + const pnpmConfiguration: PnpmOptionsConfiguration = PnpmOptionsConfiguration.loadFromJsonFileOrThrow( + testConfigPath, + fakeCommonTempFolder + ); + + expect(TestUtilities.stripAnnotations(pnpmConfiguration.globalOnlyBuiltDependencies)).toEqual([ + 'node-gyp', + 'esbuild' + ]); + + expect(() => { + pnpmConfiguration.updateGlobalOnlyBuiltDependencies(undefined); + }).not.toThrow(); + + const reloadedConfig: PnpmOptionsConfiguration = PnpmOptionsConfiguration.loadFromJsonFileOrThrow( + testConfigPath, + fakeCommonTempFolder + ); + expect(reloadedConfig.globalOnlyBuiltDependencies).toBeUndefined(); + + const savedConfigJson = JsonFile.load(testConfigPath); + expect(savedConfigJson.$schema).toBe( + 'https://developer.microsoft.com/json-schemas/rush/v5/pnpm-config.schema.json' + ); + expect(savedConfigJson.globalOnlyBuiltDependencies).toBeUndefined(); + } finally { + if (FileSystem.exists(testConfigPath)) { + FileSystem.deleteFile(testConfigPath); + } + } + }); + }); }); diff --git a/libraries/rush-lib/src/logic/pnpm/test/PnpmWorkspaceFile.test.ts b/libraries/rush-lib/src/logic/pnpm/test/PnpmWorkspaceFile.test.ts index d8cdb149a88..49f1dca56ff 100644 --- a/libraries/rush-lib/src/logic/pnpm/test/PnpmWorkspaceFile.test.ts +++ b/libraries/rush-lib/src/logic/pnpm/test/PnpmWorkspaceFile.test.ts @@ -180,4 +180,47 @@ describe(PnpmWorkspaceFile.name, () => { expect(content).toMatchSnapshot(); }); }); + + describe('loadCatalogsFromFile', () => { + it('returns undefined for non-existent file', () => { + const nonExistentFile: string = path.join(tempDir, 'non-existent.yaml'); + const catalogs = PnpmWorkspaceFile.loadCatalogsFromFile(nonExistentFile); + expect(catalogs).toBeUndefined(); + }); + + it('returns undefined when file has no catalogs', () => { + const workspaceFile: PnpmWorkspaceFile = new PnpmWorkspaceFile(workspaceFilePath); + workspaceFile.addPackage(path.join(projectsDir, 'app1')); + workspaceFile.save(workspaceFilePath, { onlyIfChanged: true }); + + const catalogs = PnpmWorkspaceFile.loadCatalogsFromFile(workspaceFilePath); + expect(catalogs).toBeUndefined(); + }); + + it('loads catalogs from existing file', () => { + const workspaceFile: PnpmWorkspaceFile = new PnpmWorkspaceFile(workspaceFilePath); + workspaceFile.addPackage(path.join(projectsDir, 'app1')); + workspaceFile.setCatalogs({ + default: { + react: '^18.0.0', + typescript: '~5.3.0' + }, + frontend: { + vue: '^3.4.0' + } + }); + workspaceFile.save(workspaceFilePath, { onlyIfChanged: true }); + + const catalogs = PnpmWorkspaceFile.loadCatalogsFromFile(workspaceFilePath); + expect(catalogs).toEqual({ + default: { + react: '^18.0.0', + typescript: '~5.3.0' + }, + frontend: { + vue: '^3.4.0' + } + }); + }); + }); });