diff --git a/apps/rush/UPGRADING.md b/apps/rush/UPGRADING.md index 000e3bbb3f..eccb56d59c 100644 --- a/apps/rush/UPGRADING.md +++ b/apps/rush/UPGRADING.md @@ -1,5 +1,50 @@ # Upgrade notes for @microsoft/rush +### PNPM 11.6.0 and newer: migrate project `.npmrc` credentials + +PNPM 11.5.3 stopped expanding environment variables in registry credentials and request destinations +from a project or workspace `.npmrc`. The +`provideNpmrcCredentialsViaEnvironment` Rush experiment provides a compatibility workaround for PNPM +11.5.3 through versions earlier than 11.6.0, but PNPM 11.6.0 introduced a safer native replacement. + +Before upgrading to PNPM 11.6.0 or newer, replace committed credential settings such as: + +```ini +//registry.npmjs.org/:_authToken=${NPM_TOKEN} +``` + +with one of PNPM's trusted configuration mechanisms. The direct, file-free replacement is an +environment variable whose name includes the registry: + +```text +pnpm_config_//registry.npmjs.org/:_authToken= +``` + +The `/`, `:`, and `.` characters are part of the environment variable name. Operating-system child +process environments, including Windows environments, can carry these names, but many shells reject +them as assignment identifiers. On POSIX systems, use `env` rather than `export`: + +```sh +env "pnpm_config_//registry.npmjs.org/:_authToken=$NPM_TOKEN" rush install +``` + +CI systems may also provide an environment configuration interface that accepts arbitrary names. Rush +preserves the exact casing of URL-scoped `pnpm_config_//...` names on Windows because registry paths can +be case-sensitive. + +If the shell or CI system restricts environment variable names, use one of PNPM's other supported +approaches: + +- Write the credential to the user-level PNPM auth configuration before invoking Rush, for example + `pnpm config set "//registry.npmjs.org/:_authToken" "$NPM_TOKEN"`. +- Put the `${NPM_TOKEN}` setting in the user's `~/.npmrc` or a file selected by `npmrcAuthFile`. +- In CI that exclusively builds trusted repositories, set `PNPM_CONFIG_NPMRC_AUTH_FILE=.npmrc` to + explicitly treat the generated project `.npmrc` as trusted. This disables PNPM's repository + protection for that checkout. + +Dynamic registry and proxy URLs must also move out of the project `.npmrc` and into trusted user, +global, CLI, or environment configuration. + ### Rush 5.135.0 This release of Rush deprecates the `rush-project.json`'s `operationSettings.sharding.shardOperationSettings` diff --git a/common/changes/@microsoft/rush/copilot-npmrc-credentials-experiment_2026-08-28-05-19.json b/common/changes/@microsoft/rush/copilot-npmrc-credentials-experiment_2026-08-28-05-19.json new file mode 100644 index 0000000000..cad20218ea --- /dev/null +++ b/common/changes/@microsoft/rush/copilot-npmrc-credentials-experiment_2026-08-28-05-19.json @@ -0,0 +1,9 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Add a new `provideNpmrcCredentialsViaEnvironment` experiment for PNPM 10.34.2 through 10.x and PNPM 11.5.3 through versions earlier than 11.6.0. For these versions, Rush expands `${VAR}` tokens from the generated `.npmrc`, passing credentials using `npm_config_*` environment variables instead of writing them to disk. PNPM 11.6.0 and newer should instead receive URL-scoped `pnpm_config_//...` credentials directly from CI.", + "type": "minor" + } + ] +} diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index 1853464f8b..e1839f13f6 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -489,6 +489,7 @@ export interface IExperimentsJson { omitAppleDoubleFilesFromBuildCache?: boolean; omitImportersFromPreventManualShrinkwrapChanges?: boolean; printEventHooksOutputToConsole?: boolean; + provideNpmrcCredentialsViaEnvironment?: boolean; rushAlerts?: boolean; strictChangefileValidation?: boolean; trimRushEnvironmentVariablesForOperations?: boolean; diff --git a/libraries/rush-lib/assets/rush-init/common/config/rush/experiments.json b/libraries/rush-lib/assets/rush-init/common/config/rush/experiments.json index 5b959c4fed..a8c4c01cb4 100644 --- a/libraries/rush-lib/assets/rush-init/common/config/rush/experiments.json +++ b/libraries/rush-lib/assets/rush-init/common/config/rush/experiments.json @@ -150,5 +150,20 @@ * help prevent operation scripts from accidentally depending on Rush's own internal environment * variables. */ - /*[LINE "HYPOTHETICAL"]*/ "trimRushEnvironmentVariablesForOperations": true + /*[LINE "HYPOTHETICAL"]*/ "trimRushEnvironmentVariablesForOperations": true, + + /** + * PNPM 10.34.2 through 10.x and PNPM 11.5.3 through versions earlier than 11.6.0 ignore "${VAR}" + * tokens that appear in credentials and registry URLs in a project or workspace .npmrc file. If + * true for those versions, Rush expands the tokens itself: credentials are passed using + * "npm_config_*" environment variables instead of being written to the generated .npmrc file, and + * non-secret settings such as registry URLs are written with their values already expanded. PNPM + * 11.6.0 and newer support URL-scoped "pnpm_config_//..." environment variables, which should + * instead be supplied directly by CI so the trusted environment binds each credential to its + * registry. For example, supply an environment variable named + * "pnpm_config_//registry.npmjs.org/:_authToken" whose value is the registry token. Dynamic + * registry and proxy settings must likewise be supplied through trusted user, global, CLI, or + * environment configuration rather than a project .npmrc. + */ + /*[LINE "HYPOTHETICAL"]*/ "provideNpmrcCredentialsViaEnvironment": true } diff --git a/libraries/rush-lib/src/api/ExperimentsConfiguration.ts b/libraries/rush-lib/src/api/ExperimentsConfiguration.ts index f41cf7a033..658671e14c 100644 --- a/libraries/rush-lib/src/api/ExperimentsConfiguration.ts +++ b/libraries/rush-lib/src/api/ExperimentsConfiguration.ts @@ -162,6 +162,21 @@ export interface IExperimentsJson { * variables. */ trimRushEnvironmentVariablesForOperations?: boolean; + + /** + * If true, when using PNPM, Rush resolves the `${VAR}` tokens that appear in credentials and + * registry URLs in the `.npmrc` file, instead of relying on PNPM to expand them. Credentials are + * passed to PNPM using `npm_config_*` environment variables and are not written to the generated + * `.npmrc` file. + * + * @remarks + * This compatibility workaround applies to PNPM 10.34.2 through 10.x and PNPM 11.5.3 through + * versions earlier than 11.6.0. PNPM 11.6.0 and newer support URL-scoped `pnpm_config_//...` + * environment variables, which should be supplied directly by CI so the trusted environment binds + * each credential to its registry. Dynamic registry and proxy settings must likewise be supplied + * through trusted user, global, CLI, or environment configuration rather than a project `.npmrc`. + */ + provideNpmrcCredentialsViaEnvironment?: boolean; } const _EXPERIMENTS_JSON_SCHEMA: JsonSchema = JsonSchema.fromLoadedObject(schemaJson); diff --git a/libraries/rush-lib/src/cli/RushPnpmCommandLineParser.ts b/libraries/rush-lib/src/cli/RushPnpmCommandLineParser.ts index ecebe85de7..30dda7645a 100644 --- a/libraries/rush-lib/src/cli/RushPnpmCommandLineParser.ts +++ b/libraries/rush-lib/src/cli/RushPnpmCommandLineParser.ts @@ -30,6 +30,8 @@ import type { IBuiltInPluginConfiguration } from '../pluginFramework/PluginLoade import type { BaseInstallManager } from '../logic/base/BaseInstallManager'; import type { IInstallManagerOptions } from '../logic/base/BaseInstallManagerTypes'; import { Utilities } from '../utilities/Utilities'; +import { getNpmrcEnvironmentVariables } from '../utilities/npmrcUtilities'; +import { InstallHelpers } from '../logic/installManager/InstallHelpers'; import type { Subspace } from '../api/Subspace'; import type { PnpmOptionsConfiguration } from '../logic/pnpm/PnpmOptionsConfiguration'; import { PnpmWorkspaceFile } from '../logic/pnpm/PnpmWorkspaceFile'; @@ -476,6 +478,18 @@ export class RushPnpmCommandLineParser { } } + // Provide any credentials that "rush install" moved out of the generated .npmrc file. + // See the "provideNpmrcCredentialsViaEnvironment" experiment. + if (InstallHelpers.shouldProvideNpmrcCredentialsViaEnvironment(rushConfiguration)) { + const npmrcEnvironmentVariables: Record | undefined = getNpmrcEnvironmentVariables({ + npmrcFolder: workspaceFolder, + supportEnvVarFallbackSyntax: rushConfiguration.isPnpm + }); + for (const [envKey, envValue] of Object.entries(npmrcEnvironmentVariables ?? {})) { + pnpmEnvironmentMap.set(envKey, envValue); + } + } + let onStdoutStreamChunk: ((chunk: string) => string | void) | undefined; switch (this._commandName) { case 'patch': { diff --git a/libraries/rush-lib/src/logic/Autoinstaller.ts b/libraries/rush-lib/src/logic/Autoinstaller.ts index a47dd0d89b..87e3995157 100644 --- a/libraries/rush-lib/src/logic/Autoinstaller.ts +++ b/libraries/rush-lib/src/logic/Autoinstaller.ts @@ -16,6 +16,7 @@ import { Colorize } from '@rushstack/terminal'; import { AsyncRecycler } from '../utilities/AsyncRecycler'; import { Utilities } from '../utilities/Utilities'; +import { getNpmrcEnvironmentVariables } from '../utilities/npmrcUtilities'; import type { RushConfiguration } from '../api/RushConfiguration'; import { PackageJsonEditor } from '../api/PackageJsonEditor'; import { InstallHelpers } from './installManager/InstallHelpers'; @@ -143,7 +144,10 @@ export class Autoinstaller { Utilities.syncNpmrc({ sourceNpmrcFolder: this._rushConfiguration.commonRushConfigFolder, targetNpmrcFolder: autoinstallerFullPath, - supportEnvVarFallbackSyntax: this._rushConfiguration.isPnpm + supportEnvVarFallbackSyntax: this._rushConfiguration.isPnpm, + moveSensitiveSettingsToEnvironment: InstallHelpers.shouldProvideNpmrcCredentialsViaEnvironment( + this._rushConfiguration + ) }); this._logIfConsoleOutputIsNotRestricted( @@ -154,6 +158,7 @@ export class Autoinstaller { command: this._rushConfiguration.packageManagerToolFilename, args: ['install', '--frozen-lockfile'], workingDirectory: autoinstallerFullPath, + environment: this._getPackageManagerEnvironment(autoinstallerFullPath), keepEnvironment: true }); @@ -229,13 +234,17 @@ export class Autoinstaller { Utilities.syncNpmrc({ sourceNpmrcFolder: this._rushConfiguration.commonRushConfigFolder, targetNpmrcFolder: this.folderFullPath, - supportEnvVarFallbackSyntax: this._rushConfiguration.isPnpm + supportEnvVarFallbackSyntax: this._rushConfiguration.isPnpm, + moveSensitiveSettingsToEnvironment: InstallHelpers.shouldProvideNpmrcCredentialsViaEnvironment( + this._rushConfiguration + ) }); await Utilities.executeCommandAsync({ command: this._rushConfiguration.packageManagerToolFilename, args: ['install'], workingDirectory: this.folderFullPath, + environment: this._getPackageManagerEnvironment(this.folderFullPath), keepEnvironment: true }); @@ -278,4 +287,21 @@ export class Autoinstaller { console.log(message ?? ''); } } + + /** + * Returns the environment to invoke the package manager with, or `undefined` to inherit this + * process's environment. See the `provideNpmrcCredentialsViaEnvironment` experiment. + */ + private _getPackageManagerEnvironment(npmrcFolder: string): NodeJS.ProcessEnv | undefined { + if (!InstallHelpers.shouldProvideNpmrcCredentialsViaEnvironment(this._rushConfiguration)) { + return undefined; + } + + const npmrcEnvironmentVariables: Record | undefined = getNpmrcEnvironmentVariables({ + npmrcFolder, + supportEnvVarFallbackSyntax: this._rushConfiguration.isPnpm + }); + + return npmrcEnvironmentVariables && { ...process.env, ...npmrcEnvironmentVariables }; + } } diff --git a/libraries/rush-lib/src/logic/base/BaseInstallManager.ts b/libraries/rush-lib/src/logic/base/BaseInstallManager.ts index fa21aed84c..a8d87e064f 100644 --- a/libraries/rush-lib/src/logic/base/BaseInstallManager.ts +++ b/libraries/rush-lib/src/logic/base/BaseInstallManager.ts @@ -554,15 +554,41 @@ export abstract class BaseInstallManager { // Also copy down the committed .npmrc file, if there is one // "common\config\rush\.npmrc" --> "common\temp\.npmrc" // Also ensure that we remove any old one that may be hanging around + const { + isPnpm, + packageManagerToolVersion, + experimentsConfiguration: { + configuration: { provideNpmrcCredentialsViaEnvironment } + } + } = this.rushConfiguration; + const shouldWarnAboutIgnoredEnvironmentVariables: boolean | undefined = + isPnpm && provideNpmrcCredentialsViaEnvironment && semver.gte(packageManagerToolVersion, '11.6.0'); + const environmentVariableSettingNames: Set | undefined = + shouldWarnAboutIgnoredEnvironmentVariables ? new Set() : undefined; const npmrcText: string | undefined = Utilities.syncNpmrc({ sourceNpmrcFolder: subspace.getSubspaceConfigFolderPath(), targetNpmrcFolder: subspace.getSubspaceTempFolderPath(), linesToPrepend: extraNpmrcLines, createIfMissing: this.rushConfiguration.subspacesFeatureEnabled, - supportEnvVarFallbackSyntax: this.rushConfiguration.isPnpm + supportEnvVarFallbackSyntax: this.rushConfiguration.isPnpm, + moveSensitiveSettingsToEnvironment: InstallHelpers.shouldProvideNpmrcCredentialsViaEnvironment( + this.rushConfiguration + ), + environmentVariableSettingNames }); this._syncNpmrcAlreadyCalled = true; + if (environmentVariableSettingNames?.size) { + terminal.writeWarningLine( + `The "provideNpmrcCredentialsViaEnvironment" experiment does not translate project ` + + `.npmrc settings for PNPM ${packageManagerToolVersion}. PNPM will ignore environment ` + + `variables in these settings: ${Array.from(environmentVariableSettingNames).join(', ')}. ` + + `Supply credentials using URL-scoped "pnpm_config_//..." environment variables, or move ` + + `the settings to trusted user, global, CLI, or environment configuration. See the PNPM ` + + `11.6.0 section in the Rush upgrade notes.` + ); + } + const npmrcHash: string | undefined = npmrcText ? crypto.createHash('sha1').update(npmrcText).digest('hex') : undefined; diff --git a/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts b/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts index 220df476bd..8ae6806b00 100644 --- a/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts +++ b/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts @@ -21,6 +21,7 @@ import type { IConfigurationEnvironment } from '../base/BasePackageManagerOption import type { PnpmOptionsConfiguration } from '../pnpm/PnpmOptionsConfiguration'; import { PnpmWorkspaceFile } from '../pnpm/PnpmWorkspaceFile'; import { merge } from '../../utilities/objectUtilities'; +import { getNpmrcEnvironmentVariables } from '../../utilities/npmrcUtilities'; import type { Subspace } from '../../api/Subspace'; import { RushConstants } from '../RushConstants'; @@ -377,10 +378,41 @@ export class InstallHelpers { }; } + /** + * Returns true if Rush (rather than PNPM) should expand the `${VAR}` tokens that appear in + * credentials and registry URLs in the `.npmrc` file. See the + * `provideNpmrcCredentialsViaEnvironment` experiment. + */ + public static shouldProvideNpmrcCredentialsViaEnvironment(rushConfiguration: RushConfiguration): boolean { + const { + isPnpm, + packageManagerToolVersion, + experimentsConfiguration: { + configuration: { provideNpmrcCredentialsViaEnvironment = false } + } + } = rushConfiguration; + if (!isPnpm || !provideNpmrcCredentialsViaEnvironment) { + return false; + } + + // PNPM 11.6.0 added URL-scoped `pnpm_config_//...` credentials, which let CI bind a token to + // a registry without deriving that trusted binding from repository-controlled configuration. + // Keep this compatibility workaround only for patched versions that lack that native path. + return ( + (semver.gte(packageManagerToolVersion, '10.34.2') && semver.lt(packageManagerToolVersion, '11.0.0')) || + (semver.gte(packageManagerToolVersion, '11.5.3') && semver.lt(packageManagerToolVersion, '11.6.0')) + ); + } + + /** + * Returns the environment that the package manager should be invoked with, including any + * credentials that were moved out of the generated `.npmrc` file in `npmrcFolder`. + */ public static getPackageManagerEnvironment( rushConfiguration: RushConfiguration, options: { debug?: boolean; + npmrcFolder?: string; } = {} ): NodeJS.ProcessEnv { let configurationEnvironment: IConfigurationEnvironment | undefined = undefined; @@ -393,7 +425,26 @@ export class InstallHelpers { configurationEnvironment = rushConfiguration.yarnOptions?.environmentVariables; } - return _mergeEnvironmentVariables(process.env, configurationEnvironment, options); + const packageManagerEnvironment: NodeJS.ProcessEnv = _mergeEnvironmentVariables( + process.env, + configurationEnvironment, + options + ); + + const { npmrcFolder } = options; + const shouldProvideCredentials: boolean = + InstallHelpers.shouldProvideNpmrcCredentialsViaEnvironment(rushConfiguration); + if (npmrcFolder !== undefined && shouldProvideCredentials) { + Object.assign( + packageManagerEnvironment, + getNpmrcEnvironmentVariables({ + npmrcFolder, + supportEnvVarFallbackSyntax: rushConfiguration.isPnpm + }) + ); + } + + return packageManagerEnvironment; } /** @@ -514,7 +565,7 @@ function _mergeEnvironmentVariables( debug?: boolean; } = {} ): NodeJS.ProcessEnv { - const packageManagerEnv: NodeJS.ProcessEnv = baseEnv; + const packageManagerEnv: NodeJS.ProcessEnv = { ...baseEnv }; if (environmentVariables) { // eslint-disable-next-line guard-for-in diff --git a/libraries/rush-lib/src/logic/installManager/RushInstallManager.ts b/libraries/rush-lib/src/logic/installManager/RushInstallManager.ts index ff64f638de..8985569cd2 100644 --- a/libraries/rush-lib/src/logic/installManager/RushInstallManager.ts +++ b/libraries/rush-lib/src/logic/installManager/RushInstallManager.ts @@ -502,8 +502,10 @@ export class RushInstallManager extends BaseInstallManager { const packageManagerEnv: NodeJS.ProcessEnv = InstallHelpers.getPackageManagerEnvironment( this.rushConfiguration, - this.options + { ...this.options, npmrcFolder: subspace.getSubspaceTempFolderPath() } ); + const keepEnvironment: boolean = + InstallHelpers.shouldProvideNpmrcCredentialsViaEnvironment(this.rushConfiguration); const commonNodeModulesFolder: string = path.join( this.rushConfiguration.commonTempFolder, @@ -622,6 +624,7 @@ export class RushInstallManager extends BaseInstallManager { args: installArgs, workingDirectory: this.rushConfiguration.commonTempFolder, environment: packageManagerEnv, + keepEnvironment, suppressOutput: false }, this.options.maxInstallAttempts, diff --git a/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts b/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts index de745a7233..b58bff7850 100644 --- a/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts +++ b/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts @@ -494,8 +494,10 @@ export class WorkspaceInstallManager extends BaseInstallManager { const packageManagerEnv: NodeJS.ProcessEnv = InstallHelpers.getPackageManagerEnvironment( this.rushConfiguration, - this.options + { ...this.options, npmrcFolder: subspace.getSubspaceTempFolderPath() } ); + const keepEnvironment: boolean = + InstallHelpers.shouldProvideNpmrcCredentialsViaEnvironment(this.rushConfiguration); if (ConsoleTerminalProvider.supportsColor) { packageManagerEnv.FORCE_COLOR = '1'; } @@ -596,6 +598,7 @@ export class WorkspaceInstallManager extends BaseInstallManager { args: installArgs, workingDirectory: subspace.getSubspaceTempFolderPath(), environment: packageManagerEnv, + keepEnvironment, suppressOutput: false, onStdoutStreamChunk: onPnpmStdoutChunk }, diff --git a/libraries/rush-lib/src/logic/test/InstallHelpers.test.ts b/libraries/rush-lib/src/logic/test/InstallHelpers.test.ts index cfc2319219..e974122307 100644 --- a/libraries/rush-lib/src/logic/test/InstallHelpers.test.ts +++ b/libraries/rush-lib/src/logic/test/InstallHelpers.test.ts @@ -10,6 +10,66 @@ import { RushConfiguration } from '../../api/RushConfiguration'; import type { PnpmWorkspaceFile } from '../pnpm/PnpmWorkspaceFile'; describe(InstallHelpers.name, () => { + describe(InstallHelpers.shouldProvideNpmrcCredentialsViaEnvironment.name, () => { + let rushConfiguration: RushConfiguration; + let experimentsConfigurationMock: ReturnType; + + beforeAll(() => { + rushConfiguration = RushConfiguration.loadFromConfigurationFile(`${__dirname}/pnpmConfig/rush.json`); + experimentsConfigurationMock = jest.replaceProperty( + rushConfiguration.experimentsConfiguration, + 'configuration', + { provideNpmrcCredentialsViaEnvironment: true } + ); + }); + + afterAll(() => { + experimentsConfigurationMock.restore(); + }); + + it.each([ + ['10.34.1', false], + ['10.34.2', true], + ['10.35.0-rc.1', true], + ['10.99.0', true], + ['11.5.2', false], + ['11.5.3', true], + ['11.6.0-rc.1', true], + ['11.6.0', false], + ['12.0.0', false] + ])('for PNPM version %s returns %s', (pnpmVersion: string, expectedResult: boolean) => { + const packageManagerToolVersionMock = jest.replaceProperty( + rushConfiguration, + 'packageManagerToolVersion', + pnpmVersion + ); + + try { + expect(InstallHelpers.shouldProvideNpmrcCredentialsViaEnvironment(rushConfiguration)).toBe( + expectedResult + ); + } finally { + packageManagerToolVersionMock.restore(); + } + }); + }); + + describe(InstallHelpers.getPackageManagerEnvironment.name, () => { + it('does not modify process.env', () => { + const RUSH_JSON_FILENAME: string = `${__dirname}/pnpmConfig/rush.json`; + const rushConfiguration: RushConfiguration = + RushConfiguration.loadFromConfigurationFile(RUSH_JSON_FILENAME); + const environmentVariableName: string = 'RUSH_TEST_PACKAGE_MANAGER_ENVIRONMENT'; + const originalValue: string | undefined = process.env[environmentVariableName]; + + const packageManagerEnvironment: NodeJS.ProcessEnv = + InstallHelpers.getPackageManagerEnvironment(rushConfiguration); + packageManagerEnvironment[environmentVariableName] = 'test value'; + + expect(process.env[environmentVariableName]).toBe(originalValue); + }); + }); + describe(InstallHelpers.generateCommonPackageJsonAsync.name, () => { let mockJsonFileSaveAsync: jest.SpyInstance; let terminal: Terminal; diff --git a/libraries/rush-lib/src/schemas/experiments.schema.json b/libraries/rush-lib/src/schemas/experiments.schema.json index 445ab9cb9f..fa4d2ee130 100644 --- a/libraries/rush-lib/src/schemas/experiments.schema.json +++ b/libraries/rush-lib/src/schemas/experiments.schema.json @@ -97,6 +97,10 @@ "trimRushEnvironmentVariablesForOperations": { "description": "By default, Rush forwards its entire process environment (minus a small denylist) to the shell commands it invokes for operations (e.g. 'build', 'test'). If true, environment variables whose names begin with `RUSH_` will additionally be omitted from that forwarded environment. This can help prevent operation scripts from accidentally depending on Rush's own internal environment variables.", "type": "boolean" + }, + "provideNpmrcCredentialsViaEnvironment": { + "description": "If true, when using PNPM 10.34.2 through 10.x or PNPM 11.5.3 through versions earlier than 11.6.0, Rush resolves the \"${VAR}\" tokens that appear in credentials and registry URLs in the .npmrc file, instead of relying on PNPM to expand them. Credentials are passed to PNPM using \"npm_config_*\" environment variables and are not written to the generated .npmrc file. PNPM 11.6.0 and newer support URL-scoped \"pnpm_config_//...\" environment variables, which should instead be supplied directly by CI so the trusted environment binds each credential to its registry. Dynamic registry and proxy settings must likewise come from trusted user, global, CLI, or environment configuration.", + "type": "boolean" } }, "additionalProperties": false diff --git a/libraries/rush-lib/src/utilities/Utilities.ts b/libraries/rush-lib/src/utilities/Utilities.ts index 1b4ca37cc4..430a04deca 100644 --- a/libraries/rush-lib/src/utilities/Utilities.ts +++ b/libraries/rush-lib/src/utilities/Utilities.ts @@ -736,7 +736,10 @@ function _createEnvironmentForRushCommand(options: ICreateEnvironmentForRushComm } for (const key of Object.getOwnPropertyNames(options.initialEnvironment)) { - const normalizedKey: string = IS_WINDOWS ? key.toUpperCase() : key; + // URL-scoped PNPM configuration embeds a registry path in the variable name. Preserve its + // casing because registry paths may be case-sensitive even on Windows. + const preserveKeyCasing: boolean = /^pnpm_config_\/\//i.test(key); + const normalizedKey: string = IS_WINDOWS && !preserveKeyCasing ? key.toUpperCase() : key; // If Rush itself was invoked inside a lifecycle script, this may be set and would interfere // with Rush's installations. If we actually want it, we will set it explicitly below. diff --git a/libraries/rush-lib/src/utilities/npmrcUtilities.ts b/libraries/rush-lib/src/utilities/npmrcUtilities.ts index 7ca6febe48..6544dd7268 100644 --- a/libraries/rush-lib/src/utilities/npmrcUtilities.ts +++ b/libraries/rush-lib/src/utilities/npmrcUtilities.ts @@ -27,6 +27,8 @@ function _trimNpmrcFile( | 'linesToPrepend' | 'supportEnvVarFallbackSyntax' | 'filterNpmIncompatibleProperties' + | 'moveSensitiveSettingsToEnvironment' + | 'environmentVariableSettingNames' | 'env' > ): string { @@ -36,6 +38,8 @@ function _trimNpmrcFile( linesToAppend, supportEnvVarFallbackSyntax, filterNpmIncompatibleProperties, + moveSensitiveSettingsToEnvironment, + environmentVariableSettingNames, env = process.env } = options; @@ -58,7 +62,9 @@ function _trimNpmrcFile( npmrcFileLines, env, supportEnvVarFallbackSyntax, - filterNpmIncompatibleProperties + filterNpmIncompatibleProperties, + moveSensitiveSettingsToEnvironment, + environmentVariableSettingNames ); const combinedNpmrc: string = resultLines.join('\n'); @@ -111,25 +117,350 @@ const PROPERTY_NAME_REGEX: RegExp = /^([^=\[\s]+)/; */ const ENV_VAR_WITH_FALLBACK_REGEX: RegExp = /^(?[^:-]+)(?::?-(?.+))?$/; +// Matches an environment variable reference such as "${NPM_TOKEN}" anywhere in a setting. +const ENVIRONMENT_VARIABLE_DETECTION_REGEX: RegExp = /\$\{[^\}]+\}/; + +/** + * The comment marker that is written in place of an .npmrc setting whose value was moved into an + * `npm_config_*` environment variable. The remainder of the line is the original (unexpanded) + * setting, so that the secret itself never gets written to disk. + * + * @remarks + * See {@link getNpmrcEnvironmentVariables} for the code that reads these lines back. + */ +const PROVIDED_VIA_ENVIRONMENT_PREFIX: string = '; PROVIDED VIA ENVIRONMENT: '; + +/** + * The names of .npmrc settings that PNPM considers to be credentials. They may appear either + * as a bare setting name (`_authToken=...`) or scoped to a registry URI + * (`//registry.example.com/:_authToken=...`). + * + * @remarks + * This list mirrors PNPM's own list; PNPM 10.34.2 and newer refuse to expand `${VAR}` tokens in + * these settings when they come from a project or workspace .npmrc file. + */ +const AUTH_VALUE_SETTING_NAMES: Set = new Set([ + '_authToken', + '_auth', + '_password', + 'username', + 'tokenHelper', + 'cert', + 'key' +]); + +/** + * The names of .npmrc settings that determine where PNPM sends a request. PNPM 10.34.2 and newer + * refuse to expand `${VAR}` tokens in these settings when they come from a project or workspace + * .npmrc file, because a compromised value could redirect a request (and its credentials) to an + * attacker-controlled server. + */ +const REQUEST_DESTINATION_SETTING_NAMES: Set = new Set([ + 'registry', + 'proxy', + 'http-proxy', + 'https-proxy' +]); + +function _isRegistrySettingName(settingName: string): boolean { + return settingName === 'registry' || (settingName.startsWith('@') && settingName.endsWith(':registry')); +} + +/** + * Returns true if PNPM treats the setting's value as a credential. + */ +function _isAuthValueSettingName(settingName: string): boolean { + if (AUTH_VALUE_SETTING_NAMES.has(settingName)) { + return true; + } + + // Example: "//registry.example.com/:_authToken" --> "_authToken" + const lastColonIndex: number = settingName.lastIndexOf(':'); + return lastColonIndex >= 0 && AUTH_VALUE_SETTING_NAMES.has(settingName.substring(lastColonIndex + 1)); +} + +/** + * Returns true if PNPM refuses to expand environment variables that appear in the setting's NAME. + */ +function _isRequestDestinationSettingName(settingName: string): boolean { + return _isRegistrySettingName(settingName) || settingName.startsWith('//'); +} + +/** + * Returns true if PNPM refuses to expand environment variables that appear in the setting's VALUE. + */ +function _isRequestDestinationValueSettingName(settingName: string): boolean { + return _isRegistrySettingName(settingName) || REQUEST_DESTINATION_SETTING_NAMES.has(settingName); +} + +interface IParsedNpmrcSetting { + line: string; + name: string; + value: string; +} + +function _tryParseNpmrcSetting(line: string): IParsedNpmrcSetting | undefined { + const equalsIndex: number = line.indexOf('='); + if (equalsIndex < 0) { + return undefined; + } + + return { + line, + name: line.substring(0, equalsIndex), + value: line.substring(equalsIndex + 1) + }; +} + +function _hasIgnoredEnvironmentVariable(setting: IParsedNpmrcSetting): boolean { + const { name, value } = setting; + return ( + (ENVIRONMENT_VARIABLE_DETECTION_REGEX.test(name) && + (_isRequestDestinationSettingName(name) || _isAuthValueSettingName(name))) || + (ENVIRONMENT_VARIABLE_DETECTION_REGEX.test(value) && + (_isRequestDestinationValueSettingName(name) || _isAuthValueSettingName(name))) + ); +} + +/** + * Reproduces PNPM's `envKeyToSetting()`, which converts the portion of an `npm_config_*` environment + * variable name that follows the prefix back into an .npmrc setting name. + */ +function _environmentVariableSuffixToSettingName(suffix: string): string { + const colonIndex: number = suffix.indexOf(':'); + if (colonIndex === -1) { + return _normalizeSettingNamePart(suffix); + } + + return `${suffix.substring(0, colonIndex)}:${_normalizeSettingNamePart(suffix.substring(colonIndex + 1))}`; +} + +function _normalizeSettingNamePart(settingNamePart: string): string { + const lowerCased: string = settingNamePart.toLowerCase(); + if (lowerCased === '_authtoken') { + return '_authToken'; + } + + // Underscores become dashes, except for a leading underscore + return lowerCased.charAt(0) + lowerCased.substring(1).replace(/_/g, '-'); +} + +/** + * Returns true if the setting can be expressed as an `npm_config_*` environment variable without + * being mangled by PNPM's name normalization. + * + * @remarks + * For example, a registry URL that includes an explicit port such as + * `//registry.example.com:8080/:_authToken` cannot round-trip, because PNPM splits the name on its + * FIRST colon and then normalizes everything after it. + */ +function _canSettingRoundTripThroughEnvironmentVariable(settingName: string): boolean { + return _environmentVariableSuffixToSettingName(settingName) === settingName; +} + +interface IEnvironmentVariableExpansionResult { + /** + * The text with all `${VAR}` tokens replaced. If `hasUndefinedVariable` is true, this is the + * original text. + */ + expandedText: string; + /** + * Whether the text contained at least one `${VAR}` token. + */ + hasVariable: boolean; + /** + * Whether the text referenced a variable that is not defined and has no fallback value. + */ + hasUndefinedVariable: boolean; +} + +// This finds environment variable tokens that look like "${VAR_NAME}" +const ENVIRONMENT_VARIABLE_REGEX: RegExp = /\$\{([^\}]+)\}/g; + +function _expandEnvironmentVariables( + text: string, + env: NodeJS.ProcessEnv, + supportEnvVarFallbackSyntax: boolean +): IEnvironmentVariableExpansionResult { + let hasVariable: boolean = false; + let hasUndefinedVariable: boolean = false; + + const expandedText: string = text.replace(ENVIRONMENT_VARIABLE_REGEX, (token: string) => { + hasVariable = true; + + /** + * Remove the leading "${" and the trailing "}" from the token + * + * ${nameString} -> nameString + * ${nameString-fallbackString} -> nameString-fallbackString + * ${nameString:-fallbackString} -> nameString:-fallbackString + */ + const nameWithFallback: string = token.slice(2, -1); + + let environmentVariableName: string; + let fallback: string | undefined; + if (supportEnvVarFallbackSyntax) { + /** + * Get the environment variable name and fallback value. + * + * name fallback + * nameString -> nameString undefined + * nameString-fallbackString -> nameString fallbackString + * nameString:-fallbackString -> nameString fallbackString + */ + const matched: RegExpMatchArray | null = nameWithFallback.match(ENV_VAR_WITH_FALLBACK_REGEX); + environmentVariableName = matched?.groups?.name ?? nameWithFallback; + fallback = matched?.groups?.fallback; + } else { + environmentVariableName = nameWithFallback; + } + + const environmentVariableValue: string | undefined = env[environmentVariableName]; + if (environmentVariableValue) { + return environmentVariableValue; + } else if (fallback) { + return fallback; + } else { + hasUndefinedVariable = true; + return token; + } + }); + + return { + expandedText: hasUndefinedVariable ? text : expandedText, + hasVariable, + hasUndefinedVariable + }; +} + +/** + * Describes how a .npmrc setting containing `${VAR}` tokens must be transformed so that PNPM will + * honor it. See {@link _classifySensitiveNpmrcSetting}. + */ +type ISensitiveNpmrcLineAction = + | { + /** + * The setting is a credential, so its value is passed to PNPM via an environment variable + * and never written to disk. + */ + kind: 'environment'; + variableName: string; + variableValue: string; + } + | { + /** + * The setting is not a credential (for example, a registry URL), so it is safe to write its + * expanded value into the generated .npmrc file. + */ + kind: 'expand'; + expandedLine: string; + }; + +/** + * Determines how a .npmrc line whose environment variables are all defined must be transformed + * so that PNPM 10.34.2 and newer will honor it. Returns `undefined` if PNPM expands the line's + * environment variables itself, in which case the line is left alone. + */ +function _classifySensitiveNpmrcSetting( + setting: IParsedNpmrcSetting, + env: NodeJS.ProcessEnv, + supportEnvVarFallbackSyntax: boolean +): ISensitiveNpmrcLineAction | undefined { + const { name: settingName, value: settingValue } = setting; + + const expandedName: IEnvironmentVariableExpansionResult = _expandEnvironmentVariables( + settingName, + env, + supportEnvVarFallbackSyntax + ); + const expandedValue: IEnvironmentVariableExpansionResult = _expandEnvironmentVariables( + settingValue, + env, + supportEnvVarFallbackSyntax + ); + if (expandedName.hasUndefinedVariable || expandedValue.hasUndefinedVariable) { + return undefined; + } + + // Consider both spellings, because PNPM discards the setting if EITHER form is sensitive + const isAuthValue: boolean = + _isAuthValueSettingName(expandedName.expandedText) || _isAuthValueSettingName(settingName); + if (isAuthValue) { + if (_canSettingRoundTripThroughEnvironmentVariable(expandedName.expandedText)) { + return { + kind: 'environment', + variableName: `npm_config_${expandedName.expandedText}`, + variableValue: expandedValue.expandedText + }; + } + + throw new Error( + `The .npmrc credential setting "${expandedName.expandedText}" cannot be provided via an ` + + 'environment variable because PNPM cannot round-trip this setting name.' + ); + } + + const isRequestDestination: boolean = + (expandedName.hasVariable && + (_isRequestDestinationSettingName(expandedName.expandedText) || + _isRequestDestinationSettingName(settingName))) || + (expandedValue.hasVariable && _isRequestDestinationValueSettingName(expandedName.expandedText)); + if (isRequestDestination) { + return { kind: 'expand', expandedLine: `${expandedName.expandedText}=${expandedValue.expandedText}` }; + } + + return undefined; +} + +/** + * Returns the replacement text for a .npmrc line that PNPM would otherwise discard, or `undefined` + * if the line does not need to be rewritten. + */ +function _rewriteSensitiveNpmrcLine( + setting: IParsedNpmrcSetting, + env: NodeJS.ProcessEnv, + supportEnvVarFallbackSyntax: boolean +): string | undefined { + const action: ISensitiveNpmrcLineAction | undefined = _classifySensitiveNpmrcSetting( + setting, + env, + supportEnvVarFallbackSyntax + ); + switch (action?.kind) { + case 'environment': + // Example output: + // "; PROVIDED VIA ENVIRONMENT: //my-registry.com/npm/:_authToken=${MY_AUTH_TOKEN}" + return PROVIDED_VIA_ENVIRONMENT_PREFIX + setting.line; + case 'expand': + return action.expandedLine; + default: + return undefined; + } +} + /** * * @param npmrcFileLines The npmrc file's lines * @param env The environment variables object * @param supportEnvVarFallbackSyntax Whether to support fallback values in the form of `${VAR_NAME:-fallback}` * @param filterNpmIncompatibleProperties Whether to filter out properties that npm doesn't understand + * @param moveSensitiveSettingsToEnvironment Whether to replace settings that PNPM refuses to expand + * environment variables in with a `; PROVIDED VIA ENVIRONMENT: ` comment. See + * {@link getNpmrcEnvironmentVariables}. + * @param environmentVariableSettingNames If provided, collects settings containing environment + * variable references that PNPM ignores in a project `.npmrc`. * @returns An array of processed npmrc file lines with undefined environment variables and npm-incompatible properties commented out */ export function trimNpmrcFileLines( npmrcFileLines: string[], env: NodeJS.ProcessEnv, supportEnvVarFallbackSyntax: boolean, - filterNpmIncompatibleProperties: boolean = false + filterNpmIncompatibleProperties: boolean = false, + moveSensitiveSettingsToEnvironment: boolean = false, + environmentVariableSettingNames?: Set ): string[] { const resultLines: string[] = []; - // This finds environment variable tokens that look like "${VAR_NAME}" - const expansionRegExp: RegExp = /\$\{([^\}]+)\}/g; - // Comment lines start with "#" or ";" const commentRegExp: RegExp = /^\s*[#;]/; @@ -146,6 +477,11 @@ export function trimNpmrcFileLines( // Ignore comment lines if (!commentRegExp.test(line)) { + const parsedSetting: IParsedNpmrcSetting | undefined = _tryParseNpmrcSetting(line); + if (environmentVariableSettingNames && parsedSetting && _hasIgnoredEnvironmentVariable(parsedSetting)) { + environmentVariableSettingNames.add(parsedSetting.name); + } + // Check if this is a property that npm doesn't understand if (filterNpmIncompatibleProperties) { // Extract the property name (everything before the '=' or '[') @@ -179,43 +515,24 @@ export function trimNpmrcFileLines( // Check for undefined environment variables if (!lineShouldBeTrimmed) { - const environmentVariables: string[] | null = line.match(expansionRegExp); - if (environmentVariables) { - for (const token of environmentVariables) { - /** - * Remove the leading "${" and the trailing "}" from the token - * - * ${nameString} -> nameString - * ${nameString-fallbackString} -> name-fallbackString - * ${nameString:-fallbackString} -> name:-fallbackString - */ - const nameWithFallback: string = token.slice(2, -1); - - let environmentVariableName: string; - let fallback: string | undefined; - if (supportEnvVarFallbackSyntax) { - /** - * Get the environment variable name and fallback value. - * - * name fallback - * nameString -> nameString undefined - * nameString-fallbackString -> nameString fallbackString - * nameString:-fallbackString -> nameString fallbackString - */ - const matched: RegExpMatchArray | null = nameWithFallback.match(ENV_VAR_WITH_FALLBACK_REGEX); - environmentVariableName = matched?.groups?.name ?? nameWithFallback; - fallback = matched?.groups?.fallback; - } else { - environmentVariableName = nameWithFallback; - } - - // Is the environment variable and fallback value defined. - if (!env[environmentVariableName] && !fallback) { - // No, so trim this line - lineShouldBeTrimmed = true; - trimReason = 'MISSING_ENVIRONMENT_VARIABLE'; - break; - } + const { hasVariable, hasUndefinedVariable } = _expandEnvironmentVariables( + line, + env, + supportEnvVarFallbackSyntax + ); + + if (hasUndefinedVariable) { + lineShouldBeTrimmed = true; + trimReason = 'MISSING_ENVIRONMENT_VARIABLE'; + } else if (hasVariable && moveSensitiveSettingsToEnvironment && parsedSetting) { + const rewrittenLine: string | undefined = _rewriteSensitiveNpmrcLine( + parsedSetting, + env, + supportEnvVarFallbackSyntax + ); + if (rewrittenLine !== undefined) { + resultLines.push(rewrittenLine); + continue; } } } @@ -262,6 +579,12 @@ interface INpmrcTrimOptions { linesToAppend?: string[]; supportEnvVarFallbackSyntax: boolean; filterNpmIncompatibleProperties?: boolean; + moveSensitiveSettingsToEnvironment?: boolean; + /** + * If provided, collects settings containing environment variable references that PNPM ignores + * when they come from a project `.npmrc`. + */ + environmentVariableSettingNames?: Set; env?: NodeJS.ProcessEnv; } @@ -296,6 +619,20 @@ export interface ISyncNpmrcOptions { linesToAppend?: string[]; createIfMissing?: boolean; filterNpmIncompatibleProperties?: boolean; + /** + * PNPM 10.34.2 and newer refuse to expand `${VAR}` tokens that appear in credentials or registry + * URLs in a project or workspace .npmrc file, because such files are normally committed to Git. + * When this option is true, Rush resolves those settings itself: credentials are replaced with a + * `; PROVIDED VIA ENVIRONMENT: ` comment and must be passed to the package manager using the + * variables returned by {@link getNpmrcEnvironmentVariables}, and non-secret settings such as + * registry URLs are written to the generated .npmrc file with their values already expanded. + */ + moveSensitiveSettingsToEnvironment?: boolean; + /** + * If provided, collects settings containing environment variable references that PNPM ignores + * when they come from a project `.npmrc`. + */ + environmentVariableSettingNames?: Set; env?: NodeJS.ProcessEnv; } @@ -361,3 +698,63 @@ export function isVariableSetInNpmrcFile( const variableKeyRegExp: RegExp = new RegExp(`^${variableKey}=`, 'm'); return trimmedNpmrcFile.match(variableKeyRegExp) !== null; } + +/** + * Options for {@link getNpmrcEnvironmentVariables}. + */ +export interface IGetNpmrcEnvironmentVariablesOptions { + /** + * The folder containing the generated .npmrc file, i.e. the folder that was passed as + * `targetNpmrcFolder` to {@link syncNpmrc}. + */ + npmrcFolder: string; + supportEnvVarFallbackSyntax: boolean; + env?: NodeJS.ProcessEnv; +} + +/** + * Returns the `npm_config_*` environment variables that must be passed to the package manager to + * provide the credentials that {@link syncNpmrc} moved out of the generated .npmrc file when its + * `moveSensitiveSettingsToEnvironment` option was enabled. Returns `undefined` if there are none. + * + * @remarks + * PNPM only expands `${VAR}` tokens in credentials that come from a trusted source, and an + * environment variable is such a source. Recomputing the variables from the generated .npmrc file + * (instead of remembering them from the {@link syncNpmrc} call) allows commands such as + * `rush-pnpm` to authenticate without re-synchronizing the file. + */ +export function getNpmrcEnvironmentVariables( + options: IGetNpmrcEnvironmentVariablesOptions +): Record | undefined { + const { npmrcFolder, supportEnvVarFallbackSyntax, env = process.env } = options; + + let npmrcFileContent: string; + try { + npmrcFileContent = fs.readFileSync(path.join(npmrcFolder, '.npmrc')).toString(); + } catch (e) { + if ((e as NodeJS.ErrnoException).code === 'ENOENT') { + return undefined; + } + + throw e; + } + + let environmentVariables: Record | undefined; + for (const npmrcFileLine of npmrcFileContent.split('\n')) { + const trimmedLine: string = npmrcFileLine.trim(); + if (!trimmedLine.startsWith(PROVIDED_VIA_ENVIRONMENT_PREFIX)) { + continue; + } + + const originalLine: string = trimmedLine.substring(PROVIDED_VIA_ENVIRONMENT_PREFIX.length); + const parsedSetting: IParsedNpmrcSetting | undefined = _tryParseNpmrcSetting(originalLine); + const action: ISensitiveNpmrcLineAction | undefined = + parsedSetting && _classifySensitiveNpmrcSetting(parsedSetting, env, supportEnvVarFallbackSyntax); + if (action?.kind === 'environment') { + environmentVariables ??= {}; + environmentVariables[action.variableName] = action.variableValue; + } + } + + return environmentVariables; +} diff --git a/libraries/rush-lib/src/utilities/test/npmrcUtilities.test.ts b/libraries/rush-lib/src/utilities/test/npmrcUtilities.test.ts index 3c84a54cfc..0ee88363c8 100644 --- a/libraries/rush-lib/src/utilities/test/npmrcUtilities.test.ts +++ b/libraries/rush-lib/src/utilities/test/npmrcUtilities.test.ts @@ -1,9 +1,40 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import { trimNpmrcFileLines } from '../npmrcUtilities'; +import { FileSystem } from '@rushstack/node-core-library'; +import { getNpmrcEnvironmentVariables, syncNpmrc, trimNpmrcFileLines } from '../npmrcUtilities'; describe('npmrcUtilities', () => { + describe(trimNpmrcFileLines.name, () => { + it('collects project settings with environment variables that PNPM ignores', () => { + const environmentVariableSettingNames: Set = new Set(); + trimNpmrcFileLines( + [ + 'registry=https://${REGISTRY_HOST}/npm/', + '@scope:registry=https://${REGISTRY_HOST}/npm/', + 'https-proxy=https://${PROXY_HOST}/', + '//registry.example.com/:_authToken=${NPM_TOKEN}', + '//${REGISTRY_HOST}/:always-auth=true', + 'store-dir=${STORE_DIR}', + '; //ignored.example.com/:_authToken=${IGNORED_TOKEN}' + ], + {}, + true, + false, + false, + environmentVariableSettingNames + ); + + expect(Array.from(environmentVariableSettingNames)).toEqual([ + 'registry', + '@scope:registry', + 'https-proxy', + '//registry.example.com/:_authToken', + '//${REGISTRY_HOST}/:always-auth' + ]); + }); + }); + function runTests(supportEnvVarFallbackSyntax: boolean): void { it('handles empty input', () => { expect(trimNpmrcFileLines([], {}, supportEnvVarFallbackSyntax)).toEqual([]); @@ -205,5 +236,181 @@ describe('npmrcUtilities', () => { ).toMatchSnapshot(); }); }); + + describe('With moveSensitiveSettingsToEnvironment', () => { + const supportEnvVarFallbackSyntax: boolean = true; + const filterNpmIncompatibleProperties: boolean = false; + const moveSensitiveSettingsToEnvironment: boolean = true; + + function trimLines(npmrcFileLines: string[], env: NodeJS.ProcessEnv): string[] { + return trimNpmrcFileLines( + npmrcFileLines, + env, + supportEnvVarFallbackSyntax, + filterNpmIncompatibleProperties, + moveSensitiveSettingsToEnvironment + ); + } + + it('moves credentials out of the file', () => { + expect( + trimLines( + [ + 'registry=https://registry.example.com/npm/registry/', + '//registry.example.com/npm/registry/:_authToken=${NPM_AUTH_TOKEN}', + '_authToken=${NPM_AUTH_TOKEN}', + '//registry.example.com/npm/:_password=${NPM_PASSWORD}', + '//registry.example.com/npm/:username=${NPM_USERNAME}' + ], + { NPM_AUTH_TOKEN: 'token123', NPM_PASSWORD: 'password123', NPM_USERNAME: 'user123' } + ) + ).toEqual([ + 'registry=https://registry.example.com/npm/registry/', + '; PROVIDED VIA ENVIRONMENT: //registry.example.com/npm/registry/:_authToken=${NPM_AUTH_TOKEN}', + '; PROVIDED VIA ENVIRONMENT: _authToken=${NPM_AUTH_TOKEN}', + '; PROVIDED VIA ENVIRONMENT: //registry.example.com/npm/:_password=${NPM_PASSWORD}', + '; PROVIDED VIA ENVIRONMENT: //registry.example.com/npm/:username=${NPM_USERNAME}' + ]); + }); + + it('leaves credentials with undefined variables commented out', () => { + expect(trimLines(['//registry.example.com/npm/:_authToken=${NPM_AUTH_TOKEN}'], {})).toEqual([ + '; MISSING ENVIRONMENT VARIABLE: //registry.example.com/npm/:_authToken=${NPM_AUTH_TOKEN}' + ]); + }); + + it('honors fallback values', () => { + expect( + trimLines(['//registry.example.com/npm/:_authToken=${NPM_AUTH_TOKEN:-fallbackToken}'], {}) + ).toEqual([ + '; PROVIDED VIA ENVIRONMENT: //registry.example.com/npm/:_authToken=${NPM_AUTH_TOKEN:-fallbackToken}' + ]); + }); + + it('rejects credentials whose names cannot round-trip through an environment variable', () => { + // PNPM splits an "npm_config_*" variable name on its FIRST colon, so a registry URL that + // includes an explicit port cannot be expressed as an environment variable + expect(() => + trimLines(['//registry.example.com:8080/:_authToken=${NPM_AUTH_TOKEN}'], { + NPM_AUTH_TOKEN: 'token123' + }) + ).toThrow( + 'The .npmrc credential setting "//registry.example.com:8080/:_authToken" cannot be provided via an environment variable' + ); + }); + + it('expands request destinations in the file', () => { + expect( + trimLines( + [ + 'registry=https://${REGISTRY_HOST}/npm/registry/', + '@scope:registry=https://${REGISTRY_HOST}/npm/registry/', + 'https-proxy=https://${PROXY_HOST}/', + '//${REGISTRY_HOST}/npm/:always-auth=true' + ], + { REGISTRY_HOST: 'registry.example.com', PROXY_HOST: 'proxy.example.com' } + ) + ).toEqual([ + 'registry=https://registry.example.com/npm/registry/', + '@scope:registry=https://registry.example.com/npm/registry/', + 'https-proxy=https://proxy.example.com/', + '//registry.example.com/npm/:always-auth=true' + ]); + }); + + it('does not modify settings that PNPM expands itself', () => { + expect( + trimLines( + [ + '; //registry.example.com/npm/:_authToken=${NPM_AUTH_TOKEN}', + 'registry=https://registry.example.com/npm/registry/', + 'store-dir=${STORE_DIR}', + 'always-auth=true' + ], + { STORE_DIR: '/tmp/store' } + ) + ).toEqual([ + '; //registry.example.com/npm/:_authToken=${NPM_AUTH_TOKEN}', + 'registry=https://registry.example.com/npm/registry/', + 'store-dir=${STORE_DIR}', + 'always-auth=true' + ]); + }); + + it('does not modify anything when the option is disabled', () => { + expect( + trimNpmrcFileLines( + [ + 'registry=https://${REGISTRY_HOST}/npm/registry/', + '//registry.example.com/npm/:_authToken=${NPM_AUTH_TOKEN}' + ], + { REGISTRY_HOST: 'registry.example.com', NPM_AUTH_TOKEN: 'token123' }, + supportEnvVarFallbackSyntax, + filterNpmIncompatibleProperties, + false + ) + ).toEqual([ + 'registry=https://${REGISTRY_HOST}/npm/registry/', + '//registry.example.com/npm/:_authToken=${NPM_AUTH_TOKEN}' + ]); + }); + }); + }); + + describe(getNpmrcEnvironmentVariables.name, () => { + it('returns credentials moved by syncNpmrc', async () => { + const tempFolder: string = `${__dirname}/../../../../temp/test/npmrcUtilities/roundtrip`; + const sourceFolder: string = `${tempFolder}/source`; + const targetFolder: string = `${tempFolder}/target`; + await FileSystem.deleteFolderAsync(tempFolder); + await FileSystem.writeFileAsync( + `${sourceFolder}/.npmrc`, + [ + '//registry.example.com/npm/:_authToken=${NPM_AUTH_TOKEN}', + '//other.example.com/npm/:_password=${NPM_PASSWORD:-fallbackPassword}' + ].join('\n'), + { ensureFolderExists: true } + ); + + try { + syncNpmrc({ + sourceNpmrcFolder: sourceFolder, + targetNpmrcFolder: targetFolder, + supportEnvVarFallbackSyntax: true, + moveSensitiveSettingsToEnvironment: true, + env: { NPM_AUTH_TOKEN: 'token123' }, + logger: { info: () => {}, error: () => {} } + }); + + expect( + getNpmrcEnvironmentVariables({ + npmrcFolder: targetFolder, + supportEnvVarFallbackSyntax: true, + env: { NPM_AUTH_TOKEN: 'token123' } + }) + ).toEqual({ + 'npm_config_//registry.example.com/npm/:_authToken': 'token123', + 'npm_config_//other.example.com/npm/:_password': 'fallbackPassword' + }); + } finally { + await FileSystem.deleteFolderAsync(tempFolder); + } + }); + + it('returns undefined when the generated .npmrc file is missing', async () => { + const tempFolder: string = `${__dirname}/../../../../temp/test/npmrcUtilities/missing`; + await FileSystem.deleteFolderAsync(tempFolder); + await FileSystem.ensureFolderAsync(tempFolder); + try { + expect( + getNpmrcEnvironmentVariables({ + npmrcFolder: tempFolder, + supportEnvVarFallbackSyntax: true + }) + ).toBeUndefined(); + } finally { + await FileSystem.deleteFolderAsync(tempFolder); + } + }); }); });