@velaro/cli 0.7.0 → 1.4.8
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 +161 -138
- package/bin/velaro.js +177 -62
- package/lib/api.js +91 -52
- package/lib/api.test.js +46 -0
- package/lib/banner.js +76 -0
- package/lib/commands/activity.js +133 -0
- package/lib/commands/acuity.js +66 -0
- package/lib/commands/agent.js +204 -50
- package/lib/commands/ai-config.js +193 -0
- package/lib/commands/ai-models.js +159 -0
- package/lib/commands/appointments.js +198 -0
- package/lib/commands/article.js +668 -388
- package/lib/commands/automation-draft.js +134 -0
- package/lib/commands/avatar.js +75 -0
- package/lib/commands/bigcommerce.js +50 -0
- package/lib/commands/billing-contacts.js +62 -0
- package/lib/commands/billing-email-preference.js +64 -0
- package/lib/commands/billing-subscription.js +265 -0
- package/lib/commands/billing.js +138 -0
- package/lib/commands/bot.js +141 -137
- package/lib/commands/bundle.js +168 -0
- package/lib/commands/calendly.js +62 -0
- package/lib/commands/callback.js +125 -0
- package/lib/commands/callrail.js +88 -0
- package/lib/commands/campaigns.js +44 -0
- package/lib/commands/case.js +102 -0
- package/lib/commands/check.js +163 -163
- package/lib/commands/compliance.js +229 -0
- package/lib/commands/conversation-efficiency.js +178 -0
- package/lib/commands/copilotstudio.js +114 -0
- package/lib/commands/coupon-grant.js +192 -0
- package/lib/commands/db.js +101 -0
- package/lib/commands/deployment.js +107 -107
- package/lib/commands/diagnostics.js +298 -0
- package/lib/commands/email-campaign.js +47 -0
- package/lib/commands/email-inbox.js +88 -0
- package/lib/commands/entitlement.js +176 -0
- package/lib/commands/env.js +45 -45
- package/lib/commands/feature-discovery.js +40 -0
- package/lib/commands/focus.js +278 -0
- package/lib/commands/index.js +38 -5
- package/lib/commands/ingest.js +31 -31
- package/lib/commands/inline-widget-config.js +126 -0
- package/lib/commands/integration.js +93 -0
- package/lib/commands/kb.js +450 -309
- package/lib/commands/login.js +86 -86
- package/lib/commands/logs.js +680 -0
- package/lib/commands/magento.js +210 -0
- package/lib/commands/mcp-key.js +188 -159
- package/lib/commands/migrate.js +134 -0
- package/lib/commands/migration-status.js +66 -0
- package/lib/commands/monday.js +137 -0
- package/lib/commands/netsuite.js +87 -0
- package/lib/commands/notifications.js +63 -0
- package/lib/commands/notion.js +70 -0
- package/lib/commands/ops.js +267 -173
- package/lib/commands/payment-recovery.js +170 -0
- package/lib/commands/pickup.js +172 -0
- package/lib/commands/pricing.js +132 -0
- package/lib/commands/product.js +55 -0
- package/lib/commands/recruiting.js +374 -0
- package/lib/commands/report.js +462 -0
- package/lib/commands/routing.js +304 -0
- package/lib/commands/rule.js +85 -85
- package/lib/commands/sharepoint.js +167 -0
- package/lib/commands/site-provision.js +68 -0
- package/lib/commands/site.js +62 -62
- package/lib/commands/sitesync.js +158 -0
- package/lib/commands/slack.js +64 -0
- package/lib/commands/squarespace.js +108 -0
- package/lib/commands/status.js +24 -24
- package/lib/commands/subscription.js +43 -0
- package/lib/commands/support.js +128 -0
- package/lib/commands/survey.js +216 -0
- package/lib/commands/team.js +144 -144
- package/lib/commands/teams-phone.js +131 -0
- package/lib/commands/teams.js +106 -0
- package/lib/commands/telephony.js +99 -0
- package/lib/commands/update.js +47 -47
- package/lib/commands/webflow.js +128 -0
- package/lib/commands/whoami.js +25 -22
- package/lib/commands/widget-container.js +152 -0
- package/lib/commands/woocommerce.js +240 -0
- package/lib/commands/workflow.js +233 -98
- package/lib/config.js +85 -83
- package/lib/kb-screenshot.js +320 -0
- package/lib/migrations/amscro.json +72 -0
- package/lib/migrations/azenta.json +68 -0
- package/lib/migrations/bluefire.json +49 -0
- package/lib/migrations/donaldson.json +75 -0
- package/lib/oauth.js +149 -135
- package/lib/run.js +21 -16
- package/lib/sharepoint-auth.js +138 -0
- package/lib/subscription.js +41 -39
- package/lib/track.js +35 -35
- package/lib/update-check.js +64 -64
- package/package.json +34 -19
- package/scripts/postinstall.js +12 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// velaro entitlement — query and manage site feature entitlements (superadmin only)
|
|
2
|
+
import { request } from '../api.js';
|
|
3
|
+
|
|
4
|
+
async function getSite(args) {
|
|
5
|
+
if (!args.site) { console.error('Usage: velaro entitlement get --site 1032'); process.exit(1); }
|
|
6
|
+
const result = await request('GET', `Entitlements/${args.site}`);
|
|
7
|
+
|
|
8
|
+
console.log(`\nSite ${result.siteId} — plan: ${result.planKey || 'none'} | status: ${result.status}`);
|
|
9
|
+
if (result.cancelAtPeriodEnd) console.log(`⚠️ Cancels at period end: ${result.currentPeriodEnd}`);
|
|
10
|
+
if (result.trialEndAt) console.log(` Trial ends: ${result.trialEndAt}`);
|
|
11
|
+
|
|
12
|
+
const features = Object.entries(result.features || {});
|
|
13
|
+
const enabled = features.filter(([,v]) => v).map(([k]) => k).sort();
|
|
14
|
+
const disabled = features.filter(([,v]) => !v).map(([k]) => k).sort();
|
|
15
|
+
|
|
16
|
+
console.log(`\nEnabled (${enabled.length}):`);
|
|
17
|
+
for (const k of enabled) console.log(` ✅ ${k}`);
|
|
18
|
+
|
|
19
|
+
if (args.all) {
|
|
20
|
+
console.log(`\nDisabled (${disabled.length}):`);
|
|
21
|
+
for (const k of disabled) console.log(` ❌ ${k}`);
|
|
22
|
+
} else {
|
|
23
|
+
console.log(`\n(${disabled.length} features disabled — use --all to show them)`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function listFeatures() {
|
|
28
|
+
const features = await request('GET', 'Entitlements/features');
|
|
29
|
+
|
|
30
|
+
const byCategory = {};
|
|
31
|
+
for (const f of features) {
|
|
32
|
+
if (!byCategory[f.category]) byCategory[f.category] = [];
|
|
33
|
+
byCategory[f.category].push(f);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
console.log(`\nEntitlementFeature registry (${features.length} features)\n`);
|
|
37
|
+
for (const [cat, rows] of Object.entries(byCategory).sort()) {
|
|
38
|
+
console.log(`── ${cat.toUpperCase()} (${rows.length}) ──`);
|
|
39
|
+
for (const f of rows) console.log(` ${f.featureKey.padEnd(40)} ${f.displayName}`);
|
|
40
|
+
console.log('');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function setOverride(args) {
|
|
45
|
+
if (!args.site || !args.key) {
|
|
46
|
+
console.error('Usage: velaro entitlement set-override --site 1032 --key EnableIvr --value true [--reason "Custom deal"]');
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
const value = args.value === 'true' || args.value === true;
|
|
50
|
+
await request('POST', `Entitlements/${args.site}/override`, {
|
|
51
|
+
featureKey: args.key,
|
|
52
|
+
value,
|
|
53
|
+
reason: args.reason,
|
|
54
|
+
});
|
|
55
|
+
console.log(`✅ Site ${args.site}: ${args.key} = ${value}${args.reason ? ` (${args.reason})` : ''}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function seed() {
|
|
59
|
+
const result = await request('POST', 'Entitlements/admin/seed');
|
|
60
|
+
console.log(`✅ ${result.message}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function compSite(args) {
|
|
64
|
+
if (!args.site || !args.plan) {
|
|
65
|
+
console.error('Usage: velaro entitlement comp --site 1032 --plan plan.professional [--until 2027-01-01] [--notes "Custom deal"]');
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
const result = await request('POST', `Entitlements/admin/comp/${args.site}`, {
|
|
69
|
+
planKey: args.plan,
|
|
70
|
+
compEndAt: args.until ?? null,
|
|
71
|
+
notes: args.notes,
|
|
72
|
+
});
|
|
73
|
+
const expiry = result.compEndAt ? ` — expires ${result.compEndAt}` : ' — indefinite';
|
|
74
|
+
console.log(`✅ Site ${args.site} comped on plan ${result.planKey}${expiry}`);
|
|
75
|
+
console.log(` Use 'velaro entitlement set-override --site ${args.site} ...' to customize features.`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function removeOverride(args) {
|
|
79
|
+
if (!args.site || !args.key) {
|
|
80
|
+
console.error('Usage: velaro entitlement remove-override --site 1032 --key EnableIvr');
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
await request('DELETE', `Entitlements/${args.site}/override/${args.key}`);
|
|
84
|
+
console.log(`✅ Removed override for ${args.key} on site ${args.site}.`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function seedPlans() {
|
|
88
|
+
const result = await request('POST', 'Entitlements/admin/seed-plans');
|
|
89
|
+
if (result.error) { console.error(`❌ ${result.error}`); process.exit(1); }
|
|
90
|
+
console.log(`✅ Plan entitlements seeded: ${result.plansSeeded} plans, ${result.inserted} inserted, ${result.updated} versioned, ${result.unchanged} unchanged.`);
|
|
91
|
+
if (result.plans?.length) console.log(` Plans: ${result.plans.join(', ')}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function backfill() {
|
|
95
|
+
const result = await request('POST', 'Entitlements/admin/backfill-subscriptions');
|
|
96
|
+
console.log(`✅ Backfill: ${result.inserted} inserted, ${result.skipped} already existed, ${result.unknownPlan} skipped (unknown plan).`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function reconcile(args) {
|
|
100
|
+
if (!args.site) {
|
|
101
|
+
console.error('Usage: velaro entitlement reconcile --site 1032');
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
const result = await request('GET', `Entitlements/admin/reconcile/${args.site}`);
|
|
105
|
+
const status = result.deltaCount === 0
|
|
106
|
+
? `✅ ZERO deltas — site ${args.site} is ready for entitlements cutover`
|
|
107
|
+
: `⚠️ ${result.deltaCount} delta(s) — NOT ready for cutover`;
|
|
108
|
+
console.log(`\n${status}`);
|
|
109
|
+
console.log(`Plan: ${result.planKey || 'none'} | Flags checked: ${result.totalFlags} | Matching: ${result.matching}`);
|
|
110
|
+
if (result.deltas?.length) {
|
|
111
|
+
console.log('\nDeltas:');
|
|
112
|
+
for (const d of result.deltas)
|
|
113
|
+
console.log(` ${d.featureKey}: Subscription=${d.subscriptionValue} → Entitlements=${d.entitlementValue}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function listCutover() {
|
|
118
|
+
const result = await request('GET', 'Entitlements/admin/cutover');
|
|
119
|
+
console.log(`\nCutover flags: ${result.liveCount} live / ${result.total} total`);
|
|
120
|
+
const live = (result.flags || []).filter(f => f.isLive);
|
|
121
|
+
const pending = (result.flags || []).filter(f => !f.isLive);
|
|
122
|
+
if (live.length) {
|
|
123
|
+
console.log('\n✅ Live (messaging reads admin instead of Subscription.cs):');
|
|
124
|
+
for (const f of live) console.log(` ${f.featureKey} (since ${f.wentLiveAt?.slice(0,10) || 'n/a'})`);
|
|
125
|
+
}
|
|
126
|
+
if (pending.length) {
|
|
127
|
+
console.log(`\n⏸ Not live (${pending.length} flags) — use 'set-cutover' to flip`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function setCutover(args) {
|
|
132
|
+
if (!args.key) {
|
|
133
|
+
console.error('Usage: velaro entitlement set-cutover --key EnableIvr --live [--rollback]');
|
|
134
|
+
process.exit(1);
|
|
135
|
+
}
|
|
136
|
+
const isLive = !args.rollback;
|
|
137
|
+
const result = await request('PUT', `Entitlements/admin/cutover/${args.key}`, { isLive });
|
|
138
|
+
console.log(result.message);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function entitlementCommand(yargs) {
|
|
142
|
+
return yargs
|
|
143
|
+
.command('get', 'Get resolved feature set for a site', y => y
|
|
144
|
+
.option('site', { type: 'number', demandOption: true, desc: 'Site ID' })
|
|
145
|
+
.option('all', { type: 'boolean', desc: 'Show disabled features too (default: enabled only)' })
|
|
146
|
+
, a => getSite(a))
|
|
147
|
+
.command('list-features', 'List all registered entitlement feature keys', () => {}, () => listFeatures())
|
|
148
|
+
.command('set-override', 'Enable or disable a feature for a specific site (overrides plan)', y => y
|
|
149
|
+
.option('site', { type: 'number', demandOption: true, desc: 'Site ID' })
|
|
150
|
+
.option('key', { type: 'string', demandOption: true, desc: 'Feature key, e.g. EnableIvr' })
|
|
151
|
+
.option('value', { type: 'string', demandOption: true, desc: 'true or false' })
|
|
152
|
+
.option('reason', { type: 'string', desc: 'Reason / notes for the override' })
|
|
153
|
+
, a => setOverride(a))
|
|
154
|
+
.command('remove-override', 'Remove a per-site override, reverting to plan default', y => y
|
|
155
|
+
.option('site', { type: 'number', demandOption: true, desc: 'Site ID' })
|
|
156
|
+
.option('key', { type: 'string', demandOption: true, desc: 'Feature key to un-override' })
|
|
157
|
+
, a => removeOverride(a))
|
|
158
|
+
.command('seed', 'Seed the EntitlementFeature registry (idempotent, superadmin only)', () => {}, () => seed())
|
|
159
|
+
.command('seed-plans', 'Seed PlanEntitlement rows from live PackageVersions in messaging DB (idempotent, superadmin only)', () => {}, () => seedPlans())
|
|
160
|
+
.command('backfill', 'Backfill SiteSubscription rows from active Subscription rows in messaging DB (idempotent, superadmin only)', () => {}, () => backfill())
|
|
161
|
+
.command('reconcile', 'Diff Subscription.cs vs Entitlements API for a site — zero deltas = ready for cutover', y => y
|
|
162
|
+
.option('site', { type: 'number', demandOption: true, desc: 'Site ID' })
|
|
163
|
+
, a => reconcile(a))
|
|
164
|
+
.command('comp', 'Comp a site on a plan (creates a SiteSubscription so overrides can be set)', y => y
|
|
165
|
+
.option('site', { type: 'number', demandOption: true, desc: 'Site ID' })
|
|
166
|
+
.option('plan', { type: 'string', demandOption: true, desc: 'Plan key, e.g. plan.professional' })
|
|
167
|
+
.option('until', { type: 'string', desc: 'ISO 8601 comp expiry date (omit for indefinite)' })
|
|
168
|
+
.option('notes', { type: 'string', desc: 'Internal notes' })
|
|
169
|
+
, a => compSite(a))
|
|
170
|
+
.command('list-cutover', 'List all feature keys and whether they are live (messaging reads admin) or not (Subscription.cs)', () => {}, () => listCutover())
|
|
171
|
+
.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
|
+
.option('key', { type: 'string', demandOption: true, desc: 'Feature key, e.g. EnableIvr' })
|
|
173
|
+
.option('rollback', { type: 'boolean', desc: 'Pass to roll back this flag to Subscription.cs (default: go live)' })
|
|
174
|
+
, 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');
|
|
176
|
+
}
|
package/lib/commands/env.js
CHANGED
|
@@ -1,45 +1,45 @@
|
|
|
1
|
-
import { readConfig, setActiveEnv, getActiveEnv, ENVS } from '../config.js';
|
|
2
|
-
import { runCommand } from '../run.js';
|
|
3
|
-
|
|
4
|
-
export const envCommand = {
|
|
5
|
-
command: 'env [name]',
|
|
6
|
-
describe: 'Show or switch the active environment (prod/staging)',
|
|
7
|
-
builder: (y) =>
|
|
8
|
-
y.positional('name', {
|
|
9
|
-
describe: 'Environment to switch to: prod or staging',
|
|
10
|
-
type: 'string',
|
|
11
|
-
choices: ['prod', 'staging'],
|
|
12
|
-
}),
|
|
13
|
-
|
|
14
|
-
handler: runCommand(async (argv) => {
|
|
15
|
-
if (argv.name) {
|
|
16
|
-
setActiveEnv(argv.name);
|
|
17
|
-
console.log(`Switched to ${argv.name}.`);
|
|
18
|
-
console.log(`All velaro commands now target: ${ENVS[argv.name].adminApiBase}`);
|
|
19
|
-
return;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
// Show status of all environments
|
|
23
|
-
const cfg = readConfig();
|
|
24
|
-
const active = cfg.activeEnv || 'prod';
|
|
25
|
-
const envs = cfg.envs || {};
|
|
26
|
-
|
|
27
|
-
console.log('Velaro environments:\n');
|
|
28
|
-
for (const [name, urls] of Object.entries(ENVS)) {
|
|
29
|
-
const creds = envs[name];
|
|
30
|
-
const marker = name === active ? '▶ ' : ' ';
|
|
31
|
-
const status = creds?.velaroToken
|
|
32
|
-
? `logged in as ${creds.userName ?? 'unknown'} (site ${creds.siteId})`
|
|
33
|
-
: 'not logged in';
|
|
34
|
-
const expiry = creds?.velaroExpires
|
|
35
|
-
? new Date(creds.velaroExpires) > new Date() ? '' : ' ⚠ token expired'
|
|
36
|
-
: '';
|
|
37
|
-
console.log(`${marker}${name.padEnd(10)} ${status}${expiry}`);
|
|
38
|
-
console.log(` ${urls.adminApiBase}`);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
console.log(`\nActive: ${active}`);
|
|
42
|
-
console.log('\nTo switch: velaro env staging | velaro env prod');
|
|
43
|
-
console.log('To log in: velaro login | velaro login --staging');
|
|
44
|
-
}),
|
|
45
|
-
};
|
|
1
|
+
import { readConfig, setActiveEnv, getActiveEnv, ENVS } from '../config.js';
|
|
2
|
+
import { runCommand } from '../run.js';
|
|
3
|
+
|
|
4
|
+
export const envCommand = {
|
|
5
|
+
command: 'env [name]',
|
|
6
|
+
describe: 'Show or switch the active environment (prod/staging)',
|
|
7
|
+
builder: (y) =>
|
|
8
|
+
y.positional('name', {
|
|
9
|
+
describe: 'Environment to switch to: prod or staging',
|
|
10
|
+
type: 'string',
|
|
11
|
+
choices: ['prod', 'staging'],
|
|
12
|
+
}),
|
|
13
|
+
|
|
14
|
+
handler: runCommand(async (argv) => {
|
|
15
|
+
if (argv.name) {
|
|
16
|
+
setActiveEnv(argv.name);
|
|
17
|
+
console.log(`Switched to ${argv.name}.`);
|
|
18
|
+
console.log(`All velaro commands now target: ${ENVS[argv.name].adminApiBase}`);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Show status of all environments
|
|
23
|
+
const cfg = readConfig();
|
|
24
|
+
const active = cfg.activeEnv || 'prod';
|
|
25
|
+
const envs = cfg.envs || {};
|
|
26
|
+
|
|
27
|
+
console.log('Velaro environments:\n');
|
|
28
|
+
for (const [name, urls] of Object.entries(ENVS)) {
|
|
29
|
+
const creds = envs[name];
|
|
30
|
+
const marker = name === active ? '▶ ' : ' ';
|
|
31
|
+
const status = creds?.velaroToken
|
|
32
|
+
? `logged in as ${creds.userName ?? 'unknown'} (site ${creds.siteId})`
|
|
33
|
+
: 'not logged in';
|
|
34
|
+
const expiry = creds?.velaroExpires
|
|
35
|
+
? new Date(creds.velaroExpires) > new Date() ? '' : ' ⚠ token expired'
|
|
36
|
+
: '';
|
|
37
|
+
console.log(`${marker}${name.padEnd(10)} ${status}${expiry}`);
|
|
38
|
+
console.log(` ${urls.adminApiBase}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
console.log(`\nActive: ${active}`);
|
|
42
|
+
console.log('\nTo switch: velaro env staging | velaro env prod');
|
|
43
|
+
console.log('To log in: velaro login | velaro login --staging');
|
|
44
|
+
}),
|
|
45
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// velaro feature-discovery — non-nag "features on other plans this account doesn't have yet" nudge.
|
|
2
|
+
// Site scoping is server-side (FeatureDiscoveryController derives siteId from the logged-in
|
|
3
|
+
// identity) — this CLI never passes a siteId, same as the messaging/entitlement site-scoped calls.
|
|
4
|
+
import { request } from '../api.js';
|
|
5
|
+
|
|
6
|
+
async function list() {
|
|
7
|
+
const result = await request('GET', 'FeatureDiscovery/suggestions');
|
|
8
|
+
const suggestions = result?.suggestions ?? [];
|
|
9
|
+
|
|
10
|
+
if (!suggestions.length) {
|
|
11
|
+
console.log(`\nNo feature-discovery suggestions right now (newCount: ${result?.newCount ?? 0}).`);
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
console.log(`\nFeature discovery suggestions (newCount: ${result.newCount}):\n`);
|
|
16
|
+
for (const s of suggestions) {
|
|
17
|
+
const badge = s.isNew ? '🆕' : ' ';
|
|
18
|
+
const pkg = s.contactSales ? ' [Contact Sales]' : s.pricingPackageKey ? ` [package: ${s.pricingPackageKey}]` : '';
|
|
19
|
+
console.log(`${badge} ${s.featureKey.padEnd(30)} ${s.pitchCopy}${pkg}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function requestFeature(args) {
|
|
24
|
+
const featureKey = args.featureKey || args._?.[1];
|
|
25
|
+
if (!featureKey) {
|
|
26
|
+
console.error('Usage: velaro feature-discovery request <featureKey>');
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
const result = await request('POST', `FeatureDiscovery/${featureKey}/request`, {});
|
|
30
|
+
console.log(`✅ Request submitted for "${featureKey}" (requestId: ${result.requestId}, status: ${result.status})`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function featureDiscoveryCommand(yargs) {
|
|
34
|
+
return yargs
|
|
35
|
+
.command('list', 'List this site\'s current feature-discovery suggestions', () => {}, () => list())
|
|
36
|
+
.command('request <featureKey>', 'Submit "I\'m interested" for a suggested feature', y => y
|
|
37
|
+
.positional('featureKey', { type: 'string', desc: 'Feature key from "list", e.g. EnableIvr' })
|
|
38
|
+
, a => requestFeature(a))
|
|
39
|
+
.demandCommand(1, 'Specify a subcommand: list | request');
|
|
40
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { messagingGet, messagingPost, messagingPut } from '../api.js';
|
|
2
|
+
|
|
3
|
+
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
const RESET = '\x1b[0m';
|
|
6
|
+
const BOLD = '\x1b[1m';
|
|
7
|
+
const DIM = '\x1b[2m';
|
|
8
|
+
const CYN = '\x1b[36m';
|
|
9
|
+
const YEL = '\x1b[33m';
|
|
10
|
+
const GRN = '\x1b[32m';
|
|
11
|
+
|
|
12
|
+
function fmtDate(iso) {
|
|
13
|
+
if (!iso) return '—';
|
|
14
|
+
return new Date(iso).toLocaleString('en-US', { timeZone: 'America/Los_Angeles', dateStyle: 'short', timeStyle: 'short' });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const STAGE_COLORS = {
|
|
18
|
+
new: '\x1b[37m',
|
|
19
|
+
submitted: CYN,
|
|
20
|
+
offer_received: YEL,
|
|
21
|
+
negotiating: '\x1b[35m',
|
|
22
|
+
confirmed: GRN,
|
|
23
|
+
declined: '\x1b[31m',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function stageLabel(stage) {
|
|
27
|
+
const color = STAGE_COLORS[stage] ?? RESET;
|
|
28
|
+
return `${color}${(stage ?? '—').padEnd(16)}${RESET}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ── snoozed ───────────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
const snoozedCommand = {
|
|
34
|
+
command: 'snoozed',
|
|
35
|
+
describe: 'List all currently snoozed conversations',
|
|
36
|
+
builder: (y) => y,
|
|
37
|
+
handler: async () => {
|
|
38
|
+
const data = await messagingGet('/Focus/conversations/snoozed');
|
|
39
|
+
const items = Array.isArray(data) ? data : [];
|
|
40
|
+
|
|
41
|
+
console.log(`\n${BOLD}── Snoozed Conversations ──────────────────────────────────────────────${RESET}`);
|
|
42
|
+
if (!items.length) {
|
|
43
|
+
console.log(` ${DIM}No snoozed conversations.${RESET}\n`);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const idW = String(items.reduce((m, i) => Math.max(m, String(i.id).length), 2)).length + 2;
|
|
48
|
+
const nameW = 24;
|
|
49
|
+
const chanW = 14;
|
|
50
|
+
|
|
51
|
+
console.log(
|
|
52
|
+
` ${DIM}${'ID'.padEnd(idW)} ${'Contact'.padEnd(nameW)} ${'Channel'.padEnd(chanW)} Snoozed Until${RESET}`
|
|
53
|
+
);
|
|
54
|
+
for (const item of items) {
|
|
55
|
+
const name = (item.contactName ?? '—').slice(0, nameW).padEnd(nameW);
|
|
56
|
+
const chan = (item.channel ?? '—').slice(0, chanW).padEnd(chanW);
|
|
57
|
+
console.log(
|
|
58
|
+
` ${CYN}${String(item.id).padEnd(idW)}${RESET} ${name} ${DIM}${chan}${RESET} ${YEL}${fmtDate(item.snoozedUntil)}${RESET}`
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
console.log();
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// ── follow-ups ────────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
const followUpsCommand = {
|
|
68
|
+
command: 'follow-ups',
|
|
69
|
+
describe: 'List follow-ups due in the next 24 hours',
|
|
70
|
+
builder: (y) => y,
|
|
71
|
+
handler: async () => {
|
|
72
|
+
const data = await messagingGet('/Focus/conversations/follow-ups');
|
|
73
|
+
const items = Array.isArray(data) ? data : [];
|
|
74
|
+
|
|
75
|
+
console.log(`\n${BOLD}── Follow-Ups Due (next 24h) ───────────────────────────────────────────${RESET}`);
|
|
76
|
+
if (!items.length) {
|
|
77
|
+
console.log(` ${DIM}No follow-ups due in the next 24 hours.${RESET}\n`);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for (const item of items) {
|
|
82
|
+
const name = item.contactName ?? '—';
|
|
83
|
+
console.log(
|
|
84
|
+
` ${CYN}#${item.id}${RESET} ${name.padEnd(24)} ${DIM}${(item.channel ?? '—').padEnd(14)}${RESET} ${YEL}${fmtDate(item.followUpAt)}${RESET}`
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
console.log();
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// ── clusters ──────────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
const clustersCommand = {
|
|
94
|
+
command: 'clusters',
|
|
95
|
+
describe: 'List all saved conversation clusters',
|
|
96
|
+
builder: (y) => y,
|
|
97
|
+
handler: async () => {
|
|
98
|
+
const data = await messagingGet('/Focus/clusters');
|
|
99
|
+
const items = Array.isArray(data) ? data : [];
|
|
100
|
+
|
|
101
|
+
console.log(`\n${BOLD}── Focus Clusters ─────────────────────────────────────────────────────${RESET}`);
|
|
102
|
+
if (!items.length) {
|
|
103
|
+
console.log(` ${DIM}No clusters saved yet.${RESET}\n`);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for (const c of items) {
|
|
108
|
+
const ids = Array.isArray(c.conversationIds) ? c.conversationIds : JSON.parse(c.conversationIds || '[]');
|
|
109
|
+
const val = c.estimatedValue != null ? ` ${GRN}$${c.estimatedValue.toLocaleString()}${RESET}` : '';
|
|
110
|
+
const updated = fmtDate(c.updatedAt);
|
|
111
|
+
console.log(
|
|
112
|
+
` ${CYN}#${c.id}${RESET} ${BOLD}${c.title}${RESET}\n` +
|
|
113
|
+
` Stage: ${stageLabel(c.stage)} Conversations: ${ids.length}${val} Updated: ${DIM}${updated}${RESET}`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
console.log();
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// ── cluster-stage ─────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
const VALID_STAGES = ['new', 'submitted', 'offer_received', 'negotiating', 'confirmed', 'declined'];
|
|
123
|
+
|
|
124
|
+
const clusterStageCommand = {
|
|
125
|
+
command: 'cluster-stage <id> <stage>',
|
|
126
|
+
describe: 'Move a cluster to a different pipeline stage',
|
|
127
|
+
builder: (y) => y
|
|
128
|
+
.positional('id', { type: 'number', describe: 'Cluster ID' })
|
|
129
|
+
.positional('stage', { type: 'string', describe: `Pipeline stage: ${VALID_STAGES.join(', ')}` }),
|
|
130
|
+
handler: async (argv) => {
|
|
131
|
+
const { id, stage } = argv;
|
|
132
|
+
if (!VALID_STAGES.includes(stage)) {
|
|
133
|
+
console.error(`Invalid stage "${stage}". Valid values: ${VALID_STAGES.join(', ')}`);
|
|
134
|
+
process.exit(1);
|
|
135
|
+
}
|
|
136
|
+
const result = await messagingPut(`/Focus/clusters/${id}/stage`, { stage });
|
|
137
|
+
const color = STAGE_COLORS[result?.stage ?? stage] ?? RESET;
|
|
138
|
+
console.log(`\n ${GRN}✓${RESET} Cluster #${result?.id ?? id} moved to ${color}${result?.stage ?? stage}${RESET}\n`);
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// ── auto-reply report ────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
function pct(used, max) {
|
|
145
|
+
if (!max) return '0%';
|
|
146
|
+
return `${Math.round((used / max) * 100)}%`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function bar(count, maxCount, width = 24) {
|
|
150
|
+
if (maxCount <= 0) return DIM + '·'.repeat(width) + RESET;
|
|
151
|
+
const filled = Math.round((count / maxCount) * width);
|
|
152
|
+
return CYN + '█'.repeat(filled) + DIM + '·'.repeat(width - filled) + RESET;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const reportCommand = {
|
|
156
|
+
command: 'report',
|
|
157
|
+
describe: 'AI auto-reply funnel report: auto-resolved vs escalated, confidence distribution, usage vs cap',
|
|
158
|
+
builder: (y) => y
|
|
159
|
+
.option('from', { type: 'string', describe: 'ISO 8601 start of range (default: 30 days before --to)' })
|
|
160
|
+
.option('to', { type: 'string', describe: 'ISO 8601 end of range (default: now)' })
|
|
161
|
+
.option('channel', { type: 'string', choices: ['All', 'Email', 'TicketGeneral'], default: 'All', describe: 'Filter by channel' }),
|
|
162
|
+
handler: async (argv) => {
|
|
163
|
+
const qs = new URLSearchParams();
|
|
164
|
+
if (argv.from) qs.set('from', argv.from);
|
|
165
|
+
if (argv.to) qs.set('to', argv.to);
|
|
166
|
+
if (argv.channel) qs.set('channel', argv.channel);
|
|
167
|
+
const r = await messagingGet(`/Focus/AutoReply/Report?${qs.toString()}`);
|
|
168
|
+
|
|
169
|
+
console.log(`\n${BOLD}── AI Auto-Reply Report ────────────────────────────────────────────────${RESET}`);
|
|
170
|
+
console.log(` ${DIM}${fmtDate(r.from)} to ${fmtDate(r.to)} | Channel: ${r.channel}${RESET}\n`);
|
|
171
|
+
|
|
172
|
+
console.log(` ${BOLD}Total eligible:${RESET} ${r.totalEligible}`);
|
|
173
|
+
console.log(` ${GRN}Auto-resolved:${RESET} ${r.autoResolved.total} ${DIM}(Email: ${r.autoResolved.email}, Ticket: ${r.autoResolved.ticketGeneral})${RESET}`);
|
|
174
|
+
console.log(` ${YEL}Escalated:${RESET} ${r.escalated.total} ${DIM}(all below-confidence; ${r.escalated.explicitEscalationTrigger} of these explicitly told the customer)${RESET}`);
|
|
175
|
+
console.log(` Negative follow-ups after auto-reply: ${r.negativeFollowUpCount}\n`);
|
|
176
|
+
|
|
177
|
+
console.log(` ${BOLD}Confidence distribution${RESET}`);
|
|
178
|
+
const maxCount = Math.max(1, ...r.confidenceDistribution.map((b) => b.count));
|
|
179
|
+
for (const b of r.confidenceDistribution) {
|
|
180
|
+
const label = `${String(b.bucketMin).padStart(3)}-${String(b.bucketMax).padStart(3)}`;
|
|
181
|
+
console.log(` ${DIM}${label}${RESET} ${bar(b.count, maxCount)} ${b.count}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
console.log(`\n ${BOLD}Usage${RESET} ${r.usage.used} / ${r.usage.max} ${r.usage.metric} this period (${pct(r.usage.used, r.usage.max)})`);
|
|
185
|
+
if (r.usage.percent > 90) {
|
|
186
|
+
console.log(` ${YEL}Warning: approaching the auto-reply cap. Consider raising the plan limit or the confidence threshold.${RESET}`);
|
|
187
|
+
}
|
|
188
|
+
console.log();
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
// ── triage-rules (#582 Phase B) ──────────────────────────────────────────────
|
|
193
|
+
//
|
|
194
|
+
// SECURITY: the label set is fixed (urgent/needs_reply/offer/fyi/waiting). This command only
|
|
195
|
+
// ever sends a boolean enable/disable per fixed label, never free text. See CLAUDE.md's Moshky
|
|
196
|
+
// AI safety rule and ConversationTriageService's prompt assembly (velaro-messaging).
|
|
197
|
+
|
|
198
|
+
const VALID_LABELS = ['urgent', 'needs_reply', 'offer', 'fyi', 'waiting'];
|
|
199
|
+
|
|
200
|
+
const triageRulesCommand = {
|
|
201
|
+
command: 'triage-rules',
|
|
202
|
+
describe: 'Show which AI triage labels are enabled and current per-channel priority weights',
|
|
203
|
+
builder: (y) => y,
|
|
204
|
+
handler: async () => {
|
|
205
|
+
const data = await messagingGet('/Focus/triage-rules');
|
|
206
|
+
|
|
207
|
+
console.log(`\n${BOLD}── AI Triage Rules ─────────────────────────────────────────────────────${RESET}`);
|
|
208
|
+
console.log(` ${BOLD}Labels${RESET}`);
|
|
209
|
+
for (const l of data.labels ?? []) {
|
|
210
|
+
const mark = l.enabled ? `${GRN}on${RESET} ` : `${DIM}off${RESET}`;
|
|
211
|
+
console.log(` ${mark} ${l.enabled ? '' : DIM}${l.label}${RESET}`);
|
|
212
|
+
}
|
|
213
|
+
console.log(`\n ${BOLD}Channels${RESET} ${DIM}(edit via focus channel-config)${RESET}`);
|
|
214
|
+
for (const c of data.channels ?? []) {
|
|
215
|
+
const triage = c.triageEnabled ? `${GRN}on${RESET}` : `${DIM}off${RESET}`;
|
|
216
|
+
console.log(` ${(c.channel ?? '—').padEnd(12)} triage: ${triage} weight: ${c.priorityWeight}x`);
|
|
217
|
+
}
|
|
218
|
+
console.log();
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const setTriageRuleCommand = {
|
|
223
|
+
command: 'set-triage-rule <label> <enabled>',
|
|
224
|
+
describe: `Enable or disable one fixed AI triage label (${VALID_LABELS.join(', ')})`,
|
|
225
|
+
builder: (y) => y
|
|
226
|
+
.positional('label', { type: 'string', describe: `Label: ${VALID_LABELS.join(', ')}` })
|
|
227
|
+
.positional('enabled', { type: 'string', describe: 'true or false', choices: ['true', 'false'] }),
|
|
228
|
+
handler: async (argv) => {
|
|
229
|
+
const { label, enabled } = argv;
|
|
230
|
+
if (!VALID_LABELS.includes(label)) {
|
|
231
|
+
console.error(`Invalid label "${label}". Valid values: ${VALID_LABELS.join(', ')}`);
|
|
232
|
+
process.exit(1);
|
|
233
|
+
}
|
|
234
|
+
const result = await messagingPut('/Focus/triage-rules', [{ Label: label, Enabled: enabled === 'true' }]);
|
|
235
|
+
console.log(`\n ${GRN}✓${RESET} Label "${label}" ${enabled === 'true' ? 'enabled' : 'disabled'}. Effective labels: ${(result?.effectiveLabels ?? []).join(', ')}\n`);
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
// ── draft-pregeneration report ("Always Learning" addon, issue #582 Phase D) ───
|
|
240
|
+
// Entitlements-gated (EnableFocusDraftPregeneration), not a Subscription.cs flag --
|
|
241
|
+
// `entitled` on the response is the source of truth for whether the site has it.
|
|
242
|
+
|
|
243
|
+
const draftPregenerationReportCommand = {
|
|
244
|
+
command: 'draft-pregeneration-report',
|
|
245
|
+
describe: 'Draft pregeneration usage/cap report ("Always Learning" addon) -- entitled status plus used/max/percent',
|
|
246
|
+
builder: (y) => y,
|
|
247
|
+
handler: async () => {
|
|
248
|
+
const r = await messagingGet('/Focus/DraftPregeneration/Report');
|
|
249
|
+
|
|
250
|
+
console.log(`\n${BOLD}── Draft Pregeneration Report ──────────────────────────────────────────${RESET}`);
|
|
251
|
+
console.log(` ${BOLD}Status:${RESET} ${r.entitled ? `${GRN}Active${RESET}` : `${DIM}Not included in your plan${RESET}`}\n`);
|
|
252
|
+
|
|
253
|
+
console.log(` ${BOLD}Usage${RESET} ${r.usage.used} / ${r.usage.max} ${r.usage.metric} this period (${pct(r.usage.used, r.usage.max)})`);
|
|
254
|
+
if (r.usage.percent > 90) {
|
|
255
|
+
console.log(` ${YEL}Warning: approaching the draft pregeneration cap. Contact support to raise the plan limit.${RESET}`);
|
|
256
|
+
}
|
|
257
|
+
console.log();
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
// ── top-level focus command ───────────────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
export const focusCommand = {
|
|
264
|
+
command: 'focus <subcommand>',
|
|
265
|
+
describe: 'Focus Inbox: snooze, follow-ups, conversation clusters, AI triage rules, and the AI auto-reply / draft pregeneration reports',
|
|
266
|
+
builder: (y) => y
|
|
267
|
+
.command(snoozedCommand)
|
|
268
|
+
.command(followUpsCommand)
|
|
269
|
+
.command(clustersCommand)
|
|
270
|
+
.command(clusterStageCommand)
|
|
271
|
+
.command(reportCommand)
|
|
272
|
+
.command(triageRulesCommand)
|
|
273
|
+
.command(setTriageRuleCommand)
|
|
274
|
+
.command(draftPregenerationReportCommand)
|
|
275
|
+
.demandCommand(1, 'Specify a focus subcommand.')
|
|
276
|
+
.strict(),
|
|
277
|
+
handler: () => {},
|
|
278
|
+
};
|
package/lib/commands/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { get, post, getCredentials } from '../api.js';
|
|
1
|
+
import { get, post, getCredentials, messagingGet, messagingPost } from '../api.js';
|
|
2
2
|
|
|
3
3
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
4
4
|
|
|
@@ -69,7 +69,7 @@ const listCommand = {
|
|
|
69
69
|
// ── Customer: show their virtual indexes + quota ────────────────────────
|
|
70
70
|
const siteId = argv.site ?? creds.siteId;
|
|
71
71
|
const [indexes, breakdown] = await Promise.all([
|
|
72
|
-
|
|
72
|
+
messagingGet('/AzureIndexes/List'),
|
|
73
73
|
get(`/DatabaseTool/SearchIndexSite?siteId=${siteId}`).catch(() => null),
|
|
74
74
|
]);
|
|
75
75
|
|
|
@@ -171,7 +171,7 @@ const reingestCommand = {
|
|
|
171
171
|
// Check quota before allowing reingest
|
|
172
172
|
const sub = await get('/Subscription/Get').catch(() => null);
|
|
173
173
|
if (!isVelaroAdmin(creds) && sub?.maxIndexedChunkTotal > 0) {
|
|
174
|
-
const indexes = await
|
|
174
|
+
const indexes = await messagingGet('/AzureIndexes/List').catch(() => []);
|
|
175
175
|
const usedChunks = indexes.reduce((s, i) => s + (i.documentCount ?? 0), 0);
|
|
176
176
|
if (usedChunks >= sub.maxIndexedChunkTotal) {
|
|
177
177
|
console.error(`\x1b[31m✗ Index quota full (${usedChunks.toLocaleString()} / ${sub.maxIndexedChunkTotal.toLocaleString()} chunks).\x1b[0m`);
|
|
@@ -186,7 +186,7 @@ const reingestCommand = {
|
|
|
186
186
|
for (const source of sources) {
|
|
187
187
|
try {
|
|
188
188
|
console.log(` Syncing ${source}...`);
|
|
189
|
-
await
|
|
189
|
+
await messagingPost(`/AzureIndexes/SyncIntegration${siteParam}`, { source, forceAll: true });
|
|
190
190
|
console.log(` \x1b[32m✓ ${source} queued\x1b[0m`);
|
|
191
191
|
} catch (e) {
|
|
192
192
|
console.log(` \x1b[33m⚠ ${source} skipped: ${e.message}\x1b[0m`);
|
|
@@ -196,6 +196,38 @@ const reingestCommand = {
|
|
|
196
196
|
},
|
|
197
197
|
};
|
|
198
198
|
|
|
199
|
+
// ── test ─────────────────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
const testCommand = {
|
|
202
|
+
command: 'test <query>',
|
|
203
|
+
describe: 'Run a content-search query against your site\'s index and show results',
|
|
204
|
+
builder: (y) =>
|
|
205
|
+
y
|
|
206
|
+
.positional('query', { type: 'string', describe: 'Search query' })
|
|
207
|
+
.option('top', { type: 'number', default: 5, describe: 'Number of results to return' })
|
|
208
|
+
.option('site', { type: 'number', describe: 'Site ID override (Velaro staff only)' }),
|
|
209
|
+
handler: async (argv) => {
|
|
210
|
+
const creds = await getCredentials();
|
|
211
|
+
const siteParam = isVelaroAdmin(creds) && argv.site ? `&siteId=${argv.site}` : '';
|
|
212
|
+
const qs = `q=${encodeURIComponent(argv.query)}&top=${argv.top}${siteParam}`;
|
|
213
|
+
|
|
214
|
+
console.log(`\nSearching index for: "${argv.query}"\n`);
|
|
215
|
+
const results = await messagingGet(`/AzureIndexes/content-search?${qs}`);
|
|
216
|
+
|
|
217
|
+
if (!results?.length) {
|
|
218
|
+
console.log(' No results found. Index may be empty — run `velaro ingest --job-id <id>` first.');
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
for (let i = 0; i < results.length; i++) {
|
|
223
|
+
const r = results[i];
|
|
224
|
+
console.log(`\x1b[1m[${i + 1}] ${r.label ?? r.sourceId ?? 'unknown'}\x1b[0m`);
|
|
225
|
+
console.log(` type: ${r.contentType ?? 'unknown'} index: ${r.indexName ?? ''} id: ${r.sourceId ?? ''}`);
|
|
226
|
+
console.log('');
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
|
|
199
231
|
// ── export ────────────────────────────────────────────────────────────────────
|
|
200
232
|
|
|
201
233
|
export const indexCommand = {
|
|
@@ -207,6 +239,7 @@ export const indexCommand = {
|
|
|
207
239
|
.command(siteCommand)
|
|
208
240
|
.command(provisionCommand)
|
|
209
241
|
.command(reingestCommand)
|
|
210
|
-
.
|
|
242
|
+
.command(testCommand)
|
|
243
|
+
.demandCommand(1, 'Specify a subcommand: list, site, provision, reingest, test'),
|
|
211
244
|
handler: () => {},
|
|
212
245
|
};
|