@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 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.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",
@@ -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
- // 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`.
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 Windows itself. These are flagged
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
- const SYSTEM_PIDS = new Set([0, 4]);
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
- 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 };
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
- /** 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.
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 net) {
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 (and its child tree) by PID via taskkill. */
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 { stderr, code } = await runCapture(["taskkill", "/PID", String(pid), "/F", "/T"]);
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() || `taskkill exited with code ${code}` };
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). */