@mason-data/cli 0.9.0 → 0.11.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 +6 -4
- package/dist/src/api/error.js +4 -0
- package/dist/src/api/postgres/proxy.js +6 -160
- package/dist/src/api/postgres/server.js +22 -4
- package/dist/src/cli/command.js +169 -5
- package/dist/src/cli/commands.js +295 -159
- package/dist/src/cli/deploy.js +134 -28
- package/dist/src/cli/dev.js +21 -5
- package/dist/src/cli/doctor.js +14 -11
- package/dist/src/cli/guides.js +20 -11
- package/dist/src/cli/hosted.js +1 -1
- package/dist/src/cli/init.js +110 -25
- package/dist/src/cli/login.js +20 -17
- package/dist/src/cli/machine.js +125 -40
- package/dist/src/cli/main.js +51 -8
- package/dist/src/cli/mcp.js +15 -12
- package/dist/src/cli/project.js +25 -2
- package/dist/src/cli/registry.js +80 -35
- package/dist/src/cli/replica-hosted.js +761 -102
- package/dist/src/cli/replica.js +43 -5
- package/dist/src/cli/replication-mode.js +5 -0
- package/dist/src/cli/request-filter.js +16 -6
- package/dist/src/cli/run-server.js +70 -31
- package/dist/src/cli/secret-input.js +193 -0
- package/dist/src/cli/security.js +7 -2
- package/dist/src/cli/serve.js +119 -9
- package/dist/src/cli/share.js +20 -15
- package/dist/src/cli/source-role.js +14 -15
- package/dist/src/cli/sources.js +20 -3
- package/dist/src/cli/style.js +14 -0
- package/dist/src/cli/sync.js +17 -8
- package/dist/src/cli/token-hosted.js +30 -24
- package/dist/src/cli/tunnel.js +1 -1
- package/dist/src/cli/ui-hosted.js +163 -0
- package/dist/src/cli/ui-read-only.js +23 -0
- package/dist/src/cli/ui-run-file.js +247 -0
- package/dist/src/cli/ui-socket.js +183 -0
- package/dist/src/cli/ui-ssh.js +3 -3
- package/dist/src/cli/ui.js +67 -26
- package/dist/src/cli/usage.js +19 -15
- package/dist/src/cli/user-hosted.js +78 -13
- package/dist/src/cli/user.js +168 -50
- package/dist/src/duck/users.js +105 -9
- package/dist/src/lib/cidr.js +177 -0
- package/dist/src/lib/table-sync.js +69 -19
- package/dist/src/web/app.css +5 -0
- package/dist/src/web/app.js +4 -4
- package/dist/src/web/index.html +5 -8
- package/dist/src/web/pages/queries.js +3 -3
- package/dist/src/web/pages/status.js +23 -17
- package/dist/src/web/pages/syncs.js +3 -3
- package/dist/src/web/pages/tables.js +18 -15
- package/dist/src/web/pages/usage.js +16 -12
- package/dist/src/web/pages/users.js +45 -14
- package/dist/src/web/shared.js +10 -4
- package/package.json +6 -6
package/dist/src/cli/serve.js
CHANGED
|
@@ -19,6 +19,10 @@
|
|
|
19
19
|
*
|
|
20
20
|
* - ITS WEB PAGE ON LOOPBACK ALONE (src/cli/run-server.ts `listenOnLoopback`): the run's server, MASON_UI_PORT, reached
|
|
21
21
|
* from a developer's machine with `mason ui --ssh`, never published. No setting moves it to another address.
|
|
22
|
+
* MASON_UI_SOCKET serves it on a Unix socket too, for a process on this machine (src/cli/ui-socket.ts): mode 0600, in
|
|
23
|
+
* a folder only Mason's user can write, behind the same guard as `mason ui --ssh`'s requests. MASON_UI_RUN_FILE
|
|
24
|
+
* writes the page's port and the run's token to a file for a supervisor on this machine (src/cli/ui-run-file.ts),
|
|
25
|
+
* held to the same bar, once the run answers; /healthz says from the start where the page is served (`PageServed`).
|
|
22
26
|
*
|
|
23
27
|
* The commands beside it work as on a laptop, in the data folder: `docker compose exec mason mason add orders`.
|
|
24
28
|
*/
|
|
@@ -36,7 +40,10 @@ import { connectDatabase, DATABASE_SETTING, databaseNameProblem } from "../lib/d
|
|
|
36
40
|
import { appEnvValue } from "../lib/dotenv.js";
|
|
37
41
|
import { imagePinProblem, PIN_FILE } from "../lib/project-version.js";
|
|
38
42
|
import { commandPinProblem } from "./pin.js";
|
|
43
|
+
import { listenOnUiSocket, prepareUiSocket, uiSocketSetting } from "./ui-socket.js";
|
|
44
|
+
import { prepareUiRunFile, uiRunFileSetting, UI_RUN_FILE_SETTING, writeUiRunFile } from "./ui-run-file.js";
|
|
39
45
|
import { recordRefusal } from "./replication-mode.js";
|
|
46
|
+
import { UI_READ_ONLY_SETTING, uiReadOnlySetting } from "./ui-read-only.js";
|
|
40
47
|
import { changesRetiredText, pollMsRetiredText } from "../lib/table-sync.js";
|
|
41
48
|
import { historyDaysSetting, historyDaysWrongText } from "../duck/history.js";
|
|
42
49
|
import { DEFAULT_MEMORY_RULE, memoryProblems, memoryProblemText, memorySize, MEMORY_FLOOR_BYTES } from "../duck/store.js";
|
|
@@ -59,6 +66,8 @@ export const SERVE = {
|
|
|
59
66
|
DATABASE_URL=postgres://mason:…@db.internal:5432/app MASON_API_KEY=… mason serve
|
|
60
67
|
docker compose exec mason mason add public.orders beside it, in its container
|
|
61
68
|
|
|
69
|
+
--json a refusal to start as data: { error, next }; once it serves, its output is its log
|
|
70
|
+
|
|
62
71
|
Settings (the environment):
|
|
63
72
|
DATABASE_URL the database to sync from, as the sources file's url: \${DATABASE_URL} names it (the variable
|
|
64
73
|
the file names is required); read-only: SELECTs, or a replication slot
|
|
@@ -79,6 +88,18 @@ export const SERVE = {
|
|
|
79
88
|
MASON_UI_PORT the web page's and the commands' port, on 127.0.0.1 alone: no setting moves the address, and
|
|
80
89
|
mason ui --ssh <user@server> opens the page from your machine (default ${DEFAULT_UI_PORT}, or the next
|
|
81
90
|
free one; a port named here is that port, or the start is refused)
|
|
91
|
+
MASON_UI_SOCKET a Unix socket the web page is served on too, for a process on this machine (a supervisor,
|
|
92
|
+
a local reverse proxy): made mode 0600 in a folder only Mason's user can write (else the
|
|
93
|
+
start is refused), a stale one replaced, removed on exit; a request on it passes the same
|
|
94
|
+
guard as mason ui --ssh's (default unset: none)
|
|
95
|
+
MASON_UI_RUN_FILE a file Mason writes once it answers, for a process on this machine that supervises it:
|
|
96
|
+
{ "port", "token" }, the web page's port and the run's token, which grants what the token
|
|
97
|
+
grants — the commands' writes (mason add, mason sync, mason user) as well as a tunnel to
|
|
98
|
+
the page; written whole, mode 0600, in a folder only Mason's user can write (else the
|
|
99
|
+
start is refused), a stale one replaced, removed on exit (default unset: none)
|
|
100
|
+
MASON_UI_READ_ONLY 1 when the web page should only show: it offers no control that changes anything, and
|
|
101
|
+
refuses every change it is sent, through mason ui --ssh or the socket alike; the mason
|
|
102
|
+
commands beside it are unchanged (default 0)
|
|
82
103
|
MASON_PG_HOST, MASON_PG_PORT the Postgres API's address (default 127.0.0.1:5433)
|
|
83
104
|
MASON_PG_TLS_CERT, MASON_PG_TLS_KEY its certificate, required off loopback; or MASON_PG_SELF_SIGNED_TLS=1
|
|
84
105
|
for one made in .mason/, or MASON_PG_PLAIN=1 behind a TLS proxy of your own
|
|
@@ -98,8 +119,6 @@ export const SERVE = {
|
|
|
98
119
|
UV_THREADPOOL_SIZE threads for running statements, one each, 1 to 1024 (default 32, lowered to fit a limit
|
|
99
120
|
on threads; one that does not fit, or is no such number, refuses the start)
|
|
100
121
|
|
|
101
|
-
--json a refusal to start as data: { error, next }; once it serves, its output is its log
|
|
102
|
-
|
|
103
122
|
Exit status: 0 stopped by SIGTERM or SIGINT; 1 it could not start (said); 2 a wrong command line.
|
|
104
123
|
`,
|
|
105
124
|
options: { json: { type: "boolean", default: false } },
|
|
@@ -164,6 +183,19 @@ export function serveSettings(env, cwd) {
|
|
|
164
183
|
else if (env.MASON_UI_PORT && uiPort === pgPortOf(env))
|
|
165
184
|
wrong.push(`MASON_UI_PORT=${uiText} is the Postgres API's port too (MASON_PG_PORT): two ports`);
|
|
166
185
|
// a host would only be believed: the page has no address but loopback, and running on while one is asked for would hide that
|
|
186
|
+
const socket = uiSocketSetting(env, cwd);
|
|
187
|
+
if (socket !== null && typeof socket !== "string")
|
|
188
|
+
wrong.push(socket.problem);
|
|
189
|
+
// the run's file (src/cli/ui-run-file.ts): never the socket's own path
|
|
190
|
+
const runFile = uiRunFileSetting(env, cwd);
|
|
191
|
+
if (runFile !== null && typeof runFile !== "string")
|
|
192
|
+
wrong.push(runFile.problem);
|
|
193
|
+
else if (runFile !== null && runFile === socket)
|
|
194
|
+
wrong.push(`${UI_RUN_FILE_SETTING} names the path MASON_UI_SOCKET names: two paths`);
|
|
195
|
+
// the read-only page (src/cli/ui-read-only.ts): 1 or 0, anything else refused here, never read as off
|
|
196
|
+
const pageReadOnly = uiReadOnlySetting(env);
|
|
197
|
+
if (typeof pageReadOnly !== "boolean")
|
|
198
|
+
wrong.push(pageReadOnly.problem);
|
|
167
199
|
if (env.MASON_UI_HOST)
|
|
168
200
|
wrong.push("MASON_UI_HOST is set, and the web page has no address but 127.0.0.1: remove it (mason ui --ssh <user@server> opens the page from your machine)");
|
|
169
201
|
if (wrong.length) {
|
|
@@ -176,7 +208,7 @@ export function serveSettings(env, cwd) {
|
|
|
176
208
|
name, apiKey, selfSignedTls: selfSigned === "1",
|
|
177
209
|
project, data,
|
|
178
210
|
health: { host: env.MASON_HEALTH_HOST || "0.0.0.0", port },
|
|
179
|
-
ui: { port: uiPort, named: !!env.MASON_UI_PORT },
|
|
211
|
+
ui: { port: uiPort, named: !!env.MASON_UI_PORT, socket: typeof socket === "string" ? socket : null, runFile: typeof runFile === "string" ? runFile : null },
|
|
180
212
|
};
|
|
181
213
|
}
|
|
182
214
|
/**
|
|
@@ -331,6 +363,7 @@ export function serveRecordRefusal(s, existing, env) {
|
|
|
331
363
|
return null;
|
|
332
364
|
return recordRefusal(existing.config, file, env, sourcesTop(path.dirname(s.project), s.project).shown, "mason serve");
|
|
333
365
|
}
|
|
366
|
+
export const NO_PAGE_SERVED = { socket: null, readOnly: false };
|
|
334
367
|
/** seconds since the serve started: /healthz's `uptimeSeconds` and /metrics' `mason_uptime_seconds`, one number */
|
|
335
368
|
const uptimeSeconds = (startedAt, now) => Math.floor((now - startedAt) / 1000);
|
|
336
369
|
/**
|
|
@@ -338,8 +371,8 @@ const uptimeSeconds = (startedAt, now) => Math.floor((now - startedAt) / 1000);
|
|
|
338
371
|
* leaves the rest served: the counts say it); 503 while it starts and stops. Counts only, never a table's name: the
|
|
339
372
|
* endpoint takes no credential.
|
|
340
373
|
*/
|
|
341
|
-
export function healthBody(state, startedAt, now = Date.now()) {
|
|
342
|
-
const base = { status: state.phase, version: version(), uptimeSeconds: uptimeSeconds(startedAt, now) };
|
|
374
|
+
export function healthBody(state, startedAt, now = Date.now(), page = NO_PAGE_SERVED) {
|
|
375
|
+
const base = { status: state.phase, version: version(), uptimeSeconds: uptimeSeconds(startedAt, now), page: { socket: page.socket, readOnly: page.readOnly } };
|
|
343
376
|
if (state.phase !== "ready")
|
|
344
377
|
return { status: 503, body: base };
|
|
345
378
|
return { status: 200, body: { ...base, tables: phaseCounts(state.tables()) } };
|
|
@@ -355,7 +388,7 @@ export async function metricsBody(state, startedAt, now = Date.now()) {
|
|
|
355
388
|
return metricsText({ version: version(), run: { ...run, uptimeSeconds: uptimeSeconds(startedAt, now) } });
|
|
356
389
|
}
|
|
357
390
|
/** The health server: GET /healthz and GET /metrics, nothing else. `log` says a scrape whose numbers could not be read. */
|
|
358
|
-
export function healthServer(state, startedAt, log = say) {
|
|
391
|
+
export function healthServer(state, startedAt, log = say, page = NO_PAGE_SERVED) {
|
|
359
392
|
return http.createServer((req, res) => {
|
|
360
393
|
const url = new URL(req.url ?? "/", "http://health");
|
|
361
394
|
if (url.pathname !== "/healthz" && url.pathname !== "/metrics") {
|
|
@@ -381,7 +414,7 @@ export function healthServer(state, startedAt, log = say) {
|
|
|
381
414
|
});
|
|
382
415
|
return;
|
|
383
416
|
}
|
|
384
|
-
const h = healthBody(state(), startedAt);
|
|
417
|
+
const h = healthBody(state(), startedAt, Date.now(), page);
|
|
385
418
|
res.writeHead(h.status, { "content-type": "application/json", "cache-control": "no-store" }).end(`${JSON.stringify(h.body)}\n`);
|
|
386
419
|
});
|
|
387
420
|
}
|
|
@@ -421,14 +454,57 @@ export async function serve(argv) {
|
|
|
421
454
|
const held = s.ui.named ? await portHolder(s.ui.port) : null;
|
|
422
455
|
if (held !== null)
|
|
423
456
|
return failed("serve", { message: `MASON_UI_PORT: ${heldText(s.ui.port, held)}`, code: "refused", fix: "name another port, or leave MASON_UI_PORT unset for the next free one" }, { json });
|
|
457
|
+
// the page's socket, before anything listens: its folder and its path held to the bar, a stale one replaced, then made
|
|
458
|
+
let page = null;
|
|
459
|
+
// what making it says, under the start's heading below
|
|
460
|
+
const socketSaid = [];
|
|
461
|
+
if (s.ui.socket !== null) {
|
|
462
|
+
const prepared = await prepareUiSocket(s.ui.socket, (l) => socketSaid.push(l));
|
|
463
|
+
if (prepared !== null)
|
|
464
|
+
return failed("serve", prepared, { json });
|
|
465
|
+
const opened = await listenOnUiSocket(s.ui.socket, (l) => say(` ${l}`));
|
|
466
|
+
if ("message" in opened)
|
|
467
|
+
return failed("serve", opened, { json });
|
|
468
|
+
page = opened;
|
|
469
|
+
}
|
|
470
|
+
// the run's file: written once the run answers (asserted wide: they are set in the run's hooks, which the compiler
|
|
471
|
+
// does not follow)
|
|
472
|
+
let runFile = null;
|
|
473
|
+
let runFileFailed = null;
|
|
474
|
+
// a SIGTERM before the run has its own stop (the run file's check, the run still connecting) ends the process by its
|
|
475
|
+
// default action, which runs no finally: the socket is removed first, then the signal raised again as it was
|
|
476
|
+
const early = (signal) => {
|
|
477
|
+
if (process.listenerCount(signal) > 1)
|
|
478
|
+
return; // the run's stop is in place: it stops, and the finally removes it
|
|
479
|
+
page?.close();
|
|
480
|
+
runFile?.remove();
|
|
481
|
+
unhookEarly();
|
|
482
|
+
process.kill(process.pid, signal);
|
|
483
|
+
};
|
|
484
|
+
const unhookEarly = () => { process.removeListener("SIGTERM", early); process.removeListener("SIGINT", early); };
|
|
485
|
+
if (page !== null) {
|
|
486
|
+
process.on("SIGTERM", early);
|
|
487
|
+
process.on("SIGINT", early);
|
|
488
|
+
}
|
|
489
|
+
// its folder and its path held to the bar now, a stale one removed (its run asked, up to 2 s: the handler is in place)
|
|
490
|
+
if (s.ui.runFile !== null) {
|
|
491
|
+
const prepared = await prepareUiRunFile(s.ui.runFile, (l) => socketSaid.push(l));
|
|
492
|
+
if (prepared !== null) {
|
|
493
|
+
page?.close();
|
|
494
|
+
unhookEarly();
|
|
495
|
+
return failed("serve", prepared, { json });
|
|
496
|
+
}
|
|
497
|
+
}
|
|
424
498
|
const startedAt = Date.now();
|
|
425
499
|
let state = { phase: "starting" };
|
|
426
|
-
const health = healthServer(() => state, startedAt);
|
|
500
|
+
const health = healthServer(() => state, startedAt, say, { socket: page?.file ?? null, readOnly: uiReadOnlySetting(process.env) === true });
|
|
427
501
|
const listening = await new Promise((resolve) => {
|
|
428
502
|
health.once("error", resolve);
|
|
429
503
|
health.listen(s.health.port, s.health.host, () => resolve(null));
|
|
430
504
|
});
|
|
431
505
|
if (listening !== null) {
|
|
506
|
+
page?.close();
|
|
507
|
+
unhookEarly();
|
|
432
508
|
return failed("serve", { message: `the health endpoint cannot listen on ${s.health.host}:${s.health.port}: ${listening.message}`, code: "refused",
|
|
433
509
|
fix: "MASON_HEALTH_PORT names a port free on this machine" }, { json });
|
|
434
510
|
}
|
|
@@ -440,18 +516,52 @@ export async function serve(argv) {
|
|
|
440
516
|
say(` database ${made.database}, what a tool connects with`);
|
|
441
517
|
say(` health http://${s.health.host}:${s.health.port}/healthz`);
|
|
442
518
|
say(` metrics http://${s.health.host}:${s.health.port}/metrics`);
|
|
519
|
+
for (const l of socketSaid)
|
|
520
|
+
say(` ${l}`);
|
|
521
|
+
if (s.ui.runFile !== null)
|
|
522
|
+
say(` run file ${s.ui.runFile}: written once Mason answers (mode 0600; it holds the run's token)`);
|
|
523
|
+
if (page !== null)
|
|
524
|
+
say(` web page ${page.file} (a Unix socket, mode 0600: a request on it passes the same guard as mason ui --ssh's)`);
|
|
525
|
+
if (uiReadOnlySetting(process.env) === true)
|
|
526
|
+
say(` web page read-only (${UI_READ_ONLY_SETTING}=1): it refuses every change it is sent; the mason commands beside it are unchanged`);
|
|
443
527
|
// the run reads the project of the folder it runs in (src/cli/project.ts): the data folder's
|
|
444
528
|
process.chdir(s.data);
|
|
445
529
|
let code;
|
|
446
530
|
try {
|
|
447
531
|
code = await dev({ port: s.ui.port, exactPort: s.ui.named, version: version(), selfSignedTls: s.selfSignedTls,
|
|
448
|
-
serve: { log: say,
|
|
532
|
+
serve: { log: say,
|
|
533
|
+
ready: (run) => {
|
|
534
|
+
// the run's file before /healthz says ready: a supervisor that sees ready finds it
|
|
535
|
+
if (s.ui.runFile !== null) {
|
|
536
|
+
const w = writeUiRunFile(s.ui.runFile, run.page, (l) => say(` ${l}`));
|
|
537
|
+
if ("message" in w) {
|
|
538
|
+
// fail closed: a supervisor would wait on a file that never comes, so the run stops, never says it is
|
|
539
|
+
// ready, records why, and the serve exits 1
|
|
540
|
+
runFileFailed = w;
|
|
541
|
+
say(` ${w.message}: Mason stops (${w.fix})`);
|
|
542
|
+
state = { phase: "stopping" };
|
|
543
|
+
return `stopped: the run file (${UI_RUN_FILE_SETTING}) could not be written: ${w.message.slice(`${UI_RUN_FILE_SETTING}=`.length)}`;
|
|
544
|
+
}
|
|
545
|
+
runFile = w;
|
|
546
|
+
say(` run file ${w.file} (the web page's port and the run's token, mode 0600)`);
|
|
547
|
+
}
|
|
548
|
+
state = { phase: "ready", tables: run.tables, metrics: run.metrics };
|
|
549
|
+
return null;
|
|
550
|
+
},
|
|
551
|
+
stopping: () => { state = { phase: "stopping" }; page?.close(); runFile?.remove(); },
|
|
552
|
+
...(page !== null ? { page: page.attach, socket: page.file } : {}) } });
|
|
449
553
|
}
|
|
450
554
|
finally {
|
|
555
|
+
// removed whatever ended the run: a stop, a crash of the engine's thread, or a start that failed
|
|
556
|
+
page?.close();
|
|
557
|
+
runFile?.remove();
|
|
558
|
+
unhookEarly();
|
|
451
559
|
// a platform's health check may hold a keep-alive connection open, which close() alone would wait for
|
|
452
560
|
health.closeAllConnections();
|
|
453
561
|
await new Promise((resolve) => health.close(() => resolve()));
|
|
454
562
|
}
|
|
563
|
+
if (runFileFailed !== null)
|
|
564
|
+
return failed("serve", runFileFailed, { json: false });
|
|
455
565
|
if (code !== 0)
|
|
456
566
|
return failed("serve", { message: "Mason did not start: what stopped it is said above", code: "failed",
|
|
457
567
|
fix: "fix what it says, then start it again (a platform restarts it by itself)" }, { json: false });
|
package/dist/src/cli/share.js
CHANGED
|
@@ -25,6 +25,7 @@ import { alive } from "./runs.js";
|
|
|
25
25
|
import { appEnvValue } from "../lib/dotenv.js";
|
|
26
26
|
import { newSharePassword, shareSecret } from "./share-run.js";
|
|
27
27
|
import { probeShare, startTunnel, sweepTunnelFiles, TOKEN_RE, TUNNELS, tunnelPlan } from "./tunnel.js";
|
|
28
|
+
import { parseDuration } from "../lib/table-sync.js";
|
|
28
29
|
const out = (m) => { process.stdout.write(`${m}\n`); };
|
|
29
30
|
const say = (m) => { process.stderr.write(`${m}\n`); };
|
|
30
31
|
const DEFAULT_TIMEOUT_S = 30;
|
|
@@ -35,12 +36,22 @@ export const SHARE = {
|
|
|
35
36
|
needs: "run",
|
|
36
37
|
name: "share",
|
|
37
38
|
summary: "share the synced data with a tool outside this machine: a temporary address, TLS end to end, a password for this run",
|
|
38
|
-
usage: `usage: mason share [--role <role>] [--via pinggy|bore] [--timeout <
|
|
39
|
+
usage: `usage: mason share [--role <role>] [--via pinggy|bore] [--timeout <duration>] [--json]
|
|
39
40
|
|
|
40
41
|
For a BI tool in the cloud, which cannot reach 127.0.0.1: opens a temporary public address for this project's Postgres
|
|
41
42
|
API, through a free tunnel service, and prints what to type into the tool. It stays in the foreground: Ctrl-C ends
|
|
42
43
|
the share. Your local tools notice nothing: the share has a socket of its own, and no session is dropped.
|
|
43
44
|
|
|
45
|
+
mason share
|
|
46
|
+
mason share --role sales
|
|
47
|
+
mason share --via bore
|
|
48
|
+
|
|
49
|
+
--role <role> what it reads: a role of mason/roles.yml
|
|
50
|
+
--via <tunnel> pinggy (default) or bore
|
|
51
|
+
--timeout <duration> how long the tunnel has to give its address (default ${DEFAULT_TIMEOUT_S}s; a bare number is
|
|
52
|
+
seconds; 30s, 2m); past it, exit 124
|
|
53
|
+
--json as data: its shape is at the end
|
|
54
|
+
|
|
44
55
|
The socket requires TLS of every connection, so a service that forwards bytes cannot read them. The certificate is
|
|
45
56
|
self-signed (the tool connects with sslmode=require, and checks no certificate), so a malicious service could sit in
|
|
46
57
|
the middle of the handshake: this is for trying Mason, on data you may send that way. mason serve on a host is the
|
|
@@ -56,17 +67,10 @@ export const SHARE = {
|
|
|
56
67
|
line is visible to every user of the machine), a paid Pinggy token, keeps the address, so a tunnel that drops is opened
|
|
57
68
|
again by itself, the password unchanged. --via bore needs bore (cargo install bore-cli).
|
|
58
69
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
--role <role> what it reads: a role of mason/roles.yml
|
|
64
|
-
--via <tunnel> pinggy (default) or bore
|
|
65
|
-
--timeout <s> how long the tunnel has to give its address (default ${DEFAULT_TIMEOUT_S})
|
|
66
|
-
--json as data, once the address is known: { share: { host, port, database, user, password, sslmode,
|
|
67
|
-
fingerprint, role, reads, via, endsAt (ISO 8601, or null), verified (the address answered with this
|
|
68
|
-
share's certificate; false: it could not be reached from this machine) }, next }; the command
|
|
69
|
-
then holds the share: the end is on stderr, exit 0 for Ctrl-C or SIGTERM, 1 otherwise
|
|
70
|
+
The --json answer, once the address is known: { share: { host, port, database, user, password, sslmode,
|
|
71
|
+
fingerprint, role, reads, via, endsAt (ISO 8601, or null), verified (the address answered with this
|
|
72
|
+
share's certificate; false: it could not be reached from this machine) }, next }; the command
|
|
73
|
+
then holds the share: the end is on stderr, exit 0 for Ctrl-C or SIGTERM, 1 otherwise
|
|
70
74
|
`,
|
|
71
75
|
options: { role: { type: "string" }, via: { type: "string" }, timeout: { type: "string" }, json: { type: "boolean", default: false } },
|
|
72
76
|
json: ["share", "next"],
|
|
@@ -135,9 +139,10 @@ export async function share(argv) {
|
|
|
135
139
|
const via = (v.via ?? "pinggy");
|
|
136
140
|
if (!TUNNELS.includes(via))
|
|
137
141
|
return failed("share", { message: `--via must be ${TUNNELS.join(" or ")}, not ${JSON.stringify(v.via)}`, code: "usage", usage: SHARE.usage }, { json });
|
|
138
|
-
const
|
|
139
|
-
if (
|
|
140
|
-
return failed("share", { message: `--timeout
|
|
142
|
+
const read = parseDuration(v.timeout ?? String(DEFAULT_TIMEOUT_S));
|
|
143
|
+
if ("problem" in read)
|
|
144
|
+
return failed("share", { message: `--timeout ${read.problem}`, code: "usage", usage: SHARE.usage }, { json });
|
|
145
|
+
const timeoutS = read.seconds;
|
|
141
146
|
const w = await reach(SHARE, json);
|
|
142
147
|
if (w === null || !("run" in w))
|
|
143
148
|
return 1;
|
|
@@ -187,29 +187,28 @@ function via(x) {
|
|
|
187
187
|
view: ` (a view that writes ${x.entry})`, rule: ` (its rules write ${x.entry})` };
|
|
188
188
|
return said[x.kind];
|
|
189
189
|
}
|
|
190
|
-
const WHY = "Mason only reads,
|
|
190
|
+
const WHY = "Mason only reads, but, for security reasons, we recommend using a role that only reads";
|
|
191
191
|
/** The checks of the role, from its facts: one warning per code that applies, else one line saying it only reads. */
|
|
192
192
|
export function roleChecks(f, ask) {
|
|
193
193
|
const fix = readOnlyFix(f, ask);
|
|
194
|
-
const warn = (code, detail) => ({ name: "role", ok: true, warn: true, code, detail: `${detail}. ${WHY}
|
|
194
|
+
const warn = (code, detail) => ({ name: "role", ok: true, warn: true, code, detail: `${detail}. ${WHY}`, fix });
|
|
195
195
|
const out = [];
|
|
196
196
|
const supers = f.roles.filter((r) => r.superuser);
|
|
197
197
|
if (supers.length) {
|
|
198
198
|
// a superuser may do everything: what else it holds follows from it, and is not said again
|
|
199
199
|
const self = supers.some((r) => r.self);
|
|
200
|
-
out.push(warn("role_superuser", self ? `${f.user} is a superuser
|
|
201
|
-
: `${f.user} is a member of ${list(supers.map((r) => r.name))}, a superuser
|
|
200
|
+
out.push(warn("role_superuser", self ? `${f.user} is a superuser`
|
|
201
|
+
: `${f.user} is a member of ${list(supers.map((r) => r.name))}, a superuser`));
|
|
202
202
|
return out;
|
|
203
203
|
}
|
|
204
204
|
const bypass = f.roles.filter((r) => r.bypassrls);
|
|
205
205
|
if (bypass.length) {
|
|
206
|
-
out.push(warn("role_bypassrls", `${f.user} has BYPASSRLS${route(bypass.filter((r) => !r.self).map((r) => r.name))}
|
|
206
|
+
out.push(warn("role_bypassrls", `${f.user} has BYPASSRLS${route(bypass.filter((r) => !r.self).map((r) => r.name))}`));
|
|
207
207
|
}
|
|
208
208
|
const owned = f.relations.filter((x) => x.owned);
|
|
209
209
|
if (owned.length) {
|
|
210
210
|
const others = [...new Set(owned.map((x) => x.owner).filter((o) => o !== f.user))];
|
|
211
|
-
out.push(warn("role_owns_table", `${f.user} owns ${list(owned.map((x) => `${x.name}${via(x)}`))}${route(others)}
|
|
212
|
-
"an owner may write to, alter or drop a table whatever is granted"));
|
|
211
|
+
out.push(warn("role_owns_table", `${f.user} owns ${list(owned.map((x) => `${x.name}${via(x)}`))}${route(others)}`));
|
|
213
212
|
}
|
|
214
213
|
// an owner holds every privilege on its table: the owner's line said it
|
|
215
214
|
const writable = f.relations.filter((x) => !x.owned && x.privileges.length > 0);
|
|
@@ -223,18 +222,18 @@ export function roleChecks(f, ask) {
|
|
|
223
222
|
}
|
|
224
223
|
const creates = f.schemas.filter((s) => s.create.length > 0 || s.publicCreate);
|
|
225
224
|
if (creates.length) {
|
|
226
|
-
const held = creates.map((s) => `CREATE in schema ${s.name}${s.owned ? ` (${s.owner === f.user ? "it owns the schema" : `${s.owner}, which owns the schema`}
|
|
227
|
-
out.push(warn("role_creates_in_schema", `${f.user} may ${held.join("; ")}
|
|
225
|
+
const held = creates.map((s) => `CREATE in schema ${s.name}${s.owned ? ` (${s.owner === f.user ? "it owns the schema" : `${s.owner}, which owns the schema`})` : route([...s.create.filter((r) => r !== f.user), ...(s.publicCreate ? ["PUBLIC"] : [])])}`);
|
|
226
|
+
out.push(warn("role_creates_in_schema", `${f.user} may ${held.join("; ")}`));
|
|
228
227
|
}
|
|
229
228
|
if (f.foreignTables.length || f.mappedServers.length) {
|
|
230
229
|
const held = [...(f.foreignTables.length ? [`may write the foreign table(s) ${list(f.foreignTables)}`] : []),
|
|
231
|
-
...(f.mappedServers.length ? [`has a user mapping (its own or PUBLIC's) on ${list(f.mappedServers)}
|
|
232
|
-
out.push(warn("role_reaches_foreign_server", `${f.user} ${held.join(", and ")}
|
|
230
|
+
...(f.mappedServers.length ? [`has a user mapping (its own or PUBLIC's) on ${list(f.mappedServers)}`] : [])];
|
|
231
|
+
out.push(warn("role_reaches_foreign_server", `${f.user} ${held.join(", and ")}`));
|
|
233
232
|
}
|
|
234
233
|
if (f.adminOf.length || f.createRole || f.serverRoles.length) {
|
|
235
|
-
const held = [...(f.adminOf.length ? [`has ADMIN OPTION on ${list(f.adminOf)}
|
|
236
|
-
...(f.createRole ? ["has CREATEROLE
|
|
237
|
-
...(f.serverRoles.length ? [`holds ${list(f.serverRoles)}
|
|
234
|
+
const held = [...(f.adminOf.length ? [`has ADMIN OPTION on ${list(f.adminOf)}`] : []),
|
|
235
|
+
...(f.createRole ? ["has CREATEROLE"] : []),
|
|
236
|
+
...(f.serverRoles.length ? [`holds ${list(f.serverRoles)}`] : [])];
|
|
238
237
|
out.push(warn("role_may_become_another", `${f.user} ${held.join("; ")}`));
|
|
239
238
|
}
|
|
240
239
|
// SECURITY DEFINER code: what it writes is in its body, which the catalog does not say, so it is LISTED for a person
|
|
@@ -263,7 +262,7 @@ function definerLine(f) {
|
|
|
263
262
|
return `${d.name} runs as ${d.owner}${d.ownerSuperuser ? ", a superuser" : ", which may write the synced tables"}: ${how.join("; ")}`;
|
|
264
263
|
});
|
|
265
264
|
return `may run ${f.definers.length} SECURITY DEFINER function(s) of roles that may write the synced tables — ${each.join("; ")}. ` +
|
|
266
|
-
"
|
|
265
|
+
"We recommend reviewing each: whether it writes a synced table, and who may call it";
|
|
267
266
|
}
|
|
268
267
|
/** The line a check is when the role could not be asked: said, never taken for "only reads". */
|
|
269
268
|
export function roleUnknown(why) {
|
package/dist/src/cli/sources.js
CHANGED
|
@@ -42,6 +42,15 @@ export const SOURCES_DIR = "sources";
|
|
|
42
42
|
const KEYS = ["kind", "url", "database", "sync", "publication", "tables"];
|
|
43
43
|
const ENTRY_KEYS = ["columns", "exclude", "key", "sync"];
|
|
44
44
|
const ENV_REF = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/;
|
|
45
|
+
/**
|
|
46
|
+
* A `${NAME}` naming one of Mason's own settings (`MASON_*`, such as `MASON_API_KEY`): refused where the file is read,
|
|
47
|
+
* before anything resolves it, because a project may be someone else's and the value would be printed or sent as the
|
|
48
|
+
* publication's name (issue 718). `url:` keeps `${MASON_DATABASE_URL}`, the source's URL `mason init` reads and writes.
|
|
49
|
+
*/
|
|
50
|
+
const ownSetting = (name, key) => !/^MASON_/i.test(name) || (key === "url" && name === "MASON_DATABASE_URL") ? null :
|
|
51
|
+
`${key}: \${${name}} names one of Mason's own settings (MASON_*), which a sources file may not read — ` +
|
|
52
|
+
(key === "url" ? "write url: ${DATABASE_URL} (or ${MASON_DATABASE_URL}), or a postgres:// URL" :
|
|
53
|
+
"write publication: ${PG_PUBLICATION}, the publication's name, or publication: null and Mason makes its own");
|
|
45
54
|
/** The tables a file keeps current by replication, by their effective sync (`effectiveSync`): what streams. */
|
|
46
55
|
export function replicatedEntries(src) {
|
|
47
56
|
return src.tables.filter((e) => effectiveSync(src, e).sync.mode === "replication");
|
|
@@ -176,7 +185,10 @@ export function parseSourceText(text, name, file) {
|
|
|
176
185
|
else {
|
|
177
186
|
const m = ENV_REF.exec(v.url.trim());
|
|
178
187
|
const bare = /^\$([A-Za-z_][A-Za-z0-9_]*)$/.exec(v.url.trim());
|
|
179
|
-
|
|
188
|
+
const own = m ? ownSetting(m[1], "url") : null;
|
|
189
|
+
if (own !== null)
|
|
190
|
+
problems.push(own);
|
|
191
|
+
else if (m)
|
|
180
192
|
url = { env: m[1] };
|
|
181
193
|
else if (bare)
|
|
182
194
|
problems.push(`url: $${bare[1]} — write \${${bare[1]}}: a variable is named in braces`);
|
|
@@ -201,8 +213,13 @@ export function parseSourceText(text, name, file) {
|
|
|
201
213
|
const m = ENV_REF.exec(p);
|
|
202
214
|
if (p === "")
|
|
203
215
|
problems.push("publication: write publication: ${PG_PUBLICATION}, or a publication's name (publication: null and Mason makes its own)");
|
|
204
|
-
else if (m)
|
|
205
|
-
|
|
216
|
+
else if (m) {
|
|
217
|
+
const own = ownSetting(m[1], "publication");
|
|
218
|
+
if (own !== null)
|
|
219
|
+
problems.push(own);
|
|
220
|
+
else
|
|
221
|
+
publication = { env: m[1] };
|
|
222
|
+
}
|
|
206
223
|
else if (p.includes("$"))
|
|
207
224
|
problems.push(`publication: ${JSON.stringify(p)} — write \${NAME} alone, like \${PG_PUBLICATION}, or the publication's name`);
|
|
208
225
|
else
|
package/dist/src/cli/style.js
CHANGED
|
@@ -158,6 +158,20 @@ export function stepRenderer(o) {
|
|
|
158
158
|
done(text) { end(`${s.green(live ? "✔" : "✓")} ${text ?? label ?? ""}`); },
|
|
159
159
|
fail(text) { end(`${s.red(live ? "✘" : "✗")} ${text ?? label ?? ""}`); },
|
|
160
160
|
print(text) { clear(); o.line(text); draw(); },
|
|
161
|
+
/** the step still turning, said in new words (a count that moves: how long it has waited); a step begun if none is */
|
|
162
|
+
relabel(text) {
|
|
163
|
+
if (label === null) {
|
|
164
|
+
label = text;
|
|
165
|
+
if (live) {
|
|
166
|
+
draw();
|
|
167
|
+
timer = setInterval(draw, 80);
|
|
168
|
+
timer.unref();
|
|
169
|
+
}
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
label = text;
|
|
173
|
+
draw();
|
|
174
|
+
},
|
|
161
175
|
/** a step still turning: its label (null when none) */
|
|
162
176
|
open: () => label,
|
|
163
177
|
};
|
package/dist/src/cli/sync.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* polled: its answer is where the stream is, beside the polled ones'. The run's `POST /api/sync` (src/cli/run-server.ts) answers it, through the sync's own
|
|
7
7
|
* poll (`Sync.pollNow`).
|
|
8
8
|
*/
|
|
9
|
-
import { failed, jsonText, parseCommand, reportText } from "./command.js";
|
|
9
|
+
import { failed, failureCode, jsonText, parseCommand, reportText } from "./command.js";
|
|
10
10
|
import { out as styleOut } from "./style.js";
|
|
11
11
|
import { callRun } from "./control.js";
|
|
12
12
|
import { reach } from "./state.js";
|
|
@@ -15,7 +15,7 @@ import { didYouMean } from "../lib/did-you-mean.js";
|
|
|
15
15
|
import { syncedText, syncWords } from "../lib/table-sync.js";
|
|
16
16
|
const out = (m) => { process.stdout.write(`${m}\n`); };
|
|
17
17
|
const n = (x) => x.toLocaleString("en-US");
|
|
18
|
-
export const SYNC_USAGE = `usage: mason sync <table>… [--json]
|
|
18
|
+
export const SYNC_USAGE = `usage: mason sync (<table>… | --all) [--json]
|
|
19
19
|
|
|
20
20
|
Brings these synced tables up to date now: it reads what changed in your database since Mason last read them —
|
|
21
21
|
the rows inserted or updated, and the ones deleted — in one transaction, as a poll does (each one scans its table
|
|
@@ -27,7 +27,9 @@ export const SYNC_USAGE = `usage: mason sync <table>… [--json]
|
|
|
27
27
|
|
|
28
28
|
mason sync orders
|
|
29
29
|
mason sync orders public.events
|
|
30
|
+
mason sync --all every table the sources file names (each one a scan of its table)
|
|
30
31
|
|
|
32
|
+
--all every table the sources file names, instead of naming them
|
|
31
33
|
--json as data: { syncedAt, tables: [{ table, changed, deleted, rows, ms }] (the tables polled), streamed (the
|
|
32
34
|
tables whose sync: is replication, not polled: [{ table, position, state, why }]), next }
|
|
33
35
|
`;
|
|
@@ -36,7 +38,7 @@ export const SYNC = {
|
|
|
36
38
|
name: "sync",
|
|
37
39
|
summary: "bring these synced tables up to date now: mason sync orders (a table whose sync is off is read again only so)",
|
|
38
40
|
usage: SYNC_USAGE,
|
|
39
|
-
options: { json: { type: "boolean" } },
|
|
41
|
+
options: { json: { type: "boolean" }, all: { type: "boolean" } },
|
|
40
42
|
json: ["syncedAt", "tables", "streamed", "next"],
|
|
41
43
|
};
|
|
42
44
|
/**
|
|
@@ -67,8 +69,11 @@ export async function sync(argv) {
|
|
|
67
69
|
return f;
|
|
68
70
|
const json = f.values.json === true;
|
|
69
71
|
const names = [...new Set(f.positionals)];
|
|
70
|
-
|
|
71
|
-
|
|
72
|
+
const all = f.values.all === true;
|
|
73
|
+
if (all && names.length)
|
|
74
|
+
return failed("sync", { message: `--all syncs every added table: name tables (${names.join(" ")}) or --all, not both`, code: "usage", usage: SYNC_USAGE }, { json });
|
|
75
|
+
if (!names.length && !all)
|
|
76
|
+
return failed("sync", { message: "name a table: mason sync orders (mason status lists the added ones), or mason sync --all", code: "usage", usage: SYNC_USAGE }, { json });
|
|
72
77
|
const w = await reach(SYNC, json);
|
|
73
78
|
if (w === null || !("run" in w))
|
|
74
79
|
return 1;
|
|
@@ -76,7 +81,11 @@ export async function sync(argv) {
|
|
|
76
81
|
return guarded("sync", json, async () => {
|
|
77
82
|
const s = await readState(run);
|
|
78
83
|
const added = s.tables.filter((t) => t.added);
|
|
79
|
-
|
|
84
|
+
// --all: every table the sources file names, as the run read it
|
|
85
|
+
const picked = all ? added.map((t) => t.table) : [];
|
|
86
|
+
if (all && !picked.length) {
|
|
87
|
+
return failed("sync", { message: "no table is added yet, so there is nothing to sync", code: "nothing_yet", next: [["mason tables", "the database's tables"], ["mason add <table>", "add one to the sources file"]] }, { json });
|
|
88
|
+
}
|
|
80
89
|
for (const name of names) {
|
|
81
90
|
const t = findTable(added, name);
|
|
82
91
|
if (!("message" in t)) {
|
|
@@ -97,12 +106,12 @@ export async function sync(argv) {
|
|
|
97
106
|
// a sync scans each table whole on the database (and waits for a copy under way): minutes, not the commands' 30 s
|
|
98
107
|
const r = await callRun(run, "POST", "/api/sync", { tables: picked }, 60 * 60_000);
|
|
99
108
|
if (r.status !== 200) {
|
|
100
|
-
const code =
|
|
109
|
+
const code = failureCode(r.body.code, r.status === 404 ? "not_found" : "failed");
|
|
101
110
|
return failed("sync", { message: `${picked.join(", ")} ${picked.length === 1 ? "was" : "were"} not synced`, code, why: String(r.body.error),
|
|
102
111
|
next: [["mason status", "each synced table"], ["mason logs", "the run's log"]] }, { json });
|
|
103
112
|
}
|
|
104
113
|
const b = r.body;
|
|
105
|
-
const next = [[`mason sync ${names.join(" ")}`, "sync again, whenever you need it current"], ["mason status", "how each table is kept current, and when it was last synced"]];
|
|
114
|
+
const next = [[all ? "mason sync --all" : `mason sync ${names.join(" ")}`, "sync again, whenever you need it current"], ["mason status", "how each table is kept current, and when it was last synced"]];
|
|
106
115
|
if (json) {
|
|
107
116
|
out(jsonText(SYNC, { syncedAt: b.at, tables: b.tables, ...(b.streamed ? { streamed: b.streamed } : {}) }, next));
|
|
108
117
|
return 0;
|
|
@@ -28,9 +28,9 @@
|
|
|
28
28
|
* createdAt, expiresAt, lastUsedAt rows, --json `isServiceTime` (lastUsedAt or null)
|
|
29
29
|
* more a tip, --json a boolean
|
|
30
30
|
*/
|
|
31
|
-
import { endText, failed, jsonText, parseCommand, reportText, serviceStyle, strict } from "./command.js";
|
|
31
|
+
import { endText, failed, jsonText, listFlag, parseCommand, reportText, serviceStyle, strict } from "./command.js";
|
|
32
32
|
import { call, carriesNoSecret, echoesSecret, isFailure, isServiceTime, serviceFailure, serviceOf, serviceText, unexpected } from "./hosted.js";
|
|
33
|
-
import { loginOrgs, orgNamed, REPLICA_PART, replicaOf, tokenFor } from "./replica.js";
|
|
33
|
+
import { loginOrgs, orgNamed, orgOf, REPLICA_PART, replicaOf, tokenFor } from "./replica.js";
|
|
34
34
|
import { refusedNext } from "./replica-hosted.js";
|
|
35
35
|
import { holdOutput } from "./output-gate.js";
|
|
36
36
|
import { didYouMean } from "../lib/did-you-mean.js";
|
|
@@ -53,7 +53,7 @@ export const TOKEN = {
|
|
|
53
53
|
name: "token",
|
|
54
54
|
summary: "a hosted Mason service's CI tokens: make one for a replica (printed once), list them, revoke one",
|
|
55
55
|
usage: `usage: mason token create --replica <org>/<name> [--scope deploy|users …] [--name <name>] [--days <n>] [--json]
|
|
56
|
-
mason token list [<org>] [--json]
|
|
56
|
+
mason token list [<org> | --org <org>] [--json]
|
|
57
57
|
mason token revoke <id> [--org <org>] [--json]
|
|
58
58
|
|
|
59
59
|
A CI token lets CI act on ONE replica of a hosted Mason service, for the scopes it was made with (deploy: mason
|
|
@@ -61,6 +61,19 @@ export const TOKEN = {
|
|
|
61
61
|
an owner of the organisation makes, lists and revokes them, signed in as a person (mason login): a CI token
|
|
62
62
|
makes none.
|
|
63
63
|
|
|
64
|
+
mason token create --replica acme/shop --name github-actions
|
|
65
|
+
mason token create --replica acme/shop --scope deploy --scope users --days 30 --json
|
|
66
|
+
mason token list acme
|
|
67
|
+
mason token revoke 3f1c9a2e --org acme
|
|
68
|
+
|
|
69
|
+
--replica <org>/<name> create: the replica it acts on (else MASON_REPLICA)
|
|
70
|
+
--scope <scope,…> create: what it may do, deploy or users; both: --scope deploy,users or the flag twice
|
|
71
|
+
(default deploy)
|
|
72
|
+
--name <name> create: what the list calls it, 1 to 64 characters (default ${DEFAULT_NAME})
|
|
73
|
+
--days <n> create: the days it acts, 1 to ${MAX_DAYS} (default ${DEFAULT_DAYS})
|
|
74
|
+
--org <org> list: the organisation, in place of the argument; revoke: the organisation it is of
|
|
75
|
+
--json as data: its shape is at the end
|
|
76
|
+
|
|
64
77
|
create makes one and prints it ONCE: the service does not keep it, and nothing shows it again, so store it at once
|
|
65
78
|
(as MASON_TOKEN in CI's secrets). It is alone on the first line printed, never broken, or, with --json, in the
|
|
66
79
|
one field token.value of the document, so a setup step can pipe it straight into a secret store. Nothing else
|
|
@@ -72,27 +85,18 @@ export const TOKEN = {
|
|
|
72
85
|
|
|
73
86
|
The login is MASON_TOKEN, else this machine's (mason login); the service is MASON_CLOUD_URL, else the default.
|
|
74
87
|
|
|
75
|
-
mason token create --replica acme/shop --name github-actions
|
|
76
|
-
mason token create --replica acme/shop --scope deploy --scope users --days 30 --json
|
|
77
|
-
mason token list acme
|
|
78
|
-
mason token revoke 3f1c9a2e --org acme
|
|
79
|
-
|
|
80
|
-
--replica <org>/<name> create: the replica it acts on (else MASON_REPLICA)
|
|
81
|
-
--scope <scope> create: what it may do, deploy or users; repeat it for both (default deploy)
|
|
82
|
-
--name <name> create: what the list calls it, 1 to 64 characters (default ${DEFAULT_NAME})
|
|
83
|
-
--days <n> create: the days it acts, 1 to ${MAX_DAYS} (default ${DEFAULT_DAYS})
|
|
84
|
-
--org <org> revoke: the organisation it is of
|
|
85
|
-
--json as data: { token (create: { id, value (the token itself, this once), name,
|
|
86
|
-
replica, scopes, createdAt, expiresAt }), tokens (list: [{ id, name, replica, scopes,
|
|
87
|
-
madeBy, createdAt, lastUsedAt (null: never used), expiresAt }]), more (list: true when an
|
|
88
|
-
organisation has more than the service lists), revoked (revoke: { org, id, name }), next }
|
|
89
|
-
|
|
90
88
|
Exit status: 0 done; 1 not signed in, refused by the service (its words printed as they came, the code the
|
|
91
89
|
CLI's own), or the service answered outside its contract; 2 a wrong command line.
|
|
90
|
+
|
|
91
|
+
The --json answer: { token (create: { id, value (the token itself, this once), name,
|
|
92
|
+
replica, scopes, createdAt, expiresAt }), tokens (list: [{ id, name, replica, scopes,
|
|
93
|
+
madeBy, createdAt, lastUsedAt (null: never used), expiresAt }]), more (list: true when an
|
|
94
|
+
organisation has more than the service lists), revokedToken (revoke: { org, id,
|
|
95
|
+
name }), revoked (revoke: the same, kept until 1.0), next }
|
|
92
96
|
`,
|
|
93
97
|
options: { replica: { type: "string" }, scope: { type: "string", multiple: true }, name: { type: "string" }, days: { type: "string" }, org: { type: "string" },
|
|
94
98
|
json: { type: "boolean", default: false } },
|
|
95
|
-
json: ["token", "tokens", "more", "revoked", "next"],
|
|
99
|
+
json: ["token", "tokens", "more", "revokedToken", "revoked", "next"],
|
|
96
100
|
};
|
|
97
101
|
const outStyle = () => serviceStyle(styleOut());
|
|
98
102
|
const errStyle = () => serviceStyle(styleErr());
|
|
@@ -176,10 +180,10 @@ export async function token(argv) {
|
|
|
176
180
|
for (const k of ["replica", "scope", "name", "days"])
|
|
177
181
|
if (verb !== "create" && f.values[k] !== undefined)
|
|
178
182
|
return usage(`--${k} goes with mason token create`);
|
|
179
|
-
if (verb
|
|
180
|
-
return usage("--org goes with mason token
|
|
183
|
+
if (verb === "create" && f.values.org !== undefined)
|
|
184
|
+
return usage("--org goes with mason token list and revoke", "create names it: --replica <org>/<name>");
|
|
181
185
|
if (verb === "list") {
|
|
182
|
-
const org =
|
|
186
|
+
const org = orgOf(typeof f.values.org === "string" ? f.values.org : undefined, arg, TOKEN.usage);
|
|
183
187
|
if (org !== null && isFailure(org))
|
|
184
188
|
return fail(org, json);
|
|
185
189
|
return list(org, json);
|
|
@@ -197,7 +201,9 @@ export async function token(argv) {
|
|
|
197
201
|
return usage("mason token create names the replica it acts on", "--replica <org>/<name>, or MASON_REPLICA=<org>/<name>");
|
|
198
202
|
if (isFailure(r))
|
|
199
203
|
return fail(r, json);
|
|
200
|
-
|
|
204
|
+
// a comma list or the flag repeated, the same (src/cli/command.ts listFlag)
|
|
205
|
+
const listed = listFlag(f.values.scope);
|
|
206
|
+
const asked = listed === undefined ? [] : listed.split(",");
|
|
201
207
|
for (const s of asked)
|
|
202
208
|
if (!SCOPES.includes(s))
|
|
203
209
|
return usage(`--scope ${JSON.stringify(serviceText(s, 100))} is not a scope${didYouMean(s, SCOPES)}`, `a CI token's scopes are ${SCOPES.join(" and ")}`);
|
|
@@ -329,7 +335,7 @@ async function revoke(id, named, json) {
|
|
|
329
335
|
const revoked = { org, id, name: serviceText(j.revoked.name, 64, secrets) };
|
|
330
336
|
const next = [listStep(org)];
|
|
331
337
|
if (json) {
|
|
332
|
-
out(jsonText(TOKEN, { revoked }, next));
|
|
338
|
+
out(jsonText(TOKEN, { revokedToken: revoked, revoked }, next));
|
|
333
339
|
return 0;
|
|
334
340
|
}
|
|
335
341
|
out(reportText({ headline: `Revoked the CI token ${id} of ${org}: it acts no more`, details: [["name", revoked.name]], next }, outStyle()));
|