@mason-data/cli 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +101 -73
- package/dist/src/api/error.js +3 -0
- package/dist/src/api/postgres/catalog-worker.js +49 -2
- package/dist/src/api/postgres/catalog.js +368 -25
- package/dist/src/api/postgres/encode.js +3 -1
- package/dist/src/api/postgres/explain.js +3 -2
- package/dist/src/api/postgres/listener.js +11 -3
- package/dist/src/api/postgres/protocol.js +39 -23
- 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 +1617 -245
- package/dist/src/api/postgres/translate.js +127 -10
- package/dist/src/api/request-log.js +18 -5
- package/dist/src/cdc/poll.js +5 -5
- package/dist/src/cli/background.js +47 -25
- package/dist/src/cli/bridge-stdin.js +101 -0
- package/dist/src/cli/cancel.js +75 -0
- package/dist/src/cli/command.js +170 -11
- package/dist/src/cli/commands.js +804 -209
- package/dist/src/cli/control.js +59 -0
- package/dist/src/cli/credentials.js +182 -0
- package/dist/src/cli/demo.js +20 -11
- package/dist/src/cli/deploy.js +574 -0
- package/dist/src/cli/dev.js +491 -130
- package/dist/src/cli/doctor.js +131 -38
- package/dist/src/cli/generated-files.js +143 -0
- package/dist/src/cli/guides.js +175 -36
- package/dist/src/cli/hosted.js +850 -0
- package/dist/src/cli/init.js +162 -65
- package/dist/src/cli/login.js +289 -0
- package/dist/src/cli/machine.js +13 -12
- package/dist/src/cli/main.js +11 -5
- package/dist/src/cli/mason.cjs +17 -0
- package/dist/src/cli/mcp.js +31 -22
- package/dist/src/cli/metrics.js +79 -0
- 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 +14 -4
- package/dist/src/cli/pg-url.js +4 -0
- package/dist/src/cli/pin.js +19 -14
- package/dist/src/cli/pool-size.cjs +85 -0
- package/dist/src/cli/ports.js +2 -0
- package/dist/src/cli/preflight.js +138 -46
- package/dist/src/cli/project.js +142 -4
- package/dist/src/cli/readiness.js +76 -6
- package/dist/src/cli/registry.js +65 -38
- package/dist/src/cli/replica-hosted.js +665 -0
- package/dist/src/cli/replica.js +76 -0
- package/dist/src/cli/replication-checks.js +7 -5
- package/dist/src/cli/replication-mode.js +105 -26
- package/dist/src/cli/request-filter.js +134 -0
- package/dist/src/cli/roles.js +585 -0
- package/dist/src/cli/run-server.js +637 -48
- package/dist/src/cli/runs.js +1 -1
- package/dist/src/cli/security.js +67 -18
- package/dist/src/cli/serve.js +204 -46
- package/dist/src/cli/share-run.js +329 -0
- package/dist/src/cli/share.js +286 -0
- package/dist/src/cli/source-edit.js +75 -17
- package/dist/src/cli/source-role.js +344 -0
- package/dist/src/cli/sources.js +306 -47
- package/dist/src/cli/style.js +2 -1
- package/dist/src/cli/supervise.js +27 -24
- package/dist/src/cli/sync.js +55 -25
- package/dist/src/cli/token-hosted.js +337 -0
- package/dist/src/cli/tunnel.js +200 -0
- package/dist/src/cli/ui-ssh.js +460 -0
- package/dist/src/cli/ui.js +124 -17
- package/dist/src/cli/usage.js +128 -0
- package/dist/src/cli/user-hosted.js +299 -0
- package/dist/src/cli/user.js +573 -65
- package/dist/src/cli/version.js +9 -7
- package/dist/src/demo/seed.js +29 -0
- package/dist/src/duck/analyze.js +36 -20
- package/dist/src/duck/cancel.js +10 -1
- 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 +165 -0
- package/dist/src/duck/executor-worker.js +20 -11
- package/dist/src/duck/executor.js +47 -33
- package/dist/src/duck/explain.js +60 -46
- package/dist/src/duck/extensions.js +160 -0
- package/dist/src/duck/functions.js +461 -63
- package/dist/src/duck/gaps.js +48 -21
- package/dist/src/duck/generated-plan.js +1070 -0
- package/dist/src/duck/generated.js +906 -0
- package/dist/src/duck/grants.js +176 -0
- package/dist/src/duck/held-types.js +64 -0
- package/dist/src/duck/history.js +127 -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 +15 -13
- 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 +369 -47
- package/dist/src/duck/publication.js +20 -6
- package/dist/src/duck/requests.js +269 -93
- package/dist/src/duck/resolved.js +117 -2
- package/dist/src/duck/rewrite.js +4584 -580
- package/dist/src/duck/row-filter.js +655 -0
- package/dist/src/duck/statement.js +19 -0
- package/dist/src/duck/store.js +439 -177
- package/dist/src/duck/stream.js +19 -10
- package/dist/src/duck/sync.js +1411 -368
- package/dist/src/duck/token.js +4 -1
- package/dist/src/duck/types.js +69 -12
- package/dist/src/duck/usage.js +326 -0
- package/dist/src/duck/users.js +69 -15
- package/dist/src/duck/verified.js +161 -1
- package/dist/src/duck/views.js +5 -5
- package/dist/src/lib/database-name.js +62 -0
- package/dist/src/lib/database-url.js +55 -10
- package/dist/src/lib/dotenv.js +17 -4
- package/dist/src/lib/instant.js +50 -0
- package/dist/src/lib/log-buffer.js +77 -0
- package/dist/src/lib/log-read.js +196 -0
- package/dist/src/lib/pg-numeric.js +390 -49
- package/dist/src/lib/project-version.js +23 -23
- package/dist/src/lib/secret-mask.js +142 -0
- package/dist/src/lib/table-sync.js +282 -0
- package/dist/src/web/app.css +706 -66
- package/dist/src/web/app.js +331 -409
- package/dist/src/web/charts.js +229 -0
- package/dist/src/web/index.html +82 -18
- package/dist/src/web/pages/queries.js +192 -0
- package/dist/src/web/pages/status.js +611 -0
- package/dist/src/web/pages/syncs.js +155 -0
- package/dist/src/web/pages/tables.js +439 -0
- package/dist/src/web/pages/usage.js +397 -0
- package/dist/src/web/pages/users.js +219 -0
- package/dist/src/web/shared.js +584 -0
- package/dist/src/web/vendor/README.md +25 -0
- package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
- package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
- package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
- package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
- package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
- package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
- package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
- package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
- package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
- package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
- package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
- package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
- package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
- package/package.json +12 -4
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mason login` AND `mason logout` — THIS MACHINE SIGNED IN TO A HOSTED MASON SERVICE (docs/HOSTED-API.md). They need
|
|
3
|
+
* no project: a login is the person's, kept in their own config folder (src/cli/credentials.ts), never in a project.
|
|
4
|
+
*
|
|
5
|
+
* `mason login` is OAuth's device authorization grant (RFC 8628; src/cli/hosted.ts speaks it): it asks the service for
|
|
6
|
+
* a code, says where to approve it — at once, on stderr, in plain lines, so a person in a terminal and a coding agent
|
|
7
|
+
* relaying it both read it before the wait begins (a browser opens on a terminal, when one can) — polls at the
|
|
8
|
+
* service's interval (five seconds more at each `slow_down`) until the code is approved, denied or expires, then asks
|
|
9
|
+
* who signed in (`GET /v1/me`) and saves the token. Its answer is ONE report on stdout, or under `--json` ONE object,
|
|
10
|
+
* once, when it ends: what it prints while waiting is progress, on stderr.
|
|
11
|
+
*
|
|
12
|
+
* `mason logout` revokes the saved token (best effort: a service that cannot be reached is said, with the way to revoke
|
|
13
|
+
* it by hand) and removes the saved login either way.
|
|
14
|
+
*
|
|
15
|
+
* A TOKEN NEVER REACHES A COMMAND LINE, A LOG, THE OUTPUT OR `--json`: there is no flag that takes one; `MASON_TOKEN` is
|
|
16
|
+
* read from the environment and never printed; and everything printed from the service is masked (`serviceText`).
|
|
17
|
+
* `MASON_TOKEN` comes before a saved login (CI), so `mason login` with it set is refused, naming how to sign in anyway,
|
|
18
|
+
* and `mason logout` says it cannot remove an environment variable.
|
|
19
|
+
*/
|
|
20
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
21
|
+
import { endText, failed, jsonText, parseCommand, reportText, serviceStyle, strict } from "./command.js";
|
|
22
|
+
import { environmentToken, readLogins, TOKEN_ENV, writeLogins } from "./credentials.js";
|
|
23
|
+
import { DEFAULT_SERVICE_URL, expired, isFailure, pollToken, revoke, serviceOf, serviceText, startDevice, whoAmI } from "./hosted.js";
|
|
24
|
+
import { err as styleErr, out as styleOut, stepRenderer } from "./style.js";
|
|
25
|
+
import { holdOutput } from "./output-gate.js";
|
|
26
|
+
import { openBrowser } from "./ui.js";
|
|
27
|
+
const out = (m) => { process.stdout.write(`${m}\n`); };
|
|
28
|
+
/** Everything these commands print can hold the service's text, so it is drawn in serviceStyle (src/cli/command.ts):
|
|
29
|
+
* on a terminal the CLI breaks every line itself, and a person never sees a line begin with what the service sent. */
|
|
30
|
+
const outStyle = () => serviceStyle(styleOut());
|
|
31
|
+
const errStyle = () => serviceStyle(styleErr());
|
|
32
|
+
const fail = (cmd, f, o) => failed(cmd, f, { json: o.json, style: errStyle() });
|
|
33
|
+
const say = (m) => { process.stderr.write(`${m}\n`); };
|
|
34
|
+
export const LOGIN = {
|
|
35
|
+
name: "login",
|
|
36
|
+
summary: "sign this machine in to a hosted Mason service, by a code you approve in your browser",
|
|
37
|
+
usage: `usage: mason login [--json]
|
|
38
|
+
|
|
39
|
+
Signs this machine in to a hosted Mason service: it prints a page and a code, opens the page in your browser
|
|
40
|
+
when it runs in a terminal, and waits while you sign in there and approve the code (until the code expires, as
|
|
41
|
+
the service says; Ctrl-C stops it). It then says who is signed in and their organisations, and keeps the login in
|
|
42
|
+
~/.config/mason/credentials.json ($XDG_CONFIG_HOME/mason/ when that is set), readable by you alone, never in a
|
|
43
|
+
project. Signing in again replaces this machine's login to that service, and revokes the one it replaces.
|
|
44
|
+
|
|
45
|
+
The page and the code are printed on stderr at once, as plain lines, so a coding agent can pass them on to you
|
|
46
|
+
before the wait ends; the answer is printed when it ends.
|
|
47
|
+
|
|
48
|
+
The service is ${DEFAULT_SERVICE_URL} unless MASON_CLOUD_URL names another (https://, or http:// for one on
|
|
49
|
+
this machine). With MASON_TOKEN set (CI), every command uses that token before a saved login, so mason login is
|
|
50
|
+
refused: run it without the variable (env -u MASON_TOKEN mason login) to sign in anyway.
|
|
51
|
+
|
|
52
|
+
mason login
|
|
53
|
+
MASON_CLOUD_URL=http://127.0.0.1:8080 mason login a service on this machine
|
|
54
|
+
|
|
55
|
+
--json as data, once signed in: { service (its address), email, name, orgs: [{ slug, name, role }], file (where
|
|
56
|
+
the login is kept), replaced (null, or the login it replaced: { email, revoked }), next }
|
|
57
|
+
`,
|
|
58
|
+
options: { json: { type: "boolean", default: false } },
|
|
59
|
+
json: ["service", "email", "name", "orgs", "file", "replaced", "next"],
|
|
60
|
+
};
|
|
61
|
+
export const LOGOUT = {
|
|
62
|
+
name: "logout",
|
|
63
|
+
summary: "sign this machine out of a hosted Mason service: its login revoked and removed",
|
|
64
|
+
usage: `usage: mason logout [--json]
|
|
65
|
+
|
|
66
|
+
Signs this machine out of the hosted Mason service (MASON_CLOUD_URL, else ${DEFAULT_SERVICE_URL}): it revokes
|
|
67
|
+
the login on the service, then removes it from ~/.config/mason/credentials.json. When the service cannot be
|
|
68
|
+
reached the login is still removed here, and it says the login was NOT revoked and how to revoke it: sign in to
|
|
69
|
+
the service's web app and revoke it there. Logins to other services stay.
|
|
70
|
+
|
|
71
|
+
MASON_TOKEN in the environment is not a saved login: logout cannot remove it, and says so while it is set.
|
|
72
|
+
|
|
73
|
+
mason logout
|
|
74
|
+
MASON_CLOUD_URL=http://127.0.0.1:8080 mason logout a service on this machine
|
|
75
|
+
|
|
76
|
+
--json as data: { service, signedOut (a saved login was removed), email (its account, null when there was
|
|
77
|
+
none), revoked (true, false when the service did not take it, null when there was nothing to revoke),
|
|
78
|
+
revokeError (why not, else null), environmentToken (MASON_TOKEN is set, and still used), file, next }
|
|
79
|
+
`,
|
|
80
|
+
options: { json: { type: "boolean", default: false } },
|
|
81
|
+
json: ["service", "signedOut", "email", "revoked", "revokeError", "environmentToken", "file", "next"],
|
|
82
|
+
};
|
|
83
|
+
/** Where a person may approve the code, for the browser to open: a plain https (or this machine's http) URL, and none
|
|
84
|
+
* of the characters a shell or `cmd /c start` reads (the service's URL is not ours). */
|
|
85
|
+
const OPENABLE = /^https?:\/\/[A-Za-z0-9.:[\]-]+(?:\/[A-Za-z0-9._~/?=+-]*)?$/;
|
|
86
|
+
/** An organisation as a line: its slug, its name when it says more, the role. */
|
|
87
|
+
const orgLine = (o) => `${o.slug}${o.name && o.name !== o.slug ? ` (${o.name})` : ""}, ${o.role}`;
|
|
88
|
+
export async function login(argv) {
|
|
89
|
+
const f = parseCommand(LOGIN, argv);
|
|
90
|
+
if (typeof f === "number")
|
|
91
|
+
return f;
|
|
92
|
+
const json = f.values.json === true;
|
|
93
|
+
if (f.positionals.length)
|
|
94
|
+
return fail("login", { message: "it takes no argument", code: "usage", why: "the service is MASON_CLOUD_URL's, else the default", usage: LOGIN.usage }, { json });
|
|
95
|
+
const service = serviceOf();
|
|
96
|
+
if (isFailure(service))
|
|
97
|
+
return fail("login", service, { json });
|
|
98
|
+
if (environmentToken() !== null) {
|
|
99
|
+
return fail("login", { message: `${TOKEN_ENV} is set in this environment, so a login would not be used`, code: "refused",
|
|
100
|
+
why: `every command sends ${TOKEN_ENV} before a saved login: a new login to ${service.base} would be shadowed by it`,
|
|
101
|
+
fix: `to sign in anyway, run it without the variable: env -u ${TOKEN_ENV} mason login (the login is used once ${TOKEN_ENV} is unset)`,
|
|
102
|
+
next: [[`env -u ${TOKEN_ENV} mason login`, "sign in, for when MASON_TOKEN is unset"]] }, { json });
|
|
103
|
+
}
|
|
104
|
+
// the file is checked BEFORE the person spends a minute approving a code: a folder others can read is said now
|
|
105
|
+
const before = readLogins();
|
|
106
|
+
if (isFailure(before))
|
|
107
|
+
return fail("login", before, { json });
|
|
108
|
+
// what this command holds that the service may echo before it sends a token: the login it replaces (src/cli/hosted.ts
|
|
109
|
+
// `carriesNoSecret`: an answer that carries it is refused whole)
|
|
110
|
+
const held = before.logins.logins[service.base] ? [before.logins.logins[service.base].token] : [];
|
|
111
|
+
// and from here every byte written is put to the exact test against what it holds (src/cli/output-gate.ts)
|
|
112
|
+
holdOutput({ command: "login", json, secrets: held });
|
|
113
|
+
const code = await startDevice(service, held);
|
|
114
|
+
if (isFailure(code))
|
|
115
|
+
return fail("login", code, { json });
|
|
116
|
+
const token = await approved(service, code, json, held);
|
|
117
|
+
if (isFailure(token))
|
|
118
|
+
return fail("login", token, { json });
|
|
119
|
+
holdOutput({ command: "login", json, secrets: [token] });
|
|
120
|
+
// FROM HERE THE SERVICE HAS GRANTED A TOKEN, which is kept only once it is saved: on every failure before that —
|
|
121
|
+
// one said, or one thrown (PR 645's review: a crash left a granted token live) — it is revoked, and a revoke that
|
|
122
|
+
// did not reach the service is said
|
|
123
|
+
let kept;
|
|
124
|
+
try {
|
|
125
|
+
kept = await keep(service, token, held);
|
|
126
|
+
}
|
|
127
|
+
catch (e) {
|
|
128
|
+
const notRevoked = await revoke(service, token);
|
|
129
|
+
if (notRevoked === null)
|
|
130
|
+
throw e;
|
|
131
|
+
throw new Error(`${e instanceof Error ? e.message : String(e)}; the token the service granted was NOT revoked (${notRevoked}): sign in to ${service.base} in a browser and revoke it there`, { cause: e });
|
|
132
|
+
}
|
|
133
|
+
if (isFailure(kept)) {
|
|
134
|
+
const notRevoked = await revoke(service, token);
|
|
135
|
+
return fail("login", notRevoked === null ? kept : { ...kept, why: `${kept.why ? `${kept.why}; ` : ""}the token the service granted was NOT revoked (${notRevoked}): sign in to ${service.base} in a browser and revoke it there` }, { json });
|
|
136
|
+
}
|
|
137
|
+
const { me, was, saved } = kept;
|
|
138
|
+
// the login it replaced is revoked, so this machine never leaves a token it no longer holds alive on the service
|
|
139
|
+
const replaced = was === null || was.token === token ? null : { email: serviceText(was.email, 320, [token, was.token]), revokeError: await revoke(service, was.token) };
|
|
140
|
+
// what the service said, as it may be shown, in the report and in --json alike: printable, bounded, no token in it
|
|
141
|
+
const secrets = [token, ...(was ? [was.token] : [])];
|
|
142
|
+
const email = serviceText(me.email, 320, secrets), name = serviceText(me.name, 300, secrets);
|
|
143
|
+
const orgs = me.orgs.map((o) => ({ slug: serviceText(o.slug, 200, secrets), name: serviceText(o.name, 200, secrets), role: serviceText(o.role, 32, secrets) }));
|
|
144
|
+
const next = [["mason logout", `sign this machine out of ${service.base}`]];
|
|
145
|
+
if (json) {
|
|
146
|
+
out(jsonText(LOGIN, { service: service.base, email, name, orgs, file: saved.file,
|
|
147
|
+
replaced: replaced === null ? null : { email: replaced.email, revoked: replaced.revokeError === null } }, next));
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
150
|
+
out(reportText({
|
|
151
|
+
headline: `Signed in to ${service.base} as ${email}`,
|
|
152
|
+
details: [
|
|
153
|
+
...(name ? [["name", name]] : []),
|
|
154
|
+
...(orgs.length ? orgs.map((o, i) => [i === 0 ? "orgs" : "", orgLine(o)]) : [["orgs", "none yet"]]),
|
|
155
|
+
["saved", saved.file],
|
|
156
|
+
],
|
|
157
|
+
tips: replaced === null ? undefined : replaced.revokeError === null
|
|
158
|
+
? [`This replaces the login of ${replaced.email}, which is revoked.`]
|
|
159
|
+
: [`This replaces the login of ${replaced.email}, which was NOT revoked (${replaced.revokeError}): sign in to ${service.base} in a browser and revoke it there.`],
|
|
160
|
+
next,
|
|
161
|
+
}, outStyle()));
|
|
162
|
+
return 0;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* WHAT A GRANTED TOKEN NEEDS BEFORE IT IS KEPT: who signed in (a token the service will not say who it is is never
|
|
166
|
+
* kept), the logins read again just before the write (so a login another command saved meanwhile is kept), and the
|
|
167
|
+
* write. A Failure when any is not had; the caller revokes the token then.
|
|
168
|
+
*/
|
|
169
|
+
async function keep(service, token, held) {
|
|
170
|
+
const me = await whoAmI(service, token, held);
|
|
171
|
+
if (isFailure(me))
|
|
172
|
+
return me;
|
|
173
|
+
const now = readLogins();
|
|
174
|
+
if (isFailure(now))
|
|
175
|
+
return now;
|
|
176
|
+
const was = now.logins.logins[service.base] ?? null;
|
|
177
|
+
const logins = { version: 1, logins: { ...now.logins.logins, [service.base]: { token, email: me.email, savedAt: new Date().toISOString() } } };
|
|
178
|
+
const saved = writeLogins(logins);
|
|
179
|
+
if (isFailure(saved))
|
|
180
|
+
return saved;
|
|
181
|
+
return { me, was, saved };
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* The page and the code said, then the wait: a poll every `interval` seconds (five more at each slow_down, RFC 8628
|
|
185
|
+
* 3.5), until the token, a refusal, or the code's expiry. On a terminal a spinner turns beside the wait; elsewhere the
|
|
186
|
+
* wait is one line.
|
|
187
|
+
*/
|
|
188
|
+
async function approved(service, code, json, held) {
|
|
189
|
+
const page = code.verificationUriComplete ?? code.verificationUri;
|
|
190
|
+
const s = errStyle();
|
|
191
|
+
say(reportText({ headline: `To sign in to ${service.base}, open this page and approve the code:`,
|
|
192
|
+
details: [["page", page], ["code", code.verificationUriComplete ? `${code.userCode} (the page shows it: check they match)` : `${code.userCode} (enter it on the page)`]] }, s));
|
|
193
|
+
const terminal = !json && process.stderr.isTTY === true;
|
|
194
|
+
if (terminal) {
|
|
195
|
+
const couldNot = OPENABLE.test(page) ? await openBrowser(page) : "the page's address is not one Mason opens by itself";
|
|
196
|
+
if (couldNot !== null)
|
|
197
|
+
say(endText({ tips: [`Could not open a browser (${couldNot}): open the page yourself.`] }, s).replace(/^\n/, ""));
|
|
198
|
+
}
|
|
199
|
+
const minutes = Math.max(1, Math.round(code.expiresIn / 60));
|
|
200
|
+
const full = `Waiting for the code to be approved (it expires in ${minutes} min; Ctrl-C stops)`;
|
|
201
|
+
// a spinner's line is redrawn in place, so it must never reach the terminal's edge (src/cli/command.ts serviceStyle):
|
|
202
|
+
// the longest label that fits beside its frame, else no spinner and the wait said once, broken as any line
|
|
203
|
+
const room = (process.stderr.columns ?? 0) - 1 - 6;
|
|
204
|
+
const label = terminal ? [full, "Waiting for approval", "Waiting"].find((l) => l.length <= room) : undefined;
|
|
205
|
+
const live = label !== undefined;
|
|
206
|
+
const steps = stepRenderer({ line: (t) => say(strict(t, s)), live: live ? process.stderr : null, style: s });
|
|
207
|
+
if (live)
|
|
208
|
+
steps.begin(label);
|
|
209
|
+
else
|
|
210
|
+
say(strict(`${full} …`, s));
|
|
211
|
+
const deadline = Date.now() + code.expiresIn * 1000;
|
|
212
|
+
let interval = code.interval;
|
|
213
|
+
for (;;) {
|
|
214
|
+
if (Date.now() + interval * 1000 > deadline) {
|
|
215
|
+
if (live)
|
|
216
|
+
steps.fail();
|
|
217
|
+
return expired(code);
|
|
218
|
+
}
|
|
219
|
+
await sleep(interval * 1000);
|
|
220
|
+
const p = await pollToken(service, code, held);
|
|
221
|
+
if ("pending" in p)
|
|
222
|
+
continue;
|
|
223
|
+
if ("slowDown" in p) {
|
|
224
|
+
interval += 5;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (isFailure(p)) {
|
|
228
|
+
if (live)
|
|
229
|
+
steps.fail();
|
|
230
|
+
return p;
|
|
231
|
+
}
|
|
232
|
+
if (live)
|
|
233
|
+
steps.done("Approved");
|
|
234
|
+
else
|
|
235
|
+
say("Approved");
|
|
236
|
+
return p.token;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
export async function logout(argv) {
|
|
240
|
+
const f = parseCommand(LOGOUT, argv);
|
|
241
|
+
if (typeof f === "number")
|
|
242
|
+
return f;
|
|
243
|
+
const json = f.values.json === true;
|
|
244
|
+
if (f.positionals.length)
|
|
245
|
+
return fail("logout", { message: "it takes no argument", code: "usage", why: "the service is MASON_CLOUD_URL's, else the default", usage: LOGOUT.usage }, { json });
|
|
246
|
+
const service = serviceOf();
|
|
247
|
+
if (isFailure(service))
|
|
248
|
+
return fail("logout", service, { json });
|
|
249
|
+
const fromEnv = environmentToken() !== null;
|
|
250
|
+
const envTip = fromEnv ? [`${TOKEN_ENV} is set in this environment, and every command still uses it: logout cannot remove an environment variable. Unset it (unset ${TOKEN_ENV}), and revoke it in the service's web app if it was shared.`] : [];
|
|
251
|
+
const read = readLogins();
|
|
252
|
+
if (isFailure(read))
|
|
253
|
+
return fail("logout", read, { json });
|
|
254
|
+
const entry = read.logins.logins[service.base];
|
|
255
|
+
const env = environmentToken();
|
|
256
|
+
if (entry !== undefined || env !== null)
|
|
257
|
+
holdOutput({ command: "logout", json, secrets: [...(entry ? [entry.token] : []), ...(env !== null ? [env] : [])] });
|
|
258
|
+
if (entry === undefined) {
|
|
259
|
+
const next = [["mason login", `sign this machine in to ${service.base}`]];
|
|
260
|
+
if (json) {
|
|
261
|
+
out(jsonText(LOGOUT, { service: service.base, signedOut: false, email: null, revoked: null, revokeError: null, environmentToken: fromEnv, file: read.file }, next));
|
|
262
|
+
return 0;
|
|
263
|
+
}
|
|
264
|
+
out(reportText({ headline: `This machine is not signed in to ${service.base}`, details: [["file", read.file]], tips: envTip, next }, outStyle()));
|
|
265
|
+
return 0;
|
|
266
|
+
}
|
|
267
|
+
const revokeError = await revoke(service, entry.token);
|
|
268
|
+
// removed whether or not the service took the revoke: a person who logs out means this machine to hold it no more
|
|
269
|
+
const rest = { ...read.logins.logins };
|
|
270
|
+
delete rest[service.base];
|
|
271
|
+
const written = writeLogins({ version: 1, logins: rest });
|
|
272
|
+
if (isFailure(written))
|
|
273
|
+
return fail("logout", written, { json });
|
|
274
|
+
const email = serviceText(entry.email, 320, [entry.token]);
|
|
275
|
+
const next = [["mason login", `sign in to ${service.base} again`]];
|
|
276
|
+
if (json) {
|
|
277
|
+
out(jsonText(LOGOUT, { service: service.base, signedOut: true, email, revoked: revokeError === null, revokeError, environmentToken: fromEnv, file: written.file }, next));
|
|
278
|
+
return 0;
|
|
279
|
+
}
|
|
280
|
+
const notRevoked = revokeError === null ? undefined
|
|
281
|
+
: `The login was NOT revoked (${revokeError}), so it still works on the service until it expires: sign in to ${service.base} in a browser and revoke this machine's login there.`;
|
|
282
|
+
out(reportText({
|
|
283
|
+
headline: revokeError === null ? `Signed out of ${service.base}` : `Signed out of ${service.base} on this machine, but the login was NOT revoked`,
|
|
284
|
+
details: [["account", email], ["revoked", revokeError === null ? "yes: the service takes this login no more" : "no"], ["removed from", written.file]],
|
|
285
|
+
tips: [...(notRevoked ? [notRevoked] : []), ...envTip],
|
|
286
|
+
next,
|
|
287
|
+
}, outStyle()));
|
|
288
|
+
return 0;
|
|
289
|
+
}
|
package/dist/src/cli/machine.js
CHANGED
|
@@ -18,16 +18,16 @@ import { dropOwnReplication, dropStatements, madeReplication } from "./replicati
|
|
|
18
18
|
import { sourceFileNames } from "./sources.js";
|
|
19
19
|
export const DESTROY_USAGE = `usage: mason destroy [--json]
|
|
20
20
|
|
|
21
|
-
Removes this folder's project's
|
|
21
|
+
Removes this folder's project's synced data from this machine — its file — and Mason's state in its
|
|
22
22
|
.mason/ (the logs, the request log). Mason is stopped first if it runs. Your files (mason/sources/), .mason/config.json and
|
|
23
|
-
the users (mason user) stay: the next mason start
|
|
23
|
+
the users (mason user) stay: the next mason start syncs the tables again, and every tool connects as before.
|
|
24
24
|
|
|
25
25
|
mason destroy this folder's project
|
|
26
26
|
|
|
27
|
-
mason stop keeps the
|
|
27
|
+
mason stop keeps the synced data and the state, and the next mason start resumes in seconds; mason destroy frees the disk, and
|
|
28
28
|
the next mason start begins again. In replication mode the slot (and Mason's own publication) are dropped from the
|
|
29
29
|
database too: they hold its WAL for as long as they exist. A demo project's database (mason init demo, in .mason/demo/)
|
|
30
|
-
goes with the
|
|
30
|
+
goes with the synced data: the next mason start makes and seeds it again.
|
|
31
31
|
|
|
32
32
|
--json as data: { removed: [{ name, removed: [what], error }], next }
|
|
33
33
|
|
|
@@ -36,7 +36,7 @@ export const DESTROY_USAGE = `usage: mason destroy [--json]
|
|
|
36
36
|
export const DESTROY = {
|
|
37
37
|
needs: "project",
|
|
38
38
|
name: "destroy",
|
|
39
|
-
summary: "remove this project's
|
|
39
|
+
summary: "remove this project's synced data and Mason's state from this machine (your files and users stay)",
|
|
40
40
|
usage: DESTROY_USAGE,
|
|
41
41
|
options: { json: { type: "boolean" } },
|
|
42
42
|
json: ["removed", "next"],
|
|
@@ -71,18 +71,19 @@ export async function removeProject(p, o = {}) {
|
|
|
71
71
|
if (sup !== null)
|
|
72
72
|
removed.push(`stopped its supervisor (pid ${sup}), which was about to start it again`);
|
|
73
73
|
}
|
|
74
|
-
// REPLICATION
|
|
75
|
-
//
|
|
74
|
+
// REPLICATION made a publication and a slot on the database (src/cli/replication-mode.ts), recorded by the start or
|
|
75
|
+
// the run that made them (an older start recorded its mode, `changes`): the slot holds its WAL for as long as it
|
|
76
|
+
// exists, so it goes with the copy — and when it cannot, the rest goes and the failure says how
|
|
76
77
|
let replication = null;
|
|
77
78
|
let objects = "publication and replication slot";
|
|
78
|
-
if (p.config.changes === "replication") {
|
|
79
|
+
if ((p.config.replication ?? null) !== null || p.config.changes === "replication") {
|
|
79
80
|
// the slot, and the publication only when it is Mason's own: one the operator named is its owner's, left as it is
|
|
80
81
|
const src = projectSource(p);
|
|
81
82
|
// what the start that made them recorded (madeReplication); a project with no record names them by its one sources file
|
|
82
83
|
const own = madeReplication(p.config, "message" in src ? (sourceFileNames(p.folder)[0] ?? "app.yml").replace(/\.ya?ml$/, "") : src.name);
|
|
83
84
|
if (!own.owned)
|
|
84
85
|
objects = "replication slot";
|
|
85
|
-
const parsed = "message" in src ? { problem: `the database's URL cannot be read: ${src.message}` } : parseSource(src.resolved);
|
|
86
|
+
const parsed = "message" in src ? { problem: `the database's URL cannot be read: ${src.message}` } : parseSource(src.resolved, src.from);
|
|
86
87
|
try {
|
|
87
88
|
if ("problem" in parsed)
|
|
88
89
|
throw new Error(parsed.problem);
|
|
@@ -90,7 +91,7 @@ export async function removeProject(p, o = {}) {
|
|
|
90
91
|
// and the real slot goes on holding its WAL (the review of the sources file)
|
|
91
92
|
const from = p.config.sourceServer ?? null;
|
|
92
93
|
if (from !== null && from.database !== parsed.source.database) {
|
|
93
|
-
throw new Error(`the sources file names ${parsed.source.database} now, and the
|
|
94
|
+
throw new Error(`the sources file names ${parsed.source.database} now, and the synced data came from ${from.database}, where the slot is: ` +
|
|
94
95
|
`put that database's URL back in the sources file and run mason destroy again, or drop them on ${from.database} by hand`);
|
|
95
96
|
}
|
|
96
97
|
for (const d of await (o.dropReplication ?? dropOwnReplication)(parsed.source, own, from))
|
|
@@ -145,7 +146,7 @@ export async function destroy(argv, deps = {}) {
|
|
|
145
146
|
catch (e) {
|
|
146
147
|
result = { name, removed: [], error: e.message };
|
|
147
148
|
}
|
|
148
|
-
const next = result.error ? [] : [["mason start", "
|
|
149
|
+
const next = result.error ? [] : [["mason start", "syncs the tables again"]];
|
|
149
150
|
if (json) {
|
|
150
151
|
out(jsonText(DESTROY, { removed: [result] }, next));
|
|
151
152
|
return result.error ? 1 : 0;
|
|
@@ -156,7 +157,7 @@ export async function destroy(argv, deps = {}) {
|
|
|
156
157
|
headline: result.removed.length ? `Removed ${name}` : `Nothing was left of ${name} to remove`,
|
|
157
158
|
details: [...(result.removed.length ? [["removed", result.removed.join(", ")]] : []), ["kept", `your files in ${t.project.folder}, .mason/config.json and the users` +
|
|
158
159
|
// the demo database is state like the copy (src/cli/demo.ts): it goes, and config.demo stays to make it again
|
|
159
|
-
(t.project.config.demo === true ? ` — not the demo database, which went with the
|
|
160
|
+
(t.project.config.demo === true ? ` — not the demo database, which went with the synced data: the next mason start seeds it again` : "")]],
|
|
160
161
|
}), { next }));
|
|
161
162
|
return 0;
|
|
162
163
|
}
|
package/dist/src/cli/main.js
CHANGED
|
@@ -10,12 +10,15 @@
|
|
|
10
10
|
* project of the folder they run in. Every command is in src/cli/registry.ts, described once (src/cli/command.ts).
|
|
11
11
|
* Apache-2.0: it runs the stack (CLAUDE.md "Licensing").
|
|
12
12
|
*/
|
|
13
|
+
// FIRST: it sizes libuv's pool, which nothing below may have started (src/cli/pool-size.cjs)
|
|
14
|
+
import "./pool-size.cjs";
|
|
13
15
|
import { COMMANDS, orientation, topUsage, version } from "./registry.js";
|
|
14
16
|
import { didYouMean } from "../lib/did-you-mean.js";
|
|
15
17
|
import { spinUntilOutput } from "./style.js";
|
|
16
18
|
import { CommandFailed, failed, wantsJson } from "./command.js";
|
|
17
19
|
import { RunUnreachable } from "./control.js";
|
|
18
20
|
import { exitWhenWritten } from "../lib/exit-when-written.js";
|
|
21
|
+
import { gatedExit, installOutputGate } from "./output-gate.js";
|
|
19
22
|
async function main(argv) {
|
|
20
23
|
const [command, ...args] = argv;
|
|
21
24
|
// mason alone: where you are and the next command; mason help / --help: every command
|
|
@@ -38,11 +41,11 @@ async function main(argv) {
|
|
|
38
41
|
dev: { message: "mason dev is now mason start", next: [["mason init <postgres url>", "once, in your app's folder"], ["mason start", "Mason in the background"]] },
|
|
39
42
|
docs: { message: "mason docs is now mason help", next: [["mason help agent", "driving Mason from a coding agent"], ["mason help production", "running Mason on a server"]] },
|
|
40
43
|
// named for what it does to the copy, never for the mechanism
|
|
41
|
-
poll: { message: "mason poll is now mason sync", next: [["mason sync <table>", "
|
|
42
|
-
purge: { message: "mason purge is now mason destroy", next: [["mason destroy", "this folder's project: its
|
|
44
|
+
poll: { message: "mason poll is now mason sync", next: [["mason sync <table>", "bring a synced table up to date now"], ["mason status", "each synced table, and which the sync leaves out"]] },
|
|
45
|
+
purge: { message: "mason purge is now mason destroy", next: [["mason destroy", "this folder's project: its synced data and state, never its files"]] },
|
|
43
46
|
// mason rm read as the opposite of mason add: it removed a project's copy and state, never a table;
|
|
44
47
|
// the opposite of mason add is mason remove, so both are offered
|
|
45
|
-
rm: { message: "mason rm is gone: mason remove takes a table out of the project, mason destroy removes the project's
|
|
48
|
+
rm: { message: "mason rm is gone: mason remove takes a table out of the project, mason destroy removes the project's synced data", next: [["mason remove <table>", "a table, and its synced data"], ["mason destroy", "this folder's project: its synced data and state, never its files"]] },
|
|
46
49
|
// a user's password does not expire, so mason user is enough and mason connect was dropped
|
|
47
50
|
connect: { message: "mason connect is gone: mason user add <name> makes a user whose password does not expire, and prints the URL to paste", next: [["mason user add <name>", "a user for a BI tool or your app"], ["mason status", "where the Postgres API listens"]] },
|
|
48
51
|
// a project is its folder (mason list was dropped): nothing on the machine lists them
|
|
@@ -70,7 +73,10 @@ async function main(argv) {
|
|
|
70
73
|
stop();
|
|
71
74
|
}
|
|
72
75
|
}
|
|
73
|
-
|
|
76
|
+
// every byte written passes the output's gate, which a hosted command arms with the secrets it holds (src/cli/output-gate.ts):
|
|
77
|
+
// installed before anything else wraps the streams (the spinner, below), and its refusal is the exit's (`gatedExit`)
|
|
78
|
+
installOutputGate();
|
|
79
|
+
main(process.argv.slice(2)).then((code) => exitWhenWritten(gatedExit(code)), (e) => {
|
|
74
80
|
// an error no command said in its own words: its message, never a stack trace (CLAUDE.md: a message that needs a
|
|
75
81
|
// stack to be understood is a bug — mason query orders printed one when Mason's API was not up yet).
|
|
76
82
|
// MASON_DEBUG=1 prints where it happened, for the bug report it deserves. The same shape as every failure, JSON under --json.
|
|
@@ -79,5 +85,5 @@ main(process.argv.slice(2)).then(exitWhenWritten, (e) => {
|
|
|
79
85
|
const code = failed(command ?? "", { message, code: "failed", fix: process.env.MASON_DEBUG ? undefined : "this is a bug in mason: MASON_DEBUG=1 prints where it happened, for the report" }, { json: wantsJson(args, COMMANDS.find((c) => c.name === command)) });
|
|
80
86
|
if (process.env.MASON_DEBUG)
|
|
81
87
|
process.stderr.write(`${e.stack ?? ""}\n`);
|
|
82
|
-
exitWhenWritten(code);
|
|
88
|
+
exitWhenWritten(gatedExit(code));
|
|
83
89
|
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* THE `mason` COMMAND'S ENTRY (the published `bin`, the image's `mason`, the run src/cli/background.ts starts): sets the
|
|
4
|
+
* pool's size, then loads the command. CommonJS because on Node 22, which the package allows, loading an ES module
|
|
5
|
+
* reads its files through libuv's pool, so the pool has started with four threads before an ES entry's first line
|
|
6
|
+
* runs: measured on 22.13, where a CommonJS entry alone got 16 threads of 16. Node 24 and 26 read those files
|
|
7
|
+
* synchronously, which is why main.ts's first import is enough in a checkout. Nothing here may use the pool.
|
|
8
|
+
*/
|
|
9
|
+
"use strict";
|
|
10
|
+
require("./pool-size.cjs");
|
|
11
|
+
const fs = require("node:fs");
|
|
12
|
+
const path = require("node:path");
|
|
13
|
+
const { pathToFileURL } = require("node:url");
|
|
14
|
+
|
|
15
|
+
// the .ts in a checkout and the image, the .js in the package (src/lib/runnable.ts, which this file cannot import yet)
|
|
16
|
+
const ts = path.join(__dirname, "main.ts");
|
|
17
|
+
import(pathToFileURL(fs.existsSync(ts) ? ts : path.join(__dirname, "main.js")).href);
|
package/dist/src/cli/mcp.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* `--url`, else `MASON_URL`), so it works the same beside `mason start` and against a `mason serve` elsewhere, and every
|
|
10
10
|
* statement is a row of the request log under `application_name` `mason-mcp/<client>`. Everything it reads comes over
|
|
11
11
|
* that wire: the tables and columns from the catalog (a real Postgres), the rows and how current each copy is from
|
|
12
|
-
* EXPLAIN (FORMAT JSON)'s `Mason.
|
|
12
|
+
* EXPLAIN (FORMAT JSON)'s `Mason.Tables` — the sync's own words, the same line `mason status` prints.
|
|
13
13
|
*
|
|
14
14
|
* STDOUT IS THE PROTOCOL'S: newline-delimited JSON-RPC 2.0 frames and nothing else (`CommandSpec.stdout`: no --json,
|
|
15
15
|
* every failure on stderr); what it says goes to stderr. A tool's failure is Mason's refusal (SQLSTATE, message,
|
|
@@ -29,15 +29,15 @@ import { PgConnection, PgError, pgIdent, pgLiteral, socketErrorText } from "../c
|
|
|
29
29
|
import { qualifiedName } from "../duck/explain.js";
|
|
30
30
|
import { didYouMean } from "../lib/did-you-mean.js";
|
|
31
31
|
/** The URL a client is shown in the help: a user's, as mason user add prints it. */
|
|
32
|
-
const EXAMPLE_URL = "postgres://claude:<password>@127.0.0.1:5433/
|
|
32
|
+
const EXAMPLE_URL = "postgres://claude:<password>@127.0.0.1:5433/mason";
|
|
33
33
|
export const MCP = {
|
|
34
34
|
name: "mcp",
|
|
35
|
-
summary: "an MCP server on stdio, for Claude Code, Claude Desktop and other agents: the
|
|
35
|
+
summary: "an MCP server on stdio, for Claude Code, Claude Desktop and other agents: the synced tables, queries, refusals",
|
|
36
36
|
usage: `usage: mason mcp [--url <postgres://user:password@host:port/database>]
|
|
37
37
|
|
|
38
38
|
An MCP (Model Context Protocol) server on stdin and stdout, for Claude Code, Claude Desktop and any MCP client.
|
|
39
39
|
Its tools say what an agent would otherwise guess: the tables Mason serves, each one's columns, rows and how
|
|
40
|
-
current
|
|
40
|
+
current each is, and each statement's answer with its Postgres types — or Mason's refusal, with what to write
|
|
41
41
|
instead. It connects to the Postgres API as a mason user, by the URL mason user add printed (MASON_URL, else
|
|
42
42
|
--url), so it works beside mason start or against a mason serve elsewhere, and every statement it runs is in
|
|
43
43
|
the request log under application_name mason-mcp/<the client's name>. It serves until the client closes stdin.
|
|
@@ -103,9 +103,9 @@ async function theUrl(given, cwd) {
|
|
|
103
103
|
}
|
|
104
104
|
/** The URL read as a connection: the CLI's own reading (src/cli/preflight.ts parseSource, TLS as the URL's sslmode). */
|
|
105
105
|
function theSource(url, from) {
|
|
106
|
-
const p = parseSource(url);
|
|
106
|
+
const p = parseSource(url, from);
|
|
107
107
|
if ("problem" in p)
|
|
108
|
-
return { message:
|
|
108
|
+
return { message: p.problem, code: "usage", fix: `the URL mason user add printed: ${EXAMPLE_URL}`, next: [HELP_STEP] };
|
|
109
109
|
return p.source;
|
|
110
110
|
}
|
|
111
111
|
/** application_name's client part: the MCP client's own name, in letters, digits, `.`, `_` and `-` (Postgres keeps 63 bytes). */
|
|
@@ -121,7 +121,7 @@ function connect(s, applicationName) {
|
|
|
121
121
|
applicationName, connectTimeoutMs: 10_000, textForms: [] });
|
|
122
122
|
}
|
|
123
123
|
/** Why a connection failed, in the failure shape: nothing listening, a password refused, a database it does not serve. */
|
|
124
|
-
function connectFailure(e, s) {
|
|
124
|
+
export function connectFailure(e, s) {
|
|
125
125
|
const where = `${s.host}:${s.port}`;
|
|
126
126
|
if (e instanceof PgError) {
|
|
127
127
|
const hint = e.fields.H;
|
|
@@ -131,7 +131,9 @@ function connectFailure(e, s) {
|
|
|
131
131
|
next: [["mason user list", "the users, beside the run"], ADD_STEP] };
|
|
132
132
|
}
|
|
133
133
|
return { message: `${where} refused the connection: ${e.fields.M ?? e.message}${e.code ? ` (${e.code})` : ""}`, code: "refused", why: hint ?? e.detail,
|
|
134
|
-
|
|
134
|
+
// a URL from before the name moved is the likely one: the hint is the listener's, and says the right name
|
|
135
|
+
fix: e.code === "3D000" ? "the last part of the URL is the database: change it to the one the hint names, or use the URL mason user add printed"
|
|
136
|
+
: "the URL mason user add printed", next: [HELP_STEP] };
|
|
135
137
|
}
|
|
136
138
|
const reason = e instanceof Error ? socketErrorText(e) : String(e);
|
|
137
139
|
const said = /ECONNREFUSED/.test(reason) ? `nothing is listening on ${where}` : reason.replace(/^connecting to \S+: /, "");
|
|
@@ -224,8 +226,9 @@ export const TOOLS = [
|
|
|
224
226
|
{
|
|
225
227
|
name: "tables",
|
|
226
228
|
title: "The tables Mason serves",
|
|
227
|
-
description: "The tables Mason serves, each
|
|
228
|
-
"and how
|
|
229
|
+
description: "The tables Mason serves, each synced from a table of the application's Postgres database: about how many rows each holds, " +
|
|
230
|
+
"and how it is kept current and when it was last synced (sync: off — read again only when someone runs mason sync — or polled " +
|
|
231
|
+
"every so often, or streamed by logical replication). A table last synced days ago answers as of then: say so with the answer. " +
|
|
229
232
|
"Start here: a statement can read only these, named as listed.",
|
|
230
233
|
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
231
234
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
@@ -233,7 +236,8 @@ export const TOOLS = [
|
|
|
233
236
|
{
|
|
234
237
|
name: "describe",
|
|
235
238
|
title: "One table's columns",
|
|
236
|
-
description: "One table's columns with their Postgres types, its primary key and foreign keys, about how many rows it holds
|
|
239
|
+
description: "One table's columns with their Postgres types, its primary key and foreign keys, about how many rows it holds, how it is kept current " +
|
|
240
|
+
"and when it was last synced.",
|
|
237
241
|
inputSchema: { type: "object", properties: { table: { type: "string", description: "the table as tables lists it (public.orders), or a bare name in public (orders)" } },
|
|
238
242
|
required: ["table"], additionalProperties: false },
|
|
239
243
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
@@ -254,8 +258,9 @@ export const TOOLS = [
|
|
|
254
258
|
{
|
|
255
259
|
name: "explain",
|
|
256
260
|
title: "How Mason answers a statement",
|
|
257
|
-
description: "How Mason answers one statement, as its EXPLAIN prints it: each
|
|
258
|
-
"the SQL Mason runs for it, and the plan. analyze: true runs the statement too and adds each step's rows and time."
|
|
261
|
+
description: "How Mason answers one statement, as its EXPLAIN prints it: each synced table the statement reads with its rows and how current it is, " +
|
|
262
|
+
"the SQL Mason runs for it, and the plan. analyze: true runs the statement too and adds each step's rows and time. " +
|
|
263
|
+
"For a statement that reads a table your role filters: no plan and no row counts, and analyze is refused.",
|
|
259
264
|
inputSchema: { type: "object", properties: {
|
|
260
265
|
sql: { type: "string", description: "one statement, in Postgres's SQL (without EXPLAIN)" },
|
|
261
266
|
analyze: { type: "boolean", default: false, description: "run it, and say each step's rows and time" },
|
|
@@ -269,13 +274,14 @@ export const RESOURCES = [
|
|
|
269
274
|
{ uri: "mason://help/agent", name: "agent", title: "Driving Mason from an agent", description: "mason help agent: the commands in order, their --json shapes, exit codes and failures", mimeType: "text/plain" },
|
|
270
275
|
{ uri: "mason://help/mcp", name: "mcp", title: "mason mcp", description: "mason help mcp: this server's tools, and how to add it to a client", mimeType: "text/plain" },
|
|
271
276
|
];
|
|
272
|
-
const INSTRUCTIONS = "Mason serves read-only
|
|
273
|
-
"Call tables first, describe a table before querying it, and write Postgres's SQL. Each table says how current
|
|
277
|
+
const INSTRUCTIONS = "Mason serves read-only tables synced from an application's Postgres database, for analytical SQL (scans and aggregates). " +
|
|
278
|
+
"Call tables first, describe a table before querying it, and write Postgres's SQL. Each table says how current it is. " +
|
|
274
279
|
"When a statement is refused, its hint says what to write instead.";
|
|
275
280
|
/** The relations Mason serves, as the catalog lists them (a catalog read: no row of the request log). */
|
|
276
281
|
async function relations(pg) {
|
|
277
282
|
const r = await pg.query(`SELECT n.nspname, c.relname, c.relkind FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
278
|
-
WHERE c.relkind IN ('r', 'p', 'v', 'm') AND n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg\\_%'
|
|
283
|
+
WHERE c.relkind IN ('r', 'p', 'v', 'm') AND n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg\\_%'
|
|
284
|
+
AND (pg_catalog.has_table_privilege(c.oid, 'SELECT') OR pg_catalog.has_any_column_privilege(c.oid, 'SELECT')) ORDER BY 1, 2`);
|
|
279
285
|
return r.rows.map(([schema, name, kind]) => ({ schema: schema, name: name, kind: kind === "v" || kind === "m" ? "view" : "table" }));
|
|
280
286
|
}
|
|
281
287
|
class McpServer {
|
|
@@ -494,7 +500,7 @@ class McpServer {
|
|
|
494
500
|
return { tables: all.map((t) => t.kind === "view" ? { table: qualifiedName(t), kind: "view" } : { table: qualifiedName(t), kind: "table", ...copyFacts(copies.get(qualifiedName(t))) }) };
|
|
495
501
|
}
|
|
496
502
|
/**
|
|
497
|
-
* HOW EACH COPY IS KEPT CURRENT, and its rows: EXPLAIN (FORMAT JSON)'s `Mason.
|
|
503
|
+
* HOW EACH COPY IS KEPT CURRENT, and its rows: EXPLAIN (FORMAT JSON)'s `Mason.Tables`, the sync's own words (what
|
|
498
504
|
* `mason status` prints), read over the wire so a remote mason serve answers as a local run does. One EXPLAIN a table
|
|
499
505
|
* (a union of them all failed whole for one table it could not plan, and left a refusal in the request log the agent
|
|
500
506
|
* never sent), several at once on connections of this call's. In a run the catalog lists only the tables copied
|
|
@@ -523,7 +529,7 @@ class McpServer {
|
|
|
523
529
|
catch (e) {
|
|
524
530
|
throw new BadAnswer(`EXPLAIN (FORMAT JSON) of ${key} is not JSON (${e.message}): ${text.slice(0, 200)}`);
|
|
525
531
|
}
|
|
526
|
-
const copy = doc[0]?.Mason?.
|
|
532
|
+
const copy = doc[0]?.Mason?.Tables?.find((c) => c.Table === key);
|
|
527
533
|
if (copy !== undefined)
|
|
528
534
|
out.set(key, copy);
|
|
529
535
|
};
|
|
@@ -556,7 +562,8 @@ class McpServer {
|
|
|
556
562
|
const rel = `${pgLiteral(qualifiedName(found))}::regclass`;
|
|
557
563
|
// pg_catalog.format_type, written out: these are the catalog's own OIDs (mason_wire_type names the wire's)
|
|
558
564
|
const columns = await pg.query(`SELECT a.attname, pg_catalog.format_type(a.atttypid, a.atttypmod), a.attnotnull, pg_catalog.col_description(a.attrelid, a.attnum)
|
|
559
|
-
FROM pg_catalog.pg_attribute a WHERE a.attrelid = ${rel} AND a.attnum > 0 AND NOT a.attisdropped
|
|
565
|
+
FROM pg_catalog.pg_attribute a WHERE a.attrelid = ${rel} AND a.attnum > 0 AND NOT a.attisdropped
|
|
566
|
+
AND pg_catalog.has_column_privilege(a.attrelid, a.attnum, 'SELECT') ORDER BY a.attnum`);
|
|
560
567
|
const key = await pg.query(`SELECT a.attname FROM pg_catalog.pg_index i JOIN pg_catalog.pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY (i.indkey)
|
|
561
568
|
WHERE i.indrelid = ${rel} AND i.indisprimary ORDER BY array_position(i.indkey::int2[], a.attnum)`);
|
|
562
569
|
const foreign = await pg.query(`SELECT pg_catalog.pg_get_constraintdef(oid) FROM pg_catalog.pg_constraint WHERE conrelid = ${rel} AND contype = 'f' ORDER BY conname`);
|
|
@@ -666,13 +673,15 @@ class ToolAnswerError extends ToolError {
|
|
|
666
673
|
}
|
|
667
674
|
/** A JSON-RPC error answer. */
|
|
668
675
|
function rpcError(id, code, message) { return { jsonrpc: "2.0", id, error: { code, message } }; }
|
|
669
|
-
/** A copy's facts as the tools say them: about how many rows, how it is kept current,
|
|
676
|
+
/** A copy's facts as the tools say them: about how many rows, how it is kept current, what keeps it current now, and —
|
|
677
|
+
* polled or off — its sync and when it was last synced (a copy's age is never hidden: issue 33). */
|
|
670
678
|
function copyFacts(c) {
|
|
671
679
|
if (c === undefined)
|
|
672
|
-
return { rows_about: null, kept: "how it is kept current is unknown: Mason has
|
|
680
|
+
return { rows_about: null, kept: "how it is kept current is unknown: Mason has not synced it" };
|
|
673
681
|
if ("error" in c)
|
|
674
682
|
return { rows_about: null, kept: `how it is kept current could not be read: ${c.error}` };
|
|
675
|
-
return { rows_about: c["Estimated Rows"], kept: c["Kept Current"], kept_current_by: c["Kept Current By"]
|
|
683
|
+
return { rows_about: c["Estimated Rows"] ?? null, kept: c["Kept Current"], kept_current_by: c["Kept Current By"],
|
|
684
|
+
...(c.Sync !== undefined ? { sync: c.Sync, last_synced: c["Last Synced"] ?? null } : {}) };
|
|
676
685
|
}
|
|
677
686
|
/** A refusal as the model reads it: Postgres's fields, as psql prints them, and the same as structured content.
|
|
678
687
|
* `shift`: the characters this server put before the statement (EXPLAIN's), taken off its position. */
|