feat(cli): installing new configurational dependencies (#9377)

This commit is contained in:
Zoltan Kochan
2025-04-07 13:38:18 +02:00
committed by GitHub
parent ead11ad8ec
commit 750ae7d1d7
23 changed files with 329 additions and 76 deletions

View File

@@ -0,0 +1,5 @@
---
"@pnpm/types": minor
---
Export `ConfigDependencies` type.

View File

@@ -0,0 +1,10 @@
---
"@pnpm/plugin-commands-installation": minor
"@pnpm/default-reporter": minor
"@pnpm/config.deps-installer": minor
"@pnpm/core-loggers": minor
"pnpm": minor
---
Now you can use the `pnpm add` command with the `--config` flag to install new configurational dependencies [#9377](https://github.com/pnpm/pnpm/pull/9377).

View File

@@ -0,0 +1,5 @@
---
"@pnpm/fetch": minor
---
Export `CreateFetchFromRegistryOptions` type.

View File

@@ -137,6 +137,7 @@ export function toOutput$ (
const statsPushStream = new Rx.Subject<logs.StatsLog>()
const packageImportMethodPushStream = new Rx.Subject<logs.PackageImportMethodLog>()
const installCheckPushStream = new Rx.Subject<logs.InstallCheckLog>()
const installingConfigDepsStream = new Rx.Subject<logs.InstallingConfigDepsLog>()
const ignoredScriptsPushStream = new Rx.Subject<logs.IgnoredScriptsLog>()
const registryPushStream = new Rx.Subject<logs.RegistryLog>()
const rootPushStream = new Rx.Subject<logs.RootLog>()
@@ -188,6 +189,9 @@ export function toOutput$ (
case 'pnpm:install-check':
installCheckPushStream.next(log)
break
case 'pnpm:installing-config-deps':
installingConfigDepsStream.next(log)
break
case 'pnpm:ignored-scripts':
ignoredScriptsPushStream.next(log)
break
@@ -242,6 +246,7 @@ export function toOutput$ (
executionTime: Rx.from(executionTimePushStream),
hook: Rx.from(hookPushStream),
installCheck: Rx.from(installCheckPushStream),
installingConfigDeps: Rx.from(installingConfigDepsStream),
ignoredScripts: Rx.from(ignoredScriptsPushStream),
lifecycle: Rx.from(lifecyclePushStream),
link: Rx.from(linkPushStream),

View File

@@ -9,6 +9,7 @@ import { reportExecutionTime } from './reportExecutionTime'
import { reportDeprecations } from './reportDeprecations'
import { reportHooks } from './reportHooks'
import { reportInstallChecks } from './reportInstallChecks'
import { reportInstallingConfigDeps } from './reportInstallingConfigDeps'
import { reportLifecycleScripts } from './reportLifecycleScripts'
import { reportMisc, LOG_LEVEL_NUMBER } from './reportMisc'
import { reportPeerDependencyIssues } from './reportPeerDependencyIssues'
@@ -41,6 +42,7 @@ export function reporterForClient (
lifecycle: Rx.Observable<logs.LifecycleLog>
stats: Rx.Observable<logs.StatsLog>
installCheck: Rx.Observable<logs.InstallCheckLog>
installingConfigDeps: Rx.Observable<logs.InstallingConfigDepsLog>
registry: Rx.Observable<logs.RegistryLog>
root: Rx.Observable<logs.RootLog>
packageManifest: Rx.Observable<logs.PackageManifestLog>
@@ -126,6 +128,7 @@ export function reporterForClient (
width,
}),
reportInstallChecks(log$.installCheck, { cwd }),
reportInstallingConfigDeps(log$.installingConfigDeps),
reportScope(log$.scope, { isRecursive: opts.isRecursive, cmd: opts.cmd }),
reportSkippedOptionalDependencies(log$.skippedOptionalDependency, { cwd }),
reportHooks(log$.hook, { cwd, isRecursive: opts.isRecursive }),

View File

@@ -0,0 +1,23 @@
import { type InstallingConfigDepsLog } from '@pnpm/core-loggers'
import * as Rx from 'rxjs'
import { map } from 'rxjs/operators'
export function reportInstallingConfigDeps (
installingConfigDeps$: Rx.Observable<InstallingConfigDepsLog>
): Rx.Observable<Rx.Observable<{ msg: string }>> {
return Rx.of(installingConfigDeps$.pipe(
map((log) => {
switch (log.status) {
case 'started': {
return {
msg: 'Installing config dependencies...',
}
}
case 'done':
return {
msg: `Installed config dependencies: ${log.deps.map(({ name, version }) => `${name}@${version}`).join(', ')}`,
}
}
})
))
}

View File

@@ -33,8 +33,14 @@
"compile": "tsc --build && pnpm run lint --fix"
},
"dependencies": {
"@pnpm/config.config-writer": "workspace:*",
"@pnpm/core-loggers": "workspace:*",
"@pnpm/error": "workspace:*",
"@pnpm/fetch": "workspace:*",
"@pnpm/network.auth-header": "workspace:*",
"@pnpm/npm-resolver": "workspace:*",
"@pnpm/package-store": "workspace:*",
"@pnpm/parse-wanted-dependency": "workspace:*",
"@pnpm/pick-registry-for-package": "workspace:*",
"@pnpm/read-modules-dir": "workspace:*",
"@pnpm/read-package-json": "workspace:*",
@@ -42,12 +48,16 @@
"@zkochan/rimraf": "catalog:",
"get-npm-tarball-url": "catalog:"
},
"peerDependencies": {
"@pnpm/logger": ">=5.1.0 <1001.0.0"
},
"devDependencies": {
"@pnpm/config.deps-installer": "workspace:*",
"@pnpm/prepare": "workspace:*",
"@pnpm/registry-mock": "catalog:",
"@pnpm/testing.temp-store": "workspace:*",
"load-json-file": "catalog:"
"load-json-file": "catalog:",
"read-yaml-file": "catalog:"
},
"engines": {
"node": ">=18.12"

View File

@@ -1,66 +1,2 @@
import path from 'path'
import getNpmTarballUrl from 'get-npm-tarball-url'
import { PnpmError } from '@pnpm/error'
import { pickRegistryForPackage } from '@pnpm/pick-registry-for-package'
import { readModulesDir } from '@pnpm/read-modules-dir'
import rimraf from '@zkochan/rimraf'
import { safeReadPackageJsonFromDir } from '@pnpm/read-package-json'
import { type StoreController } from '@pnpm/package-store'
import { type Registries } from '@pnpm/types'
export async function installConfigDeps (configDeps: Record<string, string>, opts: {
registries: Registries
rootDir: string
store: StoreController
}): Promise<void> {
const configModulesDir = path.join(opts.rootDir, 'node_modules/.pnpm-config')
const existingConfigDeps: string[] = await readModulesDir(configModulesDir) ?? []
await Promise.all(existingConfigDeps.map(async (existingConfigDep) => {
if (!configDeps[existingConfigDep]) {
await rimraf(path.join(configModulesDir, existingConfigDep))
}
}))
await Promise.all(Object.entries(configDeps).map(async ([pkgName, pkgSpec]) => {
const configDepPath = path.join(configModulesDir, pkgName)
const sepIndex = pkgSpec.indexOf('+')
if (sepIndex === -1) {
throw new PnpmError('CONFIG_DEP_NO_INTEGRITY', `Your config dependency called "${pkgName}" at "pnpm.configDependencies" doesn't have an integrity checksum`, {
hint: `All config dependencies should have their integrity checksum inlined in the version specifier. For example:
{
"pnpm": {
"configDependencies": {
"my-config": "1.0.0+sha512-Xg0tn4HcfTijTwfDwYlvVCl43V6h4KyVVX2aEm4qdO/PC6L2YvzLHFdmxhoeSA3eslcE6+ZVXHgWwopXYLNq4Q=="
},
}
}`,
})
}
const version = pkgSpec.substring(0, sepIndex)
const integrity = pkgSpec.substring(sepIndex + 1)
if (existingConfigDeps.includes(pkgName)) {
const configDepPkgJson = await safeReadPackageJsonFromDir(configDepPath)
if (configDepPkgJson == null || configDepPkgJson.name !== pkgName || configDepPkgJson.version !== version) {
await rimraf(configDepPath)
}
}
const registry = pickRegistryForPackage(opts.registries, pkgName)
const { fetching } = await opts.store.fetchPackage({
force: true,
lockfileDir: opts.rootDir,
pkg: {
id: `${pkgName}@${version}`,
resolution: {
tarball: getNpmTarballUrl(pkgName, version, { registry }),
integrity,
},
},
})
const { files: filesResponse } = await fetching()
await opts.store.importPackage(configDepPath, {
force: true,
requiresBuild: false,
filesResponse,
})
}))
}
export { installConfigDeps, type InstallConfigDepsOpts } from './installConfigDeps'
export { resolveConfigDeps, type ResolveConfigDepsOpts } from './resolveConfigDeps'

View File

@@ -0,0 +1,77 @@
import path from 'path'
import getNpmTarballUrl from 'get-npm-tarball-url'
import { installingConfigDepsLogger } from '@pnpm/core-loggers'
import { PnpmError } from '@pnpm/error'
import { pickRegistryForPackage } from '@pnpm/pick-registry-for-package'
import { readModulesDir } from '@pnpm/read-modules-dir'
import rimraf from '@zkochan/rimraf'
import { safeReadPackageJsonFromDir } from '@pnpm/read-package-json'
import { type StoreController } from '@pnpm/package-store'
import { type Registries } from '@pnpm/types'
export interface InstallConfigDepsOpts {
registries: Registries
rootDir: string
store: StoreController
}
export async function installConfigDeps (configDeps: Record<string, string>, opts: InstallConfigDepsOpts): Promise<void> {
const configModulesDir = path.join(opts.rootDir, 'node_modules/.pnpm-config')
const existingConfigDeps: string[] = await readModulesDir(configModulesDir) ?? []
await Promise.all(existingConfigDeps.map(async (existingConfigDep) => {
if (!configDeps[existingConfigDep]) {
await rimraf(path.join(configModulesDir, existingConfigDep))
}
}))
const installedConfigDeps: Array<{ name: string, version: string }> = []
await Promise.all(Object.entries(configDeps).map(async ([pkgName, pkgSpec]) => {
const configDepPath = path.join(configModulesDir, pkgName)
const sepIndex = pkgSpec.indexOf('+')
if (sepIndex === -1) {
throw new PnpmError('CONFIG_DEP_NO_INTEGRITY', `Your config dependency called "${pkgName}" at "pnpm.configDependencies" doesn't have an integrity checksum`, {
hint: `All config dependencies should have their integrity checksum inlined in the version specifier. For example:
pnpm-workspace.yaml:
configDependencies:
my-config: "1.0.0+sha512-Xg0tn4HcfTijTwfDwYlvVCl43V6h4KyVVX2aEm4qdO/PC6L2YvzLHFdmxhoeSA3eslcE6+ZVXHgWwopXYLNq4Q=="
`,
})
}
const version = pkgSpec.substring(0, sepIndex)
const integrity = pkgSpec.substring(sepIndex + 1)
if (existingConfigDeps.includes(pkgName)) {
const configDepPkgJson = await safeReadPackageJsonFromDir(configDepPath)
if (configDepPkgJson == null || configDepPkgJson.name !== pkgName || configDepPkgJson.version !== version) {
await rimraf(configDepPath)
} else {
return
}
}
installingConfigDepsLogger.debug({ status: 'started' })
const registry = pickRegistryForPackage(opts.registries, pkgName)
const { fetching } = await opts.store.fetchPackage({
force: true,
lockfileDir: opts.rootDir,
pkg: {
id: `${pkgName}@${version}`,
resolution: {
tarball: getNpmTarballUrl(pkgName, version, { registry }),
integrity,
},
},
})
const { files: filesResponse } = await fetching()
await opts.store.importPackage(configDepPath, {
force: true,
requiresBuild: false,
filesResponse,
})
installedConfigDeps.push({
name: pkgName,
version,
})
}))
if (installedConfigDeps.length) {
installingConfigDepsLogger.debug({ status: 'done', deps: installedConfigDeps })
}
}

View File

@@ -0,0 +1,45 @@
import { PnpmError } from '@pnpm/error'
import { writeSettings } from '@pnpm/config.config-writer'
import { createFetchFromRegistry, type CreateFetchFromRegistryOptions } from '@pnpm/fetch'
import { createNpmResolver, type ResolverFactoryOptions } from '@pnpm/npm-resolver'
import { createGetAuthHeaderByURI } from '@pnpm/network.auth-header'
import { parseWantedDependency } from '@pnpm/parse-wanted-dependency'
import { type ConfigDependencies } from '@pnpm/types'
import { installConfigDeps, type InstallConfigDepsOpts } from './installConfigDeps'
export type ResolveConfigDepsOpts = CreateFetchFromRegistryOptions & ResolverFactoryOptions & InstallConfigDepsOpts & {
configDependencies?: ConfigDependencies
rootDir: string
userConfig?: Record<string, string>
}
export async function resolveConfigDeps (configDeps: string[], opts: ResolveConfigDepsOpts): Promise<void> {
const fetch = createFetchFromRegistry(opts)
const getAuthHeader = createGetAuthHeaderByURI({ allSettings: opts.userConfig!, userSettings: opts.userConfig })
const { resolveFromNpm } = createNpmResolver(fetch, getAuthHeader, opts)
const configDependencies = opts.configDependencies ?? {}
await Promise.all(configDeps.map(async (configDep) => {
const wantedDep = parseWantedDependency(configDep)
if (!wantedDep.alias) {
throw new PnpmError('BAD_CONFIG_DEP', `Cannot install ${configDep} as configuration dependency`)
}
const resolution = await resolveFromNpm(wantedDep, {
lockfileDir: opts.rootDir,
preferredVersions: {},
projectDir: opts.rootDir,
})
if (resolution?.resolution == null || !('integrity' in resolution?.resolution)) {
throw new PnpmError('BAD_CONFIG_DEP', `Cannot install ${configDep} as configuration dependency because it has no integrity`)
}
configDependencies[wantedDep.alias] = `${resolution?.manifest?.version}+${resolution.resolution.integrity}`
}))
await writeSettings({
...opts,
rootProjectManifestDir: opts.rootDir,
workspaceDir: opts.rootDir,
updatedSettings: {
configDependencies,
},
})
await installConfigDeps(configDependencies, opts)
}

View File

@@ -0,0 +1,28 @@
import path from 'path'
import { prepareEmpty } from '@pnpm/prepare'
import { getIntegrity, REGISTRY_MOCK_PORT } from '@pnpm/registry-mock'
import { resolveConfigDeps } from '@pnpm/config.deps-installer'
import { createTempStore } from '@pnpm/testing.temp-store'
import { sync as readYamlFile } from 'read-yaml-file'
const registry = `http://localhost:${REGISTRY_MOCK_PORT}/`
test('configuration dependency is resolved', async () => {
prepareEmpty()
const { storeController } = createTempStore()
await resolveConfigDeps(['@pnpm.e2e/foo@100.0.0'], {
registries: {
default: registry,
},
rootDir: process.cwd(),
cacheDir: path.resolve('cache'),
userConfig: {},
store: storeController,
})
const workspaceManifest = readYamlFile<{ configDependencies: Record<string, string> }>('pnpm-workspace.yaml')
expect(workspaceManifest.configDependencies).toStrictEqual({
'@pnpm.e2e/foo': `100.0.0+${getIntegrity('@pnpm.e2e/foo', '100.0.0')}`,
})
})

View File

@@ -15,21 +15,39 @@
{
"path": "../../fs/read-modules-dir"
},
{
"path": "../../network/auth-header"
},
{
"path": "../../network/fetch"
},
{
"path": "../../packages/core-loggers"
},
{
"path": "../../packages/error"
},
{
"path": "../../packages/parse-wanted-dependency"
},
{
"path": "../../packages/types"
},
{
"path": "../../pkg-manifest/read-package-json"
},
{
"path": "../../resolving/npm-resolver"
},
{
"path": "../../store/package-store"
},
{
"path": "../../testing/temp-store"
},
{
"path": "../config-writer"
},
{
"path": "../pick-registry-for-package"
}

View File

@@ -30,13 +30,13 @@ export function fetchWithAgent (url: RequestInfo, opts: FetchWithAgentOptions):
export type { AgentOptions }
export function createFetchFromRegistry (
defaultOpts: {
fullMetadata?: boolean
userAgent?: string
sslConfigs?: Record<string, SslConfig>
} & AgentOptions
): FetchFromRegistry {
export interface CreateFetchFromRegistryOptions extends AgentOptions {
fullMetadata?: boolean
userAgent?: string
sslConfigs?: Record<string, SslConfig>
}
export function createFetchFromRegistry (defaultOpts: CreateFetchFromRegistryOptions): FetchFromRegistry {
return async (url, opts): Promise<Response> => {
const headers = {
'user-agent': USER_AGENT,

View File

@@ -1,3 +1,3 @@
export type { FetchFromRegistry } from '@pnpm/fetching-types'
export { fetch, type RetryTimeoutOptions } from './fetch'
export { createFetchFromRegistry, fetchWithAgent, type AgentOptions } from './fetchFromRegistry'
export { createFetchFromRegistry, fetchWithAgent, type AgentOptions, type CreateFetchFromRegistryOptions } from './fetchFromRegistry'

View File

@@ -3,6 +3,7 @@ export * from './deprecationLogger'
export * from './fetchingProgressLogger'
export * from './hookLogger'
export * from './installCheckLogger'
export * from './installingConfigDeps'
export * from './ignoredScriptsLogger'
export * from './lifecycleLogger'
export * from './linkLogger'

View File

@@ -5,6 +5,7 @@ import {
type ExecutionTimeLog,
type HookLog,
type InstallCheckLog,
type InstallingConfigDepsLog,
type IgnoredScriptsLog,
type LifecycleLog,
type LinkLog,
@@ -32,6 +33,7 @@ export type Log =
| ExecutionTimeLog
| HookLog
| InstallCheckLog
| InstallingConfigDepsLog
| IgnoredScriptsLog
| LifecycleLog
| LinkLog

View File

@@ -0,0 +1,23 @@
import {
type LogBase,
logger,
} from '@pnpm/logger'
export const installingConfigDepsLogger = logger<InstallingConfigDepsMessage>('installing-config-deps')
export interface InstallingConfigDepsMessageBase {
status?: 'started' | 'done'
}
export interface InstallingConfigDepsStartedMessage extends InstallingConfigDepsMessageBase {
status: 'started'
}
export interface InstallingConfigDepsDoneMessage extends InstallingConfigDepsMessageBase {
deps: Array<{ name: string, version: string }>
status: 'done'
}
export type InstallingConfigDepsMessage = InstallingConfigDepsStartedMessage | InstallingConfigDepsDoneMessage
export type InstallingConfigDepsLog = { name: 'pnpm:installing-config-deps' } & LogBase & InstallingConfigDepsMessage

View File

@@ -133,8 +133,10 @@ export interface PeerDependencyRules {
export type AllowedDeprecatedVersions = Record<string, string>
export type ConfigDependencies = Record<string, string>
export interface PnpmSettings {
configDependencies?: Record<string, string>
configDependencies?: ConfigDependencies
neverBuiltDependencies?: string[]
onlyBuiltDependencies?: string[]
onlyBuiltDependenciesFile?: string

View File

@@ -37,6 +37,7 @@
"@pnpm/common-cli-options-help": "workspace:*",
"@pnpm/config": "workspace:*",
"@pnpm/config.config-writer": "workspace:*",
"@pnpm/config.deps-installer": "workspace:*",
"@pnpm/constants": "workspace:*",
"@pnpm/core": "workspace:*",
"@pnpm/dedupe.check": "workspace:*",

View File

@@ -1,8 +1,10 @@
import { docsUrl } from '@pnpm/cli-utils'
import { FILTERING, OPTIONS, UNIVERSAL_OPTIONS } from '@pnpm/common-cli-options-help'
import { types as allTypes } from '@pnpm/config'
import { resolveConfigDeps } from '@pnpm/config.deps-installer'
import { PnpmError } from '@pnpm/error'
import { prepareExecutionEnv } from '@pnpm/plugin-commands-env'
import { createOrConnectStoreController } from '@pnpm/store-connection-manager'
import pick from 'ramda/src/pick'
import renderHelp from 'render-help'
import { createProjectManifestWriter } from './createProjectManifestWriter'
@@ -80,6 +82,7 @@ export function cliOptionsTypes (): Record<string, unknown> {
recursive: Boolean,
save: Boolean,
workspace: Boolean,
config: Boolean,
}
}
@@ -137,6 +140,10 @@ For options that may be used with `-r`, see "pnpm help recursive"',
description: 'Only adds the new dependency if it is found in the workspace',
name: '--workspace',
},
{
description: 'Save the dependency to configurational dependencies',
name: '--config',
},
OPTIONS.ignoreScripts,
OPTIONS.offline,
OPTIONS.preferOffline,
@@ -175,6 +182,7 @@ export type AddCommandOptions = InstallCommandOptions & {
update?: boolean
useBetaCli?: boolean
workspaceRoot?: boolean
config?: boolean
}
export async function handler (
@@ -187,6 +195,15 @@ export async function handler (
if (!params || (params.length === 0)) {
throw new PnpmError('MISSING_PACKAGE_NAME', '`pnpm add` requires the package name')
}
if (opts.config) {
const store = await createOrConnectStoreController(opts)
await resolveConfigDeps(params, {
...opts,
store: store.ctrl,
rootDir: opts.workspaceDir ?? opts.rootProjectManifestDir,
})
return
}
if (
!opts.recursive &&
opts.workspaceDir === opts.dir &&

View File

@@ -36,6 +36,9 @@
{
"path": "../../config/config-writer"
},
{
"path": "../../config/deps-installer"
},
{
"path": "../../config/matcher"
},

27
pnpm-lock.yaml generated
View File

@@ -1591,12 +1591,33 @@ importers:
config/deps-installer:
dependencies:
'@pnpm/config.config-writer':
specifier: workspace:*
version: link:../config-writer
'@pnpm/core-loggers':
specifier: workspace:*
version: link:../../packages/core-loggers
'@pnpm/error':
specifier: workspace:*
version: link:../../packages/error
'@pnpm/fetch':
specifier: workspace:*
version: link:../../network/fetch
'@pnpm/logger':
specifier: '>=5.1.0 <1001.0.0'
version: 1000.0.0
'@pnpm/network.auth-header':
specifier: workspace:*
version: link:../../network/auth-header
'@pnpm/npm-resolver':
specifier: workspace:*
version: link:../../resolving/npm-resolver
'@pnpm/package-store':
specifier: workspace:*
version: link:../../store/package-store
'@pnpm/parse-wanted-dependency':
specifier: workspace:*
version: link:../../packages/parse-wanted-dependency
'@pnpm/pick-registry-for-package':
specifier: workspace:*
version: link:../pick-registry-for-package
@@ -1631,6 +1652,9 @@ importers:
load-json-file:
specifier: 'catalog:'
version: 6.2.0
read-yaml-file:
specifier: 'catalog:'
version: 2.1.0
config/matcher:
dependencies:
@@ -5374,6 +5398,9 @@ importers:
'@pnpm/config.config-writer':
specifier: workspace:*
version: link:../../config/config-writer
'@pnpm/config.deps-installer':
specifier: workspace:*
version: link:../../config/deps-installer
'@pnpm/constants':
specifier: workspace:*
version: link:../../packages/constants

View File

@@ -2,6 +2,7 @@ import fs from 'fs'
import { prepare } from '@pnpm/prepare'
import { getIntegrity } from '@pnpm/registry-mock'
import { sync as rimraf } from '@zkochan/rimraf'
import { sync as readYamlFile } from 'read-yaml-file'
import { sync as writeYamlFile } from 'write-yaml-file'
import { execPnpm } from './utils'
@@ -119,3 +120,14 @@ test('catalog applied by configurational dependency hook', async () => {
},
})
})
test('installing a new configurational dependency', async () => {
prepare()
await execPnpm(['add', '@pnpm.e2e/foo@100.0.0', '--config'])
const workspaceManifest = readYamlFile<{ configDependencies: Record<string, string> }>('pnpm-workspace.yaml')
expect(workspaceManifest.configDependencies).toStrictEqual({
'@pnpm.e2e/foo': `100.0.0+${getIntegrity('@pnpm.e2e/foo', '100.0.0')}`,
})
})