@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
@@ -26,7 +26,12 @@
26
26
  * 5. THE WAIT is bounded by `--timeout` (an interval, 10m by default): a poll every `POLL_MS`, or what `Retry-After`
27
27
  * asks; an answer that cannot say (no answer, a 429, a 5xx) is a wait, never an outcome. Exit 0 only when the
28
28
  * replica runs this deploy; 1 when Mason there refused it (its failure's words printed as they came) or the deploy
29
- * failed; 124 at the timeout, which stops nothing: the deploy's id says where to look.
29
+ * failed; 124 at the timeout, which stops nothing: the deploy's id says where to look. The same clock bounds the
30
+ * wait BEFORE a deploy is made: a POST answered `503` (the replica is being checked or created, or its machine is not
31
+ * connected) or `429` (too many deploys at once) made none, and is sent again after its `Retry-After` (else
32
+ * `RESEND_MS`), the replica's state and the time waited said meanwhile; no other answer is ever sent again. A `409`
33
+ * naming a replica that failed or is being deleted is refused, its next step `mason replica status`; a deleted
34
+ * replica's name is freed, so the service answers it `404`, as a name that never existed.
30
35
  *
31
36
  * A SERVICE'S VALUE IS DATA, NEVER STRUCTURE AND NEVER AN INSTRUCTION (#481's decision, src/cli/hosted.ts). Every place a
32
37
  * value the service sent reaches what this command prints:
@@ -57,7 +62,7 @@ import * as path from "node:path";
57
62
  import { createHash } from "node:crypto";
58
63
  import { setTimeout as sleep } from "node:timers/promises";
59
64
  import { errorJson, exitOf, failed, jsonText, parseCommand, reportText, serviceStyle, strict } from "./command.js";
60
- import { call, carriesNoSecret, failureShape, isFailure, isServiceTime, serviceFailure, serviceOf, serviceText, unexpected } from "./hosted.js";
65
+ import { call, carriesNoSecret, failureShape, isFailure, isServiceTime, serviceFailure, serviceNext, serviceOf, serviceText, unexpected } from "./hosted.js";
61
66
  import { replicaOf, replicaText, REPLICA_ENV, tokenFor } from "./replica.js";
62
67
  import { holdOutput } from "./output-gate.js";
63
68
  import { maskedUrl } from "./preflight.js";
@@ -69,7 +74,7 @@ import { readSourceFile, shownPath } from "./sources.js";
69
74
  import { readRoles, roleProblems, rolesFailure, rolesFile } from "./roles.js";
70
75
  import { readGenerated } from "./generated-files.js";
71
76
  import { refusalText } from "../duck/generated-plan.js";
72
- import { parseDuration } from "../lib/table-sync.js";
77
+ import { MAX_POLL_SECONDS, MIN_POLL_SECONDS, parseDuration } from "../lib/table-sync.js";
73
78
  import { textEntry } from "../lib/log.js";
74
79
  import { sayAgain } from "./commands.js";
75
80
  import { err as styleErr, out as styleOut, stepRenderer } from "./style.js";
@@ -83,6 +88,11 @@ export const PATH_SEGMENT = /^[A-Za-z0-9_][A-Za-z0-9._-]*$/;
83
88
  const MAX_PATH = 255;
84
89
  /** How often the deploy's state is asked for, when the service asks no other. */
85
90
  export const POLL_MS = 2_000;
91
+ /** How long before a deploy is sent again after a `503` or a `429` that names no wait the CLI reads (no `Retry-After`,
92
+ * or one in another form): short, and bounded by `--timeout` as every wait is. */
93
+ export const RESEND_MS = 5_000;
94
+ /** How often a wait to send says how long it has waited (a terminal's spinner; elsewhere `sayAgain` decides). */
95
+ const TICK_MS = 1_000;
86
96
  const DEFAULT_TIMEOUT = "10m";
87
97
  /** A deploy's id, as it goes into a path: the contract's grammar. */
88
98
  const DEPLOY_ID = /^[A-Za-z0-9_-]{1,64}$/;
@@ -121,7 +131,7 @@ export const DEPLOY = {
121
131
  needs: "project",
122
132
  name: "deploy",
123
133
  summary: "send this project to a replica on a hosted Mason service, and wait until the replica runs it",
124
- usage: `usage: mason deploy [--replica <org>/<name>] [--dry-run] [--timeout <interval>] [--json]
134
+ usage: `usage: mason deploy [--replica <org>/<name>] [--dry-run] [--timeout <duration>] [--json]
125
135
 
126
136
  Sends this project's files (what mason/ holds that Mason reads: the sources file, mason.yml, roles.yml, the
127
137
  generated tables — never .env, .mason/, a dotfile or a file that is not YAML) to a replica on a hosted Mason
@@ -130,10 +140,6 @@ export const DEPLOY = {
130
140
  project that would be refused is refused here, and nothing is sent. A url: written with its password is refused:
131
141
  write url: \${DATABASE_URL} and set it on the replica.
132
142
 
133
- The replica is --replica <org>/<name>, else MASON_REPLICA (each part lower case letters, digits and -). The login
134
- is MASON_TOKEN, else this machine's (mason login); the service is MASON_CLOUD_URL, else the default. Progress
135
- goes to stderr; the answer is printed when it ends.
136
-
137
143
  mason deploy --replica acme/shop
138
144
  mason deploy --replica acme/shop --dry-run what would be sent, sending nothing
139
145
  MASON_REPLICA=acme/shop mason deploy --timeout 30m
@@ -141,19 +147,31 @@ export const DEPLOY = {
141
147
  --replica <org>/<name> the replica to deploy to (else MASON_REPLICA)
142
148
  --dry-run print each file that would be sent, its size and its sha256, and send nothing (no
143
149
  login needed)
144
- --timeout <interval> how long to wait for the outcome (default ${DEFAULT_TIMEOUT}; 1s to 24h); the deploy goes on after it
145
- --json as data, once it ends: { dryRun, replica (null when none is named on a dry run),
146
- service, files: [{ path, size, sha256 }], totalBytes, warnings (the generated tables
147
- refused, as mason start says them, each one line), deploy ({ id, status (queued,
148
- applying, starting, running, refused, failed), phase (null, checking, writing,
149
- restarting, syncing), createdAt, finishedAt };
150
- null on a dry run), error (when the replica refused it, it failed or the wait timed out:
151
- { code, message, why, fix }), next }
150
+ --timeout <duration> how long to wait for the outcome, a wait to send included (default ${DEFAULT_TIMEOUT}; 1s
151
+ to 24h; a bare number is seconds); a deploy sent goes on after it
152
+ --json as data: its shape is at the end
153
+
154
+ The replica is --replica <org>/<name>, else MASON_REPLICA (each part lower case letters, digits and -). The login
155
+ is MASON_TOKEN, else this machine's (mason login); the service is MASON_CLOUD_URL, else the default. Progress
156
+ goes to stderr; the answer is printed when it ends.
157
+
158
+ While the replica is being created (or the service asks for a wait: too many deploys at once), it waits as the
159
+ service asks and sends the deploy again, within --timeout, saying the replica's state and how long it has waited.
160
+ A replica that failed or is being deleted is refused: mason replica status says its state.
152
161
 
153
162
  Exit status: 0 the replica runs it (or a dry run printed); 1 refused here or by the service, refused by Mason on
154
163
  the replica (its words printed as they came, the code the CLI's own), the deploy failed, or the service answered
155
164
  outside its contract; 2 a wrong command line; 124 the wait timed out (nothing stopped: the deploy's id says where
156
- to look). Whatever the service sends, it never makes the exit 0, 2 or 124.
165
+ to look; or, before one was sent, no deploy was made). Whatever the service sends, it never makes the exit 0, 2
166
+ or 124.
167
+
168
+ The --json answer, once it ends: { dryRun, replica (null when none is named on a dry run),
169
+ service, files: [{ path, size, sha256 }], totalBytes, warnings (the generated tables
170
+ refused, as mason start says them, each one line), deploy ({ id, status (queued,
171
+ applying, starting, running, refused, failed), phase (null, checking, writing,
172
+ restarting, syncing), createdAt, finishedAt };
173
+ null on a dry run), error (when the replica refused it, it failed or the wait timed out:
174
+ { code, message, why, fix }), next }
157
175
  `,
158
176
  options: { replica: { type: "string" }, "dry-run": { type: "boolean", default: false }, timeout: { type: "string" }, json: { type: "boolean", default: false } },
159
177
  json: ["dryRun", "replica", "service", "files", "totalBytes", "warnings", "deploy", "error", "next"],
@@ -377,6 +395,29 @@ const STATUS_WORDS = {
377
395
  failed: "The deploy failed",
378
396
  };
379
397
  const NEXT_AGAIN = (r) => [[`mason deploy --replica ${replicaText(r)}`, "deploy again, after the next edit"]];
398
+ /**
399
+ * THE REPLICA'S STATE, as a `503` or a `409` to the POST may carry it (`{ replica: { state } }`, docs/HOSTED-API.md):
400
+ * one of a closed set, said in the CLI's words, never the service's text; null when it carries none, or another.
401
+ * `503`: a replica being made (`checking`, `creating`), or `ready` with the machine it runs on not connected just now —
402
+ * a wait, and the deploy is sent again. `409`: a replica that cannot take one (`failed`, `deleting`) — refused,
403
+ * never sent again. A deleted replica is no state here: its name is freed, and the service answers it `404`.
404
+ */
405
+ export const WAITING_STATES = {
406
+ checking: "is being checked (Mason checks its database)",
407
+ creating: "is being created",
408
+ ready: "is ready, and the machine it runs on is not connected just now",
409
+ };
410
+ export const REFUSED_STATES = {
411
+ failed: "failed: its creation did not finish",
412
+ deleting: "is being deleted",
413
+ };
414
+ function replicaStateOf(json, states) {
415
+ if (!obj(json) || !obj(json.replica) || typeof json.replica.state !== "string")
416
+ return null;
417
+ return Object.hasOwn(states, json.replica.state) ? json.replica.state : null;
418
+ }
419
+ /** A wait, as a person reads it: whole seconds, then minutes and seconds. */
420
+ const waited = (ms) => { const n = Math.floor(ms / 1000); return n < 60 ? `${n} s` : `${Math.floor(n / 60)} min ${n % 60} s`; };
380
421
  const stateLine = (s) => `${STATUS_WORDS[s.status]}${s.phase ? ` (${PHASES[s.phase]})` : ""}`;
381
422
  export async function deploy(argv) {
382
423
  const f = parseCommand(DEPLOY, argv);
@@ -387,7 +428,7 @@ export async function deploy(argv) {
387
428
  if (f.positionals.length)
388
429
  return fail({ message: "it takes no argument", code: "usage", why: "the project is the folder's, the replica --replica's", usage: DEPLOY.usage }, { json });
389
430
  const timeoutText = typeof f.values.timeout === "string" ? f.values.timeout : DEFAULT_TIMEOUT;
390
- const timeout = parseDuration(timeoutText);
431
+ const timeout = parseDuration(timeoutText, { min: MIN_POLL_SECONDS, max: MAX_POLL_SECONDS });
391
432
  if ("problem" in timeout)
392
433
  return fail({ message: `--timeout ${timeout.problem}`, code: "usage", usage: DEPLOY.usage }, { json });
393
434
  const replica = replicaOf(typeof f.values.replica === "string" ? f.values.replica : undefined, DEPLOY.usage);
@@ -448,26 +489,91 @@ export async function deploy(argv) {
448
489
  const secrets = [token];
449
490
  const where = `/v1/replicas/${r.org}/${r.name}/deploys`;
450
491
  const started = Date.now(), deadline = started + timeout.seconds * 1000;
451
- // THE DEPLOY, SENT: 201 and its id, else the service's failure as it worded it
452
- const sent = await call(service, "POST", where, { token, json: { files: files.map(({ path: p, sha256, content }) => ({ path: p, sha256, content })), client: { version: version() } } });
492
+ const s = errStyle();
493
+ // a spinner turns only on a terminal wide enough for a label beside its frame; else each line is said once, as on a pipe
494
+ const terminal = !json && process.stderr.isTTY === true && clipped("x") !== null;
495
+ const steps = stepRenderer({ line: say, live: terminal ? process.stderr : null, style: s });
496
+ const name = replicaText(r);
497
+ const statusStep = [`mason replica status ${name}`, "the replica's state"];
498
+ // THE DEPLOY, SENT: 201 and its id, else the service's failure as it worded it. A 503 (the replica is being made, or
499
+ // its machine is not connected; or the service is full) or a 429 (too many deploys at once) says NO deploy was made:
500
+ // the same body is sent again after the wait it asks (`Retry-After`, else RESEND_MS), within --timeout. No other
501
+ // answer is ever sent again: a POST that may have made a deploy would make a second.
502
+ const body = { files: files.map(({ path: p, sha256, content }) => ({ path: p, sha256, content })), client: { version: version() } };
503
+ let waitSaid = "", waitSaidAt = 0, waitLine = null;
504
+ let sent = await call(service, "POST", where, { token, json: body });
505
+ while (!isFailure(sent) && (sent.status === 503 || sent.status === 429)) {
506
+ const asked = sent;
507
+ // nothing of a wait's answer is printed but its status and the replica's state (in the CLI's words); still, an
508
+ // answer that carries the token anywhere but a failure's words is refused whole, as every answer is
509
+ const j = asked.json;
510
+ const keys = obj(j) && obj(j.error) ? { ...j, error: { ...j.error, message: null, why: null, fix: null } } : j;
511
+ if (!carriesNoSecret(keys, secrets)) {
512
+ if (terminal && waitLine !== null)
513
+ steps.fail(waitLine);
514
+ return fail(unexpected(service, asked, "a deploy was sent"), { json });
515
+ }
516
+ const st = asked.status === 503 ? replicaStateOf(j, WAITING_STATES) : null;
517
+ const reason = asked.status === 429 ? "too many deploys are being sent at once"
518
+ : st !== null ? `${name} ${WAITING_STATES[st]}` : "the service cannot take a deploy just now";
519
+ const resendAt = Date.now() + (asked.retryAfter !== null ? asked.retryAfter * 1000 : RESEND_MS);
520
+ for (;;) {
521
+ waitLine = `Waiting to send the deploy: ${reason} (HTTP ${asked.status}); waited ${waited(Date.now() - started)}`;
522
+ if (terminal)
523
+ steps.relabel(clipped(waitLine));
524
+ else if (!json) {
525
+ // a line when what it waits on changes, and every 30 s while it does not (`sayAgain`, as mason wait says its block)
526
+ const what = waitLine.replace(/; waited .*$/, "");
527
+ if (sayAgain(what, waitSaid, waitSaidAt, Date.now())) {
528
+ say(` ${waitLine}`);
529
+ waitSaid = what;
530
+ waitSaidAt = Date.now();
531
+ }
532
+ }
533
+ const left = Math.min(resendAt, deadline) - Date.now();
534
+ if (left <= 0)
535
+ break;
536
+ await sleep(Math.min(TICK_MS, left));
537
+ }
538
+ if (Date.now() >= deadline) {
539
+ if (terminal)
540
+ steps.fail(waitLine);
541
+ return fail({ message: `the deploy to ${name} was not sent within ${timeout.every}`, code: "timeout",
542
+ why: `the service asked to wait: ${reason} (HTTP ${asked.status}); no deploy was made`,
543
+ fix: `mason deploy again once ${name} is ready (mason replica status ${name} says its state); a longer wait is --timeout 30m`,
544
+ next: [statusStep, [`mason deploy --replica ${name}`, "deploy again"]] }, { json });
545
+ }
546
+ sent = await call(service, "POST", where, { token, json: body });
547
+ }
548
+ if (terminal && waitLine !== null)
549
+ steps.done(waitLine);
453
550
  if (isFailure(sent)) {
454
551
  return fail({ ...sent, why: `${sent.why ?? ""}${sent.why ? "; " : ""}whether the deploy was made is not known`,
455
552
  fix: `${sent.fix ?? "check this machine is online"}, then mason deploy again (a deploy still running is refused as busy)` }, { json });
456
553
  }
457
- if (sent.status !== 201)
458
- return fail(serviceFailure(sent, secrets) ?? unexpected(service, sent, "a deploy was sent"), { json });
554
+ if (sent.status !== 201) {
555
+ // a 409 that names the replica's state: it cannot take a deploy (failed, being deleted), never sent again;
556
+ // the next step is the replica's status, the CLI's own (never the generic "mason login")
557
+ const refused = sent.status === 409 ? replicaStateOf(sent.json, REFUSED_STATES) : null;
558
+ // a 404: no such replica, a deleted one included (its name is freed): the organisation's replicas, never mason login
559
+ const notFound = (st) => st === 404
560
+ ? [[`mason replica list ${r.org}`, "the organisation's replicas: this one was deleted, or never existed"]] : serviceNext(st);
561
+ if (refused === null)
562
+ return fail(serviceFailure(sent, secrets, notFound) ?? unexpected(service, sent, "a deploy was sent"), { json });
563
+ const f = serviceFailure(sent, secrets, () => [statusStep]);
564
+ if (f === null)
565
+ return fail(unexpected(service, sent, "a deploy was sent"), { json });
566
+ return fail({ ...f, why: `${name} ${REFUSED_STATES[refused]}; ${f.why}`,
567
+ fix: f.fix ?? `a replica takes a deploy only once it is ready: mason replica status ${name} says its state` }, { json });
568
+ }
459
569
  // what to run after a refused or failed deploy: the CLI's own step, never one the service names
460
- const again = [[`mason deploy --replica ${replicaText(r)}`, "deploy again, once what it says is fixed"]];
570
+ const again = [[`mason deploy --replica ${name}`, "deploy again, once what it says is fixed"]];
461
571
  const first = deployStateOf(sent.json, null, secrets, again);
462
572
  if (first === null || first.status !== "queued")
463
573
  return fail(unexpected(service, sent, "a deploy was sent"), { json });
464
574
  const id = first.id;
465
- const s = errStyle();
466
- // a spinner turns only on a terminal wide enough for a label beside its frame; else each line is said once, as on a pipe
467
- const terminal = !json && process.stderr.isTTY === true && clipped("x") !== null;
468
- const steps = stepRenderer({ line: say, live: terminal ? process.stderr : null, style: s });
469
575
  if (!json)
470
- say(`Sent ${files.length} file${files.length === 1 ? "" : "s"} (${bytes(totalBytes)}) to ${replicaText(r)} on ${service.base}: deploy ${id}`);
576
+ say(`Sent ${files.length} file${files.length === 1 ? "" : "s"} (${bytes(totalBytes)}) to ${name} on ${service.base}: deploy ${id}`);
471
577
  // THE WAIT: a line when the state changes (and every 30 s while it does not), a spinner beside it on a terminal
472
578
  let state = first, said = "", saidAt = 0, unsure = null, unsureSaid = null;
473
579
  const show = (line) => {
@@ -29,6 +29,7 @@ import * as path from "node:path";
29
29
  import { choosePgPort, freePort } from "./ports.js";
30
30
  import { failed } from "./command.js";
31
31
  import { checkSource, connectSource, maskedUrl, readSourceFacts, remoteConfirmation, remoteWarning } from "./preflight.js";
32
+ import { uiReadOnlySetting } from "./ui-read-only.js";
32
33
  import { createRunServer, listenOnLoopback, metricsFacts, usageFacts } from "./run-server.js";
33
34
  import { readGenerated } from "./generated-files.js";
34
35
  import { addedTables, effectiveSync, entryName, replicatedEntries, rereadSources, shownPath, syncWritten } from "./sources.js";
@@ -789,6 +790,10 @@ async function run(o, hosted) {
789
790
  version: o.version, startedAt,
790
791
  // the web page (mason ui): on loopback alone, so mason serve's is reached through mason ui --ssh
791
792
  page: true, served: o.serve !== undefined,
793
+ // MASON_UI_READ_ONLY=1: a server's page refuses every change it asks for (mason start refuses the setting above)
794
+ pageReadOnly: o.serve !== undefined && uiReadOnlySetting(process.env) === true,
795
+ // MASON_UI_SOCKET: the page on a Unix socket too (src/cli/ui-socket.ts), or null
796
+ pageSocket: o.serve?.socket ?? null,
792
797
  demo: cfg.demo === true,
793
798
  users: { file: path.join(home, USERS_FILE), apiKey: cfg.apiKey, database, postgresUrl },
794
799
  // the roles as the listener holds them: their row filters checked by its catalog (a filter Postgres refuses is a problem)
@@ -824,6 +829,7 @@ async function run(o, hosted) {
824
829
  restarts: () => readRestarts(home), jsonPaths: () => sync.kept.status(), generated: () => sync.generated.status() },
825
830
  };
826
831
  const server = createRunServer(runCtx);
832
+ o.serve?.page?.(server);
827
833
  // a sample is /api/state's facts at its moment (src/cli/run-server.ts usageFacts): one computation of each
828
834
  usage.start(() => usageFacts(runCtx));
829
835
  await listenOnLoopback(server, setupPort);
@@ -867,6 +873,8 @@ async function run(o, hosted) {
867
873
  // the stop is in place BEFORE the ready line: a supervisor or a test signals on reading it, and a SIGTERM that found
868
874
  // no handler would end the run with the default action, never recording `running: null`
869
875
  let stopping = false;
876
+ // the run's own stop, for a step of a serve's ready that failed (`ServeHooks.ready`): its words are what lastStop says
877
+ let stopFor = () => undefined;
870
878
  const stopped = new Promise((resolve) => {
871
879
  // a crash of the engine's thread: the run exits 1 with its reason in V8's fatal form, which the supervisor reads
872
880
  // (src/cli/supervise.ts crashReason), and its config still says it runs, so the supervisor starts it again
@@ -889,7 +897,7 @@ async function run(o, hosted) {
889
897
  resolve(1);
890
898
  })();
891
899
  };
892
- const stop = (signal) => {
900
+ const stop = (signal, how) => {
893
901
  if (stopping)
894
902
  return;
895
903
  stopping = true;
@@ -965,14 +973,16 @@ async function run(o, hosted) {
965
973
  (repl.owned ? "" : `; publication ${repl.publication} is its owner's, left as it is`));
966
974
  cfg.running = null;
967
975
  // how it ended, for mason status once it is gone (a kill records nothing: mason stop writes that one)
968
- cfg.lastStop = { at: new Date().toISOString(), how: signal === "SIGINT" ? "stopped from its terminal (SIGINT)" : `stopped by mason stop, or a shutdown (${signal})` };
976
+ cfg.lastStop = { at: new Date().toISOString(), how: how ?? (signal === "SIGINT" ? "stopped from its terminal (SIGINT)" : `stopped by mason stop, or a shutdown (${signal})`) };
969
977
  writeConfig(home, cfg);
970
978
  log.end();
971
979
  resolve(0);
972
980
  })();
973
981
  };
974
- process.on("SIGINT", stop);
975
- process.on("SIGTERM", stop);
982
+ // wrapped: Node calls a signal's listener with (name, number), and the number must never be read as `how`
983
+ process.on("SIGINT", () => { stop("SIGINT"); });
984
+ process.on("SIGTERM", () => { stop("SIGTERM"); });
985
+ stopFor = (how) => { stop("SIGTERM", how); };
976
986
  });
977
987
  say("");
978
988
  say(` Postgres ${withPlaceholders(postgresUrl)} (mason user add <name> makes a user)`);
@@ -985,9 +995,15 @@ async function run(o, hosted) {
985
995
  const elsewhere = elsewhereTip(pgTls.listener);
986
996
  if (elsewhere)
987
997
  say(` ${elsewhere}`);
998
+ // before the ready line: what a serve writes when it answers (MASON_UI_RUN_FILE) is there when a supervisor reads it
999
+ // a step of it that failed stops the run, said in its words, and the ready line is never printed: it did not get ready
1000
+ const notReady = o.serve?.ready({ tables: () => sync.state(), metrics: () => metricsFacts(runCtx), page: { port: setupPort, token } }) ?? null;
1001
+ if (notReady !== null) {
1002
+ stopFor(notReady);
1003
+ return await stopped;
1004
+ }
988
1005
  say(READY);
989
1006
  say("");
990
- o.serve?.ready({ tables: () => sync.state(), metrics: () => metricsFacts(runCtx) });
991
1007
  return await stopped;
992
1008
  }
993
1009
  /**
@@ -32,8 +32,13 @@ export const DOCTOR_USAGE = `usage: mason doctor [--json]
32
32
  Says whether this machine is ready for Mason — Node ${NODE_MIN} or later, memory (what the engine may use and what it
33
33
  leaves beside it; MASON_DUCKDB_MEMORY=4GiB sets it), Mason's postgres and inet extensions (installed with Mason for this
34
34
  platform, so nothing is downloaded; else the first sync downloads them), the ports Mason listens on — with the way
35
- out for anything missing. In a
36
- project's folder it checks the project's database too; with Mason running for it, the run in a few lines instead: its
35
+ out for anything missing.
36
+
37
+ mason doctor
38
+
39
+ --json as data: its shape is at the end
40
+
41
+ In a project's folder it checks the project's database too; with Mason running for it, the run in a few lines instead: its
37
42
  database, whether every added table is synced, the statements of the last day it refused or answered slowly. Both
38
43
  say whether the user Mason connects as could change a synced table's rows (a superuser, BYPASSRLS, an owner of a
39
44
  synced table, a write privilege on one or CREATE on its schema; or a write that reaches its rows through a
@@ -41,16 +46,14 @@ export const DOCTOR_USAGE = `usage: mason doctor [--json]
41
46
  reaches; a foreign server; ADMIN OPTION or a server role; directly or through a role it is a member of), with the
42
47
  statements that make a role that only reads.
43
48
 
44
- mason doctor
45
-
46
- --json as data: { ready, verdict, checks: [{ name, ok, detail, warn, code, fix }], next: [{ command, what }] }; ok
47
- is true, false, or null when it could not tell; warn (only when true): fine to run, but said loudly; code
48
- (the role's lines): ${ROLE_CODES.slice(0, 5).join(", ")},
49
- ${ROLE_CODES.slice(5).join(", ")};
50
- fix (with code): the statements and the edit that make a role that only reads
51
-
52
49
  Exit status: 0 nothing failed — a warning (!) and a check that could not tell (?) are said, and leave it 0; 1
53
50
  something failed (said, with the way out).
51
+
52
+ The --json answer: { ready, verdict, checks: [{ name, ok, detail, warn, code, fix }], next: [{ command, what }] }; ok
53
+ is true, false, or null when it could not tell; warn (only when true): fine to run, but said loudly; code
54
+ (the role's lines): ${ROLE_CODES.slice(0, 5).join(", ")},
55
+ ${ROLE_CODES.slice(5).join(", ")};
56
+ fix (with code): the statements and the edit that make a role that only reads
54
57
  `;
55
58
  export const DOCTOR = {
56
59
  name: "doctor",
@@ -255,7 +258,7 @@ export function nextSteps(checks, run, project) {
255
258
  if (project === null)
256
259
  return [
257
260
  ["mason init", "in your app's folder: the database from MASON_DATABASE_URL or DATABASE_URL in .env.local, .env or the shell"],
258
- ["mason init postgres://user:password@localhost:5432/your_app", "or name it"],
261
+ ["mason init - < db-url.txt", "or its URL on stdin"],
259
262
  ];
260
263
  if (run === null)
261
264
  return [["mason start", "Mason in the background, for this folder"]];
@@ -110,18 +110,29 @@ ${keys}
110
110
  async function agentGuide() {
111
111
  // imported here, not at the top: the registry imports this file for the HELP usage
112
112
  const { STEPS } = await import("./registry.js");
113
- const { FAILURE_CODES } = await import("./command.js");
113
+ const { EXIT_CODES, FAILURE_CODES, TIMEOUT_COMMANDS } = await import("./command.js");
114
114
  const { GENERATED_DIR } = await import("./generated-files.js");
115
115
  const w = Math.max(...STEPS.flatMap(([, cs]) => cs.map((c) => c.name.length))) + "mason --json".length;
116
116
  const commands = STEPS.map(([step, cs]) => ` ${step}\n${cs.map((c) => ` ${`mason ${c.name}${Object.hasOwn(c.options, "json") ? " --json" : ""}`.padEnd(w)} ${c.summary}`).join("\n")}`).join("\n");
117
- const codes = Object.entries(FAILURE_CODES).map(([code, what]) => ` ${code.padEnd(12)} ${what}`).join("\n");
117
+ const codes = Object.entries(FAILURE_CODES).map(([code, what]) => ` ${code.padEnd(13)} ${what}`).join("\n");
118
118
  return `DRIVING MASON FROM A CODING AGENT — the same commands a person runs, answered as data
119
119
 
120
- Every command prints for a person, or with --json one JSON object for a program. One prints text alone: mason help
121
- (this reference); mason mcp's stdout is the MCP protocol's, so it takes no --json and says a failure to start on
122
- stderr; mason logs --json is the log's entries ({ at, text }), or with --requests the request log, --events the sync's
123
- events, --running the statements running now. Nothing a command prints
124
- refers to a file this package does not ship.
120
+ Every command prints for a person, or with --json one JSON object for a program. mason mcp's stdout is the MCP
121
+ protocol's, so it takes no --json and says a failure to start on stderr; mason logs --json is the log's entries
122
+ ({ at, text }), or with --requests the request log, --events the sync's events, --running the statements running now.
123
+ mason version --json is { version, next }. Nothing a command prints refers to a file this package does not ship.
124
+
125
+ EVERY COMMAND'S SPEC, AS DATA: mason <command> --help --json (or mason help <command> --json) is { command: <spec>,
126
+ next }; mason help --json is { version, commands: [<spec>…], guides: [{ name, summary }], failureCodes: [{ code,
127
+ meaning }], next }. A <spec> is read from the same description --help prints, so the two cannot drift:
128
+ name, summary, usage usage is the text --help prints
129
+ forms [{ synopsis, positionals }]: each form of the synopsis, and its words that are no option
130
+ options [{ name, short, type, multiple, default, value }]: every option, --help too; type
131
+ "string" or "boolean"; short, default and value (a string option's placeholder) null if none
132
+ json the top-level keys of its --json answer, next last; null when it takes no --json
133
+ needs "project", "run" or null
134
+ stdout "protocol" for mason mcp, else null
135
+ exitCodes [{ code, meaning }]: the exit codes below it can exit with
125
136
 
126
137
  AN AGENT WITH MCP (Claude Code, Claude Desktop, any MCP client) adds Mason as an MCP server first, then asks with
127
138
  its tools rather than through a shell: tables (each synced table's rows and how current it is), describe, query (rows with
@@ -141,10 +152,8 @@ ${commands}
141
152
 
142
153
  A FAILURE is { "error": { "code", "message", "why", "fix" }, "next": […] } on stdout ("why" and "fix" where there is
143
154
  more to say than the message), and the exit code says it — the same code exits the same wherever it is found:
144
- 0 done
145
- 1 failed, with the reason
146
- 2 the command line is wrong (without --json the usage follows)
147
- 124 ran out of time: mason wait, mason deploy, mason replica create (nothing stopped)
155
+ ${Object.entries(EXIT_CODES).map(([code, meaning]) => ` ${code} ${code === "124" ? `${meaning}: ${TIMEOUT_COMMANDS}` : meaning}`).join("\n")}
156
+ A wrong command line's next steps end with mason <command> --help, and its fix says what to change.
148
157
  The codes:
149
158
  ${codes}
150
159
 
@@ -689,7 +689,7 @@ export function failureShape(error, secrets, as) {
689
689
  * the mask would change is no page to send a person to: either refuses it. An honest page is refused by the mask with
690
690
  * a probability under 1 in a million (it must hold 3 runs of 4 of a 43-character body by chance).
691
691
  */
692
- function pageUrl(service, v, secrets) {
692
+ export function pageUrl(service, v, secrets) {
693
693
  if (!str(v, 2048))
694
694
  return null;
695
695
  let u;