@velaro/cli 1.4.102 → 1.4.104

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 (50) hide show
  1. package/README.md +19 -0
  2. package/bin/velaro.js +14 -0
  3. package/lib/api.js +2 -1
  4. package/lib/apple-pay-presets.js +66 -0
  5. package/lib/apple-pay-presets.test.js +61 -0
  6. package/lib/apple-pay.js +32 -0
  7. package/lib/apple-pay.test.js +32 -0
  8. package/lib/avatar-switch.js +42 -0
  9. package/lib/avatar-switch.test.js +49 -0
  10. package/lib/build-info.js +28 -0
  11. package/lib/build-info.test.js +24 -0
  12. package/lib/commands/apple-pay-preset.js +65 -0
  13. package/lib/commands/apple-pay.js +73 -0
  14. package/lib/commands/avatar.js +60 -2
  15. package/lib/commands/billing-automation.js +184 -0
  16. package/lib/commands/copilotstudio.js +14 -1
  17. package/lib/commands/enterprise-reports.js +1 -0
  18. package/lib/commands/entitlement.js +79 -1
  19. package/lib/commands/env.js +1 -1
  20. package/lib/commands/focus.js +1 -1
  21. package/lib/commands/index.js +38 -2
  22. package/lib/commands/logs-insights.js +342 -0
  23. package/lib/commands/logs.js +310 -11
  24. package/lib/commands/magento.js +16 -0
  25. package/lib/commands/mcp-key.js +7 -1
  26. package/lib/commands/netsuite.js +67 -2
  27. package/lib/commands/notifications.js +34 -1
  28. package/lib/commands/ops.js +23 -54
  29. package/lib/commands/report-backfill.js +23 -5
  30. package/lib/commands/report.js +77 -14
  31. package/lib/commands/salesforce.js +101 -0
  32. package/lib/commands/shopify.js +84 -0
  33. package/lib/commands/signature.js +79 -0
  34. package/lib/commands/site-builder.js +30 -0
  35. package/lib/commands/support.js +22 -0
  36. package/lib/commands/teams.js +11 -1
  37. package/lib/commands/workflow.js +157 -2
  38. package/lib/commands/zoom-phone.js +15 -0
  39. package/lib/enterprise-reports.test.js +1 -1
  40. package/lib/entitlement-report-packages.test.js +47 -0
  41. package/lib/logs-insights.test.js +146 -0
  42. package/lib/mcp-key.test.js +11 -0
  43. package/lib/messaging-routes.test.js +10 -1
  44. package/lib/oauth.js +14 -2
  45. package/lib/report-range.js +21 -0
  46. package/lib/report-range.test.js +38 -0
  47. package/lib/shopify-write-policy.test.js +34 -0
  48. package/lib/signature-policy.js +70 -0
  49. package/lib/signature-policy.test.js +48 -0
  50. package/package.json +1 -1
@@ -0,0 +1,342 @@
1
+ // `velaro logs insights <groups|new|regression|trace|anomalies|sites|mutes|mute|unmute|sensitive|brief>`
2
+ //
3
+ // CLI parity for the Velaro Logger Insights UI (client/src/pages/Admin/VelaroLoggerInsights/).
4
+ // All routes live on the MESSAGING api (superadmin/logs/insights/*), so every call goes through the
5
+ // messaging helper, never the admin get/post/del helpers (guarded by messaging-routes.test.js).
6
+ // Staff only: the server enforces it; requireStaff() just fails fast with a clear message.
7
+
8
+ import { getCredentials, messagingRequest } from '../api.js';
9
+
10
+ const RESET = '\x1b[0m';
11
+ const DIM = '\x1b[2m';
12
+ const RED = '\x1b[31m';
13
+ const YEL = '\x1b[33m';
14
+ const CYN = '\x1b[36m';
15
+ const BOLD = '\x1b[1m';
16
+
17
+ export const INSIGHTS_BASE = 'superadmin/logs/insights';
18
+ export const INSIGHTS_META = 'superadmin/logs/meta/insights';
19
+
20
+ // ── pure helpers (unit-tested in logs-insights.test.js) ───────────────────────────────────────
21
+
22
+ export function parseWindowMs(s, fallbackMs = 24 * 3600 * 1000) {
23
+ // Missing -> fallback. Present but malformed -> throw: silently widening a typo like "6hrs" to the
24
+ // default window would return data for the wrong period.
25
+ if (s === undefined || s === null || String(s).trim() === '') return fallbackMs;
26
+ const m = /^(\d+)\s*(m|h|d)$/i.exec(String(s).trim());
27
+ if (!m) throw new Error(`Invalid duration ${JSON.stringify(s)}: use a number plus m, h or d (e.g. 30m, 6h).`);
28
+ const n = parseInt(m[1], 10);
29
+ const unit = m[2].toLowerCase();
30
+ return n * (unit === 'm' ? 60_000 : unit === 'h' ? 3_600_000 : 86_400_000);
31
+ }
32
+
33
+ /** Accepts an ISO timestamp or a relative "2h" / "30m" / "1d" (meaning that long ago). Returns a Date or null. */
34
+ export function parseWhen(s, now = new Date()) {
35
+ if (!s) return null;
36
+ const rel = /^(\d+)\s*(m|h|d)$/i.exec(String(s).trim());
37
+ if (rel) return new Date(now.getTime() - parseWindowMs(s));
38
+ const d = new Date(String(s));
39
+ return Number.isNaN(d.getTime()) ? null : d;
40
+ }
41
+
42
+ /** Query string from a plain object, skipping undefined/null/empty. */
43
+ export function qs(params) {
44
+ const sp = new URLSearchParams();
45
+ for (const [k, v] of Object.entries(params)) {
46
+ if (v === undefined || v === null || v === '') continue;
47
+ sp.set(k, String(v));
48
+ }
49
+ const s = sp.toString();
50
+ return s ? `?${s}` : '';
51
+ }
52
+
53
+ /** Params shared by every insights call, from yargs argv. */
54
+ export function commonParams(argv) {
55
+ return {
56
+ level: argv.level ? String(argv.level).toUpperCase() : undefined,
57
+ platform: argv.platform,
58
+ environment: argv.environment,
59
+ siteId: argv.site,
60
+ };
61
+ }
62
+
63
+ export function windowParams(argv, defaultWindow = '24h', now = new Date()) {
64
+ const to = now;
65
+ const from = new Date(to.getTime() - parseWindowMs(argv.last, parseWindowMs(defaultWindow)));
66
+ return { from: from.toISOString(), to: to.toISOString() };
67
+ }
68
+
69
+ /** "mute --expires" -> ISO or undefined (never). Throws on junk so the CLI never silently mutes forever. */
70
+ export function parseExpiry(s, now = new Date()) {
71
+ if (s === undefined || s === null || s === '' || String(s).toLowerCase() === 'never') return undefined;
72
+ const d = parseWhen(s, now);
73
+ if (!d) throw new Error(`--expires must be "never", a duration like 7d / 12h, or an ISO date (got ${JSON.stringify(s)}).`);
74
+ // parseWhen treats durations as "ago"; for expiry we want "from now".
75
+ if (/^(\d+)\s*(m|h|d)$/i.test(String(s).trim())) return new Date(now.getTime() + parseWindowMs(s)).toISOString();
76
+ return d.toISOString();
77
+ }
78
+
79
+ export function ptTime(iso) {
80
+ if (!iso) return '-';
81
+ const d = new Date(iso);
82
+ if (Number.isNaN(d.getTime())) return '-';
83
+ return `${d.toLocaleString('en-US', { timeZone: 'America/Los_Angeles', dateStyle: 'short', timeStyle: 'short' })} PT`;
84
+ }
85
+
86
+ export function trendText(pct) {
87
+ if (pct === null || pct === undefined || Number.isNaN(pct)) return 'n/a';
88
+ const r = Math.round(pct * 10) / 10;
89
+ if (Math.abs(r) < 1) return 'flat';
90
+ return r > 0 ? `up ${r}%` : `down ${Math.abs(r)}%`;
91
+ }
92
+
93
+ const BLOCKS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
94
+ export function sparkline(values) {
95
+ if (!Array.isArray(values) || values.length === 0) return '';
96
+ const max = Math.max(...values, 1);
97
+ return values.map(v => BLOCKS[Math.min(BLOCKS.length - 1, Math.floor((Math.max(v, 0) / max) * (BLOCKS.length - 1)))]).join('');
98
+ }
99
+
100
+ // Strips control characters so log content can never inject terminal escape sequences.
101
+ export function clean(s) {
102
+ // eslint-disable-next-line no-control-regex
103
+ return String(s ?? '').replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
104
+ }
105
+
106
+ function trunc(s, n) {
107
+ const t = clean(s).replace(/\s+/g, ' ');
108
+ return t.length > n ? `${t.slice(0, n - 1)}…` : t;
109
+ }
110
+
111
+ export function formatGroups(resp) {
112
+ const lines = [];
113
+ if (resp.servedFrom) lines.push(`${DIM}Showing ${resp.servedFrom} logs (served via ${String(resp.servedFrom).toLowerCase()} logger)${RESET}`);
114
+ if (resp.truncated) lines.push(`${YEL}Results capped${resp.scanned ? ` after scanning ${resp.scanned} rows` : ''}; narrow the window for complete numbers.${RESET}`);
115
+ const groups = resp.groups ?? [];
116
+ if (!groups.length) { lines.push('No error groups.'); return lines.join('\n'); }
117
+ for (const g of groups) {
118
+ const flags = `${g.isNew ? ` ${CYN}[new]${RESET}` : ''}${g.muted ? ` ${DIM}[muted]${RESET}` : ''}`;
119
+ lines.push(`${BOLD}${String(g.count).padStart(6)}${RESET} ${sparkline(g.sparkline)}${flags} ${trunc(g.sampleMessage, 110)}`);
120
+ lines.push(`${DIM} ${g.fingerprint} ${trunc(g.logger ?? '-', 50)} sites:${g.sitesCount} ${ptTime(g.firstSeen)} -> ${ptTime(g.lastSeen)}${RESET}`);
121
+ }
122
+ return lines.join('\n');
123
+ }
124
+
125
+ export function formatRegression(r) {
126
+ const lines = [
127
+ `${BOLD}Before${RESET}: ${r.before?.total ?? 0} events, ${r.before?.errors ?? 0} errors ${BOLD}After${RESET}: ${r.after?.total ?? 0} events, ${r.after?.errors ?? 0} errors`,
128
+ ];
129
+ if (r.truncated) lines.push(`${YEL}Results capped.${RESET}`);
130
+ lines.push(`\n${BOLD}New (${(r.newGroups ?? []).length})${RESET}`);
131
+ for (const g of r.newGroups ?? []) lines.push(` ${String(g.count).padStart(5)} ${trunc(g.sampleMessage, 110)} ${DIM}${g.fingerprint}${RESET}`);
132
+ lines.push(`\n${BOLD}Increased (${(r.increased ?? []).length})${RESET}`);
133
+ for (const g of r.increased ?? []) lines.push(` ${g.before} -> ${g.after} (${g.ratio == null ? 'from zero' : `${Number(g.ratio).toFixed(1)}x`}) ${trunc(g.sampleMessage, 100)}`);
134
+ lines.push(`\n${BOLD}Gone (${(r.gone ?? []).length})${RESET}`);
135
+ for (const g of r.gone ?? []) lines.push(` ${g.before} -> 0 ${trunc(g.sampleMessage, 100)}`);
136
+ return lines.join('\n');
137
+ }
138
+
139
+ export function formatTrace(t) {
140
+ const lines = [`${BOLD}${(t.events ?? []).length} events${RESET} across ${(t.platformsSeen ?? []).join(', ') || 'no platforms'} over ${t.durationMs ?? 0} ms, ${t.errorCount ?? 0} error(s)`];
141
+ if (t.servedFrom) lines.push(`${DIM}Served via ${String(t.servedFrom).toLowerCase()} logger${RESET}`);
142
+ for (const e of t.events ?? []) {
143
+ const isErr = /^(ERROR|FATAL|CRITICAL)$/i.test(e.level ?? '');
144
+ const colour = isErr ? RED : RESET;
145
+ const lane = String(e.lane ?? e.platform ?? '?').padEnd(7);
146
+ lines.push(`${DIM}+${String(e.deltaMs ?? 0).padStart(6)}ms${RESET} ${lane} ${colour}${String(e.level ?? '?').padEnd(5)}${RESET} ${trunc(e.message, 120)}`);
147
+ }
148
+ return lines.join('\n');
149
+ }
150
+
151
+ export function formatAnomalies(a) {
152
+ const lines = [];
153
+ const buckets = a.buckets ?? [];
154
+ if (!buckets.length) return 'No hourly data.';
155
+ const max = Math.max(...buckets.map(b => b.count), 1);
156
+ for (const b of buckets) {
157
+ const bar = '█'.repeat(Math.round((b.count / max) * 30));
158
+ const flag = b.isAnomaly ? ` ${RED}ANOMALY z=${Number(b.z).toFixed(1)}${RESET}` : '';
159
+ const low = b.lowConfidence ? ` ${DIM}(low confidence)${RESET}` : '';
160
+ lines.push(`${ptTime(b.hourUtc).padEnd(20)} ${String(b.count).padStart(6)} ${bar}${flag}${low}`);
161
+ }
162
+ lines.push(`\n${(a.anomalies ?? []).length} anomalous hour(s).`);
163
+ return lines.join('\n');
164
+ }
165
+
166
+ export function formatSites(s) {
167
+ const sites = s.sites ?? [];
168
+ if (!sites.length) return 'No site-attributed events.';
169
+ const lines = [`${BOLD}${'SITE'.padEnd(8)}${'EVENTS'.padStart(8)}${'ERRORS'.padStart(8)}${'RATE'.padStart(8)} TREND${RESET}`];
170
+ for (const x of sites) {
171
+ lines.push(`${String(x.siteId).padEnd(8)}${String(x.total).padStart(8)}${String(x.errors).padStart(8)}${`${(x.errorRate * 100).toFixed(1)}%`.padStart(8)} ${trendText(x.trendPct)}`);
172
+ for (const g of (x.topGroups ?? []).slice(0, 2)) lines.push(`${DIM} ${g.count}x ${trunc(g.sampleMessage, 100)}${RESET}`);
173
+ }
174
+ if (s.unattributed) lines.push(`\n${DIM}${s.unattributed} events had no site id.${RESET}`);
175
+ return lines.join('\n');
176
+ }
177
+
178
+ export function formatMutes(m) {
179
+ const mutes = m.mutes ?? [];
180
+ if (!mutes.length) return 'No active mutes.';
181
+ return mutes.map(x => `${x.fingerprint} ${x.expiresAt ? `until ${ptTime(x.expiresAt)}` : 'never expires'} ${trunc(x.reason, 80)} ${DIM}${x.createdBy ?? ''}${RESET}`).join('\n');
182
+ }
183
+
184
+ export function formatSensitive(r) {
185
+ const f = r.findings ?? [];
186
+ if (!f.length) return 'No sensitive data found.';
187
+ const lines = f.map(x => `${String(x.count).padStart(6)} ${String(x.type).padEnd(10)} ${trunc(x.sampleRedacted, 90)} ${DIM}${trunc(x.logger ?? '-', 40)}${RESET}`);
188
+ if (r.truncated) lines.push(`${YEL}Results capped.${RESET}`);
189
+ return lines.join('\n');
190
+ }
191
+
192
+ // ── handlers ───────────────────────────────────────────────────────────────────────────────
193
+
194
+ // Scans can be slow: 90s for reads/writes, 120s for the AI brief (the server may call a model).
195
+ export const CALL_TIMEOUT_MS = 90_000;
196
+ export const BRIEF_TIMEOUT_MS = 120_000;
197
+ /** Server cap for from/to windows (meta.maxWindowHours today). Anomalies use --hours instead. */
198
+ export const MAX_WINDOW_MS = 24 * 3_600_000;
199
+
200
+ async function requireStaff(argv) {
201
+ const creds = await getCredentials(argv.env);
202
+ if (creds.siteId !== 1032) {
203
+ console.error('velaro logs insights is only available to Velaro staff (site 1032).');
204
+ process.exit(1);
205
+ }
206
+ }
207
+
208
+ /** Runs a call, prints JSON or the formatted view, and exits non-zero with the real status+body on failure. */
209
+ async function run(argv, call, format) {
210
+ try {
211
+ await requireStaff(argv);
212
+ const data = await call();
213
+ if (argv.json) console.log(JSON.stringify(data, null, 2));
214
+ else console.log(`\n${format(data)}\n`);
215
+ } catch (err) {
216
+ console.error(err instanceof Error ? err.message : String(err));
217
+ process.exit(1);
218
+ }
219
+ }
220
+
221
+ const call = (argv, method, path, body, timeoutMs = CALL_TIMEOUT_MS) =>
222
+ messagingRequest(method, `${INSIGHTS_BASE}/${path}`, body, argv.env, {}, { timeoutMs });
223
+
224
+ const get = (argv, path, params = {}) => call(argv, 'GET', `${path}${qs(params)}`);
225
+
226
+ /** Which of the shared filters an endpoint actually accepts. new / anomalies / regression take no site. */
227
+ const NO_SITE = { site: false };
228
+ const NO_SITE_NO_LEVEL = { site: false, level: false };
229
+
230
+ export function scoped(argv, { site = true, level = true } = {}) {
231
+ const p = commonParams(argv);
232
+ if (!site) delete p.siteId;
233
+ if (!level) delete p.level;
234
+ return p;
235
+ }
236
+
237
+ /** Anomalies bucket by hour and only support ERROR or WARN. */
238
+ export function anomalyLevel(level) {
239
+ const l = String(level ?? 'ERROR').toUpperCase();
240
+ if (l !== 'ERROR' && l !== 'WARN') throw new Error(`--level for anomalies must be ERROR or WARN (got ${JSON.stringify(level)}).`);
241
+ return l;
242
+ }
243
+
244
+ export const handlers = {
245
+ groups: argv => run(argv,
246
+ () => get(argv, 'groups', { ...windowParams(argv), ...scoped(argv), logger: argv.logger, take: argv.take, includeMuted: argv['include-muted'] ? 'true' : undefined }),
247
+ formatGroups),
248
+ new: argv => run(argv,
249
+ () => get(argv, 'new', { ...windowParams(argv), baselineDays: argv['baseline-days'], ...scoped(argv, NO_SITE) }),
250
+ formatGroups),
251
+ regression: argv => run(argv, () => {
252
+ const at = parseWhen(argv.at);
253
+ if (!at) throw new Error('--at is required: an ISO time or how long ago, e.g. --at 2h');
254
+ return get(argv, 'regression', { at: at.toISOString(), windowMinutes: argv.window, ...scoped(argv, NO_SITE_NO_LEVEL) });
255
+ }, formatRegression),
256
+ trace: argv => run(argv, () => {
257
+ const scope = { environment: argv.environment, platform: argv.platform };
258
+ if (argv.correlationId) return get(argv, 'trace', { correlationId: argv.correlationId, ...scope });
259
+ if (!argv.site) throw new Error('Give a correlation id, or --site with --last (window must be 1h or less).');
260
+ if (parseWindowMs(argv.last, 3_600_000) > 3_600_000) throw new Error('Trace by site needs --last of 1h or less.');
261
+ return get(argv, 'trace', { siteId: argv.site, ...windowParams(argv, '30m'), ...scope });
262
+ }, formatTrace),
263
+ anomalies: argv => run(argv,
264
+ () => get(argv, 'anomalies', { hours: argv.hours, ...scoped(argv, NO_SITE), level: anomalyLevel(argv.level) }),
265
+ formatAnomalies),
266
+ sites: argv => run(argv,
267
+ () => get(argv, 'sites/scorecard', { ...windowParams(argv), take: argv.take, ...scoped(argv, NO_SITE_NO_LEVEL) }),
268
+ formatSites),
269
+ mutes: argv => run(argv, () => get(argv, 'mutes', { environment: argv.environment }), formatMutes),
270
+ mute: argv => run(argv, () => {
271
+ if (!argv.reason || !String(argv.reason).trim()) throw new Error('--reason is required when muting a group.');
272
+ return call(argv, 'POST', 'mutes', {
273
+ fingerprint: argv.fingerprint,
274
+ reason: String(argv.reason).trim(),
275
+ expiresAt: parseExpiry(argv.expires),
276
+ environment: argv.environment,
277
+ });
278
+ }, r => `Muted ${r?.fingerprint ?? ''}${r?.environment ? ` (${r.environment})` : ''}`.trim()),
279
+ unmute: argv => run(argv,
280
+ async () => { await call(argv, 'DELETE', `mutes/${encodeURIComponent(argv.fingerprint)}${qs({ environment: argv.environment })}`); return { unmuted: argv.fingerprint }; },
281
+ r => `Unmuted ${r.unmuted}`),
282
+ sensitive: argv => run(argv,
283
+ () => get(argv, 'sensitive', { ...windowParams(argv), ...scoped(argv, NO_SITE_NO_LEVEL) }),
284
+ formatSensitive),
285
+ brief: argv => run(argv, () => {
286
+ // The server needs a time window in BOTH modes (a fingerprint alone has no time scope), capped at
287
+ // the server's from/to maximum. siteId is sent whenever --site is given.
288
+ const requested = parseWindowMs(argv.last, MAX_WINDOW_MS);
289
+ const win = windowParams({ last: `${Math.max(1, Math.floor(Math.min(requested, MAX_WINDOW_MS) / 60_000))}m` });
290
+ const body = { ...win, platform: argv.platform, environment: argv.environment, siteId: argv.site };
291
+ if (argv.fingerprint) body.fingerprint = argv.fingerprint;
292
+ return call(argv, 'POST', 'brief', body, BRIEF_TIMEOUT_MS);
293
+ }, r => `${r.markdown}\n\n${DIM}${r.groupsUsed} group(s) used${r.model ? `, ${r.model}` : ''}${typeof r.costUsd === 'number' ? `, $${r.costUsd.toFixed(4)}` : ''}${RESET}`),
294
+ };
295
+
296
+ // ── yargs command ──────────────────────────────────────────────────────────────────────────
297
+
298
+ const common = y => y
299
+ .option('last', { describe: 'Time window: 30m, 6h, 24h (server maximum is 24h; anomalies use --hours)', default: '24h', type: 'string' })
300
+ .option('platform', { describe: 'v10, v20 or admin', type: 'string' })
301
+ .option('environment', { describe: 'Production or Staging (server may federate to the other logger; mutes are per environment)', type: 'string' })
302
+ .option('env', { describe: 'CLI login to use: staging or prod', type: 'string' })
303
+ .option('json', { describe: 'Print the raw JSON response', type: 'boolean', default: false });
304
+
305
+ const withSite = y => y.option('site', { describe: 'Filter by site ID', type: 'number' });
306
+
307
+ export const logsInsightsCommand = {
308
+ command: 'insights <subcommand>',
309
+ describe: 'Error groups, regressions, anomalies, traces and more (Velaro staff only)',
310
+ builder: yargs => yargs
311
+ .command({ command: 'groups', describe: 'Top error groups (grouped by fingerprint)',
312
+ builder: y => withSite(common(y)).option('level', { default: 'ERROR', type: 'string' }).option('logger', { type: 'string' }).option('take', { default: 50, type: 'number' }).option('include-muted', { type: 'boolean', default: false }),
313
+ handler: handlers.groups })
314
+ .command({ command: 'new', describe: 'Error groups first seen in the window (not in the baseline); no site filter',
315
+ builder: y => common(y).option('level', { default: 'ERROR', type: 'string' }).option('baseline-days', { default: 7, type: 'number' }),
316
+ handler: handlers.new })
317
+ .command({ command: 'regression', describe: 'Compare the window before vs after a deploy time (all levels, no site filter)',
318
+ builder: y => common(y).option('at', { describe: 'Deploy time: ISO or how long ago (2h)', type: 'string', demandOption: true }).option('window', { describe: 'Minutes before/after', default: 60, type: 'number' }),
319
+ handler: handlers.regression })
320
+ .command({ command: 'trace [correlationId]', describe: 'Cross-platform timeline for a correlation id (or --site with a --last of 1h or less)',
321
+ builder: y => withSite(common(y)).positional('correlationId', { type: 'string' }),
322
+ handler: handlers.trace })
323
+ .command({ command: 'anomalies', describe: 'Hourly counts vs baseline, flags anomalous hours (ERROR or WARN; no site filter)',
324
+ builder: y => common(y).option('hours', { describe: 'Hours of history (server maximum: meta.maxAnomalyHours, 168 today)', default: 24, type: 'number' }).option('level', { describe: 'ERROR or WARN', default: 'ERROR', type: 'string' }),
325
+ handler: handlers.anomalies })
326
+ .command({ command: 'sites', describe: 'Per-site error scorecard',
327
+ builder: y => common(y).option('take', { default: 25, type: 'number' }),
328
+ handler: handlers.sites })
329
+ .command({ command: 'mutes', describe: 'List active mutes for --environment', builder: y => common(y), handler: handlers.mutes })
330
+ .command({ command: 'mute <fingerprint>', describe: 'Mute an error group in --environment (reason required)',
331
+ builder: y => common(y).positional('fingerprint', { type: 'string' }).option('reason', { type: 'string', demandOption: true }).option('expires', { describe: 'never, 7d, 12h, or an ISO date', default: '7d', type: 'string' }),
332
+ handler: handlers.mute })
333
+ .command({ command: 'unmute <fingerprint>', describe: 'Remove a mute from --environment',
334
+ builder: y => common(y).positional('fingerprint', { type: 'string' }),
335
+ handler: handlers.unmute })
336
+ .command({ command: 'sensitive', describe: 'Scan logs for emails, tokens, card numbers and similar',
337
+ builder: y => common(y), handler: handlers.sensitive })
338
+ .command({ command: 'brief [fingerprint]', describe: 'AI brief for one error group, or the whole window (always scoped to --last, max 24h)',
339
+ builder: y => withSite(common(y)).positional('fingerprint', { type: 'string' }),
340
+ handler: handlers.brief })
341
+ .demandCommand(1, 'Specify a subcommand: groups, new, regression, trace, anomalies, sites, mutes, mute, unmute, sensitive, or brief'),
342
+ };