@mason-data/cli 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -4
- package/dist/src/api/postgres/catalog-worker.js +49 -2
- package/dist/src/api/postgres/catalog.js +166 -32
- package/dist/src/api/postgres/listener.js +11 -3
- package/dist/src/api/postgres/proxy.js +283 -0
- package/dist/src/api/postgres/schema.js +6 -0
- package/dist/src/api/postgres/server.js +505 -99
- package/dist/src/api/postgres/translate.js +47 -2
- package/dist/src/cli/background.js +18 -19
- package/dist/src/cli/bridge-stdin.js +101 -0
- package/dist/src/cli/command.js +169 -11
- package/dist/src/cli/commands.js +207 -51
- package/dist/src/cli/credentials.js +182 -0
- package/dist/src/cli/demo.js +2 -2
- package/dist/src/cli/deploy.js +574 -0
- package/dist/src/cli/dev.js +148 -27
- package/dist/src/cli/doctor.js +116 -33
- package/dist/src/cli/generated-files.js +143 -0
- package/dist/src/cli/guides.js +97 -6
- package/dist/src/cli/hosted.js +850 -0
- package/dist/src/cli/init.js +47 -11
- package/dist/src/cli/login.js +289 -0
- package/dist/src/cli/machine.js +1 -1
- package/dist/src/cli/main.js +8 -2
- package/dist/src/cli/mason.cjs +17 -0
- package/dist/src/cli/mcp.js +2 -2
- package/dist/src/cli/output-gate.js +176 -0
- package/dist/src/cli/page-share.js +96 -0
- package/dist/src/cli/pg-tls.js +1 -1
- package/dist/src/cli/pin.js +6 -2
- package/dist/src/cli/pool-size.cjs +85 -0
- package/dist/src/cli/ports.js +2 -0
- package/dist/src/cli/preflight.js +130 -39
- package/dist/src/cli/project.js +99 -0
- package/dist/src/cli/readiness.js +42 -6
- package/dist/src/cli/registry.js +12 -4
- package/dist/src/cli/replica-hosted.js +665 -0
- package/dist/src/cli/replica.js +76 -0
- package/dist/src/cli/roles.js +2 -1
- package/dist/src/cli/run-server.js +154 -7
- package/dist/src/cli/security.js +57 -10
- package/dist/src/cli/serve.js +63 -7
- package/dist/src/cli/share-run.js +51 -8
- package/dist/src/cli/share.js +3 -3
- package/dist/src/cli/source-edit.js +10 -4
- package/dist/src/cli/source-role.js +344 -0
- package/dist/src/cli/sources.js +30 -20
- package/dist/src/cli/style.js +2 -1
- package/dist/src/cli/supervise.js +15 -23
- package/dist/src/cli/token-hosted.js +337 -0
- package/dist/src/cli/ui-ssh.js +460 -0
- package/dist/src/cli/ui.js +115 -11
- package/dist/src/cli/user-hosted.js +299 -0
- package/dist/src/cli/user.js +126 -74
- package/dist/src/demo/seed.js +26 -0
- package/dist/src/duck/analyze.js +36 -20
- package/dist/src/duck/collation.js +266 -0
- package/dist/src/duck/constant-key.js +94 -0
- package/dist/src/duck/distinct-key.js +283 -26
- package/dist/src/duck/events.js +39 -8
- package/dist/src/duck/executor-worker.js +2 -2
- package/dist/src/duck/executor.js +6 -5
- package/dist/src/duck/explain.js +4 -0
- package/dist/src/duck/functions.js +448 -49
- package/dist/src/duck/gaps.js +34 -16
- package/dist/src/duck/generated-plan.js +1070 -0
- package/dist/src/duck/generated.js +906 -0
- package/dist/src/duck/grants.js +1 -1
- package/dist/src/duck/held-types.js +64 -0
- package/dist/src/duck/history.js +4 -0
- package/dist/src/duck/jsonb-order.js +9 -12
- package/dist/src/duck/kept-route.js +6 -5
- package/dist/src/duck/kept.js +9 -7
- package/dist/src/duck/late.js +1469 -40
- package/dist/src/duck/libc-bound.js +341 -0
- package/dist/src/duck/libc-order.js +892 -126
- package/dist/src/duck/pg-eval-worker.js +7 -1
- package/dist/src/duck/pg-eval.js +6 -4
- package/dist/src/duck/pgtree.js +774 -23
- package/dist/src/duck/plan.js +275 -30
- package/dist/src/duck/publication.js +14 -0
- package/dist/src/duck/requests.js +9 -5
- package/dist/src/duck/resolved.js +117 -2
- package/dist/src/duck/rewrite.js +4567 -583
- package/dist/src/duck/row-filter.js +12 -6
- package/dist/src/duck/statement.js +19 -0
- package/dist/src/duck/store.js +294 -68
- package/dist/src/duck/stream.js +9 -5
- package/dist/src/duck/sync.js +602 -126
- package/dist/src/duck/token.js +4 -1
- package/dist/src/duck/types.js +63 -7
- package/dist/src/duck/usage.js +6 -4
- package/dist/src/duck/users.js +5 -0
- package/dist/src/duck/verified.js +159 -2
- package/dist/src/duck/views.js +4 -3
- package/dist/src/lib/database-url.js +55 -10
- package/dist/src/lib/instant.js +50 -0
- package/dist/src/lib/log-buffer.js +13 -4
- package/dist/src/lib/log-read.js +196 -0
- package/dist/src/lib/pg-numeric.js +387 -49
- package/dist/src/lib/secret-mask.js +142 -0
- package/dist/src/lib/table-sync.js +24 -9
- package/dist/src/web/app.js +9 -4
- package/dist/src/web/pages/status.js +178 -13
- package/dist/src/web/pages/syncs.js +1 -1
- package/dist/src/web/pages/tables.js +8 -3
- package/dist/src/web/pages/users.js +4 -3
- package/dist/src/web/shared.js +19 -3
- package/package.json +8 -7
|
@@ -55,6 +55,45 @@ function collect(n, kind, out = []) {
|
|
|
55
55
|
}
|
|
56
56
|
return out;
|
|
57
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Whether a statement of no table has a construct that can order text, read off the raw tree: a sort (ORDER BY, a
|
|
60
|
+
* window's, an aggregate's or WITHIN GROUP's), <, >, <=, >= (BETWEEN, ANY / ALL and a subquery's among them),
|
|
61
|
+
* GREATEST / LEAST, min / max, upper / lower / initcap (which map case, issue 406), and a COLLATE (`'a' = 'A' COLLATE mason_ci`). One without (`SELECT 1`, `SELECT version()`, `current_setting(…)`, `'a' = 'A'`) is the
|
|
62
|
+
* catalog's without asking its analysis, which costs a statement several milliseconds (`ordersText`, issue 122): a
|
|
63
|
+
* statement it says may order is asked, so a false "may" costs time, never an answer.
|
|
64
|
+
*/
|
|
65
|
+
export function mayOrderText(n) {
|
|
66
|
+
if (Array.isArray(n))
|
|
67
|
+
return n.some(mayOrderText);
|
|
68
|
+
if (n === null || typeof n !== "object")
|
|
69
|
+
return false;
|
|
70
|
+
const named = (x) => collect(x ?? [], "String").map((s) => String(bodyOf(s).sval ?? ""));
|
|
71
|
+
for (const [k, v] of Object.entries(n)) {
|
|
72
|
+
if ((k === "sortClause" || k === "orderClause" || k === "agg_order") && Array.isArray(v) && v.length > 0)
|
|
73
|
+
return true;
|
|
74
|
+
if (k === "MinMaxExpr")
|
|
75
|
+
return true;
|
|
76
|
+
// a COLLATE names a collation the source declared, whose rules the catalog's copy of it does not hold: a
|
|
77
|
+
// nondeterministic one equates otherwise, and the synced data's plan refuses it by name (`ordersText` asks)
|
|
78
|
+
if (k === "CollateClause")
|
|
79
|
+
return true;
|
|
80
|
+
if ((k === "A_Expr" || k === "SubLink") && v !== null && typeof v === "object") {
|
|
81
|
+
const b = v;
|
|
82
|
+
if (typeof b.kind === "string" && b.kind.includes("BETWEEN"))
|
|
83
|
+
return true;
|
|
84
|
+
if (named(b.name ?? b.operName).some((o) => ORDERING_OPERATORS.has(o)))
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
// min / max order text; upper / lower / initcap map its case, which the catalog does by its own rules (issue 406)
|
|
88
|
+
if (k === "FuncCall" && v !== null && typeof v === "object" && COLLATED_CALLS.has((named(v.funcname).at(-1) ?? "").toLowerCase()))
|
|
89
|
+
return true;
|
|
90
|
+
if (mayOrderText(v))
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
const ORDERING_OPERATORS = new Set(["<", ">", "<=", ">="]);
|
|
96
|
+
const COLLATED_CALLS = new Set(["min", "max", "upper", "lower", "initcap"]);
|
|
58
97
|
const INFORMATION_SCHEMA = "information_schema";
|
|
59
98
|
const PG_CATALOG = "pg_catalog";
|
|
60
99
|
/** Postgres's IsTransactionExitStmt: what a failed block still accepts. `ROLLBACK TO` and `PREPARE TRANSACTION` are among
|
|
@@ -133,6 +172,8 @@ export function duckTranslate(stmt, text) {
|
|
|
133
172
|
const ctes = (s.withClause?.ctes ?? []).map((n) => bodyOf(n));
|
|
134
173
|
const names = new Set(ctes.map((c) => String(c.ctename ?? "")));
|
|
135
174
|
const relations = collect(s, "RangeVar").map((n) => bodyOf(n)).filter((r) => r.schemaname !== undefined || !names.has(String(r.relname ?? "")));
|
|
175
|
+
if (relations.length === 0)
|
|
176
|
+
return mayOrderText(s) ? { kind: "catalog", fromless: { names: pgColumnNames(s) } } : { kind: "catalog" };
|
|
136
177
|
if (relations.every(isCatalogRelation))
|
|
137
178
|
return { kind: "catalog" };
|
|
138
179
|
return { kind: "duckdb", sql: text, names: pgColumnNames(s) };
|
|
@@ -288,8 +329,12 @@ function setStatement(s) {
|
|
|
288
329
|
if (s.kind === "VAR_RESET_ALL")
|
|
289
330
|
return { kind: "set", name: "all", value: null, local: false, tag: "RESET" };
|
|
290
331
|
// SET TRANSACTION is for the transaction alone, whichever way it is spelt; SET SESSION CHARACTERISTICS is the session's
|
|
291
|
-
|
|
292
|
-
|
|
332
|
+
// its value is the isolation level it names, '' for none (`READ ONLY` alone): the listener remembers the level
|
|
333
|
+
if (s.kind === "VAR_SET_MULTI") {
|
|
334
|
+
const level = (s.args ?? []).map((a) => a.DefElem).find((d) => d?.defname === "transaction_isolation");
|
|
335
|
+
const value = level?.arg !== undefined && kindOf(level.arg) === "A_Const" ? constText(bodyOf(level.arg)) : "";
|
|
336
|
+
return { kind: "set", name, value, local: name === "transaction", tag: "SET" };
|
|
337
|
+
}
|
|
293
338
|
if (s.kind !== "VAR_SET_VALUE")
|
|
294
339
|
throw new Unsupported(`SET ${name}: ${String(s.kind)} is not supported`, s.location ?? null);
|
|
295
340
|
const parts = (s.args ?? []).map((a) => {
|
|
@@ -17,6 +17,7 @@ import * as path from "node:path";
|
|
|
17
17
|
import { spawn } from "node:child_process";
|
|
18
18
|
import { fileURLToPath } from "node:url";
|
|
19
19
|
import { runnable } from "../lib/runnable.js";
|
|
20
|
+
import { LogReader, logPath } from "../lib/log-read.js";
|
|
20
21
|
import { READY, STEP_MARK } from "./dev.js";
|
|
21
22
|
import { liveRun } from "./runs.js";
|
|
22
23
|
import { projectSource, startSettingRefusal, theProject } from "./project.js";
|
|
@@ -26,12 +27,13 @@ import { recordRefusal } from "./replication-mode.js";
|
|
|
26
27
|
import { shownPath } from "./sources.js";
|
|
27
28
|
import { endText, failed, jsonText, nextBlock } from "./command.js";
|
|
28
29
|
import { historyDaysSetting } from "../duck/history.js";
|
|
30
|
+
import { memoryProblems } from "../duck/store.js";
|
|
29
31
|
import { DEV } from "./registry.js";
|
|
30
32
|
import { UI } from "./ui.js";
|
|
31
33
|
import { out as styleOut, stepRenderer } from "./style.js";
|
|
32
34
|
import { postgresLink, withoutPlaceholders } from "./pg-url.js";
|
|
33
35
|
const say = (m) => { process.stdout.write(`${m}\n`); };
|
|
34
|
-
const MAIN = path.join(path.dirname(fileURLToPath(import.meta.url)), "
|
|
36
|
+
const MAIN = path.join(path.dirname(fileURLToPath(import.meta.url)), "mason.cjs");
|
|
35
37
|
const SUPERVISE = path.join(path.dirname(fileURLToPath(import.meta.url)), "supervise.ts");
|
|
36
38
|
/** The commands to run next, as `mason start` prints them once the run is up: one a line, what each does beside it
|
|
37
39
|
* (not `mason status | mason logs | mason stop` on one line). */
|
|
@@ -91,6 +93,11 @@ export async function devInBackground(args, o) {
|
|
|
91
93
|
const days = historyDaysSetting(process.env);
|
|
92
94
|
if (typeof days !== "number")
|
|
93
95
|
return failed("start", { code: "refused", ...days }, { json: o.json });
|
|
96
|
+
// MASON_DUCKDB_MEMORY not an amount (src/duck/store.ts memoryProblems), by the run's own check, refused here as in the
|
|
97
|
+
// run, before anything is spawned or opened; a size under the floor only warns, in the run (memoryWarning)
|
|
98
|
+
const memory = memoryProblems(process.env)[0];
|
|
99
|
+
if (memory !== undefined)
|
|
100
|
+
return failed("start", { code: "refused", ...memory }, { json: o.json });
|
|
94
101
|
// what the config records and the file does not say (src/cli/replication-mode.ts), in the run's order: settings first
|
|
95
102
|
const unsaid = file === null ? null : recordRefusal(t.project.config, file, process.env, shown, "mason start");
|
|
96
103
|
if (unsaid !== null)
|
|
@@ -107,12 +114,13 @@ export async function devInBackground(args, o) {
|
|
|
107
114
|
return failed("start", listen, { json: o.json });
|
|
108
115
|
fs.mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
109
116
|
// the run's terminal: its setup URL carries the run's token and it prints the API key, so only the developer reads it
|
|
110
|
-
const logFile =
|
|
117
|
+
const logFile = logPath(home, "start");
|
|
111
118
|
const fd = fs.openSync(logFile, "a", 0o600);
|
|
112
119
|
fs.chmodSync(logFile, 0o600);
|
|
113
120
|
const from = fs.fstatSync(fd).size;
|
|
114
121
|
fs.writeSync(fd, `\n--- mason start ${new Date().toISOString()} ---\n`);
|
|
115
|
-
// the run is started IN the project's root, so it finds the same project
|
|
122
|
+
// the run is started IN the project's root, so it finds the same project. Its env carries the size this process
|
|
123
|
+
// set (src/cli/pool-size.cjs), and the supervisor between them inherits it too: it never uses that many
|
|
116
124
|
const run = [process.execPath, runnable(MAIN), "start", ...args.filter((a) => a !== "--json"), "--attach"];
|
|
117
125
|
const child = spawn(process.execPath, [runnable(SUPERVISE), home, logFile, ...run], { detached: true, stdio: ["ignore", fd, fd], cwd: t.project.root, env: process.env });
|
|
118
126
|
fs.closeSync(fd);
|
|
@@ -129,25 +137,15 @@ export async function devInBackground(args, o) {
|
|
|
129
137
|
line(st.bold(`Starting Mason for ${t.project.config.name}`));
|
|
130
138
|
const steps = stepRenderer({ line, live: !o.json && process.stdout.isTTY ? { write: (x) => { process.stdout.write(x); } } : null, style: st });
|
|
131
139
|
let begun = false;
|
|
132
|
-
|
|
133
|
-
|
|
140
|
+
// the run's terminal from this start on, through the one reader: a line is had whole, masked (src/lib/log-read.ts)
|
|
141
|
+
const reader = new LogReader(logFile, { from });
|
|
134
142
|
let postgres = null;
|
|
135
143
|
const since = []; // the run's lines after its last step: a failure's reasons
|
|
136
144
|
try {
|
|
137
145
|
for (;;) {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const r = fs.openSync(logFile, "r");
|
|
142
|
-
try {
|
|
143
|
-
fs.readSync(r, buf, 0, buf.length, offset);
|
|
144
|
-
}
|
|
145
|
-
finally {
|
|
146
|
-
fs.closeSync(r);
|
|
147
|
-
}
|
|
148
|
-
offset = size;
|
|
149
|
-
const lines = (partial + buf.toString()).split("\n");
|
|
150
|
-
partial = lines.pop() ?? "";
|
|
146
|
+
for (let text = reader.read(); text !== null; text = reader.read()) {
|
|
147
|
+
const lines = text.split("\n");
|
|
148
|
+
lines.pop(); // every line read ends in its newline
|
|
151
149
|
for (const l of lines) {
|
|
152
150
|
if (l.startsWith("--- mason start "))
|
|
153
151
|
continue;
|
|
@@ -206,7 +204,8 @@ export async function devInBackground(args, o) {
|
|
|
206
204
|
if (exited !== null) {
|
|
207
205
|
if (steps.open() !== null)
|
|
208
206
|
steps.fail();
|
|
209
|
-
|
|
207
|
+
const partial = reader.rest(); // the line it was writing, unended
|
|
208
|
+
if (partial.trim())
|
|
210
209
|
since.push(partial.trim());
|
|
211
210
|
// the run's own last words are the reason (a wrong password), already shown line by line to a person
|
|
212
211
|
return failed("start", { message: `the run stopped before it was ready (exit ${exited})`, code: "failed", why: o.json && since.length ? since.join("\n") : undefined,
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE BRIDGE'S STDIN, READ ON A THREAD OF ITS OWN, AND ITS WATCHDOG (src/cli/ui-ssh.ts `bridge`, `StdinByWorker`).
|
|
3
|
+
*
|
|
4
|
+
* `mason ui --bridge` ends when its stdin ends: the laptop's ssh gone. Heard on the main thread, that end waits on the
|
|
5
|
+
* main event loop, and a loop that is blocked (a synchronous call that never returns, a hung native call, a busy loop)
|
|
6
|
+
* never hears it: the process would live on, holding the run's tunnel, with nobody left who can end it. So this thread
|
|
7
|
+
* owns fd 0, on an event loop of its own (Node's docs, worker_threads: "each Worker has its own event loop"), forwards
|
|
8
|
+
* its bytes to the main thread, and on its end arms a timer here; if the process is still alive when it fires, the main
|
|
9
|
+
* thread did not end it, so this one does, by SIGKILL to the process (`process.kill`, which a worker may call: a signal
|
|
10
|
+
* is delivered by the system, not by a loop, and SIGKILL cannot be caught or delayed by anything the process runs).
|
|
11
|
+
*
|
|
12
|
+
* THE FLOW CONTROL IS THIS THREAD'S, BY CREDIT: it hands the main thread at most `windowBytes` it has not acknowledged
|
|
13
|
+
* (`ack`: consumed, and FrameLink not paused), and stops reading at that bound (`stream.pause()`, so the pipe fills
|
|
14
|
+
* and the laptop's writes wait, as they did on `process.stdin`). Nothing is read into memory past it, whatever the main
|
|
15
|
+
* thread does. A stopped reader cannot hear stdin's end, so the main thread says it is alive (`beat`, every
|
|
16
|
+
* `BEAT_MS`): a main thread that is only waiting (on the run's page taking what was sent) keeps beating and the reader
|
|
17
|
+
* waits with it; one silent for `exitMs` with the window full is STUCK, and this thread then reads on and DROPS what it
|
|
18
|
+
* reads, so that the end is heard and the watchdog can fire. The stream it fed is then broken, which the main thread is
|
|
19
|
+
* told (`dropped`): if it ever runs again it ends the bridge, exit 1, never reads past the hole.
|
|
20
|
+
*
|
|
21
|
+
* fd 0 is opened as Node opens `process.stdin` for its kind (Node's docs, process.stdin, net.Socket `fd`, tty.ReadStream,
|
|
22
|
+
* fs.createReadStream `fd`): a pipe or a socket (ssh's, `docker exec -i`'s) as a `net.Socket`, a terminal (`ssh -t`) as a
|
|
23
|
+
* `tty.ReadStream`, anything else (a file, /dev/null) as a file stream, which always reaches its end. A read error is an
|
|
24
|
+
* end too (a terminal hung up reads EIO). A stdin that cannot be opened at all is said, and ends the bridge.
|
|
25
|
+
*/
|
|
26
|
+
import * as fs from "node:fs";
|
|
27
|
+
import * as net from "node:net";
|
|
28
|
+
import * as tty from "node:tty";
|
|
29
|
+
import { parentPort, workerData } from "node:worker_threads";
|
|
30
|
+
const { exitMs, windowBytes } = workerData;
|
|
31
|
+
if (parentPort === null)
|
|
32
|
+
throw new Error("src/cli/bridge-stdin.ts is a worker thread's script, started by src/cli/ui-ssh.ts");
|
|
33
|
+
const port = parentPort;
|
|
34
|
+
let over = false;
|
|
35
|
+
/** Bytes handed to the main thread that it has not acknowledged. */
|
|
36
|
+
let owed = 0;
|
|
37
|
+
let lastHeard = Date.now();
|
|
38
|
+
let dropping = false;
|
|
39
|
+
/** The end, once: said to the main thread, then the watchdog armed. */
|
|
40
|
+
function finish(why, failed = false) {
|
|
41
|
+
if (over)
|
|
42
|
+
return;
|
|
43
|
+
over = true;
|
|
44
|
+
port.postMessage({ end: why, failed });
|
|
45
|
+
setTimeout(() => {
|
|
46
|
+
try {
|
|
47
|
+
fs.writeSync(2, `mason ui --bridge: its stdin closed ${exitMs / 1000} s ago and it had not ended (its main thread is blocked): ended now, and the tunnel with it\n`);
|
|
48
|
+
}
|
|
49
|
+
catch (e) {
|
|
50
|
+
void e; /* stderr gone too, as the laptop's ssh is: the kill below is what ends the process */
|
|
51
|
+
}
|
|
52
|
+
process.kill(process.pid, "SIGKILL");
|
|
53
|
+
}, exitMs);
|
|
54
|
+
}
|
|
55
|
+
function open() {
|
|
56
|
+
if (tty.isatty(0))
|
|
57
|
+
return new tty.ReadStream(0);
|
|
58
|
+
const kind = fs.fstatSync(0);
|
|
59
|
+
if (kind.isFIFO() || kind.isSocket())
|
|
60
|
+
return new net.Socket({ fd: 0, readable: true, writable: false });
|
|
61
|
+
return fs.createReadStream("", { fd: 0, autoClose: false });
|
|
62
|
+
}
|
|
63
|
+
let input = null;
|
|
64
|
+
try {
|
|
65
|
+
input = open();
|
|
66
|
+
}
|
|
67
|
+
catch (e) {
|
|
68
|
+
finish(`its stdin cannot be read: ${e.message}`, true);
|
|
69
|
+
}
|
|
70
|
+
if (input !== null) {
|
|
71
|
+
const stream = input;
|
|
72
|
+
stream.on("data", (b) => {
|
|
73
|
+
if (dropping)
|
|
74
|
+
return;
|
|
75
|
+
const copy = Uint8Array.from(b);
|
|
76
|
+
port.postMessage({ data: copy }, [copy.buffer]);
|
|
77
|
+
owed += b.length;
|
|
78
|
+
if (owed >= windowBytes)
|
|
79
|
+
stream.pause();
|
|
80
|
+
});
|
|
81
|
+
stream.once("end", () => { finish(null); });
|
|
82
|
+
stream.once("error", (e) => { finish(`its stdin: ${e.message}`); });
|
|
83
|
+
stream.once("close", () => { finish(null); });
|
|
84
|
+
port.on("message", (m) => {
|
|
85
|
+
lastHeard = Date.now();
|
|
86
|
+
if (!("ack" in m) || dropping)
|
|
87
|
+
return;
|
|
88
|
+
owed -= m.ack;
|
|
89
|
+
if (owed < windowBytes && stream.isPaused())
|
|
90
|
+
stream.resume();
|
|
91
|
+
});
|
|
92
|
+
// a main thread silent for the bound while the window is full is stuck: read on, dropping, so the end is heard
|
|
93
|
+
const look = setInterval(() => {
|
|
94
|
+
if (dropping || over || owed < windowBytes || Date.now() - lastHeard < exitMs)
|
|
95
|
+
return;
|
|
96
|
+
dropping = true;
|
|
97
|
+
clearInterval(look);
|
|
98
|
+
port.postMessage({ dropped: owed });
|
|
99
|
+
stream.resume();
|
|
100
|
+
}, Math.min(250, exitMs));
|
|
101
|
+
}
|
package/dist/src/cli/command.js
CHANGED
|
@@ -13,6 +13,159 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { parseArgs } from "node:util";
|
|
15
15
|
import { err as styleErr, out as styleOut, PLAIN, wrap } from "./style.js";
|
|
16
|
+
/**
|
|
17
|
+
* A LINE THAT HOLDS A HOSTED SERVICE'S TEXT IS BROKEN BY THE CLI, NEVER BY THE TERMINAL, AT EVERY WIDTH (PR 481's
|
|
18
|
+
* class rounds: a value sized to the terminal's width made the terminal start a visual line at column 0 with a forged
|
|
19
|
+
* `Next` heading, a step or a row; then the same under 40 columns, where no style wraps). src/cli/hosted.ts makes every
|
|
20
|
+
* value one line with no run of spaces (`serviceText`); the style a command gives its output when that output holds
|
|
21
|
+
* such a value (`serviceStyle`) makes every renderer here break every line it writes, at the terminal's own width
|
|
22
|
+
* (`Style.columns`, read when the output is drawn, whatever it is), so no line ever reaches the terminal's edge:
|
|
23
|
+
*
|
|
24
|
+
* - a line takes at most width - 1 columns (one column at width 1), a character outside ASCII counted as two;
|
|
25
|
+
* - it breaks at a space where one is in reach, else within the word; the headline, a row's label and a step's
|
|
26
|
+
* command are broken as any text is (`strictLines`, the last pass of every renderer: `strict`);
|
|
27
|
+
* - every continuation starts at `continuationAt(width)`: CONTINUATION (4) wherever the width leaves 4 columns after
|
|
28
|
+
* it, else 1 (3 to 8 columns), else 0 (1 or 2 columns, where one character is a line). The CLI's own lines start at
|
|
29
|
+
* 0 (a heading, a failure, a tip) or 2 (a row, a step), never at 1 or 4, so a continuation can never be taken for
|
|
30
|
+
* one; at 1 or 2 columns nothing the CLI starts a line with fits in a line, so none can be forged either;
|
|
31
|
+
* - a value's own column (a row's, a step's description) is kept while it leaves `MIN_ROOM` columns, else its lines
|
|
32
|
+
* continue at continuationAt(width), never one character a line under an indent wider than the terminal.
|
|
33
|
+
*
|
|
34
|
+
* So what a person sees begin a line is always the CLI's own. A pipe, a file and `--json` are unwrapped as ever (no
|
|
35
|
+
* terminal width): what an agent parses never changes.
|
|
36
|
+
*/
|
|
37
|
+
export const CONTINUATION = 4;
|
|
38
|
+
/** The fewest columns a value's own column must leave it before its lines continue there. */
|
|
39
|
+
const MIN_ROOM = 12;
|
|
40
|
+
export const serviceStyle = (s) => ({ ...s, untrusted: true });
|
|
41
|
+
/** The columns a word takes, counted so a line never takes more than the terminal has: a character outside ASCII as two. */
|
|
42
|
+
const columns = (w) => { let n = 0; for (const ch of w)
|
|
43
|
+
n += ch.codePointAt(0) <= 0x7e ? 1 : 2; return n; };
|
|
44
|
+
/** Where a continuation starts on a terminal `width` columns wide (above). */
|
|
45
|
+
export const continuationAt = (width) => lineLimit(width) - CONTINUATION >= 4 ? CONTINUATION : width >= 3 ? 1 : 0;
|
|
46
|
+
/** The most columns one line may take on a terminal `width` wide: one short of its edge, never none. */
|
|
47
|
+
const lineLimit = (width) => Math.max(1, width - 1);
|
|
48
|
+
/** The terminal width a style that holds a service's text breaks at; null when it is no terminal (or holds none). */
|
|
49
|
+
const strictWidth = (s) => s.untrusted ? s.columns ?? s.width : null;
|
|
50
|
+
/** `text` broken at `width` as `serviceStyle` asks (above), its first line after `firstUsed` columns of prefix: every
|
|
51
|
+
* line within the limit, a long word broken within, every line after the first at `indent` while that leaves MIN_ROOM,
|
|
52
|
+
* else at continuationAt(width). */
|
|
53
|
+
export function strictWrap(text, width, indent, firstUsed) {
|
|
54
|
+
const limit = lineLimit(width);
|
|
55
|
+
const cont = Math.max(indent, CONTINUATION) + MIN_ROOM <= limit ? Math.max(indent, CONTINUATION) : continuationAt(width);
|
|
56
|
+
const lines = [];
|
|
57
|
+
let line = "", taken = 0, used = firstUsed;
|
|
58
|
+
const room = () => Math.max(1, limit - used);
|
|
59
|
+
const breakLine = () => { lines.push(line); line = ""; taken = 0; used = cont; };
|
|
60
|
+
// a prefix that leaves no room: the text starts on the next line
|
|
61
|
+
if (limit - used < 1)
|
|
62
|
+
breakLine();
|
|
63
|
+
for (const word of text.split(" ")) {
|
|
64
|
+
if (word === "")
|
|
65
|
+
continue;
|
|
66
|
+
const wc = columns(word);
|
|
67
|
+
if (line && taken + 1 + wc <= room()) {
|
|
68
|
+
line += ` ${word}`;
|
|
69
|
+
taken += 1 + wc;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (line)
|
|
73
|
+
breakLine();
|
|
74
|
+
if (wc <= room()) {
|
|
75
|
+
line = word;
|
|
76
|
+
taken = wc;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
for (const ch of word) {
|
|
80
|
+
const cc = columns(ch);
|
|
81
|
+
if (taken + cc > room() && line)
|
|
82
|
+
breakLine();
|
|
83
|
+
line += ch;
|
|
84
|
+
taken += cc;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
lines.push(line);
|
|
88
|
+
return lines.join(`\n${" ".repeat(cont)}`);
|
|
89
|
+
}
|
|
90
|
+
/** A colour code, which takes no column. */
|
|
91
|
+
const SGR = /^\x1b\[[0-9;]*m/;
|
|
92
|
+
/**
|
|
93
|
+
* THE LAST PASS OF EVERY RENDERER under serviceStyle: each line of `text` that would reach the terminal's edge broken
|
|
94
|
+
* (above) — at its last space in reach, else within a word — its continuations at continuationAt(width), its colour codes
|
|
95
|
+
* taking no column and never split. A line within the limit is left as it is.
|
|
96
|
+
*/
|
|
97
|
+
export function strictLines(text, width) {
|
|
98
|
+
const limit = lineLimit(width), cont = continuationAt(width);
|
|
99
|
+
const out = [];
|
|
100
|
+
for (const whole of text.split("\n")) {
|
|
101
|
+
const tokens = [];
|
|
102
|
+
for (let i = 0; i < whole.length;) {
|
|
103
|
+
const sgr = SGR.exec(whole.slice(i));
|
|
104
|
+
if (sgr) {
|
|
105
|
+
tokens.push({ t: sgr[0], w: 0 });
|
|
106
|
+
i += sgr[0].length;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const ch = String.fromCodePoint(whole.codePointAt(i));
|
|
110
|
+
tokens.push({ t: ch, w: columns(ch) });
|
|
111
|
+
i += ch.length;
|
|
112
|
+
}
|
|
113
|
+
if (tokens.reduce((n, k) => n + k.w, 0) <= limit) {
|
|
114
|
+
out.push(whole);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
// the line's own indent, kept while it leaves room for a word, else the continuation's
|
|
118
|
+
let lead = 0;
|
|
119
|
+
while (tokens[lead]?.t === " ")
|
|
120
|
+
lead++;
|
|
121
|
+
const indent = (n) => Array.from({ length: n }, () => ({ t: " ", w: 1 }));
|
|
122
|
+
let line = indent(lead + MIN_ROOM <= limit ? lead : Math.min(lead, cont));
|
|
123
|
+
let taken = line.length, start = line.length;
|
|
124
|
+
// each token placed in turn; past the limit the line is broken at its last space past the indent, and what followed
|
|
125
|
+
// the space is placed again from there (so it is broken again if it must), else broken here, within the word
|
|
126
|
+
let placed = []; // the source index of each of line's tokens past its indent
|
|
127
|
+
for (let i = lead; i < tokens.length; i++) {
|
|
128
|
+
const k = tokens[i];
|
|
129
|
+
if (taken + k.w > limit && taken > start) {
|
|
130
|
+
let at = -1;
|
|
131
|
+
for (let j = placed.length - 1; j > 0; j--)
|
|
132
|
+
if (tokens[placed[j]].t === " ") {
|
|
133
|
+
at = j;
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
const kept = at >= 0 ? line.slice(0, start + at) : line;
|
|
137
|
+
while (kept.length > start && kept[kept.length - 1].t === " ")
|
|
138
|
+
kept.pop();
|
|
139
|
+
out.push(kept.map((x) => x.t).join(""));
|
|
140
|
+
if (at >= 0)
|
|
141
|
+
i = placed[at]; // the loop's i++ places what followed the space
|
|
142
|
+
else
|
|
143
|
+
i--; // this token begins the next line
|
|
144
|
+
line = indent(cont);
|
|
145
|
+
taken = cont;
|
|
146
|
+
start = cont;
|
|
147
|
+
placed = [];
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (k.t === " " && taken === start && start > 0)
|
|
151
|
+
continue; // no space at a continuation's start
|
|
152
|
+
line.push(k);
|
|
153
|
+
placed.push(i);
|
|
154
|
+
taken += k.w;
|
|
155
|
+
}
|
|
156
|
+
// a continuation with nothing after its indent (the line ended in spaces) is no line
|
|
157
|
+
if (taken > start || start === 0)
|
|
158
|
+
out.push(line.map((x) => x.t).join(""));
|
|
159
|
+
}
|
|
160
|
+
return out.join("\n");
|
|
161
|
+
}
|
|
162
|
+
/** A renderer's output, as serviceStyle asks it on a terminal (strictLines), else as it is. */
|
|
163
|
+
export const strict = (text, s) => { const w = strictWidth(s); return w === null ? text : strictLines(text, w); };
|
|
164
|
+
/** A renderer's wrap: the strict one for a style that holds a service's text on a terminal, else style.ts's. */
|
|
165
|
+
const fit = (text, s, indent, firstUsed = indent) => {
|
|
166
|
+
const w = strictWidth(s);
|
|
167
|
+
return w === null ? wrap(text, s.width, indent, firstUsed) : strictWrap(text, w, indent, firstUsed);
|
|
168
|
+
};
|
|
16
169
|
const HELP = { help: { type: "boolean", short: "h" } };
|
|
17
170
|
/**
|
|
18
171
|
* A command line read by its spec: the values and positionals; or the exit code when it answered itself — 0 after
|
|
@@ -46,25 +199,30 @@ export function nextBlock(steps, s = styleOut()) {
|
|
|
46
199
|
if (!what)
|
|
47
200
|
return ` ${s.cyan(command)}`;
|
|
48
201
|
const used = 2 + Math.max(command.length, NEXT_WIDTH) + 2;
|
|
49
|
-
|
|
202
|
+
// a terminal too narrow for the column under serviceStyle: what it does on the next line, at the continuation
|
|
203
|
+
const w = strictWidth(s);
|
|
204
|
+
if (w !== null && 2 + NEXT_WIDTH + 2 + MIN_ROOM > lineLimit(w))
|
|
205
|
+
return ` ${s.cyan(command)}\n${" ".repeat(continuationAt(w))}${s.dim(fit(what, s, continuationAt(w)))}`;
|
|
206
|
+
return ` ${s.cyan(command.padEnd(NEXT_WIDTH))} ${s.dim(fit(what, s, 2 + NEXT_WIDTH + 2, used))}`;
|
|
50
207
|
};
|
|
51
|
-
return ["", s.bold("Next:"), ...steps.map(step)].join("\n");
|
|
208
|
+
return strict(["", s.bold("Next:"), ...steps.map(step)].join("\n"), s);
|
|
52
209
|
}
|
|
53
210
|
export function reportText(r, s = styleOut()) {
|
|
54
|
-
|
|
211
|
+
// a headline is a terminal's to wrap, unless it holds a service's text: then the CLI breaks it (above)
|
|
212
|
+
const lines = [s.bold(strictWidth(s) !== null ? fit(r.headline, s, CONTINUATION, 0) : r.headline)];
|
|
55
213
|
const w = Math.max(0, ...(r.details ?? []).map(([label]) => label.length));
|
|
56
214
|
for (const [label, value] of r.details ?? [])
|
|
57
|
-
lines.push(` ${s.dim(label.padEnd(w))} ${
|
|
58
|
-
return withEnd(lines.join("\n"), r, s);
|
|
215
|
+
lines.push(` ${s.dim(label.padEnd(w))} ${fit(value, s, 2 + w + 3)}`);
|
|
216
|
+
return strict(withEnd(lines.join("\n"), r, s), s);
|
|
59
217
|
}
|
|
60
218
|
/**
|
|
61
219
|
* The end of a command's output, to print on its own (out(endText(…))): its tips, then what to run next, each part after
|
|
62
220
|
* a blank line; "" when it has neither. withEnd puts it after text a command has not printed yet.
|
|
63
221
|
*/
|
|
64
222
|
export function endText(r, s = styleOut()) {
|
|
65
|
-
const tips = r.tips?.length ? `\n${r.tips.map((t) => s.dim(`Tip: ${
|
|
223
|
+
const tips = r.tips?.length ? `\n${r.tips.map((t) => s.dim(`Tip: ${fit(t, s, 5)}`)).join("\n")}` : "";
|
|
66
224
|
const next = r.next?.length ? nextBlock(r.next, s) : "";
|
|
67
|
-
return tips && next ? `${tips}\n${next}` : tips || next;
|
|
225
|
+
return strict(tips && next ? `${tips}\n${next}` : tips || next, s);
|
|
68
226
|
}
|
|
69
227
|
export function withEnd(text, r, s = styleOut()) {
|
|
70
228
|
const end = endText(r, s);
|
|
@@ -112,7 +270,7 @@ export const FAILURE_CODES = {
|
|
|
112
270
|
nothing_yet: "nothing to work on yet: no table added (the next steps say what adds one)",
|
|
113
271
|
refused: "Mason refused what was asked, with the reason",
|
|
114
272
|
failed: "the command tried and could not finish: the reason, and what to do",
|
|
115
|
-
timeout: "ran out of time — mason wait; nothing stopped (exit 124)",
|
|
273
|
+
timeout: "ran out of time — mason wait, mason deploy, mason replica create; nothing stopped (exit 124)",
|
|
116
274
|
};
|
|
117
275
|
/** A failure thrown from deep in a command, for main.ts to print: the one way a failure leaves a command without a return code. */
|
|
118
276
|
export class CommandFailed extends Error {
|
|
@@ -127,17 +285,17 @@ export function failureText(cmd, f, s = styleErr()) {
|
|
|
127
285
|
const head = cmd ? `mason ${cmd}:` : "mason:";
|
|
128
286
|
// the checklist's marks: ✘ on a terminal (beside its ✔), ✗ in plain text (beside its ✓); a style that colours is one
|
|
129
287
|
const mark = s.red("x") === "x" ? "✗" : "✘";
|
|
130
|
-
const lines = [`${s.red(mark)} ${s.red(head)} ${s.bold(
|
|
288
|
+
const lines = [`${s.red(mark)} ${s.red(head)} ${s.bold(fit(f.message, s, 2, (strictWidth(s) === null ? mark.length : columns(mark)) + 1 + head.length + 1))}`];
|
|
131
289
|
const rows = [...(f.why ? [["why", f.why]] : []), ...(f.fix ? [["fix", f.fix]] : [])];
|
|
132
290
|
// a value of several lines (a list of failures, an entry to paste) keeps its lines, each under the first
|
|
133
291
|
for (const [label, value] of rows)
|
|
134
|
-
lines.push(` ${s.dim(label)} ${value.split("\n").map((l, i) => i ? `${" ".repeat(8)}${
|
|
292
|
+
lines.push(` ${s.dim(label)} ${value.split("\n").map((l, i) => i ? `${" ".repeat(8)}${fit(l, s, 8)}` : fit(l, s, 8)).join("\n")}`);
|
|
135
293
|
let text = lines.join("\n");
|
|
136
294
|
if (f.next?.length)
|
|
137
295
|
text += `\n${nextBlock(f.next, s)}`;
|
|
138
296
|
if (f.usage)
|
|
139
297
|
text += `\n\n${f.usage.replace(/\n$/, "")}`;
|
|
140
|
-
return text;
|
|
298
|
+
return strict(text, s);
|
|
141
299
|
}
|
|
142
300
|
/** A failure's `error` as --json carries it: on its own (failureJson), or beside a command's own keys when it did part of
|
|
143
301
|
* its work (mason add: the tables added; mason wait: the tables' state). */
|