Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI uses bun-version: latest, which can make builds non-deterministic and introduce sudden breakages when Bun releases. Consider pinning to a specific Bun version (or at least the minimum supported in package.json engines) and optionally adding a separate scheduled job for latest if you want early warning.

Suggested change
bun-version: latest
bun-version: '1.0.0'

Copilot uses AI. Check for mistakes.

- 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
31 changes: 21 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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) |
Comment on lines +114 to +118
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Align Linux platform status with the new CI matrix.

Line 117 currently says Linux is “Not tested yet”, but this PR adds Linux CI runs. Please update wording to avoid contradictory platform claims.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` around lines 114 - 118, Update the Linux status row in the README
table that currently reads "Linux | ⚠️ Not tested yet" to reflect the new CI
coverage introduced by this PR; replace that cell with a concise phrase such as
"✅ Tested (CI)" or "✅ Tested (CI runs)" so the Linux entry matches the CI matrix
and avoids contradiction with the added CI runs.

Comment on lines +114 to +118
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

README now claims Linux is "⚠️ Not tested yet", but this PR adds a CI job that runs typecheck/build and CLI smoke tests on ubuntu-latest. Either update the table to reflect that Linux is tested (at least via CI smoke tests) or adjust the workflow/wording so they’re consistent.

Copilot uses AI. Check for mistakes.

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

Expand Down
80 changes: 66 additions & 14 deletions benchmark/three-way-benchmark.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,25 @@ function shouldSkip(p: string): boolean {
);
}

async function npkillScan(dir: string): Promise<number> {
/** 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("\\", "/");
Comment on lines +37 to +42
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's see the actual code around lines 37-50 in the file
cat -n benchmark/three-way-benchmark.ts | head -60 | tail -35

Repository: codingstark-dev/bunkill

Length of output: 1591


🏁 Script executed:

# Search for where toWslPath is called and how dir parameter is passed
rg "toWslPath|npkillScan" benchmark/three-way-benchmark.ts -A 2 -B 2

Repository: codingstark-dev/bunkill

Length of output: 992


🏁 Script executed:

# Find where SCAN_DIR is defined
rg "SCAN_DIR|--dir" benchmark/three-way-benchmark.ts -B 2 -A 2

Repository: codingstark-dev/bunkill

Length of output: 862


🏁 Script executed:

# Verify the path resolution behavior by testing the proposed fix logic
python3 << 'EOF'
import re

# Current implementation
def toWslPath_current(winPath: str) -> str:
    result = winPath
    # Regex only matches backslashes
    result = re.sub(r'^([A-Za-z]):\\', lambda m: f"/mnt/{m.group(1).lower()}/", result)
    result = result.replace("\\", "/")
    return result

# Proposed implementation (simulated on Unix)
def toWslPath_proposed(winPath: str) -> str:
    # Simulate resolve() by converting mixed slashes to forward slashes
    normalized = winPath.replace("\\", "/")
    # Now match forward slash pattern
    result = re.sub(r'^([A-Za-z]):/', lambda m: f"/mnt/{m.group(1).lower()}/", normalized)
    return result

# Test cases
test_cases = [
    ".",
    "./repo",
    "C:\\Users\\foo",
    "C:/Users/foo",
    "C:\\Users\\foo's\\bar",
]

print("Testing path conversions:")
print("-" * 60)
for case in test_cases:
    current = toWslPath_current(case)
    proposed = toWslPath_proposed(case)
    print(f"Input: {case!r}")
    print(f"  Current:  {current!r}")
    print(f"  Proposed: {proposed!r}")
    print()
EOF

Repository: codingstark-dev/bunkill

Length of output: 530


🏁 Script executed:

# Also verify the shell escaping issue with a simple test
python3 << 'EOF'
def escapeForSingleQuotedShell(value: str) -> str:
    return value.replace("'", "'\"'\"'")

# Test with a problematic path
path_with_quote = "/mnt/c/User's Files"
escaped = escapeForSingleQuotedShell(path_with_quote)

print("Shell escaping test:")
print(f"Original path: {path_with_quote!r}")
print(f"Escaped path:  {escaped!r}")
print()
print("Bash command construction:")
print(f"Unescaped: bash -lc 'npkill -d \"{path_with_quote}\" -nu'")
print(f"Escaped:   bash -lc 'npkill -d \"{escaped}\" -nu'")
EOF

Repository: codingstark-dev/bunkill

Length of output: 316


Resolve relative paths and escape shell metacharacters in WSL path conversion.

The toWslPath() function has three bugs:

  1. Relative paths (e.g., --dir .) are not resolved to absolute paths, so WSL scans the wrong directory
  2. Forward-slash Windows paths (e.g., --dir C:/repo) are not converted to /mnt/ form due to the backslash-only regex
  3. Paths containing single quotes break the bash -lc string interpolation

Use resolve() to normalize the input, update the regex to handle forward slashes, and shell-escape the final WSL path before building the command.

Fix
-import { join } from "node:path";
+import { join, resolve } from "node:path";
+
+function escapeForSingleQuotedShell(value: string): string {
+  return value.replaceAll("'", `'"'"'`);
+}
 
 /** 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("\\", "/");
+  const normalized = resolve(winPath).replaceAll("\\", "/");
+  return normalized.replace(/^([A-Za-z]):\//, (_, drive) => `/mnt/${drive.toLowerCase()}/`);
 }
 
 async function npkillScan(dir: string, useWsl = false): Promise<number> {
   const scriptCmd = useWsl
-    ? ["wsl", "bash", "-lc", `script -q /dev/null -c 'npkill -d "${toWslPath(dir)}" -nu --hide-errors' 2>/dev/null`]
+    ? ["wsl", "bash", "-lc", `script -q /dev/null -c 'npkill -d "${escapeForSingleQuotedShell(toWslPath(dir))}" -nu --hide-errors' 2>/dev/null`]
     : ["script", "-q", "/dev/null", "npkill", "-d", dir, "-nu", "--hide-errors"];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@benchmark/three-way-benchmark.ts` around lines 37 - 42, The toWslPath
function must resolve relative paths to absolute ones, handle both back- and
forward-slash Windows drive prefixes, and shell-escape single quotes before
returning; fix it by first calling path.resolve(winPath) to normalize and
resolve relative inputs, update the drive-regex in toWslPath to accept either
backslash or forward slash after the drive letter (e.g., /^([A-Za-z]):[\\/]/),
convert Windows separators to POSIX with replaceAll("\\", "/") (or equivalent)
so C:/... becomes /mnt/c/..., and finally escape single quotes in the produced
WSL path (replace ' with '\'' or the equivalent safe shell-escaping) so the path
is safe to interpolate into the bash -lc command. Ensure changes are made inside
the toWslPath function and that callers (where the command is built with bash
-lc) receive the escaped WSL path.

}

async function npkillScan(dir: string, useWsl = false): Promise<number> {
// 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`]
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When useWsl is true, the bash -lc command string interpolates dir into a shell script (... "${toWslPath(dir)}" ...). If --dir contains characters like ", $, backticks, or ', this can break quoting and potentially lead to shell injection or unexpected behavior. Prefer passing the path as a positional parameter to bash -lc (e.g. bash -lc '... "$1" ...' -- <path>) or otherwise escaping the value safely.

Suggested change
? ["wsl", "bash", "-lc", `script -q /dev/null -c 'npkill -d "${toWslPath(dir)}" -nu --hide-errors' 2>/dev/null`]
? [
"wsl",
"bash",
"-lc",
'script -q /dev/null -c "npkill -d \\"$1\\" -nu --hide-errors" 2>/dev/null',
"--",
toWslPath(dir),
]

Copilot uses AI. Check for mistakes.
: ["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,
Comment on lines +45 to +55
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cd benchmark && wc -l three-way-benchmark.ts

Repository: codingstark-dev/bunkill

Length of output: 93


🏁 Script executed:

cd benchmark && sed -n '40,90p' three-way-benchmark.ts

Repository: codingstark-dev/bunkill

Length of output: 1697


🏁 Script executed:

cd benchmark && sed -n '210,260p' three-way-benchmark.ts

Repository: codingstark-dev/bunkill

Length of output: 2058


🏁 Script executed:

cd benchmark && sed -n '75,95p' three-way-benchmark.ts

Repository: codingstark-dev/bunkill

Length of output: 682


🏁 Script executed:

cd benchmark && grep -n "exitCode\|exitStatus\|exit\|proc\." three-way-benchmark.ts | head -20

Repository: codingstark-dev/bunkill

Length of output: 381


🏁 Script executed:

web_search
Bun.spawn exit code status checking

Repository: codingstark-dev/bunkill

Length of output: 165


🌐 Web query:

Bun.spawn exit code checking proc.exited

💡 Result:

To check the exit code of a process spawned with Bun.spawn, use the proc.exited Promise and the proc.exitCode property on the returned Subprocess object. Key methods: 1. Await proc.exited to wait for the process to exit, which resolves to the exit code: const proc = Bun.spawn(["bun", "--version"]); const exitCode = await proc.exited; // e.g. 0 for success console.log(exitCode); 2. After awaiting exited (or if already exited), read proc.exitCode synchronously (null if not exited): await proc.exited; console.log(proc.exitCode); // number | null 3. Use the onExit option in spawn options for a callback: const proc = Bun.spawn(["echo", "hello"], { onExit(subprocess, exitCode, signalCode, error) { console.log(Exited with code ${exitCode}); }, }); proc.exited is a Promise that resolves to the exit code when the process exits. Bun waits for all child processes to exit by default unless unref is called. This matches Node.js child_process behavior but uses ReadableStreams for stdio.

Citations:


🏁 Script executed:

cd benchmark && rg -A 5 -B 5 "proc\.exited" three-way-benchmark.ts

Repository: codingstark-dev/bunkill

Length of output: 420


🏁 Script executed:

cd benchmark && grep -n "available" three-way-benchmark.ts | tail -10

Repository: codingstark-dev/bunkill

Length of output: 706


Check WSL tool availability before running benchmarks and inspect process exit codes.

where npkill and where wsl only confirm the Windows shim and WSL binary exist—they do not prove npkill or script are available inside the WSL environment. The code runs wsl bash -lc "script... npkill..." at line 49, but if either tool is missing in WSL, the process fails silently.

Additionally, Promise.race([collectPromise, proc.exited]) at line 75 awaits the exit but never inspects proc.exitCode. A failed command with no output returns found: 0 indistinguishably from a successful scan with zero packages, allowing the benchmark to appear available and successful when the underlying tools are unavailable.

Probe inside WSL before enabling npkill (e.g., wsl bash -lc "command -v script && command -v npkill"), or check proc.exitCode after line 75 and return -1 if non-zero before parsing.

Also applies to: 218–224, 246–254, 79–87

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@benchmark/three-way-benchmark.ts` around lines 45 - 55, npkillScan currently
assumes npkill/script exist inside WSL and treats any no-output run as success;
fix by probing the WSL environment for required binaries before running and by
checking the process exit code after the Promise.race. Specifically, before
constructing or invoking the wsl command in npkillScan, run a quick probe like
`wsl bash -lc "command -v script >/dev/null && command -v npkill >/dev/null"`
(or equivalent spawn) and bail out (return -1) if the probe fails; after
awaiting Promise.race([collectPromise, proc.exited]) inspect proc.exitCode (or
proc.success) and return -1 / throw if non-zero before parsing stdout. Apply the
same pre-check + exit-code inspection pattern to the other similar call sites
noted (the sections around lines 79–87, 218–224, 246–254) so missing tools in
WSL are detected rather than silently producing zero results.

stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
Expand Down Expand Up @@ -84,11 +99,23 @@ async function bunGlobScan(dir: string): Promise<number> {
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;
Comment on lines +114 to +117
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parentPath is derived by slicing with the hard-coded string length of "/node_modules". On Windows fullPath uses backslashes (e.g. ...\node_modules), so this slice removes the wrong number of characters and can produce an invalid parent directory (breaking the package.json existence check). Prefer using path.dirname(fullPath) (or compute based on the actual separator/normalized string) instead of string-length slicing.

Copilot uses AI. Check for mistakes.

results.push(fullPath);
}

Expand Down Expand Up @@ -162,19 +189,20 @@ 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;
}

const speedup = r.avgMs === baseline
? "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`,
);
}

Expand All @@ -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`);

Expand All @@ -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);

Expand Down
4 changes: 2 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `
Expand Down Expand Up @@ -558,7 +558,7 @@ class BunKill {
}

this.pendingUiMeta.add(module.path);
const projectPath = module.path.replace(/\/node_modules$/, "");
const projectPath = normalizeProjectPath(module.path);
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

normalizeProjectPath(module.path) uses the default target (node_modules). If the user runs the CLI with --target <name>, module.path will end with that custom target, and this call won’t strip it—so git -C will run inside the target directory instead of the project root (often causing git status to fail / mark as non-repo). Consider persisting the scan target on the BunKill instance and passing it here (or storing the derived project root on NodeModule).

Suggested change
const projectPath = normalizeProjectPath(module.path);
const projectPath =
(module as any).projectRoot ??
normalizeProjectPath(module.path);

Copilot uses AI. Check for mistakes.

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