@velaro/cli 1.4.89 → 1.4.90

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
@@ -56,6 +56,7 @@ import { keywordIntelligenceCommand } from '../lib/commands/keyword-intelligence
56
56
  import { aiFieldExtractionCommand } from '../lib/commands/ai-field-extraction.js';
57
57
  import { sentimentCommand, qualityCommand, chatIntelligenceThemesCommand } from '../lib/commands/quality-reports.js';
58
58
  import { cannedLearningCommand } from '../lib/commands/canned-learning.js';
59
+ import { chatIntelligenceTopicsCommand } from '../lib/commands/chat-intelligence-topics.js';
59
60
  import { focusCommand } from '../lib/commands/focus.js';
60
61
  import { triageCommand } from '../lib/commands/triage.js';
61
62
  import { automationDraftCommand } from '../lib/commands/automation-draft.js';
@@ -187,6 +188,7 @@ await yargs(hideBin(process.argv))
187
188
  .command(qualityCommand)
188
189
  .command(chatIntelligenceThemesCommand)
189
190
  .command(cannedLearningCommand)
191
+ .command(chatIntelligenceTopicsCommand)
190
192
  .command(focusCommand)
191
193
  .command(triageCommand)
192
194
  .command(automationDraftCommand)
@@ -13,7 +13,7 @@ function fmtDate(iso) {
13
13
  // ── plan-status ───────────────────────────────────────────────────────────────
14
14
 
15
15
  async function planStatus() {
16
- const s = await request('GET', '/BillingPayment/plan-status');
16
+ const s = await request('GET', '/api/BillingPayment/plan-status');
17
17
 
18
18
  console.log(`\nPlan Status — Site ${s.siteId}\n${'─'.repeat(50)}`);
19
19
 
@@ -0,0 +1,47 @@
1
+ import { messagingGet, messagingPost, messagingPut } from '../api.js';
2
+ import { runCommand } from '../run.js';
3
+
4
+ const print = (data) => console.log(JSON.stringify(data, null, 2));
5
+ const list = (v) => (v == null ? [] : String(v).split(',').map((s) => s.trim()).filter(Boolean));
6
+
7
+ // velaro chat-topics <show|set|template|merge|rename|retire|group|group-suggestions|suggestions|accept|dismiss|
8
+ // reclassify-estimate|reclassify|analysis-estimate|analysis|proposal>
9
+ // REST: messaging /ChatIntelligenceTopics/* (site comes from the CLI session, never an argument)
10
+ export const chatIntelligenceTopicsCommand = {
11
+ command: 'chat-topics <action>',
12
+ describe: 'Chat Intelligence topics: edit the list, merge/rename/group (no AI cost), suggested topics, optional reclassify and analysis',
13
+ builder: (y) => y
14
+ .positional('action', { choices: ['show', 'set', 'template', 'merge', 'rename', 'retire', 'group', 'group-suggestions', 'suggestions', 'accept', 'dismiss', 'reclassify-estimate', 'reclassify', 'analysis-estimate', 'analysis', 'proposal'] })
15
+ .option('topics', { type: 'string', describe: 'set: comma-separated topic list' })
16
+ .option('industry', { type: 'string', describe: 'template: insurance|healthcare|retail|ecommerce|financial|saas|government|manufacturing' })
17
+ .option('from', { type: 'string', describe: 'merge/rename/retire/group: comma-separated topics' })
18
+ .option('to', { type: 'string', describe: 'merge/rename: target topic' })
19
+ .option('group', { type: 'string', describe: 'group: group name (empty clears)' })
20
+ .option('suggestion', { type: 'string', describe: 'accept/dismiss/merge a suggested topic' })
21
+ .option('scope', { type: 'string', default: 'other', describe: 'reclassify: other (default) or all' })
22
+ .option('days', { type: 'number', default: 90 })
23
+ .option('max', { type: 'number', describe: 'reclassify: cap (server hard cap 500)' })
24
+ .option('confirm', { type: 'boolean', default: false, describe: 'reclassify/analysis: required after reading the estimate' })
25
+ .option('id', { type: 'string', describe: 'proposal: proposal id' })
26
+ .option('accept', { type: 'boolean', default: true, describe: 'proposal: --no-accept rejects' }),
27
+ handler: runCommand(async (a) => {
28
+ const base = '/ChatIntelligenceTopics';
29
+ const ops = (op) => messagingPost(`${base}/mapping`, { ops: [{ op, from: list(a.from), to: a.to ?? null, group: a.group ?? null }] });
30
+ switch (a.action) {
31
+ case 'show': return print(await messagingGet(base));
32
+ case 'set': return print(await messagingPut(base, { topics: list(a.topics) }));
33
+ case 'template': return print(await messagingPost(`${base}/template`, { industry: a.industry }));
34
+ case 'merge': case 'rename': case 'retire': case 'group': return print(await ops(a.action));
35
+ case 'group-suggestions': return print(await messagingGet(`${base}/group-suggestions?days=${a.days}`));
36
+ case 'suggestions': return print(await messagingGet(`${base}/suggestions?days=${a.days}`));
37
+ case 'accept': return print(await messagingPost(`${base}/suggestions/resolve`, { suggestion: a.suggestion, action: a.to ? 'merge' : 'accept', target: a.to ?? null }));
38
+ case 'dismiss': return print(await messagingPost(`${base}/suggestions/resolve`, { suggestion: a.suggestion, action: 'dismiss' }));
39
+ case 'reclassify-estimate': return print(await messagingGet(`${base}/reclassify/estimate?scope=${a.scope}&days=${a.days}${a.max ? `&max=${a.max}` : ''}`));
40
+ case 'reclassify': return print(await messagingPost(`${base}/reclassify`, { scope: a.scope, days: a.days, max: a.max ?? null, confirm: a.confirm }));
41
+ case 'analysis-estimate': return print(await messagingGet(`${base}/analysis/estimate`));
42
+ case 'analysis': return print(a.confirm ? await messagingPost(`${base}/analysis`, { confirm: true }) : await messagingGet(`${base}/analysis`));
43
+ case 'proposal': return print(await messagingPost(`${base}/analysis/proposals`, { id: a.id, accept: a.accept }));
44
+ default: throw new Error(`Unknown action ${a.action}`);
45
+ }
46
+ }),
47
+ };
@@ -1,5 +1,5 @@
1
1
  import { writeFileSync } from 'node:fs';
2
- import { messagingGet, messagingPost } from '../api.js';
2
+ import { messagingGet, messagingPost, messagingRequestText } from '../api.js';
3
3
  import { runCommand } from '../run.js';
4
4
 
5
5
  // -- velaro executive-insights ---------------------------------------------------
@@ -27,7 +27,7 @@ const frictionCommand = {
27
27
  handler: runCommand(async (argv) => {
28
28
  const qs = rangeArgs(argv);
29
29
  qs.set('limit', String(argv.limit ?? 20));
30
- const r = await messagingGet(`/executive-insights/friction-radar?${qs}`);
30
+ const r = await messagingGet(`/api/executive-insights/friction-radar?${qs}`);
31
31
  console.log(`Friction radar: ${r.conversationsScanned} conversations scanned${r.truncated ? ' (truncated)' : ''}`);
32
32
  for (const t of r.topics ?? []) {
33
33
  const growth = t.isNew ? 'new' : t.growthPct == null ? 'n/a' : `${t.growthPct}%`;
@@ -41,7 +41,7 @@ const briefCommand = {
41
41
  describe: 'Executive brief: volume, sentiment, QA, Experience Impact, rising keywords, top friction',
42
42
  builder: rangeOptions,
43
43
  handler: runCommand(async (argv) => {
44
- const r = await messagingGet(`/executive-insights/brief?${rangeArgs(argv)}`);
44
+ const r = await messagingGet(`/api/executive-insights/brief?${rangeArgs(argv)}`);
45
45
  console.log(JSON.stringify(r.brief, null, 2));
46
46
  }),
47
47
  };
@@ -51,12 +51,14 @@ const exportCommand = {
51
51
  describe: 'CSV of conversations with sentiment, QA score, tag, escalations, handle time and keywords',
52
52
  builder: (y) => rangeOptions(y).option('out', { type: 'string', describe: 'Write the CSV to this file (default: stdout)' }),
53
53
  handler: runCommand(async (argv) => {
54
- const r = await messagingGet(`/executive-insights/export?${rangeArgs(argv)}`);
54
+ // The endpoint is GET api/executive-insights/export.csv and answers text/csv, not JSON.
55
+ const csv = await messagingRequestText('GET', `/api/executive-insights/export.csv?${rangeArgs(argv)}`);
55
56
  if (argv.out) {
56
- writeFileSync(argv.out, r.csv, 'utf8');
57
- console.log(`Wrote ${r.rows} rows to ${argv.out}${r.truncated ? ' (truncated at the row cap)' : ''}`);
57
+ writeFileSync(argv.out, csv, 'utf8');
58
+ const rows = Math.max(csv.split('\n').filter((l) => l.trim().length > 0).length - 1, 0);
59
+ console.log(`Wrote ${rows} rows to ${argv.out}`);
58
60
  } else {
59
- process.stdout.write(r.csv);
61
+ process.stdout.write(csv);
60
62
  }
61
63
  }),
62
64
  };
@@ -74,7 +74,12 @@ const listCommand = {
74
74
  ]);
75
75
 
76
76
  // Quota from subscription
77
- const sub = await get('/Subscription/Get').catch(() => null);
77
+ // GET /Subscription lives on the MESSAGING api (the old admin /Subscription/Get never existed, and its
78
+ // .catch(() => null) hid that, so the quota line silently never rendered).
79
+ const sub = await messagingGet('/Subscription').catch((err) => {
80
+ console.error(`\x1b[33m⚠ Could not read subscription quota: ${err.message}\x1b[0m`);
81
+ return null;
82
+ });
78
83
  const chunkCap = sub?.maxIndexedChunkTotal ?? 0;
79
84
  const usedChunks = indexes.reduce((s, i) => s + (i.documentCount ?? 0), 0);
80
85
 
@@ -169,7 +174,10 @@ const reingestCommand = {
169
174
  const creds = await getCredentials();
170
175
 
171
176
  // Check quota before allowing reingest
172
- const sub = await get('/Subscription/Get').catch(() => null);
177
+ const sub = await messagingGet('/Subscription').catch((err) => {
178
+ console.error(`\x1b[33m⚠ Could not read subscription quota (index quota pre-check skipped): ${err.message}\x1b[0m`);
179
+ return null;
180
+ });
173
181
  if (!isVelaroAdmin(creds) && sub?.maxIndexedChunkTotal > 0) {
174
182
  const indexes = await messagingGet('/AzureIndexes/List').catch(() => []);
175
183
  const usedChunks = indexes.reduce((s, i) => s + (i.documentCount ?? 0), 0);
@@ -1,4 +1,4 @@
1
- import { getCredentials, post } from '../api.js';
1
+ import { getCredentials, messagingPost } from '../api.js';
2
2
  import { runCommand } from '../run.js';
3
3
 
4
4
  export const ingestCommand = {
@@ -18,7 +18,8 @@ export const ingestCommand = {
18
18
 
19
19
  console.log(`Triggering ingestion for job ${argv['job-id']} on site ${creds.siteId}...`);
20
20
 
21
- const result = await post('/AzureIndexes/IngestJobDirect', {
21
+ // AzureIndexesController (IngestJobDirect) lives on the MESSAGING api; the admin api has no such route.
22
+ const result = await messagingPost('/AzureIndexes/IngestJobDirect', {
22
23
  siteId: creds.siteId,
23
24
  jobId: argv['job-id'],
24
25
  });
@@ -122,7 +122,8 @@ const reindexCommand = {
122
122
  }
123
123
  } else {
124
124
  console.log("Reindexing your site's KB articles\u2026");
125
- const result = await post('/KBSearchIndex/reindex', {});
125
+ // KBSearchIndexController is routed api/kb/search (the old /KBSearchIndex/reindex 404'd).
126
+ const result = await post('/api/kb/search/reindex', {});
126
127
  if (result.success === false) {
127
128
  console.error(`❌ Failed: ${result.message}`);
128
129
  } else {
@@ -1,6 +1,7 @@
1
1
  import { writeFileSync } from 'fs';
2
2
  import { messagingGet, messagingPost, messagingPut, messagingDel, getCredentials } from '../api.js';
3
3
  import { runCommand } from '../run.js';
4
+ import { applicationIdPath, buildApplySettingsBody, buildBotSettingsBody } from '../recruiting-apply.js';
4
5
 
5
6
  // -- velaro recruiting --------------------------------------------------------
6
7
  // CLI surface for the Recruiting Bot: job-feed sync (JSON/XML/RSS/Indeed/
@@ -16,6 +17,7 @@ import { runCommand } from '../run.js';
16
17
  // GET Recruiting/Jobs?search=&location=&department= -> jobListing[]
17
18
  // DELETE Recruiting/Jobs/{id} -> { success }
18
19
  // GET Recruiting/Applications -> candidateApplication[]
20
+ // GET Recruiting/Applications/AtsFailures; POST Recruiting/Applications/{id}/AtsRetry; PUT Recruiting/ApplySettings { publicApplyEnabled?, autoCreateCaseTypeId? }; GET Recruiting/Applications/{id}/Resume (binary)
19
21
  // GET Recruiting/Applications/ExportEeoc -> text/csv (Professional tier+, EnableRecruitingBotEeoc)
20
22
 
21
23
  const BOLD = '\x1b[1m';
@@ -123,6 +125,56 @@ async function applicationsExportEeocHandler(argv) {
123
125
  console.log(`\n${GRN}OK${RST} EEOC export saved to ${outPath}\n`);
124
126
  }
125
127
 
128
+ async function atsFailuresHandler() {
129
+ const rows = await messagingGet('/Recruiting/Applications/AtsFailures');
130
+ if (!Array.isArray(rows) || !rows.length) {
131
+ console.log('\n No applications with pending or failed ATS write-back.\n');
132
+ return;
133
+ }
134
+ console.log(`\n${BOLD}ATS write-back queue${RST} (${rows.length})\n`);
135
+ for (const r of rows) {
136
+ console.log(` ${DIM}#${r.id}${RST} ${r.candidateName} ${r.atsSyncStatus} ${DIM}attempts=${r.atsSyncAttempts} next=${r.atsSyncNextRetryAt || '-'} ${r.atsSyncLastError || ''}${RST}`);
137
+ }
138
+ console.log('');
139
+ }
140
+
141
+ async function atsRetryHandler(argv) {
142
+ const r = await messagingPost(applicationIdPath(argv.applicationId, 'AtsRetry'), {});
143
+ console.log(`\n${GRN}OK${RST} Application ${r.id} ATS status: ${r.atsSyncStatus} (attempts ${r.atsSyncAttempts})${r.atsSyncLastError ? ` ${RED}${r.atsSyncLastError}${RST}` : ''}\n`);
144
+ }
145
+
146
+ async function botSettingsHandler(argv) {
147
+ const keys = ['pageContext', 'revealPostedDate', 'revealDescription', 'revealApplyLink', 'publicApply', 'applyMode', 'tone', 'maxResults', 'greeting'];
148
+ const given = Object.fromEntries(keys.filter((k) => argv[k] !== undefined).map((k) => [k, argv[k]]));
149
+ // No flags = show current settings.
150
+ const r = Object.keys(given).length ? await messagingPut('/Recruiting/BotSettings', buildBotSettingsBody(given)) : await messagingGet('/Recruiting/BotSettings');
151
+ console.log(`\n${GRN}OK${RST} Recruiting bot settings:\n${JSON.stringify(r, null, 2)}\n`);
152
+ }
153
+
154
+ async function applySettingsHandler(argv) {
155
+ const r = await messagingPut('/Recruiting/ApplySettings', buildApplySettingsBody({ publicApply: argv.publicApply, caseTypeId: argv.caseTypeId }));
156
+ console.log(`\n${GRN}OK${RST} Apply settings: publicApplyEnabled=${r.publicApplyEnabled}, autoCreateCaseTypeId=${r.autoCreateCaseTypeId ?? '(none)'}\n`);
157
+ }
158
+
159
+ // Resume is a binary download; fetch raw (messagingGet JSON-parses) with the same auth/site header.
160
+ async function resumeDownloadHandler(argv) {
161
+ const creds = await getCredentials();
162
+ const res = await fetch(`${creds.messagingApiBase.replace(/\/+$/, '')}${applicationIdPath(argv.applicationId, 'Resume')}`, {
163
+ headers: {
164
+ Authorization: `Bearer ${creds.velaroToken}`,
165
+ ...(creds.siteId ? { 'vmsg-siteId': String(creds.siteId) } : {}),
166
+ },
167
+ });
168
+ if (!res.ok) {
169
+ const text = await res.text().catch(() => '');
170
+ throw new Error(`GET Resume -> ${res.status}: ${text.slice(0, 300)}`);
171
+ }
172
+ const m = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(res.headers.get('content-disposition') || '');
173
+ const outPath = argv.out || (m ? decodeURIComponent(m[1]) : `resume-${argv.applicationId}`);
174
+ writeFileSync(outPath, Buffer.from(await res.arrayBuffer()));
175
+ console.log(`\n${GRN}OK${RST} Resume saved to ${outPath}\n`);
176
+ }
177
+
126
178
  const syncCommand = {
127
179
  command: 'sync',
128
180
  describe: 'Manually trigger a Recruiting Bot job-feed sync for this site',
@@ -192,7 +244,43 @@ const applicationsCommand = {
192
244
  builder: (y2) => y2.option('out', { describe: 'Output CSV file path (default: eeoc-export-<timestamp>.csv)', type: 'string' }),
193
245
  handler: runCommand(applicationsExportEeocHandler),
194
246
  })
195
- .demandCommand(1, 'Specify an applications subcommand: list | export-eeoc'),
247
+ .command({ command: 'ats-failures', describe: 'List applications whose ATS write-back is Pending/Failed/Abandoned', handler: runCommand(atsFailuresHandler) })
248
+ .command({
249
+ command: 'ats-retry <applicationId>',
250
+ describe: 'Retry the ATS write-back for one application now (resets attempts)',
251
+ builder: (y2) => y2.positional('applicationId', { type: 'number' }),
252
+ handler: runCommand(atsRetryHandler),
253
+ })
254
+ .command({
255
+ command: 'resume <applicationId>',
256
+ describe: 'Download an applicant resume',
257
+ builder: (y2) => y2.positional('applicationId', { type: 'number' }).option('out', { describe: 'Output file path (default: original file name)', type: 'string' }),
258
+ handler: runCommand(resumeDownloadHandler),
259
+ })
260
+ .command({
261
+ command: 'apply-settings',
262
+ describe: 'Set public apply form options (apply-if-present)',
263
+ builder: (y2) => y2
264
+ .option('public-apply', { describe: 'Enable/disable the public apply endpoint for this site', type: 'boolean' })
265
+ .option('case-type-id', { describe: 'Case type to auto-create a case per application (0 clears)', type: 'number' }),
266
+ handler: runCommand(applySettingsHandler),
267
+ })
268
+ .command({
269
+ command: 'bot-settings',
270
+ describe: 'Show or set what the recruiting bot reveals and how it behaves (omit flags to show)',
271
+ builder: (y2) => y2
272
+ .option('page-context', { describe: 'Use the job from the host page (Velaro context call)', type: 'boolean' })
273
+ .option('reveal-posted-date', { describe: 'Let the bot share when a job was first listed', type: 'boolean' })
274
+ .option('reveal-description', { describe: 'Let the bot share the full job description', type: 'boolean' })
275
+ .option('reveal-apply-link', { describe: 'Let the bot share the listing link', type: 'boolean' })
276
+ .option('public-apply', { describe: 'Enable the public apply endpoint', type: 'boolean' })
277
+ .option('apply-mode', { describe: 'both | link | inproduct', type: 'string' })
278
+ .option('tone', { describe: 'friendly | professional | concise', type: 'string' })
279
+ .option('max-results', { describe: 'Most jobs per search (1-10)', type: 'number' })
280
+ .option('greeting', { describe: 'Opening greeting (500 chars max)', type: 'string' }),
281
+ handler: runCommand(botSettingsHandler),
282
+ })
283
+ .demandCommand(1, 'Specify an applications subcommand: list | export-eeoc | ats-failures | ats-retry | resume | apply-settings | bot-settings'),
196
284
  handler: () => {},
197
285
  };
198
286
 
@@ -1,4 +1,4 @@
1
- import { messagingGet as get, messagingPost as post, messagingDel as del } from '../api.js';
1
+ import { messagingGet as get, messagingPost as post, messagingRequest } from '../api.js';
2
2
  import { requireFeature } from '../subscription.js';
3
3
  import { runCommand } from '../run.js';
4
4
 
@@ -79,7 +79,9 @@ const ruleDeleteCommand = {
79
79
  builder: (y) => y.positional('id', { type: 'number', describe: 'Rule ID' }),
80
80
  handler: runCommand(async (argv) => {
81
81
  await requireFeature('enableWorkflowRules', 'Routing Rules');
82
- await del(`/Rules/${argv.id}`);
82
+ // RulesController.DeleteAsync is `[HttpDelete]` taking the rule in the BODY (DELETE /Rules {id}); there is no
83
+ // DELETE /Rules/{id} route (it 404'd). Verified live on staging 2026-10-12: DELETE /Rules {"id":N} -> 200.
84
+ await messagingRequest('DELETE', '/Rules', { id: argv.id });
83
85
  console.log(`Rule ${argv.id} deleted.`);
84
86
  }),
85
87
  };
@@ -0,0 +1,53 @@
1
+ // Pure helpers for the Recruiting public-apply / ATS write-back CLI commands.
2
+ // Backend: velaro-messaging RecruitingController (Applications/AtsFailures, Applications/{id}/AtsRetry,
3
+ // ApplySettings, Applications/{id}/Resume).
4
+
5
+ export function applicationIdPath(id, suffix) {
6
+ const n = Number(id);
7
+ if (!Number.isInteger(n) || n <= 0) throw new Error('applicationId must be a positive integer.');
8
+ return `/Recruiting/Applications/${n}/${suffix}`;
9
+ }
10
+
11
+ // Apply-if-present body for PUT /Recruiting/ApplySettings. caseTypeId 0 clears the auto-case link.
12
+ export function buildApplySettingsBody({ publicApply, caseTypeId } = {}) {
13
+ const body = {};
14
+ if (publicApply !== undefined) body.publicApplyEnabled = Boolean(publicApply);
15
+ if (caseTypeId !== undefined) {
16
+ const n = Number(caseTypeId);
17
+ if (!Number.isInteger(n) || n < 0) throw new Error('caseTypeId must be an integer >= 0 (0 clears).');
18
+ body.autoCreateCaseTypeId = n;
19
+ }
20
+ if (!Object.keys(body).length) throw new Error('Specify at least one of publicApply, caseTypeId.');
21
+ return body;
22
+ }
23
+
24
+ const APPLY_MODES = ['both', 'link', 'inproduct'];
25
+ const TONES = ['friendly', 'professional', 'concise'];
26
+
27
+ // Apply-if-present body for PUT /Recruiting/BotSettings (customer controls for the recruiting bot).
28
+ export function buildBotSettingsBody(o = {}) {
29
+ const body = {};
30
+ const bools = { pageContext: 'pageContextEnabled', revealPostedDate: 'revealPostedDate', revealDescription: 'revealDescription', revealApplyLink: 'revealApplyLink', publicApply: 'publicApplyEnabled' };
31
+ for (const [k, field] of Object.entries(bools)) if (o[k] !== undefined) body[field] = Boolean(o[k]);
32
+ if (o.applyMode !== undefined) {
33
+ const m = String(o.applyMode).toLowerCase();
34
+ if (!APPLY_MODES.includes(m)) throw new Error(`applyMode must be one of: ${APPLY_MODES.join(', ')}.`);
35
+ body.applyMode = m;
36
+ }
37
+ if (o.tone !== undefined) {
38
+ const t = String(o.tone).toLowerCase();
39
+ if (!TONES.includes(t)) throw new Error(`tone must be one of: ${TONES.join(', ')}.`);
40
+ body.tone = t;
41
+ }
42
+ if (o.maxResults !== undefined) {
43
+ const n = Number(o.maxResults);
44
+ if (!Number.isInteger(n) || n < 1 || n > 10) throw new Error('maxResults must be an integer from 1 to 10.');
45
+ body.maxResults = n;
46
+ }
47
+ if (o.greeting !== undefined) {
48
+ if (String(o.greeting).length > 500) throw new Error('greeting must be 500 characters or fewer.');
49
+ body.greeting = String(o.greeting);
50
+ }
51
+ if (!Object.keys(body).length) throw new Error('Specify at least one bot setting.');
52
+ return body;
53
+ }
@@ -0,0 +1,28 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { applicationIdPath, buildApplySettingsBody, buildBotSettingsBody } from './recruiting-apply.js';
4
+
5
+ test('application paths validate id', () => {
6
+ assert.equal(applicationIdPath(12, 'AtsRetry'), '/Recruiting/Applications/12/AtsRetry');
7
+ assert.equal(applicationIdPath('5', 'Resume'), '/Recruiting/Applications/5/Resume');
8
+ assert.throws(() => applicationIdPath('x', 'Resume'));
9
+ assert.throws(() => applicationIdPath(0, 'Resume'));
10
+ });
11
+
12
+ test('apply settings body is apply-if-present', () => {
13
+ assert.deepEqual(buildApplySettingsBody({ publicApply: true }), { publicApplyEnabled: true });
14
+ assert.deepEqual(buildApplySettingsBody({ caseTypeId: 0 }), { autoCreateCaseTypeId: 0 });
15
+ assert.deepEqual(buildApplySettingsBody({ publicApply: false, caseTypeId: 3 }), { publicApplyEnabled: false, autoCreateCaseTypeId: 3 });
16
+ assert.throws(() => buildApplySettingsBody({}));
17
+ assert.throws(() => buildApplySettingsBody({ caseTypeId: -1 }));
18
+ });
19
+
20
+ test('bot settings body is apply-if-present and validated', () => {
21
+ assert.deepEqual(buildBotSettingsBody({ revealPostedDate: false }), { revealPostedDate: false });
22
+ assert.deepEqual(buildBotSettingsBody({ applyMode: 'LINK', tone: 'Concise', maxResults: 3, pageContext: true, publicApply: false }), { pageContextEnabled: true, publicApplyEnabled: false, applyMode: 'link', tone: 'concise', maxResults: 3 });
23
+ assert.throws(() => buildBotSettingsBody({}));
24
+ assert.throws(() => buildBotSettingsBody({ applyMode: 'x' }));
25
+ assert.throws(() => buildBotSettingsBody({ tone: 'angry' }));
26
+ assert.throws(() => buildBotSettingsBody({ maxResults: 11 }));
27
+ assert.throws(() => buildBotSettingsBody({ greeting: 'g'.repeat(501) }));
28
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velaro/cli",
3
- "version": "1.4.89",
3
+ "version": "1.4.90",
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": {