Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
ea6ee3d
Add 'rush-pnpm up' support for catalogs
Jan 31, 2026
8323e43
Update common/changes/@microsoft/rush/main_2026-01-31-22-28.json
benkeen Feb 24, 2026
f715b55
Apply code review feedback
Feb 25, 2026
ad97f17
Update libraries/rush-lib/src/logic/pnpm/PnpmWorkspaceFile.ts
benkeen Feb 26, 2026
a6e3c54
Update libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts
benkeen Feb 26, 2026
46f7359
Update libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts
benkeen Feb 26, 2026
19f5af2
Update libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts
benkeen Feb 26, 2026
89041c9
Update libraries/rush-lib/src/cli/test/RushPnpmCommandLineParser.test.ts
benkeen Feb 26, 2026
9507313
Update libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts
benkeen Feb 26, 2026
cb3c760
Update libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts
benkeen Feb 26, 2026
073c2b6
Update libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts
benkeen Feb 26, 2026
88b3000
Update libraries/rush-lib/src/logic/pnpm/test/PnpmOptionsConfiguratio…
benkeen Feb 26, 2026
c59d56f
Update libraries/rush-lib/src/logic/pnpm/test/PnpmOptionsConfiguratio…
benkeen Feb 26, 2026
d89bc91
Update libraries/rush-lib/src/logic/pnpm/test/PnpmOptionsConfiguratio…
benkeen Feb 26, 2026
27445b9
Update libraries/rush-lib/src/logic/pnpm/test/PnpmOptionsConfiguratio…
benkeen Feb 26, 2026
20f3cf8
Update libraries/rush-lib/src/logic/pnpm/test/PnpmOptionsConfiguratio…
benkeen Feb 26, 2026
30a60dd
Update libraries/rush-lib/src/logic/pnpm/test/PnpmOptionsConfiguratio…
benkeen Feb 26, 2026
fe4d784
Update libraries/rush-lib/src/logic/pnpm/test/PnpmOptionsConfiguratio…
benkeen Feb 26, 2026
fa9b039
Update libraries/rush-lib/src/logic/pnpm/test/PnpmOptionsConfiguratio…
benkeen Feb 26, 2026
8d44296
Update libraries/rush-lib/src/logic/pnpm/test/PnpmOptionsConfiguratio…
benkeen Feb 26, 2026
3842170
Update libraries/rush-lib/src/logic/pnpm/test/PnpmOptionsConfiguratio…
benkeen Feb 26, 2026
93d85a5
Update libraries/rush-lib/src/logic/pnpm/test/PnpmOptionsConfiguratio…
benkeen Feb 26, 2026
1ffabcd
Update libraries/rush-lib/src/logic/pnpm/test/PnpmOptionsConfiguratio…
benkeen Feb 26, 2026
30064fe
Update libraries/rush-lib/src/logic/pnpm/PnpmWorkspaceFile.ts
benkeen Feb 26, 2026
3ee9066
Code review feedback - test and code cleanup
Feb 26, 2026
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 @@ -1183,7 +1183,8 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration
readonly resolutionMode: PnpmResolutionMode | undefined;
readonly strictPeerDependencies: boolean;
readonly unsupportedPackageJsonSettings: unknown | undefined;
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
26 changes: 25 additions & 1 deletion 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 @@ -583,7 +584,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 @@ -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 =
await PnpmWorkspaceFile.loadCatalogsFromFileAsync(workspaceYamlFilename);
const currentCatalogs: Record<string, Record<string, string>> | undefined =
pnpmOptions.globalCatalogs;

if (!Objects.areDeepEqual(currentCatalogs, newCatalogs)) {
await pnpmOptions.updateGlobalCatalogsAsync(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
133 changes: 133 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,133 @@
// 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';

const MONOREPO_ROOT: string = path.dirname(
RushConfiguration.tryFindRushJsonLocation({ startingFolder: __dirname })!
);
const CATALOG_SYNC_REPO_PATH: string = `${__dirname}/catalogSyncTestRepo`;

describe('RushPnpmCommandLineParser', () => {
describe('catalog syncing', () => {
const testRepoPath: string = `${MONOREPO_ROOT}/temp/catalog-sync-test-repo`;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't do anything outside of the project folder. Do this in <projectRoot>/temp/catalog-sync-test-repo

const pnpmConfigPath: string = `${testRepoPath}/common/config/rush/pnpm-config.json`;
const pnpmWorkspacePath: string = `${testRepoPath}/common/temp/pnpm-workspace.yaml`;

beforeEach(async () => {
await FileSystem.copyFilesAsync({ sourcePath: CATALOG_SYNC_REPO_PATH, destinationPath: testRepoPath });

// common/temp is gitignored so it is not part of the static repo; copy the initial workspace file here
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a .gitignore in the parent folder of with !temp?

await FileSystem.copyFilesAsync({
sourcePath: `${CATALOG_SYNC_REPO_PATH}/pnpm-workspace.yaml`,
destinationPath: pnpmWorkspacePath
});
});

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

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

const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile(
`${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');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const { PnpmWorkspaceFile } = require('../../logic/pnpm/PnpmWorkspaceFile');
const { PnpmWorkspaceFile } = await import('../../logic/pnpm/PnpmWorkspaceFile');

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you just import this at the top?

const newCatalogs = await PnpmWorkspaceFile.loadCatalogsFromFileAsync(pnpmWorkspacePath);

await pnpmOptions?.updateGlobalCatalogsAsync(newCatalogs);

const updatedRushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile(
`${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', async () => {
const { PnpmWorkspaceFile } = require('../../logic/pnpm/PnpmWorkspaceFile');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const { PnpmWorkspaceFile } = require('../../logic/pnpm/PnpmWorkspaceFile');
const { PnpmWorkspaceFile } = await import('../../logic/pnpm/PnpmWorkspaceFile');

const newCatalogs = await PnpmWorkspaceFile.loadCatalogsFromFileAsync(pnpmWorkspacePath);

const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile(
`${testRepoPath}/rush.json`
);
const subspace = rushConfiguration.getSubspace('default');
const pnpmOptions = subspace.getPnpmOptions();

await pnpmOptions?.updateGlobalCatalogsAsync(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', async () => {
const workspaceWithoutCatalogs = `packages:
- '../../apps/*'
`;
await FileSystem.writeFileAsync(pnpmWorkspacePath, workspaceWithoutCatalogs);

const { PnpmWorkspaceFile } = require('../../logic/pnpm/PnpmWorkspaceFile');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const { PnpmWorkspaceFile } = require('../../logic/pnpm/PnpmWorkspaceFile');
const { PnpmWorkspaceFile } = await import('../../logic/pnpm/PnpmWorkspaceFile');

const newCatalogs = await PnpmWorkspaceFile.loadCatalogsFromFileAsync(pnpmWorkspacePath);

expect(newCatalogs).toBeUndefined();

const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile(
`${testRepoPath}/rush.json`
);
const subspace = rushConfiguration.getSubspace('default');
const pnpmOptions = subspace.getPnpmOptions();

await pnpmOptions?.updateGlobalCatalogsAsync(newCatalogs);

const updatedRushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile(
`${testRepoPath}/rush.json`
);
const updatedSubspace = updatedRushConfiguration.getSubspace('default');
const updatedPnpmOptions = updatedSubspace.getPnpmOptions();

expect(updatedPnpmOptions?.globalCatalogs).toBeUndefined();
});
});
});
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": []
}
28 changes: 21 additions & 7 deletions libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -535,18 +535,32 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration
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.jsonFilename as string, { updateExistingFile: true });
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will throw if jsonFilename is undefined. Is the property type wrong?

}

/**
* 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.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<string, Record<string, string>> | undefined
): Promise<void> {
this._json.globalCatalogs = catalogs;
await JsonFile.saveAsync(this._json, this.jsonFilename as string, {
updateExistingFile: true,
ignoreUndefinedValues: true
});
}
}
31 changes: 29 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,33 @@ 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 async loadCatalogsFromFileAsync(
workspaceYamlFilename: string
): Promise<Record<string, Record<string, string>> | undefined> {
let content: string;
try {
content = await FileSystem.readFileAsync(workspaceYamlFilename);
} catch (error) {
if (FileSystem.isNotExistError(error)) {
return undefined;
} else {
throw error;
}
}

const parsed: IPnpmWorkspaceYaml | undefined = yamlModule.load(content) as IPnpmWorkspaceYaml | undefined;

return parsed?.catalogs;
}

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