diff --git a/common/changes/@microsoft/rush/more-install-helpers-cleanup_2026-07-17-20-53-20.json b/common/changes/@microsoft/rush/more-install-helpers-cleanup_2026-07-17-20-53-20.json new file mode 100644 index 0000000000..efcd84c45f --- /dev/null +++ b/common/changes/@microsoft/rush/more-install-helpers-cleanup_2026-07-17-20-53-20.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "", + "type": "none", + "packageName": "@microsoft/rush" + } + ], + "packageName": "@microsoft/rush", + "email": "iclanton@users.noreply.github.com" +} \ No newline at end of file diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index 92f8722f7e..6c8297f0ef 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -1574,6 +1574,7 @@ export class RushConstants { static readonly pnpmSyncFilename: '.pnpm-sync.json'; static readonly pnpmV3ShrinkwrapFilename: 'pnpm-lock.yaml'; static readonly pnpmVirtualStoreFolderName: '.pnpm'; + static readonly pnpmWorkspaceFileName: 'pnpm-workspace.yaml'; static readonly projectImpactGraphFilename: 'project-impact-graph.yaml'; static readonly projectRushFolderName: '.rush'; static readonly projectShrinkwrapFilename: 'shrinkwrap-deps.json'; diff --git a/libraries/rush-lib/src/cli/RushPnpmCommandLineParser.ts b/libraries/rush-lib/src/cli/RushPnpmCommandLineParser.ts index 9a83b272d9..39a0c81a02 100644 --- a/libraries/rush-lib/src/cli/RushPnpmCommandLineParser.ts +++ b/libraries/rush-lib/src/cli/RushPnpmCommandLineParser.ts @@ -166,7 +166,7 @@ export class RushPnpmCommandLineParser { this._subspace = subspace; const workspaceFolder: string = subspace.getSubspaceTempFolderPath(); - const workspaceFilePath: string = path.join(workspaceFolder, 'pnpm-workspace.yaml'); + const workspaceFilePath: string = `${workspaceFolder}/${RushConstants.pnpmWorkspaceFileName}`; if (!FileSystem.exists(workspaceFilePath)) { this._terminal.writeErrorLine('Error: The PNPM workspace file has not been generated:'); @@ -545,9 +545,10 @@ export class RushPnpmCommandLineParser { let newGlobalPatchedDependencies: Record | undefined; if (semver.gte(pnpmVersion, '11.0.0')) { // PNPM 11+ stores patchedDependencies in pnpm-workspace.yaml instead of the package.json "pnpm" field - newGlobalPatchedDependencies = await PnpmWorkspaceFile.loadPatchedDependenciesAsync( - `${subspaceTempFolder}/pnpm-workspace.yaml` + const workspaceFile: PnpmWorkspaceFile = await PnpmWorkspaceFile.loadAsync( + `${subspaceTempFolder}/${RushConstants.pnpmWorkspaceFileName}` ); + newGlobalPatchedDependencies = workspaceFile.patchedDependencies; } else { // PNPM 10.x and earlier store patchedDependencies in the package.json "pnpm" field // Example: "C:\MyRepo\common\temp\package.json" @@ -612,13 +613,10 @@ export class RushPnpmCommandLineParser { if (semver.gte(pnpmVersion, '11.0.0')) { // PNPM 11+ uses allowBuilds in pnpm-workspace.yaml instead of onlyBuiltDependencies in package.json - const workspaceYamlFilename: string = `${subspaceTempFolder}/pnpm-workspace.yaml`; - const yamlModule: typeof import('js-yaml') = await import('js-yaml'); - const workspaceYamlContent: string = await FileSystem.readFileAsync(workspaceYamlFilename); - const workspaceYaml: { allowBuilds?: Record } = (yamlModule.load( - workspaceYamlContent - ) ?? {}) as { allowBuilds?: Record }; - const newGlobalAllowBuilds: Record | undefined = workspaceYaml?.allowBuilds; + const workspaceFile: PnpmWorkspaceFile = await PnpmWorkspaceFile.loadAsync( + `${subspaceTempFolder}/${RushConstants.pnpmWorkspaceFileName}` + ); + const newGlobalAllowBuilds: Record | undefined = workspaceFile.allowBuilds; const currentGlobalAllowBuilds: Record | undefined = pnpmOptions?.globalAllowBuilds; diff --git a/libraries/rush-lib/src/logic/RushConstants.ts b/libraries/rush-lib/src/logic/RushConstants.ts index 0311f84572..b853450fd1 100644 --- a/libraries/rush-lib/src/logic/RushConstants.ts +++ b/libraries/rush-lib/src/logic/RushConstants.ts @@ -369,4 +369,9 @@ export class RushConstants { * The filename ("pnpm-sync.json") used to store the state of the pnpm sync command. */ public static readonly pnpmSyncFilename: '.pnpm-sync.json' = '.pnpm-sync.json'; + + /** + * The filename ("pnpm-workspace.yaml") used to store the state of the pnpm workspace configuration. + */ + public static readonly pnpmWorkspaceFileName: 'pnpm-workspace.yaml' = 'pnpm-workspace.yaml'; } diff --git a/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts b/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts index 30fcdd73f6..4c74ba1a5d 100644 --- a/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts +++ b/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts @@ -19,11 +19,12 @@ import type { RushGlobalFolder } from '../../api/RushGlobalFolder'; import { Utilities } from '../../utilities/Utilities'; import type { IConfigurationEnvironment } from '../base/BasePackageManagerOptionsConfiguration'; import type { PnpmOptionsConfiguration } from '../pnpm/PnpmOptionsConfiguration'; +import { PnpmWorkspaceFile } from '../pnpm/PnpmWorkspaceFile'; import { merge } from '../../utilities/objectUtilities'; import type { Subspace } from '../../api/Subspace'; import { RushConstants } from '../RushConstants'; -interface ICommonPackageJsonPnpmSection { +export interface ICommonPackageJsonPnpmSection { overrides?: typeof PnpmOptionsConfiguration.prototype.globalOverrides; packageExtensions?: typeof PnpmOptionsConfiguration.prototype.globalPackageExtensions; peerDependencyRules?: typeof PnpmOptionsConfiguration.prototype.globalPeerDependencyRules; @@ -41,170 +42,326 @@ interface ICommonPackageJson extends IPackageJson { pnpm?: ICommonPackageJsonPnpmSection; } +/** + * The pnpm-specific settings that Rush writes into the generated `common/temp` install files, + * derived from a subspace's pnpm options by {@link InstallHelpers.resolvePnpmSettings}. + */ +export interface IResolvedPnpmSettings { + /** + * The "pnpm" field to write into `common/temp/package.json`. For pnpm 11+, settings that pnpm + * no longer reads from package.json are omitted here and appear in {@link workspaceFile}. + */ + packageJsonPnpmSection: ICommonPackageJsonPnpmSection; + + /** + * Additional top-level properties to merge into `common/temp/package.json` + * (from `pnpmOptions.unsupportedPackageJsonSettings`). + */ + additionalPackageJsonProperties: unknown; + + /** + * The `common/temp/pnpm-workspace.yaml` file, populated with the version-gated pnpm settings. + * The caller is responsible for adding the workspace packages and saving the file. Only used by + * workspace installs. + */ + workspaceFile: PnpmWorkspaceFile; + + /** + * The configured pnpm `globalOverrides` (defaulting to `{}`), regardless of the pnpm version. + * Used to verify that the shrinkwrap file is up to date. + */ + configuredOverrides: Record; + + /** + * The configured pnpm `globalPackageExtensions`, regardless of the pnpm version. Used to verify + * that the shrinkwrap file is up to date. + */ + configuredPackageExtensions: typeof PnpmOptionsConfiguration.prototype.globalPackageExtensions; +} + export class InstallHelpers { public static async generateCommonPackageJsonAsync( - rushConfiguration: RushConfiguration, subspace: Subspace, dependenciesMap: Map = new Map(), - terminal: ITerminal + resolvedPnpmSettings: IResolvedPnpmSettings | undefined ): Promise { - let pnpmSection: ICommonPackageJsonPnpmSection | undefined; - let additionalCommonPackageJsonPropertiesToMerge: unknown | undefined; - if (rushConfiguration.isPnpm) { - const { - globalOverrides: overrides, - globalPackageExtensions: packageExtensions, - globalPeerDependencyRules: peerDependencyRules, - globalNeverBuiltDependencies, - globalOnlyBuiltDependencies, - globalIgnoredOptionalDependencies: ignoredOptionalDependencies, - globalAllowedDeprecatedVersions: allowedDeprecatedVersions, - globalPatchedDependencies: patchedDependencies, - trustPolicy, - trustPolicyExclude, - // NOTE: the pnpm setting is `trustPolicyIgnoreAfter`, but the rush pnpm setting is `trustPolicyIgnoreAfterMinutes` - trustPolicyIgnoreAfterMinutes: trustPolicyIgnoreAfter, - unsupportedPackageJsonSettings - } = subspace.getPnpmOptions() || rushConfiguration.pnpmOptions; - - const pnpmVersion: string = rushConfiguration.packageManagerToolVersion; - const isPnpm11: boolean = semver.gte(pnpmVersion, '11.0.0'); - - let neverBuiltDependencies: ICommonPackageJsonPnpmSection['neverBuiltDependencies']; - if (globalNeverBuiltDependencies) { - if (isPnpm11) { - terminal.writeWarningLine( - Colorize.yellow( - `Your version of PNPM (${pnpmVersion}) ` + - `no longer supports the "globalNeverBuiltDependencies" field in ` + - `${rushConfiguration.commonRushConfigFolder}/${RushConstants.pnpmConfigFilename}. ` + - 'Use "globalAllowBuilds" instead (with a value of false to deny build scripts).' - ) - ); - } else { - neverBuiltDependencies = globalNeverBuiltDependencies; - } - } + const { packageJsonPnpmSection, additionalPackageJsonProperties } = resolvedPnpmSettings ?? {}; - let onlyBuiltDependencies: ICommonPackageJsonPnpmSection['onlyBuiltDependencies']; - if (globalOnlyBuiltDependencies) { - if (isPnpm11) { - terminal.writeWarningLine( - Colorize.yellow( - `Your version of PNPM (${pnpmVersion}) ` + - `no longer supports the "globalOnlyBuiltDependencies" field in ` + - `${rushConfiguration.commonRushConfigFolder}/${RushConstants.pnpmConfigFilename}. ` + - 'Use "globalAllowBuilds" instead (with a value of true to allow build scripts).' - ) - ); - } else { - if (semver.lt(pnpmVersion, '10.1.0')) { - terminal.writeWarningLine( - Colorize.yellow( - `Your version of PNPM (${pnpmVersion}) ` + - `doesn't support the "globalOnlyBuiltDependencies" field in ` + - `${rushConfiguration.commonRushConfigFolder}/${RushConstants.pnpmConfigFilename}. ` + - 'Remove this field or upgrade to PNPM 10.1.0 or newer.' - ) - ); - } + // Add any preferred versions to the top of the commonPackageJson + // do this in alphabetical order for simpler debugging + const sortedDependencyEntries: [string, string][] = Array.from(dependenciesMap.entries()).sort( + ([a], [b]) => (a < b ? -1 : a > b ? 1 : 0) + ); + const dependencies: Record = Object.fromEntries(sortedDependencyEntries); + const commonPackageJson: ICommonPackageJson = { + dependencies, + description: 'Temporary file generated by the Rush tool', + name: 'rush-common', + private: true, + version: '0.0.0', + pnpm: packageJsonPnpmSection + }; - onlyBuiltDependencies = globalOnlyBuiltDependencies; - } - } + if (additionalPackageJsonProperties) { + merge(commonPackageJson, additionalPackageJsonProperties); + } - if (ignoredOptionalDependencies && semver.lt(pnpmVersion, '9.0.0')) { - terminal.writeWarningLine( - Colorize.yellow( - `Your version of PNPM (${pnpmVersion}) ` + - `doesn't support the "globalIgnoredOptionalDependencies" field in ` + - `${rushConfiguration.commonRushConfigFolder}/${RushConstants.pnpmConfigFilename}. ` + - 'Remove this field or upgrade to PNPM 9.' - ) - ); - } + // Example: "C:\MyRepo\common\temp\package.json" + const commonPackageJsonFilename: string = `${subspace.getSubspaceTempFolderPath()}/${FileConstants.PackageJson}`; + + // Don't update the file timestamp unless the content has changed, since "rush install" + // will consider this timestamp + await JsonFile.saveAsync(commonPackageJson, commonPackageJsonFilename, { + onlyIfChanged: true, + ignoreUndefinedValues: true + }); + } - if (trustPolicy !== undefined && semver.lt(pnpmVersion, '10.21.0')) { + /** + * Interprets the pnpm options for the given subspace and derives the pnpm-specific settings that + * Rush writes into the generated install files: the "pnpm" field of `common/temp/package.json` + * (see {@link IResolvedPnpmSettings.packageJsonPnpmSection}) and the settings for + * `common/temp/pnpm-workspace.yaml` (see {@link IResolvedPnpmSettings.workspaceSettings}). + * + * This is the single place that reads through the pnpm options and emits the associated + * version-compatibility warnings, so that both "rush install" code paths stay consistent. + * + * Returns `undefined` when the package manager is not pnpm. + */ + public static resolvePnpmSettings( + rushConfiguration: RushConfiguration, + subspace: Subspace, + terminal: ITerminal + ): IResolvedPnpmSettings | undefined { + if (!rushConfiguration.isPnpm) { + return undefined; + } + + const { + globalOverrides = {}, + globalPackageExtensions, + globalPeerDependencyRules, + globalNeverBuiltDependencies, + globalOnlyBuiltDependencies, + globalIgnoredOptionalDependencies, + globalAllowedDeprecatedVersions, + globalPatchedDependencies, + globalCatalogs, + globalAllowBuilds, + minimumReleaseAgeMinutes, + minimumReleaseAgeExclude, + trustPolicy, + trustPolicyExclude, + // NOTE: the pnpm setting is `trustPolicyIgnoreAfter`, but the rush pnpm setting is `trustPolicyIgnoreAfterMinutes` + trustPolicyIgnoreAfterMinutes: trustPolicyIgnoreAfter, + unsupportedPackageJsonSettings + } = subspace.getPnpmOptions() || rushConfiguration.pnpmOptions; + + const pnpmVersion: string = rushConfiguration.packageManagerToolVersion; + const isPnpm11: boolean = semver.gte(pnpmVersion, '11.0.0'); + // Example: "C:\MyRepo\common\config\rush\pnpm-config.json" + const pnpmConfigLocation: string = `${rushConfiguration.commonRushConfigFolder}/${RushConstants.pnpmConfigFilename}`; + + // + // Derive the "pnpm" field of common/temp/package.json + // + let neverBuiltDependencies: ICommonPackageJsonPnpmSection['neverBuiltDependencies']; + if (globalNeverBuiltDependencies) { + if (isPnpm11) { terminal.writeWarningLine( Colorize.yellow( `Your version of PNPM (${pnpmVersion}) ` + - `doesn't support the "trustPolicy" field in ` + - `${rushConfiguration.commonRushConfigFolder}/${RushConstants.pnpmConfigFilename}. ` + - 'Remove this field or upgrade to PNPM 10.21.0 or newer.' + `no longer supports the "globalNeverBuiltDependencies" field in ` + + `${pnpmConfigLocation}. ` + + 'Use "globalAllowBuilds" instead (with a value of false to deny build scripts).' ) ); + } else { + neverBuiltDependencies = globalNeverBuiltDependencies; } + } - if (trustPolicyExclude && semver.lt(pnpmVersion, '10.22.0')) { + let onlyBuiltDependencies: ICommonPackageJsonPnpmSection['onlyBuiltDependencies']; + if (globalOnlyBuiltDependencies) { + if (isPnpm11) { terminal.writeWarningLine( Colorize.yellow( `Your version of PNPM (${pnpmVersion}) ` + - `doesn't support the "trustPolicyExclude" field in ` + - `${rushConfiguration.commonRushConfigFolder}/${RushConstants.pnpmConfigFilename}. ` + - 'Remove this field or upgrade to PNPM 10.22.0 or newer.' + `no longer supports the "globalOnlyBuiltDependencies" field in ` + + `${pnpmConfigLocation}. ` + + 'Use "globalAllowBuilds" instead (with a value of true to allow build scripts).' ) ); + } else { + if (semver.lt(pnpmVersion, '10.1.0')) { + terminal.writeWarningLine( + Colorize.yellow( + `Your version of PNPM (${pnpmVersion}) ` + + `doesn't support the "globalOnlyBuiltDependencies" field in ` + + `${pnpmConfigLocation}. ` + + 'Remove this field or upgrade to PNPM 10.1.0 or newer.' + ) + ); + } + + onlyBuiltDependencies = globalOnlyBuiltDependencies; } + } - if (trustPolicyIgnoreAfter !== undefined && semver.lt(pnpmVersion, '10.27.0')) { - terminal.writeWarningLine( - Colorize.yellow( - `Your version of PNPM (${pnpmVersion}) ` + - `doesn't support the "trustPolicyIgnoreAfterMinutes" field in ` + - `${rushConfiguration.commonRushConfigFolder}/${RushConstants.pnpmConfigFilename}. ` + - 'Remove this field or upgrade to PNPM 10.27.0 or newer.' - ) - ); + if (globalIgnoredOptionalDependencies && semver.lt(pnpmVersion, '9.0.0')) { + terminal.writeWarningLine( + Colorize.yellow( + `Your version of PNPM (${pnpmVersion}) ` + + `doesn't support the "globalIgnoredOptionalDependencies" field in ` + + `${pnpmConfigLocation}. ` + + 'Remove this field or upgrade to PNPM 9.' + ) + ); + } + + if (trustPolicy !== undefined && semver.lt(pnpmVersion, '10.21.0')) { + terminal.writeWarningLine( + Colorize.yellow( + `Your version of PNPM (${pnpmVersion}) ` + + `doesn't support the "trustPolicy" field in ` + + `${pnpmConfigLocation}. ` + + 'Remove this field or upgrade to PNPM 10.21.0 or newer.' + ) + ); + } + + if (trustPolicyExclude && semver.lt(pnpmVersion, '10.22.0')) { + terminal.writeWarningLine( + Colorize.yellow( + `Your version of PNPM (${pnpmVersion}) ` + + `doesn't support the "trustPolicyExclude" field in ` + + `${pnpmConfigLocation}. ` + + 'Remove this field or upgrade to PNPM 10.22.0 or newer.' + ) + ); + } + + if (trustPolicyIgnoreAfter !== undefined && semver.lt(pnpmVersion, '10.27.0')) { + terminal.writeWarningLine( + Colorize.yellow( + `Your version of PNPM (${pnpmVersion}) ` + + `doesn't support the "trustPolicyIgnoreAfterMinutes" field in ` + + `${pnpmConfigLocation}. ` + + 'Remove this field or upgrade to PNPM 10.27.0 or newer.' + ) + ); + } + + // + // Derive the settings for common/temp/pnpm-workspace.yaml + // + if (globalCatalogs && semver.lt(pnpmVersion, '9.5.0')) { + terminal.writeWarningLine( + Colorize.yellow( + `Your version of pnpm (${pnpmVersion}) ` + + `doesn't support the "globalCatalogs" fields in ` + + `${pnpmConfigLocation}. ` + + 'Remove these fields or upgrade to pnpm 9.5.0 or newer.' + ) + ); + } + + // For pnpm 11+, "allowBuilds" replaces onlyBuiltDependencies/neverBuiltDependencies + let allowBuilds: Record | undefined; + if (isPnpm11) { + if (globalAllowBuilds) { + allowBuilds = globalAllowBuilds; + } else if (globalOnlyBuiltDependencies || globalNeverBuiltDependencies) { + // Backward compatibility: convert globalOnlyBuiltDependencies/globalNeverBuiltDependencies + // to allowBuilds format for pnpm 11+ + allowBuilds = {}; + if (globalOnlyBuiltDependencies) { + for (const pkg of globalOnlyBuiltDependencies) { + allowBuilds[pkg] = true; + } + } + + if (globalNeverBuiltDependencies) { + for (const pkg of globalNeverBuiltDependencies) { + allowBuilds[pkg] = false; + } + } } + } else if (globalAllowBuilds) { + terminal.writeWarningLine( + Colorize.yellow( + `Your version of pnpm (${pnpmVersion}) ` + + `doesn't support the "globalAllowBuilds" field in ` + + `${pnpmConfigLocation}. ` + + 'Remove this field or upgrade to pnpm 11.0.0 or newer.' + ) + ); + } - additionalCommonPackageJsonPropertiesToMerge = unsupportedPackageJsonSettings; - - // pnpm 11 no longer reads the "pnpm" field of package.json. For pnpm 11+, these settings - // are written to common/temp/pnpm-workspace.yaml by WorkspaceInstallManager instead. - // See https://github.com/microsoft/rushstack/issues/5837 - pnpmSection = { - overrides: isPnpm11 ? undefined : overrides, - packageExtensions: isPnpm11 ? undefined : packageExtensions, - peerDependencyRules: isPnpm11 ? undefined : peerDependencyRules, - neverBuiltDependencies, - onlyBuiltDependencies, - ignoredOptionalDependencies, - allowedDeprecatedVersions: isPnpm11 ? undefined : allowedDeprecatedVersions, - patchedDependencies: isPnpm11 ? undefined : patchedDependencies, - trustPolicy, - trustPolicyExclude, - trustPolicyIgnoreAfter - }; + // pnpm does not read these fields from package.json, only from pnpm-workspace.yaml or .npmrc. + if ( + (minimumReleaseAgeMinutes !== undefined || minimumReleaseAgeExclude) && + semver.lt(pnpmVersion, '10.16.0') + ) { + terminal.writeWarningLine( + Colorize.yellow( + `Your version of pnpm (${pnpmVersion}) ` + + `doesn't support the "minimumReleaseAgeMinutes" or "minimumReleaseAgeExclude" fields in ` + + `${pnpmConfigLocation}. ` + + 'Remove these fields or upgrade to pnpm 10.16.0 or newer.' + ) + ); } - // Add any preferred versions to the top of the commonPackageJson - // do this in alphabetical order for simpler debugging - const sortedDependencyEntries: [string, string][] = Array.from(dependenciesMap.entries()).sort( - ([a], [b]) => (a < b ? -1 : a > b ? 1 : 0) + // Populate a PnpmWorkspaceFile with the workspace settings. The caller adds the workspace + // packages to this file and saves it; here we only set the pnpm settings. + const workspaceFile: PnpmWorkspaceFile = new PnpmWorkspaceFile( + `${subspace.getSubspaceTempFolderPath()}/${RushConstants.pnpmWorkspaceFileName}` ); - const dependencies: Record = Object.fromEntries(sortedDependencyEntries); - const commonPackageJson: ICommonPackageJson = { - dependencies, - description: 'Temporary file generated by the Rush tool', - name: 'rush-common', - private: true, - version: '0.0.0', - pnpm: pnpmSection + workspaceFile.catalogs = globalCatalogs; + workspaceFile.allowBuilds = allowBuilds; + // NOTE: the pnpm setting is `minimumReleaseAge`, but the Rush setting is `minimumReleaseAgeMinutes` + workspaceFile.minimumReleaseAge = minimumReleaseAgeMinutes; + workspaceFile.minimumReleaseAgeExclude = minimumReleaseAgeExclude; + + // pnpm 11 no longer reads the "pnpm" field of package.json. For pnpm 11+, the overrides, + // packageExtensions, peerDependencyRules, allowedDeprecatedVersions, and patchedDependencies + // settings are written to common/temp/pnpm-workspace.yaml (see workspaceSettings below) instead. + // See https://github.com/microsoft/rushstack/issues/5837 + const packageJsonPnpmSection: ICommonPackageJsonPnpmSection = { + neverBuiltDependencies, + onlyBuiltDependencies, + ignoredOptionalDependencies: globalIgnoredOptionalDependencies, + trustPolicy, + trustPolicyExclude, + trustPolicyIgnoreAfter }; - if (additionalCommonPackageJsonPropertiesToMerge) { - merge(commonPackageJson, additionalCommonPackageJsonPropertiesToMerge); + if (isPnpm11) { + // These are written to pnpm-workspace.yaml only for pnpm 11+ (see note above); for older pnpm + // they live in packageJsonPnpmSection instead and are left undefined here. + workspaceFile.overrides = globalOverrides; + workspaceFile.packageExtensions = globalPackageExtensions; + workspaceFile.peerDependencyRules = globalPeerDependencyRules; + workspaceFile.allowedDeprecatedVersions = globalAllowedDeprecatedVersions; + workspaceFile.patchedDependencies = globalPatchedDependencies; + } else { + packageJsonPnpmSection.overrides = globalOverrides; + packageJsonPnpmSection.packageExtensions = globalPackageExtensions; + packageJsonPnpmSection.peerDependencyRules = globalPeerDependencyRules; + packageJsonPnpmSection.allowedDeprecatedVersions = globalAllowedDeprecatedVersions; + packageJsonPnpmSection.patchedDependencies = globalPatchedDependencies; } - // Example: "C:\MyRepo\common\temp\package.json" - const commonPackageJsonFilename: string = `${subspace.getSubspaceTempFolderPath()}/${FileConstants.PackageJson}`; - - // Don't update the file timestamp unless the content has changed, since "rush install" - // will consider this timestamp - await JsonFile.saveAsync(commonPackageJson, commonPackageJsonFilename, { - onlyIfChanged: true, - ignoreUndefinedValues: true - }); + return { + packageJsonPnpmSection, + additionalPackageJsonProperties: unsupportedPackageJsonSettings, + workspaceFile, + // The configured overrides/packageExtensions are needed to verify the shrinkwrap is up to + // date, regardless of the pnpm version. + configuredOverrides: globalOverrides, + configuredPackageExtensions: globalPackageExtensions + }; } public static getPackageManagerEnvironment( diff --git a/libraries/rush-lib/src/logic/installManager/RushInstallManager.ts b/libraries/rush-lib/src/logic/installManager/RushInstallManager.ts index c8fa3a23f2..4e1c294ba8 100644 --- a/libraries/rush-lib/src/logic/installManager/RushInstallManager.ts +++ b/libraries/rush-lib/src/logic/installManager/RushInstallManager.ts @@ -32,7 +32,7 @@ import { type PackageJsonDependency } from '../../api/PackageJsonEditor'; import { DependencySpecifier, DependencySpecifierType } from '../DependencySpecifier'; -import { InstallHelpers } from './InstallHelpers'; +import { InstallHelpers, type IResolvedPnpmSettings } from './InstallHelpers'; import { TempProjectHelper } from '../TempProjectHelper'; import type { RushGlobalFolder } from '../../api/RushGlobalFolder'; import type { RushConfiguration } from '../..'; @@ -367,10 +367,7 @@ export class RushInstallManager extends BaseInstallManager { // Remove the workspace file if it exists if (this.rushConfiguration.isPnpm) { - const workspaceFilePath: string = path.join( - this.rushConfiguration.commonTempFolder, - 'pnpm-workspace.yaml' - ); + const workspaceFilePath: string = `${this.rushConfiguration.commonTempFolder}/pnpm-workspace.yaml`; try { await FileSystem.deleteFileAsync(workspaceFilePath); } catch (e) { @@ -380,13 +377,17 @@ export class RushInstallManager extends BaseInstallManager { } } - // Write the common package.json - await InstallHelpers.generateCommonPackageJsonAsync( + // Read the pnpm options (if any) in a single place, then write the common package.json. + const pnpmSettings: IResolvedPnpmSettings | undefined = InstallHelpers.resolvePnpmSettings( this.rushConfiguration, this.rushConfiguration.defaultSubspace, - commonDependencies, this._terminal ); + await InstallHelpers.generateCommonPackageJsonAsync( + this.rushConfiguration.defaultSubspace, + commonDependencies, + pnpmSettings + ); stopwatch.stop(); // eslint-disable-next-line no-console diff --git a/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts b/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts index da941a9ffd..b531c71cdb 100644 --- a/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts +++ b/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts @@ -12,6 +12,7 @@ import { AlreadyReportedError, Async, type IDependenciesMetaTable, + InternalError, Objects, Path, Sort @@ -27,11 +28,10 @@ import { DependencyType, type PackageJsonDependencyMeta } from '../../api/PackageJsonEditor'; -import { PnpmWorkspaceFile } from '../pnpm/PnpmWorkspaceFile'; import type { RushConfigurationProject } from '../../api/RushConfigurationProject'; import { RushConstants } from '../RushConstants'; import { Utilities } from '../../utilities/Utilities'; -import { InstallHelpers } from './InstallHelpers'; +import { InstallHelpers, type IResolvedPnpmSettings } from './InstallHelpers'; import type { CommonVersionsConfiguration } from '../../api/CommonVersionsConfiguration'; import type { RepoStateFile } from '../RepoStateFile'; import { EnvironmentConfiguration } from '../../api/EnvironmentConfiguration'; @@ -43,7 +43,6 @@ import type { Subspace } from '../../api/Subspace'; import { BaseLinkManager, SymlinkKind } from '../base/BaseLinkManager'; import { FlagFile } from '../../api/FlagFile'; import { Stopwatch } from '../../utilities/Stopwatch'; -import type { PnpmOptionsConfiguration } from '../pnpm/PnpmOptionsConfiguration'; export interface IPnpmModules { hoistedDependencies: { [dep in string]: { [depPath in string]: string } }; @@ -181,10 +180,21 @@ export class WorkspaceInstallManager extends BaseInstallManager { } } - // To generate the workspace file, we will add each project to the file as we loop through and validate - const workspaceFile: PnpmWorkspaceFile = new PnpmWorkspaceFile( - path.join(subspace.getSubspaceTempFolderPath(), 'pnpm-workspace.yaml') + // Read the pnpm options in a single place, deriving the "pnpm" field for the common package.json + // and a pnpm-workspace.yaml file pre-populated with its settings (and emitting any related + // warnings). WorkspaceInstallManager is only used for pnpm, so this always returns a value. + const pnpmSettings: IResolvedPnpmSettings | undefined = InstallHelpers.resolvePnpmSettings( + this.rushConfiguration, + subspace, + this._terminal ); + if (!pnpmSettings) { + throw new InternalError('Expected pnpm settings to be resolved for a workspace install'); + } + + // To generate the workspace file, we will add each project to the file as we loop through and + // validate. The file already carries the pnpm settings; here we add the workspace packages. + const { workspaceFile, configuredOverrides, configuredPackageExtensions } = pnpmSettings; // For pnpm package manager, we need to handle dependenciesMeta changes in package.json. See more: https://pnpm.io/package_json#dependenciesmeta // If dependenciesMeta settings is different between package.json and pnpm-lock.yaml, then shrinkwrapIsUpToDate return false. @@ -380,20 +390,9 @@ export class WorkspaceInstallManager extends BaseInstallManager { shrinkwrapIsUpToDate = false; } - // Check if overrides and globalOverrides are the same - const { - globalOverrides = {}, - globalPackageExtensions, - globalCatalogs: catalogs, - globalAllowBuilds, - globalOnlyBuiltDependencies, - globalNeverBuiltDependencies, - minimumReleaseAgeMinutes, - minimumReleaseAgeExclude - }: PnpmOptionsConfiguration = subspace.getPnpmOptions() || this.rushConfiguration.pnpmOptions; - + // Check if the configured overrides match the shrinkwrap const overridesAreEqual: boolean = Objects.areDeepEqual>( - globalOverrides, + configuredOverrides, shrinkwrapFile?.overrides ? Object.fromEntries(shrinkwrapFile?.overrides) : {} ); @@ -402,7 +401,7 @@ export class WorkspaceInstallManager extends BaseInstallManager { shrinkwrapIsUpToDate = false; } - // Check if packageExtensionsChecksum matches globalPackageExtension's hash + // Check if packageExtensionsChecksum matches the configured packageExtensions' hash let packageExtensionsChecksum: string | undefined; let existingPackageExtensionsChecksum: string | undefined; if (shrinkwrapFile) { @@ -424,12 +423,12 @@ export class WorkspaceInstallManager extends BaseInstallManager { } // https://github.com/pnpm/pnpm/blob/ba9409ffcef0c36dc1b167d770a023c87444822d/pkg-manager/core/src/install/index.ts#L331 - if (globalPackageExtensions && Object.keys(globalPackageExtensions).length !== 0) { + if (configuredPackageExtensions && Object.keys(configuredPackageExtensions).length !== 0) { if (packageExtensionsChecksumAlgorithm) { // In PNPM v10, the algorithm changed to SHA256 and the digest changed from hex to base64 - packageExtensionsChecksum = await createObjectChecksumAsync(globalPackageExtensions); + packageExtensionsChecksum = await createObjectChecksumAsync(configuredPackageExtensions); } else { - packageExtensionsChecksum = createObjectChecksumLegacy(globalPackageExtensions); + packageExtensionsChecksum = createObjectChecksumLegacy(configuredPackageExtensions); } } } @@ -442,109 +441,12 @@ export class WorkspaceInstallManager extends BaseInstallManager { shrinkwrapIsUpToDate = false; } - // Write the common package.json - await InstallHelpers.generateCommonPackageJsonAsync( - this.rushConfiguration, - subspace, - undefined, - this._terminal - ); - - // Set catalog definitions in the workspace file if specified - if (catalogs) { - if ( - this.rushConfiguration.rushConfigurationJson.pnpmVersion !== undefined && - semver.lt(this.rushConfiguration.rushConfigurationJson.pnpmVersion, '9.5.0') - ) { - this._terminal.writeWarningLine( - Colorize.yellow( - `Your version of pnpm (${this.rushConfiguration.rushConfigurationJson.pnpmVersion}) ` + - `doesn't support the "globalCatalogs" fields in ` + - `${this.rushConfiguration.commonRushConfigFolder}/${RushConstants.pnpmConfigFilename}. ` + - 'Remove these fields or upgrade to pnpm 9.5.0 or newer.' - ) - ); - } - - workspaceFile.catalogs = catalogs; - } - - // Set allowBuilds in the workspace file for pnpm 11+ (replaces onlyBuiltDependencies/neverBuiltDependencies) - if ( - this.rushConfiguration.rushConfigurationJson.pnpmVersion !== undefined && - semver.gte(this.rushConfiguration.rushConfigurationJson.pnpmVersion, '11.0.0') - ) { - if (globalAllowBuilds) { - workspaceFile.allowBuilds = globalAllowBuilds; - } else if (globalOnlyBuiltDependencies || globalNeverBuiltDependencies) { - // Backward compatibility: convert globalOnlyBuiltDependencies/globalNeverBuiltDependencies - // to allowBuilds format for pnpm 11+ - const allowBuilds: Record = {}; - if (globalOnlyBuiltDependencies) { - for (const pkg of globalOnlyBuiltDependencies) { - allowBuilds[pkg] = true; - } - } + // Write the common package.json using the "pnpm" field derived above. + await InstallHelpers.generateCommonPackageJsonAsync(subspace, undefined, pnpmSettings); - if (globalNeverBuiltDependencies) { - for (const pkg of globalNeverBuiltDependencies) { - allowBuilds[pkg] = false; - } - } - - workspaceFile.allowBuilds = allowBuilds; - } - } else if (globalAllowBuilds && this.rushConfiguration.rushConfigurationJson.pnpmVersion !== undefined) { - this._terminal.writeWarningLine( - Colorize.yellow( - `Your version of pnpm (${this.rushConfiguration.rushConfigurationJson.pnpmVersion}) ` + - `doesn't support the "globalAllowBuilds" field in ` + - `${this.rushConfiguration.commonRushConfigFolder}/${RushConstants.pnpmConfigFilename}. ` + - 'Remove this field or upgrade to pnpm 11.0.0 or newer.' - ) - ); - } - - // For pnpm 11+, the following settings must be written to pnpm-workspace.yaml because pnpm 11 - // no longer reads the "pnpm" field of package.json (where Rush writes them for older pnpm). - // See https://github.com/microsoft/rushstack/issues/5837 - if ( - this.rushConfiguration.rushConfigurationJson.pnpmVersion !== undefined && - semver.gte(this.rushConfiguration.rushConfigurationJson.pnpmVersion, '11.0.0') - ) { - const pnpmOptions: PnpmOptionsConfiguration = - subspace.getPnpmOptions() || this.rushConfiguration.pnpmOptions; - workspaceFile.overrides = pnpmOptions.globalOverrides; - workspaceFile.packageExtensions = pnpmOptions.globalPackageExtensions; - workspaceFile.peerDependencyRules = pnpmOptions.globalPeerDependencyRules; - workspaceFile.allowedDeprecatedVersions = pnpmOptions.globalAllowedDeprecatedVersions; - workspaceFile.patchedDependencies = pnpmOptions.globalPatchedDependencies; - } - - // Set minimumReleaseAge/minimumReleaseAgeExclude in the workspace file. - // pnpm does not read these fields from package.json, only from pnpm-workspace.yaml or .npmrc. - if (minimumReleaseAgeMinutes !== undefined || minimumReleaseAgeExclude) { - if ( - this.rushConfiguration.rushConfigurationJson.pnpmVersion !== undefined && - semver.lt(this.rushConfiguration.rushConfigurationJson.pnpmVersion, '10.16.0') - ) { - this._terminal.writeWarningLine( - Colorize.yellow( - `Your version of pnpm (${this.rushConfiguration.rushConfigurationJson.pnpmVersion}) ` + - `doesn't support the "minimumReleaseAgeMinutes" or "minimumReleaseAgeExclude" fields in ` + - `${this.rushConfiguration.commonRushConfigFolder}/${RushConstants.pnpmConfigFilename}. ` + - 'Remove these fields or upgrade to pnpm 10.16.0 or newer.' - ) - ); - } - - // NOTE: the pnpm setting is `minimumReleaseAge`, but the Rush setting is `minimumReleaseAgeMinutes` - workspaceFile.minimumReleaseAge = minimumReleaseAgeMinutes; - workspaceFile.minimumReleaseAgeExclude = minimumReleaseAgeExclude; - } - - // Save the generated workspace file. Don't update the file timestamp unless the content has changed, - // since "rush install" will consider this timestamp + // The pnpm-workspace.yaml settings were already populated by resolvePnpmSettings, and the + // workspace packages were added in the loop above. Save the generated workspace file. Don't + // update the file timestamp unless the content has changed, since "rush install" considers it. await workspaceFile.saveAsync(workspaceFile.workspaceFilename, { onlyIfChanged: true }); return { shrinkwrapIsUpToDate, shrinkwrapWarnings }; @@ -563,10 +465,7 @@ export class WorkspaceInstallManager extends BaseInstallManager { if (this.rushConfiguration.isPnpm) { // Add workspace file. This file is only modified when workspace packages change. - const pnpmWorkspaceFilename: string = path.join( - subspace.getSubspaceTempFolderPath(), - 'pnpm-workspace.yaml' - ); + const pnpmWorkspaceFilename: string = `${subspace.getSubspaceTempFolderPath()}/${RushConstants.pnpmWorkspaceFileName}`; if (FileSystem.exists(pnpmWorkspaceFilename)) { potentiallyChangedFiles.push(pnpmWorkspaceFilename); @@ -576,14 +475,10 @@ export class WorkspaceInstallManager extends BaseInstallManager { // Also consider timestamps for all the project node_modules folders, as well as the package.json // files // Example: [ "C:\MyRepo\projects\projectA\node_modules", "C:\MyRepo\projects\projectA\package.json" ] - potentiallyChangedFiles.push( - ...subspace.getProjects().map((project) => { - return path.join(project.projectFolder, RushConstants.nodeModulesFolderName); - }), - ...subspace.getProjects().map((project) => { - return path.join(project.projectFolder, FileConstants.PackageJson); - }) - ); + for (const { projectFolder } of subspace.getProjects()) { + potentiallyChangedFiles.push(`${projectFolder}/${RushConstants.nodeModulesFolderName}`); + potentiallyChangedFiles.push(`${projectFolder}/${FileConstants.PackageJson}`); + } // NOTE: If any of the potentiallyChangedFiles does not exist, then isFileTimestampCurrent() // returns false. @@ -605,10 +500,7 @@ export class WorkspaceInstallManager extends BaseInstallManager { packageManagerEnv.FORCE_COLOR = '1'; } - const commonNodeModulesFolder: string = path.join( - subspace.getSubspaceTempFolderPath(), - RushConstants.nodeModulesFolderName - ); + const commonNodeModulesFolder: string = `${subspace.getSubspaceTempFolderPath()}/${RushConstants.nodeModulesFolderName}`; // Is there an existing "node_modules" folder to consider? if (FileSystem.exists(commonNodeModulesFolder)) { @@ -757,9 +649,9 @@ export class WorkspaceInstallManager extends BaseInstallManager { // Ensure that node_modules folders exist after install, since the timestamps on these folders are used // to determine if the install can be skipped const projectNodeModulesFolders: string[] = [ - path.join(subspace.getSubspaceTempFolderPath(), RushConstants.nodeModulesFolderName), + `${subspace.getSubspaceTempFolderPath()}/${RushConstants.nodeModulesFolderName}`, ...this.rushConfiguration.projects.map((project) => { - return path.join(project.projectFolder, RushConstants.nodeModulesFolderName); + return `${project.projectFolder}/${RushConstants.nodeModulesFolderName}`; }) ]; diff --git a/libraries/rush-lib/src/logic/pnpm/PnpmWorkspaceFile.ts b/libraries/rush-lib/src/logic/pnpm/PnpmWorkspaceFile.ts index 9713a37c54..0495b3388d 100644 --- a/libraries/rush-lib/src/logic/pnpm/PnpmWorkspaceFile.ts +++ b/libraries/rush-lib/src/logic/pnpm/PnpmWorkspaceFile.ts @@ -114,18 +114,37 @@ export class PnpmWorkspaceFile extends BaseWorkspaceFile { } /** - * Reads the `patchedDependencies` field from an existing `pnpm-workspace.yaml` file. + * Reads an existing `pnpm-workspace.yaml` file and returns a {@link PnpmWorkspaceFile} whose + * settings properties are populated from its contents. + * + * @remarks + * The workspace `packages` list is not loaded; the returned instance is intended for reading the + * generated pnpm settings (such as `allowBuilds` and `patchedDependencies`), not for + * re-serialization. + * * @param workspaceYamlFilename - The path to the `pnpm-workspace.yaml` file */ - public static async loadPatchedDependenciesAsync( - workspaceYamlFilename: string - ): Promise | undefined> { + public static async loadAsync(workspaceYamlFilename: string): Promise { const workspaceYamlContent: string = await FileSystem.readFileAsync(workspaceYamlFilename); const yamlModule: typeof import('js-yaml') = await import('js-yaml'); const workspaceYaml: IPnpmWorkspaceYaml | undefined = yamlModule.load(workspaceYamlContent) as | IPnpmWorkspaceYaml | undefined; - return workspaceYaml?.patchedDependencies; + + const workspaceFile: PnpmWorkspaceFile = new PnpmWorkspaceFile(workspaceYamlFilename); + if (workspaceYaml) { + workspaceFile.catalogs = workspaceYaml.catalogs; + workspaceFile.allowBuilds = workspaceYaml.allowBuilds; + workspaceFile.overrides = workspaceYaml.overrides; + workspaceFile.packageExtensions = workspaceYaml.packageExtensions; + workspaceFile.peerDependencyRules = workspaceYaml.peerDependencyRules; + workspaceFile.allowedDeprecatedVersions = workspaceYaml.allowedDeprecatedVersions; + workspaceFile.patchedDependencies = workspaceYaml.patchedDependencies; + workspaceFile.minimumReleaseAge = workspaceYaml.minimumReleaseAge; + workspaceFile.minimumReleaseAgeExclude = workspaceYaml.minimumReleaseAgeExclude; + } + + return workspaceFile; } public override addPackage(packagePath: string): void { diff --git a/libraries/rush-lib/src/logic/pnpm/test/PnpmWorkspaceFile.test.ts b/libraries/rush-lib/src/logic/pnpm/test/PnpmWorkspaceFile.test.ts index 0889b92a6f..32c586c287 100644 --- a/libraries/rush-lib/src/logic/pnpm/test/PnpmWorkspaceFile.test.ts +++ b/libraries/rush-lib/src/logic/pnpm/test/PnpmWorkspaceFile.test.ts @@ -350,7 +350,7 @@ describe(PnpmWorkspaceFile.name, () => { }); }); - describe(PnpmWorkspaceFile.loadPatchedDependenciesAsync.name, () => { + describe(PnpmWorkspaceFile.loadAsync.name, () => { let mockReadFileAsync: jest.SpyInstance; beforeEach(() => { @@ -375,7 +375,8 @@ describe(PnpmWorkspaceFile.name, () => { }; await workspaceFile.saveAsync(workspaceFilePath, { onlyIfChanged: true }); - await expect(PnpmWorkspaceFile.loadPatchedDependenciesAsync(workspaceFilePath)).resolves.toEqual({ + const loadedWorkspaceFile: PnpmWorkspaceFile = await PnpmWorkspaceFile.loadAsync(workspaceFilePath); + expect(loadedWorkspaceFile.patchedDependencies).toEqual({ 'lodash@4.17.21': 'patches/lodash@4.17.21.patch' }); }); @@ -385,9 +386,8 @@ describe(PnpmWorkspaceFile.name, () => { workspaceFile.addPackage(`${projectsDir}/app1`); await workspaceFile.saveAsync(workspaceFilePath, { onlyIfChanged: true }); - await expect( - PnpmWorkspaceFile.loadPatchedDependenciesAsync(workspaceFilePath) - ).resolves.toBeUndefined(); + const loadedWorkspaceFile: PnpmWorkspaceFile = await PnpmWorkspaceFile.loadAsync(workspaceFilePath); + expect(loadedWorkspaceFile.patchedDependencies).toBeUndefined(); }); }); diff --git a/libraries/rush-lib/src/logic/test/InstallHelpers.test.ts b/libraries/rush-lib/src/logic/test/InstallHelpers.test.ts index 97ba777f5b..b766db92f1 100644 --- a/libraries/rush-lib/src/logic/test/InstallHelpers.test.ts +++ b/libraries/rush-lib/src/logic/test/InstallHelpers.test.ts @@ -36,12 +36,16 @@ describe(InstallHelpers.name, () => { const RUSH_JSON_FILENAME: string = `${__dirname}/pnpmConfig/rush.json`; const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile(RUSH_JSON_FILENAME); - await InstallHelpers.generateCommonPackageJsonAsync( + const pnpmSettings = InstallHelpers.resolvePnpmSettings( rushConfiguration, rushConfiguration.defaultSubspace, - undefined, terminal ); + await InstallHelpers.generateCommonPackageJsonAsync( + rushConfiguration.defaultSubspace, + undefined, + pnpmSettings + ); const packageJson: IPackageJson = JSON.parse( JsonFile.stringify(mockJsonFileSaveAsync.mock.calls[0][0], { ignoreUndefinedValues: true }) ); @@ -87,12 +91,16 @@ describe(InstallHelpers.name, () => { const RUSH_JSON_FILENAME: string = `${__dirname}/pnpmConfigPnpm11/rush.json`; const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile(RUSH_JSON_FILENAME); - await InstallHelpers.generateCommonPackageJsonAsync( + const pnpmSettings = InstallHelpers.resolvePnpmSettings( rushConfiguration, rushConfiguration.defaultSubspace, - undefined, terminal ); + await InstallHelpers.generateCommonPackageJsonAsync( + rushConfiguration.defaultSubspace, + undefined, + pnpmSettings + ); const packageJson: IPackageJson = JSON.parse( JsonFile.stringify(mockJsonFileSaveAsync.mock.calls[0][0], { ignoreUndefinedValues: true }) );