@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.
- package/README.md +19 -0
- package/bin/velaro.js +14 -0
- package/lib/api.js +2 -1
- package/lib/apple-pay-presets.js +66 -0
- package/lib/apple-pay-presets.test.js +61 -0
- package/lib/apple-pay.js +32 -0
- package/lib/apple-pay.test.js +32 -0
- package/lib/avatar-switch.js +42 -0
- package/lib/avatar-switch.test.js +49 -0
- package/lib/build-info.js +28 -0
- package/lib/build-info.test.js +24 -0
- package/lib/commands/apple-pay-preset.js +65 -0
- package/lib/commands/apple-pay.js +73 -0
- package/lib/commands/avatar.js +60 -2
- package/lib/commands/billing-automation.js +184 -0
- package/lib/commands/copilotstudio.js +14 -1
- package/lib/commands/enterprise-reports.js +1 -0
- package/lib/commands/entitlement.js +79 -1
- package/lib/commands/env.js +1 -1
- package/lib/commands/focus.js +1 -1
- package/lib/commands/index.js +38 -2
- package/lib/commands/logs-insights.js +342 -0
- package/lib/commands/logs.js +310 -11
- package/lib/commands/magento.js +16 -0
- package/lib/commands/mcp-key.js +7 -1
- package/lib/commands/netsuite.js +67 -2
- package/lib/commands/notifications.js +34 -1
- package/lib/commands/ops.js +23 -54
- package/lib/commands/report-backfill.js +23 -5
- package/lib/commands/report.js +77 -14
- package/lib/commands/salesforce.js +101 -0
- package/lib/commands/shopify.js +84 -0
- package/lib/commands/signature.js +79 -0
- package/lib/commands/site-builder.js +30 -0
- package/lib/commands/support.js +22 -0
- package/lib/commands/teams.js +11 -1
- package/lib/commands/workflow.js +157 -2
- package/lib/commands/zoom-phone.js +15 -0
- package/lib/enterprise-reports.test.js +1 -1
- package/lib/entitlement-report-packages.test.js +47 -0
- package/lib/logs-insights.test.js +146 -0
- package/lib/mcp-key.test.js +11 -0
- package/lib/messaging-routes.test.js +10 -1
- package/lib/oauth.js +14 -2
- package/lib/report-range.js +21 -0
- package/lib/report-range.test.js +38 -0
- package/lib/shopify-write-policy.test.js +34 -0
- package/lib/signature-policy.js +70 -0
- package/lib/signature-policy.test.js +48 -0
- package/package.json +1 -1
package/lib/commands/logs.js
CHANGED
|
@@ -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) {
|
|
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,49 @@ function parsePositiveIntFlag(flagName, rawValue, { max } = {}) {
|
|
|
343
351
|
return { ok: true, value: n };
|
|
344
352
|
}
|
|
345
353
|
|
|
346
|
-
// A rule with none of the
|
|
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
|
|
349
|
-
// also cover webhook, which that component's check omits).
|
|
350
|
-
|
|
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 }) {
|
|
351
365
|
return Boolean(
|
|
352
366
|
(emails && String(emails).trim()) ||
|
|
353
367
|
(sms && String(sms).trim()) ||
|
|
354
368
|
(webhook && String(webhook).trim()) ||
|
|
369
|
+
teams ||
|
|
355
370
|
(mobileUserIds && String(mobileUserIds).trim()) ||
|
|
356
|
-
|
|
371
|
+
// Blank emails AND blank sms both fall back to global defaults together (see
|
|
372
|
+
// ResolveRecipients) -- if there's at least one global-default contact with a phone or
|
|
373
|
+
// email, a rule with both blank is NOT actually silent, even though neither field is set.
|
|
374
|
+
((!emails || !String(emails).trim()) && (!sms || !String(sms).trim()) && hasGlobalDefaultContacts)
|
|
357
375
|
);
|
|
358
376
|
}
|
|
359
377
|
|
|
378
|
+
// Fetches whether at least one global-default AlertContact exists with a phone or email --
|
|
379
|
+
// used only to soften the "fires into the void" guard above, never cached across CLI
|
|
380
|
+
// invocations, so a stale answer can't cause a false pass. Returns false (fail toward the
|
|
381
|
+
// stricter behavior) on any fetch error rather than silently skipping the guard.
|
|
382
|
+
async function fetchHasGlobalDefaultContacts(creds, msgBase) {
|
|
383
|
+
try {
|
|
384
|
+
const res = await fetch(`${msgBase}/superadmin/log-alerts/contacts`, {
|
|
385
|
+
headers: { Authorization: `Bearer ${creds.velaroToken}`, 'X-Internal-SiteId': String(creds.siteId) },
|
|
386
|
+
signal: AbortSignal.timeout(15000),
|
|
387
|
+
});
|
|
388
|
+
if (!res.ok) return false;
|
|
389
|
+
const contacts = await res.json();
|
|
390
|
+
return Array.isArray(contacts) && contacts.some(c =>
|
|
391
|
+
c.isGlobalDefault && ((c.phoneNumber && c.phoneNumber.trim()) || (c.email && c.email.trim())));
|
|
392
|
+
} catch {
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
360
397
|
function printAlertRule(r) {
|
|
361
398
|
const state = r.enabled === false ? `${DIM}paused${RESET}` : `${CYN}active${RESET}`;
|
|
362
399
|
const bits = [`>=${r.thresholdCount} in ${r.windowMinutes}m`];
|
|
@@ -365,9 +402,12 @@ function printAlertRule(r) {
|
|
|
365
402
|
if (r.integration) bits.push(`tag=${r.integration}`);
|
|
366
403
|
if (r.siteId) bits.push(`site=${r.siteId}`);
|
|
367
404
|
if (r.notifyPhoneNumbers) bits.push('SMS');
|
|
405
|
+
else if (!r.notifyEmails) bits.push('SMS+email (global defaults)');
|
|
368
406
|
if (r.notifyEmails) bits.push('email');
|
|
369
407
|
if (r.notifyWebhookUrl) bits.push('webhook');
|
|
408
|
+
if (r.notifyTeams) bits.push('Teams');
|
|
370
409
|
if (r.notifyMobileUserIds) bits.push('mobile push');
|
|
410
|
+
if (r.excludeContactNames) bits.push(`excl:${r.excludeContactNames}`);
|
|
371
411
|
if (r.notifyTeams) bits.push('Teams');
|
|
372
412
|
bits.push(`cooldown ${r.minRefireMinutes}m`);
|
|
373
413
|
console.log(`${DIM}[${r.id}]${RESET} ${r.name} ${state}`);
|
|
@@ -481,11 +521,15 @@ async function createAlertRule(argv) {
|
|
|
481
521
|
}
|
|
482
522
|
const [thresholdCheck, windowCheck, cooldownCheck] = checks;
|
|
483
523
|
|
|
484
|
-
// HIGH: a rule with zero notification channels fires into the void.
|
|
485
|
-
|
|
524
|
+
// HIGH: a rule with zero notification channels fires into the void. A blank --emails/--sms
|
|
525
|
+
// may still be covered by global-default AlertContacts (see hasAnyRecipient docs), so check
|
|
526
|
+
// those before failing.
|
|
527
|
+
const hasDefaults = await fetchHasGlobalDefaultContacts(creds, msgBase);
|
|
528
|
+
if (!hasAnyRecipient({ emails: argv.emails, sms: argv.sms, webhook: argv.webhook, teams: argv.teams, mobileUserIds: argv.mobileUserIds, hasGlobalDefaultContacts: hasDefaults })
|
|
486
529
|
&& !argv.noRecipientsOk) {
|
|
487
|
-
console.error('No notification channel given (--emails, --sms, --webhook, --teams, or --mobile-user-ids) — this rule would fire but notify no one.');
|
|
530
|
+
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.');
|
|
488
531
|
console.error('If that\'s intentional (e.g. you only want it visible in Alert History), pass --no-recipients-ok to proceed anyway.');
|
|
532
|
+
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
533
|
process.exit(1);
|
|
490
534
|
}
|
|
491
535
|
|
|
@@ -500,7 +544,9 @@ async function createAlertRule(argv) {
|
|
|
500
544
|
notifyEmails: argv.emails,
|
|
501
545
|
notifyPhoneNumbers: argv.sms,
|
|
502
546
|
notifyWebhookUrl: argv.webhook,
|
|
547
|
+
notifyTeams: Boolean(argv.teams),
|
|
503
548
|
notifyMobileUserIds: argv.mobileUserIds,
|
|
549
|
+
excludeContactNames: argv.excludeContacts,
|
|
504
550
|
notifyTeams: Boolean(argv.teams),
|
|
505
551
|
enabled: true,
|
|
506
552
|
};
|
|
@@ -590,7 +636,9 @@ async function updateAlertRule(argv) {
|
|
|
590
636
|
notifyEmails: argv.emails ?? target.notifyEmails,
|
|
591
637
|
notifyPhoneNumbers: argv.sms ?? target.notifyPhoneNumbers,
|
|
592
638
|
notifyWebhookUrl: argv.webhook ?? target.notifyWebhookUrl,
|
|
639
|
+
notifyTeams: argv.teams !== undefined ? Boolean(argv.teams) : target.notifyTeams,
|
|
593
640
|
notifyMobileUserIds: argv.mobileUserIds ?? target.notifyMobileUserIds,
|
|
641
|
+
excludeContactNames: argv.excludeContacts ?? target.excludeContactNames,
|
|
594
642
|
notifyTeams: argv.teams !== undefined ? Boolean(argv.teams) : Boolean(target.notifyTeams),
|
|
595
643
|
enabled: argv.enabled !== undefined ? argv.enabled : target.enabled,
|
|
596
644
|
};
|
|
@@ -598,9 +646,10 @@ async function updateAlertRule(argv) {
|
|
|
598
646
|
// HIGH: same "fires into the void" guard as create -- an update that strips
|
|
599
647
|
// every notification channel (or a rule that already had none, being
|
|
600
648
|
// re-enabled without adding one) silently produces an alert nobody sees.
|
|
601
|
-
|
|
649
|
+
const hasDefaults = await fetchHasGlobalDefaultContacts(creds, msgBase);
|
|
650
|
+
if (!hasAnyRecipient({ emails: body.notifyEmails, sms: body.notifyPhoneNumbers, webhook: body.notifyWebhookUrl, teams: body.notifyTeams, mobileUserIds: body.notifyMobileUserIds, hasGlobalDefaultContacts: hasDefaults })
|
|
602
651
|
&& !argv.noRecipientsOk) {
|
|
603
|
-
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.');
|
|
652
|
+
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.');
|
|
604
653
|
console.error('If that\'s intentional, pass --no-recipients-ok to proceed anyway.');
|
|
605
654
|
process.exit(1);
|
|
606
655
|
}
|
|
@@ -704,6 +753,198 @@ async function deleteAlertRule(argv) {
|
|
|
704
753
|
console.log(`\nDeleted rule ${argv.id}.\n`);
|
|
705
754
|
}
|
|
706
755
|
|
|
756
|
+
// ── Alert contacts ───────────────────────────────────────────────────────────
|
|
757
|
+
|
|
758
|
+
function printAlertContact(c) {
|
|
759
|
+
const scope = c.isGlobalDefault ? `${CYN}global default${RESET}` : `${DIM}not global${RESET}`;
|
|
760
|
+
const bits = [];
|
|
761
|
+
if (c.phoneNumber) bits.push(`SMS ${c.phoneNumber}`);
|
|
762
|
+
if (c.email) bits.push(`email ${c.email}`);
|
|
763
|
+
console.log(`${DIM}[${c.id}]${RESET} ${c.name}${c.displayName ? ` (${c.displayName})` : ''} ${scope}`);
|
|
764
|
+
console.log(` ${bits.join(' ') || '(no phone/email set)'}`);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
async function listAlertContacts(argv) {
|
|
768
|
+
const creds = await getCredentials();
|
|
769
|
+
await requireAdmin(creds);
|
|
770
|
+
const msgBase = await getMsgBase(argv);
|
|
771
|
+
|
|
772
|
+
const res = await fetch(`${msgBase}/superadmin/log-alerts/contacts`, {
|
|
773
|
+
headers: { Authorization: `Bearer ${creds.velaroToken}`, 'X-Internal-SiteId': String(creds.siteId) },
|
|
774
|
+
signal: AbortSignal.timeout(15000),
|
|
775
|
+
});
|
|
776
|
+
if (!res.ok) {
|
|
777
|
+
console.error(`HTTP ${res.status}: ${await res.text().catch(() => '')}`);
|
|
778
|
+
process.exit(1);
|
|
779
|
+
}
|
|
780
|
+
const contacts = await res.json();
|
|
781
|
+
if (!Array.isArray(contacts) || !contacts.length) {
|
|
782
|
+
console.log('No alert contacts yet. Add one with: velaro logs contacts add --name alex --phone +1... --email alex@velaro.com');
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
console.log(`\n${DIM}${contacts.length} alert contact(s)${RESET}\n`);
|
|
786
|
+
for (const c of contacts) printAlertContact(c);
|
|
787
|
+
console.log('');
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
async function addAlertContact(argv) {
|
|
791
|
+
const creds = await getCredentials();
|
|
792
|
+
await requireAdmin(creds);
|
|
793
|
+
const msgBase = await getMsgBase(argv);
|
|
794
|
+
|
|
795
|
+
if (!argv.name) {
|
|
796
|
+
console.error('--name is required. Example: velaro logs contacts add --name alex --phone +14436295872 --email alex@velaro.com');
|
|
797
|
+
process.exit(1);
|
|
798
|
+
}
|
|
799
|
+
if (!argv.phone && !argv.email) {
|
|
800
|
+
console.error('At least one of --phone or --email is required.');
|
|
801
|
+
process.exit(1);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
const body = {
|
|
805
|
+
name: argv.name,
|
|
806
|
+
displayName: argv.displayName,
|
|
807
|
+
phoneNumber: argv.phone,
|
|
808
|
+
email: argv.email,
|
|
809
|
+
isGlobalDefault: argv.global,
|
|
810
|
+
};
|
|
811
|
+
|
|
812
|
+
if (!argv.yes) {
|
|
813
|
+
console.log(`\n${DIM}About to create this contact (dry run — nothing sent yet):${RESET}\n`);
|
|
814
|
+
printAlertContact({ ...body, id: '(new)' });
|
|
815
|
+
console.log(`\nRe-run with --yes to actually create it.\n`);
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
const res = await fetch(`${msgBase}/superadmin/log-alerts/contacts`, {
|
|
820
|
+
method: 'POST',
|
|
821
|
+
headers: {
|
|
822
|
+
Authorization: `Bearer ${creds.velaroToken}`,
|
|
823
|
+
'X-Internal-SiteId': String(creds.siteId),
|
|
824
|
+
'Content-Type': 'application/json',
|
|
825
|
+
},
|
|
826
|
+
body: JSON.stringify(body),
|
|
827
|
+
signal: AbortSignal.timeout(15000),
|
|
828
|
+
});
|
|
829
|
+
if (!res.ok) {
|
|
830
|
+
console.error(`HTTP ${res.status}: ${await res.text().catch(() => '')}`);
|
|
831
|
+
process.exit(1);
|
|
832
|
+
}
|
|
833
|
+
const data = await res.json();
|
|
834
|
+
console.log(`\n${CYN}✓${RESET} Created contact [${data.id}] "${data.name}"\n`);
|
|
835
|
+
printAlertContact(data);
|
|
836
|
+
console.log('');
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
async function updateAlertContact(argv) {
|
|
840
|
+
const creds = await getCredentials();
|
|
841
|
+
await requireAdmin(creds);
|
|
842
|
+
const msgBase = await getMsgBase(argv);
|
|
843
|
+
|
|
844
|
+
if (!argv.id) {
|
|
845
|
+
console.error('Contact id is required. Example: velaro logs contacts update 1 --phone +1...');
|
|
846
|
+
process.exit(1);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
const listRes = await fetch(`${msgBase}/superadmin/log-alerts/contacts`, {
|
|
850
|
+
headers: { Authorization: `Bearer ${creds.velaroToken}`, 'X-Internal-SiteId': String(creds.siteId) },
|
|
851
|
+
signal: AbortSignal.timeout(15000),
|
|
852
|
+
});
|
|
853
|
+
if (!listRes.ok) {
|
|
854
|
+
console.error(`HTTP ${listRes.status}: ${await listRes.text().catch(() => '')}`);
|
|
855
|
+
process.exit(1);
|
|
856
|
+
}
|
|
857
|
+
const contacts = await listRes.json().catch(() => []);
|
|
858
|
+
const target = Array.isArray(contacts) ? contacts.find(c => String(c.id) === String(argv.id)) : null;
|
|
859
|
+
if (!target) {
|
|
860
|
+
console.error(`No contact with id ${argv.id} found. Run 'velaro logs contacts list' to see current ids.`);
|
|
861
|
+
process.exit(1);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const body = {
|
|
865
|
+
...target,
|
|
866
|
+
displayName: argv.displayName ?? target.displayName,
|
|
867
|
+
phoneNumber: argv.phone ?? target.phoneNumber,
|
|
868
|
+
email: argv.email ?? target.email,
|
|
869
|
+
isGlobalDefault: argv.global !== undefined ? argv.global : target.isGlobalDefault,
|
|
870
|
+
};
|
|
871
|
+
|
|
872
|
+
if (!argv.yes) {
|
|
873
|
+
console.log(`\n${DIM}Current contact:${RESET}\n`);
|
|
874
|
+
printAlertContact(target);
|
|
875
|
+
console.log(`\n${DIM}About to update to (dry run — nothing sent yet):${RESET}\n`);
|
|
876
|
+
printAlertContact(body);
|
|
877
|
+
console.log(`\nRe-run with --yes to actually apply this update.\n`);
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
const res = await fetch(`${msgBase}/superadmin/log-alerts/contacts/${argv.id}`, {
|
|
882
|
+
method: 'PUT',
|
|
883
|
+
headers: {
|
|
884
|
+
Authorization: `Bearer ${creds.velaroToken}`,
|
|
885
|
+
'X-Internal-SiteId': String(creds.siteId),
|
|
886
|
+
'Content-Type': 'application/json',
|
|
887
|
+
},
|
|
888
|
+
body: JSON.stringify(body),
|
|
889
|
+
signal: AbortSignal.timeout(15000),
|
|
890
|
+
});
|
|
891
|
+
if (!res.ok) {
|
|
892
|
+
console.error(`HTTP ${res.status}: ${await res.text().catch(() => '')}`);
|
|
893
|
+
process.exit(1);
|
|
894
|
+
}
|
|
895
|
+
const data = await res.json();
|
|
896
|
+
console.log(`\n${CYN}✓${RESET} Updated contact [${data.id}] "${data.name}"\n`);
|
|
897
|
+
printAlertContact(data);
|
|
898
|
+
console.log('');
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
async function removeAlertContact(argv) {
|
|
902
|
+
const creds = await getCredentials();
|
|
903
|
+
await requireAdmin(creds);
|
|
904
|
+
const msgBase = await getMsgBase(argv);
|
|
905
|
+
|
|
906
|
+
if (!argv.id) {
|
|
907
|
+
console.error('Contact id is required. Example: velaro logs contacts remove 1');
|
|
908
|
+
process.exit(1);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
const listRes = await fetch(`${msgBase}/superadmin/log-alerts/contacts`, {
|
|
912
|
+
headers: { Authorization: `Bearer ${creds.velaroToken}`, 'X-Internal-SiteId': String(creds.siteId) },
|
|
913
|
+
signal: AbortSignal.timeout(15000),
|
|
914
|
+
});
|
|
915
|
+
let target = null;
|
|
916
|
+
if (listRes.ok) {
|
|
917
|
+
const contacts = await listRes.json().catch(() => []);
|
|
918
|
+
target = Array.isArray(contacts) ? contacts.find(c => String(c.id) === String(argv.id)) : null;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
if (target) {
|
|
922
|
+
console.log(`\n${DIM}About to delete this contact:${RESET}\n`);
|
|
923
|
+
printAlertContact(target);
|
|
924
|
+
if (target.isGlobalDefault) {
|
|
925
|
+
console.log(`${YEL}This is a global-default contact -- removing it stops it from covering any rule that leaves --emails/--sms blank.${RESET}`);
|
|
926
|
+
}
|
|
927
|
+
} else {
|
|
928
|
+
console.log(`\n${YEL}Could not fetch contact ${argv.id} to preview it (it may already be gone) — proceeding on id alone.${RESET}`);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
if (!argv.yes) {
|
|
932
|
+
console.log(`\nRe-run with --yes to actually delete it.\n`);
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
const res = await fetch(`${msgBase}/superadmin/log-alerts/contacts/${argv.id}`, {
|
|
937
|
+
method: 'DELETE',
|
|
938
|
+
headers: { Authorization: `Bearer ${creds.velaroToken}`, 'X-Internal-SiteId': String(creds.siteId) },
|
|
939
|
+
signal: AbortSignal.timeout(15000),
|
|
940
|
+
});
|
|
941
|
+
if (!res.ok) {
|
|
942
|
+
console.error(`HTTP ${res.status}: ${await res.text().catch(() => '')}`);
|
|
943
|
+
process.exit(1);
|
|
944
|
+
}
|
|
945
|
+
console.log(`\nDeleted contact ${argv.id}.\n`);
|
|
946
|
+
}
|
|
947
|
+
|
|
707
948
|
// ── command export ────────────────────────────────────────────────────────────
|
|
708
949
|
|
|
709
950
|
export const logsCommand = {
|
|
@@ -797,6 +1038,11 @@ export const logsCommand = {
|
|
|
797
1038
|
.option('window-minutes', { describe: 'Rolling window in minutes', type: 'number' })
|
|
798
1039
|
.option('threshold', { describe: 'Fire when count >= this within the window', type: 'number' })
|
|
799
1040
|
.option('cooldown-minutes', { describe: 'Minimum minutes between re-fires', type: 'number' })
|
|
1041
|
+
.option('emails', { describe: 'Comma-separated notification email addresses. Leave blank to fall back to global-default contacts (see: velaro logs contacts list)', type: 'string' })
|
|
1042
|
+
.option('sms', { describe: 'Comma-separated E.164 phone numbers for SMS. Leave blank to fall back to global-default contacts', type: 'string' })
|
|
1043
|
+
.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' })
|
|
1044
|
+
.option('teams', { describe: 'Post fired/resolved notifications to the velaro-alerts-to-teams Teams channel', type: 'boolean' })
|
|
1045
|
+
.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
1046
|
.option('emails', { describe: 'Comma-separated notification email addresses', type: 'string' })
|
|
801
1047
|
.option('sms', { describe: 'Comma-separated E.164 phone numbers for SMS', type: 'string' })
|
|
802
1048
|
.option('webhook', { describe: 'Webhook URL (Slack/Teams) to POST on fire', type: 'string' })
|
|
@@ -819,6 +1065,11 @@ export const logsCommand = {
|
|
|
819
1065
|
.option('window-minutes', { describe: 'Rolling window in minutes', type: 'number' })
|
|
820
1066
|
.option('threshold', { describe: 'Fire when count >= this within the window', type: 'number' })
|
|
821
1067
|
.option('cooldown-minutes', { describe: 'Minimum minutes between re-fires', type: 'number' })
|
|
1068
|
+
.option('emails', { describe: 'Comma-separated notification email addresses. Leave blank to fall back to global-default contacts (see: velaro logs contacts list)', type: 'string' })
|
|
1069
|
+
.option('sms', { describe: 'Comma-separated E.164 phone numbers for SMS. Leave blank to fall back to global-default contacts', type: 'string' })
|
|
1070
|
+
.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' })
|
|
1071
|
+
.option('teams', { describe: 'Post fired/resolved notifications to the velaro-alerts-to-teams Teams channel', type: 'boolean' })
|
|
1072
|
+
.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
1073
|
.option('emails', { describe: 'Comma-separated notification email addresses', type: 'string' })
|
|
823
1074
|
.option('sms', { describe: 'Comma-separated E.164 phone numbers for SMS', type: 'string' })
|
|
824
1075
|
.option('webhook', { describe: 'Webhook URL (Slack/Teams) to POST on fire', type: 'string' })
|
|
@@ -849,5 +1100,53 @@ export const logsCommand = {
|
|
|
849
1100
|
})
|
|
850
1101
|
.demandCommand(1, 'Specify a subcommand: list, suggest, create, update, toggle, or delete'),
|
|
851
1102
|
})
|
|
852
|
-
.
|
|
1103
|
+
.command({
|
|
1104
|
+
command: 'contacts <subcommand>',
|
|
1105
|
+
describe: 'Manage global-default notification contacts for alert rules (Velaro staff only)',
|
|
1106
|
+
builder: y => y
|
|
1107
|
+
.command({
|
|
1108
|
+
command: 'list',
|
|
1109
|
+
describe: 'List all alert contacts',
|
|
1110
|
+
builder: yy => yy.option('env', { describe: 'staging or prod', type: 'string' }),
|
|
1111
|
+
handler: listAlertContacts,
|
|
1112
|
+
})
|
|
1113
|
+
.command({
|
|
1114
|
+
command: 'add',
|
|
1115
|
+
describe: 'Add a new alert contact',
|
|
1116
|
+
builder: yy => yy
|
|
1117
|
+
.option('name', { describe: 'Short unique handle, e.g. "alex" -- used in --exclude-contacts', type: 'string', demandOption: true })
|
|
1118
|
+
.option('display-name', { describe: 'Human-readable label, e.g. "Alex Bloom"', type: 'string' })
|
|
1119
|
+
.option('phone', { describe: 'E.164 phone number for SMS, e.g. +14436295872', type: 'string' })
|
|
1120
|
+
.option('email', { describe: 'Email address', type: 'string' })
|
|
1121
|
+
.option('global', { describe: 'Include this contact in every rule that leaves --emails/--sms blank (the normal case)', type: 'boolean', default: true })
|
|
1122
|
+
.option('yes', { describe: 'Skip the dry-run preview and actually create the contact', type: 'boolean', default: false })
|
|
1123
|
+
.option('env', { describe: 'staging or prod', type: 'string' }),
|
|
1124
|
+
handler: addAlertContact,
|
|
1125
|
+
})
|
|
1126
|
+
.command({
|
|
1127
|
+
command: 'update <id>',
|
|
1128
|
+
describe: 'Change an existing contact\'s phone/email/global-default flag',
|
|
1129
|
+
builder: yy => yy
|
|
1130
|
+
.positional('id', { describe: 'Contact id (from: velaro logs contacts list)', type: 'number' })
|
|
1131
|
+
.option('display-name', { describe: 'Human-readable label', type: 'string' })
|
|
1132
|
+
.option('phone', { describe: 'E.164 phone number for SMS', type: 'string' })
|
|
1133
|
+
.option('email', { describe: 'Email address', type: 'string' })
|
|
1134
|
+
.option('global', { describe: 'Include/exclude this contact from global defaults', type: 'boolean' })
|
|
1135
|
+
.option('yes', { describe: 'Skip the dry-run preview and actually apply the update', type: 'boolean', default: false })
|
|
1136
|
+
.option('env', { describe: 'staging or prod', type: 'string' }),
|
|
1137
|
+
handler: updateAlertContact,
|
|
1138
|
+
})
|
|
1139
|
+
.command({
|
|
1140
|
+
command: 'remove <id>',
|
|
1141
|
+
describe: 'Delete an alert contact',
|
|
1142
|
+
builder: yy => yy
|
|
1143
|
+
.positional('id', { describe: 'Contact id', type: 'number' })
|
|
1144
|
+
.option('yes', { describe: 'Skip the confirmation preview and actually delete', type: 'boolean', default: false })
|
|
1145
|
+
.option('env', { describe: 'staging or prod', type: 'string' }),
|
|
1146
|
+
handler: removeAlertContact,
|
|
1147
|
+
})
|
|
1148
|
+
.demandCommand(1, 'Specify a subcommand: list, add, update, or remove'),
|
|
1149
|
+
})
|
|
1150
|
+
.command(logsInsightsCommand)
|
|
1151
|
+
.demandCommand(1, 'Specify a subcommand: search, tail, diagnose, digest-recipients, set-digest-recipients, alerts, contacts, or insights'),
|
|
853
1152
|
};
|
package/lib/commands/magento.js
CHANGED
|
@@ -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,
|
package/lib/commands/mcp-key.js
CHANGED
|
@@ -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
|
|
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) {
|
|
@@ -132,6 +137,7 @@ const mcpKeyInstallCommand = {
|
|
|
132
137
|
},
|
|
133
138
|
};
|
|
134
139
|
|
|
140
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
135
141
|
// Claude Code itself writes ~/.claude.json continuously (history, project state),
|
|
136
142
|
// so this must never be a plain in-place write: write to a temp file and rename,
|
|
137
143
|
// which is atomic on the same filesystem — a crash or concurrent write mid-save
|
package/lib/commands/netsuite.js
CHANGED
|
@@ -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
|
-
.
|
|
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
|
};
|