|
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,48 @@ 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 | +/** Strip trailing /<target> segment and normalize separators. */ |
| 58 | +export function normalizeProjectPath(nmPath: string, target = "node_modules"): string { |
| 59 | + return normalizeSep(nmPath).replace(new RegExp(`/${target}$`), ""); |
| 60 | +} |
| 61 | + |
50 | 62 | function isWithinRoot(path: string, root: string): boolean { |
51 | | - return path === root || path.startsWith(`${root}/`); |
| 63 | + const np = normalizeSep(path); |
| 64 | + const nr = normalizeSep(root); |
| 65 | + return np === nr || np.startsWith(`${nr}/`); |
52 | 66 | } |
53 | 67 |
|
54 | 68 | function hasHiddenPathSegment(dirPath: string, root: string): boolean { |
55 | | - const relativePath = dirPath.startsWith(`${root}/`) |
56 | | - ? dirPath.slice(root.length + 1) |
57 | | - : dirPath === root |
| 69 | + const np = normalizeSep(dirPath); |
| 70 | + const nr = normalizeSep(root); |
| 71 | + |
| 72 | + const relativePath = np.startsWith(`${nr}/`) |
| 73 | + ? np.slice(nr.length + 1) |
| 74 | + : np === nr |
58 | 75 | ? "" |
59 | | - : dirPath; |
| 76 | + : np; |
60 | 77 |
|
61 | 78 | if (!relativePath) { |
62 | 79 | return false; |
@@ -84,27 +101,29 @@ function createShouldSkipMatcher(options: ScanOptions): (dirPath: string) => boo |
84 | 101 | return true; |
85 | 102 | } |
86 | 103 |
|
87 | | - const lowerRoot = matchingRoot.toLowerCase(); |
88 | | - const lowerPath = dirPath.toLowerCase(); |
| 104 | + const np = normalizeSep(dirPath); |
| 105 | + const nr = normalizeSep(matchingRoot); |
| 106 | + const lowerNp = np.toLowerCase(); |
| 107 | + const lowerNr = nr.toLowerCase(); |
89 | 108 |
|
90 | 109 | const isAllowedCache = SCAN_PATHS.allowCachePatterns.some( |
91 | 110 | (pattern) => |
92 | | - dirPath.includes(pattern) && |
93 | | - !SCAN_PATHS.skipCacheSubdirs.some((skip) => dirPath.includes(skip)), |
| 111 | + np.includes(pattern) && |
| 112 | + !SCAN_PATHS.skipCacheSubdirs.some((skip) => np.includes(skip)), |
94 | 113 | ); |
95 | | - if (isAllowedCache || dirPath.includes(".npm/_npx")) { |
| 114 | + if (isAllowedCache || np.includes(".npm/_npx")) { |
96 | 115 | return false; |
97 | 116 | } |
98 | 117 |
|
99 | 118 | return SCAN_PATHS.systemSkipPatterns.some((pattern) => { |
100 | 119 | const matchesPattern = |
101 | | - dirPath.includes(pattern) || lowerPath.includes(pattern.toLowerCase()); |
| 120 | + np.includes(pattern) || lowerNp.includes(pattern.toLowerCase()); |
102 | 121 | if (!matchesPattern) { |
103 | 122 | return false; |
104 | 123 | } |
105 | 124 |
|
106 | 125 | const rootIncludesPattern = |
107 | | - matchingRoot.includes(pattern) || lowerRoot.includes(pattern.toLowerCase()); |
| 126 | + nr.includes(pattern) || lowerNr.includes(pattern.toLowerCase()); |
108 | 127 | return !rootIncludesPattern; |
109 | 128 | }); |
110 | 129 | }; |
@@ -153,25 +172,27 @@ async function readPackageMetadata(projectPath: string): Promise<{ |
153 | 172 | } |
154 | 173 |
|
155 | 174 | 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; |
| 175 | + if (!IS_WINDOWS) { |
| 176 | + try { |
| 177 | + const proc = Bun.spawn({ |
| 178 | + cmd: ["du", "-sk", dirPath], |
| 179 | + stdout: "pipe", |
| 180 | + stderr: "ignore", |
| 181 | + }); |
| 182 | + const output = await (new Response(proc.stdout) as globalThis.Response).text(); |
| 183 | + if (await proc.exited === 0) { |
| 184 | + const match = output.match(/^(\d+)/); |
| 185 | + if (match?.[1]) return parseInt(match[1], 10) * 1024; |
| 186 | + } |
| 187 | + } catch { |
| 188 | + /* ignore */ |
166 | 189 | } |
167 | | - } catch { |
168 | | - /* ignore */ |
169 | 190 | } |
170 | 191 |
|
171 | 192 | let total = 0; |
172 | 193 | try { |
173 | 194 | const glob = new Bun.Glob("**/*"); |
174 | | - for await (const file of glob.scan({ cwd: dirPath, onlyFiles: true })) { |
| 195 | + for await (const file of glob.scan({ cwd: dirPath, onlyFiles: true, dot: true })) { |
175 | 196 | try { |
176 | 197 | const s = await Bun.file(join(dirPath, file)).stat(); |
177 | 198 | total += s.size; |
@@ -242,7 +263,10 @@ async function discoverNodeModulesWithFs( |
242 | 263 | } |
243 | 264 |
|
244 | 265 | if (entry.name === options.target) { |
245 | | - if (fullPath.split("/node_modules").length > 2) { |
| 266 | + // Detect nesting: check if the normalized path contains /target/ as a segment |
| 267 | + const normalizedFull = normalizeSep(fullPath); |
| 268 | + const segmentMarker = "/" + options.target + "/"; |
| 269 | + if (normalizedFull.includes(segmentMarker)) { |
246 | 270 | continue; |
247 | 271 | } |
248 | 272 | hits.push(fullPath); |
@@ -325,7 +349,7 @@ export async function scan(options: ScanOptions): Promise<ScanResult> { |
325 | 349 | let mod: NodeModule | null = null; |
326 | 350 |
|
327 | 351 | await metaSemaphore.acquire(); |
328 | | - const projectPath = nmPath.replace(/\/node_modules$/, ""); |
| 352 | + const projectPath = normalizeProjectPath(nmPath, options.target); |
329 | 353 | try { |
330 | 354 | mod = await processModuleMeta(nmPath, projectPath); |
331 | 355 | } finally { |
@@ -423,16 +447,7 @@ export async function deleteModules( |
423 | 447 |
|
424 | 448 | for (const mod of modules) { |
425 | 449 | 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 | | - } |
| 450 | + await rm(mod.path, { recursive: true, force: true }); |
436 | 451 | deleted++; |
437 | 452 | freed += mod.size; |
438 | 453 | deletedPaths.push(mod.path); |
|
0 commit comments