@velaro/cli 1.4.102 → 1.4.104

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +19 -0
  2. package/bin/velaro.js +14 -0
  3. package/lib/api.js +2 -1
  4. package/lib/apple-pay-presets.js +66 -0
  5. package/lib/apple-pay-presets.test.js +61 -0
  6. package/lib/apple-pay.js +32 -0
  7. package/lib/apple-pay.test.js +32 -0
  8. package/lib/avatar-switch.js +42 -0
  9. package/lib/avatar-switch.test.js +49 -0
  10. package/lib/build-info.js +28 -0
  11. package/lib/build-info.test.js +24 -0
  12. package/lib/commands/apple-pay-preset.js +65 -0
  13. package/lib/commands/apple-pay.js +73 -0
  14. package/lib/commands/avatar.js +60 -2
  15. package/lib/commands/billing-automation.js +184 -0
  16. package/lib/commands/copilotstudio.js +14 -1
  17. package/lib/commands/enterprise-reports.js +1 -0
  18. package/lib/commands/entitlement.js +79 -1
  19. package/lib/commands/env.js +1 -1
  20. package/lib/commands/focus.js +1 -1
  21. package/lib/commands/index.js +38 -2
  22. package/lib/commands/logs-insights.js +342 -0
  23. package/lib/commands/logs.js +310 -11
  24. package/lib/commands/magento.js +16 -0
  25. package/lib/commands/mcp-key.js +7 -1
  26. package/lib/commands/netsuite.js +67 -2
  27. package/lib/commands/notifications.js +34 -1
  28. package/lib/commands/ops.js +23 -54
  29. package/lib/commands/report-backfill.js +23 -5
  30. package/lib/commands/report.js +77 -14
  31. package/lib/commands/salesforce.js +101 -0
  32. package/lib/commands/shopify.js +84 -0
  33. package/lib/commands/signature.js +79 -0
  34. package/lib/commands/site-builder.js +30 -0
  35. package/lib/commands/support.js +22 -0
  36. package/lib/commands/teams.js +11 -1
  37. package/lib/commands/workflow.js +157 -2
  38. package/lib/commands/zoom-phone.js +15 -0
  39. package/lib/enterprise-reports.test.js +1 -1
  40. package/lib/entitlement-report-packages.test.js +47 -0
  41. package/lib/logs-insights.test.js +146 -0
  42. package/lib/mcp-key.test.js +11 -0
  43. package/lib/messaging-routes.test.js +10 -1
  44. package/lib/oauth.js +14 -2
  45. package/lib/report-range.js +21 -0
  46. package/lib/report-range.test.js +38 -0
  47. package/lib/shopify-write-policy.test.js +34 -0
  48. package/lib/signature-policy.js +70 -0
  49. package/lib/signature-policy.test.js +48 -0
  50. package/package.json +1 -1
@@ -1,6 +1,7 @@
1
1
  import { describeRawMeta, fetchRetry429 } from '../log-search.js';
2
2
  import { get, del, post, getCredentials } from '../api.js';
3
3
  import { readConfig, ENVS } from '../config.js';
4
+ import { parseBuild, formatBuild } from '../build-info.js';
4
5
  import { collectAll, render as renderDeployState } from '../deploy-state.js';
5
6
 
6
7
  // ── helpers ──────────────────────────────────────────────────────────────────
@@ -27,12 +28,6 @@ function extractLogEntries(response) {
27
28
  return [];
28
29
  }
29
30
 
30
- function utcLabel(dateStr) {
31
- if (!dateStr) return '';
32
- const d = new Date(dateStr);
33
- return d.toISOString().replace('T', ' ').slice(0, 16) + ' UTC';
34
- }
35
-
36
31
  const OK = '\x1b[32m✓\x1b[0m';
37
32
  const WARN = '\x1b[33m⚠\x1b[0m';
38
33
  const ERR = '\x1b[31m✗\x1b[0m';
@@ -46,21 +41,15 @@ function severityPrefix(s) {
46
41
  return LOW;
47
42
  }
48
43
 
49
- async function pingVersion(label, base) {
50
- try {
51
- const res = await fetch(`${base}/Version`, { signal: AbortSignal.timeout(6000) });
52
- if (!res.ok) return { label, ok: false, status: res.status };
53
- const data = await res.json();
54
- return { label, ok: true, version: data.version, deployedAt: data.deployedAt };
55
- } catch (e) {
56
- return { label, ok: false, err: e.message };
57
- }
58
- }
59
-
44
+ // Reads the real running build from /api/health (build.commitId|sha, builtAt). The old /Version
45
+ // endpoint returned a stale CI-stamped version/deployedAt, so workers polling for a new staging
46
+ // build never saw it land (2026-10-12).
60
47
  async function pingHealth(label, url) {
61
48
  try {
62
49
  const res = await fetch(url, { signal: AbortSignal.timeout(6000) });
63
- return { label, ok: res.ok, status: res.status };
50
+ let build = null;
51
+ try { build = parseBuild(await res.json()); } catch { /* non-JSON health body */ }
52
+ return { label, ok: res.ok, status: res.status, build };
64
53
  } catch (e) {
65
54
  return { label, ok: false, err: e.message };
66
55
  }
@@ -133,31 +122,32 @@ export const opsCommand = {
133
122
 
134
123
  const cfg = readConfig();
135
124
 
136
- // ── 1. Deploy versions (anonymous — both envs in parallel) ───────────────
125
+ // ── 1+2. Deploy (real running build) + API health, all four services ────
137
126
  const prodBase = cfg.envs?.prod?.adminApiBase ?? ENVS.prod.adminApiBase;
138
127
  const stagingBase = cfg.envs?.staging?.adminApiBase ?? ENVS.staging.adminApiBase;
128
+ const prodMsgBase = cfg.envs?.prod?.messagingApiBase ?? ENVS.prod.messagingApiBase;
129
+ const stagingMsgBase = cfg.envs?.staging?.messagingApiBase ?? ENVS.staging.messagingApiBase;
139
130
 
140
- const [prodVer, stagingVer] = await Promise.all([
141
- pingVersion('prod', prodBase),
142
- pingVersion('staging', stagingBase),
131
+ const services = await Promise.all([
132
+ pingHealth('admin/prod', `${prodBase}/api/health`),
133
+ pingHealth('admin/staging', `${stagingBase}/api/health`),
134
+ pingHealth('msg/prod', `${prodMsgBase}/api/health`),
135
+ pingHealth('msg/staging', `${stagingMsgBase}/api/health`),
143
136
  ]);
144
137
 
145
- console.log('\n\x1b[1m── Deploy ──────────────────────────────────────────────────\x1b[0m');
146
- for (const v of [prodVer, stagingVer]) {
147
- if (v.ok) {
148
- const sym = OK;
149
- const ver = `\x1b[36m${v.version}\x1b[0m`;
150
- const time = `${ago(v.deployedAt).padEnd(12)} (${utcLabel(v.deployedAt)})`;
151
- console.log(` ${sym} ${v.label.padEnd(8)} ${ver.padEnd(30)} deployed ${time}`);
138
+ console.log('\n\x1b[1m── Deploy / API Health ─────────────────────────────────────\x1b[0m');
139
+ for (const h of services) {
140
+ if (h.ok) {
141
+ console.log(` ${OK} ${h.label.padEnd(14)} ${formatBuild(h.build)}`);
152
142
  } else {
153
- const detail = v.err ?? `HTTP ${v.status}`;
154
- console.log(` ${ERR} ${v.label.padEnd(8)} UNREACHABLE — ${detail}`);
143
+ const detail = h.err ?? `HTTP ${h.status}`;
144
+ console.log(` ${ERR} ${h.label.padEnd(14)} ${h.build ? formatBuild(h.build) + ' ' : ''}UNHEALTHY — ${detail}`);
155
145
  }
156
146
  }
157
147
 
158
148
  // ── 1b. Where each push landed: staging environment, staging slot 1 of production, production ──
159
149
  // 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');
150
+ console.log('\n── Where each push landed ──────────────────────────────────');
161
151
  try {
162
152
  const state = await collectAll();
163
153
  console.log(renderDeployState(state, { compact: true }).split('\n').map(l => ' ' + l).join('\n'));
@@ -166,27 +156,6 @@ export const opsCommand = {
166
156
  console.log(` ${WARN} Deploy state unavailable (${e.message}). Needs gh logged in; run velaro deploy-state.`);
167
157
  }
168
158
 
169
- // ── 2. API health (admin + messaging, both envs) ──────────────────────────
170
- const prodMsgBase = cfg.envs?.prod?.messagingApiBase ?? ENVS.prod.messagingApiBase;
171
- const stagingMsgBase = cfg.envs?.staging?.messagingApiBase ?? ENVS.staging.messagingApiBase;
172
-
173
- const [adminProdHealth, adminStagingHealth, msgProdHealth, msgStagingHealth] = await Promise.all([
174
- pingHealth('admin/prod', `${prodBase}/Status`),
175
- pingHealth('admin/staging', `${stagingBase}/Status`),
176
- pingHealth('msg/prod', `${prodMsgBase}/api/health`),
177
- pingHealth('msg/staging', `${stagingMsgBase}/api/health`),
178
- ]);
179
-
180
- console.log('\n\x1b[1m── API Health ──────────────────────────────────────────────\x1b[0m');
181
- for (const h of [adminProdHealth, adminStagingHealth, msgProdHealth, msgStagingHealth]) {
182
- if (h.ok) {
183
- console.log(` ${OK} ${h.label}`);
184
- } else {
185
- const detail = h.err ?? `HTTP ${h.status}`;
186
- console.log(` ${ERR} ${h.label.padEnd(20)} ${detail}`);
187
- }
188
- }
189
-
190
159
  // ── 3. Hangfire failed jobs ───────────────────────────────────────────────
191
160
  console.log('\n\x1b[1m── Hangfire ────────────────────────────────────────────────\x1b[0m');
192
161
  try {
@@ -246,7 +215,7 @@ export const opsCommand = {
246
215
  // ── 5. Recent errors + perf + AI routing (last 1h) ───────────────────────
247
216
  console.log('\n\x1b[1m── Recent Issues (last 1h) ─────────────────────────────────\x1b[0m');
248
217
  try {
249
- const stagingMsgBaseLocal = cfg.envs?.staging?.messagingApiBase ?? ENVS.staging.messagingApiBase;
218
+ const stagingMsgBaseLocal = stagingMsgBase;
250
219
  const from1h = new Date(Date.now() - 3600000).toISOString();
251
220
 
252
221
  // One search at a time: the server runs at most 2 exact scans per instance. 429 (gate full) is retried honouring Retry-After.
@@ -1,22 +1,40 @@
1
- import { messagingPost } from '../api.js';
1
+ import { messagingGet, messagingPost } from '../api.js';
2
2
  import { runCommand } from '../run.js';
3
3
 
4
4
  // -- velaro report-backfill <type> --from --to [--apply] ---------------------------
5
5
  // Backend: velaro-messaging POST /Reports/backfill/{type}?from&to&apply (site managers; site from auth).
6
6
  // Preview by default ("would fill N"); --apply writes. Idempotent. Type "csat" fills survey ratings only.
7
+ // Type "csat-demo" adds tagged demo CSAT assigned to the demo agents (allow-listed demo sites only, e.g. 100050);
8
+ // "csat-demo-undo" with the SAME --from/--to deletes exactly the tagged rows. Example:
9
+ // velaro report-backfill csat-demo --from 2026-08-01 --to 2026-10-06 [--apply]
10
+ // velaro report-backfill csat-demo-undo --from 2026-08-01 --to 2026-10-06 [--apply]
11
+ // history-classification(-sample) --apply is QUEUED (HTTP 202 + runId): the run happens in the background on the server;
12
+ // read progress/result with --status (GET /Reports/backfill/{type}/status). csat --apply stays synchronous (200).
7
13
  export const reportBackfillCommand = {
8
14
  command: 'report-backfill <type>',
9
- describe: 'Backfill report data for your site (preview by default; --apply to write). Type: csat',
15
+ describe: 'Backfill report data for your site (preview by default; --apply to write). Types: csat, csat-demo, csat-demo-undo, history-classification-sample (propose tags from 20 recent chats), history-classification (tag and score past chats, 100 per run)',
10
16
  builder: (y) => y
11
- .positional('type', { type: 'string', describe: 'Backfill type, e.g. csat' })
12
- .option('from', { type: 'string', demandOption: true, describe: 'yyyy-MM-dd' })
13
- .option('to', { type: 'string', demandOption: true, describe: 'yyyy-MM-dd' })
17
+ .positional('type', { type: 'string', describe: 'Backfill type: csat | csat-demo | csat-demo-undo | history-classification-sample | history-classification' })
18
+ .option('from', { type: 'string', describe: 'yyyy-MM-dd (required unless --status)' })
19
+ .option('to', { type: 'string', describe: 'yyyy-MM-dd (required unless --status)' })
20
+ .option('status', { type: 'boolean', default: false, describe: 'Show the latest queued/running/finished apply run for this type (history-classification types)' })
14
21
  .option('apply', { type: 'boolean', default: false, describe: 'Write the changes (default: preview only)' })
15
22
  .option('json', { type: 'boolean', default: false, describe: 'Raw JSON output' }),
16
23
  handler: runCommand(async (argv) => {
24
+ if (argv.status) {
25
+ const s = await messagingGet(`/Reports/backfill/${encodeURIComponent(argv.type)}/status`);
26
+ console.log(JSON.stringify(s, null, 2));
27
+ return;
28
+ }
29
+ if (!argv.from || !argv.to) throw new Error('--from and --to are required (yyyy-MM-dd) unless --status');
17
30
  const qs = new URLSearchParams({ from: argv.from, to: argv.to, apply: String(!!argv.apply) });
18
31
  const r = await messagingPost(`/Reports/backfill/${encodeURIComponent(argv.type)}?${qs}`);
19
32
  if (argv.json) { console.log(JSON.stringify(r, null, 2)); return; }
33
+ if (argv.apply && r && r.runId) {
34
+ console.log(r.alreadyRunning ? `\nA run is already ${r.status} for this site (run ${r.runId}). Not starting another.` : `\nBackfill QUEUED (run ${r.runId}). It runs in the background; check progress with: velaro report-backfill ${argv.type} --status`);
35
+ console.log(JSON.stringify(r, null, 2));
36
+ return;
37
+ }
20
38
  console.log(argv.apply ? '\nBackfill APPLIED' : '\nBackfill PREVIEW (nothing written; add --apply to write)');
21
39
  console.log(JSON.stringify(r, null, 2));
22
40
  }),
@@ -1,6 +1,7 @@
1
1
  import { writeFileSync } from 'fs';
2
2
  import { getCredentials, request, messagingRequest } from '../api.js';
3
3
  import { runCommand } from '../run.js';
4
+ import { toExclusiveEnd, exclusiveEndParam } from '../report-range.js';
4
5
 
5
6
  // ── helpers ───────────────────────────────────────────────────────────────────
6
7
 
@@ -33,7 +34,7 @@ function section(title) {
33
34
  function dateParams(argv) {
34
35
  const params = [];
35
36
  if (argv.start) params.push(`start=${encodeURIComponent(argv.start)}`);
36
- if (argv.end) params.push(`end=${encodeURIComponent(argv.end)}`);
37
+ if (argv.end) params.push(`end=${encodeURIComponent(exclusiveEndParam(argv.end))}`);
37
38
  return params.length ? `?${params.join('&')}` : '';
38
39
  }
39
40
 
@@ -66,12 +67,12 @@ const callcenterTeamsCommand = {
66
67
  describe: 'Team breakdown for the call center report',
67
68
  builder: (y) => y
68
69
  .option('start', { type: 'string', describe: 'Start date (YYYY-MM-DD)' })
69
- .option('end', { type: 'string', describe: 'End date (YYYY-MM-DD)' })
70
+ .option('end', { type: 'string', describe: 'End date (YYYY-MM-DD; includes that whole day)' })
70
71
  .option('sla', { type: 'number', describe: 'SLA threshold in seconds (overrides server default)' }),
71
72
  handler: async (argv) => {
72
73
  const params = [];
73
74
  if (argv.start) params.push(`start=${encodeURIComponent(argv.start)}`);
74
- if (argv.end) params.push(`end=${encodeURIComponent(argv.end)}`);
75
+ if (argv.end) params.push(`end=${encodeURIComponent(exclusiveEndParam(argv.end))}`);
75
76
  if (argv.sla) params.push(`slaSeconds=${argv.sla}`);
76
77
  const qs = params.length ? `?${params.join('&')}` : '';
77
78
  const data = await fetchReport(`/CallCenterAnalytics/TeamBreakdown${qs}`);
@@ -106,7 +107,7 @@ const callcenterChannelsCommand = {
106
107
  describe: 'Channel breakdown for the call center report',
107
108
  builder: (y) => y
108
109
  .option('start', { type: 'string', describe: 'Start date (YYYY-MM-DD)' })
109
- .option('end', { type: 'string', describe: 'End date (YYYY-MM-DD)' }),
110
+ .option('end', { type: 'string', describe: 'End date (YYYY-MM-DD; includes that whole day)' }),
110
111
  handler: async (argv) => {
111
112
  const data = await fetchReport(`/CallCenterAnalytics/ChannelBreakdown${dateParams(argv)}`);
112
113
  const channels = Array.isArray(data) ? data : (data?.channels ?? []);
@@ -141,7 +142,7 @@ const callcenterCommand = {
141
142
  .command(callcenterTeamsCommand)
142
143
  .command(callcenterChannelsCommand)
143
144
  .option('start', { type: 'string', describe: 'Start date (YYYY-MM-DD)' })
144
- .option('end', { type: 'string', describe: 'End date (YYYY-MM-DD)' }),
145
+ .option('end', { type: 'string', describe: 'End date (YYYY-MM-DD; includes that whole day)' }),
145
146
  handler: async (argv) => {
146
147
  const data = await fetchReport(`/CallCenterAnalytics/Report${dateParams(argv)}`);
147
148
 
@@ -197,12 +198,12 @@ const servicelevelCommand = {
197
198
  describe: 'Service level compliance report over time',
198
199
  builder: (y) => y
199
200
  .option('start', { type: 'string', describe: 'Start date (YYYY-MM-DD)' })
200
- .option('end', { type: 'string', describe: 'End date (YYYY-MM-DD)' })
201
+ .option('end', { type: 'string', describe: 'End date (YYYY-MM-DD; includes that whole day)' })
201
202
  .option('granularity', { type: 'string', choices: ['hour', 'day', 'week', 'month'], default: 'day', describe: 'Time bucket size' }),
202
203
  handler: async (argv) => {
203
204
  const params = [];
204
- if (argv.start) params.push(`start=${encodeURIComponent(argv.start)}`);
205
- if (argv.end) params.push(`end=${encodeURIComponent(argv.end)}`);
205
+ if (argv.start) params.push(`startDate=${encodeURIComponent(argv.start)}`);
206
+ if (argv.end) params.push(`endDate=${encodeURIComponent(exclusiveEndParam(argv.end))}`);
206
207
  if (argv.granularity) params.push(`granularity=${argv.granularity}`);
207
208
  const qs = params.length ? `?${params.join('&')}` : '';
208
209
  const data = await fetchReport(`/ServiceLevel${qs}`);
@@ -249,17 +250,17 @@ const conversationsCommand = {
249
250
  describe: 'Conversation volume report over time',
250
251
  builder: (y) => y
251
252
  .option('start', { type: 'string', describe: 'Start date (YYYY-MM-DD)' })
252
- .option('end', { type: 'string', describe: 'End date (YYYY-MM-DD)' })
253
+ .option('end', { type: 'string', describe: 'End date (YYYY-MM-DD; includes that whole day)' })
253
254
  .option('granularity', { type: 'string', choices: ['hour', 'day', 'week', 'month'], default: 'day', describe: 'Time bucket size' })
254
255
  .option('channel', { type: 'string', describe: 'Filter by channel (e.g. Web, TwilioSms, WhatsApp)' }),
255
256
  handler: async (argv) => {
256
257
  const params = [];
257
- if (argv.start) params.push(`start=${encodeURIComponent(argv.start)}`);
258
- if (argv.end) params.push(`end=${encodeURIComponent(argv.end)}`);
258
+ if (argv.start) params.push(`startDate=${encodeURIComponent(argv.start)}`);
259
+ if (argv.end) params.push(`endDate=${encodeURIComponent(exclusiveEndParam(argv.end))}`);
259
260
  if (argv.granularity) params.push(`granularity=${argv.granularity}`);
260
261
  if (argv.channel) params.push(`channel=${encodeURIComponent(argv.channel)}`);
261
262
  const qs = params.length ? `?${params.join('&')}` : '';
262
- const data = await fetchReport(`/Conversations${qs}`);
263
+ const data = await fetchReport(`/ConversationsReport${qs}`);
263
264
 
264
265
  section('Conversations Report');
265
266
 
@@ -384,7 +385,7 @@ const workedHoursCommand = {
384
385
  describe: `Worked hours breakdown report: site KPIs + per-agent/per-day detail (Time Tracking, max ${MAX_WORKED_HOURS_RANGE_DAYS}-day range per query)`,
385
386
  builder: (y) => y
386
387
  .option('start', { type: 'string', demandOption: true, describe: 'Start date (ISO 8601 or YYYY-MM-DD)' })
387
- .option('end', { type: 'string', demandOption: true, describe: `End date (ISO 8601 or YYYY-MM-DD) -- must be at most ${MAX_WORKED_HOURS_RANGE_DAYS} days after --start` }),
388
+ .option('end', { type: 'string', demandOption: true, describe: `End date (ISO 8601 or YYYY-MM-DD; a plain date includes that whole day) -- must be at most ${MAX_WORKED_HOURS_RANGE_DAYS} days after --start` }),
388
389
  handler: async (argv) => {
389
390
  const start = new Date(argv.start);
390
391
  const end = new Date(argv.end);
@@ -400,7 +401,7 @@ const workedHoursCommand = {
400
401
 
401
402
  let data;
402
403
  try {
403
- data = await messagingRequest('POST', '/Reports/worked-hours-breakdown', { Start: start.toISOString(), End: end.toISOString() });
404
+ data = await messagingRequest('POST', '/Reports/worked-hours-breakdown', { Start: start.toISOString(), End: toExclusiveEnd(argv.end, end).toISOString() });
404
405
  } catch (err) {
405
406
  if (/403/.test(err.message)) {
406
407
  console.error('Time Tracking is not enabled for this account.');
@@ -678,6 +679,67 @@ const exportCommand = {
678
679
  handler: () => {},
679
680
  };
680
681
 
682
+ // ── country performance ───────────────────────────────────────────────────────
683
+
684
+ const countryGetCommand = {
685
+ command: 'get',
686
+ describe: 'Performance by Country (US / Canada / Europe / Other)',
687
+ builder: (y) => y
688
+ .option('start', { type: 'string', describe: 'Start date (YYYY-MM-DD), default 30 days ago' })
689
+ .option('end', { type: 'string', describe: 'End date (YYYY-MM-DD), default now' })
690
+ .option('attribution', { type: 'string', choices: ['visitor', 'agent', 'group'], default: 'visitor', describe: 'visitor country, agent profile country, or assigned group' })
691
+ .option('channel', { type: 'string', describe: 'Limit to one channel, e.g. Web or TwilioSms' })
692
+ .option('json', { type: 'boolean', describe: 'Print raw JSON' }),
693
+ handler: runCommand(async (argv) => {
694
+ const end = argv.end ? new Date(argv.end) : new Date();
695
+ const start = argv.start ? new Date(argv.start) : new Date(end.getTime() - 30 * 86400000);
696
+ const r = await messagingRequest('POST', '/reports/performance-by-country/query', {
697
+ start: start.toISOString(), end: end.toISOString(), attribution: argv.attribution,
698
+ channels: argv.channel ? [argv.channel] : [],
699
+ });
700
+ if (argv.json) { console.log(JSON.stringify(r, null, 2)); return; }
701
+ section(`Performance by Country (${r.attribution})`);
702
+ for (const b of r.buckets ?? []) {
703
+ fmtRow(b.bucket, `eng ${b.engagements} handled ${b.handled} missed ${b.missed} abandoned ${b.abandoned} csat ${fmtNum(b.csatAvg)} (${b.csatCount})`);
704
+ }
705
+ section('By agent');
706
+ for (const a of r.agents ?? []) {
707
+ fmtRow(a.agentName ?? '', `${a.bucket} eng ${a.engagements} handled ${a.handled} csat ${fmtNum(a.csatAvg)}${a.agentCountryNotSet ? ' [country not set]' : ''}`);
708
+ }
709
+ }),
710
+ };
711
+
712
+ const countryGroupsCommand = {
713
+ command: 'groups',
714
+ describe: 'Show the site groups and their Performance by Country bucket; set mappings with --set <teamId>=<label>',
715
+ builder: (y) => y.option('set', { type: 'array', describe: 'teamId=label (repeatable). REPLACES the whole mapping.' }),
716
+ handler: runCommand(async (argv) => {
717
+ const groups = await messagingRequest('GET', '/reports/performance-by-country/groups');
718
+ let current;
719
+ if (argv.set && argv.set.length) {
720
+ const map = {};
721
+ for (const pair of argv.set) {
722
+ const i = String(pair).indexOf('=');
723
+ if (i < 1) throw new Error(`--set expects teamId=label, got "${pair}"`);
724
+ map[String(pair).slice(0, i)] = String(pair).slice(i + 1);
725
+ }
726
+ current = await messagingRequest('PUT', '/reports/performance-by-country/group-map', { map });
727
+ } else {
728
+ current = await messagingRequest('GET', '/reports/performance-by-country/group-map');
729
+ }
730
+ const map = current?.map ?? {};
731
+ section('Groups');
732
+ for (const g of Array.isArray(groups) ? groups : []) fmtRow(`${g.id} ${g.name}`, map[g.id] ?? '(unmapped: Other)');
733
+ }),
734
+ };
735
+
736
+ const countryCommand = {
737
+ command: 'country',
738
+ describe: 'Performance by Country report and group-to-bucket mapping',
739
+ builder: (y) => y.command(countryGetCommand).command(countryGroupsCommand).demandCommand(1, 'Specify: get | groups'),
740
+ handler: () => {},
741
+ };
742
+
681
743
  // ── top-level report command ──────────────────────────────────────────────────
682
744
 
683
745
  export const reportCommand = {
@@ -692,6 +754,7 @@ export const reportCommand = {
692
754
  .command(visitorTrafficCommand)
693
755
  .command(schedulesCommand)
694
756
  .command(exportCommand)
757
+ .command(countryCommand)
695
758
  .demandCommand(1, 'Specify a report type. Run "velaro report --help" for options.'),
696
759
  handler: () => {},
697
760
  };
@@ -0,0 +1,101 @@
1
+ /**
2
+ * velaro salesforce — Salesforce integration diagnostics (OAuth, per-object read probes)
3
+ *
4
+ * velaro salesforce diagnose --site-id <id> [--quick] Live read-only diagnostic: config, auth, per-capability probes
5
+ * velaro salesforce matrix --site-id <id> Capability matrix vs live probes (same data as diagnose)
6
+ *
7
+ * Calls messaging DiagController GET /diagnostics/test-salesforce?siteId=<id>&deep=<bool> (Velaro staff only). Read-only:
8
+ * nothing is created or changed in Salesforce, and a write capability is never reported as verified.
9
+ */
10
+
11
+ import { messagingRequest } from '../api.js';
12
+
13
+ const GRN = '\x1b[32m';
14
+ const RED = '\x1b[31m';
15
+ const YEL = '\x1b[33m';
16
+ const DIM = '\x1b[2m';
17
+ const BOLD = '\x1b[1m';
18
+ const RST = '\x1b[0m';
19
+
20
+ const ICON = { pass: `${GRN}✓${RST}`, warn: `${YEL}!${RST}`, fail: `${RED}✗${RST}`, skip: `${DIM}-${RST}` };
21
+
22
+ async function fetchReport(argv, deep) {
23
+ const siteId = argv['site-id'];
24
+ if (!siteId) {
25
+ console.error(`${RED}✗ --site-id is required${RST}`);
26
+ process.exitCode = 1;
27
+ return null;
28
+ }
29
+ try {
30
+ const r = await messagingRequest('GET', `/diagnostics/test-salesforce?siteId=${encodeURIComponent(siteId)}&deep=${deep ? 'true' : 'false'}`);
31
+ return r?.report ?? r;
32
+ } catch (e) {
33
+ console.error(`\n${RED}✗ Diagnostic failed: ${e.message}${RST}\n`);
34
+ process.exitCode = 1;
35
+ return null;
36
+ }
37
+ }
38
+
39
+ function printSteps(report) {
40
+ console.log(`\n${BOLD}Salesforce diagnostic — site ${report.siteId ?? report.SiteId}${RST} ${DIM}${report.product} / ${report.target}${RST}`);
41
+ console.log(` overall: ${String(report.overall).toUpperCase()}\n`);
42
+ for (const s of report.steps ?? []) {
43
+ const ms = s.latencyMs != null ? `${DIM} (${s.latencyMs}ms)${RST}` : '';
44
+ console.log(` ${ICON[s.status] ?? '?'} ${s.name}${ms}${s.detail ? ` ${DIM}${s.detail}${RST}` : ''}`);
45
+ if (s.hint) console.log(` ${DIM}→ ${s.hint}${RST}`);
46
+ }
47
+ }
48
+
49
+ function printCapabilities(report) {
50
+ const caps = report.capabilities ?? [];
51
+ if (!caps.length) return;
52
+ console.log(`\n${BOLD}Capabilities${RST} ${DIM}(package = skill tier; live = read-only SOQL probe now; writes are never probed)${RST}\n`);
53
+ for (const c of caps) {
54
+ const live = c.liveOk === true ? `${GRN}live ok${RST}` : c.liveOk === false ? `${RED}live FAIL${RST}` : `${DIM}not probed${RST}`;
55
+ const proven = c.liveVerified ? `${GRN}verified${RST}` : `${DIM}unverified${RST}`;
56
+ console.log(` ${c.capability.padEnd(38)} ${String(c.package).padEnd(9)} ${String(c.documented).padEnd(6)} ${live} ${proven}`);
57
+ if (c.liveOk === false && c.hint) console.log(` ${DIM}→ ${c.hint}${RST}`);
58
+ }
59
+ console.log('');
60
+ }
61
+
62
+ async function diagnoseHandler(argv) {
63
+ const report = await fetchReport(argv, !argv.quick);
64
+ if (!report) return;
65
+ if (argv.json) { console.log(JSON.stringify(report, null, 2)); return; }
66
+ printSteps(report);
67
+ printCapabilities(report);
68
+ if (report.overall === 'failed' || report.overall === 'not_configured') process.exitCode = 1;
69
+ }
70
+
71
+ async function matrixHandler(argv) {
72
+ const report = await fetchReport(argv, true);
73
+ if (!report) return;
74
+ if (argv.json) { console.log(JSON.stringify(report.capabilities ?? [], null, 2)); return; }
75
+ printCapabilities(report);
76
+ }
77
+
78
+ export const salesforceCommand = {
79
+ command: 'salesforce <subcommand>',
80
+ describe: 'Salesforce integration diagnostics (token, per-object read probes)',
81
+ builder: y => y
82
+ .command({
83
+ command: 'diagnose',
84
+ describe: 'Live read-only diagnostic: configuration, token, per-object probes',
85
+ builder: yy => yy
86
+ .option('site-id', { describe: 'Customer site id', type: 'number' })
87
+ .option('quick', { describe: 'Skip per-capability probes', type: 'boolean', default: false })
88
+ .option('json', { describe: 'Raw JSON output', type: 'boolean', default: false }),
89
+ handler: diagnoseHandler,
90
+ })
91
+ .command({
92
+ command: 'matrix',
93
+ describe: 'Capability matrix: package tier and what a live read probe proves',
94
+ builder: yy => yy
95
+ .option('site-id', { describe: 'Customer site id', type: 'number' })
96
+ .option('json', { describe: 'Raw JSON output', type: 'boolean', default: false }),
97
+ handler: matrixHandler,
98
+ })
99
+ .demandCommand(1),
100
+ handler: () => {},
101
+ };
@@ -0,0 +1,84 @@
1
+ /**
2
+ * velaro shopify - Shopify write-tier policy (velaro-messaging ShopifyIntegrationsController)
3
+ *
4
+ * velaro shopify write-policy get
5
+ * velaro shopify write-policy set [--enable-shopify-write|--no-enable-shopify-write]
6
+ * [--require-confirmation|--no-require-confirmation]
7
+ * [--max-refund-amount N|default] [--max-discount-percent N|default]
8
+ * [--max-discount-fixed-amount N|default]
9
+ *
10
+ * Backend contract: GET/PUT /api/Shopify/WritePolicy (velaro-messaging). Site is resolved
11
+ * server-side from the auth context, never caller-supplied. Partial update: omitted flags keep
12
+ * their stored value; the word "default" sends null, returning that cap to its default.
13
+ */
14
+
15
+ import { messagingGet, messagingPut } from '../api.js';
16
+
17
+ const GRN = '\x1b[32m';
18
+ const BOLD = '\x1b[1m';
19
+ const RST = '\x1b[0m';
20
+
21
+ export function parseCap(v, name) {
22
+ if (v === undefined) return undefined;
23
+ if (v === null || String(v).toLowerCase() === 'default' || String(v).toLowerCase() === 'null') return null;
24
+ const n = Number(v);
25
+ if (!Number.isFinite(n) || n < 0) throw new Error(`--${name} must be a non-negative number or "default".`);
26
+ return n;
27
+ }
28
+
29
+ export function buildWritePolicyBody(argv) {
30
+ const body = {};
31
+ if (argv.enableShopifyWrite !== undefined) body.enableShopifyWrite = !!argv.enableShopifyWrite;
32
+ if (argv.requireConfirmation !== undefined) body.requireConfirmation = !!argv.requireConfirmation;
33
+ const caps = [
34
+ ['maxRefundAmount', 'max-refund-amount'],
35
+ ['maxDiscountPercent', 'max-discount-percent'],
36
+ ['maxDiscountFixedAmount', 'max-discount-fixed-amount'],
37
+ ];
38
+ for (const [key, flag] of caps) {
39
+ const v = parseCap(argv[key], flag);
40
+ if (v !== undefined) body[key] = v;
41
+ }
42
+ if (!Object.keys(body).length) throw new Error('Provide at least one option to change (see --help).');
43
+ return body;
44
+ }
45
+
46
+ function printPolicy(p) {
47
+ console.log(`\n${BOLD}Shopify write policy${RST}`);
48
+ console.log(` Shopify write skills enabled: ${p.enableShopifyWrite}`);
49
+ console.log(` Require confirmation: ${p.requireConfirmation}`);
50
+ console.log(` Max refund amount: ${p.maxRefundAmount}`);
51
+ console.log(` Max discount percent: ${p.maxDiscountPercent}`);
52
+ console.log(` Max discount fixed amount: ${p.maxDiscountFixedAmount}\n`);
53
+ }
54
+
55
+ async function getHandler() {
56
+ printPolicy(await messagingGet('/Shopify/WritePolicy'));
57
+ }
58
+
59
+ async function setHandler(argv) {
60
+ const updated = await messagingPut('/Shopify/WritePolicy', buildWritePolicyBody(argv));
61
+ console.log(`\n${GRN}✓${RST} Shopify write policy updated.`);
62
+ printPolicy(updated);
63
+ }
64
+
65
+ export const shopifyCommand = {
66
+ command: 'shopify <subcommand>',
67
+ describe: 'Shopify integration settings (write-tier policy: enable toggle, confirmation, refund/discount caps)',
68
+ builder: y => y
69
+ .command({
70
+ command: 'write-policy <action>',
71
+ describe: 'Get or set the Shopify write policy for this site',
72
+ builder: yy => yy
73
+ .positional('action', { choices: ['get', 'set'], type: 'string' })
74
+ .option('enable-shopify-write', { type: 'boolean', describe: 'Master toggle for the 11 Shopify write skills (reads unaffected)' })
75
+ .option('require-confirmation', { type: 'boolean', describe: 'Require customer confirmation before a write skill runs' })
76
+ .option('max-refund-amount', { type: 'string', describe: 'Max refund amount, or "default"' })
77
+ .option('max-discount-percent', { type: 'string', describe: 'Max percentage discount, or "default"' })
78
+ .option('max-discount-fixed-amount', { type: 'string', describe: 'Max fixed discount, or "default"' }),
79
+ handler: argv => (argv.action === 'get' ? getHandler() : setHandler(argv)),
80
+ })
81
+ .demandCommand(1, 'Specify a shopify subcommand.')
82
+ .strict(),
83
+ handler: () => {},
84
+ };
@@ -0,0 +1,79 @@
1
+ import { messagingGet, messagingPut } from '../api.js';
2
+ import { buildPolicyBody, findPolicy, describeMode, parseTeamId } from '../signature-policy.js';
3
+
4
+ // velaro signature policy get|set
5
+ //
6
+ // Company email signature policy for the calling admin's site. Goes through GET/PUT Signatures/policy in
7
+ // velaro-messaging (admin only there). The site comes from the caller's token, never from an argument.
8
+
9
+ const GRN = '\x1b[32m';
10
+ const DIM = '\x1b[2m';
11
+ const BOLD = '\x1b[1m';
12
+ const RESET = '\x1b[0m';
13
+
14
+ function printRow(label, row) {
15
+ console.log(`${BOLD}${label}${RESET}`);
16
+ if (!row) { console.log(` ${DIM}no override, follows the company-wide setting${RESET}`); return; }
17
+ console.log(` mode ${describeMode(row.mode)}`);
18
+ console.log(` signature ${row.corporateSignature ? `${row.corporateSignature.length} characters of HTML` : '(none)'}`);
19
+ const dis = row.disclaimerEnabled === null || row.disclaimerEnabled === undefined ? 'follows company-wide (off if none set)' : row.disclaimerEnabled ? 'on' : 'off';
20
+ console.log(` disclaimer ${dis}`);
21
+ if (row.disclaimerText) console.log(` text ${row.disclaimerText}`);
22
+ }
23
+
24
+ const getCommand = {
25
+ command: 'get',
26
+ describe: 'Show the company signature policy and any per-team overrides',
27
+ builder: (y) => y.option('team-id', { type: 'number', describe: 'Show only this team override (0 = company-wide)' }),
28
+ handler: async (argv) => {
29
+ const data = await messagingGet('/Signatures/policy');
30
+ const rows = Array.isArray(data.policies) ? data.policies : [];
31
+ if (argv['team-id'] !== undefined) {
32
+ printRow(`Team ${argv['team-id']}`, findPolicy(rows, argv['team-id']));
33
+ } else {
34
+ printRow('Company-wide', findPolicy(rows, 0));
35
+ for (const r of rows.filter((p) => p.teamId !== 0)) printRow(`Team ${r.teamId}`, r);
36
+ }
37
+ console.log(`\n${DIM}Suggested disclaimer:${RESET} ${data.suggestedDisclaimer ?? ''}`);
38
+ },
39
+ };
40
+
41
+ const setCommand = {
42
+ command: 'set',
43
+ describe: 'Update the signature policy (only the options you pass change; default clears back to company-wide)',
44
+ builder: (y) => y
45
+ .option('team-id', { type: 'number', default: 0, describe: '0 = company-wide, otherwise a per-team override' })
46
+ .option('mode', { type: 'string', describe: 'locked | company-default | personal-only | default' })
47
+ .option('signature', { type: 'string', describe: 'Company signature HTML (empty string clears it)' })
48
+ .option('disclaimer', { type: 'boolean', describe: 'Turn the legal disclaimer on or off' })
49
+ .option('disclaimer-default', { type: 'boolean', describe: 'Clear the disclaimer on/off choice back to company-wide' })
50
+ .option('disclaimer-text', { type: 'string', describe: 'Legal disclaimer text (empty string clears it)' }),
51
+ handler: async (argv) => {
52
+ const teamId = parseTeamId(argv['team-id']);
53
+ const data = await messagingGet('/Signatures/policy');
54
+ const current = findPolicy(data.policies, teamId);
55
+ const body = buildPolicyBody(current, {
56
+ teamId,
57
+ mode: argv.mode,
58
+ corporateSignature: argv.signature,
59
+ disclaimerEnabled: argv['disclaimer-default'] ? null : argv.disclaimer,
60
+ disclaimerText: argv['disclaimer-text'],
61
+ });
62
+ await messagingPut('/Signatures/policy', body);
63
+ console.log(`${GRN}Signature policy updated.${RESET} Run \`velaro signature policy get\` to review.`);
64
+ },
65
+ };
66
+
67
+ const policyCommand = {
68
+ command: 'policy <subcommand>',
69
+ describe: 'View and update the company email signature policy',
70
+ builder: (y) => y.command(getCommand).command(setCommand).demandCommand(1, 'Specify a subcommand: get | set'),
71
+ handler: () => {},
72
+ };
73
+
74
+ export const signatureCommand = {
75
+ command: 'signature <subcommand>',
76
+ describe: 'Email signature controls',
77
+ builder: (y) => y.command(policyCommand).demandCommand(1, 'Specify: policy get | policy set'),
78
+ handler: () => {},
79
+ };