mirror of
https://github.com/pnpm/pnpm.git
synced 2026-04-27 18:46:18 -04:00
feat(dlx): accept more local configs (#11240)
* feat(config): make dlx inherit security and trust policy settings from local config
Previously, `pnpm dlx` and `pnpm create` only inherited auth/registry
settings from the local project config, ignoring all other settings.
This meant security policy settings like `minimumReleaseAge` and
`trustPolicy` configured in a project's `pnpm-workspace.yaml` were
silently dropped.
Now these commands inherit two categories of local settings:
1. Registry & auth (existing) — needed to reach the same package sources
2. Security & trust policy (new) — settings that gate what is allowed
to be downloaded, reflecting the org's security posture
Project-structural settings (hoisting, linking, workspace layout, etc.)
remain correctly excluded.
Closes #11183
https://claude.ai/code/session_01NumMLsTvswMVJpbWp3YJrH
* refactor(config): rename auth.ts to localConfig.ts and clean up tests
Addresses review feedback:
- Rename auth.ts / auth.test.ts to localConfig.ts / localConfig.test.ts
to reflect the broader scope (auth + security/trust policy + npmrc utils)
- Remove unnecessary `as any` casts from tests; the types already work
- Consolidate individual expect() assertions into toMatchObject
https://claude.ai/code/session_01NumMLsTvswMVJpbWp3YJrH
* fix(config): sort imports and exports after rename
Fixes simple-import-sort/imports and simple-import-sort/exports lint
errors introduced when localConfig.js replaced auth.js; the previous
position was correct for auth.* but not for localConfig.*.
https://claude.ai/code/session_01NumMLsTvswMVJpbWp3YJrH
* refactor(config): remove dead RAW_POLICY_CFG_KEYS handling
Policy keys (minimum-release-age*, trust-policy*) are filtered out of
.npmrc by isNpmrcReadableKey, so they can never appear in authConfig.
The RAW_POLICY_CFG_KEYS / isRawPolicyCfgKey / pickRawDlxConfig branch
for those keys was unreachable in production.
inheritDlxConfig now uses pickRawAuthConfig directly for the raw config
pick. The test assertion that placed minimum-release-age in authConfig
(an impossible state) is also dropped.
https://claude.ai/code/session_01NumMLsTvswMVJpbWp3YJrH
* test(dlx): respect minimumReleaseAge from pnpm-workspace.yaml
Integration test for #11183 — verifies that pnpm dlx, invoked via the
bundled CLI, picks up minimumReleaseAge from the project's
pnpm-workspace.yaml and rejects packages that don't meet the cutoff.
Uses the public npm registry (matching the existing minimumReleaseAge
tests in exec/commands/test/dlx.e2e.ts:391) because verdaccio includes
the 'time' field in abbreviated metadata, which short-circuits the
publish-date check.
https://claude.ai/code/session_01NumMLsTvswMVJpbWp3YJrH
* fix(test): allow pnpm-workspace.yaml to override minimumReleaseAge in tests
The execPnpmSync test helper hardcoded
pnpm_config_minimum_release_age: '0'
which forced the value via env var (highest priority) for every test,
overriding any minimumReleaseAge set via pnpm-workspace.yaml.
This was inconsistent with the other settings in the helper (registry,
hoist, storeDir, fetchRetries) which use a `fallback()` reading from
the workspace manifest if present and falling back to a default
otherwise. Apply the same pattern for minimumReleaseAge.
Restores the integration test added in 6bc965b — without this fix the
test passes through dlx without applying the workspace's
minimumReleaseAge, making it not fail as the test expected.
https://claude.ai/code/session_01NumMLsTvswMVJpbWp3YJrH
* refactor(config,test): address review feedback
localConfig.ts doc comment:
- Drop redundant "(camelCase, from Config type)" parenthetical
- Replace em-dash-sandwiched paragraph with two flat sentences
- Switch list-item em dashes to colons (label: definition form)
pnpm/test/dlx.ts:
- Switch em dash in registry-override comment to colon
- Group the minimumReleaseAge tests into a describe block
- Add positive test: dlx succeeds when the pinned version is older
than the computed minimumReleaseAge cutoff
- Add range-resolution test: dlx resolves `shx@0.3.x` to 0.3.2 when
the cutoff is positioned between 0.3.2 (2018-07-11) and 0.3.3
(2020-10-26). The ~2.3 year gap leaves ample room for CI variance;
0.3.2's publish date is hardcoded (npm policy forbids unpublishing
past 72h).
https://claude.ai/code/session_01NumMLsTvswMVJpbWp3YJrH
* fix(test,config): address Copilot review feedback
- execPnpm.ts: only set pnpm_config_minimum_release_age env var when
the workspace manifest does not specify minimumReleaseAge, so tests
that verify dlx's local-config inheritance exercise the real config
path instead of being masked by the env var
- dlx.ts: fix "~19 years" comment to "~27.4 years" (10,000 days)
- dlx.ts: add pnpm create test verifying minimumReleaseAge from
pnpm-workspace.yaml (create delegates to dlx internally)
- changeset: bump @pnpm/config.reader to major (the rename of
ignoreNonAuthSettingsFromLocal → onlyInheritDlxSettingsFromLocal
is a breaking change to the published getConfig API)
https://claude.ai/code/session_01NumMLsTvswMVJpbWp3YJrH
* refactor(test): add noDefaultMinimumReleaseAge option to execPnpmSync
Replace the implicit workspace-yaml auto-detection with an explicit
opt-in flag. Tests that verify dlx/create inherits minimumReleaseAge
from pnpm-workspace.yaml pass `noDefaultMinimumReleaseAge: true` so
the env var default doesn't mask the real inheritance path.
https://claude.ai/code/session_01NumMLsTvswMVJpbWp3YJrH
* refactor(test): use omitEnvDefaults instead of noDefaultMinimumReleaseAge
Replace the single-purpose boolean flag with a general-purpose
`omitEnvDefaults: string[]` option on ExecPnpmSyncOpts. Tests pass the
env var name(s) to skip, e.g.
`omitEnvDefaults: ['pnpm_config_minimum_release_age']`.
https://claude.ai/code/session_01NumMLsTvswMVJpbWp3YJrH
* refactor(test): type omitEnvDefaults as PnpmEnvDefault[] literal union
Provides autocomplete and prevents typos by constraining the array
to known pnpm_config_* env var names set by the test helper.
https://claude.ai/code/session_01NumMLsTvswMVJpbWp3YJrH
* refactor(test): make omitEnvDefaults honor all listed env var names
Previously the code only checked for 'pnpm_config_minimum_release_age',
but the PnpmEnvDefault type listed 7 names, making the option silently
ineffective for the other 6. Now all defaults are set unconditionally
and any listed in omitEnvDefaults are deleted after, so every member
of PnpmEnvDefault actually works.
https://claude.ai/code/session_01NumMLsTvswMVJpbWp3YJrH
* docs(config): remove 'proxies' from inherited-settings examples
dlx does not actually inherit proxy settings (httpProxy / httpsProxy
etc. are neither in AUTH_CFG_KEYS nor RAW_AUTH_CFG_KEYS). The doc
comment in localConfig.ts listed 'proxies' as an example, which
mismatched the code. Drop the mention.
Behavior is unchanged; this is a docs-only fix.
https://claude.ai/code/session_01NumMLsTvswMVJpbWp3YJrH
* fix(dlx): fetch full metadata when minimumReleaseAge is set
Including minimumReleaseAge in the fullMetadata condition (alongside
the existing resolution-mode=time-based and trustPolicy=no-downgrade
triggers) bypasses the abbreviated→full metadata upgrade path in
pickPackage.ts for this case. That upgrade path is fragile on Windows:
the integration test at pnpm/test/dlx.ts:112 was failing with
ERR_PNPM_MISSING_TIME only on windows-latest runners, even though
the registry response is identical across platforms.
When minimumReleaseAge is set, pnpm always needs per-version
timestamps to decide which versions are mature enough. The original
condition only handled the two other time-dependent features
(resolution-mode=time-based and trust-policy=no-downgrade), missing
minimumReleaseAge. Adding it here eliminates an unnecessary round
trip plus the flaky upgrade, and matches the intent of the existing
siblings in the condition.
https://claude.ai/code/session_01NumMLsTvswMVJpbWp3YJrH
* style(test): avoid 'verdaccio: verdaccio' repetition in test comment
https://claude.ai/code/session_01NumMLsTvswMVJpbWp3YJrH
* refactor(config): rename POLICY_CFG_KEYS to SECURITY_POLICY_CFG_KEYS
'POLICY_CFG_KEYS' was too vague — reading it cold didn't convey what
kind of policy. Renamed to match the doc comment's 'security policy'
wording. Also renamed 'isPolicyCfgKey' → 'isSecurityPolicyCfgKey'.
https://claude.ai/code/session_01NumMLsTvswMVJpbWp3YJrH
* test(config): drop impossible 'cache-dir' key from inheritAuthConfig test
Addressing @zkochan's review: 'cache-dir' can never appear in
authConfig in production (pickIniConfig filters it out at .npmrc
load), so the assertion was testing an impossible state. Removed
from both the target's authConfig and the expected assertion.
https://claude.ai/code/session_01NumMLsTvswMVJpbWp3YJrH
---------
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
6
.changeset/dlx-inherit-policy-config.md
Normal file
6
.changeset/dlx-inherit-policy-config.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@pnpm/config.reader": major
|
||||
"pnpm": minor
|
||||
---
|
||||
|
||||
`pnpm dlx` and `pnpm create` now respect security and trust policy settings (`minimumReleaseAge`, `minimumReleaseAgeExclude`, `minimumReleaseAgeStrict`, `trustPolicy`, `trustPolicyExclude`, `trustPolicyIgnoreAfter`) from project-level configuration [#11183](https://github.com/pnpm/pnpm/issues/11183).
|
||||
@@ -1,40 +0,0 @@
|
||||
import { inheritAuthConfig } from './auth.js'
|
||||
import type { InheritableConfigPair } from './inheritPickedConfig.js'
|
||||
|
||||
test('inheritAuthConfig copies only auth keys from source to target', () => {
|
||||
const target: InheritableConfigPair = {
|
||||
config: {
|
||||
bin: 'foo',
|
||||
cacheDir: '/path/to/cache/dir',
|
||||
registry: 'https://npmjs.com/registry/',
|
||||
authConfig: {
|
||||
'cache-dir': '/path/to/cache/dir',
|
||||
registry: 'https://npmjs.com/registry/',
|
||||
},
|
||||
} as any, // eslint-disable-line
|
||||
}
|
||||
|
||||
inheritAuthConfig(target, {
|
||||
config: {
|
||||
bin: 'bar',
|
||||
cacheDir: '/path/to/another/cache/dir',
|
||||
storeDir: '/path/to/custom/store/dir',
|
||||
registry: 'https://example.com/local-registry/',
|
||||
authConfig: {
|
||||
registry: 'https://example.com/global-registry/',
|
||||
'//example.com/global-registry/:_auth': 'MY_SECRET_GLOBAL_AUTH',
|
||||
},
|
||||
} as any, // eslint-disable-line
|
||||
})
|
||||
|
||||
expect(target.config).toMatchObject({
|
||||
bin: 'foo',
|
||||
cacheDir: '/path/to/cache/dir',
|
||||
registry: 'https://example.com/local-registry/',
|
||||
authConfig: {
|
||||
'cache-dir': '/path/to/cache/dir',
|
||||
registry: 'https://example.com/global-registry/',
|
||||
'//example.com/global-registry/:_auth': 'MY_SECRET_GLOBAL_AUTH',
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -22,7 +22,6 @@ import { omit } from 'ramda'
|
||||
import { realpathMissing } from 'realpath-missing'
|
||||
import semver from 'semver'
|
||||
|
||||
import { inheritAuthConfig, pickIniConfig } from './auth.js'
|
||||
import { checkGlobalBinDir } from './checkGlobalBinDir.js'
|
||||
import { getDefaultWorkspaceConcurrency, getWorkspaceConcurrency } from './concurrency.js'
|
||||
import type {
|
||||
@@ -40,6 +39,7 @@ import { parseEnvVars } from './env.js'
|
||||
import { getDefaultCreds, getNetworkConfigs } from './getNetworkConfigs.js'
|
||||
import { getOptionsFromPnpmSettings } from './getOptionsFromRootManifest.js'
|
||||
import { loadNpmrcConfig } from './loadNpmrcFiles.js'
|
||||
import { inheritDlxConfig, pickIniConfig } from './localConfig.js'
|
||||
import { npmDefaults } from './npmDefaults.js'
|
||||
import {
|
||||
type CliOptions as SupportedArchitecturesCliOptions,
|
||||
@@ -66,8 +66,8 @@ export {
|
||||
} from './projectConfig.js'
|
||||
export type { Config, ConfigContext, ProjectConfig, UniversalOptions, VerifyDepsBeforeRun }
|
||||
|
||||
export { isIniConfigKey, isNpmrcReadableKey } from './auth.js'
|
||||
export { type ConfigFileKey, isConfigFileKey } from './configFileKey.js'
|
||||
export { isIniConfigKey, isNpmrcReadableKey } from './localConfig.js'
|
||||
|
||||
type CamelToKebabCase<S extends string> = S extends `${infer T}${infer U}`
|
||||
? `${T extends Capitalize<T> ? '-' : ''}${Lowercase<T>}${CamelToKebabCase<U>}`
|
||||
@@ -88,22 +88,22 @@ export async function getConfig (opts: {
|
||||
}
|
||||
workspaceDir?: string | undefined
|
||||
env?: Record<string, string | undefined>
|
||||
ignoreNonAuthSettingsFromLocal?: boolean
|
||||
onlyInheritDlxSettingsFromLocal?: boolean
|
||||
ignoreLocalSettings?: boolean
|
||||
}): Promise<{ config: Config, context: ConfigContext, warnings: string[] }> {
|
||||
if (opts.ignoreNonAuthSettingsFromLocal) {
|
||||
const { ignoreNonAuthSettingsFromLocal: _, ...authOpts } = opts
|
||||
const globalCfgOpts: typeof authOpts = {
|
||||
...authOpts,
|
||||
if (opts.onlyInheritDlxSettingsFromLocal) {
|
||||
const { onlyInheritDlxSettingsFromLocal: _, ...localOpts } = opts
|
||||
const globalCfgOpts: typeof localOpts = {
|
||||
...localOpts,
|
||||
ignoreLocalSettings: true,
|
||||
cliOptions: {
|
||||
...authOpts.cliOptions,
|
||||
...localOpts.cliOptions,
|
||||
dir: os.homedir(),
|
||||
},
|
||||
}
|
||||
const [final, authSrc] = await Promise.all([getConfig(globalCfgOpts), getConfig(authOpts)])
|
||||
inheritAuthConfig(final, authSrc)
|
||||
final.warnings.push(...authSrc.warnings)
|
||||
const [final, localSrc] = await Promise.all([getConfig(globalCfgOpts), getConfig(localOpts)])
|
||||
inheritDlxConfig(final, localSrc)
|
||||
final.warnings.push(...localSrc.warnings)
|
||||
return final
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import path from 'node:path'
|
||||
import { envReplace } from '@pnpm/config.env-replace'
|
||||
import { readIniFileSync } from 'read-ini-file'
|
||||
|
||||
import { isNpmrcReadableKey } from './auth.js'
|
||||
import { isNpmrcReadableKey } from './localConfig.js'
|
||||
|
||||
export interface NpmrcConfigResult {
|
||||
/**
|
||||
|
||||
93
config/reader/src/localConfig.test.ts
Normal file
93
config/reader/src/localConfig.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { InheritableConfigPair } from './inheritPickedConfig.js'
|
||||
import { inheritAuthConfig, inheritDlxConfig } from './localConfig.js'
|
||||
|
||||
test('inheritAuthConfig copies only auth keys from source to target', () => {
|
||||
const target: InheritableConfigPair = {
|
||||
config: {
|
||||
bin: 'foo',
|
||||
cacheDir: '/path/to/cache/dir',
|
||||
registry: 'https://npmjs.com/registry/',
|
||||
authConfig: {
|
||||
registry: 'https://npmjs.com/registry/',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
inheritAuthConfig(target, {
|
||||
config: {
|
||||
bin: 'bar',
|
||||
cacheDir: '/path/to/another/cache/dir',
|
||||
storeDir: '/path/to/custom/store/dir',
|
||||
registry: 'https://example.com/local-registry/',
|
||||
authConfig: {
|
||||
registry: 'https://example.com/global-registry/',
|
||||
'//example.com/global-registry/:_auth': 'MY_SECRET_GLOBAL_AUTH',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(target.config).toMatchObject({
|
||||
bin: 'foo',
|
||||
cacheDir: '/path/to/cache/dir',
|
||||
registry: 'https://example.com/local-registry/',
|
||||
authConfig: {
|
||||
registry: 'https://example.com/global-registry/',
|
||||
'//example.com/global-registry/:_auth': 'MY_SECRET_GLOBAL_AUTH',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('inheritDlxConfig copies auth and security policy keys from source to target', () => {
|
||||
const target: InheritableConfigPair = {
|
||||
config: {
|
||||
bin: 'foo',
|
||||
cacheDir: '/path/to/cache/dir',
|
||||
registry: 'https://npmjs.com/registry/',
|
||||
shamefullyHoist: true,
|
||||
authConfig: {
|
||||
registry: 'https://npmjs.com/registry/',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
inheritDlxConfig(target, {
|
||||
config: {
|
||||
bin: 'bar',
|
||||
cacheDir: '/path/to/another/cache/dir',
|
||||
storeDir: '/path/to/custom/store/dir',
|
||||
registry: 'https://example.com/local-registry/',
|
||||
shamefullyHoist: false,
|
||||
minimumReleaseAge: 1440,
|
||||
minimumReleaseAgeExclude: ['trusted-pkg'],
|
||||
minimumReleaseAgeStrict: true,
|
||||
trustPolicy: 'no-downgrade',
|
||||
trustPolicyExclude: ['legacy-pkg'],
|
||||
trustPolicyIgnoreAfter: 525600,
|
||||
authConfig: {
|
||||
registry: 'https://example.com/local-registry/',
|
||||
'//example.com/local-registry/:_authToken': 'SECRET_TOKEN',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Auth keys and security/trust policy keys are inherited;
|
||||
// project-structural keys (bin, cacheDir, shamefullyHoist) keep their target values.
|
||||
expect(target.config).toMatchObject({
|
||||
bin: 'foo',
|
||||
cacheDir: '/path/to/cache/dir',
|
||||
shamefullyHoist: true,
|
||||
registry: 'https://example.com/local-registry/',
|
||||
minimumReleaseAge: 1440,
|
||||
minimumReleaseAgeExclude: ['trusted-pkg'],
|
||||
minimumReleaseAgeStrict: true,
|
||||
trustPolicy: 'no-downgrade',
|
||||
trustPolicyExclude: ['legacy-pkg'],
|
||||
trustPolicyIgnoreAfter: 525600,
|
||||
authConfig: {
|
||||
registry: 'https://example.com/local-registry/',
|
||||
'//example.com/local-registry/:_authToken': 'SECRET_TOKEN',
|
||||
},
|
||||
})
|
||||
// storeDir exists only on the source, must not be inherited.
|
||||
expect(target.config.storeDir).toBeUndefined()
|
||||
})
|
||||
@@ -45,6 +45,44 @@ const AUTH_CFG_KEYS = [
|
||||
'registries',
|
||||
] satisfies Array<keyof Config>
|
||||
|
||||
/**
|
||||
* Security policy config keys.
|
||||
*
|
||||
* ## Principle
|
||||
*
|
||||
* `pnpm dlx` runs packages in isolation from the current project. It must not
|
||||
* read project-structural settings (hoisting, linking, workspace layout, etc.)
|
||||
* from local config. However, two categories of local settings DO apply:
|
||||
*
|
||||
* 1. **Registry & auth:** needed to reach the same package sources
|
||||
* (registries, tokens, certificates).
|
||||
* 2. **Security & trust policy:** these reflect the user's or organization's
|
||||
* security posture and must apply regardless of how a package is installed.
|
||||
* A setting that answers "what am I allowed to download?" belongs here.
|
||||
*
|
||||
* Other settings are intentionally excluded. These are the ones that control
|
||||
* how downloaded packages are arranged in `node_modules` (hoisting, linking,
|
||||
* workspace layout, etc.).
|
||||
*
|
||||
* ## Rules
|
||||
*
|
||||
* | Category | Inherited by dlx? | Examples |
|
||||
* |--------------------------------|--------------------|--------------------------------------------------|
|
||||
* | Registry & auth | Yes | registry, _authToken, ca |
|
||||
* | Security & trust policy | Yes | minimumReleaseAge, trustPolicy |
|
||||
* | Installation structure | No | shamefully-hoist, node-linker, hoist-pattern |
|
||||
* | Workspace settings | No | link-workspace-packages, shared-workspace-lockfile|
|
||||
* | Resolution strategy | No | resolution-mode, dedupe-peers |
|
||||
*/
|
||||
const SECURITY_POLICY_CFG_KEYS = [
|
||||
'minimumReleaseAge',
|
||||
'minimumReleaseAgeExclude',
|
||||
'minimumReleaseAgeStrict',
|
||||
'trustPolicy',
|
||||
'trustPolicyExclude',
|
||||
'trustPolicyIgnoreAfter',
|
||||
] satisfies Array<keyof Config>
|
||||
|
||||
const NPM_AUTH_SETTINGS = [
|
||||
...RAW_AUTH_CFG_KEYS,
|
||||
'_auth',
|
||||
@@ -65,6 +103,10 @@ function isAuthCfgKey (cfgKey: keyof Config): cfgKey is typeof AUTH_CFG_KEYS[num
|
||||
return (AUTH_CFG_KEYS as Array<keyof Config>).includes(cfgKey)
|
||||
}
|
||||
|
||||
function isSecurityPolicyCfgKey (cfgKey: keyof Config): cfgKey is typeof SECURITY_POLICY_CFG_KEYS[number] {
|
||||
return (SECURITY_POLICY_CFG_KEYS as Array<keyof Config>).includes(cfgKey)
|
||||
}
|
||||
|
||||
function pickRawAuthConfig<RawLocalCfg extends Record<string, unknown>> (rawLocalCfg: RawLocalCfg): Partial<RawLocalCfg> {
|
||||
const result: Partial<RawLocalCfg> = {}
|
||||
for (const key in rawLocalCfg) {
|
||||
@@ -85,10 +127,32 @@ function pickAuthConfig (localCfg: Partial<Config>): Partial<Config> {
|
||||
return result as Partial<Config>
|
||||
}
|
||||
|
||||
function pickDlxConfig (localCfg: Partial<Config>): Partial<Config> {
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const key in localCfg) {
|
||||
if (isAuthCfgKey(key as keyof Config) || isSecurityPolicyCfgKey(key as keyof Config)) {
|
||||
result[key] = localCfg[key as keyof Config]
|
||||
}
|
||||
}
|
||||
return result as Partial<Config>
|
||||
}
|
||||
|
||||
export function inheritAuthConfig (target: InheritableConfigPair, src: InheritableConfigPair): void {
|
||||
inheritPickedConfig(target, src, pickAuthConfig, pickRawAuthConfig)
|
||||
}
|
||||
|
||||
/**
|
||||
* Inherits both auth/registry settings and security/trust policy settings
|
||||
* from a local config source into the target config.
|
||||
*
|
||||
* Used by `pnpm dlx` and `pnpm create` so that these commands respect
|
||||
* the local project's registry authentication and security policies
|
||||
* while ignoring project-structural settings.
|
||||
*/
|
||||
export function inheritDlxConfig (target: InheritableConfigPair, src: InheritableConfigPair): void {
|
||||
inheritPickedConfig(target, src, pickDlxConfig, pickRawAuthConfig)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the config key would be read from an INI config file.
|
||||
*/
|
||||
@@ -97,7 +97,8 @@ export async function handler (
|
||||
const fullMetadata = (
|
||||
(
|
||||
opts.resolutionMode === 'time-based' ||
|
||||
opts.trustPolicy === 'no-downgrade'
|
||||
opts.trustPolicy === 'no-downgrade' ||
|
||||
Boolean(opts.minimumReleaseAge)
|
||||
) && !opts.registrySupportsTimeField
|
||||
)
|
||||
const catalogResolver = resolveFromCatalog.bind(null, opts.catalogs ?? {})
|
||||
|
||||
@@ -16,7 +16,7 @@ export async function getConfig (
|
||||
excludeReporter: boolean
|
||||
globalDirShouldAllowWrite?: boolean
|
||||
workspaceDir: string | undefined
|
||||
ignoreNonAuthSettingsFromLocal?: boolean
|
||||
onlyInheritDlxSettingsFromLocal?: boolean
|
||||
}
|
||||
): Promise<{ config: Config, context: ConfigContext }> {
|
||||
const { config, context, warnings } = await _getConfig({
|
||||
@@ -24,7 +24,7 @@ export async function getConfig (
|
||||
globalDirShouldAllowWrite: opts.globalDirShouldAllowWrite,
|
||||
packageManager,
|
||||
workspaceDir: opts.workspaceDir,
|
||||
ignoreNonAuthSettingsFromLocal: opts.ignoreNonAuthSettingsFromLocal,
|
||||
onlyInheritDlxSettingsFromLocal: opts.onlyInheritDlxSettingsFromLocal,
|
||||
})
|
||||
context.cliOptions = cliOptions
|
||||
applyDerivedConfig(config)
|
||||
|
||||
@@ -100,7 +100,7 @@ export async function main (inputArgv: string[]): Promise<void> {
|
||||
excludeReporter: false,
|
||||
globalDirShouldAllowWrite,
|
||||
workspaceDir,
|
||||
ignoreNonAuthSettingsFromLocal: isDlxOrCreateCommand,
|
||||
onlyInheritDlxSettingsFromLocal: isDlxOrCreateCommand,
|
||||
}) as { config: typeof config, context: ConfigContext })
|
||||
if (!isExecutedByCorepack() && cmd !== 'setup' && context.wantedPackageManager != null && !shouldSkipPmHandling(cmd, cliParams)) {
|
||||
const pm = context.wantedPackageManager
|
||||
|
||||
@@ -8,6 +8,7 @@ import { prepare, prepareEmpty } from '@pnpm/prepare'
|
||||
import { addUser, REGISTRY_MOCK_PORT } from '@pnpm/registry-mock'
|
||||
import type { BaseManifest } from '@pnpm/types'
|
||||
import PATH_NAME from 'path-name'
|
||||
import { writeYamlFileSync } from 'write-yaml-file'
|
||||
|
||||
import { execPnpm, execPnpmSync } from './utils/index.js'
|
||||
|
||||
@@ -81,6 +82,97 @@ patchedDependencies:
|
||||
})
|
||||
})
|
||||
|
||||
// The public npm registry is used here instead of verdaccio because verdaccio
|
||||
// includes the 'time' field in abbreviated metadata, which short-circuits
|
||||
// the publish-date check.
|
||||
describe('minimumReleaseAge from pnpm-workspace.yaml', () => {
|
||||
// Hard-coded publish timestamps from the public npm registry.
|
||||
// The gap of ~2.3 years between 0.3.2 and 0.3.3 provides ample buffer
|
||||
// for any CI timing variance.
|
||||
const SHX_0_3_2_PUBLISHED = new Date('2018-07-11T04:13:54.318Z').getTime()
|
||||
const SHX_0_3_3_PUBLISHED = new Date('2020-10-26T05:35:14.984Z').getTime()
|
||||
const MINUTES_MS = 60 * 1000
|
||||
|
||||
test('dlx fails when the requested version is younger than minimumReleaseAge', () => {
|
||||
prepare()
|
||||
writeYamlFileSync('pnpm-workspace.yaml', {
|
||||
minimumReleaseAge: 60 * 24 * 10000, // ~27.4 years: rejects everything published recently
|
||||
minimumReleaseAgeStrict: true,
|
||||
})
|
||||
|
||||
const result = execPnpmSync([
|
||||
'--config.registry=https://registry.npmjs.org/',
|
||||
'dlx', 'shx@0.3.4', 'echo', 'hi',
|
||||
], { omitEnvDefaults: ['pnpm_config_minimum_release_age'] })
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr.toString()).toMatch(/does not meet the minimumReleaseAge constraint/)
|
||||
})
|
||||
|
||||
test('dlx succeeds when the requested version is older than minimumReleaseAge', () => {
|
||||
prepare()
|
||||
// Cutoff 30 days after 0.3.2 was published: 0.3.2 is "mature". Anything
|
||||
// newer (like 0.3.3 or 0.3.4) would not be, but the spec pins 0.3.2.
|
||||
const bufferMinutes = 30 * 24 * 60
|
||||
const minimumReleaseAge = Math.floor((Date.now() - SHX_0_3_2_PUBLISHED) / MINUTES_MS) - bufferMinutes
|
||||
writeYamlFileSync('pnpm-workspace.yaml', {
|
||||
minimumReleaseAge,
|
||||
minimumReleaseAgeStrict: true,
|
||||
})
|
||||
|
||||
execPnpmSync([
|
||||
'--config.registry=https://registry.npmjs.org/',
|
||||
'dlx', 'shx@0.3.2', 'echo', 'hi',
|
||||
], { expectSuccess: true, omitEnvDefaults: ['pnpm_config_minimum_release_age'] })
|
||||
})
|
||||
|
||||
test('dlx picks the newest version within a range that satisfies minimumReleaseAge', () => {
|
||||
prepare()
|
||||
// Cutoff positioned between 0.3.2 (2018-07-11) and 0.3.3 (2020-10-26):
|
||||
// 0.3.2 is mature, 0.3.3 and 0.3.4 are not. Range `0.3.x` should resolve to 0.3.2.
|
||||
const cutoff = (SHX_0_3_2_PUBLISHED + SHX_0_3_3_PUBLISHED) / 2
|
||||
const minimumReleaseAge = Math.floor((Date.now() - cutoff) / MINUTES_MS)
|
||||
const cacheDir = path.resolve('cache')
|
||||
writeYamlFileSync('pnpm-workspace.yaml', {
|
||||
minimumReleaseAge,
|
||||
minimumReleaseAgeStrict: true,
|
||||
})
|
||||
|
||||
execPnpmSync([
|
||||
`--config.cache-dir=${cacheDir}`,
|
||||
`--config.store-dir=${path.resolve('store')}`,
|
||||
'--config.registry=https://registry.npmjs.org/',
|
||||
'dlx', 'shx@0.3.x', 'echo', 'hi',
|
||||
], { expectSuccess: true, omitEnvDefaults: ['pnpm_config_minimum_release_age'] })
|
||||
|
||||
// Verify the resolved version by reading the package.json installed in the dlx cache.
|
||||
const dlxDirs = fs.readdirSync(path.resolve(cacheDir, 'dlx'))
|
||||
expect(dlxDirs).toHaveLength(1)
|
||||
const pkgJson = JSON.parse(fs.readFileSync(
|
||||
path.resolve(cacheDir, 'dlx', dlxDirs[0], 'pkg/node_modules/shx/package.json'),
|
||||
'utf8'
|
||||
) as string)
|
||||
expect(pkgJson.version).toBe('0.3.2')
|
||||
})
|
||||
})
|
||||
|
||||
// pnpm create delegates to dlx, so the same inheritance applies.
|
||||
test('pnpm create respects minimumReleaseAge from pnpm-workspace.yaml', () => {
|
||||
prepare()
|
||||
writeYamlFileSync('pnpm-workspace.yaml', {
|
||||
minimumReleaseAge: 60 * 24 * 10000, // ~27.4 years: rejects everything published recently
|
||||
minimumReleaseAgeStrict: true,
|
||||
})
|
||||
|
||||
const result = execPnpmSync([
|
||||
'--config.registry=https://registry.npmjs.org/',
|
||||
'create', 'esm@1.0.18',
|
||||
], { omitEnvDefaults: ['pnpm_config_minimum_release_age'] })
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr.toString()).toMatch(/does not meet the minimumReleaseAge constraint/)
|
||||
})
|
||||
|
||||
test('dlx should work with pnpm_config_save_dev env variable', async () => {
|
||||
prepareEmpty()
|
||||
execPnpmSync(['dlx', '@foo/touch-file-one-bin@latest'], {
|
||||
|
||||
@@ -118,10 +118,20 @@ export interface ChildProcess {
|
||||
stderr: { toString: () => string }
|
||||
}
|
||||
|
||||
type PnpmEnvDefault =
|
||||
| 'pnpm_config_fetch_retries'
|
||||
| 'pnpm_config_hoist'
|
||||
| 'pnpm_config_minimum_release_age'
|
||||
| 'pnpm_config_registry'
|
||||
| 'pnpm_config_silent'
|
||||
| 'pnpm_config_store_dir'
|
||||
| 'pnpm_config_verify_store_integrity'
|
||||
|
||||
export interface ExecPnpmSyncOpts {
|
||||
cwd?: string
|
||||
env?: Record<string, string>
|
||||
expectSuccess?: boolean // similar to expect(status).toBe(0), but also prints error messages, which makes it easier to debug failed tests
|
||||
omitEnvDefaults?: PnpmEnvDefault[]
|
||||
stdio?: StdioOptions
|
||||
storeDir?: string
|
||||
timeout?: number
|
||||
@@ -134,7 +144,7 @@ export function execPnpmSync (
|
||||
const execResult = crossSpawn.sync(process.execPath, [pnpmBinLocation, ...args], {
|
||||
cwd: opts?.cwd,
|
||||
env: {
|
||||
...createEnv(),
|
||||
...createEnv({ omitEnvDefaults: opts?.omitEnvDefaults }),
|
||||
...opts?.env,
|
||||
} as NodeJS.ProcessEnv,
|
||||
stdio: opts?.stdio ?? 'pipe',
|
||||
@@ -172,7 +182,7 @@ export function execPnpxSync (
|
||||
return execResult as ChildProcess
|
||||
}
|
||||
|
||||
function createEnv (opts?: { storeDir?: string }): NodeJS.ProcessEnv {
|
||||
function createEnv (opts?: { storeDir?: string, omitEnvDefaults?: PnpmEnvDefault[] }): NodeJS.ProcessEnv {
|
||||
let workspaceManifest: Record<string, unknown> | undefined
|
||||
try {
|
||||
workspaceManifest = readYamlFileSync('pnpm-workspace.yaml')
|
||||
@@ -202,6 +212,9 @@ function createEnv (opts?: { storeDir?: string }): NodeJS.ProcessEnv {
|
||||
// on CI servers we set it to `false`. That is why we set it back to true for the tests
|
||||
pnpm_config_verify_store_integrity: 'true',
|
||||
}
|
||||
for (const key of opts?.omitEnvDefaults ?? []) {
|
||||
delete env[key]
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (key.toLowerCase() === 'path' || key === 'COLORTERM' || key === 'APPDATA') {
|
||||
|
||||
Reference in New Issue
Block a user