@mason-data/cli 0.8.0 → 0.10.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 (118) hide show
  1. package/README.md +19 -8
  2. package/dist/src/api/error.js +4 -0
  3. package/dist/src/api/postgres/catalog-worker.js +49 -2
  4. package/dist/src/api/postgres/catalog.js +166 -32
  5. package/dist/src/api/postgres/listener.js +11 -3
  6. package/dist/src/api/postgres/proxy.js +129 -0
  7. package/dist/src/api/postgres/schema.js +6 -0
  8. package/dist/src/api/postgres/server.js +527 -103
  9. package/dist/src/api/postgres/translate.js +47 -2
  10. package/dist/src/cli/background.js +18 -19
  11. package/dist/src/cli/bridge-stdin.js +101 -0
  12. package/dist/src/cli/command.js +337 -15
  13. package/dist/src/cli/commands.js +458 -169
  14. package/dist/src/cli/credentials.js +182 -0
  15. package/dist/src/cli/demo.js +2 -2
  16. package/dist/src/cli/deploy.js +680 -0
  17. package/dist/src/cli/dev.js +148 -27
  18. package/dist/src/cli/doctor.js +122 -36
  19. package/dist/src/cli/generated-files.js +143 -0
  20. package/dist/src/cli/guides.js +116 -16
  21. package/dist/src/cli/hosted.js +850 -0
  22. package/dist/src/cli/init.js +154 -33
  23. package/dist/src/cli/login.js +292 -0
  24. package/dist/src/cli/machine.js +125 -40
  25. package/dist/src/cli/main.js +59 -10
  26. package/dist/src/cli/mason.cjs +17 -0
  27. package/dist/src/cli/mcp.js +17 -14
  28. package/dist/src/cli/output-gate.js +176 -0
  29. package/dist/src/cli/page-share.js +96 -0
  30. package/dist/src/cli/pg-tls.js +1 -1
  31. package/dist/src/cli/pin.js +6 -2
  32. package/dist/src/cli/pool-size.cjs +85 -0
  33. package/dist/src/cli/ports.js +2 -0
  34. package/dist/src/cli/preflight.js +130 -39
  35. package/dist/src/cli/project.js +100 -1
  36. package/dist/src/cli/readiness.js +42 -6
  37. package/dist/src/cli/registry.js +89 -36
  38. package/dist/src/cli/replica-hosted.js +1324 -0
  39. package/dist/src/cli/replica.js +114 -0
  40. package/dist/src/cli/replication-mode.js +5 -0
  41. package/dist/src/cli/request-filter.js +16 -6
  42. package/dist/src/cli/roles.js +2 -1
  43. package/dist/src/cli/run-server.js +179 -9
  44. package/dist/src/cli/secret-input.js +193 -0
  45. package/dist/src/cli/security.js +57 -10
  46. package/dist/src/cli/serve.js +65 -9
  47. package/dist/src/cli/share-run.js +51 -8
  48. package/dist/src/cli/share.js +23 -18
  49. package/dist/src/cli/source-edit.js +10 -4
  50. package/dist/src/cli/source-role.js +343 -0
  51. package/dist/src/cli/sources.js +50 -23
  52. package/dist/src/cli/style.js +16 -1
  53. package/dist/src/cli/supervise.js +15 -23
  54. package/dist/src/cli/sync.js +17 -8
  55. package/dist/src/cli/token-hosted.js +343 -0
  56. package/dist/src/cli/tunnel.js +1 -1
  57. package/dist/src/cli/ui-hosted.js +163 -0
  58. package/dist/src/cli/ui-ssh.js +460 -0
  59. package/dist/src/cli/ui.js +162 -17
  60. package/dist/src/cli/usage.js +19 -15
  61. package/dist/src/cli/user-hosted.js +364 -0
  62. package/dist/src/cli/user.js +275 -105
  63. package/dist/src/demo/seed.js +26 -0
  64. package/dist/src/duck/analyze.js +36 -20
  65. package/dist/src/duck/collation.js +266 -0
  66. package/dist/src/duck/constant-key.js +94 -0
  67. package/dist/src/duck/distinct-key.js +283 -26
  68. package/dist/src/duck/events.js +39 -8
  69. package/dist/src/duck/executor-worker.js +2 -2
  70. package/dist/src/duck/executor.js +6 -5
  71. package/dist/src/duck/explain.js +4 -0
  72. package/dist/src/duck/functions.js +448 -49
  73. package/dist/src/duck/gaps.js +34 -16
  74. package/dist/src/duck/generated-plan.js +1070 -0
  75. package/dist/src/duck/generated.js +906 -0
  76. package/dist/src/duck/grants.js +1 -1
  77. package/dist/src/duck/held-types.js +64 -0
  78. package/dist/src/duck/history.js +4 -0
  79. package/dist/src/duck/jsonb-order.js +9 -12
  80. package/dist/src/duck/kept-route.js +6 -5
  81. package/dist/src/duck/kept.js +9 -7
  82. package/dist/src/duck/late.js +1469 -40
  83. package/dist/src/duck/libc-bound.js +341 -0
  84. package/dist/src/duck/libc-order.js +892 -126
  85. package/dist/src/duck/pg-eval-worker.js +7 -1
  86. package/dist/src/duck/pg-eval.js +6 -4
  87. package/dist/src/duck/pgtree.js +774 -23
  88. package/dist/src/duck/plan.js +275 -30
  89. package/dist/src/duck/publication.js +14 -0
  90. package/dist/src/duck/requests.js +9 -5
  91. package/dist/src/duck/resolved.js +117 -2
  92. package/dist/src/duck/rewrite.js +4567 -583
  93. package/dist/src/duck/row-filter.js +12 -6
  94. package/dist/src/duck/statement.js +19 -0
  95. package/dist/src/duck/store.js +294 -68
  96. package/dist/src/duck/stream.js +9 -5
  97. package/dist/src/duck/sync.js +602 -126
  98. package/dist/src/duck/token.js +4 -1
  99. package/dist/src/duck/types.js +63 -7
  100. package/dist/src/duck/usage.js +6 -4
  101. package/dist/src/duck/users.js +110 -9
  102. package/dist/src/duck/verified.js +159 -2
  103. package/dist/src/duck/views.js +4 -3
  104. package/dist/src/lib/cidr.js +177 -0
  105. package/dist/src/lib/database-url.js +55 -10
  106. package/dist/src/lib/instant.js +50 -0
  107. package/dist/src/lib/log-buffer.js +13 -4
  108. package/dist/src/lib/log-read.js +196 -0
  109. package/dist/src/lib/pg-numeric.js +387 -49
  110. package/dist/src/lib/secret-mask.js +142 -0
  111. package/dist/src/lib/table-sync.js +84 -19
  112. package/dist/src/web/app.js +9 -4
  113. package/dist/src/web/pages/status.js +178 -13
  114. package/dist/src/web/pages/syncs.js +1 -1
  115. package/dist/src/web/pages/tables.js +8 -3
  116. package/dist/src/web/pages/users.js +4 -3
  117. package/dist/src/web/shared.js +19 -3
  118. package/package.json +8 -7
@@ -0,0 +1,163 @@
1
+ /**
2
+ * `mason ui --replica <org>/<name>` — A HOSTED REPLICA'S WEB PAGE, IN THE BROWSER OF THE ORGANISATION'S OWNER
3
+ * (docs/HOSTED-API.md "A replica's page"). ONE request, `POST /v1/replicas/{org}/{name}/ui`, whose answer is a one-use,
4
+ * short-lived address of the replica's page (on the service's origin, or on https at a subdomain of its host), read-only
5
+ * (the service refuses every write through it: changes go by `mason user … --replica`, which it audits); the CLI opens it as `mason ui` opens a run's
6
+ * page and `mason login` its sign-in page (`openBrowser`), or prints it (`--no-open`, `--json`, a browser that would not
7
+ * start). Who may ask (the owner's login alone), and the audit row of each page opened, are the service's: the CLI cannot
8
+ * write that row and never says it did.
9
+ *
10
+ * Every value of the answer, and how it is held (src/cli/hosted.ts's header: a service's value is data, never structure):
11
+ *
12
+ * value where it is printed what holds it
13
+ * every string, keys too nowhere until held `carriesNoSecret`: an answer carrying a secret is refused whole
14
+ * url opened; printed where it was not `pageOrigin`, `pageUrl`: the service's origin or https at a
15
+ * subdomain of its host, its port; no user, at most 2048, carries
16
+ * opened, and in --json no secret as printed; then `OPENABLE`: letters, digits and
17
+ * `. : [ ] - _ ~ / ? = +` alone, so no shell reads it
18
+ * expiresAt a row, --json `isServiceTime`
19
+ * a failure's words stderr's failure, --json error `serviceFailure`: one line each, masked; the code by the status
20
+ * a failure's next nowhere: never read the step is `failureNext`'s, by the status
21
+ * Retry-After a next step's words `retryAfterOf`: 1 to 3600 seconds
22
+ *
23
+ * THE ADDRESS IS PRINTED ONLY WHERE THE PERSON NEEDS IT: opened in the browser, it is not printed (the browser used it,
24
+ * and it opens the page once); printed when it was not opened, and in `--json` for the agent that passes it on. It is
25
+ * never logged: this command writes no log. A 409 or a 503 (a replica not ready, its machine not connected) is said
26
+ * with the next step, never waited on: the person runs the command again.
27
+ */
28
+ import { failed, jsonText, reportText, serviceStyle } from "./command.js";
29
+ import { environmentToken, TOKEN_ENV } from "./credentials.js";
30
+ import { call, carriesNoSecret, codeOfStatus, isFailure, isServiceTime, pageUrl, serviceFailure, serviceOf, unexpected } from "./hosted.js";
31
+ import { holdOutput } from "./output-gate.js";
32
+ import { replicaText, tokenFor } from "./replica.js";
33
+ import { err as styleErr, out as styleOut } from "./style.js";
34
+ import { OPENABLE, openBrowser, UI } from "./ui.js";
35
+ const out = (m) => { process.stdout.write(`${m}\n`); };
36
+ const obj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
37
+ /** The request: the replica's page, asked of the service. Both parts are `REPLICA_PART`'s, safe in a path as they are. */
38
+ export const pagePath = (r) => `/v1/replicas/${r.org}/${r.name}/ui`;
39
+ const STEP = "a replica's page was asked for";
40
+ /**
41
+ * THE ORIGIN A REPLICA'S PAGE MAY BE ON: the service's own, or an `https:` origin whose host is a subdomain of the
42
+ * service's host, on the service's port (a page of its own origin keeps the service's session cookie and its routes
43
+ * apart from Mason's page: `r-abc.ui.app.example` under `app.example`). A subdomain by its labels, never a suffix of the
44
+ * text (`evilapp.example` is not under `app.example`), and none under an IP address. The origin the page is held to,
45
+ * or null when `v` is on none of them (or is no URL).
46
+ */
47
+ export function pageOrigin(service, v) {
48
+ if (typeof v !== "string")
49
+ return null;
50
+ let u, base;
51
+ try {
52
+ u = new URL(v);
53
+ base = new URL(service.base);
54
+ }
55
+ catch {
56
+ return null;
57
+ }
58
+ if (u.origin === service.base)
59
+ return service;
60
+ const ip = /^[\d.]+$/.test(base.hostname) || base.hostname.startsWith("[");
61
+ if (u.protocol !== "https:" || u.port !== base.port || ip || !u.hostname.endsWith(`.${base.hostname}`))
62
+ return null;
63
+ return { base: u.origin };
64
+ }
65
+ /**
66
+ * `POST …/ui`'s answer (200), held: every string of it, each key included, put to the exact test first
67
+ * (`carriesNoSecret`), then the address on an origin it may be on (`pageOrigin`; refused naming the origin when it is
68
+ * not), held there by `pageUrl` (no user or password, at most 2048, no secret as printed), of the characters a browser
69
+ * is handed (`OPENABLE`), and the time to its grammar. The page, or a Failure said by the status alone (or naming the
70
+ * other origin).
71
+ */
72
+ export function heldPage(service, a, secrets) {
73
+ const j = a.json;
74
+ if (!obj(j) || !carriesNoSecret(j, secrets))
75
+ return unexpected(service, a, STEP);
76
+ const origin = pageOrigin(service, j.url);
77
+ // on an origin it may be on, `pageUrl` holds it as on the service's own; elsewhere, `pageUrl` says the origin safely
78
+ const page = pageUrl(origin ?? service, j.url, secrets);
79
+ if (page !== null && "elsewhere" in page) {
80
+ return { message: `${service.base} named a replica's page on another address: ${page.elsewhere}`, code: "refused",
81
+ why: `a replica's page is opened only on the service's own address, ${service.base}, or on https at a subdomain of its host: anything else would send you to an address nobody chose`,
82
+ fix: "check MASON_CLOUD_URL names the service you mean; if it does, the service is answering outside its documented API" };
83
+ }
84
+ if (page === null || !OPENABLE.test(page.href) || !isServiceTime(j.expiresAt))
85
+ return unexpected(service, a, STEP);
86
+ return { url: page.href, expiresAt: j.expiresAt };
87
+ }
88
+ /**
89
+ * WHAT TO RUN AFTER THE SERVICE REFUSED: the CLI's own, by the status (never the service's `next`). A 401 or 403 is a
90
+ * login that may not (only the organisation's owner opens a replica's page; a CI token never does), a 404 a replica the
91
+ * login does not see, a 409 or 503 a replica not ready or not connected: its state, and the command again when the
92
+ * service's `Retry-After` says.
93
+ */
94
+ export function failureNext(status, r, retryAfter, env = process.env) {
95
+ const named = replicaText(r);
96
+ if (status === 401 || status === 403) {
97
+ return environmentToken(env) !== null
98
+ ? [[`env -u ${TOKEN_ENV} mason ui --replica ${named}`, `with your own login, not ${TOKEN_ENV}: a CI token never opens a page`]]
99
+ : [["mason login", "sign in as the organisation's owner: only an owner opens a replica's page"]];
100
+ }
101
+ if (status === 404)
102
+ return [[`mason replica list ${r.org}`, "the replicas this login sees"]];
103
+ if (status === 409 || status === 503) {
104
+ return [[`mason replica status ${named}`, "whether it is ready"],
105
+ [`mason ui --replica ${named}`, retryAfter !== null ? `again in ${retryAfter} s, as the service asks` : "again in a moment"]];
106
+ }
107
+ return [[`mason ui --replica ${named}`, "again in a moment"]];
108
+ }
109
+ /** A refusal said as the service worded it; a 409 or 503 it sent without its words is said in the CLI's. */
110
+ export function refusalOf(service, a, r, secrets, env = process.env) {
111
+ const next = failureNext(a.status, r, a.retryAfter, env);
112
+ const notReady = a.status === 409 || a.status === 503;
113
+ const again = a.retryAfter !== null ? `try again in ${a.retryAfter} s` : "try again in a moment";
114
+ const said = serviceFailure(a, secrets, () => next);
115
+ if (said !== null)
116
+ return notReady && said.fix === undefined ? { ...said, fix: `${again}; mason replica status ${replicaText(r)} says whether it is ready` } : said;
117
+ if (notReady) {
118
+ return { message: `the replica ${replicaText(r)} is not ready to open its page (HTTP ${a.status})`, code: codeOfStatus(a.status),
119
+ why: a.status === 409 ? "it is not ready yet: being made, deleted, or failed" : "its machine is not connected to the service now",
120
+ fix: `${again}; mason replica status ${replicaText(r)} says whether it is ready`, next };
121
+ }
122
+ return unexpected(service, a, STEP);
123
+ }
124
+ /** `mason ui --replica <org>/<name>`: the page asked for, then opened, printed, or said as --json. */
125
+ export async function hostedUi(r, o) {
126
+ const fail = (f) => failed("ui", f, { json: o.json, style: serviceStyle(styleErr()) });
127
+ const service = serviceOf();
128
+ if (isFailure(service))
129
+ return fail(service);
130
+ const token = tokenFor(service, { asked: "a replica's page is asked for", again: `mason ui --replica ${replicaText(r)}` });
131
+ if (isFailure(token))
132
+ return fail(token);
133
+ // from here every byte written is put to the exact test against the token (src/cli/output-gate.ts)
134
+ holdOutput({ command: "ui", json: o.json, secrets: [token] });
135
+ const secrets = [token];
136
+ const a = await call(service, "POST", pagePath(r), { token });
137
+ if (isFailure(a))
138
+ return fail(a);
139
+ if (a.status !== 200)
140
+ return fail(refusalOf(service, a, r, secrets));
141
+ const page = heldPage(service, a, secrets);
142
+ if (isFailure(page))
143
+ return fail(page);
144
+ const named = replicaText(r);
145
+ const next = [[`mason replica status ${named}`, "the replica's state, in this terminal"], [`mason ui --replica ${named}`, "a new address: each opens the page once"]];
146
+ if (o.json) {
147
+ out(jsonText(UI, { url: page.url, replica: named, expiresAt: page.expiresAt }, next));
148
+ return 0;
149
+ }
150
+ const couldNot = o.open ? await openBrowser(page.url) : null;
151
+ const opened = o.open && couldNot === null;
152
+ out(reportText({
153
+ headline: opened ? `The page of the replica ${named}, opened in your browser` : `The page of the replica ${named}`,
154
+ // opened, the address is not printed: the browser used it, and it opens the page once
155
+ details: [...(opened ? [] : [["url", page.url]]), ["replica", named], ["expires", page.expiresAt]],
156
+ tips: [
157
+ ...(couldNot !== null ? [`Could not open a browser (${couldNot}): open the URL yourself.`] : []),
158
+ opened ? "The address opens the page once: for another tab, run the command again." : "The URL opens the page once, for you alone, until it expires: paste it nowhere else.",
159
+ ],
160
+ next,
161
+ }, serviceStyle(styleOut())));
162
+ return 0;
163
+ }
@@ -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, failureCode, 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: failureCode(h.error.code, "failed"), ...(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: "timeout", ...(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
+ }