@mason-data/cli 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +6 -4
  2. package/dist/src/api/error.js +4 -0
  3. package/dist/src/api/postgres/proxy.js +6 -160
  4. package/dist/src/api/postgres/server.js +22 -4
  5. package/dist/src/cli/command.js +169 -5
  6. package/dist/src/cli/commands.js +295 -159
  7. package/dist/src/cli/deploy.js +134 -28
  8. package/dist/src/cli/dev.js +21 -5
  9. package/dist/src/cli/doctor.js +14 -11
  10. package/dist/src/cli/guides.js +20 -11
  11. package/dist/src/cli/hosted.js +1 -1
  12. package/dist/src/cli/init.js +110 -25
  13. package/dist/src/cli/login.js +20 -17
  14. package/dist/src/cli/machine.js +125 -40
  15. package/dist/src/cli/main.js +51 -8
  16. package/dist/src/cli/mcp.js +15 -12
  17. package/dist/src/cli/project.js +25 -2
  18. package/dist/src/cli/registry.js +80 -35
  19. package/dist/src/cli/replica-hosted.js +761 -102
  20. package/dist/src/cli/replica.js +43 -5
  21. package/dist/src/cli/replication-mode.js +5 -0
  22. package/dist/src/cli/request-filter.js +16 -6
  23. package/dist/src/cli/run-server.js +70 -31
  24. package/dist/src/cli/secret-input.js +193 -0
  25. package/dist/src/cli/security.js +7 -2
  26. package/dist/src/cli/serve.js +119 -9
  27. package/dist/src/cli/share.js +20 -15
  28. package/dist/src/cli/source-role.js +14 -15
  29. package/dist/src/cli/sources.js +20 -3
  30. package/dist/src/cli/style.js +14 -0
  31. package/dist/src/cli/sync.js +17 -8
  32. package/dist/src/cli/token-hosted.js +30 -24
  33. package/dist/src/cli/tunnel.js +1 -1
  34. package/dist/src/cli/ui-hosted.js +163 -0
  35. package/dist/src/cli/ui-read-only.js +23 -0
  36. package/dist/src/cli/ui-run-file.js +247 -0
  37. package/dist/src/cli/ui-socket.js +183 -0
  38. package/dist/src/cli/ui-ssh.js +3 -3
  39. package/dist/src/cli/ui.js +67 -26
  40. package/dist/src/cli/usage.js +19 -15
  41. package/dist/src/cli/user-hosted.js +78 -13
  42. package/dist/src/cli/user.js +168 -50
  43. package/dist/src/duck/users.js +105 -9
  44. package/dist/src/lib/cidr.js +177 -0
  45. package/dist/src/lib/table-sync.js +69 -19
  46. package/dist/src/web/app.css +5 -0
  47. package/dist/src/web/app.js +4 -4
  48. package/dist/src/web/index.html +5 -8
  49. package/dist/src/web/pages/queries.js +3 -3
  50. package/dist/src/web/pages/status.js +23 -17
  51. package/dist/src/web/pages/syncs.js +3 -3
  52. package/dist/src/web/pages/tables.js +18 -15
  53. package/dist/src/web/pages/usage.js +16 -12
  54. package/dist/src/web/pages/users.js +45 -14
  55. package/dist/src/web/shared.js +10 -4
  56. package/package.json +6 -6
@@ -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();
@@ -21,6 +21,9 @@ import { randomBytes } from "node:crypto";
21
21
  import { connectDatabase, databaseSettingRefusal } from "../lib/database-name.js";
22
22
  import { changesRetired, pollMsRetired } from "../lib/table-sync.js";
23
23
  import { readSource, sourcesTop, writtenDatabase } from "./sources.js";
24
+ import { UI_SOCKET_SETTING } from "./ui-socket.js";
25
+ import { UI_RUN_FILE_SETTING } from "./ui-run-file.js";
26
+ import { UI_READ_ONLY_SETTING, uiReadOnlySetting } from "./ui-read-only.js";
24
27
  export const STATE_DIR = ".mason";
25
28
  const CONFIG = "config.json";
26
29
  /** A project's name: what its commands call it and (with the source's) what its replication slot is named for. Never
@@ -162,7 +165,7 @@ export function theProject(cwd) {
162
165
  if (p)
163
166
  return { project: p };
164
167
  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://…)"]] };
168
+ next: [["mason init", "make this folder a project (MASON_DATABASE_URL or DATABASE_URL from .env, or its URL on stdin: mason init -)"]] };
166
169
  }
167
170
  /** Whether `root`'s .gitignore already ignores `.mason/`. */
168
171
  export function ignoresStateDir(gitignore) {
@@ -263,5 +266,25 @@ export function startSettingRefusal(p, env, o) {
263
266
  if (changes !== null)
264
267
  return { code: "refused", ...changes };
265
268
  const database = o.serve ? null : databaseSettingRefusal(env, top().shown);
266
- return database === null ? null : { code: "refused", ...database };
269
+ if (database !== null)
270
+ return { code: "refused", ...database };
271
+ // a read-only page is a server's (src/cli/ui-read-only.ts): a value not 0 or 1 refuses either start, and 1 mason start's
272
+ const pageReadOnly = uiReadOnlySetting(env);
273
+ if (typeof pageReadOnly !== "boolean")
274
+ return { message: pageReadOnly.problem, code: "refused", fix: `set ${UI_READ_ONLY_SETTING}=1 or ${UI_READ_ONLY_SETTING}=0, or unset it` };
275
+ if (!o.serve && pageReadOnly) {
276
+ return { message: `${UI_READ_ONLY_SETTING} is mason serve's setting, and mason start ignores no setting silently`, code: "refused",
277
+ fix: `unset ${UI_READ_ONLY_SETTING}: mason start's page is yours alone, on this machine` };
278
+ }
279
+ // the page's Unix socket is a server's (src/cli/ui-socket.ts): a laptop's page is opened with mason ui
280
+ if (!o.serve && env[UI_SOCKET_SETTING]) {
281
+ return { message: `${UI_SOCKET_SETTING} is mason serve's setting, and mason start ignores no setting silently`, code: "refused",
282
+ fix: `unset ${UI_SOCKET_SETTING}: mason ui opens this run's page` };
283
+ }
284
+ // the run's file is a server's supervisor's (src/cli/ui-run-file.ts): a laptop's commands read the project's config
285
+ if (!o.serve && env[UI_RUN_FILE_SETTING]) {
286
+ return { message: `${UI_RUN_FILE_SETTING} is mason serve's setting, and mason start ignores no setting silently`, code: "refused",
287
+ fix: `unset ${UI_RUN_FILE_SETTING}: mason ui opens this run's page, and the mason commands reach the run by themselves` };
288
+ }
289
+ return null;
267
290
  }
@@ -3,7 +3,7 @@
3
3
  * command's summary, in this order — so a command cannot exist without being listed, and tests/cli-help.test.ts holds
4
4
  * each one to src/cli/command.ts's rules (its own --help, an example, every option documented).
5
5
  */
6
- import { failed, parseCommand, reportText, wantsJson } from "./command.js";
6
+ import { FAILURE_CODES, SPEC_NEXT, failed, jsonText, parseCommand, reportText, specAnswer, specJson, wantsJson } from "./command.js";
7
7
  import { removedStartFlags } from "../lib/table-sync.js";
8
8
  import { sourcesTop } from "./sources.js";
9
9
  import { dev } from "./dev.js";
@@ -37,7 +37,7 @@ export const DEV = {
37
37
  needs: "project",
38
38
  name: "start",
39
39
  summary: "start Mason for this folder's project, in the background (mason init makes the project)",
40
- usage: `usage: mason start [--attach] [--self-signed-tls] [--port <n>] [--json]
40
+ usage: `usage: mason start [--attach] [--self-signed-tls] [--ui-port <n>] [--json]
41
41
 
42
42
  Starts Mason for the project of this folder (mason init made it): one process, which syncs the tables you add
43
43
  (mason add) from the project's database into its own file, keeps them current as the sources file says, and answers
@@ -45,6 +45,17 @@ export const DEV = {
45
45
  its sync: says replication). It returns once Mason is up; run it again any time (it says so when Mason is
46
46
  already running). The synced data and Mason's state stay between runs: the next start resumes.
47
47
 
48
+ mason start
49
+ mason start --attach in the foreground: Ctrl-C stops it
50
+ MASON_PG_HOST=0.0.0.0 mason start --self-signed-tls the Postgres API for other machines, over TLS
51
+
52
+ --attach stay in the foreground (Ctrl-C stops Mason)
53
+ --self-signed-tls the Postgres API requires TLS, from a certificate made for this project in .mason/ (kept and
54
+ served again by every later start with the flag); not with MASON_PG_TLS_CERT / _KEY
55
+ --ui-port <n> the port of its web page and of the mason commands, on 127.0.0.1 (default ${DEFAULT_UI_PORT}, or the
56
+ next free one); MASON_UI_PORT on a server, never the Postgres API's (MASON_PG_PORT)
57
+ --json as data, once it is up: { running, pid, postgres: { url, host, port, database, sslmode }, next }
58
+
48
59
  After its first sync, each table is kept current as the sources file's sync: says — at the top for every table, or
49
60
  on a table's entry: off (the default, which mason init writes) reads it again only when you run mason sync <table>;
50
61
  poll <interval> (poll 5s, poll 5m, poll 1h) polls it with SELECTs at that interval; replication reads its changes
@@ -61,7 +72,8 @@ export const DEV = {
61
72
  created, altered or dropped — Mason makes only its slot. A read replica needs one (it takes no CREATE PUBLICATION).
62
73
 
63
74
  --replication, --poll and --publication are gone: each answers with the line to write at the top of the sources
64
- file (sync: replication, sync: poll 5m, publication: <name>) and exit 2, and starts nothing.
75
+ file (sync: replication, sync: poll 5m, publication: <name>) and exit 2, and starts nothing. --port is now
76
+ --ui-port: it answers with the line to run and exit 2.
65
77
 
66
78
  The Postgres API's port is the project's: the first start takes 5433, or the next port free on every address when a
67
79
  second Postgres holds it, and records it; every later start listens on the same port, so a BI tool's saved connection
@@ -78,42 +90,36 @@ export const DEV = {
78
90
  any other address is refused.
79
91
 
80
92
  ${PROJECT_LINE}
81
-
82
- mason start
83
- mason start --attach in the foreground: Ctrl-C stops it
84
- MASON_PG_HOST=0.0.0.0 mason start --self-signed-tls the Postgres API for other machines, over TLS
85
-
86
- --attach stay in the foreground (Ctrl-C stops Mason)
87
- --self-signed-tls the Postgres API requires TLS, from a certificate made for this project in .mason/ (kept and
88
- served again by every later start with the flag); not with MASON_PG_TLS_CERT / _KEY
89
- --port <n> the port the mason commands reach it on (default ${DEFAULT_UI_PORT}, or the next free one)
90
- --json as data, once it is up: { running, pid, postgres: { url, host, port, database, sslmode }, next }
91
93
  `,
92
94
  options: {
93
- port: { type: "string" }, attach: { type: "boolean", default: false }, json: { type: "boolean", default: false },
95
+ "ui-port": { type: "string" }, attach: { type: "boolean", default: false }, json: { type: "boolean", default: false },
94
96
  "self-signed-tls": { type: "boolean", default: false },
95
97
  },
96
98
  json: ["running", "pid", "postgres", "next"],
97
99
  };
98
100
  async function runDev(argv) {
99
- // --replication, --poll and --publication are gone (issue 33): each answered with the edit to make in the sources
101
+ // --replication, --poll and --publication are gone (issue 33), and --port is --ui-port: each answered with the edit to make in the sources
100
102
  // file, exit 2, before the options are parsed (it would be an unknown option there) and before anything starts. The
101
103
  // file is read only when one is given. A decision: `mason start --replication --help` answers the refusal, not the
102
104
  // usage — the flag is wrong whatever else the line asks
103
105
  const removed = removedStartFlags(argv, () => startSourcesTop(process.cwd()));
104
- if (removed !== null)
105
- return failed("start", { code: "usage", ...removed, next: [["mason start", "each table kept current as its sync: says"], ["mason help production", "sync: and publication:, and what each needs"]] }, { json: wantsJson(argv, DEV) });
106
+ if (removed !== null) {
107
+ const { start, sync, ...said } = removed;
108
+ const next = sync ? [[start, "each table kept current as its sync: says"], ["mason help production", "sync: and publication:, and what each needs"]]
109
+ : [[start, "Mason, its web page and the commands on that port"]];
110
+ return failed("start", { code: "usage", ...said, next }, { json: wantsJson(argv, DEV) });
111
+ }
106
112
  const f = parseCommand(DEV, argv);
107
113
  if (typeof f === "number")
108
114
  return f;
109
115
  const v = f.values;
110
116
  if (f.positionals.length) {
111
117
  // the database is the project's now: a URL here is the old habit, answered with the new way
112
- return failed("start", { message: "it takes no database URL: the project has one", code: "usage", why: "mason init <url> made it; .mason/config.json holds it", usage: DEV.usage }, { json: v.json });
118
+ return failed("start", { message: "it takes no database URL: the project has one", code: "usage", why: "mason init <url> made it; .mason/config.json holds it", fix: "run mason start alone", usage: DEV.usage }, { json: v.json });
113
119
  }
114
- const port = v.port === undefined ? DEFAULT_UI_PORT : Number(v.port);
120
+ const port = v["ui-port"] === undefined ? DEFAULT_UI_PORT : Number(v["ui-port"]);
115
121
  if (!Number.isInteger(port) || port < 1 || port > 65_535)
116
- return failed("start", { message: `--port must be a port number (1-65535), not ${JSON.stringify(v.port)}`, code: "usage" }, { json: v.json });
122
+ return failed("start", { message: `--ui-port must be a port number (1-65535), not ${JSON.stringify(v["ui-port"])}`, code: "usage" }, { json: v.json });
117
123
  if (!v.attach)
118
124
  return devInBackground(argv, { json: v.json, selfSignedTls: v["self-signed-tls"] });
119
125
  return dev({ port, version: version(), selfSignedTls: v["self-signed-tls"] });
@@ -127,7 +133,7 @@ function startSourcesTop(cwd) {
127
133
  export const HELP = {
128
134
  name: "help",
129
135
  summary: "the commands by step, or one command's usage: mason help add",
130
- usage: `usage: mason help [<command> | <guide>]
136
+ usage: `usage: mason help [<command> | <guide>] [--json]
131
137
 
132
138
  The commands by step (mason help), how to use one (mason help add — the same as mason add --help), or a guide,
133
139
  offline, short enough to read whole and listing exactly what Mason accepts:
@@ -135,35 +141,74 @@ export const HELP = {
135
141
  mason help
136
142
  mason help add
137
143
  ${Object.entries(GUIDES).map(([k, v]) => ` mason help ${k.padEnd(14)} ${v}`).join("\n")}
144
+ mason help --json every command's spec as data (mason help agent says its shape)
145
+
146
+ --json as data: mason help, { version, commands: [<spec>…], guides: [{ name, summary }], failureCodes:
147
+ [{ code, meaning }], next }; mason help <command> (the same as mason <command> --help --json), { command:
148
+ <spec>, next }; mason help <guide>, { guide: { name, summary, text }, next }
138
149
  `,
139
- options: {},
150
+ options: { json: { type: "boolean", default: false } },
151
+ json: ["version", "commands", "guides", "failureCodes", "command", "guide", "next"],
140
152
  };
153
+ /** `mason version` (also `--version`, `-v`): the CLI's version, as text or with --json as data. Answered by main.ts
154
+ * before the registry, so not one of COMMANDS; described here so `mason help version` and its --json read one spec. */
155
+ export const VERSION = {
156
+ name: "version",
157
+ summary: "the CLI's version",
158
+ usage: `usage: mason version [--json]
159
+
160
+ The CLI's version (mason --version and mason -v are the same):
161
+
162
+ mason version
163
+
164
+ --json as data: { version, next }
165
+ `,
166
+ options: { json: { type: "boolean", default: false } },
167
+ json: ["version", "next"],
168
+ };
169
+ /** `mason version` (and `--version`, `-v`): its line read by VERSION, as every command's is by its spec, so what it
170
+ * accepts is what its spec as data says — `--help`, `--help --json`, a wrong option or an argument refused (exit 2). */
171
+ export function runVersion(argv) {
172
+ const f = parseCommand(VERSION, argv);
173
+ if (typeof f === "number")
174
+ return f;
175
+ const json = f.values.json === true;
176
+ if (f.positionals.length)
177
+ return failed("version", { message: `it takes no argument: ${f.positionals.join(" ")}`, code: "usage", usage: VERSION.usage }, { json });
178
+ process.stdout.write(`${json ? jsonText(VERSION, { version: version() }) : version()}\n`);
179
+ return 0;
180
+ }
141
181
  async function runHelp(argv) {
142
182
  const f = parseCommand(HELP, argv);
143
183
  if (typeof f === "number")
144
184
  return f;
185
+ const json = f.values.json === true;
145
186
  const [asked, ...rest] = f.positionals;
146
187
  if (rest.length)
147
- return failed("help", { message: "one command or guide at a time", code: "usage", usage: HELP.usage }, { json: false });
188
+ return failed("help", { message: "one command or guide at a time", code: "usage", usage: HELP.usage }, { json });
148
189
  if (asked === undefined) {
149
- process.stdout.write(topUsage());
150
- return 0;
151
- }
152
- if (asked === "version") {
153
- process.stdout.write("usage: mason version (or --version, -v): the CLI's version\n");
190
+ if (!json) {
191
+ process.stdout.write(topUsage());
192
+ return 0;
193
+ }
194
+ process.stdout.write(`${jsonText(HELP, {
195
+ version: version(), commands: [...COMMANDS, VERSION].map(specJson), guides: Object.entries(GUIDES).map(([name, summary]) => ({ name, summary })),
196
+ failureCodes: Object.entries(FAILURE_CODES).map(([code, meaning]) => ({ code, meaning })),
197
+ }, [["mason help agent", "driving Mason from a coding agent: these shapes, the failures and the exit codes"]])}\n`);
154
198
  return 0;
155
199
  }
156
200
  if (Object.hasOwn(GUIDES, asked)) {
157
- process.stdout.write(await guideText(asked));
201
+ const text = await guideText(asked);
202
+ process.stdout.write(json ? `${jsonText(HELP, { guide: { name: asked, summary: GUIDES[asked], text } }, SPEC_NEXT)}\n` : text);
158
203
  return 0;
159
204
  }
160
- const cmd = COMMANDS.find((c) => c.name === asked);
205
+ const cmd = asked === "version" ? VERSION : COMMANDS.find((c) => c.name === asked);
161
206
  if (cmd) {
162
- process.stdout.write(cmd.usage);
207
+ process.stdout.write(json ? `${specAnswer(cmd)}\n` : cmd.usage);
163
208
  return 0;
164
209
  }
165
210
  return failed("help", { message: `there is no command or guide ${JSON.stringify(asked)}${didYouMean(asked, [...COMMANDS.map((c) => c.name), ...Object.keys(GUIDES)])}`, code: "usage",
166
- why: `the guides: ${Object.keys(GUIDES).join(", ")}`, next: [["mason help", "every command"]] }, { json: false });
211
+ why: `the guides: ${Object.keys(GUIDES).join(", ")}`, fix: "name a command or a guide mason help lists", next: [["mason help", "every command"]] }, { json });
167
212
  }
168
213
  /**
169
214
  * EVERY COMMAND, IN THE ORDER A FIRST SESSION NEEDS THEM (listed in the order a developer will likely need them, the
@@ -198,7 +243,7 @@ export async function orientation(cwd, s = styleOut()) {
198
243
  ? [["Project", `${project.config.name}, files in ${project.folder}`], ["Mason", run ? `running (pid ${run.pid})` : "not running"]]
199
244
  : [["Project", "none here: mason init makes this folder one"]];
200
245
  const next = !project
201
- ? [["mason init", "make this folder a project (MASON_DATABASE_URL or DATABASE_URL from .env, or mason init postgres://…)"],
246
+ ? [["mason init", "make this folder a project (MASON_DATABASE_URL or DATABASE_URL from .env, or its URL on stdin: mason init -)"],
202
247
  ["mason init demo", "or try it on a demo database Mason makes itself: nothing to install"],
203
248
  ["mason doctor", "what this machine needs for Mason, and whether it has it"]]
204
249
  : !run ? [["mason start", "start Mason for this project, in the background"], ["mason status", "the project and its tables"]]
@@ -238,10 +283,10 @@ export function topUsage(s = styleOut()) {
238
283
  listed.map(([step, es]) => ` ${s.bold(step)}\n${block(es)}`).join("\n\n"),
239
284
  "",
240
285
  para("mason <command> --help (or mason help <command>) says how to use one, with examples; the guides are offline and short enough to read whole."),
241
- para("A command means the project of the folder it runs in (or above it)."),
286
+ para("A command means the project of the folder it runs in (or above it); mason -C <folder> <command> runs it as if started in that folder."),
242
287
  para("Every command but help and mcp (whose stdout is the MCP protocol's) takes --json: one JSON object for a program, always with next (what to run " +
243
288
  "next); a failure is { error: { code, message, why, fix }, next }. The exit code says how it went: 0 done, 1 failed " +
244
- "with the reason, 2 a wrong command line, 124 a wait timed out (mason wait, mason deploy, mason replica create). Driving Mason from a coding agent: mason help agent."),
289
+ "with the reason, 2 a wrong command line, 124 a wait timed out (mason wait, mason deploy, mason replica create, mason replica delete). Driving Mason from a coding agent: mason help agent."),
245
290
  "",
246
291
  ].join("\n");
247
292
  }