diff --git a/common/changes/@microsoft/rush/feat-pnpm-patch_2022-07-27-03-39.json b/common/changes/@microsoft/rush/feat-pnpm-patch_2022-07-27-03-39.json new file mode 100644 index 00000000000..427c8507823 --- /dev/null +++ b/common/changes/@microsoft/rush/feat-pnpm-patch_2022-07-27-03-39.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Add new commands \"rush-pnpm patch\" and \"rush-pnpm patch-commit\" for patching NPM packages when using the PNPM package manager (GitHub #3554)", + "type": "none" + } + ], + "packageName": "@microsoft/rush" +} \ No newline at end of file diff --git a/common/config/rush/version-policies.json b/common/config/rush/version-policies.json index 0ba2558f681..c6201947c8b 100644 --- a/common/config/rush/version-policies.json +++ b/common/config/rush/version-policies.json @@ -103,7 +103,7 @@ "policyName": "rush", "definitionName": "lockStepVersion", "version": "5.85.1", - "nextBump": "patch", + "nextBump": "minor", "mainProject": "@microsoft/rush" } ] diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index e634773cb18..7ced338390b 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -464,6 +464,7 @@ export interface _IPnpmOptionsJson extends IPackageManagerOptionsJsonBase { globalOverrides?: Record; // Warning: (ae-forgotten-export) The symbol "IPnpmPackageExtension" needs to be exported by the entry point index.d.ts globalPackageExtensions?: Record; + globalPatchedDependencies?: Record; // Warning: (ae-forgotten-export) The symbol "IPnpmPeerDependencyRules" needs to be exported by the entry point index.d.ts globalPeerDependencyRules?: IPnpmPeerDependencyRules; pnpmStore?: PnpmStoreOptions; @@ -715,7 +716,10 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration readonly globalNeverBuiltDependencies: string[] | undefined; readonly globalOverrides: Record | undefined; readonly globalPackageExtensions: Record | undefined; + get globalPatchedDependencies(): Record | undefined; readonly globalPeerDependencyRules: IPnpmPeerDependencyRules | undefined; + // (undocumented) + get jsonFilename(): string | undefined; // @internal (undocumented) static loadFromJsonFileOrThrow(jsonFilename: string, commonTempFolder: string): PnpmOptionsConfiguration; // @internal (undocumented) @@ -725,6 +729,7 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration readonly preventManualShrinkwrapChanges: boolean; readonly strictPeerDependencies: boolean; readonly unsupportedPackageJsonSettings: unknown | undefined; + updateGlobalPatchedDependencies(patchedDependencies: Record | undefined): void; readonly useWorkspaces: boolean; } @@ -925,6 +930,7 @@ export class RushConstants { static readonly pnpmConfigFilename: string; static readonly pnpmfileV1Filename: string; static readonly pnpmfileV6Filename: string; + static readonly pnpmPatchesFolderName: string; static readonly pnpmV3ShrinkwrapFilename: string; static readonly projectRushFolderName: string; static readonly projectShrinkwrapFilename: string; diff --git a/libraries/rush-lib/assets/rush-init/common/config/rush/pnpm-config.json b/libraries/rush-lib/assets/rush-init/common/config/rush/pnpm-config.json index 0a6c37669e6..924e4ed6bb9 100644 --- a/libraries/rush-lib/assets/rush-init/common/config/rush/pnpm-config.json +++ b/libraries/rush-lib/assets/rush-init/common/config/rush/pnpm-config.json @@ -177,6 +177,16 @@ /*[LINE "HYPOTHETICAL"]*/ "request": "*" }, + + /** + * (THIS FIELD IS MACHINE GENERATED) The "globalPatchedDependencies" field is updated automatically + * by the `rush-pnpm patch-commit` command. It is a dictionary, where the key is an NPM package name + * and exact version, and the value is a relative path to the associated patch file. + * + * PNPM documentation: https://pnpm.io/package_json#pnpmpatcheddependencies + */ + "globalPatchedDependencies": { }, + /** * (USE AT YOUR OWN RISK) This is a free-form property bag that will be copied into * the `common/temp/package.json` file that is generated by Rush during installation. diff --git a/libraries/rush-lib/src/cli/RushPnpmCommandLine.ts b/libraries/rush-lib/src/cli/RushPnpmCommandLine.ts index 52eed439976..5d755e9c4b1 100644 --- a/libraries/rush-lib/src/cli/RushPnpmCommandLine.ts +++ b/libraries/rush-lib/src/cli/RushPnpmCommandLine.ts @@ -1,276 +1,16 @@ // 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 'path'; -import { - FileSystem, - AlreadyReportedError, - Executable, - EnvironmentMap, - ITerminal, - Colors, - Terminal, - ConsoleTerminalProvider -} from '@rushstack/node-core-library'; -import { PrintUtilities } from '@rushstack/terminal'; - -import { RushConfiguration } from '../api/RushConfiguration'; -import { NodeJsCompatibility } from '../logic/NodeJsCompatibility'; -import { SpawnSyncReturns } from 'child_process'; import { ILaunchOptions } from '../api/Rush'; +import { RushPnpmCommandLineParser } from './RushPnpmCommandLineParser'; export interface ILaunchRushPnpmInternalOptions extends ILaunchOptions {} -const RUSH_SKIP_CHECKS_PARAMETER: string = '--rush-skip-checks'; - export class RushPnpmCommandLine { public static launch(launcherVersion: string, options: ILaunchRushPnpmInternalOptions): void { - // Node.js can sometimes accidentally terminate with a zero exit code (e.g. for an uncaught - // promise exception), so we start with the assumption that the exit code is 1 - // and set it to 0 only on success. - process.exitCode = 1; - - const { terminalProvider } = options; - const terminal: ITerminal = new Terminal(terminalProvider ?? new ConsoleTerminalProvider()); - - try { - // Are we in a Rush repo? - let rushConfiguration: RushConfiguration | undefined = undefined; - if (RushConfiguration.tryFindRushJsonLocation()) { - // showVerbose is false because the logging message may break JSON output - rushConfiguration = RushConfiguration.loadFromDefaultLocation({ showVerbose: false }); - } - - NodeJsCompatibility.warnAboutCompatibilityIssues({ - isRushLib: true, - alreadyReportedNodeTooNewError: !!options.alreadyReportedNodeTooNewError, - rushConfiguration - }); - - if (!rushConfiguration) { - throw new Error( - 'The "rush-pnpm" command must be executed in a folder that is under a Rush workspace folder' - ); - } - - if (rushConfiguration.packageManager !== 'pnpm') { - throw new Error( - 'The "rush-pnpm" command requires your rush.json to be configured to use the PNPM package manager' - ); - } - - if (!rushConfiguration.pnpmOptions.useWorkspaces) { - throw new Error( - 'The "rush-pnpm" command requires the "useWorkspaces" setting to be enabled in rush.json' - ); - } - - const workspaceFolder: string = rushConfiguration.commonTempFolder; - const workspaceFilePath: string = path.join(workspaceFolder, 'pnpm-workspace.yaml'); - - if (!FileSystem.exists(workspaceFilePath)) { - terminal.writeErrorLine('Error: The PNPM workspace file has not been generated:'); - terminal.writeErrorLine(` ${workspaceFilePath}\n`); - terminal.writeLine(Colors.cyan(`Do you need to run "rush install" or "rush update"?`)); - throw new AlreadyReportedError(); - } - - if (!FileSystem.exists(rushConfiguration.packageManagerToolFilename)) { - terminal.writeErrorLine('Error: The PNPM local binary has not been installed yet.'); - terminal.writeLine('\n' + Colors.cyan(`Do you need to run "rush install" or "rush update"?`)); - throw new AlreadyReportedError(); - } - - // 0 = node.exe - // 1 = rush-pnpm - const pnpmArgs: string[] = process.argv.slice(2); - - RushPnpmCommandLine._validatePnpmUsage(pnpmArgs, terminal); - - const pnpmEnvironmentMap: EnvironmentMap = new EnvironmentMap(process.env); - pnpmEnvironmentMap.set('NPM_CONFIG_WORKSPACE_DIR', workspaceFolder); - - if (rushConfiguration.pnpmOptions.pnpmStorePath) { - pnpmEnvironmentMap.set('NPM_CONFIG_STORE_DIR', rushConfiguration.pnpmOptions.pnpmStorePath); - } - - if (rushConfiguration.pnpmOptions.environmentVariables) { - for (const [envKey, { value: envValue, override }] of Object.entries( - rushConfiguration.pnpmOptions.environmentVariables - )) { - if (override) { - pnpmEnvironmentMap.set(envKey, envValue); - } else { - if (undefined === pnpmEnvironmentMap.get(envKey)) { - pnpmEnvironmentMap.set(envKey, envValue); - } - } - } - } - - const result: SpawnSyncReturns = Executable.spawnSync( - rushConfiguration.packageManagerToolFilename, - pnpmArgs, - { - environmentMap: pnpmEnvironmentMap, - stdio: 'inherit' - } - ); - if (result.error) { - throw new Error('Failed to invoke PNPM: ' + result.error); - } - if (result.status === null) { - throw new Error('Failed to invoke PNPM: Spawn completed without an exit code'); - } - process.exitCode = result.status; - } catch (error) { - if (!(error instanceof AlreadyReportedError)) { - const prefix: string = 'ERROR: '; - terminal.writeErrorLine('\n' + PrintUtilities.wrapWords(prefix + error.message)); - } - } - } - - private static _validatePnpmUsage(pnpmArgs: string[], terminal: ITerminal): void { - if (pnpmArgs[0] === RUSH_SKIP_CHECKS_PARAMETER) { - pnpmArgs.shift(); - // Ignore other checks - return; - } - - if (pnpmArgs.length === 0) { - return; - } - const firstArg: string = pnpmArgs[0]; - - // Detect common safe invocations - if (pnpmArgs.includes('-h') || pnpmArgs.includes('--help') || pnpmArgs.includes('-?')) { - return; - } - - if (pnpmArgs.length === 1) { - if (firstArg === '-v' || firstArg === '--version') { - return; - } - } - - const BYPASS_NOTICE: string = `To bypass this check, add "${RUSH_SKIP_CHECKS_PARAMETER}" as the very first command line option.`; - - if (!/^[a-z]+([a-z0-9\-])*$/.test(firstArg)) { - // We can't parse this CLI syntax - terminal.writeErrorLine( - `Warning: The "rush-pnpm" wrapper expects a command verb before "${firstArg}"\n` - ); - terminal.writeLine(Colors.cyan(BYPASS_NOTICE)); - throw new AlreadyReportedError(); - } else { - const commandName: string = firstArg; - - // Also accept SKIP_RUSH_CHECKS_PARAMETER immediately after the command verb - if (pnpmArgs[1] === RUSH_SKIP_CHECKS_PARAMETER) { - pnpmArgs.splice(1, 1); - return; - } - - if (pnpmArgs.indexOf(RUSH_SKIP_CHECKS_PARAMETER) >= 0) { - // We do not attempt to parse PNPM's complete CLI syntax, so we cannot be sure how to interpret - // strings that appear outside of the specific patterns that this parser recognizes - terminal.writeErrorLine( - PrintUtilities.wrapWords( - `Error: The "${RUSH_SKIP_CHECKS_PARAMETER}" option must be the first parameter for the "rush-pnpm" command.` - ) - ); - throw new AlreadyReportedError(); - } - - // Warn about commands known not to work - /* eslint-disable no-fallthrough */ - switch (commandName) { - // Blocked - case 'import': { - terminal.writeErrorLine( - PrintUtilities.wrapWords( - `Error: The "pnpm ${commandName}" command is known to be incompatible with Rush's environment.` - ) + '\n' - ); - terminal.writeLine(Colors.cyan(BYPASS_NOTICE)); - throw new AlreadyReportedError(); - } - - // Show warning for install commands - case 'add': - case 'install': - /* synonym */ - case 'i': - case 'install-test': - /* synonym */ - case 'it': { - terminal.writeErrorLine( - PrintUtilities.wrapWords( - `Error: The "pnpm ${commandName}" command is incompatible with Rush's environment.` + - ` Use the "rush install" or "rush update" commands instead.` - ) + '\n' - ); - terminal.writeLine(Colors.cyan(BYPASS_NOTICE)); - throw new AlreadyReportedError(); - } - - // Show warning - case 'link': - /* synonym */ - case 'ln': - case 'remove': - /* synonym */ - case 'rm': - case 'unlink': - case 'update': - /* synonym */ - case 'up': { - terminal.writeWarningLine( - PrintUtilities.wrapWords( - `Warning: The "pnpm ${commandName}" command makes changes that may invalidate Rush's workspace state.` - ) + '\n' - ); - terminal.writeWarningLine(`==> Consider running "rush install" or "rush update" afterwards.\n`); - break; - } - - // Known safe - case 'audit': - case 'exec': - case 'list': - /* synonym */ - case 'ls': - case 'outdated': - case 'pack': - case 'prune': - case 'publish': - case 'rebuild': - /* synonym */ - case 'rb': - case 'root': - case 'run': - case 'start': - case 'store': - case 'test': - /* synonym */ - case 't': - case 'why': { - break; - } + const rushPnpmCommandLineParser: RushPnpmCommandLineParser = new RushPnpmCommandLineParser(options); - // Unknown - default: { - terminal.writeErrorLine( - PrintUtilities.wrapWords( - `Error: The "pnpm ${commandName}" command has not been tested with Rush's environment. It may be incompatible.` - ) + '\n' - ); - terminal.writeLine(Colors.cyan(BYPASS_NOTICE)); - throw new AlreadyReportedError(); - } - } - /* eslint-enable no-fallthrough */ - } + // RushPnpmCommandLineParser.executeAsync should never reject the promise + rushPnpmCommandLineParser.executeAsync().catch(console.error); } } diff --git a/libraries/rush-lib/src/cli/RushPnpmCommandLineParser.ts b/libraries/rush-lib/src/cli/RushPnpmCommandLineParser.ts new file mode 100644 index 00000000000..a818dc369d5 --- /dev/null +++ b/libraries/rush-lib/src/cli/RushPnpmCommandLineParser.ts @@ -0,0 +1,472 @@ +// 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 'path'; +import * as os from 'os'; +import { RushConfiguration } from '../api/RushConfiguration'; +import { NodeJsCompatibility } from '../logic/NodeJsCompatibility'; +import { + AlreadyReportedError, + Colors, + ConsoleTerminalProvider, + EnvironmentMap, + Executable, + FileConstants, + FileSystem, + Import, + ITerminal, + ITerminalProvider, + JsonFile, + JsonObject, + Terminal +} from '@rushstack/node-core-library'; +import { PrintUtilities } from '@rushstack/terminal'; +import { RushConstants } from '../logic/RushConstants'; +import { RushGlobalFolder } from '../api/RushGlobalFolder'; +import { PurgeManager } from '../logic/PurgeManager'; + +import type { IBuiltInPluginConfiguration } from '../pluginFramework/PluginLoader/BuiltInPluginLoader'; +import type { SpawnSyncReturns } from 'child_process'; +import type { BaseInstallManager, IInstallManagerOptions } from '../logic/base/BaseInstallManager'; + +const lodash: typeof import('lodash') = Import.lazy('lodash', require); +const semver: typeof import('semver') = Import.lazy('semver', require); +const installManagerFactoryModule: typeof import('../logic/InstallManagerFactory') = Import.lazy( + '../logic/InstallManagerFactory', + require +); + +const RUSH_SKIP_CHECKS_PARAMETER: string = '--rush-skip-checks'; + +/** + * Options for RushPnpmCommandLineParser + */ +export interface IRushPnpmCommandLineParserOptions { + alreadyReportedNodeTooNewError?: boolean; + builtInPluginConfigurations?: IBuiltInPluginConfiguration[]; + terminalProvider?: ITerminalProvider; +} + +export class RushPnpmCommandLineParser { + private _terminal: ITerminal; + private _rushConfiguration!: RushConfiguration; + private _pnpmArgs!: string[]; + private _commandName: string | undefined; + private _debugEnabled: boolean; + private _verboseEnabled: boolean; + + public constructor(options: IRushPnpmCommandLineParserOptions) { + const { terminalProvider } = options; + this._debugEnabled = process.argv.indexOf('--debug') >= 0; + this._verboseEnabled = process.argv.indexOf('--verbose') >= 0; + const localTerminalProvider: ITerminalProvider = + terminalProvider ?? + new ConsoleTerminalProvider({ + debugEnabled: this._debugEnabled, + verboseEnabled: this._verboseEnabled + }); + this._terminal = new Terminal(localTerminalProvider); + + try { + // Are we in a Rush repo? + let rushConfiguration: RushConfiguration | undefined = undefined; + if (RushConfiguration.tryFindRushJsonLocation()) { + // showVerbose is false because the logging message may break JSON output + rushConfiguration = RushConfiguration.loadFromDefaultLocation({ showVerbose: false }); + } + + NodeJsCompatibility.warnAboutCompatibilityIssues({ + isRushLib: true, + alreadyReportedNodeTooNewError: !!options.alreadyReportedNodeTooNewError, + rushConfiguration + }); + + if (!rushConfiguration) { + throw new Error( + 'The "rush-pnpm" command must be executed in a folder that is under a Rush workspace folder' + ); + } + this._rushConfiguration = rushConfiguration; + + if (rushConfiguration.packageManager !== 'pnpm') { + throw new Error( + 'The "rush-pnpm" command requires your rush.json to be configured to use the PNPM package manager' + ); + } + + if (!rushConfiguration.pnpmOptions.useWorkspaces) { + const pnpmConfigFilename: string = rushConfiguration.pnpmOptions.jsonFilename || 'rush.json'; + throw new Error( + `The "rush-pnpm" command requires the "useWorkspaces" setting to be enabled in ${pnpmConfigFilename}` + ); + } + + const workspaceFolder: string = rushConfiguration.commonTempFolder; + const workspaceFilePath: string = path.join(workspaceFolder, 'pnpm-workspace.yaml'); + + if (!FileSystem.exists(workspaceFilePath)) { + this._terminal.writeErrorLine('Error: The PNPM workspace file has not been generated:'); + this._terminal.writeErrorLine(` ${workspaceFilePath}\n`); + this._terminal.writeLine(Colors.cyan(`Do you need to run "rush install" or "rush update"?`)); + throw new AlreadyReportedError(); + } + + if (!FileSystem.exists(rushConfiguration.packageManagerToolFilename)) { + this._terminal.writeErrorLine('Error: The PNPM local binary has not been installed yet.'); + this._terminal.writeLine('\n' + Colors.cyan(`Do you need to run "rush install" or "rush update"?`)); + throw new AlreadyReportedError(); + } + + // 0 = node.exe + // 1 = rush-pnpm + const pnpmArgs: string[] = process.argv.slice(2); + + this._validatePnpmUsage(pnpmArgs); + + this._pnpmArgs = pnpmArgs; + } catch (error) { + this._reportErrorAndSetExitCode(error as Error); + } + } + + public async executeAsync(): Promise { + // Node.js can sometimes accidentally terminate with a zero exit code (e.g. for an uncaught + // promise exception), so we start with the assumption that the exit code is 1 + // and set it to 0 only on success. + process.exitCode = 1; + this._execute(); + + if (process.exitCode === 0) { + await this._postExecuteAsync(); + } + } + + private _validatePnpmUsage(pnpmArgs: string[]): void { + if (pnpmArgs[0] === RUSH_SKIP_CHECKS_PARAMETER) { + pnpmArgs.shift(); + // Ignore other checks + return; + } + + if (pnpmArgs.length === 0) { + return; + } + const firstArg: string = pnpmArgs[0]; + + // Detect common safe invocations + if (pnpmArgs.includes('-h') || pnpmArgs.includes('--help') || pnpmArgs.includes('-?')) { + return; + } + + if (pnpmArgs.length === 1) { + if (firstArg === '-v' || firstArg === '--version') { + return; + } + } + + const BYPASS_NOTICE: string = `To bypass this check, add "${RUSH_SKIP_CHECKS_PARAMETER}" as the very first command line option.`; + + if (!/^[a-z]+([a-z0-9\-])*$/.test(firstArg)) { + // We can't parse this CLI syntax + this._terminal.writeErrorLine( + `Warning: The "rush-pnpm" wrapper expects a command verb before "${firstArg}"\n` + ); + this._terminal.writeLine(Colors.cyan(BYPASS_NOTICE)); + throw new AlreadyReportedError(); + } else { + const commandName: string = firstArg; + + // Also accept SKIP_RUSH_CHECKS_PARAMETER immediately after the command verb + if (pnpmArgs[1] === RUSH_SKIP_CHECKS_PARAMETER) { + pnpmArgs.splice(1, 1); + return; + } + + if (pnpmArgs.indexOf(RUSH_SKIP_CHECKS_PARAMETER) >= 0) { + // We do not attempt to parse PNPM's complete CLI syntax, so we cannot be sure how to interpret + // strings that appear outside of the specific patterns that this parser recognizes + this._terminal.writeErrorLine( + PrintUtilities.wrapWords( + `Error: The "${RUSH_SKIP_CHECKS_PARAMETER}" option must be the first parameter for the "rush-pnpm" command.` + ) + ); + throw new AlreadyReportedError(); + } + + this._commandName = commandName; + + // Warn about commands known not to work + /* eslint-disable no-fallthrough */ + switch (commandName) { + // Blocked + case 'import': { + this._terminal.writeErrorLine( + PrintUtilities.wrapWords( + `Error: The "pnpm ${commandName}" command is known to be incompatible with Rush's environment.` + ) + '\n' + ); + this._terminal.writeLine(Colors.cyan(BYPASS_NOTICE)); + throw new AlreadyReportedError(); + } + + // Show warning for install commands + case 'add': + case 'install': + /* synonym */ + case 'i': + case 'install-test': + /* synonym */ + case 'it': { + this._terminal.writeErrorLine( + PrintUtilities.wrapWords( + `Error: The "pnpm ${commandName}" command is incompatible with Rush's environment.` + + ` Use the "rush install" or "rush update" commands instead.` + ) + '\n' + ); + this._terminal.writeLine(Colors.cyan(BYPASS_NOTICE)); + throw new AlreadyReportedError(); + } + + // Show warning + case 'link': + /* synonym */ + case 'ln': + case 'remove': + /* synonym */ + case 'rm': + case 'unlink': + case 'update': + /* synonym */ + case 'up': { + this._terminal.writeWarningLine( + PrintUtilities.wrapWords( + `Warning: The "pnpm ${commandName}" command makes changes that may invalidate Rush's workspace state.` + ) + '\n' + ); + this._terminal.writeWarningLine( + `==> Consider running "rush install" or "rush update" afterwards.\n` + ); + break; + } + + // Know safe after validation + case 'patch': { + /** + * If you were to accidentally attempt to use rush-pnpm patch with a pnpmVersion < 7.4.0, pnpm patch may fallback to the system patch command. + * For instance, /usr/bin/patch which may just hangs forever + * So, erroring out the command if the pnpm version is < 7.4.0 + */ + if (semver.lt(this._rushConfiguration.packageManagerToolVersion, '7.4.0')) { + this._terminal.writeErrorLine( + PrintUtilities.wrapWords( + `Error: The "pnpm patch" command is added after pnpm@7.4.0.` + + ` Please update "pnpmVersion" >= 7.4.0 in rush.json file and run "rush update" to use this command.` + ) + '\n' + ); + throw new AlreadyReportedError(); + } + break; + } + case 'patch-commit': { + const pnpmOptionsJsonFilename: string = path.join( + this._rushConfiguration.commonRushConfigFolder, + RushConstants.pnpmConfigFilename + ); + if (this._rushConfiguration.rushConfigurationJson.pnpmOptions) { + this._terminal.writeErrorLine( + PrintUtilities.wrapWords( + `Error: The "pnpm patch-commit" command is incompatible with specifying "pnpmOptions" in rush.json file.` + + ` Please move the content of "pnpmOptions" in rush.json file to ${pnpmOptionsJsonFilename}` + ) + '\n' + ); + throw new AlreadyReportedError(); + } + break; + } + + // Known safe + case 'audit': + case 'exec': + case 'list': + /* synonym */ + case 'ls': + case 'outdated': + case 'pack': + case 'prune': + case 'publish': + case 'rebuild': + /* synonym */ + case 'rb': + case 'root': + case 'run': + case 'start': + case 'store': + case 'test': + /* synonym */ + case 't': + case 'why': { + break; + } + + // Unknown + default: { + this._terminal.writeErrorLine( + PrintUtilities.wrapWords( + `Error: The "pnpm ${commandName}" command has not been tested with Rush's environment. It may be incompatible.` + ) + '\n' + ); + this._terminal.writeLine(Colors.cyan(BYPASS_NOTICE)); + } + } + /* eslint-enable no-fallthrough */ + } + } + + private _execute(): void { + const rushConfiguration: RushConfiguration = this._rushConfiguration; + const workspaceFolder: string = rushConfiguration.commonTempFolder; + const pnpmEnvironmentMap: EnvironmentMap = new EnvironmentMap(process.env); + pnpmEnvironmentMap.set('NPM_CONFIG_WORKSPACE_DIR', workspaceFolder); + + if (rushConfiguration.pnpmOptions.pnpmStorePath) { + pnpmEnvironmentMap.set('NPM_CONFIG_STORE_DIR', rushConfiguration.pnpmOptions.pnpmStorePath); + } + + if (rushConfiguration.pnpmOptions.environmentVariables) { + for (const [envKey, { value: envValue, override }] of Object.entries( + rushConfiguration.pnpmOptions.environmentVariables + )) { + if (override) { + pnpmEnvironmentMap.set(envKey, envValue); + } else { + if (undefined === pnpmEnvironmentMap.get(envKey)) { + pnpmEnvironmentMap.set(envKey, envValue); + } + } + } + } + + const result: SpawnSyncReturns = Executable.spawnSync( + rushConfiguration.packageManagerToolFilename, + this._pnpmArgs, + { + environmentMap: pnpmEnvironmentMap, + stdio: 'inherit' + } + ); + if (result.error) { + throw new Error('Failed to invoke PNPM: ' + result.error); + } + if (result.status === null) { + throw new Error('Failed to invoke PNPM: Spawn completed without an exit code'); + } + process.exitCode = result.status; + } + + private async _postExecuteAsync(): Promise { + const commandName: string | undefined = this._commandName; + if (!commandName) { + return; + } + + switch (commandName) { + case 'patch-commit': { + // Example: "C:\MyRepo\common\temp\package.json" + const commonPackageJsonFilename: string = `${this._rushConfiguration.commonTempFolder}/${FileConstants.PackageJson}`; + const commonPackageJson: JsonObject = JsonFile.load(commonPackageJsonFilename); + const newGlobalPatchedDependencies: Record | undefined = + commonPackageJson?.pnpm?.patchedDependencies; + const currentGlobalPatchedDependencies: Record | undefined = + this._rushConfiguration.pnpmOptions.globalPatchedDependencies; + + if (!lodash.isEqual(currentGlobalPatchedDependencies, newGlobalPatchedDependencies)) { + const commonTempPnpmPatchesFolder: string = `${this._rushConfiguration.commonTempFolder}/${RushConstants.pnpmPatchesFolderName}`; + const rushPnpmPatchesFolder: string = `${this._rushConfiguration.commonFolder}/pnpm-${RushConstants.pnpmPatchesFolderName}`; + // Copy (or delete) common\temp\patches\ --> common\pnpm-patches\ + if (FileSystem.exists(commonTempPnpmPatchesFolder)) { + FileSystem.ensureEmptyFolder(rushPnpmPatchesFolder); + console.log(`Copying ${commonTempPnpmPatchesFolder}`); + console.log(` --> ${rushPnpmPatchesFolder}`); + FileSystem.copyFiles({ + sourcePath: commonTempPnpmPatchesFolder, + destinationPath: rushPnpmPatchesFolder + }); + } else { + if (FileSystem.exists(rushPnpmPatchesFolder)) { + console.log(`Deleting ${rushPnpmPatchesFolder}`); + FileSystem.deleteFolder(rushPnpmPatchesFolder); + } + } + + // Update patchedDependencies to pnpm configuration file + this._rushConfiguration.pnpmOptions.updateGlobalPatchedDependencies(newGlobalPatchedDependencies); + + // Rerun installation to update + await this._doRushUpdateAsync(); + + this._terminal.writeWarningLine( + `Rush refreshed the ${RushConstants.pnpmConfigFilename}, shrinkwrap file and patch files under the "common/pnpm/patches" folder.` + + os.EOL + + ' Please commit this change to Git.' + ); + } + break; + } + } + } + + private async _doRushUpdateAsync(): Promise { + this._terminal.writeLine(); + this._terminal.writeLine(Colors.green('Running "rush update"')); + this._terminal.writeLine(); + + const rushGlobalFolder: RushGlobalFolder = new RushGlobalFolder(); + const purgeManager: PurgeManager = new PurgeManager(this._rushConfiguration, rushGlobalFolder); + const installManagerOptions: IInstallManagerOptions = { + debug: this._debugEnabled, + allowShrinkwrapUpdates: true, + bypassPolicy: false, + noLink: false, + fullUpgrade: false, + recheckShrinkwrap: true, + networkConcurrency: undefined, + collectLogFile: false, + variant: undefined, + maxInstallAttempts: RushConstants.defaultMaxInstallAttempts, + pnpmFilterArguments: [], + checkOnly: false + }; + + const installManager: BaseInstallManager = + installManagerFactoryModule.InstallManagerFactory.getInstallManager( + this._rushConfiguration, + rushGlobalFolder, + purgeManager, + installManagerOptions + ); + try { + await installManager.doInstallAsync(); + } finally { + purgeManager.deleteAll(); + } + } + + private _reportErrorAndSetExitCode(error: Error): void { + if (!(error instanceof AlreadyReportedError)) { + const prefix: string = 'ERROR: '; + this._terminal.writeErrorLine('\n' + PrintUtilities.wrapWords(prefix + error.message)); + } + + if (this._debugEnabled) { + // If catchSyncErrors() called this, then show a call stack similar to what Node.js + // would show for an uncaught error + this._terminal.writeErrorLine('\n' + error.stack); + } + + if (process.exitCode !== undefined) { + process.exit(process.exitCode); + } else { + process.exit(1); + } + } +} diff --git a/libraries/rush-lib/src/logic/InstallManagerFactory.ts b/libraries/rush-lib/src/logic/InstallManagerFactory.ts index 4307e0dbca3..2115bcfcbc2 100644 --- a/libraries/rush-lib/src/logic/InstallManagerFactory.ts +++ b/libraries/rush-lib/src/logic/InstallManagerFactory.ts @@ -2,12 +2,13 @@ // See LICENSE in the project root for license information. import { Import } from '@rushstack/node-core-library'; -import { BaseInstallManager, IInstallManagerOptions } from './base/BaseInstallManager'; import { WorkspaceInstallManager } from './installManager/WorkspaceInstallManager'; import { PurgeManager } from './PurgeManager'; import { RushConfiguration } from '../api/RushConfiguration'; import { RushGlobalFolder } from '../api/RushGlobalFolder'; +import type { BaseInstallManager, IInstallManagerOptions } from './base/BaseInstallManager'; + const rushInstallManagerModule: typeof import('./installManager/RushInstallManager') = Import.lazy( './installManager/RushInstallManager', require diff --git a/libraries/rush-lib/src/logic/RushConstants.ts b/libraries/rush-lib/src/logic/RushConstants.ts index 47f59d0df3a..539dd56d993 100644 --- a/libraries/rush-lib/src/logic/RushConstants.ts +++ b/libraries/rush-lib/src/logic/RushConstants.ts @@ -91,6 +91,13 @@ export class RushConstants { */ public static readonly pnpmfileV6Filename: string = '.pnpmfile.cjs'; + /** + * The folder name used to store patch files for pnpm + * Example: `C:\MyRepo\common\config\pnpm-patches` + * Example: `C:\MyRepo\common\temp\patches` + */ + public static readonly pnpmPatchesFolderName: string = 'patches'; + /** * The filename ("shrinkwrap.yaml") used to store state for pnpm */ diff --git a/libraries/rush-lib/src/logic/base/BaseInstallManager.ts b/libraries/rush-lib/src/logic/base/BaseInstallManager.ts index 50e5b195754..de846ed56ea 100644 --- a/libraries/rush-lib/src/logic/base/BaseInstallManager.ts +++ b/libraries/rush-lib/src/logic/base/BaseInstallManager.ts @@ -435,6 +435,18 @@ export abstract class BaseInstallManager { ? crypto.createHash('sha1').update(npmrcText).digest('hex') : undefined; + // Copy the committed patches folder if using pnpm + if (this.rushConfiguration.packageManager === 'pnpm') { + const commonTempPnpmPatchesFolder: string = `${this._rushConfiguration.commonTempFolder}/${RushConstants.pnpmPatchesFolderName}`; + const rushPnpmPatchesFolder: string = `${this._rushConfiguration.commonFolder}/pnpm-${RushConstants.pnpmPatchesFolderName}`; + if (FileSystem.exists(rushPnpmPatchesFolder)) { + FileSystem.copyFiles({ + sourcePath: rushPnpmPatchesFolder, + destinationPath: commonTempPnpmPatchesFolder + }); + } + } + // Shim support for pnpmfile in. This shim will call back into the variant-specific pnpmfile. // Additionally when in workspaces, the shim implements support for common versions. if (this.rushConfiguration.packageManager === 'pnpm') { diff --git a/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts b/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts index 5e3240023b2..5d7f43465b9 100644 --- a/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts +++ b/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts @@ -64,6 +64,9 @@ export class InstallHelpers { pnpmOptions.globalAllowedDeprecatedVersions ); } + if (pnpmOptions.globalPatchedDependencies) { + lodash.set(commonPackageJson, 'pnpm.patchedDependencies', pnpmOptions.globalPatchedDependencies); + } if (pnpmOptions.unsupportedPackageJsonSettings) { lodash.merge(commonPackageJson, pnpmOptions.unsupportedPackageJsonSettings); } diff --git a/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts b/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts index 40a65dae4a2..2424dbeaf1f 100644 --- a/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts +++ b/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts @@ -2,7 +2,7 @@ // See LICENSE in the project root for license information. import * as path from 'path'; -import { JsonFile, JsonSchema } from '@rushstack/node-core-library'; +import { JsonFile, JsonObject, JsonSchema } from '@rushstack/node-core-library'; import { IPackageManagerOptionsJsonBase, PackageManagerOptionsConfigurationBase @@ -78,6 +78,10 @@ export interface IPnpmOptionsJson extends IPackageManagerOptionsJsonBase { * {@inheritDoc PnpmOptionsConfiguration.globalAllowedDeprecatedVersions} */ globalAllowedDeprecatedVersions?: Record; + /** + * {@inheritDoc PnpmOptionsConfiguration.globalPatchedDependencies} + */ + globalPatchedDependencies?: Record; /** * {@inheritDoc PnpmOptionsConfiguration.unsupportedPackageJsonSettings} */ @@ -100,6 +104,10 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration path.resolve(__dirname, '../../schemas/pnpm-config.schema.json') ); + private _json: JsonObject; + private _jsonFilename: string | undefined; + private _globalPatchedDependencies: Record | undefined; + /** * The method used to resolve the store used by PNPM. * @@ -241,8 +249,23 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration */ public readonly unsupportedPackageJsonSettings: unknown | undefined; - private constructor(json: IPnpmOptionsJson, commonTempFolder: string) { + /** + * (GENERATED BY RUSH-PNPM PATCH-COMMIT) When modifying this property, make sure you know what you are doing. + * + * The `globalPatchedDependencies` is added/updated automatically when you run pnpm patch-commit + * command. It is a dictionary where the key should be the package name and exact version. The value + * should be a relative path to a patch file. + * + * PNPM documentation: https://pnpm.io/package_json#pnpmpatcheddependencies + */ + public get globalPatchedDependencies(): Record | undefined { + return this._globalPatchedDependencies; + } + + private constructor(json: IPnpmOptionsJson, commonTempFolder: string, jsonFilename?: string) { super(json); + this._json = json; + this._jsonFilename = jsonFilename; this.pnpmStore = json.pnpmStore || 'local'; if (EnvironmentConfiguration.pnpmStorePathOverride) { this.pnpmStorePath = EnvironmentConfiguration.pnpmStorePathOverride; @@ -261,6 +284,7 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration this.globalNeverBuiltDependencies = json.globalNeverBuiltDependencies; this.globalAllowedDeprecatedVersions = json.globalAllowedDeprecatedVersions; this.unsupportedPackageJsonSettings = json.unsupportedPackageJsonSettings; + this._globalPatchedDependencies = json.globalPatchedDependencies; } /** @internal */ @@ -272,7 +296,7 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration jsonFilename, PnpmOptionsConfiguration._jsonSchema ); - return new PnpmOptionsConfiguration(pnpmOptionJson || {}, commonTempFolder); + return new PnpmOptionsConfiguration(pnpmOptionJson || {}, commonTempFolder, jsonFilename); } /** @internal */ @@ -282,4 +306,19 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration ): PnpmOptionsConfiguration { return new PnpmOptionsConfiguration(json, commonTempFolder); } + + /** + * Updates patchedDependencies field of the PNPM options in the common/config/rush/pnpm-config.json file. + */ + public updateGlobalPatchedDependencies(patchedDependencies: Record | undefined): void { + this._globalPatchedDependencies = patchedDependencies; + this._json.globalPatchedDependencies = patchedDependencies; + if (this._jsonFilename) { + JsonFile.save(this._json, this._jsonFilename, { updateExistingFile: true }); + } + } + + public get jsonFilename(): string | undefined { + return this._jsonFilename; + } } diff --git a/libraries/rush-lib/src/schemas/pnpm-config.schema.json b/libraries/rush-lib/src/schemas/pnpm-config.schema.json index b49e830c904..5adbe10e074 100644 --- a/libraries/rush-lib/src/schemas/pnpm-config.schema.json +++ b/libraries/rush-lib/src/schemas/pnpm-config.schema.json @@ -148,6 +148,14 @@ } }, + "globalPatchedDependencies": { + "description": "(THIS FIELD IS MACHINE GENERATED) The \"globalPatchedDependencies\" field is updated automatically by the `rush-pnpm patch-commit` command. It is a dictionary, where the key is an NPM package name and exact version, and the value is a relative path to the associated patch file.\n\nPNPM documentation: https://pnpm.io/package_json#pnpmpatcheddependencies", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "unsupportedPackageJsonSettings": { "description": "(USE AT YOUR OWN RISK) This is a free-form property bag that will be copied into the `common/temp/package.json` file that is generated by Rush during installation. This provides a way to experiment with new PNPM features. These settings will override any other Rush configuration associated with a given JSON field except for `.pnpmfile.cjs`.", "type": "object"