@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,96 @@
|
|
|
1
|
+
import { newSharePassword, shareSecret } from "./share-run.js";
|
|
2
|
+
import { probeShare, startTunnel, tunnelPlan } from "./tunnel.js";
|
|
3
|
+
/** How often the page beats, and how long a share waits for a beat before it ends (the header says why each). */
|
|
4
|
+
export const BEAT_MS = 15_000;
|
|
5
|
+
export const BOUND_MS = 120_000;
|
|
6
|
+
/** How long the tunnel has to give its address: `mason share`'s default. */
|
|
7
|
+
const TIMEOUT_MS = 30_000;
|
|
8
|
+
/** A tunnel's failure in the page's words: what to do there, never a flag of the command's. */
|
|
9
|
+
function pageFailure(f, plan) {
|
|
10
|
+
if (f.code === "not_found") {
|
|
11
|
+
return { message: `${plan.bin} is not on this machine, and Connect from anywhere needs it`, code: "not_found", why: "the temporary address is a tunnel opened over ssh",
|
|
12
|
+
fix: `install ${plan.install}, then Connect from anywhere again`, next: [["mason doctor", "checks this machine"]] };
|
|
13
|
+
}
|
|
14
|
+
return { message: f.message, code: f.code, ...(f.why !== undefined ? { why: f.why } : {}),
|
|
15
|
+
fix: f.message.startsWith("the tunnel gave no address") ? "the tunnel's service answered in a way this Mason cannot read: nothing was left open; mason share, in a terminal, offers another tunnel"
|
|
16
|
+
: `${f.fix ?? "is this machine online? Port 443 outbound is all it needs"}; nothing was left open`,
|
|
17
|
+
next: [["mason logs", "the run's log"]] };
|
|
18
|
+
}
|
|
19
|
+
export class PageShares {
|
|
20
|
+
deps;
|
|
21
|
+
/** the page's share open now: its user, the tunnel once up, and the timer its beats reset */
|
|
22
|
+
current = null;
|
|
23
|
+
constructor(deps) { this.deps = deps; }
|
|
24
|
+
get bound() { return this.deps.boundMs ?? BOUND_MS; }
|
|
25
|
+
/** The roles the page offers before it asks (`RunShares.choices`). */
|
|
26
|
+
choices() { return this.deps.shares.choices(); }
|
|
27
|
+
/** Opens the page's share: the login, the tunnel, the probe; refused, the failure, and nothing left open. */
|
|
28
|
+
async open(o) {
|
|
29
|
+
const { shares } = this.deps;
|
|
30
|
+
const password = newSharePassword();
|
|
31
|
+
const opened = await shares.open({ secret: shareSecret(password), role: o.role, from: "page" });
|
|
32
|
+
if ("failure" in opened)
|
|
33
|
+
return opened;
|
|
34
|
+
const user = opened.user;
|
|
35
|
+
const mine = { user, tunnel: null, timer: this.lapse(user) };
|
|
36
|
+
this.current = mine;
|
|
37
|
+
// however it ends (Stop sharing, no beat, its tunnel, the run stopping), the tunnel's client goes with it
|
|
38
|
+
shares.onEnd(user, () => { clearTimeout(mine.timer); mine.tunnel?.stop(); if (this.current === mine)
|
|
39
|
+
this.current = null; });
|
|
40
|
+
const plan = tunnelPlan("pinggy", { port: opened.port, home: this.deps.home, token: null });
|
|
41
|
+
const t = await (this.deps.start ?? startTunnel)(plan, { timeoutMs: this.deps.timeoutMs ?? TIMEOUT_MS, onPid: (pid) => { shares.update(user, { pid, via: plan.via, state: "opening" }); } });
|
|
42
|
+
if ("message" in t) {
|
|
43
|
+
await shares.close(user, "its tunnel did not open");
|
|
44
|
+
return { failure: pageFailure(t, plan) };
|
|
45
|
+
}
|
|
46
|
+
if (this.current !== mine) {
|
|
47
|
+
t.stop();
|
|
48
|
+
return { failure: this.endedWhileOpening(user) };
|
|
49
|
+
}
|
|
50
|
+
mine.tunnel = t;
|
|
51
|
+
const probe = await (this.deps.probe ?? probeShare)(t, opened.fingerprint);
|
|
52
|
+
if (this.current !== mine) {
|
|
53
|
+
t.stop();
|
|
54
|
+
return { failure: this.endedWhileOpening(user) };
|
|
55
|
+
}
|
|
56
|
+
if (probe === "other") {
|
|
57
|
+
await shares.close(user, "its address answered with another certificate");
|
|
58
|
+
return { failure: { message: `${t.host}:${t.port} is not this share: something else answers there`, code: "failed", why: "the tunnel gave that address, and the certificate there is not this share's",
|
|
59
|
+
fix: "nothing was left open. Do not connect a tool to that address", next: [["mason logs", "the run's log"]] } };
|
|
60
|
+
}
|
|
61
|
+
shares.update(user, { host: t.host, port: t.port, endsAt: t.endsAt, state: "up", pid: t.pid, via: plan.via });
|
|
62
|
+
void t.ended.then((why) => shares.close(user, `its tunnel closed (${why})`)).catch((e) => this.deps.log(`share: closing ${user} after its tunnel: ${e.message}`));
|
|
63
|
+
this.deps.log(`share: ${user} opened from the web page, at ${t.host}:${t.port}`);
|
|
64
|
+
return { user, host: t.host, port: t.port, database: opened.database, password, sslmode: "require", fingerprint: opened.fingerprint, role: opened.role, reads: opened.reads,
|
|
65
|
+
openedAt: new Date().toISOString(), endsAt: t.endsAt, verified: probe === "same", beatMs: BEAT_MS, boundMs: this.bound };
|
|
66
|
+
}
|
|
67
|
+
endedWhileOpening(user) {
|
|
68
|
+
const why = this.deps.shares.ended?.user === user ? this.deps.shares.ended.why : "it was stopped";
|
|
69
|
+
return { message: `the share ended while its tunnel opened: ${why}`, code: "failed", fix: "nothing was left open: Connect from anywhere again" };
|
|
70
|
+
}
|
|
71
|
+
/** The timer a beat resets: past the bound with no beat, the share ends. */
|
|
72
|
+
lapse(user) {
|
|
73
|
+
const t = setTimeout(() => {
|
|
74
|
+
void this.deps.shares.close(user, `the web page stopped checking in for ${Math.round(this.bound / 1000)} s (closed, asleep or gone)`)
|
|
75
|
+
.catch((e) => this.deps.log(`share: closing ${user}: ${e.message}`));
|
|
76
|
+
}, this.bound);
|
|
77
|
+
t.unref();
|
|
78
|
+
return t;
|
|
79
|
+
}
|
|
80
|
+
/** A beat from the page: true when `user` is the page's share and still open. */
|
|
81
|
+
beat(user) {
|
|
82
|
+
const c = this.current;
|
|
83
|
+
if (c === null || c.user !== user || this.deps.shares.fromOf(user) !== "page")
|
|
84
|
+
return false;
|
|
85
|
+
clearTimeout(c.timer);
|
|
86
|
+
c.timer = this.lapse(user);
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
/** Stop sharing: the page's share ended now. false: no such share of the page's is open. */
|
|
90
|
+
async stop(user) {
|
|
91
|
+
if (this.deps.shares.fromOf(user) !== "page")
|
|
92
|
+
return false;
|
|
93
|
+
await this.deps.shares.close(user, "Stop sharing, on the web page");
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
}
|
package/dist/src/cli/pg-tls.js
CHANGED
|
@@ -31,7 +31,7 @@ export function runListenerSettings(shell, o) {
|
|
|
31
31
|
fix: "drop --self-signed-tls to serve your certificate, or unset MASON_PG_TLS_CERT and MASON_PG_TLS_KEY to serve a self-signed one" };
|
|
32
32
|
}
|
|
33
33
|
const s = pgListenerSettings({
|
|
34
|
-
MASON_PG_HOST: shell.MASON_PG_HOST, MASON_PG_PORT: String(o.port), MASON_PG_PLAIN: shell.MASON_PG_PLAIN,
|
|
34
|
+
MASON_PG_HOST: shell.MASON_PG_HOST, MASON_PG_PORT: String(o.port), MASON_PG_PLAIN: shell.MASON_PG_PLAIN, MASON_PG_PROXY_FROM: shell.MASON_PG_PROXY_FROM,
|
|
35
35
|
MASON_PG_TLS_CERT: o.selfSigned ? path.join(o.home, SELF_SIGNED_FILES.cert) : shell.MASON_PG_TLS_CERT,
|
|
36
36
|
MASON_PG_TLS_KEY: o.selfSigned ? path.join(o.home, SELF_SIGNED_FILES.key) : shell.MASON_PG_TLS_KEY,
|
|
37
37
|
}, "127.0.0.1");
|
package/dist/src/cli/pin.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import * as fs from "node:fs";
|
|
8
8
|
import * as path from "node:path";
|
|
9
9
|
import { parseVersion, pinFileText, projectPinProblem, PIN_FILE } from "../lib/project-version.js";
|
|
10
|
+
import { projectWriteRefusal } from "./project.js";
|
|
10
11
|
/** Why this mason command may not run the project in `folder`, or null (a checkout's `dev` checks nothing). */
|
|
11
12
|
export function commandPinProblem(folder, cliVersion) {
|
|
12
13
|
return projectPinProblem(folder, cliVersion, { what: "this mason command",
|
|
@@ -23,11 +24,14 @@ export function cliPinProblem(folder, cliVersion) {
|
|
|
23
24
|
}
|
|
24
25
|
/**
|
|
25
26
|
* Write `folder`'s mason.yml with this command's version when it has none; the file written, or null when there was one
|
|
26
|
-
* already (or this is a checkout's `dev`, which has no version to write)
|
|
27
|
+
* already (or this is a checkout's `dev`, which has no version to write), or when the project's files are read-only here
|
|
28
|
+
* (src/cli/project.ts `projectWriteRefusal`, asked of `root`, the project's: a deployed project's pin is the one committed).
|
|
27
29
|
*/
|
|
28
|
-
export function ensurePin(folder, cliVersion) {
|
|
30
|
+
export function ensurePin(folder, cliVersion, root, env = process.env) {
|
|
29
31
|
if (parseVersion(cliVersion) === null)
|
|
30
32
|
return null;
|
|
33
|
+
if (projectWriteRefusal(root, env) !== null)
|
|
34
|
+
return null;
|
|
31
35
|
const file = path.join(folder, PIN_FILE);
|
|
32
36
|
if (fs.existsSync(file))
|
|
33
37
|
return null;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE SIZE OF LIBUV'S POOL (issue 88). A statement in flight holds one of its threads for as long as DuckDB computes
|
|
3
|
+
* (docs/FLOWS.md "What waits, and where"), and at Node's four a fifth statement waits behind four heavy ones: 10 s to 50 s
|
|
4
|
+
* for a `SELECT 1`. libuv reads UV_THREADPOOL_SIZE once, when the first task reaches the pool, so this must run before
|
|
5
|
+
* anything uses it: CommonJS, loaded synchronously, by src/cli/mason.cjs and as main.ts's FIRST import. Nothing here may
|
|
6
|
+
* use the pool: every read is synchronous.
|
|
7
|
+
*
|
|
8
|
+
* WHY 32, MEASURED AND NOT DERIVED (docs/DESIGN.md "The pool"): Mason admits no fixed number of running statements. A
|
|
9
|
+
* session runs one at a time and CANCEL_SLOTS allows 4,096 sessions, past the 1,024 threads libuv allows, so no pool
|
|
10
|
+
* sits above that limit. The 1,024 itself is refused: libuv starts every thread at once and ABORTS the process with no
|
|
11
|
+
* message when one cannot be made (exit 133 under `--pids-limit=300`). 16 fixed five statements and 32 fixed eight; past
|
|
12
|
+
* eight there is no data, and the old queue was the only limit on how much memory statements take at once.
|
|
13
|
+
*
|
|
14
|
+
* A LIMIT ON THREADS IS READ FIRST, because that abort under a platform's restart is a silent loop. Below what Mason
|
|
15
|
+
* needs the size is lowered to fit and said; an operator's own size that does not fit refuses the start, as an invalid
|
|
16
|
+
* one does (`abc` would run 1 thread, `-1` 1,024). A size of 4 or less is never refused: it is Node's own, and what a
|
|
17
|
+
* run inherits from the `mason start` that lowered it.
|
|
18
|
+
*
|
|
19
|
+
* ON WINDOWS `process.env` never reaches libuv's getenv, so what this sets does nothing and the pool stays at 4:
|
|
20
|
+
* UV_THREADPOOL_SIZE must be set in the shell before `mason`.
|
|
21
|
+
*/
|
|
22
|
+
"use strict";
|
|
23
|
+
const fs = require("node:fs");
|
|
24
|
+
const os = require("node:os");
|
|
25
|
+
|
|
26
|
+
const POOL_THREADS = 32;
|
|
27
|
+
const LIBUV_MAX = 1024;
|
|
28
|
+
/** Node's own size: never lowered past it */
|
|
29
|
+
const FLOOR = 4;
|
|
30
|
+
const CGROUP_LIMITS = ["/sys/fs/cgroup/pids.max", "/sys/fs/cgroup/pids/pids.max"];
|
|
31
|
+
const PROCESS_LIMITS = "/proc/self/limits";
|
|
32
|
+
|
|
33
|
+
/** A file's text; null when this machine has no such file, or it cannot be read: then it says no limit. */
|
|
34
|
+
function read(file) {
|
|
35
|
+
try { return fs.readFileSync(file, "utf8"); }
|
|
36
|
+
catch { return null; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The lowest numeric limit on threads the files say; null when none does (`max`, `unlimited`, not Linux). */
|
|
40
|
+
function threadLimit(cgroupFiles, processLimits) {
|
|
41
|
+
const said = cgroupFiles.map((f) => /^\s*(\d+)\s*$/.exec(read(f) ?? "")?.[1]);
|
|
42
|
+
said.push(/^Max processes\s+(\d+)/m.exec(read(processLimits) ?? "")?.[1]);
|
|
43
|
+
const numbers = said.filter((n) => n !== undefined).map(Number);
|
|
44
|
+
return numbers.length === 0 ? null : Math.min(...numbers);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Sets UV_THREADPOOL_SIZE in `o.env` and returns the size, or refuses by `o.refuse`. Everything it reads is a parameter,
|
|
49
|
+
* for tests/cli-pool-size.test.ts. `o.announce`: say a lowered size (the commands that start a run; any other would
|
|
50
|
+
* repeat it on every call).
|
|
51
|
+
*/
|
|
52
|
+
function sizePool(o = {}) {
|
|
53
|
+
const env = o.env ?? process.env;
|
|
54
|
+
const platform = o.platform ?? process.platform;
|
|
55
|
+
const cpus = o.cpus ?? os.availableParallelism();
|
|
56
|
+
const say = o.say ?? ((line) => process.stderr.write(`${line}\n`));
|
|
57
|
+
const refuse = o.refuse ?? ((line) => { say(line); process.exit(1); });
|
|
58
|
+
const limit = platform !== "linux" ? null : threadLimit(o.cgroupFiles ?? CGROUP_LIMITS, o.processLimits ?? PROCESS_LIMITS);
|
|
59
|
+
/** the threads of a run with a pool of `size`: its other threads are about 20 plus five a CPU (read off real runs) */
|
|
60
|
+
const needs = (size) => size + 20 + 5 * cpus;
|
|
61
|
+
const fitting = limit === null ? POOL_THREADS : Math.min(POOL_THREADS, Math.max(FLOOR, limit - needs(0)));
|
|
62
|
+
|
|
63
|
+
const own = env.UV_THREADPOOL_SIZE;
|
|
64
|
+
if (own) {
|
|
65
|
+
if (!/^[1-9]\d*$/.test(own) || Number(own) > LIBUV_MAX) {
|
|
66
|
+
refuse(`UV_THREADPOOL_SIZE=${JSON.stringify(own)} is not a number of threads from 1 to ${LIBUV_MAX}: set one, or unset it for Mason's ${POOL_THREADS}`);
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
const size = Number(own);
|
|
70
|
+
if (limit !== null && size > FLOOR && limit < needs(size)) {
|
|
71
|
+
refuse(`UV_THREADPOOL_SIZE=${own} does not fit: this machine allows ${limit} threads, Mason would need about ${needs(size)}, and Node exits with no message past the limit: set UV_THREADPOOL_SIZE=${fitting}`);
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
return size;
|
|
75
|
+
}
|
|
76
|
+
if (fitting < POOL_THREADS && o.announce === true) {
|
|
77
|
+
say(`this machine allows ${limit} threads and Mason wants about ${needs(POOL_THREADS)}, so it uses a pool of ${fitting}: set UV_THREADPOOL_SIZE=${fitting} to choose it, or another that fits`);
|
|
78
|
+
}
|
|
79
|
+
env.UV_THREADPOOL_SIZE = String(fitting);
|
|
80
|
+
return fitting;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
sizePool({ announce: ["start", "serve"].includes(process.argv[2] ?? "") });
|
|
84
|
+
|
|
85
|
+
module.exports = { sizePool, POOL_THREADS };
|
package/dist/src/cli/ports.js
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import * as net from "node:net";
|
|
13
13
|
import { DEFAULT_PG_PORT, pgListenerAddress } from "../api/postgres/listener.js";
|
|
14
|
+
/** The port the commands and the web page reach a run on, unless a flag or a setting says another. */
|
|
15
|
+
export const DEFAULT_UI_PORT = 4039;
|
|
14
16
|
export const PROBE_ADDRESSES = ["127.0.0.1", "0.0.0.0", "::", "::1"];
|
|
15
17
|
/** The address that holds `port`, or null when every address binds. */
|
|
16
18
|
export async function portHolder(port) {
|
|
@@ -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. */
|
|
@@ -115,7 +162,7 @@ export function theProject(cwd) {
|
|
|
115
162
|
if (p)
|
|
116
163
|
return { project: p };
|
|
117
164
|
return { problem: "no Mason project here or above", code: "no_project", fix: "cd into a project's folder, or make this folder one (mason init demo: a demo database, nothing to install)",
|
|
118
|
-
next: [["mason init", "make this folder a project (MASON_DATABASE_URL or DATABASE_URL from .env, or mason init
|
|
165
|
+
next: [["mason init", "make this folder a project (MASON_DATABASE_URL or DATABASE_URL from .env, or its URL on stdin: mason init -)"]] };
|
|
119
166
|
}
|
|
120
167
|
/** Whether `root`'s .gitignore already ignores `.mason/`. */
|
|
121
168
|
export function ignoresStateDir(gitignore) {
|
|
@@ -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)
|