@mason-data/cli 0.8.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 (109) hide show
  1. package/README.md +13 -4
  2. package/dist/src/api/postgres/catalog-worker.js +49 -2
  3. package/dist/src/api/postgres/catalog.js +166 -32
  4. package/dist/src/api/postgres/listener.js +11 -3
  5. package/dist/src/api/postgres/proxy.js +283 -0
  6. package/dist/src/api/postgres/schema.js +6 -0
  7. package/dist/src/api/postgres/server.js +505 -99
  8. package/dist/src/api/postgres/translate.js +47 -2
  9. package/dist/src/cli/background.js +18 -19
  10. package/dist/src/cli/bridge-stdin.js +101 -0
  11. package/dist/src/cli/command.js +169 -11
  12. package/dist/src/cli/commands.js +207 -51
  13. package/dist/src/cli/credentials.js +182 -0
  14. package/dist/src/cli/demo.js +2 -2
  15. package/dist/src/cli/deploy.js +574 -0
  16. package/dist/src/cli/dev.js +148 -27
  17. package/dist/src/cli/doctor.js +116 -33
  18. package/dist/src/cli/generated-files.js +143 -0
  19. package/dist/src/cli/guides.js +97 -6
  20. package/dist/src/cli/hosted.js +850 -0
  21. package/dist/src/cli/init.js +47 -11
  22. package/dist/src/cli/login.js +289 -0
  23. package/dist/src/cli/machine.js +1 -1
  24. package/dist/src/cli/main.js +8 -2
  25. package/dist/src/cli/mason.cjs +17 -0
  26. package/dist/src/cli/mcp.js +2 -2
  27. package/dist/src/cli/output-gate.js +176 -0
  28. package/dist/src/cli/page-share.js +96 -0
  29. package/dist/src/cli/pg-tls.js +1 -1
  30. package/dist/src/cli/pin.js +6 -2
  31. package/dist/src/cli/pool-size.cjs +85 -0
  32. package/dist/src/cli/ports.js +2 -0
  33. package/dist/src/cli/preflight.js +130 -39
  34. package/dist/src/cli/project.js +99 -0
  35. package/dist/src/cli/readiness.js +42 -6
  36. package/dist/src/cli/registry.js +12 -4
  37. package/dist/src/cli/replica-hosted.js +665 -0
  38. package/dist/src/cli/replica.js +76 -0
  39. package/dist/src/cli/roles.js +2 -1
  40. package/dist/src/cli/run-server.js +154 -7
  41. package/dist/src/cli/security.js +57 -10
  42. package/dist/src/cli/serve.js +63 -7
  43. package/dist/src/cli/share-run.js +51 -8
  44. package/dist/src/cli/share.js +3 -3
  45. package/dist/src/cli/source-edit.js +10 -4
  46. package/dist/src/cli/source-role.js +344 -0
  47. package/dist/src/cli/sources.js +30 -20
  48. package/dist/src/cli/style.js +2 -1
  49. package/dist/src/cli/supervise.js +15 -23
  50. package/dist/src/cli/token-hosted.js +337 -0
  51. package/dist/src/cli/ui-ssh.js +460 -0
  52. package/dist/src/cli/ui.js +115 -11
  53. package/dist/src/cli/user-hosted.js +299 -0
  54. package/dist/src/cli/user.js +126 -74
  55. package/dist/src/demo/seed.js +26 -0
  56. package/dist/src/duck/analyze.js +36 -20
  57. package/dist/src/duck/collation.js +266 -0
  58. package/dist/src/duck/constant-key.js +94 -0
  59. package/dist/src/duck/distinct-key.js +283 -26
  60. package/dist/src/duck/events.js +39 -8
  61. package/dist/src/duck/executor-worker.js +2 -2
  62. package/dist/src/duck/executor.js +6 -5
  63. package/dist/src/duck/explain.js +4 -0
  64. package/dist/src/duck/functions.js +448 -49
  65. package/dist/src/duck/gaps.js +34 -16
  66. package/dist/src/duck/generated-plan.js +1070 -0
  67. package/dist/src/duck/generated.js +906 -0
  68. package/dist/src/duck/grants.js +1 -1
  69. package/dist/src/duck/held-types.js +64 -0
  70. package/dist/src/duck/history.js +4 -0
  71. package/dist/src/duck/jsonb-order.js +9 -12
  72. package/dist/src/duck/kept-route.js +6 -5
  73. package/dist/src/duck/kept.js +9 -7
  74. package/dist/src/duck/late.js +1469 -40
  75. package/dist/src/duck/libc-bound.js +341 -0
  76. package/dist/src/duck/libc-order.js +892 -126
  77. package/dist/src/duck/pg-eval-worker.js +7 -1
  78. package/dist/src/duck/pg-eval.js +6 -4
  79. package/dist/src/duck/pgtree.js +774 -23
  80. package/dist/src/duck/plan.js +275 -30
  81. package/dist/src/duck/publication.js +14 -0
  82. package/dist/src/duck/requests.js +9 -5
  83. package/dist/src/duck/resolved.js +117 -2
  84. package/dist/src/duck/rewrite.js +4567 -583
  85. package/dist/src/duck/row-filter.js +12 -6
  86. package/dist/src/duck/statement.js +19 -0
  87. package/dist/src/duck/store.js +294 -68
  88. package/dist/src/duck/stream.js +9 -5
  89. package/dist/src/duck/sync.js +602 -126
  90. package/dist/src/duck/token.js +4 -1
  91. package/dist/src/duck/types.js +63 -7
  92. package/dist/src/duck/usage.js +6 -4
  93. package/dist/src/duck/users.js +5 -0
  94. package/dist/src/duck/verified.js +159 -2
  95. package/dist/src/duck/views.js +4 -3
  96. package/dist/src/lib/database-url.js +55 -10
  97. package/dist/src/lib/instant.js +50 -0
  98. package/dist/src/lib/log-buffer.js +13 -4
  99. package/dist/src/lib/log-read.js +196 -0
  100. package/dist/src/lib/pg-numeric.js +387 -49
  101. package/dist/src/lib/secret-mask.js +142 -0
  102. package/dist/src/lib/table-sync.js +24 -9
  103. package/dist/src/web/app.js +9 -4
  104. package/dist/src/web/pages/status.js +178 -13
  105. package/dist/src/web/pages/syncs.js +1 -1
  106. package/dist/src/web/pages/tables.js +8 -3
  107. package/dist/src/web/pages/users.js +4 -3
  108. package/dist/src/web/shared.js +19 -3
  109. package/package.json +8 -7
@@ -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
+ }
@@ -7,18 +7,27 @@
7
7
  * `mason status`, which prints the URL without it.
8
8
  *
9
9
  * Before it prints anything it ASKS the page (`GET /` with the run's token as the commands send it), so a run that serves
10
- * none (`mason serve`'s, or one from before the page) is said, never a URL that answers 404.
10
+ * none (one from before the page) is said, never a URL that answers 404.
11
+ *
12
+ * `--ssh <user@server>` (src/cli/ui-ssh.ts, docs/WEB-UI.md "On a server") opens a server's page from this machine, through
13
+ * the machine's own ssh, and stays in the foreground until Ctrl-C, as `mason share` does; it needs no project here. Its
14
+ * URL carries a ticket for one use, never the run's token, which stays on the server. `--bridge` is what it runs there.
11
15
  */
12
16
  import { spawn } from "node:child_process";
13
17
  import { failed, jsonText, parseCommand, reportText } from "./command.js";
14
18
  import { reach } from "./state.js";
19
+ import { bridge, openSshTunnel, targetProblem } from "./ui-ssh.js";
20
+ import { version } from "./version.js";
15
21
  import { RunUnreachable } from "./control.js";
16
22
  const out = (m) => { process.stdout.write(`${m}\n`); };
23
+ const say = (m) => { process.stderr.write(`${m}\n`); };
24
+ const DEFAULT_TIMEOUT_S = 60;
17
25
  export const UI = {
18
26
  needs: "run",
19
27
  name: "ui",
20
28
  summary: "the status, tables, users and statements, in your browser",
21
29
  usage: `usage: mason ui [--no-open] [--json]
30
+ mason ui --ssh <user@server> [--mason <command>] [--timeout <seconds>] [--no-open] [--json]
22
31
 
23
32
  Opens the running Mason's web page in your browser, refreshed while the tab is open: the Overview (what mason status
24
33
  says: whether the synced data is ready, the database, the sync, the statements and the recent errors); the Tables (add,
@@ -28,18 +37,36 @@ export const UI = {
28
37
 
29
38
  The URL carries this run's token: the page trades it for a cookie at once and drops it from the address
30
39
  bar. It is printed for you alone, like a user's password: paste it nowhere else. Every mason start makes a new
31
- token, so after a restart run mason ui again. The page is mason start's: mason serve serves none (off this
32
- machine it would need TLS and a login).
40
+ token, so after a restart run mason ui again.
41
+
42
+ --ssh opens a server's page (mason serve's) from this machine, with nothing public: it runs your ssh, so your
43
+ ~/.ssh/config, keys and SSO apply, starts mason there, and forwards a free local port to the page, which listens on the
44
+ server's loopback alone. It stays in the foreground: Ctrl-C closes the tunnel. It needs no project here. Its URL
45
+ carries a ticket for one use, never the run's token, which stays on the server. The server's Mason must be this
46
+ version or close to it, and must be a command ssh can run: --mason says how, for a container or a folder.
33
47
 
34
48
  mason ui
35
49
  mason ui --no-open print the URL, open no browser
36
50
  mason ui --json
51
+ mason ui --ssh deploy@mason.example.com
52
+ mason ui --ssh deploy@mason.example.com --mason "docker exec -i mason-mason-1 mason"
53
+ mason ui --ssh deploy@mason.example.com --mason "cd /var/lib/mason && mason" --timeout 120 --no-open
54
+ mason ui --bridge --local-port 51000 what --ssh runs on the server; its stdout is a stream, not for reading
37
55
 
38
- --no-open print the URL only
39
- --json as data: { url (with this run's token), port }, next; it never opens a browser
56
+ --no-open print the URL only
57
+ --ssh <target> the server, as you give it to ssh: user@server, or a Host of your ssh config
58
+ --mason <command> how to run mason on the server (default mason); a shell's text, kept to ssh's command line
59
+ --timeout <s> how long ssh and the server have to answer (default ${DEFAULT_TIMEOUT_S}): a passphrase or a touch counts
60
+ --bridge the server's end of --ssh, on stdin and stdout
61
+ --local-port <n> the port the browser names, for --bridge: the laptop's
62
+ --json as data: { url (with this run's token; with --ssh a ticket for one use; null when Ctrl-C came
63
+ before the server answered, as port is, exit 0), port, server, project,
64
+ version (with --ssh: the server's Mason, for what its answer may lack) }, next; it never opens a
65
+ browser. With --ssh it then holds the tunnel: the end is on stderr, exit 0 for Ctrl-C or SIGTERM, 1 otherwise
40
66
  `,
41
- options: { "no-open": { type: "boolean", default: false }, json: { type: "boolean", default: false } },
42
- json: ["url", "port", "next"],
67
+ options: { "no-open": { type: "boolean", default: false }, json: { type: "boolean", default: false }, ssh: { type: "string" }, mason: { type: "string" },
68
+ timeout: { type: "string" }, bridge: { type: "boolean", default: false }, "local-port": { type: "string" } },
69
+ json: ["url", "port", "server", "project", "version", "next"],
43
70
  };
44
71
  const NEXT = [["mason status", "the same, in this terminal"], ["mason user list", "the users"]];
45
72
  /** The command that opens a URL in the person's browser, by platform (macOS `open`, Windows `start`, else `xdg-open`). */
@@ -52,7 +79,7 @@ export function openCommand(platform, url) {
52
79
  return ["xdg-open", [url]];
53
80
  }
54
81
  /** Open `url` in the browser: null once the opener started, else why it could not (the URL is printed either way). */
55
- function openBrowser(url) {
82
+ export function openBrowser(url) {
56
83
  const [command, args] = openCommand(process.platform, url);
57
84
  return new Promise((resolve) => {
58
85
  const child = spawn(command, args, { detached: true, stdio: "ignore" });
@@ -72,21 +99,98 @@ async function servesPage(run) {
72
99
  await res.arrayBuffer();
73
100
  return res.status === 200 && /^text\/html/.test(res.headers.get("content-type") ?? "");
74
101
  }
102
+ /**
103
+ * THE PAGE OF A SERVER (the header): the tunnel, the URL once, and held until Ctrl-C or until it drops. As `mason share`:
104
+ * what `--json` prints is one object, once, and everything after it is on stderr, so stdout stays one document. The
105
+ * signals are heard before ssh starts: a Ctrl-C at a passphrase ends both.
106
+ */
107
+ async function overSsh(target, mason, timeoutMs, open, json) {
108
+ let signalled = null;
109
+ const signal = new Promise((resolve) => {
110
+ const heard = (s) => { signalled = s; resolve(s); };
111
+ process.once("SIGINT", heard);
112
+ process.once("SIGTERM", heard);
113
+ });
114
+ const t = await openSshTunnel({ target, mason, timeoutMs, interactive: !json && process.stdin.isTTY === true, stopped: signal });
115
+ const next = [[`mason ui --ssh ${target}`, "again, once this one is closed: a new port and a new ticket"]];
116
+ if ("message" in t) {
117
+ if (signalled === null)
118
+ return failed("ui", t, { json });
119
+ // stopped on purpose, exit 0: under --json still the one object every answer is, its url null as nothing opened
120
+ if (json)
121
+ out(jsonText(UI, { url: null, port: null, server: target }, next));
122
+ else
123
+ say(`stopped before ${target} answered: nothing was left open`);
124
+ return 0;
125
+ }
126
+ if (json)
127
+ out(jsonText(UI, { url: t.url, port: t.port, server: target, project: t.project, version: t.serverVersion }, next));
128
+ else {
129
+ const couldNot = open ? await openBrowser(t.url) : null;
130
+ out(reportText({
131
+ headline: open && couldNot === null ? `${t.project} on ${target}, opened in your browser` : `${t.project} on ${target}`,
132
+ details: [["url", t.url], ["server", target], ["project", t.project], ["version", t.serverVersion]],
133
+ tips: [
134
+ ...(couldNot !== null ? [`Could not open a browser (${couldNot}): open the URL yourself.`] : []),
135
+ ...(t.serverVersion !== version() ? [`This machine's Mason is ${version()}: the page is the server's.`] : []),
136
+ "The URL carries a ticket for one use, and works for 5 minutes. Ctrl-C closes the tunnel.",
137
+ ],
138
+ next,
139
+ }));
140
+ }
141
+ const how = await Promise.race([signal.then((s) => ({ signal: s })), t.ended.then((why) => ({ why }))]);
142
+ if ("signal" in how) {
143
+ await t.stop();
144
+ say(`the tunnel to ${target} is closed`);
145
+ return 0;
146
+ }
147
+ return failed("ui", { message: `the tunnel to ${target} dropped`, code: "failed", why: how.why, fix: "is the server up, and this machine online? The page in your browser stops answering until a new tunnel opens one", next }, { json: false });
148
+ }
75
149
  export async function ui(argv) {
76
150
  const f = parseCommand(UI, argv);
77
151
  if (typeof f === "number")
78
152
  return f;
79
153
  const json = f.values.json === true;
154
+ const v = f.values;
155
+ const wrong = (message) => failed("ui", { message, code: "usage", usage: UI.usage }, { json });
80
156
  if (f.positionals.length)
81
- return failed("ui", { message: `mason ui takes no argument: ${f.positionals.join(" ")}`, code: "usage", usage: UI.usage }, { json });
157
+ return wrong(`mason ui takes no argument: ${f.positionals.join(" ")}`);
158
+ if (v.bridge) {
159
+ if (v.ssh !== undefined || v.mason !== undefined || v.timeout !== undefined || json)
160
+ return wrong("--bridge is mason ui --ssh's own end on the server: it goes with --local-port alone");
161
+ const port = Number(v["local-port"]);
162
+ if (!Number.isInteger(port) || port < 1 || port > 65_535)
163
+ return wrong(`--bridge needs --local-port, a port number, not ${JSON.stringify(v["local-port"] ?? "")}`);
164
+ return bridge(port);
165
+ }
166
+ if (v["local-port"] !== undefined)
167
+ return wrong("--local-port goes with --bridge");
168
+ if (v.ssh === undefined && (v.mason !== undefined || v.timeout !== undefined))
169
+ return wrong(`${v.mason !== undefined ? "--mason" : "--timeout"} goes with --ssh <user@server>`);
170
+ if (v.ssh !== undefined) {
171
+ const problem = targetProblem(v.ssh);
172
+ if (problem !== null)
173
+ return wrong(`--ssh: ${problem}`);
174
+ if (v.mason !== undefined && (v.mason.trim() === "" || /[\r\n]/.test(v.mason)))
175
+ return wrong("--mason is one line of shell that runs mason on the server");
176
+ const seconds = v.timeout === undefined ? DEFAULT_TIMEOUT_S : Number(v.timeout);
177
+ if (!Number.isFinite(seconds) || seconds <= 0)
178
+ return wrong(`--timeout is a number of seconds, not ${JSON.stringify(v.timeout)}`);
179
+ return overSsh(v.ssh, v.mason ?? "mason", seconds * 1000, !v["no-open"], json);
180
+ }
82
181
  const w = await reach(UI, json);
83
182
  if (w === null || w.state !== "up")
84
183
  return 1;
85
184
  const run = w.run;
185
+ if (w.project.config.served === true) {
186
+ return failed("ui", { message: `Mason for ${run.name} runs on this server: its page is opened from your own machine`, code: "refused",
187
+ why: "the page listens on this machine's loopback alone, and a browser on a server has no one in front of it",
188
+ fix: "on your machine, mason ui --ssh <user@server> (mason ui --help: for a container, --mason)", next: [["mason status", "what it is doing, in this terminal"]] }, { json });
189
+ }
86
190
  if (!await servesPage(run)) {
87
191
  return failed("ui", { message: `Mason for ${run.name} serves no web page`, code: "refused",
88
- why: "the page is mason start's: this is mason serve's run, or a run started by a Mason from before the page",
89
- fix: "on your machine, mason stop && mason start runs it with the page; a server's Mason is read with mason status",
192
+ why: "this run was started by a Mason from before the page",
193
+ fix: "mason stop && mason start runs it with the page",
90
194
  next: [["mason status", "what it is doing, in this terminal"]] }, { json });
91
195
  }
92
196
  const url = `${run.control.url}/?token=${encodeURIComponent(run.control.token)}`;