@velaro/cli 1.2.0 → 1.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +161 -138
  2. package/bin/velaro.js +177 -62
  3. package/lib/api.js +91 -52
  4. package/lib/api.test.js +46 -0
  5. package/lib/banner.js +76 -0
  6. package/lib/commands/activity.js +133 -0
  7. package/lib/commands/acuity.js +66 -0
  8. package/lib/commands/agent.js +204 -50
  9. package/lib/commands/ai-config.js +193 -0
  10. package/lib/commands/ai-models.js +159 -0
  11. package/lib/commands/appointments.js +198 -0
  12. package/lib/commands/article.js +668 -388
  13. package/lib/commands/automation-draft.js +134 -0
  14. package/lib/commands/avatar.js +75 -0
  15. package/lib/commands/bigcommerce.js +50 -0
  16. package/lib/commands/billing-contacts.js +62 -0
  17. package/lib/commands/billing-email-preference.js +64 -0
  18. package/lib/commands/billing-subscription.js +265 -0
  19. package/lib/commands/billing.js +138 -0
  20. package/lib/commands/bot.js +141 -137
  21. package/lib/commands/bundle.js +168 -0
  22. package/lib/commands/calendly.js +62 -0
  23. package/lib/commands/callback.js +125 -0
  24. package/lib/commands/callrail.js +88 -0
  25. package/lib/commands/campaigns.js +44 -0
  26. package/lib/commands/case.js +102 -0
  27. package/lib/commands/check.js +163 -163
  28. package/lib/commands/compliance.js +229 -0
  29. package/lib/commands/conversation-efficiency.js +178 -0
  30. package/lib/commands/copilotstudio.js +114 -0
  31. package/lib/commands/coupon-grant.js +192 -0
  32. package/lib/commands/db.js +101 -0
  33. package/lib/commands/deployment.js +107 -107
  34. package/lib/commands/diagnostics.js +298 -0
  35. package/lib/commands/email-campaign.js +47 -0
  36. package/lib/commands/email-inbox.js +88 -0
  37. package/lib/commands/entitlement.js +176 -0
  38. package/lib/commands/env.js +45 -45
  39. package/lib/commands/feature-discovery.js +40 -0
  40. package/lib/commands/focus.js +278 -0
  41. package/lib/commands/index.js +38 -5
  42. package/lib/commands/ingest.js +31 -31
  43. package/lib/commands/inline-widget-config.js +126 -0
  44. package/lib/commands/integration.js +93 -0
  45. package/lib/commands/kb.js +450 -309
  46. package/lib/commands/login.js +86 -86
  47. package/lib/commands/logs.js +680 -0
  48. package/lib/commands/magento.js +210 -0
  49. package/lib/commands/mcp-key.js +188 -159
  50. package/lib/commands/migrate.js +134 -0
  51. package/lib/commands/migration-status.js +66 -0
  52. package/lib/commands/monday.js +137 -0
  53. package/lib/commands/netsuite.js +87 -0
  54. package/lib/commands/notifications.js +63 -0
  55. package/lib/commands/notion.js +70 -0
  56. package/lib/commands/ops.js +267 -173
  57. package/lib/commands/payment-recovery.js +170 -0
  58. package/lib/commands/pickup.js +172 -0
  59. package/lib/commands/pricing.js +132 -0
  60. package/lib/commands/product.js +55 -0
  61. package/lib/commands/recruiting.js +374 -0
  62. package/lib/commands/report.js +462 -0
  63. package/lib/commands/routing.js +304 -0
  64. package/lib/commands/rule.js +85 -85
  65. package/lib/commands/sharepoint.js +167 -0
  66. package/lib/commands/site-provision.js +68 -0
  67. package/lib/commands/site.js +62 -62
  68. package/lib/commands/sitesync.js +158 -0
  69. package/lib/commands/slack.js +64 -0
  70. package/lib/commands/squarespace.js +108 -0
  71. package/lib/commands/status.js +24 -24
  72. package/lib/commands/subscription.js +43 -0
  73. package/lib/commands/support.js +128 -0
  74. package/lib/commands/survey.js +216 -0
  75. package/lib/commands/team.js +144 -144
  76. package/lib/commands/teams-phone.js +131 -0
  77. package/lib/commands/teams.js +106 -0
  78. package/lib/commands/telephony.js +99 -0
  79. package/lib/commands/update.js +47 -47
  80. package/lib/commands/webflow.js +128 -0
  81. package/lib/commands/whoami.js +25 -22
  82. package/lib/commands/widget-container.js +152 -0
  83. package/lib/commands/woocommerce.js +240 -0
  84. package/lib/commands/workflow.js +233 -98
  85. package/lib/config.js +85 -83
  86. package/lib/kb-screenshot.js +320 -0
  87. package/lib/migrations/amscro.json +72 -0
  88. package/lib/migrations/azenta.json +68 -0
  89. package/lib/migrations/bluefire.json +49 -0
  90. package/lib/migrations/donaldson.json +75 -0
  91. package/lib/oauth.js +149 -135
  92. package/lib/run.js +21 -16
  93. package/lib/sharepoint-auth.js +138 -0
  94. package/lib/subscription.js +41 -39
  95. package/lib/track.js +35 -35
  96. package/lib/update-check.js +64 -64
  97. package/package.json +34 -19
  98. package/scripts/postinstall.js +12 -0
@@ -1,309 +1,450 @@
1
- import { readFileSync } from 'fs';
2
- import { basename } from 'path';
3
- import { get, post, del } from '../api.js';
4
- import { requireFeature } from '../subscription.js';
5
- import { runCommand } from '../run.js';
6
- import { articleCommand } from './article.js';
7
-
8
- export const kbCommand = {
9
- command: 'kb <subcommand>',
10
- describe: 'Manage knowledge base — articles, Q&A pairs, bot overrides, and custom content',
11
- builder: (yargs) =>
12
- yargs
13
- .command(articleCommand)
14
- .command(qnaCommand)
15
- .command(overrideCommand)
16
- .command(contentCommand)
17
- .command(reindexCommand)
18
- .demandCommand(1, 'Specify a subcommand: article, qna, override, content, reindex'),
19
- handler: () => {},
20
- };
21
-
22
- // ────────────────────────────────────────────────────────────────────────────
23
- // Reindex re-embed all KB articles with the current embedding model
24
- // ────────────────────────────────────────────────────────────────────────────
25
-
26
- const reindexCommand = {
27
- command: 'reindex',
28
- describe: 'Re-embed KB articles or scraper/ingestion content (use after embedding model upgrade)',
29
- builder: (y) =>
30
- y
31
- .option('all', {
32
- type: 'boolean',
33
- describe: 'Reindex ALL sites (Velaro staff only)',
34
- default: false,
35
- })
36
- .option('site', {
37
- type: 'number',
38
- describe: 'Reindex a specific site by ID (Velaro staff only)',
39
- })
40
- .option('ingestion', {
41
- type: 'boolean',
42
- describe: 'Reindex scraper/ingestion Azure Search indexers instead of KB articles',
43
- default: false,
44
- }),
45
- handler: runCommand(async (argv) => {
46
- if (argv.ingestion) {
47
- if (argv.all) {
48
- console.log('Starting bulk ingestion reindex for all sites runs in background.');
49
- console.log('Watch server logs ([ReindexAllIngestion]) for progress.\n');
50
- const result = await post('/DatabaseTool/ReindexAllIngestionIndexes', {});
51
- console.log(result.message ?? 'Queued.');
52
- } else if (argv.site) {
53
- console.log(`Reindexing scraper/ingestion index for site ${argv.site}…`);
54
- const result = await post(`/DatabaseTool/ReindexIngestionIndex/${argv.site}`, {});
55
- if (result.success) {
56
- console.log(`✅ Triggered ${result.indexersRun} indexer(s) — re-processing in background.`);
57
- } else {
58
- console.error(`❌ Failed: ${result.message}`);
59
- }
60
- } else {
61
- console.error('--ingestion requires --site <id> or --all (Velaro staff only)');
62
- process.exit(1);
63
- }
64
- } else if (argv.all) {
65
- console.log('Starting bulk KB reindex for all sites — this runs in the background.');
66
- console.log('Watch server logs ([ReindexAllKb]) for progress.\n');
67
- const result = await post('/DatabaseTool/ReindexAllKb', {});
68
- console.log(result.message ?? 'Queued.');
69
- } else if (argv.site) {
70
- console.log(`Reindexing KB articles for site ${argv.site}…`);
71
- const result = await post(`/DatabaseTool/ReindexKb/${argv.site}`, {});
72
- if (result.success) {
73
- console.log(`✅ Done — ${result.articles} articles reindexed.`);
74
- } else {
75
- console.error(`❌ Failed: ${result.message}`);
76
- }
77
- } else {
78
- console.log("Reindexing your site's KB articles\u2026");
79
- const result = await post('/KBSearchIndex/reindex', {});
80
- if (result.success === false) {
81
- console.error(`❌ Failed: ${result.message}`);
82
- } else {
83
- console.log(`✅ Done — ${result.articles ?? result.count ?? 'all'} articles reindexed.`);
84
- }
85
- }
86
- }),
87
- };
88
-
89
- // ────────────────────────────────────────────────────────────────────────────
90
- // Q&A
91
- // ────────────────────────────────────────────────────────────────────────────
92
-
93
- const qnaCommand = {
94
- command: 'qna <subcommand>',
95
- describe: 'Manage Q&A knowledge pairs',
96
- builder: (yargs) =>
97
- yargs
98
- .command({
99
- command: 'list',
100
- describe: 'List Q&A pairs',
101
- builder: (y) => y.option('bot-id', { type: 'number', describe: 'Filter by bot ID' }),
102
- handler: runCommand(async (argv) => {
103
- await requireFeature('enableKnowledgeBase', 'Knowledge Base');
104
- const params = argv['bot-id'] ? `?aiConfigurationId=${argv['bot-id']}` : '';
105
- const items = await get(`/api/BotQnA${params}`);
106
- if (!items?.length) { console.log('No Q&A pairs found.'); return; }
107
-
108
- console.log(`\nFound ${items.length} Q&A pair(s):\n`);
109
- for (const item of items) {
110
- console.log(` [${item.id}] Q: ${truncate(item.question, 70)}`);
111
- console.log(` A: ${truncate(item.answer, 70)}\n`);
112
- }
113
- }),
114
- })
115
- .command({
116
- command: 'add',
117
- describe: 'Add a Q&A pair',
118
- builder: (y) =>
119
- y
120
- .option('bot-id', { type: 'number', demandOption: true, describe: 'Bot ID' })
121
- .option('question', { type: 'string', demandOption: true, describe: 'Question text' })
122
- .option('answer', { type: 'string', demandOption: true, describe: 'Answer text' }),
123
- handler: runCommand(async (argv) => {
124
- await requireFeature('enableKnowledgeBase', 'Knowledge Base');
125
- const result = await post('/api/BotQnA', {
126
- aiConfigurationId: argv['bot-id'],
127
- question: argv.question,
128
- answer: argv.answer,
129
- });
130
- console.log(`Q&A pair added: [${result.id}]`);
131
- }),
132
- })
133
- .command({
134
- command: 'delete <id>',
135
- describe: 'Delete a Q&A pair',
136
- builder: (y) => y.positional('id', { type: 'number', describe: 'Q&A pair ID' }),
137
- handler: runCommand(async (argv) => {
138
- await requireFeature('enableKnowledgeBase', 'Knowledge Base');
139
- await del(`/api/BotQnA/${argv.id}`);
140
- console.log(`Q&A pair ${argv.id} deleted.`);
141
- }),
142
- })
143
- .demandCommand(1, 'Specify a subcommand: list, add, delete'),
144
- handler: () => {},
145
- };
146
-
147
- // ────────────────────────────────────────────────────────────────────────────
148
- // Overrides authoritative facts that take priority over KB search
149
- // ────────────────────────────────────────────────────────────────────────────
150
-
151
- const overrideCommand = {
152
- command: 'override <subcommand>',
153
- describe: 'Manage bot knowledge overrides (authoritative facts)',
154
- builder: (yargs) =>
155
- yargs
156
- .command({
157
- command: 'list',
158
- describe: 'List knowledge overrides',
159
- handler: runCommand(async () => {
160
- await requireFeature('enableKnowledgeBase', 'Knowledge Base');
161
- const items = await get('/Ticket/knowledge-overrides');
162
- if (!items?.length) { console.log('No overrides found.'); return; }
163
-
164
- console.log(`\nFound ${items.length} override(s):\n`);
165
- for (const item of items) {
166
- const status = item.isActive ? 'active ' : 'inactive';
167
- const expiry = item.expiresAt ? ` expires ${new Date(item.expiresAt).toLocaleDateString()}` : '';
168
- console.log(` [${item.id}] ${status} ${truncate(item.title ?? item.content, 60)}${expiry}`);
169
- }
170
- }),
171
- })
172
- .command({
173
- command: 'add',
174
- describe: 'Add a knowledge override',
175
- builder: (y) =>
176
- y
177
- .option('title', { type: 'string', demandOption: true, describe: 'Override title' })
178
- .option('content', { type: 'string', demandOption: true, describe: 'Authoritative content' })
179
- .option('expires', { type: 'string', describe: 'Expiry date (ISO 8601, e.g. 2026-12-31)' }),
180
- handler: runCommand(async (argv) => {
181
- await requireFeature('enableKnowledgeBase', 'Knowledge Base');
182
- const payload = { title: argv.title, content: argv.content, isActive: true };
183
- if (argv.expires) payload.expiresAt = new Date(argv.expires).toISOString();
184
- const result = await post('/Ticket/knowledge-overrides', payload);
185
- console.log(`Override added: [${result.id}] "${argv.title}"`);
186
- }),
187
- })
188
- .command({
189
- command: 'delete <id>',
190
- describe: 'Delete a knowledge override',
191
- builder: (y) => y.positional('id', { type: 'number', describe: 'Override ID' }),
192
- handler: runCommand(async (argv) => {
193
- await requireFeature('enableKnowledgeBase', 'Knowledge Base');
194
- await del(`/Ticket/knowledge-overrides/${argv.id}`);
195
- console.log(`Override ${argv.id} deleted.`);
196
- }),
197
- })
198
- .demandCommand(1, 'Specify a subcommand: list, add, delete'),
199
- handler: () => {},
200
- };
201
-
202
- // ────────────────────────────────────────────────────────────────────────────
203
- // Custom content — push arbitrary text/files into the site's KB index
204
- // ────────────────────────────────────────────────────────────────────────────
205
- // Note: content goes into the site's default index (determined server-side by
206
- // SiteId). Index selection is not yet supported by this API endpoint.
207
-
208
- const contentCommand = {
209
- command: 'content <subcommand>',
210
- describe: 'Push custom text or files into the knowledge base index',
211
- builder: (yargs) =>
212
- yargs
213
- .command({
214
- command: 'ingest',
215
- describe: 'Push a file or text snippet into the KB index',
216
- builder: (y) =>
217
- y
218
- .option('file', {
219
- describe: 'Path to a .txt or .md file to ingest',
220
- type: 'string',
221
- })
222
- .option('text', {
223
- describe: 'Inline text content to ingest (alternative to --file)',
224
- type: 'string',
225
- })
226
- .option('content-id', {
227
- describe: 'Stable ID for this document (used for dedup/updates). Defaults to filename.',
228
- type: 'string',
229
- })
230
- .option('title', {
231
- describe: 'Document title shown when bot attributes this content. Defaults to filename.',
232
- type: 'string',
233
- })
234
- .option('source-url', {
235
- describe: 'Canonical URL to show when bot cites this content',
236
- type: 'string',
237
- })
238
- .check((argv) => {
239
- if (!argv.file && !argv.text) throw new Error('Provide either --file or --text');
240
- if (argv.file && argv.text) throw new Error('Use --file or --text, not both');
241
- return true;
242
- }),
243
- handler: runCommand(async (argv) => {
244
- await requireFeature('enableKnowledgeBase', 'Knowledge Base');
245
-
246
- let text, defaultId, defaultTitle;
247
- if (argv.file) {
248
- text = readFileSync(argv.file, 'utf8');
249
- defaultId = basename(argv.file);
250
- defaultTitle = basename(argv.file);
251
- } else {
252
- text = argv.text;
253
- defaultId = `inline-${Date.now()}`;
254
- defaultTitle = defaultId;
255
- }
256
-
257
- const contentId = argv['content-id'] || defaultId;
258
- const title = argv.title || defaultTitle;
259
-
260
- const result = await post('/AzureIndexes/IngestContent', {
261
- contentId,
262
- title,
263
- text,
264
- sourceUrl: argv['source-url'] || undefined,
265
- });
266
-
267
- if (result.unchanged) {
268
- console.log(`Unchanged: "${title}" — content hash matches, no re-embedding needed.`);
269
- } else {
270
- console.log(`Indexed: "${title}" [${contentId}]`);
271
- console.log(` ${result.chunks} chunk(s) embedded and searchable immediately.`);
272
- }
273
- }),
274
- })
275
- .command({
276
- command: 'list',
277
- describe: 'List all custom content documents in the KB index',
278
- handler: runCommand(async () => {
279
- await requireFeature('enableKnowledgeBase', 'Knowledge Base');
280
- const items = await get('/AzureIndexes/IngestContent');
281
- if (!items?.length) { console.log('No custom content found.'); return; }
282
-
283
- console.log(`\nFound ${items.length} document(s):\n`);
284
- for (const item of items) {
285
- const indexed = item.lastIndexedAt ? new Date(item.lastIndexedAt).toLocaleDateString() : 'never';
286
- const status = item.lastError ? ` error: ${item.lastError}` : '';
287
- console.log(` [${item.contentId}] chunks=${item.chunkCount ?? '?'} indexed=${indexed}${status}`);
288
- if (item.title && item.title !== item.contentId) console.log(` "${item.title}"`);
289
- }
290
- }),
291
- })
292
- .command({
293
- command: 'remove <content-id>',
294
- describe: 'Remove a custom content document from the KB index',
295
- builder: (y) => y.positional('content-id', { type: 'string', describe: 'Content ID to remove' }),
296
- handler: runCommand(async (argv) => {
297
- await requireFeature('enableKnowledgeBase', 'Knowledge Base');
298
- await del(`/AzureIndexes/IngestContent/${encodeURIComponent(argv['content-id'])}`);
299
- console.log(`Removed "${argv['content-id']}" from the index.`);
300
- }),
301
- })
302
- .demandCommand(1, 'Specify a subcommand: ingest, list, remove'),
303
- handler: () => {},
304
- };
305
-
306
- function truncate(str, max) {
307
- if (!str) return '—';
308
- return str.length <= max ? str : str.slice(0, max - 1) + '…';
309
- }
1
+ import { readFileSync } from 'fs';
2
+ import { basename } from 'path';
3
+ import { get, post, del, messagingGet, messagingPost, messagingDel } from '../api.js';
4
+ import { requireFeature } from '../subscription.js';
5
+ import { runCommand } from '../run.js';
6
+ import { articleCommand } from './article.js';
7
+
8
+ export const kbCommand = {
9
+ command: 'kb <subcommand>',
10
+ describe: 'Manage knowledge base — articles, Q&A pairs, bot overrides, and custom content',
11
+ builder: (yargs) =>
12
+ yargs
13
+ .command(articleCommand)
14
+ .command(qnaCommand)
15
+ .command(overrideCommand)
16
+ .command(contentCommand)
17
+ .command(reindexCommand)
18
+ .command(settingsCommand)
19
+ .command(statusCommand)
20
+ .command(swapConnectionCommand)
21
+ .command(tenantCommand)
22
+ .demandCommand(1, 'Specify a subcommand: article, qna, override, content, reindex, settings, status, swap-connection, tenant'),
23
+ handler: () => {},
24
+ };
25
+
26
+ // ────────────────────────────────────────────────────────────────────────────
27
+ // Tenant — the KB tenant linked to the current site (one per site, created once).
28
+ // Built 2026-08-25 after a KB tenant-creation 500 on staging had to be diagnosed
29
+ // entirely via ad-hoc curl + browser — there was no CLI way to even check whether
30
+ // a site already has a linked tenant before calling `kb tenant create`.
31
+ // ────────────────────────────────────────────────────────────────────────────
32
+
33
+ const tenantCommand = {
34
+ command: 'tenant <subcommand>',
35
+ describe: 'Manage the KB tenant linked to the current site (one per site)',
36
+ builder: (yargs) =>
37
+ yargs
38
+ .command({
39
+ command: 'current',
40
+ describe: 'Show the KB tenant linked to the current site, if any',
41
+ handler: runCommand(async () => {
42
+ const t = await get('/api/kb/tenants/current');
43
+ console.log(JSON.stringify(t, null, 2));
44
+ }),
45
+ })
46
+ .command({
47
+ command: 'create',
48
+ describe: 'Create a new KB tenant for the current site (fails with a clear error if one already exists)',
49
+ builder: (y) =>
50
+ y.option('company-name', { type: 'string', demandOption: true, describe: 'Company name shown as the KB title' }),
51
+ handler: runCommand(async (argv) => {
52
+ const result = await post('/api/kb/tenants', { companyName: argv['company-name'] });
53
+ console.log(JSON.stringify(result, null, 2));
54
+ }),
55
+ })
56
+ .command({
57
+ command: 'unlink',
58
+ describe: 'Unlink the current site from its KB tenant WITHOUT deleting any data (company name, subdomain, articles, topics all preserved) — use before `kb tenant create` when the linked tenant is stale/missing from the connected KB database. Reversible via a Velaro-admin relink.',
59
+ handler: runCommand(async () => {
60
+ const result = await del('/api/kb/tenants/unlink');
61
+ console.log(JSON.stringify(result, null, 2));
62
+ }),
63
+ })
64
+ .demandCommand(1, 'Specify: current, create, or unlink'),
65
+ handler: () => {},
66
+ };
67
+
68
+ // ────────────────────────────────────────────────────────────────────────────
69
+ // Reindex re-embed all KB articles with the current embedding model
70
+ // ────────────────────────────────────────────────────────────────────────────
71
+
72
+ const reindexCommand = {
73
+ command: 'reindex',
74
+ describe: 'Re-embed KB articles or scraper/ingestion content (use after embedding model upgrade)',
75
+ builder: (y) =>
76
+ y
77
+ .option('all', {
78
+ type: 'boolean',
79
+ describe: 'Reindex ALL sites (Velaro staff only)',
80
+ default: false,
81
+ })
82
+ .option('site', {
83
+ type: 'number',
84
+ describe: 'Reindex a specific site by ID (Velaro staff only)',
85
+ })
86
+ .option('ingestion', {
87
+ type: 'boolean',
88
+ describe: 'Reindex scraper/ingestion Azure Search indexers instead of KB articles',
89
+ default: false,
90
+ }),
91
+ handler: runCommand(async (argv) => {
92
+ if (argv.ingestion) {
93
+ if (argv.all) {
94
+ console.log('Starting bulk ingestion reindex for all sites — runs in background.');
95
+ console.log('Watch server logs ([ReindexAllIngestion]) for progress.\n');
96
+ const result = await post('/DatabaseTool/ReindexAllIngestionIndexes', {});
97
+ console.log(result.message ?? 'Queued.');
98
+ } else if (argv.site) {
99
+ console.log(`Reindexing scraper/ingestion index for site ${argv.site}…`);
100
+ const result = await post(`/DatabaseTool/ReindexIngestionIndex/${argv.site}`, {});
101
+ if (result.success) {
102
+ console.log(`✅ Triggered ${result.indexersRun} indexer(s) re-processing in background.`);
103
+ } else {
104
+ console.error(`❌ Failed: ${result.message}`);
105
+ }
106
+ } else {
107
+ console.error('--ingestion requires --site <id> or --all (Velaro staff only)');
108
+ process.exit(1);
109
+ }
110
+ } else if (argv.all) {
111
+ console.log('Starting bulk KB reindex for all sites — this runs in the background.');
112
+ console.log('Watch server logs ([ReindexAllKb]) for progress.\n');
113
+ const result = await post('/DatabaseTool/ReindexAllKb', {});
114
+ console.log(result.message ?? 'Queued.');
115
+ } else if (argv.site) {
116
+ console.log(`Reindexing KB articles for site ${argv.site}…`);
117
+ const result = await messagingPost(`/Scraper/superadmin/kb/reindex/${argv.site}`, {});
118
+ if (result?.queued) {
119
+ console.log(`✅ Queued — full re-ingest running in background for site ${argv.site}.`);
120
+ } else {
121
+ console.error(`❌ Failed: ${result?.error ?? 'unknown error'}`);
122
+ }
123
+ } else {
124
+ console.log("Reindexing your site's KB articles\u2026");
125
+ const result = await post('/KBSearchIndex/reindex', {});
126
+ if (result.success === false) {
127
+ console.error(`❌ Failed: ${result.message}`);
128
+ } else {
129
+ console.log(`✅ Done — ${result.articles ?? result.count ?? 'all'} articles reindexed.`);
130
+ }
131
+ }
132
+ }),
133
+ };
134
+
135
+ // ────────────────────────────────────────────────────────────────────────────
136
+ // Status which physical DB this environment's KB is actually connected to
137
+ // ────────────────────────────────────────────────────────────────────────────
138
+
139
+ const statusCommand = {
140
+ command: 'status',
141
+ describe: 'Show KB connection diagnostics: which server/database this environment is actually using (KBConnectionHolder can point at either the primary or alternate DB — this shows which one is live right now)',
142
+ handler: runCommand(async () => {
143
+ const s = await get('/api/kb/status');
144
+ console.log(JSON.stringify(s, null, 2));
145
+ }),
146
+ };
147
+
148
+ const swapConnectionCommand = {
149
+ command: 'swap-connection',
150
+ describe: 'Toggle which physical database this environment\'s KB is connected to (primary vs. alternate, per KBConnectionHolder). Built 2026-08-18 after staging was found silently connected to PRODUCTION\'s KB database (KnowledgeBaseConnectionString, not _Staging) with no CLI/API way to check or fix it except a raw REST call.',
151
+ builder: (y) =>
152
+ y
153
+ .option('use-alternate', { type: 'boolean', demandOption: true, describe: 'true = use the alternate (KnowledgeBaseConnectionString_Staging) DB; false = use primary' })
154
+ .option('yes', { type: 'boolean', default: false, describe: 'Skip the confirmation prompt (for scripting)' }),
155
+ handler: runCommand(async (argv) => {
156
+ const before = await get('/api/kb/status');
157
+ console.log(`Currently connected to: ${before.server} / ${before.database} (alternate: ${before.isAlternate})`);
158
+
159
+ if (!argv.yes) {
160
+ console.log(`\nAbout to switch to ${argv['use-alternate'] ? 'ALTERNATE' : 'PRIMARY'}. Re-run with --yes to confirm.`);
161
+ return;
162
+ }
163
+
164
+ const result = await post('/DatabaseTool/KBSwapConnection', { useAlternate: argv['use-alternate'] });
165
+ console.log(`✅ Switched. Now connected to: ${result.current.server} / ${result.current.database} (alternate: ${result.current.isAlternate})`);
166
+ }),
167
+ };
168
+
169
+ // ────────────────────────────────────────────────────────────────────────────
170
+ // Settings — branding, domain, layout, SEO/analytics
171
+ // ────────────────────────────────────────────────────────────────────────────
172
+
173
+ const settingsCommand = {
174
+ command: 'settings <subcommand>',
175
+ describe: 'View or update Knowledge Base settings (branding, domain, layout, SEO/analytics)',
176
+ builder: (yargs) =>
177
+ yargs
178
+ .command({
179
+ command: 'get',
180
+ describe: 'Show current KB settings',
181
+ handler: runCommand(async () => {
182
+ const s = await get('/api/kb/settings');
183
+ console.log(JSON.stringify(s, null, 2));
184
+ }),
185
+ })
186
+ .command({
187
+ command: 'set',
188
+ describe: 'Update KB settings. Fetches current settings first and merges — unspecified flags keep their current value.',
189
+ builder: (y) =>
190
+ y
191
+ .option('theme-color', { type: 'string', describe: 'Hex color, e.g. "#4c75ab"' })
192
+ .option('theme-preset', { type: 'string', describe: 'Curated preset id (informational only — theme-color is what renders)' })
193
+ .option('header-text', { type: 'string' })
194
+ .option('domain', { type: 'string', describe: 'Custom domain, e.g. help.acme.com. Pass "" to clear.' })
195
+ .option('topic-layout', { type: 'string', choices: ['grid', 'list'] })
196
+ .option('article-layout', { type: 'string', choices: ['table', 'cards'] })
197
+ .option('meta-description', { type: 'string', describe: 'Default SEO meta description (max 300 chars)' })
198
+ .option('ga4-id', { type: 'string', describe: 'Google Analytics 4 Measurement ID, format G-XXXXXXXXXX' }),
199
+ handler: runCommand(async (argv) => {
200
+ const current = await get('/api/kb/settings');
201
+ const merged = {
202
+ companyName: current.companyName ?? '',
203
+ themeColor: argv['theme-color'] ?? current.themeColor ?? '',
204
+ themePreset: argv['theme-preset'] ?? current.themePreset ?? null,
205
+ headerText: argv['header-text'] ?? current.headerText ?? '',
206
+ returnLinkText: current.returnLinkText ?? '',
207
+ returnLinkUrl: current.returnLinkUrl ?? '',
208
+ logoUrl: current.logoUrl ?? '',
209
+ domain: argv.domain ?? current.domain ?? '',
210
+ topicLayout: argv['topic-layout'] ?? current.topicLayout ?? 'grid',
211
+ articleLayout: argv['article-layout'] ?? current.articleLayout ?? 'table',
212
+ showLastUpdated: current.showLastUpdated ?? true,
213
+ enableLiveChat: current.enableLiveChat ?? false,
214
+ liveChatGroupId: current.liveChatGroupId ?? 0,
215
+ enableKbBot: current.enableKbBot ?? false,
216
+ kbChatWindowId: current.kbChatWindowId ?? 0,
217
+ seoMetaDescription: argv['meta-description'] ?? current.seoMetaDescription ?? '',
218
+ ga4MeasurementId: argv['ga4-id'] ?? current.ga4MeasurementId ?? '',
219
+ };
220
+ await post('/api/kb/settings', merged);
221
+ console.log('✅ KB settings updated.');
222
+ }),
223
+ })
224
+ .demandCommand(1, 'Specify: get or set'),
225
+ handler: () => {},
226
+ };
227
+
228
+ // ────────────────────────────────────────────────────────────────────────────
229
+ // Q&A
230
+ // ────────────────────────────────────────────────────────────────────────────
231
+
232
+ const qnaCommand = {
233
+ command: 'qna <subcommand>',
234
+ describe: 'Manage Q&A knowledge pairs',
235
+ builder: (yargs) =>
236
+ yargs
237
+ .command({
238
+ command: 'list',
239
+ describe: 'List Q&A pairs (site-wide -- BotQnA has no per-bot scoping)',
240
+ handler: runCommand(async () => {
241
+ await requireFeature('enableKnowledgeBase', 'Knowledge Base');
242
+ const items = await messagingGet('/BotQnA');
243
+ if (!items?.length) { console.log('No Q&A pairs found.'); return; }
244
+
245
+ console.log(`\nFound ${items.length} Q&A pair(s):\n`);
246
+ for (const item of items) {
247
+ console.log(` [${item.id}] Q: ${truncate(item.question, 70)}`);
248
+ console.log(` A: ${truncate(item.answer, 70)}\n`);
249
+ }
250
+ }),
251
+ })
252
+ .command({
253
+ command: 'add',
254
+ describe: 'Add a Q&A pair',
255
+ builder: (y) =>
256
+ y
257
+ .option('question', { type: 'string', demandOption: true, describe: 'Question text' })
258
+ .option('answer', { type: 'string', demandOption: true, describe: 'Answer text' }),
259
+ handler: runCommand(async (argv) => {
260
+ await requireFeature('enableKnowledgeBase', 'Knowledge Base');
261
+ // BotQnAItem has no per-bot column -- pairs are site-wide, not scoped to one bot.
262
+ const result = await messagingPost('/BotQnA', {
263
+ question: argv.question,
264
+ answer: argv.answer,
265
+ });
266
+ console.log(`Q&A pair added: [${result.id}]`);
267
+ }),
268
+ })
269
+ .command({
270
+ command: 'delete <id>',
271
+ describe: 'Delete a Q&A pair',
272
+ builder: (y) => y.positional('id', { type: 'number', describe: 'Q&A pair ID' }),
273
+ handler: runCommand(async (argv) => {
274
+ await requireFeature('enableKnowledgeBase', 'Knowledge Base');
275
+ await messagingDel(`/BotQnA/${argv.id}`);
276
+ console.log(`Q&A pair ${argv.id} deleted.`);
277
+ }),
278
+ })
279
+ .demandCommand(1, 'Specify a subcommand: list, add, delete'),
280
+ handler: () => {},
281
+ };
282
+
283
+ // ────────────────────────────────────────────────────────────────────────────
284
+ // Overrides authoritative facts that take priority over KB search
285
+ // ────────────────────────────────────────────────────────────────────────────
286
+
287
+ const overrideCommand = {
288
+ command: 'override <subcommand>',
289
+ describe: 'Manage bot knowledge overrides (authoritative facts)',
290
+ builder: (yargs) =>
291
+ yargs
292
+ .command({
293
+ command: 'list',
294
+ describe: 'List knowledge overrides',
295
+ handler: runCommand(async () => {
296
+ await requireFeature('enableKnowledgeBase', 'Knowledge Base');
297
+ const items = await get('/Ticket/knowledge-overrides');
298
+ if (!items?.length) { console.log('No overrides found.'); return; }
299
+
300
+ console.log(`\nFound ${items.length} override(s):\n`);
301
+ for (const item of items) {
302
+ const status = item.isActive ? 'active ' : 'inactive';
303
+ const expiry = item.expiresAt ? ` expires ${new Date(item.expiresAt).toLocaleDateString()}` : '';
304
+ console.log(` [${item.id}] ${status} ${truncate(item.title ?? item.content, 60)}${expiry}`);
305
+ }
306
+ }),
307
+ })
308
+ .command({
309
+ command: 'add',
310
+ describe: 'Add a knowledge override',
311
+ builder: (y) =>
312
+ y
313
+ .option('title', { type: 'string', demandOption: true, describe: 'Override title' })
314
+ .option('content', { type: 'string', demandOption: true, describe: 'Authoritative content' })
315
+ .option('expires', { type: 'string', describe: 'Expiry date (ISO 8601, e.g. 2026-12-31)' }),
316
+ handler: runCommand(async (argv) => {
317
+ await requireFeature('enableKnowledgeBase', 'Knowledge Base');
318
+ const payload = { title: argv.title, content: argv.content, isActive: true };
319
+ if (argv.expires) payload.expiresAt = new Date(argv.expires).toISOString();
320
+ const result = await post('/Ticket/knowledge-overrides', payload);
321
+ console.log(`Override added: [${result.id}] "${argv.title}"`);
322
+ }),
323
+ })
324
+ .command({
325
+ command: 'delete <id>',
326
+ describe: 'Delete a knowledge override',
327
+ builder: (y) => y.positional('id', { type: 'number', describe: 'Override ID' }),
328
+ handler: runCommand(async (argv) => {
329
+ await requireFeature('enableKnowledgeBase', 'Knowledge Base');
330
+ await del(`/Ticket/knowledge-overrides/${argv.id}`);
331
+ console.log(`Override ${argv.id} deleted.`);
332
+ }),
333
+ })
334
+ .demandCommand(1, 'Specify a subcommand: list, add, delete'),
335
+ handler: () => {},
336
+ };
337
+
338
+ // ────────────────────────────────────────────────────────────────────────────
339
+ // Custom content — push arbitrary text/files into the site's KB index
340
+ // ────────────────────────────────────────────────────────────────────────────
341
+ // Use --index to target a specific logical index (e.g. "tri-tool").
342
+ // Omit to push to the site's default index.
343
+
344
+ const contentCommand = {
345
+ command: 'content <subcommand>',
346
+ describe: 'Push custom text or files into the knowledge base index',
347
+ builder: (yargs) =>
348
+ yargs
349
+ .command({
350
+ command: 'ingest',
351
+ describe: 'Push a file or text snippet into the KB index',
352
+ builder: (y) =>
353
+ y
354
+ .option('file', {
355
+ describe: 'Path to a .txt or .md file to ingest',
356
+ type: 'string',
357
+ })
358
+ .option('text', {
359
+ describe: 'Inline text content to ingest (alternative to --file)',
360
+ type: 'string',
361
+ })
362
+ .option('content-id', {
363
+ describe: 'Stable ID for this document (used for dedup/updates). Defaults to filename.',
364
+ type: 'string',
365
+ })
366
+ .option('title', {
367
+ describe: 'Document title shown when bot attributes this content. Defaults to filename.',
368
+ type: 'string',
369
+ })
370
+ .option('source-url', {
371
+ describe: 'Canonical URL to show when bot cites this content',
372
+ type: 'string',
373
+ })
374
+ .option('index', {
375
+ describe: 'Logical index name to push content into (e.g. "tri-tool"). Defaults to site primary index.',
376
+ type: 'string',
377
+ })
378
+ .check((argv) => {
379
+ if (!argv.file && !argv.text) throw new Error('Provide either --file or --text');
380
+ if (argv.file && argv.text) throw new Error('Use --file or --text, not both');
381
+ return true;
382
+ }),
383
+ handler: runCommand(async (argv) => {
384
+ await requireFeature('enableKnowledgeBase', 'Knowledge Base');
385
+
386
+ let text, defaultId, defaultTitle;
387
+ if (argv.file) {
388
+ text = readFileSync(argv.file, 'utf8');
389
+ defaultId = basename(argv.file);
390
+ defaultTitle = basename(argv.file);
391
+ } else {
392
+ text = argv.text;
393
+ defaultId = `inline-${Date.now()}`;
394
+ defaultTitle = defaultId;
395
+ }
396
+
397
+ const contentId = argv['content-id'] || defaultId;
398
+ const title = argv.title || defaultTitle;
399
+
400
+ const result = await messagingPost('/AzureIndexes/IngestContent', {
401
+ contentId,
402
+ title,
403
+ text,
404
+ sourceUrl: argv['source-url'] || undefined,
405
+ indexName: argv.index || undefined,
406
+ });
407
+
408
+ if (result.unchanged) {
409
+ console.log(`Unchanged: "${title}" — content hash matches, no re-embedding needed.`);
410
+ } else {
411
+ console.log(`Indexed: "${title}" [${contentId}]`);
412
+ console.log(` ${result.chunks} chunk(s) embedded and searchable immediately.`);
413
+ }
414
+ }),
415
+ })
416
+ .command({
417
+ command: 'list',
418
+ describe: 'List all custom content documents in the KB index',
419
+ handler: runCommand(async () => {
420
+ await requireFeature('enableKnowledgeBase', 'Knowledge Base');
421
+ const items = await messagingGet('/AzureIndexes/IngestContent');
422
+ if (!items?.length) { console.log('No custom content found.'); return; }
423
+
424
+ console.log(`\nFound ${items.length} document(s):\n`);
425
+ for (const item of items) {
426
+ const indexed = item.lastIndexedAt ? new Date(item.lastIndexedAt).toLocaleDateString() : 'never';
427
+ const status = item.lastError ? ` error: ${item.lastError}` : '';
428
+ console.log(` [${item.contentId}] chunks=${item.chunkCount ?? '?'} indexed=${indexed}${status}`);
429
+ if (item.title && item.title !== item.contentId) console.log(` "${item.title}"`);
430
+ }
431
+ }),
432
+ })
433
+ .command({
434
+ command: 'remove <content-id>',
435
+ describe: 'Remove a custom content document from the KB index',
436
+ builder: (y) => y.positional('content-id', { type: 'string', describe: 'Content ID to remove' }),
437
+ handler: runCommand(async (argv) => {
438
+ await requireFeature('enableKnowledgeBase', 'Knowledge Base');
439
+ await messagingDel(`/AzureIndexes/IngestContent/${encodeURIComponent(argv['content-id'])}`);
440
+ console.log(`Removed "${argv['content-id']}" from the index.`);
441
+ }),
442
+ })
443
+ .demandCommand(1, 'Specify a subcommand: ingest, list, remove'),
444
+ handler: () => {},
445
+ };
446
+
447
+ function truncate(str, max) {
448
+ if (!str) return '—';
449
+ return str.length <= max ? str : str.slice(0, max - 1) + '…';
450
+ }