From b1fe55d871ff143b499aa9fb9e38e30ddfdff9b2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Aug 2026 05:39:33 +0000 Subject: [PATCH 1/3] Add provideNpmrcCredentialsViaEnvironment experiment to work around pnpm 10.34.2+ ignoring ${VAR} in project .npmrc credentials Co-authored-by: iclanton <5010588+iclanton@users.noreply.github.com> --- ...edentials-experiment_2026-08-28-05-19.json | 9 + common/reviews/api/rush-lib.api.md | 1 + .../common/config/rush/experiments.json | 13 +- .../src/api/ExperimentsConfiguration.ts | 14 + .../src/cli/RushPnpmCommandLineParser.ts | 14 + libraries/rush-lib/src/logic/Autoinstaller.ts | 30 +- .../src/logic/base/BaseInstallManager.ts | 5 +- .../logic/installManager/InstallHelpers.ts | 41 +- .../installManager/WorkspaceInstallManager.ts | 2 +- .../src/schemas/experiments.schema.json | 5 + .../rush-lib/src/utilities/npmrcUtilities.ts | 436 ++++++++++++++++-- .../src/utilities/test/npmrcUtilities.test.ts | 117 +++++ 12 files changed, 639 insertions(+), 48 deletions(-) create mode 100644 common/changes/@microsoft/rush/copilot-npmrc-credentials-experiment_2026-08-28-05-19.json 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 00000000000..623cff8b6fb --- /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. PNPM 10.34.2 and newer ignore `${VAR}` tokens that appear in credentials and registry URLs in a project `.npmrc` file, which broke the practice of supplying registry credentials via environment variables in CI. When this experiment is enabled, Rush expands those tokens itself, passing credentials to PNPM using `npm_config_*` environment variables instead of writing them to the generated `.npmrc` file.", + "type": "minor" + } + ] +} diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index ea311a232d8..46fb852c825 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; useDirectFileTransfersForBuildCache?: 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 1bead4b1057..b7a7f666434 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 @@ -141,5 +141,16 @@ * must implement the optional file-based methods for this to take effect; otherwise it falls back to the * buffer-based approach. */ - /*[LINE "HYPOTHETICAL"]*/ "useDirectFileTransfersForBuildCache": true + /*[LINE "HYPOTHETICAL"]*/ "useDirectFileTransfersForBuildCache": true, + + /** + * PNPM 10.34.2 and newer ignore "${VAR}" tokens that appear in credentials and registry URLs in a + * project or workspace .npmrc file, because such files are normally committed to Git. Rush generates + * "common/temp/.npmrc", which PNPM classifies as a project file even though it is not committed, so + * PNPM discards those settings and prints a warning. If true, when using PNPM, Rush expands those + * tokens itself: credentials are passed to PNPM using "npm_config_*" environment variables instead of + * being written to the generated .npmrc file, and non-secret settings such as registry URLs are + * written to the generated file with their values already expanded. + */ + /*[LINE "HYPOTHETICAL"]*/ "provideNpmrcCredentialsViaEnvironment": true } diff --git a/libraries/rush-lib/src/api/ExperimentsConfiguration.ts b/libraries/rush-lib/src/api/ExperimentsConfiguration.ts index 0f8db8e9d00..00253aa9078 100644 --- a/libraries/rush-lib/src/api/ExperimentsConfiguration.ts +++ b/libraries/rush-lib/src/api/ExperimentsConfiguration.ts @@ -153,6 +153,20 @@ export interface IExperimentsJson { * effect; otherwise it falls back to the buffer-based approach. */ useDirectFileTransfersForBuildCache?: 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 + * PNPM 10.34.2 and newer ignore `${VAR}` tokens in credentials and registry URLs that come from a + * project or workspace `.npmrc` file, because such files are normally committed to Git. Rush + * generates `common/temp/.npmrc`, which PNPM classifies as a project file even though it is not + * committed, so without this experiment PNPM discards those settings and prints a warning. + */ + 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 ecebe85de7b..30dda7645a7 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 a47dd0d89be..87e3995157d 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 fa21aed84c0..2a44dfea239 100644 --- a/libraries/rush-lib/src/logic/base/BaseInstallManager.ts +++ b/libraries/rush-lib/src/logic/base/BaseInstallManager.ts @@ -559,7 +559,10 @@ export abstract class BaseInstallManager { targetNpmrcFolder: subspace.getSubspaceTempFolderPath(), linesToPrepend: extraNpmrcLines, createIfMissing: this.rushConfiguration.subspacesFeatureEnabled, - supportEnvVarFallbackSyntax: this.rushConfiguration.isPnpm + supportEnvVarFallbackSyntax: this.rushConfiguration.isPnpm, + moveSensitiveSettingsToEnvironment: InstallHelpers.shouldProvideNpmrcCredentialsViaEnvironment( + this.rushConfiguration + ) }); this._syncNpmrcAlreadyCalled = true; diff --git a/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts b/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts index 220df476bdc..d7bef6a6f07 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,29 @@ 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 { + // Only PNPM refuses to expand these tokens, and only PNPM's "npm_config_*" environment variable + // name normalization has been validated here. + return ( + rushConfiguration.isPnpm && + !!rushConfiguration.experimentsConfiguration.configuration.provideNpmrcCredentialsViaEnvironment + ); + } + + /** + * 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 +413,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; } /** diff --git a/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts b/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts index de745a72338..f3260f1a8b3 100644 --- a/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts +++ b/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts @@ -494,7 +494,7 @@ export class WorkspaceInstallManager extends BaseInstallManager { const packageManagerEnv: NodeJS.ProcessEnv = InstallHelpers.getPackageManagerEnvironment( this.rushConfiguration, - this.options + { ...this.options, npmrcFolder: subspace.getSubspaceTempFolderPath() } ); if (ConsoleTerminalProvider.supportsColor) { packageManagerEnv.FORCE_COLOR = '1'; diff --git a/libraries/rush-lib/src/schemas/experiments.schema.json b/libraries/rush-lib/src/schemas/experiments.schema.json index dee04051b83..ee66b5a1e9b 100644 --- a/libraries/rush-lib/src/schemas/experiments.schema.json +++ b/libraries/rush-lib/src/schemas/experiments.schema.json @@ -93,6 +93,11 @@ "useDirectFileTransfersForBuildCache": { "description": "If true, the build cache will use file-based APIs to transfer cache entries to and from cloud storage. This avoids loading the entire cache entry into memory, which can prevent out-of-memory errors for large build outputs and allow cache entries to exceed the limit of a single Buffer. The cloud cache provider plugin must implement the optional file-based methods for this to take effect; otherwise it falls back to the buffer-based approach.", "type": "boolean" + }, + + "provideNpmrcCredentialsViaEnvironment": { + "description": "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. PNPM 10.34.2 and newer ignore such tokens in a project .npmrc file, which otherwise breaks the recommended practice of supplying registry credentials via environment variables in CI.", + "type": "boolean" } }, "additionalProperties": false diff --git a/libraries/rush-lib/src/utilities/npmrcUtilities.ts b/libraries/rush-lib/src/utilities/npmrcUtilities.ts index 7ca6febe487..5212bacb3e0 100644 --- a/libraries/rush-lib/src/utilities/npmrcUtilities.ts +++ b/libraries/rush-lib/src/utilities/npmrcUtilities.ts @@ -27,6 +27,7 @@ function _trimNpmrcFile( | 'linesToPrepend' | 'supportEnvVarFallbackSyntax' | 'filterNpmIncompatibleProperties' + | 'moveSensitiveSettingsToEnvironment' | 'env' > ): string { @@ -36,6 +37,7 @@ function _trimNpmrcFile( linesToAppend, supportEnvVarFallbackSyntax, filterNpmIncompatibleProperties, + moveSensitiveSettingsToEnvironment, env = process.env } = options; @@ -58,7 +60,8 @@ function _trimNpmrcFile( npmrcFileLines, env, supportEnvVarFallbackSyntax, - filterNpmIncompatibleProperties + filterNpmIncompatibleProperties, + moveSensitiveSettingsToEnvironment ); const combinedNpmrc: string = resultLines.join('\n'); @@ -111,25 +114,322 @@ const PROPERTY_NAME_REGEX: RegExp = /^([^=\[\s]+)/; */ const ENV_VAR_WITH_FALLBACK_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); +} + +/** + * 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 _classifySensitiveNpmrcLine}. + */ +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 _classifySensitiveNpmrcLine( + line: string, + env: NodeJS.ProcessEnv, + supportEnvVarFallbackSyntax: boolean +): ISensitiveNpmrcLineAction | undefined { + const equalsIndex: number = line.indexOf('='); + if (equalsIndex < 0) { + // Not a "name=value" setting + return undefined; + } + + const settingName: string = line.substring(0, equalsIndex); + const settingValue: string = line.substring(equalsIndex + 1); + + 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 + }; + } + + // The setting name cannot survive PNPM's environment variable name normalization, so fall back + // to writing the expanded value into the generated .npmrc file. This is less desirable, but the + // generated file is not committed to Git. + return { kind: 'expand', expandedLine: `${expandedName.expandedText}=${expandedValue.expandedText}` }; + } + + 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( + line: string, + env: NodeJS.ProcessEnv, + supportEnvVarFallbackSyntax: boolean +): string | undefined { + const action: ISensitiveNpmrcLineAction | undefined = _classifySensitiveNpmrcLine( + line, + 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 + 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}. * @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 ): 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*[#;]/; @@ -179,43 +479,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) { + const rewrittenLine: string | undefined = _rewriteSensitiveNpmrcLine( + line, + env, + supportEnvVarFallbackSyntax + ); + if (rewrittenLine !== undefined) { + resultLines.push(rewrittenLine); + continue; } } } @@ -262,6 +543,7 @@ interface INpmrcTrimOptions { linesToAppend?: string[]; supportEnvVarFallbackSyntax: boolean; filterNpmIncompatibleProperties?: boolean; + moveSensitiveSettingsToEnvironment?: boolean; env?: NodeJS.ProcessEnv; } @@ -296,6 +578,15 @@ 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; env?: NodeJS.ProcessEnv; } @@ -361,3 +652,64 @@ 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 action: ISensitiveNpmrcLineAction | undefined = _classifySensitiveNpmrcLine( + trimmedLine.substring(PROVIDED_VIA_ENVIRONMENT_PREFIX.length), + 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 3c84a54cfc9..be449f7c7d6 100644 --- a/libraries/rush-lib/src/utilities/test/npmrcUtilities.test.ts +++ b/libraries/rush-lib/src/utilities/test/npmrcUtilities.test.ts @@ -205,5 +205,122 @@ 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('expands settings 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' + }) + ).toEqual(['//registry.example.com:8080/:_authToken=token123']); + }); + + 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}' + ]); + }); + }); }); }); From 93ca0fbcd65fa5ad3f33384a8ef85e2803a78b08 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Aug 2026 06:55:47 +0000 Subject: [PATCH 2/3] Destructure rushConfiguration in shouldProvideNpmrcCredentialsViaEnvironment Co-authored-by: iclanton <5010588+iclanton@users.noreply.github.com> --- .../src/logic/installManager/InstallHelpers.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts b/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts index d7bef6a6f07..12d2d466def 100644 --- a/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts +++ b/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts @@ -386,10 +386,13 @@ export class InstallHelpers { public static shouldProvideNpmrcCredentialsViaEnvironment(rushConfiguration: RushConfiguration): boolean { // Only PNPM refuses to expand these tokens, and only PNPM's "npm_config_*" environment variable // name normalization has been validated here. - return ( - rushConfiguration.isPnpm && - !!rushConfiguration.experimentsConfiguration.configuration.provideNpmrcCredentialsViaEnvironment - ); + const { + isPnpm, + experimentsConfiguration: { + configuration: { provideNpmrcCredentialsViaEnvironment = false } + } + } = rushConfiguration; + return isPnpm && provideNpmrcCredentialsViaEnvironment; } /** From a34bd29771cb10d35bd34cad5adb1f063c0a419f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 29 Aug 2026 00:29:04 +0000 Subject: [PATCH 3/3] Address npmrc credential review feedback Co-authored-by: iclanton <5010588+iclanton@users.noreply.github.com> --- .../installManager/RushInstallManager.ts | 5 +- .../installManager/WorkspaceInstallManager.ts | 3 + .../rush-lib/src/utilities/npmrcUtilities.ts | 8 +- .../src/utilities/test/npmrcUtilities.test.ts | 73 +++++++++++++++++-- 4 files changed, 78 insertions(+), 11 deletions(-) diff --git a/libraries/rush-lib/src/logic/installManager/RushInstallManager.ts b/libraries/rush-lib/src/logic/installManager/RushInstallManager.ts index ff64f638de6..8985569cd22 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 f3260f1a8b3..b58bff78507 100644 --- a/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts +++ b/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts @@ -496,6 +496,8 @@ export class WorkspaceInstallManager extends BaseInstallManager { this.rushConfiguration, { ...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/utilities/npmrcUtilities.ts b/libraries/rush-lib/src/utilities/npmrcUtilities.ts index 5212bacb3e0..4351d10d9cd 100644 --- a/libraries/rush-lib/src/utilities/npmrcUtilities.ts +++ b/libraries/rush-lib/src/utilities/npmrcUtilities.ts @@ -366,10 +366,10 @@ function _classifySensitiveNpmrcLine( }; } - // The setting name cannot survive PNPM's environment variable name normalization, so fall back - // to writing the expanded value into the generated .npmrc file. This is less desirable, but the - // generated file is not committed to Git. - return { kind: 'expand', expandedLine: `${expandedName.expandedText}=${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 = diff --git a/libraries/rush-lib/src/utilities/test/npmrcUtilities.test.ts b/libraries/rush-lib/src/utilities/test/npmrcUtilities.test.ts index be449f7c7d6..a7ced6de614 100644 --- a/libraries/rush-lib/src/utilities/test/npmrcUtilities.test.ts +++ b/libraries/rush-lib/src/utilities/test/npmrcUtilities.test.ts @@ -1,7 +1,11 @@ // 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 * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { getNpmrcEnvironmentVariables, syncNpmrc, trimNpmrcFileLines } from '../npmrcUtilities'; describe('npmrcUtilities', () => { function runTests(supportEnvVarFallbackSyntax: boolean): void { @@ -256,14 +260,17 @@ describe('npmrcUtilities', () => { ]); }); - it('expands settings whose names cannot round-trip through an environment variable', () => { + 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' - }) - ).toEqual(['//registry.example.com:8080/:_authToken=token123']); + () => + 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', () => { @@ -323,4 +330,58 @@ describe('npmrcUtilities', () => { }); }); }); + + describe(getNpmrcEnvironmentVariables.name, () => { + it('returns credentials moved by syncNpmrc', () => { + const tempFolder: string = fs.mkdtempSync(path.join(os.tmpdir(), 'rush-npmrc-')); + const sourceFolder: string = path.join(tempFolder, 'source'); + const targetFolder: string = path.join(tempFolder, 'target'); + fs.mkdirSync(sourceFolder); + fs.writeFileSync( + path.join(sourceFolder, '.npmrc'), + [ + '//registry.example.com/npm/:_authToken=${NPM_AUTH_TOKEN}', + '//other.example.com/npm/:_password=${NPM_PASSWORD:-fallbackPassword}' + ].join('\n') + ); + + 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 { + fs.rmSync(tempFolder, { recursive: true, force: true }); + } + }); + + it('returns undefined when the generated .npmrc file is missing', () => { + const tempFolder: string = fs.mkdtempSync(path.join(os.tmpdir(), 'rush-npmrc-')); + try { + expect( + getNpmrcEnvironmentVariables({ + npmrcFolder: tempFolder, + supportEnvVarFallbackSyntax: true + }) + ).toBeUndefined(); + } finally { + fs.rmSync(tempFolder, { recursive: true, force: true }); + } + }); + }); });