diff --git a/README.md b/README.md index 8a54e1dea4..f0040001cd 100644 --- a/README.md +++ b/README.md @@ -222,7 +222,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/set-webpack-public-path-plugin-test](./build-tests/set-webpack-public-path-plugin-test/) | Building this project tests the set-webpack-public-path-plugin | @@ -258,4 +258,3 @@ provided by the bot. You will only need to do this once across all repos using o This repo has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. - diff --git a/build-tests/rush-package-manager-integration-test/README.md b/build-tests/rush-package-manager-integration-test/README.md index 5563850776..444c8cdbf5 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 a812e495c7..d53e12bd3d 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 e711014973..55c4f2d7c7 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 e531c6251b..ee76c1a14f 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 0000000000..cf581ee345 --- /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 0000000000..db66cc2952 --- /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 24c5a6606a..bb78e2f281 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -241,6 +241,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; @@ -261,6 +262,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"; diff --git a/libraries/rush-lib/src/api/EnvironmentConfiguration.ts b/libraries/rush-lib/src/api/EnvironmentConfiguration.ts index de8c8d2d2f..cff3fa85f4 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 @@ -276,6 +283,8 @@ export class EnvironmentConfiguration { private static _pnpmStorePathOverride: string | undefined; + private static _pnpmGlobalVirtualStore: boolean = false; + private static _pnpmVerifyStoreIntegrity: boolean | undefined; private static _rushGlobalFolderOverride: string | undefined; @@ -357,6 +366,15 @@ export class EnvironmentConfiguration { return EnvironmentConfiguration._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 { + EnvironmentConfiguration._ensureValidated(); + return EnvironmentConfiguration._pnpmGlobalVirtualStore; + } + /** * If specified, enables or disables integrity verification of the pnpm store during install. * See {@link EnvironmentVariableNames.RUSH_PNPM_VERIFY_STORE_INTEGRITY} @@ -551,6 +569,15 @@ export class EnvironmentConfiguration { break; } + case EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE: { + EnvironmentConfiguration._pnpmGlobalVirtualStore = + EnvironmentConfiguration.parseBooleanEnvironmentVariable( + EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE, + value + ) ?? false; + break; + } + case EnvironmentVariableNames.RUSH_PNPM_VERIFY_STORE_INTEGRITY: { EnvironmentConfiguration._pnpmVerifyStoreIntegrity = value === '1' ? true : value === '0' ? false : undefined; @@ -694,6 +721,9 @@ export class EnvironmentConfiguration { public static reset(): void { EnvironmentConfiguration._rushTempFolderOverride = undefined; EnvironmentConfiguration._quietMode = false; + EnvironmentConfiguration._pnpmStorePathOverride = undefined; + EnvironmentConfiguration._pnpmGlobalVirtualStore = false; + EnvironmentConfiguration._pnpmVerifyStoreIntegrity = undefined; EnvironmentConfiguration._gitBinaryPath = undefined; EnvironmentConfiguration._tarBinaryPath = undefined; EnvironmentConfiguration._hasBeenValidated = false; diff --git a/libraries/rush-lib/src/api/LastInstallFlag.ts b/libraries/rush-lib/src/api/LastInstallFlag.ts index ab854ac6ef..345542bb1d 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 78f597c2a3..3d160a08bb 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 11aa23f09a..08e7300a8d 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/api/test/RushConfiguration.test.ts b/libraries/rush-lib/src/api/test/RushConfiguration.test.ts index 6b90c8c2a1..b7d33b64af 100644 --- a/libraries/rush-lib/src/api/test/RushConfiguration.test.ts +++ b/libraries/rush-lib/src/api/test/RushConfiguration.test.ts @@ -7,7 +7,7 @@ import { JsonFile, Path, Text } from '@rushstack/node-core-library'; import { RushConfiguration } from '../RushConfiguration'; import type { ApprovedPackagesPolicy } from '../ApprovedPackagesPolicy'; import { RushConfigurationProject } from '../RushConfigurationProject'; -import { EnvironmentConfiguration } from '../EnvironmentConfiguration'; +import { EnvironmentConfiguration, EnvironmentVariableNames } from '../EnvironmentConfiguration'; import { DependencyType } from '../PackageJsonEditor'; function normalizePathForComparison(pathToNormalize: string): string { @@ -227,9 +227,11 @@ describe(RushConfiguration.name, () => { describe('PNPM Store Paths', () => { afterEach(() => { EnvironmentConfiguration['_pnpmStorePathOverride'] = undefined; + EnvironmentConfiguration['_pnpmGlobalVirtualStore'] = false; + EnvironmentConfiguration.reset(); }); - const PNPM_STORE_PATH_ENV: string = 'RUSH_PNPM_STORE_PATH'; + const PNPM_STORE_PATH_ENV: string = EnvironmentVariableNames.RUSH_PNPM_STORE_PATH; describe('Loading repo/rush-pnpm-local.json', () => { const RUSH_JSON_FILENAME: string = path.resolve(__dirname, 'repo', 'rush-pnpm-local.json'); @@ -259,6 +261,20 @@ describe(RushConfiguration.name, () => { expect(rushConfiguration.pnpmOptions.pnpmStorePath).toEqual(EXPECT_STORE_PATH); expect(path.isAbsolute(rushConfiguration.pnpmOptions.pnpmStorePath)).toEqual(true); }); + + it(`loads the local store path when RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE is defined`, () => { + const EXPECT_STORE_PATH: string = path.resolve(__dirname, 'repo', 'common', 'temp', 'pnpm-store'); + process.env[EnvironmentVariableNames.RUSH_PNPM_ENABLE_GLOBAL_VIRTUAL_STORE] = '1'; + + const rushConfiguration: RushConfiguration = + RushConfiguration.loadFromConfigurationFile(RUSH_JSON_FILENAME); + + expect(rushConfiguration.packageManager).toEqual('pnpm'); + expect(rushConfiguration.pnpmOptions.pnpmStore).toEqual('local'); + expect(Path.convertToSlashes(rushConfiguration.pnpmOptions.pnpmStorePath)).toEqual( + Path.convertToSlashes(EXPECT_STORE_PATH) + ); + }); }); describe('Loading repo/rush-pnpm-global.json', () => { diff --git a/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts b/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts index 222dd45141..089944f71a 100644 --- a/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts +++ b/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts @@ -35,7 +35,7 @@ import { Utilities } from '../../utilities/Utilities'; import { InstallHelpers } 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 { ShrinkwrapFileFactory } from '../ShrinkwrapFileFactory'; import { BaseProjectShrinkwrapFile } from '../base/BaseProjectShrinkwrapFile'; import { type CustomTipId, type ICustomTipInfo, PNPM_CUSTOM_TIPS } from '../../api/CustomTipsConfiguration'; @@ -44,12 +44,21 @@ 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'; +import type { PnpmOptionsConfiguration, PnpmStoreLocation } from '../pnpm/PnpmOptionsConfiguration'; export interface IPnpmModules { hoistedDependencies: { [dep in string]: { [depPath in string]: string } }; } +interface IGlobalVirtualStoreValidationOptions { + pnpmVersion: string; + rushJsonFolder: string; + pnpmStore: PnpmStoreLocation; + pnpmStorePath: string; + pnpmStorePathOverride: string | undefined; + usePnpmSyncForInjectedDependencies: boolean | undefined; +} + /** * This class implements common logic between "rush install" and "rush update". */ @@ -476,10 +485,7 @@ export class WorkspaceInstallManager extends BaseInstallManager { ) { if (pnpmOptions.globalAllowBuilds) { workspaceFile.setAllowBuilds(pnpmOptions.globalAllowBuilds); - } else if ( - pnpmOptions.globalOnlyBuiltDependencies || - pnpmOptions.globalNeverBuiltDependencies - ) { + } else if (pnpmOptions.globalOnlyBuiltDependencies || pnpmOptions.globalNeverBuiltDependencies) { // Backward compatibility: convert globalOnlyBuiltDependencies/globalNeverBuiltDependencies // to allowBuilds format for pnpm 11+ const allowBuilds: Record = {}; @@ -509,6 +515,23 @@ export class WorkspaceInstallManager extends BaseInstallManager { ); } + if (EnvironmentConfiguration.pnpmGlobalVirtualStore) { + const globalVirtualStoreWarning: string | undefined = + WorkspaceInstallManager._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.setEnableGlobalVirtualStore(true); + } + // Save the generated workspace file. Don't update the file timestamp unless the content has changed, // since "rush install" will consider this timestamp workspaceFile.save(workspaceFile.workspaceFilename, { onlyIfChanged: true }); @@ -516,6 +539,49 @@ export class WorkspaceInstallManager extends BaseInstallManager { return { shrinkwrapIsUpToDate, shrinkwrapWarnings }; } + private static _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; + } + protected async canSkipInstallAsync( lastModifiedDate: Date, subspace: Subspace, diff --git a/libraries/rush-lib/src/logic/pnpm/PnpmWorkspaceFile.ts b/libraries/rush-lib/src/logic/pnpm/PnpmWorkspaceFile.ts index 850aaf1687..8701f0a22b 100644 --- a/libraries/rush-lib/src/logic/pnpm/PnpmWorkspaceFile.ts +++ b/libraries/rush-lib/src/logic/pnpm/PnpmWorkspaceFile.ts @@ -27,7 +27,8 @@ const yamlModule: typeof import('js-yaml') = Import.lazy('js-yaml', require); * "allowBuilds": { * "esbuild": true, * "fsevents": false - * } + * }, + * "enableGlobalVirtualStore": true * } */ interface IPnpmWorkspaceYaml { @@ -42,6 +43,12 @@ interface IPnpmWorkspaceYaml { * (SUPPORTED ONLY IN PNPM 11.0.0 AND NEWER) */ allowBuilds?: Record; + /** + * 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; } export class PnpmWorkspaceFile extends BaseWorkspaceFile { @@ -53,6 +60,7 @@ export class PnpmWorkspaceFile extends BaseWorkspaceFile { private _workspacePackages: Set; private _catalogs: Record> | undefined; private _allowBuilds: Record | undefined; + private _enableGlobalVirtualStore: boolean | undefined; /** * The PNPM workspace file is used to specify the location of workspaces relative to the root @@ -67,6 +75,7 @@ export class PnpmWorkspaceFile extends BaseWorkspaceFile { this._workspacePackages = new Set(); this._catalogs = undefined; this._allowBuilds = undefined; + this._enableGlobalVirtualStore = undefined; } /** @@ -86,6 +95,13 @@ export class PnpmWorkspaceFile extends BaseWorkspaceFile { this._allowBuilds = allowBuilds; } + /** + * Sets whether PNPM should use the global virtual store for this workspace. + */ + public setEnableGlobalVirtualStore(enableGlobalVirtualStore: boolean | undefined): void { + this._enableGlobalVirtualStore = enableGlobalVirtualStore; + } + /** @override */ public addPackage(packagePath: string): void { // Ensure the path is relative to the pnpm-workspace.yaml file @@ -115,6 +131,10 @@ export class PnpmWorkspaceFile extends BaseWorkspaceFile { workspaceYaml.allowBuilds = this._allowBuilds; } + if (this._enableGlobalVirtualStore) { + workspaceYaml.enableGlobalVirtualStore = true; + } + return yamlModule.dump(workspaceYaml, PNPM_SHRINKWRAP_YAML_FORMAT); } } 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 a113bcc0b8..c9684f392d 100644 --- a/libraries/rush-lib/src/logic/pnpm/test/PnpmWorkspaceFile.test.ts +++ b/libraries/rush-lib/src/logic/pnpm/test/PnpmWorkspaceFile.test.ts @@ -242,4 +242,48 @@ describe(PnpmWorkspaceFile.name, () => { expect(content).not.toContain('allowBuilds'); }); }); + + describe('global virtual store functionality', () => { + it('generates workspace file with enableGlobalVirtualStore', () => { + const workspaceFile: PnpmWorkspaceFile = new PnpmWorkspaceFile(workspaceFilePath); + workspaceFile.addPackage(path.join(projectsDir, 'app1')); + workspaceFile.setEnableGlobalVirtualStore(true); + + workspaceFile.save(workspaceFilePath, { onlyIfChanged: true }); + + const content: string = FileSystem.readFile(workspaceFilePath); + expect(content).toMatchSnapshot(); + }); + + it('generates workspace file with enableGlobalVirtualStore, allowBuilds, and catalogs', () => { + const workspaceFile: PnpmWorkspaceFile = new PnpmWorkspaceFile(workspaceFilePath); + workspaceFile.addPackage(path.join(projectsDir, 'app1')); + + workspaceFile.setCatalogs({ + default: { + react: '^18.0.0' + } + }); + workspaceFile.setAllowBuilds({ + esbuild: true + }); + workspaceFile.setEnableGlobalVirtualStore(true); + + workspaceFile.save(workspaceFilePath, { onlyIfChanged: true }); + + const content: string = FileSystem.readFile(workspaceFilePath); + expect(content).toMatchSnapshot(); + }); + + it('omits enableGlobalVirtualStore when disabled', () => { + const workspaceFile: PnpmWorkspaceFile = new PnpmWorkspaceFile(workspaceFilePath); + workspaceFile.addPackage(path.join(projectsDir, 'app1')); + workspaceFile.setEnableGlobalVirtualStore(false); + + workspaceFile.save(workspaceFilePath, { onlyIfChanged: true }); + + const content: string = FileSystem.readFile(workspaceFilePath); + expect(content).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 18f15e7456..8f52904eab 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 @@ -86,3 +86,22 @@ exports[`PnpmWorkspaceFile catalog functionality handles undefined catalog 1`] = - projects/app1 " `; + +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 +" +`; diff --git a/libraries/rush-lib/src/logic/test/BaseInstallManager.test.ts b/libraries/rush-lib/src/logic/test/BaseInstallManager.test.ts index 21a2f460d3..e201c429d4 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( @@ -44,6 +45,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'); @@ -129,4 +134,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 0000000000..78f845a9d3 --- /dev/null +++ b/libraries/rush-lib/src/logic/test/WorkspaceInstallManager.test.ts @@ -0,0 +1,237 @@ +// 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 } from '../installManager/WorkspaceInstallManager'; +import { PurgeManager } from '../PurgeManager'; +import type { IInstallManagerOptions } from '../base/BaseInstallManagerTypes'; +import type { PnpmStoreLocation } from '../pnpm/PnpmOptionsConfiguration'; + +interface IGlobalVirtualStoreValidationOptions { + pnpmVersion: string; + rushJsonFolder: string; + pnpmStore: PnpmStoreLocation; + pnpmStorePath: string; + pnpmStorePathOverride: string | undefined; + usePnpmSyncForInjectedDependencies: boolean | undefined; +} + +interface IWorkspaceInstallManagerWithValidation { + _validateGlobalVirtualStoreOptions(options: IGlobalVirtualStoreValidationOptions): string | undefined; +} + +class TestWorkspaceInstallManager extends WorkspaceInstallManager { + public async prepareCommonTempForTestAsync(subspace: Subspace): Promise { + await super.prepareCommonTempAsync(subspace, undefined); + } +} + +describe(WorkspaceInstallManager.name, () => { + describe('enableGlobalVirtualStore validation', () => { + const validateGlobalVirtualStoreOptions: ( + options: IGlobalVirtualStoreValidationOptions + ) => string | undefined = (WorkspaceInstallManager as unknown as IWorkspaceInstallManagerWithValidation) + ._validateGlobalVirtualStoreOptions; + + 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 originalPnpmStorePathOverride: string | undefined; + let originalPnpmGlobalVirtualStore: boolean; + 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 }); + originalPnpmStorePathOverride = EnvironmentConfiguration.pnpmStorePathOverride; + originalPnpmGlobalVirtualStore = EnvironmentConfiguration.pnpmGlobalVirtualStore; + EnvironmentConfiguration['_pnpmStorePathOverride'] = undefined; + EnvironmentConfiguration['_pnpmGlobalVirtualStore'] = false; + 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['_pnpmStorePathOverride'] = originalPnpmStorePathOverride; + EnvironmentConfiguration['_pnpmGlobalVirtualStore'] = originalPnpmGlobalVirtualStore; + 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}.` + ); + }); + }); +});