@velaro/cli 1.4.102 → 1.4.103

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 (46) hide show
  1. package/README.md +19 -0
  2. package/bin/velaro.js +12 -0
  3. package/lib/api.js +2 -1
  4. package/lib/apple-pay.js +32 -0
  5. package/lib/apple-pay.test.js +32 -0
  6. package/lib/avatar-switch.js +42 -0
  7. package/lib/avatar-switch.test.js +49 -0
  8. package/lib/build-info.js +28 -0
  9. package/lib/build-info.test.js +24 -0
  10. package/lib/commands/apple-pay.js +73 -0
  11. package/lib/commands/avatar.js +60 -2
  12. package/lib/commands/billing-automation.js +172 -0
  13. package/lib/commands/copilotstudio.js +14 -1
  14. package/lib/commands/enterprise-reports.js +1 -0
  15. package/lib/commands/entitlement.js +29 -1
  16. package/lib/commands/focus.js +1 -1
  17. package/lib/commands/index.js +38 -2
  18. package/lib/commands/logs-insights.js +342 -0
  19. package/lib/commands/logs.js +313 -3
  20. package/lib/commands/magento.js +16 -0
  21. package/lib/commands/mcp-key.js +23 -1
  22. package/lib/commands/netsuite.js +67 -2
  23. package/lib/commands/notifications.js +34 -1
  24. package/lib/commands/ops.js +23 -54
  25. package/lib/commands/report-backfill.js +23 -5
  26. package/lib/commands/report.js +77 -14
  27. package/lib/commands/salesforce.js +101 -0
  28. package/lib/commands/shopify.js +84 -0
  29. package/lib/commands/signature.js +79 -0
  30. package/lib/commands/site-builder.js +30 -0
  31. package/lib/commands/support.js +22 -0
  32. package/lib/commands/teams.js +11 -1
  33. package/lib/commands/workflow.js +33 -0
  34. package/lib/commands/zoom-phone.js +15 -0
  35. package/lib/enterprise-reports.test.js +1 -1
  36. package/lib/entitlement-report-packages.test.js +47 -0
  37. package/lib/logs-insights.test.js +146 -0
  38. package/lib/mcp-key.test.js +11 -0
  39. package/lib/messaging-routes.test.js +10 -1
  40. package/lib/oauth.js +14 -2
  41. package/lib/report-range.js +21 -0
  42. package/lib/report-range.test.js +38 -0
  43. package/lib/shopify-write-policy.test.js +34 -0
  44. package/lib/signature-policy.js +70 -0
  45. package/lib/signature-policy.test.js +48 -0
  46. package/package.json +1 -1
@@ -1,5 +1,6 @@
1
1
  import { getCredentials } from '../api.js';
2
2
  import { readConfig, ENVS } from '../config.js';
3
+ import { logsInsightsCommand } from './logs-insights.js';
3
4
  import { completeLogSearch, describeCompleteness, describeRawMeta, fetchJsonRetry } from '../log-search.js';
4
5
 
5
6
  // ── helpers ──────────────────────────────────────────────────────────────────
@@ -64,6 +65,10 @@ async function searchLogsInner(argv) {
64
65
  const creds = await getCredentials();
65
66
  await requireAdmin(creds);
66
67
  const msgBase = await getMsgBase(argv);
68
+ // Always say WHICH environment is searched. The CLI defaults to prod, and a staging problem searched
69
+ // there looks identical to "nothing was logged" (cost days on 2026-10-21).
70
+ const searchedEnv = /staging|stg/i.test(msgBase) ? 'staging' : 'prod';
71
+ console.log(`${DIM}environment: ${searchedEnv} (${msgBase})${searchedEnv === 'prod' ? ' - add --env staging for staging logs' : ''}${RESET}`);
67
72
 
68
73
  const to = argv.to ? new Date(argv.to) : new Date();
69
74
  const from = argv.from ? new Date(argv.from) : new Date(to.getTime() - parseDuration(argv.last ?? '2h'));
@@ -112,7 +117,10 @@ async function searchLogsInner(argv) {
112
117
  wanted: argv.take ?? 50,
113
118
  });
114
119
  console.log(`${result.complete ? DIM : YEL}${describeCompleteness(result)}${RESET}`);
115
- if (!result.entries.length) { console.log('No results.'); return; }
120
+ if (!result.entries.length) {
121
+ console.log(searchedEnv === 'prod' ? 'No results in PROD. If you meant staging, re-run with --env staging.' : 'No results.');
122
+ return;
123
+ }
116
124
  const shown = result.entries.slice(0, argv.take ?? 50);
117
125
  console.log(`\n${DIM}showing ${shown.length} of ${result.entries.length} (newest first) from ${from.toISOString().slice(0, 16)} UTC${RESET}\n`);
118
126
  for (const e of shown) printEntry(e);
@@ -343,20 +351,53 @@ function parsePositiveIntFlag(flagName, rawValue, { max } = {}) {
343
351
  return { ok: true, value: n };
344
352
  }
345
353
 
346
- // A rule with none of the four notification channels fires into the void —
354
+ // A rule with none of the five notification channels fires into the void —
347
355
  // it's pure log noise that pages no one. Mirrors the same guard in
348
356
  // client/src/pages/Messaging/Integrations/AlertRules.tsx (extended here to
357
+ // also cover webhook/teams, which that component's check omits).
358
+ // NOTE: a blank emails/sms does NOT necessarily mean "no one" any more --
359
+ // LogAlertEvaluationService.ResolveRecipients falls back to global-default
360
+ // AlertContacts when a rule leaves those blank. Callers should pass
361
+ // hasGlobalDefaultContacts (from fetchHasGlobalDefaultContacts) so a blank
362
+ // emails/sms isn't wrongly flagged as "fires into the void" when defaults
363
+ // actually cover it.
364
+ function hasAnyRecipient({ emails, sms, webhook, teams, mobileUserIds, hasGlobalDefaultContacts }) {
349
365
  // also cover webhook, which that component's check omits).
350
366
  function hasAnyRecipient({ emails, sms, webhook, mobileUserIds, teams }) {
351
367
  return Boolean(
352
368
  (emails && String(emails).trim()) ||
353
369
  (sms && String(sms).trim()) ||
354
370
  (webhook && String(webhook).trim()) ||
371
+ teams ||
372
+ (mobileUserIds && String(mobileUserIds).trim()) ||
373
+ // Blank emails AND blank sms both fall back to global defaults together (see
374
+ // ResolveRecipients) -- if there's at least one global-default contact with a phone or
375
+ // email, a rule with both blank is NOT actually silent, even though neither field is set.
376
+ ((!emails || !String(emails).trim()) && (!sms || !String(sms).trim()) && hasGlobalDefaultContacts)
355
377
  (mobileUserIds && String(mobileUserIds).trim()) ||
356
378
  teams
357
379
  );
358
380
  }
359
381
 
382
+ // Fetches whether at least one global-default AlertContact exists with a phone or email --
383
+ // used only to soften the "fires into the void" guard above, never cached across CLI
384
+ // invocations, so a stale answer can't cause a false pass. Returns false (fail toward the
385
+ // stricter behavior) on any fetch error rather than silently skipping the guard.
386
+ async function fetchHasGlobalDefaultContacts(creds, msgBase) {
387
+ try {
388
+ const res = await fetch(`${msgBase}/superadmin/log-alerts/contacts`, {
389
+ headers: { Authorization: `Bearer ${creds.velaroToken}`, 'X-Internal-SiteId': String(creds.siteId) },
390
+ signal: AbortSignal.timeout(15000),
391
+ });
392
+ if (!res.ok) return false;
393
+ const contacts = await res.json();
394
+ return Array.isArray(contacts) && contacts.some(c =>
395
+ c.isGlobalDefault && ((c.phoneNumber && c.phoneNumber.trim()) || (c.email && c.email.trim())));
396
+ } catch {
397
+ return false;
398
+ }
399
+ }
400
+
360
401
  function printAlertRule(r) {
361
402
  const state = r.enabled === false ? `${DIM}paused${RESET}` : `${CYN}active${RESET}`;
362
403
  const bits = [`>=${r.thresholdCount} in ${r.windowMinutes}m`];
@@ -365,9 +406,12 @@ function printAlertRule(r) {
365
406
  if (r.integration) bits.push(`tag=${r.integration}`);
366
407
  if (r.siteId) bits.push(`site=${r.siteId}`);
367
408
  if (r.notifyPhoneNumbers) bits.push('SMS');
409
+ else if (!r.notifyEmails) bits.push('SMS+email (global defaults)');
368
410
  if (r.notifyEmails) bits.push('email');
369
411
  if (r.notifyWebhookUrl) bits.push('webhook');
412
+ if (r.notifyTeams) bits.push('Teams');
370
413
  if (r.notifyMobileUserIds) bits.push('mobile push');
414
+ if (r.excludeContactNames) bits.push(`excl:${r.excludeContactNames}`);
371
415
  if (r.notifyTeams) bits.push('Teams');
372
416
  bits.push(`cooldown ${r.minRefireMinutes}m`);
373
417
  console.log(`${DIM}[${r.id}]${RESET} ${r.name} ${state}`);
@@ -481,11 +525,19 @@ async function createAlertRule(argv) {
481
525
  }
482
526
  const [thresholdCheck, windowCheck, cooldownCheck] = checks;
483
527
 
528
+ // HIGH: a rule with zero notification channels fires into the void. A blank --emails/--sms
529
+ // may still be covered by global-default AlertContacts (see hasAnyRecipient docs), so check
530
+ // those before failing.
531
+ const hasDefaults = await fetchHasGlobalDefaultContacts(creds, msgBase);
532
+ if (!hasAnyRecipient({ emails: argv.emails, sms: argv.sms, webhook: argv.webhook, teams: argv.teams, mobileUserIds: argv.mobileUserIds, hasGlobalDefaultContacts: hasDefaults })
533
+ && !argv.noRecipientsOk) {
534
+ console.error('No notification channel given (--emails, --sms, --webhook, --teams, or --mobile-user-ids), and no global-default contact exists to fall back to — this rule would fire but notify no one.');
484
535
  // HIGH: a rule with zero notification channels fires into the void.
485
536
  if (!hasAnyRecipient({ emails: argv.emails, sms: argv.sms, webhook: argv.webhook, mobileUserIds: argv.mobileUserIds, teams: argv.teams })
486
537
  && !argv.noRecipientsOk) {
487
538
  console.error('No notification channel given (--emails, --sms, --webhook, --teams, or --mobile-user-ids) — this rule would fire but notify no one.');
488
539
  console.error('If that\'s intentional (e.g. you only want it visible in Alert History), pass --no-recipients-ok to proceed anyway.');
540
+ console.error('To set up global defaults so a blank --emails/--sms is covered automatically: velaro logs contacts add --name alex --phone +1... --email alex@velaro.com --global');
489
541
  process.exit(1);
490
542
  }
491
543
 
@@ -500,7 +552,9 @@ async function createAlertRule(argv) {
500
552
  notifyEmails: argv.emails,
501
553
  notifyPhoneNumbers: argv.sms,
502
554
  notifyWebhookUrl: argv.webhook,
555
+ notifyTeams: Boolean(argv.teams),
503
556
  notifyMobileUserIds: argv.mobileUserIds,
557
+ excludeContactNames: argv.excludeContacts,
504
558
  notifyTeams: Boolean(argv.teams),
505
559
  enabled: true,
506
560
  };
@@ -590,7 +644,9 @@ async function updateAlertRule(argv) {
590
644
  notifyEmails: argv.emails ?? target.notifyEmails,
591
645
  notifyPhoneNumbers: argv.sms ?? target.notifyPhoneNumbers,
592
646
  notifyWebhookUrl: argv.webhook ?? target.notifyWebhookUrl,
647
+ notifyTeams: argv.teams !== undefined ? Boolean(argv.teams) : target.notifyTeams,
593
648
  notifyMobileUserIds: argv.mobileUserIds ?? target.notifyMobileUserIds,
649
+ excludeContactNames: argv.excludeContacts ?? target.excludeContactNames,
594
650
  notifyTeams: argv.teams !== undefined ? Boolean(argv.teams) : Boolean(target.notifyTeams),
595
651
  enabled: argv.enabled !== undefined ? argv.enabled : target.enabled,
596
652
  };
@@ -598,6 +654,10 @@ async function updateAlertRule(argv) {
598
654
  // HIGH: same "fires into the void" guard as create -- an update that strips
599
655
  // every notification channel (or a rule that already had none, being
600
656
  // re-enabled without adding one) silently produces an alert nobody sees.
657
+ const hasDefaults = await fetchHasGlobalDefaultContacts(creds, msgBase);
658
+ if (!hasAnyRecipient({ emails: body.notifyEmails, sms: body.notifyPhoneNumbers, webhook: body.notifyWebhookUrl, teams: body.notifyTeams, mobileUserIds: body.notifyMobileUserIds, hasGlobalDefaultContacts: hasDefaults })
659
+ && !argv.noRecipientsOk) {
660
+ console.error('This update would leave the rule with no notification channel (--emails, --sms, --webhook, --teams, or --mobile-user-ids), and no global-default contact exists to fall back to — it would fire but notify no one.');
601
661
  if (!hasAnyRecipient({ emails: body.notifyEmails, sms: body.notifyPhoneNumbers, webhook: body.notifyWebhookUrl, mobileUserIds: body.notifyMobileUserIds, teams: body.notifyTeams })
602
662
  && !argv.noRecipientsOk) {
603
663
  console.error('This update would leave the rule with no notification channel (--emails, --sms, --webhook, --teams, or --mobile-user-ids) — it would fire but notify no one.');
@@ -704,6 +764,198 @@ async function deleteAlertRule(argv) {
704
764
  console.log(`\nDeleted rule ${argv.id}.\n`);
705
765
  }
706
766
 
767
+ // ── Alert contacts ───────────────────────────────────────────────────────────
768
+
769
+ function printAlertContact(c) {
770
+ const scope = c.isGlobalDefault ? `${CYN}global default${RESET}` : `${DIM}not global${RESET}`;
771
+ const bits = [];
772
+ if (c.phoneNumber) bits.push(`SMS ${c.phoneNumber}`);
773
+ if (c.email) bits.push(`email ${c.email}`);
774
+ console.log(`${DIM}[${c.id}]${RESET} ${c.name}${c.displayName ? ` (${c.displayName})` : ''} ${scope}`);
775
+ console.log(` ${bits.join(' ') || '(no phone/email set)'}`);
776
+ }
777
+
778
+ async function listAlertContacts(argv) {
779
+ const creds = await getCredentials();
780
+ await requireAdmin(creds);
781
+ const msgBase = await getMsgBase(argv);
782
+
783
+ const res = await fetch(`${msgBase}/superadmin/log-alerts/contacts`, {
784
+ headers: { Authorization: `Bearer ${creds.velaroToken}`, 'X-Internal-SiteId': String(creds.siteId) },
785
+ signal: AbortSignal.timeout(15000),
786
+ });
787
+ if (!res.ok) {
788
+ console.error(`HTTP ${res.status}: ${await res.text().catch(() => '')}`);
789
+ process.exit(1);
790
+ }
791
+ const contacts = await res.json();
792
+ if (!Array.isArray(contacts) || !contacts.length) {
793
+ console.log('No alert contacts yet. Add one with: velaro logs contacts add --name alex --phone +1... --email alex@velaro.com');
794
+ return;
795
+ }
796
+ console.log(`\n${DIM}${contacts.length} alert contact(s)${RESET}\n`);
797
+ for (const c of contacts) printAlertContact(c);
798
+ console.log('');
799
+ }
800
+
801
+ async function addAlertContact(argv) {
802
+ const creds = await getCredentials();
803
+ await requireAdmin(creds);
804
+ const msgBase = await getMsgBase(argv);
805
+
806
+ if (!argv.name) {
807
+ console.error('--name is required. Example: velaro logs contacts add --name alex --phone +14436295872 --email alex@velaro.com');
808
+ process.exit(1);
809
+ }
810
+ if (!argv.phone && !argv.email) {
811
+ console.error('At least one of --phone or --email is required.');
812
+ process.exit(1);
813
+ }
814
+
815
+ const body = {
816
+ name: argv.name,
817
+ displayName: argv.displayName,
818
+ phoneNumber: argv.phone,
819
+ email: argv.email,
820
+ isGlobalDefault: argv.global,
821
+ };
822
+
823
+ if (!argv.yes) {
824
+ console.log(`\n${DIM}About to create this contact (dry run — nothing sent yet):${RESET}\n`);
825
+ printAlertContact({ ...body, id: '(new)' });
826
+ console.log(`\nRe-run with --yes to actually create it.\n`);
827
+ return;
828
+ }
829
+
830
+ const res = await fetch(`${msgBase}/superadmin/log-alerts/contacts`, {
831
+ method: 'POST',
832
+ headers: {
833
+ Authorization: `Bearer ${creds.velaroToken}`,
834
+ 'X-Internal-SiteId': String(creds.siteId),
835
+ 'Content-Type': 'application/json',
836
+ },
837
+ body: JSON.stringify(body),
838
+ signal: AbortSignal.timeout(15000),
839
+ });
840
+ if (!res.ok) {
841
+ console.error(`HTTP ${res.status}: ${await res.text().catch(() => '')}`);
842
+ process.exit(1);
843
+ }
844
+ const data = await res.json();
845
+ console.log(`\n${CYN}✓${RESET} Created contact [${data.id}] "${data.name}"\n`);
846
+ printAlertContact(data);
847
+ console.log('');
848
+ }
849
+
850
+ async function updateAlertContact(argv) {
851
+ const creds = await getCredentials();
852
+ await requireAdmin(creds);
853
+ const msgBase = await getMsgBase(argv);
854
+
855
+ if (!argv.id) {
856
+ console.error('Contact id is required. Example: velaro logs contacts update 1 --phone +1...');
857
+ process.exit(1);
858
+ }
859
+
860
+ const listRes = await fetch(`${msgBase}/superadmin/log-alerts/contacts`, {
861
+ headers: { Authorization: `Bearer ${creds.velaroToken}`, 'X-Internal-SiteId': String(creds.siteId) },
862
+ signal: AbortSignal.timeout(15000),
863
+ });
864
+ if (!listRes.ok) {
865
+ console.error(`HTTP ${listRes.status}: ${await listRes.text().catch(() => '')}`);
866
+ process.exit(1);
867
+ }
868
+ const contacts = await listRes.json().catch(() => []);
869
+ const target = Array.isArray(contacts) ? contacts.find(c => String(c.id) === String(argv.id)) : null;
870
+ if (!target) {
871
+ console.error(`No contact with id ${argv.id} found. Run 'velaro logs contacts list' to see current ids.`);
872
+ process.exit(1);
873
+ }
874
+
875
+ const body = {
876
+ ...target,
877
+ displayName: argv.displayName ?? target.displayName,
878
+ phoneNumber: argv.phone ?? target.phoneNumber,
879
+ email: argv.email ?? target.email,
880
+ isGlobalDefault: argv.global !== undefined ? argv.global : target.isGlobalDefault,
881
+ };
882
+
883
+ if (!argv.yes) {
884
+ console.log(`\n${DIM}Current contact:${RESET}\n`);
885
+ printAlertContact(target);
886
+ console.log(`\n${DIM}About to update to (dry run — nothing sent yet):${RESET}\n`);
887
+ printAlertContact(body);
888
+ console.log(`\nRe-run with --yes to actually apply this update.\n`);
889
+ return;
890
+ }
891
+
892
+ const res = await fetch(`${msgBase}/superadmin/log-alerts/contacts/${argv.id}`, {
893
+ method: 'PUT',
894
+ headers: {
895
+ Authorization: `Bearer ${creds.velaroToken}`,
896
+ 'X-Internal-SiteId': String(creds.siteId),
897
+ 'Content-Type': 'application/json',
898
+ },
899
+ body: JSON.stringify(body),
900
+ signal: AbortSignal.timeout(15000),
901
+ });
902
+ if (!res.ok) {
903
+ console.error(`HTTP ${res.status}: ${await res.text().catch(() => '')}`);
904
+ process.exit(1);
905
+ }
906
+ const data = await res.json();
907
+ console.log(`\n${CYN}✓${RESET} Updated contact [${data.id}] "${data.name}"\n`);
908
+ printAlertContact(data);
909
+ console.log('');
910
+ }
911
+
912
+ async function removeAlertContact(argv) {
913
+ const creds = await getCredentials();
914
+ await requireAdmin(creds);
915
+ const msgBase = await getMsgBase(argv);
916
+
917
+ if (!argv.id) {
918
+ console.error('Contact id is required. Example: velaro logs contacts remove 1');
919
+ process.exit(1);
920
+ }
921
+
922
+ const listRes = await fetch(`${msgBase}/superadmin/log-alerts/contacts`, {
923
+ headers: { Authorization: `Bearer ${creds.velaroToken}`, 'X-Internal-SiteId': String(creds.siteId) },
924
+ signal: AbortSignal.timeout(15000),
925
+ });
926
+ let target = null;
927
+ if (listRes.ok) {
928
+ const contacts = await listRes.json().catch(() => []);
929
+ target = Array.isArray(contacts) ? contacts.find(c => String(c.id) === String(argv.id)) : null;
930
+ }
931
+
932
+ if (target) {
933
+ console.log(`\n${DIM}About to delete this contact:${RESET}\n`);
934
+ printAlertContact(target);
935
+ if (target.isGlobalDefault) {
936
+ console.log(`${YEL}This is a global-default contact -- removing it stops it from covering any rule that leaves --emails/--sms blank.${RESET}`);
937
+ }
938
+ } else {
939
+ console.log(`\n${YEL}Could not fetch contact ${argv.id} to preview it (it may already be gone) — proceeding on id alone.${RESET}`);
940
+ }
941
+
942
+ if (!argv.yes) {
943
+ console.log(`\nRe-run with --yes to actually delete it.\n`);
944
+ return;
945
+ }
946
+
947
+ const res = await fetch(`${msgBase}/superadmin/log-alerts/contacts/${argv.id}`, {
948
+ method: 'DELETE',
949
+ headers: { Authorization: `Bearer ${creds.velaroToken}`, 'X-Internal-SiteId': String(creds.siteId) },
950
+ signal: AbortSignal.timeout(15000),
951
+ });
952
+ if (!res.ok) {
953
+ console.error(`HTTP ${res.status}: ${await res.text().catch(() => '')}`);
954
+ process.exit(1);
955
+ }
956
+ console.log(`\nDeleted contact ${argv.id}.\n`);
957
+ }
958
+
707
959
  // ── command export ────────────────────────────────────────────────────────────
708
960
 
709
961
  export const logsCommand = {
@@ -797,6 +1049,11 @@ export const logsCommand = {
797
1049
  .option('window-minutes', { describe: 'Rolling window in minutes', type: 'number' })
798
1050
  .option('threshold', { describe: 'Fire when count >= this within the window', type: 'number' })
799
1051
  .option('cooldown-minutes', { describe: 'Minimum minutes between re-fires', type: 'number' })
1052
+ .option('emails', { describe: 'Comma-separated notification email addresses. Leave blank to fall back to global-default contacts (see: velaro logs contacts list)', type: 'string' })
1053
+ .option('sms', { describe: 'Comma-separated E.164 phone numbers for SMS. Leave blank to fall back to global-default contacts', type: 'string' })
1054
+ .option('webhook', { describe: 'Custom webhook URL for a generic JSON POST on fire (Slack, PagerDuty, etc). NOT for Teams -- use --teams instead, which posts a properly formatted Adaptive Card through Velaro\'s existing Teams pipeline', type: 'string' })
1055
+ .option('teams', { describe: 'Post fired/resolved notifications to the velaro-alerts-to-teams Teams channel', type: 'boolean' })
1056
+ .option('exclude-contacts', { describe: 'Comma-separated global-default contact names to exclude from this rule (e.g. "andrea") -- only relevant when --emails/--sms are left blank', type: 'string' })
800
1057
  .option('emails', { describe: 'Comma-separated notification email addresses', type: 'string' })
801
1058
  .option('sms', { describe: 'Comma-separated E.164 phone numbers for SMS', type: 'string' })
802
1059
  .option('webhook', { describe: 'Webhook URL (Slack/Teams) to POST on fire', type: 'string' })
@@ -819,6 +1076,11 @@ export const logsCommand = {
819
1076
  .option('window-minutes', { describe: 'Rolling window in minutes', type: 'number' })
820
1077
  .option('threshold', { describe: 'Fire when count >= this within the window', type: 'number' })
821
1078
  .option('cooldown-minutes', { describe: 'Minimum minutes between re-fires', type: 'number' })
1079
+ .option('emails', { describe: 'Comma-separated notification email addresses. Leave blank to fall back to global-default contacts (see: velaro logs contacts list)', type: 'string' })
1080
+ .option('sms', { describe: 'Comma-separated E.164 phone numbers for SMS. Leave blank to fall back to global-default contacts', type: 'string' })
1081
+ .option('webhook', { describe: 'Custom webhook URL for a generic JSON POST on fire (Slack, PagerDuty, etc). NOT for Teams -- use --teams instead, which posts a properly formatted Adaptive Card through Velaro\'s existing Teams pipeline', type: 'string' })
1082
+ .option('teams', { describe: 'Post fired/resolved notifications to the velaro-alerts-to-teams Teams channel', type: 'boolean' })
1083
+ .option('exclude-contacts', { describe: 'Comma-separated global-default contact names to exclude from this rule (e.g. "andrea") -- only relevant when --emails/--sms are left blank', type: 'string' })
822
1084
  .option('emails', { describe: 'Comma-separated notification email addresses', type: 'string' })
823
1085
  .option('sms', { describe: 'Comma-separated E.164 phone numbers for SMS', type: 'string' })
824
1086
  .option('webhook', { describe: 'Webhook URL (Slack/Teams) to POST on fire', type: 'string' })
@@ -849,5 +1111,53 @@ export const logsCommand = {
849
1111
  })
850
1112
  .demandCommand(1, 'Specify a subcommand: list, suggest, create, update, toggle, or delete'),
851
1113
  })
852
- .demandCommand(1, 'Specify a subcommand: search, tail, diagnose, digest-recipients, set-digest-recipients, or alerts'),
1114
+ .command({
1115
+ command: 'contacts <subcommand>',
1116
+ describe: 'Manage global-default notification contacts for alert rules (Velaro staff only)',
1117
+ builder: y => y
1118
+ .command({
1119
+ command: 'list',
1120
+ describe: 'List all alert contacts',
1121
+ builder: yy => yy.option('env', { describe: 'staging or prod', type: 'string' }),
1122
+ handler: listAlertContacts,
1123
+ })
1124
+ .command({
1125
+ command: 'add',
1126
+ describe: 'Add a new alert contact',
1127
+ builder: yy => yy
1128
+ .option('name', { describe: 'Short unique handle, e.g. "alex" -- used in --exclude-contacts', type: 'string', demandOption: true })
1129
+ .option('display-name', { describe: 'Human-readable label, e.g. "Alex Bloom"', type: 'string' })
1130
+ .option('phone', { describe: 'E.164 phone number for SMS, e.g. +14436295872', type: 'string' })
1131
+ .option('email', { describe: 'Email address', type: 'string' })
1132
+ .option('global', { describe: 'Include this contact in every rule that leaves --emails/--sms blank (the normal case)', type: 'boolean', default: true })
1133
+ .option('yes', { describe: 'Skip the dry-run preview and actually create the contact', type: 'boolean', default: false })
1134
+ .option('env', { describe: 'staging or prod', type: 'string' }),
1135
+ handler: addAlertContact,
1136
+ })
1137
+ .command({
1138
+ command: 'update <id>',
1139
+ describe: 'Change an existing contact\'s phone/email/global-default flag',
1140
+ builder: yy => yy
1141
+ .positional('id', { describe: 'Contact id (from: velaro logs contacts list)', type: 'number' })
1142
+ .option('display-name', { describe: 'Human-readable label', type: 'string' })
1143
+ .option('phone', { describe: 'E.164 phone number for SMS', type: 'string' })
1144
+ .option('email', { describe: 'Email address', type: 'string' })
1145
+ .option('global', { describe: 'Include/exclude this contact from global defaults', type: 'boolean' })
1146
+ .option('yes', { describe: 'Skip the dry-run preview and actually apply the update', type: 'boolean', default: false })
1147
+ .option('env', { describe: 'staging or prod', type: 'string' }),
1148
+ handler: updateAlertContact,
1149
+ })
1150
+ .command({
1151
+ command: 'remove <id>',
1152
+ describe: 'Delete an alert contact',
1153
+ builder: yy => yy
1154
+ .positional('id', { describe: 'Contact id', type: 'number' })
1155
+ .option('yes', { describe: 'Skip the confirmation preview and actually delete', type: 'boolean', default: false })
1156
+ .option('env', { describe: 'staging or prod', type: 'string' }),
1157
+ handler: removeAlertContact,
1158
+ })
1159
+ .demandCommand(1, 'Specify a subcommand: list, add, update, or remove'),
1160
+ })
1161
+ .command(logsInsightsCommand)
1162
+ .demandCommand(1, 'Specify a subcommand: search, tail, diagnose, digest-recipients, set-digest-recipients, alerts, contacts, or insights'),
853
1163
  };
@@ -40,6 +40,21 @@ async function listHandler() {
40
40
  // ── test ──────────────────────────────────────────────────────────────────────
41
41
 
42
42
  async function testHandler(argv) {
43
+ if (argv.id) {
44
+ try {
45
+ const r = await request('POST', `/Magento/Configs/${encodeURIComponent(argv.id)}/Test`);
46
+ if (r && r.valid === false) throw new Error(r.error);
47
+ console.log(`
48
+ ${GRN}✓ Connected${RST} to "${r.storeName}" (${r.edition} ${r.version})
49
+ `);
50
+ } catch (e) {
51
+ console.error(`
52
+ ${RED}✗ Connection failed: ${e.message}${RST}
53
+ `);
54
+ process.exitCode = 1;
55
+ }
56
+ return;
57
+ }
43
58
  if (!argv.url || !argv.token) {
44
59
  console.error(`${RED}✗ --url and --token are required${RST}`);
45
60
  process.exitCode = 1;
@@ -189,6 +204,7 @@ export const magentoCommand = {
189
204
  command: 'test',
190
205
  describe: 'Test a Magento store connection',
191
206
  builder: yy => yy
207
+ .option('id', { describe: 'Test a connected store by config id (stored token)', type: 'number' })
192
208
  .option('url', { describe: 'Magento store URL', type: 'string' })
193
209
  .option('token', { describe: 'Admin API bearer token', type: 'string' }),
194
210
  handler: testHandler,
@@ -31,7 +31,12 @@ const mcpKeyListCommand = {
31
31
  command: 'list',
32
32
  describe: 'List MCP API keys for your site',
33
33
  handler: runCommand(async () => {
34
- const keys = normalizeMcpKeysResponse(await get('/McpApiKeys'));
34
+ const response = await get('/McpApiKeys');
35
+ if (response?.entitled === false) {
36
+ console.log('MCP keys are included with the Professional plan and above. Contact support@velaro.com to upgrade.');
37
+ return;
38
+ }
39
+ const keys = normalizeMcpKeysResponse(response);
35
40
  if (!keys.length) { console.log('No MCP keys found.'); return; }
36
41
  console.log(`Found ${keys.length} key(s):\n`);
37
42
  for (const k of keys) {
@@ -101,6 +106,21 @@ const mcpKeyInstallCommand = {
101
106
  const result = await request('POST', '/McpApiKeys', payload, env);
102
107
  const rawKey = result.rawKey;
103
108
 
109
+ // Claude Code reads user-scope MCP server definitions from the
110
+ // top-level "mcpServers" key in ~/.claude.json — NOT from
111
+ // ~/.claude/settings.json (that file is for permissions/hooks/env,
112
+ // and any mcpServers entry written there is silently ignored).
113
+ // prod → entry named "velaro"
114
+ // staging → entry named "velaro-staging"
115
+ const serverName = env === 'prod' ? 'velaro' : `velaro-${env}`;
116
+ const claudeJsonPath = path.join(os.homedir(), '.claude.json');
117
+ let claudeJson = {};
118
+ if (fs.existsSync(claudeJsonPath)) {
119
+ try { claudeJson = JSON.parse(fs.readFileSync(claudeJsonPath, 'utf8')); } catch {}
120
+ }
121
+
122
+ claudeJson.mcpServers = claudeJson.mcpServers || {};
123
+ claudeJson.mcpServers[serverName] = {
104
124
  // Claude Code reads MCP server definitions from the top-level "mcpServers" key
105
125
  // in ~/.claude.json (NOT ~/.claude/settings.json — that file has no effect here).
106
126
  // This is the same shape `claude mcp add` itself writes.
@@ -132,6 +152,8 @@ const mcpKeyInstallCommand = {
132
152
  },
133
153
  };
134
154
 
155
+ fs.mkdirSync(path.dirname(claudeJsonPath), { recursive: true });
156
+ fs.writeFileSync(claudeJsonPath, JSON.stringify(claudeJson, null, 2));
135
157
  // Claude Code itself writes ~/.claude.json continuously (history, project state),
136
158
  // so this must never be a plain in-place write: write to a temp file and rename,
137
159
  // which is atomic on the same filesystem — a crash or concurrent write mid-save
@@ -9,7 +9,7 @@
9
9
  * (velaro-messaging). Site is resolved server-side from the auth context — never caller-supplied.
10
10
  */
11
11
 
12
- import { messagingGet, messagingPost } from '../api.js';
12
+ import { messagingGet, messagingPost, messagingDel } from '../api.js';
13
13
 
14
14
  const GRN = '\x1b[32m';
15
15
  const DIM = '\x1b[2m';
@@ -84,6 +84,42 @@ async function m2mVerifyHandler(argv) {
84
84
  printChecks(await messagingPost('/NetSuite/M2M/Verify', { id: Number(argv.configId) }));
85
85
  }
86
86
 
87
+ async function suiteappHandler(argv) {
88
+ const includeKey = argv['include-key'] !== false;
89
+ const r = await messagingGet(`/api/v1/CrmEmbed/suiteapp-package?includeKey=${includeKey}&format=json`);
90
+ const d = r?.data ?? r;
91
+ if (!d?.contentBase64) throw new Error('SuiteApp package was not returned.');
92
+ const { writeFileSync } = await import('node:fs');
93
+ const out = argv.out || d.fileName || 'velaro-netsuite-screenpop-suiteapp.zip';
94
+ writeFileSync(out, Buffer.from(d.contentBase64, 'base64'));
95
+ console.log(`\n ${GRN}Saved${RST} ${out}`);
96
+ if (d.keyIncluded) console.log(' Your Velaro key is already inside: no NetSuite API Secret, nothing to paste. Keep the zip private.\n');
97
+ else console.log(` Key-less zip. In NetSuite create an API Secret: type ID ${d.secretFormId} (NetSuite adds the prefix, giving ${d.secretFullId}), paste your crm_embed_ key as Password.\n`);
98
+ }
99
+
100
+ async function configDeleteHandler(argv) {
101
+ await messagingDel(`/Netsuite/Integrations/${Number(argv.configId)}`);
102
+ console.log(`
103
+ ${GRN}Deleted${RST} NetSuite config ${argv.configId}.
104
+ `);
105
+ }
106
+
107
+ async function m2mClearLegacyHandler(argv) {
108
+ const r = await messagingPost('/NetSuite/M2M/ClearLegacy', { id: Number(argv.configId) });
109
+ console.log(`
110
+ ${GRN}Legacy consumer secret/token cleared${RST} for config ${r?.id ?? argv.configId}.
111
+ `);
112
+ }
113
+
114
+ async function embedStatusHandler() {
115
+ const r = await messagingGet('/api/v1/CrmEmbed/embed-status');
116
+ const d = r?.data ?? r;
117
+ console.log(`\n${BOLD}NetSuite call-pop (CRM embed) status${RST}`);
118
+ if (!d?.hasEmbedKey) { console.log(` ${DIM}No crm_embed API key set for this site.${RST}\n`); return; }
119
+ if (d.connected) console.log(` ${GRN}SuiteApp connected${RST}, last polled ${d.lastPolledAt}\n`);
120
+ else console.log(` ${DIM}Embed key exists; SuiteApp not connected (last polled: ${d.lastPolledAt ?? 'never'})${RST}\n`);
121
+ }
122
+
87
123
  export const netsuiteCommand = {
88
124
  command: 'netsuite <subcommand>',
89
125
  describe: 'NetSuite integration setup (click-to-dial / CTI)',
@@ -111,6 +147,30 @@ export const netsuiteCommand = {
111
147
  .strict(),
112
148
  handler: () => {},
113
149
  })
150
+ .command({
151
+ command: 'suiteapp',
152
+ describe: 'Download the screen-pop SuiteApp zip (key included by default: no API Secret to create)',
153
+ builder: a => a.option('include-key', { type: 'boolean', default: true }).option('out', { type: 'string' }),
154
+ handler: suiteappHandler,
155
+ })
156
+ .command({
157
+ command: 'config <action>',
158
+ describe: 'NetSuite connection (config) management',
159
+ builder: yy => yy
160
+ .command({ command: 'delete <configId>', describe: 'Delete a NetSuite connection from the site', handler: configDeleteHandler })
161
+ .demandCommand(1, 'Specify a config action: delete.')
162
+ .strict(),
163
+ handler: () => {},
164
+ })
165
+ .command({
166
+ command: 'embed <action>',
167
+ describe: 'NetSuite call-pop (CRM embed) SuiteApp',
168
+ builder: yy => yy
169
+ .command({ command: 'status', describe: 'Is the crm_embed key set, and last heartbeat', handler: embedStatusHandler })
170
+ .demandCommand(1, 'Specify an embed action: status.')
171
+ .strict(),
172
+ handler: () => {},
173
+ })
114
174
  .command({
115
175
  command: 'm2m <action>',
116
176
  describe: 'NetSuite machine-to-machine (OAuth 2.0 Client Credentials) setup',
@@ -132,7 +192,12 @@ export const netsuiteCommand = {
132
192
  describe: 'Run the per-check verification (token, REST, customer read)',
133
193
  handler: m2mVerifyHandler,
134
194
  })
135
- .demandCommand(1, 'Specify an m2m action: init, save, or verify.')
195
+ .command({
196
+ command: 'clear-legacy <configId>',
197
+ describe: 'Disconnect the old consumer-key/token credentials (requires M2M saved)',
198
+ handler: m2mClearLegacyHandler,
199
+ })
200
+ .demandCommand(1, 'Specify an m2m action: init, save, verify, or clear-legacy.')
136
201
  .strict(),
137
202
  handler: () => {},
138
203
  })
@@ -1,4 +1,12 @@
1
- import { messagingGet, messagingPost } from '../api.js';
1
+ import { messagingGet, messagingPost, messagingPut } from '../api.js';
2
+
3
+ // Alert routing: Channel x Handling x Kind (+ ivrBotAlerts). Backend: GET/PUT /api/v1/alert-routing.
4
+ const ROUTING_GROUPS = {
5
+ channel: ['channels', ['web', 'sms', 'whatsapp', 'appleMessages', 'email', 'phone']],
6
+ handling: ['handling', ['mine', 'unassigned', 'bot', 'teammates']],
7
+ kind: ['kinds', ['conversations', 'tickets', 'calls', 'visitors']],
8
+ };
9
+ const csv = (v) => String(v).split(',').map((x) => x.trim()).filter(Boolean);
2
10
 
3
11
  const FLAGS = [
4
12
  ['new-conversations', 'newConversations', 'NewConversations'],
@@ -24,6 +32,15 @@ const getCommand = {
24
32
  }
25
33
  console.log(` ${'sticky-duration'.padEnd(28)} ${s.stickyDuration}ms`);
26
34
  console.log(` ${'sms-alert-phone'.padEnd(28)} ${s.smsAlertPhone ?? '(none)'}`);
35
+ try {
36
+ const r = (await messagingGet('/api/v1/alert-routing')).data;
37
+ console.log('\n\x1b[1mAlert routing\x1b[0m (alert fires only if channel, handling and kind all match)');
38
+ for (const [label, [group]] of Object.entries(ROUTING_GROUPS)) {
39
+ const on = Object.entries(r[group] ?? {}).filter(([, v]) => v).map(([k]) => k);
40
+ console.log(` ${label.padEnd(28)} ${on.join(', ') || '(none)'}`);
41
+ }
42
+ console.log(` ${'ivr-bot-alerts'.padEnd(28)} ${r.ivrBotAlerts ? '\x1b[32mon\x1b[0m' : '\x1b[90moff\x1b[0m'}`);
43
+ } catch { /* older server without alert routing */ }
27
44
  console.log('');
28
45
  },
29
46
  };
@@ -35,6 +52,10 @@ const setCommand = {
35
52
  for (const [flag] of FLAGS) {
36
53
  y.option(flag, { type: 'boolean', describe: `Turn ${flag.replace(/-/g, ' ')} on/off` });
37
54
  }
55
+ y.option('channel', { type: 'string', describe: 'Channels to alert on, comma list of: web,sms,whatsapp,appleMessages,email,phone (others turned off)' });
56
+ y.option('handling', { type: 'string', describe: 'Handling to alert on, comma list of: mine,unassigned,bot,teammates (others turned off)' });
57
+ y.option('kind', { type: 'string', describe: 'Kinds to alert on, comma list of: conversations,tickets,calls,visitors (others turned off)' });
58
+ y.option('ivr-bot-alerts', { type: 'boolean', describe: 'Alert for inbound IVR / phone bot chats' });
38
59
  y.option('sms-alert-phone', { type: 'string', describe: 'Phone number for SMS alerts (E.164, e.g. +14155551234)' });
39
60
  return y;
40
61
  },
@@ -47,6 +68,18 @@ const setCommand = {
47
68
  if (argv['sms-alert-phone'] !== undefined) current.smsAlertPhone = argv['sms-alert-phone'];
48
69
 
49
70
  await messagingPost('/NotificationSettings', current);
71
+
72
+ const patch = {};
73
+ for (const [flag, [group, keys]] of Object.entries(ROUTING_GROUPS)) {
74
+ if (argv[flag] === undefined) continue;
75
+ const wanted = csv(argv[flag]);
76
+ const bad = wanted.filter((k) => !keys.includes(k));
77
+ if (bad.length) { console.error(`Unknown ${flag}: ${bad.join(', ')}. Valid: ${keys.join(', ')}`); process.exit(1); }
78
+ patch[group] = Object.fromEntries(keys.map((k) => [k, wanted.includes(k)]));
79
+ }
80
+ if (argv['ivr-bot-alerts'] !== undefined) patch.ivrBotAlerts = argv['ivr-bot-alerts'];
81
+ if (Object.keys(patch).length) await messagingPut('/api/v1/alert-routing', patch);
82
+
50
83
  console.log('\x1b[32m✓ Notification settings updated.\x1b[0m Run `velaro notifications get` to review.');
51
84
  },
52
85
  };