Skip to content

Commit 26e308d

Browse files
Merge pull request #7 from vindiarputra/main
fix: Windows compatibility for delete, size, and path separators
2 parents 91676da + 3c62ee2 commit 26e308d

9 files changed

Lines changed: 257 additions & 76 deletions

File tree

.github/workflows/ci.yml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
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
38+
run: bun run src/cli.ts --dir "${{ github.workspace }}" --dry-run --hide-errors

.github/workflows/publish.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,12 @@ jobs:
2121
run: bun install
2222

2323
- name: Run tests
24-
run: bun test
24+
run: bun run test
2525

2626
- name: Build package
2727
run: bun run build
2828

2929
- name: Publish to npm
3030
run: bun publish
3131
env:
32-
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
32+
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

README.md

Lines changed: 22 additions & 9 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,16 @@ 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
114+
| Platform | Status |
115+
|---|---|
116+
| macOS | ✅ Tested |
117+
| Linux | ✅ CI smoke tested |
118+
| Windows 10/11 | ✅ CI smoke tested (Windows Terminal recommended) |
107119

108-
Linux and Windows may work, but they have not been validated in this repo yet.
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.
109122
110-
Contributions for Linux and Windows testing or fixes are welcome.
123+
CI covers help output, type checking, builds, and dry-run scanning. Manual interactive terminal testing is still useful across terminals.
111124

112125
## Performance
113126

benchmark/three-way-benchmark.ts

Lines changed: 101 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* This is now JS/Bun-only.
77
*/
88

9-
import { join } from "node:path";
9+
import { join, resolve } from "node:path";
1010
import { scan } from "../src/scanner.ts";
1111

1212
const args = Bun.argv.slice(2);
@@ -34,10 +34,51 @@ function shouldSkip(p: string): boolean {
3434
);
3535
}
3636

37-
async function npkillScan(dir: string): Promise<number> {
37+
function shellQuote(value: string): string {
38+
return `'${value.replaceAll("'", "'\\''")}'`;
39+
}
40+
41+
/** Convert a Windows absolute path to a WSL /mnt/... path. */
42+
function toWslPath(winPath: string): string {
43+
const normalizedPath = resolve(winPath).replaceAll("\\", "/");
44+
const driveMatch = /^([A-Za-z]):\/(.*)$/.exec(normalizedPath);
45+
if (!driveMatch?.[1]) {
46+
return normalizedPath;
47+
}
48+
49+
const drive = driveMatch[1].toLowerCase();
50+
const rest = driveMatch[2] ?? "";
51+
return `/mnt/${drive}/${rest}`;
52+
}
53+
54+
async function hasWslNpkill(): Promise<boolean> {
55+
const probe = Bun.spawn({
56+
cmd: [
57+
"wsl",
58+
"bash",
59+
"-lc",
60+
"command -v script >/dev/null && command -v npkill >/dev/null",
61+
],
62+
stdout: "ignore",
63+
stderr: "ignore",
64+
});
65+
66+
return await probe.exited === 0;
67+
}
68+
69+
async function npkillScan(dir: string, useWsl = false): Promise<number> {
70+
if (useWsl && !(await hasWslNpkill())) {
71+
return -1;
72+
}
73+
74+
const wslCommand = `npkill -d ${shellQuote(toWslPath(dir))} -nu --hide-errors`;
75+
const scriptCmd = useWsl
76+
? ["wsl", "bash", "-lc", `script -q /dev/null -c ${shellQuote(wslCommand)} 2>/dev/null`]
77+
: ["script", "-q", "/dev/null", "npkill", "-d", dir, "-nu", "--hide-errors"];
78+
3879
try {
3980
const proc = Bun.spawn({
40-
cmd: ["script", "-q", "/dev/null", "npkill", "-d", dir, "-nu", "--hide-errors"],
81+
cmd: scriptCmd,
4182
stdout: "pipe",
4283
stderr: "pipe",
4384
stdin: "pipe",
@@ -61,6 +102,10 @@ async function npkillScan(dir: string): Promise<number> {
61102
clearTimeout(killTimer);
62103
try { proc.kill(); } catch {}
63104

105+
if (await proc.exited !== 0) {
106+
return -1;
107+
}
108+
64109
const raw = Buffer.concat(chunks).toString("utf8");
65110
const clean = raw.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "").replace(/\r/g, "");
66111

@@ -84,11 +129,24 @@ async function bunGlobScan(dir: string): Promise<number> {
84129
onlyFiles: false,
85130
followSymlinks: false,
86131
})) {
132+
const normalized = relative.replaceAll("\\", "/");
87133
const fullPath = join(dir, relative);
88-
if (fullPath.split("/node_modules").length > 2) continue;
89-
if (shouldSkip(fullPath)) continue;
90-
const depth = relative.split("/").filter(Boolean).length;
134+
const fullNormalized = fullPath.replaceAll("\\", "/");
135+
136+
const targetSegments = normalized.split("/")
137+
.filter((segment) => segment === "node_modules");
138+
if (targetSegments.length > 1) continue;
139+
if (shouldSkip(fullNormalized)) continue;
140+
141+
const depth = normalized.split("/").filter(Boolean).length;
91142
if (depth > MAX_DEPTH) continue;
143+
144+
const projectPath = join(fullPath, "..");
145+
const packageJson = await Bun.file(join(projectPath, "package.json"))
146+
.json()
147+
.catch(() => null);
148+
if (!packageJson) continue;
149+
92150
results.push(fullPath);
93151
}
94152

@@ -162,19 +220,20 @@ function fmtMs(ms: number): string {
162220
}
163221

164222
function printRow(r: BenchResult, baseline: number): void {
223+
// \r\x1b[2K clears any partial line left by WSL/npkill carriage returns
165224
if (!r.available) {
166-
console.log(` ${r.label.padEnd(24)} N/A (${r.note ?? "unavailable"})`);
225+
process.stdout.write(`\r\x1b[2K ${r.label.padEnd(24)} N/A (${r.note ?? "unavailable"})\n`);
167226
return;
168227
}
169228

170229
const speedup = r.avgMs === baseline
171230
? "baseline"
172231
: `${(baseline / r.avgMs).toFixed(1)}x faster`;
173232

174-
console.log(
175-
` ${r.label.padEnd(24)} avg=${fmtMs(r.avgMs).padStart(8)} ` +
233+
process.stdout.write(
234+
`\r\x1b[2K ${r.label.padEnd(24)} avg=${fmtMs(r.avgMs).padStart(8)} ` +
176235
`min=${fmtMs(r.minMs).padStart(8)} max=${fmtMs(r.maxMs).padStart(8)} ` +
177-
`found=${String(r.found).padStart(4)} ${speedup}`,
236+
`found=${String(r.found).padStart(4)} ${speedup}\n`,
178237
);
179238
}
180239

@@ -184,9 +243,29 @@ console.log("╚═════════════════════
184243
console.log(` Scan root : ${SCAN_DIR}`);
185244
console.log(` Runs : ${RUNS} (+ 1 warm-up)\n`);
186245

187-
const npkillPath = await Bun.$.nothrow()`which npkill`.text().then((s) => s.trim()).catch(() => "");
188-
189-
console.log(` npkill : ${npkillPath || "NOT FOUND (npm install -g npkill)"}`);
246+
const isWindows = process.platform === "win32";
247+
248+
// Detect npkill
249+
const npkillPath = isWindows
250+
? await Bun.$.nothrow()`where npkill`.text().then((s) => s.trim().split("\n")[0]?.trim() ?? "").catch(() => "")
251+
: await Bun.$.nothrow()`which npkill`.text().then((s) => s.trim()).catch(() => "");
252+
253+
const wslAvailable = isWindows
254+
? await Bun.$.nothrow()`where wsl`.text().then((s) => s.trim().length > 0).catch(() => false)
255+
: false;
256+
const wslNpkillAvailable = isWindows && wslAvailable
257+
? await hasWslNpkill()
258+
: false;
259+
260+
const npkillStatus = !npkillPath
261+
? "NOT FOUND (npm install -g npkill)"
262+
: isWindows && wslNpkillAvailable
263+
? `${npkillPath} (via WSL)`
264+
: isWindows
265+
? `${npkillPath} (skipped: WSL npkill/script unavailable)`
266+
: npkillPath;
267+
268+
console.log(` npkill : ${npkillStatus}`);
190269
console.log(` Bun.Glob : baseline glob walk`);
191270
console.log(` bunkill : current scanner.ts implementation\n`);
192271

@@ -197,9 +276,15 @@ const [globResult, bunkillResult] = await Promise.all([
197276
bench("bunkill current", bunKillScan, SCAN_DIR, RUNS),
198277
]);
199278

200-
const npkillResult = npkillPath
201-
? await bench("npkill", npkillScan, SCAN_DIR, 1)
202-
: { label: "npkill", avgMs: 0, minMs: 0, maxMs: 0, found: -1, available: false, note: "not installed" };
279+
// On Windows: run npkill via WSL if available; on Unix: run directly
280+
const canRunNpkill = npkillPath && (!isWindows || wslNpkillAvailable);
281+
const npkillResult = canRunNpkill
282+
? await bench("npkill", (d) => npkillScan(d, isWindows && wslAvailable), SCAN_DIR, 1)
283+
: {
284+
label: "npkill",
285+
avgMs: 0, minMs: 0, maxMs: 0, found: -1, available: false,
286+
note: !npkillPath ? "not installed" : "WSL npkill/script unavailable",
287+
};
203288

204289
const available = [npkillResult, globResult, bunkillResult].filter((r) => r.available);
205290

bun.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@
2222
},
2323
"devDependencies": {
2424
"@types/bun": "^1.3.10",
25-
"@types/node": "^24.5.2"
25+
"@types/node": "^24.5.2",
26+
"typescript": "^6.0.3"
2627
},
2728
"scripts": {
2829
"test": "bun run src/cli.ts --help",

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/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export const APP_CONFIG = {
1212
: 8;
1313
return Math.max(4, Math.min(16, cpuCount || 8));
1414
})(),
15+
defaultDeleteConcurrency: 8,
1516
} as const;
1617

1718
export const SCAN_PATHS = {

0 commit comments

Comments
 (0)