nx
Version:
1,552 lines • 76.6 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.getPnpmSpawnRegistryEnv = getPnpmSpawnRegistryEnv;
const fs_1 = require("fs");
const os_1 = require("os");
const path_1 = require("path");
const semver_1 = require("semver");
const pnpm_config_1 = require("../package-manager-config/pnpm-config");
const npmrc_1 = require("../package-manager-config/npmrc");
const fileutils_1 = require("../fileutils");
const logger_1 = require("../logger");
const utils_1 = require("./utils");
/*
* pnpm registry resolution, by version line:
*
* - < 10.6.0: registry config lives only in the .npmrc chain and npm_config_*
* env vars, which a spawned npm resolves identically on its own. Nothing to
* bridge.
* - 10.6.0 - 10.x: pnpm-workspace.yaml accepts every .npmrc setting in
* camelCase (https://github.com/pnpm/pnpm/pull/9211) and the parsed yaml
* object is Object.assign-ed over the npmrc-derived config, so a
* `registries` map (default/@scope keys) wholesale-replaces the
* npmrc/env/CLI registry selection. That config keeps npm's own tiers plus a
* `workspace` one, the .npmrc beside the workspace manifest, ranked under the
* project .npmrc and over the user one.
* - >= 11.0.0: the config reader merges per key: registries =
* {...fromNpmrc, ...fromYaml}, then `pnpm_config_registry` env overrides
* only `registries.default`. npm_config_* env vars are no longer read (11.6.0
* restores the URL-scoped `//<dart>:<key>` ones alone), and .npmrc is
* restricted to auth/registry/network keys. The per-package lookup is
* registries[scope] ?? registries.default. An `auth.ini` file in pnpm's
* config dir layers between the user and workspace .npmrc. Because pnpm
* ignores npm_config_* here, the overlay this builds is consumed by the
* spawned `npm pack` (and a forced `npm view`), not by `pnpm view`, which
* resolves natively.
* - >= 11.10.0: a JSON auth tier (`pnpm_config__auth` env over the global
* config.yaml `_auth`) layers credentials above the URL-scoped env tier and
* registries above the yaml.
*/
const DEFAULT_REGISTRY = 'https://registry.npmjs.org/';
const BARE_AUTH_KEYS = [
'_authToken',
'_auth',
'username',
'_password',
];
// pnpm's UNSCOPED_RESCOPABLE_KEYS: the credentials above plus the client TLS
// material and the helper, all of which it pins to the declaring file's own
// registry rather than leaving unscoped.
const PNPM_RESCOPABLE_KEYS = [
...BARE_AUTH_KEYS,
'tokenHelper',
'cert',
'key',
];
/**
* pnpm's normalize-registry-url as it reads from 11.15.1: a trailing slash on
* whatever the URL parser makes of the value. Before that it appended one only
* to a URL with no path at all, which nerfDart already resolves the same way, so
* only this form needs reproducing.
*/
function normalizePnpmRegistry(registry) {
let normalized = registry;
try {
normalized = new URL(registry).toString();
}
catch {
// Left as written, the way pnpm leaves it; nerfDart rejects it next.
}
return normalized.endsWith('/') ? normalized : `${normalized}/`;
}
/**
* The nerf dart pnpm keys a registry's own settings on. Normalizing first moves
* it a path segment deeper than npm's plain dart: `https://h/api/npm` keys on
* `//h/api/npm/`, not on its parent `//h/api/`, so an unscoped credential pinned
* there no longer reaches a sibling path. 10.x still bundles the older
* normalizer through 10.34.5, where the plain dart is what pnpm computes.
*/
function pnpmNerfDart(registry, pnpmVersion) {
return (0, utils_1.nerfDart)((0, semver_1.gte)(pnpmVersion, '11.15.1') ? normalizePnpmRegistry(registry) : registry);
}
/**
* pnpm's workspaceIsTrustedAuthFile: the workspace .npmrc doubling as the file
* pnpm authenticates from. It then expands the references it withholds from a
* project-controlled file. pnpm reads the coincident path at both tiers and
* merges them, which the workspace copy wins outright, so it is read once here.
*/
function isTrustedWorkspaceNpmrc(workspaceDir, userConfigPath) {
return (0, path_1.resolve)(workspaceDir, '.npmrc') === userConfigPath;
}
function getPnpmSpawnRegistryEnv(packageName, root, pnpmVersion) {
const env = {};
// Which surfaces this pnpm honors depends on the version, so an undetermined
// one bridges nothing.
if (!pnpmVersion || (0, semver_1.lt)(pnpmVersion, '10.6.0')) {
return env;
}
const workspaceFile = findPnpmWorkspaceFile(root, pnpmVersion);
const settings = readPnpmWorkspaceSettings(workspaceFile, pnpmVersion);
const scope = (0, utils_1.getPackageScope)(packageName);
// Kept identical to the predicate the caller hands mergeNpmConfigEnv at spawn
// time, which drops the bridged ambient npm_config_* this answers true for
// (settings outside the bridged set stay ambient either way).
const managerIgnoresEnv = (0, utils_1.ignoresNpmConfigEnv)('pnpm', pnpmVersion);
if ((0, semver_1.lt)(pnpmVersion, '11.0.0')) {
// The replace wipes the npmrc/env/CLI selection outright, so the scoped key
// is forced to the yaml default when the map has no entry for the scope. A
// scoped-only map leaves pnpm no default at all, which crashes it on an
// unscoped target but resolves a scoped one fine, so npm's own default is
// left in place rather than aimed at a registry pnpm uses only for that
// scope.
const yamlDefault = pickYamlRegistry(settings, 'default', workspaceFile);
if (yamlDefault) {
(0, utils_1.setRegistry)(env, yamlDefault);
}
const pick = scope
? (pickYamlRegistry(settings, scope, workspaceFile) ?? yamlDefault)
: undefined;
if (scope && pick) {
(0, utils_1.setScopedRegistry)(env, scope, pick);
}
// Both .npmrc files pnpm reads here, project first, which is the order both
// the bridge and the bypass list resolve them in.
const npmrcPaths = pnpmNpmrcPaths(root, workspaceFile);
// On this version line pnpm's user config is npm's own (no auth.ini, no
// npmrcAuthFile), always a file npm reads for itself, so it is a tier the
// derived settings resolve across rather than one to bridge.
const userConfigPath = getNpmUserConfigPath(root);
const npmrcProxies = bridgeWorkspaceNpmrc(env, npmrcPaths, userConfigPath, scope, pnpmVersion);
// auth.ini is an 11.x file, so the .npmrc chain is the whole of what can
// need its bypass list re-spelled here.
bridgeNoProxy(env, [...npmrcPaths, userConfigPath], pnpmVersion);
// Applied last: pnpm assigns the yaml over the whole npmrc-derived config,
// so what it declares outranks everything the files above contributed.
applyYamlNetworkSettings(env, settings);
applyResolvedProxies(env, [settings, npmrcProxies], root, scope, managerIgnoresEnv);
reportCredentialDivergences(env, root, scope, userConfigPath, npmrcPaths.map((path) => ({ path, filtered: false })), pnpmVersion, managerIgnoresEnv);
return env;
}
// The yaml-only keys go in at npm's env tier, where npm's per-key chain
// reproduces pnpm's ordering: a project .npmrc @scope:registry still beats an
// injected default, while an injected @scope:registry beats the project
// .npmrc scoped key (yaml @scope > npmrc @scope in pnpm). JSON-auth
// registries sit above the yaml and below the named env registry, which pnpm
// applies onto registries.default after every spread.
const jsonAuth = readJsonAuthTier(pnpmVersion);
const globalSettings = readPnpmGlobalSettings(pnpmVersion);
const globalPath = getGlobalConfigPath();
const scopedRegistry = scope
? (jsonAuth?.registries[scope] ??
pickYamlRegistry(settings, scope, workspaceFile) ??
pickYamlRegistry(globalSettings, scope, globalPath))
: undefined;
if (scope && scopedRegistry) {
(0, utils_1.setScopedRegistry)(env, scope, scopedRegistry);
}
// A top-level `registry` in a yaml file is an explicitly set key, which pnpm
// applies onto registries.default on its own. Where it does that moved: from
// 11.5.3 before the workspace file is even read, so only the global file's
// reaches it and every map still outranks it, and from 11.10.0 again after
// every map has merged, which puts both files' above the JSON auth tier.
const lateScalar = (0, semver_1.gte)(pnpmVersion, '11.10.0')
? settings.registry || globalSettings.registry
: undefined;
const earlyScalar = (0, semver_1.gte)(pnpmVersion, '11.5.3') && (0, semver_1.lt)(pnpmVersion, '11.10.0')
? globalSettings.registry
: undefined;
const defaultRegistry = readPnpmEnvVar('registry', pnpmVersion) ??
lateScalar ??
jsonAuth?.registries['default'] ??
pickYamlRegistry(settings, 'default', workspaceFile) ??
earlyScalar ??
pickYamlRegistry(globalSettings, 'default', globalPath);
if (defaultRegistry) {
(0, utils_1.setRegistry)(env, defaultRegistry);
}
const authIniPath = getAuthIniPath();
const userConfigPath = getPnpmUserConfigPath(pnpmVersion, root);
applyUrlScopedEnvConfig(env, pnpmVersion);
applyJsonAuthCredentials(env, scope, jsonAuth);
// From 11 pnpm reads one project .npmrc, and it is the one beside the
// workspace file it walked up to, not the one in the directory it runs in
// (loadNpmrcConfig's `workspaceDir ?? localPrefix`). npm reads the latter for
// itself, so a nested workspace puts the two readers on different files.
const workspaceDir = workspaceFile ? (0, path_1.dirname)(workspaceFile) : root;
const npmrcProxies = bridgeNpmrcSources(env, root, workspaceDir, scope, authIniPath, userConfigPath, pnpmVersion, managerIgnoresEnv);
reportCredentialDivergences(env, root, scope, userConfigPath, [
{
path: (0, path_1.join)(workspaceDir, '.npmrc'),
filtered: !isTrustedWorkspaceNpmrc(workspaceDir, userConfigPath),
},
{ path: authIniPath, filtered: false },
], pnpmVersion, managerIgnoresEnv);
// resolveNoProxy takes the bypass list across every layer below, so the yaml
// does not write it here.
applyYamlNetworkSettings(env, globalSettings, false);
applyYamlNetworkSettings(env, settings, false);
applyEnvNetworkSettings(env, pnpmVersion);
applyResolvedProxies(env, [envProxyDeclarations(pnpmVersion), settings, globalSettings, npmrcProxies], root, scope, managerIgnoresEnv);
const noProxy = resolveNoProxy(settings, globalSettings, workspaceDir, authIniPath, userConfigPath, pnpmVersion);
if (noProxy) {
(0, utils_1.setProxies)(env, { noProxy });
}
return env;
}
/**
* pnpm's own env reader: the lowercase prefix, then the uppercase one (which
* only arrived in 11.0.6), with an empty value counting as undeclared.
* See readEnvVar in pnpm's config reader.
*/
function readPnpmEnvVar(key, pnpmVersion) {
const value = process.env[`pnpm_config_${key}`] ??
((0, semver_1.gte)(pnpmVersion, '11.0.6')
? process.env[`PNPM_CONFIG_${key.toUpperCase()}`]
: undefined);
return value || undefined;
}
/**
* The URL-scoped entries pnpm >= 11.6.0 reads from the environment
* (readUrlScopedEnvConfig): `p?npm_config_//<dart>:<key>`, case-insensitive
* prefix, minus `:tokenHelper`, which pnpm refuses to take from it. The
* `npm_config_` spellings reach the spawned npm ambiently (mergeNpmConfigEnv
* keeps them for these versions); the `pnpm_config_` spellings are invisible to
* npm, so re-spell those onto the overlay, which also reproduces pnpm merging
* its own prefix above npm's for the same dart.
*/
function applyUrlScopedEnvConfig(env, pnpmVersion) {
if ((0, semver_1.lt)(pnpmVersion, '11.6.0')) {
return;
}
for (const [key, value] of Object.entries(process.env)) {
// pnpm skips a null or empty value outright, matching npm's env tier.
if (!value) {
continue;
}
const match = /^pnpm_config_(\/\/.+)$/i.exec(key);
if (!match || match[1].endsWith(':tokenHelper')) {
continue;
}
// pnpm takes the value as written, so escape what npm's env tier would
// otherwise expand out of a credential that carries a `${VAR}` of its own.
env[`npm_config_${match[1]}`] = (0, utils_1.escapeNpmEnvExpr)(value);
}
}
/**
* The JSON auth tier pnpm reads from 11.10.0: `pnpm_config__auth` (then the
* uppercase spelling, an empty value skipped) parsed as JSON, over the global
* config.yaml's top-level `_auth`, merged per entry with the env winning.
* Its registries outrank the workspace yaml and lose to the named env/CLI
* registry; its credentials outrank the URL-scoped env tier and every file.
* pnpm dies on a declaration it cannot parse, so that throws into the caller's
* fall-open. See readJsonAuthEnv/parseJsonAuth in pnpm's config reader.
*/
function readJsonAuthTier(pnpmVersion) {
if ((0, semver_1.lt)(pnpmVersion, '11.10.0')) {
return null;
}
const raw = process.env['pnpm_config__auth'] ||
process.env['PNPM_CONFIG__AUTH'] ||
undefined;
let envTier = null;
if (raw !== undefined) {
let parsed;
try {
parsed = JSON.parse(raw);
}
catch {
throw new Error('The pnpm_config__auth environment variable is not valid JSON.');
}
envTier = parsePnpmJsonAuth(parsed, 'pnpm_config__auth', pnpmVersion);
}
const yamlAuth = readPnpmGlobalConfigYaml()?.['_auth'];
const yamlTier = yamlAuth != null ? parsePnpmJsonAuth(yamlAuth, '_auth', pnpmVersion) : null;
if (!envTier && !yamlTier) {
return null;
}
const merged = new Map();
for (const tier of [yamlTier, envTier]) {
for (const entry of tier?.auth ?? []) {
merged.set(`${entry.scope}\0${entry.dart}`, entry);
}
}
return {
auth: [...merged.values()],
registries: {
...(yamlTier?.registries ?? {}),
...(envTier?.registries ?? {}),
},
};
}
/**
* pnpm's parseJsonAuth: registry URL over scope, each leaf exactly
* { authToken: string }, every violation fatal. Messages carry the setting
* name and entry position rather than the entry itself, since a malformed
* URL key can embed credentials.
*/
function parsePnpmJsonAuth(parsed, source, pnpmVersion) {
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error(`The pnpm ${source} setting must be a JSON object of registry URLs.`);
}
const auth = [];
const registries = {};
let entryNumber = 0;
for (const [rawUrl, scopes] of Object.entries(parsed)) {
entryNumber++;
let url;
try {
url = new URL(rawUrl);
}
catch {
throw invalidJsonAuthEntry(source, entryNumber);
}
if ((url.protocol !== 'https:' && url.protocol !== 'http:') ||
url.hostname === '' ||
url.username !== '' ||
url.password !== '' ||
url.search !== '' ||
url.hash !== '') {
throw invalidJsonAuthEntry(source, entryNumber);
}
const dart = pnpmNerfDart(url.href, pnpmVersion);
if (!dart) {
throw invalidJsonAuthEntry(source, entryNumber);
}
if (scopes === null ||
typeof scopes !== 'object' ||
Array.isArray(scopes)) {
throw invalidJsonAuthScopes(source, entryNumber);
}
for (const [scope, creds] of Object.entries(scopes)) {
const validScope = scope === '@' ||
(scope.startsWith('@') &&
scope.length > 1 &&
!scope.includes('/') &&
!scope.includes(':'));
if (!validScope ||
creds === null ||
typeof creds !== 'object' ||
Array.isArray(creds) ||
Object.keys(creds).some((field) => field !== 'authToken') ||
typeof creds['authToken'] !== 'string') {
throw invalidJsonAuthScopes(source, entryNumber);
}
auth.push({
dart,
scope,
token: creds['authToken'],
});
registries[scope === '@' ? 'default' : scope] = url.href;
}
}
return { auth, registries };
}
function invalidJsonAuthEntry(source, entryNumber) {
return new Error(`Entry ${entryNumber} of the pnpm ${source} setting is not a plain http(s) registry URL.`);
}
function invalidJsonAuthScopes(source, entryNumber) {
return new Error(`Entry ${entryNumber} of the pnpm ${source} setting must map scopes ("@" or "@org") to { "authToken": string } objects.`);
}
/**
* npm has no scope-qualified auth key, so a scoped entry lands on the plain
* dart, and only for the scope of the package being fetched: pnpm would not
* send that token for anything else. Registry-wide entries go first so a
* scoped token for the same registry wins, the way pnpm's per-scope credential
* lookup prefers the specific entry. Every registry in the map is bridged, on
* the same grounds as the auth.ini dart loop.
*/
function applyJsonAuthCredentials(env, scope, jsonAuth) {
if (!jsonAuth) {
return;
}
for (const wanted of scope ? ['@', scope] : ['@']) {
for (const entry of jsonAuth.auth) {
if (entry.scope === wanted) {
// The token sits in a nested object, which pnpm's yaml replacer passes
// through untouched, so it is escaped rather than expanded here: npm
// would otherwise resolve a `${VAR}` pnpm sends as written.
env[`npm_config_${entry.dart}:_authToken`] = (0, utils_1.escapeNpmEnvExpr)(entry.token);
}
}
}
}
/**
* The TLS settings pnpm >= 11 takes from its own `PNPM_CONFIG_*` prefix. They
* outrank pnpm-workspace.yaml, so they are applied after it. `cafile` is left
* out on purpose: pnpm accepts it and then never uses it for the fetch, the
* same dead config as the yaml key.
*/
function applyEnvNetworkSettings(env, pnpmVersion) {
const strictSsl = readPnpmEnvVar('strict_ssl', pnpmVersion);
if (strictSsl !== undefined) {
// parseField types this Boolean, so only an explicit 'false' turns
// verification off.
(0, utils_1.setStrictSsl)(env, strictSsl !== 'false');
}
}
/** The proxy settings pnpm >= 11 takes from its own `PNPM_CONFIG_*` prefix. */
function envProxyDeclarations(pnpmVersion) {
return {
proxy: readPnpmEnvVar('proxy', pnpmVersion),
httpProxy: readPnpmEnvVar('http_proxy', pnpmVersion),
httpsProxy: readPnpmEnvVar('https_proxy', pnpmVersion),
};
}
/**
* The two proxies npm should end up with for `registry`. pnpm resolves each of
* the three settings across every tier first, and only then falls back from
* httpsProxy to the legacy proxy and from httpProxy to whichever of those won,
* so the derivation cannot be done per tier: a workspace file's httpsProxy is
* what an environment-supplied `proxy` leaves undeclared, and so still wins.
*
* npm has no http-only proxy, its `proxy` serving https too when `https-proxy`
* is unset, so an http-only one is withheld unless http is what npm requests.
* A value npm already resolves for itself under the same key is left to it.
*/
function resolveProxies(tiers, registry, npmSees) {
const declared = (key) => tiers.map((tier) => tier[key]).find(Boolean);
const httpsProxy = declared('httpsProxy') || declared('proxy');
const httpProxy = declared('httpProxy') || httpsProxy;
const send = (value, npmKey) => value === npmSees(npmKey) ? undefined : value;
return {
httpProxy: send(httpsProxy || registry.startsWith('http://') ? httpProxy : undefined, 'proxy'),
httpsProxy: send(httpsProxy, 'https-proxy'),
};
}
/**
* The proxy-bypass list pnpm >= 11 ends up using. It reads the `no-proxy`
* spelling and only falls back to `noproxy`, so the spelling decides before the
* layer does: a workspace .npmrc `no-proxy` beats a pnpm-workspace.yaml
* `noproxy`. Within one spelling the env sits above the yaml files, the
* workspace one above the global one, and those above the npmrc-family files,
* which only the `no-proxy` spelling reaches.
* See createPackageManagerNetworkConfig in pnpm's config reader.
*/
function resolveNoProxy(settings, globalSettings, npmrcDir, authIniPath, userConfigPath, pnpmVersion) {
const envNoProxy = readPnpmEnvVar('no_proxy', pnpmVersion);
if (envNoProxy) {
return envNoProxy;
}
const yamlNoProxy = settings.noProxy ?? globalSettings.noProxy;
if (yamlNoProxy) {
return yamlNoProxy;
}
const fromFiles = fileNoProxy([(0, path_1.join)(npmrcDir, '.npmrc'), authIniPath, userConfigPath], pnpmVersion);
if (fromFiles) {
return fromFiles;
}
return (readPnpmEnvVar('noproxy', pnpmVersion) ??
settings.noproxy ??
globalSettings.noproxy);
}
/**
* pnpm looks pnpm-workspace.yaml up before a reader that only tolerates ENOENT,
* so whatever this misses reads as absent while a file it finds and cannot open
* aborts the command. 11.8.0 swapped find-up, which requires the match to be a
* file, for a bare existence check, which is where a directory in the file's
* place stops being looked past.
*/
function pnpmFindsWorkspaceFile(path, pnpmVersion) {
return (0, semver_1.lt)(pnpmVersion, '11.8.0') ? (0, fileutils_1.fileExists)(path) : (0, fs_1.existsSync)(path);
}
const WORKSPACE_MANIFEST_FILENAME = 'pnpm-workspace.yaml';
/**
* pnpm's INVALID_WORKSPACE_MANIFEST_FILENAME: names near enough the real one
* that it looks for them alongside it and refuses to walk past one it finds.
*/
const MISSPELLED_WORKSPACE_MANIFEST_NAMES = [
'pnpm-workspaces.yaml',
'pnpm-workspaces.yml',
'pnpm-workspace.yml',
];
/** 11.0.0 added the dot-prefixed spellings to that list. */
const MISSPELLED_WORKSPACE_MANIFEST_NAMES_11 = [
...MISSPELLED_WORKSPACE_MANIFEST_NAMES,
'.pnpm-workspace.yaml',
'.pnpm-workspace.yml',
'.pnpm-workspaces.yaml',
'.pnpm-workspaces.yml',
];
/**
* pnpm resolves pnpm-workspace.yaml by walking up from the directory it runs
* in and stopping at the nearest hit, so a workspace nested under another one
* inherits the outer file's settings. Null when no directory on the way up has
* one.
* See findWorkspaceDir in pnpm's workspace root finder.
*/
function findPnpmWorkspaceFile(root, pnpmVersion) {
// The env var names the directory outright, skipping the walk without
// checking that the file is there, so a missing one reads as a workspace
// declaring nothing rather than sending the lookup back up the tree.
const fromEnv = (0, utils_1.readEnvVar)(process.env, 'NPM_CONFIG_WORKSPACE_DIR');
if (fromEnv) {
return (0, path_1.join)((0, path_1.resolve)(root, fromEnv), WORKSPACE_MANIFEST_FILENAME);
}
const misspelled = (0, semver_1.lt)(pnpmVersion, '11.0.0')
? MISSPELLED_WORKSPACE_MANIFEST_NAMES
: MISSPELLED_WORKSPACE_MANIFEST_NAMES_11;
for (const dir of [root, ...(0, utils_1.ancestorDirectories)(root)]) {
const path = (0, path_1.join)(dir, WORKSPACE_MANIFEST_FILENAME);
// Looked up first, because pnpm searches the names in this order within a
// directory and takes the first hit: a correctly named file beside a
// misspelled one is the one it reads.
if (pnpmFindsWorkspaceFile(path, pnpmVersion)) {
return path;
}
for (const name of misspelled) {
const misspelledPath = (0, path_1.join)(dir, name);
if (pnpmFindsWorkspaceFile(misspelledPath, pnpmVersion)) {
// pnpm aborts the command here (BAD_WORKSPACE_MANIFEST_NAME) instead of
// walking on, so there is no resolution left to reproduce. Propagating
// to the caller's fall-open warns instead of silently resolving against
// a correctly named file further up that pnpm never reaches.
throw new Error(`The pnpm workspace manifest file should be named "${WORKSPACE_MANIFEST_FILENAME}". File found: ${misspelledPath}`);
}
}
}
return null;
}
function readPnpmWorkspaceSettings(path, pnpmVersion) {
if (path === null) {
return {};
}
const doc = (0, pnpm_config_1.readPnpmYamlConfig)(path);
if (doc === null) {
return {};
}
if (doc === 'unusable') {
// pnpm aborts on this file, so there is no resolution left to reproduce.
// Propagating to the caller's fall-open warns instead of silently treating
// the workspace as declaring no registry.
throw new Error(`The pnpm workspace file at ${path} could not be read.`);
}
return normalizePnpmWorkspaceSettings(resolveYamlEnv(doc, path, pnpmVersion, false), path);
}
const PNPM_REQUEST_PROXY_SCALARS = [
'httpProxy',
'httpsProxy',
'noProxy',
'proxy',
'noproxy',
];
const PNPM_REQUEST_DESTINATION_SCALARS = new Set(['pnprServer', 'registry']);
const PNPM_REQUEST_DESTINATION_SCALARS_11_11 = new Set([
...PNPM_REQUEST_DESTINATION_SCALARS,
...PNPM_REQUEST_PROXY_SCALARS,
]);
const PNPM_REQUEST_DESTINATION_SCALARS_10 = new Set(['registry']);
const PNPM_REQUEST_DESTINATION_SCALARS_10_34_5 = new Set([
...PNPM_REQUEST_DESTINATION_SCALARS_10,
...PNPM_REQUEST_PROXY_SCALARS,
]);
/**
* The scalar settings pnpm withholds from a file rather than expanding a
* `${VAR}` into them, its REQUEST_DESTINATION_SCALAR_KEYS, null on a line that
* withholds nothing. It covered the destination alone until 11.11.0 added the
* proxies that carry a request there, and it was backported onto the 10 line,
* where it never names the pnpr server and picked the proxies up in 10.34.5.
*
* Each line is decided before the next is consulted: a 10.x version must not
* reach the 11 gates, which a plain `gte` ladder would let it do.
*/
function requestDestinationScalars(pnpmVersion) {
if ((0, semver_1.lt)(pnpmVersion, '11.0.0')) {
if ((0, semver_1.lt)(pnpmVersion, '10.34.2')) {
return null;
}
return (0, semver_1.lt)(pnpmVersion, '10.34.5')
? PNPM_REQUEST_DESTINATION_SCALARS_10
: PNPM_REQUEST_DESTINATION_SCALARS_10_34_5;
}
if ((0, semver_1.lt)(pnpmVersion, '11.5.3')) {
return null;
}
return (0, semver_1.lt)(pnpmVersion, '11.11.0')
? PNPM_REQUEST_DESTINATION_SCALARS
: PNPM_REQUEST_DESTINATION_SCALARS_11_11;
}
/**
* A yaml settings file as pnpm's replaceEnvInSettings leaves it. Which
* `${VAR}` it touches moved twice, and what it does with one it cannot resolve
* moved once:
*
* - Keys, on every line from 10.7.0, and a key it resolves nothing for aborts
* the command. 10.6.0 has no replacer at all and takes the file verbatim.
* - Top-level string values, on the same line, and likewise fatal.
* - `registries` and `namedRegistries` values, from 11.1.0.
* - A scalar naming a request destination is dropped instead of expanded, from
* 10.34.2 on the 10 line and 11.5.3 on the 11 one
* (requestDestinationScalars). A `registries` entry holding one is dropped
* too, but from 11.5.3 alone: 10.x has no branch for that key and passes the
* map through whole. Both apply to a file a project controls; the global
* config.yaml is trusted and keeps expanding, and only 11 reads it anyway.
*
* A nested object elsewhere is passed through untouched on every line, so a
* placeholder there is neither expanded nor fatal.
*
* Values come back in the form npm's own expansion turns back into what pnpm
* resolved: a line that expands leaves an escaped reference for npm to consume
* (expandPnpmEnvVars), and a line that does not escapes what it passes through.
*/
function resolveYamlEnv(doc, path, pnpmVersion, trusted) {
const assertResolves = (value) => {
const unresolved = (0, utils_1.unresolvedPnpmEnvVars)(value);
if (unresolved.length > 0) {
// pnpm aborts the command here, so there is no resolution left to
// reproduce. Propagating to the caller's fall-open warns instead. Only the
// references are named: the rest of the value can be a credential.
throw new Error(`The pnpm configuration file at ${path} references an environment variable that is not set: ${unresolved.join(', ')}`);
}
};
/** For a key, which is read rather than handed to npm. */
const expandKey = (value) => {
assertResolves(value);
return (0, utils_1.expandPnpmEnvVars)(value);
};
/** For a value, which the spawned npm expands again. */
const expandValue = (value) => {
assertResolves(value);
return (0, utils_1.bridgePnpmEnvVars)(value);
};
const expands = (0, semver_1.gte)(pnpmVersion, '10.7.0');
const resolveScalar = expands ? expandValue : utils_1.escapeNpmEnvExpr;
const resolveRegistry = (0, semver_1.gte)(pnpmVersion, '11.1.0')
? resolveScalar
: utils_1.escapeNpmEnvExpr;
const droppedScalars = trusted
? null
: requestDestinationScalars(pnpmVersion);
const dropsRegistries = !trusted && (0, semver_1.gte)(pnpmVersion, '11.5.3');
const resolved = {};
for (const [rawKey, value] of Object.entries(doc)) {
const key = expands ? expandKey(rawKey) : rawKey;
if (typeof value === 'string') {
if (droppedScalars?.has(key) && PNPM_ENV_PLACEHOLDER.test(value)) {
continue;
}
resolved[key] = resolveScalar(value);
}
else if (key === 'registries' || key === 'namedRegistries') {
resolved[key] = mapYamlStrings(value, (entry) => dropsRegistries && PNPM_ENV_PLACEHOLDER.test(entry)
? undefined
: resolveRegistry(entry));
}
else {
resolved[key] = value;
}
}
return resolved;
}
/**
* `map` over the string values of a plain object, an entry it returns nothing
* for dropped. Anything else is passed through, which is how pnpm's own two
* mappers treat a shape they were not given.
*/
function mapYamlStrings(value, map) {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
return value;
}
const mapped = {};
for (const [key, entry] of Object.entries(value)) {
if (typeof entry !== 'string') {
mapped[key] = entry;
continue;
}
const result = map(entry);
if (result !== undefined) {
mapped[key] = result;
}
}
return mapped;
}
/**
* pnpm type-checks none of these, so its tolerance is uneven and each shape
* here mirrors a measured 11.10.0 outcome. A truthy non-string proxy breaks
* pnpm's own fetch, so it is fatal into the caller's fall-open; a falsy one is
* never read as a proxy at all and drops here the way it drops there. A
* wrong-shaped noProxy also survives pnpm, so it is dropped
* rather than handed to the string-typed spawn env. `registries` and
* `strictSsl` stay unnarrowed for the consumer that reads them, because pnpm
* only reacts to the registry value it picks, and turns TLS verification off
* for the boolean alone.
*/
function normalizePnpmWorkspaceSettings(doc, path) {
const fail = (what) => {
throw new Error(`The pnpm configuration file at ${path} declares ${what}.`);
};
if (doc.proxy && typeof doc.proxy !== 'string') {
fail('a proxy that is not a string');
}
if (doc.httpProxy && typeof doc.httpProxy !== 'string') {
fail('an httpProxy that is not a string');
}
if (doc.httpsProxy && typeof doc.httpsProxy !== 'string') {
fail('an httpsProxy that is not a string');
}
const text = (key) => typeof doc[key] === 'string' ? doc[key] : undefined;
return {
registries: doc.registries,
strictSsl: doc.strictSsl,
registry: text('registry'),
proxy: text('proxy'),
httpProxy: text('httpProxy'),
httpsProxy: text('httpsProxy'),
noProxy: text('noProxy'),
noproxy: text('noproxy'),
ca: text('ca'),
cert: text('cert'),
key: text('key'),
};
}
/**
* The yaml registry for `key`, fatal when it exists with a non-string shape:
* pnpm dies in `new URL` on the registry it picks (measured on 11.10.0), and an
* entry it never picks harms nothing, so the check runs per pick rather than
* over the whole map. A registries that is not a map declares no entry at all,
* which is how pnpm resolves one.
*/
function pickYamlRegistry(settings, key, path) {
const { registries } = settings;
if (registries === null ||
typeof registries !== 'object' ||
Array.isArray(registries)) {
return undefined;
}
const value = registries[key];
if (value === undefined) {
return undefined;
}
if (typeof value !== 'string') {
throw new Error(`The pnpm configuration file at ${path} declares a registries["${key}"] that is not a string.`);
}
return value;
}
function getAuthIniPath() {
return (0, path_1.join)((0, pnpm_config_1.getPnpmConfigDir)(process.env), 'auth.ini');
}
function getGlobalConfigPath() {
return (0, path_1.join)((0, pnpm_config_1.getPnpmConfigDir)(process.env), 'config.yaml');
}
/**
* The global config.yaml, null when absent. pnpm reads this one straight,
* without the existence check it puts in front of pnpm-workspace.yaml, so every
* command aborts on a file it cannot open or parse. That propagates to the
* caller's fall-open instead of resolving on without the file's settings.
*/
function readPnpmGlobalConfigYaml() {
const path = getGlobalConfigPath();
const doc = (0, pnpm_config_1.readPnpmYamlConfig)(path);
if (doc === 'unusable') {
throw new Error(`The pnpm global configuration file at ${path} could not be read.`);
}
return doc;
}
/**
* The settings pnpm >= 11 takes from that file. It applies them the way it
* applies a workspace manifest, over the npmrc-derived config and under the
* workspace file's own, but only for the keys it allows there: `registries` is
* refused with a warning until 11.11.0, while every other key read here is an
* npm setting name it has always allowed. The file is the user's own rather
* than a project's, so a `${VAR}` naming a request destination is expanded
* instead of withheld.
*/
function readPnpmGlobalSettings(pnpmVersion) {
if ((0, semver_1.lt)(pnpmVersion, '11.0.0')) {
return {};
}
const doc = readPnpmGlobalConfigYaml();
if (doc === null) {
return {};
}
const path = getGlobalConfigPath();
const settings = normalizePnpmWorkspaceSettings(resolveYamlEnv(doc, path, pnpmVersion, true), path);
if ((0, semver_1.lt)(pnpmVersion, '11.11.0')) {
delete settings.registries;
}
return settings;
}
// pnpm keeps resolving from the remaining layers for an npmrc-family file it
// cannot read, so mirror the absent semantics. It stays silent on ENOENT and
// EISDIR and warns otherwise; we warn for the whole unreadable class, minus the
// ENOTDIR the reader already reports as absent, where pnpm warns and resolves
// on all the same.
const warnedUnreadableFiles = new Set();
function warnUnreadableFile(path) {
if (warnedUnreadableFiles.has(path)) {
return;
}
warnedUnreadableFiles.add(path);
logger_1.logger.warn(`Could not read ${path}; resolving the pnpm registry configuration without it, the way pnpm itself does.`);
}
function readNpmrcEntriesOrWarn(path) {
const entries = (0, npmrc_1.readNpmrcEntries)(path);
if (entries !== 'unreadable') {
return entries;
}
warnUnreadableFile(path);
return null;
}
/** An npmrc-family file as written, null when it could not be read. */
function readNpmrcOrWarn(path) {
const entries = readNpmrcEntriesOrWarn(path);
return entries && (0, npmrc_1.npmrcEntriesToMap)(entries);
}
/**
* The same file as pnpm ends up with it, which below 11 can be not at all: its
* reader expands `${VAR}` in both halves of an entry through a function that
* throws on a reference it resolves nothing for, and the config chain catches
* that per file, so one bad reference costs every entry in the file and pnpm
* carries on from the layers below. From 11 the lossy reader substitutes an
* empty string per entry instead and the file survives.
* See parseKey/parseField and Conf.addFile in pnpm's bundled npm-conf.
*/
function readPnpmNpmrcMap(path, pnpmVersion) {
const entries = readNpmrcEntriesOrWarn(path);
if (entries === null || (0, semver_1.gte)(pnpmVersion, '11.0.0')) {
return entries && (0, npmrc_1.npmrcEntriesToMap)(entries);
}
// parseField hands a `key[]` array straight back, so the values under a key
// ini collected into one are never expanded and never throw. The key is, and
// one repeated line is enough to make every value under it an array.
const arrayKeys = new Set(entries.filter((entry) => entry.array).map((entry) => entry.key));
for (const { key, value } of entries) {
if (!(0, utils_1.pnpmEnvVarsResolve)(key) ||
(!arrayKeys.has(key) && !(0, utils_1.pnpmEnvVarsResolve)(value))) {
return null;
}
}
return (0, npmrc_1.npmrcEntriesToMap)(entries);
}
// pnpm's AUTH_VALUE_KEYS, which holds the same seven settings as its
// UNSCOPED_RESCOPABLE_KEYS. Aliased rather than spelled out again so the two
// cannot drift apart here while pnpm keeps them equal.
const PNPM_AUTH_VALUE_KEYS = PNPM_RESCOPABLE_KEYS;
// pnpm's hasEnvPlaceholder, which unlike its expander honors no escape.
const PNPM_ENV_PLACEHOLDER = /\$\{[^}]+\}/;
const isRegistryKey = (key) => key === 'registry' || (key.startsWith('@') && key.endsWith(':registry'));
const isAuthValueKey = (key) => PNPM_AUTH_VALUE_KEYS.some((k) => key === k || key.endsWith(`:${k}`));
/** pnpm's isRequestDestinationKey: the set its key-side test covers. */
const isRequestDestinationKey = (key) => isRegistryKey(key) || key.startsWith('//');
/** pnpm's isRequestDestinationValueKey: the set its value-side test covers. */
const isRequestDestinationValueKey = (key) => isRegistryKey(key) ||
key === 'proxy' ||
key === 'http-proxy' ||
key === 'https-proxy';
/** Whether 11.5.3+ drops an entry rather than expanding its `${VAR}`. */
function pnpmDropsProjectEntry(rawKey, key, rawValue) {
if (PNPM_ENV_PLACEHOLDER.test(rawKey) &&
// Tested on both sides of the expansion, because a placeholder can carry a
// key that is plain in neither set into one of them and vice versa.
(isRequestDestinationKey(rawKey) ||
isAuthValueKey(rawKey) ||
isRequestDestinationKey(key) ||
isAuthValueKey(key))) {
return true;
}
return (PNPM_ENV_PLACEHOLDER.test(rawValue) &&
(isRequestDestinationValueKey(key) || isAuthValueKey(key)));
}
/**
* One npmrc-family file as pnpm reads it: both halves expanded, rebuilt in file
* order so a later key that resolves to the same setting wins, the way pnpm's
* own assignment does. npm's env-tier expansion uses a different grammar, which
* is why the values are expanded here rather than left to the spawn.
*
* `filtered` applies the rule pnpm puts on the workspace file alone: until
* 11.5.3 a `${VAR}` expanded there like anywhere else, so a placeholder-keyed
* entry lands under the spelling it expands to; from 11.5.3 an entry naming a
* host or carrying a credential is dropped instead of expanded when either half
* holds one. See readAndFilterNpmrc in pnpm's config reader.
*/
function readPnpmNpmrcEntries(raw, pnpmVersion, filtered) {
const drops = filtered && (0, semver_1.gte)(pnpmVersion, '11.5.3');
const map = new Map();
for (const [rawKey, rawValue] of raw) {
const key = PNPM_ENV_PLACEHOLDER.test(rawKey)
? (0, utils_1.expandPnpmEnvVars)(rawKey)
: rawKey;
if (drops && pnpmDropsProjectEntry(rawKey, key, rawValue)) {
continue;
}
map.set(key, rawValue);
}
// parseField decides a Boolean-typed setting from the literal value, before it
// expands any `${VAR}`, so strict-ssl has to be read pre-expansion.
const rawStrictSsl = map.get('strict-ssl');
// What pnpm itself resolves this file's own registry to. The escaped form
// below is text for npm to expand, not a URL: `\` is a path separator to the
// URL parser, so darting it would key the credential a segment off.
const fileRegistry = (0, utils_1.expandPnpmEnvVars)(map.get('registry') ?? '');
for (const [key, value] of map) {
// cafile is joined onto a directory before it is handed over, and on Windows
// the backslashes an escape adds are separators that path normalization
// collapses, leaving npm to consume one of them as the escape. It is escaped
// once resolved instead.
map.set(key, key === 'cafile' ? (0, utils_1.expandPnpmEnvVars)(value) : (0, utils_1.bridgePnpmEnvVars)(value));
}
// pnpm's getDefaultCreds applies a bare global _authToken/_auth/username/
// _password (no nerf-dart prefix); npm honors auth only in the nerf-darted
// form, so re-key each onto the registry this file itself carries, or npmjs
// when it carries none. pnpm does this per file and before the merge
// (rescopeUnscopedCreds), which is why two files can each contribute a bare
// credential under a different dart, and why a dart the same file spells out
// keeps its own value. From 11.4.0 that is pnpm's own pin; earlier 11.x paired
// the credential with whichever registry won overall, which let a
// workspace-local .npmrc or pnpm-workspace.yaml aim a user-level credential at
// a host of its choosing (CVE-2026-50017), so the pin is applied there too
// rather than reproducing the hole.
const dart = pnpmNerfDart(fileRegistry || DEFAULT_REGISTRY, pnpmVersion);
const rescoped = [];
for (const bareKey of PNPM_RESCOPABLE_KEYS) {
const value = map.get(bareKey);
if (value === undefined) {
continue;
}
map.delete(bareKey);
// An unparseable registry leaves pnpm nowhere safe to pin them, so it drops
// them outright.
if (!dart) {
continue;
}
// Re-keyed on presence, an empty value included, because that is how pnpm
// does it: an emptied credential goes on to shadow the same key in every
// file below, which is how a workspace clears one it inherits.
if (!map.has(`${dart}:${bareKey}`)) {
map.set(`${dart}:${bareKey}`, value);
}
// Nothing is withheld from npm when the credential was empty to begin with.
if (value && BARE_AUTH_KEYS.includes(bareKey)) {
rescoped.push(bareKey);
}
}
return { map, rawStrictSsl, rescoped, dart };
}
/**
* The .npmrc files pnpm reads below its own environment, highest first: the one
* beside the package.json the command runs from, then the one beside the
* workspace manifest it walked up to. They are the same file for a workspace
* that is its own root, and the second is the tier npm has none of.
*/
function pnpmNpmrcPaths(root, workspaceFile) {
const project = (0, path_1.join)(root, '.npmrc');
const workspaceDir = workspaceFile ? (0, path_1.dirname)(workspaceFile) : root;
return workspaceDir === root
? [project]
: [project, (0, path_1.join)(workspaceDir, '.npmrc')];
}
/**
* An npmrc-family file with its keys expanded, rebuilt in file order so a later
* key that expands onto the same setting wins the way pnpm's own assignment
* does. Values stay as written, because parseField types a Boolean setting from
* the literal before any `${VAR}` in it is expanded.
*/
function expandPnpmNpmrcKeys(raw) {
const map = new Map();
for (const [rawKey, rawValue] of raw) {
map.set((0, utils_1.expandPnpmEnvVars)(rawKey), rawValue);
}
return map;
}
/**
* A registry the yaml or the environment already forced in outranks these files
* in pnpm, so it keeps winning here.
*/
function applyPnpmFileRegistry(env, scope, bridged) {
const registry = bridged('registry');
if (!env['npm_config_registry'] && registry) {
(0, utils_1.setRegistry)(env, registry);
}
const scopedRegistry = scope ? bridged(`${scope}:registry`) : undefined;
if (scope && !env[`npm_config_${scope}:registry`] && scopedRegistry) {
(0, utils_1.setScopedRegistry)(env, scope, scopedRegistry);
}
}
/**
* `raw` is the value as written, before any `${VAR}` expansion: strict-ssl is
* typed Boolean-only, so parseField turns just 'true'/'false' (plus '' -> true
* and the null/undefined literals) into non-strings and leaves everything else
* a truthy string. '0', 'no' and 'off' all keep TLS verification on in pnpm;
* only an explicit 'false' turns it off.
*/
function applyPnpmStrictSsl(env, raw) {
if (raw !== undefined) {
(0, utils_1.setStrictSsl)(env, raw !== 'false');
}
}
/**
* The workspace .npmrc pnpm below 11 layers under the project one. npm has no
* tier for it at all, so a setting the project file leaves undeclared has to
* reach npm through the environment; one the project file declares npm resolves
* for itself, and injecting the workspace value would put it above that file
* rather than below it. The ambient npm_config_* both readers honor on this line
* outranks either file, so a setting declared there is left alone as well.
*
* Nothing is bridged out of `userConfigPath`, which npm reads for itself, but
* the proxies pnpm derives from one another are resolved across it: a tier
* missing from the lookup makes an `https-proxy` it declares read as
* undeclared, and the caller then derives one from a legacy `proxy` a file
* above it set.
*
* A bare credential is deliberately not bridged. pnpm has no per-file rescoping
* here and pins one to nerfDart(allSettings.registry), the registry the npmrc
* chain resolves rather than the one the pnpm-workspace.yaml sends the fetch to,
* so npm's nerf-darted form cannot be derived from what this can see.
*/
function bridgeWorkspaceNpmrc(env, npmrcPaths, userConfigPath, scope, pnpmVersion) {
const [projectPath, workspacePath] = npmrcPaths;
// pnpm's view of the shadowing tier: a file its reader discarded shadows
// nothing, even though npm goes on reading that same file for itself.
const tiers = [...npmrcPaths, userConfigPath].map((path) => {
const raw = readPnpmNpmrcMap(path, pnpmVersion);
return raw && expandPnpmNpmrcKeys(raw);
});
const projectNpmrc = tiers[0];
const workspaceNpmrc = workspacePath ? tiers[1] : null;
/** What pnpm resolves from these files and the env tier over them. */
const resolved = (key) => (0, utils_1.bridgePnpmEnvVars)((0, utils_1.readNpmConfigEnv)(process.env, key) ??
tiers.find((tier) => tier?.has(key))?.get(key) ??
'') || undefined;
const proxies = {
proxy: resolved('proxy'),
httpProxy: resolved('http-proxy'),
httpsProxy: resolved('https-proxy'),
};
if (!workspaceNpmrc) {
return proxies;
}
/** The value as written, unless a tier above the workspace file declares one. */
const declared = (key) => projectNpmrc?.has(key) || (0, utils_1.readNpmConfigEnv)(process.env, key) !== undefined
? undefined
: workspaceNpmrc.get(key);
// An empty value declares nothing to derive from: pnpm's own readers re-check
// for an empty registry, and npm skips an empty env value outright. Deriving
// from one is what does damage (an empty cafile resolves to its own directory).
const bridged = (key) => (0, utils_1.bridgePnpmEnvVars)(declared(key) ?? '') || undefined;
applyPnpmFileRegistry(env, scope, bridged);
// Every dart is copied, not just the contacted registry's: npm resolves auth
// per fetched URI and sends only the matching key, so a tarball served from a
// second authenticated host keeps working. Filtering here would strip it.
for (const key of workspaceNpmrc.keys()) {
// npm has no tokenHelper setting, and pnpm takes one from its user config
// alone, so a scoped helper here stands for no credential the fetch had.
// `:cert`/`:key` carry inline PEM, which neither tool reads in scoped form
// (pnpm's getNetworkConfigs pairs a registry with `:certfile`/`:keyfile`
// paths, the same keys npm resolves per URI, and those do go through).
if (!key.startsWith('//') ||
key.endsWith(':tokenHelper') ||
key.endsWith(':cert') ||
key.endsWith(':key')) {
continue;
}
const value = bridged(key);
if (value) {
env[`npm_config_${key}`] = value;
}
}
// Resolved before the npm-facing escape: on Windows the backslashes it adds
// are separators, which normalization collapses and npm then reads one of them
// as the escape.
const cafile = (0, utils_1.expandPnpmEnvVars)(declared('cafile') ?? '') || undefined;
if (cafile) {
// pnpm's only reader on this line is loadCAFile, a bare readFileSync on the
// raw value, so a relative one resolves against the cwd the command runs in,
// which is the root the spawn uses. It expands no leading `~`, and npm
// ignores a cafile it cannot open, so getting the base wrong drops the trust
// anchor with no diagnostic at all. (11.2.0 moved that base to the directory
// of the declaring file.)
(0, utils_1.setCafile)(env, (0, utils_1.escapeNpmEnvExpr)((0, path_1.resolve)((0, path_1.dirname)(projectPath), cafile)));
}
// Flat keys on this line: pnpm pins neither trust anchors nor client TLS
// material to a registry before 11, and npm reads all three the same way.
for (const key of ['ca', 'cert', 'key']) {
const value = bridged(key);
if (value) {
env[`npm_config_${key}`] = value;
}
}
applyPnpmStrictSsl(env, declared('strict-ssl'));
(0, utils_1.setProxies)(env, {
// The spelling npm reads natively, which it can still only read from its own
// project config. pnpm prefers `no-proxy` across every layer over `noproxy`
// across every layer, so bridgeNoProxy runs after this and overwrites it.
noProxy: bridged('noproxy'),
});
return proxies;
}
function bridgeNpmrcSources(env, root, workspaceDir, scope, authIniPath, userConfigPath, pnpmVersion, managerIgnoresEnv) {
// The file npm resolves as its project config, beside the package.json the
// spawn runs from.
const projectRaw = readNpmrcOrWarn((0, path_1.join)(root, '.npmrc'));
// pnpm reads exactly one workspace .npmrc, beside the workspace manifest it
// walked up to, and merges it over auth.ini. That file is npm's own only when
// the two directories coincide; above the spawn's, it is a source only pnpm
// reads, so its entries have to be bridged rather than left to npm.
const workspaceRaw = workspaceDir === root
? projectRaw
: readNpmrcOrWarn((0, path_1.join)(workspaceDir, '.npmrc'));
const authIniRaw = readNpmrcOrWarn(authIniPath);
const trustedWorkspace = isTrustedWorkspaceNpmrc(workspaceDir, userConfigPath);
// The same file at the workspace tier already, where pnpm trusts and reads it.
const userRaw = trustedWorkspace ? null : readNpmrcOrWarn(userConfigPath);
// Highest pnpm precedence first, matching the order it assigns them in
// (workspace .npmrc over auth.ini over the file it authenticates from).
const sources = [];
if (workspaceRaw) {
sources.push({
dir: workspaceDir,
npmNative: workspaceDir === root,
...readPnpmNpmrcEntries(workspaceRaw, pnpmVersion, !trustedWorkspace),
});
}
if (authIniRaw) {
sources.push({
dir: (0, path_1.dirname)(authIniPath),
npmNative: false,
...readPnpmNpmrcEntries(authIniRaw, pnpmVersion, false),
});
}
if (userRaw) {
sources.push({
dir: (0, path_1.dirname)(userConfigPath),
// pnpm authenticates from the file `npmrcAuthFile`/`userconfig` selects.
// npm opens that same file only where its own `userconfig` lands on it;
// anywhere else it is a source npm never reads, so its entries need
// bridging like auth.ini's.
npmNative: userConfigPath === getNpmUserConfigPath(root),
...readPnpmNpmrcEntries(userRaw, pnpmVersion, false),
});
}
if (sources.length === 0) {
return {};
}
const projectNpmrc = projectRaw ?? new Map();
/** The highest source declaring `key`; an empty value still shadows the rest. */
const declaringSource = (key) => sources.find((source) => source.map.has(key));
/**
* What pnpm resolves from these files, npm-native or not: a value it derives
* another setting from is one npm does not derive for itself.
*/
const declaredValue = (key) => declaringSource(key)?.map.get(key) || undefined;
/** That source, unless npm reads it for itself and needs no bridging. */
const bridging = (key) => {
const source = declaringSource(key);
return source?.npmNative === false ? source : undefined;
};
// An empty value declares nothing to derive from: pnpm's own readers re-check
// for an empty registry, and npm skips an empty env value outright. Deriving
// from one is what does damage (an empty cafile resolves to its own directory).
const bridgedValue = (key) => bridging(key)?.map.get(key) || undefined;
applyPnpmFileRegistry(env, scope, bridgedValue);
// Every dart is copied, not just the contacted registry's: npm resolves auth
// per fetched URI and sends only the matching key, so a tarball served from a
// second authenticated host keeps working. Filtering here would strip it.
for (const source of sources) {
for (const [key, value] of source.map) {
// A key a higher source declares is that source's to decide, whether it
// bridges the value or leaves it to npm.
if (!key.startsWith('//') || declaringSource(key) !== source) {
continue;
}
// npm has no tokenHelper setting and pnpm ignores one that arrives through
// the environment, so bridging it would only put a command line in the
// child's environment. pnpm also refuses to run a helper from any file but
// its user auth config, so one here stands for no credential the fetch
// would have had. `:cert`/`:key` carry inline PEM, which npm has no
// registry-scoped form for, so they go in flat below instead.
if (source.npmNative ||
key.endsWith(':tokenHelper') ||
key.endsWith(':cert') ||
key.endsWith(':key')) {
continue;
}
// The env checks keep the URL-scoped env tier above these files, matching
// pnpm's merge order: pnpm_config_ spellings are already in the overlay
// (applyUrlScopedEnvConfig); ambient npm_config_ ones pnpm reads must stay
// unbridged, or the overlaid value would shadow them out of the merge.
if (env[`npm_config_${key}`] !== undefined ||
(!managerIgnoresEnv(key) &&
(0, utils_1.readNpmConfigEnv)(process.env, key) !== undefined)) {
continue;
}
env[`npm_config_${key}`] = value;
}
}
// The rescoped credentials went in through the dart loop above, since that is
// the form they carry by the time pnpm merges them. What is left is naming the
// ones npm will not get: a file npm reads itself contributes none, because npm
// rejects bare auth in its own config (ERR_INVALID_AUTH) before any overlay
// matters.
const bareKeys = new Set();
const credentialDarts = new Set();
for (const source of sources) {
if (source.npmNative || !source.dart) {
continue;
}
for (const key of source.rescoped) {
bareKeys.add(key);
credentialDarts.add(source.dart);
}
}
const requestDart = (0, utils_1.requestNerfDart)(contactedRegistry(env, projectNpmrc, scope, managerIgnoresEnv));
const requestKeys = requestDart ? (0, utils_1.registryKeysFor)(requestDart) : [];
// A withheld credential is invisible in npm's own error, so name it, unless
// npm already finds one for that registry among the sources visible here. A
// user-level ~/.npmrc is not one, so the message states only what was
// withheld rather than predicting how the request will fail.
if (bareKeys.size > 0 &&
requestDart &&
!requestKeys.some((key) => credentialDarts.has(key)) &&
!hasCredentials(env, projectNpmrc, requestDart, managerIgnoresEnv)) {
warnUnscopedCredential(requestDart, [...bareKeys]);
}
// Flat TLS/proxy keys are part of pnpm's auth-config inheritance set
// (RAW_AUTH_CFG_KEYS) and are written to auth.ini by `pnpm config set`, so
// bridge them too.
const cafileSource = bridging('cafile');
const cafile = bridgedValue('cafile');
if (cafileSource && cafile) {
// From 11.2.0 pnpm resolves a relative cafile against the directory of the
// file that declared it, not the workspace root; before that its only reader
// is loadCAFile, a bare readFileSync on the raw value, so it lands on the
// cwd the command runs in (the workspace root for a migrate). Neither
// expands a leading `~`. npm ignores a cafile it cannot open, so getting the
// base wrong drops the trust anchor with no diagnostic at all.
const base = (0, semver_1.gte)(pnpmVersion, '11.2.0') ? cafileSource.dir : root;
(0, utils_1.setCafile)(env, (0, utils_1.escapeNpmEnvExpr)((0, path_1.resolve)(base, cafile)));
}
// npm reads inline `ca` PEM only as a flat (global) key, and pnpm does not
// source-scope trust anchors, so it needs no pin check.
const ca = bridgedValue('ca');
if (ca) {
env['npm_config_ca'] = ca;
}
// `cert`/`key` are client TLS material, which pnpm pins to a registry the same
// way it pins credentials, so by here they are darted. npm has no
// registry-scoped inline form (its //host/:certfile / :keyfile keys take
// paths, not PEM) and npm_config_cert presents the certificate to every host
// npm contacts, so only the pair pinned to the registry npm will actually
// contact can go in, and it goes in flat.
// Every tier is read here, the URL-scoped env one above the files and the
// project file among them: npm's own registry-scoped TLS keys take paths
// (certfile/keyfile), so inline PEM cannot reach it in scoped form from any of
// them, npm-native file included. Which tier declares it settles the value
// first and the nearest dart declaring one then wins, the order pnpm resolves
// them in (pickSettingByUrl walks a map every tier has already merged into).
const pinnedTls = (dartKey) => env[`npm_config_${dartKey}`] ||
// The same ambient tier the dart loop honors: from 11.6.0 pnpm reads a
// URL-scoped npm_config_ entry the spawn would otherwise pass straight
// through in a form npm makes no use of.
(managerIgnoresEnv(dartKey)
? undefined
: (0, utils_1.readNpmConfigEnv)(process.env, dartKey)) ||
declaringSource(dartKey)?.map.get(dartKey);
if (requestDart) {
for (const key of ['cert', 'key']) {
const value = requestKeys
.map((regKey) => pinnedTls(`${regKey}:${key}`))
.find(Boolean);
if (value) {
env[`npm_config_${key}`] = value;
}
else if (projectRaw &&
// Read as npm resolves it: it expands a `${VAR}` in the key before it
// looks the setting up, so a placeholder-spelled one still reaches it.
(0, utils_1.readExpandedKey)(projectRaw, key, utils_1.expandNpmEnvVars) !== undefined) {
// npm reads this one out of its own project config and presents it to
// every host it contacts, where pnpm pinned it to a registry this fetch
// never reaches. The `null` literal is what cancels a file value at npm's
// env tier; an empty one leaves the file's in place (measured on npm 9,
// 10 and 11).
env[`npm_config_${key}`] = 'null';
}
}
}
applyPnpmStrictSsl(env, bridging('strict-ssl')?.rawStrictSsl);
return {
proxy: declaredValue('proxy'),
httpProxy: declaredValue('http-proxy'),
httpsProxy: declaredValue('https-proxy'),
};
}
/**
* The proxy-bypass list is the one npmrc key whose spelling differs. In these
* files pnpm 11 honors `no-proxy` and ignores `noproxy`, where npm does the
* exact opposite (it warns about `no-proxy` as an unknown config and moves on).
* pnpm 10.x honors both, so only the spelling npm cannot read needs bridging on
* either line. Either way pnpm's `no-proxy` never reaches the spawned npm from
* any file it reads, so the layer that wins in pnpm has to be re-spelled. A
* `noProxy` in pnpm-workspace.yaml outranks every one of them and is applied
* after this.
*/
function bridgeNoProxy(env, npmrcPaths, pnpmVersion) {
const value = fileNoProxy(npmrcPaths, pnpmVersion);
if (value) {
(0, utils_1.setProxies)(env, { noProxy: value });
}
}
/** The bypass list the highest of `npmrcPaths` to declare one contributes. */
function fileNoProxy(npmrcPaths, pnpmVersion) {
for (const path of npmrcPaths) {
const npmrc = readPnpmNpmrcMap(path, pnpmVersion);
// Declaring the key empty is pnpm's way of clearing a list it inherits, so
// presence settles the layer and an empty value stops the search here.
if (!npmrc?.has('no-proxy')) {
continue;
}
const value = npmrc.get('no-proxy');
// npm ignores `no-proxy` in the file it does read, so the value never goes
// through npm's own expansion under that key; expand it with pnpm's grammar.
return value ? (0, utils_1.bridgePnpmEnvVars)(value) : undefined;
}
return undefined;
}
/**
* The registry the spawned npm will contact, as far as this process can see: a
* scoped registry for the package, else the default, else npm's own. A registry
* declared only in a user-level ~/.npmrc is not visible here, which leaves the
* comparison covering the sources that can redirect the request to a host the
* user never configured.
*/
function contactedRegistry(env, projectNpmrc, scope, managerIgnoresEnv) {
// npm's pickRegistry falls through on a falsy value, so a setting that
// expanded to nothing lands on the next one rather than on an empty host.
return ((scope
? npmResolved(env, projectNpmrc, `${scope}:registry`, managerIgnoresEnv)
: undefined) ||
npmResolved(env, projectNpmrc, 'registry', managerIgnoresEnv) ||
DEFAULT_REGISTRY);
}
/**
* Writes the proxy pair pnpm ends up with, once every tier has declared. The
* registry npm is about to contact decides whether an http-only proxy is worth
* bridging, and what npm reads for itself decides whether a value needs to be.
*/
function applyResolvedProxies(env, tiers, root, scope, managerIgnoresEnv) {
const projectNpmrc = readNpmrcOrWarn((0, path_1.join)(root, '.npmrc')) ?? new Map();
(0, utils_1.setProxies)(env, resolveProxies(tiers, contactedRegistry(env, projectNpmrc, scope, managerIgnoresEnv),
// The user config counts as what npm sees: a proxy it holds there is one
// npm resolves for itself, and overwriting it with a value derived from
// another key would put the overlay above a file npm was already reading.
npmVisibleReader(env, root, projectNpmrc, managerIgnoresEnv)));
}
function npmResolved(env, projectNpmrc, key, managerIgnoresEnv) {
// npm's env tier outranks the .npmrc, but the spawn strips a bridged ambient
// npm_config_* the manager ignores (mergeNpmConfigEnv), and every key read
// here is bridged, so a value npm never sees is not counted either.
const ambient = managerIgnoresEnv(key)
? undefined
: (0, utils_1.readNpmConfigEnv)(process.env, key);
const declared = env[`npm_config_${key}`] ??
ambient ??
(0, utils_1.readExpandedKey)(projectNpmrc, key, utils_1.expandNpmEnvVars);
// npm trims a value before it expands one (parseField), so a blank value
// collapses while a padded reference still resolves.
return declared === undefined ? undefined : (0, utils_1.expandNpmEnvVars)(declared.trim());
}
/**
* What the spawned npm resolves a key to on its own: its env tier, minus the
* bridged spellings the spawn strips, then the two .npmrc files it opens here.
* An unreadable one is silently absent, which is what npm makes of it.
*/
function npmVisibleReader(env, root, projectNpmrc, managerIgnoresEnv) {
const userConfig = (0, npmrc_1.readNpmrcMap)(getNpmUserConfigPath(root));
return (key) => {
const declared = npmResolved(env, projectNpmrc, key, managerIgnoresEnv);
if (declared !== undefined || !(userConfig instanceof Map)) {
return declared;
}
const value = (0, utils_1.readExpandedKey)(userConfig, key, utils_1.expandNpmEnvVars);
return value === undefined ? undefined : (0, utils_1.expandNpmEnvVars)(value.trim());
};
}
/**
* What pnpm itself ends up with for a key, across the tiers the overlay already
* carries and the npmrc-family files it reads for itself, each as its own
* reader leaves it: a file it discarded whole declares nothing, and neither
* does an entry it withheld. A key it answers is one npm answering the same way
* reproduces rather than diverges from.
*/
function pnpmResolvedReader(env, files, pnpmVersion, managerIgnoresEnv) {
const maps = files.map(({ path, filtered }) => {
const raw = readPnpmNpmrcMap(path, pnpmVersion);
return raw && readPnpmNpmrcEntries(raw, pnpmVersion, filtered).map;
});
return (key) =>
// The overlay is pnpm's own resolution of every tier above these files.
env[`npm_config_${key}`] ||
(managerIgnoresEnv(key) ? undefined : (0, utils_1.readNpmConfigEnv)(process.env, key)) ||
maps.find((map) => map?.get(key))?.get(key);
}
function hasCredentials(env, projectNpmrc, dart, managerIgnoresEnv) {
return (0, utils_1.hasCredentialFor)(dart, (key) => npmResolved(env, projectNpmrc, key, managerIgnoresEnv));
}
/** pnpm accepts `~/` and `~\` on every platform; npm accepts `~\` on Windows only. */
const PNPM_HOME_PATH = /^~[/\\]/;
const NPM_HOME_PATH = process.platform === 'win32' ? /^~[/\\]/ : /^~\//;
/** Both tools normalize a config path this way: a leading `~/` (or `~\`) for
* the home directory, else the cwd the command runs in. That cwd is the config
* root the spawn uses, not this process's, which a migrate from a subdirectory
* differs from. */
function resolveConfigPath(value, homePattern, root) {
return homePattern.test(value)
? (0, path_1.resolve)((0, os_1.homedir)(), value.slice(2))
: (0, path_1.resolve)(root, value);
}
/**
* The file pnpm >= 11 authenticates from. Its selection chain is followed here
* minus the two CLI links, which nx never passes.
* See loadNpmrcConfig in pnpm's config reader.
*/
function getPnpmUserConfigPath(pnpmVersion, root) {
// Read first: pnpm parses the global config.yaml before the selector
// applies, so a malformed one aborts even when the env names the auth file.
const globalYaml = readPnpmGlobalConfigYaml();
let selected = readPnpmEnvVar('npmrc_auth_file', pnpmVersion) ??
readPnpmEnvVar('userconfig', pnpmVersion);
if (selected === undefined) {
const fromYaml = globalYaml?.['npmrcAuthFile'];
selected =
(typeof fromYaml === 'string' ? fromYaml : undefined) ||
// The last link is npm's own setting, which npm then reads for itself.
(0, utils_1.readEnvVar)(process.env, 'npm_config_userconfig') ||
undefined;
}
return selected
? resolveConfigPath(selected, PNPM_HOME_PATH, root)
: (0, path_1.join)((0, os_1.homedir)(), '.npmrc');
}
/**
* The file npm resolves as its own user config. npm documents `userconfig` as
* settable from the environment and the command line only, never from another
* config file, so its env tier over the `~/.npmrc` default is the whole chain.
*/
function getNpmUserConfigPath(root) {
const configured = (0, utils_1.readNpmConfigEnv)(process.env, 'userconfig');
return configured
? resolveConfigPath((0, utils_1.expandNpmEnvVars)(configured.trim()), NPM_HOME_PATH, root)
: (0, path_1.join)((0, os_1.homedir)(), '.npmrc');
}
/**
* Reports the two credentials the overlay cannot reproduce for the registry npm
* is about to contact: one pnpm produces by running a token helper, which npm
* has no setting for, and one npm holds in a file of its own that pnpm would
* not send. Both supported lines take a helper only from the user config pnpm
* resolves (10.x getAuthHeadersFromConfig reads it from userSettings alone; 11
* additionally aborts the command outright with TOKEN_HELPER_IN_PROJECT_CONFIG
* when one reaches it from any other file), so `userConfigPath` is the one
* place worth reading for it.
*/
function reportCredentialDivergences(env, root, scope, userConfigPath, npmrcFiles, pnpmVersion, managerIgnoresEnv) {
const projectNpmrc = readNpmrcOrWarn((0, path_1.join)(root, '.npmrc')) ?? new Map();
const requestDart = (0, utils_1.requestNerfDart)(contactedRegistry(env, projectNpmrc, scope, managerIgnoresEnv));
const npmVisible = npmVisibleReader(env, root, projectNpmrc, managerIgnoresEnv);
if (requestDart) {
// pnpm goes without one where it resolved the registry but not a credential
// for it: a file it discards whole over an unresolvable reference, an entry
// it withholds from 11.5.3, or the .npmrc beside an outer workspace file
// that it reads in place of the one npm opens here.
const pnpmSends = pnpmResolvedReader(env, [...npmrcFiles, { path: userConfigPath, filtered: false }], pnpmVersion, managerIgnoresEnv);
(0, utils_1.warnNativeCredential)(env, requestDart, 'pnpm', 'Declare it where pnpm reads it too if it should authenticate there, or remove it from .npmrc if npm should not.', (key) => (pnpmSends(key) ? undefined : npmVisible(key)));
}
const userConfig = readPnpmNpmrcMap(userConfigPath, pnpmVersion);
if (!userConfig || !requestDart) {
return;
}
// Where pnpm pins a `tokenHelper` written without a registry prefix.
const pinnedDart = (0, semver_1.gte)(pnpmVersion, '11.4.0')
? // From 11.4.0 it rescopes per file, onto the registry that same file
// declares (rescopeUnscopedCreds), expanding `${VAR}` before reading it
// off.
pnpmNerfDart((0, utils_1.expandPnpmEnvVars)(userConfig.get('registry') ?? '') || DEFAULT_REGISTRY, pnpmVersion)
: // 10.x and 11.0-11.3 pin it onto the registry that wins overall instead
// (getAuthHeadersFromCreds keys the unscoped credential on the resolved
// `registry`). The default registry, never a scoped one: pnpm keys the
// helper on `registry` alone, so a scoped package goes elsewhere without
// it.
(0, utils_1.nerfDart)(npmResolved(env, projectNpmrc, 'registry', managerIgnoresEnv) ||
DEFAULT_REGISTRY);
const requestKeys = (0, utils_1.registryKeysFor)(requestDart);
if (!declaresTokenHelper(userConfig, requestKeys, pinnedDart)) {
return;
}
// A plain credential npm holds beside the helper is one npm still sends, so
// there is nothing to report about the helper it never runs.
if (!(0, utils_1.hasCredentialFor)(requestDart, npmVisible)) {
warnTokenHelper(requestDart);
}
}
/**
* Whether the credential pnpm presents for a request comes from a token helper.
* A helper outranks every other credential for that registry, whichever layer it
* came from (credsToHeader), so finding one settles what pnpm sends.
* `requestKeys` is the dart chain pnpm walks, so an unscoped helper counts when
* the dart it was pinned to is one of them.
*/
function declaresTokenHelper(userConfig, requestKeys, pinnedDart) {
// pnpm expands `${VAR}` in this file's values as well as its keys, and a value
// resolving to nothing declares no helper at all.
const declared = (key) => (0, utils_1.expandPnpmEnvVars)((0, utils_1.readExpandedKey)(userConfig, key, utils_1.expandPnpmEnvVars) ?? '');
return requestKeys.some((key) => !!declared(`${key}:tokenHelper`) ||
(key === pinnedDart && !!declared('tokenHelper')));
}
let warnedTokenHelper = false;
// The nerf dart only, for the same reason warnUnscopedCredential uses it. The
// helper's command line stays out too: it is what produces the credential.
function warnTokenHelper(dart) {
if (warnedTokenHelper) {
return;
}
warnedTokenHelper = true;
logger_1.logger.warn(`pnpm runs a token helper to authenticate with ${dart}, which npm cannot do, so packages will be fetched from there without that credential. Store the token the helper returns as "${dart}:_authToken=..." in a file npm reads if it should authenticate there.`);
}
let warnedUnscopedCredential = false;
// The nerf dart, not the registry URL: a registry URL can carry its own basic
// auth, which would then be in every console and CI log the warning reaches.
function warnUnscopedCredential(dart, keys) {
if (warnedUnscopedCredential) {
return;
}
warnedUnscopedCredential = true;
const scoped = keys.map((key) => `"${dart}:${key}=..."`).join(', ');
logger_1.logger.warn(`A credential in pnpm's auth.ini is not scoped to a registry, so it was not used for ${dart} when fetching packages. pnpm pins an unscoped credential to the registry that same file declares, and has deprecated the unscoped form. Scope it (${scoped}) to use it with this registry.`);
}
/**
* Network settings pnpm honors from a yaml configuration file. `caFile`/
* `cafile` is the one it accepts and then never uses (it loads a CA file from
* the npmrc-family files alone, measured on 10.18.0 and 11.20.0), so that key
* is deliberately not bridged; inline `ca`/`cert`/`key` declared here do reach
* its fetch, unpinned, exactly as npm's own spelling of them does.
*/
function applyYamlNetworkSettings(env, settings, applyNoProxy = true) {
// Only the boolean turns verification off (rejectUnauthorized is
// `strictSsl ?? true`, and the agent that carries it is built for
// `strictSsl === false`), so any other declared value restores npm's default
// over a `strict-ssl=false` from a file below.
if (settings.strictSsl !== undefined) {
(0, utils_1.setStrictSsl)(env, settings.strictSsl !== false);
}
if (settings.ca) {
env['npm_config_ca'] = settings.ca;
}
for (const key of ['cert', 'key']) {
if (settings[key]) {
env[`npm_config_${key}`] = settings[key];
}
}
// pnpm honors either spelling and prefers noProxy when both are set. The
// proxies themselves are resolved with every other tier's rather than here.
if (applyNoProxy) {
(0, utils_1.setProxies)(env, { noProxy: settings.noProxy ?? settings.noproxy });
}
}