feat(config): global yaml (#10145)

* feat(config): global `rc.yaml`

* fix: undefined `rawConfig`

* test: add a test

* feat: re-export `isSupportedNpmConfig`

* feat: return `'compat'` to distinguish compatibility reason

* docs: `isSupportedNpmConfig`

* fix: eslint

* docs: clarify the case of the config key

* feat(cli/config/set): target yaml for pnpm-specific settings

* fix: read the correct file

* fix: write to the correct directory

* refactor: remove disabled code

* refactor: get `configDir` directly

* docs: remove outdated documentation

* test: fix a test

* test: rename

* fix: explicitly tell npm the config file path

* test: add a test

* test: add a test

* test: fix a test

* fix: local config dir

* fix: `managingAuthSettings`

* test: rename

* test: fix

* test: add a test

* test: demonstrate choosing config files

* test: fix

* docs: yet another consideration

* test: demonstrate choosing config files

* fix: correct local config file names in test helper

* test: demonstrate choosing config files

* test: use the helper

* test: add a test

* test: correct a test

* test: fix

* test: fix

* fix: eslint

* test: remove duplicate

* feat: validate `rc.yaml`

* docs: changeset

* test: fix `configDelete.test.ts`

* feat: other `npm` call-sites

* fix: make optional again

* feat: remove the change from `publish`

* fix: eslint

* refactor: just one is sufficient

* refactor: replace type union with 3 functions

* refactor(test): extract helper functions

* fix: add `rc.yaml` to `rawConfig`

* test: keep workspace settings out of `rc.yaml`

* test: fix `spawn ENOENT`

* chore(git): revert invalid change

This reverts commit 1ff6fe2323.

* feat: rename `rc.yaml` to `config.yaml`

* refactor: replace `acceptNonRc` with `!globalSettingsOnly`

* feat!: remove compat completely

* refactor: rename a function

* fix: no actual catalogs

* refactor: replace bool flag with preemptive filter

* feat!: filter global config keys

* test: fix

* fix: exclude `deploy-all-files`

* fix: reverse schema merge order

* feat(cli/config/set): validate global config yaml key

* test: remove duplicated assertion

* docs: correct

* docs: goal changed
This commit is contained in:
Khải
2025-11-11 17:24:06 +07:00
committed by GitHub
parent f03b9ecf4e
commit 075aa993bb
23 changed files with 1132 additions and 221 deletions

View File

@@ -0,0 +1,15 @@
---
"@pnpm/plugin-commands-config": minor
"@pnpm/workspace.manifest-writer": minor
"@pnpm/workspace.read-manifest": minor
"@pnpm/constants": minor
"@pnpm/config": minor
"pnpm": minor
---
Add support for a global YAML config file named `config.yaml`.
Now configurations are divided into 2 categories:
- Registry and auth settings which can be stored in INI files such as global `rc` and local `.npmrc`.
- pnpm-specific settings which can only be loaded from YAML files such as global `config.yaml` and local `pnpm-workspace.yaml`.

View File

@@ -40,16 +40,8 @@ const AUTH_CFG_KEYS = [
'strictSsl',
] satisfies Array<keyof Config>
const PNPM_COMPAT_SETTINGS = [
// NOTE: This field is kept in .npmrc because `managePackageManagerVersions: true`
// in pnpm-workspace.yaml currently causes pnpm to be unresponsive (probably
// due to an infinite loop of some kind).
'manage-package-manager-versions',
] satisfies Array<keyof typeof types>
const NPM_AUTH_SETTINGS = [
...RAW_AUTH_CFG_KEYS,
...PNPM_COMPAT_SETTINGS,
'_auth',
'_authToken',
'_password',
@@ -92,14 +84,20 @@ export function inheritAuthConfig (targetCfg: InheritableConfig, authSrcCfg: Inh
inheritPickedConfig(targetCfg, authSrcCfg, pickAuthConfig, pickRawAuthConfig)
}
export const isSupportedNpmConfig = (key: string): boolean =>
/**
* Whether the config key would be read from an INI config file.
*/
export const isIniConfigKey = (key: string): boolean =>
key.startsWith('@') || key.startsWith('//') || NPM_AUTH_SETTINGS.includes(key)
export function pickNpmAuthConfig<RawConfig extends Record<string, unknown>> (rawConfig: RawConfig): Partial<RawConfig> {
/**
* Filter keys that are allowed to be read from an INI config file.
*/
export function pickIniConfig<RawConfig extends Record<string, unknown>> (rawConfig: RawConfig): Partial<RawConfig> {
const result: Partial<RawConfig> = {}
for (const key in rawConfig) {
if (isSupportedNpmConfig(key)) {
if (isIniConfigKey(key)) {
result[key] = rawConfig[key]
}
}

View File

@@ -0,0 +1,183 @@
import npmTypes from '@pnpm/npm-conf/lib/types.js'
import { type pnpmTypes } from './types.js'
type NpmKey = keyof typeof npmTypes.types
type PnpmKey = keyof typeof pnpmTypes
/**
* Keys from {@link pnpmTypes} that are valid fields in a global config file.
*/
export const pnpmConfigFileKeys = [
'bail',
'ci',
'color',
'cache-dir',
'child-concurrency',
'dangerously-allow-all-builds',
'enable-modules-dir',
'enable-global-virtual-store',
'exclude-links-from-lockfile',
'extend-node-path',
'fetch-timeout',
'fetch-warn-timeout-ms',
'fetch-min-speed-ki-bps',
'fetching-concurrency',
'git-checks',
'git-shallow-hosts',
'global-bin-dir',
'global-dir',
'global-path',
'global-pnpmfile',
'optimistic-repeat-install',
'loglevel',
'maxsockets',
'modules-cache-max-age',
'dlx-cache-max-age',
'minimum-release-age',
'minimum-release-age-exclude',
'network-concurrency',
'noproxy',
'npm-path',
'package-import-method',
'prefer-frozen-lockfile',
'prefer-offline',
'prefer-symlinked-executables',
'reporter',
'resolution-mode',
'store-dir',
'use-beta-cli',
'use-running-store-server',
'use-store-server',
] as const satisfies readonly PnpmKey[]
export type PnpmConfigFileKey = typeof pnpmConfigFileKeys[number]
/**
* Keys that present in {@link pnpmTypes} but are excluded from {@link ConfigFileKey}.
* They are usually CLI flags or workspace-only settings.
*/
export const excludedPnpmKeys = [
'auto-install-peers',
'catalog-mode',
'config-dir',
'merge-git-branch-lockfiles',
'merge-git-branch-lockfiles-branch-pattern',
'deploy-all-files',
'dedupe-peer-dependents',
'dedupe-direct-deps',
'dedupe-injected-deps',
'dev',
'dir',
'disallow-workspace-cycles',
'enable-pre-post-scripts',
'filter',
'filter-prod',
'force-legacy-deploy',
'frozen-lockfile',
'git-branch-lockfile',
'hoist',
'hoist-pattern',
'hoist-workspace-packages',
'ignore-compatibility-db',
'ignore-dep-scripts',
'ignore-pnpmfile',
'ignore-workspace',
'ignore-workspace-cycles',
'ignore-workspace-root-check',
'include-workspace-root',
'init-package-manager',
'init-type',
'inject-workspace-packages',
'legacy-dir-filtering',
'link-workspace-packages',
'lockfile',
'lockfile-dir',
'lockfile-directory',
'lockfile-include-tarball-url',
'lockfile-only',
'manage-package-manager-versions',
'modules-dir',
'node-linker',
'offline',
'only-built-dependencies',
'pack-destination',
'pack-gzip-level',
'patches-dir',
'pnpmfile',
'package-manager-strict',
'package-manager-strict-version',
'prefer-workspace-packages',
'preserve-absolute-paths',
'production',
'public-hoist-pattern',
'publish-branch',
'recursive-install',
'resolve-peers-from-workspace-root',
'aggregate-output',
'reporter-hide-prefix',
'save-catalog-name',
'save-peer',
'save-workspace-protocol',
'script-shell',
'shamefully-flatten',
'shamefully-hoist',
'shared-workspace-lockfile',
'shell-emulator',
'side-effects-cache',
'side-effects-cache-readonly',
'symlink',
'sort',
'state-dir',
'stream',
'strict-dep-builds',
'strict-store-pkg-content-check',
'strict-peer-dependencies',
'trust-policy',
'use-node-version',
'use-stderr',
'verify-deps-before-run',
'verify-store-integrity',
'global-virtual-store-dir',
'virtual-store-dir',
'virtual-store-dir-max-length',
'peers-suffix-max-length',
'workspace-concurrency',
'workspace-packages',
'workspace-root',
'test-pattern',
'changed-files-ignore-pattern',
'embed-readme',
'update-notifier',
'registry-supports-time-field',
'fail-if-no-match',
'sync-injected-deps-after-scripts',
'cpu',
'libc',
'os',
] as const satisfies ReadonlyArray<Exclude<PnpmKey, PnpmConfigFileKey>>
export type ExcludedPnpmKey = typeof excludedPnpmKeys[number]
/**
* Proof that {@link excludedPnpmKeys} is complete and exhaustive, i.e. All keys that appear in {@link pnpmTypes} but not in
* {@link pnpmConfigFileKeys} should be included in {@link excludedPnpmKeys}.
*/
export const _proofExcludedPnpmKeysIsExhaustive = (carrier: Exclude<PnpmKey, PnpmConfigFileKey>): ExcludedPnpmKey => carrier
/**
* Proof that there are no keys that are both included and excluded, i.e. {@link pnpmConfigFileKeys} and {@link excludedPnpmKeys}
* have no overlap.
*/
export const _proofNoContradiction = (carrier: PnpmConfigFileKey & ExcludedPnpmKey): never => carrier
// even npmTypes still have keys that don't make sense in global config, but the list is quite long, let's do it another day.
// TODO: compile a list of npm keys that are valid or invalid in a global config file.
export type NpmConfigFileKey = Exclude<NpmKey, ExcludedPnpmKey>
/** Key that is valid in a global config file. */
export type ConfigFileKey = NpmConfigFileKey | PnpmConfigFileKey
const setOfPnpmConfigFilesKeys: ReadonlySet<string> = new Set(pnpmConfigFileKeys)
const setOfExcludedPnpmKeys: ReadonlySet<string> = new Set(excludedPnpmKeys)
/** Whether the key (in kebab-case) is a valid key in a global config file. */
export const isConfigFileKey = (kebabKey: string): kebabKey is ConfigFileKey =>
setOfPnpmConfigFilesKeys.has(kebabKey) || (kebabKey in npmTypes.types && !setOfExcludedPnpmKeys.has(kebabKey))

View File

@@ -64,7 +64,7 @@ export function getOptionsFromRootManifest (manifestDir: string, manifest: Proje
return settings
}
export function getOptionsFromPnpmSettings (manifestDir: string, pnpmSettings: PnpmSettings, manifest?: ProjectManifest): OptionsFromRootManifest {
export function getOptionsFromPnpmSettings (manifestDir: string | undefined, pnpmSettings: PnpmSettings, manifest?: ProjectManifest): OptionsFromRootManifest {
const renamedKeys = ['allowNonAppliedPatches'] as const satisfies Array<keyof PnpmSettings>
const settings: OptionsFromRootManifest = omit(renamedKeys, replaceEnvInSettings(pnpmSettings))
if (settings.overrides) {
@@ -74,13 +74,13 @@ export function getOptionsFromPnpmSettings (manifestDir: string, pnpmSettings: P
settings.overrides = mapValues(createVersionReferencesReplacer(manifest), settings.overrides)
}
}
if (pnpmSettings.onlyBuiltDependenciesFile) {
if (pnpmSettings.onlyBuiltDependenciesFile && manifestDir != null) {
settings.onlyBuiltDependenciesFile = path.join(manifestDir, pnpmSettings.onlyBuiltDependenciesFile)
}
if (pnpmSettings.patchedDependencies) {
settings.patchedDependencies = { ...pnpmSettings.patchedDependencies }
for (const [dep, patchFile] of Object.entries(pnpmSettings.patchedDependencies)) {
if (path.isAbsolute(patchFile)) continue
if (manifestDir == null || path.isAbsolute(patchFile)) continue
settings.patchedDependencies[dep] = path.join(manifestDir, patchFile)
}
}

View File

@@ -4,7 +4,7 @@ import os from 'os'
import { isCI } from 'ci-info'
import { omit } from 'ramda'
import { getCatalogsFromWorkspaceManifest } from '@pnpm/catalogs.config'
import { LAYOUT_VERSION } from '@pnpm/constants'
import { GLOBAL_CONFIG_YAML_FILENAME, LAYOUT_VERSION } from '@pnpm/constants'
import { PnpmError } from '@pnpm/error'
import { isCamelCase } from '@pnpm/naming-cases'
import loadNpmConf from '@pnpm/npm-conf'
@@ -12,6 +12,7 @@ import type npmTypes from '@pnpm/npm-conf/lib/types.js'
import { safeReadProjectManifestOnly } from '@pnpm/read-project-manifest'
import { getCurrentBranch } from '@pnpm/git-utils'
import { createMatcher } from '@pnpm/matcher'
import { type ProjectManifest } from '@pnpm/types'
import betterPathResolve from 'better-path-resolve'
import camelcase from 'camelcase'
import isWindows from 'is-windows'
@@ -20,7 +21,8 @@ import normalizeRegistryUrl from 'normalize-registry-url'
import realpathMissing from 'realpath-missing'
import pathAbsolute from 'path-absolute'
import which from 'which'
import { inheritAuthConfig, isSupportedNpmConfig, pickNpmAuthConfig } from './auth.js'
import { inheritAuthConfig, isIniConfigKey, pickIniConfig } from './auth.js'
import { isConfigFileKey } from './configFileKey.js'
import { checkGlobalBinDir } from './checkGlobalBinDir.js'
import { hasDependencyBuildOptions, extractAndRemoveDependencyBuildOptions } from './dependencyBuildOptions.js'
import { getNetworkConfigs } from './getNetworkConfigs.js'
@@ -35,7 +37,7 @@ import {
} from './Config.js'
import { getDefaultWorkspaceConcurrency, getWorkspaceConcurrency } from './concurrency.js'
import { parseEnvVars } from './env.js'
import { readWorkspaceManifest } from '@pnpm/workspace.read-manifest'
import { type WorkspaceManifest, readWorkspaceManifest } from '@pnpm/workspace.read-manifest'
import { types } from './types.js'
import { getOptionsFromPnpmSettings, getOptionsFromRootManifest } from './getOptionsFromRootManifest.js'
@@ -51,6 +53,9 @@ export { getDefaultWorkspaceConcurrency, getWorkspaceConcurrency } from './concu
export type { Config, UniversalOptions, WantedPackageManager, VerifyDepsBeforeRun }
export { isIniConfigKey } from './auth.js'
export { type ConfigFileKey, isConfigFileKey } from './configFileKey.js'
type CamelToKebabCase<S extends string> = S extends `${infer T}${infer U}`
? `${T extends Capitalize<T> ? '-' : ''}${Lowercase<T>}${CamelToKebabCase<U>}`
: S
@@ -244,9 +249,10 @@ export async function getConfig (opts: {
rcOptions
.map((configKey) => [
camelcase(configKey, { locale: 'en-US' }),
isSupportedNpmConfig(configKey) ? npmConfig.get(configKey) : (defaultOptions as Record<string, unknown>)[configKey],
isIniConfigKey(configKey) ? npmConfig.get(configKey) : (defaultOptions as Record<string, unknown>)[configKey],
])
) as ConfigWithDeprecatedSettings
const globalDepsBuildConfig = extractAndRemoveDependencyBuildOptions(pnpmConfig)
Object.assign(pnpmConfig, configFromCliOpts)
@@ -271,12 +277,27 @@ export async function getConfig (opts: {
: `${packageManager.name}/${packageManager.version} npm/? node/${process.version} ${process.platform} ${process.arch}`
pnpmConfig.rawConfig = Object.assign(
{},
...npmConfig.list.map(pickNpmAuthConfig).reverse(),
pickNpmAuthConfig(cliOptions),
...npmConfig.list.map(pickIniConfig).reverse(),
pickIniConfig(cliOptions),
{ 'user-agent': pnpmConfig.userAgent },
{ globalconfig: path.join(configDir, 'rc') },
{ 'npm-globalconfig': npmDefaults.globalconfig }
)
const globalYamlConfig = await readWorkspaceManifest(configDir, GLOBAL_CONFIG_YAML_FILENAME)
for (const key in globalYamlConfig) {
if (!isConfigFileKey(kebabCase(key))) {
delete globalYamlConfig[key as keyof typeof globalYamlConfig]
}
}
if (globalYamlConfig) {
addSettingsFromWorkspaceManifestToConfig(pnpmConfig, {
configFromCliOpts,
projectManifest: undefined,
workspaceDir: undefined,
workspaceManifest: globalYamlConfig,
})
}
const networkConfigs = getNetworkConfigs(pnpmConfig.rawConfig)
pnpmConfig.registries = {
default: normalizeRegistryUrl(pnpmConfig.rawConfig.registry),
@@ -370,29 +391,12 @@ export async function getConfig (opts: {
pnpmConfig.workspacePackagePatterns = cliOptions['workspace-packages'] as string[] ?? workspaceManifest?.packages
if (workspaceManifest) {
const newSettings = Object.assign(getOptionsFromPnpmSettings(pnpmConfig.workspaceDir, workspaceManifest, pnpmConfig.rootProjectManifest), configFromCliOpts)
for (const [key, value] of Object.entries(newSettings)) {
if (!isCamelCase(key)) continue
// @ts-expect-error
pnpmConfig[key] = value
const kebabKey = kebabCase(key)
// Q: Why `types` instead of `rcOptionTypes`?
// A: `rcOptionTypes` includes options that would matter to the `npm` cli which wouldn't care about `pnpm-workspace.yaml`.
const targetKey = kebabKey in types ? kebabKey : key
pnpmConfig.rawConfig[targetKey] = value
}
// All the pnpm_config_ env variables should override the settings from pnpm-workspace.yaml,
// as it happens with .npmrc.
// Until that is fixed, we should at the very least keep the right priority for verifyDepsBeforeRun,
// or else, we'll get infinite recursion.
// Related issue: https://github.com/pnpm/pnpm/issues/10060
if (process.env.pnpm_config_verify_deps_before_run != null) {
pnpmConfig.verifyDepsBeforeRun = process.env.pnpm_config_verify_deps_before_run as VerifyDepsBeforeRun
pnpmConfig.rawConfig['verify-deps-before-run'] = pnpmConfig.verifyDepsBeforeRun
}
pnpmConfig.catalogs = getCatalogsFromWorkspaceManifest(workspaceManifest)
addSettingsFromWorkspaceManifestToConfig(pnpmConfig, {
configFromCliOpts,
projectManifest: pnpmConfig.rootProjectManifest,
workspaceDir: pnpmConfig.workspaceDir,
workspaceManifest,
})
}
}
}
@@ -638,3 +642,40 @@ function parsePackageManager (packageManager: string): { name: string, version:
version,
}
}
function addSettingsFromWorkspaceManifestToConfig (pnpmConfig: Config, {
configFromCliOpts,
projectManifest,
workspaceManifest,
workspaceDir,
}: {
configFromCliOpts: Record<string, unknown>
projectManifest: ProjectManifest | undefined
workspaceDir: string | undefined
workspaceManifest: WorkspaceManifest
}): void {
const newSettings = Object.assign(getOptionsFromPnpmSettings(workspaceDir, workspaceManifest, projectManifest), configFromCliOpts)
for (const [key, value] of Object.entries(newSettings)) {
if (!isCamelCase(key)) continue
// @ts-expect-error
pnpmConfig[key] = value
const kebabKey = kebabCase(key)
// Q: Why `types` instead of `rcOptionTypes`?
// A: `rcOptionTypes` includes options that would matter to the `npm` cli which wouldn't care about `pnpm-workspace.yaml`.
const isRc = kebabKey in types
const targetKey = isRc ? kebabKey : key
pnpmConfig.rawConfig[targetKey] = value
}
// All the pnpm_config_ env variables should override the settings from pnpm-workspace.yaml,
// as it happens with .npmrc.
// Until that is fixed, we should at the very least keep the right priority for verifyDepsBeforeRun,
// or else, we'll get infinite recursion.
// Related issue: https://github.com/pnpm/pnpm/issues/10060
if (process.env.pnpm_config_verify_deps_before_run != null) {
pnpmConfig.verifyDepsBeforeRun = process.env.pnpm_config_verify_deps_before_run as VerifyDepsBeforeRun
pnpmConfig.rawConfig['verify-deps-before-run'] = pnpmConfig.verifyDepsBeforeRun
}
pnpmConfig.catalogs = getCatalogsFromWorkspaceManifest(workspaceManifest)
}

View File

@@ -1,7 +1,7 @@
import npmTypes from '@pnpm/npm-conf/lib/types.js'
import { type TrustPolicy } from '@pnpm/types'
export const types = Object.assign({
export const pnpmTypes = {
'auto-install-peers': Boolean,
bail: Boolean,
ci: Boolean,
@@ -139,4 +139,14 @@ export const types = Object.assign({
cpu: [String, Array],
libc: [String, Array],
os: [String, Array],
}, npmTypes.types)
}
// NOTE: There is an oversight I just now notice thanks to a test failure: pnpmTypes (which used to be the object literal inside `Object.assign`)
// contains some field that overlaps with that of `npmTypes.types`. The definitions of such fields are pointless as they are overwritten by
// `npmTypes.types` anyway.
// TODO: Fix this overlap later.
// TODO: After that, move `...pnpmTypes` down, `...npmTypes.types` up.
export const types = {
...pnpmTypes,
...npmTypes.types,
}

View File

@@ -1358,3 +1358,44 @@ test('CLI should override environment variable pnpm_config_*', async () => {
'use-node-version': '22.0.0',
})).toBe('22.0.0')
})
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 })
writeYamlFile('.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,
// `pnpm config list` would convert them to camelCase.
// TODO: switch to camelCase entirely later.
expect(config.rawConfig).toHaveProperty(['dangerously-allow-all-builds'])
})
})

View File

@@ -34,6 +34,7 @@
"dependencies": {
"@pnpm/cli-utils": "workspace:*",
"@pnpm/config": "workspace:*",
"@pnpm/constants": "workspace:*",
"@pnpm/error": "workspace:*",
"@pnpm/naming-cases": "workspace:*",
"@pnpm/object.key-sorting": "workspace:*",
@@ -45,7 +46,8 @@
"lodash.kebabcase": "catalog:",
"read-ini-file": "catalog:",
"render-help": "catalog:",
"write-ini-file": "catalog:"
"write-ini-file": "catalog:",
"write-yaml-file": "catalog:"
},
"peerDependencies": {
"@pnpm/logger": "catalog:"

View File

@@ -1,6 +1,7 @@
import path from 'path'
import util from 'util'
import { types } from '@pnpm/config'
import { type ConfigFileKey, types, isConfigFileKey } from '@pnpm/config'
import { GLOBAL_CONFIG_YAML_FILENAME, WORKSPACE_MANIFEST_FILENAME } from '@pnpm/constants'
import { PnpmError } from '@pnpm/error'
import { isCamelCase, isStrictlyKebabCase } from '@pnpm/naming-cases'
import { parsePropertyPath } from '@pnpm/object.property-path'
@@ -11,7 +12,7 @@ import kebabCase from 'lodash.kebabcase'
import { readIniFile } from 'read-ini-file'
import { writeIniFile } from 'write-ini-file'
import { type ConfigCommandOptions } from './ConfigCommandOptions.js'
import { getConfigFilePath } from './getConfigFilePath.js'
import { getConfigFileInfo } from './getConfigFileInfo.js'
import { settingShouldFallBackToNpm } from './settingShouldFallBackToNpm.js'
export async function configSet (opts: ConfigCommandOptions, key: string, valueParam: string | null): Promise<void> {
@@ -56,18 +57,29 @@ export async function configSet (opts: ConfigCommandOptions, key: string, valueP
}
}
const { configPath, isWorkspaceYaml } = getConfigFilePath(opts)
const { configDir, configFileName } = getConfigFileInfo(key, opts)
const configPath = path.join(configDir, configFileName)
if (isWorkspaceYaml) {
switch (configFileName) {
case GLOBAL_CONFIG_YAML_FILENAME:
case WORKSPACE_MANIFEST_FILENAME: {
if (configFileName === GLOBAL_CONFIG_YAML_FILENAME) {
key = validateYamlConfigKey(key)
}
key = validateWorkspaceKey(key)
await updateWorkspaceManifest(opts.workspaceDir ?? opts.dir, {
await updateWorkspaceManifest(configDir, {
fileName: configFileName,
updatedFields: ({
[key]: castField(value, kebabCase(key)),
}),
})
} else {
break
}
case 'rc':
case '.npmrc': {
const settings = await safeReadIniFile(configPath)
key = validateRcKey(key)
key = validateIniConfigKey(key)
if (value == null) {
if (settings[key] == null) return
delete settings[key]
@@ -75,6 +87,13 @@ export async function configSet (opts: ConfigCommandOptions, key: string, valueP
settings[key] = value
}
await writeIniFile(configPath, settings)
break
}
default: {
const _typeGuard: never = configFileName
throw new Error(`Unhandled case: ${JSON.stringify(_typeGuard)}`)
}
}
}
@@ -145,10 +164,10 @@ function validateSimpleKey (key: string): string {
return first.value.toString()
}
export class ConfigSetUnsupportedRcKeyError extends PnpmError {
export class ConfigSetUnsupportedIniConfigKeyError extends PnpmError {
readonly key: string
constructor (key: string) {
super('CONFIG_SET_UNSUPPORTED_RC_KEY', `Key ${JSON.stringify(key)} isn't supported by rc files`, {
super('CONFIG_SET_UNSUPPORTED_INI_CONFIG_KEY', `Key ${JSON.stringify(key)} isn't supported by INI config files`, {
hint: `Add ${JSON.stringify(camelCase(key))} to the project workspace manifest instead`,
})
this.key = key
@@ -156,16 +175,20 @@ export class ConfigSetUnsupportedRcKeyError extends PnpmError {
}
/**
* Validate if the kebab-case of {@link key} is supported by rc files.
* Validate whether the kebab-case of {@link key} is supported by INI config files.
*
* Return the kebab-case if it is, throw an error otherwise.
*
* "INI config files" includes:
* * The global INI config file named `rc`.
* * The local INI config file named `.npmrc`.
*/
function validateRcKey (key: string): string {
function validateIniConfigKey (key: string): string {
const kebabKey = kebabCase(key)
if (kebabKey in types) {
return kebabKey
}
throw new ConfigSetUnsupportedRcKeyError(key)
throw new ConfigSetUnsupportedIniConfigKeyError(key)
}
export class ConfigSetUnsupportedWorkspaceKeyError extends PnpmError {
@@ -197,3 +220,26 @@ async function safeReadIniFile (configPath: string): Promise<Record<string, unkn
throw err
}
}
export class ConfigSetUnsupportedYamlConfigKeyError extends PnpmError {
readonly key: string
constructor (key: string) {
super('CONFIG_SET_UNSUPPORTED_YAML_CONFIG_KEY', `The key ${JSON.stringify(key)} isn't supported by the global config.yaml file`, {
hint: 'Try setting them instead to the local pnpm-workspace.yaml file',
})
this.key = key
}
}
/**
* Validate whether the {@link key} is allowed in the global config.yaml file.
*
* Return the kebab-case if it is, throw an error otherwise.
*/
function validateYamlConfigKey (key: string): ConfigFileKey {
const kebabKey = kebabCase(key)
if (!isConfigFileKey(kebabKey)) {
throw new ConfigSetUnsupportedYamlConfigKeyError(key)
}
return kebabKey
}

View File

@@ -0,0 +1,33 @@
import kebabCase from 'lodash.kebabcase'
import { isIniConfigKey } from '@pnpm/config'
import { GLOBAL_CONFIG_YAML_FILENAME, WORKSPACE_MANIFEST_FILENAME } from '@pnpm/constants'
import { type ConfigCommandOptions } from './ConfigCommandOptions.js'
export type ConfigFileName =
| 'rc'
| '.npmrc'
| typeof GLOBAL_CONFIG_YAML_FILENAME
| typeof WORKSPACE_MANIFEST_FILENAME
export interface ConfigFilePathInfo {
configDir: string
configFileName: ConfigFileName
}
export function getConfigFileInfo (key: string, opts: Pick<ConfigCommandOptions, 'global' | 'configDir' | 'dir'>): ConfigFilePathInfo {
key = kebabCase(key)
const configDir = opts.global ? opts.configDir : opts.dir
if (isIniConfigKey(key)) {
// NOTE: The following code no longer does what the merged PR at <https://github.com/pnpm/pnpm/pull/10073> wants to do,
// but considering the settings are now clearly divided into 2 separate categories, it should no longer be relevant.
// TODO: Auth, network, and proxy settings should belong only to INI files.
// Add more settings to `isIniConfigKey` to make it complete.
const configFileName = opts.global ? 'rc' : '.npmrc'
return { configDir, configFileName }
} else {
const configFileName = opts.global ? GLOBAL_CONFIG_YAML_FILENAME : WORKSPACE_MANIFEST_FILENAME
return { configDir, configFileName }
}
}

View File

@@ -1,42 +0,0 @@
import fs from 'fs'
import path from 'path'
import { type ConfigCommandOptions } from './ConfigCommandOptions.js'
interface ConfigFilePathInfo {
configPath: string
isWorkspaceYaml: boolean
}
/**
* Priority: pnpm-workspace.yaml > .npmrc > default to pnpm-workspace.yaml
*/
export function getConfigFilePath (opts: Pick<ConfigCommandOptions, 'global' | 'configDir' | 'dir'>): ConfigFilePathInfo {
if (opts.global) {
return {
configPath: path.join(opts.configDir, 'rc'),
isWorkspaceYaml: false,
}
}
const workspaceYamlPath = path.join(opts.dir, 'pnpm-workspace.yaml')
if (fs.existsSync(workspaceYamlPath)) {
return {
configPath: workspaceYamlPath,
isWorkspaceYaml: true,
}
}
const npmrcPath = path.join(opts.dir, '.npmrc')
if (fs.existsSync(npmrcPath)) {
return {
configPath: npmrcPath,
isWorkspaceYaml: false,
}
}
// If neither exists, return pnpm-workspace.yaml
return {
configPath: workspaceYamlPath,
isWorkspaceYaml: true,
}
}

View File

@@ -1,3 +1,6 @@
// NOTE: The logic may be duplicated with `isIniConfigKey` from `@pnpm/config`,
// but we have not the time to refactor it right now.
// TODO: Refactor it when we have the time.
export function settingShouldFallBackToNpm (key: string): boolean {
return (
['registry', '_auth', '_authToken', 'username', '_password'].includes(key) ||

View File

@@ -3,13 +3,90 @@ import path from 'path'
import { tempDir } from '@pnpm/prepare'
import { config } from '@pnpm/plugin-commands-config'
import { readIniFileSync } from 'read-ini-file'
import { sync as readYamlFile } from 'read-yaml-file'
import { sync as writeYamlFile } from 'write-yaml-file'
test('config delete', async () => {
test('config delete on registry key not set', async () => {
const tmp = tempDir()
const configDir = path.join(tmp, 'global-config')
fs.mkdirSync(configDir, { recursive: true })
fs.writeFileSync(path.join(configDir, 'rc'), `store-dir=~/store
cache-dir=~/cache`)
fs.writeFileSync(path.join(configDir, 'rc'), '@my-company:registry=https://registry.my-company.example.com/')
await config.handler({
dir: process.cwd(),
cliOptions: {},
configDir,
global: true,
rawConfig: {},
}, ['delete', 'registry'])
expect(readIniFileSync(path.join(configDir, 'rc'))).toEqual({
'@my-company:registry': 'https://registry.my-company.example.com/',
})
})
test('config delete on registry key set', async () => {
const tmp = tempDir()
const configDir = path.join(tmp, 'global-config')
fs.mkdirSync(configDir, { recursive: true })
fs.writeFileSync(path.join(configDir, 'rc'), 'registry=https://registry.my-company.example.com/')
await config.handler({
dir: process.cwd(),
cliOptions: {},
configDir,
global: true,
rawConfig: {},
}, ['delete', 'registry'])
expect(fs.readdirSync(configDir)).not.toContain('rc')
})
test('config delete on npm-compatible key not set', async () => {
const tmp = tempDir()
const configDir = path.join(tmp, 'global-config')
fs.mkdirSync(configDir, { recursive: true })
fs.writeFileSync(path.join(configDir, 'rc'), '@my-company:registry=https://registry.my-company.example.com/')
await config.handler({
dir: process.cwd(),
cliOptions: {},
configDir,
global: true,
rawConfig: {},
}, ['delete', 'cafile'])
expect(readIniFileSync(path.join(configDir, 'rc'))).toEqual({
'@my-company:registry': 'https://registry.my-company.example.com/',
})
})
test('config delete on npm-compatible key set', async () => {
const tmp = tempDir()
const configDir = path.join(tmp, 'global-config')
fs.mkdirSync(configDir, { recursive: true })
fs.writeFileSync(path.join(configDir, 'rc'), 'cafile=some-cafile')
await config.handler({
dir: process.cwd(),
cliOptions: {},
configDir,
global: true,
rawConfig: {},
}, ['delete', 'cafile'])
// NOTE: pnpm currently does not delete empty rc files.
// TODO: maybe we should?
expect(readIniFileSync(path.join(configDir, 'rc'))).toEqual({})
})
test('config delete on pnpm-specific key not set', async () => {
const tmp = tempDir()
const configDir = path.join(tmp, 'global-config')
fs.mkdirSync(configDir, { recursive: true })
writeYamlFile(path.join(configDir, 'config.yaml'), {
cacheDir: '~/cache',
})
await config.handler({
dir: process.cwd(),
@@ -19,7 +96,26 @@ cache-dir=~/cache`)
rawConfig: {},
}, ['delete', 'store-dir'])
expect(readIniFileSync(path.join(configDir, 'rc'))).toEqual({
'cache-dir': '~/cache',
expect(readYamlFile(path.join(configDir, 'config.yaml'))).toStrictEqual({
cacheDir: '~/cache',
})
})
test('config delete on pnpm-specific key set', async () => {
const tmp = tempDir()
const configDir = path.join(tmp, 'global-config')
fs.mkdirSync(configDir, { recursive: true })
writeYamlFile(path.join(configDir, 'config.yaml'), {
cacheDir: '~/cache',
})
await config.handler({
dir: process.cwd(),
cliOptions: {},
configDir,
global: true,
rawConfig: {},
}, ['delete', 'cache-dir'])
expect(fs.readdirSync(configDir)).not.toContain('config.yaml')
})

View File

@@ -5,12 +5,86 @@ import { tempDir } from '@pnpm/prepare'
import { config } from '@pnpm/plugin-commands-config'
import { readIniFileSync } from 'read-ini-file'
import { sync as readYamlFile } from 'read-yaml-file'
import { type ConfigFilesData, readConfigFiles, writeConfigFiles } from './utils/index.js'
test('config set using the global option', async () => {
test('config set registry setting using the global option', async () => {
const tmp = tempDir()
const configDir = path.join(tmp, 'global-config')
fs.mkdirSync(configDir, { recursive: true })
fs.writeFileSync(path.join(configDir, 'rc'), 'store-dir=~/store')
const initConfig = {
globalRc: {
'@jsr:registry': 'https://alternate-jsr.example.com/',
},
globalYaml: {
storeDir: '~/store',
},
localRc: undefined,
localYaml: undefined,
} satisfies ConfigFilesData
writeConfigFiles(configDir, tmp, initConfig)
await config.handler({
dir: process.cwd(),
cliOptions: {},
configDir,
global: true,
rawConfig: {},
}, ['set', 'registry', 'https://npm-registry.example.com/'])
expect(readConfigFiles(configDir, tmp)).toEqual({
...initConfig,
globalRc: {
...initConfig.globalRc,
registry: 'https://npm-registry.example.com/',
},
})
})
test('config set npm-compatible setting using the global option', async () => {
const tmp = tempDir()
const configDir = path.join(tmp, 'global-config')
const initConfig = {
globalRc: {
'@jsr:registry': 'https://alternate-jsr.example.com/',
},
globalYaml: {
storeDir: '~/store',
},
localRc: undefined,
localYaml: undefined,
} satisfies ConfigFilesData
writeConfigFiles(configDir, tmp, initConfig)
await config.handler({
dir: process.cwd(),
cliOptions: {},
configDir,
global: true,
rawConfig: {},
}, ['set', 'cafile', 'some-cafile'])
expect(readConfigFiles(configDir, tmp)).toEqual({
...initConfig,
globalRc: {
...initConfig.globalRc,
cafile: 'some-cafile',
},
})
})
test('config set pnpm-specific key using the global option', async () => {
const tmp = tempDir()
const configDir = path.join(tmp, 'global-config')
const initConfig = {
globalRc: {
'@jsr:registry': 'https://alternate-jsr.example.com/',
},
globalYaml: {
storeDir: '~/store',
},
localRc: undefined,
localYaml: undefined,
} satisfies ConfigFilesData
writeConfigFiles(configDir, tmp, initConfig)
await config.handler({
dir: process.cwd(),
@@ -20,17 +94,29 @@ test('config set using the global option', async () => {
rawConfig: {},
}, ['set', 'fetch-retries', '1'])
expect(readIniFileSync(path.join(configDir, 'rc'))).toEqual({
'store-dir': '~/store',
'fetch-retries': '1',
expect(readConfigFiles(configDir, tmp)).toEqual({
...initConfig,
globalYaml: {
...initConfig.globalYaml,
fetchRetries: 1,
},
})
})
test('config set using the location=global option', async () => {
const tmp = tempDir()
const configDir = path.join(tmp, 'global-config')
fs.mkdirSync(configDir, { recursive: true })
fs.writeFileSync(path.join(configDir, 'rc'), 'store-dir=~/store')
const initConfig = {
globalRc: {
'@jsr:registry': 'https://alternate-jsr.example.com/',
},
globalYaml: {
storeDir: '~/store',
},
localRc: undefined,
localYaml: undefined,
} satisfies ConfigFilesData
writeConfigFiles(configDir, tmp, initConfig)
await config.handler({
dir: process.cwd(),
@@ -40,16 +126,29 @@ test('config set using the location=global option', async () => {
rawConfig: {},
}, ['set', 'fetchRetries', '1'])
expect(readIniFileSync(path.join(configDir, 'rc'))).toEqual({
'store-dir': '~/store',
'fetch-retries': '1',
expect(readConfigFiles(configDir, tmp)).toEqual({
...initConfig,
globalYaml: {
...initConfig.globalYaml,
fetchRetries: 1,
},
})
})
test('config set using the location=project option. The setting is written to pnpm-workspace.yaml, when .npmrc is not present', async () => {
test('config set pnpm-specific setting using the location=project option', async () => {
const tmp = tempDir()
const configDir = path.join(tmp, 'global-config')
fs.mkdirSync(configDir, { recursive: true })
const initConfig = {
globalRc: undefined,
globalYaml: undefined,
localRc: {
'@jsr:registry': 'https://alternate-jsr.example.com/',
},
localYaml: {
storeDir: '~/store',
},
} satisfies ConfigFilesData
writeConfigFiles(configDir, tmp, initConfig)
await config.handler({
dir: process.cwd(),
@@ -59,12 +158,16 @@ test('config set using the location=project option. The setting is written to pn
rawConfig: {},
}, ['set', 'virtual-store-dir', '.pnpm'])
expect(readYamlFile(path.join(tmp, 'pnpm-workspace.yaml'))).toEqual({
virtualStoreDir: '.pnpm',
expect(readConfigFiles(configDir, tmp)).toEqual({
...initConfig,
localYaml: {
...initConfig.localYaml,
virtualStoreDir: '.pnpm',
},
})
})
test('config delete using the location=project option. The setting in pnpm-workspace.yaml will be deleted, when .npmrc is not present', async () => {
test('config delete with location=project, when delete the last setting from pnpm-workspace.yaml, would delete the file itself', async () => {
const tmp = tempDir()
const configDir = path.join(tmp, 'global-config')
fs.mkdirSync(configDir, { recursive: true })
@@ -92,11 +195,21 @@ test('config delete using the location=project option. The setting in pnpm-works
expect(fs.existsSync(path.join(tmp, 'pnpm-workspace.yaml'))).toBeFalsy()
})
test('config set using the location=project option', async () => {
test('config set registry setting using the location=project option', async () => {
const tmp = tempDir()
const configDir = path.join(tmp, 'global-config')
fs.mkdirSync(configDir, { recursive: true })
fs.writeFileSync(path.join(tmp, '.npmrc'), 'store-dir=~/store')
const initConfig = {
globalRc: undefined,
globalYaml: undefined,
localRc: {
'@jsr:registry': 'https://alternate-jsr.example.com/',
},
localYaml: {
storeDir: '~/store',
},
} satisfies ConfigFilesData
writeConfigFiles(configDir, tmp, initConfig)
await config.handler({
dir: process.cwd(),
@@ -104,11 +217,47 @@ test('config set using the location=project option', async () => {
configDir,
location: 'project',
rawConfig: {},
}, ['set', 'fetch-retries', '1'])
}, ['set', 'registry', 'https://npm-registry.example.com/'])
expect(readIniFileSync(path.join(tmp, '.npmrc'))).toEqual({
'store-dir': '~/store',
'fetch-retries': '1',
expect(readConfigFiles(configDir, tmp)).toEqual({
...initConfig,
localRc: {
...initConfig.localRc,
registry: 'https://npm-registry.example.com/',
},
})
})
test('config set npm-compatible setting using the location=project option', async () => {
const tmp = tempDir()
const configDir = path.join(tmp, 'global-config')
fs.mkdirSync(configDir, { recursive: true })
const initConfig = {
globalRc: undefined,
globalYaml: undefined,
localRc: {
'@jsr:registry': 'https://alternate-jsr.example.com/',
},
localYaml: {
storeDir: '~/store',
},
} satisfies ConfigFilesData
writeConfigFiles(configDir, tmp, initConfig)
await config.handler({
dir: process.cwd(),
cliOptions: {},
configDir,
location: 'project',
rawConfig: {},
}, ['set', 'cafile', 'some-cafile'])
expect(readConfigFiles(configDir, tmp)).toEqual({
...initConfig,
localRc: {
...initConfig.localRc,
cafile: 'some-cafile',
},
})
})
@@ -130,10 +279,98 @@ test('config set saves the setting in the right format to pnpm-workspace.yaml',
})
})
test('config set in project .npmrc file', async () => {
test('config set registry setting in project .npmrc file', async () => {
const tmp = tempDir()
const configDir = path.join(tmp, 'global-config')
fs.writeFileSync(path.join(tmp, '.npmrc'), 'store-dir=~/store')
const initConfig = {
globalRc: {
'@my-company:registry': 'https://registry.my-company.example.com/',
},
globalYaml: {
onlyBuiltDependencies: ['foo', 'bar'],
},
localRc: {
'@local:registry': 'https://localhost:7777/',
},
localYaml: {
storeDir: '~/store',
},
} satisfies ConfigFilesData
writeConfigFiles(configDir, tmp, initConfig)
await config.handler({
dir: process.cwd(),
cliOptions: {},
configDir,
global: false,
location: 'project',
rawConfig: {},
}, ['set', 'registry', 'https://npm-registry.example.com/'])
expect(readConfigFiles(configDir, tmp)).toEqual({
...initConfig,
localRc: {
...initConfig.localRc,
registry: 'https://npm-registry.example.com/',
},
})
})
test('config set npm-compatible setting in project .npmrc file', async () => {
const tmp = tempDir()
const configDir = path.join(tmp, 'global-config')
const initConfig = {
globalRc: {
'@my-company:registry': 'https://registry.my-company.example.com/',
},
globalYaml: {
onlyBuiltDependencies: ['foo', 'bar'],
},
localRc: {
'@local:registry': 'https://localhost:7777/',
},
localYaml: {
storeDir: '~/store',
},
} satisfies ConfigFilesData
writeConfigFiles(configDir, tmp, initConfig)
await config.handler({
dir: process.cwd(),
cliOptions: {},
configDir,
global: false,
location: 'project',
rawConfig: {},
}, ['set', 'cafile', 'some-cafile'])
expect(readConfigFiles(configDir, tmp)).toEqual({
...initConfig,
localRc: {
...initConfig.localRc,
cafile: 'some-cafile',
},
})
})
test('config set pnpm-specific setting in project pnpm-workspace.yaml file', async () => {
const tmp = tempDir()
const configDir = path.join(tmp, 'global-config')
const initConfig = {
globalRc: {
'@my-company:registry': 'https://registry.my-company.example.com/',
},
globalYaml: {
onlyBuiltDependencies: ['foo', 'bar'],
},
localRc: {
'@local:registry': 'https://localhost:7777/',
},
localYaml: {
storeDir: '~/store',
},
} satisfies ConfigFilesData
writeConfigFiles(configDir, tmp, initConfig)
await config.handler({
dir: process.cwd(),
@@ -144,17 +381,33 @@ test('config set in project .npmrc file', async () => {
rawConfig: {},
}, ['set', 'fetch-retries', '1'])
expect(readIniFileSync(path.join(tmp, '.npmrc'))).toEqual({
'store-dir': '~/store',
'fetch-retries': '1',
expect(readConfigFiles(configDir, tmp)).toEqual({
...initConfig,
localYaml: {
...initConfig.localYaml,
fetchRetries: 1,
},
})
})
test('config set key=value', async () => {
const tmp = tempDir()
const configDir = path.join(tmp, 'global-config')
fs.mkdirSync(configDir, { recursive: true })
fs.writeFileSync(path.join(tmp, '.npmrc'), 'store-dir=~/store')
const initConfig = {
globalRc: {
'@my-company:registry': 'https://registry.my-company.example.com/',
},
globalYaml: {
onlyBuiltDependencies: ['foo', 'bar'],
},
localRc: {
'@local:registry': 'https://localhost:7777/',
},
localYaml: {
storeDir: '~/store',
},
} satisfies ConfigFilesData
writeConfigFiles(configDir, tmp, initConfig)
await config.handler({
dir: process.cwd(),
@@ -164,17 +417,33 @@ test('config set key=value', async () => {
rawConfig: {},
}, ['set', 'fetch-retries=1'])
expect(readIniFileSync(path.join(tmp, '.npmrc'))).toEqual({
'store-dir': '~/store',
'fetch-retries': '1',
expect(readConfigFiles(configDir, tmp)).toEqual({
...initConfig,
localYaml: {
...initConfig.localYaml,
fetchRetries: 1,
},
})
})
test('config set key=value, when value contains a "="', async () => {
const tmp = tempDir()
const configDir = path.join(tmp, 'global-config')
fs.mkdirSync(configDir, { recursive: true })
fs.writeFileSync(path.join(tmp, '.npmrc'), 'store-dir=~/store')
const initConfig = {
globalRc: {
'@my-company:registry': 'https://registry.my-company.example.com/',
},
globalYaml: {
onlyBuiltDependencies: ['foo', 'bar'],
},
localRc: {
'@local:registry': 'https://localhost:7777/',
},
localYaml: {
storeDir: '~/store',
},
} satisfies ConfigFilesData
writeConfigFiles(configDir, tmp, initConfig)
await config.handler({
dir: process.cwd(),
@@ -184,9 +453,12 @@ test('config set key=value, when value contains a "="', async () => {
rawConfig: {},
}, ['set', 'lockfile-dir=foo=bar'])
expect(readIniFileSync(path.join(tmp, '.npmrc'))).toEqual({
'store-dir': '~/store',
'lockfile-dir': 'foo=bar',
expect(readConfigFiles(configDir, tmp)).toEqual({
...initConfig,
localYaml: {
...initConfig.localYaml,
lockfileDir: 'foo=bar',
},
})
})
@@ -216,8 +488,15 @@ test('config set or delete throws missing params error', async () => {
test('config set with dot leading key', async () => {
const tmp = tempDir()
const configDir = path.join(tmp, 'global-config')
fs.mkdirSync(configDir, { recursive: true })
fs.writeFileSync(path.join(configDir, 'rc'), 'store-dir=~/store')
const initConfig = {
globalRc: undefined,
globalYaml: {
storeDir: '~/store',
},
localRc: undefined,
localYaml: undefined,
} satisfies ConfigFilesData
writeConfigFiles(configDir, tmp, initConfig)
await config.handler({
dir: process.cwd(),
@@ -227,17 +506,27 @@ test('config set with dot leading key', async () => {
rawConfig: {},
}, ['set', '.fetchRetries', '1'])
expect(readIniFileSync(path.join(configDir, 'rc'))).toEqual({
'store-dir': '~/store',
'fetch-retries': '1',
expect(readConfigFiles(configDir, tmp)).toEqual({
...initConfig,
globalYaml: {
...initConfig.globalYaml,
fetchRetries: 1,
},
})
})
test('config set with subscripted key', async () => {
const tmp = tempDir()
const configDir = path.join(tmp, 'global-config')
fs.mkdirSync(configDir, { recursive: true })
fs.writeFileSync(path.join(configDir, 'rc'), 'store-dir=~/store')
const initConfig = {
globalRc: undefined,
globalYaml: {
storeDir: '~/store',
},
localRc: undefined,
localYaml: undefined,
} satisfies ConfigFilesData
writeConfigFiles(configDir, tmp, initConfig)
await config.handler({
dir: process.cwd(),
@@ -247,9 +536,12 @@ test('config set with subscripted key', async () => {
rawConfig: {},
}, ['set', '["fetch-retries"]', '1'])
expect(readIniFileSync(path.join(configDir, 'rc'))).toEqual({
'store-dir': '~/store',
'fetch-retries': '1',
expect(readConfigFiles(configDir, tmp)).toEqual({
...initConfig,
globalYaml: {
...initConfig.globalYaml,
fetchRetries: 1,
},
})
})
@@ -332,8 +624,15 @@ test('config set with location=project and json=true', async () => {
test('config set refuses writing workspace-specific settings to the global config', async () => {
const tmp = tempDir()
const configDir = path.join(tmp, 'global-config')
fs.mkdirSync(configDir, { recursive: true })
fs.writeFileSync(path.join(configDir, 'rc'), 'store-dir=~/store')
const initConfig = {
globalRc: undefined,
globalYaml: {
storeDir: '~/store',
},
localRc: undefined,
localYaml: undefined,
} satisfies ConfigFilesData
writeConfigFiles(configDir, tmp, initConfig)
await expect(config.handler({
dir: process.cwd(),
@@ -343,7 +642,7 @@ test('config set refuses writing workspace-specific settings to the global confi
json: true,
rawConfig: {},
}, ['set', 'catalog', '{ "react": "19" }'])).rejects.toMatchObject({
code: 'ERR_PNPM_CONFIG_SET_UNSUPPORTED_RC_KEY',
code: 'ERR_PNPM_CONFIG_SET_UNSUPPORTED_YAML_CONFIG_KEY',
key: 'catalog',
})
@@ -366,7 +665,7 @@ test('config set refuses writing workspace-specific settings to the global confi
},
},
})])).rejects.toMatchObject({
code: 'ERR_PNPM_CONFIG_SET_UNSUPPORTED_RC_KEY',
code: 'ERR_PNPM_CONFIG_SET_UNSUPPORTED_YAML_CONFIG_KEY',
key: 'packageExtensions',
})
@@ -389,37 +688,42 @@ test('config set refuses writing workspace-specific settings to the global confi
},
},
})])).rejects.toMatchObject({
code: 'ERR_PNPM_CONFIG_SET_UNSUPPORTED_RC_KEY',
code: 'ERR_PNPM_CONFIG_SET_UNSUPPORTED_YAML_CONFIG_KEY',
key: 'package-extensions',
})
})
test('config set refuses writing workspace-specific settings to .npmrc', async () => {
test('config set writes workspace-specific settings to pnpm-workspace.yaml', async () => {
const tmp = tempDir()
const configDir = path.join(tmp, 'global-config')
fs.mkdirSync(configDir, { recursive: true })
fs.writeFileSync(path.join(tmp, '.npmrc'), 'store-dir=~/store')
const initConfig = {
globalRc: undefined,
globalYaml: undefined,
localRc: undefined,
localYaml: {
storeDir: '~/store',
},
} satisfies ConfigFilesData
writeConfigFiles(configDir, tmp, initConfig)
await expect(config.handler({
const catalog = { react: '19' }
await config.handler({
dir: process.cwd(),
cliOptions: {},
configDir,
location: 'project',
json: true,
rawConfig: {},
}, ['set', 'catalog', '{ "react": "19" }'])).rejects.toMatchObject({
code: 'ERR_PNPM_CONFIG_SET_UNSUPPORTED_RC_KEY',
key: 'catalog',
}, ['set', 'catalog', JSON.stringify(catalog)])
expect(readConfigFiles(configDir, tmp)).toEqual({
...initConfig,
localYaml: {
...initConfig.localYaml,
catalog,
},
})
await expect(config.handler({
dir: process.cwd(),
cliOptions: {},
configDir,
location: 'project',
json: true,
rawConfig: {},
}, ['set', 'packageExtensions', JSON.stringify({
const packageExtensions = {
'@babel/parser': {
peerDependencies: {
'@babel/types': '*',
@@ -430,32 +734,22 @@ test('config set refuses writing workspace-specific settings to .npmrc', async (
slash: '3',
},
},
})])).rejects.toMatchObject({
code: 'ERR_PNPM_CONFIG_SET_UNSUPPORTED_RC_KEY',
key: 'packageExtensions',
})
await expect(config.handler({
}
await config.handler({
dir: process.cwd(),
cliOptions: {},
configDir,
location: 'project',
json: true,
rawConfig: {},
}, ['set', 'package-extensions', JSON.stringify({
'@babel/parser': {
peerDependencies: {
'@babel/types': '*',
},
}, ['set', 'packageExtensions', JSON.stringify(packageExtensions)])
expect(readConfigFiles(configDir, tmp)).toEqual({
...initConfig,
localYaml: {
...initConfig.localYaml,
catalog,
packageExtensions,
},
'jest-circus': {
dependencies: {
slash: '3',
},
},
})])).rejects.toMatchObject({
code: 'ERR_PNPM_CONFIG_SET_UNSUPPORTED_RC_KEY',
key: 'package-extensions',
})
})
@@ -526,6 +820,8 @@ test('config set scoped registry with --location=project should create .npmrc',
expect(fs.existsSync(path.join(tmp, 'pnpm-workspace.yaml'))).toBeFalsy()
})
// NOTE: this test gives false positive since <https://github.com/pnpm/pnpm/pull/10145>.
// TODO: fix this test.
test('config set when both pnpm-workspace.yaml and .npmrc exist, pnpm-workspace.yaml has priority', async () => {
const tmp = tempDir()
const configDir = path.join(tmp, 'global-config')
@@ -550,6 +846,8 @@ test('config set when both pnpm-workspace.yaml and .npmrc exist, pnpm-workspace.
})
})
// NOTE: this test gives false positive since <https://github.com/pnpm/pnpm/pull/10145>.
// TODO: fix this test.
test('config set when only pnpm-workspace.yaml exists, writes to it', async () => {
const tmp = tempDir()
const configDir = path.join(tmp, 'global-config')

View File

@@ -1,3 +1,9 @@
import fs from 'fs'
import path from 'path'
import { readIniFileSync } from 'read-ini-file'
import { writeIniFileSync } from 'write-ini-file'
import { sync as readYamlFile } from 'read-yaml-file'
import { sync as writeYamlFile } from 'write-yaml-file'
import { type config } from '../../src/index.js'
export function getOutputString (result: config.ConfigHandlerResult): string {
@@ -7,3 +13,64 @@ export function getOutputString (result: config.ConfigHandlerResult): string {
const _typeGuard: never = result // eslint-disable-line @typescript-eslint/no-unused-vars
throw new Error('unreachable')
}
export interface ConfigFilesData {
globalRc: Record<string, unknown> | undefined
globalYaml: Record<string, unknown> | undefined
localRc: Record<string, unknown> | undefined
localYaml: Record<string, unknown> | undefined
}
export function readConfigFiles (globalConfigDir: string | undefined, localDir: string | undefined): ConfigFilesData {
function tryRead<Return> (reader: () => Return): Return | undefined {
try {
return reader()
} catch (error) {
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
return undefined
}
throw error
}
}
return {
globalRc: globalConfigDir
? tryRead(() => readIniFileSync(path.join(globalConfigDir, 'rc')) as Record<string, unknown>)
: undefined,
globalYaml: globalConfigDir
? tryRead(() => readYamlFile(path.join(globalConfigDir, 'config.yaml')))
: undefined,
localRc: localDir
? tryRead(() => readIniFileSync(path.join(localDir, '.npmrc')) as Record<string, unknown>)
: undefined,
localYaml: localDir
? tryRead(() => readYamlFile(path.join(localDir, 'pnpm-workspace.yaml')))
: undefined,
}
}
export function writeConfigFiles (globalConfigDir: string | undefined, localDir: string | undefined, data: ConfigFilesData): void {
if (globalConfigDir) {
fs.mkdirSync(globalConfigDir, { recursive: true })
if (data.globalRc) {
writeIniFileSync(path.join(globalConfigDir, 'rc'), data.globalRc)
}
if (data.globalYaml) {
writeYamlFile(path.join(globalConfigDir, 'config.yaml'), data.globalYaml)
}
}
if (localDir) {
fs.mkdirSync(localDir, { recursive: true })
if (data.localRc) {
writeIniFileSync(path.join(localDir, '.npmrc'), data.localRc)
}
if (data.localYaml) {
writeYamlFile(path.join(localDir, 'pnpm-workspace.yaml'), data.localYaml)
}
}
}

View File

@@ -24,6 +24,9 @@
{
"path": "../../object/property-path"
},
{
"path": "../../packages/constants"
},
{
"path": "../../packages/error"
},

View File

@@ -8,6 +8,7 @@ export const ENGINE_NAME = `${process.platform};${process.arch};node${process.ve
export const LAYOUT_VERSION = 5
export const STORE_VERSION = 'v10'
export const GLOBAL_CONFIG_YAML_FILENAME = 'config.yaml'
export const WORKSPACE_MANIFEST_FILENAME = 'pnpm-workspace.yaml'
// This file contains meta information

6
pnpm-lock.yaml generated
View File

@@ -1944,6 +1944,9 @@ importers:
'@pnpm/config':
specifier: workspace:*
version: link:../config
'@pnpm/constants':
specifier: workspace:*
version: link:../../packages/constants
'@pnpm/error':
specifier: workspace:*
version: link:../../packages/error
@@ -1980,6 +1983,9 @@ importers:
write-ini-file:
specifier: 'catalog:'
version: 4.0.1
write-yaml-file:
specifier: 'catalog:'
version: 5.0.0
devDependencies:
'@jest/globals':
specifier: 'catalog:'

View File

@@ -1,4 +1,5 @@
import fs from 'fs'
import path from 'path'
import { sync as writeYamlFile } from 'write-yaml-file'
import { type WorkspaceManifest } from '@pnpm/workspace.read-manifest'
import { prepare } from '@pnpm/prepare'
@@ -228,3 +229,58 @@ test('pnpm config get "" gives exactly the same result as pnpm config list', ()
expect(getResult.stdout.toString()).toBe(listResult.stdout.toString())
}
})
test('pnpm config get shows settings from global config.yaml', () => {
prepare()
const XDG_CONFIG_HOME = path.resolve('.config')
const configDir = path.join(XDG_CONFIG_HOME, 'pnpm')
fs.mkdirSync(configDir, { recursive: true })
writeYamlFile(path.join(configDir, 'config.yaml'), {
dangerouslyAllowAllBuilds: true,
dlxCacheMaxAge: 1234,
dev: true,
frozenLockfile: true,
catalog: {
react: '^19.0.0',
},
packages: ['baz', 'qux'],
packageExtensions: {
'@babel/parser': {
peerDependencies: {
'@babel/types': '*',
},
},
'jest-circus': {
dependencies: {
slash: '3',
},
},
},
})
const configGet = (key: string) => execPnpmSync(['config', 'get', key], {
expectSuccess: true,
env: {
XDG_CONFIG_HOME,
},
}).stdout.toString().trim()
// lists keys that belong to global
expect(configGet('dangerouslyAllowAllBuilds')).toBe('true')
expect(configGet('dangerously-allow-all-builds')).toBe('true')
expect(configGet('dlxCacheMaxAge')).toBe('1234')
expect(configGet('dlx-cache-max-age')).toBe('1234')
// doesn't list CLI options
expect(configGet('dev')).toBe('undefined')
expect(configGet('frozenLockfile')).toBe('undefined')
expect(configGet('frozen-lockfile')).toBe('undefined')
// doesn't list workspace-specific keys
expect(configGet('catalog')).toBe('undefined')
expect(configGet('catalogs')).toBe('undefined')
expect(configGet('packages')).toBe('undefined')
expect(configGet('packageExtensions')).toBe('undefined')
expect(configGet('package-extensions')).toBe('undefined')
})

View File

@@ -1,4 +1,5 @@
import fs from 'fs'
import path from 'path'
import { sync as writeYamlFile } from 'write-yaml-file'
import { type Config } from '@pnpm/config'
import { prepare } from '@pnpm/prepare'
@@ -141,3 +142,60 @@ test('pnpm config list --json shows all keys in camelCase', () => {
expect(JSON.parse(stdout.toString())).not.toHaveProperty(['only-built-dependencies'])
expect(JSON.parse(stdout.toString())).not.toHaveProperty(['package-extensions'])
})
test('pnpm config list shows settings from global config.yaml', () => {
prepare()
const XDG_CONFIG_HOME = path.resolve('.config')
const configDir = path.join(XDG_CONFIG_HOME, 'pnpm')
fs.mkdirSync(configDir, { recursive: true })
writeYamlFile(path.join(configDir, 'config.yaml'), {
dangerouslyAllowAllBuilds: true,
dlxCacheMaxAge: 1234,
dev: true,
frozenLockfile: true,
catalog: {
react: '^19.0.0',
},
packages: ['baz', 'qux'],
packageExtensions: {
'@babel/parser': {
peerDependencies: {
'@babel/types': '*',
},
},
'jest-circus': {
dependencies: {
slash: '3',
},
},
},
})
const { stdout } = execPnpmSync(['config', 'list'], {
expectSuccess: true,
env: {
XDG_CONFIG_HOME,
},
})
expect(JSON.parse(stdout.toString())).toStrictEqual(expect.objectContaining({
dangerouslyAllowAllBuilds: true,
dlxCacheMaxAge: 1234,
}))
// doesn't list CLI options
expect(JSON.parse(stdout.toString())).not.toHaveProperty(['dev'])
expect(JSON.parse(stdout.toString())).not.toHaveProperty(['frozenLockfile'])
// doesn't list workspace-specific settings
expect(JSON.parse(stdout.toString())).not.toHaveProperty(['catalog'])
expect(JSON.parse(stdout.toString())).not.toHaveProperty(['catalogs'])
expect(JSON.parse(stdout.toString())).not.toHaveProperty(['packages'])
expect(JSON.parse(stdout.toString())).not.toHaveProperty(['packageExtensions'])
// doesn't list the kebab-case versions
expect(JSON.parse(stdout.toString())).not.toHaveProperty(['frozen-lockfile'])
expect(JSON.parse(stdout.toString())).not.toHaveProperty(['only-built-dependencies'])
expect(JSON.parse(stdout.toString())).not.toHaveProperty(['dlx-cache-max-age'])
expect(JSON.parse(stdout.toString())).not.toHaveProperty(['package-extensions'])
})

View File

@@ -20,20 +20,6 @@ test('switch to the pnpm version specified in the packageManager field of packag
expect(stdout.toString()).toContain('Version 9.3.0')
})
test('do not switch to the pnpm version specified in the packageManager field of package.json, if manage-package-manager-versions is set to false (backward-compatibility)', async () => {
prepare()
const pnpmHome = path.resolve('pnpm')
const env = { PNPM_HOME: pnpmHome }
fs.writeFileSync('.npmrc', 'manage-package-manager-versions=false')
writeJsonFileSync('package.json', {
packageManager: 'pnpm@9.3.0',
})
const { stdout } = execPnpmSync(['help'], { env })
expect(stdout.toString()).not.toContain('Version 9.3.0')
})
test('do not switch to the pnpm version specified in the packageManager field of package.json, if managePackageManagerVersions is set to false', async () => {
prepare()
const pnpmHome = path.resolve('pnpm')
@@ -91,19 +77,17 @@ test('do not switch to pnpm version when a range is specified', async () => {
test('throws error if pnpm tools dir is corrupt', () => {
prepare()
const config = ['--config.manage-package-manager-versions=true'] as const
const pnpmHome = path.resolve('pnpm')
const env = { PNPM_HOME: pnpmHome }
const version = '9.3.0'
// NOTE: replace this .npmrc file with an equivalent pnpm-workspace.yaml would cause the test to hang indefinitely.
fs.writeFileSync('.npmrc', 'manage-package-manager-versions=true')
writeJsonFileSync('package.json', {
packageManager: `pnpm@${version}`,
})
// Run pnpm once to ensure the tools dir is created.
execPnpmSync(['help'], { env })
execPnpmSync([...config, 'help'], { env })
// Intentionally corrupt the tool dir.
const toolDir = getToolDirPath({ pnpmHomeDir: pnpmHome, tool: { name: 'pnpm', version } })
@@ -112,6 +96,6 @@ test('throws error if pnpm tools dir is corrupt', () => {
fs.rmSync(path.join(toolDir, 'bin/pnpm.cmd'))
}
const { stderr } = execPnpmSync(['help'], { env })
const { stderr } = execPnpmSync([...config, 'help'], { env })
expect(stderr.toString()).toContain('Failed to switch pnpm to v9.3.0. Looks like pnpm CLI is missing')
})

View File

@@ -3,7 +3,7 @@ import path from 'path'
import { type Catalogs } from '@pnpm/catalogs.types'
import { type ResolvedCatalogEntry } from '@pnpm/lockfile.types'
import { readWorkspaceManifest, type WorkspaceManifest } from '@pnpm/workspace.read-manifest'
import { WORKSPACE_MANIFEST_FILENAME } from '@pnpm/constants'
import { type GLOBAL_CONFIG_YAML_FILENAME, WORKSPACE_MANIFEST_FILENAME } from '@pnpm/constants'
import writeYamlFile from 'write-yaml-file'
import { equals } from 'ramda'
import { sortKeysByPriority } from '@pnpm/object.key-sorting'
@@ -11,12 +11,18 @@ import {
type Project,
} from '@pnpm/types'
async function writeManifestFile (dir: string, manifest: Partial<WorkspaceManifest>): Promise<void> {
export type FileName =
| typeof GLOBAL_CONFIG_YAML_FILENAME
| typeof WORKSPACE_MANIFEST_FILENAME
const DEFAULT_FILENAME: FileName = WORKSPACE_MANIFEST_FILENAME
async function writeManifestFile (dir: string, fileName: FileName, manifest: Partial<WorkspaceManifest>): Promise<void> {
manifest = sortKeysByPriority({
priority: { packages: 0 },
deep: true,
}, manifest)
return writeYamlFile(path.join(dir, WORKSPACE_MANIFEST_FILENAME), manifest, {
return writeYamlFile(path.join(dir, fileName), manifest, {
lineWidth: -1, // This is setting line width to never wrap
blankLines: true,
noCompatMode: true,
@@ -28,10 +34,12 @@ async function writeManifestFile (dir: string, manifest: Partial<WorkspaceManife
export async function updateWorkspaceManifest (dir: string, opts: {
updatedFields?: Partial<WorkspaceManifest>
updatedCatalogs?: Catalogs
fileName?: FileName
cleanupUnusedCatalogs?: boolean
allProjects?: Project[]
}): Promise<void> {
const manifest = await readWorkspaceManifest(dir) ?? {} as WorkspaceManifest
const fileName = opts.fileName ?? DEFAULT_FILENAME
const manifest = await readWorkspaceManifest(dir, fileName) ?? {} as WorkspaceManifest
let shouldBeUpdated = opts.updatedCatalogs != null && addCatalogs(manifest, opts.updatedCatalogs)
if (opts.cleanupUnusedCatalogs) {
shouldBeUpdated = removePackagesFromWorkspaceCatalog(manifest, opts.allProjects ?? []) || shouldBeUpdated
@@ -52,10 +60,10 @@ export async function updateWorkspaceManifest (dir: string, opts: {
return
}
if (Object.keys(manifest).length === 0) {
await fs.promises.rm(path.join(dir, WORKSPACE_MANIFEST_FILENAME))
await fs.promises.rm(path.join(dir, fileName))
return
}
await writeManifestFile(dir, manifest)
await writeManifestFile(dir, fileName, manifest)
}
export interface NewCatalogs {

View File

@@ -1,5 +1,5 @@
import util from 'util'
import { WORKSPACE_MANIFEST_FILENAME } from '@pnpm/constants'
import { type GLOBAL_CONFIG_YAML_FILENAME, WORKSPACE_MANIFEST_FILENAME } from '@pnpm/constants'
import { type PnpmSettings } from '@pnpm/types'
import path from 'node:path'
import readYamlFile from 'read-yaml-file'
@@ -11,6 +11,10 @@ import {
} from './catalogs.js'
import { InvalidWorkspaceManifestError } from './errors/InvalidWorkspaceManifestError.js'
export type ConfigFileName =
| typeof GLOBAL_CONFIG_YAML_FILENAME
| typeof WORKSPACE_MANIFEST_FILENAME
export interface WorkspaceManifest extends PnpmSettings {
packages: string[]
@@ -28,15 +32,15 @@ export interface WorkspaceManifest extends PnpmSettings {
catalogs?: WorkspaceNamedCatalogs
}
export async function readWorkspaceManifest (dir: string): Promise<WorkspaceManifest | undefined> {
const manifest = await readManifestRaw(dir)
export async function readWorkspaceManifest (dir: string, cfgFileName: ConfigFileName = WORKSPACE_MANIFEST_FILENAME): Promise<WorkspaceManifest | undefined> {
const manifest = await readManifestRaw(dir, cfgFileName)
validateWorkspaceManifest(manifest)
return manifest
}
async function readManifestRaw (dir: string): Promise<unknown> {
async function readManifestRaw (dir: string, cfgFileName: ConfigFileName): Promise<unknown> {
try {
return await readYamlFile.default<WorkspaceManifest>(path.join(dir, WORKSPACE_MANIFEST_FILENAME))
return await readYamlFile.default<WorkspaceManifest>(path.join(dir, cfgFileName))
} catch (err: unknown) {
// File not exists is the same as empty file (undefined)
if (util.types.isNativeError(err) && 'code' in err && err.code === 'ENOENT') {