@tidyports/guard 0.1.0 → 0.2.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 (3) hide show
  1. package/README.md +25 -4
  2. package/guard.js +67 -7
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -9,16 +9,37 @@ $ tidyports-guard npm run dev
9
9
 
10
10
  Error: listen EADDRINUSE: address already in use :::3000
11
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
12
+ :3000 is held by Claude Code, in Ghostty [PID 12345] in acme-web (feature/auth)
13
+
14
+ [1] use 3001 instead
15
+ [2] stop it (asks again first)
16
+ [enter] leave it
15
17
  ```
16
18
 
17
- That last part matters most when an agent is reading it. An agent that hits a port
19
+ Pick `1` and the port lands on your clipboard. Enter leaves everything alone, so a stray
20
+ keystroke never stops anyone's server.
21
+
22
+ Naming the owner matters most when an agent is reading it. An agent that hits a port
18
23
  conflict and can't tell it from a bug will often "fix" working code to get around the
19
24
  port — usually by editing your app's configuration. Naming the owner is what makes the
20
25
  conflict legible as a conflict.
21
26
 
27
+ ### When you get the choice, and when you don't
28
+
29
+ Not everything dies when it can't bind. Vite prints `Port 5173 is in use` and carries on
30
+ at 5174, and a prompt underneath a live dev server would be competing with its output for
31
+ the same terminal. So the choice appears only once the command has actually exited and
32
+ the terminal is free.
33
+
34
+ If it's still running — or you're piped, in CI, or an agent is reading — you get the same
35
+ answer as plain text instead, and nothing ever waits for input:
36
+
37
+ ```
38
+ [tidyports] :3000 is held by Claude Code, in Ghostty [PID 12345] in acme-web (feature/auth)
39
+ To take a different port: PORT=$(tidy-ports alloc web)
40
+ To stop it: tidy-ports kill --match 3000
41
+ ```
42
+
22
43
  ## Use it
23
44
 
24
45
  ```bash
package/guard.js CHANGED
@@ -65,14 +65,33 @@ let reported = false;
65
65
  const TAIL_MAX = 8192;
66
66
  let tail = "";
67
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 = "";
68
+ /* Two forms of the same answer, and which one you get depends on whether the
69
+ thing that failed is still running.
75
70
 
71
+ A process that cannot bind usually dies — node, rails, flask — and then the
72
+ terminal is free, you are sitting looking at the error, and the useful thing
73
+ is the choice `who` offers a person: take this other port, stop it, leave it.
74
+ But not everything dies. Vite prints "Port 5173 is in use" and carries on at
75
+ 5174, and putting a prompt under a live dev server means competing with its
76
+ output for the same terminal.
77
+
78
+ So detection starts a short timer instead of deciding immediately. If the
79
+ child is gone before it fires, the terminal belongs to us and `who` runs
80
+ interactively. If it is still alive, the plain lines print and no prompt ever
81
+ appears. Exactly one of the two happens. */
82
+ const SETTLE_MS = 400;
83
+
84
+ let pending = null;
85
+
86
+ // Can a person answer? `who` makes the same check itself, but it would see the
87
+ // pipe we hand it and never prompt, so the decision has to be made out here.
88
+ function canPrompt() {
89
+ return Boolean(
90
+ process.stdout.isTTY && !process.env.TP_PORCELAIN && !process.env.CI,
91
+ );
92
+ }
93
+
94
+ function printPlain(port) {
76
95
  const answer = whoHolds(port);
77
96
  if (!answer) {
78
97
  // No CLI, so there is nothing useful to add. Say so once, briefly, rather
@@ -85,6 +104,28 @@ function explain(chunk) {
85
104
  process.stderr.write(`\n[tidyports] ${answer}\n`);
86
105
  }
87
106
 
107
+ function explain(chunk) {
108
+ if (reported) return;
109
+ tail = (tail + chunk).slice(-TAIL_MAX);
110
+ const port = portFrom(tail);
111
+ if (!port) return;
112
+ reported = true;
113
+ tail = "";
114
+
115
+ if (!canPrompt()) {
116
+ printPlain(port);
117
+ return;
118
+ }
119
+ // Hold briefly to see whether the child is dying. `unref` so a wrapper around
120
+ // a long-lived server is never the reason the process stays up.
121
+ const timer = setTimeout(() => {
122
+ pending = null;
123
+ printPlain(port);
124
+ }, SETTLE_MS);
125
+ timer.unref?.();
126
+ pending = { port, timer };
127
+ }
128
+
88
129
  const child = spawn(argv[0], argv.slice(1), {
89
130
  stdio: ["inherit", "inherit", "pipe"],
90
131
  shell: false,
@@ -108,6 +149,25 @@ child.on("error", (err) => {
108
149
  });
109
150
 
110
151
  child.on("close", (code, signal) => {
152
+ /* The child is gone, so the terminal is ours and the prompt can't collide with
153
+ anything. Skipped on a signal: ctrl-c means you wanted out, and answering a
154
+ question is not what you asked for. stdio is inherited so `who` sees a real
155
+ tty and offers its own choice — including the second confirmation before it
156
+ stops anything. */
157
+ if (pending) {
158
+ clearTimeout(pending.timer);
159
+ const { port } = pending;
160
+ pending = null;
161
+ if (signal) {
162
+ printPlain(port);
163
+ } else {
164
+ process.stderr.write("\n");
165
+ const res = spawnSync("tidy-ports", ["who", port], { stdio: "inherit" });
166
+ // No CLI to be interactive with, so fall back to saying what we can.
167
+ if (res.error) printPlain(port);
168
+ }
169
+ }
170
+
111
171
  // Reproduce how the child ended, so this is invisible to anything upstream:
112
172
  // a shell, a CI step, or an agent reading the exit status.
113
173
  if (signal) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tidyports/guard",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "When a dev server can't bind, say who is holding the port instead of just EADDRINUSE.",
5
5
  "type": "module",
6
6
  "bin": {