@vigilbase/cli 0.2.4 → 0.2.6

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.
Files changed (32) hide show
  1. package/CLOUDFLARE.md +9 -2
  2. package/COMMANDS.md +5 -5
  3. package/LOCAL.md +87 -12
  4. package/ONE.md +2 -2
  5. package/README.md +13 -10
  6. package/dist/commands/cloudflare.js +79 -13
  7. package/dist/commands/helpers.d.ts +4 -1
  8. package/dist/commands/local.js +44 -23
  9. package/dist/lib/checkup-report-limits.d.ts +16 -0
  10. package/dist/lib/checkup-report-limits.js +16 -0
  11. package/dist/lib/checkup-report.d.ts +118 -0
  12. package/dist/lib/checkup-report.js +503 -0
  13. package/dist/lib/format.d.ts +2 -0
  14. package/dist/lib/format.js +1 -1
  15. package/dist/lib/one-error-hints.d.ts +1 -0
  16. package/dist/lib/one-error-hints.js +10 -0
  17. package/dist/lib/tui.d.ts +4 -0
  18. package/dist/lib/tui.js +2 -2
  19. package/dist/local/cloudflare-checkup/cf/allowlist.js +5 -2
  20. package/dist/local/cloudflare-checkup/collector.js +5 -5
  21. package/dist/local/cloudflare-checkup/contract.d.ts +481 -20
  22. package/dist/local/cloudflare-checkup/contract.js +111 -27
  23. package/dist/local/cloudflare-checkup/email-verification.d.ts +8 -1
  24. package/dist/local/cloudflare-checkup/email-verification.js +0 -5
  25. package/dist/local/cloudflare-checkup/identity-verification.d.ts +11 -0
  26. package/dist/local/cloudflare-checkup/identity-verification.js +65 -0
  27. package/dist/local/cloudflare-checkup/snapshot-file.js +13 -1
  28. package/dist/local/cloudflare-checkup/submit-client.d.ts +6 -3
  29. package/dist/local/cloudflare-checkup/submit-client.js +17 -4
  30. package/dist/local/cloudflare-checkup/terminal-ui.d.ts +10 -3
  31. package/dist/local/cloudflare-checkup/terminal-ui.js +13 -3
  32. package/package.json +1 -1
@@ -0,0 +1,503 @@
1
+ /**
2
+ * A readable Cloudflare Checkup result, from what the server sent: the
3
+ * locked free projection a local checkup's upload returns, or the stored
4
+ * result One returns for `cf checkup result` and `cf checkup`.
5
+ *
6
+ * Presentation only. Grades, scores, severities, statuses, counts and names
7
+ * are the server's; nothing here computes a grade or assigns a severity. It
8
+ * groups the server's checks by check ID, orders them by the server's status
9
+ * and severity, and fits them to the terminal.
10
+ *
11
+ * Every string from the payload is sanitised when it is read (no terminal
12
+ * controls, bidi overrides or line breaks), so the views only ever hold text
13
+ * that is safe to print; styles are added by the renderers.
14
+ */
15
+ import { CLI_CHECKUP_LIMITS } from './checkup-report-limits.js';
16
+ import { reportUrl, sanitizeTerminal } from './format.js';
17
+ import { displayWidth, padEnd, truncate } from './tui.js';
18
+ /** Longest text kept from the payload; the renderers cut to the terminal width. */
19
+ const MAX_TEXT = 300;
20
+ const record = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value : null;
21
+ const list = (value) => Array.isArray(value) ? value.map(record).filter((item) => item !== null) : [];
22
+ /** Untrusted text on one line: controls, bidi overrides and line breaks removed, whitespace collapsed. */
23
+ function text(value, max = MAX_TEXT) {
24
+ if (typeof value !== 'string')
25
+ return null;
26
+ const flat = sanitizeTerminal(value.replace(/[\s\u0085\u2028\u2029]+/g, ' ')).trim();
27
+ return flat === '' ? null : [...flat].slice(0, max).join('');
28
+ }
29
+ const count = (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? Math.floor(value) : null;
30
+ const score = (value) => typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : null;
31
+ /** A valid timestamp, normalised to ISO 8601 UTC. */
32
+ function isoDate(value) {
33
+ if (typeof value !== 'string')
34
+ return null;
35
+ const time = Date.parse(value);
36
+ return Number.isNaN(time) ? null : new Date(time).toISOString();
37
+ }
38
+ /** The first non-empty line of untrusted text. */
39
+ function firstLine(value) {
40
+ if (typeof value !== 'string')
41
+ return null;
42
+ for (const line of value.split(/\r\n|[\n\r\u0085\u2028\u2029]/)) {
43
+ const flat = text(line);
44
+ if (flat)
45
+ return flat;
46
+ }
47
+ return null;
48
+ }
49
+ const SEVERITY_ORDER = ['critical', 'high', 'medium', 'warning', 'low', 'info'];
50
+ const severityRank = (severity) => {
51
+ const rank = SEVERITY_ORDER.indexOf((severity ?? '').toLowerCase());
52
+ return rank === -1 ? SEVERITY_ORDER.length : rank;
53
+ };
54
+ const statusRank = (status) => (status === 'failed' ? 0 : status === 'warning' ? 1 : 2);
55
+ function counts(value) {
56
+ const summary = record(value);
57
+ if (!summary)
58
+ return null;
59
+ const view = { total: count(summary.total), passed: count(summary.passed), warnings: count(summary.warnings), failed: count(summary.failed), skipped: count(summary.skipped) };
60
+ return view.passed === null && view.warnings === null && view.failed === null ? null : view;
61
+ }
62
+ /** Instances of the same check ID become one finding; the most severe of the server's labels is shown. */
63
+ function group(instances) {
64
+ const groups = new Map();
65
+ for (const instance of instances) {
66
+ let entry = groups.get(instance.key);
67
+ if (!entry) {
68
+ entry = { finding: { name: instance.name, severity: instance.severity, status: instance.status, zones: 0, account: false, recommendation: null }, zones: new Set() };
69
+ groups.set(instance.key, entry);
70
+ }
71
+ const { finding } = entry;
72
+ if (statusRank(instance.status) < statusRank(finding.status))
73
+ finding.status = instance.status;
74
+ if (severityRank(instance.severity) < severityRank(finding.severity))
75
+ finding.severity = instance.severity;
76
+ if (instance.zone !== null)
77
+ entry.zones.add(instance.zone);
78
+ if (instance.account)
79
+ finding.account = true;
80
+ finding.recommendation ??= instance.recommendation;
81
+ }
82
+ const findings = [...groups.values()].map(({ finding, zones }) => ({ ...finding, zones: zones.size }));
83
+ return findings
84
+ .map((finding, index) => ({ finding, index }))
85
+ .sort((a, b) => statusRank(a.finding.status) - statusRank(b.finding.status)
86
+ || severityRank(a.finding.severity) - severityRank(b.finding.severity)
87
+ || Number(b.finding.account) - Number(a.finding.account)
88
+ || b.finding.zones - a.finding.zones
89
+ || a.index - b.index)
90
+ .map(({ finding }) => finding);
91
+ }
92
+ function checkInstances(checks) {
93
+ const instances = [];
94
+ for (const check of checks) {
95
+ const status = check.status === 'fail' || check.status === 'error' ? 'failed' : check.status === 'warning' ? 'warning' : null;
96
+ if (status === null)
97
+ continue;
98
+ const name = text(check.name, 120) ?? text(check.id, 120);
99
+ if (name === null)
100
+ continue;
101
+ const zone = text(check.zoneName) ?? text(check.zoneId);
102
+ instances.push({
103
+ key: typeof check.id === 'string' && check.id !== '' ? `id:${check.id}` : `name:${name}`,
104
+ name,
105
+ severity: text(check.severity, 12),
106
+ status,
107
+ zone,
108
+ account: check.scope === 'account' || zone === null,
109
+ recommendation: firstLine(check.recommendation),
110
+ });
111
+ }
112
+ return instances;
113
+ }
114
+ /** A legacy One report's issues (no status; severity `critical`, `warning` or `info`). */
115
+ function issueInstances(issues) {
116
+ const instances = [];
117
+ for (const issue of issues) {
118
+ const name = text(issue.title, 120);
119
+ if (name === null)
120
+ continue;
121
+ const zone = text(issue.zoneName) ?? text(issue.zoneId);
122
+ instances.push({
123
+ // Legacy issue IDs may carry the zone, so the same issue is grouped by its title.
124
+ key: `name:${name}`,
125
+ name,
126
+ severity: text(issue.severity, 12),
127
+ status: null,
128
+ zone,
129
+ account: zone === null,
130
+ recommendation: firstLine(issue.recommendation),
131
+ });
132
+ }
133
+ return instances;
134
+ }
135
+ function zoneRows(zones) {
136
+ const rows = [];
137
+ for (const zone of zones) {
138
+ const name = text(zone.name, 120) ?? text(zone.zoneName, 120);
139
+ if (name === null)
140
+ continue;
141
+ const summary = record(zone.summary);
142
+ const issues = Array.isArray(zone.issues) ? zone.issues.length : null;
143
+ rows.push({
144
+ name,
145
+ plan: text(zone.plan, 40) ?? text(record(zone.plan)?.name, 40),
146
+ status: text(zone.status, 20),
147
+ grade: text(zone.grade, 3),
148
+ score: score(zone.score),
149
+ passed: count(summary?.passed),
150
+ warnings: count(summary?.warnings),
151
+ failed: count(summary?.failed),
152
+ issues,
153
+ });
154
+ }
155
+ return rows;
156
+ }
157
+ /**
158
+ * The view of a checkup result (`CheckupResult`, its locked projection, or a
159
+ * legacy One report), or null when `value` is not an object. Malformed parts
160
+ * are left out rather than failing the whole view.
161
+ */
162
+ export function readCheckupReport(value) {
163
+ const result = record(value);
164
+ if (!result)
165
+ return null;
166
+ const account = record(result.account);
167
+ const checks = list(result.checks);
168
+ const isLegacy = !Array.isArray(result.checks) && Array.isArray(result.issues);
169
+ return {
170
+ accountName: text(account?.name, 120) ?? text(result.accountName, 120),
171
+ zoneCount: count(account?.zoneCount) ?? (isLegacy ? count(record(result.summary)?.totalZones) : null),
172
+ scannedZones: count(account?.scannedZones),
173
+ grade: text(result.grade, 3),
174
+ score: score(result.score),
175
+ summary: isLegacy ? null : counts(result.summary),
176
+ zones: zoneRows(list(result.zones)),
177
+ findings: group(isLegacy ? issueInstances(list(result.issues)) : checkInstances(checks)),
178
+ lockedPremiumCount: count(result.lockedPremiumCount) ?? 0,
179
+ shareableUrl: reportUrl(result.shareableUrl),
180
+ scannedAt: isoDate(result.scannedAt) ?? isoDate(result.generatedAt),
181
+ };
182
+ }
183
+ const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
184
+ export function joinParts(palette, parts) {
185
+ return parts.join(` ${palette.symbols.separator} `);
186
+ }
187
+ /** `account-wide`, `on 2 domains`, or '' (a legacy issue without a zone list). */
188
+ export function findingScope(finding) {
189
+ if (finding.account && finding.zones === 0)
190
+ return 'account-wide';
191
+ if (finding.zones > 0)
192
+ return `on ${plural(finding.zones, 'domain')}`;
193
+ return '';
194
+ }
195
+ /** A grade in the colour of its band, bold: A green, B and C yellow, D and F red (the server's letter, coloured). */
196
+ export function styleGrade(style, grade) {
197
+ const letter = grade.trim().charAt(0).toUpperCase();
198
+ const colour = letter === 'A' ? style.green : letter === 'B' || letter === 'C' ? style.yellow : letter === 'D' || letter === 'F' ? style.red : (value) => value;
199
+ return style.bold(colour(grade));
200
+ }
201
+ function styleSeverity(style, severity) {
202
+ switch (severity.toLowerCase()) {
203
+ case 'critical': return style.bold(style.red(severity));
204
+ case 'high': return style.red(severity);
205
+ case 'medium':
206
+ case 'warning': return style.yellow(severity);
207
+ case 'info': return style.dim(severity);
208
+ default: return severity;
209
+ }
210
+ }
211
+ /**
212
+ * Cells per segment for a stacked bar: proportional, summing to `width`, and
213
+ * at least one cell for every non-zero count while the width allows.
214
+ */
215
+ export function segmentWidths(values, width) {
216
+ const total = values.reduce((sum, value) => sum + Math.max(0, value), 0);
217
+ if (total <= 0 || width <= 0)
218
+ return values.map(() => 0);
219
+ const exact = values.map((value) => (Math.max(0, value) / total) * width);
220
+ const cells = exact.map(Math.floor);
221
+ values.forEach((value, index) => {
222
+ if (value > 0 && cells[index] === 0)
223
+ cells[index] = 1;
224
+ });
225
+ let left = width - cells.reduce((sum, cell) => sum + cell, 0);
226
+ const byRemainder = exact.map((value, index) => ({ index, remainder: value - Math.floor(value) })).sort((a, b) => b.remainder - a.remainder);
227
+ for (let i = 0; left > 0; i = (i + 1) % byRemainder.length) {
228
+ const index = byRemainder[i].index;
229
+ if (values[index] > 0) {
230
+ cells[index]++;
231
+ left--;
232
+ }
233
+ }
234
+ while (left < 0) {
235
+ const largest = cells.indexOf(Math.max(...cells));
236
+ cells[largest]--;
237
+ left++;
238
+ }
239
+ return cells;
240
+ }
241
+ /**
242
+ * `██████░░`: passed, then warnings and failed (yellow and red with colour),
243
+ * then checks with any other status. Without colour, everything but passed is
244
+ * the empty cell, so the bar still reads as the share that passed.
245
+ */
246
+ export function checksBar(palette, summary, width) {
247
+ const passed = summary.passed ?? 0;
248
+ const warnings = summary.warnings ?? 0;
249
+ const failed = summary.failed ?? 0;
250
+ const total = Math.max(summary.total ?? 0, passed + warnings + failed);
251
+ if (total === 0 || width < 4)
252
+ return null;
253
+ const { style, symbols } = palette;
254
+ const [p, w, f, rest] = segmentWidths([passed, warnings, failed, total - passed - warnings - failed], width);
255
+ return style.green(symbols.barFull.repeat(p))
256
+ + style.yellow(symbols.barFull.repeat(w))
257
+ + style.red(symbols.barFull.repeat(f))
258
+ + style.dim(symbols.barEmpty.repeat(rest));
259
+ }
260
+ /** Plain bar for output without colour: passed full, everything else empty. */
261
+ function monoBar(palette, summary, width) {
262
+ const passed = summary.passed ?? 0;
263
+ const total = Math.max(summary.total ?? 0, passed + (summary.warnings ?? 0) + (summary.failed ?? 0));
264
+ if (total === 0 || width < 4)
265
+ return null;
266
+ const [full, empty] = segmentWidths([passed, total - passed], width);
267
+ return palette.symbols.barFull.repeat(full) + palette.symbols.barEmpty.repeat(empty);
268
+ }
269
+ function bar(palette, summary, width) {
270
+ return palette.color ? checksBar(palette, summary, width) : monoBar(palette, summary, width);
271
+ }
272
+ /** Fit a styled line to the palette's width (plain output is never cut). */
273
+ function fit(palette, line) {
274
+ return palette.columns === null ? line : truncate(line, palette.columns - 1, palette.symbols.ellipsis);
275
+ }
276
+ /**
277
+ * Finding lines, aligned: `✗ critical Account MFA Enforcement account-wide`.
278
+ * `indent` prefixes every line; with `recommendations`, the first line of the
279
+ * recommendation follows each finding, dim, under its name.
280
+ */
281
+ export function findingLines(palette, findings, options) {
282
+ const { style, symbols } = palette;
283
+ const severityWidth = Math.max(0, ...findings.map((finding) => displayWidth(finding.severity ?? '')));
284
+ const scopes = findings.map(findingScope);
285
+ const scopeWidth = Math.max(0, ...scopes.map(displayWidth));
286
+ // indent, icon and space, severity and two spaces, then the name, two spaces and the scope.
287
+ const fixed = displayWidth(options.indent) + 2 + (severityWidth ? severityWidth + 2 : 0);
288
+ const longest = Math.max(0, ...findings.map((finding) => displayWidth(finding.name)));
289
+ const room = palette.columns === null ? longest : Math.max(12, palette.columns - 1 - fixed - (scopeWidth ? scopeWidth + 2 : 0));
290
+ const nameWidth = Math.min(longest, room);
291
+ const lines = [];
292
+ findings.forEach((finding, index) => {
293
+ const icon = finding.status === 'failed' ? style.red(symbols.cross) : finding.status === 'warning' ? style.yellow(symbols.warning) : style.dim(symbols.bullet);
294
+ const severity = severityWidth ? `${padEnd(styleSeverity(style, finding.severity ?? ''), severityWidth)} ` : '';
295
+ const name = truncate(finding.name, nameWidth, symbols.ellipsis);
296
+ const scope = scopes[index];
297
+ const line = scope ? `${padEnd(name, nameWidth)} ${style.dim(scope)}` : name;
298
+ lines.push(fit(palette, `${options.indent}${icon} ${severity}${line}`));
299
+ if (options.recommendations && finding.recommendation) {
300
+ lines.push(fit(palette, `${options.indent}${' '.repeat(fixed - displayWidth(options.indent))}${style.dim(finding.recommendation)}`));
301
+ }
302
+ });
303
+ return lines;
304
+ }
305
+ /** `+87 more checks in the full report` */
306
+ export function lockedTeaser(palette, locked) {
307
+ if (locked <= 0)
308
+ return null;
309
+ return `${palette.style.brand(`+${locked}`)} more ${locked === 1 ? 'check' : 'checks'} in the full report`;
310
+ }
311
+ /** Findings shown at the end of a local checkup. */
312
+ export const LOCAL_FINDINGS_SHOWN = CLI_CHECKUP_LIMITS.card;
313
+ /**
314
+ * `… 2 more findings on the results page`: the findings the card leaves out
315
+ * are on the results page (the link printed with the card). Running again
316
+ * with --json would collect and upload again, so it is not suggested.
317
+ */
318
+ function moreLocalFindings(hidden) {
319
+ return `${plural(hidden, 'more finding')} on the results page`;
320
+ }
321
+ /**
322
+ * The result card of the terminal UI, under `└ ✓ Done. …`: the free checks as
323
+ * a bar, the top findings (failed, then warnings; names and zone counts, never
324
+ * zone names) and how many premium checks the full report adds. Lines are
325
+ * indented to the end of the timeline and fit the width.
326
+ */
327
+ export function localResultCard(palette, view) {
328
+ const { style, symbols } = palette;
329
+ const indent = ' ';
330
+ const lines = [];
331
+ const summary = view.summary;
332
+ if (summary) {
333
+ const passed = summary.passed ?? 0;
334
+ const total = Math.max(summary.total ?? 0, passed + (summary.warnings ?? 0) + (summary.failed ?? 0));
335
+ const legend = total > 0 ? `${passed} of ${plural(total, 'free check')} passed` : '';
336
+ const width = Math.min(32, (palette.columns ?? 80) - 1 - indent.length - 2 - displayWidth(legend));
337
+ const drawn = legend ? bar(palette, summary, width) : null;
338
+ if (drawn)
339
+ lines.push(fit(palette, `${indent}${drawn} ${style.dim(legend)}`));
340
+ }
341
+ const shown = view.findings.slice(0, LOCAL_FINDINGS_SHOWN);
342
+ const teaser = lockedTeaser(palette, view.lockedPremiumCount);
343
+ if (shown.length || teaser)
344
+ lines.push('');
345
+ if (shown.length) {
346
+ lines.push(`${indent}${style.bold('Top findings')}`);
347
+ lines.push(...findingLines(palette, shown, { indent }));
348
+ const hidden = view.findings.length - shown.length;
349
+ if (hidden > 0)
350
+ lines.push(fit(palette, `${indent}${style.dim(`${symbols.ellipsis} ${moreLocalFindings(hidden)}`)}`));
351
+ }
352
+ if (teaser)
353
+ lines.push(fit(palette, `${indent}${teaser}`));
354
+ return lines;
355
+ }
356
+ /**
357
+ * The same, as plain lines appended after the plain result lines:
358
+ * `Top findings:`, one indented line each, then the premium teaser. Empty when
359
+ * there is nothing to add.
360
+ */
361
+ export function localResultPlainLines(view, palette) {
362
+ const lines = [];
363
+ const shown = view.findings.slice(0, LOCAL_FINDINGS_SHOWN);
364
+ if (shown.length) {
365
+ lines.push('Top findings:');
366
+ const severityWidth = Math.max(0, ...shown.map((finding) => displayWidth(finding.severity ?? '')));
367
+ for (const finding of shown) {
368
+ const icon = finding.status === 'failed' ? palette.symbols.cross : finding.status === 'warning' ? palette.symbols.warning : palette.symbols.bullet;
369
+ const scope = findingScope(finding);
370
+ lines.push(` ${icon} ${severityWidth ? `${padEnd(finding.severity ?? '', severityWidth)} ` : ''}${finding.name}${scope ? ` ${palette.symbols.separator} ${scope}` : ''}`);
371
+ }
372
+ const hidden = view.findings.length - shown.length;
373
+ if (hidden > 0)
374
+ lines.push(` ${palette.symbols.ellipsis} ${moreLocalFindings(hidden)}`);
375
+ }
376
+ const teaser = lockedTeaser(palette, view.lockedPremiumCount);
377
+ if (teaser)
378
+ lines.push(teaser);
379
+ return lines;
380
+ }
381
+ // ============================================================================
382
+ // cf checkup result / status
383
+ // ============================================================================
384
+ /** Priorities shown by `cf checkup result` and `cf checkup`. */
385
+ export const PRIORITIES_SHOWN = CLI_CHECKUP_LIMITS.priorities;
386
+ /** Domain rows shown before `… N more domains`. */
387
+ export const ZONES_SHOWN = CLI_CHECKUP_LIMITS.domains;
388
+ /**
389
+ * `… 3 more domains in the full report (--json lists them all)`: where to see
390
+ * what a list leaves out. The full report is the link printed last, when the
391
+ * result has one; --json returns every check and field.
392
+ */
393
+ function moreHint(view, hidden, noun) {
394
+ return `${plural(hidden, `more ${noun}`)}${view.shareableUrl ? ' in the full report' : ''} (--json lists them all)`;
395
+ }
396
+ /** `2026-10-08 12:00 UTC` */
397
+ function formatUtc(iso) {
398
+ return `${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC`;
399
+ }
400
+ /** `checks ████░░ 48 passed · 15 warnings · 3 failed · 16 skipped · 109 total` (the bar only on a terminal). */
401
+ function countsLine(palette, summary, key) {
402
+ const { style } = palette;
403
+ const parts = [
404
+ summary.passed !== null ? style.green(`${summary.passed} passed`) : null,
405
+ summary.warnings !== null ? (summary.warnings > 0 ? style.yellow : style.dim)(plural(summary.warnings, 'warning')) : null,
406
+ summary.failed !== null ? (summary.failed > 0 ? style.red : style.dim)(`${summary.failed} failed`) : null,
407
+ summary.skipped ? style.dim(`${summary.skipped} skipped`) : null,
408
+ summary.total !== null ? style.dim(`${summary.total} total`) : null,
409
+ ].filter((part) => part !== null);
410
+ const text = joinParts(palette, parts);
411
+ // The bar only on a terminal, and only where it leaves room for the counts.
412
+ const width = palette.columns === null ? 0 : Math.min(24, palette.columns - 1 - displayWidth(key('checks')) - 2 - displayWidth(text));
413
+ const drawn = width >= 8 ? bar(palette, summary, width) : null;
414
+ return fit(palette, `${key('checks')}${drawn ? `${drawn} ` : ''}${text}`);
415
+ }
416
+ function zoneTable(palette, view) {
417
+ const { style, symbols } = palette;
418
+ const zones = view.zones;
419
+ // Worst first when the server scored every zone; otherwise the server's order.
420
+ const rows = zones.every((zone) => zone.score !== null)
421
+ ? zones.map((zone, index) => ({ zone, index })).sort((a, b) => a.zone.score - b.zone.score || a.index - b.index).map(({ zone }) => zone)
422
+ : [...zones];
423
+ const shown = rows.slice(0, ZONES_SHOWN);
424
+ const legacy = shown.every((zone) => zone.passed === null && zone.failed === null && zone.warnings === null);
425
+ const plans = shown.map((zone) => [zone.plan, zone.status && zone.status !== 'active' ? zone.status : null].filter(Boolean).join(` ${symbols.separator} `));
426
+ const grades = shown.map((zone) => [zone.grade, zone.score !== null ? String(zone.score) : null].filter(Boolean).join(' '));
427
+ const headers = legacy ? ['Domain', 'Plan', 'Issues'] : ['Domain', 'Plan', 'Grade', 'Failed', 'Warnings', 'Passed'];
428
+ const hasGrade = grades.some(Boolean);
429
+ const planWidth = Math.max(displayWidth(headers[1]), ...plans.map(displayWidth));
430
+ const gradeWidth = hasGrade ? Math.max(5, ...grades.map(displayWidth)) : 0;
431
+ const numbers = legacy ? ['Issues'] : ['Failed', 'Warnings', 'Passed'];
432
+ const numberWidths = numbers.map((header) => displayWidth(header));
433
+ const longest = Math.max(displayWidth('Domain'), ...shown.map((zone) => displayWidth(zone.name)));
434
+ const fixedWidth = (gradeWidth ? gradeWidth + 2 : 0) + numberWidths.reduce((sum, width) => sum + width + 2, 0);
435
+ // The plan is the first column to go on a narrow terminal.
436
+ const hasPlan = plans.some(Boolean) && (palette.columns === null || 2 + longest + planWidth + 2 + fixedWidth <= palette.columns - 1);
437
+ const tailWidth = (hasPlan ? planWidth + 2 : 0) + fixedWidth;
438
+ const nameWidth = palette.columns === null ? longest : Math.min(longest, Math.max(12, palette.columns - 1 - 2 - tailWidth));
439
+ const number = (value, width, colour) => value === null ? ' '.repeat(width - 1) + style.dim('-') : (value > 0 ? colour : style.dim)(String(value).padStart(width));
440
+ const head = [
441
+ padEnd('Domain', nameWidth),
442
+ ...(hasPlan ? [padEnd('Plan', planWidth)] : []),
443
+ ...(gradeWidth ? [padEnd('Grade', gradeWidth)] : []),
444
+ ...numbers.map((header, index) => header.padStart(numberWidths[index])),
445
+ ].join(' ');
446
+ const lines = [fit(palette, ` ${style.dim(head)}`)];
447
+ shown.forEach((zone, index) => {
448
+ const cells = [
449
+ padEnd(truncate(zone.name, nameWidth, symbols.ellipsis), nameWidth),
450
+ ...(hasPlan ? [style.dim(padEnd(truncate(plans[index], planWidth, symbols.ellipsis), planWidth))] : []),
451
+ ...(gradeWidth ? [zone.grade ? `${styleGrade(style, zone.grade)}${padEnd(zone.score !== null ? ` ${zone.score}` : '', gradeWidth - displayWidth(zone.grade))}` : padEnd(grades[index], gradeWidth)] : []),
452
+ ...(legacy
453
+ ? [number(zone.issues, numberWidths[0], style.yellow)]
454
+ : [number(zone.failed, numberWidths[0], style.red), number(zone.warnings, numberWidths[1], style.yellow), number(zone.passed, numberWidths[2], style.green)]),
455
+ ];
456
+ lines.push(fit(palette, ` ${cells.join(' ')}`.trimEnd()));
457
+ });
458
+ if (rows.length > shown.length)
459
+ lines.push(fit(palette, ` ${style.dim(`${symbols.ellipsis} ${moreHint(view, rows.length - shown.length, 'domain')}`)}`));
460
+ return lines;
461
+ }
462
+ /**
463
+ * The body of `cf checkup result` and `cf checkup` in text mode, after their
464
+ * `checkup` and `grade` lines: the account, the counts (a bar on a terminal),
465
+ * the domains, the top priorities with the first line of each recommendation,
466
+ * the premium teaser of a locked result and the full report link.
467
+ */
468
+ export function checkupReportLines(palette, view) {
469
+ const { style } = palette;
470
+ const key = (name) => name.padEnd(11);
471
+ const lines = [];
472
+ const accountParts = [
473
+ view.accountName,
474
+ view.zoneCount !== null
475
+ ? (view.scannedZones !== null && view.scannedZones !== view.zoneCount ? `${view.scannedZones} of ${plural(view.zoneCount, 'domain')} scanned` : plural(view.zoneCount, 'domain'))
476
+ : view.zones.length ? plural(view.zones.length, 'domain') : null,
477
+ ].filter((part) => part !== null);
478
+ if (accountParts.length)
479
+ lines.push(fit(palette, `${key('account')}${joinParts(palette, accountParts)}`));
480
+ if (view.scannedAt)
481
+ lines.push(`${key('scanned')}${formatUtc(view.scannedAt)}`);
482
+ if (view.summary)
483
+ lines.push(countsLine(palette, view.summary, key));
484
+ if (view.zones.length) {
485
+ lines.push('', style.bold('Domains'), ...zoneTable(palette, view));
486
+ }
487
+ if (view.findings.length) {
488
+ const shown = view.findings.slice(0, PRIORITIES_SHOWN);
489
+ lines.push('', style.bold('Top priorities'), ...findingLines(palette, shown, { indent: ' ', recommendations: true }));
490
+ const hidden = view.findings.length - shown.length;
491
+ if (hidden > 0)
492
+ lines.push(fit(palette, ` ${style.dim(`${palette.symbols.ellipsis} ${moreHint(view, hidden, 'finding')}`)}`));
493
+ }
494
+ else if (view.summary && (view.summary.failed ?? 0) === 0 && (view.summary.warnings ?? 0) === 0) {
495
+ lines.push('', `${style.green(palette.symbols.check)} No failed checks or warnings.`);
496
+ }
497
+ const teaser = lockedTeaser(palette, view.lockedPremiumCount);
498
+ if (teaser)
499
+ lines.push('', teaser);
500
+ if (view.shareableUrl)
501
+ lines.push('', `Full report: ${style.underline(view.shareableUrl)}`);
502
+ return lines;
503
+ }
@@ -11,6 +11,8 @@ export declare function sanitizeTerminal(value: string): string;
11
11
  export declare function singleLine(value: string, maxLength?: number): string;
12
12
  export declare function createWriter(stdout?: NodeJS.WritableStream, stderr?: NodeJS.WritableStream): Writer;
13
13
  export declare function printJson(writer: Writer, value: unknown): void;
14
+ /** The shareable report link, only when it is a plain https://vigilbase.com URL. */
15
+ export declare function reportUrl(value: unknown): string | null;
14
16
  export declare function formatToolsList(catalog: AgentToolsCatalogResponse, mode: CliOutputMode, writer: Writer, options?: {
15
17
  authRequiredTools?: readonly string[];
16
18
  /** @deprecated Use authRequiredTools */
@@ -203,7 +203,7 @@ function collectFindings(result) {
203
203
  }
204
204
  const MAX_FINDING_LINES = 12;
205
205
  /** The shareable report link, only when it is a plain https://vigilbase.com URL. */
206
- function reportUrl(value) {
206
+ export function reportUrl(value) {
207
207
  if (typeof value !== 'string')
208
208
  return null;
209
209
  let url;
@@ -7,6 +7,7 @@
7
7
  export declare const STAFF_CUSTOMER_SCOPE_HINT = "Vigilbase staff: add --customer-id <uuid>.";
8
8
  export declare const PERSONAL_SETTINGS_HINT = "Personal settings belong to each user, so View-as (--customer-id) cannot read them.";
9
9
  export declare const BROWSER_SESSION_HINT = "Invitations need a One browser session: manage them at https://one.vigilbase.com.";
10
+ export declare const VIGIL_UI_DISABLED_HINT = "Vigil investigations aren't enabled in Vigilbase One.";
10
11
  export type OneErrorContext = {
11
12
  method: string;
12
13
  /** The One path the CLI requested, with its query string. */
@@ -7,6 +7,7 @@
7
7
  export const STAFF_CUSTOMER_SCOPE_HINT = 'Vigilbase staff: add --customer-id <uuid>.';
8
8
  export const PERSONAL_SETTINGS_HINT = 'Personal settings belong to each user, so View-as (--customer-id) cannot read them.';
9
9
  export const BROWSER_SESSION_HINT = 'Invitations need a One browser session: manage them at https://one.vigilbase.com.';
10
+ export const VIGIL_UI_DISABLED_HINT = "Vigil investigations aren't enabled in Vigilbase One.";
10
11
  /** One's security boundary codes that already explain a 403 (apps/one/src/lib/flarehawk/access.ts). */
11
12
  const SECURITY_ACCESS_CODES = new Set([
12
13
  'FLAREHAWK_CUSTOMER_ACCESS_DENIED',
@@ -33,6 +34,15 @@ const RULES = [
33
34
  // Identity's management API accepts only One browser sessions, so CLI tokens can't read or send invitations.
34
35
  { method: 'GET', route: /^\/api\/auth\/orgs\/invitations$/u, status: 403, hasCustomerScope: false, hint: BROWSER_SESSION_HINT, code: 'browser_session_required' },
35
36
  { method: 'POST', route: /^\/api\/auth\/orgs\/invite$/u, status: 403, hasCustomerScope: false, hint: BROWSER_SESSION_HINT, code: 'browser_session_required' },
37
+ // One answers authorized staff 501 VIGIL_UI_DISABLED while the investigation UI ships dark.
38
+ {
39
+ method: 'GET',
40
+ route: /^\/api\/customers\/[0-9a-f-]{36}\/vigil\/investigations(?:\/[0-9a-f-]{36})?$/iu,
41
+ status: 501,
42
+ hasCustomerScope: false,
43
+ hint: VIGIL_UI_DISABLED_HINT,
44
+ code: 'VIGIL_UI_DISABLED',
45
+ },
36
46
  ];
37
47
  export function oneErrorHint(context) {
38
48
  const [pathname = '', query = ''] = context.path.split('?', 2);
package/dist/lib/tui.d.ts CHANGED
@@ -40,6 +40,10 @@ export type Symbols = {
40
40
  warning: string;
41
41
  error: string;
42
42
  check: string;
43
+ /** A failed check in a result list. */
44
+ cross: string;
45
+ /** An item without a status in a result list. */
46
+ bullet: string;
43
47
  radioOn: string;
44
48
  radioOff: string;
45
49
  pointer: string;
package/dist/lib/tui.js CHANGED
@@ -59,12 +59,12 @@ export function supportsHyperlinks(env) {
59
59
  return Number(env.KONSOLE_VERSION) >= 201200;
60
60
  }
61
61
  export const UNICODE_SYMBOLS = {
62
- active: '◆', done: '◇', rail: '│', end: '└', warning: '▲', error: '■', check: '✓',
62
+ active: '◆', done: '◇', rail: '│', end: '└', warning: '▲', error: '■', check: '✓', cross: '✗', bullet: '•',
63
63
  radioOn: '●', radioOff: '○', pointer: '›', ellipsis: '…', separator: '·', mask: '●', placeholder: '·',
64
64
  barFull: '█', barEmpty: '░', up: '↑', down: '↓', spinner: ['◒', '◐', '◓', '◑'],
65
65
  };
66
66
  export const ASCII_SYMBOLS = {
67
- active: '*', done: 'o', rail: '|', end: '+', warning: '!', error: 'x', check: 'v',
67
+ active: '*', done: 'o', rail: '|', end: '+', warning: '!', error: 'x', check: 'v', cross: 'x', bullet: '-',
68
68
  radioOn: '>', radioOff: ' ', pointer: '>', ellipsis: '...', separator: '-', mask: '*', placeholder: '.',
69
69
  barFull: '#', barEmpty: '-', up: '^', down: 'v', spinner: ['|', '/', '-', '\\'],
70
70
  };
@@ -34,7 +34,7 @@ export const TEMPLATES = deepFreeze({
34
34
  account: { args: ['accounts', 'get'], path: '/accounts/{A}', paging: { kind: 'single' }, output: 'object', scope: 'account' },
35
35
  zones: { args: ['zones', 'list', '--account-id', '{A}', '--per-page', '50', '--page', '{N}'], path: '/zones', query: { 'account.id': '{A}', page: '{N}', per_page: 50 }, paging: pages(50), output: 'array', scope: 'account' },
36
36
  account_members: { args: ['accounts', 'members', 'list', '--per-page', '50', '--page', '{N}'], path: '/accounts/{A}/members', query: { page: '{N}', per_page: 50 }, paging: pages(50), output: 'array', scope: 'account' },
37
- account_rulesets: { args: ['rulesets', 'list', '--per-page', '100'], path: '/accounts/{A}/rulesets', query: { per_page: 100 }, paging: { kind: 'one_call', perPage: 100 }, output: 'array', scope: 'account' },
37
+ account_rulesets: { args: ['rulesets', 'list', '--per-page', '50'], path: '/accounts/{A}/rulesets', query: { per_page: 50 }, paging: { kind: 'one_call', perPage: 50 }, output: 'array', scope: 'account' },
38
38
  account_access_rules: { args: ['firewall', 'access-rules', 'list', '--per-page', '50', '--page', '{N}'], path: '/accounts/{A}/firewall/access_rules/rules', query: { page: '{N}', per_page: 50 }, paging: pages(50), output: 'array', scope: 'account' },
39
39
  access_apps: { args: ['zero-trust', 'access', 'applications', 'list', '--per-page', '50', '--page', '{N}'], path: '/accounts/{A}/access/apps', query: { page: '{N}', per_page: 50 }, paging: pages(50), output: 'array', scope: 'account' },
40
40
  access_identity_providers: { args: ['zero-trust', 'identity-providers', 'list', '--per-page', '50', '--page', '{N}'], path: '/accounts/{A}/access/identity_providers', query: { page: '{N}', per_page: 50 }, paging: pages(50), output: 'array', scope: 'account' },
@@ -48,7 +48,10 @@ export const TEMPLATES = deepFreeze({
48
48
  zone_dns_records: { args: ['dns', 'records', 'list', '--zone', '{Z}', '--per-page', '100', '--page', '{N}'], path: '/zones/{Z}/dns_records', query: { page: '{N}', per_page: 100 }, paging: pages(100, 100), output: 'array', scope: 'zone' },
49
49
  zone_universal_ssl: { args: ['ssl', 'universal', 'settings', 'get', '--zone', '{Z}'], path: '/zones/{Z}/ssl/universal/settings', paging: { kind: 'single' }, output: 'object', scope: 'zone' },
50
50
  zone_total_tls: { args: ['acm', 'total-tls', 'get', '--zone', '{Z}'], path: '/zones/{Z}/acm/total_tls', paging: { kind: 'single' }, output: 'object', scope: 'zone' },
51
- zone_rulesets: { args: ['rulesets', 'list', '--zone', '{Z}', '--per-page', '100'], path: '/zones/{Z}/rulesets', query: { per_page: 100 }, paging: { kind: 'one_call', perPage: 100 }, output: 'array', scope: 'zone' },
51
+ zone_certificate_packs: { args: ['ssl', 'certificate-packs', 'list', '--zone', '{Z}', '--status', 'all', '--per-page', '50', '--page', '{N}'], path: '/zones/{Z}/ssl/certificate_packs', query: { status: 'all', per_page: 50, page: '{N}' }, paging: pages(50, 100), output: 'array', scope: 'zone' },
52
+ zone_custom_certificates: { args: ['custom-certificates', 'list', '--zone', '{Z}', '--per-page', '50', '--page', '{N}'], path: '/zones/{Z}/custom_certificates', query: { per_page: 50, page: '{N}' }, paging: pages(50, 100), output: 'array', scope: 'zone' },
53
+ zone_api_operations: { args: ['web-assets', 'operations', 'list', '--zone', '{Z}', '--feature', 'schema_info', '--per-page', '50', '--page', '{N}'], path: '/zones/{Z}/api_gateway/operations', query: { feature: 'schema_info', per_page: 50, page: '{N}' }, paging: pages(50, 100), output: 'array', scope: 'zone' },
54
+ zone_rulesets: { args: ['rulesets', 'list', '--zone', '{Z}', '--per-page', '50'], path: '/zones/{Z}/rulesets', query: { per_page: 50 }, paging: { kind: 'one_call', perPage: 50 }, output: 'array', scope: 'zone' },
52
55
  zone_ruleset: { args: ['rulesets', 'get', '{R}', '--zone', '{Z}'], path: '/zones/{Z}/rulesets/{R}', paging: { kind: 'single' }, output: 'object', scope: 'ruleset' },
53
56
  zone_access_rules: { args: ['firewall', 'access-rules', 'list', '--zone', '{Z}', '--per-page', '100', '--page', '{N}'], path: '/zones/{Z}/firewall/access_rules/rules', query: { page: '{N}', per_page: 100 }, paging: pages(100), output: 'array', scope: 'zone' },
54
57
  zone_page_rules: { args: ['page-rules', 'list', '--zone', '{Z}'], path: '/zones/{Z}/pagerules', query: {}, paging: { kind: 'single' }, output: 'array', scope: 'zone' },
@@ -7,7 +7,7 @@
7
7
  * Lists are paged with `--page N` until an empty page, because cf strips
8
8
  * `result_info`: a final page and a server-clamped page look the same.
9
9
  */
10
- import { ACCOUNT_KINDS, CloudflareCheckupSnapshotV1Schema, NOT_COLLECTABLE_ZONE_KINDS, NOT_COLLECTED_RESULT, PROJECTORS, SNAPSHOT_LIMITS, ZONE_SETTING_IDS, ZONE_SINGLE_KINDS, assembleSnapshot, collectionFailedResult, httpErrorResult, projectAccount, projectZones, projectionToResult, serializeSnapshot, } from './contract.js';
10
+ import { ACCOUNT_KINDS, CloudflareCheckupSnapshotV1Schema, NOT_COLLECTABLE_ZONE_KINDS, NOT_COLLECTED_RESULT, OPTIONAL_ZONE_KINDS, PROJECTORS, SNAPSHOT_LIMITS, ZONE_SETTING_IDS, ZONE_SINGLE_KINDS, assembleSnapshot, collectionFailedResult, httpErrorResult, projectAccount, projectZones, projectionToResult, serializeSnapshot, } from './contract.js';
11
11
  import { ENV_TOKEN_PERMISSION_HINT, LocalCheckupError } from './errors.js';
12
12
  import { snapshotParseError } from './snapshot-file.js';
13
13
  import { TEMPLATES, buildArgv } from './cf/allowlist.js';
@@ -113,7 +113,7 @@ export async function listAccounts(env) {
113
113
  }
114
114
  /** Requests for N zones: about 19 + extra pages + N × (33 + rulesets per zone). */
115
115
  export function estimateRequests(zoneCount, rulesetsPerZone = ESTIMATED_RULESETS_PER_ZONE) {
116
- return 19 + zoneCount * (33 + rulesetsPerZone);
116
+ return 19 + zoneCount * (33 + OPTIONAL_ZONE_KINDS.length * 2 + rulesetsPerZone);
117
117
  }
118
118
  /** A worker pool whose tasks may enqueue more tasks (ruleset details). */
119
119
  async function runTasks(seed, concurrency, onError) {
@@ -219,7 +219,7 @@ export async function collectAccount(env, accountId, hooks = {}) {
219
219
  report();
220
220
  };
221
221
  const projectInto = async (kind, taskIds) => {
222
- const outcome = await fetchRaw(env, kind, taskIds);
222
+ const outcome = await fetchRaw(env, kind, taskIds, { maxItems: SNAPSHOT_LIMITS.maxListItems });
223
223
  if (!outcome.ok)
224
224
  return outcome.result;
225
225
  const project = PROJECTORS[kind];
@@ -236,14 +236,14 @@ export async function collectAccount(env, accountId, hooks = {}) {
236
236
  }
237
237
  for (const zone of zones) {
238
238
  const zoneIds = { accountId, zoneId: zone.id };
239
- zoneTasksLeft.set(zone.id, ZONE_SETTING_IDS.length + ZONE_SINGLE_KINDS.length);
239
+ zoneTasksLeft.set(zone.id, ZONE_SETTING_IDS.length + ZONE_SINGLE_KINDS.length + OPTIONAL_ZONE_KINDS.length);
240
240
  for (const settingId of ZONE_SETTING_IDS) {
241
241
  enqueue(async () => {
242
242
  entries.push({ kind: 'zone_setting', zoneId: zone.id, settingId, result: await projectInto('zone_setting', { ...zoneIds, settingId }) });
243
243
  finishZoneTask(zone.id);
244
244
  });
245
245
  }
246
- for (const kind of ZONE_SINGLE_KINDS) {
246
+ for (const kind of [...ZONE_SINGLE_KINDS, ...OPTIONAL_ZONE_KINDS]) {
247
247
  enqueue(async (more) => {
248
248
  const result = await projectInto(kind, zoneIds);
249
249
  entries.push({ kind, zoneId: zone.id, result });