Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"changes": [
{
"packageName": "@microsoft/rush",
"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.",
"type": "minor"
}
],
"packageName": "@microsoft/rush",
"email": "223556219+Copilot@users.noreply.github.com"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"changes": [
{
"packageName": "@rushstack/npm-check-fork",
"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.",
"type": "patch"
}
],
"packageName": "@rushstack/npm-check-fork",
"email": "223556219+Copilot@users.noreply.github.com"
}
9 changes: 6 additions & 3 deletions libraries/npm-check-fork/src/NpmCheckState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,15 @@ import {
import readPackageJson from './ReadPackageJson';

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

if (state.cwd) {
const cwd: string = path.resolve(state.cwd);
const pkg: INpmCheckPackageJson = readPackageJson(path.join(cwd, 'package.json'));
state.cwdPackageJson = pkg;
if (!initialOptions?.cwdPackageJson) {
const pkg: INpmCheckPackageJson = readPackageJson(path.join(cwd, 'package.json'));
state.cwdPackageJson = pkg;
}

state.cwd = cwd;
}

Expand Down
8 changes: 8 additions & 0 deletions libraries/npm-check-fork/src/interfaces/INpmCheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ export interface INpmCheckPackageJson {

export interface INpmCheckState {
cwd: string;
/**
* The package.json contents of the project to check. If this is not provided, it is read from
* the package.json file in `cwd`.
*
* @remarks
* Provide this to check dependency version specifiers that differ from the package.json file, for example
* after resolving references such as PNPM's `catalog:` protocol to the versions that they refer to.
*/
cwdPackageJson?: INpmCheckPackageJson;
packages?: INpmCheckPackageSummary[];
}
Expand Down
18 changes: 17 additions & 1 deletion libraries/npm-check-fork/src/tests/NpmCheckState.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { INpmCheckState } from '../interfaces/INpmCheck';
import type { INpmCheckPackageJson, INpmCheckState } from '../interfaces/INpmCheck';
import initializeState from '../NpmCheckState';

describe('NpmCheckState', () => {
Expand All @@ -9,4 +9,20 @@ describe('NpmCheckState', () => {
expect(state.cwdPackageJson).toHaveProperty('name');
expect(state.cwdPackageJson).toHaveProperty('version');
});

it('should use the provided package.json instead of reading it from cwd', async () => {
const cwdPackageJson: INpmCheckPackageJson = {
name: 'provided-package',
version: '1.2.3',
dependencies: { 'example-dependency': '^1.0.0' },
devDependencies: {}
};
const state: INpmCheckState = await initializeState({ cwd: process.cwd(), cwdPackageJson });
expect(state.cwd).toBe(process.cwd());
expect(state.cwdPackageJson).toBe(cwdPackageJson);

// The provided package.json must not leak into later calls
const laterState: INpmCheckState = await initializeState({ cwd: process.cwd() });
expect(laterState.cwdPackageJson?.name).toBe('@rushstack/npm-check-fork');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Object {
"shortName": "-s",
},
Object {
"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\\".",
"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\\".",
"environmentVariable": undefined,
"kind": "StringList",
"longName": "--package",
Expand Down
20 changes: 17 additions & 3 deletions libraries/rush-lib/src/cli/actions/AddAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
SemVerStyle
} from '../../logic/PackageJsonUpdaterTypes';
import { getVariantAsync } from '../../api/Variants';
import { tryParsePnpmCatalogName } from '../../logic/pnpm/PnpmCatalogReference';

const ADD_ACTION_NAME: 'add' = 'add';
export const MAKE_CONSISTENT_FLAG_NAME: '--make-consistent' = '--make-consistent';
Expand All @@ -35,7 +36,12 @@ export class AddAction extends BaseAddAndRemoveAction {
' either the latest version or a version that won\'t break the "ensureConsistentVersions" policy). If a version' +
' range (or a workspace range) is specified, the latest version in the range will be used. The version will be' +
` automatically prepended with a tilde, unless the "${EXACT_FLAG_NAME}" or "${CARET_FLAG_NAME}" flags are used.` +
` The "${MAKE_CONSISTENT_FLAG_NAME}" flag can be used to update all packages with the dependency.`
` The "${MAKE_CONSISTENT_FLAG_NAME}" flag can be used to update all packages with the dependency.` +
' When using PNPM catalogs, a catalog reference such as "catalog:" is written instead of a version if' +
' the catalog has an entry for the package that matches the requested version (or if no version is' +
` specified). With the "${MAKE_CONSISTENT_FLAG_NAME}" flag, the catalog entry is updated to the requested` +
' version. A catalog can also be referenced explicitly, for example "example@catalog:" or' +
' "example@catalog:tools".'
].join('\n');
super({
actionName: ADD_ACTION_NAME,
Expand All @@ -46,7 +52,8 @@ export class AddAction extends BaseAddAndRemoveAction {
allFlagDescription: 'If specified, the dependency will be added to all projects.',
packageNameListParameterDescription:
'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' +
' 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_PARAMETER_NAME} "example@^1.2.3"" instead of "rush add ${PACKAGE_PARAMETER_NAME} example@^1.2.3".` +
` To add multiple packages, write "rush add ${PACKAGE_PARAMETER_NAME} foo ${PACKAGE_PARAMETER_NAME} bar".`
Expand Down Expand Up @@ -115,7 +122,14 @@ export class AddAction extends BaseAddAndRemoveAction {
throw new Error(`The package name "${packageName}" is not valid.`);
}

if (version && version !== 'latest') {
if (version && tryParsePnpmCatalogName(version) !== undefined) {
if (!this.rushConfiguration.isPnpm) {
throw new Error(
`The version specifier "${version}" for "${packageName}" references a PNPM catalog, ` +
'but catalogs are only supported when the package manager is PNPM.'
);
}
} else if (version && version !== 'latest') {
const specifier: DependencySpecifier = new DependencySpecifier(packageName, version);
if (!semver.validRange(specifier.versionSpecifier) && !semver.valid(specifier.versionSpecifier)) {
throw new Error(`The SemVer specifier "${version}" is not valid.`);
Expand Down
60 changes: 60 additions & 0 deletions libraries/rush-lib/src/cli/actions/test/AddAction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import '../../test/mockRushCommandLineParser';

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

import { PackageJsonUpdater } from '../../../logic/PackageJsonUpdater';
import type { IPackageJsonUpdaterRushAddOptions } from '../../../logic/PackageJsonUpdaterTypes';
Expand Down Expand Up @@ -105,5 +106,64 @@ describe(AddAction.name, () => {
`);
});
});

describe("'add' action with PNPM catalog references", () => {
it('passes explicit catalog references through', async () => {
const startPath: string = `${__dirname}/../../../logic/test/repoWithCatalogs`;
const parser: RushCommandLineParser = new RushCommandLineParser({ cwd: startPath });
jest.spyOn(process, 'cwd').mockReturnValue(`${startPath}/c`);

process.argv = [
'pretend-this-is-node.exe',
'pretend-this-is-rush',
'add',
'-p',
'semver@catalog:',
'-p',
'typescript@catalog:tools'
];

await expect(parser.executeAsync()).resolves.toEqual(true);
expect(doRushAddMock).toHaveBeenCalledTimes(1);
const doRushAddOptions: IPackageJsonUpdaterRushAddOptions = doRushAddMock.mock.calls[0][0];
expect(doRushAddOptions.projects.map(({ packageName }) => packageName)).toEqual(['c']);
expect(doRushAddOptions.packagesToUpdate).toMatchInlineSnapshot(`
Array [
Object {
"packageName": "semver",
"rangeStyle": "passthrough",
"version": "catalog:",
},
Object {
"packageName": "typescript",
"rangeStyle": "passthrough",
"version": "catalog:tools",
},
]
`);
});

it('rejects catalog references when the package manager is not PNPM', async () => {
const consoleErrorMock: jest.SpyInstance = jest.spyOn(console, 'error').mockImplementation();
const startPath: string = `${__dirname}/addRepo`;
const parser: RushCommandLineParser = new RushCommandLineParser({ cwd: startPath });
jest.spyOn(process, 'cwd').mockReturnValue(`${startPath}/a`);

process.argv = ['pretend-this-is-node.exe', 'pretend-this-is-rush', 'add', '-p', 'assert@catalog:'];

await parser.executeAsync();
expect(process.exitCode).toEqual(1);
expect(doRushAddMock).not.toHaveBeenCalled();
// The error message is colorized and word-wrapped
const errorOutput: string = AnsiEscape.removeCodes(consoleErrorMock.mock.calls.join('\n')).replace(
/\s+/g,
' '
);
expect(errorOutput).toContain(
'The version specifier "catalog:" for "assert" references a PNPM catalog, ' +
'but catalogs are only supported when the package manager is PNPM.'
);
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -99,22 +99,28 @@ either the latest version or a version that won't break the
is specified, the latest version in the range will be used. The version will
be automatically prepended with a tilde, unless the \\"--exact\\" or \\"--caret\\"
flags are used. The \\"--make-consistent\\" flag can be used to update all
packages with the dependency.
packages with the dependency. When using PNPM catalogs, a catalog reference
such as \\"catalog:\\" is written instead of a version if the catalog has an
entry for the package that matches the requested version (or if no version is
specified). With the \\"--make-consistent\\" flag, the catalog entry is updated
to the requested version. A catalog can also be referenced explicitly, for
example \\"example@catalog:\\" or \\"example@catalog:tools\\".

Optional arguments:
-h, --help Show this help message and exit.
-s, --skip-update If specified, the \\"rush update\\" command will not be
run after updating the package.json files.
-p PACKAGE, --package PACKAGE
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\\".
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\\".
--all If specified, the dependency will be added to all
projects.
--variant VARIANT Run command using a variant installation
Expand Down
57 changes: 56 additions & 1 deletion libraries/rush-lib/src/logic/InteractiveUpgrader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,16 @@
// See LICENSE in the project root for license information.

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

import type { RushConfiguration } from '../api/RushConfiguration';
import { upgradeInteractive, type IDepsToUpgradeAnswers } from '../utilities/InteractiveUpgradeUI';
import type { RushConfigurationProject } from '../api/RushConfigurationProject';
import { getEffectivePnpmCatalogs, type PnpmCatalogs } from './pnpm/EffectivePnpmOptions';
import { tryResolvePnpmCatalogReference, type IPnpmCatalogReference } from './pnpm/PnpmCatalogReference';

type NpmCheckPackageJson = NonNullable<INpmCheckState['cwdPackageJson']>;

interface IUpgradeInteractiveDeps {
projects: RushConfigurationProject[];
Expand Down Expand Up @@ -68,9 +73,59 @@ export class InteractiveUpgrader {
const { projectFolder } = rushProject;

const currentState: INpmCheckState = await NpmCheck({
cwd: projectFolder
cwd: projectFolder,
cwdPackageJson: this.#tryGetPackageJsonWithResolvedCatalogReferences(rushProject)
});

return currentState.packages ?? [];
}

/**
* NpmCheck ignores dependencies whose version specifier is not a SemVer range, such as PNPM catalog
* references (`catalog:`). If the project has catalog references, returns a copy of its package.json
* with each reference replaced by the version from the catalog, so that those dependencies can be upgraded.
* The upgrade updates the catalog entry instead of the reference (see PackageJsonUpdater.doRushUpgradeAsync).
*/
#tryGetPackageJsonWithResolvedCatalogReferences(
rushProject: RushConfigurationProject
): NpmCheckPackageJson | undefined {
const catalogs: PnpmCatalogs | undefined = getEffectivePnpmCatalogs(
rushProject.subspace,
this.#rushConfiguration
);
if (!catalogs) {
return undefined;
}

let hasResolvedReferences: boolean = false;
const resolveCatalogReferences: (
dependencies: Record<string, string> | undefined
) => Record<string, string> = (dependencies: Record<string, string> | undefined) => {
const result: Record<string, string> = {};
for (const [packageName, versionSpecifier] of Object.entries(dependencies ?? {})) {
const catalogReference: IPnpmCatalogReference | undefined = tryResolvePnpmCatalogReference(
packageName,
versionSpecifier,
catalogs
);
if (catalogReference?.resolvedVersion !== undefined) {
result[packageName] = catalogReference.resolvedVersion;
hasResolvedReferences = true;
} else {
result[packageName] = versionSpecifier;
}
}

return result;
};

const packageJson: IPackageJson = rushProject.packageJson;
const packageJsonWithResolvedReferences: NpmCheckPackageJson = {
...packageJson,
dependencies: resolveCatalogReferences(packageJson.dependencies),
devDependencies: resolveCatalogReferences(packageJson.devDependencies)
};

return hasResolvedReferences ? packageJsonWithResolvedReferences : undefined;
}
}
Loading
Loading