mirror of
https://github.com/pnpm/pnpm.git
synced 2026-04-28 11:01:30 -04:00
feat: install config dependencies into the global virtual store (#10910)
* feat: install config dependencies into the global virtual store
Config dependencies are now imported into {storeDir}/links/ following the
same path convention as regular packages (@scope/name/version/hash), then
symlinked into node_modules/.pnpm-config/. When the package already exists
in the GVS, the fetch and import are skipped entirely.
* refactor: extract shared GVS path computation into @pnpm/calc-dep-state
Move the leaf node hash computation from config deps-installer into
calcLeafGlobalVirtualStorePath in @pnpm/calc-dep-state to avoid
duplicating the hash logic.
This commit is contained in:
6
.changeset/config-deps-global-virtual-store.md
Normal file
6
.changeset/config-deps-global-virtual-store.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@pnpm/config.deps-installer": minor
|
||||
"pnpm": minor
|
||||
---
|
||||
|
||||
Config dependencies are now installed into the global virtual store (`{storeDir}/links/`) and symlinked into `node_modules/.pnpm-config/`. This allows config dependencies to be shared across projects that use the same store, avoiding redundant fetches and imports.
|
||||
@@ -36,6 +36,7 @@ export async function getConfig (
|
||||
registries: config.registries,
|
||||
rootDir: config.lockfileDir ?? config.rootProjectManifestDir,
|
||||
store: store.ctrl,
|
||||
storeDir: store.dir,
|
||||
})
|
||||
}
|
||||
if (!config.ignorePnpmfile) {
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"compile": "tsgo --build && pnpm run lint --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pnpm/calc-dep-state": "workspace:*",
|
||||
"@pnpm/config.config-writer": "workspace:*",
|
||||
"@pnpm/core-loggers": "workspace:*",
|
||||
"@pnpm/error": "workspace:*",
|
||||
@@ -46,7 +47,8 @@
|
||||
"@pnpm/read-package-json": "workspace:*",
|
||||
"@pnpm/types": "workspace:*",
|
||||
"@zkochan/rimraf": "catalog:",
|
||||
"get-npm-tarball-url": "catalog:"
|
||||
"get-npm-tarball-url": "catalog:",
|
||||
"symlink-dir": "catalog:"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@pnpm/logger": "catalog:"
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { calcLeafGlobalVirtualStorePath } from '@pnpm/calc-dep-state'
|
||||
import { installingConfigDepsLogger } from '@pnpm/core-loggers'
|
||||
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 { ConfigDependencies, Registries } from '@pnpm/types'
|
||||
import symlinkDir from 'symlink-dir'
|
||||
import { normalizeConfigDeps } from './normalizeConfigDeps.js'
|
||||
|
||||
export interface InstallConfigDepsOpts {
|
||||
registries: Registries
|
||||
rootDir: string
|
||||
store: StoreController
|
||||
storeDir: string
|
||||
}
|
||||
|
||||
export async function installConfigDeps (configDeps: ConfigDependencies, opts: InstallConfigDepsOpts): Promise<void> {
|
||||
const globalVirtualStoreDir = path.join(opts.storeDir, 'links')
|
||||
const configModulesDir = path.join(opts.rootDir, 'node_modules/.pnpm-config')
|
||||
const existingConfigDeps: string[] = await readModulesDir(configModulesDir) ?? []
|
||||
await Promise.all(existingConfigDeps.map(async (existingConfigDep) => {
|
||||
@@ -28,29 +33,37 @@ export async function installConfigDeps (configDeps: ConfigDependencies, opts: I
|
||||
})
|
||||
await Promise.all(Object.entries(normalizedConfigDeps).map(async ([pkgName, pkg]) => {
|
||||
const configDepPath = path.join(configModulesDir, pkgName)
|
||||
if (existingConfigDeps.includes(pkgName)) {
|
||||
const configDepPkgJson = await safeReadPackageJsonFromDir(configDepPath)
|
||||
if (configDepPkgJson == null || configDepPkgJson.name !== pkgName || configDepPkgJson.version !== pkg.version) {
|
||||
await rimraf(configDepPath)
|
||||
} else {
|
||||
return
|
||||
}
|
||||
const existingPkgJson = existingConfigDeps.includes(pkgName)
|
||||
? await safeReadPackageJsonFromDir(configDepPath)
|
||||
: null
|
||||
if (existingPkgJson != null && existingPkgJson.name === pkgName && existingPkgJson.version === pkg.version) {
|
||||
return
|
||||
}
|
||||
installingConfigDepsLogger.debug({ status: 'started' })
|
||||
const { fetching } = await opts.store.fetchPackage({
|
||||
force: true,
|
||||
lockfileDir: opts.rootDir,
|
||||
pkg: {
|
||||
id: `${pkgName}@${pkg.version}`,
|
||||
resolution: pkg.resolution,
|
||||
},
|
||||
})
|
||||
const { files: filesResponse } = await fetching()
|
||||
await opts.store.importPackage(configDepPath, {
|
||||
force: true,
|
||||
requiresBuild: false,
|
||||
filesResponse,
|
||||
})
|
||||
const fullPkgId = `${pkgName}@${pkg.version}:${pkg.resolution.integrity}`
|
||||
const relPath = calcLeafGlobalVirtualStorePath(fullPkgId, pkgName, pkg.version)
|
||||
const pkgDirInGlobalVirtualStore = path.join(globalVirtualStoreDir, relPath, 'node_modules', pkgName)
|
||||
if (!fs.existsSync(path.join(pkgDirInGlobalVirtualStore, 'package.json'))) {
|
||||
const { fetching } = await opts.store.fetchPackage({
|
||||
force: true,
|
||||
lockfileDir: opts.rootDir,
|
||||
pkg: {
|
||||
id: `${pkgName}@${pkg.version}`,
|
||||
resolution: pkg.resolution,
|
||||
},
|
||||
})
|
||||
const { files: filesResponse } = await fetching()
|
||||
await opts.store.importPackage(pkgDirInGlobalVirtualStore, {
|
||||
force: true,
|
||||
requiresBuild: false,
|
||||
filesResponse,
|
||||
})
|
||||
}
|
||||
if (existingConfigDeps.includes(pkgName)) {
|
||||
await rimraf(configDepPath)
|
||||
}
|
||||
await fs.promises.mkdir(path.dirname(configDepPath), { recursive: true })
|
||||
await symlinkDir(pkgDirInGlobalVirtualStore, configDepPath)
|
||||
installedConfigDeps.push({
|
||||
name: pkgName,
|
||||
version: pkg.version,
|
||||
|
||||
@@ -9,7 +9,7 @@ const registry = `http://localhost:${REGISTRY_MOCK_PORT}/`
|
||||
|
||||
test('configuration dependency is installed', async () => {
|
||||
prepareEmpty()
|
||||
const { storeController } = createTempStore()
|
||||
const { storeController, storeDir } = createTempStore()
|
||||
|
||||
let configDeps: Record<string, string> = {
|
||||
'@pnpm.e2e/foo': `100.0.0+${getIntegrity('@pnpm.e2e/foo', '100.0.0')}`,
|
||||
@@ -20,12 +20,15 @@ test('configuration dependency is installed', async () => {
|
||||
},
|
||||
rootDir: process.cwd(),
|
||||
store: storeController,
|
||||
storeDir,
|
||||
})
|
||||
|
||||
{
|
||||
const configDepManifest = loadJsonFileSync<{ name: string, version: string }>('node_modules/.pnpm-config/@pnpm.e2e/foo/package.json')
|
||||
expect(configDepManifest.name).toBe('@pnpm.e2e/foo')
|
||||
expect(configDepManifest.version).toBe('100.0.0')
|
||||
// The local path should be a symlink to the global virtual store
|
||||
expect(fs.lstatSync('node_modules/.pnpm-config/@pnpm.e2e/foo').isSymbolicLink()).toBe(true)
|
||||
}
|
||||
|
||||
// Dependency is updated
|
||||
@@ -37,6 +40,7 @@ test('configuration dependency is installed', async () => {
|
||||
},
|
||||
rootDir: process.cwd(),
|
||||
store: storeController,
|
||||
storeDir,
|
||||
})
|
||||
|
||||
{
|
||||
@@ -54,6 +58,7 @@ test('configuration dependency is installed', async () => {
|
||||
},
|
||||
rootDir: process.cwd(),
|
||||
store: storeController,
|
||||
storeDir,
|
||||
})
|
||||
|
||||
expect(fs.existsSync('node_modules/.pnpm-config/@pnpm.e2e/foo/package.json')).toBeFalsy()
|
||||
@@ -61,7 +66,7 @@ test('configuration dependency is installed', async () => {
|
||||
|
||||
test('installation fails if the checksum of the config dependency is invalid', async () => {
|
||||
prepareEmpty()
|
||||
const { storeController } = createTempStore({
|
||||
const { storeController, storeDir } = createTempStore({
|
||||
clientOptions: {
|
||||
retry: {
|
||||
retries: 0,
|
||||
@@ -78,12 +83,13 @@ test('installation fails if the checksum of the config dependency is invalid', a
|
||||
},
|
||||
rootDir: process.cwd(),
|
||||
store: storeController,
|
||||
storeDir,
|
||||
})).rejects.toThrow('Got unexpected checksum for')
|
||||
})
|
||||
|
||||
test('installation fails if the config dependency does not have a checksum', async () => {
|
||||
prepareEmpty()
|
||||
const { storeController } = createTempStore({
|
||||
const { storeController, storeDir } = createTempStore({
|
||||
clientOptions: {
|
||||
retry: {
|
||||
retries: 0,
|
||||
@@ -100,5 +106,6 @@ test('installation fails if the config dependency does not have a checksum', asy
|
||||
},
|
||||
rootDir: process.cwd(),
|
||||
store: storeController,
|
||||
storeDir,
|
||||
})).rejects.toThrow("doesn't have an integrity checksum")
|
||||
})
|
||||
|
||||
@@ -9,7 +9,7 @@ const registry = `http://localhost:${REGISTRY_MOCK_PORT}/`
|
||||
|
||||
test('configuration dependency is resolved', async () => {
|
||||
prepareEmpty()
|
||||
const { storeController } = createTempStore()
|
||||
const { storeController, storeDir } = createTempStore()
|
||||
|
||||
await resolveConfigDeps(['@pnpm.e2e/foo@100.0.0'], {
|
||||
registries: {
|
||||
@@ -19,7 +19,7 @@ test('configuration dependency is resolved', async () => {
|
||||
cacheDir: path.resolve('cache'),
|
||||
userConfig: {},
|
||||
store: storeController,
|
||||
storeDir: '.store',
|
||||
storeDir,
|
||||
})
|
||||
|
||||
const workspaceManifest = readYamlFile<{ configDependencies: Record<string, string> }>('pnpm-workspace.yaml')
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
{
|
||||
"path": "../../network/fetch"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/calc-dep-state"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/core-loggers"
|
||||
},
|
||||
|
||||
@@ -133,14 +133,22 @@ export function calcGraphNodeHash<T extends PkgMeta> (
|
||||
// so they survive Node.js upgrades and architecture changes.
|
||||
const includeEngine = builtDepPaths === undefined ||
|
||||
transitivelyRequiresBuild(graph, builtDepPaths, buildRequiredCache ??= {}, depPath, new Set())
|
||||
const state = {
|
||||
engine: includeEngine ? ENGINE_NAME : null,
|
||||
deps: calcDepGraphHash(graph, cache, new Set(), depPath),
|
||||
}
|
||||
const hexDigest = hashObjectWithoutSorting(state, { encoding: 'hex' })
|
||||
// Use @/ prefix for unscoped packages to maintain uniform 4-level directory depth
|
||||
// Scoped: @scope/pkg/version/hash
|
||||
// Unscoped: @/pkg/version/hash
|
||||
const engine = includeEngine ? ENGINE_NAME : null
|
||||
const deps = calcDepGraphHash(graph, cache, new Set(), depPath)
|
||||
const hexDigest = hashObjectWithoutSorting({ engine, deps }, { encoding: 'hex' })
|
||||
return formatGlobalVirtualStorePath(name, version, hexDigest)
|
||||
}
|
||||
|
||||
export function calcLeafGlobalVirtualStorePath (fullPkgId: string, name: string, version: string): string {
|
||||
const depsHash = hashObject({ id: fullPkgId, deps: {} })
|
||||
const hexDigest = hashObjectWithoutSorting({ engine: null, deps: depsHash }, { encoding: 'hex' })
|
||||
return formatGlobalVirtualStorePath(name, version, hexDigest)
|
||||
}
|
||||
|
||||
// Use @/ prefix for unscoped packages to maintain uniform 4-level directory depth
|
||||
// Scoped: @scope/pkg/version/hash
|
||||
// Unscoped: @/pkg/version/hash
|
||||
function formatGlobalVirtualStorePath (name: string, version: string, hexDigest: string): string {
|
||||
const prefix = name.startsWith('@') ? '' : '@/'
|
||||
return `${prefix}${name}/${version}/${hexDigest}`
|
||||
}
|
||||
|
||||
@@ -223,6 +223,7 @@ export async function handler (
|
||||
await resolveConfigDeps(params, {
|
||||
...opts,
|
||||
store: store.ctrl,
|
||||
storeDir: store.dir,
|
||||
rootDir: opts.workspaceDir ?? opts.rootProjectManifestDir,
|
||||
})
|
||||
return
|
||||
|
||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -1753,6 +1753,9 @@ importers:
|
||||
|
||||
config/deps-installer:
|
||||
dependencies:
|
||||
'@pnpm/calc-dep-state':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/calc-dep-state
|
||||
'@pnpm/config.config-writer':
|
||||
specifier: workspace:*
|
||||
version: link:../config-writer
|
||||
@@ -1798,6 +1801,9 @@ importers:
|
||||
get-npm-tarball-url:
|
||||
specifier: 'catalog:'
|
||||
version: 2.1.0
|
||||
symlink-dir:
|
||||
specifier: 'catalog:'
|
||||
version: 7.1.0
|
||||
devDependencies:
|
||||
'@pnpm/config.deps-installer':
|
||||
specifier: workspace:*
|
||||
|
||||
@@ -35,7 +35,8 @@ test('patch from configuration dependency is applied via updateConfig hook', asy
|
||||
expect(fs.existsSync('node_modules/@pnpm.e2e/foo/index.js')).toBeTruthy()
|
||||
|
||||
const lockfile = project.readLockfile()
|
||||
expect(lockfile.patchedDependencies['@pnpm.e2e/foo'].path).toBe('node_modules/.pnpm-config/@pnpm.e2e/has-patch-for-foo/@pnpm.e2e__foo@100.0.0.patch')
|
||||
// The patch path goes through the global virtual store since config deps are symlinked there
|
||||
expect(lockfile.patchedDependencies['@pnpm.e2e/foo'].path).toContain('@pnpm.e2e/has-patch-for-foo/@pnpm.e2e__foo@100.0.0.patch')
|
||||
})
|
||||
|
||||
test('catalog applied by configurational dependency hook', async () => {
|
||||
|
||||
Reference in New Issue
Block a user