@kdonev/termscape 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 (139) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +288 -0
  3. package/dist/agents/env.d.ts +36 -0
  4. package/dist/agents/env.d.ts.map +1 -0
  5. package/dist/agents/env.js +61 -0
  6. package/dist/agents/env.js.map +1 -0
  7. package/dist/agents/profiles.d.ts +45 -0
  8. package/dist/agents/profiles.d.ts.map +1 -0
  9. package/dist/agents/profiles.js +123 -0
  10. package/dist/agents/profiles.js.map +1 -0
  11. package/dist/agents/resolve.d.ts +16 -0
  12. package/dist/agents/resolve.d.ts.map +1 -0
  13. package/dist/agents/resolve.js +71 -0
  14. package/dist/agents/resolve.js.map +1 -0
  15. package/dist/agents/router.d.ts +29 -0
  16. package/dist/agents/router.d.ts.map +1 -0
  17. package/dist/agents/router.js +83 -0
  18. package/dist/agents/router.js.map +1 -0
  19. package/dist/agents/tokens.d.ts +21 -0
  20. package/dist/agents/tokens.d.ts.map +1 -0
  21. package/dist/agents/tokens.js +39 -0
  22. package/dist/agents/tokens.js.map +1 -0
  23. package/dist/agents/wiring.d.ts +30 -0
  24. package/dist/agents/wiring.d.ts.map +1 -0
  25. package/dist/agents/wiring.js +110 -0
  26. package/dist/agents/wiring.js.map +1 -0
  27. package/dist/browser.d.ts +13 -0
  28. package/dist/browser.d.ts.map +1 -0
  29. package/dist/browser.js +34 -0
  30. package/dist/browser.js.map +1 -0
  31. package/dist/cli.d.ts +3 -0
  32. package/dist/cli.d.ts.map +1 -0
  33. package/dist/cli.js +200 -0
  34. package/dist/cli.js.map +1 -0
  35. package/dist/db/index.d.ts +14 -0
  36. package/dist/db/index.d.ts.map +1 -0
  37. package/dist/db/index.js +24 -0
  38. package/dist/db/index.js.map +1 -0
  39. package/dist/db/migrations.d.ts +15 -0
  40. package/dist/db/migrations.d.ts.map +1 -0
  41. package/dist/db/migrations.js +176 -0
  42. package/dist/db/migrations.js.map +1 -0
  43. package/dist/db/store.d.ts +71 -0
  44. package/dist/db/store.d.ts.map +1 -0
  45. package/dist/db/store.js +341 -0
  46. package/dist/db/store.js.map +1 -0
  47. package/dist/hub.d.ts +188 -0
  48. package/dist/hub.d.ts.map +1 -0
  49. package/dist/hub.js +636 -0
  50. package/dist/hub.js.map +1 -0
  51. package/dist/hub.tgz +0 -0
  52. package/dist/mcp/server.d.ts +31 -0
  53. package/dist/mcp/server.d.ts.map +1 -0
  54. package/dist/mcp/server.js +66 -0
  55. package/dist/mcp/server.js.map +1 -0
  56. package/dist/paths.d.ts +27 -0
  57. package/dist/paths.d.ts.map +1 -0
  58. package/dist/paths.js +31 -0
  59. package/dist/paths.js.map +1 -0
  60. package/dist/protocol/address.d.ts +15 -0
  61. package/dist/protocol/address.d.ts.map +1 -0
  62. package/dist/protocol/address.js +39 -0
  63. package/dist/protocol/address.js.map +1 -0
  64. package/dist/protocol/domain.d.ts +151 -0
  65. package/dist/protocol/domain.d.ts.map +1 -0
  66. package/dist/protocol/domain.js +104 -0
  67. package/dist/protocol/domain.js.map +1 -0
  68. package/dist/protocol/index.d.ts +7 -0
  69. package/dist/protocol/index.d.ts.map +1 -0
  70. package/dist/protocol/index.js +7 -0
  71. package/dist/protocol/index.js.map +1 -0
  72. package/dist/protocol/inject.d.ts +29 -0
  73. package/dist/protocol/inject.d.ts.map +1 -0
  74. package/dist/protocol/inject.js +40 -0
  75. package/dist/protocol/inject.js.map +1 -0
  76. package/dist/protocol/mcp-tools.d.ts +45 -0
  77. package/dist/protocol/mcp-tools.d.ts.map +1 -0
  78. package/dist/protocol/mcp-tools.js +46 -0
  79. package/dist/protocol/mcp-tools.js.map +1 -0
  80. package/dist/protocol/peer.d.ts +304 -0
  81. package/dist/protocol/peer.d.ts.map +1 -0
  82. package/dist/protocol/peer.js +183 -0
  83. package/dist/protocol/peer.js.map +1 -0
  84. package/dist/protocol/ws.d.ts +416 -0
  85. package/dist/protocol/ws.d.ts.map +1 -0
  86. package/dist/protocol/ws.js +119 -0
  87. package/dist/protocol/ws.js.map +1 -0
  88. package/dist/remote/deployer.d.ts +78 -0
  89. package/dist/remote/deployer.d.ts.map +1 -0
  90. package/dist/remote/deployer.js +216 -0
  91. package/dist/remote/deployer.js.map +1 -0
  92. package/dist/remote/enroll.d.ts +14 -0
  93. package/dist/remote/enroll.d.ts.map +1 -0
  94. package/dist/remote/enroll.js +244 -0
  95. package/dist/remote/enroll.js.map +1 -0
  96. package/dist/remote/join-script.d.ts +29 -0
  97. package/dist/remote/join-script.d.ts.map +1 -0
  98. package/dist/remote/join-script.js +709 -0
  99. package/dist/remote/join-script.js.map +1 -0
  100. package/dist/remote/join.d.ts +19 -0
  101. package/dist/remote/join.d.ts.map +1 -0
  102. package/dist/remote/join.js +195 -0
  103. package/dist/remote/join.js.map +1 -0
  104. package/dist/remote/lan.d.ts +45 -0
  105. package/dist/remote/lan.d.ts.map +1 -0
  106. package/dist/remote/lan.js +105 -0
  107. package/dist/remote/lan.js.map +1 -0
  108. package/dist/remote/peer-serve.d.ts +27 -0
  109. package/dist/remote/peer-serve.d.ts.map +1 -0
  110. package/dist/remote/peer-serve.js +274 -0
  111. package/dist/remote/peer-serve.js.map +1 -0
  112. package/dist/remote/peer.d.ts +62 -0
  113. package/dist/remote/peer.d.ts.map +1 -0
  114. package/dist/remote/peer.js +250 -0
  115. package/dist/remote/peer.js.map +1 -0
  116. package/dist/remote/registry.d.ts +104 -0
  117. package/dist/remote/registry.d.ts.map +1 -0
  118. package/dist/remote/registry.js +359 -0
  119. package/dist/remote/registry.js.map +1 -0
  120. package/dist/remote/tarball.d.ts +9 -0
  121. package/dist/remote/tarball.d.ts.map +1 -0
  122. package/dist/remote/tarball.js +22 -0
  123. package/dist/remote/tarball.js.map +1 -0
  124. package/dist/server.d.ts +54 -0
  125. package/dist/server.d.ts.map +1 -0
  126. package/dist/server.js +438 -0
  127. package/dist/server.js.map +1 -0
  128. package/dist/session/manager.d.ts +104 -0
  129. package/dist/session/manager.d.ts.map +1 -0
  130. package/dist/session/manager.js +421 -0
  131. package/dist/session/manager.js.map +1 -0
  132. package/dist/session/pty.d.ts +114 -0
  133. package/dist/session/pty.d.ts.map +1 -0
  134. package/dist/session/pty.js +300 -0
  135. package/dist/session/pty.js.map +1 -0
  136. package/package.json +49 -0
  137. package/web/assets/index-B2JtCtHa.css +1 -0
  138. package/web/assets/index-BOqZOoep.js +107 -0
  139. package/web/index.html +13 -0
@@ -0,0 +1,709 @@
1
+ /**
2
+ * The installers a joining machine downloads and runs.
3
+ *
4
+ * They are generated rather than shipped as files so the hub's own origin and
5
+ * a freshly minted single-use token are baked in — the person running this has
6
+ * nothing to configure and nothing to paste twice.
7
+ *
8
+ * Both scripts follow the same order, and it matters: work out what is missing
9
+ * first, and say precisely what is happening *before* changing anything. The
10
+ * SSH deploy path could only report a failure as a wall of build output in a
11
+ * tooltip; here the message lands in the terminal of the person standing at
12
+ * the keyboard.
13
+ *
14
+ * They are also deliberately chatty. Two steps take a minute or more — pulling
15
+ * ~30MB of Node, and an `npm install` that may compile native modules — and a
16
+ * terminal that prints nothing for that long is indistinguishable from one
17
+ * that has hung. Every long step therefore says what it is about to do, ticks
18
+ * while it runs, and reports how long it took.
19
+ *
20
+ * When Node is missing or too old, a private copy is fetched into
21
+ * `~/.termscape/node` rather than installed system-wide. That is deliberate:
22
+ * it needs no administrator rights, no package manager, and no PATH surgery
23
+ * that a fresh shell would have to pick up; and uninstalling is deleting one
24
+ * directory. The download is checksum-verified against nodejs.org's own
25
+ * SHASUMS256.txt, because this is a binary we are about to execute.
26
+ */
27
+ /** Shell-safe: our tokens are base64url and origins are URLs, but be explicit. */
28
+ function sq(s) {
29
+ return `'${s.replace(/'/g, `'\\''`)}'`;
30
+ }
31
+ /** The Node line we fetch from. 22 is the minimum the hub supports. */
32
+ const NODE_DIST = 'https://nodejs.org/dist/latest-v22.x';
33
+ export function joinScriptPosix(origin, token) {
34
+ return `#!/bin/sh
35
+ # Joins this machine to the Termscape canvas at ${origin}.
36
+ set -eu
37
+
38
+ HUB_URL=${sq(origin)}
39
+ JOIN_TOKEN=${sq(token)}
40
+ HOME_DIR="\${TERMSCAPE_HOME:-$HOME/.termscape}"
41
+ NODE_DIST=${sq(NODE_DIST)}
42
+
43
+ say() { printf '%s\\n' "$*"; }
44
+ note() { printf ' %s\\n' "$*"; }
45
+ step() { printf '\\n[%s] %s\\n' "$1" "$2"; }
46
+ fail() { printf '\\n%s\\n' "$*" >&2; exit 1; }
47
+
48
+ now() { date +%s; }
49
+ since() { printf '%ss' "$(( $(now) - $1 ))"; }
50
+ human() {
51
+ if [ "$1" -ge 1048576 ]; then printf '%s MB' "$(( $1 / 1048576 ))"
52
+ elif [ "$1" -ge 1024 ]; then printf '%s KB' "$(( $1 / 1024 ))"
53
+ else printf '%s B' "$1"; fi
54
+ }
55
+ size_of() { wc -c < "$1" 2>/dev/null | tr -d ' ' || echo 0; }
56
+
57
+ # $1 url, $2 destination, $3 "quiet" to suppress the progress bar
58
+ fetch() {
59
+ if command -v curl >/dev/null 2>&1; then
60
+ if [ "\${3:-}" = quiet ]; then curl -fsSL "$1" -o "$2"; else curl -f -# -L "$1" -o "$2"; fi
61
+ elif command -v wget >/dev/null 2>&1; then
62
+ if [ "\${3:-}" = quiet ]; then wget -qO "$2" "$1"; else wget -O "$2" "$1"; fi
63
+ else
64
+ fail "Neither curl nor wget is available; cannot download anything."
65
+ fi
66
+ }
67
+
68
+ fetch_stdout() {
69
+ if command -v curl >/dev/null 2>&1; then curl -fsSL "$1"; else wget -qO- "$1"; fi
70
+ }
71
+
72
+ sha256_of() {
73
+ if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1
74
+ elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | cut -d' ' -f1
75
+ else echo ''; fi
76
+ }
77
+
78
+ # Run a long command in the background, ticking so the terminal never looks
79
+ # hung. A finished child stays a zombie until it is reaped, so kill -0 would
80
+ # spin forever; a marker file is the reliable POSIX signal that it is done.
81
+ run_ticking() {
82
+ LABEL="$1"; shift
83
+ RC_FILE="$HOME_DIR/.step.rc"
84
+ rm -f "$RC_FILE"
85
+ ( "$@" >>"$HOME_DIR/install.log" 2>&1; echo $? >"$RC_FILE" ) &
86
+ T0=$(now)
87
+ printf ' %s ' "$LABEL"
88
+ while [ ! -f "$RC_FILE" ]; do
89
+ printf '.'
90
+ sleep 2
91
+ done
92
+ RC=$(cat "$RC_FILE"); rm -f "$RC_FILE"
93
+ printf ' %s\\n' "$(since "$T0")"
94
+ return "$RC"
95
+ }
96
+
97
+ # A hub from an earlier join is still running and still holds its own install
98
+ # open, so it has to go before those files can be replaced. It also stops this
99
+ # machine appearing twice on the canvas.
100
+ stop_running_hub() {
101
+ OLD_PID=""
102
+ [ -f "$HOME_DIR/hub.pid" ] && OLD_PID=$(cat "$HOME_DIR/hub.pid" 2>/dev/null || true)
103
+ # A hub predating the pid file, or one whose file was lost.
104
+ if [ -z "$OLD_PID" ] && command -v pgrep >/dev/null 2>&1; then
105
+ OLD_PID=$(pgrep -f "$HOME_DIR/hub/dist/cli.js" 2>/dev/null | head -1 || true)
106
+ [ -n "$OLD_PID" ] || OLD_PID=$(pgrep -f "TERMSCAPE_HOME=$HOME_DIR" 2>/dev/null | head -1 || true)
107
+ fi
108
+ [ -n "$OLD_PID" ] || return 0
109
+ kill -0 "$OLD_PID" 2>/dev/null || return 0
110
+
111
+ note "stopping the hub already running here (pid $OLD_PID)"
112
+ kill "$OLD_PID" 2>/dev/null || true
113
+ i=0
114
+ while [ $i -lt 40 ]; do
115
+ kill -0 "$OLD_PID" 2>/dev/null || { rm -f "$HOME_DIR/hub.pid"; return 0; }
116
+ i=$((i + 1))
117
+ sleep 0.25
118
+ done
119
+ kill -9 "$OLD_PID" 2>/dev/null || true
120
+ sleep 1
121
+ rm -f "$HOME_DIR/hub.pid"
122
+ }
123
+
124
+ mkdir -p "$HOME_DIR"
125
+ say ""
126
+ say "Termscape - joining $HUB_URL"
127
+
128
+ # --- node -------------------------------------------------------------------
129
+ NODE_BIN=node
130
+ NPM_BIN=npm
131
+
132
+ node_major() { "$1" -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0; }
133
+
134
+ use_private_node() {
135
+ NODE_BIN="$HOME_DIR/node/bin/node"
136
+ NPM_BIN="$HOME_DIR/node/bin/npm"
137
+ PATH="$HOME_DIR/node/bin:$PATH"
138
+ export PATH
139
+ }
140
+
141
+ install_node() {
142
+ case "$(uname -s)" in
143
+ Darwin) OS=darwin ;;
144
+ Linux) OS=linux ;;
145
+ *) fail "Unsupported platform $(uname -s); install Node 22 yourself and re-run." ;;
146
+ esac
147
+ case "$(uname -m)" in
148
+ x86_64|amd64) ARCH=x64 ;;
149
+ arm64|aarch64) ARCH=arm64 ;;
150
+ *) fail "Unsupported CPU $(uname -m); install Node 22 yourself and re-run." ;;
151
+ esac
152
+
153
+ note "looking up the latest Node 22 for \${OS}-\${ARCH}"
154
+ SUMS=$(fetch_stdout "$NODE_DIST/SHASUMS256.txt") \\
155
+ || fail "Could not reach $NODE_DIST - check this machine's internet access."
156
+
157
+ LINE=$(printf '%s\\n' "$SUMS" | grep -- "-\${OS}-\${ARCH}\\.tar\\.gz\$" | head -1)
158
+ [ -n "$LINE" ] || fail "nodejs.org has no \${OS}-\${ARCH} build on the v22 line."
159
+ WANT_SHA=$(printf '%s' "$LINE" | awk '{print $1}')
160
+ TARBALL=$(printf '%s' "$LINE" | awk '{print $2}')
161
+
162
+ note "downloading $TARBALL (about 30 MB)"
163
+ T0=$(now)
164
+ fetch "$NODE_DIST/$TARBALL" "$HOME_DIR/$TARBALL"
165
+ note "downloaded $(human "$(size_of "$HOME_DIR/$TARBALL")") in $(since "$T0")"
166
+
167
+ # We are about to execute this, so verify it rather than trusting the pipe.
168
+ printf ' verifying checksum ... '
169
+ GOT_SHA=$(sha256_of "$HOME_DIR/$TARBALL")
170
+ if [ -z "$GOT_SHA" ]; then
171
+ say "skipped (no sha256 tool on this machine)"
172
+ elif [ "$GOT_SHA" != "$WANT_SHA" ]; then
173
+ say "FAILED"
174
+ rm -f "$HOME_DIR/$TARBALL"
175
+ fail "Checksum mismatch on $TARBALL. Refusing to run it."
176
+ else
177
+ say "ok"
178
+ fi
179
+
180
+ note "unpacking into $HOME_DIR/node"
181
+ rm -rf "$HOME_DIR/node"
182
+ mkdir -p "$HOME_DIR/node"
183
+ tar xzf "$HOME_DIR/$TARBALL" -C "$HOME_DIR/node" --strip-components=1
184
+ rm -f "$HOME_DIR/$TARBALL"
185
+
186
+ use_private_node
187
+ note "installed $("$NODE_BIN" --version)"
188
+ }
189
+
190
+ step 1/4 "Node 22"
191
+ if [ -x "$HOME_DIR/node/bin/node" ] && [ "$(node_major "$HOME_DIR/node/bin/node")" -ge 22 ]; then
192
+ use_private_node
193
+ note "$("$NODE_BIN" --version) already here (private copy)"
194
+ elif command -v node >/dev/null 2>&1 && [ "$(node_major node)" -ge 22 ]; then
195
+ note "$(node --version) already on this machine"
196
+ elif command -v node >/dev/null 2>&1; then
197
+ note "$(node --version) is older than 22; fetching a private copy"
198
+ install_node
199
+ else
200
+ note "not installed; fetching a private copy"
201
+ install_node
202
+ fi
203
+
204
+ # --- the hub ----------------------------------------------------------------
205
+ step 2/4 "hub package"
206
+ note "downloading from $HUB_URL"
207
+ fetch "$HUB_URL/hub.tgz" "$HOME_DIR/hub.tgz" quiet
208
+ note "got $(human "$(size_of "$HOME_DIR/hub.tgz")")"
209
+
210
+ stop_running_hub
211
+
212
+ # The dependency tree is the slowest thing here to rebuild and the tarball
213
+ # never carries one, so it is moved aside rather than deleted along with the
214
+ # rest of the install. Whether it can be kept is step 3's question.
215
+ KEPT_MODULES="$HOME_DIR/node_modules.kept"
216
+ rm -rf "$KEPT_MODULES"
217
+ if [ -d "$HOME_DIR/hub/node_modules" ]; then
218
+ mv "$HOME_DIR/hub/node_modules" "$KEPT_MODULES"
219
+ fi
220
+
221
+ rm -rf "$HOME_DIR/hub"
222
+ mkdir -p "$HOME_DIR/hub"
223
+ tar xzf "$HOME_DIR/hub.tgz" -C "$HOME_DIR/hub" --strip-components=1
224
+ if [ -d "$KEPT_MODULES" ]; then
225
+ mv "$KEPT_MODULES" "$HOME_DIR/hub/node_modules"
226
+ fi
227
+ note "unpacked into $HOME_DIR/hub"
228
+
229
+ # --- install ----------------------------------------------------------------
230
+ step 3/4 "dependencies"
231
+ note "full log: $HOME_DIR/install.log"
232
+ cd "$HOME_DIR/hub"
233
+ : >"$HOME_DIR/install.log"
234
+
235
+ # What the tree already here would have to match to be worth keeping: the
236
+ # fingerprint the tarball shipped, plus the two things it cannot know - the
237
+ # Node ABI these modules were built against, and the platform.
238
+ STAMP_FILE="$HOME_DIR/deps.stamp"
239
+ FINGERPRINT=$(cat "$HOME_DIR/hub/deps.fingerprint" 2>/dev/null || echo none)
240
+ NODE_ABI=$("$NODE_BIN" -p 'process.versions.node.split(".")[0] + "-" + process.platform + "-" + process.arch')
241
+ WANT_STAMP="$FINGERPRINT-$NODE_ABI"
242
+
243
+ # The stamp claims this tree still works. Confirming it does costs one Node
244
+ # start, and is the whole difference between an optimisation and a machine
245
+ # that joins with a hub unable to load its own modules.
246
+ deps_reusable() {
247
+ [ -d "$HOME_DIR/hub/node_modules" ] || return 1
248
+ [ "$(cat "$STAMP_FILE" 2>/dev/null || true)" = "$WANT_STAMP" ] || return 1
249
+ "$NODE_BIN" -e "require('node-pty'); require('better-sqlite3')" >>"$HOME_DIR/install.log" 2>&1
250
+ }
251
+
252
+ install_deps() {
253
+ note "this is the slow part - a minute or two on a first run"
254
+ # A half-finished tree must not inherit the last run's stamp.
255
+ rm -f "$STAMP_FILE"
256
+
257
+ # Prebuilt binaries first: node-pty and better-sqlite3 publish them for the
258
+ # mainstream platforms, and downloading one beats compiling it every time.
259
+ if run_ticking "installing" "$NPM_BIN" install --omit=dev --no-audit --no-fund; then
260
+ return 0
261
+ fi
262
+ note "no prebuilt binaries for this platform; compiling instead"
263
+ if run_ticking "compiling" "$NPM_BIN" install --omit=dev --no-audit --no-fund --build-from-source; then
264
+ return 0
265
+ fi
266
+
267
+ tail -20 "$HOME_DIR/install.log" >&2
268
+ case "$(uname -s)" in
269
+ Darwin) TOOLCHAIN="xcode-select --install" ;;
270
+ *) TOOLCHAIN="sudo apt install build-essential python3 # or your distro's equivalent" ;;
271
+ esac
272
+ fail \\
273
+ "Could not install node-pty and better-sqlite3.
274
+ This machine has no prebuilt binaries and no C++ toolchain:
275
+ $TOOLCHAIN
276
+ Full log: $HOME_DIR/install.log
277
+ Then re-run this command."
278
+ }
279
+
280
+ if deps_reusable; then
281
+ note "unchanged since the last run - keeping the modules already here"
282
+ else
283
+ install_deps
284
+ echo "$WANT_STAMP" >"$STAMP_FILE"
285
+ fi
286
+
287
+ # --- join -------------------------------------------------------------------
288
+ step 4/4 "connecting to $HUB_URL"
289
+ TERMSCAPE_HOME="$HOME_DIR" nohup "$NODE_BIN" "$HOME_DIR/hub/dist/cli.js" \\
290
+ --headless --port 0 --join "$HUB_URL" --join-token "$JOIN_TOKEN" \\
291
+ >"$HOME_DIR/hub.log" 2>&1 &
292
+ HUB_PID=$!
293
+
294
+ # Wait for the join itself, not merely for the hub to start listening. A hub
295
+ # that is up but was refused by the canvas is not joined, and saying otherwise
296
+ # is how someone ends up staring at a host that never appears.
297
+ T0=$(now)
298
+ printf ' starting the hub '
299
+ i=0
300
+ while [ $i -lt 360 ]; do
301
+ if grep -q TERMSCAPE_JOINED= "$HOME_DIR/hub.log" 2>/dev/null; then
302
+ printf ' %s\\n' "$(since "$T0")"
303
+ say ""
304
+ say "Joined. This machine is on the canvas at $HUB_URL"
305
+ note "it keeps running in the background, and rejoins by itself"
306
+ note "log: $HOME_DIR/hub.log"
307
+ say ""
308
+ exit 0
309
+ fi
310
+ if grep -q TERMSCAPE_JOIN_FAILED= "$HOME_DIR/hub.log" 2>/dev/null; then
311
+ printf '\\n'
312
+ REASON=$(grep -m1 TERMSCAPE_JOIN_FAILED= "$HOME_DIR/hub.log" | sed 's/.*TERMSCAPE_JOIN_FAILED=//')
313
+ fail \
314
+ "$HUB_URL refused this machine:
315
+ $REASON
316
+ Open $HUB_URL/join on this machine for a fresh command - a join key is
317
+ single-use and expires after 15 minutes."
318
+ fi
319
+ # Checked last: a refusal, read just above, explains an exit far better
320
+ # than the bare fact of the exit does.
321
+ if ! kill -0 "$HUB_PID" 2>/dev/null; then
322
+ printf '\\n'
323
+ tail -20 "$HOME_DIR/hub.log" >&2 || true
324
+ fail "The hub exited without joining. See $HOME_DIR/hub.log"
325
+ fi
326
+ printf '.'
327
+ i=$((i + 1))
328
+ sleep 0.5
329
+ done
330
+
331
+ printf '\\n'
332
+ tail -20 "$HOME_DIR/hub.log" >&2 || true
333
+ fail "The hub started but never reported joining $HUB_URL. See $HOME_DIR/hub.log"
334
+ `;
335
+ }
336
+ export function joinScriptPowerShell(origin, token) {
337
+ return `# Joins this machine to the Termscape canvas at ${origin}.
338
+ $ErrorActionPreference = 'Stop'
339
+ $ProgressPreference = 'SilentlyContinue' # PS5.1's progress bar cripples Invoke-WebRequest
340
+
341
+ $HubUrl = ${sq(origin)}
342
+ $JoinToken = ${sq(token)}
343
+ $HomeDir = if ($env:TERMSCAPE_HOME) { $env:TERMSCAPE_HOME } else { Join-Path $HOME '.termscape' }
344
+ $NodeDist = ${sq(NODE_DIST)}
345
+
346
+ # Write-Error, not exit: with $ErrorActionPreference = 'Stop' this ends the
347
+ # script, and "exit" inside "irm ... | iex" closes the user's whole console -
348
+ # taking the message they need with it.
349
+ function Fail($msg) { Write-Host ""; Write-Error $msg }
350
+ function Note($msg) { Write-Host " $msg" }
351
+ function Step($n, $title) { Write-Host ""; Write-Host "[$n] $title" }
352
+ function Human($bytes) {
353
+ if ($bytes -ge 1MB) { return "{0:N1} MB" -f ($bytes / 1MB) }
354
+ if ($bytes -ge 1KB) { return "{0:N0} KB" -f ($bytes / 1KB) }
355
+ return "$bytes B"
356
+ }
357
+
358
+ function Get-NodeMajor($exe) {
359
+ # Deliberately not "node -p <expression>": PowerShell strips the inner
360
+ # quotes on the way to a native exe, so node would see .split(.)[0].
361
+ try {
362
+ $v = & $exe --version
363
+ if ($v -match '^v(\\d+)\\.') { return [int]$Matches[1] }
364
+ return 0
365
+ } catch { return 0 }
366
+ }
367
+
368
+ # curl.exe ships with Windows 10 1803 and newer - the same vintage as tar,
369
+ # which this script already needs - and unlike Invoke-WebRequest it can show a
370
+ # progress bar without the PS5.1 slowdown.
371
+ $CurlExe = Get-Command curl.exe -ErrorAction SilentlyContinue
372
+
373
+ function Fetch($url, $dest, $quiet) {
374
+ if ($CurlExe -and -not $quiet) {
375
+ & $CurlExe.Source -f -# -L $url -o $dest
376
+ if ($LASTEXITCODE -ne 0) { Fail "Download failed: $url" }
377
+ } else {
378
+ Invoke-WebRequest -Uri $url -OutFile $dest -UseBasicParsing
379
+ }
380
+ }
381
+
382
+ # Long commands run through cmd.exe as a detached process, for two reasons.
383
+ #
384
+ # The tick: a terminal that prints nothing for a minute looks like one that has
385
+ # hung, so we poll and print a dot.
386
+ #
387
+ # And the redirection has to happen outside PowerShell. In 5.1, sending a
388
+ # native command's stderr through a PowerShell stream ("npm ... *> $log") wraps
389
+ # every line in an ErrorRecord, so with $ErrorActionPreference = 'Stop' a
390
+ # perfectly healthy "npm notice" on stderr kills the script. Start-Process
391
+ # redirects at the OS level and never touches PowerShell's error stream.
392
+ #
393
+ # node running npm's own entrypoint, rather than npm.cmd: CreateProcess cannot
394
+ # launch a .cmd without an interpreter once UseShellExecute is off, which is
395
+ # what redirecting switches off, and going through cmd.exe would drag in its
396
+ # quoting rules for no benefit. $argString is one pre-quoted string because
397
+ # -ArgumentList joins an array on spaces without quoting anything.
398
+ function Invoke-Ticking($label, $exe, $argString, $log) {
399
+ Write-Host " $label " -NoNewline
400
+ $sw = [Diagnostics.Stopwatch]::StartNew()
401
+ $p = Start-Process -FilePath $exe -ArgumentList $argString \`
402
+ -WorkingDirectory (Get-Location).Path -NoNewWindow -PassThru \`
403
+ -RedirectStandardOutput $log -RedirectStandardError "$log.err"
404
+ # Touching .Handle caches it. Without that, a process from
405
+ # Start-Process -PassThru reports a null ExitCode once it has exited,
406
+ # and a successful install reads as a failure.
407
+ $null = $p.Handle
408
+ while (-not $p.HasExited) {
409
+ Write-Host "." -NoNewline
410
+ Start-Sleep -Seconds 2
411
+ }
412
+ $p.WaitForExit()
413
+ Write-Host " $([int]$sw.Elapsed.TotalSeconds)s"
414
+ if ($null -eq $p.ExitCode) { Fail "Could not read the exit code of $label" }
415
+ return $p.ExitCode
416
+ }
417
+
418
+ # A hub from an earlier join is still running and still holds its own install
419
+ # open. On Windows a loaded .node cannot be deleted at all, so without this the
420
+ # whole re-join dies on an access-denied removing better-sqlite3. It also stops
421
+ # this machine appearing twice on the canvas.
422
+ function Stop-RunningHub($hubDir) {
423
+ $pidFile = Join-Path $HomeDir 'hub.pid'
424
+ $targets = @()
425
+
426
+ if (Test-Path $pidFile) {
427
+ $recorded = (Get-Content $pidFile -Raw).Trim()
428
+ if ($recorded -match '^\\d+$') {
429
+ $p = Get-Process -Id ([int]$recorded) -ErrorAction SilentlyContinue
430
+ if ($p) { $targets += $p }
431
+ }
432
+ }
433
+
434
+ # A hub predating the pid file, or one whose file was lost: find it by the
435
+ # install it was started from.
436
+ if (-not $targets) {
437
+ $targets = @(Get-CimInstance Win32_Process -Filter "Name='node.exe'" -ErrorAction SilentlyContinue |
438
+ Where-Object {
439
+ $_.CommandLine -and
440
+ ($_.CommandLine -like "*$hubDir*" -or $_.CommandLine -like "*$HomeDir*")
441
+ } |
442
+ ForEach-Object { Get-Process -Id $_.ProcessId -ErrorAction SilentlyContinue })
443
+ }
444
+
445
+ foreach ($p in $targets) {
446
+ Note "stopping the hub already running here (pid $($p.Id))"
447
+ try {
448
+ $p.CloseMainWindow() | Out-Null
449
+ Stop-Process -Id $p.Id -Force -ErrorAction Stop
450
+ $p.WaitForExit(10000) | Out-Null
451
+ } catch {
452
+ Note "could not stop pid $($p.Id): $($_.Exception.Message)"
453
+ }
454
+ }
455
+ # Windows releases the file handles a moment after the process goes.
456
+ if ($targets) { Start-Sleep -Milliseconds 750 }
457
+ Remove-Item $pidFile -Force -ErrorAction SilentlyContinue
458
+ }
459
+
460
+ New-Item -ItemType Directory -Force -Path $HomeDir | Out-Null
461
+ Write-Host ""
462
+ Write-Host "Termscape - joining $HubUrl"
463
+
464
+ # --- node -------------------------------------------------------------------
465
+ # A private copy under ~/.termscape, not a system install: no administrator
466
+ # rights, no package manager, and no new shell needed to pick up a PATH change.
467
+ function Install-Node {
468
+ $arch = if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64') { 'arm64' } else { 'x64' }
469
+ Note "looking up the latest Node 22 for win-$arch"
470
+
471
+ try {
472
+ $sums = (Invoke-WebRequest -Uri "$NodeDist/SHASUMS256.txt" -UseBasicParsing).Content
473
+ } catch {
474
+ Fail "Could not reach $NodeDist - check this machine's internet access.\`n$($_.Exception.Message)"
475
+ }
476
+
477
+ $line = $sums -split "\`n" | Where-Object { $_ -match "node-v[\\d.]+-win-$arch\\.zip\\s*$" } | Select-Object -First 1
478
+ if (-not $line) { Fail "nodejs.org has no win-$arch build on the v22 line." }
479
+ $fields = ($line.Trim() -split '\\s+')
480
+ $wantSha = $fields[0]
481
+ $zipName = $fields[1]
482
+
483
+ $zip = Join-Path $HomeDir $zipName
484
+ Note "downloading $zipName (about 30 MB)"
485
+ $sw = [Diagnostics.Stopwatch]::StartNew()
486
+ Fetch "$NodeDist/$zipName" $zip $false
487
+ Note "downloaded $(Human (Get-Item $zip).Length) in $([int]$sw.Elapsed.TotalSeconds)s"
488
+
489
+ # We are about to execute this, so verify it rather than trusting the pipe.
490
+ Write-Host " verifying checksum ... " -NoNewline
491
+ $gotSha = (Get-FileHash -Path $zip -Algorithm SHA256).Hash.ToLower()
492
+ if ($gotSha -ne $wantSha.ToLower()) {
493
+ Write-Host "FAILED"
494
+ Remove-Item $zip -Force
495
+ Fail "Checksum mismatch on $zipName. Refusing to run it."
496
+ }
497
+ Write-Host "ok"
498
+
499
+ Note "unpacking into $HomeDir\\node"
500
+ $staging = Join-Path $HomeDir 'node-unzip'
501
+ if (Test-Path $staging) { Remove-Item -Recurse -Force $staging }
502
+ Expand-Archive -Path $zip -DestinationPath $staging -Force
503
+ Remove-Item $zip -Force
504
+
505
+ # The zip wraps everything in node-vX.Y.Z-win-arch\\; lift that up a level so
506
+ # the path is stable across versions.
507
+ $inner = Get-ChildItem -Path $staging -Directory | Select-Object -First 1
508
+ $target = Join-Path $HomeDir 'node'
509
+ if (Test-Path $target) { Remove-Item -Recurse -Force $target }
510
+ Move-Item -Path $inner.FullName -Destination $target
511
+ Remove-Item -Recurse -Force $staging
512
+
513
+ Note "installed $(& (Join-Path $target 'node.exe') --version)"
514
+ }
515
+
516
+ Step '1/4' 'Node 22'
517
+ $privateNode = Join-Path $HomeDir 'node\\node.exe'
518
+
519
+ if ((Test-Path $privateNode) -and (Get-NodeMajor $privateNode) -ge 22) {
520
+ Note "$(& $privateNode --version) already here (private copy)"
521
+ } elseif ((Get-Command node -ErrorAction SilentlyContinue) -and (Get-NodeMajor 'node') -ge 22) {
522
+ Note "$(node --version) already on this machine"
523
+ } else {
524
+ if (Get-Command node -ErrorAction SilentlyContinue) {
525
+ Note "$(node --version) is older than 22; fetching a private copy"
526
+ } else {
527
+ Note "not installed; fetching a private copy"
528
+ }
529
+ Install-Node
530
+ }
531
+
532
+ if (Test-Path $privateNode) {
533
+ $NodeExe = $privateNode
534
+ $NodeHome = Join-Path $HomeDir 'node'
535
+ # Anything npm shells out to must find the private copy first.
536
+ $env:PATH = $NodeHome + ';' + $env:PATH
537
+ } else {
538
+ $NodeExe = (Get-Command node).Source
539
+ $NodeHome = Split-Path $NodeExe -Parent
540
+ }
541
+ $NpmCli = Join-Path $NodeHome 'node_modules\\npm\\bin\\npm-cli.js'
542
+ if (-not (Test-Path $NpmCli)) { Fail "Could not find npm alongside $NodeExe" }
543
+
544
+ # --- the hub ----------------------------------------------------------------
545
+ Step '2/4' 'hub package'
546
+ Note "downloading from $HubUrl"
547
+ $tgz = Join-Path $HomeDir 'hub.tgz'
548
+ Fetch "$HubUrl/hub.tgz" $tgz $true
549
+ Note "got $(Human (Get-Item $tgz).Length)"
550
+
551
+ $hubDir = Join-Path $HomeDir 'hub'
552
+ $hubModules = Join-Path $hubDir 'node_modules'
553
+ Stop-RunningHub $hubDir
554
+
555
+ # The dependency tree is the slowest thing here to rebuild and the tarball
556
+ # never carries one, so it is moved aside rather than deleted along with the
557
+ # rest of the install. Whether it can be kept is step 3's question. It also
558
+ # means far less for the removal below to fail on: a loaded .node is the file
559
+ # Windows refuses to delete.
560
+ $keptModules = Join-Path $HomeDir 'node_modules.kept'
561
+ if (Test-Path $keptModules) { Remove-Item -Recurse -Force $keptModules }
562
+ if (Test-Path $hubModules) { Move-Item $hubModules $keptModules }
563
+
564
+ if (Test-Path $hubDir) {
565
+ try {
566
+ Remove-Item -Recurse -Force $hubDir
567
+ } catch {
568
+ Fail ("Could not replace the previous install at $hubDir - something still has a file open there." + [Environment]::NewLine +
569
+ "A hub from an earlier join is probably still running. Close it and re-run this command." + [Environment]::NewLine +
570
+ $_.Exception.Message)
571
+ }
572
+ }
573
+ New-Item -ItemType Directory -Force -Path $hubDir | Out-Null
574
+ # tar ships with Windows 10 1803 and newer.
575
+ tar xzf $tgz -C $hubDir --strip-components=1
576
+ if (Test-Path $keptModules) { Move-Item $keptModules $hubModules }
577
+ Note "unpacked into $hubDir"
578
+
579
+ # --- install ----------------------------------------------------------------
580
+ Step '3/4' 'dependencies'
581
+ $log = Join-Path $HomeDir 'install.log'
582
+ Note "full log: $log"
583
+ Push-Location $hubDir
584
+
585
+ # What the tree already here would have to match to be worth keeping: the
586
+ # fingerprint the tarball shipped, plus the two things it cannot know - the
587
+ # Node ABI these modules were built against, and the platform.
588
+ $stampFile = Join-Path $HomeDir 'deps.stamp'
589
+ $fingerprintFile = Join-Path $hubDir 'deps.fingerprint'
590
+ $fingerprint = 'none'
591
+ if (Test-Path $fingerprintFile) {
592
+ $fingerprint = ((Get-Content $fingerprintFile -TotalCount 1) + '').Trim()
593
+ }
594
+ # Single quotes inside a double-quoted string, deliberately: PowerShell
595
+ # strips a double quote out of an argument on its way to a native command,
596
+ # and this expression would reach node as unparseable JavaScript.
597
+ $abiJs = "process.versions.node.split('.')[0] + '-' + process.platform + '-' + process.arch"
598
+ $nodeAbi = & $NodeExe -p $abiJs
599
+ $wantStamp = "$fingerprint-$nodeAbi"
600
+ $haveStamp = ''
601
+ if (Test-Path $stampFile) { $haveStamp = ((Get-Content $stampFile -TotalCount 1) + '').Trim() }
602
+
603
+ $reusable = $false
604
+ if ((Test-Path $hubModules) -and ($haveStamp -eq $wantStamp)) {
605
+ # The stamp claims this tree still works. Confirming it does costs one Node
606
+ # start, and is the whole difference between an optimisation and a machine
607
+ # that joins with a hub unable to load its own modules. The probe catches
608
+ # its own errors and reports through stdout and an exit code, so nothing
609
+ # reaches PowerShell's error stream - where 5.1 turns a native command's
610
+ # stderr into a terminating error.
611
+ $probe = "try { require('node-pty'); require('better-sqlite3') } catch (e) { console.log('dependency probe failed: ' + e.message); process.exit(1) }"
612
+ & $NodeExe -e $probe > $log
613
+ $reusable = ($LASTEXITCODE -eq 0)
614
+ }
615
+
616
+ if ($reusable) {
617
+ Note "unchanged since the last run - keeping the modules already here"
618
+ } else {
619
+ Note "this is the slow part - a minute or two on a first run"
620
+ # A half-finished tree must not inherit the last run's stamp.
621
+ if (Test-Path $stampFile) { Remove-Item $stampFile -Force }
622
+
623
+ # Prebuilt binaries first; compiling is the fallback, not the default.
624
+ $npmArgs = '"' + $NpmCli + '" install --omit=dev --no-audit --no-fund'
625
+ $rc = Invoke-Ticking 'installing' $NodeExe $npmArgs $log
626
+ if ($rc -ne 0) {
627
+ Note "no prebuilt binaries for this platform; compiling instead"
628
+ $rc = Invoke-Ticking 'compiling' $NodeExe ($npmArgs + ' --build-from-source') $log
629
+ if ($rc -ne 0) {
630
+ Get-Content $log -Tail 20 | Write-Host
631
+ Pop-Location
632
+ Fail "Could not install node-pty and better-sqlite3.\`nThis machine has no prebuilt binaries and no C++ toolchain. Install the Visual Studio Build Tools with the 'Desktop development with C++' workload:\`n https://visualstudio.microsoft.com/visual-cpp-build-tools/\`nFull log: $log\`nThen re-run this command."
633
+ }
634
+ }
635
+
636
+ Set-Content -Path $stampFile -Value $wantStamp -Encoding ascii
637
+ }
638
+ Pop-Location
639
+
640
+ # --- join -------------------------------------------------------------------
641
+ Step '4/4' "connecting to $HubUrl"
642
+ $hubLog = Join-Path $HomeDir 'hub.log'
643
+ $env:TERMSCAPE_HOME = $HomeDir
644
+ # cli.js is named absolutely so this hub is findable in the process list by
645
+ # the install it came from - which is how a later re-join knows what to stop.
646
+ $cliArgs = '"' + (Join-Path $hubDir 'dist/cli.js') + '"' +
647
+ ' --headless --port 0 --join "' + $HubUrl + '" --join-token "' + $JoinToken + '"'
648
+ $hubProc = Start-Process -FilePath $NodeExe -ArgumentList $cliArgs -PassThru \`
649
+ -WorkingDirectory $hubDir -RedirectStandardOutput $hubLog \`
650
+ -RedirectStandardError (Join-Path $HomeDir 'hub.err.log') -WindowStyle Hidden
651
+ # Same trap as the install: without touching .Handle, HasExited on a process
652
+ # from Start-Process -PassThru is not reliable.
653
+ $null = $hubProc.Handle
654
+
655
+ # Wait for the join itself, not merely for the hub to start listening. A hub
656
+ # that is up but was refused by the canvas is not joined, and saying otherwise
657
+ # is how someone ends up staring at a host that never appears.
658
+ Write-Host " starting the hub " -NoNewline
659
+ $sw = [Diagnostics.Stopwatch]::StartNew()
660
+ for ($i = 0; $i -lt 360; $i++) {
661
+ $joined = $false
662
+ $refused = $null
663
+ # The hub is writing this file as we read it, so a locked read is a retry,
664
+ # not a failure.
665
+ try {
666
+ if (Test-Path $hubLog) {
667
+ $joined = Select-String -Path $hubLog -Pattern 'TERMSCAPE_JOINED=' -Quiet
668
+ $refused = Select-String -Path $hubLog -Pattern 'TERMSCAPE_JOIN_FAILED=(.*)' |
669
+ Select-Object -First 1
670
+ }
671
+ } catch { $joined = $false }
672
+
673
+ if ($joined) {
674
+ Write-Host " $([int]$sw.Elapsed.TotalSeconds)s"
675
+ Write-Host ""
676
+ Write-Host "Joined. This machine is on the canvas at $HubUrl"
677
+ Note "it keeps running in the background, and rejoins by itself"
678
+ Note "log: $hubLog"
679
+ Write-Host ""
680
+ return
681
+ }
682
+
683
+ if ($refused) {
684
+ $reason = $refused.Matches[0].Groups[1].Value.Trim()
685
+ Write-Host ""
686
+ Fail ("$HubUrl refused this machine:" + [Environment]::NewLine +
687
+ " $reason" + [Environment]::NewLine +
688
+ "Open $HubUrl/join on this machine for a fresh command - a join key is" +
689
+ [Environment]::NewLine + "single-use and expires after 15 minutes.")
690
+ }
691
+
692
+ # Checked last: a refusal, read just above, explains an exit far better
693
+ # than the bare fact of the exit does.
694
+ if ($hubProc.HasExited) {
695
+ Write-Host ""
696
+ if (Test-Path $hubLog) { Get-Content $hubLog -Tail 20 | Write-Host }
697
+ Fail "The hub exited without joining. See $hubLog"
698
+ }
699
+
700
+ Write-Host "." -NoNewline
701
+ Start-Sleep -Milliseconds 500
702
+ }
703
+
704
+ Write-Host ""
705
+ if (Test-Path $hubLog) { Get-Content $hubLog -Tail 20 | Write-Host }
706
+ Fail "The hub started but never reported joining $HubUrl. See $hubLog"
707
+ `;
708
+ }
709
+ //# sourceMappingURL=join-script.js.map