@pnpm/store.controller 1004.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +13 -0
- package/lib/index.d.ts +3 -0
- package/lib/index.js +4 -0
- package/lib/storeController/index.d.ts +27 -0
- package/lib/storeController/index.js +63 -0
- package/lib/storeController/projectRegistry.d.ts +11 -0
- package/lib/storeController/projectRegistry.js +90 -0
- package/lib/storeController/prune.d.ts +7 -0
- package/lib/storeController/prune.js +91 -0
- package/lib/storeController/pruneGlobalVirtualStore.d.ts +8 -0
- package/lib/storeController/pruneGlobalVirtualStore.js +245 -0
- package/package.json +82 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2015-2016 Rico Sta. Cruz and other contributors
|
|
4
|
+
Copyright (c) 2016-2026 Zoltan Kochan and other contributors
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
package/README.md
ADDED
package/lib/index.d.ts
ADDED
package/lib/index.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Fetchers } from '@pnpm/fetching.fetcher-base';
|
|
2
|
+
import type { CustomFetcher } from '@pnpm/hooks.types';
|
|
3
|
+
import type { ResolveFunction } from '@pnpm/resolving.resolver-base';
|
|
4
|
+
import type { ImportIndexedPackageAsync, StoreController } from '@pnpm/store.controller-types';
|
|
5
|
+
import { type CafsLocker } from '@pnpm/store.create-cafs-store';
|
|
6
|
+
import type { StoreIndex } from '@pnpm/store.index';
|
|
7
|
+
export { type CafsLocker };
|
|
8
|
+
export interface CreatePackageStoreOptions {
|
|
9
|
+
cafsLocker?: CafsLocker;
|
|
10
|
+
engineStrict?: boolean;
|
|
11
|
+
force?: boolean;
|
|
12
|
+
nodeVersion?: string;
|
|
13
|
+
importPackage?: ImportIndexedPackageAsync;
|
|
14
|
+
pnpmVersion?: string;
|
|
15
|
+
ignoreFile?: (filename: string) => boolean;
|
|
16
|
+
cacheDir: string;
|
|
17
|
+
storeDir: string;
|
|
18
|
+
networkConcurrency?: number;
|
|
19
|
+
packageImportMethod?: 'auto' | 'hardlink' | 'copy' | 'clone' | 'clone-or-copy';
|
|
20
|
+
verifyStoreIntegrity: boolean;
|
|
21
|
+
virtualStoreDirMaxLength: number;
|
|
22
|
+
strictStorePkgContentCheck?: boolean;
|
|
23
|
+
clearResolutionCache: () => void;
|
|
24
|
+
customFetchers?: CustomFetcher[];
|
|
25
|
+
storeIndex: StoreIndex;
|
|
26
|
+
}
|
|
27
|
+
export declare function createPackageStore(resolve: ResolveFunction, fetchers: Fetchers, initOpts: CreatePackageStoreOptions): StoreController;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createPackageRequester } from '@pnpm/installing.package-requester';
|
|
4
|
+
import { createCafsStore, createPackageImporterAsync } from '@pnpm/store.create-cafs-store';
|
|
5
|
+
import { addFilesFromDir, importPackage, initStoreDir } from '@pnpm/worker';
|
|
6
|
+
import { prune } from './prune.js';
|
|
7
|
+
export {};
|
|
8
|
+
export function createPackageStore(resolve, fetchers, initOpts) {
|
|
9
|
+
const storeDir = initOpts.storeDir;
|
|
10
|
+
if (!fs.existsSync(path.join(storeDir, 'files'))) {
|
|
11
|
+
initStoreDir(storeDir).catch();
|
|
12
|
+
}
|
|
13
|
+
const cafs = createCafsStore(storeDir, {
|
|
14
|
+
cafsLocker: initOpts.cafsLocker,
|
|
15
|
+
packageImportMethod: initOpts.packageImportMethod,
|
|
16
|
+
});
|
|
17
|
+
const packageRequester = createPackageRequester({
|
|
18
|
+
force: initOpts.force,
|
|
19
|
+
engineStrict: initOpts.engineStrict,
|
|
20
|
+
nodeVersion: initOpts.nodeVersion,
|
|
21
|
+
pnpmVersion: initOpts.pnpmVersion,
|
|
22
|
+
resolve,
|
|
23
|
+
fetchers,
|
|
24
|
+
cafs,
|
|
25
|
+
ignoreFile: initOpts.ignoreFile,
|
|
26
|
+
networkConcurrency: initOpts.networkConcurrency,
|
|
27
|
+
storeDir: initOpts.storeDir,
|
|
28
|
+
verifyStoreIntegrity: initOpts.verifyStoreIntegrity,
|
|
29
|
+
virtualStoreDirMaxLength: initOpts.virtualStoreDirMaxLength,
|
|
30
|
+
strictStorePkgContentCheck: initOpts.strictStorePkgContentCheck,
|
|
31
|
+
customFetchers: initOpts.customFetchers,
|
|
32
|
+
});
|
|
33
|
+
return {
|
|
34
|
+
close: async () => {
|
|
35
|
+
initOpts.storeIndex.flush();
|
|
36
|
+
},
|
|
37
|
+
fetchPackage: packageRequester.fetchPackageToStore,
|
|
38
|
+
getFilesIndexFilePath: packageRequester.getFilesIndexFilePath,
|
|
39
|
+
importPackage: initOpts.importPackage
|
|
40
|
+
? createPackageImporterAsync({ importIndexedPackage: initOpts.importPackage, storeDir: cafs.storeDir })
|
|
41
|
+
: (targetDir, opts) => importPackage({
|
|
42
|
+
...opts,
|
|
43
|
+
packageImportMethod: initOpts.packageImportMethod,
|
|
44
|
+
storeDir: initOpts.storeDir,
|
|
45
|
+
targetDir,
|
|
46
|
+
}),
|
|
47
|
+
prune: prune.bind(null, { storeDir, cacheDir: initOpts.cacheDir, storeIndex: initOpts.storeIndex }),
|
|
48
|
+
requestPackage: packageRequester.requestPackage,
|
|
49
|
+
upload,
|
|
50
|
+
clearResolutionCache: initOpts.clearResolutionCache,
|
|
51
|
+
};
|
|
52
|
+
async function upload(builtPkgLocation, opts) {
|
|
53
|
+
await addFilesFromDir({
|
|
54
|
+
storeDir: cafs.storeDir,
|
|
55
|
+
storeIndex: initOpts.storeIndex,
|
|
56
|
+
dir: builtPkgLocation,
|
|
57
|
+
sideEffectsCacheKey: opts.sideEffectsCacheKey,
|
|
58
|
+
filesIndexFile: opts.filesIndexFile,
|
|
59
|
+
pkg: {},
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare function getProjectsRegistryDir(storeDir: string): string;
|
|
2
|
+
/**
|
|
3
|
+
* Register a project as using the store.
|
|
4
|
+
* Creates a symlink in {storeDir}/projects/{hash} → {projectDir}
|
|
5
|
+
*/
|
|
6
|
+
export declare function registerProject(storeDir: string, projectDir: string): Promise<void>;
|
|
7
|
+
/**
|
|
8
|
+
* Get all registered projects that use the global virtual store.
|
|
9
|
+
* Cleans up stale entries (projects that no longer exist).
|
|
10
|
+
*/
|
|
11
|
+
export declare function getRegisteredProjects(storeDir: string): Promise<string[]>;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import util from 'node:util';
|
|
4
|
+
import { createShortHash } from '@pnpm/crypto.hash';
|
|
5
|
+
import { PnpmError } from '@pnpm/error';
|
|
6
|
+
import { globalInfo } from '@pnpm/logger';
|
|
7
|
+
import { isSubdir } from 'is-subdir';
|
|
8
|
+
import symlinkDir from 'symlink-dir';
|
|
9
|
+
const PROJECTS_DIR = 'projects';
|
|
10
|
+
export function getProjectsRegistryDir(storeDir) {
|
|
11
|
+
return path.join(storeDir, PROJECTS_DIR);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Register a project as using the store.
|
|
15
|
+
* Creates a symlink in {storeDir}/projects/{hash} → {projectDir}
|
|
16
|
+
*/
|
|
17
|
+
export async function registerProject(storeDir, projectDir) {
|
|
18
|
+
// Avoid creating circular symlinks when the store is inside the project directory
|
|
19
|
+
if (isSubdir(projectDir, storeDir)) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const registryDir = getProjectsRegistryDir(storeDir);
|
|
23
|
+
await fs.mkdir(registryDir, { recursive: true });
|
|
24
|
+
const linkPath = path.join(registryDir, createShortHash(projectDir));
|
|
25
|
+
// symlink-dir handles the case where the symlink already exists
|
|
26
|
+
await symlinkDir(projectDir, linkPath);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Get all registered projects that use the global virtual store.
|
|
30
|
+
* Cleans up stale entries (projects that no longer exist).
|
|
31
|
+
*/
|
|
32
|
+
export async function getRegisteredProjects(storeDir) {
|
|
33
|
+
const registryDir = getProjectsRegistryDir(storeDir);
|
|
34
|
+
let entries;
|
|
35
|
+
try {
|
|
36
|
+
entries = await fs.readdir(registryDir, { withFileTypes: true });
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
if (util.types.isNativeError(err) && 'code' in err && err.code === 'ENOENT') {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
throw err;
|
|
43
|
+
}
|
|
44
|
+
const projects = [];
|
|
45
|
+
await Promise.all(entries.map(async (entry) => {
|
|
46
|
+
if (entry.name.startsWith('.'))
|
|
47
|
+
return;
|
|
48
|
+
// We expect only symlinks (or junctions on Windows) in the registry
|
|
49
|
+
if (!entry.isSymbolicLink())
|
|
50
|
+
return;
|
|
51
|
+
const linkPath = path.join(registryDir, entry.name);
|
|
52
|
+
// Read the symlink target - if this fails, it's an invalid entry
|
|
53
|
+
let target;
|
|
54
|
+
try {
|
|
55
|
+
target = await fs.readlink(linkPath);
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
// If the file is not a symlink (EINVAL) or doesn't exist (ENOENT), ignore it
|
|
59
|
+
if (util.types.isNativeError(err) && 'code' in err && (err.code === 'ENOENT' || err.code === 'EINVAL')) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
// For permission errors etc, inform the user
|
|
63
|
+
const message = util.types.isNativeError(err) ? err.message : String(err);
|
|
64
|
+
throw new PnpmError('PROJECT_REGISTRY_ENTRY_INACCESSIBLE', `Cannot read project registry entry "${linkPath}": ${message}`, {
|
|
65
|
+
hint: `To remove this project from the registry, delete the file at:\n ${linkPath}`,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
const absoluteTarget = path.isAbsolute(target) ? target : path.resolve(path.dirname(linkPath), target);
|
|
69
|
+
// Check if project still exists
|
|
70
|
+
try {
|
|
71
|
+
await fs.stat(absoluteTarget);
|
|
72
|
+
projects.push(absoluteTarget);
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
// Only clean up if project directory no longer exists
|
|
76
|
+
if (util.types.isNativeError(err) && 'code' in err && err.code === 'ENOENT') {
|
|
77
|
+
await fs.unlink(linkPath);
|
|
78
|
+
globalInfo(`Removed stale project registry entry: ${absoluteTarget}`);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
// Can't access project - throw error to prevent incorrect pruning
|
|
82
|
+
const message = util.types.isNativeError(err) ? err.message : String(err);
|
|
83
|
+
throw new PnpmError('PROJECT_INACCESSIBLE', `Cannot access registered project "${absoluteTarget}": ${message}`, {
|
|
84
|
+
hint: `To remove this project from the registry, delete the symlink at:\n ${linkPath}`,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}));
|
|
88
|
+
return projects;
|
|
89
|
+
}
|
|
90
|
+
//# sourceMappingURL=projectRegistry.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { StoreIndex } from '@pnpm/store.index';
|
|
2
|
+
export interface PruneOptions {
|
|
3
|
+
cacheDir: string;
|
|
4
|
+
storeDir: string;
|
|
5
|
+
storeIndex: StoreIndex;
|
|
6
|
+
}
|
|
7
|
+
export declare function prune({ cacheDir, storeDir, storeIndex }: PruneOptions, removeAlienFiles?: boolean): Promise<void>;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import util from 'node:util';
|
|
4
|
+
import { globalInfo, globalWarn } from '@pnpm/logger';
|
|
5
|
+
import { rimraf } from '@zkochan/rimraf';
|
|
6
|
+
import { pruneGlobalVirtualStore } from './pruneGlobalVirtualStore.js';
|
|
7
|
+
const BIG_ONE = BigInt(1);
|
|
8
|
+
export async function prune({ cacheDir, storeDir, storeIndex }, removeAlienFiles) {
|
|
9
|
+
// 1. First, prune the global virtual store
|
|
10
|
+
// This must happen BEFORE pruning the CAS, because removing packages from
|
|
11
|
+
// the virtual store will reduce hard link counts on files in the CAS
|
|
12
|
+
await pruneGlobalVirtualStore(storeDir);
|
|
13
|
+
// 2. Clean up metadata cache
|
|
14
|
+
const metadataDirs = await getSubdirsSafely(cacheDir);
|
|
15
|
+
await Promise.all(metadataDirs.map(async (metadataDir) => {
|
|
16
|
+
if (!metadataDir.startsWith('metadata'))
|
|
17
|
+
return;
|
|
18
|
+
try {
|
|
19
|
+
await rimraf(path.join(cacheDir, metadataDir));
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
if (!(util.types.isNativeError(err) && 'code' in err && err.code === 'ENOENT')) {
|
|
23
|
+
throw err;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}));
|
|
27
|
+
await rimraf(path.join(storeDir, 'tmp'));
|
|
28
|
+
globalInfo('Removed all cached metadata files');
|
|
29
|
+
// 3. Prune the content-addressable store (CAS)
|
|
30
|
+
const cafsDir = path.join(storeDir, 'files');
|
|
31
|
+
const removedHashes = new Set();
|
|
32
|
+
const dirs = await getSubdirsSafely(cafsDir);
|
|
33
|
+
let fileCounter = 0;
|
|
34
|
+
await Promise.all(dirs.map(async (dir) => {
|
|
35
|
+
const subdir = path.join(cafsDir, dir);
|
|
36
|
+
await Promise.all((await fs.readdir(subdir)).map(async (fileName) => {
|
|
37
|
+
const filePath = path.join(subdir, fileName);
|
|
38
|
+
const stat = await fs.stat(filePath);
|
|
39
|
+
if (stat.isDirectory()) {
|
|
40
|
+
if (removeAlienFiles) {
|
|
41
|
+
await rimraf(filePath);
|
|
42
|
+
globalWarn(`An alien directory has been removed from the store: ${filePath}`);
|
|
43
|
+
fileCounter++;
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
globalWarn(`An alien directory is present in the store: ${filePath}`);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (stat.nlink === 1 || stat.nlink === BIG_ONE) {
|
|
52
|
+
await fs.unlink(filePath);
|
|
53
|
+
fileCounter++;
|
|
54
|
+
// Store the hex digest, which matches the format stored in PackageFileInfo.digest
|
|
55
|
+
// The file name in the store is the hex representation of the hash (with optional -exec suffix)
|
|
56
|
+
removedHashes.add(`${dir}${fileName.replace(/-exec$/, '')}`);
|
|
57
|
+
}
|
|
58
|
+
}));
|
|
59
|
+
}));
|
|
60
|
+
globalInfo(`Removed ${fileCounter} file${fileCounter === 1 ? '' : 's'}`);
|
|
61
|
+
// 4. Clean up orphaned package index entries
|
|
62
|
+
let pkgCounter = 0;
|
|
63
|
+
const toDelete = [];
|
|
64
|
+
for (const [filesIndexFile, data] of storeIndex.entries()) {
|
|
65
|
+
const pkgFilesIndex = data;
|
|
66
|
+
const pkgJson = pkgFilesIndex.files.get('package.json');
|
|
67
|
+
// TODO: implement prune of Node.js packages, they don't have a package.json file
|
|
68
|
+
if (pkgJson && removedHashes.has(pkgJson.digest)) {
|
|
69
|
+
toDelete.push(filesIndexFile);
|
|
70
|
+
pkgCounter++;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
storeIndex.deleteMany(toDelete);
|
|
74
|
+
globalInfo(`Removed ${pkgCounter} package${pkgCounter === 1 ? '' : 's'}`);
|
|
75
|
+
}
|
|
76
|
+
async function getSubdirsSafely(dir) {
|
|
77
|
+
let entries;
|
|
78
|
+
try {
|
|
79
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
if (util.types.isNativeError(err) && 'code' in err && err.code === 'ENOENT') {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
throw err;
|
|
86
|
+
}
|
|
87
|
+
return entries
|
|
88
|
+
.filter(entry => entry.isDirectory())
|
|
89
|
+
.map(dir => dir.name);
|
|
90
|
+
}
|
|
91
|
+
//# sourceMappingURL=prune.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prune unused packages from the global virtual store using mark-and-sweep:
|
|
3
|
+
* 1. Get all registered projects
|
|
4
|
+
* 2. Find all node_modules directories in each project (including workspace packages)
|
|
5
|
+
* 3. Walk symlinks from each node_modules to mark reachable packages
|
|
6
|
+
* 4. Remove any package directories that weren't marked as reachable
|
|
7
|
+
*/
|
|
8
|
+
export declare function pruneGlobalVirtualStore(storeDir: string): Promise<void>;
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import util from 'node:util';
|
|
5
|
+
import { globalInfo } from '@pnpm/logger';
|
|
6
|
+
import { rimraf } from '@zkochan/rimraf';
|
|
7
|
+
import { isSubdir } from 'is-subdir';
|
|
8
|
+
import { getRegisteredProjects } from './projectRegistry.js';
|
|
9
|
+
const LINKS_DIR = 'links';
|
|
10
|
+
/**
|
|
11
|
+
* Prune unused packages from the global virtual store using mark-and-sweep:
|
|
12
|
+
* 1. Get all registered projects
|
|
13
|
+
* 2. Find all node_modules directories in each project (including workspace packages)
|
|
14
|
+
* 3. Walk symlinks from each node_modules to mark reachable packages
|
|
15
|
+
* 4. Remove any package directories that weren't marked as reachable
|
|
16
|
+
*/
|
|
17
|
+
export async function pruneGlobalVirtualStore(storeDir) {
|
|
18
|
+
const linksDir = path.join(storeDir, LINKS_DIR);
|
|
19
|
+
if (!await pathExists(linksDir)) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const projects = await getRegisteredProjects(storeDir);
|
|
23
|
+
if (projects.length === 0) {
|
|
24
|
+
globalInfo('No registered projects for global virtual store');
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
globalInfo(`Checking ${projects.length} registered project(s) for global virtual store usage`);
|
|
28
|
+
// Mark phase: collect all reachable package directories
|
|
29
|
+
const reachable = new Set();
|
|
30
|
+
const visited = new Set(); // Track visited directories to prevent infinite loops
|
|
31
|
+
// For each project, find all node_modules directories (root + workspace packages)
|
|
32
|
+
await Promise.all(projects.map(async (projectDir) => {
|
|
33
|
+
const nodeModulesDirs = await findAllNodeModulesDirs(projectDir);
|
|
34
|
+
await Promise.all(nodeModulesDirs.map((modulesDir) => walkSymlinksToStore(modulesDir, linksDir, reachable, visited)));
|
|
35
|
+
}));
|
|
36
|
+
// Sweep phase: remove unreachable packages
|
|
37
|
+
const unreachableCount = await removeUnreachablePackages(linksDir, reachable);
|
|
38
|
+
if (unreachableCount > 0) {
|
|
39
|
+
globalInfo(`Removed ${unreachableCount} package${unreachableCount === 1 ? '' : 's'} from global virtual store`);
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
globalInfo('No unused packages found in global virtual store');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Find all node_modules directories within a project, including those
|
|
47
|
+
* in workspace packages. Does not descend into node_modules directories.
|
|
48
|
+
*/
|
|
49
|
+
async function findAllNodeModulesDirs(projectDir) {
|
|
50
|
+
const nodeModulesDirs = [];
|
|
51
|
+
async function scan(dir) {
|
|
52
|
+
let entries;
|
|
53
|
+
try {
|
|
54
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const subdirs = [];
|
|
60
|
+
for (const entry of entries) {
|
|
61
|
+
if (!entry.isDirectory())
|
|
62
|
+
continue;
|
|
63
|
+
const entryPath = path.join(dir, entry.name);
|
|
64
|
+
if (entry.name === 'node_modules') {
|
|
65
|
+
nodeModulesDirs.push(entryPath);
|
|
66
|
+
// Don't descend into node_modules
|
|
67
|
+
}
|
|
68
|
+
else if (!entry.name.startsWith('.')) {
|
|
69
|
+
// Collect directories to descend into (workspace packages, etc.)
|
|
70
|
+
// Skip hidden directories like .git, .pnpm
|
|
71
|
+
subdirs.push(entryPath);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// Scan subdirectories concurrently
|
|
75
|
+
await Promise.all(subdirs.map((subdir) => scan(subdir)));
|
|
76
|
+
}
|
|
77
|
+
await scan(projectDir);
|
|
78
|
+
return nodeModulesDirs;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Recursively walk symlinks from a directory, marking any that point
|
|
82
|
+
* into the global virtual store's links directory.
|
|
83
|
+
*/
|
|
84
|
+
async function walkSymlinksToStore(dir, linksDir, reachable, visited) {
|
|
85
|
+
// Prevent infinite loops from circular symlinks
|
|
86
|
+
const dirHash = await getRealPathHash(dir);
|
|
87
|
+
if (visited.has(dirHash)) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
visited.add(dirHash);
|
|
91
|
+
let entries;
|
|
92
|
+
try {
|
|
93
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
await Promise.all(entries.map(async (entry) => {
|
|
99
|
+
const entryPath = path.join(dir, entry.name);
|
|
100
|
+
if (entry.isSymbolicLink()) {
|
|
101
|
+
try {
|
|
102
|
+
const target = await fs.readlink(entryPath);
|
|
103
|
+
const absoluteTarget = path.isAbsolute(target)
|
|
104
|
+
? target
|
|
105
|
+
: path.resolve(dir, target);
|
|
106
|
+
// Check if this symlink points into the global virtual store
|
|
107
|
+
if (isSubdir(linksDir, absoluteTarget)) {
|
|
108
|
+
// Mark the package directory as reachable
|
|
109
|
+
// The path structure is:
|
|
110
|
+
// - Scoped: {linksDir}/{scope}/{pkgName}/{version}/{hash}/node_modules/{pkgName}
|
|
111
|
+
// - Unscoped: {linksDir}/@/{pkgName}/{version}/{hash}/node_modules/{pkgName}
|
|
112
|
+
// We want to mark the {hash} directory
|
|
113
|
+
const relPath = path.relative(linksDir, absoluteTarget);
|
|
114
|
+
const parts = relPath.split(path.sep);
|
|
115
|
+
// Find the hash directory (the one containing node_modules)
|
|
116
|
+
const nodeModulesIdx = parts.indexOf('node_modules');
|
|
117
|
+
if (nodeModulesIdx !== -1) {
|
|
118
|
+
// Store relative path like "@scope/pkg-a/1.0.0/hash123" or "@/pkg-a/1.0.0/hash123"
|
|
119
|
+
const relativePath = parts.slice(0, nodeModulesIdx).join(path.sep);
|
|
120
|
+
reachable.add(relativePath);
|
|
121
|
+
// Also walk into the package's node_modules for transitive deps
|
|
122
|
+
const pkgNodeModules = path.join(linksDir, relativePath, 'node_modules');
|
|
123
|
+
await walkSymlinksToStore(pkgNodeModules, linksDir, reachable, visited);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// Ignore broken symlinks
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
else if (entry.isDirectory() && entry.name !== '.pnpm') {
|
|
132
|
+
// Recurse into directories (but not .pnpm which is the local virtual store)
|
|
133
|
+
await walkSymlinksToStore(entryPath, linksDir, reachable, visited);
|
|
134
|
+
}
|
|
135
|
+
}));
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Resolve symlinks and return a hash of the real path (for cycle detection)
|
|
139
|
+
*/
|
|
140
|
+
async function getRealPathHash(p) {
|
|
141
|
+
let realPath;
|
|
142
|
+
try {
|
|
143
|
+
realPath = await fs.realpath(p);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
realPath = p;
|
|
147
|
+
}
|
|
148
|
+
// Create a compact hash for in-memory use (base64url is shorter than hex that we use for file name hashes)
|
|
149
|
+
return crypto.createHash('sha256').update(realPath).digest('base64url');
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Remove package directories from the global virtual store that are not in the reachable set.
|
|
153
|
+
* Returns the count of removed packages.
|
|
154
|
+
*
|
|
155
|
+
* Directory structure is uniform 4-level:
|
|
156
|
+
* - Scoped: {linksDir}/{scope}/{pkgName}/{version}/{hash}/
|
|
157
|
+
* - Unscoped: {linksDir}/@/{pkgName}/{version}/{hash}/
|
|
158
|
+
*/
|
|
159
|
+
async function removeUnreachablePackages(linksDir, reachable) {
|
|
160
|
+
// First level is always a scope (either @scope or @ for unscoped packages)
|
|
161
|
+
const scopes = await getSubdirsSafely(linksDir);
|
|
162
|
+
let count = 0;
|
|
163
|
+
await Promise.all(scopes.map(async (scope) => {
|
|
164
|
+
const scopePath = path.join(linksDir, scope);
|
|
165
|
+
const pkgNames = await getSubdirsSafely(scopePath);
|
|
166
|
+
let removedPkgs = 0;
|
|
167
|
+
await Promise.all(pkgNames.map(async (pkgName) => {
|
|
168
|
+
const pkgDir = path.join(scopePath, pkgName);
|
|
169
|
+
const removedVersions = await removeUnreachableVersions(pkgDir, path.join(scope, pkgName), reachable);
|
|
170
|
+
count += removedVersions.count;
|
|
171
|
+
if (removedVersions.allRemoved) {
|
|
172
|
+
// Remove the package directory when all its versions are removed
|
|
173
|
+
await rimraf(pkgDir);
|
|
174
|
+
removedPkgs++;
|
|
175
|
+
}
|
|
176
|
+
}));
|
|
177
|
+
// If we removed all packages in scope, remove the scope directory
|
|
178
|
+
if (removedPkgs === pkgNames.length && pkgNames.length > 0) {
|
|
179
|
+
await rimraf(scopePath);
|
|
180
|
+
}
|
|
181
|
+
}));
|
|
182
|
+
return count;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Remove unreachable versions and hashes for a package.
|
|
186
|
+
* Returns the count of removed packages and whether all versions were removed.
|
|
187
|
+
*/
|
|
188
|
+
async function removeUnreachableVersions(pkgDir, pkgPath, // relative path like "@/is-positive" or "@pnpm.e2e/romeo"
|
|
189
|
+
reachable) {
|
|
190
|
+
const versions = await getSubdirsSafely(pkgDir);
|
|
191
|
+
let count = 0;
|
|
192
|
+
let removedVersions = 0;
|
|
193
|
+
await Promise.all(versions.map(async (version) => {
|
|
194
|
+
const versionDir = path.join(pkgDir, version);
|
|
195
|
+
const hashes = await getSubdirsSafely(versionDir);
|
|
196
|
+
// Remove unreachable hash directories
|
|
197
|
+
let removedHashes = 0;
|
|
198
|
+
await Promise.all(hashes.map(async (hash) => {
|
|
199
|
+
const relativePath = path.join(pkgPath, version, hash);
|
|
200
|
+
if (!reachable.has(relativePath)) {
|
|
201
|
+
await rimraf(path.join(versionDir, hash));
|
|
202
|
+
removedHashes++;
|
|
203
|
+
count++;
|
|
204
|
+
}
|
|
205
|
+
}));
|
|
206
|
+
// If we removed all hashes, remove the version directory
|
|
207
|
+
if (removedHashes === hashes.length && hashes.length > 0) {
|
|
208
|
+
await rimraf(versionDir);
|
|
209
|
+
removedVersions++;
|
|
210
|
+
}
|
|
211
|
+
}));
|
|
212
|
+
return {
|
|
213
|
+
count,
|
|
214
|
+
allRemoved: removedVersions === versions.length && versions.length > 0,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
async function pathExists(p) {
|
|
218
|
+
try {
|
|
219
|
+
await fs.stat(p);
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
async function getSubdirsSafely(dir) {
|
|
227
|
+
let entries;
|
|
228
|
+
try {
|
|
229
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
230
|
+
}
|
|
231
|
+
catch (err) {
|
|
232
|
+
if (util.types.isNativeError(err) && 'code' in err && err.code === 'ENOENT') {
|
|
233
|
+
return [];
|
|
234
|
+
}
|
|
235
|
+
throw err;
|
|
236
|
+
}
|
|
237
|
+
const subdirs = [];
|
|
238
|
+
for (const entry of entries) {
|
|
239
|
+
if (entry.isDirectory()) {
|
|
240
|
+
subdirs.push(entry.name);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return subdirs;
|
|
244
|
+
}
|
|
245
|
+
//# sourceMappingURL=pruneGlobalVirtualStore.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pnpm/store.controller",
|
|
3
|
+
"version": "1004.0.0",
|
|
4
|
+
"description": "A storage for packages",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pnpm",
|
|
7
|
+
"pnpm11",
|
|
8
|
+
"cache",
|
|
9
|
+
"central storage",
|
|
10
|
+
"global store",
|
|
11
|
+
"maching store",
|
|
12
|
+
"packages",
|
|
13
|
+
"storage",
|
|
14
|
+
"store"
|
|
15
|
+
],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"funding": "https://opencollective.com/pnpm",
|
|
18
|
+
"repository": "https://github.com/pnpm/pnpm/tree/main/store/controller",
|
|
19
|
+
"homepage": "https://github.com/pnpm/pnpm/tree/main/store/controller#readme",
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/pnpm/pnpm/issues"
|
|
22
|
+
},
|
|
23
|
+
"type": "module",
|
|
24
|
+
"main": "lib/index.js",
|
|
25
|
+
"types": "lib/index.d.ts",
|
|
26
|
+
"exports": {
|
|
27
|
+
".": "./lib/index.js"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"lib",
|
|
31
|
+
"!*.map"
|
|
32
|
+
],
|
|
33
|
+
"directories": {
|
|
34
|
+
"test": "test"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@zkochan/rimraf": "^4.0.0",
|
|
38
|
+
"is-subdir": "^2.0.0",
|
|
39
|
+
"ramda": "npm:@pnpm/ramda@0.28.1",
|
|
40
|
+
"ssri": "13.0.0",
|
|
41
|
+
"symlink-dir": "^7.0.0",
|
|
42
|
+
"@pnpm/fetching.fetcher-base": "1001.0.2",
|
|
43
|
+
"@pnpm/error": "1000.0.5",
|
|
44
|
+
"@pnpm/installing.package-requester": "1008.0.0",
|
|
45
|
+
"@pnpm/hooks.types": "1001.0.12",
|
|
46
|
+
"@pnpm/resolving.resolver-base": "1005.1.0",
|
|
47
|
+
"@pnpm/store.cafs": "1000.0.19",
|
|
48
|
+
"@pnpm/store.controller-types": "1004.1.0",
|
|
49
|
+
"@pnpm/store.create-cafs-store": "1000.0.20",
|
|
50
|
+
"@pnpm/store.index": "1000.0.0-0",
|
|
51
|
+
"@pnpm/crypto.hash": "1000.2.1",
|
|
52
|
+
"@pnpm/types": "1000.9.0"
|
|
53
|
+
},
|
|
54
|
+
"peerDependencies": {
|
|
55
|
+
"@pnpm/logger": ">=1001.0.0 <1002.0.0",
|
|
56
|
+
"@pnpm/worker": "^1000.3.0"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@types/ramda": "0.29.12",
|
|
60
|
+
"@types/ssri": "^7.1.5",
|
|
61
|
+
"tempy": "3.0.0",
|
|
62
|
+
"@pnpm/installing.client": "1001.1.4",
|
|
63
|
+
"@pnpm/logger": "1001.0.1",
|
|
64
|
+
"@pnpm/store.controller": "1004.0.0",
|
|
65
|
+
"@pnpm/prepare": "1000.0.4"
|
|
66
|
+
},
|
|
67
|
+
"engines": {
|
|
68
|
+
"node": ">=22.13"
|
|
69
|
+
},
|
|
70
|
+
"jest": {
|
|
71
|
+
"preset": "@pnpm/jest-config"
|
|
72
|
+
},
|
|
73
|
+
"scripts": {
|
|
74
|
+
"start": "tsgo --watch",
|
|
75
|
+
"fix": "tslint -c tslint.json src/**/*.ts test/**/*.ts --fix",
|
|
76
|
+
"lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"",
|
|
77
|
+
"pretest": "rimraf .tmp",
|
|
78
|
+
"_test": "pnpm pretest && cross-env NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169\" jest",
|
|
79
|
+
"test": "pnpm run compile && pnpm run _test",
|
|
80
|
+
"compile": "tsgo --build && pnpm run lint --fix"
|
|
81
|
+
}
|
|
82
|
+
}
|