feat(config)!: project-specific packageConfigs (#10304)

* feat(config)!: project level `config.yaml`

* test: fix

* refactor: shorten some names

* docs(changeset): change wording

* feat: move project settings to pnpm-workspace.yaml

* test: remove unneeded fixture

* docs(changeset): correct

* refactor: replace validation with creation

* docs: consistent terminology

* perf: validate once

* test: projectConfig

* refactor: explicitly use `undefined`

* refactor: reuse `ProjectConfigRecord`

* chore(deps): remove unused dependency

* style: remove extra pipe character

* refactor: rename to `projectConfigs`

* feat: flatten `projectConfig` with `match`

* refactor: correct error class names

* docs(changeset): update

* test: fix

* feat: rename to `packageConfigs`

Rename `projectConfigs` to `packageConfigs` in the workspace manifest.

The term "project config" is still used internally, because, internally,
"project" refers to workspace packages whilst "package" refers to 3rd party
packages and dependencies.

* docs(changeset): clarify `project-N`
This commit is contained in:
Khải
2025-12-21 18:01:18 +07:00
committed by GitHub
parent 8b5dcaac4d
commit 90bd3c31f8
16 changed files with 843 additions and 99 deletions

View File

@@ -0,0 +1,33 @@
---
"@pnpm/config": major
"pnpm": major
---
Replace workspace project specific `.npmrc` with `packageConfigs` in `pnpm-workspace.yaml`.
A workspace manifest with `packageConfigs` would look something like this:
```yaml
# File: pnpm-workspace.yaml
packages:
- 'packages/project-1'
- 'packages/project-2'
packageConfigs:
'project-1':
saveExact: true
'project-2':
savePrefix: '~'
```
Or this:
```yaml
# File: pnpm-workspace.yaml
packages:
- 'packages/project-1'
- 'packages/project-2'
packageConfigs:
- match: ['project-1', 'project-2']
modulesDir: 'node_modules'
saveExact: true
```

View File

@@ -237,6 +237,8 @@ export interface Config extends OptionsFromRootManifest {
fetchMinSpeedKiBps?: number
trustPolicy?: TrustPolicy
trustPolicyExclude?: string[]
packageConfigs?: ProjectConfigSet
}
export interface ConfigWithDeprecatedSettings extends Config {
@@ -244,3 +246,22 @@ export interface ConfigWithDeprecatedSettings extends Config {
proxy?: string
shamefullyFlatten?: boolean
}
export const PROJECT_CONFIG_FIELDS = [
'hoist',
'modulesDir',
'saveExact',
'savePrefix',
] as const satisfies Array<keyof Config>
export type ProjectConfig = Partial<Pick<Config, typeof PROJECT_CONFIG_FIELDS[number] | 'hoistPattern'>>
/** Simple map from project names to {@link ProjectConfig} */
export type ProjectConfigRecord = Record<string, ProjectConfig>
/** Map multiple project names to a shared {@link ProjectConfig} */
export type ProjectConfigMultiMatch = { match: string[] } & ProjectConfig
export type ProjectConfigSet =
| ProjectConfigRecord
| ProjectConfigMultiMatch[]

View File

@@ -31,6 +31,7 @@ import { getCacheDir, getConfigDir, getDataDir, getStateDir } from './dirs.js'
import {
type Config,
type ConfigWithDeprecatedSettings,
type ProjectConfig,
type UniversalOptions,
type VerifyDepsBeforeRun,
type WantedPackageManager,
@@ -48,10 +49,22 @@ import {
export { types }
export { getOptionsFromRootManifest, getOptionsFromPnpmSettings, type OptionsFromRootManifest } from './getOptionsFromRootManifest.js'
export * from './readLocalConfig.js'
export { getDefaultWorkspaceConcurrency, getWorkspaceConcurrency } from './concurrency.js'
export type { Config, UniversalOptions, WantedPackageManager, VerifyDepsBeforeRun }
export {
ProjectConfigInvalidValueTypeError,
ProjectConfigIsNotAnObjectError,
ProjectConfigUnsupportedFieldError,
ProjectConfigsArrayItemIsNotAnObjectError,
ProjectConfigsArrayItemMatchIsNotAnArrayError,
ProjectConfigsArrayItemMatchIsNotDefinedError,
ProjectConfigsIsNeitherObjectNorArrayError,
ProjectConfigsMatchItemIsNotAStringError,
type CreateProjectConfigRecordOptions,
createProjectConfigRecord,
} from './projectConfig.js'
export type { Config, ProjectConfig, UniversalOptions, WantedPackageManager, VerifyDepsBeforeRun }
export { isIniConfigKey } from './auth.js'
export { type ConfigFileKey, isConfigFileKey } from './configFileKey.js'

View File

@@ -0,0 +1,153 @@
import { omit } from 'ramda'
import { PnpmError } from '@pnpm/error'
import { PROJECT_CONFIG_FIELDS, type Config, type ProjectConfig, type ProjectConfigRecord } from './Config.js'
export type CreateProjectConfigRecordOptions = Pick<Config, 'packageConfigs'>
export function createProjectConfigRecord (opts: CreateProjectConfigRecordOptions): ProjectConfigRecord | undefined {
return createProjectConfigRecordFromConfigSet(opts.packageConfigs)
}
export class ProjectConfigIsNotAnObjectError extends PnpmError {
readonly actualRawConfig: unknown
constructor (actualRawConfig: unknown) {
super('PROJECT_CONFIG_NOT_AN_OBJECT', `Expecting project-specific config to be an object, but received ${JSON.stringify(actualRawConfig)}`)
this.actualRawConfig = actualRawConfig
}
}
export class ProjectConfigInvalidValueTypeError extends PnpmError {
readonly expectedType: string
readonly actualType: string
readonly actualValue: unknown
constructor (expectedType: string, actualValue: unknown) {
const actualType = typeof actualValue
super('PROJECT_CONFIG_INVALID_VALUE_TYPE', `Expecting a value of type ${expectedType} but received a value of type ${actualType}: ${JSON.stringify(actualValue)}`)
this.expectedType = expectedType
this.actualType = actualType
this.actualValue = actualValue
}
}
export class ProjectConfigUnsupportedFieldError extends PnpmError {
readonly field: string
constructor (field: string) {
super('PROJECT_CONFIG_UNSUPPORTED_FIELD', `Field ${field} is not supported but was specified`)
this.field = field
}
}
function createProjectConfigFromRaw (config: unknown): ProjectConfig {
if (typeof config !== 'object' || !config || Array.isArray(config)) {
throw new ProjectConfigIsNotAnObjectError(config)
}
if ('hoist' in config && config.hoist !== undefined && typeof config.hoist !== 'boolean') {
throw new ProjectConfigInvalidValueTypeError('boolean', config.hoist)
}
if ('modulesDir' in config && config.modulesDir !== undefined && typeof config.modulesDir !== 'string') {
throw new ProjectConfigInvalidValueTypeError('string', config.modulesDir)
}
if ('saveExact' in config && config.saveExact !== undefined && typeof config.saveExact !== 'boolean') {
throw new ProjectConfigInvalidValueTypeError('boolean', config.saveExact)
}
if ('savePrefix' in config && config.savePrefix !== undefined && typeof config.savePrefix !== 'string') {
throw new ProjectConfigInvalidValueTypeError('string', config.savePrefix)
}
for (const key in config) {
if ((config as Record<string, unknown>)[key] !== undefined && !(PROJECT_CONFIG_FIELDS as string[]).includes(key)) {
throw new ProjectConfigUnsupportedFieldError(key)
}
}
const result: ProjectConfig = config
if (result.hoist === false) {
return { ...result, hoistPattern: undefined }
}
return result
}
export class ProjectConfigsIsNeitherObjectNorArrayError extends PnpmError {
readonly configSet: unknown
constructor (configSet: unknown) {
super('PROJECT_CONFIGS_IS_NEITHER_OBJECT_NOR_ARRAY', `Expecting packageConfigs to be either an object or an array but received ${JSON.stringify(configSet)}`)
this.configSet = configSet
}
}
export class ProjectConfigsArrayItemIsNotAnObjectError extends PnpmError {
readonly item: unknown
constructor (item: unknown) {
super('PROJECT_CONFIGS_ARRAY_ITEM_IS_NOT_AN_OBJECT', `Expecting a packageConfigs item to be an object but received ${JSON.stringify(item)}`)
this.item = item
}
}
export class ProjectConfigsArrayItemMatchIsNotDefinedError extends PnpmError {
constructor () {
super('PROJECT_CONFIGS_ARRAY_ITEM_MATCH_IS_NOT_DEFINED', 'A packageConfigs match is not defined')
}
}
export class ProjectConfigsArrayItemMatchIsNotAnArrayError extends PnpmError {
readonly match: unknown
constructor (match: unknown) {
super('PROJECT_CONFIGS_ARRAY_ITEM_MATCH_IS_NOT_AN_ARRAY', `Expecting a packageConfigs match to be an array but received ${JSON.stringify(match)}`)
this.match = match
}
}
export class ProjectConfigsMatchItemIsNotAStringError extends PnpmError {
readonly matchItem: unknown
constructor (matchItem: unknown) {
super('PROJECT_CONFIGS_MATCH_ITEM_IS_NOT_A_STRING', `Expecting a match item to be a string but received ${JSON.stringify(matchItem)}`)
this.matchItem = matchItem
}
}
const withoutMatch = omit(['match'])
function createProjectConfigRecordFromConfigSet (configSet: unknown): ProjectConfigRecord | undefined {
if (configSet == null) return undefined
if (typeof configSet !== 'object') throw new ProjectConfigsIsNeitherObjectNorArrayError(configSet)
const result: ProjectConfigRecord = {}
if (!Array.isArray(configSet)) {
for (const projectName in configSet) {
const projectConfig = (configSet as Record<string, unknown>)[projectName]
result[projectName] = createProjectConfigFromRaw(projectConfig)
}
return result
}
for (const item of configSet as unknown[]) {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
throw new ProjectConfigsArrayItemIsNotAnObjectError(item)
}
if (!('match' in item)) {
throw new ProjectConfigsArrayItemMatchIsNotDefinedError()
}
if (typeof item.match !== 'object' || !Array.isArray(item.match)) {
throw new ProjectConfigsArrayItemMatchIsNotAnArrayError(item.match)
}
const projectConfig = createProjectConfigFromRaw(withoutMatch(item))
for (const projectName of item.match as unknown[]) {
if (typeof projectName !== 'string') {
throw new ProjectConfigsMatchItemIsNotAStringError(projectName)
}
result[projectName] = projectConfig
}
}
return result
}

View File

@@ -1,35 +0,0 @@
import path from 'path'
import util from 'util'
import camelcaseKeys from 'camelcase-keys'
import { envReplace } from '@pnpm/config.env-replace'
import { readIniFile } from 'read-ini-file'
import { parseField } from '@pnpm/npm-conf/lib/util.js'
import { types } from './types.js'
export type LocalConfig = Record<string, string> & { hoist?: boolean }
export async function readLocalConfig (prefix: string): Promise<LocalConfig> {
try {
const ini = await readIniFile(path.join(prefix, '.npmrc')) as Record<string, string>
for (let [key, val] of Object.entries(ini)) {
if (typeof val === 'string') {
try {
key = envReplace(key, process.env)
ini[key] = parseField(types, envReplace(val, process.env), key) as any // eslint-disable-line
} catch {}
}
}
const config = camelcaseKeys(ini) as LocalConfig
if (config.shamefullyFlatten) {
config.hoistPattern = '*'
// TODO: print a warning
}
if (config.hoist === false) {
config.hoistPattern = ''
}
return config
} catch (err: unknown) {
if (util.types.isNativeError(err) && 'code' in err && err.code === 'ENOENT') return {}
throw err
}
}

View File

@@ -0,0 +1,548 @@
import { omit } from 'ramda'
import {
type Config,
type ProjectConfig,
type ProjectConfigMultiMatch,
type ProjectConfigRecord,
type ProjectConfigSet,
} from '../src/Config.js'
import { createProjectConfigRecord } from '../src/projectConfig.js'
it('returns undefined for undefined', () => {
expect(createProjectConfigRecord({})).toBeUndefined()
expect(createProjectConfigRecord({ packageConfigs: undefined })).toBeUndefined()
expect(createProjectConfigRecord({ packageConfigs: null as unknown as undefined })).toBeUndefined()
})
it('errors on invalid packageConfigs', () => {
expect(() => createProjectConfigRecord({
packageConfigs: 0 as unknown as ProjectConfigSet,
})).toThrow(expect.objectContaining({
configSet: 0,
code: 'ERR_PNPM_PROJECT_CONFIGS_IS_NEITHER_OBJECT_NOR_ARRAY',
}))
expect(() => createProjectConfigRecord({
packageConfigs: 'some string' as unknown as ProjectConfigSet,
})).toThrow(expect.objectContaining({
configSet: 'some string',
code: 'ERR_PNPM_PROJECT_CONFIGS_IS_NEITHER_OBJECT_NOR_ARRAY',
}))
expect(() => createProjectConfigRecord({
packageConfigs: true as unknown as ProjectConfigSet,
})).toThrow(expect.objectContaining({
configSet: true,
code: 'ERR_PNPM_PROJECT_CONFIGS_IS_NEITHER_OBJECT_NOR_ARRAY',
}))
})
describe('record', () => {
it('returns an empty record for an empty record', () => {
expect(createProjectConfigRecord({ packageConfigs: {} })).toStrictEqual({})
})
it('returns a valid record for a valid record', () => {
const packageConfigs: ProjectConfigRecord = {
'project-1': {
modulesDir: 'foo',
},
'project-2': {
saveExact: true,
},
'project-3': {
savePrefix: '~',
},
}
expect(createProjectConfigRecord({ packageConfigs })).toStrictEqual(packageConfigs)
})
it('explicitly sets hoistPattern to undefined when hoist is false', () => {
expect(createProjectConfigRecord({
packageConfigs: {
'project-1': { hoist: false },
},
})).toStrictEqual({
'project-1': {
hoist: false,
hoistPattern: undefined,
},
} as ProjectConfigRecord)
})
it('errors on invalid project config', () => {
expect(() => createProjectConfigRecord({
packageConfigs: {
'project-1': 0 as unknown as ProjectConfig,
},
})).toThrow(expect.objectContaining({
actualRawConfig: 0,
code: 'ERR_PNPM_PROJECT_CONFIG_NOT_AN_OBJECT',
}))
expect(() => createProjectConfigRecord({
packageConfigs: {
'project-1': 'some string' as unknown as ProjectConfig,
},
})).toThrow(expect.objectContaining({
actualRawConfig: 'some string',
code: 'ERR_PNPM_PROJECT_CONFIG_NOT_AN_OBJECT',
}))
expect(() => createProjectConfigRecord({
packageConfigs: {
'project-1': true as unknown as ProjectConfig,
},
})).toThrow(expect.objectContaining({
actualRawConfig: true,
code: 'ERR_PNPM_PROJECT_CONFIG_NOT_AN_OBJECT',
}))
expect(() => createProjectConfigRecord({
packageConfigs: {
'project-1': null as unknown as ProjectConfig,
},
})).toThrow(expect.objectContaining({
actualRawConfig: null,
code: 'ERR_PNPM_PROJECT_CONFIG_NOT_AN_OBJECT',
}))
expect(() => createProjectConfigRecord({
packageConfigs: {
'project-1': [0, 1, 2] as unknown as ProjectConfig,
},
})).toThrow(expect.objectContaining({
actualRawConfig: [0, 1, 2],
code: 'ERR_PNPM_PROJECT_CONFIG_NOT_AN_OBJECT',
}))
})
it('errors on invalid hoist', () => {
expect(() => createProjectConfigRecord({
packageConfigs: {
'project-1': { hoist: 'invalid' as unknown as boolean },
},
})).toThrow(expect.objectContaining({
expectedType: 'boolean',
actualValue: 'invalid',
code: 'ERR_PNPM_PROJECT_CONFIG_INVALID_VALUE_TYPE',
}))
expect(() => createProjectConfigRecord({
packageConfigs: {
'project-1': { hoist: 0 as unknown as boolean },
},
})).toThrow(expect.objectContaining({
expectedType: 'boolean',
actualValue: 0,
code: 'ERR_PNPM_PROJECT_CONFIG_INVALID_VALUE_TYPE',
}))
})
it('errors on invalid modulesDir', () => {
expect(() => createProjectConfigRecord({
packageConfigs: {
'project-1': { modulesDir: 0 as unknown as string },
},
})).toThrow(expect.objectContaining({
expectedType: 'string',
actualValue: 0,
code: 'ERR_PNPM_PROJECT_CONFIG_INVALID_VALUE_TYPE',
}))
expect(() => createProjectConfigRecord({
packageConfigs: {
'project-1': { modulesDir: true as unknown as string },
},
})).toThrow(expect.objectContaining({
expectedType: 'string',
actualValue: true,
code: 'ERR_PNPM_PROJECT_CONFIG_INVALID_VALUE_TYPE',
}))
})
it('errors on invalid saveExact', () => {
expect(() => createProjectConfigRecord({
packageConfigs: {
'project-1': { saveExact: 'invalid' as unknown as boolean },
},
})).toThrow(expect.objectContaining({
expectedType: 'boolean',
actualValue: 'invalid',
code: 'ERR_PNPM_PROJECT_CONFIG_INVALID_VALUE_TYPE',
}))
expect(() => createProjectConfigRecord({
packageConfigs: {
'project-1': { saveExact: 0 as unknown as boolean },
},
})).toThrow(expect.objectContaining({
expectedType: 'boolean',
actualValue: 0,
code: 'ERR_PNPM_PROJECT_CONFIG_INVALID_VALUE_TYPE',
}))
})
it('errors on invalid savePrefix', () => {
expect(() => createProjectConfigRecord({
packageConfigs: {
'project-1': { savePrefix: 0 as unknown as string },
},
})).toThrow(expect.objectContaining({
expectedType: 'string',
actualValue: 0,
code: 'ERR_PNPM_PROJECT_CONFIG_INVALID_VALUE_TYPE',
}))
expect(() => createProjectConfigRecord({
packageConfigs: {
'project-1': { savePrefix: false as unknown as string },
},
})).toThrow(expect.objectContaining({
expectedType: 'string',
actualValue: false,
code: 'ERR_PNPM_PROJECT_CONFIG_INVALID_VALUE_TYPE',
}))
})
it('errors on unsupported fields', () => {
expect(() => createProjectConfigRecord({
packageConfigs: {
'project-1': {
ignoreScripts: true,
} as Partial<Config>,
},
})).toThrow(expect.objectContaining({
field: 'ignoreScripts',
code: 'ERR_PNPM_PROJECT_CONFIG_UNSUPPORTED_FIELD',
}))
expect(() => createProjectConfigRecord({
packageConfigs: {
'project-1': {
hoistPattern: ['*'],
} as Partial<Config>,
},
})).toThrow(expect.objectContaining({
field: 'hoistPattern',
code: 'ERR_PNPM_PROJECT_CONFIG_UNSUPPORTED_FIELD',
}))
})
it('does not error on unsupported but undefined fields', () => {
expect(createProjectConfigRecord({
packageConfigs: {
'project-1': {
ignoreScripts: undefined,
hoistPattern: undefined,
} as Partial<Config>,
},
})).toStrictEqual({
'project-1': {
ignoreScripts: undefined,
hoistPattern: undefined,
} as Partial<Config>,
})
})
})
describe('array', () => {
type ProjectConfigWithExtraFields = Pick<ProjectConfigMultiMatch, 'match'> & Partial<Config>
it('returns an empty record for an empty array', () => {
expect(createProjectConfigRecord({ packageConfigs: [] })).toStrictEqual({})
})
it('returns a map of project-specific settings for a non-empty array', () => {
const withoutMatch: (withMatch: ProjectConfigMultiMatch) => ProjectConfig = omit(['match'])
const packageConfigs = [
{
match: ['project-1'],
modulesDir: 'foo',
},
{
match: ['project-2', 'project-3'],
saveExact: true,
},
{
match: ['project-4', 'project-5', 'project-6'],
savePrefix: '~',
},
] as const satisfies ProjectConfigMultiMatch[]
const record: ProjectConfigRecord | undefined = createProjectConfigRecord({ packageConfigs })
expect(record).toStrictEqual({
'project-1': withoutMatch(packageConfigs[0]),
'project-2': withoutMatch(packageConfigs[1]),
'project-3': withoutMatch(packageConfigs[1]),
'project-4': withoutMatch(packageConfigs[2]),
'project-5': withoutMatch(packageConfigs[2]),
'project-6': withoutMatch(packageConfigs[2]),
} as ProjectConfigRecord)
expect(createProjectConfigRecord({ packageConfigs: record })).toStrictEqual(record)
})
it('explicitly sets hoistPattern to undefined when hoist is false', () => {
expect(createProjectConfigRecord({
packageConfigs: [{
match: ['project-1'],
hoist: false,
}],
})).toStrictEqual({
'project-1': {
hoist: false,
hoistPattern: undefined,
},
} as ProjectConfigRecord)
})
it('errors on invalid array items', () => {
expect(() => createProjectConfigRecord({
packageConfigs: [0 as unknown as ProjectConfigMultiMatch],
})).toThrow(expect.objectContaining({
item: 0,
code: 'ERR_PNPM_PROJECT_CONFIGS_ARRAY_ITEM_IS_NOT_AN_OBJECT',
}))
expect(() => createProjectConfigRecord({
packageConfigs: ['some string' as unknown as ProjectConfigMultiMatch],
})).toThrow(expect.objectContaining({
item: 'some string',
code: 'ERR_PNPM_PROJECT_CONFIGS_ARRAY_ITEM_IS_NOT_AN_OBJECT',
}))
expect(() => createProjectConfigRecord({
packageConfigs: [true as unknown as ProjectConfigMultiMatch],
})).toThrow(expect.objectContaining({
item: true,
code: 'ERR_PNPM_PROJECT_CONFIGS_ARRAY_ITEM_IS_NOT_AN_OBJECT',
}))
expect(() => createProjectConfigRecord({
packageConfigs: [null as unknown as ProjectConfigMultiMatch],
})).toThrow(expect.objectContaining({
item: null,
code: 'ERR_PNPM_PROJECT_CONFIGS_ARRAY_ITEM_IS_NOT_AN_OBJECT',
}))
})
it('errors on undefined match', () => {
expect(() => createProjectConfigRecord({
packageConfigs: [{} as ProjectConfigMultiMatch],
})).toThrow(expect.objectContaining({
code: 'ERR_PNPM_PROJECT_CONFIGS_ARRAY_ITEM_MATCH_IS_NOT_DEFINED',
}))
})
it('errors on non-array match', () => {
expect(() => createProjectConfigRecord({
packageConfigs: [{
match: 0 as unknown as string[],
}],
})).toThrow(expect.objectContaining({
match: 0,
code: 'ERR_PNPM_PROJECT_CONFIGS_ARRAY_ITEM_MATCH_IS_NOT_AN_ARRAY',
}))
expect(() => createProjectConfigRecord({
packageConfigs: [{
match: 'some string' as unknown as string[],
}],
})).toThrow(expect.objectContaining({
match: 'some string',
code: 'ERR_PNPM_PROJECT_CONFIGS_ARRAY_ITEM_MATCH_IS_NOT_AN_ARRAY',
}))
expect(() => createProjectConfigRecord({
packageConfigs: [{
match: true as unknown as string[],
}],
})).toThrow(expect.objectContaining({
match: true,
code: 'ERR_PNPM_PROJECT_CONFIGS_ARRAY_ITEM_MATCH_IS_NOT_AN_ARRAY',
}))
expect(() => createProjectConfigRecord({
packageConfigs: [{
match: undefined as unknown as string[],
}],
})).toThrow(expect.objectContaining({
match: undefined,
code: 'ERR_PNPM_PROJECT_CONFIGS_ARRAY_ITEM_MATCH_IS_NOT_AN_ARRAY',
}))
expect(() => createProjectConfigRecord({
packageConfigs: [{
match: null as unknown as string[],
}],
})).toThrow(expect.objectContaining({
match: null,
code: 'ERR_PNPM_PROJECT_CONFIGS_ARRAY_ITEM_MATCH_IS_NOT_AN_ARRAY',
}))
})
it('errors on non-string match item', () => {
expect(() => createProjectConfigRecord({
packageConfigs: [{
match: [0 as unknown as string],
}],
})).toThrow(expect.objectContaining({
matchItem: 0,
code: 'ERR_PNPM_PROJECT_CONFIGS_MATCH_ITEM_IS_NOT_A_STRING',
}))
expect(() => createProjectConfigRecord({
packageConfigs: [{
match: [null as unknown as string],
}],
})).toThrow(expect.objectContaining({
matchItem: null,
code: 'ERR_PNPM_PROJECT_CONFIGS_MATCH_ITEM_IS_NOT_A_STRING',
}))
expect(() => createProjectConfigRecord({
packageConfigs: [{
match: [{} as unknown as string],
}],
})).toThrow(expect.objectContaining({
matchItem: {},
code: 'ERR_PNPM_PROJECT_CONFIGS_MATCH_ITEM_IS_NOT_A_STRING',
}))
})
it('errors on invalid hoist', () => {
expect(() => createProjectConfigRecord({
packageConfigs: [{
match: ['project-1'],
hoist: 'invalid' as unknown as boolean,
}],
})).toThrow(expect.objectContaining({
expectedType: 'boolean',
actualValue: 'invalid',
code: 'ERR_PNPM_PROJECT_CONFIG_INVALID_VALUE_TYPE',
}))
expect(() => createProjectConfigRecord({
packageConfigs: [{
match: ['project-1'],
hoist: 0 as unknown as boolean,
}],
})).toThrow(expect.objectContaining({
expectedType: 'boolean',
actualValue: 0,
code: 'ERR_PNPM_PROJECT_CONFIG_INVALID_VALUE_TYPE',
}))
})
it('errors on invalid modulesDir', () => {
expect(() => createProjectConfigRecord({
packageConfigs: [{
match: ['project-1'],
modulesDir: 0 as unknown as string,
}],
})).toThrow(expect.objectContaining({
expectedType: 'string',
actualValue: 0,
code: 'ERR_PNPM_PROJECT_CONFIG_INVALID_VALUE_TYPE',
}))
expect(() => createProjectConfigRecord({
packageConfigs: [{
match: ['project-1'],
modulesDir: true as unknown as string,
}],
})).toThrow(expect.objectContaining({
expectedType: 'string',
actualValue: true,
code: 'ERR_PNPM_PROJECT_CONFIG_INVALID_VALUE_TYPE',
}))
})
it('errors on invalid saveExact', () => {
expect(() => createProjectConfigRecord({
packageConfigs: [{
match: ['project-1'],
saveExact: 'invalid' as unknown as boolean,
}],
})).toThrow(expect.objectContaining({
expectedType: 'boolean',
actualValue: 'invalid',
code: 'ERR_PNPM_PROJECT_CONFIG_INVALID_VALUE_TYPE',
}))
expect(() => createProjectConfigRecord({
packageConfigs: [{
match: ['project-1'],
saveExact: 0 as unknown as boolean,
}],
})).toThrow(expect.objectContaining({
expectedType: 'boolean',
actualValue: 0,
code: 'ERR_PNPM_PROJECT_CONFIG_INVALID_VALUE_TYPE',
}))
})
it('errors on invalid savePrefix', () => {
expect(() => createProjectConfigRecord({
packageConfigs: [{
match: ['project-1'],
savePrefix: 0 as unknown as string,
}],
})).toThrow(expect.objectContaining({
expectedType: 'string',
actualValue: 0,
code: 'ERR_PNPM_PROJECT_CONFIG_INVALID_VALUE_TYPE',
}))
expect(() => createProjectConfigRecord({
packageConfigs: [{
match: ['project-1'],
savePrefix: false as unknown as string,
}],
})).toThrow(expect.objectContaining({
expectedType: 'string',
actualValue: false,
code: 'ERR_PNPM_PROJECT_CONFIG_INVALID_VALUE_TYPE',
}))
})
it('errors on unsupported fields', () => {
expect(() => createProjectConfigRecord({
packageConfigs: [{
match: ['project-1'],
ignoreScripts: true,
} as ProjectConfigWithExtraFields],
})).toThrow(expect.objectContaining({
field: 'ignoreScripts',
code: 'ERR_PNPM_PROJECT_CONFIG_UNSUPPORTED_FIELD',
}))
expect(() => createProjectConfigRecord({
packageConfigs: [{
match: ['project-1'],
hoistPattern: ['*'],
}],
})).toThrow(expect.objectContaining({
field: 'hoistPattern',
code: 'ERR_PNPM_PROJECT_CONFIG_UNSUPPORTED_FIELD',
}))
})
it('does not error on unsupported but undefined fields', () => {
expect(createProjectConfigRecord({
packageConfigs: [{
match: ['project-1'],
ignoreScripts: undefined,
hoistPattern: undefined,
} as ProjectConfigWithExtraFields],
})).toStrictEqual({
'project-1': {
ignoreScripts: undefined,
hoistPattern: undefined,
} as Partial<Config>,
})
})
})

View File

@@ -1,9 +0,0 @@
import { fixtures } from '@pnpm/test-fixtures'
import { readLocalConfig } from '@pnpm/config'
const f = fixtures(import.meta.dirname)
test('readLocalConfig parse number field', async () => {
const config = await readLocalConfig(f.find('has-number-setting'))
expect(typeof config.childConcurrency).toBe('number')
})

View File

@@ -6,14 +6,13 @@ import {
} from '@pnpm/cli-utils'
import {
type Config,
readLocalConfig,
createProjectConfigRecord,
getWorkspaceConcurrency,
} from '@pnpm/config'
import { logger } from '@pnpm/logger'
import { sortPackages } from '@pnpm/sort-packages'
import { createOrConnectStoreController, type CreateStoreControllerOptions } from '@pnpm/store-connection-manager'
import { type Project, type ProjectManifest, type ProjectRootDir } from '@pnpm/types'
import mem from 'mem'
import pLimit from 'p-limit'
import { rebuildProjects as rebuildAll, type RebuildOptions, rebuildSelectedPkgs } from './implementation/index.js'
@@ -25,6 +24,7 @@ type RecursiveRebuildOpts = CreateStoreControllerOptions & Pick<Config,
| 'lockfileDir'
| 'lockfileOnly'
| 'nodeLinker'
| 'packageConfigs'
| 'rawLocalConfig'
| 'registries'
| 'rootProjectManifest'
@@ -74,7 +74,7 @@ export async function recursiveRebuild (
const result: RecursiveSummary = {}
const memReadLocalConfig = mem(readLocalConfig)
const projectConfigRecord = createProjectConfigRecord(opts) ?? {}
async function getImporters () {
const importers = [] as Array<{ buildIndex: number, manifest: ProjectManifest, rootDir: ProjectRootDir }>
@@ -121,7 +121,8 @@ export async function recursiveRebuild (
return
}
result[rootDir] = { status: 'running' }
const localConfig = await memReadLocalConfig(rootDir)
const { manifest } = opts.selectedProjectsGraph[rootDir].package
const localConfig = manifest.name ? projectConfigRecord[manifest.name] : undefined
await rebuild(
[
{

View File

@@ -82,7 +82,6 @@
"get-npm-tarball-url": "catalog:",
"is-subdir": "catalog:",
"load-json-file": "catalog:",
"mem": "catalog:",
"normalize-path": "catalog:",
"p-filter": "catalog:",
"p-limit": "catalog:",

View File

@@ -332,6 +332,7 @@ export type InstallCommandOptions = Pick<Config,
| 'updateConfig'
| 'overrides'
| 'supportedArchitectures'
| 'packageConfigs'
> & CreateStoreControllerOptions & {
argv: {
original: string[]

View File

@@ -8,9 +8,10 @@ import {
import {
type Config,
type OptionsFromRootManifest,
type ProjectConfig,
createProjectConfigRecord,
getOptionsFromRootManifest,
getWorkspaceConcurrency,
readLocalConfig,
} from '@pnpm/config'
import { PnpmError } from '@pnpm/error'
import { arrayOfWorkspacePackagesToMap } from '@pnpm/get-context'
@@ -45,7 +46,6 @@ import {
type WorkspacePackages,
} from '@pnpm/core'
import isSubdir from 'is-subdir'
import mem from 'mem'
import pFilter from 'p-filter'
import pLimit from 'p-limit'
import { createWorkspaceSpecs, updateToWorkspacePackagesFromManifest } from './updateWorkspaceDependencies.js'
@@ -84,6 +84,7 @@ export type RecursiveOptions = CreateStoreControllerOptions & Pick<Config,
| 'sharedWorkspaceLockfile'
| 'tag'
| 'cleanupUnusedCatalogs'
| 'packageConfigs'
> & {
include?: IncludedDependencies
includeDirect?: IncludedDependencies
@@ -166,7 +167,11 @@ export async function recursive (
const result: RecursiveSummary = {}
const memReadLocalConfig = mem(readLocalConfig)
const projectConfigRecord = createProjectConfigRecord(opts)
const getProjectConfig: (manifest: Pick<ProjectManifest, 'name'>) => ProjectConfig | undefined =
projectConfigRecord
? manifest => manifest.name ? projectConfigRecord[manifest.name] : undefined
: () => undefined
const updateToLatest = opts.update && opts.latest
const includeDirect = opts.includeDirect ?? {
@@ -208,9 +213,9 @@ export async function recursive (
}
const mutatedImporters = [] as MutatedProject[]
await Promise.all(importers.map(async ({ rootDir }) => {
const localConfig = await memReadLocalConfig(rootDir)
const modulesDir = localConfig.modulesDir ?? opts.modulesDir
const { manifest } = manifestsByPath[rootDir]
const localConfig = getProjectConfig(manifest) ?? {}
const modulesDir = localConfig.modulesDir ?? opts.modulesDir
let currentInput = [...params]
if (updateMatch != null) {
currentInput = matchDependencies(updateMatch, manifest, includeDirect)
@@ -386,7 +391,7 @@ export async function recursive (
break
}
const localConfig = await memReadLocalConfig(rootDir)
const localConfig = getProjectConfig(manifest) ?? {}
const {
updatedCatalogs: newCatalogsAddition,
updatedManifest: newManifest,

View File

@@ -621,7 +621,7 @@ test('recursive install on workspace with custom lockfile-dir', async () => {
expect(Object.keys(lockfile.importers!)).toStrictEqual(['../project-1', '../project-2'])
})
test('recursive install in a monorepo with different modules directories', async () => {
test('recursive install in a monorepo with different modules directories specified by packageConfigs record', async () => {
const projects = preparePackages([
{
name: 'project-1',
@@ -640,8 +640,6 @@ test('recursive install in a monorepo with different modules directories', async
},
},
])
fs.writeFileSync('project-1/.npmrc', 'modules-dir=modules_1', 'utf8')
fs.writeFileSync('project-2/.npmrc', 'modules-dir=modules_2', 'utf8')
const { allProjects, allProjectsGraph, selectedProjectsGraph } = await filterPackagesFromDir(process.cwd(), [])
await install.handler({
@@ -652,16 +650,28 @@ test('recursive install in a monorepo with different modules directories', async
recursive: true,
selectedProjectsGraph,
workspaceDir: process.cwd(),
packageConfigs: {
'project-1': { modulesDir: 'modules_1' },
'project-2': { modulesDir: 'modules_2' },
},
})
projects['project-1'].has('is-positive', 'modules_1')
projects['project-2'].has('is-positive', 'modules_2')
})
test('recursive install in a monorepo with parsing env variables', async () => {
test('recursive install in a monorepo with different modules directories specified by packageConfigs multi match', async () => {
const projects = preparePackages([
{
name: 'project',
name: 'project-1',
version: '1.0.0',
dependencies: {
'is-positive': '1.0.0',
},
},
{
name: 'project-2',
version: '1.0.0',
dependencies: {
@@ -670,10 +680,6 @@ test('recursive install in a monorepo with parsing env variables', async () => {
},
])
process.env['SOME_NAME'] = 'some_name'
// eslint-disable-next-line no-template-curly-in-string
fs.writeFileSync('project/.npmrc', 'modules-dir=${SOME_NAME}_modules', 'utf8')
const { allProjects, allProjectsGraph, selectedProjectsGraph } = await filterPackagesFromDir(process.cwd(), [])
await install.handler({
...DEFAULT_OPTS,
@@ -683,9 +689,14 @@ test('recursive install in a monorepo with parsing env variables', async () => {
recursive: true,
selectedProjectsGraph,
workspaceDir: process.cwd(),
packageConfigs: [{
match: ['project-1', 'project-2'],
modulesDir: 'different_node_modules',
}],
})
projects['project'].has('is-positive', `${process.env['SOME_NAME']}_modules`)
projects['project-1'].has('is-positive', 'different_node_modules')
projects['project-2'].has('is-positive', 'different_node_modules')
})
test('prefer-workspace-package', async () => {

3
pnpm-lock.yaml generated
View File

@@ -6006,9 +6006,6 @@ importers:
load-json-file:
specifier: 'catalog:'
version: 7.0.1
mem:
specifier: 'catalog:'
version: 10.0.0
normalize-path:
specifier: 'catalog:'
version: 3.0.0

View File

@@ -1,7 +1,9 @@
// cspell:ignore buildscript
import fs from 'fs'
import path from 'path'
import { type Config } from '@pnpm/config'
import { LOCKFILE_VERSION, WANTED_LOCKFILE } from '@pnpm/constants'
import { type WorkspaceManifest } from '@pnpm/workspace.read-manifest'
import { findWorkspacePackages } from '@pnpm/workspace.find-packages'
import { type LockfileFile } from '@pnpm/lockfile.types'
import { readModulesManifest } from '@pnpm/modules-yaml'
@@ -662,17 +664,13 @@ test('recursive install with link-workspace-packages and shared-workspace-lockfi
},
])
writeYamlFile('pnpm-workspace.yaml', { packages: ['**', '!store/**'] })
fs.writeFileSync(
'is-positive/.npmrc',
'save-exact = true',
'utf8'
)
fs.writeFileSync(
'project-1/.npmrc',
'save-prefix = ~',
'utf8'
)
writeYamlFile('pnpm-workspace.yaml', {
packages: ['**', '!store/**'],
packageConfigs: {
'is-positive': { saveExact: true },
'project-1': { savePrefix: '~' },
},
} satisfies Partial<Config> & WorkspaceManifest)
await execPnpm(['recursive', 'install', '--link-workspace-packages', '--shared-workspace-lockfile=true', '--store-dir', 'store'])

View File

@@ -1,7 +1,9 @@
import fs from 'fs'
import path from 'path'
import { type Config } from '@pnpm/config'
import { STORE_VERSION } from '@pnpm/constants'
import { preparePackages } from '@pnpm/prepare'
import { type WorkspaceManifest } from '@pnpm/workspace.read-manifest'
import { type LockfileFile } from '@pnpm/lockfile.types'
import { sync as readYamlFile } from 'read-yaml-file'
import { isCI } from 'ci-info'
@@ -16,7 +18,7 @@ import {
const skipOnWindows = isWindows() ? test.skip : test
test('recursive installation with package-specific .npmrc', async () => {
test('recursive installation with packageConfigs', async () => {
const projects = preparePackages([
{
name: 'project-1',
@@ -36,7 +38,13 @@ test('recursive installation with package-specific .npmrc', async () => {
},
])
fs.writeFileSync('project-2/.npmrc', 'hoist = false', 'utf8')
writeYamlFile('pnpm-workspace.yaml', {
packages: ['*'],
packageConfigs: {
'project-2': { hoist: false },
},
sharedWorkspaceLockfile: false,
} satisfies Partial<Config> & WorkspaceManifest)
await execPnpm(['recursive', 'install'])
@@ -50,7 +58,7 @@ test('recursive installation with package-specific .npmrc', async () => {
expect(modulesYaml2?.hoistPattern).toBeFalsy()
})
test('workspace .npmrc is always read', async () => {
test('workspace packageConfigs is always read', async () => {
const projects = preparePackages([
{
location: 'workspace/project-1',
@@ -79,10 +87,12 @@ test('workspace .npmrc is always read', async () => {
const storeDir = path.resolve('../store')
writeYamlFile('pnpm-workspace.yaml', {
packages: ['workspace/*'],
shamefullyFlatten: true,
packageConfigs: {
'project-2': { hoist: false },
},
shamefullyHoist: true,
sharedWorkspaceLockfile: false,
})
fs.writeFileSync('workspace/project-2/.npmrc', 'hoist=false', 'utf8')
} satisfies Partial<Config> & WorkspaceManifest)
process.chdir('workspace/project-1')
await execPnpm(['install', '--store-dir', storeDir, '--filter', '.'])

View File

@@ -1,6 +1,8 @@
import fs from 'fs'
import path from 'path'
import { sync as writeYamlFile } from 'write-yaml-file'
import { type Config } from '@pnpm/config'
import { preparePackages } from '@pnpm/prepare'
import { type WorkspaceManifest } from '@pnpm/workspace.read-manifest'
import { addDistTag } from '@pnpm/registry-mock'
import { execPnpm } from '../utils/index.js'
@@ -35,17 +37,13 @@ test.skip('recursive update --latest should update deps with correct specs', asy
},
])
fs.writeFileSync(
'project-2/.npmrc',
'save-exact = true',
'utf8'
)
fs.writeFileSync(
'project-3/.npmrc',
'save-prefix = ~',
'utf8'
)
writeYamlFile('pnpm-workspace.yaml', {
packages: ['*'],
packageConfigs: {
'project-2': { saveExact: true },
'project-3': { savePrefix: '~' },
},
} satisfies Partial<Config> & WorkspaceManifest)
await execPnpm(['recursive', 'update', '--latest'])