@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
@@ -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) => {
@@ -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;
@@ -38,7 +38,7 @@ import { databaseNameProblem, DEFAULT_DATABASE } from "../lib/database-name.js";
38
38
  import { DEFAULT_SYNC, syncText } from "../lib/table-sync.js";
39
39
  import { newRolesText, rolesFile } from "./roles.js";
40
40
  import { newSourceText, OLD_TABLES_DIR, parseSourceText, shownPath, sourceFileNames, sourceNameFor, sourceOf, sourcesDir } from "./sources.js";
41
- export const INIT_USAGE = `usage: mason init [<postgres url> | demo] [--name <name>] [--database <name>] [--folder <dir>] [--remote] [--skip-check] [--json]
41
+ export const INIT_USAGE = `usage: mason init [- | <postgres url> | demo] [--name <name>] [--database <name>] [--folder <dir>] [--remote] [--skip-check] [--json]
42
42
 
43
43
  Makes this folder a Mason project — run it in your app's folder. It checks it can read the database (and writes
44
44
  nothing when it cannot), then creates:
@@ -47,6 +47,21 @@ export const INIT_USAGE = `usage: mason init [<postgres url> | demo] [--name <na
47
47
  mason/roles.yml who may read what: one role, everything (tables: all), the default of a user added
48
48
  without --role, so every user reads every synced table until you narrow it — commit it
49
49
  mason/mason.yml the Mason version the project is written for — commit it: production runs that version
50
+
51
+ mason init MASON_DATABASE_URL, else DATABASE_URL (.env or the shell)
52
+ mason init - < db-url.txt the URL on stdin, written to .env, never on the command line
53
+ mason init demo a demo database Mason hosts itself: nothing to install
54
+ mason init --folder db/mason --name shop-admin the project's files elsewhere; another name
55
+ mason init --remote the database in .env is on another machine
56
+
57
+ --name <name> the project's name, which its replication slot is named for (default: this folder's)
58
+ --database <name> the database name a tool connects with, written as database: in the source file (default: none
59
+ written, so tools connect with ${DEFAULT_DATABASE}): your database's own, and a tool keeps the name it has
60
+ --folder <dir> where sources/ goes, relative to this folder (default mason; . for this folder itself)
61
+ --remote go ahead with a database on another machine (read the warnings mason init prints); not with demo
62
+ --skip-check write the project without connecting (the database is not up yet); mason start checks it; not with demo
63
+ --json as data: its shape is at the end
64
+
50
65
  With no URL it reads MASON_DATABASE_URL, else your app's DATABASE_URL, each from .env.local, then .env, then the
51
66
  shell. The source file names the variable read, url: \${DATABASE_URL} — \${MASON_DATABASE_URL} when that is the one
52
67
  read, or when a URL given here is another database than your app's DATABASE_URL: the URL itself is in .env (written
@@ -54,6 +69,9 @@ export const INIT_USAGE = `usage: mason init [<postgres url> | demo] [--name <na
54
69
  .gitignore. Every other mason command then works anywhere in this folder. A source file already here (a clone of the
55
70
  project) is kept, and a bare mason init reads the variable it names; a roles file already here is kept. In a project
56
71
  with no source file (an older Mason's), it writes that file alone, and the roles file when there is none.
72
+ mason init - reads the URL from stdin (one line, from a pipe), so its password stays off the command line, where ps
73
+ shows it to this machine's users and your shell keeps it in its history: a URL with a password given as the argument
74
+ is still taken, with a warning on stderr.
57
75
  mason init demo, in a new folder, makes a project on a demo database Mason hosts itself (a shop: customers, products,
58
76
  orders, order_items): mason start makes it in .mason/demo/ and changes it every few seconds. Three of its tables are
59
77
  added; the fourth is yours to add after mason start (mason add ${DEMO_TABLE_TO_ADD}), to watch its first sync begin.
@@ -61,27 +79,15 @@ export const INIT_USAGE = `usage: mason init [<postgres url> | demo] [--name <na
61
79
  revenue per day and status, kept current: mason wait ${DEMO_GENERATED.table} once Mason is up; mason help generated); one
62
80
  already there is kept.
63
81
 
64
- mason init MASON_DATABASE_URL, else DATABASE_URL (.env or the shell)
65
- mason init postgres://me:secret@localhost:5432/shop
66
- mason init demo a demo database Mason hosts itself: nothing to install
67
- mason init --folder db/mason --name shop-admin the project's files elsewhere; another name
68
- mason init postgres://me:secret@db.example.com/shop --remote
69
-
70
- --name <name> the project's name, which its replication slot is named for (default: this folder's)
71
- --database <name> the database name a tool connects with, written as database: in the source file (default: none
72
- written, so tools connect with ${DEFAULT_DATABASE}): your database's own, and a tool keeps the name it has
73
- --folder <dir> where sources/ goes, relative to this folder (default mason; . for this folder itself)
74
- --remote go ahead with a database on another machine (read the warnings mason init prints); not with demo
75
- --skip-check write the project without connecting (the database is not up yet); mason start checks it; not with demo
76
- --json as data: { name, root, folder, config, demo (true for the demo database's project, else false),
77
- source: { name (the source's: its file's), file, url (as the file writes it), database (the name
78
- a tool connects with, or null when the file has a mistake), env (the .env line written, its value masked, or null) }, roles: { file, written (false: one was here, and kept) },
79
- generated: [{ table, file, written (false: one was here, and kept) }] (the demo's generated table; [] for
80
- any other project), database: { url (password masked), from, version, tables }, warnings, next }
82
+ The --json answer: { name, root, folder, config, demo (true for the demo database's project, else false),
83
+ source: { name (the source's: its file's), file, url (as the file writes it), database (the name
84
+ a tool connects with, or null when the file has a mistake), env (the .env line written, its value masked, or null) }, roles: { file, written (false: one was here, and kept) },
85
+ generated: [{ table, file, written (false: one was here, and kept) }] (the demo's generated table; [] for
86
+ any other project), database: { url (password masked), from, version, tables }, warnings, next }
81
87
  `;
82
88
  export const INIT = {
83
89
  name: "init",
84
- summary: "make this folder a Mason project on your Postgres: mason init (its URL from .env), mason init postgres://… or demo",
90
+ summary: "make this folder a Mason project on your Postgres: mason init (its URL from .env), mason init - (from stdin) or demo",
85
91
  usage: INIT_USAGE,
86
92
  options: { name: { type: "string" }, database: { type: "string" }, folder: { type: "string" }, remote: { type: "boolean" }, "skip-check": { type: "boolean" }, json: { type: "boolean" } },
87
93
  json: ["name", "root", "folder", "config", "demo", "source", "roles", "generated", "database", "warnings", "next"],
@@ -162,6 +168,63 @@ export function urlVariable(root, url, found = null) {
162
168
  const addEnvLine = (root, line) => appendEnvLine(root, line, "the database Mason syncs from (mason/sources/ names it; never commit this file)");
163
169
  /** A `.env` line as it is said: its value's password masked. */
164
170
  const maskedLine = (line) => { const eq = line.indexOf("="); return `${line.slice(0, eq)}=${maskedUrl(line.slice(eq + 1))}`; };
171
+ /** The argument that reads the URL from stdin: `mason init -`. */
172
+ export const STDIN_ARG = "-";
173
+ /** The most of stdin `mason init -` reads (a URL is far shorter), and how long it waits for a pipe left open and silent. */
174
+ const STDIN_MAX_BYTES = 8192, STDIN_TIMEOUT_MS = 10_000;
175
+ /**
176
+ * THE URL FROM A PIPE (`mason init -`): stdin read whole, bounded in bytes and in time, one trailing newline trimmed;
177
+ * a terminal, nothing, more than one line, too much or nothing in time is said by kind — never by what was read, which
178
+ * may hold a password.
179
+ */
180
+ export function readPipedUrl(input, max = STDIN_MAX_BYTES, timeoutMs = STDIN_TIMEOUT_MS) {
181
+ const way = "pipe one line into it (mason init - < db-url.txt), or put the URL in .env as DATABASE_URL=… and run mason init";
182
+ if (input.isTTY === true)
183
+ return Promise.resolve({ problem: `mason init - reads the URL from a pipe, and stdin is a terminal: ${way}` });
184
+ return new Promise((resolve) => {
185
+ const chunks = [];
186
+ let size = 0, ended = false;
187
+ const finish = (r) => {
188
+ if (ended)
189
+ return;
190
+ ended = true;
191
+ clearTimeout(timer);
192
+ input.removeListener("data", onData);
193
+ input.removeListener("end", onEnd);
194
+ input.removeListener("error", onError);
195
+ input.pause();
196
+ resolve(r);
197
+ };
198
+ const onData = (c) => {
199
+ const b = typeof c === "string" ? Buffer.from(c) : c;
200
+ size += b.length;
201
+ if (size > max) {
202
+ finish({ problem: `stdin held more than ${max} bytes, which is no URL: ${way}` });
203
+ return;
204
+ }
205
+ chunks.push(b);
206
+ };
207
+ const onEnd = () => {
208
+ const text = Buffer.concat(chunks).toString("utf8").replace(/\r?\n$/, "");
209
+ if (text.trim() === "")
210
+ finish({ problem: `stdin was empty: ${way}` });
211
+ else if (/[\r\n]/.test(text))
212
+ finish({ problem: `stdin held more than one line: ${way}` });
213
+ else
214
+ finish({ url: text.trim() });
215
+ };
216
+ const onError = (e) => finish({ problem: `stdin could not be read (${e.message}): ${way}` });
217
+ const timer = setTimeout(() => finish({ problem: `nothing ended on stdin within ${timeoutMs / 1000} s: ${way}` }), timeoutMs);
218
+ input.on("data", onData);
219
+ input.on("end", onEnd);
220
+ input.on("error", onError);
221
+ input.resume();
222
+ });
223
+ }
224
+ /** The warning for a URL whose password was given as the argument, naming the safer forms — `variable` the one the
225
+ * sources file would name (DATABASE_URL, or MASON_DATABASE_URL for a database other than the app's) — never the password. */
226
+ export const argvPasswordWarning = (variable) => "the URL given on the command line holds its password, which ps shows to this machine's users and your shell keeps in its history: " +
227
+ `put it in .env as ${variable}=… and run mason init, or pipe it in with mason init - < db-url.txt`;
165
228
  export async function init(argv, cwd = process.cwd(), deps = {}) {
166
229
  const io = deps.io ?? STDIO;
167
230
  const { out: say } = io;
@@ -247,7 +310,19 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
247
310
  const keptFile = kept === null || keptPath === null ? null : parseSourceText(fs.readFileSync(keptPath, "utf8"), kept.replace(/\.ya?ml$/, ""), keptPath);
248
311
  const keptVar = keptFile !== null && !("problems" in keptFile) && "env" in keptFile.url ? keptFile.url.env : null;
249
312
  // the demo's URL names a port free now, and is the one place the port is kept: mason start reads it from there
250
- let url = demo ? demoUrl(await freePort(DEMO_PORT)) : f.positionals[0] ?? null;
313
+ // `mason init -`: the URL from a pipe, so its password never reaches the command line (ps, the shell's history)
314
+ const piped = f.positionals[0] === STDIN_ARG;
315
+ let url;
316
+ if (demo)
317
+ url = demoUrl(await freePort(DEMO_PORT));
318
+ else if (piped) {
319
+ const r = await readPipedUrl(deps.stdin ?? process.stdin);
320
+ if ("problem" in r)
321
+ return failed("init", { message: r.problem, code: "usage", usage: INIT_USAGE }, { json, io });
322
+ url = r.url;
323
+ }
324
+ else
325
+ url = f.positionals[0] ?? null;
251
326
  let from = "";
252
327
  let envFile = null; // the folder's file the URL was read from (.env.local or .env): ignored with .mason/
253
328
  let envVariable = null; // the variable envFile holds the URL in, set with it: the warning names it
@@ -263,8 +338,8 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
263
338
  if (v === null) {
264
339
  return failed("init", { message: `which database? ${file} names url: \${${keptVar}}, and this folder has no ${keptVar}`, code: "usage",
265
340
  why: `mason start reads the URL from ${keptVar}, so mason init checks that one`,
266
- fix: `put it in this folder's .env as ${keptVar}=postgres://… (the variable ${file} names), or give its URL on the command line: mason init writes it there`,
267
- next: [["mason init postgres://user:password@localhost:5432/your_app", `its URL, given: written to .env as ${keptVar}`], ["mason init", `again, once .env has ${keptVar}`]] }, { json, io });
341
+ fix: `put it in this folder's .env as ${keptVar}=postgres://… (the variable ${file} names), or pipe its URL in (mason init - < db-url.txt): mason init writes it there`,
342
+ next: [["mason init - < db-url.txt", `its URL on stdin: written to .env as ${keptVar}`], ["mason init", `again, once .env has ${keptVar}`]] }, { json, io });
268
343
  }
269
344
  found = { url: v.value, variable: keptVar, from: `${keptVar} in ${v.from}`, file: v.file };
270
345
  }
@@ -273,8 +348,8 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
273
348
  if (!found) {
274
349
  // the fixes ARE the next commands, so a coding agent reads them as such too
275
350
  return failed("init", { message: "which database? none is named, and this folder has no MASON_DATABASE_URL or DATABASE_URL", code: "usage",
276
- fix: "give its URL on the command line, or put it in this folder's .env as DATABASE_URL=postgres://… (your app's, usually) or as MASON_DATABASE_URL=postgres://… (read first: a database other than your app's)",
277
- next: [["mason init postgres://user:password@localhost:5432/your_app", "its URL, given"], ["mason init", "again, once .env has MASON_DATABASE_URL or DATABASE_URL"],
351
+ fix: "pipe its URL in (mason init - < db-url.txt), or put it in this folder's .env as DATABASE_URL=postgres://… (your app's, usually) or as MASON_DATABASE_URL=postgres://… (read first: a database other than your app's)",
352
+ next: [["mason init - < db-url.txt", "its URL on stdin: written to .env"], ["mason init", "again, once .env has MASON_DATABASE_URL or DATABASE_URL"],
278
353
  ["mason init demo", "a demo database Mason makes itself: nothing to install"]] }, { json, io });
279
354
  }
280
355
  url = found.url;
@@ -283,15 +358,22 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
283
358
  envVariable = found.variable;
284
359
  }
285
360
  // a problem names where the URL is (DATABASE_URL in .env, the URL given), never the value (src/cli/preflight.ts shownUrl)
286
- const urlName = found !== null ? found.from : demo ? "the demo database's URL" : "the URL given";
361
+ const urlName = found !== null ? found.from : demo ? "the demo database's URL" : piped ? "the URL read from stdin" : "the URL given";
287
362
  const parsed = parseSource(url, urlName);
288
363
  if ("problem" in parsed)
289
364
  return usage(parsed.problem);
365
+ // A PASSWORD ON THE COMMAND LINE: taken (refusing it would break scripts), and said on stderr with the safer forms;
366
+ // the password itself is never repeated
367
+ const argvPassword = !demo && !piped && found === null && parsed.source.password !== "";
290
368
  const remote = f.values.remote === true || sourceless?.config.remote === true;
291
369
  // a kept file's variable gets this URL in .env when the folder's files give it none; one they give another value is said
292
370
  const keptValue = keptVar === null ? null : appEnvValue(root, keptVar, {});
293
371
  const where = kept === null ? urlVariable(root, url, found)
294
372
  : { variable: keptVar ?? "", line: keptVar !== null && keptValue === null ? `${keptVar}=${url}` : null };
373
+ // the variable .env would hold it in: the one the sources file names (MASON_DATABASE_URL for another database than the app's)
374
+ const argvWarning = argvPassword ? argvPasswordWarning("problem" in where || where.variable === "" ? "DATABASE_URL" : where.variable) : null;
375
+ if (argvWarning !== null)
376
+ io.err(`! ${argvWarning}`);
295
377
  if ("problem" in where)
296
378
  return failed("init", { message: where.problem, code: "refused" }, { json, io });
297
379
  // WHAT IT DOES, AS A CHECKLIST (each item it is doing, with a spinner left of it that turns into a checkmark when
@@ -304,6 +386,9 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
304
386
  const db = maskedUrl(parsed.source.url);
305
387
  line(st.bold(`Making ${name} a Mason project in ${root}`));
306
388
  const notes = [];
389
+ // a person read the warning on stderr already; an agent reads warnings in the answer too
390
+ if (argvWarning !== null && json)
391
+ notes.push(argvWarning);
307
392
  if (keptValue !== null && keptValue.value !== url)
308
393
  notes.push(`${kept} reads ${keptVar} from ${keptValue.from}, which is ${maskedUrl(keptValue.value)}: mason start syncs from that one, not ${db}`);
309
394
  let database = { url: db, from: from ? from.slice(2, -1) : null, version: null, tables: null };