@mason-data/cli 0.8.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 (118) hide show
  1. package/README.md +19 -8
  2. package/dist/src/api/error.js +4 -0
  3. package/dist/src/api/postgres/catalog-worker.js +49 -2
  4. package/dist/src/api/postgres/catalog.js +166 -32
  5. package/dist/src/api/postgres/listener.js +11 -3
  6. package/dist/src/api/postgres/proxy.js +129 -0
  7. package/dist/src/api/postgres/schema.js +6 -0
  8. package/dist/src/api/postgres/server.js +527 -103
  9. package/dist/src/api/postgres/translate.js +47 -2
  10. package/dist/src/cli/background.js +18 -19
  11. package/dist/src/cli/bridge-stdin.js +101 -0
  12. package/dist/src/cli/command.js +337 -15
  13. package/dist/src/cli/commands.js +458 -169
  14. package/dist/src/cli/credentials.js +182 -0
  15. package/dist/src/cli/demo.js +2 -2
  16. package/dist/src/cli/deploy.js +680 -0
  17. package/dist/src/cli/dev.js +148 -27
  18. package/dist/src/cli/doctor.js +122 -36
  19. package/dist/src/cli/generated-files.js +143 -0
  20. package/dist/src/cli/guides.js +116 -16
  21. package/dist/src/cli/hosted.js +850 -0
  22. package/dist/src/cli/init.js +154 -33
  23. package/dist/src/cli/login.js +292 -0
  24. package/dist/src/cli/machine.js +125 -40
  25. package/dist/src/cli/main.js +59 -10
  26. package/dist/src/cli/mason.cjs +17 -0
  27. package/dist/src/cli/mcp.js +17 -14
  28. package/dist/src/cli/output-gate.js +176 -0
  29. package/dist/src/cli/page-share.js +96 -0
  30. package/dist/src/cli/pg-tls.js +1 -1
  31. package/dist/src/cli/pin.js +6 -2
  32. package/dist/src/cli/pool-size.cjs +85 -0
  33. package/dist/src/cli/ports.js +2 -0
  34. package/dist/src/cli/preflight.js +130 -39
  35. package/dist/src/cli/project.js +100 -1
  36. package/dist/src/cli/readiness.js +42 -6
  37. package/dist/src/cli/registry.js +89 -36
  38. package/dist/src/cli/replica-hosted.js +1324 -0
  39. package/dist/src/cli/replica.js +114 -0
  40. package/dist/src/cli/replication-mode.js +5 -0
  41. package/dist/src/cli/request-filter.js +16 -6
  42. package/dist/src/cli/roles.js +2 -1
  43. package/dist/src/cli/run-server.js +179 -9
  44. package/dist/src/cli/secret-input.js +193 -0
  45. package/dist/src/cli/security.js +57 -10
  46. package/dist/src/cli/serve.js +65 -9
  47. package/dist/src/cli/share-run.js +51 -8
  48. package/dist/src/cli/share.js +23 -18
  49. package/dist/src/cli/source-edit.js +10 -4
  50. package/dist/src/cli/source-role.js +343 -0
  51. package/dist/src/cli/sources.js +50 -23
  52. package/dist/src/cli/style.js +16 -1
  53. package/dist/src/cli/supervise.js +15 -23
  54. package/dist/src/cli/sync.js +17 -8
  55. package/dist/src/cli/token-hosted.js +343 -0
  56. package/dist/src/cli/tunnel.js +1 -1
  57. package/dist/src/cli/ui-hosted.js +163 -0
  58. package/dist/src/cli/ui-ssh.js +460 -0
  59. package/dist/src/cli/ui.js +162 -17
  60. package/dist/src/cli/usage.js +19 -15
  61. package/dist/src/cli/user-hosted.js +364 -0
  62. package/dist/src/cli/user.js +275 -105
  63. package/dist/src/demo/seed.js +26 -0
  64. package/dist/src/duck/analyze.js +36 -20
  65. package/dist/src/duck/collation.js +266 -0
  66. package/dist/src/duck/constant-key.js +94 -0
  67. package/dist/src/duck/distinct-key.js +283 -26
  68. package/dist/src/duck/events.js +39 -8
  69. package/dist/src/duck/executor-worker.js +2 -2
  70. package/dist/src/duck/executor.js +6 -5
  71. package/dist/src/duck/explain.js +4 -0
  72. package/dist/src/duck/functions.js +448 -49
  73. package/dist/src/duck/gaps.js +34 -16
  74. package/dist/src/duck/generated-plan.js +1070 -0
  75. package/dist/src/duck/generated.js +906 -0
  76. package/dist/src/duck/grants.js +1 -1
  77. package/dist/src/duck/held-types.js +64 -0
  78. package/dist/src/duck/history.js +4 -0
  79. package/dist/src/duck/jsonb-order.js +9 -12
  80. package/dist/src/duck/kept-route.js +6 -5
  81. package/dist/src/duck/kept.js +9 -7
  82. package/dist/src/duck/late.js +1469 -40
  83. package/dist/src/duck/libc-bound.js +341 -0
  84. package/dist/src/duck/libc-order.js +892 -126
  85. package/dist/src/duck/pg-eval-worker.js +7 -1
  86. package/dist/src/duck/pg-eval.js +6 -4
  87. package/dist/src/duck/pgtree.js +774 -23
  88. package/dist/src/duck/plan.js +275 -30
  89. package/dist/src/duck/publication.js +14 -0
  90. package/dist/src/duck/requests.js +9 -5
  91. package/dist/src/duck/resolved.js +117 -2
  92. package/dist/src/duck/rewrite.js +4567 -583
  93. package/dist/src/duck/row-filter.js +12 -6
  94. package/dist/src/duck/statement.js +19 -0
  95. package/dist/src/duck/store.js +294 -68
  96. package/dist/src/duck/stream.js +9 -5
  97. package/dist/src/duck/sync.js +602 -126
  98. package/dist/src/duck/token.js +4 -1
  99. package/dist/src/duck/types.js +63 -7
  100. package/dist/src/duck/usage.js +6 -4
  101. package/dist/src/duck/users.js +110 -9
  102. package/dist/src/duck/verified.js +159 -2
  103. package/dist/src/duck/views.js +4 -3
  104. package/dist/src/lib/cidr.js +177 -0
  105. package/dist/src/lib/database-url.js +55 -10
  106. package/dist/src/lib/instant.js +50 -0
  107. package/dist/src/lib/log-buffer.js +13 -4
  108. package/dist/src/lib/log-read.js +196 -0
  109. package/dist/src/lib/pg-numeric.js +387 -49
  110. package/dist/src/lib/secret-mask.js +142 -0
  111. package/dist/src/lib/table-sync.js +84 -19
  112. package/dist/src/web/app.js +9 -4
  113. package/dist/src/web/pages/status.js +178 -13
  114. package/dist/src/web/pages/syncs.js +1 -1
  115. package/dist/src/web/pages/tables.js +8 -3
  116. package/dist/src/web/pages/users.js +4 -3
  117. package/dist/src/web/shared.js +19 -3
  118. package/package.json +8 -7
@@ -9,37 +9,44 @@ import * as fs from "node:fs";
9
9
  import * as path from "node:path";
10
10
  import { STDIO, failed, jsonText, parseCommand, reportText, withEnd } from "./command.js";
11
11
  import { alive, END_GRACE_MS, runState } from "./runs.js";
12
- import { stopSupervisor } from "./supervise.js";
12
+ import { stopSupervisor, supervisorOf } from "./supervise.js";
13
13
  import { STATE_DIR, isProject, projectSource, readProject, theProject, writeConfig } from "./project.js";
14
14
  import { parseSource } from "./preflight.js";
15
15
  import { USERS_FILE } from "../duck/users.js";
16
16
  import { SELF_SIGNED_FILES } from "./pg-tls.js";
17
17
  import { dropOwnReplication, dropStatements, madeReplication } from "./replication-mode.js";
18
18
  import { sourceFileNames } from "./sources.js";
19
- export const DESTROY_USAGE = `usage: mason destroy [--json]
19
+ export const DESTROY_USAGE = `usage: mason destroy [--dry-run] [--json]
20
20
 
21
21
  Removes this folder's project's synced data from this machine — its file — and Mason's state in its
22
22
  .mason/ (the logs, the request log). Mason is stopped first if it runs. Your files (mason/sources/), .mason/config.json and
23
23
  the users (mason user) stay: the next mason start syncs the tables again, and every tool connects as before.
24
24
 
25
25
  mason destroy this folder's project
26
+ mason destroy --dry-run what it would remove, changing nothing
27
+
28
+ --dry-run list what would go — Mason stopped, the replication slot and Mason's own publication on the database, the
29
+ synced data and state in .mason/ — and change nothing: it stops nothing and never connects to the
30
+ database (the slot and publication are named from what the start that made them recorded)
31
+ --json as data: { dryRun, destroyed: { project, removedItems: [what, in words], replication (null: nothing on
32
+ the database; else { database, slot, publication (null: one the operator named, never dropped) }), error },
33
+ removed (the same as [{ name, removed: [what], error }], kept until 1.0; not on a dry run), next }
26
34
 
27
35
  mason stop keeps the synced data and the state, and the next mason start resumes in seconds; mason destroy frees the disk, and
28
36
  the next mason start begins again. In replication mode the slot (and Mason's own publication) are dropped from the
29
37
  database too: they hold its WAL for as long as they exist. A demo project's database (mason init demo, in .mason/demo/)
30
38
  goes with the synced data: the next mason start makes and seeds it again.
31
39
 
32
- --json as data: { removed: [{ name, removed: [what], error }], next }
33
-
34
- Exit status: 0 removed (or nothing to remove); 1 something could not be removed (said); 2 a wrong command line.
40
+ Exit status: 0 removed (or nothing to remove, or a dry run that found nothing in the way); 1 something could not be
41
+ removed, or on a dry run would not be (said); 2 a wrong command line.
35
42
  `;
36
43
  export const DESTROY = {
37
44
  needs: "project",
38
45
  name: "destroy",
39
46
  summary: "remove this project's synced data and Mason's state from this machine (your files and users stay)",
40
47
  usage: DESTROY_USAGE,
41
- options: { json: { type: "boolean" } },
42
- json: ["removed", "next"],
48
+ options: { json: { type: "boolean" }, "dry-run": { type: "boolean" } },
49
+ json: ["dryRun", "destroyed", "removed", "next"],
43
50
  };
44
51
  /** Wait for `pid` to exit after SIGTERM; throws when it does not within 90 s. */
45
52
  async function stopPid(pid, what, isAlive, kill = (p) => process.kill(p, "SIGTERM")) {
@@ -75,39 +82,20 @@ export async function removeProject(p, o = {}) {
75
82
  // the run that made them (an older start recorded its mode, `changes`): the slot holds its WAL for as long as it
76
83
  // exists, so it goes with the copy — and when it cannot, the rest goes and the failure says how
77
84
  let replication = null;
78
- let objects = "publication and replication slot";
79
- if ((p.config.replication ?? null) !== null || p.config.changes === "replication") {
80
- // the slot, and the publication only when it is Mason's own: one the operator named is its owner's, left as it is
81
- const src = projectSource(p);
82
- // what the start that made them recorded (madeReplication); a project with no record names them by its one sources file
83
- const own = madeReplication(p.config, "message" in src ? (sourceFileNames(p.folder)[0] ?? "app.yml").replace(/\.ya?ml$/, "") : src.name);
84
- if (!own.owned)
85
- objects = "replication slot";
86
- const parsed = "message" in src ? { problem: `the database's URL cannot be read: ${src.message}` } : parseSource(src.resolved);
85
+ const target = replicationTarget(p);
86
+ if (target !== null) {
87
87
  try {
88
- if ("problem" in parsed)
89
- throw new Error(parsed.problem);
90
- // the slot is on the database the copy came from: a url: edited since names another, where dropping finds nothing
91
- // and the real slot goes on holding its WAL (the review of the sources file)
92
- const from = p.config.sourceServer ?? null;
93
- if (from !== null && from.database !== parsed.source.database) {
94
- throw new Error(`the sources file names ${parsed.source.database} now, and the synced data came from ${from.database}, where the slot is: ` +
95
- `put that database's URL back in the sources file and run mason destroy again, or drop them on ${from.database} by hand`);
96
- }
97
- for (const d of await (o.dropReplication ?? dropOwnReplication)(parsed.source, own, from))
98
- removed.push(`${d} from ${parsed.source.database}`);
88
+ if ("problem" in target.on)
89
+ throw new Error(target.on.problem);
90
+ const source = target.on.source;
91
+ for (const d of await (o.dropReplication ?? dropOwnReplication)(source, target.own, target.from))
92
+ removed.push(`${d} from ${source.database}`);
99
93
  }
100
94
  catch (e) {
101
- replication = `${e.message}. They hold the database's WAL while they exist: drop them by hand — ${dropStatements(own)}`;
95
+ replication = `${e.message}. They hold the database's WAL while they exist: drop them by hand — ${dropStatements(target.own)}`;
102
96
  }
103
97
  }
104
- // the users (src/duck/users.ts) are logins a BI tool keeps, like the API key their passwords are made with, and the
105
- // self-signed certificate is the fingerprint a tool pinned (the review of the cut: a new one at the next
106
- // start, and config.selfSignedTls stale): the next start copies again, and every tool connects as before. A demo
107
- // project's database (`.mason/demo/`, src/cli/demo.ts) is state like the copy: it goes, and config.demo stays, so the
108
- // next start makes and seeds it again
109
- const kept = new Set(["config.json", USERS_FILE, SELF_SIGNED_FILES.cert, SELF_SIGNED_FILES.key]);
110
- const gone = fs.existsSync(p.dir) ? fs.readdirSync(p.dir).filter((x) => !kept.has(x)) : [];
98
+ const gone = stateEntries(p);
111
99
  for (const x of gone)
112
100
  fs.rmSync(path.join(p.dir, x), { recursive: true, force: true });
113
101
  if (gone.length)
@@ -123,9 +111,84 @@ export async function removeProject(p, o = {}) {
123
111
  writeConfig(p.dir, fresh.config);
124
112
  }
125
113
  if (replication)
126
- throw new Error(`removed ${removed.join(", ")}; but not its ${objects}: ${replication}`);
114
+ throw new Error(`removed ${removed.join(", ")}; but not its ${target.objects}: ${replication}`);
127
115
  return removed;
128
116
  }
117
+ /**
118
+ * WHAT REPLICATION MADE ON THE DATABASE, AND WHERE IT IS — read from the config and the sources file alone, never by
119
+ * connecting (`mason destroy --dry-run` says it; `removeProject` drops it): null when the project made none. `on` is the
120
+ * database to drop them on, or why it cannot be named.
121
+ */
122
+ export function replicationTarget(p) {
123
+ if ((p.config.replication ?? null) === null && p.config.changes !== "replication")
124
+ return null;
125
+ // the slot, and the publication only when it is Mason's own: one the operator named is its owner's, left as it is
126
+ const src = projectSource(p);
127
+ // what the start that made them recorded (madeReplication); a project with no record names them by its one sources file
128
+ const own = madeReplication(p.config, "message" in src ? (sourceFileNames(p.folder)[0] ?? "app.yml").replace(/\.ya?ml$/, "") : src.name);
129
+ const objects = own.owned ? "publication and replication slot" : "replication slot";
130
+ const from = p.config.sourceServer ?? null;
131
+ const parsed = "message" in src ? { problem: `the database's URL cannot be read: ${src.message}` } : parseSource(src.resolved, src.from);
132
+ if ("problem" in parsed)
133
+ return { own, objects, from, on: { problem: parsed.problem } };
134
+ // the slot is on the database the copy came from: a url: edited since names another, where dropping finds nothing
135
+ // and the real slot goes on holding its WAL (the review of the sources file)
136
+ if (from !== null && from.database !== parsed.source.database) {
137
+ return { own, objects, from, on: { problem: `the sources file names ${parsed.source.database} now, and the synced data came from ${from.database}, where the slot is: ` +
138
+ `put that database's URL back in the sources file and run mason destroy again, or drop them on ${from.database} by hand` } };
139
+ }
140
+ return { own, objects, from, on: { source: parsed.source } };
141
+ }
142
+ /**
143
+ * WHAT `.mason/` HOLDS THAT DESTROY REMOVES. The users (src/duck/users.ts) are logins a BI tool keeps, like the API key
144
+ * their passwords are made with, and the self-signed certificate is the fingerprint a tool pinned (the review of the
145
+ * cut: a new one at the next start, and config.selfSignedTls stale): the next start copies again, and every tool
146
+ * connects as before. A demo project's database (`.mason/demo/`, src/cli/demo.ts) is state like the copy: it goes, and
147
+ * config.demo stays, so the next start makes and seeds it again.
148
+ */
149
+ function stateEntries(p) {
150
+ const kept = new Set(["config.json", USERS_FILE, SELF_SIGNED_FILES.cert, SELF_SIGNED_FILES.key]);
151
+ return fs.existsSync(p.dir) ? fs.readdirSync(p.dir).filter((x) => !kept.has(x)).sort() : [];
152
+ }
153
+ /** What replication made, as `--json` names it: the database it is on (the one the synced data came from when the
154
+ * sources file cannot say it; null when neither can), the slot, and the publication when it is Mason's own. */
155
+ function replicationFacts(t) {
156
+ return { database: "source" in t.on ? t.on.source.database : t.from?.database ?? null, slot: t.own.slot, publication: t.own.owned ? t.own.publication : null };
157
+ }
158
+ /**
159
+ * `mason destroy --dry-run`: WHAT WOULD GO, CHANGING NOTHING. It reads the run's state (`/api/ping`, which changes nothing),
160
+ * the supervisor's record, the config, the sources file and `.mason/`'s names — never the database: what replication
161
+ * made there is named from the config's record, exactly what `removeProject` would drop.
162
+ */
163
+ export async function destroyPlan(p, o = {}) {
164
+ const st = await (o.state ?? runState)(p);
165
+ if (st.kind === "hung") {
166
+ return { items: [], replication: null, error: `Mason for ${p.config.name} is running (pid ${st.run.pid}) but does not answer its commands, so mason destroy would remove nothing: mason stop ends it (SIGKILL after ${END_GRACE_MS / 1000} s), then mason destroy` };
167
+ }
168
+ const items = [];
169
+ if (st.run)
170
+ items.push(`Mason (pid ${st.run.pid}), stopped first`);
171
+ else {
172
+ const sup = (o.supervisor ?? supervisorOf)(p.dir);
173
+ if (sup !== null)
174
+ items.push(`its supervisor (pid ${sup}), stopped first: it is about to start Mason again`);
175
+ }
176
+ let replication = null, error = null;
177
+ const target = replicationTarget(p);
178
+ if (target !== null) {
179
+ replication = replicationFacts(target);
180
+ const on = replication.database === null ? "" : ` on ${replication.database}`;
181
+ items.push(`replication slot ${target.own.slot}${on}`);
182
+ if (target.own.owned)
183
+ items.push(`publication ${target.own.publication}${on}`);
184
+ if ("problem" in target.on)
185
+ error = `the rest would go, but not its ${target.objects}: ${target.on.problem}`;
186
+ }
187
+ const gone = stateEntries(p);
188
+ if (gone.length)
189
+ items.push(`${path.join(p.root, STATE_DIR)}/ (${gone.join(", ")})`);
190
+ return { items, replication, error };
191
+ }
129
192
  export async function destroy(argv, deps = {}) {
130
193
  const io = deps.io ?? STDIO;
131
194
  const { out } = io;
@@ -133,12 +196,36 @@ export async function destroy(argv, deps = {}) {
133
196
  if (typeof f === "number")
134
197
  return f;
135
198
  const json = f.values.json === true;
199
+ const dryRun = f.values["dry-run"] === true;
136
200
  if (f.positionals.length)
137
201
  return failed("destroy", { message: `it takes no name (${f.positionals.join(" ")}): a project is its folder`, code: "usage", usage: DESTROY_USAGE, fix: "cd into the project's folder, then mason destroy" }, { json, io });
138
202
  const t = theProject(deps.cwd ?? process.cwd());
139
203
  if ("problem" in t)
140
204
  return failed("destroy", t, { json, io });
141
205
  const name = t.project.config.name;
206
+ const kept = `your files in ${t.project.folder}, .mason/config.json and the users` +
207
+ // the demo database is state like the copy (src/cli/demo.ts): it goes, and config.demo stays to make it again
208
+ (t.project.config.demo === true ? ` — not the demo database, which ${dryRun ? "would go" : "went"} with the synced data: the next mason start seeds it again` : "");
209
+ if (dryRun) {
210
+ const plan = await (deps.destroyPlan ?? destroyPlan)(t.project);
211
+ const next = plan.error ? [] : [["mason destroy", "remove them"]];
212
+ if (json) {
213
+ out(jsonText(DESTROY, { dryRun: true, destroyed: { project: name, removedItems: plan.items, replication: plan.replication, error: plan.error } }, next));
214
+ return plan.error ? 1 : 0;
215
+ }
216
+ if (plan.error) {
217
+ return failed("destroy", { message: `${name}: ${plan.error}`, code: "failed", why: plan.items.length ? `mason destroy would remove: ${plan.items.join(", ")}` : undefined,
218
+ fix: "nothing was changed (a dry run)" }, { json: false, io });
219
+ }
220
+ out(withEnd(reportText({
221
+ headline: plan.items.length ? `mason destroy would remove ${name}'s synced data and state; nothing was changed` : `Nothing is left of ${name} to remove; nothing was changed`,
222
+ details: [...(plan.items.length ? [["would remove", plan.items.join(", ")]] : []), ["would keep", kept]],
223
+ }), { next }));
224
+ return 0;
225
+ }
226
+ // what replication made on the database, named before it goes (read from the config: nothing is connected for it)
227
+ const target = replicationTarget(t.project);
228
+ const replication = target === null ? null : replicationFacts(target);
142
229
  let result;
143
230
  try {
144
231
  result = { name, removed: await (deps.removeProject ?? removeProject)(t.project) };
@@ -148,16 +235,14 @@ export async function destroy(argv, deps = {}) {
148
235
  }
149
236
  const next = result.error ? [] : [["mason start", "syncs the tables again"]];
150
237
  if (json) {
151
- out(jsonText(DESTROY, { removed: [result] }, next));
238
+ out(jsonText(DESTROY, { dryRun: false, destroyed: { project: name, removedItems: result.removed, replication, error: result.error ?? null }, removed: [result] }, next));
152
239
  return result.error ? 1 : 0;
153
240
  }
154
241
  if (result.error)
155
242
  return failed("destroy", { message: `${name}: ${result.error}`, code: "failed" }, { json: false, io });
156
243
  out(withEnd(reportText({
157
244
  headline: result.removed.length ? `Removed ${name}` : `Nothing was left of ${name} to remove`,
158
- details: [...(result.removed.length ? [["removed", result.removed.join(", ")]] : []), ["kept", `your files in ${t.project.folder}, .mason/config.json and the users` +
159
- // the demo database is state like the copy (src/cli/demo.ts): it goes, and config.demo stays to make it again
160
- (t.project.config.demo === true ? ` — not the demo database, which went with the synced data: the next mason start seeds it again` : "")]],
245
+ details: [...(result.removed.length ? [["removed", result.removed.join(", ")]] : []), ["kept", kept]],
161
246
  }), { next }));
162
247
  return 0;
163
248
  }
@@ -10,27 +10,73 @@
10
10
  * project of the folder they run in. Every command is in src/cli/registry.ts, described once (src/cli/command.ts).
11
11
  * Apache-2.0: it runs the stack (CLAUDE.md "Licensing").
12
12
  */
13
- import { COMMANDS, orientation, topUsage, version } from "./registry.js";
13
+ // FIRST: it sizes libuv's pool, which nothing below may have started (src/cli/pool-size.cjs)
14
+ import "./pool-size.cjs";
15
+ import { COMMANDS, orientation, runVersion, topUsage } from "./registry.js";
14
16
  import { didYouMean } from "../lib/did-you-mean.js";
15
17
  import { spinUntilOutput } from "./style.js";
16
18
  import { CommandFailed, failed, wantsJson } from "./command.js";
17
19
  import { RunUnreachable } from "./control.js";
18
20
  import { exitWhenWritten } from "../lib/exit-when-written.js";
19
- async function main(argv) {
21
+ import { gatedExit, installOutputGate } from "./output-gate.js";
22
+ import * as fs from "node:fs";
23
+ /** `-C <folder>` before the command, as git's and make's (each one from the last): the folders, and the line after them. */
24
+ function leadingFolders(argv) {
25
+ const folders = [];
26
+ let i = 0;
27
+ while (argv[i] === "-C") {
28
+ folders.push(argv[i + 1]);
29
+ i += 2;
30
+ }
31
+ return { folders, rest: argv.slice(i) };
32
+ }
33
+ /** Runs in each `-C` folder in turn, so the command means the project there; a folder missing or unreadable is a wrong
34
+ * line, said before anything runs. Null once moved. */
35
+ function moveTo(folders, json) {
36
+ for (const dir of folders) {
37
+ const wrong = (message) => failed("", { message, code: "usage", fix: "mason -C <folder> <command>: the command runs as if started in that folder" }, { json });
38
+ if (dir === undefined || dir === "")
39
+ return wrong("-C needs a folder: mason -C <folder> <command>");
40
+ let st;
41
+ try {
42
+ st = fs.statSync(dir);
43
+ }
44
+ catch (e) {
45
+ return wrong(`-C ${JSON.stringify(dir)}: ${e.code === "ENOENT" ? "there is no such folder" : "it cannot be read"}`);
46
+ }
47
+ if (!st.isDirectory())
48
+ return wrong(`-C ${JSON.stringify(dir)}: it is not a folder`);
49
+ try {
50
+ fs.accessSync(dir, fs.constants.R_OK | fs.constants.X_OK);
51
+ process.chdir(dir);
52
+ }
53
+ catch {
54
+ return wrong(`-C ${JSON.stringify(dir)}: it cannot be read`);
55
+ }
56
+ }
57
+ return null;
58
+ }
59
+ async function main(line) {
60
+ // -C <folder> first: everything after it, the project lookup included, is as if started there
61
+ const { folders, rest: argv } = leadingFolders(line);
20
62
  const [command, ...args] = argv;
63
+ const moved = moveTo(folders, wantsJson(args, COMMANDS.find((c) => c.name === command)));
64
+ if (moved !== null)
65
+ return moved;
21
66
  // mason alone: where you are and the next command; mason help / --help: every command
22
67
  if (command === undefined) {
23
68
  process.stdout.write(await orientation(process.cwd()));
24
69
  return 0;
25
70
  }
71
+ // mason --help --json is mason help --json: every command's spec as data
26
72
  if (command === "--help" || command === "-h") {
73
+ if (args.includes("--json"))
74
+ return COMMANDS.find((c) => c.name === "help").run(["--json"]);
27
75
  process.stdout.write(topUsage());
28
76
  return 0;
29
77
  }
30
- if (command === "--version" || command === "-v" || command === "version") {
31
- process.stdout.write(`${version()}\n`);
32
- return 0;
33
- }
78
+ if (command === "--version" || command === "-v" || command === "version")
79
+ return runVersion(args);
34
80
  const cmd = COMMANDS.find((c) => c.name === command);
35
81
  const json = wantsJson(args, cmd);
36
82
  // the commands' old names: said, never run as different ones
@@ -49,7 +95,7 @@ async function main(argv) {
49
95
  list: { message: "mason list is gone: a project is its folder, and a command means the project of the folder it runs in", next: [["mason status", "this folder's project"]] },
50
96
  };
51
97
  if (!cmd && Object.hasOwn(RENAMED, command))
52
- return failed("", { ...RENAMED[command], code: "usage" }, { json });
98
+ return failed("", { ...RENAMED[command], code: "usage", fix: "run the command that does it now: the next steps name it" }, { json });
53
99
  if (!cmd)
54
100
  return failed("", { message: `there is no command ${JSON.stringify(command)}${didYouMean(command, COMMANDS.map((c) => c.name))}`, code: "usage", usage: json ? undefined : topUsage() }, { json });
55
101
  // a command quiet for a moment shows a spinner on a terminal until it prints (src/cli/style.ts spinUntilOutput)
@@ -70,14 +116,17 @@ async function main(argv) {
70
116
  stop();
71
117
  }
72
118
  }
73
- main(process.argv.slice(2)).then(exitWhenWritten, (e) => {
119
+ // every byte written passes the output's gate, which a hosted command arms with the secrets it holds (src/cli/output-gate.ts):
120
+ // installed before anything else wraps the streams (the spinner, below), and its refusal is the exit's (`gatedExit`)
121
+ installOutputGate();
122
+ main(process.argv.slice(2)).then((code) => exitWhenWritten(gatedExit(code)), (e) => {
74
123
  // an error no command said in its own words: its message, never a stack trace (CLAUDE.md: a message that needs a
75
124
  // stack to be understood is a bug — mason query orders printed one when Mason's API was not up yet).
76
125
  // MASON_DEBUG=1 prints where it happened, for the bug report it deserves. The same shape as every failure, JSON under --json.
77
126
  const message = e instanceof Error ? e.message : String(e);
78
- const [command, ...args] = process.argv.slice(2);
127
+ const [command, ...args] = leadingFolders(process.argv.slice(2)).rest;
79
128
  const code = failed(command ?? "", { message, code: "failed", fix: process.env.MASON_DEBUG ? undefined : "this is a bug in mason: MASON_DEBUG=1 prints where it happened, for the report" }, { json: wantsJson(args, COMMANDS.find((c) => c.name === command)) });
80
129
  if (process.env.MASON_DEBUG)
81
130
  process.stderr.write(`${e.stack ?? ""}\n`);
82
- exitWhenWritten(code);
131
+ exitWhenWritten(gatedExit(code));
83
132
  });
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * THE `mason` COMMAND'S ENTRY (the published `bin`, the image's `mason`, the run src/cli/background.ts starts): sets the
4
+ * pool's size, then loads the command. CommonJS because on Node 22, which the package allows, loading an ES module
5
+ * reads its files through libuv's pool, so the pool has started with four threads before an ES entry's first line
6
+ * runs: measured on 22.13, where a CommonJS entry alone got 16 threads of 16. Node 24 and 26 read those files
7
+ * synchronously, which is why main.ts's first import is enough in a checkout. Nothing here may use the pool.
8
+ */
9
+ "use strict";
10
+ require("./pool-size.cjs");
11
+ const fs = require("node:fs");
12
+ const path = require("node:path");
13
+ const { pathToFileURL } = require("node:url");
14
+
15
+ // the .ts in a checkout and the image, the .js in the package (src/lib/runnable.ts, which this file cannot import yet)
16
+ const ts = path.join(__dirname, "main.ts");
17
+ import(pathToFileURL(fs.existsSync(ts) ? ts : path.join(__dirname, "main.js")).href);
@@ -43,26 +43,24 @@ export const MCP = {
43
43
  the request log under application_name mason-mcp/<the client's name>. It serves until the client closes stdin.
44
44
  Its stdout is the protocol's: it takes no --json, and a failure to start is said on stderr.
45
45
 
46
- Its tools: tables; describe (a table); query (one statement; max_rows default 200, at most 10000; timeout_ms
47
- default 30000, at most 300000); explain (a statement). Its resources: mason://help/agent and mason://help/mcp.
48
-
49
46
  Claude Code (in the project's folder, with Mason running), the URL in the server's environment:
50
47
  mason user add claude the URL to paste, printed once
51
48
  claude mcp add mason --env MASON_URL="${EXAMPLE_URL}" -- mason mcp
52
- or on its command line, where ps shows it to this machine's users and claude mcp add prints it:
53
- claude mcp add mason -- mason mcp --url "${EXAMPLE_URL}"
54
-
55
- Claude Desktop (claude_desktop_config.json). The app often starts servers without your shell's PATH, where neither
56
- mason nor the node its first line asks for is found: name both by their absolute paths (which node, which mason):
57
- { "mcpServers": { "mason": { "command": "/absolute/path/to/node", "args": ["/absolute/path/to/mason", "mcp"],
58
- "env": { "MASON_URL": "${EXAMPLE_URL}" } } } }
59
49
 
60
50
  What the client runs:
61
51
  MASON_URL="${EXAMPLE_URL}" mason mcp
62
- mason mcp --url "${EXAMPLE_URL}"
63
52
 
64
53
  --url <url> the Postgres API as a mason user: what mason user add printed (default MASON_URL); TLS as its sslmode
65
- says (a host off this machine: require when it names none)
54
+ says (a host off this machine: require when it names none). A URL with a password here is taken,
55
+ with a warning on stderr: ps shows it to this machine's users, so MASON_URL is the safer place
56
+
57
+ Its tools: tables; describe (a table); query (one statement; max_rows default 200, at most 10000; timeout_ms
58
+ default 30000, at most 300000); explain (a statement). Its resources: mason://help/agent and mason://help/mcp.
59
+
60
+ Claude Desktop (claude_desktop_config.json). The app often starts servers without your shell's PATH, where neither
61
+ mason nor the node its first line asks for is found: name both by their absolute paths (which node, which mason):
62
+ { "mcpServers": { "mason": { "command": "/absolute/path/to/node", "args": ["/absolute/path/to/mason", "mcp"],
63
+ "env": { "MASON_URL": "${EXAMPLE_URL}" } } } }
66
64
  `,
67
65
  options: { url: { type: "string" } },
68
66
  stdout: "protocol",
@@ -71,6 +69,8 @@ export const MCP = {
71
69
  * its own log (Claude Code: claude --debug; Claude Desktop: mcp-server-mason.log). A failure to START is failed()'s. */
72
70
  const log = (m) => { process.stderr.write(`[mason mcp] ${m}\n`); };
73
71
  const NEED_URL = "mason mcp needs a mason user's URL: MASON_URL, or --url";
72
+ /** What `--url` with a password is told on stderr: the safer form, never the password. */
73
+ export const MCP_ARGV_PASSWORD_WARNING = "warning: --url holds the user's password, which ps shows to this machine's users: put the URL in the server's environment as MASON_URL instead (claude mcp add mason --env MASON_URL=… -- mason mcp)";
74
74
  const HELP_STEP = ["mason help mcp", "how to add mason mcp to Claude Code or Claude Desktop"];
75
75
  const ADD_STEP = ["mason user add claude", "a user for the agent: its password and URL, printed once"];
76
76
  /** Where `mason mcp` connects: the URL given, or why there is none (the project's state says what to run). */
@@ -103,9 +103,9 @@ async function theUrl(given, cwd) {
103
103
  }
104
104
  /** The URL read as a connection: the CLI's own reading (src/cli/preflight.ts parseSource, TLS as the URL's sslmode). */
105
105
  function theSource(url, from) {
106
- const p = parseSource(url);
106
+ const p = parseSource(url, from);
107
107
  if ("problem" in p)
108
- return { message: `${from} is not a Postgres URL: ${p.problem}`, code: "usage", fix: `the URL mason user add printed: ${EXAMPLE_URL}`, next: [HELP_STEP] };
108
+ return { message: p.problem, code: "usage", fix: `the URL mason user add printed: ${EXAMPLE_URL}`, next: [HELP_STEP] };
109
109
  return p.source;
110
110
  }
111
111
  /** application_name's client part: the MCP client's own name, in letters, digits, `.`, `_` and `-` (Postgres keeps 63 bytes). */
@@ -156,6 +156,9 @@ export async function mcp(argv) {
156
156
  const source = theSource(u.url, u.from);
157
157
  if ("message" in source)
158
158
  return failed("mcp", source, { json });
159
+ // a password on the command line is taken (a client's config may hold it so), and said with the safer form; never repeated
160
+ if (u.from === "--url" && source.password !== "")
161
+ log(MCP_ARGV_PASSWORD_WARNING);
159
162
  // the URL is checked once, before the protocol starts: a client that cannot connect is told now, in the failure shape
160
163
  try {
161
164
  (await connect(source, "mason-mcp")).close();
@@ -0,0 +1,176 @@
1
+ /**
2
+ * THE OUTPUT'S GATE: every byte a hosted command writes, on stdout and on stderr, put to the exact test at the point it
3
+ * is written (PR 645's class round: what was tested was not what was printed — a page tested whole and printed cut
4
+ * inside a `%XX` escape, a name tested with a lone surrogate read as a separator and printed by --json as `\udc00`, a
5
+ * page tested as it came and printed as the URL parser rewrote it). The per-value tests of src/cli/hosted.ts stay the
6
+ * first line (`carriesNoSecret`, `pageUrl`, `serviceText`); this is the last, and the authority: the bytes written.
7
+ *
8
+ * WHAT IS READ: each stream's text as written, after every transformation the CLI makes — styling (colour, the CLI's
9
+ * own line breaks: src/cli/command.ts serviceStyle), `JSON.stringify`'s escapes, a value's cut, the masks, the
10
+ * spinner's redraws — and the two streams together (a log that takes `2>&1`). Each is read in eight ways: with its
11
+ * terminal escapes and without them (what a log keeps; what a person sees), keeping `-` and `_` or not, and a `%XX`
12
+ * escape decoded or read as the characters it is written with — each as src/cli/hosted.ts reads a value (`bodyRead`:
13
+ * re-cased, in its compatibility form, every other character skipped). A run that crosses two writes is read whole:
14
+ * each reading keeps the last `ECHO_RUN - 1` characters it read, and a raw tail is kept for the shapes.
15
+ *
16
+ * WHAT FIRES IT: `ECHO_RUN` of a held secret's body in a row in any reading, or anything shaped as a documented token
17
+ * or (unless the command prints a password it made: `mason user add --replica`) a Mason user's password. Then the
18
+ * write that would complete it is NOT written, nor anything after it: the command's refusal is written instead, in the
19
+ * CLI's words (on stdout under --json, as its one object), and the process exits 1 (`gatedExit`). What was written
20
+ * before showed at most `ECHO_RUN - 1` in a row.
21
+ *
22
+ * HONEST OUTPUT IS NEVER REFUSED BY CHANCE, by `ECHO_RUN`'s derivation (src/cli/hosted.ts): an honest output is
23
+ * independent of the token, and a command writes a few KiB; read 8 ways and over 3 streams it is under 2^20
24
+ * characters read, and the bound W × 43 × 2 × 31^-11 is under 10^-8. tests/cli-hosted.test.ts measures it with a fixed
25
+ * seed over the honest outputs of deploy, login, logout and mason user --replica.
26
+ *
27
+ * ONE VALUE MAY BE PRINTED ONCE (`holdOutput`'s `once`: the CI token `mason token create` made, the one answer that
28
+ * carries a token by design): its FIRST whole occurrence in a write (`onceAt`: exactly as it is; the CLI writes it on a
29
+ * line of its own, never broken) is read as a separator, never as the secret it is, and is written; every other byte is read as before, the value held among the secrets, so a second
30
+ * occurrence, a piece of it elsewhere (11 of its body in a row) or anything else shaped as a token is refused as any
31
+ * secret is. What it allows is exactly that value, whole, one time, in one write.
32
+ *
33
+ * INERT until a command holds a secret (`holdOutput`): every other command writes as it always did.
34
+ */
35
+ import { failed, serviceStyle } from "./command.js";
36
+ import { bodyRead, canonicalTexts, ECHO_RUN, echoViews, ESCAPES, shapedSecret, wholeInCanonical } from "./hosted.js";
37
+ import { err as styleErr } from "./style.js";
38
+ /** The eight readings: [with escapes, without] × [keeping `-` and `_`, not] × [a `%XX` decoded, not]. */
39
+ const READINGS = [true, false].flatMap((escapes) => [true, false].flatMap((keep) => [true, false].map((percent) => ({ escapes, keep, percent }))));
40
+ const TAIL = ECHO_RUN - 1;
41
+ /** The raw tail kept for the shapes and the secrets read whole: longer than any shape (a token is 56 characters, a
42
+ * password 55), and than any held secret written `%XX`-encoded (`rawTail`). */
43
+ const RAW_TAIL = 80;
44
+ const rawTail = (secrets) => Math.max(RAW_TAIL, ...secrets.map((x) => 3 * x.length));
45
+ export const nothingWritten = () => ({ reads: READINGS.map(() => ""), raw: "" });
46
+ /**
47
+ * WHETHER WRITING `text` AFTER `before` WOULD WRITE A SECRET (the gate's test, pure): `ECHO_RUN` of a held secret's body
48
+ * in a row in any reading of the two together, or a shape. The stream as it would stand after it, either way.
49
+ */
50
+ export function writes(before, text, secrets, o = {}) {
51
+ return writesAfter([before], readingsOf(text), secrets, o)[0];
52
+ }
53
+ /**
54
+ * `fresh` (a text's readings, `readingsOf(text)`) written after each of `befores`: the text is read and scanned once,
55
+ * and only where it meets each stream's tail again (a window of `ECHO_RUN` crossing into it lies within the tail and
56
+ * the text's first `TAIL` characters).
57
+ */
58
+ function writesAfter(befores, fresh, secrets, o) {
59
+ const inside = readingsCarry(fresh, secrets, o);
60
+ const tail = rawTail(secrets);
61
+ return befores.map((before) => {
62
+ const meet = { reads: before.reads.map((r, i) => r + fresh.reads[i].slice(0, TAIL)), raw: before.raw + fresh.raw.slice(0, tail) };
63
+ const after = { reads: before.reads.map((r, i) => (r + fresh.reads[i]).slice(-TAIL)), raw: (before.raw + fresh.raw).slice(-tail) };
64
+ return { secret: inside || readingsCarry(meet, secrets, o), after };
65
+ });
66
+ }
67
+ /** `text`'s readings, after what was written before it (its tails): what `readingsCarry` tests. */
68
+ export function readingsOf(text, before = nothingWritten()) {
69
+ // a text with no escape, or no `%`, reads the same either way: each distinct reading is made once
70
+ const stripped = text.includes("\x1b") ? text.replace(ESCAPES, "") : text, percents = text.includes("%");
71
+ const made = new Map();
72
+ const read = (escapes, keep, percent) => {
73
+ const key = `${escapes && stripped !== text}${keep}${percent && percents}`;
74
+ let r = made.get(key);
75
+ if (r === undefined) {
76
+ r = bodyRead(escapes ? text : stripped, keep, percent);
77
+ made.set(key, r);
78
+ }
79
+ return r;
80
+ };
81
+ return { reads: READINGS.map(({ escapes, keep, percent }, i) => before.reads[i] + read(escapes, keep, percent)), raw: before.raw + text };
82
+ }
83
+ /** Whether readings hold a secret: `run` of a held secret's body in a row in any of them, the secret whole, or a shape. */
84
+ export function readingsCarry(read, secrets, o = {}) {
85
+ if (shapedSecret(read.raw, o) || shapedSecret(read.raw.replace(ESCAPES, ""), o))
86
+ return true;
87
+ for (const secret of secrets) {
88
+ if (!secret)
89
+ continue;
90
+ // whole, in the one reading, with its terminal escapes and without them (src/cli/hosted.ts wholeIn)
91
+ read.canonical ??= canonicalTexts(read.raw);
92
+ if (read.canonical.some((t) => wholeInCanonical(t, secret)))
93
+ return true;
94
+ for (const { keep, n, grams } of echoViews(secret, o.run ?? ECHO_RUN)) {
95
+ if (n === 0)
96
+ continue;
97
+ // a text with no escape and no `%` reads the same in several ways: each distinct reading is scanned once
98
+ const distinct = new Set(READINGS.flatMap((r, i) => r.keep === keep ? [read.reads[i]] : []));
99
+ for (const text of distinct)
100
+ for (let j = 0; j + n <= text.length; j++)
101
+ if (grams.has(text.slice(j, j + n)))
102
+ return true;
103
+ }
104
+ }
105
+ return false;
106
+ }
107
+ /** WHERE `value` IS WRITTEN WHOLE in `text`, exactly as it is (the CLI writes it alone, never broken: src/cli/token-hosted.ts);
108
+ * null when it is not. The span, from its first character to its last. */
109
+ export function onceAt(text, value) {
110
+ const i = text.indexOf(value);
111
+ return i < 0 ? null : [i, i + value.length];
112
+ }
113
+ let held = null;
114
+ let refused = false;
115
+ let originals = null;
116
+ const state = { out: nothingWritten(), err: nothingWritten(), both: nothingWritten() };
117
+ /** The refusal written in place of what the gate held back: the CLI's words alone. */
118
+ const REFUSAL = {
119
+ message: "the service's answer would print a secret this command holds, so nothing more of it is printed",
120
+ code: "refused",
121
+ why: "what was about to be written held a secret this command holds (11 or more characters of a token's body in a row, or the database's password it sent), or something shaped as a token or a password: a hosted service's text is printed only when it carries none",
122
+ fix: "a service that repeats the token it was sent is answering outside its documented API: sign in again (mason login), so this token is replaced, and revoke it in the service's web app if it was shared",
123
+ next: [["mason login", "sign in again"]],
124
+ };
125
+ /**
126
+ * Hold `secrets` from here on, for `command`, whose answer is `--json` or not; again with more (login's new token)
127
+ * adds them. `passwords` false: this command prints a password it made (its answer's), so a password's shape is no
128
+ * secret in its output. `once`: a secret this command prints one time, whole (above), held as a secret beside it.
129
+ */
130
+ export function holdOutput(o) {
131
+ const secrets = [...(held?.secrets ?? []), ...o.secrets.filter(Boolean), ...(o.once ? [o.once] : [])];
132
+ held = { command: o.command, json: o.json, secrets, passwords: o.passwords ?? held?.passwords ?? true, once: o.once || (held?.once ?? null) };
133
+ }
134
+ /** Whether the gate held output back: the process then exits 1, whatever the command returned. */
135
+ export const outputRefused = () => refused;
136
+ export const gatedExit = (code) => refused ? 1 : code;
137
+ /** The gate, on process.stdout and process.stderr, once, before anything else wraps them (src/cli/main.ts). */
138
+ export function installOutputGate() {
139
+ if (originals !== null)
140
+ return;
141
+ originals = { out: process.stdout.write.bind(process.stdout), err: process.stderr.write.bind(process.stderr) };
142
+ const gate = (which) => ((chunk, ...rest) => {
143
+ const original = originals[which];
144
+ const text = typeof chunk === "string" ? chunk : chunk instanceof Uint8Array ? Buffer.from(chunk).toString("utf8") : String(chunk);
145
+ const done = rest.find((x) => typeof x === "function");
146
+ // nothing to write (exitWhenWritten's flush), or nothing held: as it always was; once refused, nothing more
147
+ if (text === "")
148
+ return original(chunk, ...rest);
149
+ if (refused) {
150
+ if (done)
151
+ setImmediate(done);
152
+ return true;
153
+ }
154
+ if (held === null)
155
+ return original(chunk, ...rest);
156
+ // the one value printed once, whole, the first time it is written: read as a separator (above)
157
+ const once = held.once === null ? null : onceAt(text, held.once);
158
+ const tested = once === null ? text : `${text.slice(0, once[0])} ${text.slice(once[1])}`;
159
+ const [own, both] = writesAfter([state[which], state.both], readingsOf(tested), held.secrets, { passwords: held.passwords });
160
+ if (!own.secret && !both.secret) {
161
+ state[which] = own.after;
162
+ state.both = both.after;
163
+ if (once !== null)
164
+ held = { ...held, once: null };
165
+ return original(chunk, ...rest);
166
+ }
167
+ refused = true;
168
+ const io = { out: (m) => { originals.out(`${m}\n`); }, err: (m) => { originals.err(`\n${m}\n`); } };
169
+ failed(held.command, REFUSAL, { json: held.json, io, style: serviceStyle(styleErr()) });
170
+ if (done)
171
+ setImmediate(done);
172
+ return true;
173
+ });
174
+ process.stdout.write = gate("out");
175
+ process.stderr.write = gate("err");
176
+ }