From 877569581676a00d99e001bee80cebb2723bc986 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Dieuze?= Date: Tue, 1 Sep 2026 10:30:37 +0200 Subject: [PATCH 1/2] feat: support PNPM global virtual store via environment When RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE=1, workspace installs set enableGlobalVirtualStore in the generated pnpm-workspace.yaml so package instances live in the shared PNPM store instead of each worktree's node_modules/.pnpm. Follow-up to microsoft/rushstack#5830, rebased onto current main. Co-authored-by: chenzhelong.sirius --- README.md | 2 +- .../README.md | 15 +- .../package.json | 2 +- .../src/TestHelper.ts | 105 ++++++++- .../src/runTests.ts | 18 +- .../src/testPnpmGlobalVirtualStore.ts | 102 +++++++++ ...global-virtual-store_2026-06-11-15-20.json | 11 + common/reviews/api/rush-lib.api.md | 3 +- .../src/api/EnvironmentConfiguration.ts | 36 ++- libraries/rush-lib/src/api/LastInstallFlag.ts | 24 ++ .../api/test/EnvironmentConfiguration.test.ts | 32 +++ .../src/api/test/LastInstallFlag.test.ts | 35 +++ .../installManager/WorkspaceInstallManager.ts | 71 +++++- .../src/logic/pnpm/PnpmWorkspaceFile.ts | 17 +- .../logic/pnpm/test/PnpmWorkspaceFile.test.ts | 41 ++++ .../PnpmWorkspaceFile.test.ts.snap | 19 ++ .../src/logic/test/BaseInstallManager.test.ts | 151 ++++++++++++ .../test/WorkspaceInstallManager.test.ts | 214 ++++++++++++++++++ 18 files changed, 877 insertions(+), 21 deletions(-) create mode 100644 build-tests/rush-package-manager-integration-test/src/testPnpmGlobalVirtualStore.ts create mode 100644 common/changes/@microsoft/rush/support-pnpm-global-virtual-store_2026-06-11-15-20.json create mode 100644 libraries/rush-lib/src/logic/test/WorkspaceInstallManager.test.ts diff --git a/README.md b/README.md index 9cbcb7c3317..39776297ab9 100644 --- a/README.md +++ b/README.md @@ -228,7 +228,7 @@ These GitHub repositories provide supplementary resources for Rush Stack: | [/build-tests/rush-amazon-s3-build-cache-plugin-integration-test](./build-tests/rush-amazon-s3-build-cache-plugin-integration-test/) | Tests connecting to an amazon S3 endpoint | | [/build-tests/rush-lib-declaration-paths-test](./build-tests/rush-lib-declaration-paths-test/) | This project ensures all of the paths in rush-lib/lib/... have imports that resolve correctly. If this project builds, all `lib/**/*.d.ts` files in the `@microsoft/rush-lib` package are valid. | | [/build-tests/rush-mcp-example-plugin](./build-tests/rush-mcp-example-plugin/) | Example showing how to create a plugin for @rushstack/mcp-server | -| [/build-tests/rush-package-manager-integration-test](./build-tests/rush-package-manager-integration-test/) | Integration tests for non-pnpm package managers in Rush. | +| [/build-tests/rush-package-manager-integration-test](./build-tests/rush-package-manager-integration-test/) | Integration tests for package managers in Rush. | | [/build-tests/rush-project-change-analyzer-test](./build-tests/rush-project-change-analyzer-test/) | This is an example project that uses rush-lib's ProjectChangeAnalyzer to | | [/build-tests/rush-redis-cobuild-plugin-integration-test](./build-tests/rush-redis-cobuild-plugin-integration-test/) | Tests connecting to an redis server | | [/build-tests/rushd-wire-e2e-test](./build-tests/rushd-wire-e2e-test/) | End-to-end conformance tests for the rushd wire layer (protocol + transport + renderer against the rush-lib engine) | diff --git a/build-tests/rush-package-manager-integration-test/README.md b/build-tests/rush-package-manager-integration-test/README.md index bb5e2c7b53a..b27a32a309a 100644 --- a/build-tests/rush-package-manager-integration-test/README.md +++ b/build-tests/rush-package-manager-integration-test/README.md @@ -1,6 +1,6 @@ # Rush Package Manager Integration Tests -This directory contains integration tests for verifying Rush works correctly with different package managers after the tar 7.x upgrade. +This directory contains integration tests for verifying Rush works correctly with different package managers. ## Background @@ -10,6 +10,8 @@ Rush's npm and yarn modes use temp project tarballs (stored in `common/temp/proj These tests ensure the tar 7.x upgrade works correctly with these workflows. +The PNPM test additionally verifies Rush's workspace install integration with PNPM's global virtual store. + ## Tests The test suite is written in TypeScript using `@rushstack/node-core-library` for cross-platform compatibility. @@ -30,6 +32,14 @@ Tests Rush yarn mode by: - Running `rush install` - Running `rush build` (verifies everything works end-to-end) +### testPnpmGlobalVirtualStore.ts +Tests Rush pnpm workspace mode with global virtual store by: +- Initializing a Rush repo with `pnpmVersion` configured +- Enabling `useWorkspaces` and setting `RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE=1` +- Running `rush update` +- Running `rush install` +- Verifying the generated workspace file, shared PNPM store, dependency links, and build output + ## Prerequisites Before running these tests: @@ -62,6 +72,7 @@ These integration tests verify: - ✓ Tarballs are extracted correctly during `rush install` - ✓ File permissions are preserved (tar filter function works) - ✓ Dependencies are linked properly between projects +- ✓ PNPM global virtual store is passed through to a real workspace install - ✓ The complete workflow (update → install → build) succeeds - ✓ Built code executes correctly @@ -70,6 +81,7 @@ These integration tests verify: Each test creates a temporary Rush repository in `/tmp/rush-package-manager-test/`: - `/tmp/rush-package-manager-test/npm-test-repo/` - npm mode test repository - `/tmp/rush-package-manager-test/yarn-test-repo/` - yarn mode test repository +- `/tmp/rush-package-manager-test/pnpm-global-virtual-store-test-repo/` - pnpm global virtual store test repository These directories are cleaned up at the start of each test run. @@ -86,6 +98,7 @@ The tests use: The tar library is used in: - `libraries/rush-lib/src/logic/TempProjectHelper.ts` - Creates tarballs - `libraries/rush-lib/src/logic/npm/NpmLinkManager.ts` - Extracts tarballs +- `libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts` - Generates PNPM workspace files ## Troubleshooting diff --git a/build-tests/rush-package-manager-integration-test/package.json b/build-tests/rush-package-manager-integration-test/package.json index a812e495c73..d53e12bd3d1 100644 --- a/build-tests/rush-package-manager-integration-test/package.json +++ b/build-tests/rush-package-manager-integration-test/package.json @@ -2,7 +2,7 @@ "name": "rush-package-manager-integration-test", "version": "1.0.0", "private": true, - "description": "Integration tests for non-pnpm package managers in Rush.", + "description": "Integration tests for package managers in Rush.", "license": "MIT", "scripts": { "_phase:build": "heft build --clean", diff --git a/build-tests/rush-package-manager-integration-test/src/TestHelper.ts b/build-tests/rush-package-manager-integration-test/src/TestHelper.ts index 2ef48061152..73b5cd0ad59 100644 --- a/build-tests/rush-package-manager-integration-test/src/TestHelper.ts +++ b/build-tests/rush-package-manager-integration-test/src/TestHelper.ts @@ -4,7 +4,13 @@ import * as path from 'node:path'; import type * as child_process from 'node:child_process'; -import { FileSystem, Executable, JsonFile, type JsonObject } from '@rushstack/node-core-library'; +import { + FileSystem, + Executable, + JsonFile, + type FileSystemStats, + type JsonObject +} from '@rushstack/node-core-library'; import type { ITerminal } from '@rushstack/terminal'; /** @@ -23,7 +29,11 @@ export class TestHelper { /** * Execute a Rush command using the locally-built Rush */ - public async executeRushAsync(args: string[], workingDirectory: string): Promise { + public async executeRushAsync( + args: string[], + workingDirectory: string, + environment?: NodeJS.ProcessEnv + ): Promise { this._terminal.writeLine(`Executing: ${process.argv0} ${this._rushBinPath} ${args.join(' ')}`); const childProcess: child_process.ChildProcess = Executable.spawn( @@ -31,6 +41,7 @@ export class TestHelper { [this._rushBinPath, ...args], { currentWorkingDirectory: workingDirectory, + environment, stdio: 'inherit' } ); @@ -45,7 +56,7 @@ export class TestHelper { */ public async createTestRepoAsync( testRepoPath: string, - packageManagerType: 'npm' | 'yarn', + packageManagerType: 'npm' | 'pnpm' | 'yarn', packageManagerVersion: string ): Promise { // Clean up previous test run and create empty test repo directory @@ -66,6 +77,10 @@ export class TestHelper { delete rushJson.pnpmVersion; delete rushJson.yarnVersion; rushJson.npmVersion = packageManagerVersion; + } else if (packageManagerType === 'pnpm') { + delete rushJson.npmVersion; + delete rushJson.yarnVersion; + rushJson.pnpmVersion = packageManagerVersion; } else if (packageManagerType === 'yarn') { delete rushJson.pnpmVersion; delete rushJson.npmVersion; @@ -151,8 +166,9 @@ export class TestHelper { // Verify symlinks resolve correctly for local dependencies if (dep.startsWith('test-project-')) { const depRealPath: string = await FileSystem.getRealPathAsync(depPath); - const expectedRealPath: string = path.join(testRepoPath, 'projects', dep); - if (depRealPath !== expectedRealPath) { + const expectedPath: string = path.join(testRepoPath, 'projects', dep); + const expectedRealPath: string = await FileSystem.getRealPathAsync(expectedPath); + if (!(await this._doPathsReferToSameObjectAsync(depPath, expectedPath))) { throw new Error( `ERROR: Symlink for ${dep} does not resolve correctly!\n` + `Expected: ${expectedRealPath}\n` + @@ -164,6 +180,85 @@ export class TestHelper { this._terminal.writeLine('✓ Dependencies installed correctly'); } + private async _doPathsReferToSameObjectAsync(path1: string, path2: string): Promise { + const path1Stats: FileSystemStats = await FileSystem.getStatisticsAsync(path1); + const path2Stats: FileSystemStats = await FileSystem.getStatisticsAsync(path2); + return path1Stats.dev === path2Stats.dev && path1Stats.ino === path2Stats.ino; + } + + /** + * Verify that PNPM's global virtual store was enabled and moved out of the workspace node_modules folder. + */ + public async verifyPnpmGlobalVirtualStoreAsync( + testRepoPath: string, + sharedStorePath: string + ): Promise { + this._terminal.writeLine('\nVerifying PNPM global virtual store structure...'); + + const workspaceFilePath: string = path.join(testRepoPath, 'common/temp/pnpm-workspace.yaml'); + const workspaceFileContents: string = await FileSystem.readFileAsync(workspaceFilePath); + if (!workspaceFileContents.includes('enableGlobalVirtualStore: true')) { + throw new Error(`ERROR: enableGlobalVirtualStore was not written to ${workspaceFilePath}`); + } + + const localVirtualStorePath: string = path.join(testRepoPath, 'common/temp/node_modules/.pnpm'); + if (await FileSystem.existsAsync(localVirtualStorePath)) { + const localVirtualStoreItemNames: string[] = + await FileSystem.readFolderItemNamesAsync(localVirtualStorePath); + const unexpectedLocalPackageFolders: string[] = localVirtualStoreItemNames.filter( + (itemName) => itemName !== 'lock.yaml' && itemName !== 'node_modules' + ); + if (unexpectedLocalPackageFolders.length > 0) { + throw new Error( + `ERROR: Expected ${localVirtualStorePath} to omit package instance folders, but found: ` + + unexpectedLocalPackageFolders.join(', ') + ); + } + } + + if (!(await FileSystem.existsAsync(sharedStorePath))) { + throw new Error(`ERROR: Shared PNPM store was not created at ${sharedStorePath}`); + } + + const sharedStoreItemNames: string[] = await FileSystem.readFolderItemNamesAsync(sharedStorePath); + if (sharedStoreItemNames.length === 0) { + throw new Error(`ERROR: Shared PNPM store is empty at ${sharedStorePath}`); + } + + const globalVirtualStorePath: string = await this._findPnpmGlobalVirtualStorePathAsync(sharedStorePath); + if (!(await FileSystem.existsAsync(globalVirtualStorePath))) { + throw new Error( + `ERROR: Expected PNPM global virtual store package links under ${sharedStorePath}, ` + + `but ${globalVirtualStorePath} was not found.` + ); + } + + const globalVirtualStoreItemNames: string[] = + await FileSystem.readFolderItemNamesAsync(globalVirtualStorePath); + if (globalVirtualStoreItemNames.length === 0) { + throw new Error( + `ERROR: PNPM global virtual store package links folder is empty at ${globalVirtualStorePath}` + ); + } + + this._terminal.writeLine('✓ PNPM global virtual store structure verified'); + } + + private async _findPnpmGlobalVirtualStorePathAsync(sharedStorePath: string): Promise { + const sharedStoreVersionFolderNames: string[] = + await FileSystem.readFolderItemNamesAsync(sharedStorePath); + for (const folderName of sharedStoreVersionFolderNames) { + if (folderName.startsWith('v')) { + const linksPath: string = path.join(sharedStorePath, folderName, 'links'); + if (await FileSystem.existsAsync(linksPath)) { + return linksPath; + } + } + } + + return path.join(sharedStorePath, '', 'links'); + } + /** * Verify that build outputs were created */ diff --git a/build-tests/rush-package-manager-integration-test/src/runTests.ts b/build-tests/rush-package-manager-integration-test/src/runTests.ts index e531c6251bb..ee76c1a14f1 100644 --- a/build-tests/rush-package-manager-integration-test/src/runTests.ts +++ b/build-tests/rush-package-manager-integration-test/src/runTests.ts @@ -4,6 +4,7 @@ import { Terminal, ConsoleTerminalProvider } from '@rushstack/terminal'; import { testNpmModeAsync } from './testNpmMode'; +import { testPnpmGlobalVirtualStoreAsync } from './testPnpmGlobalVirtualStore'; import { testYarnModeAsync } from './testYarnMode'; /** @@ -17,7 +18,7 @@ async function runTestsAsync(): Promise { terminal.writeLine('=========================================='); terminal.writeLine(''); terminal.writeLine('These tests verify that the tar 7.x upgrade works correctly'); - terminal.writeLine('with different Rush package managers (npm, yarn).'); + terminal.writeLine('with different Rush package managers (npm, pnpm, yarn).'); terminal.writeLine(''); terminal.writeLine('Tests will:'); terminal.writeLine(' 1. Create Rush repos using locally-built Rush'); @@ -45,6 +46,20 @@ async function runTestsAsync(): Promise { terminal.writeErrorLine(String(error)); } + // Run pnpm global virtual store test + terminal.writeLine('=========================================='); + terminal.writeLine('Running PNPM global virtual store test...'); + terminal.writeLine('=========================================='); + try { + await testPnpmGlobalVirtualStoreAsync(terminal); + testsPassed++; + } catch (error) { + testsFailed++; + failedTests.push('PNPM global virtual store'); + terminal.writeErrorLine('⚠️ PNPM global virtual store test FAILED'); + terminal.writeErrorLine(String(error)); + } + // Run yarn mode test terminal.writeLine('=========================================='); terminal.writeLine('Running Yarn mode test...'); @@ -81,6 +96,7 @@ async function runTestsAsync(): Promise { terminal.writeLine(''); terminal.writeLine('The tar 7.x upgrade is working correctly with:'); terminal.writeLine(' - NPM package manager'); + terminal.writeLine(' - PNPM global virtual store'); terminal.writeLine(' - Yarn package manager'); terminal.writeLine(''); process.exit(0); diff --git a/build-tests/rush-package-manager-integration-test/src/testPnpmGlobalVirtualStore.ts b/build-tests/rush-package-manager-integration-test/src/testPnpmGlobalVirtualStore.ts new file mode 100644 index 00000000000..cf581ee345e --- /dev/null +++ b/build-tests/rush-package-manager-integration-test/src/testPnpmGlobalVirtualStore.ts @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { FileSystem, JsonFile, type JsonObject } from '@rushstack/node-core-library'; +import type { ITerminal } from '@rushstack/terminal'; + +import { TestHelper } from './TestHelper'; + +/** + * Integration test for Rush PNPM workspace mode with PNPM's global virtual store. + * This test verifies that Rush passes enableGlobalVirtualStore through to a real PNPM install. + */ +export async function testPnpmGlobalVirtualStoreAsync(terminal: ITerminal): Promise { + const helper: TestHelper = new TestHelper(terminal); + // Use system temp directory to avoid rush init detecting parent rush.json + const testRepoPath: string = path.join( + os.tmpdir(), + 'rush-package-manager-test', + 'pnpm-global-virtual-store-test-repo' + ); + const sharedStorePath: string = path.join(os.tmpdir(), 'rush-package-manager-test', 'shared-pnpm-store'); + const rushEnvironment: NodeJS.ProcessEnv = { + ...process.env, + CI: 'false', + PNPM_CONFIG_CI: 'false', + RUSH_PNPM_STORE_PATH: sharedStorePath, + RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE: '1' + }; + + terminal.writeLine('=========================================='); + terminal.writeLine('Rush PNPM Global Virtual Store Integration Test'); + terminal.writeLine('=========================================='); + terminal.writeLine(''); + terminal.writeLine( + 'This test verifies that Rush can enable PNPM global virtual store during workspace installs.' + ); + terminal.writeLine(''); + + await helper.createTestRepoAsync(testRepoPath, 'pnpm', '10.12.1'); + + const pnpmConfigPath: string = path.join(testRepoPath, 'common/config/rush/pnpm-config.json'); + const pnpmConfigJson: JsonObject = await JsonFile.loadAsync(pnpmConfigPath); + pnpmConfigJson.useWorkspaces = true; + await JsonFile.saveAsync(pnpmConfigJson, pnpmConfigPath, { updateExistingFile: true }); + + terminal.writeLine('Creating test-project-a...'); + await helper.createTestProjectAsync( + testRepoPath, + 'test-project-a', + '1.0.0', + { semver: '^7.5.4' }, + `node -e "const fs = require('fs'); fs.mkdirSync('lib', {recursive: true}); fs.writeFileSync('lib/index.js', 'module.exports = { greet: () => \\"Hello from A\\" };');"` + ); + + terminal.writeLine('Creating test-project-b...'); + await helper.createTestProjectAsync( + testRepoPath, + 'test-project-b', + '1.0.0', + { + 'test-project-a': 'workspace:*', + moment: '^2.29.4' + }, + `node -e "const fs = require('fs'); fs.mkdirSync('lib', {recursive: true}); fs.writeFileSync('lib/index.js', 'module.exports = { test: () => \\"Using: \\" + require(\\'test-project-a\\').greet() };');"` + ); + + await FileSystem.ensureEmptyFolderAsync(sharedStorePath); + + terminal.writeLine(''); + terminal.writeLine("Running 'rush update' with PNPM global virtual store enabled..."); + await helper.executeRushAsync(['update'], testRepoPath, rushEnvironment); + + terminal.writeLine(''); + terminal.writeLine("Running 'rush install' with PNPM global virtual store enabled..."); + await helper.executeRushAsync(['install'], testRepoPath, rushEnvironment); + + await helper.verifyPnpmGlobalVirtualStoreAsync(testRepoPath, sharedStorePath); + await helper.verifyDependenciesAsync(testRepoPath, 'test-project-a', ['semver']); + await helper.verifyDependenciesAsync(testRepoPath, 'test-project-b', ['test-project-a']); + + terminal.writeLine(''); + terminal.writeLine("Running 'rush build'..."); + await helper.executeRushAsync(['build'], testRepoPath, rushEnvironment); + + await helper.verifyBuildOutputsAsync(testRepoPath, ['test-project-a', 'test-project-b']); + await helper.testBuiltCodeAsync(testRepoPath, 'test-project-b'); + + terminal.writeLine(''); + terminal.writeLine('=========================================='); + terminal.writeLine('✓ PNPM Global Virtual Store Integration Test PASSED'); + terminal.writeLine('=========================================='); + terminal.writeLine(''); + terminal.writeLine('PNPM global virtual store works correctly with Rush workspace installs:'); + terminal.writeLine(' - Workspace file includes enableGlobalVirtualStore'); + terminal.writeLine(' - Shared PNPM store is populated'); + terminal.writeLine(' - Dependencies link and resolve correctly'); + terminal.writeLine(' - Build completed successfully'); + terminal.writeLine(''); +} diff --git a/common/changes/@microsoft/rush/support-pnpm-global-virtual-store_2026-06-11-15-20.json b/common/changes/@microsoft/rush/support-pnpm-global-virtual-store_2026-06-11-15-20.json new file mode 100644 index 00000000000..db66cc29520 --- /dev/null +++ b/common/changes/@microsoft/rush/support-pnpm-global-virtual-store_2026-06-11-15-20.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Add PNPM global virtual store support for workspace installs via RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE.", + "type": "minor" + } + ], + "packageName": "@microsoft/rush", + "email": "EscapeB@users.noreply.github.com" +} diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index 1853464f8b2..1729f8a25b5 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -243,6 +243,7 @@ export class EnvironmentConfiguration { static get hasBeenValidated(): boolean; // (undocumented) static parseBooleanEnvironmentVariable(name: string, value: string | undefined): boolean | undefined; + static get pnpmGlobalVirtualStore(): boolean; static get pnpmStorePathOverride(): string | undefined; static get pnpmVerifyStoreIntegrity(): boolean | undefined; static get quietMode(): boolean; @@ -263,6 +264,7 @@ export const EnvironmentVariableNames: { readonly RUSH_PARALLELISM: "RUSH_PARALLELISM"; readonly RUSH_ABSOLUTE_SYMLINKS: "RUSH_ABSOLUTE_SYMLINKS"; readonly RUSH_PNPM_STORE_PATH: "RUSH_PNPM_STORE_PATH"; + readonly RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE: "RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE"; readonly RUSH_PNPM_VERIFY_STORE_INTEGRITY: "RUSH_PNPM_VERIFY_STORE_INTEGRITY"; readonly RUSH_DEPLOY_TARGET_FOLDER: "RUSH_DEPLOY_TARGET_FOLDER"; readonly RUSH_GLOBAL_FOLDER: "RUSH_GLOBAL_FOLDER"; @@ -491,7 +493,6 @@ export interface IExperimentsJson { printEventHooksOutputToConsole?: boolean; rushAlerts?: boolean; strictChangefileValidation?: boolean; - trimRushEnvironmentVariablesForOperations?: boolean; useDirectFileTransfersForBuildCache?: boolean; useIPCScriptsInWatchMode?: boolean; usePnpmFrozenLockfileForRushInstall?: boolean; diff --git a/libraries/rush-lib/src/api/EnvironmentConfiguration.ts b/libraries/rush-lib/src/api/EnvironmentConfiguration.ts index 663e70d557e..c6a40e2e67b 100644 --- a/libraries/rush-lib/src/api/EnvironmentConfiguration.ts +++ b/libraries/rush-lib/src/api/EnvironmentConfiguration.ts @@ -81,6 +81,13 @@ export const EnvironmentVariableNames = { */ RUSH_PNPM_STORE_PATH: 'RUSH_PNPM_STORE_PATH', + /** + * When using PNPM as the package manager, this variable can be used to enable PNPM's global + * virtual store for workspace installs. The value of this environment variable must be `1` (for + * true) or `0` (for false). If not specified, PNPM's global virtual store is not enabled by Rush. + */ + RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE: 'RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE', + /** * When using PNPM as the package manager, this variable can be used to control whether or not PNPM * validates the integrity of the PNPM store during installation. The value of this environment variable must be @@ -255,12 +262,6 @@ export const EnvironmentVariableNames = { RUSH_QUIET_MODE: 'RUSH_QUIET_MODE' } as const; -/** - * Matches the names of environment variables that are reserved for use by Rush itself. - * @internal - */ -export const RUSH_ENVIRONMENT_VARIABLE_NAME_REGEXP: RegExp = /^RUSH_/i; - let _hasBeenValidated: boolean = false; let _rushTempFolderOverride: string | undefined; @@ -273,6 +274,8 @@ let _allowWarningsInSuccessfulBuild: boolean = false; let _pnpmStorePathOverride: string | undefined; +let _pnpmGlobalVirtualStore: boolean = false; + let _pnpmVerifyStoreIntegrity: boolean | undefined; let _rushGlobalFolderOverride: string | undefined; @@ -363,6 +366,15 @@ export class EnvironmentConfiguration { return _pnpmStorePathOverride; } + /** + * If true, enables PNPM's global virtual store during workspace installs. + * See {@link EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE} + */ + public static get pnpmGlobalVirtualStore(): boolean { + _ensureValidated(); + return _pnpmGlobalVirtualStore; + } + /** * If specified, enables or disables integrity verification of the pnpm store during install. * See {@link EnvironmentVariableNames.RUSH_PNPM_VERIFY_STORE_INTEGRITY} @@ -502,7 +514,7 @@ export class EnvironmentConfiguration { const unknownEnvVariables: string[] = []; for (const envVarName in process.env) { - if (process.env.hasOwnProperty(envVarName) && envVarName.match(RUSH_ENVIRONMENT_VARIABLE_NAME_REGEXP)) { + if (process.env.hasOwnProperty(envVarName) && envVarName.match(/^RUSH_/i)) { const value: string | undefined = process.env[envVarName]; // Environment variables are only case-insensitive on Windows const normalizedEnvVarName: string = IS_WINDOWS ? envVarName.toUpperCase() : envVarName; @@ -556,6 +568,15 @@ export class EnvironmentConfiguration { break; } + case EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE: { + _pnpmGlobalVirtualStore = + EnvironmentConfiguration.parseBooleanEnvironmentVariable( + EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE, + value + ) ?? false; + break; + } + case EnvironmentVariableNames.RUSH_PNPM_VERIFY_STORE_INTEGRITY: { _pnpmVerifyStoreIntegrity = value === '1' ? true : value === '0' ? false : undefined; break; @@ -691,6 +712,7 @@ export class EnvironmentConfiguration { public static reset(): void { _rushTempFolderOverride = undefined; _pnpmStorePathOverride = undefined; + _pnpmGlobalVirtualStore = false; _quietMode = false; _gitBinaryPath = undefined; _tarBinaryPath = undefined; diff --git a/libraries/rush-lib/src/api/LastInstallFlag.ts b/libraries/rush-lib/src/api/LastInstallFlag.ts index a0af3df9dd8..f2534132adc 100644 --- a/libraries/rush-lib/src/api/LastInstallFlag.ts +++ b/libraries/rush-lib/src/api/LastInstallFlag.ts @@ -7,6 +7,7 @@ import { JsonFile, type JsonObject, Path, type IPackageJson, Objects } from '@ru import type { PackageManagerName } from './packageManager/PackageManager'; import type { RushConfiguration } from './RushConfiguration'; +import { EnvironmentConfiguration, EnvironmentVariableNames } from './EnvironmentConfiguration'; import * as objectUtilities from '../utilities/objectUtilities'; import type { Subspace } from './Subspace'; import { Selection } from '../logic/Selection'; @@ -42,6 +43,10 @@ export interface ILastInstallFlagJson { * Same with pnpmOptions.pnpmStorePath in rush.json */ storePath?: string; + /** + * True when RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE is enabled. + */ + pnpmGlobalVirtualStore?: boolean; /** * An experimental flag used by cleanInstallAfterNpmrcChanges */ @@ -149,6 +154,22 @@ export class LastInstallFlag extends FlagFile> { `New Path: ${normalizedNewStorePath}` ); } + const oldPnpmGlobalVirtualStore: boolean = oldState.pnpmGlobalVirtualStore === true; + const newPnpmGlobalVirtualStore: boolean = newState.pnpmGlobalVirtualStore === true; + if (oldPnpmGlobalVirtualStore !== newPnpmGlobalVirtualStore) { + throw new Error( + 'Current PNPM global virtual store setting does not match the last one used. ' + + 'This may cause inconsistency in your builds.\n\n' + + `If you wish to install with the new global virtual store setting, please run ` + + `"rush ${rushVerb} --purge"\n\n` + + `Old ${EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE}: ${ + oldPnpmGlobalVirtualStore ? '1' : '0' + }\n` + + `New ${EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE}: ${ + newPnpmGlobalVirtualStore ? '1' : '0' + }` + ); + } } // check whether new selected projects are installed if (newState.selectedProjectNames) { @@ -207,6 +228,9 @@ export function getCommonTempFlag( if (currentState.packageManager === 'pnpm' && rushConfiguration.pnpmOptions) { currentState.storePath = rushConfiguration.pnpmOptions.pnpmStorePath; + if (EnvironmentConfiguration.pnpmGlobalVirtualStore) { + currentState.pnpmGlobalVirtualStore = true; + } if (rushConfiguration.pnpmOptions.useWorkspaces) { currentState.workspaces = rushConfiguration.pnpmOptions.useWorkspaces; } diff --git a/libraries/rush-lib/src/api/test/EnvironmentConfiguration.test.ts b/libraries/rush-lib/src/api/test/EnvironmentConfiguration.test.ts index 78f597c2a3d..3d160a08bbe 100644 --- a/libraries/rush-lib/src/api/test/EnvironmentConfiguration.test.ts +++ b/libraries/rush-lib/src/api/test/EnvironmentConfiguration.test.ts @@ -108,4 +108,36 @@ describe(EnvironmentConfiguration.name, () => { expect(EnvironmentConfiguration.pnpmStorePathOverride).toEqual(expectedValue); }); }); + + describe('pnpmGlobalVirtualStore', () => { + const ENV_VAR: string = 'RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE'; + + it('returns false for unset environment variable', () => { + EnvironmentConfiguration.validate(); + + expect(EnvironmentConfiguration.pnpmGlobalVirtualStore).toEqual(false); + }); + + it('returns true when environment variable is set to 1', () => { + process.env[ENV_VAR] = '1'; + EnvironmentConfiguration.validate(); + + expect(EnvironmentConfiguration.pnpmGlobalVirtualStore).toEqual(true); + }); + + it('returns false when environment variable is set to 0', () => { + process.env[ENV_VAR] = '0'; + EnvironmentConfiguration.validate(); + + expect(EnvironmentConfiguration.pnpmGlobalVirtualStore).toEqual(false); + }); + + it('rejects unsupported environment variable values', () => { + process.env[ENV_VAR] = 'true'; + + expect(EnvironmentConfiguration.validate).toThrow( + `Invalid value "true" for the environment variable ${ENV_VAR}` + ); + }); + }); }); diff --git a/libraries/rush-lib/src/api/test/LastInstallFlag.test.ts b/libraries/rush-lib/src/api/test/LastInstallFlag.test.ts index 11aa23f09a5..08e7300a8d6 100644 --- a/libraries/rush-lib/src/api/test/LastInstallFlag.test.ts +++ b/libraries/rush-lib/src/api/test/LastInstallFlag.test.ts @@ -4,6 +4,7 @@ import * as path from 'node:path'; import { FileSystem } from '@rushstack/node-core-library'; +import { EnvironmentVariableNames } from '../EnvironmentConfiguration'; import { LastInstallFlag } from '../LastInstallFlag'; const TEMP_DIR_PATH: string = `${__dirname}/temp`; @@ -92,6 +93,40 @@ describe(LastInstallFlag.name, () => { }).rejects.toThrow(/PNPM store path/); }); + it("throws an error if the PNPM global virtual store setting doesn't match the old one", async () => { + const flag1: LastInstallFlag = new LastInstallFlag(TEMP_DIR_PATH, { + packageManager: 'pnpm', + storePath: `${TEMP_DIR_PATH}/pnpm-store` + }); + const flag2: LastInstallFlag = new LastInstallFlag(TEMP_DIR_PATH, { + packageManager: 'pnpm', + storePath: `${TEMP_DIR_PATH}/pnpm-store`, + pnpmGlobalVirtualStore: true + }); + + await flag1.createAsync(); + await expect(async () => { + await flag2.checkValidAndReportStoreIssuesAsync({ rushVerb: 'install' }); + }).rejects.toThrow(EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE); + }); + + it("throws an error if the PNPM global virtual store setting was previously enabled but isn't now", async () => { + const flag1: LastInstallFlag = new LastInstallFlag(TEMP_DIR_PATH, { + packageManager: 'pnpm', + storePath: `${TEMP_DIR_PATH}/pnpm-store`, + pnpmGlobalVirtualStore: true + }); + const flag2: LastInstallFlag = new LastInstallFlag(TEMP_DIR_PATH, { + packageManager: 'pnpm', + storePath: `${TEMP_DIR_PATH}/pnpm-store` + }); + + await flag1.createAsync(); + await expect(async () => { + await flag2.checkValidAndReportStoreIssuesAsync({ rushVerb: 'install' }); + }).rejects.toThrow(EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE); + }); + it("doesn't throw an error if conditions for error aren't met", async () => { const flag1: LastInstallFlag = new LastInstallFlag(TEMP_DIR_PATH, { packageManager: 'pnpm', diff --git a/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts b/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts index de745a72338..1682076dbc6 100644 --- a/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts +++ b/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts @@ -34,7 +34,8 @@ import { Utilities } from '../../utilities/Utilities'; import { InstallHelpers, type IResolvedPnpmSettings } from './InstallHelpers'; import type { CommonVersionsConfiguration } from '../../api/CommonVersionsConfiguration'; import type { RepoStateFile } from '../RepoStateFile'; -import { EnvironmentConfiguration } from '../../api/EnvironmentConfiguration'; +import { EnvironmentConfiguration, EnvironmentVariableNames } from '../../api/EnvironmentConfiguration'; +import type { PnpmStoreLocation } from '../pnpm/PnpmOptionsConfiguration'; import { ShrinkwrapFileFactory } from '../ShrinkwrapFileFactory'; import { BaseProjectShrinkwrapFile } from '../base/BaseProjectShrinkwrapFile'; import { type CustomTipId, type ICustomTipInfo, PNPM_CUSTOM_TIPS } from '../../api/CustomTipsConfiguration'; @@ -48,6 +49,58 @@ export interface IPnpmModules { hoistedDependencies: { [dep in string]: { [depPath in string]: string } }; } +export interface IGlobalVirtualStoreValidationOptions { + pnpmVersion: string; + rushJsonFolder: string; + pnpmStore: PnpmStoreLocation; + pnpmStorePath: string; + pnpmStorePathOverride: string | undefined; + usePnpmSyncForInjectedDependencies: boolean | undefined; +} + +export function validateGlobalVirtualStoreOptions( + options: IGlobalVirtualStoreValidationOptions +): string | undefined { + if (semver.lt(options.pnpmVersion, '10.12.1')) { + throw new Error( + `Your version of PNPM (${options.pnpmVersion}) doesn't support the ` + + `${EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE} environment variable. ` + + 'Unset this environment variable or upgrade to PNPM 10.12.1 or newer.' + ); + } + + if (options.pnpmStore === 'local' && !options.pnpmStorePathOverride) { + throw new Error( + `The ${EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE} environment ` + + `variable requires a shared PNPM store. The current "pnpmStore" setting resolves to ` + + `a worktree-local store under ${options.pnpmStorePath}. Set "pnpmStore" to "global" ` + + `or use ${EnvironmentVariableNames.RUSH_PNPM_STORE_PATH}.` + ); + } + + if (options.pnpmStorePathOverride) { + if (Path.isUnderOrEqual(path.resolve(options.pnpmStorePathOverride), options.rushJsonFolder)) { + return ( + `The ${EnvironmentVariableNames.RUSH_PNPM_STORE_PATH} environment variable points inside ` + + `the Rush repo: ${options.pnpmStorePathOverride}. PNPM global virtual store will still ` + + `be enabled, but the store will remain worktree-local and will not reduce setup or ` + + `cleanup costs across multiple worktrees.` + ); + } + } + + if (options.usePnpmSyncForInjectedDependencies) { + throw new Error( + `The ${EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE} environment ` + + `variable is not compatible with the ` + + `"usePnpmSyncForInjectedDependencies" experiment. PNPM global virtual store moves the ` + + `virtual store out of "node_modules/.pnpm", but pnpm-sync currently requires that folder.` + ); + } + + return undefined; +} + /** * This class implements common logic between "rush install" and "rush update". */ @@ -444,6 +497,22 @@ export class WorkspaceInstallManager extends BaseInstallManager { // Write the common package.json using the "pnpm" field derived above. await InstallHelpers.generateCommonPackageJsonAsync(subspace, undefined, pnpmSettings); + if (EnvironmentConfiguration.pnpmGlobalVirtualStore) { + const globalVirtualStoreWarning: string | undefined = validateGlobalVirtualStoreOptions({ + pnpmVersion: this.rushConfiguration.packageManagerToolVersion, + rushJsonFolder: this.rushConfiguration.rushJsonFolder, + pnpmStore: this.rushConfiguration.pnpmOptions.pnpmStore, + pnpmStorePath: this.rushConfiguration.pnpmOptions.pnpmStorePath, + pnpmStorePathOverride: EnvironmentConfiguration.pnpmStorePathOverride, + usePnpmSyncForInjectedDependencies: + this.rushConfiguration.experimentsConfiguration.configuration?.usePnpmSyncForInjectedDependencies + }); + if (globalVirtualStoreWarning) { + this._terminal.writeWarningLine(Colorize.yellow(globalVirtualStoreWarning)); + } + workspaceFile.enableGlobalVirtualStore = true; + } + // 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. diff --git a/libraries/rush-lib/src/logic/pnpm/PnpmWorkspaceFile.ts b/libraries/rush-lib/src/logic/pnpm/PnpmWorkspaceFile.ts index 7734ee575e1..58c20f5fd27 100644 --- a/libraries/rush-lib/src/logic/pnpm/PnpmWorkspaceFile.ts +++ b/libraries/rush-lib/src/logic/pnpm/PnpmWorkspaceFile.ts @@ -120,6 +120,12 @@ interface IPnpmWorkspaceYaml { * (USED ONLY IN PNPM 11.0.0 AND NEWER) */ globalPnpmfile: string | undefined; + /** + * Places the virtual store under the configured PNPM store instead of under the workspace + * node_modules folder. + * (SUPPORTED ONLY IN PNPM 10.12.1 AND NEWER) + */ + enableGlobalVirtualStore: boolean | undefined; } export class PnpmWorkspaceFile extends BaseWorkspaceFile { @@ -143,6 +149,7 @@ export class PnpmWorkspaceFile extends BaseWorkspaceFile { public minimumReleaseAge: IPnpmWorkspaceYaml['minimumReleaseAge']; public minimumReleaseAgeExclude: IPnpmWorkspaceYaml['minimumReleaseAgeExclude']; public globalPnpmfile: IPnpmWorkspaceYaml['globalPnpmfile']; + public enableGlobalVirtualStore: IPnpmWorkspaceYaml['enableGlobalVirtualStore']; /** * The PNPM workspace file is used to specify the location of workspaces relative to the root @@ -201,7 +208,8 @@ export class PnpmWorkspaceFile extends BaseWorkspaceFile { trustPolicyIgnoreAfter, minimumReleaseAge, minimumReleaseAgeExclude, - globalPnpmfile + globalPnpmfile, + enableGlobalVirtualStore } = workspaceYaml; workspaceFile.catalogs = catalogs; workspaceFile.allowBuilds = allowBuilds; @@ -217,6 +225,7 @@ export class PnpmWorkspaceFile extends BaseWorkspaceFile { workspaceFile.minimumReleaseAge = minimumReleaseAge; workspaceFile.minimumReleaseAgeExclude = minimumReleaseAgeExclude; workspaceFile.globalPnpmfile = globalPnpmfile; + workspaceFile.enableGlobalVirtualStore = enableGlobalVirtualStore; } return workspaceFile; @@ -249,7 +258,8 @@ export class PnpmWorkspaceFile extends BaseWorkspaceFile { trustPolicyIgnoreAfter, minimumReleaseAge, minimumReleaseAgeExclude, - globalPnpmfile + globalPnpmfile, + enableGlobalVirtualStore } = this; // Ensure stable sort order when serializing Sort.sortSet(workspacePackages); @@ -270,7 +280,8 @@ export class PnpmWorkspaceFile extends BaseWorkspaceFile { trustPolicyIgnoreAfter, minimumReleaseAge, minimumReleaseAgeExclude, - globalPnpmfile + globalPnpmfile, + enableGlobalVirtualStore: enableGlobalVirtualStore ? true : undefined }; const yamlModule: typeof import('js-yaml') = await import('js-yaml'); 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 d34b55191ea..ba7f18cf510 100644 --- a/libraries/rush-lib/src/logic/pnpm/test/PnpmWorkspaceFile.test.ts +++ b/libraries/rush-lib/src/logic/pnpm/test/PnpmWorkspaceFile.test.ts @@ -554,4 +554,45 @@ describe(PnpmWorkspaceFile.name, () => { expect(writtenContent).not.toContain('minimumReleaseAgeExclude'); }); }); + + describe('global virtual store functionality', () => { + it('generates workspace file with enableGlobalVirtualStore', async () => { + const workspaceFile: PnpmWorkspaceFile = new PnpmWorkspaceFile(workspaceFilePath); + workspaceFile.addPackage(`${projectsDir}/app1`); + workspaceFile.enableGlobalVirtualStore = true; + + await workspaceFile.saveAsync(workspaceFilePath, { onlyIfChanged: true }); + + expect(writtenContent).toMatchSnapshot(); + }); + + it('generates workspace file with enableGlobalVirtualStore, allowBuilds, and catalogs', async () => { + const workspaceFile: PnpmWorkspaceFile = new PnpmWorkspaceFile(workspaceFilePath); + workspaceFile.addPackage(`${projectsDir}/app1`); + + workspaceFile.catalogs = { + default: { + react: '^18.0.0' + } + }; + workspaceFile.allowBuilds = { + esbuild: true + }; + workspaceFile.enableGlobalVirtualStore = true; + + await workspaceFile.saveAsync(workspaceFilePath, { onlyIfChanged: true }); + + expect(writtenContent).toMatchSnapshot(); + }); + + it('omits enableGlobalVirtualStore when disabled', async () => { + const workspaceFile: PnpmWorkspaceFile = new PnpmWorkspaceFile(workspaceFilePath); + workspaceFile.addPackage(`${projectsDir}/app1`); + workspaceFile.enableGlobalVirtualStore = false; + + await workspaceFile.saveAsync(workspaceFilePath, { onlyIfChanged: true }); + + expect(writtenContent).not.toContain('enableGlobalVirtualStore'); + }); + }); }); diff --git a/libraries/rush-lib/src/logic/pnpm/test/__snapshots__/PnpmWorkspaceFile.test.ts.snap b/libraries/rush-lib/src/logic/pnpm/test/__snapshots__/PnpmWorkspaceFile.test.ts.snap index b0eff80d3aa..cc7e56bdbdf 100644 --- a/libraries/rush-lib/src/logic/pnpm/test/__snapshots__/PnpmWorkspaceFile.test.ts.snap +++ b/libraries/rush-lib/src/logic/pnpm/test/__snapshots__/PnpmWorkspaceFile.test.ts.snap @@ -133,6 +133,25 @@ packages: " `; +exports[`PnpmWorkspaceFile global virtual store functionality generates workspace file with enableGlobalVirtualStore 1`] = ` +"enableGlobalVirtualStore: true +packages: + - projects/app1 +" +`; + +exports[`PnpmWorkspaceFile global virtual store functionality generates workspace file with enableGlobalVirtualStore, allowBuilds, and catalogs 1`] = ` +"allowBuilds: + esbuild: true +catalogs: + default: + react: ^18.0.0 +enableGlobalVirtualStore: true +packages: + - projects/app1 +" +`; + exports[`PnpmWorkspaceFile minimumReleaseAge functionality generates workspace file with minimumReleaseAge 1`] = ` "minimumReleaseAge: 20160 packages: diff --git a/libraries/rush-lib/src/logic/test/BaseInstallManager.test.ts b/libraries/rush-lib/src/logic/test/BaseInstallManager.test.ts index 7ecf61071f4..084e5865dc7 100644 --- a/libraries/rush-lib/src/logic/test/BaseInstallManager.test.ts +++ b/libraries/rush-lib/src/logic/test/BaseInstallManager.test.ts @@ -11,6 +11,7 @@ import type { IInstallManagerOptions } from '../base/BaseInstallManagerTypes'; import { RushConfiguration } from '../../api/RushConfiguration'; import { RushGlobalFolder } from '../../api/RushGlobalFolder'; import type { Subspace } from '../../api/Subspace'; +import { EnvironmentConfiguration, EnvironmentVariableNames } from '../../api/EnvironmentConfiguration'; class FakeBaseInstallManager extends BaseInstallManager { public constructor( @@ -49,6 +50,10 @@ class FakeBaseInstallManager extends BaseInstallManager { describe('BaseInstallManager Test', () => { const rushGlobalFolder: RushGlobalFolder = new RushGlobalFolder(); + afterEach(() => { + EnvironmentConfiguration.reset(); + }); + it('pnpm version in 6.32.12 - 6.33.x || 7.0.1 - 7.8.x should output warning', () => { const rushJsonFilePnpmV6: string = path.resolve(__dirname, 'ignoreCompatibilityDb/rush1.json'); const rushJsonFilePnpmV7: string = path.resolve(__dirname, 'ignoreCompatibilityDb/rush2.json'); @@ -134,4 +139,150 @@ describe('BaseInstallManager Test', () => { ); } }); + + it('passes --store when RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE is used with a local PNPM store', () => { + const originalPnpmGlobalVirtualStore: string | undefined = + process.env[EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE]; + const originalPnpmStorePath: string | undefined = + process.env[EnvironmentVariableNames.RUSH_PNPM_STORE_PATH]; + try { + process.env[EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE] = '1'; + delete process.env[EnvironmentVariableNames.RUSH_PNPM_STORE_PATH]; + EnvironmentConfiguration.reset(); + + const rushJsonFile: string = path.resolve(__dirname, 'ignoreCompatibilityDb/rush3.json'); + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile(rushJsonFile); + const purgeManager: typeof PurgeManager.prototype = new PurgeManager( + rushConfiguration, + rushGlobalFolder + ); + const options: IInstallManagerOptions = { + subspace: rushConfiguration.defaultSubspace + } as IInstallManagerOptions; + const fakeBaseInstallManager: FakeBaseInstallManager = new FakeBaseInstallManager( + rushConfiguration, + rushGlobalFolder, + purgeManager, + options + ); + + const args: string[] = []; + fakeBaseInstallManager.pushConfigurationArgs(args, options, rushConfiguration.defaultSubspace); + + expect(rushConfiguration.pnpmOptions.pnpmStore).toEqual('local'); + expect(rushConfiguration.pnpmOptions.pnpmStorePath).not.toEqual(''); + expect(args).toContain('--store'); + expect(args).toContain(rushConfiguration.pnpmOptions.pnpmStorePath); + } finally { + if (originalPnpmGlobalVirtualStore === undefined) { + delete process.env[EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE]; + } else { + process.env[EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE] = + originalPnpmGlobalVirtualStore; + } + if (originalPnpmStorePath === undefined) { + delete process.env[EnvironmentVariableNames.RUSH_PNPM_STORE_PATH]; + } else { + process.env[EnvironmentVariableNames.RUSH_PNPM_STORE_PATH] = originalPnpmStorePath; + } + EnvironmentConfiguration.reset(); + } + }); + + it('does not pass --store when RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE is used with a global PNPM store', () => { + const originalPnpmGlobalVirtualStore: string | undefined = + process.env[EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE]; + const originalPnpmStorePath: string | undefined = + process.env[EnvironmentVariableNames.RUSH_PNPM_STORE_PATH]; + try { + process.env[EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE] = '1'; + delete process.env[EnvironmentVariableNames.RUSH_PNPM_STORE_PATH]; + EnvironmentConfiguration.reset(); + + const rushJsonFile: string = path.resolve(__dirname, '../../api/test/repo/rush-pnpm-global.json'); + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile(rushJsonFile); + const purgeManager: typeof PurgeManager.prototype = new PurgeManager( + rushConfiguration, + rushGlobalFolder + ); + const options: IInstallManagerOptions = { + subspace: rushConfiguration.defaultSubspace + } as IInstallManagerOptions; + const fakeBaseInstallManager: FakeBaseInstallManager = new FakeBaseInstallManager( + rushConfiguration, + rushGlobalFolder, + purgeManager, + options + ); + + const args: string[] = []; + fakeBaseInstallManager.pushConfigurationArgs(args, options, rushConfiguration.defaultSubspace); + + expect(rushConfiguration.pnpmOptions.pnpmStore).toEqual('global'); + expect(rushConfiguration.pnpmOptions.pnpmStorePath).toEqual(''); + expect(args).not.toContain('--store'); + } finally { + if (originalPnpmGlobalVirtualStore === undefined) { + delete process.env[EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE]; + } else { + process.env[EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE] = + originalPnpmGlobalVirtualStore; + } + if (originalPnpmStorePath === undefined) { + delete process.env[EnvironmentVariableNames.RUSH_PNPM_STORE_PATH]; + } else { + process.env[EnvironmentVariableNames.RUSH_PNPM_STORE_PATH] = originalPnpmStorePath; + } + EnvironmentConfiguration.reset(); + } + }); + + it('passes --store when RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE uses an explicit PNPM store path', () => { + const originalPnpmGlobalVirtualStore: string | undefined = + process.env[EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE]; + const originalPnpmStorePath: string | undefined = + process.env[EnvironmentVariableNames.RUSH_PNPM_STORE_PATH]; + try { + const expectedStorePath: string = path.resolve('/var/temp/pnpm-store'); + process.env[EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE] = '1'; + process.env[EnvironmentVariableNames.RUSH_PNPM_STORE_PATH] = expectedStorePath; + EnvironmentConfiguration.reset(); + + const rushJsonFile: string = path.resolve(__dirname, 'ignoreCompatibilityDb/rush3.json'); + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile(rushJsonFile); + const purgeManager: typeof PurgeManager.prototype = new PurgeManager( + rushConfiguration, + rushGlobalFolder + ); + const options: IInstallManagerOptions = { + subspace: rushConfiguration.defaultSubspace + } as IInstallManagerOptions; + const fakeBaseInstallManager: FakeBaseInstallManager = new FakeBaseInstallManager( + rushConfiguration, + rushGlobalFolder, + purgeManager, + options + ); + + const args: string[] = []; + fakeBaseInstallManager.pushConfigurationArgs(args, options, rushConfiguration.defaultSubspace); + + expect(rushConfiguration.pnpmOptions.pnpmStorePath).toEqual(expectedStorePath); + expect(args).toContain('--store'); + expect(args).toContain(expectedStorePath); + } finally { + if (originalPnpmGlobalVirtualStore === undefined) { + delete process.env[EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE]; + } else { + process.env[EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE] = + originalPnpmGlobalVirtualStore; + } + if (originalPnpmStorePath === undefined) { + delete process.env[EnvironmentVariableNames.RUSH_PNPM_STORE_PATH]; + } else { + process.env[EnvironmentVariableNames.RUSH_PNPM_STORE_PATH] = originalPnpmStorePath; + } + EnvironmentConfiguration.reset(); + } + }); }); diff --git a/libraries/rush-lib/src/logic/test/WorkspaceInstallManager.test.ts b/libraries/rush-lib/src/logic/test/WorkspaceInstallManager.test.ts new file mode 100644 index 00000000000..e71503acc3f --- /dev/null +++ b/libraries/rush-lib/src/logic/test/WorkspaceInstallManager.test.ts @@ -0,0 +1,214 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; + +import { FileSystem, JsonFile, Path } from '@rushstack/node-core-library'; +import { StringBufferTerminalProvider, Terminal } from '@rushstack/terminal'; + +import { EnvironmentConfiguration, EnvironmentVariableNames } from '../../api/EnvironmentConfiguration'; +import { RushConfiguration } from '../../api/RushConfiguration'; +import { RushGlobalFolder } from '../../api/RushGlobalFolder'; +import type { Subspace } from '../../api/Subspace'; +import { + WorkspaceInstallManager, + validateGlobalVirtualStoreOptions +} from '../installManager/WorkspaceInstallManager'; +import { PurgeManager } from '../PurgeManager'; +import type { IInstallManagerOptions } from '../base/BaseInstallManagerTypes'; +import type { PnpmStoreLocation } from '../pnpm/PnpmOptionsConfiguration'; + +class TestWorkspaceInstallManager extends WorkspaceInstallManager { + public async prepareCommonTempForTestAsync(subspace: Subspace): Promise { + await super.prepareCommonTempAsync(subspace, undefined); + } +} + +describe(WorkspaceInstallManager.name, () => { + describe('enableGlobalVirtualStore validation', () => { + it('throws if the configured PNPM version does not support global virtual store', () => { + expect(() => + validateGlobalVirtualStoreOptions({ + pnpmVersion: '10.12.0', + rushJsonFolder: '/repo', + pnpmStore: 'global', + pnpmStorePath: '', + pnpmStorePathOverride: undefined, + usePnpmSyncForInjectedDependencies: undefined + }) + ).toThrow( + `Your version of PNPM (10.12.0) doesn't support the ` + + `${EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE} environment variable` + ); + }); + + it('throws if global virtual store is enabled with a worktree-local PNPM store', () => { + expect(() => + validateGlobalVirtualStoreOptions({ + pnpmVersion: '10.12.1', + rushJsonFolder: '/repo', + pnpmStore: 'local', + pnpmStorePath: '/repo/common/temp/pnpm-store', + pnpmStorePathOverride: undefined, + usePnpmSyncForInjectedDependencies: undefined + }) + ).toThrow(`Set "pnpmStore" to "global" or use ${EnvironmentVariableNames.RUSH_PNPM_STORE_PATH}.`); + }); + + it('throws if global virtual store is enabled with pnpm-sync for injected dependencies', () => { + expect(() => + validateGlobalVirtualStoreOptions({ + pnpmVersion: '10.12.1', + rushJsonFolder: '/repo', + pnpmStore: 'global', + pnpmStorePath: '', + pnpmStorePathOverride: undefined, + usePnpmSyncForInjectedDependencies: true + }) + ).toThrow( + `The ${EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE} environment ` + + `variable is not compatible with the ` + + '"usePnpmSyncForInjectedDependencies" experiment' + ); + }); + + it('warns if the PNPM store path override points inside the Rush repo', () => { + expect( + validateGlobalVirtualStoreOptions({ + pnpmVersion: '10.12.1', + rushJsonFolder: '/repo', + pnpmStore: 'local', + pnpmStorePath: '/repo/common/temp/pnpm-store', + pnpmStorePathOverride: '/repo/common/temp/shared-pnpm-store', + usePnpmSyncForInjectedDependencies: undefined + }) + ).toContain( + `The ${EnvironmentVariableNames.RUSH_PNPM_STORE_PATH} environment variable points inside ` + + `the Rush repo` + ); + }); + + it('allows global virtual store with a PNPM store path override', () => { + expect( + validateGlobalVirtualStoreOptions({ + pnpmVersion: '10.12.1', + rushJsonFolder: '/repo', + pnpmStore: 'local', + pnpmStorePath: '/repo/common/temp/pnpm-store', + pnpmStorePathOverride: '/shared/pnpm-store', + usePnpmSyncForInjectedDependencies: undefined + }) + ).toBeUndefined(); + }); + }); + + describe('prepareCommonTempAsync', () => { + const fixtureRepoPath: string = path.resolve(__dirname, 'repoWithSubspacesCatalogs'); + const tempFolderPath: string = `${__dirname}/temp/${WorkspaceInstallManager.name}`; + let originalPnpmStorePathEnvValue: string | undefined; + let originalPnpmGlobalVirtualStoreEnvValue: string | undefined; + + beforeEach(() => { + originalPnpmStorePathEnvValue = process.env[EnvironmentVariableNames.RUSH_PNPM_STORE_PATH]; + originalPnpmGlobalVirtualStoreEnvValue = + process.env[EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE]; + delete process.env[EnvironmentVariableNames.RUSH_PNPM_STORE_PATH]; + delete process.env[EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE]; + EnvironmentConfiguration.reset(); + EnvironmentConfiguration.validate({ doNotNormalizePaths: true }); + FileSystem.ensureEmptyFolder(tempFolderPath); + }); + + afterEach(() => { + if (originalPnpmStorePathEnvValue === undefined) { + delete process.env[EnvironmentVariableNames.RUSH_PNPM_STORE_PATH]; + } else { + process.env[EnvironmentVariableNames.RUSH_PNPM_STORE_PATH] = originalPnpmStorePathEnvValue; + } + if (originalPnpmGlobalVirtualStoreEnvValue === undefined) { + delete process.env[EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE]; + } else { + process.env[EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE] = + originalPnpmGlobalVirtualStoreEnvValue; + } + EnvironmentConfiguration.reset(); + FileSystem.deleteFolder(tempFolderPath); + }); + + function prepareFixtureRepo(options: { pnpmStore?: PnpmStoreLocation }): RushConfiguration { + const repoPath: string = `${tempFolderPath}/repo`; + FileSystem.copyFiles({ + sourcePath: fixtureRepoPath, + destinationPath: repoPath + }); + + const rushJsonPath: string = `${repoPath}/rush.json`; + const rushJson: Record = JsonFile.load(rushJsonPath); + rushJson.pnpmVersion = '10.12.1'; + JsonFile.save(rushJson, rushJsonPath, { updateExistingFile: true }); + + const commonPnpmConfigPath: string = `${repoPath}/common/config/rush/pnpm-config.json`; + const commonPnpmConfigJson: Record = JsonFile.load(commonPnpmConfigPath); + if (options.pnpmStore) { + commonPnpmConfigJson.pnpmStore = options.pnpmStore; + } else { + delete commonPnpmConfigJson.pnpmStore; + } + JsonFile.save(commonPnpmConfigJson, commonPnpmConfigPath, { updateExistingFile: true }); + + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile(rushJsonPath); + FileSystem.ensureFolder(rushConfiguration.defaultSubspace.getSubspaceTempFolderPath()); + return rushConfiguration; + } + + function createInstallManager(rushConfiguration: RushConfiguration): TestWorkspaceInstallManager { + const terminal: Terminal = new Terminal(new StringBufferTerminalProvider()); + const options: IInstallManagerOptions = { + allowShrinkwrapUpdates: true, + fullUpgrade: false, + variant: undefined, + subspace: rushConfiguration.defaultSubspace, + terminal + } as unknown as IInstallManagerOptions; + const rushGlobalFolder: RushGlobalFolder = new RushGlobalFolder(); + + return new TestWorkspaceInstallManager( + rushConfiguration, + rushGlobalFolder, + new PurgeManager(rushConfiguration, rushGlobalFolder), + options + ); + } + + it('writes enableGlobalVirtualStore through the workspace install prepare path', async () => { + process.env[EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE] = '1'; + EnvironmentConfiguration.reset(); + const rushConfiguration: RushConfiguration = prepareFixtureRepo({ pnpmStore: 'global' }); + const installManager: TestWorkspaceInstallManager = createInstallManager(rushConfiguration); + + await installManager.prepareCommonTempForTestAsync(rushConfiguration.defaultSubspace); + + const workspaceYaml: string = FileSystem.readFile( + `${rushConfiguration.defaultSubspace.getSubspaceTempFolderPath()}/pnpm-workspace.yaml` + ); + expect(workspaceYaml).toContain('enableGlobalVirtualStore: true'); + expect(Path.convertToSlashes(workspaceYaml)).toContain('../../../a'); + }); + + it('throws from the workspace install prepare path when using a worktree-local PNPM store', async () => { + process.env[EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE] = '1'; + EnvironmentConfiguration.reset(); + const rushConfiguration: RushConfiguration = prepareFixtureRepo({}); + const installManager: TestWorkspaceInstallManager = createInstallManager(rushConfiguration); + + expect(rushConfiguration.pnpmOptions.pnpmStore).toEqual('local'); + expect(rushConfiguration.pnpmOptions.pnpmStorePath).not.toEqual(''); + + await expect( + installManager.prepareCommonTempForTestAsync(rushConfiguration.defaultSubspace) + ).rejects.toThrow( + `Set "pnpmStore" to "global" or use ${EnvironmentVariableNames.RUSH_PNPM_STORE_PATH}.` + ); + }); + }); +}); From b2062da6f14a2e4d43c413bf9d83986990c666a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Dieuze?= Date: Tue, 1 Sep 2026 11:11:26 +0200 Subject: [PATCH 2/2] fix: restore RUSH_ENVIRONMENT_VARIABLE_NAME_REGEXP after GVS rebase #5994 exported this regexp for TrimRushEnvironmentVariablesPlugin. The GVS follow-up accidentally dropped it and inlined /^RUSH_/i. --- common/reviews/api/rush-lib.api.md | 1 + libraries/rush-lib/src/api/EnvironmentConfiguration.ts | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index 1729f8a25b5..5bec3ca7652 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -493,6 +493,7 @@ export interface IExperimentsJson { printEventHooksOutputToConsole?: boolean; rushAlerts?: boolean; strictChangefileValidation?: boolean; + trimRushEnvironmentVariablesForOperations?: boolean; useDirectFileTransfersForBuildCache?: boolean; useIPCScriptsInWatchMode?: boolean; usePnpmFrozenLockfileForRushInstall?: boolean; diff --git a/libraries/rush-lib/src/api/EnvironmentConfiguration.ts b/libraries/rush-lib/src/api/EnvironmentConfiguration.ts index c6a40e2e67b..e9e147b0d3e 100644 --- a/libraries/rush-lib/src/api/EnvironmentConfiguration.ts +++ b/libraries/rush-lib/src/api/EnvironmentConfiguration.ts @@ -262,6 +262,12 @@ export const EnvironmentVariableNames = { RUSH_QUIET_MODE: 'RUSH_QUIET_MODE' } as const; +/** + * Matches the names of environment variables that are reserved for use by Rush itself. + * @internal + */ +export const RUSH_ENVIRONMENT_VARIABLE_NAME_REGEXP: RegExp = /^RUSH_/i; + let _hasBeenValidated: boolean = false; let _rushTempFolderOverride: string | undefined; @@ -514,7 +520,7 @@ export class EnvironmentConfiguration { const unknownEnvVariables: string[] = []; for (const envVarName in process.env) { - if (process.env.hasOwnProperty(envVarName) && envVarName.match(/^RUSH_/i)) { + if (process.env.hasOwnProperty(envVarName) && envVarName.match(RUSH_ENVIRONMENT_VARIABLE_NAME_REGEXP)) { const value: string | undefined = process.env[envVarName]; // Environment variables are only case-insensitive on Windows const normalizedEnvVarName: string = IS_WINDOWS ? envVarName.toUpperCase() : envVarName;