@velaro/cli 1.4.106 → 1.4.108

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.
@@ -40,9 +40,56 @@ function printSettings(s) {
40
40
  console.log(`Count alert: ${s.threshold?.count ?? 0} events in ${s.threshold?.windowMinutes ?? 60} minutes`);
41
41
  }
42
42
 
43
+
44
+ // ── Safety policy (Compliance > Safety Controls) ─────────────────────────────────────────────
45
+ // Wraps SafetyPolicyController. Customer routes take the site from login; Velaro staff pass --site to use the staff routes.
46
+ // Turning a control OFF needs the recorded liability acknowledgement: the CLI prints the exact text and only sends it when you pass --accept.
47
+ const policyPath = (a) => (a.site ? `/SafetyPolicy/staff/${Number(a.site)}` : '/SafetyPolicy');
48
+ const AUDIENCES = ['ai_bot', 'human_agent'];
49
+
50
+ function printPolicy(v) {
51
+ console.log('Safety controls (on by default):');
52
+ for (const c of v.controls ?? []) {
53
+ console.log(` ${c.control}: AI bot ${c.aiBot ? 'on' : 'OFF'}, human agent ${c.humanAgent ? 'on' : 'OFF'}`);
54
+ }
55
+ console.log(`Always on (floor): ${(v.floor ?? []).join(', ')}`);
56
+ if (v.anyOff) console.log(`\nWARNING: ${v.warning}`);
57
+ const feats = Object.keys(v.overrides?.features ?? {});
58
+ if (feats.length) console.log(`Per-feature overrides: ${feats.join(', ')}`);
59
+ console.log(`Acknowledgement version: ${v.acknowledgement?.version}`);
60
+ }
61
+
62
+ async function applyChange(a, enabled) {
63
+ const audiences = a.audience === 'both' ? AUDIENCES : [a.audience];
64
+ let last;
65
+ if (enabled === false) {
66
+ if (!a.accept) {
67
+ const cur = await messagingGet(policyPath(a));
68
+ console.error(`Turning off a safety control needs the acknowledgement. Read it, then re-run with --accept:\n\n${cur.acknowledgement?.text}\n`);
69
+ throw new Error('Acknowledgement not accepted (pass --accept after reading the text above).');
70
+ }
71
+ }
72
+ const ack = enabled === false ? (await messagingGet(policyPath(a))).acknowledgement : null;
73
+ for (const audience of audiences) {
74
+ last = await messagingPut(policyPath(a), {
75
+ control: a.control,
76
+ audience,
77
+ featureKey: a.feature ?? null,
78
+ enabled,
79
+ ...(enabled === false ? { acknowledged: true, ackTextVersion: ack.version } : {}),
80
+ });
81
+ }
82
+ return last;
83
+ }
84
+
85
+ const siteOpt = { type: 'number', describe: 'Velaro staff only: target site ID (uses the staff route)' };
86
+ const controlOpt = { type: 'string', demandOption: true, describe: 'emergency_self_harm | abuse_ladder | threat_detection | input_jailbreak | output_filter' };
87
+ const audienceOpt = { type: 'string', choices: [...AUDIENCES, 'both'], default: 'both' };
88
+ const featureOpt = { type: 'string', describe: 'Per-feature/channel scope (chat, voice, sms, whatsapp, email). Omit for the whole account.' };
89
+
43
90
  export const safetyCommand = {
44
91
  command: 'safety',
45
- describe: 'Safety events and alerts: list events, acknowledge, alert settings, test alert, ask Velaro for help',
92
+ describe: 'Safety events, alerts and safety controls: list events, acknowledge, alert settings, policy (on/off controls), test alert, ask Velaro for help',
46
93
  builder: (yargs) => yargs
47
94
  .command('events', 'List safety events', (y) => y
48
95
  .option('from', { type: 'string', describe: 'ISO start' })
@@ -106,6 +153,35 @@ export const safetyCommand = {
106
153
  await messagingPost('/SafetyEvents/need-help', { message: a.message });
107
154
  console.log('Sent. The Velaro team has been alerted.');
108
155
  }))
109
- .demandCommand(1, 'Specify a subcommand: events, summary, ack, settings, test, need-help'),
156
+ .command('policy <action>', 'Safety controls on/off policy (get | set)', (y) => y
157
+ .positional('action', { choices: ['get', 'set'] })
158
+ .option('site', siteOpt)
159
+ .option('control', { ...controlOpt, demandOption: false })
160
+ .option('audience', audienceOpt)
161
+ .option('feature', featureOpt)
162
+ .option('enabled', { type: 'boolean', describe: 'true = on, false = off (off needs --accept)' })
163
+ .option('reset', { type: 'boolean', describe: 'Clear the override and use the default (on)' })
164
+ .option('accept', { type: 'boolean', describe: 'Accept the liability acknowledgement (required to turn a control off)' })
165
+ .option('json', { type: 'boolean' }),
166
+ runCommand(async (a) => {
167
+ if (a.action === 'get') return out(a, await messagingGet(policyPath(a)), printPolicy);
168
+ if (!a.control) throw new Error('--control is required for set.');
169
+ if (a.reset && a.enabled !== undefined) throw new Error('Use either --enabled or --reset, not both.');
170
+ if (!a.reset && a.enabled === undefined) throw new Error('Pass --enabled true|false or --reset.');
171
+ const v = await applyChange(a, a.reset ? null : a.enabled);
172
+ out(a, v, (x) => { console.log('Saved.\n'); printPolicy(x); });
173
+ }))
174
+ .command('acknowledge-off', 'Turn a safety control OFF after accepting the liability acknowledgement', (y) => y
175
+ .option('site', siteOpt)
176
+ .option('control', controlOpt)
177
+ .option('audience', audienceOpt)
178
+ .option('feature', featureOpt)
179
+ .option('accept', { type: 'boolean', describe: 'I have read the acknowledgement text and accept it' })
180
+ .option('json', { type: 'boolean' }),
181
+ runCommand(async (a) => {
182
+ const v = await applyChange(a, false);
183
+ out(a, v, (x) => { console.log(`${a.control} turned off. The acceptance was recorded.\n`); printPolicy(x); });
184
+ }))
185
+ .demandCommand(1, 'Specify a subcommand: events, summary, ack, settings, policy, acknowledge-off, test, need-help'),
110
186
  handler: () => {},
111
187
  };
@@ -5,6 +5,9 @@
5
5
  * velaro teams-phone set --tenant-id ... Create or update the configuration
6
6
  * velaro teams-phone remove Delete the configuration
7
7
  * velaro teams-phone test Run the inbound-calling readiness checks
8
+ * velaro teams-phone agents list List agents and their Teams user object IDs
9
+ * velaro teams-phone agents set <agentId> <teamsUserId> Map an agent to an Entra user Object ID
10
+ * velaro teams-phone agents delete <agentId> Clear an agent's mapping
8
11
  *
9
12
  * Backend contract: server/Velaro.Messaging/Controllers/Voice/TeamsPhoneConfigController.cs
10
13
  * (velaro-messaging). Site is resolved server-side from the auth context — never
@@ -98,6 +101,39 @@ async function testHandler() {
98
101
  if (!result?.success) process.exitCode = 1;
99
102
  }
100
103
 
104
+ // -- agents (agent -> Entra user object id) ---------------------------------------
105
+
106
+ async function agentsListHandler() {
107
+ const agents = await messagingGet('/TeamsPhone/Agents');
108
+ if (!Array.isArray(agents) || agents.length === 0) {
109
+ console.log(`
110
+ ${DIM}No agents found.${RST}
111
+ `);
112
+ return;
113
+ }
114
+ console.log(`
115
+ ${BOLD}Teams Phone agent mapping${RST}`);
116
+ for (const a of agents) {
117
+ const name = `${a.firstName ?? ''} ${a.lastName ?? ''}`.trim();
118
+ console.log(` ${String(a.id).padEnd(8)} ${name.padEnd(28)} ${String(a.email ?? '').padEnd(34)} ${a.teamsUserId ? GRN + a.teamsUserId + RST : DIM + '(unmapped)' + RST}`);
119
+ }
120
+ console.log('');
121
+ }
122
+
123
+ async function agentsSetHandler(argv) {
124
+ const r = await messagingPut(`/TeamsPhone/Agents/${encodeURIComponent(argv.agentId)}`, { teamsUserId: argv.teamsUserId });
125
+ console.log(`
126
+ ${GRN}✓${RST} Agent ${argv.agentId} mapped to Teams user ${r?.teamsUserId ?? argv.teamsUserId}.
127
+ `);
128
+ }
129
+
130
+ async function agentsDeleteHandler(argv) {
131
+ await messagingDel(`/TeamsPhone/Agents/${encodeURIComponent(argv.agentId)}`);
132
+ console.log(`
133
+ ${GRN}✓${RST} Agent ${argv.agentId} mapping cleared.
134
+ `);
135
+ }
136
+
101
137
  // -- command export ------------------------------------------------------------------
102
138
 
103
139
  export const teamsPhoneCommand = {
@@ -137,6 +173,33 @@ export const teamsPhoneCommand = {
137
173
  builder: yy => yy,
138
174
  handler: testHandler,
139
175
  })
176
+ .command({
177
+ command: 'agents <action>',
178
+ describe: 'Agent mapping: which Entra user Object ID is which Velaro agent (list | set | delete)',
179
+ builder: yy => yy
180
+ .command({
181
+ command: 'list',
182
+ describe: 'List agents with their Teams user Object ID',
183
+ builder: z => z,
184
+ handler: agentsListHandler,
185
+ })
186
+ .command({
187
+ command: 'set <agentId> <teamsUserId>',
188
+ describe: 'Map a Velaro agent (workspace user id) to a Microsoft Entra user Object ID (GUID)',
189
+ builder: z => z
190
+ .positional('agentId', { type: 'number', describe: 'Velaro agent (workspace user) id' })
191
+ .positional('teamsUserId', { type: 'string', describe: 'Entra user Object ID (GUID); "" clears' }),
192
+ handler: agentsSetHandler,
193
+ })
194
+ .command({
195
+ command: 'delete <agentId>',
196
+ describe: 'Clear the Teams user mapping for a Velaro agent',
197
+ builder: z => z.positional('agentId', { type: 'number', describe: 'Velaro agent (workspace user) id' }),
198
+ handler: agentsDeleteHandler,
199
+ })
200
+ .demandCommand(1, 'Specify list, set or delete.'),
201
+ handler: () => {},
202
+ })
140
203
  .demandCommand(1, 'Specify a teams-phone subcommand.')
141
204
  .strict(),
142
205
  handler: () => {},
package/lib/log-search.js CHANGED
@@ -207,3 +207,46 @@ export function describeRawMeta(resp) {
207
207
  const bestEffort = resp.exact === false ? ' exact=false (answered from the best-effort search index: it can lag and misses text that starts mid-word, so treat counts as a floor, not exact)' : '';
208
208
  return `searchPath=${resp.searchPath} scannedRows=${resp.scannedRows} truncated=${t}${t ? ' (NOT proof of absence; continue with nextCursor or narrow the window)' : ''}${bestEffort}${resp.nextCursor ? ' nextCursor=present' : ''}`;
209
209
  }
210
+
211
+ // ── Time window + raw query building (shared by server.js and staff-server.js velaro_logs_search) ──
212
+ // Bug fixed 2026-10-21: the tool only honored `last` and silently ignored from/to, so an explicit
213
+ // historical range returned the newest entries instead. from/to accept an ISO timestamp or a
214
+ // relative duration (30m, 2h, 1d = that long ago). `to` defaults to now, `from` to to - last (2h).
215
+ const DUR_RE = /^(\d+)(m|h|d)$/i;
216
+ const durMs = (s) => {
217
+ const m = DUR_RE.exec(String(s).trim());
218
+ const n = parseInt(m[1], 10);
219
+ const u = m[2].toLowerCase();
220
+ return u === 'm' ? n * 60000 : u === 'h' ? n * 3600000 : n * 86400000;
221
+ };
222
+ function parseWhen(label, v, nowMs) {
223
+ const s = String(v).trim();
224
+ if (DUR_RE.test(s)) return new Date(nowMs - durMs(s));
225
+ const d = new Date(s);
226
+ if (Number.isNaN(d.getTime())) throw new Error(`Invalid ${label} "${v}": use an ISO timestamp (2026-10-20T00:00:00Z) or a relative duration like 2h.`);
227
+ return d;
228
+ }
229
+ export function resolveLogWindow(args = {}, nowMs = Date.now()) {
230
+ const given = (v) => v !== undefined && v !== null && String(v).trim() !== '';
231
+ const to = given(args.to) ? parseWhen('to', args.to, nowMs) : new Date(nowMs);
232
+ const lastMs = DUR_RE.test(String(args.last ?? '').trim()) ? durMs(args.last) : 2 * 3600000;
233
+ const from = given(args.from) ? parseWhen('from', args.from, nowMs) : new Date(to.getTime() - lastMs);
234
+ if (from.getTime() >= to.getTime()) throw new Error(`Invalid window: from (${from.toISOString()}) must be before to (${to.toISOString()}).`);
235
+ return { from, to };
236
+ }
237
+
238
+ // Query string for GET /superadmin/logs/search (same params as /internal/logs/search).
239
+ export function buildRawLogQuery(args, from, to) {
240
+ const params = new URLSearchParams({ from: from.toISOString(), to: to.toISOString(), take: String(args.take ?? 50) });
241
+ if (args.q) params.set('q', args.q);
242
+ if (args.level) params.set('level', String(args.level).toUpperCase());
243
+ if (args.logger) params.set('logger', args.logger);
244
+ if (args.integration) params.set('integration', args.integration);
245
+ if (args.siteId) params.set('siteId', String(args.siteId));
246
+ if (args.source) params.set('source', args.source);
247
+ if (args.platform) params.set('platform', args.platform);
248
+ if (args.environment) params.set('environment', args.environment);
249
+ if (args.correlationId) params.set('correlationId', args.correlationId);
250
+ if (args.regex) params.set('regex', 'true');
251
+ return params.toString();
252
+ }
@@ -321,7 +321,7 @@ test('fetchRetry429 retries only 429 and returns the final response after the re
321
321
  });
322
322
 
323
323
  test('bot_full_diagnostic: errors use the server-side level=ERROR filter, patterns are a separate read; neither is the old text regex', () => {
324
- for (const rel of ['../../mcp/staff-server.js', '../../mcp/server.js']) {
324
+ for (const rel of ['../../mcp/staff-server.js']) { // bot_full_diagnostic is staff-only since MCP 2.0.0
325
325
  const src = fs.readFileSync(path.join(here, rel), 'utf8');
326
326
  const a = src.indexOf('const velaroLoggerP = safeJson');
327
327
  const body = src.slice(a, a + 2600);
@@ -0,0 +1,27 @@
1
+ // Shared helpers for the MCP-parity command groups (diag-groups.js, ops-groups.js).
2
+ export const show = (r) => console.log(typeof r === 'string' ? r : JSON.stringify(r, null, 2));
3
+ export const qs = (o) => {
4
+ const p = new URLSearchParams();
5
+ for (const [k, v] of Object.entries(o)) if (v !== undefined && v !== null && v !== '') p.set(k, String(v));
6
+ const s = p.toString();
7
+ return s ? `?${s}` : '';
8
+ };
9
+ export const run = (fn) => async (argv) => {
10
+ try { await fn(argv); } catch (e) { console.error(`Error: ${e.message}`); process.exit(1); }
11
+ };
12
+ export const needYes = (argv, what) => {
13
+ if (!argv.yes) { console.error(`Refusing to ${what} without --yes.`); process.exit(1); }
14
+ };
15
+ // Build a yargs group body (spread into `{ command: 'name', ...group(...) }` so the parity validator sees the command name): subs = [[command, describe, builderFn|null, handler]]
16
+ export function group(describe, subs) {
17
+ return {
18
+ describe,
19
+ builder: (y) => {
20
+ for (const [command, desc, b, handler] of subs) {
21
+ y.command({ command, describe: desc, builder: b || ((x) => x), handler: run(handler) });
22
+ }
23
+ return y.demandCommand(1, `Specify a subcommand: ${subs.map((s) => s[0].split(' ')[0]).join(' | ')}`);
24
+ },
25
+ handler: () => {},
26
+ };
27
+ }
@@ -42,7 +42,7 @@ import { readFileSync } from 'node:fs';
42
42
  import { fileURLToPath } from 'node:url';
43
43
  import { dirname, join as pjoin } from 'node:path';
44
44
  const root = pjoin(dirname(fileURLToPath(import.meta.url)), '..', '..');
45
- for (const f of ['mcp/server.js', 'mcp/staff-server.js', 'server/Velaro.Admin/Controllers/StaffMcpController.cs']) {
45
+ for (const f of ['mcp/staff-server.js', 'server/Velaro.Admin/Controllers/StaffMcpController.cs']) {
46
46
  test(`${f} registers sign_versions tools`, () => {
47
47
  const s = readFileSync(pjoin(root, f), 'utf8');
48
48
  for (const t of ['sign_versions_list', 'sign_versions_diff', 'sign_versions_upload']) assert.ok(s.includes(t), `${t} missing in ${f}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velaro/cli",
3
- "version": "1.4.106",
3
+ "version": "1.4.108",
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": {