@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.
Files changed (4) hide show
  1. package/README.md +83 -0
  2. package/detect.js +32 -0
  3. package/guard.js +118 -0
  4. 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
+ }