@mason-data/cli 0.0.0-stage → 0.7.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 (109) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +268 -2
  3. package/dist/src/api/error.js +18 -0
  4. package/dist/src/api/postgres/catalog-worker.js +92 -0
  5. package/dist/src/api/postgres/catalog.js +477 -0
  6. package/dist/src/api/postgres/encode.js +541 -0
  7. package/dist/src/api/postgres/explain.js +143 -0
  8. package/dist/src/api/postgres/listener.js +60 -0
  9. package/dist/src/api/postgres/parser-worker.js +58 -0
  10. package/dist/src/api/postgres/parser.js +121 -0
  11. package/dist/src/api/postgres/plain.js +33 -0
  12. package/dist/src/api/postgres/protocol.js +404 -0
  13. package/dist/src/api/postgres/schema.js +54 -0
  14. package/dist/src/api/postgres/server.js +1771 -0
  15. package/dist/src/api/postgres/translate.js +248 -0
  16. package/dist/src/api/request-log.js +50 -0
  17. package/dist/src/cdc/pg-wire.js +588 -0
  18. package/dist/src/cdc/pgoutput.js +107 -0
  19. package/dist/src/cdc/poll.js +67 -0
  20. package/dist/src/cdc/source.js +65 -0
  21. package/dist/src/cli/background.js +198 -0
  22. package/dist/src/cli/command.js +173 -0
  23. package/dist/src/cli/commands.js +1277 -0
  24. package/dist/src/cli/control.js +36 -0
  25. package/dist/src/cli/demo.js +149 -0
  26. package/dist/src/cli/dev.js +669 -0
  27. package/dist/src/cli/doctor.js +260 -0
  28. package/dist/src/cli/guides.js +158 -0
  29. package/dist/src/cli/init.js +366 -0
  30. package/dist/src/cli/machine.js +162 -0
  31. package/dist/src/cli/main.js +83 -0
  32. package/dist/src/cli/mcp.js +726 -0
  33. package/dist/src/cli/pg-tls.js +100 -0
  34. package/dist/src/cli/pg-url.js +52 -0
  35. package/dist/src/cli/pin.js +36 -0
  36. package/dist/src/cli/ports.js +118 -0
  37. package/dist/src/cli/preflight.js +347 -0
  38. package/dist/src/cli/project.js +129 -0
  39. package/dist/src/cli/readiness.js +76 -0
  40. package/dist/src/cli/registry.js +220 -0
  41. package/dist/src/cli/replication-checks.js +120 -0
  42. package/dist/src/cli/replication-mode.js +148 -0
  43. package/dist/src/cli/run-server.js +311 -0
  44. package/dist/src/cli/runs.js +120 -0
  45. package/dist/src/cli/security.js +86 -0
  46. package/dist/src/cli/self-signed-cert.js +64 -0
  47. package/dist/src/cli/serve.js +301 -0
  48. package/dist/src/cli/source-edit.js +116 -0
  49. package/dist/src/cli/sources.js +564 -0
  50. package/dist/src/cli/state.js +58 -0
  51. package/dist/src/cli/style.js +187 -0
  52. package/dist/src/cli/supervise.js +248 -0
  53. package/dist/src/cli/sync.js +94 -0
  54. package/dist/src/cli/ui.js +107 -0
  55. package/dist/src/cli/user.js +229 -0
  56. package/dist/src/cli/version.js +23 -0
  57. package/dist/src/demo/seed.js +237 -0
  58. package/dist/src/demo/source.js +655 -0
  59. package/dist/src/duck/analyze.js +113 -0
  60. package/dist/src/duck/cancel.js +49 -0
  61. package/dist/src/duck/distinct-key.js +228 -0
  62. package/dist/src/duck/executor-worker.js +268 -0
  63. package/dist/src/duck/executor.js +567 -0
  64. package/dist/src/duck/explain.js +238 -0
  65. package/dist/src/duck/functions.js +819 -0
  66. package/dist/src/duck/gaps.js +94 -0
  67. package/dist/src/duck/jsonb-order.js +175 -0
  68. package/dist/src/duck/jsonb.js +188 -0
  69. package/dist/src/duck/kept-route.js +299 -0
  70. package/dist/src/duck/kept.js +964 -0
  71. package/dist/src/duck/late.js +221 -0
  72. package/dist/src/duck/libc-order.js +190 -0
  73. package/dist/src/duck/pg-eval-worker.js +34 -0
  74. package/dist/src/duck/pg-eval.js +59 -0
  75. package/dist/src/duck/pgtree.js +378 -0
  76. package/dist/src/duck/plan.js +259 -0
  77. package/dist/src/duck/publication.js +207 -0
  78. package/dist/src/duck/requests.js +209 -0
  79. package/dist/src/duck/resolved.js +248 -0
  80. package/dist/src/duck/rewrite.js +5101 -0
  81. package/dist/src/duck/store.js +907 -0
  82. package/dist/src/duck/stream.js +518 -0
  83. package/dist/src/duck/sync.js +2929 -0
  84. package/dist/src/duck/to-char.js +482 -0
  85. package/dist/src/duck/token.js +31 -0
  86. package/dist/src/duck/types.js +427 -0
  87. package/dist/src/duck/users.js +231 -0
  88. package/dist/src/duck/verified.js +1001 -0
  89. package/dist/src/duck/views.js +150 -0
  90. package/dist/src/lib/database-url.js +74 -0
  91. package/dist/src/lib/did-you-mean.js +43 -0
  92. package/dist/src/lib/dotenv.js +72 -0
  93. package/dist/src/lib/exit-when-written.js +20 -0
  94. package/dist/src/lib/float-equal.js +69 -0
  95. package/dist/src/lib/http-json.js +22 -0
  96. package/dist/src/lib/log.js +161 -0
  97. package/dist/src/lib/pg-numeric.js +489 -0
  98. package/dist/src/lib/port.js +22 -0
  99. package/dist/src/lib/project-file.js +49 -0
  100. package/dist/src/lib/project-files.js +43 -0
  101. package/dist/src/lib/project-version.js +76 -0
  102. package/dist/src/lib/runnable.js +14 -0
  103. package/dist/src/lib/shell-word.js +4 -0
  104. package/dist/src/lib/timezone.js +237 -0
  105. package/dist/src/lib/zone-names.js +114 -0
  106. package/dist/src/web/app.css +76 -0
  107. package/dist/src/web/app.js +491 -0
  108. package/dist/src/web/index.html +44 -0
  109. package/package.json +22 -4
@@ -0,0 +1,107 @@
1
+ /**
2
+ * THE `pgoutput` DECODER (protocol version 1): Postgres's built-in logical decoding output, the messages
3
+ * inside the replication stream's XLogData. Pure — a Buffer in, a message out — so every message shape is
4
+ * pinned by tests/cdc-pgoutput.test.ts against bytes built by hand from the protocol reference (Postgres's own documentation):
5
+ * https://www.postgresql.org/docs/current/protocol-logicalrep-message-formats.html
6
+ *
7
+ * Version 1 is enough: no streamed in-progress transactions (the server hands a transaction over only at
8
+ * its commit, which is what makes "acknowledge only complete transactions" simple), no two-phase commit.
9
+ */
10
+ class Cursor {
11
+ b;
12
+ i = 0;
13
+ constructor(b) { this.b = b; }
14
+ byte() { return String.fromCharCode(this.b[this.i++]); }
15
+ int8() { return this.b.readInt8(this.i++); }
16
+ int16() { const v = this.b.readInt16BE(this.i); this.i += 2; return v; }
17
+ int32() { const v = this.b.readInt32BE(this.i); this.i += 4; return v; }
18
+ uint32() { const v = this.b.readUInt32BE(this.i); this.i += 4; return v; }
19
+ int64() { const v = this.b.readBigUInt64BE(this.i); this.i += 8; return v; }
20
+ cstr() {
21
+ const end = this.b.indexOf(0, this.i);
22
+ if (end < 0)
23
+ throw new Error("pgoutput: unterminated string");
24
+ const s = this.b.toString("utf8", this.i, end);
25
+ this.i = end + 1;
26
+ return s;
27
+ }
28
+ bytes(n) { const v = this.b.subarray(this.i, this.i + n); this.i += n; return v; }
29
+ }
30
+ function tuple(c) {
31
+ const n = c.int16();
32
+ const out = [];
33
+ for (let k = 0; k < n; k++) {
34
+ const kind = c.byte();
35
+ if (kind === "n")
36
+ out.push({ kind: "null" });
37
+ else if (kind === "u")
38
+ out.push({ kind: "unchanged" });
39
+ else if (kind === "t") {
40
+ const len = c.int32();
41
+ out.push({ kind: "text", text: c.bytes(len).toString("utf8") });
42
+ }
43
+ else if (kind === "b")
44
+ throw new Error("pgoutput: a binary column value arrived, but the reader asks for text");
45
+ else
46
+ throw new Error(`pgoutput: unknown tuple column kind ${JSON.stringify(kind)}`);
47
+ }
48
+ return out;
49
+ }
50
+ export function decode(data) {
51
+ const c = new Cursor(data);
52
+ const tag = c.byte();
53
+ switch (tag) {
54
+ case "B": return { type: "begin", finalLsn: c.int64(), commitTime: c.int64(), xid: c.int32() };
55
+ case "C": {
56
+ c.int8();
57
+ return { type: "commit", commitLsn: c.int64(), endLsn: c.int64(), commitTime: c.int64() };
58
+ }
59
+ case "R": {
60
+ const relid = c.uint32(), namespace = c.cstr(), name = c.cstr(), replicaIdentity = c.byte();
61
+ const n = c.int16();
62
+ const columns = [];
63
+ for (let k = 0; k < n; k++) {
64
+ const flags = c.int8();
65
+ columns.push({ isKey: (flags & 1) === 1, name: c.cstr(), typeOid: c.uint32(), typmod: c.int32() });
66
+ }
67
+ return { type: "relation", relid, namespace, name, replicaIdentity, columns };
68
+ }
69
+ case "I": {
70
+ const relid = c.uint32();
71
+ expect(c.byte(), "N", "insert");
72
+ return { type: "insert", relid, tuple: tuple(c) };
73
+ }
74
+ case "U": {
75
+ const relid = c.uint32();
76
+ let marker = c.byte();
77
+ let oldKey = null;
78
+ if (marker === "K" || marker === "O") {
79
+ oldKey = tuple(c);
80
+ marker = c.byte();
81
+ }
82
+ expect(marker, "N", "update");
83
+ return { type: "update", relid, oldKey, tuple: tuple(c) };
84
+ }
85
+ case "D": {
86
+ const relid = c.uint32();
87
+ const marker = c.byte();
88
+ if (marker !== "K" && marker !== "O")
89
+ throw new Error(`pgoutput: delete carries ${marker}, expected K or O`);
90
+ return { type: "delete", relid, key: tuple(c) };
91
+ }
92
+ case "T": {
93
+ const n = c.int32();
94
+ c.int8(); // options: CASCADE / RESTART IDENTITY
95
+ const relids = [];
96
+ for (let k = 0; k < n; k++)
97
+ relids.push(c.uint32());
98
+ return { type: "truncate", relids };
99
+ }
100
+ // Origin, Type and logical decoding messages carry nothing the reader stores
101
+ default: return { type: "other", tag };
102
+ }
103
+ }
104
+ function expect(got, want, what) {
105
+ if (got !== want)
106
+ throw new Error(`pgoutput: ${what} carries ${JSON.stringify(got)} where ${want} was expected`);
107
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * THE POLL'S SQL (so that development can read a local database without WAL): the
3
+ * xmin poll `mason start` keeps a copy current with by default (src/duck/sync.ts), with plain SELECTs — no
4
+ * `wal_level=logical`, no replication slot, no publication, no REPLICATION attribute, nothing created or changed on
5
+ * the source. Each poll reads, in one snapshot, every row whose `xmin` (the transaction that wrote this version of it)
6
+ * is at or past the WATERMARK — the oldest transaction still running at the last poll (`pg_snapshot_xmin`):
7
+ * everything that committed before it was read by an earlier poll (or the copy); a transaction that was running then
8
+ * has an id at or past it, and so has every subtransaction of it (a savepoint's rows carry the SUBtransaction's id,
9
+ * which `pg_visible_in_snapshot` would misjudge, so the bound is a range, never a visibility test). A copy taken in
10
+ * several source transactions is held to its first snapshot by the complement (`xminBeforeSql`).
11
+ *
12
+ * WHAT IT COSTS THE SOURCE: a poll reads every polled table whole (no index covers `xmin`), so the interval stretches
13
+ * to four times what the last poll took (`nextPollDelayMs`).
14
+ */
15
+ /** The snapshot's `xmin` — the oldest transaction running when it was taken — from its text (`xmin:xmax:xip,…`). */
16
+ export function snapshotXmin(snapshot) {
17
+ const m = /^(\d+):(\d+):/.exec(snapshot);
18
+ if (!m)
19
+ throw new Error(`not a pg_snapshot: ${JSON.stringify(snapshot)}`);
20
+ return BigInt(m[1]);
21
+ }
22
+ /** The poll's snapshot, its xmin and xmax (64-bit transaction ids, Postgres 13+), read as the transaction's first statement. */
23
+ export const SNAPSHOT_SQL = "SELECT pg_current_snapshot()::text, pg_snapshot_xmax(pg_current_snapshot())::text";
24
+ /**
25
+ * A row's `xmin` as a 64-bit transaction id. `xmin` is the 32-bit id; its epoch is the current one, or the one
26
+ * before when its low bits are past the current id's (it was assigned before the counter last wrapped). A row older
27
+ * than one wrap (2^31 transactions) is frozen long since and reads as old either way: it is never at the watermark.
28
+ */
29
+ export function xmin8Sql(currentXid8) {
30
+ const epoch = currentXid8 >> 32n, low = currentXid8 & 0xffffffffn;
31
+ return `((${epoch}::bigint - (xmin::text::bigint > ${low})::int) << 32 | xmin::text::bigint)`;
32
+ }
33
+ /**
34
+ * A ROW WRITTEN BEFORE THE WATERMARK: the complement of `xmin8Sql(…) >= watermark` (the poll's "changed since"), for a
35
+ * copy taken in several source transactions that is held to its FIRST snapshot (src/duck/sync.ts `copy`): every
36
+ * transaction that commits after a snapshot has an id at or past its xmin (it was running then, or began later), and a
37
+ * tuple's xmin never changes, so a read in any later snapshot that keeps only these rows keeps none written after the
38
+ * first — and the catch-up poll from that watermark reads every row this leaves out.
39
+ *
40
+ * Written with `age()`, never `xmin::text::bigint` (measured on PG 18 over 3 M rows: the text casts took a
41
+ * scan from 320 to 1,640 ms of the source's CPU, `age` to 437): `age(x)` is the 32-bit distance from the transaction's
42
+ * stable latest id L (the next id, read once per transaction) to `x`, so for every row within 2^31 transactions of L,
43
+ * `age(xmin) > age(watermark)` is exactly "older than the watermark" — while L is within 2^31 of the watermark, which
44
+ * `xminBeforeHolds` checks. A row whose `age` is negative has a raw xmin more than 2^31 transactions old (frozen: a
45
+ * freeze keeps the raw id since 9.4), which `xmin8Sql` may place at or past a watermark: it is kept here AND read again by
46
+ * the catch-up (written by key, so once), never neither. `age` of a special id (frozen, bootstrap) is INT_MAX: kept.
47
+ * Normal ids never have low bits below 3 (FirstNormalTransactionId), so a watermark's below 3 reads as 3: the same rows.
48
+ */
49
+ export function xminBeforeSql(watermark) {
50
+ const low = watermark & 0xffffffffn;
51
+ return `(age(xmin) > (SELECT age('${low < 3n ? 3n : low}'::xid)) OR age(xmin) < 0)`;
52
+ }
53
+ /** `xminBeforeSql` is exact while the transaction's latest id is within 2^31 of the watermark: held to a margin of half
54
+ * that (a billion transactions), `current` being the reading transaction's snapshot xmax (`SNAPSHOT_SQL`) */
55
+ export function xminBeforeHolds(current, watermark) {
56
+ return current >= watermark && current - watermark < 2n ** 30n;
57
+ }
58
+ /**
59
+ * The wait before the next poll: MASON_POLL_MS, or four times what the last poll took when that is longer, so a
60
+ * source whose tables take a while to read spends at most a fifth of its time on polls. Never sooner than either
61
+ * (the test server: a cap of a minute polled a 22 M-row production table every 74 s for 6.5 hours under
62
+ * MASON_POLL_MS=3600000, and a poll of 20 s every 60 s is a third of the source's time): the database's load decides,
63
+ * and a slow poll's freshness is said by its own timing line.
64
+ */
65
+ export function nextPollDelayMs(pollMs, lastPollMs) {
66
+ return Math.max(pollMs, 4 * lastPollMs);
67
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * THE REPLICATION SLOT, and the names a run replicates through (src/duck/sync.ts, src/cli/replication-mode.ts). The
3
+ * slot is the one object Mason creates on the source (its user needs the REPLICATION attribute); a publication is read,
4
+ * never created there, unless it is Mason's own (src/duck/publication.ts; CLAUDE.md "We are read-only on a customer's
5
+ * Postgres").
6
+ */
7
+ import { PgConnection, parseLsn, pgIdent, pgLiteral } from "./pg-wire.js";
8
+ import { parsePort } from "../lib/port.js";
9
+ /**
10
+ * The publication and slot a project replicates through: `PG_PUBLICATION` / `PG_SLOT_NAME`, else Mason's own
11
+ * `mason_<name>`. A publication of Mason's own name is one Mason may create; any other is the database owner's and is
12
+ * only ever read.
13
+ */
14
+ export function replicationNames(env, deployment) {
15
+ const own = `mason_${deployment.replace(/[^A-Za-z0-9_]/g, "_")}`;
16
+ const publication = env.PG_PUBLICATION || own;
17
+ return { publication, slot: env.PG_SLOT_NAME || own, owned: publication === own };
18
+ }
19
+ /** The connection options from the `PG_*` environment (DATABASE_URL already laid under it). */
20
+ export function pgOptions(env, replication = false) {
21
+ const need = (k) => {
22
+ const v = env[k];
23
+ if (!v)
24
+ throw new Error(`${k} is not set (DATABASE_URL, or the PG_* variables)`);
25
+ return v;
26
+ };
27
+ const port = parsePort("PG_PORT", env.PG_PORT, 5432);
28
+ if (port.problem !== undefined)
29
+ throw new Error(port.problem);
30
+ return {
31
+ host: need("PG_HOST"), port: port.port, database: need("PG_DATABASE"), user: need("PG_USER"),
32
+ password: env.PG_PASSWORD ?? "", sslmode: (env.PG_SSLMODE || "prefer"),
33
+ replication, applicationName: replication ? "mason-cdc" : "mason",
34
+ };
35
+ }
36
+ const NO_SLOT = { exists: false, active: false, activePid: null, plugin: null, database: null, confirmedFlush: null,
37
+ walStatus: null, retainedBytes: null, safeBytes: null };
38
+ export async function readSlot(pg, slot) {
39
+ const r = await pg.query(`SELECT plugin, database, active, active_pid::text, confirmed_flush_lsn::text, wal_status, safe_wal_size::text, ` +
40
+ `pg_wal_lsn_diff((CASE WHEN pg_is_in_recovery() THEN pg_last_wal_replay_lsn() ELSE pg_current_wal_lsn() END), restart_lsn)::text ` +
41
+ `FROM pg_replication_slots WHERE slot_name = ${pgLiteral(slot)}`);
42
+ const row = r.rows[0];
43
+ if (!row)
44
+ return NO_SLOT;
45
+ const big = (v) => (v === null || v === undefined || v === "" ? null : BigInt(v.split(".")[0]));
46
+ return { exists: true, plugin: row[0], database: row[1], active: row[2] === "t", activePid: row[3] ?? null, confirmedFlush: row[4] ?? null,
47
+ walStatus: row[5] ?? null, safeBytes: big(row[6]), retainedBytes: big(row[7]) };
48
+ }
49
+ /** Drop the slot (it must not be active). */
50
+ export async function dropSlot(pg, slot) {
51
+ await pg.query(`SELECT pg_drop_replication_slot(${pgLiteral(slot)})`);
52
+ }
53
+ /** Where the source's WAL is now: the insert position on a primary, the replayed position on a standby (a
54
+ * logical slot on a standby decodes only what it has replayed). */
55
+ export async function currentLsn(pg) {
56
+ const r = await pg.query("SELECT (CASE WHEN pg_is_in_recovery() THEN pg_last_wal_replay_lsn() ELSE pg_current_wal_lsn() END)::text");
57
+ return parseLsn(r.rows[0]?.[0] ?? "");
58
+ }
59
+ /** Create the slot over a replication connection; returns the WAL position it starts at. */
60
+ export async function createSlot(repl, slot) {
61
+ // NOEXPORT_SNAPSHOT: the first copy reads each table by key pages, each its own statement (the stream's
62
+ // versions win wherever they overlap) — no long snapshot to hold open.
63
+ const r = await repl.query(`CREATE_REPLICATION_SLOT ${pgIdent(slot)} LOGICAL pgoutput NOEXPORT_SNAPSHOT`);
64
+ return r.rows[0]?.[1] ?? "";
65
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * `mason start` IN THE BACKGROUND (an interface as good for people as for coding agents).
3
+ * The run is a process of its own (`mason start … --attach`, detached, its terminal the file `dev.log` in the run's
4
+ * folder): this shows the run's start as it happens, and returns once the run says it is ready — or with the run's own
5
+ * exit code when it stops first (a wrong password), its reason already shown. A coding agent's shell can
6
+ * then go on (`mason tables`, `mason add`, `mason wait`), and a person's terminal is theirs again.
7
+ *
8
+ * A second `mason start` for a project whose run is up does not start another: it says so and returns 0, so running it
9
+ * again is always safe.
10
+ *
11
+ * What is detached is the run's SUPERVISOR (src/cli/supervise.ts), which starts the run and starts it again if it dies
12
+ * once it was ready; a start that fails ends the supervisor with the run's own exit code, so this reads it as before.
13
+ */
14
+ import { choosePgPort } from "./ports.js";
15
+ import * as fs from "node:fs";
16
+ import * as path from "node:path";
17
+ import { spawn } from "node:child_process";
18
+ import { fileURLToPath } from "node:url";
19
+ import { runnable } from "../lib/runnable.js";
20
+ import { READY, STEP_MARK } from "./dev.js";
21
+ import { liveRun } from "./runs.js";
22
+ import { projectSource, theProject } from "./project.js";
23
+ import { runListenerSettings } from "./pg-tls.js";
24
+ import { demoChangesRefusal, demoPort } from "./demo.js";
25
+ import { endText, failed, jsonText, nextBlock } from "./command.js";
26
+ import { DEV } from "./registry.js";
27
+ import { out as styleOut, stepRenderer } from "./style.js";
28
+ import { postgresLink, withoutPlaceholders } from "./pg-url.js";
29
+ const say = (m) => { process.stdout.write(`${m}\n`); };
30
+ const MAIN = path.join(path.dirname(fileURLToPath(import.meta.url)), "main.ts");
31
+ const SUPERVISE = path.join(path.dirname(fileURLToPath(import.meta.url)), "supervise.ts");
32
+ /** The commands to run next, as `mason start` prints them once the run is up: one a line, what each does beside it
33
+ * (not `mason status | mason logs | mason stop` on one line). */
34
+ export const NEXT_STEPS = [
35
+ ["mason tables", "the database's tables, and what you can add"],
36
+ ["mason add <table>", "copy a table into Mason"],
37
+ ["mason user add <name>", "a user for a BI tool or your app: its password and URL, printed once"],
38
+ ["mason status", "what Mason is doing"],
39
+ ["mason logs", "its log: every copy, poll and statement"],
40
+ ["mason stop", "stop Mason; the next mason start resumes where it stopped"],
41
+ ];
42
+ /** The ports a demo project's database takes (its URL's port, read as the run reads it), for the API's choice to avoid;
43
+ * none for any other project. A sources file or a URL that cannot be read gives none HERE: the run refuses it in its
44
+ * own words (its source step, its demo step), shown by this start as every other refusal of the run's is. */
45
+ function demoPortsOf(project) {
46
+ if (project.config.demo !== true)
47
+ return new Set();
48
+ const src = projectSource(project, process.env);
49
+ if ("message" in src)
50
+ return new Set();
51
+ const at = demoPort(src.resolved, src.from);
52
+ return new Set("port" in at ? [at.port] : []);
53
+ }
54
+ /** `args` are `mason start`'s own, passed on to the run with --attach. */
55
+ export async function devInBackground(args, o) {
56
+ const t = theProject(process.cwd());
57
+ if ("problem" in t)
58
+ return failed("start", t, { json: o.json });
59
+ const home = t.project.dir;
60
+ const other = await liveRun(t.project);
61
+ if (other !== null) {
62
+ // running already is what start wants: said, exit 0, the same answer as a start (what is up, and what to run)
63
+ if (o.json) {
64
+ say(jsonText(DEV, { running: true, pid: other.pid, postgres: postgresLink(other.postgresUrl) }, [["mason status", "what it is doing"], ["mason stop", "stop it"]]));
65
+ return 0;
66
+ }
67
+ say(`Mason is already running for ${t.project.config.name} (pid ${other.pid}).`);
68
+ say(nextBlock([["mason status", "what it is doing"], ["mason stop", "stop it"]]));
69
+ return 0;
70
+ }
71
+ // a demo project with --replication (src/cli/demo.ts): refused here too, where it is read, before a run is spawned
72
+ const demoRefused = demoChangesRefusal(t.project.config, o.changes ?? null);
73
+ if (demoRefused)
74
+ return failed("start", demoRefused, { json: o.json });
75
+ // a MASON_PG_PORT that is no port, or is held, is refused HERE, where the person (or --json) reads it, before a run is
76
+ // spawned; the run checks again for itself (choosePgPort, src/cli/ports.ts), since a port may be taken in between.
77
+ // A demo project's database port (its URL's, src/cli/demo.ts demoPort) is avoided as the run avoids it
78
+ const pg = await choosePgPort(process.env.MASON_PG_PORT, demoPortsOf(t.project), t.project.config.pgPort ?? null);
79
+ if ("problem" in pg)
80
+ return failed("start", pg, { json: o.json });
81
+ // and so is a listener off loopback without TLS, by the run's own check (src/cli/pg-tls.ts): read, never written
82
+ const listen = runListenerSettings(process.env, { port: pg.port, selfSigned: o.selfSignedTls, home });
83
+ if ("problem" in listen)
84
+ return failed("start", listen, { json: o.json });
85
+ fs.mkdirSync(home, { recursive: true, mode: 0o700 });
86
+ // the run's terminal: its setup URL carries the run's token and it prints the API key, so only the developer reads it
87
+ const logFile = path.join(home, "dev.log");
88
+ const fd = fs.openSync(logFile, "a", 0o600);
89
+ fs.chmodSync(logFile, 0o600);
90
+ const from = fs.fstatSync(fd).size;
91
+ fs.writeSync(fd, `\n--- mason start ${new Date().toISOString()} ---\n`);
92
+ // the run is started IN the project's root, so it finds the same project
93
+ const run = [process.execPath, runnable(MAIN), "start", ...args.filter((a) => a !== "--json"), "--attach"];
94
+ const child = spawn(process.execPath, [runnable(SUPERVISE), home, logFile, ...run], { detached: true, stdio: ["ignore", fd, fd], cwd: t.project.root, env: process.env });
95
+ fs.closeSync(fd);
96
+ let exited = null;
97
+ child.on("exit", (code, signal) => { exited = code ?? (signal ? 1 : 0); });
98
+ // Ctrl-C while it starts stops the start too: nothing is left running that nobody asked for
99
+ const abort = () => { child.kill("SIGTERM"); };
100
+ process.on("SIGINT", abort);
101
+ // what it does, as a checklist (as `mason init` draws its own): the run's steps, drawn as they happen; under
102
+ // --json nothing until it is up (one document), the lines after the last step kept as a failure's why
103
+ const st = styleOut();
104
+ const line = (x) => { if (!o.json)
105
+ say(x); };
106
+ line(st.bold(`Starting Mason for ${t.project.config.name}`));
107
+ const steps = stepRenderer({ line, live: !o.json && process.stdout.isTTY ? { write: (x) => { process.stdout.write(x); } } : null, style: st });
108
+ let begun = false;
109
+ let offset = from;
110
+ let partial = "";
111
+ let postgres = null;
112
+ const since = []; // the run's lines after its last step: a failure's reasons
113
+ try {
114
+ for (;;) {
115
+ const size = fs.statSync(logFile).size;
116
+ if (size > offset) {
117
+ const buf = Buffer.alloc(size - offset);
118
+ const r = fs.openSync(logFile, "r");
119
+ try {
120
+ fs.readSync(r, buf, 0, buf.length, offset);
121
+ }
122
+ finally {
123
+ fs.closeSync(r);
124
+ }
125
+ offset = size;
126
+ const lines = (partial + buf.toString()).split("\n");
127
+ partial = lines.pop() ?? "";
128
+ for (const l of lines) {
129
+ if (l.startsWith("--- mason start "))
130
+ continue;
131
+ if (l === "" && steps.open() === null && !begun)
132
+ continue; // the log's spacing before the first step
133
+ begun = true;
134
+ if (l === READY) {
135
+ child.unref();
136
+ const next = NEXT_STEPS;
137
+ // the run's pid, as it recorded it (the child is its supervisor), the one mason stop signals
138
+ const pid = JSON.parse(fs.readFileSync(path.join(home, "config.json"), "utf8")).running?.pid ?? null;
139
+ if (o.json)
140
+ say(jsonText(DEV, { running: true, pid, postgres }, next));
141
+ else
142
+ say(endText({ next }, st));
143
+ return 0;
144
+ }
145
+ // the run's steps (src/cli/dev.ts STEP_MARK) as a checklist; any other line as it came
146
+ if (l.startsWith(STEP_MARK.begin)) {
147
+ since.length = 0;
148
+ steps.begin(l.slice(STEP_MARK.begin.length));
149
+ continue;
150
+ }
151
+ if (l.startsWith(STEP_MARK.done)) {
152
+ since.length = 0;
153
+ steps.done(l.slice(STEP_MARK.done.length));
154
+ continue;
155
+ }
156
+ if (l.startsWith(STEP_MARK.fail)) {
157
+ since.length = 0;
158
+ steps.fail(l.slice(STEP_MARK.fail.length));
159
+ continue;
160
+ }
161
+ // the run's line (src/cli/dev.ts: "Postgres <url>", the URL in its shown form, `withPlaceholders`: postgres://<user>:<password>@…)
162
+ const pgLine = /^\s+Postgres\s+(postgres:\/\/<user>:<password>@\S+)/.exec(l);
163
+ if (pgLine) {
164
+ postgres = postgresLink(withoutPlaceholders(pgLine[1]));
165
+ steps.print(` ${st.dim("Postgres API")} ${pgLine[1]} ${st.dim("(mason user add <name> makes a user)")}`);
166
+ continue;
167
+ }
168
+ if (/^\s+Web UI\s+mason ui\b/.test(l)) {
169
+ steps.print(` ${st.dim("Web UI".padEnd("Postgres API".length))} ${st.cyan("mason ui")} ${st.dim("(the status page and the users, in your browser)")}`);
170
+ continue;
171
+ }
172
+ // a demo project's line (src/cli/dev.ts: "Demo a new order lands every <n> s: …"), in the same column
173
+ const demoLine = /^\s+Demo\s{2}(.+)$/.exec(l);
174
+ if (demoLine) {
175
+ steps.print(` ${st.dim("Demo".padEnd("Postgres API".length))} ${demoLine[1]}`);
176
+ continue;
177
+ }
178
+ if (l.trim())
179
+ since.push(l.trim().replace(/^mason start: /, ""));
180
+ steps.print(l);
181
+ }
182
+ }
183
+ if (exited !== null) {
184
+ if (steps.open() !== null)
185
+ steps.fail();
186
+ if (partial)
187
+ since.push(partial.trim());
188
+ // the run's own last words are the reason (a wrong password), already shown line by line to a person
189
+ return failed("start", { message: `the run stopped before it was ready (exit ${exited})`, code: "failed", why: o.json && since.length ? since.join("\n") : undefined,
190
+ fix: `the whole log: ${logFile}`, exit: exited === 0 ? 1 : exited }, { json: o.json });
191
+ }
192
+ await new Promise((r) => setTimeout(r, 150));
193
+ }
194
+ }
195
+ finally {
196
+ process.off("SIGINT", abort);
197
+ }
198
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * A `mason` COMMAND, DESCRIBED ONCE (every command, present and future, is and stays self-documenting, and that is
3
+ * checked). Each command is a `CommandSpec`: its name, the one line `mason --help` lists it with, its usage
4
+ * (what `mason <name> --help` prints), and the options its parser accepts — the SAME object the parser reads, so an
5
+ * option cannot exist without its spec. src/cli/main.ts builds the command table and the top-level help from the
6
+ * specs, and tests/cli-help.test.ts holds every one to the rules below, a command added later included:
7
+ *
8
+ * - `mason <name> --help` and `mason help <name>` print its usage and exit 0;
9
+ * - the usage starts `usage: mason <name>`, and shows at least one example (an indented `mason <name> …` line);
10
+ * - every option the parser takes is in the usage, by its `--name` (and `-x` when it has one);
11
+ * - `mason --help` lists it with its summary;
12
+ * - a wrong option prints what is wrong, then the usage, and exits 2.
13
+ */
14
+ import { parseArgs } from "node:util";
15
+ import { err as styleErr, out as styleOut, PLAIN, wrap } from "./style.js";
16
+ const HELP = { help: { type: "boolean", short: "h" } };
17
+ /**
18
+ * A command line read by its spec: the values and positionals; or the exit code when it answered itself — 0 after
19
+ * printing the usage for `--help`, 2 after saying what is wrong with the line, then the usage.
20
+ */
21
+ export function parseCommand(spec, argv) {
22
+ let parsed;
23
+ try {
24
+ parsed = parseArgs({ args: argv, options: { ...spec.options, ...HELP }, allowPositionals: true });
25
+ }
26
+ catch (e) {
27
+ return failed(spec.name, { message: e.message, code: "usage", usage: spec.usage, next: [[`mason ${spec.name} --help`, "its usage, with every option"]] }, { json: wantsJson(argv, spec) });
28
+ }
29
+ if (parsed.values.help) {
30
+ process.stdout.write(spec.usage);
31
+ return 0;
32
+ }
33
+ return { values: parsed.values, positionals: parsed.positionals };
34
+ }
35
+ /** The column a step's `what` starts in, after the two-space indent: wide enough for `mason add orders --columns …`. */
36
+ const NEXT_WIDTH = 44;
37
+ /**
38
+ * WHAT TO RUN NEXT, in one shape for every command (some commands showed Next as one line, some without the Next
39
+ * prefix and some as a kind of table; the table won): a blank line, `Next:`, then each command with what
40
+ * it does in a column, after everything else a command prints — a failure's way out included (failureText). With --json the
41
+ * same steps are the answer's `next` (nextJson). tests/conventions.test.ts refuses a `Next` or `Try` written any other way.
42
+ */
43
+ export function nextBlock(steps, s = styleOut()) {
44
+ // on a terminal: `Next:` bold, each command cyan, what it does dim and wrapped under its own column (src/cli/style.ts)
45
+ const step = ([command, what]) => {
46
+ if (!what)
47
+ return ` ${s.cyan(command)}`;
48
+ const used = 2 + Math.max(command.length, NEXT_WIDTH) + 2;
49
+ return ` ${s.cyan(command.padEnd(NEXT_WIDTH))} ${s.dim(wrap(what, s.width, 2 + NEXT_WIDTH + 2, used))}`;
50
+ };
51
+ return ["", s.bold("Next:"), ...steps.map(step)].join("\n");
52
+ }
53
+ export function reportText(r, s = styleOut()) {
54
+ const lines = [s.bold(r.headline)];
55
+ const w = Math.max(0, ...(r.details ?? []).map(([label]) => label.length));
56
+ for (const [label, value] of r.details ?? [])
57
+ lines.push(` ${s.dim(label.padEnd(w))} ${wrap(value, s.width, 2 + w + 3)}`);
58
+ return withEnd(lines.join("\n"), r, s);
59
+ }
60
+ /**
61
+ * The end of a command's output, to print on its own (out(endText(…))): its tips, then what to run next, each part after
62
+ * a blank line; "" when it has neither. withEnd puts it after text a command has not printed yet.
63
+ */
64
+ export function endText(r, s = styleOut()) {
65
+ const tips = r.tips?.length ? `\n${r.tips.map((t) => s.dim(`Tip: ${wrap(t, s.width, 5)}`)).join("\n")}` : "";
66
+ const next = r.next?.length ? nextBlock(r.next, s) : "";
67
+ return tips && next ? `${tips}\n${next}` : tips || next;
68
+ }
69
+ export function withEnd(text, r, s = styleOut()) {
70
+ const end = endText(r, s);
71
+ return end ? `${text}\n${end}` : text;
72
+ }
73
+ export const STDIO = { out: (m) => { process.stdout.write(`${m}\n`); }, err: (m) => { process.stderr.write(`${m}\n`); } };
74
+ /** The steps as a program reads them: `[{ command, what }]`, the same list the human output prints under Next. */
75
+ export function nextJson(steps = []) {
76
+ return steps.map(([command, what]) => what ? { command, what } : { command });
77
+ }
78
+ /** Whether a command line asked for data: `--json` anywhere on it (read before the line is parsed, so a wrong line
79
+ * is answered in JSON too) — never for a command whose stdout is a protocol's (`CommandSpec.stdout`), whose every
80
+ * failure is said on stderr. */
81
+ export const wantsJson = (argv, spec) => spec?.stdout !== "protocol" && argv.includes("--json");
82
+ /**
83
+ * A COMMAND'S --json ANSWER (an agent wants clear, unambiguous context and a machine-readable format, and a person
84
+ * and an agent both want the next steps): one JSON object on stdout, always with `next` — the same steps the human
85
+ * output prints — and only the keys the command's spec declares (`CommandSpec.json`), so the help's shape and the answer
86
+ * cannot drift: a key the spec does not name is a bug here, said as one. A list is wrapped in an object for the same
87
+ * reason (`{ tables: […], next }`), never printed bare.
88
+ */
89
+ export function jsonText(spec, value, next = []) {
90
+ const declared = spec.json;
91
+ if (declared === undefined)
92
+ throw new Error(`mason ${spec.name} prints JSON, and its spec declares no json keys (src/cli/command.ts CommandSpec.json)`);
93
+ const stray = Object.keys(value).filter((k) => !declared.includes(k));
94
+ if (stray.length)
95
+ throw new Error(`mason ${spec.name} --json: ${stray.join(", ")} ${stray.length === 1 ? "is" : "are"} not in its spec's json keys (${declared.join(", ")}): add ${stray.length === 1 ? "it" : "them"} there and to its usage`);
96
+ if (!declared.includes("next"))
97
+ throw new Error(`mason ${spec.name}: its json keys must end with next (every --json answer carries what to run next)`);
98
+ return JSON.stringify({ ...value, next: nextJson(next) }, null, 2);
99
+ }
100
+ /**
101
+ * THE CODES A FAILURE CARRIES, a closed set a program can branch on (`mason help agent` lists them).
102
+ */
103
+ export const FAILURE_CODES = {
104
+ usage: "the command line is wrong: a flag, an argument, a value (exit 2; the usage follows for a person)",
105
+ no_project: "no Mason project here or above (mason init makes one)",
106
+ bad_config: "this folder is a Mason project whose .mason/config.json cannot be read: repair it, or move .mason/ aside and mason init",
107
+ bad_source: "the project's sources file (mason/sources/<name>.yml) is missing or names something wrong: the file, the key and the way out are said",
108
+ not_running: "Mason is not running for the project (mason start)",
109
+ unreachable: "Mason is running but does not answer (mason logs says why)",
110
+ not_found: "a name nothing has: a table, a column, a user",
111
+ nothing_yet: "nothing to work on yet: no table added (the next steps say what adds one)",
112
+ refused: "Mason refused what was asked, with the reason",
113
+ failed: "the command tried and could not finish: the reason, and what to do",
114
+ timeout: "ran out of time — mason wait; nothing stopped (exit 124)",
115
+ };
116
+ /** A failure thrown from deep in a command, for main.ts to print: the one way a failure leaves a command without a return code. */
117
+ export class CommandFailed extends Error {
118
+ failure;
119
+ constructor(f) { const n = asFailure(f); super(n.message); this.failure = n; }
120
+ }
121
+ export const asFailure = (f) => "problem" in f ? { ...f, message: f.problem } : f;
122
+ /** The failure as a person reads it (above): a red ✘ (wherever a step fails, as a green tick shows wherever one
123
+ * succeeds — the checklist's own mark, ✗ where nothing is styled), the command's name and the
124
+ * message in bold, then why and fix as rows, then Next, then a wrong line's usage. */
125
+ export function failureText(cmd, f, s = styleErr()) {
126
+ const head = cmd ? `mason ${cmd}:` : "mason:";
127
+ // the checklist's marks: ✘ on a terminal (beside its ✔), ✗ in plain text (beside its ✓); a style that colours is one
128
+ const mark = s.red("x") === "x" ? "✗" : "✘";
129
+ const lines = [`${s.red(mark)} ${s.red(head)} ${s.bold(wrap(f.message, s.width, 2, mark.length + 1 + head.length + 1))}`];
130
+ const rows = [...(f.why ? [["why", f.why]] : []), ...(f.fix ? [["fix", f.fix]] : [])];
131
+ // a value of several lines (a list of failures, an entry to paste) keeps its lines, each under the first
132
+ for (const [label, value] of rows)
133
+ lines.push(` ${s.dim(label)} ${value.split("\n").map((l, i) => i ? `${" ".repeat(8)}${wrap(l, s.width, 8)}` : wrap(l, s.width, 8)).join("\n")}`);
134
+ let text = lines.join("\n");
135
+ if (f.next?.length)
136
+ text += `\n${nextBlock(f.next, s)}`;
137
+ if (f.usage)
138
+ text += `\n\n${f.usage.replace(/\n$/, "")}`;
139
+ return text;
140
+ }
141
+ /** A failure's `error` as --json carries it: on its own (failureJson), or beside a command's own keys when it did part of
142
+ * its work (mason add: the tables added; mason wait: the tables' state). */
143
+ export function errorJson(f) {
144
+ return { code: f.code, message: f.message, ...(f.why ? { why: f.why } : {}), ...(f.fix ? { fix: f.fix } : {}) };
145
+ }
146
+ /** The failure as a program reads it: `{"error": {code, message, why?, fix?}, "next": […]}`. */
147
+ export function failureJson(f) {
148
+ return JSON.stringify({ error: errorJson(f), next: nextJson(f.next) }, null, 2);
149
+ }
150
+ /**
151
+ * SAY A FAILURE AND RETURN ITS EXIT CODE — the one way a command reports that it could not do what was asked: for a
152
+ * person on stderr (failureText), under --json as one JSON document on stdout (failureJson), so a program that asked for
153
+ * data never has to read prose. tests/conventions.test.ts refuses a `mason <cmd>: …` written to stderr any other way.
154
+ */
155
+ export function failed(cmd, f, o) {
156
+ const n = asFailure(f);
157
+ const io = o.io ?? STDIO;
158
+ if (o.json)
159
+ io.out(failureJson(n));
160
+ else
161
+ io.err(failureText(cmd, n, o.style ?? failureStyle(io)));
162
+ return exitOf(n);
163
+ }
164
+ /** The style a failure is drawn in: stderr's own when it goes to stderr, plain for an io a test or a caller supplied.
165
+ * By the STREAM, never by "was an io passed" (CLAUDE.md "Two readers, one interface"): mason init defaults its io to
166
+ * STDIO and passed it on, and every failure it drew was plain — no red head, no dim labels — on a terminal. */
167
+ export const failureStyle = (io) => io === STDIO ? styleErr() : PLAIN;
168
+ /** A failure's exit code: its code's (2 a wrong command line; 124 anything that ran out of time, a wait or a query Mason
169
+ * did not answer in time; else 1), unless it names one. The one place it is decided, so the same refusal exits the
170
+ * same wherever it is found. */
171
+ export function exitOf(f) {
172
+ return f.exit ?? (f.code === "usage" ? 2 : f.code === "timeout" ? 124 : 1);
173
+ }