@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
@@ -30,14 +30,15 @@ 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
- export const INIT_USAGE = `usage: mason init [<postgres url> | demo] [--name <name>] [--database <name>] [--folder <dir>] [--remote] [--skip-check] [--json]
40
+ import { newSourceText, OLD_TABLES_DIR, parseSourceText, shownPath, sourceFileNames, sourceNameFor, sourceOf, sourcesDir } from "./sources.js";
41
+ export const INIT_USAGE = `usage: mason init [- | <postgres url> | demo] [--name <name>] [--database <name>] [--folder <dir>] [--remote] [--skip-check] [--json]
41
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
43
44
  nothing when it cannot), then creates:
@@ -46,22 +47,12 @@ export const INIT_USAGE = `usage: mason init [<postgres url> | demo] [--name <na
46
47
  mason/roles.yml who may read what: one role, everything (tables: all), the default of a user added
47
48
  without --role, so every user reads every synced table until you narrow it — commit it
48
49
  mason/mason.yml the Mason version the project is written for — commit it: production runs that version
49
- With no URL it reads MASON_DATABASE_URL, else your app's DATABASE_URL, each from .env.local, then .env, then the
50
- shell. The source file names the variable read, url: \${DATABASE_URL} — \${MASON_DATABASE_URL} when that is the one
51
- read, or when a URL given here is another database than your app's DATABASE_URL: the URL itself is in .env (written
52
- there when you give it here or it is only in the shell), never in a committed file. .mason/ and that .env go into
53
- .gitignore. Every other mason command then works anywhere in this folder. A source file already here (a clone of the
54
- project) is kept, and a bare mason init reads the variable it names; a roles file already here is kept. In a project
55
- with no source file (an older Mason's), it writes that file alone, and the roles file when there is none.
56
- mason init demo, in a new folder, makes a project on a demo database Mason hosts itself (a shop: customers, products,
57
- orders, order_items): mason start makes it in .mason/demo/ and changes it every few seconds. Three of its tables are
58
- added; the fourth is yours to add after mason start (mason add ${DEMO_TABLE_TO_ADD}), to watch its first sync begin.
59
50
 
60
51
  mason init MASON_DATABASE_URL, else DATABASE_URL (.env or the shell)
61
- mason init postgres://me:secret@localhost:5432/shop
52
+ mason init - < db-url.txt the URL on stdin, written to .env, never on the command line
62
53
  mason init demo a demo database Mason hosts itself: nothing to install
63
54
  mason init --folder db/mason --name shop-admin the project's files elsewhere; another name
64
- mason init postgres://me:secret@db.example.com/shop --remote
55
+ mason init --remote the database in .env is on another machine
65
56
 
66
57
  --name <name> the project's name, which its replication slot is named for (default: this folder's)
67
58
  --database <name> the database name a tool connects with, written as database: in the source file (default: none
@@ -69,17 +60,37 @@ export const INIT_USAGE = `usage: mason init [<postgres url> | demo] [--name <na
69
60
  --folder <dir> where sources/ goes, relative to this folder (default mason; . for this folder itself)
70
61
  --remote go ahead with a database on another machine (read the warnings mason init prints); not with demo
71
62
  --skip-check write the project without connecting (the database is not up yet); mason start checks it; not with demo
72
- --json as data: { name, root, folder, config, demo (true for the demo database's project, else false),
73
- source: { name (the source's: its file's), file, url (as the file writes it), database (the name
74
- 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 }
63
+ --json as data: its shape is at the end
64
+
65
+ With no URL it reads MASON_DATABASE_URL, else your app's DATABASE_URL, each from .env.local, then .env, then the
66
+ shell. The source file names the variable read, url: \${DATABASE_URL} — \${MASON_DATABASE_URL} when that is the one
67
+ read, or when a URL given here is another database than your app's DATABASE_URL: the URL itself is in .env (written
68
+ there when you give it here or it is only in the shell), never in a committed file. .mason/ and that .env go into
69
+ .gitignore. Every other mason command then works anywhere in this folder. A source file already here (a clone of the
70
+ project) is kept, and a bare mason init reads the variable it names; a roles file already here is kept. In a project
71
+ with no source file (an older Mason's), it writes that file alone, and the roles file when there is none.
72
+ mason init - reads the URL from stdin (one line, from a pipe), so its password stays off the command line, where ps
73
+ shows it to this machine's users and your shell keeps it in its history: a URL with a password given as the argument
74
+ is still taken, with a warning on stderr.
75
+ mason init demo, in a new folder, makes a project on a demo database Mason hosts itself (a shop: customers, products,
76
+ orders, order_items): mason start makes it in .mason/demo/ and changes it every few seconds. Three of its tables are
77
+ added; the fourth is yours to add after mason start (mason add ${DEMO_TABLE_TO_ADD}), to watch its first sync begin.
78
+ It also writes mason/${GENERATED_DIR}/${DEMO_GENERATED.file}, a generated table over the synced orders (orders and
79
+ revenue per day and status, kept current: mason wait ${DEMO_GENERATED.table} once Mason is up; mason help generated); one
80
+ already there is kept.
81
+
82
+ The --json answer: { name, root, folder, config, demo (true for the demo database's project, else false),
83
+ source: { name (the source's: its file's), file, url (as the file writes it), database (the name
84
+ a tool connects with, or null when the file has a mistake), env (the .env line written, its value masked, or null) }, roles: { file, written (false: one was here, and kept) },
85
+ generated: [{ table, file, written (false: one was here, and kept) }] (the demo's generated table; [] for
86
+ any other project), database: { url (password masked), from, version, tables }, warnings, next }
76
87
  `;
77
88
  export const INIT = {
78
89
  name: "init",
79
- summary: "make this folder a Mason project on your Postgres: mason init (its URL from .env), mason init postgres://… or demo",
90
+ summary: "make this folder a Mason project on your Postgres: mason init (its URL from .env), mason init - (from stdin) or demo",
80
91
  usage: INIT_USAGE,
81
92
  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"],
93
+ json: ["name", "root", "folder", "config", "demo", "source", "roles", "generated", "database", "warnings", "next"],
83
94
  };
84
95
  /** Why `name` cannot be this project's, or null: not a name its replication slot takes. */
85
96
  export function nameProblem(name) {
@@ -157,6 +168,63 @@ export function urlVariable(root, url, found = null) {
157
168
  const addEnvLine = (root, line) => appendEnvLine(root, line, "the database Mason syncs from (mason/sources/ names it; never commit this file)");
158
169
  /** A `.env` line as it is said: its value's password masked. */
159
170
  const maskedLine = (line) => { const eq = line.indexOf("="); return `${line.slice(0, eq)}=${maskedUrl(line.slice(eq + 1))}`; };
171
+ /** The argument that reads the URL from stdin: `mason init -`. */
172
+ export const STDIN_ARG = "-";
173
+ /** The most of stdin `mason init -` reads (a URL is far shorter), and how long it waits for a pipe left open and silent. */
174
+ const STDIN_MAX_BYTES = 8192, STDIN_TIMEOUT_MS = 10_000;
175
+ /**
176
+ * THE URL FROM A PIPE (`mason init -`): stdin read whole, bounded in bytes and in time, one trailing newline trimmed;
177
+ * a terminal, nothing, more than one line, too much or nothing in time is said by kind — never by what was read, which
178
+ * may hold a password.
179
+ */
180
+ export function readPipedUrl(input, max = STDIN_MAX_BYTES, timeoutMs = STDIN_TIMEOUT_MS) {
181
+ const way = "pipe one line into it (mason init - < db-url.txt), or put the URL in .env as DATABASE_URL=… and run mason init";
182
+ if (input.isTTY === true)
183
+ return Promise.resolve({ problem: `mason init - reads the URL from a pipe, and stdin is a terminal: ${way}` });
184
+ return new Promise((resolve) => {
185
+ const chunks = [];
186
+ let size = 0, ended = false;
187
+ const finish = (r) => {
188
+ if (ended)
189
+ return;
190
+ ended = true;
191
+ clearTimeout(timer);
192
+ input.removeListener("data", onData);
193
+ input.removeListener("end", onEnd);
194
+ input.removeListener("error", onError);
195
+ input.pause();
196
+ resolve(r);
197
+ };
198
+ const onData = (c) => {
199
+ const b = typeof c === "string" ? Buffer.from(c) : c;
200
+ size += b.length;
201
+ if (size > max) {
202
+ finish({ problem: `stdin held more than ${max} bytes, which is no URL: ${way}` });
203
+ return;
204
+ }
205
+ chunks.push(b);
206
+ };
207
+ const onEnd = () => {
208
+ const text = Buffer.concat(chunks).toString("utf8").replace(/\r?\n$/, "");
209
+ if (text.trim() === "")
210
+ finish({ problem: `stdin was empty: ${way}` });
211
+ else if (/[\r\n]/.test(text))
212
+ finish({ problem: `stdin held more than one line: ${way}` });
213
+ else
214
+ finish({ url: text.trim() });
215
+ };
216
+ const onError = (e) => finish({ problem: `stdin could not be read (${e.message}): ${way}` });
217
+ const timer = setTimeout(() => finish({ problem: `nothing ended on stdin within ${timeoutMs / 1000} s: ${way}` }), timeoutMs);
218
+ input.on("data", onData);
219
+ input.on("end", onEnd);
220
+ input.on("error", onError);
221
+ input.resume();
222
+ });
223
+ }
224
+ /** The warning for a URL whose password was given as the argument, naming the safer forms — `variable` the one the
225
+ * sources file would name (DATABASE_URL, or MASON_DATABASE_URL for a database other than the app's) — never the password. */
226
+ export const argvPasswordWarning = (variable) => "the URL given on the command line holds its password, which ps shows to this machine's users and your shell keeps in its history: " +
227
+ `put it in .env as ${variable}=… and run mason init, or pipe it in with mason init - < db-url.txt`;
160
228
  export async function init(argv, cwd = process.cwd(), deps = {}) {
161
229
  const io = deps.io ?? STDIO;
162
230
  const { out: say } = io;
@@ -205,8 +273,16 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
205
273
  const name = sourceless?.config.name ?? f.values.name ?? nameFromFolder(root);
206
274
  const folderArg = f.values.folder ?? "mason";
207
275
  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");
276
+ // the one authority on where the files go (src/cli/project.ts folderPlace): outside this folder, or a place it cannot
277
+ // resolve (a loop, a file as a folder, unreadable, too long), which can never become a project's folder, is refused
278
+ const place = folderPlace(root, folder);
279
+ const shownArg = folderArg.length > 200 ? `${folderArg.slice(0, 200)}…` : folderArg;
280
+ if (place.place === "outside")
281
+ return usage(`--folder ${shownArg} is outside this folder`, "the project's files go in this folder or below it");
282
+ if (place.place === "unknown") {
283
+ return failed("init", { message: `--folder ${shownArg} cannot be resolved to a folder`, code: "usage", why: place.why,
284
+ fix: "name a folder of its own inside this one with --folder (the default, mason, is one), or fix the path" }, { json, io });
285
+ }
210
286
  if (fs.existsSync(folder) && !fs.statSync(folder).isDirectory())
211
287
  return usage(`${folder} is a file, not a folder`, "--folder <dir> chooses another");
212
288
  const named = nameProblem(name);
@@ -216,6 +292,11 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
216
292
  const badDatabase = connectName === null ? null : databaseNameProblem(connectName);
217
293
  if (badDatabase !== null)
218
294
  return usage(`--database: ${badDatabase}`);
295
+ // THE PROJECT'S FILES DEPLOYED READ-ONLY (src/cli/project.ts projectWriteRefusal, the one authority): init writes them,
296
+ // so it writes none — refused before the database is asked, as every writer of a project file is
297
+ const readOnly = projectWriteRefusal(sourceless?.root ?? null, deps.env ?? process.env);
298
+ if (readOnly !== null)
299
+ return failed("init", readOnly, { json, io });
219
300
  // THE SOURCE FILE: one already here (a clone of the project) is kept; else written, its URL a variable of .env
220
301
  const kept = sourceFileNames(folder)[0] ?? null;
221
302
  if (demo && kept !== null) {
@@ -229,7 +310,19 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
229
310
  const keptFile = kept === null || keptPath === null ? null : parseSourceText(fs.readFileSync(keptPath, "utf8"), kept.replace(/\.ya?ml$/, ""), keptPath);
230
311
  const keptVar = keptFile !== null && !("problems" in keptFile) && "env" in keptFile.url ? keptFile.url.env : null;
231
312
  // the demo's URL names a port free now, and is the one place the port is kept: mason start reads it from there
232
- let url = demo ? demoUrl(await freePort(DEMO_PORT)) : f.positionals[0] ?? null;
313
+ // `mason init -`: the URL from a pipe, so its password never reaches the command line (ps, the shell's history)
314
+ const piped = f.positionals[0] === STDIN_ARG;
315
+ let url;
316
+ if (demo)
317
+ url = demoUrl(await freePort(DEMO_PORT));
318
+ else if (piped) {
319
+ const r = await readPipedUrl(deps.stdin ?? process.stdin);
320
+ if ("problem" in r)
321
+ return failed("init", { message: r.problem, code: "usage", usage: INIT_USAGE }, { json, io });
322
+ url = r.url;
323
+ }
324
+ else
325
+ url = f.positionals[0] ?? null;
233
326
  let from = "";
234
327
  let envFile = null; // the folder's file the URL was read from (.env.local or .env): ignored with .mason/
235
328
  let envVariable = null; // the variable envFile holds the URL in, set with it: the warning names it
@@ -245,8 +338,8 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
245
338
  if (v === null) {
246
339
  return failed("init", { message: `which database? ${file} names url: \${${keptVar}}, and this folder has no ${keptVar}`, code: "usage",
247
340
  why: `mason start reads the URL from ${keptVar}, so mason init checks that one`,
248
- fix: `put it in this folder's .env as ${keptVar}=postgres://… (the variable ${file} names), or give its URL on the command line: mason init writes it there`,
249
- next: [["mason init postgres://user:password@localhost:5432/your_app", `its URL, given: written to .env as ${keptVar}`], ["mason init", `again, once .env has ${keptVar}`]] }, { json, io });
341
+ fix: `put it in this folder's .env as ${keptVar}=postgres://… (the variable ${file} names), or pipe its URL in (mason init - < db-url.txt): mason init writes it there`,
342
+ next: [["mason init - < db-url.txt", `its URL on stdin: written to .env as ${keptVar}`], ["mason init", `again, once .env has ${keptVar}`]] }, { json, io });
250
343
  }
251
344
  found = { url: v.value, variable: keptVar, from: `${keptVar} in ${v.from}`, file: v.file };
252
345
  }
@@ -255,8 +348,8 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
255
348
  if (!found) {
256
349
  // the fixes ARE the next commands, so a coding agent reads them as such too
257
350
  return failed("init", { message: "which database? none is named, and this folder has no MASON_DATABASE_URL or DATABASE_URL", code: "usage",
258
- fix: "give its URL on the command line, or put it in this folder's .env as DATABASE_URL=postgres://… (your app's, usually) or as MASON_DATABASE_URL=postgres://… (read first: a database other than your app's)",
259
- next: [["mason init postgres://user:password@localhost:5432/your_app", "its URL, given"], ["mason init", "again, once .env has MASON_DATABASE_URL or DATABASE_URL"],
351
+ fix: "pipe its URL in (mason init - < db-url.txt), or put it in this folder's .env as DATABASE_URL=postgres://… (your app's, usually) or as MASON_DATABASE_URL=postgres://… (read first: a database other than your app's)",
352
+ next: [["mason init - < db-url.txt", "its URL on stdin: written to .env"], ["mason init", "again, once .env has MASON_DATABASE_URL or DATABASE_URL"],
260
353
  ["mason init demo", "a demo database Mason makes itself: nothing to install"]] }, { json, io });
261
354
  }
262
355
  url = found.url;
@@ -264,14 +357,23 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
264
357
  envFile = found.file;
265
358
  envVariable = found.variable;
266
359
  }
267
- const parsed = parseSource(url);
360
+ // a problem names where the URL is (DATABASE_URL in .env, the URL given), never the value (src/cli/preflight.ts shownUrl)
361
+ const urlName = found !== null ? found.from : demo ? "the demo database's URL" : piped ? "the URL read from stdin" : "the URL given";
362
+ const parsed = parseSource(url, urlName);
268
363
  if ("problem" in parsed)
269
364
  return usage(parsed.problem);
365
+ // A PASSWORD ON THE COMMAND LINE: taken (refusing it would break scripts), and said on stderr with the safer forms;
366
+ // the password itself is never repeated
367
+ const argvPassword = !demo && !piped && found === null && parsed.source.password !== "";
270
368
  const remote = f.values.remote === true || sourceless?.config.remote === true;
271
369
  // a kept file's variable gets this URL in .env when the folder's files give it none; one they give another value is said
272
370
  const keptValue = keptVar === null ? null : appEnvValue(root, keptVar, {});
273
371
  const where = kept === null ? urlVariable(root, url, found)
274
372
  : { variable: keptVar ?? "", line: keptVar !== null && keptValue === null ? `${keptVar}=${url}` : null };
373
+ // the variable .env would hold it in: the one the sources file names (MASON_DATABASE_URL for another database than the app's)
374
+ const argvWarning = argvPassword ? argvPasswordWarning("problem" in where || where.variable === "" ? "DATABASE_URL" : where.variable) : null;
375
+ if (argvWarning !== null)
376
+ io.err(`! ${argvWarning}`);
275
377
  if ("problem" in where)
276
378
  return failed("init", { message: where.problem, code: "refused" }, { json, io });
277
379
  // WHAT IT DOES, AS A CHECKLIST (each item it is doing, with a spinner left of it that turns into a checkmark when
@@ -284,6 +386,9 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
284
386
  const db = maskedUrl(parsed.source.url);
285
387
  line(st.bold(`Making ${name} a Mason project in ${root}`));
286
388
  const notes = [];
389
+ // a person read the warning on stderr already; an agent reads warnings in the answer too
390
+ if (argvWarning !== null && json)
391
+ notes.push(argvWarning);
287
392
  if (keptValue !== null && keptValue.value !== url)
288
393
  notes.push(`${kept} reads ${keptVar} from ${keptValue.from}, which is ${maskedUrl(keptValue.value)}: mason start syncs from that one, not ${db}`);
289
394
  let database = { url: db, from: from ? from.slice(2, -1) : null, version: null, tables: null };
@@ -291,6 +396,9 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
291
396
  const sourceFile = kept !== null ? path.join(sourcesDir(folder), kept) : path.join(sourcesDir(folder), `${sourceNameFor(parsed.source.database)}.yml`);
292
397
  const rolesPath = rolesFile(folder);
293
398
  const rolesKept = fs.existsSync(rolesPath);
399
+ // THE DEMO'S GENERATED TABLE (src/demo/seed.ts DEMO_GENERATED): written beside the sources file, kept when one is here
400
+ const generatedPath = demo ? path.join(generatedDir(folder), DEMO_GENERATED.file) : null;
401
+ const generatedKept = generatedPath !== null && fs.existsSync(generatedPath);
294
402
  // the folder an older Mason listed the tables in: read by nothing (no backwards compatibility before v1.0), said
295
403
  const oldTables = path.join(folder, OLD_TABLES_DIR);
296
404
  if (fs.existsSync(oldTables))
@@ -315,7 +423,7 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
315
423
  const said = [];
316
424
  let needsRemote = false;
317
425
  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) });
426
+ const c = await checkSource(url, { remote, cmd: "mason init", say: (m) => said.push(m), name: urlName });
319
427
  if ("exit" in c)
320
428
  throw new StepFailed(said.filter((m) => !/: connecting to /.test(m)).join("\n"), c.exit);
321
429
  if ("needsRemote" in c) {
@@ -353,6 +461,17 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
353
461
  `(${demo ? "every table polled every 5 s" : "each table read once, then again only by mason sync <table>"}) and the tables to sync, ` +
354
462
  `${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
463
  }
464
+ if (generatedPath !== null) {
465
+ const g = rel(generatedPath);
466
+ if (generatedKept)
467
+ await steps.step(`Keeping ${g}`, () => undefined, () => `Kept ${g}: the demo's generated table, as it is`);
468
+ else {
469
+ await steps.step(`Writing ${g}`, () => {
470
+ fs.mkdirSync(path.dirname(generatedPath), { recursive: true });
471
+ fs.writeFileSync(generatedPath, DEMO_GENERATED.text);
472
+ }, () => `Wrote ${g}: a generated table, orders and revenue per day, kept current from the synced orders (mason help generated) — commit it`);
473
+ }
474
+ }
356
475
  // THE ROLES FILE (src/cli/roles.ts newRolesText): today's access said in a committed file — every user reads every
357
476
  // copied table, as the default role everything — kept when one is here (a clone of the project)
358
477
  if (rolesKept)
@@ -373,7 +492,7 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
373
492
  const cli = deps.version ?? version();
374
493
  const pinFile = rel(path.join(folder, PIN_FILE));
375
494
  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}`);
495
+ 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
496
  const pinned = cliPinProblem(folder, cli);
378
497
  if (pinned)
379
498
  notes.push(pinned);
@@ -403,6 +522,7 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
403
522
  const next = demo ? [
404
523
  ["mason start", "the demo database and Mason, in the background: its tables synced, then kept current"],
405
524
  [`mason add ${DEMO_TABLE_TO_ADD}`, "the demo's fourth table, once Mason is up: watch its first sync begin"],
525
+ [`mason wait ${DEMO_GENERATED.table}`, "the demo's generated table, once Mason is up: orders and revenue per day, kept current"],
406
526
  ["mason status", "the synced data keeping up with the demo's changes"],
407
527
  ["mason user add <name>", "a user for psql or a BI tool: its password and URL, printed once"],
408
528
  ] : [
@@ -416,12 +536,13 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
416
536
  database: keptFile === null ? connectName ?? DEFAULT_DATABASE : "problems" in keptFile ? null : keptFile.database ?? DEFAULT_DATABASE,
417
537
  env: where.line === null ? null : maskedLine(where.line) },
418
538
  roles: { file: rolesPath, written: !rolesKept },
539
+ generated: generatedPath === null ? [] : [{ table: sourceOf(DEMO_GENERATED.table), file: generatedPath, written: !generatedKept }],
419
540
  database, warnings: notes }, next));
420
541
  return 0;
421
542
  }
422
543
  for (const n of notes)
423
544
  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`
545
+ 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
546
  : "Mason only reads your database: no setting, slot or publication is needed or made"], next }, st));
426
547
  return 0;
427
548
  }
@@ -0,0 +1,292 @@
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 { OPENABLE, 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
+ mason login
46
+ MASON_CLOUD_URL=http://127.0.0.1:8080 mason login a service on this machine
47
+
48
+ --json as data, once signed in: { service (its address), email, name, orgs: [{ slug, name, role }], file (where
49
+ the login is kept), replaced (null, or the login it replaced: { email, revoked }), next }
50
+
51
+ The page and the code are printed on stderr at once, as plain lines, so a coding agent can pass them on to you
52
+ before the wait ends; the answer is printed when it ends.
53
+
54
+ The service is ${DEFAULT_SERVICE_URL} unless MASON_CLOUD_URL names another (https://, or http:// for one on
55
+ this machine). With MASON_TOKEN set (CI), every command uses that token before a saved login, so mason login is
56
+ refused: run it without the variable (env -u MASON_TOKEN mason login) to sign in anyway.
57
+
58
+ Exit status: 0 signed in; 1 refused or failed (said); 2 a wrong command line; 124 the code expired before it
59
+ was approved (run mason login again).
60
+ `,
61
+ options: { json: { type: "boolean", default: false } },
62
+ json: ["service", "email", "name", "orgs", "file", "replaced", "next"],
63
+ };
64
+ export const LOGOUT = {
65
+ name: "logout",
66
+ summary: "sign this machine out of a hosted Mason service: its login revoked and removed",
67
+ usage: `usage: mason logout [--json]
68
+
69
+ Signs this machine out of the hosted Mason service (MASON_CLOUD_URL, else ${DEFAULT_SERVICE_URL}): it revokes
70
+ the login on the service, then removes it from ~/.config/mason/credentials.json. When the service cannot be
71
+ reached the login is still removed here, and it says the login was NOT revoked and how to revoke it: sign in to
72
+ the service's web app and revoke it there. Logins to other services stay.
73
+
74
+ mason logout
75
+ MASON_CLOUD_URL=http://127.0.0.1:8080 mason logout a service on this machine
76
+
77
+ --json as data: its shape is at the end
78
+
79
+ MASON_TOKEN in the environment is not a saved login: logout cannot remove it, and says so while it is set.
80
+
81
+ The --json answer: { service, signedOut (a saved login was removed), email (its account, null when there was
82
+ none), loginRevoked (true, false when the service did not take it, null when there was nothing to
83
+ revoke), revoked (the same, kept until 1.0), revokeError (why not, else null), environmentToken
84
+ (MASON_TOKEN is set, and still used), file, next }
85
+ `,
86
+ options: { json: { type: "boolean", default: false } },
87
+ json: ["service", "signedOut", "email", "loginRevoked", "revoked", "revokeError", "environmentToken", "file", "next"],
88
+ };
89
+ /** An organisation as a line: its slug, its name when it says more, the role. */
90
+ const orgLine = (o) => `${o.slug}${o.name && o.name !== o.slug ? ` (${o.name})` : ""}, ${o.role}`;
91
+ export async function login(argv) {
92
+ const f = parseCommand(LOGIN, argv);
93
+ if (typeof f === "number")
94
+ return f;
95
+ const json = f.values.json === true;
96
+ if (f.positionals.length)
97
+ 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 });
98
+ const service = serviceOf();
99
+ if (isFailure(service))
100
+ return fail("login", service, { json });
101
+ if (environmentToken() !== null) {
102
+ return fail("login", { message: `${TOKEN_ENV} is set in this environment, so a login would not be used`, code: "refused",
103
+ why: `every command sends ${TOKEN_ENV} before a saved login: a new login to ${service.base} would be shadowed by it`,
104
+ 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)`,
105
+ next: [[`env -u ${TOKEN_ENV} mason login`, "sign in, for when MASON_TOKEN is unset"]] }, { json });
106
+ }
107
+ // the file is checked BEFORE the person spends a minute approving a code: a folder others can read is said now
108
+ const before = readLogins();
109
+ if (isFailure(before))
110
+ return fail("login", before, { json });
111
+ // what this command holds that the service may echo before it sends a token: the login it replaces (src/cli/hosted.ts
112
+ // `carriesNoSecret`: an answer that carries it is refused whole)
113
+ const held = before.logins.logins[service.base] ? [before.logins.logins[service.base].token] : [];
114
+ // and from here every byte written is put to the exact test against what it holds (src/cli/output-gate.ts)
115
+ holdOutput({ command: "login", json, secrets: held });
116
+ const code = await startDevice(service, held);
117
+ if (isFailure(code))
118
+ return fail("login", code, { json });
119
+ const token = await approved(service, code, json, held);
120
+ if (isFailure(token))
121
+ return fail("login", token, { json });
122
+ holdOutput({ command: "login", json, secrets: [token] });
123
+ // FROM HERE THE SERVICE HAS GRANTED A TOKEN, which is kept only once it is saved: on every failure before that —
124
+ // one said, or one thrown (PR 645's review: a crash left a granted token live) — it is revoked, and a revoke that
125
+ // did not reach the service is said
126
+ let kept;
127
+ try {
128
+ kept = await keep(service, token, held);
129
+ }
130
+ catch (e) {
131
+ const notRevoked = await revoke(service, token);
132
+ if (notRevoked === null)
133
+ throw e;
134
+ 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 });
135
+ }
136
+ if (isFailure(kept)) {
137
+ const notRevoked = await revoke(service, token);
138
+ 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 });
139
+ }
140
+ const { me, was, saved } = kept;
141
+ // the login it replaced is revoked, so this machine never leaves a token it no longer holds alive on the service
142
+ const replaced = was === null || was.token === token ? null : { email: serviceText(was.email, 320, [token, was.token]), revokeError: await revoke(service, was.token) };
143
+ // what the service said, as it may be shown, in the report and in --json alike: printable, bounded, no token in it
144
+ const secrets = [token, ...(was ? [was.token] : [])];
145
+ const email = serviceText(me.email, 320, secrets), name = serviceText(me.name, 300, secrets);
146
+ const orgs = me.orgs.map((o) => ({ slug: serviceText(o.slug, 200, secrets), name: serviceText(o.name, 200, secrets), role: serviceText(o.role, 32, secrets) }));
147
+ const next = [["mason logout", `sign this machine out of ${service.base}`]];
148
+ if (json) {
149
+ out(jsonText(LOGIN, { service: service.base, email, name, orgs, file: saved.file,
150
+ replaced: replaced === null ? null : { email: replaced.email, revoked: replaced.revokeError === null } }, next));
151
+ return 0;
152
+ }
153
+ out(reportText({
154
+ headline: `Signed in to ${service.base} as ${email}`,
155
+ details: [
156
+ ...(name ? [["name", name]] : []),
157
+ ...(orgs.length ? orgs.map((o, i) => [i === 0 ? "orgs" : "", orgLine(o)]) : [["orgs", "none yet"]]),
158
+ ["saved", saved.file],
159
+ ],
160
+ tips: replaced === null ? undefined : replaced.revokeError === null
161
+ ? [`This replaces the login of ${replaced.email}, which is revoked.`]
162
+ : [`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.`],
163
+ next,
164
+ }, outStyle()));
165
+ return 0;
166
+ }
167
+ /**
168
+ * WHAT A GRANTED TOKEN NEEDS BEFORE IT IS KEPT: who signed in (a token the service will not say who it is is never
169
+ * kept), the logins read again just before the write (so a login another command saved meanwhile is kept), and the
170
+ * write. A Failure when any is not had; the caller revokes the token then.
171
+ */
172
+ async function keep(service, token, held) {
173
+ const me = await whoAmI(service, token, held);
174
+ if (isFailure(me))
175
+ return me;
176
+ const now = readLogins();
177
+ if (isFailure(now))
178
+ return now;
179
+ const was = now.logins.logins[service.base] ?? null;
180
+ const logins = { version: 1, logins: { ...now.logins.logins, [service.base]: { token, email: me.email, savedAt: new Date().toISOString() } } };
181
+ const saved = writeLogins(logins);
182
+ if (isFailure(saved))
183
+ return saved;
184
+ return { me, was, saved };
185
+ }
186
+ /**
187
+ * The page and the code said, then the wait: a poll every `interval` seconds (five more at each slow_down, RFC 8628
188
+ * 3.5), until the token, a refusal, or the code's expiry. On a terminal a spinner turns beside the wait; elsewhere the
189
+ * wait is one line.
190
+ */
191
+ async function approved(service, code, json, held) {
192
+ const page = code.verificationUriComplete ?? code.verificationUri;
193
+ const s = errStyle();
194
+ say(reportText({ headline: `To sign in to ${service.base}, open this page and approve the code:`,
195
+ details: [["page", page], ["code", code.verificationUriComplete ? `${code.userCode} (the page shows it: check they match)` : `${code.userCode} (enter it on the page)`]] }, s));
196
+ const terminal = !json && process.stderr.isTTY === true;
197
+ if (terminal) {
198
+ const couldNot = OPENABLE.test(page) ? await openBrowser(page) : "the page's address is not one Mason opens by itself";
199
+ if (couldNot !== null)
200
+ say(endText({ tips: [`Could not open a browser (${couldNot}): open the page yourself.`] }, s).replace(/^\n/, ""));
201
+ }
202
+ const minutes = Math.max(1, Math.round(code.expiresIn / 60));
203
+ const full = `Waiting for the code to be approved (it expires in ${minutes} min; Ctrl-C stops)`;
204
+ // a spinner's line is redrawn in place, so it must never reach the terminal's edge (src/cli/command.ts serviceStyle):
205
+ // the longest label that fits beside its frame, else no spinner and the wait said once, broken as any line
206
+ const room = (process.stderr.columns ?? 0) - 1 - 6;
207
+ const label = terminal ? [full, "Waiting for approval", "Waiting"].find((l) => l.length <= room) : undefined;
208
+ const live = label !== undefined;
209
+ const steps = stepRenderer({ line: (t) => say(strict(t, s)), live: live ? process.stderr : null, style: s });
210
+ if (live)
211
+ steps.begin(label);
212
+ else
213
+ say(strict(`${full} …`, s));
214
+ const deadline = Date.now() + code.expiresIn * 1000;
215
+ let interval = code.interval;
216
+ for (;;) {
217
+ if (Date.now() + interval * 1000 > deadline) {
218
+ if (live)
219
+ steps.fail();
220
+ return expired(code);
221
+ }
222
+ await sleep(interval * 1000);
223
+ const p = await pollToken(service, code, held);
224
+ if ("pending" in p)
225
+ continue;
226
+ if ("slowDown" in p) {
227
+ interval += 5;
228
+ continue;
229
+ }
230
+ if (isFailure(p)) {
231
+ if (live)
232
+ steps.fail();
233
+ return p;
234
+ }
235
+ if (live)
236
+ steps.done("Approved");
237
+ else
238
+ say("Approved");
239
+ return p.token;
240
+ }
241
+ }
242
+ export async function logout(argv) {
243
+ const f = parseCommand(LOGOUT, argv);
244
+ if (typeof f === "number")
245
+ return f;
246
+ const json = f.values.json === true;
247
+ if (f.positionals.length)
248
+ 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 });
249
+ const service = serviceOf();
250
+ if (isFailure(service))
251
+ return fail("logout", service, { json });
252
+ const fromEnv = environmentToken() !== null;
253
+ 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.`] : [];
254
+ const read = readLogins();
255
+ if (isFailure(read))
256
+ return fail("logout", read, { json });
257
+ const entry = read.logins.logins[service.base];
258
+ const env = environmentToken();
259
+ if (entry !== undefined || env !== null)
260
+ holdOutput({ command: "logout", json, secrets: [...(entry ? [entry.token] : []), ...(env !== null ? [env] : [])] });
261
+ if (entry === undefined) {
262
+ const next = [["mason login", `sign this machine in to ${service.base}`]];
263
+ if (json) {
264
+ out(jsonText(LOGOUT, { service: service.base, signedOut: false, email: null, loginRevoked: null, revoked: null, revokeError: null, environmentToken: fromEnv, file: read.file }, next));
265
+ return 0;
266
+ }
267
+ out(reportText({ headline: `This machine is not signed in to ${service.base}`, details: [["file", read.file]], tips: envTip, next }, outStyle()));
268
+ return 0;
269
+ }
270
+ const revokeError = await revoke(service, entry.token);
271
+ // removed whether or not the service took the revoke: a person who logs out means this machine to hold it no more
272
+ const rest = { ...read.logins.logins };
273
+ delete rest[service.base];
274
+ const written = writeLogins({ version: 1, logins: rest });
275
+ if (isFailure(written))
276
+ return fail("logout", written, { json });
277
+ const email = serviceText(entry.email, 320, [entry.token]);
278
+ const next = [["mason login", `sign in to ${service.base} again`]];
279
+ if (json) {
280
+ out(jsonText(LOGOUT, { service: service.base, signedOut: true, email, loginRevoked: revokeError === null, revoked: revokeError === null, revokeError, environmentToken: fromEnv, file: written.file }, next));
281
+ return 0;
282
+ }
283
+ const notRevoked = revokeError === null ? undefined
284
+ : `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.`;
285
+ out(reportText({
286
+ headline: revokeError === null ? `Signed out of ${service.base}` : `Signed out of ${service.base} on this machine, but the login was NOT revoked`,
287
+ details: [["account", email], ["revoked", revokeError === null ? "yes: the service takes this login no more" : "no"], ["removed from", written.file]],
288
+ tips: [...(notRevoked ? [notRevoked] : []), ...envTip],
289
+ next,
290
+ }, outStyle()));
291
+ return 0;
292
+ }