@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.
- package/README.md +13 -4
- package/dist/src/api/postgres/catalog-worker.js +49 -2
- package/dist/src/api/postgres/catalog.js +166 -32
- package/dist/src/api/postgres/listener.js +11 -3
- package/dist/src/api/postgres/proxy.js +283 -0
- package/dist/src/api/postgres/schema.js +6 -0
- package/dist/src/api/postgres/server.js +505 -99
- package/dist/src/api/postgres/translate.js +47 -2
- package/dist/src/cli/background.js +18 -19
- package/dist/src/cli/bridge-stdin.js +101 -0
- package/dist/src/cli/command.js +169 -11
- package/dist/src/cli/commands.js +207 -51
- package/dist/src/cli/credentials.js +182 -0
- package/dist/src/cli/demo.js +2 -2
- package/dist/src/cli/deploy.js +574 -0
- package/dist/src/cli/dev.js +148 -27
- package/dist/src/cli/doctor.js +116 -33
- package/dist/src/cli/generated-files.js +143 -0
- package/dist/src/cli/guides.js +97 -6
- package/dist/src/cli/hosted.js +850 -0
- package/dist/src/cli/init.js +47 -11
- package/dist/src/cli/login.js +289 -0
- package/dist/src/cli/machine.js +1 -1
- package/dist/src/cli/main.js +8 -2
- package/dist/src/cli/mason.cjs +17 -0
- package/dist/src/cli/mcp.js +2 -2
- package/dist/src/cli/output-gate.js +176 -0
- package/dist/src/cli/page-share.js +96 -0
- package/dist/src/cli/pg-tls.js +1 -1
- package/dist/src/cli/pin.js +6 -2
- package/dist/src/cli/pool-size.cjs +85 -0
- package/dist/src/cli/ports.js +2 -0
- package/dist/src/cli/preflight.js +130 -39
- package/dist/src/cli/project.js +99 -0
- package/dist/src/cli/readiness.js +42 -6
- package/dist/src/cli/registry.js +12 -4
- package/dist/src/cli/replica-hosted.js +665 -0
- package/dist/src/cli/replica.js +76 -0
- package/dist/src/cli/roles.js +2 -1
- package/dist/src/cli/run-server.js +154 -7
- package/dist/src/cli/security.js +57 -10
- package/dist/src/cli/serve.js +63 -7
- package/dist/src/cli/share-run.js +51 -8
- package/dist/src/cli/share.js +3 -3
- package/dist/src/cli/source-edit.js +10 -4
- package/dist/src/cli/source-role.js +344 -0
- package/dist/src/cli/sources.js +30 -20
- package/dist/src/cli/style.js +2 -1
- package/dist/src/cli/supervise.js +15 -23
- package/dist/src/cli/token-hosted.js +337 -0
- package/dist/src/cli/ui-ssh.js +460 -0
- package/dist/src/cli/ui.js +115 -11
- package/dist/src/cli/user-hosted.js +299 -0
- package/dist/src/cli/user.js +126 -74
- package/dist/src/demo/seed.js +26 -0
- package/dist/src/duck/analyze.js +36 -20
- package/dist/src/duck/collation.js +266 -0
- package/dist/src/duck/constant-key.js +94 -0
- package/dist/src/duck/distinct-key.js +283 -26
- package/dist/src/duck/events.js +39 -8
- package/dist/src/duck/executor-worker.js +2 -2
- package/dist/src/duck/executor.js +6 -5
- package/dist/src/duck/explain.js +4 -0
- package/dist/src/duck/functions.js +448 -49
- package/dist/src/duck/gaps.js +34 -16
- package/dist/src/duck/generated-plan.js +1070 -0
- package/dist/src/duck/generated.js +906 -0
- package/dist/src/duck/grants.js +1 -1
- package/dist/src/duck/held-types.js +64 -0
- package/dist/src/duck/history.js +4 -0
- package/dist/src/duck/jsonb-order.js +9 -12
- package/dist/src/duck/kept-route.js +6 -5
- package/dist/src/duck/kept.js +9 -7
- package/dist/src/duck/late.js +1469 -40
- package/dist/src/duck/libc-bound.js +341 -0
- package/dist/src/duck/libc-order.js +892 -126
- package/dist/src/duck/pg-eval-worker.js +7 -1
- package/dist/src/duck/pg-eval.js +6 -4
- package/dist/src/duck/pgtree.js +774 -23
- package/dist/src/duck/plan.js +275 -30
- package/dist/src/duck/publication.js +14 -0
- package/dist/src/duck/requests.js +9 -5
- package/dist/src/duck/resolved.js +117 -2
- package/dist/src/duck/rewrite.js +4567 -583
- package/dist/src/duck/row-filter.js +12 -6
- package/dist/src/duck/statement.js +19 -0
- package/dist/src/duck/store.js +294 -68
- package/dist/src/duck/stream.js +9 -5
- package/dist/src/duck/sync.js +602 -126
- package/dist/src/duck/token.js +4 -1
- package/dist/src/duck/types.js +63 -7
- package/dist/src/duck/usage.js +6 -4
- package/dist/src/duck/users.js +5 -0
- package/dist/src/duck/verified.js +159 -2
- package/dist/src/duck/views.js +4 -3
- package/dist/src/lib/database-url.js +55 -10
- package/dist/src/lib/instant.js +50 -0
- package/dist/src/lib/log-buffer.js +13 -4
- package/dist/src/lib/log-read.js +196 -0
- package/dist/src/lib/pg-numeric.js +387 -49
- package/dist/src/lib/secret-mask.js +142 -0
- package/dist/src/lib/table-sync.js +24 -9
- package/dist/src/web/app.js +9 -4
- package/dist/src/web/pages/status.js +178 -13
- package/dist/src/web/pages/syncs.js +1 -1
- package/dist/src/web/pages/tables.js +8 -3
- package/dist/src/web/pages/users.js +4 -3
- package/dist/src/web/shared.js +19 -3
- 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
|
+
}
|
package/dist/src/cli/ui.js
CHANGED
|
@@ -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 (
|
|
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.
|
|
32
|
-
|
|
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
|
|
39
|
-
--
|
|
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
|
-
|
|
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
|
|
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: "
|
|
89
|
-
fix: "
|
|
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)}`;
|