From 81d4d98cfcba092535d9131c4208e15cecf5cd30 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/5] 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 | 4 + .../rush-lib/src/utilities/npmrcUtilities.ts | 436 ++++++++++++++++-- .../src/utilities/test/npmrcUtilities.test.ts | 117 +++++ 12 files changed, 638 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 1853464f8b2..e1839f13f69 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 5b959c4feda..f4d588baca7 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,16 @@ * 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 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 f41cf7a033d..d184ab275e6 100644 --- a/libraries/rush-lib/src/api/ExperimentsConfiguration.ts +++ b/libraries/rush-lib/src/api/ExperimentsConfiguration.ts @@ -162,6 +162,20 @@ 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 + * 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 445ab9cb9f6..056342a8d6c 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, 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 706b92e8f8c0aaa8e852684a3fa3424c3ae7b8fb 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/5] 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 93ec8c2c8e0ae693befa0927a94e1dd1a6e31c74 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/5] 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 }); + } + }); + }); }); From 5814f56019c548741f0ef4e925e0ba720f159073 Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Wed, 2 Sep 2026 05:00:35 +0000 Subject: [PATCH 4/5] Avoid mutating package manager base environment Clone the supplied base environment inside the merge helper so callers cannot accidentally modify process.env while adding package manager settings or credentials. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 94e3515c-0412-4943-b7c2-ee910f2be5df --- .../src/logic/installManager/InstallHelpers.ts | 2 +- .../src/logic/test/InstallHelpers.test.ts | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts b/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts index 12d2d466def..b2056b1d13e 100644 --- a/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts +++ b/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts @@ -556,7 +556,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/test/InstallHelpers.test.ts b/libraries/rush-lib/src/logic/test/InstallHelpers.test.ts index cfc2319219a..c30715677e5 100644 --- a/libraries/rush-lib/src/logic/test/InstallHelpers.test.ts +++ b/libraries/rush-lib/src/logic/test/InstallHelpers.test.ts @@ -10,6 +10,22 @@ import { RushConfiguration } from '../../api/RushConfiguration'; import type { PnpmWorkspaceFile } from '../pnpm/PnpmWorkspaceFile'; describe(InstallHelpers.name, () => { + 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; From bc14ebb29b100a4be11daf5d2171ae5ca6284bab Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Fri, 4 Sep 2026 17:49:59 +0000 Subject: [PATCH 5/5] Support pnpm native environment credentials Use Rush's compatibility translation only for patched pnpm versions that lack URL-scoped credential environment variables. Warn and document the required trusted configuration migration for pnpm 11.6 and newer. Assistant-model: GPT-5.6 Sol Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 94e3515c-0412-4943-b7c2-ee910f2be5df --- apps/rush/UPGRADING.md | 45 +++++++++ ...edentials-experiment_2026-08-28-05-19.json | 2 +- .../common/config/rush/experiments.json | 18 ++-- .../src/api/ExperimentsConfiguration.ts | 9 +- .../src/logic/base/BaseInstallManager.ts | 25 ++++- .../logic/installManager/InstallHelpers.ts | 15 ++- .../src/logic/test/InstallHelpers.test.ts | 44 +++++++++ .../src/schemas/experiments.schema.json | 2 +- libraries/rush-lib/src/utilities/Utilities.ts | 5 +- .../rush-lib/src/utilities/npmrcUtilities.ts | 93 ++++++++++++++----- .../src/utilities/test/npmrcUtilities.test.ts | 71 +++++++++----- 11 files changed, 266 insertions(+), 63 deletions(-) diff --git a/apps/rush/UPGRADING.md b/apps/rush/UPGRADING.md index 000e3bbb3fc..eccb56d59c9 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 index 623cff8b6fb..cad20218eaf 100644 --- 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 @@ -2,7 +2,7 @@ "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.", + "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/libraries/rush-lib/assets/rush-init/common/config/rush/experiments.json b/libraries/rush-lib/assets/rush-init/common/config/rush/experiments.json index f4d588baca7..a8c4c01cb4e 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 @@ -153,13 +153,17 @@ /*[LINE "HYPOTHETICAL"]*/ "trimRushEnvironmentVariablesForOperations": 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. + * 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 d184ab275e6..658671e14c9 100644 --- a/libraries/rush-lib/src/api/ExperimentsConfiguration.ts +++ b/libraries/rush-lib/src/api/ExperimentsConfiguration.ts @@ -170,10 +170,11 @@ export interface IExperimentsJson { * `.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. + * 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; } diff --git a/libraries/rush-lib/src/logic/base/BaseInstallManager.ts b/libraries/rush-lib/src/logic/base/BaseInstallManager.ts index 2a44dfea239..a8d87e064f7 100644 --- a/libraries/rush-lib/src/logic/base/BaseInstallManager.ts +++ b/libraries/rush-lib/src/logic/base/BaseInstallManager.ts @@ -554,6 +554,17 @@ 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(), @@ -562,10 +573,22 @@ export abstract class BaseInstallManager { 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 b2056b1d13e..8ae6806b00d 100644 --- a/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts +++ b/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts @@ -384,15 +384,24 @@ export class InstallHelpers { * `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. const { isPnpm, + packageManagerToolVersion, experimentsConfiguration: { configuration: { provideNpmrcCredentialsViaEnvironment = false } } } = rushConfiguration; - return isPnpm && provideNpmrcCredentialsViaEnvironment; + 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')) + ); } /** diff --git a/libraries/rush-lib/src/logic/test/InstallHelpers.test.ts b/libraries/rush-lib/src/logic/test/InstallHelpers.test.ts index c30715677e5..e974122307f 100644 --- a/libraries/rush-lib/src/logic/test/InstallHelpers.test.ts +++ b/libraries/rush-lib/src/logic/test/InstallHelpers.test.ts @@ -10,6 +10,50 @@ 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`; diff --git a/libraries/rush-lib/src/schemas/experiments.schema.json b/libraries/rush-lib/src/schemas/experiments.schema.json index 056342a8d6c..fa4d2ee1308 100644 --- a/libraries/rush-lib/src/schemas/experiments.schema.json +++ b/libraries/rush-lib/src/schemas/experiments.schema.json @@ -99,7 +99,7 @@ "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.", + "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" } }, diff --git a/libraries/rush-lib/src/utilities/Utilities.ts b/libraries/rush-lib/src/utilities/Utilities.ts index 1b4ca37cc41..430a04decae 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 4351d10d9cd..6544dd7268f 100644 --- a/libraries/rush-lib/src/utilities/npmrcUtilities.ts +++ b/libraries/rush-lib/src/utilities/npmrcUtilities.ts @@ -28,6 +28,7 @@ function _trimNpmrcFile( | 'supportEnvVarFallbackSyntax' | 'filterNpmIncompatibleProperties' | 'moveSensitiveSettingsToEnvironment' + | 'environmentVariableSettingNames' | 'env' > ): string { @@ -38,6 +39,7 @@ function _trimNpmrcFile( supportEnvVarFallbackSyntax, filterNpmIncompatibleProperties, moveSensitiveSettingsToEnvironment, + environmentVariableSettingNames, env = process.env } = options; @@ -61,7 +63,8 @@ function _trimNpmrcFile( env, supportEnvVarFallbackSyntax, filterNpmIncompatibleProperties, - moveSensitiveSettingsToEnvironment + moveSensitiveSettingsToEnvironment, + environmentVariableSettingNames ); const combinedNpmrc: string = resultLines.join('\n'); @@ -114,6 +117,9 @@ 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) @@ -187,6 +193,35 @@ 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. @@ -300,7 +335,7 @@ function _expandEnvironmentVariables( /** * Describes how a .npmrc setting containing `${VAR}` tokens must be transformed so that PNPM will - * honor it. See {@link _classifySensitiveNpmrcLine}. + * honor it. See {@link _classifySensitiveNpmrcSetting}. */ type ISensitiveNpmrcLineAction = | { @@ -326,19 +361,12 @@ type ISensitiveNpmrcLineAction = * 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, +function _classifySensitiveNpmrcSetting( + setting: IParsedNpmrcSetting, 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 { name: settingName, value: settingValue } = setting; const expandedName: IEnvironmentVariableExpansionResult = _expandEnvironmentVariables( settingName, @@ -389,12 +417,12 @@ function _classifySensitiveNpmrcLine( * if the line does not need to be rewritten. */ function _rewriteSensitiveNpmrcLine( - line: string, + setting: IParsedNpmrcSetting, env: NodeJS.ProcessEnv, supportEnvVarFallbackSyntax: boolean ): string | undefined { - const action: ISensitiveNpmrcLineAction | undefined = _classifySensitiveNpmrcLine( - line, + const action: ISensitiveNpmrcLineAction | undefined = _classifySensitiveNpmrcSetting( + setting, env, supportEnvVarFallbackSyntax ); @@ -402,7 +430,7 @@ function _rewriteSensitiveNpmrcLine( case 'environment': // Example output: // "; PROVIDED VIA ENVIRONMENT: //my-registry.com/npm/:_authToken=${MY_AUTH_TOKEN}" - return PROVIDED_VIA_ENVIRONMENT_PREFIX + line; + return PROVIDED_VIA_ENVIRONMENT_PREFIX + setting.line; case 'expand': return action.expandedLine; default: @@ -419,6 +447,8 @@ function _rewriteSensitiveNpmrcLine( * @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( @@ -426,7 +456,8 @@ export function trimNpmrcFileLines( env: NodeJS.ProcessEnv, supportEnvVarFallbackSyntax: boolean, filterNpmIncompatibleProperties: boolean = false, - moveSensitiveSettingsToEnvironment: boolean = false + moveSensitiveSettingsToEnvironment: boolean = false, + environmentVariableSettingNames?: Set ): string[] { const resultLines: string[] = []; @@ -446,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 '[') @@ -488,9 +524,9 @@ export function trimNpmrcFileLines( if (hasUndefinedVariable) { lineShouldBeTrimmed = true; trimReason = 'MISSING_ENVIRONMENT_VARIABLE'; - } else if (hasVariable && moveSensitiveSettingsToEnvironment) { + } else if (hasVariable && moveSensitiveSettingsToEnvironment && parsedSetting) { const rewrittenLine: string | undefined = _rewriteSensitiveNpmrcLine( - line, + parsedSetting, env, supportEnvVarFallbackSyntax ); @@ -544,6 +580,11 @@ interface INpmrcTrimOptions { 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; } @@ -587,6 +628,11 @@ export interface ISyncNpmrcOptions { * 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; } @@ -700,11 +746,10 @@ export function getNpmrcEnvironmentVariables( continue; } - const action: ISensitiveNpmrcLineAction | undefined = _classifySensitiveNpmrcLine( - trimmedLine.substring(PROVIDED_VIA_ENVIRONMENT_PREFIX.length), - env, - supportEnvVarFallbackSyntax - ); + 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; diff --git a/libraries/rush-lib/src/utilities/test/npmrcUtilities.test.ts b/libraries/rush-lib/src/utilities/test/npmrcUtilities.test.ts index a7ced6de614..0ee88363c88 100644 --- a/libraries/rush-lib/src/utilities/test/npmrcUtilities.test.ts +++ b/libraries/rush-lib/src/utilities/test/npmrcUtilities.test.ts @@ -1,13 +1,40 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import * as fs from 'node:fs'; -import * as os from 'node:os'; -import * as path from 'node:path'; - +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([]); @@ -263,11 +290,10 @@ describe('npmrcUtilities', () => { 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' - }) + 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' ); @@ -332,17 +358,18 @@ 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'), + 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') + ].join('\n'), + { ensureFolderExists: true } ); try { @@ -366,12 +393,14 @@ describe('npmrcUtilities', () => { 'npm_config_//other.example.com/npm/:_password': 'fallbackPassword' }); } finally { - fs.rmSync(tempFolder, { recursive: true, force: true }); + await FileSystem.deleteFolderAsync(tempFolder); } }); - it('returns undefined when the generated .npmrc file is missing', () => { - const tempFolder: string = fs.mkdtempSync(path.join(os.tmpdir(), 'rush-npmrc-')); + 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({ @@ -380,7 +409,7 @@ describe('npmrcUtilities', () => { }) ).toBeUndefined(); } finally { - fs.rmSync(tempFolder, { recursive: true, force: true }); + await FileSystem.deleteFolderAsync(tempFolder); } }); });