Skip to content

Commit 9e3b1cb

Browse files
committed
fix(cleanup): make directory removal safe and reliable
1 parent 23a5a6c commit 9e3b1cb

3 files changed

Lines changed: 92 additions & 24 deletions

File tree

packages/nuxt-cli/src/utils/fs.ts

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { existsSync, promises as fsp } from 'node:fs'
22
import { join } from 'pathe'
3-
import { debug } from '../utils/logger'
43

54
export async function clearDir(path: string, exclude?: string[]) {
65
if (!exclude) {
@@ -22,14 +21,3 @@ export async function clearDir(path: string, exclude?: string[]) {
2221
export function clearBuildDir(path: string) {
2322
return clearDir(path, ['cache', 'analyze', 'nuxt.json', 'nuxt.lock'])
2423
}
25-
26-
export async function rmRecursive(paths: string[]) {
27-
await Promise.all(
28-
paths
29-
.filter(p => typeof p === 'string')
30-
.map(async (path) => {
31-
debug(`Removing recursive path: ${path}`)
32-
await fsp.rm(path, { recursive: true, force: true }).catch(() => {})
33-
}),
34-
)
35-
}

packages/nuxt-cli/src/utils/nuxt.ts

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,7 @@ import { promises as fsp } from 'node:fs'
55
import { hash } from 'ohash'
66
import { dirname, resolve } from 'pathe'
77

8-
import { logger } from '../utils/logger'
9-
import { rmRecursive } from './fs'
8+
import { debug, logger } from '../utils/logger'
109

1110
const GIT_ID_RE = /\.([0-9a-f]{7,8})$/
1211

@@ -20,21 +19,29 @@ interface NuxtProjectManifest {
2019
}
2120
}
2221

23-
/** `silent` is for callers that already report progress themselves. */
2422
export async function cleanupNuxtDirs(rootDir: string, buildDir: string, options: { silent?: boolean } = {}) {
23+
const root = resolve(rootDir)
24+
const build = resolve(root, buildDir)
25+
if (build === root || root.startsWith(build.endsWith('/') ? build : `${build}/`)) {
26+
throw new Error('Cannot clean a build directory that contains the project root.')
27+
}
28+
2529
if (!options.silent) {
2630
logger.info('Cleaning up generated Nuxt files and caches...')
2731
}
2832

29-
await rmRecursive(
30-
[
31-
buildDir,
32-
'.output',
33-
'dist',
34-
'node_modules/.vite',
35-
'node_modules/.cache',
36-
].map(dir => resolve(rootDir, dir)),
37-
)
33+
const paths = new Set([
34+
build,
35+
'.output',
36+
'dist',
37+
'node_modules/.vite',
38+
'node_modules/.cache',
39+
].map(dir => resolve(root, dir)))
40+
41+
await Promise.all([...paths].map((path) => {
42+
debug(`Removing recursive path: ${path}`)
43+
return fsp.rm(path, { recursive: true, force: true })
44+
}))
3845
}
3946

4047
export function nuxtVersionToGitIdentifier(version: string) {
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { existsSync } from 'node:fs'
2+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
3+
import { tmpdir } from 'node:os'
4+
5+
import { runCommand } from 'citty'
6+
import { join } from 'pathe'
7+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
8+
9+
import cleanup from '../../../src/commands/cleanup'
10+
11+
const { loadNuxtConfig } = vi.hoisted(() => ({
12+
loadNuxtConfig: vi.fn(),
13+
}))
14+
15+
vi.mock('../../../src/utils/kit', () => ({
16+
loadKit: () => Promise.resolve({ loadNuxtConfig }),
17+
}))
18+
19+
let cwd: string
20+
21+
async function createFile(path: string) {
22+
await mkdir(join(path, '..'), { recursive: true })
23+
await writeFile(path, '')
24+
}
25+
26+
describe('cleanup', () => {
27+
beforeEach(async () => {
28+
vi.clearAllMocks()
29+
cwd = await mkdtemp(join(tmpdir(), 'nuxt-cleanup-'))
30+
loadNuxtConfig.mockResolvedValue({ rootDir: cwd, buildDir: join(cwd, '.nuxt') })
31+
})
32+
33+
afterEach(async () => {
34+
await rm(cwd, { recursive: true, force: true })
35+
})
36+
37+
it('loads the development config and removes generated directories', async () => {
38+
const generated = [
39+
'.nuxt/nuxt.json',
40+
'.output/server/index.mjs',
41+
'dist/index.html',
42+
'node_modules/.vite/cache',
43+
'node_modules/.cache/nuxt/client.json',
44+
]
45+
await Promise.all(generated.map(path => createFile(join(cwd, path))))
46+
await createFile(join(cwd, 'node_modules/nuxt/package.json'))
47+
48+
await runCommand(cleanup, { rawArgs: [cwd] })
49+
50+
expect(loadNuxtConfig).toHaveBeenCalledWith({ cwd, overrides: { dev: true } })
51+
expect(generated.every(path => !existsSync(join(cwd, path)))).toBe(true)
52+
expect(existsSync(join(cwd, 'node_modules/nuxt/package.json'))).toBe(true)
53+
})
54+
55+
it('removes a custom build directory only once when it overlaps a cache directory', async () => {
56+
const buildDir = join(cwd, 'node_modules/.cache')
57+
loadNuxtConfig.mockResolvedValue({ rootDir: cwd, buildDir })
58+
await createFile(join(buildDir, 'nuxt/client.json'))
59+
60+
await expect(runCommand(cleanup, { rawArgs: [cwd] })).resolves.toBeDefined()
61+
})
62+
63+
it.each([
64+
['the project root', () => cwd],
65+
['a parent of the project root', () => join(cwd, '..')],
66+
])('refuses to remove %s', async (_, getBuildDir) => {
67+
loadNuxtConfig.mockResolvedValue({ rootDir: cwd, buildDir: getBuildDir() })
68+
await createFile(join(cwd, 'package.json'))
69+
70+
await expect(runCommand(cleanup, { rawArgs: [cwd] })).rejects.toThrow('Cannot clean a build directory that contains the project root.')
71+
expect(existsSync(join(cwd, 'package.json'))).toBe(true)
72+
})
73+
})

0 commit comments

Comments
 (0)