@tidyports/guard 0.1.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/README.md +83 -0
- package/detect.js +32 -0
- package/guard.js +118 -0
- package/package.json +37 -0
package/README.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# @tidyports/guard
|
|
2
|
+
|
|
3
|
+
`EADDRINUSE` tells you a port is taken. It never tells you by what.
|
|
4
|
+
|
|
5
|
+
Put this in front of your dev command and the error answers the actual question:
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
$ tidyports-guard npm run dev
|
|
9
|
+
|
|
10
|
+
Error: listen EADDRINUSE: address already in use :::3000
|
|
11
|
+
|
|
12
|
+
[tidyports] :3000 is held by Claude Code, in Ghostty [PID 12345] in acme-web (feature/auth)
|
|
13
|
+
To take a different port: PORT=$(tidy-ports alloc web)
|
|
14
|
+
To stop it: tidy-ports kill --match 3000
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
That last part matters most when an agent is reading it. An agent that hits a port
|
|
18
|
+
conflict and can't tell it from a bug will often "fix" working code to get around the
|
|
19
|
+
port — usually by editing your app's configuration. Naming the owner is what makes the
|
|
20
|
+
conflict legible as a conflict.
|
|
21
|
+
|
|
22
|
+
## Use it
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npx @tidyports/guard npm run dev
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Or wire it into the script you already run:
|
|
29
|
+
|
|
30
|
+
```json
|
|
31
|
+
{
|
|
32
|
+
"scripts": {
|
|
33
|
+
"dev": "tidyports-guard vite"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
It needs the [TidyPorts](https://tidyports.app) CLI on your `PATH` to name the owner.
|
|
39
|
+
Without it you get a one-line note instead, and nothing else changes.
|
|
40
|
+
|
|
41
|
+
## What it does to your dev loop
|
|
42
|
+
|
|
43
|
+
Nothing, by design. It is a wrapper around a command you already run, so it is worth
|
|
44
|
+
being precise about what it does not touch:
|
|
45
|
+
|
|
46
|
+
- **stdout stays a TTY.** Only stderr is intercepted, and every chunk is written through
|
|
47
|
+
before it's even inspected — so your dev server keeps its colour and its interactive
|
|
48
|
+
keys.
|
|
49
|
+
- **Exit codes and signals are reproduced exactly**, including the terminating signal, so
|
|
50
|
+
a shell, a CI step or an agent reading the status sees what it would have seen anyway.
|
|
51
|
+
- **Ctrl-C still works.** `SIGINT`, `SIGTERM` and `SIGHUP` are forwarded to the child.
|
|
52
|
+
- **Nothing is added on success.** The message appears only when a bind actually failed,
|
|
53
|
+
once, and never again for the same run.
|
|
54
|
+
|
|
55
|
+
## What it understands
|
|
56
|
+
|
|
57
|
+
The message is different in every ecosystem, so it matches the wording rather than the
|
|
58
|
+
language:
|
|
59
|
+
|
|
60
|
+
| | |
|
|
61
|
+
|---|---|
|
|
62
|
+
| Node | `listen EADDRINUSE: address already in use :::3000` |
|
|
63
|
+
| Vite | `Port 5173 is in use` |
|
|
64
|
+
| Flask / Python | `Address already in use` + `Port 5000 is in use` |
|
|
65
|
+
| Rails / Puma | `bind(2) for "127.0.0.1" port 3000` |
|
|
66
|
+
| Go | `listen tcp :8080: bind: address already in use` |
|
|
67
|
+
| Docker | `Bind for 0.0.0.0:5432 failed: port is already allocated` |
|
|
68
|
+
|
|
69
|
+
Lines that merely mention a port are deliberately ignored. A false positive would send
|
|
70
|
+
you after the wrong process, which is worse than staying quiet.
|
|
71
|
+
|
|
72
|
+
## Tests
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
npm test
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Covers the patterns above — including the ones that must not match — and the wrapper's
|
|
79
|
+
behaviour: exit codes, stderr passthrough, and a real bind failure.
|
|
80
|
+
|
|
81
|
+
## Licence
|
|
82
|
+
|
|
83
|
+
MIT
|
package/detect.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Pulling a port number out of "the port is taken", in whichever dialect the
|
|
2
|
+
// thing that failed happens to speak.
|
|
3
|
+
//
|
|
4
|
+
// Separate from guard.js so the table in test.mjs asserts against the patterns
|
|
5
|
+
// that actually ship. A regex nobody tests is a regex that quietly stops
|
|
6
|
+
// matching the day a framework rewords its error.
|
|
7
|
+
|
|
8
|
+
/* Ordered most specific first, so a line carrying both an address and a port
|
|
9
|
+
cannot match the looser trailing patterns before the exact one. */
|
|
10
|
+
export const PATTERNS = [
|
|
11
|
+
/EADDRINUSE[^\n]*?:(\d{2,5})\b/i, // node: ...address already in use :::3000
|
|
12
|
+
/bind\(2\) for [^\n]*?port (\d{2,5})/i, // puma / rails
|
|
13
|
+
/listen tcp [^\n]*?:(\d{2,5}):[^\n]*?address already in use/i, // go
|
|
14
|
+
/Bind for [^\n]*?:(\d{2,5}) failed: port is already allocated/i, // docker
|
|
15
|
+
/\bport (\d{2,5})\b[^\n]*?(?:is )?(?:already )?in use/i, // vite, flask
|
|
16
|
+
/address already in use[^\n]*?:(\d{2,5})\b/i, // generic, address last
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
// portFrom TEXT — the port as a string, or null when this isn't a bind failure.
|
|
20
|
+
// Returning null generously is the point: adding nothing is always safe, and
|
|
21
|
+
// guessing a port from an unrelated line would send someone after the wrong
|
|
22
|
+
// process.
|
|
23
|
+
export function portFrom(text) {
|
|
24
|
+
for (const re of PATTERNS) {
|
|
25
|
+
const m = text.match(re);
|
|
26
|
+
if (m) {
|
|
27
|
+
const port = Number(m[1]);
|
|
28
|
+
if (port > 0 && port < 65536) return String(port);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
package/guard.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// tidyports-guard — run your dev command through this, and when it can't bind,
|
|
3
|
+
// the error says who is holding the port instead of just naming it.
|
|
4
|
+
//
|
|
5
|
+
// tidyports-guard npm run dev
|
|
6
|
+
//
|
|
7
|
+
// Why a wrapper and not a plugin: "address already in use" is not a JavaScript
|
|
8
|
+
// problem. Vite, Next, Rails, Flask, Puma and Go all report it differently and a
|
|
9
|
+
// framework plugin would only ever cover one of them. Watching the child's
|
|
10
|
+
// stderr for the message is ugly in the abstract and works everywhere in
|
|
11
|
+
// practice, which is the right trade for a thing you only notice when it fires.
|
|
12
|
+
//
|
|
13
|
+
// Rules it holds to, because a wrapper around your dev loop has to be boring:
|
|
14
|
+
// - stdout stays inherited, so the child still sees a TTY and keeps its colour
|
|
15
|
+
// and its interactive keys. Only stderr is intercepted, and every chunk is
|
|
16
|
+
// written straight through before it is even looked at.
|
|
17
|
+
// - the child's exit code and terminating signal are reproduced exactly.
|
|
18
|
+
// - signals are forwarded, so ctrl-c still stops your dev server.
|
|
19
|
+
// - nothing is ever added on success, and it gives up quietly if the CLI is
|
|
20
|
+
// not installed.
|
|
21
|
+
|
|
22
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
23
|
+
import { portFrom } from "./detect.js";
|
|
24
|
+
|
|
25
|
+
const argv = process.argv.slice(2);
|
|
26
|
+
|
|
27
|
+
if (argv.length === 0 || argv[0] === "-h" || argv[0] === "--help") {
|
|
28
|
+
process.stderr.write(
|
|
29
|
+
"usage: tidyports-guard <command> [args...]\n" +
|
|
30
|
+
"\n" +
|
|
31
|
+
"Runs the command and, if it fails to bind a port, names what is holding it.\n" +
|
|
32
|
+
" tidyports-guard npm run dev\n" +
|
|
33
|
+
" tidyports-guard flask run\n",
|
|
34
|
+
);
|
|
35
|
+
process.exit(argv.length === 0 ? 1 : 0);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/* TP_PORCELAIN is the CLI's own "no prompt, machine-readable" flag. Without it
|
|
39
|
+
`who` would offer an interactive choice — correct when a person runs it, wrong
|
|
40
|
+
here, where it would sit waiting for an answer underneath a dev server that
|
|
41
|
+
has already died. */
|
|
42
|
+
function whoHolds(port) {
|
|
43
|
+
const res = spawnSync("tidy-ports", ["who", port], {
|
|
44
|
+
encoding: "utf8",
|
|
45
|
+
env: { ...process.env, TP_PORCELAIN: "1" },
|
|
46
|
+
timeout: 5000,
|
|
47
|
+
});
|
|
48
|
+
if (res.error || res.status === null) return null; // not installed, or hung
|
|
49
|
+
const out = (res.stdout || "").trim();
|
|
50
|
+
return out || null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let reported = false;
|
|
54
|
+
|
|
55
|
+
/* stderr arrives in whatever chunks the pipe hands over, and there is no promise
|
|
56
|
+
a line survives whole — a message split mid-sentence used to be missed
|
|
57
|
+
silently, which is the worst kind of miss, because short errors usually do
|
|
58
|
+
arrive intact and it looks like it works. So matching runs against a rolling
|
|
59
|
+
tail rather than a single chunk.
|
|
60
|
+
|
|
61
|
+
Bounded, because a dev server can log for hours: only the last few KB are
|
|
62
|
+
kept, which is far more than any one bind error, and the buffer is released
|
|
63
|
+
the moment there is nothing left to look for. The patterns never cross a
|
|
64
|
+
newline, so rejoining chunks cannot invent a match that wasn't there. */
|
|
65
|
+
const TAIL_MAX = 8192;
|
|
66
|
+
let tail = "";
|
|
67
|
+
|
|
68
|
+
function explain(chunk) {
|
|
69
|
+
if (reported) return;
|
|
70
|
+
tail = (tail + chunk).slice(-TAIL_MAX);
|
|
71
|
+
const port = portFrom(tail);
|
|
72
|
+
if (!port) return;
|
|
73
|
+
reported = true;
|
|
74
|
+
tail = "";
|
|
75
|
+
|
|
76
|
+
const answer = whoHolds(port);
|
|
77
|
+
if (!answer) {
|
|
78
|
+
// No CLI, so there is nothing useful to add. Say so once, briefly, rather
|
|
79
|
+
// than leaving the reader wondering what this wrapper is even for.
|
|
80
|
+
process.stderr.write(
|
|
81
|
+
`\n[tidyports] :${port} is taken. Install TidyPorts to see what is holding it: https://tidyports.app\n`,
|
|
82
|
+
);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
process.stderr.write(`\n[tidyports] ${answer}\n`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const child = spawn(argv[0], argv.slice(1), {
|
|
89
|
+
stdio: ["inherit", "inherit", "pipe"],
|
|
90
|
+
shell: false,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
child.stderr.on("data", (buf) => {
|
|
94
|
+
process.stderr.write(buf); // through first, always, unaltered
|
|
95
|
+
explain(buf.toString());
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
99
|
+
process.on(sig, () => {
|
|
100
|
+
if (!child.killed) child.kill(sig);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
child.on("error", (err) => {
|
|
105
|
+
const why = err.code === "ENOENT" ? `command not found: ${argv[0]}` : err.message;
|
|
106
|
+
process.stderr.write(`tidyports-guard: ${why}\n`);
|
|
107
|
+
process.exit(127);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
child.on("close", (code, signal) => {
|
|
111
|
+
// Reproduce how the child ended, so this is invisible to anything upstream:
|
|
112
|
+
// a shell, a CI step, or an agent reading the exit status.
|
|
113
|
+
if (signal) {
|
|
114
|
+
process.kill(process.pid, signal);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
process.exit(code ?? 0);
|
|
118
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tidyports/guard",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "When a dev server can't bind, say who is holding the port instead of just EADDRINUSE.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"tidyports-guard": "guard.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"guard.js",
|
|
11
|
+
"detect.js"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=18"
|
|
15
|
+
},
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/dan-fetch-studio/tidyports-integrations.git",
|
|
19
|
+
"directory": "guard"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://tidyports.app",
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"eaddrinuse",
|
|
28
|
+
"port",
|
|
29
|
+
"address-already-in-use",
|
|
30
|
+
"dev-server",
|
|
31
|
+
"localhost",
|
|
32
|
+
"ports"
|
|
33
|
+
],
|
|
34
|
+
"scripts": {
|
|
35
|
+
"test": "node test.mjs"
|
|
36
|
+
}
|
|
37
|
+
}
|