Skip to content

Commit e15378c

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

8 files changed

Lines changed: 512 additions & 5 deletions

File tree

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: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
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('does not update pnpm-config.json when catalogs are unchanged', () => {
114+
const { PnpmWorkspaceFile } = require('../../logic/pnpm/PnpmWorkspaceFile');
115+
const newCatalogs = PnpmWorkspaceFile.loadCatalogsFromFile(pnpmWorkspacePath);
116+
117+
const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile(
118+
path.join(testRepoPath, 'rush.json')
119+
);
120+
const subspace = rushConfiguration.getSubspace('default');
121+
const pnpmOptions = subspace.getPnpmOptions();
122+
123+
pnpmOptions?.updateGlobalCatalogs(newCatalogs);
124+
125+
const savedConfig = JsonFile.load(pnpmConfigPath);
126+
expect(savedConfig.globalCatalogs).toEqual({
127+
default: {
128+
react: '^18.0.0',
129+
'react-dom': '^18.0.0'
130+
}
131+
});
132+
});
133+
134+
it('removes catalogs when pnpm-workspace.yaml has no catalogs', () => {
135+
const workspaceWithoutCatalogs = `packages:
136+
- '../../apps/*'
137+
`;
138+
FileSystem.writeFile(pnpmWorkspacePath, workspaceWithoutCatalogs);
139+
140+
const { PnpmWorkspaceFile } = require('../../logic/pnpm/PnpmWorkspaceFile');
141+
const newCatalogs = PnpmWorkspaceFile.loadCatalogsFromFile(pnpmWorkspacePath);
142+
143+
expect(newCatalogs).toBeUndefined();
144+
145+
const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile(
146+
path.join(testRepoPath, 'rush.json')
147+
);
148+
const subspace = rushConfiguration.getSubspace('default');
149+
const pnpmOptions = subspace.getPnpmOptions();
150+
151+
pnpmOptions?.updateGlobalCatalogs(newCatalogs);
152+
153+
const updatedRushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile(
154+
path.join(testRepoPath, 'rush.json')
155+
);
156+
const updatedSubspace = updatedRushConfiguration.getSubspace('default');
157+
const updatedPnpmOptions = updatedSubspace.getPnpmOptions();
158+
159+
expect(updatedPnpmOptions?.globalCatalogs).toBeUndefined();
160+
});
161+
});
162+
});

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: 26 additions & 2 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';
@@ -29,7 +29,7 @@ const globEscape: (unescaped: string) => string = require('glob-escape'); // No
2929
interface IPnpmWorkspaceYaml {
3030
/** The list of local package directories */
3131
packages: string[];
32-
/** Catalog definitions for centralized version management */
32+
/** Named catalog definitions for centralized version management */
3333
catalogs?: Record<string, Record<string, string>>;
3434
}
3535

@@ -56,6 +56,30 @@ export class PnpmWorkspaceFile extends BaseWorkspaceFile {
5656
this._catalogs = undefined;
5757
}
5858

59+
/**
60+
* Loads and returns the catalogs section from an existing pnpm-workspace.yaml file.
61+
* This method handles both the singular 'catalog' field (for the default catalog) and
62+
* the plural 'catalogs' field (for named catalogs), merging them into a single object.
63+
*
64+
* @param workspaceYamlFilename - The path to the pnpm-workspace.yaml file
65+
* @returns The catalogs object, or undefined if the file doesn't exist or has no catalogs
66+
*/
67+
public static loadCatalogsFromFile(
68+
workspaceYamlFilename: string
69+
): Record<string, Record<string, string>> | undefined {
70+
if (!FileSystem.exists(workspaceYamlFilename)) {
71+
return undefined;
72+
}
73+
const content: string = FileSystem.readFile(workspaceYamlFilename);
74+
const parsed: IPnpmWorkspaceYaml | undefined = yamlModule.load(content) as IPnpmWorkspaceYaml | undefined;
75+
76+
if (!parsed || !parsed.catalogs) {
77+
return undefined;
78+
}
79+
80+
return parsed.catalogs;
81+
}
82+
5983
/**
6084
* Sets the catalog definitions for the workspace.
6185
* @param catalogs - A map of catalog name to package versions

0 commit comments

Comments
 (0)