@mason-data/cli 0.7.0 → 0.9.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 (155) hide show
  1. package/README.md +101 -73
  2. package/dist/src/api/error.js +3 -0
  3. package/dist/src/api/postgres/catalog-worker.js +49 -2
  4. package/dist/src/api/postgres/catalog.js +368 -25
  5. package/dist/src/api/postgres/encode.js +3 -1
  6. package/dist/src/api/postgres/explain.js +3 -2
  7. package/dist/src/api/postgres/listener.js +11 -3
  8. package/dist/src/api/postgres/protocol.js +39 -23
  9. package/dist/src/api/postgres/proxy.js +283 -0
  10. package/dist/src/api/postgres/schema.js +6 -0
  11. package/dist/src/api/postgres/server.js +1617 -245
  12. package/dist/src/api/postgres/translate.js +127 -10
  13. package/dist/src/api/request-log.js +18 -5
  14. package/dist/src/cdc/poll.js +5 -5
  15. package/dist/src/cli/background.js +47 -25
  16. package/dist/src/cli/bridge-stdin.js +101 -0
  17. package/dist/src/cli/cancel.js +75 -0
  18. package/dist/src/cli/command.js +170 -11
  19. package/dist/src/cli/commands.js +804 -209
  20. package/dist/src/cli/control.js +59 -0
  21. package/dist/src/cli/credentials.js +182 -0
  22. package/dist/src/cli/demo.js +20 -11
  23. package/dist/src/cli/deploy.js +574 -0
  24. package/dist/src/cli/dev.js +491 -130
  25. package/dist/src/cli/doctor.js +131 -38
  26. package/dist/src/cli/generated-files.js +143 -0
  27. package/dist/src/cli/guides.js +175 -36
  28. package/dist/src/cli/hosted.js +850 -0
  29. package/dist/src/cli/init.js +162 -65
  30. package/dist/src/cli/login.js +289 -0
  31. package/dist/src/cli/machine.js +13 -12
  32. package/dist/src/cli/main.js +11 -5
  33. package/dist/src/cli/mason.cjs +17 -0
  34. package/dist/src/cli/mcp.js +31 -22
  35. package/dist/src/cli/metrics.js +79 -0
  36. package/dist/src/cli/output-gate.js +176 -0
  37. package/dist/src/cli/page-share.js +96 -0
  38. package/dist/src/cli/pg-tls.js +14 -4
  39. package/dist/src/cli/pg-url.js +4 -0
  40. package/dist/src/cli/pin.js +19 -14
  41. package/dist/src/cli/pool-size.cjs +85 -0
  42. package/dist/src/cli/ports.js +2 -0
  43. package/dist/src/cli/preflight.js +138 -46
  44. package/dist/src/cli/project.js +142 -4
  45. package/dist/src/cli/readiness.js +76 -6
  46. package/dist/src/cli/registry.js +65 -38
  47. package/dist/src/cli/replica-hosted.js +665 -0
  48. package/dist/src/cli/replica.js +76 -0
  49. package/dist/src/cli/replication-checks.js +7 -5
  50. package/dist/src/cli/replication-mode.js +105 -26
  51. package/dist/src/cli/request-filter.js +134 -0
  52. package/dist/src/cli/roles.js +585 -0
  53. package/dist/src/cli/run-server.js +637 -48
  54. package/dist/src/cli/runs.js +1 -1
  55. package/dist/src/cli/security.js +67 -18
  56. package/dist/src/cli/serve.js +204 -46
  57. package/dist/src/cli/share-run.js +329 -0
  58. package/dist/src/cli/share.js +286 -0
  59. package/dist/src/cli/source-edit.js +75 -17
  60. package/dist/src/cli/source-role.js +344 -0
  61. package/dist/src/cli/sources.js +306 -47
  62. package/dist/src/cli/style.js +2 -1
  63. package/dist/src/cli/supervise.js +27 -24
  64. package/dist/src/cli/sync.js +55 -25
  65. package/dist/src/cli/token-hosted.js +337 -0
  66. package/dist/src/cli/tunnel.js +200 -0
  67. package/dist/src/cli/ui-ssh.js +460 -0
  68. package/dist/src/cli/ui.js +124 -17
  69. package/dist/src/cli/usage.js +128 -0
  70. package/dist/src/cli/user-hosted.js +299 -0
  71. package/dist/src/cli/user.js +573 -65
  72. package/dist/src/cli/version.js +9 -7
  73. package/dist/src/demo/seed.js +29 -0
  74. package/dist/src/duck/analyze.js +36 -20
  75. package/dist/src/duck/cancel.js +10 -1
  76. package/dist/src/duck/collation.js +266 -0
  77. package/dist/src/duck/constant-key.js +94 -0
  78. package/dist/src/duck/distinct-key.js +283 -26
  79. package/dist/src/duck/events.js +165 -0
  80. package/dist/src/duck/executor-worker.js +20 -11
  81. package/dist/src/duck/executor.js +47 -33
  82. package/dist/src/duck/explain.js +60 -46
  83. package/dist/src/duck/extensions.js +160 -0
  84. package/dist/src/duck/functions.js +461 -63
  85. package/dist/src/duck/gaps.js +48 -21
  86. package/dist/src/duck/generated-plan.js +1070 -0
  87. package/dist/src/duck/generated.js +906 -0
  88. package/dist/src/duck/grants.js +176 -0
  89. package/dist/src/duck/held-types.js +64 -0
  90. package/dist/src/duck/history.js +127 -0
  91. package/dist/src/duck/jsonb-order.js +9 -12
  92. package/dist/src/duck/kept-route.js +6 -5
  93. package/dist/src/duck/kept.js +15 -13
  94. package/dist/src/duck/late.js +1469 -40
  95. package/dist/src/duck/libc-bound.js +341 -0
  96. package/dist/src/duck/libc-order.js +892 -126
  97. package/dist/src/duck/pg-eval-worker.js +7 -1
  98. package/dist/src/duck/pg-eval.js +6 -4
  99. package/dist/src/duck/pgtree.js +774 -23
  100. package/dist/src/duck/plan.js +369 -47
  101. package/dist/src/duck/publication.js +20 -6
  102. package/dist/src/duck/requests.js +269 -93
  103. package/dist/src/duck/resolved.js +117 -2
  104. package/dist/src/duck/rewrite.js +4584 -580
  105. package/dist/src/duck/row-filter.js +655 -0
  106. package/dist/src/duck/statement.js +19 -0
  107. package/dist/src/duck/store.js +439 -177
  108. package/dist/src/duck/stream.js +19 -10
  109. package/dist/src/duck/sync.js +1411 -368
  110. package/dist/src/duck/token.js +4 -1
  111. package/dist/src/duck/types.js +69 -12
  112. package/dist/src/duck/usage.js +326 -0
  113. package/dist/src/duck/users.js +69 -15
  114. package/dist/src/duck/verified.js +161 -1
  115. package/dist/src/duck/views.js +5 -5
  116. package/dist/src/lib/database-name.js +62 -0
  117. package/dist/src/lib/database-url.js +55 -10
  118. package/dist/src/lib/dotenv.js +17 -4
  119. package/dist/src/lib/instant.js +50 -0
  120. package/dist/src/lib/log-buffer.js +77 -0
  121. package/dist/src/lib/log-read.js +196 -0
  122. package/dist/src/lib/pg-numeric.js +390 -49
  123. package/dist/src/lib/project-version.js +23 -23
  124. package/dist/src/lib/secret-mask.js +142 -0
  125. package/dist/src/lib/table-sync.js +282 -0
  126. package/dist/src/web/app.css +706 -66
  127. package/dist/src/web/app.js +331 -409
  128. package/dist/src/web/charts.js +229 -0
  129. package/dist/src/web/index.html +82 -18
  130. package/dist/src/web/pages/queries.js +192 -0
  131. package/dist/src/web/pages/status.js +611 -0
  132. package/dist/src/web/pages/syncs.js +155 -0
  133. package/dist/src/web/pages/tables.js +439 -0
  134. package/dist/src/web/pages/usage.js +397 -0
  135. package/dist/src/web/pages/users.js +219 -0
  136. package/dist/src/web/shared.js +584 -0
  137. package/dist/src/web/vendor/README.md +25 -0
  138. package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
  139. package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
  140. package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
  141. package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
  142. package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
  143. package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
  144. package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
  145. package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
  146. package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
  147. package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
  148. package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
  149. package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
  150. package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
  151. package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
  152. package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
  153. package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
  154. package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
  155. package/package.json +12 -4
@@ -0,0 +1,200 @@
1
+ /**
2
+ * THE TUNNEL OF `mason share`: a client of a free service that forwards raw TCP, run as a child process and pointed at the
3
+ * share's socket (src/cli/share-run.ts). What crosses is TLS between the BI tool and Mason, so a service that only
4
+ * forwards reads nothing; a hostile one could sit in the handshake (docs/POSTGRES-API.md says so). Which one is `--via`:
5
+ * Pinggy over the `ssh` every machine has, or `bore`, a binary to install. The address is the client's OUTPUT, a format
6
+ * Mason does not own, so reading it is held three ways: a time limit (`timeoutMs`), a shape it must have
7
+ * (`tunnelAddress`), and a probe of the address it gave (`probeShare`): the share's own certificate must answer there.
8
+ *
9
+ * `ssh` NEVER PROMPTS, because nothing stands behind a prompt (an agent, a pipe, a terminal that went on to something
10
+ * else): it runs in a session of its own with no terminal (`detached`), stdin a pipe this process holds. The first
11
+ * connection's host key is taken and kept in `.mason/share-known-hosts` (`accept-new`: a key that CHANGES later fails, said
12
+ * by the file's name), never the developer's own `~/.ssh`; their keys, agent and config are left out (`-F`, no
13
+ * `PubkeyAuthentication`), so nothing can ask for a passphrase or a touch and nothing of theirs is offered to a
14
+ * service. Pinggy's free route asks for a password that is empty (its docs: "just press enter"), so a password
15
+ * question is answered by `true`, which prints nothing (`SSH_ASKPASS`, forced): that is the one answer any question gets.
16
+ *
17
+ * A PINGGY TOKEN NEVER REACHES A COMMAND LINE: `ps` shows every process's arguments to every user, and the service takes the
18
+ * token as the ssh user. So the user is written in a config file of ours, 0600 in `.mason/`, named by `-F`, and
19
+ * removed as soon as the tunnel is up (ssh has read it), or when it fails. `MASON_PINGGY_TOKEN` is read from the
20
+ * shell or `.env`, never a flag, and is masked in whatever of the client's output a failure repeats.
21
+ */
22
+ import { spawn } from "node:child_process";
23
+ import * as fs from "node:fs";
24
+ import * as net from "node:net";
25
+ import * as path from "node:path";
26
+ import * as tls from "node:tls";
27
+ import { stripVTControlCharacters } from "node:util";
28
+ export const TUNNELS = ["pinggy", "bore"];
29
+ /** A free tunnel's limit where its client does not say one: Pinggy's, measured in docs/DESIGN.md "mason share". */
30
+ const FREE_MINUTES = 60;
31
+ /** How much of the client's output is kept to read an address from, and how many lines a failure repeats. */
32
+ const KEPT_BYTES = 64 * 1024;
33
+ const SAID_LINES = 3;
34
+ const STOP_GRACE_MS = 2_000;
35
+ /** How long the address is given to answer: long enough for a relay on another continent, short enough to wait for. */
36
+ export const PROBE_MS = 8_000;
37
+ /** The ssh user and host: a free tunnel, or a token's (`<token>+tcp@`, Pinggy's own form). */
38
+ const pinggyTarget = (token) => token === null ? { user: "tcp", host: "free.pinggy.io" } : { user: `${token}+tcp`, host: "pro.pinggy.io" };
39
+ /** A token as it may stand in a config file: anything else would be a line of it. */
40
+ export const TOKEN_RE = /^[A-Za-z0-9._~-]+$/;
41
+ const quoted = (file) => `"${file.replaceAll("%", "%%")}"`;
42
+ /** The ssh config of a tunnel (the header says why each line): every option here, so the command line carries none. */
43
+ export function pinggyConfig(o) {
44
+ const t = pinggyTarget(o.token);
45
+ return `Host mason-share
46
+ HostName ${t.host}
47
+ Port 443
48
+ User ${t.user}
49
+ StrictHostKeyChecking accept-new
50
+ UserKnownHostsFile ${quoted(o.knownHosts)}
51
+ GlobalKnownHostsFile none
52
+ PubkeyAuthentication no
53
+ IdentityAgent none
54
+ NumberOfPasswordPrompts 1
55
+ ExitOnForwardFailure yes
56
+ ServerAliveInterval 30
57
+ ServerAliveCountMax 3
58
+ ConnectTimeout 15
59
+ ConnectionAttempts 1
60
+ RequestTTY no
61
+ LogLevel ERROR
62
+ `;
63
+ }
64
+ export function tunnelPlan(via, o) {
65
+ if (via === "bore") {
66
+ return { via, bin: "bore", args: ["local", String(o.port), "--local-host", "127.0.0.1", "--to", "bore.pub"], env: process.env, install: "cargo install bore-cli, or brew install bore-cli",
67
+ reconnects: false, free: false, token: null, file: null };
68
+ }
69
+ const file = path.join(o.home, `share-tunnel-${process.pid}.ssh`);
70
+ return { via, bin: "ssh", args: ["-F", file, "-T", `-R0:127.0.0.1:${o.port}`, "mason-share"],
71
+ env: { ...process.env, SSH_ASKPASS: "true", SSH_ASKPASS_REQUIRE: "force", DISPLAY: process.env.DISPLAY ?? ":0" }, install: "the OpenSSH client (it comes with macOS and most Linux; apt install openssh-client)",
72
+ reconnects: o.token !== null, free: o.token === null, token: o.token,
73
+ file: { path: file, text: pinggyConfig({ token: o.token, knownHosts: path.join(o.home, "share-known-hosts") }) } };
74
+ }
75
+ /** A `share-tunnel-<pid>.ssh` a `kill -9` left behind (it may hold a token): removed unless its process is alive. */
76
+ export function sweepTunnelFiles(home, isAlive) {
77
+ for (const name of fs.readdirSync(home)) {
78
+ const m = /^share-tunnel-(\d+)\.ssh$/.exec(name);
79
+ if (m !== null && !isAlive(Number(m[1])))
80
+ fs.rmSync(path.join(home, name), { force: true });
81
+ }
82
+ }
83
+ const PINGGY_ADDRESS = /\btcp:\/\/([a-z0-9][a-z0-9.-]*):(\d{1,5})(?![\d.])/i;
84
+ const BORE_ADDRESS = /\blistening at ([a-z0-9][a-z0-9.-]*):(\d{1,5})(?![\d.])/i;
85
+ /** The public address in what a client printed so far, or null: a shape, and a real port. */
86
+ export function tunnelAddress(via, output) {
87
+ const m = (via === "pinggy" ? PINGGY_ADDRESS : BORE_ADDRESS).exec(stripVTControlCharacters(output));
88
+ if (m === null)
89
+ return null;
90
+ const port = Number(m[2]);
91
+ return port >= 1 && port <= 65_535 ? { host: m[1].toLowerCase(), port } : null;
92
+ }
93
+ /** When a free tunnel ends: what its client says, else its known limit; null when it has none. */
94
+ export function tunnelEnds(plan, output, now) {
95
+ if (!plan.free)
96
+ return null;
97
+ const said = /expires?\s+in\s+(\d+)\s*min/i.exec(stripVTControlCharacters(output));
98
+ return new Date(now + (said === null ? FREE_MINUTES : Number(said[1])) * 60_000).toISOString();
99
+ }
100
+ /** The last lines a client printed, on one line, its token masked: what a failure repeats. */
101
+ export function lastLines(output, token) {
102
+ const lines = stripVTControlCharacters(output).split(/[\r\n]+/).map((l) => l.trim()).filter((l) => l !== "");
103
+ const text = lines.slice(-SAID_LINES).join(" / ");
104
+ return token === null ? text : text.replaceAll(token, "<token>");
105
+ }
106
+ /** Starts a tunnel client and reads its address: the tunnel, or why not. `onPid`: as soon as there is a process. */
107
+ export function startTunnel(plan, o) {
108
+ return new Promise((resolve) => {
109
+ const forget = () => { if (plan.file !== null)
110
+ fs.rmSync(plan.file.path, { force: true }); };
111
+ if (plan.file !== null)
112
+ fs.writeFileSync(plan.file.path, plan.file.text, { mode: 0o600 });
113
+ // detached: a session of its own, so no terminal to prompt on, and Ctrl-C reaches this process alone, which ends it.
114
+ // stdin a pipe held open: the end of stdin would end the session the service prints on
115
+ const child = spawn(plan.bin, plan.args, { stdio: ["pipe", "pipe", "pipe"], env: plan.env, detached: true });
116
+ let output = "", settled = false, finished = () => undefined;
117
+ const ended = new Promise((r) => { finished = r; });
118
+ const stop = () => {
119
+ if (child.exitCode !== null || child.signalCode !== null)
120
+ return;
121
+ child.kill("SIGTERM");
122
+ setTimeout(() => { if (child.exitCode === null && child.signalCode === null)
123
+ child.kill("SIGKILL"); }, STOP_GRACE_MS).unref();
124
+ };
125
+ const settle = (v) => {
126
+ if (settled)
127
+ return;
128
+ settled = true;
129
+ clearTimeout(timer);
130
+ forget();
131
+ resolve(v);
132
+ };
133
+ const timer = setTimeout(() => {
134
+ stop();
135
+ const said = lastLines(output, plan.token);
136
+ settle({ message: `the tunnel gave no address in ${Math.round(o.timeoutMs / 1000)} s`, code: "failed", why: said === "" ? `${plan.bin} printed nothing` : `${plan.bin} printed: ${said}`,
137
+ fix: `its output is the service's, and may have changed: this Mason cannot read it${plan.via === "pinggy" ? ", and --via bore is another tunnel" : ""}. Nothing was left open` });
138
+ }, o.timeoutMs);
139
+ const read = (b) => {
140
+ output = (output + b.toString("utf8")).slice(-KEPT_BYTES);
141
+ if (settled)
142
+ return;
143
+ const address = tunnelAddress(plan.via, output);
144
+ if (address === null)
145
+ return;
146
+ settle({ ...address, endsAt: tunnelEnds(plan, output, Date.now()), pid: child.pid, ended, stop });
147
+ };
148
+ child.stdout.on("data", read);
149
+ child.stderr.on("data", read);
150
+ // the end of stdin would be a command's end, and EPIPE from a client gone is that end, said by its exit
151
+ child.stdin.on("error", () => undefined);
152
+ child.on("spawn", () => o.onPid?.(child.pid));
153
+ child.on("error", (e) => {
154
+ if (e.code !== "ENOENT") {
155
+ settle({ message: `${plan.bin} could not be started: ${e.message}`, code: "failed" });
156
+ return;
157
+ }
158
+ settle({ message: `${plan.bin} is not on this machine's PATH, and --via ${plan.via} needs it`, code: "not_found", fix: `install ${plan.install}${plan.via === "pinggy" ? ", or use --via bore" : ", or use --via pinggy, which needs only ssh"}`,
159
+ next: [[`mason share --via ${plan.via === "pinggy" ? "bore" : "pinggy"}`, "the other tunnel"]] });
160
+ });
161
+ const over = (code, signal) => {
162
+ const said = lastLines(output, plan.token);
163
+ const how = signal !== null ? `it was ended (${signal})` : `${plan.bin} exited ${code}`;
164
+ finished(said === "" ? how : `${how}: ${said}`);
165
+ settle({ message: "the tunnel ended before it gave an address", code: "failed", why: said === "" ? how : `${how}: ${said}`,
166
+ fix: plan.via === "pinggy" && /REMOTE HOST IDENTIFICATION|Host key verification failed/i.test(said) ? "the service's host key changed: remove .mason/share-known-hosts if you expected that" : "is this machine online? Port 443 outbound is all it needs" });
167
+ };
168
+ // 'close' waits for its pipes, which a grandchild can hold for ever: 'exit' ends the wait
169
+ let code = null, signal = null;
170
+ child.on("exit", (c, s) => { code = c; signal = s; setTimeout(() => over(c, s), 200).unref(); });
171
+ child.on("close", () => over(code, signal));
172
+ });
173
+ }
174
+ /**
175
+ * WHETHER THE ADDRESS IS THIS SHARE: an SSLRequest and a handshake through the tunnel, its certificate read, no login.
176
+ * `other`: something else answered there, a wrong address or something in the way. `unknown`: nothing answered in time
177
+ * (a firewall may keep this machine from a port the BI tool can reach): no verdict, and said so.
178
+ */
179
+ export function probeShare(address, fingerprint, timeoutMs = PROBE_MS) {
180
+ return new Promise((resolve) => {
181
+ const socket = net.connect(address.port, address.host);
182
+ let secure = null;
183
+ // the TLS socket owns the connection once it wraps it: destroying the one under it segfaults Node (measured, 24.18)
184
+ const answer = (v) => { clearTimeout(timer); (secure ?? socket).destroy(); resolve(v); };
185
+ const timer = setTimeout(() => answer("unknown"), timeoutMs);
186
+ socket.once("error", () => answer("unknown"));
187
+ socket.once("close", () => answer("unknown"));
188
+ socket.once("connect", () => socket.write(Buffer.from([0, 0, 0, 8, 0x04, 0xd2, 0x16, 0x2f])));
189
+ socket.once("data", (b) => {
190
+ if (b[0] !== 0x53 /* S */) {
191
+ answer("other");
192
+ return;
193
+ }
194
+ socket.removeAllListeners("close");
195
+ secure = tls.connect({ socket, rejectUnauthorized: false, ALPNProtocols: ["postgresql"] });
196
+ secure.once("error", () => answer("unknown"));
197
+ secure.once("secureConnect", () => answer(secure?.getPeerX509Certificate()?.fingerprint256 === fingerprint ? "same" : "other"));
198
+ });
199
+ });
200
+ }
@@ -0,0 +1,460 @@
1
+ /**
2
+ * `mason ui --ssh <user@server>`: A SERVER'S WEB PAGE IN THE DEVELOPER'S BROWSER, WITH NOTHING PUBLIC (docs/WEB-UI.md "On
3
+ * a server"). Two processes, one on each end of the machine's own `ssh` (never a library: `~/.ssh/config`, keys, SSO,
4
+ * hardware tokens and a cloud's private route are then the person's, and Mason knows none of them):
5
+ *
6
+ * browser ── 127.0.0.1:<free port> ── mason ui --ssh ══ ssh, its stdin and stdout ══ mason ui --bridge ── the run's page
7
+ * on the laptop on the server, or in its container
8
+ *
9
+ * THE BRIDGE RUNS WHERE THE RUN RUNS, AND THE TUNNEL IS ITS STDIO, NOT `ssh -L`. The page listens on loopback alone, by
10
+ * construction (src/cli/run-server.ts `listenOnLoopback`), and a container's loopback is not its host's: `-L` from the
11
+ * host reaches a port Docker publishes, and Docker publishes to the container's own network address, so `-L` would need
12
+ * the page off loopback. A process inside the container reaches it as it is, so ONE route serves a host and a container,
13
+ * and needs no `AllowTcpForwarding` on the server and no port published or even known.
14
+ *
15
+ * ONE SSH CONNECTION CARRIES EVERY BROWSER CONNECTION (`FrameLink`): a page opens six at once, and a connection per one
16
+ * would mean a touch of a hardware token per one. After the hello line the two ends speak frames: 1 byte type, 4 bytes
17
+ * connection, 4 bytes length, then that many bytes.
18
+ *
19
+ * THE TOKEN STAYS ON THE SERVER: the bridge reads it from the run's config, as every command does, opens the tunnel
20
+ * with it (`POST /api/ui/tunnel`, its connection IS the tunnel) and sends the laptop a ticket (src/cli/security.ts). The
21
+ * hello line is what crosses: nothing secret is on a command line, in the laptop's output or in a log.
22
+ *
23
+ * THE BRIDGE ENDS A BOUND AFTER ITS STDIN DOES, WHATEVER ITS MAIN THREAD IS DOING: stdin is read on a worker thread
24
+ * (src/cli/bridge-stdin.ts), whose own event loop hears the end and ends the process `BRIDGE_EXIT_MS` later if it is
25
+ * still there, so a blocked main loop cannot keep the run's tunnel open once the laptop's ssh is gone.
26
+ *
27
+ * WHAT `ssh` PRINTS IS NOT OURS: stdout is scanned for the hello line, as a server's shell may print before it; stderr
28
+ * is passed on as it comes, because a person may be asked for a passphrase or a touch there.
29
+ */
30
+ import { spawn } from "node:child_process";
31
+ import * as net from "node:net";
32
+ import { fileURLToPath } from "node:url";
33
+ import { Worker } from "node:worker_threads";
34
+ import { runnable } from "../lib/runnable.js";
35
+ import { errorJson, nextJson } from "./command.js";
36
+ import { holdRun, RunUnreachable } from "./control.js";
37
+ import { whereIs } from "./state.js";
38
+ import { lastLines } from "./tunnel.js";
39
+ import { version } from "./version.js";
40
+ /** What the laptop looks for on the bridge's stdout: this, a space, then one line of JSON. */
41
+ export const HELLO = "MASON-UI-BRIDGE";
42
+ /** The frames' version: two ends of another say so, whatever their Mason versions. */
43
+ export const BRIDGE_PROTOCOL = 1;
44
+ const OPEN = 1, DATA = 2, CLOSE = 3;
45
+ const HEADER = 9;
46
+ /** No end sends more than a socket's read at once: a length past this is not a frame, it is a broken stream. */
47
+ const MAX_FRAME = 1024 * 1024;
48
+ const STOP_GRACE_MS = 2_000;
49
+ /** How long after ssh exits the end is read: a Ctrl-C reaches ssh and this process together. */
50
+ const SETTLE_MS = 200;
51
+ /** Why a `--ssh` target cannot be given to ssh, or null: a target opening with `-` would be read as ssh's own option. */
52
+ export function targetProblem(target) {
53
+ return /^[A-Za-z0-9_][A-Za-z0-9._@%+:[\]-]*$/.test(target) ? null : `${JSON.stringify(target)} is not user@server or a Host of your ssh config`;
54
+ }
55
+ /** The server-side command: `mason`, as `--mason` says to run it there. */
56
+ export const bridgeCommand = (mason, localPort) => `${mason} ui --bridge --local-port ${localPort}`;
57
+ /**
58
+ * THE `ssh` LINE. No secret in it: `ps` shows it. `BatchMode` where nobody can answer a prompt, so ssh fails rather
59
+ * than waits. A shared connection (`ControlPath`) is left out: a master that persists would be an ssh process left
60
+ * behind by Ctrl-C. Forwards from the person's config are cleared: a tunnel of ours must not fail on theirs.
61
+ */
62
+ export function sshArgs(target, remote, interactive) {
63
+ const options = ["ConnectTimeout=15", "ServerAliveInterval=15", "ServerAliveCountMax=3", "ControlMaster=no", "ControlPath=none", "ClearAllForwardings=yes", "ForwardAgent=no", "ForwardX11=no",
64
+ ...(interactive ? [] : ["BatchMode=yes"])];
65
+ return ["-T", "-e", "none", ...options.flatMap((o) => ["-o", o]), "--", target, remote];
66
+ }
67
+ function frame(type, id, payload = Buffer.alloc(0)) {
68
+ const head = Buffer.alloc(HEADER);
69
+ head.writeUInt8(type, 0);
70
+ head.writeUInt32BE(id, 1);
71
+ head.writeUInt32BE(payload.length, 5);
72
+ return Buffer.concat([head, payload]);
73
+ }
74
+ /**
75
+ * ONE END OF THE STREAM: many sockets over one pipe each way. `connect` is the bridge's, which dials the page when the
76
+ * laptop opens a connection; the laptop has none and calls `open`. Backpressure is the pipe's: a socket that cannot take
77
+ * more pauses the pipe, and a pipe that cannot take more pauses the sockets, so memory stays bounded and a browser's
78
+ * slow tab can hold the others back a moment, which a page of small JSON answers does not notice.
79
+ */
80
+ export class FrameLink {
81
+ sockets = new Map();
82
+ input;
83
+ output;
84
+ connect;
85
+ broken;
86
+ kept = Buffer.alloc(0);
87
+ next = 1;
88
+ waiting = false;
89
+ constructor(o) {
90
+ this.input = o.input;
91
+ this.output = o.output;
92
+ this.connect = o.connect ?? null;
93
+ this.broken = o.broken;
94
+ }
95
+ /** A connection from this end: the laptop's, one per browser connection. */
96
+ open(socket) {
97
+ const id = this.next++;
98
+ this.send(frame(OPEN, id));
99
+ this.attach(id, socket);
100
+ }
101
+ /** Bytes from the pipe. */
102
+ read(chunk) {
103
+ this.kept = this.kept.length === 0 ? chunk : Buffer.concat([this.kept, chunk]);
104
+ while (this.kept.length >= HEADER) {
105
+ const type = this.kept.readUInt8(0), id = this.kept.readUInt32BE(1), length = this.kept.readUInt32BE(5);
106
+ if (length > MAX_FRAME || type < OPEN || type > CLOSE) {
107
+ this.broken(`a frame of type ${type} and ${length} bytes: this is not the bridge's stream`);
108
+ return;
109
+ }
110
+ if (this.kept.length < HEADER + length)
111
+ return;
112
+ this.frameIn(type, id, this.kept.subarray(HEADER, HEADER + length));
113
+ this.kept = this.kept.subarray(HEADER + length);
114
+ }
115
+ }
116
+ /** Every connection dropped: the tunnel is gone. */
117
+ end() { for (const s of [...this.sockets.values()])
118
+ s.destroy(); this.sockets.clear(); }
119
+ frameIn(type, id, payload) {
120
+ if (type === OPEN) {
121
+ if (this.connect !== null)
122
+ this.attach(id, this.connect());
123
+ return;
124
+ }
125
+ const socket = this.sockets.get(id);
126
+ // the other end closed it while this frame crossed
127
+ if (socket === undefined)
128
+ return;
129
+ if (type === CLOSE) {
130
+ this.sockets.delete(id);
131
+ socket.destroySoon();
132
+ return;
133
+ }
134
+ if (socket.write(Buffer.from(payload)))
135
+ return;
136
+ this.input.pause();
137
+ const go = () => { socket.off("drain", go); socket.off("close", go); this.input.resume(); };
138
+ socket.once("drain", go);
139
+ socket.once("close", go);
140
+ }
141
+ attach(id, socket) {
142
+ this.sockets.set(id, socket);
143
+ socket.on("data", (b) => { this.send(frame(DATA, id, b)); });
144
+ // a browser or the page dropping a connection is an ordinary end, said to the other side by the close below
145
+ socket.on("error", () => undefined);
146
+ socket.on("close", () => { if (this.sockets.delete(id))
147
+ this.send(frame(CLOSE, id)); });
148
+ }
149
+ send(b) {
150
+ if (this.output.write(b) || this.waiting)
151
+ return;
152
+ this.waiting = true;
153
+ for (const s of this.sockets.values())
154
+ s.pause();
155
+ this.output.once("drain", () => { this.waiting = false; for (const s of this.sockets.values())
156
+ s.resume(); });
157
+ }
158
+ }
159
+ // ------------------------------------------------------------------ the server's end: mason ui --bridge
160
+ /**
161
+ * How long the bridge may take to end by itself once its stdin has ended, before its watchdog ends it (SIGKILL), and
162
+ * how long its main thread may be silent with stdin's window full before it is taken for stuck. An ordinary end takes
163
+ * milliseconds: the tunnel's hold closed, its sockets destroyed, the output flushed or found gone.
164
+ */
165
+ export const BRIDGE_EXIT_MS = 5_000;
166
+ /**
167
+ * What the stdin thread hands the main thread before it is acknowledged: the most of the laptop's bytes the bridge
168
+ * holds between the pipe and FrameLink, whatever its main thread does. Past it the pipe fills and the laptop waits.
169
+ */
170
+ export const STDIN_WINDOW_BYTES = 1024 * 1024;
171
+ /** How often the main thread tells the stdin thread it is alive: well inside BRIDGE_EXIT_MS. */
172
+ const BEAT_MS = 1_000;
173
+ /**
174
+ * The bridge's stdin, as its thread forwards it (src/cli/bridge-stdin.ts), by credit: each chunk is acknowledged once
175
+ * FrameLink has read it and is not paused, so the thread's window is what has not reached a page's socket yet. Started
176
+ * first, so the watchdog bounds the whole bridge, the run's lookup included; what arrives before `attach` is kept for
177
+ * it, unacknowledged, so that too is inside the window.
178
+ */
179
+ class StdinByWorker {
180
+ worker;
181
+ kept = [];
182
+ end = null;
183
+ read = null;
184
+ ended = null;
185
+ paused = false;
186
+ /** Bytes read while paused: acknowledged on resume. */
187
+ unacked = 0;
188
+ broken = false;
189
+ constructor() {
190
+ // execArgv [] as the other workers here: the main thread's flags (a test's --import) are not the reader's
191
+ this.worker = new Worker(runnable(fileURLToPath(new URL("./bridge-stdin.ts", import.meta.url))), { workerData: { exitMs: BRIDGE_EXIT_MS, windowBytes: STDIN_WINDOW_BYTES }, execArgv: [], argv: [] });
192
+ // the process ends when the bridge does: the thread never holds it, and dies with it
193
+ this.worker.unref();
194
+ this.worker.on("message", (m) => {
195
+ if ("data" in m) {
196
+ if (this.broken)
197
+ return;
198
+ const b = Buffer.from(m.data.buffer, m.data.byteOffset, m.data.byteLength);
199
+ if (this.read === null)
200
+ this.kept.push(b);
201
+ else
202
+ this.consume(b);
203
+ return;
204
+ }
205
+ if ("dropped" in m) {
206
+ // the thread took this one for stuck and read on past what it handed over: the stream has a hole, never read on
207
+ this.broken = true;
208
+ this.over(1, `its main thread was stuck for ${BRIDGE_EXIT_MS / 1000} s with ${m.dropped} bytes unread, and what the laptop sent since was dropped: the tunnel is broken, run mason ui --ssh again`);
209
+ return;
210
+ }
211
+ this.over(m.failed ? 1 : 0, m.failed ? m.end : null);
212
+ });
213
+ // a stdin nobody reads is a bridge that cannot work: said, and the bridge ends, never left without its end
214
+ this.worker.on("error", (e) => { this.over(1, `its stdin cannot be read: ${e.message}`); });
215
+ this.worker.on("exit", (code) => { this.over(1, `its stdin's reader stopped (exit ${code})`); });
216
+ // a main thread that is waiting, not stuck, says so; one that is stuck cannot
217
+ setInterval(() => { this.worker.postMessage({ beat: true }); }, BEAT_MS).unref();
218
+ }
219
+ /** From now on, bytes to `read` and the end to `ended`: what came before first. */
220
+ attach(read, ended) {
221
+ this.read = read;
222
+ for (const b of this.kept)
223
+ this.consume(b);
224
+ this.kept = [];
225
+ this.ended = ended;
226
+ if (this.end !== null)
227
+ this.say(this.end);
228
+ }
229
+ /** FrameLink's backpressure: a page's socket is full, so what is read now is acknowledged only when it drains. */
230
+ pause() { this.paused = true; }
231
+ resume() {
232
+ this.paused = false;
233
+ if (this.unacked > 0) {
234
+ this.worker.postMessage({ ack: this.unacked });
235
+ this.unacked = 0;
236
+ }
237
+ }
238
+ consume(b) {
239
+ this.read?.(b);
240
+ if (this.paused)
241
+ this.unacked += b.length;
242
+ else
243
+ this.worker.postMessage({ ack: b.length });
244
+ }
245
+ over(code, why) {
246
+ if (this.end !== null)
247
+ return;
248
+ this.end = { code, why };
249
+ if (this.ended !== null)
250
+ this.say(this.end);
251
+ }
252
+ say(e) {
253
+ if (e.why !== null)
254
+ process.stderr.write(`mason ui --bridge: ${e.why}\n`);
255
+ this.ended?.(e.code);
256
+ }
257
+ }
258
+ /**
259
+ * `mason ui --bridge`: the hello line, then the frames, until the laptop's end goes (stdin closes), or the run ends the
260
+ * tunnel. A failure is the hello, in the shape the laptop says; what it prints on stderr reaches the laptop through ssh.
261
+ */
262
+ export async function bridge(localPort) {
263
+ const stdin = new StdinByWorker();
264
+ const hello = (h) => { process.stdout.write(`${HELLO} ${JSON.stringify(h)}\n`); };
265
+ const refuse = (f) => { hello({ protocol: BRIDGE_PROTOCOL, version: version(), error: errorJson(f), next: nextJson(f.next) }); return 1; };
266
+ const w = await whereIs();
267
+ if (w.state !== "up")
268
+ return refuse({ ...w.problem, message: w.problem.problem });
269
+ const { run } = w;
270
+ let held;
271
+ try {
272
+ held = await holdRun(run, "/api/ui/tunnel", { port: localPort });
273
+ }
274
+ catch (e) {
275
+ if (e instanceof RunUnreachable)
276
+ return refuse({ message: e.message, code: "unreachable", next: [["mason logs", "the run's log"]] });
277
+ throw e;
278
+ }
279
+ if (!("first" in held)) {
280
+ if (held.status === 404) {
281
+ return refuse({ message: `Mason for ${run.name} serves no web page through a tunnel`, code: "refused", why: "the run was started by a Mason from before mason ui --ssh",
282
+ fix: "restart it on this Mason's version: mason serve's page needs no other setting" });
283
+ }
284
+ const f = held.body.failure;
285
+ return refuse(f ?? { message: String(held.body.error ?? `HTTP ${held.status}`), code: "failed" });
286
+ }
287
+ const { first, ended, close } = held;
288
+ hello({ protocol: BRIDGE_PROTOCOL, version: version(), project: run.name, ticket: String(first.ticket) });
289
+ const page = new URL(run.control.url);
290
+ return await new Promise((resolve) => {
291
+ let over = false;
292
+ const done = (code) => { over = true; close(); link.end(); resolve(code); };
293
+ const link = new FrameLink({ input: stdin, output: process.stdout, connect: () => net.connect(Number(page.port), "127.0.0.1"),
294
+ broken: (why) => { process.stderr.write(`mason ui --bridge: ${why}\n`); done(1); } });
295
+ stdin.attach((b) => { link.read(b); }, (code) => { done(code); });
296
+ // the laptop's end gone with bytes still to write: the same end as stdin's, which says it
297
+ process.stdout.on("error", () => { done(0); });
298
+ // settles on this end's own close too, which is no news
299
+ void ended.then(() => { if (over)
300
+ return; process.stderr.write(`mason ui --bridge: Mason for ${run.name} ended the tunnel: it stopped or restarted\n`); done(1); });
301
+ });
302
+ }
303
+ /** What a Mason that lacks `--bridge` prints: its parser's refusal of the flag, or its usage. */
304
+ const OLDER_MASON = /unknown option\b.*--bridge|^usage: mason ui\b/im;
305
+ /** The report for an ssh that exited before the bridge said hello, by what its exit means. */
306
+ export function exitFailure(o) {
307
+ const { target, mason, code, signal, said, stderr } = o;
308
+ const how = signal !== null ? `ssh was ended (${signal})` : `ssh exited ${code}`;
309
+ const why = said === "" ? how : `${how}: ${said}`;
310
+ const here = [`ssh ${target} true`, "whether ssh reaches it on its own"];
311
+ // 127 is a shell's "command not found", and ssh hands on the remote command's exit
312
+ if (code === 127) {
313
+ return { message: `${JSON.stringify(mason)} is not a command on ${target}`, code: "not_found", why,
314
+ fix: `--mason says how to run Mason there: --mason "docker exec -i <container> mason" for a container (docker ps names it), or --mason "/path/to/mason" where a shell ssh opens lacks it on its PATH`, next: [here] };
315
+ }
316
+ // 2 alone proves nothing (a shell exits 2 when its cd fails): an older Mason says so in its own words, read from the
317
+ // whole of stderr, as what it prints after that line pushes it out of `said`
318
+ if (code === 2 && OLDER_MASON.test(stderr)) {
319
+ return { message: `the Mason on ${target} does not know mason ui --bridge`, code: "refused", why, fix: "it is older than mason ui --ssh: upgrade the server's Mason to this one's version", next: [["mason --version", "this machine's"]] };
320
+ }
321
+ if (code === 255 && /host key|host identification/i.test(stderr)) {
322
+ return { message: `ssh does not trust the host key of ${target}`, code: "failed", why,
323
+ fix: `run ssh ${target} on its own and check the key before you accept it: mason ui --ssh never answers that question for you`, next: [here] };
324
+ }
325
+ if (code === 255) {
326
+ return { message: `ssh could not reach ${target}`, code: "failed", why,
327
+ fix: `run ssh ${target} on its own: this uses your ~/.ssh/config, as that does. A firewall rule that names your old IP address is the common cause`, next: [here] };
328
+ }
329
+ return { message: `the bridge on ${target} ended before it answered`, code: "failed", why, fix: `run ${bridgeCommand(mason, 1)} there to see why`, next: [here] };
330
+ }
331
+ /** A hello's refusal, in the words of who reads it: on this machine, about that one. */
332
+ function refusal(h, target) {
333
+ const lost = h.error.code === "no_project", stopped = h.error.code === "not_running";
334
+ const fix = lost ? `ssh lands in a folder with no Mason project. --mason says where it is: --mason "cd <MASON_DATA> && mason", or --mason "docker exec -i <container> mason"`
335
+ : stopped ? `start mason serve on ${target}: its service or container` : h.error.fix;
336
+ // these two say mason init and mason start, words for a person at that machine: run here they would do the wrong thing
337
+ const next = lost || stopped ? [] : h.next.map((n) => n.what === undefined ? [n.command] : [n.command, n.what]);
338
+ return { message: `on ${target}: ${h.error.message}`, code: h.error.code, ...(h.error.why ? { why: h.error.why } : {}), ...(fix ? { fix } : {}), next };
339
+ }
340
+ /** Starts ssh and the bridge, with a free loopback port before them: the tunnel, or why not. */
341
+ export function openSshTunnel(o) {
342
+ return new Promise((resolve) => {
343
+ let link = null;
344
+ // no link yet: nothing was handed out that names this port, so nothing that connects is ours
345
+ const local = net.createServer((socket) => { if (link === null)
346
+ socket.destroy();
347
+ else
348
+ link.open(socket); });
349
+ local.once("error", (e) => { resolve({ message: `no local port could be opened: ${e.message}`, code: "failed" }); });
350
+ local.listen(0, "127.0.0.1", () => {
351
+ const port = local.address().port;
352
+ const child = spawn("ssh", sshArgs(o.target, bridgeCommand(o.mason, port), o.interactive), { stdio: ["pipe", "pipe", "pipe"] });
353
+ let output = Buffer.alloc(0), said = "", settled = false, stopping = false, finish = () => undefined;
354
+ const ended = new Promise((r) => { finish = r; });
355
+ let gone = () => undefined;
356
+ const exited = new Promise((r) => { gone = r; });
357
+ const stop = () => {
358
+ stopping = true;
359
+ link?.end();
360
+ if (child.exitCode === null && child.signalCode === null) {
361
+ child.kill("SIGTERM");
362
+ setTimeout(() => { if (child.exitCode === null && child.signalCode === null)
363
+ child.kill("SIGKILL"); }, STOP_GRACE_MS).unref();
364
+ }
365
+ return Promise.all([exited, new Promise((r) => { local.close(() => r()); })]).then(() => undefined);
366
+ };
367
+ const settle = (v) => {
368
+ if (settled)
369
+ return;
370
+ settled = true;
371
+ clearTimeout(timer);
372
+ resolve(v);
373
+ };
374
+ // said only once ssh is gone: the command exits on this answer, and the timer that kills an ssh ignoring SIGTERM dies with it
375
+ const fail = (f) => {
376
+ if (settled)
377
+ return;
378
+ settled = true;
379
+ clearTimeout(timer);
380
+ void stop().then(() => { resolve(f); });
381
+ };
382
+ const timer = setTimeout(() => {
383
+ const last = lastLines(said, null);
384
+ fail({ message: `no answer from ${o.target} in ${Math.round(o.timeoutMs / 1000)} s`, code: "failed", ...(last === "" ? {} : { why: `ssh printed: ${last}` }),
385
+ fix: `run ssh ${o.target} on its own to see where it waits; --timeout gives it longer`, next: [[`ssh ${o.target} true`, "whether ssh reaches it on its own"]] });
386
+ }, o.timeoutMs);
387
+ // Ctrl-C before an answer: ssh goes too, a process signalled alone included
388
+ void o.stopped.then(() => { fail({ message: "stopped before the server answered", code: "failed" }); });
389
+ const helloIn = (h) => {
390
+ if ("error" in h) {
391
+ fail(refusal(h, o.target));
392
+ return;
393
+ }
394
+ if (h.protocol !== BRIDGE_PROTOCOL) {
395
+ fail({ message: `the Mason on ${o.target} (${h.version}) and this one (${version()}) speak different bridges`, code: "refused", why: `its bridge is version ${h.protocol}, this one's ${BRIDGE_PROTOCOL}`,
396
+ fix: "run the same Mason version on both ends", next: [["mason --version", "this machine's"]] });
397
+ return;
398
+ }
399
+ // what a later drop repeats is what ssh says from here on, never the login's banner
400
+ said = "";
401
+ settle({ url: `http://127.0.0.1:${port}/?ticket=${encodeURIComponent(h.ticket)}`, port, project: h.project, serverVersion: h.version, ended, stop });
402
+ };
403
+ child.stdout.on("data", (b) => {
404
+ if (link !== null) {
405
+ link.read(b);
406
+ return;
407
+ }
408
+ output = Buffer.concat([output, b]);
409
+ const at = output.indexOf(`${HELLO} `);
410
+ const eol = at < 0 ? -1 : output.indexOf("\n", at);
411
+ if (eol < 0) {
412
+ if (output.length > MAX_FRAME)
413
+ output = output.subarray(-MAX_FRAME);
414
+ return;
415
+ }
416
+ const rest = output.subarray(eol + 1);
417
+ let h;
418
+ try {
419
+ h = JSON.parse(output.subarray(at + HELLO.length + 1, eol).toString("utf8"));
420
+ }
421
+ catch (e) {
422
+ fail({ message: `the bridge on ${o.target} said something that is not its hello`, code: "failed", why: e.message, fix: "run the same Mason version on both ends" });
423
+ return;
424
+ }
425
+ link = new FrameLink({ input: child.stdout, output: child.stdin, broken: (why) => { void stop().then(() => { finish(why); }); } });
426
+ helloIn(h);
427
+ if (rest.length > 0)
428
+ link.read(rest);
429
+ });
430
+ child.stderr.on("data", (b) => { said = (said + b.toString("utf8")).slice(-16 * 1024); process.stderr.write(b); });
431
+ // ssh gone with bytes still to write: said by its exit
432
+ child.stdin.on("error", () => undefined);
433
+ child.on("error", (e) => {
434
+ if (e.code === "ENOENT") {
435
+ fail({ message: "ssh is not on this machine's PATH, and mason ui --ssh runs it", code: "not_found", fix: "install the OpenSSH client (it comes with macOS, Windows 10 and later, and most Linux; apt install openssh-client)" });
436
+ gone();
437
+ return;
438
+ }
439
+ fail({ message: `ssh could not be started: ${e.message}`, code: "failed" });
440
+ gone();
441
+ });
442
+ child.on("exit", (code, signal) => {
443
+ gone();
444
+ // read after a moment: a Ctrl-C reaches ssh and this process together, and `stop` says which end it was
445
+ setTimeout(() => {
446
+ const last = lastLines(said, null);
447
+ if (!settled) {
448
+ fail(exitFailure({ target: o.target, mason: o.mason, code, signal, said: last, stderr: said }));
449
+ return;
450
+ }
451
+ if (stopping)
452
+ return;
453
+ link?.end();
454
+ local.close();
455
+ finish(last === "" ? `ssh exited ${code ?? signal}` : `ssh exited ${code ?? signal}: ${last}`);
456
+ }, SETTLE_MS).unref();
457
+ });
458
+ });
459
+ });
460
+ }