@velaro/cli 1.4.89 → 1.4.91

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';
@@ -96,6 +97,7 @@ import { supportCommand } from '../lib/commands/support.js';
96
97
  import { routingCommand } from '../lib/commands/routing.js';
97
98
  import { diagnosticsCommand } from '../lib/commands/diagnostics.js';
98
99
  import { aiSpendCommand } from '../lib/commands/ai-spend.js';
100
+ import { aiFederationCommand } from '../lib/commands/ai-federation.js';
99
101
  import { statusPageCommand } from '../lib/commands/status-page.js';
100
102
  import { statusNotifyCommand } from '../lib/commands/status-notify.js';
101
103
  import { statusSlaCommand } from '../lib/commands/status-sla.js';
@@ -187,6 +189,7 @@ await yargs(hideBin(process.argv))
187
189
  .command(qualityCommand)
188
190
  .command(chatIntelligenceThemesCommand)
189
191
  .command(cannedLearningCommand)
192
+ .command(chatIntelligenceTopicsCommand)
190
193
  .command(focusCommand)
191
194
  .command(triageCommand)
192
195
  .command(automationDraftCommand)
@@ -227,6 +230,7 @@ await yargs(hideBin(process.argv))
227
230
  .command(routingCommand)
228
231
  .command(diagnosticsCommand)
229
232
  .command(aiSpendCommand)
233
+ .command(aiFederationCommand)
230
234
  .command(statusPageCommand)
231
235
  .command(statusNotifyCommand)
232
236
  .command(statusSlaCommand)
@@ -0,0 +1,16 @@
1
+ // AI follow-up suggestions ("AI mode" on a transferToAI node). Pure helpers, no I/O.
2
+ // Resolution on the server: node value ?? account default (SiteSettings) ?? OFF.
3
+
4
+ export function applyFollowUpOptions(node, { mode, count, instruction } = {}) {
5
+ const updated = { ...node };
6
+ if (mode === 'on') updated.suggestFollowUps = true;
7
+ if (mode === 'off') updated.suggestFollowUps = false;
8
+ // "inherit" CLEARS the node value so the account default decides (never persist false for it).
9
+ if (mode === 'inherit') delete updated.suggestFollowUps;
10
+ if (count != null) updated.suggestFollowUpCount = Math.min(4, Math.max(2, Math.round(count)));
11
+ if (instruction != null) {
12
+ if (instruction === '') delete updated.suggestFollowUpInstruction;
13
+ else updated.suggestFollowUpInstruction = String(instruction).slice(0, 300);
14
+ }
15
+ return updated;
16
+ }
@@ -0,0 +1,32 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import { applyFollowUpOptions } from './ai-followups.js';
5
+
6
+ const node = { id: 1, actionType: 'transferToAI', aiConfigId: 5 };
7
+
8
+ test('on/off set the node value; inherit clears it', () => {
9
+ assert.equal(applyFollowUpOptions(node, { mode: 'on' }).suggestFollowUps, true);
10
+ assert.equal(applyFollowUpOptions(node, { mode: 'off' }).suggestFollowUps, false);
11
+ const cleared = applyFollowUpOptions({ ...node, suggestFollowUps: true }, { mode: 'inherit' });
12
+ assert.equal('suggestFollowUps' in cleared, false);
13
+ });
14
+
15
+ test('count clamps to 2-4 and unrelated fields survive', () => {
16
+ assert.equal(applyFollowUpOptions(node, { count: 0 }).suggestFollowUpCount, 2);
17
+ assert.equal(applyFollowUpOptions(node, { count: 9 }).suggestFollowUpCount, 4);
18
+ assert.equal(applyFollowUpOptions(node, { count: 3 }).aiConfigId, 5);
19
+ });
20
+
21
+ test('instruction is capped at 300 chars and empty string clears it', () => {
22
+ assert.equal(applyFollowUpOptions(node, { instruction: 'x'.repeat(500) }).suggestFollowUpInstruction.length, 300);
23
+ const cleared = applyFollowUpOptions({ ...node, suggestFollowUpInstruction: 'a' }, { instruction: '' });
24
+ assert.equal('suggestFollowUpInstruction' in cleared, false);
25
+ });
26
+
27
+ test('workflow command wires the helper and the site-default route', () => {
28
+ const src = fs.readFileSync(new URL('./commands/workflow.js', import.meta.url), 'utf8');
29
+ assert.match(src, /applyFollowUpOptions/);
30
+ assert.match(src, /\/SiteSettings\/AiFollowUps/);
31
+ assert.match(src, /ai-followups <workflowId> <nodeId>/);
32
+ });
@@ -0,0 +1,55 @@
1
+ import { get, post } from '../api.js';
2
+
3
+ function fmtPT(ts) {
4
+ if (!ts) return '-';
5
+ return new Date(ts).toLocaleString('en-US', { timeZone: 'America/Los_Angeles', dateStyle: 'short', timeStyle: 'short' }) + ' PT';
6
+ }
7
+
8
+ function printStatus(s) {
9
+ console.log(`Anthropic federation: ${s.enabled ? 'ENABLED' : 'disabled'}${s.enabled && !s.configured ? ' (NOT fully configured)' : ''}`);
10
+ console.log(` breaker: ${s.breakerState}${s.breakerOpenUntil ? ' until ' + fmtPT(s.breakerOpenUntil) : ''}`);
11
+ console.log(` last success: ${fmtPT(s.lastSuccessUtc)}`);
12
+ console.log(` last failure: ${fmtPT(s.lastFailureUtc)}${s.lastFailureReason ? ' ' + s.lastFailureReason : ''}`);
13
+ console.log(` calls: federated ${s.federatedCalls}, api-key fallback ${s.fallbackCalls}, breaker opens ${s.breakerOpens}`);
14
+ console.log(` token expires: ${fmtPT(s.tokenExpiresUtc)}`);
15
+ }
16
+
17
+ const statusCommand = {
18
+ command: 'status',
19
+ describe: 'Anthropic federation state: enabled, breaker, last success/failure, federated vs fallback counts (superadmin only)',
20
+ builder: (y) => y.option('json', { type: 'boolean', default: false }),
21
+ handler: async (argv) => {
22
+ const s = await get('AiFederation/status');
23
+ if (argv.json) { console.log(JSON.stringify(s, null, 2)); return; }
24
+ printStatus(s);
25
+ },
26
+ };
27
+
28
+ const testCommand = {
29
+ command: 'test',
30
+ describe: 'Real token exchange plus a 1-token Haiku message; reports which auth path served it',
31
+ builder: (y) => y.option('json', { type: 'boolean', default: false }),
32
+ handler: async (argv) => {
33
+ const r = await post('AiFederation/test', {});
34
+ if (argv.json) { console.log(JSON.stringify(r, null, 2)); return; }
35
+ console.log(`exchange: ${r.exchangeOk ? 'OK' : 'FAILED ' + (r.exchangeError ?? '')}`);
36
+ console.log(`message: HTTP ${r.messageStatus ?? '-'}${r.messageError ? ' ' + r.messageError : ''}`);
37
+ console.log(`path used: ${r.pathUsed ?? 'unknown'}`);
38
+ printStatus(r.status);
39
+ },
40
+ };
41
+
42
+ const resetCommand = {
43
+ command: 'reset-breaker',
44
+ describe: 'Close the federation circuit breaker so the next call retries federated auth',
45
+ builder: (y) => y,
46
+ handler: async () => { printStatus(await post('AiFederation/reset-breaker', {})); },
47
+ };
48
+
49
+ export const aiFederationCommand = {
50
+ command: 'ai-federation <subcommand>',
51
+ describe: 'Anthropic Workload Identity Federation status, test and breaker reset (superadmin only)',
52
+ builder: (y) => y.command(statusCommand).command(testCommand).command(resetCommand)
53
+ .demandCommand(1, 'Specify a subcommand: status, test, reset-breaker').strict(),
54
+ handler: () => {},
55
+ };
@@ -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
+ };
@@ -13,6 +13,12 @@ const REPORTS = {
13
13
  'sentiment-breakdown': 'Sentiment by website, team, agent or topic',
14
14
  'topic-radar': 'Topics by volume, growth, transfer rate and handle time',
15
15
  'weekly-brief': 'Preview the Monday brief sections',
16
+ 'repeat-contact': 'Share of closed chats where the same contact came back within --window-days, by agent, topic and program',
17
+ 'demand-coverage': 'Day-of-week by hour demand against Available agent hours over whole local weeks (from/to ignored), top gaps, after-hours, next-week forecast',
18
+ 'bot-containment': 'Claimed vs true bot containment and handoff regret',
19
+ 'effort-index': 'Inferred 0-100 customer effort by topic, program and team (not a survey CES)',
20
+ 'promise-ledger': 'Agent commitments made in chat and whether a follow-up appeared; overdue list',
21
+ 'promise-dismiss': 'Dismiss one promise by --id (managers only)',
16
22
  'weekly-brief-send-test': 'Email this week\'s brief to the signed-in user',
17
23
  };
18
24
 
@@ -29,14 +35,27 @@ export const enterpriseReportsCommand = {
29
35
  .option('threshold', { type: 'number', describe: 'coaching-queue: queue chats scored below this (1-100)' })
30
36
  .option('per-agent', { type: 'number', describe: 'coaching-queue: chats listed per agent' })
31
37
  .option('limit', { type: 'number', describe: 'topic-radar: max topics' })
32
- .option('days', { type: 'number', describe: 'weekly-brief: days to cover' }),
38
+ .option('days', { type: 'number', describe: 'weekly-brief: days to cover' })
39
+ .option('window-days', { type: 'number', describe: 'repeat-contact: days a contact has to come back to count as a repeat (1-30, default 7)' })
40
+ .option('weeks', { type: 'number', describe: 'demand-coverage: whole local weeks of history (1-12, default 6). from/to are IGNORED by this report' })
41
+ .option('handle-minutes', { type: 'number', describe: 'demand-coverage: assumed minutes per chat (1-120, default 10)' })
42
+ .option('max-concurrent', { type: 'number', describe: 'demand-coverage: assumed chats an agent handles at once (1-10, default 3)' })
43
+ .option('time-zone', { type: 'string', describe: 'demand-coverage: IANA time zone (default America/Los_Angeles)' })
44
+ .option('low-score', { type: 'number', describe: 'bot-containment: QA score below which a handed-off chat counts as regret (1-100, default 60)' })
45
+ .option('include-demo', { type: 'boolean', describe: 'Show only the seeded sample (demo) data' })
46
+ .option('id', { type: 'string', describe: 'promise-dismiss: the promise id' }),
33
47
  handler: runCommand(async (argv) => {
34
48
  if (argv.report === 'weekly-brief-send-test') {
35
49
  console.log(JSON.stringify(await messagingPost('/api/enterprise-reports/weekly-brief/send-test', {}), null, 2));
36
50
  return;
37
51
  }
52
+ if (argv.report === 'promise-dismiss') {
53
+ if (!argv.id) throw new Error('--id is required for promise-dismiss');
54
+ console.log(JSON.stringify(await messagingPost(`/api/enterprise-reports/promise-ledger/${encodeURIComponent(String(argv.id))}/dismiss`, {}), null, 2));
55
+ return;
56
+ }
38
57
  const qs = new URLSearchParams();
39
- const map = { from: 'from', to: 'to', groupBy: 'groupBy', deploymentId: 'deploymentId', teamId: 'teamId', threshold: 'threshold', perAgent: 'perAgent', limit: 'limit', days: 'days' };
58
+ const map = { from: 'from', to: 'to', groupBy: 'groupBy', deploymentId: 'deploymentId', teamId: 'teamId', threshold: 'threshold', perAgent: 'perAgent', limit: 'limit', days: 'days', windowDays: 'windowDays', weeks: 'weeks', handleMinutes: 'handleMinutes', maxConcurrent: 'maxConcurrent', timeZone: 'timeZone', lowScore: 'lowScore', includeDemo: 'includeDemo' };
40
59
  for (const [arg, key] of Object.entries(map)) if (argv[arg] != null && argv[arg] !== '') qs.set(key, String(argv[arg]));
41
60
  const q = qs.toString();
42
61
  console.log(JSON.stringify(await messagingGet(`/api/enterprise-reports/${argv.report}${q ? `?${q}` : ''}`), null, 2));
@@ -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, buildRenewBody, jobLifecyclePath } 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/
@@ -15,7 +16,10 @@ import { runCommand } from '../run.js';
15
16
  // POST Recruiting/Sync -> { success, error, added, updated, total }
16
17
  // GET Recruiting/Jobs?search=&location=&department= -> jobListing[]
17
18
  // DELETE Recruiting/Jobs/{id} -> { success }
19
+ // GET Recruiting/Jobs?includeAll=true -> every status (needs the Job Board lifecycle package)
20
+ // POST Recruiting/Jobs/{id}/close | /renew { days? } ; GET Recruiting/Lifecycle -> { entitled, defaultListingDays } (403 without the package)
18
21
  // GET Recruiting/Applications -> candidateApplication[]
22
+ // GET Recruiting/Applications/AtsFailures; POST Recruiting/Applications/{id}/AtsRetry; PUT Recruiting/ApplySettings { publicApplyEnabled?, autoCreateCaseTypeId?, defaultListingDays? }; GET Recruiting/Applications/{id}/Resume (binary)
19
23
  // GET Recruiting/Applications/ExportEeoc -> text/csv (Professional tier+, EnableRecruitingBotEeoc)
20
24
 
21
25
  const BOLD = '\x1b[1m';
@@ -70,7 +74,8 @@ async function jobsListHandler(argv) {
70
74
  }
71
75
  console.log(`\n${BOLD}Recruiting Bot job listings${RST} (${jobs.length})\n`);
72
76
  for (const j of jobs) {
73
- console.log(` ${DIM}#${j.id}${RST} ${j.title} ${DIM}${j.department || '-'} / ${j.location || '-'}${RST}`);
77
+ const life = j.status ? ` ${DIM}${j.status}${j.expiresAt ? ` until ${j.expiresAt}` : ''}${RST}` : '';
78
+ console.log(` ${DIM}#${j.id}${RST} ${j.title} ${DIM}${j.department || '-'} / ${j.location || '-'}${RST}${life}`);
74
79
  }
75
80
  console.log('');
76
81
  }
@@ -80,6 +85,7 @@ function buildQuery(argv) {
80
85
  if (argv.search) params.set('search', argv.search);
81
86
  if (argv.location) params.set('location', argv.location);
82
87
  if (argv.department) params.set('department', argv.department);
88
+ if (argv.all) params.set('includeAll', 'true');
83
89
  const qs = params.toString();
84
90
  return qs ? `?${qs}` : '';
85
91
  }
@@ -89,6 +95,23 @@ async function jobsDeleteHandler(argv) {
89
95
  console.log(`\n${GRN}OK${RST} Job listing ${argv.jobId} deleted.\n`);
90
96
  }
91
97
 
98
+ async function jobsCloseHandler(argv) {
99
+ const job = await messagingPost(jobLifecyclePath(argv.jobId, 'close'), {});
100
+ console.log(`\n${GRN}OK${RST} Job listing ${job.id} closed (status ${job.status}, closed at ${job.closedAt}).\n`);
101
+ }
102
+
103
+ async function jobsRenewHandler(argv) {
104
+ const job = await messagingPost(jobLifecyclePath(argv.jobId, 'renew'), buildRenewBody(argv.days));
105
+ console.log(`\n${GRN}OK${RST} Job listing ${job.id} renewed until ${job.expiresAt} (renewal #${job.renewalCount}, status ${job.status}).\n`);
106
+ }
107
+
108
+ async function jobsLifecycleHandler() {
109
+ const r = await messagingGet('/Recruiting/Lifecycle');
110
+ console.log(`\n${BOLD}Job Board lifecycle${RST}`);
111
+ console.log(` Package: ${r.entitled ? `${GRN}included${RST}` : 'not included (close, renew and JSON-LD pages return 403)'}`);
112
+ console.log(` Default listing days: ${r.defaultListingDays}\n`);
113
+ }
114
+
92
115
  async function applicationsListHandler() {
93
116
  const apps = await messagingGet('/Recruiting/Applications');
94
117
  if (!Array.isArray(apps) || !apps.length) {
@@ -123,6 +146,56 @@ async function applicationsExportEeocHandler(argv) {
123
146
  console.log(`\n${GRN}OK${RST} EEOC export saved to ${outPath}\n`);
124
147
  }
125
148
 
149
+ async function atsFailuresHandler() {
150
+ const rows = await messagingGet('/Recruiting/Applications/AtsFailures');
151
+ if (!Array.isArray(rows) || !rows.length) {
152
+ console.log('\n No applications with pending or failed ATS write-back.\n');
153
+ return;
154
+ }
155
+ console.log(`\n${BOLD}ATS write-back queue${RST} (${rows.length})\n`);
156
+ for (const r of rows) {
157
+ console.log(` ${DIM}#${r.id}${RST} ${r.candidateName} ${r.atsSyncStatus} ${DIM}attempts=${r.atsSyncAttempts} next=${r.atsSyncNextRetryAt || '-'} ${r.atsSyncLastError || ''}${RST}`);
158
+ }
159
+ console.log('');
160
+ }
161
+
162
+ async function atsRetryHandler(argv) {
163
+ const r = await messagingPost(applicationIdPath(argv.applicationId, 'AtsRetry'), {});
164
+ console.log(`\n${GRN}OK${RST} Application ${r.id} ATS status: ${r.atsSyncStatus} (attempts ${r.atsSyncAttempts})${r.atsSyncLastError ? ` ${RED}${r.atsSyncLastError}${RST}` : ''}\n`);
165
+ }
166
+
167
+ async function botSettingsHandler(argv) {
168
+ const keys = ['pageContext', 'revealPostedDate', 'revealDescription', 'revealApplyLink', 'publicApply', 'applyMode', 'tone', 'maxResults', 'greeting'];
169
+ const given = Object.fromEntries(keys.filter((k) => argv[k] !== undefined).map((k) => [k, argv[k]]));
170
+ // No flags = show current settings.
171
+ const r = Object.keys(given).length ? await messagingPut('/Recruiting/BotSettings', buildBotSettingsBody(given)) : await messagingGet('/Recruiting/BotSettings');
172
+ console.log(`\n${GRN}OK${RST} Recruiting bot settings:\n${JSON.stringify(r, null, 2)}\n`);
173
+ }
174
+
175
+ async function applySettingsHandler(argv) {
176
+ const r = await messagingPut('/Recruiting/ApplySettings', buildApplySettingsBody({ publicApply: argv.publicApply, caseTypeId: argv.caseTypeId, defaultListingDays: argv.defaultListingDays }));
177
+ console.log(`\n${GRN}OK${RST} Apply settings: publicApplyEnabled=${r.publicApplyEnabled}, autoCreateCaseTypeId=${r.autoCreateCaseTypeId ?? '(none)'}, defaultListingDays=${r.defaultListingDays ?? '(unchanged)'}\n`);
178
+ }
179
+
180
+ // Resume is a binary download; fetch raw (messagingGet JSON-parses) with the same auth/site header.
181
+ async function resumeDownloadHandler(argv) {
182
+ const creds = await getCredentials();
183
+ const res = await fetch(`${creds.messagingApiBase.replace(/\/+$/, '')}${applicationIdPath(argv.applicationId, 'Resume')}`, {
184
+ headers: {
185
+ Authorization: `Bearer ${creds.velaroToken}`,
186
+ ...(creds.siteId ? { 'vmsg-siteId': String(creds.siteId) } : {}),
187
+ },
188
+ });
189
+ if (!res.ok) {
190
+ const text = await res.text().catch(() => '');
191
+ throw new Error(`GET Resume -> ${res.status}: ${text.slice(0, 300)}`);
192
+ }
193
+ const m = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(res.headers.get('content-disposition') || '');
194
+ const outPath = argv.out || (m ? decodeURIComponent(m[1]) : `resume-${argv.applicationId}`);
195
+ writeFileSync(outPath, Buffer.from(await res.arrayBuffer()));
196
+ console.log(`\n${GRN}OK${RST} Resume saved to ${outPath}\n`);
197
+ }
198
+
126
199
  const syncCommand = {
127
200
  command: 'sync',
128
201
  describe: 'Manually trigger a Recruiting Bot job-feed sync for this site',
@@ -164,16 +237,36 @@ const jobsCommand = {
164
237
  builder: (y2) => y2
165
238
  .option('search', { describe: 'Free-text search across title/description', type: 'string' })
166
239
  .option('location', { describe: 'Filter by location', type: 'string' })
167
- .option('department', { describe: 'Filter by department', type: 'string' }),
240
+ .option('department', { describe: 'Filter by department', type: 'string' })
241
+ .option('all', { describe: 'Include closed, paused and expired listings (needs the Job Board lifecycle package)', type: 'boolean' }),
168
242
  handler: runCommand(jobsListHandler),
169
243
  })
244
+ .command({
245
+ command: 'close <jobId>',
246
+ describe: 'Close a listing (gone from search and the public board; its public page and applying answer 410). Needs the Job Board lifecycle package',
247
+ builder: (y2) => y2.positional('jobId', { describe: 'Job listing ID', type: 'number' }),
248
+ handler: runCommand(jobsCloseHandler),
249
+ })
250
+ .command({
251
+ command: 'renew <jobId>',
252
+ describe: 'Renew a listing: extend its expiry and republish it. Needs the Job Board lifecycle package',
253
+ builder: (y2) => y2
254
+ .positional('jobId', { describe: 'Job listing ID', type: 'number' })
255
+ .option('days', { describe: 'Days to extend by (1-365; default: the site default listing days)', type: 'number' }),
256
+ handler: runCommand(jobsRenewHandler),
257
+ })
258
+ .command({
259
+ command: 'lifecycle',
260
+ describe: 'Show whether this site has the Job Board lifecycle package and its default listing days',
261
+ handler: runCommand(jobsLifecycleHandler),
262
+ })
170
263
  .command({
171
264
  command: 'delete <jobId>',
172
265
  describe: 'Delete a synced job listing',
173
266
  builder: (y2) => y2.positional('jobId', { describe: 'Job listing ID', type: 'number' }),
174
267
  handler: runCommand(jobsDeleteHandler),
175
268
  })
176
- .demandCommand(1, 'Specify a jobs subcommand: list | delete'),
269
+ .demandCommand(1, 'Specify a jobs subcommand: list | close | renew | lifecycle | delete'),
177
270
  handler: () => {},
178
271
  };
179
272
 
@@ -192,7 +285,44 @@ const applicationsCommand = {
192
285
  builder: (y2) => y2.option('out', { describe: 'Output CSV file path (default: eeoc-export-<timestamp>.csv)', type: 'string' }),
193
286
  handler: runCommand(applicationsExportEeocHandler),
194
287
  })
195
- .demandCommand(1, 'Specify an applications subcommand: list | export-eeoc'),
288
+ .command({ command: 'ats-failures', describe: 'List applications whose ATS write-back is Pending/Failed/Abandoned', handler: runCommand(atsFailuresHandler) })
289
+ .command({
290
+ command: 'ats-retry <applicationId>',
291
+ describe: 'Retry the ATS write-back for one application now (resets attempts)',
292
+ builder: (y2) => y2.positional('applicationId', { type: 'number' }),
293
+ handler: runCommand(atsRetryHandler),
294
+ })
295
+ .command({
296
+ command: 'resume <applicationId>',
297
+ describe: 'Download an applicant resume',
298
+ builder: (y2) => y2.positional('applicationId', { type: 'number' }).option('out', { describe: 'Output file path (default: original file name)', type: 'string' }),
299
+ handler: runCommand(resumeDownloadHandler),
300
+ })
301
+ .command({
302
+ command: 'apply-settings',
303
+ describe: 'Set public apply form options (apply-if-present)',
304
+ builder: (y2) => y2
305
+ .option('public-apply', { describe: 'Enable/disable the public apply endpoint for this site', type: 'boolean' })
306
+ .option('case-type-id', { describe: 'Case type to auto-create a case per application (0 clears)', type: 'number' })
307
+ .option('default-listing-days', { describe: 'Days a new listing stays live when its feed gives no expiry (1-365; needs the Job Board lifecycle package)', type: 'number' }),
308
+ handler: runCommand(applySettingsHandler),
309
+ })
310
+ .command({
311
+ command: 'bot-settings',
312
+ describe: 'Show or set what the recruiting bot reveals and how it behaves (omit flags to show)',
313
+ builder: (y2) => y2
314
+ .option('page-context', { describe: 'Use the job from the host page (Velaro context call)', type: 'boolean' })
315
+ .option('reveal-posted-date', { describe: 'Let the bot share when a job was first listed', type: 'boolean' })
316
+ .option('reveal-description', { describe: 'Let the bot share the full job description', type: 'boolean' })
317
+ .option('reveal-apply-link', { describe: 'Let the bot share the listing link', type: 'boolean' })
318
+ .option('public-apply', { describe: 'Enable the public apply endpoint', type: 'boolean' })
319
+ .option('apply-mode', { describe: 'both | link | inproduct', type: 'string' })
320
+ .option('tone', { describe: 'friendly | professional | concise', type: 'string' })
321
+ .option('max-results', { describe: 'Most jobs per search (1-10)', type: 'number' })
322
+ .option('greeting', { describe: 'Opening greeting (500 chars max)', type: 'string' }),
323
+ handler: runCommand(botSettingsHandler),
324
+ })
325
+ .demandCommand(1, 'Specify an applications subcommand: list | export-eeoc | ats-failures | ats-retry | resume | apply-settings | bot-settings'),
196
326
  handler: () => {},
197
327
  };
198
328
 
@@ -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
  };
@@ -101,6 +101,61 @@ export const teamsCommand = {
101
101
  else { console.error(`Send failed: ${result?.message || 'unknown error'}`); process.exit(1); }
102
102
  }
103
103
  )
104
+ .command(
105
+ 'send-direct <user> <title>',
106
+ 'Send a 1:1 Teams message to any user in the tenant (UPN or Entra id). Velaro staff only.',
107
+ (y) =>
108
+ y
109
+ .positional('user', { type: 'string', describe: 'alex@velaro.com or Entra object id' })
110
+ .positional('title', { type: 'string', describe: 'Card title' })
111
+ .option('message', { type: 'string', alias: 'm', describe: 'Message text' })
112
+ .option('fact', { type: 'array', describe: 'Repeatable "Label=Value"' })
113
+ .option('button-label', { type: 'string' })
114
+ .option('button-url', { type: 'string' }),
115
+ async (argv) => {
116
+ const facts = (argv.fact || []).map((f) => { const eq = f.indexOf('='); return { label: f.slice(0, eq), value: f.slice(eq + 1) }; });
117
+ const r = await request('POST', '/TeamsMessaging/Direct', {
118
+ user: argv.user, title: argv.title, message: argv.message || '', facts,
119
+ actionUrl: argv.buttonUrl, actionLabel: argv.buttonLabel,
120
+ });
121
+ if (r?.sent) console.log(`Sent to ${argv.user} via ${r.path}.`);
122
+ else { console.error(`Send failed: ${r?.error || 'unknown error'}`); process.exit(1); }
123
+ }
124
+ )
125
+ .command(
126
+ 'card <title>',
127
+ 'Post a card to a named DevOps-team channel with real @mentions (Velaro staff). Destinations: prs, workflows, deploys, alerts, qa, general.',
128
+ (y) =>
129
+ y
130
+ .positional('title', { type: 'string' })
131
+ .option('destination', { type: 'string', alias: 'd', describe: 'Named channel (see: velaro teams destinations)' })
132
+ .option('mention', { type: 'array', describe: 'Repeatable UPN or UPN=Display Name, e.g. --mention andrea@velaro.com=Andrea' })
133
+ .option('message', { type: 'string', alias: 'm', describe: 'Subtitle text' })
134
+ .option('fact', { type: 'array', describe: 'Repeatable "Label=Value"' })
135
+ .option('button-label', { type: 'string' })
136
+ .option('button-url', { type: 'string' }),
137
+ async (argv) => {
138
+ const facts = (argv.fact || []).map((f) => { const eq = f.indexOf('='); return { label: f.slice(0, eq), value: f.slice(eq + 1) }; });
139
+ const mentions = (argv.mention || []).map((m) => { const t = String(m); const eq = t.indexOf('='); return eq < 0 ? { user: t } : { user: t.slice(0, eq), name: t.slice(eq + 1) }; });
140
+ const r = await request('POST', '/TeamsMessaging/Card', {
141
+ title: argv.title, subtitle: argv.message || '', facts, mentions, destination: argv.destination,
142
+ actionUrl: argv.buttonUrl, actionLabel: argv.buttonLabel,
143
+ });
144
+ if (r?.sent) console.log(`Card sent via ${r.path}.${r.warnings?.length ? ' Warnings: ' + r.warnings.join('; ') : ''}`);
145
+ else { console.error(`Send failed: ${r?.error || 'unknown error'}`); process.exit(1); }
146
+ }
147
+ )
148
+ .command(
149
+ 'destinations',
150
+ 'List named Teams destinations and the live channels of the DevOps team (Velaro staff)',
151
+ () => {},
152
+ async () => {
153
+ const r = await request('GET', '/TeamsMessaging/Destinations');
154
+ for (const n of r.named || []) console.log(`${n.name.padEnd(10)} ${n.resolved ? n.channelId : '(not resolved: set app setting TeamsDestinations:' + n.name + ':ChannelId)'}`);
155
+ console.log('\nChannels:');
156
+ for (const c of r.channels || []) console.log(` ${c.displayName} ${c.id}`);
157
+ }
158
+ )
104
159
  .command(
105
160
  'push-settings',
106
161
  'Show or change which alerts the Velaro Bot pushes into Teams (per-alert switches + daily cap). Only flags you pass are changed.',
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import { getCredentials, messagingGet, messagingPost, messagingPut } from '../api.js';
3
3
  import { requireFeature } from '../subscription.js';
4
4
  import { runCommand } from '../run.js';
5
+ import { applyFollowUpOptions } from '../ai-followups.js';
5
6
 
6
7
  export const workflowCommand = {
7
8
  command: 'workflow <subcommand>',
@@ -15,7 +16,9 @@ export const workflowCommand = {
15
16
  .command(workflowDisableCommand)
16
17
  .command(workflowNodeOptionsCommand)
17
18
  .command(workflowIntegrityScanCommand)
18
- .demandCommand(1, 'Specify a subcommand: list, get, create, enable, disable, node-options, integrity-scan'),
19
+ .command(workflowAiFollowupsCommand)
20
+ .command(siteAiFollowupsCommand)
21
+ .demandCommand(1, 'Specify a subcommand: list, get, create, enable, disable, node-options, integrity-scan, ai-followups, ai-followups-site'),
19
22
  handler: () => {},
20
23
  };
21
24
 
@@ -232,6 +235,68 @@ const workflowNodeOptionsCommand = {
232
235
  }),
233
236
  };
234
237
 
238
+ // ── ai-followups (AI follow-up suggestions / "AI mode" on a transferToAI node) ────────
239
+ // After each bot reply the bot offers 2-4 short tappable follow-up replies (web chat; typing stays
240
+ // available). Node value ?? account default ?? OFF. "inherit" CLEARS the node value so the account
241
+ // default decides. Same read-modify-write PUT /Workflows as node-options (no per-node route exists).
242
+
243
+ const workflowAiFollowupsCommand = {
244
+ command: 'ai-followups <workflowId> <nodeId>',
245
+ describe: 'View or set AI follow-up suggestions on an AI Chatbot (transferToAI) node',
246
+ builder: (y) =>
247
+ y
248
+ .positional('workflowId', { type: 'number', describe: 'Workflow ID' })
249
+ .positional('nodeId', { type: 'number', describe: 'Node ID of the transferToAI node' })
250
+ .option('mode', { type: 'string', choices: ['on', 'off', 'inherit'], describe: 'on/off for this node, or inherit = use the account default' })
251
+ .option('count', { type: 'number', describe: 'Number of suggestions, 2-4 (default 3)' })
252
+ .option('instruction', { type: 'string', describe: 'Optional style/focus guidance (max 300 chars); pass "" to clear' }),
253
+ handler: runCommand(async (argv) => {
254
+ await requireFeature('enableAutomation', 'Workflows');
255
+ const w = await messagingGet(`/Workflows/${argv.workflowId}`);
256
+ if (!w) { console.error(`Workflow ${argv.workflowId} not found.`); process.exit(1); }
257
+ const nodes = w.nodes ?? {};
258
+ const nodeKey = String(argv.nodeId);
259
+ const node = nodes[nodeKey];
260
+ if (!node) { console.error(`Node ${argv.nodeId} not found in workflow ${argv.workflowId}.`); process.exit(1); }
261
+ if (node.actionType !== 'transferToAI') {
262
+ console.error(`Node ${argv.nodeId} is not an AI Chatbot (transferToAI) node (found: ${node.actionType}).`);
263
+ process.exit(1);
264
+ }
265
+
266
+ const hasChanges = argv.mode || argv.count != null || argv.instruction != null;
267
+ if (!hasChanges) {
268
+ const mode = node.suggestFollowUps === true ? 'on' : node.suggestFollowUps === false ? 'off' : 'inherit (account default)';
269
+ console.log(`\nNode ${argv.nodeId} — AI follow-up suggestions:`);
270
+ console.log(` Mode: ${mode}`);
271
+ console.log(` Count: ${node.suggestFollowUpCount ?? '3 (default)'}`);
272
+ console.log(` Guidance: ${node.suggestFollowUpInstruction ?? '(none)'}`);
273
+ return;
274
+ }
275
+
276
+ const updated = applyFollowUpOptions(node, { mode: argv.mode, count: argv.count, instruction: argv.instruction });
277
+ await messagingPut('/Workflows', { ...w, nodes: { ...nodes, [nodeKey]: updated } });
278
+ // Read back from the server -- a "saved" message alone is not evidence.
279
+ const check = (await messagingGet(`/Workflows/${argv.workflowId}`))?.nodes?.[nodeKey] ?? {};
280
+ console.log(`Node ${argv.nodeId} updated. Read-back: suggestFollowUps=${check.suggestFollowUps ?? 'unset'} count=${check.suggestFollowUpCount ?? 'unset'}`);
281
+ }),
282
+ };
283
+
284
+ const siteAiFollowupsCommand = {
285
+ command: 'ai-followups-site',
286
+ describe: "View or set this account's default for AI follow-up suggestions (nodes can override)",
287
+ builder: (y) => y.option('mode', { type: 'string', choices: ['on', 'off', 'inherit'], describe: 'on/off, or inherit = clear (platform default: off)' }),
288
+ handler: runCommand(async (argv) => {
289
+ const show = (r) => (r?.enabled === true ? 'on' : r?.enabled === false ? 'off' : 'unset (off)');
290
+ if (!argv.mode) {
291
+ console.log(`Account default: ${show(await messagingGet('/SiteSettings/AiFollowUps'))}`);
292
+ return;
293
+ }
294
+ const enabled = argv.mode === 'on' ? true : argv.mode === 'off' ? false : null;
295
+ await messagingPut('/SiteSettings/AiFollowUps', { enabled });
296
+ console.log(`Account default now: ${show(await messagingGet('/SiteSettings/AiFollowUps'))}`);
297
+ }),
298
+ };
299
+
235
300
  // ── integrity-scan (Velaro staff only) ──────────────────────────────────────────
236
301
 
237
302
  const workflowIntegrityScanCommand = {
@@ -0,0 +1,10 @@
1
+ // The five novel Enterprise Reports (asks 11-15) must stay wired into the CLI with the exact messaging route segments.
2
+ import test from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import { enterpriseReportsCommand } from './commands/enterprise-reports.js';
5
+
6
+ test('describe lists the five novel reports and the promise dismiss action', () => {
7
+ for (const r of ['repeat-contact', 'demand-coverage', 'bot-containment', 'effort-index', 'promise-ledger', 'promise-dismiss']) {
8
+ assert.ok(enterpriseReportsCommand.describe.includes(r), `${r} missing from describe`);
9
+ }
10
+ });
@@ -9,7 +9,7 @@ import path from 'node:path';
9
9
  import { fileURLToPath } from 'node:url';
10
10
 
11
11
  const dir = path.join(path.dirname(fileURLToPath(import.meta.url)), 'commands');
12
- const MESSAGING_ONLY = ['/Teams/List', '/Deployment', '/Users/List', '/Workflows/List', '/Rules', '/Ticket/knowledge-overrides', '/UserStatus/admin', '/api/ivr/config', '/WooCommerce/billing', '/WooCommerce/auth', '/diagnostics/test-magento', '/voice/admin'];
12
+ const MESSAGING_ONLY = ['/Teams/List', '/Deployment', '/Users/List', '/Workflows/List', '/Rules', '/Ticket/knowledge-overrides', '/UserStatus/admin', '/api/ivr/config', '/WooCommerce/billing', '/WooCommerce/auth', '/diagnostics/test-magento', '/voice/admin', '/api/enterprise-reports'];
13
13
 
14
14
  test('messaging-only routes are never called through the admin api helpers', () => {
15
15
  const offenders = [];
@@ -0,0 +1,75 @@
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
+ // Job Board listing lifecycle (package job-board-lifecycle): POST Recruiting/Jobs/{id}/close | renew.
12
+ export function jobLifecyclePath(id, action) {
13
+ const n = Number(id);
14
+ if (!Number.isInteger(n) || n <= 0) throw new Error('jobId must be a positive integer.');
15
+ if (!['close', 'renew'].includes(action)) throw new Error('action must be close or renew.');
16
+ return `/Recruiting/Jobs/${n}/${action}`;
17
+ }
18
+
19
+ // Body for POST Recruiting/Jobs/{id}/renew. Omit days to use the site default (RecruitingConfig.DefaultListingDays).
20
+ export function buildRenewBody(days) {
21
+ if (days === undefined) return {};
22
+ const n = Number(days);
23
+ if (!Number.isInteger(n) || n < 1 || n > 365) throw new Error('days must be an integer from 1 to 365.');
24
+ return { days: n };
25
+ }
26
+
27
+ // Apply-if-present body for PUT /Recruiting/ApplySettings. caseTypeId 0 clears the auto-case link.
28
+ // defaultListingDays (1-365) needs the Job Board lifecycle package (the server answers 403 otherwise).
29
+ export function buildApplySettingsBody({ publicApply, caseTypeId, defaultListingDays } = {}) {
30
+ const body = {};
31
+ if (publicApply !== undefined) body.publicApplyEnabled = Boolean(publicApply);
32
+ if (caseTypeId !== undefined) {
33
+ const n = Number(caseTypeId);
34
+ if (!Number.isInteger(n) || n < 0) throw new Error('caseTypeId must be an integer >= 0 (0 clears).');
35
+ body.autoCreateCaseTypeId = n;
36
+ }
37
+ if (defaultListingDays !== undefined) {
38
+ const d = Number(defaultListingDays);
39
+ if (!Number.isInteger(d) || d < 1 || d > 365) throw new Error('defaultListingDays must be an integer from 1 to 365.');
40
+ body.defaultListingDays = d;
41
+ }
42
+ if (!Object.keys(body).length) throw new Error('Specify at least one of publicApply, caseTypeId, defaultListingDays.');
43
+ return body;
44
+ }
45
+
46
+ const APPLY_MODES = ['both', 'link', 'inproduct'];
47
+ const TONES = ['friendly', 'professional', 'concise'];
48
+
49
+ // Apply-if-present body for PUT /Recruiting/BotSettings (customer controls for the recruiting bot).
50
+ export function buildBotSettingsBody(o = {}) {
51
+ const body = {};
52
+ const bools = { pageContext: 'pageContextEnabled', revealPostedDate: 'revealPostedDate', revealDescription: 'revealDescription', revealApplyLink: 'revealApplyLink', publicApply: 'publicApplyEnabled' };
53
+ for (const [k, field] of Object.entries(bools)) if (o[k] !== undefined) body[field] = Boolean(o[k]);
54
+ if (o.applyMode !== undefined) {
55
+ const m = String(o.applyMode).toLowerCase();
56
+ if (!APPLY_MODES.includes(m)) throw new Error(`applyMode must be one of: ${APPLY_MODES.join(', ')}.`);
57
+ body.applyMode = m;
58
+ }
59
+ if (o.tone !== undefined) {
60
+ const t = String(o.tone).toLowerCase();
61
+ if (!TONES.includes(t)) throw new Error(`tone must be one of: ${TONES.join(', ')}.`);
62
+ body.tone = t;
63
+ }
64
+ if (o.maxResults !== undefined) {
65
+ const n = Number(o.maxResults);
66
+ if (!Number.isInteger(n) || n < 1 || n > 10) throw new Error('maxResults must be an integer from 1 to 10.');
67
+ body.maxResults = n;
68
+ }
69
+ if (o.greeting !== undefined) {
70
+ if (String(o.greeting).length > 500) throw new Error('greeting must be 500 characters or fewer.');
71
+ body.greeting = String(o.greeting);
72
+ }
73
+ if (!Object.keys(body).length) throw new Error('Specify at least one bot setting.');
74
+ return body;
75
+ }
@@ -0,0 +1,51 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { applicationIdPath, buildApplySettingsBody, buildBotSettingsBody, buildRenewBody, jobLifecyclePath } from './recruiting-apply.js';
4
+
5
+ test('job lifecycle paths validate id and action', () => {
6
+ assert.equal(jobLifecyclePath(7, 'close'), '/Recruiting/Jobs/7/close');
7
+ assert.equal(jobLifecyclePath('9', 'renew'), '/Recruiting/Jobs/9/renew');
8
+ assert.throws(() => jobLifecyclePath('x', 'close'));
9
+ assert.throws(() => jobLifecyclePath(0, 'close'));
10
+ assert.throws(() => jobLifecyclePath(1, 'delete'));
11
+ });
12
+
13
+ test('renew body: omitted days uses the site default, supplied days are range-checked', () => {
14
+ assert.deepEqual(buildRenewBody(undefined), {});
15
+ assert.deepEqual(buildRenewBody(30), { days: 30 });
16
+ assert.deepEqual(buildRenewBody('7'), { days: 7 });
17
+ assert.throws(() => buildRenewBody(0));
18
+ assert.throws(() => buildRenewBody(366));
19
+ assert.throws(() => buildRenewBody(2.5));
20
+ });
21
+
22
+ test('apply settings carries defaultListingDays (1-365)', () => {
23
+ assert.deepEqual(buildApplySettingsBody({ defaultListingDays: 45 }), { defaultListingDays: 45 });
24
+ assert.throws(() => buildApplySettingsBody({ defaultListingDays: 0 }));
25
+ assert.throws(() => buildApplySettingsBody({ defaultListingDays: 400 }));
26
+ });
27
+
28
+ test('application paths validate id', () => {
29
+ assert.equal(applicationIdPath(12, 'AtsRetry'), '/Recruiting/Applications/12/AtsRetry');
30
+ assert.equal(applicationIdPath('5', 'Resume'), '/Recruiting/Applications/5/Resume');
31
+ assert.throws(() => applicationIdPath('x', 'Resume'));
32
+ assert.throws(() => applicationIdPath(0, 'Resume'));
33
+ });
34
+
35
+ test('apply settings body is apply-if-present', () => {
36
+ assert.deepEqual(buildApplySettingsBody({ publicApply: true }), { publicApplyEnabled: true });
37
+ assert.deepEqual(buildApplySettingsBody({ caseTypeId: 0 }), { autoCreateCaseTypeId: 0 });
38
+ assert.deepEqual(buildApplySettingsBody({ publicApply: false, caseTypeId: 3 }), { publicApplyEnabled: false, autoCreateCaseTypeId: 3 });
39
+ assert.throws(() => buildApplySettingsBody({}));
40
+ assert.throws(() => buildApplySettingsBody({ caseTypeId: -1 }));
41
+ });
42
+
43
+ test('bot settings body is apply-if-present and validated', () => {
44
+ assert.deepEqual(buildBotSettingsBody({ revealPostedDate: false }), { revealPostedDate: false });
45
+ assert.deepEqual(buildBotSettingsBody({ applyMode: 'LINK', tone: 'Concise', maxResults: 3, pageContext: true, publicApply: false }), { pageContextEnabled: true, publicApplyEnabled: false, applyMode: 'link', tone: 'concise', maxResults: 3 });
46
+ assert.throws(() => buildBotSettingsBody({}));
47
+ assert.throws(() => buildBotSettingsBody({ applyMode: 'x' }));
48
+ assert.throws(() => buildBotSettingsBody({ tone: 'angry' }));
49
+ assert.throws(() => buildBotSettingsBody({ maxResults: 11 }));
50
+ assert.throws(() => buildBotSettingsBody({ greeting: 'g'.repeat(501) }));
51
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velaro/cli",
3
- "version": "1.4.89",
3
+ "version": "1.4.91",
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": {