@mapled/cli 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
- switch (`${group} ${sub ?? ""}`.trim()) {
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:
@@ -0,0 +1,11 @@
1
+ Copyright (c) Felix Böhm
2
+ All rights reserved.
3
+
4
+ Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
5
+
6
+ Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
7
+
8
+ Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
9
+
10
+ THIS IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS,
11
+ EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2013-2019 Ivan Nikulin (ifaaan@gmail.com, https://github.com/inikulin)
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in
11
+ all copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ THE SOFTWARE.
@@ -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/dist/scan.d.ts CHANGED
@@ -32,7 +32,7 @@ export type Read = {
32
32
  };
33
33
  export type ScanResult = {
34
34
  framework: string | null;
35
- parser: "typescript" | "tokens";
35
+ parser: "typescript" | "tokens" | "html";
36
36
  files: number;
37
37
  pages: ManifestPage[];
38
38
  bindings: ManifestBinding[];
package/dist/scan.js CHANGED
@@ -1200,7 +1200,10 @@ export async function scanRepository(dir, opts) {
1200
1200
  ...(read.component ? { component: read.component } : {}),
1201
1201
  file: relFile(read.file),
1202
1202
  collection: read.collection,
1203
- target: "collection",
1203
+ // a list shows any record of the collection, a single is its one record; one read
1204
+ // record of a collection is that record — Mapled doesn't offer the page as the
1205
+ // Preview of another (recordRoutes.ts)
1206
+ target: isList || read.method === "getSingle" ? "collection" : "other",
1204
1207
  });
1205
1208
  }
1206
1209
  if (read.bySlug && /\[[^\]]+\]/.test(page)) {