@mason-data/cli 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) 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 +290 -157
  7. package/dist/src/cli/deploy.js +134 -28
  8. package/dist/src/cli/doctor.js +14 -11
  9. package/dist/src/cli/guides.js +20 -11
  10. package/dist/src/cli/hosted.js +1 -1
  11. package/dist/src/cli/init.js +110 -25
  12. package/dist/src/cli/login.js +20 -17
  13. package/dist/src/cli/machine.js +125 -40
  14. package/dist/src/cli/main.js +51 -8
  15. package/dist/src/cli/mcp.js +15 -12
  16. package/dist/src/cli/project.js +1 -1
  17. package/dist/src/cli/registry.js +80 -35
  18. package/dist/src/cli/replica-hosted.js +761 -102
  19. package/dist/src/cli/replica.js +43 -5
  20. package/dist/src/cli/replication-mode.js +5 -0
  21. package/dist/src/cli/request-filter.js +16 -6
  22. package/dist/src/cli/run-server.js +25 -2
  23. package/dist/src/cli/secret-input.js +193 -0
  24. package/dist/src/cli/serve.js +2 -2
  25. package/dist/src/cli/share.js +20 -15
  26. package/dist/src/cli/source-role.js +14 -15
  27. package/dist/src/cli/sources.js +20 -3
  28. package/dist/src/cli/style.js +14 -0
  29. package/dist/src/cli/sync.js +17 -8
  30. package/dist/src/cli/token-hosted.js +30 -24
  31. package/dist/src/cli/tunnel.js +1 -1
  32. package/dist/src/cli/ui-hosted.js +163 -0
  33. package/dist/src/cli/ui-ssh.js +3 -3
  34. package/dist/src/cli/ui.js +67 -26
  35. package/dist/src/cli/usage.js +19 -15
  36. package/dist/src/cli/user-hosted.js +78 -13
  37. package/dist/src/cli/user.js +168 -50
  38. package/dist/src/duck/users.js +105 -9
  39. package/dist/src/lib/cidr.js +177 -0
  40. package/dist/src/lib/table-sync.js +69 -19
  41. package/package.json +6 -6
@@ -12,9 +12,10 @@ import { fileSize, guarded, runFailure } from "./commands.js";
12
12
  import { timeOf } from "./request-filter.js";
13
13
  import { duration, msText } from "./readiness.js";
14
14
  import { SIZE_EVERY_MS } from "../duck/usage.js";
15
+ import { parseDuration } from "../lib/table-sync.js";
15
16
  const out = (m) => { process.stdout.write(`${m}\n`); };
16
17
  const n = (x) => x.toLocaleString("en-US");
17
- export const USAGE_USAGE = `usage: mason usage [--since <when>] [--until <when>] [--step <span>] [--json]
18
+ export const USAGE_USAGE = `usage: mason usage [--since <when>] [--until <when>] [--step <duration>] [--json]
18
19
 
19
20
  What Mason was and did over time, from a sample it takes every minute and keeps MASON_HISTORY_DAYS days
20
21
  (default 7): a line each span — the statements of the Postgres API (how many, how many were refused, failed or
@@ -29,15 +30,18 @@ export const USAGE_USAGE = `usage: mason usage [--since <when>] [--until <when>]
29
30
 
30
31
  --since <when> from when: an age (90s, 30m, 6h, 7d) or an ISO 8601 date or time with its zone; default 1h
31
32
  --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 }
33
+ --step <duration> a line per span (1m to the days kept; default 5m; a bare number is seconds), or 0 for every
34
+ sample as it was taken
35
+ --json as data: its shape is at the end
36
+
37
+ The --json answer: { since, until, stepSeconds (null: every sample), sampleSeconds, keptDays, samples: [{ at
38
+ (with a step, the span's start), seconds, copyBytes, copyRows, tablesAdded, tablesCopied,
39
+ tablesFailed, oldestSyncSeconds, statements, statementsRefused, statementsFailed, statementsCancelled,
40
+ rowsSent, bytesSent, durationP50Ms, durationP95Ms, streamLagBytes, streamLagSeconds, rssBytes,
41
+ heapUsedBytes, copyMemoryBytes, copyMemoryLimitBytes, slotWalHeldBytes (replication: the WAL the
42
+ slot holds on the database), diskFreeBytes (free where the synced data is; with a step, the lowest),
43
+ sessionsOpen (the Postgres API's sessions logged in) }],
44
+ copySizes: [{ table, bytes, at }], next }
41
45
  `;
42
46
  export const USAGE = {
43
47
  needs: "run",
@@ -47,13 +51,13 @@ export const USAGE = {
47
51
  options: { since: { type: "string" }, until: { type: "string" }, step: { type: "string" }, json: { type: "boolean" } },
48
52
  json: ["since", "until", "stepSeconds", "sampleSeconds", "keptDays", "samples", "copySizes", "next"],
49
53
  };
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. */
54
+ /** A span as the options write it (the command line's one duration grammar, src/lib/table-sync.ts `parseDuration`:
55
+ * 90s, 30m, 6h, 7d, a bare number of seconds; 0 for every sample), in whole seconds, or null when it is not one. */
52
56
  export function spanSeconds(text) {
53
57
  if (text.trim() === "0")
54
58
  return 0;
55
- const m = /^\s*(\d+)\s*([smhd])\s*$/.exec(text);
56
- return m ? Number(m[1]) * UNIT[m[2]] : null;
59
+ const d = parseDuration(text, { zero: true });
60
+ return "problem" in d || !Number.isInteger(d.seconds) ? null : d.seconds;
57
61
  }
58
62
  /** 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
63
  export function sampleLine(s, withLag) {
@@ -87,7 +91,7 @@ export async function usage(argv, now = Date.now()) {
87
91
  return failed("usage", { ...t, usage: USAGE_USAGE }, { json });
88
92
  const step = spanSeconds(v.step ?? "5m");
89
93
  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 });
94
+ return failed("usage", { message: `--step is a duration (300, 1m, 5m, 1h, 1d: a bare number is seconds) or 0 for every sample, not ${JSON.stringify(v.step)}`, code: "usage", usage: USAGE_USAGE }, { json });
91
95
  const w = await reach(USAGE, json);
92
96
  if (w === null || !("run" in w))
93
97
  return 1;
@@ -4,7 +4,8 @@
4
4
  * server takes for it (src/cli/run-server.ts `/api/users`), sent to the service under `/v1/replicas/{org}/{name}/users`:
5
5
  *
6
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)
7
+ * add POST …/users { name, role?, set?, from? } mason user add --json's shape (the run is up: its url)
8
+ * from POST …/users/{user}/from { from } { changed, already }: its ranges replaced (null: any address)
8
9
  * disable POST …/users/{user}/disable { disabled, already }
9
10
  * enable POST …/users/{user}/enable { enabled, already }
10
11
  * remove DELETE …/users/{user} { removed }
@@ -36,6 +37,10 @@
36
37
  * role, roleFrom, readsNothing, rows, list's lines, --json `ROLE_NAME_RE`; closed sets; booleans; each consistent with the others
37
38
  * roleKnown, live
38
39
  * attributes, missingAttributes rows, list's lines, --json `ATTRIBUTE_NAME_RE` names; each value one line (`serviceText`), bounded
40
+ * from (a user's address ranges) rows, list's lines, add's row, --json null, or 1 to MAX_RANGES ranges each in the one form
41
+ * src/duck/users.ts `userRanges` writes back (`heldRanges`);
42
+ * on add and from, exactly the ranges sent, else the answer
43
+ * is refused whole (`sameRanges`, `unconfirmed`: fail closed)
39
44
  * lastUsedNote, urlNote list's tip "Last used: …", --json one line (`serviceText`), bounded
40
45
  * requestLogDays list's lines, --json a whole number, 1 to 36500
41
46
  * already the headline chosen, --json a boolean
@@ -57,12 +62,12 @@ import { replicaText, tokenFor } from "./replica.js";
57
62
  import { holdOutput } from "./output-gate.js";
58
63
  import { err as styleErr, out as styleOut } from "./style.js";
59
64
  import { connectionUrl, psqlLine } from "./pg-url.js";
60
- import { USER_NAME_RE } from "../duck/users.js";
65
+ import { MAX_RANGES, USER_NAME_RE, userRanges } from "../duck/users.js";
61
66
  import { ROLE_NAME_RE } from "./roles.js";
62
67
  import { ATTRIBUTE_NAME_RE } from "../duck/row-filter.js";
63
68
  import { databaseNameProblem } from "../lib/database-name.js";
64
69
  import { NO_ROLES } from "../duck/grants.js";
65
- import { addedNext, addedReport, disabledNext, disabledReport, enabledNext, enabledReport, listNext, listText, REMOVED_NEXT, removedReport, USER } from "./user.js";
70
+ import { addedNext, addedReport, disabledNext, disabledReport, enabledNext, enabledReport, fromNext, fromReport, listNext, listText, REMOVED_NEXT, removedReport, USER } from "./user.js";
66
71
  /** 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
72
  * is `call`'s own 64 KiB. */
68
73
  export const MAX_LIST_BYTES = 1024 * 1024;
@@ -83,7 +88,9 @@ export function requestOf(r, o) {
83
88
  // a user's name is USER_NAME_RE's (checked before this): letters, digits, `_`, `.` and `-`, safe in a path as it is
84
89
  switch (o.verb) {
85
90
  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] } : {}) } };
91
+ case "add": return { method: "POST", path: base, json: { name: o.name, ...(o.role !== undefined ? { role: o.role } : {}), ...(o.sets.length ? { set: [...o.sets] } : {}),
92
+ ...(o.from !== undefined && o.from !== null ? { from: [...o.from] } : {}) } };
93
+ case "from": return { method: "POST", path: `${base}/${o.name}/from`, json: { from: o.from === undefined || o.from === null ? null : [...o.from] } };
87
94
  case "disable":
88
95
  case "enable": return { method: "POST", path: `${base}/${o.name}/${o.verb}` };
89
96
  case "remove": return { method: "DELETE", path: `${base}/${o.name}` };
@@ -125,6 +132,16 @@ function attributesOf(v) {
125
132
  }
126
133
  return outv;
127
134
  }
135
+ /** A user's address ranges as the service sends them: null (any address), or 1 to MAX_RANGES ranges, each in the one form
136
+ * the one grammar writes back (src/duck/users.ts `userRanges`); undefined when they are not that. */
137
+ export function heldRanges(v) {
138
+ if (v === null)
139
+ return null;
140
+ if (!Array.isArray(v) || v.length < 1 || v.length > MAX_RANGES || !v.every((x) => typeof x === "string" && x.length <= 64))
141
+ return undefined;
142
+ const r = userRanges(v);
143
+ return "problem" in r || r.from.length !== v.length || r.from.some((x, i) => x !== v[i]) ? undefined : r.from;
144
+ }
128
145
  const names = (v) => Array.isArray(v) && v.length >= 1 && v.length <= 64 && v.every((x) => typeof x === "string" && ATTRIBUTE_NAME_RE.test(x));
129
146
  const timeOrNull = (v) => v === null || isServiceTime(v);
130
147
  /** One row of a list, as `listUsers` makes it (src/cli/user.ts `UserRow`); null when it is not that shape. */
@@ -142,6 +159,10 @@ function rowOf(v) {
142
159
  const role = roleOf(u);
143
160
  if (role === null)
144
161
  return null;
162
+ // a replica's Mason before address ranges sends none: it has none, so any address
163
+ const from = u.from === undefined ? null : heldRanges(u.from);
164
+ if (from === undefined)
165
+ return null;
145
166
  const attributes = u.attributes === undefined ? undefined : attributesOf(u.attributes);
146
167
  if (attributes === null)
147
168
  return null;
@@ -150,7 +171,7 @@ function rowOf(v) {
150
171
  if (u.roleKnown !== undefined && (typeof u.roleKnown !== "boolean" || role.roleFrom !== "file"))
151
172
  return null;
152
173
  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 } : {}),
174
+ url: u.url, from, ...role, ...(attributes !== undefined ? { attributes } : {}),
154
175
  ...(u.missingAttributes !== undefined ? { missingAttributes: u.missingAttributes } : {}),
155
176
  ...(typeof u.roleKnown === "boolean" ? { roleKnown: u.roleKnown } : {}) };
156
177
  }
@@ -195,11 +216,15 @@ export function heldAdded(json, asked, secrets) {
195
216
  const role = roleOf(a);
196
217
  if (role === null)
197
218
  return null;
219
+ const from = a.from === undefined ? undefined : heldRanges(a.from);
220
+ // null (any address) is read as no ranges, as a list's row reads it: an add that sent ranges then refuses it unconfirmed
221
+ if (from === undefined && a.from !== undefined)
222
+ return null;
198
223
  const attributes = a.attributes === undefined ? undefined : attributesOf(a.attributes);
199
224
  if (attributes === null)
200
225
  return null;
201
226
  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 } : {}) };
227
+ ...role, ...(attributes !== undefined ? { attributes } : {}), ...(from !== undefined && from !== null ? { from } : {}) };
203
228
  }
204
229
  /** A user as disable, enable and remove name it: the name asked, its id, when it was added, and `more` times. */
205
230
  function userOf(v, asked, more) {
@@ -219,6 +244,14 @@ export function heldEnabled(json, asked, secrets) {
219
244
  const e = userOf(json.enabled, asked, []);
220
245
  return e === null ? null : { enabled: e, already: json.already };
221
246
  }
247
+ /** `POST …/users/{user}/from`'s answer: the user asked, its ranges now (null: any address), and `already`. */
248
+ export function heldFrom(json, asked, secrets) {
249
+ if (!obj(json) || typeof json.already !== "boolean" || !carriesNoSecret(json, secrets))
250
+ return null;
251
+ const c = userOf(json.changed, asked, []);
252
+ const from = obj(json.changed) ? heldRanges(json.changed.from) : undefined;
253
+ return c === null || from === undefined ? null : { changed: { ...c, from }, already: json.already };
254
+ }
222
255
  export function heldRemoved(json, asked, secrets) {
223
256
  if (!obj(json) || !carriesNoSecret(json, secrets))
224
257
  return null;
@@ -226,8 +259,25 @@ export function heldRemoved(json, asked, secrets) {
226
259
  return r === null ? null : { removed: r };
227
260
  }
228
261
  // ------------------------------------------------------------------ the command
262
+ /** Whether the ranges an answer carries are exactly those sent (each in its one form, in order; null: any address). */
263
+ export const sameRanges = (got, sent) => got !== undefined && JSON.stringify(got) === JSON.stringify(sent);
264
+ /**
265
+ * AN ADDRESS LIMIT THE SERVICE DID NOT CONFIRM: its 200 does not carry the ranges sent (none, null, or others). The
266
+ * answer is refused whole, never printed (an add's password included): the user may now connect from more addresses
267
+ * than asked, which only the replica's list can say, and the way out removes it or sets the ranges again.
268
+ */
269
+ export function unconfirmed(r, name, sent, verb) {
270
+ const on = `--replica ${replicaText(r)}`;
271
+ const asked = sent === null ? "any address" : sent.join(", ");
272
+ return { message: `the service did not confirm ${name}'s address limit: its answer does not carry the ranges sent (${asked})`, code: "failed",
273
+ why: verb === "add" ? `the replica may have added ${name} with no limit, so it would connect from any address, and its password was not shown`
274
+ : `the replica may have kept ${name}'s ranges as they were, or set others`,
275
+ fix: verb === "add" ? `mason user list ${on} says what ${name} has; if it was made without the limit, mason user remove ${name} ${on}`
276
+ : `mason user list ${on} says what ${name} has now; mason user from ${name} ${sent === null ? "--any" : sent.join(" ")} ${on} sets it again, or mason user remove ${name} ${on}`,
277
+ next: [[`mason user list ${on}`, "the replica's users and the addresses each connects from"], [`mason user remove ${name} ${on}`, "if it was made without the limit"]] };
278
+ }
229
279
  /** 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" };
280
+ const STEP = { list: "the replica's users were asked for", add: "a user was added", from: "a user's address ranges were set", disable: "a user was disabled", enable: "a user was enabled", remove: "a user was removed" };
231
281
  /** `mason user <verb> … --replica <org>/<name>`: one request, its answer held and printed as the local command prints it. */
232
282
  export async function hostedUser(r, o) {
233
283
  const outStyle = serviceStyle(styleOut());
@@ -250,8 +300,10 @@ export async function hostedUser(r, o) {
250
300
  if (a.status !== 200)
251
301
  return fail(refused(a));
252
302
  const outside = () => fail(unexpected(service, a, STEP[o.verb]));
303
+ // the target in every answer: the replica's name in --json and in the headline
304
+ const on = replicaText(r);
253
305
  const print = (value, next, text) => {
254
- out(o.json ? jsonText(USER, value, next) : text());
306
+ out(o.json ? jsonText(USER, { replica: on, ...value }, next) : text());
255
307
  return 0;
256
308
  };
257
309
  switch (o.verb) {
@@ -260,7 +312,7 @@ export async function hostedUser(r, o) {
260
312
  if (l === null)
261
313
  return outside();
262
314
  const next = onReplica(listNext(l.users), r);
263
- return print({ ...l }, next, () => listText(replicaText(r), l, NO_ROLES, next, outStyle));
315
+ return print({ ...l }, next, () => listText(on, l, NO_ROLES, next, outStyle));
264
316
  }
265
317
  case "add": {
266
318
  const added = heldAdded(a.json, o.name, secrets);
@@ -271,29 +323,42 @@ export async function hostedUser(r, o) {
271
323
  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
324
  next: [[`mason user list --replica ${replicaText(r)}`, "the replica's users"]] });
273
325
  }
326
+ // FAIL CLOSED: ranges sent must come back as sent, or the user may have been made with none (a service that does
327
+ // not know `from` would make it connect from any address), and its password is not shown
328
+ if (o.from !== undefined && o.from !== null && !sameRanges(added.from, o.from))
329
+ return fail(unconfirmed(r, o.name, o.from, "add"));
274
330
  const next = onReplica(addedNext(added.user, true), r);
275
- return print({ ...added }, next, () => addedReport(added, { roles: NO_ROLES, listener: null, next }, outStyle));
331
+ return print({ ...added }, next, () => addedReport(added, { roles: NO_ROLES, listener: null, next, on }, outStyle));
332
+ }
333
+ case "from": {
334
+ const c = heldFrom(a.json, o.name, secrets);
335
+ if (c === null)
336
+ return outside();
337
+ if (!sameRanges(c.changed.from, o.from ?? null))
338
+ return fail(unconfirmed(r, o.name, o.from ?? null, "from"));
339
+ const next = onReplica(fromNext(c.changed.name, c.changed.from), r);
340
+ return print({ ...c }, next, () => fromReport(c, true, next, outStyle));
276
341
  }
277
342
  case "disable": {
278
343
  const d = heldDisabled(a.json, o.name, secrets);
279
344
  if (d === null)
280
345
  return outside();
281
346
  const next = onReplica(disabledNext(d.disabled.name), r);
282
- return print({ ...d }, next, () => disabledReport(d, true, next, outStyle));
347
+ return print({ ...d }, next, () => disabledReport(d, true, next, outStyle, on));
283
348
  }
284
349
  case "enable": {
285
350
  const e = heldEnabled(a.json, o.name, secrets);
286
351
  if (e === null)
287
352
  return outside();
288
353
  const next = onReplica(enabledNext(e.enabled.name), r);
289
- return print({ ...e }, next, () => enabledReport(e, next, outStyle));
354
+ return print({ ...e }, next, () => enabledReport(e, next, outStyle, on));
290
355
  }
291
356
  case "remove": {
292
357
  const x = heldRemoved(a.json, o.name, secrets);
293
358
  if (x === null)
294
359
  return outside();
295
360
  const next = onReplica(REMOVED_NEXT, r);
296
- return print({ ...x }, next, () => removedReport(x, true, next, outStyle));
361
+ return print({ removedUser: x.removed, ...x }, next, () => removedReport(x, true, next, outStyle, on));
297
362
  }
298
363
  }
299
364
  }