@velaro/cli 1.4.53 → 1.4.55

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.
@@ -23,6 +23,12 @@ import { runCommand } from '../run.js';
23
23
  // GET ContactSegments/{id}/Members -> ContactListController.GetSegmentMembers
24
24
  // POST ContactLists/crm-preview { filters } -> ContactListController.CrmPreview
25
25
  // POST ContactLists/import-from-crm { filters, newListName? | listId? } -> ContactListController.ImportFromCrm
26
+ //
27
+ // `import-csv` below is NOT a wrapper for the browser file-upload wizard
28
+ // (ContactListController.StartImport, still intentionally absent here) --
29
+ // it is this CLI parsing a local CSV and looping the plain AddMember POST
30
+ // above, once per row. No new backend endpoint, no IFormFile, fully
31
+ // scriptable.
26
32
 
27
33
  function fmtRow(label, value) {
28
34
  console.log(` ${label.padEnd(24)}${value ?? '—'}`);
@@ -86,6 +92,78 @@ async function addMemberHandler(argv) {
86
92
  console.log(result.isNew ? `\n✅ Added ${argv.email} to list #${argv.id}.\n` : `\n${argv.email} was already a member of list #${argv.id}.\n`);
87
93
  }
88
94
 
95
+ // Minimal RFC4180-ish CSV line parser -- handles quoted fields (with embedded
96
+ // commas/newlines via doubled quotes) without pulling in a new dependency for
97
+ // a CLI tool that otherwise has none. Not a full CSV spec implementation, but
98
+ // covers what a customer exporting from Excel/Sheets/another CRM produces.
99
+ function parseCsv(text) {
100
+ const rows = [];
101
+ let row = [], field = '', inQuotes = false;
102
+ for (let i = 0; i < text.length; i++) {
103
+ const c = text[i];
104
+ if (inQuotes) {
105
+ if (c === '"') {
106
+ if (text[i + 1] === '"') { field += '"'; i++; }
107
+ else inQuotes = false;
108
+ } else field += c;
109
+ } else if (c === '"') inQuotes = true;
110
+ else if (c === ',') { row.push(field); field = ''; }
111
+ else if (c === '\r') { /* skip, \n handles the row break */ }
112
+ else if (c === '\n') { row.push(field); rows.push(row); row = []; field = ''; }
113
+ else field += c;
114
+ }
115
+ if (field.length || row.length) { row.push(field); rows.push(row); }
116
+ return rows.filter(r => r.length > 1 || (r.length === 1 && r[0].trim() !== ''));
117
+ }
118
+
119
+ // POST ContactLists/{listId}/Members, once per CSV row, drives the existing
120
+ // single-member API (ContactListController.AddMember) from a file instead of
121
+ // one CLI invocation per person. No new backend endpoint needed.
122
+ // mcp-cli-exempt does NOT apply here (unlike the browser upload wizard) since
123
+ // this never touches a raw file-upload/IFormFile path, it's plain scriptable
124
+ // JSON POSTs, just looped.
125
+ async function importCsvHandler(argv) {
126
+ const { readFileSync } = await import('fs');
127
+ const raw = readFileSync(argv.file, 'utf8');
128
+ const rows = parseCsv(raw);
129
+ if (!rows.length) { console.log('\nNo rows found in CSV.\n'); return; }
130
+
131
+ const header = rows[0].map(h => h.trim().toLowerCase());
132
+ const emailIdx = header.indexOf('email');
133
+ if (emailIdx === -1) throw new Error('CSV must have an "email" column in its header row.');
134
+ const nameIdx = header.indexOf('name');
135
+ const phoneIdx = header.indexOf('phone');
136
+ const tagsIdx = header.indexOf('tags');
137
+
138
+ const dataRows = rows.slice(1).filter(r => (r[emailIdx] || '').trim());
139
+ console.log(`\nImporting ${dataRows.length} row(s) from ${argv.file} into list #${argv.id}${argv.dryRun ? ' (dry run)' : ''}...\n`);
140
+
141
+ let added = 0, existing = 0, failed = 0;
142
+ const concurrency = Math.max(1, Math.min(argv.concurrency || 5, 20));
143
+ let cursor = 0;
144
+ async function worker() {
145
+ while (cursor < dataRows.length) {
146
+ const r = dataRows[cursor++];
147
+ const email = (r[emailIdx] || '').trim();
148
+ const name = nameIdx >= 0 ? (r[nameIdx] || '').trim() : undefined;
149
+ const phone = phoneIdx >= 0 ? (r[phoneIdx] || '').trim() : undefined;
150
+ const tags = tagsIdx >= 0 ? (r[tagsIdx] || '').trim() : undefined;
151
+ if (argv.dryRun) { console.log(` would add: ${email}`); continue; }
152
+ try {
153
+ const result = await request('POST', `ContactLists/${argv.id}/Members`, { email, name, phone, tags });
154
+ if (result.isNew) added++; else existing++;
155
+ } catch (e) {
156
+ failed++;
157
+ console.log(` FAILED ${email}: ${e.message}`);
158
+ }
159
+ }
160
+ }
161
+ await Promise.all(Array.from({ length: concurrency }, worker));
162
+
163
+ if (argv.dryRun) { console.log(`\nDry run complete. ${dataRows.length} row(s) would be imported.\n`); return; }
164
+ console.log(`\n✅ Import complete: ${added} added, ${existing} already members, ${failed} failed.\n`);
165
+ }
166
+
89
167
  // DELETE ContactLists/{listId}/Members/{memberId} -> ContactListController.RemoveMember
90
168
  async function removeMemberHandler(argv) {
91
169
  await request('DELETE', `ContactLists/${argv.id}/Members/${argv.memberId}`);
@@ -244,6 +322,15 @@ export const contactListCommand = {
244
322
  .option('tags', { type: 'string' }),
245
323
  handler: runCommand(addMemberHandler),
246
324
  })
325
+ .command({
326
+ command: 'import-csv <id> <file>',
327
+ describe: 'Bulk-add members to a contact list from a CSV file (header row required, "email" column mandatory; optional "name", "phone", "tags")',
328
+ builder: (y2) => y2.positional('id', { type: 'number' })
329
+ .positional('file', { type: 'string' })
330
+ .option('dryRun', { type: 'boolean', default: false, describe: 'Preview rows without adding them' })
331
+ .option('concurrency', { type: 'number', default: 5, describe: 'Parallel add-member requests (max 20)' }),
332
+ handler: runCommand(importCsvHandler),
333
+ })
247
334
  .command({
248
335
  command: 'remove-member <id> <memberId>',
249
336
  describe: 'Remove a member from a contact list',
@@ -292,6 +379,6 @@ export const contactListCommand = {
292
379
  .option('listId', { type: 'number', describe: 'Import into an existing list instead of creating one' }),
293
380
  handler: runCommand(importFromCrmHandler),
294
381
  })
295
- .demandCommand(1, 'Specify a contact-list subcommand: list | create | update | delete | members | add-member | remove-member | export | resync | auto-resync | quota | segment-members | crm-preview | import-from-crm'),
382
+ .demandCommand(1, 'Specify a contact-list subcommand: list | create | update | delete | members | add-member | import-csv | remove-member | export | resync | auto-resync | quota | segment-members | crm-preview | import-from-crm'),
296
383
  handler: () => {},
297
384
  };
@@ -25,6 +25,9 @@ const DB_SECRETS = {
25
25
  kb: {
26
26
  staging: { vault: 'velaro-eastus-stg-kv', secret: 'KnowledgeBaseConnectionString-Staging' },
27
27
  },
28
+ messaging: {
29
+ prod: { vault: 'velaro-eastus-prd-kv', secret: 'MessagingReadOnlyConnectionString' },
30
+ },
28
31
  };
29
32
 
30
33
  async function getConnectionString(dbName, env, connectionStringFile) {
@@ -114,6 +114,24 @@ async function reconcile(args) {
114
114
  }
115
115
  }
116
116
 
117
+ async function setLock(args) {
118
+ if (!args.site) {
119
+ console.error('Usage: velaro entitlement set-lock --site 1032 --at 2026-01-01T00:00:00Z\n velaro entitlement set-lock --site 1032 --unlock');
120
+ process.exit(1);
121
+ }
122
+ if (!args.at && !args.unlock) {
123
+ console.error('Pass --at <ISO 8601 timestamp> to lock, or --unlock to clear an existing lock.');
124
+ process.exit(1);
125
+ }
126
+ const lockedAt = args.unlock ? null : args.at;
127
+ const result = await request('PUT', `Entitlements/admin/lock/${args.site}`, { lockedAt });
128
+ if (lockedAt) {
129
+ console.log(`✅ Site ${args.site} entitlements locked to ${result.entitlementLockedAt} (plan ${result.planKey}).`);
130
+ } else {
131
+ console.log(`✅ Site ${args.site} entitlement lock cleared — resolves against the current plan version again.`);
132
+ }
133
+ }
134
+
117
135
  async function listCutover() {
118
136
  const result = await request('GET', 'Entitlements/admin/cutover');
119
137
  console.log(`\nCutover flags: ${result.liveCount} live / ${result.total} total`);
@@ -167,10 +185,15 @@ export function entitlementCommand(yargs) {
167
185
  .option('until', { type: 'string', desc: 'ISO 8601 comp expiry date (omit for indefinite)' })
168
186
  .option('notes', { type: 'string', desc: 'Internal notes' })
169
187
  , a => compSite(a))
188
+ .command('set-lock', 'Lock a site\'s entitlement resolution to a point-in-time plan version, or clear an existing lock', y => y
189
+ .option('site', { type: 'number', demandOption: true, desc: 'Site ID' })
190
+ .option('at', { type: 'string', desc: 'ISO 8601 timestamp to lock entitlement resolution to' })
191
+ .option('unlock', { type: 'boolean', desc: 'Clear an existing lock (resolves against current plan version again)' })
192
+ , a => setLock(a))
170
193
  .command('list-cutover', 'List all feature keys and whether they are live (messaging reads admin) or not (Subscription.cs)', () => {}, () => listCutover())
171
194
  .command('set-cutover', 'Flip a feature key live (or roll it back). Live = messaging reads admin value within 45s. --rollback reverts to Subscription.cs.', y => y
172
195
  .option('key', { type: 'string', demandOption: true, desc: 'Feature key, e.g. EnableIvr' })
173
196
  .option('rollback', { type: 'boolean', desc: 'Pass to roll back this flag to Subscription.cs (default: go live)' })
174
197
  , a => setCutover(a))
175
- .demandCommand(1, 'Specify a subcommand: get | list-features | set-override | remove-override | seed | seed-plans | backfill | reconcile | comp | list-cutover | set-cutover');
198
+ .demandCommand(1, 'Specify a subcommand: get | list-features | set-override | remove-override | seed | seed-plans | backfill | reconcile | comp | set-lock | list-cutover | set-cutover');
176
199
  }
@@ -11,15 +11,16 @@ export const loginCommand = {
11
11
  describe: 'Log in to the staging environment',
12
12
  type: 'boolean',
13
13
  default: false,
14
- })
15
- .option('api', {
16
- describe: 'Override the admin API base URL (advanced)',
17
- type: 'string',
18
14
  }),
19
15
 
16
+ // Advanced API-base overrides go through VELARO_ADMIN_API_STAGING/VELARO_ADMIN_API_PROD env
17
+ // vars (read by ENVS in config.js) -- never a login flag. A flag-supplied override used to get
18
+ // persisted into the stored config and then silently clobbered on the next token refresh
19
+ // (refreshVelaroToken() always resolves against ENVS[env]), so login would report success
20
+ // against a custom URL while every later command quietly used the canonical one instead.
20
21
  handler: runCommand(async (argv) => {
21
- const env = argv.staging || argv.api?.includes('staging') ? 'staging' : 'prod';
22
- const adminApiBase = argv.api || ENVS[env].adminApiBase;
22
+ const env = argv.staging ? 'staging' : 'prod';
23
+ const adminApiBase = ENVS[env].adminApiBase;
23
24
 
24
25
  console.log(`Starting Velaro login (${env})...\n`);
25
26
 
@@ -307,12 +307,13 @@ function parsePositiveIntFlag(flagName, rawValue, { max } = {}) {
307
307
  // it's pure log noise that pages no one. Mirrors the same guard in
308
308
  // client/src/pages/Messaging/Integrations/AlertRules.tsx (extended here to
309
309
  // also cover webhook, which that component's check omits).
310
- function hasAnyRecipient({ emails, sms, webhook, mobileUserIds }) {
310
+ function hasAnyRecipient({ emails, sms, webhook, mobileUserIds, teams }) {
311
311
  return Boolean(
312
312
  (emails && String(emails).trim()) ||
313
313
  (sms && String(sms).trim()) ||
314
314
  (webhook && String(webhook).trim()) ||
315
- (mobileUserIds && String(mobileUserIds).trim())
315
+ (mobileUserIds && String(mobileUserIds).trim()) ||
316
+ teams
316
317
  );
317
318
  }
318
319
 
@@ -327,6 +328,7 @@ function printAlertRule(r) {
327
328
  if (r.notifyEmails) bits.push('email');
328
329
  if (r.notifyWebhookUrl) bits.push('webhook');
329
330
  if (r.notifyMobileUserIds) bits.push('mobile push');
331
+ if (r.notifyTeams) bits.push('Teams');
330
332
  bits.push(`cooldown ${r.minRefireMinutes}m`);
331
333
  console.log(`${DIM}[${r.id}]${RESET} ${r.name} ${state}`);
332
334
  console.log(` ${bits.join(' ')}`);
@@ -440,9 +442,9 @@ async function createAlertRule(argv) {
440
442
  const [thresholdCheck, windowCheck, cooldownCheck] = checks;
441
443
 
442
444
  // HIGH: a rule with zero notification channels fires into the void.
443
- if (!hasAnyRecipient({ emails: argv.emails, sms: argv.sms, webhook: argv.webhook, mobileUserIds: argv.mobileUserIds })
445
+ if (!hasAnyRecipient({ emails: argv.emails, sms: argv.sms, webhook: argv.webhook, mobileUserIds: argv.mobileUserIds, teams: argv.teams })
444
446
  && !argv.noRecipientsOk) {
445
- console.error('No notification channel given (--emails, --sms, --webhook, or --mobile-user-ids) — this rule would fire but notify no one.');
447
+ console.error('No notification channel given (--emails, --sms, --webhook, --teams, or --mobile-user-ids) — this rule would fire but notify no one.');
446
448
  console.error('If that\'s intentional (e.g. you only want it visible in Alert History), pass --no-recipients-ok to proceed anyway.');
447
449
  process.exit(1);
448
450
  }
@@ -459,6 +461,7 @@ async function createAlertRule(argv) {
459
461
  notifyPhoneNumbers: argv.sms,
460
462
  notifyWebhookUrl: argv.webhook,
461
463
  notifyMobileUserIds: argv.mobileUserIds,
464
+ notifyTeams: Boolean(argv.teams),
462
465
  enabled: true,
463
466
  };
464
467
 
@@ -494,6 +497,103 @@ async function createAlertRule(argv) {
494
497
  console.log('');
495
498
  }
496
499
 
500
+ async function updateAlertRule(argv) {
501
+ const creds = await getCredentials();
502
+ await requireAdmin(creds);
503
+ const msgBase = await getMsgBase(argv);
504
+
505
+ if (!argv.id) {
506
+ console.error('Rule id is required. Example: velaro logs alerts update 42 --threshold 60');
507
+ process.exit(1);
508
+ }
509
+
510
+ // No single-rule GET endpoint (same limitation deleteAlertRule already works
511
+ // around) -- UpdateRule is a whole-object PUT, not a partial patch, so every
512
+ // field has to be re-sent or it gets wiped. Fetch the current rule from the
513
+ // list first and merge only the flags actually passed on top of it.
514
+ const listRes = await fetch(`${msgBase}/superadmin/log-alerts/rules`, {
515
+ headers: { Authorization: `Bearer ${creds.velaroToken}`, 'X-Internal-SiteId': String(creds.siteId) },
516
+ signal: AbortSignal.timeout(15000),
517
+ });
518
+ if (!listRes.ok) {
519
+ console.error(`HTTP ${listRes.status}: ${await listRes.text().catch(() => '')}`);
520
+ process.exit(1);
521
+ }
522
+ const rules = await listRes.json().catch(() => []);
523
+ const target = Array.isArray(rules) ? rules.find(r => String(r.id) === String(argv.id)) : null;
524
+ if (!target) {
525
+ console.error(`No rule with id ${argv.id} found. Run 'velaro logs alerts list' to see current ids.`);
526
+ process.exit(1);
527
+ }
528
+
529
+ const checks = [
530
+ parsePositiveIntFlag('threshold', argv.threshold),
531
+ parsePositiveIntFlag('window-minutes', argv.windowMinutes, { max: 1440 }),
532
+ parsePositiveIntFlag('cooldown-minutes', argv.cooldownMinutes),
533
+ ];
534
+ const failed = checks.find(c => !c.ok);
535
+ if (failed) {
536
+ console.error(failed.error);
537
+ process.exit(1);
538
+ }
539
+ const [thresholdCheck, windowCheck, cooldownCheck] = checks;
540
+
541
+ const body = {
542
+ ...target,
543
+ name: argv.name ?? target.name,
544
+ queryText: argv.query ?? target.queryText,
545
+ level: argv.level ? argv.level.toUpperCase() : target.level,
546
+ integration: argv.integration ?? target.integration,
547
+ windowMinutes: windowCheck.value ?? target.windowMinutes,
548
+ thresholdCount: thresholdCheck.value ?? target.thresholdCount,
549
+ minRefireMinutes: cooldownCheck.value ?? target.minRefireMinutes,
550
+ notifyEmails: argv.emails ?? target.notifyEmails,
551
+ notifyPhoneNumbers: argv.sms ?? target.notifyPhoneNumbers,
552
+ notifyWebhookUrl: argv.webhook ?? target.notifyWebhookUrl,
553
+ notifyMobileUserIds: argv.mobileUserIds ?? target.notifyMobileUserIds,
554
+ notifyTeams: argv.teams !== undefined ? Boolean(argv.teams) : Boolean(target.notifyTeams),
555
+ enabled: argv.enabled !== undefined ? argv.enabled : target.enabled,
556
+ };
557
+
558
+ // HIGH: same "fires into the void" guard as create -- an update that strips
559
+ // every notification channel (or a rule that already had none, being
560
+ // re-enabled without adding one) silently produces an alert nobody sees.
561
+ if (!hasAnyRecipient({ emails: body.notifyEmails, sms: body.notifyPhoneNumbers, webhook: body.notifyWebhookUrl, mobileUserIds: body.notifyMobileUserIds, teams: body.notifyTeams })
562
+ && !argv.noRecipientsOk) {
563
+ console.error('This update would leave the rule with no notification channel (--emails, --sms, --webhook, --teams, or --mobile-user-ids) — it would fire but notify no one.');
564
+ console.error('If that\'s intentional, pass --no-recipients-ok to proceed anyway.');
565
+ process.exit(1);
566
+ }
567
+
568
+ if (!argv.yes) {
569
+ console.log(`\n${DIM}Current rule:${RESET}\n`);
570
+ printAlertRule(target);
571
+ console.log(`\n${DIM}About to update to (dry run — nothing sent yet):${RESET}\n`);
572
+ printAlertRule(body);
573
+ console.log(`\nRe-run with --yes to actually apply this update.\n`);
574
+ return;
575
+ }
576
+
577
+ const res = await fetch(`${msgBase}/superadmin/log-alerts/rules/${argv.id}`, {
578
+ method: 'PUT',
579
+ headers: {
580
+ Authorization: `Bearer ${creds.velaroToken}`,
581
+ 'X-Internal-SiteId': String(creds.siteId),
582
+ 'Content-Type': 'application/json',
583
+ },
584
+ body: JSON.stringify(body),
585
+ signal: AbortSignal.timeout(15000),
586
+ });
587
+ if (!res.ok) {
588
+ console.error(`HTTP ${res.status}: ${await res.text().catch(() => '')}`);
589
+ process.exit(1);
590
+ }
591
+ const data = await res.json();
592
+ console.log(`\n${CYN}✓${RESET} Updated rule [${data.id}] "${data.name}"\n`);
593
+ printAlertRule(data);
594
+ console.log('');
595
+ }
596
+
497
597
  async function toggleAlertRule(argv) {
498
598
  const creds = await getCredentials();
499
599
  await requireAdmin(creds);
@@ -651,12 +751,36 @@ export const logsCommand = {
651
751
  .option('emails', { describe: 'Comma-separated notification email addresses', type: 'string' })
652
752
  .option('sms', { describe: 'Comma-separated E.164 phone numbers for SMS', type: 'string' })
653
753
  .option('webhook', { describe: 'Webhook URL (Slack/Teams) to POST on fire', type: 'string' })
754
+ .option('teams', { describe: 'Also post to the Production/Staging Alerts Teams channel via the velaro-alerts-to-teams Logic App', type: 'boolean' })
654
755
  .option('mobile-user-ids', { describe: 'Comma-separated Velaro workspace user IDs for mobile push', type: 'string' })
655
756
  .option('no-recipients-ok', { describe: 'Allow creating a rule with zero notification channels (visible in Alert History only)', type: 'boolean', default: false })
656
757
  .option('yes', { describe: 'Skip the dry-run preview and actually create the rule', type: 'boolean', default: false })
657
758
  .option('env', { describe: 'staging or prod', type: 'string' }),
658
759
  handler: createAlertRule,
659
760
  })
761
+ .command({
762
+ command: 'update <id>',
763
+ describe: 'Change an existing alert rule (threshold, window, query, recipients, etc.) without touching code',
764
+ builder: yy => yy
765
+ .positional('id', { describe: 'Rule id (from: velaro logs alerts list)', type: 'number' })
766
+ .option('name', { describe: 'Rule name', type: 'string' })
767
+ .option('query', { describe: 'Message-contains text to match', type: 'string' })
768
+ .option('level', { describe: 'ERROR, WARN, INFO, or DEBUG', type: 'string' })
769
+ .option('integration', { describe: 'Integration tag to match', type: 'string' })
770
+ .option('window-minutes', { describe: 'Rolling window in minutes', type: 'number' })
771
+ .option('threshold', { describe: 'Fire when count >= this within the window', type: 'number' })
772
+ .option('cooldown-minutes', { describe: 'Minimum minutes between re-fires', type: 'number' })
773
+ .option('emails', { describe: 'Comma-separated notification email addresses', type: 'string' })
774
+ .option('sms', { describe: 'Comma-separated E.164 phone numbers for SMS', type: 'string' })
775
+ .option('webhook', { describe: 'Webhook URL (Slack/Teams) to POST on fire', type: 'string' })
776
+ .option('teams', { describe: 'Also post to the Production/Staging Alerts Teams channel via the velaro-alerts-to-teams Logic App', type: 'boolean' })
777
+ .option('mobile-user-ids', { describe: 'Comma-separated Velaro workspace user IDs for mobile push', type: 'string' })
778
+ .option('enabled', { describe: 'Enable (true) or pause (false) the rule', type: 'boolean' })
779
+ .option('no-recipients-ok', { describe: 'Allow leaving the rule with zero notification channels', type: 'boolean', default: false })
780
+ .option('yes', { describe: 'Skip the dry-run preview and actually apply the update', type: 'boolean', default: false })
781
+ .option('env', { describe: 'staging or prod', type: 'string' }),
782
+ handler: updateAlertRule,
783
+ })
660
784
  .command({
661
785
  command: 'toggle <id>',
662
786
  describe: 'Enable/pause an alert rule',
@@ -674,7 +798,7 @@ export const logsCommand = {
674
798
  .option('env', { describe: 'staging or prod', type: 'string' }),
675
799
  handler: deleteAlertRule,
676
800
  })
677
- .demandCommand(1, 'Specify a subcommand: list, suggest, create, toggle, or delete'),
801
+ .demandCommand(1, 'Specify a subcommand: list, suggest, create, update, toggle, or delete'),
678
802
  })
679
803
  .demandCommand(1, 'Specify a subcommand: search, tail, diagnose, digest-recipients, set-digest-recipients, or alerts'),
680
804
  };
@@ -1,10 +1,18 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import os from 'os';
4
- import { get, post, del } from '../api.js';
4
+ import { get, post, del, request } from '../api.js';
5
5
  import { getActiveEnv, ENVS } from '../config.js';
6
6
  import { runCommand } from '../run.js';
7
7
 
8
+ // The API returns { keys, monthlyLimit }; older deployments returned an array.
9
+ // Fail on any other shape so a contract change cannot appear as an empty list.
10
+ export function normalizeMcpKeysResponse(response) {
11
+ if (Array.isArray(response)) return response;
12
+ if (Array.isArray(response?.keys)) return response.keys;
13
+ throw new Error('Unexpected MCP API key list response.');
14
+ }
15
+
8
16
  export const mcpKeyCommand = {
9
17
  command: 'mcp-key <subcommand>',
10
18
  describe: 'Manage MCP API keys (vel_live_* keys for AI skill access)',
@@ -23,8 +31,8 @@ const mcpKeyListCommand = {
23
31
  command: 'list',
24
32
  describe: 'List MCP API keys for your site',
25
33
  handler: runCommand(async () => {
26
- const keys = await get('/McpApiKeys');
27
- if (!keys?.length) { console.log('No MCP keys found.'); return; }
34
+ const keys = normalizeMcpKeysResponse(await get('/McpApiKeys'));
35
+ if (!keys.length) { console.log('No MCP keys found.'); return; }
28
36
  console.log(`Found ${keys.length} key(s):\n`);
29
37
  for (const k of keys) {
30
38
  const used = k.lastUsedAt ? new Date(k.lastUsedAt).toLocaleDateString() : 'never';
@@ -90,7 +98,7 @@ const mcpKeyInstallCommand = {
90
98
  const label = argv.label || (env === 'staging' ? 'Claude Code (Staging)' : 'Claude Code');
91
99
 
92
100
  const payload = { label };
93
- const result = await post('/McpApiKeys', payload);
101
+ const result = await request('POST', '/McpApiKeys', payload, env);
94
102
  const rawKey = result.rawKey;
95
103
 
96
104
  // Claude Code reads MCP server definitions from the top-level "mcpServers" key
@@ -142,7 +150,7 @@ const mcpKeyInstallCommand = {
142
150
 
143
151
  const mcpKeyRotateCommand = {
144
152
  command: 'rotate <id|label>',
145
- describe: 'Revoke an existing key and issue a replacement in one step',
153
+ describe: 'Issue a replacement key, then revoke the existing key',
146
154
  builder: (y) =>
147
155
  y
148
156
  .positional('id|label', { type: 'string', describe: 'Numeric ID or partial label of the key to rotate (e.g. "jb" or "42")' })
@@ -156,12 +164,12 @@ const mcpKeyRotateCommand = {
156
164
  }),
157
165
 
158
166
  handler: runCommand(async (argv) => {
159
- const keys = await get('/McpApiKeys');
167
+ const keys = normalizeMcpKeysResponse(await get('/McpApiKeys'));
160
168
  const search = String(argv['id|label'] ?? argv.id ?? '');
161
- const numId = parseInt(search, 10);
162
- const matches = isNaN(numId)
163
- ? keys?.filter(k => k.label.toLowerCase().includes(search.toLowerCase()))
164
- : keys?.filter(k => k.id === numId);
169
+ const numId = /^\d+$/.test(search) ? Number(search) : null;
170
+ const matches = numId === null
171
+ ? keys.filter(k => k.label.toLowerCase().includes(search.toLowerCase()))
172
+ : keys.filter(k => k.id === numId);
165
173
 
166
174
  if (!matches?.length)
167
175
  throw new Error(`No key found matching "${search}". Run 'velaro mcp-key list' to see your keys.`);
@@ -174,15 +182,17 @@ const mcpKeyRotateCommand = {
174
182
 
175
183
  const label = argv.label || old.label;
176
184
 
177
- await del(`/McpApiKeys/${old.id}`);
178
- console.log(`Revoked: [${old.id}] ${old.label} (${old.keyPrefix}...)`);
179
-
180
185
  const payload = { label };
181
186
  if (argv.expires) payload.expiresAt = new Date(argv.expires).toISOString();
187
+ else if (old.expiresAt) payload.expiresAt = old.expiresAt;
182
188
  const result = await post('/McpApiKeys', payload);
183
189
 
184
- console.log(`Replaced: [${result.id}] ${result.label}`);
190
+ // Print the one-time secret before revoking. If revocation fails, the user
191
+ // can still install this replacement and revoke the old key by ID.
192
+ console.log(`Created: [${result.id}] ${result.label}`);
185
193
  console.log(`\n New key: ${result.rawKey}`);
186
194
  console.log(`\n ${result.warning ?? 'Copy this key now — it will not be shown again.'}`);
195
+ await del(`/McpApiKeys/${old.id}`);
196
+ console.log(`Revoked: [${old.id}] ${old.label} (${old.keyPrefix}...)`);
187
197
  }),
188
198
  };
@@ -0,0 +1,107 @@
1
+ // velaro order-form -- staff-only order form onboarding flow (OrderFormController).
2
+ // See docs/architecture/order-form-onboarding-flow.md. Nothing here provisions, charges, or
3
+ // touches QuickBooks: it drafts a deal, generates the contract, and sends it through Velaro Sign.
4
+ import { request } from '../api.js';
5
+
6
+ function money(cents) {
7
+ return `$${(cents / 100).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
8
+ }
9
+
10
+ // --line "slug:qty[:unitPriceCents[:setupFeeCents[:termMonths[:cadence]]]]" (repeatable)
11
+ function parseLine(spec) {
12
+ const [slug, qty, unit, setup, term, cadence] = String(spec).split(':');
13
+ if (!slug) throw new Error(`Bad --line "${spec}". Format: slug:qty[:unitPriceCents[:setupFeeCents[:termMonths[:cadence]]]]`);
14
+ const line = { catalogSlug: slug, quantity: qty ? Number(qty) : 1 };
15
+ if (unit) line.unitPriceCents = Number(unit);
16
+ if (setup) line.setupFeeCents = Number(setup);
17
+ if (term) line.termMonths = Number(term);
18
+ if (cadence) line.cadence = cadence;
19
+ return line;
20
+ }
21
+
22
+ function printForm(f) {
23
+ console.log(`Order form ${f.id}: ${f.customerName} [${f.status}]`);
24
+ console.log(` payment=${f.paymentMode} invoiceDetail=${f.invoiceDetailMode} applyOnSign=${f.applyOnSign} contractId=${f.contractId ?? '-'}`);
25
+ for (const l of f.lines ?? []) {
26
+ console.log(` - ${l.catalogSlug} x${l.quantity} @ ${money(l.unitPriceCents)} (list ${money(l.listPriceCents)}) setup ${money(l.setupFeeCents)} ${l.termMonths}mo ${l.cadence}`);
27
+ }
28
+ }
29
+
30
+ async function createForm(args) {
31
+ const lines = [].concat(args.line ?? []).map(parseLine);
32
+ if (!args.customer || lines.length === 0) {
33
+ console.error('Usage: velaro order-form create --customer "Acme" --line slug:qty[:unitCents[:setupCents[:termMonths[:cadence]]]] [--line ...] [--site-id N] [--target v20] [--target-account-id X] [--payment-mode manual|card_checkout|invoice_net_terms] [--invoice-detail LineItemized|TermsReference] [--apply-on-sign]');
34
+ process.exit(1);
35
+ }
36
+ const body = {
37
+ customerName: args.customer,
38
+ siteId: args.siteId,
39
+ targetSystem: args.target,
40
+ targetAccountId: args.targetAccountId,
41
+ paymentMode: args.paymentMode,
42
+ invoiceDetailMode: args.invoiceDetail,
43
+ applyOnSign: !!args.applyOnSign,
44
+ lines,
45
+ };
46
+ const f = await request('POST', '/api/OrderForm', body);
47
+ printForm(f);
48
+ }
49
+
50
+ async function getForm(id) {
51
+ printForm(await request('GET', `/api/OrderForm/${id}`));
52
+ }
53
+
54
+ async function listForms(args) {
55
+ const q = args.status ? `?status=${encodeURIComponent(args.status)}` : '';
56
+ const forms = await request('GET', `/api/OrderForm${q}`);
57
+ if (!forms.length) { console.log('No order forms.'); return; }
58
+ for (const f of forms) console.log(`${f.id}\t${f.status}\t${f.customerName}\tcontract=${f.contractId ?? '-'}`);
59
+ }
60
+
61
+ async function generateContract(id, args) {
62
+ if (!args.email) {
63
+ console.error('Usage: velaro order-form generate-contract <id> --email buyer@example.com [--name "Buyer Name"]');
64
+ process.exit(1);
65
+ }
66
+ const r = await request('POST', `/api/OrderForm/${id}/generate-contract`, { customerEmail: args.email, customerName: args.name });
67
+ console.log(`Contract ${r.contractId} generated (draft). Run: velaro order-form send ${id}`);
68
+ }
69
+
70
+ async function sendForm(id, args) {
71
+ const r = await request('POST', `/api/OrderForm/${id}/send`, { expirationDays: args.expirationDays });
72
+ console.log(`Sent. emailSent=${r.emailSent}${r.emailError ? ` (${r.emailError})` : ''}\nSigning URL: ${r.signingUrl}`);
73
+ }
74
+
75
+ async function voidForm(id) {
76
+ const f = await request('POST', `/api/OrderForm/${id}/void`);
77
+ console.log(`Order form ${f.id} is now ${f.status}.`);
78
+ }
79
+
80
+ export const orderFormCommand = {
81
+ command: 'order-form <subcommand>',
82
+ describe: 'Draft, contract, send, and void order forms (staff only)',
83
+ builder: (yargs) =>
84
+ yargs
85
+ .command('create', 'Create a draft order form (lines validated against the catalog)', {
86
+ customer: { type: 'string' },
87
+ line: { type: 'string', array: true, describe: 'slug:qty[:unitPriceCents[:setupFeeCents[:termMonths[:cadence]]]]' },
88
+ 'site-id': { type: 'number' },
89
+ target: { type: 'string' },
90
+ 'target-account-id': { type: 'string' },
91
+ 'payment-mode': { type: 'string', choices: ['manual', 'card_checkout', 'invoice_net_terms'] },
92
+ 'invoice-detail': { type: 'string', choices: ['LineItemized', 'TermsReference'] },
93
+ 'apply-on-sign': { type: 'boolean', default: false },
94
+ }, (args) => createForm(args))
95
+ .command('get <id>', 'Show an order form', {}, (args) => getForm(args.id))
96
+ .command('list', 'List order forms', { status: { type: 'string' } }, (args) => listForms(args))
97
+ .command('generate-contract <id>', 'Generate the draft contract from the order form', {
98
+ email: { type: 'string' },
99
+ name: { type: 'string' },
100
+ }, (args) => generateContract(args.id, args))
101
+ .command('send <id>', 'Send the generated contract for signature via Velaro Sign', {
102
+ 'expiration-days': { type: 'number' },
103
+ }, (args) => sendForm(args.id, args))
104
+ .command('void <id>', 'Void an order form (and its unsigned contract)', {}, (args) => voidForm(args.id))
105
+ .demandCommand(1),
106
+ handler: () => {},
107
+ };