@mason-data/cli 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +6 -4
  2. package/dist/src/api/error.js +4 -0
  3. package/dist/src/api/postgres/proxy.js +6 -160
  4. package/dist/src/api/postgres/server.js +22 -4
  5. package/dist/src/cli/command.js +169 -5
  6. package/dist/src/cli/commands.js +290 -157
  7. package/dist/src/cli/deploy.js +134 -28
  8. package/dist/src/cli/doctor.js +14 -11
  9. package/dist/src/cli/guides.js +20 -11
  10. package/dist/src/cli/hosted.js +1 -1
  11. package/dist/src/cli/init.js +110 -25
  12. package/dist/src/cli/login.js +20 -17
  13. package/dist/src/cli/machine.js +125 -40
  14. package/dist/src/cli/main.js +51 -8
  15. package/dist/src/cli/mcp.js +15 -12
  16. package/dist/src/cli/project.js +1 -1
  17. package/dist/src/cli/registry.js +80 -35
  18. package/dist/src/cli/replica-hosted.js +761 -102
  19. package/dist/src/cli/replica.js +43 -5
  20. package/dist/src/cli/replication-mode.js +5 -0
  21. package/dist/src/cli/request-filter.js +16 -6
  22. package/dist/src/cli/run-server.js +25 -2
  23. package/dist/src/cli/secret-input.js +193 -0
  24. package/dist/src/cli/serve.js +2 -2
  25. package/dist/src/cli/share.js +20 -15
  26. package/dist/src/cli/source-role.js +14 -15
  27. package/dist/src/cli/sources.js +20 -3
  28. package/dist/src/cli/style.js +14 -0
  29. package/dist/src/cli/sync.js +17 -8
  30. package/dist/src/cli/token-hosted.js +30 -24
  31. package/dist/src/cli/tunnel.js +1 -1
  32. package/dist/src/cli/ui-hosted.js +163 -0
  33. package/dist/src/cli/ui-ssh.js +3 -3
  34. package/dist/src/cli/ui.js +67 -26
  35. package/dist/src/cli/usage.js +19 -15
  36. package/dist/src/cli/user-hosted.js +78 -13
  37. package/dist/src/cli/user.js +168 -50
  38. package/dist/src/duck/users.js +105 -9
  39. package/dist/src/lib/cidr.js +177 -0
  40. package/dist/src/lib/table-sync.js +69 -19
  41. package/package.json +6 -6
@@ -23,7 +23,7 @@ import { environmentToken, readLogins, TOKEN_ENV, writeLogins } from "./credenti
23
23
  import { DEFAULT_SERVICE_URL, expired, isFailure, pollToken, revoke, serviceOf, serviceText, startDevice, whoAmI } from "./hosted.js";
24
24
  import { err as styleErr, out as styleOut, stepRenderer } from "./style.js";
25
25
  import { holdOutput } from "./output-gate.js";
26
- import { openBrowser } from "./ui.js";
26
+ import { OPENABLE, openBrowser } from "./ui.js";
27
27
  const out = (m) => { process.stdout.write(`${m}\n`); };
28
28
  /** Everything these commands print can hold the service's text, so it is drawn in serviceStyle (src/cli/command.ts):
29
29
  * on a terminal the CLI breaks every line itself, and a person never sees a line begin with what the service sent. */
@@ -42,6 +42,12 @@ export const LOGIN = {
42
42
  ~/.config/mason/credentials.json ($XDG_CONFIG_HOME/mason/ when that is set), readable by you alone, never in a
43
43
  project. Signing in again replaces this machine's login to that service, and revokes the one it replaces.
44
44
 
45
+ mason login
46
+ MASON_CLOUD_URL=http://127.0.0.1:8080 mason login a service on this machine
47
+
48
+ --json as data, once signed in: { service (its address), email, name, orgs: [{ slug, name, role }], file (where
49
+ the login is kept), replaced (null, or the login it replaced: { email, revoked }), next }
50
+
45
51
  The page and the code are printed on stderr at once, as plain lines, so a coding agent can pass them on to you
46
52
  before the wait ends; the answer is printed when it ends.
47
53
 
@@ -49,11 +55,8 @@ export const LOGIN = {
49
55
  this machine). With MASON_TOKEN set (CI), every command uses that token before a saved login, so mason login is
50
56
  refused: run it without the variable (env -u MASON_TOKEN mason login) to sign in anyway.
51
57
 
52
- mason login
53
- MASON_CLOUD_URL=http://127.0.0.1:8080 mason login a service on this machine
54
-
55
- --json as data, once signed in: { service (its address), email, name, orgs: [{ slug, name, role }], file (where
56
- the login is kept), replaced (null, or the login it replaced: { email, revoked }), next }
58
+ Exit status: 0 signed in; 1 refused or failed (said); 2 a wrong command line; 124 the code expired before it
59
+ was approved (run mason login again).
57
60
  `,
58
61
  options: { json: { type: "boolean", default: false } },
59
62
  json: ["service", "email", "name", "orgs", "file", "replaced", "next"],
@@ -68,21 +71,21 @@ export const LOGOUT = {
68
71
  reached the login is still removed here, and it says the login was NOT revoked and how to revoke it: sign in to
69
72
  the service's web app and revoke it there. Logins to other services stay.
70
73
 
71
- MASON_TOKEN in the environment is not a saved login: logout cannot remove it, and says so while it is set.
72
-
73
74
  mason logout
74
75
  MASON_CLOUD_URL=http://127.0.0.1:8080 mason logout a service on this machine
75
76
 
76
- --json as data: { service, signedOut (a saved login was removed), email (its account, null when there was
77
- none), revoked (true, false when the service did not take it, null when there was nothing to revoke),
78
- revokeError (why not, else null), environmentToken (MASON_TOKEN is set, and still used), file, next }
77
+ --json as data: its shape is at the end
78
+
79
+ MASON_TOKEN in the environment is not a saved login: logout cannot remove it, and says so while it is set.
80
+
81
+ The --json answer: { service, signedOut (a saved login was removed), email (its account, null when there was
82
+ none), loginRevoked (true, false when the service did not take it, null when there was nothing to
83
+ revoke), revoked (the same, kept until 1.0), revokeError (why not, else null), environmentToken
84
+ (MASON_TOKEN is set, and still used), file, next }
79
85
  `,
80
86
  options: { json: { type: "boolean", default: false } },
81
- json: ["service", "signedOut", "email", "revoked", "revokeError", "environmentToken", "file", "next"],
87
+ json: ["service", "signedOut", "email", "loginRevoked", "revoked", "revokeError", "environmentToken", "file", "next"],
82
88
  };
83
- /** Where a person may approve the code, for the browser to open: a plain https (or this machine's http) URL, and none
84
- * of the characters a shell or `cmd /c start` reads (the service's URL is not ours). */
85
- const OPENABLE = /^https?:\/\/[A-Za-z0-9.:[\]-]+(?:\/[A-Za-z0-9._~/?=+-]*)?$/;
86
89
  /** An organisation as a line: its slug, its name when it says more, the role. */
87
90
  const orgLine = (o) => `${o.slug}${o.name && o.name !== o.slug ? ` (${o.name})` : ""}, ${o.role}`;
88
91
  export async function login(argv) {
@@ -258,7 +261,7 @@ export async function logout(argv) {
258
261
  if (entry === undefined) {
259
262
  const next = [["mason login", `sign this machine in to ${service.base}`]];
260
263
  if (json) {
261
- out(jsonText(LOGOUT, { service: service.base, signedOut: false, email: null, revoked: null, revokeError: null, environmentToken: fromEnv, file: read.file }, next));
264
+ out(jsonText(LOGOUT, { service: service.base, signedOut: false, email: null, loginRevoked: null, revoked: null, revokeError: null, environmentToken: fromEnv, file: read.file }, next));
262
265
  return 0;
263
266
  }
264
267
  out(reportText({ headline: `This machine is not signed in to ${service.base}`, details: [["file", read.file]], tips: envTip, next }, outStyle()));
@@ -274,7 +277,7 @@ export async function logout(argv) {
274
277
  const email = serviceText(entry.email, 320, [entry.token]);
275
278
  const next = [["mason login", `sign in to ${service.base} again`]];
276
279
  if (json) {
277
- out(jsonText(LOGOUT, { service: service.base, signedOut: true, email, revoked: revokeError === null, revokeError, environmentToken: fromEnv, file: written.file }, next));
280
+ out(jsonText(LOGOUT, { service: service.base, signedOut: true, email, loginRevoked: revokeError === null, revoked: revokeError === null, revokeError, environmentToken: fromEnv, file: written.file }, next));
278
281
  return 0;
279
282
  }
280
283
  const notRevoked = revokeError === null ? undefined
@@ -9,37 +9,44 @@ import * as fs from "node:fs";
9
9
  import * as path from "node:path";
10
10
  import { STDIO, failed, jsonText, parseCommand, reportText, withEnd } from "./command.js";
11
11
  import { alive, END_GRACE_MS, runState } from "./runs.js";
12
- import { stopSupervisor } from "./supervise.js";
12
+ import { stopSupervisor, supervisorOf } from "./supervise.js";
13
13
  import { STATE_DIR, isProject, projectSource, readProject, theProject, writeConfig } from "./project.js";
14
14
  import { parseSource } from "./preflight.js";
15
15
  import { USERS_FILE } from "../duck/users.js";
16
16
  import { SELF_SIGNED_FILES } from "./pg-tls.js";
17
17
  import { dropOwnReplication, dropStatements, madeReplication } from "./replication-mode.js";
18
18
  import { sourceFileNames } from "./sources.js";
19
- export const DESTROY_USAGE = `usage: mason destroy [--json]
19
+ export const DESTROY_USAGE = `usage: mason destroy [--dry-run] [--json]
20
20
 
21
21
  Removes this folder's project's synced data from this machine — its file — and Mason's state in its
22
22
  .mason/ (the logs, the request log). Mason is stopped first if it runs. Your files (mason/sources/), .mason/config.json and
23
23
  the users (mason user) stay: the next mason start syncs the tables again, and every tool connects as before.
24
24
 
25
25
  mason destroy this folder's project
26
+ mason destroy --dry-run what it would remove, changing nothing
27
+
28
+ --dry-run list what would go — Mason stopped, the replication slot and Mason's own publication on the database, the
29
+ synced data and state in .mason/ — and change nothing: it stops nothing and never connects to the
30
+ database (the slot and publication are named from what the start that made them recorded)
31
+ --json as data: { dryRun, destroyed: { project, removedItems: [what, in words], replication (null: nothing on
32
+ the database; else { database, slot, publication (null: one the operator named, never dropped) }), error },
33
+ removed (the same as [{ name, removed: [what], error }], kept until 1.0; not on a dry run), next }
26
34
 
27
35
  mason stop keeps the synced data and the state, and the next mason start resumes in seconds; mason destroy frees the disk, and
28
36
  the next mason start begins again. In replication mode the slot (and Mason's own publication) are dropped from the
29
37
  database too: they hold its WAL for as long as they exist. A demo project's database (mason init demo, in .mason/demo/)
30
38
  goes with the synced data: the next mason start makes and seeds it again.
31
39
 
32
- --json as data: { removed: [{ name, removed: [what], error }], next }
33
-
34
- Exit status: 0 removed (or nothing to remove); 1 something could not be removed (said); 2 a wrong command line.
40
+ Exit status: 0 removed (or nothing to remove, or a dry run that found nothing in the way); 1 something could not be
41
+ removed, or on a dry run would not be (said); 2 a wrong command line.
35
42
  `;
36
43
  export const DESTROY = {
37
44
  needs: "project",
38
45
  name: "destroy",
39
46
  summary: "remove this project's synced data and Mason's state from this machine (your files and users stay)",
40
47
  usage: DESTROY_USAGE,
41
- options: { json: { type: "boolean" } },
42
- json: ["removed", "next"],
48
+ options: { json: { type: "boolean" }, "dry-run": { type: "boolean" } },
49
+ json: ["dryRun", "destroyed", "removed", "next"],
43
50
  };
44
51
  /** Wait for `pid` to exit after SIGTERM; throws when it does not within 90 s. */
45
52
  async function stopPid(pid, what, isAlive, kill = (p) => process.kill(p, "SIGTERM")) {
@@ -75,39 +82,20 @@ export async function removeProject(p, o = {}) {
75
82
  // the run that made them (an older start recorded its mode, `changes`): the slot holds its WAL for as long as it
76
83
  // exists, so it goes with the copy — and when it cannot, the rest goes and the failure says how
77
84
  let replication = null;
78
- let objects = "publication and replication slot";
79
- if ((p.config.replication ?? null) !== null || p.config.changes === "replication") {
80
- // the slot, and the publication only when it is Mason's own: one the operator named is its owner's, left as it is
81
- const src = projectSource(p);
82
- // what the start that made them recorded (madeReplication); a project with no record names them by its one sources file
83
- const own = madeReplication(p.config, "message" in src ? (sourceFileNames(p.folder)[0] ?? "app.yml").replace(/\.ya?ml$/, "") : src.name);
84
- if (!own.owned)
85
- objects = "replication slot";
86
- const parsed = "message" in src ? { problem: `the database's URL cannot be read: ${src.message}` } : parseSource(src.resolved, src.from);
85
+ const target = replicationTarget(p);
86
+ if (target !== null) {
87
87
  try {
88
- if ("problem" in parsed)
89
- throw new Error(parsed.problem);
90
- // the slot is on the database the copy came from: a url: edited since names another, where dropping finds nothing
91
- // and the real slot goes on holding its WAL (the review of the sources file)
92
- const from = p.config.sourceServer ?? null;
93
- if (from !== null && from.database !== parsed.source.database) {
94
- throw new Error(`the sources file names ${parsed.source.database} now, and the synced data came from ${from.database}, where the slot is: ` +
95
- `put that database's URL back in the sources file and run mason destroy again, or drop them on ${from.database} by hand`);
96
- }
97
- for (const d of await (o.dropReplication ?? dropOwnReplication)(parsed.source, own, from))
98
- removed.push(`${d} from ${parsed.source.database}`);
88
+ if ("problem" in target.on)
89
+ throw new Error(target.on.problem);
90
+ const source = target.on.source;
91
+ for (const d of await (o.dropReplication ?? dropOwnReplication)(source, target.own, target.from))
92
+ removed.push(`${d} from ${source.database}`);
99
93
  }
100
94
  catch (e) {
101
- replication = `${e.message}. They hold the database's WAL while they exist: drop them by hand — ${dropStatements(own)}`;
95
+ replication = `${e.message}. They hold the database's WAL while they exist: drop them by hand — ${dropStatements(target.own)}`;
102
96
  }
103
97
  }
104
- // the users (src/duck/users.ts) are logins a BI tool keeps, like the API key their passwords are made with, and the
105
- // self-signed certificate is the fingerprint a tool pinned (the review of the cut: a new one at the next
106
- // start, and config.selfSignedTls stale): the next start copies again, and every tool connects as before. A demo
107
- // project's database (`.mason/demo/`, src/cli/demo.ts) is state like the copy: it goes, and config.demo stays, so the
108
- // next start makes and seeds it again
109
- const kept = new Set(["config.json", USERS_FILE, SELF_SIGNED_FILES.cert, SELF_SIGNED_FILES.key]);
110
- const gone = fs.existsSync(p.dir) ? fs.readdirSync(p.dir).filter((x) => !kept.has(x)) : [];
98
+ const gone = stateEntries(p);
111
99
  for (const x of gone)
112
100
  fs.rmSync(path.join(p.dir, x), { recursive: true, force: true });
113
101
  if (gone.length)
@@ -123,9 +111,84 @@ export async function removeProject(p, o = {}) {
123
111
  writeConfig(p.dir, fresh.config);
124
112
  }
125
113
  if (replication)
126
- throw new Error(`removed ${removed.join(", ")}; but not its ${objects}: ${replication}`);
114
+ throw new Error(`removed ${removed.join(", ")}; but not its ${target.objects}: ${replication}`);
127
115
  return removed;
128
116
  }
117
+ /**
118
+ * WHAT REPLICATION MADE ON THE DATABASE, AND WHERE IT IS — read from the config and the sources file alone, never by
119
+ * connecting (`mason destroy --dry-run` says it; `removeProject` drops it): null when the project made none. `on` is the
120
+ * database to drop them on, or why it cannot be named.
121
+ */
122
+ export function replicationTarget(p) {
123
+ if ((p.config.replication ?? null) === null && p.config.changes !== "replication")
124
+ return null;
125
+ // the slot, and the publication only when it is Mason's own: one the operator named is its owner's, left as it is
126
+ const src = projectSource(p);
127
+ // what the start that made them recorded (madeReplication); a project with no record names them by its one sources file
128
+ const own = madeReplication(p.config, "message" in src ? (sourceFileNames(p.folder)[0] ?? "app.yml").replace(/\.ya?ml$/, "") : src.name);
129
+ const objects = own.owned ? "publication and replication slot" : "replication slot";
130
+ const from = p.config.sourceServer ?? null;
131
+ const parsed = "message" in src ? { problem: `the database's URL cannot be read: ${src.message}` } : parseSource(src.resolved, src.from);
132
+ if ("problem" in parsed)
133
+ return { own, objects, from, on: { problem: parsed.problem } };
134
+ // the slot is on the database the copy came from: a url: edited since names another, where dropping finds nothing
135
+ // and the real slot goes on holding its WAL (the review of the sources file)
136
+ if (from !== null && from.database !== parsed.source.database) {
137
+ return { own, objects, from, on: { problem: `the sources file names ${parsed.source.database} now, and the synced data came from ${from.database}, where the slot is: ` +
138
+ `put that database's URL back in the sources file and run mason destroy again, or drop them on ${from.database} by hand` } };
139
+ }
140
+ return { own, objects, from, on: { source: parsed.source } };
141
+ }
142
+ /**
143
+ * WHAT `.mason/` HOLDS THAT DESTROY REMOVES. The users (src/duck/users.ts) are logins a BI tool keeps, like the API key
144
+ * their passwords are made with, and the self-signed certificate is the fingerprint a tool pinned (the review of the
145
+ * cut: a new one at the next start, and config.selfSignedTls stale): the next start copies again, and every tool
146
+ * connects as before. A demo project's database (`.mason/demo/`, src/cli/demo.ts) is state like the copy: it goes, and
147
+ * config.demo stays, so the next start makes and seeds it again.
148
+ */
149
+ function stateEntries(p) {
150
+ const kept = new Set(["config.json", USERS_FILE, SELF_SIGNED_FILES.cert, SELF_SIGNED_FILES.key]);
151
+ return fs.existsSync(p.dir) ? fs.readdirSync(p.dir).filter((x) => !kept.has(x)).sort() : [];
152
+ }
153
+ /** What replication made, as `--json` names it: the database it is on (the one the synced data came from when the
154
+ * sources file cannot say it; null when neither can), the slot, and the publication when it is Mason's own. */
155
+ function replicationFacts(t) {
156
+ return { database: "source" in t.on ? t.on.source.database : t.from?.database ?? null, slot: t.own.slot, publication: t.own.owned ? t.own.publication : null };
157
+ }
158
+ /**
159
+ * `mason destroy --dry-run`: WHAT WOULD GO, CHANGING NOTHING. It reads the run's state (`/api/ping`, which changes nothing),
160
+ * the supervisor's record, the config, the sources file and `.mason/`'s names — never the database: what replication
161
+ * made there is named from the config's record, exactly what `removeProject` would drop.
162
+ */
163
+ export async function destroyPlan(p, o = {}) {
164
+ const st = await (o.state ?? runState)(p);
165
+ if (st.kind === "hung") {
166
+ return { items: [], replication: null, error: `Mason for ${p.config.name} is running (pid ${st.run.pid}) but does not answer its commands, so mason destroy would remove nothing: mason stop ends it (SIGKILL after ${END_GRACE_MS / 1000} s), then mason destroy` };
167
+ }
168
+ const items = [];
169
+ if (st.run)
170
+ items.push(`Mason (pid ${st.run.pid}), stopped first`);
171
+ else {
172
+ const sup = (o.supervisor ?? supervisorOf)(p.dir);
173
+ if (sup !== null)
174
+ items.push(`its supervisor (pid ${sup}), stopped first: it is about to start Mason again`);
175
+ }
176
+ let replication = null, error = null;
177
+ const target = replicationTarget(p);
178
+ if (target !== null) {
179
+ replication = replicationFacts(target);
180
+ const on = replication.database === null ? "" : ` on ${replication.database}`;
181
+ items.push(`replication slot ${target.own.slot}${on}`);
182
+ if (target.own.owned)
183
+ items.push(`publication ${target.own.publication}${on}`);
184
+ if ("problem" in target.on)
185
+ error = `the rest would go, but not its ${target.objects}: ${target.on.problem}`;
186
+ }
187
+ const gone = stateEntries(p);
188
+ if (gone.length)
189
+ items.push(`${path.join(p.root, STATE_DIR)}/ (${gone.join(", ")})`);
190
+ return { items, replication, error };
191
+ }
129
192
  export async function destroy(argv, deps = {}) {
130
193
  const io = deps.io ?? STDIO;
131
194
  const { out } = io;
@@ -133,12 +196,36 @@ export async function destroy(argv, deps = {}) {
133
196
  if (typeof f === "number")
134
197
  return f;
135
198
  const json = f.values.json === true;
199
+ const dryRun = f.values["dry-run"] === true;
136
200
  if (f.positionals.length)
137
201
  return failed("destroy", { message: `it takes no name (${f.positionals.join(" ")}): a project is its folder`, code: "usage", usage: DESTROY_USAGE, fix: "cd into the project's folder, then mason destroy" }, { json, io });
138
202
  const t = theProject(deps.cwd ?? process.cwd());
139
203
  if ("problem" in t)
140
204
  return failed("destroy", t, { json, io });
141
205
  const name = t.project.config.name;
206
+ const kept = `your files in ${t.project.folder}, .mason/config.json and the users` +
207
+ // the demo database is state like the copy (src/cli/demo.ts): it goes, and config.demo stays to make it again
208
+ (t.project.config.demo === true ? ` — not the demo database, which ${dryRun ? "would go" : "went"} with the synced data: the next mason start seeds it again` : "");
209
+ if (dryRun) {
210
+ const plan = await (deps.destroyPlan ?? destroyPlan)(t.project);
211
+ const next = plan.error ? [] : [["mason destroy", "remove them"]];
212
+ if (json) {
213
+ out(jsonText(DESTROY, { dryRun: true, destroyed: { project: name, removedItems: plan.items, replication: plan.replication, error: plan.error } }, next));
214
+ return plan.error ? 1 : 0;
215
+ }
216
+ if (plan.error) {
217
+ return failed("destroy", { message: `${name}: ${plan.error}`, code: "failed", why: plan.items.length ? `mason destroy would remove: ${plan.items.join(", ")}` : undefined,
218
+ fix: "nothing was changed (a dry run)" }, { json: false, io });
219
+ }
220
+ out(withEnd(reportText({
221
+ headline: plan.items.length ? `mason destroy would remove ${name}'s synced data and state; nothing was changed` : `Nothing is left of ${name} to remove; nothing was changed`,
222
+ details: [...(plan.items.length ? [["would remove", plan.items.join(", ")]] : []), ["would keep", kept]],
223
+ }), { next }));
224
+ return 0;
225
+ }
226
+ // what replication made on the database, named before it goes (read from the config: nothing is connected for it)
227
+ const target = replicationTarget(t.project);
228
+ const replication = target === null ? null : replicationFacts(target);
142
229
  let result;
143
230
  try {
144
231
  result = { name, removed: await (deps.removeProject ?? removeProject)(t.project) };
@@ -148,16 +235,14 @@ export async function destroy(argv, deps = {}) {
148
235
  }
149
236
  const next = result.error ? [] : [["mason start", "syncs the tables again"]];
150
237
  if (json) {
151
- out(jsonText(DESTROY, { removed: [result] }, next));
238
+ out(jsonText(DESTROY, { dryRun: false, destroyed: { project: name, removedItems: result.removed, replication, error: result.error ?? null }, removed: [result] }, next));
152
239
  return result.error ? 1 : 0;
153
240
  }
154
241
  if (result.error)
155
242
  return failed("destroy", { message: `${name}: ${result.error}`, code: "failed" }, { json: false, io });
156
243
  out(withEnd(reportText({
157
244
  headline: result.removed.length ? `Removed ${name}` : `Nothing was left of ${name} to remove`,
158
- details: [...(result.removed.length ? [["removed", result.removed.join(", ")]] : []), ["kept", `your files in ${t.project.folder}, .mason/config.json and the users` +
159
- // the demo database is state like the copy (src/cli/demo.ts): it goes, and config.demo stays to make it again
160
- (t.project.config.demo === true ? ` — not the demo database, which went with the synced data: the next mason start seeds it again` : "")]],
245
+ details: [...(result.removed.length ? [["removed", result.removed.join(", ")]] : []), ["kept", kept]],
161
246
  }), { next }));
162
247
  return 0;
163
248
  }
@@ -12,28 +12,71 @@
12
12
  */
13
13
  // FIRST: it sizes libuv's pool, which nothing below may have started (src/cli/pool-size.cjs)
14
14
  import "./pool-size.cjs";
15
- import { COMMANDS, orientation, topUsage, version } from "./registry.js";
15
+ import { COMMANDS, orientation, runVersion, topUsage } from "./registry.js";
16
16
  import { didYouMean } from "../lib/did-you-mean.js";
17
17
  import { spinUntilOutput } from "./style.js";
18
18
  import { CommandFailed, failed, wantsJson } from "./command.js";
19
19
  import { RunUnreachable } from "./control.js";
20
20
  import { exitWhenWritten } from "../lib/exit-when-written.js";
21
21
  import { gatedExit, installOutputGate } from "./output-gate.js";
22
- async function main(argv) {
22
+ import * as fs from "node:fs";
23
+ /** `-C <folder>` before the command, as git's and make's (each one from the last): the folders, and the line after them. */
24
+ function leadingFolders(argv) {
25
+ const folders = [];
26
+ let i = 0;
27
+ while (argv[i] === "-C") {
28
+ folders.push(argv[i + 1]);
29
+ i += 2;
30
+ }
31
+ return { folders, rest: argv.slice(i) };
32
+ }
33
+ /** Runs in each `-C` folder in turn, so the command means the project there; a folder missing or unreadable is a wrong
34
+ * line, said before anything runs. Null once moved. */
35
+ function moveTo(folders, json) {
36
+ for (const dir of folders) {
37
+ const wrong = (message) => failed("", { message, code: "usage", fix: "mason -C <folder> <command>: the command runs as if started in that folder" }, { json });
38
+ if (dir === undefined || dir === "")
39
+ return wrong("-C needs a folder: mason -C <folder> <command>");
40
+ let st;
41
+ try {
42
+ st = fs.statSync(dir);
43
+ }
44
+ catch (e) {
45
+ return wrong(`-C ${JSON.stringify(dir)}: ${e.code === "ENOENT" ? "there is no such folder" : "it cannot be read"}`);
46
+ }
47
+ if (!st.isDirectory())
48
+ return wrong(`-C ${JSON.stringify(dir)}: it is not a folder`);
49
+ try {
50
+ fs.accessSync(dir, fs.constants.R_OK | fs.constants.X_OK);
51
+ process.chdir(dir);
52
+ }
53
+ catch {
54
+ return wrong(`-C ${JSON.stringify(dir)}: it cannot be read`);
55
+ }
56
+ }
57
+ return null;
58
+ }
59
+ async function main(line) {
60
+ // -C <folder> first: everything after it, the project lookup included, is as if started there
61
+ const { folders, rest: argv } = leadingFolders(line);
23
62
  const [command, ...args] = argv;
63
+ const moved = moveTo(folders, wantsJson(args, COMMANDS.find((c) => c.name === command)));
64
+ if (moved !== null)
65
+ return moved;
24
66
  // mason alone: where you are and the next command; mason help / --help: every command
25
67
  if (command === undefined) {
26
68
  process.stdout.write(await orientation(process.cwd()));
27
69
  return 0;
28
70
  }
71
+ // mason --help --json is mason help --json: every command's spec as data
29
72
  if (command === "--help" || command === "-h") {
73
+ if (args.includes("--json"))
74
+ return COMMANDS.find((c) => c.name === "help").run(["--json"]);
30
75
  process.stdout.write(topUsage());
31
76
  return 0;
32
77
  }
33
- if (command === "--version" || command === "-v" || command === "version") {
34
- process.stdout.write(`${version()}\n`);
35
- return 0;
36
- }
78
+ if (command === "--version" || command === "-v" || command === "version")
79
+ return runVersion(args);
37
80
  const cmd = COMMANDS.find((c) => c.name === command);
38
81
  const json = wantsJson(args, cmd);
39
82
  // the commands' old names: said, never run as different ones
@@ -52,7 +95,7 @@ async function main(argv) {
52
95
  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"]] },
53
96
  };
54
97
  if (!cmd && Object.hasOwn(RENAMED, command))
55
- return failed("", { ...RENAMED[command], code: "usage" }, { json });
98
+ return failed("", { ...RENAMED[command], code: "usage", fix: "run the command that does it now: the next steps name it" }, { json });
56
99
  if (!cmd)
57
100
  return failed("", { message: `there is no command ${JSON.stringify(command)}${didYouMean(command, COMMANDS.map((c) => c.name))}`, code: "usage", usage: json ? undefined : topUsage() }, { json });
58
101
  // a command quiet for a moment shows a spinner on a terminal until it prints (src/cli/style.ts spinUntilOutput)
@@ -81,7 +124,7 @@ main(process.argv.slice(2)).then((code) => exitWhenWritten(gatedExit(code)), (e)
81
124
  // stack to be understood is a bug — mason query orders printed one when Mason's API was not up yet).
82
125
  // MASON_DEBUG=1 prints where it happened, for the bug report it deserves. The same shape as every failure, JSON under --json.
83
126
  const message = e instanceof Error ? e.message : String(e);
84
- const [command, ...args] = process.argv.slice(2);
127
+ const [command, ...args] = leadingFolders(process.argv.slice(2)).rest;
85
128
  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)) });
86
129
  if (process.env.MASON_DEBUG)
87
130
  process.stderr.write(`${e.stack ?? ""}\n`);
@@ -43,26 +43,24 @@ export const MCP = {
43
43
  the request log under application_name mason-mcp/<the client's name>. It serves until the client closes stdin.
44
44
  Its stdout is the protocol's: it takes no --json, and a failure to start is said on stderr.
45
45
 
46
- Its tools: tables; describe (a table); query (one statement; max_rows default 200, at most 10000; timeout_ms
47
- default 30000, at most 300000); explain (a statement). Its resources: mason://help/agent and mason://help/mcp.
48
-
49
46
  Claude Code (in the project's folder, with Mason running), the URL in the server's environment:
50
47
  mason user add claude the URL to paste, printed once
51
48
  claude mcp add mason --env MASON_URL="${EXAMPLE_URL}" -- mason mcp
52
- or on its command line, where ps shows it to this machine's users and claude mcp add prints it:
53
- claude mcp add mason -- mason mcp --url "${EXAMPLE_URL}"
54
-
55
- Claude Desktop (claude_desktop_config.json). The app often starts servers without your shell's PATH, where neither
56
- mason nor the node its first line asks for is found: name both by their absolute paths (which node, which mason):
57
- { "mcpServers": { "mason": { "command": "/absolute/path/to/node", "args": ["/absolute/path/to/mason", "mcp"],
58
- "env": { "MASON_URL": "${EXAMPLE_URL}" } } } }
59
49
 
60
50
  What the client runs:
61
51
  MASON_URL="${EXAMPLE_URL}" mason mcp
62
- mason mcp --url "${EXAMPLE_URL}"
63
52
 
64
53
  --url <url> the Postgres API as a mason user: what mason user add printed (default MASON_URL); TLS as its sslmode
65
- says (a host off this machine: require when it names none)
54
+ says (a host off this machine: require when it names none). A URL with a password here is taken,
55
+ with a warning on stderr: ps shows it to this machine's users, so MASON_URL is the safer place
56
+
57
+ Its tools: tables; describe (a table); query (one statement; max_rows default 200, at most 10000; timeout_ms
58
+ default 30000, at most 300000); explain (a statement). Its resources: mason://help/agent and mason://help/mcp.
59
+
60
+ Claude Desktop (claude_desktop_config.json). The app often starts servers without your shell's PATH, where neither
61
+ mason nor the node its first line asks for is found: name both by their absolute paths (which node, which mason):
62
+ { "mcpServers": { "mason": { "command": "/absolute/path/to/node", "args": ["/absolute/path/to/mason", "mcp"],
63
+ "env": { "MASON_URL": "${EXAMPLE_URL}" } } } }
66
64
  `,
67
65
  options: { url: { type: "string" } },
68
66
  stdout: "protocol",
@@ -71,6 +69,8 @@ export const MCP = {
71
69
  * its own log (Claude Code: claude --debug; Claude Desktop: mcp-server-mason.log). A failure to START is failed()'s. */
72
70
  const log = (m) => { process.stderr.write(`[mason mcp] ${m}\n`); };
73
71
  const NEED_URL = "mason mcp needs a mason user's URL: MASON_URL, or --url";
72
+ /** What `--url` with a password is told on stderr: the safer form, never the password. */
73
+ export const MCP_ARGV_PASSWORD_WARNING = "warning: --url holds the user's password, which ps shows to this machine's users: put the URL in the server's environment as MASON_URL instead (claude mcp add mason --env MASON_URL=… -- mason mcp)";
74
74
  const HELP_STEP = ["mason help mcp", "how to add mason mcp to Claude Code or Claude Desktop"];
75
75
  const ADD_STEP = ["mason user add claude", "a user for the agent: its password and URL, printed once"];
76
76
  /** Where `mason mcp` connects: the URL given, or why there is none (the project's state says what to run). */
@@ -156,6 +156,9 @@ export async function mcp(argv) {
156
156
  const source = theSource(u.url, u.from);
157
157
  if ("message" in source)
158
158
  return failed("mcp", source, { json });
159
+ // a password on the command line is taken (a client's config may hold it so), and said with the safer form; never repeated
160
+ if (u.from === "--url" && source.password !== "")
161
+ log(MCP_ARGV_PASSWORD_WARNING);
159
162
  // the URL is checked once, before the protocol starts: a client that cannot connect is told now, in the failure shape
160
163
  try {
161
164
  (await connect(source, "mason-mcp")).close();
@@ -162,7 +162,7 @@ export function theProject(cwd) {
162
162
  if (p)
163
163
  return { project: p };
164
164
  return { problem: "no Mason project here or above", code: "no_project", fix: "cd into a project's folder, or make this folder one (mason init demo: a demo database, nothing to install)",
165
- next: [["mason init", "make this folder a project (MASON_DATABASE_URL or DATABASE_URL from .env, or mason init postgres://…)"]] };
165
+ next: [["mason init", "make this folder a project (MASON_DATABASE_URL or DATABASE_URL from .env, or its URL on stdin: mason init -)"]] };
166
166
  }
167
167
  /** Whether `root`'s .gitignore already ignores `.mason/`. */
168
168
  export function ignoresStateDir(gitignore) {