fix: update GVS symlinks after approve-builds by running install (#11043)

Fixes #11042

- **Root cause**: When `enableGlobalVirtualStore` is true and `allowBuilds` is not configured, `createAllowBuildFunction()` returned `undefined`, causing all GVS hashes to include `ENGINE_NAME`. When `approve-builds` later configured `allowBuilds`, the hash didn't change because the engine was already included.
- **Fix**: Default `allowBuilds` to `{}` in GVS mode so hashes are engine-agnostic by default, and have `approve-builds` call `install.handler()` in GVS mode instead of the low-level `install()` function, so it properly handles workspaces and updates symlinks.
- **Refactor**: Broke circular dependencies between `building/commands`, `installing/commands`, and `global/commands` using dependency injection via a `commands` map passed as the third argument to command handlers. Added `CommandHandler` and `CommandHandlerMap` types to `@pnpm/cli.command`.

## Changes

### Architecture
- Command handlers now receive a `commands` map as an optional third argument `(opts, params, commands?)`
- The CLI dispatcher in `main.ts` passes the full commands map to every handler
- Handlers that need other commands (e.g., `globalAdd` needs `approve-builds`, `recursive` needs `rebuild`) access them from this map
- This replaces direct cross-package imports that would create circular dependencies

### Packages changed
- `@pnpm/cli.command` — new `CommandHandler` and `CommandHandlerMap` types
- `@pnpm/building.commands` — `approve-builds` uses `install.handler` for GVS
- `@pnpm/global.commands` — removed `building/commands` dependency; receives `approve-builds` via commands map
- `@pnpm/installing.commands` — receives `rebuild` via commands map instead of direct import
- `@pnpm/installing.deps-installer` / `@pnpm/installing.deps-restorer` — default `allowBuilds` to `{}` in GVS mode
- `pnpm` CLI — dispatcher passes commands map to all handlers
This commit is contained in:
Zoltan Kochan
2026-03-21 12:50:46 +01:00
committed by GitHub
parent 659bb13793
commit 9fc552d37a
31 changed files with 245 additions and 72 deletions

View File

@@ -0,0 +1,9 @@
---
"@pnpm/building.commands": patch
"@pnpm/installing.commands": patch
"@pnpm/installing.deps-installer": patch
"@pnpm/installing.deps-restorer": patch
"pnpm": patch
---
In GVS mode, `pnpm approve-builds` now runs a full install instead of rebuild. This ensures that GVS hash directories and symlinks are updated correctly after changing `allowBuilds`, preventing build artifact contamination of engine-agnostic directories [#11042](https://github.com/pnpm/pnpm/issues/11042).

View File

@@ -33,12 +33,14 @@
},
"dependencies": {
"@pnpm/building.after-install": "workspace:*",
"@pnpm/cli.command": "workspace:*",
"@pnpm/cli.common-cli-options-help": "workspace:*",
"@pnpm/cli.utils": "workspace:*",
"@pnpm/config.reader": "workspace:*",
"@pnpm/config.writer": "workspace:*",
"@pnpm/deps.path": "workspace:*",
"@pnpm/error": "workspace:*",
"@pnpm/installing.commands": "workspace:*",
"@pnpm/installing.modules-yaml": "workspace:*",
"@pnpm/prepare-temp-dir": "workspace:*",
"@pnpm/store.connection-manager": "workspace:*",

View File

@@ -55,7 +55,7 @@ For options that may be used with `-r`, see "pnpm help recursive"',
shortAlias: '-r',
},
{
description: 'Rebuild packages that were not build during installation. Packages are not build when installing with the --ignore-scripts flag',
description: 'Rebuild packages that were not built during installation. Packages are not built when installing with the --ignore-scripts flag',
name: '--pending',
},
{

View File

@@ -1,4 +1,3 @@
export { rebuild } from './build/index.js'
export type { RebuildCommandOpts } from './build/rebuild.js'
export { rebuild, type RebuildCommandOpts } from './build/index.js'
export type { ApproveBuildsCommandOpts } from './policy/approveBuilds.js'
export { approveBuilds, ignoredBuilds } from './policy/index.js'

View File

@@ -1,8 +1,9 @@
import { rebuild, type RebuildCommandOpts } from '@pnpm/building.commands'
import type { CommandHandlerMap } from '@pnpm/cli.command'
import type { Config } from '@pnpm/config.reader'
import { writeSettings } from '@pnpm/config.writer'
import { parse } from '@pnpm/deps.path'
import { PnpmError } from '@pnpm/error'
import { install } from '@pnpm/installing.commands'
import { type StrictModules, writeModulesManifest } from '@pnpm/installing.modules-yaml'
import { globalInfo } from '@pnpm/logger'
import { lexCompare } from '@pnpm/util.lex-comparator'
@@ -10,9 +11,10 @@ import chalk from 'chalk'
import enquirer from 'enquirer'
import { renderHelp } from 'render-help'
import { rebuild, type RebuildCommandOpts } from '../build/index.js'
import { getAutomaticallyIgnoredBuilds } from './getAutomaticallyIgnoredBuilds.js'
export type ApproveBuildsCommandOpts = Pick<Config, 'modulesDir' | 'dir' | 'rootProjectManifest' | 'rootProjectManifestDir' | 'allowBuilds'> & { all?: boolean, global?: boolean }
export type ApproveBuildsCommandOpts = Pick<Config, 'modulesDir' | 'dir' | 'rootProjectManifest' | 'rootProjectManifestDir' | 'allowBuilds' | 'enableGlobalVirtualStore'> & { all?: boolean, global?: boolean }
export const commandNames = ['approve-builds']
@@ -49,7 +51,7 @@ export function rcOptionsTypes (): Record<string, unknown> {
return {}
}
export async function handler (opts: ApproveBuildsCommandOpts & RebuildCommandOpts, params: string[] = []): Promise<void> {
export async function handler (opts: ApproveBuildsCommandOpts & RebuildCommandOpts, params: string[] = [], commands?: CommandHandlerMap): Promise<void> {
if (opts.global) {
throw new PnpmError(
'APPROVE_BUILDS_NOT_SUPPORTED_WITH_GLOBAL',
@@ -201,6 +203,15 @@ Do you approve?`,
await writeModulesManifest(modulesDir, modulesManifest as StrictModules)
}
if (buildPackages.length) {
if (opts.enableGlobalVirtualStore) {
await install.handler({
...opts,
allowBuilds,
frozenLockfile: true,
optimisticRepeatInstall: false,
} as any, [], commands) // eslint-disable-line @typescript-eslint/no-explicit-any
return
}
return rebuild.handler({
...opts,
allowBuilds,

View File

@@ -23,7 +23,7 @@ const prompt = jest.mocked(enquirer.prompt)
const REGISTRY = `http://localhost:${REGISTRY_MOCK_PORT}/`
const pnpmBin = path.join(import.meta.dirname, '../../../../pnpm/bin/pnpm.mjs')
async function execPnpmInstall (): Promise<void> {
async function execPnpmInstall (opts?: { enableGlobalVirtualStore?: boolean }): Promise<void> {
await execa('node', [
pnpmBin,
'install',
@@ -31,7 +31,7 @@ async function execPnpmInstall (): Promise<void> {
`--cache-dir=${path.resolve('cache')}`,
`--registry=${REGISTRY}`,
'--config.strict-dep-builds=false',
'--config.enable-global-virtual-store=false',
`--config.enable-global-virtual-store=${opts?.enableGlobalVirtualStore ?? false}`,
])
}
@@ -70,7 +70,7 @@ async function approveSomeBuilds (opts?: ApproveBuildsOptions) {
build: true,
})
await approveBuilds.handler({ ...config, ...opts })
await approveBuilds.handler({ ...config, ...opts }, [], {})
}
async function approveNoBuilds (opts?: ApproveBuildsOptions) {
@@ -81,7 +81,7 @@ async function approveNoBuilds (opts?: ApproveBuildsOptions) {
result: [],
})
await approveBuilds.handler({ ...config, ...opts })
await approveBuilds.handler({ ...config, ...opts }, [], {})
}
test('approve selected build', async () => {
@@ -155,7 +155,7 @@ test("works when root project manifest doesn't exist in a workspace", async () =
result: [{ value: '@pnpm.e2e/pre-and-postinstall-scripts-example' }],
})
prompt.mockResolvedValueOnce({ build: true })
await approveBuilds.handler({ ...config, workspaceDir, rootProjectManifestDir: workspaceDir })
await approveBuilds.handler({ ...config, workspaceDir, rootProjectManifestDir: workspaceDir }, [], {})
expect(readYamlFileSync(workspaceManifestFile)).toStrictEqual({
packages: ['packages/*'],
@@ -203,7 +203,7 @@ test('approve all builds with --all flag', async () => {
const config = await getApproveBuildsConfig()
prompt.mockClear()
await approveBuilds.handler({ ...config, all: true })
await approveBuilds.handler({ ...config, all: true }, [], {})
expect(prompt).not.toHaveBeenCalled()
@@ -230,7 +230,7 @@ test('approve builds via positional arguments', async () => {
const config = await getApproveBuildsConfig()
prompt.mockClear()
await approveBuilds.handler(config, ['@pnpm.e2e/pre-and-postinstall-scripts-example'])
await approveBuilds.handler(config, ['@pnpm.e2e/pre-and-postinstall-scripts-example'], {})
expect(prompt).not.toHaveBeenCalled()
@@ -263,7 +263,7 @@ test('deny builds via !pkg positional arguments', async () => {
await approveBuilds.handler(config, [
'@pnpm.e2e/pre-and-postinstall-scripts-example',
'!@pnpm.e2e/install-script-example',
])
], {})
expect(prompt).not.toHaveBeenCalled()
@@ -291,7 +291,7 @@ test('deny-only via !pkg keeps other builds pending', async () => {
prompt.mockClear()
await approveBuilds.handler(config, [
'!@pnpm.e2e/install-script-example',
])
], {})
expect(prompt).not.toHaveBeenCalled()
@@ -319,7 +319,7 @@ test('positional arguments with unknown package throws error', async () => {
const config = await getApproveBuildsConfig()
await expect(
approveBuilds.handler(config, ['@pnpm.e2e/nonexistent-package'])
approveBuilds.handler(config, ['@pnpm.e2e/nonexistent-package'], {})
).rejects.toThrow('not awaiting approval')
})
@@ -334,7 +334,7 @@ test('!pkg with unknown package throws error', async () => {
const config = await getApproveBuildsConfig()
await expect(
approveBuilds.handler(config, ['!@pnpm.e2e/nonexistent-package'])
approveBuilds.handler(config, ['!@pnpm.e2e/nonexistent-package'], {})
).rejects.toThrow('not awaiting approval')
})
@@ -352,7 +352,7 @@ test('contradictory arguments throw error', async () => {
approveBuilds.handler(config, [
'@pnpm.e2e/pre-and-postinstall-scripts-example',
'!@pnpm.e2e/pre-and-postinstall-scripts-example',
])
], {})
).rejects.toThrow('both approved and denied')
})
@@ -367,7 +367,7 @@ test('--all with positional arguments throws error', async () => {
const config = await getApproveBuildsConfig()
await expect(
approveBuilds.handler({ ...config, all: true }, ['@pnpm.e2e/pre-and-postinstall-scripts-example'])
approveBuilds.handler({ ...config, all: true }, ['@pnpm.e2e/pre-and-postinstall-scripts-example'], {})
).rejects.toThrow('Cannot use --all with positional arguments')
})
@@ -401,7 +401,7 @@ test('positional args preserve existing allowBuilds entries', async () => {
allowBuilds: {
'@pnpm.e2e/existing-package': true,
},
}, ['@pnpm.e2e/pre-and-postinstall-scripts-example'])
}, ['@pnpm.e2e/pre-and-postinstall-scripts-example'], {})
const manifest = readYamlFileSync<any>(workspaceManifestFile) // eslint-disable-line
expect(manifest.allowBuilds['@pnpm.e2e/existing-package']).toBe(true)
@@ -449,7 +449,7 @@ test('should retain existing allowBuilds entries when approving builds', async (
'@pnpm.e2e/test': false,
'@pnpm.e2e/install-script-example': true,
},
})
}, [], {})
expect(readYamlFileSync(workspaceManifestFile)).toStrictEqual({
packages: ['packages/*'],

View File

@@ -24,6 +24,9 @@
{
"path": "../../__utils__/test-ipc-server"
},
{
"path": "../../cli/command"
},
{
"path": "../../cli/common-cli-options-help"
},
@@ -54,6 +57,9 @@
{
"path": "../../deps/path"
},
{
"path": "../../installing/commands"
},
{
"path": "../../installing/modules-yaml"
},

View File

@@ -4,3 +4,8 @@ export type CompletionFunc = (
options: Record<string, unknown>,
params: string[]
) => Promise<CompletionItem[]>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type CommandHandler = (opts: any, params: string[], commands?: CommandHandlerMap) => any
export type CommandHandlerMap = Record<string, CommandHandler>

View File

@@ -383,6 +383,13 @@ export async function getConfig (opts: {
} else if (!pnpmConfig.bin) {
pnpmConfig.bin = path.join(pnpmConfig.dir, 'node_modules', '.bin')
}
// Default allowBuilds to {} when GVS is enabled so that GVS hashes
// are engine-agnostic when no build policy is configured. Without
// this, allowBuilds is undefined which makes createAllowBuildFunction
// return undefined, causing all hashes to include ENGINE_NAME.
if (pnpmConfig.enableGlobalVirtualStore && pnpmConfig.allowBuilds == null) {
pnpmConfig.allowBuilds = {}
}
pnpmConfig.packageManager = packageManager
if (!opts.ignoreLocalSettings) {

View File

@@ -32,6 +32,7 @@
},
"dependencies": {
"@pnpm/bins.linker": "workspace:*",
"@pnpm/building.policy": "workspace:*",
"@pnpm/cli.meta": "workspace:*",
"@pnpm/cli.utils": "workspace:*",
"@pnpm/config.reader": "workspace:*",

View File

@@ -3,6 +3,7 @@ import path from 'node:path'
import util from 'node:util'
import { linkBins } from '@pnpm/bins.linker'
import { createAllowBuildFunction } from '@pnpm/building.policy'
import { getCurrentPackageName } from '@pnpm/cli.meta'
import {
iterateHashedGraphNodes,
@@ -23,6 +24,10 @@ import type { StoreController } from '@pnpm/store.controller'
import type { DepPath, ProjectId, ProjectRootDir, Registries } from '@pnpm/types'
import { symlinkDir } from 'symlink-dir'
// @pnpm/exe has platform-specific binaries, so its GVS hash must
// include ENGINE_NAME for correct per-platform resolution.
const PNPM_ALLOW_BUILDS: Record<string, boolean> = { '@pnpm/exe': true }
export interface InstallPnpmResult {
binDir: string
baseDir: string
@@ -82,7 +87,7 @@ export async function installPnpmToStore (
const globalVirtualStoreDir = path.join(opts.storeDir, 'links')
// Compute the GVS hash for the pnpm package to find its path
const pnpmGvsPath = findPnpmGvsPath(wantedLockfile, currentPkgName, globalVirtualStoreDir)
const pnpmGvsPath = findPnpmGvsPath(wantedLockfile, currentPkgName, globalVirtualStoreDir, PNPM_ALLOW_BUILDS)
const pnpmPkgDir = path.join(pnpmGvsPath, 'node_modules', currentPkgName)
const binDir = path.join(pnpmGvsPath, 'bin')
@@ -102,6 +107,7 @@ export async function installPnpmToStore (
try {
await installFromLockfile(tmpInstallDir, binDir, {
wantedLockfile,
allowBuilds: PNPM_ALLOW_BUILDS,
storeController: opts.storeController,
storeDir: opts.storeDir,
registries: opts.registries,
@@ -126,11 +132,13 @@ function noop (_message: string) {}
function findPnpmGvsPath (
lockfile: LockfileObject,
pkgName: string,
globalVirtualStoreDir: string
globalVirtualStoreDir: string,
allowBuilds?: Record<string, boolean | string>
): string {
const graph = lockfileToDepGraph(lockfile)
const pkgMetaIterator = iteratePkgMeta(lockfile, graph)
for (const { hash, pkgMeta } of iterateHashedGraphNodes(graph, pkgMetaIterator)) {
const allowBuild = createAllowBuildFunction({ allowBuilds })
for (const { hash, pkgMeta } of iterateHashedGraphNodes(graph, pkgMetaIterator, allowBuild)) {
if (pkgMeta.name === pkgName) {
return path.join(globalVirtualStoreDir, hash)
}
@@ -181,6 +189,7 @@ async function installPnpmToGlobalDir (
if (wantedLockfile != null && opts.storeController != null && opts.storeDir != null) {
await installFromLockfile(installDir, binDir, {
wantedLockfile,
allowBuilds: PNPM_ALLOW_BUILDS,
storeController: opts.storeController,
storeDir: opts.storeDir,
registries: opts.registries as Registries,
@@ -215,6 +224,7 @@ async function installFromLockfile (
binDir: string,
opts: {
wantedLockfile: LockfileObject
allowBuilds?: Record<string, boolean | string>
storeController: StoreController
storeDir: string
registries: Registries
@@ -234,6 +244,7 @@ async function installFromLockfile (
registries: opts.registries,
enableGlobalVirtualStore: true,
globalVirtualStoreDir: path.join(opts.storeDir, 'links'),
allowBuilds: opts.allowBuilds,
ignoreScripts: true,
ignoreDepScripts: true,
force: false,

View File

@@ -16,6 +16,9 @@
{
"path": "../../../bins/linker"
},
{
"path": "../../../building/policy"
},
{
"path": "../../../cli/meta"
},

View File

@@ -35,7 +35,7 @@
"@pnpm/bins.linker": "workspace:*",
"@pnpm/bins.remover": "workspace:*",
"@pnpm/bins.resolver": "workspace:*",
"@pnpm/building.commands": "workspace:*",
"@pnpm/cli.command": "workspace:*",
"@pnpm/cli.utils": "workspace:*",
"@pnpm/config.matcher": "workspace:*",
"@pnpm/config.reader": "workspace:*",

View File

@@ -3,7 +3,7 @@ import path from 'node:path'
import { linkBinsOfPackages } from '@pnpm/bins.linker'
import { removeBin } from '@pnpm/bins.remover'
import { approveBuilds } from '@pnpm/building.commands'
import type { CommandHandlerMap } from '@pnpm/cli.command'
import {
cleanOrphanedInstallDirs,
createGlobalCacheKey,
@@ -17,10 +17,8 @@ import type { CreateStoreControllerOptions } from '@pnpm/store.connection-manage
import { isSubdir } from 'is-subdir'
import { symlinkDir } from 'symlink-dir'
import { installGlobalPackages } from './installGlobalPackages.js'
type ApproveBuildsHandlerOpts = Parameters<typeof approveBuilds.handler>[0]
import { checkGlobalBinConflicts } from './checkGlobalBinConflicts.js'
import { installGlobalPackages } from './installGlobalPackages.js'
import { readInstalledPackages } from './readInstalledPackages.js'
export type GlobalAddOptions = CreateStoreControllerOptions & {
@@ -37,7 +35,8 @@ export type GlobalAddOptions = CreateStoreControllerOptions & {
export async function handleGlobalAdd (
opts: GlobalAddOptions,
params: string[]
params: string[],
commands: CommandHandlerMap
): Promise<void> {
// Resolve relative path selectors to absolute paths before the working
// directory is changed to the global install dir, otherwise "." or
@@ -94,7 +93,7 @@ export async function handleGlobalAdd (
// If any packages had their builds skipped, prompt the user to approve them
// (reuses the same interactive flow as `pnpm approve-builds`)
if (ignoredBuilds?.size && process.stdin.isTTY) {
await approveBuilds.handler({
await commands['approve-builds']({
...opts,
modulesDir: path.join(installDir, 'node_modules'),
dir: installDir,
@@ -105,7 +104,7 @@ export async function handleGlobalAdd (
global: false,
pending: false,
allowBuilds,
} as ApproveBuildsHandlerOpts)
}, [], commands)
}
// Read resolved aliases from the installed package.json

View File

@@ -3,7 +3,7 @@ import path from 'node:path'
import { linkBinsOfPackages } from '@pnpm/bins.linker'
import { removeBin } from '@pnpm/bins.remover'
import { approveBuilds } from '@pnpm/building.commands'
import type { CommandHandlerMap } from '@pnpm/cli.command'
import {
cleanOrphanedInstallDirs,
createInstallDir,
@@ -16,10 +16,8 @@ import type { CreateStoreControllerOptions } from '@pnpm/store.connection-manage
import { isSubdir } from 'is-subdir'
import { symlinkDir } from 'symlink-dir'
import { installGlobalPackages } from './installGlobalPackages.js'
type ApproveBuildsHandlerOpts = Parameters<typeof approveBuilds.handler>[0]
import { checkGlobalBinConflicts } from './checkGlobalBinConflicts.js'
import { installGlobalPackages } from './installGlobalPackages.js'
import { readInstalledPackages } from './readInstalledPackages.js'
export type GlobalUpdateOptions = CreateStoreControllerOptions & {
@@ -35,7 +33,8 @@ export type GlobalUpdateOptions = CreateStoreControllerOptions & {
export async function handleGlobalUpdate (
opts: GlobalUpdateOptions,
params: string[]
params: string[],
commands: CommandHandlerMap
): Promise<string | undefined> {
const globalDir = opts.globalPkgDir!
const globalBinDir = opts.bin!
@@ -62,7 +61,7 @@ export async function handleGlobalUpdate (
// Update each package group sequentially to avoid overwhelming the system
for (const pkg of packagesToUpdate) {
await updateGlobalPackageGroup(opts, globalDir, globalBinDir, pkg) // eslint-disable-line no-await-in-loop
await updateGlobalPackageGroup(opts, globalDir, globalBinDir, pkg, commands) // eslint-disable-line no-await-in-loop
}
return undefined
}
@@ -71,7 +70,8 @@ async function updateGlobalPackageGroup (
opts: GlobalUpdateOptions,
globalDir: string,
globalBinDir: string,
pkg: GlobalPackageInfo
pkg: GlobalPackageInfo,
commands: CommandHandlerMap
): Promise<void> {
const installDir = createInstallDir(globalDir)
@@ -113,7 +113,7 @@ async function updateGlobalPackageGroup (
// If any packages had their builds skipped, prompt the user to approve them
// (reuses the same interactive flow as `pnpm approve-builds`)
if (ignoredBuilds?.size && process.stdin.isTTY) {
await approveBuilds.handler({
await commands['approve-builds']({
...opts,
modulesDir: path.join(installDir, 'node_modules'),
dir: installDir,
@@ -124,7 +124,7 @@ async function updateGlobalPackageGroup (
global: false,
pending: false,
allowBuilds,
} as ApproveBuildsHandlerOpts)
}, [], commands)
}
// Check for bin name conflicts with other global packages

View File

@@ -19,7 +19,7 @@
"path": "../../bins/resolver"
},
{
"path": "../../building/commands"
"path": "../../cli/command"
},
{
"path": "../../cli/utils"

View File

@@ -33,7 +33,6 @@
},
"dependencies": {
"@pnpm/building.after-install": "workspace:*",
"@pnpm/building.commands": "workspace:*",
"@pnpm/catalogs.types": "workspace:*",
"@pnpm/cli.command": "workspace:*",
"@pnpm/cli.common-cli-options-help": "workspace:*",

View File

@@ -1,3 +1,4 @@
import type { CommandHandlerMap } from '@pnpm/cli.command'
import { FILTERING, OPTIONS, UNIVERSAL_OPTIONS } from '@pnpm/cli.common-cli-options-help'
import { docsUrl } from '@pnpm/cli.utils'
import { types as allTypes } from '@pnpm/config.reader'
@@ -209,7 +210,8 @@ export type AddCommandOptions = InstallCommandOptions & {
export async function handler (
opts: AddCommandOptions,
params: string[]
params: string[],
commands?: CommandHandlerMap
): Promise<void> {
if (opts.cliOptions['save'] === false) {
throw new PnpmError('OPTION_NOT_SUPPORTED', 'The "add" command currently does not support the no-save option')
@@ -251,7 +253,7 @@ export async function handler (
if (params.includes('pnpm') || params.includes('@pnpm/exe')) {
throw new PnpmError('GLOBAL_PNPM_INSTALL', 'Use the "pnpm self-update" command to install or update pnpm')
}
return handleGlobalAdd(opts, params)
return handleGlobalAdd(opts, params, commands ?? {})
}
const include = {
@@ -296,6 +298,7 @@ export async function handler (
return installDeps({
...opts,
allowBuilds: mergedAllowBuilds,
rebuildHandler: commands?.rebuild,
fetchFullMetadata: getFetchFullMetadata(opts),
include,
includeDirect: include,
@@ -303,6 +306,7 @@ export async function handler (
}
return installDeps({
...opts,
rebuildHandler: commands?.rebuild,
fetchFullMetadata: getFetchFullMetadata(opts),
include,
includeDirect: include,

View File

@@ -1,3 +1,4 @@
import type { CommandHandlerMap } from '@pnpm/cli.command'
import { OPTIONS, UNIVERSAL_OPTIONS } from '@pnpm/cli.common-cli-options-help'
import { docsUrl } from '@pnpm/cli.utils'
import { dedupeDiffCheck } from '@pnpm/installing.dedupe.check'
@@ -52,7 +53,7 @@ export interface DedupeCommandOptions extends InstallCommandOptions {
readonly check?: boolean
}
export async function handler (opts: DedupeCommandOptions): Promise<void> {
export async function handler (opts: DedupeCommandOptions, _params?: string[], commands?: CommandHandlerMap): Promise<void> {
const include = {
dependencies: opts.production !== false,
devDependencies: opts.dev !== false,
@@ -60,6 +61,7 @@ export async function handler (opts: DedupeCommandOptions): Promise<void> {
}
return installDeps({
...opts,
rebuildHandler: commands?.rebuild,
dedupe: true,
include,
includeDirect: include,

View File

@@ -1,3 +1,4 @@
import type { CommandHandlerMap } from '@pnpm/cli.command'
import { FILTERING, OPTIONS, OUTPUT_OPTIONS, UNIVERSAL_OPTIONS } from '@pnpm/cli.common-cli-options-help'
import { docsUrl } from '@pnpm/cli.utils'
import { type Config, types as allTypes } from '@pnpm/config.reader'
@@ -349,7 +350,7 @@ export type InstallCommandOptions = Pick<Config,
pnpmfile: string[]
} & Partial<Pick<Config, 'ci' | 'modulesCacheMaxAge' | 'pnpmHomeDir' | 'preferWorkspacePackages' | 'useLockfile' | 'symlink'>>
export async function handler (opts: InstallCommandOptions & { _calledFromLink?: boolean }): Promise<void> {
export async function handler (opts: InstallCommandOptions & { _calledFromLink?: boolean }, _params?: string[], commands?: CommandHandlerMap): Promise<void> {
if (opts.global && !opts._calledFromLink) {
throw new PnpmError('GLOBAL_INSTALL_NOT_SUPPORTED',
'"pnpm install -g" is not supported. Use "pnpm add -g <pkg>" to install global packages.')
@@ -361,6 +362,7 @@ export async function handler (opts: InstallCommandOptions & { _calledFromLink?:
}
const installDepsOptions: InstallDepsOptions = {
...opts,
rebuildHandler: commands?.rebuild,
frozenLockfileIfExists: opts.frozenLockfileIfExists ?? (
opts.ci && !opts.lockfileOnly &&
typeof opts.frozenLockfile === 'undefined' &&

View File

@@ -1,6 +1,7 @@
import path from 'node:path'
import { buildProjects } from '@pnpm/building.after-install'
import type { CommandHandler } from '@pnpm/cli.command'
import {
readProjectManifestOnly,
tryReadProjectManifest,
@@ -149,6 +150,7 @@ export type InstallDepsOptions = Pick<Config,
includeOnlyPackageFiles?: boolean
fetchFullMetadata?: boolean
pruneLockfileImporters?: boolean
rebuildHandler?: CommandHandler
pnpmfile: string[]
packageVulnerabilityAudit?: PackageVulnerabilityAudit
} & Partial<Pick<Config, 'pnpmHomeDir' | 'strictDepBuilds'>>

View File

@@ -1,8 +1,8 @@
import { promises as fs } from 'node:fs'
import path from 'node:path'
import { rebuild } from '@pnpm/building.commands'
import type { Catalogs } from '@pnpm/catalogs.types'
import type { CommandHandler } from '@pnpm/cli.command'
import {
type RecursiveSummary,
throwOnCommandFail,
@@ -90,6 +90,7 @@ export type RecursiveOptions = CreateStoreControllerOptions & Pick<Config,
| 'packageConfigs'
| 'updateConfig'
> & {
rebuildHandler?: CommandHandler
include?: IncludedDependencies
includeDirect?: IncludedDependencies
latest?: boolean
@@ -463,7 +464,7 @@ export async function recursive (
cmdFullName === 'update'
)
) {
await rebuild.handler({
await opts.rebuildHandler?.({
...opts,
pending: opts.pending === true,
skipIfHasSideEffectsCache: true,

View File

@@ -1,4 +1,4 @@
import type { CompletionFunc } from '@pnpm/cli.command'
import type { CommandHandler, CommandHandlerMap, CompletionFunc } from '@pnpm/cli.command'
import { FILTERING, OPTIONS, UNIVERSAL_OPTIONS } from '@pnpm/cli.common-cli-options-help'
import {
docsUrl,
@@ -168,7 +168,8 @@ export type UpdateCommandOptions = InstallCommandOptions & {
export async function handler (
opts: UpdateCommandOptions,
params: string[] = []
params: string[] = [],
commands?: CommandHandlerMap
): Promise<string | undefined> {
if (opts.global) {
if (!opts.bin) {
@@ -176,17 +177,19 @@ export async function handler (
hint: 'Run "pnpm setup" to create it automatically, or set the global-bin-dir setting, or the PNPM_HOME env variable. The global bin directory should be in the PATH.',
})
}
return handleGlobalUpdate(opts, params)
return handleGlobalUpdate(opts, params, commands ?? {})
}
const rebuildHandler = commands?.rebuild
if (opts.interactive) {
return interactiveUpdate(params, opts)
return interactiveUpdate(params, opts, rebuildHandler)
}
return update(params, opts) as Promise<undefined>
return update(params, opts, rebuildHandler) as Promise<undefined>
}
async function interactiveUpdate (
input: string[],
opts: UpdateCommandOptions
opts: UpdateCommandOptions,
rebuildHandler?: CommandHandler
): Promise<string | undefined> {
const include = makeIncludeDependenciesFromCLI(opts.cliOptions)
const projects = (opts.selectedProjectsGraph != null)
@@ -276,12 +279,13 @@ async function interactiveUpdate (
} as any) as any // eslint-disable-line @typescript-eslint/no-explicit-any
const updatePkgNames = pluck('value', updateDependencies as ChoiceRow[])
return update(updatePkgNames, opts) as Promise<undefined>
return update(updatePkgNames, opts, rebuildHandler) as Promise<undefined>
}
async function update (
dependencies: string[],
opts: UpdateCommandOptions
opts: UpdateCommandOptions,
rebuildHandler?: CommandHandler
): Promise<void> {
if (opts.latest) {
const dependenciesWithTags = dependencies.filter((name) => parseUpdateParam(name).versionSpec != null)
@@ -307,6 +311,7 @@ async function update (
}
return installDeps({
...opts,
rebuildHandler,
allowNew: false,
depth,
ignoreCurrentSpecifiers: false,

View File

@@ -24,9 +24,6 @@
{
"path": "../../building/after-install"
},
{
"path": "../../building/commands"
},
{
"path": "../../catalogs/types"
},

View File

@@ -337,8 +337,11 @@ export function extendOptions (
}
extendedOpts.registries = normalizeRegistries(extendedOpts.registries)
extendedOpts.rawConfig['registry'] = extendedOpts.registries.default
if (extendedOpts.enableGlobalVirtualStore && extendedOpts.virtualStoreDir == null) {
extendedOpts.virtualStoreDir = path.join(extendedOpts.storeDir, 'links')
if (extendedOpts.enableGlobalVirtualStore) {
if (extendedOpts.virtualStoreDir == null) {
extendedOpts.virtualStoreDir = path.join(extendedOpts.storeDir, 'links')
}
extendedOpts.allowBuilds ??= {}
}
extendedOpts.globalVirtualStoreDir = extendedOpts.enableGlobalVirtualStore
? extendedOpts.virtualStoreDir!

View File

@@ -286,6 +286,54 @@ test('GVS successful build creates package directory with build artifacts', asyn
}
})
test('GVS: approve-builds scenario — install with no builds, then reinstall with allowBuilds', async () => {
prepareEmpty()
const globalVirtualStoreDir = path.resolve('links')
const manifest = {
dependencies: {
'@pnpm.e2e/pre-and-postinstall-scripts-example': '1.0.0',
},
}
// Step 1: Install with builds NOT approved (simulating first `pnpm install`)
await install(manifest, testDefaults({
enableGlobalVirtualStore: true,
virtualStoreDir: globalVirtualStoreDir,
fastUnpack: false,
allowBuilds: {},
}))
const pkgVersionDir = path.join(globalVirtualStoreDir, '@pnpm.e2e/pre-and-postinstall-scripts-example/1.0.0')
const hashBefore = fs.readdirSync(pkgVersionDir)
expect(hashBefore).toHaveLength(1)
// Build artifacts should NOT be present
expect(fs.existsSync(path.resolve('node_modules/@pnpm.e2e/pre-and-postinstall-scripts-example/generated-by-postinstall.js'))).toBeFalsy()
// Step 2: Reinstall with allowBuilds changed (simulating what approve-builds does)
await install(manifest, testDefaults({
enableGlobalVirtualStore: true,
virtualStoreDir: globalVirtualStoreDir,
fastUnpack: false,
allowBuilds: { '@pnpm.e2e/pre-and-postinstall-scripts-example': true },
}))
// Step 3: Verify the hash changed and build artifacts are in the new directory
const hashesAfter = fs.readdirSync(pkgVersionDir)
const newHash = hashesAfter.find((h) => h !== hashBefore[0])
expect(newHash).toBeDefined()
expect(newHash).not.toBe(hashBefore[0])
// Build artifacts in new hash directory
const newPkgDir = path.join(pkgVersionDir, newHash!, 'node_modules/@pnpm.e2e/pre-and-postinstall-scripts-example')
expect(fs.existsSync(path.join(newPkgDir, 'generated-by-postinstall.js'))).toBeTruthy()
expect(fs.existsSync(path.join(newPkgDir, 'generated-by-preinstall.js'))).toBeTruthy()
// Build artifacts accessible via node_modules
expect(fs.existsSync(path.resolve('node_modules/@pnpm.e2e/pre-and-postinstall-scripts-example/generated-by-postinstall.js'))).toBeTruthy()
expect(fs.existsSync(path.resolve('node_modules/@pnpm.e2e/pre-and-postinstall-scripts-example/generated-by-preinstall.js'))).toBeTruthy()
})
test('GVS build failure cleans up broken package directory', async () => {
prepareEmpty()
const globalVirtualStoreDir = path.resolve('links')

View File

@@ -333,6 +333,9 @@ export async function headlessInstall (opts: HeadlessOptions): Promise<Installat
}
}
if (opts.enableGlobalVirtualStore) {
opts.allowBuilds ??= {}
}
const allowBuild = createAllowBuildFunction(opts)
const lockfileToDepGraphOpts = {
...opts,

16
pnpm-lock.yaml generated
View File

@@ -1660,6 +1660,9 @@ importers:
'@pnpm/building.after-install':
specifier: workspace:*
version: link:../after-install
'@pnpm/cli.command':
specifier: workspace:*
version: link:../../cli/command
'@pnpm/cli.common-cli-options-help':
specifier: workspace:*
version: link:../../cli/common-cli-options-help
@@ -1678,6 +1681,9 @@ importers:
'@pnpm/error':
specifier: workspace:*
version: link:../../core/error
'@pnpm/installing.commands':
specifier: workspace:*
version: link:../../installing/commands
'@pnpm/installing.modules-yaml':
specifier: workspace:*
version: link:../../installing/modules-yaml
@@ -3464,6 +3470,9 @@ importers:
'@pnpm/bins.linker':
specifier: workspace:*
version: link:../../../bins/linker
'@pnpm/building.policy':
specifier: workspace:*
version: link:../../../building/policy
'@pnpm/cli.meta':
specifier: workspace:*
version: link:../../../cli/meta
@@ -4499,9 +4508,9 @@ importers:
'@pnpm/bins.resolver':
specifier: workspace:*
version: link:../../bins/resolver
'@pnpm/building.commands':
'@pnpm/cli.command':
specifier: workspace:*
version: link:../../building/commands
version: link:../../cli/command
'@pnpm/cli.utils':
specifier: workspace:*
version: link:../../cli/utils
@@ -4742,9 +4751,6 @@ importers:
'@pnpm/building.after-install':
specifier: workspace:*
version: link:../../building/after-install
'@pnpm/building.commands':
specifier: workspace:*
version: link:../../building/commands
'@pnpm/catalogs.types':
specifier: workspace:*
version: link:../../catalogs/types

View File

@@ -1,6 +1,6 @@
import { approveBuilds, ignoredBuilds, rebuild } from '@pnpm/building.commands'
import { cache } from '@pnpm/cache.commands'
import type { CompletionFunc } from '@pnpm/cli.command'
import type { CommandHandlerMap, CompletionFunc } from '@pnpm/cli.command'
import { createCompletionServer, doctor, generateCompletion } from '@pnpm/cli.commands'
import { config, getCommand, setCommand } from '@pnpm/config.commands'
import { types as allTypes } from '@pnpm/config.reader'
@@ -59,11 +59,11 @@ export const GLOBAL_OPTIONS = pick([
export type CommandResponse = string | { output?: string, exitCode: number }
export type Command = (
(opts: PnpmOptions | any, params: string[]) => CommandResponse | Promise<CommandResponse> // eslint-disable-line @typescript-eslint/no-explicit-any
(opts: PnpmOptions | any, params: string[], commands?: CommandHandlerMap) => CommandResponse | Promise<CommandResponse> // eslint-disable-line @typescript-eslint/no-explicit-any
) | (
(opts: PnpmOptions | any, params: string[]) => void // eslint-disable-line @typescript-eslint/no-explicit-any
(opts: PnpmOptions | any, params: string[], commands?: CommandHandlerMap) => void // eslint-disable-line @typescript-eslint/no-explicit-any
) | (
(opts: PnpmOptions | any, params: string[]) => Promise<void> // eslint-disable-line @typescript-eslint/no-explicit-any
(opts: PnpmOptions | any, params: string[], commands?: CommandHandlerMap) => Promise<void> // eslint-disable-line @typescript-eslint/no-explicit-any
)
export interface CommandDefinition {

View File

@@ -307,7 +307,8 @@ export async function main (inputArgv: string[]): Promise<void> {
// TypeScript doesn't currently infer that the type of config
// is `Omit<typeof config, 'reporter'>` after the `delete config.reporter` statement
config as Omit<typeof config, 'reporter'>,
cliParams
cliParams,
pnpmCmds
)
try {
if (result instanceof Promise) {

View File

@@ -2,6 +2,7 @@ import fs from 'node:fs'
import path from 'node:path'
import { prepare } from '@pnpm/prepare'
import { readYamlFileSync } from 'read-yaml-file'
import { writeYamlFileSync } from 'write-yaml-file'
import { execPnpm } from '../utils/index.js'
@@ -28,3 +29,49 @@ test('using a global virtual store', async () => {
expect(fs.existsSync(path.join(globalVirtualStoreDir, '@pnpm.e2e/pkg-with-1-dep/100.0.0', files[0], 'node_modules/@pnpm.e2e/pkg-with-1-dep/package.json'))).toBeTruthy()
expect(fs.existsSync(path.join(globalVirtualStoreDir, '@pnpm.e2e/pkg-with-1-dep/100.0.0', files[0], 'node_modules/@pnpm.e2e/dep-of-pkg-with-1-dep/package.json'))).toBeTruthy()
})
test('approve-builds updates GVS symlinks and runs builds at correct hash directory', async () => {
prepare({
dependencies: {
'@pnpm.e2e/pre-and-postinstall-scripts-example': '1.0.0',
},
})
const storeDir = path.resolve('store')
const globalVirtualStoreDir = path.join(storeDir, 'v11/links')
writeYamlFileSync(path.resolve('pnpm-workspace.yaml'), {
enableGlobalVirtualStore: true,
storeDir,
})
// Step 1: Install with GVS, builds NOT approved
await execPnpm(['install', '--config.strict-dep-builds=false'])
const pkgVersionDir = path.join(globalVirtualStoreDir, '@pnpm.e2e/pre-and-postinstall-scripts-example/1.0.0')
const hashBefore = fs.readdirSync(pkgVersionDir)
expect(hashBefore).toHaveLength(1)
// Build artifacts should NOT be present
expect(fs.existsSync('node_modules/@pnpm.e2e/pre-and-postinstall-scripts-example/generated-by-postinstall.js')).toBeFalsy()
// Step 2: approve-builds — updates config then runs install in GVS mode
await execPnpm(['approve-builds', '--all'])
// Step 3: Verify GVS hash changed (new engine-specific directory)
const hashesAfter = fs.readdirSync(pkgVersionDir)
const newHash = hashesAfter.find((h) => h !== hashBefore[0])
expect(newHash).toBeDefined()
expect(newHash).not.toBe(hashBefore[0])
// Build artifacts should be in the new hash directory
const newPkgDir = path.join(pkgVersionDir, newHash!, 'node_modules/@pnpm.e2e/pre-and-postinstall-scripts-example')
expect(fs.existsSync(path.join(newPkgDir, 'generated-by-postinstall.js'))).toBeTruthy()
expect(fs.existsSync(path.join(newPkgDir, 'generated-by-preinstall.js'))).toBeTruthy()
// Build artifacts should be accessible through node_modules
expect(fs.existsSync('node_modules/@pnpm.e2e/pre-and-postinstall-scripts-example/generated-by-preinstall.js')).toBeTruthy()
expect(fs.existsSync('node_modules/@pnpm.e2e/pre-and-postinstall-scripts-example/generated-by-postinstall.js')).toBeTruthy()
// allowBuilds should be persisted in workspace manifest
const workspaceManifest = readYamlFileSync<any>(path.resolve('pnpm-workspace.yaml')) // eslint-disable-line
expect(workspaceManifest.allowBuilds?.['@pnpm.e2e/pre-and-postinstall-scripts-example']).toBe(true)
})