VYPR
High severityOSV Advisory· Published Jan 7, 2026· Updated Feb 26, 2026

pnpm Lockfile Integrity Bypass Allows Remote Dynamic Dependencies

CVE-2025-69263

Description

pnpm is a package manager. Versions 10.26.2 and below store HTTP tarball dependencies (and git-hosted tarballs) in the lockfile without integrity hashes. This allows the remote server to serve different content on each install, even when a lockfile is committed. An attacker who publishes a package with an HTTP tarball dependency can serve different code to different users or CI/CD environments. The attack requires the victim to install a package that has an HTTP/git tarball in its dependency tree. The victim's lockfile provides no protection. This issue is fixed in version 10.26.0.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
pnpmnpm
< 10.26.010.26.0

Affected products

1
  • Range: 0.19.0, @pnpm/headless@0.6.2, @pnpm/utils@0.6.1, …

Patches

1
0958027f88a9

feat: enhance `store prune` to clean global virtual store (#10360)

https://github.com/pnpm/pnpmZoltan KochanDec 26, 2025via ghsa
16 files changed · +1038 32
  • .changeset/global-virtual-store-prune.md+9 0 added
    @@ -0,0 +1,9 @@
    +---
    +"@pnpm/package-store": minor
    +"@pnpm/get-context": minor
    +"pnpm": minor
    +---
    +
    +Added project registry for global virtual store prune support.
    +
    +Projects using the store are now registered via symlinks in `{storeDir}/v10/projects/`. This enables `pnpm store prune` to track which packages are still in use by active projects and safely remove unused packages from the global virtual store.
    
  • .changeset/prune-global-virtual-store.md+14 0 added
    @@ -0,0 +1,14 @@
    +---
    +"@pnpm/package-store": minor
    +"pnpm": minor
    +---
    +
    +Added mark-and-sweep garbage collection for global virtual store.
    +
    +`pnpm store prune` now removes unused packages from the global virtual store's `links/` directory. The algorithm:
    +
    +1. Scans all registered projects for symlinks pointing to the store
    +2. Walks transitive dependencies to mark reachable packages
    +3. Removes any package directories not marked as reachable
    +
    +This includes support for workspace monorepos - all `node_modules` directories within a project (including those in workspace packages) are scanned.
    
  • pkg-manager/get-context/package.json+1 0 modified
    @@ -36,6 +36,7 @@
         "@pnpm/core-loggers": "workspace:*",
         "@pnpm/lockfile.fs": "workspace:*",
         "@pnpm/modules-yaml": "workspace:*",
    +    "@pnpm/package-store": "workspace:*",
         "@pnpm/read-projects-context": "workspace:*",
         "@pnpm/resolver-base": "workspace:*",
         "@pnpm/types": "workspace:*",
    
  • pkg-manager/get-context/src/index.ts+7 0 modified
    @@ -21,6 +21,7 @@ import {
     } from '@pnpm/types'
     import pathAbsolute from 'path-absolute'
     import clone from 'ramda/src/clone'
    +import { registerProject } from '@pnpm/package-store'
     import { readLockfiles } from './readLockfiles.js'
     
     /**
    @@ -123,6 +124,9 @@ export async function getContext (
     
       await fs.mkdir(opts.storeDir, { recursive: true })
     
    +  // Register this project for store prune tracking
    +  await registerProject(opts.storeDir, opts.lockfileDir)
    +
       for (const project of opts.allProjects) {
         packageManifestLogger.debug({
           initial: project.manifest,
    @@ -293,6 +297,9 @@ export async function getContextForSingleImporter (
       const virtualStoreDir = pathAbsolute(opts.virtualStoreDir ?? 'node_modules/.pnpm', opts.lockfileDir)
     
       await fs.mkdir(storeDir, { recursive: true })
    +
    +  // Register this project for store prune tracking
    +  await registerProject(storeDir, opts.lockfileDir)
       const extraBinPaths = [
         ...opts.extraBinPaths || [],
       ]
    
  • pkg-manager/get-context/tsconfig.json+3 0 modified
    @@ -27,6 +27,9 @@
         {
           "path": "../../resolving/resolver-base"
         },
    +    {
    +      "path": "../../store/package-store"
    +    },
         {
           "path": "../modules-yaml"
         },
    
  • pnpm-lock.yaml+180 29 modified
    @@ -1166,7 +1166,7 @@ importers:
         dependencies:
           '@pnpm/workspace.find-packages':
             specifier: 'catalog:'
    -        version: 1000.0.25(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.4.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))(typanion@3.14.0)
    +        version: 1000.0.25(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.6.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))(typanion@3.14.0)
           '@pnpm/workspace.read-manifest':
             specifier: 'catalog:'
             version: 1000.1.5
    @@ -5183,6 +5183,9 @@ importers:
           '@pnpm/modules-yaml':
             specifier: workspace:*
             version: link:../modules-yaml
    +      '@pnpm/package-store':
    +        specifier: workspace:*
    +        version: link:../../store/package-store
           '@pnpm/read-projects-context':
             specifier: workspace:*
             version: link:../read-projects-context
    @@ -8048,6 +8051,12 @@ importers:
           '@pnpm/create-cafs-store':
             specifier: workspace:*
             version: link:../create-cafs-store
    +      '@pnpm/crypto.hash':
    +        specifier: workspace:*
    +        version: link:../../crypto/hash
    +      '@pnpm/error':
    +        specifier: workspace:*
    +        version: link:../../packages/error
           '@pnpm/fetcher-base':
             specifier: workspace:*
             version: link:../../fetching/fetcher-base
    @@ -8072,6 +8081,9 @@ importers:
           '@zkochan/rimraf':
             specifier: 'catalog:'
             version: 3.0.2
    +      is-subdir:
    +        specifier: 'catalog:'
    +        version: 1.2.0
           load-json-file:
             specifier: 'catalog:'
             version: 6.2.0
    @@ -8081,6 +8093,9 @@ importers:
           ssri:
             specifier: 'catalog:'
             version: 10.0.5
    +      symlink-dir:
    +        specifier: 'catalog:'
    +        version: 6.0.5
         devDependencies:
           '@pnpm/client':
             specifier: workspace:*
    @@ -8246,6 +8261,9 @@ importers:
           '@pnpm/logger':
             specifier: workspace:*
             version: link:../../packages/logger
    +      '@pnpm/package-store':
    +        specifier: workspace:*
    +        version: link:../package-store
           '@pnpm/plugin-commands-script-runners':
             specifier: workspace:*
             version: link:../../exec/plugin-commands-script-runners
    @@ -9928,6 +9946,10 @@ packages:
         resolution: {integrity: sha512-BN7y+f4JHsixxq5uX1HYb791/CRJrIkGnH4EKN/vTgLWG7QyBzplyE8+gh1SfPGrcdefU10G+B1zMOkOiN/iwA==}
         engines: {node: '>=18.12'}
     
    +  '@pnpm/cafs-types@1000.1.0':
    +    resolution: {integrity: sha512-uUAnheFdWz+rwgDSr0MO8LH0M27j/ocj+KVXlGmmaAHyMKqIMRnuQZdAciAW7/Cb29WOfmPFm+U/aRtBjysE9g==}
    +    engines: {node: '>=18.12'}
    +
       '@pnpm/catalogs.config@1000.0.2':
         resolution: {integrity: sha512-2GCYZwxmgw6w0bpB71VbbXapgIcSSFOF9vnS+YLyTdy8JaIYoag2XkhXP1cMu24THPRXeo/zKTyziEsqgr1u8w==}
         engines: {node: '>=18.12'}
    @@ -10002,6 +10024,12 @@ packages:
         peerDependencies:
           '@pnpm/logger': '>=1001.0.0 <1002.0.0'
     
    +  '@pnpm/core-loggers@1001.0.8':
    +    resolution: {integrity: sha512-uQOhMKaym12a3Yk1vYhp6T1NecgS7YACex6VXYZaasmBq5D0iCIz/ZFgaDEWPsNPehKb8v9BJmElT1nHHsNWEQ==}
    +    engines: {node: '>=18.12'}
    +    peerDependencies:
    +      '@pnpm/logger': '>=1001.0.0 <1002.0.0'
    +
       '@pnpm/create-cafs-store@1000.0.14':
         resolution: {integrity: sha512-95OczT9/zsJea3Nm6x9ovUD04GoLKYHnSCKUhe5VFx7ckUxW8DUrFJynl94Tx+kdf7u7zXBIlwhhHJEplDaNTg==}
         engines: {node: '>=18.12'}
    @@ -10014,6 +10042,12 @@ packages:
         peerDependencies:
           '@pnpm/logger': '>=1001.0.0 <1002.0.0'
     
    +  '@pnpm/create-cafs-store@1000.0.27':
    +    resolution: {integrity: sha512-52BHUOB0sWT8DGkL87FujFc/fxd0Gn8O+UciLMtDh9wB5jQyJMR07HI4N5JmEFlNvKyzCx57JPHFOQrxA6jYQw==}
    +    engines: {node: '>=18.12'}
    +    peerDependencies:
    +      '@pnpm/logger': '>=1001.0.0 <1002.0.0'
    +
       '@pnpm/crypto.hash@1000.1.1':
         resolution: {integrity: sha512-lb5kwXaOXdIW/4bkLLmtM9HEVRvp2eIvp+TrdawcPoaptgA/5f0/sRG0P52BF8dFqeNDj+1tGdqH89WQEqJnxA==}
         engines: {node: '>=18.12'}
    @@ -10066,6 +10100,10 @@ packages:
         resolution: {integrity: sha512-qAPTRskRWyG/2jOZH6WMNNUd4Px552AvrvjXPdesRmGFw3ht/Gv066LEYYuOZr8kOewD2AnYZwyXYsJ4gxHyKw==}
         engines: {node: '>=18.12'}
     
    +  '@pnpm/exec.pkg-requires-build@1000.0.15':
    +    resolution: {integrity: sha512-eDcGM6P/PmkCQQTlDWuMmhPjc734YchAnpe2mBd8QzaDlhIKzkg2eBYICg6VKcMr+YBGa6Cu7426ExY767ljYw==}
    +    engines: {node: '>=18.12'}
    +
       '@pnpm/exec.pkg-requires-build@1000.0.8':
         resolution: {integrity: sha512-8Mx71nPcUEJpLVzl4k/+Yu5Mir8JLg4oWEImkMfLKd9orU/F7A5FIHTeLw4RAnK0MummjmXPwj8UMQgOxkq2eA==}
         engines: {node: '>=18.12'}
    @@ -10088,6 +10126,10 @@ packages:
         resolution: {integrity: sha512-hnVGfLGJtzSsysy1iqrOYYXZCxdlO7RZeRp28+j9Pq/HAchWvuK9kEo35kgE0upmegCTYuNuD3f0zDRCD5hZuQ==}
         engines: {node: '>=18.12'}
     
    +  '@pnpm/fetcher-base@1001.2.0':
    +    resolution: {integrity: sha512-6dCnVyIuSmJdPeqHxp0+cFKeb5rROvLQCO0OpR8iuH5HWpWsOPwu5mm4aOdmSjL+fCTrV90waMwlnIW7sXjmgg==}
    +    engines: {node: '>=18.12'}
    +
       '@pnpm/fetching-types@1000.1.0':
         resolution: {integrity: sha512-0JFRtWH/6Pwsl9Q9CwxHpCxsoaaTr4cYbL4moMiVYnllg8yeJSU3V5S0gPsAlIdhHfjBVNfwMIM99pICzic33Q==}
         engines: {node: '>=18.12'}
    @@ -10112,6 +10154,12 @@ packages:
         peerDependencies:
           '@pnpm/logger': '>=1001.0.0 <1002.0.0'
     
    +  '@pnpm/fs.indexed-pkg-importer@1000.1.21':
    +    resolution: {integrity: sha512-1AbbtJlKTshkYImHKVoSIh6QygnM+UkJCdpl7WFWgVjLgYRa8dMRP3Pq5yHv9t7e8ePjeELcJidl9/r0hGR6yQ==}
    +    engines: {node: '>=18.12'}
    +    peerDependencies:
    +      '@pnpm/logger': '>=1001.0.0 <1002.0.0'
    +
       '@pnpm/fs.indexed-pkg-importer@1000.1.8':
         resolution: {integrity: sha512-VwsjBhAyW+5TQO6Ndon1y8kyvSLQJyyWzwNRENQN+UkbrybsjNXHX1vcMV4Li/+pQ0VBYFVxYl/cx+EkU8H9hQ==}
         engines: {node: '>=18.12'}
    @@ -10364,6 +10412,10 @@ packages:
         resolution: {integrity: sha512-d/l84T+J26xVfl28HbfTNaZcpbzr+VH3CFI2RLFWxI8Y34pIphKbD18Zke6dQmv+g7JenT6FmE8YSRIfwAEjaw==}
         engines: {node: '>=18.12'}
     
    +  '@pnpm/resolver-base@1005.3.3':
    +    resolution: {integrity: sha512-mwh5nHZXP1iORD6bQLYAYp9WWma9kwHsK9tLWICLvrWPxw+RqRthKsp0NxYPR7nV1Yd1cEgAi1lWxgWNxjJ8wA==}
    +    engines: {node: '>=18.12'}
    +
       '@pnpm/resolving.jsr-specifier-parser@1000.0.0':
         resolution: {integrity: sha512-/61cFu7EJcrXCJtqo9cjaEcuvtUYWOZQE0o7CrYL0S15lN7gSbUMD19y/n0eG4rBxyRJg3U+fnj43f+mvREL/A==}
         engines: {node: '>=18.12'}
    @@ -10400,6 +10452,10 @@ packages:
         resolution: {integrity: sha512-MHslDEKzoXj+rGA89smxVQC98vU9eFRZSBMfoQVNL/fKyigLDI4vLwxUhf6LiMZwO8wbr+Phi2TdcIY4gNeeAw==}
         engines: {node: '>=18.12'}
     
    +  '@pnpm/store-controller-types@1004.4.1':
    +    resolution: {integrity: sha512-eB6sHYQg+AcCfcYzm8iQLhJnQTJiC4vJVls1NBElj7XnPzU75L0hYhDZr3NL5knlIp1V91pBh//3D4L/IM4AOA==}
    +    engines: {node: '>=18.12'}
    +
       '@pnpm/store-path@1000.0.2':
         resolution: {integrity: sha512-Ab2RJUnMb0ZP7rRTP9mr+KUSeoWjozNbd9gqC7ZYptHUlPohpVbjBY2xeppApw6GVzHLWPB3hIyXXz7qylnHuQ==}
         engines: {node: '>=18.12'}
    @@ -10412,12 +10468,22 @@ packages:
         resolution: {integrity: sha512-F/82ClolPdQIn3JP19LTpoi0FfzhjrkLLZjwDDFN6Kzy6QxRYIfIG3ame3eZEijAsPMWv7X9iIZVvG4NOSP2aA==}
         engines: {node: '>=18.12'}
     
    +  '@pnpm/store.cafs@1000.1.0':
    +    resolution: {integrity: sha512-WILVM1wwefg8PxX5ment/CLkCj1ibWYXhAe16v+0BRNI7NQ6vknqF0xD4TGG1gKOJmcbLZY4oJN7Cjwz8G4jmA==}
    +    engines: {node: '>=18.12'}
    +
       '@pnpm/symlink-dependency@1000.0.14':
         resolution: {integrity: sha512-L1DZsBSGSo1zyDxuW6qmAo0tDMYnLi1I65dPwqUpjuolGuldh4mzalyYRaHUBgFyCRBwcYzKLEYGOsbF7lquMQ==}
         engines: {node: '>=18.12'}
         peerDependencies:
           '@pnpm/logger': '>=1001.0.0 <1002.0.0'
     
    +  '@pnpm/symlink-dependency@1000.0.16':
    +    resolution: {integrity: sha512-xPvIQgGRF7PX6ZBP+E/2DMp5JxCd1Zd6w+Fiwiszgs1sDR5G00jUtHlg8PBVHW5SGUqOikGHiOsFDRfmhxo8TQ==}
    +    engines: {node: '>=18.12'}
    +    peerDependencies:
    +      '@pnpm/logger': '>=1001.0.0 <1002.0.0'
    +
       '@pnpm/tabtab@0.5.4':
         resolution: {integrity: sha512-bWLDlHsBlgKY/05wDN/V3ETcn5G2SV/SiA2ZmNvKGGlmVX4G5li7GRDhHcgYvHJHyJ8TUStqg2xtHmCs0UbAbg==}
         engines: {node: '>=18'}
    @@ -10448,6 +10514,10 @@ packages:
         resolution: {integrity: sha512-v5X09E6LkJFOOw9FgGITpAs7nQJtx6u3N0SNtyIC5mSeIC5SebMrrelpCz6QUTJvyXBEa1AWj2dZhYfLj59xhA==}
         engines: {node: '>=18.12'}
     
    +  '@pnpm/types@1001.2.0':
    +    resolution: {integrity: sha512-UIju+OadUVS0q5q/MbRAzMS5M9HZcZyT6evyrgPUH0DV9przkcW7/LH1Sj33Q2MpJO9Nzqw4b4w72x8mvtUAew==}
    +    engines: {node: '>=18.12'}
    +
       '@pnpm/util.lex-comparator@3.0.2':
         resolution: {integrity: sha512-blFO4Ws97tWv/SNE6N39ZdGmZBrocXnBOfVp0ln4kELmns4pGPZizqyRtR8EjfOLMLstbmNCTReBoDvLz1isVg==}
         engines: {node: '>=18.12'}
    @@ -10463,6 +10533,12 @@ packages:
         peerDependencies:
           '@pnpm/logger': '>=1001.0.0 <1002.0.0'
     
    +  '@pnpm/worker@1000.6.0':
    +    resolution: {integrity: sha512-DgWHMQld2TMwt5AYvvNLc3+8peaVLV+9quZSa3zU5QEUceaIRXV0lc2cQhaJCbaLOejbsitqoWNnTV+2fg5jGA==}
    +    engines: {node: '>=18.12'}
    +    peerDependencies:
    +      '@pnpm/logger': '>=1001.0.0 <1002.0.0'
    +
       '@pnpm/workspace.find-packages@1000.0.25':
         resolution: {integrity: sha512-dKXeM46nSXKOzIIvofAhrcZqivxeJIqG27MX2nQoYYtccdJw6IBWozPqDJIPw0V3WLt9DAEQOqooEasbBmB5wg==}
         engines: {node: '>=18.12'}
    @@ -17080,6 +17156,8 @@ snapshots:
     
       '@pnpm/cafs-types@1000.0.0': {}
     
    +  '@pnpm/cafs-types@1000.1.0': {}
    +
       '@pnpm/catalogs.config@1000.0.2':
         dependencies:
           '@pnpm/error': 1000.0.2
    @@ -17113,19 +17191,19 @@ snapshots:
           - supports-color
           - typanion
     
    -  '@pnpm/cli-utils@1000.1.5(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.4.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))(typanion@3.14.0)':
    +  '@pnpm/cli-utils@1000.1.5(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.6.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))(typanion@3.14.0)':
         dependencies:
           '@pnpm/cli-meta': 1000.0.8
           '@pnpm/config': 1003.1.1(@pnpm/logger@1001.0.0)
    -      '@pnpm/config.deps-installer': 1000.0.5(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.4.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))
    +      '@pnpm/config.deps-installer': 1000.0.5(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.6.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))
           '@pnpm/default-reporter': 1002.0.1(@pnpm/logger@1001.0.0)
           '@pnpm/error': 1000.0.2
           '@pnpm/logger': 1001.0.0
           '@pnpm/manifest-utils': 1001.0.1(@pnpm/logger@1001.0.0)
           '@pnpm/package-is-installable': 1000.0.10(@pnpm/logger@1001.0.0)
           '@pnpm/pnpmfile': 1001.2.2(@pnpm/logger@1001.0.0)
           '@pnpm/read-project-manifest': 1000.0.11
    -      '@pnpm/store-connection-manager': 1002.0.3(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.4.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))(typanion@3.14.0)
    +      '@pnpm/store-connection-manager': 1002.0.3(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.6.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))(typanion@3.14.0)
           '@pnpm/types': 1000.6.0
           chalk: 4.1.2
           load-json-file: 6.2.0
    @@ -17154,16 +17232,16 @@ snapshots:
           - supports-color
           - typanion
     
    -  '@pnpm/client@1000.0.19(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.4.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))(typanion@3.14.0)':
    +  '@pnpm/client@1000.0.19(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.6.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))(typanion@3.14.0)':
         dependencies:
           '@pnpm/default-resolver': 1002.0.2(@pnpm/logger@1001.0.0)
           '@pnpm/directory-fetcher': 1000.1.7(@pnpm/logger@1001.0.0)
           '@pnpm/fetch': 1000.2.2(@pnpm/logger@1001.0.0)
           '@pnpm/fetching-types': 1000.1.0
    -      '@pnpm/git-fetcher': 1001.0.8(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.4.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))(typanion@3.14.0)
    +      '@pnpm/git-fetcher': 1001.0.8(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.6.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))(typanion@3.14.0)
           '@pnpm/network.auth-header': 1000.0.3
           '@pnpm/resolver-base': 1003.0.1
    -      '@pnpm/tarball-fetcher': 1001.0.8(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.4.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))(typanion@3.14.0)
    +      '@pnpm/tarball-fetcher': 1001.0.8(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.6.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))(typanion@3.14.0)
           '@pnpm/types': 1000.6.0
           ramda: '@pnpm/ramda@0.28.1'
         transitivePeerDependencies:
    @@ -17206,7 +17284,7 @@ snapshots:
           - domexception
           - supports-color
     
    -  '@pnpm/config.deps-installer@1000.0.5(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.4.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))':
    +  '@pnpm/config.deps-installer@1000.0.5(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.6.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))':
         dependencies:
           '@pnpm/config.config-writer': 1000.0.5
           '@pnpm/core-loggers': 1001.0.1(@pnpm/logger@1001.0.0)
    @@ -17215,7 +17293,7 @@ snapshots:
           '@pnpm/logger': 1001.0.0
           '@pnpm/network.auth-header': 1000.0.3
           '@pnpm/npm-resolver': 1004.0.1(@pnpm/logger@1001.0.0)
    -      '@pnpm/package-store': 1002.0.4(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.4.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))
    +      '@pnpm/package-store': 1002.0.4(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.6.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))
           '@pnpm/parse-wanted-dependency': 1001.0.0
           '@pnpm/pick-registry-for-package': 1000.0.8
           '@pnpm/read-modules-dir': 1000.0.0
    @@ -17278,6 +17356,11 @@ snapshots:
           '@pnpm/logger': 1001.0.0
           '@pnpm/types': 1001.0.1
     
    +  '@pnpm/core-loggers@1001.0.8(@pnpm/logger@1001.0.0)':
    +    dependencies:
    +      '@pnpm/logger': 1001.0.0
    +      '@pnpm/types': 1001.2.0
    +
       '@pnpm/create-cafs-store@1000.0.14(@pnpm/logger@1001.0.0)':
         dependencies:
           '@pnpm/exec.pkg-requires-build': 1000.0.8
    @@ -17302,6 +17385,18 @@ snapshots:
           path-temp: 2.1.0
           ramda: '@pnpm/ramda@0.28.1'
     
    +  '@pnpm/create-cafs-store@1000.0.27(@pnpm/logger@1001.0.0)':
    +    dependencies:
    +      '@pnpm/exec.pkg-requires-build': 1000.0.15
    +      '@pnpm/fetcher-base': 1001.2.0
    +      '@pnpm/fs.indexed-pkg-importer': 1000.1.21(@pnpm/logger@1001.0.0)
    +      '@pnpm/logger': 1001.0.0
    +      '@pnpm/store-controller-types': 1004.4.1
    +      '@pnpm/store.cafs': 1000.1.0
    +      mem: 8.1.1
    +      path-temp: 2.1.0
    +      ramda: '@pnpm/ramda@0.28.1'
    +
       '@pnpm/crypto.hash@1000.1.1':
         dependencies:
           '@pnpm/crypto.polyfill': 1000.1.0
    @@ -17391,6 +17486,10 @@ snapshots:
         dependencies:
           '@pnpm/types': 1001.0.1
     
    +  '@pnpm/exec.pkg-requires-build@1000.0.15':
    +    dependencies:
    +      '@pnpm/types': 1001.2.0
    +
       '@pnpm/exec.pkg-requires-build@1000.0.8':
         dependencies:
           '@pnpm/types': 1000.6.0
    @@ -17426,6 +17525,12 @@ snapshots:
           '@pnpm/types': 1001.0.1
           '@types/ssri': 7.1.5
     
    +  '@pnpm/fetcher-base@1001.2.0':
    +    dependencies:
    +      '@pnpm/resolver-base': 1005.3.3
    +      '@pnpm/types': 1001.2.0
    +      '@types/ssri': 7.1.5
    +
       '@pnpm/fetching-types@1000.1.0':
         dependencies:
           '@zkochan/retry': 0.2.0
    @@ -17468,6 +17573,21 @@ snapshots:
           rename-overwrite: 6.0.3
           sanitize-filename: 1.6.3
     
    +  '@pnpm/fs.indexed-pkg-importer@1000.1.21(@pnpm/logger@1001.0.0)':
    +    dependencies:
    +      '@pnpm/core-loggers': 1001.0.8(@pnpm/logger@1001.0.0)
    +      '@pnpm/graceful-fs': 1000.0.1
    +      '@pnpm/logger': 1001.0.0
    +      '@pnpm/store-controller-types': 1004.4.1
    +      '@reflink/reflink': 0.1.19
    +      '@zkochan/rimraf': 3.0.2
    +      fs-extra: 11.3.2
    +      make-empty-dir: 3.0.2
    +      p-limit: 3.1.0
    +      path-temp: 2.1.0
    +      rename-overwrite: 6.0.3
    +      sanitize-filename: 1.6.3
    +
       '@pnpm/fs.indexed-pkg-importer@1000.1.8(@pnpm/logger@1001.0.0)':
         dependencies:
           '@pnpm/core-loggers': 1001.0.1(@pnpm/logger@1001.0.0)
    @@ -17504,13 +17624,13 @@ snapshots:
           - supports-color
           - typanion
     
    -  '@pnpm/git-fetcher@1001.0.8(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.4.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))(typanion@3.14.0)':
    +  '@pnpm/git-fetcher@1001.0.8(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.6.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))(typanion@3.14.0)':
         dependencies:
           '@pnpm/fetcher-base': 1000.0.11
           '@pnpm/fs.packlist': 2.0.0
           '@pnpm/logger': 1001.0.0
           '@pnpm/prepare-package': 1000.0.16(@pnpm/logger@1001.0.0)(typanion@3.14.0)
    -      '@pnpm/worker': 1000.4.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30)
    +      '@pnpm/worker': 1000.6.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30)
           '@zkochan/rimraf': 3.0.2
           execa: safe-execa@0.1.2
         transitivePeerDependencies:
    @@ -17833,7 +17953,7 @@ snapshots:
           semver: 7.7.2
           ssri: 10.0.5
     
    -  '@pnpm/package-requester@1004.0.2(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.4.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))':
    +  '@pnpm/package-requester@1004.0.2(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.6.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))':
         dependencies:
           '@pnpm/core-loggers': 1001.0.1(@pnpm/logger@1001.0.0)
           '@pnpm/dependency-path': 1000.0.9
    @@ -17848,7 +17968,7 @@ snapshots:
           '@pnpm/store-controller-types': 1003.0.2
           '@pnpm/store.cafs': 1000.0.13
           '@pnpm/types': 1000.6.0
    -      '@pnpm/worker': 1000.4.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30)
    +      '@pnpm/worker': 1000.6.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30)
           p-defer: 3.0.0
           p-limit: 3.1.0
           p-queue: 6.6.2
    @@ -17873,17 +17993,17 @@ snapshots:
           ramda: '@pnpm/ramda@0.28.1'
           ssri: 10.0.5
     
    -  '@pnpm/package-store@1002.0.4(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.4.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))':
    +  '@pnpm/package-store@1002.0.4(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.6.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))':
         dependencies:
           '@pnpm/create-cafs-store': 1000.0.14(@pnpm/logger@1001.0.0)
           '@pnpm/fetcher-base': 1000.0.11
           '@pnpm/logger': 1001.0.0
    -      '@pnpm/package-requester': 1004.0.2(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.4.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))
    +      '@pnpm/package-requester': 1004.0.2(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.6.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))
           '@pnpm/resolver-base': 1003.0.1
           '@pnpm/store-controller-types': 1003.0.2
           '@pnpm/store.cafs': 1000.0.13
           '@pnpm/types': 1000.6.0
    -      '@pnpm/worker': 1000.4.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30)
    +      '@pnpm/worker': 1000.6.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30)
           '@zkochan/rimraf': 3.0.2
           load-json-file: 6.2.0
           ramda: '@pnpm/ramda@0.28.1'
    @@ -18005,6 +18125,10 @@ snapshots:
         dependencies:
           '@pnpm/types': 1001.0.1
     
    +  '@pnpm/resolver-base@1005.3.3':
    +    dependencies:
    +      '@pnpm/types': 1001.2.0
    +
       '@pnpm/resolving.jsr-specifier-parser@1000.0.0':
         dependencies:
           '@pnpm/error': 1000.0.2
    @@ -18049,14 +18173,14 @@ snapshots:
           - supports-color
           - typanion
     
    -  '@pnpm/store-connection-manager@1002.0.3(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.4.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))(typanion@3.14.0)':
    +  '@pnpm/store-connection-manager@1002.0.3(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.6.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))(typanion@3.14.0)':
         dependencies:
           '@pnpm/cli-meta': 1000.0.8
    -      '@pnpm/client': 1000.0.19(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.4.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))(typanion@3.14.0)
    +      '@pnpm/client': 1000.0.19(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.6.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))(typanion@3.14.0)
           '@pnpm/config': 1003.1.1(@pnpm/logger@1001.0.0)
           '@pnpm/error': 1000.0.2
           '@pnpm/logger': 1001.0.0
    -      '@pnpm/package-store': 1002.0.4(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.4.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))
    +      '@pnpm/package-store': 1002.0.4(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.6.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))
           '@pnpm/server': 1001.0.4(@pnpm/logger@1001.0.0)
           '@pnpm/store-path': 1000.0.2
           '@zkochan/diable': 1.0.2
    @@ -18080,6 +18204,12 @@ snapshots:
           '@pnpm/resolver-base': 1005.3.1
           '@pnpm/types': 1001.0.1
     
    +  '@pnpm/store-controller-types@1004.4.1':
    +    dependencies:
    +      '@pnpm/fetcher-base': 1001.2.0
    +      '@pnpm/resolver-base': 1005.3.3
    +      '@pnpm/types': 1001.2.0
    +
       '@pnpm/store-path@1000.0.2':
         dependencies:
           '@pnpm/constants': 1001.1.0
    @@ -18115,13 +18245,32 @@ snapshots:
           ssri: 10.0.5
           strip-bom: 4.0.0
     
    +  '@pnpm/store.cafs@1000.1.0':
    +    dependencies:
    +      '@pnpm/fetcher-base': 1001.2.0
    +      '@pnpm/graceful-fs': 1000.0.1
    +      '@pnpm/store-controller-types': 1004.4.1
    +      '@zkochan/rimraf': 3.0.2
    +      is-gzip: 2.0.0
    +      p-limit: 3.1.0
    +      rename-overwrite: 6.0.3
    +      ssri: 10.0.5
    +      strip-bom: 4.0.0
    +
       '@pnpm/symlink-dependency@1000.0.14(@pnpm/logger@1001.0.0)':
         dependencies:
           '@pnpm/core-loggers': 1001.0.6(@pnpm/logger@1001.0.0)
           '@pnpm/logger': 1001.0.0
           '@pnpm/types': 1001.0.1
           symlink-dir: 6.0.5
     
    +  '@pnpm/symlink-dependency@1000.0.16(@pnpm/logger@1001.0.0)':
    +    dependencies:
    +      '@pnpm/core-loggers': 1001.0.8(@pnpm/logger@1001.0.0)
    +      '@pnpm/logger': 1001.0.0
    +      '@pnpm/types': 1001.2.0
    +      symlink-dir: 6.0.5
    +
       '@pnpm/tabtab@0.5.4':
         dependencies:
           debug: 4.4.1
    @@ -18153,7 +18302,7 @@ snapshots:
           - supports-color
           - typanion
     
    -  '@pnpm/tarball-fetcher@1001.0.8(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.4.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))(typanion@3.14.0)':
    +  '@pnpm/tarball-fetcher@1001.0.8(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.6.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))(typanion@3.14.0)':
         dependencies:
           '@pnpm/core-loggers': 1001.0.1(@pnpm/logger@1001.0.0)
           '@pnpm/error': 1000.0.2
    @@ -18163,7 +18312,7 @@ snapshots:
           '@pnpm/graceful-fs': 1000.0.0
           '@pnpm/logger': 1001.0.0
           '@pnpm/prepare-package': 1000.0.16(@pnpm/logger@1001.0.0)(typanion@3.14.0)
    -      '@pnpm/worker': 1000.4.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30)
    +      '@pnpm/worker': 1000.6.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30)
           '@zkochan/retry': 0.2.0
           lodash.throttle: 4.1.1
           p-map-values: 1.0.0
    @@ -18192,6 +18341,8 @@ snapshots:
     
       '@pnpm/types@1001.0.1': {}
     
    +  '@pnpm/types@1001.2.0': {}
    +
       '@pnpm/util.lex-comparator@3.0.2': {}
     
       '@pnpm/which@3.0.1':
    @@ -18217,18 +18368,18 @@ snapshots:
         transitivePeerDependencies:
           - '@types/node'
     
    -  '@pnpm/worker@1000.4.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30)':
    +  '@pnpm/worker@1000.6.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30)':
         dependencies:
    -      '@pnpm/cafs-types': 1000.0.0
    -      '@pnpm/create-cafs-store': 1000.0.24(@pnpm/logger@1001.0.0)
    +      '@pnpm/cafs-types': 1000.1.0
    +      '@pnpm/create-cafs-store': 1000.0.27(@pnpm/logger@1001.0.0)
           '@pnpm/crypto.polyfill': 1000.1.0
           '@pnpm/error': 1000.0.5
    -      '@pnpm/exec.pkg-requires-build': 1000.0.13
    +      '@pnpm/exec.pkg-requires-build': 1000.0.15
           '@pnpm/fs.hard-link-dir': 1000.0.5(@pnpm/logger@1001.0.0)
           '@pnpm/graceful-fs': 1000.0.1
           '@pnpm/logger': 1001.0.0
    -      '@pnpm/store.cafs': 1000.0.22
    -      '@pnpm/symlink-dependency': 1000.0.14(@pnpm/logger@1001.0.0)
    +      '@pnpm/store.cafs': 1000.1.0
    +      '@pnpm/symlink-dependency': 1000.0.16(@pnpm/logger@1001.0.0)
           '@rushstack/worker-pool': 0.4.9(@types/node@22.15.30)
           is-windows: 1.0.2
           load-json-file: 6.2.0
    @@ -18250,9 +18401,9 @@ snapshots:
           - supports-color
           - typanion
     
    -  '@pnpm/workspace.find-packages@1000.0.25(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.4.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))(typanion@3.14.0)':
    +  '@pnpm/workspace.find-packages@1000.0.25(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.6.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))(typanion@3.14.0)':
         dependencies:
    -      '@pnpm/cli-utils': 1000.1.5(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.4.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))(typanion@3.14.0)
    +      '@pnpm/cli-utils': 1000.1.5(@pnpm/logger@1001.0.0)(@pnpm/worker@1000.6.0(@pnpm/logger@1001.0.0)(@types/node@22.15.30))(typanion@3.14.0)
           '@pnpm/constants': 1001.1.0
           '@pnpm/fs.find-packages': 1000.0.11
           '@pnpm/logger': 1001.0.0
    
  • store/package-store/package.json+5 1 modified
    @@ -45,16 +45,20 @@
       },
       "dependencies": {
         "@pnpm/create-cafs-store": "workspace:*",
    +    "@pnpm/crypto.hash": "workspace:*",
    +    "@pnpm/error": "workspace:*",
         "@pnpm/fetcher-base": "workspace:*",
         "@pnpm/package-requester": "workspace:*",
         "@pnpm/resolver-base": "workspace:*",
         "@pnpm/store-controller-types": "workspace:*",
         "@pnpm/store.cafs": "workspace:*",
         "@pnpm/types": "workspace:*",
         "@zkochan/rimraf": "catalog:",
    +    "is-subdir": "catalog:",
         "load-json-file": "catalog:",
         "ramda": "catalog:",
    -    "ssri": "catalog:"
    +    "ssri": "catalog:",
    +    "symlink-dir": "catalog:"
       },
       "peerDependencies": {
         "@pnpm/logger": "catalog:",
    
  • store/package-store/src/index.ts+1 0 modified
    @@ -1,3 +1,4 @@
     export { createPackageStore, type CafsLocker, type CreatePackageStoreOptions } from './storeController/index.js'
    +export { registerProject, getRegisteredProjects } from './storeController/projectRegistry.js'
     
     export * from '@pnpm/store-controller-types'
    
  • store/package-store/src/storeController/projectRegistry.ts+94 0 added
    @@ -0,0 +1,94 @@
    +import { type Dirent, promises as fs } from 'fs'
    +import util from 'util'
    +import path from 'path'
    +import { createShortHash } from '@pnpm/crypto.hash'
    +import { PnpmError } from '@pnpm/error'
    +import { globalInfo } from '@pnpm/logger'
    +import symlinkDir from 'symlink-dir'
    +
    +const PROJECTS_DIR = 'projects'
    +
    +export function getProjectsRegistryDir (storeDir: string): string {
    +  return path.join(storeDir, PROJECTS_DIR)
    +}
    +
    +/**
    + * Register a project as using the store.
    + * Creates a symlink in {storeDir}/projects/{hash} → {projectDir}
    + */
    +export async function registerProject (storeDir: string, projectDir: string): Promise<void> {
    +  const registryDir = getProjectsRegistryDir(storeDir)
    +  await fs.mkdir(registryDir, { recursive: true })
    +  const linkPath = path.join(registryDir, createShortHash(projectDir))
    +  // symlink-dir handles the case where the symlink already exists
    +  await symlinkDir(projectDir, linkPath)
    +}
    +
    +/**
    + * Get all registered projects that use the global virtual store.
    + * Cleans up stale entries (projects that no longer exist).
    + */
    +export async function getRegisteredProjects (storeDir: string): Promise<string[]> {
    +  const registryDir = getProjectsRegistryDir(storeDir)
    +  let entries: Dirent[]
    +  try {
    +    entries = await fs.readdir(registryDir, { withFileTypes: true })
    +  } catch (err: unknown) {
    +    if (util.types.isNativeError(err) && 'code' in err && err.code === 'ENOENT') {
    +      return []
    +    }
    +    throw err
    +  }
    +
    +  const projects: string[] = []
    +  await Promise.all(entries.map(async (entry) => {
    +    if (entry.name.startsWith('.')) return
    +    // We expect only symlinks (or junctions on Windows) in the registry
    +    if (!entry.isSymbolicLink()) return
    +    const linkPath = path.join(registryDir, entry.name)
    +
    +    // Read the symlink target - if this fails, it's an invalid entry
    +    let target: string
    +    try {
    +      target = await fs.readlink(linkPath)
    +    } catch (err: unknown) {
    +      // If the file is not a symlink (EINVAL) or doesn't exist (ENOENT), ignore it
    +      if (util.types.isNativeError(err) && 'code' in err && (err.code === 'ENOENT' || err.code === 'EINVAL')) {
    +        return
    +      }
    +      // For permission errors etc, inform the user
    +      const message = util.types.isNativeError(err) ? err.message : String(err)
    +      throw new PnpmError('PROJECT_REGISTRY_ENTRY_INACCESSIBLE',
    +        `Cannot read project registry entry "${linkPath}": ${message}`,
    +        {
    +          hint: `To remove this project from the registry, delete the file at:\n  ${linkPath}`,
    +        }
    +      )
    +    }
    +
    +    const absoluteTarget = path.isAbsolute(target) ? target : path.resolve(path.dirname(linkPath), target)
    +
    +    // Check if project still exists
    +    try {
    +      await fs.stat(absoluteTarget)
    +      projects.push(absoluteTarget)
    +    } catch (err: unknown) {
    +      // Only clean up if project directory no longer exists
    +      if (util.types.isNativeError(err) && 'code' in err && err.code === 'ENOENT') {
    +        await fs.unlink(linkPath)
    +        globalInfo(`Removed stale project registry entry: ${absoluteTarget}`)
    +        return
    +      }
    +      // Can't access project - throw error to prevent incorrect pruning
    +      const message = util.types.isNativeError(err) ? err.message : String(err)
    +      throw new PnpmError('PROJECT_INACCESSIBLE',
    +        `Cannot access registered project "${absoluteTarget}": ${message}`,
    +        {
    +          hint: `To remove this project from the registry, delete the symlink at:\n  ${linkPath}`,
    +        }
    +      )
    +    }
    +  }))
    +
    +  return projects
    +}
    
  • store/package-store/src/storeController/pruneGlobalVirtualStore.ts+297 0 added
    @@ -0,0 +1,297 @@
    +import { type Dirent, promises as fs } from 'fs'
    +import util from 'util'
    +import path from 'path'
    +import crypto from 'crypto'
    +import { globalInfo } from '@pnpm/logger'
    +import rimraf from '@zkochan/rimraf'
    +import isSubdir from 'is-subdir'
    +import { getRegisteredProjects } from './projectRegistry.js'
    +
    +const LINKS_DIR = 'links'
    +
    +/**
    + * Prune unused packages from the global virtual store using mark-and-sweep:
    + * 1. Get all registered projects
    + * 2. Find all node_modules directories in each project (including workspace packages)
    + * 3. Walk symlinks from each node_modules to mark reachable packages
    + * 4. Remove any package directories that weren't marked as reachable
    + */
    +export async function pruneGlobalVirtualStore (storeDir: string): Promise<void> {
    +  const linksDir = path.join(storeDir, LINKS_DIR)
    +  if (!await pathExists(linksDir)) {
    +    return
    +  }
    +
    +  const projects = await getRegisteredProjects(storeDir)
    +  if (projects.length === 0) {
    +    globalInfo('No registered projects for global virtual store')
    +    return
    +  }
    +
    +  globalInfo(`Checking ${projects.length} registered project(s) for global virtual store usage`)
    +
    +  // Mark phase: collect all reachable package directories
    +  const reachable = new Set<string>()
    +  const visited = new Set<string>() // Track visited directories to prevent infinite loops
    +
    +  // For each project, find all node_modules directories (root + workspace packages)
    +  await Promise.all(
    +    projects.map(async (projectDir) => {
    +      const nodeModulesDirs = await findAllNodeModulesDirs(projectDir)
    +      await Promise.all(
    +        nodeModulesDirs.map((modulesDir) =>
    +          walkSymlinksToStore(modulesDir, linksDir, reachable, visited)
    +        )
    +      )
    +    })
    +  )
    +
    +  // Sweep phase: remove unreachable packages
    +  const unreachableCount = await removeUnreachablePackages(linksDir, reachable)
    +  if (unreachableCount > 0) {
    +    globalInfo(`Removed ${unreachableCount} package${unreachableCount === 1 ? '' : 's'} from global virtual store`)
    +  } else {
    +    globalInfo('No unused packages found in global virtual store')
    +  }
    +}
    +
    +/**
    + * Find all node_modules directories within a project, including those
    + * in workspace packages. Does not descend into node_modules directories.
    + */
    +async function findAllNodeModulesDirs (projectDir: string): Promise<string[]> {
    +  const nodeModulesDirs: string[] = []
    +
    +  async function scan (dir: string): Promise<void> {
    +    let entries: Dirent[]
    +    try {
    +      entries = await fs.readdir(dir, { withFileTypes: true })
    +    } catch {
    +      return
    +    }
    +
    +    const subdirs: string[] = []
    +    for (const entry of entries) {
    +      if (!entry.isDirectory()) continue
    +
    +      const entryPath = path.join(dir, entry.name)
    +
    +      if (entry.name === 'node_modules') {
    +        nodeModulesDirs.push(entryPath)
    +        // Don't descend into node_modules
    +      } else if (!entry.name.startsWith('.')) {
    +        // Collect directories to descend into (workspace packages, etc.)
    +        // Skip hidden directories like .git, .pnpm
    +        subdirs.push(entryPath)
    +      }
    +    }
    +
    +    // Scan subdirectories concurrently
    +    await Promise.all(subdirs.map((subdir) => scan(subdir)))
    +  }
    +
    +  await scan(projectDir)
    +  return nodeModulesDirs
    +}
    +
    +/**
    + * Recursively walk symlinks from a directory, marking any that point
    + * into the global virtual store's links directory.
    + */
    +async function walkSymlinksToStore (
    +  dir: string,
    +  linksDir: string,
    +  reachable: Set<string>,
    +  visited: Set<string>
    +): Promise<void> {
    +  // Prevent infinite loops from circular symlinks
    +  const dirHash = await getRealPathHash(dir)
    +  if (visited.has(dirHash)) {
    +    return
    +  }
    +  visited.add(dirHash)
    +
    +  let entries: Dirent[]
    +  try {
    +    entries = await fs.readdir(dir, { withFileTypes: true })
    +  } catch {
    +    return
    +  }
    +
    +  await Promise.all(
    +    entries.map(async (entry) => {
    +      const entryPath = path.join(dir, entry.name)
    +
    +      if (entry.isSymbolicLink()) {
    +        try {
    +          const target = await fs.readlink(entryPath)
    +          const absoluteTarget = path.isAbsolute(target)
    +            ? target
    +            : path.resolve(dir, target)
    +
    +          // Check if this symlink points into the global virtual store
    +          if (isSubdir(linksDir, absoluteTarget)) {
    +            // Mark the package directory as reachable
    +            // The path structure is:
    +            //   - Scoped:   {linksDir}/{scope}/{pkgName}/{version}/{hash}/node_modules/{pkgName}
    +            //   - Unscoped: {linksDir}/@/{pkgName}/{version}/{hash}/node_modules/{pkgName}
    +            // We want to mark the {hash} directory
    +            const relPath = path.relative(linksDir, absoluteTarget)
    +            const parts = relPath.split(path.sep)
    +            // Find the hash directory (the one containing node_modules)
    +            const nodeModulesIdx = parts.indexOf('node_modules')
    +            if (nodeModulesIdx !== -1) {
    +              // Store relative path like "@scope/pkg-a/1.0.0/hash123" or "@/pkg-a/1.0.0/hash123"
    +              const relativePath = parts.slice(0, nodeModulesIdx).join(path.sep)
    +              reachable.add(relativePath)
    +              // Also walk into the package's node_modules for transitive deps
    +              const pkgNodeModules = path.join(linksDir, relativePath, 'node_modules')
    +              await walkSymlinksToStore(pkgNodeModules, linksDir, reachable, visited)
    +            }
    +          }
    +        } catch {
    +          // Ignore broken symlinks
    +        }
    +      } else if (entry.isDirectory() && entry.name !== '.pnpm') {
    +        // Recurse into directories (but not .pnpm which is the local virtual store)
    +        await walkSymlinksToStore(entryPath, linksDir, reachable, visited)
    +      }
    +    })
    +  )
    +}
    +
    +/**
    + * Resolve symlinks and return a hash of the real path (for cycle detection)
    + */
    +async function getRealPathHash (p: string): Promise<string> {
    +  let realPath: string
    +  try {
    +    realPath = await fs.realpath(p)
    +  } catch {
    +    realPath = p
    +  }
    +  // Create a compact hash for in-memory use (base64url is shorter than hex that we use for file name hashes)
    +  return crypto.createHash('sha256').update(realPath).digest('base64url')
    +}
    +
    +/**
    + * Remove package directories from the global virtual store that are not in the reachable set.
    + * Returns the count of removed packages.
    + *
    + * Directory structure is uniform 4-level:
    + * - Scoped: {linksDir}/{scope}/{pkgName}/{version}/{hash}/
    + * - Unscoped: {linksDir}/@/{pkgName}/{version}/{hash}/
    + */
    +async function removeUnreachablePackages (
    +  linksDir: string,
    +  reachable: Set<string>
    +): Promise<number> {
    +  // First level is always a scope (either @scope or @ for unscoped packages)
    +  const scopes = await getSubdirsSafely(linksDir)
    +  let count = 0
    +
    +  await Promise.all(
    +    scopes.map(async (scope) => {
    +      const scopePath = path.join(linksDir, scope)
    +      const pkgNames = await getSubdirsSafely(scopePath)
    +      let removedPkgs = 0
    +
    +      await Promise.all(
    +        pkgNames.map(async (pkgName) => {
    +          const pkgDir = path.join(scopePath, pkgName)
    +          const removedVersions = await removeUnreachableVersions(
    +            pkgDir,
    +            path.join(scope, pkgName),
    +            reachable
    +          )
    +          count += removedVersions.count
    +          if (removedVersions.allRemoved) {
    +            // Remove the package directory when all its versions are removed
    +            await rimraf(pkgDir)
    +            removedPkgs++
    +          }
    +        })
    +      )
    +
    +      // If we removed all packages in scope, remove the scope directory
    +      if (removedPkgs === pkgNames.length && pkgNames.length > 0) {
    +        await rimraf(scopePath)
    +      }
    +    })
    +  )
    +
    +  return count
    +}
    +
    +/**
    + * Remove unreachable versions and hashes for a package.
    + * Returns the count of removed packages and whether all versions were removed.
    + */
    +async function removeUnreachableVersions (
    +  pkgDir: string,
    +  pkgPath: string, // relative path like "@/is-positive" or "@pnpm.e2e/romeo"
    +  reachable: Set<string>
    +): Promise<{ count: number, allRemoved: boolean }> {
    +  const versions = await getSubdirsSafely(pkgDir)
    +  let count = 0
    +  let removedVersions = 0
    +
    +  await Promise.all(
    +    versions.map(async (version) => {
    +      const versionDir = path.join(pkgDir, version)
    +      const hashes = await getSubdirsSafely(versionDir)
    +
    +      // Remove unreachable hash directories
    +      let removedHashes = 0
    +      await Promise.all(
    +        hashes.map(async (hash) => {
    +          const relativePath = path.join(pkgPath, version, hash)
    +          if (!reachable.has(relativePath)) {
    +            await rimraf(path.join(versionDir, hash))
    +            removedHashes++
    +            count++
    +          }
    +        })
    +      )
    +
    +      // If we removed all hashes, remove the version directory
    +      if (removedHashes === hashes.length && hashes.length > 0) {
    +        await rimraf(versionDir)
    +        removedVersions++
    +      }
    +    })
    +  )
    +
    +  return {
    +    count,
    +    allRemoved: removedVersions === versions.length && versions.length > 0,
    +  }
    +}
    +
    +async function pathExists (p: string): Promise<boolean> {
    +  try {
    +    await fs.stat(p)
    +    return true
    +  } catch {
    +    return false
    +  }
    +}
    +
    +async function getSubdirsSafely (dir: string): Promise<string[]> {
    +  let entries: Dirent[]
    +  try {
    +    entries = await fs.readdir(dir, { withFileTypes: true }) as Dirent[]
    +  } catch (err: unknown) {
    +    if (util.types.isNativeError(err) && 'code' in err && err.code === 'ENOENT') {
    +      return []
    +    }
    +    throw err
    +  }
    +  const subdirs: string[] = []
    +  for (const entry of entries) {
    +    if (entry.isDirectory()) {
    +      subdirs.push(entry.name)
    +    }
    +  }
    +  return subdirs
    +}
    
  • store/package-store/src/storeController/prune.ts+11 1 modified
    @@ -6,6 +6,7 @@ import { globalInfo, globalWarn } from '@pnpm/logger'
     import rimraf from '@zkochan/rimraf'
     import loadJsonFile from 'load-json-file'
     import ssri from 'ssri'
    +import { pruneGlobalVirtualStore } from './pruneGlobalVirtualStore.js'
     
     const BIG_ONE = BigInt(1) as unknown
     
    @@ -15,7 +16,12 @@ export interface PruneOptions {
     }
     
     export async function prune ({ cacheDir, storeDir }: PruneOptions, removeAlienFiles?: boolean): Promise<void> {
    -  const cafsDir = path.join(storeDir, 'files')
    +  // 1. First, prune the global virtual store
    +  // This must happen BEFORE pruning the CAS, because removing packages from
    +  // the virtual store will reduce hard link counts on files in the CAS
    +  await pruneGlobalVirtualStore(storeDir)
    +
    +  // 2. Clean up metadata cache
       const metadataDirs = await getSubdirsSafely(cacheDir)
       await Promise.all(metadataDirs.map(async (metadataDir) => {
         if (!metadataDir.startsWith('metadata')) return
    @@ -29,6 +35,9 @@ export async function prune ({ cacheDir, storeDir }: PruneOptions, removeAlienFi
       }))
       await rimraf(path.join(storeDir, 'tmp'))
       globalInfo('Removed all cached metadata files')
    +
    +  // 3. Prune the content-addressable store (CAS)
    +  const cafsDir = path.join(storeDir, 'files')
       const pkgIndexFiles = [] as string[]
       const indexDir = path.join(storeDir, 'index')
       await Promise.all((await getSubdirsSafely(indexDir)).map(async (dir) => {
    @@ -72,6 +81,7 @@ export async function prune ({ cacheDir, storeDir }: PruneOptions, removeAlienFi
       }))
       globalInfo(`Removed ${fileCounter} file${fileCounter === 1 ? '' : 's'}`)
     
    +  // 4. Clean up orphaned package index files
       let pkgCounter = 0
       await Promise.all(pkgIndexFiles.map(async (pkgIndexFilePath) => {
         const { files: pkgFilesIndex } = await loadJsonFile<PackageFilesIndex>(pkgIndexFilePath)
    
  • store/package-store/test/projectRegistry.ts+110 0 added
    @@ -0,0 +1,110 @@
    +/// <reference path="../../../__typings__/index.d.ts"/>
    +import { promises as fs } from 'fs'
    +import path from 'path'
    +import { registerProject, getRegisteredProjects } from '@pnpm/package-store'
    +import tempy from 'tempy'
    +
    +describe('projectRegistry', () => {
    +  describe('registerProject()', () => {
    +    it('creates a symlink in the projects directory', async () => {
    +      const storeDir = tempy.directory()
    +      const projectDir = tempy.directory()
    +
    +      await registerProject(storeDir, projectDir)
    +
    +      // Check that projects directory was created
    +      const projectsDir = path.join(storeDir, 'projects')
    +      const entries = await fs.readdir(projectsDir)
    +      expect(entries).toHaveLength(1)
    +
    +      // Check that the symlink points to the project
    +      const linkPath = path.join(projectsDir, entries[0])
    +      const target = await fs.readlink(linkPath)
    +      expect(path.resolve(path.dirname(linkPath), target)).toBe(projectDir)
    +    })
    +
    +    it('is idempotent - registering the same project twice works', async () => {
    +      const storeDir = tempy.directory()
    +      const projectDir = tempy.directory()
    +
    +      await registerProject(storeDir, projectDir)
    +      await registerProject(storeDir, projectDir)
    +
    +      const projectsDir = path.join(storeDir, 'projects')
    +      const entries = await fs.readdir(projectsDir)
    +      expect(entries).toHaveLength(1)
    +    })
    +
    +    it('registers multiple projects with different hashes', async () => {
    +      const storeDir = tempy.directory()
    +      const projectDir1 = tempy.directory()
    +      const projectDir2 = tempy.directory()
    +
    +      await registerProject(storeDir, projectDir1)
    +      await registerProject(storeDir, projectDir2)
    +
    +      const projectsDir = path.join(storeDir, 'projects')
    +      const entries = await fs.readdir(projectsDir)
    +      expect(entries).toHaveLength(2)
    +    })
    +  })
    +
    +  describe('getRegisteredProjects()', () => {
    +    it('returns empty array when no projects are registered', async () => {
    +      const storeDir = tempy.directory()
    +
    +      const projects = await getRegisteredProjects(storeDir)
    +      expect(projects).toEqual([])
    +    })
    +
    +    it('returns registered project paths', async () => {
    +      const storeDir = tempy.directory()
    +      const projectDir1 = tempy.directory()
    +      const projectDir2 = tempy.directory()
    +
    +      await registerProject(storeDir, projectDir1)
    +      await registerProject(storeDir, projectDir2)
    +
    +      const projects = await getRegisteredProjects(storeDir)
    +      expect(projects.sort()).toEqual([projectDir1, projectDir2].sort())
    +    })
    +
    +    it('cleans up stale entries for deleted projects', async () => {
    +      const storeDir = tempy.directory()
    +      const projectDir = tempy.directory()
    +
    +      await registerProject(storeDir, projectDir)
    +
    +      // Verify project is registered
    +      let projects = await getRegisteredProjects(storeDir)
    +      expect(projects).toEqual([projectDir])
    +
    +      // Delete the project directory
    +      await fs.rm(projectDir, { recursive: true })
    +
    +      // getRegisteredProjects should clean up stale entry
    +      projects = await getRegisteredProjects(storeDir)
    +      expect(projects).toEqual([])
    +
    +      // Verify the symlink was removed
    +      const projectsDir = path.join(storeDir, 'projects')
    +      const entries = await fs.readdir(projectsDir)
    +      expect(entries).toEqual([])
    +    })
    +
    +    it('handles mix of valid and stale entries', async () => {
    +      const storeDir = tempy.directory()
    +      const validProject = tempy.directory()
    +      const staleProject = tempy.directory()
    +
    +      await registerProject(storeDir, validProject)
    +      await registerProject(storeDir, staleProject)
    +
    +      // Delete one project
    +      await fs.rm(staleProject, { recursive: true })
    +
    +      const projects = await getRegisteredProjects(storeDir)
    +      expect(projects).toEqual([validProject])
    +    })
    +  })
    +})
    
  • store/package-store/tsconfig.json+6 0 modified
    @@ -12,9 +12,15 @@
         {
           "path": "../../__utils__/prepare"
         },
    +    {
    +      "path": "../../crypto/hash"
    +    },
         {
           "path": "../../fetching/fetcher-base"
         },
    +    {
    +      "path": "../../packages/error"
    +    },
         {
           "path": "../../packages/logger"
         },
    
  • store/plugin-commands-store/package.json+2 1 modified
    @@ -60,6 +60,7 @@
         "@pnpm/constants": "workspace:*",
         "@pnpm/lockfile.fs": "workspace:*",
         "@pnpm/logger": "workspace:*",
    +    "@pnpm/package-store": "workspace:*",
         "@pnpm/plugin-commands-script-runners": "workspace:*",
         "@pnpm/plugin-commands-store": "workspace:*",
         "@pnpm/prepare": "workspace:*",
    @@ -78,4 +79,4 @@
       "jest": {
         "preset": "@pnpm/jest-config/with-registry"
       }
    -}
    +}
    \ No newline at end of file
    
  • store/plugin-commands-store/test/storePrune.ts+295 0 modified
    @@ -418,3 +418,298 @@ test('prune removes cache directories that outlives dlx-cache-max-age', async ()
           .sort()
       )
     })
    +
    +describe('global virtual store prune', () => {
    +  test('prune removes unreferenced packages from global virtual store', async () => {
    +    // Create project that installs a package with global virtual store enabled
    +    prepare({
    +      dependencies: {
    +        'is-positive': '1.0.0',
    +      },
    +    })
    +    const cacheDir = path.resolve('cache')
    +    const storeDir = path.resolve('store')
    +
    +    // Install with global virtual store enabled
    +    await execa('node', [
    +      pnpmBin,
    +      'install',
    +      `--store-dir=${storeDir}`,
    +      `--cache-dir=${cacheDir}`,
    +      `--registry=${REGISTRY}`,
    +      '--config.enableGlobalVirtualStore=true',
    +      '--config.ci=false', // This is needed because enableGlobalVirtualStore is set to fails in CI
    +    ])
    +
    +    // Verify the links directory was created
    +    const linksDir = path.join(storeDir, STORE_VERSION, 'links')
    +    expect(fs.existsSync(linksDir)).toBe(true)
    +
    +    // Remove the dependency from package.json and reinstall
    +    fs.writeFileSync('package.json', JSON.stringify({ dependencies: {} }))
    +    await execa('node', [
    +      pnpmBin,
    +      'install',
    +      `--store-dir=${storeDir}`,
    +      `--cache-dir=${cacheDir}`,
    +      `--registry=${REGISTRY}`,
    +      '--config.enableGlobalVirtualStore=true',
    +      '--config.ci=false',
    +    ])
    +
    +    // Run prune - should remove the now-unreferenced package
    +    await store.handler({
    +      cacheDir,
    +      dir: process.cwd(),
    +      pnpmHomeDir: '',
    +      rawConfig: {
    +        registry: REGISTRY,
    +      },
    +      registries: { default: REGISTRY },
    +      storeDir: path.join(storeDir, STORE_VERSION),
    +      userConfig: {},
    +      dlxCacheMaxAge: Infinity,
    +      virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120,
    +    }, ['prune'])
    +
    +    // Verify: is-positive should no longer exist in links/@/ directory
    +    const unscopedDir = path.join(linksDir, '@')
    +    const entries = fs.existsSync(unscopedDir) ? fs.readdirSync(unscopedDir) : []
    +    expect(entries).not.toContain('is-positive')
    +  })
    +
    +  test('prune keeps packages that are referenced by multiple projects', async () => {
    +    const storeDir = path.resolve('shared-store')
    +    const cacheDir = path.resolve('cache')
    +
    +    // Create first project with is-positive
    +    const project1Dir = path.resolve('project1')
    +    fs.mkdirSync(project1Dir, { recursive: true })
    +    fs.writeFileSync(path.join(project1Dir, 'package.json'), JSON.stringify({
    +      dependencies: { 'is-positive': '1.0.0' },
    +    }))
    +
    +    await execa('node', [
    +      pnpmBin,
    +      'install',
    +      `--store-dir=${storeDir}`,
    +      `--cache-dir=${cacheDir}`,
    +      `--registry=${REGISTRY}`,
    +      '--config.enableGlobalVirtualStore=true',
    +      '--config.ci=false',
    +    ], { cwd: project1Dir })
    +
    +    // Create second project with the same dependency
    +    const project2Dir = path.resolve('project2')
    +    fs.mkdirSync(project2Dir, { recursive: true })
    +    fs.writeFileSync(path.join(project2Dir, 'package.json'), JSON.stringify({
    +      dependencies: { 'is-positive': '1.0.0' },
    +    }))
    +
    +    await execa('node', [
    +      pnpmBin,
    +      'install',
    +      `--store-dir=${storeDir}`,
    +      `--cache-dir=${cacheDir}`,
    +      `--registry=${REGISTRY}`,
    +      '--config.enableGlobalVirtualStore=true',
    +      '--config.ci=false',
    +    ], { cwd: project2Dir })
    +
    +    // Delete project1
    +    rimraf(project1Dir)
    +
    +    // Verify package still exists in links/@/ directory
    +    const linksDir = path.join(storeDir, STORE_VERSION, 'links')
    +    const unscopedDir = path.join(linksDir, '@')
    +    const beforePrune = fs.readdirSync(unscopedDir)
    +    expect(beforePrune).toContain('is-positive')
    +
    +    // Run prune
    +    await store.handler({
    +      cacheDir,
    +      dir: process.cwd(),
    +      pnpmHomeDir: '',
    +      rawConfig: {
    +        registry: REGISTRY,
    +      },
    +      registries: { default: REGISTRY },
    +      storeDir: path.join(storeDir, STORE_VERSION),
    +      userConfig: {},
    +      dlxCacheMaxAge: Infinity,
    +      virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120,
    +    }, ['prune'])
    +
    +    // Package should still exist because project2 references it
    +    const afterPrune = fs.readdirSync(unscopedDir)
    +    expect(afterPrune).toContain('is-positive')
    +
    +    rimraf(project2Dir)
    +  })
    +
    +  test('prune removes packages when project using them is deleted', async () => {
    +    const storeDir = path.resolve('orphan-store')
    +    const cacheDir = path.resolve('cache')
    +
    +    // Create first project with is-positive
    +    const project1Dir = path.resolve('orphan-project1')
    +    fs.mkdirSync(project1Dir, { recursive: true })
    +    fs.writeFileSync(path.join(project1Dir, 'package.json'), JSON.stringify({
    +      dependencies: { 'is-positive': '1.0.0' },
    +    }))
    +
    +    await execa('node', [
    +      pnpmBin,
    +      'install',
    +      `--store-dir=${storeDir}`,
    +      `--cache-dir=${cacheDir}`,
    +      `--registry=${REGISTRY}`,
    +      '--config.enableGlobalVirtualStore=true',
    +      '--config.ci=false',
    +    ], { cwd: project1Dir })
    +
    +    // Create second project with a different package (so it stays)
    +    const project2Dir = path.resolve('orphan-project2')
    +    fs.mkdirSync(project2Dir, { recursive: true })
    +    fs.writeFileSync(path.join(project2Dir, 'package.json'), JSON.stringify({
    +      dependencies: { 'is-negative': '1.0.0' },
    +    }))
    +
    +    await execa('node', [
    +      pnpmBin,
    +      'install',
    +      `--store-dir=${storeDir}`,
    +      `--cache-dir=${cacheDir}`,
    +      `--registry=${REGISTRY}`,
    +      '--config.enableGlobalVirtualStore=true',
    +      '--config.ci=false',
    +    ], { cwd: project2Dir })
    +
    +    // Verify both packages exist in links/@/ directory
    +    const linksDir = path.join(storeDir, STORE_VERSION, 'links')
    +    const unscopedDir = path.join(linksDir, '@')
    +    expect(fs.existsSync(unscopedDir)).toBe(true)
    +    const beforePrune = fs.readdirSync(unscopedDir)
    +    expect(beforePrune).toContain('is-positive')
    +    expect(beforePrune).toContain('is-negative')
    +
    +    // Delete project1 (which uses is-positive)
    +    rimraf(project1Dir)
    +
    +    // Run prune
    +    await store.handler({
    +      cacheDir,
    +      dir: process.cwd(),
    +      pnpmHomeDir: '',
    +      rawConfig: {
    +        registry: REGISTRY,
    +      },
    +      registries: { default: REGISTRY },
    +      storeDir: path.join(storeDir, STORE_VERSION),
    +      userConfig: {},
    +      dlxCacheMaxAge: Infinity,
    +      virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120,
    +    }, ['prune'])
    +
    +    // is-positive should be removed since project1 was deleted
    +    const afterPrune = fs.readdirSync(unscopedDir)
    +    expect(afterPrune).not.toContain('is-positive')
    +    // is-negative should remain since project2 still exists
    +    expect(afterPrune).toContain('is-negative')
    +
    +    rimraf(project2Dir)
    +  })
    +
    +  test('prune preserves transitive dependencies and removes isolated ones', async () => {
    +    // Create project with three packages:
    +    // - @pnpm.e2e/pkg-with-1-dep has transitive dep @pnpm.e2e/dep-of-pkg-with-1-dep
    +    // - @pnpm.e2e/romeo has transitive dep @pnpm.e2e/romeo-dep
    +    // - is-positive has no transitive deps
    +    prepare({
    +      dependencies: {
    +        '@pnpm.e2e/pkg-with-1-dep': '100.0.0',
    +        '@pnpm.e2e/romeo': '1.0.0',
    +        'is-positive': '1.0.0',
    +      },
    +    })
    +
    +    // Store should be OUTSIDE the project directory to avoid findAllNodeModulesDirs
    +    // scanning the store's internal node_modules
    +    const storeDir = path.resolve('..', 'transitive-store')
    +    const cacheDir = path.resolve('..', 'cache')
    +
    +    await execa('node', [
    +      pnpmBin,
    +      'install',
    +      `--store-dir=${storeDir}`,
    +      `--cache-dir=${cacheDir}`,
    +      `--registry=${REGISTRY}`,
    +      '--config.enableGlobalVirtualStore=true',
    +      '--config.ci=false',
    +    ])
    +
    +    // Verify all packages exist in links directory
    +    const linksDir = path.join(storeDir, STORE_VERSION, 'links')
    +
    +    // Scoped packages are in links/@pnpm.e2e/pkg-name/
    +    const scopeDir = path.join(linksDir, '@pnpm.e2e')
    +    const scopedPkgs = fs.readdirSync(scopeDir)
    +    expect(scopedPkgs).toContain('pkg-with-1-dep')
    +    expect(scopedPkgs).toContain('dep-of-pkg-with-1-dep')
    +    expect(scopedPkgs).toContain('romeo')
    +    expect(scopedPkgs).toContain('romeo-dep')
    +    // Unscoped packages are in links/@/pkg-name/ (uniform 4-level depth)
    +    const unscopedDir = path.join(linksDir, '@')
    +    const unscopedPkgs = fs.readdirSync(unscopedDir)
    +    expect(unscopedPkgs).toContain('is-positive')
    +
    +    // Remove @pnpm.e2e/pkg-with-1-dep, keeping romeo and is-positive
    +    fs.writeFileSync('package.json', JSON.stringify({
    +      dependencies: {
    +        '@pnpm.e2e/romeo': '1.0.0',
    +        'is-positive': '1.0.0',
    +      },
    +    }))
    +    await execa('node', [
    +      pnpmBin,
    +      'install',
    +      `--store-dir=${storeDir}`,
    +      `--cache-dir=${cacheDir}`,
    +      `--registry=${REGISTRY}`,
    +      '--config.enableGlobalVirtualStore=true',
    +      '--config.ci=false',
    +    ])
    +
    +    // Run prune
    +    await store.handler({
    +      cacheDir,
    +      dir: process.cwd(),
    +      pnpmHomeDir: '',
    +      rawConfig: {
    +        registry: REGISTRY,
    +      },
    +      registries: { default: REGISTRY },
    +      storeDir: path.join(storeDir, STORE_VERSION),
    +      userConfig: {},
    +      dlxCacheMaxAge: Infinity,
    +      virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120,
    +    }, ['prune'])
    +
    +    // Verify:
    +    // - pkg-with-1-dep and its transitive dep-of-pkg-with-1-dep should be removed
    +    // - romeo and its transitive romeo-dep should still exist
    +    // - is-positive should still exist
    +    const afterPruneScopes = fs.readdirSync(linksDir)
    +    expect(afterPruneScopes).toContain('@') // unscoped packages scope
    +    const unscopedAfterPrune = fs.readdirSync(unscopedDir)
    +    expect(unscopedAfterPrune).toContain('is-positive')
    +
    +    const scopedPkgsAfter = fs.readdirSync(scopeDir)
    +    // pkg-with-1-dep and its transitive dep should be removed
    +    expect(scopedPkgsAfter).not.toEqual(expect.arrayContaining([expect.stringContaining('pkg-with-1-dep')]))
    +    expect(scopedPkgsAfter).not.toEqual(expect.arrayContaining([expect.stringContaining('dep-of-pkg-with-1-dep')]))
    +    // romeo and its transitive dep should be preserved
    +    expect(scopedPkgsAfter).toContain('romeo')
    +    expect(scopedPkgsAfter).toContain('romeo-dep')
    +  })
    +})
    
  • store/plugin-commands-store/tsconfig.json+3 0 modified
    @@ -57,6 +57,9 @@
         {
           "path": "../cafs"
         },
    +    {
    +      "path": "../package-store"
    +    },
         {
           "path": "../store-connection-manager"
         },
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

4

News mentions

0

No linked articles in our index yet.