@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 +21 -0
- package/README.md +87 -0
- package/package.json +52 -0
- package/pkg/core/appname.ts +206 -0
- package/pkg/core/config.ts +34 -0
- package/pkg/core/killport.ts +273 -0
- package/pkg/core/launch.ts +434 -0
- package/pkg/core/manifest.ts +123 -0
- package/pkg/tui/app.tsx +33 -0
- package/pkg/tui/components/Confirm.tsx +53 -0
- package/pkg/tui/components/Header.tsx +37 -0
- package/pkg/tui/components/Help.tsx +83 -0
- package/pkg/tui/components/ListSelect.tsx +436 -0
- package/pkg/tui/components/TextPrompt.tsx +83 -0
- package/pkg/tui/components/index.ts +7 -0
- package/pkg/tui/devkit.tsx +79 -0
- package/pkg/tui/killport.tsx +369 -0
- package/pkg/tui/launch.tsx +971 -0
- package/pkg/tui/theme-context.tsx +30 -0
- package/pkg/tui/theme.ts +97 -0
- package/pkg/tui/tools.tsx +27 -0
- package/pkg/tui/winmouse.ts +70 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// killport — find what's listening on a TCP port and kill it.
|
|
3
|
+
//
|
|
4
|
+
// killport interactive picker (this OpenTUI screen)
|
|
5
|
+
// killport 3000 8080 kill listeners on those ports (plain CLI, scriptable)
|
|
6
|
+
// killport 3000 -y skip the confirmation prompt
|
|
7
|
+
//
|
|
8
|
+
// UI logic only; all discovery/killing lives in pkg/core/killport.ts.
|
|
9
|
+
|
|
10
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
11
|
+
import { useTerminalDimensions } from "@opentui/react";
|
|
12
|
+
import { mountScreen } from "./app";
|
|
13
|
+
import { useTheme } from "./theme-context";
|
|
14
|
+
import { Header, Confirm, ListSelect, Help, type Binding } from "./components";
|
|
15
|
+
import {
|
|
16
|
+
listListeners,
|
|
17
|
+
listenersForPort,
|
|
18
|
+
killPid,
|
|
19
|
+
parsePorts,
|
|
20
|
+
type Listener,
|
|
21
|
+
type PortCategory,
|
|
22
|
+
} from "../core/killport";
|
|
23
|
+
import { resolveAppName } from "../core/appname";
|
|
24
|
+
|
|
25
|
+
type Row = Listener & { appName?: string };
|
|
26
|
+
|
|
27
|
+
const RANK: Record<PortCategory, number> = { app: 0, service: 1, other: 2 };
|
|
28
|
+
const SECTION_NAME: Record<PortCategory, string> = {
|
|
29
|
+
app: "APPS",
|
|
30
|
+
service: "SERVICES",
|
|
31
|
+
other: "OTHER",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// Auto-refresh presets cycled by `f` (ms; 0 = off). Default is 1500ms.
|
|
35
|
+
const REFRESH_STEPS = [500, 1000, 1500, 3000, 5000, 0];
|
|
36
|
+
const DEFAULT_REFRESH = 1500;
|
|
37
|
+
const fmtInterval = (ms: number) => (ms === 0 ? "off" : `${ms / 1000}s`);
|
|
38
|
+
const nextInterval = (ms: number) =>
|
|
39
|
+
REFRESH_STEPS[(REFRESH_STEPS.indexOf(ms) + 1) % REFRESH_STEPS.length] ?? DEFAULT_REFRESH;
|
|
40
|
+
|
|
41
|
+
const HELP: Binding[] = [
|
|
42
|
+
{ keys: "j / k", desc: "move (or arrow keys)" },
|
|
43
|
+
{ keys: "space / tab", desc: "toggle the highlighted row" },
|
|
44
|
+
{ keys: "v", desc: "visual range - v, sweep with j/k, v again to keep" },
|
|
45
|
+
{ keys: "enter", desc: "kill the selection (or highlighted row)" },
|
|
46
|
+
{ keys: "/", desc: "filter by port, process name, or PID" },
|
|
47
|
+
{ keys: "a", desc: "toggle common (apps + services) <-> all ports" },
|
|
48
|
+
{ keys: "s", desc: "toggle sort: port number <-> process name" },
|
|
49
|
+
{ keys: "i", desc: "cycle auto-refresh interval: 0.5s / 1s / 1.5s / 3s / 5s / off" },
|
|
50
|
+
{ keys: "y", desc: "copy the highlighted PID to the clipboard" },
|
|
51
|
+
{ keys: "r", desc: "refresh now" },
|
|
52
|
+
{ keys: "t", desc: "cycle color theme" },
|
|
53
|
+
{ keys: "q / esc esc", desc: "quit (q, or Esc twice)" },
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
function copyPid(pid: number): boolean {
|
|
57
|
+
try {
|
|
58
|
+
Bun.spawn(["clip"], { stdin: Buffer.from(String(pid)) });
|
|
59
|
+
return true;
|
|
60
|
+
} catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Columns: PORT · PID · NAME · PROCESS. PORT/PID/NAME are fixed width (NAME a bit
|
|
66
|
+
// wider than the old runtime column); PROCESS is last and fills the remaining
|
|
67
|
+
// terminal width (computed by the screen). ListSelect prefixes a "› ◉ " gutter.
|
|
68
|
+
const COL = { port: 7, pid: 8, name: 24 };
|
|
69
|
+
const GUTTER = 4; // "› " + "◉ " prefix that ListSelect adds before each row
|
|
70
|
+
const CHROME = 2; // screen box left+right padding
|
|
71
|
+
// Width the trailing PROCESS column may use: everything right of the fixed cols.
|
|
72
|
+
export const procColWidth = (termWidth: number) =>
|
|
73
|
+
Math.max(10, termWidth - CHROME - GUTTER - COL.port - COL.pid - COL.name);
|
|
74
|
+
|
|
75
|
+
// Pad/truncate to exactly `w` chars, always leaving >=1 trailing space as a gap.
|
|
76
|
+
// "..." (3 chars) marks truncation, kept within w-1.
|
|
77
|
+
const fit = (s: string, w: number) => {
|
|
78
|
+
if (s.length <= w - 1) return s.padEnd(w);
|
|
79
|
+
return (s.slice(0, Math.max(0, w - 4)) + "...").padEnd(w);
|
|
80
|
+
};
|
|
81
|
+
// Truncate (no padding) - for the trailing PROCESS column so it can't wrap.
|
|
82
|
+
const clip = (s: string, w: number) => (s.length > w ? s.slice(0, Math.max(0, w - 3)) + "..." : s);
|
|
83
|
+
|
|
84
|
+
// Header row matching the data columns (4 spaces = the ListSelect gutter width).
|
|
85
|
+
function ColumnHeader() {
|
|
86
|
+
const theme = useTheme();
|
|
87
|
+
return (
|
|
88
|
+
<text fg={theme.dim}>
|
|
89
|
+
{" " + fit("PORT", COL.port) + fit("PID", COL.pid) + fit("NAME", COL.name) + "PROCESS"}
|
|
90
|
+
</text>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function PortRow({ l, selected, procWidth }: { l: Row; selected: boolean; procWidth: number }) {
|
|
95
|
+
const theme = useTheme();
|
|
96
|
+
const port = fit(String(l.port), COL.port);
|
|
97
|
+
const pid = fit(String(l.pid), COL.pid);
|
|
98
|
+
const proc = clip(l.name, procWidth); // last column, fills remaining width
|
|
99
|
+
if (l.system) {
|
|
100
|
+
return (
|
|
101
|
+
<text fg={theme.dim}>
|
|
102
|
+
{port}
|
|
103
|
+
{pid}
|
|
104
|
+
{fit("(protected)", COL.name)}
|
|
105
|
+
{proc}
|
|
106
|
+
</text>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
// NAME column: the resolved app name is more useful than the generic port-use
|
|
110
|
+
// label, so prefer it; the runtime process fills the last column.
|
|
111
|
+
const name = l.appName ?? l.portLabel ?? "";
|
|
112
|
+
return (
|
|
113
|
+
<text>
|
|
114
|
+
<span fg={theme.accent}>{port}</span>
|
|
115
|
+
<span fg={theme.dim}>{pid}</span>
|
|
116
|
+
<span fg={selected ? theme.selFg : theme.fg}>{fit(name, COL.name)}</span>
|
|
117
|
+
<span fg={theme.dim}>{proc}</span>
|
|
118
|
+
</text>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function KillportScreen({
|
|
123
|
+
onExit,
|
|
124
|
+
initialInterval = DEFAULT_REFRESH,
|
|
125
|
+
}: {
|
|
126
|
+
onExit: () => void;
|
|
127
|
+
initialInterval?: number;
|
|
128
|
+
}) {
|
|
129
|
+
const [listeners, setListeners] = useState<Listener[] | null>(null);
|
|
130
|
+
const [mode, setMode] = useState<"list" | "confirm">("list");
|
|
131
|
+
const [pending, setPending] = useState<Listener[]>([]);
|
|
132
|
+
const [status, setStatus] = useState("");
|
|
133
|
+
const [view, setView] = useState<"curated" | "all">("curated");
|
|
134
|
+
const [sortBy, setSortBy] = useState<"port" | "name">("port");
|
|
135
|
+
const [showHelp, setShowHelp] = useState(false);
|
|
136
|
+
const [intervalMs, setIntervalMs] = useState(initialInterval);
|
|
137
|
+
const [interacting, setInteracting] = useState(false);
|
|
138
|
+
const [names, setNames] = useState<Record<number, string>>({}); // pid → app name
|
|
139
|
+
const attempted = useRef<Set<number>>(new Set()); // pids we've already resolved
|
|
140
|
+
const theme = useTheme();
|
|
141
|
+
const { width } = useTerminalDimensions();
|
|
142
|
+
const procWidth = procColWidth(width);
|
|
143
|
+
|
|
144
|
+
// Refresh in the background without blanking the list to "Scanning…" — only
|
|
145
|
+
// the very first load (listeners === null) shows that.
|
|
146
|
+
const load = useCallback(async () => {
|
|
147
|
+
setListeners(await listListeners());
|
|
148
|
+
}, []);
|
|
149
|
+
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
void load();
|
|
152
|
+
}, [load]);
|
|
153
|
+
|
|
154
|
+
// Auto-refresh on a timer, paused while a confirm/help is up or the user is
|
|
155
|
+
// mid-interaction (filtering / visual sweep) so the list doesn't shift.
|
|
156
|
+
const paused = mode !== "list" || showHelp || interacting;
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
if (intervalMs <= 0 || paused) return;
|
|
159
|
+
const id = setInterval(() => void load(), intervalMs);
|
|
160
|
+
return () => clearInterval(id);
|
|
161
|
+
}, [intervalMs, paused, load]);
|
|
162
|
+
|
|
163
|
+
// Lazily resolve a friendly app name (from each process's package.json) once
|
|
164
|
+
// the list is shown. Each pid is resolved once per session — refreshes reuse
|
|
165
|
+
// the cache, so this never re-runs the native lookup for known pids.
|
|
166
|
+
useEffect(() => {
|
|
167
|
+
if (!listeners) return;
|
|
168
|
+
const pids = [
|
|
169
|
+
...new Set(
|
|
170
|
+
listeners.filter((l) => l.category === "app" && !l.system).map((l) => l.pid),
|
|
171
|
+
),
|
|
172
|
+
].filter((pid) => !attempted.current.has(pid));
|
|
173
|
+
if (!pids.length) return;
|
|
174
|
+
pids.forEach((pid) => attempted.current.add(pid));
|
|
175
|
+
let cancelled = false;
|
|
176
|
+
void (async () => {
|
|
177
|
+
const found = await Promise.all(
|
|
178
|
+
pids.map(async (pid) => [pid, await resolveAppName(pid)] as const),
|
|
179
|
+
);
|
|
180
|
+
if (cancelled) return;
|
|
181
|
+
const hits = found.filter(([, name]) => name);
|
|
182
|
+
if (hits.length) setNames((prev) => ({ ...prev, ...Object.fromEntries(hits) }));
|
|
183
|
+
})();
|
|
184
|
+
return () => {
|
|
185
|
+
cancelled = true;
|
|
186
|
+
};
|
|
187
|
+
}, [listeners]);
|
|
188
|
+
|
|
189
|
+
// Group by category (apps → services → other), then by the chosen sort key.
|
|
190
|
+
const ordered = useMemo(() => {
|
|
191
|
+
const arr = [...(listeners ?? [])];
|
|
192
|
+
arr.sort(
|
|
193
|
+
(a, b) =>
|
|
194
|
+
RANK[a.category] - RANK[b.category] ||
|
|
195
|
+
(sortBy === "name" ? a.name.localeCompare(b.name) : 0) ||
|
|
196
|
+
a.port - b.port,
|
|
197
|
+
);
|
|
198
|
+
return arr;
|
|
199
|
+
}, [listeners, sortBy]);
|
|
200
|
+
|
|
201
|
+
const visible = view === "curated" ? ordered.filter((l) => l.category !== "other") : ordered;
|
|
202
|
+
const display: Row[] = visible.map((l) => ({ ...l, appName: names[l.pid] }));
|
|
203
|
+
const otherCount = ordered.filter((l) => l.category === "other").length;
|
|
204
|
+
|
|
205
|
+
if (listeners === null) {
|
|
206
|
+
return (
|
|
207
|
+
<box style={{ padding: 1, flexDirection: "column" }}>
|
|
208
|
+
<Header title="killport" subtitle="kill processes by port" />
|
|
209
|
+
<text fg={theme.dim}>Scanning listening ports...</text>
|
|
210
|
+
</box>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const doKill = async () => {
|
|
215
|
+
// One process can own several selected ports — kill each PID once.
|
|
216
|
+
const pids = [...new Set(pending.map((t) => t.pid))];
|
|
217
|
+
const results = await Promise.all(pids.map((pid) => killPid(pid)));
|
|
218
|
+
const ok = results.filter((r) => r.ok).length;
|
|
219
|
+
const fail = results.length - ok;
|
|
220
|
+
setStatus(`Killed ${ok}${fail ? `, ${fail} failed` : ""} at ${new Date().toLocaleTimeString()}`);
|
|
221
|
+
setPending([]);
|
|
222
|
+
setMode("list");
|
|
223
|
+
await load();
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const confirmMsg =
|
|
227
|
+
`Kill ${pending.length}: ` + pending.map((t) => `${t.name} (${t.port})`).join(", ") + " ?";
|
|
228
|
+
|
|
229
|
+
const emptyText =
|
|
230
|
+
view === "curated"
|
|
231
|
+
? `No common ports in use - press a to show all (${ordered.length} listening).`
|
|
232
|
+
: "No listening ports found.";
|
|
233
|
+
|
|
234
|
+
if (showHelp) {
|
|
235
|
+
return <Help title="killport - keys" bindings={HELP} onClose={() => setShowHelp(false)} />;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return (
|
|
239
|
+
<box style={{ padding: 1, flexDirection: "column" }}>
|
|
240
|
+
<Header
|
|
241
|
+
title="killport"
|
|
242
|
+
subtitle={`${view === "curated" ? "common" : "all"} ports | sort: ${sortBy} | auto: ${fmtInterval(intervalMs)}`}
|
|
243
|
+
hint="j/k | space/tab/v select | enter kill | / filter | a all | t theme | h help"
|
|
244
|
+
/>
|
|
245
|
+
{status ? <text fg={theme.green}>{status}</text> : null}
|
|
246
|
+
<ColumnHeader />
|
|
247
|
+
<ListSelect
|
|
248
|
+
items={display}
|
|
249
|
+
getKey={(l) => `${l.port}:${l.pid}`}
|
|
250
|
+
multiSelect
|
|
251
|
+
active={mode === "list" && !showHelp}
|
|
252
|
+
isSelectable={(l) => !l.system}
|
|
253
|
+
filterText={(l) => `${l.port} ${l.name} ${l.pid} ${l.appName ?? ""} ${l.portLabel ?? ""}`}
|
|
254
|
+
sectionOf={(l) => l.category}
|
|
255
|
+
sectionLabel={(id, count) => `${SECTION_NAME[id as PortCategory]} (${count})`}
|
|
256
|
+
onInteractingChange={setInteracting}
|
|
257
|
+
emptyText={emptyText}
|
|
258
|
+
onSubmit={(targets) => {
|
|
259
|
+
setPending(targets);
|
|
260
|
+
setMode("confirm");
|
|
261
|
+
}}
|
|
262
|
+
onCancel={onExit}
|
|
263
|
+
onExtraKey={(name, current) => {
|
|
264
|
+
if (name === "r") void load();
|
|
265
|
+
else if (name === "a") setView((v) => (v === "curated" ? "all" : "curated"));
|
|
266
|
+
else if (name === "s") setSortBy((s) => (s === "port" ? "name" : "port"));
|
|
267
|
+
else if (name === "i") setIntervalMs((ms) => nextInterval(ms));
|
|
268
|
+
else if (name === "h") setShowHelp(true);
|
|
269
|
+
else if (name === "y" && current)
|
|
270
|
+
setStatus(copyPid(current.pid) ? `Copied PID ${current.pid}` : "Copy failed");
|
|
271
|
+
}}
|
|
272
|
+
renderRow={(l, { selected }) => (
|
|
273
|
+
<PortRow l={l} selected={selected} procWidth={procWidth} />
|
|
274
|
+
)}
|
|
275
|
+
/>
|
|
276
|
+
{view === "curated" && otherCount > 0 ? (
|
|
277
|
+
<box style={{ flexDirection: "column" }}>
|
|
278
|
+
<text fg={theme.accentDim}>{`OTHER (${otherCount})`}</text>
|
|
279
|
+
<text fg={theme.dim}>{' press "a" to show all ports'}</text>
|
|
280
|
+
</box>
|
|
281
|
+
) : null}
|
|
282
|
+
{mode === "confirm" ? (
|
|
283
|
+
<Confirm
|
|
284
|
+
message={confirmMsg}
|
|
285
|
+
onConfirm={() => void doKill()}
|
|
286
|
+
onCancel={() => {
|
|
287
|
+
setPending([]);
|
|
288
|
+
setMode("list");
|
|
289
|
+
}}
|
|
290
|
+
/>
|
|
291
|
+
) : null}
|
|
292
|
+
</box>
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ---------- non-interactive CLI path (killport <ports> [-y]) ----------
|
|
297
|
+
|
|
298
|
+
async function runKillCli(ports: number[], autoYes: boolean) {
|
|
299
|
+
const targets: Listener[] = [];
|
|
300
|
+
for (const p of ports) {
|
|
301
|
+
const ls = (await listenersForPort(p)).filter((l) => !l.system);
|
|
302
|
+
if (!ls.length) console.log(`Port ${p}: nothing listening.`);
|
|
303
|
+
targets.push(...ls);
|
|
304
|
+
}
|
|
305
|
+
if (!targets.length) {
|
|
306
|
+
console.log("Nothing to kill.");
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
console.log("Targets:");
|
|
310
|
+
for (const t of targets) console.log(` ${t.port} ${t.name} (pid ${t.pid})`);
|
|
311
|
+
// One process can own several of the requested ports — kill each PID once.
|
|
312
|
+
const byPid = new Map<number, Listener>();
|
|
313
|
+
for (const t of targets) if (!byPid.has(t.pid)) byPid.set(t.pid, t);
|
|
314
|
+
if (!autoYes) {
|
|
315
|
+
const ans = prompt(`Kill ${byPid.size} process(es)? [y/N]`);
|
|
316
|
+
if (!ans || !/^y/i.test(ans)) {
|
|
317
|
+
console.log("Aborted.");
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
for (const [pid, t] of byPid) {
|
|
322
|
+
const r = await killPid(pid);
|
|
323
|
+
console.log(
|
|
324
|
+
r.ok ? ` ✓ killed ${t.name} (pid ${pid})` : ` ✗ ${t.name} (pid ${pid}): ${r.error}`,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export async function runKillport() {
|
|
330
|
+
const argv = process.argv.slice(2);
|
|
331
|
+
if (argv.includes("-h") || argv.includes("--help")) {
|
|
332
|
+
console.log(
|
|
333
|
+
[
|
|
334
|
+
"Usage: killport [ports...] [-y] [--interval=<ms|off>]",
|
|
335
|
+
" killport interactive picker",
|
|
336
|
+
" killport 3000 8080 kill listeners on those ports",
|
|
337
|
+
" -y, --yes skip the confirmation prompt",
|
|
338
|
+
" --interval=<ms|off> picker auto-refresh (default 1500)",
|
|
339
|
+
].join("\n"),
|
|
340
|
+
);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const autoYes = argv.includes("-y") || argv.includes("--yes");
|
|
344
|
+
const ports = parsePorts(argv);
|
|
345
|
+
if (ports.length) {
|
|
346
|
+
await runKillCli(ports, autoYes);
|
|
347
|
+
} else {
|
|
348
|
+
await mountScreen<void>((done) => (
|
|
349
|
+
<KillportScreen onExit={done} initialInterval={parseInterval(argv)} />
|
|
350
|
+
));
|
|
351
|
+
process.exit(0);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// `--interval=2000` / `--interval=off` (and `--no-watch`) set the picker's
|
|
356
|
+
// starting auto-refresh; anything invalid falls back to the default.
|
|
357
|
+
function parseInterval(argv: string[]): number {
|
|
358
|
+
if (argv.includes("--no-watch")) return 0;
|
|
359
|
+
const arg = argv.find((a) => a.startsWith("--interval="));
|
|
360
|
+
if (!arg) return DEFAULT_REFRESH;
|
|
361
|
+
const val = arg.slice("--interval=".length).toLowerCase();
|
|
362
|
+
if (val === "off" || val === "0") return 0;
|
|
363
|
+
const n = Number(val);
|
|
364
|
+
return Number.isFinite(n) && n >= 250 ? n : DEFAULT_REFRESH;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (import.meta.main) {
|
|
368
|
+
await runKillport();
|
|
369
|
+
}
|