@velaro/cli 1.4.58 → 1.4.60

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
@@ -9,6 +9,7 @@ import { kbCommand } from '../lib/commands/kb.js';
9
9
  import { botCommand } from '../lib/commands/bot.js';
10
10
  import { indexCommand } from '../lib/commands/index.js';
11
11
  import { ingestCommand } from '../lib/commands/ingest.js';
12
+ import { indexMoveCommand } from '../lib/commands/index-move.js';
12
13
  import { productCommand } from '../lib/commands/product.js';
13
14
  import { mcpKeyCommand } from '../lib/commands/mcp-key.js';
14
15
  import { opsCommand } from '../lib/commands/ops.js';
@@ -83,6 +84,8 @@ import { recruitingCommand } from '../lib/commands/recruiting.js';
83
84
  import { surveyCommand } from '../lib/commands/survey.js';
84
85
  import { bundleCommand } from '../lib/commands/bundle.js';
85
86
  import { featureDiscoveryCommand } from '../lib/commands/feature-discovery.js';
87
+ import { tourPolicyCommand } from '../lib/commands/tour-policy.js';
88
+ import { scraperCommand } from '../lib/commands/scraper.js';
86
89
  import { campaignsCommand } from '../lib/commands/campaigns.js';
87
90
  import { contactsCommand } from '../lib/commands/contacts.js';
88
91
  import { contactListCommand } from '../lib/commands/contact-list.js';
@@ -104,6 +107,7 @@ await yargs(hideBin(process.argv))
104
107
  .command(botCommand)
105
108
  .command(indexCommand)
106
109
  .command(ingestCommand)
110
+ .command(indexMoveCommand)
107
111
  .command(productCommand)
108
112
  .command(mcpKeyCommand)
109
113
  .command(opsCommand)
@@ -178,6 +182,8 @@ await yargs(hideBin(process.argv))
178
182
  .command(surveyCommand)
179
183
  .command(bundleCommand)
180
184
  .command({ command: 'feature-discovery', desc: 'View and act on feature-discovery suggestions for this site', builder: featureDiscoveryCommand, handler: () => {} })
185
+ .command({ command: 'scraper', desc: 'Velaro staff: scraper / upload / chunk usage for any site', builder: scraperCommand, handler: () => {} })
186
+ .command({ command: 'tour-policy', desc: 'Velaro staff: guided-tour auto-open policy (versioned, rollback-able)', builder: tourPolicyCommand, handler: () => {} })
181
187
  .command(campaignsCommand)
182
188
  .command(contactsCommand)
183
189
  .command(contactListCommand)
@@ -21,7 +21,9 @@ export const aiConfigCommand = {
21
21
  .command(aiConfigSetChannelOverrideCommand)
22
22
  .command(aiConfigListChannelOverridesCommand)
23
23
  .command(aiConfigStaffListCommand)
24
- .demandCommand(1, 'Specify a subcommand: list, get, create, set-channel-override, list-channel-overrides, staff-list'),
24
+ .command(aiConfigResolvedModelCommand)
25
+ .command(aiConfigModelCallsCommand)
26
+ .demandCommand(1, 'Specify a subcommand: list, get, create, set-channel-override, list-channel-overrides, staff-list, resolved-model, model-calls'),
25
27
  handler: () => {},
26
28
  };
27
29
 
@@ -44,7 +46,7 @@ const aiConfigListCommand = {
44
46
  const id = `[${c.id}]`.padStart(idW);
45
47
  const name = (c.name ?? '').padEnd(nameW);
46
48
  const stat = (c.status ?? '—').padEnd(7);
47
- console.log(`${id} ${name} ${stat} ${c.aiModel ?? '—'}`);
49
+ console.log(`${id} ${name} ${stat} ${c.aiModel || 'Velaro Default'}`);
48
50
  }
49
51
  console.log(`\n${rows.length} configuration(s)`);
50
52
  }),
@@ -191,3 +193,36 @@ const aiConfigSetChannelOverrideCommand = {
191
193
  console.log(`Channel override set: ai-config=${argv.id} channel=${argv.channel} mode=${argv.mode}`);
192
194
  }),
193
195
  };
196
+
197
+ // ── resolved-model ────────────────────────────────────────────────────────────
198
+ // Voice preview (GET avatar/voices/{voiceId}/preview) is intentionally UI-only: it streams binary
199
+ // audio for a browser <audio> element and has no useful CLI/MCP shape.
200
+
201
+ const aiConfigResolvedModelCommand = {
202
+ command: 'resolved-model <id>',
203
+ describe: 'Show which model an AI configuration runs on (model name is returned to Velaro staff only)',
204
+ builder: (y) => y.positional('id', { type: 'number', describe: 'AI configuration ID' }),
205
+ handler: runCommand(async (argv) => {
206
+ const r = await get(`/AIConfiguration/${argv.id}/resolved-model`);
207
+ if (!r?.isStaff) { console.log('Velaro Default (model name is visible to Velaro staff only)'); return; }
208
+ console.log(`resolved: ${r.resolvedModel ?? 'unknown'} ${r.usingDefault ? '(Velaro Default)' : `(explicit: ${r.explicitModel})`}`);
209
+ }),
210
+ };
211
+
212
+ // ── model-calls ───────────────────────────────────────────────────────────────
213
+
214
+ const aiConfigModelCallsCommand = {
215
+ command: 'model-calls',
216
+ describe: 'Velaro staff only: recent AI calls on the current site with the model each call used',
217
+ builder: (y) => y
218
+ .option('role', { type: 'string', describe: 'AI role filter, e.g. chatbot' })
219
+ .option('limit', { type: 'number', default: 50 }),
220
+ handler: runCommand(async (argv) => {
221
+ const qs = new URLSearchParams({ limit: String(argv.limit) });
222
+ if (argv.role) qs.set('role', argv.role);
223
+ const rows = (await get(`/AIConfiguration/model-calls?${qs}`)) ?? [];
224
+ if (!rows.length) { console.log('No AI calls found.'); return; }
225
+ for (const r of rows)
226
+ console.log(`${new Date(r.calledAt).toISOString()} ${String(r.role).padEnd(22)} ${String(r.modelId).padEnd(28)} in ${r.inputTokens} out ${r.outputTokens} $${Number(r.estimatedCostUsd).toFixed(4)}`);
227
+ }),
228
+ };
@@ -0,0 +1,170 @@
1
+ // velaro index-move — move or split knowledge between indexes with no rescan and no OCR, check an
2
+ // index for integrity, and watch/resume/undo a move. Wraps velaro-messaging IndexMoveController
3
+ // (docs/architecture/index-move-and-integrity.md there). The site always comes from your login,
4
+ // never from an argument. Same routes the admin "Knowledge Setup" page uses.
5
+ import { messagingGet, messagingPost } from '../api.js';
6
+ import { runCommand } from '../run.js';
7
+
8
+ const enc = encodeURIComponent;
9
+ const n = (v) => (v ?? 0).toLocaleString('en-US');
10
+
11
+ function selectorFrom(argv) {
12
+ const sel = {};
13
+ if (argv.all) sel.all = true;
14
+ if (argv['job-id']) sel.jobId = argv['job-id'];
15
+ if (argv['url-prefix']) sel.urlPrefix = argv['url-prefix'];
16
+ if (argv['source-type']) sel.sourceType = argv['source-type'];
17
+ if (argv['file-name']) sel.fileNames = [].concat(argv['file-name']);
18
+ if (argv['doc-id']) sel.docIds = [].concat(argv['doc-id']).map(Number);
19
+ return sel;
20
+ }
21
+
22
+ function moveBody(argv) {
23
+ const selector = selectorFrom(argv);
24
+ if (Object.keys(selector).length === 0) {
25
+ throw new Error('Give a selector: --all, --job-id, --url-prefix, --file-name, --doc-id or --source-type.');
26
+ }
27
+ return {
28
+ fromIndex: argv.from,
29
+ toIndex: argv.to,
30
+ kind: argv.copy ? 'copy' : 'move',
31
+ skipBlocked: !!argv['skip-blocked'],
32
+ selector,
33
+ };
34
+ }
35
+
36
+ export function printPlan(p) {
37
+ const q = p.quotaEffect ?? {};
38
+ console.log(`\n${p.kind}: ${p.fromIndex} -> ${p.toIndex}`);
39
+ console.log(` Documents: ${n(p.docs)} Chunks: ${n(p.chunks)} Pages: ${n(p.pages)}`);
40
+ console.log(` Copied as is: ${n(p.copyVectorsDocs)} Re-split from saved text: ${n(p.rechunkDocs)} Already there: ${n(p.alreadyInTargetDocs)} Blocked: ${n(p.blockedDocs)}`);
41
+ console.log(` Storage: ${n(q.indexedChunksCurrent)} -> ${n((q.indexedChunksCurrent ?? 0) + (q.indexedChunksDelta ?? 0))} of ${n(q.indexedChunksMax)} chunks`);
42
+ console.log(' No rescan, no OCR.');
43
+ for (const r of p.reasons ?? []) console.log(` Problem: ${r}`);
44
+ for (const w of p.warnings ?? []) console.log(` Note: ${w}`);
45
+ console.log(` Can run: ${p.canExecute ? 'yes' : 'no'}`);
46
+ }
47
+
48
+ export function printStatus(s) {
49
+ console.log(`\nMove ${s.moveId} (${s.kind}): ${s.fromIndex} -> ${s.toIndex}`);
50
+ console.log(` Status: ${s.status} Documents: ${s.docsDone}/${s.totalDocs} done, ${s.docsSkipped} skipped, ${s.docsFailed} failed`);
51
+ if (s.errorMessage) console.log(` Error: ${s.errorMessage}`);
52
+ const can = [s.canResume && 'resume', s.canAbort && 'abort', s.canUndo && 'undo'].filter(Boolean);
53
+ if (can.length) console.log(` You can: ${can.join(', ')}`);
54
+ }
55
+
56
+ const out = (argv, data, printer) => {
57
+ if (argv.json) console.log(JSON.stringify(data, null, 2));
58
+ else printer(data);
59
+ };
60
+
61
+ export const indexMoveCommand = {
62
+ command: 'index-move',
63
+ describe: 'Move/split content between knowledge indexes (no rescan, no OCR), check index integrity',
64
+ builder: (yargs) => yargs
65
+ .command('preview', 'Show what a move would do. Writes nothing.', (y) => y
66
+ .option('from', { type: 'string', demandOption: true })
67
+ .option('to', { type: 'string', demandOption: true })
68
+ .option('all', { type: 'boolean' })
69
+ .option('job-id', { type: 'string' })
70
+ .option('url-prefix', { type: 'string' })
71
+ .option('source-type', { type: 'string' })
72
+ .option('file-name', { type: 'string' })
73
+ .option('doc-id', { type: 'number' })
74
+ .option('copy', { type: 'boolean', describe: 'Copy instead of move' })
75
+ .option('skip-blocked', { type: 'boolean' })
76
+ .option('json', { type: 'boolean' }),
77
+ runCommand(async (a) => out(a, await messagingPost('/IndexMove/Preview', moveBody(a)), printPlan)))
78
+ .command('execute', 'Run a move. Preview it first.', (y) => y
79
+ .option('from', { type: 'string', demandOption: true })
80
+ .option('to', { type: 'string', demandOption: true })
81
+ .option('all', { type: 'boolean' })
82
+ .option('job-id', { type: 'string' })
83
+ .option('url-prefix', { type: 'string' })
84
+ .option('source-type', { type: 'string' })
85
+ .option('file-name', { type: 'string' })
86
+ .option('doc-id', { type: 'number' })
87
+ .option('copy', { type: 'boolean' })
88
+ .option('skip-blocked', { type: 'boolean' })
89
+ .option('json', { type: 'boolean' }),
90
+ runCommand(async (a) => {
91
+ const r = await messagingPost('/IndexMove/Execute', moveBody(a));
92
+ out(a, r, (d) => console.log(`Started move ${d.moveId}. Check it with: velaro index-move status --id ${d.moveId}`));
93
+ }))
94
+ .command('split', 'Move detected manuals out of a general index into a troubleshooting index', (y) => y
95
+ .option('from', { type: 'string', demandOption: true })
96
+ .option('to', { type: 'string', demandOption: true, describe: 'An existing troubleshooting-purpose index' })
97
+ .option('doc-id', { type: 'number', describe: 'Only these documents (default: every detected manual)' })
98
+ .option('skip-blocked', { type: 'boolean' })
99
+ .option('execute', { type: 'boolean', describe: 'Run it. Without this, only previews.' })
100
+ .option('json', { type: 'boolean' }),
101
+ runCommand(async (a) => {
102
+ const body = { fromIndex: a.from, toIndex: a.to, skipBlocked: !!a['skip-blocked'] };
103
+ if (a['doc-id'] !== undefined) body.docIds = [].concat(a['doc-id']).map(Number);
104
+ if (a.execute) {
105
+ const r = await messagingPost('/IndexMove/Split/Execute', body);
106
+ out(a, r, (d) => console.log(`Started split ${d.moveId}. Check it with: velaro index-move status --id ${d.moveId}`));
107
+ } else {
108
+ const r = await messagingPost('/IndexMove/Split/Preview', body);
109
+ out(a, r, (d) => printPlan(d.plan));
110
+ }
111
+ }))
112
+ .command('status', 'Show one move (--id) or the recent moves', (y) => y
113
+ .option('id', { type: 'string' })
114
+ .option('json', { type: 'boolean' }),
115
+ runCommand(async (a) => {
116
+ if (a.id) {
117
+ out(a, await messagingGet(`/IndexMove/Moves/${enc(a.id)}`), printStatus);
118
+ } else {
119
+ out(a, await messagingGet('/IndexMove/Moves?take=25'), (list) => list.forEach(printStatus));
120
+ }
121
+ }))
122
+ .command('resume', 'Resume a stopped move', (y) => y.option('id', { type: 'string', demandOption: true }),
123
+ runCommand(async (a) => {
124
+ await messagingPost(`/IndexMove/Moves/${enc(a.id)}/Resume`);
125
+ console.log(`Resumed move ${a.id}.`);
126
+ }))
127
+ .command('abort', 'Cancel a queued or failed move that has not started removing content', (y) => y.option('id', { type: 'string', demandOption: true }),
128
+ runCommand(async (a) => {
129
+ printStatus(await messagingPost(`/IndexMove/Moves/${enc(a.id)}/Abort`));
130
+ }))
131
+ .command('undo', 'Move a completed move back', (y) => y.option('id', { type: 'string', demandOption: true }),
132
+ runCommand(async (a) => {
133
+ const r = await messagingPost(`/IndexMove/Moves/${enc(a.id)}/Undo`);
134
+ console.log(`Started undo as move ${r.moveId}.`);
135
+ }))
136
+ .command('mixed', 'Check whether an index holds manual-like documents', (y) => y
137
+ .option('index', { type: 'string', demandOption: true })
138
+ .option('json', { type: 'boolean' }),
139
+ runCommand(async (a) => {
140
+ const r = await messagingGet(`/IndexMove/MixedContent?index=${enc(a.index)}`);
141
+ out(a, r, (d) => {
142
+ console.log(`\n${d.indexName}: ${n(d.manualLikeDocs)} of ${n(d.totalDocs)} documents look like manuals (${n(d.manualLikeChunks)} chunks). ${d.mixed ? 'Recommendation: ' + (d.recommendation ?? 'split') : 'No split needed.'}`);
143
+ for (const c of d.candidates ?? []) console.log(` #${c.docId} ${c.title ?? c.sourceId} ${n(c.chunks)} chunks`);
144
+ });
145
+ }))
146
+ .command('integrity', 'Read-only integrity report for an index', (y) => y
147
+ .option('index', { type: 'string', demandOption: true })
148
+ .option('json', { type: 'boolean' }),
149
+ runCommand(async (a) => {
150
+ const r = await messagingGet(`/IndexMove/Integrity?index=${enc(a.index)}`);
151
+ out(a, r, (d) => {
152
+ const f = d.findings ?? [];
153
+ console.log(f.length === 0 ? 'No problems found.' : `${f.length} finding(s)${d.truncated ? ' (partial: index is very large)' : ''}:`);
154
+ for (const x of f) console.log(` ${x.kind} ${x.sourceType ?? ''} ${x.sourceId ?? ''}`);
155
+ });
156
+ }))
157
+ .command('repair', 'Repair an index. Additive: fixes counts and records, never deletes content.', (y) => y
158
+ .option('index', { type: 'string', demandOption: true })
159
+ .option('remove-duplicate-records', { type: 'boolean', describe: 'Also remove duplicate tracking rows (rows only)' })
160
+ .option('json', { type: 'boolean' }),
161
+ runCommand(async (a) => {
162
+ const r = await messagingPost('/IndexMove/Integrity/Repair', {
163
+ indexName: a.index,
164
+ removeDuplicateRecords: !!a['remove-duplicate-records'],
165
+ });
166
+ out(a, r, () => console.log('Repair finished. Run "velaro index-move integrity --index ' + a.index + '" to confirm.'));
167
+ }))
168
+ .demandCommand(1, 'Specify a subcommand: preview, execute, split, status, resume, abort, undo, mixed, integrity, repair'),
169
+ handler: () => {},
170
+ };
@@ -7,6 +7,7 @@
7
7
  * velaro magento orders Search orders by email or order ID
8
8
  * velaro magento products Search the product catalog
9
9
  * velaro magento logs View integration activity logs
10
+ * velaro magento diagnose Live connection diagnostic (token, /rest/V1, scopes)
10
11
  */
11
12
 
12
13
  import { request } from '../api.js';
@@ -152,6 +153,27 @@ async function logsHandler(argv) {
152
153
  console.log('');
153
154
  }
154
155
 
156
+ // ── diagnose ──────────────────────────────────────────────────────────────────
157
+
158
+ async function diagnoseHandler(argv) {
159
+ const siteId = argv['site-id'];
160
+ if (!siteId) {
161
+ console.error(`${RED}✗ --site-id is required${RST}`);
162
+ process.exitCode = 1;
163
+ return;
164
+ }
165
+ try {
166
+ // Messaging DiagController: GET diagnostics/test-magento?siteId=<id>
167
+ const r = await request('GET', `/diagnostics/test-magento?siteId=${encodeURIComponent(siteId)}`);
168
+ console.log(`\n${BOLD}Magento diagnostic (site ${siteId})${RST}\n`);
169
+ console.log(JSON.stringify(r, null, 2));
170
+ console.log('');
171
+ } catch (e) {
172
+ console.error(`\n${RED}✗ Diagnostic failed: ${e.message}${RST}\n`);
173
+ process.exitCode = 1;
174
+ }
175
+ }
176
+
155
177
  // ── command export ────────────────────────────────────────────────────────────
156
178
 
157
179
  export const magentoCommand = {
@@ -205,6 +227,13 @@ export const magentoCommand = {
205
227
  .option('page-size', { describe: 'Results per page (default: 25)', type: 'number', default: 25 }),
206
228
  handler: logsHandler,
207
229
  })
230
+ .command({
231
+ command: 'diagnose',
232
+ describe: 'Live Magento connection diagnostic for a site',
233
+ builder: yy => yy
234
+ .option('site-id', { describe: 'Site ID to diagnose', type: 'number' }),
235
+ handler: diagnoseHandler,
236
+ })
208
237
  .demandCommand(1, 'Specify a subcommand. Try: velaro magento --help'),
209
238
  handler: () => {},
210
239
  };
@@ -54,6 +54,36 @@ async function generateHandler(argv) {
54
54
  printUrls(urls);
55
55
  }
56
56
 
57
+ // velaro netsuite m2m init|save|verify - OAuth 2.0 Client Credentials (machine-to-machine).
58
+ // Backend: NetSuiteAuthController POST NetSuite/M2M/Init | Save | Verify (velaro-messaging).
59
+ function printChecks(res) {
60
+ const checks = Array.isArray(res?.checks) ? res.checks : [];
61
+ console.log(`\n${BOLD}NetSuite M2M verification${RST}`);
62
+ for (const c of checks) {
63
+ console.log(` ${c.ok ? 'PASS' : 'FAIL'} ${c.name}${c.ok ? '' : ` - ${c.reason || c.reasonCode || 'failed'}`}`);
64
+ if (!c.ok && c.howToFix) console.log(` ${DIM}How to fix: ${c.howToFix}${RST}`);
65
+ }
66
+ console.log('');
67
+ }
68
+
69
+ async function m2mInitHandler(argv) {
70
+ const res = await messagingPost('/NetSuite/M2M/Init', { id: argv.config ?? null, company: argv.company, displayName: argv.name });
71
+ console.log(`\n${BOLD}Velaro public certificate${RST} (config ${res.id ?? argv.config ?? 'new'})`);
72
+ console.log('Upload in NetSuite: Setup > Integration > Manage Authentication > OAuth 2.0 Client Credentials (M2M) Setup');
73
+ console.log(res.certificatePem ?? '(no certificate returned)');
74
+ if (Array.isArray(res.steps)) res.steps.forEach((st, i) => console.log(` ${i + 1}. ${st}`));
75
+ console.log('');
76
+ }
77
+
78
+ async function m2mSaveHandler(argv) {
79
+ await messagingPost('/NetSuite/M2M/Save', { id: Number(argv.configId), clientId: argv.clientId, certificateId: argv.certificateId });
80
+ console.log(`\n ${GRN}Saved.${RST} Run "velaro netsuite m2m verify ${argv.configId}".\n`);
81
+ }
82
+
83
+ async function m2mVerifyHandler(argv) {
84
+ printChecks(await messagingPost('/NetSuite/M2M/Verify', { id: Number(argv.configId) }));
85
+ }
86
+
57
87
  export const netsuiteCommand = {
58
88
  command: 'netsuite <subcommand>',
59
89
  describe: 'NetSuite integration setup (click-to-dial / CTI)',
@@ -81,6 +111,31 @@ export const netsuiteCommand = {
81
111
  .strict(),
82
112
  handler: () => {},
83
113
  })
114
+ .command({
115
+ command: 'm2m <action>',
116
+ describe: 'NetSuite machine-to-machine (OAuth 2.0 Client Credentials) setup',
117
+ builder: yy => yy
118
+ .command({
119
+ command: 'init',
120
+ describe: 'Generate the Velaro public certificate to upload into NetSuite',
121
+ builder: a => a.option('config', { type: 'number' }).option('company', { type: 'string' }).option('name', { type: 'string' }),
122
+ handler: m2mInitHandler,
123
+ })
124
+ .command({
125
+ command: 'save <configId>',
126
+ describe: 'Store the Client ID and Certificate ID from NetSuite',
127
+ builder: a => a.option('client-id', { type: 'string', demandOption: true }).option('certificate-id', { type: 'string', demandOption: true }),
128
+ handler: argv => m2mSaveHandler({ configId: argv.configId, clientId: argv['client-id'], certificateId: argv['certificate-id'] }),
129
+ })
130
+ .command({
131
+ command: 'verify <configId>',
132
+ describe: 'Run the per-check verification (token, REST, customer read)',
133
+ handler: m2mVerifyHandler,
134
+ })
135
+ .demandCommand(1, 'Specify an m2m action: init, save, or verify.')
136
+ .strict(),
137
+ handler: () => {},
138
+ })
84
139
  .demandCommand(1, 'Specify a netsuite subcommand.')
85
140
  .strict(),
86
141
  handler: () => {},
@@ -0,0 +1,27 @@
1
+ // velaro scraper usage --site <id> — Velaro staff only. Scraper / upload / chunk consumption for
2
+ // any site (GET ScraperJobs/StaffUsage/{siteId}; IsVelaroAdminAsync + super-admin enforced
3
+ // server-side). The site is always explicit in the route, never derived from the caller's auth.
4
+ import { request } from '../api.js';
5
+
6
+ const n = (v) => (v ?? 0).toLocaleString('en-US');
7
+
8
+ async function usage(argv) {
9
+ const u = await request('GET', `ScraperJobs/StaffUsage/${encodeURIComponent(argv.site)}`);
10
+ if (argv.json) { console.log(JSON.stringify(u, null, 2)); return; }
11
+ console.log(`\nScraper usage, site ${u.siteId}${u.isTrial ? ' (trial)' : ''}`);
12
+ console.log(` Crawl pages this month: ${n(u.pagesThisMonthEffective)} / ${n(u.pagesBudgetPerMonth)} (admin ledger ${n(u.pagesThisMonthAdminLedger)}, messaging ledger ${u.pagesThisMonthMessagingLedger ?? 'n/a'})`);
13
+ console.log(` Upload pages this month: ${n(u.uploadPagesThisMonth)} / ${n(u.uploadPagesBudgetPerMonth)}`);
14
+ console.log(` OCR pages this month: ${n(u.ocrPagesThisMonth)}`);
15
+ console.log(` Jobs today: ${n(u.jobsToday)} / ${n(u.maxJobsPerDay)}`);
16
+ console.log(` Chunks indexed: ${n(u.chunksIndexed)} / ${n(u.maxIndexedChunkTotal)}`);
17
+ for (const i of u.chunksByIndex ?? []) console.log(` ${i.indexName}: ${n(i.chunks)} chunks, ${n(i.documents)} docs`);
18
+ for (const note of u.notes ?? []) console.log(` Note: ${note}`);
19
+ }
20
+
21
+ export function scraperCommand(yargs) {
22
+ return yargs
23
+ .command('usage', 'Show scraper/upload/chunk usage for a site (staff only)', (y) => y
24
+ .option('site', { type: 'number', demandOption: true, describe: 'Target site id' })
25
+ .option('json', { type: 'boolean', describe: 'Print raw JSON' }), (a) => usage(a))
26
+ .demandCommand(1, 'Specify a subcommand: usage');
27
+ }
@@ -105,8 +105,21 @@ async function listDeployments(args) {
105
105
  console.log('Use the embed key as test-frame.html?siteId=<site>&deploymentId=<embed-key>.');
106
106
  }
107
107
 
108
+ async function tourInsights(args) {
109
+ const days = Math.min(365, Math.max(1, args.days ?? 30));
110
+ const path = args.site
111
+ ? `SupportTools/sites/${args.site}/tour-events?days=${days}`
112
+ : `SupportTools/tour-insights?days=${days}`;
113
+ const result = await request('GET', path);
114
+ console.log(JSON.stringify(result, null, 2));
115
+ }
116
+
108
117
  export function supportCommand(yargs) {
109
118
  return yargs
119
+ .command('tour-insights', 'Moshky setup-tour funnel + scrubbed feedback across all sites, or one site\'s tour timeline with --site (Velaro staff, read-only)', y => y
120
+ .option('site', { type: 'number', desc: 'Optional site ID: show that site\'s tour events and feedback instead of the funnel' })
121
+ .option('days', { type: 'number', default: 30, desc: 'Look-back window in days (max 365)' })
122
+ , a => tourInsights(a))
110
123
  .command('deployments', 'List a site’s widget embed keys and survey assignments (Velaro staff, read-only)', y => y
111
124
  .option('site', { type: 'number', demandOption: true, desc: 'Target site ID' })
112
125
  .option('json', { type: 'boolean', default: false, desc: 'Print the complete JSON response' })
@@ -142,5 +155,5 @@ export function supportCommand(yargs) {
142
155
  .option('channel', { type: 'string', desc: 'Conversation source, e.g. Web, TwilioSms (default Web)' })
143
156
  .option('overrideIndexes', { type: 'string', desc: 'Comma-separated index names to override the config\'s own IndexName for this query' })
144
157
  , a => chatQuery(a))
145
- .demandCommand(1, 'Specify a subcommand: deployments | impersonate | agent-lookup | toggle-flag | scraper-create-job | chat-query | send-email');
158
+ .demandCommand(1, 'Specify a subcommand: deployments | impersonate | agent-lookup | toggle-flag | scraper-create-job | chat-query | send-email | tour-insights');
146
159
  }
@@ -0,0 +1,61 @@
1
+ // velaro tour-policy — Velaro staff only. Guided-tour auto-open policy, resolved
2
+ // site override, then global default, then built-in default. Every write is versioned
3
+ // (last 20) and reversible. Site is always explicit in the route (TourPolicy/sites/{siteId}/...),
4
+ // never derived from the caller's auth context; omit --site for the global default (Velaro Admin).
5
+ import { request } from '../api.js';
6
+
7
+ const enc = encodeURIComponent;
8
+ const base = (argv) =>
9
+ argv.site ? `TourPolicy/sites/${argv.site}/${enc(argv.tour)}` : `TourPolicy/global/${enc(argv.tour)}`;
10
+
11
+ const fmt = (p) =>
12
+ p ? `${p.enabled ? 'ON' : 'OFF'}, ${p.maxAutoOpens} opens, ${p.minHoursBetweenAutoOpens}h apart, ${p.windowDays}d window${p.source ? ` (source: ${p.source})` : ''}` : '(none)';
13
+
14
+ async function get(argv) {
15
+ const r = await request('GET', base(argv));
16
+ console.log(`\nTour ${r.tourKey}, ${argv.site ? `site ${argv.site}` : 'global default'}`);
17
+ console.log(` Stored: ${fmt(r.stored)}`);
18
+ if (argv.site) console.log(` Effective: ${fmt(r.effective)}`);
19
+ }
20
+
21
+ async function set(argv) {
22
+ const body = {};
23
+ if (argv.enabled !== undefined) body.enabled = argv.enabled;
24
+ if (argv.maxAutoOpens !== undefined) body.maxAutoOpens = argv.maxAutoOpens;
25
+ if (argv.minHours !== undefined) body.minHoursBetweenAutoOpens = argv.minHours;
26
+ if (argv.windowDays !== undefined) body.windowDays = argv.windowDays;
27
+ const r = await request('PUT', base(argv), body);
28
+ console.log(`Saved as version ${r.version.version}. Undo with: velaro tour-policy rollback --tour ${argv.tour}${argv.site ? ` --site ${argv.site}` : ''} --version <n>`);
29
+ }
30
+
31
+ async function history(argv) {
32
+ const r = await request('GET', `${base(argv)}/history`);
33
+ if (!r.versions?.length) { console.log('No history yet.'); return; }
34
+ for (const v of r.versions) {
35
+ const restored = v.restoredFromVersion ? ` (from v${v.restoredFromVersion})` : '';
36
+ console.log(`v${v.version} ${v.action}${restored} by ${v.by} at ${v.at}: ${fmt(v.oldPolicy)} -> ${fmt(v.newPolicy)}`);
37
+ }
38
+ }
39
+
40
+ async function rollback(argv) {
41
+ const r = await request('POST', `${base(argv)}/rollback`, { version: argv.version });
42
+ console.log(`Restored v${argv.version} as new version ${r.version.version}.`);
43
+ }
44
+
45
+ const common = (y) => y
46
+ .option('tour', { type: 'string', default: 'CommerceWelcome', describe: 'CommerceWelcome or ShopifyWelcome' })
47
+ .option('site', { type: 'number', describe: 'Site id. Omit for the global default (Velaro Admin).' });
48
+
49
+ export function tourPolicyCommand(yargs) {
50
+ return yargs
51
+ .command('get', 'Show stored and effective tour policy', common, (a) => get(a))
52
+ .command('set', 'Set tour policy (partial: omitted fields keep their value)', (y) => common(y)
53
+ .option('enabled', { type: 'boolean' })
54
+ .option('max-auto-opens', { type: 'number', alias: 'maxAutoOpens', describe: '0-20' })
55
+ .option('min-hours', { type: 'number', describe: 'Hours between auto-opens, 0-720' })
56
+ .option('window-days', { type: 'number', alias: 'windowDays', describe: '1-365' }), (a) => set(a))
57
+ .command('history', 'List the last 20 versions, newest first', common, (a) => history(a))
58
+ .command('rollback', 'Restore the state after a version (recorded as a new version)', (y) => common(y)
59
+ .option('version', { type: 'number', demandOption: true }), (a) => rollback(a))
60
+ .demandCommand(1, 'Specify a subcommand: get | set | history | rollback');
61
+ }
@@ -1,5 +1,5 @@
1
1
  import fs from 'node:fs';
2
- import { get, post, getCredentials, messagingGet } from '../api.js';
2
+ import { getCredentials, messagingGet, messagingPost, messagingPut } from '../api.js';
3
3
  import { requireFeature } from '../subscription.js';
4
4
  import { runCommand } from '../run.js';
5
5
 
@@ -20,6 +20,9 @@ export const workflowCommand = {
20
20
  };
21
21
 
22
22
  // ── list ───────────────────────────────────────────────────────────────────────
23
+ // NOTE: every /Workflows/* route lives on the messaging API (WorkflowsController in
24
+ // velaro-messaging), never the admin API — these commands were 404ing in every environment
25
+ // until fixed 2026-09-27 because they called the admin-API-only get()/post() helpers.
23
26
 
24
27
  const workflowListCommand = {
25
28
  command: 'list',
@@ -27,7 +30,7 @@ const workflowListCommand = {
27
30
  handler: runCommand(async () => {
28
31
  await requireFeature('enableAutomation', 'Workflows');
29
32
 
30
- const workflows = await get('/Workflows/List');
33
+ const workflows = await messagingGet('/Workflows/List');
31
34
  const rows = (workflows ?? []).filter(w => !w.isTemplate);
32
35
 
33
36
  if (!rows.length) { console.log('No workflows found.'); return; }
@@ -56,14 +59,17 @@ const workflowGetCommand = {
56
59
  handler: runCommand(async (argv) => {
57
60
  await requireFeature('enableAutomation', 'Workflows');
58
61
 
59
- const w = await get(`/Workflows/${argv.id}`);
62
+ const w = await messagingGet(`/Workflows/${argv.id}`);
60
63
  if (!w) { console.error(`Workflow ${argv.id} not found.`); process.exit(1); }
61
64
 
62
65
  console.log(`\n[${w.id}] ${w.name}`);
63
66
  console.log(` Status: ${w.enabled ? 'enabled' : 'disabled'}`);
64
67
  console.log(` Trigger: ${w.triggerType ?? '—'}`);
65
- if (w.nodes?.length) {
66
- console.log(` Nodes: ${w.nodes.length}`);
68
+ // WorkflowViewModel.Nodes serializes as a JSON object keyed by node id
69
+ // (Dictionary<int, WorkflowNode>), not an array.
70
+ const nodeCount = w.nodes ? Object.keys(w.nodes).length : 0;
71
+ if (nodeCount) {
72
+ console.log(` Nodes: ${nodeCount}`);
67
73
  }
68
74
  }),
69
75
  };
@@ -100,7 +106,7 @@ const workflowCreateCommand = {
100
106
  };
101
107
  }
102
108
 
103
- const id = await post('/Workflows', body);
109
+ const id = await messagingPost('/Workflows', body);
104
110
  console.log(`Workflow created: ${id}`);
105
111
  console.log(`View: velaro workflow get ${id}`);
106
112
  }),
@@ -114,7 +120,16 @@ const workflowEnableCommand = {
114
120
  builder: (y) => y.positional('id', { type: 'number', describe: 'Workflow ID' }),
115
121
  handler: runCommand(async (argv) => {
116
122
  await requireFeature('enableAutomation', 'Workflows');
117
- await post(`/Workflows/toggle/${argv.id}`, null);
123
+
124
+ const w = await messagingGet(`/Workflows/${argv.id}`);
125
+ if (!w) { console.error(`Workflow ${argv.id} not found.`); process.exit(1); }
126
+
127
+ if (w.enabled) {
128
+ console.log(`Workflow ${argv.id} is already enabled.`);
129
+ return;
130
+ }
131
+
132
+ await messagingPost(`/Workflows/toggle/${argv.id}`, null);
118
133
  console.log(`Workflow ${argv.id} enabled.`);
119
134
  }),
120
135
  };
@@ -126,7 +141,7 @@ const workflowDisableCommand = {
126
141
  handler: runCommand(async (argv) => {
127
142
  await requireFeature('enableAutomation', 'Workflows');
128
143
 
129
- const w = await get(`/Workflows/${argv.id}`);
144
+ const w = await messagingGet(`/Workflows/${argv.id}`);
130
145
  if (!w) { console.error(`Workflow ${argv.id} not found.`); process.exit(1); }
131
146
 
132
147
  if (!w.enabled) {
@@ -134,12 +149,17 @@ const workflowDisableCommand = {
134
149
  return;
135
150
  }
136
151
 
137
- await post(`/Workflows/toggle/${argv.id}`, null);
152
+ await messagingPost(`/Workflows/toggle/${argv.id}`, null);
138
153
  console.log(`Workflow ${argv.id} disabled.`);
139
154
  }),
140
155
  };
141
156
 
142
157
  // ── node-options ───────────────────────────────────────────────────────────────
158
+ // The backend has no per-node route — WorkflowsController only exposes [HttpPut] on the
159
+ // whole workflow (PutAsync(WorkflowViewModel)). This command previously POSTed to a
160
+ // nonexistent `/Workflows/{workflowId}/node/{nodeId}` route; it now reads the full workflow,
161
+ // mutates the one node in its Nodes dictionary, and PUTs the whole WorkflowViewModel back —
162
+ // the same read-modify-write the admin canvas UI performs on every node edit.
143
163
 
144
164
  const workflowNodeOptionsCommand = {
145
165
  command: 'node-options <workflowId> <nodeId>',
@@ -156,10 +176,14 @@ const workflowNodeOptionsCommand = {
156
176
  handler: runCommand(async (argv) => {
157
177
  await requireFeature('enableAutomation', 'Workflows');
158
178
 
159
- const w = await get(`/Workflows/${argv.workflowId}`);
179
+ const w = await messagingGet(`/Workflows/${argv.workflowId}`);
160
180
  if (!w) { console.error(`Workflow ${argv.workflowId} not found.`); process.exit(1); }
161
181
 
162
- const node = (w.nodes ?? []).find(n => n.id === argv.nodeId) ?? Object.values(w.nodes ?? {})[argv.nodeId];
182
+ // Nodes is a Dictionary<int, WorkflowNode> — serializes as a JSON object keyed by
183
+ // node id string, not an array.
184
+ const nodes = w.nodes ?? {};
185
+ const nodeKey = String(argv.nodeId);
186
+ const node = nodes[nodeKey];
163
187
  if (!node) { console.error(`Node ${argv.nodeId} not found in workflow ${argv.workflowId}.`); process.exit(1); }
164
188
  if (node.actionType !== 'askQuestion' || node.questionType !== 'multiSelect') {
165
189
  console.error(`Node ${argv.nodeId} is not a multiSelect ask-question node (found: ${node.actionType}/${node.questionType}).`);
@@ -168,6 +192,16 @@ const workflowNodeOptionsCommand = {
168
192
 
169
193
  const hasChanges = argv.options || argv.min != null || argv.max != null || argv['dynamic-var'] || argv.strategy;
170
194
 
195
+ // WorkflowNode (velaro-messaging) has no MinSelections/MaxSelections/OptionsSource/
196
+ // DynamicOptionsVariable properties and no [JsonExtensionData] catch-all, so these fields
197
+ // are silently dropped on save — confirmed by reading WorkflowNode's source. Refuse rather
198
+ // than print a false "Node updated." The admin canvas (MultiSelectEditor.tsx) has the same
199
+ // gap; the real fix is adding those properties server-side.
200
+ if (argv.min != null || argv.max != null || argv['dynamic-var']) {
201
+ console.error('--min, --max, and --dynamic-var are not supported yet: WorkflowNode has no backing fields for them, so the server silently drops them on save. Only --options and --strategy actually persist.');
202
+ process.exit(1);
203
+ }
204
+
171
205
  if (!hasChanges) {
172
206
  // Read mode — print current config
173
207
  console.log(`\nNode ${argv.nodeId} — multiSelect options:`);
@@ -184,21 +218,16 @@ const workflowNodeOptionsCommand = {
184
218
  return;
185
219
  }
186
220
 
187
- // Update mode
221
+ // Update mode — only --options and --strategy have backing fields on WorkflowNode today.
188
222
  const updated = { ...node };
189
223
  if (argv.options) {
190
224
  updated.options = JSON.parse(argv.options);
191
225
  updated.optionsSource = 'static';
192
226
  }
193
- if (argv['dynamic-var']) {
194
- updated.dynamicOptionsVariable = argv['dynamic-var'];
195
- updated.optionsSource = 'dynamic';
196
- }
197
- if (argv.min != null) updated.minSelections = argv.min;
198
- if (argv.max != null) updated.maxSelections = argv.max;
199
- if (argv.strategy) updated.largeListStrategy = argv.strategy;
227
+ if (argv.strategy) updated.largeListStrategy = argv.strategy;
200
228
 
201
- await post(`/Workflows/${argv.workflowId}/node/${argv.nodeId}`, updated);
229
+ const updatedWorkflow = { ...w, nodes: { ...nodes, [nodeKey]: updated } };
230
+ await messagingPut('/Workflows', updatedWorkflow);
202
231
  console.log(`Node ${argv.nodeId} updated.`);
203
232
  }),
204
233
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velaro/cli",
3
- "version": "1.4.58",
3
+ "version": "1.4.60",
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": {
@@ -17,7 +17,7 @@
17
17
  "@azure/identity": "^4.4.1",
18
18
  "@azure/keyvault-secrets": "^4.9.0",
19
19
  "mssql": "^12.7.0",
20
- "pdfkit": "^0.15.2",
20
+ "pdfkit": "^0.20.2",
21
21
  "yargs": "^17.7.2"
22
22
  },
23
23
  "optionalDependencies": {
@@ -33,5 +33,8 @@
33
33
  "lib/",
34
34
  "scripts/",
35
35
  "assets/"
36
- ]
36
+ ],
37
+ "devDependencies": {
38
+ "license-checker-rseidelsohn": "^4.4.2"
39
+ }
37
40
  }