mirror of
https://github.com/pnpm/pnpm.git
synced 2026-06-28 01:45:30 -04:00
fix: harden package-manager bootstrap metadata (#12296)
- Resolve package-manager bootstrap metadata through trusted user/CLI registries and trusted network config, defaulting to the public npm registry instead of project/workspace registry settings. - Apply that bootstrap config in `switchCliVersion()` and `syncEnvLockfile()` so repository `.npmrc` proxy/TLS/configByUri values cannot steer package-manager bootstrap traffic. - Validate repository-provided package-manager env-lockfile entries before auto-switch install/execution: dependency paths must be registry package paths and package records must use integrity-only resolutions. - Preserve the fast path for fully resolved, valid package-manager metadata; incomplete metadata is still resolved through trusted bootstrap registries. - Handle peer-suffixed package-manager snapshots by looking up `packages` entries with `removeSuffix(depPath)` while keeping `snapshots` keyed by the full dep path.
This commit is contained in:
6
.changeset/clean-package-manager-registries.md
Normal file
6
.changeset/clean-package-manager-registries.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@pnpm/config.reader": patch
|
||||
"pnpm": patch
|
||||
---
|
||||
|
||||
Resolve package-manager bootstrap dependencies with trusted user or CLI registry and network config, and reject package-manager env-lockfile records that do not use registry package paths with integrity-only resolutions before auto-switch execution.
|
||||
@@ -18,6 +18,18 @@ export type UniversalOptions = Pick<Config, 'color' | 'dir' | 'authConfig'>
|
||||
|
||||
export type VerifyDepsBeforeRun = 'install' | 'warn' | 'error' | 'prompt' | false
|
||||
|
||||
export interface PackageManagerNetworkConfig {
|
||||
ca?: string | string[]
|
||||
cert?: string | string[]
|
||||
configByUri: Record<string, RegistryConfig>
|
||||
httpProxy?: string
|
||||
httpsProxy?: string
|
||||
key?: string
|
||||
localAddress?: string
|
||||
noProxy?: string | boolean
|
||||
strictSsl?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime state, workspace context, and CLI metadata.
|
||||
* These fields are NOT user-facing settings — they are computed at startup
|
||||
@@ -226,6 +238,8 @@ export interface Config extends OptionsFromRootManifest {
|
||||
pnprServer?: string
|
||||
|
||||
registries: Registries
|
||||
packageManagerRegistries?: Registries
|
||||
packageManagerNetworkConfig?: PackageManagerNetworkConfig
|
||||
namedRegistries?: Record<string, string>
|
||||
configByUri: Record<string, RegistryConfig>
|
||||
ignoreWorkspaceRootCheck: boolean
|
||||
|
||||
@@ -29,6 +29,7 @@ import type {
|
||||
Config,
|
||||
ConfigContext,
|
||||
ConfigWithDeprecatedSettings,
|
||||
PackageManagerNetworkConfig,
|
||||
ProjectConfig,
|
||||
UniversalOptions,
|
||||
VerifyDepsBeforeRun,
|
||||
@@ -322,10 +323,21 @@ export async function getConfig (opts: {
|
||||
default: normalizeRegistryUrl(pnpmConfig.authConfig.registry),
|
||||
...networkConfigs.registries,
|
||||
}
|
||||
const trustedAuthConfig = pickIniConfig(npmrcResult.trustedConfig)
|
||||
const trustedNetworkConfigs = getNetworkConfigs(trustedAuthConfig)
|
||||
pnpmConfig.registries = { ...registriesFromNpmrc }
|
||||
if (explicitlySetKeys.has('registry') && typeof pnpmConfig.registry === 'string') {
|
||||
pnpmConfig.registries.default = normalizeRegistryUrl(pnpmConfig.registry)
|
||||
}
|
||||
pnpmConfig.packageManagerRegistries = {
|
||||
default: normalizeRegistryUrl(trustedAuthConfig.registry as string),
|
||||
...trustedNetworkConfigs.registries,
|
||||
}
|
||||
pnpmConfig.packageManagerNetworkConfig = createPackageManagerNetworkConfig(
|
||||
npmrcResult.trustedConfig,
|
||||
trustedNetworkConfigs.configByUri ?? {},
|
||||
env
|
||||
)
|
||||
pnpmConfig.configByUri = { ...networkConfigs.configByUri }
|
||||
|
||||
// tokenHelper must only come from user-level config (~/.npmrc or global auth.ini),
|
||||
@@ -496,6 +508,7 @@ export async function getConfig (opts: {
|
||||
throw new TypeError(`Unexpected type of registry, expecting a string but received ${JSON.stringify(value)}`)
|
||||
}
|
||||
pnpmConfig.registries.default = normalizeRegistryUrl(value)
|
||||
pnpmConfig.packageManagerRegistries.default = normalizeRegistryUrl(value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -716,6 +729,42 @@ function getProcessEnv (env: string): string | undefined {
|
||||
process.env[env.toLowerCase()]
|
||||
}
|
||||
|
||||
function createPackageManagerNetworkConfig (
|
||||
trustedConfig: Record<string, unknown>,
|
||||
configByUri: PackageManagerNetworkConfig['configByUri'],
|
||||
env: Record<string, string | undefined>
|
||||
): PackageManagerNetworkConfig {
|
||||
const httpsProxy = getProxyValue(
|
||||
trustedConfig['https-proxy'] ?? trustedConfig.proxy,
|
||||
getEnvValue(env, 'https_proxy')
|
||||
)
|
||||
const httpProxy = getProxyValue(
|
||||
trustedConfig['http-proxy'],
|
||||
httpsProxy ?? getEnvValue(env, 'http_proxy') ?? getEnvValue(env, 'proxy')
|
||||
)
|
||||
return {
|
||||
ca: trustedConfig.ca as string | string[] | undefined,
|
||||
cert: trustedConfig.cert as string | string[] | undefined,
|
||||
configByUri,
|
||||
httpProxy,
|
||||
httpsProxy,
|
||||
key: trustedConfig.key as string | undefined,
|
||||
localAddress: trustedConfig['local-address'] as string | undefined,
|
||||
noProxy: (trustedConfig['no-proxy'] ?? trustedConfig.noproxy ?? getEnvValue(env, 'no_proxy')) as string | boolean | undefined,
|
||||
strictSsl: trustedConfig['strict-ssl'] as boolean | undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function getEnvValue (env: Record<string, string | undefined>, key: string): string | undefined {
|
||||
return env[key] ?? env[key.toUpperCase()] ?? env[key.toLowerCase()]
|
||||
}
|
||||
|
||||
function getProxyValue (value: unknown, fallback: string | undefined): string | undefined {
|
||||
if (value === false || value === null) return undefined
|
||||
if (typeof value === 'string' && value.length > 0) return value
|
||||
return fallback
|
||||
}
|
||||
|
||||
// Look up a `pnpm_config_<key>` env var, accepting both lowercase and
|
||||
// uppercase forms. Used for env vars that need to be read before the
|
||||
// general parseEnvVars pass, such as those that affect which .npmrc file
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface NpmrcConfigResult {
|
||||
mergedConfig: Record<string, unknown>
|
||||
/** Raw config suitable for pnpmConfig.authConfig (filtered through pickIniConfig by consumer) */
|
||||
rawConfig: Record<string, unknown>
|
||||
/** Non-project npmrc config used for package-manager bootstrap */
|
||||
trustedConfig: Record<string, unknown>
|
||||
/** Workspace .npmrc data */
|
||||
workspaceNpmrc: Record<string, unknown>
|
||||
/** User ~/.npmrc data (for token helpers) */
|
||||
@@ -115,6 +117,15 @@ export function loadNpmrcConfig (opts: LoadNpmrcConfigOpts): NpmrcConfigResult {
|
||||
}
|
||||
}
|
||||
|
||||
const trustedConfig: Record<string, unknown> = {}
|
||||
for (const source of [pnpmBuiltinConfig, opts.defaultOptions, userConfig, pnpmAuthConfig, cliOptions]) {
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (isNpmrcReadableKey(key)) {
|
||||
trustedConfig[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build rawConfig with same priority order
|
||||
const rawConfig = {
|
||||
...pnpmBuiltinConfig,
|
||||
@@ -128,6 +139,7 @@ export function loadNpmrcConfig (opts: LoadNpmrcConfigOpts): NpmrcConfigResult {
|
||||
return {
|
||||
mergedConfig,
|
||||
rawConfig,
|
||||
trustedConfig,
|
||||
workspaceNpmrc,
|
||||
userConfig,
|
||||
localPrefix,
|
||||
|
||||
@@ -605,6 +605,7 @@ test('registries in current directory\'s .npmrc have bigger priority then global
|
||||
'@bar': 'https://bar.com/',
|
||||
'@qar': 'https://qar.com/qar',
|
||||
})
|
||||
expect(config.packageManagerRegistries?.default).toBe('https://default.com/')
|
||||
})
|
||||
|
||||
test('project .npmrc does not expand env variables in registry URLs', async () => {
|
||||
@@ -818,6 +819,69 @@ test('pnpm-workspace.yaml request destinations do not expand env variables', asy
|
||||
expect(JSON.stringify(config)).not.toContain('secret')
|
||||
})
|
||||
|
||||
test('package manager bootstrap registries ignore project workspace registries', async () => {
|
||||
prepareEmpty()
|
||||
|
||||
fs.writeFileSync('user.npmrc', [
|
||||
'registry=https://trusted.example.com/',
|
||||
'@pnpm:registry=https://trusted-pnpm.example.com/',
|
||||
'strict-ssl=true',
|
||||
'//trusted.example.com/:_authToken=trusted-token',
|
||||
'',
|
||||
].join('\n'), 'utf8')
|
||||
fs.writeFileSync('.npmrc', [
|
||||
'registry=https://project.example.com/',
|
||||
'https-proxy=http://project-proxy.example.com:8080',
|
||||
'strict-ssl=false',
|
||||
'//project.example.com/:_authToken=project-token',
|
||||
'',
|
||||
].join('\n'), 'utf8')
|
||||
writeYamlFileSync('pnpm-workspace.yaml', {
|
||||
registries: {
|
||||
'@pnpm': 'https://workspace-pnpm.example.com/',
|
||||
default: 'https://workspace.example.com/',
|
||||
},
|
||||
})
|
||||
|
||||
const { config } = await getConfig({
|
||||
cliOptions: {
|
||||
userconfig: path.resolve('user.npmrc'),
|
||||
},
|
||||
env: {
|
||||
...env,
|
||||
XDG_CONFIG_HOME: path.resolve('xdg-config'),
|
||||
https_proxy: 'http://trusted-env-proxy.example.com:8080',
|
||||
no_proxy: 'trusted-env-no-proxy.example.com',
|
||||
},
|
||||
packageManager: { name: 'pnpm', version: '1.0.0' },
|
||||
workspaceDir: process.cwd(),
|
||||
})
|
||||
|
||||
expect(config.registries).toMatchObject({
|
||||
'@pnpm': 'https://workspace-pnpm.example.com/',
|
||||
default: 'https://workspace.example.com/',
|
||||
})
|
||||
expect(config.packageManagerRegistries).toMatchObject({
|
||||
'@pnpm': 'https://trusted-pnpm.example.com/',
|
||||
default: 'https://trusted.example.com/',
|
||||
})
|
||||
expect(config.httpsProxy).toBe('http://project-proxy.example.com:8080')
|
||||
expect(config.strictSsl).toBe(false)
|
||||
expect(config.configByUri).toMatchObject({
|
||||
'//project.example.com/': { creds: { authToken: 'project-token' } },
|
||||
})
|
||||
expect(config.packageManagerNetworkConfig).toMatchObject({
|
||||
configByUri: {
|
||||
'//trusted.example.com/': { creds: { authToken: 'trusted-token' } },
|
||||
},
|
||||
httpProxy: 'http://trusted-env-proxy.example.com:8080',
|
||||
httpsProxy: 'http://trusted-env-proxy.example.com:8080',
|
||||
noProxy: 'trusted-env-no-proxy.example.com',
|
||||
strictSsl: true,
|
||||
})
|
||||
expect(config.packageManagerNetworkConfig?.configByUri['//project.example.com/']).toBeUndefined()
|
||||
})
|
||||
|
||||
test('CLI --registry overrides pnpm-workspace.yaml registries.default (#10099)', async () => {
|
||||
prepareEmpty()
|
||||
|
||||
@@ -834,6 +898,7 @@ test('CLI --registry overrides pnpm-workspace.yaml registries.default (#10099)',
|
||||
})
|
||||
|
||||
expect(config.registry).toBe('https://cli.example.com/')
|
||||
expect(config.packageManagerRegistries?.default).toBe('https://cli.example.com/')
|
||||
})
|
||||
|
||||
test('auth tokens from pnpm auth file override ~/.npmrc', async () => {
|
||||
|
||||
88
pnpm/src/packageManagerLockfile.ts
Normal file
88
pnpm/src/packageManagerLockfile.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { parse as parseDepPath, refToRelative, removeSuffix } from '@pnpm/deps.path'
|
||||
import { PnpmError } from '@pnpm/error'
|
||||
import type { EnvLockfile, LockfilePackageInfo, LockfileResolution } from '@pnpm/lockfile.types'
|
||||
import type { DepPath } from '@pnpm/types'
|
||||
|
||||
export function assertPackageManagerLockfileUsesRegistryResolutions (envLockfile: EnvLockfile): void {
|
||||
const packageManagerDependencies = envLockfile.importers['.'].packageManagerDependencies
|
||||
if (packageManagerDependencies == null) {
|
||||
throw new PnpmError('INVALID_PACKAGE_MANAGER_LOCKFILE', 'The packageManager dependencies were not found in pnpm-lock.yaml')
|
||||
}
|
||||
|
||||
const visited = new Set<DepPath>()
|
||||
for (const [name, dependency] of Object.entries(packageManagerDependencies)) {
|
||||
const depPath = refToRelative(dependency.version, name)
|
||||
if (depPath == null) {
|
||||
throw invalidPackageManagerLockfile(name)
|
||||
}
|
||||
assertRegistryPackageManagerDependency(envLockfile, depPath, visited)
|
||||
}
|
||||
}
|
||||
|
||||
function assertRegistryPackageManagerDependency (
|
||||
envLockfile: EnvLockfile,
|
||||
depPath: DepPath,
|
||||
visited: Set<DepPath>
|
||||
): void {
|
||||
if (visited.has(depPath)) return
|
||||
visited.add(depPath)
|
||||
|
||||
const packageInfo = envLockfile.packages[removeSuffix(depPath)]
|
||||
const snapshot = envLockfile.snapshots[depPath]
|
||||
if (packageInfo == null || snapshot == null) {
|
||||
throw invalidPackageManagerLockfile(depPath)
|
||||
}
|
||||
|
||||
assertRegistryPackagePath(depPath, packageInfo)
|
||||
assertIntegrityOnlyResolution(depPath, packageInfo.resolution)
|
||||
|
||||
for (const [name, ref] of Object.entries({
|
||||
...snapshot.dependencies,
|
||||
...snapshot.optionalDependencies,
|
||||
})) {
|
||||
const nextDepPath = refToRelative(ref, name)
|
||||
if (nextDepPath == null) {
|
||||
throw invalidPackageManagerLockfile(depPath)
|
||||
}
|
||||
assertRegistryPackageManagerDependency(envLockfile, nextDepPath, visited)
|
||||
}
|
||||
}
|
||||
|
||||
function assertRegistryPackagePath (depPath: DepPath, packageInfo: LockfilePackageInfo): void {
|
||||
const parsedDepPath = parseDepPath(depPath)
|
||||
if (parsedDepPath.name == null || parsedDepPath.version == null || parsedDepPath.nonSemverVersion != null) {
|
||||
throw invalidPackageManagerLockfile(depPath)
|
||||
}
|
||||
if (packageInfo.id != null) {
|
||||
throw invalidPackageManagerLockfile(depPath)
|
||||
}
|
||||
if (packageInfo.name != null && packageInfo.name !== parsedDepPath.name) {
|
||||
throw invalidPackageManagerLockfile(depPath)
|
||||
}
|
||||
if (packageInfo.version != null && packageInfo.version !== parsedDepPath.version) {
|
||||
throw invalidPackageManagerLockfile(depPath)
|
||||
}
|
||||
}
|
||||
|
||||
function assertIntegrityOnlyResolution (depPath: DepPath, resolution: LockfileResolution): void {
|
||||
if (resolution == null || typeof resolution !== 'object' || Array.isArray(resolution)) {
|
||||
throw invalidPackageManagerLockfile(depPath)
|
||||
}
|
||||
const resolutionKeys = Object.keys(resolution)
|
||||
if (
|
||||
resolutionKeys.length !== 1 ||
|
||||
resolutionKeys[0] !== 'integrity' ||
|
||||
!('integrity' in resolution) ||
|
||||
typeof resolution.integrity !== 'string' ||
|
||||
resolution.integrity.length === 0
|
||||
) {
|
||||
throw invalidPackageManagerLockfile(depPath)
|
||||
}
|
||||
}
|
||||
|
||||
function invalidPackageManagerLockfile (depPath: string): PnpmError {
|
||||
return new PnpmError(
|
||||
'INVALID_PACKAGE_MANAGER_LOCKFILE',
|
||||
`The packageManager dependency "${depPath}" in pnpm-lock.yaml must use a registry package path and an integrity-only resolution`
|
||||
)
|
||||
}
|
||||
39
pnpm/src/packageManagerRegistries.ts
Normal file
39
pnpm/src/packageManagerRegistries.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { Config } from '@pnpm/config.reader'
|
||||
import type { Registries, RegistryConfig } from '@pnpm/types'
|
||||
|
||||
const DEFAULT_PACKAGE_MANAGER_REGISTRY = 'https://registry.npmjs.org/'
|
||||
|
||||
export interface PackageManagerBootstrapConfig {
|
||||
ca?: string | string[]
|
||||
cert?: string | string[]
|
||||
configByUri: Record<string, RegistryConfig>
|
||||
httpProxy?: string
|
||||
httpsProxy?: string
|
||||
key?: string
|
||||
localAddress?: string
|
||||
noProxy?: string | boolean
|
||||
registries: Registries
|
||||
strictSsl?: boolean
|
||||
}
|
||||
|
||||
export function getPackageManagerRegistries (config: Config): Registries {
|
||||
return {
|
||||
default: DEFAULT_PACKAGE_MANAGER_REGISTRY,
|
||||
...config.packageManagerRegistries,
|
||||
}
|
||||
}
|
||||
|
||||
export function getPackageManagerBootstrapConfig (config: Config): PackageManagerBootstrapConfig {
|
||||
return {
|
||||
ca: config.packageManagerNetworkConfig?.ca,
|
||||
cert: config.packageManagerNetworkConfig?.cert,
|
||||
configByUri: config.packageManagerNetworkConfig?.configByUri ?? {},
|
||||
httpProxy: config.packageManagerNetworkConfig?.httpProxy,
|
||||
httpsProxy: config.packageManagerNetworkConfig?.httpsProxy,
|
||||
key: config.packageManagerNetworkConfig?.key,
|
||||
localAddress: config.packageManagerNetworkConfig?.localAddress,
|
||||
noProxy: config.packageManagerNetworkConfig?.noProxy,
|
||||
registries: getPackageManagerRegistries(config),
|
||||
strictSsl: config.packageManagerNetworkConfig?.strictSsl,
|
||||
}
|
||||
}
|
||||
378
pnpm/src/switchCliVersion.test.ts
Normal file
378
pnpm/src/switchCliVersion.test.ts
Normal file
@@ -0,0 +1,378 @@
|
||||
import { beforeEach, expect, jest, test } from '@jest/globals'
|
||||
import type { Config, ConfigContext } from '@pnpm/config.reader'
|
||||
import type { EnvLockfile } from '@pnpm/lockfile.types'
|
||||
|
||||
const closeStore = jest.fn<() => Promise<void>>(async () => {})
|
||||
const createStoreController = jest.fn<(opts: object) => Promise<{
|
||||
ctrl: { close: typeof closeStore }
|
||||
dir: string
|
||||
}>>(async () => ({
|
||||
ctrl: { close: closeStore },
|
||||
dir: '/store',
|
||||
}))
|
||||
const envLockfile: EnvLockfile = {
|
||||
importers: {
|
||||
'.': {
|
||||
configDependencies: {},
|
||||
packageManagerDependencies: {
|
||||
'@pnpm/exe': { specifier: '9.3.0', version: '9.3.0' },
|
||||
pnpm: { specifier: '9.3.0', version: '9.3.0' },
|
||||
},
|
||||
},
|
||||
},
|
||||
lockfileVersion: '9.0',
|
||||
packages: {
|
||||
'@pnpm/exe@9.3.0': {
|
||||
resolution: {
|
||||
integrity: 'sha512-exe',
|
||||
},
|
||||
},
|
||||
'@pnpm/linux-x64@9.3.0': {
|
||||
resolution: {
|
||||
integrity: 'sha512-linux-x64',
|
||||
},
|
||||
},
|
||||
'pnpm@9.3.0': {
|
||||
resolution: {
|
||||
integrity: 'sha512-pnpm',
|
||||
},
|
||||
},
|
||||
},
|
||||
snapshots: {
|
||||
'@pnpm/exe@9.3.0': {
|
||||
optionalDependencies: {
|
||||
'@pnpm/linux-x64': '9.3.0',
|
||||
},
|
||||
},
|
||||
'@pnpm/linux-x64@9.3.0': {
|
||||
optional: true,
|
||||
},
|
||||
'pnpm@9.3.0': {},
|
||||
},
|
||||
}
|
||||
const installPnpmToStore = jest.fn<(version: string, opts: object) => Promise<{ binDir: string }>>(async () => ({ binDir: '/store/bin' }))
|
||||
const readEnvLockfile = jest.fn<(rootDir: string) => Promise<EnvLockfile | null>>(async () => envLockfile)
|
||||
const resolvePackageManagerIntegrities = jest.fn<(version: string, opts: object) => Promise<EnvLockfile>>(async () => envLockfile)
|
||||
const spawnSync = jest.fn(() => ({ status: 0 }))
|
||||
|
||||
jest.unstable_mockModule('@pnpm/cli.meta', () => ({
|
||||
packageManager: { name: 'pnpm', version: '11.0.0' },
|
||||
}))
|
||||
jest.unstable_mockModule('@pnpm/engine.pm.commands', () => ({
|
||||
installPnpmToStore,
|
||||
}))
|
||||
jest.unstable_mockModule('@pnpm/installing.env-installer', () => ({
|
||||
isPackageManagerResolved: () => true,
|
||||
resolvePackageManagerIntegrities,
|
||||
}))
|
||||
jest.unstable_mockModule('@pnpm/lockfile.fs', () => ({
|
||||
readEnvLockfile,
|
||||
}))
|
||||
jest.unstable_mockModule('@pnpm/shell.path', () => ({
|
||||
prependDirsToPath: () => ({ name: 'PATH', updated: true, value: '/store/bin' }),
|
||||
}))
|
||||
jest.unstable_mockModule('@pnpm/store.connection-manager', () => ({
|
||||
createStoreController,
|
||||
}))
|
||||
jest.unstable_mockModule('cross-spawn', () => ({
|
||||
default: { sync: spawnSync },
|
||||
}))
|
||||
|
||||
const { switchCliVersion } = await import('./switchCliVersion.js')
|
||||
|
||||
beforeEach(() => {
|
||||
closeStore.mockClear()
|
||||
createStoreController.mockClear()
|
||||
installPnpmToStore.mockClear()
|
||||
readEnvLockfile.mockClear()
|
||||
readEnvLockfile.mockResolvedValue(envLockfile)
|
||||
resolvePackageManagerIntegrities.mockClear()
|
||||
resolvePackageManagerIntegrities.mockResolvedValue(envLockfile)
|
||||
spawnSync.mockClear()
|
||||
})
|
||||
|
||||
test('switchCliVersion uses trusted package-manager registries instead of project registries', async () => {
|
||||
const exit = jest.spyOn(process, 'exit').mockImplementation(((code?: string | number | null | undefined) => {
|
||||
throw new Error(`exit ${code ?? 0}`)
|
||||
}) as typeof process.exit)
|
||||
|
||||
const projectRegistries = {
|
||||
'@pnpm': 'https://project-pnpm.example.com/',
|
||||
default: 'https://project.example.com/',
|
||||
}
|
||||
const packageManagerRegistries = {
|
||||
'@pnpm': 'https://trusted-pnpm.example.com/',
|
||||
default: 'https://trusted.example.com/',
|
||||
}
|
||||
const packageManagerNetworkConfig = {
|
||||
configByUri: {
|
||||
'//trusted.example.com/': {
|
||||
creds: { authToken: 'trusted-token' },
|
||||
},
|
||||
},
|
||||
httpProxy: 'http://trusted-http-proxy.example.com:8080',
|
||||
httpsProxy: 'http://trusted-https-proxy.example.com:8080',
|
||||
noProxy: 'trusted.internal',
|
||||
strictSsl: true,
|
||||
}
|
||||
const config = {
|
||||
configByUri: {
|
||||
'//project.example.com/': {
|
||||
creds: { authToken: 'project-token' },
|
||||
},
|
||||
},
|
||||
httpProxy: 'http://project-http-proxy.example.com:8080',
|
||||
httpsProxy: 'http://project-https-proxy.example.com:8080',
|
||||
noProxy: 'project.internal',
|
||||
packageManagerRegistries,
|
||||
packageManagerNetworkConfig,
|
||||
registries: projectRegistries,
|
||||
strictSsl: false,
|
||||
virtualStoreDirMaxLength: 120,
|
||||
} as unknown as Config
|
||||
const context = {
|
||||
rootProjectManifestDir: '/repo',
|
||||
wantedPackageManager: {
|
||||
fromDevEngines: true,
|
||||
name: 'pnpm',
|
||||
onFail: 'download',
|
||||
version: '9.3.0',
|
||||
},
|
||||
} as unknown as ConfigContext
|
||||
|
||||
await expect(switchCliVersion(config, context)).rejects.toThrow('exit 0')
|
||||
|
||||
expect(createStoreController).toHaveBeenCalledWith(expect.objectContaining({
|
||||
configByUri: packageManagerNetworkConfig.configByUri,
|
||||
httpProxy: packageManagerNetworkConfig.httpProxy,
|
||||
httpsProxy: packageManagerNetworkConfig.httpsProxy,
|
||||
noProxy: packageManagerNetworkConfig.noProxy,
|
||||
registries: packageManagerRegistries,
|
||||
strictSsl: packageManagerNetworkConfig.strictSsl,
|
||||
}))
|
||||
expect(resolvePackageManagerIntegrities).not.toHaveBeenCalled()
|
||||
expect(installPnpmToStore).toHaveBeenCalledWith('9.3.0', expect.objectContaining({
|
||||
registries: packageManagerRegistries,
|
||||
}))
|
||||
expect(installPnpmToStore).not.toHaveBeenCalledWith('9.3.0', expect.objectContaining({
|
||||
registries: projectRegistries,
|
||||
}))
|
||||
|
||||
exit.mockRestore()
|
||||
})
|
||||
|
||||
test('switchCliVersion defaults package-manager registries to npmjs instead of project registries', async () => {
|
||||
const exit = jest.spyOn(process, 'exit').mockImplementation(((code?: string | number | null | undefined) => {
|
||||
throw new Error(`exit ${code ?? 0}`)
|
||||
}) as typeof process.exit)
|
||||
|
||||
const projectRegistries = {
|
||||
'@pnpm': 'https://project-pnpm.example.com/',
|
||||
default: 'https://project.example.com/',
|
||||
}
|
||||
const config = {
|
||||
configByUri: {
|
||||
'//project.example.com/': {
|
||||
creds: { authToken: 'project-token' },
|
||||
},
|
||||
},
|
||||
httpProxy: 'http://project-http-proxy.example.com:8080',
|
||||
httpsProxy: 'http://project-https-proxy.example.com:8080',
|
||||
noProxy: 'project.internal',
|
||||
registries: projectRegistries,
|
||||
strictSsl: false,
|
||||
virtualStoreDirMaxLength: 120,
|
||||
} as unknown as Config
|
||||
const context = {
|
||||
rootProjectManifestDir: '/repo',
|
||||
wantedPackageManager: {
|
||||
fromDevEngines: true,
|
||||
name: 'pnpm',
|
||||
onFail: 'download',
|
||||
version: '9.3.0',
|
||||
},
|
||||
} as unknown as ConfigContext
|
||||
|
||||
await expect(switchCliVersion(config, context)).rejects.toThrow('exit 0')
|
||||
|
||||
expect(createStoreController).toHaveBeenCalledWith(expect.objectContaining({
|
||||
configByUri: {},
|
||||
httpProxy: undefined,
|
||||
httpsProxy: undefined,
|
||||
noProxy: undefined,
|
||||
registries: { default: 'https://registry.npmjs.org/' },
|
||||
strictSsl: undefined,
|
||||
}))
|
||||
expect(resolvePackageManagerIntegrities).not.toHaveBeenCalled()
|
||||
expect(installPnpmToStore).toHaveBeenCalledWith('9.3.0', expect.objectContaining({
|
||||
registries: { default: 'https://registry.npmjs.org/' },
|
||||
}))
|
||||
expect(installPnpmToStore).not.toHaveBeenCalledWith('9.3.0', expect.objectContaining({
|
||||
registries: projectRegistries,
|
||||
}))
|
||||
|
||||
exit.mockRestore()
|
||||
})
|
||||
|
||||
test('switchCliVersion installs from a registry-only package-manager lockfile without re-resolving', async () => {
|
||||
const exit = jest.spyOn(process, 'exit').mockImplementation(((code?: string | number | null | undefined) => {
|
||||
throw new Error(`exit ${code ?? 0}`)
|
||||
}) as typeof process.exit)
|
||||
|
||||
await expect(switchCliVersion({
|
||||
registries: { default: 'https://registry.npmjs.org/' },
|
||||
virtualStoreDirMaxLength: 120,
|
||||
} as unknown as Config, {
|
||||
rootProjectManifestDir: '/repo',
|
||||
wantedPackageManager: {
|
||||
fromDevEngines: true,
|
||||
name: 'pnpm',
|
||||
onFail: 'download',
|
||||
version: '9.3.0',
|
||||
},
|
||||
} as unknown as ConfigContext)).rejects.toThrow('exit 0')
|
||||
|
||||
expect(resolvePackageManagerIntegrities).not.toHaveBeenCalled()
|
||||
expect(installPnpmToStore).toHaveBeenCalledWith('9.3.0', expect.objectContaining({
|
||||
envLockfile,
|
||||
}))
|
||||
|
||||
exit.mockRestore()
|
||||
})
|
||||
|
||||
test('switchCliVersion accepts registry-only package-manager lockfiles with peer-suffixed snapshots', async () => {
|
||||
const exit = jest.spyOn(process, 'exit').mockImplementation(((code?: string | number | null | undefined) => {
|
||||
throw new Error(`exit ${code ?? 0}`)
|
||||
}) as typeof process.exit)
|
||||
const peerLockfile: EnvLockfile = {
|
||||
...envLockfile,
|
||||
packages: {
|
||||
...envLockfile.packages,
|
||||
'@pnpm/linux-x64@9.3.0': {
|
||||
resolution: {
|
||||
integrity: 'sha512-linux-x64',
|
||||
},
|
||||
},
|
||||
'peer-provider@1.0.0': {
|
||||
resolution: {
|
||||
integrity: 'sha512-peer-provider',
|
||||
},
|
||||
},
|
||||
},
|
||||
snapshots: {
|
||||
...envLockfile.snapshots,
|
||||
'@pnpm/exe@9.3.0': {
|
||||
optionalDependencies: {
|
||||
'@pnpm/linux-x64': '9.3.0(peer-provider@1.0.0)',
|
||||
},
|
||||
},
|
||||
'@pnpm/linux-x64@9.3.0(peer-provider@1.0.0)': {
|
||||
dependencies: {
|
||||
'peer-provider': '1.0.0',
|
||||
},
|
||||
optional: true,
|
||||
},
|
||||
'peer-provider@1.0.0': {},
|
||||
},
|
||||
}
|
||||
|
||||
readEnvLockfile.mockResolvedValueOnce(peerLockfile)
|
||||
|
||||
await expect(switchCliVersion({
|
||||
registries: { default: 'https://registry.npmjs.org/' },
|
||||
virtualStoreDirMaxLength: 120,
|
||||
} as unknown as Config, {
|
||||
rootProjectManifestDir: '/repo',
|
||||
wantedPackageManager: {
|
||||
fromDevEngines: true,
|
||||
name: 'pnpm',
|
||||
onFail: 'download',
|
||||
version: '9.3.0',
|
||||
},
|
||||
} as unknown as ConfigContext)).rejects.toThrow('exit 0')
|
||||
|
||||
expect(resolvePackageManagerIntegrities).not.toHaveBeenCalled()
|
||||
expect(installPnpmToStore).toHaveBeenCalledWith('9.3.0', expect.objectContaining({
|
||||
envLockfile: peerLockfile,
|
||||
}))
|
||||
|
||||
exit.mockRestore()
|
||||
})
|
||||
|
||||
test('switchCliVersion rejects package-manager lockfile resolutions with non-integrity fields', async () => {
|
||||
const poisonedLockfile: EnvLockfile = {
|
||||
...envLockfile,
|
||||
packages: {
|
||||
...envLockfile.packages,
|
||||
'@pnpm/linux-x64@9.3.0': {
|
||||
resolution: {
|
||||
integrity: 'sha512-poisoned',
|
||||
tarball: 'https://evil.example.com/pnpm-linux-x64.tgz',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
readEnvLockfile.mockResolvedValueOnce(poisonedLockfile)
|
||||
|
||||
await expect(switchCliVersion({
|
||||
registries: { default: 'https://registry.npmjs.org/' },
|
||||
virtualStoreDirMaxLength: 120,
|
||||
} as unknown as Config, {
|
||||
rootProjectManifestDir: '/repo',
|
||||
wantedPackageManager: {
|
||||
fromDevEngines: true,
|
||||
name: 'pnpm',
|
||||
onFail: 'download',
|
||||
version: '9.3.0',
|
||||
},
|
||||
} as unknown as ConfigContext)).rejects.toThrow('integrity-only resolution')
|
||||
|
||||
expect(resolvePackageManagerIntegrities).not.toHaveBeenCalled()
|
||||
expect(createStoreController).not.toHaveBeenCalled()
|
||||
expect(installPnpmToStore).not.toHaveBeenCalled()
|
||||
expect(spawnSync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('switchCliVersion rejects package-manager lockfile dependencies with non-registry dep paths', async () => {
|
||||
const poisonedLockfile: EnvLockfile = {
|
||||
...envLockfile,
|
||||
packages: {
|
||||
...envLockfile.packages,
|
||||
'payload@file:../payload.tgz': {
|
||||
resolution: {
|
||||
integrity: 'sha512-payload',
|
||||
},
|
||||
},
|
||||
},
|
||||
snapshots: {
|
||||
...envLockfile.snapshots,
|
||||
'pnpm@9.3.0': {
|
||||
dependencies: {
|
||||
payload: 'file:../payload.tgz',
|
||||
},
|
||||
},
|
||||
'payload@file:../payload.tgz': {},
|
||||
},
|
||||
}
|
||||
|
||||
readEnvLockfile.mockResolvedValueOnce(poisonedLockfile)
|
||||
|
||||
await expect(switchCliVersion({
|
||||
registries: { default: 'https://registry.npmjs.org/' },
|
||||
virtualStoreDirMaxLength: 120,
|
||||
} as unknown as Config, {
|
||||
rootProjectManifestDir: '/repo',
|
||||
wantedPackageManager: {
|
||||
fromDevEngines: true,
|
||||
name: 'pnpm',
|
||||
onFail: 'download',
|
||||
version: '9.3.0',
|
||||
},
|
||||
} as unknown as ConfigContext)).rejects.toThrow('registry package path')
|
||||
|
||||
expect(resolvePackageManagerIntegrities).not.toHaveBeenCalled()
|
||||
expect(createStoreController).not.toHaveBeenCalled()
|
||||
expect(installPnpmToStore).not.toHaveBeenCalled()
|
||||
expect(spawnSync).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -13,6 +13,8 @@ import spawn from 'cross-spawn'
|
||||
import semver from 'semver'
|
||||
|
||||
import { exit } from './exit.js'
|
||||
import { assertPackageManagerLockfileUsesRegistryResolutions } from './packageManagerLockfile.js'
|
||||
import { getPackageManagerBootstrapConfig } from './packageManagerRegistries.js'
|
||||
|
||||
export async function switchCliVersion (config: Config, context: ConfigContext): Promise<void> {
|
||||
const pm = context.wantedPackageManager
|
||||
@@ -30,15 +32,16 @@ export async function switchCliVersion (config: Config, context: ConfigContext):
|
||||
? (await readEnvLockfile(context.rootProjectManifestDir) ?? undefined)
|
||||
: undefined
|
||||
let storeToUse: Awaited<ReturnType<typeof createStoreController>> | undefined
|
||||
const packageManagerConfig = getPackageManagerBootstrapConfig(config)
|
||||
|
||||
// Check if the env lockfile already has a resolved version that satisfies the wanted version/range.
|
||||
let pmVersion = envLockfile?.importers['.'].packageManagerDependencies?.['pnpm']?.version
|
||||
if (!pmVersion || !semver.satisfies(pmVersion, pm.version, { includePrerelease: true })) {
|
||||
// Resolve to an exact version from the registry.
|
||||
storeToUse = await createStoreController({ ...config, ...context })
|
||||
storeToUse = await createStoreController({ ...config, ...context, ...packageManagerConfig })
|
||||
envLockfile = await resolvePackageManagerIntegrities(pm.version, {
|
||||
envLockfile,
|
||||
registries: config.registries,
|
||||
registries: packageManagerConfig.registries,
|
||||
rootDir: context.rootProjectManifestDir,
|
||||
storeController: storeToUse.ctrl,
|
||||
storeDir: storeToUse.dir,
|
||||
@@ -51,10 +54,10 @@ export async function switchCliVersion (config: Config, context: ConfigContext):
|
||||
return
|
||||
}
|
||||
} else if (!isPackageManagerResolved(envLockfile, pmVersion)) {
|
||||
storeToUse = await createStoreController({ ...config, ...context })
|
||||
storeToUse = await createStoreController({ ...config, ...context, ...packageManagerConfig })
|
||||
envLockfile = await resolvePackageManagerIntegrities(pmVersion, {
|
||||
envLockfile,
|
||||
registries: config.registries,
|
||||
registries: packageManagerConfig.registries,
|
||||
rootDir: context.rootProjectManifestDir,
|
||||
storeController: storeToUse.ctrl,
|
||||
storeDir: storeToUse.dir,
|
||||
@@ -69,15 +72,22 @@ export async function switchCliVersion (config: Config, context: ConfigContext):
|
||||
return
|
||||
}
|
||||
|
||||
if (!envLockfile) {
|
||||
await storeToUse?.ctrl.close()
|
||||
throw new PnpmError('NO_PKG_MANAGER_INTEGRITY', `The packageManager dependency ${pmVersion} was not found in pnpm-lock.yaml`)
|
||||
}
|
||||
|
||||
try {
|
||||
assertPackageManagerLockfileUsesRegistryResolutions(envLockfile)
|
||||
} catch (err: unknown) {
|
||||
await storeToUse?.ctrl.close()
|
||||
throw err
|
||||
}
|
||||
|
||||
// We need a store controller to install pnpm. If it wasn't created during
|
||||
// integrity resolution (because integrities were already cached), create it now.
|
||||
if (!storeToUse) {
|
||||
storeToUse = await createStoreController({ ...config, ...context })
|
||||
}
|
||||
|
||||
if (!envLockfile) {
|
||||
await storeToUse.ctrl.close()
|
||||
throw new PnpmError('NO_PKG_MANAGER_INTEGRITY', `The packageManager dependency ${pmVersion} was not found in pnpm-lock.yaml`)
|
||||
storeToUse = await createStoreController({ ...config, ...context, ...packageManagerConfig })
|
||||
}
|
||||
|
||||
let wantedPnpmBinDir: string
|
||||
@@ -86,7 +96,7 @@ export async function switchCliVersion (config: Config, context: ConfigContext):
|
||||
envLockfile,
|
||||
storeController: storeToUse.ctrl,
|
||||
storeDir: storeToUse.dir,
|
||||
registries: config.registries,
|
||||
registries: packageManagerConfig.registries,
|
||||
virtualStoreDirMaxLength: config.virtualStoreDirMaxLength,
|
||||
packageManager: { name: packageManager.name, version: packageManager.version },
|
||||
// Network settings so the engine identity check can reach the canonical
|
||||
|
||||
@@ -11,7 +11,7 @@ import { tempDir } from '@pnpm/prepare'
|
||||
// Simulate what the real resolvePackageManagerIntegrities does that this test
|
||||
// cares about: record the resolved pnpm version under
|
||||
// packageManagerDependencies and persist the lockfile to disk.
|
||||
const resolvePackageManagerIntegrities = jest.fn<(version: string, opts: { envLockfile?: EnvLockfile, rootDir: string, save?: boolean }) => Promise<EnvLockfile>>(
|
||||
const resolvePackageManagerIntegrities = jest.fn<(version: string, opts: { envLockfile?: EnvLockfile, registries?: unknown, rootDir: string, save?: boolean }) => Promise<EnvLockfile>>(
|
||||
async (version, opts) => {
|
||||
const lockfile = opts.envLockfile ?? ({ lockfileVersion: '9.0', importers: { '.': { configDependencies: {} } }, packages: {}, snapshots: {} } as EnvLockfile)
|
||||
lockfile.importers['.'].packageManagerDependencies = {
|
||||
@@ -135,6 +135,99 @@ test('updates the lockfile when locked version no longer satisfies wanted versio
|
||||
})
|
||||
})
|
||||
|
||||
test('uses trusted package-manager registries instead of project registries', async () => {
|
||||
const dir = tempDir()
|
||||
const projectRegistries = {
|
||||
'@pnpm': 'https://project-pnpm.example.com/',
|
||||
default: 'https://project.example.com/',
|
||||
}
|
||||
const packageManagerRegistries = {
|
||||
'@pnpm': 'https://trusted-pnpm.example.com/',
|
||||
default: 'https://trusted.example.com/',
|
||||
}
|
||||
const packageManagerNetworkConfig = {
|
||||
configByUri: {
|
||||
'//trusted.example.com/': {
|
||||
creds: { authToken: 'trusted-token' },
|
||||
},
|
||||
},
|
||||
httpProxy: 'http://trusted-http-proxy.example.com:8080',
|
||||
httpsProxy: 'http://trusted-https-proxy.example.com:8080',
|
||||
noProxy: 'trusted.internal',
|
||||
strictSsl: true,
|
||||
}
|
||||
|
||||
await syncEnvLockfile({
|
||||
configByUri: {
|
||||
'//project.example.com/': {
|
||||
creds: { authToken: 'project-token' },
|
||||
},
|
||||
},
|
||||
httpProxy: 'http://project-http-proxy.example.com:8080',
|
||||
httpsProxy: 'http://project-https-proxy.example.com:8080',
|
||||
noProxy: 'project.internal',
|
||||
packageManagerRegistries,
|
||||
packageManagerNetworkConfig,
|
||||
registries: projectRegistries,
|
||||
strictSsl: false,
|
||||
} as unknown as Config, makeContext(dir, {
|
||||
wantedPackageManager: { name: 'pnpm', version: packageManager.version, fromDevEngines: true },
|
||||
}))
|
||||
|
||||
expect(createStoreController).toHaveBeenCalledWith(expect.objectContaining({
|
||||
configByUri: packageManagerNetworkConfig.configByUri,
|
||||
httpProxy: packageManagerNetworkConfig.httpProxy,
|
||||
httpsProxy: packageManagerNetworkConfig.httpsProxy,
|
||||
noProxy: packageManagerNetworkConfig.noProxy,
|
||||
registries: packageManagerRegistries,
|
||||
strictSsl: packageManagerNetworkConfig.strictSsl,
|
||||
}))
|
||||
expect(resolvePackageManagerIntegrities).toHaveBeenCalledWith(packageManager.version, expect.objectContaining({
|
||||
registries: packageManagerRegistries,
|
||||
}))
|
||||
expect(resolvePackageManagerIntegrities).not.toHaveBeenCalledWith(packageManager.version, expect.objectContaining({
|
||||
registries: projectRegistries,
|
||||
}))
|
||||
})
|
||||
|
||||
test('defaults package-manager registries to npmjs instead of project registries', async () => {
|
||||
const dir = tempDir()
|
||||
const projectRegistries = {
|
||||
'@pnpm': 'https://project-pnpm.example.com/',
|
||||
default: 'https://project.example.com/',
|
||||
}
|
||||
|
||||
await syncEnvLockfile({
|
||||
configByUri: {
|
||||
'//project.example.com/': {
|
||||
creds: { authToken: 'project-token' },
|
||||
},
|
||||
},
|
||||
httpProxy: 'http://project-http-proxy.example.com:8080',
|
||||
httpsProxy: 'http://project-https-proxy.example.com:8080',
|
||||
noProxy: 'project.internal',
|
||||
registries: projectRegistries,
|
||||
strictSsl: false,
|
||||
} as unknown as Config, makeContext(dir, {
|
||||
wantedPackageManager: { name: 'pnpm', version: packageManager.version, fromDevEngines: true },
|
||||
}))
|
||||
|
||||
expect(createStoreController).toHaveBeenCalledWith(expect.objectContaining({
|
||||
configByUri: {},
|
||||
httpProxy: undefined,
|
||||
httpsProxy: undefined,
|
||||
noProxy: undefined,
|
||||
registries: { default: 'https://registry.npmjs.org/' },
|
||||
strictSsl: undefined,
|
||||
}))
|
||||
expect(resolvePackageManagerIntegrities).toHaveBeenCalledWith(packageManager.version, expect.objectContaining({
|
||||
registries: { default: 'https://registry.npmjs.org/' },
|
||||
}))
|
||||
expect(resolvePackageManagerIntegrities).not.toHaveBeenCalledWith(packageManager.version, expect.objectContaining({
|
||||
registries: projectRegistries,
|
||||
}))
|
||||
})
|
||||
|
||||
function writeStaleEnvLockfile (dir: string, pnpmVersion: string): void {
|
||||
// readEnvLockfile expects a multi-document YAML file beginning with `---\n`,
|
||||
// where the env lockfile is the first document.
|
||||
|
||||
@@ -5,6 +5,8 @@ import { readEnvLockfile } from '@pnpm/lockfile.fs'
|
||||
import { createStoreController } from '@pnpm/store.connection-manager'
|
||||
import semver from 'semver'
|
||||
|
||||
import { getPackageManagerBootstrapConfig } from './packageManagerRegistries.js'
|
||||
|
||||
/**
|
||||
* Records the currently running pnpm version in the env lockfile's
|
||||
* `packageManagerDependencies` entry when the project opts in to
|
||||
@@ -31,11 +33,12 @@ export async function syncEnvLockfile (config: Config, context: ConfigContext):
|
||||
const lockedVersion = envLockfile?.importers['.'].packageManagerDependencies?.['pnpm']?.version
|
||||
if (lockedVersion != null && semver.satisfies(lockedVersion, pm.version, { includePrerelease: true })) return
|
||||
|
||||
const store = await createStoreController({ ...config, ...context })
|
||||
const packageManagerConfig = getPackageManagerBootstrapConfig(config)
|
||||
const store = await createStoreController({ ...config, ...context, ...packageManagerConfig })
|
||||
try {
|
||||
await resolvePackageManagerIntegrities(packageManager.version, {
|
||||
envLockfile: envLockfile ?? undefined,
|
||||
registries: config.registries,
|
||||
registries: packageManagerConfig.registries,
|
||||
rootDir: context.rootProjectManifestDir,
|
||||
storeController: store.ctrl,
|
||||
storeDir: store.dir,
|
||||
|
||||
@@ -49,7 +49,7 @@ afterEach(() => {
|
||||
jest.mocked(console.warn).mockRestore()
|
||||
})
|
||||
|
||||
test('console a warning when the .npmrc has an env variable that does not exist', async () => {
|
||||
test('console a warning when the .npmrc has an env variable in a project-level registry', async () => {
|
||||
prepare()
|
||||
|
||||
fs.writeFileSync('.npmrc', 'registry=${ENV_VAR_123}', 'utf8')
|
||||
@@ -61,7 +61,7 @@ test('console a warning when the .npmrc has an env variable that does not exist'
|
||||
excludeReporter: false,
|
||||
})
|
||||
|
||||
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('Failed to replace env in config: ${ENV_VAR_123}'))
|
||||
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('Ignored project-level request destination "registry"'))
|
||||
})
|
||||
|
||||
describe('calcPnpmfilePathsOfPluginDeps', () => {
|
||||
|
||||
Reference in New Issue
Block a user