Skip to content

Commit b74e056

Browse files
committed
fix: Windows compatibility for delete, size, and path separators
1 parent 91676da commit b74e056

4 files changed

Lines changed: 125 additions & 55 deletions

File tree

.github/workflows/ci.yml

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
test:
11+
name: Test on ${{ matrix.os }}
12+
runs-on: ${{ matrix.os }}
13+
strategy:
14+
fail-fast: false
15+
matrix:
16+
os: [ubuntu-latest, macos-latest, windows-latest]
17+
18+
steps:
19+
- uses: actions/checkout@v4
20+
21+
- uses: oven-sh/setup-bun@v2
22+
with:
23+
bun-version: latest
24+
25+
- name: Install dependencies
26+
run: bun install
27+
28+
- name: Type check
29+
run: bun run check
30+
31+
- name: Build
32+
run: bun run build
33+
34+
- name: Smoke test - help
35+
run: bun run src/cli.ts --help
36+
37+
- name: Smoke test - dry-run (Unix)
38+
if: runner.os != 'Windows'
39+
run: bun run src/cli.ts --dir ${{ github.workspace }} --dry-run --hide-errors
40+
41+
- name: Smoke test - dry-run (Windows)
42+
if: runner.os == 'Windows'
43+
run: bun run src/cli.ts --dir ${{ github.workspace }} --dry-run --hide-errors
44+
shell: pwsh

README.md

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,22 @@ BunKill scans large directory trees, calculates folder sizes, and lets you delet
2929

3030
## Requirements
3131

32-
- Bun is required at runtime
33-
- macOS is the only platform tested so far
32+
- [Bun](https://bun.sh) runtime is required
33+
- Supported platforms: **macOS**, **Linux**, **Windows 10/11**
34+
- Windows: requires [Windows Terminal](https://aka.ms/terminal) for best interactive UI experience
3435

35-
Install Bun if needed:
36+
**Install Bun:**
3637

38+
macOS / Linux:
3739
```bash
3840
curl -fsSL https://bun.sh/install | bash
3941
```
4042

43+
Windows (PowerShell):
44+
```powershell
45+
powershell -c "irm bun.sh/install.ps1 | iex"
46+
```
47+
4148
## Install
4249

4350
```bash
@@ -56,9 +63,12 @@ bun install -g bunkill
5663
# interactive scan in current directory
5764
bunkill
5865

59-
# scan a specific directory
66+
# scan a specific directory (macOS / Linux)
6067
bunkill --dir ~/Projects
6168

69+
# scan a specific directory (Windows)
70+
bunkill --dir "C:\Users\YourName\Projects"
71+
6272
# preview only
6373
bunkill --dir ~/Projects --dry-run
6474

@@ -101,13 +111,14 @@ Search filters the already loaded list, so you can quickly narrow large result s
101111

102112
## Platform status
103113

104-
- macOS: tested
105-
- Linux: not tested yet
106-
- Windows: not tested yet
107-
108-
Linux and Windows may work, but they have not been validated in this repo yet.
114+
| Platform | Status |
115+
|---|---|
116+
| macOS | ✅ Tested |
117+
| Linux | ⚠️ Not tested yet |
118+
| Windows 10/11 | ✅ Tested (Windows Terminal recommended) |
109119

110-
Contributions for Linux and Windows testing or fixes are welcome.
120+
> **Windows note:** Interactive mode requires a terminal that supports ANSI escape codes and raw mode.
121+
> [Windows Terminal](https://aka.ms/terminal) works well. The legacy `cmd.exe` prompt is not supported.
111122
112123
## Performance
113124

src/cli.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { stat } from "node:fs/promises";
55
import { basename, join, resolve } from "node:path";
66
import { filesize } from "filesize";
77
import { APP_CONFIG } from "./config.ts";
8-
import { deleteModules, scan as scanEngine } from "./scanner.ts";
8+
import { deleteModules, normalizeProjectPath, scan as scanEngine } from "./scanner.ts";
99
import type { NodeModule, ScanOptions } from "./types.ts";
1010

1111
const LOGO = `
@@ -558,7 +558,7 @@ class BunKill {
558558
}
559559

560560
this.pendingUiMeta.add(module.path);
561-
const projectPath = module.path.replace(/\/node_modules$/, "");
561+
const projectPath = normalizeProjectPath(module.path);
562562

563563
try {
564564
const result = await Bun.$`git -C ${projectPath} status --short --branch`.quiet().nothrow();

src/scanner.ts

Lines changed: 58 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { readdir } from "node:fs/promises";
1+
import { readdir, rm } from "node:fs/promises";
22
import { basename, join } from "node:path";
33
import { APP_CONFIG, SCAN_PATHS } from "./config.ts";
44
import type { DeleteResult, NodeModule, ScanOptions, ScanResult } from "./types.ts";
@@ -32,31 +32,48 @@ class Semaphore {
3232
const PERMISSION_ERROR_CODES = new Set(SCAN_PATHS.permissionErrorCodes);
3333

3434
function shouldSkip(dirPath: string): boolean {
35+
const np = normalizeSep(dirPath);
3536
const isAllowedCache = SCAN_PATHS.allowCachePatterns.some(
3637
(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)),
3940
);
4041
if (isAllowedCache) return false;
41-
if (dirPath.includes(".npm/_npx")) return false;
42+
if (np.includes(".npm/_npx")) return false;
4243

4344
return SCAN_PATHS.systemSkipPatterns.some(
4445
(p) =>
45-
dirPath.includes(p) ||
46-
dirPath.toLowerCase().includes(p.toLowerCase()),
46+
np.includes(p) ||
47+
np.toLowerCase().includes(p.toLowerCase()),
4748
);
4849
}
4950

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+
5062
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}/`);
5266
}
5367

5468
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
5875
? ""
59-
: dirPath;
76+
: np;
6077

6178
if (!relativePath) {
6279
return false;
@@ -84,27 +101,29 @@ function createShouldSkipMatcher(options: ScanOptions): (dirPath: string) => boo
84101
return true;
85102
}
86103

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();
89108

90109
const isAllowedCache = SCAN_PATHS.allowCachePatterns.some(
91110
(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)),
94113
);
95-
if (isAllowedCache || dirPath.includes(".npm/_npx")) {
114+
if (isAllowedCache || np.includes(".npm/_npx")) {
96115
return false;
97116
}
98117

99118
return SCAN_PATHS.systemSkipPatterns.some((pattern) => {
100119
const matchesPattern =
101-
dirPath.includes(pattern) || lowerPath.includes(pattern.toLowerCase());
120+
np.includes(pattern) || lowerNp.includes(pattern.toLowerCase());
102121
if (!matchesPattern) {
103122
return false;
104123
}
105124

106125
const rootIncludesPattern =
107-
matchingRoot.includes(pattern) || lowerRoot.includes(pattern.toLowerCase());
126+
nr.includes(pattern) || lowerNr.includes(pattern.toLowerCase());
108127
return !rootIncludesPattern;
109128
});
110129
};
@@ -153,25 +172,27 @@ async function readPackageMetadata(projectPath: string): Promise<{
153172
}
154173

155174
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 */
166189
}
167-
} catch {
168-
/* ignore */
169190
}
170191

171192
let total = 0;
172193
try {
173194
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 })) {
175196
try {
176197
const s = await Bun.file(join(dirPath, file)).stat();
177198
total += s.size;
@@ -242,7 +263,10 @@ async function discoverNodeModulesWithFs(
242263
}
243264

244265
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)) {
246270
continue;
247271
}
248272
hits.push(fullPath);
@@ -325,7 +349,7 @@ export async function scan(options: ScanOptions): Promise<ScanResult> {
325349
let mod: NodeModule | null = null;
326350

327351
await metaSemaphore.acquire();
328-
const projectPath = nmPath.replace(/\/node_modules$/, "");
352+
const projectPath = normalizeProjectPath(nmPath, options.target);
329353
try {
330354
mod = await processModuleMeta(nmPath, projectPath);
331355
} finally {
@@ -423,16 +447,7 @@ export async function deleteModules(
423447

424448
for (const mod of modules) {
425449
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 });
436451
deleted++;
437452
freed += mod.size;
438453
deletedPaths.push(mod.path);

0 commit comments

Comments
 (0)