@mason-data/cli 0.0.0-stage → 0.6.2
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/LICENSE +202 -0
- package/README.md +209 -2
- package/dist/src/api/error.js +18 -0
- package/dist/src/api/postgres/catalog-worker.js +92 -0
- package/dist/src/api/postgres/catalog.js +442 -0
- package/dist/src/api/postgres/encode.js +541 -0
- package/dist/src/api/postgres/explain.js +143 -0
- package/dist/src/api/postgres/listener.js +60 -0
- package/dist/src/api/postgres/parser-worker.js +58 -0
- package/dist/src/api/postgres/parser.js +121 -0
- package/dist/src/api/postgres/plain.js +33 -0
- package/dist/src/api/postgres/protocol.js +404 -0
- package/dist/src/api/postgres/schema.js +54 -0
- package/dist/src/api/postgres/server.js +1670 -0
- package/dist/src/api/postgres/translate.js +248 -0
- package/dist/src/api/request-log.js +50 -0
- package/dist/src/cdc/pg-wire.js +482 -0
- package/dist/src/cdc/pgoutput.js +107 -0
- package/dist/src/cdc/poll.js +67 -0
- package/dist/src/cdc/source.js +65 -0
- package/dist/src/cli/background.js +174 -0
- package/dist/src/cli/command.js +172 -0
- package/dist/src/cli/commands.js +1145 -0
- package/dist/src/cli/control.js +36 -0
- package/dist/src/cli/dev.js +576 -0
- package/dist/src/cli/doctor.js +257 -0
- package/dist/src/cli/guides.js +134 -0
- package/dist/src/cli/init.js +326 -0
- package/dist/src/cli/machine.js +157 -0
- package/dist/src/cli/main.js +83 -0
- package/dist/src/cli/pg-tls.js +100 -0
- package/dist/src/cli/pg-url.js +52 -0
- package/dist/src/cli/pin.js +36 -0
- package/dist/src/cli/ports.js +118 -0
- package/dist/src/cli/preflight.js +338 -0
- package/dist/src/cli/project.js +127 -0
- package/dist/src/cli/readiness.js +76 -0
- package/dist/src/cli/registry.js +217 -0
- package/dist/src/cli/replication-checks.js +120 -0
- package/dist/src/cli/replication-mode.js +148 -0
- package/dist/src/cli/run-server.js +388 -0
- package/dist/src/cli/runs.js +120 -0
- package/dist/src/cli/security.js +86 -0
- package/dist/src/cli/self-signed-cert.js +64 -0
- package/dist/src/cli/serve.js +296 -0
- package/dist/src/cli/sources.js +562 -0
- package/dist/src/cli/state.js +58 -0
- package/dist/src/cli/style.js +187 -0
- package/dist/src/cli/supervise.js +248 -0
- package/dist/src/cli/sync.js +94 -0
- package/dist/src/cli/ui.js +107 -0
- package/dist/src/cli/user.js +229 -0
- package/dist/src/cli/version.js +23 -0
- package/dist/src/duck/analyze.js +113 -0
- package/dist/src/duck/cancel.js +49 -0
- package/dist/src/duck/executor-worker.js +263 -0
- package/dist/src/duck/executor.js +526 -0
- package/dist/src/duck/explain.js +119 -0
- package/dist/src/duck/functions.js +764 -0
- package/dist/src/duck/gaps.js +89 -0
- package/dist/src/duck/jsonb.js +186 -0
- package/dist/src/duck/kept-route.js +219 -0
- package/dist/src/duck/kept.js +797 -0
- package/dist/src/duck/late.js +221 -0
- package/dist/src/duck/libc-order.js +190 -0
- package/dist/src/duck/pg-eval-worker.js +34 -0
- package/dist/src/duck/pg-eval.js +59 -0
- package/dist/src/duck/pgtree.js +338 -0
- package/dist/src/duck/plan.js +246 -0
- package/dist/src/duck/publication.js +207 -0
- package/dist/src/duck/requests.js +209 -0
- package/dist/src/duck/resolved.js +248 -0
- package/dist/src/duck/rewrite.js +4001 -0
- package/dist/src/duck/store.js +831 -0
- package/dist/src/duck/stream.js +518 -0
- package/dist/src/duck/sync.js +2864 -0
- package/dist/src/duck/to-char.js +482 -0
- package/dist/src/duck/token.js +31 -0
- package/dist/src/duck/types.js +427 -0
- package/dist/src/duck/users.js +231 -0
- package/dist/src/duck/verified.js +995 -0
- package/dist/src/duck/views.js +150 -0
- package/dist/src/lib/database-url.js +74 -0
- package/dist/src/lib/did-you-mean.js +43 -0
- package/dist/src/lib/dotenv.js +72 -0
- package/dist/src/lib/exit-when-written.js +20 -0
- package/dist/src/lib/float-equal.js +69 -0
- package/dist/src/lib/http-json.js +22 -0
- package/dist/src/lib/log.js +161 -0
- package/dist/src/lib/pg-numeric.js +489 -0
- package/dist/src/lib/port.js +22 -0
- package/dist/src/lib/project-file.js +49 -0
- package/dist/src/lib/project-files.js +43 -0
- package/dist/src/lib/project-version.js +76 -0
- package/dist/src/lib/runnable.js +14 -0
- package/dist/src/lib/shell-word.js +4 -0
- package/dist/src/lib/timezone.js +236 -0
- package/dist/src/lib/zone-names.js +114 -0
- package/dist/src/web/app.css +65 -0
- package/dist/src/web/app.js +336 -0
- package/dist/src/web/index.html +39 -0
- package/package.json +22 -4
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE REPLICATION SLOT, and the names a run replicates through (src/duck/sync.ts, src/cli/replication-mode.ts). The
|
|
3
|
+
* slot is the one object Mason creates on the source (its user needs the REPLICATION attribute); a publication is read,
|
|
4
|
+
* never created there, unless it is Mason's own (src/duck/publication.ts; CLAUDE.md "We are read-only on a customer's
|
|
5
|
+
* Postgres").
|
|
6
|
+
*/
|
|
7
|
+
import { PgConnection, parseLsn, pgIdent, pgLiteral } from "./pg-wire.js";
|
|
8
|
+
import { parsePort } from "../lib/port.js";
|
|
9
|
+
/**
|
|
10
|
+
* The publication and slot a project replicates through: `PG_PUBLICATION` / `PG_SLOT_NAME`, else Mason's own
|
|
11
|
+
* `mason_<name>`. A publication of Mason's own name is one Mason may create; any other is the database owner's and is
|
|
12
|
+
* only ever read.
|
|
13
|
+
*/
|
|
14
|
+
export function replicationNames(env, deployment) {
|
|
15
|
+
const own = `mason_${deployment.replace(/[^A-Za-z0-9_]/g, "_")}`;
|
|
16
|
+
const publication = env.PG_PUBLICATION || own;
|
|
17
|
+
return { publication, slot: env.PG_SLOT_NAME || own, owned: publication === own };
|
|
18
|
+
}
|
|
19
|
+
/** The connection options from the `PG_*` environment (DATABASE_URL already laid under it). */
|
|
20
|
+
export function pgOptions(env, replication = false) {
|
|
21
|
+
const need = (k) => {
|
|
22
|
+
const v = env[k];
|
|
23
|
+
if (!v)
|
|
24
|
+
throw new Error(`${k} is not set (DATABASE_URL, or the PG_* variables)`);
|
|
25
|
+
return v;
|
|
26
|
+
};
|
|
27
|
+
const port = parsePort("PG_PORT", env.PG_PORT, 5432);
|
|
28
|
+
if (port.problem !== undefined)
|
|
29
|
+
throw new Error(port.problem);
|
|
30
|
+
return {
|
|
31
|
+
host: need("PG_HOST"), port: port.port, database: need("PG_DATABASE"), user: need("PG_USER"),
|
|
32
|
+
password: env.PG_PASSWORD ?? "", sslmode: (env.PG_SSLMODE || "prefer"),
|
|
33
|
+
replication, applicationName: replication ? "mason-cdc" : "mason",
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
const NO_SLOT = { exists: false, active: false, activePid: null, plugin: null, database: null, confirmedFlush: null,
|
|
37
|
+
walStatus: null, retainedBytes: null, safeBytes: null };
|
|
38
|
+
export async function readSlot(pg, slot) {
|
|
39
|
+
const r = await pg.query(`SELECT plugin, database, active, active_pid::text, confirmed_flush_lsn::text, wal_status, safe_wal_size::text, ` +
|
|
40
|
+
`pg_wal_lsn_diff((CASE WHEN pg_is_in_recovery() THEN pg_last_wal_replay_lsn() ELSE pg_current_wal_lsn() END), restart_lsn)::text ` +
|
|
41
|
+
`FROM pg_replication_slots WHERE slot_name = ${pgLiteral(slot)}`);
|
|
42
|
+
const row = r.rows[0];
|
|
43
|
+
if (!row)
|
|
44
|
+
return NO_SLOT;
|
|
45
|
+
const big = (v) => (v === null || v === undefined || v === "" ? null : BigInt(v.split(".")[0]));
|
|
46
|
+
return { exists: true, plugin: row[0], database: row[1], active: row[2] === "t", activePid: row[3] ?? null, confirmedFlush: row[4] ?? null,
|
|
47
|
+
walStatus: row[5] ?? null, safeBytes: big(row[6]), retainedBytes: big(row[7]) };
|
|
48
|
+
}
|
|
49
|
+
/** Drop the slot (it must not be active). */
|
|
50
|
+
export async function dropSlot(pg, slot) {
|
|
51
|
+
await pg.query(`SELECT pg_drop_replication_slot(${pgLiteral(slot)})`);
|
|
52
|
+
}
|
|
53
|
+
/** Where the source's WAL is now: the insert position on a primary, the replayed position on a standby (a
|
|
54
|
+
* logical slot on a standby decodes only what it has replayed). */
|
|
55
|
+
export async function currentLsn(pg) {
|
|
56
|
+
const r = await pg.query("SELECT (CASE WHEN pg_is_in_recovery() THEN pg_last_wal_replay_lsn() ELSE pg_current_wal_lsn() END)::text");
|
|
57
|
+
return parseLsn(r.rows[0]?.[0] ?? "");
|
|
58
|
+
}
|
|
59
|
+
/** Create the slot over a replication connection; returns the WAL position it starts at. */
|
|
60
|
+
export async function createSlot(repl, slot) {
|
|
61
|
+
// NOEXPORT_SNAPSHOT: the first copy reads each table by key pages, each its own statement (the stream's
|
|
62
|
+
// versions win wherever they overlap) — no long snapshot to hold open.
|
|
63
|
+
const r = await repl.query(`CREATE_REPLICATION_SLOT ${pgIdent(slot)} LOGICAL pgoutput NOEXPORT_SNAPSHOT`);
|
|
64
|
+
return r.rows[0]?.[1] ?? "";
|
|
65
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mason start` IN THE BACKGROUND (an interface as good for people as for coding agents).
|
|
3
|
+
* The run is a process of its own (`mason start … --attach`, detached, its terminal the file `dev.log` in the run's
|
|
4
|
+
* folder): this shows the run's start as it happens, and returns once the run says it is ready — or with the run's own
|
|
5
|
+
* exit code when it stops first (a wrong password), its reason already shown. A coding agent's shell can
|
|
6
|
+
* then go on (`mason tables`, `mason add`, `mason wait`), and a person's terminal is theirs again.
|
|
7
|
+
*
|
|
8
|
+
* A second `mason start` for a project whose run is up does not start another: it says so and returns 0, so running it
|
|
9
|
+
* again is always safe.
|
|
10
|
+
*
|
|
11
|
+
* What is detached is the run's SUPERVISOR (src/cli/supervise.ts), which starts the run and starts it again if it dies
|
|
12
|
+
* once it was ready; a start that fails ends the supervisor with the run's own exit code, so this reads it as before.
|
|
13
|
+
*/
|
|
14
|
+
import { choosePgPort } from "./ports.js";
|
|
15
|
+
import * as fs from "node:fs";
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
import { spawn } from "node:child_process";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
import { runnable } from "../lib/runnable.js";
|
|
20
|
+
import { READY, STEP_MARK } from "./dev.js";
|
|
21
|
+
import { liveRun } from "./runs.js";
|
|
22
|
+
import { theProject } from "./project.js";
|
|
23
|
+
import { runListenerSettings } from "./pg-tls.js";
|
|
24
|
+
import { endText, failed, jsonText, nextBlock } from "./command.js";
|
|
25
|
+
import { DEV } from "./registry.js";
|
|
26
|
+
import { out as styleOut, stepRenderer } from "./style.js";
|
|
27
|
+
import { postgresLink, withoutPlaceholders } from "./pg-url.js";
|
|
28
|
+
const say = (m) => { process.stdout.write(`${m}\n`); };
|
|
29
|
+
const MAIN = path.join(path.dirname(fileURLToPath(import.meta.url)), "main.ts");
|
|
30
|
+
const SUPERVISE = path.join(path.dirname(fileURLToPath(import.meta.url)), "supervise.ts");
|
|
31
|
+
/** The commands to run next, as `mason start` prints them once the run is up: one a line, what each does beside it
|
|
32
|
+
* (not `mason status | mason logs | mason stop` on one line). */
|
|
33
|
+
export const NEXT_STEPS = [
|
|
34
|
+
["mason tables", "the database's tables, and what you can add"],
|
|
35
|
+
["mason add <table>", "copy a table into Mason"],
|
|
36
|
+
["mason user add <name>", "a user for a BI tool or your app: its password and URL, printed once"],
|
|
37
|
+
["mason status", "what Mason is doing"],
|
|
38
|
+
["mason logs", "its log: every copy, poll and statement"],
|
|
39
|
+
["mason stop", "stop Mason; the next mason start resumes where it stopped"],
|
|
40
|
+
];
|
|
41
|
+
/** `args` are `mason start`'s own, passed on to the run with --attach. */
|
|
42
|
+
export async function devInBackground(args, o) {
|
|
43
|
+
const t = theProject(process.cwd());
|
|
44
|
+
if ("problem" in t)
|
|
45
|
+
return failed("start", t, { json: o.json });
|
|
46
|
+
const home = t.project.dir;
|
|
47
|
+
const other = await liveRun(t.project);
|
|
48
|
+
if (other !== null) {
|
|
49
|
+
// running already is what start wants: said, exit 0, the same answer as a start (what is up, and what to run)
|
|
50
|
+
if (o.json) {
|
|
51
|
+
say(jsonText(DEV, { running: true, pid: other.pid, postgres: postgresLink(other.postgresUrl) }, [["mason status", "what it is doing"], ["mason stop", "stop it"]]));
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
say(`Mason is already running for ${t.project.config.name} (pid ${other.pid}).`);
|
|
55
|
+
say(nextBlock([["mason status", "what it is doing"], ["mason stop", "stop it"]]));
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
// a MASON_PG_PORT that is no port, or is held, is refused HERE, where the person (or --json) reads it, before a run is
|
|
59
|
+
// spawned; the run checks again for itself (choosePgPort, src/cli/ports.ts), since a port may be taken in between
|
|
60
|
+
const pg = await choosePgPort(process.env.MASON_PG_PORT, new Set(), t.project.config.pgPort ?? null);
|
|
61
|
+
if ("problem" in pg)
|
|
62
|
+
return failed("start", pg, { json: o.json });
|
|
63
|
+
// and so is a listener off loopback without TLS, by the run's own check (src/cli/pg-tls.ts): read, never written
|
|
64
|
+
const listen = runListenerSettings(process.env, { port: pg.port, selfSigned: o.selfSignedTls, home });
|
|
65
|
+
if ("problem" in listen)
|
|
66
|
+
return failed("start", listen, { json: o.json });
|
|
67
|
+
fs.mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
68
|
+
// the run's terminal: its setup URL carries the run's token and it prints the API key, so only the developer reads it
|
|
69
|
+
const logFile = path.join(home, "dev.log");
|
|
70
|
+
const fd = fs.openSync(logFile, "a", 0o600);
|
|
71
|
+
fs.chmodSync(logFile, 0o600);
|
|
72
|
+
const from = fs.fstatSync(fd).size;
|
|
73
|
+
fs.writeSync(fd, `\n--- mason start ${new Date().toISOString()} ---\n`);
|
|
74
|
+
// the run is started IN the project's root, so it finds the same project
|
|
75
|
+
const run = [process.execPath, runnable(MAIN), "start", ...args.filter((a) => a !== "--json"), "--attach"];
|
|
76
|
+
const child = spawn(process.execPath, [runnable(SUPERVISE), home, logFile, ...run], { detached: true, stdio: ["ignore", fd, fd], cwd: t.project.root, env: process.env });
|
|
77
|
+
fs.closeSync(fd);
|
|
78
|
+
let exited = null;
|
|
79
|
+
child.on("exit", (code, signal) => { exited = code ?? (signal ? 1 : 0); });
|
|
80
|
+
// Ctrl-C while it starts stops the start too: nothing is left running that nobody asked for
|
|
81
|
+
const abort = () => { child.kill("SIGTERM"); };
|
|
82
|
+
process.on("SIGINT", abort);
|
|
83
|
+
// what it does, as a checklist (as `mason init` draws its own): the run's steps, drawn as they happen; under
|
|
84
|
+
// --json nothing until it is up (one document), the lines after the last step kept as a failure's why
|
|
85
|
+
const st = styleOut();
|
|
86
|
+
const line = (x) => { if (!o.json)
|
|
87
|
+
say(x); };
|
|
88
|
+
line(st.bold(`Starting Mason for ${t.project.config.name}`));
|
|
89
|
+
const steps = stepRenderer({ line, live: !o.json && process.stdout.isTTY ? { write: (x) => { process.stdout.write(x); } } : null, style: st });
|
|
90
|
+
let begun = false;
|
|
91
|
+
let offset = from;
|
|
92
|
+
let partial = "";
|
|
93
|
+
let postgres = null;
|
|
94
|
+
const since = []; // the run's lines after its last step: a failure's reasons
|
|
95
|
+
try {
|
|
96
|
+
for (;;) {
|
|
97
|
+
const size = fs.statSync(logFile).size;
|
|
98
|
+
if (size > offset) {
|
|
99
|
+
const buf = Buffer.alloc(size - offset);
|
|
100
|
+
const r = fs.openSync(logFile, "r");
|
|
101
|
+
try {
|
|
102
|
+
fs.readSync(r, buf, 0, buf.length, offset);
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
fs.closeSync(r);
|
|
106
|
+
}
|
|
107
|
+
offset = size;
|
|
108
|
+
const lines = (partial + buf.toString()).split("\n");
|
|
109
|
+
partial = lines.pop() ?? "";
|
|
110
|
+
for (const l of lines) {
|
|
111
|
+
if (l.startsWith("--- mason start "))
|
|
112
|
+
continue;
|
|
113
|
+
if (l === "" && steps.open() === null && !begun)
|
|
114
|
+
continue; // the log's spacing before the first step
|
|
115
|
+
begun = true;
|
|
116
|
+
if (l === READY) {
|
|
117
|
+
child.unref();
|
|
118
|
+
const next = NEXT_STEPS;
|
|
119
|
+
// the run's pid, as it recorded it (the child is its supervisor), the one mason stop signals
|
|
120
|
+
const pid = JSON.parse(fs.readFileSync(path.join(home, "config.json"), "utf8")).running?.pid ?? null;
|
|
121
|
+
if (o.json)
|
|
122
|
+
say(jsonText(DEV, { running: true, pid, postgres }, next));
|
|
123
|
+
else
|
|
124
|
+
say(endText({ next }, st));
|
|
125
|
+
return 0;
|
|
126
|
+
}
|
|
127
|
+
// the run's steps (src/cli/dev.ts STEP_MARK) as a checklist; any other line as it came
|
|
128
|
+
if (l.startsWith(STEP_MARK.begin)) {
|
|
129
|
+
since.length = 0;
|
|
130
|
+
steps.begin(l.slice(STEP_MARK.begin.length));
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (l.startsWith(STEP_MARK.done)) {
|
|
134
|
+
since.length = 0;
|
|
135
|
+
steps.done(l.slice(STEP_MARK.done.length));
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (l.startsWith(STEP_MARK.fail)) {
|
|
139
|
+
since.length = 0;
|
|
140
|
+
steps.fail(l.slice(STEP_MARK.fail.length));
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
// the run's line (src/cli/dev.ts: "Postgres <url>")
|
|
144
|
+
const pgLine = /^\s+Postgres\s+(postgresql:\S+)/.exec(l);
|
|
145
|
+
if (pgLine) {
|
|
146
|
+
postgres = postgresLink(withoutPlaceholders(pgLine[1]));
|
|
147
|
+
steps.print(` ${st.dim("Postgres API")} ${pgLine[1]} ${st.dim("(mason user add <name> makes a user)")}`);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (/^\s+Web UI\s+mason ui\b/.test(l)) {
|
|
151
|
+
steps.print(` ${st.dim("Web UI".padEnd("Postgres API".length))} ${st.cyan("mason ui")} ${st.dim("(the status page and the users, in your browser)")}`);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (l.trim())
|
|
155
|
+
since.push(l.trim().replace(/^mason start: /, ""));
|
|
156
|
+
steps.print(l);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (exited !== null) {
|
|
160
|
+
if (steps.open() !== null)
|
|
161
|
+
steps.fail();
|
|
162
|
+
if (partial)
|
|
163
|
+
since.push(partial.trim());
|
|
164
|
+
// the run's own last words are the reason (a wrong password), already shown line by line to a person
|
|
165
|
+
return failed("start", { message: `the run stopped before it was ready (exit ${exited})`, code: "failed", why: o.json && since.length ? since.join("\n") : undefined,
|
|
166
|
+
fix: `the whole log: ${logFile}`, exit: exited === 0 ? 1 : exited }, { json: o.json });
|
|
167
|
+
}
|
|
168
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
process.off("SIGINT", abort);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A `mason` COMMAND, DESCRIBED ONCE (every command, present and future, is and stays self-documenting, and that is
|
|
3
|
+
* checked). Each command is a `CommandSpec`: its name, the one line `mason --help` lists it with, its usage
|
|
4
|
+
* (what `mason <name> --help` prints), and the options its parser accepts — the SAME object the parser reads, so an
|
|
5
|
+
* option cannot exist without its spec. src/cli/main.ts builds the command table and the top-level help from the
|
|
6
|
+
* specs, and tests/cli-help.test.ts holds every one to the rules below, a command added later included:
|
|
7
|
+
*
|
|
8
|
+
* - `mason <name> --help` and `mason help <name>` print its usage and exit 0;
|
|
9
|
+
* - the usage starts `usage: mason <name>`, and shows at least one example (an indented `mason <name> …` line);
|
|
10
|
+
* - every option the parser takes is in the usage, by its `--name` (and `-x` when it has one);
|
|
11
|
+
* - `mason --help` lists it with its summary;
|
|
12
|
+
* - a wrong option prints what is wrong, then the usage, and exits 2.
|
|
13
|
+
*/
|
|
14
|
+
import { parseArgs } from "node:util";
|
|
15
|
+
import { err as styleErr, out as styleOut, PLAIN, wrap } from "./style.js";
|
|
16
|
+
const HELP = { help: { type: "boolean", short: "h" } };
|
|
17
|
+
/**
|
|
18
|
+
* A command line read by its spec: the values and positionals; or the exit code when it answered itself — 0 after
|
|
19
|
+
* printing the usage for `--help`, 2 after saying what is wrong with the line, then the usage.
|
|
20
|
+
*/
|
|
21
|
+
export function parseCommand(spec, argv) {
|
|
22
|
+
let parsed;
|
|
23
|
+
try {
|
|
24
|
+
parsed = parseArgs({ args: argv, options: { ...spec.options, ...HELP }, allowPositionals: true });
|
|
25
|
+
}
|
|
26
|
+
catch (e) {
|
|
27
|
+
return failed(spec.name, { message: e.message, code: "usage", usage: spec.usage, next: [[`mason ${spec.name} --help`, "its usage, with every option"]] }, { json: wantsJson(argv) });
|
|
28
|
+
}
|
|
29
|
+
if (parsed.values.help) {
|
|
30
|
+
process.stdout.write(spec.usage);
|
|
31
|
+
return 0;
|
|
32
|
+
}
|
|
33
|
+
return { values: parsed.values, positionals: parsed.positionals };
|
|
34
|
+
}
|
|
35
|
+
/** The column a step's `what` starts in, after the two-space indent: wide enough for `mason add orders --columns …`. */
|
|
36
|
+
const NEXT_WIDTH = 44;
|
|
37
|
+
/**
|
|
38
|
+
* WHAT TO RUN NEXT, in one shape for every command (some commands showed Next as one line, some without the Next
|
|
39
|
+
* prefix and some as a kind of table; the table won): a blank line, `Next:`, then each command with what
|
|
40
|
+
* it does in a column, after everything else a command prints — a failure's way out included (failureText). With --json the
|
|
41
|
+
* same steps are the answer's `next` (nextJson). tests/conventions.test.ts refuses a `Next` or `Try` written any other way.
|
|
42
|
+
*/
|
|
43
|
+
export function nextBlock(steps, s = styleOut()) {
|
|
44
|
+
// on a terminal: `Next:` bold, each command cyan, what it does dim and wrapped under its own column (src/cli/style.ts)
|
|
45
|
+
const step = ([command, what]) => {
|
|
46
|
+
if (!what)
|
|
47
|
+
return ` ${s.cyan(command)}`;
|
|
48
|
+
const used = 2 + Math.max(command.length, NEXT_WIDTH) + 2;
|
|
49
|
+
return ` ${s.cyan(command.padEnd(NEXT_WIDTH))} ${s.dim(wrap(what, s.width, 2 + NEXT_WIDTH + 2, used))}`;
|
|
50
|
+
};
|
|
51
|
+
return ["", s.bold("Next:"), ...steps.map(step)].join("\n");
|
|
52
|
+
}
|
|
53
|
+
export function reportText(r, s = styleOut()) {
|
|
54
|
+
const lines = [s.bold(r.headline)];
|
|
55
|
+
const w = Math.max(0, ...(r.details ?? []).map(([label]) => label.length));
|
|
56
|
+
for (const [label, value] of r.details ?? [])
|
|
57
|
+
lines.push(` ${s.dim(label.padEnd(w))} ${wrap(value, s.width, 2 + w + 3)}`);
|
|
58
|
+
return withEnd(lines.join("\n"), r, s);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* 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
|
+
* a blank line; "" when it has neither. withEnd puts it after text a command has not printed yet.
|
|
63
|
+
*/
|
|
64
|
+
export function endText(r, s = styleOut()) {
|
|
65
|
+
const tips = r.tips?.length ? `\n${r.tips.map((t) => s.dim(`Tip: ${wrap(t, s.width, 5)}`)).join("\n")}` : "";
|
|
66
|
+
const next = r.next?.length ? nextBlock(r.next, s) : "";
|
|
67
|
+
return tips && next ? `${tips}\n${next}` : tips || next;
|
|
68
|
+
}
|
|
69
|
+
export function withEnd(text, r, s = styleOut()) {
|
|
70
|
+
const end = endText(r, s);
|
|
71
|
+
return end ? `${text}\n${end}` : text;
|
|
72
|
+
}
|
|
73
|
+
export const STDIO = { out: (m) => { process.stdout.write(`${m}\n`); }, err: (m) => { process.stderr.write(`${m}\n`); } };
|
|
74
|
+
/** The steps as a program reads them: `[{ command, what }]`, the same list the human output prints under Next. */
|
|
75
|
+
export function nextJson(steps = []) {
|
|
76
|
+
return steps.map(([command, what]) => what ? { command, what } : { command });
|
|
77
|
+
}
|
|
78
|
+
/** Whether a command line asked for data: `--json` anywhere on it (read before the line is parsed, so a wrong line
|
|
79
|
+
* is answered in JSON too). */
|
|
80
|
+
export const wantsJson = (argv) => argv.includes("--json");
|
|
81
|
+
/**
|
|
82
|
+
* A COMMAND'S --json ANSWER (an agent wants clear, unambiguous context and a machine-readable format, and a person
|
|
83
|
+
* and an agent both want the next steps): one JSON object on stdout, always with `next` — the same steps the human
|
|
84
|
+
* output prints — and only the keys the command's spec declares (`CommandSpec.json`), so the help's shape and the answer
|
|
85
|
+
* cannot drift: a key the spec does not name is a bug here, said as one. A list is wrapped in an object for the same
|
|
86
|
+
* reason (`{ tables: […], next }`), never printed bare.
|
|
87
|
+
*/
|
|
88
|
+
export function jsonText(spec, value, next = []) {
|
|
89
|
+
const declared = spec.json;
|
|
90
|
+
if (declared === undefined)
|
|
91
|
+
throw new Error(`mason ${spec.name} prints JSON, and its spec declares no json keys (src/cli/command.ts CommandSpec.json)`);
|
|
92
|
+
const stray = Object.keys(value).filter((k) => !declared.includes(k));
|
|
93
|
+
if (stray.length)
|
|
94
|
+
throw new Error(`mason ${spec.name} --json: ${stray.join(", ")} ${stray.length === 1 ? "is" : "are"} not in its spec's json keys (${declared.join(", ")}): add ${stray.length === 1 ? "it" : "them"} there and to its usage`);
|
|
95
|
+
if (!declared.includes("next"))
|
|
96
|
+
throw new Error(`mason ${spec.name}: its json keys must end with next (every --json answer carries what to run next)`);
|
|
97
|
+
return JSON.stringify({ ...value, next: nextJson(next) }, null, 2);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* THE CODES A FAILURE CARRIES, a closed set a program can branch on (`mason help agent` lists them).
|
|
101
|
+
*/
|
|
102
|
+
export const FAILURE_CODES = {
|
|
103
|
+
usage: "the command line is wrong: a flag, an argument, a value (exit 2; the usage follows for a person)",
|
|
104
|
+
no_project: "no Mason project here or above (mason init makes one)",
|
|
105
|
+
bad_config: "this folder is a Mason project whose .mason/config.json cannot be read: repair it, or move .mason/ aside and mason init",
|
|
106
|
+
bad_source: "the project's sources file (mason/sources/<name>.yml) is missing or names something wrong: the file, the key and the way out are said",
|
|
107
|
+
not_running: "Mason is not running for the project (mason start)",
|
|
108
|
+
unreachable: "Mason is running but does not answer (mason logs says why)",
|
|
109
|
+
not_found: "a name nothing has: a table, a column, a user",
|
|
110
|
+
nothing_yet: "nothing to work on yet: no table added (the next steps say what adds one)",
|
|
111
|
+
refused: "Mason refused what was asked, with the reason",
|
|
112
|
+
failed: "the command tried and could not finish: the reason, and what to do",
|
|
113
|
+
timeout: "ran out of time — mason wait; nothing stopped (exit 124)",
|
|
114
|
+
};
|
|
115
|
+
/** 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. */
|
|
116
|
+
export class CommandFailed extends Error {
|
|
117
|
+
failure;
|
|
118
|
+
constructor(f) { const n = asFailure(f); super(n.message); this.failure = n; }
|
|
119
|
+
}
|
|
120
|
+
export const asFailure = (f) => "problem" in f ? { ...f, message: f.problem } : f;
|
|
121
|
+
/** The failure as a person reads it (above): a red ✘ (wherever a step fails, as a green tick shows wherever one
|
|
122
|
+
* succeeds — the checklist's own mark, ✗ where nothing is styled), the command's name and the
|
|
123
|
+
* message in bold, then why and fix as rows, then Next, then a wrong line's usage. */
|
|
124
|
+
export function failureText(cmd, f, s = styleErr()) {
|
|
125
|
+
const head = cmd ? `mason ${cmd}:` : "mason:";
|
|
126
|
+
// the checklist's marks: ✘ on a terminal (beside its ✔), ✗ in plain text (beside its ✓); a style that colours is one
|
|
127
|
+
const mark = s.red("x") === "x" ? "✗" : "✘";
|
|
128
|
+
const lines = [`${s.red(mark)} ${s.red(head)} ${s.bold(wrap(f.message, s.width, 2, mark.length + 1 + head.length + 1))}`];
|
|
129
|
+
const rows = [...(f.why ? [["why", f.why]] : []), ...(f.fix ? [["fix", f.fix]] : [])];
|
|
130
|
+
// a value of several lines (a list of failures, an entry to paste) keeps its lines, each under the first
|
|
131
|
+
for (const [label, value] of rows)
|
|
132
|
+
lines.push(` ${s.dim(label)} ${value.split("\n").map((l, i) => i ? `${" ".repeat(8)}${wrap(l, s.width, 8)}` : wrap(l, s.width, 8)).join("\n")}`);
|
|
133
|
+
let text = lines.join("\n");
|
|
134
|
+
if (f.next?.length)
|
|
135
|
+
text += `\n${nextBlock(f.next, s)}`;
|
|
136
|
+
if (f.usage)
|
|
137
|
+
text += `\n\n${f.usage.replace(/\n$/, "")}`;
|
|
138
|
+
return text;
|
|
139
|
+
}
|
|
140
|
+
/** A failure's `error` as --json carries it: on its own (failureJson), or beside a command's own keys when it did part of
|
|
141
|
+
* its work (mason add: the tables added; mason wait: the tables' state). */
|
|
142
|
+
export function errorJson(f) {
|
|
143
|
+
return { code: f.code, message: f.message, ...(f.why ? { why: f.why } : {}), ...(f.fix ? { fix: f.fix } : {}) };
|
|
144
|
+
}
|
|
145
|
+
/** The failure as a program reads it: `{"error": {code, message, why?, fix?}, "next": […]}`. */
|
|
146
|
+
export function failureJson(f) {
|
|
147
|
+
return JSON.stringify({ error: errorJson(f), next: nextJson(f.next) }, null, 2);
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* SAY A FAILURE AND RETURN ITS EXIT CODE — the one way a command reports that it could not do what was asked: for a
|
|
151
|
+
* person on stderr (failureText), under --json as one JSON document on stdout (failureJson), so a program that asked for
|
|
152
|
+
* data never has to read prose. tests/conventions.test.ts refuses a `mason <cmd>: …` written to stderr any other way.
|
|
153
|
+
*/
|
|
154
|
+
export function failed(cmd, f, o) {
|
|
155
|
+
const n = asFailure(f);
|
|
156
|
+
const io = o.io ?? STDIO;
|
|
157
|
+
if (o.json)
|
|
158
|
+
io.out(failureJson(n));
|
|
159
|
+
else
|
|
160
|
+
io.err(failureText(cmd, n, o.style ?? failureStyle(io)));
|
|
161
|
+
return exitOf(n);
|
|
162
|
+
}
|
|
163
|
+
/** The style a failure is drawn in: stderr's own when it goes to stderr, plain for an io a test or a caller supplied.
|
|
164
|
+
* By the STREAM, never by "was an io passed" (CLAUDE.md "Two readers, one interface"): mason init defaults its io to
|
|
165
|
+
* STDIO and passed it on, and every failure it drew was plain — no red head, no dim labels — on a terminal. */
|
|
166
|
+
export const failureStyle = (io) => io === STDIO ? styleErr() : PLAIN;
|
|
167
|
+
/** A failure's exit code: its code's (2 a wrong command line; 124 anything that ran out of time, a wait or a query Mason
|
|
168
|
+
* did not answer in time; else 1), unless it names one. The one place it is decided, so the same refusal exits the
|
|
169
|
+
* same wherever it is found. */
|
|
170
|
+
export function exitOf(f) {
|
|
171
|
+
return f.exit ?? (f.code === "usage" ? 2 : f.code === "timeout" ? 124 : 1);
|
|
172
|
+
}
|