@mason-data/cli 0.0.0-stage → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +268 -2
  3. package/dist/src/api/error.js +18 -0
  4. package/dist/src/api/postgres/catalog-worker.js +92 -0
  5. package/dist/src/api/postgres/catalog.js +477 -0
  6. package/dist/src/api/postgres/encode.js +541 -0
  7. package/dist/src/api/postgres/explain.js +143 -0
  8. package/dist/src/api/postgres/listener.js +60 -0
  9. package/dist/src/api/postgres/parser-worker.js +58 -0
  10. package/dist/src/api/postgres/parser.js +121 -0
  11. package/dist/src/api/postgres/plain.js +33 -0
  12. package/dist/src/api/postgres/protocol.js +404 -0
  13. package/dist/src/api/postgres/schema.js +54 -0
  14. package/dist/src/api/postgres/server.js +1771 -0
  15. package/dist/src/api/postgres/translate.js +248 -0
  16. package/dist/src/api/request-log.js +50 -0
  17. package/dist/src/cdc/pg-wire.js +588 -0
  18. package/dist/src/cdc/pgoutput.js +107 -0
  19. package/dist/src/cdc/poll.js +67 -0
  20. package/dist/src/cdc/source.js +65 -0
  21. package/dist/src/cli/background.js +198 -0
  22. package/dist/src/cli/command.js +173 -0
  23. package/dist/src/cli/commands.js +1277 -0
  24. package/dist/src/cli/control.js +36 -0
  25. package/dist/src/cli/demo.js +149 -0
  26. package/dist/src/cli/dev.js +669 -0
  27. package/dist/src/cli/doctor.js +260 -0
  28. package/dist/src/cli/guides.js +158 -0
  29. package/dist/src/cli/init.js +366 -0
  30. package/dist/src/cli/machine.js +162 -0
  31. package/dist/src/cli/main.js +83 -0
  32. package/dist/src/cli/mcp.js +726 -0
  33. package/dist/src/cli/pg-tls.js +100 -0
  34. package/dist/src/cli/pg-url.js +52 -0
  35. package/dist/src/cli/pin.js +36 -0
  36. package/dist/src/cli/ports.js +118 -0
  37. package/dist/src/cli/preflight.js +347 -0
  38. package/dist/src/cli/project.js +129 -0
  39. package/dist/src/cli/readiness.js +76 -0
  40. package/dist/src/cli/registry.js +220 -0
  41. package/dist/src/cli/replication-checks.js +120 -0
  42. package/dist/src/cli/replication-mode.js +148 -0
  43. package/dist/src/cli/run-server.js +311 -0
  44. package/dist/src/cli/runs.js +120 -0
  45. package/dist/src/cli/security.js +86 -0
  46. package/dist/src/cli/self-signed-cert.js +64 -0
  47. package/dist/src/cli/serve.js +301 -0
  48. package/dist/src/cli/source-edit.js +116 -0
  49. package/dist/src/cli/sources.js +564 -0
  50. package/dist/src/cli/state.js +58 -0
  51. package/dist/src/cli/style.js +187 -0
  52. package/dist/src/cli/supervise.js +248 -0
  53. package/dist/src/cli/sync.js +94 -0
  54. package/dist/src/cli/ui.js +107 -0
  55. package/dist/src/cli/user.js +229 -0
  56. package/dist/src/cli/version.js +23 -0
  57. package/dist/src/demo/seed.js +237 -0
  58. package/dist/src/demo/source.js +655 -0
  59. package/dist/src/duck/analyze.js +113 -0
  60. package/dist/src/duck/cancel.js +49 -0
  61. package/dist/src/duck/distinct-key.js +228 -0
  62. package/dist/src/duck/executor-worker.js +268 -0
  63. package/dist/src/duck/executor.js +567 -0
  64. package/dist/src/duck/explain.js +238 -0
  65. package/dist/src/duck/functions.js +819 -0
  66. package/dist/src/duck/gaps.js +94 -0
  67. package/dist/src/duck/jsonb-order.js +175 -0
  68. package/dist/src/duck/jsonb.js +188 -0
  69. package/dist/src/duck/kept-route.js +299 -0
  70. package/dist/src/duck/kept.js +964 -0
  71. package/dist/src/duck/late.js +221 -0
  72. package/dist/src/duck/libc-order.js +190 -0
  73. package/dist/src/duck/pg-eval-worker.js +34 -0
  74. package/dist/src/duck/pg-eval.js +59 -0
  75. package/dist/src/duck/pgtree.js +378 -0
  76. package/dist/src/duck/plan.js +259 -0
  77. package/dist/src/duck/publication.js +207 -0
  78. package/dist/src/duck/requests.js +209 -0
  79. package/dist/src/duck/resolved.js +248 -0
  80. package/dist/src/duck/rewrite.js +5101 -0
  81. package/dist/src/duck/store.js +907 -0
  82. package/dist/src/duck/stream.js +518 -0
  83. package/dist/src/duck/sync.js +2929 -0
  84. package/dist/src/duck/to-char.js +482 -0
  85. package/dist/src/duck/token.js +31 -0
  86. package/dist/src/duck/types.js +427 -0
  87. package/dist/src/duck/users.js +231 -0
  88. package/dist/src/duck/verified.js +1001 -0
  89. package/dist/src/duck/views.js +150 -0
  90. package/dist/src/lib/database-url.js +74 -0
  91. package/dist/src/lib/did-you-mean.js +43 -0
  92. package/dist/src/lib/dotenv.js +72 -0
  93. package/dist/src/lib/exit-when-written.js +20 -0
  94. package/dist/src/lib/float-equal.js +69 -0
  95. package/dist/src/lib/http-json.js +22 -0
  96. package/dist/src/lib/log.js +161 -0
  97. package/dist/src/lib/pg-numeric.js +489 -0
  98. package/dist/src/lib/port.js +22 -0
  99. package/dist/src/lib/project-file.js +49 -0
  100. package/dist/src/lib/project-files.js +43 -0
  101. package/dist/src/lib/project-version.js +76 -0
  102. package/dist/src/lib/runnable.js +14 -0
  103. package/dist/src/lib/shell-word.js +4 -0
  104. package/dist/src/lib/timezone.js +237 -0
  105. package/dist/src/lib/zone-names.js +114 -0
  106. package/dist/src/web/app.css +76 -0
  107. package/dist/src/web/app.js +491 -0
  108. package/dist/src/web/index.html +44 -0
  109. package/package.json +22 -4
@@ -0,0 +1,366 @@
1
+ /**
2
+ * `mason init [<postgres url>]` (a project directory is initiated as other CLIs do it, by `mason init`):
3
+ * makes the folder it is run in a Mason project. The database's URL comes from the argument, else the folder's
4
+ * `.env.local` or `.env` (`DATABASE_URL`, the variable web frameworks use), else the shell's. The database is checked as
5
+ * `mason start` checks it (checkSource) and nothing is written when it fails, unless --skip-check says the database is
6
+ * not up yet.
7
+ *
8
+ * It writes `.mason/config.json` (0600: the project's name and Mason's key), the project's source file
9
+ * (`mason/sources/<database>.yml`, src/cli/sources.ts: `kind: postgres`, `url: ${DATABASE_URL}`, `tables: {}`) — the
10
+ * URL itself in `.env`, never in a committed file (the sources file is the developer's to commit, so the URL stays out
11
+ * of it): nothing more when it came from there, the line added when it came from the argument or the shell — and adds
12
+ * `.mason/` and that .env to the folder's .gitignore. It refuses a folder that is in a project already (here or above)
13
+ * — but for a project with no source file (one an older Mason made), whose source file it writes, nothing else changed.
14
+ *
15
+ * `mason init demo` (src/cli/demo.ts) writes the same files for a database Mason hosts itself: the demo's URL, on a port
16
+ * free now, in `.env` (the one place the port is kept), three of the demo's four tables already added (the fourth,
17
+ * `DEMO_TABLE_TO_ADD`, left for the person's first `mason add`), and `demo: true` in the config; there is nothing to
18
+ * connect to yet — `mason start` makes the database in `.mason/demo/`.
19
+ */
20
+ import * as fs from "node:fs";
21
+ import * as path from "node:path";
22
+ import { spawnSync } from "node:child_process";
23
+ import { STDIO, endText, failed, jsonText, parseCommand } from "./command.js";
24
+ import { checklist, out as styleOut, PLAIN, StepFailed } from "./style.js";
25
+ import { checkSource, maskedUrl, parseSource, remoteConfirmation, remoteWarning } from "./preflight.js";
26
+ import { cliPinProblem, ensurePin } from "./pin.js";
27
+ import { version } from "./version.js";
28
+ import { parseVersion, PIN_FILE } from "../lib/project-version.js";
29
+ import { appDatabaseUrl, appEnvValue, appendEnvLine, ENV_FILE } from "../lib/dotenv.js";
30
+ import { DEMO_DIR, NAME_RE, STATE_DIR, brokenProblem, findProject, ignoresStateDir, nameFromFolder, newConfig, writeConfig } from "./project.js";
31
+ import { DEMO_ADDED_TABLES, DEMO_ARG, DEMO_PORT, DEMO_TABLE_TO_ADD, demoUrl } from "./demo.js";
32
+ import { freePort } from "./ports.js";
33
+ import { newSourceText, OLD_TABLES_DIR, parseSourceText, shownPath, sourceFileNames, sourceNameFor, sourcesDir } from "./sources.js";
34
+ export const INIT_USAGE = `usage: mason init [<postgres url> | demo] [--name <name>] [--folder <dir>] [--remote] [--skip-check] [--json]
35
+
36
+ Makes this folder a Mason project — run it in your app's folder. It checks it can read the database (and writes
37
+ nothing when it cannot), then creates:
38
+ .mason/ this machine's: Mason's key and, once Mason runs, its copy and state (gitignored)
39
+ mason/sources/<db>.yml the database and the tables to copy (mason add adds them) — commit it
40
+ mason/mason.yml the Mason version the project is written for — commit it: production runs that version
41
+ The source file says url: \${DATABASE_URL}: the URL itself is in .env (written there when you give it here or it is
42
+ only in the shell), never in a committed file. .mason/ and that .env go into .gitignore. Every other mason command
43
+ then works anywhere in this folder. In a project with no source file (an older Mason's), it writes that file alone.
44
+ mason init demo, in a new folder, makes a project on a demo database Mason hosts itself (a shop: customers, products,
45
+ orders, order_items): mason start makes it in .mason/demo/ and changes it every few seconds. Three of its tables are
46
+ added; the fourth is yours to add after mason start (mason add ${DEMO_TABLE_TO_ADD}), to watch its copy begin.
47
+
48
+ mason init DATABASE_URL from .env.local, .env or the shell
49
+ mason init postgres://me:secret@localhost:5432/shop
50
+ mason init demo a demo database Mason hosts itself: nothing to install
51
+ mason init --folder db/mason --name shop-admin the project's files elsewhere; another name
52
+ mason init postgres://me:secret@db.example.com/shop --remote
53
+
54
+ --name <name> the project's name: the database name a tool connects to on the Postgres API (default: this folder's)
55
+ --folder <dir> where sources/ goes, relative to this folder (default mason; . for this folder itself)
56
+ --remote go ahead with a database on another machine (read the warnings mason init prints); not with demo
57
+ --skip-check write the project without connecting (the database is not up yet); mason start checks it; not with demo
58
+ --json as data: { name, root, folder, config, demo (true for the demo database's project, else false),
59
+ source: { name (the source's: its file's), file, url (as the file writes it), env (the .env line
60
+ written, its value masked, or null) }, database: { url (password masked), from, version, tables },
61
+ warnings, next }
62
+ `;
63
+ export const INIT = {
64
+ name: "init",
65
+ summary: "make this folder a Mason project on your Postgres: mason init (DATABASE_URL from .env), mason init postgres://… or demo",
66
+ usage: INIT_USAGE,
67
+ options: { name: { type: "string" }, folder: { type: "string" }, remote: { type: "boolean" }, "skip-check": { type: "boolean" }, json: { type: "boolean" } },
68
+ json: ["name", "root", "folder", "config", "demo", "source", "database", "warnings", "next"],
69
+ };
70
+ /** Why `name` cannot be this project's, or null: not a name the Postgres API's database and the slot take. */
71
+ export function nameProblem(name) {
72
+ if (!NAME_RE.test(name))
73
+ return `${JSON.stringify(name)} is not a project name: lower-case letters, digits, - and _, starting with a letter or digit (--name sets it)`;
74
+ return null;
75
+ }
76
+ /**
77
+ * .gitignore with `.mason/` and each of `files` (the one DATABASE_URL was read from) added where it does not ignore them
78
+ * already, and what was added; null when nothing is. Reading the URL from a file and leaving that file committable
79
+ * was the defect (on the test server, `git add .` in a new folder committed the database password), so the
80
+ * file is ignored with the state. A line already covering it — exact, anchored, or a glob like `.env*` — is enough.
81
+ */
82
+ export function withIgnored(gitignore, files = []) {
83
+ const text = gitignore ?? "";
84
+ const added = [];
85
+ let tail = "";
86
+ if (!ignoresStateDir(text)) {
87
+ added.push(`${STATE_DIR}/`);
88
+ tail += `# Mason: this machine's key, copy and state (mason init)\n${STATE_DIR}/\n`;
89
+ }
90
+ for (const f of files)
91
+ if (!ignoresFile(text, f)) {
92
+ added.push(f);
93
+ tail += `# the database URL mason/sources/ names (mason init)\n${f}\n`;
94
+ }
95
+ if (added.length === 0)
96
+ return null;
97
+ const head = text === "" ? "" : text.endsWith("\n") ? text : `${text}\n`;
98
+ return { text: head + tail, added };
99
+ }
100
+ /** Whether a .gitignore line ignores the file `name` (at the root): its name, anchored or not, or a glob matching it. */
101
+ export function ignoresFile(gitignore, name) {
102
+ return gitignore.split("\n").map((l) => l.trim()).some((l) => {
103
+ if (l === "" || l.startsWith("#") || l.startsWith("!") || l.endsWith("/"))
104
+ return false;
105
+ const pattern = l.replace(/^\//, "");
106
+ if (pattern.includes("/"))
107
+ return false;
108
+ const re = new RegExp(`^${pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]")}$`);
109
+ return re.test(name);
110
+ });
111
+ }
112
+ /**
113
+ * Whether `file` in `root` is committed in git — then ignoring it changes nothing and init says what to run. Without
114
+ * git, or outside a repository, git answers with another status or does not run: the file is not known to be
115
+ * committed, and nothing is said (a read with a safe default).
116
+ */
117
+ export function committedInGit(root, file) {
118
+ const r = spawnSync("git", ["ls-files", "--error-unmatch", "--", file], { cwd: root, encoding: "utf8" });
119
+ return r.status === 0;
120
+ }
121
+ /**
122
+ * WHERE THE SOURCE FILE'S URL IS KEPT: the variable `url: ${…}` names and the `.env` line that gives it this URL — null
123
+ * when the folder's .env.local or .env gives it already. DATABASE_URL when the folder's files give it no other value
124
+ * (one only in the shell is written down: the next shell may not have it); else MASON_DATABASE_URL, so the app's own
125
+ * DATABASE_URL is never changed under it. A problem when both name other databases.
126
+ */
127
+ export function urlVariable(root, url) {
128
+ for (const variable of ["DATABASE_URL", "MASON_DATABASE_URL"]) {
129
+ const inFiles = appEnvValue(root, variable, {});
130
+ if (inFiles === null)
131
+ return { variable, line: `${variable}=${url}` };
132
+ if (inFiles.value === url)
133
+ return { variable, line: null };
134
+ }
135
+ return { problem: "this folder's .env names DATABASE_URL and MASON_DATABASE_URL for other databases: set one of them to the database Mason copies from, then mason init again" };
136
+ }
137
+ /** `.env` with `line` added (made when there is none). */
138
+ const addEnvLine = (root, line) => appendEnvLine(root, line, "the database Mason copies from (mason/sources/ names it; never commit this file)");
139
+ /** A `.env` line as it is said: its value's password masked. */
140
+ const maskedLine = (line) => { const eq = line.indexOf("="); return `${line.slice(0, eq)}=${maskedUrl(line.slice(eq + 1))}`; };
141
+ export async function init(argv, cwd = process.cwd(), deps = {}) {
142
+ const io = deps.io ?? STDIO;
143
+ const { out: say } = io;
144
+ const f = parseCommand(INIT, argv);
145
+ if (typeof f === "number")
146
+ return f;
147
+ const json = f.values.json === true;
148
+ const usage = (message, fix) => failed("init", { message, code: "usage", fix }, { json, io });
149
+ if (f.positionals.length > 1)
150
+ return failed("init", { message: "one postgres:// URL at most", code: "usage", usage: INIT_USAGE }, { json, io });
151
+ // THE DEMO (src/cli/demo.ts): a database Mason hosts, made by mason start; there is nothing to check, nor elsewhere
152
+ const demo = f.positionals[0] === DEMO_ARG;
153
+ if (demo) {
154
+ for (const flag of ["remote", "skip-check"]) {
155
+ if (f.values[flag] === true)
156
+ return failed("init", { message: `--${flag} means nothing for the demo database: it is on this machine, made and hosted by mason start`, code: "usage", fix: "mason init demo, without it", usage: INIT_USAGE }, { json, io });
157
+ }
158
+ }
159
+ let root = path.resolve(cwd);
160
+ const existing = findProject(root);
161
+ // a project whose config cannot be read is still this folder's: a second init over it would hide the first's data
162
+ if (existing && "broken" in existing)
163
+ return failed("init", brokenProblem(existing), { json, io });
164
+ // A PROJECT WITH NO SOURCE FILE (an older Mason's): its source file is written, its config kept; any other is refused
165
+ const sourceless = existing !== null && sourceFileNames(existing.folder).length === 0 ? existing : null;
166
+ if (existing && sourceless === null) {
167
+ const src = sourceFileNames(existing.folder)[0];
168
+ const file = path.join(sourcesDir(existing.folder), src);
169
+ const parsedFile = parseSourceText(fs.readFileSync(file, "utf8"), src.replace(/\.ya?ml$/, ""), file);
170
+ // the variable the file itself names, else its literal URL is where the database is said
171
+ const where = !("problems" in parsedFile) && "env" in parsedFile.url
172
+ ? `change ${parsedFile.url.env} in ${path.join(existing.root, ENV_FILE)} (url: in ${shownPath(existing.root, file)} names it), or url: itself`
173
+ : `change url: in ${shownPath(existing.root, file)}`;
174
+ return failed("init", { message: `${existing.root === root ? "this folder is" : `this folder is inside ${existing.root}, which is`} already the Mason project ${existing.config.name}`, code: "refused",
175
+ fix: `to point it at another database, ${where}`, next: [["mason status", "what it is doing"]] }, { json, io });
176
+ }
177
+ if (sourceless && demo) {
178
+ return failed("init", { message: `this folder is the Mason project ${sourceless.config.name} already: mason init demo makes a project of its own`, code: "refused",
179
+ fix: "run it in a new folder (mkdir mason-demo && cd mason-demo && mason init demo)", next: [["mason init", "this project's source file, on your database"]] }, { json, io });
180
+ }
181
+ if (sourceless && (f.values.name !== undefined || f.values.folder !== undefined)) {
182
+ return usage(`this folder is the Mason project ${sourceless.config.name} already, whose source file is all mason init writes here: it takes no --name or --folder`, "mason init [<postgres url>], in the project's folder");
183
+ }
184
+ if (sourceless)
185
+ root = sourceless.root;
186
+ const name = sourceless?.config.name ?? f.values.name ?? nameFromFolder(root);
187
+ const folderArg = f.values.folder ?? "mason";
188
+ const folder = sourceless?.folder ?? path.resolve(root, folderArg);
189
+ if (path.relative(root, folder).startsWith(".."))
190
+ return usage(`--folder ${folderArg} is outside this folder`, "the project's files go in this folder or below it");
191
+ if (fs.existsSync(folder) && !fs.statSync(folder).isDirectory())
192
+ return usage(`${folder} is a file, not a folder`, "--folder <dir> chooses another");
193
+ const named = nameProblem(name);
194
+ if (named)
195
+ return usage(named);
196
+ // the demo's URL names a port free now, and is the one place the port is kept: mason start reads it from there
197
+ let url = demo ? demoUrl(await freePort(DEMO_PORT)) : f.positionals[0] ?? null;
198
+ let from = "";
199
+ let envFile = null; // the folder's file the URL was read from (.env.local or .env): ignored with .mason/
200
+ if (url === null) {
201
+ const found = appDatabaseUrl(root);
202
+ if (!found) {
203
+ // the fixes ARE the next commands, so a coding agent reads them as such too
204
+ return failed("init", { message: "which database? none is named, and this folder has no DATABASE_URL", code: "usage",
205
+ fix: "give its URL on the command line, or put it in this folder's .env as DATABASE_URL=postgres://… (your app's, usually)",
206
+ next: [["mason init postgres://user:password@localhost:5432/your_app", "its URL, given"], ["mason init", "again, once .env has DATABASE_URL"],
207
+ ["mason init demo", "a demo database Mason makes itself: nothing to install"]] }, { json, io });
208
+ }
209
+ url = found.url;
210
+ from = ` (DATABASE_URL in ${found.from})`;
211
+ envFile = found.file;
212
+ }
213
+ const parsed = parseSource(url);
214
+ if ("problem" in parsed)
215
+ return usage(parsed.problem);
216
+ const remote = f.values.remote === true || sourceless?.config.remote === true;
217
+ // THE SOURCE FILE: one already here (a clone of the project) is kept; else written, its URL a variable of .env
218
+ const kept = sourceFileNames(folder)[0] ?? null;
219
+ if (demo && kept !== null) {
220
+ // a clone's sources file names the team's database: the demo never takes its place
221
+ return failed("init", { message: `${path.relative(root, path.join(sourcesDir(folder), kept))} is already here: mason init demo makes a project of its own`, code: "refused",
222
+ fix: "run it in a new folder (mkdir mason-demo && cd mason-demo && mason init demo), or mason init here for the database that file names" }, { json, io });
223
+ }
224
+ const keptText = kept === null ? null : fs.readFileSync(path.join(sourcesDir(folder), kept), "utf8");
225
+ const keptFile = kept === null ? null : parseSourceText(keptText, kept.replace(/\.ya?ml$/, ""), path.join(sourcesDir(folder), kept));
226
+ const keptVar = keptFile !== null && !("problems" in keptFile) && "env" in keptFile.url ? keptFile.url.env : null;
227
+ // 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
228
+ const keptValue = keptVar === null ? null : appEnvValue(root, keptVar, {});
229
+ const where = kept === null ? urlVariable(root, url)
230
+ : { variable: keptVar ?? "", line: keptVar !== null && keptValue === null ? `${keptVar}=${url}` : null };
231
+ if ("problem" in where)
232
+ return failed("init", { message: where.problem, code: "refused" }, { json, io });
233
+ // WHAT IT DOES, AS A CHECKLIST (each item it is doing, with a spinner left of it that turns into a checkmark when
234
+ // done): live on a terminal, the finished lines anywhere else (src/cli/style.ts checklist); under
235
+ // --json none of it, the facts as one document at the end
236
+ const st = deps.io ? PLAIN : styleOut();
237
+ const line = (t) => { if (!json)
238
+ say(t); };
239
+ const steps = checklist({ line, live: !deps.io && !json && process.stdout.isTTY ? { write: (t) => { process.stdout.write(t); } } : null, style: st });
240
+ const db = maskedUrl(parsed.source.url);
241
+ line(st.bold(`Making ${name} a Mason project in ${root}`));
242
+ const notes = [];
243
+ if (keptValue !== null && keptValue.value !== url)
244
+ notes.push(`${kept} reads ${keptVar} from ${keptValue.from}, which is ${maskedUrl(keptValue.value)}: mason start copies from that one, not ${db}`);
245
+ let database = { url: db, from: from ? from.slice(2, -1) : null, version: null, tables: null };
246
+ const rel = (p) => path.relative(root, p) || ".";
247
+ const sourceFile = kept !== null ? path.join(sourcesDir(folder), kept) : path.join(sourcesDir(folder), `${sourceNameFor(parsed.source.database)}.yml`);
248
+ // the folder an older Mason listed the tables in: read by nothing (no backwards compatibility before v1.0), said
249
+ const oldTables = path.join(folder, OLD_TABLES_DIR);
250
+ if (fs.existsSync(oldTables))
251
+ notes.push(`${rel(oldTables)}/ is an older Mason's, which this one does not read: mason add each table again, then remove it`);
252
+ // A DATABASE ON ANOTHER MACHINE, NOT CONFIRMED: not a failed step (the check passed, or was skipped) but a stop before
253
+ // anything is written, said as one failure (remoteConfirmation) — the same words whether the check ran or not
254
+ const confirmRemote = () => failed("init", remoteConfirmation(parsed.source.host, { cmd: "mason init",
255
+ wayOut: "run the same mason init again with --remote: that says yes, copy from that machine",
256
+ next: [["mason init --remote <url>", "the same command, confirmed"]] }), { json, io });
257
+ try {
258
+ if (demo) {
259
+ // nothing exists yet: mason start makes the database (src/demo/source.ts) at its first start
260
+ await steps.step("Choosing the demo database's port", () => null, () => `The demo database will be ${db}: mason start makes it in ${STATE_DIR}/${DEMO_DIR}/ and seeds it (nothing to connect to yet)`);
261
+ }
262
+ else if (f.values["skip-check"] === true) {
263
+ if (parsed.source.remote && !remote)
264
+ return confirmRemote();
265
+ await steps.step(`Not checking ${db}${from}`, () => null, () => `Not checking ${db}${from} (--skip-check): mason start checks it`);
266
+ }
267
+ else {
268
+ // the check's own words are its failure's (an error, then the ways out); on success its facts are the step's line
269
+ const said = [];
270
+ let needsRemote = false;
271
+ const checked = await steps.step(`Connecting to ${db}${from}`, async () => {
272
+ const c = await checkSource(url, { remote, cmd: "mason init", say: (m) => said.push(m) });
273
+ if ("exit" in c)
274
+ throw new StepFailed(said.filter((m) => !/: connecting to /.test(m)).join("\n"), c.exit);
275
+ if ("needsRemote" in c) {
276
+ needsRemote = true;
277
+ return c.needsRemote;
278
+ }
279
+ return c;
280
+ }, (c) => `Connected to ${db}${from}: Postgres ${c.facts.versionText}${c.facts.standby ? " (a read replica)" : ""}, ${c.facts.tables.length} table(s) this user can read`);
281
+ if (needsRemote) {
282
+ // the other warnings (a primary, a serverless host) stand; the remote one is the confirmation's own words
283
+ for (const w of checked.warnings.filter((w) => w !== remoteWarning(checked.source)))
284
+ line(` ! ${w}`);
285
+ return confirmRemote();
286
+ }
287
+ notes.push(...checked.warnings);
288
+ database = { ...database, version: checked.facts.versionText, tables: checked.facts.tables.length };
289
+ }
290
+ if (sourceless)
291
+ await steps.step(`Keeping ${STATE_DIR}/config.json`, () => undefined, () => `Kept ${STATE_DIR}/config.json: the project ${name}, its key and its copy`);
292
+ else {
293
+ await steps.step(`Writing ${STATE_DIR}/config.json`, () => {
294
+ writeConfig(path.join(root, STATE_DIR), newConfig(name, path.relative(root, folder) || ".", remote, { demo }));
295
+ }, () => `Wrote ${STATE_DIR}/config.json: Mason's key (this machine's)`);
296
+ }
297
+ if (kept !== null) {
298
+ await steps.step(`Keeping ${rel(sourceFile)}`, () => undefined, () => `Kept ${rel(sourceFile)}: the database and the tables this project copies`);
299
+ if (keptFile !== null && "problems" in keptFile)
300
+ notes.push(`${rel(sourceFile)} has ${keptFile.problems.length === 1 ? "a mistake" : "mistakes"}, which mason start refuses: ${keptFile.problems.join("; ")}`);
301
+ }
302
+ else {
303
+ await steps.step(`Writing ${rel(sourceFile)}`, () => {
304
+ fs.mkdirSync(path.dirname(sourceFile), { recursive: true });
305
+ fs.writeFileSync(sourceFile, newSourceText(`\${${where.variable}}`, demo ? DEMO_ADDED_TABLES : []));
306
+ }, () => `Wrote ${rel(sourceFile)}: the database (url: \${${where.variable}}) and the tables to copy, ${demo ? `the demo's ${DEMO_ADDED_TABLES.length} added — mason add ${DEMO_TABLE_TO_ADD} adds the fourth` : "none yet (mason add)"} — commit it`);
307
+ }
308
+ if (where.line !== null) {
309
+ const envLine = where.line;
310
+ await steps.step(`Writing ${where.variable} to .env`, () => addEnvLine(root, envLine), () => `Wrote ${maskedLine(envLine)} to .env: the URL the source file names, kept out of git`);
311
+ envFile = ENV_FILE;
312
+ }
313
+ // THE VERSION PIN (src/cli/pin.ts): what production's image is held to. A checkout's mason (`dev`) has none to write
314
+ const cli = deps.version ?? version();
315
+ const pinFile = rel(path.join(folder, PIN_FILE));
316
+ if (parseVersion(cli) !== null) {
317
+ 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}`);
318
+ const pinned = cliPinProblem(folder, cli);
319
+ if (pinned)
320
+ notes.push(pinned);
321
+ }
322
+ const gi = path.join(root, ".gitignore");
323
+ const ignored = [`${STATE_DIR}/`, ...(envFile ? [envFile] : [])].join(" and ");
324
+ await steps.step(`Ignoring ${ignored} in .gitignore`, () => {
325
+ const before = fs.existsSync(gi) ? fs.readFileSync(gi, "utf8") : null;
326
+ const after = withIgnored(before, envFile ? [envFile] : []);
327
+ if (after !== null)
328
+ fs.writeFileSync(gi, after.text);
329
+ return after === null ? "already" : before === null ? "made" : after.added.join(" and ");
330
+ }, (r) => r === "already" ? `.gitignore already ignores ${ignored}` : r === "made" ? `Made .gitignore, ignoring ${ignored}` : `Added ${r} to .gitignore`);
331
+ if (envFile && committedInGit(root, envFile)) {
332
+ notes.push(`${envFile} holds the database URL (DATABASE_URL) and is committed in git: git rm --cached ${envFile} takes it out of the next commit (the file stays); then change the password`);
333
+ }
334
+ }
335
+ catch (e) {
336
+ if (!(e instanceof StepFailed))
337
+ throw e;
338
+ // the step's own words (checkSource's: what is wrong, then the ways out as hints): the first line is the failure,
339
+ // the rest its fix
340
+ const [first, ...rest] = e.message.split("\n").map((l) => l.replace(/^mason init: /, ""));
341
+ return failed("init", { message: first ?? e.message, code: e.exit === 2 ? "usage" : "failed", exit: e.exit, fix: rest.length ? rest.map((l) => l.replace(/^\s*- /, "")).join("\n") : undefined,
342
+ why: parsed.source.remote ? undefined : `the database's URL is ${db}${from}` }, { json, io });
343
+ }
344
+ const next = demo ? [
345
+ ["mason start", "the demo database and Mason, in the background: its tables copied, then kept current"],
346
+ [`mason add ${DEMO_TABLE_TO_ADD}`, "the demo's fourth table, once Mason is up: watch its copy begin"],
347
+ ["mason status", "the copy keeping up with the demo's changes"],
348
+ ["mason user add <name>", "a user for psql or a BI tool: its password and URL, printed once"],
349
+ ] : [
350
+ ["mason start", "Mason in the background, for this folder"],
351
+ ["mason tables", "the database's tables and their columns"],
352
+ ["mason add <table>", "copy a table into Mason"],
353
+ ];
354
+ if (json) {
355
+ say(jsonText(INIT, { name, root, folder: path.relative(root, folder) || ".", config: path.join(root, STATE_DIR, "config.json"), demo,
356
+ source: { name: path.basename(sourceFile).replace(/\.ya?ml$/, ""), file: sourceFile, url: keptFile !== null && !("problems" in keptFile) ? ("env" in keptFile.url ? `\${${keptFile.url.env}}` : maskedUrl(keptFile.url.literal)) : `\${${where.variable}}`,
357
+ env: where.line === null ? null : maskedLine(where.line) },
358
+ database, warnings: notes }, next));
359
+ return 0;
360
+ }
361
+ for (const n of notes)
362
+ say(` ! ${n}`);
363
+ 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 copy`
364
+ : "Mason only reads your database: no setting, slot or publication is needed or made"], next }, st));
365
+ return 0;
366
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * `mason destroy` — WHAT MASON HAS ON THIS MACHINE FOR A PROJECT, REMOVED (it was not clear how to purge everything, or
3
+ * even what still existed; named `destroy` so the CLI works more like docker's): the project's copy of the data (its
4
+ * DuckDB file) and its state in .mason/ — never its files, never .mason/config.json and never its users, so `mason
5
+ * start` begins again from the files and every tool connects as before. It means the project of the folder it runs in:
6
+ * a project is its folder (`mason list` was dropped; nothing on the machine lists them).
7
+ */
8
+ import * as fs from "node:fs";
9
+ import * as path from "node:path";
10
+ import { STDIO, failed, jsonText, parseCommand, reportText, withEnd } from "./command.js";
11
+ import { alive, END_GRACE_MS, runState } from "./runs.js";
12
+ import { stopSupervisor } from "./supervise.js";
13
+ import { STATE_DIR, isProject, projectSource, readProject, theProject, writeConfig } from "./project.js";
14
+ import { parseSource } from "./preflight.js";
15
+ import { USERS_FILE } from "../duck/users.js";
16
+ import { SELF_SIGNED_FILES } from "./pg-tls.js";
17
+ import { dropOwnReplication, dropStatements, madeReplication } from "./replication-mode.js";
18
+ import { sourceFileNames } from "./sources.js";
19
+ export const DESTROY_USAGE = `usage: mason destroy [--json]
20
+
21
+ Removes this folder's project's copy of your data from this machine — its file — and Mason's state in its
22
+ .mason/ (the logs, the request log). Mason is stopped first if it runs. Your files (mason/sources/), .mason/config.json and
23
+ the users (mason user) stay: the next mason start copies the tables again, and every tool connects as before.
24
+
25
+ mason destroy this folder's project
26
+
27
+ mason stop keeps the copy and the state, and the next mason start resumes in seconds; mason destroy frees the disk, and
28
+ the next mason start begins again. In replication mode the slot (and Mason's own publication) are dropped from the
29
+ database too: they hold its WAL for as long as they exist. A demo project's database (mason init demo, in .mason/demo/)
30
+ goes with the copy: the next mason start makes and seeds it again.
31
+
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.
35
+ `;
36
+ export const DESTROY = {
37
+ needs: "project",
38
+ name: "destroy",
39
+ summary: "remove this project's copy of the data and Mason's state from this machine (your files and users stay)",
40
+ usage: DESTROY_USAGE,
41
+ options: { json: { type: "boolean" } },
42
+ json: ["removed", "next"],
43
+ };
44
+ /** Wait for `pid` to exit after SIGTERM; throws when it does not within 90 s. */
45
+ async function stopPid(pid, what, isAlive, kill = (p) => process.kill(p, "SIGTERM")) {
46
+ kill(pid);
47
+ const until = Date.now() + 90_000;
48
+ while (isAlive(pid) && Date.now() < until)
49
+ await new Promise((res) => setTimeout(res, 250));
50
+ if (isAlive(pid))
51
+ throw new Error(`${what} (pid ${pid}) is still running after 90 s: mason logs shows what it is doing`);
52
+ }
53
+ /** Stop the project's run if it is up, drop its replication objects, and empty its .mason/ but for the config and the users. */
54
+ export async function removeProject(p, o = {}) {
55
+ const isAlive = o.isAlive ?? alive;
56
+ const removed = [];
57
+ const st = await (o.state ?? runState)(p);
58
+ // A RUN THAT IS ALIVE AND DOES NOT ANSWER (src/cli/runs.ts "alive and silent"; on the test server the copy
59
+ // was deleted under it, and the hung process kept the 58 GB file open): nothing is removed; mason stop ends it first
60
+ if (st.kind === "hung") {
61
+ throw new Error(`Mason for ${p.config.name} is running (pid ${st.run.pid}) but does not answer its commands, so nothing was removed: mason stop ends it (SIGKILL after ${END_GRACE_MS / 1000} s), then mason destroy`);
62
+ }
63
+ const run = st.run;
64
+ if (run) {
65
+ await stopPid(run.pid, `Mason for ${p.config.name}`, isAlive, o.kill);
66
+ removed.push(`stopped Mason (pid ${run.pid})`);
67
+ }
68
+ else {
69
+ // between restarts the run is dead and its supervisor waits to start it again over what is removed here
70
+ const sup = await (o.stopSupervisor ?? stopSupervisor)(p.dir);
71
+ if (sup !== null)
72
+ removed.push(`stopped its supervisor (pid ${sup}), which was about to start it again`);
73
+ }
74
+ // REPLICATION MODE made a publication and a slot on the database (src/cli/replication-mode.ts): the slot holds its WAL
75
+ // for as long as it exists, so it goes with the copy — and when it cannot, the rest goes and the failure says how
76
+ let replication = null;
77
+ let objects = "publication and replication slot";
78
+ if (p.config.changes === "replication") {
79
+ // the slot, and the publication only when it is Mason's own: one the operator named is its owner's, left as it is
80
+ const src = projectSource(p);
81
+ // what the start that made them recorded (madeReplication); a project with no record names them by its one sources file
82
+ const own = madeReplication(p.config, "message" in src ? (sourceFileNames(p.folder)[0] ?? "app.yml").replace(/\.ya?ml$/, "") : src.name);
83
+ if (!own.owned)
84
+ objects = "replication slot";
85
+ const parsed = "message" in src ? { problem: `the database's URL cannot be read: ${src.message}` } : parseSource(src.resolved);
86
+ try {
87
+ if ("problem" in parsed)
88
+ throw new Error(parsed.problem);
89
+ // the slot is on the database the copy came from: a url: edited since names another, where dropping finds nothing
90
+ // and the real slot goes on holding its WAL (the review of the sources file)
91
+ const from = p.config.sourceServer ?? null;
92
+ if (from !== null && from.database !== parsed.source.database) {
93
+ throw new Error(`the sources file names ${parsed.source.database} now, and the copy came from ${from.database}, where the slot is: ` +
94
+ `put that database's URL back in the sources file and run mason destroy again, or drop them on ${from.database} by hand`);
95
+ }
96
+ for (const d of await (o.dropReplication ?? dropOwnReplication)(parsed.source, own, from))
97
+ removed.push(`${d} from ${parsed.source.database}`);
98
+ }
99
+ catch (e) {
100
+ replication = `${e.message}. They hold the database's WAL while they exist: drop them by hand — ${dropStatements(own)}`;
101
+ }
102
+ }
103
+ // the users (src/duck/users.ts) are logins a BI tool keeps, like the API key their passwords are made with, and the
104
+ // self-signed certificate is the fingerprint a tool pinned (the review of the cut: a new one at the next
105
+ // start, and config.selfSignedTls stale): the next start copies again, and every tool connects as before. A demo
106
+ // project's database (`.mason/demo/`, src/cli/demo.ts) is state like the copy: it goes, and config.demo stays, so the
107
+ // next start makes and seeds it again
108
+ const kept = new Set(["config.json", USERS_FILE, SELF_SIGNED_FILES.cert, SELF_SIGNED_FILES.key]);
109
+ const gone = fs.existsSync(p.dir) ? fs.readdirSync(p.dir).filter((x) => !kept.has(x)) : [];
110
+ for (const x of gone)
111
+ fs.rmSync(path.join(p.dir, x), { recursive: true, force: true });
112
+ if (gone.length)
113
+ removed.push(`${path.join(p.root, STATE_DIR)}/ (${gone.join(", ")})`);
114
+ // the copy is gone: the next start records the database's server again, and nothing is running
115
+ const fresh = readProject(p.root);
116
+ // the slot and publication dropped, nothing names them; when they could not be, the record stays for the next destroy
117
+ if (isProject(fresh)) {
118
+ fresh.config.running = null;
119
+ fresh.config.sourceServer = null;
120
+ if (replication === null)
121
+ fresh.config.replication = null;
122
+ writeConfig(p.dir, fresh.config);
123
+ }
124
+ if (replication)
125
+ throw new Error(`removed ${removed.join(", ")}; but not its ${objects}: ${replication}`);
126
+ return removed;
127
+ }
128
+ export async function destroy(argv, deps = {}) {
129
+ const io = deps.io ?? STDIO;
130
+ const { out } = io;
131
+ const f = parseCommand(DESTROY, argv);
132
+ if (typeof f === "number")
133
+ return f;
134
+ const json = f.values.json === true;
135
+ if (f.positionals.length)
136
+ 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 });
137
+ const t = theProject(deps.cwd ?? process.cwd());
138
+ if ("problem" in t)
139
+ return failed("destroy", t, { json, io });
140
+ const name = t.project.config.name;
141
+ let result;
142
+ try {
143
+ result = { name, removed: await (deps.removeProject ?? removeProject)(t.project) };
144
+ }
145
+ catch (e) {
146
+ result = { name, removed: [], error: e.message };
147
+ }
148
+ const next = result.error ? [] : [["mason start", "copies the tables again"]];
149
+ if (json) {
150
+ out(jsonText(DESTROY, { removed: [result] }, next));
151
+ return result.error ? 1 : 0;
152
+ }
153
+ if (result.error)
154
+ return failed("destroy", { message: `${name}: ${result.error}`, code: "failed" }, { json: false, io });
155
+ out(withEnd(reportText({
156
+ headline: result.removed.length ? `Removed ${name}` : `Nothing was left of ${name} to remove`,
157
+ details: [...(result.removed.length ? [["removed", result.removed.join(", ")]] : []), ["kept", `your files in ${t.project.folder}, .mason/config.json and the users` +
158
+ // the demo database is state like the copy (src/cli/demo.ts): it goes, and config.demo stays to make it again
159
+ (t.project.config.demo === true ? ` — not the demo database, which went with the copy: the next mason start seeds it again` : "")]],
160
+ }), { next }));
161
+ return 0;
162
+ }
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `mason` — the command line of `@mason-data/cli`: its commands for people AND coding agents, a project per folder.
4
+ *
5
+ * mason init postgres://user:password@localhost:5432/your_app
6
+ * mason start
7
+ *
8
+ * makes the folder a project, then reads that database with SELECTs alone and serves a copy of the tables you add over
9
+ * the Postgres protocol, in the background (src/cli/dev.ts, src/cli/background.ts); the other commands work with the
10
+ * project of the folder they run in. Every command is in src/cli/registry.ts, described once (src/cli/command.ts).
11
+ * Apache-2.0: it runs the stack (CLAUDE.md "Licensing").
12
+ */
13
+ import { COMMANDS, orientation, topUsage, version } from "./registry.js";
14
+ import { didYouMean } from "../lib/did-you-mean.js";
15
+ import { spinUntilOutput } from "./style.js";
16
+ import { CommandFailed, failed, wantsJson } from "./command.js";
17
+ import { RunUnreachable } from "./control.js";
18
+ import { exitWhenWritten } from "../lib/exit-when-written.js";
19
+ async function main(argv) {
20
+ const [command, ...args] = argv;
21
+ // mason alone: where you are and the next command; mason help / --help: every command
22
+ if (command === undefined) {
23
+ process.stdout.write(await orientation(process.cwd()));
24
+ return 0;
25
+ }
26
+ if (command === "--help" || command === "-h") {
27
+ process.stdout.write(topUsage());
28
+ return 0;
29
+ }
30
+ if (command === "--version" || command === "-v" || command === "version") {
31
+ process.stdout.write(`${version()}\n`);
32
+ return 0;
33
+ }
34
+ const cmd = COMMANDS.find((c) => c.name === command);
35
+ const json = wantsJson(args, cmd);
36
+ // the commands' old names: said, never run as different ones
37
+ const RENAMED = {
38
+ dev: { message: "mason dev is now mason start", next: [["mason init <postgres url>", "once, in your app's folder"], ["mason start", "Mason in the background"]] },
39
+ docs: { message: "mason docs is now mason help", next: [["mason help agent", "driving Mason from a coding agent"], ["mason help production", "running Mason on a server"]] },
40
+ // named for what it does to the copy, never for the mechanism
41
+ poll: { message: "mason poll is now mason sync", next: [["mason sync <table>", "make the copy of a table current now"], ["mason status", "each table's copy, and which the sync leaves out"]] },
42
+ purge: { message: "mason purge is now mason destroy", next: [["mason destroy", "this folder's project: its copy and state, never its files"]] },
43
+ // mason rm read as the opposite of mason add: it removed a project's copy and state, never a table;
44
+ // the opposite of mason add is mason remove, so both are offered
45
+ rm: { message: "mason rm is gone: mason remove takes a table out of the project, mason destroy removes the project's copy", next: [["mason remove <table>", "a table, and its copy"], ["mason destroy", "this folder's project: its copy and state, never its files"]] },
46
+ // a user's password does not expire, so mason user is enough and mason connect was dropped
47
+ connect: { message: "mason connect is gone: mason user add <name> makes a user whose password does not expire, and prints the URL to paste", next: [["mason user add <name>", "a user for a BI tool or your app"], ["mason status", "where the Postgres API listens"]] },
48
+ // a project is its folder (mason list was dropped): nothing on the machine lists them
49
+ list: { message: "mason list is gone: a project is its folder, and a command means the project of the folder it runs in", next: [["mason status", "this folder's project"]] },
50
+ };
51
+ if (!cmd && Object.hasOwn(RENAMED, command))
52
+ return failed("", { ...RENAMED[command], code: "usage" }, { json });
53
+ if (!cmd)
54
+ return failed("", { message: `there is no command ${JSON.stringify(command)}${didYouMean(command, COMMANDS.map((c) => c.name))}`, code: "usage", usage: json ? undefined : topUsage() }, { json });
55
+ // a command quiet for a moment shows a spinner on a terminal until it prints (src/cli/style.ts spinUntilOutput)
56
+ const stop = spinUntilOutput(`mason ${cmd.name}`);
57
+ try {
58
+ return await cmd.run(args);
59
+ }
60
+ catch (e) {
61
+ // a failure a command threw instead of returning (src/cli/command.ts CommandFailed), or a run that stopped answering
62
+ // mid-command: said as every failure is, in JSON under --json
63
+ if (e instanceof CommandFailed)
64
+ return failed(cmd.name, e.failure, { json });
65
+ if (e instanceof RunUnreachable)
66
+ return failed(cmd.name, e.failure, { json });
67
+ throw e;
68
+ }
69
+ finally {
70
+ stop();
71
+ }
72
+ }
73
+ main(process.argv.slice(2)).then(exitWhenWritten, (e) => {
74
+ // an error no command said in its own words: its message, never a stack trace (CLAUDE.md: a message that needs a
75
+ // stack to be understood is a bug — mason query orders printed one when Mason's API was not up yet).
76
+ // MASON_DEBUG=1 prints where it happened, for the bug report it deserves. The same shape as every failure, JSON under --json.
77
+ const message = e instanceof Error ? e.message : String(e);
78
+ const [command, ...args] = process.argv.slice(2);
79
+ const code = failed(command ?? "", { message, code: "failed", fix: process.env.MASON_DEBUG ? undefined : "this is a bug in mason: MASON_DEBUG=1 prints where it happened, for the report" }, { json: wantsJson(args, COMMANDS.find((c) => c.name === command)) });
80
+ if (process.env.MASON_DEBUG)
81
+ process.stderr.write(`${e.stack ?? ""}\n`);
82
+ exitWhenWritten(code);
83
+ });