@velaro/cli 1.4.103 → 1.4.105

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/velaro.js CHANGED
@@ -42,6 +42,7 @@ import { calendlyCommand } from '../lib/commands/calendly.js';
42
42
  import { copilotStudioCommand } from '../lib/commands/copilotstudio.js';
43
43
  import { acuityCommand } from '../lib/commands/acuity.js';
44
44
  import { avatarCommand } from '../lib/commands/avatar.js';
45
+ import { applePayPresetCommand } from '../lib/commands/apple-pay-preset.js';
45
46
  import { imageAnalysisCommand } from '../lib/commands/image-analysis.js';
46
47
  import { integrationCommand } from '../lib/commands/integration.js';
47
48
  import { appointmentsCommand } from '../lib/commands/appointments.js';
@@ -97,6 +98,7 @@ import { orderFormCommand } from '../lib/commands/order-form.js';
97
98
  import { billingEmailPreferenceCommand } from '../lib/commands/billing-email-preference.js';
98
99
  import { billingCommand } from '../lib/commands/billing.js';
99
100
  import { couponGrantCommand } from '../lib/commands/coupon-grant.js';
101
+ import { signCommand } from '../lib/commands/sign.js';
100
102
  import { widgetContainerCommand } from '../lib/commands/widget-container.js';
101
103
  import { inlineWidgetConfigCommand } from '../lib/commands/inline-widget-config.js';
102
104
  import { pricingCommand } from '../lib/commands/pricing.js';
@@ -114,6 +116,7 @@ import { emailInboxCommand } from '../lib/commands/email-inbox.js';
114
116
  import { callbackCommand } from '../lib/commands/callback.js';
115
117
  import { caseCommand } from '../lib/commands/case.js';
116
118
  import { pickupCommand } from '../lib/commands/pickup.js';
119
+ import { visitorLinkCommand } from '../lib/commands/visitor-link.js';
117
120
  import { applePayCommand } from '../lib/commands/apple-pay.js';
118
121
  import { siteBuilderCommand } from '../lib/commands/site-builder.js';
119
122
  import { paymentRecoveryCommand } from '../lib/commands/payment-recovery.js';
@@ -124,6 +127,7 @@ import { chatWindowCommand } from '../lib/commands/chat-window.js';
124
127
  import { featureDiscoveryCommand } from '../lib/commands/feature-discovery.js';
125
128
  import { tourPolicyCommand } from '../lib/commands/tour-policy.js';
126
129
  import { scraperCommand } from '../lib/commands/scraper.js';
130
+ import { usageCommand } from '../lib/commands/usage.js';
127
131
  import { campaignsCommand } from '../lib/commands/campaigns.js';
128
132
  import { senderDomainHealthCommand } from '../lib/commands/sender-domain-health.js';
129
133
  import { contactsCommand } from '../lib/commands/contacts.js';
@@ -185,6 +189,7 @@ await yargs(hideBin(process.argv))
185
189
  .command(copilotStudioCommand)
186
190
  .command(acuityCommand)
187
191
  .command(avatarCommand)
192
+ .command(applePayPresetCommand)
188
193
  .command(imageAnalysisCommand)
189
194
  .command(integrationCommand)
190
195
  .command(appointmentsCommand)
@@ -242,6 +247,7 @@ await yargs(hideBin(process.argv))
242
247
  .command(billingEmailPreferenceCommand)
243
248
  .command(billingCommand)
244
249
  .command(couponGrantCommand)
250
+ .command(signCommand)
245
251
  .command(widgetContainerCommand)
246
252
  .command(inlineWidgetConfigCommand)
247
253
  .command({ command: 'pricing', desc: 'View and manage the pricing catalog', builder: pricingCommand, handler: () => {} })
@@ -259,6 +265,7 @@ await yargs(hideBin(process.argv))
259
265
  .command(callbackCommand)
260
266
  .command(caseCommand)
261
267
  .command(pickupCommand)
268
+ .command(visitorLinkCommand)
262
269
  .command(applePayCommand)
263
270
  .command(siteBuilderCommand)
264
271
  .command(paymentRecoveryCommand)
@@ -267,6 +274,7 @@ await yargs(hideBin(process.argv))
267
274
  .command(bundleCommand)
268
275
  .command(chatWindowCommand)
269
276
  .command({ command: 'feature-discovery', desc: 'View and act on feature-discovery suggestions for this site', builder: featureDiscoveryCommand, handler: () => {} })
277
+ .command(usageCommand)
270
278
  .command({ command: 'scraper', desc: 'Velaro staff: scraper / upload / chunk usage for any site', builder: scraperCommand, handler: () => {} })
271
279
  .command({ command: 'tour-policy', desc: 'Velaro staff: guided-tour auto-open policy (versioned, rollback-able)', builder: tourPolicyCommand, handler: () => {} })
272
280
  .command(campaignsCommand)
@@ -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,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
+ };
@@ -136,6 +136,16 @@ async function setReceipts(subscriptionId, args) {
136
136
  console.log(`Subscription ${res.id} receipts: card=${res.card ?? 'inherit'} ach=${res.ach ?? 'inherit'} other=${res.other ?? 'inherit'}.`);
137
137
  }
138
138
 
139
+ async function digestSendNow() {
140
+ const res = await request('POST', '/api/BillingNotifications/digest/send-now', {});
141
+ console.log(`Digest sent to ${res.recipients.join(', ')}: ${res.subject}`);
142
+ }
143
+
144
+ async function receiptsRunNow() {
145
+ const res = await request('POST', '/api/BillingNotifications/receipts/run-now', {});
146
+ console.log(`Receipts run: ${res.summary}`);
147
+ }
148
+
139
149
  export const billingAutomationCommand = {
140
150
  command: 'billing-automation <subcommand>',
141
151
  describe: 'Review-hold and dry-run switches for invoice emails and charges, the review queue, and account types',
@@ -161,6 +171,8 @@ export const billingAutomationCommand = {
161
171
  .command('dismiss <id>', 'Dismiss a held entry (it stays on hold, alerts stop)', {}, (args) => dismiss(args.id))
162
172
  .command('account-type <subscriptionId> [value]', 'Set a subscription account type that rules match on (omit value to clear)', {}, (args) => setAccountType(args.subscriptionId, args.value))
163
173
  .command('notify-settings', 'Show the global notification settings (receipts, invoice BCC, digest recipients)', {}, () => notifySettings())
174
+ .command('digest-send-now', 'Send the billing digest now to digest.recipients (same email as the 7am run)', {}, () => digestSendNow())
175
+ .command('receipts-run-now', 'Decide pending payment receipts now instead of at :05 and print the counts', {}, () => receiptsRunNow())
164
176
  .command('notify-set <key> <value>', 'Set a global notification setting: receipts.card|receipts.ach|receipts.other = never|send; invoices.bcc = emails (blank = none); digest.recipients = emails', {}, (args) => notifySet(args.key, args.value))
165
177
  .command('receipts <subscriptionId>', 'Set receipt overrides on one subscription (--card/--ach/--other = never|send). Channels you omit keep their current override.', {
166
178
  card: { type: 'string', choices: ['never', 'send'] },
@@ -21,6 +21,9 @@ const REPORTS = {
21
21
  'promise-ledger': 'Agent commitments made in chat and whether a follow-up appeared; overdue list',
22
22
  'new-agent-ramp': 'Agents whose first chat is in --from/--to: week-by-week ramp (chats, first response, QA, rating) against tenured agents',
23
23
  'promise-dismiss': 'Dismiss one promise by --id (managers only)',
24
+ 'bonus': 'Bonus insights, pick one with --name: ' + 'coverage_gap | next_best_fix | escalation_risk | skill_gap_matrix | handle_quality_outliers | abandon_causes' + ' (deterministic, no AI cost; assumptions are echoed in the result)',
25
+ 'operations-briefing': 'One run of every package report from a single data load, plus one cached token-capped AI narrative (--no-narrative skips it); reports its own AI cost',
26
+ 'entitled': 'Which Enterprise Reports items this site holds (includes Entry-point routing and Automatic tagging, which have no report of their own)',
24
27
  'weekly-brief-send-test': 'Email this week\'s brief to the signed-in user',
25
28
  'year-in-review': 'Build the 12-month Year in review document: --from/--to (default last 12 whole Pacific months) starts a build and prints its id; --id <id> checks status and the download link',
26
29
  };
@@ -46,6 +49,13 @@ export const enterpriseReportsCommand = {
46
49
  .option('time-zone', { type: 'string', describe: 'demand-coverage: IANA time zone (default America/Los_Angeles)' })
47
50
  .option('low-score', { type: 'number', describe: 'bot-containment: QA score below which a handed-off chat counts as regret (1-100, default 60)' })
48
51
  .option('include-demo', { type: 'boolean', describe: 'Show only the seeded sample (demo) data' })
52
+ .option('name', { type: 'string', describe: 'bonus: which analysis (coverage_gap|next_best_fix|escalation_risk|skill_gap_matrix|handle_quality_outliers|abandon_causes)' })
53
+ .option('narrative', { type: 'boolean', describe: 'operations-briefing: include the AI narrative (default true; --no-narrative to skip)' })
54
+ .option('minutes-per-review', { type: 'number', describe: 'bonus: assumed minutes per manual review (0-240)' })
55
+ .option('hourly-cost', { type: 'number', describe: 'bonus: assumed reviewer hourly cost (0-1000)' })
56
+ .option('deflection-pct', { type: 'number', describe: 'bonus: assumed deflection percent (0-100)' })
57
+ .option('cost-per-chat', { type: 'number', describe: 'bonus: assumed cost per chat (0-1000)' })
58
+ .option('wait-minutes', { type: 'number', describe: 'bonus: wait minutes that counts as abandoned (1-1440)' })
49
59
  .option('id', { type: 'string', describe: 'promise-dismiss: the promise id; year-in-review: the build id to check' }),
50
60
  handler: runCommand(async (argv) => {
51
61
  if (argv.report === 'weekly-brief-send-test') {
@@ -66,10 +76,15 @@ export const enterpriseReportsCommand = {
66
76
  console.log(JSON.stringify(await messagingPost(`/api/enterprise-reports/promise-ledger/${encodeURIComponent(String(argv.id))}/dismiss`, {}), null, 2));
67
77
  return;
68
78
  }
79
+ let seg = argv.report;
80
+ if (argv.report === 'bonus') {
81
+ if (!argv.name) throw new Error('--name is required for bonus (coverage_gap|next_best_fix|escalation_risk|skill_gap_matrix|handle_quality_outliers|abandon_causes)');
82
+ seg = `bonus/${encodeURIComponent(String(argv.name).replace(/-/g, '_'))}`;
83
+ }
69
84
  const qs = new URLSearchParams();
70
- const map = { from: 'from', to: 'to', groupBy: 'groupBy', deploymentId: 'deploymentId', teamId: 'teamId', threshold: 'threshold', perAgent: 'perAgent', limit: 'limit', days: 'days', windowDays: 'windowDays', weeks: 'weeks', handleMinutes: 'handleMinutes', maxConcurrent: 'maxConcurrent', timeZone: 'timeZone', lowScore: 'lowScore', includeDemo: 'includeDemo' };
85
+ const map = { minutesPerReview: 'minutesPerReview', hourlyCost: 'hourlyCost', deflectionPct: 'deflectionPct', costPerChat: 'costPerChat', waitMinutes: 'waitMinutes', narrative: 'narrative', from: 'from', to: 'to', groupBy: 'groupBy', deploymentId: 'deploymentId', teamId: 'teamId', threshold: 'threshold', perAgent: 'perAgent', limit: 'limit', days: 'days', windowDays: 'windowDays', weeks: 'weeks', handleMinutes: 'handleMinutes', maxConcurrent: 'maxConcurrent', timeZone: 'timeZone', lowScore: 'lowScore', includeDemo: 'includeDemo' };
71
86
  for (const [arg, key] of Object.entries(map)) if (argv[arg] != null && argv[arg] !== '') qs.set(key, String(argv[arg]));
72
87
  const q = qs.toString();
73
- console.log(JSON.stringify(await messagingGet(`/api/enterprise-reports/${argv.report}${q ? `?${q}` : ''}`), null, 2));
88
+ console.log(JSON.stringify(await messagingGet(`/api/enterprise-reports/${seg}${q ? `?${q}` : ''}`), null, 2));
74
89
  }),
75
90
  };
@@ -166,6 +166,45 @@ async function reconcile(args) {
166
166
  }
167
167
  }
168
168
 
169
+ async function trial(args) {
170
+ const sub = args.action;
171
+ const need = (v, n) => { if (v === undefined || v === null || v === '') { console.error(`--${n} is required for trial ${sub}`); process.exit(1); } };
172
+ if (sub === 'get' || sub === 'recommend') {
173
+ need(args.site, 'site');
174
+ const r = await request('GET', `Entitlements/${args.site}/trial${sub === 'recommend' ? '/recommendation' : ''}`);
175
+ console.log(JSON.stringify(r, null, 2));
176
+ } else if (sub === 'not-paying') {
177
+ console.log(JSON.stringify(await request('GET', 'Entitlements/admin/trial/not-paying'), null, 2));
178
+ } else if (sub === 'grant') {
179
+ need(args.site, 'site'); need(args.feature, 'feature'); need(args.reason, 'reason');
180
+ const r = await request('POST', `Entitlements/${args.site}/trial/grant`, { featureKey: args.feature, reason: args.reason });
181
+ console.log(`Site ${args.site}: ${r.featureKey} granted for the trial (${r.state}).`);
182
+ } else if (sub === 'hold' || sub === 'reactivate') {
183
+ need(args.site, 'site');
184
+ const body = { featureKey: args.feature, dryRun: !!args['dry-run'] };
185
+ console.log(JSON.stringify(await request('POST', `Entitlements/${args.site}/trial/${sub}`, body)));
186
+ } else if (sub === 'set-limit') {
187
+ need(args.site, 'site'); need(args.key, 'key'); need(args.value, 'value'); need(args.reason, 'reason');
188
+ const r = await request('POST', `Entitlements/${args.site}/trial/limit`, { key: args.key, value: Number(args.value), reason: args.reason });
189
+ console.log(`Site ${args.site}: ${r.key} ${r.old} -> ${r.value}.`);
190
+ } else if (sub === 'set-text') {
191
+ need(args.feature, 'feature');
192
+ console.log(JSON.stringify(await request('POST', 'Entitlements/admin/trial/feature-text', { featureKey: args.feature, text: args.text })));
193
+ } else if (sub === 'approve-limit' || sub === 'deny-limit') {
194
+ need(args.request, 'request');
195
+ if (sub === 'approve-limit') {
196
+ need(args.key, 'key'); need(args.value, 'value');
197
+ console.log(JSON.stringify(await request('POST', `Entitlements/admin/trial/limit-request/${args.request}/approve`, { key: args.key, value: Number(args.value), message: args.message })));
198
+ } else {
199
+ need(args.reason, 'reason');
200
+ console.log(JSON.stringify(await request('POST', `Entitlements/admin/trial/limit-request/${args.request}/deny`, { reason: args.reason })));
201
+ }
202
+ } else {
203
+ console.error('Unknown trial action. Use: get | recommend | not-paying | grant | hold | reactivate | set-limit | approve-limit | deny-limit | set-text');
204
+ process.exit(1);
205
+ }
206
+ }
207
+
169
208
  async function setLock(args) {
170
209
  if (!args.site) {
171
210
  console.error('Usage: velaro entitlement set-lock --site 1032 --at 2026-01-01T00:00:00Z\n velaro entitlement set-lock --site 1032 --unlock');
@@ -250,10 +289,21 @@ export function entitlementCommand(yargs) {
250
289
  .option('at', { type: 'string', desc: 'ISO 8601 timestamp to lock entitlement resolution to' })
251
290
  .option('unlock', { type: 'boolean', desc: 'Clear an existing lock (resolves against current plan version again)' })
252
291
  , a => setLock(a))
292
+ .command('trial <action>', 'Trial governance (superadmin): get | recommend | not-paying | grant | hold | reactivate | set-limit | approve-limit | deny-limit | set-text', y => y
293
+ .option('site', { type: 'number', desc: 'Site ID' })
294
+ .option('feature', { type: 'string', desc: 'Feature key, e.g. EnableIvr' })
295
+ .option('key', { type: 'string', desc: 'Cap name, e.g. MaxKbArticles' })
296
+ .option('value', { type: 'string', desc: 'Whole number cap' })
297
+ .option('reason', { type: 'string', desc: 'Reason (audited)' })
298
+ .option('request', { type: 'number', desc: 'Limit-increase request id' })
299
+ .option('message', { type: 'string', desc: 'approve-limit: note added to the customer reply' })
300
+ .option('text', { type: 'string', desc: 'set-text: customer-safe, number-free limit text' })
301
+ .option('dry-run', { type: 'boolean', desc: 'hold: report only, write nothing' })
302
+ , a => trial(a))
253
303
  .command('list-cutover', 'List all feature keys and whether they are live (messaging reads admin) or not (Subscription.cs)', () => {}, () => listCutover())
254
304
  .command('set-cutover', 'Flip a feature key live (or roll it back). Live = messaging reads admin value within 45s. --rollback reverts to Subscription.cs.', y => y
255
305
  .option('key', { type: 'string', demandOption: true, desc: 'Feature key, e.g. EnableIvr' })
256
306
  .option('rollback', { type: 'boolean', desc: 'Pass to roll back this flag to Subscription.cs (default: go live)' })
257
307
  , a => setCutover(a))
258
- .demandCommand(1, 'Specify a subcommand: get | list-features | enterprise-reports | report-packages | set-override | remove-override | seed | seed-plans | backfill | backfill-sap-tiers | backfill-legacy-write-tiers | reconcile | comp | set-lock | list-cutover | set-cutover');
308
+ .demandCommand(1, 'Specify a subcommand: get | list-features | enterprise-reports | report-packages | set-override | remove-override | seed | seed-plans | backfill | backfill-sap-tiers | backfill-legacy-write-tiers | reconcile | comp | set-lock | trial | list-cutover | set-cutover');
259
309
  }
@@ -32,7 +32,7 @@ export const envCommand = {
32
32
  ? `logged in as ${creds.userName ?? 'unknown'} (site ${creds.siteId})`
33
33
  : 'not logged in';
34
34
  const expiry = creds?.velaroExpires
35
- ? new Date(creds.velaroExpires) > new Date() ? '' : ' ⚠ token expired'
35
+ ? new Date(creds.velaroExpires) > new Date() ? '' : creds.entraRefreshToken ? ' (session token idle; auto-refreshes on next command)' : ' ⚠ token expired, run velaro login'
36
36
  : '';
37
37
  console.log(`${marker}${name.padEnd(10)} ${status}${expiry}`);
38
38
  console.log(` ${urls.adminApiBase}`);
@@ -362,8 +362,6 @@ function parsePositiveIntFlag(flagName, rawValue, { max } = {}) {
362
362
  // emails/sms isn't wrongly flagged as "fires into the void" when defaults
363
363
  // actually cover it.
364
364
  function hasAnyRecipient({ emails, sms, webhook, teams, mobileUserIds, hasGlobalDefaultContacts }) {
365
- // also cover webhook, which that component's check omits).
366
- function hasAnyRecipient({ emails, sms, webhook, mobileUserIds, teams }) {
367
365
  return Boolean(
368
366
  (emails && String(emails).trim()) ||
369
367
  (sms && String(sms).trim()) ||
@@ -374,8 +372,6 @@ function hasAnyRecipient({ emails, sms, webhook, mobileUserIds, teams }) {
374
372
  // ResolveRecipients) -- if there's at least one global-default contact with a phone or
375
373
  // email, a rule with both blank is NOT actually silent, even though neither field is set.
376
374
  ((!emails || !String(emails).trim()) && (!sms || !String(sms).trim()) && hasGlobalDefaultContacts)
377
- (mobileUserIds && String(mobileUserIds).trim()) ||
378
- teams
379
375
  );
380
376
  }
381
377
 
@@ -532,10 +528,6 @@ async function createAlertRule(argv) {
532
528
  if (!hasAnyRecipient({ emails: argv.emails, sms: argv.sms, webhook: argv.webhook, teams: argv.teams, mobileUserIds: argv.mobileUserIds, hasGlobalDefaultContacts: hasDefaults })
533
529
  && !argv.noRecipientsOk) {
534
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.');
535
- // HIGH: a rule with zero notification channels fires into the void.
536
- if (!hasAnyRecipient({ emails: argv.emails, sms: argv.sms, webhook: argv.webhook, mobileUserIds: argv.mobileUserIds, teams: argv.teams })
537
- && !argv.noRecipientsOk) {
538
- console.error('No notification channel given (--emails, --sms, --webhook, --teams, or --mobile-user-ids) — this rule would fire but notify no one.');
539
531
  console.error('If that\'s intentional (e.g. you only want it visible in Alert History), pass --no-recipients-ok to proceed anyway.');
540
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');
541
533
  process.exit(1);
@@ -658,9 +650,6 @@ async function updateAlertRule(argv) {
658
650
  if (!hasAnyRecipient({ emails: body.notifyEmails, sms: body.notifyPhoneNumbers, webhook: body.notifyWebhookUrl, teams: body.notifyTeams, mobileUserIds: body.notifyMobileUserIds, hasGlobalDefaultContacts: hasDefaults })
659
651
  && !argv.noRecipientsOk) {
660
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.');
661
- if (!hasAnyRecipient({ emails: body.notifyEmails, sms: body.notifyPhoneNumbers, webhook: body.notifyWebhookUrl, mobileUserIds: body.notifyMobileUserIds, teams: body.notifyTeams })
662
- && !argv.noRecipientsOk) {
663
- console.error('This update would leave the rule with no notification channel (--emails, --sms, --webhook, --teams, or --mobile-user-ids) — it would fire but notify no one.');
664
653
  console.error('If that\'s intentional, pass --no-recipients-ok to proceed anyway.');
665
654
  process.exit(1);
666
655
  }
@@ -106,21 +106,6 @@ const mcpKeyInstallCommand = {
106
106
  const result = await request('POST', '/McpApiKeys', payload, env);
107
107
  const rawKey = result.rawKey;
108
108
 
109
- // Claude Code reads user-scope MCP server definitions from the
110
- // top-level "mcpServers" key in ~/.claude.json — NOT from
111
- // ~/.claude/settings.json (that file is for permissions/hooks/env,
112
- // and any mcpServers entry written there is silently ignored).
113
- // prod → entry named "velaro"
114
- // staging → entry named "velaro-staging"
115
- const serverName = env === 'prod' ? 'velaro' : `velaro-${env}`;
116
- const claudeJsonPath = path.join(os.homedir(), '.claude.json');
117
- let claudeJson = {};
118
- if (fs.existsSync(claudeJsonPath)) {
119
- try { claudeJson = JSON.parse(fs.readFileSync(claudeJsonPath, 'utf8')); } catch {}
120
- }
121
-
122
- claudeJson.mcpServers = claudeJson.mcpServers || {};
123
- claudeJson.mcpServers[serverName] = {
124
109
  // Claude Code reads MCP server definitions from the top-level "mcpServers" key
125
110
  // in ~/.claude.json (NOT ~/.claude/settings.json — that file has no effect here).
126
111
  // This is the same shape `claude mcp add` itself writes.
@@ -152,8 +137,7 @@ const mcpKeyInstallCommand = {
152
137
  },
153
138
  };
154
139
 
155
- fs.mkdirSync(path.dirname(claudeJsonPath), { recursive: true });
156
- fs.writeFileSync(claudeJsonPath, JSON.stringify(claudeJson, null, 2));
140
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
157
141
  // Claude Code itself writes ~/.claude.json continuously (history, project state),
158
142
  // so this must never be a plain in-place write: write to a temp file and rename,
159
143
  // which is atomic on the same filesystem — a crash or concurrent write mid-save
@@ -43,6 +43,13 @@ async function listHandler() {
43
43
  printConfigs(configs);
44
44
  }
45
45
 
46
+ async function rollbackAuthHandler(argv) {
47
+ const r = await messagingPost('/NetSuite/Auth/RollbackAuthType', { id: argv.configId });
48
+ console.log(`
49
+ ${GRN}✓${RST} Config ${r?.id ?? argv.configId} switched back to ${r?.authType ?? 'oauth1'}.
50
+ `);
51
+ }
52
+
46
53
  async function urlsHandler(argv) {
47
54
  const urls = await messagingGet(`/NetSuite/Integrations/${argv.configId}/CtiUrls`);
48
55
  printUrls(urls);
@@ -147,6 +154,17 @@ export const netsuiteCommand = {
147
154
  .strict(),
148
155
  handler: () => {},
149
156
  })
157
+ .command({
158
+ command: 'embed-status',
159
+ describe: 'Alias of: embed status',
160
+ handler: embedStatusHandler,
161
+ })
162
+ .command({
163
+ command: 'rollback-auth <configId>',
164
+ describe: 'Undo a switch to OAuth 2.0/M2M: restore OAuth 1.0a (only while old credentials still exist)',
165
+ builder: a => a.positional('configId', { type: 'number' }),
166
+ handler: rollbackAuthHandler,
167
+ })
150
168
  .command({
151
169
  command: 'suiteapp',
152
170
  describe: 'Download the screen-pop SuiteApp zip (key included by default: no API Secret to create)',
@@ -71,18 +71,26 @@ const calibration = {
71
71
 
72
72
  const rubric = {
73
73
  command: 'rubric <action>',
74
- describe: 'QA rubrics: list | get | default | create | update | delete (--json for the rubric body)',
75
- builder: (y) => y.positional('action', { choices: ['list', 'get', 'default', 'create', 'update', 'delete'] })
76
- .option('id', { type: 'number' }).option('json', { type: 'string', describe: 'Rubric JSON body' }),
74
+ describe: 'QA rubrics: list | get | default | starter | create | update | clone | activate | deactivate | rescore | delete (--json for the rubric body)',
75
+ builder: (y) => y.positional('action', { choices: ['list', 'get', 'default', 'starter', 'create', 'update', 'clone', 'activate', 'deactivate', 'rescore', 'delete'] })
76
+ .option('id', { type: 'number' }).option('json', { type: 'string', describe: 'Rubric JSON body' })
77
+ .option('name', { type: 'string', describe: 'clone: name of the copy' })
78
+ .option('days', { type: 'number', default: 30, describe: 'rescore: look back this many days (1-90)' })
79
+ .option('limit', { type: 'number', default: 200, describe: 'rescore: max chats (1-500)' }),
77
80
  handler: runCommand(async (a) => {
78
81
  const base = '/api/admin/QualityQa/rubrics';
79
82
  const body = a.json ? JSON.parse(a.json) : {};
80
83
  if (a.action === 'list') return print(await messagingGet(base));
81
84
  if (a.action === 'default') return print(await messagingGet(`${base}/default`));
85
+ if (a.action === 'starter') return print(await messagingPost(`${base}/starter`, {}));
86
+ if (a.action === 'rescore') return print(await messagingPost(`/api/admin/QualityQa/rescore?days=${a.days}&limit=${a.limit}`, {}));
82
87
  if (a.action === 'create') return print(await messagingPost(base, body));
83
88
  if (a.id == null) throw new Error('--id is required');
84
89
  if (a.action === 'get') return print(await messagingGet(`${base}/${a.id}`));
85
90
  if (a.action === 'update') return print(await messagingPut(`${base}/${a.id}`, body));
91
+ if (a.action === 'clone') return print(await messagingPost(`${base}/${a.id}/clone`, a.name ? { name: a.name } : {}));
92
+ if (a.action === 'activate') return print(await messagingPut(`${base}/${a.id}`, { isActive: true }));
93
+ if (a.action === 'deactivate') return print(await messagingPut(`${base}/${a.id}`, { isActive: false }));
86
94
  print(await messagingDel(`${base}/${a.id}`));
87
95
  }),
88
96
  };
@@ -0,0 +1,49 @@
1
+ // velaro sign - Velaro Sign document versions (owner-only). REST: GET /api/Contract/{id}/versions,
2
+ // GET /api/Contract/{id}/diff, POST /api/Contract/{id}/document (multipart, draft only).
3
+ import { getCredentials, get } from '../api.js';
4
+ import {
5
+ versionsPath, diffPath, uploadPath, describeVersions, describeDiff, buildUploadForm, OWNER_ONLY_RULE,
6
+ } from '../sign-versions.js';
7
+
8
+ async function versions(id, json) {
9
+ const res = await get(versionsPath(id));
10
+ console.log(json ? JSON.stringify(res, null, 2) : describeVersions(res));
11
+ }
12
+
13
+ async function diff(id, from, to, json) {
14
+ const res = await get(diffPath(id, from, to));
15
+ console.log(json ? JSON.stringify(res, null, 2) : describeDiff(res));
16
+ }
17
+
18
+ async function uploadVersion(id, file, note) {
19
+ const creds = await getCredentials();
20
+ const form = buildUploadForm(file, note);
21
+ const base = String(creds.adminApiBase).replace(/\/+$/, '');
22
+ const res = await fetch(`${base}${uploadPath(id)}`, {
23
+ method: 'POST',
24
+ headers: { Authorization: `Bearer ${creds.velaroToken}` },
25
+ body: form,
26
+ });
27
+ const text = await res.text();
28
+ if (!res.ok) throw new Error(`POST ${uploadPath(id)} -> ${res.status}: ${text.slice(0, 300)}\n${OWNER_ONLY_RULE}`);
29
+ console.log(`Uploaded new version for contract ${id}.`);
30
+ console.log(describeVersions(await get(versionsPath(id))));
31
+ }
32
+
33
+ export const signCommand = {
34
+ command: 'sign <subcommand>',
35
+ describe: 'Velaro Sign document versions (owner-only; signers never see old versions)',
36
+ builder: (yargs) =>
37
+ yargs
38
+ .command('versions <contractId>', 'List every uploaded document version of a contract', {
39
+ json: { type: 'boolean', default: false },
40
+ }, (a) => versions(a.contractId, a.json))
41
+ .command('diff <contractId> <from> <to>', 'Text diff between two document versions', {
42
+ json: { type: 'boolean', default: false },
43
+ }, (a) => diff(a.contractId, a.from, a.to, a.json))
44
+ .command('upload-version <contractId> <file>', 'Upload a new PDF version (draft contracts only; after send/sign void and re-send)', {
45
+ note: { type: 'string', describe: 'Change note stored with the version' },
46
+ }, (a) => uploadVersion(a.contractId, a.file, a.note))
47
+ .demandCommand(1, 'Specify: versions | diff | upload-version'),
48
+ handler: () => {},
49
+ };
@@ -0,0 +1,28 @@
1
+ // velaro usage alerts — plan-limit alerts for the authenticated site (GET Usage/alerts on the messaging API).
2
+ // Same rules as the usage-limit emails and the dashboard banner; site comes from auth, never an argument.
3
+ import { messagingGet } from '../api.js';
4
+
5
+ async function alerts(argv) {
6
+ const r = await messagingGet('/Usage/alerts');
7
+ if (argv.json) { console.log(JSON.stringify(r, null, 2)); return; }
8
+ if (!r.alerts?.length) { console.log('\nNo plan-limit alerts. Every metered resource is under its warning threshold.'); return; }
9
+ console.log(`\nPlan-limit alerts (${r.alerts.length})\n${'─'.repeat(50)}`);
10
+ for (const a of r.alerts) {
11
+ const tag = a.level === 'limit' ? 'LIMIT REACHED' : a.level === 'critical' ? 'CRITICAL' : 'WARNING';
12
+ console.log(` [${tag}] ${a.displayName}: ${a.current.toLocaleString('en-US')} of ${a.limit.toLocaleString('en-US')} (${a.percent}%)${a.softLimit ? ' overage is billed' : ''}`);
13
+ }
14
+ console.log('\nRequest more capacity: Billing > Contact Account Manager, or email sales@velaro.com.');
15
+ }
16
+
17
+ // Object form (not a bare builder) so cli/bin/velaro.js registers it like every other group and the
18
+ // CLI/MCP group-parity check (scripts/validate-cli-mcp-group-parity.js) can see the "usage" group.
19
+ export const usageCommand = {
20
+ command: 'usage <subcommand>',
21
+ describe: 'Plan-limit usage and alerts for this site',
22
+ builder: (y) => y
23
+ .command('alerts', 'Show plan-limit alerts (resources at or above the warning threshold)', (y2) => y2
24
+ .option('json', { type: 'boolean', describe: 'Print raw JSON' }), (a) => alerts(a))
25
+ .demandCommand(1, 'Specify a subcommand: alerts')
26
+ .strict(),
27
+ handler: () => {},
28
+ };
@@ -0,0 +1,90 @@
1
+ import { messagingGet, messagingPost, messagingPut } from '../api.js';
2
+ import { runCommand } from '../run.js';
3
+ import { diagnoseLink, validateSettingsArgs } from '../visitor-link.js';
4
+
5
+ // ── velaro visitor-link ────────────────────────────────────────────────────────
6
+ // Signed visitor link: lets a chat visitor be tied to a later form or survey submission.
7
+ // Off by default per site; a link is only minted for a visitor who has chatted and (when the site uses a
8
+ // consent banner) granted functional storage. siteId comes from the session, never a CLI arg.
9
+ //
10
+ // Backend contract: server/Velaro.Messaging/Controllers/VisitorLinkControllers.cs (messaging)
11
+ // GET SiteSettings/visitor-linking -> { enabled, ttlMinutes, keyConfigured }
12
+ // PUT SiteSettings/visitor-linking { enabled?, ttlMinutes? (1..1440) } -> same shape
13
+ // POST api/v1/visitor-links/resolve { token } -> { data: { visitorId, conversationBaseId, contactId }, error }
14
+
15
+ const BOLD = '\x1b[1m';
16
+ const GRN = '\x1b[32m';
17
+ const YEL = '\x1b[33m';
18
+ const RST = '\x1b[0m';
19
+
20
+ function printSettings(s) {
21
+ console.log(`\n${BOLD}Visitor linking${RST}`);
22
+ console.log(` Enabled: ${s.enabled ? GRN + 'on' + RST : 'off'}`);
23
+ console.log(` Link lifetime: ${s.ttlMinutes} minutes`);
24
+ console.log(` Signing key: ${s.keyConfigured ? 'configured' : YEL + 'NOT configured (links fail closed)' + RST}\n`);
25
+ }
26
+
27
+ async function getHandler() {
28
+ printSettings(await messagingGet('/SiteSettings/visitor-linking'));
29
+ }
30
+
31
+ async function setHandler(argv) {
32
+ const body = validateSettingsArgs({ enabled: argv.enabled, ttlMinutes: argv.ttlMinutes });
33
+ printSettings(await messagingPut('/SiteSettings/visitor-linking', body));
34
+ }
35
+
36
+ async function resolveHandler(argv) {
37
+ const r = await messagingPost('/api/v1/visitor-links/resolve', { token: argv.token });
38
+ const d = r?.data ?? r;
39
+ console.log(`\n${GRN}✓${RST} Token resolved`);
40
+ console.log(` Visitor: ${d.visitorId}`);
41
+ console.log(` Conversation: ${d.conversationBaseId ?? '(none)'}`);
42
+ console.log(` Contact: ${d.contactId ?? '(none)'}\n`);
43
+ }
44
+
45
+ async function diagnoseHandler(argv) {
46
+ const settings = await messagingGet('/SiteSettings/visitor-linking');
47
+ const d = diagnoseLink({ token: argv.token, settings });
48
+ printSettings(settings);
49
+ if (argv.token) console.log(` Token claims: ${JSON.stringify(d.claims)}`);
50
+ console.log(d.healthy ? `\n${GRN}✓${RST} No problems found.\n` : `\n${YEL}Findings${RST}\n${d.findings.map((f) => ' - ' + f).join('\n')}\n`);
51
+ }
52
+
53
+ const getCommand = { command: 'get', describe: 'Show visitor-linking settings for this site', builder: (y) => y, handler: runCommand(getHandler) };
54
+ const setCommand = {
55
+ command: 'set',
56
+ describe: 'Turn visitor linking on/off and set the link lifetime',
57
+ builder: (y) => y
58
+ .option('enabled', { describe: 'Enable (true) or disable (false) visitor linking', type: 'boolean' })
59
+ .option('ttl-minutes', { describe: 'Link lifetime in minutes, 1-1440', type: 'number' }),
60
+ handler: runCommand(setHandler),
61
+ };
62
+ const settingsCommand = {
63
+ command: 'settings <action>',
64
+ describe: 'Get or set visitor-linking settings',
65
+ builder: (y) => y.command(getCommand).command(setCommand).demandCommand(1, 'Specify: get | set'),
66
+ handler: () => {},
67
+ };
68
+ const resolveCommand = {
69
+ command: 'resolve <token>',
70
+ describe: 'Exchange a link token for the visitor and conversation ids (what your server does)',
71
+ builder: (y) => y.positional('token', { type: 'string', describe: 'vlk1 token from the form hidden field' }),
72
+ handler: runCommand(resolveHandler),
73
+ };
74
+ const diagnoseCommand = {
75
+ command: 'diagnose [token]',
76
+ describe: 'Check settings and, optionally, why a specific link token would fail',
77
+ builder: (y) => y.positional('token', { type: 'string', describe: 'Optional vlk1 token to inspect' }),
78
+ handler: runCommand(diagnoseHandler),
79
+ };
80
+
81
+ export const visitorLinkCommand = {
82
+ command: 'visitor-link <subcommand>',
83
+ describe: 'Link chat visitors to later form and survey submissions (settings, resolve, diagnose)',
84
+ builder: (y) => y
85
+ .command(settingsCommand)
86
+ .command(resolveCommand)
87
+ .command(diagnoseCommand)
88
+ .demandCommand(1, 'Specify a visitor-link subcommand: settings | resolve | diagnose'),
89
+ handler: () => {},
90
+ };
@@ -1,5 +1,5 @@
1
1
  import fs from 'node:fs';
2
- import { getCredentials, messagingGet, messagingPost, messagingPut } from '../api.js';
2
+ import { getCredentials, messagingGet, messagingPost, messagingPut, messagingDel } from '../api.js';
3
3
  import { requireFeature } from '../subscription.js';
4
4
  import { runCommand } from '../run.js';
5
5
  import { applyFollowUpOptions } from '../ai-followups.js';
@@ -18,8 +18,9 @@ export const workflowCommand = {
18
18
  .command(workflowNodeOptionsCommand)
19
19
  .command(workflowIntegrityScanCommand)
20
20
  .command(workflowAiFollowupsCommand)
21
+ .command(workflowScheduleCommand)
21
22
  .command(siteAiFollowupsCommand)
22
- .demandCommand(1, 'Specify a subcommand: list, get, create, enable, disable, node-options, integrity-scan, ai-followups, ai-followups-site'),
23
+ .demandCommand(1, 'Specify a subcommand: list, get, create, enable, disable, node-options, integrity-scan, ai-followups, ai-followups-site, schedule'),
23
24
  handler: () => {},
24
25
  };
25
26
 
@@ -358,3 +359,124 @@ const workflowIntegrityScanCommand = {
358
359
  }
359
360
  }),
360
361
  };
362
+
363
+ // ── schedule (workflow schedule priority; messaging owns /Workflows/schedule/*) ──────────────
364
+ // Mirrors the velaro MCP tools workflow_schedule_resolve / _timeline / _validate / _banner / _override_save / _override_list / _override_delete.
365
+ // An older messaging answers 404/501: print "not available" instead of failing.
366
+
367
+ const NOT_AVAILABLE = "Workflow scheduling is not available on this site's version.";
368
+
369
+ async function scheduleTolerant(fn) {
370
+ try {
371
+ return await fn();
372
+ } catch (err) {
373
+ if (/->\s*(404|501)\b|\b(404|501)\b/.test(String(err?.message))) { console.log(NOT_AVAILABLE); return undefined; }
374
+ throw err;
375
+ }
376
+ }
377
+
378
+ const qs = (o) => new URLSearchParams(Object.entries(o).filter(([, v]) => v !== undefined && v !== '')).toString();
379
+
380
+ const workflowScheduleCommand = {
381
+ command: 'schedule <subcommand>',
382
+ describe: 'Workflow schedule priority: set, get, delete, validate, resolve, timeline, gaps',
383
+ builder: (yargs) =>
384
+ yargs
385
+ .command({
386
+ command: 'set',
387
+ describe: 'Create/update a SwitchWorkflow schedule override (run another workflow on given dates)',
388
+ builder: (y) => y
389
+ .option('id', { type: 'number', describe: 'Existing override id to update (omit to create)' })
390
+ .option('schedule', { type: 'number', demandOption: true, describe: 'Shared schedule id' })
391
+ .option('workflow', { type: 'number', demandOption: true, describe: 'Target workflow id to run' })
392
+ .option('name', { type: 'string' })
393
+ .option('start', { type: 'string', demandOption: true, describe: 'YYYY-MM-DD' })
394
+ .option('end', { type: 'string', describe: 'YYYY-MM-DD' }),
395
+ handler: runCommand(async (argv) => {
396
+ await requireFeature('enableAutomation', 'Workflows');
397
+ const body = { sharedScheduleId: argv.schedule, name: argv.name, startDate: argv.start, endDate: argv.end, action: 3, targetWorkflowId: argv.workflow };
398
+ const r = await scheduleTolerant(() => argv.id
399
+ ? messagingPut(`/ScheduleOverride/${argv.id}`, body)
400
+ : messagingPost('/ScheduleOverride', body));
401
+ if (r !== undefined) console.log(JSON.stringify(r, null, 2));
402
+ }),
403
+ })
404
+ .command({
405
+ command: 'get',
406
+ describe: 'List this site\'s SwitchWorkflow schedule overrides',
407
+ handler: runCommand(async () => {
408
+ await requireFeature('enableAutomation', 'Workflows');
409
+ const r = await scheduleTolerant(() => messagingGet('/ScheduleOverride?action=3'));
410
+ if (r !== undefined) console.log(JSON.stringify(r, null, 2));
411
+ }),
412
+ })
413
+ .command({
414
+ command: 'delete',
415
+ describe: 'Delete a schedule override by id (site admins only)',
416
+ builder: (y) => y.option('id', { type: 'number', demandOption: true }),
417
+ handler: runCommand(async (argv) => {
418
+ await requireFeature('enableAutomation', 'Workflows');
419
+ const r = await scheduleTolerant(async () => { await messagingDel(`/ScheduleOverride/${argv.id}`); return { success: true, deleted: argv.id }; });
420
+ if (r !== undefined) console.log(JSON.stringify(r, null, 2));
421
+ }),
422
+ })
423
+ .command({
424
+ command: 'validate',
425
+ describe: 'Dry-run a draft override (or no override: list current gaps) without saving',
426
+ builder: (y) => y
427
+ .option('trigger', { type: 'string', default: 'conversation_opened' })
428
+ .option('channel', { type: 'string' })
429
+ .option('schedule', { type: 'number', describe: 'Shared schedule id of the draft override' })
430
+ .option('workflow', { type: 'number', describe: 'Target workflow id of the draft override' })
431
+ .option('name', { type: 'string' })
432
+ .option('start', { type: 'string', describe: 'YYYY-MM-DD' })
433
+ .option('end', { type: 'string', describe: 'YYYY-MM-DD' }),
434
+ handler: runCommand(async (argv) => {
435
+ await requireFeature('enableAutomation', 'Workflows');
436
+ const override = argv.schedule && argv.workflow && argv.start
437
+ ? { sharedScheduleId: argv.schedule, name: argv.name, startDate: argv.start, endDate: argv.end, action: 3, targetWorkflowId: argv.workflow }
438
+ : undefined;
439
+ const r = await scheduleTolerant(() => messagingPost('/Workflows/schedule/validate', { trigger: argv.trigger, channel: argv.channel, override }));
440
+ if (r !== undefined) console.log(JSON.stringify(r, null, 2));
441
+ }),
442
+ })
443
+ .command({
444
+ command: 'resolve',
445
+ describe: 'Which workflow runs at a date/time?',
446
+ builder: (y) => y
447
+ .option('trigger', { type: 'string', demandOption: true })
448
+ .option('channel', { type: 'string' })
449
+ .option('at', { type: 'string', describe: 'ISO date-time (default now)' })
450
+ .option('tz', { type: 'string', describe: 'IANA time zone' }),
451
+ handler: runCommand(async (argv) => {
452
+ await requireFeature('enableAutomation', 'Workflows');
453
+ const r = await scheduleTolerant(() => messagingGet('/Workflows/schedule/resolve?' + qs({ trigger: argv.trigger, channel: argv.channel, at: argv.at, tz: argv.tz })));
454
+ if (r !== undefined) console.log(JSON.stringify(r, null, 2));
455
+ }),
456
+ })
457
+ .command({
458
+ command: 'timeline',
459
+ describe: 'Winner timeline, gaps and conflicts over a date range',
460
+ builder: (y) => y
461
+ .option('trigger', { type: 'string', demandOption: true })
462
+ .option('channel', { type: 'string' })
463
+ .option('from', { type: 'string' })
464
+ .option('to', { type: 'string' }),
465
+ handler: runCommand(async (argv) => {
466
+ await requireFeature('enableAutomation', 'Workflows');
467
+ const r = await scheduleTolerant(() => messagingGet('/Workflows/schedule/timeline?' + qs({ trigger: argv.trigger, channel: argv.channel, from: argv.from, to: argv.to })));
468
+ if (r !== undefined) console.log(JSON.stringify(r, null, 2));
469
+ }),
470
+ })
471
+ .command({
472
+ command: 'gaps',
473
+ describe: 'Upcoming gaps where no workflow will run',
474
+ handler: runCommand(async () => {
475
+ await requireFeature('enableAutomation', 'Workflows');
476
+ const r = await scheduleTolerant(() => messagingGet('/Workflows/schedule/banner'));
477
+ if (r !== undefined) console.log(JSON.stringify(r, null, 2));
478
+ }),
479
+ })
480
+ .demandCommand(1, 'Specify: set, get, delete, validate, resolve, timeline, gaps'),
481
+ handler: () => {},
482
+ };
@@ -10,6 +10,23 @@ test('describe lists the five novel reports and the promise dismiss action', ()
10
10
  }
11
11
  });
12
12
 
13
+ // Parity burn-down (2026-10-14): Bonus insights, Operations Briefing and the shared `entitled` view (which also covers
14
+ // Entry-point routing and Automatic tagging, items without a report route) are on the CLI and the MCP tool.
15
+ test('describe lists bonus, operations-briefing and entitled', () => {
16
+ for (const r of ['bonus', 'operations-briefing', 'entitled']) {
17
+ assert.ok(enterpriseReportsCommand.describe.includes(r), `${r} missing from describe`);
18
+ }
19
+ });
20
+
21
+ test('bonus requires --name and the MCP tool maps the same segments', async () => {
22
+ const { readFileSync } = await import('node:fs');
23
+ const { fileURLToPath } = await import('node:url');
24
+ const mcp = readFileSync(fileURLToPath(new URL('../../mcp/server.js', import.meta.url)), 'utf8');
25
+ for (const m of ["bonus_insights: 'bonus'", "operations_briefing: 'operations-briefing'", "entitled: 'entitled'"]) assert.ok(mcp.includes(m), m);
26
+ const cli = readFileSync(fileURLToPath(new URL('./commands/enterprise-reports.js', import.meta.url)), 'utf8');
27
+ assert.ok(cli.includes('--name is required for bonus') && cli.includes('`bonus/${encodeURIComponent'), 'bonus must require --name and hit bonus/{report}');
28
+ });
29
+
13
30
  // Year in review (item 16): the CLI sends Pacific-midnight instants, defaulting to the last 12 whole Pacific months.
14
31
  test('describe lists year-in-review', () => {
15
32
  assert.ok(enterpriseReportsCommand.describe.includes('year-in-review'));
@@ -9,7 +9,7 @@ import path from 'node:path';
9
9
  import { fileURLToPath } from 'node:url';
10
10
 
11
11
  const dir = path.join(path.dirname(fileURLToPath(import.meta.url)), 'commands');
12
- const MESSAGING_ONLY = ['/Teams/List', '/Deployment', '/Users/List', '/Workflows/List', '/Rules', '/Ticket/knowledge-overrides', '/UserStatus/admin', '/api/ivr/config', '/WooCommerce/billing', '/WooCommerce/auth', '/diagnostics/test-magento', '/voice/admin', '/api/enterprise-reports', '/avatar/groups', '/avatar/workflows', '/avatar/group-overrides', '/Recruiting', '/superadmin/logs/insights', '/ApplePayConfig'];
12
+ const MESSAGING_ONLY = ['/Teams/List', '/Deployment', '/Users/List', '/Workflows/List', '/Rules', '/Ticket/knowledge-overrides', '/UserStatus/admin', '/api/ivr/config', '/WooCommerce/billing', '/WooCommerce/auth', '/diagnostics/test-magento', '/voice/admin', '/api/enterprise-reports', '/avatar/groups', '/avatar/workflows', '/avatar/group-overrides', '/Recruiting', '/superadmin/logs/insights', '/ApplePayPresetOrders', '/ApplePayConfig'];
13
13
 
14
14
  test('messaging-only routes are never called through the admin api helpers', () => {
15
15
  const offenders = [];
@@ -0,0 +1,58 @@
1
+ // Shared helpers for Velaro Sign document versions (owner-only; REST: ContractVersionsController +
2
+ // ContractController.UploadSourceDocument). Used by `velaro sign ...`; pure functions so they unit-test cleanly.
3
+ import { readFileSync, existsSync } from 'node:fs';
4
+ import { basename } from 'node:path';
5
+
6
+ export function versionsPath(contractId) {
7
+ return `/api/Contract/${Number(contractId)}/versions`;
8
+ }
9
+
10
+ export function diffPath(contractId, from, to) {
11
+ return `/api/Contract/${Number(contractId)}/diff?from=${Number(from)}&to=${Number(to)}`;
12
+ }
13
+
14
+ export function uploadPath(contractId) {
15
+ return `/api/Contract/${Number(contractId)}/document`;
16
+ }
17
+
18
+ export function describeVersions(res) {
19
+ const rows = res?.versions ?? [];
20
+ if (!rows.length) return `Contract ${res?.contractId}: no document versions yet.`;
21
+ const lines = [`Contract ${res.contractId}: ${rows.length} version(s), newest first`];
22
+ for (const v of rows) {
23
+ const kb = Math.max(1, Math.round((v.byteLength || 0) / 1024));
24
+ lines.push(` v${v.versionNumber} ${String(v.uploadedAt || '').slice(0, 19)} ${v.uploadedBy || '-'} ${v.fileName || '-'} ${v.pageCount ?? '?'}p ${kb}KB sha256:${String(v.sha256 || '').slice(0, 12)}${v.note ? ` note: ${v.note}` : ''}`);
25
+ }
26
+ return lines.join('\n');
27
+ }
28
+
29
+ export function describeDiff(res) {
30
+ if (res?.identical) return `v${res.from} and v${res.to} are byte-identical (same SHA-256).`;
31
+ const blocks = res?.blocks ?? [];
32
+ const changed = blocks.filter(b => b.kind !== 'same' && b.kind !== 'equal');
33
+ const lines = [`Diff v${res.from} -> v${res.to}: ${changed.length} changed block(s)`];
34
+ for (const b of changed) {
35
+ for (const r of b.removed ?? []) lines.push(`- ${r}`);
36
+ for (const a of b.added ?? []) lines.push(`+ ${a}`);
37
+ lines.push('');
38
+ }
39
+ return lines.join('\n').trimEnd();
40
+ }
41
+
42
+ export function readPdf(filePath) {
43
+ if (!filePath || !existsSync(filePath)) throw new Error(`File not found: ${filePath}`);
44
+ const buf = readFileSync(filePath);
45
+ if (buf.length < 5 || buf.subarray(0, 5).toString('latin1') !== '%PDF-') throw new Error('Only PDF files can be uploaded as a contract version.');
46
+ return { buf, name: basename(filePath) };
47
+ }
48
+
49
+ export function buildUploadForm(filePath, note) {
50
+ const { buf, name } = readPdf(filePath);
51
+ const form = new FormData();
52
+ form.append('file', new Blob([buf], { type: 'application/pdf' }), name);
53
+ if (note) form.append('note', note);
54
+ return form;
55
+ }
56
+
57
+ export const OWNER_ONLY_RULE =
58
+ 'Versions are owner-only: signers never see older versions. A new version can be uploaded only while the contract is a draft; after send/sign, void it and re-send.';
@@ -0,0 +1,50 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { writeFileSync, mkdtempSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { tmpdir } from 'node:os';
6
+ import { versionsPath, diffPath, uploadPath, describeVersions, describeDiff, buildUploadForm } from './sign-versions.js';
7
+
8
+ test('paths', () => {
9
+ assert.equal(versionsPath('12'), '/api/Contract/12/versions');
10
+ assert.equal(diffPath(12, 1, 3), '/api/Contract/12/diff?from=1&to=3');
11
+ assert.equal(uploadPath(12), '/api/Contract/12/document');
12
+ });
13
+
14
+ test('describeVersions lists newest first with note', () => {
15
+ const out = describeVersions({ contractId: 5, versions: [
16
+ { versionNumber: 2, fileName: 'b.pdf', sha256: 'abcdef1234567890', byteLength: 2048, pageCount: 3, uploadedBy: 'a@v.com', uploadedAt: '2026-10-11T10:00:00Z', note: 'fix term' },
17
+ { versionNumber: 1, fileName: 'a.pdf', sha256: '1111111111111111', byteLength: 100, pageCount: 3, uploadedBy: 'a@v.com', uploadedAt: '2026-10-10T10:00:00Z' }] });
18
+ assert.match(out, /2 version\(s\)/);
19
+ assert.match(out, /v2 .*note: fix term/);
20
+ assert.equal(describeVersions({ contractId: 5, versions: [] }), 'Contract 5: no document versions yet.');
21
+ });
22
+
23
+ test('describeDiff shows removed/added and identical case', () => {
24
+ assert.match(describeDiff({ from: 1, to: 2, identical: true }), /byte-identical/);
25
+ const out = describeDiff({ from: 1, to: 2, identical: false, blocks: [{ kind: 'changed', removed: ['Term 12 months'], added: ['Term 24 months'] }] });
26
+ assert.match(out, /- Term 12 months/);
27
+ assert.match(out, /\+ Term 24 months/);
28
+ });
29
+
30
+ test('buildUploadForm rejects non-PDF and accepts PDF', () => {
31
+ const d = mkdtempSync(join(tmpdir(), 'sv-'));
32
+ const bad = join(d, 'x.pdf'); writeFileSync(bad, 'hello');
33
+ assert.throws(() => buildUploadForm(bad), /Only PDF/);
34
+ assert.throws(() => buildUploadForm(join(d, 'missing.pdf')), /not found/);
35
+ const ok = join(d, 'ok.pdf'); writeFileSync(ok, '%PDF-1.4\n%%EOF');
36
+ const f = buildUploadForm(ok, 'n');
37
+ assert.equal(f.get('note'), 'n');
38
+ assert.equal(f.get('file').name, 'ok.pdf');
39
+ });
40
+
41
+ import { readFileSync } from 'node:fs';
42
+ import { fileURLToPath } from 'node:url';
43
+ import { dirname, join as pjoin } from 'node:path';
44
+ const root = pjoin(dirname(fileURLToPath(import.meta.url)), '..', '..');
45
+ for (const f of ['mcp/server.js', 'mcp/staff-server.js', 'server/Velaro.Admin/Controllers/StaffMcpController.cs']) {
46
+ test(`${f} registers sign_versions tools`, () => {
47
+ const s = readFileSync(pjoin(root, f), 'utf8');
48
+ for (const t of ['sign_versions_list', 'sign_versions_diff', 'sign_versions_upload']) assert.ok(s.includes(t), `${t} missing in ${f}`);
49
+ });
50
+ }
@@ -0,0 +1,61 @@
1
+ // Shared helpers for `velaro visitor-link` (settings + token diagnose).
2
+ // Token format (velaro-messaging VisitorLinkTokenService): "vlk1." + b64url(payload) + "." + b64url(hmac)
3
+ // payload = siteId|visitorId|conversationBaseId|scope|iat|exp. The signature needs the server key, so this
4
+ // only reads the claims; it never proves a token valid. Resolve (server side) is the real validity check.
5
+
6
+ export const MAX_TTL_MINUTES = 1440;
7
+
8
+ export function decodeLinkToken(token) {
9
+ const out = { ok: false, problems: [] };
10
+ if (typeof token !== 'string' || !token.trim()) { out.problems.push('empty token'); return out; }
11
+ const parts = token.trim().split('.');
12
+ if (parts.length !== 3 || parts[0] !== 'vlk1') { out.problems.push('not a vlk1 link token (expected vlk1.<payload>.<signature>)'); return out; }
13
+ const payload = Buffer.from(parts[1].replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8');
14
+ const f = payload.split('|');
15
+ if (f.length < 6) { out.problems.push('payload has fewer than 6 fields'); return out; }
16
+ const [siteId, visitorId, conversationBaseId, scope, iat, exp] = f;
17
+ const toMs = (v) => { const n = Number(v); return Number.isFinite(n) && n > 0 ? (n > 1e12 ? n : n * 1000) : null; };
18
+ const issuedMs = toMs(iat);
19
+ const expMs = toMs(exp);
20
+ Object.assign(out, {
21
+ ok: true,
22
+ siteId: Number(siteId) || null,
23
+ visitorId,
24
+ conversationBaseId: Number(conversationBaseId) || null,
25
+ scope,
26
+ issuedAt: issuedMs ? new Date(issuedMs).toISOString() : null,
27
+ expiresAt: expMs ? new Date(expMs).toISOString() : null,
28
+ signaturePresent: parts[2].length > 0,
29
+ });
30
+ if (scope !== 'link') out.problems.push(`unexpected scope "${scope}" (v1 only issues "link")`);
31
+ if (!out.signaturePresent) out.problems.push('signature missing');
32
+ if (!expMs) out.problems.push('expiry not parseable');
33
+ return out;
34
+ }
35
+
36
+ // Full diagnosis: claims plus comparison with the site's current settings.
37
+ export function diagnoseLink({ token, siteId, settings, now = Date.now() }) {
38
+ const claims = token ? decodeLinkToken(token) : { ok: false, problems: [] };
39
+ const findings = [...claims.problems];
40
+ if (claims.ok) {
41
+ if (siteId && claims.siteId && Number(siteId) !== claims.siteId) findings.push(`token was minted for site ${claims.siteId}, not site ${siteId}: cross-site tokens always fail`);
42
+ if (claims.expiresAt && Date.parse(claims.expiresAt) <= now) findings.push(`token expired at ${claims.expiresAt}`);
43
+ if (!claims.conversationBaseId) findings.push('token carries no conversation id (visitor had not started a chat when it was minted)');
44
+ }
45
+ if (settings) {
46
+ if (!settings.enabled) findings.push('visitor linking is OFF for this site: mint returns linking_disabled');
47
+ if (settings.keyConfigured === false) findings.push('link signing key is not configured on the server: mint and resolve fail closed (503 key_not_configured)');
48
+ }
49
+ return { claims, settings: settings ?? null, findings, healthy: findings.length === 0 };
50
+ }
51
+
52
+ export function validateSettingsArgs({ enabled, ttlMinutes }) {
53
+ if (enabled === undefined && ttlMinutes === undefined) throw new Error('Pass enabled and/or ttlMinutes.');
54
+ if (ttlMinutes !== undefined && (!Number.isInteger(ttlMinutes) || ttlMinutes < 1 || ttlMinutes > MAX_TTL_MINUTES)) {
55
+ throw new Error(`ttlMinutes must be a whole number from 1 to ${MAX_TTL_MINUTES}.`);
56
+ }
57
+ const body = {};
58
+ if (enabled !== undefined) body.enabled = !!enabled;
59
+ if (ttlMinutes !== undefined) body.ttlMinutes = ttlMinutes;
60
+ return body;
61
+ }
@@ -0,0 +1,67 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { decodeLinkToken, diagnoseLink, validateSettingsArgs } from './visitor-link.js';
4
+
5
+ const b64 = (s) => Buffer.from(s, 'utf8').toString('base64url');
6
+ const mk = (fields, sig = 'sig') => `vlk1.${b64(fields.join('|'))}.${sig}`;
7
+ const NOW = Date.UTC(2026, 9, 13, 12, 0, 0);
8
+ const sec = (ms) => Math.floor(ms / 1000);
9
+
10
+ test('decodes a well-formed token', () => {
11
+ const c = decodeLinkToken(mk([1032, 'v-9', 555, 'link', sec(NOW), sec(NOW + 1800000)]));
12
+ assert.equal(c.ok, true);
13
+ assert.equal(c.siteId, 1032);
14
+ assert.equal(c.visitorId, 'v-9');
15
+ assert.equal(c.conversationBaseId, 555);
16
+ assert.deepEqual(c.problems, []);
17
+ });
18
+
19
+ test('rejects wrong prefix, empty and short payload', () => {
20
+ assert.equal(decodeLinkToken('').ok, false);
21
+ assert.equal(decodeLinkToken('abc.def.ghi').ok, false);
22
+ assert.equal(decodeLinkToken(`vlk1.${b64('1|2')}.s`).ok, false);
23
+ });
24
+
25
+ test('flags cross-site, expired, no conversation, missing signature', () => {
26
+ const t = mk([1032, 'v', 0, 'link', sec(NOW - 7200000), sec(NOW - 3600000)], '');
27
+ const d = diagnoseLink({ token: t, siteId: 20365, now: NOW });
28
+ const text = d.findings.join('\n');
29
+ assert.match(text, /not site 20365/);
30
+ assert.match(text, /expired/);
31
+ assert.match(text, /no conversation id/);
32
+ assert.match(text, /signature missing/);
33
+ assert.equal(d.healthy, false);
34
+ });
35
+
36
+ test('settings findings: off and missing key fail closed', () => {
37
+ const d = diagnoseLink({ settings: { enabled: false, ttlMinutes: 30, keyConfigured: false } });
38
+ assert.match(d.findings.join('\n'), /OFF/);
39
+ assert.match(d.findings.join('\n'), /key is not configured/);
40
+ });
41
+
42
+ test('healthy when token valid and settings on', () => {
43
+ const t = mk([1032, 'v', 5, 'link', sec(NOW), sec(NOW + 600000)]);
44
+ const d = diagnoseLink({ token: t, siteId: 1032, settings: { enabled: true, keyConfigured: true }, now: NOW });
45
+ assert.equal(d.healthy, true);
46
+ });
47
+
48
+ test('validateSettingsArgs enforces 1..1440 and requires a field', () => {
49
+ assert.throws(() => validateSettingsArgs({}), /enabled and\/or ttlMinutes/);
50
+ assert.throws(() => validateSettingsArgs({ ttlMinutes: 0 }), /1 to 1440/);
51
+ assert.throws(() => validateSettingsArgs({ ttlMinutes: 1441 }), /1 to 1440/);
52
+ assert.throws(() => validateSettingsArgs({ ttlMinutes: 1.5 }), /1 to 1440/);
53
+ assert.deepEqual(validateSettingsArgs({ enabled: true, ttlMinutes: 30 }), { enabled: true, ttlMinutes: 30 });
54
+ assert.deepEqual(validateSettingsArgs({ enabled: false }), { enabled: false });
55
+ });
56
+
57
+ test('MCP and staff-MCP inlined helpers stay in sync with the CLI helper', async () => {
58
+ const fs = await import('node:fs');
59
+ const path = await import('node:path');
60
+ const here = path.dirname(new URL(import.meta.url).pathname.replace(/^\/(\w:)/, '$1'));
61
+ const norm = (src) => src.replace(/\/\/.*$/gm, '').replace(/\bexport\s+/g, '').replace(/\s+/g, ' ');
62
+ const cli = norm(fs.readFileSync(path.join(here, 'visitor-link.js'), 'utf8')).trim();
63
+ for (const f of ['server.js', 'staff-server.js']) {
64
+ const src = norm(fs.readFileSync(path.join(here, '..', '..', 'mcp', f), 'utf8'));
65
+ assert.ok(src.includes(cli), `mcp/${f} inlined visitor-link helpers drifted from cli/lib/visitor-link.js`);
66
+ }
67
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velaro/cli",
3
- "version": "1.4.103",
3
+ "version": "1.4.105",
4
4
  "description": "Velaro Workspace v20 — command-line interface for managing bots, knowledge base ingestion, MCP API keys, search indexes, and ops health.",
5
5
  "type": "module",
6
6
  "bin": {