Skip to content
Merged
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: they write or keep \"catalog:\" references and update catalog entries in pnpm-config.json instead of writing versions.",
"type": "minor"
}
],
"packageName": "@microsoft/rush",
"email": "[email protected]"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"changes": [
{
"packageName": "@rushstack/npm-check-fork",
"comment": "Honor the \"cwdPackageJson\" option, and stop mutating the default options on each call.",
"type": "patch"
}
],
"packageName": "@rushstack/npm-check-fork",
"email": "[email protected]"
}
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
3 changes: 3 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,9 @@ export interface INpmCheckPackageJson {

export interface INpmCheckState {
cwd: string;
/**
* The package.json contents to check. Defaults to the package.json file in `cwd`.
*/
cwdPackageJson?: INpmCheckPackageJson;
packages?: INpmCheckPackageSummary[];
}
Expand Down
16 changes: 15 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,18 @@ 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.cwdPackageJson).toBe(cwdPackageJson);

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
18 changes: 15 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,10 @@ 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.` +
' With PNPM catalogs, a catalog reference such as "catalog:" is written instead of a version when the' +
' catalog entry matches the requested version (or no version is specified); with' +
` "${MAKE_CONSISTENT_FLAG_NAME}", the catalog entry is updated.`
].join('\n');
super({
actionName: ADD_ACTION_NAME,
Expand All @@ -46,7 +50,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 +120,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,26 @@ 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. With PNPM catalogs, a catalog reference such as
\\"catalog:\\" is written instead of a version when the catalog entry matches the
requested version (or no version is specified); with \\"--make-consistent\\", the
catalog entry is updated.

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
55 changes: 54 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,57 @@ export class InteractiveUpgrader {
const { projectFolder } = rushProject;

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

return currentState.packages ?? [];
}

/**
* NpmCheck skips non-SemVer specifiers such as `catalog:`, so this returns a copy of package.json with
* catalog references resolved, or `undefined` if there are none.
*/
#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