mirror of
https://github.com/pnpm/pnpm.git
synced 2026-06-01 20:49:45 -04:00
* fix(config/reader): drop user-level default auth when workspace overrides registry
When a workspace `.npmrc` overrides `registry=` to a different value than the
user's `~/.npmrc` or `~/.config/pnpm/auth.ini` would have set, do not bind
unscoped/default credentials (`_authToken`, `_auth`, `username`/`_password`)
from the user-level config to the workspace-selected registry. The previous
behavior leaked user-trusted credentials to whatever registry an untrusted
workspace `.npmrc` pointed at. Reported by JUNYI LIU.
* chore(cspell): allow JUNYI in changeset and tests
* fix(config/reader): also defend when pnpm-workspace.yaml overrides registry
Move the rebind defense to after all config layers (CLI, env vars,
pnpm-workspace.yaml, .npmrc) have settled. Compare the final resolved
default registry against what the user-level config alone would produce,
and skip the check entirely if the user requested a registry via CLI/env
themselves.
* feat(config/reader): deprecate unscoped authentication credentials
Emit a per-file warning whenever an .npmrc or auth.ini contains an
unscoped auth value (_authToken, _auth, username, _password,
tokenHelper). URL-scoped tokens have been npm's recommended pattern
since npm@9, and unscoped credentials are slated for removal in a
future major. The warning fires independently of whether the rebind
defense rejects the credentials, so users see the deprecation even when
their setup happens to be safe today.
* refactor(config/reader): rescope unscoped credentials at load time instead of detecting rebinds post-merge
Each .npmrc / auth.ini / CLI source's unscoped credential keys
(_authToken, _auth, username, _password, tokenHelper) are rewritten to
their URL-scoped equivalent during load, using the same source's
registry= value (or the npmjs default if it declares none). A later
layer overriding registry= can no longer rebind a credential to its own
registry — the credential is already pinned to the URL its author
intended.
This removes the post-merge source-tracking defense and replaces it
with the simpler per-source normalization. Each rescope emits a
deprecation warning so users migrate to writing the URL-scoped form
directly.
* refactor(network/auth-header): drop empty-string default-registry slot
After load-time rescoping, no source can populate configByUri[''] —
every credential is either URL-scoped from the start or rewritten to
the URL-scoped form during the .npmrc / auth.ini / CLI parse. The
runtime fallback that re-keyed configByUri[''] onto the merged default
registry, and the publish-side fallback that read it, are both dead
code.
Removed:
- empty-string handling in getAuthHeadersFromCreds, including its
defaultRegistry parameter
- defaultRegistry parameter from createGetAuthHeaderByURI
- the corresponding dedicated unit test
- the configByUri['']?.creds fallback in publishPackedPkg.ts
- empty-key assertions in config/reader tests
Updated all ~16 call sites of createGetAuthHeaderByURI to drop the now
unused second argument.
* feat(config/reader): extend per-source rescoping to client TLS cert/key
The same trust-boundary issue that affected unscoped credentials applies
to client TLS settings: an unscoped cert=/key= would be presented to
whatever registry the merged config settles on, even if a later layer
(workspace .npmrc, pnpm-workspace.yaml, CLI flag) overrode it. The
existing rescope helper now also rewrites unscoped `cert` and `key`
to their URL-scoped form, pinning them to the registry their author
named in the same source.
`ca`/`cafile` are intentionally left unscoped: they're trust anchors,
not credentials, and corporate MITM-proxy setups depend on them
applying to every HTTPS request. The default-registry override can't
weaponize an unscoped CA — the attacker would need a cert signed by it.
`certfile`/`keyfile` (file-path variants) are not rescoped either:
`certfile` isn't read unscoped by pnpm today (asymmetric vs. `keyfile`
in NPM_AUTH_SETTINGS), and supporting only one of them would be
confusing. Users wanting the path form can write it URL-scoped
directly.
* chore(config/reader): remove dead unscoped `keyfile` allowlist entry
`keyfile` was listed in NPM_AUTH_SETTINGS so unscoped `keyfile=<path>`
passed the .npmrc filter and ended up in authConfig — but nothing in
the codebase ever read it from there. The dispatcher uses `opts.key`
(inline PEM) and `configByUri[host].tls.key` (URL-scoped path/inline
content), neither of which is populated from unscoped `keyfile=`.
`certfile` was already absent from the allowlist for the same reason,
so this also removes the asymmetry between the two file-path variants.
URL-scoped `//host/:certfile=...` and `//host/:keyfile=...` continue
to work via `tryParseSslKey` and are unaffected.
* test(network/auth-header): drop test for removed default-registry slot
This test exercised the configByUri[''] re-keying path that was
removed in the rescope-at-load refactor. With createGetAuthHeaderByURI
no longer accepting a defaultRegistry parameter and unscoped
credentials no longer reaching the merged config, the scenario the
test described is structurally unreachable.
* fix(config/reader): handle empty/invalid registry value in rescope
Two CI fixes:
1. When a source's `registry=` resolves to an empty string (e.g. an
unresolved `${ENV_VAR}` placeholder), `new URL(...)` inside
`nerfDart` throws. Guard the call with try/catch: drop the
unscoped per-registry keys (a bare token has nowhere safe to bind)
and emit a warning naming the offending source.
2. Update `.npmrc does not load pnpm settings` to expect the rescoped
form of unscoped `_authToken`/`username` in `authConfig` — they
now appear as `//registry.npmjs.org/:_authToken` etc. since the
test's .npmrc declares no `registry=` of its own.
* chore(cspell): allow "rescoping"
* test(installing/deps-installer): drop "legacy way" auth test
This test passed credentials via the configByUri[''] empty-string slot,
which the auth-header layer re-keyed to the merged default registry at
request time. That slot was removed in the rescope-at-load refactor —
credentials are now always URL-scoped before they reach configByUri,
so the empty-key entry is unreachable from any code path.
The scenario the test covered (basicAuth via username/password) is
already exercised by the existing "installing a package that need
authentication, using password" test using the URL-scoped form.
2769 lines
81 KiB
TypeScript
2769 lines
81 KiB
TypeScript
/// <reference path="../../../__typings__/index.d.ts"/>
|
|
import fs from 'node:fs'
|
|
import os from 'node:os'
|
|
import path from 'node:path'
|
|
|
|
import { afterEach, beforeEach, describe, expect, jest, test } from '@jest/globals'
|
|
import { GLOBAL_LAYOUT_VERSION } from '@pnpm/constants'
|
|
import { prepare, prepareEmpty } from '@pnpm/prepare'
|
|
import { fixtures } from '@pnpm/test-fixtures'
|
|
import PATH from 'path-name'
|
|
import { symlinkDir } from 'symlink-dir'
|
|
import { writeYamlFileSync } from 'write-yaml-file'
|
|
|
|
jest.unstable_mockModule('@pnpm/network.git-utils', () => ({ getCurrentBranch: jest.fn() }))
|
|
|
|
const { getConfig } = await import('@pnpm/config.reader')
|
|
const { getCurrentBranch } = await import('@pnpm/network.git-utils')
|
|
|
|
// To override any local settings,
|
|
// we force the default values of config
|
|
process.env['npm_config_hoist'] = 'true'
|
|
process.env['pnpm_config_hoist'] = 'true'
|
|
for (const suffix of [
|
|
'depth',
|
|
'registry',
|
|
'virtual_store_dir',
|
|
'shared_workspace_lockfile',
|
|
'node_version',
|
|
'fetch_retries',
|
|
]) {
|
|
delete process.env[`npm_config_${suffix}`]
|
|
delete process.env[`pnpm_config_${suffix}`]
|
|
}
|
|
|
|
const env = {
|
|
PNPM_HOME: import.meta.dirname,
|
|
[PATH]: path.join(import.meta.dirname, 'bin'),
|
|
}
|
|
const f = fixtures(import.meta.dirname)
|
|
|
|
test('getConfig()', async () => {
|
|
const { config } = await getConfig({
|
|
cliOptions: {},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
expect(config).toBeDefined()
|
|
expect(config.fetchRetries).toBe(2)
|
|
expect(config.fetchRetryFactor).toBe(10)
|
|
expect(config.fetchRetryMintimeout).toBe(10000)
|
|
expect(config.fetchRetryMaxtimeout).toBe(60000)
|
|
// nodeVersion should not have a default value.
|
|
// When not specified, the package-is-installable package detects nodeVersion automatically.
|
|
expect(config.nodeVersion).toBeUndefined()
|
|
})
|
|
|
|
test.each([
|
|
{ field: 'devEngines' as const, version: '22.20.0', onFail: 'download' as const, expected: '22.20.0' },
|
|
{ field: 'devEngines' as const, version: '22.20.0', onFail: 'error' as const, expected: '22.20.0' },
|
|
{ field: 'devEngines' as const, version: '^22.0.0', onFail: 'download' as const, expected: '22.0.0' },
|
|
{ field: 'engines' as const, version: '22.20.0', onFail: 'download' as const, expected: '22.20.0' },
|
|
])('when $field is $version and onFail is $onFail, nodeVersion is set to $expected', async ({ field, version, onFail, expected }) => {
|
|
prepare({
|
|
[field]: {
|
|
runtime: {
|
|
name: 'node',
|
|
version,
|
|
onFail,
|
|
},
|
|
},
|
|
})
|
|
|
|
const { config } = await getConfig({
|
|
cliOptions: {},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
|
|
expect(config.nodeVersion).toBe(expected)
|
|
})
|
|
|
|
test('nodeVersion from config takes priority over devEngines.runtime', async () => {
|
|
prepare({
|
|
devEngines: {
|
|
runtime: {
|
|
name: 'node',
|
|
version: '22.20.0',
|
|
onFail: 'download',
|
|
},
|
|
},
|
|
})
|
|
|
|
const { config } = await getConfig({
|
|
cliOptions: {
|
|
'node-version': '20.0.0',
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
|
|
expect(config.nodeVersion).toBe('20.0.0')
|
|
})
|
|
|
|
test('runtimeOnFail=download overrides devEngines.runtime.onFail and adds node to devDependencies', async () => {
|
|
prepare({
|
|
devEngines: {
|
|
runtime: {
|
|
name: 'node',
|
|
version: '22.20.0',
|
|
},
|
|
},
|
|
})
|
|
|
|
const { config, context } = await getConfig({
|
|
cliOptions: {
|
|
'runtime-on-fail': 'download',
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
|
|
expect(config.runtimeOnFail).toBe('download')
|
|
const runtime = context.rootProjectManifest?.devEngines?.runtime
|
|
expect(Array.isArray(runtime) ? runtime[0] : runtime).toMatchObject({
|
|
name: 'node',
|
|
onFail: 'download',
|
|
})
|
|
expect(context.rootProjectManifest?.devDependencies?.node).toBe('runtime:22.20.0')
|
|
})
|
|
|
|
test('runtimeOnFail=ignore overrides an existing onFail=download and removes node from devDependencies', async () => {
|
|
prepare({
|
|
devEngines: {
|
|
runtime: {
|
|
name: 'node',
|
|
version: '22.20.0',
|
|
onFail: 'download',
|
|
},
|
|
},
|
|
})
|
|
|
|
const { config, context } = await getConfig({
|
|
cliOptions: {
|
|
'runtime-on-fail': 'ignore',
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
|
|
expect(config.runtimeOnFail).toBe('ignore')
|
|
const runtime = context.rootProjectManifest?.devEngines?.runtime
|
|
expect(Array.isArray(runtime) ? runtime[0] : runtime).toMatchObject({
|
|
name: 'node',
|
|
onFail: 'ignore',
|
|
})
|
|
expect(context.rootProjectManifest?.devDependencies?.node).toBeUndefined()
|
|
})
|
|
|
|
test('devEngines.packageManager without onFail resolves to the documented pmOnFail default "download" (#11676)', async () => {
|
|
prepare({
|
|
devEngines: {
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '11.0.0',
|
|
},
|
|
},
|
|
})
|
|
|
|
const { context } = await getConfig({
|
|
cliOptions: {},
|
|
packageManager: { name: 'pnpm', version: '11.0.0' },
|
|
})
|
|
|
|
expect(context.wantedPackageManager).toMatchObject({
|
|
name: 'pnpm',
|
|
version: '11.0.0',
|
|
onFail: 'download',
|
|
})
|
|
})
|
|
|
|
test('devEngines.packageManager with explicit onFail is respected (regression guard for #11676)', async () => {
|
|
prepare({
|
|
devEngines: {
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '11.0.0',
|
|
onFail: 'error',
|
|
},
|
|
},
|
|
})
|
|
|
|
const { context } = await getConfig({
|
|
cliOptions: {},
|
|
packageManager: { name: 'pnpm', version: '11.0.0' },
|
|
})
|
|
|
|
expect(context.wantedPackageManager?.onFail).toBe('error')
|
|
})
|
|
|
|
test('throw error if --link-workspace-packages is used with --global', async () => {
|
|
await expect(getConfig({
|
|
cliOptions: {
|
|
global: true,
|
|
'link-workspace-packages': true,
|
|
},
|
|
env,
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})).rejects.toMatchObject({
|
|
code: 'ERR_PNPM_CONFIG_CONFLICT_LINK_WORKSPACE_PACKAGES_WITH_GLOBAL',
|
|
message: 'Configuration conflict. "link-workspace-packages" may not be used with "global"',
|
|
})
|
|
})
|
|
|
|
test('correct settings on global install', async () => {
|
|
const { config } = await getConfig({
|
|
cliOptions: {
|
|
global: true,
|
|
save: false,
|
|
},
|
|
env,
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
expect(config.save).toBe(true)
|
|
})
|
|
|
|
test('throw error if --shared-workspace-lockfile is used with --global', async () => {
|
|
await expect(getConfig({
|
|
cliOptions: {
|
|
global: true,
|
|
'shared-workspace-lockfile': true,
|
|
},
|
|
env,
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})).rejects.toMatchObject({
|
|
code: 'ERR_PNPM_CONFIG_CONFLICT_SHARED_WORKSPACE_LOCKFILE_WITH_GLOBAL',
|
|
message: 'Configuration conflict. "shared-workspace-lockfile" may not be used with "global"',
|
|
})
|
|
})
|
|
|
|
test('throw error if --lockfile-dir is used with --global', async () => {
|
|
await expect(getConfig({
|
|
cliOptions: {
|
|
global: true,
|
|
'lockfile-dir': '/home/src',
|
|
},
|
|
env,
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})).rejects.toMatchObject({
|
|
code: 'ERR_PNPM_CONFIG_CONFLICT_LOCKFILE_DIR_WITH_GLOBAL',
|
|
message: 'Configuration conflict. "lockfile-dir" may not be used with "global"',
|
|
})
|
|
})
|
|
|
|
test('throw error if --hoist-pattern is used with --global', async () => {
|
|
await expect(getConfig({
|
|
cliOptions: {
|
|
global: true,
|
|
'hoist-pattern': 'eslint',
|
|
},
|
|
env,
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})).rejects.toMatchObject({
|
|
code: 'ERR_PNPM_CONFIG_CONFLICT_HOIST_PATTERN_WITH_GLOBAL',
|
|
message: 'Configuration conflict. "hoist-pattern" may not be used with "global"',
|
|
})
|
|
})
|
|
|
|
test('throw error if --virtual-store-dir is used with --global', async () => {
|
|
await expect(getConfig({
|
|
cliOptions: {
|
|
global: true,
|
|
'virtual-store-dir': 'pkgs',
|
|
},
|
|
env,
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})).rejects.toMatchObject({
|
|
code: 'ERR_PNPM_CONFIG_CONFLICT_VIRTUAL_STORE_DIR_WITH_GLOBAL',
|
|
message: 'Configuration conflict. "virtual-store-dir" may not be used with "global"',
|
|
})
|
|
})
|
|
|
|
test('.npmrc does not load pnpm settings', async () => {
|
|
prepareEmpty()
|
|
|
|
const npmrc = [
|
|
// npm options
|
|
'//my-org.registry.example.com:username=some-employee',
|
|
'//my-org.registry.example.com:_authToken=some-employee-token',
|
|
'@my-org:registry=https://my-org.registry.example.com',
|
|
'@jsr:registry=https://not-actually-jsr.example.com',
|
|
'username=example-user-name',
|
|
'_authToken=example-auth-token',
|
|
|
|
// pnpm options
|
|
'dlx-cache-max-age=1234',
|
|
'trust-policy-exclude[]=foo',
|
|
'trust-policy-exclude[]=bar',
|
|
'packages[]=baz',
|
|
'packages[]=qux',
|
|
].join('\n')
|
|
fs.writeFileSync('.npmrc', npmrc)
|
|
|
|
const { config } = await getConfig({
|
|
cliOptions: {
|
|
global: false,
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
|
|
// rc options appear as usual. Unscoped credentials (`username`,
|
|
// `_authToken`) are rescoped to the file's registry at load — the .npmrc
|
|
// here doesn't set its own `registry=`, so they pin to the npmjs default.
|
|
expect(config.authConfig).toMatchObject({
|
|
'//my-org.registry.example.com:username': 'some-employee',
|
|
'//my-org.registry.example.com:_authToken': 'some-employee-token',
|
|
'@my-org:registry': 'https://my-org.registry.example.com',
|
|
'@jsr:registry': 'https://not-actually-jsr.example.com',
|
|
'//registry.npmjs.org/:username': 'example-user-name',
|
|
'//registry.npmjs.org/:_authToken': 'example-auth-token',
|
|
})
|
|
expect(config.authConfig.username).toBeUndefined()
|
|
expect(config.authConfig._authToken).toBeUndefined()
|
|
|
|
// workspace-specific settings are omitted
|
|
expect(config.authConfig['dlx-cache-max-age']).toBeUndefined()
|
|
expect(config.authConfig['dlxCacheMaxAge']).toBeUndefined()
|
|
expect(config.dlxCacheMaxAge).toBe(24 * 60) // TODO: refactor to make defaultOptions importable
|
|
expect(config.authConfig['trust-policy-exclude']).toBeUndefined()
|
|
expect(config.authConfig['trustPolicyExclude']).toBeUndefined()
|
|
expect(config.trustPolicyExclude).toBeUndefined()
|
|
expect(config.authConfig.packages).toBeUndefined()
|
|
})
|
|
|
|
describe('minimumReleaseAgeStrict default', () => {
|
|
test('defaults to true when minimumReleaseAge is set in pnpm-workspace.yaml', async () => {
|
|
prepareEmpty()
|
|
|
|
writeYamlFileSync('pnpm-workspace.yaml', {
|
|
minimumReleaseAge: 60,
|
|
})
|
|
|
|
const { config } = await getConfig({
|
|
cliOptions: {},
|
|
packageManager: { name: 'pnpm', version: '1.0.0' },
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
|
|
expect(config.minimumReleaseAge).toBe(60)
|
|
expect(config.minimumReleaseAgeStrict).toBe(true)
|
|
})
|
|
|
|
test('defaults to true when minimumReleaseAge is set on the CLI', async () => {
|
|
prepareEmpty()
|
|
|
|
const { config } = await getConfig({
|
|
cliOptions: {
|
|
'minimum-release-age': 60,
|
|
},
|
|
packageManager: { name: 'pnpm', version: '1.0.0' },
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
|
|
expect(config.minimumReleaseAge).toBe(60)
|
|
expect(config.minimumReleaseAgeStrict).toBe(true)
|
|
})
|
|
|
|
test('defaults to true when minimumReleaseAge is set via pnpm_config_* env var', async () => {
|
|
prepareEmpty()
|
|
|
|
const { config } = await getConfig({
|
|
cliOptions: {},
|
|
env: {
|
|
pnpm_config_minimum_release_age: '60',
|
|
},
|
|
packageManager: { name: 'pnpm', version: '1.0.0' },
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
|
|
expect(config.minimumReleaseAge).toBe(60)
|
|
expect(config.minimumReleaseAgeStrict).toBe(true)
|
|
})
|
|
|
|
test('respects an explicit minimumReleaseAgeStrict=false from pnpm-workspace.yaml', async () => {
|
|
prepareEmpty()
|
|
|
|
writeYamlFileSync('pnpm-workspace.yaml', {
|
|
minimumReleaseAge: 60,
|
|
minimumReleaseAgeStrict: false,
|
|
})
|
|
|
|
const { config } = await getConfig({
|
|
cliOptions: {},
|
|
packageManager: { name: 'pnpm', version: '1.0.0' },
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
|
|
expect(config.minimumReleaseAge).toBe(60)
|
|
expect(config.minimumReleaseAgeStrict).toBe(false)
|
|
})
|
|
|
|
test('does not become strict when only the built-in default for minimumReleaseAge applies', async () => {
|
|
prepareEmpty()
|
|
|
|
writeYamlFileSync('pnpm-workspace.yaml', {})
|
|
|
|
const { config } = await getConfig({
|
|
cliOptions: {},
|
|
packageManager: { name: 'pnpm', version: '1.0.0' },
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
|
|
expect(config.minimumReleaseAge).toBe(1440)
|
|
expect(config.minimumReleaseAgeStrict).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
test('camelCase settings from pnpm-workspace.yaml are read into typed Config properties', async () => {
|
|
prepareEmpty()
|
|
|
|
writeYamlFileSync('pnpm-workspace.yaml', {
|
|
ignoreScripts: true,
|
|
linkWorkspacePackages: true,
|
|
nodeLinker: 'hoisted',
|
|
sharedWorkspaceLockfile: true,
|
|
})
|
|
|
|
const { config } = await getConfig({
|
|
cliOptions: {
|
|
global: false,
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
|
|
expect(config).toMatchObject({
|
|
ignoreScripts: true,
|
|
linkWorkspacePackages: true,
|
|
nodeLinker: 'hoisted',
|
|
sharedWorkspaceLockfile: true,
|
|
})
|
|
})
|
|
|
|
test('workspace-specific settings are read into typed Config properties', async () => {
|
|
prepareEmpty()
|
|
|
|
writeYamlFileSync('pnpm-workspace.yaml', {
|
|
packages: ['foo', 'bar'],
|
|
packageExtensions: {
|
|
'@babel/parser': {
|
|
peerDependencies: {
|
|
'@babel/types': '*',
|
|
},
|
|
},
|
|
'jest-circus': {
|
|
dependencies: {
|
|
slash: '3',
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
const { config } = await getConfig({
|
|
cliOptions: {
|
|
global: false,
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
|
|
expect(config.workspacePackagePatterns).toStrictEqual(['foo', 'bar'])
|
|
expect(config.packageExtensions).toStrictEqual({
|
|
'@babel/parser': {
|
|
peerDependencies: {
|
|
'@babel/types': '*',
|
|
},
|
|
},
|
|
'jest-circus': {
|
|
dependencies: {
|
|
slash: '3',
|
|
},
|
|
},
|
|
})
|
|
})
|
|
|
|
test('when using --global, linkWorkspacePackages, sharedWorkspaceLockfile and lockfileDir are false even if they are set to true in pnpm-workspace.yaml', async () => {
|
|
prepareEmpty()
|
|
|
|
writeYamlFileSync('pnpm-workspace.yaml', {
|
|
linkWorkspacePackages: true,
|
|
sharedWorkspaceLockfile: true,
|
|
lockfileDir: true,
|
|
})
|
|
|
|
{
|
|
const { config } = await getConfig({
|
|
cliOptions: {
|
|
global: false,
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
expect(config.linkWorkspacePackages).toBeTruthy()
|
|
expect(config.sharedWorkspaceLockfile).toBeTruthy()
|
|
expect(config.lockfileDir).toBeTruthy()
|
|
}
|
|
|
|
{
|
|
const { config } = await getConfig({
|
|
cliOptions: {
|
|
global: true,
|
|
},
|
|
env,
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
expect(config.linkWorkspacePackages).toBeFalsy()
|
|
expect(config.sharedWorkspaceLockfile).toBeFalsy()
|
|
// FIXME: it supposed to return null but is undefined
|
|
expect(config.lockfileDir).toBeUndefined()
|
|
}
|
|
})
|
|
|
|
test('registries of scoped packages are read and normalized', async () => {
|
|
const { config } = await getConfig({
|
|
cliOptions: {
|
|
userconfig: path.join(import.meta.dirname, 'scoped-registries.ini'),
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
|
|
expect(config.registries).toStrictEqual({
|
|
default: 'https://default.com/',
|
|
'@jsr': 'https://npm.jsr.io/',
|
|
'@foo': 'https://foo.com/',
|
|
'@bar': 'https://bar.com/',
|
|
'@qar': 'https://qar.com/qar',
|
|
})
|
|
})
|
|
|
|
test('registries in current directory\'s .npmrc have bigger priority then global config settings', async () => {
|
|
prepare()
|
|
|
|
fs.writeFileSync('.npmrc', 'registry=https://pnpm.io/', 'utf8')
|
|
|
|
const { config } = await getConfig({
|
|
cliOptions: {
|
|
userconfig: path.join(import.meta.dirname, 'scoped-registries.ini'),
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
|
|
expect(config.registries).toStrictEqual({
|
|
default: 'https://pnpm.io/',
|
|
'@jsr': 'https://npm.jsr.io/',
|
|
'@foo': 'https://foo.com/',
|
|
'@bar': 'https://bar.com/',
|
|
'@qar': 'https://qar.com/qar',
|
|
})
|
|
})
|
|
|
|
test('pnpm-workspace.yaml registries override the same scope from .npmrc (#11492)', async () => {
|
|
prepareEmpty()
|
|
|
|
fs.writeFileSync('.npmrc', '@my-org:registry=https://from-npmrc.example.com/', 'utf8')
|
|
writeYamlFileSync('pnpm-workspace.yaml', {
|
|
registries: {
|
|
'@my-org': 'https://from-workspace-yaml.example.com/',
|
|
},
|
|
})
|
|
|
|
const { config } = await getConfig({
|
|
cliOptions: {},
|
|
packageManager: { name: 'pnpm', version: '1.0.0' },
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
|
|
expect(config.registries['@my-org']).toBe('https://from-workspace-yaml.example.com/')
|
|
})
|
|
|
|
test('pnpm-workspace.yaml registries.default is reflected in config.registry (#10099)', async () => {
|
|
prepareEmpty()
|
|
|
|
writeYamlFileSync('pnpm-workspace.yaml', {
|
|
registries: {
|
|
default: 'https://private.example.com/',
|
|
},
|
|
})
|
|
|
|
const { config } = await getConfig({
|
|
cliOptions: {},
|
|
packageManager: { name: 'pnpm', version: '1.0.0' },
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
|
|
expect(config.registry).toBe('https://private.example.com/')
|
|
expect(config.registries.default).toBe('https://private.example.com/')
|
|
})
|
|
|
|
test('CLI --registry overrides pnpm-workspace.yaml registries.default (#10099)', async () => {
|
|
prepareEmpty()
|
|
|
|
writeYamlFileSync('pnpm-workspace.yaml', {
|
|
registries: {
|
|
default: 'https://workspace.example.com/',
|
|
},
|
|
})
|
|
|
|
const { config } = await getConfig({
|
|
cliOptions: { registry: 'https://cli.example.com/' },
|
|
packageManager: { name: 'pnpm', version: '1.0.0' },
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
|
|
expect(config.registry).toBe('https://cli.example.com/')
|
|
})
|
|
|
|
test('auth tokens from pnpm auth file override ~/.npmrc', async () => {
|
|
prepareEmpty()
|
|
|
|
// Set up a user .npmrc with a stale token
|
|
fs.mkdirSync('user-home')
|
|
fs.writeFileSync(path.resolve('user-home', '.npmrc'), '//registry.npmjs.org/:_authToken=stale-token', 'utf8')
|
|
|
|
// Set up a pnpm auth file with a fresh token via XDG_CONFIG_HOME
|
|
const configHome = path.resolve('xdg-config')
|
|
fs.mkdirSync(path.join(configHome, 'pnpm'), { recursive: true })
|
|
fs.writeFileSync(
|
|
path.join(configHome, 'pnpm', 'auth.ini'),
|
|
'//registry.npmjs.org/:_authToken=fresh-token'
|
|
)
|
|
|
|
const originalXdg = process.env.XDG_CONFIG_HOME
|
|
process.env.XDG_CONFIG_HOME = configHome
|
|
try {
|
|
const { config } = await getConfig({
|
|
cliOptions: {
|
|
userconfig: path.resolve('user-home', '.npmrc'),
|
|
},
|
|
env: {
|
|
...env,
|
|
XDG_CONFIG_HOME: configHome,
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
|
|
expect(config.authConfig['//registry.npmjs.org/:_authToken']).toBe('fresh-token')
|
|
} finally {
|
|
if (originalXdg != null) {
|
|
process.env.XDG_CONFIG_HOME = originalXdg
|
|
} else {
|
|
delete process.env.XDG_CONFIG_HOME
|
|
}
|
|
}
|
|
})
|
|
|
|
test('workspace .npmrc overrides pnpm auth file', async () => {
|
|
prepareEmpty()
|
|
|
|
// Set up a workspace .npmrc with a project-specific token
|
|
fs.writeFileSync('.npmrc', '//registry.npmjs.org/:_authToken=workspace-token', 'utf8')
|
|
|
|
// Set up a pnpm auth file with a different token
|
|
const configHome = path.resolve('xdg-config')
|
|
fs.mkdirSync(path.join(configHome, 'pnpm'), { recursive: true })
|
|
fs.writeFileSync(
|
|
path.join(configHome, 'pnpm', 'auth.ini'),
|
|
'//registry.npmjs.org/:_authToken=global-token'
|
|
)
|
|
|
|
const originalXdg = process.env.XDG_CONFIG_HOME
|
|
process.env.XDG_CONFIG_HOME = configHome
|
|
try {
|
|
const { config } = await getConfig({
|
|
cliOptions: {},
|
|
env: {
|
|
...env,
|
|
XDG_CONFIG_HOME: configHome,
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
|
|
expect(config.authConfig['//registry.npmjs.org/:_authToken']).toBe('workspace-token')
|
|
} finally {
|
|
if (originalXdg != null) {
|
|
process.env.XDG_CONFIG_HOME = originalXdg
|
|
} else {
|
|
delete process.env.XDG_CONFIG_HOME
|
|
}
|
|
}
|
|
})
|
|
|
|
describe('unresolved ${VAR} placeholders in .npmrc auth values', () => {
|
|
// Regression suite for https://github.com/pnpm/pnpm/issues/11513: actions/setup-node
|
|
// writes `_authToken=${NODE_AUTH_TOKEN}` to .npmrc, and when the user relies on OIDC
|
|
// trusted publishing without setting NODE_AUTH_TOKEN, the literal placeholder must not
|
|
// surface as a bearer token — otherwise the registry sees `Bearer ${NODE_AUTH_TOKEN}`
|
|
// and rejects the publish.
|
|
let originalXdg: string | undefined
|
|
let configHome: string
|
|
|
|
beforeEach(() => {
|
|
prepareEmpty()
|
|
fs.writeFileSync('.npmrc', '//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}\n', 'utf8')
|
|
fs.mkdirSync('user-home')
|
|
fs.writeFileSync(path.resolve('user-home', '.npmrc'), '', 'utf8')
|
|
// Isolate from the developer's real ~/.config/pnpm/auth.ini, which on a maintainer's
|
|
// machine often contains a working npm token that would otherwise satisfy the assertion.
|
|
configHome = path.resolve('xdg-config')
|
|
fs.mkdirSync(path.join(configHome, 'pnpm'), { recursive: true })
|
|
originalXdg = process.env.XDG_CONFIG_HOME
|
|
process.env.XDG_CONFIG_HOME = configHome
|
|
})
|
|
|
|
afterEach(() => {
|
|
if (originalXdg != null) {
|
|
process.env.XDG_CONFIG_HOME = originalXdg
|
|
} else {
|
|
delete process.env.XDG_CONFIG_HOME
|
|
}
|
|
})
|
|
|
|
test('drops the placeholder when the env var is unset', async () => {
|
|
const { config } = await getConfig({
|
|
cliOptions: {
|
|
userconfig: path.resolve('user-home', '.npmrc'),
|
|
},
|
|
env: { ...env, XDG_CONFIG_HOME: configHome }, // NODE_AUTH_TOKEN intentionally unset
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
|
|
expect(config.authConfig['//registry.npmjs.org/:_authToken']).toBe('')
|
|
})
|
|
|
|
test('substitutes normally when the env var is set', async () => {
|
|
const { config } = await getConfig({
|
|
cliOptions: {
|
|
userconfig: path.resolve('user-home', '.npmrc'),
|
|
},
|
|
env: { ...env, XDG_CONFIG_HOME: configHome, NODE_AUTH_TOKEN: 'real-token' },
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
|
|
expect(config.authConfig['//registry.npmjs.org/:_authToken']).toBe('real-token')
|
|
})
|
|
|
|
test('only drops the unresolved placeholder, preserving resolved ones and defaults', async () => {
|
|
// Same value contains one resolvable placeholder, one unresolved bare placeholder,
|
|
// and one placeholder with a `-default` fallback. The unresolved one becomes ''
|
|
// but the other two must still expand. Guards against the original implementation
|
|
// that stripped every `${...}` on any substitution failure.
|
|
fs.writeFileSync(
|
|
'.npmrc',
|
|
'//registry.test/:_authToken=${SET}-${UNSET}-${DEFAULTED-fallback}\n',
|
|
'utf8'
|
|
)
|
|
|
|
const { config } = await getConfig({
|
|
cliOptions: {
|
|
userconfig: path.resolve('user-home', '.npmrc'),
|
|
},
|
|
env: { ...env, XDG_CONFIG_HOME: configHome, SET: 'AAA' }, // UNSET, DEFAULTED unset
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
|
|
expect(config.authConfig['//registry.test/:_authToken']).toBe('AAA--fallback')
|
|
})
|
|
|
|
test('explicit `undefined` value in env is treated as unset for `${VAR-default}` fallbacks', async () => {
|
|
// Callers that construct the env object directly (notably tests) commonly use
|
|
// `{ KEY: undefined }` to model an unset variable. `${VAR-default}` must then
|
|
// resolve to `default`, matching the `Record<string, string | undefined>` contract.
|
|
fs.writeFileSync(
|
|
'.npmrc',
|
|
'//registry.test/:_authToken=${EXPLICIT_UNDEF-fallback}\n',
|
|
'utf8'
|
|
)
|
|
|
|
const { config } = await getConfig({
|
|
cliOptions: {
|
|
userconfig: path.resolve('user-home', '.npmrc'),
|
|
},
|
|
env: { ...env, XDG_CONFIG_HOME: configHome, EXPLICIT_UNDEF: undefined },
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
|
|
expect(config.authConfig['//registry.test/:_authToken']).toBe('fallback')
|
|
})
|
|
})
|
|
|
|
describe('unscoped credentials are pinned to the registry declared in their source file', () => {
|
|
// Each .npmrc / auth.ini gets its unscoped credential keys rewritten to
|
|
// URL-scoped form using the same source's `registry=` value (or the npmjs
|
|
// default if it has none). A later layer overriding `registry=` therefore
|
|
// cannot rebind the credential to its own registry — the credential is
|
|
// already pinned to the URL its author intended.
|
|
let originalXdg: string | undefined
|
|
let configHome: string
|
|
let userconfig: string
|
|
|
|
beforeEach(() => {
|
|
prepareEmpty()
|
|
fs.mkdirSync('user-home')
|
|
userconfig = path.resolve('user-home', '.npmrc')
|
|
configHome = path.resolve('xdg-config')
|
|
fs.mkdirSync(path.join(configHome, 'pnpm'), { recursive: true })
|
|
originalXdg = process.env.XDG_CONFIG_HOME
|
|
process.env.XDG_CONFIG_HOME = configHome
|
|
})
|
|
|
|
afterEach(() => {
|
|
if (originalXdg != null) {
|
|
process.env.XDG_CONFIG_HOME = originalXdg
|
|
} else {
|
|
delete process.env.XDG_CONFIG_HOME
|
|
}
|
|
})
|
|
|
|
test('pins user-level _authToken to that file\'s registry, never the workspace registry', async () => {
|
|
fs.writeFileSync(userconfig, 'registry=https://trusted.example.com/\n_authToken=user-secret\n', 'utf8')
|
|
fs.writeFileSync('.npmrc', 'registry=https://attacker.example.com/\n', 'utf8')
|
|
|
|
const { config } = await getConfig({
|
|
cliOptions: { userconfig },
|
|
env: { ...env, XDG_CONFIG_HOME: configHome },
|
|
packageManager: { name: 'pnpm', version: '1.0.0' },
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
|
|
expect(config.configByUri).toMatchObject({
|
|
'//trusted.example.com/': { creds: { authToken: 'user-secret' } },
|
|
})
|
|
expect(config.configByUri['//attacker.example.com/']).toBeUndefined()
|
|
})
|
|
|
|
test('pins user-level _auth (basic) the same way', async () => {
|
|
// cspell:disable-next-line
|
|
fs.writeFileSync(userconfig, 'registry=https://trusted.example.com/\n_auth=dXNlcjpwYXNz\n', 'utf8')
|
|
fs.writeFileSync('.npmrc', 'registry=https://attacker.example.com/\n', 'utf8')
|
|
|
|
const { config } = await getConfig({
|
|
cliOptions: { userconfig },
|
|
env: { ...env, XDG_CONFIG_HOME: configHome },
|
|
packageManager: { name: 'pnpm', version: '1.0.0' },
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
|
|
expect(config.configByUri).toMatchObject({
|
|
'//trusted.example.com/': { creds: { basicAuth: { username: 'user', password: 'pass' } } },
|
|
})
|
|
expect(config.configByUri['//attacker.example.com/']).toBeUndefined()
|
|
})
|
|
|
|
test('pins user-level username/_password the same way', async () => {
|
|
// cspell:disable-next-line
|
|
fs.writeFileSync(userconfig, 'registry=https://trusted.example.com/\nusername=alice\n_password=cGFzcw==\n', 'utf8')
|
|
fs.writeFileSync('.npmrc', 'registry=https://attacker.example.com/\n', 'utf8')
|
|
|
|
const { config } = await getConfig({
|
|
cliOptions: { userconfig },
|
|
env: { ...env, XDG_CONFIG_HOME: configHome },
|
|
packageManager: { name: 'pnpm', version: '1.0.0' },
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
|
|
expect(config.configByUri).toMatchObject({
|
|
'//trusted.example.com/': { creds: { basicAuth: { username: 'alice', password: 'pass' } } },
|
|
})
|
|
expect(config.configByUri['//attacker.example.com/']).toBeUndefined()
|
|
})
|
|
|
|
test('auth.ini with no registry of its own falls back to the npmjs default', async () => {
|
|
// The split-file case: ~/.npmrc declares a registry but no creds; auth.ini
|
|
// declares an unscoped credential with no registry. Each file rescopes in
|
|
// isolation, so the credential pins to the builtin npmjs default — NOT to
|
|
// whatever the workspace later overrides the merged registry to.
|
|
fs.writeFileSync(userconfig, 'registry=https://trusted.example.com/\n', 'utf8')
|
|
fs.writeFileSync(
|
|
path.join(configHome, 'pnpm', 'auth.ini'),
|
|
'_authToken=user-secret\n',
|
|
'utf8'
|
|
)
|
|
fs.writeFileSync('.npmrc', 'registry=https://attacker.example.com/\n', 'utf8')
|
|
|
|
const { config } = await getConfig({
|
|
cliOptions: { userconfig },
|
|
env: { ...env, XDG_CONFIG_HOME: configHome },
|
|
packageManager: { name: 'pnpm', version: '1.0.0' },
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
|
|
expect(config.configByUri).toMatchObject({
|
|
'//registry.npmjs.org/': { creds: { authToken: 'user-secret' } },
|
|
})
|
|
expect(config.configByUri['//attacker.example.com/']).toBeUndefined()
|
|
expect(config.configByUri['//trusted.example.com/']).toBeUndefined()
|
|
})
|
|
|
|
test('user-level credentials work when no workspace .npmrc exists', async () => {
|
|
fs.writeFileSync(userconfig, 'registry=https://trusted.example.com/\n_authToken=user-secret\n', 'utf8')
|
|
|
|
const { config } = await getConfig({
|
|
cliOptions: { userconfig },
|
|
env: { ...env, XDG_CONFIG_HOME: configHome },
|
|
packageManager: { name: 'pnpm', version: '1.0.0' },
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
|
|
expect(config.configByUri).toMatchObject({
|
|
'//trusted.example.com/': { creds: { authToken: 'user-secret' } },
|
|
})
|
|
})
|
|
|
|
test('workspace-supplied unscoped credentials pin to the workspace registry', async () => {
|
|
fs.writeFileSync(userconfig, '', 'utf8')
|
|
fs.writeFileSync('.npmrc', 'registry=https://workspace.example.com/\n_authToken=workspace-token\n', 'utf8')
|
|
|
|
const { config } = await getConfig({
|
|
cliOptions: { userconfig },
|
|
env: { ...env, XDG_CONFIG_HOME: configHome },
|
|
packageManager: { name: 'pnpm', version: '1.0.0' },
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
|
|
expect(config.configByUri).toMatchObject({
|
|
'//workspace.example.com/': { creds: { authToken: 'workspace-token' } },
|
|
})
|
|
})
|
|
|
|
test('explicit URL-scoped credentials pass through unchanged', async () => {
|
|
fs.writeFileSync(
|
|
userconfig,
|
|
'registry=https://trusted.example.com/\n//trusted.example.com/:_authToken=user-secret\n',
|
|
'utf8'
|
|
)
|
|
fs.writeFileSync('.npmrc', 'registry=https://attacker.example.com/\n', 'utf8')
|
|
|
|
const { config, warnings } = await getConfig({
|
|
cliOptions: { userconfig },
|
|
env: { ...env, XDG_CONFIG_HOME: configHome },
|
|
packageManager: { name: 'pnpm', version: '1.0.0' },
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
|
|
expect(config.configByUri).toMatchObject({
|
|
'//trusted.example.com/': { creds: { authToken: 'user-secret' } },
|
|
})
|
|
// URL-scoped tokens should NOT trigger the deprecation warning.
|
|
expect(warnings.join('\n')).not.toMatch(/deprecated/i)
|
|
})
|
|
|
|
test('CLI --registry override does not pull an unscoped user-level token along', async () => {
|
|
// Same trust boundary as the workspace case: an unscoped token is ambient
|
|
// and shouldn't follow whatever registry the CLI happens to point at.
|
|
fs.writeFileSync(userconfig, '_authToken=user-secret\n', 'utf8')
|
|
|
|
const { config } = await getConfig({
|
|
cliOptions: { userconfig, registry: 'https://attacker.example.com/' },
|
|
env: { ...env, XDG_CONFIG_HOME: configHome },
|
|
packageManager: { name: 'pnpm', version: '1.0.0' },
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
|
|
// The token rescoped to the npmjs default when the user file was read.
|
|
expect(config.configByUri).toMatchObject({
|
|
'//registry.npmjs.org/': { creds: { authToken: 'user-secret' } },
|
|
})
|
|
expect(config.configByUri['//attacker.example.com/']).toBeUndefined()
|
|
})
|
|
|
|
test('pins inline client cert/key to the file\'s registry, never the workspace registry', async () => {
|
|
const inlineCert = '-----BEGIN CERTIFICATE-----\\ncertbody\\n-----END CERTIFICATE-----'
|
|
const inlineKey = '-----BEGIN PRIVATE KEY-----\\nkeybody\\n-----END PRIVATE KEY-----'
|
|
fs.writeFileSync(
|
|
userconfig,
|
|
`registry=https://trusted.example.com/\ncert=${inlineCert}\nkey=${inlineKey}\n`,
|
|
'utf8'
|
|
)
|
|
fs.writeFileSync('.npmrc', 'registry=https://attacker.example.com/\n', 'utf8')
|
|
|
|
const { config } = await getConfig({
|
|
cliOptions: { userconfig },
|
|
env: { ...env, XDG_CONFIG_HOME: configHome },
|
|
packageManager: { name: 'pnpm', version: '1.0.0' },
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
|
|
// `\n` escapes are expanded to real newlines by getNetworkConfigs.
|
|
expect(config.configByUri['//trusted.example.com/']?.tls).toMatchObject({
|
|
cert: inlineCert.replace(/\\n/g, '\n'),
|
|
key: inlineKey.replace(/\\n/g, '\n'),
|
|
})
|
|
expect(config.configByUri['//attacker.example.com/']).toBeUndefined()
|
|
})
|
|
|
|
})
|
|
|
|
describe('unscoped credential deprecation warning', () => {
|
|
// pnpm warns whenever it reads any unscoped auth value from an .npmrc /
|
|
// auth.ini, regardless of whether the rebind defense fires. URL-scoped tokens
|
|
// have been npm's recommended pattern since npm@9, and unscoped credentials
|
|
// are slated for removal in a future major release.
|
|
let originalXdg: string | undefined
|
|
let configHome: string
|
|
let userconfig: string
|
|
|
|
beforeEach(() => {
|
|
prepareEmpty()
|
|
fs.mkdirSync('user-home')
|
|
userconfig = path.resolve('user-home', '.npmrc')
|
|
configHome = path.resolve('xdg-config')
|
|
fs.mkdirSync(path.join(configHome, 'pnpm'), { recursive: true })
|
|
originalXdg = process.env.XDG_CONFIG_HOME
|
|
process.env.XDG_CONFIG_HOME = configHome
|
|
})
|
|
|
|
afterEach(() => {
|
|
if (originalXdg != null) {
|
|
process.env.XDG_CONFIG_HOME = originalXdg
|
|
} else {
|
|
delete process.env.XDG_CONFIG_HOME
|
|
}
|
|
})
|
|
|
|
test('warns about unscoped _authToken in user .npmrc', async () => {
|
|
fs.writeFileSync(userconfig, 'registry=https://example.com/\n_authToken=secret\n', 'utf8')
|
|
|
|
const { warnings } = await getConfig({
|
|
cliOptions: { userconfig },
|
|
env: { ...env, XDG_CONFIG_HOME: configHome },
|
|
packageManager: { name: 'pnpm', version: '1.0.0' },
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
|
|
expect(warnings.find(w => w.includes('Unscoped per-registry settings'))).toBeDefined()
|
|
expect(warnings.find(w => w.includes('_authToken'))).toBeDefined()
|
|
expect(warnings.find(w => w.includes(userconfig))).toBeDefined()
|
|
})
|
|
|
|
test('warns about unscoped _auth, username, _password', async () => {
|
|
// _auth and _password are base64-encoded per npm convention.
|
|
// cspell:disable-next-line
|
|
fs.writeFileSync(userconfig, '_auth=dXNlcjpwYXNz\nusername=alice\n_password=cGFzcw==\n', 'utf8')
|
|
|
|
const { warnings } = await getConfig({
|
|
cliOptions: { userconfig },
|
|
env: { ...env, XDG_CONFIG_HOME: configHome },
|
|
packageManager: { name: 'pnpm', version: '1.0.0' },
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
|
|
const warning = warnings.find(w => w.includes('Unscoped per-registry settings'))
|
|
expect(warning).toBeDefined()
|
|
expect(warning).toContain('_auth')
|
|
expect(warning).toContain('username')
|
|
expect(warning).toContain('_password')
|
|
})
|
|
|
|
test('warns about unscoped credentials in workspace .npmrc too', async () => {
|
|
fs.writeFileSync('.npmrc', 'registry=https://workspace.example.com/\n_authToken=workspace-token\n', 'utf8')
|
|
|
|
const { warnings } = await getConfig({
|
|
cliOptions: {},
|
|
env: { ...env, XDG_CONFIG_HOME: configHome },
|
|
packageManager: { name: 'pnpm', version: '1.0.0' },
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
|
|
const warning = warnings.find(w => w.includes('Unscoped per-registry settings'))
|
|
expect(warning).toBeDefined()
|
|
expect(warning).toContain(path.resolve('.npmrc'))
|
|
})
|
|
|
|
test('does not warn when only URL-scoped credentials are present', async () => {
|
|
fs.writeFileSync(
|
|
userconfig,
|
|
'registry=https://example.com/\n//example.com/:_authToken=secret\n',
|
|
'utf8'
|
|
)
|
|
|
|
const { warnings } = await getConfig({
|
|
cliOptions: { userconfig },
|
|
env: { ...env, XDG_CONFIG_HOME: configHome },
|
|
packageManager: { name: 'pnpm', version: '1.0.0' },
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
|
|
expect(warnings.find(w => w.includes('Unscoped per-registry settings'))).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
test('throw error if --save-prod is used with --save-peer', async () => {
|
|
await expect(getConfig({
|
|
cliOptions: {
|
|
'save-peer': true,
|
|
'save-prod': true,
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})).rejects.toMatchObject({
|
|
code: 'ERR_PNPM_CONFIG_CONFLICT_PEER_CANNOT_BE_PROD_DEP',
|
|
message: 'A package cannot be a peer dependency and a prod dependency at the same time',
|
|
})
|
|
})
|
|
|
|
test('throw error if --save-optional is used with --save-peer', async () => {
|
|
await expect(getConfig({
|
|
cliOptions: {
|
|
'save-optional': true,
|
|
'save-peer': true,
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})).rejects.toMatchObject({
|
|
code: 'ERR_PNPM_CONFIG_CONFLICT_PEER_CANNOT_BE_OPTIONAL_DEP',
|
|
message: 'A package cannot be a peer dependency and an optional dependency at the same time',
|
|
})
|
|
})
|
|
|
|
test('extraBinPaths', async () => {
|
|
prepareEmpty()
|
|
|
|
{
|
|
const { config } = await getConfig({
|
|
cliOptions: {},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
// extraBinPaths is empty outside of a workspace
|
|
expect(config.extraBinPaths).toHaveLength(0)
|
|
}
|
|
|
|
{
|
|
const { config } = await getConfig({
|
|
cliOptions: {},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
// extraBinPaths has the node_modules/.bin folder from the root of the workspace
|
|
expect(config.extraBinPaths).toStrictEqual([path.resolve('node_modules/.bin')])
|
|
}
|
|
|
|
{
|
|
const { config } = await getConfig({
|
|
cliOptions: {
|
|
'ignore-scripts': true,
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
// extraBinPaths has the node_modules/.bin folder from the root of the workspace if scripts are ignored
|
|
expect(config.extraBinPaths).toStrictEqual([path.resolve('node_modules/.bin')])
|
|
}
|
|
|
|
{
|
|
const { config } = await getConfig({
|
|
cliOptions: {
|
|
'ignore-scripts': true,
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
// extraBinPaths is empty inside a workspace if scripts are ignored
|
|
expect(config.extraBinPaths).toEqual([])
|
|
}
|
|
})
|
|
|
|
// hoist → hoistPattern processing is done in @pnpm/cli.utils
|
|
test('hoist-pattern is unchanged if --no-hoist used', async () => {
|
|
const { config } = await getConfig({
|
|
cliOptions: {
|
|
hoist: false,
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
|
|
expect(config.hoist).toBe(false)
|
|
expect(config.hoistPattern).toStrictEqual(['*'])
|
|
})
|
|
|
|
test('throw error if --no-hoist is used with --shamefully-hoist', async () => {
|
|
await expect(getConfig({
|
|
cliOptions: {
|
|
hoist: false,
|
|
'shamefully-hoist': true,
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})).rejects.toMatchObject({
|
|
code: 'ERR_PNPM_CONFIG_CONFLICT_HOIST',
|
|
message: '--shamefully-hoist cannot be used with --no-hoist',
|
|
})
|
|
})
|
|
|
|
test('throw error if --no-hoist is used with --hoist-pattern', async () => {
|
|
await expect(getConfig({
|
|
cliOptions: {
|
|
hoist: false,
|
|
'hoist-pattern': 'eslint-*',
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})).rejects.toMatchObject({
|
|
code: 'ERR_PNPM_CONFIG_CONFLICT_HOIST',
|
|
message: '--hoist-pattern cannot be used with --no-hoist',
|
|
})
|
|
})
|
|
|
|
// public-hoist-pattern normalization is done in @pnpm/cli.utils
|
|
test('normalizing the value of public-hoist-pattern', async () => {
|
|
{
|
|
const { config } = await getConfig({
|
|
cliOptions: {
|
|
'public-hoist-pattern': '',
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
|
|
expect(config.publicHoistPattern).toBe('')
|
|
}
|
|
{
|
|
const { config } = await getConfig({
|
|
cliOptions: {
|
|
'public-hoist-pattern': [''],
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
|
|
expect(config.publicHoistPattern).toStrictEqual([''])
|
|
}
|
|
})
|
|
|
|
|
|
test('normalize the value of the color flag', async () => {
|
|
{
|
|
const { config } = await getConfig({
|
|
cliOptions: {
|
|
color: true,
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
|
|
expect(config.color).toBe('always')
|
|
}
|
|
{
|
|
const { config } = await getConfig({
|
|
cliOptions: {
|
|
color: false,
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
|
|
expect(config.color).toBe('never')
|
|
}
|
|
})
|
|
|
|
// NOTE: This test currently fails as pnpm currently lack a way to verify pnpm-workspace.yaml
|
|
test.skip('read only supported settings from config', async () => {
|
|
prepare()
|
|
|
|
writeYamlFileSync('pnpm-workspace.yaml', {
|
|
storeDir: '__store__',
|
|
foo: 'bar',
|
|
})
|
|
|
|
const { config } = await getConfig({
|
|
cliOptions: {},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
|
|
expect(config.storeDir).toBe('__store__')
|
|
// @ts-expect-error
|
|
expect(config['foo']).toBeUndefined() // NOTE: This line current fails as there are yet a way to verify fields in pnpm-workspace.yaml
|
|
expect(config.authConfig['foo']).toBe('bar')
|
|
})
|
|
|
|
test('all CLI options are added to the config', async () => {
|
|
const { config } = await getConfig({
|
|
cliOptions: {
|
|
'foo-bar': 'qar',
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
|
|
// @ts-expect-error
|
|
expect(config['fooBar']).toBe('qar')
|
|
})
|
|
|
|
test('local prefix search stops on pnpm-workspace.yaml', async () => {
|
|
const workspaceDir = path.join(import.meta.dirname, 'has-workspace-yaml')
|
|
process.chdir(workspaceDir)
|
|
const { config } = await getConfig({
|
|
cliOptions: {},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
|
|
expect(config.dir).toEqual(workspaceDir)
|
|
})
|
|
|
|
test('reads workspacePackagePatterns', async () => {
|
|
const workspaceDir = path.join(import.meta.dirname, 'fixtures/pkg-with-valid-workspace-yaml')
|
|
process.chdir(workspaceDir)
|
|
const { config } = await getConfig({
|
|
cliOptions: {},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
workspaceDir,
|
|
})
|
|
|
|
expect(config.workspacePackagePatterns).toEqual(['packages/*'])
|
|
})
|
|
|
|
test('workspacePackagePatterns defaults to ["."] when pnpm-workspace.yaml has no packages field', async () => {
|
|
const workspaceDir = path.join(import.meta.dirname, 'fixtures/workspace-yaml-without-packages')
|
|
process.chdir(workspaceDir)
|
|
const { config } = await getConfig({
|
|
cliOptions: {},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
workspaceDir,
|
|
})
|
|
|
|
expect(config.workspacePackagePatterns).toEqual(['.'])
|
|
})
|
|
|
|
test('setting workspace-concurrency to negative number', async () => {
|
|
const workspaceDir = path.join(import.meta.dirname, 'fixtures/pkg-with-valid-workspace-yaml')
|
|
process.chdir(workspaceDir)
|
|
const { config } = await getConfig({
|
|
cliOptions: {
|
|
'workspace-concurrency': -1,
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
workspaceDir,
|
|
})
|
|
expect(config.workspaceConcurrency >= 1).toBeTruthy()
|
|
})
|
|
|
|
test('respects testPattern', async () => {
|
|
{
|
|
const { config } = await getConfig({
|
|
cliOptions: {},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
|
|
expect(config.testPattern).toBeUndefined()
|
|
}
|
|
{
|
|
const workspaceDir = path.join(import.meta.dirname, 'using-test-pattern')
|
|
process.chdir(workspaceDir)
|
|
const { config } = await getConfig({
|
|
cliOptions: {},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
workspaceDir,
|
|
})
|
|
|
|
expect(config.testPattern).toEqual(['*.spec.js', '*.spec.ts'])
|
|
}
|
|
{
|
|
const workspaceDir = path.join(import.meta.dirname, 'ignore-test-pattern')
|
|
process.chdir(workspaceDir)
|
|
const { config } = await getConfig({
|
|
cliOptions: {},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
workspaceDir,
|
|
})
|
|
|
|
expect(config.testPattern).toBeUndefined()
|
|
}
|
|
})
|
|
|
|
test('respects changedFilesIgnorePattern', async () => {
|
|
{
|
|
const { config } = await getConfig({
|
|
cliOptions: {},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
|
|
expect(config.changedFilesIgnorePattern).toBeUndefined()
|
|
}
|
|
{
|
|
prepareEmpty()
|
|
|
|
writeYamlFileSync('pnpm-workspace.yaml', {
|
|
changedFilesIgnorePattern: ['.github/**', '**/README.md'],
|
|
})
|
|
|
|
const { config } = await getConfig({
|
|
cliOptions: {
|
|
global: false,
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
|
|
expect(config.changedFilesIgnorePattern).toEqual(['.github/**', '**/README.md'])
|
|
}
|
|
})
|
|
|
|
test('dir is resolved to real path', async () => {
|
|
prepareEmpty()
|
|
const realDir = path.resolve('real-path')
|
|
fs.mkdirSync(realDir)
|
|
const symlink = path.resolve('symlink')
|
|
await symlinkDir(realDir, symlink)
|
|
const { config } = await getConfig({
|
|
cliOptions: { dir: symlink },
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
expect(config.dir).toBe(realDir)
|
|
})
|
|
|
|
test('non-auth settings in npmrc do not produce warnings', async () => {
|
|
prepare()
|
|
|
|
const npmrc = [
|
|
'typo-setting=true',
|
|
' ',
|
|
'mistake-setting=false',
|
|
'//foo.bar:_authToken=aaa',
|
|
'@qar:registry=https://registry.example.org/',
|
|
].join('\n')
|
|
fs.writeFileSync('.npmrc', npmrc, 'utf8')
|
|
|
|
// Non-auth settings like typo-setting and mistake-setting are no longer
|
|
// read from .npmrc, so they won't trigger unknown setting warnings.
|
|
const { warnings } = await getConfig({
|
|
cliOptions: {},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
|
|
expect(warnings).toStrictEqual([])
|
|
})
|
|
|
|
test('getConfig() converts noproxy to noProxy', async () => {
|
|
const { config } = await getConfig({
|
|
cliOptions: {
|
|
noproxy: 'www.foo.com',
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
expect(config.noProxy).toBe('www.foo.com')
|
|
})
|
|
|
|
test('getConfig() returns the userconfig', async () => {
|
|
prepareEmpty()
|
|
fs.mkdirSync('user-home')
|
|
fs.writeFileSync(path.resolve('user-home', '.npmrc'), 'registry = https://registry.example.test', 'utf-8')
|
|
const { config } = await getConfig({
|
|
cliOptions: {
|
|
userconfig: path.resolve('user-home', '.npmrc'),
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
expect(config.userConfig).toEqual({ registry: 'https://registry.example.test' })
|
|
})
|
|
|
|
test('getConfig() returns the userconfig even when overridden locally', async () => {
|
|
prepareEmpty()
|
|
fs.mkdirSync('user-home')
|
|
fs.writeFileSync(path.resolve('user-home', '.npmrc'), 'registry = https://registry.example.test', 'utf-8')
|
|
fs.writeFileSync('.npmrc', 'registry = https://project-local.example.test', 'utf-8')
|
|
const { config } = await getConfig({
|
|
cliOptions: {
|
|
userconfig: path.resolve('user-home', '.npmrc'),
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
expect(config.registry).toBe('https://project-local.example.test')
|
|
expect(config.userConfig).toEqual({ registry: 'https://registry.example.test' })
|
|
})
|
|
|
|
test('getConfig() reads userconfig from PNPM_CONFIG_USERCONFIG env var', async () => {
|
|
prepareEmpty()
|
|
fs.mkdirSync('user-home')
|
|
fs.writeFileSync(path.resolve('user-home', '.npmrc'), 'registry = https://registry.example.test', 'utf-8')
|
|
const { config } = await getConfig({
|
|
cliOptions: {},
|
|
env: {
|
|
...env,
|
|
PNPM_CONFIG_USERCONFIG: path.resolve('user-home', '.npmrc'),
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
expect(config.userConfig).toEqual({ registry: 'https://registry.example.test' })
|
|
})
|
|
|
|
test('getConfig() reads userconfig from pnpm_config_userconfig env var', async () => {
|
|
prepareEmpty()
|
|
fs.mkdirSync('user-home')
|
|
fs.writeFileSync(path.resolve('user-home', '.npmrc'), 'registry = https://registry.example.test', 'utf-8')
|
|
const { config } = await getConfig({
|
|
cliOptions: {},
|
|
env: {
|
|
...env,
|
|
pnpm_config_userconfig: path.resolve('user-home', '.npmrc'),
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
expect(config.userConfig).toEqual({ registry: 'https://registry.example.test' })
|
|
})
|
|
|
|
test('getConfig() reads userconfig from PNPM_CONFIG_NPMRC_AUTH_FILE env var', async () => {
|
|
prepareEmpty()
|
|
fs.mkdirSync('user-home')
|
|
fs.writeFileSync(path.resolve('user-home', '.npmrc'), 'registry = https://registry.example.test', 'utf-8')
|
|
const { config } = await getConfig({
|
|
cliOptions: {},
|
|
env: {
|
|
...env,
|
|
PNPM_CONFIG_NPMRC_AUTH_FILE: path.resolve('user-home', '.npmrc'),
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
expect(config.userConfig).toEqual({ registry: 'https://registry.example.test' })
|
|
})
|
|
|
|
test('getConfig() reads userconfig from pnpm_config_npmrc_auth_file env var', async () => {
|
|
prepareEmpty()
|
|
fs.mkdirSync('user-home')
|
|
fs.writeFileSync(path.resolve('user-home', '.npmrc'), 'registry = https://registry.example.test', 'utf-8')
|
|
const { config } = await getConfig({
|
|
cliOptions: {},
|
|
env: {
|
|
...env,
|
|
pnpm_config_npmrc_auth_file: path.resolve('user-home', '.npmrc'),
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
expect(config.userConfig).toEqual({ registry: 'https://registry.example.test' })
|
|
})
|
|
|
|
// Locks in the precedence so future refactors don't accidentally flip it.
|
|
test('getConfig() prefers pnpm_config_userconfig over PNPM_CONFIG_USERCONFIG when both are set', async () => {
|
|
prepareEmpty()
|
|
fs.mkdirSync('user-home')
|
|
fs.writeFileSync(path.resolve('user-home', 'upper.npmrc'), 'registry = https://upper.example.test', 'utf-8')
|
|
fs.writeFileSync(path.resolve('user-home', 'lower.npmrc'), 'registry = https://lower.example.test', 'utf-8')
|
|
const { config } = await getConfig({
|
|
cliOptions: {},
|
|
env: {
|
|
...env,
|
|
PNPM_CONFIG_USERCONFIG: path.resolve('user-home', 'upper.npmrc'),
|
|
pnpm_config_userconfig: path.resolve('user-home', 'lower.npmrc'),
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
expect(config.userConfig).toEqual({ registry: 'https://lower.example.test' })
|
|
})
|
|
|
|
// actions/setup-node writes auth to ${runner.temp}/.npmrc and sets NPM_CONFIG_USERCONFIG;
|
|
// pnpm honors it as a low-priority compatibility fallback for that flow.
|
|
test('getConfig() reads userconfig from NPM_CONFIG_USERCONFIG env var', async () => {
|
|
prepareEmpty()
|
|
fs.mkdirSync('user-home')
|
|
fs.writeFileSync(path.resolve('user-home', '.npmrc'), 'registry = https://registry.example.test', 'utf-8')
|
|
const { config } = await getConfig({
|
|
cliOptions: {},
|
|
env: {
|
|
...env,
|
|
NPM_CONFIG_USERCONFIG: path.resolve('user-home', '.npmrc'),
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
expect(config.userConfig).toEqual({ registry: 'https://registry.example.test' })
|
|
})
|
|
|
|
test('getConfig() reads userconfig from npm_config_userconfig env var', async () => {
|
|
prepareEmpty()
|
|
fs.mkdirSync('user-home')
|
|
fs.writeFileSync(path.resolve('user-home', '.npmrc'), 'registry = https://registry.example.test', 'utf-8')
|
|
const { config } = await getConfig({
|
|
cliOptions: {},
|
|
env: {
|
|
...env,
|
|
npm_config_userconfig: path.resolve('user-home', '.npmrc'),
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
expect(config.userConfig).toEqual({ registry: 'https://registry.example.test' })
|
|
})
|
|
|
|
test('getConfig() prefers PNPM_CONFIG_USERCONFIG over NPM_CONFIG_USERCONFIG when both are set', async () => {
|
|
prepareEmpty()
|
|
fs.mkdirSync('user-home')
|
|
fs.writeFileSync(path.resolve('user-home', 'pnpm.npmrc'), 'registry = https://pnpm.example.test', 'utf-8')
|
|
fs.writeFileSync(path.resolve('user-home', 'npm.npmrc'), 'registry = https://npm.example.test', 'utf-8')
|
|
const { config } = await getConfig({
|
|
cliOptions: {},
|
|
env: {
|
|
...env,
|
|
PNPM_CONFIG_USERCONFIG: path.resolve('user-home', 'pnpm.npmrc'),
|
|
NPM_CONFIG_USERCONFIG: path.resolve('user-home', 'npm.npmrc'),
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
expect(config.userConfig).toEqual({ registry: 'https://pnpm.example.test' })
|
|
})
|
|
|
|
// An empty NPM_CONFIG_USERCONFIG (e.g. `export NPM_CONFIG_USERCONFIG=`) must be
|
|
// treated as unset. Otherwise it short-circuits the fallback chain and resolves
|
|
// to the cwd, returning an empty/invalid auth config instead of ~/.npmrc.
|
|
test('getConfig() ignores an empty NPM_CONFIG_USERCONFIG and falls back to ~/.npmrc', async () => {
|
|
prepareEmpty()
|
|
const homedirSpy = jest.spyOn(os, 'homedir').mockReturnValue(path.resolve('user-home'))
|
|
try {
|
|
fs.mkdirSync('user-home')
|
|
fs.writeFileSync(path.resolve('user-home', '.npmrc'), 'registry = https://home.example.test', 'utf-8')
|
|
const { config } = await getConfig({
|
|
cliOptions: {},
|
|
env: {
|
|
...env,
|
|
NPM_CONFIG_USERCONFIG: '',
|
|
npm_config_userconfig: '',
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
expect(config.userConfig).toEqual({ registry: 'https://home.example.test' })
|
|
} finally {
|
|
homedirSpy.mockRestore()
|
|
}
|
|
})
|
|
|
|
test('getConfig() sets sideEffectsCacheRead and sideEffectsCacheWrite when side-effects-cache is set', async () => {
|
|
const { config } = await getConfig({
|
|
cliOptions: {
|
|
'side-effects-cache': true,
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
expect(config).toBeDefined()
|
|
expect(config.sideEffectsCacheRead).toBeTruthy()
|
|
expect(config.sideEffectsCacheWrite).toBeTruthy()
|
|
})
|
|
|
|
test('getConfig() should read cafile', async () => {
|
|
const { config } = await getConfig({
|
|
cliOptions: {
|
|
cafile: path.join(import.meta.dirname, 'cafile.txt'),
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
expect(config).toBeDefined()
|
|
expect(config.ca).toStrictEqual([`xxx
|
|
-----END CERTIFICATE-----`])
|
|
})
|
|
|
|
// Regression for https://github.com/pnpm/pnpm/issues/11624.
|
|
test('getConfig() resolves a relative cafile= from .npmrc against the npmrc directory, not process.cwd()', async () => {
|
|
prepareEmpty()
|
|
const projectDir = path.resolve('project')
|
|
fs.mkdirSync(path.join(projectDir, 'certs'), { recursive: true })
|
|
fs.writeFileSync(
|
|
path.join(projectDir, 'certs', 'ca.pem'),
|
|
'relative-ca\n-----END CERTIFICATE-----'
|
|
)
|
|
fs.writeFileSync(path.join(projectDir, '.npmrc'), 'cafile=certs/ca.pem\n')
|
|
|
|
// process.cwd() is the prepareEmpty() root, *not* projectDir — i.e. the same
|
|
// shape as `pnpm --dir <projectDir> install` invoked from a sibling cwd.
|
|
const { config } = await getConfig({
|
|
cliOptions: { dir: projectDir },
|
|
packageManager: { name: 'pnpm', version: '1.0.0' },
|
|
})
|
|
|
|
expect(config.ca).toStrictEqual(['relative-ca\n-----END CERTIFICATE-----'])
|
|
})
|
|
|
|
test('getConfig() should read inline SSL certificates from .npmrc', async () => {
|
|
prepareEmpty()
|
|
|
|
// These are written to .npmrc with literal \n strings
|
|
const inlineCa = '-----BEGIN CERTIFICATE-----\\nMIIFNzCCAx+gAwIBAgIQNB613yRzpKtDztlXiHmOGDANBgkqhkiG9w0BAQsFADAR\\n-----END CERTIFICATE-----'
|
|
const inlineCert = '-----BEGIN CERTIFICATE-----\\nMIIClientCert\\n-----END CERTIFICATE-----'
|
|
const inlineKey = '-----BEGIN PRIVATE KEY-----\\nMIIClientKey\\n-----END PRIVATE KEY-----'
|
|
|
|
const npmrc = [
|
|
'//registry.example.com/:ca=' + inlineCa,
|
|
'//registry.example.com/:cert=' + inlineCert,
|
|
'//registry.example.com/:key=' + inlineKey,
|
|
].join('\n')
|
|
fs.writeFileSync('.npmrc', npmrc, 'utf8')
|
|
|
|
const { config } = await getConfig({
|
|
cliOptions: {},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
|
|
// After processing, \n should be converted to actual newlines
|
|
expect(config.configByUri['//registry.example.com/']?.tls).toMatchObject({
|
|
ca: inlineCa.replace(/\\n/g, '\n'),
|
|
cert: inlineCert.replace(/\\n/g, '\n'),
|
|
key: inlineKey.replace(/\\n/g, '\n'),
|
|
})
|
|
})
|
|
|
|
test('respect mergeGitBranchLockfilesBranchPattern', async () => {
|
|
{
|
|
prepareEmpty()
|
|
const { config } = await getConfig({
|
|
cliOptions: {},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
|
|
expect(config.mergeGitBranchLockfilesBranchPattern).toBeUndefined()
|
|
expect(config.mergeGitBranchLockfiles).toBeUndefined()
|
|
}
|
|
{
|
|
prepareEmpty()
|
|
|
|
writeYamlFileSync('pnpm-workspace.yaml', {
|
|
mergeGitBranchLockfilesBranchPattern: ['main', 'release/**'],
|
|
})
|
|
|
|
const { config } = await getConfig({
|
|
cliOptions: {
|
|
global: false,
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
|
|
expect(config.mergeGitBranchLockfilesBranchPattern).toEqual(['main', 'release/**'])
|
|
}
|
|
})
|
|
|
|
test('getConfig() sets mergeGitBranchLockfiles when branch matches mergeGitBranchLockfilesBranchPattern', async () => {
|
|
prepareEmpty()
|
|
{
|
|
writeYamlFileSync('pnpm-workspace.yaml', {
|
|
mergeGitBranchLockfilesBranchPattern: ['main', 'release/**'],
|
|
})
|
|
|
|
jest.mocked(getCurrentBranch).mockReturnValue(Promise.resolve('develop'))
|
|
const { config } = await getConfig({
|
|
cliOptions: {
|
|
global: false,
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
|
|
expect(config.mergeGitBranchLockfilesBranchPattern).toEqual(['main', 'release/**'])
|
|
expect(config.mergeGitBranchLockfiles).toBe(false)
|
|
}
|
|
{
|
|
jest.mocked(getCurrentBranch).mockReturnValue(Promise.resolve('main'))
|
|
const { config } = await getConfig({
|
|
cliOptions: {
|
|
global: false,
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
expect(config.mergeGitBranchLockfiles).toBe(true)
|
|
}
|
|
{
|
|
jest.mocked(getCurrentBranch).mockReturnValue(Promise.resolve('release/1.0.0'))
|
|
const { config } = await getConfig({
|
|
cliOptions: {
|
|
global: false,
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
expect(config.mergeGitBranchLockfiles).toBe(true)
|
|
}
|
|
})
|
|
|
|
test('preferSymlinkedExecutables should be true when nodeLinker is hoisted', async () => {
|
|
prepareEmpty()
|
|
|
|
const { config } = await getConfig({
|
|
cliOptions: {
|
|
'node-linker': 'hoisted',
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
expect(config.preferSymlinkedExecutables).toBeTruthy()
|
|
})
|
|
|
|
test('return a warning when the .npmrc has an env variable that does not exist', async () => {
|
|
fs.writeFileSync('.npmrc', 'registry=${ENV_VAR_123}', 'utf8')
|
|
const { warnings } = await getConfig({
|
|
cliOptions: {},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
|
|
const expected = [
|
|
expect.stringContaining('Failed to replace env in config: ${ENV_VAR_123}') // eslint-disable-line
|
|
]
|
|
|
|
expect(warnings).toEqual(expect.arrayContaining(expected))
|
|
})
|
|
|
|
test('return a warning if a package.json has workspaces field but there is no pnpm-workspaces.yaml file', async () => {
|
|
const prefix = f.find('pkg-using-workspaces')
|
|
const { warnings } = await getConfig({
|
|
cliOptions: { dir: prefix },
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
|
|
expect(warnings).toStrictEqual([
|
|
'The "workspaces" field in package.json is not supported by pnpm. Create a "pnpm-workspace.yaml" file instead.',
|
|
])
|
|
})
|
|
|
|
test('do not return a warning if a package.json has workspaces field and there is a pnpm-workspace.yaml file', async () => {
|
|
const prefix = f.find('pkg-using-workspaces')
|
|
const { warnings } = await getConfig({
|
|
cliOptions: { dir: prefix },
|
|
workspaceDir: prefix,
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
expect(warnings).toStrictEqual([])
|
|
})
|
|
|
|
test('return a warning if a package.json has a legacy "pnpm" field with ignored settings', async () => {
|
|
const prefix = f.find('pkg-with-legacy-pnpm-field')
|
|
const { warnings } = await getConfig({
|
|
cliOptions: { dir: prefix },
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
|
|
expect(warnings).toStrictEqual([
|
|
'The "pnpm" field in package.json is no longer read by pnpm. The following keys were ignored: "pnpm.overrides", "pnpm.patchedDependencies". See https://pnpm.io/settings for the new home of each setting.',
|
|
])
|
|
})
|
|
|
|
test('do not return a warning if a package.json "pnpm" field only contains keys that are still actively read (e.g. "pnpm.app")', async () => {
|
|
const prefix = f.find('pkg-with-pnpm-app-field')
|
|
const { warnings } = await getConfig({
|
|
cliOptions: { dir: prefix },
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
|
|
expect(warnings).toStrictEqual([])
|
|
})
|
|
|
|
test('do not return a warning if a package.json "pnpm" field only contains keys unrelated to migrated settings (e.g. set by third-party tooling)', async () => {
|
|
const prefix = f.find('pkg-with-unknown-pnpm-field')
|
|
const { warnings } = await getConfig({
|
|
cliOptions: { dir: prefix },
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
|
|
expect(warnings).toStrictEqual([])
|
|
})
|
|
|
|
test('read PNPM_HOME defined in environment variables', async () => {
|
|
const oldEnv = process.env
|
|
const homeDir = './specified-dir'
|
|
process.env = {
|
|
...oldEnv,
|
|
PNPM_HOME: homeDir,
|
|
}
|
|
|
|
const { config } = await getConfig({
|
|
cliOptions: {},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
expect(config.pnpmHomeDir).toBe(homeDir)
|
|
|
|
process.env = oldEnv
|
|
})
|
|
|
|
test('xxx', async () => {
|
|
const oldEnv = process.env
|
|
process.env = {
|
|
...oldEnv,
|
|
FOO: 'registry',
|
|
}
|
|
|
|
const { config } = await getConfig({
|
|
cliOptions: {
|
|
dir: f.find('has-env-in-key'),
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
expect(config.registry).toBe('https://registry.example.com/')
|
|
|
|
process.env = oldEnv
|
|
})
|
|
|
|
test('settings from pnpm-workspace.yaml are read', async () => {
|
|
const workspaceDir = f.find('settings-in-workspace-yaml')
|
|
process.chdir(workspaceDir)
|
|
const { config } = await getConfig({
|
|
cliOptions: {},
|
|
workspaceDir,
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
|
|
expect(config.trustPolicyExclude).toStrictEqual(['foo', 'bar'])
|
|
})
|
|
|
|
test('settings sharedWorkspaceLockfile in pnpm-workspace.yaml should take effect', async () => {
|
|
const workspaceDir = f.find('settings-in-workspace-yaml')
|
|
process.chdir(workspaceDir)
|
|
const { config } = await getConfig({
|
|
cliOptions: {},
|
|
workspaceDir,
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
|
|
expect(config.sharedWorkspaceLockfile).toBe(false)
|
|
expect(config.lockfileDir).toBeUndefined()
|
|
})
|
|
|
|
// shamefullyHoist → publicHoistPattern conversion is done in @pnpm/cli.utils
|
|
test('settings shamefullyHoist in pnpm-workspace.yaml should take effect', async () => {
|
|
const workspaceDir = f.find('settings-in-workspace-yaml')
|
|
process.chdir(workspaceDir)
|
|
const { config } = await getConfig({
|
|
cliOptions: {},
|
|
workspaceDir,
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
|
|
expect(config.shamefullyHoist).toBe(true)
|
|
})
|
|
|
|
test('settings gitBranchLockfile in pnpm-workspace.yaml should take effect', async () => {
|
|
const workspaceDir = f.find('settings-in-workspace-yaml')
|
|
process.chdir(workspaceDir)
|
|
const { config } = await getConfig({
|
|
cliOptions: {},
|
|
workspaceDir,
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
|
|
expect(config.gitBranchLockfile).toBe(true)
|
|
expect(config.useGitBranchLockfile).toBe(true)
|
|
})
|
|
|
|
test('loads setting from environment variable pnpm_config_*', async () => {
|
|
prepareEmpty()
|
|
const { config } = await getConfig({
|
|
cliOptions: {},
|
|
env: {
|
|
pnpm_config_fetch_retries: '100',
|
|
pnpm_config_hoist_pattern: '["react", "react-dom"]',
|
|
pnpm_config_use_node_version: '22.0.0',
|
|
pnpm_config_trust_policy_exclude: '["foo", "bar"]',
|
|
pnpm_config_registry: 'https://registry.example.com',
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
expect(config.fetchRetries).toBe(100)
|
|
expect(config.hoistPattern).toStrictEqual(['react', 'react-dom'])
|
|
expect(config.trustPolicyExclude).toStrictEqual(['foo', 'bar'])
|
|
expect(config.registry).toBe('https://registry.example.com/')
|
|
expect(config.registries.default).toBe('https://registry.example.com/')
|
|
})
|
|
|
|
test('environment variable pnpm_config_* should override pnpm-workspace.yaml', async () => {
|
|
prepareEmpty()
|
|
|
|
writeYamlFileSync('pnpm-workspace.yaml', {
|
|
fetchRetries: 5,
|
|
})
|
|
|
|
async function getConfigValue (env: NodeJS.ProcessEnv): Promise<number | undefined> {
|
|
const { config } = await getConfig({
|
|
cliOptions: {},
|
|
env,
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
return config.fetchRetries
|
|
}
|
|
|
|
expect(await getConfigValue({})).toBe(5)
|
|
expect(await getConfigValue({
|
|
pnpm_config_fetch_retries: '10',
|
|
})).toBe(10)
|
|
})
|
|
|
|
test('CLI should override environment variable pnpm_config_*', async () => {
|
|
prepareEmpty()
|
|
|
|
async function getConfigValue (cliOptions: Record<string, unknown>): Promise<number | undefined> {
|
|
const { config } = await getConfig({
|
|
cliOptions,
|
|
env: {
|
|
pnpm_config_fetch_retries: '5',
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
return config.fetchRetries
|
|
}
|
|
|
|
expect(await getConfigValue({})).toBe(5)
|
|
expect(await getConfigValue({
|
|
fetchRetries: 10,
|
|
})).toBe(10)
|
|
expect(await getConfigValue({
|
|
'fetch-retries': 10,
|
|
})).toBe(10)
|
|
})
|
|
|
|
test('warn when directory contains PATH delimiter character', async () => {
|
|
const tempDir = path.join(os.tmpdir(), `pnpm-test${path.delimiter}project-${Date.now()}`)
|
|
fs.mkdirSync(tempDir, { recursive: true })
|
|
|
|
try {
|
|
const { warnings } = await getConfig({
|
|
cliOptions: { dir: tempDir },
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
|
|
expect(warnings).toContainEqual(
|
|
expect.stringContaining('path delimiter character')
|
|
)
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true })
|
|
}
|
|
})
|
|
|
|
test('no warning when directory does not contain PATH delimiter character', async () => {
|
|
const tempDir = path.join(os.tmpdir(), `pnpm-test-normal-${Date.now()}`)
|
|
fs.mkdirSync(tempDir, { recursive: true })
|
|
|
|
try {
|
|
const { warnings } = await getConfig({
|
|
cliOptions: { dir: tempDir },
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
|
|
expect(warnings).not.toContainEqual(
|
|
expect.stringContaining('path delimiter character')
|
|
)
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true })
|
|
}
|
|
})
|
|
|
|
test.each([
|
|
[undefined, undefined],
|
|
[false, undefined],
|
|
[true, true],
|
|
])('sets autoConfirmAllPrompts when CLI is passed --yes=%s', async (cliValue?: boolean, expectedValue?: boolean) => {
|
|
const { config } = await getConfig({
|
|
cliOptions: {
|
|
'yes': cliValue,
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
|
|
expect(config.autoConfirmAllPrompts).toBe(expectedValue)
|
|
})
|
|
|
|
describe('global config.yaml', () => {
|
|
let XDG_CONFIG_HOME: string | undefined
|
|
|
|
beforeEach(() => {
|
|
XDG_CONFIG_HOME = process.env.XDG_CONFIG_HOME
|
|
})
|
|
|
|
afterEach(() => {
|
|
process.env.XDG_CONFIG_HOME = XDG_CONFIG_HOME
|
|
})
|
|
|
|
test('reads config from global config.yaml', async () => {
|
|
prepareEmpty()
|
|
|
|
fs.mkdirSync('.config/pnpm', { recursive: true })
|
|
writeYamlFileSync('.config/pnpm/config.yaml', {
|
|
dangerouslyAllowAllBuilds: true,
|
|
})
|
|
|
|
// TODO: `getConfigDir`, `getHomeDir`, etc. (from dirs.ts) should allow customizing env or process.
|
|
// TODO: after that, remove this `describe` wrapper.
|
|
process.env.XDG_CONFIG_HOME = path.resolve('.config')
|
|
|
|
const { config } = await getConfig({
|
|
cliOptions: {},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
|
|
expect(config.dangerouslyAllowAllBuilds).toBe(true)
|
|
|
|
// NOTE: the field may appear kebab-case here, but only internally,
|
|
expect(config.dangerouslyAllowAllBuilds).toBeDefined()
|
|
})
|
|
|
|
test('reads user-level preference settings from global config.yaml', async () => {
|
|
prepareEmpty()
|
|
|
|
fs.mkdirSync('.config/pnpm', { recursive: true })
|
|
writeYamlFileSync('.config/pnpm/config.yaml', {
|
|
scriptShell: '/usr/local/bin/bash',
|
|
shellEmulator: true,
|
|
updateNotifier: false,
|
|
stateDir: '/custom/state',
|
|
trustPolicy: 'no-downgrade',
|
|
trustPolicyExclude: ['legacy-pkg'],
|
|
registrySupportsTimeField: true,
|
|
sideEffectsCache: false,
|
|
strictDepBuilds: true,
|
|
useStderr: true,
|
|
verifyDepsBeforeRun: 'error',
|
|
verifyStoreIntegrity: false,
|
|
virtualStoreDir: '/custom/.pnpm',
|
|
virtualStoreDirMaxLength: 80,
|
|
})
|
|
|
|
process.env.XDG_CONFIG_HOME = path.resolve('.config')
|
|
|
|
const { config, warnings } = await getConfig({
|
|
cliOptions: {},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
|
|
expect(config.scriptShell).toBe('/usr/local/bin/bash')
|
|
expect(config.shellEmulator).toBe(true)
|
|
expect(config.updateNotifier).toBe(false)
|
|
expect(config.stateDir).toBe('/custom/state')
|
|
expect(config.trustPolicy).toBe('no-downgrade')
|
|
expect(config.trustPolicyExclude).toEqual(['legacy-pkg'])
|
|
expect(config.registrySupportsTimeField).toBe(true)
|
|
expect(config.sideEffectsCache).toBe(false)
|
|
expect(config.strictDepBuilds).toBe(true)
|
|
expect(config.useStderr).toBe(true)
|
|
expect(config.verifyDepsBeforeRun).toBe('error')
|
|
expect(config.verifyStoreIntegrity).toBe(false)
|
|
expect(config.virtualStoreDir).toBe('/custom/.pnpm')
|
|
expect(config.virtualStoreDirMaxLength).toBe(80)
|
|
expect(warnings.find((w) => w.includes('global config file'))).toBeUndefined()
|
|
})
|
|
|
|
test('warns when global config.yaml contains settings that are not allowed in the global config', async () => {
|
|
prepareEmpty()
|
|
|
|
fs.mkdirSync('.config/pnpm', { recursive: true })
|
|
writeYamlFileSync('.config/pnpm/config.yaml', {
|
|
dangerouslyAllowAllBuilds: true,
|
|
nodeLinker: 'hoisted',
|
|
hoistPattern: ['*eslint*'],
|
|
})
|
|
|
|
process.env.XDG_CONFIG_HOME = path.resolve('.config')
|
|
|
|
const { config, warnings } = await getConfig({
|
|
cliOptions: {},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
|
|
// Allowed setting is still applied.
|
|
expect(config.dangerouslyAllowAllBuilds).toBe(true)
|
|
// Ignored settings do not leak into the config.
|
|
expect(config.nodeLinker).not.toBe('hoisted')
|
|
expect(config.hoistPattern).toEqual(['*'])
|
|
|
|
const warning = warnings.find((w) => w.includes('global config file'))
|
|
expect(warning).toBeDefined()
|
|
expect(warning).toContain('"nodeLinker"')
|
|
expect(warning).toContain('"hoistPattern"')
|
|
expect(warning).not.toContain('"dangerouslyAllowAllBuilds"')
|
|
expect(warning).toContain(path.join(process.env.XDG_CONFIG_HOME!, 'pnpm', 'config.yaml'))
|
|
expect(warning).toContain('pnpm-workspace.yaml')
|
|
expect(warning).toContain('https://pnpm.io/11.x/config-dependencies')
|
|
expect(warning).not.toContain('.npmrc')
|
|
})
|
|
|
|
test('reads proxy settings from global config.yaml', async () => {
|
|
prepareEmpty()
|
|
|
|
fs.mkdirSync('.config/pnpm', { recursive: true })
|
|
writeYamlFileSync('.config/pnpm/config.yaml', {
|
|
httpProxy: 'http://proxy.example.com:8080',
|
|
httpsProxy: 'http://proxy.example.com:8443',
|
|
noProxy: 'localhost,127.0.0.1',
|
|
})
|
|
|
|
process.env.XDG_CONFIG_HOME = path.resolve('.config')
|
|
|
|
const { config } = await getConfig({
|
|
cliOptions: {},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
|
|
expect(config.httpProxy).toBe('http://proxy.example.com:8080')
|
|
expect(config.httpsProxy).toBe('http://proxy.example.com:8443')
|
|
expect(config.noProxy).toBe('localhost,127.0.0.1')
|
|
})
|
|
|
|
test('proxy settings from global config.yaml override .npmrc', async () => {
|
|
prepareEmpty()
|
|
|
|
// Set proxy in .npmrc (npm-style keys)
|
|
fs.writeFileSync('.npmrc', 'https-proxy=http://npmrc-proxy.example.com:8080', 'utf8')
|
|
|
|
// Set different proxy in global config.yaml
|
|
fs.mkdirSync('.config/pnpm', { recursive: true })
|
|
writeYamlFileSync('.config/pnpm/config.yaml', {
|
|
httpsProxy: 'http://yaml-proxy.example.com:9090',
|
|
})
|
|
|
|
process.env.XDG_CONFIG_HOME = path.resolve('.config')
|
|
|
|
const { config } = await getConfig({
|
|
cliOptions: {},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
|
|
// Global YAML should override .npmrc
|
|
expect(config.httpsProxy).toBe('http://yaml-proxy.example.com:9090')
|
|
})
|
|
|
|
test('CLI flags override proxy settings from global config.yaml', async () => {
|
|
prepareEmpty()
|
|
|
|
fs.mkdirSync('.config/pnpm', { recursive: true })
|
|
writeYamlFileSync('.config/pnpm/config.yaml', {
|
|
httpsProxy: 'http://yaml-proxy.example.com:9090',
|
|
})
|
|
|
|
process.env.XDG_CONFIG_HOME = path.resolve('.config')
|
|
|
|
const { config } = await getConfig({
|
|
cliOptions: {
|
|
'https-proxy': 'http://cli-proxy.example.com:7070',
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
|
|
expect(config.httpsProxy).toBe('http://cli-proxy.example.com:7070')
|
|
})
|
|
|
|
// npmrcAuthFile in global config.yaml is a deliberate pnpm-native setting and should
|
|
// not be silently overridden by an ambient NPM_CONFIG_USERCONFIG (e.g. from a CI runner).
|
|
test('npmrcAuthFile from global config.yaml takes precedence over NPM_CONFIG_USERCONFIG', async () => {
|
|
prepareEmpty()
|
|
fs.mkdirSync('user-home')
|
|
fs.writeFileSync(path.resolve('user-home', 'yaml.npmrc'), 'registry = https://yaml.example.test', 'utf-8')
|
|
fs.writeFileSync(path.resolve('user-home', 'npm.npmrc'), 'registry = https://npm.example.test', 'utf-8')
|
|
|
|
fs.mkdirSync('.config/pnpm', { recursive: true })
|
|
writeYamlFileSync('.config/pnpm/config.yaml', {
|
|
npmrcAuthFile: path.resolve('user-home', 'yaml.npmrc'),
|
|
})
|
|
|
|
process.env.XDG_CONFIG_HOME = path.resolve('.config')
|
|
|
|
const { config } = await getConfig({
|
|
cliOptions: {},
|
|
env: {
|
|
...env,
|
|
NPM_CONFIG_USERCONFIG: path.resolve('user-home', 'npm.npmrc'),
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
|
|
expect(config.userConfig).toEqual({ registry: 'https://yaml.example.test' })
|
|
})
|
|
})
|
|
|
|
test('proxy settings are still read from .npmrc', async () => {
|
|
prepareEmpty()
|
|
|
|
fs.writeFileSync('.npmrc', 'https-proxy=http://npmrc-proxy.example.com:8080\nproxy=http://npmrc-http-proxy.example.com:3128\nno-proxy=internal.example.com', 'utf8')
|
|
|
|
const { config } = await getConfig({
|
|
cliOptions: {},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
|
|
expect(config.httpsProxy).toBe('http://npmrc-proxy.example.com:8080')
|
|
expect(config.httpProxy).toBe('http://npmrc-proxy.example.com:8080')
|
|
expect(config.noProxy).toBe('internal.example.com')
|
|
})
|
|
|
|
test('lockfile: false in pnpm-workspace.yaml sets useLockfile to false', async () => {
|
|
prepareEmpty()
|
|
|
|
writeYamlFileSync('pnpm-workspace.yaml', {
|
|
lockfile: false,
|
|
})
|
|
|
|
const { config } = await getConfig({
|
|
cliOptions: {},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
|
|
expect(config.useLockfile).toBe(false)
|
|
})
|
|
|
|
test('pnpm_config_lockfile env var overrides lockfile from pnpm-workspace.yaml in useLockfile', async () => {
|
|
prepareEmpty()
|
|
|
|
writeYamlFileSync('pnpm-workspace.yaml', {
|
|
lockfile: true,
|
|
})
|
|
|
|
const { config } = await getConfig({
|
|
cliOptions: {},
|
|
env: {
|
|
pnpm_config_lockfile: 'false',
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
|
|
expect(config.useLockfile).toBe(false)
|
|
})
|
|
|
|
test('ci disables enableGlobalVirtualStore by default', async () => {
|
|
prepareEmpty()
|
|
|
|
writeYamlFileSync('pnpm-workspace.yaml', {
|
|
ci: true,
|
|
})
|
|
|
|
const { config } = await getConfig({
|
|
cliOptions: {},
|
|
env,
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
|
|
expect(config.enableGlobalVirtualStore).toBe(false)
|
|
})
|
|
|
|
test('ci respects explicit enableGlobalVirtualStore from config', async () => {
|
|
prepareEmpty()
|
|
|
|
writeYamlFileSync('pnpm-workspace.yaml', {
|
|
ci: true,
|
|
enableGlobalVirtualStore: true,
|
|
})
|
|
|
|
const { config } = await getConfig({
|
|
cliOptions: {},
|
|
env,
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
|
|
expect(config.enableGlobalVirtualStore).toBe(true)
|
|
})
|
|
|
|
test('pnpm_config_git_branch_lockfile env var overrides git-branch-lockfile from pnpm-workspace.yaml in useGitBranchLockfile', async () => {
|
|
prepareEmpty()
|
|
|
|
writeYamlFileSync('pnpm-workspace.yaml', {
|
|
gitBranchLockfile: false,
|
|
})
|
|
|
|
const { config } = await getConfig({
|
|
cliOptions: {},
|
|
env: {
|
|
pnpm_config_git_branch_lockfile: 'true',
|
|
},
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
workspaceDir: process.cwd(),
|
|
})
|
|
|
|
expect(config.useGitBranchLockfile).toBe(true)
|
|
})
|
|
|
|
test('GVS: workspace manifest allowBuilds takes precedence over global config.yaml dangerouslyAllowAllBuilds', async () => {
|
|
prepareEmpty()
|
|
|
|
const prevXdgConfigHome = process.env.XDG_CONFIG_HOME
|
|
|
|
const globalDir = path.join(import.meta.dirname, 'global', GLOBAL_LAYOUT_VERSION)
|
|
|
|
try {
|
|
process.env.XDG_CONFIG_HOME = path.resolve('.config')
|
|
|
|
fs.mkdirSync(path.join(process.env.XDG_CONFIG_HOME, 'pnpm'), { recursive: true })
|
|
writeYamlFileSync(path.join(process.env.XDG_CONFIG_HOME, 'pnpm', 'config.yaml'), {
|
|
dangerouslyAllowAllBuilds: true,
|
|
})
|
|
|
|
fs.mkdirSync(globalDir, { recursive: true })
|
|
writeYamlFileSync(path.join(globalDir, 'pnpm-workspace.yaml'), {
|
|
allowBuilds: { '@some/pkg': true, esbuild: true },
|
|
})
|
|
|
|
const { config } = await getConfig({
|
|
cliOptions: {
|
|
global: true,
|
|
},
|
|
env,
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
|
|
expect(config.enableGlobalVirtualStore).toBe(true)
|
|
expect(config.allowBuilds).toStrictEqual({ '@some/pkg': true, esbuild: true })
|
|
// The dangerouslyAllowAllBuilds value from the already-loaded global config.yaml
|
|
// is preserved when workspace manifest settings are applied after
|
|
// extractAndRemoveDependencyBuildOptions strips the workspace build options.
|
|
expect(config.dangerouslyAllowAllBuilds).toBe(true)
|
|
} finally {
|
|
if (prevXdgConfigHome === undefined) {
|
|
delete process.env.XDG_CONFIG_HOME
|
|
} else {
|
|
process.env.XDG_CONFIG_HOME = prevXdgConfigHome
|
|
}
|
|
fs.rmSync(globalDir, { recursive: true, force: true })
|
|
const parentGlobalDir = path.join(import.meta.dirname, 'global')
|
|
if (fs.existsSync(parentGlobalDir)) {
|
|
fs.rmSync(parentGlobalDir, { recursive: true, force: true })
|
|
}
|
|
}
|
|
})
|
|
|
|
test('GVS: global config.yaml dangerouslyAllowAllBuilds is preserved when no workspace manifest exists', async () => {
|
|
prepareEmpty()
|
|
|
|
const prevXdgConfigHome = process.env.XDG_CONFIG_HOME
|
|
|
|
try {
|
|
// Set up global config.yaml with a build policy
|
|
fs.mkdirSync('.config/pnpm', { recursive: true })
|
|
writeYamlFileSync('.config/pnpm/config.yaml', {
|
|
dangerouslyAllowAllBuilds: true,
|
|
})
|
|
process.env.XDG_CONFIG_HOME = path.resolve('.config')
|
|
|
|
// No global pnpm-workspace.yaml
|
|
// intentionally do not write a workspace manifest
|
|
|
|
const { config } = await getConfig({
|
|
cliOptions: {
|
|
global: true,
|
|
},
|
|
env,
|
|
packageManager: {
|
|
name: 'pnpm',
|
|
version: '1.0.0',
|
|
},
|
|
})
|
|
|
|
// For global installs, enableGlobalVirtualStore defaults to true.
|
|
expect(config.enableGlobalVirtualStore).toBe(true)
|
|
// The key assertion: global config.yaml policy should NOT be wiped by the GVS
|
|
// allowBuilds = {} default. Previously this block set allowBuilds
|
|
// before globalDepsBuildConfig was re-applied, so hasDependencyBuildOptions
|
|
// saw allowBuilds = {} and skipped re-application, silently losing
|
|
// dangerouslyAllowAllBuilds.
|
|
expect(config.dangerouslyAllowAllBuilds).toBe(true)
|
|
// allowBuilds should remain null — dangerouslyAllowAllBuilds IS the policy
|
|
expect(config.allowBuilds).toBeUndefined()
|
|
} finally {
|
|
if (prevXdgConfigHome === undefined) {
|
|
delete process.env.XDG_CONFIG_HOME
|
|
} else {
|
|
process.env.XDG_CONFIG_HOME = prevXdgConfigHome
|
|
}
|
|
}
|
|
})
|