@mason-data/cli 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -4
- package/dist/src/api/error.js +4 -0
- package/dist/src/api/postgres/proxy.js +6 -160
- package/dist/src/api/postgres/server.js +22 -4
- package/dist/src/cli/command.js +169 -5
- package/dist/src/cli/commands.js +295 -159
- package/dist/src/cli/deploy.js +134 -28
- package/dist/src/cli/dev.js +21 -5
- package/dist/src/cli/doctor.js +14 -11
- package/dist/src/cli/guides.js +20 -11
- package/dist/src/cli/hosted.js +1 -1
- package/dist/src/cli/init.js +110 -25
- package/dist/src/cli/login.js +20 -17
- package/dist/src/cli/machine.js +125 -40
- package/dist/src/cli/main.js +51 -8
- package/dist/src/cli/mcp.js +15 -12
- package/dist/src/cli/project.js +25 -2
- package/dist/src/cli/registry.js +80 -35
- package/dist/src/cli/replica-hosted.js +761 -102
- package/dist/src/cli/replica.js +43 -5
- package/dist/src/cli/replication-mode.js +5 -0
- package/dist/src/cli/request-filter.js +16 -6
- package/dist/src/cli/run-server.js +70 -31
- package/dist/src/cli/secret-input.js +193 -0
- package/dist/src/cli/security.js +7 -2
- package/dist/src/cli/serve.js +119 -9
- package/dist/src/cli/share.js +20 -15
- package/dist/src/cli/source-role.js +14 -15
- package/dist/src/cli/sources.js +20 -3
- package/dist/src/cli/style.js +14 -0
- package/dist/src/cli/sync.js +17 -8
- package/dist/src/cli/token-hosted.js +30 -24
- package/dist/src/cli/tunnel.js +1 -1
- package/dist/src/cli/ui-hosted.js +163 -0
- package/dist/src/cli/ui-read-only.js +23 -0
- package/dist/src/cli/ui-run-file.js +247 -0
- package/dist/src/cli/ui-socket.js +183 -0
- package/dist/src/cli/ui-ssh.js +3 -3
- package/dist/src/cli/ui.js +67 -26
- package/dist/src/cli/usage.js +19 -15
- package/dist/src/cli/user-hosted.js +78 -13
- package/dist/src/cli/user.js +168 -50
- package/dist/src/duck/users.js +105 -9
- package/dist/src/lib/cidr.js +177 -0
- package/dist/src/lib/table-sync.js +69 -19
- package/dist/src/web/app.css +5 -0
- package/dist/src/web/app.js +4 -4
- package/dist/src/web/index.html +5 -8
- package/dist/src/web/pages/queries.js +3 -3
- package/dist/src/web/pages/status.js +23 -17
- package/dist/src/web/pages/syncs.js +3 -3
- package/dist/src/web/pages/tables.js +18 -15
- package/dist/src/web/pages/usage.js +16 -12
- package/dist/src/web/pages/users.js +45 -14
- package/dist/src/web/shared.js +10 -4
- package/package.json +6 -6
package/dist/src/cli/deploy.js
CHANGED
|
@@ -26,7 +26,12 @@
|
|
|
26
26
|
* 5. THE WAIT is bounded by `--timeout` (an interval, 10m by default): a poll every `POLL_MS`, or what `Retry-After`
|
|
27
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
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.
|
|
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.
|
|
30
35
|
*
|
|
31
36
|
* A SERVICE'S VALUE IS DATA, NEVER STRUCTURE AND NEVER AN INSTRUCTION (#481's decision, src/cli/hosted.ts). Every place a
|
|
32
37
|
* value the service sent reaches what this command prints:
|
|
@@ -57,7 +62,7 @@ import * as path from "node:path";
|
|
|
57
62
|
import { createHash } from "node:crypto";
|
|
58
63
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
59
64
|
import { errorJson, exitOf, failed, jsonText, parseCommand, reportText, serviceStyle, strict } from "./command.js";
|
|
60
|
-
import { call, carriesNoSecret, failureShape, isFailure, isServiceTime, serviceFailure, serviceOf, serviceText, unexpected } from "./hosted.js";
|
|
65
|
+
import { call, carriesNoSecret, failureShape, isFailure, isServiceTime, serviceFailure, serviceNext, serviceOf, serviceText, unexpected } from "./hosted.js";
|
|
61
66
|
import { replicaOf, replicaText, REPLICA_ENV, tokenFor } from "./replica.js";
|
|
62
67
|
import { holdOutput } from "./output-gate.js";
|
|
63
68
|
import { maskedUrl } from "./preflight.js";
|
|
@@ -69,7 +74,7 @@ import { readSourceFile, shownPath } from "./sources.js";
|
|
|
69
74
|
import { readRoles, roleProblems, rolesFailure, rolesFile } from "./roles.js";
|
|
70
75
|
import { readGenerated } from "./generated-files.js";
|
|
71
76
|
import { refusalText } from "../duck/generated-plan.js";
|
|
72
|
-
import { parseDuration } from "../lib/table-sync.js";
|
|
77
|
+
import { MAX_POLL_SECONDS, MIN_POLL_SECONDS, parseDuration } from "../lib/table-sync.js";
|
|
73
78
|
import { textEntry } from "../lib/log.js";
|
|
74
79
|
import { sayAgain } from "./commands.js";
|
|
75
80
|
import { err as styleErr, out as styleOut, stepRenderer } from "./style.js";
|
|
@@ -83,6 +88,11 @@ export const PATH_SEGMENT = /^[A-Za-z0-9_][A-Za-z0-9._-]*$/;
|
|
|
83
88
|
const MAX_PATH = 255;
|
|
84
89
|
/** How often the deploy's state is asked for, when the service asks no other. */
|
|
85
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;
|
|
86
96
|
const DEFAULT_TIMEOUT = "10m";
|
|
87
97
|
/** A deploy's id, as it goes into a path: the contract's grammar. */
|
|
88
98
|
const DEPLOY_ID = /^[A-Za-z0-9_-]{1,64}$/;
|
|
@@ -121,7 +131,7 @@ export const DEPLOY = {
|
|
|
121
131
|
needs: "project",
|
|
122
132
|
name: "deploy",
|
|
123
133
|
summary: "send this project to a replica on a hosted Mason service, and wait until the replica runs it",
|
|
124
|
-
usage: `usage: mason deploy [--replica <org>/<name>] [--dry-run] [--timeout <
|
|
134
|
+
usage: `usage: mason deploy [--replica <org>/<name>] [--dry-run] [--timeout <duration>] [--json]
|
|
125
135
|
|
|
126
136
|
Sends this project's files (what mason/ holds that Mason reads: the sources file, mason.yml, roles.yml, the
|
|
127
137
|
generated tables — never .env, .mason/, a dotfile or a file that is not YAML) to a replica on a hosted Mason
|
|
@@ -130,10 +140,6 @@ export const DEPLOY = {
|
|
|
130
140
|
project that would be refused is refused here, and nothing is sent. A url: written with its password is refused:
|
|
131
141
|
write url: \${DATABASE_URL} and set it on the replica.
|
|
132
142
|
|
|
133
|
-
The replica is --replica <org>/<name>, else MASON_REPLICA (each part lower case letters, digits and -). The login
|
|
134
|
-
is MASON_TOKEN, else this machine's (mason login); the service is MASON_CLOUD_URL, else the default. Progress
|
|
135
|
-
goes to stderr; the answer is printed when it ends.
|
|
136
|
-
|
|
137
143
|
mason deploy --replica acme/shop
|
|
138
144
|
mason deploy --replica acme/shop --dry-run what would be sent, sending nothing
|
|
139
145
|
MASON_REPLICA=acme/shop mason deploy --timeout 30m
|
|
@@ -141,19 +147,31 @@ export const DEPLOY = {
|
|
|
141
147
|
--replica <org>/<name> the replica to deploy to (else MASON_REPLICA)
|
|
142
148
|
--dry-run print each file that would be sent, its size and its sha256, and send nothing (no
|
|
143
149
|
login needed)
|
|
144
|
-
--timeout <
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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.
|
|
152
161
|
|
|
153
162
|
Exit status: 0 the replica runs it (or a dry run printed); 1 refused here or by the service, refused by Mason on
|
|
154
163
|
the replica (its words printed as they came, the code the CLI's own), the deploy failed, or the service answered
|
|
155
164
|
outside its contract; 2 a wrong command line; 124 the wait timed out (nothing stopped: the deploy's id says where
|
|
156
|
-
to look). Whatever the service sends, it never makes the exit 0, 2
|
|
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 }
|
|
157
175
|
`,
|
|
158
176
|
options: { replica: { type: "string" }, "dry-run": { type: "boolean", default: false }, timeout: { type: "string" }, json: { type: "boolean", default: false } },
|
|
159
177
|
json: ["dryRun", "replica", "service", "files", "totalBytes", "warnings", "deploy", "error", "next"],
|
|
@@ -377,6 +395,29 @@ const STATUS_WORDS = {
|
|
|
377
395
|
failed: "The deploy failed",
|
|
378
396
|
};
|
|
379
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`; };
|
|
380
421
|
const stateLine = (s) => `${STATUS_WORDS[s.status]}${s.phase ? ` (${PHASES[s.phase]})` : ""}`;
|
|
381
422
|
export async function deploy(argv) {
|
|
382
423
|
const f = parseCommand(DEPLOY, argv);
|
|
@@ -387,7 +428,7 @@ export async function deploy(argv) {
|
|
|
387
428
|
if (f.positionals.length)
|
|
388
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 });
|
|
389
430
|
const timeoutText = typeof f.values.timeout === "string" ? f.values.timeout : DEFAULT_TIMEOUT;
|
|
390
|
-
const timeout = parseDuration(timeoutText);
|
|
431
|
+
const timeout = parseDuration(timeoutText, { min: MIN_POLL_SECONDS, max: MAX_POLL_SECONDS });
|
|
391
432
|
if ("problem" in timeout)
|
|
392
433
|
return fail({ message: `--timeout ${timeout.problem}`, code: "usage", usage: DEPLOY.usage }, { json });
|
|
393
434
|
const replica = replicaOf(typeof f.values.replica === "string" ? f.values.replica : undefined, DEPLOY.usage);
|
|
@@ -448,26 +489,91 @@ export async function deploy(argv) {
|
|
|
448
489
|
const secrets = [token];
|
|
449
490
|
const where = `/v1/replicas/${r.org}/${r.name}/deploys`;
|
|
450
491
|
const started = Date.now(), deadline = started + timeout.seconds * 1000;
|
|
451
|
-
|
|
452
|
-
|
|
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);
|
|
453
550
|
if (isFailure(sent)) {
|
|
454
551
|
return fail({ ...sent, why: `${sent.why ?? ""}${sent.why ? "; " : ""}whether the deploy was made is not known`,
|
|
455
552
|
fix: `${sent.fix ?? "check this machine is online"}, then mason deploy again (a deploy still running is refused as busy)` }, { json });
|
|
456
553
|
}
|
|
457
|
-
if (sent.status !== 201)
|
|
458
|
-
|
|
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
|
+
}
|
|
459
569
|
// what to run after a refused or failed deploy: the CLI's own step, never one the service names
|
|
460
|
-
const again = [[`mason deploy --replica ${
|
|
570
|
+
const again = [[`mason deploy --replica ${name}`, "deploy again, once what it says is fixed"]];
|
|
461
571
|
const first = deployStateOf(sent.json, null, secrets, again);
|
|
462
572
|
if (first === null || first.status !== "queued")
|
|
463
573
|
return fail(unexpected(service, sent, "a deploy was sent"), { json });
|
|
464
574
|
const id = first.id;
|
|
465
|
-
const s = errStyle();
|
|
466
|
-
// 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
|
|
467
|
-
const terminal = !json && process.stderr.isTTY === true && clipped("x") !== null;
|
|
468
|
-
const steps = stepRenderer({ line: say, live: terminal ? process.stderr : null, style: s });
|
|
469
575
|
if (!json)
|
|
470
|
-
say(`Sent ${files.length} file${files.length === 1 ? "" : "s"} (${bytes(totalBytes)}) to ${
|
|
576
|
+
say(`Sent ${files.length} file${files.length === 1 ? "" : "s"} (${bytes(totalBytes)}) to ${name} on ${service.base}: deploy ${id}`);
|
|
471
577
|
// THE WAIT: a line when the state changes (and every 30 s while it does not), a spinner beside it on a terminal
|
|
472
578
|
let state = first, said = "", saidAt = 0, unsure = null, unsureSaid = null;
|
|
473
579
|
const show = (line) => {
|
package/dist/src/cli/dev.js
CHANGED
|
@@ -29,6 +29,7 @@ import * as path from "node:path";
|
|
|
29
29
|
import { choosePgPort, freePort } from "./ports.js";
|
|
30
30
|
import { failed } from "./command.js";
|
|
31
31
|
import { checkSource, connectSource, maskedUrl, readSourceFacts, remoteConfirmation, remoteWarning } from "./preflight.js";
|
|
32
|
+
import { uiReadOnlySetting } from "./ui-read-only.js";
|
|
32
33
|
import { createRunServer, listenOnLoopback, metricsFacts, usageFacts } from "./run-server.js";
|
|
33
34
|
import { readGenerated } from "./generated-files.js";
|
|
34
35
|
import { addedTables, effectiveSync, entryName, replicatedEntries, rereadSources, shownPath, syncWritten } from "./sources.js";
|
|
@@ -789,6 +790,10 @@ async function run(o, hosted) {
|
|
|
789
790
|
version: o.version, startedAt,
|
|
790
791
|
// the web page (mason ui): on loopback alone, so mason serve's is reached through mason ui --ssh
|
|
791
792
|
page: true, served: o.serve !== undefined,
|
|
793
|
+
// MASON_UI_READ_ONLY=1: a server's page refuses every change it asks for (mason start refuses the setting above)
|
|
794
|
+
pageReadOnly: o.serve !== undefined && uiReadOnlySetting(process.env) === true,
|
|
795
|
+
// MASON_UI_SOCKET: the page on a Unix socket too (src/cli/ui-socket.ts), or null
|
|
796
|
+
pageSocket: o.serve?.socket ?? null,
|
|
792
797
|
demo: cfg.demo === true,
|
|
793
798
|
users: { file: path.join(home, USERS_FILE), apiKey: cfg.apiKey, database, postgresUrl },
|
|
794
799
|
// the roles as the listener holds them: their row filters checked by its catalog (a filter Postgres refuses is a problem)
|
|
@@ -824,6 +829,7 @@ async function run(o, hosted) {
|
|
|
824
829
|
restarts: () => readRestarts(home), jsonPaths: () => sync.kept.status(), generated: () => sync.generated.status() },
|
|
825
830
|
};
|
|
826
831
|
const server = createRunServer(runCtx);
|
|
832
|
+
o.serve?.page?.(server);
|
|
827
833
|
// a sample is /api/state's facts at its moment (src/cli/run-server.ts usageFacts): one computation of each
|
|
828
834
|
usage.start(() => usageFacts(runCtx));
|
|
829
835
|
await listenOnLoopback(server, setupPort);
|
|
@@ -867,6 +873,8 @@ async function run(o, hosted) {
|
|
|
867
873
|
// the stop is in place BEFORE the ready line: a supervisor or a test signals on reading it, and a SIGTERM that found
|
|
868
874
|
// no handler would end the run with the default action, never recording `running: null`
|
|
869
875
|
let stopping = false;
|
|
876
|
+
// the run's own stop, for a step of a serve's ready that failed (`ServeHooks.ready`): its words are what lastStop says
|
|
877
|
+
let stopFor = () => undefined;
|
|
870
878
|
const stopped = new Promise((resolve) => {
|
|
871
879
|
// a crash of the engine's thread: the run exits 1 with its reason in V8's fatal form, which the supervisor reads
|
|
872
880
|
// (src/cli/supervise.ts crashReason), and its config still says it runs, so the supervisor starts it again
|
|
@@ -889,7 +897,7 @@ async function run(o, hosted) {
|
|
|
889
897
|
resolve(1);
|
|
890
898
|
})();
|
|
891
899
|
};
|
|
892
|
-
const stop = (signal) => {
|
|
900
|
+
const stop = (signal, how) => {
|
|
893
901
|
if (stopping)
|
|
894
902
|
return;
|
|
895
903
|
stopping = true;
|
|
@@ -965,14 +973,16 @@ async function run(o, hosted) {
|
|
|
965
973
|
(repl.owned ? "" : `; publication ${repl.publication} is its owner's, left as it is`));
|
|
966
974
|
cfg.running = null;
|
|
967
975
|
// how it ended, for mason status once it is gone (a kill records nothing: mason stop writes that one)
|
|
968
|
-
cfg.lastStop = { at: new Date().toISOString(), how: signal === "SIGINT" ? "stopped from its terminal (SIGINT)" : `stopped by mason stop, or a shutdown (${signal})` };
|
|
976
|
+
cfg.lastStop = { at: new Date().toISOString(), how: how ?? (signal === "SIGINT" ? "stopped from its terminal (SIGINT)" : `stopped by mason stop, or a shutdown (${signal})`) };
|
|
969
977
|
writeConfig(home, cfg);
|
|
970
978
|
log.end();
|
|
971
979
|
resolve(0);
|
|
972
980
|
})();
|
|
973
981
|
};
|
|
974
|
-
|
|
975
|
-
process.on("
|
|
982
|
+
// wrapped: Node calls a signal's listener with (name, number), and the number must never be read as `how`
|
|
983
|
+
process.on("SIGINT", () => { stop("SIGINT"); });
|
|
984
|
+
process.on("SIGTERM", () => { stop("SIGTERM"); });
|
|
985
|
+
stopFor = (how) => { stop("SIGTERM", how); };
|
|
976
986
|
});
|
|
977
987
|
say("");
|
|
978
988
|
say(` Postgres ${withPlaceholders(postgresUrl)} (mason user add <name> makes a user)`);
|
|
@@ -985,9 +995,15 @@ async function run(o, hosted) {
|
|
|
985
995
|
const elsewhere = elsewhereTip(pgTls.listener);
|
|
986
996
|
if (elsewhere)
|
|
987
997
|
say(` ${elsewhere}`);
|
|
998
|
+
// before the ready line: what a serve writes when it answers (MASON_UI_RUN_FILE) is there when a supervisor reads it
|
|
999
|
+
// a step of it that failed stops the run, said in its words, and the ready line is never printed: it did not get ready
|
|
1000
|
+
const notReady = o.serve?.ready({ tables: () => sync.state(), metrics: () => metricsFacts(runCtx), page: { port: setupPort, token } }) ?? null;
|
|
1001
|
+
if (notReady !== null) {
|
|
1002
|
+
stopFor(notReady);
|
|
1003
|
+
return await stopped;
|
|
1004
|
+
}
|
|
988
1005
|
say(READY);
|
|
989
1006
|
say("");
|
|
990
|
-
o.serve?.ready({ tables: () => sync.state(), metrics: () => metricsFacts(runCtx) });
|
|
991
1007
|
return await stopped;
|
|
992
1008
|
}
|
|
993
1009
|
/**
|
package/dist/src/cli/doctor.js
CHANGED
|
@@ -32,8 +32,13 @@ export const DOCTOR_USAGE = `usage: mason doctor [--json]
|
|
|
32
32
|
Says whether this machine is ready for Mason — Node ${NODE_MIN} or later, memory (what the engine may use and what it
|
|
33
33
|
leaves beside it; MASON_DUCKDB_MEMORY=4GiB sets it), Mason's postgres and inet extensions (installed with Mason for this
|
|
34
34
|
platform, so nothing is downloaded; else the first sync downloads them), the ports Mason listens on — with the way
|
|
35
|
-
out for anything missing.
|
|
36
|
-
|
|
35
|
+
out for anything missing.
|
|
36
|
+
|
|
37
|
+
mason doctor
|
|
38
|
+
|
|
39
|
+
--json as data: its shape is at the end
|
|
40
|
+
|
|
41
|
+
In a project's folder it checks the project's database too; with Mason running for it, the run in a few lines instead: its
|
|
37
42
|
database, whether every added table is synced, the statements of the last day it refused or answered slowly. Both
|
|
38
43
|
say whether the user Mason connects as could change a synced table's rows (a superuser, BYPASSRLS, an owner of a
|
|
39
44
|
synced table, a write privilege on one or CREATE on its schema; or a write that reaches its rows through a
|
|
@@ -41,16 +46,14 @@ export const DOCTOR_USAGE = `usage: mason doctor [--json]
|
|
|
41
46
|
reaches; a foreign server; ADMIN OPTION or a server role; directly or through a role it is a member of), with the
|
|
42
47
|
statements that make a role that only reads.
|
|
43
48
|
|
|
44
|
-
mason doctor
|
|
45
|
-
|
|
46
|
-
--json as data: { ready, verdict, checks: [{ name, ok, detail, warn, code, fix }], next: [{ command, what }] }; ok
|
|
47
|
-
is true, false, or null when it could not tell; warn (only when true): fine to run, but said loudly; code
|
|
48
|
-
(the role's lines): ${ROLE_CODES.slice(0, 5).join(", ")},
|
|
49
|
-
${ROLE_CODES.slice(5).join(", ")};
|
|
50
|
-
fix (with code): the statements and the edit that make a role that only reads
|
|
51
|
-
|
|
52
49
|
Exit status: 0 nothing failed — a warning (!) and a check that could not tell (?) are said, and leave it 0; 1
|
|
53
50
|
something failed (said, with the way out).
|
|
51
|
+
|
|
52
|
+
The --json answer: { ready, verdict, checks: [{ name, ok, detail, warn, code, fix }], next: [{ command, what }] }; ok
|
|
53
|
+
is true, false, or null when it could not tell; warn (only when true): fine to run, but said loudly; code
|
|
54
|
+
(the role's lines): ${ROLE_CODES.slice(0, 5).join(", ")},
|
|
55
|
+
${ROLE_CODES.slice(5).join(", ")};
|
|
56
|
+
fix (with code): the statements and the edit that make a role that only reads
|
|
54
57
|
`;
|
|
55
58
|
export const DOCTOR = {
|
|
56
59
|
name: "doctor",
|
|
@@ -255,7 +258,7 @@ export function nextSteps(checks, run, project) {
|
|
|
255
258
|
if (project === null)
|
|
256
259
|
return [
|
|
257
260
|
["mason init", "in your app's folder: the database from MASON_DATABASE_URL or DATABASE_URL in .env.local, .env or the shell"],
|
|
258
|
-
["mason init
|
|
261
|
+
["mason init - < db-url.txt", "or its URL on stdin"],
|
|
259
262
|
];
|
|
260
263
|
if (run === null)
|
|
261
264
|
return [["mason start", "Mason in the background, for this folder"]];
|
package/dist/src/cli/guides.js
CHANGED
|
@@ -110,18 +110,29 @@ ${keys}
|
|
|
110
110
|
async function agentGuide() {
|
|
111
111
|
// imported here, not at the top: the registry imports this file for the HELP usage
|
|
112
112
|
const { STEPS } = await import("./registry.js");
|
|
113
|
-
const { FAILURE_CODES } = await import("./command.js");
|
|
113
|
+
const { EXIT_CODES, FAILURE_CODES, TIMEOUT_COMMANDS } = await import("./command.js");
|
|
114
114
|
const { GENERATED_DIR } = await import("./generated-files.js");
|
|
115
115
|
const w = Math.max(...STEPS.flatMap(([, cs]) => cs.map((c) => c.name.length))) + "mason --json".length;
|
|
116
116
|
const commands = STEPS.map(([step, cs]) => ` ${step}\n${cs.map((c) => ` ${`mason ${c.name}${Object.hasOwn(c.options, "json") ? " --json" : ""}`.padEnd(w)} ${c.summary}`).join("\n")}`).join("\n");
|
|
117
|
-
const codes = Object.entries(FAILURE_CODES).map(([code, what]) => ` ${code.padEnd(
|
|
117
|
+
const codes = Object.entries(FAILURE_CODES).map(([code, what]) => ` ${code.padEnd(13)} ${what}`).join("\n");
|
|
118
118
|
return `DRIVING MASON FROM A CODING AGENT — the same commands a person runs, answered as data
|
|
119
119
|
|
|
120
|
-
Every command prints for a person, or with --json one JSON object for a program.
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
120
|
+
Every command prints for a person, or with --json one JSON object for a program. mason mcp's stdout is the MCP
|
|
121
|
+
protocol's, so it takes no --json and says a failure to start on stderr; mason logs --json is the log's entries
|
|
122
|
+
({ at, text }), or with --requests the request log, --events the sync's events, --running the statements running now.
|
|
123
|
+
mason version --json is { version, next }. Nothing a command prints refers to a file this package does not ship.
|
|
124
|
+
|
|
125
|
+
EVERY COMMAND'S SPEC, AS DATA: mason <command> --help --json (or mason help <command> --json) is { command: <spec>,
|
|
126
|
+
next }; mason help --json is { version, commands: [<spec>…], guides: [{ name, summary }], failureCodes: [{ code,
|
|
127
|
+
meaning }], next }. A <spec> is read from the same description --help prints, so the two cannot drift:
|
|
128
|
+
name, summary, usage usage is the text --help prints
|
|
129
|
+
forms [{ synopsis, positionals }]: each form of the synopsis, and its words that are no option
|
|
130
|
+
options [{ name, short, type, multiple, default, value }]: every option, --help too; type
|
|
131
|
+
"string" or "boolean"; short, default and value (a string option's placeholder) null if none
|
|
132
|
+
json the top-level keys of its --json answer, next last; null when it takes no --json
|
|
133
|
+
needs "project", "run" or null
|
|
134
|
+
stdout "protocol" for mason mcp, else null
|
|
135
|
+
exitCodes [{ code, meaning }]: the exit codes below it can exit with
|
|
125
136
|
|
|
126
137
|
AN AGENT WITH MCP (Claude Code, Claude Desktop, any MCP client) adds Mason as an MCP server first, then asks with
|
|
127
138
|
its tools rather than through a shell: tables (each synced table's rows and how current it is), describe, query (rows with
|
|
@@ -141,10 +152,8 @@ ${commands}
|
|
|
141
152
|
|
|
142
153
|
A FAILURE is { "error": { "code", "message", "why", "fix" }, "next": […] } on stdout ("why" and "fix" where there is
|
|
143
154
|
more to say than the message), and the exit code says it — the same code exits the same wherever it is found:
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
2 the command line is wrong (without --json the usage follows)
|
|
147
|
-
124 ran out of time: mason wait, mason deploy, mason replica create (nothing stopped)
|
|
155
|
+
${Object.entries(EXIT_CODES).map(([code, meaning]) => ` ${code} ${code === "124" ? `${meaning}: ${TIMEOUT_COMMANDS}` : meaning}`).join("\n")}
|
|
156
|
+
A wrong command line's next steps end with mason <command> --help, and its fix says what to change.
|
|
148
157
|
The codes:
|
|
149
158
|
${codes}
|
|
150
159
|
|
package/dist/src/cli/hosted.js
CHANGED
|
@@ -689,7 +689,7 @@ export function failureShape(error, secrets, as) {
|
|
|
689
689
|
* the mask would change is no page to send a person to: either refuses it. An honest page is refused by the mask with
|
|
690
690
|
* a probability under 1 in a million (it must hold 3 runs of 4 of a 43-character body by chance).
|
|
691
691
|
*/
|
|
692
|
-
function pageUrl(service, v, secrets) {
|
|
692
|
+
export function pageUrl(service, v, secrets) {
|
|
693
693
|
if (!str(v, 2048))
|
|
694
694
|
return null;
|
|
695
695
|
let u;
|