@mason-data/cli 0.8.0 → 0.9.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/README.md +13 -4
  2. package/dist/src/api/postgres/catalog-worker.js +49 -2
  3. package/dist/src/api/postgres/catalog.js +166 -32
  4. package/dist/src/api/postgres/listener.js +11 -3
  5. package/dist/src/api/postgres/proxy.js +283 -0
  6. package/dist/src/api/postgres/schema.js +6 -0
  7. package/dist/src/api/postgres/server.js +505 -99
  8. package/dist/src/api/postgres/translate.js +47 -2
  9. package/dist/src/cli/background.js +18 -19
  10. package/dist/src/cli/bridge-stdin.js +101 -0
  11. package/dist/src/cli/command.js +169 -11
  12. package/dist/src/cli/commands.js +207 -51
  13. package/dist/src/cli/credentials.js +182 -0
  14. package/dist/src/cli/demo.js +2 -2
  15. package/dist/src/cli/deploy.js +574 -0
  16. package/dist/src/cli/dev.js +148 -27
  17. package/dist/src/cli/doctor.js +116 -33
  18. package/dist/src/cli/generated-files.js +143 -0
  19. package/dist/src/cli/guides.js +97 -6
  20. package/dist/src/cli/hosted.js +850 -0
  21. package/dist/src/cli/init.js +47 -11
  22. package/dist/src/cli/login.js +289 -0
  23. package/dist/src/cli/machine.js +1 -1
  24. package/dist/src/cli/main.js +8 -2
  25. package/dist/src/cli/mason.cjs +17 -0
  26. package/dist/src/cli/mcp.js +2 -2
  27. package/dist/src/cli/output-gate.js +176 -0
  28. package/dist/src/cli/page-share.js +96 -0
  29. package/dist/src/cli/pg-tls.js +1 -1
  30. package/dist/src/cli/pin.js +6 -2
  31. package/dist/src/cli/pool-size.cjs +85 -0
  32. package/dist/src/cli/ports.js +2 -0
  33. package/dist/src/cli/preflight.js +130 -39
  34. package/dist/src/cli/project.js +99 -0
  35. package/dist/src/cli/readiness.js +42 -6
  36. package/dist/src/cli/registry.js +12 -4
  37. package/dist/src/cli/replica-hosted.js +665 -0
  38. package/dist/src/cli/replica.js +76 -0
  39. package/dist/src/cli/roles.js +2 -1
  40. package/dist/src/cli/run-server.js +154 -7
  41. package/dist/src/cli/security.js +57 -10
  42. package/dist/src/cli/serve.js +63 -7
  43. package/dist/src/cli/share-run.js +51 -8
  44. package/dist/src/cli/share.js +3 -3
  45. package/dist/src/cli/source-edit.js +10 -4
  46. package/dist/src/cli/source-role.js +344 -0
  47. package/dist/src/cli/sources.js +30 -20
  48. package/dist/src/cli/style.js +2 -1
  49. package/dist/src/cli/supervise.js +15 -23
  50. package/dist/src/cli/token-hosted.js +337 -0
  51. package/dist/src/cli/ui-ssh.js +460 -0
  52. package/dist/src/cli/ui.js +115 -11
  53. package/dist/src/cli/user-hosted.js +299 -0
  54. package/dist/src/cli/user.js +126 -74
  55. package/dist/src/demo/seed.js +26 -0
  56. package/dist/src/duck/analyze.js +36 -20
  57. package/dist/src/duck/collation.js +266 -0
  58. package/dist/src/duck/constant-key.js +94 -0
  59. package/dist/src/duck/distinct-key.js +283 -26
  60. package/dist/src/duck/events.js +39 -8
  61. package/dist/src/duck/executor-worker.js +2 -2
  62. package/dist/src/duck/executor.js +6 -5
  63. package/dist/src/duck/explain.js +4 -0
  64. package/dist/src/duck/functions.js +448 -49
  65. package/dist/src/duck/gaps.js +34 -16
  66. package/dist/src/duck/generated-plan.js +1070 -0
  67. package/dist/src/duck/generated.js +906 -0
  68. package/dist/src/duck/grants.js +1 -1
  69. package/dist/src/duck/held-types.js +64 -0
  70. package/dist/src/duck/history.js +4 -0
  71. package/dist/src/duck/jsonb-order.js +9 -12
  72. package/dist/src/duck/kept-route.js +6 -5
  73. package/dist/src/duck/kept.js +9 -7
  74. package/dist/src/duck/late.js +1469 -40
  75. package/dist/src/duck/libc-bound.js +341 -0
  76. package/dist/src/duck/libc-order.js +892 -126
  77. package/dist/src/duck/pg-eval-worker.js +7 -1
  78. package/dist/src/duck/pg-eval.js +6 -4
  79. package/dist/src/duck/pgtree.js +774 -23
  80. package/dist/src/duck/plan.js +275 -30
  81. package/dist/src/duck/publication.js +14 -0
  82. package/dist/src/duck/requests.js +9 -5
  83. package/dist/src/duck/resolved.js +117 -2
  84. package/dist/src/duck/rewrite.js +4567 -583
  85. package/dist/src/duck/row-filter.js +12 -6
  86. package/dist/src/duck/statement.js +19 -0
  87. package/dist/src/duck/store.js +294 -68
  88. package/dist/src/duck/stream.js +9 -5
  89. package/dist/src/duck/sync.js +602 -126
  90. package/dist/src/duck/token.js +4 -1
  91. package/dist/src/duck/types.js +63 -7
  92. package/dist/src/duck/usage.js +6 -4
  93. package/dist/src/duck/users.js +5 -0
  94. package/dist/src/duck/verified.js +159 -2
  95. package/dist/src/duck/views.js +4 -3
  96. package/dist/src/lib/database-url.js +55 -10
  97. package/dist/src/lib/instant.js +50 -0
  98. package/dist/src/lib/log-buffer.js +13 -4
  99. package/dist/src/lib/log-read.js +196 -0
  100. package/dist/src/lib/pg-numeric.js +387 -49
  101. package/dist/src/lib/secret-mask.js +142 -0
  102. package/dist/src/lib/table-sync.js +24 -9
  103. package/dist/src/web/app.js +9 -4
  104. package/dist/src/web/pages/status.js +178 -13
  105. package/dist/src/web/pages/syncs.js +1 -1
  106. package/dist/src/web/pages/tables.js +8 -3
  107. package/dist/src/web/pages/users.js +4 -3
  108. package/dist/src/web/shared.js +19 -3
  109. package/package.json +8 -7
@@ -30,13 +30,14 @@ import { cliPinProblem, ensurePin } from "./pin.js";
30
30
  import { version } from "./version.js";
31
31
  import { parseVersion, PIN_FILE } from "../lib/project-version.js";
32
32
  import { appDatabaseUrl, appEnvValue, appendEnvLine, ENV_FILE } from "../lib/dotenv.js";
33
- import { DEMO_DIR, NAME_RE, STATE_DIR, brokenProblem, findProject, ignoresStateDir, nameFromFolder, newConfig, writeConfig } from "./project.js";
34
- import { DEMO_ADDED_TABLES, DEMO_ARG, DEMO_PORT, DEMO_SYNC, DEMO_TABLE_TO_ADD, demoUrl } from "./demo.js";
33
+ import { DEMO_DIR, NAME_RE, STATE_DIR, brokenProblem, findProject, folderPlace, ignoresStateDir, nameFromFolder, newConfig, projectWriteRefusal, writeConfig } from "./project.js";
34
+ import { DEMO_ADDED_TABLES, DEMO_ARG, DEMO_GENERATED, DEMO_PORT, DEMO_SYNC, DEMO_TABLE_TO_ADD, demoUrl } from "./demo.js";
35
+ import { GENERATED_DIR, generatedDir } from "./generated-files.js";
35
36
  import { freePort } from "./ports.js";
36
37
  import { databaseNameProblem, DEFAULT_DATABASE } from "../lib/database-name.js";
37
38
  import { DEFAULT_SYNC, syncText } from "../lib/table-sync.js";
38
39
  import { newRolesText, rolesFile } from "./roles.js";
39
- import { newSourceText, OLD_TABLES_DIR, parseSourceText, shownPath, sourceFileNames, sourceNameFor, sourcesDir } from "./sources.js";
40
+ import { newSourceText, OLD_TABLES_DIR, parseSourceText, shownPath, sourceFileNames, sourceNameFor, sourceOf, sourcesDir } from "./sources.js";
40
41
  export const INIT_USAGE = `usage: mason init [<postgres url> | demo] [--name <name>] [--database <name>] [--folder <dir>] [--remote] [--skip-check] [--json]
41
42
 
42
43
  Makes this folder a Mason project — run it in your app's folder. It checks it can read the database (and writes
@@ -56,6 +57,9 @@ export const INIT_USAGE = `usage: mason init [<postgres url> | demo] [--name <na
56
57
  mason init demo, in a new folder, makes a project on a demo database Mason hosts itself (a shop: customers, products,
57
58
  orders, order_items): mason start makes it in .mason/demo/ and changes it every few seconds. Three of its tables are
58
59
  added; the fourth is yours to add after mason start (mason add ${DEMO_TABLE_TO_ADD}), to watch its first sync begin.
60
+ It also writes mason/${GENERATED_DIR}/${DEMO_GENERATED.file}, a generated table over the synced orders (orders and
61
+ revenue per day and status, kept current: mason wait ${DEMO_GENERATED.table} once Mason is up; mason help generated); one
62
+ already there is kept.
59
63
 
60
64
  mason init MASON_DATABASE_URL, else DATABASE_URL (.env or the shell)
61
65
  mason init postgres://me:secret@localhost:5432/shop
@@ -72,14 +76,15 @@ export const INIT_USAGE = `usage: mason init [<postgres url> | demo] [--name <na
72
76
  --json as data: { name, root, folder, config, demo (true for the demo database's project, else false),
73
77
  source: { name (the source's: its file's), file, url (as the file writes it), database (the name
74
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) },
75
- database: { url (password masked), from, version, tables }, warnings, next }
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 }
76
81
  `;
77
82
  export const INIT = {
78
83
  name: "init",
79
84
  summary: "make this folder a Mason project on your Postgres: mason init (its URL from .env), mason init postgres://… or demo",
80
85
  usage: INIT_USAGE,
81
86
  options: { name: { type: "string" }, database: { type: "string" }, folder: { type: "string" }, remote: { type: "boolean" }, "skip-check": { type: "boolean" }, json: { type: "boolean" } },
82
- json: ["name", "root", "folder", "config", "demo", "source", "roles", "database", "warnings", "next"],
87
+ json: ["name", "root", "folder", "config", "demo", "source", "roles", "generated", "database", "warnings", "next"],
83
88
  };
84
89
  /** Why `name` cannot be this project's, or null: not a name its replication slot takes. */
85
90
  export function nameProblem(name) {
@@ -205,8 +210,16 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
205
210
  const name = sourceless?.config.name ?? f.values.name ?? nameFromFolder(root);
206
211
  const folderArg = f.values.folder ?? "mason";
207
212
  const folder = sourceless?.folder ?? path.resolve(root, folderArg);
208
- if (path.relative(root, folder).startsWith(".."))
209
- return usage(`--folder ${folderArg} is outside this folder`, "the project's files go in this folder or below it");
213
+ // the one authority on where the files go (src/cli/project.ts folderPlace): outside this folder, or a place it cannot
214
+ // resolve (a loop, a file as a folder, unreadable, too long), which can never become a project's folder, is refused
215
+ const place = folderPlace(root, folder);
216
+ const shownArg = folderArg.length > 200 ? `${folderArg.slice(0, 200)}…` : folderArg;
217
+ if (place.place === "outside")
218
+ return usage(`--folder ${shownArg} is outside this folder`, "the project's files go in this folder or below it");
219
+ if (place.place === "unknown") {
220
+ return failed("init", { message: `--folder ${shownArg} cannot be resolved to a folder`, code: "usage", why: place.why,
221
+ fix: "name a folder of its own inside this one with --folder (the default, mason, is one), or fix the path" }, { json, io });
222
+ }
210
223
  if (fs.existsSync(folder) && !fs.statSync(folder).isDirectory())
211
224
  return usage(`${folder} is a file, not a folder`, "--folder <dir> chooses another");
212
225
  const named = nameProblem(name);
@@ -216,6 +229,11 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
216
229
  const badDatabase = connectName === null ? null : databaseNameProblem(connectName);
217
230
  if (badDatabase !== null)
218
231
  return usage(`--database: ${badDatabase}`);
232
+ // THE PROJECT'S FILES DEPLOYED READ-ONLY (src/cli/project.ts projectWriteRefusal, the one authority): init writes them,
233
+ // so it writes none — refused before the database is asked, as every writer of a project file is
234
+ const readOnly = projectWriteRefusal(sourceless?.root ?? null, deps.env ?? process.env);
235
+ if (readOnly !== null)
236
+ return failed("init", readOnly, { json, io });
219
237
  // THE SOURCE FILE: one already here (a clone of the project) is kept; else written, its URL a variable of .env
220
238
  const kept = sourceFileNames(folder)[0] ?? null;
221
239
  if (demo && kept !== null) {
@@ -264,7 +282,9 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
264
282
  envFile = found.file;
265
283
  envVariable = found.variable;
266
284
  }
267
- const parsed = parseSource(url);
285
+ // 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";
287
+ const parsed = parseSource(url, urlName);
268
288
  if ("problem" in parsed)
269
289
  return usage(parsed.problem);
270
290
  const remote = f.values.remote === true || sourceless?.config.remote === true;
@@ -291,6 +311,9 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
291
311
  const sourceFile = kept !== null ? path.join(sourcesDir(folder), kept) : path.join(sourcesDir(folder), `${sourceNameFor(parsed.source.database)}.yml`);
292
312
  const rolesPath = rolesFile(folder);
293
313
  const rolesKept = fs.existsSync(rolesPath);
314
+ // THE DEMO'S GENERATED TABLE (src/demo/seed.ts DEMO_GENERATED): written beside the sources file, kept when one is here
315
+ const generatedPath = demo ? path.join(generatedDir(folder), DEMO_GENERATED.file) : null;
316
+ const generatedKept = generatedPath !== null && fs.existsSync(generatedPath);
294
317
  // the folder an older Mason listed the tables in: read by nothing (no backwards compatibility before v1.0), said
295
318
  const oldTables = path.join(folder, OLD_TABLES_DIR);
296
319
  if (fs.existsSync(oldTables))
@@ -315,7 +338,7 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
315
338
  const said = [];
316
339
  let needsRemote = false;
317
340
  const checked = await steps.step(`Connecting to ${db}${from}`, async () => {
318
- const c = await checkSource(url, { remote, cmd: "mason init", say: (m) => said.push(m) });
341
+ const c = await checkSource(url, { remote, cmd: "mason init", say: (m) => said.push(m), name: urlName });
319
342
  if ("exit" in c)
320
343
  throw new StepFailed(said.filter((m) => !/: connecting to /.test(m)).join("\n"), c.exit);
321
344
  if ("needsRemote" in c) {
@@ -353,6 +376,17 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
353
376
  `(${demo ? "every table polled every 5 s" : "each table read once, then again only by mason sync <table>"}) and the tables to sync, ` +
354
377
  `${demo ? `the demo's ${DEMO_ADDED_TABLES.length} added — mason add ${DEMO_TABLE_TO_ADD} adds the fourth` : "none yet (mason add)"} — commit it`);
355
378
  }
379
+ if (generatedPath !== null) {
380
+ const g = rel(generatedPath);
381
+ if (generatedKept)
382
+ await steps.step(`Keeping ${g}`, () => undefined, () => `Kept ${g}: the demo's generated table, as it is`);
383
+ else {
384
+ await steps.step(`Writing ${g}`, () => {
385
+ fs.mkdirSync(path.dirname(generatedPath), { recursive: true });
386
+ fs.writeFileSync(generatedPath, DEMO_GENERATED.text);
387
+ }, () => `Wrote ${g}: a generated table, orders and revenue per day, kept current from the synced orders (mason help generated) — commit it`);
388
+ }
389
+ }
356
390
  // THE ROLES FILE (src/cli/roles.ts newRolesText): today's access said in a committed file — every user reads every
357
391
  // copied table, as the default role everything — kept when one is here (a clone of the project)
358
392
  if (rolesKept)
@@ -373,7 +407,7 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
373
407
  const cli = deps.version ?? version();
374
408
  const pinFile = rel(path.join(folder, PIN_FILE));
375
409
  if (parseVersion(cli) !== null) {
376
- await steps.step(`Writing ${pinFile}`, () => ensurePin(folder, cli), (wrote) => wrote ? `Wrote ${pinFile}: version ${cli}, the Mason this project is written for — commit it` : `Kept ${pinFile}`);
410
+ await steps.step(`Writing ${pinFile}`, () => ensurePin(folder, cli, root, deps.env ?? process.env), (wrote) => wrote ? `Wrote ${pinFile}: version ${cli}, the Mason this project is written for — commit it` : `Kept ${pinFile}`);
377
411
  const pinned = cliPinProblem(folder, cli);
378
412
  if (pinned)
379
413
  notes.push(pinned);
@@ -403,6 +437,7 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
403
437
  const next = demo ? [
404
438
  ["mason start", "the demo database and Mason, in the background: its tables synced, then kept current"],
405
439
  [`mason add ${DEMO_TABLE_TO_ADD}`, "the demo's fourth table, once Mason is up: watch its first sync begin"],
440
+ [`mason wait ${DEMO_GENERATED.table}`, "the demo's generated table, once Mason is up: orders and revenue per day, kept current"],
406
441
  ["mason status", "the synced data keeping up with the demo's changes"],
407
442
  ["mason user add <name>", "a user for psql or a BI tool: its password and URL, printed once"],
408
443
  ] : [
@@ -416,12 +451,13 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
416
451
  database: keptFile === null ? connectName ?? DEFAULT_DATABASE : "problems" in keptFile ? null : keptFile.database ?? DEFAULT_DATABASE,
417
452
  env: where.line === null ? null : maskedLine(where.line) },
418
453
  roles: { file: rolesPath, written: !rolesKept },
454
+ generated: generatedPath === null ? [] : [{ table: sourceOf(DEMO_GENERATED.table), file: generatedPath, written: !generatedKept }],
419
455
  database, warnings: notes }, next));
420
456
  return 0;
421
457
  }
422
458
  for (const n of notes)
423
459
  say(` ! ${n}`);
424
- say(endText({ tips: [demo ? `The demo database runs inside mason start, read by polling: a new order every few seconds; ${DEMO_TABLE_TO_ADD} is left for you to add; mason destroy removes it with the synced data`
460
+ say(endText({ tips: [demo ? `The demo database runs inside mason start, read by polling: a new order every few seconds; ${DEMO_TABLE_TO_ADD} is left for you to add; ${DEMO_GENERATED.table} is a generated table over the synced orders; mason destroy removes the demo database with the synced data`
425
461
  : "Mason only reads your database: no setting, slot or publication is needed or made"], next }, st));
426
462
  return 0;
427
463
  }
@@ -0,0 +1,289 @@
1
+ /**
2
+ * `mason login` AND `mason logout` — THIS MACHINE SIGNED IN TO A HOSTED MASON SERVICE (docs/HOSTED-API.md). They need
3
+ * no project: a login is the person's, kept in their own config folder (src/cli/credentials.ts), never in a project.
4
+ *
5
+ * `mason login` is OAuth's device authorization grant (RFC 8628; src/cli/hosted.ts speaks it): it asks the service for
6
+ * a code, says where to approve it — at once, on stderr, in plain lines, so a person in a terminal and a coding agent
7
+ * relaying it both read it before the wait begins (a browser opens on a terminal, when one can) — polls at the
8
+ * service's interval (five seconds more at each `slow_down`) until the code is approved, denied or expires, then asks
9
+ * who signed in (`GET /v1/me`) and saves the token. Its answer is ONE report on stdout, or under `--json` ONE object,
10
+ * once, when it ends: what it prints while waiting is progress, on stderr.
11
+ *
12
+ * `mason logout` revokes the saved token (best effort: a service that cannot be reached is said, with the way to revoke
13
+ * it by hand) and removes the saved login either way.
14
+ *
15
+ * A TOKEN NEVER REACHES A COMMAND LINE, A LOG, THE OUTPUT OR `--json`: there is no flag that takes one; `MASON_TOKEN` is
16
+ * read from the environment and never printed; and everything printed from the service is masked (`serviceText`).
17
+ * `MASON_TOKEN` comes before a saved login (CI), so `mason login` with it set is refused, naming how to sign in anyway,
18
+ * and `mason logout` says it cannot remove an environment variable.
19
+ */
20
+ import { setTimeout as sleep } from "node:timers/promises";
21
+ import { endText, failed, jsonText, parseCommand, reportText, serviceStyle, strict } from "./command.js";
22
+ import { environmentToken, readLogins, TOKEN_ENV, writeLogins } from "./credentials.js";
23
+ import { DEFAULT_SERVICE_URL, expired, isFailure, pollToken, revoke, serviceOf, serviceText, startDevice, whoAmI } from "./hosted.js";
24
+ import { err as styleErr, out as styleOut, stepRenderer } from "./style.js";
25
+ import { holdOutput } from "./output-gate.js";
26
+ import { openBrowser } from "./ui.js";
27
+ const out = (m) => { process.stdout.write(`${m}\n`); };
28
+ /** Everything these commands print can hold the service's text, so it is drawn in serviceStyle (src/cli/command.ts):
29
+ * on a terminal the CLI breaks every line itself, and a person never sees a line begin with what the service sent. */
30
+ const outStyle = () => serviceStyle(styleOut());
31
+ const errStyle = () => serviceStyle(styleErr());
32
+ const fail = (cmd, f, o) => failed(cmd, f, { json: o.json, style: errStyle() });
33
+ const say = (m) => { process.stderr.write(`${m}\n`); };
34
+ export const LOGIN = {
35
+ name: "login",
36
+ summary: "sign this machine in to a hosted Mason service, by a code you approve in your browser",
37
+ usage: `usage: mason login [--json]
38
+
39
+ Signs this machine in to a hosted Mason service: it prints a page and a code, opens the page in your browser
40
+ when it runs in a terminal, and waits while you sign in there and approve the code (until the code expires, as
41
+ the service says; Ctrl-C stops it). It then says who is signed in and their organisations, and keeps the login in
42
+ ~/.config/mason/credentials.json ($XDG_CONFIG_HOME/mason/ when that is set), readable by you alone, never in a
43
+ project. Signing in again replaces this machine's login to that service, and revokes the one it replaces.
44
+
45
+ The page and the code are printed on stderr at once, as plain lines, so a coding agent can pass them on to you
46
+ before the wait ends; the answer is printed when it ends.
47
+
48
+ The service is ${DEFAULT_SERVICE_URL} unless MASON_CLOUD_URL names another (https://, or http:// for one on
49
+ this machine). With MASON_TOKEN set (CI), every command uses that token before a saved login, so mason login is
50
+ refused: run it without the variable (env -u MASON_TOKEN mason login) to sign in anyway.
51
+
52
+ mason login
53
+ MASON_CLOUD_URL=http://127.0.0.1:8080 mason login a service on this machine
54
+
55
+ --json as data, once signed in: { service (its address), email, name, orgs: [{ slug, name, role }], file (where
56
+ the login is kept), replaced (null, or the login it replaced: { email, revoked }), next }
57
+ `,
58
+ options: { json: { type: "boolean", default: false } },
59
+ json: ["service", "email", "name", "orgs", "file", "replaced", "next"],
60
+ };
61
+ export const LOGOUT = {
62
+ name: "logout",
63
+ summary: "sign this machine out of a hosted Mason service: its login revoked and removed",
64
+ usage: `usage: mason logout [--json]
65
+
66
+ Signs this machine out of the hosted Mason service (MASON_CLOUD_URL, else ${DEFAULT_SERVICE_URL}): it revokes
67
+ the login on the service, then removes it from ~/.config/mason/credentials.json. When the service cannot be
68
+ reached the login is still removed here, and it says the login was NOT revoked and how to revoke it: sign in to
69
+ the service's web app and revoke it there. Logins to other services stay.
70
+
71
+ MASON_TOKEN in the environment is not a saved login: logout cannot remove it, and says so while it is set.
72
+
73
+ mason logout
74
+ MASON_CLOUD_URL=http://127.0.0.1:8080 mason logout a service on this machine
75
+
76
+ --json as data: { service, signedOut (a saved login was removed), email (its account, null when there was
77
+ none), revoked (true, false when the service did not take it, null when there was nothing to revoke),
78
+ revokeError (why not, else null), environmentToken (MASON_TOKEN is set, and still used), file, next }
79
+ `,
80
+ options: { json: { type: "boolean", default: false } },
81
+ json: ["service", "signedOut", "email", "revoked", "revokeError", "environmentToken", "file", "next"],
82
+ };
83
+ /** Where a person may approve the code, for the browser to open: a plain https (or this machine's http) URL, and none
84
+ * of the characters a shell or `cmd /c start` reads (the service's URL is not ours). */
85
+ const OPENABLE = /^https?:\/\/[A-Za-z0-9.:[\]-]+(?:\/[A-Za-z0-9._~/?=+-]*)?$/;
86
+ /** An organisation as a line: its slug, its name when it says more, the role. */
87
+ const orgLine = (o) => `${o.slug}${o.name && o.name !== o.slug ? ` (${o.name})` : ""}, ${o.role}`;
88
+ export async function login(argv) {
89
+ const f = parseCommand(LOGIN, argv);
90
+ if (typeof f === "number")
91
+ return f;
92
+ const json = f.values.json === true;
93
+ if (f.positionals.length)
94
+ return fail("login", { message: "it takes no argument", code: "usage", why: "the service is MASON_CLOUD_URL's, else the default", usage: LOGIN.usage }, { json });
95
+ const service = serviceOf();
96
+ if (isFailure(service))
97
+ return fail("login", service, { json });
98
+ if (environmentToken() !== null) {
99
+ return fail("login", { message: `${TOKEN_ENV} is set in this environment, so a login would not be used`, code: "refused",
100
+ why: `every command sends ${TOKEN_ENV} before a saved login: a new login to ${service.base} would be shadowed by it`,
101
+ fix: `to sign in anyway, run it without the variable: env -u ${TOKEN_ENV} mason login (the login is used once ${TOKEN_ENV} is unset)`,
102
+ next: [[`env -u ${TOKEN_ENV} mason login`, "sign in, for when MASON_TOKEN is unset"]] }, { json });
103
+ }
104
+ // the file is checked BEFORE the person spends a minute approving a code: a folder others can read is said now
105
+ const before = readLogins();
106
+ if (isFailure(before))
107
+ return fail("login", before, { json });
108
+ // what this command holds that the service may echo before it sends a token: the login it replaces (src/cli/hosted.ts
109
+ // `carriesNoSecret`: an answer that carries it is refused whole)
110
+ const held = before.logins.logins[service.base] ? [before.logins.logins[service.base].token] : [];
111
+ // and from here every byte written is put to the exact test against what it holds (src/cli/output-gate.ts)
112
+ holdOutput({ command: "login", json, secrets: held });
113
+ const code = await startDevice(service, held);
114
+ if (isFailure(code))
115
+ return fail("login", code, { json });
116
+ const token = await approved(service, code, json, held);
117
+ if (isFailure(token))
118
+ return fail("login", token, { json });
119
+ holdOutput({ command: "login", json, secrets: [token] });
120
+ // FROM HERE THE SERVICE HAS GRANTED A TOKEN, which is kept only once it is saved: on every failure before that —
121
+ // one said, or one thrown (PR 645's review: a crash left a granted token live) — it is revoked, and a revoke that
122
+ // did not reach the service is said
123
+ let kept;
124
+ try {
125
+ kept = await keep(service, token, held);
126
+ }
127
+ catch (e) {
128
+ const notRevoked = await revoke(service, token);
129
+ if (notRevoked === null)
130
+ throw e;
131
+ throw new Error(`${e instanceof Error ? e.message : String(e)}; the token the service granted was NOT revoked (${notRevoked}): sign in to ${service.base} in a browser and revoke it there`, { cause: e });
132
+ }
133
+ if (isFailure(kept)) {
134
+ const notRevoked = await revoke(service, token);
135
+ return fail("login", notRevoked === null ? kept : { ...kept, why: `${kept.why ? `${kept.why}; ` : ""}the token the service granted was NOT revoked (${notRevoked}): sign in to ${service.base} in a browser and revoke it there` }, { json });
136
+ }
137
+ const { me, was, saved } = kept;
138
+ // the login it replaced is revoked, so this machine never leaves a token it no longer holds alive on the service
139
+ const replaced = was === null || was.token === token ? null : { email: serviceText(was.email, 320, [token, was.token]), revokeError: await revoke(service, was.token) };
140
+ // what the service said, as it may be shown, in the report and in --json alike: printable, bounded, no token in it
141
+ const secrets = [token, ...(was ? [was.token] : [])];
142
+ const email = serviceText(me.email, 320, secrets), name = serviceText(me.name, 300, secrets);
143
+ const orgs = me.orgs.map((o) => ({ slug: serviceText(o.slug, 200, secrets), name: serviceText(o.name, 200, secrets), role: serviceText(o.role, 32, secrets) }));
144
+ const next = [["mason logout", `sign this machine out of ${service.base}`]];
145
+ if (json) {
146
+ out(jsonText(LOGIN, { service: service.base, email, name, orgs, file: saved.file,
147
+ replaced: replaced === null ? null : { email: replaced.email, revoked: replaced.revokeError === null } }, next));
148
+ return 0;
149
+ }
150
+ out(reportText({
151
+ headline: `Signed in to ${service.base} as ${email}`,
152
+ details: [
153
+ ...(name ? [["name", name]] : []),
154
+ ...(orgs.length ? orgs.map((o, i) => [i === 0 ? "orgs" : "", orgLine(o)]) : [["orgs", "none yet"]]),
155
+ ["saved", saved.file],
156
+ ],
157
+ tips: replaced === null ? undefined : replaced.revokeError === null
158
+ ? [`This replaces the login of ${replaced.email}, which is revoked.`]
159
+ : [`This replaces the login of ${replaced.email}, which was NOT revoked (${replaced.revokeError}): sign in to ${service.base} in a browser and revoke it there.`],
160
+ next,
161
+ }, outStyle()));
162
+ return 0;
163
+ }
164
+ /**
165
+ * WHAT A GRANTED TOKEN NEEDS BEFORE IT IS KEPT: who signed in (a token the service will not say who it is is never
166
+ * kept), the logins read again just before the write (so a login another command saved meanwhile is kept), and the
167
+ * write. A Failure when any is not had; the caller revokes the token then.
168
+ */
169
+ async function keep(service, token, held) {
170
+ const me = await whoAmI(service, token, held);
171
+ if (isFailure(me))
172
+ return me;
173
+ const now = readLogins();
174
+ if (isFailure(now))
175
+ return now;
176
+ const was = now.logins.logins[service.base] ?? null;
177
+ const logins = { version: 1, logins: { ...now.logins.logins, [service.base]: { token, email: me.email, savedAt: new Date().toISOString() } } };
178
+ const saved = writeLogins(logins);
179
+ if (isFailure(saved))
180
+ return saved;
181
+ return { me, was, saved };
182
+ }
183
+ /**
184
+ * The page and the code said, then the wait: a poll every `interval` seconds (five more at each slow_down, RFC 8628
185
+ * 3.5), until the token, a refusal, or the code's expiry. On a terminal a spinner turns beside the wait; elsewhere the
186
+ * wait is one line.
187
+ */
188
+ async function approved(service, code, json, held) {
189
+ const page = code.verificationUriComplete ?? code.verificationUri;
190
+ const s = errStyle();
191
+ say(reportText({ headline: `To sign in to ${service.base}, open this page and approve the code:`,
192
+ details: [["page", page], ["code", code.verificationUriComplete ? `${code.userCode} (the page shows it: check they match)` : `${code.userCode} (enter it on the page)`]] }, s));
193
+ const terminal = !json && process.stderr.isTTY === true;
194
+ if (terminal) {
195
+ const couldNot = OPENABLE.test(page) ? await openBrowser(page) : "the page's address is not one Mason opens by itself";
196
+ if (couldNot !== null)
197
+ say(endText({ tips: [`Could not open a browser (${couldNot}): open the page yourself.`] }, s).replace(/^\n/, ""));
198
+ }
199
+ const minutes = Math.max(1, Math.round(code.expiresIn / 60));
200
+ const full = `Waiting for the code to be approved (it expires in ${minutes} min; Ctrl-C stops)`;
201
+ // a spinner's line is redrawn in place, so it must never reach the terminal's edge (src/cli/command.ts serviceStyle):
202
+ // the longest label that fits beside its frame, else no spinner and the wait said once, broken as any line
203
+ const room = (process.stderr.columns ?? 0) - 1 - 6;
204
+ const label = terminal ? [full, "Waiting for approval", "Waiting"].find((l) => l.length <= room) : undefined;
205
+ const live = label !== undefined;
206
+ const steps = stepRenderer({ line: (t) => say(strict(t, s)), live: live ? process.stderr : null, style: s });
207
+ if (live)
208
+ steps.begin(label);
209
+ else
210
+ say(strict(`${full} …`, s));
211
+ const deadline = Date.now() + code.expiresIn * 1000;
212
+ let interval = code.interval;
213
+ for (;;) {
214
+ if (Date.now() + interval * 1000 > deadline) {
215
+ if (live)
216
+ steps.fail();
217
+ return expired(code);
218
+ }
219
+ await sleep(interval * 1000);
220
+ const p = await pollToken(service, code, held);
221
+ if ("pending" in p)
222
+ continue;
223
+ if ("slowDown" in p) {
224
+ interval += 5;
225
+ continue;
226
+ }
227
+ if (isFailure(p)) {
228
+ if (live)
229
+ steps.fail();
230
+ return p;
231
+ }
232
+ if (live)
233
+ steps.done("Approved");
234
+ else
235
+ say("Approved");
236
+ return p.token;
237
+ }
238
+ }
239
+ export async function logout(argv) {
240
+ const f = parseCommand(LOGOUT, argv);
241
+ if (typeof f === "number")
242
+ return f;
243
+ const json = f.values.json === true;
244
+ if (f.positionals.length)
245
+ return fail("logout", { message: "it takes no argument", code: "usage", why: "the service is MASON_CLOUD_URL's, else the default", usage: LOGOUT.usage }, { json });
246
+ const service = serviceOf();
247
+ if (isFailure(service))
248
+ return fail("logout", service, { json });
249
+ const fromEnv = environmentToken() !== null;
250
+ const envTip = fromEnv ? [`${TOKEN_ENV} is set in this environment, and every command still uses it: logout cannot remove an environment variable. Unset it (unset ${TOKEN_ENV}), and revoke it in the service's web app if it was shared.`] : [];
251
+ const read = readLogins();
252
+ if (isFailure(read))
253
+ return fail("logout", read, { json });
254
+ const entry = read.logins.logins[service.base];
255
+ const env = environmentToken();
256
+ if (entry !== undefined || env !== null)
257
+ holdOutput({ command: "logout", json, secrets: [...(entry ? [entry.token] : []), ...(env !== null ? [env] : [])] });
258
+ if (entry === undefined) {
259
+ const next = [["mason login", `sign this machine in to ${service.base}`]];
260
+ if (json) {
261
+ out(jsonText(LOGOUT, { service: service.base, signedOut: false, email: null, revoked: null, revokeError: null, environmentToken: fromEnv, file: read.file }, next));
262
+ return 0;
263
+ }
264
+ out(reportText({ headline: `This machine is not signed in to ${service.base}`, details: [["file", read.file]], tips: envTip, next }, outStyle()));
265
+ return 0;
266
+ }
267
+ const revokeError = await revoke(service, entry.token);
268
+ // removed whether or not the service took the revoke: a person who logs out means this machine to hold it no more
269
+ const rest = { ...read.logins.logins };
270
+ delete rest[service.base];
271
+ const written = writeLogins({ version: 1, logins: rest });
272
+ if (isFailure(written))
273
+ return fail("logout", written, { json });
274
+ const email = serviceText(entry.email, 320, [entry.token]);
275
+ const next = [["mason login", `sign in to ${service.base} again`]];
276
+ if (json) {
277
+ out(jsonText(LOGOUT, { service: service.base, signedOut: true, email, revoked: revokeError === null, revokeError, environmentToken: fromEnv, file: written.file }, next));
278
+ return 0;
279
+ }
280
+ const notRevoked = revokeError === null ? undefined
281
+ : `The login was NOT revoked (${revokeError}), so it still works on the service until it expires: sign in to ${service.base} in a browser and revoke this machine's login there.`;
282
+ out(reportText({
283
+ headline: revokeError === null ? `Signed out of ${service.base}` : `Signed out of ${service.base} on this machine, but the login was NOT revoked`,
284
+ details: [["account", email], ["revoked", revokeError === null ? "yes: the service takes this login no more" : "no"], ["removed from", written.file]],
285
+ tips: [...(notRevoked ? [notRevoked] : []), ...envTip],
286
+ next,
287
+ }, outStyle()));
288
+ return 0;
289
+ }
@@ -83,7 +83,7 @@ export async function removeProject(p, o = {}) {
83
83
  const own = madeReplication(p.config, "message" in src ? (sourceFileNames(p.folder)[0] ?? "app.yml").replace(/\.ya?ml$/, "") : src.name);
84
84
  if (!own.owned)
85
85
  objects = "replication slot";
86
- const parsed = "message" in src ? { problem: `the database's URL cannot be read: ${src.message}` } : parseSource(src.resolved);
86
+ const parsed = "message" in src ? { problem: `the database's URL cannot be read: ${src.message}` } : parseSource(src.resolved, src.from);
87
87
  try {
88
88
  if ("problem" in parsed)
89
89
  throw new Error(parsed.problem);
@@ -10,12 +10,15 @@
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
+ // FIRST: it sizes libuv's pool, which nothing below may have started (src/cli/pool-size.cjs)
14
+ import "./pool-size.cjs";
13
15
  import { COMMANDS, orientation, topUsage, version } 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";
21
+ import { gatedExit, installOutputGate } from "./output-gate.js";
19
22
  async function main(argv) {
20
23
  const [command, ...args] = argv;
21
24
  // mason alone: where you are and the next command; mason help / --help: every command
@@ -70,7 +73,10 @@ async function main(argv) {
70
73
  stop();
71
74
  }
72
75
  }
73
- main(process.argv.slice(2)).then(exitWhenWritten, (e) => {
76
+ // every byte written passes the output's gate, which a hosted command arms with the secrets it holds (src/cli/output-gate.ts):
77
+ // installed before anything else wraps the streams (the spinner, below), and its refusal is the exit's (`gatedExit`)
78
+ installOutputGate();
79
+ main(process.argv.slice(2)).then((code) => exitWhenWritten(gatedExit(code)), (e) => {
74
80
  // an error no command said in its own words: its message, never a stack trace (CLAUDE.md: a message that needs a
75
81
  // stack to be understood is a bug — mason query orders printed one when Mason's API was not up yet).
76
82
  // MASON_DEBUG=1 prints where it happened, for the bug report it deserves. The same shape as every failure, JSON under --json.
@@ -79,5 +85,5 @@ main(process.argv.slice(2)).then(exitWhenWritten, (e) => {
79
85
  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
86
  if (process.env.MASON_DEBUG)
81
87
  process.stderr.write(`${e.stack ?? ""}\n`);
82
- exitWhenWritten(code);
88
+ exitWhenWritten(gatedExit(code));
83
89
  });
@@ -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);
@@ -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). */