@velaro/cli 1.4.90 → 1.4.92

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
@@ -97,6 +97,7 @@ import { supportCommand } from '../lib/commands/support.js';
97
97
  import { routingCommand } from '../lib/commands/routing.js';
98
98
  import { diagnosticsCommand } from '../lib/commands/diagnostics.js';
99
99
  import { aiSpendCommand } from '../lib/commands/ai-spend.js';
100
+ import { aiFederationCommand } from '../lib/commands/ai-federation.js';
100
101
  import { statusPageCommand } from '../lib/commands/status-page.js';
101
102
  import { statusNotifyCommand } from '../lib/commands/status-notify.js';
102
103
  import { statusSlaCommand } from '../lib/commands/status-sla.js';
@@ -229,6 +230,7 @@ await yargs(hideBin(process.argv))
229
230
  .command(routingCommand)
230
231
  .command(diagnosticsCommand)
231
232
  .command(aiSpendCommand)
233
+ .command(aiFederationCommand)
232
234
  .command(statusPageCommand)
233
235
  .command(statusNotifyCommand)
234
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,56 @@
1
+ // Pure helpers for `velaro avatar group-override | workflow-override | effective | reset`.
2
+ // Per-group / per-workflow avatar + backdrop overrides live in velaro-messaging (VelaroAvatarController);
3
+ // effective = perWorkflow ?? perGroup ?? perSite. Kept dependency-free so it is unit-testable.
4
+
5
+ export const BACKDROP_MODES = ['none', 'color', 'gradient', 'image'];
6
+
7
+ const HEX = /^#[0-9a-fA-F]{3,8}$/;
8
+ const GRADIENT = /^#[0-9a-fA-F]{3,8},#[0-9a-fA-F]{3,8}$/;
9
+ const HTTPS = /^https:\/\/[^\s"'()<>\\]+$/i;
10
+
11
+ /** Returns an error string or null. Mirrors AvatarExperienceSettings.ValidateBackdrop on the server. */
12
+ export function validateBackdrop(mode, value) {
13
+ if (mode === undefined || mode === '') return null;
14
+ if (!BACKDROP_MODES.includes(mode)) return `--backdrop must be one of ${BACKDROP_MODES.join(', ')}`;
15
+ const v = (value ?? '').trim();
16
+ if (mode === 'none') return null;
17
+ if (mode === 'color') return HEX.test(v) ? null : '--backdrop-value must be a hex color like #1e293b';
18
+ if (mode === 'gradient') return GRADIENT.test(v) ? null : '--backdrop-value must be two hex colors like #1e293b,#7c3aed';
19
+ return v.length <= 500 && HTTPS.test(v) ? null : '--backdrop-value must be an https image URL';
20
+ }
21
+
22
+ /**
23
+ * Builds the PUT body. Merge by default: only flags the caller passed are sent, so changing one field never wipes the
24
+ * others; an empty string clears that one field back to inherit. `replace` replaces the whole override.
25
+ */
26
+ export function overrideBody(argv) {
27
+ const body = { merge: !argv.replace };
28
+ const map = {
29
+ provider: 'providerSlug',
30
+ avatar: 'avatarId',
31
+ personality: 'personalityPresetSlug',
32
+ voice: 'voiceId',
33
+ 'display-name': 'displayName',
34
+ backdrop: 'backgroundMode',
35
+ 'backdrop-value': 'backgroundValue',
36
+ };
37
+ for (const [flag, key] of Object.entries(map)) {
38
+ if (argv[flag] !== undefined) body[key] = String(argv[flag]);
39
+ }
40
+ const err = validateBackdrop(body.backgroundMode, body.backgroundValue);
41
+ if (err) throw new Error(err);
42
+ if (body.backgroundMode === undefined && body.backgroundValue !== undefined && body.backgroundValue !== '')
43
+ throw new Error('--backdrop-value needs --backdrop (none | color | gradient | image)');
44
+ if (Object.keys(body).length === 1) throw new Error('Pass at least one of --avatar --personality --voice --display-name --backdrop --backdrop-value --provider');
45
+ return body;
46
+ }
47
+
48
+ /** `group` or `workflow` target path segment from the flags; exactly one is required. */
49
+ export function scopePath(argv) {
50
+ const hasGroup = argv.group !== undefined;
51
+ const hasWorkflow = argv.workflow !== undefined;
52
+ if (hasGroup === hasWorkflow) throw new Error('Pass exactly one of --group <teamId> or --workflow <workflowId>');
53
+ const id = Number(hasGroup ? argv.group : argv.workflow);
54
+ if (!Number.isInteger(id) || id <= 0) throw new Error('The id must be a positive whole number');
55
+ return hasGroup ? `/avatar/groups/${id}` : `/avatar/workflows/${id}`;
56
+ }
@@ -0,0 +1,43 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { overrideBody, scopePath, validateBackdrop } from './avatar-override.js';
4
+
5
+ const TIMKEN = 'https://admin.velaro.com/avatar-backdrops/timken-hr-office.png';
6
+
7
+ test('validateBackdrop accepts https images, hex colors and two-color gradients only', () => {
8
+ assert.equal(validateBackdrop('image', TIMKEN), null);
9
+ assert.match(validateBackdrop('image', 'http://admin.velaro.com/a.png'), /https/);
10
+ assert.match(validateBackdrop('image', 'javascript:alert(1)'), /https/);
11
+ assert.match(validateBackdrop('image', 'https://admin.velaro.com/a.png");background:url(//evil'), /https/);
12
+ assert.equal(validateBackdrop('color', '#1e293b'), null);
13
+ assert.match(validateBackdrop('color', 'red'), /hex/);
14
+ assert.equal(validateBackdrop('gradient', '#111,#222'), null);
15
+ assert.match(validateBackdrop('gradient', '#111'), /two hex/);
16
+ assert.equal(validateBackdrop('none', ''), null);
17
+ assert.match(validateBackdrop('sparkles', 'x'), /one of/);
18
+ });
19
+
20
+ test('overrideBody merges by default and only sends the flags that were passed', () => {
21
+ assert.deepEqual(overrideBody({ avatar: 'priya', backdrop: 'image', 'backdrop-value': TIMKEN }), {
22
+ merge: true,
23
+ avatarId: 'priya',
24
+ backgroundMode: 'image',
25
+ backgroundValue: TIMKEN,
26
+ });
27
+ assert.deepEqual(overrideBody({ 'display-name': '' }), { merge: true, displayName: '' }); // clears just that field
28
+ assert.equal(overrideBody({ avatar: 'maya', replace: true }).merge, false);
29
+ });
30
+
31
+ test('overrideBody refuses an unsafe backdrop, a value with no mode, and an empty change', () => {
32
+ assert.throws(() => overrideBody({ backdrop: 'image', 'backdrop-value': 'http://admin.velaro.com/a.png' }), /https/);
33
+ assert.throws(() => overrideBody({ 'backdrop-value': '#fff' }), /needs --backdrop/);
34
+ assert.throws(() => overrideBody({}), /at least one/);
35
+ });
36
+
37
+ test('scopePath needs exactly one of --group / --workflow and a positive id', () => {
38
+ assert.equal(scopePath({ workflow: 12 }), '/avatar/workflows/12');
39
+ assert.equal(scopePath({ group: 7 }), '/avatar/groups/7');
40
+ assert.throws(() => scopePath({}), /exactly one/);
41
+ assert.throws(() => scopePath({ group: 1, workflow: 2 }), /exactly one/);
42
+ assert.throws(() => scopePath({ group: 0 }), /positive/);
43
+ });
@@ -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
+ };
@@ -1,4 +1,5 @@
1
- import { request } from '../api.js';
1
+ import { request, messagingGet, messagingPut, messagingDel } from '../api.js';
2
+ import { overrideBody, scopePath } from '../avatar-override.js';
2
3
  import { runCommand } from '../run.js';
3
4
 
4
5
  // ── velaro avatar ─────────────────────────────────────────────────────────────
@@ -104,5 +105,53 @@ export const avatarCommand = {
104
105
  console.log(` Max TTS chars/mo: ${data.maxAvatarTtsChars}\n`);
105
106
  }),
106
107
  })
107
- .demandCommand(1, 'Specify a subcommand: get | enable | disable | set'),
108
+ .command({
109
+ command: 'override',
110
+ describe: 'Set the person / personality / voice / name / backdrop for ONE group or workflow of YOUR site (perWorkflow ?? perGroup ?? perSite)',
111
+ builder: y => y
112
+ .option('group', { type: 'number', describe: 'Group (team) id' })
113
+ .option('workflow', { type: 'number', describe: 'Workflow id (wins over its group)' })
114
+ .option('avatar', { type: 'string', describe: 'Library avatar slug (see the avatar library), e.g. priya. Empty string clears.' })
115
+ .option('personality', { type: 'string', describe: 'Personality preset slug. Empty string clears.' })
116
+ .option('voice', { type: 'string', describe: 'ElevenLabs voice id. Empty string clears.' })
117
+ .option('display-name', { type: 'string', describe: 'Visitor-facing avatar name. Empty string clears.' })
118
+ .option('backdrop', { type: 'string', choices: ['none', 'color', 'gradient', 'image', ''], describe: 'Backdrop type. none = explicitly no backdrop; empty string = inherit.' })
119
+ .option('backdrop-value', { type: 'string', describe: 'Hex color, "#a,#b" gradient, or https image URL' })
120
+ .option('provider', { type: 'string', describe: 'Provider slug (a library avatar implies velaro)' })
121
+ .option('replace', { type: 'boolean', describe: 'Replace the whole override instead of changing only the flags passed' }),
122
+ handler: runCommand(async argv => {
123
+ const base = scopePath(argv);
124
+ const data = await messagingPut(`${base}/override`, overrideBody(argv));
125
+ if (!data?.success) throw new Error(data?.error || 'Override failed');
126
+ const eff = await messagingGet(`${base}/effective`);
127
+ console.log(`
128
+ ✓ Override saved. Effective now: avatar=${eff.avatarId ?? '(default)'} backdrop=${eff.backgroundMode}${eff.backgroundValue ? ' ' + eff.backgroundValue : ''} name=${eff.displayName ?? '(AI)'} layer=${eff.layer}
129
+ `);
130
+ }),
131
+ })
132
+ .command({
133
+ command: 'effective',
134
+ describe: 'Show what a group or workflow of your site gets (override fields layered over the site default)',
135
+ builder: y => y
136
+ .option('group', { type: 'number', describe: 'Group (team) id' })
137
+ .option('workflow', { type: 'number', describe: 'Workflow id' }),
138
+ handler: runCommand(async argv => {
139
+ const eff = await messagingGet(`${scopePath(argv)}/effective`);
140
+ console.log(JSON.stringify(eff, null, 2));
141
+ }),
142
+ })
143
+ .command({
144
+ command: 'reset',
145
+ describe: 'Remove a group or workflow override (back to the next layer / site default)',
146
+ builder: y => y
147
+ .option('group', { type: 'number', describe: 'Group (team) id' })
148
+ .option('workflow', { type: 'number', describe: 'Workflow id' }),
149
+ handler: runCommand(async argv => {
150
+ const data = await messagingDel(`${scopePath(argv)}/override`);
151
+ console.log(`
152
+ ✓ Reset (${data?.removed ?? 0} override removed)
153
+ `);
154
+ }),
155
+ })
156
+ .demandCommand(1, 'Specify a subcommand: get | enable | disable | set | override | effective | reset'),
108
157
  };
@@ -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));
@@ -9,7 +9,8 @@ export const migrateCommand = {
9
9
  .command(migrateExportCommand)
10
10
  .command(migrateLivefluenceCommand)
11
11
  .command(migrateUrlRulesCommand)
12
- .demandCommand(1, 'Specify a subcommand: export, livefluence, url-rules'),
12
+ .command(migrateTeamsCommand)
13
+ .demandCommand(1, 'Specify a subcommand: export, livefluence, url-rules, teams'),
13
14
  handler: () => {},
14
15
  };
15
16
 
@@ -21,9 +22,13 @@ const migrateExportCommand = {
21
22
  builder: (y) =>
22
23
  y
23
24
  .positional('v10SiteId', { type: 'number', describe: 'V10 site ID to export from' })
24
- .option('v20-site-id', { type: 'number', alias: 's', describe: 'V20 site ID for auto-matching teams/AI configs' }),
25
+ .option('v20-site-id', { type: 'number', alias: 's', describe: 'V20 site ID for auto-matching teams/AI configs' })
26
+ .option('include-non-routing', { type: 'boolean', default: false, describe: 'Also include v10 rules with IsRouting=false (group-assignment rules)' }),
25
27
  handler: runCommand(async (argv) => {
26
- const qs = argv['v20-site-id'] ? `?v20SiteId=${argv['v20-site-id']}` : '';
28
+ const params = [];
29
+ if (argv['v20-site-id']) params.push(`v20SiteId=${argv['v20-site-id']}`);
30
+ if (argv['include-non-routing']) params.push('includeNonRouting=true');
31
+ const qs = params.length ? `?${params.join('&')}` : '';
27
32
  const data = await get(`/DatabaseTool/V10Export/${argv.v10SiteId}${qs}`);
28
33
 
29
34
  console.log(`\n${'─'.repeat(60)}`);
@@ -78,7 +83,9 @@ const migrateLivefluenceCommand = {
78
83
  .option('v10-site-id', { type: 'number', alias: 'v', describe: 'V10 (Livefluence) site ID', demandOption: true })
79
84
  .option('v20-site-id', { type: 'number', alias: 's', describe: 'V20 destination site ID', demandOption: true })
80
85
  .option('dry-run', { type: 'boolean', default: false, describe: 'Preview without creating' })
81
- .option('enable', { type: 'boolean', default: false, describe: 'Enable created workflows immediately (default: disabled)' })
86
+ .option('enable', { type: 'boolean', default: false, describe: 'Enable created workflows immediately (default: disabled; workflows with unconverted v10 terms always stay disabled)' })
87
+ .option('include-non-routing', { type: 'boolean', default: false, describe: 'Also migrate v10 rules with IsRouting=false (group-assignment rules)' })
88
+ .option('route-bot-groups-to-team', { type: 'boolean', default: false, describe: 'Route to the v20 team named after the v10 group even if the group had a v10 bot' })
82
89
  .option('team-map', { type: 'string', describe: 'JSON: [{"v10GroupId":1,"v20TeamId":5},...] override auto-resolution for teams' })
83
90
  .option('ai-config-map',{ type: 'string', describe: 'JSON: [{"v10GroupId":2,"v20AiConfigId":3},...] override auto-resolution for AI configs' }),
84
91
  handler: runCommand(async (argv) => {
@@ -90,6 +97,8 @@ const migrateLivefluenceCommand = {
90
97
  v20SiteId,
91
98
  dryRun: argv['dry-run'],
92
99
  enable: argv.enable,
100
+ includeNonRouting: argv['include-non-routing'],
101
+ routeBotGroupsToTeam: argv['route-bot-groups-to-team'],
93
102
  teamMappings: argv['team-map'] ? JSON.parse(argv['team-map']) : [],
94
103
  aiConfigMappings: argv['ai-config-map'] ? JSON.parse(argv['ai-config-map']) : [],
95
104
  };
@@ -111,6 +120,8 @@ const migrateLivefluenceCommand = {
111
120
  if (item.v2AiConfigId) console.log(` → AI Config id=${item.v2AiConfigId}`);
112
121
  if (item.v2TeamId) console.log(` → Team id=${item.v2TeamId}`);
113
122
  if (item.v10Expression) console.log(` V10 expression: ${item.v10Expression}`);
123
+ if (item.conditionGroups !== undefined) console.log(` v20 condition groups (OR): ${item.conditionGroups}`);
124
+ for (const u of item.unconverted ?? []) console.log(` ⚠ not converted: ${u}`);
114
125
  console.log('');
115
126
  }
116
127
  }
@@ -127,8 +138,7 @@ const migrateLivefluenceCommand = {
127
138
  console.log(` ${result.message}`);
128
139
  if (!argv['dry-run'] && result.created > 0) {
129
140
  console.log(`\n ⚠ All workflows created DISABLED — review conditions in the canvas then enable.`);
130
- console.log(` Note: V10 routing expressions were NOT auto-converted.`);
131
- console.log(` Add conditions manually in the Workflows canvas (Phase 3 trigger node).`);
141
+ console.log(` Country and URL terms are converted; terms marked 'not converted' need manual canvas conditions.`);
132
142
  }
133
143
  console.log(`${'─'.repeat(60)}\n`);
134
144
  }),
@@ -157,3 +167,31 @@ const migrateUrlRulesCommand = {
157
167
  console.log(`\n ${result.dryRun ? 'DRY RUN: ' : ''}created ${result.created}, skipped ${result.skipped}`);
158
168
  }),
159
169
  };
170
+
171
+ // -- copy v10 groups to v20 teams (v10 read-only) ------------------------------
172
+
173
+ const migrateTeamsCommand = {
174
+ command: 'teams',
175
+ describe: 'Copy v10 (Livefluence) groups to v20 teams on a site (v10 is read-only; idempotent by name)',
176
+ builder: (y) =>
177
+ y
178
+ .option('v10-site-id', { type: 'number', alias: 'v', describe: 'V10 (Livefluence) site ID', demandOption: true })
179
+ .option('v20-site-id', { type: 'number', alias: 's', describe: 'V20 destination site ID', demandOption: true })
180
+ .option('groups', { type: 'string', describe: 'Comma-separated group names to copy (default: every non-deleted v10 group). "Default Group" maps to the site default team.' })
181
+ .option('dry-run', { type: 'boolean', default: false, describe: 'Preview without creating' }),
182
+ handler: runCommand(async (argv) => {
183
+ const body = {
184
+ v10SiteId: argv['v10-site-id'],
185
+ v20SiteId: argv['v20-site-id'],
186
+ dryRun: argv['dry-run'],
187
+ groupNames: argv.groups ? argv.groups.split(',').map(g => g.trim()).filter(Boolean) : [],
188
+ };
189
+ const result = await post('/DatabaseTool/V10CreateTeams', body);
190
+ console.log(`\n ${result.dryRun ? 'DRY RUN - ' : ''}v10 site ${body.v10SiteId} -> v20 site ${body.v20SiteId}\n`);
191
+ for (const t of result.teams ?? []) {
192
+ const members = t.v10Members !== undefined ? ` (v10 members: ${t.v10Members})` : '';
193
+ console.log(` ${t.status.padEnd(24)} ${t.name}${t.teamId ? ` [team id=${t.teamId}]` : ''}${members}`);
194
+ }
195
+ console.log('\n Members are not copied; assign v20 agents to the new teams.\n');
196
+ }),
197
+ };
@@ -1,7 +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
+ import { applicationIdPath, buildApplySettingsBody, buildBotSettingsBody, buildHostedBody, buildJobPayBody, buildRenewBody, jobLifecyclePath, jobPayPath } from '../recruiting-apply.js';
5
5
 
6
6
  // -- velaro recruiting --------------------------------------------------------
7
7
  // CLI surface for the Recruiting Bot: job-feed sync (JSON/XML/RSS/Indeed/
@@ -16,8 +16,11 @@ import { applicationIdPath, buildApplySettingsBody, buildBotSettingsBody } from
16
16
  // POST Recruiting/Sync -> { success, error, added, updated, total }
17
17
  // GET Recruiting/Jobs?search=&location=&department= -> jobListing[]
18
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)
21
+ // GET/PUT Recruiting/Hosted { enabled?, regenerateSlug? } ; POST Recruiting/Hosted/FeedKey/Rotate ; PUT Recruiting/Jobs/{id}/Pay -> hosted job pages (package job-board-hosted, 403 without it)
19
22
  // 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)
23
+ // GET Recruiting/Applications/AtsFailures; POST Recruiting/Applications/{id}/AtsRetry; PUT Recruiting/ApplySettings { publicApplyEnabled?, autoCreateCaseTypeId?, defaultListingDays? }; GET Recruiting/Applications/{id}/Resume (binary)
21
24
  // GET Recruiting/Applications/ExportEeoc -> text/csv (Professional tier+, EnableRecruitingBotEeoc)
22
25
 
23
26
  const BOLD = '\x1b[1m';
@@ -72,7 +75,8 @@ async function jobsListHandler(argv) {
72
75
  }
73
76
  console.log(`\n${BOLD}Recruiting Bot job listings${RST} (${jobs.length})\n`);
74
77
  for (const j of jobs) {
75
- console.log(` ${DIM}#${j.id}${RST} ${j.title} ${DIM}${j.department || '-'} / ${j.location || '-'}${RST}`);
78
+ const life = j.status ? ` ${DIM}${j.status}${j.expiresAt ? ` until ${j.expiresAt}` : ''}${RST}` : '';
79
+ console.log(` ${DIM}#${j.id}${RST} ${j.title} ${DIM}${j.department || '-'} / ${j.location || '-'}${RST}${life}`);
76
80
  }
77
81
  console.log('');
78
82
  }
@@ -82,6 +86,7 @@ function buildQuery(argv) {
82
86
  if (argv.search) params.set('search', argv.search);
83
87
  if (argv.location) params.set('location', argv.location);
84
88
  if (argv.department) params.set('department', argv.department);
89
+ if (argv.all) params.set('includeAll', 'true');
85
90
  const qs = params.toString();
86
91
  return qs ? `?${qs}` : '';
87
92
  }
@@ -91,6 +96,59 @@ async function jobsDeleteHandler(argv) {
91
96
  console.log(`\n${GRN}OK${RST} Job listing ${argv.jobId} deleted.\n`);
92
97
  }
93
98
 
99
+ async function jobsCloseHandler(argv) {
100
+ const job = await messagingPost(jobLifecyclePath(argv.jobId, 'close'), {});
101
+ console.log(`\n${GRN}OK${RST} Job listing ${job.id} closed (status ${job.status}, closed at ${job.closedAt}).\n`);
102
+ }
103
+
104
+ async function jobsRenewHandler(argv) {
105
+ const job = await messagingPost(jobLifecyclePath(argv.jobId, 'renew'), buildRenewBody(argv.days));
106
+ console.log(`\n${GRN}OK${RST} Job listing ${job.id} renewed until ${job.expiresAt} (renewal #${job.renewalCount}, status ${job.status}).\n`);
107
+ }
108
+
109
+ async function jobsLifecycleHandler() {
110
+ const r = await messagingGet('/Recruiting/Lifecycle');
111
+ console.log(`\n${BOLD}Job Board lifecycle${RST}`);
112
+ console.log(` Package: ${r.entitled ? `${GRN}included${RST}` : 'not included (close, renew and JSON-LD pages return 403)'}`);
113
+ console.log(` Default listing days: ${r.defaultListingDays}\n`);
114
+ }
115
+
116
+ async function jobsPayHandler(argv) {
117
+ const r = await messagingPut(jobPayPath(argv.jobId), buildJobPayBody({ min: argv.min, max: argv.max, currency: argv.currency, period: argv.period, clear: argv.clear }));
118
+ console.log(`\n${GRN}OK${RST} Job listing ${r.id} pay: ${r.display || '(not stated)'} (source: ${r.salarySource || 'none'}).\n`);
119
+ }
120
+
121
+ function printHosted(r) {
122
+ console.log(`\n${BOLD}Job Board hosted pages${RST}`);
123
+ if (!r.entitled) {
124
+ console.log(` Package: not included (the hosted pages, sitemap and feeds are off; admin calls return 403)\n`);
125
+ return;
126
+ }
127
+ console.log(` Package: ${GRN}included${RST}`);
128
+ console.log(` Published: ${r.enabled ? `${GRN}yes${RST}` : 'no (every hosted link answers 404)'}`);
129
+ console.log(` Open listings:${' '}${r.liveListings ?? 0}`);
130
+ if (r.boardUrl) {
131
+ console.log(` Board link: ${r.boardUrl}`);
132
+ console.log(` Sitemap: ${r.sitemapUrl}`);
133
+ console.log(` Indeed feed: ${r.indeedFeedUrl}`);
134
+ console.log(` Google feed: ${r.googleFeedUrl}`);
135
+ console.log(` ${DIM}The feed URLs contain a secret key; treat them like a password.${RST}`);
136
+ }
137
+ console.log(` Apply form: ${r.publicApplyEnabled ? 'on' : 'off (candidates are sent to each listing\'s apply link)'}${r.publicApplyEnabled && r.eeocSelfIdAvailable ? ', with voluntary EEO self-ID' : ''}\n`);
138
+ }
139
+
140
+ async function hostedHandler(argv) {
141
+ const body = buildHostedBody({ enabled: argv.enabled, regenerateLink: argv.regenerateLink });
142
+ const r = Object.keys(body).length ? await messagingPut('/Recruiting/Hosted', body) : await messagingGet('/Recruiting/Hosted');
143
+ printHosted(r);
144
+ }
145
+
146
+ async function hostedRotateKeyHandler() {
147
+ const r = await messagingPost('/Recruiting/Hosted/FeedKey/Rotate', {});
148
+ console.log(`\n${GRN}OK${RST} Feed key rotated; the previous Indeed and Google feed URLs no longer work.`);
149
+ printHosted(r);
150
+ }
151
+
94
152
  async function applicationsListHandler() {
95
153
  const apps = await messagingGet('/Recruiting/Applications');
96
154
  if (!Array.isArray(apps) || !apps.length) {
@@ -152,8 +210,8 @@ async function botSettingsHandler(argv) {
152
210
  }
153
211
 
154
212
  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`);
213
+ const r = await messagingPut('/Recruiting/ApplySettings', buildApplySettingsBody({ publicApply: argv.publicApply, caseTypeId: argv.caseTypeId, defaultListingDays: argv.defaultListingDays }));
214
+ console.log(`\n${GRN}OK${RST} Apply settings: publicApplyEnabled=${r.publicApplyEnabled}, autoCreateCaseTypeId=${r.autoCreateCaseTypeId ?? '(none)'}, defaultListingDays=${r.defaultListingDays ?? '(unchanged)'}\n`);
157
215
  }
158
216
 
159
217
  // Resume is a binary download; fetch raw (messagingGet JSON-parses) with the same auth/site header.
@@ -216,19 +274,65 @@ const jobsCommand = {
216
274
  builder: (y2) => y2
217
275
  .option('search', { describe: 'Free-text search across title/description', type: 'string' })
218
276
  .option('location', { describe: 'Filter by location', type: 'string' })
219
- .option('department', { describe: 'Filter by department', type: 'string' }),
277
+ .option('department', { describe: 'Filter by department', type: 'string' })
278
+ .option('all', { describe: 'Include closed, paused and expired listings (needs the Job Board lifecycle package)', type: 'boolean' }),
220
279
  handler: runCommand(jobsListHandler),
221
280
  })
281
+ .command({
282
+ command: 'close <jobId>',
283
+ describe: 'Close a listing (gone from search and the public board; its public page and applying answer 410). Needs the Job Board lifecycle package',
284
+ builder: (y2) => y2.positional('jobId', { describe: 'Job listing ID', type: 'number' }),
285
+ handler: runCommand(jobsCloseHandler),
286
+ })
287
+ .command({
288
+ command: 'renew <jobId>',
289
+ describe: 'Renew a listing: extend its expiry and republish it. Needs the Job Board lifecycle package',
290
+ builder: (y2) => y2
291
+ .positional('jobId', { describe: 'Job listing ID', type: 'number' })
292
+ .option('days', { describe: 'Days to extend by (1-365; default: the site default listing days)', type: 'number' }),
293
+ handler: runCommand(jobsRenewHandler),
294
+ })
295
+ .command({
296
+ command: 'lifecycle',
297
+ describe: 'Show whether this site has the Job Board lifecycle package and its default listing days',
298
+ handler: runCommand(jobsLifecycleHandler),
299
+ })
300
+ .command({
301
+ command: 'pay <jobId>',
302
+ describe: 'Set or clear the pay range shown on the hosted page and in its structured data. Needs the Job Board hosted pages package',
303
+ builder: (y2) => y2
304
+ .positional('jobId', { describe: 'Job listing ID', type: 'number' })
305
+ .option('min', { describe: 'Minimum (or only) amount', type: 'number' })
306
+ .option('max', { describe: 'Maximum amount (optional)', type: 'number' })
307
+ .option('currency', { describe: '3-letter ISO currency (default USD)', type: 'string' })
308
+ .option('period', { describe: 'Hour | Day | Week | Month | Year', type: 'string' })
309
+ .option('clear', { describe: 'Remove the stated pay', type: 'boolean' }),
310
+ handler: runCommand(jobsPayHandler),
311
+ })
222
312
  .command({
223
313
  command: 'delete <jobId>',
224
314
  describe: 'Delete a synced job listing',
225
315
  builder: (y2) => y2.positional('jobId', { describe: 'Job listing ID', type: 'number' }),
226
316
  handler: runCommand(jobsDeleteHandler),
227
317
  })
228
- .demandCommand(1, 'Specify a jobs subcommand: list | delete'),
318
+ .demandCommand(1, 'Specify a jobs subcommand: list | close | renew | lifecycle | pay | delete'),
229
319
  handler: () => {},
230
320
  };
231
321
 
322
+ const hostedCommand = {
323
+ command: 'hosted',
324
+ describe: 'Hosted public job pages: show or change the board settings (needs the Job Board hosted pages package)',
325
+ builder: (y) => y
326
+ .option('enabled', { describe: 'Publish (true) or unpublish (false) the hosted pages, sitemap and feeds', type: 'boolean' })
327
+ .option('regenerate-link', { describe: 'Issue a NEW opaque board link; every old hosted URL answers 404 (administrators)', type: 'boolean' })
328
+ .command({
329
+ command: 'rotate-key',
330
+ describe: 'Rotate the outbound feed key; the old Indeed and Google feed URLs stop working (administrators)',
331
+ handler: runCommand(hostedRotateKeyHandler),
332
+ }),
333
+ handler: runCommand(hostedHandler),
334
+ };
335
+
232
336
  const applicationsCommand = {
233
337
  command: 'applications <subcommand>',
234
338
  describe: 'List candidate applications or export EEOC voluntary self-ID data',
@@ -262,7 +366,8 @@ const applicationsCommand = {
262
366
  describe: 'Set public apply form options (apply-if-present)',
263
367
  builder: (y2) => y2
264
368
  .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' }),
369
+ .option('case-type-id', { describe: 'Case type to auto-create a case per application (0 clears)', type: 'number' })
370
+ .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' }),
266
371
  handler: runCommand(applySettingsHandler),
267
372
  })
268
373
  .command({
@@ -453,10 +558,11 @@ export const recruitingCommand = {
453
558
  .command(syncCommand)
454
559
  .command(configCommand)
455
560
  .command(jobsCommand)
561
+ .command(hostedCommand)
456
562
  .command(applicationsCommand)
457
563
  .command(sourcesCommand)
458
564
  .command(pipelineCommand)
459
- .demandCommand(1, 'Specify a recruiting subcommand: sync | config | jobs | applications | sources | pipeline')
565
+ .demandCommand(1, 'Specify a recruiting subcommand: sync | config | jobs | hosted | applications | sources | pipeline')
460
566
  .strict(),
461
567
  handler: () => {},
462
568
  };
@@ -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', '/avatar/groups', '/avatar/workflows', '/avatar/group-overrides'];
13
13
 
14
14
  test('messaging-only routes are never called through the admin api helpers', () => {
15
15
  const offenders = [];
@@ -8,8 +8,63 @@ export function applicationIdPath(id, suffix) {
8
8
  return `/Recruiting/Applications/${n}/${suffix}`;
9
9
  }
10
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
+ // Job Board hosted pages (package job-board-hosted): PUT Recruiting/Jobs/{id}/Pay.
20
+ export function jobPayPath(id) {
21
+ const n = Number(id);
22
+ if (!Number.isInteger(n) || n <= 0) throw new Error('jobId must be a positive integer.');
23
+ return `/Recruiting/Jobs/${n}/Pay`;
24
+ }
25
+
26
+ const PAY_PERIODS = ['Hour', 'Day', 'Week', 'Month', 'Year'];
27
+
28
+ // Body for PUT Recruiting/Jobs/{id}/Pay. { clear: true } removes the stated pay; otherwise min + period are required
29
+ // (max optional, currency defaults to USD). A manual value is never overwritten by a feed sync.
30
+ export function buildJobPayBody({ min, max, currency, period, clear } = {}) {
31
+ if (clear) return { clear: true };
32
+ const lo = Number(min);
33
+ if (min === undefined || !Number.isFinite(lo) || lo <= 0) throw new Error('min must be a number greater than 0 (or pass clear).');
34
+ const body = { salaryMin: lo };
35
+ if (max !== undefined) {
36
+ const hi = Number(max);
37
+ if (!Number.isFinite(hi) || hi < lo) throw new Error('max must be a number greater than or equal to min.');
38
+ body.salaryMax = hi;
39
+ }
40
+ const cur = String(currency ?? 'USD').trim().toUpperCase();
41
+ if (!/^[A-Z]{3}$/.test(cur)) throw new Error('currency must be a 3-letter ISO code such as USD.');
42
+ body.salaryCurrency = cur;
43
+ const per = PAY_PERIODS.find((p) => p.toLowerCase() === String(period ?? '').trim().toLowerCase());
44
+ if (!per) throw new Error(`period must be one of: ${PAY_PERIODS.join(', ')}.`);
45
+ body.salaryPeriod = per;
46
+ return body;
47
+ }
48
+
49
+ // Apply-if-present body for PUT Recruiting/Hosted: { enabled?, regenerateSlug? }. Omit both to only read the settings.
50
+ export function buildHostedBody({ enabled, regenerateLink } = {}) {
51
+ const body = {};
52
+ if (enabled !== undefined) body.enabled = Boolean(enabled);
53
+ if (regenerateLink) body.regenerateSlug = true;
54
+ return body;
55
+ }
56
+
57
+ // Body for POST Recruiting/Jobs/{id}/renew. Omit days to use the site default (RecruitingConfig.DefaultListingDays).
58
+ export function buildRenewBody(days) {
59
+ if (days === undefined) return {};
60
+ const n = Number(days);
61
+ if (!Number.isInteger(n) || n < 1 || n > 365) throw new Error('days must be an integer from 1 to 365.');
62
+ return { days: n };
63
+ }
64
+
11
65
  // Apply-if-present body for PUT /Recruiting/ApplySettings. caseTypeId 0 clears the auto-case link.
12
- export function buildApplySettingsBody({ publicApply, caseTypeId } = {}) {
66
+ // defaultListingDays (1-365) needs the Job Board lifecycle package (the server answers 403 otherwise).
67
+ export function buildApplySettingsBody({ publicApply, caseTypeId, defaultListingDays } = {}) {
13
68
  const body = {};
14
69
  if (publicApply !== undefined) body.publicApplyEnabled = Boolean(publicApply);
15
70
  if (caseTypeId !== undefined) {
@@ -17,7 +72,12 @@ export function buildApplySettingsBody({ publicApply, caseTypeId } = {}) {
17
72
  if (!Number.isInteger(n) || n < 0) throw new Error('caseTypeId must be an integer >= 0 (0 clears).');
18
73
  body.autoCreateCaseTypeId = n;
19
74
  }
20
- if (!Object.keys(body).length) throw new Error('Specify at least one of publicApply, caseTypeId.');
75
+ if (defaultListingDays !== undefined) {
76
+ const d = Number(defaultListingDays);
77
+ if (!Number.isInteger(d) || d < 1 || d > 365) throw new Error('defaultListingDays must be an integer from 1 to 365.');
78
+ body.defaultListingDays = d;
79
+ }
80
+ if (!Object.keys(body).length) throw new Error('Specify at least one of publicApply, caseTypeId, defaultListingDays.');
21
81
  return body;
22
82
  }
23
83
 
@@ -1,6 +1,29 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { applicationIdPath, buildApplySettingsBody, buildBotSettingsBody } from './recruiting-apply.js';
3
+ import { applicationIdPath, buildApplySettingsBody, buildBotSettingsBody, buildHostedBody, buildJobPayBody, buildRenewBody, jobLifecyclePath, jobPayPath } 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
+ });
4
27
 
5
28
  test('application paths validate id', () => {
6
29
  assert.equal(applicationIdPath(12, 'AtsRetry'), '/Recruiting/Applications/12/AtsRetry');
@@ -26,3 +49,30 @@ test('bot settings body is apply-if-present and validated', () => {
26
49
  assert.throws(() => buildBotSettingsBody({ maxResults: 11 }));
27
50
  assert.throws(() => buildBotSettingsBody({ greeting: 'g'.repeat(501) }));
28
51
  });
52
+
53
+ // -- Job Board hosted pages (Stage 2) -----------------------------------------
54
+
55
+ test('job pay path validates the id', () => {
56
+ assert.equal(jobPayPath(7), '/Recruiting/Jobs/7/Pay');
57
+ assert.throws(() => jobPayPath('x'));
58
+ assert.throws(() => jobPayPath(0));
59
+ });
60
+
61
+ test('job pay body: range, currency default and period are validated like the server', () => {
62
+ assert.deepEqual(buildJobPayBody({ min: 50000, max: 70000, period: 'year' }), { salaryMin: 50000, salaryMax: 70000, salaryCurrency: 'USD', salaryPeriod: 'Year' });
63
+ assert.deepEqual(buildJobPayBody({ min: '18.5', currency: 'cad', period: 'HOUR' }), { salaryMin: 18.5, salaryCurrency: 'CAD', salaryPeriod: 'Hour' });
64
+ assert.deepEqual(buildJobPayBody({ clear: true }), { clear: true });
65
+ assert.throws(() => buildJobPayBody({ period: 'Year' }));
66
+ assert.throws(() => buildJobPayBody({ min: 0, period: 'Year' }));
67
+ assert.throws(() => buildJobPayBody({ min: 10, max: 5, period: 'Year' }));
68
+ assert.throws(() => buildJobPayBody({ min: 10, currency: 'US', period: 'Year' }));
69
+ assert.throws(() => buildJobPayBody({ min: 10, period: 'Fortnight' }));
70
+ assert.throws(() => buildJobPayBody({ min: 10 }));
71
+ });
72
+
73
+ test('hosted body is apply-if-present; no flags means read-only', () => {
74
+ assert.deepEqual(buildHostedBody({}), {});
75
+ assert.deepEqual(buildHostedBody({ enabled: true }), { enabled: true });
76
+ assert.deepEqual(buildHostedBody({ enabled: false }), { enabled: false });
77
+ assert.deepEqual(buildHostedBody({ regenerateLink: true }), { regenerateSlug: true });
78
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velaro/cli",
3
- "version": "1.4.90",
3
+ "version": "1.4.92",
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": {