@mason-data/cli 0.8.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.
- package/README.md +13 -4
- package/dist/src/api/postgres/catalog-worker.js +49 -2
- package/dist/src/api/postgres/catalog.js +166 -32
- package/dist/src/api/postgres/listener.js +11 -3
- package/dist/src/api/postgres/proxy.js +283 -0
- package/dist/src/api/postgres/schema.js +6 -0
- package/dist/src/api/postgres/server.js +505 -99
- package/dist/src/api/postgres/translate.js +47 -2
- package/dist/src/cli/background.js +18 -19
- package/dist/src/cli/bridge-stdin.js +101 -0
- package/dist/src/cli/command.js +169 -11
- package/dist/src/cli/commands.js +207 -51
- package/dist/src/cli/credentials.js +182 -0
- package/dist/src/cli/demo.js +2 -2
- package/dist/src/cli/deploy.js +574 -0
- package/dist/src/cli/dev.js +148 -27
- package/dist/src/cli/doctor.js +116 -33
- package/dist/src/cli/generated-files.js +143 -0
- package/dist/src/cli/guides.js +97 -6
- package/dist/src/cli/hosted.js +850 -0
- package/dist/src/cli/init.js +47 -11
- package/dist/src/cli/login.js +289 -0
- package/dist/src/cli/machine.js +1 -1
- package/dist/src/cli/main.js +8 -2
- package/dist/src/cli/mason.cjs +17 -0
- package/dist/src/cli/mcp.js +2 -2
- package/dist/src/cli/output-gate.js +176 -0
- package/dist/src/cli/page-share.js +96 -0
- package/dist/src/cli/pg-tls.js +1 -1
- package/dist/src/cli/pin.js +6 -2
- package/dist/src/cli/pool-size.cjs +85 -0
- package/dist/src/cli/ports.js +2 -0
- package/dist/src/cli/preflight.js +130 -39
- package/dist/src/cli/project.js +99 -0
- package/dist/src/cli/readiness.js +42 -6
- package/dist/src/cli/registry.js +12 -4
- package/dist/src/cli/replica-hosted.js +665 -0
- package/dist/src/cli/replica.js +76 -0
- package/dist/src/cli/roles.js +2 -1
- package/dist/src/cli/run-server.js +154 -7
- package/dist/src/cli/security.js +57 -10
- package/dist/src/cli/serve.js +63 -7
- package/dist/src/cli/share-run.js +51 -8
- package/dist/src/cli/share.js +3 -3
- package/dist/src/cli/source-edit.js +10 -4
- package/dist/src/cli/source-role.js +344 -0
- package/dist/src/cli/sources.js +30 -20
- package/dist/src/cli/style.js +2 -1
- package/dist/src/cli/supervise.js +15 -23
- package/dist/src/cli/token-hosted.js +337 -0
- package/dist/src/cli/ui-ssh.js +460 -0
- package/dist/src/cli/ui.js +115 -11
- package/dist/src/cli/user-hosted.js +299 -0
- package/dist/src/cli/user.js +126 -74
- package/dist/src/demo/seed.js +26 -0
- package/dist/src/duck/analyze.js +36 -20
- package/dist/src/duck/collation.js +266 -0
- package/dist/src/duck/constant-key.js +94 -0
- package/dist/src/duck/distinct-key.js +283 -26
- package/dist/src/duck/events.js +39 -8
- package/dist/src/duck/executor-worker.js +2 -2
- package/dist/src/duck/executor.js +6 -5
- package/dist/src/duck/explain.js +4 -0
- package/dist/src/duck/functions.js +448 -49
- package/dist/src/duck/gaps.js +34 -16
- package/dist/src/duck/generated-plan.js +1070 -0
- package/dist/src/duck/generated.js +906 -0
- package/dist/src/duck/grants.js +1 -1
- package/dist/src/duck/held-types.js +64 -0
- package/dist/src/duck/history.js +4 -0
- package/dist/src/duck/jsonb-order.js +9 -12
- package/dist/src/duck/kept-route.js +6 -5
- package/dist/src/duck/kept.js +9 -7
- package/dist/src/duck/late.js +1469 -40
- package/dist/src/duck/libc-bound.js +341 -0
- package/dist/src/duck/libc-order.js +892 -126
- package/dist/src/duck/pg-eval-worker.js +7 -1
- package/dist/src/duck/pg-eval.js +6 -4
- package/dist/src/duck/pgtree.js +774 -23
- package/dist/src/duck/plan.js +275 -30
- package/dist/src/duck/publication.js +14 -0
- package/dist/src/duck/requests.js +9 -5
- package/dist/src/duck/resolved.js +117 -2
- package/dist/src/duck/rewrite.js +4567 -583
- package/dist/src/duck/row-filter.js +12 -6
- package/dist/src/duck/statement.js +19 -0
- package/dist/src/duck/store.js +294 -68
- package/dist/src/duck/stream.js +9 -5
- package/dist/src/duck/sync.js +602 -126
- package/dist/src/duck/token.js +4 -1
- package/dist/src/duck/types.js +63 -7
- package/dist/src/duck/usage.js +6 -4
- package/dist/src/duck/users.js +5 -0
- package/dist/src/duck/verified.js +159 -2
- package/dist/src/duck/views.js +4 -3
- package/dist/src/lib/database-url.js +55 -10
- package/dist/src/lib/instant.js +50 -0
- package/dist/src/lib/log-buffer.js +13 -4
- package/dist/src/lib/log-read.js +196 -0
- package/dist/src/lib/pg-numeric.js +387 -49
- package/dist/src/lib/secret-mask.js +142 -0
- package/dist/src/lib/table-sync.js +24 -9
- package/dist/src/web/app.js +9 -4
- package/dist/src/web/pages/status.js +178 -13
- package/dist/src/web/pages/syncs.js +1 -1
- package/dist/src/web/pages/tables.js +8 -3
- package/dist/src/web/pages/users.js +4 -3
- package/dist/src/web/shared.js +19 -3
- package/package.json +8 -7
|
@@ -10,49 +10,103 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import * as os from "node:os";
|
|
12
12
|
import { PgConnection, PgError } from "../cdc/pg-wire.js";
|
|
13
|
-
import { parseDatabaseUrl } from "../lib/database-url.js";
|
|
13
|
+
import { parseDatabaseUrl, queryProblem } from "../lib/database-url.js";
|
|
14
14
|
/** A host on this machine: polling it costs nothing that leaves the machine. */
|
|
15
15
|
export function isLocalHost(host) {
|
|
16
16
|
const h = host.toLowerCase();
|
|
17
17
|
return h === "localhost" || h === "::1" || h === "0.0.0.0" || /^127\./.test(h) || h === "host.docker.internal" || h.endsWith(".localhost");
|
|
18
18
|
}
|
|
19
|
-
/**
|
|
20
|
-
|
|
19
|
+
/**
|
|
20
|
+
* THE ONE WAY A VALUE OF THE SOURCE'S URL SETTING IS SHOWN (issue 541), wherever Mason prints, logs or answers it: the URL
|
|
21
|
+
* with its password replaced by `****`, or null — never shown. It fails closed, so a secret masked by recognising its
|
|
22
|
+
* shape cannot leak in another shape: shown only when the value parses WHOLE as a postgres:// URL with a host, nothing
|
|
23
|
+
* dropped or rewritten by the parse (no space or control character inside it, which a parse strips or encodes; one at
|
|
24
|
+
* either end, a secret file's last newline, is trimmed as the parse trims it), no `#` at all and no `@` after the host
|
|
25
|
+
* (a password holding `/`, `?` or `#` unencoded splits the authority there, and its tail would be shown as a path, a
|
|
26
|
+
* parameter or a fragment), and a query the URL is read by whole (src/lib/database-url.ts queryProblem: only the
|
|
27
|
+
* parameters read, each once, each value one the reading accepts — libpq takes any connection keyword there,
|
|
28
|
+
* `?password=…` included, and a value can hold one, `sslmode=prefer%26password=…`), so no part shown is one not read;
|
|
29
|
+
* the query shown is the one read, written out again. A keyword/value string, a bare password, any other URL: null.
|
|
30
|
+
*/
|
|
31
|
+
export function shownUrl(value) {
|
|
32
|
+
const v = value.trim();
|
|
33
|
+
if (/[\s\x00-\x1f\x7f#]/.test(v))
|
|
34
|
+
return null;
|
|
35
|
+
let u;
|
|
21
36
|
try {
|
|
22
|
-
|
|
23
|
-
if (u.password)
|
|
24
|
-
u.password = "****";
|
|
25
|
-
return u.toString();
|
|
37
|
+
u = new URL(v);
|
|
26
38
|
}
|
|
27
39
|
catch {
|
|
28
|
-
|
|
29
|
-
// was not encoded) — a host never holds an @
|
|
30
|
-
return url.replace(/:\/\/([^:@/]*):.*@/, "://$1:****@");
|
|
40
|
+
return null;
|
|
31
41
|
}
|
|
42
|
+
if (u.protocol !== "postgres:" && u.protocol !== "postgresql:")
|
|
43
|
+
return null;
|
|
44
|
+
if (!u.hostname || /@/.test(u.pathname + u.search))
|
|
45
|
+
return null;
|
|
46
|
+
if (queryProblem(u.searchParams) !== null)
|
|
47
|
+
return null;
|
|
48
|
+
u.search = u.searchParams.toString();
|
|
49
|
+
if (u.password)
|
|
50
|
+
u.password = "****";
|
|
51
|
+
return u.toString();
|
|
32
52
|
}
|
|
33
|
-
/**
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
53
|
+
/** A value of the source's URL setting in a sentence: `shownUrl`'s, or words that say it is not shown, never the value. */
|
|
54
|
+
export function maskedUrl(value) {
|
|
55
|
+
return shownUrl(value) ?? "a value that is not a postgres:// URL (not shown)";
|
|
56
|
+
}
|
|
57
|
+
const URL_FORM = "postgres://user:password@host:5432/your_app";
|
|
58
|
+
/** The schemes a wrong URL is named by: Mason's own list, so a scheme said is never a part of the value Mason did not know. */
|
|
59
|
+
const KNOWN_SCHEMES = new Set(["mysql", "mariadb", "mssql", "sqlserver", "oracle", "mongodb", "mongodb+srv", "redis", "rediss", "sqlite", "clickhouse",
|
|
60
|
+
"http", "https", "file", "pg", "pgsql", "psql", "postgis", "postgre", "postgress", "postgresq"]);
|
|
61
|
+
const ENCODE = "a password holding @ : / # ? % or a space is URL-encoded (encodeURIComponent; @ is %40, / is %2F, # is %23)";
|
|
62
|
+
/** What is wrong with the query of a postgres:// URL that reads whole but for it (queryProblem's words), else null. */
|
|
63
|
+
function queryOf(t) {
|
|
64
|
+
if (!/^postgres(ql)?:\/\//i.test(t) || /[\s\x00-\x1f\x7f#]/.test(t))
|
|
65
|
+
return null;
|
|
38
66
|
let u;
|
|
39
67
|
try {
|
|
40
|
-
u = new URL(
|
|
68
|
+
u = new URL(t);
|
|
41
69
|
}
|
|
42
70
|
catch {
|
|
43
|
-
|
|
44
|
-
return { problem: `${JSON.stringify(maskedUrl(url))} is not a URL. The form is postgres://user:password@localhost:5432/your_app — a password ` +
|
|
45
|
-
`containing @ : / # ? or % must be URL-encoded (encodeURIComponent; @ is %40).` };
|
|
71
|
+
return null;
|
|
46
72
|
}
|
|
47
|
-
|
|
48
|
-
|
|
73
|
+
// an @ after the host is a password split there (said as such), not a parameter
|
|
74
|
+
return u.hostname === "" || /@/.test(u.pathname + u.search) ? null : queryProblem(u.searchParams);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Why a value `shownUrl` will not show is not a URL Mason reads, told from its SHAPE alone (`name` says where it is):
|
|
78
|
+
* what it looks like and how to write it, never a part of it, so a coding agent fixes it from the message alone.
|
|
79
|
+
*/
|
|
80
|
+
function notShownProblem(value, name) {
|
|
81
|
+
const t = value.trim();
|
|
82
|
+
const why = t === "" ? "it is empty"
|
|
83
|
+
: /^[A-Za-z_][A-Za-z0-9_]*\s*=/.test(t) ? "it looks like a keyword/value connection string (host=… user=… password=…)"
|
|
84
|
+
: /^jdbc:/i.test(t) ? "it looks like a JDBC URL: drop jdbc:, and give the user and password before the host"
|
|
85
|
+
: !/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(t) ? "it is not a URL"
|
|
86
|
+
: queryOf(t) !== null ? queryOf(t)
|
|
87
|
+
: !/^postgres(ql)?:\/\//i.test(t) ? `its scheme is ${KNOWN_SCHEMES.has(t.slice(0, t.indexOf(":")).toLowerCase()) ? `${t.slice(0, t.indexOf(":")).toLowerCase()}, not` : "not"} postgres:// or postgresql://`
|
|
88
|
+
: "it does not read as one URL (a character that is not URL-encoded splits it: an @ : / ? # % or a space in the password, an @ or # in the database " +
|
|
89
|
+
"or a parameter; or the port is not a number up to 65535)";
|
|
90
|
+
return `${name} is not a postgres:// URL: ${why}. Write it as ${URL_FORM}, where ${ENCODE}`;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Parse the URL `mason start` was given (`name` says where it is, `DATABASE_URL in .env`: a problem names it, never the
|
|
94
|
+
* value). The user defaults to the OS user (libpq's default, and Homebrew's and Postgres.app's superuser); the database
|
|
95
|
+
* never defaults — guessing it would read the wrong one. Only a value `shownUrl` shows is read, so every URL a run
|
|
96
|
+
* connects with is one its messages can show.
|
|
97
|
+
*/
|
|
98
|
+
export function parseSource(url, name = "the URL") {
|
|
99
|
+
if (shownUrl(url) === null)
|
|
100
|
+
return { problem: notShownProblem(url, name) };
|
|
101
|
+
const u = new URL(url.trim());
|
|
49
102
|
if (!u.username)
|
|
50
103
|
u.username = encodeURIComponent(os.userInfo().username);
|
|
51
|
-
if (!u.pathname.replace(/^\//, "")) {
|
|
52
|
-
return { problem: `the URL names no database. Add it after the port: postgres://${decodeURIComponent(u.username)}@${u.host || "localhost:5432"}/your_app ` +
|
|
53
|
-
`(the one your app uses — its DATABASE_URL usually says).` };
|
|
54
|
-
}
|
|
55
104
|
try {
|
|
105
|
+
// inside the try: a user name with a malformed %-escape is refused (decodeURIComponent throws), never a crash
|
|
106
|
+
if (!u.pathname.replace(/^\//, "")) {
|
|
107
|
+
return { problem: `${name} names no database. Add it after the port: postgres://${decodeURIComponent(u.username)}@${u.host || "localhost:5432"}/your_app ` +
|
|
108
|
+
`(the one your app uses — its DATABASE_URL usually says).` };
|
|
109
|
+
}
|
|
56
110
|
const parts = parseDatabaseUrl(u.toString());
|
|
57
111
|
const remote = !isLocalHost(parts.host);
|
|
58
112
|
// a database elsewhere is read over the network: never in the clear unless the URL says so outright
|
|
@@ -61,7 +115,9 @@ export function parseSource(url) {
|
|
|
61
115
|
return { source: { ...parts, url: u.toString(), remote } };
|
|
62
116
|
}
|
|
63
117
|
catch (e) {
|
|
64
|
-
|
|
118
|
+
if (e instanceof URIError)
|
|
119
|
+
return { problem: `${name} holds a % that starts no %-escape: a % in the user or the password is written %25. Write it as ${URL_FORM}, where ${ENCODE}` };
|
|
120
|
+
return { problem: e.message.replace(/^DATABASE_URL/, name) };
|
|
65
121
|
}
|
|
66
122
|
}
|
|
67
123
|
/** What a failed connection means, and what to change. */
|
|
@@ -80,8 +136,9 @@ export function explainConnectError(e, s, o = {}) {
|
|
|
80
136
|
],
|
|
81
137
|
};
|
|
82
138
|
}
|
|
139
|
+
// the database is not repeated (issue 541: a refusal echoes no part of the value); the hint lists the ones there are
|
|
83
140
|
if (e.code === "3D000")
|
|
84
|
-
return { message: `${where} has no database
|
|
141
|
+
return { message: `${where} has no database by the name the URL gives (the part after the port)`, hints: [] };
|
|
85
142
|
return { message: `${where}: ${msg}`, hints: [] };
|
|
86
143
|
}
|
|
87
144
|
if (/ECONNREFUSED/.test(msg)) {
|
|
@@ -138,6 +195,18 @@ export async function connectSource(s, o = {}) {
|
|
|
138
195
|
return { failure };
|
|
139
196
|
}
|
|
140
197
|
}
|
|
198
|
+
/** pg_collation_actual_version (a libc collation's glibc now) exists from this server_version_num */
|
|
199
|
+
const COLLATION_ACTUAL_NUM = 130000;
|
|
200
|
+
/** The database's collations of its encoding, each as src/duck/collation.ts reads it. The locale's column differs by
|
|
201
|
+
* version (colllocale from 17, colliculocale 15–16, none before: collcollate holds ICU's), so the row is read whole. */
|
|
202
|
+
export async function readCollations(pg, versionNum) {
|
|
203
|
+
const version = versionNum >= COLLATION_ACTUAL_NUM ? "CASE WHEN c.collprovider = 'c' THEN pg_collation_actual_version(c.oid) END" : "NULL::text";
|
|
204
|
+
const rows = JSON.parse((await pg.query(`SELECT coalesce(json_agg(to_jsonb(c) || jsonb_build_object('schema', n.nspname, 'actual', ${version}) ORDER BY c.oid), '[]')::text ` +
|
|
205
|
+
"FROM pg_collation c JOIN pg_namespace n ON n.oid = c.collnamespace WHERE c.collencoding IN (-1, pg_char_to_encoding(getdatabaseencoding()))")).rows[0]?.[0] ?? "[]");
|
|
206
|
+
return rows.map((r) => ({ schema: String(r.schema), name: String(r.collname), provider: String(r.collprovider ?? "c"), collate: (r.collcollate ?? null), ctype: (r.collctype ?? null),
|
|
207
|
+
locale: (r.colllocale ?? r.colliculocale ?? (r.collprovider === "i" ? r.collcollate : null) ?? null), deterministic: r.collisdeterministic !== false,
|
|
208
|
+
version: (r.actual ?? r.collversion ?? null) }));
|
|
209
|
+
}
|
|
141
210
|
/** The source's plain views (never a materialized one: that is a table of its own), each definition printed under a
|
|
142
211
|
* search_path of pg_catalog alone, which qualifies every table, type and function a view reads (`public.orders`):
|
|
143
212
|
* `SET LOCAL` holds for the rest of this one Query and ends with it, whatever happens */
|
|
@@ -177,13 +246,21 @@ export const TABLES_SQL = `SELECT json_agg(t ORDER BY t.schema, t.table)::text F
|
|
|
177
246
|
-- nothing: never declared to the catalog, never a key: Mason accepts
|
|
178
247
|
WHERE i.indrelid = c.oid AND i.indisunique AND i.indisvalid AND NOT i.indisprimary AND i.indpred IS NULL AND i.indexprs IS NULL
|
|
179
248
|
AND i.indnkeyatts = i.indnatts) AS unique_keys,
|
|
180
|
-
(SELECT coalesce(json_agg(json_build_object('name', a.attname, 'type', format_type(
|
|
181
|
-
'not_null', a.attnotnull, 'comment', col_description(c.oid, a.attnum), 'type_oid',
|
|
182
|
-
'type_array_oid', (SELECT typarray FROM pg_type WHERE oid =
|
|
249
|
+
(SELECT coalesce(json_agg(json_build_object('name', a.attname, 'type', format_type(b.oid, b.mod), 'domain', b.oid <> a.atttypid,
|
|
250
|
+
'not_null', a.attnotnull, 'comment', col_description(c.oid, a.attnum), 'type_oid', b.oid,
|
|
251
|
+
'type_array_oid', (SELECT typarray FROM pg_type WHERE oid = b.oid),
|
|
252
|
+
'type_kind', (SELECT coalesce(e.typtype, t.typtype) FROM pg_type t LEFT JOIN pg_type e ON e.typarray = t.oid WHERE t.oid = b.oid),
|
|
183
253
|
'default', pg_get_expr(d.adbin, d.adrelid), 'identity', a.attidentity, 'generated', a.attgenerated,
|
|
254
|
+
-- the column's own collation, where it is not its (base) type's: the catalog declares it (src/duck/collation.ts)
|
|
255
|
+
'collation', (SELECT json_build_object('schema', cn.nspname, 'name', co.collname) FROM pg_collation co JOIN pg_namespace cn ON cn.oid = co.collnamespace
|
|
256
|
+
WHERE co.oid = a.attcollation AND a.attcollation <> 0 AND a.attcollation <> (SELECT typcollation FROM pg_type WHERE oid = b.oid)),
|
|
184
257
|
-- the sequence a serial or identity column owns: asked only of a column with a default or an identity
|
|
185
258
|
'sequence', CASE WHEN d.adbin IS NOT NULL OR a.attidentity <> '' THEN pg_get_serial_sequence(format('%I.%I', n.nspname, c.relname), a.attname) END) ORDER BY a.attnum), '[]'::json)
|
|
186
259
|
FROM pg_attribute a LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
|
|
260
|
+
-- a domain's column is read and declared as its base type (a domain of a domain too)
|
|
261
|
+
CROSS JOIN LATERAL (WITH RECURSIVE base(oid, mod) AS (SELECT a.atttypid, a.atttypmod UNION ALL
|
|
262
|
+
SELECT t.typbasetype, t.typtypmod FROM base JOIN pg_type t ON t.oid = base.oid WHERE t.typtype = 'd')
|
|
263
|
+
SELECT base.oid, base.mod FROM base JOIN pg_type t ON t.oid = base.oid WHERE t.typtype <> 'd') b
|
|
187
264
|
WHERE a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped) AS columns,
|
|
188
265
|
(SELECT coalesce(json_agg(json_build_object('column', a.attname, 'target', tn.nspname || '.' || tc.relname, 'target_column', ta.attname)), '[]'::json)
|
|
189
266
|
FROM pg_constraint f
|
|
@@ -199,8 +276,18 @@ export const TABLES_SQL = `SELECT json_agg(t ORDER BY t.schema, t.table)::text F
|
|
|
199
276
|
/** What a never-analysed table counted to, by oid: `mason start` reads the catalog every 10 s, and a table is counted
|
|
200
277
|
* once per run, never per read (review of #404: a remote one over the network each time). */
|
|
201
278
|
const COUNTED = new Map();
|
|
279
|
+
/** the server_version_num from which `pg_database_collation_actual_version` exists */
|
|
280
|
+
const COLLATION_ACTUAL_VERSION_NUM = 150000;
|
|
281
|
+
const COLLATION_ACTUAL_VERSION_SQL = "SELECT pg_database_collation_actual_version(oid) FROM pg_database WHERE datname = current_database()";
|
|
282
|
+
/** The collation library's version now, else what the database recorded (`recorded`, datcollversion; before Postgres 15, none). */
|
|
283
|
+
export async function collationVersion(pg, versionNum, recorded) {
|
|
284
|
+
if (versionNum < COLLATION_ACTUAL_VERSION_NUM)
|
|
285
|
+
return recorded;
|
|
286
|
+
return (await pg.query(COLLATION_ACTUAL_VERSION_SQL)).rows[0]?.[0] ?? recorded;
|
|
287
|
+
}
|
|
202
288
|
/** The database's facts: its version, whether it is a standby, and every table this user can read. */
|
|
203
|
-
|
|
289
|
+
/** `collations: false` (the run's 10-second refresh) leaves the database's collations unread: they were read at the start */
|
|
290
|
+
export async function readSourceFacts(pg, counted = COUNTED, o = {}) {
|
|
204
291
|
const r = (await pg.query("SELECT current_setting('server_version_num'), current_setting('server_version'), pg_is_in_recovery()::text")).rows[0];
|
|
205
292
|
const rows = JSON.parse((await pg.query(TABLES_SQL)).rows[0]?.[0] ?? "null");
|
|
206
293
|
const tables = [];
|
|
@@ -213,24 +300,28 @@ export async function readSourceFacts(pg, counted = COUNTED) {
|
|
|
213
300
|
}
|
|
214
301
|
tables.push({
|
|
215
302
|
source: `${t.schema}.${t.table}`, schema: t.schema, table: t.table, key: t.key, rows: count, bytes: Number(t.bytes), comment: t.comment,
|
|
216
|
-
columns: t.columns.map((c) => ({ name: c.name, type: c.type, notNull: c.not_null, comment: c.comment,
|
|
217
|
-
|
|
303
|
+
columns: t.columns.map((c) => ({ name: c.name, type: c.type, typeKind: c.type_kind, ...(c.domain ? { domain: true } : {}), notNull: c.not_null, comment: c.comment,
|
|
304
|
+
// an oid is a STRING in Postgres's JSON (only the int, float and numeric types are numbers): one spelling, here
|
|
305
|
+
...(Number(c.type_oid) >= 16384 ? { typeOid: Number(c.type_oid), ...(Number(c.type_array_oid) > 0 ? { typeArrayOid: Number(c.type_array_oid) } : {}) } : {}),
|
|
218
306
|
// a generated column's pg_attrdef row is its expression, never a default
|
|
219
307
|
...(c.default !== null && c.generated === "" ? { default: c.default } : {}), ...(c.identity === "a" || c.identity === "d" ? { identity: c.identity } : {}),
|
|
220
|
-
...(c.generated === "s" ? { generated: true } : {}), ...(c.sequence !== null ? { sequence: c.sequence } : {})
|
|
308
|
+
...(c.generated === "s" ? { generated: true } : {}), ...(c.sequence !== null ? { sequence: c.sequence } : {}),
|
|
309
|
+
...(c.collation !== null && c.collation !== undefined ? { collation: c.collation } : {}) })),
|
|
221
310
|
foreignKeys: t.foreign_keys.map((f) => ({ column: f.column, target: f.target, targetColumn: f.target_column })),
|
|
222
311
|
uniqueKeys: t.unique_keys.map((u) => ({ name: u.name, columns: u.columns, constraint: u.constraint, nullsNotDistinct: u.nulls_not_distinct })),
|
|
223
|
-
...(t.row_type_oid > 0 ? { rowTypeOid: t.row_type_oid } : {}),
|
|
312
|
+
...(Number(t.row_type_oid) > 0 ? { rowTypeOid: Number(t.row_type_oid) } : {}),
|
|
224
313
|
...(t.replica_identity === "d" || t.replica_identity === "n" || t.replica_identity === "f" || t.replica_identity === "i" ? { replicaIdentity: t.replica_identity } : {}),
|
|
225
314
|
});
|
|
226
315
|
}
|
|
227
316
|
// the columns differ by version (datlocprovider from 15, daticulocale 15–16, datlocale from 17): read the row whole
|
|
228
317
|
const db = JSON.parse((await pg.query("SELECT to_jsonb(d)::text FROM pg_database d WHERE datname = current_database()")).rows[0]?.[0] ?? "{}");
|
|
229
|
-
//
|
|
230
|
-
|
|
318
|
+
// the library's version of it NOW (Postgres 15+; glibc's for libc): datcollversion is what the database was created or
|
|
319
|
+
// last refreshed under, so it stays where it was through an OS upgrade, which is what must read the order again
|
|
320
|
+
const collation = { provider: db.datlocprovider ?? "c", collate: db.datcollate ?? "C", ctype: db.datctype ?? db.datcollate ?? "C", locale: db.datlocale ?? db.daticulocale ?? null, version: await collationVersion(pg, Number(r[0]), db.datcollversion ?? null) };
|
|
231
321
|
const views = JSON.parse((await pg.query(VIEWS_SQL)).rows[0]?.[0] ?? "[]")
|
|
232
322
|
.map((v) => ({ schema: v.schema, name: v.name, definition: v.definition, comment: v.comment }));
|
|
233
|
-
|
|
323
|
+
const collations = o.collations === false ? undefined : await readCollations(pg, Number(r[0]));
|
|
324
|
+
return { version: Number(r[0]), versionText: r[1] ?? "", standby: r[2] === "true", tables, systemIdentifier: await systemIdentifier(pg), collation, ...(collations !== undefined ? { collations } : {}), views };
|
|
234
325
|
}
|
|
235
326
|
/** The server's system identifier, or null when this user may not read it (only that refusal: any other error is real). */
|
|
236
327
|
export async function systemIdentifier(pg) {
|
|
@@ -310,7 +401,7 @@ export function versionProblem(f) {
|
|
|
310
401
|
* after the check's facts.
|
|
311
402
|
*/
|
|
312
403
|
export async function checkSource(url, o) {
|
|
313
|
-
const parsed = parseSource(url);
|
|
404
|
+
const parsed = parseSource(url, o.name);
|
|
314
405
|
if ("problem" in parsed) {
|
|
315
406
|
o.say(`${o.cmd}: ${parsed.problem}`);
|
|
316
407
|
return { exit: 2 };
|
package/dist/src/cli/project.js
CHANGED
|
@@ -76,6 +76,53 @@ export function readProject(root) {
|
|
|
76
76
|
}
|
|
77
77
|
return { root, dir, folder: path.resolve(root, config.folder), config };
|
|
78
78
|
}
|
|
79
|
+
/** Why a path could not be resolved, in a person's words (the system's code beside them). */
|
|
80
|
+
export function unresolvedWhy(e) {
|
|
81
|
+
const code = e.code;
|
|
82
|
+
const words = {
|
|
83
|
+
ELOOP: "a symbolic link on the way leads back to itself (a loop)",
|
|
84
|
+
ENOTDIR: "a part of the path is a file, not a folder",
|
|
85
|
+
EACCES: "a folder on the way may not be read by this user",
|
|
86
|
+
EPERM: "a folder on the way may not be read by this user",
|
|
87
|
+
ENAMETOOLONG: "the path is longer than this system takes",
|
|
88
|
+
};
|
|
89
|
+
return code !== undefined && words[code] !== undefined ? `${words[code]} (${code})` : `the system could not resolve it (${code ?? e.message})`;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* WHERE A PROJECT'S FILES ARE, against its root (the folder `.mason/` is in): strictly `inside` it (a folder of their own,
|
|
93
|
+
* `mason/`), the `root` itself (`mason init --folder .`), or `outside` it (`..`, `../app`, an absolute path elsewhere, a
|
|
94
|
+
* link that leaves it). Read from the real paths — a folder not made yet by its nearest folder that is — so every
|
|
95
|
+
* spelling of one place is one answer. A path that cannot be resolved (a loop, a file as a folder, a folder this user
|
|
96
|
+
* may not read, a path too long) is a fourth answer, `unknown` with why, never an exception: fail closed, each caller
|
|
97
|
+
* refuses it with the way out. The one authority: `mason init` refuses outside and unknown, `mason deploy` sends only
|
|
98
|
+
* from inside (src/cli/deploy.ts: a root's or an outside folder's YAML is the application's, or anyone's).
|
|
99
|
+
*/
|
|
100
|
+
export function folderPlace(root, folder) {
|
|
101
|
+
const real = (p) => {
|
|
102
|
+
const rest = [];
|
|
103
|
+
for (let d = path.resolve(p);; d = path.dirname(d)) {
|
|
104
|
+
try {
|
|
105
|
+
return path.join(fs.realpathSync(d), ...rest.reverse());
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
// a part not made yet: the nearest one that is decides (a folder mason init will make); anything else is unknown
|
|
109
|
+
if (e.code !== "ENOENT" || path.dirname(d) === d)
|
|
110
|
+
throw e;
|
|
111
|
+
rest.push(path.basename(d));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
let rel;
|
|
116
|
+
try {
|
|
117
|
+
rel = path.relative(real(root), real(folder));
|
|
118
|
+
}
|
|
119
|
+
catch (e) {
|
|
120
|
+
return { place: "unknown", why: unresolvedWhy(e) };
|
|
121
|
+
}
|
|
122
|
+
if (rel === "")
|
|
123
|
+
return { place: "root" };
|
|
124
|
+
return { place: rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel) ? "outside" : "inside" };
|
|
125
|
+
}
|
|
79
126
|
/** Whether a lookup found a project it could read. */
|
|
80
127
|
export const isProject = (p) => p !== null && !("broken" in p);
|
|
81
128
|
/** What a broken project's commands say: the file, what is wrong with it, and the two ways out. */
|
|
@@ -150,12 +197,64 @@ export function databaseMoved(p, answersAs) {
|
|
|
150
197
|
const says = connectDatabase(written, {}, {});
|
|
151
198
|
return says === answersAs ? null : says;
|
|
152
199
|
}
|
|
200
|
+
/** The setting that says the project's files are deployed, and read-only where Mason runs (`projectWriteRefusal`). */
|
|
201
|
+
export const READONLY_SETTING = "MASON_PROJECT_READONLY";
|
|
202
|
+
/** MASON_PROJECT_READONLY as the environment says it: 1 true, 0 or unset false, anything else what is wrong with it. */
|
|
203
|
+
export function projectReadOnlySetting(env) {
|
|
204
|
+
const v = env[READONLY_SETTING];
|
|
205
|
+
if (v === undefined || v === "" || v === "0")
|
|
206
|
+
return false;
|
|
207
|
+
if (v === "1")
|
|
208
|
+
return true;
|
|
209
|
+
return { problem: `${READONLY_SETTING}=${JSON.stringify(v)}: 1 (the project's files are deployed, and read-only here) or 0 (the default)` };
|
|
210
|
+
}
|
|
211
|
+
/** what a refused edit of a deployed project says to run next */
|
|
212
|
+
const READONLY_NEXT = [["mason status", "what Mason syncs here, as the deployed files say"]];
|
|
213
|
+
/**
|
|
214
|
+
* WHETHER THE PROJECT'S FILES MAY BE WRITTEN HERE — THE ONE AUTHORITY (issue 473): null when they may, else the refusal.
|
|
215
|
+
* A deployment that ships its committed project folder (CI, GitOps, a platform that deploys the folder) runs the files
|
|
216
|
+
* as committed: an edit on the server would run what nobody committed, and the next deploy would undo it without a word.
|
|
217
|
+
* MASON_PROJECT_READONLY=1 says so, and EVERY WRITER OF A PROJECT FILE ASKS THIS FIRST and writes nothing on a refusal:
|
|
218
|
+
* the sources file (`mason add` and `mason remove` at both entrances, the page's Add and Remove: src/cli/source-edit.ts
|
|
219
|
+
* `sourceToEdit`; `mason serve`'s first file, `serveSource`), `mason.yml` (src/cli/pin.ts `ensurePin`) and what `mason
|
|
220
|
+
* init` writes (the sources, roles and generated tables' files, `.env`, `.gitignore`). The users (`.mason/users.json`),
|
|
221
|
+
* the sync and every read are not the project's files, and are unchanged.
|
|
222
|
+
*
|
|
223
|
+
* Read from two places, either refusing: this process's environment, and THE RECORD the run makes in the project's
|
|
224
|
+
* config at its start (`projectReadOnly`, src/cli/dev.ts) — so a command beside the run (`docker compose exec mason
|
|
225
|
+
* mason add …`), with the run up or down, is refused whatever its own environment says (`MASON_PROJECT_READONLY=0
|
|
226
|
+
* mason add` does not un-say the run's). `root` is the project's (where `.mason/` is), or null where no record is to be
|
|
227
|
+
* read (`mason serve` before it writes the config: its own environment decides). Fail closed: a value that is not 0 or
|
|
228
|
+
* 1, or a config that cannot be read, cannot tell, and refuses.
|
|
229
|
+
*/
|
|
230
|
+
export function projectWriteRefusal(root, env) {
|
|
231
|
+
const set = projectReadOnlySetting(env);
|
|
232
|
+
if (typeof set !== "boolean") {
|
|
233
|
+
return { message: `${set.problem}, so whether the project's files may be written here cannot be told`, code: "refused",
|
|
234
|
+
why: "Mason writes none of them until it can tell", fix: `set ${READONLY_SETTING}=1 or ${READONLY_SETTING}=0, or unset it`, next: READONLY_NEXT };
|
|
235
|
+
}
|
|
236
|
+
const p = root === null ? null : readProject(root);
|
|
237
|
+
if (p !== null && "broken" in p) {
|
|
238
|
+
return { message: `whether the project's files may be written here cannot be told: ${p.file}: ${p.broken}`, code: "refused",
|
|
239
|
+
why: "the config records whether Mason runs the project read-only, and Mason writes none of its files until it can tell", fix: brokenProblem(p).fix, next: READONLY_NEXT };
|
|
240
|
+
}
|
|
241
|
+
const recorded = p?.config.projectReadOnly === true;
|
|
242
|
+
if (!set && !recorded)
|
|
243
|
+
return null;
|
|
244
|
+
return { message: `the project's files are read-only here (${READONLY_SETTING}=1${set ? "" : " when Mason last started"})`, code: "refused",
|
|
245
|
+
why: "Mason runs them as they were deployed: an edit here would run what nobody committed, and the next deploy would undo it",
|
|
246
|
+
fix: "edit the files and deploy them: mason add or mason remove where the project is committed, then commit and deploy",
|
|
247
|
+
next: READONLY_NEXT };
|
|
248
|
+
}
|
|
153
249
|
/**
|
|
154
250
|
* A SETTING A START REFUSES, for the start that is typed and the run it spawns alike: read by the parent where the person
|
|
155
251
|
* (or --json) reads the answer, and by the run, which `mason start --attach` and `mason serve` reach without the parent.
|
|
156
252
|
* One list, because a check in the run alone reaches a background start as "the run stopped", its way out in a log.
|
|
157
253
|
*/
|
|
158
254
|
export function startSettingRefusal(p, env, o) {
|
|
255
|
+
const readOnly = projectReadOnlySetting(env);
|
|
256
|
+
if (typeof readOnly !== "boolean")
|
|
257
|
+
return { message: readOnly.problem, code: "refused", fix: `set ${READONLY_SETTING}=1 or ${READONLY_SETTING}=0, or unset it` };
|
|
159
258
|
const top = () => sourcesTop(p.root, p.folder);
|
|
160
259
|
const pollMs = pollMsRetired(env, top);
|
|
161
260
|
if (pollMs !== null)
|
|
@@ -55,19 +55,30 @@ export function copyProgress(source, copied, rowsEstimate, p) {
|
|
|
55
55
|
const c = copyingText(copied, rowsEstimate, p);
|
|
56
56
|
return `${source}: ${c.fraction !== null ? `syncing ${Math.floor(c.fraction * 100)}% · ` : /^\d/.test(c.text) ? "syncing " : ""}${c.text}`;
|
|
57
57
|
}
|
|
58
|
-
export function syncStateOf(c) {
|
|
58
|
+
export function syncStateOf(c, now = Date.now()) {
|
|
59
59
|
if (c === undefined || c.phase === "waiting")
|
|
60
60
|
return "waiting";
|
|
61
61
|
if (c.phase === "failed" || c.error !== null)
|
|
62
62
|
return "failed";
|
|
63
63
|
if (c.phase === "copying" || c.rebuilding !== undefined || c.behind !== undefined)
|
|
64
64
|
return "syncing";
|
|
65
|
-
return "synced";
|
|
65
|
+
return (behindOf(c.kept, now).behindBySeconds ?? 0) > 0 ? "behind" : "synced";
|
|
66
|
+
}
|
|
67
|
+
export function behindOf(k, now) {
|
|
68
|
+
if (k === undefined || k.by === "generated")
|
|
69
|
+
return { asOf: null, promisedSeconds: null, behindBySeconds: null, behindWhy: null };
|
|
70
|
+
const asOf = k.by === "recopy" ? k.syncedAt ?? k.copiedAt : k.syncedAt;
|
|
71
|
+
const promisedSeconds = k.promisedSeconds ?? null;
|
|
72
|
+
if (promisedSeconds === null || asOf === null)
|
|
73
|
+
return { asOf, promisedSeconds, behindBySeconds: null, behindWhy: null };
|
|
74
|
+
const over = (now - Date.parse(asOf)) / 1000 - promisedSeconds;
|
|
75
|
+
return over > 0 ? { asOf, promisedSeconds, behindBySeconds: Math.round(over * 10) / 10, behindWhy: k.lateWhy ?? "its next sync is late" }
|
|
76
|
+
: { asOf, promisedSeconds, behindBySeconds: 0, behindWhy: null };
|
|
66
77
|
}
|
|
67
78
|
export function tableSync(c, now) {
|
|
68
79
|
const s = c?.synced;
|
|
69
|
-
return { state: syncStateOf(c), syncedAt: s?.at ?? null, syncSeconds: s?.seconds ?? null, syncedBy: s?.by ?? null,
|
|
70
|
-
ageSeconds: s === undefined ? null : Math.max(0, Math.round((now - Date.parse(s.at)) / 100) / 10) };
|
|
80
|
+
return { state: syncStateOf(c, now), syncedAt: s?.at ?? null, syncSeconds: s?.seconds ?? null, syncedBy: s?.by ?? null,
|
|
81
|
+
ageSeconds: s === undefined ? null : Math.max(0, Math.round((now - Date.parse(s.at)) / 100) / 10), ...behindOf(c?.kept, now) };
|
|
71
82
|
}
|
|
72
83
|
export function syncTotals(added, copies) {
|
|
73
84
|
const copied = copies.filter((c) => c.phase === "copied");
|
|
@@ -95,8 +106,12 @@ export function readinessWord(r) {
|
|
|
95
106
|
export function readiness(s) {
|
|
96
107
|
const failed = [], waiting = [];
|
|
97
108
|
for (const t of s.tables.filter((t) => t.added)) {
|
|
98
|
-
|
|
99
|
-
|
|
109
|
+
// the table's state in one word (`syncStateOf`), never a second reading of its error or phase: a copied table whose
|
|
110
|
+
// poll, catch-up, stream or publication check failed is failed here as it is there (issues 100, 82). Behind is said
|
|
111
|
+
// on its line, never a failure: it is served and being kept. A run from before `sync` was said: its error and phase.
|
|
112
|
+
const state = t.sync?.state ?? (t.error ? "failed" : t.copy?.phase === "copying" ? "syncing" : !t.copy ? "waiting" : "synced");
|
|
113
|
+
if (state === "failed") {
|
|
114
|
+
failed.push(`${t.table}: ${firstLine(t.error ?? "its sync failed (mason status says why)")}`);
|
|
100
115
|
continue;
|
|
101
116
|
}
|
|
102
117
|
if (t.copy?.phase === "copying") {
|
|
@@ -108,3 +123,24 @@ export function readiness(s) {
|
|
|
108
123
|
}
|
|
109
124
|
return { ready: failed.length === 0 && waiting.length === 0, failed, waiting };
|
|
110
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* WHERE A GENERATED TABLE IS, for `mason wait <table>`: ready once it is served and holds every base change Mason had
|
|
128
|
+
* synced when the wait began (`since`, ms), across its chain — nothing behind (`behindSince` null), or only changes
|
|
129
|
+
* committed after it began. A last run that failed, or a parent that blocks it, is a failure, so an agent is never left
|
|
130
|
+
* waiting for a run that cannot come. The marks are the run's clock and `since` this machine's: `mason wait` reads a run
|
|
131
|
+
* on this machine.
|
|
132
|
+
*/
|
|
133
|
+
export function generatedReadiness(g, since) {
|
|
134
|
+
const caught = g.serving && (g.behindSince === null || Date.parse(g.behindSince) > since);
|
|
135
|
+
if (caught)
|
|
136
|
+
return { ready: true, failed: [], waiting: [] };
|
|
137
|
+
const fail = (why) => ({ ready: false, failed: [`${g.table}: ${why}`], waiting: [] });
|
|
138
|
+
if (g.error !== null)
|
|
139
|
+
return fail(`its last run failed: ${firstLine(g.error)}`);
|
|
140
|
+
if (g.state === "refused" && !g.serving)
|
|
141
|
+
return fail(`refused: ${g.refusals[0]?.message ?? "mason status says why"}`);
|
|
142
|
+
if (g.waitingFor !== null && /(failed its last run|is refused)$/.test(g.waitingFor))
|
|
143
|
+
return fail(`not run, because ${g.waitingFor}`);
|
|
144
|
+
return { ready: false, failed: [], waiting: [`${g.table}: ${!g.serving ? g.waitingFor === null ? "being built" : `waiting for ${g.waitingFor}` :
|
|
145
|
+
`${Math.round(g.behindSeconds)} s behind its bases; its next run brings in what was synced before the wait`}`] };
|
|
146
|
+
}
|
package/dist/src/cli/registry.js
CHANGED
|
@@ -12,6 +12,7 @@ import { ADD, LOGS, REMOVE, STATUS, STOP, TABLES, WAIT, add, logs, remove, statu
|
|
|
12
12
|
import { USER, user } from "./user.js";
|
|
13
13
|
import { MCP, mcp } from "./mcp.js";
|
|
14
14
|
import { SHARE, share } from "./share.js";
|
|
15
|
+
import { DEFAULT_UI_PORT } from "./ports.js";
|
|
15
16
|
import { UI, ui } from "./ui.js";
|
|
16
17
|
import { GUIDES, guideText } from "./guides.js";
|
|
17
18
|
import { DOCTOR, NODE_MIN, doctor } from "./doctor.js";
|
|
@@ -21,6 +22,10 @@ import { SYNC, sync } from "./sync.js";
|
|
|
21
22
|
import { USAGE, usage } from "./usage.js";
|
|
22
23
|
import { CANCEL, cancel } from "./cancel.js";
|
|
23
24
|
import { SERVE, serve } from "./serve.js";
|
|
25
|
+
import { LOGIN, LOGOUT, login, logout } from "./login.js";
|
|
26
|
+
import { DEPLOY, deploy } from "./deploy.js";
|
|
27
|
+
import { REPLICA, replica } from "./replica-hosted.js";
|
|
28
|
+
import { TOKEN, token } from "./token-hosted.js";
|
|
24
29
|
import { PROJECT_LINE } from "./dev.js";
|
|
25
30
|
import { version } from "./version.js";
|
|
26
31
|
import { didYouMean } from "../lib/did-you-mean.js";
|
|
@@ -68,6 +73,9 @@ export const DEV = {
|
|
|
68
73
|
MASON_PG_TLS_KEY (PEM files), or --self-signed-tls, which makes a certificate for the project in .mason/ and serves
|
|
69
74
|
it again at every later start with the flag. Every client then connects with sslmode=require. A team whose own proxy
|
|
70
75
|
or load balancer terminates TLS keeps it on 127.0.0.1, or sets MASON_PG_PLAIN=1 for a private network.
|
|
76
|
+
Behind a TCP proxy that sends the PROXY protocol v2, MASON_PG_PROXY_FROM=<cidr>[,<cidr>…] names the proxy's own
|
|
77
|
+
addresses: a connection from one must begin with the header, whose client the request log names, and a header from
|
|
78
|
+
any other address is refused.
|
|
71
79
|
|
|
72
80
|
${PROJECT_LINE}
|
|
73
81
|
|
|
@@ -78,7 +86,7 @@ export const DEV = {
|
|
|
78
86
|
--attach stay in the foreground (Ctrl-C stops Mason)
|
|
79
87
|
--self-signed-tls the Postgres API requires TLS, from a certificate made for this project in .mason/ (kept and
|
|
80
88
|
served again by every later start with the flag); not with MASON_PG_TLS_CERT / _KEY
|
|
81
|
-
--port <n> the port the mason commands reach it on (default
|
|
89
|
+
--port <n> the port the mason commands reach it on (default ${DEFAULT_UI_PORT}, or the next free one)
|
|
82
90
|
--json as data, once it is up: { running, pid, postgres: { url, host, port, database, sslmode }, next }
|
|
83
91
|
`,
|
|
84
92
|
options: {
|
|
@@ -103,7 +111,7 @@ async function runDev(argv) {
|
|
|
103
111
|
// the database is the project's now: a URL here is the old habit, answered with the new way
|
|
104
112
|
return failed("start", { message: "it takes no database URL: the project has one", code: "usage", why: "mason init <url> made it; .mason/config.json holds it", usage: DEV.usage }, { json: v.json });
|
|
105
113
|
}
|
|
106
|
-
const port = v.port === undefined ?
|
|
114
|
+
const port = v.port === undefined ? DEFAULT_UI_PORT : Number(v.port);
|
|
107
115
|
if (!Number.isInteger(port) || port < 1 || port > 65_535)
|
|
108
116
|
return failed("start", { message: `--port must be a port number (1-65535), not ${JSON.stringify(v.port)}`, code: "usage" }, { json: v.json });
|
|
109
117
|
if (!v.attach)
|
|
@@ -169,7 +177,7 @@ export const STEPS = [
|
|
|
169
177
|
["Add your tables", [{ ...TABLES, run: tables }, { ...ADD, run: add }, { ...REMOVE, run: remove }, { ...WAIT, run: wait }]],
|
|
170
178
|
["Connect", [{ ...USER, run: user }, { ...MCP, run: mcp }, { ...SHARE, run: share }]],
|
|
171
179
|
["Run", [{ ...STATUS, run: status }, { ...UI, run: ui }, { ...SYNC, run: sync }, { ...LOGS, run: logs }, { ...CANCEL, run: cancel }, { ...USAGE, run: (argv) => usage(argv) }, { ...STOP, run: stop }, { ...DESTROY, run: (argv) => destroy(argv) }]],
|
|
172
|
-
["Go to production", [{ ...SERVE, run: serve }]],
|
|
180
|
+
["Go to production", [{ ...SERVE, run: serve }, { ...LOGIN, run: login }, { ...LOGOUT, run: logout }, { ...REPLICA, run: replica }, { ...TOKEN, run: token }, { ...DEPLOY, run: deploy }]],
|
|
173
181
|
];
|
|
174
182
|
/** Every command, in the order `mason --help` lists them. */
|
|
175
183
|
export const COMMANDS = STEPS.flatMap(([, commands]) => commands);
|
|
@@ -233,7 +241,7 @@ export function topUsage(s = styleOut()) {
|
|
|
233
241
|
para("A command means the project of the folder it runs in (or above it)."),
|
|
234
242
|
para("Every command but help and mcp (whose stdout is the MCP protocol's) takes --json: one JSON object for a program, always with next (what to run " +
|
|
235
243
|
"next); a failure is { error: { code, message, why, fix }, next }. The exit code says how it went: 0 done, 1 failed " +
|
|
236
|
-
"with the reason, 2 a wrong command line, 124
|
|
244
|
+
"with the reason, 2 a wrong command line, 124 a wait timed out (mason wait, mason deploy, mason replica create). Driving Mason from a coding agent: mason help agent."),
|
|
237
245
|
"",
|
|
238
246
|
].join("\n");
|
|
239
247
|
}
|