@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
package/README.md CHANGED
@@ -138,6 +138,25 @@ velaro kb article screenshot 1234 \
138
138
 
139
139
  > Titles are auto-normalized to Title Case with no dashes, even if you pass a slug-style name — use `--no-title-normalize` to skip that.
140
140
 
141
+ ### `velaro logs insights <subcommand>` (Velaro staff only)
142
+
143
+ Grouped views over Velaro Logger data (same data as the Admin UI "Insights" tab). All subcommands accept `--last 6h`, `--platform v10|v20|admin`, `--environment Production|Staging`, `--site <id>`, `--env staging|prod` and `--json`.
144
+
145
+ ```bash
146
+ velaro logs insights groups --last 6h --platform v20 # error groups, most frequent first
147
+ velaro logs insights new # groups not seen in the previous 7 days
148
+ velaro logs insights regression --at 2h # before vs after a deploy
149
+ velaro logs insights trace <correlationId> # cross-platform timeline
150
+ velaro logs insights trace --site 1032 --last 30m
151
+ velaro logs insights anomalies --hours 48
152
+ velaro logs insights sites # per-site scorecard
153
+ velaro logs insights mutes
154
+ velaro logs insights mute <fingerprint> --reason "known outage" --expires 7d
155
+ velaro logs insights unmute <fingerprint>
156
+ velaro logs insights sensitive # emails/tokens/cards in logs
157
+ velaro logs insights brief <fingerprint> # AI markdown brief
158
+ ```
159
+
141
160
  ### `velaro status`
142
161
  Check that the Velaro API is reachable.
143
162
 
package/bin/velaro.js CHANGED
@@ -35,12 +35,14 @@ import { updateCommand } from '../lib/commands/update.js';
35
35
  import { agentCommand } from '../lib/commands/agent.js';
36
36
  import { linkedIdentityCommand } from '../lib/commands/linked-identity.js';
37
37
  import { notificationsCommand } from '../lib/commands/notifications.js';
38
+ import { signatureCommand } from '../lib/commands/signature.js';
38
39
  import { articleCommand } from '../lib/commands/article.js';
39
40
  import { sharepointCommand } from '../lib/commands/sharepoint.js';
40
41
  import { calendlyCommand } from '../lib/commands/calendly.js';
41
42
  import { copilotStudioCommand } from '../lib/commands/copilotstudio.js';
42
43
  import { acuityCommand } from '../lib/commands/acuity.js';
43
44
  import { avatarCommand } from '../lib/commands/avatar.js';
45
+ import { applePayPresetCommand } from '../lib/commands/apple-pay-preset.js';
44
46
  import { imageAnalysisCommand } from '../lib/commands/image-analysis.js';
45
47
  import { integrationCommand } from '../lib/commands/integration.js';
46
48
  import { appointmentsCommand } from '../lib/commands/appointments.js';
@@ -67,6 +69,7 @@ import { triageCommand } from '../lib/commands/triage.js';
67
69
  import { automationDraftCommand } from '../lib/commands/automation-draft.js';
68
70
  import { magentoCommand } from '../lib/commands/magento.js';
69
71
  import { sapCommand } from '../lib/commands/sap.js';
72
+ import { salesforceCommand } from '../lib/commands/salesforce.js';
70
73
  import { actionsCommand } from '../lib/commands/actions.js';
71
74
  import { mondayCommand } from '../lib/commands/monday.js';
72
75
  import { notionCommand } from '../lib/commands/notion.js';
@@ -80,6 +83,7 @@ import { zoomPhoneCommand } from '../lib/commands/zoom-phone.js';
80
83
  import { teamsCommand } from '../lib/commands/teams.js';
81
84
  import { slackCommand } from '../lib/commands/slack.js';
82
85
  import { webflowCommand } from '../lib/commands/webflow.js';
86
+ import { shopifyCommand } from '../lib/commands/shopify.js';
83
87
  import { netsuiteCommand } from '../lib/commands/netsuite.js';
84
88
  import { woocommerceCommand } from '../lib/commands/woocommerce.js';
85
89
  import { squarespaceCommand } from '../lib/commands/squarespace.js';
@@ -89,6 +93,7 @@ import { migrationStatusCommand } from '../lib/commands/migration-status.js';
89
93
  import { complianceCommand } from '../lib/commands/compliance.js';
90
94
  import { billingContactsCommand } from '../lib/commands/billing-contacts.js';
91
95
  import { billingSubscriptionCommand } from '../lib/commands/billing-subscription.js';
96
+ import { billingAutomationCommand } from '../lib/commands/billing-automation.js';
92
97
  import { orderFormCommand } from '../lib/commands/order-form.js';
93
98
  import { billingEmailPreferenceCommand } from '../lib/commands/billing-email-preference.js';
94
99
  import { billingCommand } from '../lib/commands/billing.js';
@@ -110,6 +115,8 @@ import { emailInboxCommand } from '../lib/commands/email-inbox.js';
110
115
  import { callbackCommand } from '../lib/commands/callback.js';
111
116
  import { caseCommand } from '../lib/commands/case.js';
112
117
  import { pickupCommand } from '../lib/commands/pickup.js';
118
+ import { applePayCommand } from '../lib/commands/apple-pay.js';
119
+ import { siteBuilderCommand } from '../lib/commands/site-builder.js';
113
120
  import { paymentRecoveryCommand } from '../lib/commands/payment-recovery.js';
114
121
  import { recruitingCommand } from '../lib/commands/recruiting.js';
115
122
  import { surveyCommand } from '../lib/commands/survey.js';
@@ -172,12 +179,14 @@ await yargs(hideBin(process.argv))
172
179
  .command(agentCommand)
173
180
  .command(linkedIdentityCommand)
174
181
  .command(notificationsCommand)
182
+ .command(signatureCommand)
175
183
  .command(articleCommand)
176
184
  .command(sharepointCommand)
177
185
  .command(calendlyCommand)
178
186
  .command(copilotStudioCommand)
179
187
  .command(acuityCommand)
180
188
  .command(avatarCommand)
189
+ .command(applePayPresetCommand)
181
190
  .command(imageAnalysisCommand)
182
191
  .command(integrationCommand)
183
192
  .command(appointmentsCommand)
@@ -206,6 +215,7 @@ await yargs(hideBin(process.argv))
206
215
  .command(automationDraftCommand)
207
216
  .command(magentoCommand)
208
217
  .command(sapCommand)
218
+ .command(salesforceCommand)
209
219
  .command(actionsCommand)
210
220
  .command(mondayCommand)
211
221
  .command(notionCommand)
@@ -219,6 +229,7 @@ await yargs(hideBin(process.argv))
219
229
  .command(teamsCommand)
220
230
  .command(slackCommand)
221
231
  .command(webflowCommand)
232
+ .command(shopifyCommand)
222
233
  .command(netsuiteCommand)
223
234
  .command(woocommerceCommand)
224
235
  .command(squarespaceCommand)
@@ -228,6 +239,7 @@ await yargs(hideBin(process.argv))
228
239
  .command(complianceCommand)
229
240
  .command(billingContactsCommand)
230
241
  .command(billingSubscriptionCommand)
242
+ .command(billingAutomationCommand)
231
243
  .command(orderFormCommand)
232
244
  .command(billingEmailPreferenceCommand)
233
245
  .command(billingCommand)
@@ -249,6 +261,8 @@ await yargs(hideBin(process.argv))
249
261
  .command(callbackCommand)
250
262
  .command(caseCommand)
251
263
  .command(pickupCommand)
264
+ .command(applePayCommand)
265
+ .command(siteBuilderCommand)
252
266
  .command(paymentRecoveryCommand)
253
267
  .command(recruitingCommand)
254
268
  .command(surveyCommand)
package/lib/api.js CHANGED
@@ -60,7 +60,7 @@ export const post = (path, body) => request('POST', path, body);
60
60
  export const put = (path, body) => request('PUT', path, body);
61
61
  export const del = (path) => request('DELETE', path);
62
62
 
63
- export async function messagingRequest(method, path, body, envOverride, extraHeaders = {}) {
63
+ export async function messagingRequest(method, path, body, envOverride, extraHeaders = {}, opts = {}) {
64
64
  const creds = await getCredentials(envOverride);
65
65
  const res = await fetch(joinUrl(creds.messagingApiBase, path), {
66
66
  method,
@@ -75,6 +75,7 @@ export async function messagingRequest(method, path, body, envOverride, extraHea
75
75
  ...extraHeaders,
76
76
  },
77
77
  body: body !== undefined ? JSON.stringify(body) : undefined,
78
+ ...(opts.timeoutMs ? { signal: AbortSignal.timeout(opts.timeoutMs) } : {}),
78
79
  });
79
80
  if (!res.ok) {
80
81
  let msg = `${method} ${path} → ${res.status}`;
@@ -0,0 +1,66 @@
1
+ // Pure helpers for `velaro apple-pay-preset`: parse line items and build request bodies for the
2
+ // messaging ApplePayPresetOrders API. Totals are always computed server-side; never sent from here.
3
+
4
+ export const MAX_LINE_ITEMS = 20;
5
+
6
+ /** Parse one "Label=12.50" spec. The last "=" splits label from amount so labels may contain "=". */
7
+ export function parseItem(spec) {
8
+ const s = String(spec ?? '');
9
+ const i = s.lastIndexOf('=');
10
+ if (i < 1) throw new Error(`Line item "${s}" must look like Label=12.50`);
11
+ const label = s.slice(0, i).trim();
12
+ const raw = s.slice(i + 1).trim();
13
+ if (!label) throw new Error(`Line item "${s}" needs a label`);
14
+ if (!/^-?\d+(\.\d{1,2})?$/.test(raw)) throw new Error(`Line item "${label}" amount "${raw}" must be a number with at most 2 decimals`);
15
+ return { label, amount: Number(raw) };
16
+ }
17
+
18
+ /** Accepts --item specs (string or array) and/or --items-json '[{"label":"x","amount":1}]'. Returns undefined if neither given. */
19
+ export function parseItems({ item, itemsJson } = {}) {
20
+ let items;
21
+ if (itemsJson !== undefined) {
22
+ let parsed;
23
+ try { parsed = JSON.parse(itemsJson); } catch { throw new Error('--items-json must be valid JSON'); }
24
+ if (!Array.isArray(parsed)) throw new Error('--items-json must be a JSON array');
25
+ items = parsed.map(p => {
26
+ if (!p || typeof p.label !== 'string' || !p.label.trim() || typeof p.amount !== 'number' || !Number.isFinite(p.amount)) {
27
+ throw new Error('Each --items-json entry needs a string label and a numeric amount');
28
+ }
29
+ return { label: p.label.trim(), amount: p.amount };
30
+ });
31
+ }
32
+ if (item !== undefined) {
33
+ const specs = Array.isArray(item) ? item : [item];
34
+ items = (items ?? []).concat(specs.map(parseItem));
35
+ }
36
+ if (items && items.length > MAX_LINE_ITEMS) throw new Error(`A preset order holds at most ${MAX_LINE_ITEMS} line items`);
37
+ return items;
38
+ }
39
+
40
+ /** Body for POST/PUT ApplePayPresetOrders. Only passed fields are included (PUT is a partial update). */
41
+ export function presetBody(args) {
42
+ const body = {};
43
+ if (args.name !== undefined) body.name = String(args.name);
44
+ if (args.description !== undefined) body.description = String(args.description);
45
+ if (args.currency !== undefined) body.currencyCode = String(args.currency).toUpperCase();
46
+ const items = parseItems({ item: args.item, itemsJson: args.itemsJson });
47
+ if (items !== undefined) body.lineItems = items;
48
+ if (args.active !== undefined) body.isActive = Boolean(args.active);
49
+ if (args.managersOnly !== undefined) body.overrideRestrictedToManagers = Boolean(args.managersOnly);
50
+ return body;
51
+ }
52
+
53
+ export function presetPath(id) {
54
+ const n = Number(id);
55
+ if (!Number.isInteger(n) || n <= 0) throw new Error('Preset id must be a positive whole number');
56
+ return `/ApplePayPresetOrders/${n}`;
57
+ }
58
+
59
+ export function listPath(activeOnly) {
60
+ return `/ApplePayPresetOrders${activeOnly ? '?activeOnly=true' : ''}`;
61
+ }
62
+
63
+ export function formatPreset(p) {
64
+ const items = (p.lineItems || []).map(li => ` ${li.label} ${Number(li.amount).toFixed(2)} ${p.currencyCode}`).join('\n');
65
+ return `#${p.id} ${p.name}${p.isActive ? '' : ' (inactive)'}${p.overrideRestrictedToManagers ? ' [managers-only amount changes]' : ''}\n${items}\n Total ${Number(p.totalAmount).toFixed(2)} ${p.currencyCode}`;
66
+ }
@@ -0,0 +1,61 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import { parseItem, parseItems, presetBody, presetPath, listPath, formatPreset } from './apple-pay-presets.js';
5
+
6
+ test('parseItem splits on the last "=" and validates the amount', () => {
7
+ assert.deepEqual(parseItem('Haircut=30'), { label: 'Haircut', amount: 30 });
8
+ assert.deepEqual(parseItem('A=B=1.50'), { label: 'A=B', amount: 1.5 });
9
+ assert.throws(() => parseItem('Haircut'), /Label=12.50/);
10
+ assert.throws(() => parseItem('=5'), /Label=12.50/);
11
+ assert.throws(() => parseItem('x=abc'), /number/);
12
+ assert.throws(() => parseItem('x=1.234'), /2 decimals/);
13
+ });
14
+
15
+ test('parseItems merges --item and --items-json, caps at 20, rejects bad json', () => {
16
+ assert.equal(parseItems({}), undefined);
17
+ assert.deepEqual(parseItems({ item: 'a=1' }), [{ label: 'a', amount: 1 }]);
18
+ assert.deepEqual(parseItems({ itemsJson: '[{"label":"b","amount":2}]', item: ['c=3'] }), [{ label: 'b', amount: 2 }, { label: 'c', amount: 3 }]);
19
+ assert.throws(() => parseItems({ itemsJson: '{' }), /valid JSON/);
20
+ assert.throws(() => parseItems({ itemsJson: '{}' }), /array/);
21
+ assert.throws(() => parseItems({ itemsJson: '[{"label":"","amount":1}]' }), /label/);
22
+ assert.throws(() => parseItems({ item: Array.from({ length: 21 }, (_, i) => `i${i}=1`) }), /at most 20/);
23
+ });
24
+
25
+ test('presetBody includes only passed fields and never a total', () => {
26
+ assert.deepEqual(presetBody({}), {});
27
+ assert.deepEqual(presetBody({ active: false }), { isActive: false });
28
+ const b = presetBody({ name: 'Cut', currency: 'usd', item: ['Haircut=30'], managersOnly: true, active: true, description: 'd' });
29
+ assert.deepEqual(b, { name: 'Cut', description: 'd', currencyCode: 'USD', lineItems: [{ label: 'Haircut', amount: 30 }], isActive: true, overrideRestrictedToManagers: true });
30
+ assert.equal('totalAmount' in b, false);
31
+ });
32
+
33
+ test('paths validate ids', () => {
34
+ assert.equal(presetPath('7'), '/ApplePayPresetOrders/7');
35
+ assert.throws(() => presetPath('0'), /positive/);
36
+ assert.throws(() => presetPath('x'), /positive/);
37
+ assert.equal(listPath(true), '/ApplePayPresetOrders?activeOnly=true');
38
+ assert.equal(listPath(false), '/ApplePayPresetOrders');
39
+ });
40
+
41
+ test('formatPreset shows items and total', () => {
42
+ const out = formatPreset({ id: 3, name: 'Cut', isActive: false, overrideRestrictedToManagers: true, currencyCode: 'USD', totalAmount: 35, lineItems: [{ label: 'Haircut', amount: 30 }, { label: 'Tip', amount: 5 }] });
43
+ assert.match(out, /#3 Cut \(inactive\) \[managers-only/);
44
+ assert.match(out, /Total {2}35.00 USD/);
45
+ });
46
+
47
+ test('MCP server exposes the four preset tools wired to ApplePayPresetOrders on the messaging api', () => {
48
+ const src = fs.readFileSync(new URL('../../mcp/server.js', import.meta.url), 'utf8');
49
+ for (const t of ['apple_pay_preset_list', 'apple_pay_preset_create', 'apple_pay_preset_update', 'apple_pay_preset_delete']) {
50
+ assert.ok(src.includes(`name: '${t}'`), `tool def ${t}`);
51
+ assert.ok(src.includes(`case '${t}':`), `handler ${t}`);
52
+ }
53
+ assert.match(src, /messagingRequest\('POST', '\/ApplePayPresetOrders'/);
54
+ assert.match(src, /messagingRequest\('DELETE', `\/ApplePayPresetOrders\//);
55
+ });
56
+
57
+ test('command file uses messaging helpers only', () => {
58
+ const src = fs.readFileSync(new URL('./commands/apple-pay-preset.js', import.meta.url), 'utf8');
59
+ assert.match(src, /messagingGet, messagingPost, messagingPut, messagingDel/);
60
+ assert.doesNotMatch(src, /import\s*\{[^}]*\b(get|post|del|request)\b[^}]*\}\s*from '\.\.\/api\.js'/);
61
+ });
@@ -0,0 +1,32 @@
1
+ // Pure helpers for `velaro apple-pay ...` (no I/O except the optional logo file read, so they are unit-testable).
2
+ // Backend: velaro-messaging ApplePayConfigsController (GET/POST ApplePayConfig, PUT ApplePayConfig/{id}).
3
+ // Only display + test-mode fields are exposed here. The merchant id / certificate decide the CHARGE and stay behind
4
+ // secure Velaro admin provisioning; this CLI deliberately cannot set them.
5
+
6
+ import fs from 'node:fs';
7
+
8
+ export const LOGO_MAX_BYTES = 500 * 1024;
9
+
10
+ /** Returns the base64 of a PNG/JPEG file under 500 KB, or throws with a plain-language reason. */
11
+ export function readLogoBase64(filePath) {
12
+ const bytes = fs.readFileSync(filePath);
13
+ if (bytes.length > LOGO_MAX_BYTES) throw new Error('--logo must be under 500 KB.');
14
+ const png = bytes.length > 8 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47;
15
+ const jpg = bytes.length > 8 && bytes[0] === 0xff && bytes[1] === 0xd8;
16
+ if (!png && !jpg) throw new Error('--logo must be a PNG or JPEG image.');
17
+ return bytes.toString('base64');
18
+ }
19
+
20
+ /**
21
+ * Builds the PUT/POST body from CLI args. Apply-if-present: only fields the operator named are sent.
22
+ * argv: { name, testMode, logo, clearLogo }. Returns {} when nothing was named.
23
+ */
24
+ export function buildApplePayUpdate(argv, readLogo = readLogoBase64) {
25
+ if (argv.logo && argv.clearLogo) throw new Error('Use either --logo or --clear-logo, not both.');
26
+ const body = {};
27
+ if (argv.name !== undefined) body.merchantDisplayName = String(argv.name);
28
+ if (argv.testMode !== undefined) body.isTestMode = !!argv.testMode;
29
+ if (argv.logo) body.bubbleLogoBase64 = readLogo(argv.logo);
30
+ else if (argv.clearLogo) body.bubbleLogoBase64 = '';
31
+ return body;
32
+ }
@@ -0,0 +1,32 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { buildApplePayUpdate, readLogoBase64, LOGO_MAX_BYTES } from './apple-pay.js';
7
+
8
+ function tmpFile(bytes) {
9
+ const f = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'applepay-')), 'logo.bin');
10
+ fs.writeFileSync(f, bytes);
11
+ return f;
12
+ }
13
+ const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]);
14
+
15
+ test('only named fields are sent, with the exact JSON names the controller binds', () => {
16
+ assert.deepEqual(buildApplePayUpdate({ name: 'Aurora Guitars' }), { merchantDisplayName: 'Aurora Guitars' });
17
+ assert.deepEqual(buildApplePayUpdate({ testMode: true }), { isTestMode: true });
18
+ assert.deepEqual(buildApplePayUpdate({ testMode: false }), { isTestMode: false });
19
+ assert.deepEqual(buildApplePayUpdate({}), {});
20
+ });
21
+
22
+ test('clear-logo sends an empty string; logo and clear-logo together are rejected', () => {
23
+ assert.deepEqual(buildApplePayUpdate({ clearLogo: true }), { bubbleLogoBase64: '' });
24
+ assert.throws(() => buildApplePayUpdate({ logo: 'x.png', clearLogo: true }), /either --logo or --clear-logo/);
25
+ });
26
+
27
+ test('logo is read as base64 and validated as PNG/JPEG under 500 KB', () => {
28
+ assert.equal(readLogoBase64(tmpFile(PNG)), PNG.toString('base64'));
29
+ assert.equal(buildApplePayUpdate({ logo: tmpFile(PNG) }).bubbleLogoBase64, PNG.toString('base64'));
30
+ assert.throws(() => readLogoBase64(tmpFile(Buffer.from('GIF89a-not-allowed'))), /PNG or JPEG/);
31
+ assert.throws(() => readLogoBase64(tmpFile(Buffer.concat([PNG, Buffer.alloc(LOGO_MAX_BYTES)]))), /under 500 KB/);
32
+ });
@@ -0,0 +1,42 @@
1
+ // Pure helpers for `velaro avatar workflow-switch | bulk-switch`.
2
+ // The on / off / rules switch per workflow or team lives in velaro-messaging (VelaroAvatarController):
3
+ // PUT /avatar/workflows/{id}/enabled body {"mode":"on"|"off"|"rules"}
4
+ // PUT /avatar/overrides/enabled body {"mode":..., "workflowIds":[], "groupIds":[]} (max 500 rows)
5
+ // "rules" clears the switch so the site rules decide. Kept dependency-free so it is unit-testable.
6
+
7
+ export const SWITCH_VALUES = ['on', 'off', 'rules'];
8
+ const MAX_BULK_ROWS = 500;
9
+
10
+ /** Returns the normalized mode or throws. Mirrors AvatarOverrideEnabledMode.TryParse on the server. */
11
+ export function parseSwitchValue(raw) {
12
+ const v = String(raw ?? '').trim().toLowerCase();
13
+ if (!SWITCH_VALUES.includes(v)) throw new Error(`The value must be one of: ${SWITCH_VALUES.join(', ')}`);
14
+ return v;
15
+ }
16
+
17
+ /** Parses "12,13, 14" into positive whole ids, de-duplicated. Throws on anything else. */
18
+ export function parseIdList(raw, label = '--ids') {
19
+ const parts = String(raw ?? '').split(',').map(s => s.trim()).filter(s => s !== '');
20
+ if (parts.length === 0) throw new Error(`${label} needs at least one id, e.g. ${label} 12,13`);
21
+ const ids = parts.map(p => {
22
+ const n = Number(p);
23
+ if (!/^\d+$/.test(p) || !Number.isInteger(n) || n <= 0) throw new Error(`${label} must be positive whole numbers; got "${p}"`);
24
+ return n;
25
+ });
26
+ return [...new Set(ids)];
27
+ }
28
+
29
+ /** Body for PUT /avatar/workflows/{id}/enabled. */
30
+ export function workflowSwitchBody(raw) {
31
+ return { mode: parseSwitchValue(raw) };
32
+ }
33
+
34
+ /** Body for PUT /avatar/overrides/enabled. Enforces the server's selection rules up front so a bad call fails before the network. */
35
+ export function bulkSwitchBody({ ids, groups, value }) {
36
+ const mode = parseSwitchValue(value);
37
+ const workflowIds = ids === undefined ? [] : parseIdList(ids, '--ids');
38
+ const groupIds = groups === undefined ? [] : parseIdList(groups, '--groups');
39
+ if (workflowIds.length + groupIds.length === 0) throw new Error('Pass --ids <workflowIds> and/or --groups <teamIds>');
40
+ if (workflowIds.length + groupIds.length > MAX_BULK_ROWS) throw new Error(`Too many rows in one call (max ${MAX_BULK_ROWS})`);
41
+ return { mode, workflowIds, groupIds };
42
+ }
@@ -0,0 +1,49 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import { parseSwitchValue, parseIdList, workflowSwitchBody, bulkSwitchBody } from './avatar-switch.js';
5
+
6
+ test('switch value accepts on, off, rules and normalizes case; anything else is rejected', () => {
7
+ assert.equal(parseSwitchValue('ON'), 'on');
8
+ assert.equal(parseSwitchValue(' rules '), 'rules');
9
+ assert.throws(() => parseSwitchValue('sometimes'), /one of: on, off, rules/);
10
+ assert.throws(() => parseSwitchValue(''), /one of/);
11
+ assert.throws(() => parseSwitchValue(undefined), /one of/);
12
+ });
13
+
14
+ test('id list parses comma lists, de-duplicates, and rejects non-positive or non-integer ids', () => {
15
+ assert.deepEqual(parseIdList('12, 13,12'), [12, 13]);
16
+ assert.throws(() => parseIdList(''), /at least one id/);
17
+ assert.throws(() => parseIdList('12,abc'), /positive whole numbers/);
18
+ assert.throws(() => parseIdList('0'), /positive whole numbers/);
19
+ assert.throws(() => parseIdList('1.5'), /positive whole numbers/);
20
+ assert.throws(() => parseIdList('-3'), /positive whole numbers/);
21
+ });
22
+
23
+ test('workflow switch sends exactly {mode}', () => {
24
+ assert.deepEqual(workflowSwitchBody('off'), { mode: 'off' });
25
+ assert.deepEqual(workflowSwitchBody('rules'), { mode: 'rules' });
26
+ assert.throws(() => workflowSwitchBody('maybe'), /one of/);
27
+ });
28
+
29
+ test('bulk switch builds the request shape and enforces selection rules', () => {
30
+ assert.deepEqual(bulkSwitchBody({ ids: '5,6', value: 'on' }), { mode: 'on', workflowIds: [5, 6], groupIds: [] });
31
+ assert.deepEqual(bulkSwitchBody({ groups: '9', value: 'rules' }), { mode: 'rules', workflowIds: [], groupIds: [9] });
32
+ assert.throws(() => bulkSwitchBody({ value: 'on' }), /Pass --ids/);
33
+ assert.throws(() => bulkSwitchBody({ ids: '5', value: 'bogus' }), /one of/);
34
+ const many = Array.from({ length: 501 }, (_, i) => i + 1).join(',');
35
+ assert.throws(() => bulkSwitchBody({ ids: many, value: 'off' }), /max 500/);
36
+ });
37
+
38
+ test('avatar command registers workflow-switch and bulk-switch and the MCP server exposes matching tools', () => {
39
+ const src = fs.readFileSync(new URL('./commands/avatar.js', import.meta.url), 'utf8');
40
+ assert.match(src, /command:\s+'workflow-switch <workflowId> <value>'/);
41
+ assert.match(src, /command:\s+'bulk-switch'/);
42
+ assert.match(src, /\/avatar\/workflows\/\$\{id\}\/enabled/);
43
+ assert.match(src, /\/avatar\/overrides\/enabled/);
44
+ const mcp = fs.readFileSync(new URL('../../mcp/server.js', import.meta.url), 'utf8');
45
+ assert.match(mcp, /name: 'avatar_set_workflow_switch'/);
46
+ assert.match(mcp, /name: 'avatar_bulk_set_switch'/);
47
+ assert.match(mcp, /`\/avatar\/workflows\/\$\{id\}\/enabled`/);
48
+ assert.match(mcp, /'\/avatar\/overrides\/enabled'/);
49
+ });
@@ -0,0 +1,28 @@
1
+ // Real running-build info from each service's /api/health `build` block.
2
+ // Admin reports {sha, shortSha, builtAt}; messaging reports {commitId, shortCommitId, builtAt}.
3
+ export function parseBuild(health) {
4
+ const b = health?.build;
5
+ if (!b) return null;
6
+ const full = b.commitId ?? b.sha ?? null;
7
+ const short = b.shortCommitId ?? b.shortSha ?? (full ? String(full).slice(0, 7) : null);
8
+ if (!short && !b.builtAt) return null;
9
+ return { short: short ?? '?', builtAt: b.builtAt ?? null };
10
+ }
11
+
12
+ export function ptLabel(dateStr) {
13
+ const d = new Date(dateStr);
14
+ const s = d.toLocaleString('en-US', {
15
+ timeZone: 'America/Los_Angeles', month: '2-digit', day: '2-digit',
16
+ hour: '2-digit', minute: '2-digit', hour12: false,
17
+ });
18
+ const tz = d.toLocaleString('en-US', { timeZone: 'America/Los_Angeles', timeZoneName: 'short' }).split(' ').pop();
19
+ return `${s.replace(',', '')} ${tz}`;
20
+ }
21
+
22
+ export function formatBuild(build, now = Date.now()) {
23
+ if (!build) return 'build unknown';
24
+ if (!build.builtAt) return `commit ${build.short} built unknown`;
25
+ const mins = Math.max(0, Math.round((now - new Date(build.builtAt).getTime()) / 60000));
26
+ const age = mins < 2 ? 'just now' : mins < 60 ? `${mins}m ago` : mins < 1440 ? `${Math.round(mins / 60)}h ago` : `${Math.round(mins / 1440)}d ago`;
27
+ return `commit ${build.short} built ${ptLabel(build.builtAt)} (${age})`;
28
+ }
@@ -0,0 +1,24 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { parseBuild, formatBuild } from './build-info.js';
4
+
5
+ test('parses messaging health build (commitId/shortCommitId)', () => {
6
+ const b = parseBuild({ build: { commitId: '5398fce8069e98de38edacb74db8c77155ca70a5', shortCommitId: '5398fce', builtAt: '2026-10-12T05:28:25Z' } });
7
+ assert.deepEqual(b, { short: '5398fce', builtAt: '2026-10-12T05:28:25Z' });
8
+ });
9
+
10
+ test('parses admin health build (sha/shortSha)', () => {
11
+ const b = parseBuild({ build: { sha: 'a908399d25ee3b0d35bcfca2a499ae6508d03c88', shortSha: 'a908399', builtAt: '2026-10-12T01:22:28Z' } });
12
+ assert.equal(b.short, 'a908399');
13
+ });
14
+
15
+ test('derives short from full sha; null without build block', () => {
16
+ assert.equal(parseBuild({ build: { sha: 'abcdef1234567', builtAt: 'x' } }).short, 'abcdef1');
17
+ assert.equal(parseBuild({}), null);
18
+ });
19
+
20
+ test('formats commit, PT time and age', () => {
21
+ const now = new Date('2026-10-12T05:50:25Z').getTime();
22
+ const s = formatBuild({ short: '5398fce', builtAt: '2026-10-12T05:28:25Z' }, now);
23
+ assert.match(s, /^commit 5398fce built 10\/11 22:28 PDT \(22m ago\)$/);
24
+ });
@@ -0,0 +1,65 @@
1
+ import { messagingGet, messagingPost, messagingPut, messagingDel } from '../api.js';
2
+ import { presetBody, presetPath, listPath, formatPreset } from '../apple-pay-presets.js';
3
+ import { runCommand } from '../run.js';
4
+
5
+ // ── velaro apple-pay-preset ──────────────────────────────────────────────────
6
+ // Pre-canned Apple Pay orders (messaging ApplePayPresetOrders API). Messaging routes, so
7
+ // every call goes through the messaging helpers. Totals are computed by the server.
8
+ // Registered as `apple-pay-preset` until the `apple-pay` command (PR #1774) lands, then it can nest as `apple-pay preset`.
9
+
10
+ const fieldOptions = y => y
11
+ .option('name', { type: 'string', describe: 'Preset name' })
12
+ .option('description', { type: 'string', describe: 'Preset description' })
13
+ .option('currency', { type: 'string', describe: 'ISO currency code, e.g. USD' })
14
+ .option('item', { type: 'string', array: true, describe: 'Line item as "Label=12.50" (repeatable, max 20)' })
15
+ .option('items-json', { type: 'string', describe: 'Line items as JSON: [{"label":"Haircut","amount":30}]' })
16
+ .option('active', { type: 'boolean', describe: 'Preset is available to agents and the AI' })
17
+ .option('managers-only', { type: 'boolean', describe: 'Only managers can change amounts at send time' });
18
+
19
+ export const applePayPresetCommand = {
20
+ command: 'apple-pay-preset <subcommand>',
21
+ describe: 'Manage pre-canned Apple Pay orders',
22
+ builder: yargs => yargs
23
+ .command({
24
+ command: 'list',
25
+ describe: 'List preset orders',
26
+ builder: y => y.option('active-only', { type: 'boolean', default: false }),
27
+ handler: runCommand(async argv => {
28
+ const rows = await messagingGet(listPath(argv.activeOnly));
29
+ const list = Array.isArray(rows) ? rows : [];
30
+ console.log(list.length ? `\n${list.map(formatPreset).join('\n\n')}\n` : '\nNo preset orders.\n');
31
+ }),
32
+ })
33
+ .command({
34
+ command: 'get <id>',
35
+ describe: 'Show one preset order',
36
+ handler: runCommand(async argv => { console.log(`\n${formatPreset(await messagingGet(presetPath(argv.id)))}\n`); }),
37
+ })
38
+ .command({
39
+ command: 'create',
40
+ describe: 'Create a preset order',
41
+ builder: y => fieldOptions(y).demandOption('name'),
42
+ handler: runCommand(async argv => {
43
+ const body = presetBody(argv);
44
+ if (!body.lineItems || !body.lineItems.length) throw new Error('Provide at least one --item "Label=12.50" (or --items-json)');
45
+ console.log(`\nCreated:\n${formatPreset(await messagingPost('/ApplePayPresetOrders', body))}\n`);
46
+ }),
47
+ })
48
+ .command({
49
+ command: 'update <id>',
50
+ describe: 'Update a preset order (only passed fields change)',
51
+ builder: fieldOptions,
52
+ handler: runCommand(async argv => {
53
+ const body = presetBody(argv);
54
+ if (!Object.keys(body).length) throw new Error('Pass at least one field to change');
55
+ console.log(`\nUpdated:\n${formatPreset(await messagingPut(presetPath(argv.id), body))}\n`);
56
+ }),
57
+ })
58
+ .command({
59
+ command: 'delete <id>',
60
+ describe: 'Delete a preset order',
61
+ handler: runCommand(async argv => { await messagingDel(presetPath(argv.id)); console.log(`\nDeleted preset ${Number(argv.id)}.\n`); }),
62
+ })
63
+ .demandCommand(1),
64
+ handler: () => {},
65
+ };
@@ -0,0 +1,73 @@
1
+ import { messagingGet, messagingPost, messagingPut } from '../api.js';
2
+ import { runCommand } from '../run.js';
3
+ import { buildApplePayUpdate } from '../apple-pay.js';
4
+
5
+ // ── velaro apple-pay ───────────────────────────────────────────────────────────
6
+ // CLI surface for the per-site Apple Pay display settings (business name, payment bubble logo, test mode).
7
+ // siteId is applied server-side from the vmsg-siteId header -- never a CLI arg.
8
+ //
9
+ // Backend contract: server/Velaro.Messaging/Controllers/Channels/ApplePayConfigsController.cs
10
+ // GET ApplePayConfig -> dto (id 0 when the site has no row yet)
11
+ // POST ApplePayConfig -> create (409 if one already exists)
12
+ // PUT ApplePayConfig/{id} -> apply-if-present update
13
+
14
+ const BOLD = '\x1b[1m';
15
+ const DIM = '\x1b[2m';
16
+ const GRN = '\x1b[32m';
17
+ const RST = '\x1b[0m';
18
+
19
+ async function configGetHandler() {
20
+ const c = await messagingGet('/ApplePayConfig');
21
+ console.log(`\n${BOLD}Apple Pay config${RST} (id ${c.id || 'none yet'})`);
22
+ console.log(` Enabled: ${c.isEnabled ? `${GRN}yes${RST}` : 'no'}`);
23
+ console.log(` Test mode (sandbox): ${c.isTestMode ? 'on -- no real money moves' : 'off'}`);
24
+ console.log(` Business name shown: ${c.merchantDisplayName ?? `${DIM}(default)${RST}`}`);
25
+ console.log(` Bubble logo: ${c.hasBubbleLogo ? 'custom' : `${DIM}(default image)${RST}`}`);
26
+ console.log(` Merchant: ${c.usingPlatformDefault ? `Velaro platform (${c.platformMerchantIdentifier || 'default'})` : c.merchantIdentifier}`);
27
+ console.log('');
28
+ }
29
+
30
+ async function configSetHandler(argv) {
31
+ const body = buildApplePayUpdate(argv);
32
+ if (Object.keys(body).length === 0) {
33
+ throw new Error('Nothing to set. Pass --name, --test-mode, --logo <file> or --clear-logo.');
34
+ }
35
+ const existing = await messagingGet('/ApplePayConfig');
36
+ const config = existing && existing.id > 0
37
+ ? await messagingPut(`/ApplePayConfig/${existing.id}`, body)
38
+ : await messagingPost('/ApplePayConfig', body);
39
+ console.log(`\n${GRN}✓${RST} Apple Pay config ${existing && existing.id > 0 ? 'updated' : 'created'} (id ${config.id}).\n`);
40
+ }
41
+
42
+ const configCommand = {
43
+ command: 'config <subcommand>',
44
+ describe: 'Get or set this site\'s Apple Pay display settings and test mode',
45
+ builder: (y) => y
46
+ .command({
47
+ command: 'get',
48
+ describe: 'Show this site\'s Apple Pay config',
49
+ handler: runCommand(configGetHandler),
50
+ })
51
+ .command({
52
+ command: 'set',
53
+ describe: 'Create or update display settings (apply-if-present -- omit a flag to leave it unchanged). Display only: never changes the charge.',
54
+ builder: (y2) => y2
55
+ .option('name', { describe: 'Business name shown on the Apple Pay sheet (pass "" to clear)', type: 'string' })
56
+ .option('test-mode', { describe: 'Sandbox mode: Velaro Stripe test account, no real money, real Wallet cards decline', type: 'boolean' })
57
+ .option('logo', { describe: 'Path to a PNG/JPEG (under 500 KB) for the payment bubble', type: 'string' })
58
+ .option('clear-logo', { describe: 'Remove the custom logo and use the default image', type: 'boolean' }),
59
+ handler: runCommand(configSetHandler),
60
+ })
61
+ .demandCommand(1, 'Specify a config subcommand: get | set'),
62
+ handler: () => {},
63
+ };
64
+
65
+ export const applePayCommand = {
66
+ command: 'apple-pay <subcommand>',
67
+ describe: 'Apple Pay (Apple Messages for Business) -- display settings and test mode',
68
+ builder: (y) => y
69
+ .command(configCommand)
70
+ .demandCommand(1, 'Specify a subcommand: config')
71
+ .strict(),
72
+ handler: () => {},
73
+ };