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