@velaro/cli 1.4.103 → 1.4.104
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/velaro.js +2 -0
- package/lib/apple-pay-presets.js +66 -0
- package/lib/apple-pay-presets.test.js +61 -0
- package/lib/commands/apple-pay-preset.js +65 -0
- package/lib/commands/billing-automation.js +12 -0
- package/lib/commands/entitlement.js +51 -1
- package/lib/commands/env.js +1 -1
- package/lib/commands/logs.js +0 -11
- package/lib/commands/mcp-key.js +1 -17
- package/lib/commands/workflow.js +124 -2
- package/lib/messaging-routes.test.js +1 -1
- package/package.json +1 -1
package/bin/velaro.js
CHANGED
|
@@ -42,6 +42,7 @@ import { calendlyCommand } from '../lib/commands/calendly.js';
|
|
|
42
42
|
import { copilotStudioCommand } from '../lib/commands/copilotstudio.js';
|
|
43
43
|
import { acuityCommand } from '../lib/commands/acuity.js';
|
|
44
44
|
import { avatarCommand } from '../lib/commands/avatar.js';
|
|
45
|
+
import { applePayPresetCommand } from '../lib/commands/apple-pay-preset.js';
|
|
45
46
|
import { imageAnalysisCommand } from '../lib/commands/image-analysis.js';
|
|
46
47
|
import { integrationCommand } from '../lib/commands/integration.js';
|
|
47
48
|
import { appointmentsCommand } from '../lib/commands/appointments.js';
|
|
@@ -185,6 +186,7 @@ await yargs(hideBin(process.argv))
|
|
|
185
186
|
.command(copilotStudioCommand)
|
|
186
187
|
.command(acuityCommand)
|
|
187
188
|
.command(avatarCommand)
|
|
189
|
+
.command(applePayPresetCommand)
|
|
188
190
|
.command(imageAnalysisCommand)
|
|
189
191
|
.command(integrationCommand)
|
|
190
192
|
.command(appointmentsCommand)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Pure helpers for `velaro apple-pay-preset`: parse line items and build request bodies for the
|
|
2
|
+
// messaging ApplePayPresetOrders API. Totals are always computed server-side; never sent from here.
|
|
3
|
+
|
|
4
|
+
export const MAX_LINE_ITEMS = 20;
|
|
5
|
+
|
|
6
|
+
/** Parse one "Label=12.50" spec. The last "=" splits label from amount so labels may contain "=". */
|
|
7
|
+
export function parseItem(spec) {
|
|
8
|
+
const s = String(spec ?? '');
|
|
9
|
+
const i = s.lastIndexOf('=');
|
|
10
|
+
if (i < 1) throw new Error(`Line item "${s}" must look like Label=12.50`);
|
|
11
|
+
const label = s.slice(0, i).trim();
|
|
12
|
+
const raw = s.slice(i + 1).trim();
|
|
13
|
+
if (!label) throw new Error(`Line item "${s}" needs a label`);
|
|
14
|
+
if (!/^-?\d+(\.\d{1,2})?$/.test(raw)) throw new Error(`Line item "${label}" amount "${raw}" must be a number with at most 2 decimals`);
|
|
15
|
+
return { label, amount: Number(raw) };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Accepts --item specs (string or array) and/or --items-json '[{"label":"x","amount":1}]'. Returns undefined if neither given. */
|
|
19
|
+
export function parseItems({ item, itemsJson } = {}) {
|
|
20
|
+
let items;
|
|
21
|
+
if (itemsJson !== undefined) {
|
|
22
|
+
let parsed;
|
|
23
|
+
try { parsed = JSON.parse(itemsJson); } catch { throw new Error('--items-json must be valid JSON'); }
|
|
24
|
+
if (!Array.isArray(parsed)) throw new Error('--items-json must be a JSON array');
|
|
25
|
+
items = parsed.map(p => {
|
|
26
|
+
if (!p || typeof p.label !== 'string' || !p.label.trim() || typeof p.amount !== 'number' || !Number.isFinite(p.amount)) {
|
|
27
|
+
throw new Error('Each --items-json entry needs a string label and a numeric amount');
|
|
28
|
+
}
|
|
29
|
+
return { label: p.label.trim(), amount: p.amount };
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
if (item !== undefined) {
|
|
33
|
+
const specs = Array.isArray(item) ? item : [item];
|
|
34
|
+
items = (items ?? []).concat(specs.map(parseItem));
|
|
35
|
+
}
|
|
36
|
+
if (items && items.length > MAX_LINE_ITEMS) throw new Error(`A preset order holds at most ${MAX_LINE_ITEMS} line items`);
|
|
37
|
+
return items;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Body for POST/PUT ApplePayPresetOrders. Only passed fields are included (PUT is a partial update). */
|
|
41
|
+
export function presetBody(args) {
|
|
42
|
+
const body = {};
|
|
43
|
+
if (args.name !== undefined) body.name = String(args.name);
|
|
44
|
+
if (args.description !== undefined) body.description = String(args.description);
|
|
45
|
+
if (args.currency !== undefined) body.currencyCode = String(args.currency).toUpperCase();
|
|
46
|
+
const items = parseItems({ item: args.item, itemsJson: args.itemsJson });
|
|
47
|
+
if (items !== undefined) body.lineItems = items;
|
|
48
|
+
if (args.active !== undefined) body.isActive = Boolean(args.active);
|
|
49
|
+
if (args.managersOnly !== undefined) body.overrideRestrictedToManagers = Boolean(args.managersOnly);
|
|
50
|
+
return body;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function presetPath(id) {
|
|
54
|
+
const n = Number(id);
|
|
55
|
+
if (!Number.isInteger(n) || n <= 0) throw new Error('Preset id must be a positive whole number');
|
|
56
|
+
return `/ApplePayPresetOrders/${n}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function listPath(activeOnly) {
|
|
60
|
+
return `/ApplePayPresetOrders${activeOnly ? '?activeOnly=true' : ''}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function formatPreset(p) {
|
|
64
|
+
const items = (p.lineItems || []).map(li => ` ${li.label} ${Number(li.amount).toFixed(2)} ${p.currencyCode}`).join('\n');
|
|
65
|
+
return `#${p.id} ${p.name}${p.isActive ? '' : ' (inactive)'}${p.overrideRestrictedToManagers ? ' [managers-only amount changes]' : ''}\n${items}\n Total ${Number(p.totalAmount).toFixed(2)} ${p.currencyCode}`;
|
|
66
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { parseItem, parseItems, presetBody, presetPath, listPath, formatPreset } from './apple-pay-presets.js';
|
|
5
|
+
|
|
6
|
+
test('parseItem splits on the last "=" and validates the amount', () => {
|
|
7
|
+
assert.deepEqual(parseItem('Haircut=30'), { label: 'Haircut', amount: 30 });
|
|
8
|
+
assert.deepEqual(parseItem('A=B=1.50'), { label: 'A=B', amount: 1.5 });
|
|
9
|
+
assert.throws(() => parseItem('Haircut'), /Label=12.50/);
|
|
10
|
+
assert.throws(() => parseItem('=5'), /Label=12.50/);
|
|
11
|
+
assert.throws(() => parseItem('x=abc'), /number/);
|
|
12
|
+
assert.throws(() => parseItem('x=1.234'), /2 decimals/);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('parseItems merges --item and --items-json, caps at 20, rejects bad json', () => {
|
|
16
|
+
assert.equal(parseItems({}), undefined);
|
|
17
|
+
assert.deepEqual(parseItems({ item: 'a=1' }), [{ label: 'a', amount: 1 }]);
|
|
18
|
+
assert.deepEqual(parseItems({ itemsJson: '[{"label":"b","amount":2}]', item: ['c=3'] }), [{ label: 'b', amount: 2 }, { label: 'c', amount: 3 }]);
|
|
19
|
+
assert.throws(() => parseItems({ itemsJson: '{' }), /valid JSON/);
|
|
20
|
+
assert.throws(() => parseItems({ itemsJson: '{}' }), /array/);
|
|
21
|
+
assert.throws(() => parseItems({ itemsJson: '[{"label":"","amount":1}]' }), /label/);
|
|
22
|
+
assert.throws(() => parseItems({ item: Array.from({ length: 21 }, (_, i) => `i${i}=1`) }), /at most 20/);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('presetBody includes only passed fields and never a total', () => {
|
|
26
|
+
assert.deepEqual(presetBody({}), {});
|
|
27
|
+
assert.deepEqual(presetBody({ active: false }), { isActive: false });
|
|
28
|
+
const b = presetBody({ name: 'Cut', currency: 'usd', item: ['Haircut=30'], managersOnly: true, active: true, description: 'd' });
|
|
29
|
+
assert.deepEqual(b, { name: 'Cut', description: 'd', currencyCode: 'USD', lineItems: [{ label: 'Haircut', amount: 30 }], isActive: true, overrideRestrictedToManagers: true });
|
|
30
|
+
assert.equal('totalAmount' in b, false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('paths validate ids', () => {
|
|
34
|
+
assert.equal(presetPath('7'), '/ApplePayPresetOrders/7');
|
|
35
|
+
assert.throws(() => presetPath('0'), /positive/);
|
|
36
|
+
assert.throws(() => presetPath('x'), /positive/);
|
|
37
|
+
assert.equal(listPath(true), '/ApplePayPresetOrders?activeOnly=true');
|
|
38
|
+
assert.equal(listPath(false), '/ApplePayPresetOrders');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('formatPreset shows items and total', () => {
|
|
42
|
+
const out = formatPreset({ id: 3, name: 'Cut', isActive: false, overrideRestrictedToManagers: true, currencyCode: 'USD', totalAmount: 35, lineItems: [{ label: 'Haircut', amount: 30 }, { label: 'Tip', amount: 5 }] });
|
|
43
|
+
assert.match(out, /#3 Cut \(inactive\) \[managers-only/);
|
|
44
|
+
assert.match(out, /Total {2}35.00 USD/);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('MCP server exposes the four preset tools wired to ApplePayPresetOrders on the messaging api', () => {
|
|
48
|
+
const src = fs.readFileSync(new URL('../../mcp/server.js', import.meta.url), 'utf8');
|
|
49
|
+
for (const t of ['apple_pay_preset_list', 'apple_pay_preset_create', 'apple_pay_preset_update', 'apple_pay_preset_delete']) {
|
|
50
|
+
assert.ok(src.includes(`name: '${t}'`), `tool def ${t}`);
|
|
51
|
+
assert.ok(src.includes(`case '${t}':`), `handler ${t}`);
|
|
52
|
+
}
|
|
53
|
+
assert.match(src, /messagingRequest\('POST', '\/ApplePayPresetOrders'/);
|
|
54
|
+
assert.match(src, /messagingRequest\('DELETE', `\/ApplePayPresetOrders\//);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('command file uses messaging helpers only', () => {
|
|
58
|
+
const src = fs.readFileSync(new URL('./commands/apple-pay-preset.js', import.meta.url), 'utf8');
|
|
59
|
+
assert.match(src, /messagingGet, messagingPost, messagingPut, messagingDel/);
|
|
60
|
+
assert.doesNotMatch(src, /import\s*\{[^}]*\b(get|post|del|request)\b[^}]*\}\s*from '\.\.\/api\.js'/);
|
|
61
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { messagingGet, messagingPost, messagingPut, messagingDel } from '../api.js';
|
|
2
|
+
import { presetBody, presetPath, listPath, formatPreset } from '../apple-pay-presets.js';
|
|
3
|
+
import { runCommand } from '../run.js';
|
|
4
|
+
|
|
5
|
+
// ── velaro apple-pay-preset ──────────────────────────────────────────────────
|
|
6
|
+
// Pre-canned Apple Pay orders (messaging ApplePayPresetOrders API). Messaging routes, so
|
|
7
|
+
// every call goes through the messaging helpers. Totals are computed by the server.
|
|
8
|
+
// Registered as `apple-pay-preset` until the `apple-pay` command (PR #1774) lands, then it can nest as `apple-pay preset`.
|
|
9
|
+
|
|
10
|
+
const fieldOptions = y => y
|
|
11
|
+
.option('name', { type: 'string', describe: 'Preset name' })
|
|
12
|
+
.option('description', { type: 'string', describe: 'Preset description' })
|
|
13
|
+
.option('currency', { type: 'string', describe: 'ISO currency code, e.g. USD' })
|
|
14
|
+
.option('item', { type: 'string', array: true, describe: 'Line item as "Label=12.50" (repeatable, max 20)' })
|
|
15
|
+
.option('items-json', { type: 'string', describe: 'Line items as JSON: [{"label":"Haircut","amount":30}]' })
|
|
16
|
+
.option('active', { type: 'boolean', describe: 'Preset is available to agents and the AI' })
|
|
17
|
+
.option('managers-only', { type: 'boolean', describe: 'Only managers can change amounts at send time' });
|
|
18
|
+
|
|
19
|
+
export const applePayPresetCommand = {
|
|
20
|
+
command: 'apple-pay-preset <subcommand>',
|
|
21
|
+
describe: 'Manage pre-canned Apple Pay orders',
|
|
22
|
+
builder: yargs => yargs
|
|
23
|
+
.command({
|
|
24
|
+
command: 'list',
|
|
25
|
+
describe: 'List preset orders',
|
|
26
|
+
builder: y => y.option('active-only', { type: 'boolean', default: false }),
|
|
27
|
+
handler: runCommand(async argv => {
|
|
28
|
+
const rows = await messagingGet(listPath(argv.activeOnly));
|
|
29
|
+
const list = Array.isArray(rows) ? rows : [];
|
|
30
|
+
console.log(list.length ? `\n${list.map(formatPreset).join('\n\n')}\n` : '\nNo preset orders.\n');
|
|
31
|
+
}),
|
|
32
|
+
})
|
|
33
|
+
.command({
|
|
34
|
+
command: 'get <id>',
|
|
35
|
+
describe: 'Show one preset order',
|
|
36
|
+
handler: runCommand(async argv => { console.log(`\n${formatPreset(await messagingGet(presetPath(argv.id)))}\n`); }),
|
|
37
|
+
})
|
|
38
|
+
.command({
|
|
39
|
+
command: 'create',
|
|
40
|
+
describe: 'Create a preset order',
|
|
41
|
+
builder: y => fieldOptions(y).demandOption('name'),
|
|
42
|
+
handler: runCommand(async argv => {
|
|
43
|
+
const body = presetBody(argv);
|
|
44
|
+
if (!body.lineItems || !body.lineItems.length) throw new Error('Provide at least one --item "Label=12.50" (or --items-json)');
|
|
45
|
+
console.log(`\nCreated:\n${formatPreset(await messagingPost('/ApplePayPresetOrders', body))}\n`);
|
|
46
|
+
}),
|
|
47
|
+
})
|
|
48
|
+
.command({
|
|
49
|
+
command: 'update <id>',
|
|
50
|
+
describe: 'Update a preset order (only passed fields change)',
|
|
51
|
+
builder: fieldOptions,
|
|
52
|
+
handler: runCommand(async argv => {
|
|
53
|
+
const body = presetBody(argv);
|
|
54
|
+
if (!Object.keys(body).length) throw new Error('Pass at least one field to change');
|
|
55
|
+
console.log(`\nUpdated:\n${formatPreset(await messagingPut(presetPath(argv.id), body))}\n`);
|
|
56
|
+
}),
|
|
57
|
+
})
|
|
58
|
+
.command({
|
|
59
|
+
command: 'delete <id>',
|
|
60
|
+
describe: 'Delete a preset order',
|
|
61
|
+
handler: runCommand(async argv => { await messagingDel(presetPath(argv.id)); console.log(`\nDeleted preset ${Number(argv.id)}.\n`); }),
|
|
62
|
+
})
|
|
63
|
+
.demandCommand(1),
|
|
64
|
+
handler: () => {},
|
|
65
|
+
};
|
|
@@ -136,6 +136,16 @@ async function setReceipts(subscriptionId, args) {
|
|
|
136
136
|
console.log(`Subscription ${res.id} receipts: card=${res.card ?? 'inherit'} ach=${res.ach ?? 'inherit'} other=${res.other ?? 'inherit'}.`);
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
async function digestSendNow() {
|
|
140
|
+
const res = await request('POST', '/api/BillingNotifications/digest/send-now', {});
|
|
141
|
+
console.log(`Digest sent to ${res.recipients.join(', ')}: ${res.subject}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function receiptsRunNow() {
|
|
145
|
+
const res = await request('POST', '/api/BillingNotifications/receipts/run-now', {});
|
|
146
|
+
console.log(`Receipts run: ${res.summary}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
139
149
|
export const billingAutomationCommand = {
|
|
140
150
|
command: 'billing-automation <subcommand>',
|
|
141
151
|
describe: 'Review-hold and dry-run switches for invoice emails and charges, the review queue, and account types',
|
|
@@ -161,6 +171,8 @@ export const billingAutomationCommand = {
|
|
|
161
171
|
.command('dismiss <id>', 'Dismiss a held entry (it stays on hold, alerts stop)', {}, (args) => dismiss(args.id))
|
|
162
172
|
.command('account-type <subscriptionId> [value]', 'Set a subscription account type that rules match on (omit value to clear)', {}, (args) => setAccountType(args.subscriptionId, args.value))
|
|
163
173
|
.command('notify-settings', 'Show the global notification settings (receipts, invoice BCC, digest recipients)', {}, () => notifySettings())
|
|
174
|
+
.command('digest-send-now', 'Send the billing digest now to digest.recipients (same email as the 7am run)', {}, () => digestSendNow())
|
|
175
|
+
.command('receipts-run-now', 'Decide pending payment receipts now instead of at :05 and print the counts', {}, () => receiptsRunNow())
|
|
164
176
|
.command('notify-set <key> <value>', 'Set a global notification setting: receipts.card|receipts.ach|receipts.other = never|send; invoices.bcc = emails (blank = none); digest.recipients = emails', {}, (args) => notifySet(args.key, args.value))
|
|
165
177
|
.command('receipts <subscriptionId>', 'Set receipt overrides on one subscription (--card/--ach/--other = never|send). Channels you omit keep their current override.', {
|
|
166
178
|
card: { type: 'string', choices: ['never', 'send'] },
|
|
@@ -166,6 +166,45 @@ async function reconcile(args) {
|
|
|
166
166
|
}
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
+
async function trial(args) {
|
|
170
|
+
const sub = args.action;
|
|
171
|
+
const need = (v, n) => { if (v === undefined || v === null || v === '') { console.error(`--${n} is required for trial ${sub}`); process.exit(1); } };
|
|
172
|
+
if (sub === 'get' || sub === 'recommend') {
|
|
173
|
+
need(args.site, 'site');
|
|
174
|
+
const r = await request('GET', `Entitlements/${args.site}/trial${sub === 'recommend' ? '/recommendation' : ''}`);
|
|
175
|
+
console.log(JSON.stringify(r, null, 2));
|
|
176
|
+
} else if (sub === 'not-paying') {
|
|
177
|
+
console.log(JSON.stringify(await request('GET', 'Entitlements/admin/trial/not-paying'), null, 2));
|
|
178
|
+
} else if (sub === 'grant') {
|
|
179
|
+
need(args.site, 'site'); need(args.feature, 'feature'); need(args.reason, 'reason');
|
|
180
|
+
const r = await request('POST', `Entitlements/${args.site}/trial/grant`, { featureKey: args.feature, reason: args.reason });
|
|
181
|
+
console.log(`Site ${args.site}: ${r.featureKey} granted for the trial (${r.state}).`);
|
|
182
|
+
} else if (sub === 'hold' || sub === 'reactivate') {
|
|
183
|
+
need(args.site, 'site');
|
|
184
|
+
const body = { featureKey: args.feature, dryRun: !!args['dry-run'] };
|
|
185
|
+
console.log(JSON.stringify(await request('POST', `Entitlements/${args.site}/trial/${sub}`, body)));
|
|
186
|
+
} else if (sub === 'set-limit') {
|
|
187
|
+
need(args.site, 'site'); need(args.key, 'key'); need(args.value, 'value'); need(args.reason, 'reason');
|
|
188
|
+
const r = await request('POST', `Entitlements/${args.site}/trial/limit`, { key: args.key, value: Number(args.value), reason: args.reason });
|
|
189
|
+
console.log(`Site ${args.site}: ${r.key} ${r.old} -> ${r.value}.`);
|
|
190
|
+
} else if (sub === 'set-text') {
|
|
191
|
+
need(args.feature, 'feature');
|
|
192
|
+
console.log(JSON.stringify(await request('POST', 'Entitlements/admin/trial/feature-text', { featureKey: args.feature, text: args.text })));
|
|
193
|
+
} else if (sub === 'approve-limit' || sub === 'deny-limit') {
|
|
194
|
+
need(args.request, 'request');
|
|
195
|
+
if (sub === 'approve-limit') {
|
|
196
|
+
need(args.key, 'key'); need(args.value, 'value');
|
|
197
|
+
console.log(JSON.stringify(await request('POST', `Entitlements/admin/trial/limit-request/${args.request}/approve`, { key: args.key, value: Number(args.value), message: args.message })));
|
|
198
|
+
} else {
|
|
199
|
+
need(args.reason, 'reason');
|
|
200
|
+
console.log(JSON.stringify(await request('POST', `Entitlements/admin/trial/limit-request/${args.request}/deny`, { reason: args.reason })));
|
|
201
|
+
}
|
|
202
|
+
} else {
|
|
203
|
+
console.error('Unknown trial action. Use: get | recommend | not-paying | grant | hold | reactivate | set-limit | approve-limit | deny-limit | set-text');
|
|
204
|
+
process.exit(1);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
169
208
|
async function setLock(args) {
|
|
170
209
|
if (!args.site) {
|
|
171
210
|
console.error('Usage: velaro entitlement set-lock --site 1032 --at 2026-01-01T00:00:00Z\n velaro entitlement set-lock --site 1032 --unlock');
|
|
@@ -250,10 +289,21 @@ export function entitlementCommand(yargs) {
|
|
|
250
289
|
.option('at', { type: 'string', desc: 'ISO 8601 timestamp to lock entitlement resolution to' })
|
|
251
290
|
.option('unlock', { type: 'boolean', desc: 'Clear an existing lock (resolves against current plan version again)' })
|
|
252
291
|
, a => setLock(a))
|
|
292
|
+
.command('trial <action>', 'Trial governance (superadmin): get | recommend | not-paying | grant | hold | reactivate | set-limit | approve-limit | deny-limit | set-text', y => y
|
|
293
|
+
.option('site', { type: 'number', desc: 'Site ID' })
|
|
294
|
+
.option('feature', { type: 'string', desc: 'Feature key, e.g. EnableIvr' })
|
|
295
|
+
.option('key', { type: 'string', desc: 'Cap name, e.g. MaxKbArticles' })
|
|
296
|
+
.option('value', { type: 'string', desc: 'Whole number cap' })
|
|
297
|
+
.option('reason', { type: 'string', desc: 'Reason (audited)' })
|
|
298
|
+
.option('request', { type: 'number', desc: 'Limit-increase request id' })
|
|
299
|
+
.option('message', { type: 'string', desc: 'approve-limit: note added to the customer reply' })
|
|
300
|
+
.option('text', { type: 'string', desc: 'set-text: customer-safe, number-free limit text' })
|
|
301
|
+
.option('dry-run', { type: 'boolean', desc: 'hold: report only, write nothing' })
|
|
302
|
+
, a => trial(a))
|
|
253
303
|
.command('list-cutover', 'List all feature keys and whether they are live (messaging reads admin) or not (Subscription.cs)', () => {}, () => listCutover())
|
|
254
304
|
.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
|
|
255
305
|
.option('key', { type: 'string', demandOption: true, desc: 'Feature key, e.g. EnableIvr' })
|
|
256
306
|
.option('rollback', { type: 'boolean', desc: 'Pass to roll back this flag to Subscription.cs (default: go live)' })
|
|
257
307
|
, a => setCutover(a))
|
|
258
|
-
.demandCommand(1, 'Specify a subcommand: get | list-features | enterprise-reports | report-packages | set-override | remove-override | seed | seed-plans | backfill | backfill-sap-tiers | backfill-legacy-write-tiers | reconcile | comp | set-lock | list-cutover | set-cutover');
|
|
308
|
+
.demandCommand(1, 'Specify a subcommand: get | list-features | enterprise-reports | report-packages | set-override | remove-override | seed | seed-plans | backfill | backfill-sap-tiers | backfill-legacy-write-tiers | reconcile | comp | set-lock | trial | list-cutover | set-cutover');
|
|
259
309
|
}
|
package/lib/commands/env.js
CHANGED
|
@@ -32,7 +32,7 @@ export const envCommand = {
|
|
|
32
32
|
? `logged in as ${creds.userName ?? 'unknown'} (site ${creds.siteId})`
|
|
33
33
|
: 'not logged in';
|
|
34
34
|
const expiry = creds?.velaroExpires
|
|
35
|
-
? new Date(creds.velaroExpires) > new Date() ? '' : ' ⚠ token expired'
|
|
35
|
+
? new Date(creds.velaroExpires) > new Date() ? '' : creds.entraRefreshToken ? ' (session token idle; auto-refreshes on next command)' : ' ⚠ token expired, run velaro login'
|
|
36
36
|
: '';
|
|
37
37
|
console.log(`${marker}${name.padEnd(10)} ${status}${expiry}`);
|
|
38
38
|
console.log(` ${urls.adminApiBase}`);
|
package/lib/commands/logs.js
CHANGED
|
@@ -362,8 +362,6 @@ function parsePositiveIntFlag(flagName, rawValue, { max } = {}) {
|
|
|
362
362
|
// emails/sms isn't wrongly flagged as "fires into the void" when defaults
|
|
363
363
|
// actually cover it.
|
|
364
364
|
function hasAnyRecipient({ emails, sms, webhook, teams, mobileUserIds, hasGlobalDefaultContacts }) {
|
|
365
|
-
// also cover webhook, which that component's check omits).
|
|
366
|
-
function hasAnyRecipient({ emails, sms, webhook, mobileUserIds, teams }) {
|
|
367
365
|
return Boolean(
|
|
368
366
|
(emails && String(emails).trim()) ||
|
|
369
367
|
(sms && String(sms).trim()) ||
|
|
@@ -374,8 +372,6 @@ function hasAnyRecipient({ emails, sms, webhook, mobileUserIds, teams }) {
|
|
|
374
372
|
// ResolveRecipients) -- if there's at least one global-default contact with a phone or
|
|
375
373
|
// email, a rule with both blank is NOT actually silent, even though neither field is set.
|
|
376
374
|
((!emails || !String(emails).trim()) && (!sms || !String(sms).trim()) && hasGlobalDefaultContacts)
|
|
377
|
-
(mobileUserIds && String(mobileUserIds).trim()) ||
|
|
378
|
-
teams
|
|
379
375
|
);
|
|
380
376
|
}
|
|
381
377
|
|
|
@@ -532,10 +528,6 @@ async function createAlertRule(argv) {
|
|
|
532
528
|
if (!hasAnyRecipient({ emails: argv.emails, sms: argv.sms, webhook: argv.webhook, teams: argv.teams, mobileUserIds: argv.mobileUserIds, hasGlobalDefaultContacts: hasDefaults })
|
|
533
529
|
&& !argv.noRecipientsOk) {
|
|
534
530
|
console.error('No notification channel given (--emails, --sms, --webhook, --teams, or --mobile-user-ids), and no global-default contact exists to fall back to — this rule would fire but notify no one.');
|
|
535
|
-
// HIGH: a rule with zero notification channels fires into the void.
|
|
536
|
-
if (!hasAnyRecipient({ emails: argv.emails, sms: argv.sms, webhook: argv.webhook, mobileUserIds: argv.mobileUserIds, teams: argv.teams })
|
|
537
|
-
&& !argv.noRecipientsOk) {
|
|
538
|
-
console.error('No notification channel given (--emails, --sms, --webhook, --teams, or --mobile-user-ids) — this rule would fire but notify no one.');
|
|
539
531
|
console.error('If that\'s intentional (e.g. you only want it visible in Alert History), pass --no-recipients-ok to proceed anyway.');
|
|
540
532
|
console.error('To set up global defaults so a blank --emails/--sms is covered automatically: velaro logs contacts add --name alex --phone +1... --email alex@velaro.com --global');
|
|
541
533
|
process.exit(1);
|
|
@@ -658,9 +650,6 @@ async function updateAlertRule(argv) {
|
|
|
658
650
|
if (!hasAnyRecipient({ emails: body.notifyEmails, sms: body.notifyPhoneNumbers, webhook: body.notifyWebhookUrl, teams: body.notifyTeams, mobileUserIds: body.notifyMobileUserIds, hasGlobalDefaultContacts: hasDefaults })
|
|
659
651
|
&& !argv.noRecipientsOk) {
|
|
660
652
|
console.error('This update would leave the rule with no notification channel (--emails, --sms, --webhook, --teams, or --mobile-user-ids), and no global-default contact exists to fall back to — it would fire but notify no one.');
|
|
661
|
-
if (!hasAnyRecipient({ emails: body.notifyEmails, sms: body.notifyPhoneNumbers, webhook: body.notifyWebhookUrl, mobileUserIds: body.notifyMobileUserIds, teams: body.notifyTeams })
|
|
662
|
-
&& !argv.noRecipientsOk) {
|
|
663
|
-
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.');
|
|
664
653
|
console.error('If that\'s intentional, pass --no-recipients-ok to proceed anyway.');
|
|
665
654
|
process.exit(1);
|
|
666
655
|
}
|
package/lib/commands/mcp-key.js
CHANGED
|
@@ -106,21 +106,6 @@ const mcpKeyInstallCommand = {
|
|
|
106
106
|
const result = await request('POST', '/McpApiKeys', payload, env);
|
|
107
107
|
const rawKey = result.rawKey;
|
|
108
108
|
|
|
109
|
-
// Claude Code reads user-scope MCP server definitions from the
|
|
110
|
-
// top-level "mcpServers" key in ~/.claude.json — NOT from
|
|
111
|
-
// ~/.claude/settings.json (that file is for permissions/hooks/env,
|
|
112
|
-
// and any mcpServers entry written there is silently ignored).
|
|
113
|
-
// prod → entry named "velaro"
|
|
114
|
-
// staging → entry named "velaro-staging"
|
|
115
|
-
const serverName = env === 'prod' ? 'velaro' : `velaro-${env}`;
|
|
116
|
-
const claudeJsonPath = path.join(os.homedir(), '.claude.json');
|
|
117
|
-
let claudeJson = {};
|
|
118
|
-
if (fs.existsSync(claudeJsonPath)) {
|
|
119
|
-
try { claudeJson = JSON.parse(fs.readFileSync(claudeJsonPath, 'utf8')); } catch {}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
claudeJson.mcpServers = claudeJson.mcpServers || {};
|
|
123
|
-
claudeJson.mcpServers[serverName] = {
|
|
124
109
|
// Claude Code reads MCP server definitions from the top-level "mcpServers" key
|
|
125
110
|
// in ~/.claude.json (NOT ~/.claude/settings.json — that file has no effect here).
|
|
126
111
|
// This is the same shape `claude mcp add` itself writes.
|
|
@@ -152,8 +137,7 @@ const mcpKeyInstallCommand = {
|
|
|
152
137
|
},
|
|
153
138
|
};
|
|
154
139
|
|
|
155
|
-
fs.mkdirSync(path.dirname(
|
|
156
|
-
fs.writeFileSync(claudeJsonPath, JSON.stringify(claudeJson, null, 2));
|
|
140
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
157
141
|
// Claude Code itself writes ~/.claude.json continuously (history, project state),
|
|
158
142
|
// so this must never be a plain in-place write: write to a temp file and rename,
|
|
159
143
|
// which is atomic on the same filesystem — a crash or concurrent write mid-save
|
package/lib/commands/workflow.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
-
import { getCredentials, messagingGet, messagingPost, messagingPut } from '../api.js';
|
|
2
|
+
import { getCredentials, messagingGet, messagingPost, messagingPut, messagingDel } from '../api.js';
|
|
3
3
|
import { requireFeature } from '../subscription.js';
|
|
4
4
|
import { runCommand } from '../run.js';
|
|
5
5
|
import { applyFollowUpOptions } from '../ai-followups.js';
|
|
@@ -18,8 +18,9 @@ export const workflowCommand = {
|
|
|
18
18
|
.command(workflowNodeOptionsCommand)
|
|
19
19
|
.command(workflowIntegrityScanCommand)
|
|
20
20
|
.command(workflowAiFollowupsCommand)
|
|
21
|
+
.command(workflowScheduleCommand)
|
|
21
22
|
.command(siteAiFollowupsCommand)
|
|
22
|
-
.demandCommand(1, 'Specify a subcommand: list, get, create, enable, disable, node-options, integrity-scan, ai-followups, ai-followups-site'),
|
|
23
|
+
.demandCommand(1, 'Specify a subcommand: list, get, create, enable, disable, node-options, integrity-scan, ai-followups, ai-followups-site, schedule'),
|
|
23
24
|
handler: () => {},
|
|
24
25
|
};
|
|
25
26
|
|
|
@@ -358,3 +359,124 @@ const workflowIntegrityScanCommand = {
|
|
|
358
359
|
}
|
|
359
360
|
}),
|
|
360
361
|
};
|
|
362
|
+
|
|
363
|
+
// ── schedule (workflow schedule priority; messaging owns /Workflows/schedule/*) ──────────────
|
|
364
|
+
// Mirrors the velaro MCP tools workflow_schedule_resolve / _timeline / _validate / _banner / _override_save / _override_list / _override_delete.
|
|
365
|
+
// An older messaging answers 404/501: print "not available" instead of failing.
|
|
366
|
+
|
|
367
|
+
const NOT_AVAILABLE = "Workflow scheduling is not available on this site's version.";
|
|
368
|
+
|
|
369
|
+
async function scheduleTolerant(fn) {
|
|
370
|
+
try {
|
|
371
|
+
return await fn();
|
|
372
|
+
} catch (err) {
|
|
373
|
+
if (/->\s*(404|501)\b|\b(404|501)\b/.test(String(err?.message))) { console.log(NOT_AVAILABLE); return undefined; }
|
|
374
|
+
throw err;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const qs = (o) => new URLSearchParams(Object.entries(o).filter(([, v]) => v !== undefined && v !== '')).toString();
|
|
379
|
+
|
|
380
|
+
const workflowScheduleCommand = {
|
|
381
|
+
command: 'schedule <subcommand>',
|
|
382
|
+
describe: 'Workflow schedule priority: set, get, delete, validate, resolve, timeline, gaps',
|
|
383
|
+
builder: (yargs) =>
|
|
384
|
+
yargs
|
|
385
|
+
.command({
|
|
386
|
+
command: 'set',
|
|
387
|
+
describe: 'Create/update a SwitchWorkflow schedule override (run another workflow on given dates)',
|
|
388
|
+
builder: (y) => y
|
|
389
|
+
.option('id', { type: 'number', describe: 'Existing override id to update (omit to create)' })
|
|
390
|
+
.option('schedule', { type: 'number', demandOption: true, describe: 'Shared schedule id' })
|
|
391
|
+
.option('workflow', { type: 'number', demandOption: true, describe: 'Target workflow id to run' })
|
|
392
|
+
.option('name', { type: 'string' })
|
|
393
|
+
.option('start', { type: 'string', demandOption: true, describe: 'YYYY-MM-DD' })
|
|
394
|
+
.option('end', { type: 'string', describe: 'YYYY-MM-DD' }),
|
|
395
|
+
handler: runCommand(async (argv) => {
|
|
396
|
+
await requireFeature('enableAutomation', 'Workflows');
|
|
397
|
+
const body = { sharedScheduleId: argv.schedule, name: argv.name, startDate: argv.start, endDate: argv.end, action: 3, targetWorkflowId: argv.workflow };
|
|
398
|
+
const r = await scheduleTolerant(() => argv.id
|
|
399
|
+
? messagingPut(`/ScheduleOverride/${argv.id}`, body)
|
|
400
|
+
: messagingPost('/ScheduleOverride', body));
|
|
401
|
+
if (r !== undefined) console.log(JSON.stringify(r, null, 2));
|
|
402
|
+
}),
|
|
403
|
+
})
|
|
404
|
+
.command({
|
|
405
|
+
command: 'get',
|
|
406
|
+
describe: 'List this site\'s SwitchWorkflow schedule overrides',
|
|
407
|
+
handler: runCommand(async () => {
|
|
408
|
+
await requireFeature('enableAutomation', 'Workflows');
|
|
409
|
+
const r = await scheduleTolerant(() => messagingGet('/ScheduleOverride?action=3'));
|
|
410
|
+
if (r !== undefined) console.log(JSON.stringify(r, null, 2));
|
|
411
|
+
}),
|
|
412
|
+
})
|
|
413
|
+
.command({
|
|
414
|
+
command: 'delete',
|
|
415
|
+
describe: 'Delete a schedule override by id (site admins only)',
|
|
416
|
+
builder: (y) => y.option('id', { type: 'number', demandOption: true }),
|
|
417
|
+
handler: runCommand(async (argv) => {
|
|
418
|
+
await requireFeature('enableAutomation', 'Workflows');
|
|
419
|
+
const r = await scheduleTolerant(async () => { await messagingDel(`/ScheduleOverride/${argv.id}`); return { success: true, deleted: argv.id }; });
|
|
420
|
+
if (r !== undefined) console.log(JSON.stringify(r, null, 2));
|
|
421
|
+
}),
|
|
422
|
+
})
|
|
423
|
+
.command({
|
|
424
|
+
command: 'validate',
|
|
425
|
+
describe: 'Dry-run a draft override (or no override: list current gaps) without saving',
|
|
426
|
+
builder: (y) => y
|
|
427
|
+
.option('trigger', { type: 'string', default: 'conversation_opened' })
|
|
428
|
+
.option('channel', { type: 'string' })
|
|
429
|
+
.option('schedule', { type: 'number', describe: 'Shared schedule id of the draft override' })
|
|
430
|
+
.option('workflow', { type: 'number', describe: 'Target workflow id of the draft override' })
|
|
431
|
+
.option('name', { type: 'string' })
|
|
432
|
+
.option('start', { type: 'string', describe: 'YYYY-MM-DD' })
|
|
433
|
+
.option('end', { type: 'string', describe: 'YYYY-MM-DD' }),
|
|
434
|
+
handler: runCommand(async (argv) => {
|
|
435
|
+
await requireFeature('enableAutomation', 'Workflows');
|
|
436
|
+
const override = argv.schedule && argv.workflow && argv.start
|
|
437
|
+
? { sharedScheduleId: argv.schedule, name: argv.name, startDate: argv.start, endDate: argv.end, action: 3, targetWorkflowId: argv.workflow }
|
|
438
|
+
: undefined;
|
|
439
|
+
const r = await scheduleTolerant(() => messagingPost('/Workflows/schedule/validate', { trigger: argv.trigger, channel: argv.channel, override }));
|
|
440
|
+
if (r !== undefined) console.log(JSON.stringify(r, null, 2));
|
|
441
|
+
}),
|
|
442
|
+
})
|
|
443
|
+
.command({
|
|
444
|
+
command: 'resolve',
|
|
445
|
+
describe: 'Which workflow runs at a date/time?',
|
|
446
|
+
builder: (y) => y
|
|
447
|
+
.option('trigger', { type: 'string', demandOption: true })
|
|
448
|
+
.option('channel', { type: 'string' })
|
|
449
|
+
.option('at', { type: 'string', describe: 'ISO date-time (default now)' })
|
|
450
|
+
.option('tz', { type: 'string', describe: 'IANA time zone' }),
|
|
451
|
+
handler: runCommand(async (argv) => {
|
|
452
|
+
await requireFeature('enableAutomation', 'Workflows');
|
|
453
|
+
const r = await scheduleTolerant(() => messagingGet('/Workflows/schedule/resolve?' + qs({ trigger: argv.trigger, channel: argv.channel, at: argv.at, tz: argv.tz })));
|
|
454
|
+
if (r !== undefined) console.log(JSON.stringify(r, null, 2));
|
|
455
|
+
}),
|
|
456
|
+
})
|
|
457
|
+
.command({
|
|
458
|
+
command: 'timeline',
|
|
459
|
+
describe: 'Winner timeline, gaps and conflicts over a date range',
|
|
460
|
+
builder: (y) => y
|
|
461
|
+
.option('trigger', { type: 'string', demandOption: true })
|
|
462
|
+
.option('channel', { type: 'string' })
|
|
463
|
+
.option('from', { type: 'string' })
|
|
464
|
+
.option('to', { type: 'string' }),
|
|
465
|
+
handler: runCommand(async (argv) => {
|
|
466
|
+
await requireFeature('enableAutomation', 'Workflows');
|
|
467
|
+
const r = await scheduleTolerant(() => messagingGet('/Workflows/schedule/timeline?' + qs({ trigger: argv.trigger, channel: argv.channel, from: argv.from, to: argv.to })));
|
|
468
|
+
if (r !== undefined) console.log(JSON.stringify(r, null, 2));
|
|
469
|
+
}),
|
|
470
|
+
})
|
|
471
|
+
.command({
|
|
472
|
+
command: 'gaps',
|
|
473
|
+
describe: 'Upcoming gaps where no workflow will run',
|
|
474
|
+
handler: runCommand(async () => {
|
|
475
|
+
await requireFeature('enableAutomation', 'Workflows');
|
|
476
|
+
const r = await scheduleTolerant(() => messagingGet('/Workflows/schedule/banner'));
|
|
477
|
+
if (r !== undefined) console.log(JSON.stringify(r, null, 2));
|
|
478
|
+
}),
|
|
479
|
+
})
|
|
480
|
+
.demandCommand(1, 'Specify: set, get, delete, validate, resolve, timeline, gaps'),
|
|
481
|
+
handler: () => {},
|
|
482
|
+
};
|
|
@@ -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', '/Recruiting', '/superadmin/logs/insights', '/ApplePayConfig'];
|
|
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', '/superadmin/logs/insights', '/ApplePayPresetOrders', '/ApplePayConfig'];
|
|
13
13
|
|
|
14
14
|
test('messaging-only routes are never called through the admin api helpers', () => {
|
|
15
15
|
const offenders = [];
|
package/package.json
CHANGED