feat: add virtualStoreOnly option to skip post-import linking (#10965)

* feat: add virtualStoreOnly option to skip post-import linking

Adds a new `virtualStoreOnly` config option that populates the virtual
store (standard or GVS) without creating importer symlinks, hoisting,
bin links, or running lifecycle scripts.

- Config: add virtual-store-only to types, Config interface, defaults
- extendInstallOptions: validate against enableModulesDir=false, force
  ignoreScripts=true and empty hoist patterns when enabled
- headless: add skipPostImportLinking flag guarding 7 post-import steps
- core install: guard buildModules, bin linking, and lifecycle hooks
- link.ts: skip hoisting and symlink creation
- fetch command: use virtualStoreOnly internally
- CLI: wire through rcOptionsTypes and installDeps Pick type

Closes #10840

* fix: address virtualStoreOnly review comments

- Remove ignoreScripts=true forcing (allow builds with virtualStoreOnly)
- Allow virtualStoreOnly + enableModulesDir=false when GVS is enabled
- Guard linkHoistedModules with skipPostImportLinking in hoisted branch
- Un-guard buildModules so lifecycle scripts can run with virtualStoreOnly
- Split metadata block so writeModulesManifest persists with virtualStoreOnly
- Add enableModulesDir=true to pnpm fetch to avoid config conflict
- Fix test bugs: dep version 100.0.0→100.1.0, globalVirtualStoreDir→virtualStoreDir
- Add test for virtualStoreOnly + enableModulesDir=false + GVS
- Relax headless import guard to allow GVS with enableModulesDir=false

* fix: pin dep-of-pkg-with-1-dep dist-tag in virtualStoreOnly tests

The tests hardcode dep-of-pkg-with-1-dep@100.1.0 in path assertions
but didn't call addDistTag to pin the latest version. Since test files
run concurrently, other tests can change the dist-tag to 100.0.0,
causing resolution to pick a different version and the path check to
fail.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Victor Sumner
2026-03-15 14:59:27 -04:00
committed by GitHub
parent a61617d517
commit 09a999af04
13 changed files with 320 additions and 77 deletions

View File

@@ -0,0 +1,9 @@
---
"@pnpm/config": patch
"@pnpm/core": minor
"@pnpm/headless": minor
"@pnpm/plugin-commands-installation": minor
"pnpm": minor
---
Added a new setting `virtualStoreOnly` that populates the virtual store without creating importer symlinks, hoisting, bin links, or running lifecycle scripts. This is useful for pre-populating a store (e.g., in Nix builds) without creating unnecessary project-level artifacts. `pnpm fetch` now uses this mode internally [#10840](https://github.com/pnpm/pnpm/issues/10840).

View File

@@ -131,6 +131,7 @@ export interface Config extends AuthInfo, OptionsFromRootManifest {
stateDir: string
storeDir?: string
virtualStoreDir?: string
virtualStoreOnly?: boolean
enableGlobalVirtualStore?: boolean
verifyStoreIntegrity?: boolean
maxSockets?: number

View File

@@ -139,6 +139,7 @@ export const excludedPnpmKeys = [
'global-virtual-store-dir',
'virtual-store-dir',
'virtual-store-dir-max-length',
'virtual-store-only',
'peers-suffix-max-length',
'workspace-concurrency',
'workspace-packages',

View File

@@ -229,6 +229,7 @@ export async function getConfig (opts: {
'embed-readme': false,
'registry-supports-time-field': false,
'virtual-store-dir-max-length': isWindows() ? 60 : 120,
'virtual-store-only': false,
'peers-suffix-max-length': 1000,
}

View File

@@ -123,6 +123,7 @@ export const pnpmTypes = {
'verify-store-integrity': Boolean,
'global-virtual-store-dir': String,
'virtual-store-dir': String,
'virtual-store-only': Boolean,
'virtual-store-dir-max-length': Number,
'peers-suffix-max-length': Number,
'workspace-concurrency': Number,

View File

@@ -125,6 +125,7 @@ export interface StrictInstallOptions {
dir: string
symlink: boolean
enableModulesDir: boolean
virtualStoreOnly: boolean
modulesCacheMaxAge: number
peerDependencyRules: PeerDependencyRules
allowedDeprecatedVersions: AllowedDeprecatedVersions
@@ -267,6 +268,7 @@ const defaults = (opts: InstallOptions): StrictInstallOptions => {
userAgent: `${packageManager.name}/${packageManager.version} npm/? node/${process.version} ${process.platform} ${process.arch}`,
verifyStoreIntegrity: true,
enableModulesDir: true,
virtualStoreOnly: false,
modulesCacheMaxAge: 7 * 24 * 60,
resolveSymlinksInInjectedDirs: false,
dedupeDirectDeps: true,
@@ -313,6 +315,16 @@ export function extendOptions (
packageExtensions: extendedOpts.packageExtensions,
ignoredOptionalDependencies: extendedOpts.ignoredOptionalDependencies,
})
if (extendedOpts.virtualStoreOnly && !extendedOpts.enableModulesDir && !extendedOpts.enableGlobalVirtualStore) {
throw new PnpmError('CONFIG_CONFLICT_VIRTUAL_STORE_ONLY_WITH_NO_MODULES_DIR',
'Cannot use virtualStoreOnly when enableModulesDir is false (the standard virtual store requires node_modules/.pnpm)')
}
if (extendedOpts.virtualStoreOnly) {
// Ensure .modules.yaml records empty hoist patterns so a subsequent
// normal install knows hoisting must be redone from scratch.
extendedOpts.hoistPattern = []
extendedOpts.publicHoistPattern = []
}
if (extendedOpts.lockfileOnly) {
extendedOpts.ignoreScripts = true
if (!extendedOpts.useLockfile) {

View File

@@ -1375,6 +1375,7 @@ const _installInContext: InstallFunction = async (projects, ctx, opts) => {
wantedLockfile: newLockfile,
wantedToBeSkippedPackageIds,
hoistWorkspacePackages: opts.hoistWorkspacePackages,
virtualStoreOnly: opts.virtualStoreOnly,
}
)
stats = result.stats
@@ -1451,7 +1452,7 @@ const _installInContext: InstallFunction = async (projects, ctx, opts) => {
const binWarn = (prefix: string, message: string) => {
logger.info({ message, prefix })
}
if (result.newDepPaths?.length) {
if (result.newDepPaths?.length && !opts.virtualStoreOnly) {
const newPkgs = props<DepPath, DependenciesGraphNode>(result.newDepPaths, dependenciesGraph)
await linkAllBins(newPkgs, dependenciesGraph, {
extraNodePaths: ctx.extraNodePaths,
@@ -1460,7 +1461,7 @@ const _installInContext: InstallFunction = async (projects, ctx, opts) => {
})
}
await Promise.all(projects.map(async (project, index) => {
if (!opts.virtualStoreOnly) await Promise.all(projects.map(async (project, index) => {
let linkedPackages!: string[]
if (ctx.publicHoistPattern?.length && path.relative(project.rootDir, opts.lockfileDir) === '') {
linkedPackages = await linkBins(project.modulesDir, project.binsDir, {
@@ -1559,7 +1560,7 @@ const _installInContext: InstallFunction = async (projects, ctx, opts) => {
})
})(),
])
if (!opts.ignoreScripts) {
if (!opts.ignoreScripts && !opts.virtualStoreOnly) {
if (opts.enablePnp) {
opts.scriptsOpts.extraEnv = {
...opts.scriptsOpts.extraEnv,

View File

@@ -73,6 +73,7 @@ export interface LinkPackagesOptions {
wantedLockfile: LockfileObject
wantedToBeSkippedPackageIds: Set<string>
hoistWorkspacePackages?: boolean
virtualStoreOnly: boolean
}
export interface LinkPackagesResult {
@@ -210,7 +211,7 @@ export async function linkPackages (projects: ImporterToUpdate[], depGraph: Depe
}
let newHoistedDependencies!: HoistedDependencies
if (opts.hoistPattern == null && opts.publicHoistPattern == null) {
if (opts.virtualStoreOnly || (opts.hoistPattern == null && opts.publicHoistPattern == null)) {
newHoistedDependencies = {}
} else if (newDepPaths.length > 0 || removedDepPaths.size > 0) {
newHoistedDependencies = {
@@ -250,7 +251,7 @@ export async function linkPackages (projects: ImporterToUpdate[], depGraph: Depe
}
let linkedToRoot = 0
if (opts.symlink) {
if (opts.symlink && !opts.virtualStoreOnly) {
const projectsToLink = Object.fromEntries(await Promise.all(
projects.map(async ({ id, manifest, modulesDir, rootDir }) => {
const deps = opts.dependenciesByProjectId[id]

View File

@@ -4,7 +4,7 @@ import path from 'node:path'
import { assertProject } from '@pnpm/assert-project'
import { install, type MutatedProject, mutateModules, type ProjectOptions } from '@pnpm/core'
import { prepareEmpty, preparePackages } from '@pnpm/prepare'
import { getIntegrity } from '@pnpm/registry-mock'
import { addDistTag, getIntegrity } from '@pnpm/registry-mock'
import type { PackageFilesIndex } from '@pnpm/store.cafs'
import { StoreIndex, storeIndexKey } from '@pnpm/store.index'
import type { ProjectRootDir } from '@pnpm/types'
@@ -486,3 +486,194 @@ test('injected local packages work with global virtual store', async () => {
expect(injectedDepLocation).toContain('links')
expect(fs.existsSync(path.join(injectedDepLocation!, 'foo.js'))).toBeTruthy()
})
test('virtualStoreOnly populates standard virtual store without importer symlinks', async () => {
await addDistTag({ package: '@pnpm.e2e/dep-of-pkg-with-1-dep', version: '100.1.0', distTag: 'latest' })
prepareEmpty()
const manifest = {
dependencies: {
'@pnpm.e2e/pkg-with-1-dep': '100.0.0',
},
}
await install(manifest, testDefaults({
virtualStoreOnly: true,
}))
// Standard virtual store should be populated
expect(fs.existsSync(path.resolve('node_modules/.pnpm/@pnpm.e2e+pkg-with-1-dep@100.0.0/node_modules/@pnpm.e2e/pkg-with-1-dep/package.json'))).toBeTruthy()
expect(fs.existsSync(path.resolve('node_modules/.pnpm/@pnpm.e2e+dep-of-pkg-with-1-dep@100.1.0/node_modules/@pnpm.e2e/dep-of-pkg-with-1-dep/package.json'))).toBeTruthy()
// Importer-level symlinks should NOT exist
expect(fs.existsSync(path.resolve('node_modules/@pnpm.e2e/pkg-with-1-dep'))).toBeFalsy()
})
test('virtualStoreOnly with enableModulesDir=false throws config error (standard virtual store)', async () => {
prepareEmpty()
await expect(
install({}, testDefaults({
virtualStoreOnly: true,
enableModulesDir: false,
}))
).rejects.toMatchObject({
code: 'ERR_PNPM_CONFIG_CONFLICT_VIRTUAL_STORE_ONLY_WITH_NO_MODULES_DIR',
})
})
test('virtualStoreOnly with enableModulesDir=false works when GVS is enabled', async () => {
prepareEmpty()
const globalVirtualStoreDir = path.resolve('gvs-no-modules')
const manifest = {
dependencies: {
'@pnpm.e2e/pkg-with-1-dep': '100.0.0',
},
}
// First install to generate lockfile (with modules dir enabled)
await install(manifest, testDefaults({
enableGlobalVirtualStore: true,
virtualStoreDir: globalVirtualStoreDir,
}))
rimrafSync('node_modules')
rimrafSync(globalVirtualStoreDir)
// Now install with virtualStoreOnly + enableModulesDir=false + GVS — should NOT throw
await install(manifest, testDefaults({
enableGlobalVirtualStore: true,
virtualStoreDir: globalVirtualStoreDir,
virtualStoreOnly: true,
enableModulesDir: false,
frozenLockfile: true,
}))
// GVS should be populated
const pkgDir = path.join(globalVirtualStoreDir, '@pnpm.e2e/pkg-with-1-dep/100.0.0')
expect(fs.existsSync(pkgDir)).toBeTruthy()
const hashes = fs.readdirSync(pkgDir)
expect(hashes).toHaveLength(1)
expect(fs.existsSync(path.join(pkgDir, hashes[0], 'node_modules/@pnpm.e2e/pkg-with-1-dep/package.json'))).toBeTruthy()
})
test('virtualStoreOnly with GVS populates global virtual store without importer links', async () => {
prepareEmpty()
const globalVirtualStoreDir = path.resolve('gvs')
const manifest = {
dependencies: {
'@pnpm.e2e/pkg-with-1-dep': '100.0.0',
},
}
await install(manifest, testDefaults({
enableGlobalVirtualStore: true,
virtualStoreDir: globalVirtualStoreDir,
virtualStoreOnly: true,
}))
// GVS should be populated
const pkgDir = path.join(globalVirtualStoreDir, '@pnpm.e2e/pkg-with-1-dep/100.0.0')
expect(fs.existsSync(pkgDir)).toBeTruthy()
const hashes = fs.readdirSync(pkgDir)
expect(hashes).toHaveLength(1)
expect(fs.existsSync(path.join(pkgDir, hashes[0], 'node_modules/@pnpm.e2e/pkg-with-1-dep/package.json'))).toBeTruthy()
expect(fs.existsSync(path.join(pkgDir, hashes[0], 'node_modules/@pnpm.e2e/dep-of-pkg-with-1-dep/package.json'))).toBeTruthy()
// Importer-level links should NOT exist
expect(fs.existsSync(path.resolve('node_modules/@pnpm.e2e/pkg-with-1-dep'))).toBeFalsy()
// No hoisted deps
expect(fs.existsSync(path.resolve('node_modules/.pnpm/node_modules/@pnpm.e2e/dep-of-pkg-with-1-dep'))).toBeFalsy()
// No bin links
expect(fs.existsSync(path.resolve('node_modules/.bin'))).toBeFalsy()
})
test('virtualStoreOnly with frozenLockfile populates virtual store without importer symlinks', async () => {
prepareEmpty()
const globalVirtualStoreDir = path.resolve('gvs-frozen')
const manifest = {
dependencies: {
'@pnpm.e2e/pkg-with-1-dep': '100.0.0',
},
}
// First install to generate lockfile
await install(manifest, testDefaults({
enableGlobalVirtualStore: true,
virtualStoreDir: globalVirtualStoreDir,
}))
// Remove node_modules and GVS, then reinstall with frozenLockfile + virtualStoreOnly
rimrafSync('node_modules')
rimrafSync(globalVirtualStoreDir)
await install(manifest, testDefaults({
enableGlobalVirtualStore: true,
virtualStoreDir: globalVirtualStoreDir,
virtualStoreOnly: true,
frozenLockfile: true,
}))
// GVS should be populated
const pkgDir = path.join(globalVirtualStoreDir, '@pnpm.e2e/pkg-with-1-dep/100.0.0')
expect(fs.existsSync(pkgDir)).toBeTruthy()
const hashes = fs.readdirSync(pkgDir)
expect(hashes).toHaveLength(1)
expect(fs.existsSync(path.join(pkgDir, hashes[0], 'node_modules/@pnpm.e2e/pkg-with-1-dep/package.json'))).toBeTruthy()
// Transitive dependency should also be in GVS
expect(fs.existsSync(path.join(pkgDir, hashes[0], 'node_modules/@pnpm.e2e/dep-of-pkg-with-1-dep/package.json'))).toBeTruthy()
// Importer-level symlinks should NOT exist
expect(fs.existsSync(path.resolve('node_modules/@pnpm.e2e/pkg-with-1-dep'))).toBeFalsy()
// No hoisted deps
expect(fs.existsSync(path.resolve('node_modules/.pnpm/node_modules/@pnpm.e2e/dep-of-pkg-with-1-dep'))).toBeFalsy()
// No bin links
expect(fs.existsSync(path.resolve('node_modules/.bin'))).toBeFalsy()
})
test('virtualStoreOnly with frozenLockfile populates standard virtual store without importer symlinks', async () => {
await addDistTag({ package: '@pnpm.e2e/dep-of-pkg-with-1-dep', version: '100.1.0', distTag: 'latest' })
prepareEmpty()
const manifest = {
dependencies: {
'@pnpm.e2e/pkg-with-1-dep': '100.0.0',
},
}
// First install to generate lockfile
await install(manifest, testDefaults())
// Remove node_modules, then reinstall with frozenLockfile + virtualStoreOnly
rimrafSync('node_modules')
await install(manifest, testDefaults({
virtualStoreOnly: true,
frozenLockfile: true,
}))
// Standard virtual store should be populated
expect(fs.existsSync(path.resolve('node_modules/.pnpm/@pnpm.e2e+pkg-with-1-dep@100.0.0/node_modules/@pnpm.e2e/pkg-with-1-dep/package.json'))).toBeTruthy()
expect(fs.existsSync(path.resolve('node_modules/.pnpm/@pnpm.e2e+dep-of-pkg-with-1-dep@100.1.0/node_modules/@pnpm.e2e/dep-of-pkg-with-1-dep/package.json'))).toBeTruthy()
// Importer-level symlinks should NOT exist
expect(fs.existsSync(path.resolve('node_modules/@pnpm.e2e/pkg-with-1-dep'))).toBeFalsy()
// No hoisted deps
expect(fs.existsSync(path.resolve('node_modules/.pnpm/node_modules/@pnpm.e2e/dep-of-pkg-with-1-dep'))).toBeFalsy()
// No bin links
expect(fs.existsSync(path.resolve('node_modules/.bin'))).toBeFalsy()
})
test('virtualStoreOnly suppresses hoisting even with explicit hoistPattern', async () => {
prepareEmpty()
const manifest = {
dependencies: {
'@pnpm.e2e/pkg-with-1-dep': '100.0.0',
},
}
await install(manifest, testDefaults({
virtualStoreOnly: true,
hoistPattern: ['*'],
publicHoistPattern: ['*'],
}))
// Virtual store should be populated
expect(fs.existsSync(path.resolve('node_modules/.pnpm/@pnpm.e2e+pkg-with-1-dep@100.0.0/node_modules/@pnpm.e2e/pkg-with-1-dep/package.json'))).toBeTruthy()
// No hoisted packages (despite hoistPattern: ['*'])
expect(fs.existsSync(path.resolve('node_modules/.pnpm/node_modules/@pnpm.e2e/dep-of-pkg-with-1-dep'))).toBeFalsy()
// No importer-level symlinks
expect(fs.existsSync(path.resolve('node_modules/@pnpm.e2e/pkg-with-1-dep'))).toBeFalsy()
})

View File

@@ -23,6 +23,7 @@ import {
lockfileToDepGraph,
type LockfileToDepGraphOptions,
} from '@pnpm/deps.graph-builder'
import { PnpmError } from '@pnpm/error'
import { hoist, type HoistedWorkspaceProject } from '@pnpm/hoist'
import {
makeNodeRequireOption,
@@ -176,6 +177,7 @@ export interface HeadlessOptions {
resolveSymlinksInInjectedDirs?: boolean
skipped: Set<DepPath>
enableModulesDir?: boolean
virtualStoreOnly?: boolean
nodeLinker?: 'isolated' | 'hoisted' | 'pnp'
useGitBranchLockfile?: boolean
useLockfile?: boolean
@@ -242,6 +244,12 @@ export async function headlessInstall (opts: HeadlessOptions): Promise<Installat
unsafePerm: opts.unsafePerm || false,
}
if (opts.virtualStoreOnly && opts.enableModulesDir === false && !opts.enableGlobalVirtualStore) {
throw new PnpmError('CONFIG_CONFLICT_VIRTUAL_STORE_ONLY_WITH_NO_MODULES_DIR',
'Cannot use virtualStoreOnly when enableModulesDir is false (the standard virtual store requires node_modules/.pnpm)')
}
const skipPostImportLinking = opts.virtualStoreOnly === true
const skipped = opts.skipped || new Set<DepPath>()
const filterOpts = {
include: opts.include,
@@ -391,34 +399,38 @@ export async function headlessInstall (opts: HeadlessOptions): Promise<Installat
let newHoistedDependencies!: HoistedDependencies
let linkedToRoot = 0
if (opts.nodeLinker === 'hoisted' && hierarchy && prevGraph) {
await linkHoistedModules(opts.storeController, graph, prevGraph, hierarchy, {
allowBuild,
depsStateCache,
disableRelinkLocalDirDeps: opts.disableRelinkLocalDirDeps,
force: opts.force,
ignoreScripts: opts.ignoreScripts,
lockfileDir: opts.lockfileDir,
preferSymlinkedExecutables: opts.preferSymlinkedExecutables,
sideEffectsCacheRead: opts.sideEffectsCacheRead,
})
stageLogger.debug({
prefix: lockfileDir,
stage: 'importing_done',
})
if (!skipPostImportLinking) {
await linkHoistedModules(opts.storeController, graph, prevGraph, hierarchy, {
allowBuild,
depsStateCache,
disableRelinkLocalDirDeps: opts.disableRelinkLocalDirDeps,
force: opts.force,
ignoreScripts: opts.ignoreScripts,
lockfileDir: opts.lockfileDir,
preferSymlinkedExecutables: opts.preferSymlinkedExecutables,
sideEffectsCacheRead: opts.sideEffectsCacheRead,
})
stageLogger.debug({
prefix: lockfileDir,
stage: 'importing_done',
})
linkedToRoot = await symlinkDirectDependencies({
directDependenciesByImporterId: symlinkedDirectDependenciesByImporterId!,
dedupe: Boolean(opts.dedupeDirectDeps),
filteredLockfile,
lockfileDir,
projects: selectedProjects,
registries: opts.registries,
symlink: opts.symlink,
})
} else if (opts.enableModulesDir !== false) {
await Promise.all(depNodes.map(async (depNode) => fs.mkdir(depNode.modules, { recursive: true })))
linkedToRoot = await symlinkDirectDependencies({
directDependenciesByImporterId: symlinkedDirectDependenciesByImporterId!,
dedupe: Boolean(opts.dedupeDirectDeps),
filteredLockfile,
lockfileDir,
projects: selectedProjects,
registries: opts.registries,
symlink: opts.symlink,
})
}
} else if (opts.enableModulesDir !== false || opts.enableGlobalVirtualStore) {
if (opts.enableModulesDir !== false) {
await Promise.all(depNodes.map(async (depNode) => fs.mkdir(depNode.modules, { recursive: true })))
}
await Promise.all([
opts.symlink === false
opts.symlink === false || opts.enableModulesDir === false
? Promise.resolve()
: linkAllModules(depNodes, {
optional: opts.include.optionalDependencies,
@@ -442,7 +454,7 @@ export async function headlessInstall (opts: HeadlessOptions): Promise<Installat
stage: 'importing_done',
})
if (opts.ignorePackageManifest !== true && (opts.hoistPattern != null || opts.publicHoistPattern != null)) {
if (opts.ignorePackageManifest !== true && !skipPostImportLinking && (opts.hoistPattern != null || opts.publicHoistPattern != null)) {
newHoistedDependencies = {
...opts.hoistedDependencies,
...await hoist({
@@ -478,19 +490,21 @@ export async function headlessInstall (opts: HeadlessOptions): Promise<Installat
newHoistedDependencies = {}
}
await linkAllBins(graph, {
extraNodePaths: opts.extraNodePaths,
optional: opts.include.optionalDependencies,
preferSymlinkedExecutables: opts.preferSymlinkedExecutables,
warn,
})
if (!skipPostImportLinking) {
await linkAllBins(graph, {
extraNodePaths: opts.extraNodePaths,
optional: opts.include.optionalDependencies,
preferSymlinkedExecutables: opts.preferSymlinkedExecutables,
warn,
})
}
if ((currentLockfile != null) && !equals(importerIds.sort(), Object.keys(filteredLockfile.importers).sort())) {
Object.assign(filteredLockfile.packages!, currentLockfile.packages)
}
/** Skip linking and due to no project manifest */
if (!opts.ignorePackageManifest) {
if (!opts.ignorePackageManifest && !skipPostImportLinking) {
linkedToRoot = await symlinkDirectDependencies({
dedupe: Boolean(opts.dedupeDirectDeps),
directDependenciesByImporterId,
@@ -579,45 +593,47 @@ export async function headlessInstall (opts: HeadlessOptions): Promise<Installat
const projectsToBeBuilt = extendProjectsWithTargetDirs(selectedProjects, injectionTargetsByDepPath)
if (opts.enableModulesDir !== false) {
const rootProjectDeps = !opts.dedupeDirectDeps ? {} : (directDependenciesByImporterId['.'] ?? {})
/** Skip linking and due to no project manifest */
if (!opts.ignorePackageManifest) {
await Promise.all(selectedProjects.map(async (project) => {
if (opts.nodeLinker === 'hoisted' || opts.publicHoistPattern?.length && path.relative(opts.lockfileDir, project.rootDir) === '') {
await linkBinsOfImporter(project, {
extraNodePaths: opts.extraNodePaths,
preferSymlinkedExecutables: opts.preferSymlinkedExecutables,
})
} else {
let directPkgDirs: string[]
if (project.id === '.') {
directPkgDirs = Object.values(directDependenciesByImporterId[project.id])
} else {
directPkgDirs = []
for (const [alias, dir] of Object.entries(directDependenciesByImporterId[project.id])) {
if (rootProjectDeps[alias] !== dir) {
directPkgDirs.push(dir)
}
}
}
await linkBinsOfPackages(
(
await Promise.all(
directPkgDirs.map(async (dir) => ({
location: dir,
manifest: await safeReadProjectManifestOnly(dir),
}))
)
)
.filter(({ manifest }) => manifest != null) as Array<{ location: string, manifest: DependencyManifest }>,
project.binsDir,
{
if (!skipPostImportLinking) {
const rootProjectDeps = !opts.dedupeDirectDeps ? {} : (directDependenciesByImporterId['.'] ?? {})
/** Skip linking and due to no project manifest */
if (!opts.ignorePackageManifest) {
await Promise.all(selectedProjects.map(async (project) => {
if (opts.nodeLinker === 'hoisted' || opts.publicHoistPattern?.length && path.relative(opts.lockfileDir, project.rootDir) === '') {
await linkBinsOfImporter(project, {
extraNodePaths: opts.extraNodePaths,
preferSymlinkedExecutables: opts.preferSymlinkedExecutables,
})
} else {
let directPkgDirs: string[]
if (project.id === '.') {
directPkgDirs = Object.values(directDependenciesByImporterId[project.id])
} else {
directPkgDirs = []
for (const [alias, dir] of Object.entries(directDependenciesByImporterId[project.id])) {
if (rootProjectDeps[alias] !== dir) {
directPkgDirs.push(dir)
}
}
}
)
}
}))
await linkBinsOfPackages(
(
await Promise.all(
directPkgDirs.map(async (dir) => ({
location: dir,
manifest: await safeReadProjectManifestOnly(dir),
}))
)
)
.filter(({ manifest }) => manifest != null) as Array<{ location: string, manifest: DependencyManifest }>,
project.binsDir,
{
extraNodePaths: opts.extraNodePaths,
preferSymlinkedExecutables: opts.preferSymlinkedExecutables,
}
)
}
}))
}
}
const injectedDeps: Record<string, string[]> = {}
for (const project of projectsToBeBuilt) {
@@ -671,7 +687,7 @@ export async function headlessInstall (opts: HeadlessOptions): Promise<Installat
summaryLogger.debug({ prefix: lockfileDir })
if (!opts.ignoreScripts && !opts.ignorePackageManifest) {
if (!opts.ignoreScripts && !opts.ignorePackageManifest && !skipPostImportLinking) {
await runLifecycleHooksConcurrently(
['preinstall', 'install', 'postinstall', 'preprepare', 'prepare', 'postprepare'],
projectsToBeBuilt,

View File

@@ -76,5 +76,12 @@ export async function handler (opts: FetchCommandOptions): Promise<void> {
// to let the subsequent install know that hoisting should be performed.
hoistPattern: [],
publicHoistPattern: [],
// virtualStoreOnly skips post-import linking (symlinks, bins, hoisting)
// even if ignorePackageManifest handling changes in the future.
virtualStoreOnly: true,
// Ensure fetch can populate the virtual store even when the user has
// enable-modules-dir=false in their config — fetch always needs node_modules/.pnpm
// (unless GVS is active, in which case enableModulesDir doesn't matter).
enableModulesDir: true,
} as InstallOptions)
}

View File

@@ -73,6 +73,7 @@ export function rcOptionsTypes (): Record<string, unknown> {
'unsafe-perm',
'verify-store-integrity',
'virtual-store-dir',
'virtual-store-only',
], allTypes)
}

View File

@@ -69,6 +69,7 @@ export type InstallDepsOptions = Pick<Config,
| 'depth'
| 'dev'
| 'enableGlobalVirtualStore'
| 'virtualStoreOnly'
| 'engineStrict'
| 'excludeLinksFromLockfile'
| 'global'