@mason-data/cli 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/README.md +101 -73
  2. package/dist/src/api/error.js +3 -0
  3. package/dist/src/api/postgres/catalog-worker.js +49 -2
  4. package/dist/src/api/postgres/catalog.js +368 -25
  5. package/dist/src/api/postgres/encode.js +3 -1
  6. package/dist/src/api/postgres/explain.js +3 -2
  7. package/dist/src/api/postgres/listener.js +11 -3
  8. package/dist/src/api/postgres/protocol.js +39 -23
  9. package/dist/src/api/postgres/proxy.js +283 -0
  10. package/dist/src/api/postgres/schema.js +6 -0
  11. package/dist/src/api/postgres/server.js +1617 -245
  12. package/dist/src/api/postgres/translate.js +127 -10
  13. package/dist/src/api/request-log.js +18 -5
  14. package/dist/src/cdc/poll.js +5 -5
  15. package/dist/src/cli/background.js +47 -25
  16. package/dist/src/cli/bridge-stdin.js +101 -0
  17. package/dist/src/cli/cancel.js +75 -0
  18. package/dist/src/cli/command.js +170 -11
  19. package/dist/src/cli/commands.js +804 -209
  20. package/dist/src/cli/control.js +59 -0
  21. package/dist/src/cli/credentials.js +182 -0
  22. package/dist/src/cli/demo.js +20 -11
  23. package/dist/src/cli/deploy.js +574 -0
  24. package/dist/src/cli/dev.js +491 -130
  25. package/dist/src/cli/doctor.js +131 -38
  26. package/dist/src/cli/generated-files.js +143 -0
  27. package/dist/src/cli/guides.js +175 -36
  28. package/dist/src/cli/hosted.js +850 -0
  29. package/dist/src/cli/init.js +162 -65
  30. package/dist/src/cli/login.js +289 -0
  31. package/dist/src/cli/machine.js +13 -12
  32. package/dist/src/cli/main.js +11 -5
  33. package/dist/src/cli/mason.cjs +17 -0
  34. package/dist/src/cli/mcp.js +31 -22
  35. package/dist/src/cli/metrics.js +79 -0
  36. package/dist/src/cli/output-gate.js +176 -0
  37. package/dist/src/cli/page-share.js +96 -0
  38. package/dist/src/cli/pg-tls.js +14 -4
  39. package/dist/src/cli/pg-url.js +4 -0
  40. package/dist/src/cli/pin.js +19 -14
  41. package/dist/src/cli/pool-size.cjs +85 -0
  42. package/dist/src/cli/ports.js +2 -0
  43. package/dist/src/cli/preflight.js +138 -46
  44. package/dist/src/cli/project.js +142 -4
  45. package/dist/src/cli/readiness.js +76 -6
  46. package/dist/src/cli/registry.js +65 -38
  47. package/dist/src/cli/replica-hosted.js +665 -0
  48. package/dist/src/cli/replica.js +76 -0
  49. package/dist/src/cli/replication-checks.js +7 -5
  50. package/dist/src/cli/replication-mode.js +105 -26
  51. package/dist/src/cli/request-filter.js +134 -0
  52. package/dist/src/cli/roles.js +585 -0
  53. package/dist/src/cli/run-server.js +637 -48
  54. package/dist/src/cli/runs.js +1 -1
  55. package/dist/src/cli/security.js +67 -18
  56. package/dist/src/cli/serve.js +204 -46
  57. package/dist/src/cli/share-run.js +329 -0
  58. package/dist/src/cli/share.js +286 -0
  59. package/dist/src/cli/source-edit.js +75 -17
  60. package/dist/src/cli/source-role.js +344 -0
  61. package/dist/src/cli/sources.js +306 -47
  62. package/dist/src/cli/style.js +2 -1
  63. package/dist/src/cli/supervise.js +27 -24
  64. package/dist/src/cli/sync.js +55 -25
  65. package/dist/src/cli/token-hosted.js +337 -0
  66. package/dist/src/cli/tunnel.js +200 -0
  67. package/dist/src/cli/ui-ssh.js +460 -0
  68. package/dist/src/cli/ui.js +124 -17
  69. package/dist/src/cli/usage.js +128 -0
  70. package/dist/src/cli/user-hosted.js +299 -0
  71. package/dist/src/cli/user.js +573 -65
  72. package/dist/src/cli/version.js +9 -7
  73. package/dist/src/demo/seed.js +29 -0
  74. package/dist/src/duck/analyze.js +36 -20
  75. package/dist/src/duck/cancel.js +10 -1
  76. package/dist/src/duck/collation.js +266 -0
  77. package/dist/src/duck/constant-key.js +94 -0
  78. package/dist/src/duck/distinct-key.js +283 -26
  79. package/dist/src/duck/events.js +165 -0
  80. package/dist/src/duck/executor-worker.js +20 -11
  81. package/dist/src/duck/executor.js +47 -33
  82. package/dist/src/duck/explain.js +60 -46
  83. package/dist/src/duck/extensions.js +160 -0
  84. package/dist/src/duck/functions.js +461 -63
  85. package/dist/src/duck/gaps.js +48 -21
  86. package/dist/src/duck/generated-plan.js +1070 -0
  87. package/dist/src/duck/generated.js +906 -0
  88. package/dist/src/duck/grants.js +176 -0
  89. package/dist/src/duck/held-types.js +64 -0
  90. package/dist/src/duck/history.js +127 -0
  91. package/dist/src/duck/jsonb-order.js +9 -12
  92. package/dist/src/duck/kept-route.js +6 -5
  93. package/dist/src/duck/kept.js +15 -13
  94. package/dist/src/duck/late.js +1469 -40
  95. package/dist/src/duck/libc-bound.js +341 -0
  96. package/dist/src/duck/libc-order.js +892 -126
  97. package/dist/src/duck/pg-eval-worker.js +7 -1
  98. package/dist/src/duck/pg-eval.js +6 -4
  99. package/dist/src/duck/pgtree.js +774 -23
  100. package/dist/src/duck/plan.js +369 -47
  101. package/dist/src/duck/publication.js +20 -6
  102. package/dist/src/duck/requests.js +269 -93
  103. package/dist/src/duck/resolved.js +117 -2
  104. package/dist/src/duck/rewrite.js +4584 -580
  105. package/dist/src/duck/row-filter.js +655 -0
  106. package/dist/src/duck/statement.js +19 -0
  107. package/dist/src/duck/store.js +439 -177
  108. package/dist/src/duck/stream.js +19 -10
  109. package/dist/src/duck/sync.js +1411 -368
  110. package/dist/src/duck/token.js +4 -1
  111. package/dist/src/duck/types.js +69 -12
  112. package/dist/src/duck/usage.js +326 -0
  113. package/dist/src/duck/users.js +69 -15
  114. package/dist/src/duck/verified.js +161 -1
  115. package/dist/src/duck/views.js +5 -5
  116. package/dist/src/lib/database-name.js +62 -0
  117. package/dist/src/lib/database-url.js +55 -10
  118. package/dist/src/lib/dotenv.js +17 -4
  119. package/dist/src/lib/instant.js +50 -0
  120. package/dist/src/lib/log-buffer.js +77 -0
  121. package/dist/src/lib/log-read.js +196 -0
  122. package/dist/src/lib/pg-numeric.js +390 -49
  123. package/dist/src/lib/project-version.js +23 -23
  124. package/dist/src/lib/secret-mask.js +142 -0
  125. package/dist/src/lib/table-sync.js +282 -0
  126. package/dist/src/web/app.css +706 -66
  127. package/dist/src/web/app.js +331 -409
  128. package/dist/src/web/charts.js +229 -0
  129. package/dist/src/web/index.html +82 -18
  130. package/dist/src/web/pages/queries.js +192 -0
  131. package/dist/src/web/pages/status.js +611 -0
  132. package/dist/src/web/pages/syncs.js +155 -0
  133. package/dist/src/web/pages/tables.js +439 -0
  134. package/dist/src/web/pages/usage.js +397 -0
  135. package/dist/src/web/pages/users.js +219 -0
  136. package/dist/src/web/shared.js +584 -0
  137. package/dist/src/web/vendor/README.md +25 -0
  138. package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
  139. package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
  140. package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
  141. package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
  142. package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
  143. package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
  144. package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
  145. package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
  146. package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
  147. package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
  148. package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
  149. package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
  150. package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
  151. package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
  152. package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
  153. package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
  154. package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
  155. package/package.json +12 -4
@@ -1,42 +1,72 @@
1
1
  /**
2
- * `mason ui` — THE WEB PAGE OF THE RUNNING MASON, IN THE BROWSER (src/web/, docs/WEB-UI.md): what `mason status` says,
3
- * refreshed while the tab is seen, and the users of `mason user`. It prints the page's URL WITH the run's token (src/cli/security.ts: the first request trades it for an HttpOnly cookie and is redirected to a URL without
2
+ * `mason ui` — THE WEB PAGE OF THE RUNNING MASON, IN THE BROWSER (src/web/, docs/WEB-UI.md): the Overview (what `mason
3
+ * status` says), the Tables (`mason tables`, `add`, `remove`, `sync`), the Users (`mason user`) and Observability (`mason
4
+ * logs`, `mason usage`), refreshed while the tab is seen. It prints the page's URL WITH the run's token (src/cli/security.ts: the first request trades it for an HttpOnly cookie and is redirected to a URL without
4
5
  * it) and opens the browser. The reader is the person at this terminal, whom `mason user add` already shows a
5
6
  * password once; the token is never written anywhere else — not in `mason start`'s output (its log is a file), not in
6
7
  * `mason status`, which prints the URL without it.
7
8
  *
8
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
9
- * 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.
10
15
  */
11
16
  import { spawn } from "node:child_process";
12
17
  import { failed, jsonText, parseCommand, reportText } from "./command.js";
13
18
  import { reach } from "./state.js";
19
+ import { bridge, openSshTunnel, targetProblem } from "./ui-ssh.js";
20
+ import { version } from "./version.js";
14
21
  import { RunUnreachable } from "./control.js";
15
22
  const out = (m) => { process.stdout.write(`${m}\n`); };
23
+ const say = (m) => { process.stderr.write(`${m}\n`); };
24
+ const DEFAULT_TIMEOUT_S = 60;
16
25
  export const UI = {
17
26
  needs: "run",
18
27
  name: "ui",
19
- summary: "the status page and the users, in your browser",
28
+ summary: "the status, tables, users and statements, in your browser",
20
29
  usage: `usage: mason ui [--no-open] [--json]
30
+ mason ui --ssh <user@server> [--mason <command>] [--timeout <seconds>] [--no-open] [--json]
21
31
 
22
- Opens the running Mason's web page in your browser: what mason status says (the database, each table's copy and
23
- its progress, the slow and refused statements, whether it is ready), refreshed every 2 s while the tab is open, and
24
- the users of mason user — add one (its password shown once, with the URL to paste) or remove one.
32
+ Opens the running Mason's web page in your browser, refreshed while the tab is open: the Overview (what mason status
33
+ says: whether the synced data is ready, the database, the sync, the statements and the recent errors); the Tables (add,
34
+ remove or sync one, as mason add, remove and sync do); the Users of mason user (add one, its password shown once with
35
+ the URL to paste, or remove one); and Observability: the statements running and that ran (mason logs --requests,
36
+ cancel one), usage over time (mason usage), and the sync's events (mason logs --events).
25
37
 
26
38
  The URL carries this run's token: the page trades it for a cookie at once and drops it from the address
27
39
  bar. It is printed for you alone, like a user's password: paste it nowhere else. Every mason start makes a new
28
- token, so after a restart run mason ui again. The page is mason start's: mason serve serves none (off this
29
- 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.
30
47
 
31
48
  mason ui
32
49
  mason ui --no-open print the URL, open no browser
33
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
34
55
 
35
- --no-open print the URL only
36
- --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
37
66
  `,
38
- options: { "no-open": { type: "boolean", default: false }, json: { type: "boolean", default: false } },
39
- 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"],
40
70
  };
41
71
  const NEXT = [["mason status", "the same, in this terminal"], ["mason user list", "the users"]];
42
72
  /** The command that opens a URL in the person's browser, by platform (macOS `open`, Windows `start`, else `xdg-open`). */
@@ -49,7 +79,7 @@ export function openCommand(platform, url) {
49
79
  return ["xdg-open", [url]];
50
80
  }
51
81
  /** Open `url` in the browser: null once the opener started, else why it could not (the URL is printed either way). */
52
- function openBrowser(url) {
82
+ export function openBrowser(url) {
53
83
  const [command, args] = openCommand(process.platform, url);
54
84
  return new Promise((resolve) => {
55
85
  const child = spawn(command, args, { detached: true, stdio: "ignore" });
@@ -69,21 +99,98 @@ async function servesPage(run) {
69
99
  await res.arrayBuffer();
70
100
  return res.status === 200 && /^text\/html/.test(res.headers.get("content-type") ?? "");
71
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
+ }
72
149
  export async function ui(argv) {
73
150
  const f = parseCommand(UI, argv);
74
151
  if (typeof f === "number")
75
152
  return f;
76
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 });
77
156
  if (f.positionals.length)
78
- 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
+ }
79
181
  const w = await reach(UI, json);
80
182
  if (w === null || w.state !== "up")
81
183
  return 1;
82
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
+ }
83
190
  if (!await servesPage(run)) {
84
191
  return failed("ui", { message: `Mason for ${run.name} serves no web page`, code: "refused",
85
- why: "the page is mason start's: this is mason serve's run, or a run started by a Mason from before the page",
86
- 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",
87
194
  next: [["mason status", "what it is doing, in this terminal"]] }, { json });
88
195
  }
89
196
  const url = `${run.control.url}/?token=${encodeURIComponent(run.control.token)}`;
@@ -0,0 +1,128 @@
1
+ /**
2
+ * `mason usage`: what the run was and did over time — a sample a minute (src/duck/usage.ts) of the copy's size and
3
+ * rows, the copies failed, how old the stalest copy is, the statements and their duration, the stream's lag and the
4
+ * memory — and each copy's own size. The run's `GET /api/usage` (src/cli/run-server.ts) answers it; `--json` prints that
5
+ * answer as it is, so an agent and a chart read the same samples a person reads here.
6
+ */
7
+ import { failed, jsonText, parseCommand, endText } from "./command.js";
8
+ import { out as styleOut } from "./style.js";
9
+ import { callRun } from "./control.js";
10
+ import { reach } from "./state.js";
11
+ import { fileSize, guarded, runFailure } from "./commands.js";
12
+ import { timeOf } from "./request-filter.js";
13
+ import { duration, msText } from "./readiness.js";
14
+ import { SIZE_EVERY_MS } from "../duck/usage.js";
15
+ const out = (m) => { process.stdout.write(`${m}\n`); };
16
+ const n = (x) => x.toLocaleString("en-US");
17
+ export const USAGE_USAGE = `usage: mason usage [--since <when>] [--until <when>] [--step <span>] [--json]
18
+
19
+ What Mason was and did over time, from a sample it takes every minute and keeps MASON_HISTORY_DAYS days
20
+ (default 7): a line each span — the statements of the Postgres API (how many, how many were refused, failed or
21
+ cancelled — the errors column —, the completed ones' p95 duration, the rows sent), the synced data's size and rows, the
22
+ tables whose sync is failing, how old the stalest table was, the stream's lag (a table under sync: replication), and
23
+ the memory of the process and of the engine against its limit (MASON_DUCKDB_MEMORY). Then each synced table's own size, measured every
24
+ 10 minutes. A span sums its statements and shows each level at its highest. Read through the running Mason.
25
+
26
+ mason usage the last hour, 5 minutes a line
27
+ mason usage --since 7d --step 1h the last week, an hour a line
28
+ mason usage --since 2026-10-28T09:00:00Z --until 2026-10-28T10:00:00Z --step 0
29
+
30
+ --since <when> from when: an age (90s, 30m, 6h, 7d) or an ISO 8601 date or time with its zone; default 1h
31
+ --until <when> until when, the same way; default now
32
+ --step <span> a line per span (1m to the days kept; default 5m), or 0 for every sample as it was taken
33
+ --json as data: { since, until, stepSeconds (null: every sample), sampleSeconds, keptDays, samples: [{ at
34
+ (with a step, the span's start), seconds, copyBytes, copyRows, tablesAdded, tablesCopied,
35
+ tablesFailed, oldestSyncSeconds, statements, statementsRefused, statementsFailed, statementsCancelled,
36
+ rowsSent, bytesSent, durationP50Ms, durationP95Ms, streamLagBytes, streamLagSeconds, rssBytes,
37
+ heapUsedBytes, copyMemoryBytes, copyMemoryLimitBytes, slotWalHeldBytes (replication: the WAL the
38
+ slot holds on the database), diskFreeBytes (free where the synced data is; with a step, the lowest),
39
+ sessionsOpen (the Postgres API's sessions logged in) }],
40
+ copySizes: [{ table, bytes, at }], next }
41
+ `;
42
+ export const USAGE = {
43
+ needs: "run",
44
+ name: "usage",
45
+ summary: "what Mason did over time: statements, the synced data's size and age, memory (a sample a minute; --since 7d --step 1h)",
46
+ usage: USAGE_USAGE,
47
+ options: { since: { type: "string" }, until: { type: "string" }, step: { type: "string" }, json: { type: "boolean" } },
48
+ json: ["since", "until", "stepSeconds", "sampleSeconds", "keptDays", "samples", "copySizes", "next"],
49
+ };
50
+ const UNIT = { s: 1, m: 60, h: 3600, d: 86_400 };
51
+ /** A span as the options write it (90s, 30m, 6h, 7d; 0), in seconds, or null when it is not one. */
52
+ export function spanSeconds(text) {
53
+ if (text.trim() === "0")
54
+ return 0;
55
+ const m = /^\s*(\d+)\s*([smhd])\s*$/.exec(text);
56
+ return m ? Number(m[1]) * UNIT[m[2]] : null;
57
+ }
58
+ /** One sample on one line: the time, the statements, those refused, failed or cancelled, p95, rows sent, the copy, its rows, the stalest copy, the lag, the memory. */
59
+ export function sampleLine(s, withLag) {
60
+ const time = s.at.slice(0, 16).replace("T", " ");
61
+ const p95 = s.durationP95Ms === null ? "–" : msText(s.durationP95Ms);
62
+ const bad = s.statementsRefused + s.statementsFailed + s.statementsCancelled;
63
+ const age = s.oldestSyncSeconds === null ? "–" : duration(s.oldestSyncSeconds);
64
+ const lag = !withLag ? "" : ` ${(s.streamLagSeconds === null ? "–" : duration(s.streamLagSeconds)).padStart(9)}`;
65
+ const memory = `${fileSize(s.rssBytes)}${s.copyMemoryBytes === null ? "" : ` (engine ${fileSize(s.copyMemoryBytes)} of ${fileSize(s.copyMemoryLimitBytes)})`}`;
66
+ return `${time} ${n(s.statements).padStart(10)} ${(bad === 0 ? "" : n(bad)).padStart(7)} ${p95.padStart(10)} ${n(s.rowsSent).padStart(11)} ` +
67
+ `${(s.copyBytes === null ? "–" : fileSize(s.copyBytes)).padStart(10)} ${n(s.copyRows).padStart(13)} ${(s.tablesFailed === 0 ? "" : n(s.tablesFailed)).padStart(7)} ` +
68
+ `${age.padStart(10)}${lag} ${memory}`;
69
+ }
70
+ /** The heading over `sampleLine`'s columns. */
71
+ export function sampleHeading(withLag) {
72
+ return `${"time (UTC)".padEnd(16)} ${"statements".padStart(10)} ${"errors".padStart(7)} ${"p95".padStart(10)} ${"rows sent".padStart(11)} ` +
73
+ `${"size".padStart(10)} ${"rows".padStart(13)} ${"failing".padStart(7)} ${"oldest".padStart(10)}${withLag ? ` ${"lag".padStart(9)}` : ""} memory`;
74
+ }
75
+ export async function usage(argv, now = Date.now()) {
76
+ const f = parseCommand(USAGE, argv);
77
+ if (typeof f === "number")
78
+ return f;
79
+ const v = f.values;
80
+ const json = v.json === true;
81
+ if (f.positionals.length)
82
+ return failed("usage", { message: `it takes no ${f.positionals.length === 1 ? "argument" : "arguments"}: ${f.positionals.join(" ")}`, code: "usage", usage: USAGE_USAGE }, { json });
83
+ // the one time grammar (src/cli/request-filter.ts timeOf): an age, or an ISO 8601 date or time WITH its zone
84
+ const since = timeOf("--since", v.since ?? "1h", now), until = v.until === undefined ? new Date(now).toISOString() : timeOf("--until", v.until, now);
85
+ for (const t of [since, until])
86
+ if (typeof t !== "string")
87
+ return failed("usage", { ...t, usage: USAGE_USAGE }, { json });
88
+ const step = spanSeconds(v.step ?? "5m");
89
+ if (step === null)
90
+ return failed("usage", { message: `--step is a span (1m, 5m, 1h, 1d) or 0 for every sample, not ${JSON.stringify(v.step)}`, code: "usage", usage: USAGE_USAGE }, { json });
91
+ const w = await reach(USAGE, json);
92
+ if (w === null || !("run" in w))
93
+ return 1;
94
+ const run = w.run;
95
+ return guarded("usage", json, async () => {
96
+ const q = new URLSearchParams({ since: since, until: until, ...(step === 0 ? {} : { step: String(step) }) });
97
+ const r = await callRun(run, "GET", `/api/usage?${q}`);
98
+ // the run's refusal (a step past the days kept, a since after until) in its own words and code
99
+ if (r.status !== 200)
100
+ return failed("usage", runFailure(r, [["mason usage --help", "the spans and times it takes"], ["mason logs", "the run's log"]]), { json });
101
+ const b = r.body;
102
+ const next = [["mason usage --since 7d --step 1h", "the week, an hour a line"], ["mason status", "where everything is now"]];
103
+ if (json) {
104
+ out(jsonText(USAGE, b, next));
105
+ return 0;
106
+ }
107
+ const st = styleOut();
108
+ const span = b.stepSeconds === null ? "every sample" : `${duration(b.stepSeconds)} a line`;
109
+ out(st.bold(`Usage from ${b.since.slice(0, 16).replace("T", " ")} to ${b.until.slice(0, 16).replace("T", " ")} UTC, ${span} (a sample every ${duration(b.sampleSeconds)}, kept ${b.keptDays} day${b.keptDays === 1 ? "" : "s"}):`));
110
+ if (!b.samples.length)
111
+ out(st.dim(" no sample in this time: Mason takes the first a minute after it starts"));
112
+ else {
113
+ const withLag = b.samples.some((s) => s.streamLagBytes !== null || s.streamLagSeconds !== null);
114
+ out(st.dim(sampleHeading(withLag)));
115
+ for (const s of b.samples)
116
+ out(sampleLine(s, withLag));
117
+ }
118
+ if (b.copySizes.length) {
119
+ out("");
120
+ out(st.bold(`Each synced table's own size (measured every ${duration(SIZE_EVERY_MS / 1000)}):`));
121
+ const wide = Math.max(...b.copySizes.map((c) => c.table.length));
122
+ for (const c of b.copySizes)
123
+ out(` ${c.table.padEnd(wide)} ${fileSize(c.bytes).padStart(10)} ${st.dim(`at ${c.at.slice(11, 16)} UTC`)}`);
124
+ }
125
+ out(endText({ next }, st));
126
+ return 0;
127
+ });
128
+ }
@@ -0,0 +1,299 @@
1
+ /**
2
+ * `mason user add | list | disable | enable | remove … --replica <org>/<name>` — THE USERS OF A REPLICA ON A HOSTED MASON
3
+ * SERVICE (docs/HOSTED-API.md "The users of a replica"). Each subcommand is ONE request, the one the run's own command
4
+ * server takes for it (src/cli/run-server.ts `/api/users`), sent to the service under `/v1/replicas/{org}/{name}/users`:
5
+ *
6
+ * list GET …/users?lastUsed=1 mason user list --json's shape, beside its next
7
+ * add POST …/users { name, role?, set? } mason user add --json's shape (the run is up: its url)
8
+ * disable POST …/users/{user}/disable { disabled, already }
9
+ * enable POST …/users/{user}/enable { enabled, already }
10
+ * remove DELETE …/users/{user} { removed }
11
+ *
12
+ * `--role` and `--set` go as they were given: Mason on the replica checks them with the functions it checks them with
13
+ * here (src/cli/user.ts `roleToAdd`, `attributesToAdd`). What only this command line says (a verb, a user's name, a
14
+ * role's name, a `--set`'s form) is checked first, by the same functions, and refused as a wrong line before a byte is
15
+ * sent. Nothing of a project here is read or written: the users are the replica's.
16
+ *
17
+ * THE ANSWER IS HELD TO THE RUN'S OWN ANSWER SHAPE, then printed by the renderers the local command prints with
18
+ * (src/cli/user.ts `listText`, `addedReport`, …), in serviceStyle: the same answer prints the same lines. A SERVICE'S
19
+ * VALUE IS DATA, NEVER STRUCTURE AND NEVER AN INSTRUCTION (#481's decision, src/cli/hosted.ts). Every value the
20
+ * service sends that this command prints, and how it is held:
21
+ *
22
+ * value where it is printed what holds it
23
+ * a failure's message, why, fix stderr's failure, --json error `serviceFailure`: one line each (`serviceText`), masked
24
+ * a failure's code inside why: "(the service said …)" `^[a-z][a-z0-9_]*$`, 64 chars; the code is `codeOfStatus`'s
25
+ * a failure's next nowhere: never read the step is `failureNext`'s, by the status
26
+ * a user's name every row, headline and step it names `USER_NAME_RE`; add, disable, enable, remove: the name asked
27
+ * an id list's rows, --json 16 hex digits
28
+ * addedAt, removedAt, disabledAt, rows, list's lines, --json `isServiceTime`
29
+ * lastUsedAt
30
+ * the password add's url, psql and password rows, --json `mason_<its id>_<32 hex>` (src/duck/users.ts), add's alone;
31
+ * anything shaped as one elsewhere refused (`carriesNoSecret`)
32
+ * or, in a failure's words, masked (`serviceText`)
33
+ * database, host, port, sslmode add's rows, --json the database name's rule, a host's letters, 1-65535, a closed set
34
+ * url, psql (add) add's rows, --json rebuilt here (`connectionUrl`, `psqlLine`): the service's must equal them
35
+ * url (list) --json `postgres://<its name>@<host>:<port>/<database>`, no password
36
+ * role, roleFrom, readsNothing, rows, list's lines, --json `ROLE_NAME_RE`; closed sets; booleans; each consistent with the others
37
+ * roleKnown, live
38
+ * attributes, missingAttributes rows, list's lines, --json `ATTRIBUTE_NAME_RE` names; each value one line (`serviceText`), bounded
39
+ * lastUsedNote, urlNote list's tip "Last used: …", --json one line (`serviceText`), bounded
40
+ * requestLogDays list's lines, --json a whole number, 1 to 36500
41
+ * already the headline chosen, --json a boolean
42
+ * an HTTP status a failure's why a number
43
+ *
44
+ * BEFORE ANY OF THESE, every string of the answer, its keys included, is put to the exact test (src/cli/hosted.ts
45
+ * `carriesNoSecret`, `carriesSecret` on each: an echo of the held token, `ECHO_RUN` of its body in a row in one string; a
46
+ * token's or a password's shape — add's own password, url and psql for the token alone): an answer that carries one
47
+ * anywhere is refused whole, so no grammar is ever the only thing between a secret and the output, and an honest answer
48
+ * is refused with a probability under 1 in a million whatever its size. A failure's code, printed inside why, is put to
49
+ * the same test (`failureShape`). The free text (the notes, a refusal's words) is masked too, by #591's quarter rule.
50
+ *
51
+ * An answer any of which does not hold is outside the contract: said by the request and its status, never its body.
52
+ * What to run next is the CLI's own (`onReplica`: the local command's steps, each `mason user …` naming the replica).
53
+ */
54
+ import { failed, jsonText, serviceStyle } from "./command.js";
55
+ import { call, carriesNoSecret, isFailure, isServiceTime, serviceFailure, serviceNext, serviceOf, serviceText, unexpected } from "./hosted.js";
56
+ import { replicaText, tokenFor } from "./replica.js";
57
+ import { holdOutput } from "./output-gate.js";
58
+ import { err as styleErr, out as styleOut } from "./style.js";
59
+ import { connectionUrl, psqlLine } from "./pg-url.js";
60
+ import { USER_NAME_RE } from "../duck/users.js";
61
+ import { ROLE_NAME_RE } from "./roles.js";
62
+ import { ATTRIBUTE_NAME_RE } from "../duck/row-filter.js";
63
+ import { databaseNameProblem } from "../lib/database-name.js";
64
+ import { NO_ROLES } from "../duck/grants.js";
65
+ import { addedNext, addedReport, disabledNext, disabledReport, enabledNext, enabledReport, listNext, listText, REMOVED_NEXT, removedReport, USER } from "./user.js";
66
+ /** The bound of a list's answer (a replica's users, a row each), and of the number of users in it; every other answer
67
+ * is `call`'s own 64 KiB. */
68
+ export const MAX_LIST_BYTES = 1024 * 1024;
69
+ export const MAX_USERS = 2000;
70
+ const out = (m) => { process.stdout.write(`${m}\n`); };
71
+ const obj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
72
+ const ID = /^[0-9a-f]{16}$/;
73
+ const HOST = /^(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,62})(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,62}))*\.?|[0-9A-Fa-f:.]{2,45})$/;
74
+ const ROLE_FROM = ["file", "default", "none"];
75
+ /** The local command's next steps, as a replica's: each `mason user …` names the replica, so it runs against it. */
76
+ export const onReplica = (steps, r) => steps.map(([command, ...what]) => [command.startsWith("mason user ") ? `${command} --replica ${replicaText(r)}` : command, ...what]);
77
+ /** What to run after the service refused a request about a replica's users: the CLI's own, by the status (never the
78
+ * service's `next`): sign in again for a 401 or 403, else the replica's users. */
79
+ export const failureNext = (status, r) => status === 401 || status === 403 ? serviceNext(status) : [[`mason user list --replica ${replicaText(r)}`, "the replica's users"]];
80
+ /** The request a subcommand is: the run's own `/api/users` request, under the replica's path. */
81
+ export function requestOf(r, o) {
82
+ const base = `/v1/replicas/${r.org}/${r.name}/users`;
83
+ // a user's name is USER_NAME_RE's (checked before this): letters, digits, `_`, `.` and `-`, safe in a path as it is
84
+ switch (o.verb) {
85
+ case "list": return { method: "GET", path: `${base}?lastUsed=1` };
86
+ case "add": return { method: "POST", path: base, json: { name: o.name, ...(o.role !== undefined ? { role: o.role } : {}), ...(o.sets.length ? { set: [...o.sets] } : {}) } };
87
+ case "disable":
88
+ case "enable": return { method: "POST", path: `${base}/${o.name}/${o.verb}` };
89
+ case "remove": return { method: "DELETE", path: `${base}/${o.name}` };
90
+ }
91
+ }
92
+ // ------------------------------------------------------------------ the answers, held to the run's own shapes
93
+ /** A string the service sends, as one line: null when it is not a string. */
94
+ const line = (v, max, secrets) => typeof v === "string" ? serviceText(v, max, secrets) : null;
95
+ /** A user's role fields, each consistent with the others (src/cli/roles.ts userRole): a role for `file` and `default`,
96
+ * none for `none`, `readsNothing` only where there is none. */
97
+ function roleOf(u) {
98
+ if (typeof u.roleFrom !== "string" || !ROLE_FROM.includes(u.roleFrom))
99
+ return null;
100
+ const roleFrom = u.roleFrom;
101
+ if (u.role !== undefined && (typeof u.role !== "string" || !ROLE_NAME_RE.test(u.role)))
102
+ return null;
103
+ if ((roleFrom === "none") !== (u.role === undefined))
104
+ return null;
105
+ if (u.readsNothing !== undefined && (u.readsNothing !== true || roleFrom !== "none"))
106
+ return null;
107
+ return { ...(typeof u.role === "string" ? { role: u.role } : {}), roleFrom, ...(u.readsNothing === true ? { readsNothing: true } : {}) };
108
+ }
109
+ /** A user's attributes: at most 64 names, each a value (one line, bounded) or a list of 1 to 100 of them. A value is a
110
+ * value (a tenant's id), put to the exact test with every string (`carriesNoSecret`), so it is made one line here
111
+ * without the quarter rule of free text, which a thousand random ids would trip by chance (PR 609's review). */
112
+ function attributesOf(v) {
113
+ if (!obj(v) || Object.keys(v).length > 64)
114
+ return null;
115
+ const outv = {};
116
+ for (const [k, x] of Object.entries(v)) {
117
+ if (!ATTRIBUTE_NAME_RE.test(k))
118
+ return null;
119
+ if (typeof x === "string")
120
+ outv[k] = serviceText(x, 1000);
121
+ else if (Array.isArray(x) && x.length >= 1 && x.length <= 100 && x.every((y) => typeof y === "string"))
122
+ outv[k] = x.map((y) => serviceText(y, 1000));
123
+ else
124
+ return null;
125
+ }
126
+ return outv;
127
+ }
128
+ const names = (v) => Array.isArray(v) && v.length >= 1 && v.length <= 64 && v.every((x) => typeof x === "string" && ATTRIBUTE_NAME_RE.test(x));
129
+ const timeOrNull = (v) => v === null || isServiceTime(v);
130
+ /** One row of a list, as `listUsers` makes it (src/cli/user.ts `UserRow`); null when it is not that shape. */
131
+ function rowOf(v) {
132
+ if (!obj(v))
133
+ return null;
134
+ const u = v;
135
+ if (typeof u.name !== "string" || !USER_NAME_RE.test(u.name) || typeof u.id !== "string" || !ID.test(u.id) || !isServiceTime(u.addedAt)
136
+ || !timeOrNull(u.removedAt) || !timeOrNull(u.disabledAt) || !timeOrNull(u.lastUsedAt) || typeof u.live !== "boolean")
137
+ return null;
138
+ // the URL a tool pastes, WITHOUT the password: its own name, then where the replica listens
139
+ if (u.url !== null && !(typeof u.url === "string" && u.url.length <= 600 && u.url.startsWith(`postgres://${u.name}@`)
140
+ && /^postgres:\/\/[a-z0-9_.-]+@(?:[A-Za-z0-9.-]+|\[[0-9A-Fa-f:.]+\]):\d{1,5}\/[A-Za-z0-9%._~!*'()-]+(?:\?sslmode=require)?$/.test(u.url)))
141
+ return null;
142
+ const role = roleOf(u);
143
+ if (role === null)
144
+ return null;
145
+ const attributes = u.attributes === undefined ? undefined : attributesOf(u.attributes);
146
+ if (attributes === null)
147
+ return null;
148
+ if (u.missingAttributes !== undefined && !names(u.missingAttributes))
149
+ return null;
150
+ if (u.roleKnown !== undefined && (typeof u.roleKnown !== "boolean" || role.roleFrom !== "file"))
151
+ return null;
152
+ return { name: u.name, id: u.id, addedAt: u.addedAt, removedAt: u.removedAt, disabledAt: u.disabledAt, live: u.live, lastUsedAt: u.lastUsedAt,
153
+ url: u.url, ...role, ...(attributes !== undefined ? { attributes } : {}),
154
+ ...(u.missingAttributes !== undefined ? { missingAttributes: u.missingAttributes } : {}),
155
+ ...(typeof u.roleKnown === "boolean" ? { roleKnown: u.roleKnown } : {}) };
156
+ }
157
+ /** `GET …/users?lastUsed=1`'s answer: mason user list --json's shape (its `next` never read); null when it is not it. */
158
+ export function heldList(json, secrets) {
159
+ if (!obj(json) || !Array.isArray(json.users) || json.users.length > MAX_USERS || !carriesNoSecret(json, secrets))
160
+ return null;
161
+ const users = [];
162
+ for (const v of json.users) {
163
+ const u = rowOf(v);
164
+ if (u === null)
165
+ return null;
166
+ users.push(u);
167
+ }
168
+ const note = (v) => v === null ? null : line(v, 500, secrets) ?? undefined;
169
+ const lastUsedNote = note(json.lastUsedNote), urlNote = note(json.urlNote);
170
+ const days = json.requestLogDays;
171
+ if (lastUsedNote === undefined || urlNote === undefined || !(days === null || (Number.isInteger(days) && days >= 1 && days <= 36500)))
172
+ return null;
173
+ return { users, lastUsedNote, urlNote, requestLogDays: days };
174
+ }
175
+ /** `POST …/users`'s answer: mason user add --json's shape with Mason up — where it listens, the URL rebuilt here by the
176
+ * one builder and equal to the service's —, for the name asked; null when it is not it. */
177
+ export function heldAdded(json, asked, secrets) {
178
+ if (!obj(json) || !carriesNoSecret(json, secrets, ["password", "url", "psql"]))
179
+ return null;
180
+ const a = json;
181
+ if (a.user !== asked || typeof a.id !== "string" || !ID.test(a.id) || !isServiceTime(a.addedAt))
182
+ return null;
183
+ // the password is the user's, by its one grammar: its id, then its MAC
184
+ if (typeof a.password !== "string" || !new RegExp(`^mason_${a.id}_[0-9a-f]{32}$`).test(a.password))
185
+ return null;
186
+ if (typeof a.database !== "string" || databaseNameProblem(a.database) !== null || serviceText(a.database, 63) !== a.database)
187
+ return null;
188
+ if (typeof a.host !== "string" || !HOST.test(a.host) || !Number.isInteger(a.port) || a.port < 1 || a.port > 65535)
189
+ return null;
190
+ if (a.sslmode !== "require" && a.sslmode !== "disable")
191
+ return null;
192
+ const url = connectionUrl({ url: "", host: a.host, port: a.port, database: a.database, ...(a.sslmode === "require" ? { sslmode: "require" } : {}) }, asked, a.password);
193
+ if (a.url !== url || a.psql !== psqlLine(url))
194
+ return null;
195
+ const role = roleOf(a);
196
+ if (role === null)
197
+ return null;
198
+ const attributes = a.attributes === undefined ? undefined : attributesOf(a.attributes);
199
+ if (attributes === null)
200
+ return null;
201
+ return { user: asked, id: a.id, addedAt: a.addedAt, password: a.password, database: a.database, url, psql: psqlLine(url), host: a.host, port: a.port, sslmode: a.sslmode,
202
+ ...role, ...(attributes !== undefined ? { attributes } : {}) };
203
+ }
204
+ /** A user as disable, enable and remove name it: the name asked, its id, when it was added, and `more` times. */
205
+ function userOf(v, asked, more) {
206
+ if (!obj(v) || v.name !== asked || typeof v.id !== "string" || !ID.test(v.id) || !isServiceTime(v.addedAt) || !more.every((k) => isServiceTime(v[k])))
207
+ return null;
208
+ return { name: asked, id: v.id, addedAt: v.addedAt, ...Object.fromEntries(more.map((k) => [k, v[k]])) };
209
+ }
210
+ export function heldDisabled(json, asked, secrets) {
211
+ if (!obj(json) || typeof json.already !== "boolean" || !carriesNoSecret(json, secrets))
212
+ return null;
213
+ const d = userOf(json.disabled, asked, ["disabledAt"]);
214
+ return d === null ? null : { disabled: d, already: json.already };
215
+ }
216
+ export function heldEnabled(json, asked, secrets) {
217
+ if (!obj(json) || typeof json.already !== "boolean" || !carriesNoSecret(json, secrets))
218
+ return null;
219
+ const e = userOf(json.enabled, asked, []);
220
+ return e === null ? null : { enabled: e, already: json.already };
221
+ }
222
+ export function heldRemoved(json, asked, secrets) {
223
+ if (!obj(json) || !carriesNoSecret(json, secrets))
224
+ return null;
225
+ const r = userOf(json.removed, asked, ["removedAt"]);
226
+ return r === null ? null : { removed: r };
227
+ }
228
+ // ------------------------------------------------------------------ the command
229
+ /** What each subcommand asked, in the words of a failure outside the contract (`unexpected`). */
230
+ const STEP = { list: "the replica's users were asked for", add: "a user was added", disable: "a user was disabled", enable: "a user was enabled", remove: "a user was removed" };
231
+ /** `mason user <verb> … --replica <org>/<name>`: one request, its answer held and printed as the local command prints it. */
232
+ export async function hostedUser(r, o) {
233
+ const outStyle = serviceStyle(styleOut());
234
+ const fail = (f) => failed("user", f, { json: o.json, style: serviceStyle(styleErr()) });
235
+ const service = serviceOf();
236
+ if (isFailure(service))
237
+ return fail(service);
238
+ const token = tokenFor(service, { asked: "a replica's users are asked for", again: `mason user ${o.verb}` });
239
+ if (isFailure(token))
240
+ return fail(token);
241
+ // from here every byte written is put to the exact test against the token (src/cli/output-gate.ts); add prints the
242
+ // password it made, so a password's shape is its answer's there
243
+ holdOutput({ command: "user", json: o.json, secrets: [token], passwords: o.verb !== "add" });
244
+ const secrets = [token];
245
+ const req = requestOf(r, o);
246
+ const a = await call(service, req.method, req.path, { token, ...(req.json !== undefined ? { json: req.json } : {}), ...(o.verb === "list" ? { maxBytes: MAX_LIST_BYTES } : {}) });
247
+ if (isFailure(a))
248
+ return fail(a);
249
+ const refused = (x) => serviceFailure(x, secrets, (status) => failureNext(status, r)) ?? unexpected(service, x, STEP[o.verb]);
250
+ if (a.status !== 200)
251
+ return fail(refused(a));
252
+ const outside = () => fail(unexpected(service, a, STEP[o.verb]));
253
+ const print = (value, next, text) => {
254
+ out(o.json ? jsonText(USER, value, next) : text());
255
+ return 0;
256
+ };
257
+ switch (o.verb) {
258
+ case "list": {
259
+ const l = heldList(a.json, secrets);
260
+ if (l === null)
261
+ return outside();
262
+ const next = onReplica(listNext(l.users), r);
263
+ return print({ ...l }, next, () => listText(replicaText(r), l, NO_ROLES, next, outStyle));
264
+ }
265
+ case "add": {
266
+ const added = heldAdded(a.json, o.name, secrets);
267
+ // the service answered 200: the replica may have made the user, whose password nobody has now been shown
268
+ if (added === null) {
269
+ const f = unexpected(service, a, STEP[o.verb]);
270
+ return fail({ ...f, why: `${f.why}; the replica may have added ${o.name} all the same, and its password was not shown`,
271
+ fix: `mason user list --replica ${replicaText(r)} says whether it did; if so, mason user remove ${o.name} --replica ${replicaText(r)}, then add it again`,
272
+ next: [[`mason user list --replica ${replicaText(r)}`, "the replica's users"]] });
273
+ }
274
+ const next = onReplica(addedNext(added.user, true), r);
275
+ return print({ ...added }, next, () => addedReport(added, { roles: NO_ROLES, listener: null, next }, outStyle));
276
+ }
277
+ case "disable": {
278
+ const d = heldDisabled(a.json, o.name, secrets);
279
+ if (d === null)
280
+ return outside();
281
+ const next = onReplica(disabledNext(d.disabled.name), r);
282
+ return print({ ...d }, next, () => disabledReport(d, true, next, outStyle));
283
+ }
284
+ case "enable": {
285
+ const e = heldEnabled(a.json, o.name, secrets);
286
+ if (e === null)
287
+ return outside();
288
+ const next = onReplica(enabledNext(e.enabled.name), r);
289
+ return print({ ...e }, next, () => enabledReport(e, next, outStyle));
290
+ }
291
+ case "remove": {
292
+ const x = heldRemoved(a.json, o.name, secrets);
293
+ if (x === null)
294
+ return outside();
295
+ const next = onReplica(REMOVED_NEXT, r);
296
+ return print({ ...x }, next, () => removedReport(x, true, next, outStyle));
297
+ }
298
+ }
299
+ }