@burakboduroglu/portkill 0.4.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/LICENSE +21 -0
- package/README.md +136 -0
- package/dist/index.js +1308 -0
- package/package.json +56 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1308 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { readFileSync } from "fs";
|
|
5
|
+
import { dirname, join } from "path";
|
|
6
|
+
import process6 from "process";
|
|
7
|
+
import { fileURLToPath } from "url";
|
|
8
|
+
import { Command } from "commander";
|
|
9
|
+
|
|
10
|
+
// src/commands/kill.ts
|
|
11
|
+
import * as readline from "readline/promises";
|
|
12
|
+
import process3 from "process";
|
|
13
|
+
|
|
14
|
+
// src/core/finder.ts
|
|
15
|
+
import { execFile } from "child_process";
|
|
16
|
+
import { promisify } from "util";
|
|
17
|
+
var execFileAsync = promisify(execFile);
|
|
18
|
+
var defaultExecFile = (file, args, options) => execFileAsync(file, args, options);
|
|
19
|
+
function parseLsofListenTable(stdout) {
|
|
20
|
+
const lines = stdout.trim().split(/\r?\n/).filter(Boolean);
|
|
21
|
+
if (lines.length === 0) return [];
|
|
22
|
+
const dataLines = lines[0]?.startsWith("COMMAND") ? lines.slice(1) : lines;
|
|
23
|
+
const byPid = /* @__PURE__ */ new Map();
|
|
24
|
+
for (const line of dataLines) {
|
|
25
|
+
const trimmed = line.trim();
|
|
26
|
+
if (!trimmed) continue;
|
|
27
|
+
const parts = trimmed.split(/\s+/);
|
|
28
|
+
const commandName = parts[0] ?? null;
|
|
29
|
+
const pidRaw = parts[1];
|
|
30
|
+
const pid = pidRaw ? Number.parseInt(pidRaw, 10) : Number.NaN;
|
|
31
|
+
if (!Number.isFinite(pid)) continue;
|
|
32
|
+
if (!byPid.has(pid)) {
|
|
33
|
+
byPid.set(pid, { pid, commandName: commandName ?? null });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return [...byPid.values()].sort((a, b) => a.pid - b.pid);
|
|
37
|
+
}
|
|
38
|
+
function parseFuserCombined(output) {
|
|
39
|
+
const colonIdx = output.indexOf(":");
|
|
40
|
+
const tail = colonIdx >= 0 ? output.slice(colonIdx + 1) : output;
|
|
41
|
+
const pids = tail.split(/\s+/).map((s) => s.trim()).filter(Boolean).map((s) => Number.parseInt(s, 10)).filter((n) => Number.isFinite(n));
|
|
42
|
+
return [...new Set(pids)].sort((a, b) => a - b);
|
|
43
|
+
}
|
|
44
|
+
async function findWithLsof(port, execFileFn) {
|
|
45
|
+
try {
|
|
46
|
+
const { stdout } = await execFileFn("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN"], {
|
|
47
|
+
encoding: "utf8"
|
|
48
|
+
});
|
|
49
|
+
const list = parseLsofListenTable(stdout);
|
|
50
|
+
return list.length === 0 ? "empty" : list;
|
|
51
|
+
} catch (err) {
|
|
52
|
+
const code = err && typeof err === "object" && "code" in err ? err.code : void 0;
|
|
53
|
+
if (code === 1) {
|
|
54
|
+
return "empty";
|
|
55
|
+
}
|
|
56
|
+
if (code === "ENOENT") {
|
|
57
|
+
return "error";
|
|
58
|
+
}
|
|
59
|
+
return "error";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async function findWithFuser(port, execFileFn) {
|
|
63
|
+
try {
|
|
64
|
+
const { stdout, stderr } = await execFileFn("fuser", ["-n", "tcp", String(port)], {
|
|
65
|
+
encoding: "utf8"
|
|
66
|
+
});
|
|
67
|
+
const combined = `${stdout}
|
|
68
|
+
${stderr}`;
|
|
69
|
+
const pids = parseFuserCombined(combined);
|
|
70
|
+
if (pids.length === 0) return "empty";
|
|
71
|
+
return pids.map((pid) => ({ pid, commandName: null }));
|
|
72
|
+
} catch (err) {
|
|
73
|
+
const code = err && typeof err === "object" && "code" in err ? err.code : void 0;
|
|
74
|
+
if (code === 1) return "empty";
|
|
75
|
+
if (code === "ENOENT") return "error";
|
|
76
|
+
return "error";
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
async function findListeners(port, platform, options = {}) {
|
|
80
|
+
const execFileFn = options.execFile ?? defaultExecFile;
|
|
81
|
+
const lsofResult = await findWithLsof(port, execFileFn);
|
|
82
|
+
if (Array.isArray(lsofResult)) {
|
|
83
|
+
return { ok: true, processes: lsofResult };
|
|
84
|
+
}
|
|
85
|
+
if (lsofResult === "empty") {
|
|
86
|
+
return { ok: true, processes: [] };
|
|
87
|
+
}
|
|
88
|
+
if (platform === "linux") {
|
|
89
|
+
const fuserResult = await findWithFuser(port, execFileFn);
|
|
90
|
+
if (Array.isArray(fuserResult)) {
|
|
91
|
+
return { ok: true, processes: fuserResult };
|
|
92
|
+
}
|
|
93
|
+
if (fuserResult === "empty") {
|
|
94
|
+
return { ok: true, processes: [] };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
ok: false,
|
|
99
|
+
message: platform === "linux" ? "Could not list listeners (install `lsof` or `fuser`, or check PATH)." : "Could not list listeners (`lsof` failed or is missing)."
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// src/core/killer.ts
|
|
104
|
+
import process2 from "process";
|
|
105
|
+
var defaultKill = (pid, signal) => {
|
|
106
|
+
process2.kill(pid, signal);
|
|
107
|
+
};
|
|
108
|
+
function killPid(pid, signal = "SIGTERM", killFn = defaultKill) {
|
|
109
|
+
try {
|
|
110
|
+
killFn(pid, signal);
|
|
111
|
+
return { ok: true };
|
|
112
|
+
} catch (err) {
|
|
113
|
+
const code = err && typeof err === "object" && "code" in err ? String(err.code) : "";
|
|
114
|
+
if (code === "EPERM") {
|
|
115
|
+
return { ok: false, permissionDenied: true };
|
|
116
|
+
}
|
|
117
|
+
if (code === "ESRCH") {
|
|
118
|
+
return { ok: true };
|
|
119
|
+
}
|
|
120
|
+
return { ok: false, permissionDenied: false };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// src/utils/exit-code.ts
|
|
125
|
+
function aggregateExitCode(outcomes) {
|
|
126
|
+
if (outcomes.length === 0) return 0;
|
|
127
|
+
if (outcomes.some((o) => o.kind === "permissionDenied")) return 3;
|
|
128
|
+
if (outcomes.some((o) => o.kind === "error")) return 1;
|
|
129
|
+
if (outcomes.every((o) => o.kind === "notFound")) return 2;
|
|
130
|
+
return 0;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// src/utils/style.ts
|
|
134
|
+
import chalk from "chalk";
|
|
135
|
+
var style = {
|
|
136
|
+
success: (text) => chalk.green(text),
|
|
137
|
+
info: (text) => chalk.cyan(text),
|
|
138
|
+
error: (text) => chalk.red(text),
|
|
139
|
+
listRow: (port, commandName, pid) => `${chalk.white("\u2022 Port ")}${chalk.bold.yellow(String(port))}${chalk.white(` \u2192 ${commandName} (PID ${pid})`)}`
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// src/utils/output.ts
|
|
143
|
+
function formatOutcomeLine(outcome) {
|
|
144
|
+
switch (outcome.kind) {
|
|
145
|
+
case "notFound":
|
|
146
|
+
return style.info(`\u2139 Port ${outcome.port} \u2192 no process found`);
|
|
147
|
+
case "killed":
|
|
148
|
+
return style.success(
|
|
149
|
+
`\u2714 Port ${outcome.port} \u2192 killed (${outcome.commandName ?? "unknown"}, PID ${outcome.pid})`
|
|
150
|
+
);
|
|
151
|
+
case "dryRunWouldKill":
|
|
152
|
+
return style.info(
|
|
153
|
+
`\u2139 Port ${outcome.port} \u2192 ${outcome.commandName ?? "unknown"} (PID ${outcome.pid}) \u2014 dry-run (no signal sent)`
|
|
154
|
+
);
|
|
155
|
+
case "permissionDenied":
|
|
156
|
+
return style.error(`\u2716 Port ${outcome.port} \u2192 permission denied (try with sudo)`);
|
|
157
|
+
case "error":
|
|
158
|
+
return style.error(`\u2716 Port ${outcome.port} \u2192 ${outcome.message}`);
|
|
159
|
+
default: {
|
|
160
|
+
const _exhaustive = outcome;
|
|
161
|
+
return _exhaustive;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// src/commands/kill.ts
|
|
167
|
+
function logVerbose(verbose, message) {
|
|
168
|
+
if (verbose) {
|
|
169
|
+
process3.stderr.write(`[verbose] ${message}
|
|
170
|
+
`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
async function confirmKill(summary) {
|
|
174
|
+
if (!process3.stdin.isTTY) {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
const rl = readline.createInterface({ input: process3.stdin, output: process3.stdout });
|
|
178
|
+
try {
|
|
179
|
+
const answer = (await rl.question(`${summary} [y/N] `)).trim().toLowerCase();
|
|
180
|
+
return answer === "y" || answer === "yes";
|
|
181
|
+
} finally {
|
|
182
|
+
rl.close();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
function normalizeSignal(signal) {
|
|
186
|
+
if (typeof signal === "number") return signal;
|
|
187
|
+
if (typeof signal !== "string") return signal;
|
|
188
|
+
const n = Number.parseInt(signal, 10);
|
|
189
|
+
if (Number.isFinite(n)) return n;
|
|
190
|
+
return signal;
|
|
191
|
+
}
|
|
192
|
+
async function runKill(opts) {
|
|
193
|
+
const signal = normalizeSignal(opts.signal);
|
|
194
|
+
const phases = [];
|
|
195
|
+
for (const port of opts.ports) {
|
|
196
|
+
logVerbose(opts.verbose, `finding listeners on TCP ${port}`);
|
|
197
|
+
const found = await findListeners(port, opts.platform);
|
|
198
|
+
if (!found.ok) {
|
|
199
|
+
phases.push({ kind: "resolved", outcome: { kind: "error", port, message: found.message } });
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (found.processes.length === 0) {
|
|
203
|
+
phases.push({ kind: "resolved", outcome: { kind: "notFound", port } });
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
phases.push({ kind: "pendingKill", port, processes: found.processes });
|
|
207
|
+
}
|
|
208
|
+
const pending = phases.filter((p) => p.kind === "pendingKill");
|
|
209
|
+
const describe = (port, processes) => {
|
|
210
|
+
const first = processes[0];
|
|
211
|
+
const extra = processes.length > 1 ? ` (+${processes.length - 1} more)` : "";
|
|
212
|
+
const name = first?.commandName ?? "unknown";
|
|
213
|
+
const pid = first?.pid ?? 0;
|
|
214
|
+
return `port ${port}: ${name} (PID ${pid})${extra}`;
|
|
215
|
+
};
|
|
216
|
+
if (!opts.dryRun && pending.length > 0 && !opts.force) {
|
|
217
|
+
if (!process3.stdin.isTTY) {
|
|
218
|
+
process3.stderr.write("Not a TTY: use --force to kill without confirmation, or use --dry-run.\n");
|
|
219
|
+
return { exitCode: 1, lines: [], outcomes: [] };
|
|
220
|
+
}
|
|
221
|
+
const lines2 = pending.map((p) => describe(p.port, p.processes));
|
|
222
|
+
const summary = `Kill process(es)?
|
|
223
|
+
${lines2.map((l) => ` - ${l}`).join("\n")}
|
|
224
|
+
`;
|
|
225
|
+
const ok = await confirmKill(summary);
|
|
226
|
+
if (!ok) {
|
|
227
|
+
process3.stderr.write("Aborted.\n");
|
|
228
|
+
return { exitCode: 1, lines: [], outcomes: [] };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const outcomes = [];
|
|
232
|
+
for (const phase of phases) {
|
|
233
|
+
if (phase.kind === "resolved") {
|
|
234
|
+
outcomes.push(phase.outcome);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const { port, processes } = phase;
|
|
238
|
+
const display = processes[0];
|
|
239
|
+
const displayPid = display?.pid ?? 0;
|
|
240
|
+
const displayName = display?.commandName ?? null;
|
|
241
|
+
if (opts.dryRun) {
|
|
242
|
+
outcomes.push({
|
|
243
|
+
kind: "dryRunWouldKill",
|
|
244
|
+
port,
|
|
245
|
+
pid: displayPid,
|
|
246
|
+
commandName: displayName
|
|
247
|
+
});
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
let denied = false;
|
|
251
|
+
let otherError = false;
|
|
252
|
+
for (const proc of processes) {
|
|
253
|
+
logVerbose(opts.verbose, `sending ${String(signal)} to PID ${proc.pid}`);
|
|
254
|
+
const result = killPid(proc.pid, signal, opts.killFn);
|
|
255
|
+
if (!result.ok) {
|
|
256
|
+
if (result.permissionDenied) denied = true;
|
|
257
|
+
else otherError = true;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (denied) {
|
|
261
|
+
outcomes.push({ kind: "permissionDenied", port });
|
|
262
|
+
} else if (otherError) {
|
|
263
|
+
outcomes.push({
|
|
264
|
+
kind: "error",
|
|
265
|
+
port,
|
|
266
|
+
message: "failed to send signal to one or more processes"
|
|
267
|
+
});
|
|
268
|
+
} else {
|
|
269
|
+
outcomes.push({
|
|
270
|
+
kind: "killed",
|
|
271
|
+
port,
|
|
272
|
+
pid: displayPid,
|
|
273
|
+
commandName: displayName
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const lines = outcomes.map(formatOutcomeLine);
|
|
278
|
+
const exitCode = aggregateExitCode(outcomes);
|
|
279
|
+
return { exitCode, lines, outcomes };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// src/commands/list.ts
|
|
283
|
+
import process4 from "process";
|
|
284
|
+
|
|
285
|
+
// src/core/lister.ts
|
|
286
|
+
import { execFile as execFile2 } from "child_process";
|
|
287
|
+
import { promisify as promisify2 } from "util";
|
|
288
|
+
var execFileAsync2 = promisify2(execFile2);
|
|
289
|
+
var defaultExecFile2 = (file, args, options) => execFileAsync2(file, args, options);
|
|
290
|
+
function parseLsofListenLine(line) {
|
|
291
|
+
if (!line.includes("(LISTEN)") || !line.includes("TCP")) return null;
|
|
292
|
+
const portMatch = line.match(/TCP .+:(\d+) \(LISTEN\)/);
|
|
293
|
+
if (!portMatch?.[1]) return null;
|
|
294
|
+
const port = Number.parseInt(portMatch[1], 10);
|
|
295
|
+
if (!Number.isFinite(port)) return null;
|
|
296
|
+
const head = line.match(/^(\S+)\s+(\d+)\s+/);
|
|
297
|
+
if (!head?.[1] || !head[2]) return null;
|
|
298
|
+
const pid = Number.parseInt(head[2], 10);
|
|
299
|
+
if (!Number.isFinite(pid)) return null;
|
|
300
|
+
return { port, pid, commandName: head[1] };
|
|
301
|
+
}
|
|
302
|
+
async function listAllTcpListeners(_platform, options = {}) {
|
|
303
|
+
const execFileFn = options.execFile ?? defaultExecFile2;
|
|
304
|
+
try {
|
|
305
|
+
const { stdout } = await execFileFn("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN"], { encoding: "utf8" });
|
|
306
|
+
const seen = /* @__PURE__ */ new Set();
|
|
307
|
+
const rows = [];
|
|
308
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
309
|
+
const row = parseLsofListenLine(line);
|
|
310
|
+
if (!row) continue;
|
|
311
|
+
const key = `${row.port}\0${row.pid}`;
|
|
312
|
+
if (seen.has(key)) continue;
|
|
313
|
+
seen.add(key);
|
|
314
|
+
rows.push(row);
|
|
315
|
+
}
|
|
316
|
+
rows.sort((a, b) => a.port - b.port || a.pid - b.pid);
|
|
317
|
+
return { ok: true, rows };
|
|
318
|
+
} catch (err) {
|
|
319
|
+
const code = err && typeof err === "object" && "code" in err ? err.code : void 0;
|
|
320
|
+
if (code === 1) {
|
|
321
|
+
return { ok: true, rows: [] };
|
|
322
|
+
}
|
|
323
|
+
return {
|
|
324
|
+
ok: false,
|
|
325
|
+
message: "Could not list listeners (`lsof` failed or is missing). Install `lsof` or check PATH."
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// src/commands/list.ts
|
|
331
|
+
async function runList(opts) {
|
|
332
|
+
if (opts.verbose) {
|
|
333
|
+
process4.stderr.write("[verbose] running lsof -nP -iTCP -sTCP:LISTEN\n");
|
|
334
|
+
}
|
|
335
|
+
const result = await listAllTcpListeners(opts.platform);
|
|
336
|
+
if (!result.ok) {
|
|
337
|
+
return { exitCode: 1, lines: [style.error(`\u2716 ${result.message}`)] };
|
|
338
|
+
}
|
|
339
|
+
if (result.rows.length === 0) {
|
|
340
|
+
return { exitCode: 0, lines: [style.info("\u2139 No TCP listeners found.")] };
|
|
341
|
+
}
|
|
342
|
+
const lines = result.rows.map((r) => style.listRow(r.port, r.commandName, r.pid));
|
|
343
|
+
return { exitCode: 0, lines };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// src/gui/server.ts
|
|
347
|
+
import {
|
|
348
|
+
createServer
|
|
349
|
+
} from "http";
|
|
350
|
+
import process5 from "process";
|
|
351
|
+
|
|
352
|
+
// src/utils/parse-ports.ts
|
|
353
|
+
var SINGLE = /^(\d+)$/;
|
|
354
|
+
var RANGE = /^(\d+)-(\d+)$/;
|
|
355
|
+
var MAX_PORTS_PER_RANGE = 4096;
|
|
356
|
+
function assertPort(n, label) {
|
|
357
|
+
if (!Number.isFinite(n) || n < 1 || n > 65535) {
|
|
358
|
+
throw new Error(`invalid port in ${label}`);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
function expandPortToken(token) {
|
|
362
|
+
const t = token.trim();
|
|
363
|
+
if (!t) {
|
|
364
|
+
throw new Error(`invalid port: ${JSON.stringify(token)}`);
|
|
365
|
+
}
|
|
366
|
+
const rangeMatch = t.match(RANGE);
|
|
367
|
+
if (rangeMatch) {
|
|
368
|
+
const start = Number.parseInt(rangeMatch[1], 10);
|
|
369
|
+
const end = Number.parseInt(rangeMatch[2], 10);
|
|
370
|
+
assertPort(start, "range start");
|
|
371
|
+
assertPort(end, "range end");
|
|
372
|
+
if (start > end) {
|
|
373
|
+
throw new Error(`invalid port range: ${t} (start must be <= end)`);
|
|
374
|
+
}
|
|
375
|
+
const count = end - start + 1;
|
|
376
|
+
if (count > MAX_PORTS_PER_RANGE) {
|
|
377
|
+
throw new Error(`port range too large: ${count} ports (max ${MAX_PORTS_PER_RANGE} per range)`);
|
|
378
|
+
}
|
|
379
|
+
const out = [];
|
|
380
|
+
for (let p = start; p <= end; p++) {
|
|
381
|
+
out.push(p);
|
|
382
|
+
}
|
|
383
|
+
return out;
|
|
384
|
+
}
|
|
385
|
+
const singleMatch = t.match(SINGLE);
|
|
386
|
+
if (singleMatch) {
|
|
387
|
+
const n = Number.parseInt(singleMatch[1], 10);
|
|
388
|
+
assertPort(n, t);
|
|
389
|
+
return [n];
|
|
390
|
+
}
|
|
391
|
+
throw new Error(`invalid port: ${t}`);
|
|
392
|
+
}
|
|
393
|
+
function parsePortArguments(tokens) {
|
|
394
|
+
const result = [];
|
|
395
|
+
const seen = /* @__PURE__ */ new Set();
|
|
396
|
+
for (const token of tokens) {
|
|
397
|
+
for (const port of expandPortToken(token)) {
|
|
398
|
+
if (!seen.has(port)) {
|
|
399
|
+
seen.add(port);
|
|
400
|
+
result.push(port);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return result;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// src/gui/index-html.ts
|
|
408
|
+
function getIndexHtml() {
|
|
409
|
+
const faviconSvg = '<svg xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" width="100" height="100" viewBox="0 0 48 48"><path fill="#F44336" d="M21.5 4.5H26.501V43.5H21.5z" transform="rotate(45.001 24 24)"/><path fill="#F44336" d="M21.5 4.5H26.5V43.501H21.5z" transform="rotate(135.008 24 24)"/></svg>';
|
|
410
|
+
const faviconHref = `data:image/svg+xml,${encodeURIComponent(faviconSvg)}`;
|
|
411
|
+
return `<!DOCTYPE html>
|
|
412
|
+
<html lang="en">
|
|
413
|
+
<head>
|
|
414
|
+
<meta charset="utf-8" />
|
|
415
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
416
|
+
<title>.portkill</title>
|
|
417
|
+
<link rel="icon" type="image/svg+xml" href="${faviconHref}" />
|
|
418
|
+
<style>
|
|
419
|
+
:root {
|
|
420
|
+
--ink: #163300;
|
|
421
|
+
--ink-secondary: #3a4d39;
|
|
422
|
+
--muted: #687385;
|
|
423
|
+
--surface: #ffffff;
|
|
424
|
+
--canvas: #f2f5f0;
|
|
425
|
+
--border: #d3dcd3;
|
|
426
|
+
--border-strong: #b8c4b8;
|
|
427
|
+
--focus: #163300;
|
|
428
|
+
--primary: #9fe870;
|
|
429
|
+
--primary-hover: #8fd960;
|
|
430
|
+
--primary-ink: #163300;
|
|
431
|
+
--danger: #b42318;
|
|
432
|
+
--danger-surface: #fef3f2;
|
|
433
|
+
--danger-border: #fecdca;
|
|
434
|
+
--info-bg: #eef6ff;
|
|
435
|
+
--info-border: #c7d9f5;
|
|
436
|
+
--success-text: #0d532a;
|
|
437
|
+
--warn-text: #7a5c00;
|
|
438
|
+
--radius-lg: 16px;
|
|
439
|
+
--radius-md: 12px;
|
|
440
|
+
--radius-sm: 8px;
|
|
441
|
+
--shadow: 0 1px 2px rgba(22, 51, 0, 0.06), 0 4px 16px rgba(22, 51, 0, 0.04);
|
|
442
|
+
font-family: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
443
|
+
}
|
|
444
|
+
@media (prefers-color-scheme: dark) {
|
|
445
|
+
:root {
|
|
446
|
+
--ink: #e8f0e8;
|
|
447
|
+
--ink-secondary: #c5d4c5;
|
|
448
|
+
--muted: #9aa89a;
|
|
449
|
+
--surface: #1c221c;
|
|
450
|
+
--canvas: #121612;
|
|
451
|
+
--border: #2e382e;
|
|
452
|
+
--border-strong: #3d4a3d;
|
|
453
|
+
--focus: #9fe870;
|
|
454
|
+
--primary: #9fe870;
|
|
455
|
+
--primary-hover: #b5f090;
|
|
456
|
+
--primary-ink: #163300;
|
|
457
|
+
--danger: #f97066;
|
|
458
|
+
--danger-surface: #2a1816;
|
|
459
|
+
--danger-border: #5c2f2a;
|
|
460
|
+
--info-bg: #1a2430;
|
|
461
|
+
--info-border: #3a4a62;
|
|
462
|
+
--success-text: #7ee787;
|
|
463
|
+
--warn-text: #f5d565;
|
|
464
|
+
--shadow: 0 1px 2px rgba(0,0,0,0.2), 0 4px 20px rgba(0,0,0,0.25);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
* { box-sizing: border-box; }
|
|
468
|
+
html {
|
|
469
|
+
height: 100%;
|
|
470
|
+
overflow-y: scroll;
|
|
471
|
+
scrollbar-gutter: stable;
|
|
472
|
+
}
|
|
473
|
+
body {
|
|
474
|
+
margin: 0;
|
|
475
|
+
height: 100%;
|
|
476
|
+
background: var(--canvas);
|
|
477
|
+
color: var(--ink);
|
|
478
|
+
line-height: 1.5;
|
|
479
|
+
font-size: 14px;
|
|
480
|
+
padding: clamp(0.65rem, 1.5vw, 1rem) clamp(0.75rem, 2vw, 1.25rem);
|
|
481
|
+
overflow: hidden;
|
|
482
|
+
}
|
|
483
|
+
.wrap {
|
|
484
|
+
max-width: 58rem;
|
|
485
|
+
margin: 0 auto;
|
|
486
|
+
height: 100%;
|
|
487
|
+
max-height: 100%;
|
|
488
|
+
min-height: 0;
|
|
489
|
+
display: flex;
|
|
490
|
+
flex-direction: column;
|
|
491
|
+
gap: 0.5rem;
|
|
492
|
+
}
|
|
493
|
+
.masthead {
|
|
494
|
+
display: flex;
|
|
495
|
+
flex-wrap: wrap;
|
|
496
|
+
align-items: baseline;
|
|
497
|
+
gap: 0.25rem 1.25rem;
|
|
498
|
+
flex-shrink: 0;
|
|
499
|
+
}
|
|
500
|
+
.masthead .page-title { margin: 0; }
|
|
501
|
+
.page-title {
|
|
502
|
+
font-size: clamp(1.35rem, 3vw, 1.65rem);
|
|
503
|
+
font-weight: 600;
|
|
504
|
+
letter-spacing: -0.02em;
|
|
505
|
+
margin: 0 0 0.35rem;
|
|
506
|
+
color: var(--ink);
|
|
507
|
+
line-height: 1.15;
|
|
508
|
+
}
|
|
509
|
+
.page-title .title-dot {
|
|
510
|
+
color: var(--primary);
|
|
511
|
+
font-weight: 800;
|
|
512
|
+
margin-right: 0.02em;
|
|
513
|
+
position: relative;
|
|
514
|
+
top: 0.03em;
|
|
515
|
+
}
|
|
516
|
+
.lede {
|
|
517
|
+
margin: 0;
|
|
518
|
+
color: var(--muted);
|
|
519
|
+
font-size: 0.8125rem;
|
|
520
|
+
flex: 1;
|
|
521
|
+
min-width: 14rem;
|
|
522
|
+
max-width: 36rem;
|
|
523
|
+
}
|
|
524
|
+
.info-prompt {
|
|
525
|
+
display: flex;
|
|
526
|
+
gap: 0.5rem;
|
|
527
|
+
align-items: center;
|
|
528
|
+
padding: 0.45rem 0.75rem;
|
|
529
|
+
background: var(--info-bg);
|
|
530
|
+
border: 1px solid var(--info-border);
|
|
531
|
+
border-radius: var(--radius-sm);
|
|
532
|
+
font-size: 0.75rem;
|
|
533
|
+
color: var(--ink-secondary);
|
|
534
|
+
margin: 0;
|
|
535
|
+
flex-shrink: 0;
|
|
536
|
+
}
|
|
537
|
+
.info-prompt strong { color: var(--ink); }
|
|
538
|
+
.panel-unified {
|
|
539
|
+
flex: 1;
|
|
540
|
+
min-height: 0;
|
|
541
|
+
display: flex;
|
|
542
|
+
flex-direction: column;
|
|
543
|
+
background: var(--surface);
|
|
544
|
+
border-radius: var(--radius-lg);
|
|
545
|
+
border: 1px solid var(--border);
|
|
546
|
+
box-shadow: var(--shadow);
|
|
547
|
+
overflow: hidden;
|
|
548
|
+
}
|
|
549
|
+
.action-bar {
|
|
550
|
+
flex-shrink: 0;
|
|
551
|
+
padding: 0.75rem 1rem 0.85rem;
|
|
552
|
+
border-bottom: 1px solid var(--border);
|
|
553
|
+
background: var(--canvas);
|
|
554
|
+
}
|
|
555
|
+
.action-bar-title {
|
|
556
|
+
font-size: 0.65rem;
|
|
557
|
+
font-weight: 600;
|
|
558
|
+
letter-spacing: 0.06em;
|
|
559
|
+
text-transform: uppercase;
|
|
560
|
+
color: var(--muted);
|
|
561
|
+
margin: 0 0 0.5rem;
|
|
562
|
+
}
|
|
563
|
+
.actions {
|
|
564
|
+
display: flex;
|
|
565
|
+
flex-wrap: wrap;
|
|
566
|
+
gap: 0.4rem;
|
|
567
|
+
}
|
|
568
|
+
.action-bar .hint {
|
|
569
|
+
font-size: 0.6875rem;
|
|
570
|
+
color: var(--muted);
|
|
571
|
+
margin: 0.5rem 0 0;
|
|
572
|
+
line-height: 1.4;
|
|
573
|
+
}
|
|
574
|
+
.panel-split {
|
|
575
|
+
flex: 1;
|
|
576
|
+
min-height: 0;
|
|
577
|
+
display: grid;
|
|
578
|
+
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
|
579
|
+
gap: 0;
|
|
580
|
+
}
|
|
581
|
+
.col {
|
|
582
|
+
padding: 0.85rem 1rem;
|
|
583
|
+
min-height: 0;
|
|
584
|
+
display: flex;
|
|
585
|
+
flex-direction: column;
|
|
586
|
+
}
|
|
587
|
+
.col--controls {
|
|
588
|
+
overflow-y: scroll;
|
|
589
|
+
overflow-x: hidden;
|
|
590
|
+
}
|
|
591
|
+
.col--results {
|
|
592
|
+
border-left: 1px solid var(--border);
|
|
593
|
+
background: var(--surface);
|
|
594
|
+
}
|
|
595
|
+
.panel-footer {
|
|
596
|
+
flex-shrink: 0;
|
|
597
|
+
border-top: 1px solid var(--border);
|
|
598
|
+
padding: 0.4rem 0.75rem;
|
|
599
|
+
text-align: center;
|
|
600
|
+
font-size: 0.6875rem;
|
|
601
|
+
color: var(--muted);
|
|
602
|
+
line-height: 1.4;
|
|
603
|
+
}
|
|
604
|
+
.panel-footer .heart { color: #c41e3a; }
|
|
605
|
+
.section-header {
|
|
606
|
+
font-size: 0.65rem;
|
|
607
|
+
font-weight: 600;
|
|
608
|
+
letter-spacing: 0.08em;
|
|
609
|
+
text-transform: uppercase;
|
|
610
|
+
color: var(--muted);
|
|
611
|
+
margin: 0 0 0.3rem;
|
|
612
|
+
}
|
|
613
|
+
.section-title {
|
|
614
|
+
font-size: 0.9375rem;
|
|
615
|
+
font-weight: 600;
|
|
616
|
+
margin: 0 0 0.35rem;
|
|
617
|
+
color: var(--ink);
|
|
618
|
+
letter-spacing: -0.01em;
|
|
619
|
+
}
|
|
620
|
+
.supporting {
|
|
621
|
+
font-size: 0.75rem;
|
|
622
|
+
color: var(--muted);
|
|
623
|
+
margin: 0 0 0.45rem;
|
|
624
|
+
line-height: 1.45;
|
|
625
|
+
}
|
|
626
|
+
.field { margin-bottom: 0.65rem; }
|
|
627
|
+
.field:last-child { margin-bottom: 0; }
|
|
628
|
+
.label {
|
|
629
|
+
display: block;
|
|
630
|
+
font-size: 0.75rem;
|
|
631
|
+
font-weight: 500;
|
|
632
|
+
color: var(--ink);
|
|
633
|
+
margin-bottom: 0.3rem;
|
|
634
|
+
}
|
|
635
|
+
.textarea, .input, .select {
|
|
636
|
+
width: 100%;
|
|
637
|
+
padding: 0.55rem 0.65rem;
|
|
638
|
+
border-radius: var(--radius-sm);
|
|
639
|
+
border: 1px solid var(--border-strong);
|
|
640
|
+
background: var(--surface);
|
|
641
|
+
color: var(--ink);
|
|
642
|
+
font-size: 0.875rem;
|
|
643
|
+
font-family: ui-monospace, "SF Mono", Consolas, monospace;
|
|
644
|
+
transition: border-color 0.15s, box-shadow 0.15s;
|
|
645
|
+
}
|
|
646
|
+
.textarea { min-height: 3.25rem; resize: none; line-height: 1.45; }
|
|
647
|
+
.textarea:focus, .input:focus, .select:focus {
|
|
648
|
+
outline: none;
|
|
649
|
+
border-color: var(--focus);
|
|
650
|
+
box-shadow: 0 0 0 3px rgba(159, 232, 112, 0.35);
|
|
651
|
+
}
|
|
652
|
+
.hint {
|
|
653
|
+
font-size: 0.6875rem;
|
|
654
|
+
color: var(--muted);
|
|
655
|
+
margin: 0.35rem 0 0;
|
|
656
|
+
line-height: 1.45;
|
|
657
|
+
}
|
|
658
|
+
.select { font-family: inherit; cursor: pointer; }
|
|
659
|
+
.custom-signal-wrap { margin-top: 0.45rem; display: none; }
|
|
660
|
+
.custom-signal-wrap.visible { display: block; }
|
|
661
|
+
.checkbox-row {
|
|
662
|
+
display: flex;
|
|
663
|
+
gap: 0.5rem;
|
|
664
|
+
align-items: flex-start;
|
|
665
|
+
padding: 0.1rem 0;
|
|
666
|
+
}
|
|
667
|
+
.checkbox-row input {
|
|
668
|
+
width: 1.05rem;
|
|
669
|
+
height: 1.05rem;
|
|
670
|
+
margin-top: 0.15rem;
|
|
671
|
+
accent-color: var(--primary-ink);
|
|
672
|
+
cursor: pointer;
|
|
673
|
+
}
|
|
674
|
+
.checkbox-row label {
|
|
675
|
+
font-size: 0.875rem;
|
|
676
|
+
color: var(--ink);
|
|
677
|
+
cursor: pointer;
|
|
678
|
+
line-height: 1.45;
|
|
679
|
+
}
|
|
680
|
+
.checkbox-row .hint-inline {
|
|
681
|
+
display: block;
|
|
682
|
+
font-size: 0.6875rem;
|
|
683
|
+
color: var(--muted);
|
|
684
|
+
margin-top: 0.15rem;
|
|
685
|
+
}
|
|
686
|
+
.divider {
|
|
687
|
+
height: 1px;
|
|
688
|
+
background: var(--border);
|
|
689
|
+
margin: 0.35rem 0 0.6rem;
|
|
690
|
+
border: none;
|
|
691
|
+
flex-shrink: 0;
|
|
692
|
+
}
|
|
693
|
+
.btn {
|
|
694
|
+
display: inline-flex;
|
|
695
|
+
align-items: center;
|
|
696
|
+
justify-content: center;
|
|
697
|
+
padding: 0.5rem 0.75rem;
|
|
698
|
+
border-radius: var(--radius-sm);
|
|
699
|
+
font-size: 0.8125rem;
|
|
700
|
+
font-weight: 600;
|
|
701
|
+
font-family: inherit;
|
|
702
|
+
cursor: pointer;
|
|
703
|
+
border: 1px solid transparent;
|
|
704
|
+
transition: background 0.15s, border-color 0.15s, transform 0.05s;
|
|
705
|
+
}
|
|
706
|
+
.btn:active { transform: scale(0.98); }
|
|
707
|
+
.btn:focus-visible {
|
|
708
|
+
outline: none;
|
|
709
|
+
box-shadow: 0 0 0 3px rgba(159, 232, 112, 0.45);
|
|
710
|
+
}
|
|
711
|
+
.btn-primary {
|
|
712
|
+
background: var(--primary);
|
|
713
|
+
color: var(--primary-ink);
|
|
714
|
+
border-color: var(--primary);
|
|
715
|
+
}
|
|
716
|
+
.btn-primary:hover { background: var(--primary-hover); }
|
|
717
|
+
.btn-secondary {
|
|
718
|
+
background: transparent;
|
|
719
|
+
color: var(--ink);
|
|
720
|
+
border-color: var(--border-strong);
|
|
721
|
+
}
|
|
722
|
+
.btn-secondary:hover { background: var(--canvas); }
|
|
723
|
+
.btn-danger {
|
|
724
|
+
background: var(--danger-surface);
|
|
725
|
+
color: var(--danger);
|
|
726
|
+
border-color: var(--danger-border);
|
|
727
|
+
}
|
|
728
|
+
.btn-danger:hover { filter: brightness(0.97); }
|
|
729
|
+
.fine-print {
|
|
730
|
+
font-size: 0.6875rem;
|
|
731
|
+
color: var(--muted);
|
|
732
|
+
margin: 0.45rem 0 0;
|
|
733
|
+
line-height: 1.4;
|
|
734
|
+
}
|
|
735
|
+
.danger-callout {
|
|
736
|
+
font-size: 0.6875rem;
|
|
737
|
+
color: var(--danger);
|
|
738
|
+
background: var(--danger-surface);
|
|
739
|
+
border: 1px solid var(--danger-border);
|
|
740
|
+
border-radius: var(--radius-sm);
|
|
741
|
+
padding: 0.45rem 0.55rem;
|
|
742
|
+
margin-top: 0.45rem;
|
|
743
|
+
line-height: 1.4;
|
|
744
|
+
}
|
|
745
|
+
.output-panel {
|
|
746
|
+
margin: 0;
|
|
747
|
+
flex: 1;
|
|
748
|
+
min-height: 5rem;
|
|
749
|
+
padding: 0.65rem 0.75rem;
|
|
750
|
+
border-radius: var(--radius-md);
|
|
751
|
+
border: 1px solid var(--border);
|
|
752
|
+
background: var(--canvas);
|
|
753
|
+
font-size: 0.8125rem;
|
|
754
|
+
line-height: 1.5;
|
|
755
|
+
white-space: pre-wrap;
|
|
756
|
+
word-break: break-word;
|
|
757
|
+
overflow-y: scroll;
|
|
758
|
+
overflow-x: hidden;
|
|
759
|
+
}
|
|
760
|
+
.out-ok { color: var(--success-text); }
|
|
761
|
+
.out-warn { color: var(--warn-text); }
|
|
762
|
+
.out-err { color: var(--danger); }
|
|
763
|
+
@media (max-width: 720px) {
|
|
764
|
+
html {
|
|
765
|
+
height: auto;
|
|
766
|
+
min-height: 100%;
|
|
767
|
+
overflow-y: scroll;
|
|
768
|
+
scrollbar-gutter: stable;
|
|
769
|
+
}
|
|
770
|
+
body {
|
|
771
|
+
height: auto;
|
|
772
|
+
min-height: 100%;
|
|
773
|
+
overflow-x: hidden;
|
|
774
|
+
overflow-y: visible;
|
|
775
|
+
}
|
|
776
|
+
.wrap {
|
|
777
|
+
height: auto;
|
|
778
|
+
max-height: none;
|
|
779
|
+
}
|
|
780
|
+
.panel-unified {
|
|
781
|
+
min-height: min(85vh, 40rem);
|
|
782
|
+
}
|
|
783
|
+
.panel-split {
|
|
784
|
+
grid-template-columns: 1fr;
|
|
785
|
+
}
|
|
786
|
+
.col--results {
|
|
787
|
+
border-left: none;
|
|
788
|
+
border-top: 1px solid var(--border);
|
|
789
|
+
min-height: 14rem;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
</style>
|
|
793
|
+
</head>
|
|
794
|
+
<body>
|
|
795
|
+
<div class="wrap">
|
|
796
|
+
<header class="masthead">
|
|
797
|
+
<h1 class="page-title" aria-label="portkill"><span class="title-dot" aria-hidden="true">.</span>portkill</h1>
|
|
798
|
+
<p class="lede">TCP ports on <strong>this machine</strong> \u2014 preview first, then stop only what you mean to.</p>
|
|
799
|
+
</header>
|
|
800
|
+
|
|
801
|
+
<div class="info-prompt" role="note">
|
|
802
|
+
<span aria-hidden="true">\u{1F512}</span>
|
|
803
|
+
<div><strong>Local only.</strong> Server on <code>127.0.0.1</code> \u2014 not exposed by portkill.</div>
|
|
804
|
+
</div>
|
|
805
|
+
|
|
806
|
+
<main class="panel-unified">
|
|
807
|
+
<div class="action-bar">
|
|
808
|
+
<p class="action-bar-title">Actions</p>
|
|
809
|
+
<div class="actions">
|
|
810
|
+
<button type="button" class="btn btn-primary" id="btn-list">Show listening ports</button>
|
|
811
|
+
<button type="button" class="btn btn-secondary" id="btn-run">Preview</button>
|
|
812
|
+
<button type="button" class="btn btn-danger" id="btn-kill">Stop</button>
|
|
813
|
+
</div>
|
|
814
|
+
<p class="hint">Listening ports needs no input below. Preview / Stop use ports, signal, and dry-run.</p>
|
|
815
|
+
</div>
|
|
816
|
+
|
|
817
|
+
<div class="panel-split">
|
|
818
|
+
<section class="col col--controls" aria-label="Controls">
|
|
819
|
+
<div class="field">
|
|
820
|
+
<p class="section-header">Ports</p>
|
|
821
|
+
<h2 class="section-title">Targets</h2>
|
|
822
|
+
<p class="supporting">Spaces, commas, or ranges (e.g. <code>3000-3003</code>).</p>
|
|
823
|
+
<label class="label" for="ports">Port numbers</label>
|
|
824
|
+
<textarea id="ports" class="textarea" placeholder="e.g. 3000 8080" aria-describedby="ports-hint"></textarea>
|
|
825
|
+
<p id="ports-hint" class="hint">Use Show listening ports if unsure what is open.</p>
|
|
826
|
+
</div>
|
|
827
|
+
|
|
828
|
+
<hr class="divider" />
|
|
829
|
+
|
|
830
|
+
<div class="field">
|
|
831
|
+
<p class="section-header">Signal</p>
|
|
832
|
+
<h2 class="section-title">Stop request</h2>
|
|
833
|
+
<p class="supporting">Signals are OS messages \u2014 polite shutdown vs. force.</p>
|
|
834
|
+
<label class="label" for="signal-preset">Signal</label>
|
|
835
|
+
<select id="signal-preset" class="select" aria-describedby="signal-hint">
|
|
836
|
+
<option value="SIGTERM">SIGTERM \u2014 polite (recommended)</option>
|
|
837
|
+
<option value="SIGINT">SIGINT \u2014 like Ctrl+C</option>
|
|
838
|
+
<option value="SIGKILL">SIGKILL \u2014 cannot be ignored</option>
|
|
839
|
+
<option value="custom">Custom\u2026</option>
|
|
840
|
+
</select>
|
|
841
|
+
<div id="custom-signal-wrap" class="custom-signal-wrap">
|
|
842
|
+
<label class="label" for="signal-custom">Custom</label>
|
|
843
|
+
<input type="text" id="signal-custom" class="input" placeholder="SIGUSR1 or 15" autocomplete="off" />
|
|
844
|
+
</div>
|
|
845
|
+
<p id="signal-hint" class="hint"><strong>SIGTERM</strong> for a clean exit; <strong>SIGKILL</strong> only if needed.</p>
|
|
846
|
+
</div>
|
|
847
|
+
|
|
848
|
+
<hr class="divider" />
|
|
849
|
+
|
|
850
|
+
<div class="field">
|
|
851
|
+
<div class="checkbox-row">
|
|
852
|
+
<input type="checkbox" id="dry" checked />
|
|
853
|
+
<label for="dry">
|
|
854
|
+
Preview only (dry-run)
|
|
855
|
+
<span class="hint-inline">No signal sent until you turn this off and choose Stop.</span>
|
|
856
|
+
</label>
|
|
857
|
+
</div>
|
|
858
|
+
</div>
|
|
859
|
+
|
|
860
|
+
<hr class="divider" />
|
|
861
|
+
|
|
862
|
+
<p class="fine-print">Stop asks for confirmation. Some ports may need <code>sudo</code> in Terminal.</p>
|
|
863
|
+
<div class="danger-callout" role="note"><strong>SIGKILL</strong> \u2014 possible data loss; last resort.</div>
|
|
864
|
+
</section>
|
|
865
|
+
|
|
866
|
+
<section class="col col--results" aria-label="Results">
|
|
867
|
+
<p class="section-header">Results</p>
|
|
868
|
+
<h2 class="section-title">Output</h2>
|
|
869
|
+
<div id="out" class="output-panel" role="status" aria-live="polite">Ready when you are.</div>
|
|
870
|
+
</section>
|
|
871
|
+
</div>
|
|
872
|
+
|
|
873
|
+
<footer class="panel-footer">Designed by Burak Boduroglu \xB7 Made with <span class="heart" aria-label="love">\u2665</span></footer>
|
|
874
|
+
</main>
|
|
875
|
+
</div>
|
|
876
|
+
<script>
|
|
877
|
+
(function () {
|
|
878
|
+
var out = document.getElementById("out");
|
|
879
|
+
var presetEl = document.getElementById("signal-preset");
|
|
880
|
+
var customWrap = document.getElementById("custom-signal-wrap");
|
|
881
|
+
var customEl = document.getElementById("signal-custom");
|
|
882
|
+
|
|
883
|
+
function setOut(html) { out.innerHTML = html; }
|
|
884
|
+
function toggleCustom() {
|
|
885
|
+
var show = presetEl.value === "custom";
|
|
886
|
+
customWrap.classList.toggle("visible", show);
|
|
887
|
+
customEl.disabled = !show;
|
|
888
|
+
}
|
|
889
|
+
presetEl.addEventListener("change", toggleCustom);
|
|
890
|
+
toggleCustom();
|
|
891
|
+
|
|
892
|
+
function getSignal() {
|
|
893
|
+
if (presetEl.value === "custom") {
|
|
894
|
+
return (customEl.value || "").trim() || "SIGTERM";
|
|
895
|
+
}
|
|
896
|
+
return presetEl.value;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function tokensFromInput() {
|
|
900
|
+
var raw = document.getElementById("ports").value.trim();
|
|
901
|
+
if (!raw) return [];
|
|
902
|
+
return raw.split(/[\\s,]+/).filter(Boolean);
|
|
903
|
+
}
|
|
904
|
+
function escapeHtml(s) {
|
|
905
|
+
return String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
|
906
|
+
}
|
|
907
|
+
function formatOutcome(o) {
|
|
908
|
+
switch (o.kind) {
|
|
909
|
+
case "notFound": return "Port " + o.port + " \u2014 nothing listening";
|
|
910
|
+
case "killed": return "Port " + o.port + " \u2014 stopped (" + (o.commandName || "unknown") + ", PID " + o.pid + ")";
|
|
911
|
+
case "dryRunWouldKill":
|
|
912
|
+
return "Port " + o.port + " \u2014 would stop " + (o.commandName || "unknown") + " (PID " + o.pid + "); preview only";
|
|
913
|
+
case "permissionDenied": return "Port " + o.port + " \u2014 permission denied (try sudo in Terminal)";
|
|
914
|
+
case "error": return "Port " + o.port + " \u2014 " + o.message;
|
|
915
|
+
default: return JSON.stringify(o);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
function outcomeClass(k) {
|
|
919
|
+
if (k === "killed" || k === "dryRunWouldKill") return "out-ok";
|
|
920
|
+
if (k === "notFound") return "out-warn";
|
|
921
|
+
return "out-err";
|
|
922
|
+
}
|
|
923
|
+
function apiUrl(path) {
|
|
924
|
+
return new URL(path, window.location.origin).href;
|
|
925
|
+
}
|
|
926
|
+
async function api(path, opts) {
|
|
927
|
+
var r = await fetch(apiUrl(path), opts);
|
|
928
|
+
var text = await r.text();
|
|
929
|
+
var data;
|
|
930
|
+
try { data = JSON.parse(text); } catch (e) { data = { raw: text }; }
|
|
931
|
+
if (!r.ok) throw new Error(data.message || data.error || text || String(r.status));
|
|
932
|
+
return data;
|
|
933
|
+
}
|
|
934
|
+
document.getElementById("btn-list").onclick = async function () {
|
|
935
|
+
setOut("Loading\u2026");
|
|
936
|
+
try {
|
|
937
|
+
var data = await api("/api/listeners");
|
|
938
|
+
if (!data.ok) throw new Error(data.message || "failed");
|
|
939
|
+
if (!data.rows.length) { setOut("No TCP listeners right now."); return; }
|
|
940
|
+
var lines = data.rows.map(function (row) {
|
|
941
|
+
return "Port " + row.port + " \u2014 " + row.commandName + " (PID " + row.pid + ")";
|
|
942
|
+
});
|
|
943
|
+
setOut(lines.map(function (l) {
|
|
944
|
+
return '<span class="' + outcomeClass("killed") + '">' + escapeHtml(l) + "</span>";
|
|
945
|
+
}).join("<br>"));
|
|
946
|
+
} catch (e) {
|
|
947
|
+
setOut('<span class="out-err">' + escapeHtml(String(e.message || e)) + "</span>");
|
|
948
|
+
}
|
|
949
|
+
};
|
|
950
|
+
document.getElementById("btn-run").onclick = async function () {
|
|
951
|
+
document.getElementById("dry").checked = true;
|
|
952
|
+
await resolvePorts(false);
|
|
953
|
+
};
|
|
954
|
+
document.getElementById("btn-kill").onclick = async function () {
|
|
955
|
+
var msg = "Send a stop signal to processes on these ports? Unsaved work in those apps may be lost.";
|
|
956
|
+
if (!confirm(msg)) return;
|
|
957
|
+
document.getElementById("dry").checked = false;
|
|
958
|
+
await resolvePorts(true);
|
|
959
|
+
};
|
|
960
|
+
async function resolvePorts(forceKill) {
|
|
961
|
+
var tokens = tokensFromInput();
|
|
962
|
+
if (!tokens.length) {
|
|
963
|
+
setOut('<span class="out-err">Add at least one port or range in Targets.</span>');
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
var dryRun = document.getElementById("dry").checked;
|
|
967
|
+
var signal = getSignal();
|
|
968
|
+
var force = forceKill === true;
|
|
969
|
+
setOut("Working\u2026");
|
|
970
|
+
try {
|
|
971
|
+
var data = await api("/api/resolve", {
|
|
972
|
+
method: "POST",
|
|
973
|
+
headers: { "Content-Type": "application/json" },
|
|
974
|
+
body: JSON.stringify({ tokens: tokens, dryRun: dryRun, force: force, signal: signal }),
|
|
975
|
+
});
|
|
976
|
+
if (!data.ok) throw new Error(data.message || "failed");
|
|
977
|
+
var html = (data.outcomes || []).map(function (o) {
|
|
978
|
+
return '<span class="' + outcomeClass(o.kind) + '">' + escapeHtml(formatOutcome(o)) + "</span>";
|
|
979
|
+
}).join("<br>");
|
|
980
|
+
setOut(html || ("Finished. Exit code " + data.exitCode + "."));
|
|
981
|
+
} catch (e) {
|
|
982
|
+
setOut('<span class="out-err">' + escapeHtml(String(e.message || e)) + "</span>");
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
})();
|
|
986
|
+
</script>
|
|
987
|
+
</body>
|
|
988
|
+
</html>`;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// src/gui/open-browser.ts
|
|
992
|
+
import { spawn } from "child_process";
|
|
993
|
+
function openBrowser(url) {
|
|
994
|
+
try {
|
|
995
|
+
if (process.platform === "darwin") {
|
|
996
|
+
spawn("open", [url], { detached: true, stdio: "ignore" }).unref();
|
|
997
|
+
} else if (process.platform === "linux") {
|
|
998
|
+
spawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref();
|
|
999
|
+
}
|
|
1000
|
+
} catch {
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// src/gui/server.ts
|
|
1005
|
+
var HOST = "127.0.0.1";
|
|
1006
|
+
var MAX_BODY = 64 * 1024;
|
|
1007
|
+
function applyApiCors(req, res) {
|
|
1008
|
+
const origin = req.headers.origin;
|
|
1009
|
+
if (origin) {
|
|
1010
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
1011
|
+
res.setHeader("Vary", "Origin");
|
|
1012
|
+
} else {
|
|
1013
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
1014
|
+
}
|
|
1015
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
1016
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
1017
|
+
}
|
|
1018
|
+
function json(res, req, status, body) {
|
|
1019
|
+
applyApiCors(req, res);
|
|
1020
|
+
const payload = JSON.stringify(body);
|
|
1021
|
+
res.writeHead(status, {
|
|
1022
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
1023
|
+
"Content-Length": Buffer.byteLength(payload)
|
|
1024
|
+
});
|
|
1025
|
+
res.end(payload);
|
|
1026
|
+
}
|
|
1027
|
+
async function readJsonBody(req) {
|
|
1028
|
+
const chunks = [];
|
|
1029
|
+
let total = 0;
|
|
1030
|
+
for await (const chunk of req) {
|
|
1031
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
1032
|
+
total += buf.length;
|
|
1033
|
+
if (total > MAX_BODY) {
|
|
1034
|
+
throw new Error("request body too large");
|
|
1035
|
+
}
|
|
1036
|
+
chunks.push(buf);
|
|
1037
|
+
}
|
|
1038
|
+
if (chunks.length === 0) return {};
|
|
1039
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
1040
|
+
if (!raw.trim()) return {};
|
|
1041
|
+
return JSON.parse(raw);
|
|
1042
|
+
}
|
|
1043
|
+
function startGuiServer(opts) {
|
|
1044
|
+
const requestHandler = async (req, res) => {
|
|
1045
|
+
try {
|
|
1046
|
+
const url = new URL(req.url ?? "/", `http://${HOST}`);
|
|
1047
|
+
if (req.method === "OPTIONS" && url.pathname.startsWith("/api/")) {
|
|
1048
|
+
applyApiCors(req, res);
|
|
1049
|
+
res.writeHead(204).end();
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
if (req.method === "GET" && url.pathname === "/") {
|
|
1053
|
+
const html = getIndexHtml();
|
|
1054
|
+
res.writeHead(200, {
|
|
1055
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
1056
|
+
"Content-Length": Buffer.byteLength(html, "utf8")
|
|
1057
|
+
});
|
|
1058
|
+
res.end(html);
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
if (req.method === "GET" && url.pathname === "/api/listeners") {
|
|
1062
|
+
const result = await listAllTcpListeners(opts.platform);
|
|
1063
|
+
if (!result.ok) {
|
|
1064
|
+
json(res, req, 200, { ok: false, message: result.message });
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
json(res, req, 200, { ok: true, rows: result.rows });
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
if (req.method === "POST" && url.pathname === "/api/resolve") {
|
|
1071
|
+
let body;
|
|
1072
|
+
try {
|
|
1073
|
+
body = await readJsonBody(req);
|
|
1074
|
+
} catch {
|
|
1075
|
+
json(res, req, 400, { ok: false, message: "invalid JSON body" });
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
if (!body || typeof body !== "object") {
|
|
1079
|
+
json(res, req, 400, { ok: false, message: "expected JSON object" });
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
const b = body;
|
|
1083
|
+
const tokens = b.tokens;
|
|
1084
|
+
if (!Array.isArray(tokens) || !tokens.every((t) => typeof t === "string")) {
|
|
1085
|
+
json(res, req, 400, { ok: false, message: "tokens must be an array of strings" });
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
let ports;
|
|
1089
|
+
try {
|
|
1090
|
+
ports = parsePortArguments(tokens);
|
|
1091
|
+
} catch (e) {
|
|
1092
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1093
|
+
json(res, req, 400, { ok: false, message: msg });
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
if (ports.length === 0) {
|
|
1097
|
+
json(res, req, 400, { ok: false, message: "no valid ports after parsing" });
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
1100
|
+
const dryRun = Boolean(b.dryRun);
|
|
1101
|
+
const force = Boolean(b.force);
|
|
1102
|
+
const signal = typeof b.signal === "string" ? b.signal : "SIGTERM";
|
|
1103
|
+
const { exitCode, outcomes } = await runKill({
|
|
1104
|
+
ports,
|
|
1105
|
+
dryRun,
|
|
1106
|
+
force,
|
|
1107
|
+
verbose: false,
|
|
1108
|
+
signal,
|
|
1109
|
+
platform: opts.platform
|
|
1110
|
+
});
|
|
1111
|
+
json(res, req, 200, {
|
|
1112
|
+
ok: true,
|
|
1113
|
+
exitCode,
|
|
1114
|
+
outcomes
|
|
1115
|
+
});
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
res.writeHead(404).end("not found");
|
|
1119
|
+
} catch (err) {
|
|
1120
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1121
|
+
json(res, req, 500, { ok: false, message: msg });
|
|
1122
|
+
}
|
|
1123
|
+
};
|
|
1124
|
+
const server4 = createServer(requestHandler);
|
|
1125
|
+
return new Promise((resolve, reject) => {
|
|
1126
|
+
server4.once("error", reject);
|
|
1127
|
+
server4.listen(opts.port ?? 0, HOST, () => {
|
|
1128
|
+
server4.off("error", reject);
|
|
1129
|
+
const addr = server4.address();
|
|
1130
|
+
const port = addr && typeof addr === "object" && "port" in addr ? addr.port : 0;
|
|
1131
|
+
const baseUrl = `http://${HOST}:${port}`;
|
|
1132
|
+
const v6 = createServer(requestHandler);
|
|
1133
|
+
let settled = false;
|
|
1134
|
+
const finish = (servers) => {
|
|
1135
|
+
if (settled) return;
|
|
1136
|
+
settled = true;
|
|
1137
|
+
if (opts.openBrowser !== false) {
|
|
1138
|
+
openBrowser(baseUrl);
|
|
1139
|
+
}
|
|
1140
|
+
resolve({ url: baseUrl, servers });
|
|
1141
|
+
};
|
|
1142
|
+
v6.once("error", () => finish([server4]));
|
|
1143
|
+
v6.listen(port, "::1", () => finish([server4, v6]));
|
|
1144
|
+
});
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
function attachGuiShutdown(...servers) {
|
|
1148
|
+
const shutdown = () => {
|
|
1149
|
+
const list = servers.filter(Boolean);
|
|
1150
|
+
if (list.length === 0) {
|
|
1151
|
+
process5.exit(0);
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
let pending = list.length;
|
|
1155
|
+
for (const s of list) {
|
|
1156
|
+
s.close(() => {
|
|
1157
|
+
if (--pending <= 0) process5.exit(0);
|
|
1158
|
+
});
|
|
1159
|
+
}
|
|
1160
|
+
};
|
|
1161
|
+
process5.on("SIGINT", shutdown);
|
|
1162
|
+
process5.on("SIGTERM", shutdown);
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
// src/utils/platform.ts
|
|
1166
|
+
var UnsupportedPlatformError = class extends Error {
|
|
1167
|
+
platform;
|
|
1168
|
+
constructor(platform) {
|
|
1169
|
+
super(`portkill is not supported on this platform (${platform}). Supported: macOS, Linux.`);
|
|
1170
|
+
this.name = "UnsupportedPlatformError";
|
|
1171
|
+
this.platform = platform;
|
|
1172
|
+
}
|
|
1173
|
+
};
|
|
1174
|
+
function getSupportedPlatform() {
|
|
1175
|
+
const p = process.platform;
|
|
1176
|
+
if (p === "darwin" || p === "linux") return p;
|
|
1177
|
+
throw new UnsupportedPlatformError(p);
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
// src/index.ts
|
|
1181
|
+
function readPackageVersion() {
|
|
1182
|
+
try {
|
|
1183
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
1184
|
+
const pkgPath = join(here, "..", "package.json");
|
|
1185
|
+
const raw = readFileSync(pkgPath, "utf8");
|
|
1186
|
+
const pkg = JSON.parse(raw);
|
|
1187
|
+
return pkg.version ?? "0.0.0";
|
|
1188
|
+
} catch {
|
|
1189
|
+
return "0.0.0";
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
async function main() {
|
|
1193
|
+
const program = new Command();
|
|
1194
|
+
program.name("portkill").description("Kill processes listening on given TCP ports").argument(
|
|
1195
|
+
"[ports...]",
|
|
1196
|
+
"TCP ports and/or ranges (e.g. 3000 8080 3000-3005); 1\u201365535, max 4096 ports per range"
|
|
1197
|
+
).option("-f, --force", "do not prompt for confirmation", false).option("-n, --dry-run", "show targets only; do not send signals", false).option("-s, --signal <name>", "signal to send (default: SIGTERM)", "SIGTERM").option("-v, --verbose", "verbose stderr logs", false).option("-l, --list", "list all TCP listening ports and processes", false).option("--gui", "open local web UI (127.0.0.1 only)", false).configureHelp({ helpWidth: 88 }).action(async (portsArg, options) => {
|
|
1198
|
+
const list = options.list;
|
|
1199
|
+
const gui = options.gui;
|
|
1200
|
+
if (gui && list) {
|
|
1201
|
+
process6.stderr.write("error: do not combine --gui with --list\n");
|
|
1202
|
+
process6.exitCode = 1;
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1205
|
+
if (gui && portsArg.length > 0) {
|
|
1206
|
+
process6.stderr.write("error: do not pass ports together with --gui\n");
|
|
1207
|
+
process6.exitCode = 1;
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1210
|
+
if (list && portsArg.length > 0) {
|
|
1211
|
+
process6.stderr.write("error: do not pass ports together with --list\n");
|
|
1212
|
+
process6.exitCode = 1;
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
if (list) {
|
|
1216
|
+
let platform2;
|
|
1217
|
+
try {
|
|
1218
|
+
platform2 = getSupportedPlatform();
|
|
1219
|
+
} catch (e) {
|
|
1220
|
+
if (e instanceof UnsupportedPlatformError) {
|
|
1221
|
+
process6.stderr.write(`${e.message}
|
|
1222
|
+
`);
|
|
1223
|
+
process6.exitCode = 1;
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
throw e;
|
|
1227
|
+
}
|
|
1228
|
+
const { exitCode: exitCode2, lines: lines2 } = await runList({
|
|
1229
|
+
platform: platform2,
|
|
1230
|
+
verbose: options.verbose
|
|
1231
|
+
});
|
|
1232
|
+
for (const line of lines2) {
|
|
1233
|
+
process6.stdout.write(`${line}
|
|
1234
|
+
`);
|
|
1235
|
+
}
|
|
1236
|
+
process6.exitCode = exitCode2;
|
|
1237
|
+
return;
|
|
1238
|
+
}
|
|
1239
|
+
if (gui) {
|
|
1240
|
+
let platform2;
|
|
1241
|
+
try {
|
|
1242
|
+
platform2 = getSupportedPlatform();
|
|
1243
|
+
} catch (e) {
|
|
1244
|
+
if (e instanceof UnsupportedPlatformError) {
|
|
1245
|
+
process6.stderr.write(`${e.message}
|
|
1246
|
+
`);
|
|
1247
|
+
process6.exitCode = 1;
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
throw e;
|
|
1251
|
+
}
|
|
1252
|
+
const { url, servers } = await startGuiServer({ platform: platform2 });
|
|
1253
|
+
process6.stdout.write(`portkill GUI: ${url}
|
|
1254
|
+
`);
|
|
1255
|
+
process6.stdout.write("Press Ctrl+C to stop.\n");
|
|
1256
|
+
attachGuiShutdown(...servers);
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
if (portsArg.length === 0) {
|
|
1260
|
+
program.help({ error: true });
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
1263
|
+
let ports;
|
|
1264
|
+
try {
|
|
1265
|
+
ports = parsePortArguments(portsArg);
|
|
1266
|
+
} catch (e) {
|
|
1267
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1268
|
+
process6.stderr.write(`${msg}
|
|
1269
|
+
`);
|
|
1270
|
+
process6.exitCode = 1;
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
let platform;
|
|
1274
|
+
try {
|
|
1275
|
+
platform = getSupportedPlatform();
|
|
1276
|
+
} catch (e) {
|
|
1277
|
+
if (e instanceof UnsupportedPlatformError) {
|
|
1278
|
+
process6.stderr.write(`${e.message}
|
|
1279
|
+
`);
|
|
1280
|
+
process6.exitCode = 1;
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
throw e;
|
|
1284
|
+
}
|
|
1285
|
+
const { exitCode, lines } = await runKill({
|
|
1286
|
+
ports,
|
|
1287
|
+
dryRun: options.dryRun,
|
|
1288
|
+
force: options.force,
|
|
1289
|
+
verbose: options.verbose,
|
|
1290
|
+
signal: options.signal,
|
|
1291
|
+
platform
|
|
1292
|
+
});
|
|
1293
|
+
for (const line of lines) {
|
|
1294
|
+
process6.stdout.write(`${line}
|
|
1295
|
+
`);
|
|
1296
|
+
}
|
|
1297
|
+
process6.exitCode = exitCode;
|
|
1298
|
+
});
|
|
1299
|
+
program.version(readPackageVersion(), "-V, --version", "output version number");
|
|
1300
|
+
await program.parseAsync(process6.argv);
|
|
1301
|
+
}
|
|
1302
|
+
main().catch((err) => {
|
|
1303
|
+
process6.stderr.write(err instanceof Error ? `${err.message}
|
|
1304
|
+
` : `${String(err)}
|
|
1305
|
+
`);
|
|
1306
|
+
process6.exitCode = 1;
|
|
1307
|
+
});
|
|
1308
|
+
//# sourceMappingURL=index.js.map
|