@velaro/cli 1.4.104 → 1.4.106

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
@@ -12,6 +12,7 @@ import { ingestCommand } from '../lib/commands/ingest.js';
12
12
  import { indexMoveCommand } from '../lib/commands/index-move.js';
13
13
  import { safetyCommand } from '../lib/commands/safety.js';
14
14
  import { knowledgeAdvisorCommand } from '../lib/commands/knowledge-advisor.js';
15
+ import { knowledgeCommand } from '../lib/commands/knowledge.js';
15
16
  import { productCommand } from '../lib/commands/product.js';
16
17
  import { mcpKeyCommand } from '../lib/commands/mcp-key.js';
17
18
  import { opsCommand } from '../lib/commands/ops.js';
@@ -63,6 +64,7 @@ import { keywordIntelligenceCommand } from '../lib/commands/keyword-intelligence
63
64
  import { aiFieldExtractionCommand } from '../lib/commands/ai-field-extraction.js';
64
65
  import { sentimentCommand, qualityCommand, chatIntelligenceThemesCommand } from '../lib/commands/quality-reports.js';
65
66
  import { cannedLearningCommand } from '../lib/commands/canned-learning.js';
67
+ import { csatOverrideUnapproveCommand } from '../lib/commands/csat-override.js';
66
68
  import { chatIntelligenceTopicsCommand } from '../lib/commands/chat-intelligence-topics.js';
67
69
  import { focusCommand } from '../lib/commands/focus.js';
68
70
  import { triageCommand } from '../lib/commands/triage.js';
@@ -98,6 +100,7 @@ import { orderFormCommand } from '../lib/commands/order-form.js';
98
100
  import { billingEmailPreferenceCommand } from '../lib/commands/billing-email-preference.js';
99
101
  import { billingCommand } from '../lib/commands/billing.js';
100
102
  import { couponGrantCommand } from '../lib/commands/coupon-grant.js';
103
+ import { signCommand } from '../lib/commands/sign.js';
101
104
  import { widgetContainerCommand } from '../lib/commands/widget-container.js';
102
105
  import { inlineWidgetConfigCommand } from '../lib/commands/inline-widget-config.js';
103
106
  import { pricingCommand } from '../lib/commands/pricing.js';
@@ -107,6 +110,7 @@ import { supportCommand } from '../lib/commands/support.js';
107
110
  import { routingCommand } from '../lib/commands/routing.js';
108
111
  import { diagnosticsCommand } from '../lib/commands/diagnostics.js';
109
112
  import { aiSpendCommand } from '../lib/commands/ai-spend.js';
113
+ import { skillUsageCommand } from '../lib/commands/skill-usage.js';
110
114
  import { aiFederationCommand } from '../lib/commands/ai-federation.js';
111
115
  import { statusPageCommand } from '../lib/commands/status-page.js';
112
116
  import { statusNotifyCommand } from '../lib/commands/status-notify.js';
@@ -115,6 +119,8 @@ import { emailInboxCommand } from '../lib/commands/email-inbox.js';
115
119
  import { callbackCommand } from '../lib/commands/callback.js';
116
120
  import { caseCommand } from '../lib/commands/case.js';
117
121
  import { pickupCommand } from '../lib/commands/pickup.js';
122
+ import { visitorMonitorCommand } from '../lib/commands/visitor-monitor.js';
123
+ import { visitorLinkCommand } from '../lib/commands/visitor-link.js';
118
124
  import { applePayCommand } from '../lib/commands/apple-pay.js';
119
125
  import { siteBuilderCommand } from '../lib/commands/site-builder.js';
120
126
  import { paymentRecoveryCommand } from '../lib/commands/payment-recovery.js';
@@ -125,6 +131,7 @@ import { chatWindowCommand } from '../lib/commands/chat-window.js';
125
131
  import { featureDiscoveryCommand } from '../lib/commands/feature-discovery.js';
126
132
  import { tourPolicyCommand } from '../lib/commands/tour-policy.js';
127
133
  import { scraperCommand } from '../lib/commands/scraper.js';
134
+ import { usageCommand } from '../lib/commands/usage.js';
128
135
  import { campaignsCommand } from '../lib/commands/campaigns.js';
129
136
  import { senderDomainHealthCommand } from '../lib/commands/sender-domain-health.js';
130
137
  import { contactsCommand } from '../lib/commands/contacts.js';
@@ -156,6 +163,7 @@ await yargs(hideBin(process.argv))
156
163
  .command(indexMoveCommand)
157
164
  .command(safetyCommand)
158
165
  .command(knowledgeAdvisorCommand)
166
+ .command(knowledgeCommand)
159
167
  .command(productCommand)
160
168
  .command(mcpKeyCommand)
161
169
  .command(opsCommand)
@@ -210,6 +218,7 @@ await yargs(hideBin(process.argv))
210
218
  .command(chatIntelligenceThemesCommand)
211
219
  .command(cannedLearningCommand)
212
220
  .command(chatIntelligenceTopicsCommand)
221
+ .command(csatOverrideUnapproveCommand)
213
222
  .command(focusCommand)
214
223
  .command(triageCommand)
215
224
  .command(automationDraftCommand)
@@ -244,6 +253,7 @@ await yargs(hideBin(process.argv))
244
253
  .command(billingEmailPreferenceCommand)
245
254
  .command(billingCommand)
246
255
  .command(couponGrantCommand)
256
+ .command(signCommand)
247
257
  .command(widgetContainerCommand)
248
258
  .command(inlineWidgetConfigCommand)
249
259
  .command({ command: 'pricing', desc: 'View and manage the pricing catalog', builder: pricingCommand, handler: () => {} })
@@ -253,6 +263,7 @@ await yargs(hideBin(process.argv))
253
263
  .command(routingCommand)
254
264
  .command(diagnosticsCommand)
255
265
  .command(aiSpendCommand)
266
+ .command(skillUsageCommand)
256
267
  .command(aiFederationCommand)
257
268
  .command(statusPageCommand)
258
269
  .command(statusNotifyCommand)
@@ -261,6 +272,8 @@ await yargs(hideBin(process.argv))
261
272
  .command(callbackCommand)
262
273
  .command(caseCommand)
263
274
  .command(pickupCommand)
275
+ .command(visitorMonitorCommand)
276
+ .command(visitorLinkCommand)
264
277
  .command(applePayCommand)
265
278
  .command(siteBuilderCommand)
266
279
  .command(paymentRecoveryCommand)
@@ -269,6 +282,7 @@ await yargs(hideBin(process.argv))
269
282
  .command(bundleCommand)
270
283
  .command(chatWindowCommand)
271
284
  .command({ command: 'feature-discovery', desc: 'View and act on feature-discovery suggestions for this site', builder: featureDiscoveryCommand, handler: () => {} })
285
+ .command(usageCommand)
272
286
  .command({ command: 'scraper', desc: 'Velaro staff: scraper / upload / chunk usage for any site', builder: scraperCommand, handler: () => {} })
273
287
  .command({ command: 'tour-policy', desc: 'Velaro staff: guided-tour auto-open policy (versioned, rollback-able)', builder: tourPolicyCommand, handler: () => {} })
274
288
  .command(campaignsCommand)
@@ -84,7 +84,7 @@ const aiConfigCreateCommand = {
84
84
  y
85
85
  .option('name', { type: 'string', describe: 'Configuration name' })
86
86
  .option('prompt', { type: 'string', describe: 'System prompt text' })
87
- .option('model', { type: 'string', describe: 'Model (e.g. gpt-4o-mini, claude-haiku-4-5)' })
87
+ .option('model', { type: 'string', describe: 'Model (e.g. gpt-4o-mini, claude-haiku-5-5)' })
88
88
  .option('tone', { type: 'string', describe: 'Tone (e.g. friendly, professional)' })
89
89
  .option('language', { type: 'string', default: 'en', describe: 'Language code' })
90
90
  .option('reply-length',{ type: 'string', describe: 'Reply length (e.g. short, medium, long)' })
@@ -0,0 +1,14 @@
1
+ import { messagingPost } from '../api.js';
2
+ import { runCommand } from '../run.js';
3
+
4
+ // velaro csat-override-unapprove <id>
5
+ // REST: messaging POST /api/intelligence/csat-override-candidates/{id}/unapprove (site comes from the CLI session)
6
+ export const csatOverrideUnapproveCommand = {
7
+ command: 'csat-override-unapprove <id>',
8
+ describe: 'Return an approved Chat Intelligence improvement candidate to Pending (deactivates the override, removes any vectorized copy)',
9
+ builder: (y) => y.positional('id', { type: 'number', describe: 'Approved override candidate id' }),
10
+ handler: runCommand(async (a) => {
11
+ if (!Number.isInteger(a.id) || a.id <= 0) throw new Error('id must be a positive integer');
12
+ console.log(JSON.stringify(await messagingPost(`/api/intelligence/csat-override-candidates/${a.id}/unapprove`, {}), null, 2));
13
+ }),
14
+ };
@@ -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
  };
@@ -0,0 +1,114 @@
1
+ // velaro knowledge clinical-text get|set -- emergency and safety wording per channel and language.
2
+ // Backend: velaro-messaging KnowledgeSetupController. Customer routes take the site from auth
3
+ // (GET KnowledgeSetup/state, PUT KnowledgeSetup/clinical-text); --site switches to the staff twins
4
+ // (GET/PUT KnowledgeSetup/Staff/{siteId}/...), Velaro super admin only.
5
+ import fs from 'node:fs';
6
+ import { messagingGet, messagingPut } from '../api.js';
7
+ import { runCommand } from '../run.js';
8
+
9
+ const CHANNELS = ['chat', 'voice', 'sms', 'whatsapp', 'email'];
10
+
11
+ export function basePath(site) {
12
+ return site ? `/KnowledgeSetup/Staff/${encodeURIComponent(site)}` : '/KnowledgeSetup';
13
+ }
14
+
15
+ /** Key is default | channel | lang | lang.channel (es.voice). Value is the wording. */
16
+ export function parseEntries(list) {
17
+ const out = {};
18
+ for (const raw of [].concat(list ?? [])) {
19
+ const i = String(raw).indexOf('=');
20
+ if (i < 1) throw new Error(`Expected key=text (for example es.voice="..."), got: ${raw}`);
21
+ out[String(raw).slice(0, i).trim().toLowerCase()] = String(raw).slice(i + 1);
22
+ }
23
+ return out;
24
+ }
25
+
26
+ export function buildBody(argv) {
27
+ if (argv.reset) return { resetToDefault: true };
28
+ let body = {};
29
+ if (argv.file) body = JSON.parse(fs.readFileSync(argv.file, 'utf8'));
30
+ const em = parseEntries(argv.emergency);
31
+ const rf = parseEntries(argv.referral);
32
+ if (Object.keys(em).length) body.emergency = { ...(body.emergency ?? {}), ...em };
33
+ if (Object.keys(rf).length) body.referral = { ...(body.referral ?? {}), ...rf };
34
+ if (argv['emergency-number'] !== undefined) body.emergencyNumber = argv['emergency-number'];
35
+ if (argv['service-mode'] !== undefined || argv['service-mode-text'] !== undefined) {
36
+ body.emergencyServiceMode = {};
37
+ if (argv['service-mode'] !== undefined) body.emergencyServiceMode.enabled = argv['service-mode'];
38
+ if (argv['service-mode-text'] !== undefined) body.emergencyServiceMode.text = argv['service-mode-text'];
39
+ if (argv.confirm) body.emergencyServiceMode.confirm = true;
40
+ }
41
+ return body;
42
+ }
43
+
44
+ export function printState(ct) {
45
+ console.log(`\nEmergency number: ${ct.overrides?.emergencyNumber || `(default) ${ct.defaultEmergencyNumber}`}`);
46
+ console.log(`Limits: ${ct.limits?.minLength}-${ct.limits?.maxLength} characters. Emergency text must keep ${ct.placeholder}.`);
47
+ console.log(`Supported languages: ${(ct.supportedLanguages ?? []).join(', ') || '-'}`);
48
+ const m = ct.emergencyServiceMode;
49
+ console.log(`Emergency-service mode: ${m?.enabled ? 'ON' : 'off'}${m?.text ? ` | text: ${m.text}` : ''} (staff only)`);
50
+ console.log('Custom wording (key = text):');
51
+ for (const kind of ['emergency', 'referral']) {
52
+ const e = Object.entries(ct.overrides?.[kind] ?? {});
53
+ if (!e.length) console.log(` ${kind}: none, built-in wording`);
54
+ for (const [k, v] of e) console.log(` ${kind}.${k} = ${v}`);
55
+ }
56
+ console.log('Effective English wording per channel:');
57
+ for (const c of CHANNELS) {
58
+ const x = ct.effective?.[c];
59
+ if (x) console.log(` ${c}: emergency "${x.emergency}" | referral "${x.referral}"`);
60
+ }
61
+ }
62
+
63
+ const clinicalGet = {
64
+ command: 'get',
65
+ describe: 'Show emergency/safety wording, supported languages and emergency-service mode',
66
+ builder: (y) => y
67
+ .option('site', { type: 'number', describe: 'Target site ID (Velaro staff only; omit for your own site)' })
68
+ .option('json', { type: 'boolean', default: false }),
69
+ handler: runCommand(async (argv) => {
70
+ const state = await messagingGet(`${basePath(argv.site)}/state`);
71
+ const ct = state?.clinicalText;
72
+ if (!ct) throw new Error('The setup state did not include clinicalText (backend too old?).');
73
+ if (argv.json) { console.log(JSON.stringify(ct, null, 2)); return; }
74
+ printState(ct);
75
+ }),
76
+ };
77
+
78
+ const clinicalSet = {
79
+ command: 'set',
80
+ describe: 'Set emergency/safety wording. Entries replace the stored set: pass every override you want to keep (use get --json).',
81
+ builder: (y) => y
82
+ .option('site', { type: 'number', describe: 'Target site ID (Velaro staff only)' })
83
+ .option('emergency', { type: 'array', string: true, describe: 'key=text, key is default|chat|voice|sms|whatsapp|email|<lang>|<lang>.<channel>; must contain {emergencyNumber}' })
84
+ .option('referral', { type: 'array', string: true, describe: 'key=text, same keys as --emergency' })
85
+ .option('emergency-number', { type: 'string', describe: 'Replaces {emergencyNumber}' })
86
+ .option('file', { type: 'string', describe: 'JSON body { emergency, referral, emergencyNumber }' })
87
+ .option('reset', { type: 'boolean', default: false, describe: 'Clear all custom wording' })
88
+ .option('service-mode', { type: 'boolean', describe: 'Staff only (--site): turn emergency-service mode on/off' })
89
+ .option('service-mode-text', { type: 'string', describe: 'Staff only: required emergency text for service mode (20-600 chars)' })
90
+ .option('confirm', { type: 'boolean', default: false, describe: 'Required to turn service mode on' })
91
+ .option('json', { type: 'boolean', default: false }),
92
+ handler: runCommand(async (argv) => {
93
+ const body = buildBody(argv);
94
+ if (body.emergencyServiceMode && !argv.site) throw new Error('Emergency-service mode is staff only: pass --site <id>.');
95
+ const r = await messagingPut(`${basePath(argv.site)}/clinical-text`, body);
96
+ if (argv.json) { console.log(JSON.stringify(r, null, 2)); return; }
97
+ console.log(r.changed ? 'Saved.' : 'No change.');
98
+ if (r.clinicalText) printState(r.clinicalText);
99
+ }),
100
+ };
101
+
102
+ export const knowledgeCommand = {
103
+ command: 'knowledge <subcommand>',
104
+ describe: 'Knowledge setup safety settings',
105
+ builder: (y) => y
106
+ .command({
107
+ command: 'clinical-text <action>',
108
+ describe: 'Emergency and safety messages per channel and language',
109
+ builder: (yy) => yy.command(clinicalGet).command(clinicalSet).demandCommand(1, 'Specify get or set'),
110
+ handler: () => {},
111
+ })
112
+ .demandCommand(1, 'Specify a subcommand: clinical-text'),
113
+ handler: () => {},
114
+ };
@@ -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,39 @@
1
+ import { messagingGet } from '../api.js';
2
+ import { runCommand } from '../run.js';
3
+
4
+ const usd = (n) => `$${Number(n ?? 0).toFixed(2)}`;
5
+
6
+ // Pure formatter so the output shape is unit-testable without a network call.
7
+ export function formatSkillUsage(r) {
8
+ const lines = [];
9
+ lines.push(`Site ${r.siteId}, ${r.month}${r.isTrial ? ' (trial)' : ''}, overage: ${r.overageBehavior}`);
10
+ lines.push('');
11
+ lines.push('Billable usage (same numbers as the customer Billing page):');
12
+ for (const u of r.usage ?? []) {
13
+ const limit = u.unlimited ? 'unlimited' : u.limit.toLocaleString();
14
+ lines.push(` ${u.name.padEnd(24)} ${String(u.used.toLocaleString()).padStart(9)} / ${limit.padStart(9)} ${u.unlimited ? '' : `${u.percent}%`.padStart(5)} gate: ${u.mode ?? 'n/a'}`);
15
+ }
16
+ lines.push('');
17
+ lines.push('Calls by surface (staff, copilot and composite calls are never billed to the customer):');
18
+ const surfaces = r.callsBySurface ?? [];
19
+ if (!surfaces.length) lines.push(' (none recorded)');
20
+ for (const s of surfaces) lines.push(` ${String(s.surface).padEnd(20)} ${s.calls.toLocaleString()}`);
21
+ lines.push('');
22
+ lines.push('LLM cost this month by key source (BYOK removes this cost only; skill usage above still counts):');
23
+ const llm = r.llmCostThisMonth ?? [];
24
+ if (!llm.length) lines.push(' (none logged)');
25
+ for (const l of llm) lines.push(` ${String(l.keySource ?? 'unknown').padEnd(12)} ${usd(l.costUsd)} / ${l.calls} calls`);
26
+ return lines.join('\n');
27
+ }
28
+
29
+ export const skillUsageCommand = {
30
+ command: 'skill-usage <siteId>',
31
+ describe: 'Per-customer skill/integration usage vs plan limits, by surface, with LLM cost split (Velaro staff only)',
32
+ builder: (y) => y
33
+ .positional('siteId', { type: 'number', describe: 'Target site ID' })
34
+ .option('json', { type: 'boolean', default: false }),
35
+ handler: runCommand(async (argv) => {
36
+ const r = await messagingGet(`/SupportTools/sites/${argv.siteId}/skill-usage`);
37
+ console.log(argv.json ? JSON.stringify(r, null, 2) : formatSkillUsage(r));
38
+ }),
39
+ };
@@ -4,7 +4,7 @@
4
4
  * velaro teams-phone status Show current configuration
5
5
  * velaro teams-phone set --tenant-id ... Create or update the configuration
6
6
  * velaro teams-phone remove Delete the configuration
7
- * velaro teams-phone test Test the Graph subscription connectivity
7
+ * velaro teams-phone test Run the inbound-calling readiness checks
8
8
  *
9
9
  * Backend contract: server/Velaro.Messaging/Controllers/Voice/TeamsPhoneConfigController.cs
10
10
  * (velaro-messaging). Site is resolved server-side from the auth context — never
@@ -34,6 +34,9 @@ function printConfig(c) {
34
34
  console.log(` Assigned phone number: ${c.assignedPhoneNumber ?? '(none)'}`);
35
35
  console.log(` Webhook secret configured: ${c.webhookSecretConfigured ? `${GRN}yes${RST}` : `${DIM}no${RST}`}`);
36
36
  const subColor = c.graphSubscriptionActive ? GRN : YEL;
37
+ if (typeof c.callingReady === 'boolean') {
38
+ console.log(` Inbound calling ready: ${c.callingReady ? GRN : YEL}${c.callingReady}${RST}`);
39
+ }
37
40
  console.log(` Graph subscription active: ${subColor}${c.graphSubscriptionActive}${RST}` +
38
41
  (c.graphSubscriptionExpiresAt ? ` (expires ${c.graphSubscriptionExpiresAt})` : ''));
39
42
  console.log('');
@@ -82,7 +85,16 @@ async function removeHandler() {
82
85
  async function testHandler() {
83
86
  const result = await messagingPost('/TeamsPhone/Config/Test', {});
84
87
  const color = result?.success ? GRN : RED;
85
- console.log(`\n${color}${result?.success ? 'Success' : 'Failed'}${RST}: ${result?.message ?? '(no message)'}\n`);
88
+ console.log(`\n${color}${result?.success ? 'Success' : 'Failed'}${RST}: ${result?.message ?? '(no message)'}`);
89
+ if (typeof result?.callingReady === 'boolean') {
90
+ console.log(` Inbound calling ready: ${result.callingReady ? GRN : RED}${result.callingReady}${RST}`);
91
+ }
92
+ if (Array.isArray(result?.checks)) {
93
+ for (const ck of result.checks) {
94
+ console.log(` ${ck?.ok ? GRN + 'PASS' : RED + 'FAIL'}${RST} ${ck?.name ?? ''}${ck?.message ? ': ' + ck.message : ''}`);
95
+ }
96
+ }
97
+ console.log('');
86
98
  if (!result?.success) process.exitCode = 1;
87
99
  }
88
100
 
@@ -121,7 +133,7 @@ export const teamsPhoneCommand = {
121
133
  })
122
134
  .command({
123
135
  command: 'test',
124
- describe: 'Test the Teams Phone connection by creating/renewing the Graph /communications/calls subscription',
136
+ describe: 'Run the Teams Phone readiness checks (consent, resource account, Graph reachability) and print each check',
125
137
  builder: yy => yy,
126
138
  handler: testHandler,
127
139
  })
@@ -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
+ };
@@ -0,0 +1,61 @@
1
+ import { messagingGet, messagingPut } from '../api.js';
2
+ import { runCommand } from '../run.js';
3
+
4
+ // ── velaro visitor-monitor ────────────────────────────────────────────────────
5
+ // Visitor Monitor view preferences (per user) and site settings. siteId is applied
6
+ // server-side from the JWT (see cli/lib/api.js messagingRequest), never a CLI arg.
7
+ //
8
+ // Backend contract: velaro-messaging VisitorController (PR 2798)
9
+ // GET/PUT Visitor/preferences { columns[], groupBy, sortBy, sortDir, regionGrouping }
10
+ // GET/PUT Visitor/settings { storeLocationDetail?, activeWindowMinutes? } (window 5-240; >30 needs Advanced plan, else 403 feature_not_in_plan)
11
+ // GET Visitor/active live sessions + activeWindowMinutes + locationDetailEnabled
12
+
13
+ const show = data => console.log(JSON.stringify(data, null, 2));
14
+
15
+ export const visitorMonitorCommand = {
16
+ command: 'visitor-monitor <subcommand>',
17
+ describe: 'Visitor Monitor preferences, settings and live visitors',
18
+ builder: yargs => yargs
19
+ .command({
20
+ command: 'active',
21
+ describe: 'List live visitors',
22
+ handler: runCommand(async () => show(await messagingGet('/Visitor/active'))),
23
+ })
24
+ .command({
25
+ command: 'preferences <action>',
26
+ describe: 'get or set your saved Visitor Monitor view',
27
+ builder: y => y
28
+ .positional('action', { choices: ['get', 'set'] })
29
+ .option('columns', { type: 'string', describe: 'Comma-separated column keys (set)' })
30
+ .option('group-by', { type: 'string', describe: 'Group by key (set)' })
31
+ .option('sort-by', { type: 'string', describe: 'Sort key (set)' })
32
+ .option('sort-dir', { type: 'string', choices: ['asc', 'desc'], describe: 'Sort direction (set)' })
33
+ .option('region-grouping', { type: 'string', describe: 'region or city (set)' }),
34
+ handler: runCommand(async argv => {
35
+ if (argv.action === 'get') return show(await messagingGet('/Visitor/preferences'));
36
+ const body = {};
37
+ if (argv.columns !== undefined) body.columns = String(argv.columns).split(',').map(s => s.trim()).filter(Boolean);
38
+ if (argv['group-by'] !== undefined) body.groupBy = argv['group-by'];
39
+ if (argv['sort-by'] !== undefined) body.sortBy = argv['sort-by'];
40
+ if (argv['sort-dir'] !== undefined) body.sortDir = argv['sort-dir'];
41
+ if (argv['region-grouping'] !== undefined) body.regionGrouping = argv['region-grouping'];
42
+ show(await messagingPut('/Visitor/preferences', body));
43
+ }),
44
+ })
45
+ .command({
46
+ command: 'settings <action>',
47
+ describe: 'get or set site Visitor Monitor settings',
48
+ builder: y => y
49
+ .positional('action', { choices: ['get', 'set'] })
50
+ .option('store-location-detail', { type: 'boolean', describe: 'Store region/city + truncated IP (Advanced plan) (set)' })
51
+ .option('active-window-minutes', { type: 'number', describe: 'Minutes visitors stay listed, 5-240 (above 30 needs Advanced) (set)' }),
52
+ handler: runCommand(async argv => {
53
+ if (argv.action === 'get') return show(await messagingGet('/Visitor/settings'));
54
+ const body = {};
55
+ if (argv['store-location-detail'] !== undefined) body.storeLocationDetail = argv['store-location-detail'];
56
+ if (argv['active-window-minutes'] !== undefined) body.activeWindowMinutes = argv['active-window-minutes'];
57
+ show(await messagingPut('/Visitor/settings', body));
58
+ }),
59
+ })
60
+ .demandCommand(1),
61
+ };
@@ -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'));
@@ -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,36 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { formatSkillUsage } from './commands/skill-usage.js';
4
+
5
+ const sample = {
6
+ siteId: 1032,
7
+ month: '2026-10',
8
+ isTrial: true,
9
+ overageBehavior: 'block',
10
+ usage: [
11
+ { metric: 'ai_tool_calls', name: 'AI Tool Calls', used: 120, limit: 500, unlimited: false, percent: 24, mode: 'Enforce' },
12
+ { metric: 'api_calls', name: 'REST API Calls', used: 9, limit: 0, unlimited: true, percent: 0, mode: 'Observe' },
13
+ ],
14
+ callsBySurface: [{ surface: 'McpStaff', calls: 4 }],
15
+ llmCostThisMonth: [{ keySource: 'byok', costUsd: 1.5, calls: 3 }],
16
+ };
17
+
18
+ test('shows usage against the limit with the gate mode, and marks unlimited metrics', () => {
19
+ const out = formatSkillUsage(sample);
20
+ assert.match(out, /Site 1032, 2026-10 \(trial\), overage: block/);
21
+ assert.match(out, /AI Tool Calls\s+120 \/\s+500\s+24%\s+gate: Enforce/);
22
+ assert.match(out, /REST API Calls\s+9 \/\s+unlimited/);
23
+ });
24
+
25
+ test('lists calls by surface and the LLM cost split, saying BYOK removes LLM cost only', () => {
26
+ const out = formatSkillUsage(sample);
27
+ assert.match(out, /McpStaff\s+4/);
28
+ assert.match(out, /byok\s+\$1\.50 \/ 3 calls/);
29
+ assert.match(out, /BYOK removes this cost only/);
30
+ });
31
+
32
+ test('handles a site with no recorded usage without throwing', () => {
33
+ const out = formatSkillUsage({ siteId: 5, month: '2026-10', overageBehavior: 'warn' });
34
+ assert.match(out, /\(none recorded\)/);
35
+ assert.match(out, /\(none logged\)/);
36
+ });
@@ -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.104",
3
+ "version": "1.4.106",
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": {