@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
@@ -6,7 +6,7 @@
6
6
  * polled: its answer is where the stream is, beside the polled ones'. The run's `POST /api/sync` (src/cli/run-server.ts) answers it, through the sync's own
7
7
  * poll (`Sync.pollNow`).
8
8
  */
9
- import { failed, jsonText, parseCommand, reportText } from "./command.js";
9
+ import { failed, failureCode, jsonText, parseCommand, reportText } from "./command.js";
10
10
  import { out as styleOut } from "./style.js";
11
11
  import { callRun } from "./control.js";
12
12
  import { reach } from "./state.js";
@@ -15,7 +15,7 @@ import { didYouMean } from "../lib/did-you-mean.js";
15
15
  import { syncedText, syncWords } from "../lib/table-sync.js";
16
16
  const out = (m) => { process.stdout.write(`${m}\n`); };
17
17
  const n = (x) => x.toLocaleString("en-US");
18
- export const SYNC_USAGE = `usage: mason sync <table>… [--json]
18
+ export const SYNC_USAGE = `usage: mason sync (<table>… | --all) [--json]
19
19
 
20
20
  Brings these synced tables up to date now: it reads what changed in your database since Mason last read them —
21
21
  the rows inserted or updated, and the ones deleted — in one transaction, as a poll does (each one scans its table
@@ -27,7 +27,9 @@ export const SYNC_USAGE = `usage: mason sync <table>… [--json]
27
27
 
28
28
  mason sync orders
29
29
  mason sync orders public.events
30
+ mason sync --all every table the sources file names (each one a scan of its table)
30
31
 
32
+ --all every table the sources file names, instead of naming them
31
33
  --json as data: { syncedAt, tables: [{ table, changed, deleted, rows, ms }] (the tables polled), streamed (the
32
34
  tables whose sync: is replication, not polled: [{ table, position, state, why }]), next }
33
35
  `;
@@ -36,7 +38,7 @@ export const SYNC = {
36
38
  name: "sync",
37
39
  summary: "bring these synced tables up to date now: mason sync orders (a table whose sync is off is read again only so)",
38
40
  usage: SYNC_USAGE,
39
- options: { json: { type: "boolean" } },
41
+ options: { json: { type: "boolean" }, all: { type: "boolean" } },
40
42
  json: ["syncedAt", "tables", "streamed", "next"],
41
43
  };
42
44
  /**
@@ -67,8 +69,11 @@ export async function sync(argv) {
67
69
  return f;
68
70
  const json = f.values.json === true;
69
71
  const names = [...new Set(f.positionals)];
70
- if (!names.length)
71
- return failed("sync", { message: "name a table: mason sync orders (mason status lists the added ones)", code: "usage", usage: SYNC_USAGE }, { json });
72
+ const all = f.values.all === true;
73
+ if (all && names.length)
74
+ return failed("sync", { message: `--all syncs every added table: name tables (${names.join(" ")}) or --all, not both`, code: "usage", usage: SYNC_USAGE }, { json });
75
+ if (!names.length && !all)
76
+ return failed("sync", { message: "name a table: mason sync orders (mason status lists the added ones), or mason sync --all", code: "usage", usage: SYNC_USAGE }, { json });
72
77
  const w = await reach(SYNC, json);
73
78
  if (w === null || !("run" in w))
74
79
  return 1;
@@ -76,7 +81,11 @@ export async function sync(argv) {
76
81
  return guarded("sync", json, async () => {
77
82
  const s = await readState(run);
78
83
  const added = s.tables.filter((t) => t.added);
79
- const picked = [];
84
+ // --all: every table the sources file names, as the run read it
85
+ const picked = all ? added.map((t) => t.table) : [];
86
+ if (all && !picked.length) {
87
+ return failed("sync", { message: "no table is added yet, so there is nothing to sync", code: "nothing_yet", next: [["mason tables", "the database's tables"], ["mason add <table>", "add one to the sources file"]] }, { json });
88
+ }
80
89
  for (const name of names) {
81
90
  const t = findTable(added, name);
82
91
  if (!("message" in t)) {
@@ -97,12 +106,12 @@ export async function sync(argv) {
97
106
  // a sync scans each table whole on the database (and waits for a copy under way): minutes, not the commands' 30 s
98
107
  const r = await callRun(run, "POST", "/api/sync", { tables: picked }, 60 * 60_000);
99
108
  if (r.status !== 200) {
100
- const code = typeof r.body.code === "string" ? r.body.code : r.status === 404 ? "not_found" : "failed";
109
+ const code = failureCode(r.body.code, r.status === 404 ? "not_found" : "failed");
101
110
  return failed("sync", { message: `${picked.join(", ")} ${picked.length === 1 ? "was" : "were"} not synced`, code, why: String(r.body.error),
102
111
  next: [["mason status", "each synced table"], ["mason logs", "the run's log"]] }, { json });
103
112
  }
104
113
  const b = r.body;
105
- const next = [[`mason sync ${names.join(" ")}`, "sync again, whenever you need it current"], ["mason status", "how each table is kept current, and when it was last synced"]];
114
+ const next = [[all ? "mason sync --all" : `mason sync ${names.join(" ")}`, "sync again, whenever you need it current"], ["mason status", "how each table is kept current, and when it was last synced"]];
106
115
  if (json) {
107
116
  out(jsonText(SYNC, { syncedAt: b.at, tables: b.tables, ...(b.streamed ? { streamed: b.streamed } : {}) }, next));
108
117
  return 0;
@@ -28,9 +28,9 @@
28
28
  * createdAt, expiresAt, lastUsedAt rows, --json `isServiceTime` (lastUsedAt or null)
29
29
  * more a tip, --json a boolean
30
30
  */
31
- import { endText, failed, jsonText, parseCommand, reportText, serviceStyle, strict } from "./command.js";
31
+ import { endText, failed, jsonText, listFlag, parseCommand, reportText, serviceStyle, strict } from "./command.js";
32
32
  import { call, carriesNoSecret, echoesSecret, isFailure, isServiceTime, serviceFailure, serviceOf, serviceText, unexpected } from "./hosted.js";
33
- import { loginOrgs, orgNamed, REPLICA_PART, replicaOf, tokenFor } from "./replica.js";
33
+ import { loginOrgs, orgNamed, orgOf, REPLICA_PART, replicaOf, tokenFor } from "./replica.js";
34
34
  import { refusedNext } from "./replica-hosted.js";
35
35
  import { holdOutput } from "./output-gate.js";
36
36
  import { didYouMean } from "../lib/did-you-mean.js";
@@ -53,7 +53,7 @@ export const TOKEN = {
53
53
  name: "token",
54
54
  summary: "a hosted Mason service's CI tokens: make one for a replica (printed once), list them, revoke one",
55
55
  usage: `usage: mason token create --replica <org>/<name> [--scope deploy|users …] [--name <name>] [--days <n>] [--json]
56
- mason token list [<org>] [--json]
56
+ mason token list [<org> | --org <org>] [--json]
57
57
  mason token revoke <id> [--org <org>] [--json]
58
58
 
59
59
  A CI token lets CI act on ONE replica of a hosted Mason service, for the scopes it was made with (deploy: mason
@@ -61,6 +61,19 @@ export const TOKEN = {
61
61
  an owner of the organisation makes, lists and revokes them, signed in as a person (mason login): a CI token
62
62
  makes none.
63
63
 
64
+ mason token create --replica acme/shop --name github-actions
65
+ mason token create --replica acme/shop --scope deploy --scope users --days 30 --json
66
+ mason token list acme
67
+ mason token revoke 3f1c9a2e --org acme
68
+
69
+ --replica <org>/<name> create: the replica it acts on (else MASON_REPLICA)
70
+ --scope <scope,…> create: what it may do, deploy or users; both: --scope deploy,users or the flag twice
71
+ (default deploy)
72
+ --name <name> create: what the list calls it, 1 to 64 characters (default ${DEFAULT_NAME})
73
+ --days <n> create: the days it acts, 1 to ${MAX_DAYS} (default ${DEFAULT_DAYS})
74
+ --org <org> list: the organisation, in place of the argument; revoke: the organisation it is of
75
+ --json as data: its shape is at the end
76
+
64
77
  create makes one and prints it ONCE: the service does not keep it, and nothing shows it again, so store it at once
65
78
  (as MASON_TOKEN in CI's secrets). It is alone on the first line printed, never broken, or, with --json, in the
66
79
  one field token.value of the document, so a setup step can pipe it straight into a secret store. Nothing else
@@ -72,27 +85,18 @@ export const TOKEN = {
72
85
 
73
86
  The login is MASON_TOKEN, else this machine's (mason login); the service is MASON_CLOUD_URL, else the default.
74
87
 
75
- mason token create --replica acme/shop --name github-actions
76
- mason token create --replica acme/shop --scope deploy --scope users --days 30 --json
77
- mason token list acme
78
- mason token revoke 3f1c9a2e --org acme
79
-
80
- --replica <org>/<name> create: the replica it acts on (else MASON_REPLICA)
81
- --scope <scope> create: what it may do, deploy or users; repeat it for both (default deploy)
82
- --name <name> create: what the list calls it, 1 to 64 characters (default ${DEFAULT_NAME})
83
- --days <n> create: the days it acts, 1 to ${MAX_DAYS} (default ${DEFAULT_DAYS})
84
- --org <org> revoke: the organisation it is of
85
- --json as data: { token (create: { id, value (the token itself, this once), name,
86
- replica, scopes, createdAt, expiresAt }), tokens (list: [{ id, name, replica, scopes,
87
- madeBy, createdAt, lastUsedAt (null: never used), expiresAt }]), more (list: true when an
88
- organisation has more than the service lists), revoked (revoke: { org, id, name }), next }
89
-
90
88
  Exit status: 0 done; 1 not signed in, refused by the service (its words printed as they came, the code the
91
89
  CLI's own), or the service answered outside its contract; 2 a wrong command line.
90
+
91
+ The --json answer: { token (create: { id, value (the token itself, this once), name,
92
+ replica, scopes, createdAt, expiresAt }), tokens (list: [{ id, name, replica, scopes,
93
+ madeBy, createdAt, lastUsedAt (null: never used), expiresAt }]), more (list: true when an
94
+ organisation has more than the service lists), revokedToken (revoke: { org, id,
95
+ name }), revoked (revoke: the same, kept until 1.0), next }
92
96
  `,
93
97
  options: { replica: { type: "string" }, scope: { type: "string", multiple: true }, name: { type: "string" }, days: { type: "string" }, org: { type: "string" },
94
98
  json: { type: "boolean", default: false } },
95
- json: ["token", "tokens", "more", "revoked", "next"],
99
+ json: ["token", "tokens", "more", "revokedToken", "revoked", "next"],
96
100
  };
97
101
  const outStyle = () => serviceStyle(styleOut());
98
102
  const errStyle = () => serviceStyle(styleErr());
@@ -176,10 +180,10 @@ export async function token(argv) {
176
180
  for (const k of ["replica", "scope", "name", "days"])
177
181
  if (verb !== "create" && f.values[k] !== undefined)
178
182
  return usage(`--${k} goes with mason token create`);
179
- if (verb !== "revoke" && f.values.org !== undefined)
180
- return usage("--org goes with mason token revoke", verb === "list" ? "name the organisation: mason token list <org>" : "create names it: --replica <org>/<name>");
183
+ if (verb === "create" && f.values.org !== undefined)
184
+ return usage("--org goes with mason token list and revoke", "create names it: --replica <org>/<name>");
181
185
  if (verb === "list") {
182
- const org = arg === undefined ? null : orgNamed(arg, TOKEN.usage);
186
+ const org = orgOf(typeof f.values.org === "string" ? f.values.org : undefined, arg, TOKEN.usage);
183
187
  if (org !== null && isFailure(org))
184
188
  return fail(org, json);
185
189
  return list(org, json);
@@ -197,7 +201,9 @@ export async function token(argv) {
197
201
  return usage("mason token create names the replica it acts on", "--replica <org>/<name>, or MASON_REPLICA=<org>/<name>");
198
202
  if (isFailure(r))
199
203
  return fail(r, json);
200
- const asked = Array.isArray(f.values.scope) ? f.values.scope.filter((x) => typeof x === "string") : [];
204
+ // a comma list or the flag repeated, the same (src/cli/command.ts listFlag)
205
+ const listed = listFlag(f.values.scope);
206
+ const asked = listed === undefined ? [] : listed.split(",");
201
207
  for (const s of asked)
202
208
  if (!SCOPES.includes(s))
203
209
  return usage(`--scope ${JSON.stringify(serviceText(s, 100))} is not a scope${didYouMean(s, SCOPES)}`, `a CI token's scopes are ${SCOPES.join(" and ")}`);
@@ -329,7 +335,7 @@ async function revoke(id, named, json) {
329
335
  const revoked = { org, id, name: serviceText(j.revoked.name, 64, secrets) };
330
336
  const next = [listStep(org)];
331
337
  if (json) {
332
- out(jsonText(TOKEN, { revoked }, next));
338
+ out(jsonText(TOKEN, { revokedToken: revoked, revoked }, next));
333
339
  return 0;
334
340
  }
335
341
  out(reportText({ headline: `Revoked the CI token ${id} of ${org}: it acts no more`, details: [["name", revoked.name]], next }, outStyle()));
@@ -133,7 +133,7 @@ export function startTunnel(plan, o) {
133
133
  const timer = setTimeout(() => {
134
134
  stop();
135
135
  const said = lastLines(output, plan.token);
136
- settle({ message: `the tunnel gave no address in ${Math.round(o.timeoutMs / 1000)} s`, code: "failed", why: said === "" ? `${plan.bin} printed nothing` : `${plan.bin} printed: ${said}`,
136
+ settle({ message: `the tunnel gave no address in ${Math.round(o.timeoutMs / 1000)} s`, code: "timeout", why: said === "" ? `${plan.bin} printed nothing` : `${plan.bin} printed: ${said}`,
137
137
  fix: `its output is the service's, and may have changed: this Mason cannot read it${plan.via === "pinggy" ? ", and --via bore is another tunnel" : ""}. Nothing was left open` });
138
138
  }, o.timeoutMs);
139
139
  const read = (b) => {
@@ -0,0 +1,163 @@
1
+ /**
2
+ * `mason ui --replica <org>/<name>` — A HOSTED REPLICA'S WEB PAGE, IN THE BROWSER OF THE ORGANISATION'S OWNER
3
+ * (docs/HOSTED-API.md "A replica's page"). ONE request, `POST /v1/replicas/{org}/{name}/ui`, whose answer is a one-use,
4
+ * short-lived address of the replica's page (on the service's origin, or on https at a subdomain of its host), read-only
5
+ * (the service refuses every write through it: changes go by `mason user … --replica`, which it audits); the CLI opens it as `mason ui` opens a run's
6
+ * page and `mason login` its sign-in page (`openBrowser`), or prints it (`--no-open`, `--json`, a browser that would not
7
+ * start). Who may ask (the owner's login alone), and the audit row of each page opened, are the service's: the CLI cannot
8
+ * write that row and never says it did.
9
+ *
10
+ * Every value of the answer, and how it is held (src/cli/hosted.ts's header: a service's value is data, never structure):
11
+ *
12
+ * value where it is printed what holds it
13
+ * every string, keys too nowhere until held `carriesNoSecret`: an answer carrying a secret is refused whole
14
+ * url opened; printed where it was not `pageOrigin`, `pageUrl`: the service's origin or https at a
15
+ * subdomain of its host, its port; no user, at most 2048, carries
16
+ * opened, and in --json no secret as printed; then `OPENABLE`: letters, digits and
17
+ * `. : [ ] - _ ~ / ? = +` alone, so no shell reads it
18
+ * expiresAt a row, --json `isServiceTime`
19
+ * a failure's words stderr's failure, --json error `serviceFailure`: one line each, masked; the code by the status
20
+ * a failure's next nowhere: never read the step is `failureNext`'s, by the status
21
+ * Retry-After a next step's words `retryAfterOf`: 1 to 3600 seconds
22
+ *
23
+ * THE ADDRESS IS PRINTED ONLY WHERE THE PERSON NEEDS IT: opened in the browser, it is not printed (the browser used it,
24
+ * and it opens the page once); printed when it was not opened, and in `--json` for the agent that passes it on. It is
25
+ * never logged: this command writes no log. A 409 or a 503 (a replica not ready, its machine not connected) is said
26
+ * with the next step, never waited on: the person runs the command again.
27
+ */
28
+ import { failed, jsonText, reportText, serviceStyle } from "./command.js";
29
+ import { environmentToken, TOKEN_ENV } from "./credentials.js";
30
+ import { call, carriesNoSecret, codeOfStatus, isFailure, isServiceTime, pageUrl, serviceFailure, serviceOf, unexpected } from "./hosted.js";
31
+ import { holdOutput } from "./output-gate.js";
32
+ import { replicaText, tokenFor } from "./replica.js";
33
+ import { err as styleErr, out as styleOut } from "./style.js";
34
+ import { OPENABLE, openBrowser, UI } from "./ui.js";
35
+ const out = (m) => { process.stdout.write(`${m}\n`); };
36
+ const obj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
37
+ /** The request: the replica's page, asked of the service. Both parts are `REPLICA_PART`'s, safe in a path as they are. */
38
+ export const pagePath = (r) => `/v1/replicas/${r.org}/${r.name}/ui`;
39
+ const STEP = "a replica's page was asked for";
40
+ /**
41
+ * THE ORIGIN A REPLICA'S PAGE MAY BE ON: the service's own, or an `https:` origin whose host is a subdomain of the
42
+ * service's host, on the service's port (a page of its own origin keeps the service's session cookie and its routes
43
+ * apart from Mason's page: `r-abc.ui.app.example` under `app.example`). A subdomain by its labels, never a suffix of the
44
+ * text (`evilapp.example` is not under `app.example`), and none under an IP address. The origin the page is held to,
45
+ * or null when `v` is on none of them (or is no URL).
46
+ */
47
+ export function pageOrigin(service, v) {
48
+ if (typeof v !== "string")
49
+ return null;
50
+ let u, base;
51
+ try {
52
+ u = new URL(v);
53
+ base = new URL(service.base);
54
+ }
55
+ catch {
56
+ return null;
57
+ }
58
+ if (u.origin === service.base)
59
+ return service;
60
+ const ip = /^[\d.]+$/.test(base.hostname) || base.hostname.startsWith("[");
61
+ if (u.protocol !== "https:" || u.port !== base.port || ip || !u.hostname.endsWith(`.${base.hostname}`))
62
+ return null;
63
+ return { base: u.origin };
64
+ }
65
+ /**
66
+ * `POST …/ui`'s answer (200), held: every string of it, each key included, put to the exact test first
67
+ * (`carriesNoSecret`), then the address on an origin it may be on (`pageOrigin`; refused naming the origin when it is
68
+ * not), held there by `pageUrl` (no user or password, at most 2048, no secret as printed), of the characters a browser
69
+ * is handed (`OPENABLE`), and the time to its grammar. The page, or a Failure said by the status alone (or naming the
70
+ * other origin).
71
+ */
72
+ export function heldPage(service, a, secrets) {
73
+ const j = a.json;
74
+ if (!obj(j) || !carriesNoSecret(j, secrets))
75
+ return unexpected(service, a, STEP);
76
+ const origin = pageOrigin(service, j.url);
77
+ // on an origin it may be on, `pageUrl` holds it as on the service's own; elsewhere, `pageUrl` says the origin safely
78
+ const page = pageUrl(origin ?? service, j.url, secrets);
79
+ if (page !== null && "elsewhere" in page) {
80
+ return { message: `${service.base} named a replica's page on another address: ${page.elsewhere}`, code: "refused",
81
+ why: `a replica's page is opened only on the service's own address, ${service.base}, or on https at a subdomain of its host: anything else would send you to an address nobody chose`,
82
+ fix: "check MASON_CLOUD_URL names the service you mean; if it does, the service is answering outside its documented API" };
83
+ }
84
+ if (page === null || !OPENABLE.test(page.href) || !isServiceTime(j.expiresAt))
85
+ return unexpected(service, a, STEP);
86
+ return { url: page.href, expiresAt: j.expiresAt };
87
+ }
88
+ /**
89
+ * WHAT TO RUN AFTER THE SERVICE REFUSED: the CLI's own, by the status (never the service's `next`). A 401 or 403 is a
90
+ * login that may not (only the organisation's owner opens a replica's page; a CI token never does), a 404 a replica the
91
+ * login does not see, a 409 or 503 a replica not ready or not connected: its state, and the command again when the
92
+ * service's `Retry-After` says.
93
+ */
94
+ export function failureNext(status, r, retryAfter, env = process.env) {
95
+ const named = replicaText(r);
96
+ if (status === 401 || status === 403) {
97
+ return environmentToken(env) !== null
98
+ ? [[`env -u ${TOKEN_ENV} mason ui --replica ${named}`, `with your own login, not ${TOKEN_ENV}: a CI token never opens a page`]]
99
+ : [["mason login", "sign in as the organisation's owner: only an owner opens a replica's page"]];
100
+ }
101
+ if (status === 404)
102
+ return [[`mason replica list ${r.org}`, "the replicas this login sees"]];
103
+ if (status === 409 || status === 503) {
104
+ return [[`mason replica status ${named}`, "whether it is ready"],
105
+ [`mason ui --replica ${named}`, retryAfter !== null ? `again in ${retryAfter} s, as the service asks` : "again in a moment"]];
106
+ }
107
+ return [[`mason ui --replica ${named}`, "again in a moment"]];
108
+ }
109
+ /** A refusal said as the service worded it; a 409 or 503 it sent without its words is said in the CLI's. */
110
+ export function refusalOf(service, a, r, secrets, env = process.env) {
111
+ const next = failureNext(a.status, r, a.retryAfter, env);
112
+ const notReady = a.status === 409 || a.status === 503;
113
+ const again = a.retryAfter !== null ? `try again in ${a.retryAfter} s` : "try again in a moment";
114
+ const said = serviceFailure(a, secrets, () => next);
115
+ if (said !== null)
116
+ return notReady && said.fix === undefined ? { ...said, fix: `${again}; mason replica status ${replicaText(r)} says whether it is ready` } : said;
117
+ if (notReady) {
118
+ return { message: `the replica ${replicaText(r)} is not ready to open its page (HTTP ${a.status})`, code: codeOfStatus(a.status),
119
+ why: a.status === 409 ? "it is not ready yet: being made, deleted, or failed" : "its machine is not connected to the service now",
120
+ fix: `${again}; mason replica status ${replicaText(r)} says whether it is ready`, next };
121
+ }
122
+ return unexpected(service, a, STEP);
123
+ }
124
+ /** `mason ui --replica <org>/<name>`: the page asked for, then opened, printed, or said as --json. */
125
+ export async function hostedUi(r, o) {
126
+ const fail = (f) => failed("ui", f, { json: o.json, style: serviceStyle(styleErr()) });
127
+ const service = serviceOf();
128
+ if (isFailure(service))
129
+ return fail(service);
130
+ const token = tokenFor(service, { asked: "a replica's page is asked for", again: `mason ui --replica ${replicaText(r)}` });
131
+ if (isFailure(token))
132
+ return fail(token);
133
+ // from here every byte written is put to the exact test against the token (src/cli/output-gate.ts)
134
+ holdOutput({ command: "ui", json: o.json, secrets: [token] });
135
+ const secrets = [token];
136
+ const a = await call(service, "POST", pagePath(r), { token });
137
+ if (isFailure(a))
138
+ return fail(a);
139
+ if (a.status !== 200)
140
+ return fail(refusalOf(service, a, r, secrets));
141
+ const page = heldPage(service, a, secrets);
142
+ if (isFailure(page))
143
+ return fail(page);
144
+ const named = replicaText(r);
145
+ const next = [[`mason replica status ${named}`, "the replica's state, in this terminal"], [`mason ui --replica ${named}`, "a new address: each opens the page once"]];
146
+ if (o.json) {
147
+ out(jsonText(UI, { url: page.url, replica: named, expiresAt: page.expiresAt }, next));
148
+ return 0;
149
+ }
150
+ const couldNot = o.open ? await openBrowser(page.url) : null;
151
+ const opened = o.open && couldNot === null;
152
+ out(reportText({
153
+ headline: opened ? `The page of the replica ${named}, opened in your browser` : `The page of the replica ${named}`,
154
+ // opened, the address is not printed: the browser used it, and it opens the page once
155
+ details: [...(opened ? [] : [["url", page.url]]), ["replica", named], ["expires", page.expiresAt]],
156
+ tips: [
157
+ ...(couldNot !== null ? [`Could not open a browser (${couldNot}): open the URL yourself.`] : []),
158
+ opened ? "The address opens the page once: for another tab, run the command again." : "The URL opens the page once, for you alone, until it expires: paste it nowhere else.",
159
+ ],
160
+ next,
161
+ }, serviceStyle(styleOut())));
162
+ return 0;
163
+ }
@@ -32,7 +32,7 @@ import * as net from "node:net";
32
32
  import { fileURLToPath } from "node:url";
33
33
  import { Worker } from "node:worker_threads";
34
34
  import { runnable } from "../lib/runnable.js";
35
- import { errorJson, nextJson } from "./command.js";
35
+ import { errorJson, failureCode, nextJson } from "./command.js";
36
36
  import { holdRun, RunUnreachable } from "./control.js";
37
37
  import { whereIs } from "./state.js";
38
38
  import { lastLines } from "./tunnel.js";
@@ -335,7 +335,7 @@ function refusal(h, target) {
335
335
  : stopped ? `start mason serve on ${target}: its service or container` : h.error.fix;
336
336
  // these two say mason init and mason start, words for a person at that machine: run here they would do the wrong thing
337
337
  const next = lost || stopped ? [] : h.next.map((n) => n.what === undefined ? [n.command] : [n.command, n.what]);
338
- return { message: `on ${target}: ${h.error.message}`, code: h.error.code, ...(h.error.why ? { why: h.error.why } : {}), ...(fix ? { fix } : {}), next };
338
+ return { message: `on ${target}: ${h.error.message}`, code: failureCode(h.error.code, "failed"), ...(h.error.why ? { why: h.error.why } : {}), ...(fix ? { fix } : {}), next };
339
339
  }
340
340
  /** Starts ssh and the bridge, with a free loopback port before them: the tunnel, or why not. */
341
341
  export function openSshTunnel(o) {
@@ -381,7 +381,7 @@ export function openSshTunnel(o) {
381
381
  };
382
382
  const timer = setTimeout(() => {
383
383
  const last = lastLines(said, null);
384
- fail({ message: `no answer from ${o.target} in ${Math.round(o.timeoutMs / 1000)} s`, code: "failed", ...(last === "" ? {} : { why: `ssh printed: ${last}` }),
384
+ fail({ message: `no answer from ${o.target} in ${Math.round(o.timeoutMs / 1000)} s`, code: "timeout", ...(last === "" ? {} : { why: `ssh printed: ${last}` }),
385
385
  fix: `run ssh ${o.target} on its own to see where it waits; --timeout gives it longer`, next: [[`ssh ${o.target} true`, "whether ssh reaches it on its own"]] });
386
386
  }, o.timeoutMs);
387
387
  // Ctrl-C before an answer: ssh goes too, a process signalled alone included
@@ -2,7 +2,8 @@
2
2
  * `mason ui` — THE WEB PAGE OF THE RUNNING MASON, IN THE BROWSER (src/web/, docs/WEB-UI.md): the Overview (what `mason
3
3
  * status` says), the Tables (`mason tables`, `add`, `remove`, `sync`), the Users (`mason user`) and Observability (`mason
4
4
  * logs`, `mason usage`), refreshed while the tab is seen. It prints the page's URL WITH the run's token (src/cli/security.ts: the first request trades it for an HttpOnly cookie and is redirected to a URL without
5
- * it) and opens the browser. The reader is the person at this terminal, whom `mason user add` already shows a
5
+ * it) and opens the browser when its stdout is a terminal (as `mason login` does: a pipe, a file or an agent's shell has
6
+ * no one at a browser, so it gets the address alone). The reader is the person at this terminal, whom `mason user add` already shows a
6
7
  * password once; the token is never written anywhere else — not in `mason start`'s output (its log is a file), not in
7
8
  * `mason status`, which prints the URL without it.
8
9
  *
@@ -12,6 +13,9 @@
12
13
  * `--ssh <user@server>` (src/cli/ui-ssh.ts, docs/WEB-UI.md "On a server") opens a server's page from this machine, through
13
14
  * the machine's own ssh, and stays in the foreground until Ctrl-C, as `mason share` does; it needs no project here. Its
14
15
  * URL carries a ticket for one use, never the run's token, which stays on the server. `--bridge` is what it runs there.
16
+ *
17
+ * `--replica <org>/<name>` (else `MASON_REPLICA`; src/cli/ui-hosted.ts, docs/HOSTED-API.md "A replica's page") opens a
18
+ * replica's page on a hosted Mason service: one request, whose one-use address is opened as this command opens a run's.
15
19
  */
16
20
  import { spawn } from "node:child_process";
17
21
  import { failed, jsonText, parseCommand, reportText } from "./command.js";
@@ -19,6 +23,9 @@ import { reach } from "./state.js";
19
23
  import { bridge, openSshTunnel, targetProblem } from "./ui-ssh.js";
20
24
  import { version } from "./version.js";
21
25
  import { RunUnreachable } from "./control.js";
26
+ import { parseDuration } from "../lib/table-sync.js";
27
+ import { replicaOf } from "./replica.js";
28
+ import { hostedUi } from "./ui-hosted.js";
22
29
  const out = (m) => { process.stdout.write(`${m}\n`); };
23
30
  const say = (m) => { process.stderr.write(`${m}\n`); };
24
31
  const DEFAULT_TIMEOUT_S = 60;
@@ -27,7 +34,8 @@ export const UI = {
27
34
  name: "ui",
28
35
  summary: "the status, tables, users and statements, in your browser",
29
36
  usage: `usage: mason ui [--no-open] [--json]
30
- mason ui --ssh <user@server> [--mason <command>] [--timeout <seconds>] [--no-open] [--json]
37
+ mason ui --ssh <user@server> [--mason <command>] [--timeout <duration>] [--no-open] [--json]
38
+ mason ui --replica <org>/<name> [--no-open] [--json]
31
39
 
32
40
  Opens the running Mason's web page in your browser, refreshed while the tab is open: the Overview (what mason status
33
41
  says: whether the synced data is ready, the database, the sync, the statements and the recent errors); the Tables (add,
@@ -35,38 +43,51 @@ export const UI = {
35
43
  the URL to paste, or remove one); and Observability: the statements running and that ran (mason logs --requests,
36
44
  cancel one), usage over time (mason usage), and the sync's events (mason logs --events).
37
45
 
38
- The URL carries this run's token: the page trades it for a cookie at once and drops it from the address
39
- bar. It is printed for you alone, like a user's password: paste it nowhere else. Every mason start makes a new
40
- token, so after a restart run mason ui again.
41
-
42
- --ssh opens a server's page (mason serve's) from this machine, with nothing public: it runs your ssh, so your
43
- ~/.ssh/config, keys and SSO apply, starts mason there, and forwards a free local port to the page, which listens on the
44
- server's loopback alone. It stays in the foreground: Ctrl-C closes the tunnel. It needs no project here. Its URL
45
- carries a ticket for one use, never the run's token, which stays on the server. The server's Mason must be this
46
- version or close to it, and must be a command ssh can run: --mason says how, for a container or a folder.
47
-
48
46
  mason ui
49
47
  mason ui --no-open print the URL, open no browser
50
48
  mason ui --json
51
49
  mason ui --ssh deploy@mason.example.com
52
50
  mason ui --ssh deploy@mason.example.com --mason "docker exec -i mason-mason-1 mason"
53
51
  mason ui --ssh deploy@mason.example.com --mason "cd /var/lib/mason && mason" --timeout 120 --no-open
52
+ mason ui --replica acme/shop a replica's page on a hosted Mason service
54
53
  mason ui --bridge --local-port 51000 what --ssh runs on the server; its stdout is a stream, not for reading
55
54
 
56
- --no-open print the URL only
55
+ --no-open print the URL only (as when its output is not a terminal: a browser opens only for one)
57
56
  --ssh <target> the server, as you give it to ssh: user@server, or a Host of your ssh config
58
57
  --mason <command> how to run mason on the server (default mason); a shell's text, kept to ssh's command line
59
- --timeout <s> how long ssh and the server have to answer (default ${DEFAULT_TIMEOUT_S}): a passphrase or a touch counts
58
+ --timeout <duration> how long ssh and the server have to answer (default ${DEFAULT_TIMEOUT_S}s; a bare number is seconds;
59
+ 2m): a passphrase or a touch counts; past it, exit 124
60
60
  --bridge the server's end of --ssh, on stdin and stdout
61
61
  --local-port <n> the port the browser names, for --bridge: the laptop's
62
- --json as data: { url (with this run's token; with --ssh a ticket for one use; null when Ctrl-C came
63
- before the server answered, as port is, exit 0), port, server, project,
64
- version (with --ssh: the server's Mason, for what its answer may lack) }, next; it never opens a
65
- browser. With --ssh it then holds the tunnel: the end is on stderr, exit 0 for Ctrl-C or SIGTERM, 1 otherwise
62
+ --replica <org>/<name> a replica on a hosted Mason service, as the service lists it
63
+ --json as data: its shape is at the end
64
+
65
+ The URL carries this run's token: the page trades it for a cookie at once and drops it from the address
66
+ bar. It is printed for you alone, like a user's password: paste it nowhere else. Every mason start makes a new
67
+ token, so after a restart run mason ui again.
68
+
69
+ --ssh opens a server's page (mason serve's) from this machine, with nothing public: it runs your ssh, so your
70
+ ~/.ssh/config, keys and SSO apply, starts mason there, and forwards a free local port to the page, which listens on the
71
+ server's loopback alone. It stays in the foreground: Ctrl-C closes the tunnel. It needs no project here. Its URL
72
+ carries a ticket for one use, never the run's token, which stays on the server. The server's Mason must be this
73
+ version or close to it, and must be a command ssh can run: --mason says how, for a container or a folder.
74
+
75
+ --replica opens the page of a replica on a hosted Mason service (else MASON_REPLICA, unless --ssh or --bridge names
76
+ the target), for the organisation's owner alone: the service answers an address that opens it once, for a few
77
+ minutes. The page is read-only: the service refuses changes through it, so add or remove users with mason user …
78
+ --replica. It needs no project here, and goes with --no-open and --json alone. Opened in your browser, the address is
79
+ not printed: run it again for another tab. A replica not ready yet is said, with when to try again.
80
+
81
+ The --json answer: { url (with this run's token; with --ssh a ticket for one use; null when Ctrl-C came
82
+ before the server answered, as port is, exit 0; with --replica the one-use address), port, server,
83
+ project, version (with --ssh: the server's Mason, for what its answer may lack), replica
84
+ (with --replica: "<org>/<name>"), expiresAt (with --replica: until when the address opens it)
85
+ }, next; it never opens a
86
+ browser. With --ssh it then holds the tunnel: the end is on stderr, exit 0 for Ctrl-C or SIGTERM, 1 otherwise
66
87
  `,
67
88
  options: { "no-open": { type: "boolean", default: false }, json: { type: "boolean", default: false }, ssh: { type: "string" }, mason: { type: "string" },
68
- timeout: { type: "string" }, bridge: { type: "boolean", default: false }, "local-port": { type: "string" } },
69
- json: ["url", "port", "server", "project", "version", "next"],
89
+ timeout: { type: "string" }, bridge: { type: "boolean", default: false }, "local-port": { type: "string" }, replica: { type: "string" } },
90
+ json: ["url", "port", "server", "project", "version", "replica", "expiresAt", "next"],
70
91
  };
71
92
  const NEXT = [["mason status", "the same, in this terminal"], ["mason user list", "the users"]];
72
93
  /** The command that opens a URL in the person's browser, by platform (macOS `open`, Windows `start`, else `xdg-open`). */
@@ -78,6 +99,9 @@ export function openCommand(platform, url) {
78
99
  return ["cmd", ["/c", "start", "", url]];
79
100
  return ["xdg-open", [url]];
80
101
  }
102
+ /** An address Mason hands the browser by itself (a sign-in page, a replica's page): a plain https (or this machine's
103
+ * http) URL, and none of the characters a shell or `cmd /c start` reads (the service's URL is not ours). */
104
+ export const OPENABLE = /^https?:\/\/[A-Za-z0-9.:[\]-]+(?:\/[A-Za-z0-9._~/?=+-]*)?$/;
81
105
  /** Open `url` in the browser: null once the opener started, else why it could not (the URL is printed either way). */
82
106
  export function openBrowser(url) {
83
107
  const [command, args] = openCommand(process.platform, url);
@@ -87,6 +111,9 @@ export function openBrowser(url) {
87
111
  child.once("spawn", () => { child.unref(); resolve(null); });
88
112
  });
89
113
  }
114
+ /** Whether to open a browser: asked for (no --no-open) and stdout a terminal, a person in front of it (never a pipe,
115
+ * a file or a coding agent's shell, which get the address alone). */
116
+ const opens = (noOpen) => !noOpen && process.stdout.isTTY === true;
90
117
  /** Whether the run serves the page: its `GET /` answered, with the token the commands send. */
91
118
  async function servesPage(run) {
92
119
  let res;
@@ -155,6 +182,12 @@ export async function ui(argv) {
155
182
  const wrong = (message) => failed("ui", { message, code: "usage", usage: UI.usage }, { json });
156
183
  if (f.positionals.length)
157
184
  return wrong(`mason ui takes no argument: ${f.positionals.join(" ")}`);
185
+ if (v.replica !== undefined) {
186
+ const beside = [["--ssh", v.ssh !== undefined], ["--bridge", v.bridge], ["--mason", v.mason !== undefined], ["--timeout", v.timeout !== undefined],
187
+ ["--local-port", v["local-port"] !== undefined]].filter(([, given]) => given).map(([name]) => name);
188
+ if (beside.length)
189
+ return wrong(`--replica opens a hosted replica's page: it goes with --no-open and --json alone, not ${beside.join(", ")}`);
190
+ }
158
191
  if (v.bridge) {
159
192
  if (v.ssh !== undefined || v.mason !== undefined || v.timeout !== undefined || json)
160
193
  return wrong("--bridge is mason ui --ssh's own end on the server: it goes with --local-port alone");
@@ -173,11 +206,18 @@ export async function ui(argv) {
173
206
  return wrong(`--ssh: ${problem}`);
174
207
  if (v.mason !== undefined && (v.mason.trim() === "" || /[\r\n]/.test(v.mason)))
175
208
  return wrong("--mason is one line of shell that runs mason on the server");
176
- const seconds = v.timeout === undefined ? DEFAULT_TIMEOUT_S : Number(v.timeout);
177
- if (!Number.isFinite(seconds) || seconds <= 0)
178
- return wrong(`--timeout is a number of seconds, not ${JSON.stringify(v.timeout)}`);
179
- return overSsh(v.ssh, v.mason ?? "mason", seconds * 1000, !v["no-open"], json);
209
+ const read = parseDuration(v.timeout ?? String(DEFAULT_TIMEOUT_S));
210
+ if ("problem" in read)
211
+ return wrong(`--timeout ${read.problem}`);
212
+ const seconds = read.seconds;
213
+ return overSsh(v.ssh, v.mason ?? "mason", seconds * 1000, opens(v["no-open"]), json);
180
214
  }
215
+ // a replica on a hosted service (--replica, else MASON_REPLICA): --ssh and --bridge name their own target, above
216
+ const replica = replicaOf(v.replica, UI.usage);
217
+ if (replica !== null && "message" in replica)
218
+ return failed("ui", replica, { json });
219
+ if (replica !== null)
220
+ return hostedUi(replica, { open: opens(v["no-open"]), json });
181
221
  const w = await reach(UI, json);
182
222
  if (w === null || w.state !== "up")
183
223
  return 1;
@@ -199,8 +239,9 @@ export async function ui(argv) {
199
239
  out(jsonText(UI, { url, port }, NEXT));
200
240
  return 0;
201
241
  }
202
- const couldNot = f.values["no-open"] === true ? null : await openBrowser(url);
203
- const opened = f.values["no-open"] !== true && couldNot === null;
242
+ const open = opens(v["no-open"]);
243
+ const couldNot = open ? await openBrowser(url) : null;
244
+ const opened = open && couldNot === null;
204
245
  out(reportText({
205
246
  headline: opened ? `Mason's web page, opened in your browser` : `Mason's web page`,
206
247
  details: [["url", url], ["project", run.name]],