@velaro/cli 1.4.96 → 1.4.98

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/bin/velaro.js CHANGED
@@ -10,10 +10,12 @@ import { botCommand } from '../lib/commands/bot.js';
10
10
  import { indexCommand } from '../lib/commands/index.js';
11
11
  import { ingestCommand } from '../lib/commands/ingest.js';
12
12
  import { indexMoveCommand } from '../lib/commands/index-move.js';
13
+ import { safetyCommand } from '../lib/commands/safety.js';
13
14
  import { knowledgeAdvisorCommand } from '../lib/commands/knowledge-advisor.js';
14
15
  import { productCommand } from '../lib/commands/product.js';
15
16
  import { mcpKeyCommand } from '../lib/commands/mcp-key.js';
16
17
  import { opsCommand } from '../lib/commands/ops.js';
18
+ import { deployStateCommand } from '../lib/commands/deploy-state.js';
17
19
  import { checkCommand } from '../lib/commands/check.js';
18
20
  import { logsCommand } from '../lib/commands/logs.js';
19
21
  import { aiModelsCommand } from '../lib/commands/ai-models.js';
@@ -109,6 +111,7 @@ import { paymentRecoveryCommand } from '../lib/commands/payment-recovery.js';
109
111
  import { recruitingCommand } from '../lib/commands/recruiting.js';
110
112
  import { surveyCommand } from '../lib/commands/survey.js';
111
113
  import { bundleCommand } from '../lib/commands/bundle.js';
114
+ import { chatWindowCommand } from '../lib/commands/chat-window.js';
112
115
  import { featureDiscoveryCommand } from '../lib/commands/feature-discovery.js';
113
116
  import { tourPolicyCommand } from '../lib/commands/tour-policy.js';
114
117
  import { scraperCommand } from '../lib/commands/scraper.js';
@@ -141,10 +144,12 @@ await yargs(hideBin(process.argv))
141
144
  .command(indexCommand)
142
145
  .command(ingestCommand)
143
146
  .command(indexMoveCommand)
147
+ .command(safetyCommand)
144
148
  .command(knowledgeAdvisorCommand)
145
149
  .command(productCommand)
146
150
  .command(mcpKeyCommand)
147
151
  .command(opsCommand)
152
+ .command(deployStateCommand)
148
153
  .command(checkCommand)
149
154
  .command(logsCommand)
150
155
  .command(aiModelsCommand)
@@ -242,6 +247,7 @@ await yargs(hideBin(process.argv))
242
247
  .command(recruitingCommand)
243
248
  .command(surveyCommand)
244
249
  .command(bundleCommand)
250
+ .command(chatWindowCommand)
245
251
  .command({ command: 'feature-discovery', desc: 'View and act on feature-discovery suggestions for this site', builder: featureDiscoveryCommand, handler: () => {} })
246
252
  .command({ command: 'scraper', desc: 'Velaro staff: scraper / upload / chunk usage for any site', builder: scraperCommand, handler: () => {} })
247
253
  .command({ command: 'tour-policy', desc: 'Velaro staff: guided-tour auto-open policy (versioned, rollback-able)', builder: tourPolicyCommand, handler: () => {} })
@@ -3,22 +3,25 @@ import { runCommand } from '../run.js';
3
3
 
4
4
  const print = (data) => console.log(JSON.stringify(data, null, 2));
5
5
 
6
- // velaro canned-learning <list|generate|approve|reject|rollback|edits|record-edit> (REST: messaging /CannedLearning/*)
6
+ // velaro canned-learning <list|generate|approve|reject|rollback|edits|record-edit|teams> (REST: messaging /CannedLearning/*)
7
7
  export const cannedLearningCommand = {
8
8
  command: 'canned-learning <action> [id]',
9
9
  describe: 'AI canned message learning: list/generate suggestions, approve/reject/rollback (human review)',
10
10
  builder: (y) => y
11
- .positional('action', { choices: ['list', 'generate', 'approve', 'reject', 'rollback', 'edits', 'record-edit'] })
11
+ .positional('action', { choices: ['list', 'generate', 'approve', 'reject', 'rollback', 'edits', 'record-edit', 'teams'] })
12
12
  .positional('id', { type: 'number' })
13
+ .option('team-id', { type: 'number', describe: 'list/edits: only this team; generate: run for this team (team managers must pass one they manage)' })
13
14
  .option('state', { type: 'string', default: 'pending_review', describe: 'pending_review|approved|rejected|rolled_back|all' })
14
15
  .option('text', { type: 'string', describe: 'approve: edited replacement text (defaults to the AI proposal)' })
15
16
  .option('notes', { type: 'string', describe: 'rollback: reason' })
16
17
  .option('final-text', { type: 'string', describe: 'record-edit: the text the agent actually sent (redacted server-side)' }),
17
18
  handler: runCommand(async (a) => {
18
19
  const base = '/CannedLearning';
19
- if (a.action === 'list') return print(await messagingGet(`${base}/suggestions?state=${encodeURIComponent(a.state)}`));
20
- if (a.action === 'generate') return print(await messagingPost(`${base}/generate`, {}));
21
- if (a.action === 'edits') return print(await messagingGet(`${base}/edit-events`));
20
+ const teamQs = a.teamId != null ? `&teamId=${encodeURIComponent(a.teamId)}` : '';
21
+ if (a.action === 'list') return print(await messagingGet(`${base}/suggestions?state=${encodeURIComponent(a.state)}${teamQs}`));
22
+ if (a.action === 'generate') return print(await messagingPost(`${base}/generate`, { teamId: a.teamId ?? null }));
23
+ if (a.action === 'edits') return print(await messagingGet(`${base}/edit-events${a.teamId != null ? `?teamId=${encodeURIComponent(a.teamId)}` : ''}`));
24
+ if (a.action === 'teams') return print(await messagingGet(`${base}/teams`));
22
25
  if (a.action === 'record-edit') {
23
26
  if (a.id == null || !a.finalText) throw new Error('record-edit requires a canned response id and --final-text');
24
27
  return print(await messagingPost(`${base}/edit-events`, { cannedResponseId: a.id, finalText: a.finalText }));
@@ -0,0 +1,47 @@
1
+ // velaro chat-window — per-page launcher label + opening greeting (ChatWindow/* on velaro-messaging).
2
+ // Site isolation is enforced server-side (ClientControllerBase SiteId), never passed by the client.
3
+ // To show different copy on different pages: create one chat window per page type, set its
4
+ // label/greeting here, then pick it with a Select Chat Window workflow node.
5
+ import { messagingGet, messagingPut } from '../api.js';
6
+ import { runCommand } from '../run.js';
7
+
8
+ export const chatWindowCommand = {
9
+ command: 'chat-window <subcommand>',
10
+ describe: 'Chat window launcher label and greeting — list, set-copy',
11
+ builder: (yargs) =>
12
+ yargs
13
+ .command({
14
+ command: 'list',
15
+ describe: 'List chat windows with launcher label and greeting',
16
+ handler: runCommand(async () => {
17
+ const rows = (await messagingGet('/ChatWindow/list')) ?? [];
18
+ if (!rows.length) { console.log('No chat windows found.'); return; }
19
+ for (const w of rows) {
20
+ console.log(`${w.id} ${w.name ?? '(unnamed)'} [${w.languageConfig?.language ?? '?'}]`);
21
+ console.log(` launcherText: ${w.styleConfig?.launcherText ?? '(none)'}`);
22
+ console.log(` greeting: ${w.languageConfig?.greeting ?? '(none)'}`);
23
+ }
24
+ console.log(`\n${rows.length} chat window(s)`);
25
+ }),
26
+ })
27
+ .command({
28
+ command: 'set-copy <id>',
29
+ describe: 'Set launcher label and/or greeting on one chat window (empty string clears)',
30
+ builder: (y) => y
31
+ .positional('id', { type: 'number', describe: 'Chat window ID' })
32
+ .option('launcher-text', { type: 'string', describe: 'Launcher label, max 60 chars' })
33
+ .option('greeting', { type: 'string', describe: 'Opening greeting, max 256 chars' }),
34
+ handler: runCommand(async (argv) => {
35
+ const body = {};
36
+ if (argv.launcherText !== undefined) body.launcherText = argv.launcherText;
37
+ if (argv.greeting !== undefined) body.greeting = argv.greeting;
38
+ if (!Object.keys(body).length) throw new Error('Pass --launcher-text and/or --greeting.');
39
+ const r = await messagingPut(`/ChatWindow/${argv.id}/launcher-copy`, body);
40
+ console.log(`Updated chat window ${r.id}.`);
41
+ console.log(` launcherText: ${r.launcherText ?? '(none)'}`);
42
+ console.log(` greeting: ${r.greeting ?? '(none)'}`);
43
+ }),
44
+ })
45
+ .demandCommand(1, 'Specify a subcommand: list, set-copy'),
46
+ handler: () => {},
47
+ };
@@ -26,6 +26,7 @@ const DB_SECRETS = {
26
26
  staging: { vault: 'velaro-eastus-stg-kv', secret: 'KnowledgeBaseConnectionString-Staging' },
27
27
  },
28
28
  messaging: {
29
+ staging: { vault: 'velaro-eastus-stg-kv', secret: 'MessagingConnectionString-Staging' },
29
30
  prod: { vault: 'velaro-eastus-prd-kv', secret: 'MessagingReadOnlyConnectionString' },
30
31
  },
31
32
  };
@@ -0,0 +1,26 @@
1
+ import { collectAll, render, PRODUCTS } from '../deploy-state.js';
2
+
3
+ // velaro deploy-state: what is on the staging environment, on staging slot 1 of production and in
4
+ // production for Admin and Messaging, what is merged to dev but not in production, which gate is
5
+ // waiting and the merge-freeze file. Uses only `gh` (logged in) and public /Version endpoints.
6
+ export const deployStateCommand = {
7
+ command: 'deploy-state',
8
+ describe: 'Where each push landed: staging environment, staging slot 1 of production, production (Velaro staff, needs gh)',
9
+ builder: (yargs) => yargs
10
+ .option('product', { type: 'string', choices: ['admin', 'messaging'], describe: 'Only one product (default both)' })
11
+ .option('json', { type: 'boolean', describe: 'Print the raw state as JSON' })
12
+ .option('compact', { type: 'boolean', describe: 'One line per place' }),
13
+ handler: async (argv) => {
14
+ try {
15
+ const products = argv.product
16
+ ? [Object.keys(PRODUCTS).find((n) => n.toLowerCase() === argv.product)]
17
+ : undefined;
18
+ const state = await collectAll({ products });
19
+ console.log(argv.json ? JSON.stringify(state, null, 2) : render(state, { compact: !!argv.compact }));
20
+ } catch (e) {
21
+ console.error(`deploy-state failed: ${e.message}`);
22
+ console.error('This command needs the GitHub CLI (gh) installed and logged in with access to velaro/velaro-admin and velaro/velaro-messaging. Run: gh auth login');
23
+ process.exit(1);
24
+ }
25
+ },
26
+ };
@@ -1,6 +1,6 @@
1
1
  import { getCredentials } from '../api.js';
2
2
  import { readConfig, ENVS } from '../config.js';
3
- import { completeLogSearch, describeCompleteness, describeRawMeta } from '../log-search.js';
3
+ import { completeLogSearch, describeCompleteness, describeRawMeta, fetchJsonRetry } from '../log-search.js';
4
4
 
5
5
  // ── helpers ──────────────────────────────────────────────────────────────────
6
6
 
@@ -57,6 +57,10 @@ async function requireAdmin(creds) {
57
57
  // ── search handler ────────────────────────────────────────────────────────────
58
58
 
59
59
  async function searchLogs(argv) {
60
+ try { await searchLogsInner(argv); } catch (err) { console.error(err?.message ?? String(err)); process.exit(1); }
61
+ }
62
+
63
+ async function searchLogsInner(argv) {
60
64
  const creds = await getCredentials();
61
65
  await requireAdmin(creds);
62
66
  const msgBase = await getMsgBase(argv);
@@ -68,14 +72,8 @@ async function searchLogs(argv) {
68
72
  process.exit(1);
69
73
  }
70
74
  const headers = { Authorization: `Bearer ${creds.velaroToken}`, 'X-Internal-SiteId': String(creds.siteId) };
71
- const getJson = async (query) => {
72
- const res = await fetch(`${msgBase}/superadmin/logs/search?${query}`, { headers, signal: AbortSignal.timeout(30000) });
73
- if (!res.ok) {
74
- console.error(`HTTP ${res.status}: ${await res.text().catch(() => '')}`);
75
- process.exit(1);
76
- }
77
- return res.json();
78
- };
75
+ // 40 s: the server's own scan budget is 12 s (+6 s AI search) per request, and a 429 (scan gate full) is retried with Retry-After.
76
+ const getJson = (query) => fetchJsonRetry(`${msgBase}/superadmin/logs/search?${query}`, () => ({ headers, signal: AbortSignal.timeout(40000) }));
79
77
 
80
78
  // --raw: the server's own q= search (kept for parity). Proven 2026-10-14 to under-report (q=[HttpFallback]
81
79
  // returned 0-2 rows while 136 existed), so it now always prints the server's searchPath/scannedRows/truncated.
@@ -110,6 +108,8 @@ async function searchLogs(argv) {
110
108
  regex: argv.regex ? argv.query : undefined,
111
109
  logger: argv.logger,
112
110
  maxRequests: argv.maxRequests ?? 200,
111
+ maxSeconds: argv.maxSeconds ?? 120,
112
+ wanted: argv.take ?? 50,
113
113
  });
114
114
  console.log(`${result.complete ? DIM : YEL}${describeCompleteness(result)}${RESET}`);
115
115
  if (!result.entries.length) { console.log('No results.'); return; }
@@ -238,21 +238,26 @@ async function diagnoseLogs(argv) {
238
238
 
239
239
  console.log(`\n${BOLD}── Bot Diagnostic — last ${window} ──────────────────────────────${RESET2}`);
240
240
 
241
- // Run error search and perf/routing search in parallel
242
- async function search(q) {
243
- const p = new URLSearchParams({ q, from: from.toISOString(), take: '50' });
244
- const r = await fetch(`${msgBase}/superadmin/logs/search?${p}`, {
241
+ // Run error search and perf/routing search in parallel. FIXED 2026-10-14: the old calls searched the TEXT "level:ERROR" and the
242
+ // TEXT "WF-AI-ROUTE OR PERF-SLOW", which no log line contains, so this printed "No errors" no matter what. Errors are now the
243
+ // server-side level filter and the perf/routing pair is a regex; the server's scan metadata is surfaced so an unverified
244
+ // empty answer is never shown as a clean bill of health.
245
+ const notes = [];
246
+ async function search(label, p0) {
247
+ const p = new URLSearchParams({ from: from.toISOString(), take: '50', ...p0 });
248
+ const resp = await fetchJsonRetry(`${msgBase}/superadmin/logs/search?${p}`, () => ({
245
249
  headers: { Authorization: `Bearer ${creds.velaroToken}`, 'X-Internal-SiteId': String(creds.siteId) },
246
- signal: AbortSignal.timeout(15000),
247
- });
248
- if (!r.ok) return [];
249
- return extractLogEntries(await r.json());
250
+ signal: AbortSignal.timeout(40000),
251
+ })).catch((err) => { notes.push(`${label}: search failed (${err.message})`); return null; });
252
+ if (resp && !Array.isArray(resp) && resp.truncated !== false) notes.push(`${label}: ${describeRawMeta(resp)}`);
253
+ else if (Array.isArray(resp)) notes.push(`${label}: ${describeRawMeta(resp)}`);
254
+ return resp ? extractLogEntries(resp) : [];
250
255
  }
251
256
 
252
- const [errors, perfAndRouting] = await Promise.all([
253
- search('level:ERROR'),
254
- search('WF-AI-ROUTE OR PERF-SLOW'),
255
- ]);
257
+ // One at a time: the server runs at most 2 exact scans per instance.
258
+ const errors = await search('errors', { level: 'ERROR' });
259
+ const perfAndRouting = await search('perf/routing', { q: 'WF-AI-ROUTE|PERF-SLOW', regex: 'true' });
260
+ for (const n of notes) console.log(` \x1b[33m!\x1b[0m ${n}`);
256
261
 
257
262
  // ── Errors ──
258
263
  console.log(`\n${BOLD}Errors${RESET2}`);
@@ -724,6 +729,7 @@ export const logsCommand = {
724
729
  .option('to', { describe: 'ISO end time (default now)', type: 'string' })
725
730
  .option('raw', { describe: 'Use the server q= search (can under-report; prints searchPath/scannedRows/truncated)', boolean: true, default: false })
726
731
  .option('max-requests', { describe: 'Request budget for the complete read', default: 200, type: 'number' })
732
+ .option('max-seconds', { describe: 'Wall-clock budget for the complete read', default: 120, type: 'number' })
727
733
  .option('take', { describe: 'Max lines to print (complete read) / server page size (--raw, max 500)', default: 50, type: 'number' })
728
734
  .option('env', { describe: 'staging or prod', type: 'string' }),
729
735
  handler: searchLogs,
@@ -1,5 +1,7 @@
1
+ import { describeRawMeta, fetchRetry429 } from '../log-search.js';
1
2
  import { get, del, post, getCredentials } from '../api.js';
2
3
  import { readConfig, ENVS } from '../config.js';
4
+ import { collectAll, render as renderDeployState } from '../deploy-state.js';
3
5
 
4
6
  // ── helpers ──────────────────────────────────────────────────────────────────
5
7
 
@@ -153,6 +155,17 @@ export const opsCommand = {
153
155
  }
154
156
  }
155
157
 
158
+ // ── 1b. Where each push landed: staging environment, staging slot 1 of production, production ──
159
+ // Same data as `velaro deploy-state` (needs gh). Never blocks the rest of ops if gh is missing.
160
+ console.log('\n\x1b[1m── Where each push landed ──────────────────────────────────\x1b[0m');
161
+ try {
162
+ const state = await collectAll();
163
+ console.log(renderDeployState(state, { compact: true }).split('\n').map(l => ' ' + l).join('\n'));
164
+ console.log(' Full detail: velaro deploy-state');
165
+ } catch (e) {
166
+ console.log(` ${WARN} Deploy state unavailable (${e.message}). Needs gh logged in; run velaro deploy-state.`);
167
+ }
168
+
156
169
  // ── 2. API health (admin + messaging, both envs) ──────────────────────────
157
170
  const prodMsgBase = cfg.envs?.prod?.messagingApiBase ?? ENVS.prod.messagingApiBase;
158
171
  const stagingMsgBase = cfg.envs?.staging?.messagingApiBase ?? ENVS.staging.messagingApiBase;
@@ -236,19 +249,25 @@ export const opsCommand = {
236
249
  const stagingMsgBaseLocal = cfg.envs?.staging?.messagingApiBase ?? ENVS.staging.messagingApiBase;
237
250
  const from1h = new Date(Date.now() - 3600000).toISOString();
238
251
 
239
- const [errRes, perfRes] = await Promise.all([
240
- fetch(`${stagingMsgBaseLocal}/superadmin/logs/search?q=level%3AERROR&from=${from1h}&take=5`, {
241
- headers: { Authorization: `Bearer ${creds.velaroToken}`, 'X-Internal-SiteId': String(creds.siteId) },
242
- signal: AbortSignal.timeout(12000),
243
- }),
244
- fetch(`${stagingMsgBaseLocal}/superadmin/logs/search?q=WF-AI-ROUTE+OR+PERF-SLOW&from=${from1h}&take=20`, {
245
- headers: { Authorization: `Bearer ${creds.velaroToken}`, 'X-Internal-SiteId': String(creds.siteId) },
246
- signal: AbortSignal.timeout(12000),
247
- }),
248
- ]);
249
-
250
- const errors = errRes.ok ? extractLogEntries(await errRes.json().catch(() => [])) : [];
251
- const perfLogs = perfRes.ok ? extractLogEntries(await perfRes.json().catch(() => [])) : [];
252
+ // One search at a time: the server runs at most 2 exact scans per instance. 429 (gate full) is retried honouring Retry-After.
253
+ const errRes = await fetchRetry429(`${stagingMsgBaseLocal}/superadmin/logs/search?level=ERROR&from=${from1h}&take=5`, () => ({
254
+ headers: { Authorization: `Bearer ${creds.velaroToken}`, 'X-Internal-SiteId': String(creds.siteId) },
255
+ signal: AbortSignal.timeout(40000),
256
+ }));
257
+ const perfRes = await fetchRetry429(`${stagingMsgBaseLocal}/superadmin/logs/search?q=${encodeURIComponent('WF-AI-ROUTE|PERF-SLOW')}&regex=true&from=${from1h}&take=20`, () => ({
258
+ headers: { Authorization: `Bearer ${creds.velaroToken}`, 'X-Internal-SiteId': String(creds.siteId) },
259
+ signal: AbortSignal.timeout(40000),
260
+ }));
261
+
262
+ // FIXED 2026-10-14: both searches used to look for the literal TEXT "level:ERROR" / "WF-AI-ROUTE OR PERF-SLOW" (never in any log
263
+ // line), so this check always said "0 errors". Errors are now the server-side level filter, perf/routing a regex, and an
264
+ // unverified or failed scan is reported instead of being counted as a clean result.
265
+ const errBody = errRes.ok ? await errRes.json().catch(() => null) : null;
266
+ const perfBody = perfRes.ok ? await perfRes.json().catch(() => null) : null;
267
+ const errors = errBody ? extractLogEntries(errBody) : [];
268
+ const perfLogs = perfBody ? extractLogEntries(perfBody) : [];
269
+ const scanNote = (label, body, ok) => (!ok || !body) ? `${label}: search failed or timed out (HTTP ${ok ? 'ok' : 'error'}), NOT verified` : (body.truncated === false ? null : `${label}: ${describeRawMeta(body)}`);
270
+ for (const n of [scanNote('errors', errBody, errRes.ok), scanNote('perf/routing', perfBody, perfRes.ok)].filter(Boolean)) console.log(` ${WARN} ${n}`);
252
271
 
253
272
  const perfCount = perfLogs.filter(e => (e.Message ?? e.message ?? '').includes('PERF-SLOW')).length;
254
273
  const routeCount = perfLogs.filter(e => (e.Message ?? e.message ?? '').includes('WF-AI-ROUTE')).length;
@@ -0,0 +1,111 @@
1
+ // velaro safety — see safety events, acknowledge them, and manage alert settings. Wraps velaro-messaging
2
+ // SafetyEventsController. The site always comes from your login, never from an argument. Same routes the
3
+ // admin "Safety and alerts" page uses. Needs the EnableTroubleshootingKnowledge entitlement.
4
+ import { messagingGet, messagingPost, messagingPut } from '../api.js';
5
+ import { runCommand } from '../run.js';
6
+
7
+ const enc = encodeURIComponent;
8
+
9
+ const out = (argv, data, printer) => {
10
+ if (argv.json) console.log(JSON.stringify(data, null, 2));
11
+ else printer(data);
12
+ };
13
+
14
+ const list = (v) => (v === undefined ? undefined : [].concat(v).flatMap((x) => String(x).split(',')).map((s) => s.trim()).filter(Boolean));
15
+
16
+ function printEvents(r) {
17
+ const items = r.items ?? [];
18
+ if (!items.length) { console.log('No safety events.'); return; }
19
+ for (const e of items) {
20
+ console.log(`[${e.id}] ${e.createdUtc} ${e.type} (${e.channel}) ${e.status}`);
21
+ if (e.reason) console.log(` Why: ${e.reason}`);
22
+ if (e.excerpt) console.log(` "${e.excerpt}"`);
23
+ }
24
+ console.log(`\n${items.length} of ${r.total ?? items.length} shown.`);
25
+ }
26
+
27
+ function printSummary(s) {
28
+ console.log('Totals:');
29
+ for (const [k, v] of Object.entries(s.totals ?? {})) console.log(` ${k}: ${v}`);
30
+ console.log('\nBy day:');
31
+ for (const d of s.days ?? []) console.log(` ${d.date} ${JSON.stringify(d.counts)}`);
32
+ }
33
+
34
+ function printSettings(s) {
35
+ console.log(`Alerts: ${s.enabled ? 'on' : 'off'}`);
36
+ console.log(`Email recipients: ${(s.emailRecipients ?? []).join(', ') || '(none)'}`);
37
+ console.log(`SMS recipients: ${(s.smsRecipients ?? []).join(', ') || '(none)'}`);
38
+ console.log(`Alert right away: ${(s.immediateTypes ?? []).join(', ') || '(none)'}`);
39
+ console.log(`Summary email: ${s.digest}`);
40
+ console.log(`Count alert: ${s.threshold?.count ?? 0} events in ${s.threshold?.windowMinutes ?? 60} minutes`);
41
+ }
42
+
43
+ export const safetyCommand = {
44
+ command: 'safety',
45
+ describe: 'Safety events and alerts: list events, acknowledge, alert settings, test alert, ask Velaro for help',
46
+ builder: (yargs) => yargs
47
+ .command('events', 'List safety events', (y) => y
48
+ .option('from', { type: 'string', describe: 'ISO start' })
49
+ .option('to', { type: 'string', describe: 'ISO end' })
50
+ .option('type', { type: 'string' })
51
+ .option('status', { type: 'string', choices: ['open', 'acknowledged'] })
52
+ .option('take', { type: 'number', default: 25 })
53
+ .option('skip', { type: 'number', default: 0 })
54
+ .option('json', { type: 'boolean' }),
55
+ runCommand(async (a) => {
56
+ const q = new URLSearchParams();
57
+ for (const k of ['from', 'to', 'type', 'status']) if (a[k]) q.set(k, a[k]);
58
+ q.set('take', String(a.take));
59
+ q.set('skip', String(a.skip));
60
+ out(a, await messagingGet(`/SafetyEvents?${q}`), printEvents);
61
+ }))
62
+ .command('summary', 'Counts by type and by day', (y) => y
63
+ .option('days', { type: 'number', default: 7 })
64
+ .option('json', { type: 'boolean' }),
65
+ runCommand(async (a) => out(a, await messagingGet(`/SafetyEvents/summary?days=${a.days}`), printSummary)))
66
+ .command('ack', 'Mark an event as reviewed', (y) => y.option('id', { type: 'string', demandOption: true }),
67
+ runCommand(async (a) => {
68
+ await messagingPost(`/SafetyEvents/${enc(a.id)}/acknowledge`);
69
+ console.log(`Event ${a.id} marked as reviewed.`);
70
+ }))
71
+ .command('settings <action>', 'Read or change alert settings (get | set)', (y) => y
72
+ .positional('action', { choices: ['get', 'set'] })
73
+ .option('enabled', { type: 'boolean' })
74
+ .option('email', { type: 'string', describe: 'Email recipients, comma separated (replaces the list)' })
75
+ .option('sms', { type: 'string', describe: 'SMS recipients in +15551234567 form, comma separated (replaces the list)' })
76
+ .option('immediate', { type: 'string', describe: 'Event types that alert right away, comma separated (replaces the list)' })
77
+ .option('digest', { type: 'string', choices: ['off', 'daily', 'weekly'] })
78
+ .option('threshold-count', { type: 'number', describe: 'Alert at this many events inside the window. 0 turns it off.' })
79
+ .option('threshold-window', { type: 'number', describe: 'Window in minutes, 1 to 10080' })
80
+ .option('json', { type: 'boolean' }),
81
+ runCommand(async (a) => {
82
+ const cur = (await messagingGet('/SafetyEvents/settings')) ?? {};
83
+ if (a.action === 'get') return out(a, cur, printSettings);
84
+ const next = { ...cur };
85
+ if (a.enabled !== undefined) next.enabled = a.enabled;
86
+ if (a.email !== undefined) next.emailRecipients = list(a.email);
87
+ if (a.sms !== undefined) next.smsRecipients = list(a.sms);
88
+ if (a.immediate !== undefined) next.immediateTypes = list(a.immediate);
89
+ if (a.digest !== undefined) next.digest = a.digest;
90
+ if (a['threshold-count'] !== undefined || a['threshold-window'] !== undefined) {
91
+ next.threshold = {
92
+ count: a['threshold-count'] ?? cur.threshold?.count ?? 0,
93
+ windowMinutes: a['threshold-window'] ?? cur.threshold?.windowMinutes ?? 60,
94
+ };
95
+ }
96
+ out(a, await messagingPut('/SafetyEvents/settings', next), (s) => { console.log('Saved.\n'); printSettings(s); });
97
+ }))
98
+ .command('test', 'Send a test alert to the configured recipients', (y) => y,
99
+ runCommand(async () => {
100
+ await messagingPost('/SafetyEvents/settings/test');
101
+ console.log('Test alert sent. Check the inboxes and phones on the recipient list.');
102
+ }))
103
+ .command('need-help', 'Tell the Velaro team the bot is going off the rails', (y) => y
104
+ .option('message', { type: 'string', demandOption: true, describe: 'What is happening' }),
105
+ runCommand(async (a) => {
106
+ await messagingPost('/SafetyEvents/need-help', { message: a.message });
107
+ console.log('Sent. The Velaro team has been alerted.');
108
+ }))
109
+ .demandCommand(1, 'Specify a subcommand: events, summary, ack, settings, test, need-help'),
110
+ handler: () => {},
111
+ };