@mason-data/cli 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -4
- package/dist/src/api/postgres/catalog-worker.js +49 -2
- package/dist/src/api/postgres/catalog.js +166 -32
- package/dist/src/api/postgres/listener.js +11 -3
- package/dist/src/api/postgres/proxy.js +283 -0
- package/dist/src/api/postgres/schema.js +6 -0
- package/dist/src/api/postgres/server.js +505 -99
- package/dist/src/api/postgres/translate.js +47 -2
- package/dist/src/cli/background.js +18 -19
- package/dist/src/cli/bridge-stdin.js +101 -0
- package/dist/src/cli/command.js +169 -11
- package/dist/src/cli/commands.js +207 -51
- package/dist/src/cli/credentials.js +182 -0
- package/dist/src/cli/demo.js +2 -2
- package/dist/src/cli/deploy.js +574 -0
- package/dist/src/cli/dev.js +148 -27
- package/dist/src/cli/doctor.js +116 -33
- package/dist/src/cli/generated-files.js +143 -0
- package/dist/src/cli/guides.js +97 -6
- package/dist/src/cli/hosted.js +850 -0
- package/dist/src/cli/init.js +47 -11
- package/dist/src/cli/login.js +289 -0
- package/dist/src/cli/machine.js +1 -1
- package/dist/src/cli/main.js +8 -2
- package/dist/src/cli/mason.cjs +17 -0
- package/dist/src/cli/mcp.js +2 -2
- package/dist/src/cli/output-gate.js +176 -0
- package/dist/src/cli/page-share.js +96 -0
- package/dist/src/cli/pg-tls.js +1 -1
- package/dist/src/cli/pin.js +6 -2
- package/dist/src/cli/pool-size.cjs +85 -0
- package/dist/src/cli/ports.js +2 -0
- package/dist/src/cli/preflight.js +130 -39
- package/dist/src/cli/project.js +99 -0
- package/dist/src/cli/readiness.js +42 -6
- package/dist/src/cli/registry.js +12 -4
- package/dist/src/cli/replica-hosted.js +665 -0
- package/dist/src/cli/replica.js +76 -0
- package/dist/src/cli/roles.js +2 -1
- package/dist/src/cli/run-server.js +154 -7
- package/dist/src/cli/security.js +57 -10
- package/dist/src/cli/serve.js +63 -7
- package/dist/src/cli/share-run.js +51 -8
- package/dist/src/cli/share.js +3 -3
- package/dist/src/cli/source-edit.js +10 -4
- package/dist/src/cli/source-role.js +344 -0
- package/dist/src/cli/sources.js +30 -20
- package/dist/src/cli/style.js +2 -1
- package/dist/src/cli/supervise.js +15 -23
- package/dist/src/cli/token-hosted.js +337 -0
- package/dist/src/cli/ui-ssh.js +460 -0
- package/dist/src/cli/ui.js +115 -11
- package/dist/src/cli/user-hosted.js +299 -0
- package/dist/src/cli/user.js +126 -74
- package/dist/src/demo/seed.js +26 -0
- package/dist/src/duck/analyze.js +36 -20
- package/dist/src/duck/collation.js +266 -0
- package/dist/src/duck/constant-key.js +94 -0
- package/dist/src/duck/distinct-key.js +283 -26
- package/dist/src/duck/events.js +39 -8
- package/dist/src/duck/executor-worker.js +2 -2
- package/dist/src/duck/executor.js +6 -5
- package/dist/src/duck/explain.js +4 -0
- package/dist/src/duck/functions.js +448 -49
- package/dist/src/duck/gaps.js +34 -16
- package/dist/src/duck/generated-plan.js +1070 -0
- package/dist/src/duck/generated.js +906 -0
- package/dist/src/duck/grants.js +1 -1
- package/dist/src/duck/held-types.js +64 -0
- package/dist/src/duck/history.js +4 -0
- package/dist/src/duck/jsonb-order.js +9 -12
- package/dist/src/duck/kept-route.js +6 -5
- package/dist/src/duck/kept.js +9 -7
- package/dist/src/duck/late.js +1469 -40
- package/dist/src/duck/libc-bound.js +341 -0
- package/dist/src/duck/libc-order.js +892 -126
- package/dist/src/duck/pg-eval-worker.js +7 -1
- package/dist/src/duck/pg-eval.js +6 -4
- package/dist/src/duck/pgtree.js +774 -23
- package/dist/src/duck/plan.js +275 -30
- package/dist/src/duck/publication.js +14 -0
- package/dist/src/duck/requests.js +9 -5
- package/dist/src/duck/resolved.js +117 -2
- package/dist/src/duck/rewrite.js +4567 -583
- package/dist/src/duck/row-filter.js +12 -6
- package/dist/src/duck/statement.js +19 -0
- package/dist/src/duck/store.js +294 -68
- package/dist/src/duck/stream.js +9 -5
- package/dist/src/duck/sync.js +602 -126
- package/dist/src/duck/token.js +4 -1
- package/dist/src/duck/types.js +63 -7
- package/dist/src/duck/usage.js +6 -4
- package/dist/src/duck/users.js +5 -0
- package/dist/src/duck/verified.js +159 -2
- package/dist/src/duck/views.js +4 -3
- package/dist/src/lib/database-url.js +55 -10
- package/dist/src/lib/instant.js +50 -0
- package/dist/src/lib/log-buffer.js +13 -4
- package/dist/src/lib/log-read.js +196 -0
- package/dist/src/lib/pg-numeric.js +387 -49
- package/dist/src/lib/secret-mask.js +142 -0
- package/dist/src/lib/table-sync.js +24 -9
- package/dist/src/web/app.js +9 -4
- package/dist/src/web/pages/status.js +178 -13
- package/dist/src/web/pages/syncs.js +1 -1
- package/dist/src/web/pages/tables.js +8 -3
- package/dist/src/web/pages/users.js +4 -3
- package/dist/src/web/shared.js +19 -3
- package/package.json +8 -7
package/dist/src/cli/init.js
CHANGED
|
@@ -30,13 +30,14 @@ 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
|
+
import { newSourceText, OLD_TABLES_DIR, parseSourceText, shownPath, sourceFileNames, sourceNameFor, sourceOf, sourcesDir } from "./sources.js";
|
|
40
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
|
|
@@ -56,6 +57,9 @@ export const INIT_USAGE = `usage: mason init [<postgres url> | demo] [--name <na
|
|
|
56
57
|
mason init demo, in a new folder, makes a project on a demo database Mason hosts itself (a shop: customers, products,
|
|
57
58
|
orders, order_items): mason start makes it in .mason/demo/ and changes it every few seconds. Three of its tables are
|
|
58
59
|
added; the fourth is yours to add after mason start (mason add ${DEMO_TABLE_TO_ADD}), to watch its first sync begin.
|
|
60
|
+
It also writes mason/${GENERATED_DIR}/${DEMO_GENERATED.file}, a generated table over the synced orders (orders and
|
|
61
|
+
revenue per day and status, kept current: mason wait ${DEMO_GENERATED.table} once Mason is up; mason help generated); one
|
|
62
|
+
already there is kept.
|
|
59
63
|
|
|
60
64
|
mason init MASON_DATABASE_URL, else DATABASE_URL (.env or the shell)
|
|
61
65
|
mason init postgres://me:secret@localhost:5432/shop
|
|
@@ -72,14 +76,15 @@ export const INIT_USAGE = `usage: mason init [<postgres url> | demo] [--name <na
|
|
|
72
76
|
--json as data: { name, root, folder, config, demo (true for the demo database's project, else false),
|
|
73
77
|
source: { name (the source's: its file's), file, url (as the file writes it), database (the name
|
|
74
78
|
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) },
|
|
75
|
-
|
|
79
|
+
generated: [{ table, file, written (false: one was here, and kept) }] (the demo's generated table; [] for
|
|
80
|
+
any other project), database: { url (password masked), from, version, tables }, warnings, next }
|
|
76
81
|
`;
|
|
77
82
|
export const INIT = {
|
|
78
83
|
name: "init",
|
|
79
84
|
summary: "make this folder a Mason project on your Postgres: mason init (its URL from .env), mason init postgres://… or demo",
|
|
80
85
|
usage: INIT_USAGE,
|
|
81
86
|
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"],
|
|
87
|
+
json: ["name", "root", "folder", "config", "demo", "source", "roles", "generated", "database", "warnings", "next"],
|
|
83
88
|
};
|
|
84
89
|
/** Why `name` cannot be this project's, or null: not a name its replication slot takes. */
|
|
85
90
|
export function nameProblem(name) {
|
|
@@ -205,8 +210,16 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
|
|
|
205
210
|
const name = sourceless?.config.name ?? f.values.name ?? nameFromFolder(root);
|
|
206
211
|
const folderArg = f.values.folder ?? "mason";
|
|
207
212
|
const folder = sourceless?.folder ?? path.resolve(root, folderArg);
|
|
208
|
-
|
|
209
|
-
|
|
213
|
+
// the one authority on where the files go (src/cli/project.ts folderPlace): outside this folder, or a place it cannot
|
|
214
|
+
// resolve (a loop, a file as a folder, unreadable, too long), which can never become a project's folder, is refused
|
|
215
|
+
const place = folderPlace(root, folder);
|
|
216
|
+
const shownArg = folderArg.length > 200 ? `${folderArg.slice(0, 200)}…` : folderArg;
|
|
217
|
+
if (place.place === "outside")
|
|
218
|
+
return usage(`--folder ${shownArg} is outside this folder`, "the project's files go in this folder or below it");
|
|
219
|
+
if (place.place === "unknown") {
|
|
220
|
+
return failed("init", { message: `--folder ${shownArg} cannot be resolved to a folder`, code: "usage", why: place.why,
|
|
221
|
+
fix: "name a folder of its own inside this one with --folder (the default, mason, is one), or fix the path" }, { json, io });
|
|
222
|
+
}
|
|
210
223
|
if (fs.existsSync(folder) && !fs.statSync(folder).isDirectory())
|
|
211
224
|
return usage(`${folder} is a file, not a folder`, "--folder <dir> chooses another");
|
|
212
225
|
const named = nameProblem(name);
|
|
@@ -216,6 +229,11 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
|
|
|
216
229
|
const badDatabase = connectName === null ? null : databaseNameProblem(connectName);
|
|
217
230
|
if (badDatabase !== null)
|
|
218
231
|
return usage(`--database: ${badDatabase}`);
|
|
232
|
+
// THE PROJECT'S FILES DEPLOYED READ-ONLY (src/cli/project.ts projectWriteRefusal, the one authority): init writes them,
|
|
233
|
+
// so it writes none — refused before the database is asked, as every writer of a project file is
|
|
234
|
+
const readOnly = projectWriteRefusal(sourceless?.root ?? null, deps.env ?? process.env);
|
|
235
|
+
if (readOnly !== null)
|
|
236
|
+
return failed("init", readOnly, { json, io });
|
|
219
237
|
// THE SOURCE FILE: one already here (a clone of the project) is kept; else written, its URL a variable of .env
|
|
220
238
|
const kept = sourceFileNames(folder)[0] ?? null;
|
|
221
239
|
if (demo && kept !== null) {
|
|
@@ -264,7 +282,9 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
|
|
|
264
282
|
envFile = found.file;
|
|
265
283
|
envVariable = found.variable;
|
|
266
284
|
}
|
|
267
|
-
|
|
285
|
+
// a problem names where the URL is (DATABASE_URL in .env, the URL given), never the value (src/cli/preflight.ts shownUrl)
|
|
286
|
+
const urlName = found !== null ? found.from : demo ? "the demo database's URL" : "the URL given";
|
|
287
|
+
const parsed = parseSource(url, urlName);
|
|
268
288
|
if ("problem" in parsed)
|
|
269
289
|
return usage(parsed.problem);
|
|
270
290
|
const remote = f.values.remote === true || sourceless?.config.remote === true;
|
|
@@ -291,6 +311,9 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
|
|
|
291
311
|
const sourceFile = kept !== null ? path.join(sourcesDir(folder), kept) : path.join(sourcesDir(folder), `${sourceNameFor(parsed.source.database)}.yml`);
|
|
292
312
|
const rolesPath = rolesFile(folder);
|
|
293
313
|
const rolesKept = fs.existsSync(rolesPath);
|
|
314
|
+
// THE DEMO'S GENERATED TABLE (src/demo/seed.ts DEMO_GENERATED): written beside the sources file, kept when one is here
|
|
315
|
+
const generatedPath = demo ? path.join(generatedDir(folder), DEMO_GENERATED.file) : null;
|
|
316
|
+
const generatedKept = generatedPath !== null && fs.existsSync(generatedPath);
|
|
294
317
|
// the folder an older Mason listed the tables in: read by nothing (no backwards compatibility before v1.0), said
|
|
295
318
|
const oldTables = path.join(folder, OLD_TABLES_DIR);
|
|
296
319
|
if (fs.existsSync(oldTables))
|
|
@@ -315,7 +338,7 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
|
|
|
315
338
|
const said = [];
|
|
316
339
|
let needsRemote = false;
|
|
317
340
|
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) });
|
|
341
|
+
const c = await checkSource(url, { remote, cmd: "mason init", say: (m) => said.push(m), name: urlName });
|
|
319
342
|
if ("exit" in c)
|
|
320
343
|
throw new StepFailed(said.filter((m) => !/: connecting to /.test(m)).join("\n"), c.exit);
|
|
321
344
|
if ("needsRemote" in c) {
|
|
@@ -353,6 +376,17 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
|
|
|
353
376
|
`(${demo ? "every table polled every 5 s" : "each table read once, then again only by mason sync <table>"}) and the tables to sync, ` +
|
|
354
377
|
`${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
378
|
}
|
|
379
|
+
if (generatedPath !== null) {
|
|
380
|
+
const g = rel(generatedPath);
|
|
381
|
+
if (generatedKept)
|
|
382
|
+
await steps.step(`Keeping ${g}`, () => undefined, () => `Kept ${g}: the demo's generated table, as it is`);
|
|
383
|
+
else {
|
|
384
|
+
await steps.step(`Writing ${g}`, () => {
|
|
385
|
+
fs.mkdirSync(path.dirname(generatedPath), { recursive: true });
|
|
386
|
+
fs.writeFileSync(generatedPath, DEMO_GENERATED.text);
|
|
387
|
+
}, () => `Wrote ${g}: a generated table, orders and revenue per day, kept current from the synced orders (mason help generated) — commit it`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
356
390
|
// THE ROLES FILE (src/cli/roles.ts newRolesText): today's access said in a committed file — every user reads every
|
|
357
391
|
// copied table, as the default role everything — kept when one is here (a clone of the project)
|
|
358
392
|
if (rolesKept)
|
|
@@ -373,7 +407,7 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
|
|
|
373
407
|
const cli = deps.version ?? version();
|
|
374
408
|
const pinFile = rel(path.join(folder, PIN_FILE));
|
|
375
409
|
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}`);
|
|
410
|
+
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
411
|
const pinned = cliPinProblem(folder, cli);
|
|
378
412
|
if (pinned)
|
|
379
413
|
notes.push(pinned);
|
|
@@ -403,6 +437,7 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
|
|
|
403
437
|
const next = demo ? [
|
|
404
438
|
["mason start", "the demo database and Mason, in the background: its tables synced, then kept current"],
|
|
405
439
|
[`mason add ${DEMO_TABLE_TO_ADD}`, "the demo's fourth table, once Mason is up: watch its first sync begin"],
|
|
440
|
+
[`mason wait ${DEMO_GENERATED.table}`, "the demo's generated table, once Mason is up: orders and revenue per day, kept current"],
|
|
406
441
|
["mason status", "the synced data keeping up with the demo's changes"],
|
|
407
442
|
["mason user add <name>", "a user for psql or a BI tool: its password and URL, printed once"],
|
|
408
443
|
] : [
|
|
@@ -416,12 +451,13 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
|
|
|
416
451
|
database: keptFile === null ? connectName ?? DEFAULT_DATABASE : "problems" in keptFile ? null : keptFile.database ?? DEFAULT_DATABASE,
|
|
417
452
|
env: where.line === null ? null : maskedLine(where.line) },
|
|
418
453
|
roles: { file: rolesPath, written: !rolesKept },
|
|
454
|
+
generated: generatedPath === null ? [] : [{ table: sourceOf(DEMO_GENERATED.table), file: generatedPath, written: !generatedKept }],
|
|
419
455
|
database, warnings: notes }, next));
|
|
420
456
|
return 0;
|
|
421
457
|
}
|
|
422
458
|
for (const n of notes)
|
|
423
459
|
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
|
|
460
|
+
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
461
|
: "Mason only reads your database: no setting, slot or publication is needed or made"], next }, st));
|
|
426
462
|
return 0;
|
|
427
463
|
}
|
|
@@ -0,0 +1,289 @@
|
|
|
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 { 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
|
+
The page and the code are printed on stderr at once, as plain lines, so a coding agent can pass them on to you
|
|
46
|
+
before the wait ends; the answer is printed when it ends.
|
|
47
|
+
|
|
48
|
+
The service is ${DEFAULT_SERVICE_URL} unless MASON_CLOUD_URL names another (https://, or http:// for one on
|
|
49
|
+
this machine). With MASON_TOKEN set (CI), every command uses that token before a saved login, so mason login is
|
|
50
|
+
refused: run it without the variable (env -u MASON_TOKEN mason login) to sign in anyway.
|
|
51
|
+
|
|
52
|
+
mason login
|
|
53
|
+
MASON_CLOUD_URL=http://127.0.0.1:8080 mason login a service on this machine
|
|
54
|
+
|
|
55
|
+
--json as data, once signed in: { service (its address), email, name, orgs: [{ slug, name, role }], file (where
|
|
56
|
+
the login is kept), replaced (null, or the login it replaced: { email, revoked }), next }
|
|
57
|
+
`,
|
|
58
|
+
options: { json: { type: "boolean", default: false } },
|
|
59
|
+
json: ["service", "email", "name", "orgs", "file", "replaced", "next"],
|
|
60
|
+
};
|
|
61
|
+
export const LOGOUT = {
|
|
62
|
+
name: "logout",
|
|
63
|
+
summary: "sign this machine out of a hosted Mason service: its login revoked and removed",
|
|
64
|
+
usage: `usage: mason logout [--json]
|
|
65
|
+
|
|
66
|
+
Signs this machine out of the hosted Mason service (MASON_CLOUD_URL, else ${DEFAULT_SERVICE_URL}): it revokes
|
|
67
|
+
the login on the service, then removes it from ~/.config/mason/credentials.json. When the service cannot be
|
|
68
|
+
reached the login is still removed here, and it says the login was NOT revoked and how to revoke it: sign in to
|
|
69
|
+
the service's web app and revoke it there. Logins to other services stay.
|
|
70
|
+
|
|
71
|
+
MASON_TOKEN in the environment is not a saved login: logout cannot remove it, and says so while it is set.
|
|
72
|
+
|
|
73
|
+
mason logout
|
|
74
|
+
MASON_CLOUD_URL=http://127.0.0.1:8080 mason logout a service on this machine
|
|
75
|
+
|
|
76
|
+
--json as data: { service, signedOut (a saved login was removed), email (its account, null when there was
|
|
77
|
+
none), revoked (true, false when the service did not take it, null when there was nothing to revoke),
|
|
78
|
+
revokeError (why not, else null), environmentToken (MASON_TOKEN is set, and still used), file, next }
|
|
79
|
+
`,
|
|
80
|
+
options: { json: { type: "boolean", default: false } },
|
|
81
|
+
json: ["service", "signedOut", "email", "revoked", "revokeError", "environmentToken", "file", "next"],
|
|
82
|
+
};
|
|
83
|
+
/** Where a person may approve the code, for the browser to open: a plain https (or this machine's http) URL, and none
|
|
84
|
+
* of the characters a shell or `cmd /c start` reads (the service's URL is not ours). */
|
|
85
|
+
const OPENABLE = /^https?:\/\/[A-Za-z0-9.:[\]-]+(?:\/[A-Za-z0-9._~/?=+-]*)?$/;
|
|
86
|
+
/** An organisation as a line: its slug, its name when it says more, the role. */
|
|
87
|
+
const orgLine = (o) => `${o.slug}${o.name && o.name !== o.slug ? ` (${o.name})` : ""}, ${o.role}`;
|
|
88
|
+
export async function login(argv) {
|
|
89
|
+
const f = parseCommand(LOGIN, argv);
|
|
90
|
+
if (typeof f === "number")
|
|
91
|
+
return f;
|
|
92
|
+
const json = f.values.json === true;
|
|
93
|
+
if (f.positionals.length)
|
|
94
|
+
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 });
|
|
95
|
+
const service = serviceOf();
|
|
96
|
+
if (isFailure(service))
|
|
97
|
+
return fail("login", service, { json });
|
|
98
|
+
if (environmentToken() !== null) {
|
|
99
|
+
return fail("login", { message: `${TOKEN_ENV} is set in this environment, so a login would not be used`, code: "refused",
|
|
100
|
+
why: `every command sends ${TOKEN_ENV} before a saved login: a new login to ${service.base} would be shadowed by it`,
|
|
101
|
+
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)`,
|
|
102
|
+
next: [[`env -u ${TOKEN_ENV} mason login`, "sign in, for when MASON_TOKEN is unset"]] }, { json });
|
|
103
|
+
}
|
|
104
|
+
// the file is checked BEFORE the person spends a minute approving a code: a folder others can read is said now
|
|
105
|
+
const before = readLogins();
|
|
106
|
+
if (isFailure(before))
|
|
107
|
+
return fail("login", before, { json });
|
|
108
|
+
// what this command holds that the service may echo before it sends a token: the login it replaces (src/cli/hosted.ts
|
|
109
|
+
// `carriesNoSecret`: an answer that carries it is refused whole)
|
|
110
|
+
const held = before.logins.logins[service.base] ? [before.logins.logins[service.base].token] : [];
|
|
111
|
+
// and from here every byte written is put to the exact test against what it holds (src/cli/output-gate.ts)
|
|
112
|
+
holdOutput({ command: "login", json, secrets: held });
|
|
113
|
+
const code = await startDevice(service, held);
|
|
114
|
+
if (isFailure(code))
|
|
115
|
+
return fail("login", code, { json });
|
|
116
|
+
const token = await approved(service, code, json, held);
|
|
117
|
+
if (isFailure(token))
|
|
118
|
+
return fail("login", token, { json });
|
|
119
|
+
holdOutput({ command: "login", json, secrets: [token] });
|
|
120
|
+
// FROM HERE THE SERVICE HAS GRANTED A TOKEN, which is kept only once it is saved: on every failure before that —
|
|
121
|
+
// one said, or one thrown (PR 645's review: a crash left a granted token live) — it is revoked, and a revoke that
|
|
122
|
+
// did not reach the service is said
|
|
123
|
+
let kept;
|
|
124
|
+
try {
|
|
125
|
+
kept = await keep(service, token, held);
|
|
126
|
+
}
|
|
127
|
+
catch (e) {
|
|
128
|
+
const notRevoked = await revoke(service, token);
|
|
129
|
+
if (notRevoked === null)
|
|
130
|
+
throw e;
|
|
131
|
+
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 });
|
|
132
|
+
}
|
|
133
|
+
if (isFailure(kept)) {
|
|
134
|
+
const notRevoked = await revoke(service, token);
|
|
135
|
+
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 });
|
|
136
|
+
}
|
|
137
|
+
const { me, was, saved } = kept;
|
|
138
|
+
// the login it replaced is revoked, so this machine never leaves a token it no longer holds alive on the service
|
|
139
|
+
const replaced = was === null || was.token === token ? null : { email: serviceText(was.email, 320, [token, was.token]), revokeError: await revoke(service, was.token) };
|
|
140
|
+
// what the service said, as it may be shown, in the report and in --json alike: printable, bounded, no token in it
|
|
141
|
+
const secrets = [token, ...(was ? [was.token] : [])];
|
|
142
|
+
const email = serviceText(me.email, 320, secrets), name = serviceText(me.name, 300, secrets);
|
|
143
|
+
const orgs = me.orgs.map((o) => ({ slug: serviceText(o.slug, 200, secrets), name: serviceText(o.name, 200, secrets), role: serviceText(o.role, 32, secrets) }));
|
|
144
|
+
const next = [["mason logout", `sign this machine out of ${service.base}`]];
|
|
145
|
+
if (json) {
|
|
146
|
+
out(jsonText(LOGIN, { service: service.base, email, name, orgs, file: saved.file,
|
|
147
|
+
replaced: replaced === null ? null : { email: replaced.email, revoked: replaced.revokeError === null } }, next));
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
150
|
+
out(reportText({
|
|
151
|
+
headline: `Signed in to ${service.base} as ${email}`,
|
|
152
|
+
details: [
|
|
153
|
+
...(name ? [["name", name]] : []),
|
|
154
|
+
...(orgs.length ? orgs.map((o, i) => [i === 0 ? "orgs" : "", orgLine(o)]) : [["orgs", "none yet"]]),
|
|
155
|
+
["saved", saved.file],
|
|
156
|
+
],
|
|
157
|
+
tips: replaced === null ? undefined : replaced.revokeError === null
|
|
158
|
+
? [`This replaces the login of ${replaced.email}, which is revoked.`]
|
|
159
|
+
: [`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.`],
|
|
160
|
+
next,
|
|
161
|
+
}, outStyle()));
|
|
162
|
+
return 0;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* WHAT A GRANTED TOKEN NEEDS BEFORE IT IS KEPT: who signed in (a token the service will not say who it is is never
|
|
166
|
+
* kept), the logins read again just before the write (so a login another command saved meanwhile is kept), and the
|
|
167
|
+
* write. A Failure when any is not had; the caller revokes the token then.
|
|
168
|
+
*/
|
|
169
|
+
async function keep(service, token, held) {
|
|
170
|
+
const me = await whoAmI(service, token, held);
|
|
171
|
+
if (isFailure(me))
|
|
172
|
+
return me;
|
|
173
|
+
const now = readLogins();
|
|
174
|
+
if (isFailure(now))
|
|
175
|
+
return now;
|
|
176
|
+
const was = now.logins.logins[service.base] ?? null;
|
|
177
|
+
const logins = { version: 1, logins: { ...now.logins.logins, [service.base]: { token, email: me.email, savedAt: new Date().toISOString() } } };
|
|
178
|
+
const saved = writeLogins(logins);
|
|
179
|
+
if (isFailure(saved))
|
|
180
|
+
return saved;
|
|
181
|
+
return { me, was, saved };
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* The page and the code said, then the wait: a poll every `interval` seconds (five more at each slow_down, RFC 8628
|
|
185
|
+
* 3.5), until the token, a refusal, or the code's expiry. On a terminal a spinner turns beside the wait; elsewhere the
|
|
186
|
+
* wait is one line.
|
|
187
|
+
*/
|
|
188
|
+
async function approved(service, code, json, held) {
|
|
189
|
+
const page = code.verificationUriComplete ?? code.verificationUri;
|
|
190
|
+
const s = errStyle();
|
|
191
|
+
say(reportText({ headline: `To sign in to ${service.base}, open this page and approve the code:`,
|
|
192
|
+
details: [["page", page], ["code", code.verificationUriComplete ? `${code.userCode} (the page shows it: check they match)` : `${code.userCode} (enter it on the page)`]] }, s));
|
|
193
|
+
const terminal = !json && process.stderr.isTTY === true;
|
|
194
|
+
if (terminal) {
|
|
195
|
+
const couldNot = OPENABLE.test(page) ? await openBrowser(page) : "the page's address is not one Mason opens by itself";
|
|
196
|
+
if (couldNot !== null)
|
|
197
|
+
say(endText({ tips: [`Could not open a browser (${couldNot}): open the page yourself.`] }, s).replace(/^\n/, ""));
|
|
198
|
+
}
|
|
199
|
+
const minutes = Math.max(1, Math.round(code.expiresIn / 60));
|
|
200
|
+
const full = `Waiting for the code to be approved (it expires in ${minutes} min; Ctrl-C stops)`;
|
|
201
|
+
// a spinner's line is redrawn in place, so it must never reach the terminal's edge (src/cli/command.ts serviceStyle):
|
|
202
|
+
// the longest label that fits beside its frame, else no spinner and the wait said once, broken as any line
|
|
203
|
+
const room = (process.stderr.columns ?? 0) - 1 - 6;
|
|
204
|
+
const label = terminal ? [full, "Waiting for approval", "Waiting"].find((l) => l.length <= room) : undefined;
|
|
205
|
+
const live = label !== undefined;
|
|
206
|
+
const steps = stepRenderer({ line: (t) => say(strict(t, s)), live: live ? process.stderr : null, style: s });
|
|
207
|
+
if (live)
|
|
208
|
+
steps.begin(label);
|
|
209
|
+
else
|
|
210
|
+
say(strict(`${full} …`, s));
|
|
211
|
+
const deadline = Date.now() + code.expiresIn * 1000;
|
|
212
|
+
let interval = code.interval;
|
|
213
|
+
for (;;) {
|
|
214
|
+
if (Date.now() + interval * 1000 > deadline) {
|
|
215
|
+
if (live)
|
|
216
|
+
steps.fail();
|
|
217
|
+
return expired(code);
|
|
218
|
+
}
|
|
219
|
+
await sleep(interval * 1000);
|
|
220
|
+
const p = await pollToken(service, code, held);
|
|
221
|
+
if ("pending" in p)
|
|
222
|
+
continue;
|
|
223
|
+
if ("slowDown" in p) {
|
|
224
|
+
interval += 5;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (isFailure(p)) {
|
|
228
|
+
if (live)
|
|
229
|
+
steps.fail();
|
|
230
|
+
return p;
|
|
231
|
+
}
|
|
232
|
+
if (live)
|
|
233
|
+
steps.done("Approved");
|
|
234
|
+
else
|
|
235
|
+
say("Approved");
|
|
236
|
+
return p.token;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
export async function logout(argv) {
|
|
240
|
+
const f = parseCommand(LOGOUT, argv);
|
|
241
|
+
if (typeof f === "number")
|
|
242
|
+
return f;
|
|
243
|
+
const json = f.values.json === true;
|
|
244
|
+
if (f.positionals.length)
|
|
245
|
+
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 });
|
|
246
|
+
const service = serviceOf();
|
|
247
|
+
if (isFailure(service))
|
|
248
|
+
return fail("logout", service, { json });
|
|
249
|
+
const fromEnv = environmentToken() !== null;
|
|
250
|
+
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.`] : [];
|
|
251
|
+
const read = readLogins();
|
|
252
|
+
if (isFailure(read))
|
|
253
|
+
return fail("logout", read, { json });
|
|
254
|
+
const entry = read.logins.logins[service.base];
|
|
255
|
+
const env = environmentToken();
|
|
256
|
+
if (entry !== undefined || env !== null)
|
|
257
|
+
holdOutput({ command: "logout", json, secrets: [...(entry ? [entry.token] : []), ...(env !== null ? [env] : [])] });
|
|
258
|
+
if (entry === undefined) {
|
|
259
|
+
const next = [["mason login", `sign this machine in to ${service.base}`]];
|
|
260
|
+
if (json) {
|
|
261
|
+
out(jsonText(LOGOUT, { service: service.base, signedOut: false, email: null, revoked: null, revokeError: null, environmentToken: fromEnv, file: read.file }, next));
|
|
262
|
+
return 0;
|
|
263
|
+
}
|
|
264
|
+
out(reportText({ headline: `This machine is not signed in to ${service.base}`, details: [["file", read.file]], tips: envTip, next }, outStyle()));
|
|
265
|
+
return 0;
|
|
266
|
+
}
|
|
267
|
+
const revokeError = await revoke(service, entry.token);
|
|
268
|
+
// removed whether or not the service took the revoke: a person who logs out means this machine to hold it no more
|
|
269
|
+
const rest = { ...read.logins.logins };
|
|
270
|
+
delete rest[service.base];
|
|
271
|
+
const written = writeLogins({ version: 1, logins: rest });
|
|
272
|
+
if (isFailure(written))
|
|
273
|
+
return fail("logout", written, { json });
|
|
274
|
+
const email = serviceText(entry.email, 320, [entry.token]);
|
|
275
|
+
const next = [["mason login", `sign in to ${service.base} again`]];
|
|
276
|
+
if (json) {
|
|
277
|
+
out(jsonText(LOGOUT, { service: service.base, signedOut: true, email, revoked: revokeError === null, revokeError, environmentToken: fromEnv, file: written.file }, next));
|
|
278
|
+
return 0;
|
|
279
|
+
}
|
|
280
|
+
const notRevoked = revokeError === null ? undefined
|
|
281
|
+
: `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.`;
|
|
282
|
+
out(reportText({
|
|
283
|
+
headline: revokeError === null ? `Signed out of ${service.base}` : `Signed out of ${service.base} on this machine, but the login was NOT revoked`,
|
|
284
|
+
details: [["account", email], ["revoked", revokeError === null ? "yes: the service takes this login no more" : "no"], ["removed from", written.file]],
|
|
285
|
+
tips: [...(notRevoked ? [notRevoked] : []), ...envTip],
|
|
286
|
+
next,
|
|
287
|
+
}, outStyle()));
|
|
288
|
+
return 0;
|
|
289
|
+
}
|
package/dist/src/cli/machine.js
CHANGED
|
@@ -83,7 +83,7 @@ export async function removeProject(p, o = {}) {
|
|
|
83
83
|
const own = madeReplication(p.config, "message" in src ? (sourceFileNames(p.folder)[0] ?? "app.yml").replace(/\.ya?ml$/, "") : src.name);
|
|
84
84
|
if (!own.owned)
|
|
85
85
|
objects = "replication slot";
|
|
86
|
-
const parsed = "message" in src ? { problem: `the database's URL cannot be read: ${src.message}` } : parseSource(src.resolved);
|
|
86
|
+
const parsed = "message" in src ? { problem: `the database's URL cannot be read: ${src.message}` } : parseSource(src.resolved, src.from);
|
|
87
87
|
try {
|
|
88
88
|
if ("problem" in parsed)
|
|
89
89
|
throw new Error(parsed.problem);
|
package/dist/src/cli/main.js
CHANGED
|
@@ -10,12 +10,15 @@
|
|
|
10
10
|
* project of the folder they run in. Every command is in src/cli/registry.ts, described once (src/cli/command.ts).
|
|
11
11
|
* Apache-2.0: it runs the stack (CLAUDE.md "Licensing").
|
|
12
12
|
*/
|
|
13
|
+
// FIRST: it sizes libuv's pool, which nothing below may have started (src/cli/pool-size.cjs)
|
|
14
|
+
import "./pool-size.cjs";
|
|
13
15
|
import { COMMANDS, orientation, topUsage, version } from "./registry.js";
|
|
14
16
|
import { didYouMean } from "../lib/did-you-mean.js";
|
|
15
17
|
import { spinUntilOutput } from "./style.js";
|
|
16
18
|
import { CommandFailed, failed, wantsJson } from "./command.js";
|
|
17
19
|
import { RunUnreachable } from "./control.js";
|
|
18
20
|
import { exitWhenWritten } from "../lib/exit-when-written.js";
|
|
21
|
+
import { gatedExit, installOutputGate } from "./output-gate.js";
|
|
19
22
|
async function main(argv) {
|
|
20
23
|
const [command, ...args] = argv;
|
|
21
24
|
// mason alone: where you are and the next command; mason help / --help: every command
|
|
@@ -70,7 +73,10 @@ async function main(argv) {
|
|
|
70
73
|
stop();
|
|
71
74
|
}
|
|
72
75
|
}
|
|
73
|
-
|
|
76
|
+
// every byte written passes the output's gate, which a hosted command arms with the secrets it holds (src/cli/output-gate.ts):
|
|
77
|
+
// installed before anything else wraps the streams (the spinner, below), and its refusal is the exit's (`gatedExit`)
|
|
78
|
+
installOutputGate();
|
|
79
|
+
main(process.argv.slice(2)).then((code) => exitWhenWritten(gatedExit(code)), (e) => {
|
|
74
80
|
// an error no command said in its own words: its message, never a stack trace (CLAUDE.md: a message that needs a
|
|
75
81
|
// stack to be understood is a bug — mason query orders printed one when Mason's API was not up yet).
|
|
76
82
|
// MASON_DEBUG=1 prints where it happened, for the bug report it deserves. The same shape as every failure, JSON under --json.
|
|
@@ -79,5 +85,5 @@ main(process.argv.slice(2)).then(exitWhenWritten, (e) => {
|
|
|
79
85
|
const code = failed(command ?? "", { message, code: "failed", fix: process.env.MASON_DEBUG ? undefined : "this is a bug in mason: MASON_DEBUG=1 prints where it happened, for the report" }, { json: wantsJson(args, COMMANDS.find((c) => c.name === command)) });
|
|
80
86
|
if (process.env.MASON_DEBUG)
|
|
81
87
|
process.stderr.write(`${e.stack ?? ""}\n`);
|
|
82
|
-
exitWhenWritten(code);
|
|
88
|
+
exitWhenWritten(gatedExit(code));
|
|
83
89
|
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* THE `mason` COMMAND'S ENTRY (the published `bin`, the image's `mason`, the run src/cli/background.ts starts): sets the
|
|
4
|
+
* pool's size, then loads the command. CommonJS because on Node 22, which the package allows, loading an ES module
|
|
5
|
+
* reads its files through libuv's pool, so the pool has started with four threads before an ES entry's first line
|
|
6
|
+
* runs: measured on 22.13, where a CommonJS entry alone got 16 threads of 16. Node 24 and 26 read those files
|
|
7
|
+
* synchronously, which is why main.ts's first import is enough in a checkout. Nothing here may use the pool.
|
|
8
|
+
*/
|
|
9
|
+
"use strict";
|
|
10
|
+
require("./pool-size.cjs");
|
|
11
|
+
const fs = require("node:fs");
|
|
12
|
+
const path = require("node:path");
|
|
13
|
+
const { pathToFileURL } = require("node:url");
|
|
14
|
+
|
|
15
|
+
// the .ts in a checkout and the image, the .js in the package (src/lib/runnable.ts, which this file cannot import yet)
|
|
16
|
+
const ts = path.join(__dirname, "main.ts");
|
|
17
|
+
import(pathToFileURL(fs.existsSync(ts) ? ts : path.join(__dirname, "main.js")).href);
|
package/dist/src/cli/mcp.js
CHANGED
|
@@ -103,9 +103,9 @@ async function theUrl(given, cwd) {
|
|
|
103
103
|
}
|
|
104
104
|
/** The URL read as a connection: the CLI's own reading (src/cli/preflight.ts parseSource, TLS as the URL's sslmode). */
|
|
105
105
|
function theSource(url, from) {
|
|
106
|
-
const p = parseSource(url);
|
|
106
|
+
const p = parseSource(url, from);
|
|
107
107
|
if ("problem" in p)
|
|
108
|
-
return { message:
|
|
108
|
+
return { message: p.problem, code: "usage", fix: `the URL mason user add printed: ${EXAMPLE_URL}`, next: [HELP_STEP] };
|
|
109
109
|
return p.source;
|
|
110
110
|
}
|
|
111
111
|
/** application_name's client part: the MCP client's own name, in letters, digits, `.`, `_` and `-` (Postgres keeps 63 bytes). */
|