@hicoders/devkit 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 hicoders
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,87 @@
1
+ # devkit
2
+
3
+ **Your terminal toolbelt — the small, annoying dev chores, fixed in a keystroke.**
4
+
5
+ `devkit` is a set of fast, keyboard-driven terminal tools for everyday development.
6
+ One hub, clean UI, mouse-friendly, no config to get started.
7
+
8
+ ```sh
9
+ bun install -g @hicoders/devkit
10
+ ```
11
+
12
+ Then just run `devkit` — or jump straight to a tool by name.
13
+
14
+ ---
15
+
16
+ ## What you get
17
+
18
+ ### 🔪 Kill whatever's hogging a port
19
+
20
+ That dreaded `Error: port 3000 is already in use`? Gone.
21
+
22
+ ```sh
23
+ killport 3000 # kill whatever is on port 3000
24
+ killport 3000 8080 # several at once
25
+ killport # not sure what's running? open the picker
26
+ ```
27
+
28
+ The picker shows what's **actually** listening — grouped into your **apps** and
29
+ **services**, with real project names (not just "node"), auto-refreshing live.
30
+ Select with the keyboard or mouse, hit Enter, done.
31
+
32
+ > No more `netstat | findstr` → copy PID → `taskkill /F /PID …`. One screen, one keystroke.
33
+
34
+ ### 🚀 Start your projects with one key
35
+
36
+ Stop `cd`-ing into folders and remembering which script to run.
37
+
38
+ ```sh
39
+ launch # pick a project, press Enter
40
+ launch my-app # or start it straight away
41
+ ```
42
+
43
+ Point `launch` at the folders where your projects live and it finds them all
44
+ automatically — reading `package.json`, `go.mod`, `Cargo.toml`, and
45
+ `pyproject.toml`. Press **Enter** to start a project's default command(s)
46
+ (backend **and** frontend together, streaming logs); Ctrl-C stops everything.
47
+
48
+ - **Pin** your go-to projects to the top.
49
+ - **Reorder** the list however you like, or sort by what you ran most recently.
50
+ - Press **`a`** to launch a different combo of scripts for a one-off run — it even
51
+ remembers your last choice.
52
+ - Add projects by auto-detecting a folder, or by hand.
53
+
54
+ ### 🧰 One hub for all of it
55
+
56
+ ```sh
57
+ devkit
58
+ ```
59
+
60
+ A single menu lists every tool. Pick one, use it, and you're back at the menu when
61
+ it exits. Filter with `/`, navigate with arrows or `j`/`k`, press `h` anytime for
62
+ help.
63
+
64
+ ---
65
+
66
+ ## Nice touches
67
+
68
+ - ⌨️ **Keyboard-first**, but the **mouse works too** — click, hover, scroll, double-click.
69
+ - 🎨 **Themes** — press `t` to cycle; your choice sticks across every tool.
70
+ - 🧠 **Remembers your setup** — pins, order, and preferences persist automatically.
71
+ - ⚡ **Instant** — no build step, snappy native-feeling UI.
72
+
73
+ ## Try it without installing
74
+
75
+ ```sh
76
+ bunx @hicoders/devkit # the hub
77
+ bunx -p @hicoders/devkit killport 3000
78
+ ```
79
+
80
+ ## Requirements
81
+
82
+ devkit runs on **[Bun](https://bun.com)** (v1.2+). Install Bun first, then install
83
+ devkit — that's the only prerequisite.
84
+
85
+ ## License
86
+
87
+ [MIT](./LICENSE) © hicoders
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@hicoders/devkit",
3
+ "version": "1.0.0",
4
+ "description": "A terminal toolbelt of small, daily-use developer tools (killport, launch, ...) with a unified TUI hub.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "hicoders (https://www.npmjs.com/~hicoders)",
8
+ "keywords": [
9
+ "cli",
10
+ "tui",
11
+ "terminal",
12
+ "toolbelt",
13
+ "developer-tools",
14
+ "killport",
15
+ "launcher",
16
+ "opentui",
17
+ "bun"
18
+ ],
19
+ "bin": {
20
+ "devkit": "pkg/tui/devkit.tsx",
21
+ "killport": "pkg/tui/killport.tsx",
22
+ "launch": "pkg/tui/launch.tsx"
23
+ },
24
+ "files": [
25
+ "pkg",
26
+ "tsconfig.json",
27
+ "README.md",
28
+ "LICENSE"
29
+ ],
30
+ "engines": {
31
+ "bun": ">=1.2.0"
32
+ },
33
+ "scripts": {
34
+ "devkit": "bun pkg/tui/devkit.tsx",
35
+ "launch": "bun pkg/tui/launch.tsx",
36
+ "killport": "bun pkg/tui/killport.tsx",
37
+ "typecheck": "tsc --noEmit",
38
+ "prepublishOnly": "tsc --noEmit"
39
+ },
40
+ "devDependencies": {
41
+ "@types/bun": "latest",
42
+ "@types/react": "^19.2.17"
43
+ },
44
+ "dependencies": {
45
+ "@opentui/core": "0.0.0-20260616-38ae4bd9",
46
+ "@opentui/react": "0.0.0-20260616-38ae4bd9",
47
+ "react": "^19.2.7"
48
+ },
49
+ "publishConfig": {
50
+ "access": "public"
51
+ }
52
+ }
@@ -0,0 +1,206 @@
1
+ // appname — resolve a friendly app name for a process by reading its working
2
+ // directory and walking up to the nearest project file.
3
+ //
4
+ // "node"/"bun"/"python" tell you the runtime, not which app. The app's project
5
+ // dir is the process's current directory (dev servers are launched from the
6
+ // project), so we read the cwd and look upward for a project manifest:
7
+ // package.json (name) · Cargo.toml · pyproject.toml · go.mod
8
+ //
9
+ // Getting another process's cwd is OS-specific:
10
+ // - Windows (x64): read it from the process PEB via bun:ffi
11
+ // - Linux: readlink /proc/<pid>/cwd
12
+ // - macOS: lsof -a -p <pid> -d cwd
13
+ // For processes we can't inspect (elevated / other user) this returns null and
14
+ // the caller just shows the runtime name. Results are cached per pid per session.
15
+
16
+ import { dlopen, FFIType, ptr } from "bun:ffi";
17
+ import { basename, dirname } from "node:path";
18
+ import { readlinkSync } from "node:fs";
19
+ import { tomlName } from "./manifest";
20
+
21
+ // ---- native plumbing (lazy + guarded so importing never throws) ----
22
+
23
+ function initNative() {
24
+ const k32 = dlopen("kernel32.dll", {
25
+ OpenProcess: { args: [FFIType.u32, FFIType.i32, FFIType.u32], returns: FFIType.ptr },
26
+ ReadProcessMemory: {
27
+ args: [FFIType.ptr, FFIType.u64, FFIType.ptr, FFIType.u64, FFIType.ptr],
28
+ returns: FFIType.i32,
29
+ },
30
+ CloseHandle: { args: [FFIType.ptr], returns: FFIType.i32 },
31
+ });
32
+ const ntdll = dlopen("ntdll.dll", {
33
+ NtQueryInformationProcess: {
34
+ args: [FFIType.ptr, FFIType.i32, FFIType.ptr, FFIType.u32, FFIType.ptr],
35
+ returns: FFIType.i32,
36
+ },
37
+ });
38
+ return {
39
+ OpenProcess: k32.symbols.OpenProcess,
40
+ ReadProcessMemory: k32.symbols.ReadProcessMemory,
41
+ CloseHandle: k32.symbols.CloseHandle,
42
+ NtQueryInformationProcess: ntdll.symbols.NtQueryInformationProcess,
43
+ };
44
+ }
45
+
46
+ let nativeInit = false;
47
+ let native: ReturnType<typeof initNative> | null = null;
48
+
49
+ function getNative() {
50
+ if (nativeInit) return native;
51
+ nativeInit = true;
52
+ if (process.platform !== "win32" || process.arch !== "x64") return (native = null);
53
+ try {
54
+ native = initNative();
55
+ } catch {
56
+ native = null;
57
+ }
58
+ return native;
59
+ }
60
+
61
+ const PROCESS_QUERY_INFORMATION = 0x0400;
62
+ const PROCESS_VM_READ = 0x0010;
63
+
64
+ // PEB walk offsets (x64): PROCESS_BASIC_INFORMATION.PebBaseAddress @ 0x08,
65
+ // PEB.ProcessParameters @ 0x20, RTL_USER_PROCESS_PARAMETERS.CurrentDirectory
66
+ // (a UNICODE_STRING: Length @ 0x00, Buffer @ 0x08) @ 0x38.
67
+ function readCwdWindows(pid: number): string | null {
68
+ const n = getNative();
69
+ if (!n) return null;
70
+ const h = n.OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, 0, pid);
71
+ if (!h) return null;
72
+ try {
73
+ const scratch = new Uint8Array(8);
74
+ const rpm = (addr: bigint, size: number): Uint8Array | null => {
75
+ const out = new Uint8Array(size);
76
+ const ok = n.ReadProcessMemory(h, addr, ptr(out), BigInt(size), ptr(scratch));
77
+ return ok ? out : null;
78
+ };
79
+ const u64 = (b: Uint8Array, off = 0) => new DataView(b.buffer).getBigUint64(off, true);
80
+
81
+ const pbi = new Uint8Array(48);
82
+ if (n.NtQueryInformationProcess(h, 0, ptr(pbi), 48, ptr(scratch)) !== 0) return null;
83
+ const ppBuf = rpm(u64(pbi, 8) + 0x20n, 8);
84
+ if (!ppBuf) return null;
85
+ const cur = rpm(u64(ppBuf) + 0x38n, 16);
86
+ if (!cur) return null;
87
+ const len = new DataView(cur.buffer).getUint16(0, true);
88
+ if (!len) return null;
89
+ const strBuf = rpm(u64(cur, 8), len);
90
+ if (!strBuf) return null;
91
+ const cwd = Buffer.from(strBuf).toString("utf16le").replace(/\\+$/, "");
92
+ return cwd || null;
93
+ } catch {
94
+ return null;
95
+ } finally {
96
+ n.CloseHandle(h);
97
+ }
98
+ }
99
+
100
+ function readCwdLinux(pid: number): string | null {
101
+ try {
102
+ return readlinkSync(`/proc/${pid}/cwd`) || null;
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+
108
+ async function readCwdDarwin(pid: number): Promise<string | null> {
109
+ try {
110
+ const proc = Bun.spawn(["lsof", "-a", "-p", String(pid), "-d", "cwd", "-Fn"], {
111
+ stdout: "pipe",
112
+ stderr: "ignore",
113
+ stdin: "ignore",
114
+ });
115
+ const out = await new Response(proc.stdout).text();
116
+ await proc.exited;
117
+ for (const line of out.split("\n")) if (line.startsWith("n")) return line.slice(1).trim() || null;
118
+ return null;
119
+ } catch {
120
+ return null;
121
+ }
122
+ }
123
+
124
+ /** Current working directory of another process, or null if unavailable. */
125
+ export async function readProcessCwd(pid: number): Promise<string | null> {
126
+ switch (process.platform) {
127
+ case "win32":
128
+ return readCwdWindows(pid);
129
+ case "linux":
130
+ return readCwdLinux(pid);
131
+ case "darwin":
132
+ return readCwdDarwin(pid);
133
+ default:
134
+ return null;
135
+ }
136
+ }
137
+
138
+ // ---- name resolution (cached per pid for the session) ----
139
+
140
+ const cache = new Map<number, string | null>();
141
+
142
+ async function tryRead(path: string): Promise<string | null> {
143
+ try {
144
+ return await Bun.file(path).text();
145
+ } catch {
146
+ return null;
147
+ }
148
+ }
149
+
150
+ // If `dir` holds a known project manifest, return its app name (or the folder
151
+ // name as a fallback); return null when there's no manifest here.
152
+ async function nameFromManifest(dir: string): Promise<string | null> {
153
+ const base = dir.replace(/\\/g, "/");
154
+ const folder = basename(dir);
155
+
156
+ const pj = await tryRead(`${base}/package.json`);
157
+ if (pj !== null) {
158
+ try {
159
+ const n = JSON.parse(pj)?.name;
160
+ if (typeof n === "string" && n) return n;
161
+ } catch {
162
+ /* malformed package.json — fall through to folder name */
163
+ }
164
+ return folder;
165
+ }
166
+
167
+ const cargo = await tryRead(`${base}/Cargo.toml`);
168
+ if (cargo !== null) return tomlName(cargo, "package") ?? folder;
169
+
170
+ const py = await tryRead(`${base}/pyproject.toml`);
171
+ if (py !== null) return tomlName(py, "project") ?? tomlName(py, "tool\\.poetry") ?? folder;
172
+
173
+ const gomod = await tryRead(`${base}/go.mod`);
174
+ if (gomod !== null) {
175
+ const m = gomod.match(/^\s*module\s+(\S+)/m);
176
+ return m ? m[1]!.split("/").pop()! : folder;
177
+ }
178
+
179
+ return null;
180
+ }
181
+
182
+ async function projectNameAbove(startDir: string): Promise<string | null> {
183
+ let dir = startDir;
184
+ for (let i = 0; i < 8 && dir; i++) {
185
+ const name = await nameFromManifest(dir);
186
+ if (name) return name;
187
+ const parent = dirname(dir);
188
+ if (parent === dir) break;
189
+ dir = parent;
190
+ }
191
+ return null;
192
+ }
193
+
194
+ /**
195
+ * Friendly app name for a process (from package.json / Cargo.toml /
196
+ * pyproject.toml / go.mod, or the project folder), or null if it can't be
197
+ * determined. Cached per pid for the session.
198
+ */
199
+ export async function resolveAppName(pid: number): Promise<string | null> {
200
+ if (cache.has(pid)) return cache.get(pid)!;
201
+ let name: string | null = null;
202
+ const cwd = await readProcessCwd(pid);
203
+ if (cwd) name = await projectNameAbove(cwd);
204
+ cache.set(pid, name);
205
+ return name;
206
+ }
@@ -0,0 +1,34 @@
1
+ // config — tiny persisted settings for devkit, shared across all tools.
2
+ //
3
+ // Pure logic (no UI): reads/writes a single JSON file in the user's home dir, so
4
+ // a preference chosen in one tool (e.g. the theme) sticks and applies to every
5
+ // tool, including across the hub spawning each tool as its own process.
6
+
7
+ import { readFileSync, writeFileSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { join } from "node:path";
10
+ import type { LaunchConfig } from "./launch";
11
+
12
+ const CONFIG_PATH = join(homedir(), ".devkit.json");
13
+
14
+ export interface DevkitConfig {
15
+ theme?: string;
16
+ /** The `launch` tool's project registry + scan roots. */
17
+ launch?: LaunchConfig;
18
+ }
19
+
20
+ export function loadConfig(): DevkitConfig {
21
+ try {
22
+ return JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as DevkitConfig;
23
+ } catch {
24
+ return {};
25
+ }
26
+ }
27
+
28
+ export function saveConfig(patch: Partial<DevkitConfig>): void {
29
+ try {
30
+ writeFileSync(CONFIG_PATH, JSON.stringify({ ...loadConfig(), ...patch }, null, 2));
31
+ } catch {
32
+ /* best-effort — a missing setting just falls back to defaults */
33
+ }
34
+ }
@@ -0,0 +1,273 @@
1
+ // killport core — discover TCP listeners and kill processes by PID.
2
+ //
3
+ // Pure logic only: no terminal UI, no console output. The TUI layer
4
+ // (pkg/tui/killport.tsx) and any future GUI both build on these functions.
5
+ //
6
+ // Windows-only. Listeners are gathered from two commands run in parallel:
7
+ // - native `netstat -ano` (~15ms) for port → pid → state, and
8
+ // - one `Get-Process` (PowerShell) for pid → name + exe path.
9
+ // Joining them avoids the slow `Get-NetTCPConnection` cmdlet (~500ms) and the
10
+ // per-connection `Get-Process` calls the first version used (~1s total).
11
+ // Kills go through `taskkill`.
12
+
13
+ import { homedir } from "node:os";
14
+
15
+ export type PortCategory = "app" | "service" | "other";
16
+
17
+ export interface Listener {
18
+ port: number;
19
+ pid: number;
20
+ name: string; // ACTUAL process name from the OS, e.g. "node" — always primary
21
+ path: string | null; // full exe path when readable (null for protected/system)
22
+ system: boolean; // true => protected, must not be killed
23
+ category: PortCategory; // which section this port belongs to
24
+ portLabel?: string; // conventional use of the port, e.g. "MySQL" — hint only
25
+ }
26
+
27
+ export interface KillResult {
28
+ pid: number;
29
+ ok: boolean;
30
+ error?: string;
31
+ }
32
+
33
+ // Conventional uses of well-known ports. This is ONLY used to (a) sort ports
34
+ // into sections and (b) show a dim hint — it never overrides the real process
35
+ // name, so something other than MySQL on 3306 still shows its true name.
36
+ const PORT_CATALOG: Record<number, { label: string; category: "app" | "service" }> = {
37
+ // app / dev servers
38
+ 3000: { label: "dev server", category: "app" },
39
+ 3001: { label: "dev server", category: "app" },
40
+ 4173: { label: "Vite preview", category: "app" },
41
+ 4200: { label: "Angular", category: "app" },
42
+ 4321: { label: "Astro", category: "app" },
43
+ 5000: { label: "dev server", category: "app" },
44
+ 8000: { label: "dev server", category: "app" },
45
+ 8080: { label: "HTTP alt", category: "app" },
46
+ 8081: { label: "HTTP alt", category: "app" },
47
+ 1420: { label: "Tauri", category: "app" },
48
+ 19006: { label: "Expo", category: "app" },
49
+ // services / infra
50
+ 1433: { label: "SQL Server", category: "service" },
51
+ 2181: { label: "Zookeeper", category: "service" },
52
+ 3306: { label: "MySQL", category: "service" },
53
+ 5432: { label: "PostgreSQL", category: "service" },
54
+ 5672: { label: "RabbitMQ", category: "service" },
55
+ 5984: { label: "CouchDB", category: "service" },
56
+ 6379: { label: "Redis", category: "service" },
57
+ 9092: { label: "Kafka", category: "service" },
58
+ 9200: { label: "Elasticsearch", category: "service" },
59
+ 11211: { label: "Memcached", category: "service" },
60
+ 27017: { label: "MongoDB", category: "service" },
61
+ };
62
+
63
+ // Inclusive port ranges with a shared conventional use.
64
+ const RANGE_CATALOG: { from: number; to: number; label: string; category: "app" | "service" }[] = [
65
+ { from: 5173, to: 5190, label: "Vite", category: "app" },
66
+ ];
67
+
68
+ /** Conventional category + label for a port from the catalog (port-only). */
69
+ export function portInfo(port: number): { category: "app" | "service" | null; label?: string } {
70
+ const exact = PORT_CATALOG[port];
71
+ if (exact) return exact;
72
+ for (const r of RANGE_CATALOG) {
73
+ if (port >= r.from && port <= r.to) return { category: r.category, label: r.label };
74
+ }
75
+ return { category: null };
76
+ }
77
+
78
+ // Programming-language runtimes / dev tooling. A process with one of these names
79
+ // is "something I'm running" → grouped under APPS, regardless of port. (Compiled
80
+ // Go/Rust/etc. binaries get an arbitrary exe name, so those are caught instead by
81
+ // the dev-path heuristic below.)
82
+ const RUNTIME_NAMES = new Set([
83
+ "node", "bun", "deno", "tsx", "ts-node", "nodemon",
84
+ "python", "python3", "pythonw", "py",
85
+ "ruby", "rubyw", "php",
86
+ "java", "javaw", "dotnet",
87
+ "go", "cargo", "rustc", "air",
88
+ "elixir", "erl", "dart", "flutter", "perl",
89
+ "uvicorn", "gunicorn", "hypercorn",
90
+ ]);
91
+
92
+ // Database / infrastructure daemons → grouped under SERVICES.
93
+ const SERVICE_NAMES = new Set([
94
+ "mysqld", "mariadbd", "postgres", "pg_ctl", "mongod",
95
+ "redis-server", "redis", "memcached", "sqlservr", "oracle",
96
+ "cassandra", "influxd", "couchdb", "rabbitmq", "rabbitmq-server",
97
+ "docker", "dockerd", "com.docker.backend",
98
+ ]);
99
+
100
+ // True when an exe lives in one of the user's own dev locations (scoop, .cargo,
101
+ // go/bin, project/target dirs, go-build temp) — i.e. something the user built or
102
+ // runs themselves. This is what lets a compiled Go/Rust binary with an arbitrary
103
+ // name land in APPS. We deliberately exclude the AppData subtree so installed
104
+ // Electron/GUI apps (VS Code, Discord, Slack…) don't masquerade as dev servers —
105
+ // except AppData\Local\Temp, where `go run` drops its compiled binaries.
106
+ const HOME = (homedir() || "").toLowerCase();
107
+ function isDevPath(path: string | null): boolean {
108
+ if (!path || !HOME) return false;
109
+ const p = path.toLowerCase();
110
+ if (!p.startsWith(HOME)) return false;
111
+ const rel = p.slice(HOME.length);
112
+ if (rel.includes("\\appdata\\")) return rel.includes("\\appdata\\local\\temp\\");
113
+ return true;
114
+ }
115
+
116
+ /**
117
+ * Decide which section a listener belongs to, from the actual process + port.
118
+ * APPS first (dev runtime name OR a user-owned exe path), so "anything I run"
119
+ * floats to the top; then SERVICES (known daemon name or service port); the
120
+ * catalog is also consulted for the dim port-use hint.
121
+ */
122
+ export function classify(p: { port: number; name: string; path: string | null }): {
123
+ category: PortCategory;
124
+ label?: string;
125
+ } {
126
+ const name = p.name.toLowerCase();
127
+ const { category: portCat, label } = portInfo(p.port);
128
+
129
+ if (RUNTIME_NAMES.has(name) || isDevPath(p.path)) return { category: "app", label };
130
+ if (SERVICE_NAMES.has(name) || portCat === "service") return { category: "service", label };
131
+ if (portCat === "app") return { category: "app", label }; // well-known dev port, unknown proc
132
+ return { category: "other", label };
133
+ }
134
+
135
+ // PIDs / process names that belong to Windows itself. These are flagged
136
+ // `system: true` and treated as protected — a "free up my dev port" tool
137
+ // should never invite killing core OS processes (their ports are RPC/SMB/etc).
138
+ const SYSTEM_PIDS = new Set([0, 4]);
139
+ const SYSTEM_NAMES = new Set([
140
+ "system",
141
+ "idle",
142
+ "system idle process",
143
+ "registry",
144
+ "svchost",
145
+ "lsass",
146
+ "services",
147
+ "wininit",
148
+ "winlogon",
149
+ "csrss",
150
+ "smss",
151
+ "spoolsv",
152
+ ]);
153
+
154
+ // One Get-Process call maps every pid to its name + exe path as compact JSON.
155
+ // `Path` is null for processes we can't open (system / other-session) — a useful
156
+ // "don't touch this" signal that also keeps them out of APPS.
157
+ const PS_PROCS = "Get-Process | Select-Object Id,ProcessName,Path | ConvertTo-Json -Compress";
158
+
159
+ interface ProcRow {
160
+ Id: number;
161
+ ProcessName: string | null;
162
+ Path: string | null;
163
+ }
164
+
165
+ async function runCapture(cmd: string[]): Promise<{ stdout: string; stderr: string; code: number }> {
166
+ const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "pipe", stdin: "ignore" });
167
+ const [stdout, stderr, code] = await Promise.all([
168
+ new Response(proc.stdout).text(),
169
+ new Response(proc.stderr).text(),
170
+ proc.exited,
171
+ ]);
172
+ return { stdout, stderr, code };
173
+ }
174
+
175
+ function isSystem(pid: number, name: string): boolean {
176
+ return SYSTEM_PIDS.has(pid) || SYSTEM_NAMES.has(name.toLowerCase());
177
+ }
178
+
179
+ // Parse `netstat -ano` rows into { port, pid }, keeping only TCP LISTENING.
180
+ // Rows look like: " TCP 0.0.0.0:135 0.0.0.0:0 LISTENING 2216"
181
+ // IPv6 rows are " TCP [::1]:5178 [::]:0 LISTENING 3144" (also "TCP").
182
+ // UDP rows have no state column (4 fields) so they're filtered out below.
183
+ function parseNetstat(out: string): { port: number; pid: number }[] {
184
+ const rows: { port: number; pid: number }[] = [];
185
+ for (const line of out.split(/\r?\n/)) {
186
+ const parts = line.trim().split(/\s+/);
187
+ if (parts.length < 5 || parts[0] !== "TCP" || parts[3] !== "LISTENING") continue;
188
+ const local = parts[1]!;
189
+ const port = Number(local.slice(local.lastIndexOf(":") + 1));
190
+ const pid = Number(parts[parts.length - 1]);
191
+ if (Number.isInteger(port) && Number.isInteger(pid)) rows.push({ port, pid });
192
+ }
193
+ return rows;
194
+ }
195
+
196
+ // Parse the Get-Process JSON into a pid → { name, path } map.
197
+ function parseProcs(out: string): Map<number, { name: string; path: string | null }> {
198
+ const map = new Map<number, { name: string; path: string | null }>();
199
+ const raw = out.trim();
200
+ if (!raw) return map;
201
+ let parsed: ProcRow | ProcRow[];
202
+ try {
203
+ parsed = JSON.parse(raw);
204
+ } catch {
205
+ return map;
206
+ }
207
+ for (const r of Array.isArray(parsed) ? parsed : [parsed]) {
208
+ if (r && typeof r.Id === "number") {
209
+ map.set(r.Id, { name: r.ProcessName ?? "(unknown)", path: r.Path ?? null });
210
+ }
211
+ }
212
+ return map;
213
+ }
214
+
215
+ /** All TCP ports currently in the LISTEN state, sorted by port then pid. */
216
+ export async function listListeners(): Promise<Listener[]> {
217
+ // Run the fast native port scan and the process lookup concurrently.
218
+ // Plain `netstat -ano` (no `-p TCP`!) is used on purpose: `-p TCP` lists only
219
+ // IPv4, dropping IPv6-only listeners like Vite/Node on [::1]. Both IPv4 and
220
+ // IPv6 TCP rows are labelled "TCP" by netstat, so the parser handles both.
221
+ const [net, procs] = await Promise.all([
222
+ runCapture(["netstat", "-ano"]).then((r) => parseNetstat(r.stdout)),
223
+ runCapture(["powershell", "-NoProfile", "-NonInteractive", "-Command", PS_PROCS]).then((r) =>
224
+ parseProcs(r.stdout),
225
+ ),
226
+ ]);
227
+
228
+ const seen = new Set<string>();
229
+ const listeners: Listener[] = [];
230
+ for (const { port, pid } of net) {
231
+ const key = `${port}:${pid}`;
232
+ if (seen.has(key)) continue; // collapse IPv4 + IPv6 duplicates of the same socket
233
+ seen.add(key);
234
+ const proc = procs.get(pid);
235
+ const name = proc?.name ?? "(unknown)";
236
+ const path = proc?.path ?? null;
237
+ const { category, label } = classify({ port, name, path });
238
+ listeners.push({
239
+ port,
240
+ pid,
241
+ name,
242
+ path,
243
+ system: isSystem(pid, name),
244
+ category,
245
+ portLabel: label,
246
+ });
247
+ }
248
+ listeners.sort((a, b) => a.port - b.port || a.pid - b.pid);
249
+ return listeners;
250
+ }
251
+
252
+ /** Listeners bound to a specific port. */
253
+ export async function listenersForPort(port: number): Promise<Listener[]> {
254
+ return (await listListeners()).filter((l) => l.port === port);
255
+ }
256
+
257
+ /** Force-kill a process (and its child tree) by PID via taskkill. */
258
+ export async function killPid(pid: number): Promise<KillResult> {
259
+ const { stderr, code } = await runCapture(["taskkill", "/PID", String(pid), "/F", "/T"]);
260
+ if (code === 0) return { pid, ok: true };
261
+ return { pid, ok: false, error: stderr.trim() || `taskkill exited with code ${code}` };
262
+ }
263
+
264
+ /** Parse CLI args into a unique, sorted list of valid port numbers (1-65535). */
265
+ export function parsePorts(args: string[]): number[] {
266
+ const ports = new Set<number>();
267
+ for (const a of args) {
268
+ if (a.startsWith("-")) continue; // skip flags such as -y / --yes
269
+ const n = Number(a);
270
+ if (Number.isInteger(n) && n >= 1 && n <= 65535) ports.add(n);
271
+ }
272
+ return [...ports].sort((a, b) => a - b);
273
+ }