@mason-data/cli 0.9.0 → 0.11.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 (56) hide show
  1. package/README.md +6 -4
  2. package/dist/src/api/error.js +4 -0
  3. package/dist/src/api/postgres/proxy.js +6 -160
  4. package/dist/src/api/postgres/server.js +22 -4
  5. package/dist/src/cli/command.js +169 -5
  6. package/dist/src/cli/commands.js +295 -159
  7. package/dist/src/cli/deploy.js +134 -28
  8. package/dist/src/cli/dev.js +21 -5
  9. package/dist/src/cli/doctor.js +14 -11
  10. package/dist/src/cli/guides.js +20 -11
  11. package/dist/src/cli/hosted.js +1 -1
  12. package/dist/src/cli/init.js +110 -25
  13. package/dist/src/cli/login.js +20 -17
  14. package/dist/src/cli/machine.js +125 -40
  15. package/dist/src/cli/main.js +51 -8
  16. package/dist/src/cli/mcp.js +15 -12
  17. package/dist/src/cli/project.js +25 -2
  18. package/dist/src/cli/registry.js +80 -35
  19. package/dist/src/cli/replica-hosted.js +761 -102
  20. package/dist/src/cli/replica.js +43 -5
  21. package/dist/src/cli/replication-mode.js +5 -0
  22. package/dist/src/cli/request-filter.js +16 -6
  23. package/dist/src/cli/run-server.js +70 -31
  24. package/dist/src/cli/secret-input.js +193 -0
  25. package/dist/src/cli/security.js +7 -2
  26. package/dist/src/cli/serve.js +119 -9
  27. package/dist/src/cli/share.js +20 -15
  28. package/dist/src/cli/source-role.js +14 -15
  29. package/dist/src/cli/sources.js +20 -3
  30. package/dist/src/cli/style.js +14 -0
  31. package/dist/src/cli/sync.js +17 -8
  32. package/dist/src/cli/token-hosted.js +30 -24
  33. package/dist/src/cli/tunnel.js +1 -1
  34. package/dist/src/cli/ui-hosted.js +163 -0
  35. package/dist/src/cli/ui-read-only.js +23 -0
  36. package/dist/src/cli/ui-run-file.js +247 -0
  37. package/dist/src/cli/ui-socket.js +183 -0
  38. package/dist/src/cli/ui-ssh.js +3 -3
  39. package/dist/src/cli/ui.js +67 -26
  40. package/dist/src/cli/usage.js +19 -15
  41. package/dist/src/cli/user-hosted.js +78 -13
  42. package/dist/src/cli/user.js +168 -50
  43. package/dist/src/duck/users.js +105 -9
  44. package/dist/src/lib/cidr.js +177 -0
  45. package/dist/src/lib/table-sync.js +69 -19
  46. package/dist/src/web/app.css +5 -0
  47. package/dist/src/web/app.js +4 -4
  48. package/dist/src/web/index.html +5 -8
  49. package/dist/src/web/pages/queries.js +3 -3
  50. package/dist/src/web/pages/status.js +23 -17
  51. package/dist/src/web/pages/syncs.js +3 -3
  52. package/dist/src/web/pages/tables.js +18 -15
  53. package/dist/src/web/pages/usage.js +16 -12
  54. package/dist/src/web/pages/users.js +45 -14
  55. package/dist/src/web/shared.js +10 -4
  56. package/package.json +6 -6
package/README.md CHANGED
@@ -106,7 +106,7 @@ again and remove `mason/models/`.
106
106
  | `mason wait [--timeout <s>]` | Waits until every added table is synced and served, showing every added table on a line of its own: 100% for the finished ones, in the order they finished, and a bar for each still syncing (rows, %, rate, time left) at the bottom. Exit 0 ready, 1 a first sync failed (printed, with what to do), 124 timed out (default 600 s). |
107
107
  | `mason user add\|list\|remove <name>` | The users of the Postgres API: a name and a password that does not expire, as a BI tool's datasource form asks for them or an app's `DATABASE_URL` carries them. `add` prints the password once with the URL; `remove` refuses the next connection at once and ends the sessions it holds at their next statement, without a restart. |
108
108
  | `mason mcp [--url <url>]` | An MCP server on stdio for Claude Code, Claude Desktop and other agents (below): the tables Mason serves with each one's rows and how current it is, a table's columns, a statement's rows with their Postgres types or Mason's refusal with its way out, and EXPLAIN — connected as a `mason user` (`MASON_URL`, else `--url`), beside `mason start` or a `mason serve` elsewhere. |
109
- | `mason ui` | Opens the running Mason's web page in your browser: the Overview (what `mason status` says), the Tables (add, remove or sync one), the Users of `mason user` (add one, its password shown once; disable, enable or remove one) and Observability (the statements, usage over time, the sync's events), refreshed while the tab is open. It prints the URL with the run's token, which the page trades for a cookie on the first visit; `--no-open` prints it only, `--json` is `{ url, port }`. `mason start`'s alone: `mason serve` serves no page. |
109
+ | `mason ui` | Opens the running Mason's web page in your browser: the Overview (what `mason status` says), the Tables (add, remove or sync one), the Users of `mason user` (add one, its password shown once; disable, enable or remove one) and Observability (the statements, usage over time, the sync's events), refreshed while the tab is open. It prints the URL with the run's token, which the page trades for a cookie on the first visit, and opens it in a browser on a terminal; `--no-open` (or output that is not a terminal) prints it only, `--json` is `{ url, port }`. `mason start`'s alone: `mason serve` serves no page. |
110
110
  | `mason status` | What Mason is doing: the database and how its changes are read, the project folder, the synced data's file, the Postgres API and how it listens, each synced table, the statements of the last day and week (each outcome, the completed ones' average, p50 and p95 time, the rows and bytes sent), the ones answered slowly in the last day and the ones refused or failed, each with its reason. In replication mode, where the stream is. |
111
111
  | `mason sync <table>…` | Brings these synced tables up to date now, by one poll each: a table whose `sync:` is `off` is kept current only so (from cron, before a report), a polled one is polled now as well as at its interval. In replication mode the stream keeps every table current, and it says where the stream is. |
112
112
  | `mason logs [-n <lines>] [-f] [--start] [--requests [<filters>]] [--events [--failed]] [--running]` | Mason's log: every sync, poll and statement, with warnings and errors and their reasons; `-f` follows it, `--start` shows what `mason start` printed instead. `--requests` is the request log: every statement a tool sent, when, the outcome (ok, refused, failed, cancelled) and SQLSTATE, how long, the rows and bytes sent, the tool and the user; `--failed` only the ones not answered, `--outcome`, `--since` / `--until` (`2h`, `7d`, an ISO time), `--user` and `--client` narrow it, and `--before` reads the page before. `--events` is the sync's events: each first sync, poll, catch-up or stream that failed, the database out of reach, a table current again, each sync asked by hand (mason sync, the page's Sync now), and each restart of the run (`--failed` only the failures). `--running` lists the statements running now, by session. |
@@ -117,10 +117,12 @@ again and remove `mason/models/`.
117
117
  | `mason serve` | Mason for a server or a container: `mason start --attach` configured by the environment alone, its state in one folder (a volume), its log on stdout, `GET /healthz`, `GET /metrics` (Prometheus, numbers only), a clean stop on SIGTERM. `mason serve --help` lists every setting with its default; `MASON_PROJECT_READONLY=1`, for a project folder deployed as committed, refuses every edit of its files there (`mason add`, `mason remove`, the page's Add and Remove), with "edit the files and deploy them". |
118
118
  | `mason login` | Signs this machine in to a hosted Mason service (`MASON_CLOUD_URL`, else `https://app.masondata.ai`): it prints a page and a code (on stderr, at once, so a coding agent can pass them on), opens the page in a browser on a terminal, waits until the code is approved, then says who is signed in and their organisations. The login is kept in `~/.config/mason/credentials.json` (0600), never in a project; refused while `MASON_TOKEN` is set, which every command uses first. [`docs/HOSTED-API.md`](../docs/HOSTED-API.md) is the API. |
119
119
  | `mason logout` | Revokes this machine's login to that service and removes it. When the service cannot be reached, the login is still removed, and it says it was NOT revoked and how to revoke it. |
120
+ | `mason replica create\|list\|status` | A hosted Mason service's replicas: `create <org>/<name> --region <region> --size <gb>` makes one for this project's database (its URL read by the variable the sources file names, never from the command line) and waits until it is ready; `list [<org>]` lists them; `status <org>/<name>` says how one is. [`docs/HOSTED-API.md`](../docs/HOSTED-API.md#replicas-mason-replica) is the API. |
121
+ | `mason token create\|list\|revoke` | A hosted Mason service's CI tokens: `create --replica <org>/<name>` makes one for a replica, printed once, for a deploy from CI (`MASON_TOKEN`); `list [<org>]` lists them; `revoke <id>` revokes one. |
120
122
  | `mason deploy [--replica <org>/<name>] [--dry-run] [--timeout 10m]` | Sends this project's files (the YAML under `mason/`: never `.env` or `.mason/`) to a replica on the hosted service (`--replica`, else `MASON_REPLICA`), after checking the project as `mason start` does, without connecting to any database, then waits until the replica runs it (exit 0), Mason there refuses it (its failure printed as it came, exit 1), or the timeout (exit 124: the deploy goes on, its id said). `--dry-run` prints each file, its size and its sha256, and sends nothing. The login is `MASON_TOKEN`, else `mason login`'s. |
121
123
 
122
124
  Every command documents itself: `mason --help` lists them, and `mason <command> --help` (or `mason help <command>`) says
123
- how to use one, with examples. Every command but `help` and `mcp` (whose stdout is the MCP protocol's) takes `--json`. A command means the project of the folder it
125
+ how to use one, with examples; `mason <command> --help --json` is its spec as data, and `mason help --json` every command's. Every command but `mcp` (whose stdout is the MCP protocol's) takes `--json`. A command means the project of the folder it
124
126
  runs in, or the nearest one above it.
125
127
 
126
128
  ## What `mason start` does
@@ -159,7 +161,7 @@ there (`mason status` lists them); `MASON_STORE_JSON_PATHS=off` turns that off.
159
161
  | Option | What it does |
160
162
  |---|---|
161
163
  | `--self-signed-tls` | The Postgres API requires TLS, from a certificate made for this project in `.mason/` (served again by every later start with the flag); clients connect with `sslmode=require`. |
162
- | `--port <n>` | The port the `mason` commands reach it on (default 4039, or the next free one). |
164
+ | `--ui-port <n>` | The port of the web page and the `mason` commands, on 127.0.0.1 (default 4039, or the next free one); `MASON_UI_PORT` on a server. `--port` is gone: it answers with the line to run and exit 2. |
163
165
  | `--attach` | Stay in the foreground: the terminal shows Mason's progress, and Ctrl-C stops it. |
164
166
 
165
167
  `--replication`, `--poll` and `--publication` are gone: each answers with the line to write at the top of the sources
@@ -181,7 +183,7 @@ what changed is written. A different database is refused: `mason destroy`, then
181
183
  `mason help agent` is the page an agent reads first, offline: the commands in the order a project takes them, every
182
184
  answer's shape, the exit codes and the failure codes. In short:
183
185
 
184
- - Every command but `help` and `mcp` takes `--json` and answers with **one JSON object on stdout**, always with
186
+ - Every command but `mcp` takes `--json` and answers with **one JSON object on stdout**, always with
185
187
  `next: [{ command, what }]` — the commands to run next, the same a person reads under `Next:`. The other keys are the
186
188
  command's own; `mason help <command>` names them on its `--json` line. Lists are wrapped (`{ tables: […], next }`).
187
189
  - A failure is `{ "error": { "code", "message", "why", "fix" }, "next": […] }` on stdout, and the **exit code** says it:
@@ -19,3 +19,7 @@ export class ApiError extends Error {
19
19
  /** The code of a DISABLED `mason user`'s refused login or session (src/duck/users.ts): the listener answers it 28000, as
20
20
  * Postgres answers a role without LOGIN, never 28P01 — its password was right. */
21
21
  export const USER_DISABLED = "user_disabled";
22
+ /** The code of a `mason user`'s login or session refused for its ADDRESS (src/duck/users.ts `from`): the listener answers
23
+ * it 28000 before any password is checked, in the same words whether the user exists or not, as Postgres answers a
24
+ * connection no pg_hba.conf line takes. */
25
+ export const ADDRESS_NOT_ALLOWED = "address_not_allowed";
@@ -14,6 +14,8 @@
14
14
  * peer, a v4-mapped IPv6 address shown as IPv4 (`shownAddress`). The request log's `client_addr` and the session's log
15
15
  * line read it, never the socket.
16
16
  */
17
+ import { ALL_END, cidrOf, covers, inRanges, ipv6Text, rangeOf, shownAddress, V4_END, V4_START } from "../../lib/cidr.js";
18
+ export { shownAddress };
17
19
  /** the v2 header's first 12 bytes (section 2.2) */
18
20
  export const PROXY_V2_SIGNATURE = Buffer.from([0x0d, 0x0a, 0x0d, 0x0a, 0x00, 0x0d, 0x0a, 0x51, 0x55, 0x49, 0x54, 0x0a]);
19
21
  /** how a v1 (text) header begins */
@@ -87,166 +89,10 @@ export const startsProxyHeader = (first) => first === PROXY_V2_SIGNATURE[0] || f
87
89
  export function clientAddress(peer, header) {
88
90
  return header?.source?.address ?? shownAddress(peer);
89
91
  }
90
- /** An address as Mason shows it: IPv4 dotted, IPv6 in RFC 5952's form, a v4-mapped IPv6 one as IPv4; null when it is no
91
- * address (undefined, a zone index, a name). */
92
- export function shownAddress(text) {
93
- if (text === undefined)
94
- return null;
95
- const v4 = ipv4Bytes(text);
96
- if (v4 !== null)
97
- return Array.from(v4).join(".");
98
- const v6 = ipv6Bytes(text);
99
- return v6 === null ? null : ipv6Text(v6);
100
- }
101
- /** An IPv4 address's 4 bytes, from dotted decimal with no leading zero; null for anything else. */
102
- function ipv4Bytes(text) {
103
- const parts = text.split(".");
104
- if (parts.length !== 4 || !parts.every((p) => /^(?:0|[1-9]\d{0,2})$/.test(p) && Number(p) <= 255))
105
- return null;
106
- return Uint8Array.from(parts.map(Number));
107
- }
108
- /** An IPv6 address's 16 bytes (`::` once at most, an IPv4 tail); null for anything else, a zone index included. */
109
- function ipv6Bytes(text) {
110
- if (!/^[0-9A-Fa-f:.]+$/.test(text))
111
- return null;
112
- const halves = text.split("::");
113
- if (halves.length > 2 || (halves.length === 2 && halves[0].includes(".")))
114
- return null;
115
- const groups = (s) => {
116
- if (s === "")
117
- return [];
118
- const out = [];
119
- const parts = s.split(":");
120
- for (const [i, p] of parts.entries()) {
121
- if (i === parts.length - 1 && p.includes(".")) {
122
- const v4 = ipv4Bytes(p);
123
- if (v4 === null)
124
- return null;
125
- out.push((v4[0] << 8) | v4[1], (v4[2] << 8) | v4[3]);
126
- }
127
- else if (/^[0-9A-Fa-f]{1,4}$/.test(p))
128
- out.push(parseInt(p, 16));
129
- else
130
- return null;
131
- }
132
- return out;
133
- };
134
- const head = groups(halves[0]), tail = halves.length === 2 ? groups(halves[1]) : [];
135
- if (head === null || tail === null)
136
- return null;
137
- const n = head.length + tail.length;
138
- if (halves.length === 1 ? n !== 8 : n > 7)
139
- return null;
140
- const all = [...head, ...new Array(8 - n).fill(0), ...tail];
141
- return Uint8Array.from(all.flatMap((g) => [g >> 8, g & 0xff]));
142
- }
143
- /** whether 16 bytes are an IPv4 address mapped into IPv6 (::ffff:a.b.c.d) */
144
- const v4Mapped = (b) => b.subarray(0, 10).every((x) => x === 0) && b[10] === 0xff && b[11] === 0xff;
145
- /** 16 bytes as Mason shows an IPv6 address: RFC 5952 (lower case, the longest run of two or more zero groups as `::`,
146
- * the first of equal runs), and a v4-mapped one as its IPv4 address. */
147
- function ipv6Text(b) {
148
- if (v4Mapped(b))
149
- return Array.from(b.subarray(12, 16)).join(".");
150
- const g = Array.from({ length: 8 }, (_, i) => (b[2 * i] << 8) | b[2 * i + 1]);
151
- let at = -1, run = 0;
152
- for (let i = 0; i < 8;) {
153
- if (g[i] !== 0) {
154
- i++;
155
- continue;
156
- }
157
- let j = i;
158
- while (j < 8 && g[j] === 0)
159
- j++;
160
- if (j - i > run) {
161
- at = i;
162
- run = j - i;
163
- }
164
- i = j;
165
- }
166
- const hex = g.map((x) => x.toString(16));
167
- return run < 2 ? hex.join(":") : `${hex.slice(0, at).join(":")}::${hex.slice(at + run).join(":")}`;
168
- }
169
- /**
170
- * EVERY ADDRESS IN ONE SPACE, as Mason means one: IPv6's 128 bits, an IPv4 address at its v4-mapped place
171
- * (::ffff:a.b.c.d), since a v4-mapped peer or header source IS that IPv4 client (`shownAddress`). What an entry names and
172
- * what the list covers are both read here, never from how an entry is spelt: `0.0.0.0/1,128.0.0.0/1` is every IPv4
173
- * address, as `0.0.0.0/0` is, and `::/64` holds every v4-mapped address, so every IPv4 one too.
174
- */
175
- const V4_START = 0xffffn << 32n;
176
- const V4_END = V4_START + (1n << 32n) - 1n;
177
- const ALL_END = (1n << 128n) - 1n;
178
- const bigOf = (b) => b.reduce((n, x) => (n << 8n) | BigInt(x), 0n);
179
- /** an address's place (`V4_START`), or null when it is no address */
180
- function placeOf(address) {
181
- const v4 = ipv4Bytes(address);
182
- if (v4 !== null)
183
- return V4_START | bigOf(v4);
184
- const v6 = ipv6Bytes(address);
185
- return v6 === null ? null : bigOf(v6);
186
- }
187
- /** the places an entry names */
188
- function rangeOf(c) {
189
- const start = c.family === 4 ? V4_START | bigOf(c.bytes) : bigOf(c.bytes);
190
- return { start, end: start + (1n << BigInt((c.family === 4 ? 32 : 128) - c.prefix)) - 1n };
191
- }
192
92
  /** Whether `address` (as `shownAddress` shows one, or in any spelling of the same address) is among those `from` names. */
193
- export function proxyNames(from, address) {
194
- const at = placeOf(address);
195
- return at !== null && from.ranges.some((r) => r.start <= at && at <= r.end);
196
- }
197
- /** whether `ranges` together hold every place from `lo` to `hi` */
198
- function covers(ranges, lo, hi) {
199
- let next = lo;
200
- for (const r of [...ranges].sort((a, b) => (a.start < b.start ? -1 : a.start > b.start ? 1 : 0))) {
201
- if (r.start > next)
202
- return false;
203
- if (r.end >= next)
204
- next = r.end + 1n;
205
- if (next > hi)
206
- return true;
207
- }
208
- return next > hi;
209
- }
210
- function prefixEqual(a, b, bits) {
211
- for (let i = 0; i * 8 < bits; i++) {
212
- const mask = bits - i * 8 >= 8 ? 0xff : (0xff << (8 - (bits - i * 8))) & 0xff;
213
- if ((a[i] & mask) !== (b[i] & mask))
214
- return false;
215
- }
216
- return true;
217
- }
218
- /** the network `bytes` / `prefix` names, its host bits cleared */
219
- function network(bytes, prefix) {
220
- return bytes.map((x, i) => i * 8 >= prefix ? 0 : prefix - i * 8 >= 8 ? x : x & ((0xff << (8 - (prefix - i * 8))) & 0xff));
221
- }
222
- const text = (family, bytes) => family === 4 ? Array.from(bytes).join(".") : ipv6Text(bytes);
223
- /** One entry read, or what is wrong with it, with a did-you-mean where one is a fair guess: a guess is offered only
224
- * once it is read as an entry itself, so it is never one that would be refused in turn. */
225
- function cidrOf(entry) {
226
- const quoted = JSON.stringify(entry);
227
- const m = /^([^/]*)(?:\/(\d{1,3}))?$/.exec(entry);
228
- const v4 = m === null ? null : ipv4Bytes(m[1]), v6 = m === null || v4 !== null ? null : ipv6Bytes(m[1]);
229
- if (m === null || (v4 === null && v6 === null)) {
230
- return { problem: `${quoted} is not an address or a CIDR (10.0.0.0/16, 2001:db8::/32, or one address such as 10.0.4.7; a host name is not looked up)` };
231
- }
232
- const family = v4 !== null ? 4 : 6, bytes = (v4 ?? v6), max = family === 4 ? 32 : 128;
233
- const prefix = m[2] === undefined ? max : Number(m[2]);
234
- if (prefix > max)
235
- return { problem: `${quoted}: an IPv${family} prefix is at most /${max}` };
236
- if (family === 6 && v4Mapped(bytes) && prefix >= 96) {
237
- const p4 = prefix - 96, net4 = network(bytes.subarray(12, 16), p4);
238
- const meant = `${text(4, net4)}${p4 === 32 ? "" : `/${p4}`}`, read = cidrOf(meant);
239
- return { problem: "problem" in read
240
- ? `${quoted} is IPv4 written as IPv6, which Mason reads as ${meant}, and ${read.problem}`
241
- : `${quoted} is IPv4 written as IPv6, which Mason reads as IPv4: did you mean ${meant}?` };
242
- }
243
- if (prefix === 0)
244
- return { problem: `${quoted} names every address, so any client could claim to be any other: name the proxy's own addresses` };
245
- const net = network(bytes, prefix);
246
- if (!prefixEqual(net, bytes, max))
247
- return { problem: `${quoted} has bits set past its prefix: did you mean ${text(family, net)}/${prefix}?` };
248
- return { family, bytes: net, prefix };
249
- }
93
+ export const proxyNames = (from, address) => inRanges(from.ranges, address);
94
+ /** what a prefix of 0 is refused with: a header believed from every address lets any client claim to be any other */
95
+ const EVERY = "so any client could claim to be any other: name the proxy's own addresses";
250
96
  /**
251
97
  * `MASON_PG_PROXY_FROM` read: null when it is unset or empty (no header is believed from anyone, as before), the
252
98
  * addresses it names, or why not: every entry that is not an address or a CIDR, each said with what is wrong, or a list
@@ -263,7 +109,7 @@ export function parseProxyFrom(value) {
263
109
  wrong.push("an empty entry between two commas");
264
110
  continue;
265
111
  }
266
- const c = cidrOf(e);
112
+ const c = cidrOf(e, EVERY);
267
113
  if ("problem" in c)
268
114
  wrong.push(c.problem);
269
115
  else
@@ -4,7 +4,7 @@ import * as crypto from "node:crypto";
4
4
  import * as net from "node:net";
5
5
  import * as tls from "node:tls";
6
6
  import { catalogRows } from "../../duck/analyze.js";
7
- import { ApiError, USER_DISABLED } from "../error.js";
7
+ import { ADDRESS_NOT_ALLOWED, ApiError, USER_DISABLED } from "../error.js";
8
8
  import { hashCaller, outcomeOf, storedPayload } from "../request-log.js";
9
9
  import { gapRefusal, StatementError } from "../../duck/statement.js";
10
10
  import { canonicalZone } from "../../lib/timezone.js";
@@ -296,6 +296,8 @@ const aborted = () => new PgError({ code: "25P02", message: "current transaction
296
296
  const nameLength = (name) => name === undefined ? undefined : Buffer.from(name, "utf8").subarray(0, 63).toString("utf8");
297
297
  /** The hint a refused login is told on the run's own listener. */
298
298
  const USER_LOGIN_HINT = "a tool that keeps one password connects as a user: mason user add <name> prints its password and URL";
299
+ /** What a connection refused for its address is told (ADDRESS_NOT_ALLOWED): the same whether the user exists or not. */
300
+ const ADDRESS_HINT = "a user limited to address ranges connects only from them: mason user list shows each user's ranges, mason user from <name> changes them";
299
301
  /**
300
302
  * THE CONNECTIONS STILL LOGGING IN ON A PUBLIC SOCKET (`ShareDoor`): at most `limit`, each for at most `ms`, the oldest
301
303
  * making way for a new one. A limit that refused the NEW connection would hand the socket to whoever opened `limit`
@@ -775,10 +777,25 @@ class Session {
775
777
  }
776
778
  let token;
777
779
  try {
778
- token = await this.door.authenticate(m.password, this.user);
780
+ token = await this.door.authenticate(m.password, this.user, this.clientAddr);
779
781
  }
780
782
  catch (err) {
781
783
  const message = err instanceof Error ? err.message : String(err);
784
+ // an address outside the user's ranges, judged before its password: refused as Postgres refuses a connection no
785
+ // pg_hba.conf line takes (28000), in words made of the name and the address alone, so a missing user and a
786
+ // limited one read the same; which it was is the log's (the ApiError's message)
787
+ if (err instanceof ApiError && err.code === ADDRESS_NOT_ALLOWED) {
788
+ this.server.log(`session ${this.id} refused: ${message}`);
789
+ const refused = new PgError({ code: "28000", message: `no entry for ${this.clientAddr === null ? "an address that could not be read" : `host "${this.clientAddr}"`}, user "${this.user}"`,
790
+ hint: ADDRESS_HINT }, 401);
791
+ this.asked = { sql: "", method: "CONNECT", startedAt: Date.now(), sentAt: this.sentBytes };
792
+ const response = errorResponse({ ...refused.fields, severity: "FATAL" });
793
+ if (this.door.refused?.(this.user) !== false)
794
+ this.recordRequest(refused, undefined, err.userName, [response]);
795
+ this.send(response);
796
+ this.end("authentication failed");
797
+ return;
798
+ }
782
799
  // a revocation record that could not be read is no wrong password: the server could not tell, said as such
783
800
  const unknown = err instanceof ApiError && err.status === 503;
784
801
  // a disabled user (`mason user disable`): its password was right, so it is no wrong password but a role that may
@@ -2446,7 +2463,7 @@ class Session {
2446
2463
  return null;
2447
2464
  try {
2448
2465
  // the user as the file has it NOW: its role given or taken since applies to this statement
2449
- const fresh = await recheck(this.token);
2466
+ const fresh = await recheck(this.token, this.clientAddr);
2450
2467
  if (fresh !== undefined && this.token !== null)
2451
2468
  this.token = fresh;
2452
2469
  }
@@ -2459,7 +2476,8 @@ class Session {
2459
2476
  this.recordRefusal(asked.toUpperCase(), err);
2460
2477
  this.fatal("28000", `the session's user is no longer accepted: ${message}`, this.door.public ? this.door.loginHint : err instanceof ApiError && err.code === USER_DISABLED
2461
2478
  ? `a disabled user's sessions end at their next statement; mason user enable ${user} lets it connect again, with the same password`
2462
- : `a removed user's sessions end at their next statement; mason user add ${user} makes it again, with a new password`);
2479
+ : err instanceof ApiError && err.code === ADDRESS_NOT_ALLOWED ? `its address ranges changed: a session from outside them ends at its next statement (mason user list shows them; mason user from ${user} changes them)`
2480
+ : `a removed user's sessions end at their next statement; mason user add ${user} makes it again, with a new password`);
2463
2481
  return null;
2464
2482
  }
2465
2483
  this.verifiedAt = now;
@@ -7,12 +7,15 @@
7
7
  *
8
8
  * - `mason <name> --help` and `mason help <name>` print its usage and exit 0;
9
9
  * - the usage starts `usage: mason <name>`, and shows at least one example (an indented `mason <name> …` line);
10
+ * - it reads in the order a person needs it: the usage line, a short description, the examples, the flags (one
11
+ * entry each); then the long prose, and a long --json shape last (`The --json answer: …`, its entry saying so);
10
12
  * - every option the parser takes is in the usage, by its `--name` (and `-x` when it has one);
11
13
  * - `mason --help` lists it with its summary;
12
14
  * - a wrong option prints what is wrong, then the usage, and exits 2.
13
15
  */
14
16
  import { parseArgs } from "node:util";
15
17
  import { err as styleErr, out as styleOut, PLAIN, wrap } from "./style.js";
18
+ import { closest } from "../lib/did-you-mean.js";
16
19
  /**
17
20
  * A LINE THAT HOLDS A HOSTED SERVICE'S TEXT IS BROKEN BY THE CLI, NEVER BY THE TERMINAL, AT EVERY WIDTH (PR 481's
18
21
  * class rounds: a value sized to the terminal's width made the terminal start a visual line at column 0 with a forged
@@ -169,15 +172,20 @@ const fit = (text, s, indent, firstUsed = indent) => {
169
172
  const HELP = { help: { type: "boolean", short: "h" } };
170
173
  /**
171
174
  * A command line read by its spec: the values and positionals; or the exit code when it answered itself — 0 after
172
- * printing the usage for `--help`, 2 after saying what is wrong with the line, then the usage.
175
+ * printing the usage for `--help` (its spec as data for `--help --json`), 2 after saying what is wrong with the line,
176
+ * then the usage.
173
177
  */
174
178
  export function parseCommand(spec, argv) {
179
+ if (specAsked(spec, argv)) {
180
+ process.stdout.write(`${specAnswer(spec)}\n`);
181
+ return 0;
182
+ }
175
183
  let parsed;
176
184
  try {
177
185
  parsed = parseArgs({ args: argv, options: { ...spec.options, ...HELP }, allowPositionals: true });
178
186
  }
179
187
  catch (e) {
180
- return failed(spec.name, { message: e.message, code: "usage", usage: spec.usage, next: [[`mason ${spec.name} --help`, "its usage, with every option"]] }, { json: wantsJson(argv, spec) });
188
+ return failed(spec.name, { ...parseProblem(spec, e), code: "usage", usage: synopsis(spec), next: [[`mason ${spec.name} --help`, "its usage, with every option"]] }, { json: wantsJson(argv, spec) });
181
189
  }
182
190
  if (parsed.values.help) {
183
191
  process.stdout.write(spec.usage);
@@ -185,6 +193,140 @@ export function parseCommand(spec, argv) {
185
193
  }
186
194
  return { values: parsed.values, positionals: parsed.positionals };
187
195
  }
196
+ /** A LIST FLAG's value, one comma list however it was written (`multiple: true` in the spec): `--columns id,status`
197
+ * and `--columns id --columns status` mean the same, `id,status`; undefined when it was not given. */
198
+ export function listFlag(v) {
199
+ if (typeof v === "string")
200
+ return v;
201
+ return Array.isArray(v) ? v.filter((x) => typeof x === "string").join(",") : undefined;
202
+ }
203
+ /** A command that takes no argument, given one (`mason stop myproject` stopped this folder's project, not that one):
204
+ * a wrong line, its usage's synopsis. Null when there is none. */
205
+ export function noArguments(spec, positionals, json) {
206
+ if (!positionals.length)
207
+ return null;
208
+ return failed(spec.name, { message: `it takes no argument (${positionals.join(" ")}): a project is its folder`, code: "usage",
209
+ fix: `run it in the project's folder: cd <folder> && mason ${spec.name}, or mason -C <folder> ${spec.name}`, usage: synopsis(spec),
210
+ next: [[`mason ${spec.name} --help`, "its usage, with every option"]] }, { json });
211
+ }
212
+ /** Whether a line asks for the command's spec as data: `--help` (or `-h`) and `--json` on a line that parses — every
213
+ * command, whether or not it takes --json itself (mason help, mason mcp): the spec is what an agent reads first. A line
214
+ * that does not parse is no such question: parseCommand's own parse says what is wrong with it. */
215
+ function specAsked(spec, argv) {
216
+ if (!argv.includes("--json") || !(argv.includes("--help") || argv.includes("-h")))
217
+ return false;
218
+ try {
219
+ const options = { ...spec.options, ...HELP, json: { type: "boolean" } };
220
+ const p = parseArgs({ args: [...argv], options, allowPositionals: true });
221
+ return p.values["help"] === true && p.values["json"] === true;
222
+ }
223
+ catch (e) {
224
+ // not swallowed: the same line goes on to parseCommand's own parse, which says this error in the CLI's words
225
+ if (!(e instanceof TypeError))
226
+ throw e;
227
+ return false;
228
+ }
229
+ }
230
+ /** The keys of a command's spec as data (`mason <name> --help --json`, `mason help <name> --json`). */
231
+ export const SPEC_ANSWER_KEYS = ["command", "next"];
232
+ /** What follows a command's spec as data. */
233
+ export const SPEC_NEXT = [["mason help --json", "every command's spec, the guides and the failure codes"]];
234
+ /** A command's spec as its --json answer: `{ command: <spec>, next }`. */
235
+ export const specAnswer = (spec) => jsonText({ ...spec, json: SPEC_ANSWER_KEYS }, { command: specJson(spec) }, SPEC_NEXT);
236
+ /**
237
+ * THE EXIT CODES, a closed set (`mason help agent` lists them, and each command's spec as data names those it can
238
+ * exit with): 124 only for a command TIMEOUT_COMMANDS names.
239
+ */
240
+ export const EXIT_CODES = {
241
+ 0: "done",
242
+ 1: "failed, with the reason",
243
+ 2: "the command line is wrong (without --json the usage follows)",
244
+ 124: "ran out of time (nothing stopped)",
245
+ };
246
+ /** Whether a command can run out of time (exit 124): TIMEOUT_COMMANDS names it. */
247
+ const timesOut = (name) => new RegExp(`\\bmason ${name}\\b`).test(TIMEOUT_COMMANDS);
248
+ export function specJson(spec) {
249
+ const lines = spec.usage.split("\n");
250
+ const end = lines.findIndex((l) => l.trim() === "");
251
+ const forms = lines.slice(0, end < 0 ? lines.length : end).map((l) => l.replace(/^usage:\s*/, "").trim());
252
+ const options = Object.entries({ ...spec.options, ...HELP }).map(([name, d]) => {
253
+ // the placeholder the synopsis writes after it, else the first the usage does
254
+ const placeholder = new RegExp(`--${name}[ =]([^\\s\\[\\]]+)`);
255
+ const value = d.type === "string" ? (forms.map((f) => placeholder.exec(f)?.[1]).find((v) => v !== undefined) ?? placeholder.exec(spec.usage)?.[1] ?? null) : null;
256
+ return { name: `--${name}`, short: d.short ? `-${d.short}` : null, type: d.type, multiple: d.multiple === true, default: d.default ?? null, value };
257
+ });
258
+ const exitCodes = Object.entries(EXIT_CODES).filter(([code]) => code !== "124" || timesOut(spec.name)).map(([code, meaning]) => ({ code: Number(code), meaning }));
259
+ return { name: spec.name, summary: spec.summary, usage: spec.usage, forms: forms.map((f) => ({ synopsis: f, positionals: positionalsOf(f, spec.name) })), options,
260
+ json: spec.json ? [...spec.json] : null, needs: spec.needs ?? null, stdout: spec.stdout ?? null, exitCodes };
261
+ }
262
+ /** The words of a synopsis form that are no option: split at the spaces outside brackets, `mason <name>` and every
263
+ * option dropped — a bracketed group that starts with `-` (`[--json]`, `[-n <lines>]`), and a bare option with its
264
+ * value (`--ssh <user@server>`). */
265
+ function positionalsOf(form, name) {
266
+ const words = [];
267
+ let word = "", depth = 0;
268
+ for (const ch of form) {
269
+ if (ch === "[" || ch === "<")
270
+ depth++;
271
+ if (ch === "]" || ch === ">")
272
+ depth = Math.max(0, depth - 1);
273
+ if (ch === " " && depth === 0) {
274
+ if (word)
275
+ words.push(word);
276
+ word = "";
277
+ continue;
278
+ }
279
+ word += ch;
280
+ }
281
+ if (word)
282
+ words.push(word);
283
+ const rest = words[0] === "mason" && words[1] === name ? words.slice(2) : words;
284
+ const out = [];
285
+ for (let i = 0; i < rest.length; i++) {
286
+ const w = rest[i];
287
+ // an option is a dash and a letter (`[--json]`, `-n`); a lone `-` (`mason init -`, stdin) is a positional
288
+ if (/^\[--?[A-Za-z]/.test(w))
289
+ continue;
290
+ if (/^--?[A-Za-z]/.test(w)) {
291
+ if (rest[i + 1]?.startsWith("<"))
292
+ i++;
293
+ continue;
294
+ }
295
+ out.push(w);
296
+ }
297
+ // an alternative whose other side was an option (`<org>/<name> | --replica <org>/<name>`) leaves no `|` behind
298
+ return out.filter((w, i) => w !== "|" || (i > 0 && i < out.length - 1));
299
+ }
300
+ /** A command's synopsis: its usage's first lines, up to the first blank one (`usage: mason <name> …` and its other
301
+ * forms), then where the rest is. What a wrong line is answered with: the whole usage is for `--help` (94 lines for
302
+ * `mason status`, and the mistake scrolled away above it). */
303
+ export function synopsis(spec) {
304
+ const lines = spec.usage.split("\n");
305
+ const end = lines.findIndex((l) => l.trim() === "");
306
+ return `${lines.slice(0, end < 0 ? lines.length : end).join("\n")}\n\nmason ${spec.name} --help for more\n`;
307
+ }
308
+ /** What is wrong with a line the parser refused, in the CLI's words (never Node's, whose advice about `--` misleads):
309
+ * an option it does not take, with the closest it does (`didYouMean` over the spec's options and `--help`); one that
310
+ * needs a value and has none; one that takes none and was given one. */
311
+ function parseProblem(spec, e) {
312
+ const options = { ...spec.options, ...HELP };
313
+ const names = Object.keys(options).map((n) => `--${n}`);
314
+ const shorts = Object.entries(options).flatMap(([n, d]) => d.short ? [[`-${d.short}`, `--${n}`]] : []);
315
+ const said = /'(-[^'\s,]*)/.exec(e.message)?.[1];
316
+ const flag = said === undefined ? undefined : said.startsWith("--") ? said.replace(/=.*$/, "") : said;
317
+ if (e.code === "ERR_PARSE_ARGS_UNKNOWN_OPTION" && flag !== undefined) {
318
+ const near = closest(flag, [...names, ...shorts.map(([s]) => s)]);
319
+ return { message: `there is no option ${flag}${near !== null ? ` — did you mean \`${near}\`?` : ""}`,
320
+ fix: `its options are ${names.join(", ")}` };
321
+ }
322
+ if (e.code === "ERR_PARSE_ARGS_INVALID_OPTION_VALUE" && flag !== undefined) {
323
+ const long = shorts.find(([s]) => s === flag)?.[1] ?? flag;
324
+ const named = long === flag ? long : `${flag} (${long})`;
325
+ const takes = options[long.slice(2)]?.type === "string";
326
+ return takes ? { message: `${named} needs a value: ${flag} <value>` } : { message: `${named} takes no value: write ${flag} alone` };
327
+ }
328
+ return { message: e.message };
329
+ }
188
330
  /** The column a step's `what` starts in, after the two-space indent: wide enough for `mason add orders --columns …`. */
189
331
  const NEXT_WIDTH = 44;
190
332
  /**
@@ -255,8 +397,11 @@ export function jsonText(spec, value, next = []) {
255
397
  throw new Error(`mason ${spec.name}: its json keys must end with next (every --json answer carries what to run next)`);
256
398
  return JSON.stringify({ ...value, next: nextJson(next) }, null, 2);
257
399
  }
400
+ /** The commands that can run out of time, exit 124 (code `timeout`): the one list the codes and the agent's guide say. */
401
+ export const TIMEOUT_COMMANDS = "mason wait, mason deploy, mason replica create, mason replica delete, mason login (its code expired), mason share and mason ui --ssh (no address or answer in time)";
258
402
  /**
259
- * THE CODES A FAILURE CARRIES, a closed set a program can branch on (`mason help agent` lists them).
403
+ * THE CODES A FAILURE CARRIES, a closed set a program can branch on (`mason help agent` lists them): `Failure.code` is
404
+ * one of them, so a code outside the set does not compile.
260
405
  */
261
406
  export const FAILURE_CODES = {
262
407
  usage: "the command line is wrong: a flag, an argument, a value (exit 2; the usage follows for a person)",
@@ -266,12 +411,16 @@ export const FAILURE_CODES = {
266
411
  bad_roles: "the project's roles file (mason/roles.yml) names something wrong: a table the sources file does not sync, a column the synced table lacks, a role with no table; the file, the key and the way out are said",
267
412
  not_running: "Mason is not running for the project (mason start)",
268
413
  unreachable: "Mason is running but does not answer (mason logs says why)",
414
+ not_answering: "Mason's process is alive and does not answer its commands, hung: mason stop ends it (its synced data stays)",
269
415
  not_found: "a name nothing has: a table, a column, a user",
270
416
  nothing_yet: "nothing to work on yet: no table added (the next steps say what adds one)",
271
417
  refused: "Mason refused what was asked, with the reason",
272
418
  failed: "the command tried and could not finish: the reason, and what to do",
273
- timeout: "ran out of time — mason wait, mason deploy, mason replica create; nothing stopped (exit 124)",
419
+ timeout: `ran out of time — ${TIMEOUT_COMMANDS}; nothing stopped (exit 124)`,
274
420
  };
421
+ /** A code another process sent (the run's refusal, the server's Mason over ssh) as one of ours: its own when it is in
422
+ * the set, else `otherwise` — a code outside it never reaches a program that branches on the set. */
423
+ export const failureCode = (code, otherwise) => typeof code === "string" && Object.hasOwn(FAILURE_CODES, code) ? code : otherwise;
275
424
  /** A failure thrown from deep in a command, for main.ts to print: the one way a failure leaves a command without a return code. */
276
425
  export class CommandFailed extends Error {
277
426
  failure;
@@ -312,7 +461,7 @@ export function failureJson(f) {
312
461
  * data never has to read prose. tests/conventions.test.ts refuses a `mason <cmd>: …` written to stderr any other way.
313
462
  */
314
463
  export function failed(cmd, f, o) {
315
- const n = asFailure(f);
464
+ const n = usageWayOut(cmd, asFailure(f));
316
465
  const io = o.io ?? STDIO;
317
466
  if (o.json)
318
467
  io.out(failureJson(n));
@@ -320,6 +469,21 @@ export function failed(cmd, f, o) {
320
469
  io.err(failureText(cmd, n, o.style ?? failureStyle(io)));
321
470
  return exitOf(n);
322
471
  }
472
+ /**
473
+ * A WRONG COMMAND LINE ALWAYS SAYS WHERE ITS USAGE IS, whatever raised it (the parse, or a command's own check, many of
474
+ * which answered with `next: []`): its next steps end with `mason <cmd> --help` (`mason --help` for no command), and it
475
+ * carries a fix, its own, else the way to its usage. Decided here, where every failure is said, so a usage failure added
476
+ * later cannot leave it out.
477
+ */
478
+ export function usageWayOut(cmd, f) {
479
+ if (f.code !== "usage")
480
+ return f;
481
+ const help = cmd ? `mason ${cmd} --help` : "mason --help";
482
+ // `mason help <cmd>` is the same page, so a failure that names it already says where the usage is
483
+ const said = (c) => c === help || (cmd !== "" && c === `mason help ${cmd}`);
484
+ const next = f.next?.some(([c]) => said(c)) ? f.next : [...(f.next ?? []), [help, cmd ? "its usage, with every option" : "every command"]];
485
+ return { ...f, next, fix: f.fix ?? (cmd ? `write the line as its usage says (mason ${cmd} --help)` : "name a command mason --help lists") };
486
+ }
323
487
  /** The style a failure is drawn in: stderr's own when it goes to stderr, plain for an io a test or a caller supplied.
324
488
  * By the STREAM, never by "was an io passed" (CLAUDE.md "Two readers, one interface"): mason init defaults its io to
325
489
  * STDIO and passed it on, and every failure it drew was plain — no red head, no dim labels — on a terminal. */