@mason-data/cli 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -8
- package/dist/src/api/error.js +4 -0
- 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 +129 -0
- package/dist/src/api/postgres/schema.js +6 -0
- package/dist/src/api/postgres/server.js +527 -103
- 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 +337 -15
- package/dist/src/cli/commands.js +458 -169
- package/dist/src/cli/credentials.js +182 -0
- package/dist/src/cli/demo.js +2 -2
- package/dist/src/cli/deploy.js +680 -0
- package/dist/src/cli/dev.js +148 -27
- package/dist/src/cli/doctor.js +122 -36
- package/dist/src/cli/generated-files.js +143 -0
- package/dist/src/cli/guides.js +116 -16
- package/dist/src/cli/hosted.js +850 -0
- package/dist/src/cli/init.js +154 -33
- package/dist/src/cli/login.js +292 -0
- package/dist/src/cli/machine.js +125 -40
- package/dist/src/cli/main.js +59 -10
- package/dist/src/cli/mason.cjs +17 -0
- package/dist/src/cli/mcp.js +17 -14
- 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 +100 -1
- package/dist/src/cli/readiness.js +42 -6
- package/dist/src/cli/registry.js +89 -36
- package/dist/src/cli/replica-hosted.js +1324 -0
- package/dist/src/cli/replica.js +114 -0
- package/dist/src/cli/replication-mode.js +5 -0
- package/dist/src/cli/request-filter.js +16 -6
- package/dist/src/cli/roles.js +2 -1
- package/dist/src/cli/run-server.js +179 -9
- package/dist/src/cli/secret-input.js +193 -0
- package/dist/src/cli/security.js +57 -10
- package/dist/src/cli/serve.js +65 -9
- package/dist/src/cli/share-run.js +51 -8
- package/dist/src/cli/share.js +23 -18
- package/dist/src/cli/source-edit.js +10 -4
- package/dist/src/cli/source-role.js +343 -0
- package/dist/src/cli/sources.js +50 -23
- package/dist/src/cli/style.js +16 -1
- package/dist/src/cli/supervise.js +15 -23
- package/dist/src/cli/sync.js +17 -8
- package/dist/src/cli/token-hosted.js +343 -0
- package/dist/src/cli/tunnel.js +1 -1
- package/dist/src/cli/ui-hosted.js +163 -0
- package/dist/src/cli/ui-ssh.js +460 -0
- package/dist/src/cli/ui.js +162 -17
- package/dist/src/cli/usage.js +19 -15
- package/dist/src/cli/user-hosted.js +364 -0
- package/dist/src/cli/user.js +275 -105
- 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 +110 -9
- package/dist/src/duck/verified.js +159 -2
- package/dist/src/duck/views.js +4 -3
- package/dist/src/lib/cidr.js +177 -0
- 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 +84 -19
- 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
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHERE A LOGIN TO A HOSTED MASON SERVICE IS KEPT (`mason login`, `mason logout`; docs/HOSTED-API.md "Where the login
|
|
3
|
+
* is kept"): one file of the person's, never a project's — `$XDG_CONFIG_HOME/mason/credentials.json`, else
|
|
4
|
+
* `~/.config/mason/credentials.json` — 0600 in a 0700 folder, written beside and renamed, so a reader never meets half
|
|
5
|
+
* a file. It is keyed by the service's base URL, so a login to a service on this machine never replaces another's:
|
|
6
|
+
*
|
|
7
|
+
* { "version": 1, "logins": { "<base url>": { "token", "email", "savedAt" } } }
|
|
8
|
+
*
|
|
9
|
+
* IT HOLDS A SECRET, SO IT IS CHECKED BEFORE IT IS READ OR WRITTEN: a folder or file another user may read, or that is
|
|
10
|
+
* another user's, is refused with the exact `chmod` to run; the file is opened without following a link and checked as
|
|
11
|
+
* opened; one that cannot be parsed is refused with its path and never overwritten. Nothing of its content is ever
|
|
12
|
+
* repeated in a failure (a JSON parser's error quotes the text, which holds the token). On Windows the mode bits say
|
|
13
|
+
* nothing of who may read a file (its ACLs do), so the checks are POSIX's; the file is under the user's own profile.
|
|
14
|
+
*
|
|
15
|
+
* `MASON_TOKEN` in the environment comes before the file (CI): `environmentToken`.
|
|
16
|
+
*/
|
|
17
|
+
import * as fs from "node:fs";
|
|
18
|
+
import * as os from "node:os";
|
|
19
|
+
import * as path from "node:path";
|
|
20
|
+
import { randomBytes } from "node:crypto";
|
|
21
|
+
import { shellWord } from "../lib/shell-word.js";
|
|
22
|
+
export const TOKEN_ENV = "MASON_TOKEN";
|
|
23
|
+
/** The token the environment carries, which every command uses before a saved login; null when unset or empty. */
|
|
24
|
+
export function environmentToken(env = process.env) {
|
|
25
|
+
const t = env[TOKEN_ENV];
|
|
26
|
+
return t ? t : null;
|
|
27
|
+
}
|
|
28
|
+
/** The folder and file a login is kept in (XDG's rule: a relative XDG_CONFIG_HOME is ignored). */
|
|
29
|
+
export function credentialsPath(env = process.env) {
|
|
30
|
+
const xdg = env.XDG_CONFIG_HOME;
|
|
31
|
+
const config = xdg && path.isAbsolute(xdg) ? xdg : path.join(os.homedir(), ".config");
|
|
32
|
+
const dir = path.join(config, "mason");
|
|
33
|
+
return { dir, file: path.join(dir, "credentials.json") };
|
|
34
|
+
}
|
|
35
|
+
const POSIX = process.platform !== "win32";
|
|
36
|
+
const NEXT_LOGIN = [["mason login", "sign in again, once it is fixed"]];
|
|
37
|
+
/** Why a folder or file of ours is not private (another may read it, or it is another's), with the chmod; else null. */
|
|
38
|
+
function notPrivate(where, st, kind) {
|
|
39
|
+
if (!POSIX)
|
|
40
|
+
return null;
|
|
41
|
+
const mode = kind === "folder" ? "700" : "600";
|
|
42
|
+
const uid = process.getuid?.();
|
|
43
|
+
if (uid !== undefined && st.uid !== uid) {
|
|
44
|
+
return { message: `${where} belongs to another user of this machine`, code: "refused", why: "it holds the login of a hosted Mason service, which must be yours alone",
|
|
45
|
+
fix: `remove it (or set XDG_CONFIG_HOME to a folder of your own), then mason login`, next: NEXT_LOGIN };
|
|
46
|
+
}
|
|
47
|
+
if ((st.mode & 0o077) !== 0) {
|
|
48
|
+
return { message: `${where} can be read by other users of this machine (mode ${(st.mode & 0o777).toString(8).padStart(3, "0")})`, code: "refused",
|
|
49
|
+
why: "it holds the login of a hosted Mason service: anyone who reads it can act as you there",
|
|
50
|
+
fix: `chmod ${mode} ${shellWord(where)}`, next: [[`chmod ${mode} ${shellWord(where)}`, `make it yours alone (${mode})`]] };
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
/** A file that cannot be used as it is: refused with its path, and never overwritten. */
|
|
55
|
+
const unreadable = (file, why) => ({ message: `${file} cannot be read as Mason's logins`, code: "refused", why,
|
|
56
|
+
fix: `move it aside (mv ${shellWord(file)} ${shellWord(`${file}.bad`)}), then mason login: nothing was changed`, next: NEXT_LOGIN });
|
|
57
|
+
/** The folder, checked; null when it does not exist yet. */
|
|
58
|
+
function checkedDir(dir) {
|
|
59
|
+
let st;
|
|
60
|
+
try {
|
|
61
|
+
st = fs.statSync(dir);
|
|
62
|
+
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
if (e.code === "ENOENT")
|
|
65
|
+
return "absent";
|
|
66
|
+
return { message: `${dir} cannot be read`, code: "refused", why: e.message, next: NEXT_LOGIN };
|
|
67
|
+
}
|
|
68
|
+
if (!st.isDirectory())
|
|
69
|
+
return { message: `${dir} is not a folder`, code: "refused", why: "Mason keeps a hosted service's login in a folder of that name", fix: "move it aside, then mason login", next: NEXT_LOGIN };
|
|
70
|
+
return notPrivate(dir, st, "folder");
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* THE LOGINS SAVED ON THIS MACHINE: none when the file is not there, else what it holds — or a Failure when the folder
|
|
74
|
+
* or the file is not private, or the file cannot be parsed.
|
|
75
|
+
*/
|
|
76
|
+
export function readLogins(env = process.env) {
|
|
77
|
+
const { dir, file } = credentialsPath(env);
|
|
78
|
+
const d = checkedDir(dir);
|
|
79
|
+
if (d === "absent")
|
|
80
|
+
return { logins: { version: 1, logins: {} }, file };
|
|
81
|
+
if (d)
|
|
82
|
+
return d;
|
|
83
|
+
let fd;
|
|
84
|
+
try {
|
|
85
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
|
|
86
|
+
}
|
|
87
|
+
catch (e) {
|
|
88
|
+
const code = e.code;
|
|
89
|
+
if (code === "ENOENT")
|
|
90
|
+
return { logins: { version: 1, logins: {} }, file };
|
|
91
|
+
if (code === "ELOOP")
|
|
92
|
+
return unreadable(file, "it is a symbolic link, which Mason does not follow to a login");
|
|
93
|
+
return unreadable(file, e.message);
|
|
94
|
+
}
|
|
95
|
+
let text;
|
|
96
|
+
try {
|
|
97
|
+
const st = fs.fstatSync(fd);
|
|
98
|
+
if (!st.isFile())
|
|
99
|
+
return unreadable(file, "it is not a file");
|
|
100
|
+
const loose = notPrivate(file, st, "file");
|
|
101
|
+
if (loose)
|
|
102
|
+
return loose;
|
|
103
|
+
if (st.size > 1024 * 1024)
|
|
104
|
+
return unreadable(file, "it is larger than 1 MiB");
|
|
105
|
+
text = fs.readFileSync(fd, "utf8");
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
fs.closeSync(fd);
|
|
109
|
+
}
|
|
110
|
+
const parsed = parseLogins(text);
|
|
111
|
+
return typeof parsed === "string" ? unreadable(file, parsed) : { logins: parsed, file };
|
|
112
|
+
}
|
|
113
|
+
/** The file's text as logins, or why it is not (never quoting it: it holds tokens). */
|
|
114
|
+
export function parseLogins(text) {
|
|
115
|
+
let v;
|
|
116
|
+
try {
|
|
117
|
+
v = JSON.parse(text);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return "it is not JSON";
|
|
121
|
+
}
|
|
122
|
+
if (typeof v !== "object" || v === null || Array.isArray(v))
|
|
123
|
+
return "it is not a JSON object";
|
|
124
|
+
const o = v;
|
|
125
|
+
if (o.version !== 1)
|
|
126
|
+
return `its version is ${typeof o.version === "number" ? o.version : "missing"}, where this Mason reads 1`;
|
|
127
|
+
if (typeof o.logins !== "object" || o.logins === null || Array.isArray(o.logins))
|
|
128
|
+
return "it has no logins object";
|
|
129
|
+
const logins = {};
|
|
130
|
+
for (const [base, l] of Object.entries(o.logins)) {
|
|
131
|
+
const e = l;
|
|
132
|
+
if (typeof e !== "object" || e === null || typeof e.token !== "string" || !e.token || typeof e.email !== "string" || typeof e.savedAt !== "string") {
|
|
133
|
+
return `its login for ${JSON.stringify(base.slice(0, 200))} is not { token, email, savedAt }`;
|
|
134
|
+
}
|
|
135
|
+
logins[base] = { token: e.token, email: e.email, savedAt: e.savedAt };
|
|
136
|
+
}
|
|
137
|
+
return { version: 1, logins };
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* WRITE THE LOGINS: the folder made 0700 when it is not there (and checked when it is), the file written 0600 beside
|
|
141
|
+
* its name and renamed over it, so a reader meets the old file or the new one, never half of either. A Failure when
|
|
142
|
+
* any of it could not be done (nothing then replaces the file).
|
|
143
|
+
*/
|
|
144
|
+
export function writeLogins(logins, env = process.env) {
|
|
145
|
+
const { dir, file } = credentialsPath(env);
|
|
146
|
+
let d = checkedDir(dir);
|
|
147
|
+
if (d === "absent") {
|
|
148
|
+
try {
|
|
149
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
150
|
+
}
|
|
151
|
+
catch (e) {
|
|
152
|
+
return { message: `could not make ${dir}`, code: "failed", why: e.message, next: NEXT_LOGIN };
|
|
153
|
+
}
|
|
154
|
+
d = checkedDir(dir);
|
|
155
|
+
}
|
|
156
|
+
if (d)
|
|
157
|
+
return d === "absent" ? { message: `could not make ${dir}`, code: "failed", next: NEXT_LOGIN } : d;
|
|
158
|
+
const tmp = path.join(dir, `.credentials.json.${process.pid}.${randomBytes(6).toString("hex")}`);
|
|
159
|
+
try {
|
|
160
|
+
const fd = fs.openSync(tmp, "wx", 0o600);
|
|
161
|
+
try {
|
|
162
|
+
fs.writeSync(fd, `${JSON.stringify(logins, null, 2)}\n`);
|
|
163
|
+
fs.fsyncSync(fd);
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
fs.closeSync(fd);
|
|
167
|
+
}
|
|
168
|
+
fs.renameSync(tmp, file);
|
|
169
|
+
}
|
|
170
|
+
catch (e) {
|
|
171
|
+
// the half-written file must not stay beside the logins; its own removal failing is said with the write's failure
|
|
172
|
+
let left = "";
|
|
173
|
+
try {
|
|
174
|
+
fs.rmSync(tmp, { force: true });
|
|
175
|
+
}
|
|
176
|
+
catch (r) {
|
|
177
|
+
left = ` (and ${tmp} could not be removed: ${r.message})`;
|
|
178
|
+
}
|
|
179
|
+
return { message: `could not write ${file}`, code: "failed", why: `${e.message}${left}`, next: NEXT_LOGIN };
|
|
180
|
+
}
|
|
181
|
+
return { file };
|
|
182
|
+
}
|
package/dist/src/cli/demo.js
CHANGED
|
@@ -30,7 +30,7 @@ export const DEMO_PORT = 5434;
|
|
|
30
30
|
/** The demo database's tables (the seed's own list), the ones `mason init demo` adds to the sources file and the one it
|
|
31
31
|
* leaves for the person to add, and its URL (src/demo/seed.ts: no PGlite is loaded by it, so `mason init demo` and
|
|
32
32
|
* `demoPort` never load the database). */
|
|
33
|
-
export { DEMO_ADDED_TABLES, DEMO_SYNC, DEMO_TABLES, DEMO_TABLE_TO_ADD, demoUrl } from "../demo/seed.js";
|
|
33
|
+
export { DEMO_ADDED_TABLES, DEMO_GENERATED, DEMO_SYNC, DEMO_TABLES, DEMO_TABLE_TO_ADD, demoUrl } from "../demo/seed.js";
|
|
34
34
|
/** How often the run changes the demo database by default (MASON_DEMO_CHANGE_MS). */
|
|
35
35
|
export const DEMO_CHANGE_MS = 5000;
|
|
36
36
|
const DEMO_CHANGE_MIN_MS = 100;
|
|
@@ -59,7 +59,7 @@ export function demoPort(url, from) {
|
|
|
59
59
|
const wayOut = "mason destroy and mason init demo again in a new folder, or put the URL back";
|
|
60
60
|
const parsed = parseSource(url);
|
|
61
61
|
if ("problem" in parsed)
|
|
62
|
-
return { problem: `${from} was changed: ${parsed.problem}. The demo database is postgres://${DEMO_USER}@127.0.0.1:<port>/${DEMO_DATABASE}; ${wayOut}` };
|
|
62
|
+
return { problem: `${from} was changed: ${parsed.problem.replace(/\.$/, "")}. The demo database is postgres://${DEMO_USER}@127.0.0.1:<port>/${DEMO_DATABASE}; ${wayOut}` };
|
|
63
63
|
const s = parsed.source;
|
|
64
64
|
// loopback alone: the demo database listens on nothing else (0.0.0.0 or a container's host name is not it)
|
|
65
65
|
if (!isLocalHost(s.host) || s.host === "0.0.0.0" || s.host === "host.docker.internal") {
|