@mason-data/cli 0.7.0 → 0.9.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 (155) hide show
  1. package/README.md +101 -73
  2. package/dist/src/api/error.js +3 -0
  3. package/dist/src/api/postgres/catalog-worker.js +49 -2
  4. package/dist/src/api/postgres/catalog.js +368 -25
  5. package/dist/src/api/postgres/encode.js +3 -1
  6. package/dist/src/api/postgres/explain.js +3 -2
  7. package/dist/src/api/postgres/listener.js +11 -3
  8. package/dist/src/api/postgres/protocol.js +39 -23
  9. package/dist/src/api/postgres/proxy.js +283 -0
  10. package/dist/src/api/postgres/schema.js +6 -0
  11. package/dist/src/api/postgres/server.js +1617 -245
  12. package/dist/src/api/postgres/translate.js +127 -10
  13. package/dist/src/api/request-log.js +18 -5
  14. package/dist/src/cdc/poll.js +5 -5
  15. package/dist/src/cli/background.js +47 -25
  16. package/dist/src/cli/bridge-stdin.js +101 -0
  17. package/dist/src/cli/cancel.js +75 -0
  18. package/dist/src/cli/command.js +170 -11
  19. package/dist/src/cli/commands.js +804 -209
  20. package/dist/src/cli/control.js +59 -0
  21. package/dist/src/cli/credentials.js +182 -0
  22. package/dist/src/cli/demo.js +20 -11
  23. package/dist/src/cli/deploy.js +574 -0
  24. package/dist/src/cli/dev.js +491 -130
  25. package/dist/src/cli/doctor.js +131 -38
  26. package/dist/src/cli/generated-files.js +143 -0
  27. package/dist/src/cli/guides.js +175 -36
  28. package/dist/src/cli/hosted.js +850 -0
  29. package/dist/src/cli/init.js +162 -65
  30. package/dist/src/cli/login.js +289 -0
  31. package/dist/src/cli/machine.js +13 -12
  32. package/dist/src/cli/main.js +11 -5
  33. package/dist/src/cli/mason.cjs +17 -0
  34. package/dist/src/cli/mcp.js +31 -22
  35. package/dist/src/cli/metrics.js +79 -0
  36. package/dist/src/cli/output-gate.js +176 -0
  37. package/dist/src/cli/page-share.js +96 -0
  38. package/dist/src/cli/pg-tls.js +14 -4
  39. package/dist/src/cli/pg-url.js +4 -0
  40. package/dist/src/cli/pin.js +19 -14
  41. package/dist/src/cli/pool-size.cjs +85 -0
  42. package/dist/src/cli/ports.js +2 -0
  43. package/dist/src/cli/preflight.js +138 -46
  44. package/dist/src/cli/project.js +142 -4
  45. package/dist/src/cli/readiness.js +76 -6
  46. package/dist/src/cli/registry.js +65 -38
  47. package/dist/src/cli/replica-hosted.js +665 -0
  48. package/dist/src/cli/replica.js +76 -0
  49. package/dist/src/cli/replication-checks.js +7 -5
  50. package/dist/src/cli/replication-mode.js +105 -26
  51. package/dist/src/cli/request-filter.js +134 -0
  52. package/dist/src/cli/roles.js +585 -0
  53. package/dist/src/cli/run-server.js +637 -48
  54. package/dist/src/cli/runs.js +1 -1
  55. package/dist/src/cli/security.js +67 -18
  56. package/dist/src/cli/serve.js +204 -46
  57. package/dist/src/cli/share-run.js +329 -0
  58. package/dist/src/cli/share.js +286 -0
  59. package/dist/src/cli/source-edit.js +75 -17
  60. package/dist/src/cli/source-role.js +344 -0
  61. package/dist/src/cli/sources.js +306 -47
  62. package/dist/src/cli/style.js +2 -1
  63. package/dist/src/cli/supervise.js +27 -24
  64. package/dist/src/cli/sync.js +55 -25
  65. package/dist/src/cli/token-hosted.js +337 -0
  66. package/dist/src/cli/tunnel.js +200 -0
  67. package/dist/src/cli/ui-ssh.js +460 -0
  68. package/dist/src/cli/ui.js +124 -17
  69. package/dist/src/cli/usage.js +128 -0
  70. package/dist/src/cli/user-hosted.js +299 -0
  71. package/dist/src/cli/user.js +573 -65
  72. package/dist/src/cli/version.js +9 -7
  73. package/dist/src/demo/seed.js +29 -0
  74. package/dist/src/duck/analyze.js +36 -20
  75. package/dist/src/duck/cancel.js +10 -1
  76. package/dist/src/duck/collation.js +266 -0
  77. package/dist/src/duck/constant-key.js +94 -0
  78. package/dist/src/duck/distinct-key.js +283 -26
  79. package/dist/src/duck/events.js +165 -0
  80. package/dist/src/duck/executor-worker.js +20 -11
  81. package/dist/src/duck/executor.js +47 -33
  82. package/dist/src/duck/explain.js +60 -46
  83. package/dist/src/duck/extensions.js +160 -0
  84. package/dist/src/duck/functions.js +461 -63
  85. package/dist/src/duck/gaps.js +48 -21
  86. package/dist/src/duck/generated-plan.js +1070 -0
  87. package/dist/src/duck/generated.js +906 -0
  88. package/dist/src/duck/grants.js +176 -0
  89. package/dist/src/duck/held-types.js +64 -0
  90. package/dist/src/duck/history.js +127 -0
  91. package/dist/src/duck/jsonb-order.js +9 -12
  92. package/dist/src/duck/kept-route.js +6 -5
  93. package/dist/src/duck/kept.js +15 -13
  94. package/dist/src/duck/late.js +1469 -40
  95. package/dist/src/duck/libc-bound.js +341 -0
  96. package/dist/src/duck/libc-order.js +892 -126
  97. package/dist/src/duck/pg-eval-worker.js +7 -1
  98. package/dist/src/duck/pg-eval.js +6 -4
  99. package/dist/src/duck/pgtree.js +774 -23
  100. package/dist/src/duck/plan.js +369 -47
  101. package/dist/src/duck/publication.js +20 -6
  102. package/dist/src/duck/requests.js +269 -93
  103. package/dist/src/duck/resolved.js +117 -2
  104. package/dist/src/duck/rewrite.js +4584 -580
  105. package/dist/src/duck/row-filter.js +655 -0
  106. package/dist/src/duck/statement.js +19 -0
  107. package/dist/src/duck/store.js +439 -177
  108. package/dist/src/duck/stream.js +19 -10
  109. package/dist/src/duck/sync.js +1411 -368
  110. package/dist/src/duck/token.js +4 -1
  111. package/dist/src/duck/types.js +69 -12
  112. package/dist/src/duck/usage.js +326 -0
  113. package/dist/src/duck/users.js +69 -15
  114. package/dist/src/duck/verified.js +161 -1
  115. package/dist/src/duck/views.js +5 -5
  116. package/dist/src/lib/database-name.js +62 -0
  117. package/dist/src/lib/database-url.js +55 -10
  118. package/dist/src/lib/dotenv.js +17 -4
  119. package/dist/src/lib/instant.js +50 -0
  120. package/dist/src/lib/log-buffer.js +77 -0
  121. package/dist/src/lib/log-read.js +196 -0
  122. package/dist/src/lib/pg-numeric.js +390 -49
  123. package/dist/src/lib/project-version.js +23 -23
  124. package/dist/src/lib/secret-mask.js +142 -0
  125. package/dist/src/lib/table-sync.js +282 -0
  126. package/dist/src/web/app.css +706 -66
  127. package/dist/src/web/app.js +331 -409
  128. package/dist/src/web/charts.js +229 -0
  129. package/dist/src/web/index.html +82 -18
  130. package/dist/src/web/pages/queries.js +192 -0
  131. package/dist/src/web/pages/status.js +611 -0
  132. package/dist/src/web/pages/syncs.js +155 -0
  133. package/dist/src/web/pages/tables.js +439 -0
  134. package/dist/src/web/pages/usage.js +397 -0
  135. package/dist/src/web/pages/users.js +219 -0
  136. package/dist/src/web/shared.js +584 -0
  137. package/dist/src/web/vendor/README.md +25 -0
  138. package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
  139. package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
  140. package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
  141. package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
  142. package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
  143. package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
  144. package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
  145. package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
  146. package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
  147. package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
  148. package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
  149. package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
  150. package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
  151. package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
  152. package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
  153. package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
  154. package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
  155. package/package.json +12 -4
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * `mason sync <table>…`: Mason's poll of exactly these tables, now — named for what it does to the copy
3
- * (made current, incrementally), never for the mechanism (`poll` was). The passes poll every added table but one over
4
- * the poll's row limit (src/duck/sync.ts POLL_ROW_LIMIT: each poll scans its table whole on the database), which is
5
- * copied and served as it was copied; this reads what changed in it since, when asked. The run's `POST /api/sync`
6
- * (src/cli/run-server.ts) answers it, through the sync's own poll (`Sync.pollNow`).
3
+ * (made current, incrementally), never for the mechanism (`poll` was). A table whose sources-file entry says `sync:
4
+ * off` (the default: src/lib/table-sync.ts) is read again only so, and one set to `poll <interval>` is polled now
5
+ * whatever its interval says, its next poll an interval from this one. A table whose sync is `replication` is not
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
+ * poll (`Sync.pollNow`).
7
8
  */
8
9
  import { failed, jsonText, parseCommand, reportText } from "./command.js";
9
10
  import { out as styleOut } from "./style.js";
@@ -11,30 +12,55 @@ import { callRun } from "./control.js";
11
12
  import { reach } from "./state.js";
12
13
  import { findTable, guarded, readState, tableArg } from "./commands.js";
13
14
  import { didYouMean } from "../lib/did-you-mean.js";
14
- import { POLL_ROW_LIMIT } from "../duck/sync.js";
15
+ import { syncedText, syncWords } from "../lib/table-sync.js";
15
16
  const out = (m) => { process.stdout.write(`${m}\n`); };
16
17
  const n = (x) => x.toLocaleString("en-US");
17
18
  export const SYNC_USAGE = `usage: mason sync <table>… [--json]
18
19
 
19
- Makes Mason's copy of these tables current now: it reads what changed in your database since Mason last read them —
20
- the rows inserted or updated, and the ones deleted. Mason syncs every added table by itself every few seconds, but
21
- a table over ${n(POLL_ROW_LIMIT)} rows is copied and served without that (each sync scans a table whole on your
22
- database): mason status says which, and this syncs one when you need it current. mason start --replication keeps
23
- every table current from the database's change log, whatever its size.
20
+ Brings these synced tables up to date now: it reads what changed in your database since Mason last read them —
21
+ the rows inserted or updated, and the ones deleted — in one transaction, as a poll does (each one scans its table
22
+ whole on your database). After its first sync, a table is read again as its sync: in the sources file says: off
23
+ (the default) only when you run this — before a report, from cron, at the end of a deploy — and poll <interval> by
24
+ itself every interval, which this does now too, whatever the interval says. mason status says how each table is
25
+ kept current and when it was last synced. A table whose sync: is replication is not polled: it answers where the
26
+ stream of the database's change log is, beside the tables it polls.
24
27
 
25
28
  mason sync orders
26
29
  mason sync orders public.events
27
30
 
28
- --json as data: { syncedAt, tables: [{ table, changed, deleted, rows, ms }], next }
31
+ --json as data: { syncedAt, tables: [{ table, changed, deleted, rows, ms }] (the tables polled), streamed (the
32
+ tables whose sync: is replication, not polled: [{ table, position, state, why }]), next }
29
33
  `;
30
34
  export const SYNC = {
31
35
  needs: "run",
32
36
  name: "sync",
33
- summary: "make the copy of these tables current now: mason sync orders (a table over the sync's row limit is synced only so)",
37
+ summary: "bring these synced tables up to date now: mason sync orders (a table whose sync is off is read again only so)",
34
38
  usage: SYNC_USAGE,
35
39
  options: { json: { type: "boolean" } },
36
- json: ["syncedAt", "tables", "next"],
40
+ json: ["syncedAt", "tables", "streamed", "next"],
37
41
  };
42
+ /**
43
+ * WHAT A SYNC DID, as `mason sync` prints it from `POST /api/sync`'s answer (the web page's Sync button says the same,
44
+ * src/web/pages/tables.js; tests/web-page.test.ts holds the two equal): the rows written and deleted, a line per table,
45
+ * and what happens next to each table synced (`syncedTips`: an `off` one stays as of this sync). A table whose sync is
46
+ * replication was not polled: its line says where the stream is (`streamed`), and a sync of those alone has nothing to sync.
47
+ */
48
+ export function syncedReport(b, picked, tables) {
49
+ const streamed = b.streamed ?? [];
50
+ const streamedRows = streamed.map((t) => [t.table, `${t.state}${t.why ? `: ${t.why}` : ""} · the stream has applied the database's changes through ${t.position}`]);
51
+ if (b.tables.length === 0 && streamed.length > 0) {
52
+ return { headline: `Nothing to sync: logical replication keeps ${picked.join(", ")} current.`, details: streamedRows, tips: [] };
53
+ }
54
+ const changed = b.tables.reduce((a, t) => a + t.changed, 0), deleted = b.tables.reduce((a, t) => a + t.deleted, 0);
55
+ const streamedNames = new Set(streamed.map((t) => t.table));
56
+ const polled = picked.filter((t) => !streamedNames.has(t));
57
+ return {
58
+ headline: `Synced ${polled.join(", ")}: ${n(changed)} row(s) written, ${n(deleted)} deleted${streamedRows.length ? `; logical replication keeps ${streamedRows.map((r) => r[0]).join(", ")} current` : ""}.`,
59
+ details: [...b.tables.map((t) => [t.table, syncedText(t)]), ...streamedRows],
60
+ // what happens next to each: an `off` table stays as of now, a polled one is polled again after its interval
61
+ tips: syncedTips(tables),
62
+ };
63
+ }
38
64
  export async function sync(argv) {
39
65
  const f = parseCommand(SYNC, argv);
40
66
  if (typeof f === "number")
@@ -62,7 +88,7 @@ export async function sync(argv) {
62
88
  if ("message" in db && db.code === "usage")
63
89
  return failed("sync", db, { json });
64
90
  if (!("message" in db)) {
65
- return failed("sync", { message: `${db.table} is not added, so Mason has no copy of it to sync`, code: "not_found", fix: `mason add ${tableArg(db.table)}`,
91
+ return failed("sync", { message: `${db.table} is not added, so Mason has not synced it`, code: "not_found", fix: `mason add ${tableArg(db.table)}`,
66
92
  next: [["mason status", "the added tables"]] }, { json });
67
93
  }
68
94
  return failed("sync", { message: `there is no added table ${JSON.stringify(name)}${didYouMean(name, added.flatMap((x) => [x.table, tableArg(x.table)]))}`, code: "not_found",
@@ -73,22 +99,26 @@ export async function sync(argv) {
73
99
  if (r.status !== 200) {
74
100
  const code = typeof r.body.code === "string" ? r.body.code : r.status === 404 ? "not_found" : "failed";
75
101
  return failed("sync", { message: `${picked.join(", ")} ${picked.length === 1 ? "was" : "were"} not synced`, code, why: String(r.body.error),
76
- next: [["mason status", "each table's copy"], ["mason logs", "the run's log"]] }, { json });
102
+ next: [["mason status", "each synced table"], ["mason logs", "the run's log"]] }, { json });
77
103
  }
78
104
  const b = r.body;
79
- const unsynced = added.filter((t) => picked.includes(t.table) && t.unsynced);
80
- const next = [[`mason sync ${names.join(" ")}`, "sync again, whenever you need it current"], ["mason status", "each table's copy, and which the sync leaves out"]];
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"]];
81
106
  if (json) {
82
- out(jsonText(SYNC, { syncedAt: b.at, tables: b.tables }, next));
107
+ out(jsonText(SYNC, { syncedAt: b.at, tables: b.tables, ...(b.streamed ? { streamed: b.streamed } : {}) }, next));
83
108
  return 0;
84
109
  }
85
- const changed = b.tables.reduce((a, t) => a + t.changed, 0), deleted = b.tables.reduce((a, t) => a + t.deleted, 0);
86
- out(reportText({
87
- headline: `Synced ${picked.join(", ")}: ${n(changed)} row(s) written, ${n(deleted)} deleted.`,
88
- details: b.tables.map((t) => [t.table, `${n(t.changed)} changed, ${n(t.deleted)} deleted, ${n(t.rows)} rows now · ${n(t.ms)} ms`]),
89
- tips: unsynced.map((t) => `${t.table} is ${t.unsynced.reason}: it stays as of this sync until the next mason sync (mason start --replication keeps every table current)`),
90
- next,
91
- }, styleOut()));
110
+ out(reportText({ ...syncedReport(b, picked, added.filter((t) => picked.includes(t.table))), next }, styleOut()));
92
111
  return 0;
93
112
  });
94
113
  }
114
+ /** What happens to each table synced after now, by its sync (src/duck/sync.ts KeptCurrent): an `off` table stays as of
115
+ * this sync until the next one, a polled one is polled again after its interval. */
116
+ export function syncedTips(tables) {
117
+ return tables.flatMap((t) => {
118
+ const k = t.kept;
119
+ if (k?.by !== "poll")
120
+ return [];
121
+ return [k.sync.mode === "off" ? `${t.table}'s sync is off: it stays as of this sync until the next mason sync ${tableArg(t.table)}`
122
+ : `${t.table} is ${syncWords(k.sync)}: its next poll is an interval from this one`];
123
+ });
124
+ }
@@ -0,0 +1,337 @@
1
+ /**
2
+ * `mason token create | list | revoke` — A HOSTED MASON SERVICE'S CI TOKENS, by its documented API alone
3
+ * (docs/HOSTED-API.md "CI tokens"). A CI token acts on ONE replica, for named scopes, until it expires or is revoked;
4
+ * an admin or an owner of the organisation makes, lists and revokes them, signed in as a person (a CI token makes none):
5
+ *
6
+ * create --replica <org>/<name> POST /v1/tokens/{org} { name, replica, scopes, expiresInDays } 201 { token }
7
+ * list [<org>] GET /v1/tokens/{org} { tokens, more } — each organisation the login may list in, when none is named
8
+ * revoke <id> [--org <org>] POST /v1/tokens/{org}/{id}/revoke (no body) { revoked: { id, name } }
9
+ *
10
+ * THE TOKEN MADE IS PRINTED ONCE, by create, alone on the first line (never broken by the CLI) or in the one field
11
+ * (`token.value` under --json) that carries it, and nowhere else: its value is held to the documented form (`masondata_ci_` and 43 base64url
12
+ * characters), never the login's own, and the rest of the answer is put to the exact test against both. The output's
13
+ * gate lets that one value through ONCE, whole (src/cli/output-gate.ts `once`), and holds it as a secret beside the
14
+ * login: a second copy, 11 of its body in a row elsewhere, or anything else shaped as a token is refused at the write.
15
+ *
16
+ * A SERVICE'S VALUE IS DATA, NEVER STRUCTURE AND NEVER AN INSTRUCTION (#481's decision, src/cli/hosted.ts), AND A GRAMMAR
17
+ * IS NO MASK (issue 627): every string of each answer, keys included, is put to the exact test (`carriesNoSecret`)
18
+ * before any is held to its grammar or printed. Every value printed:
19
+ *
20
+ * value where it is printed what holds it
21
+ * a failure's message, why, fix stderr's failure, --json error `serviceFailure` (src/cli/hosted.ts)
22
+ * a failure's next nowhere: never read the step is `refusedNext`'s, by the status
23
+ * value (create) its one row, --json token.value `CI_TOKEN`, not the login's
24
+ * id rows, steps, --json `TOKEN_ID`; revoke's, the id asked
25
+ * name, madeBy rows, --json one line each, bounded (`serviceText`)
26
+ * replica rows, --json `<org>/<name>` of the organisation asked (create: the replica asked)
27
+ * scopes rows, --json each of `SCOPES`, once (create: those asked)
28
+ * createdAt, expiresAt, lastUsedAt rows, --json `isServiceTime` (lastUsedAt or null)
29
+ * more a tip, --json a boolean
30
+ */
31
+ import { endText, failed, jsonText, parseCommand, reportText, serviceStyle, strict } from "./command.js";
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";
34
+ import { refusedNext } from "./replica-hosted.js";
35
+ import { holdOutput } from "./output-gate.js";
36
+ import { didYouMean } from "../lib/did-you-mean.js";
37
+ import { err as styleErr, out as styleOut } from "./style.js";
38
+ /** What a CI token may be made for (docs/HOSTED-API.md): deploying the project, and the replica's users. */
39
+ export const SCOPES = ["deploy", "users"];
40
+ /** The documented form of a CI token: what create prints, once. */
41
+ const CI_TOKEN = /^masondata_ci_[A-Za-z0-9_-]{43}$/;
42
+ /** A token's id, as it goes into a path: letters, digits, `-` and `_`. */
43
+ export const TOKEN_ID = /^[A-Za-z0-9_-]{1,64}$/;
44
+ const DEFAULT_NAME = "ci";
45
+ const DEFAULT_DAYS = 90;
46
+ const MAX_DAYS = 365;
47
+ const MAX_TOKENS = 1000;
48
+ /** A list's answer is read up to this (a row a token). */
49
+ const MAX_LIST_BYTES = 1024 * 1024;
50
+ /** Who may make, list and revoke CI tokens: the roles of `GET /v1/me`. */
51
+ const MAKERS = ["admin", "owner"];
52
+ export const TOKEN = {
53
+ name: "token",
54
+ summary: "a hosted Mason service's CI tokens: make one for a replica (printed once), list them, revoke one",
55
+ usage: `usage: mason token create --replica <org>/<name> [--scope deploy|users …] [--name <name>] [--days <n>] [--json]
56
+ mason token list [<org>] [--json]
57
+ mason token revoke <id> [--org <org>] [--json]
58
+
59
+ A CI token lets CI act on ONE replica of a hosted Mason service, for the scopes it was made with (deploy: mason
60
+ deploy; users: mason user … --replica), until it expires or is revoked. CI reads it from MASON_TOKEN. An admin or
61
+ an owner of the organisation makes, lists and revokes them, signed in as a person (mason login): a CI token
62
+ makes none.
63
+
64
+ create makes one and prints it ONCE: the service does not keep it, and nothing shows it again, so store it at once
65
+ (as MASON_TOKEN in CI's secrets). It is alone on the first line printed, never broken, or, with --json, in the
66
+ one field token.value of the document, so a setup step can pipe it straight into a secret store. Nothing else
67
+ this command prints ever holds it.
68
+
69
+ list says the CI tokens of <org> that still act (never a token itself), or of every organisation this login is
70
+ an admin or an owner of when none is named; revoke ends one at once, by its id (list says it), in --org, else in
71
+ the one organisation this login is an admin or an owner of.
72
+
73
+ The login is MASON_TOKEN, else this machine's (mason login); the service is MASON_CLOUD_URL, else the default.
74
+
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
+ Exit status: 0 done; 1 not signed in, refused by the service (its words printed as they came, the code the
91
+ CLI's own), or the service answered outside its contract; 2 a wrong command line.
92
+ `,
93
+ options: { replica: { type: "string" }, scope: { type: "string", multiple: true }, name: { type: "string" }, days: { type: "string" }, org: { type: "string" },
94
+ json: { type: "boolean", default: false } },
95
+ json: ["token", "tokens", "more", "revoked", "next"],
96
+ };
97
+ const outStyle = () => serviceStyle(styleOut());
98
+ const errStyle = () => serviceStyle(styleErr());
99
+ const out = (m) => { process.stdout.write(`${m}\n`); };
100
+ const fail = (f, json) => failed("token", f, { json, style: errStyle() });
101
+ const obj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
102
+ const MAY = "an admin or an owner";
103
+ /** Scopes as the service lists them: each one of `SCOPES`, once; null otherwise. In `SCOPES`' order. */
104
+ function scopesOf(v) {
105
+ if (!Array.isArray(v) || v.length < 1 || v.length > SCOPES.length || new Set(v).size !== v.length || !v.every((x) => SCOPES.includes(x)))
106
+ return null;
107
+ return SCOPES.filter((s) => v.includes(s));
108
+ }
109
+ /**
110
+ * ONE CI TOKEN AS THE SERVICE SAYS IT, held: of the organisation asked (`org`), every value to its grammar, its words one
111
+ * line each; null when any does not hold. The answer around it has been put to the exact test.
112
+ */
113
+ export function heldToken(v, org, secrets) {
114
+ if (!obj(v) || typeof v.id !== "string" || !TOKEN_ID.test(v.id) || typeof v.name !== "string" || v.name.length < 1 || v.name.length > 200)
115
+ return null;
116
+ const replica = typeof v.replica === "string" ? v.replica.split("/") : [];
117
+ if (replica.length !== 2 || replica[0] !== org || !REPLICA_PART.test(replica[1]))
118
+ return null;
119
+ const scopes = scopesOf(v.scopes);
120
+ if (scopes === null || !isServiceTime(v.createdAt) || !isServiceTime(v.expiresAt))
121
+ return null;
122
+ if (v.lastUsedAt !== undefined && v.lastUsedAt !== null && !isServiceTime(v.lastUsedAt))
123
+ return null;
124
+ if (v.madeBy !== undefined && v.madeBy !== null && (typeof v.madeBy !== "string" || v.madeBy.length > 320))
125
+ return null;
126
+ return { id: v.id, name: serviceText(v.name, 64, secrets), replica: `${org}/${replica[1]}`, scopes, madeBy: typeof v.madeBy === "string" ? serviceText(v.madeBy, 320, secrets) : null,
127
+ createdAt: v.createdAt, lastUsedAt: typeof v.lastUsedAt === "string" ? v.lastUsedAt : null, expiresAt: v.expiresAt };
128
+ }
129
+ /**
130
+ * THE TOKEN CREATE MADE (`201 { token }`): its value in the documented form and not the login's, the rest of the answer
131
+ * carrying neither (the exact test, against both: a copy of the value elsewhere is a second print), the replica and the
132
+ * scopes those asked. The token and its value, or null.
133
+ */
134
+ export function heldMade(json, asked, login) {
135
+ if (!obj(json) || !obj(json.token))
136
+ return null;
137
+ const value = json.token.value;
138
+ // the value is shaped as a token by design: what it may not be is an echo of the login's
139
+ if (typeof value !== "string" || !CI_TOKEN.test(value) || login.includes(value) || echoesSecret(value, login))
140
+ return null;
141
+ const rest = { ...json, token: { ...json.token, value: null } };
142
+ if (!carriesNoSecret(rest, [...login, value]))
143
+ return null;
144
+ const token = heldToken(json.token, asked.org, [...login, value]);
145
+ if (token === null || token.replica !== `${asked.org}/${asked.name}` || token.scopes.join() !== SCOPES.filter((s) => asked.scopes.includes(s)).join())
146
+ return null;
147
+ return { token, value };
148
+ }
149
+ /** The service and the login, the output's gate holding the token; or the Failure. */
150
+ function signedIn(asked, again, json) {
151
+ const service = serviceOf();
152
+ if (isFailure(service))
153
+ return service;
154
+ const token = tokenFor(service, { asked, again });
155
+ if (isFailure(token))
156
+ return token;
157
+ holdOutput({ command: "token", json, secrets: [token] });
158
+ return { service, token };
159
+ }
160
+ const listStep = (org) => [`mason token list ${org}`, "the organisation's CI tokens"];
161
+ export async function token(argv) {
162
+ const f = parseCommand(TOKEN, argv);
163
+ if (typeof f === "number")
164
+ return f;
165
+ const json = f.values.json === true;
166
+ const usage = (message, fix) => fail({ message, code: "usage", ...(fix ? { fix } : {}), usage: TOKEN.usage }, json);
167
+ const [verb, arg, ...rest] = f.positionals;
168
+ const VERBS = ["create", "list", "revoke"];
169
+ if (verb === undefined)
170
+ return usage("create, list or revoke: mason token create --replica <org>/<name> makes one");
171
+ if (!VERBS.includes(verb))
172
+ return usage(`there is no mason token ${verb}: create, list or revoke${didYouMean(verb, VERBS)}`);
173
+ const extra = verb === "create" ? [arg, ...rest].filter((x) => x !== undefined) : rest;
174
+ if (extra.length)
175
+ return usage(`mason token ${verb} takes ${verb === "create" ? "no argument (the replica is --replica)" : verb === "list" ? "one organisation at most" : "one id"}: ${extra.join(" ")} is too many`);
176
+ for (const k of ["replica", "scope", "name", "days"])
177
+ if (verb !== "create" && f.values[k] !== undefined)
178
+ 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>");
181
+ if (verb === "list") {
182
+ const org = arg === undefined ? null : orgNamed(arg, TOKEN.usage);
183
+ if (org !== null && isFailure(org))
184
+ return fail(org, json);
185
+ return list(org, json);
186
+ }
187
+ if (verb === "revoke") {
188
+ if (arg === undefined || !TOKEN_ID.test(arg))
189
+ return usage(arg === undefined ? "mason token revoke names the token's id: mason token revoke <id>" : `${JSON.stringify(serviceText(arg, 100))} is not a CI token's id`, "its id is what mason token list says first on its line, letters, digits, - and _ (never the token itself)");
190
+ const org = typeof f.values.org === "string" ? orgNamed(f.values.org, TOKEN.usage) : null;
191
+ if (org !== null && isFailure(org))
192
+ return fail(org, json);
193
+ return revoke(arg, org, json);
194
+ }
195
+ const r = replicaOf(typeof f.values.replica === "string" ? f.values.replica : undefined, TOKEN.usage);
196
+ if (r === null)
197
+ return usage("mason token create names the replica it acts on", "--replica <org>/<name>, or MASON_REPLICA=<org>/<name>");
198
+ if (isFailure(r))
199
+ return fail(r, json);
200
+ const asked = Array.isArray(f.values.scope) ? f.values.scope.filter((x) => typeof x === "string") : [];
201
+ for (const s of asked)
202
+ if (!SCOPES.includes(s))
203
+ return usage(`--scope ${JSON.stringify(serviceText(s, 100))} is not a scope${didYouMean(s, SCOPES)}`, `a CI token's scopes are ${SCOPES.join(" and ")}`);
204
+ if (new Set(asked).size !== asked.length)
205
+ return usage("a --scope is named twice", "name each scope once");
206
+ const scopes = (asked.length ? asked : ["deploy"]);
207
+ const name = typeof f.values.name === "string" ? f.values.name : DEFAULT_NAME;
208
+ if ([...name].length < 1 || [...name].length > 64 || /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(name) || name.trim() !== name)
209
+ return usage("--name is 1 to 64 characters, with no control character or space at either end", "--name github-actions");
210
+ const daysText = typeof f.values.days === "string" ? f.values.days : String(DEFAULT_DAYS);
211
+ if (!/^[1-9]\d{0,2}$/.test(daysText) || Number(daysText) > MAX_DAYS)
212
+ return usage(`--days ${JSON.stringify(serviceText(daysText, 100))} is not 1 to ${MAX_DAYS}`, "a CI token acts a whole number of days: --days 90");
213
+ return create({ org: r.org, name: r.name }, { label: name, scopes, days: Number(daysText) }, json);
214
+ }
215
+ /** What to run after the service refused: the CLI's own, by the status, naming the role that may. */
216
+ const refused = (a, org, again, secrets, service, step) => serviceFailure(a, secrets, (st) => st === 404 && again[0].startsWith("mason token revoke") ? [listStep(org)] : refusedNext(st, org, MAY, again)) ?? unexpected(service, a, step);
217
+ async function create(r, o, json) {
218
+ const again = [`mason token create --replica ${r.org}/${r.name}`, "make it again"];
219
+ const s = signedIn("a CI token is made", "mason token create", json);
220
+ if (isFailure(s))
221
+ return fail(s, json);
222
+ const { service, token: login } = s;
223
+ const a = await call(service, "POST", `/v1/tokens/${r.org}`, { token: login, json: { name: o.label, replica: r.name, scopes: o.scopes, expiresInDays: o.days } });
224
+ if (isFailure(a)) {
225
+ return fail({ ...a, why: `${a.why ?? "no answer"}; whether a token was made is not known, and it is never shown again`,
226
+ fix: `mason token list ${r.org} says the tokens that act: revoke any you did not see made, then make it again`, next: [listStep(r.org), again] }, json);
227
+ }
228
+ if (a.status !== 201)
229
+ return fail(refused(a, r.org, again, [login], service, "a CI token was made"), json);
230
+ const made = heldMade(a.json, { org: r.org, name: r.name, scopes: o.scopes }, [login]);
231
+ if (made === null) {
232
+ // the service answered 201: a token may have been made, whose value nobody has now been shown
233
+ const f = unexpected(service, a, "a CI token was made");
234
+ return fail({ ...f, why: `${f.why}; a token may have been made all the same, and it was not shown`,
235
+ fix: `mason token list ${r.org} says the tokens that act: revoke one you did not see made (mason token revoke <id> --org ${r.org}), then make it again`, next: [listStep(r.org)] }, json);
236
+ }
237
+ // the one print of the token: the gate lets this value through once, whole (src/cli/output-gate.ts)
238
+ holdOutput({ command: "token", json, secrets: [], once: made.value });
239
+ const t = made.token;
240
+ const next = [listStep(r.org), [`mason token revoke ${t.id} --org ${r.org}`, "end it"]];
241
+ if (json) {
242
+ out(jsonText(TOKEN, { token: { id: t.id, value: made.value, name: t.name, replica: t.replica, scopes: t.scopes, createdAt: t.createdAt, expiresAt: t.expiresAt } }, next));
243
+ return 0;
244
+ }
245
+ // THE TOKEN ON A LINE OF ITS OWN, FIRST, written alone and never broken by the CLI (PR 660's review, B2: a value to copy
246
+ // is no service text to break at the width — a line break inside it would be copied with it). A terminal narrower
247
+ // than it wraps it softly, which a copy joins. It cannot begin a line of anyone's making: held to `CI_TOKEN`, it has
248
+ // no space, no colon and no control character, so no piece of it is a step, a heading or a failure.
249
+ process.stdout.write(`${made.value}\n`);
250
+ out(reportText({ headline: `Made a CI token for ${t.replica}: the line above is the token, printed this once`,
251
+ details: [["id", t.id], ["name", t.name], ["scopes", t.scopes.join(", ")], ["expires", t.expiresAt]],
252
+ tips: ["Store it now, as MASON_TOKEN in your CI's secrets: the service does not keep it, and nothing shows it again.",
253
+ `It acts on ${t.replica} alone, for ${t.scopes.join(" and ")}, until it expires or is revoked.`], next }, outStyle()));
254
+ return 0;
255
+ }
256
+ async function list(org, json) {
257
+ const s = signedIn("the CI tokens are asked for", org === null ? "mason token list" : `mason token list ${org}`, json);
258
+ if (isFailure(s))
259
+ return fail(s, json);
260
+ const { service, token: login } = s, secrets = [login];
261
+ const orgs = org !== null ? [org] : await loginOrgs(service, login, "mason token list <org>", MAKERS);
262
+ if (isFailure(orgs))
263
+ return fail(orgs, json);
264
+ const slugs = orgs.map((o) => typeof o === "string" ? o : o.slug);
265
+ const tokens = [];
266
+ const more = [];
267
+ for (const slug of slugs) {
268
+ const a = await call(service, "GET", `/v1/tokens/${slug}`, { token: login, maxBytes: MAX_LIST_BYTES });
269
+ if (isFailure(a))
270
+ return fail(a, json);
271
+ if (a.status !== 200)
272
+ return fail(refused(a, slug, listStep(slug), secrets, service, "the CI tokens were asked for"), json);
273
+ const j = a.json;
274
+ if (!obj(j) || !Array.isArray(j.tokens) || j.tokens.length > MAX_TOKENS || typeof j.more !== "boolean" || !carriesNoSecret(j, secrets))
275
+ return fail(unexpected(service, a, "the CI tokens were asked for"), json);
276
+ for (const v of j.tokens) {
277
+ const t = heldToken(v, slug, secrets);
278
+ if (t === null)
279
+ return fail(unexpected(service, a, "the CI tokens were asked for"), json);
280
+ tokens.push(t);
281
+ }
282
+ if (j.more)
283
+ more.push(slug);
284
+ }
285
+ const where = slugs.length === 1 ? slugs[0] : slugs.length === 0 ? "no organisation this login is an admin or an owner of" : `${slugs.length} organisations`;
286
+ const first = tokens[0];
287
+ const next = first !== undefined
288
+ ? [[`mason token revoke ${first.id} --org ${first.replica.split("/")[0]}`, "end one"], ["mason token create --replica <org>/<name>", "another"]]
289
+ : [["mason token create --replica <org>/<name>", "make one"]];
290
+ if (json) {
291
+ out(jsonText(TOKEN, { tokens, more: more.length > 0 }, next));
292
+ return 0;
293
+ }
294
+ const st = outStyle();
295
+ const head = tokens.length ? `${tokens.length} CI token${tokens.length === 1 ? "" : "s"} that act, in ${where}` : `No CI tokens that act, in ${where}`;
296
+ const lines = tokens.map((t) => ` ${t.id} ${t.name}: ${t.replica}, ${t.scopes.join(" and ")}, until ${t.expiresAt}, last used ${t.lastUsedAt ?? "never"}${t.madeBy ? `, made by ${t.madeBy}` : ""}`);
297
+ const tips = more.map((o) => `${o} has more CI tokens than the service lists at once: its web app lists them all`);
298
+ const end = endText({ tips, next }, st);
299
+ out(strict(`${st.bold(head)}${lines.length ? `\n${lines.join("\n")}` : ""}${end ? `\n${end}` : ""}`, st));
300
+ return 0;
301
+ }
302
+ async function revoke(id, named, json) {
303
+ const s = signedIn("a CI token is revoked", `mason token revoke ${id}`, json);
304
+ if (isFailure(s))
305
+ return fail(s, json);
306
+ const { service, token: login } = s, secrets = [login];
307
+ let org = named;
308
+ if (org === null) {
309
+ const orgs = await loginOrgs(service, login, `mason token revoke ${id} --org <org>`, MAKERS);
310
+ if (isFailure(orgs))
311
+ return fail(orgs, json);
312
+ if (orgs.length !== 1) {
313
+ return fail({ message: orgs.length ? `this login is an admin or an owner of ${orgs.length} organisations: which one is the token of?` : "this login is an admin or an owner of no organisation",
314
+ code: orgs.length ? "usage" : "refused", why: "an admin or an owner of a token's organisation revokes it",
315
+ fix: orgs.length ? `name it: mason token revoke ${id} --org <org> (${orgs.map((o) => o.slug).join(", ")})` : "sign in as an admin or an owner of its organisation",
316
+ next: orgs.length ? orgs.slice(0, 3).map((o) => [`mason token revoke ${id} --org ${o.slug}`]) : [["mason login", `sign in as ${MAY}`]] }, json);
317
+ }
318
+ org = orgs[0].slug;
319
+ }
320
+ const again = [`mason token revoke ${id} --org ${org}`, "revoke it again"];
321
+ const a = await call(service, "POST", `/v1/tokens/${org}/${id}/revoke`, { token: login });
322
+ if (isFailure(a))
323
+ return fail({ ...a, why: `${a.why ?? "no answer"}; whether it was revoked is not known`, fix: `mason token list ${org} says whether it still acts; revoke it again if it does`, next: [listStep(org), again] }, json);
324
+ if (a.status !== 200)
325
+ return fail(refused(a, org, again, secrets, service, "a CI token was revoked"), json);
326
+ const j = a.json;
327
+ if (!obj(j) || !carriesNoSecret(j, secrets) || !obj(j.revoked) || j.revoked.id !== id || typeof j.revoked.name !== "string" || j.revoked.name.length > 200)
328
+ return fail(unexpected(service, a, "a CI token was revoked"), json);
329
+ const revoked = { org, id, name: serviceText(j.revoked.name, 64, secrets) };
330
+ const next = [listStep(org)];
331
+ if (json) {
332
+ out(jsonText(TOKEN, { revoked }, next));
333
+ return 0;
334
+ }
335
+ out(reportText({ headline: `Revoked the CI token ${id} of ${org}: it acts no more`, details: [["name", revoked.name]], next }, outStyle()));
336
+ return 0;
337
+ }