@mapled/cli 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -1
- package/dist/commands.d.ts +9 -0
- package/dist/commands.js +80 -0
- package/dist/index.js +11 -2
- package/dist/report.d.ts +127 -0
- package/dist/report.js +425 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -207,11 +207,59 @@ A site rendered in the visitor's browser — a React single-page app (Vite and t
|
|
|
207
207
|
|
|
208
208
|
Exit code 1 when something is marked ✗; `--json` prints the checks for scripts and agents.
|
|
209
209
|
|
|
210
|
+
## Read the setup report
|
|
211
|
+
|
|
212
|
+
```bash
|
|
213
|
+
npx @mapled/cli integration report
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
When an AI agent set the site up, Mapled keeps the run: the plan a person approved, what was applied, the agent's closing report, and the Verification checklist the server runs — the one on the run's page in Mapled. `integration report` prints the last applied run:
|
|
217
|
+
|
|
218
|
+
```text
|
|
219
|
+
Integration report — Dolphin Landing
|
|
220
|
+
|
|
221
|
+
Setup run Claude Code • checks failed • applied 2h ago • checked 1h ago
|
|
222
|
+
Plan “Blog with authors, a homepage and a contact form”
|
|
223
|
+
|
|
224
|
+
Verification
|
|
225
|
+
✓ Site reads content from Mapled Last read 3m ago
|
|
226
|
+
✗ Publish webhook works No deliveries since the plan was applied. Send a test ping from Integrations.
|
|
227
|
+
✓ Preview route responds Responds at /api/mapled/preview
|
|
228
|
+
⚠ Field names and help texts filled in 2 of 14 fields have no help text. Editors see it under the field.
|
|
229
|
+
✓ Site builds successfully Reported by the agent
|
|
230
|
+
✓ No secrets in the repository Reported by the agent
|
|
231
|
+
✓ MAPLED.md written Reported by the agent
|
|
232
|
+
|
|
233
|
+
Collections created
|
|
234
|
+
Articles (articles) collection • 6 fields • 12 records imported
|
|
235
|
+
Homepage (homepage) single • 4 fields • 1 record imported
|
|
236
|
+
|
|
237
|
+
Files changed
|
|
238
|
+
app/page.tsx
|
|
239
|
+
app/blog/[slug]/page.tsx
|
|
240
|
+
|
|
241
|
+
Still hardcoded
|
|
242
|
+
footer copyright
|
|
243
|
+
|
|
244
|
+
1 problem, 1 warning. Fix what the checks name, then `mapled integration report --verify` runs them again.
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
`--verify` runs the server checks again first, as **Run checks again** does in Mapled. A newer plan that isn't applied yet is named on its own line. The report never shows a key or a token: whatever an agent wrote into its report, anything shaped like a Mapled key is masked, and a reported preview address keeps only its page. Exit code 1 when a check is marked ✗; `--json` for scripts. A site connected without an agent has no run — `doctor` checks it from the repository.
|
|
248
|
+
|
|
249
|
+
## Preview the drafts
|
|
250
|
+
|
|
251
|
+
```bash
|
|
252
|
+
npx @mapled/cli preview
|
|
253
|
+
npx @mapled/cli preview articles hello-world
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Prints a Preview link — the one the **Preview** button in Mapled opens: the site in draft mode, with every saved change that isn't published yet. Name a collection and a record's slug (or id) and the link lands on the record's page, the one the pushed site manifest says shows it; a collection alone opens the page that lists it, a single its page, nothing at all the home page. Mapled makes the link after checking that the site answers and mounts its preview route; it works once, for five minutes. `--open` opens it in your browser instead of printing it; `--json` prints the link with the page and when it expires.
|
|
257
|
+
|
|
210
258
|
## Options
|
|
211
259
|
|
|
212
260
|
- `--api <origin>` — a Mapled API other than `https://api.mapled.io` (or set `MAPLED_API_URL`); `project link` remembers it in `mapled.json`
|
|
213
261
|
- `--no-browser` — print the sign-in link instead of opening a browser
|
|
214
|
-
- `--json` — machine-readable output (`doctor`, `schema diff`, `scan`, `manifest validate`, `forms list`)
|
|
262
|
+
- `--json` — machine-readable output (`doctor`, `integration report`, `schema diff`, `scan`, `manifest validate`, `forms list`, `preview`)
|
|
215
263
|
- `MAPLED_CONFIG_DIR` — where credentials live (default: `$XDG_CONFIG_HOME/mapled` or `~/.config/mapled`)
|
|
216
264
|
|
|
217
265
|
## Files in the repository
|
package/dist/commands.d.ts
CHANGED
|
@@ -34,6 +34,15 @@ export declare function mdPull(ctx: Ctx, flags: Flags): Promise<void>;
|
|
|
34
34
|
done by hand needs no agent for it. */
|
|
35
35
|
export declare function formsCreate(ctx: Ctx, flags: Flags, words: string[]): Promise<void>;
|
|
36
36
|
export declare function formsList(ctx: Ctx, flags: Flags): Promise<void>;
|
|
37
|
+
/** `mapled integration report` — the Verification checklist of the last
|
|
38
|
+
applied setup run (S-42) and what the agent reported; `--verify` runs
|
|
39
|
+
the server checks again first, as "Run checks again" does. */
|
|
40
|
+
export declare function integrationReport(ctx: Ctx, flags: Flags): Promise<number>;
|
|
41
|
+
/** `mapled preview [<collection> [<slug>]]` — a Preview link from Mapled's
|
|
42
|
+
launcher, the one the Preview button opens: the token is Mapled's, the
|
|
43
|
+
CLI only passes it on. `--open` hands it to the browser instead of
|
|
44
|
+
printing it (the link works once). */
|
|
45
|
+
export declare function preview(ctx: Ctx, flags: Flags, words: string[]): Promise<void>;
|
|
37
46
|
export declare function scan(ctx: Ctx, flags: Flags): Promise<void>;
|
|
38
47
|
export declare function doctor(ctx: Ctx, flags: Flags): Promise<number>;
|
|
39
48
|
export {};
|
package/dist/commands.js
CHANGED
|
@@ -11,6 +11,7 @@ import { authorize, clientKnown, exchangeCode, registerClient, revokeToken } fro
|
|
|
11
11
|
import { formatChecks, GLYPH, paint, summarize, useColor } from "./output.js";
|
|
12
12
|
import { diffSchema, formatChanges, pinSchema, readPin, SCHEMA_FILE, summarizeChanges, writePin } from "./pin.js";
|
|
13
13
|
import { checkMapledMd, MD_FILE, mergeMd } from "./md.js";
|
|
14
|
+
import { buildReport, clean, formatReport, isChecked, linkLifetime, pickRun, } from "./report.js";
|
|
14
15
|
import { loadTypeScript, mergeManifest, scanRepository } from "./scan.js";
|
|
15
16
|
import { scanHtml } from "./scanHtml.js";
|
|
16
17
|
import { generateTypes } from "./types.js";
|
|
@@ -447,6 +448,85 @@ export async function formsList(ctx, flags) {
|
|
|
447
448
|
ctx.out(`${f.key.padEnd(keyWidth)} ${f.displayName.padEnd(nameWidth)} spam protection ${f.spamProtection ? "on " : "off"} ${submitUrl(session.conn.api, f.key)}`);
|
|
448
449
|
}
|
|
449
450
|
}
|
|
451
|
+
/* ---- the integration report and preview (§31.2) ---- */
|
|
452
|
+
/** `mapled integration report` — the Verification checklist of the last
|
|
453
|
+
applied setup run (S-42) and what the agent reported; `--verify` runs
|
|
454
|
+
the server checks again first, as "Run checks again" does. */
|
|
455
|
+
export async function integrationReport(ctx, flags) {
|
|
456
|
+
const { found, session } = await openSession(ctx, flags);
|
|
457
|
+
const { runs } = await session.get("/v1/agent/runs");
|
|
458
|
+
const { shown, newer } = pickRun(runs);
|
|
459
|
+
let run = null;
|
|
460
|
+
if (shown && flags.verify) {
|
|
461
|
+
if (!isChecked(shown.status)) {
|
|
462
|
+
throw new CliError(`Nothing to check yet — the plan from ${clean(shown.clientName, 80)} hasn't been applied.`);
|
|
463
|
+
}
|
|
464
|
+
run = (await session.send("POST", `/v1/agent/runs/${encodeURIComponent(shown.id)}/verify`, {})).run;
|
|
465
|
+
}
|
|
466
|
+
else if (shown) {
|
|
467
|
+
run = (await session.get(`/v1/agent/runs/${encodeURIComponent(shown.id)}`)).run;
|
|
468
|
+
}
|
|
469
|
+
const report = buildReport({ id: found.config.project, name: session.conn.projectName }, run, newer);
|
|
470
|
+
if (flags.json)
|
|
471
|
+
ctx.out(JSON.stringify(report, null, 2));
|
|
472
|
+
else
|
|
473
|
+
for (const line of formatReport(report, useColor(ctx.env)))
|
|
474
|
+
ctx.out(line);
|
|
475
|
+
return report.checks.some((c) => c.status === "failed") ? 1 : 0;
|
|
476
|
+
}
|
|
477
|
+
/** `mapled preview [<collection> [<slug>]]` — a Preview link from Mapled's
|
|
478
|
+
launcher, the one the Preview button opens: the token is Mapled's, the
|
|
479
|
+
CLI only passes it on. `--open` hands it to the browser instead of
|
|
480
|
+
printing it (the link works once). */
|
|
481
|
+
export async function preview(ctx, flags, words) {
|
|
482
|
+
const [collection, record, ...rest] = words;
|
|
483
|
+
if (rest.length > 0) {
|
|
484
|
+
throw new CliError("Name a collection and, optionally, one record's slug: `mapled preview articles hello-world`.", 2);
|
|
485
|
+
}
|
|
486
|
+
const { session } = await openSession(ctx, flags);
|
|
487
|
+
let answer;
|
|
488
|
+
try {
|
|
489
|
+
answer = await session.send("POST", "/v1/agent/preview", {
|
|
490
|
+
...(collection ? { collection } : {}),
|
|
491
|
+
...(record ? { record } : {}),
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
catch (err) {
|
|
495
|
+
if (err instanceof ApiError && err.code === "NO_SITE") {
|
|
496
|
+
throw new CliError("Mapled doesn't know where the site is yet — Preview opens on it. Point the publish webhook at the deployed site (Integrations → Your site); a site rendered in the browser needs its Production domain in Project settings.");
|
|
497
|
+
}
|
|
498
|
+
throw err;
|
|
499
|
+
}
|
|
500
|
+
if (answer.status === "not_deployed") {
|
|
501
|
+
throw new CliError(`${clean(answer.origin)} doesn't answer — deploy the site publicly, then try again.`);
|
|
502
|
+
}
|
|
503
|
+
if (answer.status === "not_set_up") {
|
|
504
|
+
throw new CliError(`${clean(answer.origin)} has no preview route — mount createPreviewHandler from "@mapled/next/server" at /api/mapled/preview and redeploy.`);
|
|
505
|
+
}
|
|
506
|
+
// the link is printed as it came (masking would drop the token): printable ASCII only
|
|
507
|
+
if (typeof answer.url !== "string" || !/^https?:\/\/[!-~]+$/.test(answer.url)) {
|
|
508
|
+
throw new CliError("Mapled answered with a link this CLI can't print. Update @mapled/cli and try again.");
|
|
509
|
+
}
|
|
510
|
+
if (flags.json) {
|
|
511
|
+
ctx.out(JSON.stringify({ url: answer.url, path: answer.path, expiresAt: answer.expiresAt ?? null }, null, 2));
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
const where = answer.path ? clean(answer.path, 500) : "the site's home page";
|
|
515
|
+
const lifetime = linkLifetime(answer.expiresAt);
|
|
516
|
+
const project = clean(session.conn.projectName, 120);
|
|
517
|
+
if (flags.open && (await ctx.openBrowser(answer.url))) {
|
|
518
|
+
ctx.out(`Opened Preview of ${where} in your browser — the drafts of ${project}.`);
|
|
519
|
+
}
|
|
520
|
+
else {
|
|
521
|
+
if (flags.open)
|
|
522
|
+
ctx.out("Couldn't open a browser — open this link yourself:");
|
|
523
|
+
ctx.out(answer.url);
|
|
524
|
+
ctx.out(`Preview of ${where} with the drafts of ${project}. The link works once${lifetime ? `, for ${lifetime}` : ""}.`);
|
|
525
|
+
}
|
|
526
|
+
if (collection && !answer.path) {
|
|
527
|
+
ctx.out("Mapled doesn't know which page shows it, so the link opens the home page. Push the site manifest (`mapled scan --write`, then `mapled bindings push`) and Preview lands on its page.");
|
|
528
|
+
}
|
|
529
|
+
}
|
|
450
530
|
/* ---- wave 2: the scanner ---- */
|
|
451
531
|
function describeScan(result, schema) {
|
|
452
532
|
const singles = new Set(schema.collections.filter((c) => c.kind === "single").map((c) => c.key));
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { readFileSync } from "node:fs";
|
|
|
3
3
|
import { createInterface } from "node:readline/promises";
|
|
4
4
|
import { parseArgs } from "./args.js";
|
|
5
5
|
import { openBrowser } from "./browser.js";
|
|
6
|
-
import { bindingsPull, bindingsPush, doctor, formsCreate, formsList, generate, link, login, logout, manifestValidate, mdPull, scan, schemaDiff, schemaPull, } from "./commands.js";
|
|
6
|
+
import { bindingsPull, bindingsPush, doctor, formsCreate, formsList, generate, integrationReport, link, login, logout, manifestValidate, mdPull, preview, scan, schemaDiff, schemaPull, } from "./commands.js";
|
|
7
7
|
import { credentialsPath } from "./credentials.js";
|
|
8
8
|
import { CliError } from "./errors.js";
|
|
9
9
|
/** `mapled` — sign in, link a repository to its project, generate types
|
|
@@ -27,6 +27,8 @@ Usage
|
|
|
27
27
|
mapled forms create <name> [--key <key>] Create a form the site posts submissions to (--no-spam-protection to skip the honeypot)
|
|
28
28
|
mapled forms list [--json] List the project's forms and where the site posts to
|
|
29
29
|
mapled doctor [--json] Check the integration end to end
|
|
30
|
+
mapled integration report [--json] [--verify] Show the last setup run's verification checklist and report
|
|
31
|
+
mapled preview [<collection> [<slug>]] [--open] Print a one-time Preview link that shows the drafts on the site
|
|
30
32
|
|
|
31
33
|
Options
|
|
32
34
|
--version, -v Print the version
|
|
@@ -78,7 +80,8 @@ async function main(argv) {
|
|
|
78
80
|
version: version(),
|
|
79
81
|
credentialsFile: credentialsPath(),
|
|
80
82
|
};
|
|
81
|
-
|
|
83
|
+
// `preview` takes the record it opens as words of its own
|
|
84
|
+
switch (group === "preview" ? group : `${group} ${sub ?? ""}`.trim()) {
|
|
82
85
|
case "auth login":
|
|
83
86
|
await login(ctx, flags);
|
|
84
87
|
return 0;
|
|
@@ -118,6 +121,11 @@ async function main(argv) {
|
|
|
118
121
|
return 0;
|
|
119
122
|
case "doctor":
|
|
120
123
|
return doctor(ctx, flags);
|
|
124
|
+
case "integration report":
|
|
125
|
+
return integrationReport(ctx, flags);
|
|
126
|
+
case "preview":
|
|
127
|
+
await preview(ctx, flags, words.slice(1));
|
|
128
|
+
return 0;
|
|
121
129
|
case "auth":
|
|
122
130
|
case "project":
|
|
123
131
|
case "types":
|
|
@@ -126,6 +134,7 @@ async function main(argv) {
|
|
|
126
134
|
case "bindings":
|
|
127
135
|
case "md":
|
|
128
136
|
case "forms":
|
|
137
|
+
case "integration":
|
|
129
138
|
console.log(HELP);
|
|
130
139
|
return 2;
|
|
131
140
|
default:
|
package/dist/report.d.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { type Check } from "./doctor.js";
|
|
2
|
+
/** `mapled integration report` (§29.9, §31.2): the last setup run Mapled
|
|
3
|
+
holds for the project with its Verification checklist — the lines,
|
|
4
|
+
statuses and fix hints of S-42 — and what the agent reported. Nearly
|
|
5
|
+
every string here was typed by an agent or a member: it is cleaned of
|
|
6
|
+
control characters, and anything shaped like a Mapled key or token is
|
|
7
|
+
masked before it reaches the terminal or the JSON (§36.12 — the report
|
|
8
|
+
carries no secret, whatever an agent wrote into it). */
|
|
9
|
+
export type RunStatus = "awaiting_approval" | "approved" | "rejected" | "applying" | "verifying" | "checks_failed" | "completed" | "completed_with_warnings" | "failed";
|
|
10
|
+
/** A run as `GET /v1/agent/runs` lists it. */
|
|
11
|
+
export type RunSummary = {
|
|
12
|
+
id: string;
|
|
13
|
+
clientName: string;
|
|
14
|
+
status: RunStatus;
|
|
15
|
+
summary: string;
|
|
16
|
+
createdAt: string;
|
|
17
|
+
appliedAt: string | null;
|
|
18
|
+
verifiedAt: string | null;
|
|
19
|
+
completedAt: string | null;
|
|
20
|
+
};
|
|
21
|
+
/** A run as `GET /v1/agent/runs/:id` shows it. */
|
|
22
|
+
export type RunDetail = RunSummary & {
|
|
23
|
+
result: {
|
|
24
|
+
collections?: {
|
|
25
|
+
key: string;
|
|
26
|
+
displayName: string;
|
|
27
|
+
kind: string;
|
|
28
|
+
fieldKeys?: string[];
|
|
29
|
+
recordsCreated?: number;
|
|
30
|
+
}[];
|
|
31
|
+
warnings?: string[];
|
|
32
|
+
report?: {
|
|
33
|
+
filesChanged?: string[];
|
|
34
|
+
stillHardcoded?: string[];
|
|
35
|
+
warnings?: string[];
|
|
36
|
+
previewUrl?: string | null;
|
|
37
|
+
};
|
|
38
|
+
checks?: Check[];
|
|
39
|
+
} | null;
|
|
40
|
+
};
|
|
41
|
+
export type IntegrationReport = {
|
|
42
|
+
project: {
|
|
43
|
+
id: string;
|
|
44
|
+
name: string;
|
|
45
|
+
};
|
|
46
|
+
run: {
|
|
47
|
+
id: string;
|
|
48
|
+
client: string;
|
|
49
|
+
status: RunStatus;
|
|
50
|
+
summary: string;
|
|
51
|
+
createdAt: string;
|
|
52
|
+
appliedAt: string | null;
|
|
53
|
+
verifiedAt: string | null;
|
|
54
|
+
} | null;
|
|
55
|
+
/** A later plan than the one reported, not applied (yet). */
|
|
56
|
+
newer: {
|
|
57
|
+
id: string;
|
|
58
|
+
client: string;
|
|
59
|
+
status: RunStatus;
|
|
60
|
+
createdAt: string;
|
|
61
|
+
} | null;
|
|
62
|
+
checks: Check[];
|
|
63
|
+
collections: {
|
|
64
|
+
key: string;
|
|
65
|
+
displayName: string;
|
|
66
|
+
kind: string;
|
|
67
|
+
fields: number;
|
|
68
|
+
records: number;
|
|
69
|
+
}[];
|
|
70
|
+
filesChanged: string[];
|
|
71
|
+
stillHardcoded: string[];
|
|
72
|
+
warnings: string[];
|
|
73
|
+
previewUrl: string | null;
|
|
74
|
+
};
|
|
75
|
+
export declare const isChecked: (status: RunStatus) => boolean;
|
|
76
|
+
/** Mapled's keys and tokens, with the length and alphabet of the random
|
|
77
|
+
part Mapled issues: delivery and server keys — 16 bytes in hex
|
|
78
|
+
(`lib/deliveryKeys.ts`); agent, OAuth and code tokens — 32 bytes in
|
|
79
|
+
base64url (`generateToken`); preview links, draft sessions and the
|
|
80
|
+
webhook secret — 24 bytes in hex. The webhook secret's prefix is the
|
|
81
|
+
one of Standard Webhooks, and other issuers (Polar, Svix) write it
|
|
82
|
+
before base64 of 24 bytes or more, `+`, `/` and the `=` padding
|
|
83
|
+
included: the last kind, read up to its shortest length, so a site's
|
|
84
|
+
own secret doesn't show either. Mapled doesn't issue that one, so
|
|
85
|
+
behind a prefix written wholly in look-alikes it reads as text
|
|
86
|
+
(`keyLike`). */
|
|
87
|
+
export declare const KEYS: readonly (readonly [prefix: string, length: number, alphabet: "hex" | "base64url" | "base64"])[];
|
|
88
|
+
/** One line of text fit for a terminal: no control, blank-looking or
|
|
89
|
+
invisible characters, nothing that reads as a Mapled key. */
|
|
90
|
+
export declare function clean(text: unknown, max?: number): string;
|
|
91
|
+
/** The page an agent reported as its preview, without what may carry a
|
|
92
|
+
credential: no query, no fragment, no user info. The path is shown
|
|
93
|
+
decoded, so a key written in percent escapes is masked like any other;
|
|
94
|
+
a path that doesn't decode cleanly — malformed, or still escaped after
|
|
95
|
+
one decoding (encoded twice) — is dropped, and only the origin shows. */
|
|
96
|
+
export declare function reportedPage(url: unknown): string | null;
|
|
97
|
+
/** The run to report: the one applied last (an older plan can be applied
|
|
98
|
+
after a newer one); before any was applied, the newest there is. The
|
|
99
|
+
newer plan is the newest one proposed after it and not applied. */
|
|
100
|
+
export declare function pickRun(runs: RunSummary[]): {
|
|
101
|
+
shown: RunSummary | null;
|
|
102
|
+
newer: RunSummary | null;
|
|
103
|
+
};
|
|
104
|
+
export declare function buildReport(project: {
|
|
105
|
+
id: string;
|
|
106
|
+
name: string;
|
|
107
|
+
}, run: RunDetail | null, newer: RunSummary | null): IntegrationReport;
|
|
108
|
+
/** The closing line: what to do next. */
|
|
109
|
+
export declare function reportSummary(report: IntegrationReport): string;
|
|
110
|
+
export declare function formatReport(report: IntegrationReport, color: boolean): string[];
|
|
111
|
+
/** What `POST /v1/agent/preview` answers. */
|
|
112
|
+
export type PreviewAnswer = {
|
|
113
|
+
status: "ok";
|
|
114
|
+
url: string;
|
|
115
|
+
expiresAt?: string;
|
|
116
|
+
path: string | null;
|
|
117
|
+
} | {
|
|
118
|
+
status: "not_deployed";
|
|
119
|
+
origin: string;
|
|
120
|
+
path: string | null;
|
|
121
|
+
} | {
|
|
122
|
+
status: "not_set_up";
|
|
123
|
+
origin: string;
|
|
124
|
+
path: string | null;
|
|
125
|
+
};
|
|
126
|
+
/** "5 minutes" until the link stops working, or null when unknown. */
|
|
127
|
+
export declare function linkLifetime(expiresAt: string | undefined, now?: number): string | null;
|
package/dist/report.js
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
import { ago } from "./doctor.js";
|
|
2
|
+
import { plural } from "./manifest.js";
|
|
3
|
+
import { GLYPH, paint } from "./output.js";
|
|
4
|
+
const STATUS_LABEL = {
|
|
5
|
+
awaiting_approval: "waiting for approval",
|
|
6
|
+
approved: "approved, not applied yet",
|
|
7
|
+
rejected: "rejected",
|
|
8
|
+
applying: "applying",
|
|
9
|
+
verifying: "applied, checks pending",
|
|
10
|
+
checks_failed: "checks failed",
|
|
11
|
+
completed: "completed",
|
|
12
|
+
completed_with_warnings: "completed with warnings",
|
|
13
|
+
failed: "failed",
|
|
14
|
+
};
|
|
15
|
+
const label = (status) => STATUS_LABEL[status] ?? clean(status, 40);
|
|
16
|
+
/** Applied runs: the ones the server checks (S-42). */
|
|
17
|
+
const CHECKED = ["verifying", "checks_failed", "completed", "completed_with_warnings"];
|
|
18
|
+
export const isChecked = (status) => CHECKED.includes(status);
|
|
19
|
+
/** Mapled's keys and tokens, with the length and alphabet of the random
|
|
20
|
+
part Mapled issues: delivery and server keys — 16 bytes in hex
|
|
21
|
+
(`lib/deliveryKeys.ts`); agent, OAuth and code tokens — 32 bytes in
|
|
22
|
+
base64url (`generateToken`); preview links, draft sessions and the
|
|
23
|
+
webhook secret — 24 bytes in hex. The webhook secret's prefix is the
|
|
24
|
+
one of Standard Webhooks, and other issuers (Polar, Svix) write it
|
|
25
|
+
before base64 of 24 bytes or more, `+`, `/` and the `=` padding
|
|
26
|
+
included: the last kind, read up to its shortest length, so a site's
|
|
27
|
+
own secret doesn't show either. Mapled doesn't issue that one, so
|
|
28
|
+
behind a prefix written wholly in look-alikes it reads as text
|
|
29
|
+
(`keyLike`). */
|
|
30
|
+
export const KEYS = [
|
|
31
|
+
["mk_live_", 32, "hex"],
|
|
32
|
+
["msk_live_", 32, "hex"],
|
|
33
|
+
["mcp_live_", 43, "base64url"],
|
|
34
|
+
["mcp_oauth_", 43, "base64url"],
|
|
35
|
+
["mcp_refresh_", 43, "base64url"],
|
|
36
|
+
["mcp_code_", 43, "base64url"],
|
|
37
|
+
["mpv_", 48, "hex"],
|
|
38
|
+
["mps_", 48, "hex"],
|
|
39
|
+
["whsec_", 48, "hex"],
|
|
40
|
+
["whsec_", 32, "base64"],
|
|
41
|
+
];
|
|
42
|
+
/** A prefix, read with a blank allowed between any two of its characters. */
|
|
43
|
+
const PREFIX_SOURCE = [...new Set(KEYS.map(([prefix]) => prefix))].map((prefix) => [...prefix].join(" ?")).join("|");
|
|
44
|
+
const PREFIX = new RegExp(PREFIX_SOURCE, "g");
|
|
45
|
+
const PREFIX_HERE = new RegExp(PREFIX_SOURCE, "y");
|
|
46
|
+
/** The characters of a random part: hex and base64url, and base64 with its `+`, `/` and `=`. */
|
|
47
|
+
const KEY_CHAR = /[a-z0-9_-]/;
|
|
48
|
+
const BASE64_CHAR = /[a-z0-9_+/=-]/;
|
|
49
|
+
/** Code points a terminal draws as a blank or a line break: every control
|
|
50
|
+
character (C0, DEL, C1 — an escape sequence could rewrite the screen),
|
|
51
|
+
every Unicode space and line or paragraph separator, and the braille
|
|
52
|
+
blank. Each becomes one ordinary space: the report is one line. */
|
|
53
|
+
const BLANK = /[\p{Cc}\p{White_Space}\u2800]/u;
|
|
54
|
+
/** Code points a terminal draws as nothing: every format character (Cf —
|
|
55
|
+
bidirectional overrides and isolates reorder what is shown; the soft
|
|
56
|
+
hyphen, zero-width and invisible-operator characters hide inside a word),
|
|
57
|
+
every default-ignorable one (variation selectors, the combining grapheme
|
|
58
|
+
joiner, Hangul fillers, tags), unassigned code points and lone
|
|
59
|
+
surrogates. A key split by one would still read whole, so they go. */
|
|
60
|
+
const INVISIBLE = /[\p{Cf}\p{Default_Ignorable_Code_Point}\p{Cn}\p{Cs}]/u;
|
|
61
|
+
/** One line of text fit for a terminal: no control, blank-looking or
|
|
62
|
+
invisible characters, nothing that reads as a Mapled key. */
|
|
63
|
+
export function clean(text, max = 300) {
|
|
64
|
+
let out = "";
|
|
65
|
+
for (const ch of String(text ?? "")) {
|
|
66
|
+
if (BLANK.test(ch))
|
|
67
|
+
out += " ";
|
|
68
|
+
else if (!INVISIBLE.test(ch))
|
|
69
|
+
out += ch;
|
|
70
|
+
}
|
|
71
|
+
const shown = [...maskKeys(out.replace(/ {2,}/g, " ").trim())];
|
|
72
|
+
return shown.length > max ? `${shown.slice(0, max - 1).join("")}…` : shown.join("");
|
|
73
|
+
}
|
|
74
|
+
/** Stand-ins for a character from outside ASCII that no compatibility form
|
|
75
|
+
turns into ASCII. A letter or digit of another script (Cyrillic `ѕ`,
|
|
76
|
+
Greek `ο`, Armenian `օ`) can pass for a letter or digit; a dash, a low
|
|
77
|
+
line, a modifier or a math symbol (`‐`, `ˍ`, `∨`) for any character of
|
|
78
|
+
a key. Neither stand-in can come from the line itself: `clean` has
|
|
79
|
+
turned every control character into a blank. */
|
|
80
|
+
const LETTERLIKE = String.fromCharCode(1);
|
|
81
|
+
const STROKELIKE = String.fromCharCode(2);
|
|
82
|
+
const STROKE = /[\p{Pd}\p{Pc}\p{Sm}\p{Sk}\p{Lm}\u2017]/u;
|
|
83
|
+
/** What a reader takes a character for: its compatibility form (a
|
|
84
|
+
fullwidth letter is the letter), without accents and other marks, in
|
|
85
|
+
lower case, or a stand-in when that isn't ASCII. A mark alone reads as
|
|
86
|
+
nothing; other characters (`。`, an emoji) read as themselves. */
|
|
87
|
+
const skeletonOf = (ch) => {
|
|
88
|
+
if (ch === " ")
|
|
89
|
+
return " ";
|
|
90
|
+
const read = ch.normalize("NFKD").replace(/\p{M}/gu, "").toLowerCase();
|
|
91
|
+
if (/^[!-~]*$/.test(read))
|
|
92
|
+
return read;
|
|
93
|
+
if (STROKE.test(ch))
|
|
94
|
+
return STROKELIKE;
|
|
95
|
+
return /[\p{L}\p{N}]/u.test(ch) ? LETTERLIKE : read;
|
|
96
|
+
};
|
|
97
|
+
/** ASCII characters that pass for one of a prefix: 1, | and I (read in
|
|
98
|
+
lower case) for l, 0 for o, a hyphen for `_`. */
|
|
99
|
+
const TWINS = { l: "1|i", o: "0", _: "-" };
|
|
100
|
+
/** A prefix read with look-alikes: each letter may be itself, its ASCII
|
|
101
|
+
twin or a stand-in; `_` may be a hyphen or a stroke, never a letter; a
|
|
102
|
+
blank may stand between any two of its characters, as in `PREFIX`. */
|
|
103
|
+
const likeSource = (prefix) => [...prefix].map((c) => (c === "_" ? `[${STROKELIKE}_-]` : `[${c}${TWINS[c] ?? ""}${LETTERLIKE}${STROKELIKE}]`)).join(" ?");
|
|
104
|
+
const LIKE_SOURCE = [...new Set(KEYS.map(([prefix]) => prefix))].map((prefix) => `(?:${likeSource(prefix)})`).join("|");
|
|
105
|
+
const LIKE_PREFIX = new RegExp(LIKE_SOURCE, "g");
|
|
106
|
+
const LIKE_HERE = new RegExp(LIKE_SOURCE, "y");
|
|
107
|
+
/** Each kind's prefix on its own, in the order of `KEYS`: a stand-in
|
|
108
|
+
passes for several letters, so one prefix written in them can pass
|
|
109
|
+
for more than one kind (`мсрˍӏіѵеˍ` for `msk_live_` and `mcp_live_`).
|
|
110
|
+
Each kind is tried, and the longest key read is masked. */
|
|
111
|
+
const LIKE_KIND = KEYS.map(([prefix]) => new RegExp(likeSource(prefix), "y"));
|
|
112
|
+
const LIKE_CHAR = new RegExp(`[a-z0-9_${LETTERLIKE}${STROKELIKE}-]`);
|
|
113
|
+
const LIKE_BASE64_CHAR = new RegExp(`[a-z0-9_+/=${LETTERLIKE}${STROKELIKE}-]`);
|
|
114
|
+
/** A random part read as the hex Mapled issues: digits and `a`–`f`, or a letter of another script drawn like one; blanks between pieces. */
|
|
115
|
+
const HEX_READ = new RegExp(`^[0-9a-f ${LETTERLIKE}]*$`);
|
|
116
|
+
/** Whether a key read with look-alikes is a key, not text of another
|
|
117
|
+
script. `random` is its random part as long as Mapled issues it — what
|
|
118
|
+
is glued on after that counts for nothing — and both tests count
|
|
119
|
+
characters, so a blank anywhere in the key changes nothing:
|
|
120
|
+
- a random part with more dashes and low lines than letters and
|
|
121
|
+
digits is not a key (`что-либо ------ see`) — Mapled's are nearly
|
|
122
|
+
all letters and digits;
|
|
123
|
+
- one with more letters of another script than ASCII letters and
|
|
124
|
+
digits is text in that script (`mpv — ещё не заполнено …`);
|
|
125
|
+
- so is a prefix without one ASCII letter or digit (`что-`, `тьи —`),
|
|
126
|
+
unless the random part is one Mapled could issue: a hex one without
|
|
127
|
+
an ASCII letter past `f` (`Статьи — see the app page …` has one), a
|
|
128
|
+
base64url one without a letter of another script. Digits or none:
|
|
129
|
+
a token may be letters only, so `что-либо — see …` followed by as
|
|
130
|
+
many ASCII letters as a token has is masked with it. The base64 of
|
|
131
|
+
another issuer's webhook secret isn't one Mapled issues: behind such
|
|
132
|
+
a prefix it reads as text, or `Статьи — see …` would be a secret. */
|
|
133
|
+
const STROKES_NOT = new RegExp(`[^${STROKELIKE}_-]`, "g");
|
|
134
|
+
function keyLike(prefix, random, alphabet) {
|
|
135
|
+
const ascii = random.match(/[a-z0-9]/g)?.length ?? 0;
|
|
136
|
+
const foreign = random.split(LETTERLIKE).length - 1;
|
|
137
|
+
const strokes = random.replace(STROKES_NOT, "").length;
|
|
138
|
+
if (strokes > ascii + foreign || foreign > ascii)
|
|
139
|
+
return false;
|
|
140
|
+
if (/[a-z0-9]/.test(prefix))
|
|
141
|
+
return true;
|
|
142
|
+
if (alphabet === "hex")
|
|
143
|
+
return !/[g-z]/.test(random);
|
|
144
|
+
return alphabet === "base64url" && foreign === 0;
|
|
145
|
+
}
|
|
146
|
+
const reading = (skeleton, char, here) => {
|
|
147
|
+
const ends = new Int32Array(skeleton.length + 1);
|
|
148
|
+
ends[skeleton.length] = skeleton.length;
|
|
149
|
+
for (let i = skeleton.length - 1; i >= 0; i--)
|
|
150
|
+
ends[i] = char.test(skeleton[i]) ? ends[i + 1] : i;
|
|
151
|
+
return { ends, here };
|
|
152
|
+
};
|
|
153
|
+
/** Masks every Mapled key a reader could take off the line (§36.12).
|
|
154
|
+
Matching runs on what the characters read as, so case, fullwidth forms
|
|
155
|
+
and marks don't hide a key; a blank inside the prefix doesn't either,
|
|
156
|
+
and a key wrapped or split by blanks is read on across them while its
|
|
157
|
+
pieces add up to the length Mapled issues. A key written with look-
|
|
158
|
+
alikes — another script's letters, 1 for l, 0 for o, a dash for `_` —
|
|
159
|
+
is read the same way, blanks and pieces included, when it is as long
|
|
160
|
+
as Mapled issues it and `keyLike` doesn't take it for text of another
|
|
161
|
+
script. Each key, with all the pieces, becomes `[redacted]`. A key an
|
|
162
|
+
agent re-encodes (base64, reversed, spelled out, broken up by
|
|
163
|
+
punctuation) is not something a reader takes off the line, and no mask
|
|
164
|
+
can see it. */
|
|
165
|
+
function maskKeys(line) {
|
|
166
|
+
const chars = [...line];
|
|
167
|
+
let skeleton = "";
|
|
168
|
+
/** The character behind each UTF-16 unit of the skeleton — matching
|
|
169
|
+
indexes units, and an emoji reads as two. */
|
|
170
|
+
const owner = [];
|
|
171
|
+
chars.forEach((ch, i) => {
|
|
172
|
+
const read = skeletonOf(ch);
|
|
173
|
+
skeleton += read;
|
|
174
|
+
for (let unit = 0; unit < read.length; unit++)
|
|
175
|
+
owner.push(i);
|
|
176
|
+
});
|
|
177
|
+
const hidden = new Set();
|
|
178
|
+
const hide = (from, to) => {
|
|
179
|
+
let last = owner[to - 1];
|
|
180
|
+
while (last + 1 < chars.length && skeletonOf(chars[last + 1]) === "")
|
|
181
|
+
last++;
|
|
182
|
+
for (let i = owner[from]; i <= last; i++)
|
|
183
|
+
hidden.add(i);
|
|
184
|
+
};
|
|
185
|
+
/** Each alphabet's reading, found once per line: base64 reads on over
|
|
186
|
+
`+`, `/` and `=`. Where Mapled's own hex secret is read whole, another
|
|
187
|
+
issuer's base64 after the same prefix isn't, so `/x` after it in a
|
|
188
|
+
path stays; anywhere else the longest reading is masked. */
|
|
189
|
+
const plain = { narrow: reading(skeleton, KEY_CHAR, PREFIX_HERE), base64: reading(skeleton, BASE64_CHAR, PREFIX_HERE) };
|
|
190
|
+
PREFIX.lastIndex = 0;
|
|
191
|
+
for (let match = PREFIX.exec(skeleton); match; match = PREFIX.exec(skeleton)) {
|
|
192
|
+
const prefix = match[0].replaceAll(" ", "");
|
|
193
|
+
let longest = PREFIX.lastIndex;
|
|
194
|
+
for (const [p, length, alphabet] of KEYS) {
|
|
195
|
+
if (p !== prefix)
|
|
196
|
+
continue;
|
|
197
|
+
const { end, issued } = keyEnd(skeleton, PREFIX.lastIndex, length, alphabet === "base64" ? plain.base64 : plain.narrow);
|
|
198
|
+
longest = Math.max(longest, end);
|
|
199
|
+
if (issued !== null && alphabet === "hex" && HEX_READ.test(skeleton.slice(PREFIX.lastIndex, issued)))
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
if (longest === PREFIX.lastIndex)
|
|
203
|
+
continue;
|
|
204
|
+
hide(match.index, longest);
|
|
205
|
+
PREFIX.lastIndex = longest;
|
|
206
|
+
}
|
|
207
|
+
const like = { narrow: reading(skeleton, LIKE_CHAR, LIKE_HERE), base64: reading(skeleton, LIKE_BASE64_CHAR, LIKE_HERE) };
|
|
208
|
+
LIKE_PREFIX.lastIndex = 0;
|
|
209
|
+
for (let match = LIKE_PREFIX.exec(skeleton); match; match = LIKE_PREFIX.exec(skeleton)) {
|
|
210
|
+
let longest = 0;
|
|
211
|
+
let other = 0;
|
|
212
|
+
let hex = false;
|
|
213
|
+
for (const [kind, [, length, alphabet]] of KEYS.entries()) {
|
|
214
|
+
const here = LIKE_KIND[kind];
|
|
215
|
+
here.lastIndex = match.index;
|
|
216
|
+
if (!here.test(skeleton))
|
|
217
|
+
continue;
|
|
218
|
+
const { end, issued } = keyEnd(skeleton, here.lastIndex, length, alphabet === "base64" ? like.base64 : like.narrow);
|
|
219
|
+
const random = issued === null ? "" : skeleton.slice(here.lastIndex, issued);
|
|
220
|
+
if (issued === null || !keyLike(skeleton.slice(match.index, here.lastIndex), random, alphabet))
|
|
221
|
+
continue;
|
|
222
|
+
if (alphabet === "base64")
|
|
223
|
+
other = Math.max(other, end);
|
|
224
|
+
else
|
|
225
|
+
longest = Math.max(longest, end);
|
|
226
|
+
hex ||= alphabet === "hex" && HEX_READ.test(random);
|
|
227
|
+
}
|
|
228
|
+
if (!hex)
|
|
229
|
+
longest = Math.max(longest, other);
|
|
230
|
+
if (longest > 0)
|
|
231
|
+
hide(match.index, longest);
|
|
232
|
+
LIKE_PREFIX.lastIndex = Math.max(longest, match.index + 1);
|
|
233
|
+
}
|
|
234
|
+
let out = "";
|
|
235
|
+
chars.forEach((ch, i) => {
|
|
236
|
+
if (!hidden.has(i))
|
|
237
|
+
out += ch;
|
|
238
|
+
else if (!hidden.has(i - 1))
|
|
239
|
+
out += "[redacted]";
|
|
240
|
+
});
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
/** Where the random part that starts at `start` ends (`start` itself when
|
|
244
|
+
there is none), and where it reaches the length Mapled issues (`null`
|
|
245
|
+
when it doesn't). It runs over key characters; when it stops short of
|
|
246
|
+
`length` at a blank, the pieces after it belong to the key if they
|
|
247
|
+
complete it — a piece that starts a key of its own doesn't. */
|
|
248
|
+
function keyEnd(skeleton, start, length, reading) {
|
|
249
|
+
let end = reading.ends[start];
|
|
250
|
+
let read = end - start;
|
|
251
|
+
let issued = read >= length ? start + length : null;
|
|
252
|
+
for (let at = end; read < length;) {
|
|
253
|
+
let next = at;
|
|
254
|
+
while (skeleton[next] === " ")
|
|
255
|
+
next++;
|
|
256
|
+
if (next === at)
|
|
257
|
+
break;
|
|
258
|
+
reading.here.lastIndex = next;
|
|
259
|
+
const to = reading.ends[next];
|
|
260
|
+
if (to === next || reading.here.test(skeleton))
|
|
261
|
+
break;
|
|
262
|
+
if (read + (to - next) >= length) {
|
|
263
|
+
issued = next + (length - read);
|
|
264
|
+
end = to;
|
|
265
|
+
}
|
|
266
|
+
read += to - next;
|
|
267
|
+
at = to;
|
|
268
|
+
}
|
|
269
|
+
return { end, issued };
|
|
270
|
+
}
|
|
271
|
+
/** The page an agent reported as its preview, without what may carry a
|
|
272
|
+
credential: no query, no fragment, no user info. The path is shown
|
|
273
|
+
decoded, so a key written in percent escapes is masked like any other;
|
|
274
|
+
a path that doesn't decode cleanly — malformed, or still escaped after
|
|
275
|
+
one decoding (encoded twice) — is dropped, and only the origin shows. */
|
|
276
|
+
export function reportedPage(url) {
|
|
277
|
+
if (typeof url !== "string")
|
|
278
|
+
return null;
|
|
279
|
+
let parsed;
|
|
280
|
+
try {
|
|
281
|
+
parsed = new URL(url);
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:")
|
|
287
|
+
return null;
|
|
288
|
+
const path = decodedPath(parsed.pathname);
|
|
289
|
+
return clean(path === null ? parsed.origin : `${parsed.origin}${path}`, 300) || null;
|
|
290
|
+
}
|
|
291
|
+
function decodedPath(path) {
|
|
292
|
+
let decoded;
|
|
293
|
+
try {
|
|
294
|
+
decoded = decodeURIComponent(path);
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
return /%[0-9a-f]{2}/i.test(decoded) ? null : decoded;
|
|
300
|
+
}
|
|
301
|
+
/** The run to report: the one applied last (an older plan can be applied
|
|
302
|
+
after a newer one); before any was applied, the newest there is. The
|
|
303
|
+
newer plan is the newest one proposed after it and not applied. */
|
|
304
|
+
export function pickRun(runs) {
|
|
305
|
+
const appliedAt = (r) => Date.parse(r.appliedAt ?? r.createdAt);
|
|
306
|
+
const shown = runs.filter((r) => isChecked(r.status)).reduce((last, r) => (last && !(appliedAt(r) > appliedAt(last)) ? last : r), null) ??
|
|
307
|
+
runs[0] ??
|
|
308
|
+
null;
|
|
309
|
+
const newer = shown ? (runs.slice(0, runs.indexOf(shown)).find((r) => !isChecked(r.status)) ?? null) : null;
|
|
310
|
+
return { shown, newer };
|
|
311
|
+
}
|
|
312
|
+
const STATUSES = new Set(["passed", "failed", "warning", "skipped"]);
|
|
313
|
+
const lines = (list, max = 50) => (Array.isArray(list) ? list : []).slice(0, max).map((s) => clean(s)).filter((s) => s.length > 0);
|
|
314
|
+
export function buildReport(project, run, newer) {
|
|
315
|
+
const result = run?.result ?? null;
|
|
316
|
+
const report = result?.report;
|
|
317
|
+
return {
|
|
318
|
+
project: { id: clean(project.id, 64), name: clean(project.name, 120) },
|
|
319
|
+
run: run
|
|
320
|
+
? {
|
|
321
|
+
id: clean(run.id, 64),
|
|
322
|
+
client: clean(run.clientName, 80),
|
|
323
|
+
status: run.status,
|
|
324
|
+
summary: clean(run.summary, 500),
|
|
325
|
+
createdAt: run.createdAt,
|
|
326
|
+
appliedAt: run.appliedAt,
|
|
327
|
+
verifiedAt: run.verifiedAt,
|
|
328
|
+
}
|
|
329
|
+
: null,
|
|
330
|
+
newer: newer ? { id: clean(newer.id, 64), client: clean(newer.clientName, 80), status: newer.status, createdAt: newer.createdAt } : null,
|
|
331
|
+
checks: (result?.checks ?? [])
|
|
332
|
+
.filter((c) => STATUSES.has(c.status))
|
|
333
|
+
.map((c) => ({ key: clean(c.key, 40), label: clean(c.label, 80), status: c.status, detail: clean(c.detail, 500) })),
|
|
334
|
+
collections: (result?.collections ?? []).map((c) => ({
|
|
335
|
+
key: clean(c.key, 80),
|
|
336
|
+
displayName: clean(c.displayName, 120),
|
|
337
|
+
kind: clean(c.kind, 20),
|
|
338
|
+
fields: Array.isArray(c.fieldKeys) ? c.fieldKeys.length : 0,
|
|
339
|
+
records: typeof c.recordsCreated === "number" ? c.recordsCreated : 0,
|
|
340
|
+
})),
|
|
341
|
+
filesChanged: lines(report?.filesChanged),
|
|
342
|
+
stillHardcoded: lines(report?.stillHardcoded),
|
|
343
|
+
warnings: [...lines(result?.warnings), ...lines(report?.warnings, 20)],
|
|
344
|
+
previewUrl: reportedPage(report?.previewUrl),
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
function runLine(r) {
|
|
348
|
+
const parts = [r.client, label(r.status)];
|
|
349
|
+
if (r.appliedAt)
|
|
350
|
+
parts.push(`applied ${ago(r.appliedAt)}`);
|
|
351
|
+
else
|
|
352
|
+
parts.push(`proposed ${ago(r.createdAt)}`);
|
|
353
|
+
if (r.verifiedAt)
|
|
354
|
+
parts.push(`checked ${ago(r.verifiedAt)}`);
|
|
355
|
+
return parts.join(" • ");
|
|
356
|
+
}
|
|
357
|
+
/** The closing line: what to do next. */
|
|
358
|
+
export function reportSummary(report) {
|
|
359
|
+
const failed = report.checks.filter((c) => c.status === "failed").length;
|
|
360
|
+
const warned = report.checks.filter((c) => c.status === "warning").length;
|
|
361
|
+
if (!report.run)
|
|
362
|
+
return "";
|
|
363
|
+
if (report.checks.length === 0) {
|
|
364
|
+
return isChecked(report.run.status)
|
|
365
|
+
? "The checks haven't run yet — `mapled integration report --verify` runs them."
|
|
366
|
+
: "Mapled checks the integration once the plan is applied.";
|
|
367
|
+
}
|
|
368
|
+
if (failed === 0 && warned === 0)
|
|
369
|
+
return "Integration verified. Your site is fully connected.";
|
|
370
|
+
const parts = [];
|
|
371
|
+
if (failed > 0)
|
|
372
|
+
parts.push(plural(failed, "problem"));
|
|
373
|
+
if (warned > 0)
|
|
374
|
+
parts.push(plural(warned, "warning"));
|
|
375
|
+
return `${parts.join(", ")}.${failed > 0 ? " Fix what the checks name, then `mapled integration report --verify` runs them again." : ""}`;
|
|
376
|
+
}
|
|
377
|
+
export function formatReport(report, color) {
|
|
378
|
+
const out = [`Integration report — ${report.project.name}`, ""];
|
|
379
|
+
if (!report.run) {
|
|
380
|
+
out.push(`No AI setup run in ${report.project.name} yet — the report comes from one. \`mapled doctor\` checks the integration from this repository.`);
|
|
381
|
+
return out;
|
|
382
|
+
}
|
|
383
|
+
out.push(`Setup run ${runLine(report.run)}`);
|
|
384
|
+
if (report.run.summary)
|
|
385
|
+
out.push(`Plan “${report.run.summary}”`);
|
|
386
|
+
if (report.newer) {
|
|
387
|
+
out.push(`Newer ${report.newer.client} • ${label(report.newer.status)} • proposed ${ago(report.newer.createdAt)} — the report is of the last applied run`);
|
|
388
|
+
}
|
|
389
|
+
if (report.checks.length > 0) {
|
|
390
|
+
out.push("", "Verification");
|
|
391
|
+
const width = report.checks.reduce((w, c) => Math.max(w, c.label.length), 0);
|
|
392
|
+
for (const c of report.checks)
|
|
393
|
+
out.push(`${paint(c.status, GLYPH[c.status], color)} ${c.label.padEnd(width)} ${c.detail}`);
|
|
394
|
+
}
|
|
395
|
+
if (report.collections.length > 0) {
|
|
396
|
+
out.push("", "Collections created");
|
|
397
|
+
const width = report.collections.reduce((w, c) => Math.max(w, `${c.displayName} (${c.key})`.length), 0);
|
|
398
|
+
for (const c of report.collections) {
|
|
399
|
+
out.push(` ${`${c.displayName} (${c.key})`.padEnd(width)} ${c.kind} • ${plural(c.fields, "field")} • ${plural(c.records, "record")} imported`);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
const list = (title, items, glyph) => {
|
|
403
|
+
if (items.length === 0)
|
|
404
|
+
return;
|
|
405
|
+
out.push("", title);
|
|
406
|
+
for (const item of items)
|
|
407
|
+
out.push(glyph ? `${paint("warning", glyph, color)} ${item}` : ` ${item}`);
|
|
408
|
+
};
|
|
409
|
+
list("Files changed", report.filesChanged);
|
|
410
|
+
list("Still hardcoded", report.stillHardcoded);
|
|
411
|
+
list("Warnings", report.warnings, GLYPH.warning);
|
|
412
|
+
if (report.previewUrl)
|
|
413
|
+
out.push("", `Preview reported by the agent: ${report.previewUrl}`);
|
|
414
|
+
out.push("", reportSummary(report));
|
|
415
|
+
return out;
|
|
416
|
+
}
|
|
417
|
+
/** "5 minutes" until the link stops working, or null when unknown. */
|
|
418
|
+
export function linkLifetime(expiresAt, now = Date.now()) {
|
|
419
|
+
if (!expiresAt)
|
|
420
|
+
return null;
|
|
421
|
+
const ms = new Date(expiresAt).getTime() - now;
|
|
422
|
+
if (!Number.isFinite(ms) || ms <= 0)
|
|
423
|
+
return null;
|
|
424
|
+
return plural(Math.max(1, Math.round(ms / 60_000)), "minute");
|
|
425
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mapled/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Mapled CLI — sign in, link a project, generate TypeScript types for its content, create forms and check the site's integration.",
|
|
3
|
+
"version": "0.11.0",
|
|
4
|
+
"description": "Mapled CLI — sign in, link a project, generate TypeScript types for its content, create forms, preview drafts and check the site's integration.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://mapled.io",
|
|
7
7
|
"keywords": [
|