diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..caf2ad6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + name: Test on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install + + - name: Type check + run: bun run check + + - name: Build + run: bun run build + + - name: Smoke test - help + run: bun run src/cli.ts --help + + - name: Smoke test - dry-run (Unix) + if: runner.os != 'Windows' + run: bun run src/cli.ts --dir ${{ github.workspace }} --dry-run --hide-errors + + - name: Smoke test - dry-run (Windows) + if: runner.os == 'Windows' + run: bun run src/cli.ts --dir ${{ github.workspace }} --dry-run --hide-errors + shell: pwsh diff --git a/README.md b/README.md index 97134c1..b78054f 100644 --- a/README.md +++ b/README.md @@ -29,15 +29,22 @@ BunKill scans large directory trees, calculates folder sizes, and lets you delet ## Requirements -- Bun is required at runtime -- macOS is the only platform tested so far +- [Bun](https://bun.sh) runtime is required +- Supported platforms: **macOS**, **Linux**, **Windows 10/11** +- Windows: requires [Windows Terminal](https://aka.ms/terminal) for best interactive UI experience -Install Bun if needed: +**Install Bun:** +macOS / Linux: ```bash curl -fsSL https://bun.sh/install | bash ``` +Windows (PowerShell): +```powershell +powershell -c "irm bun.sh/install.ps1 | iex" +``` + ## Install ```bash @@ -56,9 +63,12 @@ bun install -g bunkill # interactive scan in current directory bunkill -# scan a specific directory +# scan a specific directory (macOS / Linux) bunkill --dir ~/Projects +# scan a specific directory (Windows) +bunkill --dir "C:\Users\YourName\Projects" + # preview only bunkill --dir ~/Projects --dry-run @@ -101,13 +111,14 @@ Search filters the already loaded list, so you can quickly narrow large result s ## Platform status -- macOS: tested -- Linux: not tested yet -- Windows: not tested yet - -Linux and Windows may work, but they have not been validated in this repo yet. +| Platform | Status | +|---|---| +| macOS | ✅ Tested | +| Linux | ⚠️ Not tested yet | +| Windows 10/11 | ✅ Tested (Windows Terminal recommended) | -Contributions for Linux and Windows testing or fixes are welcome. +> **Windows note:** Interactive mode requires a terminal that supports ANSI escape codes and raw mode. +> [Windows Terminal](https://aka.ms/terminal) works well. The legacy `cmd.exe` prompt is not supported. ## Performance diff --git a/benchmark/three-way-benchmark.ts b/benchmark/three-way-benchmark.ts index 05d2235..53286f3 100644 --- a/benchmark/three-way-benchmark.ts +++ b/benchmark/three-way-benchmark.ts @@ -34,10 +34,25 @@ function shouldSkip(p: string): boolean { ); } -async function npkillScan(dir: string): Promise { +/** Convert a Windows absolute path to a WSL /mnt/... path. */ +function toWslPath(winPath: string): string { + // e.g. C:\Users\foo → /mnt/c/Users/foo + return winPath + .replace(/^([A-Za-z]):\\/, (_, drive) => `/mnt/${drive.toLowerCase()}/`) + .replaceAll("\\", "/"); +} + +async function npkillScan(dir: string, useWsl = false): Promise { + // Use login shell so node/npkill are in PATH inside WSL. + // Only silence stderr (2>/dev/null) so stdout flows to proc.stdout for parsing. + // Terminal corruption from npkill's \r sequences is handled by printRow's \r\x1b[2K. + const scriptCmd = useWsl + ? ["wsl", "bash", "-lc", `script -q /dev/null -c 'npkill -d "${toWslPath(dir)}" -nu --hide-errors' 2>/dev/null`] + : ["script", "-q", "/dev/null", "npkill", "-d", dir, "-nu", "--hide-errors"]; + try { const proc = Bun.spawn({ - cmd: ["script", "-q", "/dev/null", "npkill", "-d", dir, "-nu", "--hide-errors"], + cmd: scriptCmd, stdout: "pipe", stderr: "pipe", stdin: "pipe", @@ -84,11 +99,23 @@ async function bunGlobScan(dir: string): Promise { onlyFiles: false, followSymlinks: false, })) { + // Normalize to forward slashes for consistent checks + const normalized = relative.replaceAll("\\", "/"); const fullPath = join(dir, relative); - if (fullPath.split("/node_modules").length > 2) continue; - if (shouldSkip(fullPath)) continue; - const depth = relative.split("/").filter(Boolean).length; + const fullNormalized = fullPath.replaceAll("\\", "/"); + + // Skip nested node_modules (same as bunkill) + if (fullNormalized.includes("/node_modules/")) continue; + if (shouldSkip(fullNormalized)) continue; + + const depth = normalized.split("/").filter(Boolean).length; if (depth > MAX_DEPTH) continue; + + // Require a package.json in the parent directory (same as bunkill) + const parentPath = fullPath.slice(0, fullPath.length - "/node_modules".length); + const pkgJson = Bun.file(join(parentPath, "package.json")); + if (!(await pkgJson.exists())) continue; + results.push(fullPath); } @@ -162,8 +189,9 @@ function fmtMs(ms: number): string { } function printRow(r: BenchResult, baseline: number): void { + // \r\x1b[2K clears any partial line left by WSL/npkill carriage returns if (!r.available) { - console.log(` ${r.label.padEnd(24)} N/A (${r.note ?? "unavailable"})`); + process.stdout.write(`\r\x1b[2K ${r.label.padEnd(24)} N/A (${r.note ?? "unavailable"})\n`); return; } @@ -171,10 +199,10 @@ function printRow(r: BenchResult, baseline: number): void { ? "baseline" : `${(baseline / r.avgMs).toFixed(1)}x faster`; - console.log( - ` ${r.label.padEnd(24)} avg=${fmtMs(r.avgMs).padStart(8)} ` + + process.stdout.write( + `\r\x1b[2K ${r.label.padEnd(24)} avg=${fmtMs(r.avgMs).padStart(8)} ` + `min=${fmtMs(r.minMs).padStart(8)} max=${fmtMs(r.maxMs).padStart(8)} ` + - `found=${String(r.found).padStart(4)} ${speedup}`, + `found=${String(r.found).padStart(4)} ${speedup}\n`, ); } @@ -184,9 +212,27 @@ console.log("╚═════════════════════ console.log(` Scan root : ${SCAN_DIR}`); console.log(` Runs : ${RUNS} (+ 1 warm-up)\n`); -const npkillPath = await Bun.$.nothrow()`which npkill`.text().then((s) => s.trim()).catch(() => ""); +const isWindows = process.platform === "win32"; + +// Detect npkill +const npkillPath = isWindows + ? await Bun.$.nothrow()`where npkill`.text().then((s) => s.trim().split("\n")[0]?.trim() ?? "").catch(() => "") + : await Bun.$.nothrow()`which npkill`.text().then((s) => s.trim()).catch(() => ""); + +// Detect WSL (Windows only) +const wslAvailable = isWindows + ? await Bun.$.nothrow()`where wsl`.text().then((s) => s.trim().length > 0).catch(() => false) + : false; -console.log(` npkill : ${npkillPath || "NOT FOUND (npm install -g npkill)"}`); +const npkillStatus = !npkillPath + ? "NOT FOUND (npm install -g npkill)" + : isWindows && wslAvailable + ? `${npkillPath} (via WSL)` + : isWindows + ? `${npkillPath} (skipped: WSL not found — install WSL to enable)` + : npkillPath; + +console.log(` npkill : ${npkillStatus}`); console.log(` Bun.Glob : baseline glob walk`); console.log(` bunkill : current scanner.ts implementation\n`); @@ -197,9 +243,15 @@ const [globResult, bunkillResult] = await Promise.all([ bench("bunkill current", bunKillScan, SCAN_DIR, RUNS), ]); -const npkillResult = npkillPath - ? await bench("npkill", npkillScan, SCAN_DIR, 1) - : { label: "npkill", avgMs: 0, minMs: 0, maxMs: 0, found: -1, available: false, note: "not installed" }; +// On Windows: run npkill via WSL if available; on Unix: run directly +const canRunNpkill = npkillPath && (!isWindows || wslAvailable); +const npkillResult = canRunNpkill + ? await bench("npkill", (d) => npkillScan(d, isWindows && wslAvailable), SCAN_DIR, 1) + : { + label: "npkill", + avgMs: 0, minMs: 0, maxMs: 0, found: -1, available: false, + note: !npkillPath ? "not installed" : "WSL not found (install WSL to enable)", + }; const available = [npkillResult, globResult, bunkillResult].filter((r) => r.available); diff --git a/src/cli.ts b/src/cli.ts index 85de994..3b45c22 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,7 +5,7 @@ import { stat } from "node:fs/promises"; import { basename, join, resolve } from "node:path"; import { filesize } from "filesize"; import { APP_CONFIG } from "./config.ts"; -import { deleteModules, scan as scanEngine } from "./scanner.ts"; +import { deleteModules, normalizeProjectPath, scan as scanEngine } from "./scanner.ts"; import type { NodeModule, ScanOptions } from "./types.ts"; const LOGO = ` @@ -558,7 +558,7 @@ class BunKill { } this.pendingUiMeta.add(module.path); - const projectPath = module.path.replace(/\/node_modules$/, ""); + const projectPath = normalizeProjectPath(module.path); try { const result = await Bun.$`git -C ${projectPath} status --short --branch`.quiet().nothrow(); diff --git a/src/scanner.ts b/src/scanner.ts index 7f21a59..64cbe88 100644 --- a/src/scanner.ts +++ b/src/scanner.ts @@ -1,4 +1,4 @@ -import { readdir } from "node:fs/promises"; +import { readdir, rm } from "node:fs/promises"; import { basename, join } from "node:path"; import { APP_CONFIG, SCAN_PATHS } from "./config.ts"; import type { DeleteResult, NodeModule, ScanOptions, ScanResult } from "./types.ts"; @@ -32,31 +32,49 @@ class Semaphore { const PERMISSION_ERROR_CODES = new Set(SCAN_PATHS.permissionErrorCodes); function shouldSkip(dirPath: string): boolean { + const np = normalizeSep(dirPath); const isAllowedCache = SCAN_PATHS.allowCachePatterns.some( (p) => - dirPath.includes(p) && - !SCAN_PATHS.skipCacheSubdirs.some((skip) => dirPath.includes(skip)), + np.includes(p) && + !SCAN_PATHS.skipCacheSubdirs.some((skip) => np.includes(skip)), ); if (isAllowedCache) return false; - if (dirPath.includes(".npm/_npx")) return false; + if (np.includes(".npm/_npx")) return false; return SCAN_PATHS.systemSkipPatterns.some( (p) => - dirPath.includes(p) || - dirPath.toLowerCase().includes(p.toLowerCase()), + np.includes(p) || + np.toLowerCase().includes(p.toLowerCase()), ); } +const IS_WINDOWS = process.platform === "win32"; + +function normalizeSep(p: string): string { + return IS_WINDOWS ? p.replaceAll("\\", "/") : p; +} + +export function normalizeProjectPath(nmPath: string, target = "node_modules"): string { + const normalized = normalizeSep(nmPath); + const suffix = "/" + target.replace(/\/+$/, ""); + return normalized.endsWith(suffix) ? normalized.slice(0, -suffix.length) : normalized; +} + function isWithinRoot(path: string, root: string): boolean { - return path === root || path.startsWith(`${root}/`); + const np = normalizeSep(path); + const nr = normalizeSep(root); + return np === nr || np.startsWith(`${nr}/`); } function hasHiddenPathSegment(dirPath: string, root: string): boolean { - const relativePath = dirPath.startsWith(`${root}/`) - ? dirPath.slice(root.length + 1) - : dirPath === root + const np = normalizeSep(dirPath); + const nr = normalizeSep(root); + + const relativePath = np.startsWith(`${nr}/`) + ? np.slice(nr.length + 1) + : np === nr ? "" - : dirPath; + : np; if (!relativePath) { return false; @@ -84,27 +102,29 @@ function createShouldSkipMatcher(options: ScanOptions): (dirPath: string) => boo return true; } - const lowerRoot = matchingRoot.toLowerCase(); - const lowerPath = dirPath.toLowerCase(); + const np = normalizeSep(dirPath); + const nr = normalizeSep(matchingRoot); + const lowerNp = np.toLowerCase(); + const lowerNr = nr.toLowerCase(); const isAllowedCache = SCAN_PATHS.allowCachePatterns.some( (pattern) => - dirPath.includes(pattern) && - !SCAN_PATHS.skipCacheSubdirs.some((skip) => dirPath.includes(skip)), + np.includes(pattern) && + !SCAN_PATHS.skipCacheSubdirs.some((skip) => np.includes(skip)), ); - if (isAllowedCache || dirPath.includes(".npm/_npx")) { + if (isAllowedCache || np.includes(".npm/_npx")) { return false; } return SCAN_PATHS.systemSkipPatterns.some((pattern) => { const matchesPattern = - dirPath.includes(pattern) || lowerPath.includes(pattern.toLowerCase()); + np.includes(pattern) || lowerNp.includes(pattern.toLowerCase()); if (!matchesPattern) { return false; } const rootIncludesPattern = - matchingRoot.includes(pattern) || lowerRoot.includes(pattern.toLowerCase()); + nr.includes(pattern) || lowerNr.includes(pattern.toLowerCase()); return !rootIncludesPattern; }); }; @@ -153,25 +173,27 @@ async function readPackageMetadata(projectPath: string): Promise<{ } async function getDirectorySize(dirPath: string): Promise { - try { - const proc = Bun.spawn({ - cmd: ["du", "-sk", dirPath], - stdout: "pipe", - stderr: "ignore", - }); - const output = await (new Response(proc.stdout) as globalThis.Response).text(); - if (await proc.exited === 0) { - const match = output.match(/^(\d+)/); - if (match?.[1]) return parseInt(match[1], 10) * 1024; + if (!IS_WINDOWS) { + try { + const proc = Bun.spawn({ + cmd: ["du", "-sk", dirPath], + stdout: "pipe", + stderr: "ignore", + }); + const output = await (new Response(proc.stdout) as globalThis.Response).text(); + if (await proc.exited === 0) { + const match = output.match(/^(\d+)/); + if (match?.[1]) return parseInt(match[1], 10) * 1024; + } + } catch { + /* ignore */ } - } catch { - /* ignore */ } let total = 0; try { const glob = new Bun.Glob("**/*"); - for await (const file of glob.scan({ cwd: dirPath, onlyFiles: true })) { + for await (const file of glob.scan({ cwd: dirPath, onlyFiles: true, dot: true })) { try { const s = await Bun.file(join(dirPath, file)).stat(); total += s.size; @@ -242,7 +264,10 @@ async function discoverNodeModulesWithFs( } if (entry.name === options.target) { - if (fullPath.split("/node_modules").length > 2) { + // Detect nesting: check if the normalized path contains /target/ as a segment + const normalizedFull = normalizeSep(fullPath); + const segmentMarker = "/" + options.target + "/"; + if (normalizedFull.includes(segmentMarker)) { continue; } hits.push(fullPath); @@ -325,7 +350,7 @@ export async function scan(options: ScanOptions): Promise { let mod: NodeModule | null = null; await metaSemaphore.acquire(); - const projectPath = nmPath.replace(/\/node_modules$/, ""); + const projectPath = normalizeProjectPath(nmPath, options.target); try { mod = await processModuleMeta(nmPath, projectPath); } finally { @@ -423,16 +448,7 @@ export async function deleteModules( for (const mod of modules) { try { - const proc = Bun.spawn({ - cmd: ["rm", "-rf", mod.path], - stdout: "ignore", - stderr: "ignore", - }); - const ok = await proc.exited === 0; - if (!ok) { - failedPaths.push(mod.path); - continue; - } + await rm(mod.path, { recursive: true, force: true }); deleted++; freed += mod.size; deletedPaths.push(mod.path);