diff --git a/.changeset/great-trams-drop.md b/.changeset/great-trams-drop.md new file mode 100644 index 0000000000..d9a61b3818 --- /dev/null +++ b/.changeset/great-trams-drop.md @@ -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`. diff --git a/config/config/src/auth.ts b/config/config/src/auth.ts index 6e096f9dbc..8a7913668c 100644 --- a/config/config/src/auth.ts +++ b/config/config/src/auth.ts @@ -40,16 +40,8 @@ const AUTH_CFG_KEYS = [ 'strictSsl', ] satisfies Array -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 - 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: RawConfig): Partial { +/** + * Filter keys that are allowed to be read from an INI config file. + */ +export function pickIniConfig> (rawConfig: RawConfig): Partial { const result: Partial = {} for (const key in rawConfig) { - if (isSupportedNpmConfig(key)) { + if (isIniConfigKey(key)) { result[key] = rawConfig[key] } } diff --git a/config/config/src/configFileKey.ts b/config/config/src/configFileKey.ts new file mode 100644 index 0000000000..ea17dc5b59 --- /dev/null +++ b/config/config/src/configFileKey.ts @@ -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> +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): 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 + +/** Key that is valid in a global config file. */ +export type ConfigFileKey = NpmConfigFileKey | PnpmConfigFileKey + +const setOfPnpmConfigFilesKeys: ReadonlySet = new Set(pnpmConfigFileKeys) +const setOfExcludedPnpmKeys: ReadonlySet = 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)) diff --git a/config/config/src/getOptionsFromRootManifest.ts b/config/config/src/getOptionsFromRootManifest.ts index eded043678..a6b629432e 100644 --- a/config/config/src/getOptionsFromRootManifest.ts +++ b/config/config/src/getOptionsFromRootManifest.ts @@ -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 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) } } diff --git a/config/config/src/index.ts b/config/config/src/index.ts index fa8383403f..a9cea77103 100644 --- a/config/config/src/index.ts +++ b/config/config/src/index.ts @@ -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 `${infer T}${infer U}` ? `${T extends Capitalize ? '-' : ''}${Lowercase}${CamelToKebabCase}` : 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)[configKey], + isIniConfigKey(configKey) ? npmConfig.get(configKey) : (defaultOptions as Record)[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 + 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) +} diff --git a/config/config/src/types.ts b/config/config/src/types.ts index 2d397310d0..df84071b0e 100644 --- a/config/config/src/types.ts +++ b/config/config/src/types.ts @@ -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, +} diff --git a/config/config/test/index.ts b/config/config/test/index.ts index 14d154613f..1ef05e9eb0 100644 --- a/config/config/test/index.ts +++ b/config/config/test/index.ts @@ -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']) + }) +}) diff --git a/config/plugin-commands-config/package.json b/config/plugin-commands-config/package.json index 069e1d159b..abdd5b2140 100644 --- a/config/plugin-commands-config/package.json +++ b/config/plugin-commands-config/package.json @@ -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:" diff --git a/config/plugin-commands-config/src/configSet.ts b/config/plugin-commands-config/src/configSet.ts index cbb2179b47..2baebb3713 100644 --- a/config/plugin-commands-config/src/configSet.ts +++ b/config/plugin-commands-config/src/configSet.ts @@ -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 { @@ -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): 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 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 } + } +} diff --git a/config/plugin-commands-config/src/getConfigFilePath.ts b/config/plugin-commands-config/src/getConfigFilePath.ts deleted file mode 100644 index 8532b5bd46..0000000000 --- a/config/plugin-commands-config/src/getConfigFilePath.ts +++ /dev/null @@ -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): 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, - } -} diff --git a/config/plugin-commands-config/src/settingShouldFallBackToNpm.ts b/config/plugin-commands-config/src/settingShouldFallBackToNpm.ts index aa44437f9f..7fd0efac09 100644 --- a/config/plugin-commands-config/src/settingShouldFallBackToNpm.ts +++ b/config/plugin-commands-config/src/settingShouldFallBackToNpm.ts @@ -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) || diff --git a/config/plugin-commands-config/test/configDelete.test.ts b/config/plugin-commands-config/test/configDelete.test.ts index b38dbd5450..ff1e86d30b 100644 --- a/config/plugin-commands-config/test/configDelete.test.ts +++ b/config/plugin-commands-config/test/configDelete.test.ts @@ -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') +}) diff --git a/config/plugin-commands-config/test/configSet.test.ts b/config/plugin-commands-config/test/configSet.test.ts index c779f5ad96..95631e31cb 100644 --- a/config/plugin-commands-config/test/configSet.test.ts +++ b/config/plugin-commands-config/test/configSet.test.ts @@ -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 . +// 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 . +// 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') diff --git a/config/plugin-commands-config/test/utils/index.ts b/config/plugin-commands-config/test/utils/index.ts index 05098cc56f..bf76f7fae5 100644 --- a/config/plugin-commands-config/test/utils/index.ts +++ b/config/plugin-commands-config/test/utils/index.ts @@ -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 | undefined + globalYaml: Record | undefined + localRc: Record | undefined + localYaml: Record | undefined +} + +export function readConfigFiles (globalConfigDir: string | undefined, localDir: string | undefined): ConfigFilesData { + function tryRead (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) + : undefined, + globalYaml: globalConfigDir + ? tryRead(() => readYamlFile(path.join(globalConfigDir, 'config.yaml'))) + : undefined, + localRc: localDir + ? tryRead(() => readIniFileSync(path.join(localDir, '.npmrc')) as Record) + : 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) + } + } +} diff --git a/config/plugin-commands-config/tsconfig.json b/config/plugin-commands-config/tsconfig.json index e6f3cc84f6..78fdc4be6f 100644 --- a/config/plugin-commands-config/tsconfig.json +++ b/config/plugin-commands-config/tsconfig.json @@ -24,6 +24,9 @@ { "path": "../../object/property-path" }, + { + "path": "../../packages/constants" + }, { "path": "../../packages/error" }, diff --git a/packages/constants/src/index.ts b/packages/constants/src/index.ts index 3c1c1d57d8..6c15205add 100644 --- a/packages/constants/src/index.ts +++ b/packages/constants/src/index.ts @@ -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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a32f3bcc2..af721db36d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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:' diff --git a/pnpm/test/config/get.ts b/pnpm/test/config/get.ts index 7380978d23..9798b3b87c 100644 --- a/pnpm/test/config/get.ts +++ b/pnpm/test/config/get.ts @@ -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') +}) diff --git a/pnpm/test/config/list.ts b/pnpm/test/config/list.ts index e8b2e3f97c..b170c285c4 100644 --- a/pnpm/test/config/list.ts +++ b/pnpm/test/config/list.ts @@ -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']) +}) diff --git a/pnpm/test/switchingVersions.test.ts b/pnpm/test/switchingVersions.test.ts index 364d280b7c..3ece56b7e5 100644 --- a/pnpm/test/switchingVersions.test.ts +++ b/pnpm/test/switchingVersions.test.ts @@ -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') }) diff --git a/workspace/manifest-writer/src/index.ts b/workspace/manifest-writer/src/index.ts index 67b3e83f18..44e8e63f8d 100644 --- a/workspace/manifest-writer/src/index.ts +++ b/workspace/manifest-writer/src/index.ts @@ -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): Promise { +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): Promise { 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 updatedCatalogs?: Catalogs + fileName?: FileName cleanupUnusedCatalogs?: boolean allProjects?: Project[] }): Promise { - 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 { diff --git a/workspace/read-manifest/src/index.ts b/workspace/read-manifest/src/index.ts index 5ed7a8fcb5..3b4abadca4 100644 --- a/workspace/read-manifest/src/index.ts +++ b/workspace/read-manifest/src/index.ts @@ -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 { - const manifest = await readManifestRaw(dir) +export async function readWorkspaceManifest (dir: string, cfgFileName: ConfigFileName = WORKSPACE_MANIFEST_FILENAME): Promise { + const manifest = await readManifestRaw(dir, cfgFileName) validateWorkspaceManifest(manifest) return manifest } -async function readManifestRaw (dir: string): Promise { +async function readManifestRaw (dir: string, cfgFileName: ConfigFileName): Promise { try { - return await readYamlFile.default(path.join(dir, WORKSPACE_MANIFEST_FILENAME)) + return await readYamlFile.default(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') {