@mason-data/cli 0.8.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 (118) hide show
  1. package/README.md +19 -8
  2. package/dist/src/api/error.js +4 -0
  3. package/dist/src/api/postgres/catalog-worker.js +49 -2
  4. package/dist/src/api/postgres/catalog.js +166 -32
  5. package/dist/src/api/postgres/listener.js +11 -3
  6. package/dist/src/api/postgres/proxy.js +129 -0
  7. package/dist/src/api/postgres/schema.js +6 -0
  8. package/dist/src/api/postgres/server.js +527 -103
  9. package/dist/src/api/postgres/translate.js +47 -2
  10. package/dist/src/cli/background.js +18 -19
  11. package/dist/src/cli/bridge-stdin.js +101 -0
  12. package/dist/src/cli/command.js +337 -15
  13. package/dist/src/cli/commands.js +458 -169
  14. package/dist/src/cli/credentials.js +182 -0
  15. package/dist/src/cli/demo.js +2 -2
  16. package/dist/src/cli/deploy.js +680 -0
  17. package/dist/src/cli/dev.js +148 -27
  18. package/dist/src/cli/doctor.js +122 -36
  19. package/dist/src/cli/generated-files.js +143 -0
  20. package/dist/src/cli/guides.js +116 -16
  21. package/dist/src/cli/hosted.js +850 -0
  22. package/dist/src/cli/init.js +154 -33
  23. package/dist/src/cli/login.js +292 -0
  24. package/dist/src/cli/machine.js +125 -40
  25. package/dist/src/cli/main.js +59 -10
  26. package/dist/src/cli/mason.cjs +17 -0
  27. package/dist/src/cli/mcp.js +17 -14
  28. package/dist/src/cli/output-gate.js +176 -0
  29. package/dist/src/cli/page-share.js +96 -0
  30. package/dist/src/cli/pg-tls.js +1 -1
  31. package/dist/src/cli/pin.js +6 -2
  32. package/dist/src/cli/pool-size.cjs +85 -0
  33. package/dist/src/cli/ports.js +2 -0
  34. package/dist/src/cli/preflight.js +130 -39
  35. package/dist/src/cli/project.js +100 -1
  36. package/dist/src/cli/readiness.js +42 -6
  37. package/dist/src/cli/registry.js +89 -36
  38. package/dist/src/cli/replica-hosted.js +1324 -0
  39. package/dist/src/cli/replica.js +114 -0
  40. package/dist/src/cli/replication-mode.js +5 -0
  41. package/dist/src/cli/request-filter.js +16 -6
  42. package/dist/src/cli/roles.js +2 -1
  43. package/dist/src/cli/run-server.js +179 -9
  44. package/dist/src/cli/secret-input.js +193 -0
  45. package/dist/src/cli/security.js +57 -10
  46. package/dist/src/cli/serve.js +65 -9
  47. package/dist/src/cli/share-run.js +51 -8
  48. package/dist/src/cli/share.js +23 -18
  49. package/dist/src/cli/source-edit.js +10 -4
  50. package/dist/src/cli/source-role.js +343 -0
  51. package/dist/src/cli/sources.js +50 -23
  52. package/dist/src/cli/style.js +16 -1
  53. package/dist/src/cli/supervise.js +15 -23
  54. package/dist/src/cli/sync.js +17 -8
  55. package/dist/src/cli/token-hosted.js +343 -0
  56. package/dist/src/cli/tunnel.js +1 -1
  57. package/dist/src/cli/ui-hosted.js +163 -0
  58. package/dist/src/cli/ui-ssh.js +460 -0
  59. package/dist/src/cli/ui.js +162 -17
  60. package/dist/src/cli/usage.js +19 -15
  61. package/dist/src/cli/user-hosted.js +364 -0
  62. package/dist/src/cli/user.js +275 -105
  63. package/dist/src/demo/seed.js +26 -0
  64. package/dist/src/duck/analyze.js +36 -20
  65. package/dist/src/duck/collation.js +266 -0
  66. package/dist/src/duck/constant-key.js +94 -0
  67. package/dist/src/duck/distinct-key.js +283 -26
  68. package/dist/src/duck/events.js +39 -8
  69. package/dist/src/duck/executor-worker.js +2 -2
  70. package/dist/src/duck/executor.js +6 -5
  71. package/dist/src/duck/explain.js +4 -0
  72. package/dist/src/duck/functions.js +448 -49
  73. package/dist/src/duck/gaps.js +34 -16
  74. package/dist/src/duck/generated-plan.js +1070 -0
  75. package/dist/src/duck/generated.js +906 -0
  76. package/dist/src/duck/grants.js +1 -1
  77. package/dist/src/duck/held-types.js +64 -0
  78. package/dist/src/duck/history.js +4 -0
  79. package/dist/src/duck/jsonb-order.js +9 -12
  80. package/dist/src/duck/kept-route.js +6 -5
  81. package/dist/src/duck/kept.js +9 -7
  82. package/dist/src/duck/late.js +1469 -40
  83. package/dist/src/duck/libc-bound.js +341 -0
  84. package/dist/src/duck/libc-order.js +892 -126
  85. package/dist/src/duck/pg-eval-worker.js +7 -1
  86. package/dist/src/duck/pg-eval.js +6 -4
  87. package/dist/src/duck/pgtree.js +774 -23
  88. package/dist/src/duck/plan.js +275 -30
  89. package/dist/src/duck/publication.js +14 -0
  90. package/dist/src/duck/requests.js +9 -5
  91. package/dist/src/duck/resolved.js +117 -2
  92. package/dist/src/duck/rewrite.js +4567 -583
  93. package/dist/src/duck/row-filter.js +12 -6
  94. package/dist/src/duck/statement.js +19 -0
  95. package/dist/src/duck/store.js +294 -68
  96. package/dist/src/duck/stream.js +9 -5
  97. package/dist/src/duck/sync.js +602 -126
  98. package/dist/src/duck/token.js +4 -1
  99. package/dist/src/duck/types.js +63 -7
  100. package/dist/src/duck/usage.js +6 -4
  101. package/dist/src/duck/users.js +110 -9
  102. package/dist/src/duck/verified.js +159 -2
  103. package/dist/src/duck/views.js +4 -3
  104. package/dist/src/lib/cidr.js +177 -0
  105. package/dist/src/lib/database-url.js +55 -10
  106. package/dist/src/lib/instant.js +50 -0
  107. package/dist/src/lib/log-buffer.js +13 -4
  108. package/dist/src/lib/log-read.js +196 -0
  109. package/dist/src/lib/pg-numeric.js +387 -49
  110. package/dist/src/lib/secret-mask.js +142 -0
  111. package/dist/src/lib/table-sync.js +84 -19
  112. package/dist/src/web/app.js +9 -4
  113. package/dist/src/web/pages/status.js +178 -13
  114. package/dist/src/web/pages/syncs.js +1 -1
  115. package/dist/src/web/pages/tables.js +8 -3
  116. package/dist/src/web/pages/users.js +4 -3
  117. package/dist/src/web/shared.js +19 -3
  118. package/package.json +8 -7
@@ -0,0 +1,680 @@
1
+ /**
2
+ * `mason deploy` — THE PROJECT IN THIS FOLDER SENT TO A REPLICA ON A HOSTED MASON SERVICE, and its outcome waited for
3
+ * (docs/HOSTED-API.md "Deploying a project"). Only the project's files travel: what Mason reads to run it, never this
4
+ * machine's `.env`, `.mason/` or anything else beside them.
5
+ *
6
+ * 1. THE PROJECT IS CHECKED HERE FIRST, OFFLINE, by Mason's own readers, as `mason start` checks it before it connects
7
+ * (src/cli/dev.ts): the version pin (src/lib/project-version.ts, src/cli/pin.ts), the sources file's text
8
+ * (src/cli/sources.ts `readSourceFile`, the first half of `readSource`), the roles file against the tables the
9
+ * sources file names (src/cli/roles.ts `roleProblems`, the check `resolveRoles` makes, without the database's
10
+ * columns), and the generated tables (src/cli/generated-files.ts `readGenerated`, whose refusals are said and never
11
+ * stop a project, as at a start). No database is connected to: the URL is the replica's environment's there, and the
12
+ * `.env` here may name a development database. A project the check refuses is refused here, and nothing is sent.
13
+ *
14
+ * 2. THE FILES ARE `projectFiles` (src/lib/project-files.ts), the one list of a project's files, read whole: each a
15
+ * regular file inside the project folder (a symbolic link that leaves it is refused), valid UTF-8, at most
16
+ * `MAX_FILE_BYTES`, all of them at most `MAX_TOTAL_BYTES` and `MAX_FILES`, each path in the grammar the contract
17
+ * names (`PATH_SEGMENT`). Every file the readers above read must be one of them: a file read through a linked folder,
18
+ * which the list does not follow, would be checked here and missing there. A `url:` written with its password is
19
+ * refused: the files are kept by the service, and a secret is never one of them.
20
+ *
21
+ * 3. `--dry-run` prints what would be sent (each path, its size, its sha256) and sends nothing.
22
+ *
23
+ * 4. THE REPLICA is `--replica <org>/<name>`, else `MASON_REPLICA`, and THE TOKEN `MASON_TOKEN`, else the saved login
24
+ * for the service: src/cli/replica.ts's one reader of each (`replicaOf`, `tokenFor`), `mason user --replica`'s too.
25
+ *
26
+ * 5. THE WAIT is bounded by `--timeout` (an interval, 10m by default): a poll every `POLL_MS`, or what `Retry-After`
27
+ * asks; an answer that cannot say (no answer, a 429, a 5xx) is a wait, never an outcome. Exit 0 only when the
28
+ * replica runs this deploy; 1 when Mason there refused it (its failure's words printed as they came) or the deploy
29
+ * failed; 124 at the timeout, which stops nothing: the deploy's id says where to look. The same clock bounds the
30
+ * wait BEFORE a deploy is made: a POST answered `503` (the replica is being checked or created, or its machine is not
31
+ * connected) or `429` (too many deploys at once) made none, and is sent again after its `Retry-After` (else
32
+ * `RESEND_MS`), the replica's state and the time waited said meanwhile; no other answer is ever sent again. A `409`
33
+ * naming a replica that failed or is being deleted is refused, its next step `mason replica status`; a deleted
34
+ * replica's name is freed, so the service answers it `404`, as a name that never existed.
35
+ *
36
+ * A SERVICE'S VALUE IS DATA, NEVER STRUCTURE AND NEVER AN INSTRUCTION (#481's decision, src/cli/hosted.ts). Every place a
37
+ * value the service sent reaches what this command prints:
38
+ *
39
+ * value where it is printed what holds it
40
+ * an HTTP failure's message, why, fix stderr's failure, --json error `serviceFailure`: one line each (`serviceText`)
41
+ * an HTTP failure's code inside why: "(the service said …)" `^[a-z][a-z0-9_]*$`, 64 chars; the code is `codeOfStatus`'s
42
+ * an HTTP failure's next nowhere: never read the step is `serviceNext`'s, by the status
43
+ * a deploy's failure message, why, fix stderr's failure, --json error `failureShape`: one line each (`serviceText`)
44
+ * a deploy's failure code inside why: "(Mason on the replica …)" the same grammar; the code is `refused` / `failed`
45
+ * a deploy's failure next nowhere: never read the step is `again`'s, the CLI's
46
+ * the deploy's id stderr's lines, the report, --json `DEPLOY_ID`, checked before it is in a path or printed
47
+ * status, phase stderr's lines (the CLI's words), --json closed sets (`STATUSES`, `PHASES_OF`)
48
+ * createdAt, finishedAt --json deploy `isServiceTime`: digits, `-`, `T`, `:`, `.`, a zone
49
+ * an HTTP status, Retry-After a failure's or a wait's line a number (Retry-After bounded, never printed)
50
+ * why a request got no answer the wait's line, a failure's why `call`'s own words, through `serviceText`
51
+ *
52
+ * So no value of the service's starts a line, adds a row or a step to run next, or puts a line break in --json. AND NO
53
+ * GRAMMAR IS EVER THE ONLY THING BETWEEN A SECRET AND THE OUTPUT (issue 627: `DEPLOY_ID` took the token whole, and the
54
+ * id was printed): before any of the grammars above, every string of the POST's answer and of each poll's, each key
55
+ * included, is put to the exact test (src/cli/hosted.ts `carriesNoSecret`: an echo of the token, `ECHO_RUN` of its body
56
+ * in a row in one string; a token's or a password's shape), and an answer that carries one is refused whole, said by its
57
+ * status. A failure's words (message, why, fix) alone are free text, masked by `serviceText` as every failure's are; its
58
+ * code is put to the same test (`failureShape`).
59
+ */
60
+ import * as fs from "node:fs";
61
+ import * as path from "node:path";
62
+ import { createHash } from "node:crypto";
63
+ import { setTimeout as sleep } from "node:timers/promises";
64
+ import { errorJson, exitOf, failed, jsonText, parseCommand, reportText, serviceStyle, strict } from "./command.js";
65
+ import { call, carriesNoSecret, failureShape, isFailure, isServiceTime, serviceFailure, serviceNext, serviceOf, serviceText, unexpected } from "./hosted.js";
66
+ import { replicaOf, replicaText, REPLICA_ENV, tokenFor } from "./replica.js";
67
+ import { holdOutput } from "./output-gate.js";
68
+ import { maskedUrl } from "./preflight.js";
69
+ import { folderPlace, theProject } from "./project.js";
70
+ import { commandPinProblem } from "./pin.js";
71
+ import { readPin } from "../lib/project-version.js";
72
+ import { projectFiles } from "../lib/project-files.js";
73
+ import { readSourceFile, shownPath } from "./sources.js";
74
+ import { readRoles, roleProblems, rolesFailure, rolesFile } from "./roles.js";
75
+ import { readGenerated } from "./generated-files.js";
76
+ import { refusalText } from "../duck/generated-plan.js";
77
+ import { MAX_POLL_SECONDS, MIN_POLL_SECONDS, parseDuration } from "../lib/table-sync.js";
78
+ import { textEntry } from "../lib/log.js";
79
+ import { sayAgain } from "./commands.js";
80
+ import { err as styleErr, out as styleOut, stepRenderer } from "./style.js";
81
+ import { version } from "./version.js";
82
+ /** The bounds of what a deploy sends: one file, all of them, how many. */
83
+ export const MAX_FILE_BYTES = 256 * 1024;
84
+ export const MAX_TOTAL_BYTES = 1024 * 1024;
85
+ export const MAX_FILES = 500;
86
+ /** One part of a sent path: what the contract takes (a dotfile is never a project file). */
87
+ export const PATH_SEGMENT = /^[A-Za-z0-9_][A-Za-z0-9._-]*$/;
88
+ const MAX_PATH = 255;
89
+ /** How often the deploy's state is asked for, when the service asks no other. */
90
+ export const POLL_MS = 2_000;
91
+ /** How long before a deploy is sent again after a `503` or a `429` that names no wait the CLI reads (no `Retry-After`,
92
+ * or one in another form): short, and bounded by `--timeout` as every wait is. */
93
+ export const RESEND_MS = 5_000;
94
+ /** How often a wait to send says how long it has waited (a terminal's spinner; elsewhere `sayAgain` decides). */
95
+ const TICK_MS = 1_000;
96
+ const DEFAULT_TIMEOUT = "10m";
97
+ /** A deploy's id, as it goes into a path: the contract's grammar. */
98
+ const DEPLOY_ID = /^[A-Za-z0-9_-]{1,64}$/;
99
+ const STATUSES = ["queued", "applying", "starting", "running", "refused", "failed"];
100
+ const FINAL = new Set(["running", "refused", "failed"]);
101
+ /** Everything this command prints can hold the service's text, so it is ALL drawn in serviceStyle (src/cli/command.ts):
102
+ * on a terminal every line is broken by the CLI at the width, never by the terminal, each continuation at column 4 or
103
+ * deeper — the report, the progress, the spinner's line, a failure and a warning alike. */
104
+ const outStyle = () => serviceStyle(styleOut());
105
+ const errStyle = () => serviceStyle(styleErr());
106
+ const out = (m) => { process.stdout.write(`${m}\n`); };
107
+ /** Progress on stderr, a line or several: on a terminal through the strict renderer's last pass (`strict`,
108
+ * src/cli/command.ts), every line within the terminal's width whatever it is, each continuation where no line of the
109
+ * CLI's starts — the phase lines, a step's end (its ✔ or ✘ line), the wait's note and a warning alike. */
110
+ const say = (m) => { process.stderr.write(`${strict(m, errStyle())}\n`); };
111
+ /** A step's label as the spinner draws it on one line (`⠋ label …`, six columns beside it): cut to fit within the
112
+ * terminal's width, never reaching its edge; null when not even a word fits, and then no spinner turns. */
113
+ const clipped = (label) => {
114
+ const width = errStyle().columns;
115
+ if (width === undefined)
116
+ return label;
117
+ const room = width - 1 - 6, chars = [...label];
118
+ if (room < 8)
119
+ return null;
120
+ let n = 0, i = 0;
121
+ for (; i < chars.length; i++) {
122
+ n += chars[i].codePointAt(0) <= 0x7e ? 1 : 2;
123
+ if (n > room)
124
+ break;
125
+ }
126
+ return i < chars.length ? `${chars.slice(0, Math.max(0, i - 1)).join("")}…` : label;
127
+ };
128
+ /** A failure said for a person in serviceStyle, or under --json as data. */
129
+ const fail = (f, o) => failed("deploy", f, { json: o.json, style: errStyle() });
130
+ export const DEPLOY = {
131
+ needs: "project",
132
+ name: "deploy",
133
+ summary: "send this project to a replica on a hosted Mason service, and wait until the replica runs it",
134
+ usage: `usage: mason deploy [--replica <org>/<name>] [--dry-run] [--timeout <duration>] [--json]
135
+
136
+ Sends this project's files (what mason/ holds that Mason reads: the sources file, mason.yml, roles.yml, the
137
+ generated tables — never .env, .mason/, a dotfile or a file that is not YAML) to a replica on a hosted Mason
138
+ service, then waits until Mason on the replica runs them, or refuses them. The project is checked here first, as
139
+ mason start checks it, without connecting to any database (the replica's own environment names its URL); a
140
+ project that would be refused is refused here, and nothing is sent. A url: written with its password is refused:
141
+ write url: \${DATABASE_URL} and set it on the replica.
142
+
143
+ mason deploy --replica acme/shop
144
+ mason deploy --replica acme/shop --dry-run what would be sent, sending nothing
145
+ MASON_REPLICA=acme/shop mason deploy --timeout 30m
146
+
147
+ --replica <org>/<name> the replica to deploy to (else MASON_REPLICA)
148
+ --dry-run print each file that would be sent, its size and its sha256, and send nothing (no
149
+ login needed)
150
+ --timeout <duration> how long to wait for the outcome, a wait to send included (default ${DEFAULT_TIMEOUT}; 1s
151
+ to 24h; a bare number is seconds); a deploy sent goes on after it
152
+ --json as data: its shape is at the end
153
+
154
+ The replica is --replica <org>/<name>, else MASON_REPLICA (each part lower case letters, digits and -). The login
155
+ is MASON_TOKEN, else this machine's (mason login); the service is MASON_CLOUD_URL, else the default. Progress
156
+ goes to stderr; the answer is printed when it ends.
157
+
158
+ While the replica is being created (or the service asks for a wait: too many deploys at once), it waits as the
159
+ service asks and sends the deploy again, within --timeout, saying the replica's state and how long it has waited.
160
+ A replica that failed or is being deleted is refused: mason replica status says its state.
161
+
162
+ Exit status: 0 the replica runs it (or a dry run printed); 1 refused here or by the service, refused by Mason on
163
+ the replica (its words printed as they came, the code the CLI's own), the deploy failed, or the service answered
164
+ outside its contract; 2 a wrong command line; 124 the wait timed out (nothing stopped: the deploy's id says where
165
+ to look; or, before one was sent, no deploy was made). Whatever the service sends, it never makes the exit 0, 2
166
+ or 124.
167
+
168
+ The --json answer, once it ends: { dryRun, replica (null when none is named on a dry run),
169
+ service, files: [{ path, size, sha256 }], totalBytes, warnings (the generated tables
170
+ refused, as mason start says them, each one line), deploy ({ id, status (queued,
171
+ applying, starting, running, refused, failed), phase (null, checking, writing,
172
+ restarting, syncing), createdAt, finishedAt };
173
+ null on a dry run), error (when the replica refused it, it failed or the wait timed out:
174
+ { code, message, why, fix }), next }
175
+ `,
176
+ options: { replica: { type: "string" }, "dry-run": { type: "boolean", default: false }, timeout: { type: "string" }, json: { type: "boolean", default: false } },
177
+ json: ["dryRun", "replica", "service", "files", "totalBytes", "warnings", "deploy", "error", "next"],
178
+ };
179
+ /** A `url:` that carries a password: in the URL's user part, or as a `password=` parameter. */
180
+ function writesPassword(url) {
181
+ return maskedUrl(url) !== url || /(?:^|[\s?&;])password\s*=/i.test(url);
182
+ }
183
+ /**
184
+ * THE PROJECT CHECKED AS `mason start` CHECKS IT BEFORE IT CONNECTS, offline: the pin, the sources file, the roles
185
+ * file against the tables the sources file names, the generated tables (said, never refusing). A Failure for what a
186
+ * start would refuse; else the warnings, and every file the check read (each must be among the files sent).
187
+ */
188
+ export function checkProject(p, cliVersion = version()) {
189
+ const read = [];
190
+ // the pin: a mason.yml that cannot be read is refused whatever this command's version (a checkout's `dev` checks no
191
+ // version, and the replica's image reads the same file)
192
+ const pin = readPin(p.folder);
193
+ if ("problem" in pin)
194
+ return { message: pin.problem, code: "refused", fix: `edit ${shownPath(p.root, pin.file)}: write version: <x.y.z>, the Mason version the project is written for` };
195
+ if (pin.pin !== null)
196
+ read.push(pin.file);
197
+ const pinned = commandPinProblem(p.folder, cliVersion);
198
+ if (pinned)
199
+ return { message: pinned.problem, code: "refused", ...(pinned.ways.length ? { fix: pinned.ways.join("; or ") } : {}) };
200
+ const src = readSourceFile(p.folder, p.root);
201
+ if ("message" in src)
202
+ return src;
203
+ read.push(src.file);
204
+ if ("literal" in src.url && writesPassword(src.url.literal)) {
205
+ const shown = shownPath(p.root, src.file);
206
+ return { message: `${shown}: url: is written with its password`, code: "refused",
207
+ why: "a deploy sends the project's files to the service, which keeps them: a password is never one of them",
208
+ fix: `write url: \${DATABASE_URL} in ${shown}, and set DATABASE_URL on the replica (its own environment, on the service); keep the URL here in .env` };
209
+ }
210
+ const generated = readGenerated(p.folder, p.root);
211
+ for (const g of [...generated.definitions, ...generated.refused])
212
+ read.push(path.resolve(p.root, g.file));
213
+ const warnings = generated.refused.flatMap((g) => g.refusals.map((r) => refusalText(g.file, r)));
214
+ const roles = readRoles(p.folder, p.root);
215
+ if (roles !== null && "message" in roles)
216
+ return roles;
217
+ if (roles !== null) {
218
+ read.push(rolesFile(p.folder));
219
+ // the tables the sources file names and the generated tables, as the start holds the roles to them — their columns
220
+ // are the database's, which a deploy does not read: the replica's start holds them
221
+ const named = [...src.tables.map((e) => ({ source: e.table, columns: null })),
222
+ ...[...generated.definitions, ...generated.refused].map((g) => ({ source: g.name, columns: [], pending: true }))];
223
+ const problems = roleProblems(roles, named);
224
+ if (problems.length)
225
+ return rolesFailure(roles.file, p.root, problems);
226
+ }
227
+ return { warnings, read };
228
+ }
229
+ const bytes = (n) => `${n.toLocaleString("en-US")} bytes`;
230
+ /** A file refused, with the way out. */
231
+ const refuse = (message, why, fix) => ({ message, code: "refused", why, fix });
232
+ /**
233
+ * THE PROJECT'S OWN FOLDER, or why a deploy sends nothing from it: its files must be a folder strictly inside the
234
+ * project's root (`folderPlace`, src/cli/project.ts, the one authority, which `mason init` asks too). Its root itself
235
+ * (`"folder": "."`, mason init --folder .) is the application's folder, and anything outside the root (`..`, `../app`,
236
+ * an absolute path, a link that leaves it) is anyone's: a deploy would send every .yml and .yaml there. Fail closed:
237
+ * asked before any of those files is read.
238
+ */
239
+ export function ownFolderRefusal(p) {
240
+ const place = folderPlace(p.root, p.folder);
241
+ if (place.place === "inside")
242
+ return null;
243
+ const config = shownPath(p.root, path.join(p.dir, "config.json"));
244
+ // the config's value as it is written, bounded (a path may be long)
245
+ const named = JSON.stringify(p.config.folder.length > 200 ? `${p.config.folder.slice(0, 200)}…` : p.config.folder);
246
+ const fix = `move mason.yml, roles.yml, sources/ and generated_tables/ into a folder of their own inside ${p.root} (mason/), and set "folder": "mason" in ${config}`;
247
+ // a place that cannot be told is refused, saying it could not tell (fail closed), never thrown
248
+ if (place.place === "unknown") {
249
+ return refuse(`this project's files cannot be found: ${config} names "folder": ${named}, which cannot be resolved`, place.why, `set "folder" in ${config} to the project's folder, a folder of its own inside ${p.root} (mason, as mason init writes it)`);
250
+ }
251
+ return place.place === "root"
252
+ ? refuse("this project's files are its folder's own (mason init --folder .), beside the application's", "a deploy sends every .yml and .yaml under the project's files, and here that is every one of the application's", fix)
253
+ : refuse(`this project's files are outside its folder (${config} names ${named}, which is ${p.folder.length > 300 ? `${p.folder.slice(0, 300)}…` : p.folder})`, `a deploy sends every .yml and .yaml under the project's files, and outside ${p.root} that is anyone's`, fix);
254
+ }
255
+ /**
256
+ * THE FILES A DEPLOY SENDS: `projectFiles` of the project folder, each read and held to the contract's bounds, and
257
+ * every file the check read among them. A Failure naming the file and the bound otherwise.
258
+ */
259
+ export function deployFiles(p, read) {
260
+ const folder = p.folder, shownFolder = `${shownPath(p.root, folder)}/`;
261
+ // never a file of a folder that is not the project's own, whoever calls this (deploy() asks first, before the check)
262
+ const own = ownFolderRefusal(p);
263
+ if (own !== null)
264
+ return own;
265
+ let list, realFolder;
266
+ try {
267
+ list = projectFiles(folder);
268
+ realFolder = fs.realpathSync(folder);
269
+ }
270
+ catch (e) {
271
+ return { message: `the project's files in ${shownFolder} cannot be listed`, code: "failed", why: e.message, fix: "make the folder readable, then mason deploy again" };
272
+ }
273
+ if (list.length > MAX_FILES)
274
+ return refuse(`${shownFolder} has ${list.length} project files, over the ${MAX_FILES} a deploy sends`, "a project file is any .yml or .yaml under it", `move the files Mason does not read out of ${shownFolder}`);
275
+ const files = [];
276
+ let total = 0;
277
+ for (const file of list) {
278
+ const shown = shownPath(p.root, file);
279
+ const rel = path.relative(folder, file).split(path.sep).join("/");
280
+ const parts = rel.split("/");
281
+ if (rel.length > MAX_PATH || !parts.every((s) => PATH_SEGMENT.test(s))) {
282
+ return refuse(`${shown}: its path is not one a deploy sends`, `each part of a path is letters, digits, ., _ and -, starting with a letter, a digit or _, and the path at most ${MAX_PATH} characters`, "rename it (a generated table's file is named for its table)");
283
+ }
284
+ let real, st;
285
+ try {
286
+ real = fs.realpathSync(file);
287
+ st = fs.statSync(real);
288
+ }
289
+ catch (e) {
290
+ return { message: `${shown} cannot be read`, code: "failed", why: e.message, fix: "make it readable, or move it out of the project folder" };
291
+ }
292
+ const inside = path.relative(realFolder, real);
293
+ if (inside === "" || inside.startsWith("..") || path.isAbsolute(inside)) {
294
+ return refuse(`${shown} is a symbolic link to ${real}, outside the project folder`, "a deploy sends the project folder's own files, and never follows a link out of it", `put the file itself in ${shownFolder}, or remove the link`);
295
+ }
296
+ if (!st.isFile())
297
+ return refuse(`${shown} is not a file`, "a project file is a regular file", `move it out of ${shownFolder}`);
298
+ if (st.size > MAX_FILE_BYTES)
299
+ return refuse(`${shown} is ${bytes(st.size)}, over the ${bytes(MAX_FILE_BYTES)} (256 KiB) a deploy sends of one file`, "a project's files name tables, columns and SQL: none is that large", `shorten it, or move it out of ${shownFolder} if Mason does not read it`);
300
+ let buf;
301
+ try {
302
+ buf = fs.readFileSync(real);
303
+ }
304
+ catch (e) {
305
+ return { message: `${shown} cannot be read`, code: "failed", why: e.message, fix: "make it readable, or move it out of the project folder" };
306
+ }
307
+ if (buf.length > MAX_FILE_BYTES)
308
+ return refuse(`${shown} is ${bytes(buf.length)}, over the ${bytes(MAX_FILE_BYTES)} (256 KiB) a deploy sends of one file`, "it grew while it was read", "mason deploy again once it is written");
309
+ let content;
310
+ try {
311
+ content = new TextDecoder("utf-8", { fatal: true }).decode(buf);
312
+ }
313
+ catch {
314
+ return refuse(`${shown} is not UTF-8 text`, "a deploy sends each file as UTF-8 text, as Mason reads it", "save it as UTF-8");
315
+ }
316
+ total += buf.length;
317
+ files.push({ path: rel, size: buf.length, sha256: createHash("sha256").update(buf).digest("hex"), content });
318
+ }
319
+ if (total > MAX_TOTAL_BYTES) {
320
+ const largest = [...files].sort((a, b) => b.size - a.size).slice(0, 3).map((f) => `${f.path} ${bytes(f.size)}`).join(", ");
321
+ return refuse(`the project's files are ${bytes(total)} in all, over the ${bytes(MAX_TOTAL_BYTES)} (1 MiB) a deploy sends`, `the largest: ${largest}`, `move the files Mason does not read out of ${shownFolder}`);
322
+ }
323
+ // every file the check read is sent: one read through a linked folder, which the list does not follow, would be
324
+ // checked here and missing on the replica
325
+ const sent = new Set(list.map((f) => path.resolve(f)));
326
+ const missing = read.filter((f) => !sent.has(path.resolve(f)));
327
+ if (missing.length) {
328
+ return refuse(`${shownPath(p.root, missing[0])} is read by Mason, and is not among the files a deploy sends`, "a folder of the project is a symbolic link, which a deploy does not follow", `replace the link with the folder itself, inside ${shownFolder}`);
329
+ }
330
+ return files;
331
+ }
332
+ /** A deploy's phase within its status: a closed set, each said in the CLI's own words (never the service's text). */
333
+ export const PHASES = {
334
+ checking: "Mason on the replica checks the files",
335
+ writing: "the files are written on the replica",
336
+ restarting: "Mason on the replica restarts on them",
337
+ syncing: "the tables the files add have their first sync",
338
+ };
339
+ /** The phases each status may carry; a phase beside any other status is outside the contract. */
340
+ const PHASES_OF = { applying: ["checking", "writing"], starting: ["restarting", "syncing"] };
341
+ const obj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
342
+ /**
343
+ * `{ deploy: { … } }` held to its shape, the id this command asked for (or, for the POST's answer, one in `DEPLOY_ID`'s
344
+ * grammar, since it goes into the next request's path); null when it is not that shape. Nothing in it steers the CLI:
345
+ * the status and the phase are each one of a closed set, and a refusal's or a failure's words are one line each, its code
346
+ * the CLI's own (`refused`, `failed`, by the status), the service's kept in `why`, and its next step `again`, the CLI's:
347
+ * the service's `next` is never read.
348
+ */
349
+ export function deployStateOf(json, id, secrets, again) {
350
+ if (!obj(json) || !obj(json.deploy))
351
+ return null;
352
+ const d = json.deploy;
353
+ // BEFORE ANY GRAMMAR, every string of the answer, each key included, to the exact test (src/cli/hosted.ts
354
+ // `carriesNoSecret`, issue 627: an id its grammar took was the token whole, and printed): the id, the status, the
355
+ // phase, the times, the failure's code, a key outside the shape. A failure's words alone are left to its mask
356
+ // (`failureShape`), as every failure's are.
357
+ const words = obj(d.failure) ? { ...json, deploy: { ...d, failure: { ...d.failure, message: null, why: null, fix: null } } } : json;
358
+ if (!carriesNoSecret(words, secrets))
359
+ return null;
360
+ if (typeof d.id !== "string" || !DEPLOY_ID.test(d.id) || (id !== null && d.id !== id))
361
+ return null;
362
+ if (typeof d.status !== "string" || !STATUSES.includes(d.status))
363
+ return null;
364
+ const status = d.status;
365
+ let phase = null;
366
+ if (d.phase !== undefined && d.phase !== null) {
367
+ if (typeof d.phase !== "string" || !(PHASES_OF[status] ?? []).includes(d.phase))
368
+ return null;
369
+ phase = d.phase;
370
+ }
371
+ if (!isServiceTime(d.createdAt) || (d.finishedAt !== undefined && d.finishedAt !== null && !isServiceTime(d.finishedAt)))
372
+ return null;
373
+ // a refusal or a failure carries the failure shape (its code, message, why, fix and next in one object), and nothing else does
374
+ let failure = null;
375
+ if (status === "refused" || status === "failed") {
376
+ if (!obj(d.failure))
377
+ return null;
378
+ failure = failureShape(d.failure, secrets, status === "refused"
379
+ ? { code: "refused", said: (c) => `Mason on the replica said ${c}`, next: again }
380
+ : { code: "failed", said: (c) => `the service said ${c}`, next: again });
381
+ if (failure === null)
382
+ return null;
383
+ }
384
+ else if (d.failure !== undefined && d.failure !== null)
385
+ return null;
386
+ return { id: d.id, status, phase, createdAt: d.createdAt, finishedAt: typeof d.finishedAt === "string" ? d.finishedAt : null, failure };
387
+ }
388
+ /** A status for a person, its phase beside it. */
389
+ const STATUS_WORDS = {
390
+ queued: "Queued: waiting for its turn on the replica",
391
+ applying: "Applying: Mason on the replica checks and takes the files",
392
+ starting: "Starting: Mason on the replica starts on the new files",
393
+ running: "Running: the replica runs the new files",
394
+ refused: "Refused by Mason on the replica",
395
+ failed: "The deploy failed",
396
+ };
397
+ const NEXT_AGAIN = (r) => [[`mason deploy --replica ${replicaText(r)}`, "deploy again, after the next edit"]];
398
+ /**
399
+ * THE REPLICA'S STATE, as a `503` or a `409` to the POST may carry it (`{ replica: { state } }`, docs/HOSTED-API.md):
400
+ * one of a closed set, said in the CLI's words, never the service's text; null when it carries none, or another.
401
+ * `503`: a replica being made (`checking`, `creating`), or `ready` with the machine it runs on not connected just now —
402
+ * a wait, and the deploy is sent again. `409`: a replica that cannot take one (`failed`, `deleting`) — refused,
403
+ * never sent again. A deleted replica is no state here: its name is freed, and the service answers it `404`.
404
+ */
405
+ export const WAITING_STATES = {
406
+ checking: "is being checked (Mason checks its database)",
407
+ creating: "is being created",
408
+ ready: "is ready, and the machine it runs on is not connected just now",
409
+ };
410
+ export const REFUSED_STATES = {
411
+ failed: "failed: its creation did not finish",
412
+ deleting: "is being deleted",
413
+ };
414
+ function replicaStateOf(json, states) {
415
+ if (!obj(json) || !obj(json.replica) || typeof json.replica.state !== "string")
416
+ return null;
417
+ return Object.hasOwn(states, json.replica.state) ? json.replica.state : null;
418
+ }
419
+ /** A wait, as a person reads it: whole seconds, then minutes and seconds. */
420
+ const waited = (ms) => { const n = Math.floor(ms / 1000); return n < 60 ? `${n} s` : `${Math.floor(n / 60)} min ${n % 60} s`; };
421
+ const stateLine = (s) => `${STATUS_WORDS[s.status]}${s.phase ? ` (${PHASES[s.phase]})` : ""}`;
422
+ export async function deploy(argv) {
423
+ const f = parseCommand(DEPLOY, argv);
424
+ if (typeof f === "number")
425
+ return f;
426
+ const json = f.values.json === true;
427
+ const dryRun = f.values["dry-run"] === true;
428
+ if (f.positionals.length)
429
+ return fail({ message: "it takes no argument", code: "usage", why: "the project is the folder's, the replica --replica's", usage: DEPLOY.usage }, { json });
430
+ const timeoutText = typeof f.values.timeout === "string" ? f.values.timeout : DEFAULT_TIMEOUT;
431
+ const timeout = parseDuration(timeoutText, { min: MIN_POLL_SECONDS, max: MAX_POLL_SECONDS });
432
+ if ("problem" in timeout)
433
+ return fail({ message: `--timeout ${timeout.problem}`, code: "usage", usage: DEPLOY.usage }, { json });
434
+ const replica = replicaOf(typeof f.values.replica === "string" ? f.values.replica : undefined, DEPLOY.usage);
435
+ if (replica !== null && isFailure(replica))
436
+ return fail(replica, { json });
437
+ if (replica === null && !dryRun) {
438
+ return fail({ message: "no replica named", code: "usage", why: "a deploy goes to one replica of the service",
439
+ fix: `name it: mason deploy --replica <org>/<name>, or set ${REPLICA_ENV}=<org>/<name>`, usage: DEPLOY.usage }, { json });
440
+ }
441
+ const service = serviceOf();
442
+ if (isFailure(service))
443
+ return fail(service, { json });
444
+ // THE PROJECT, checked as a start checks it, offline; then its files
445
+ const t = theProject(process.cwd());
446
+ if ("problem" in t)
447
+ return fail(t, { json });
448
+ const project = t.project;
449
+ // the project's own folder first: nothing of a folder that is not one is read, let alone sent
450
+ const own = ownFolderRefusal(project);
451
+ if (own !== null)
452
+ return fail(own, { json });
453
+ const checked = checkProject(project);
454
+ if (isFailure(checked))
455
+ return fail(checked, { json });
456
+ const files = deployFiles(project, checked.read);
457
+ if (isFailure(files))
458
+ return fail(files, { json });
459
+ const totalBytes = files.reduce((n, x) => n + x.size, 0);
460
+ const listed = files.map(({ path: p, size, sha256 }) => ({ path: p, size, sha256 }));
461
+ // --json carries each warning as one line (a string with no line break); a person reads its lines on stderr
462
+ const base = { replica: replica === null ? null : replicaText(replica), service: service.base, files: listed, totalBytes,
463
+ warnings: checked.warnings.map((w) => w.split("\n").map((l) => l.trim()).filter(Boolean).join(" ")) };
464
+ if (!json)
465
+ for (const w of checked.warnings)
466
+ say(textEntry(" ! ", w).trimEnd());
467
+ if (dryRun) {
468
+ const next = replica === null
469
+ ? [["mason deploy --replica <org>/<name>", "send it to a replica"]]
470
+ : [[`mason deploy --replica ${replicaText(replica)}`, "send it"]];
471
+ if (json) {
472
+ out(jsonText(DEPLOY, { dryRun: true, ...base, deploy: null }, next));
473
+ return 0;
474
+ }
475
+ const w = Math.max(...files.map((x) => x.path.length)), sw = Math.max(...files.map((x) => bytes(x.size).length));
476
+ out(reportText({
477
+ headline: `Would send ${files.length} file${files.length === 1 ? "" : "s"}, ${bytes(totalBytes)}, ${replica === null ? `to ${service.base} (no replica named)` : `to ${replicaText(replica)} on ${service.base}`}; nothing was sent`,
478
+ details: files.map((x) => [x.path.padEnd(w), `${bytes(x.size).padStart(sw)} sha256 ${x.sha256}`]),
479
+ next,
480
+ }, outStyle()));
481
+ return 0;
482
+ }
483
+ const r = replica;
484
+ const token = tokenFor(service, { asked: "a deploy is sent", again: "mason deploy" });
485
+ if (isFailure(token))
486
+ return fail(token, { json });
487
+ // from here every byte written is put to the exact test against the token (src/cli/output-gate.ts)
488
+ holdOutput({ command: "deploy", json, secrets: [token] });
489
+ const secrets = [token];
490
+ const where = `/v1/replicas/${r.org}/${r.name}/deploys`;
491
+ const started = Date.now(), deadline = started + timeout.seconds * 1000;
492
+ const s = errStyle();
493
+ // a spinner turns only on a terminal wide enough for a label beside its frame; else each line is said once, as on a pipe
494
+ const terminal = !json && process.stderr.isTTY === true && clipped("x") !== null;
495
+ const steps = stepRenderer({ line: say, live: terminal ? process.stderr : null, style: s });
496
+ const name = replicaText(r);
497
+ const statusStep = [`mason replica status ${name}`, "the replica's state"];
498
+ // THE DEPLOY, SENT: 201 and its id, else the service's failure as it worded it. A 503 (the replica is being made, or
499
+ // its machine is not connected; or the service is full) or a 429 (too many deploys at once) says NO deploy was made:
500
+ // the same body is sent again after the wait it asks (`Retry-After`, else RESEND_MS), within --timeout. No other
501
+ // answer is ever sent again: a POST that may have made a deploy would make a second.
502
+ const body = { files: files.map(({ path: p, sha256, content }) => ({ path: p, sha256, content })), client: { version: version() } };
503
+ let waitSaid = "", waitSaidAt = 0, waitLine = null;
504
+ let sent = await call(service, "POST", where, { token, json: body });
505
+ while (!isFailure(sent) && (sent.status === 503 || sent.status === 429)) {
506
+ const asked = sent;
507
+ // nothing of a wait's answer is printed but its status and the replica's state (in the CLI's words); still, an
508
+ // answer that carries the token anywhere but a failure's words is refused whole, as every answer is
509
+ const j = asked.json;
510
+ const keys = obj(j) && obj(j.error) ? { ...j, error: { ...j.error, message: null, why: null, fix: null } } : j;
511
+ if (!carriesNoSecret(keys, secrets)) {
512
+ if (terminal && waitLine !== null)
513
+ steps.fail(waitLine);
514
+ return fail(unexpected(service, asked, "a deploy was sent"), { json });
515
+ }
516
+ const st = asked.status === 503 ? replicaStateOf(j, WAITING_STATES) : null;
517
+ const reason = asked.status === 429 ? "too many deploys are being sent at once"
518
+ : st !== null ? `${name} ${WAITING_STATES[st]}` : "the service cannot take a deploy just now";
519
+ const resendAt = Date.now() + (asked.retryAfter !== null ? asked.retryAfter * 1000 : RESEND_MS);
520
+ for (;;) {
521
+ waitLine = `Waiting to send the deploy: ${reason} (HTTP ${asked.status}); waited ${waited(Date.now() - started)}`;
522
+ if (terminal)
523
+ steps.relabel(clipped(waitLine));
524
+ else if (!json) {
525
+ // a line when what it waits on changes, and every 30 s while it does not (`sayAgain`, as mason wait says its block)
526
+ const what = waitLine.replace(/; waited .*$/, "");
527
+ if (sayAgain(what, waitSaid, waitSaidAt, Date.now())) {
528
+ say(` ${waitLine}`);
529
+ waitSaid = what;
530
+ waitSaidAt = Date.now();
531
+ }
532
+ }
533
+ const left = Math.min(resendAt, deadline) - Date.now();
534
+ if (left <= 0)
535
+ break;
536
+ await sleep(Math.min(TICK_MS, left));
537
+ }
538
+ if (Date.now() >= deadline) {
539
+ if (terminal)
540
+ steps.fail(waitLine);
541
+ return fail({ message: `the deploy to ${name} was not sent within ${timeout.every}`, code: "timeout",
542
+ why: `the service asked to wait: ${reason} (HTTP ${asked.status}); no deploy was made`,
543
+ fix: `mason deploy again once ${name} is ready (mason replica status ${name} says its state); a longer wait is --timeout 30m`,
544
+ next: [statusStep, [`mason deploy --replica ${name}`, "deploy again"]] }, { json });
545
+ }
546
+ sent = await call(service, "POST", where, { token, json: body });
547
+ }
548
+ if (terminal && waitLine !== null)
549
+ steps.done(waitLine);
550
+ if (isFailure(sent)) {
551
+ return fail({ ...sent, why: `${sent.why ?? ""}${sent.why ? "; " : ""}whether the deploy was made is not known`,
552
+ fix: `${sent.fix ?? "check this machine is online"}, then mason deploy again (a deploy still running is refused as busy)` }, { json });
553
+ }
554
+ if (sent.status !== 201) {
555
+ // a 409 that names the replica's state: it cannot take a deploy (failed, being deleted), never sent again;
556
+ // the next step is the replica's status, the CLI's own (never the generic "mason login")
557
+ const refused = sent.status === 409 ? replicaStateOf(sent.json, REFUSED_STATES) : null;
558
+ // a 404: no such replica, a deleted one included (its name is freed): the organisation's replicas, never mason login
559
+ const notFound = (st) => st === 404
560
+ ? [[`mason replica list ${r.org}`, "the organisation's replicas: this one was deleted, or never existed"]] : serviceNext(st);
561
+ if (refused === null)
562
+ return fail(serviceFailure(sent, secrets, notFound) ?? unexpected(service, sent, "a deploy was sent"), { json });
563
+ const f = serviceFailure(sent, secrets, () => [statusStep]);
564
+ if (f === null)
565
+ return fail(unexpected(service, sent, "a deploy was sent"), { json });
566
+ return fail({ ...f, why: `${name} ${REFUSED_STATES[refused]}; ${f.why}`,
567
+ fix: f.fix ?? `a replica takes a deploy only once it is ready: mason replica status ${name} says its state` }, { json });
568
+ }
569
+ // what to run after a refused or failed deploy: the CLI's own step, never one the service names
570
+ const again = [[`mason deploy --replica ${name}`, "deploy again, once what it says is fixed"]];
571
+ const first = deployStateOf(sent.json, null, secrets, again);
572
+ if (first === null || first.status !== "queued")
573
+ return fail(unexpected(service, sent, "a deploy was sent"), { json });
574
+ const id = first.id;
575
+ if (!json)
576
+ say(`Sent ${files.length} file${files.length === 1 ? "" : "s"} (${bytes(totalBytes)}) to ${name} on ${service.base}: deploy ${id}`);
577
+ // THE WAIT: a line when the state changes (and every 30 s while it does not), a spinner beside it on a terminal
578
+ let state = first, said = "", saidAt = 0, unsure = null, unsureSaid = null;
579
+ const show = (line) => {
580
+ if (json)
581
+ return;
582
+ // on a terminal: the step that ends is said whole (a line the CLI breaks), the one that turns cut to one line
583
+ if (terminal) {
584
+ if (line !== said) {
585
+ if (said !== "")
586
+ steps.done(said);
587
+ steps.begin(clipped(line));
588
+ said = line;
589
+ }
590
+ return;
591
+ }
592
+ if (sayAgain(line, said, saidAt, Date.now())) {
593
+ say(` ${line}`);
594
+ said = line;
595
+ saidAt = Date.now();
596
+ }
597
+ };
598
+ show(stateLine(state));
599
+ let waitMs = sent.retryAfter !== null ? sent.retryAfter * 1000 : POLL_MS;
600
+ const ended = (error) => {
601
+ const value = { dryRun: false, ...base, deploy: { id, status: state.status, phase: state.phase, createdAt: state.createdAt, finishedAt: state.finishedAt } };
602
+ if (json) {
603
+ if (error === null) {
604
+ out(jsonText(DEPLOY, value, NEXT_AGAIN(r)));
605
+ return 0;
606
+ }
607
+ out(jsonText(DEPLOY, { ...value, error: errorJson(error) }, error.next ?? []));
608
+ return exitOf(error);
609
+ }
610
+ // the step it ended on: the outcome's own line when it has one, else (a timeout, an answer outside the contract) the last
611
+ const line = FINAL.has(state.status) ? stateLine(state) : null;
612
+ // (on a terminal the step turning is ticked and the outcome is a line of its own; a refusal replaces the step it ended)
613
+ if (terminal) {
614
+ if (error === null) {
615
+ if (line !== null && line !== said) {
616
+ steps.done(said);
617
+ steps.begin(clipped(line));
618
+ said = line;
619
+ }
620
+ steps.done(said);
621
+ }
622
+ else
623
+ steps.fail(line ?? said);
624
+ }
625
+ else if (line !== null && line !== said)
626
+ say(` ${line}`);
627
+ if (error !== null)
628
+ return fail(error, { json: false });
629
+ out(reportText({ headline: `Deployed to ${replicaText(r)}: the replica runs it`,
630
+ details: [["deploy", id], ["service", service.base], ["files", `${files.length} (${bytes(totalBytes)})`], ["took", `${Math.max(1, Math.round((Date.now() - started) / 1000))} s`]],
631
+ next: NEXT_AGAIN(r) }, outStyle()));
632
+ return 0;
633
+ };
634
+ for (;;) {
635
+ if (FINAL.has(state.status)) {
636
+ if (state.status === "running")
637
+ return ended(null);
638
+ // Mason on the replica refused it, or it failed: its words as they came, never re-worded; its code the CLI's (exit 1)
639
+ return ended(state.failure);
640
+ }
641
+ const left = deadline - Date.now();
642
+ if (left <= 0) {
643
+ return ended({ message: `deploy ${id} to ${replicaText(r)} did not finish within ${timeout.every}`, code: "timeout",
644
+ why: unsure !== null ? `the service could not say how it is going: ${unsure}` : `it was last: ${stateLine(state)}`,
645
+ fix: `nothing was stopped, and it may still finish: the service's web app shows deploy ${id} of ${replicaText(r)}, as GET ${where}/${id} does (the hosted API); a longer wait is --timeout 30m`,
646
+ next: [] });
647
+ }
648
+ await sleep(Math.min(waitMs, left));
649
+ const a = await call(service, "GET", `${where}/${id}`, { token });
650
+ waitMs = POLL_MS;
651
+ // an answer that cannot say how it goes is a wait, never an outcome: the timeout bounds it
652
+ if (isFailure(a) || a.status === 429 || a.status >= 500) {
653
+ unsure = isFailure(a) ? serviceText(`${a.message}${a.why ? ` (${a.why})` : ""}`, 300, secrets) : `${a.what} answered ${a.status}`;
654
+ if (!isFailure(a) && a.retryAfter !== null)
655
+ waitMs = a.retryAfter * 1000;
656
+ // on a terminal a note above the turning step, which goes on (the step is not over); elsewhere the step's line again
657
+ if (terminal) {
658
+ if (unsure !== unsureSaid)
659
+ steps.print(s.dim(` the service could not say more just now (${unsure}): asking again`));
660
+ unsureSaid = unsure;
661
+ }
662
+ else
663
+ show(`${stateLine(state)}; the service could not say more just now (${unsure}): asking again`);
664
+ continue;
665
+ }
666
+ if (a.retryAfter !== null)
667
+ waitMs = a.retryAfter * 1000;
668
+ // a refusal of the question (the token revoked meanwhile, the replica gone) is the service's, as it worded it
669
+ if (a.status !== 200)
670
+ return ended(serviceFailure(a, secrets) ?? unexpected(service, a, "the deploy's state was asked for"));
671
+ const now = deployStateOf(a.json, id, secrets, again);
672
+ if (now === null)
673
+ return ended(unexpected(service, a, "the deploy's state was asked for"));
674
+ state = now;
675
+ unsure = null;
676
+ unsureSaid = null;
677
+ if (!FINAL.has(state.status))
678
+ show(stateLine(state));
679
+ }
680
+ }