centralu 0.1.0-beta.13 → 0.1.0-beta.14

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 (2) hide show
  1. package/bin/serve.mjs +77 -2
  2. package/package.json +5 -5
package/bin/serve.mjs CHANGED
@@ -18,7 +18,7 @@
18
18
  import { spawn } from 'node:child_process'
19
19
  import { randomBytes } from 'node:crypto'
20
20
  import { chmodSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, writeSync, closeSync } from 'node:fs'
21
- import { connect } from 'node:net'
21
+ import { connect, createServer } from 'node:net'
22
22
  import { userInfo } from 'node:os'
23
23
  import { dirname, join } from 'node:path'
24
24
  import { createInterface } from 'node:readline'
@@ -34,6 +34,70 @@ import { createInterface } from 'node:readline'
34
34
  */
35
35
  export const DEFAULT_SERVE_PORT = 17175
36
36
 
37
+ /** How many ports from the recorded one `serve` tries when it is taken (#482); none at or above 20000 */
38
+ export const SERVE_PORT_TRIES = 20
39
+
40
+ /**
41
+ * The port `serve` starts the host on (#482): `{ port, from }`, `from` being the port it moved away
42
+ * from (null when it did not), or null when every candidate is taken.
43
+ *
44
+ * A port the person gave (`--port`) is used as given: they asked for that one. Otherwise the recorded
45
+ * port (else the default) when it is free, and when another program listens there, the next free
46
+ * one above it, below 20000 (the ranges `DEFAULT_SERVE_PORT` keeps out of). The host's ready line
47
+ * then records the port it got, so `--connection` reports it and the hub forwards to it.
48
+ *
49
+ * The usual other program is the other half of one Windows laptop: WSL2 relays a distro's
50
+ * 127.0.0.1 ports to Windows (`wslrelay.exe`), so a Windows install and a WSL install linked as two
51
+ * machines both wanted 17175, and whichever started second could not start at all.
52
+ * `taken(port)` answers whether something listens there.
53
+ */
54
+ export async function chooseServePort({ asked, recorded, taken }) {
55
+ if (asked !== null && asked !== undefined) return { port: asked, from: null }
56
+ const first = recorded ?? DEFAULT_SERVE_PORT
57
+ for (let p = first; p < first + SERVE_PORT_TRIES && p <= 65535; p++) {
58
+ if (p !== first && p >= 20000) break
59
+ if (!(await taken(p))) return { port: p, from: p === first ? null : first }
60
+ }
61
+ return null
62
+ }
63
+
64
+ /** Whether something listens on 127.0.0.1:`port` here: a listen that fails with EADDRINUSE (or EACCES) */
65
+ export function listensHere(port) {
66
+ return new Promise((resolve) => {
67
+ const srv = createServer()
68
+ srv.once('error', (e) => resolve(e?.code === 'EADDRINUSE' || e?.code === 'EACCES'))
69
+ srv.listen({ host: '127.0.0.1', port, exclusive: true }, () => srv.close(() => resolve(false)))
70
+ })
71
+ }
72
+
73
+ /**
74
+ * The TCP ports Windows listens on, from `netstat.exe -an -p TCP` output: a listening socket is the
75
+ * row whose foreign address is `0.0.0.0:0` (or `[::]:0`). Read that way rather than by the state
76
+ * column, which Windows translates (a Korean Windows does not print LISTENING everywhere).
77
+ */
78
+ export function parseNetstatListening(text) {
79
+ const ports = new Set()
80
+ for (const line of String(text).split(/\r?\n/)) {
81
+ const m = /^\s*TCP\s+(?:127\.0\.0\.1|0\.0\.0\.0|\[::1?\]):(\d+)\s+(?:0\.0\.0\.0:0|\[::\]:0)\s/i.exec(line)
82
+ if (m) ports.add(Number(m[1]))
83
+ }
84
+ return ports
85
+ }
86
+
87
+ /**
88
+ * The ports the Windows side listens on, seen from inside a WSL distro (empty elsewhere, or when
89
+ * Windows cannot be asked). Inside the distro a port Windows holds looks free, yet a host there
90
+ * would be unreachable from the hub: the hub's forward ends on Windows' 127.0.0.1, where the
91
+ * Windows program answers instead (#482).
92
+ */
93
+ async function windowsListening(env, platform) {
94
+ if (detachHow(platform, env) !== 'wsl') return new Set()
95
+ const netstat = windowsTool('wsl', env, ['System32', 'netstat.exe'])
96
+ if (!netstat) return new Set()
97
+ const r = await runCaptured(netstat, ['-an', '-p', 'TCP'], env)
98
+ return r.code === 0 ? parseNetstatListening(r.stdout) : new Set()
99
+ }
100
+
37
101
  /**
38
102
  * The file in the data folder that holds the token and the port, mode 0600.
39
103
  *
@@ -669,7 +733,6 @@ export async function runServe({ env, home, entry, version, cliPath, port: asked
669
733
  const dataDir = serveDataDir(env, home)
670
734
  const state = ensureServeState(dataDir, platform)
671
735
  keepLauncher(dataDir, cliPath, env)
672
- const port = askedPort ?? state.port ?? DEFAULT_SERVE_PORT
673
736
  const protocolVersion = protocolVersionOf(entry)
674
737
 
675
738
  // A serve that is already up answers on the port it recorded; say so before starting a host that
@@ -682,6 +745,18 @@ export async function runServe({ env, home, entry, version, cliPath, port: asked
682
745
  return 1
683
746
  }
684
747
 
748
+ const onWindows = await windowsListening(env, platform)
749
+ const chosen = await chooseServePort({ asked: askedPort, recorded: state.port, taken: async (p) => onWindows.has(p) || (await listensHere(p)) })
750
+ if (chosen === null) {
751
+ say(`another program listens on 127.0.0.1:${recorded} and on the ${SERVE_PORT_TRIES - 1} ports above it. Give a free one with --port <n>.`)
752
+ return 1
753
+ }
754
+ const port = chosen.port
755
+ if (chosen.from !== null) {
756
+ const who = onWindows.has(chosen.from) ? 'Windows' : 'another program'
757
+ say(`127.0.0.1:${chosen.from} is in use by ${who}; starting on ${port} instead (kept for the next start)`)
758
+ }
759
+
685
760
  const plan = hostCommand(execPath, entry, { port, dataDir, platform })
686
761
  const child = spawn(plan.command, plan.args, {
687
762
  stdio: ['pipe', 'pipe', logFd === null ? 'inherit' : 'pipe'],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "centralu",
3
- "version": "0.1.0-beta.13",
3
+ "version": "0.1.0-beta.14",
4
4
  "description": "Run, watch and steer several Claude Code and Codex CLI sessions from one window",
5
5
  "license": "MIT",
6
6
  "author": "ijun17",
@@ -37,9 +37,9 @@
37
37
  "win32"
38
38
  ],
39
39
  "optionalDependencies": {
40
- "@centralu/darwin-arm64": "0.1.0-beta.13",
41
- "@centralu/linux-arm64": "0.1.0-beta.13",
42
- "@centralu/linux-x64": "0.1.0-beta.13",
43
- "@centralu/win32-x64": "0.1.0-beta.13"
40
+ "@centralu/darwin-arm64": "0.1.0-beta.14",
41
+ "@centralu/linux-arm64": "0.1.0-beta.14",
42
+ "@centralu/linux-x64": "0.1.0-beta.14",
43
+ "@centralu/win32-x64": "0.1.0-beta.14"
44
44
  }
45
45
  }