Skip to content

Commit 61e3bfb

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

4 files changed

Lines changed: 126 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: 59 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,49 @@ 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+
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+
5063
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}/`);
5267
}
5368

5469
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
5876
? ""
59-
: dirPath;
77+
: np;
6078

6179
if (!relativePath) {
6280
return false;
@@ -84,27 +102,29 @@ function createShouldSkipMatcher(options: ScanOptions): (dirPath: string) => boo
84102
return true;
85103
}
86104

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

90110
const isAllowedCache = SCAN_PATHS.allowCachePatterns.some(
91111
(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)),
94114
);
95-
if (isAllowedCache || dirPath.includes(".npm/_npx")) {
115+
if (isAllowedCache || np.includes(".npm/_npx")) {
96116
return false;
97117
}
98118

99119
return SCAN_PATHS.systemSkipPatterns.some((pattern) => {
100120
const matchesPattern =
101-
dirPath.includes(pattern) || lowerPath.includes(pattern.toLowerCase());
121+
np.includes(pattern) || lowerNp.includes(pattern.toLowerCase());
102122
if (!matchesPattern) {
103123
return false;
104124
}
105125

106126
const rootIncludesPattern =
107-
matchingRoot.includes(pattern) || lowerRoot.includes(pattern.toLowerCase());
127+
nr.includes(pattern) || lowerNr.includes(pattern.toLowerCase());
108128
return !rootIncludesPattern;
109129
});
110130
};
@@ -153,25 +173,27 @@ async function readPackageMetadata(projectPath: string): Promise<{
153173
}
154174

155175
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 */
166190
}
167-
} catch {
168-
/* ignore */
169191
}
170192

171193
let total = 0;
172194
try {
173195
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 })) {
175197
try {
176198
const s = await Bun.file(join(dirPath, file)).stat();
177199
total += s.size;
@@ -242,7 +264,10 @@ async function discoverNodeModulesWithFs(
242264
}
243265

244266
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)) {
246271
continue;
247272
}
248273
hits.push(fullPath);
@@ -325,7 +350,7 @@ export async function scan(options: ScanOptions): Promise<ScanResult> {
325350
let mod: NodeModule | null = null;
326351

327352
await metaSemaphore.acquire();
328-
const projectPath = nmPath.replace(/\/node_modules$/, "");
353+
const projectPath = normalizeProjectPath(nmPath, options.target);
329354
try {
330355
mod = await processModuleMeta(nmPath, projectPath);
331356
} finally {
@@ -423,16 +448,7 @@ export async function deleteModules(
423448

424449
for (const mod of modules) {
425450
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 });
436452
deleted++;
437453
freed += mod.size;
438454
deletedPaths.push(mod.path);

0 commit comments

Comments
 (0)