@velaro/cli 1.4.54 → 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.
- package/README.md +9 -0
- package/assets/billing-doc-patterns/concentric-arcs.png +0 -0
- package/assets/billing-doc-patterns/diagonal-hatch.png +0 -0
- package/assets/billing-doc-patterns/dot-grid.png +0 -0
- package/assets/billing-doc-patterns/hex-grid.png +0 -0
- package/assets/billing-doc-patterns/wave-lines.png +0 -0
- package/assets/velaro-logo-lockup-full-color.png +0 -0
- package/bin/velaro.js +4 -0
- package/lib/commands/article.js +7 -3
- package/lib/commands/billing-subscription.js +610 -3
- package/lib/commands/contact-list.js +88 -1
- package/lib/commands/db.js +3 -0
- package/lib/commands/entitlement.js +24 -1
- package/lib/commands/login.js +7 -6
- package/lib/commands/logs.js +129 -5
- package/lib/commands/mcp-key.js +24 -14
- package/lib/commands/order-form.js +107 -0
- package/lib/commands/outbound-activity.js +124 -0
- package/lib/commands/subscription.js +61 -2
- package/lib/commands/support.js +19 -1
- package/lib/commands/teams.js +49 -1
- package/lib/config.js +24 -2
- package/lib/config.test.js +94 -0
- package/lib/kb-screenshot.js +120 -43
- package/lib/mcp-key.test.js +17 -0
- package/lib/pdf/billing-document.js +534 -0
- package/lib/pdf/billing-document.test.js +349 -0
- package/lib/site-flags.js +71 -0
- package/lib/site-flags.test.js +57 -0
- package/package.json +6 -3
|
@@ -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
|
};
|
package/lib/commands/db.js
CHANGED
|
@@ -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
|
}
|
package/lib/commands/login.js
CHANGED
|
@@ -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
|
|
22
|
-
const 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
|
|
package/lib/commands/logs.js
CHANGED
|
@@ -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
|
};
|
package/lib/commands/mcp-key.js
CHANGED
|
@@ -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
|
|
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
|
|
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: '
|
|
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 =
|
|
162
|
-
const matches =
|
|
163
|
-
? keys
|
|
164
|
-
: keys
|
|
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
|
-
|
|
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
|
+
};
|