@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,388 +1,668 @@
1
- /**
2
- * velaro kb article — CRUD for help-center KB articles.
3
- *
4
- * Auth: uses the stored OAuth JWT (same as all other commands).
5
- * API: velaro-admin (help.velaro.com), not the messaging API.
6
- * Base URL read from VELARO_ADMIN_API_BASE env var or creds.adminApiBase,
7
- * falling back to https://help.velaro.com.
8
- */
9
-
10
- import { readFileSync, existsSync } from 'fs';
11
- import { extname } from 'path';
12
- import { getCredentials } from '../api.js';
13
- import { runCommand } from '../run.js';
14
-
15
- const DEFAULT_ADMIN_API = process.env.VELARO_ADMIN_API_BASE || 'https://help.velaro.com';
16
-
17
- async function adminRequest(method, path, body) {
18
- const creds = await getCredentials();
19
- const apiBase = creds.adminApiBase || DEFAULT_ADMIN_API;
20
-
21
- const res = await fetch(`${apiBase}${path}`, {
22
- method,
23
- headers: {
24
- Authorization: `Bearer ${creds.velaroToken}`,
25
- 'Content-Type': 'application/json',
26
- },
27
- body: body !== undefined ? JSON.stringify(body) : undefined,
28
- });
29
-
30
- if (!res.ok) {
31
- let msg = `${method} ${path} -> ${res.status}`;
32
- try { const t = await res.text(); if (t) msg += `: ${t.slice(0, 300)}`; } catch { /* ignore */ }
33
- throw new Error(msg);
34
- }
35
-
36
- const text = await res.text();
37
- return text ? JSON.parse(text) : null;
38
- }
39
-
40
- // ── list ──────────────────────────────────────────────────────────────────────
41
-
42
- const listCommand = {
43
- command: 'list',
44
- describe: 'List KB articles',
45
- builder: (y) =>
46
- y
47
- .option('topic-id', { type: 'number', describe: 'Filter by topic ID' })
48
- .option('search', { type: 'string', describe: 'Search term' })
49
- .option('published', { type: 'boolean', describe: 'Show only published articles' }),
50
- handler: runCommand(async (argv) => {
51
- const payload = {};
52
- if (argv['topic-id']) payload.topicId = argv['topic-id'];
53
- if (argv.search) payload.searchTerm = argv.search;
54
- if (argv.published) payload.publishStatus = 'Published';
55
-
56
- const data = await adminRequest('POST', '/api/kb/articles/search', payload);
57
- const articles = data?.articles ?? [];
58
-
59
- if (!articles.length) { console.log('No articles found.'); return; }
60
-
61
- console.log(`\nFound ${articles.length} article(s):\n`);
62
- for (const a of articles) {
63
- const status = a.isPublished ? 'published ' : 'draft ';
64
- const views = `${a.hitCount ?? 0} views`;
65
- console.log(` [${a.id}] ${status} ${truncate(a.title, 55)} (${views})`);
66
- if (a.topicName) console.log(` topic: ${a.topicName}`);
67
- }
68
- }),
69
- };
70
-
71
- // ── get ───────────────────────────────────────────────────────────────────────
72
-
73
- const getCommand = {
74
- command: 'get <id>',
75
- describe: 'Get a KB article by ID',
76
- builder: (y) => y.positional('id', { type: 'number', describe: 'Article ID' }),
77
- handler: runCommand(async (argv) => {
78
- const a = await adminRequest('GET', `/api/kb/articles?id=${argv.id}`);
79
- console.log(`\n[${a.id}] ${a.title}`);
80
- console.log(` slug: ${a.slug}`);
81
- console.log(` topic: ${a.topicId}`);
82
- console.log(` published: ${a.isPublished}`);
83
- console.log(` url: ${a.url ?? '(not public)'}`);
84
- console.log(`\n--- content (HTML) ---\n${a.content ?? ''}\n`);
85
- }),
86
- };
87
-
88
- // ── topics ────────────────────────────────────────────────────────────────────
89
-
90
- const topicsCommand = {
91
- command: 'topics',
92
- describe: 'List available KB topics (you need a topic ID to push an article)',
93
- handler: runCommand(async () => {
94
- const data = await adminRequest('GET', '/api/kb/topics');
95
- const topics = data?.topics ?? [];
96
- if (!topics.length) { console.log('No topics found.'); return; }
97
- console.log(`\nTopics:\n`);
98
- for (const t of topics)
99
- console.log(` [${t.id}] ${t.name} (${t.articleCount} articles)`);
100
- }),
101
- };
102
-
103
- // ── push ──────────────────────────────────────────────────────────────────────
104
-
105
- const pushCommand = {
106
- command: 'push <file>',
107
- describe: 'Create or update a KB article from a Markdown or HTML file',
108
- builder: (y) =>
109
- y
110
- .positional('file', { type: 'string', describe: 'Path to .md or .html file' })
111
- .option('title', { type: 'string', describe: 'Article title (override file frontmatter)' })
112
- .option('slug', { type: 'string', describe: 'URL slug (override file frontmatter)' })
113
- .option('topic-id', { type: 'number', describe: 'Topic ID (required if not in frontmatter)' })
114
- .option('description', { type: 'string', describe: 'Short description' })
115
- .option('publish', { type: 'boolean', default: false, describe: 'Publish immediately' })
116
- .option('update-id', { type: 'number', describe: 'Article ID to update (if omitted, creates new)' }),
117
- handler: runCommand(async (argv) => {
118
- if (!existsSync(argv.file)) throw new Error(`File not found: ${argv.file}`);
119
-
120
- const raw = readFileSync(argv.file, 'utf8');
121
- const ext = extname(argv.file).toLowerCase();
122
- const fm = parseFrontmatter(raw);
123
- const body = fm.body;
124
-
125
- const title = argv.title || fm.title || null;
126
- const slug = argv.slug || fm.slug || null;
127
- const topicId = argv['topic-id'] || fm.topicId || null;
128
- const description = argv.description || fm.description || '';
129
-
130
- if (!title) throw new Error('Title is required. Add "title:" to frontmatter or use --title.');
131
- if (!slug) throw new Error('Slug is required. Add "slug:" to frontmatter or use --slug.');
132
- if (!topicId) throw new Error('Topic ID is required. Run "velaro kb article topics" then use --topic-id.');
133
-
134
- const content = ext === '.md' ? markdownToHtml(body) : body;
135
-
136
- const payload = {
137
- title,
138
- slug,
139
- topicId,
140
- description,
141
- content,
142
- visibility: 'Public',
143
- searchTags: fm.tags || '',
144
- isPublished: argv.publish || fm.published || false,
145
- displayPriority: 0,
146
- enableBotResponse: fm.botResponse !== false,
147
- botSummary: fm.botSummary || '',
148
- isFeatured: false,
149
- };
150
-
151
- if (argv['update-id']) {
152
- await adminRequest('PUT', `/api/kb/articles?id=${argv['update-id']}`, payload);
153
- console.log(`Updated article [${argv['update-id']}]: "${title}"`);
154
- } else {
155
- await adminRequest('POST', '/api/kb/articles', payload);
156
- // Look up the newly created article by slug to get its ID
157
- const result = await adminRequest('POST', '/api/kb/articles/search', { searchTerm: slug, topicId });
158
- const created = result?.articles?.find(a => a.slug === slug);
159
- const id = created?.id ?? '?';
160
- console.log(`Created article [${id}]: "${title}"`);
161
- if (argv.publish)
162
- console.log(` Published at: https://help.velaro.com/kb/article/${id}/${slug}`);
163
- else
164
- console.log(` Draft saved. Use --publish to make it public.`);
165
- }
166
- }),
167
- };
168
-
169
- // ── delete ────────────────────────────────────────────────────────────────────
170
-
171
- const deleteCommand = {
172
- command: 'delete <id>',
173
- describe: 'Delete a KB article',
174
- builder: (y) => y.positional('id', { type: 'number', describe: 'Article ID' }),
175
- handler: runCommand(async (argv) => {
176
- await adminRequest('DELETE', `/api/kb/articles?id=${argv.id}`);
177
- console.log(`Article ${argv.id} deleted.`);
178
- }),
179
- };
180
-
181
- // ── seed-views ────────────────────────────────────────────────────────────────
182
-
183
- const seedViewsCommand = {
184
- command: 'seed-views <id>',
185
- describe: 'Seed an article with random view counts (for new article submissions)',
186
- builder: (y) =>
187
- y
188
- .positional('id', { type: 'number', describe: 'Article ID to seed' })
189
- .option('hits', { type: 'number', default: 0, describe: 'Hit count (0 = random 1000-1999)' }),
190
- handler: runCommand(async (argv) => {
191
- const hitCount = argv.hits === 0 ? (Math.floor(Math.random() * 1000) + 1000) : argv.hits;
192
- const payload = { articleId: argv.id, hitCount };
193
-
194
- try {
195
- // Call API endpoint using stored OAuth token — no credentials in code
196
- await adminRequest('POST', '/api/kb/articles/seed-views', payload);
197
- console.log(`✅ Article [${argv.id}] seeded with ${hitCount} views.`);
198
- } catch (e) {
199
- console.error(`❌ Failed to seed article: ${e.message}`);
200
- throw e;
201
- }
202
- }),
203
- };
204
-
205
- // ── improve ───────────────────────────────────────────────────────────────────
206
-
207
- const improveCommand = {
208
- command: 'improve <id>',
209
- describe: 'AI-improve a KB article using a file or instruction (requires VelaroCopilotApiKey)',
210
- builder: (y) =>
211
- y
212
- .positional('id', { type: 'number', describe: 'Article ID to improve' })
213
- .option('file', { type: 'string', describe: 'Path to a reference file (.md, .txt, .html) to incorporate' })
214
- .option('instruction', { type: 'string', describe: 'Plain-language instruction, e.g. "add a troubleshooting section"' })
215
- .option('publish', { type: 'boolean', default: false, describe: 'Publish after improving' })
216
- .check((argv) => {
217
- if (!argv.file && !argv.instruction) throw new Error('Provide --file and/or --instruction.');
218
- return true;
219
- }),
220
- handler: runCommand(async (argv) => {
221
- // 1. Fetch the existing article
222
- const article = await adminRequest('GET', `/api/kb/articles?id=${argv.id}`);
223
- console.log(`Improving: "${article.title}" [${article.id}]`);
224
-
225
- // 2. Build prompt context
226
- let reference = '';
227
- if (argv.file) {
228
- if (!existsSync(argv.file)) throw new Error(`File not found: ${argv.file}`);
229
- reference = readFileSync(argv.file, 'utf8');
230
- }
231
-
232
- // 3. POST to the server-side AI improve endpoint
233
- const result = await adminRequest('POST', `/api/kb/articles/${argv.id}/ai-improve`, {
234
- instruction: argv.instruction || '',
235
- referenceContent: reference,
236
- publish: argv.publish,
237
- });
238
-
239
- console.log(`\nImprovement applied.`);
240
- if (result?.changesSummary) console.log(`Changes: ${result.changesSummary}`);
241
- if (argv.publish) console.log(`Published: https://help.velaro.com/kb/article/${article.id}/${article.slug}`);
242
- }),
243
- };
244
-
245
- // ── export command ─────────────────────────────────────────────────────────────
246
-
247
- export const articleCommand = {
248
- command: 'article <subcommand>',
249
- describe: 'Manage help-center KB articles',
250
- builder: (yargs) =>
251
- yargs
252
- .command(listCommand)
253
- .command(getCommand)
254
- .command(topicsCommand)
255
- .command(pushCommand)
256
- .command(deleteCommand)
257
- .command(seedViewsCommand)
258
- .command(improveCommand)
259
- .demandCommand(1, 'Specify a subcommand: list, get, topics, push, delete, seed-views, improve'),
260
- handler: () => {},
261
- };
262
-
263
- // ── helpers ───────────────────────────────────────────────────────────────────
264
-
265
- function parseFrontmatter(raw) {
266
- const fm = {};
267
- let body = raw;
268
-
269
- if (raw.startsWith('---')) {
270
- const end = raw.indexOf('\n---', 3);
271
- if (end !== -1) {
272
- const block = raw.slice(3, end).trim();
273
- body = raw.slice(end + 4).trim();
274
- for (const line of block.split('\n')) {
275
- const sep = line.indexOf(':');
276
- if (sep === -1) continue;
277
- const key = line.slice(0, sep).trim();
278
- const val = line.slice(sep + 1).trim().replace(/^["']|["']$/g, '');
279
- if (key === 'topicId' || key === 'topic_id') fm.topicId = parseInt(val, 10);
280
- else fm[key] = val === 'true' ? true : val === 'false' ? false : val;
281
- }
282
- }
283
- }
284
-
285
- return { ...fm, body };
286
- }
287
-
288
- // help.velaro.com kb.css styles .kb-article-body table/th/td natively (header #1e3a5f,
289
- // rounded corners, shadow, horizontal-scroll wrapper). Admin editor preview uses the same class.
290
- // Inline fallback styles cover any external renderer that doesn't ship our CSS.
291
- const TABLE_STYLE = 'border-collapse:collapse;width:100%;margin:16px 0;font-size:14px;';
292
- const TH_STYLE = 'border:1px solid #1e3a5f;padding:11px 16px;background:#1e3a5f;color:#fff;text-align:left;font-weight:600;';
293
- const TD_STYLE = 'border:1px solid #e2e8f0;padding:10px 14px;vertical-align:top;color:#1e293b;';
294
-
295
- function inlineMd(s) {
296
- return s
297
- .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
298
- .replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>')
299
- .replace(/`([^`]+)`/g, '<code>$1</code>')
300
- .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
301
- }
302
-
303
- function markdownToHtml(md) {
304
- const lines = md.replace(/\r\n/g, '\n').split('\n');
305
- const out = [];
306
- let i = 0;
307
- let para = [];
308
- let list = null; // { type: 'ul'|'ol', items: [] }
309
-
310
- const flushPara = () => {
311
- if (para.length) {
312
- out.push(`<p>${inlineMd(para.join(' '))}</p>`);
313
- para = [];
314
- }
315
- };
316
- const flushList = () => {
317
- if (list) {
318
- out.push(`<${list.type}>` + list.items.map(x => `<li>${inlineMd(x)}</li>`).join('') + `</${list.type}>`);
319
- list = null;
320
- }
321
- };
322
- const flushAll = () => { flushPara(); flushList(); };
323
-
324
- const isTableSep = (s) => /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$/.test(s);
325
- const splitRow = (s) => {
326
- let t = s.trim();
327
- if (t.startsWith('|')) t = t.slice(1);
328
- if (t.endsWith('|')) t = t.slice(0, -1);
329
- return t.split('|').map(c => c.trim());
330
- };
331
-
332
- while (i < lines.length) {
333
- const line = lines[i];
334
-
335
- if (/^\s*$/.test(line)) { flushAll(); i++; continue; }
336
-
337
- const h = line.match(/^(#{1,6})\s+(.+)$/);
338
- if (h) { flushAll(); out.push(`<h${h[1].length}>${inlineMd(h[2])}</h${h[1].length}>`); i++; continue; }
339
-
340
- // Pipe table
341
- if (line.includes('|') && i + 1 < lines.length && isTableSep(lines[i + 1])) {
342
- flushAll();
343
- const header = splitRow(line);
344
- i += 2;
345
- const rows = [];
346
- while (i < lines.length && lines[i].includes('|') && lines[i].trim() !== '') {
347
- rows.push(splitRow(lines[i]));
348
- i++;
349
- }
350
- const thead = `<thead><tr>${header.map(c => `<th style="${TH_STYLE}">${inlineMd(c)}</th>`).join('')}</tr></thead>`;
351
- const tbody = `<tbody>${rows.map(r => `<tr>${r.map(c => `<td style="${TD_STYLE}">${inlineMd(c)}</td>`).join('')}</tr>`).join('')}</tbody>`;
352
- out.push(`<table class="velaro-kb-table" style="${TABLE_STYLE}">${thead}${tbody}</table>`);
353
- continue;
354
- }
355
-
356
- const ul = line.match(/^\s*[-*]\s+(.+)$/);
357
- if (ul) {
358
- flushPara();
359
- if (!list || list.type !== 'ul') { flushList(); list = { type: 'ul', items: [] }; }
360
- list.items.push(ul[1]);
361
- i++; continue;
362
- }
363
- const ol = line.match(/^\s*\d+\.\s+(.+)$/);
364
- if (ol) {
365
- flushPara();
366
- if (!list || list.type !== 'ol') { flushList(); list = { type: 'ol', items: [] }; }
367
- list.items.push(ol[1]);
368
- i++; continue;
369
- }
370
-
371
- if (/^<(h[1-6]|table|ul|ol|p|div|pre|blockquote)/i.test(line.trim())) {
372
- flushAll();
373
- out.push(line);
374
- i++; continue;
375
- }
376
-
377
- flushList();
378
- para.push(line.trim());
379
- i++;
380
- }
381
- flushAll();
382
- return out.join('\n');
383
- }
384
-
385
- function truncate(str, max) {
386
- if (!str) return '-';
387
- return str.length <= max ? str : str.slice(0, max - 1) + '...';
388
- }
1
+ /**
2
+ * velaro kb article — CRUD for help-center KB articles.
3
+ *
4
+ * Auth: uses the stored OAuth JWT (same as all other commands).
5
+ * API: velaro-admin (help.velaro.com), not the messaging API.
6
+ * Base URL read from VELARO_ADMIN_API_BASE env var or creds.adminApiBase,
7
+ * falling back to https://help.velaro.com.
8
+ */
9
+
10
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
11
+ import { extname } from 'path';
12
+ import { getCredentials } from '../api.js';
13
+ import { runCommand } from '../run.js';
14
+ import { captureKbScreenshot, uploadImage, polishScreenshot, insertImageAfterHeading, escapeAttr } from '../kb-screenshot.js';
15
+
16
+ const DEFAULT_ADMIN_API = process.env.VELARO_ADMIN_API_BASE || 'https://help.velaro.com';
17
+
18
+ // Exported so cli/lib/kb-screenshot.js can reuse the exact same auth/request
19
+ // pattern instead of duplicating a parallel HTTP client. `credsOverride` lets
20
+ // callers with their own auth source (e.g. mcp/server.js, which authenticates
21
+ // via VELARO_MCP_KEY/VELARO_JWT env vars rather than the CLI's stored login
22
+ // config) supply { velaroToken, adminApiBase } directly.
23
+ export async function adminRequest(method, path, body, credsOverride) {
24
+ const creds = credsOverride || await getCredentials();
25
+ const apiBase = creds.adminApiBase || DEFAULT_ADMIN_API;
26
+
27
+ const res = await fetch(`${apiBase}${path}`, {
28
+ method,
29
+ headers: {
30
+ Authorization: `Bearer ${creds.velaroToken}`,
31
+ 'Content-Type': 'application/json',
32
+ },
33
+ body: body !== undefined ? JSON.stringify(body) : undefined,
34
+ });
35
+
36
+ if (!res.ok) {
37
+ let msg = `${method} ${path} -> ${res.status}`;
38
+ try { const t = await res.text(); if (t) msg += `: ${t.slice(0, 300)}`; } catch { /* ignore */ }
39
+ throw new Error(msg);
40
+ }
41
+
42
+ const text = await res.text();
43
+ return text ? JSON.parse(text) : null;
44
+ }
45
+
46
+ // ── list ──────────────────────────────────────────────────────────────────────
47
+
48
+ const listCommand = {
49
+ command: 'list',
50
+ describe: 'List KB articles',
51
+ builder: (y) =>
52
+ y
53
+ .option('topic-id', { type: 'number', describe: 'Filter by topic ID' })
54
+ .option('search', { type: 'string', describe: 'Search term' })
55
+ .option('published', { type: 'boolean', describe: 'Show only published articles' }),
56
+ handler: runCommand(async (argv) => {
57
+ const payload = {};
58
+ if (argv['topic-id']) payload.topicId = argv['topic-id'];
59
+ if (argv.search) payload.searchTerm = argv.search;
60
+ if (argv.published) payload.publishStatus = 'Published';
61
+
62
+ const data = await adminRequest('POST', '/api/kb/articles/search', payload);
63
+ const articles = data?.articles ?? [];
64
+
65
+ if (!articles.length) { console.log('No articles found.'); return; }
66
+
67
+ console.log(`\nFound ${articles.length} article(s):\n`);
68
+ for (const a of articles) {
69
+ const status = a.isPublished ? 'published ' : 'draft ';
70
+ const views = `${a.hitCount ?? 0} views`;
71
+ console.log(` [${a.id}] ${status} ${truncate(a.title, 55)} (${views})`);
72
+ if (a.topicName) console.log(` topic: ${a.topicName}`);
73
+ }
74
+ }),
75
+ };
76
+
77
+ // ── get ───────────────────────────────────────────────────────────────────────
78
+
79
+ const getCommand = {
80
+ command: 'get <id>',
81
+ describe: 'Get a KB article by ID',
82
+ builder: (y) => y.positional('id', { type: 'number', describe: 'Article ID' }),
83
+ handler: runCommand(async (argv) => {
84
+ const a = await adminRequest('GET', `/api/kb/articles?id=${argv.id}`);
85
+ console.log(`\n[${a.id}] ${a.title}`);
86
+ console.log(` slug: ${a.slug}`);
87
+ console.log(` topic: ${a.topicId}`);
88
+ console.log(` published: ${a.isPublished}`);
89
+ console.log(` url: ${a.url ?? '(not public)'}`);
90
+ console.log(`\n--- content (HTML) ---\n${a.content ?? ''}\n`);
91
+ }),
92
+ };
93
+
94
+ // ── topics ────────────────────────────────────────────────────────────────────
95
+
96
+ const topicsCommand = {
97
+ command: 'topics',
98
+ describe: 'List available KB topics (you need a topic ID to push an article)',
99
+ handler: runCommand(async () => {
100
+ const data = await adminRequest('GET', '/api/kb/topics');
101
+ const topics = data?.topics ?? [];
102
+ if (!topics.length) { console.log('No topics found.'); return; }
103
+ console.log(`\nTopics:\n`);
104
+ for (const t of topics)
105
+ console.log(` [${t.id}] ${t.name} (${t.articleCount} articles)`);
106
+ }),
107
+ };
108
+
109
+ // ── push ──────────────────────────────────────────────────────────────────────
110
+
111
+ const pushCommand = {
112
+ command: 'push <file>',
113
+ describe: 'Create or update a KB article from a Markdown or HTML file',
114
+ builder: (y) =>
115
+ y
116
+ .positional('file', { type: 'string', describe: 'Path to .md or .html file' })
117
+ .option('title', { type: 'string', describe: 'Article title (override file frontmatter)' })
118
+ .option('slug', { type: 'string', describe: 'URL slug (override file frontmatter)' })
119
+ .option('topic-id', { type: 'number', describe: 'Topic ID (required if not in frontmatter)' })
120
+ .option('description', { type: 'string', describe: 'Short description' })
121
+ .option('publish', { type: 'boolean', default: false, describe: 'Publish immediately' })
122
+ .option('update-id', { type: 'number', describe: 'Article ID to update (if omitted, creates new)' })
123
+ .option('no-title-normalize', { type: 'boolean', default: false, describe: 'Skip auto-formatting slug-style titles to Title Case' }),
124
+ handler: runCommand(async (argv) => {
125
+ if (!existsSync(argv.file)) throw new Error(`File not found: ${argv.file}`);
126
+
127
+ const raw = readFileSync(argv.file, 'utf8');
128
+ const ext = extname(argv.file).toLowerCase();
129
+ const fm = parseFrontmatter(raw);
130
+ const body = fm.body;
131
+
132
+ const rawTitle = argv.title || fm.title || null;
133
+ const skipNorm = argv['no-title-normalize'] === true;
134
+ const title = rawTitle && !skipNorm ? normalizeTitle(rawTitle) : rawTitle;
135
+ if (title && title !== rawTitle) console.log(` ℹ Title normalized: "${rawTitle}" → "${title}" (use --no-title-normalize to keep as-is)`);
136
+ const slug = argv.slug || fm.slug || null;
137
+ const topicId = argv['topic-id'] || fm.topicId || null;
138
+ const description = argv.description || fm.description || '';
139
+
140
+ if (!title) throw new Error('Title is required. Add "title:" to frontmatter or use --title.');
141
+ if (!slug) throw new Error('Slug is required. Add "slug:" to frontmatter or use --slug.');
142
+ if (!topicId) throw new Error('Topic ID is required. Run "velaro kb article topics" then use --topic-id.');
143
+
144
+ const content = ext === '.md' ? markdownToHtml(body) : body;
145
+
146
+ if (argv['update-id']) {
147
+ // Merge onto the CURRENT article rather than blindly overwriting every field to a
148
+ // frontmatter-or-default value — a push file with no "tags:"/"published:" line must
149
+ // never silently wipe an existing article's searchTags (this is how the CopilotKb sync
150
+ // job recognizes an already-synced article; losing it causes the sync to re-create a
151
+ // duplicate on its next run) or unpublish a live page. Only fields explicitly provided
152
+ // via CLI flags or frontmatter override what's already there.
153
+ const existing = await adminRequest('GET', `/api/kb/articles?id=${argv['update-id']}`);
154
+ const payload = {
155
+ title,
156
+ slug,
157
+ topicId,
158
+ description: argv.description || fm.description || existing.description || '',
159
+ content,
160
+ visibility: existing.visibility ?? 'Public',
161
+ searchTags: fm.tags || existing.searchTags || '',
162
+ isPublished: argv.publish || fm.published || existing.isPublished || false,
163
+ displayPriority: existing.displayPriority ?? 0,
164
+ enableBotResponse: fm.botResponse !== undefined ? fm.botResponse !== false : existing.enableBotResponse !== false,
165
+ botSummary: fm.botSummary || existing.botSummary || '',
166
+ isFeatured: existing.isFeatured || false,
167
+ };
168
+ await adminRequest('PUT', `/api/kb/articles?id=${argv['update-id']}`, payload);
169
+ console.log(`Updated article [${argv['update-id']}]: "${title}"`);
170
+ } else {
171
+ const payload = {
172
+ title,
173
+ slug,
174
+ topicId,
175
+ description,
176
+ content,
177
+ visibility: 'Public',
178
+ searchTags: fm.tags || '',
179
+ isPublished: argv.publish || fm.published || false,
180
+ displayPriority: 0,
181
+ enableBotResponse: fm.botResponse !== false,
182
+ botSummary: fm.botSummary || '',
183
+ isFeatured: false,
184
+ };
185
+ // Create response returns { id, slug, url } directly no follow-up search needed.
186
+ const created = await adminRequest('POST', '/api/kb/articles', payload);
187
+ const id = created?.id ?? '?';
188
+ console.log(`Created article [${id}]: "${title}"`);
189
+ if (argv.publish)
190
+ console.log(` Published at: ${created?.url || `https://help.velaro.com/kb/article/${id}/${slug}`}`);
191
+ else
192
+ console.log(` Draft saved. Use --publish to make it public.`);
193
+ }
194
+ }),
195
+ };
196
+
197
+ // ── delete ────────────────────────────────────────────────────────────────────
198
+
199
+ const deleteCommand = {
200
+ command: 'delete <id>',
201
+ describe: 'Delete a KB article',
202
+ builder: (y) => y.positional('id', { type: 'number', describe: 'Article ID' }),
203
+ handler: runCommand(async (argv) => {
204
+ await adminRequest('DELETE', `/api/kb/articles?id=${argv.id}`);
205
+ console.log(`Article ${argv.id} deleted.`);
206
+ }),
207
+ };
208
+
209
+ // ── seed-views ────────────────────────────────────────────────────────────────
210
+
211
+ const seedViewsCommand = {
212
+ command: 'seed-views <id>',
213
+ describe: 'Seed an article with random view counts (for new article submissions)',
214
+ builder: (y) =>
215
+ y
216
+ .positional('id', { type: 'number', describe: 'Article ID to seed' })
217
+ .option('hits', { type: 'number', default: 0, describe: 'Hit count (0 = random 1000-1999)' }),
218
+ handler: runCommand(async (argv) => {
219
+ const hitCount = argv.hits === 0 ? (Math.floor(Math.random() * 1000) + 1000) : argv.hits;
220
+ const payload = { articleId: argv.id, hitCount };
221
+
222
+ try {
223
+ // Call API endpoint using stored OAuth token — no credentials in code
224
+ await adminRequest('POST', '/api/kb/articles/seed-views', payload);
225
+ console.log(`✅ Article [${argv.id}] seeded with ${hitCount} views.`);
226
+ } catch (e) {
227
+ console.error(`❌ Failed to seed article: ${e.message}`);
228
+ throw e;
229
+ }
230
+ }),
231
+ };
232
+
233
+ // ── screenshot ────────────────────────────────────────────────────────────────
234
+
235
+ const screenshotCommand = {
236
+ command: 'screenshot <articleId>',
237
+ describe: 'Capture a live staging screenshot and insert it into a KB article',
238
+ builder: (y) =>
239
+ y
240
+ .positional('articleId', { type: 'number', describe: 'Target article ID' })
241
+ .option('app', { type: 'string', choices: ['admin', 'messaging'], demandOption: true, describe: 'Which app to capture from' })
242
+ .option('route', { type: 'string', demandOption: true, describe: 'Route to navigate to, e.g. "/Settings/Routing"' })
243
+ .option('heading', { type: 'string', describe: 'Insert the image immediately after the <h2>/<h3> whose text contains this (case-insensitive). Falls back to appending at the end.' })
244
+ .option('annotate', { type: 'array', default: [], describe: 'x,y,label annotation, repeatable — e.g. --annotate 120,80,1 --annotate 400,200,"Click here"' }),
245
+ handler: runCommand(async (argv) => {
246
+ const annotations = (argv.annotate || []).map((raw) => {
247
+ const [x, y, ...labelParts] = String(raw).split(',');
248
+ return { x: parseInt(x, 10), y: parseInt(y, 10), label: labelParts.join(',').trim() || undefined };
249
+ });
250
+
251
+ console.log(`Capturing ${argv.app} staging screenshot of "${argv.route}" for article [${argv.articleId}]...`);
252
+
253
+ const result = await captureKbScreenshot({
254
+ app: argv.app,
255
+ route: argv.route,
256
+ articleId: argv.articleId,
257
+ heading: argv.heading,
258
+ annotations,
259
+ });
260
+
261
+ console.log(`Uploaded: ${result.imageUrl}`);
262
+ console.log(result.insertedAfterHeading
263
+ ? `Inserted after heading matching "${argv.heading}" in article [${result.articleId}].`
264
+ : `Appended to the end of article [${result.articleId}] (no matching heading found).`);
265
+ }),
266
+ };
267
+
268
+ // ── image ─────────────────────────────────────────────────────────────────────
269
+ //
270
+ // General-purpose image upload+polish primitive — NOT tied to the live-capture
271
+ // flow in `screenshot`. Use for any local image (a manually captured console
272
+ // screenshot, a design mockup, a product graphic) that needs to end up hosted
273
+ // and, optionally, embedded in a KB article. This is the durable, reusable path
274
+ // for building images for any KB/product doc — don't hand-roll a one-off upload.
275
+ //
276
+ // Root cause this exists: markdownToHtml()'s `![alt](url)` handling was added in
277
+ // the same change (2026-07-31) after a KB article shipped with a silently broken
278
+ // image the CLI had no supported way to get a local file hosted at all, so an
279
+ // ad-hoc curl against the wrong endpoint (kb/assets/upload, which 404s/disables
280
+ // against the prod-API-base + staging-token combo every other command uses) was
281
+ // the only option. This command is the supported replacement for that ad-hoc path.
282
+
283
+ const imageCommand = {
284
+ command: 'image <file>',
285
+ describe: 'Upload a local image (optionally polished) to KB asset storage, and optionally insert it into an article',
286
+ builder: (y) =>
287
+ y
288
+ .positional('file', { type: 'string', describe: 'Path to a local image file (.png/.jpg/.gif/.webp)' })
289
+ .option('polish', { type: 'boolean', default: true, describe: 'Run through scripts/polish-screenshot.py with standard KB style-guide defaults before uploading' })
290
+ .option('bg', { type: 'string', describe: 'Polish background hex (default F4F5F7)' })
291
+ .option('insert-into', { type: 'number', describe: 'Article ID to insert the uploaded image into' })
292
+ .option('heading', { type: 'string', describe: 'Insert immediately after the <h2>/<h3> whose text contains this (case-insensitive). Falls back to appending at the end.' })
293
+ .option('alt', { type: 'string', describe: 'Alt text for the inserted <img> tag (defaults to --heading, then the filename)' }),
294
+ handler: runCommand(async (argv) => {
295
+ if (!existsSync(argv.file)) throw new Error(`File not found: ${argv.file}`);
296
+
297
+ const creds = await getCredentials();
298
+
299
+ let imagePath = argv.file;
300
+ if (argv.polish) {
301
+ console.log('Polishing via scripts/polish-screenshot.py...');
302
+ imagePath = await polishScreenshot(imagePath, [], { bg: argv.bg });
303
+ }
304
+
305
+ console.log(`Uploading ${imagePath}...`);
306
+ const imageUrl = await uploadImage(imagePath, creds);
307
+ console.log(`Uploaded: ${imageUrl}`);
308
+
309
+ if (!argv['insert-into']) {
310
+ console.log('No --insert-into given image is hosted but not attached to any article.');
311
+ console.log(`Reference it in markdown as: ![alt text](${imageUrl})`);
312
+ return;
313
+ }
314
+
315
+ const articleId = argv['insert-into'];
316
+ const article = await adminRequest('GET', `/api/kb/articles?id=${articleId}`, undefined, creds);
317
+ if (!article) throw new Error(`Article ${articleId} not found`);
318
+
319
+ const altText = argv.alt || argv.heading || basenameNoExt(argv.file);
320
+ const imgTag = `<img src="${imageUrl}" alt="${escapeAttr(altText)}" style="max-width:100%;border-radius:8px;" />`;
321
+ const { html: newContent, insertedAfterHeading } = insertImageAfterHeading(article.content, argv.heading, imgTag);
322
+
323
+ await adminRequest('PUT', `/api/kb/articles?id=${articleId}`, {
324
+ title: article.title,
325
+ slug: article.slug,
326
+ topicId: article.topicId,
327
+ description: article.description,
328
+ content: newContent,
329
+ visibility: article.visibility,
330
+ searchTags: article.searchTags,
331
+ isPublished: article.isPublished,
332
+ displayPriority: article.displayPriority,
333
+ enableBotResponse: article.enableBotResponse,
334
+ botSummary: article.botSummary,
335
+ isFeatured: article.isFeatured,
336
+ }, creds);
337
+
338
+ console.log(argv.heading
339
+ ? `Inserted ${insertedAfterHeading ? `after heading matching "${argv.heading}"` : '(no matching heading found — appended to end)'} in article [${articleId}].`
340
+ : `Appended to the end of article [${articleId}].`);
341
+ }),
342
+ };
343
+
344
+ function basenameNoExt(p) {
345
+ const base = p.split(/[\\/]/).pop() || p;
346
+ return base.replace(/\.[^.]+$/, '');
347
+ }
348
+
349
+ // ── improve ───────────────────────────────────────────────────────────────────
350
+
351
+ const improveCommand = {
352
+ command: 'improve <id>',
353
+ describe: 'AI-improve a KB article using a file or instruction (requires VelaroCopilotApiKey)',
354
+ builder: (y) =>
355
+ y
356
+ .positional('id', { type: 'number', describe: 'Article ID to improve' })
357
+ .option('file', { type: 'string', describe: 'Path to a reference file (.md, .txt, .html) to incorporate' })
358
+ .option('instruction', { type: 'string', describe: 'Plain-language instruction, e.g. "add a troubleshooting section"' })
359
+ .option('publish', { type: 'boolean', default: false, describe: 'Publish after improving' })
360
+ .check((argv) => {
361
+ if (!argv.file && !argv.instruction) throw new Error('Provide --file and/or --instruction.');
362
+ return true;
363
+ }),
364
+ handler: runCommand(async (argv) => {
365
+ // 1. Fetch the existing article
366
+ const article = await adminRequest('GET', `/api/kb/articles?id=${argv.id}`);
367
+ console.log(`Improving: "${article.title}" [${article.id}]`);
368
+
369
+ // 2. Build prompt context
370
+ let reference = '';
371
+ if (argv.file) {
372
+ if (!existsSync(argv.file)) throw new Error(`File not found: ${argv.file}`);
373
+ reference = readFileSync(argv.file, 'utf8');
374
+ }
375
+
376
+ // 3. POST to the server-side AI improve endpoint
377
+ const result = await adminRequest('POST', `/api/kb/articles/${argv.id}/ai-improve`, {
378
+ instruction: argv.instruction || '',
379
+ referenceContent: reference,
380
+ publish: argv.publish,
381
+ });
382
+
383
+ console.log(`\nImprovement applied.`);
384
+ if (result?.changesSummary) console.log(`Changes: ${result.changesSummary}`);
385
+ if (argv.publish) console.log(`Published: https://help.velaro.com/kb/article/${article.id}/${article.slug}`);
386
+ }),
387
+ };
388
+
389
+ // ── preview ───────────────────────────────────────────────────────────────────
390
+
391
+ const previewCommand = {
392
+ command: 'preview <file>',
393
+ describe: 'Render a Markdown article to HTML and write a preview file you can open in a browser',
394
+ builder: (y) =>
395
+ y
396
+ .positional('file', { type: 'string', describe: 'Path to .md or .html file' })
397
+ .option('output', { type: 'string', describe: 'Write HTML to this file instead of a temp file' }),
398
+ handler: runCommand(async (argv) => {
399
+ if (!existsSync(argv.file)) throw new Error(`File not found: ${argv.file}`);
400
+
401
+ const raw = readFileSync(argv.file, 'utf8');
402
+ const ext = extname(argv.file).toLowerCase();
403
+ const fm = parseFrontmatter(raw);
404
+ const body = fm.body;
405
+ const title = fm.title || argv.file;
406
+ const content = ext === '.md' ? markdownToHtml(body) : body;
407
+
408
+ const html = `<!DOCTYPE html>
409
+ <html lang="en">
410
+ <head>
411
+ <meta charset="utf-8">
412
+ <meta name="viewport" content="width=device-width,initial-scale=1">
413
+ <title>${escHtml(title)}</title>
414
+ <style>
415
+ body { font-family: system-ui, sans-serif; max-width: 860px; margin: 40px auto; padding: 0 24px; color: #1e293b; line-height: 1.6; }
416
+ h1,h2,h3,h4 { color: #1e3a5f; margin-top: 1.8em; }
417
+ h1 { font-size: 1.8em; } h2 { font-size: 1.35em; } h3 { font-size: 1.1em; }
418
+ pre { background: #f1f5f9; border: 1px solid #e2e8f0; border-radius: 6px; padding: 14px 18px; overflow-x: auto; }
419
+ code { font-family: ui-monospace, monospace; font-size: .9em; }
420
+ p code, li code { background: #f1f5f9; padding: 1px 5px; border-radius: 3px; }
421
+ table { border-collapse: collapse; width: 100%; margin: 16px 0; font-size: .9em; }
422
+ th { background: #1e3a5f; color: #fff; padding: 10px 14px; text-align: left; }
423
+ td { border: 1px solid #e2e8f0; padding: 9px 13px; }
424
+ ul, ol { padding-left: 1.5em; }
425
+ a { color: #2563eb; }
426
+ .meta { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; padding: 12px 16px; margin-bottom: 32px; font-size: .85em; color: #64748b; }
427
+ </style>
428
+ </head>
429
+ <body>
430
+ <div class="meta">
431
+ <strong>slug:</strong> ${escHtml(fm.slug || '—')} &nbsp;|&nbsp;
432
+ <strong>topic:</strong> ${fm.topicId ?? '—'} &nbsp;|&nbsp;
433
+ <strong>published:</strong> ${fm.published ?? false}
434
+ </div>
435
+ <h1>${escHtml(title)}</h1>
436
+ ${content}
437
+ </body>
438
+ </html>`;
439
+
440
+ const outPath = argv.output || `${argv.file.replace(/\.[^.]+$/, '')}.preview.html`;
441
+ writeFileSync(outPath, html, 'utf8');
442
+ console.log(`Preview written to: ${outPath}`);
443
+ console.log(`Open it with: start "${outPath}" (Windows) or open "${outPath}" (Mac)`);
444
+ }),
445
+ };
446
+
447
+ // ── export command ─────────────────────────────────────────────────────────────
448
+
449
+ export const articleCommand = {
450
+ command: 'article <subcommand>',
451
+ describe: 'Manage help-center KB articles',
452
+ builder: (yargs) =>
453
+ yargs
454
+ .command(listCommand)
455
+ .command(getCommand)
456
+ .command(topicsCommand)
457
+ .command(pushCommand)
458
+ .command(previewCommand)
459
+ .command(deleteCommand)
460
+ .command(seedViewsCommand)
461
+ .command(improveCommand)
462
+ .command(screenshotCommand)
463
+ .command(imageCommand)
464
+ .demandCommand(1, 'Specify a subcommand: list, get, topics, push, preview, delete, seed-views, improve, screenshot, image'),
465
+ handler: () => {},
466
+ };
467
+
468
+ // ── helpers ───────────────────────────────────────────────────────────────────
469
+
470
+ /**
471
+ * Convert slug-style titles to proper Title Case.
472
+ * Only fires when the title looks like a slug: all-lowercase and contains hyphens but no spaces.
473
+ * Preserves already-formatted titles ("Gmail Inbox Guide" stays unchanged).
474
+ *
475
+ * Known acronyms/brands are uppercased correctly; common short words stay lowercase mid-title.
476
+ */
477
+ function normalizeTitle(title) {
478
+ if (!title) return title;
479
+ // Only normalize if the title looks like a slug (all lowercase, hyphens, no spaces)
480
+ if (!/^[a-z0-9]+(-[a-z0-9]+)+$/.test(title)) return title;
481
+
482
+ const ACRONYMS = new Set(['cli', 'mcp', 'sap', 'ivr', 'crm', 'api', 'csp', 'csat', 'ai', 'sms', 'icp', 'ids', 'url', 'urls', 'sdk', 'ui', 'ux', 'id', 'json', 'xml', 'html', 'css', 'jwt', 'sso', 'saml', 'oauth', 'gdpr', 'pci']);
483
+ const BRANDS = { gmail: 'Gmail', quickbooks: 'QuickBooks', skyswitch: 'SkySwitch', opentable: 'OpenTable', velaro: 'Velaro', livefluence: 'LiveFluence', mindbody: 'Mindbody', cloudbeds: 'Cloudbeds', formstack: 'Formstack', uplisting: 'Uplisting', accela: 'Accela', guesty: 'Guesty', resy: 'Resy', nextiva: 'Nextiva', zapier: 'Zapier', webflow: 'Webflow', zoho: 'Zoho', shopify: 'Shopify', hubspot: 'HubSpot', salesforce: 'Salesforce', netsuite: 'NetSuite', dynamics: 'Dynamics', bigcommerce: 'BigCommerce', woocommerce: 'WooCommerce', magento: 'Magento', square: 'Square', stripe: 'Stripe', twilio: 'Twilio', google: 'Google', outlook: 'Outlook', onedrive: 'OneDrive', sharepoint: 'SharePoint', teams: 'Teams', slack: 'Slack', make: 'Make', pipedrive: 'Pipedrive', monday: 'Monday', servicenow: 'ServiceNow' };
484
+ // Small words that stay lowercase unless they're first
485
+ const SMALL = new Set(['a', 'an', 'and', 'as', 'at', 'but', 'by', 'for', 'in', 'nor', 'of', 'on', 'or', 'so', 'the', 'to', 'up', 'via', 'yet']);
486
+
487
+ const words = title.split('-');
488
+ return words.map((w, i) => {
489
+ const lw = w.toLowerCase();
490
+ if (BRANDS[lw]) return BRANDS[lw];
491
+ if (ACRONYMS.has(lw)) return lw.toUpperCase();
492
+ if (i > 0 && SMALL.has(lw)) return lw;
493
+ return lw.charAt(0).toUpperCase() + lw.slice(1);
494
+ }).join(' ');
495
+ }
496
+
497
+ function parseFrontmatter(raw) {
498
+ const fm = {};
499
+ let body = raw;
500
+
501
+ if (raw.startsWith('---')) {
502
+ const end = raw.indexOf('\n---', 3);
503
+ if (end !== -1) {
504
+ const block = raw.slice(3, end).trim();
505
+ body = raw.slice(end + 4).trim();
506
+ for (const line of block.split('\n')) {
507
+ const sep = line.indexOf(':');
508
+ if (sep === -1) continue;
509
+ const key = line.slice(0, sep).trim();
510
+ const val = line.slice(sep + 1).trim().replace(/^["']|["']$/g, '');
511
+ if (key === 'topicId' || key === 'topic_id') fm.topicId = parseInt(val, 10);
512
+ else fm[key] = val === 'true' ? true : val === 'false' ? false : val;
513
+ }
514
+ }
515
+ }
516
+
517
+ return { ...fm, body };
518
+ }
519
+
520
+ // help.velaro.com kb.css styles .kb-article-body table/th/td natively (header #1e3a5f,
521
+ // rounded corners, shadow, horizontal-scroll wrapper). Admin editor preview uses the same class.
522
+ // Inline fallback styles cover any external renderer that doesn't ship our CSS.
523
+ const TABLE_STYLE = 'border-collapse:collapse;width:100%;margin:16px 0;font-size:14px;';
524
+ const TH_STYLE = 'border:1px solid #1e3a5f;padding:11px 16px;background:#1e3a5f;color:#fff;text-align:left;font-weight:600;';
525
+ const TD_STYLE = 'border:1px solid #e2e8f0;padding:10px 14px;vertical-align:top;color:#1e293b;';
526
+
527
+ // HTML-escape a raw string (used for code span content and plain-text regions).
528
+ function escHtml(s) {
529
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
530
+ }
531
+
532
+ function inlineMd(s) {
533
+ // Strategy: extract code spans first (HTML-escape their content), replace with
534
+ // placeholder tokens that survive the subsequent markdown passes, then restore.
535
+ const saved = [];
536
+ const save = (html) => { saved.push(html); return `\x02${saved.length - 1}\x03`; };
537
+
538
+ // 1. Code spans — escape HTML inside so <script>, <NONCE>, etc. render literally.
539
+ s = s.replace(/`([^`]+)`/g, (_, c) => save(`<code>${escHtml(c)}</code>`));
540
+
541
+ // 2. Escape HTML entities in the remaining plain-text regions.
542
+ s = escHtml(s);
543
+
544
+ // 3. Apply markdown (bold, italic, images, links) — these patterns don't contain < or >.
545
+ s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
546
+ s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>');
547
+ // Images MUST be matched before plain links — otherwise `![alt](url)` falls through
548
+ // to the link regex below, which doesn't consume the leading `!`, silently degrading
549
+ // every image into a broken-looking link (`!<a href="...">alt text</a>`). Confirmed
550
+ // 2026-07-31: this exact bug shipped a NetSuite KB article with an invisible image.
551
+ s = s.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, url) =>
552
+ `<img src="${url.replace(/&/g, '&amp;')}" alt="${alt.replace(/"/g, '&quot;')}" style="max-width:100%;border-radius:8px;" />`);
553
+ s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, text, url) =>
554
+ `<a href="${url.replace(/&/g, '&amp;')}">${text}</a>`);
555
+
556
+ // 4. Restore saved code spans.
557
+ s = s.replace(/\x02(\d+)\x03/g, (_, idx) => saved[parseInt(idx)]);
558
+ return s;
559
+ }
560
+
561
+ function markdownToHtml(md) {
562
+ const lines = md.replace(/\r\n/g, '\n').split('\n');
563
+ const out = [];
564
+ let i = 0;
565
+ let para = [];
566
+ let list = null; // { type: 'ul'|'ol', items: [] }
567
+
568
+ const flushPara = () => {
569
+ if (para.length) {
570
+ out.push(`<p>${inlineMd(para.join(' '))}</p>`);
571
+ para = [];
572
+ }
573
+ };
574
+ const flushList = () => {
575
+ if (list) {
576
+ out.push(`<${list.type}>` + list.items.map(x => `<li>${inlineMd(x)}</li>`).join('') + `</${list.type}>`);
577
+ list = null;
578
+ }
579
+ };
580
+ const flushAll = () => { flushPara(); flushList(); };
581
+
582
+ const isTableSep = (s) => /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$/.test(s);
583
+ const splitRow = (s) => {
584
+ let t = s.trim();
585
+ if (t.startsWith('|')) t = t.slice(1);
586
+ if (t.endsWith('|')) t = t.slice(0, -1);
587
+ return t.split('|').map(c => c.trim());
588
+ };
589
+
590
+ while (i < lines.length) {
591
+ const line = lines[i];
592
+
593
+ if (/^\s*$/.test(line)) { flushAll(); i++; continue; }
594
+
595
+ // Fenced code block (``` or ~~~, with optional language tag)
596
+ if (/^\s*(`{3,}|~{3,})/.test(line)) {
597
+ flushAll();
598
+ i++; // skip opening fence
599
+ const codeLines = [];
600
+ const fence = line.match(/^\s*(`{3,}|~{3,})/)[1];
601
+ while (i < lines.length && !lines[i].trim().startsWith(fence)) {
602
+ codeLines.push(lines[i]);
603
+ i++;
604
+ }
605
+ i++; // skip closing fence
606
+ const escaped = codeLines.join('\n')
607
+ .replace(/&/g, '&amp;')
608
+ .replace(/</g, '&lt;')
609
+ .replace(/>/g, '&gt;');
610
+ // Plain <pre><code> — kb.js wraps every code block in the KB article body with a
611
+ // copy button client-side (regardless of authoring path: this CLI, or the Tiptap
612
+ // editor in Velaro Admin), so no special markup is needed here.
613
+ out.push(`<pre><code>${escaped}</code></pre>`);
614
+ continue;
615
+ }
616
+
617
+ const h = line.match(/^(#{1,6})\s+(.+)$/);
618
+ if (h) { flushAll(); out.push(`<h${h[1].length}>${inlineMd(h[2])}</h${h[1].length}>`); i++; continue; }
619
+
620
+ // Pipe table
621
+ if (line.includes('|') && i + 1 < lines.length && isTableSep(lines[i + 1])) {
622
+ flushAll();
623
+ const header = splitRow(line);
624
+ i += 2;
625
+ const rows = [];
626
+ while (i < lines.length && lines[i].includes('|') && lines[i].trim() !== '') {
627
+ rows.push(splitRow(lines[i]));
628
+ i++;
629
+ }
630
+ const thead = `<thead><tr>${header.map(c => `<th style="${TH_STYLE}">${inlineMd(c)}</th>`).join('')}</tr></thead>`;
631
+ const tbody = `<tbody>${rows.map(r => `<tr>${r.map(c => `<td style="${TD_STYLE}">${inlineMd(c)}</td>`).join('')}</tr>`).join('')}</tbody>`;
632
+ out.push(`<table class="velaro-kb-table" style="${TABLE_STYLE}">${thead}${tbody}</table>`);
633
+ continue;
634
+ }
635
+
636
+ const ul = line.match(/^\s*[-*]\s+(.+)$/);
637
+ if (ul) {
638
+ flushPara();
639
+ if (!list || list.type !== 'ul') { flushList(); list = { type: 'ul', items: [] }; }
640
+ list.items.push(ul[1]);
641
+ i++; continue;
642
+ }
643
+ const ol = line.match(/^\s*\d+\.\s+(.+)$/);
644
+ if (ol) {
645
+ flushPara();
646
+ if (!list || list.type !== 'ol') { flushList(); list = { type: 'ol', items: [] }; }
647
+ list.items.push(ol[1]);
648
+ i++; continue;
649
+ }
650
+
651
+ if (/^<(h[1-6]|table|ul|ol|p|div|pre|blockquote)/i.test(line.trim())) {
652
+ flushAll();
653
+ out.push(line);
654
+ i++; continue;
655
+ }
656
+
657
+ flushList();
658
+ para.push(line.trim());
659
+ i++;
660
+ }
661
+ flushAll();
662
+ return out.join('\n');
663
+ }
664
+
665
+ function truncate(str, max) {
666
+ if (!str) return '-';
667
+ return str.length <= max ? str : str.slice(0, max - 1) + '...';
668
+ }