|
1 | | -import { readdir } from "node:fs/promises"; |
| 1 | +import { readdir, rm } from "node:fs/promises"; |
2 | 2 | import { basename, join } from "node:path"; |
3 | 3 | import { APP_CONFIG, SCAN_PATHS } from "./config.ts"; |
4 | 4 | import type { DeleteResult, NodeModule, ScanOptions, ScanResult } from "./types.ts"; |
@@ -32,31 +32,49 @@ class Semaphore { |
32 | 32 | const PERMISSION_ERROR_CODES = new Set(SCAN_PATHS.permissionErrorCodes); |
33 | 33 |
|
34 | 34 | function shouldSkip(dirPath: string): boolean { |
| 35 | + const np = normalizeSep(dirPath); |
35 | 36 | const isAllowedCache = SCAN_PATHS.allowCachePatterns.some( |
36 | 37 | (p) => |
37 | | - dirPath.includes(p) && |
38 | | - !SCAN_PATHS.skipCacheSubdirs.some((skip) => dirPath.includes(skip)), |
| 38 | + np.includes(p) && |
| 39 | + !SCAN_PATHS.skipCacheSubdirs.some((skip) => np.includes(skip)), |
39 | 40 | ); |
40 | 41 | if (isAllowedCache) return false; |
41 | | - if (dirPath.includes(".npm/_npx")) return false; |
| 42 | + if (np.includes(".npm/_npx")) return false; |
42 | 43 |
|
43 | 44 | return SCAN_PATHS.systemSkipPatterns.some( |
44 | 45 | (p) => |
45 | | - dirPath.includes(p) || |
46 | | - dirPath.toLowerCase().includes(p.toLowerCase()), |
| 46 | + np.includes(p) || |
| 47 | + np.toLowerCase().includes(p.toLowerCase()), |
47 | 48 | ); |
48 | 49 | } |
49 | 50 |
|
| 51 | +const IS_WINDOWS = process.platform === "win32"; |
| 52 | + |
| 53 | +function normalizeSep(p: string): string { |
| 54 | + return IS_WINDOWS ? p.replaceAll("\\", "/") : p; |
| 55 | +} |
| 56 | + |
| 57 | +export function normalizeProjectPath(nmPath: string, target = "node_modules"): string { |
| 58 | + const normalized = normalizeSep(nmPath); |
| 59 | + const suffix = "/" + target.replace(/\/+$/, ""); |
| 60 | + return normalized.endsWith(suffix) ? normalized.slice(0, -suffix.length) : normalized; |
| 61 | +} |
| 62 | + |
50 | 63 | function isWithinRoot(path: string, root: string): boolean { |
51 | | - return path === root || path.startsWith(`${root}/`); |
| 64 | + const np = normalizeSep(path); |
| 65 | + const nr = normalizeSep(root); |
| 66 | + return np === nr || np.startsWith(`${nr}/`); |
52 | 67 | } |
53 | 68 |
|
54 | 69 | function hasHiddenPathSegment(dirPath: string, root: string): boolean { |
55 | | - const relativePath = dirPath.startsWith(`${root}/`) |
56 | | - ? dirPath.slice(root.length + 1) |
57 | | - : dirPath === root |
| 70 | + const np = normalizeSep(dirPath); |
| 71 | + const nr = normalizeSep(root); |
| 72 | + |
| 73 | + const relativePath = np.startsWith(`${nr}/`) |
| 74 | + ? np.slice(nr.length + 1) |
| 75 | + : np === nr |
58 | 76 | ? "" |
59 | | - : dirPath; |
| 77 | + : np; |
60 | 78 |
|
61 | 79 | if (!relativePath) { |
62 | 80 | return false; |
@@ -84,27 +102,29 @@ function createShouldSkipMatcher(options: ScanOptions): (dirPath: string) => boo |
84 | 102 | return true; |
85 | 103 | } |
86 | 104 |
|
87 | | - const lowerRoot = matchingRoot.toLowerCase(); |
88 | | - const lowerPath = dirPath.toLowerCase(); |
| 105 | + const np = normalizeSep(dirPath); |
| 106 | + const nr = normalizeSep(matchingRoot); |
| 107 | + const lowerNp = np.toLowerCase(); |
| 108 | + const lowerNr = nr.toLowerCase(); |
89 | 109 |
|
90 | 110 | const isAllowedCache = SCAN_PATHS.allowCachePatterns.some( |
91 | 111 | (pattern) => |
92 | | - dirPath.includes(pattern) && |
93 | | - !SCAN_PATHS.skipCacheSubdirs.some((skip) => dirPath.includes(skip)), |
| 112 | + np.includes(pattern) && |
| 113 | + !SCAN_PATHS.skipCacheSubdirs.some((skip) => np.includes(skip)), |
94 | 114 | ); |
95 | | - if (isAllowedCache || dirPath.includes(".npm/_npx")) { |
| 115 | + if (isAllowedCache || np.includes(".npm/_npx")) { |
96 | 116 | return false; |
97 | 117 | } |
98 | 118 |
|
99 | 119 | return SCAN_PATHS.systemSkipPatterns.some((pattern) => { |
100 | 120 | const matchesPattern = |
101 | | - dirPath.includes(pattern) || lowerPath.includes(pattern.toLowerCase()); |
| 121 | + np.includes(pattern) || lowerNp.includes(pattern.toLowerCase()); |
102 | 122 | if (!matchesPattern) { |
103 | 123 | return false; |
104 | 124 | } |
105 | 125 |
|
106 | 126 | const rootIncludesPattern = |
107 | | - matchingRoot.includes(pattern) || lowerRoot.includes(pattern.toLowerCase()); |
| 127 | + nr.includes(pattern) || lowerNr.includes(pattern.toLowerCase()); |
108 | 128 | return !rootIncludesPattern; |
109 | 129 | }); |
110 | 130 | }; |
@@ -153,25 +173,27 @@ async function readPackageMetadata(projectPath: string): Promise<{ |
153 | 173 | } |
154 | 174 |
|
155 | 175 | async function getDirectorySize(dirPath: string): Promise<number> { |
156 | | - try { |
157 | | - const proc = Bun.spawn({ |
158 | | - cmd: ["du", "-sk", dirPath], |
159 | | - stdout: "pipe", |
160 | | - stderr: "ignore", |
161 | | - }); |
162 | | - const output = await (new Response(proc.stdout) as globalThis.Response).text(); |
163 | | - if (await proc.exited === 0) { |
164 | | - const match = output.match(/^(\d+)/); |
165 | | - if (match?.[1]) return parseInt(match[1], 10) * 1024; |
| 176 | + if (!IS_WINDOWS) { |
| 177 | + try { |
| 178 | + const proc = Bun.spawn({ |
| 179 | + cmd: ["du", "-sk", dirPath], |
| 180 | + stdout: "pipe", |
| 181 | + stderr: "ignore", |
| 182 | + }); |
| 183 | + const output = await (new Response(proc.stdout) as globalThis.Response).text(); |
| 184 | + if (await proc.exited === 0) { |
| 185 | + const match = output.match(/^(\d+)/); |
| 186 | + if (match?.[1]) return parseInt(match[1], 10) * 1024; |
| 187 | + } |
| 188 | + } catch { |
| 189 | + /* ignore */ |
166 | 190 | } |
167 | | - } catch { |
168 | | - /* ignore */ |
169 | 191 | } |
170 | 192 |
|
171 | 193 | let total = 0; |
172 | 194 | try { |
173 | 195 | const glob = new Bun.Glob("**/*"); |
174 | | - for await (const file of glob.scan({ cwd: dirPath, onlyFiles: true })) { |
| 196 | + for await (const file of glob.scan({ cwd: dirPath, onlyFiles: true, dot: true })) { |
175 | 197 | try { |
176 | 198 | const s = await Bun.file(join(dirPath, file)).stat(); |
177 | 199 | total += s.size; |
@@ -242,7 +264,10 @@ async function discoverNodeModulesWithFs( |
242 | 264 | } |
243 | 265 |
|
244 | 266 | if (entry.name === options.target) { |
245 | | - if (fullPath.split("/node_modules").length > 2) { |
| 267 | + // Detect nesting: check if the normalized path contains /target/ as a segment |
| 268 | + const normalizedFull = normalizeSep(fullPath); |
| 269 | + const segmentMarker = "/" + options.target + "/"; |
| 270 | + if (normalizedFull.includes(segmentMarker)) { |
246 | 271 | continue; |
247 | 272 | } |
248 | 273 | hits.push(fullPath); |
@@ -325,7 +350,7 @@ export async function scan(options: ScanOptions): Promise<ScanResult> { |
325 | 350 | let mod: NodeModule | null = null; |
326 | 351 |
|
327 | 352 | await metaSemaphore.acquire(); |
328 | | - const projectPath = nmPath.replace(/\/node_modules$/, ""); |
| 353 | + const projectPath = normalizeProjectPath(nmPath, options.target); |
329 | 354 | try { |
330 | 355 | mod = await processModuleMeta(nmPath, projectPath); |
331 | 356 | } finally { |
@@ -423,16 +448,7 @@ export async function deleteModules( |
423 | 448 |
|
424 | 449 | for (const mod of modules) { |
425 | 450 | try { |
426 | | - const proc = Bun.spawn({ |
427 | | - cmd: ["rm", "-rf", mod.path], |
428 | | - stdout: "ignore", |
429 | | - stderr: "ignore", |
430 | | - }); |
431 | | - const ok = await proc.exited === 0; |
432 | | - if (!ok) { |
433 | | - failedPaths.push(mod.path); |
434 | | - continue; |
435 | | - } |
| 451 | + await rm(mod.path, { recursive: true, force: true }); |
436 | 452 | deleted++; |
437 | 453 | freed += mod.size; |
438 | 454 | deletedPaths.push(mod.path); |
|
0 commit comments