@mason-data/cli 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -8
- package/dist/src/api/error.js +4 -0
- package/dist/src/api/postgres/catalog-worker.js +49 -2
- package/dist/src/api/postgres/catalog.js +166 -32
- package/dist/src/api/postgres/listener.js +11 -3
- package/dist/src/api/postgres/proxy.js +129 -0
- package/dist/src/api/postgres/schema.js +6 -0
- package/dist/src/api/postgres/server.js +527 -103
- package/dist/src/api/postgres/translate.js +47 -2
- package/dist/src/cli/background.js +18 -19
- package/dist/src/cli/bridge-stdin.js +101 -0
- package/dist/src/cli/command.js +337 -15
- package/dist/src/cli/commands.js +458 -169
- package/dist/src/cli/credentials.js +182 -0
- package/dist/src/cli/demo.js +2 -2
- package/dist/src/cli/deploy.js +680 -0
- package/dist/src/cli/dev.js +148 -27
- package/dist/src/cli/doctor.js +122 -36
- package/dist/src/cli/generated-files.js +143 -0
- package/dist/src/cli/guides.js +116 -16
- package/dist/src/cli/hosted.js +850 -0
- package/dist/src/cli/init.js +154 -33
- package/dist/src/cli/login.js +292 -0
- package/dist/src/cli/machine.js +125 -40
- package/dist/src/cli/main.js +59 -10
- package/dist/src/cli/mason.cjs +17 -0
- package/dist/src/cli/mcp.js +17 -14
- package/dist/src/cli/output-gate.js +176 -0
- package/dist/src/cli/page-share.js +96 -0
- package/dist/src/cli/pg-tls.js +1 -1
- package/dist/src/cli/pin.js +6 -2
- package/dist/src/cli/pool-size.cjs +85 -0
- package/dist/src/cli/ports.js +2 -0
- package/dist/src/cli/preflight.js +130 -39
- package/dist/src/cli/project.js +100 -1
- package/dist/src/cli/readiness.js +42 -6
- package/dist/src/cli/registry.js +89 -36
- package/dist/src/cli/replica-hosted.js +1324 -0
- package/dist/src/cli/replica.js +114 -0
- package/dist/src/cli/replication-mode.js +5 -0
- package/dist/src/cli/request-filter.js +16 -6
- package/dist/src/cli/roles.js +2 -1
- package/dist/src/cli/run-server.js +179 -9
- package/dist/src/cli/secret-input.js +193 -0
- package/dist/src/cli/security.js +57 -10
- package/dist/src/cli/serve.js +65 -9
- package/dist/src/cli/share-run.js +51 -8
- package/dist/src/cli/share.js +23 -18
- package/dist/src/cli/source-edit.js +10 -4
- package/dist/src/cli/source-role.js +343 -0
- package/dist/src/cli/sources.js +50 -23
- package/dist/src/cli/style.js +16 -1
- package/dist/src/cli/supervise.js +15 -23
- package/dist/src/cli/sync.js +17 -8
- package/dist/src/cli/token-hosted.js +343 -0
- package/dist/src/cli/tunnel.js +1 -1
- package/dist/src/cli/ui-hosted.js +163 -0
- package/dist/src/cli/ui-ssh.js +460 -0
- package/dist/src/cli/ui.js +162 -17
- package/dist/src/cli/usage.js +19 -15
- package/dist/src/cli/user-hosted.js +364 -0
- package/dist/src/cli/user.js +275 -105
- package/dist/src/demo/seed.js +26 -0
- package/dist/src/duck/analyze.js +36 -20
- package/dist/src/duck/collation.js +266 -0
- package/dist/src/duck/constant-key.js +94 -0
- package/dist/src/duck/distinct-key.js +283 -26
- package/dist/src/duck/events.js +39 -8
- package/dist/src/duck/executor-worker.js +2 -2
- package/dist/src/duck/executor.js +6 -5
- package/dist/src/duck/explain.js +4 -0
- package/dist/src/duck/functions.js +448 -49
- package/dist/src/duck/gaps.js +34 -16
- package/dist/src/duck/generated-plan.js +1070 -0
- package/dist/src/duck/generated.js +906 -0
- package/dist/src/duck/grants.js +1 -1
- package/dist/src/duck/held-types.js +64 -0
- package/dist/src/duck/history.js +4 -0
- package/dist/src/duck/jsonb-order.js +9 -12
- package/dist/src/duck/kept-route.js +6 -5
- package/dist/src/duck/kept.js +9 -7
- package/dist/src/duck/late.js +1469 -40
- package/dist/src/duck/libc-bound.js +341 -0
- package/dist/src/duck/libc-order.js +892 -126
- package/dist/src/duck/pg-eval-worker.js +7 -1
- package/dist/src/duck/pg-eval.js +6 -4
- package/dist/src/duck/pgtree.js +774 -23
- package/dist/src/duck/plan.js +275 -30
- package/dist/src/duck/publication.js +14 -0
- package/dist/src/duck/requests.js +9 -5
- package/dist/src/duck/resolved.js +117 -2
- package/dist/src/duck/rewrite.js +4567 -583
- package/dist/src/duck/row-filter.js +12 -6
- package/dist/src/duck/statement.js +19 -0
- package/dist/src/duck/store.js +294 -68
- package/dist/src/duck/stream.js +9 -5
- package/dist/src/duck/sync.js +602 -126
- package/dist/src/duck/token.js +4 -1
- package/dist/src/duck/types.js +63 -7
- package/dist/src/duck/usage.js +6 -4
- package/dist/src/duck/users.js +110 -9
- package/dist/src/duck/verified.js +159 -2
- package/dist/src/duck/views.js +4 -3
- package/dist/src/lib/cidr.js +177 -0
- package/dist/src/lib/database-url.js +55 -10
- package/dist/src/lib/instant.js +50 -0
- package/dist/src/lib/log-buffer.js +13 -4
- package/dist/src/lib/log-read.js +196 -0
- package/dist/src/lib/pg-numeric.js +387 -49
- package/dist/src/lib/secret-mask.js +142 -0
- package/dist/src/lib/table-sync.js +84 -19
- package/dist/src/web/app.js +9 -4
- package/dist/src/web/pages/status.js +178 -13
- package/dist/src/web/pages/syncs.js +1 -1
- package/dist/src/web/pages/tables.js +8 -3
- package/dist/src/web/pages/users.js +4 -3
- package/dist/src/web/shared.js +19 -3
- package/package.json +8 -7
package/dist/src/cli/init.js
CHANGED
|
@@ -30,14 +30,15 @@ import { cliPinProblem, ensurePin } from "./pin.js";
|
|
|
30
30
|
import { version } from "./version.js";
|
|
31
31
|
import { parseVersion, PIN_FILE } from "../lib/project-version.js";
|
|
32
32
|
import { appDatabaseUrl, appEnvValue, appendEnvLine, ENV_FILE } from "../lib/dotenv.js";
|
|
33
|
-
import { DEMO_DIR, NAME_RE, STATE_DIR, brokenProblem, findProject, ignoresStateDir, nameFromFolder, newConfig, writeConfig } from "./project.js";
|
|
34
|
-
import { DEMO_ADDED_TABLES, DEMO_ARG, DEMO_PORT, DEMO_SYNC, DEMO_TABLE_TO_ADD, demoUrl } from "./demo.js";
|
|
33
|
+
import { DEMO_DIR, NAME_RE, STATE_DIR, brokenProblem, findProject, folderPlace, ignoresStateDir, nameFromFolder, newConfig, projectWriteRefusal, writeConfig } from "./project.js";
|
|
34
|
+
import { DEMO_ADDED_TABLES, DEMO_ARG, DEMO_GENERATED, DEMO_PORT, DEMO_SYNC, DEMO_TABLE_TO_ADD, demoUrl } from "./demo.js";
|
|
35
|
+
import { GENERATED_DIR, generatedDir } from "./generated-files.js";
|
|
35
36
|
import { freePort } from "./ports.js";
|
|
36
37
|
import { databaseNameProblem, DEFAULT_DATABASE } from "../lib/database-name.js";
|
|
37
38
|
import { DEFAULT_SYNC, syncText } from "../lib/table-sync.js";
|
|
38
39
|
import { newRolesText, rolesFile } from "./roles.js";
|
|
39
|
-
import { newSourceText, OLD_TABLES_DIR, parseSourceText, shownPath, sourceFileNames, sourceNameFor, sourcesDir } from "./sources.js";
|
|
40
|
-
export const INIT_USAGE = `usage: mason init [<postgres url> | demo] [--name <name>] [--database <name>] [--folder <dir>] [--remote] [--skip-check] [--json]
|
|
40
|
+
import { newSourceText, OLD_TABLES_DIR, parseSourceText, shownPath, sourceFileNames, sourceNameFor, sourceOf, sourcesDir } from "./sources.js";
|
|
41
|
+
export const INIT_USAGE = `usage: mason init [- | <postgres url> | demo] [--name <name>] [--database <name>] [--folder <dir>] [--remote] [--skip-check] [--json]
|
|
41
42
|
|
|
42
43
|
Makes this folder a Mason project — run it in your app's folder. It checks it can read the database (and writes
|
|
43
44
|
nothing when it cannot), then creates:
|
|
@@ -46,22 +47,12 @@ export const INIT_USAGE = `usage: mason init [<postgres url> | demo] [--name <na
|
|
|
46
47
|
mason/roles.yml who may read what: one role, everything (tables: all), the default of a user added
|
|
47
48
|
without --role, so every user reads every synced table until you narrow it — commit it
|
|
48
49
|
mason/mason.yml the Mason version the project is written for — commit it: production runs that version
|
|
49
|
-
With no URL it reads MASON_DATABASE_URL, else your app's DATABASE_URL, each from .env.local, then .env, then the
|
|
50
|
-
shell. The source file names the variable read, url: \${DATABASE_URL} — \${MASON_DATABASE_URL} when that is the one
|
|
51
|
-
read, or when a URL given here is another database than your app's DATABASE_URL: the URL itself is in .env (written
|
|
52
|
-
there when you give it here or it is only in the shell), never in a committed file. .mason/ and that .env go into
|
|
53
|
-
.gitignore. Every other mason command then works anywhere in this folder. A source file already here (a clone of the
|
|
54
|
-
project) is kept, and a bare mason init reads the variable it names; a roles file already here is kept. In a project
|
|
55
|
-
with no source file (an older Mason's), it writes that file alone, and the roles file when there is none.
|
|
56
|
-
mason init demo, in a new folder, makes a project on a demo database Mason hosts itself (a shop: customers, products,
|
|
57
|
-
orders, order_items): mason start makes it in .mason/demo/ and changes it every few seconds. Three of its tables are
|
|
58
|
-
added; the fourth is yours to add after mason start (mason add ${DEMO_TABLE_TO_ADD}), to watch its first sync begin.
|
|
59
50
|
|
|
60
51
|
mason init MASON_DATABASE_URL, else DATABASE_URL (.env or the shell)
|
|
61
|
-
mason init
|
|
52
|
+
mason init - < db-url.txt the URL on stdin, written to .env, never on the command line
|
|
62
53
|
mason init demo a demo database Mason hosts itself: nothing to install
|
|
63
54
|
mason init --folder db/mason --name shop-admin the project's files elsewhere; another name
|
|
64
|
-
mason init
|
|
55
|
+
mason init --remote the database in .env is on another machine
|
|
65
56
|
|
|
66
57
|
--name <name> the project's name, which its replication slot is named for (default: this folder's)
|
|
67
58
|
--database <name> the database name a tool connects with, written as database: in the source file (default: none
|
|
@@ -69,17 +60,37 @@ export const INIT_USAGE = `usage: mason init [<postgres url> | demo] [--name <na
|
|
|
69
60
|
--folder <dir> where sources/ goes, relative to this folder (default mason; . for this folder itself)
|
|
70
61
|
--remote go ahead with a database on another machine (read the warnings mason init prints); not with demo
|
|
71
62
|
--skip-check write the project without connecting (the database is not up yet); mason start checks it; not with demo
|
|
72
|
-
--json as data:
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
63
|
+
--json as data: its shape is at the end
|
|
64
|
+
|
|
65
|
+
With no URL it reads MASON_DATABASE_URL, else your app's DATABASE_URL, each from .env.local, then .env, then the
|
|
66
|
+
shell. The source file names the variable read, url: \${DATABASE_URL} — \${MASON_DATABASE_URL} when that is the one
|
|
67
|
+
read, or when a URL given here is another database than your app's DATABASE_URL: the URL itself is in .env (written
|
|
68
|
+
there when you give it here or it is only in the shell), never in a committed file. .mason/ and that .env go into
|
|
69
|
+
.gitignore. Every other mason command then works anywhere in this folder. A source file already here (a clone of the
|
|
70
|
+
project) is kept, and a bare mason init reads the variable it names; a roles file already here is kept. In a project
|
|
71
|
+
with no source file (an older Mason's), it writes that file alone, and the roles file when there is none.
|
|
72
|
+
mason init - reads the URL from stdin (one line, from a pipe), so its password stays off the command line, where ps
|
|
73
|
+
shows it to this machine's users and your shell keeps it in its history: a URL with a password given as the argument
|
|
74
|
+
is still taken, with a warning on stderr.
|
|
75
|
+
mason init demo, in a new folder, makes a project on a demo database Mason hosts itself (a shop: customers, products,
|
|
76
|
+
orders, order_items): mason start makes it in .mason/demo/ and changes it every few seconds. Three of its tables are
|
|
77
|
+
added; the fourth is yours to add after mason start (mason add ${DEMO_TABLE_TO_ADD}), to watch its first sync begin.
|
|
78
|
+
It also writes mason/${GENERATED_DIR}/${DEMO_GENERATED.file}, a generated table over the synced orders (orders and
|
|
79
|
+
revenue per day and status, kept current: mason wait ${DEMO_GENERATED.table} once Mason is up; mason help generated); one
|
|
80
|
+
already there is kept.
|
|
81
|
+
|
|
82
|
+
The --json answer: { name, root, folder, config, demo (true for the demo database's project, else false),
|
|
83
|
+
source: { name (the source's: its file's), file, url (as the file writes it), database (the name
|
|
84
|
+
a tool connects with, or null when the file has a mistake), env (the .env line written, its value masked, or null) }, roles: { file, written (false: one was here, and kept) },
|
|
85
|
+
generated: [{ table, file, written (false: one was here, and kept) }] (the demo's generated table; [] for
|
|
86
|
+
any other project), database: { url (password masked), from, version, tables }, warnings, next }
|
|
76
87
|
`;
|
|
77
88
|
export const INIT = {
|
|
78
89
|
name: "init",
|
|
79
|
-
summary: "make this folder a Mason project on your Postgres: mason init (its URL from .env), mason init
|
|
90
|
+
summary: "make this folder a Mason project on your Postgres: mason init (its URL from .env), mason init - (from stdin) or demo",
|
|
80
91
|
usage: INIT_USAGE,
|
|
81
92
|
options: { name: { type: "string" }, database: { type: "string" }, folder: { type: "string" }, remote: { type: "boolean" }, "skip-check": { type: "boolean" }, json: { type: "boolean" } },
|
|
82
|
-
json: ["name", "root", "folder", "config", "demo", "source", "roles", "database", "warnings", "next"],
|
|
93
|
+
json: ["name", "root", "folder", "config", "demo", "source", "roles", "generated", "database", "warnings", "next"],
|
|
83
94
|
};
|
|
84
95
|
/** Why `name` cannot be this project's, or null: not a name its replication slot takes. */
|
|
85
96
|
export function nameProblem(name) {
|
|
@@ -157,6 +168,63 @@ export function urlVariable(root, url, found = null) {
|
|
|
157
168
|
const addEnvLine = (root, line) => appendEnvLine(root, line, "the database Mason syncs from (mason/sources/ names it; never commit this file)");
|
|
158
169
|
/** A `.env` line as it is said: its value's password masked. */
|
|
159
170
|
const maskedLine = (line) => { const eq = line.indexOf("="); return `${line.slice(0, eq)}=${maskedUrl(line.slice(eq + 1))}`; };
|
|
171
|
+
/** The argument that reads the URL from stdin: `mason init -`. */
|
|
172
|
+
export const STDIN_ARG = "-";
|
|
173
|
+
/** The most of stdin `mason init -` reads (a URL is far shorter), and how long it waits for a pipe left open and silent. */
|
|
174
|
+
const STDIN_MAX_BYTES = 8192, STDIN_TIMEOUT_MS = 10_000;
|
|
175
|
+
/**
|
|
176
|
+
* THE URL FROM A PIPE (`mason init -`): stdin read whole, bounded in bytes and in time, one trailing newline trimmed;
|
|
177
|
+
* a terminal, nothing, more than one line, too much or nothing in time is said by kind — never by what was read, which
|
|
178
|
+
* may hold a password.
|
|
179
|
+
*/
|
|
180
|
+
export function readPipedUrl(input, max = STDIN_MAX_BYTES, timeoutMs = STDIN_TIMEOUT_MS) {
|
|
181
|
+
const way = "pipe one line into it (mason init - < db-url.txt), or put the URL in .env as DATABASE_URL=… and run mason init";
|
|
182
|
+
if (input.isTTY === true)
|
|
183
|
+
return Promise.resolve({ problem: `mason init - reads the URL from a pipe, and stdin is a terminal: ${way}` });
|
|
184
|
+
return new Promise((resolve) => {
|
|
185
|
+
const chunks = [];
|
|
186
|
+
let size = 0, ended = false;
|
|
187
|
+
const finish = (r) => {
|
|
188
|
+
if (ended)
|
|
189
|
+
return;
|
|
190
|
+
ended = true;
|
|
191
|
+
clearTimeout(timer);
|
|
192
|
+
input.removeListener("data", onData);
|
|
193
|
+
input.removeListener("end", onEnd);
|
|
194
|
+
input.removeListener("error", onError);
|
|
195
|
+
input.pause();
|
|
196
|
+
resolve(r);
|
|
197
|
+
};
|
|
198
|
+
const onData = (c) => {
|
|
199
|
+
const b = typeof c === "string" ? Buffer.from(c) : c;
|
|
200
|
+
size += b.length;
|
|
201
|
+
if (size > max) {
|
|
202
|
+
finish({ problem: `stdin held more than ${max} bytes, which is no URL: ${way}` });
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
chunks.push(b);
|
|
206
|
+
};
|
|
207
|
+
const onEnd = () => {
|
|
208
|
+
const text = Buffer.concat(chunks).toString("utf8").replace(/\r?\n$/, "");
|
|
209
|
+
if (text.trim() === "")
|
|
210
|
+
finish({ problem: `stdin was empty: ${way}` });
|
|
211
|
+
else if (/[\r\n]/.test(text))
|
|
212
|
+
finish({ problem: `stdin held more than one line: ${way}` });
|
|
213
|
+
else
|
|
214
|
+
finish({ url: text.trim() });
|
|
215
|
+
};
|
|
216
|
+
const onError = (e) => finish({ problem: `stdin could not be read (${e.message}): ${way}` });
|
|
217
|
+
const timer = setTimeout(() => finish({ problem: `nothing ended on stdin within ${timeoutMs / 1000} s: ${way}` }), timeoutMs);
|
|
218
|
+
input.on("data", onData);
|
|
219
|
+
input.on("end", onEnd);
|
|
220
|
+
input.on("error", onError);
|
|
221
|
+
input.resume();
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
/** The warning for a URL whose password was given as the argument, naming the safer forms — `variable` the one the
|
|
225
|
+
* sources file would name (DATABASE_URL, or MASON_DATABASE_URL for a database other than the app's) — never the password. */
|
|
226
|
+
export const argvPasswordWarning = (variable) => "the URL given on the command line holds its password, which ps shows to this machine's users and your shell keeps in its history: " +
|
|
227
|
+
`put it in .env as ${variable}=… and run mason init, or pipe it in with mason init - < db-url.txt`;
|
|
160
228
|
export async function init(argv, cwd = process.cwd(), deps = {}) {
|
|
161
229
|
const io = deps.io ?? STDIO;
|
|
162
230
|
const { out: say } = io;
|
|
@@ -205,8 +273,16 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
|
|
|
205
273
|
const name = sourceless?.config.name ?? f.values.name ?? nameFromFolder(root);
|
|
206
274
|
const folderArg = f.values.folder ?? "mason";
|
|
207
275
|
const folder = sourceless?.folder ?? path.resolve(root, folderArg);
|
|
208
|
-
|
|
209
|
-
|
|
276
|
+
// the one authority on where the files go (src/cli/project.ts folderPlace): outside this folder, or a place it cannot
|
|
277
|
+
// resolve (a loop, a file as a folder, unreadable, too long), which can never become a project's folder, is refused
|
|
278
|
+
const place = folderPlace(root, folder);
|
|
279
|
+
const shownArg = folderArg.length > 200 ? `${folderArg.slice(0, 200)}…` : folderArg;
|
|
280
|
+
if (place.place === "outside")
|
|
281
|
+
return usage(`--folder ${shownArg} is outside this folder`, "the project's files go in this folder or below it");
|
|
282
|
+
if (place.place === "unknown") {
|
|
283
|
+
return failed("init", { message: `--folder ${shownArg} cannot be resolved to a folder`, code: "usage", why: place.why,
|
|
284
|
+
fix: "name a folder of its own inside this one with --folder (the default, mason, is one), or fix the path" }, { json, io });
|
|
285
|
+
}
|
|
210
286
|
if (fs.existsSync(folder) && !fs.statSync(folder).isDirectory())
|
|
211
287
|
return usage(`${folder} is a file, not a folder`, "--folder <dir> chooses another");
|
|
212
288
|
const named = nameProblem(name);
|
|
@@ -216,6 +292,11 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
|
|
|
216
292
|
const badDatabase = connectName === null ? null : databaseNameProblem(connectName);
|
|
217
293
|
if (badDatabase !== null)
|
|
218
294
|
return usage(`--database: ${badDatabase}`);
|
|
295
|
+
// THE PROJECT'S FILES DEPLOYED READ-ONLY (src/cli/project.ts projectWriteRefusal, the one authority): init writes them,
|
|
296
|
+
// so it writes none — refused before the database is asked, as every writer of a project file is
|
|
297
|
+
const readOnly = projectWriteRefusal(sourceless?.root ?? null, deps.env ?? process.env);
|
|
298
|
+
if (readOnly !== null)
|
|
299
|
+
return failed("init", readOnly, { json, io });
|
|
219
300
|
// THE SOURCE FILE: one already here (a clone of the project) is kept; else written, its URL a variable of .env
|
|
220
301
|
const kept = sourceFileNames(folder)[0] ?? null;
|
|
221
302
|
if (demo && kept !== null) {
|
|
@@ -229,7 +310,19 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
|
|
|
229
310
|
const keptFile = kept === null || keptPath === null ? null : parseSourceText(fs.readFileSync(keptPath, "utf8"), kept.replace(/\.ya?ml$/, ""), keptPath);
|
|
230
311
|
const keptVar = keptFile !== null && !("problems" in keptFile) && "env" in keptFile.url ? keptFile.url.env : null;
|
|
231
312
|
// the demo's URL names a port free now, and is the one place the port is kept: mason start reads it from there
|
|
232
|
-
|
|
313
|
+
// `mason init -`: the URL from a pipe, so its password never reaches the command line (ps, the shell's history)
|
|
314
|
+
const piped = f.positionals[0] === STDIN_ARG;
|
|
315
|
+
let url;
|
|
316
|
+
if (demo)
|
|
317
|
+
url = demoUrl(await freePort(DEMO_PORT));
|
|
318
|
+
else if (piped) {
|
|
319
|
+
const r = await readPipedUrl(deps.stdin ?? process.stdin);
|
|
320
|
+
if ("problem" in r)
|
|
321
|
+
return failed("init", { message: r.problem, code: "usage", usage: INIT_USAGE }, { json, io });
|
|
322
|
+
url = r.url;
|
|
323
|
+
}
|
|
324
|
+
else
|
|
325
|
+
url = f.positionals[0] ?? null;
|
|
233
326
|
let from = "";
|
|
234
327
|
let envFile = null; // the folder's file the URL was read from (.env.local or .env): ignored with .mason/
|
|
235
328
|
let envVariable = null; // the variable envFile holds the URL in, set with it: the warning names it
|
|
@@ -245,8 +338,8 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
|
|
|
245
338
|
if (v === null) {
|
|
246
339
|
return failed("init", { message: `which database? ${file} names url: \${${keptVar}}, and this folder has no ${keptVar}`, code: "usage",
|
|
247
340
|
why: `mason start reads the URL from ${keptVar}, so mason init checks that one`,
|
|
248
|
-
fix: `put it in this folder's .env as ${keptVar}=postgres://… (the variable ${file} names), or
|
|
249
|
-
next: [["mason init
|
|
341
|
+
fix: `put it in this folder's .env as ${keptVar}=postgres://… (the variable ${file} names), or pipe its URL in (mason init - < db-url.txt): mason init writes it there`,
|
|
342
|
+
next: [["mason init - < db-url.txt", `its URL on stdin: written to .env as ${keptVar}`], ["mason init", `again, once .env has ${keptVar}`]] }, { json, io });
|
|
250
343
|
}
|
|
251
344
|
found = { url: v.value, variable: keptVar, from: `${keptVar} in ${v.from}`, file: v.file };
|
|
252
345
|
}
|
|
@@ -255,8 +348,8 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
|
|
|
255
348
|
if (!found) {
|
|
256
349
|
// the fixes ARE the next commands, so a coding agent reads them as such too
|
|
257
350
|
return failed("init", { message: "which database? none is named, and this folder has no MASON_DATABASE_URL or DATABASE_URL", code: "usage",
|
|
258
|
-
fix: "
|
|
259
|
-
next: [["mason init
|
|
351
|
+
fix: "pipe its URL in (mason init - < db-url.txt), or put it in this folder's .env as DATABASE_URL=postgres://… (your app's, usually) or as MASON_DATABASE_URL=postgres://… (read first: a database other than your app's)",
|
|
352
|
+
next: [["mason init - < db-url.txt", "its URL on stdin: written to .env"], ["mason init", "again, once .env has MASON_DATABASE_URL or DATABASE_URL"],
|
|
260
353
|
["mason init demo", "a demo database Mason makes itself: nothing to install"]] }, { json, io });
|
|
261
354
|
}
|
|
262
355
|
url = found.url;
|
|
@@ -264,14 +357,23 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
|
|
|
264
357
|
envFile = found.file;
|
|
265
358
|
envVariable = found.variable;
|
|
266
359
|
}
|
|
267
|
-
|
|
360
|
+
// a problem names where the URL is (DATABASE_URL in .env, the URL given), never the value (src/cli/preflight.ts shownUrl)
|
|
361
|
+
const urlName = found !== null ? found.from : demo ? "the demo database's URL" : piped ? "the URL read from stdin" : "the URL given";
|
|
362
|
+
const parsed = parseSource(url, urlName);
|
|
268
363
|
if ("problem" in parsed)
|
|
269
364
|
return usage(parsed.problem);
|
|
365
|
+
// A PASSWORD ON THE COMMAND LINE: taken (refusing it would break scripts), and said on stderr with the safer forms;
|
|
366
|
+
// the password itself is never repeated
|
|
367
|
+
const argvPassword = !demo && !piped && found === null && parsed.source.password !== "";
|
|
270
368
|
const remote = f.values.remote === true || sourceless?.config.remote === true;
|
|
271
369
|
// a kept file's variable gets this URL in .env when the folder's files give it none; one they give another value is said
|
|
272
370
|
const keptValue = keptVar === null ? null : appEnvValue(root, keptVar, {});
|
|
273
371
|
const where = kept === null ? urlVariable(root, url, found)
|
|
274
372
|
: { variable: keptVar ?? "", line: keptVar !== null && keptValue === null ? `${keptVar}=${url}` : null };
|
|
373
|
+
// the variable .env would hold it in: the one the sources file names (MASON_DATABASE_URL for another database than the app's)
|
|
374
|
+
const argvWarning = argvPassword ? argvPasswordWarning("problem" in where || where.variable === "" ? "DATABASE_URL" : where.variable) : null;
|
|
375
|
+
if (argvWarning !== null)
|
|
376
|
+
io.err(`! ${argvWarning}`);
|
|
275
377
|
if ("problem" in where)
|
|
276
378
|
return failed("init", { message: where.problem, code: "refused" }, { json, io });
|
|
277
379
|
// WHAT IT DOES, AS A CHECKLIST (each item it is doing, with a spinner left of it that turns into a checkmark when
|
|
@@ -284,6 +386,9 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
|
|
|
284
386
|
const db = maskedUrl(parsed.source.url);
|
|
285
387
|
line(st.bold(`Making ${name} a Mason project in ${root}`));
|
|
286
388
|
const notes = [];
|
|
389
|
+
// a person read the warning on stderr already; an agent reads warnings in the answer too
|
|
390
|
+
if (argvWarning !== null && json)
|
|
391
|
+
notes.push(argvWarning);
|
|
287
392
|
if (keptValue !== null && keptValue.value !== url)
|
|
288
393
|
notes.push(`${kept} reads ${keptVar} from ${keptValue.from}, which is ${maskedUrl(keptValue.value)}: mason start syncs from that one, not ${db}`);
|
|
289
394
|
let database = { url: db, from: from ? from.slice(2, -1) : null, version: null, tables: null };
|
|
@@ -291,6 +396,9 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
|
|
|
291
396
|
const sourceFile = kept !== null ? path.join(sourcesDir(folder), kept) : path.join(sourcesDir(folder), `${sourceNameFor(parsed.source.database)}.yml`);
|
|
292
397
|
const rolesPath = rolesFile(folder);
|
|
293
398
|
const rolesKept = fs.existsSync(rolesPath);
|
|
399
|
+
// THE DEMO'S GENERATED TABLE (src/demo/seed.ts DEMO_GENERATED): written beside the sources file, kept when one is here
|
|
400
|
+
const generatedPath = demo ? path.join(generatedDir(folder), DEMO_GENERATED.file) : null;
|
|
401
|
+
const generatedKept = generatedPath !== null && fs.existsSync(generatedPath);
|
|
294
402
|
// the folder an older Mason listed the tables in: read by nothing (no backwards compatibility before v1.0), said
|
|
295
403
|
const oldTables = path.join(folder, OLD_TABLES_DIR);
|
|
296
404
|
if (fs.existsSync(oldTables))
|
|
@@ -315,7 +423,7 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
|
|
|
315
423
|
const said = [];
|
|
316
424
|
let needsRemote = false;
|
|
317
425
|
const checked = await steps.step(`Connecting to ${db}${from}`, async () => {
|
|
318
|
-
const c = await checkSource(url, { remote, cmd: "mason init", say: (m) => said.push(m) });
|
|
426
|
+
const c = await checkSource(url, { remote, cmd: "mason init", say: (m) => said.push(m), name: urlName });
|
|
319
427
|
if ("exit" in c)
|
|
320
428
|
throw new StepFailed(said.filter((m) => !/: connecting to /.test(m)).join("\n"), c.exit);
|
|
321
429
|
if ("needsRemote" in c) {
|
|
@@ -353,6 +461,17 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
|
|
|
353
461
|
`(${demo ? "every table polled every 5 s" : "each table read once, then again only by mason sync <table>"}) and the tables to sync, ` +
|
|
354
462
|
`${demo ? `the demo's ${DEMO_ADDED_TABLES.length} added — mason add ${DEMO_TABLE_TO_ADD} adds the fourth` : "none yet (mason add)"} — commit it`);
|
|
355
463
|
}
|
|
464
|
+
if (generatedPath !== null) {
|
|
465
|
+
const g = rel(generatedPath);
|
|
466
|
+
if (generatedKept)
|
|
467
|
+
await steps.step(`Keeping ${g}`, () => undefined, () => `Kept ${g}: the demo's generated table, as it is`);
|
|
468
|
+
else {
|
|
469
|
+
await steps.step(`Writing ${g}`, () => {
|
|
470
|
+
fs.mkdirSync(path.dirname(generatedPath), { recursive: true });
|
|
471
|
+
fs.writeFileSync(generatedPath, DEMO_GENERATED.text);
|
|
472
|
+
}, () => `Wrote ${g}: a generated table, orders and revenue per day, kept current from the synced orders (mason help generated) — commit it`);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
356
475
|
// THE ROLES FILE (src/cli/roles.ts newRolesText): today's access said in a committed file — every user reads every
|
|
357
476
|
// copied table, as the default role everything — kept when one is here (a clone of the project)
|
|
358
477
|
if (rolesKept)
|
|
@@ -373,7 +492,7 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
|
|
|
373
492
|
const cli = deps.version ?? version();
|
|
374
493
|
const pinFile = rel(path.join(folder, PIN_FILE));
|
|
375
494
|
if (parseVersion(cli) !== null) {
|
|
376
|
-
await steps.step(`Writing ${pinFile}`, () => ensurePin(folder, cli), (wrote) => wrote ? `Wrote ${pinFile}: version ${cli}, the Mason this project is written for — commit it` : `Kept ${pinFile}`);
|
|
495
|
+
await steps.step(`Writing ${pinFile}`, () => ensurePin(folder, cli, root, deps.env ?? process.env), (wrote) => wrote ? `Wrote ${pinFile}: version ${cli}, the Mason this project is written for — commit it` : `Kept ${pinFile}`);
|
|
377
496
|
const pinned = cliPinProblem(folder, cli);
|
|
378
497
|
if (pinned)
|
|
379
498
|
notes.push(pinned);
|
|
@@ -403,6 +522,7 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
|
|
|
403
522
|
const next = demo ? [
|
|
404
523
|
["mason start", "the demo database and Mason, in the background: its tables synced, then kept current"],
|
|
405
524
|
[`mason add ${DEMO_TABLE_TO_ADD}`, "the demo's fourth table, once Mason is up: watch its first sync begin"],
|
|
525
|
+
[`mason wait ${DEMO_GENERATED.table}`, "the demo's generated table, once Mason is up: orders and revenue per day, kept current"],
|
|
406
526
|
["mason status", "the synced data keeping up with the demo's changes"],
|
|
407
527
|
["mason user add <name>", "a user for psql or a BI tool: its password and URL, printed once"],
|
|
408
528
|
] : [
|
|
@@ -416,12 +536,13 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
|
|
|
416
536
|
database: keptFile === null ? connectName ?? DEFAULT_DATABASE : "problems" in keptFile ? null : keptFile.database ?? DEFAULT_DATABASE,
|
|
417
537
|
env: where.line === null ? null : maskedLine(where.line) },
|
|
418
538
|
roles: { file: rolesPath, written: !rolesKept },
|
|
539
|
+
generated: generatedPath === null ? [] : [{ table: sourceOf(DEMO_GENERATED.table), file: generatedPath, written: !generatedKept }],
|
|
419
540
|
database, warnings: notes }, next));
|
|
420
541
|
return 0;
|
|
421
542
|
}
|
|
422
543
|
for (const n of notes)
|
|
423
544
|
say(` ! ${n}`);
|
|
424
|
-
say(endText({ tips: [demo ? `The demo database runs inside mason start, read by polling: a new order every few seconds; ${DEMO_TABLE_TO_ADD} is left for you to add; mason destroy removes
|
|
545
|
+
say(endText({ tips: [demo ? `The demo database runs inside mason start, read by polling: a new order every few seconds; ${DEMO_TABLE_TO_ADD} is left for you to add; ${DEMO_GENERATED.table} is a generated table over the synced orders; mason destroy removes the demo database with the synced data`
|
|
425
546
|
: "Mason only reads your database: no setting, slot or publication is needed or made"], next }, st));
|
|
426
547
|
return 0;
|
|
427
548
|
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mason login` AND `mason logout` — THIS MACHINE SIGNED IN TO A HOSTED MASON SERVICE (docs/HOSTED-API.md). They need
|
|
3
|
+
* no project: a login is the person's, kept in their own config folder (src/cli/credentials.ts), never in a project.
|
|
4
|
+
*
|
|
5
|
+
* `mason login` is OAuth's device authorization grant (RFC 8628; src/cli/hosted.ts speaks it): it asks the service for
|
|
6
|
+
* a code, says where to approve it — at once, on stderr, in plain lines, so a person in a terminal and a coding agent
|
|
7
|
+
* relaying it both read it before the wait begins (a browser opens on a terminal, when one can) — polls at the
|
|
8
|
+
* service's interval (five seconds more at each `slow_down`) until the code is approved, denied or expires, then asks
|
|
9
|
+
* who signed in (`GET /v1/me`) and saves the token. Its answer is ONE report on stdout, or under `--json` ONE object,
|
|
10
|
+
* once, when it ends: what it prints while waiting is progress, on stderr.
|
|
11
|
+
*
|
|
12
|
+
* `mason logout` revokes the saved token (best effort: a service that cannot be reached is said, with the way to revoke
|
|
13
|
+
* it by hand) and removes the saved login either way.
|
|
14
|
+
*
|
|
15
|
+
* A TOKEN NEVER REACHES A COMMAND LINE, A LOG, THE OUTPUT OR `--json`: there is no flag that takes one; `MASON_TOKEN` is
|
|
16
|
+
* read from the environment and never printed; and everything printed from the service is masked (`serviceText`).
|
|
17
|
+
* `MASON_TOKEN` comes before a saved login (CI), so `mason login` with it set is refused, naming how to sign in anyway,
|
|
18
|
+
* and `mason logout` says it cannot remove an environment variable.
|
|
19
|
+
*/
|
|
20
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
21
|
+
import { endText, failed, jsonText, parseCommand, reportText, serviceStyle, strict } from "./command.js";
|
|
22
|
+
import { environmentToken, readLogins, TOKEN_ENV, writeLogins } from "./credentials.js";
|
|
23
|
+
import { DEFAULT_SERVICE_URL, expired, isFailure, pollToken, revoke, serviceOf, serviceText, startDevice, whoAmI } from "./hosted.js";
|
|
24
|
+
import { err as styleErr, out as styleOut, stepRenderer } from "./style.js";
|
|
25
|
+
import { holdOutput } from "./output-gate.js";
|
|
26
|
+
import { OPENABLE, openBrowser } from "./ui.js";
|
|
27
|
+
const out = (m) => { process.stdout.write(`${m}\n`); };
|
|
28
|
+
/** Everything these commands print can hold the service's text, so it is drawn in serviceStyle (src/cli/command.ts):
|
|
29
|
+
* on a terminal the CLI breaks every line itself, and a person never sees a line begin with what the service sent. */
|
|
30
|
+
const outStyle = () => serviceStyle(styleOut());
|
|
31
|
+
const errStyle = () => serviceStyle(styleErr());
|
|
32
|
+
const fail = (cmd, f, o) => failed(cmd, f, { json: o.json, style: errStyle() });
|
|
33
|
+
const say = (m) => { process.stderr.write(`${m}\n`); };
|
|
34
|
+
export const LOGIN = {
|
|
35
|
+
name: "login",
|
|
36
|
+
summary: "sign this machine in to a hosted Mason service, by a code you approve in your browser",
|
|
37
|
+
usage: `usage: mason login [--json]
|
|
38
|
+
|
|
39
|
+
Signs this machine in to a hosted Mason service: it prints a page and a code, opens the page in your browser
|
|
40
|
+
when it runs in a terminal, and waits while you sign in there and approve the code (until the code expires, as
|
|
41
|
+
the service says; Ctrl-C stops it). It then says who is signed in and their organisations, and keeps the login in
|
|
42
|
+
~/.config/mason/credentials.json ($XDG_CONFIG_HOME/mason/ when that is set), readable by you alone, never in a
|
|
43
|
+
project. Signing in again replaces this machine's login to that service, and revokes the one it replaces.
|
|
44
|
+
|
|
45
|
+
mason login
|
|
46
|
+
MASON_CLOUD_URL=http://127.0.0.1:8080 mason login a service on this machine
|
|
47
|
+
|
|
48
|
+
--json as data, once signed in: { service (its address), email, name, orgs: [{ slug, name, role }], file (where
|
|
49
|
+
the login is kept), replaced (null, or the login it replaced: { email, revoked }), next }
|
|
50
|
+
|
|
51
|
+
The page and the code are printed on stderr at once, as plain lines, so a coding agent can pass them on to you
|
|
52
|
+
before the wait ends; the answer is printed when it ends.
|
|
53
|
+
|
|
54
|
+
The service is ${DEFAULT_SERVICE_URL} unless MASON_CLOUD_URL names another (https://, or http:// for one on
|
|
55
|
+
this machine). With MASON_TOKEN set (CI), every command uses that token before a saved login, so mason login is
|
|
56
|
+
refused: run it without the variable (env -u MASON_TOKEN mason login) to sign in anyway.
|
|
57
|
+
|
|
58
|
+
Exit status: 0 signed in; 1 refused or failed (said); 2 a wrong command line; 124 the code expired before it
|
|
59
|
+
was approved (run mason login again).
|
|
60
|
+
`,
|
|
61
|
+
options: { json: { type: "boolean", default: false } },
|
|
62
|
+
json: ["service", "email", "name", "orgs", "file", "replaced", "next"],
|
|
63
|
+
};
|
|
64
|
+
export const LOGOUT = {
|
|
65
|
+
name: "logout",
|
|
66
|
+
summary: "sign this machine out of a hosted Mason service: its login revoked and removed",
|
|
67
|
+
usage: `usage: mason logout [--json]
|
|
68
|
+
|
|
69
|
+
Signs this machine out of the hosted Mason service (MASON_CLOUD_URL, else ${DEFAULT_SERVICE_URL}): it revokes
|
|
70
|
+
the login on the service, then removes it from ~/.config/mason/credentials.json. When the service cannot be
|
|
71
|
+
reached the login is still removed here, and it says the login was NOT revoked and how to revoke it: sign in to
|
|
72
|
+
the service's web app and revoke it there. Logins to other services stay.
|
|
73
|
+
|
|
74
|
+
mason logout
|
|
75
|
+
MASON_CLOUD_URL=http://127.0.0.1:8080 mason logout a service on this machine
|
|
76
|
+
|
|
77
|
+
--json as data: its shape is at the end
|
|
78
|
+
|
|
79
|
+
MASON_TOKEN in the environment is not a saved login: logout cannot remove it, and says so while it is set.
|
|
80
|
+
|
|
81
|
+
The --json answer: { service, signedOut (a saved login was removed), email (its account, null when there was
|
|
82
|
+
none), loginRevoked (true, false when the service did not take it, null when there was nothing to
|
|
83
|
+
revoke), revoked (the same, kept until 1.0), revokeError (why not, else null), environmentToken
|
|
84
|
+
(MASON_TOKEN is set, and still used), file, next }
|
|
85
|
+
`,
|
|
86
|
+
options: { json: { type: "boolean", default: false } },
|
|
87
|
+
json: ["service", "signedOut", "email", "loginRevoked", "revoked", "revokeError", "environmentToken", "file", "next"],
|
|
88
|
+
};
|
|
89
|
+
/** An organisation as a line: its slug, its name when it says more, the role. */
|
|
90
|
+
const orgLine = (o) => `${o.slug}${o.name && o.name !== o.slug ? ` (${o.name})` : ""}, ${o.role}`;
|
|
91
|
+
export async function login(argv) {
|
|
92
|
+
const f = parseCommand(LOGIN, argv);
|
|
93
|
+
if (typeof f === "number")
|
|
94
|
+
return f;
|
|
95
|
+
const json = f.values.json === true;
|
|
96
|
+
if (f.positionals.length)
|
|
97
|
+
return fail("login", { message: "it takes no argument", code: "usage", why: "the service is MASON_CLOUD_URL's, else the default", usage: LOGIN.usage }, { json });
|
|
98
|
+
const service = serviceOf();
|
|
99
|
+
if (isFailure(service))
|
|
100
|
+
return fail("login", service, { json });
|
|
101
|
+
if (environmentToken() !== null) {
|
|
102
|
+
return fail("login", { message: `${TOKEN_ENV} is set in this environment, so a login would not be used`, code: "refused",
|
|
103
|
+
why: `every command sends ${TOKEN_ENV} before a saved login: a new login to ${service.base} would be shadowed by it`,
|
|
104
|
+
fix: `to sign in anyway, run it without the variable: env -u ${TOKEN_ENV} mason login (the login is used once ${TOKEN_ENV} is unset)`,
|
|
105
|
+
next: [[`env -u ${TOKEN_ENV} mason login`, "sign in, for when MASON_TOKEN is unset"]] }, { json });
|
|
106
|
+
}
|
|
107
|
+
// the file is checked BEFORE the person spends a minute approving a code: a folder others can read is said now
|
|
108
|
+
const before = readLogins();
|
|
109
|
+
if (isFailure(before))
|
|
110
|
+
return fail("login", before, { json });
|
|
111
|
+
// what this command holds that the service may echo before it sends a token: the login it replaces (src/cli/hosted.ts
|
|
112
|
+
// `carriesNoSecret`: an answer that carries it is refused whole)
|
|
113
|
+
const held = before.logins.logins[service.base] ? [before.logins.logins[service.base].token] : [];
|
|
114
|
+
// and from here every byte written is put to the exact test against what it holds (src/cli/output-gate.ts)
|
|
115
|
+
holdOutput({ command: "login", json, secrets: held });
|
|
116
|
+
const code = await startDevice(service, held);
|
|
117
|
+
if (isFailure(code))
|
|
118
|
+
return fail("login", code, { json });
|
|
119
|
+
const token = await approved(service, code, json, held);
|
|
120
|
+
if (isFailure(token))
|
|
121
|
+
return fail("login", token, { json });
|
|
122
|
+
holdOutput({ command: "login", json, secrets: [token] });
|
|
123
|
+
// FROM HERE THE SERVICE HAS GRANTED A TOKEN, which is kept only once it is saved: on every failure before that —
|
|
124
|
+
// one said, or one thrown (PR 645's review: a crash left a granted token live) — it is revoked, and a revoke that
|
|
125
|
+
// did not reach the service is said
|
|
126
|
+
let kept;
|
|
127
|
+
try {
|
|
128
|
+
kept = await keep(service, token, held);
|
|
129
|
+
}
|
|
130
|
+
catch (e) {
|
|
131
|
+
const notRevoked = await revoke(service, token);
|
|
132
|
+
if (notRevoked === null)
|
|
133
|
+
throw e;
|
|
134
|
+
throw new Error(`${e instanceof Error ? e.message : String(e)}; the token the service granted was NOT revoked (${notRevoked}): sign in to ${service.base} in a browser and revoke it there`, { cause: e });
|
|
135
|
+
}
|
|
136
|
+
if (isFailure(kept)) {
|
|
137
|
+
const notRevoked = await revoke(service, token);
|
|
138
|
+
return fail("login", notRevoked === null ? kept : { ...kept, why: `${kept.why ? `${kept.why}; ` : ""}the token the service granted was NOT revoked (${notRevoked}): sign in to ${service.base} in a browser and revoke it there` }, { json });
|
|
139
|
+
}
|
|
140
|
+
const { me, was, saved } = kept;
|
|
141
|
+
// the login it replaced is revoked, so this machine never leaves a token it no longer holds alive on the service
|
|
142
|
+
const replaced = was === null || was.token === token ? null : { email: serviceText(was.email, 320, [token, was.token]), revokeError: await revoke(service, was.token) };
|
|
143
|
+
// what the service said, as it may be shown, in the report and in --json alike: printable, bounded, no token in it
|
|
144
|
+
const secrets = [token, ...(was ? [was.token] : [])];
|
|
145
|
+
const email = serviceText(me.email, 320, secrets), name = serviceText(me.name, 300, secrets);
|
|
146
|
+
const orgs = me.orgs.map((o) => ({ slug: serviceText(o.slug, 200, secrets), name: serviceText(o.name, 200, secrets), role: serviceText(o.role, 32, secrets) }));
|
|
147
|
+
const next = [["mason logout", `sign this machine out of ${service.base}`]];
|
|
148
|
+
if (json) {
|
|
149
|
+
out(jsonText(LOGIN, { service: service.base, email, name, orgs, file: saved.file,
|
|
150
|
+
replaced: replaced === null ? null : { email: replaced.email, revoked: replaced.revokeError === null } }, next));
|
|
151
|
+
return 0;
|
|
152
|
+
}
|
|
153
|
+
out(reportText({
|
|
154
|
+
headline: `Signed in to ${service.base} as ${email}`,
|
|
155
|
+
details: [
|
|
156
|
+
...(name ? [["name", name]] : []),
|
|
157
|
+
...(orgs.length ? orgs.map((o, i) => [i === 0 ? "orgs" : "", orgLine(o)]) : [["orgs", "none yet"]]),
|
|
158
|
+
["saved", saved.file],
|
|
159
|
+
],
|
|
160
|
+
tips: replaced === null ? undefined : replaced.revokeError === null
|
|
161
|
+
? [`This replaces the login of ${replaced.email}, which is revoked.`]
|
|
162
|
+
: [`This replaces the login of ${replaced.email}, which was NOT revoked (${replaced.revokeError}): sign in to ${service.base} in a browser and revoke it there.`],
|
|
163
|
+
next,
|
|
164
|
+
}, outStyle()));
|
|
165
|
+
return 0;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* WHAT A GRANTED TOKEN NEEDS BEFORE IT IS KEPT: who signed in (a token the service will not say who it is is never
|
|
169
|
+
* kept), the logins read again just before the write (so a login another command saved meanwhile is kept), and the
|
|
170
|
+
* write. A Failure when any is not had; the caller revokes the token then.
|
|
171
|
+
*/
|
|
172
|
+
async function keep(service, token, held) {
|
|
173
|
+
const me = await whoAmI(service, token, held);
|
|
174
|
+
if (isFailure(me))
|
|
175
|
+
return me;
|
|
176
|
+
const now = readLogins();
|
|
177
|
+
if (isFailure(now))
|
|
178
|
+
return now;
|
|
179
|
+
const was = now.logins.logins[service.base] ?? null;
|
|
180
|
+
const logins = { version: 1, logins: { ...now.logins.logins, [service.base]: { token, email: me.email, savedAt: new Date().toISOString() } } };
|
|
181
|
+
const saved = writeLogins(logins);
|
|
182
|
+
if (isFailure(saved))
|
|
183
|
+
return saved;
|
|
184
|
+
return { me, was, saved };
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* The page and the code said, then the wait: a poll every `interval` seconds (five more at each slow_down, RFC 8628
|
|
188
|
+
* 3.5), until the token, a refusal, or the code's expiry. On a terminal a spinner turns beside the wait; elsewhere the
|
|
189
|
+
* wait is one line.
|
|
190
|
+
*/
|
|
191
|
+
async function approved(service, code, json, held) {
|
|
192
|
+
const page = code.verificationUriComplete ?? code.verificationUri;
|
|
193
|
+
const s = errStyle();
|
|
194
|
+
say(reportText({ headline: `To sign in to ${service.base}, open this page and approve the code:`,
|
|
195
|
+
details: [["page", page], ["code", code.verificationUriComplete ? `${code.userCode} (the page shows it: check they match)` : `${code.userCode} (enter it on the page)`]] }, s));
|
|
196
|
+
const terminal = !json && process.stderr.isTTY === true;
|
|
197
|
+
if (terminal) {
|
|
198
|
+
const couldNot = OPENABLE.test(page) ? await openBrowser(page) : "the page's address is not one Mason opens by itself";
|
|
199
|
+
if (couldNot !== null)
|
|
200
|
+
say(endText({ tips: [`Could not open a browser (${couldNot}): open the page yourself.`] }, s).replace(/^\n/, ""));
|
|
201
|
+
}
|
|
202
|
+
const minutes = Math.max(1, Math.round(code.expiresIn / 60));
|
|
203
|
+
const full = `Waiting for the code to be approved (it expires in ${minutes} min; Ctrl-C stops)`;
|
|
204
|
+
// a spinner's line is redrawn in place, so it must never reach the terminal's edge (src/cli/command.ts serviceStyle):
|
|
205
|
+
// the longest label that fits beside its frame, else no spinner and the wait said once, broken as any line
|
|
206
|
+
const room = (process.stderr.columns ?? 0) - 1 - 6;
|
|
207
|
+
const label = terminal ? [full, "Waiting for approval", "Waiting"].find((l) => l.length <= room) : undefined;
|
|
208
|
+
const live = label !== undefined;
|
|
209
|
+
const steps = stepRenderer({ line: (t) => say(strict(t, s)), live: live ? process.stderr : null, style: s });
|
|
210
|
+
if (live)
|
|
211
|
+
steps.begin(label);
|
|
212
|
+
else
|
|
213
|
+
say(strict(`${full} …`, s));
|
|
214
|
+
const deadline = Date.now() + code.expiresIn * 1000;
|
|
215
|
+
let interval = code.interval;
|
|
216
|
+
for (;;) {
|
|
217
|
+
if (Date.now() + interval * 1000 > deadline) {
|
|
218
|
+
if (live)
|
|
219
|
+
steps.fail();
|
|
220
|
+
return expired(code);
|
|
221
|
+
}
|
|
222
|
+
await sleep(interval * 1000);
|
|
223
|
+
const p = await pollToken(service, code, held);
|
|
224
|
+
if ("pending" in p)
|
|
225
|
+
continue;
|
|
226
|
+
if ("slowDown" in p) {
|
|
227
|
+
interval += 5;
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (isFailure(p)) {
|
|
231
|
+
if (live)
|
|
232
|
+
steps.fail();
|
|
233
|
+
return p;
|
|
234
|
+
}
|
|
235
|
+
if (live)
|
|
236
|
+
steps.done("Approved");
|
|
237
|
+
else
|
|
238
|
+
say("Approved");
|
|
239
|
+
return p.token;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
export async function logout(argv) {
|
|
243
|
+
const f = parseCommand(LOGOUT, argv);
|
|
244
|
+
if (typeof f === "number")
|
|
245
|
+
return f;
|
|
246
|
+
const json = f.values.json === true;
|
|
247
|
+
if (f.positionals.length)
|
|
248
|
+
return fail("logout", { message: "it takes no argument", code: "usage", why: "the service is MASON_CLOUD_URL's, else the default", usage: LOGOUT.usage }, { json });
|
|
249
|
+
const service = serviceOf();
|
|
250
|
+
if (isFailure(service))
|
|
251
|
+
return fail("logout", service, { json });
|
|
252
|
+
const fromEnv = environmentToken() !== null;
|
|
253
|
+
const envTip = fromEnv ? [`${TOKEN_ENV} is set in this environment, and every command still uses it: logout cannot remove an environment variable. Unset it (unset ${TOKEN_ENV}), and revoke it in the service's web app if it was shared.`] : [];
|
|
254
|
+
const read = readLogins();
|
|
255
|
+
if (isFailure(read))
|
|
256
|
+
return fail("logout", read, { json });
|
|
257
|
+
const entry = read.logins.logins[service.base];
|
|
258
|
+
const env = environmentToken();
|
|
259
|
+
if (entry !== undefined || env !== null)
|
|
260
|
+
holdOutput({ command: "logout", json, secrets: [...(entry ? [entry.token] : []), ...(env !== null ? [env] : [])] });
|
|
261
|
+
if (entry === undefined) {
|
|
262
|
+
const next = [["mason login", `sign this machine in to ${service.base}`]];
|
|
263
|
+
if (json) {
|
|
264
|
+
out(jsonText(LOGOUT, { service: service.base, signedOut: false, email: null, loginRevoked: null, revoked: null, revokeError: null, environmentToken: fromEnv, file: read.file }, next));
|
|
265
|
+
return 0;
|
|
266
|
+
}
|
|
267
|
+
out(reportText({ headline: `This machine is not signed in to ${service.base}`, details: [["file", read.file]], tips: envTip, next }, outStyle()));
|
|
268
|
+
return 0;
|
|
269
|
+
}
|
|
270
|
+
const revokeError = await revoke(service, entry.token);
|
|
271
|
+
// removed whether or not the service took the revoke: a person who logs out means this machine to hold it no more
|
|
272
|
+
const rest = { ...read.logins.logins };
|
|
273
|
+
delete rest[service.base];
|
|
274
|
+
const written = writeLogins({ version: 1, logins: rest });
|
|
275
|
+
if (isFailure(written))
|
|
276
|
+
return fail("logout", written, { json });
|
|
277
|
+
const email = serviceText(entry.email, 320, [entry.token]);
|
|
278
|
+
const next = [["mason login", `sign in to ${service.base} again`]];
|
|
279
|
+
if (json) {
|
|
280
|
+
out(jsonText(LOGOUT, { service: service.base, signedOut: true, email, loginRevoked: revokeError === null, revoked: revokeError === null, revokeError, environmentToken: fromEnv, file: written.file }, next));
|
|
281
|
+
return 0;
|
|
282
|
+
}
|
|
283
|
+
const notRevoked = revokeError === null ? undefined
|
|
284
|
+
: `The login was NOT revoked (${revokeError}), so it still works on the service until it expires: sign in to ${service.base} in a browser and revoke this machine's login there.`;
|
|
285
|
+
out(reportText({
|
|
286
|
+
headline: revokeError === null ? `Signed out of ${service.base}` : `Signed out of ${service.base} on this machine, but the login was NOT revoked`,
|
|
287
|
+
details: [["account", email], ["revoked", revokeError === null ? "yes: the service takes this login no more" : "no"], ["removed from", written.file]],
|
|
288
|
+
tips: [...(notRevoked ? [notRevoked] : []), ...envTip],
|
|
289
|
+
next,
|
|
290
|
+
}, outStyle()));
|
|
291
|
+
return 0;
|
|
292
|
+
}
|