@mason-data/cli 0.9.0 → 0.11.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 (56) hide show
  1. package/README.md +6 -4
  2. package/dist/src/api/error.js +4 -0
  3. package/dist/src/api/postgres/proxy.js +6 -160
  4. package/dist/src/api/postgres/server.js +22 -4
  5. package/dist/src/cli/command.js +169 -5
  6. package/dist/src/cli/commands.js +295 -159
  7. package/dist/src/cli/deploy.js +134 -28
  8. package/dist/src/cli/dev.js +21 -5
  9. package/dist/src/cli/doctor.js +14 -11
  10. package/dist/src/cli/guides.js +20 -11
  11. package/dist/src/cli/hosted.js +1 -1
  12. package/dist/src/cli/init.js +110 -25
  13. package/dist/src/cli/login.js +20 -17
  14. package/dist/src/cli/machine.js +125 -40
  15. package/dist/src/cli/main.js +51 -8
  16. package/dist/src/cli/mcp.js +15 -12
  17. package/dist/src/cli/project.js +25 -2
  18. package/dist/src/cli/registry.js +80 -35
  19. package/dist/src/cli/replica-hosted.js +761 -102
  20. package/dist/src/cli/replica.js +43 -5
  21. package/dist/src/cli/replication-mode.js +5 -0
  22. package/dist/src/cli/request-filter.js +16 -6
  23. package/dist/src/cli/run-server.js +70 -31
  24. package/dist/src/cli/secret-input.js +193 -0
  25. package/dist/src/cli/security.js +7 -2
  26. package/dist/src/cli/serve.js +119 -9
  27. package/dist/src/cli/share.js +20 -15
  28. package/dist/src/cli/source-role.js +14 -15
  29. package/dist/src/cli/sources.js +20 -3
  30. package/dist/src/cli/style.js +14 -0
  31. package/dist/src/cli/sync.js +17 -8
  32. package/dist/src/cli/token-hosted.js +30 -24
  33. package/dist/src/cli/tunnel.js +1 -1
  34. package/dist/src/cli/ui-hosted.js +163 -0
  35. package/dist/src/cli/ui-read-only.js +23 -0
  36. package/dist/src/cli/ui-run-file.js +247 -0
  37. package/dist/src/cli/ui-socket.js +183 -0
  38. package/dist/src/cli/ui-ssh.js +3 -3
  39. package/dist/src/cli/ui.js +67 -26
  40. package/dist/src/cli/usage.js +19 -15
  41. package/dist/src/cli/user-hosted.js +78 -13
  42. package/dist/src/cli/user.js +168 -50
  43. package/dist/src/duck/users.js +105 -9
  44. package/dist/src/lib/cidr.js +177 -0
  45. package/dist/src/lib/table-sync.js +69 -19
  46. package/dist/src/web/app.css +5 -0
  47. package/dist/src/web/app.js +4 -4
  48. package/dist/src/web/index.html +5 -8
  49. package/dist/src/web/pages/queries.js +3 -3
  50. package/dist/src/web/pages/status.js +23 -17
  51. package/dist/src/web/pages/syncs.js +3 -3
  52. package/dist/src/web/pages/tables.js +18 -15
  53. package/dist/src/web/pages/usage.js +16 -12
  54. package/dist/src/web/pages/users.js +45 -14
  55. package/dist/src/web/shared.js +10 -4
  56. package/package.json +6 -6
@@ -38,7 +38,7 @@ import { databaseNameProblem, DEFAULT_DATABASE } from "../lib/database-name.js";
38
38
  import { DEFAULT_SYNC, syncText } from "../lib/table-sync.js";
39
39
  import { newRolesText, rolesFile } from "./roles.js";
40
40
  import { newSourceText, OLD_TABLES_DIR, parseSourceText, shownPath, sourceFileNames, sourceNameFor, sourceOf, sourcesDir } from "./sources.js";
41
- export const INIT_USAGE = `usage: mason init [<postgres url> | demo] [--name <name>] [--database <name>] [--folder <dir>] [--remote] [--skip-check] [--json]
41
+ export const INIT_USAGE = `usage: mason init [- | <postgres url> | demo] [--name <name>] [--database <name>] [--folder <dir>] [--remote] [--skip-check] [--json]
42
42
 
43
43
  Makes this folder a Mason project — run it in your app's folder. It checks it can read the database (and writes
44
44
  nothing when it cannot), then creates:
@@ -47,6 +47,21 @@ export const INIT_USAGE = `usage: mason init [<postgres url> | demo] [--name <na
47
47
  mason/roles.yml who may read what: one role, everything (tables: all), the default of a user added
48
48
  without --role, so every user reads every synced table until you narrow it — commit it
49
49
  mason/mason.yml the Mason version the project is written for — commit it: production runs that version
50
+
51
+ mason init MASON_DATABASE_URL, else DATABASE_URL (.env or the shell)
52
+ mason init - < db-url.txt the URL on stdin, written to .env, never on the command line
53
+ mason init demo a demo database Mason hosts itself: nothing to install
54
+ mason init --folder db/mason --name shop-admin the project's files elsewhere; another name
55
+ mason init --remote the database in .env is on another machine
56
+
57
+ --name <name> the project's name, which its replication slot is named for (default: this folder's)
58
+ --database <name> the database name a tool connects with, written as database: in the source file (default: none
59
+ written, so tools connect with ${DEFAULT_DATABASE}): your database's own, and a tool keeps the name it has
60
+ --folder <dir> where sources/ goes, relative to this folder (default mason; . for this folder itself)
61
+ --remote go ahead with a database on another machine (read the warnings mason init prints); not with demo
62
+ --skip-check write the project without connecting (the database is not up yet); mason start checks it; not with demo
63
+ --json as data: its shape is at the end
64
+
50
65
  With no URL it reads MASON_DATABASE_URL, else your app's DATABASE_URL, each from .env.local, then .env, then the
51
66
  shell. The source file names the variable read, url: \${DATABASE_URL} — \${MASON_DATABASE_URL} when that is the one
52
67
  read, or when a URL given here is another database than your app's DATABASE_URL: the URL itself is in .env (written
@@ -54,6 +69,9 @@ export const INIT_USAGE = `usage: mason init [<postgres url> | demo] [--name <na
54
69
  .gitignore. Every other mason command then works anywhere in this folder. A source file already here (a clone of the
55
70
  project) is kept, and a bare mason init reads the variable it names; a roles file already here is kept. In a project
56
71
  with no source file (an older Mason's), it writes that file alone, and the roles file when there is none.
72
+ mason init - reads the URL from stdin (one line, from a pipe), so its password stays off the command line, where ps
73
+ shows it to this machine's users and your shell keeps it in its history: a URL with a password given as the argument
74
+ is still taken, with a warning on stderr.
57
75
  mason init demo, in a new folder, makes a project on a demo database Mason hosts itself (a shop: customers, products,
58
76
  orders, order_items): mason start makes it in .mason/demo/ and changes it every few seconds. Three of its tables are
59
77
  added; the fourth is yours to add after mason start (mason add ${DEMO_TABLE_TO_ADD}), to watch its first sync begin.
@@ -61,27 +79,15 @@ export const INIT_USAGE = `usage: mason init [<postgres url> | demo] [--name <na
61
79
  revenue per day and status, kept current: mason wait ${DEMO_GENERATED.table} once Mason is up; mason help generated); one
62
80
  already there is kept.
63
81
 
64
- mason init MASON_DATABASE_URL, else DATABASE_URL (.env or the shell)
65
- mason init postgres://me:secret@localhost:5432/shop
66
- mason init demo a demo database Mason hosts itself: nothing to install
67
- mason init --folder db/mason --name shop-admin the project's files elsewhere; another name
68
- mason init postgres://me:secret@db.example.com/shop --remote
69
-
70
- --name <name> the project's name, which its replication slot is named for (default: this folder's)
71
- --database <name> the database name a tool connects with, written as database: in the source file (default: none
72
- written, so tools connect with ${DEFAULT_DATABASE}): your database's own, and a tool keeps the name it has
73
- --folder <dir> where sources/ goes, relative to this folder (default mason; . for this folder itself)
74
- --remote go ahead with a database on another machine (read the warnings mason init prints); not with demo
75
- --skip-check write the project without connecting (the database is not up yet); mason start checks it; not with demo
76
- --json as data: { name, root, folder, config, demo (true for the demo database's project, else false),
77
- source: { name (the source's: its file's), file, url (as the file writes it), database (the name
78
- a tool connects with, or null when the file has a mistake), env (the .env line written, its value masked, or null) }, roles: { file, written (false: one was here, and kept) },
79
- generated: [{ table, file, written (false: one was here, and kept) }] (the demo's generated table; [] for
80
- any other project), database: { url (password masked), from, version, tables }, warnings, next }
82
+ The --json answer: { name, root, folder, config, demo (true for the demo database's project, else false),
83
+ source: { name (the source's: its file's), file, url (as the file writes it), database (the name
84
+ a tool connects with, or null when the file has a mistake), env (the .env line written, its value masked, or null) }, roles: { file, written (false: one was here, and kept) },
85
+ generated: [{ table, file, written (false: one was here, and kept) }] (the demo's generated table; [] for
86
+ any other project), database: { url (password masked), from, version, tables }, warnings, next }
81
87
  `;
82
88
  export const INIT = {
83
89
  name: "init",
84
- summary: "make this folder a Mason project on your Postgres: mason init (its URL from .env), mason init postgres://… or demo",
90
+ summary: "make this folder a Mason project on your Postgres: mason init (its URL from .env), mason init - (from stdin) or demo",
85
91
  usage: INIT_USAGE,
86
92
  options: { name: { type: "string" }, database: { type: "string" }, folder: { type: "string" }, remote: { type: "boolean" }, "skip-check": { type: "boolean" }, json: { type: "boolean" } },
87
93
  json: ["name", "root", "folder", "config", "demo", "source", "roles", "generated", "database", "warnings", "next"],
@@ -162,6 +168,63 @@ export function urlVariable(root, url, found = null) {
162
168
  const addEnvLine = (root, line) => appendEnvLine(root, line, "the database Mason syncs from (mason/sources/ names it; never commit this file)");
163
169
  /** A `.env` line as it is said: its value's password masked. */
164
170
  const maskedLine = (line) => { const eq = line.indexOf("="); return `${line.slice(0, eq)}=${maskedUrl(line.slice(eq + 1))}`; };
171
+ /** The argument that reads the URL from stdin: `mason init -`. */
172
+ export const STDIN_ARG = "-";
173
+ /** The most of stdin `mason init -` reads (a URL is far shorter), and how long it waits for a pipe left open and silent. */
174
+ const STDIN_MAX_BYTES = 8192, STDIN_TIMEOUT_MS = 10_000;
175
+ /**
176
+ * THE URL FROM A PIPE (`mason init -`): stdin read whole, bounded in bytes and in time, one trailing newline trimmed;
177
+ * a terminal, nothing, more than one line, too much or nothing in time is said by kind — never by what was read, which
178
+ * may hold a password.
179
+ */
180
+ export function readPipedUrl(input, max = STDIN_MAX_BYTES, timeoutMs = STDIN_TIMEOUT_MS) {
181
+ const way = "pipe one line into it (mason init - < db-url.txt), or put the URL in .env as DATABASE_URL=… and run mason init";
182
+ if (input.isTTY === true)
183
+ return Promise.resolve({ problem: `mason init - reads the URL from a pipe, and stdin is a terminal: ${way}` });
184
+ return new Promise((resolve) => {
185
+ const chunks = [];
186
+ let size = 0, ended = false;
187
+ const finish = (r) => {
188
+ if (ended)
189
+ return;
190
+ ended = true;
191
+ clearTimeout(timer);
192
+ input.removeListener("data", onData);
193
+ input.removeListener("end", onEnd);
194
+ input.removeListener("error", onError);
195
+ input.pause();
196
+ resolve(r);
197
+ };
198
+ const onData = (c) => {
199
+ const b = typeof c === "string" ? Buffer.from(c) : c;
200
+ size += b.length;
201
+ if (size > max) {
202
+ finish({ problem: `stdin held more than ${max} bytes, which is no URL: ${way}` });
203
+ return;
204
+ }
205
+ chunks.push(b);
206
+ };
207
+ const onEnd = () => {
208
+ const text = Buffer.concat(chunks).toString("utf8").replace(/\r?\n$/, "");
209
+ if (text.trim() === "")
210
+ finish({ problem: `stdin was empty: ${way}` });
211
+ else if (/[\r\n]/.test(text))
212
+ finish({ problem: `stdin held more than one line: ${way}` });
213
+ else
214
+ finish({ url: text.trim() });
215
+ };
216
+ const onError = (e) => finish({ problem: `stdin could not be read (${e.message}): ${way}` });
217
+ const timer = setTimeout(() => finish({ problem: `nothing ended on stdin within ${timeoutMs / 1000} s: ${way}` }), timeoutMs);
218
+ input.on("data", onData);
219
+ input.on("end", onEnd);
220
+ input.on("error", onError);
221
+ input.resume();
222
+ });
223
+ }
224
+ /** The warning for a URL whose password was given as the argument, naming the safer forms — `variable` the one the
225
+ * sources file would name (DATABASE_URL, or MASON_DATABASE_URL for a database other than the app's) — never the password. */
226
+ export const argvPasswordWarning = (variable) => "the URL given on the command line holds its password, which ps shows to this machine's users and your shell keeps in its history: " +
227
+ `put it in .env as ${variable}=… and run mason init, or pipe it in with mason init - < db-url.txt`;
165
228
  export async function init(argv, cwd = process.cwd(), deps = {}) {
166
229
  const io = deps.io ?? STDIO;
167
230
  const { out: say } = io;
@@ -247,7 +310,19 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
247
310
  const keptFile = kept === null || keptPath === null ? null : parseSourceText(fs.readFileSync(keptPath, "utf8"), kept.replace(/\.ya?ml$/, ""), keptPath);
248
311
  const keptVar = keptFile !== null && !("problems" in keptFile) && "env" in keptFile.url ? keptFile.url.env : null;
249
312
  // the demo's URL names a port free now, and is the one place the port is kept: mason start reads it from there
250
- let url = demo ? demoUrl(await freePort(DEMO_PORT)) : f.positionals[0] ?? null;
313
+ // `mason init -`: the URL from a pipe, so its password never reaches the command line (ps, the shell's history)
314
+ const piped = f.positionals[0] === STDIN_ARG;
315
+ let url;
316
+ if (demo)
317
+ url = demoUrl(await freePort(DEMO_PORT));
318
+ else if (piped) {
319
+ const r = await readPipedUrl(deps.stdin ?? process.stdin);
320
+ if ("problem" in r)
321
+ return failed("init", { message: r.problem, code: "usage", usage: INIT_USAGE }, { json, io });
322
+ url = r.url;
323
+ }
324
+ else
325
+ url = f.positionals[0] ?? null;
251
326
  let from = "";
252
327
  let envFile = null; // the folder's file the URL was read from (.env.local or .env): ignored with .mason/
253
328
  let envVariable = null; // the variable envFile holds the URL in, set with it: the warning names it
@@ -263,8 +338,8 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
263
338
  if (v === null) {
264
339
  return failed("init", { message: `which database? ${file} names url: \${${keptVar}}, and this folder has no ${keptVar}`, code: "usage",
265
340
  why: `mason start reads the URL from ${keptVar}, so mason init checks that one`,
266
- fix: `put it in this folder's .env as ${keptVar}=postgres://… (the variable ${file} names), or give its URL on the command line: mason init writes it there`,
267
- next: [["mason init postgres://user:password@localhost:5432/your_app", `its URL, given: written to .env as ${keptVar}`], ["mason init", `again, once .env has ${keptVar}`]] }, { json, io });
341
+ fix: `put it in this folder's .env as ${keptVar}=postgres://… (the variable ${file} names), or pipe its URL in (mason init - < db-url.txt): mason init writes it there`,
342
+ next: [["mason init - < db-url.txt", `its URL on stdin: written to .env as ${keptVar}`], ["mason init", `again, once .env has ${keptVar}`]] }, { json, io });
268
343
  }
269
344
  found = { url: v.value, variable: keptVar, from: `${keptVar} in ${v.from}`, file: v.file };
270
345
  }
@@ -273,8 +348,8 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
273
348
  if (!found) {
274
349
  // the fixes ARE the next commands, so a coding agent reads them as such too
275
350
  return failed("init", { message: "which database? none is named, and this folder has no MASON_DATABASE_URL or DATABASE_URL", code: "usage",
276
- fix: "give its URL on the command line, or put it in this folder's .env as DATABASE_URL=postgres://… (your app's, usually) or as MASON_DATABASE_URL=postgres://… (read first: a database other than your app's)",
277
- next: [["mason init postgres://user:password@localhost:5432/your_app", "its URL, given"], ["mason init", "again, once .env has MASON_DATABASE_URL or DATABASE_URL"],
351
+ fix: "pipe its URL in (mason init - < db-url.txt), or put it in this folder's .env as DATABASE_URL=postgres://… (your app's, usually) or as MASON_DATABASE_URL=postgres://… (read first: a database other than your app's)",
352
+ next: [["mason init - < db-url.txt", "its URL on stdin: written to .env"], ["mason init", "again, once .env has MASON_DATABASE_URL or DATABASE_URL"],
278
353
  ["mason init demo", "a demo database Mason makes itself: nothing to install"]] }, { json, io });
279
354
  }
280
355
  url = found.url;
@@ -283,15 +358,22 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
283
358
  envVariable = found.variable;
284
359
  }
285
360
  // a problem names where the URL is (DATABASE_URL in .env, the URL given), never the value (src/cli/preflight.ts shownUrl)
286
- const urlName = found !== null ? found.from : demo ? "the demo database's URL" : "the URL given";
361
+ const urlName = found !== null ? found.from : demo ? "the demo database's URL" : piped ? "the URL read from stdin" : "the URL given";
287
362
  const parsed = parseSource(url, urlName);
288
363
  if ("problem" in parsed)
289
364
  return usage(parsed.problem);
365
+ // A PASSWORD ON THE COMMAND LINE: taken (refusing it would break scripts), and said on stderr with the safer forms;
366
+ // the password itself is never repeated
367
+ const argvPassword = !demo && !piped && found === null && parsed.source.password !== "";
290
368
  const remote = f.values.remote === true || sourceless?.config.remote === true;
291
369
  // a kept file's variable gets this URL in .env when the folder's files give it none; one they give another value is said
292
370
  const keptValue = keptVar === null ? null : appEnvValue(root, keptVar, {});
293
371
  const where = kept === null ? urlVariable(root, url, found)
294
372
  : { variable: keptVar ?? "", line: keptVar !== null && keptValue === null ? `${keptVar}=${url}` : null };
373
+ // the variable .env would hold it in: the one the sources file names (MASON_DATABASE_URL for another database than the app's)
374
+ const argvWarning = argvPassword ? argvPasswordWarning("problem" in where || where.variable === "" ? "DATABASE_URL" : where.variable) : null;
375
+ if (argvWarning !== null)
376
+ io.err(`! ${argvWarning}`);
295
377
  if ("problem" in where)
296
378
  return failed("init", { message: where.problem, code: "refused" }, { json, io });
297
379
  // WHAT IT DOES, AS A CHECKLIST (each item it is doing, with a spinner left of it that turns into a checkmark when
@@ -304,6 +386,9 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
304
386
  const db = maskedUrl(parsed.source.url);
305
387
  line(st.bold(`Making ${name} a Mason project in ${root}`));
306
388
  const notes = [];
389
+ // a person read the warning on stderr already; an agent reads warnings in the answer too
390
+ if (argvWarning !== null && json)
391
+ notes.push(argvWarning);
307
392
  if (keptValue !== null && keptValue.value !== url)
308
393
  notes.push(`${kept} reads ${keptVar} from ${keptValue.from}, which is ${maskedUrl(keptValue.value)}: mason start syncs from that one, not ${db}`);
309
394
  let database = { url: db, from: from ? from.slice(2, -1) : null, version: null, tables: null };
@@ -23,7 +23,7 @@ import { environmentToken, readLogins, TOKEN_ENV, writeLogins } from "./credenti
23
23
  import { DEFAULT_SERVICE_URL, expired, isFailure, pollToken, revoke, serviceOf, serviceText, startDevice, whoAmI } from "./hosted.js";
24
24
  import { err as styleErr, out as styleOut, stepRenderer } from "./style.js";
25
25
  import { holdOutput } from "./output-gate.js";
26
- import { openBrowser } from "./ui.js";
26
+ import { OPENABLE, openBrowser } from "./ui.js";
27
27
  const out = (m) => { process.stdout.write(`${m}\n`); };
28
28
  /** Everything these commands print can hold the service's text, so it is drawn in serviceStyle (src/cli/command.ts):
29
29
  * on a terminal the CLI breaks every line itself, and a person never sees a line begin with what the service sent. */
@@ -42,6 +42,12 @@ export const LOGIN = {
42
42
  ~/.config/mason/credentials.json ($XDG_CONFIG_HOME/mason/ when that is set), readable by you alone, never in a
43
43
  project. Signing in again replaces this machine's login to that service, and revokes the one it replaces.
44
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
+
45
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
46
52
  before the wait ends; the answer is printed when it ends.
47
53
 
@@ -49,11 +55,8 @@ export const LOGIN = {
49
55
  this machine). With MASON_TOKEN set (CI), every command uses that token before a saved login, so mason login is
50
56
  refused: run it without the variable (env -u MASON_TOKEN mason login) to sign in anyway.
51
57
 
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 }
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).
57
60
  `,
58
61
  options: { json: { type: "boolean", default: false } },
59
62
  json: ["service", "email", "name", "orgs", "file", "replaced", "next"],
@@ -68,21 +71,21 @@ export const LOGOUT = {
68
71
  reached the login is still removed here, and it says the login was NOT revoked and how to revoke it: sign in to
69
72
  the service's web app and revoke it there. Logins to other services stay.
70
73
 
71
- MASON_TOKEN in the environment is not a saved login: logout cannot remove it, and says so while it is set.
72
-
73
74
  mason logout
74
75
  MASON_CLOUD_URL=http://127.0.0.1:8080 mason logout a service on this machine
75
76
 
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 }
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 }
79
85
  `,
80
86
  options: { json: { type: "boolean", default: false } },
81
- json: ["service", "signedOut", "email", "revoked", "revokeError", "environmentToken", "file", "next"],
87
+ json: ["service", "signedOut", "email", "loginRevoked", "revoked", "revokeError", "environmentToken", "file", "next"],
82
88
  };
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
89
  /** An organisation as a line: its slug, its name when it says more, the role. */
87
90
  const orgLine = (o) => `${o.slug}${o.name && o.name !== o.slug ? ` (${o.name})` : ""}, ${o.role}`;
88
91
  export async function login(argv) {
@@ -258,7 +261,7 @@ export async function logout(argv) {
258
261
  if (entry === undefined) {
259
262
  const next = [["mason login", `sign this machine in to ${service.base}`]];
260
263
  if (json) {
261
- out(jsonText(LOGOUT, { service: service.base, signedOut: false, email: null, revoked: null, revokeError: null, environmentToken: fromEnv, file: read.file }, next));
264
+ out(jsonText(LOGOUT, { service: service.base, signedOut: false, email: null, loginRevoked: null, revoked: null, revokeError: null, environmentToken: fromEnv, file: read.file }, next));
262
265
  return 0;
263
266
  }
264
267
  out(reportText({ headline: `This machine is not signed in to ${service.base}`, details: [["file", read.file]], tips: envTip, next }, outStyle()));
@@ -274,7 +277,7 @@ export async function logout(argv) {
274
277
  const email = serviceText(entry.email, 320, [entry.token]);
275
278
  const next = [["mason login", `sign in to ${service.base} again`]];
276
279
  if (json) {
277
- out(jsonText(LOGOUT, { service: service.base, signedOut: true, email, revoked: revokeError === null, revokeError, environmentToken: fromEnv, file: written.file }, next));
280
+ out(jsonText(LOGOUT, { service: service.base, signedOut: true, email, loginRevoked: revokeError === null, revoked: revokeError === null, revokeError, environmentToken: fromEnv, file: written.file }, next));
278
281
  return 0;
279
282
  }
280
283
  const notRevoked = revokeError === null ? undefined
@@ -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, src.from);
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
  }