diff --git a/.changeset/silly-teachers-cry.md b/.changeset/silly-teachers-cry.md new file mode 100644 index 0000000000..5a63386809 --- /dev/null +++ b/.changeset/silly-teachers-cry.md @@ -0,0 +1,5 @@ +--- +"@pnpm/package-store": patch +--- + +`pnpm store prune` should not fail if the store directory doesn't exist. diff --git a/store/package-store/src/storeController/prune.ts b/store/package-store/src/storeController/prune.ts index 00a657dfb9..58d18f4c38 100644 --- a/store/package-store/src/storeController/prune.ts +++ b/store/package-store/src/storeController/prune.ts @@ -1,4 +1,5 @@ -import { promises as fs } from 'fs' +import { type Dirent, promises as fs } from 'fs' +import util from 'util' import path from 'path' import { type PackageFilesIndex } from '@pnpm/store.cafs' import { globalInfo, globalWarn } from '@pnpm/logger' @@ -24,9 +25,7 @@ export async function prune ({ cacheDir, storeDir }: PruneOptions, removeAlienFi globalInfo('Removed all cached metadata files') const pkgIndexFiles = [] as string[] const removedHashes = new Set() - const dirs = (await fs.readdir(cafsDir, { withFileTypes: true })) - .filter(entry => entry.isDirectory()) - .map(dir => dir.name) + const dirs = await getSubdirsSafely(cafsDir) let fileCounter = 0 await Promise.all(dirs.map(async (dir) => { const subdir = path.join(cafsDir, dir) @@ -67,3 +66,18 @@ export async function prune ({ cacheDir, storeDir }: PruneOptions, removeAlienFi })) globalInfo(`Removed ${pkgCounter} package${pkgCounter === 1 ? '' : 's'}`) } + +async function getSubdirsSafely (dir: string): Promise { + 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 + } + return entries + .filter(entry => entry.isDirectory()) + .map(dir => dir.name) +} diff --git a/store/plugin-commands-store/test/storePrune.ts b/store/plugin-commands-store/test/storePrune.ts index b19cbddb5f..8402da1b70 100644 --- a/store/plugin-commands-store/test/storePrune.ts +++ b/store/plugin-commands-store/test/storePrune.ts @@ -281,6 +281,67 @@ test('prune removes alien files from the store if the --force flag is used', asy expect(fs.existsSync(alienDir)).toBeFalsy() }) +describe('prune when store directory is not properly configured', () => { + test('prune will not fail if the store directory does not exist (ENOENT)', async () => { + prepareEmpty() + const nonExistentStoreDir = path.resolve('store') + const reporter = jest.fn() + + await expect( + store.handler({ + cacheDir: path.resolve('cache'), + dir: process.cwd(), + pnpmHomeDir: '', + rawConfig: { + registry: REGISTRY, + }, + registries: { default: REGISTRY }, + reporter, + storeDir: nonExistentStoreDir, + userConfig: {}, + dlxCacheMaxAge: Infinity, + virtualStoreDirMaxLength: 120, + }, ['prune']) + ).resolves.toBeUndefined() + + expect(reporter).toHaveBeenCalledWith( + expect.objectContaining({ + level: 'info', + message: 'Removed 0 files', + }) + ) + + expect(reporter).toHaveBeenCalledWith( + expect.objectContaining({ + level: 'info', + message: 'Removed 0 packages', + }) + ) + }) + + test('prune will fail for other file-related errors (i.e.; not ENOENT)', async () => { + prepareEmpty() + const fileInPlaceOfStoreDir = path.resolve('store') + fs.writeFileSync(fileInPlaceOfStoreDir, '') + await expect( + store.handler({ + cacheDir: path.resolve('cache'), + dir: process.cwd(), + pnpmHomeDir: '', + rawConfig: { + registry: REGISTRY, + }, + registries: { default: REGISTRY }, + reporter: jest.fn(), + storeDir: fileInPlaceOfStoreDir, + userConfig: {}, + dlxCacheMaxAge: Infinity, + virtualStoreDirMaxLength: 120, + }, ['prune']) + ).rejects.toThrow(/^ENOTDIR/) + }) +}) + function createSampleDlxCacheLinkTarget (dirPath: string): void { fs.mkdirSync(path.join(dirPath, 'node_modules', '.pnpm'), { recursive: true }) fs.mkdirSync(path.join(dirPath, 'node_modules', '.bin'), { recursive: true })