@hicoders/devkit 1.0.0 → 1.0.1
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/README.md +4 -0
- package/check-bun.mjs +30 -0
- package/package.json +3 -1
- package/pkg/core/killport.ts +163 -28
package/README.md
CHANGED
|
@@ -9,6 +9,10 @@ One hub, clean UI, mouse-friendly, no config to get started.
|
|
|
9
9
|
bun install -g @hicoders/devkit
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
+
> **Install with [Bun](https://bun.com), not npm.** devkit runs on Bun and relies
|
|
13
|
+
> on Bun to pull in the right native UI binary for your platform. `npm install -g`
|
|
14
|
+
> is not supported and can fail at runtime.
|
|
15
|
+
|
|
12
16
|
Then just run `devkit` — or jump straight to a tool by name.
|
|
13
17
|
|
|
14
18
|
---
|
package/check-bun.mjs
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// preinstall guard: devkit is Bun-only.
|
|
2
|
+
//
|
|
3
|
+
// Bun does not run a dependency's lifecycle scripts by default, so this never
|
|
4
|
+
// runs (and never blocks) a `bun add` / `bun install`. npm / yarn / pnpm DO run
|
|
5
|
+
// preinstall, so they hit this check and abort with instructions to use Bun.
|
|
6
|
+
// (If a package manager reports itself as Bun via the user-agent, allow it.)
|
|
7
|
+
|
|
8
|
+
const ua = process.env.npm_config_user_agent || "";
|
|
9
|
+
if (ua.startsWith("bun")) process.exit(0);
|
|
10
|
+
|
|
11
|
+
const red = (s) => `\x1b[31m${s}\x1b[0m`;
|
|
12
|
+
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
13
|
+
const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
|
|
14
|
+
|
|
15
|
+
console.error(
|
|
16
|
+
[
|
|
17
|
+
"",
|
|
18
|
+
red(bold(" @hicoders/devkit must be installed with Bun.")),
|
|
19
|
+
"",
|
|
20
|
+
" devkit runs on Bun and relies on it to install the right native UI",
|
|
21
|
+
" binary for your platform. npm / yarn / pnpm are not supported.",
|
|
22
|
+
"",
|
|
23
|
+
" Install it with:",
|
|
24
|
+
cyan(" bun add -g @hicoders/devkit"),
|
|
25
|
+
"",
|
|
26
|
+
" Don't have Bun? Get it at https://bun.com",
|
|
27
|
+
"",
|
|
28
|
+
].join("\n"),
|
|
29
|
+
);
|
|
30
|
+
process.exit(1);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hicoders/devkit",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "A terminal toolbelt of small, daily-use developer tools (killport, launch, ...) with a unified TUI hub.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
},
|
|
24
24
|
"files": [
|
|
25
25
|
"pkg",
|
|
26
|
+
"check-bun.mjs",
|
|
26
27
|
"tsconfig.json",
|
|
27
28
|
"README.md",
|
|
28
29
|
"LICENSE"
|
|
@@ -31,6 +32,7 @@
|
|
|
31
32
|
"bun": ">=1.2.0"
|
|
32
33
|
},
|
|
33
34
|
"scripts": {
|
|
35
|
+
"preinstall": "node check-bun.mjs",
|
|
34
36
|
"devkit": "bun pkg/tui/devkit.tsx",
|
|
35
37
|
"launch": "bun pkg/tui/launch.tsx",
|
|
36
38
|
"killport": "bun pkg/tui/killport.tsx",
|
package/pkg/core/killport.ts
CHANGED
|
@@ -3,14 +3,18 @@
|
|
|
3
3
|
// Pure logic only: no terminal UI, no console output. The TUI layer
|
|
4
4
|
// (pkg/tui/killport.tsx) and any future GUI both build on these functions.
|
|
5
5
|
//
|
|
6
|
-
//
|
|
7
|
-
// - native `netstat -ano` (
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
6
|
+
// Cross-platform. How listeners are discovered depends on the OS:
|
|
7
|
+
// - Windows: native `netstat -ano` (port -> pid) joined with one PowerShell
|
|
8
|
+
// `Get-Process` (pid -> name + exe path). Kills via `taskkill`.
|
|
9
|
+
// - Linux: `ss -tlnp` (port + pid + name in one shot); exe path from
|
|
10
|
+
// /proc/<pid>/exe. Kills via `kill -9`.
|
|
11
|
+
// - macOS: `lsof -nP -iTCP -sTCP:LISTEN` (port + pid + name). Kills via
|
|
12
|
+
// `kill -9`.
|
|
13
|
+
// All external commands are spawned defensively: a missing binary yields an
|
|
14
|
+
// empty result instead of throwing, so the picker degrades gracefully.
|
|
12
15
|
|
|
13
16
|
import { homedir } from "node:os";
|
|
17
|
+
import { readlinkSync } from "node:fs";
|
|
14
18
|
|
|
15
19
|
export type PortCategory = "app" | "service" | "other";
|
|
16
20
|
|
|
@@ -132,11 +136,14 @@ export function classify(p: { port: number; name: string; path: string | null })
|
|
|
132
136
|
return { category: "other", label };
|
|
133
137
|
}
|
|
134
138
|
|
|
135
|
-
// PIDs / process names that belong to
|
|
139
|
+
// PIDs / process names that belong to the OS itself. These are flagged
|
|
136
140
|
// `system: true` and treated as protected — a "free up my dev port" tool
|
|
137
141
|
// should never invite killing core OS processes (their ports are RPC/SMB/etc).
|
|
138
|
-
|
|
142
|
+
// pid 0/4 are Windows; pid 1 is init/systemd/launchd on POSIX. pid 0 is also
|
|
143
|
+
// the sentinel we use when a listener's owner couldn't be resolved.
|
|
144
|
+
const SYSTEM_PIDS = new Set([0, 1, 4]);
|
|
139
145
|
const SYSTEM_NAMES = new Set([
|
|
146
|
+
// Windows
|
|
140
147
|
"system",
|
|
141
148
|
"idle",
|
|
142
149
|
"system idle process",
|
|
@@ -149,6 +156,10 @@ const SYSTEM_NAMES = new Set([
|
|
|
149
156
|
"csrss",
|
|
150
157
|
"smss",
|
|
151
158
|
"spoolsv",
|
|
159
|
+
// POSIX
|
|
160
|
+
"systemd",
|
|
161
|
+
"init",
|
|
162
|
+
"launchd",
|
|
152
163
|
]);
|
|
153
164
|
|
|
154
165
|
// One Get-Process call maps every pid to its name + exe path as compact JSON.
|
|
@@ -162,14 +173,31 @@ interface ProcRow {
|
|
|
162
173
|
Path: string | null;
|
|
163
174
|
}
|
|
164
175
|
|
|
176
|
+
// Run a command and capture its output. A missing executable (Bun.spawn throws
|
|
177
|
+
// "Executable not found in $PATH") is turned into a non-zero result rather than
|
|
178
|
+
// propagating, so a tool absent on this OS just yields no rows.
|
|
165
179
|
async function runCapture(cmd: string[]): Promise<{ stdout: string; stderr: string; code: number }> {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
180
|
+
try {
|
|
181
|
+
const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "pipe", stdin: "ignore" });
|
|
182
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
183
|
+
new Response(proc.stdout).text(),
|
|
184
|
+
new Response(proc.stderr).text(),
|
|
185
|
+
proc.exited,
|
|
186
|
+
]);
|
|
187
|
+
return { stdout, stderr, code };
|
|
188
|
+
} catch (e) {
|
|
189
|
+
return { stdout: "", stderr: e instanceof Error ? e.message : String(e), code: -1 };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Try each command in turn, returning the first that runs (exit code 0). Used to
|
|
194
|
+
// probe alternative binary locations (e.g. ss in /usr/sbin vs /usr/bin).
|
|
195
|
+
async function firstCapture(cmds: string[][]): Promise<string> {
|
|
196
|
+
for (const cmd of cmds) {
|
|
197
|
+
const r = await runCapture(cmd);
|
|
198
|
+
if (r.code === 0) return r.stdout;
|
|
199
|
+
}
|
|
200
|
+
return "";
|
|
173
201
|
}
|
|
174
202
|
|
|
175
203
|
function isSystem(pid: number, name: string): boolean {
|
|
@@ -212,28 +240,130 @@ function parseProcs(out: string): Map<number, { name: string; path: string | nul
|
|
|
212
240
|
return map;
|
|
213
241
|
}
|
|
214
242
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
243
|
+
// A raw listener row before classification: just where + who.
|
|
244
|
+
interface RawListener {
|
|
245
|
+
port: number;
|
|
246
|
+
pid: number;
|
|
247
|
+
name: string;
|
|
248
|
+
path: string | null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ---- Windows: netstat -ano joined with one Get-Process ----
|
|
252
|
+
async function collectWindows(): Promise<RawListener[]> {
|
|
253
|
+
// Plain `netstat -ano` (no `-p TCP`!) on purpose: `-p TCP` lists only IPv4,
|
|
254
|
+
// dropping IPv6-only listeners like Vite/Node on [::1]. Both IPv4 and IPv6 TCP
|
|
255
|
+
// rows are labelled "TCP" by netstat, so the parser handles both.
|
|
221
256
|
const [net, procs] = await Promise.all([
|
|
222
257
|
runCapture(["netstat", "-ano"]).then((r) => parseNetstat(r.stdout)),
|
|
223
258
|
runCapture(["powershell", "-NoProfile", "-NonInteractive", "-Command", PS_PROCS]).then((r) =>
|
|
224
259
|
parseProcs(r.stdout),
|
|
225
260
|
),
|
|
226
261
|
]);
|
|
262
|
+
return net.map(({ port, pid }) => {
|
|
263
|
+
const proc = procs.get(pid);
|
|
264
|
+
return { port, pid, name: proc?.name ?? "(unknown)", path: proc?.path ?? null };
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ---- Linux: ss -tlnp (port + pid + name); exe path from /proc/<pid>/exe ----
|
|
269
|
+
function readExe(pid: number): string | null {
|
|
270
|
+
try {
|
|
271
|
+
return readlinkSync(`/proc/${pid}/exe`);
|
|
272
|
+
} catch {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ss rows: `LISTEN 0 511 0.0.0.0:3000 0.0.0.0:* users:(("node",pid=1234,fd=20))`
|
|
278
|
+
// Columns: State Recv-Q Send-Q Local Peer [Process]. The Process column may hold
|
|
279
|
+
// several ("name",pid=N,...) tuples, or be absent for sockets we can't inspect.
|
|
280
|
+
function parseSs(out: string): RawListener[] {
|
|
281
|
+
const rows: RawListener[] = [];
|
|
282
|
+
for (const line of out.split(/\r?\n/)) {
|
|
283
|
+
const t = line.trim();
|
|
284
|
+
if (!t.startsWith("LISTEN")) continue; // skips the header and non-listening rows
|
|
285
|
+
const parts = t.split(/\s+/);
|
|
286
|
+
const local = parts[3] ?? "";
|
|
287
|
+
const port = Number(local.slice(local.lastIndexOf(":") + 1));
|
|
288
|
+
if (!Number.isInteger(port)) continue;
|
|
289
|
+
const procPart = parts.slice(5).join(" ");
|
|
290
|
+
const matches = [...procPart.matchAll(/"([^"]+)",pid=(\d+)/g)];
|
|
291
|
+
if (matches.length === 0) {
|
|
292
|
+
rows.push({ port, pid: 0, name: "(unknown)", path: null }); // owner not visible
|
|
293
|
+
} else {
|
|
294
|
+
for (const m of matches) {
|
|
295
|
+
const pid = Number(m[2]);
|
|
296
|
+
rows.push({ port, pid, name: m[1]!, path: readExe(pid) });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return rows;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function collectLinux(): Promise<RawListener[]> {
|
|
304
|
+
// ss ships with iproute2 but isn't always on a non-login PATH; probe the usual
|
|
305
|
+
// locations. (We intentionally don't fall back to the deprecated net-tools
|
|
306
|
+
// `netstat`, which is absent on many modern distros.)
|
|
307
|
+
const out = await firstCapture([
|
|
308
|
+
["ss", "-tlnp"],
|
|
309
|
+
["/usr/sbin/ss", "-tlnp"],
|
|
310
|
+
["/sbin/ss", "-tlnp"],
|
|
311
|
+
["/usr/bin/ss", "-tlnp"],
|
|
312
|
+
]);
|
|
313
|
+
return parseSs(out);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ---- macOS: lsof ----
|
|
317
|
+
// lsof rows: `node 1234 user 20u IPv4 0x.. 0t0 TCP *:3000 (LISTEN)`. The address
|
|
318
|
+
// is the token right before "(LISTEN)"; COMMAND/PID are the first two columns.
|
|
319
|
+
function parseLsof(out: string): RawListener[] {
|
|
320
|
+
const rows: RawListener[] = [];
|
|
321
|
+
for (const line of out.split(/\r?\n/)) {
|
|
322
|
+
const t = line.trim();
|
|
323
|
+
if (!t || t.startsWith("COMMAND")) continue;
|
|
324
|
+
const parts = t.split(/\s+/);
|
|
325
|
+
const pid = Number(parts[1]);
|
|
326
|
+
if (!Number.isInteger(pid)) continue;
|
|
327
|
+
const li = parts.indexOf("(LISTEN)");
|
|
328
|
+
const addr = (li > 0 ? parts[li - 1] : parts[parts.length - 1]) ?? "";
|
|
329
|
+
const port = Number(addr.slice(addr.lastIndexOf(":") + 1));
|
|
330
|
+
if (!Number.isInteger(port)) continue;
|
|
331
|
+
rows.push({ port, pid, name: parts[0]!, path: null });
|
|
332
|
+
}
|
|
333
|
+
return rows;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function collectDarwin(): Promise<RawListener[]> {
|
|
337
|
+
const out = await firstCapture([
|
|
338
|
+
["lsof", "-nP", "-iTCP", "-sTCP:LISTEN"],
|
|
339
|
+
["/usr/sbin/lsof", "-nP", "-iTCP", "-sTCP:LISTEN"],
|
|
340
|
+
]);
|
|
341
|
+
return parseLsof(out);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function collectListeners(): Promise<RawListener[]> {
|
|
345
|
+
switch (process.platform) {
|
|
346
|
+
case "win32":
|
|
347
|
+
return collectWindows();
|
|
348
|
+
case "linux":
|
|
349
|
+
return collectLinux();
|
|
350
|
+
case "darwin":
|
|
351
|
+
return collectDarwin();
|
|
352
|
+
default:
|
|
353
|
+
return [];
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** All TCP ports currently in the LISTEN state, sorted by port then pid. */
|
|
358
|
+
export async function listListeners(): Promise<Listener[]> {
|
|
359
|
+
const raw = await collectListeners();
|
|
227
360
|
|
|
228
361
|
const seen = new Set<string>();
|
|
229
362
|
const listeners: Listener[] = [];
|
|
230
|
-
for (const { port, pid } of
|
|
363
|
+
for (const { port, pid, name, path } of raw) {
|
|
231
364
|
const key = `${port}:${pid}`;
|
|
232
365
|
if (seen.has(key)) continue; // collapse IPv4 + IPv6 duplicates of the same socket
|
|
233
366
|
seen.add(key);
|
|
234
|
-
const proc = procs.get(pid);
|
|
235
|
-
const name = proc?.name ?? "(unknown)";
|
|
236
|
-
const path = proc?.path ?? null;
|
|
237
367
|
const { category, label } = classify({ port, name, path });
|
|
238
368
|
listeners.push({
|
|
239
369
|
port,
|
|
@@ -254,11 +384,16 @@ export async function listenersForPort(port: number): Promise<Listener[]> {
|
|
|
254
384
|
return (await listListeners()).filter((l) => l.port === port);
|
|
255
385
|
}
|
|
256
386
|
|
|
257
|
-
/** Force-kill a process
|
|
387
|
+
/** Force-kill a process by PID — `taskkill /F /T` on Windows (kills the tree),
|
|
388
|
+
* `kill -9` on POSIX. */
|
|
258
389
|
export async function killPid(pid: number): Promise<KillResult> {
|
|
259
|
-
const
|
|
390
|
+
const cmd =
|
|
391
|
+
process.platform === "win32"
|
|
392
|
+
? ["taskkill", "/PID", String(pid), "/F", "/T"]
|
|
393
|
+
: ["kill", "-9", String(pid)];
|
|
394
|
+
const { stderr, code } = await runCapture(cmd);
|
|
260
395
|
if (code === 0) return { pid, ok: true };
|
|
261
|
-
return { pid, ok: false, error: stderr.trim() || `
|
|
396
|
+
return { pid, ok: false, error: stderr.trim() || `kill exited with code ${code}` };
|
|
262
397
|
}
|
|
263
398
|
|
|
264
399
|
/** Parse CLI args into a unique, sorted list of valid port numbers (1-65535). */
|