@velaro/cli 1.4.94 → 1.4.96

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.
@@ -0,0 +1,32 @@
1
+ // Avatar body-motion settings (velaro-messaging AvatarExperienceSettings): shared validation for the CLI.
2
+ // level: off | subtle | natural | expressive ("" = reset to the platform default, "natural");
3
+ // intensity: whole percent 25..150 multiplying every motion amplitude.
4
+ export const MOTION_LEVELS = ['off', 'subtle', 'natural', 'expressive'];
5
+ export const MOTION_INTENSITY_MIN = 25;
6
+ export const MOTION_INTENSITY_MAX = 150;
7
+
8
+ /** Returns the normalized level ('' allowed = reset) or throws a readable error. */
9
+ export function parseMotionLevelArg(raw) {
10
+ if (raw === undefined) return undefined;
11
+ const v = String(raw).trim().toLowerCase();
12
+ if (v === '') return '';
13
+ if (!MOTION_LEVELS.includes(v)) throw new Error(`--motion-level must be one of ${MOTION_LEVELS.join(', ')} (or empty to reset to the default)`);
14
+ return v;
15
+ }
16
+
17
+ export function parseMotionIntensityArg(raw) {
18
+ if (raw === undefined) return undefined;
19
+ const n = Number(raw);
20
+ if (!Number.isInteger(n) || n < MOTION_INTENSITY_MIN || n > MOTION_INTENSITY_MAX)
21
+ throw new Error(`--motion-intensity must be a whole number between ${MOTION_INTENSITY_MIN} and ${MOTION_INTENSITY_MAX}`);
22
+ return n;
23
+ }
24
+
25
+ /** Adds motionLevel / motionIntensityPercent to a request body only when the caller passed them. */
26
+ export function applyMotionArgs(body, argv) {
27
+ const level = parseMotionLevelArg(argv['motion-level']);
28
+ const intensity = parseMotionIntensityArg(argv['motion-intensity']);
29
+ if (level !== undefined) body.motionLevel = level;
30
+ if (intensity !== undefined) body.motionIntensityPercent = intensity;
31
+ return body;
32
+ }
@@ -0,0 +1,35 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import { applyMotionArgs, parseMotionLevelArg, parseMotionIntensityArg } from './avatar-motion.js';
5
+
6
+ test('level accepts the four presets, normalizes case, and empty resets', () => {
7
+ assert.equal(parseMotionLevelArg('Expressive'), 'expressive');
8
+ assert.equal(parseMotionLevelArg(''), '');
9
+ assert.equal(parseMotionLevelArg(undefined), undefined);
10
+ assert.throws(() => parseMotionLevelArg('wild'), /off, subtle, natural, expressive/);
11
+ });
12
+
13
+ test('intensity must be a whole number 25-150', () => {
14
+ assert.equal(parseMotionIntensityArg(25), 25);
15
+ assert.equal(parseMotionIntensityArg(150), 150);
16
+ assert.throws(() => parseMotionIntensityArg(24), /25 and 150/);
17
+ assert.throws(() => parseMotionIntensityArg(151), /25 and 150/);
18
+ assert.throws(() => parseMotionIntensityArg(80.5), /whole number/);
19
+ });
20
+
21
+ test('only supplied motion args reach the request body', () => {
22
+ assert.deepEqual(applyMotionArgs({}, {}), {});
23
+ assert.deepEqual(applyMotionArgs({ enabled: true }, { 'motion-level': 'subtle' }), { enabled: true, motionLevel: 'subtle' });
24
+ assert.deepEqual(applyMotionArgs({}, { 'motion-level': '', 'motion-intensity': 60 }), { motionLevel: '', motionIntensityPercent: 60 });
25
+ });
26
+
27
+ test('avatar command wires the helper on enable and set, and override accepts --motion-level', async () => {
28
+ const src = fs.readFileSync(new URL('./commands/avatar.js', import.meta.url), 'utf8');
29
+ assert.match(src, /applyMotionArgs\(body, argv\)/);
30
+ assert.match(src, /'motion-level'/);
31
+ const { overrideBody } = await import('./avatar-override.js');
32
+ assert.deepEqual(overrideBody({ 'motion-level': 'Subtle' }), { merge: true, motionLevel: 'subtle' });
33
+ assert.deepEqual(overrideBody({ 'motion-level': '' }), { merge: true, motionLevel: '' });
34
+ assert.throws(() => overrideBody({ 'motion-level': 'wild' }), /off, subtle, natural, expressive/);
35
+ });
@@ -2,6 +2,8 @@
2
2
  // Per-group / per-workflow avatar + backdrop overrides live in velaro-messaging (VelaroAvatarController);
3
3
  // effective = perWorkflow ?? perGroup ?? perSite. Kept dependency-free so it is unit-testable.
4
4
 
5
+ import { parseMotionLevelArg } from './avatar-motion.js';
6
+
5
7
  export const BACKDROP_MODES = ['none', 'color', 'gradient', 'image'];
6
8
 
7
9
  const HEX = /^#[0-9a-fA-F]{3,8}$/;
@@ -33,15 +35,17 @@ export function overrideBody(argv) {
33
35
  'display-name': 'displayName',
34
36
  backdrop: 'backgroundMode',
35
37
  'backdrop-value': 'backgroundValue',
38
+ 'motion-level': 'motionLevel',
36
39
  };
37
40
  for (const [flag, key] of Object.entries(map)) {
38
41
  if (argv[flag] !== undefined) body[key] = String(argv[flag]);
39
42
  }
43
+ if (body.motionLevel !== undefined) body.motionLevel = parseMotionLevelArg(body.motionLevel);
40
44
  const err = validateBackdrop(body.backgroundMode, body.backgroundValue);
41
45
  if (err) throw new Error(err);
42
46
  if (body.backgroundMode === undefined && body.backgroundValue !== undefined && body.backgroundValue !== '')
43
47
  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');
48
+ if (Object.keys(body).length === 1) throw new Error('Pass at least one of --avatar --personality --voice --display-name --backdrop --backdrop-value --provider --motion-level');
45
49
  return body;
46
50
  }
47
51
 
@@ -1,5 +1,6 @@
1
1
  import { request, messagingGet, messagingPut, messagingDel } from '../api.js';
2
2
  import { overrideBody, scopePath } from '../avatar-override.js';
3
+ import { applyMotionArgs } from '../avatar-motion.js';
3
4
  import { runCommand } from '../run.js';
4
5
 
5
6
  // ── velaro avatar ─────────────────────────────────────────────────────────────
@@ -34,6 +35,7 @@ export const avatarCommand = {
34
35
  console.log(` Display name: ${data.displayName || '(AI)'}`);
35
36
  console.log(` Badge shown: ${data.showBadge ? 'yes' : 'no'}`);
36
37
  console.log(` Size: ${data.sizePx}px`);
38
+ console.log(` Body motion: ${data.motionLevel || '(default: natural)'} @ ${data.motionIntensityPercent ?? 100}%`);
37
39
  console.log(` Max minutes/mo: ${data.maxAvatarMinutesPerMonth ?? '(plan default)'}`);
38
40
  console.log(` Max TTS chars/mo: ${data.maxAvatarTtsChars ?? '(plan default)'}\n`);
39
41
  }),
@@ -50,7 +52,9 @@ export const avatarCommand = {
50
52
  .option('call-me', { type: 'boolean', describe: 'Show the "Call me instead" option. Omit on a re-run to leave it untouched.' })
51
53
  .option('display-name', { type: 'string', describe: 'Visitor-facing avatar name on the badge (e.g. Moshky). Empty string resets to "AI".' })
52
54
  .option('show-badge', { type: 'boolean', describe: 'Show the name badge over the avatar video.' })
53
- .option('size', { type: 'number', describe: 'Avatar panel max width in px (160-640). Default 360.' }),
55
+ .option('size', { type: 'number', describe: 'Avatar panel max width in px (160-640). Default 360.' })
56
+ .option('motion-level', { type: 'string', describe: 'Body motion: off | subtle | natural | expressive. Empty string resets to the default (natural).' })
57
+ .option('motion-intensity', { type: 'number', describe: 'Body motion strength percent (25-150). Default 100.' }),
54
58
  handler: runCommand(async argv => {
55
59
  if (argv.bot) {
56
60
  console.log(`\nNote: --bot is not yet supported by this endpoint — this sets the SITE-level avatar config only. Use the admin UI's per-bot override toggle for bot-specific control.\n`);
@@ -66,6 +70,7 @@ export const avatarCommand = {
66
70
  if (argv['display-name'] !== undefined) body.displayName = argv['display-name'];
67
71
  if (argv['show-badge'] !== undefined) body.showBadge = argv['show-badge'];
68
72
  if (argv.size !== undefined) body.sizePx = argv.size;
73
+ applyMotionArgs(body, argv);
69
74
  const data = await request('POST', `/Avatar/superadmin/config?siteId=${argv.site}`, body);
70
75
  if (!data.success) throw new Error(data.error || 'Configure failed');
71
76
  console.log(`\n✓ AI Video Avatar enabled for site ${argv.site}`);
@@ -89,18 +94,22 @@ export const avatarCommand = {
89
94
  builder: y => y
90
95
  .option('site', { type: 'number', demandOption: true, describe: 'Site ID' })
91
96
  .option('max-minutes', { type: 'number', describe: 'Monthly avatar video minutes cap (whole number 1-100000)' })
92
- .option('max-tts-chars', { type: 'number', describe: 'Monthly avatar TTS character cap (whole number 1-100000000)' }),
97
+ .option('max-tts-chars', { type: 'number', describe: 'Monthly avatar TTS character cap (whole number 1-100000000)' })
98
+ .option('motion-level', { type: 'string', describe: 'Body motion: off | subtle | natural | expressive. Empty string resets to the default.' })
99
+ .option('motion-intensity', { type: 'number', describe: 'Body motion strength percent (25-150).' }),
93
100
  handler: runCommand(async argv => {
94
101
  const body = {};
102
+ applyMotionArgs(body, argv);
95
103
  for (const [opt, key, max] of [['max-minutes', 'maxAvatarMinutesPerMonth', 100000], ['max-tts-chars', 'maxAvatarTtsChars', 100000000]]) {
96
104
  if (argv[opt] === undefined) continue;
97
105
  if (!Number.isInteger(argv[opt]) || argv[opt] < 1 || argv[opt] > max) throw new Error(`--${opt} must be a whole number between 1 and ${max} (never unlimited)`);
98
106
  body[key] = argv[opt];
99
107
  }
100
- if (!Object.keys(body).length) throw new Error('Provide --max-minutes and/or --max-tts-chars');
108
+ if (!Object.keys(body).length) throw new Error('Provide --max-minutes, --max-tts-chars, --motion-level and/or --motion-intensity');
101
109
  const data = await request('POST', `/Avatar/superadmin/config?siteId=${argv.site}`, body);
102
110
  if (!data.success) throw new Error(data.error || 'Set avatar caps failed');
103
- console.log(`\n✓ Avatar caps updated for site ${argv.site}`);
111
+ console.log(`\n✓ Avatar settings updated for site ${argv.site}`);
112
+ console.log(` Body motion: ${data.motionLevel || '(default: natural)'} @ ${data.motionIntensityPercent ?? 100}%`);
104
113
  console.log(` Max minutes/mo: ${data.maxAvatarMinutesPerMonth}`);
105
114
  console.log(` Max TTS chars/mo: ${data.maxAvatarTtsChars}\n`);
106
115
  }),
@@ -118,6 +127,7 @@ export const avatarCommand = {
118
127
  .option('backdrop', { type: 'string', choices: ['none', 'color', 'gradient', 'image', ''], describe: 'Backdrop type. none = explicitly no backdrop; empty string = inherit.' })
119
128
  .option('backdrop-value', { type: 'string', describe: 'Hex color, "#a,#b" gradient, or https image URL' })
120
129
  .option('provider', { type: 'string', describe: 'Provider slug (a library avatar implies velaro)' })
130
+ .option('motion-level', { type: 'string', describe: 'Body motion: off | subtle | natural | expressive. Empty string clears back to inherit.' })
121
131
  .option('replace', { type: 'boolean', describe: 'Replace the whole override instead of changing only the flags passed' }),
122
132
  handler: runCommand(async argv => {
123
133
  const base = scopePath(argv);
@@ -125,7 +135,7 @@ export const avatarCommand = {
125
135
  if (!data?.success) throw new Error(data?.error || 'Override failed');
126
136
  const eff = await messagingGet(`${base}/effective`);
127
137
  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}
138
+ ✓ Override saved. Effective now: motion=${eff.motionLevel ?? '(default)'} avatar=${eff.avatarId ?? '(default)'} backdrop=${eff.backgroundMode}${eff.backgroundValue ? ' ' + eff.backgroundValue : ''} name=${eff.displayName ?? '(AI)'} layer=${eff.layer}
129
139
  `);
130
140
  }),
131
141
  })
@@ -1,5 +1,6 @@
1
1
  import { getCredentials } from '../api.js';
2
2
  import { readConfig, ENVS } from '../config.js';
3
+ import { completeLogSearch, describeCompleteness, describeRawMeta } from '../log-search.js';
3
4
 
4
5
  // ── helpers ──────────────────────────────────────────────────────────────────
5
6
 
@@ -60,39 +61,61 @@ async function searchLogs(argv) {
60
61
  await requireAdmin(creds);
61
62
  const msgBase = await getMsgBase(argv);
62
63
 
63
- const from = new Date(Date.now() - parseDuration(argv.last ?? '2h'));
64
- const params = new URLSearchParams({ from: from.toISOString(), take: String(argv.take ?? 50) });
65
- if (argv.query) params.set('q', argv.query);
66
- if (argv.level) params.set('level', argv.level.toUpperCase());
67
- if (argv.integration) params.set('integration', argv.integration);
68
- if (argv.site) params.set('siteId', String(argv.site));
69
- if (argv.source) params.set('source', argv.source);
70
- if (argv.platform) params.set('platform', argv.platform);
71
- if (argv.environment) params.set('environment', argv.environment);
72
- if (argv.correlationId) params.set('correlationId', argv.correlationId);
73
- if (argv.regex) params.set('regex', 'true');
74
-
75
- const res = await fetch(`${msgBase}/superadmin/logs/search?${params}`, {
76
- headers: {
77
- Authorization: `Bearer ${creds.velaroToken}`,
78
- 'X-Internal-SiteId': String(creds.siteId),
79
- },
80
- signal: AbortSignal.timeout(20000),
81
- });
82
-
83
- if (!res.ok) {
84
- console.error(`HTTP ${res.status}: ${await res.text().catch(() => '')}`);
64
+ const to = argv.to ? new Date(argv.to) : new Date();
65
+ const from = argv.from ? new Date(argv.from) : new Date(to.getTime() - parseDuration(argv.last ?? '2h'));
66
+ if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime()) || to <= from) {
67
+ console.error('Invalid time window: --from/--to must be ISO timestamps with --to after --from.');
85
68
  process.exit(1);
86
69
  }
70
+ const headers = { Authorization: `Bearer ${creds.velaroToken}`, 'X-Internal-SiteId': String(creds.siteId) };
71
+ const getJson = async (query) => {
72
+ const res = await fetch(`${msgBase}/superadmin/logs/search?${query}`, { headers, signal: AbortSignal.timeout(30000) });
73
+ if (!res.ok) {
74
+ console.error(`HTTP ${res.status}: ${await res.text().catch(() => '')}`);
75
+ process.exit(1);
76
+ }
77
+ return res.json();
78
+ };
87
79
 
88
- const entries = extractLogEntries(await res.json());
89
- if (!entries.length) {
90
- console.log('No results.');
80
+ // --raw: the server's own q= search (kept for parity). Proven 2026-10-14 to under-report (q=[HttpFallback]
81
+ // returned 0-2 rows while 136 existed), so it now always prints the server's searchPath/scannedRows/truncated.
82
+ if (argv.raw) {
83
+ const params = new URLSearchParams({ from: from.toISOString(), to: to.toISOString(), take: String(argv.take ?? 50) });
84
+ if (argv.query) params.set('q', argv.query);
85
+ if (argv.level) params.set('level', argv.level.toUpperCase());
86
+ if (argv.integration) params.set('integration', argv.integration);
87
+ if (argv.site) params.set('siteId', String(argv.site));
88
+ if (argv.source) params.set('source', argv.source);
89
+ if (argv.platform) params.set('platform', argv.platform);
90
+ if (argv.environment) params.set('environment', argv.environment);
91
+ if (argv.correlationId) params.set('correlationId', argv.correlationId);
92
+ if (argv.regex) params.set('regex', 'true');
93
+ const resp = await getJson(params.toString());
94
+ const entries = extractLogEntries(resp);
95
+ console.log(`${DIM}${describeRawMeta(resp)}${RESET}`);
96
+ if (!entries.length) { console.log('No results (raw server search; see the line above before trusting that).'); return; }
97
+ console.log(`\n${DIM}${entries.length} result(s) from ${from.toISOString().slice(0, 16)} UTC${RESET}\n`);
98
+ for (const e of entries) printEntry(e);
99
+ console.log('');
91
100
  return;
92
101
  }
93
102
 
94
- console.log(`\n${DIM}${entries.length} result(s) from ${from.toISOString().slice(0, 16)} UTC${RESET}\n`);
95
- for (const e of entries) printEntry(e);
103
+ // Default: COMPLETE read. Never sends q=: filters level/site/source/... server side, bisects any slice the
104
+ // server reports as capped (includeTotal), filters text client side, and says COMPLETE or INCOMPLETE.
105
+ const result = await completeLogSearch({
106
+ fetchJson: getJson,
107
+ from, to,
108
+ params: { level: argv.level, siteId: argv.site, source: argv.source, integration: argv.integration, platform: argv.platform, environment: argv.environment, correlationId: argv.correlationId },
109
+ contains: argv.regex ? undefined : argv.query,
110
+ regex: argv.regex ? argv.query : undefined,
111
+ logger: argv.logger,
112
+ maxRequests: argv.maxRequests ?? 200,
113
+ });
114
+ console.log(`${result.complete ? DIM : YEL}${describeCompleteness(result)}${RESET}`);
115
+ if (!result.entries.length) { console.log('No results.'); return; }
116
+ const shown = result.entries.slice(0, argv.take ?? 50);
117
+ console.log(`\n${DIM}showing ${shown.length} of ${result.entries.length} (newest first) from ${from.toISOString().slice(0, 16)} UTC${RESET}\n`);
118
+ for (const e of shown) printEntry(e);
96
119
  console.log('');
97
120
  }
98
121
 
@@ -696,7 +719,12 @@ export const logsCommand = {
696
719
  .option('correlationId', { describe: 'Exact correlation/trace ID filter', type: 'string' })
697
720
  .option('last', { describe: 'Time window: 30m, 2h, 1d', default: '2h', type: 'string' })
698
721
  .option('regex', { describe: 'Treat query as regex', boolean: true, default: false })
699
- .option('take', { describe: 'Max results (max 500)', default: 50, type: 'number' })
722
+ .option('logger', { describe: 'Logger-name substring (client side)', type: 'string' })
723
+ .option('from', { describe: 'ISO start time (overrides --last)', type: 'string' })
724
+ .option('to', { describe: 'ISO end time (default now)', type: 'string' })
725
+ .option('raw', { describe: 'Use the server q= search (can under-report; prints searchPath/scannedRows/truncated)', boolean: true, default: false })
726
+ .option('max-requests', { describe: 'Request budget for the complete read', default: 200, type: 'number' })
727
+ .option('take', { describe: 'Max lines to print (complete read) / server page size (--raw, max 500)', default: 50, type: 'number' })
700
728
  .option('env', { describe: 'staging or prod', type: 'string' }),
701
729
  handler: searchLogs,
702
730
  })
@@ -0,0 +1,163 @@
1
+ import { messagingGet, messagingPost, messagingPut, messagingDel } from '../api.js';
2
+ import { runCommand } from '../run.js';
3
+ import { buildEmployerBody, buildInviteBody, buildKeywordsBody, buildListingBody, buildReasonBody, employerPath, memberPath, moderationPath, normalizeRole } from '../recruiting-employers.js';
4
+
5
+ // -- velaro recruiting employers | moderation ----------------------------------
6
+ // Job Board Stage 5 (package job-board-employers; 403 job_board_employers_not_entitled without it). siteId is applied
7
+ // server-side from the vmsg-siteId header, never a CLI arg.
8
+ // Backend contract: server/Velaro.Messaging/Controllers/RecruitingEmployersController.cs
9
+ // GET Recruiting/Employers ; POST Recruiting/Employers { name, verifiedDomain? } ; PUT Recruiting/Employers/{id} { name?, verifiedDomain? }
10
+ // POST Recruiting/Employers/{id}/Suspend { reason } | /Reactivate ; GET Recruiting/Employers/{id}/Members
11
+ // POST Recruiting/Employers/{id}/Invite { email, displayName?, role } ; PUT|DELETE Recruiting/Employers/{id}/Members/{memberId}
12
+ // GET|POST Recruiting/Employers/{id}/Listings (POST may submit: goes through the same moderation as the employer portal)
13
+ // GET Recruiting/Moderation ; POST Recruiting/Moderation/{id}/Approve | /Reject { reason } ; GET .../{id}/Events ; GET|PUT Recruiting/Moderation/Keywords
14
+
15
+ const BOLD = '\x1b[1m';
16
+ const DIM = '\x1b[2m';
17
+ const RST = '\x1b[0m';
18
+
19
+ const show = (r) => console.log(JSON.stringify(r, null, 2));
20
+
21
+ async function listHandler() {
22
+ const rows = await messagingGet('/Recruiting/Employers');
23
+ if (!Array.isArray(rows) || !rows.length) return console.log('\n No employers yet.\n');
24
+ console.log(`\n${BOLD}Employers${RST} (${rows.length})\n`);
25
+ for (const e of rows) {
26
+ console.log(` ${DIM}#${e.id}${RST} ${e.name} ${DIM}${e.status}${e.trusted ? ', trusted' : ', first posting under review'} / members ${e.memberCount} / live ${e.liveListingCount} of ${e.listingCount} / pending ${e.pendingModerationCount}${RST}`);
27
+ }
28
+ console.log('');
29
+ }
30
+
31
+ const employersCommand = {
32
+ command: 'employers <subcommand>',
33
+ describe: 'Job board employers: invite-only accounts that manage only their own listings (package job-board-employers)',
34
+ builder: (y) => y
35
+ .command({ command: 'list', describe: 'List employers with member, listing and pending counts', handler: runCommand(listHandler) })
36
+ .command({
37
+ command: 'create',
38
+ describe: 'Create an employer',
39
+ builder: (y2) => y2.option('name', { type: 'string', demandOption: true }).option('domain', { describe: 'Verified email domain (optional)', type: 'string' }),
40
+ handler: runCommand(async (a) => show(await messagingPost('/Recruiting/Employers', buildEmployerBody({ name: a.name, domain: a.domain })))),
41
+ })
42
+ .command({
43
+ command: 'update <employerId>',
44
+ describe: 'Rename an employer or change its verified domain',
45
+ builder: (y2) => y2.positional('employerId', { type: 'number' }).option('name', { type: 'string' }).option('domain', { type: 'string' }),
46
+ handler: runCommand(async (a) => show(await messagingPut(employerPath(a.employerId), buildEmployerBody({ name: a.name, domain: a.domain })))),
47
+ })
48
+ .command({
49
+ command: 'suspend <employerId>',
50
+ describe: 'Suspend an employer: members are signed out and published listings are paused',
51
+ builder: (y2) => y2.positional('employerId', { type: 'number' }).option('reason', { type: 'string', demandOption: true }),
52
+ handler: runCommand(async (a) => show(await messagingPost(employerPath(a.employerId, 'Suspend'), buildReasonBody(a.reason)))),
53
+ })
54
+ .command({
55
+ command: 'reactivate <employerId>',
56
+ describe: 'Reactivate a suspended employer (its paused listings stay paused)',
57
+ builder: (y2) => y2.positional('employerId', { type: 'number' }),
58
+ handler: runCommand(async (a) => show(await messagingPost(employerPath(a.employerId, 'Reactivate'), {}))),
59
+ })
60
+ .command({
61
+ command: 'members <employerId>',
62
+ describe: 'List an employer\'s members',
63
+ builder: (y2) => y2.positional('employerId', { type: 'number' }),
64
+ handler: runCommand(async (a) => show(await messagingGet(employerPath(a.employerId, 'Members')))),
65
+ })
66
+ .command({
67
+ command: 'invite <employerId>',
68
+ describe: 'Email a single-use invitation (valid 7 days)',
69
+ builder: (y2) => y2.positional('employerId', { type: 'number' })
70
+ .option('email', { type: 'string', demandOption: true })
71
+ .option('name', { type: 'string' })
72
+ .option('role', { describe: 'Owner | Recruiter | Viewer (default Recruiter)', type: 'string' }),
73
+ handler: runCommand(async (a) => show(await messagingPost(employerPath(a.employerId, 'Invite'), buildInviteBody({ email: a.email, name: a.name, role: a.role })))),
74
+ })
75
+ .command({
76
+ command: 'remove-member <employerId> <memberId>',
77
+ describe: 'Remove a member (their sessions end immediately)',
78
+ builder: (y2) => y2.positional('employerId', { type: 'number' }).positional('memberId', { type: 'number' }),
79
+ handler: runCommand(async (a) => show(await messagingDel(memberPath(a.employerId, a.memberId)))),
80
+ })
81
+ .command({
82
+ command: 'set-role <employerId> <memberId> <role>',
83
+ describe: 'Change a member role (Owner | Recruiter | Viewer)',
84
+ builder: (y2) => y2.positional('employerId', { type: 'number' }).positional('memberId', { type: 'number' }).positional('role', { type: 'string' }),
85
+ handler: runCommand(async (a) => show(await messagingPut(memberPath(a.employerId, a.memberId), { role: normalizeRole(a.role) }))),
86
+ })
87
+ .command({
88
+ command: 'listings <employerId>',
89
+ describe: 'List an employer\'s listings with status',
90
+ builder: (y2) => y2.positional('employerId', { type: 'number' }),
91
+ handler: runCommand(async (a) => show(await messagingGet(employerPath(a.employerId, 'Listings')))),
92
+ })
93
+ .command({
94
+ command: 'create-listing <employerId>',
95
+ describe: 'Create a listing for an employer (add --submit to publish; first postings and scam signals go to moderation)',
96
+ builder: (y2) => y2.positional('employerId', { type: 'number' })
97
+ .option('title', { type: 'string', demandOption: true })
98
+ .option('description', { type: 'string', demandOption: true })
99
+ .option('department', { type: 'string' })
100
+ .option('location', { type: 'string' })
101
+ .option('employment-type', { type: 'string' })
102
+ .option('apply-url', { type: 'string' })
103
+ .option('min', { type: 'number' })
104
+ .option('max', { type: 'number' })
105
+ .option('currency', { type: 'string' })
106
+ .option('period', { type: 'string' })
107
+ .option('submit', { type: 'boolean' }),
108
+ handler: runCommand(async (a) => show(await messagingPost(employerPath(a.employerId, 'Listings'), buildListingBody({
109
+ title: a.title, description: a.description, department: a.department, location: a.location, employmentType: a.employmentType,
110
+ applyUrl: a.applyUrl, min: a.min, max: a.max, currency: a.currency, period: a.period, submit: a.submit,
111
+ })))),
112
+ })
113
+ .demandCommand(1, 'Specify an employers subcommand: list | create | update | suspend | reactivate | members | invite | remove-member | set-role | listings | create-listing'),
114
+ handler: () => {},
115
+ };
116
+
117
+ const moderationCommand = {
118
+ command: 'moderation <subcommand>',
119
+ describe: 'Job board moderation queue: approve or reject employer listings, and the scam keyword list',
120
+ builder: (y) => y
121
+ .command({
122
+ command: 'list',
123
+ describe: 'Listings waiting for a decision',
124
+ handler: runCommand(async () => {
125
+ const rows = await messagingGet('/Recruiting/Moderation');
126
+ if (!Array.isArray(rows) || !rows.length) return console.log('\n Nothing is waiting for review.\n');
127
+ console.log(`\n${BOLD}Moderation queue${RST} (${rows.length})\n`);
128
+ for (const q of rows) {
129
+ console.log(` ${DIM}#${q.id}${RST} ${q.title} ${DIM}${q.employerName}${q.firstPosting ? ' / first posting' : ''}${q.flags ? ' / flags: ' + q.flags : ''}${RST}`);
130
+ }
131
+ console.log('');
132
+ }),
133
+ })
134
+ .command({
135
+ command: 'approve <listingId>',
136
+ describe: 'Approve a pending listing (it goes live and the employer becomes trusted)',
137
+ builder: (y2) => y2.positional('listingId', { type: 'number' }),
138
+ handler: runCommand(async (a) => show(await messagingPost(moderationPath(a.listingId, 'Approve'), {}))),
139
+ })
140
+ .command({
141
+ command: 'reject <listingId>',
142
+ describe: 'Reject a pending listing with a reason the employer will see',
143
+ builder: (y2) => y2.positional('listingId', { type: 'number' }).option('reason', { type: 'string', demandOption: true }),
144
+ handler: runCommand(async (a) => show(await messagingPost(moderationPath(a.listingId, 'Reject'), buildReasonBody(a.reason)))),
145
+ })
146
+ .command({
147
+ command: 'events <listingId>',
148
+ describe: 'Audit trail of one listing',
149
+ builder: (y2) => y2.positional('listingId', { type: 'number' }),
150
+ handler: runCommand(async (a) => show(await messagingGet(moderationPath(a.listingId, 'Events')))),
151
+ })
152
+ .command({ command: 'keywords', describe: 'Show the site\'s extra scam keywords and the built-in defaults', handler: runCommand(async () => show(await messagingGet('/Recruiting/Moderation/Keywords'))) })
153
+ .command({
154
+ command: 'set-keywords',
155
+ describe: 'Replace the site\'s extra keywords (added to the built-in list, which cannot be removed)',
156
+ builder: (y2) => y2.option('keywords', { describe: 'Comma or newline separated phrases; empty clears', type: 'string', demandOption: true }),
157
+ handler: runCommand(async (a) => show(await messagingPut('/Recruiting/Moderation/Keywords', buildKeywordsBody(a.keywords)))),
158
+ })
159
+ .demandCommand(1, 'Specify a moderation subcommand: list | approve | reject | events | keywords | set-keywords'),
160
+ handler: () => {},
161
+ };
162
+
163
+ export { employersCommand, moderationCommand };
@@ -1,6 +1,7 @@
1
1
  import { readFileSync, writeFileSync } from 'fs';
2
2
  import { messagingGet, messagingPost, messagingPut, messagingDel, getCredentials } from '../api.js';
3
3
  import { runCommand } from '../run.js';
4
+ import { employersCommand, moderationCommand } from './recruiting-employers.js';
4
5
  import { buildAssignBody, buildNoteBody, buildPipelineBody, buildRetentionBody, buildStageBody, applicationIdPath, buildApplySettingsBody, buildBotSettingsBody, buildHostedBody, buildJobPayBody, buildRenewBody, jobLifecyclePath, jobPayPath } from '../recruiting-apply.js';
5
6
 
6
7
  // -- velaro recruiting --------------------------------------------------------
@@ -19,6 +20,7 @@ import { buildAssignBody, buildNoteBody, buildPipelineBody, buildRetentionBody,
19
20
  // GET Recruiting/Jobs?includeAll=true -> every status (needs the Job Board lifecycle package)
20
21
  // POST Recruiting/Jobs/{id}/close | /renew { days? } ; GET Recruiting/Lifecycle -> { entitled, defaultListingDays } (403 without the package)
21
22
  // 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)
23
+ // GET Recruiting/Applicants -> { entitled, hostedEnabled, signInUrl, accounts, signedInLast30Days, deletionPending, savedJobs, linkedApplications } (package job-board-applicant-accounts; counts only)
22
24
  // GET Recruiting/Applications -> candidateApplication[]
23
25
  // GET Recruiting/Applications/AtsFailures; POST Recruiting/Applications/{id}/AtsRetry; PUT Recruiting/ApplySettings { publicApplyEnabled?, autoCreateCaseTypeId?, defaultListingDays? }; GET Recruiting/Applications/{id}/Resume (binary)
24
26
  // GET Recruiting/Applications/ExportEeoc -> text/csv (Professional tier+, EnableRecruitingBotEeoc)
@@ -146,6 +148,27 @@ async function hostedHandler(argv) {
146
148
  printHosted(r);
147
149
  }
148
150
 
151
+ function printApplicants(r) {
152
+ console.log(`\n${BOLD}Job Board applicant accounts${RST}`);
153
+ if (!r.entitled) {
154
+ console.log(` Package: not included (applicant sign-in, my applications, saved jobs and the resume library are off)\n`);
155
+ return;
156
+ }
157
+ console.log(` Package: ${GRN}included${RST}`);
158
+ console.log(` Hosted pages: ${r.hostedEnabled ? 'published' : 'not published (sign-in needs the hosted pages on)'}`);
159
+ if (r.signInUrl) console.log(` Sign-in page: ${r.signInUrl}`);
160
+ console.log(` Accounts: ${r.accounts ?? 0}`);
161
+ console.log(` Signed in, 30 days: ${r.signedInLast30Days ?? 0}`);
162
+ console.log(` Deletion pending: ${r.deletionPending ?? 0}`);
163
+ console.log(` Saved jobs: ${r.savedJobs ?? 0}`);
164
+ console.log(` Linked applications: ${r.linkedApplications ?? 0}`);
165
+ console.log(` ${DIM}Counts only. Applicant emails and resumes are never listed here.${RST}\n`);
166
+ }
167
+
168
+ async function applicantsHandler() {
169
+ printApplicants(await messagingGet('/Recruiting/Applicants'));
170
+ }
171
+
149
172
  async function hostedRotateKeyHandler() {
150
173
  const r = await messagingPost('/Recruiting/Hosted/FeedKey/Rotate', {});
151
174
  console.log(`\n${GRN}OK${RST} Feed key rotated; the previous Indeed and Google feed URLs no longer work.`);
@@ -336,6 +359,12 @@ const hostedCommand = {
336
359
  handler: runCommand(hostedHandler),
337
360
  };
338
361
 
362
+ const applicantsCommand = {
363
+ command: 'applicants',
364
+ describe: 'Applicant accounts on the hosted job board: package status, sign-in page and account counts (needs the Job Board applicant accounts package)',
365
+ handler: runCommand(applicantsHandler),
366
+ };
367
+
339
368
  const applicationsCommand = {
340
369
  command: 'applications <subcommand>',
341
370
  describe: 'List candidate applications or export EEOC voluntary self-ID data',
@@ -710,12 +739,15 @@ export const recruitingCommand = {
710
739
  .command(configCommand)
711
740
  .command(jobsCommand)
712
741
  .command(hostedCommand)
742
+ .command(applicantsCommand)
713
743
  .command(applicationsCommand)
714
744
  .command(sourcesCommand)
715
745
  .command(pipelineCommand)
716
746
  .command(applicationCommand)
717
747
  .command(retentionCommand)
718
- .demandCommand(1, 'Specify a recruiting subcommand: sync | config | jobs | hosted | applications | application | sources | pipeline | retention')
748
+ .command(employersCommand)
749
+ .command(moderationCommand)
750
+ .demandCommand(1, 'Specify a recruiting subcommand: sync | config | jobs | hosted | applicants | applications | application | sources | pipeline | retention | employers | moderation')
719
751
  .strict(),
720
752
  handler: () => {},
721
753
  };
@@ -0,0 +1,100 @@
1
+ // Complete Velaro Logger reads (2026-10-14).
2
+ //
3
+ // WHY: velaro-messaging's /superadmin/logs/search and /internal/logs/search silently under-report text queries.
4
+ // Proven live that day: q=[HttpFallback] over 3.9 h returned 0-2 rows while 136 existed, and q=AsrsNegotiateFailed
5
+ // (no level) returned 0 while 4 existed. The AI-search path only sees the newest 1,000 candidates and its answer was
6
+ // final; the exact path only sees the newest 5,000 rows of the window before filtering. Neither said so.
7
+ //
8
+ // HOW (no q= ever sent): level/siteId/source/integration/platform/environment/correlationId are pushed to the
9
+ // server (Table Storage OData, cheap and exact); includeTotal=true makes the server report totalCountCapped when a
10
+ // slice hit its scan cap; a capped slice is bisected until none is capped; text (contains/regex/logger) is
11
+ // filtered client side. The result says whether it is COMPLETE, so a 0 is never ambiguous.
12
+ //
13
+ // KEEP IN SYNC: this file is duplicated byte for byte in mcp/log-search.js (the npm packages ship separately);
14
+ // cli/lib/log-search.test.js fails if the two copies drift.
15
+
16
+ const MIN_SLICE_MS = 2000;
17
+
18
+ /**
19
+ * @param {object} o
20
+ * @param {(query: string) => Promise<any>} o.fetchJson GET `...search?<query>` and return parsed JSON (caller adds auth)
21
+ * @param {Date} o.from
22
+ * @param {Date} o.to
23
+ * @param {object} [o.params] server-side filters: level, siteId, source, integration, platform, environment, correlationId
24
+ * @param {string} [o.contains] case-insensitive substring of the message (client side)
25
+ * @param {string} [o.regex] regex on the message (client side)
26
+ * @param {string} [o.logger] substring of the logger name (client side)
27
+ * @param {number} [o.maxRequests=200]
28
+ */
29
+ export async function completeLogSearch({ fetchJson, from, to, params = {}, contains, regex, logger, maxRequests = 200 }) {
30
+ const server = {};
31
+ for (const k of ['level', 'siteId', 'source', 'integration', 'platform', 'environment', 'correlationId']) {
32
+ if (params[k] !== undefined && params[k] !== null && params[k] !== '') server[k] = String(k === 'level' ? String(params[k]).toUpperCase() : params[k]);
33
+ }
34
+ const byId = new Map();
35
+ const unresolved = [];
36
+ let requests = 0;
37
+ let slices = 0;
38
+ let scanned = 0;
39
+ let budgetExhausted = false;
40
+
41
+ const call = async (a, b, cursor, total) => {
42
+ if (requests >= maxRequests) { budgetExhausted = true; return null; }
43
+ requests++;
44
+ const q = new URLSearchParams({ from: a.toISOString(), to: b.toISOString(), take: '500', ...server });
45
+ if (total) q.set('includeTotal', 'true');
46
+ if (cursor) q.set('cursor', cursor);
47
+ const r = await fetchJson(q.toString());
48
+ return Array.isArray(r) ? { entries: r } : (r || {});
49
+ };
50
+
51
+ const walk = async (a, b) => {
52
+ const r = await call(a, b, null, true);
53
+ if (!r) { unresolved.push(`${a.toISOString()}..${b.toISOString()} (request budget)`); return; }
54
+ if (r.totalCountCapped === true && (b - a) > MIN_SLICE_MS) {
55
+ const mid = new Date(a.getTime() + Math.floor((b - a) / 2));
56
+ await walk(a, mid);
57
+ await walk(mid, b);
58
+ return;
59
+ }
60
+ if (r.totalCountCapped === true) unresolved.push(`${a.toISOString()}..${b.toISOString()} (still capped at <=2s)`);
61
+ slices++;
62
+ scanned += Number(r.totalCount ?? (r.entries?.length ?? 0));
63
+ for (const e of r.entries ?? []) byId.set(e.id ?? `${e.timestamp}|${e.message}`, e);
64
+ let cursor = r.nextCursor;
65
+ while (cursor) {
66
+ const r2 = await call(a, b, cursor, false);
67
+ if (!r2) { unresolved.push(`${a.toISOString()}..${b.toISOString()} (request budget while paging)`); break; }
68
+ for (const e of r2.entries ?? []) byId.set(e.id ?? `${e.timestamp}|${e.message}`, e);
69
+ cursor = r2.nextCursor;
70
+ }
71
+ };
72
+
73
+ await walk(from, to);
74
+
75
+ let rows = [...byId.values()];
76
+ const msg = (e) => String(e.message ?? e.Message ?? '');
77
+ if (contains) { const c = contains.toLowerCase(); rows = rows.filter((e) => msg(e).toLowerCase().includes(c)); }
78
+ if (regex) { const re = new RegExp(regex, 'i'); rows = rows.filter((e) => re.test(msg(e))); }
79
+ if (logger) { const l = logger.toLowerCase(); rows = rows.filter((e) => String(e.logger ?? e.Logger ?? '').toLowerCase().includes(l)); }
80
+ rows.sort((x, y) => String(y.timestamp ?? '').localeCompare(String(x.timestamp ?? '')));
81
+
82
+ return { entries: rows, fetched: byId.size, scanned, requests, slices, complete: unresolved.length === 0 && !budgetExhausted, unresolved, budgetExhausted };
83
+ }
84
+
85
+ /** One line a human or an LLM can trust: says COMPLETE or INCOMPLETE and how much was examined. */
86
+ export function describeCompleteness(r) {
87
+ const base = `${r.entries.length} matched of ${r.fetched} fetched (server scanned ${r.scanned}) in ${r.requests} request(s)/${r.slices} slice(s)`;
88
+ return r.complete
89
+ ? `${base}; COMPLETE (no slice hit the server scan cap), so 0 matches means nothing was logged.`
90
+ : `${base}; INCOMPLETE (${r.unresolved.length} slice(s) unresolved${r.budgetExhausted ? ', request budget exhausted' : ''}): narrow the window or add level/site; a short or empty result is NOT proof of absence.`;
91
+ }
92
+
93
+ /** For raw (server-side q=) responses: surfaces searchPath/scannedRows/truncated so a 0 is never ambiguous. */
94
+ export function describeRawMeta(resp) {
95
+ if (!resp || Array.isArray(resp) || resp.scannedRows === undefined) {
96
+ return 'server did not report scan metadata (older deploy): treat an empty or short result as unverified, use the complete mode.';
97
+ }
98
+ const t = resp.truncated === true;
99
+ return `searchPath=${resp.searchPath} scannedRows=${resp.scannedRows} truncated=${t}${t ? ' (NOT proof of absence; continue with nextCursor or narrow the window)' : ''}${resp.nextCursor ? ' nextCursor=present' : ''}`;
100
+ }
@@ -0,0 +1,129 @@
1
+ // Regression (2026-10-14): velaro-messaging's logs search under-reported text queries (q=[HttpFallback] returned 0-2 rows
2
+ // while 136 existed; q=AsrsNegotiateFailed with no level returned 0 while 4 existed) because each scan only examined the
3
+ // newest N rows of the window and never said so. completeLogSearch must find rows behind an arbitrary pile of newer
4
+ // rows, say COMPLETE only when no slice hit the server cap, and never send q=.
5
+ import test from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { completeLogSearch, describeCompleteness, describeRawMeta } from './log-search.js';
11
+
12
+ const here = path.dirname(fileURLToPath(import.meta.url));
13
+
14
+ /** Mock of the messaging search endpoint: newest-first scan capped at `cap` rows BEFORE any filter other than level/site. */
15
+ function mockServer(rows, cap = 5000) {
16
+ const calls = [];
17
+ const fetchJson = async (query) => {
18
+ const p = new URLSearchParams(query);
19
+ calls.push(p);
20
+ assert.equal(p.get('q'), null, 'the complete read must never send q=');
21
+ assert.equal(p.get('regex'), null);
22
+ const from = new Date(p.get('from')).getTime();
23
+ const to = new Date(p.get('to')).getTime();
24
+ const lvl = p.get('level');
25
+ const site = p.get('siteId');
26
+ // newest first, server-side filters first, then the fetch cap (the real bug), then in-window filter
27
+ let pool = rows.filter((r) => (!lvl || r.level === lvl) && (!site || String(r.siteId) === site));
28
+ pool = pool.filter((r) => new Date(r.timestamp).getTime() >= from && new Date(r.timestamp).getTime() <= to);
29
+ pool.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
30
+ const capped = pool.length >= cap;
31
+ const fetched = pool.slice(0, cap);
32
+ const take = Number(p.get('take'));
33
+ let start = 0;
34
+ if (p.get('cursor')) start = fetched.findIndex((r) => r.id === p.get('cursor')) + 1;
35
+ const page = fetched.slice(start, start + take);
36
+ const more = start + take < fetched.length;
37
+ const out = { entries: page, nextCursor: more ? page[page.length - 1].id : null };
38
+ if (p.get('includeTotal') === 'true') { out.totalCount = fetched.length; out.totalCountCapped = capped; }
39
+ return out;
40
+ };
41
+ return { fetchJson, calls };
42
+ }
43
+
44
+ function makeRows(n, needles) {
45
+ const base = Date.UTC(2026, 9, 14, 13, 45, 0);
46
+ const rows = [];
47
+ for (let i = 0; i < n; i++) {
48
+ rows.push({ id: `r${String(i).padStart(7, '0')}`, timestamp: new Date(base - i * 1000).toISOString(), level: 'WARN', siteId: 20519, logger: 'VisitorLoggingService', message: needles.has(i) ? '[AsrsNegotiateFailed] site=20715' : `noise ${i}` });
49
+ }
50
+ return rows;
51
+ }
52
+ const W = (rows) => [new Date(rows[rows.length - 1].timestamp), new Date(rows[0].timestamp)];
53
+
54
+ test('the single raw call misses a needle behind 9,000 newer rows (the live bug shape)', async () => {
55
+ const rows = makeRows(12000, new Set([9000]));
56
+ const { fetchJson } = mockServer(rows, 5000);
57
+ const [from, to] = W(rows);
58
+ const raw = await fetchJson(new URLSearchParams({ from: from.toISOString(), to: to.toISOString(), take: '500', includeTotal: 'true' }).toString());
59
+ assert.equal(raw.totalCountCapped, true);
60
+ assert.equal(raw.entries.some((e) => e.message.includes('AsrsNegotiate')), false);
61
+ });
62
+
63
+ test('completeLogSearch finds the needle behind 9,000 newer rows and reports COMPLETE', async () => {
64
+ const rows = makeRows(12000, new Set([9000, 11000]));
65
+ const { fetchJson } = mockServer(rows, 5000);
66
+ const [from, to] = W(rows);
67
+ const r = await completeLogSearch({ fetchJson, from, to, contains: '[AsrsNegotiateFailed]', maxRequests: 200 });
68
+ assert.equal(r.entries.length, 2);
69
+ assert.equal(r.complete, true);
70
+ assert.ok(r.slices > 1, 'capped slices must have been bisected');
71
+ assert.match(describeCompleteness(r), /COMPLETE/);
72
+ assert.doesNotMatch(describeCompleteness(r), /INCOMPLETE/);
73
+ });
74
+
75
+ test('a request budget that runs out is INCOMPLETE, never a silent partial', async () => {
76
+ const rows = makeRows(12000, new Set([9000]));
77
+ const { fetchJson } = mockServer(rows, 5000);
78
+ const [from, to] = W(rows);
79
+ const r = await completeLogSearch({ fetchJson, from, to, contains: '[AsrsNegotiateFailed]', maxRequests: 2 });
80
+ assert.equal(r.complete, false);
81
+ assert.equal(r.budgetExhausted, true);
82
+ assert.match(describeCompleteness(r), /INCOMPLETE/);
83
+ assert.match(describeCompleteness(r), /NOT proof of absence/);
84
+ });
85
+
86
+ test('level and siteId are pushed server side; contains/regex/logger filter client side', async () => {
87
+ const rows = makeRows(300, new Set([10, 20]));
88
+ rows[5].logger = 'Other.Logger';
89
+ const { fetchJson, calls } = mockServer(rows, 5000);
90
+ const [from, to] = W(rows);
91
+ const r = await completeLogSearch({ fetchJson, from, to, params: { level: 'warn', siteId: 20519 }, regex: 'Asrs.*Failed', logger: 'visitorlogging' });
92
+ assert.equal(r.entries.length, 2);
93
+ assert.equal(calls[0].get('level'), 'WARN');
94
+ assert.equal(calls[0].get('siteId'), '20519');
95
+ assert.equal(r.complete, true);
96
+ });
97
+
98
+ test('a genuine 0 on a complete scan says so (0 means nothing was logged)', async () => {
99
+ const rows = makeRows(500, new Set());
100
+ const { fetchJson } = mockServer(rows, 5000);
101
+ const [from, to] = W(rows);
102
+ const r = await completeLogSearch({ fetchJson, from, to, contains: 'nope' });
103
+ assert.equal(r.entries.length, 0);
104
+ assert.match(describeCompleteness(r), /0 matches means nothing was logged/);
105
+ });
106
+
107
+ test('describeRawMeta surfaces searchPath/scannedRows/truncated and warns on old deploys', () => {
108
+ assert.match(describeRawMeta({ entries: [], searchPath: 'ai-search', scannedRows: 1000, truncated: true }), /truncated=true.*NOT proof of absence/);
109
+ assert.match(describeRawMeta({ entries: [], searchPath: 'table-storage', scannedRows: 911, truncated: false }), /scannedRows=911 truncated=false/);
110
+ assert.match(describeRawMeta([]), /older deploy/);
111
+ assert.match(describeRawMeta({ entries: [] }), /older deploy/);
112
+ });
113
+
114
+ test('cli/lib/log-search.js and mcp/log-search.js are byte-identical (the npm packages ship separately)', () => {
115
+ const a = fs.readFileSync(path.join(here, 'log-search.js'), 'utf8');
116
+ const b = fs.readFileSync(path.join(here, '..', '..', 'mcp', 'log-search.js'), 'utf8');
117
+ assert.equal(a, b, 'copy cli/lib/log-search.js to mcp/log-search.js (or the reverse)');
118
+ });
119
+
120
+ test('neither the CLI search handler nor the MCP tool sends q= in complete mode', () => {
121
+ const cli = fs.readFileSync(path.join(here, 'commands', 'logs.js'), 'utf8');
122
+ const mcp = fs.readFileSync(path.join(here, '..', '..', 'mcp', 'staff-server.js'), 'utf8');
123
+ for (const [name, src, marker] of [['cli', cli, 'Default: COMPLETE read'], ['mcp', mcp, 'Default: COMPLETE read']]) {
124
+ const tail = src.slice(src.indexOf(marker));
125
+ const handler = tail.slice(0, 1200);
126
+ assert.ok(handler.includes('completeLogSearch('), `${name}: complete mode must call completeLogSearch`);
127
+ assert.ok(!/params\.set\('q'/.test(handler), `${name}: complete mode must not set q=`);
128
+ }
129
+ });
@@ -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', '/api/enterprise-reports', '/avatar/groups', '/avatar/workflows', '/avatar/group-overrides'];
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', '/Recruiting'];
13
13
 
14
14
  test('messaging-only routes are never called through the admin api helpers', () => {
15
15
  const offenders = [];
@@ -0,0 +1,78 @@
1
+ // Pure request builders for the Job Board employer + moderation commands (Stage 5, package job-board-employers).
2
+ // Kept free of I/O so cli/lib/recruiting-employers.test.js can pin the exact paths and bodies the messaging API expects.
3
+
4
+ const trimOrNull = (v) => (v === undefined || v === null ? undefined : String(v).trim());
5
+
6
+ export function employerPath(id, suffix = '') {
7
+ const n = Number(id);
8
+ if (!Number.isInteger(n) || n <= 0) throw new Error('employerId must be a positive integer');
9
+ return `/Recruiting/Employers/${n}${suffix ? '/' + suffix : ''}`;
10
+ }
11
+
12
+ export function memberPath(employerId, memberId) {
13
+ const m = Number(memberId);
14
+ if (!Number.isInteger(m) || m <= 0) throw new Error('memberId must be a positive integer');
15
+ return `${employerPath(employerId, 'Members')}/${m}`;
16
+ }
17
+
18
+ export function moderationPath(listingId, action) {
19
+ const n = Number(listingId);
20
+ if (!Number.isInteger(n) || n <= 0) throw new Error('listingId must be a positive integer');
21
+ return `/Recruiting/Moderation/${n}/${action}`;
22
+ }
23
+
24
+ export function buildEmployerBody({ name, domain }) {
25
+ const body = {};
26
+ const n = trimOrNull(name);
27
+ if (n !== undefined) body.name = n;
28
+ const d = trimOrNull(domain);
29
+ if (d !== undefined) body.verifiedDomain = d;
30
+ return body;
31
+ }
32
+
33
+ export function buildReasonBody(reason) {
34
+ const r = trimOrNull(reason);
35
+ if (!r || r.length < 3 || r.length > 500) throw new Error('--reason is required (3 to 500 characters)');
36
+ return { reason: r };
37
+ }
38
+
39
+ const ROLES = ['Owner', 'Recruiter', 'Viewer'];
40
+
41
+ export function normalizeRole(role) {
42
+ if (role === undefined) return undefined;
43
+ const hit = ROLES.find((r) => r.toLowerCase() === String(role).trim().toLowerCase());
44
+ if (!hit) throw new Error(`role must be one of ${ROLES.join(', ')}`);
45
+ return hit;
46
+ }
47
+
48
+ export function buildInviteBody({ email, name, role }) {
49
+ const e = trimOrNull(email);
50
+ if (!e || !/^[^@\s]+@[^@\s]+\.[^@\s]{2,}$/.test(e)) throw new Error('--email must be a valid email address');
51
+ const body = { email: e, role: normalizeRole(role ?? 'Recruiter') };
52
+ const n = trimOrNull(name);
53
+ if (n) body.displayName = n;
54
+ return body;
55
+ }
56
+
57
+ export function buildListingBody(o) {
58
+ const body = {};
59
+ const map = { title: 'title', description: 'description', department: 'department', location: 'location', employmentType: 'employmentType', applyUrl: 'applyUrl', currency: 'salaryCurrency', period: 'salaryPeriod' };
60
+ for (const [from, to] of Object.entries(map)) if (o[from] !== undefined) body[to] = String(o[from]);
61
+ if (o.min !== undefined) body.salaryMin = Number(o.min);
62
+ if (o.max !== undefined) body.salaryMax = Number(o.max);
63
+ if (!body.title || !body.description) throw new Error('--title and --description are required');
64
+ if (o.submit) body.submit = true;
65
+ return body;
66
+ }
67
+
68
+ export function buildKeywordsBody(text) {
69
+ const seen = new Set();
70
+ const keywords = [];
71
+ for (const part of String(text ?? '').split(/[\n,]/)) {
72
+ const k = part.trim();
73
+ if (!k || seen.has(k.toLowerCase())) continue;
74
+ seen.add(k.toLowerCase());
75
+ keywords.push(k);
76
+ }
77
+ return { keywords };
78
+ }
@@ -0,0 +1,40 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { employerPath, memberPath, moderationPath, buildEmployerBody, buildReasonBody, buildInviteBody, buildListingBody, buildKeywordsBody, normalizeRole } from './recruiting-employers.js';
4
+
5
+ test('paths address employers, members and moderation exactly as the messaging API routes them', () => {
6
+ assert.equal(employerPath(7), '/Recruiting/Employers/7');
7
+ assert.equal(employerPath(7, 'Suspend'), '/Recruiting/Employers/7/Suspend');
8
+ assert.equal(memberPath(7, 3), '/Recruiting/Employers/7/Members/3');
9
+ assert.equal(moderationPath(41, 'Approve'), '/Recruiting/Moderation/41/Approve');
10
+ assert.throws(() => employerPath('x'));
11
+ assert.throws(() => moderationPath(0, 'Reject'));
12
+ });
13
+
14
+ test('reject and suspend need a real reason', () => {
15
+ assert.deepEqual(buildReasonBody(' asks for a fee '), { reason: 'asks for a fee' });
16
+ assert.throws(() => buildReasonBody(''));
17
+ assert.throws(() => buildReasonBody('ab'));
18
+ assert.throws(() => buildReasonBody('x'.repeat(501)));
19
+ });
20
+
21
+ test('invite validates the email and role, defaulting to Recruiter', () => {
22
+ assert.deepEqual(buildInviteBody({ email: 'a@b.com' }), { email: 'a@b.com', role: 'Recruiter' });
23
+ assert.equal(buildInviteBody({ email: 'a@b.com', role: 'viewer', name: 'Ann' }).role, 'Viewer');
24
+ assert.throws(() => buildInviteBody({ email: 'nope' }));
25
+ assert.throws(() => normalizeRole('boss'));
26
+ });
27
+
28
+ test('employer and listing bodies map flags to API field names', () => {
29
+ assert.deepEqual(buildEmployerBody({ name: ' Acme ', domain: 'acme.com' }), { name: 'Acme', verifiedDomain: 'acme.com' });
30
+ assert.deepEqual(buildEmployerBody({}), {});
31
+ const l = buildListingBody({ title: 'T', description: 'D', min: '10', period: 'Hour', submit: true });
32
+ assert.equal(l.salaryMin, 10);
33
+ assert.equal(l.salaryPeriod, 'Hour');
34
+ assert.equal(l.submit, true);
35
+ assert.throws(() => buildListingBody({ title: 'T' }));
36
+ });
37
+
38
+ test('keywords are split on commas and newlines and de-duplicated', () => {
39
+ assert.deepEqual(buildKeywordsBody('Gift card, gift CARD\nwire\n'), { keywords: ['Gift card', 'wire'] });
40
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velaro/cli",
3
- "version": "1.4.94",
3
+ "version": "1.4.96",
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": {