Skip to content

Commit dfd37db

Browse files
bmiddhaCopilot
andcommitted
Make rush add and upgrade-interactive catalog-aware
"rush add" writes PNPM catalog references (catalog: / catalog:<name>) when a catalog of the project's subspace has a matching entry, accepts explicit catalog references, and with --make-consistent updates the catalog entry instead of replacing references with versions. A project's existing catalog reference is never silently replaced with a version. "rush upgrade-interactive" lists dependencies referenced through a catalog and upgrades them by bumping the catalog entry. When the updated pnpm-config.json is shared with other subspaces, the needed "rush update --subspace" commands are printed. npm-check-fork now honors a provided cwdPackageJson. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent eff340a commit dfd37db

14 files changed

Lines changed: 1743 additions & 226 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@microsoft/rush",
5+
"comment": "Make \"rush add\" and \"rush upgrade-interactive\" aware of PNPM catalogs. \"rush add\" now writes a catalog reference (such as \"catalog:\" or \"catalog:tools\") instead of a version when a catalog of the project's subspace has an entry for the package that matches the requested version (or when no version is specified), and accepts explicit catalog references such as \"rush add -p example@catalog:tools\". With \"--make-consistent\", the catalog entry in pnpm-config.json is updated instead of replacing catalog references with versions. A project's existing catalog reference is never replaced with a version. \"rush upgrade-interactive\" now lists dependencies that are referenced through a catalog, and upgrades them by updating the catalog entry. When the updated pnpm-config.json is shared with other subspaces, Rush prints the \"rush update --subspace\" commands that are needed to update their shrinkwrap files.",
6+
"type": "minor"
7+
}
8+
],
9+
"packageName": "@microsoft/rush",
10+
"email": "223556219+Copilot@users.noreply.github.com"
11+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@rushstack/npm-check-fork",
5+
"comment": "Use the \"cwdPackageJson\" option when it is provided, instead of always reading package.json from the \"cwd\" folder. The default options are no longer modified by each call.",
6+
"type": "patch"
7+
}
8+
],
9+
"packageName": "@rushstack/npm-check-fork",
10+
"email": "223556219+Copilot@users.noreply.github.com"
11+
}

‎libraries/npm-check-fork/src/NpmCheckState.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,15 @@ import {
88
import readPackageJson from './ReadPackageJson';
99

1010
export default async function initializeState(initialOptions?: INpmCheckState): Promise<INpmCheckState> {
11-
const state: INpmCheckState = Object.assign(DefaultNpmCheckOptions, initialOptions ?? {});
11+
const state: INpmCheckState = { ...DefaultNpmCheckOptions, ...initialOptions };
1212

1313
if (state.cwd) {
1414
const cwd: string = path.resolve(state.cwd);
15-
const pkg: INpmCheckPackageJson = readPackageJson(path.join(cwd, 'package.json'));
16-
state.cwdPackageJson = pkg;
15+
if (!initialOptions?.cwdPackageJson) {
16+
const pkg: INpmCheckPackageJson = readPackageJson(path.join(cwd, 'package.json'));
17+
state.cwdPackageJson = pkg;
18+
}
19+
1720
state.cwd = cwd;
1821
}
1922

‎libraries/npm-check-fork/src/interfaces/INpmCheck.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@ export interface INpmCheckPackageJson {
1313

1414
export interface INpmCheckState {
1515
cwd: string;
16+
/**
17+
* The package.json contents of the project to check. If this is not provided, it is read from
18+
* the package.json file in `cwd`.
19+
*
20+
* @remarks
21+
* Provide this to check dependency version specifiers that differ from the package.json file, for example
22+
* after resolving references such as PNPM's `catalog:` protocol to the versions that they refer to.
23+
*/
1624
cwdPackageJson?: INpmCheckPackageJson;
1725
packages?: INpmCheckPackageSummary[];
1826
}

‎libraries/npm-check-fork/src/tests/NpmCheckState.test.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { INpmCheckState } from '../interfaces/INpmCheck';
1+
import type { INpmCheckPackageJson, INpmCheckState } from '../interfaces/INpmCheck';
22
import initializeState from '../NpmCheckState';
33

44
describe('NpmCheckState', () => {
@@ -9,4 +9,20 @@ describe('NpmCheckState', () => {
99
expect(state.cwdPackageJson).toHaveProperty('name');
1010
expect(state.cwdPackageJson).toHaveProperty('version');
1111
});
12+
13+
it('should use the provided package.json instead of reading it from cwd', async () => {
14+
const cwdPackageJson: INpmCheckPackageJson = {
15+
name: 'provided-package',
16+
version: '1.2.3',
17+
dependencies: { 'example-dependency': '^1.0.0' },
18+
devDependencies: {}
19+
};
20+
const state: INpmCheckState = await initializeState({ cwd: process.cwd(), cwdPackageJson });
21+
expect(state.cwd).toBe(process.cwd());
22+
expect(state.cwdPackageJson).toBe(cwdPackageJson);
23+
24+
// The provided package.json must not leak into later calls
25+
const laterState: INpmCheckState = await initializeState({ cwd: process.cwd() });
26+
expect(laterState.cwdPackageJson?.name).toBe('@rushstack/npm-check-fork');
27+
});
1228
});

‎libraries/rush-lib/src/api/test/__snapshots__/RushCommandLine.test.ts.snap‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ Object {
1515
"shortName": "-s",
1616
},
1717
Object {
18-
"description": "The name of the package which should be added as a dependency. A SemVer version specifier can be appended after an \\"@\\" sign. WARNING: Symbol characters are usually interpreted by your shell, so it's recommended to use quotes. For example, write \\"rush add --package \\"example@^1.2.3\\"\\" instead of \\"rush add --package example@^1.2.3\\". To add multiple packages, write \\"rush add --package foo --package bar\\".",
18+
"description": "The name of the package which should be added as a dependency. A SemVer version specifier or a PNPM catalog reference (such as \\"catalog:\\" or \\"catalog:tools\\") can be appended after an \\"@\\" sign. WARNING: Symbol characters are usually interpreted by your shell, so it's recommended to use quotes. For example, write \\"rush add --package \\"example@^1.2.3\\"\\" instead of \\"rush add --package example@^1.2.3\\". To add multiple packages, write \\"rush add --package foo --package bar\\".",
1919
"environmentVariable": undefined,
2020
"kind": "StringList",
2121
"longName": "--package",

‎libraries/rush-lib/src/cli/actions/AddAction.ts‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
SemVerStyle
1616
} from '../../logic/PackageJsonUpdaterTypes';
1717
import { getVariantAsync } from '../../api/Variants';
18+
import { tryParsePnpmCatalogName } from '../../logic/pnpm/PnpmCatalogReference';
1819

1920
const ADD_ACTION_NAME: 'add' = 'add';
2021
export const MAKE_CONSISTENT_FLAG_NAME: '--make-consistent' = '--make-consistent';
@@ -35,7 +36,12 @@ export class AddAction extends BaseAddAndRemoveAction {
3536
' either the latest version or a version that won\'t break the "ensureConsistentVersions" policy). If a version' +
3637
' range (or a workspace range) is specified, the latest version in the range will be used. The version will be' +
3738
` automatically prepended with a tilde, unless the "${EXACT_FLAG_NAME}" or "${CARET_FLAG_NAME}" flags are used.` +
38-
` The "${MAKE_CONSISTENT_FLAG_NAME}" flag can be used to update all packages with the dependency.`
39+
` The "${MAKE_CONSISTENT_FLAG_NAME}" flag can be used to update all packages with the dependency.` +
40+
' When using PNPM catalogs, a catalog reference such as "catalog:" is written instead of a version if' +
41+
' the catalog has an entry for the package that matches the requested version (or if no version is' +
42+
` specified). With the "${MAKE_CONSISTENT_FLAG_NAME}" flag, the catalog entry is updated to the requested` +
43+
' version. A catalog can also be referenced explicitly, for example "example@catalog:" or' +
44+
' "example@catalog:tools".'
3945
].join('\n');
4046
super({
4147
actionName: ADD_ACTION_NAME,
@@ -46,7 +52,8 @@ export class AddAction extends BaseAddAndRemoveAction {
4652
allFlagDescription: 'If specified, the dependency will be added to all projects.',
4753
packageNameListParameterDescription:
4854
'The name of the package which should be added as a dependency.' +
49-
' A SemVer version specifier can be appended after an "@" sign. WARNING: Symbol characters' +
55+
' A SemVer version specifier or a PNPM catalog reference (such as "catalog:" or "catalog:tools")' +
56+
' can be appended after an "@" sign. WARNING: Symbol characters' +
5057
" are usually interpreted by your shell, so it's recommended to use quotes." +
5158
` For example, write "rush add ${PACKAGE_PARAMETER_NAME} "example@^1.2.3"" instead of "rush add ${PACKAGE_PARAMETER_NAME} example@^1.2.3".` +
5259
` To add multiple packages, write "rush add ${PACKAGE_PARAMETER_NAME} foo ${PACKAGE_PARAMETER_NAME} bar".`
@@ -115,7 +122,14 @@ export class AddAction extends BaseAddAndRemoveAction {
115122
throw new Error(`The package name "${packageName}" is not valid.`);
116123
}
117124

118-
if (version && version !== 'latest') {
125+
if (version && tryParsePnpmCatalogName(version) !== undefined) {
126+
if (!this.rushConfiguration.isPnpm) {
127+
throw new Error(
128+
`The version specifier "${version}" for "${packageName}" references a PNPM catalog, ` +
129+
'but catalogs are only supported when the package manager is PNPM.'
130+
);
131+
}
132+
} else if (version && version !== 'latest') {
119133
const specifier: DependencySpecifier = new DependencySpecifier(packageName, version);
120134
if (!semver.validRange(specifier.versionSpecifier) && !semver.valid(specifier.versionSpecifier)) {
121135
throw new Error(`The SemVer specifier "${version}" is not valid.`);

‎libraries/rush-lib/src/cli/actions/test/AddAction.test.ts‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import '../../test/mockRushCommandLineParser';
55

66
import { LockFile } from '@rushstack/node-core-library';
7+
import { AnsiEscape } from '@rushstack/terminal';
78

89
import { PackageJsonUpdater } from '../../../logic/PackageJsonUpdater';
910
import type { IPackageJsonUpdaterRushAddOptions } from '../../../logic/PackageJsonUpdaterTypes';
@@ -105,5 +106,64 @@ describe(AddAction.name, () => {
105106
`);
106107
});
107108
});
109+
110+
describe("'add' action with PNPM catalog references", () => {
111+
it('passes explicit catalog references through', async () => {
112+
const startPath: string = `${__dirname}/../../../logic/test/repoWithCatalogs`;
113+
const parser: RushCommandLineParser = new RushCommandLineParser({ cwd: startPath });
114+
jest.spyOn(process, 'cwd').mockReturnValue(`${startPath}/c`);
115+
116+
process.argv = [
117+
'pretend-this-is-node.exe',
118+
'pretend-this-is-rush',
119+
'add',
120+
'-p',
121+
'semver@catalog:',
122+
'-p',
123+
'typescript@catalog:tools'
124+
];
125+
126+
await expect(parser.executeAsync()).resolves.toEqual(true);
127+
expect(doRushAddMock).toHaveBeenCalledTimes(1);
128+
const doRushAddOptions: IPackageJsonUpdaterRushAddOptions = doRushAddMock.mock.calls[0][0];
129+
expect(doRushAddOptions.projects.map(({ packageName }) => packageName)).toEqual(['c']);
130+
expect(doRushAddOptions.packagesToUpdate).toMatchInlineSnapshot(`
131+
Array [
132+
Object {
133+
"packageName": "semver",
134+
"rangeStyle": "passthrough",
135+
"version": "catalog:",
136+
},
137+
Object {
138+
"packageName": "typescript",
139+
"rangeStyle": "passthrough",
140+
"version": "catalog:tools",
141+
},
142+
]
143+
`);
144+
});
145+
146+
it('rejects catalog references when the package manager is not PNPM', async () => {
147+
const consoleErrorMock: jest.SpyInstance = jest.spyOn(console, 'error').mockImplementation();
148+
const startPath: string = `${__dirname}/addRepo`;
149+
const parser: RushCommandLineParser = new RushCommandLineParser({ cwd: startPath });
150+
jest.spyOn(process, 'cwd').mockReturnValue(`${startPath}/a`);
151+
152+
process.argv = ['pretend-this-is-node.exe', 'pretend-this-is-rush', 'add', '-p', 'assert@catalog:'];
153+
154+
await parser.executeAsync();
155+
expect(process.exitCode).toEqual(1);
156+
expect(doRushAddMock).not.toHaveBeenCalled();
157+
// The error message is colorized and word-wrapped
158+
const errorOutput: string = AnsiEscape.removeCodes(consoleErrorMock.mock.calls.join('\n')).replace(
159+
/\s+/g,
160+
' '
161+
);
162+
expect(errorOutput).toContain(
163+
'The version specifier "catalog:" for "assert" references a PNPM catalog, ' +
164+
'but catalogs are only supported when the package manager is PNPM.'
165+
);
166+
});
167+
});
108168
});
109169
});

‎libraries/rush-lib/src/cli/test/__snapshots__/CommandLineHelp.test.ts.snap‎

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -99,22 +99,28 @@ either the latest version or a version that won't break the
9999
is specified, the latest version in the range will be used. The version will
100100
be automatically prepended with a tilde, unless the \\"--exact\\" or \\"--caret\\"
101101
flags are used. The \\"--make-consistent\\" flag can be used to update all
102-
packages with the dependency.
102+
packages with the dependency. When using PNPM catalogs, a catalog reference
103+
such as \\"catalog:\\" is written instead of a version if the catalog has an
104+
entry for the package that matches the requested version (or if no version is
105+
specified). With the \\"--make-consistent\\" flag, the catalog entry is updated
106+
to the requested version. A catalog can also be referenced explicitly, for
107+
example \\"example@catalog:\\" or \\"example@catalog:tools\\".
103108
104109
Optional arguments:
105110
-h, --help Show this help message and exit.
106111
-s, --skip-update If specified, the \\"rush update\\" command will not be
107112
run after updating the package.json files.
108113
-p PACKAGE, --package PACKAGE
109114
The name of the package which should be added as a
110-
dependency. A SemVer version specifier can be
111-
appended after an \\"@\\" sign. WARNING: Symbol
112-
characters are usually interpreted by your shell, so
113-
it's recommended to use quotes. For example, write
114-
\\"rush add --package \\"example@^1.2.3\\"\\" instead of
115-
\\"rush add --package example@^1.2.3\\". To add multiple
116-
packages, write \\"rush add --package foo --package
117-
bar\\".
115+
dependency. A SemVer version specifier or a PNPM
116+
catalog reference (such as \\"catalog:\\" or
117+
\\"catalog:tools\\") can be appended after an \\"@\\" sign.
118+
WARNING: Symbol characters are usually interpreted by
119+
your shell, so it's recommended to use quotes. For
120+
example, write \\"rush add --package \\"example@^1.2.3\\"\\"
121+
instead of \\"rush add --package example@^1.2.3\\". To
122+
add multiple packages, write \\"rush add --package foo
123+
--package bar\\".
118124
--all If specified, the dependency will be added to all
119125
projects.
120126
--variant VARIANT Run command using a variant installation

‎libraries/rush-lib/src/logic/InteractiveUpgrader.ts‎

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,16 @@
22
// See LICENSE in the project root for license information.
33

44
import { NpmCheck, type INpmCheckState, type INpmCheckPackageSummary } from '@rushstack/npm-check-fork';
5+
import type { IPackageJson } from '@rushstack/node-core-library';
56
import { Colorize } from '@rushstack/terminal';
67

78
import type { RushConfiguration } from '../api/RushConfiguration';
89
import { upgradeInteractive, type IDepsToUpgradeAnswers } from '../utilities/InteractiveUpgradeUI';
910
import type { RushConfigurationProject } from '../api/RushConfigurationProject';
11+
import { getEffectivePnpmCatalogs, type PnpmCatalogs } from './pnpm/EffectivePnpmOptions';
12+
import { tryResolvePnpmCatalogReference, type IPnpmCatalogReference } from './pnpm/PnpmCatalogReference';
13+
14+
type NpmCheckPackageJson = NonNullable<INpmCheckState['cwdPackageJson']>;
1015

1116
interface IUpgradeInteractiveDeps {
1217
projects: RushConfigurationProject[];
@@ -68,9 +73,59 @@ export class InteractiveUpgrader {
6873
const { projectFolder } = rushProject;
6974

7075
const currentState: INpmCheckState = await NpmCheck({
71-
cwd: projectFolder
76+
cwd: projectFolder,
77+
cwdPackageJson: this.#tryGetPackageJsonWithResolvedCatalogReferences(rushProject)
7278
});
7379

7480
return currentState.packages ?? [];
7581
}
82+
83+
/**
84+
* NpmCheck ignores dependencies whose version specifier is not a SemVer range, such as PNPM catalog
85+
* references (`catalog:`). If the project has catalog references, returns a copy of its package.json
86+
* with each reference replaced by the version from the catalog, so that those dependencies can be upgraded.
87+
* The upgrade updates the catalog entry instead of the reference (see PackageJsonUpdater.doRushUpgradeAsync).
88+
*/
89+
#tryGetPackageJsonWithResolvedCatalogReferences(
90+
rushProject: RushConfigurationProject
91+
): NpmCheckPackageJson | undefined {
92+
const catalogs: PnpmCatalogs | undefined = getEffectivePnpmCatalogs(
93+
rushProject.subspace,
94+
this.#rushConfiguration
95+
);
96+
if (!catalogs) {
97+
return undefined;
98+
}
99+
100+
let hasResolvedReferences: boolean = false;
101+
const resolveCatalogReferences: (
102+
dependencies: Record<string, string> | undefined
103+
) => Record<string, string> = (dependencies: Record<string, string> | undefined) => {
104+
const result: Record<string, string> = {};
105+
for (const [packageName, versionSpecifier] of Object.entries(dependencies ?? {})) {
106+
const catalogReference: IPnpmCatalogReference | undefined = tryResolvePnpmCatalogReference(
107+
packageName,
108+
versionSpecifier,
109+
catalogs
110+
);
111+
if (catalogReference?.resolvedVersion !== undefined) {
112+
result[packageName] = catalogReference.resolvedVersion;
113+
hasResolvedReferences = true;
114+
} else {
115+
result[packageName] = versionSpecifier;
116+
}
117+
}
118+
119+
return result;
120+
};
121+
122+
const packageJson: IPackageJson = rushProject.packageJson;
123+
const packageJsonWithResolvedReferences: NpmCheckPackageJson = {
124+
...packageJson,
125+
dependencies: resolveCatalogReferences(packageJson.dependencies),
126+
devDependencies: resolveCatalogReferences(packageJson.devDependencies)
127+
};
128+
129+
return hasResolvedReferences ? packageJsonWithResolvedReferences : undefined;
130+
}
76131
}

0 commit comments

Comments
 (0)