Skip to content

Commit 893dd63

Browse files
author
Ben Keen
committed
Add 'rush-pnpm up' support for catalogs
1 parent be05af7 commit 893dd63

File tree

8 files changed

+641
-7
lines changed

8 files changed

+641
-7
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@microsoft/rush",
5+
"comment": "add catalog support for 'rush-pnpm update'",
6+
"type": "none"
7+
}
8+
],
9+
"packageName": "@microsoft/rush"
10+
}

common/reviews/api/rush-lib.api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1176,6 +1176,7 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration
11761176
readonly resolutionMode: PnpmResolutionMode | undefined;
11771177
readonly strictPeerDependencies: boolean;
11781178
readonly unsupportedPackageJsonSettings: unknown | undefined;
1179+
updateGlobalCatalogs(catalogs: Record<string, Record<string, string>> | undefined): void;
11791180
updateGlobalOnlyBuiltDependencies(onlyBuiltDependencies: string[] | undefined): void;
11801181
updateGlobalPatchedDependencies(patchedDependencies: Record<string, string> | undefined): void;
11811182
readonly useWorkspaces: boolean;

libraries/rush-lib/src/cli/RushPnpmCommandLineParser.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import type { IInstallManagerOptions } from '../logic/base/BaseInstallManagerTyp
3232
import { Utilities } from '../utilities/Utilities';
3333
import type { Subspace } from '../api/Subspace';
3434
import type { PnpmOptionsConfiguration } from '../logic/pnpm/PnpmOptionsConfiguration';
35+
import { PnpmWorkspaceFile } from '../logic/pnpm/PnpmWorkspaceFile';
3536
import { EnvironmentVariableNames } from '../api/EnvironmentConfiguration';
3637
import { initializeDotEnv } from '../logic/dotenv';
3738

@@ -595,6 +596,29 @@ export class RushPnpmCommandLineParser {
595596
}
596597
break;
597598
}
599+
case 'up':
600+
case 'update': {
601+
const pnpmOptions: PnpmOptionsConfiguration | undefined = this._subspace.getPnpmOptions();
602+
if (pnpmOptions === undefined) {
603+
break;
604+
}
605+
606+
const workspaceYamlFilename: string = path.join(subspaceTempFolder, 'pnpm-workspace.yaml');
607+
const newCatalogs: Record<string, Record<string, string>> | undefined =
608+
PnpmWorkspaceFile.loadCatalogsFromFile(workspaceYamlFilename);
609+
const currentCatalogs: Record<string, Record<string, string>> | undefined =
610+
pnpmOptions.globalCatalogs;
611+
612+
if (!Objects.areDeepEqual(currentCatalogs, newCatalogs)) {
613+
pnpmOptions.updateGlobalCatalogs(newCatalogs);
614+
615+
this._terminal.writeWarningLine(
616+
`Rush refreshed the ${RushConstants.pnpmConfigFilename} with updated catalog definitions.\n` +
617+
` Run "rush update --recheck" to update the lockfile, then commit these changes to Git.`
618+
);
619+
}
620+
break;
621+
}
598622
}
599623
}
600624

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
2+
// See LICENSE in the project root for license information.
3+
4+
import * as path from 'node:path';
5+
import { FileSystem, JsonFile } from '@rushstack/node-core-library';
6+
import { TestUtilities } from '@rushstack/heft-config-file';
7+
import { RushConfiguration } from '../../api/RushConfiguration';
8+
9+
describe('RushPnpmCommandLineParser', () => {
10+
describe('catalog syncing', () => {
11+
let testRepoPath: string;
12+
let pnpmConfigPath: string;
13+
let pnpmWorkspacePath: string;
14+
15+
beforeEach(() => {
16+
testRepoPath = path.join(__dirname, 'temp', 'catalog-sync-test-repo');
17+
FileSystem.ensureFolder(testRepoPath);
18+
19+
const rushJsonPath: string = path.join(testRepoPath, 'rush.json');
20+
const rushJson = {
21+
$schema: 'https://developer.microsoft.com/json-schemas/rush/v5/rush.schema.json',
22+
rushVersion: '5.166.0',
23+
pnpmVersion: '10.28.1',
24+
nodeSupportedVersionRange: '>=18.0.0',
25+
projects: []
26+
};
27+
JsonFile.save(rushJson, rushJsonPath, { ensureFolderExists: true });
28+
29+
const configDir: string = path.join(testRepoPath, 'common', 'config', 'rush');
30+
FileSystem.ensureFolder(configDir);
31+
32+
pnpmConfigPath = path.join(configDir, 'pnpm-config.json');
33+
const pnpmConfig = {
34+
globalCatalogs: {
35+
default: {
36+
react: '^18.0.0',
37+
'react-dom': '^18.0.0'
38+
}
39+
}
40+
};
41+
JsonFile.save(pnpmConfig, pnpmConfigPath);
42+
43+
const tempDir: string = path.join(testRepoPath, 'common', 'temp');
44+
FileSystem.ensureFolder(tempDir);
45+
46+
pnpmWorkspacePath = path.join(tempDir, 'pnpm-workspace.yaml');
47+
const workspaceYaml = `packages:
48+
- '../../apps/*'
49+
catalogs:
50+
default:
51+
react: ^18.0.0
52+
react-dom: ^18.0.0
53+
`;
54+
FileSystem.writeFile(pnpmWorkspacePath, workspaceYaml);
55+
});
56+
57+
afterEach(() => {
58+
if (FileSystem.exists(testRepoPath)) {
59+
FileSystem.deleteFolder(testRepoPath);
60+
}
61+
});
62+
63+
it('syncs updated catalogs from pnpm-workspace.yaml to pnpm-config.json', () => {
64+
const updatedWorkspaceYaml = `packages:
65+
- '../../apps/*'
66+
catalogs:
67+
default:
68+
react: ^18.2.0
69+
react-dom: ^18.2.0
70+
typescript: ~5.3.0
71+
frontend:
72+
vue: ^3.4.0
73+
`;
74+
FileSystem.writeFile(pnpmWorkspacePath, updatedWorkspaceYaml);
75+
76+
const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile(
77+
path.join(testRepoPath, 'rush.json')
78+
);
79+
80+
const subspace = rushConfiguration.getSubspace('default');
81+
const pnpmOptions = subspace.getPnpmOptions();
82+
83+
expect(TestUtilities.stripAnnotations(pnpmOptions?.globalCatalogs)).toEqual({
84+
default: {
85+
react: '^18.0.0',
86+
'react-dom': '^18.0.0'
87+
}
88+
});
89+
90+
const { PnpmWorkspaceFile } = require('../../logic/pnpm/PnpmWorkspaceFile');
91+
const newCatalogs = PnpmWorkspaceFile.loadCatalogsFromFile(pnpmWorkspacePath);
92+
93+
pnpmOptions?.updateGlobalCatalogs(newCatalogs);
94+
95+
const updatedRushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile(
96+
path.join(testRepoPath, 'rush.json')
97+
);
98+
const updatedSubspace = updatedRushConfiguration.getSubspace('default');
99+
const updatedPnpmOptions = updatedSubspace.getPnpmOptions();
100+
101+
expect(TestUtilities.stripAnnotations(updatedPnpmOptions?.globalCatalogs)).toEqual({
102+
default: {
103+
react: '^18.2.0',
104+
'react-dom': '^18.2.0',
105+
typescript: '~5.3.0'
106+
},
107+
frontend: {
108+
vue: '^3.4.0'
109+
}
110+
});
111+
});
112+
113+
it('handles singular catalog field from pnpm', () => {
114+
const workspaceWithSingularCatalog = `packages:
115+
- '../../apps/*'
116+
catalog:
117+
react: ^18.2.0
118+
react-dom: ^18.2.0
119+
`;
120+
FileSystem.writeFile(pnpmWorkspacePath, workspaceWithSingularCatalog);
121+
122+
const { PnpmWorkspaceFile } = require('../../logic/pnpm/PnpmWorkspaceFile');
123+
const newCatalogs = PnpmWorkspaceFile.loadCatalogsFromFile(pnpmWorkspacePath);
124+
125+
const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile(
126+
path.join(testRepoPath, 'rush.json')
127+
);
128+
const subspace = rushConfiguration.getSubspace('default');
129+
const pnpmOptions = subspace.getPnpmOptions();
130+
131+
pnpmOptions?.updateGlobalCatalogs(newCatalogs);
132+
133+
const updatedRushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile(
134+
path.join(testRepoPath, 'rush.json')
135+
);
136+
const updatedSubspace = updatedRushConfiguration.getSubspace('default');
137+
const updatedPnpmOptions = updatedSubspace.getPnpmOptions();
138+
139+
expect(TestUtilities.stripAnnotations(updatedPnpmOptions?.globalCatalogs)).toEqual({
140+
default: {
141+
react: '^18.2.0',
142+
'react-dom': '^18.2.0'
143+
}
144+
});
145+
});
146+
147+
it('does not update pnpm-config.json when catalogs are unchanged', () => {
148+
const { PnpmWorkspaceFile } = require('../../logic/pnpm/PnpmWorkspaceFile');
149+
const newCatalogs = PnpmWorkspaceFile.loadCatalogsFromFile(pnpmWorkspacePath);
150+
151+
const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile(
152+
path.join(testRepoPath, 'rush.json')
153+
);
154+
const subspace = rushConfiguration.getSubspace('default');
155+
const pnpmOptions = subspace.getPnpmOptions();
156+
157+
pnpmOptions?.updateGlobalCatalogs(newCatalogs);
158+
159+
const savedConfig = JsonFile.load(pnpmConfigPath);
160+
expect(savedConfig.globalCatalogs).toEqual({
161+
default: {
162+
react: '^18.0.0',
163+
'react-dom': '^18.0.0'
164+
}
165+
});
166+
});
167+
168+
it('removes catalogs when pnpm-workspace.yaml has no catalogs', () => {
169+
const workspaceWithoutCatalogs = `packages:
170+
- '../../apps/*'
171+
`;
172+
FileSystem.writeFile(pnpmWorkspacePath, workspaceWithoutCatalogs);
173+
174+
const { PnpmWorkspaceFile } = require('../../logic/pnpm/PnpmWorkspaceFile');
175+
const newCatalogs = PnpmWorkspaceFile.loadCatalogsFromFile(pnpmWorkspacePath);
176+
177+
expect(newCatalogs).toBeUndefined();
178+
179+
const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile(
180+
path.join(testRepoPath, 'rush.json')
181+
);
182+
const subspace = rushConfiguration.getSubspace('default');
183+
const pnpmOptions = subspace.getPnpmOptions();
184+
185+
pnpmOptions?.updateGlobalCatalogs(newCatalogs);
186+
187+
const updatedRushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile(
188+
path.join(testRepoPath, 'rush.json')
189+
);
190+
const updatedSubspace = updatedRushConfiguration.getSubspace('default');
191+
const updatedPnpmOptions = updatedSubspace.getPnpmOptions();
192+
193+
expect(updatedPnpmOptions?.globalCatalogs).toBeUndefined();
194+
});
195+
});
196+
});

libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -517,7 +517,12 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration
517517
terminal,
518518
jsonFilePath
519519
);
520-
pnpmConfigJson.$schema = pnpmOptionsConfigFile.getSchemaPropertyOriginalValue(pnpmConfigJson);
520+
const schemaValue: string | undefined =
521+
pnpmOptionsConfigFile.getSchemaPropertyOriginalValue(pnpmConfigJson);
522+
// Only set $schema if it has a defined value, since JsonFile.save() will fail if any property is undefined
523+
if (schemaValue !== undefined) {
524+
pnpmConfigJson.$schema = schemaValue;
525+
}
521526
return new PnpmOptionsConfiguration(pnpmConfigJson || {}, commonTempFolder, jsonFilePath);
522527
}
523528

@@ -534,7 +539,11 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration
534539
*/
535540
public updateGlobalPatchedDependencies(patchedDependencies: Record<string, string> | undefined): void {
536541
this._globalPatchedDependencies = patchedDependencies;
537-
this._json.globalPatchedDependencies = patchedDependencies;
542+
if (patchedDependencies === undefined) {
543+
delete this._json.globalPatchedDependencies;
544+
} else {
545+
this._json.globalPatchedDependencies = patchedDependencies;
546+
}
538547
if (this.jsonFilename) {
539548
JsonFile.save(this._json, this.jsonFilename, { updateExistingFile: true });
540549
}
@@ -544,7 +553,25 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration
544553
* Updates globalOnlyBuiltDependencies field of the PNPM options in the common/config/rush/pnpm-config.json file.
545554
*/
546555
public updateGlobalOnlyBuiltDependencies(onlyBuiltDependencies: string[] | undefined): void {
547-
this._json.globalOnlyBuiltDependencies = onlyBuiltDependencies;
556+
if (onlyBuiltDependencies === undefined) {
557+
delete this._json.globalOnlyBuiltDependencies;
558+
} else {
559+
this._json.globalOnlyBuiltDependencies = onlyBuiltDependencies;
560+
}
561+
if (this.jsonFilename) {
562+
JsonFile.save(this._json, this.jsonFilename, { updateExistingFile: true });
563+
}
564+
}
565+
566+
/**
567+
* Updates globalCatalogs field of the PNPM options in the common/config/rush/pnpm-config.json file.
568+
*/
569+
public updateGlobalCatalogs(catalogs: Record<string, Record<string, string>> | undefined): void {
570+
if (catalogs === undefined) {
571+
delete this._json.globalCatalogs;
572+
} else {
573+
this._json.globalCatalogs = catalogs;
574+
}
548575
if (this.jsonFilename) {
549576
JsonFile.save(this._json, this.jsonFilename, { updateExistingFile: true });
550577
}

libraries/rush-lib/src/logic/pnpm/PnpmWorkspaceFile.ts

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
import * as path from 'node:path';
55

6-
import { Sort, Import, Path } from '@rushstack/node-core-library';
6+
import { FileSystem, Sort, Import, Path } from '@rushstack/node-core-library';
77

88
import { BaseWorkspaceFile } from '../base/BaseWorkspaceFile';
99
import { PNPM_SHRINKWRAP_YAML_FORMAT } from './PnpmYamlCommon';
@@ -19,17 +19,25 @@ const globEscape: (unescaped: string) => string = require('glob-escape'); // No
1919
* "packages": [
2020
* "../../apps/project1"
2121
* ],
22+
* "catalog": {
23+
* "react": "^18.0.0"
24+
* },
2225
* "catalogs": {
23-
* "default": {
24-
* "react": "^18.0.0"
26+
* "myNamedCatalog": {
27+
* "lodash": "^4.0.0"
2528
* }
2629
* }
2730
* }
2831
*/
2932
interface IPnpmWorkspaceYaml {
3033
/** The list of local package directories */
3134
packages: string[];
32-
/** Catalog definitions for centralized version management */
35+
/**
36+
* The default catalog (singular form). This is a shorthand for catalogs.default.
37+
* pnpm may use this form when updating the workspace file.
38+
*/
39+
catalog?: Record<string, string>;
40+
/** Named catalog definitions for centralized version management */
3341
catalogs?: Record<string, Record<string, string>>;
3442
}
3543

@@ -56,6 +64,50 @@ export class PnpmWorkspaceFile extends BaseWorkspaceFile {
5664
this._catalogs = undefined;
5765
}
5866

67+
/**
68+
* Loads and returns the catalogs section from an existing pnpm-workspace.yaml file.
69+
* This method handles both the singular 'catalog' field (for the default catalog) and
70+
* the plural 'catalogs' field (for named catalogs), merging them into a single object.
71+
*
72+
* @param workspaceYamlFilename - The path to the pnpm-workspace.yaml file
73+
* @returns The catalogs object, or undefined if the file doesn't exist or has no catalogs
74+
*/
75+
public static loadCatalogsFromFile(
76+
workspaceYamlFilename: string
77+
): Record<string, Record<string, string>> | undefined {
78+
if (!FileSystem.exists(workspaceYamlFilename)) {
79+
return undefined;
80+
}
81+
const content: string = FileSystem.readFile(workspaceYamlFilename);
82+
const parsed: IPnpmWorkspaceYaml | undefined = yamlModule.load(content) as IPnpmWorkspaceYaml | undefined;
83+
84+
if (!parsed) {
85+
return undefined;
86+
}
87+
88+
// pnpm supports two ways to define catalogs:
89+
// 1. 'catalog' (singular) - for the default catalog
90+
// 2. 'catalogs' (plural) - for named catalogs (may include a `default` catalog as well)
91+
const result: Record<string, Record<string, string>> = {};
92+
const DEFAULT_CATALOG_NAME: 'default' = 'default';
93+
94+
if (parsed.catalog && Object.keys(parsed.catalog).length > 0) {
95+
result[DEFAULT_CATALOG_NAME] = { ...parsed.catalog };
96+
}
97+
98+
if (parsed.catalogs) {
99+
for (const [catalogName, packages] of Object.entries(parsed.catalogs)) {
100+
if (catalogName === DEFAULT_CATALOG_NAME && result[DEFAULT_CATALOG_NAME]) {
101+
result[DEFAULT_CATALOG_NAME] = { ...result[DEFAULT_CATALOG_NAME], ...packages };
102+
} else {
103+
result[catalogName] = { ...packages };
104+
}
105+
}
106+
}
107+
108+
return Object.keys(result).length > 0 ? result : undefined;
109+
}
110+
59111
/**
60112
* Sets the catalog definitions for the workspace.
61113
* @param catalogs - A map of catalog name to package versions

0 commit comments

Comments
 (0)