@velaro/cli 1.2.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.
Files changed (98) hide show
  1. package/README.md +161 -138
  2. package/bin/velaro.js +177 -62
  3. package/lib/api.js +91 -52
  4. package/lib/api.test.js +46 -0
  5. package/lib/banner.js +76 -0
  6. package/lib/commands/activity.js +133 -0
  7. package/lib/commands/acuity.js +66 -0
  8. package/lib/commands/agent.js +204 -50
  9. package/lib/commands/ai-config.js +193 -0
  10. package/lib/commands/ai-models.js +159 -0
  11. package/lib/commands/appointments.js +198 -0
  12. package/lib/commands/article.js +668 -388
  13. package/lib/commands/automation-draft.js +134 -0
  14. package/lib/commands/avatar.js +75 -0
  15. package/lib/commands/bigcommerce.js +50 -0
  16. package/lib/commands/billing-contacts.js +62 -0
  17. package/lib/commands/billing-email-preference.js +64 -0
  18. package/lib/commands/billing-subscription.js +265 -0
  19. package/lib/commands/billing.js +138 -0
  20. package/lib/commands/bot.js +141 -137
  21. package/lib/commands/bundle.js +168 -0
  22. package/lib/commands/calendly.js +62 -0
  23. package/lib/commands/callback.js +125 -0
  24. package/lib/commands/callrail.js +88 -0
  25. package/lib/commands/campaigns.js +44 -0
  26. package/lib/commands/case.js +102 -0
  27. package/lib/commands/check.js +163 -163
  28. package/lib/commands/compliance.js +229 -0
  29. package/lib/commands/conversation-efficiency.js +178 -0
  30. package/lib/commands/copilotstudio.js +114 -0
  31. package/lib/commands/coupon-grant.js +192 -0
  32. package/lib/commands/db.js +101 -0
  33. package/lib/commands/deployment.js +107 -107
  34. package/lib/commands/diagnostics.js +298 -0
  35. package/lib/commands/email-campaign.js +47 -0
  36. package/lib/commands/email-inbox.js +88 -0
  37. package/lib/commands/entitlement.js +176 -0
  38. package/lib/commands/env.js +45 -45
  39. package/lib/commands/feature-discovery.js +40 -0
  40. package/lib/commands/focus.js +278 -0
  41. package/lib/commands/index.js +38 -5
  42. package/lib/commands/ingest.js +31 -31
  43. package/lib/commands/inline-widget-config.js +126 -0
  44. package/lib/commands/integration.js +93 -0
  45. package/lib/commands/kb.js +450 -309
  46. package/lib/commands/login.js +86 -86
  47. package/lib/commands/logs.js +680 -0
  48. package/lib/commands/magento.js +210 -0
  49. package/lib/commands/mcp-key.js +188 -159
  50. package/lib/commands/migrate.js +134 -0
  51. package/lib/commands/migration-status.js +66 -0
  52. package/lib/commands/monday.js +137 -0
  53. package/lib/commands/netsuite.js +87 -0
  54. package/lib/commands/notifications.js +63 -0
  55. package/lib/commands/notion.js +70 -0
  56. package/lib/commands/ops.js +267 -173
  57. package/lib/commands/payment-recovery.js +170 -0
  58. package/lib/commands/pickup.js +172 -0
  59. package/lib/commands/pricing.js +132 -0
  60. package/lib/commands/product.js +55 -0
  61. package/lib/commands/recruiting.js +374 -0
  62. package/lib/commands/report.js +462 -0
  63. package/lib/commands/routing.js +304 -0
  64. package/lib/commands/rule.js +85 -85
  65. package/lib/commands/sharepoint.js +167 -0
  66. package/lib/commands/site-provision.js +68 -0
  67. package/lib/commands/site.js +62 -62
  68. package/lib/commands/sitesync.js +158 -0
  69. package/lib/commands/slack.js +64 -0
  70. package/lib/commands/squarespace.js +108 -0
  71. package/lib/commands/status.js +24 -24
  72. package/lib/commands/subscription.js +43 -0
  73. package/lib/commands/support.js +128 -0
  74. package/lib/commands/survey.js +216 -0
  75. package/lib/commands/team.js +144 -144
  76. package/lib/commands/teams-phone.js +131 -0
  77. package/lib/commands/teams.js +106 -0
  78. package/lib/commands/telephony.js +99 -0
  79. package/lib/commands/update.js +47 -47
  80. package/lib/commands/webflow.js +128 -0
  81. package/lib/commands/whoami.js +25 -22
  82. package/lib/commands/widget-container.js +152 -0
  83. package/lib/commands/woocommerce.js +240 -0
  84. package/lib/commands/workflow.js +233 -98
  85. package/lib/config.js +85 -83
  86. package/lib/kb-screenshot.js +320 -0
  87. package/lib/migrations/amscro.json +72 -0
  88. package/lib/migrations/azenta.json +68 -0
  89. package/lib/migrations/bluefire.json +49 -0
  90. package/lib/migrations/donaldson.json +75 -0
  91. package/lib/oauth.js +149 -135
  92. package/lib/run.js +21 -16
  93. package/lib/sharepoint-auth.js +138 -0
  94. package/lib/subscription.js +41 -39
  95. package/lib/track.js +35 -35
  96. package/lib/update-check.js +64 -64
  97. package/package.json +34 -19
  98. package/scripts/postinstall.js +12 -0
@@ -0,0 +1,178 @@
1
+ import { messagingGet } from '../api.js';
2
+ import { runCommand } from '../run.js';
3
+
4
+ // -- velaro conversation-efficiency ---------------------------------------------
5
+ // CLI surface for the Conversation Efficiency Analysis (Fragmentation Index)
6
+ // report. Endpoints live in velaro-messaging at /Reports/conversation-efficiency/*
7
+ // and apply the caller's siteId from JWT/MCP-key context -- never from CLI args.
8
+ // Gated by the EnableConversationEfficiencyReport entitlement (Professional/
9
+ // Enterprise only); the server returns 403 when the account isn't entitled.
10
+ //
11
+ // Narrative regeneration is intentionally NOT exposed here -- it's rate-limited
12
+ // (max 6/month/site) and stays a deliberate action in the admin UI, not
13
+ // something scriptable on a loop.
14
+
15
+ function fmtIndex(val) {
16
+ return val == null ? '-' : Number(val).toFixed(2);
17
+ }
18
+
19
+ function fmtPct(val) {
20
+ return val == null ? '-' : `${Number(val).toFixed(1)}%`;
21
+ }
22
+
23
+ function section(title) {
24
+ console.log(`\n\x1b[1m-- ${title} ${'-'.repeat(Math.max(0, 55 - title.length))}\x1b[0m`);
25
+ }
26
+
27
+ const overviewCommand = {
28
+ command: 'overview',
29
+ describe: "This week's Fragmentation Index overview (agent vs bot, week-over-week change)",
30
+ handler: runCommand(async () => {
31
+ const data = await messagingGet('/Reports/conversation-efficiency/overview');
32
+ section('Conversation Efficiency - Overview');
33
+ console.log(` Week starting: ${data?.weekStart ?? '-'}`);
34
+ const rows = data?.rows ?? [];
35
+ if (!rows.length) { console.log(' No data for the current week yet.'); return; }
36
+
37
+ const COL = [12, 10, 10, 14, 16, 10, 10];
38
+ const header = ['Party', 'Msgs', 'Turns', 'Convs', 'Frag. Index', 'WoW %', 'Outlier'];
39
+ console.log('\n ' + header.map((h, i) => h.padEnd(COL[i])).join(''));
40
+ console.log(' ' + '-'.repeat(COL.reduce((a, b) => a + b, 0)));
41
+ for (const r of rows) {
42
+ const row = [
43
+ String(r.partyType ?? '-'),
44
+ String(r.messageCount ?? '-'),
45
+ String(r.turnCount ?? '-'),
46
+ String(r.conversationCount ?? '-'),
47
+ fmtIndex(r.avgFragmentationIndex),
48
+ fmtPct(r.weekOverWeekDeltaPct),
49
+ r.isOutlier ? 'yes' : 'no',
50
+ ];
51
+ console.log(' ' + row.map((v, i) => v.padEnd(COL[i])).join(''));
52
+ }
53
+ console.log('');
54
+ }),
55
+ };
56
+
57
+ const agentsCommand = {
58
+ command: 'agents',
59
+ describe: "This week's per-agent Fragmentation Index breakdown",
60
+ handler: runCommand(async () => {
61
+ const data = await messagingGet('/Reports/conversation-efficiency/agents');
62
+ section('Conversation Efficiency - Agents');
63
+ console.log(` Week starting: ${data?.weekStart ?? '-'}`);
64
+ const rows = data?.rows ?? [];
65
+ if (!rows.length) { console.log(' No per-agent data for the current week yet.'); return; }
66
+
67
+ const COL = [12, 10, 10, 14, 16, 10];
68
+ const header = ['Agent ID', 'Msgs', 'Turns', 'Convs', 'Frag. Index', 'Outlier'];
69
+ console.log('\n ' + header.map((h, i) => h.padEnd(COL[i])).join(''));
70
+ console.log(' ' + '-'.repeat(COL.reduce((a, b) => a + b, 0)));
71
+ for (const r of rows) {
72
+ const row = [
73
+ String(r.agentId ?? '-'),
74
+ String(r.messageCount ?? '-'),
75
+ String(r.turnCount ?? '-'),
76
+ String(r.conversationCount ?? '-'),
77
+ fmtIndex(r.avgFragmentationIndex),
78
+ r.isOutlier ? 'yes' : 'no',
79
+ ];
80
+ console.log(' ' + row.map((v, i) => v.padEnd(COL[i])).join(''));
81
+ }
82
+ console.log('');
83
+ }),
84
+ };
85
+
86
+ const trendsCommand = {
87
+ command: 'trends',
88
+ describe: 'Fragmentation Index trend line over a rolling window of weeks',
89
+ builder: (y) => y
90
+ .option('weeks', { type: 'number', default: 12, describe: 'Number of trailing weeks to include, 1-52 (default 12)' }),
91
+ handler: runCommand(async (argv) => {
92
+ const qs = new URLSearchParams();
93
+ if (argv.weeks) qs.set('weeks', String(argv.weeks));
94
+ const rows = await messagingGet(`/Reports/conversation-efficiency/trends?${qs.toString()}`);
95
+ section('Conversation Efficiency - Trends');
96
+ if (!Array.isArray(rows) || !rows.length) { console.log(' No trend data yet.'); return; }
97
+
98
+ const COL = [14, 12, 10, 14, 10, 10];
99
+ const header = ['Week', 'Party', 'Agent', 'Frag. Index', 'Convs', 'Outlier'];
100
+ console.log('\n ' + header.map((h, i) => h.padEnd(COL[i])).join(''));
101
+ console.log(' ' + '-'.repeat(COL.reduce((a, b) => a + b, 0)));
102
+ for (const r of rows) {
103
+ const row = [
104
+ String(r.snapshotWeek ?? '-').slice(0, 12),
105
+ String(r.partyType ?? '-'),
106
+ String(r.agentId ?? 'site'),
107
+ fmtIndex(r.avgFragmentationIndex),
108
+ String(r.conversationCount ?? '-'),
109
+ r.isOutlier ? 'yes' : 'no',
110
+ ];
111
+ console.log(' ' + row.map((v, i) => v.padEnd(COL[i])).join(''));
112
+ }
113
+ console.log('');
114
+ }),
115
+ };
116
+
117
+ const correlationCommand = {
118
+ command: 'correlation',
119
+ describe: 'Correlation between Fragmentation Index and survey rating (last 8 weeks)',
120
+ handler: runCommand(async () => {
121
+ const data = await messagingGet('/Reports/conversation-efficiency/correlation');
122
+ section('Conversation Efficiency - Correlation');
123
+ console.log(` Sample size: ${data?.sampleSize ?? 0}`);
124
+ console.log(` ${data?.description ?? 'Not enough data yet.'}`);
125
+ console.log('');
126
+ }),
127
+ };
128
+
129
+ const narrativeCommand = {
130
+ command: 'narrative',
131
+ describe: 'Most recent AI-generated narrative summary (read-only -- does not regenerate)',
132
+ handler: runCommand(async () => {
133
+ const data = await messagingGet('/Reports/conversation-efficiency/narrative');
134
+ section('Conversation Efficiency - Narrative');
135
+ if (!data?.narrative) { console.log(' No narrative generated yet.'); return; }
136
+ console.log(` Period: ${data.periodStart ?? '-'} -> ${data.periodEnd ?? '-'}`);
137
+ console.log(` Generated: ${data.generatedAt ?? '-'}`);
138
+ console.log(`\n ${data.narrative}\n`);
139
+ }),
140
+ };
141
+
142
+ // Recommendations list is read-only here on purpose -- apply/rollback change a bot's live
143
+ // prompt configuration and stay a human-driven admin UI action, same judgment call as
144
+ // narrative regeneration above. There is no `conversation-efficiency recommendations apply`
145
+ // or `... rollback` subcommand.
146
+ const recommendationsCommand = {
147
+ command: 'recommendations',
148
+ describe: 'List closed-loop corrective recommendations for this site\'s bots (read-only)',
149
+ handler: runCommand(async () => {
150
+ const recs = await messagingGet('/Reports/conversation-efficiency/recommendations');
151
+ section('Conversation Efficiency - Recommendations');
152
+ if (!Array.isArray(recs) || !recs.length) { console.log(' No recommendations right now.'); return; }
153
+
154
+ for (const r of recs) {
155
+ const status = r.rolledBackAt ? 'rolled back' : r.appliedAt ? 'applied' : 'pending review';
156
+ console.log(`\n #${r.id} [${status}] -- ${r.title}`);
157
+ console.log(` Week: ${r.snapshotWeek ?? '-'} Frag. Index: ${fmtIndex(r.sourceFragmentationIndex)} Conversion gap: ${fmtPct(r.sourceConversionDelta)}`);
158
+ console.log(` ${r.body ?? ''}`);
159
+ if (r.appliedAt) console.log(` Applied ${r.appliedAt} by ${r.appliedBy ?? 'unknown'}`);
160
+ if (r.rolledBackAt) console.log(` Rolled back ${r.rolledBackAt} by ${r.rolledBackBy ?? 'unknown'}${r.rollbackNotes ? ` -- "${r.rollbackNotes}"` : ''}`);
161
+ }
162
+ console.log('\n Apply/rollback are admin-UI-only actions -- not available via this CLI.\n');
163
+ }),
164
+ };
165
+
166
+ export const conversationEfficiencyCommand = {
167
+ command: 'conversation-efficiency',
168
+ describe: 'Conversation Efficiency Analysis (Fragmentation Index) -- Professional/Enterprise',
169
+ builder: (y) => y
170
+ .command(overviewCommand)
171
+ .command(agentsCommand)
172
+ .command(trendsCommand)
173
+ .command(correlationCommand)
174
+ .command(narrativeCommand)
175
+ .command(recommendationsCommand)
176
+ .demandCommand(1, 'Specify a subcommand. Run "velaro conversation-efficiency --help" for options.'),
177
+ handler: () => {},
178
+ };
@@ -0,0 +1,114 @@
1
+ import { request } from '../api.js';
2
+ import { runCommand } from '../run.js';
3
+
4
+ // -- velaro copilotstudio -----------------------------------------------------
5
+ // Manage connections to Microsoft Copilot Studio agents and Azure AI Foundry
6
+ // agents for the logged-in site - live external knowledge sources the bot
7
+ // forwards questions to in real time (not ingested).
8
+ //
9
+ // Backend contract: server/Velaro.Admin/Controllers/CopilotStudioController.cs
10
+ // Site is resolved server-side from the auth context - never caller-supplied.
11
+
12
+ function printConfig(c) {
13
+ console.log(` [${c.id}] ${c.name} type=${c.agentType} ${c.isActive ? 'enabled' : 'DISABLED'}`);
14
+ console.log(` routing: ${c.whenToUseDescription ? `"${c.whenToUseDescription}"` : '(none set)'}`);
15
+ }
16
+
17
+ export const copilotStudioCommand = {
18
+ command: 'copilotstudio <subcommand>',
19
+ describe: 'Manage Microsoft Copilot Studio / Azure AI Foundry agent connections for this site',
20
+ builder: yargs => yargs
21
+ .command({
22
+ command: 'list',
23
+ describe: 'List connected Copilot Studio / Azure AI Foundry agents',
24
+ handler: runCommand(async () => {
25
+ const configs = await request('GET', '/CopilotStudio/configs');
26
+ if (!Array.isArray(configs) || !configs.length) {
27
+ console.log('\nNo connected Copilot Studio or Azure AI agents configured for this site.');
28
+ console.log('Use "velaro copilotstudio add" to connect one.\n');
29
+ return;
30
+ }
31
+ console.log(`\n=== Connected Agents (${configs.length}) ===`);
32
+ for (const c of configs) printConfig(c);
33
+ console.log('');
34
+ }),
35
+ })
36
+ .command({
37
+ command: 'add',
38
+ describe: 'Connect a new Copilot Studio (Direct Line) or Azure AI Foundry agent',
39
+ builder: y => y
40
+ .option('name', { type: 'string', demandOption: true, describe: 'Display name for this agent connection' })
41
+ .option('agent-type', { type: 'string', demandOption: true, choices: ['copilot-studio', 'azure-ai-agent'], describe: 'copilot-studio = Direct Line; azure-ai-agent = Azure AI Foundry REST' })
42
+ .option('when-to-use', { type: 'string', demandOption: true, describe: 'Plain-language description of what questions this agent answers' })
43
+ .option('direct-line-secret', { type: 'string', demandOption: false, describe: 'Direct Line secret (copilot-studio only)' })
44
+ .option('azure-endpoint', { type: 'string', demandOption: false, describe: 'Azure AI Foundry endpoint URL (azure-ai-agent only)' })
45
+ .option('azure-agent-id', { type: 'string', demandOption: false, describe: 'Azure AI Foundry agent ID (azure-ai-agent only)' })
46
+ .option('azure-api-key', { type: 'string', demandOption: false, describe: 'Azure AI Foundry API key (azure-ai-agent only)' }),
47
+ handler: runCommand(async argv => {
48
+ if (argv['agent-type'] === 'copilot-studio' && !argv['direct-line-secret']) {
49
+ throw new Error('--direct-line-secret is required for agent-type copilot-studio');
50
+ }
51
+ if (argv['agent-type'] === 'azure-ai-agent' && (!argv['azure-endpoint'] || !argv['azure-agent-id'] || !argv['azure-api-key'])) {
52
+ throw new Error('--azure-endpoint, --azure-agent-id, and --azure-api-key are all required for agent-type azure-ai-agent');
53
+ }
54
+ const created = await request('POST', '/CopilotStudio/configs', {
55
+ name: argv.name,
56
+ agentType: argv['agent-type'],
57
+ whenToUseDescription: argv['when-to-use'],
58
+ directLineSecret: argv['direct-line-secret'] || null,
59
+ azureEndpoint: argv['azure-endpoint'] || null,
60
+ azureAgentId: argv['azure-agent-id'] || null,
61
+ azureApiKey: argv['azure-api-key'] || null,
62
+ });
63
+ console.log(`\nConnected agent "${created.name}" added (id=${created.id})\n`);
64
+ printConfig(created);
65
+ console.log('');
66
+ }),
67
+ })
68
+ .command({
69
+ command: 'update <id>',
70
+ describe: 'Update name, enabled status, or routing description of an existing connection',
71
+ builder: y => y
72
+ .positional('id', { type: 'number', describe: 'Config ID from "velaro copilotstudio list"' })
73
+ .option('name', { type: 'string', demandOption: false, describe: 'New display name' })
74
+ .option('enable', { type: 'boolean', demandOption: false, describe: 'Enable the agent' })
75
+ .option('disable', { type: 'boolean', demandOption: false, describe: 'Disable the agent' })
76
+ .option('when-to-use', { type: 'string', demandOption: false, describe: 'New routing description (pass "" to clear)' }),
77
+ handler: runCommand(async argv => {
78
+ if (argv.enable && argv.disable) throw new Error('Pass only one of --enable or --disable, not both.');
79
+ const payload = {
80
+ name: argv.name ?? null,
81
+ isActive: argv.enable ? true : argv.disable ? false : null,
82
+ whenToUseDescription: argv['when-to-use'] ?? null,
83
+ };
84
+ const updated = await request('PUT', `/CopilotStudio/configs/${argv.id}`, payload);
85
+ console.log(`\nAgent connection ${argv.id} updated\n`);
86
+ printConfig(updated);
87
+ console.log('');
88
+ }),
89
+ })
90
+ .command({
91
+ command: 'remove <id>',
92
+ describe: 'Remove a connected agent',
93
+ builder: y => y.positional('id', { type: 'number', describe: 'Config ID from "velaro copilotstudio list"' }),
94
+ handler: runCommand(async argv => {
95
+ await request('DELETE', `/CopilotStudio/configs/${argv.id}`);
96
+ console.log(`\nAgent connection ${argv.id} removed\n`);
97
+ }),
98
+ })
99
+ .command({
100
+ command: 'test <id>',
101
+ describe: 'Test connectivity for an agent (Direct Line ping / Azure AI Foundry assistant fetch)',
102
+ builder: y => y.positional('id', { type: 'number', describe: 'Config ID from "velaro copilotstudio list"' }),
103
+ handler: runCommand(async argv => {
104
+ const result = await request('POST', `/CopilotStudio/configs/${argv.id}/test`);
105
+ if (result?.success) {
106
+ console.log(`\nConnection test succeeded for agent ${argv.id}\n`);
107
+ } else {
108
+ console.log(`\nConnection test FAILED for agent ${argv.id}: ${result?.error ?? 'unknown error'}\n`);
109
+ process.exitCode = 1;
110
+ }
111
+ }),
112
+ })
113
+ .demandCommand(1, 'Specify a subcommand: list | add | update | remove | test'),
114
+ };
@@ -0,0 +1,192 @@
1
+ // CLI surface for per-account Stripe discount grants (velaro-admin
2
+ // BillingCouponGrantController). Velaro-billing-staff only, same as the REST endpoints — the API
3
+ // enforces the role gate, this is just a convenient front end for it.
4
+ //
5
+ // NOTE ON SCOPE: this manages Velaro's STRIPE-side discount grants. The reusable coupon-CODE
6
+ // catalog is a separate, pre-existing system in velaro-messaging
7
+ // (superadmin/billing/coupons + BillingCoupons table); use `--coupon-code` here to record which
8
+ // catalog code a grant corresponds to. These are deliberately not the same thing — see the
9
+ // StripeCouponGrant entity doc comment for the ownership boundary.
10
+
11
+ import { request } from '../api.js';
12
+
13
+ const BASE = '/BillingCouponGrant';
14
+
15
+ function money(cents) {
16
+ return `$${(Number(cents) / 100).toFixed(2)}`;
17
+ }
18
+
19
+ function amount(g) {
20
+ return g.amountType === 'percent' ? `${g.amountValue}%` : `${money(g.amountValue)} ${String(g.currency).toUpperCase()}`;
21
+ }
22
+
23
+ function duration(g) {
24
+ switch (g.durationKind) {
25
+ case 'forever': return 'forever';
26
+ case 'periods': return `${g.durationMonths} billing period(s)`;
27
+ case 'once': return 'one-time';
28
+ case 'trial_only': return 'trial-only (expires unused if not applied during the trial)';
29
+ case 'until_date': return `until ${g.expiresAtUtc}`;
30
+ default: return g.durationKind;
31
+ }
32
+ }
33
+
34
+ function scope(g) {
35
+ return g.scope === 'account' ? 'whole account' : `${g.scope}: ${g.scopeTargetPriceId}`;
36
+ }
37
+
38
+ // How the discount is actually enforced. Not every Velaro account is charged via Stripe —
39
+ // manually-invoiced and comped accounts get the discount recorded here for whoever bills them,
40
+ // with no Stripe call made at all.
41
+ function appliedVia(g) {
42
+ if (!g.appliedVia) return '(not applied yet)';
43
+ return g.appliedVia === 'manual_invoice'
44
+ ? 'manual invoice (account is not Stripe-billed; no Stripe coupon created)'
45
+ : 'Stripe coupon';
46
+ }
47
+
48
+ function line(g) {
49
+ const flag = g.isActive ? '*' : ' ';
50
+ return `${flag} [${g.id}] site ${g.siteId} ${amount(g).padEnd(14)} ${String(g.status).padEnd(15)} ${scope(g).padEnd(28)} ${duration(g)}`;
51
+ }
52
+
53
+ async function list(args) {
54
+ const params = new URLSearchParams();
55
+ if (args.site) params.set('siteId', String(args.site));
56
+ if (args.all) params.set('includeTerminal', 'true');
57
+ const qs = params.toString();
58
+
59
+ const rows = await request('GET', qs ? `${BASE}?${qs}` : BASE);
60
+ if (!Array.isArray(rows) || rows.length === 0) {
61
+ console.log(args.all ? 'No discount grants found.' : 'No active discount grants. Use --all to include revoked/failed/expired.');
62
+ return;
63
+ }
64
+ console.log(' ID SITE AMOUNT STATUS SCOPE DURATION');
65
+ rows.forEach((g) => console.log(line(g)));
66
+ console.log(`\n${rows.length} grant(s). "*" = active (holds the account's one-discount slot).`);
67
+ }
68
+
69
+ async function get(id) {
70
+ const g = await request('GET', `${BASE}/${id}`);
71
+ console.log(`Grant #${g.id} — site ${g.siteId}`);
72
+ console.log(` Name: ${g.name}`);
73
+ console.log(` Coupon code: ${g.couponCode ?? '(none — ad-hoc grant)'}`);
74
+ console.log(` Amount: ${amount(g)}`);
75
+ console.log(` Scope: ${scope(g)}`);
76
+ console.log(` Duration: ${duration(g)}`);
77
+ console.log(` Status: ${g.status}${g.isActive ? ' (active)' : ''}`);
78
+ console.log(` Applied via: ${appliedVia(g)}`);
79
+ console.log(` Stripe coupon: ${g.stripeCouponId ?? '(not created yet)'}`);
80
+ console.log(` Stripe sub: ${g.stripeSubscriptionId ?? '(not resolved yet)'}`);
81
+ console.log(` Stripe discount: ${g.stripeDiscountId ?? '(not attached yet)'}`);
82
+ console.log(` Applied at: ${g.appliedAtUtc ?? '(not applied)'}`);
83
+ console.log(` Attempts: ${g.attemptCount}`);
84
+ if (g.failureReason) console.log(` Last failure: ${g.failureReason}`);
85
+ if (g.notes) console.log(` Notes: ${g.notes}`);
86
+ console.log(` Created: ${g.createdAtUtc} by ${g.createdBy}`);
87
+ if (g.revokedAtUtc) console.log(` Revoked: ${g.revokedAtUtc} by ${g.revokedBy}`);
88
+ }
89
+
90
+ function buildBody(args) {
91
+ const body = {};
92
+ if (args.site !== undefined) body.siteId = Number(args.site);
93
+ if (args.name !== undefined) body.name = args.name;
94
+ if (args['coupon-code']) body.couponCode = args['coupon-code'];
95
+ if (args.type !== undefined) body.amountType = args.type;
96
+ if (args.value !== undefined) body.amountValue = Number(args.value);
97
+ if (args.currency !== undefined) body.currency = args.currency;
98
+ if (args.scope !== undefined) body.scope = args.scope;
99
+ if (args['scope-price']) body.scopeTargetPriceId = args['scope-price'];
100
+ if (args.duration !== undefined) body.durationKind = args.duration;
101
+ if (args.months !== undefined) body.durationMonths = Number(args.months);
102
+ if (args.expires !== undefined) body.expiresAtUtc = args.expires;
103
+ if (args.notes !== undefined) body.notes = args.notes;
104
+ return body;
105
+ }
106
+
107
+ async function create(args) {
108
+ if (!args.site || args.value === undefined) {
109
+ console.error('Usage: velaro coupon-grant create --site <siteId> --type percent|fixed --value <n> --duration forever|periods|once|trial_only|until_date [--months N] [--expires ISO] [--scope account|package|feature --scope-price <stripePriceId>] [--name "..."] [--coupon-code CODE] [--notes "..."]');
110
+ console.error(' --value is a percentage for --type percent, or WHOLE CENTS for --type fixed (5000 = $50.00).');
111
+ process.exit(1);
112
+ }
113
+
114
+ const g = await request('POST', BASE, buildBody(args));
115
+ console.log(`Created grant #${g.id} for site ${g.siteId}: ${amount(g)}, ${duration(g)}, ${scope(g)}.`);
116
+ console.log(`Status: ${g.status}. The Stripe side is attached by a background job — re-run "velaro coupon-grant get ${g.id}" in a moment to confirm it reached "applied".`);
117
+ }
118
+
119
+ async function update(args) {
120
+ if (!args.id) {
121
+ console.error('Usage: velaro coupon-grant update --id <grantId> [--name ...] [--type ...] [--value ...] [--duration ...] [--months N] [--expires ISO] [--scope ...] [--scope-price ...] [--notes ...]');
122
+ console.error(' Only a still-pending grant can be edited. Every field is optional — omitted fields are left untouched.');
123
+ process.exit(1);
124
+ }
125
+
126
+ const body = buildBody(args);
127
+ delete body.siteId; // a grant never moves between accounts
128
+ const g = await request('PUT', `${BASE}/${args.id}`, body);
129
+ console.log(`Updated grant #${g.id}: ${amount(g)}, ${duration(g)}, ${scope(g)}.`);
130
+ }
131
+
132
+ async function revoke(id) {
133
+ if (!id) {
134
+ console.error('Usage: velaro coupon-grant revoke <grantId>');
135
+ process.exit(1);
136
+ }
137
+ const g = await request('DELETE', `${BASE}/${id}`);
138
+ console.log(`Revoked grant #${g.id} for site ${g.siteId}. The Stripe discount has been removed and the account's discount slot is free again.`);
139
+ }
140
+
141
+ async function retry(id) {
142
+ if (!id) {
143
+ console.error('Usage: velaro coupon-grant retry <grantId>');
144
+ process.exit(1);
145
+ }
146
+ const g = await request('POST', `${BASE}/${id}/retry`);
147
+ console.log(`Re-created failed grant as #${g.id} (status ${g.status}). Check back with "velaro coupon-grant get ${g.id}".`);
148
+ }
149
+
150
+ export const couponGrantCommand = {
151
+ command: 'coupon-grant <subcommand>',
152
+ describe: "Manage per-account Stripe discount grants (Velaro billing staff only). One active discount per account.",
153
+ builder: (yargs) =>
154
+ yargs
155
+ .command('list', 'List discount grants', {
156
+ site: { type: 'number', describe: 'Filter to one site' },
157
+ all: { type: 'boolean', default: false, describe: 'Include revoked/failed/expired grants' },
158
+ }, (args) => list(args))
159
+ .command('get <id>', 'Show one grant in full', {}, (args) => get(args.id))
160
+ .command('create', 'Grant a discount to one account', {
161
+ site: { type: 'number', describe: 'Target site ID (required)' },
162
+ name: { type: 'string', describe: 'Staff-facing label for why this was granted' },
163
+ 'coupon-code': { type: 'string', describe: 'Audit link to a reusable code in the messaging coupon catalog' },
164
+ type: { type: 'string', default: 'percent', describe: 'percent | fixed' },
165
+ value: { type: 'number', describe: 'Percentage, or whole CENTS when --type fixed (required)' },
166
+ currency: { type: 'string', default: 'usd', describe: 'Currency for a fixed amount' },
167
+ scope: { type: 'string', default: 'account', describe: 'account | package | feature' },
168
+ 'scope-price': { type: 'string', describe: 'Stripe Price ID the discount is limited to (required unless scope=account)' },
169
+ duration: { type: 'string', default: 'once', describe: 'forever | periods | once | trial_only | until_date' },
170
+ months: { type: 'number', describe: 'Billing periods, when --duration periods' },
171
+ expires: { type: 'string', describe: 'ISO UTC cutoff, required when --duration until_date' },
172
+ notes: { type: 'string', describe: 'Internal note' },
173
+ }, (args) => create(args))
174
+ .command('update', 'Edit a still-pending grant (partial update)', {
175
+ id: { type: 'number', describe: 'Grant ID (required)' },
176
+ name: { type: 'string' },
177
+ 'coupon-code': { type: 'string' },
178
+ type: { type: 'string' },
179
+ value: { type: 'number' },
180
+ currency: { type: 'string' },
181
+ scope: { type: 'string' },
182
+ 'scope-price': { type: 'string' },
183
+ duration: { type: 'string' },
184
+ months: { type: 'number' },
185
+ expires: { type: 'string' },
186
+ notes: { type: 'string' },
187
+ }, (args) => update(args))
188
+ .command('revoke <id>', "Remove a grant's discount from Stripe and free the account's slot", {}, (args) => revoke(args.id))
189
+ .command('retry <id>', 'Re-attempt a failed grant', {}, (args) => retry(args.id))
190
+ .demandCommand(1, 'Specify a subcommand: list | get | create | update | revoke | retry'),
191
+ handler: () => {},
192
+ };
@@ -0,0 +1,101 @@
1
+ import { runCommand } from '../run.js';
2
+
3
+ // ────────────────────────────────────────────────────────────────────────────
4
+ // Direct database query tool — read-only by default.
5
+ //
6
+ // Built 2026-08-17 after a session burned significant time trying to answer
7
+ // one question ("did migration X actually apply to staging?") through the
8
+ // Azure Portal's browser-based Query Editor, which stopped responding to
9
+ // clicks under host memory pressure. This is the durable replacement: a
10
+ // scriptable, non-interactive way to run a SQL query against any of the
11
+ // named databases below, with connection strings sourced from Key Vault
12
+ // (never hardcoded) per this repo's KV-first secrets rule.
13
+ //
14
+ // SAFE BY DEFAULT: only SELECT statements are allowed unless --allow-write
15
+ // is passed explicitly. This tool is for diagnostics, not migrations —
16
+ // schema changes still go through `dotnet ef migrations add` per this
17
+ // repo's EF-migrations-are-the-source-of-truth rule.
18
+ // ────────────────────────────────────────────────────────────────────────────
19
+
20
+ // Maps a short --db name to the Key Vault secret holding its connection string.
21
+ // Add a new entry here (and set the secret in the vault) whenever a new
22
+ // database needs to be queryable this way — never hardcode a connection
23
+ // string in this file.
24
+ const DB_SECRETS = {
25
+ kb: {
26
+ staging: { vault: 'velaro-eastus-stg-kv', secret: 'KnowledgeBaseConnectionString-Staging' },
27
+ },
28
+ };
29
+
30
+ async function getConnectionString(dbName, env, connectionStringFile) {
31
+ if (connectionStringFile) {
32
+ const { readFileSync } = await import('fs');
33
+ return readFileSync(connectionStringFile, 'utf8').trim();
34
+ }
35
+ const entry = DB_SECRETS[dbName]?.[env];
36
+ if (!entry) {
37
+ const known = Object.entries(DB_SECRETS)
38
+ .flatMap(([db, envs]) => Object.keys(envs).map((e) => `${db}/${e}`))
39
+ .join(', ');
40
+ throw new Error(`No connection string registered for --db ${dbName} --env ${env}. Known: ${known}. Or pass --connection-string-file for a one-off ad-hoc connection (never commit that file).`);
41
+ }
42
+ const { SecretClient } = await import('@azure/keyvault-secrets');
43
+ const { DefaultAzureCredential } = await import('@azure/identity');
44
+ const client = new SecretClient(`https://${entry.vault}.vault.azure.net`, new DefaultAzureCredential());
45
+ const secret = await client.getSecret(entry.secret);
46
+ return secret.value;
47
+ }
48
+
49
+ // Conservative guard: block anything that isn't a plain read. This is a
50
+ // string check, not a real SQL parser — it exists to stop accidental writes
51
+ // from a mistyped query, not to be airtight against a determined bypass.
52
+ // Real write protection is the underlying SQL user's own grants.
53
+ function assertReadOnly(query) {
54
+ const normalized = query.trim().replace(/^\s*;+/, '').toUpperCase();
55
+ const writeVerbs = ['INSERT', 'UPDATE', 'DELETE', 'DROP', 'ALTER', 'TRUNCATE', 'EXEC', 'MERGE', 'CREATE'];
56
+ const firstWord = normalized.split(/\s+/)[0];
57
+ if (writeVerbs.includes(firstWord)) {
58
+ throw new Error(`Query looks like a write (${firstWord}) — pass --allow-write to run it, or use a proper EF migration for schema changes.`);
59
+ }
60
+ }
61
+
62
+ export const dbCommand = {
63
+ command: 'db <subcommand>',
64
+ describe: 'Run direct SQL queries against a named database (diagnostics only — read-only unless --allow-write)',
65
+ builder: (yargs) =>
66
+ yargs
67
+ .command({
68
+ command: 'query <sql>',
69
+ describe: 'Run a SQL query. Wrap the query in quotes.',
70
+ builder: (y) =>
71
+ y
72
+ .positional('sql', { type: 'string', describe: 'SQL query to run' })
73
+ .option('db', { type: 'string', describe: `Database name (${Object.keys(DB_SECRETS).join(', ')})` })
74
+ .option('env', { type: 'string', default: 'staging', choices: ['staging', 'prod'], describe: 'Environment' })
75
+ .option('connection-string-file', { type: 'string', describe: 'Path to a local file containing a raw connection string, for one-off ad-hoc queries not yet registered in Key Vault' })
76
+ .option('allow-write', { type: 'boolean', default: false, describe: 'Allow INSERT/UPDATE/DELETE/DDL statements' }),
77
+ handler: runCommand(async (argv) => {
78
+ if (!argv['allow-write']) assertReadOnly(argv.sql);
79
+
80
+ const sql = await import('mssql');
81
+ const connectionString = await getConnectionString(argv.db, argv.env, argv['connection-string-file']);
82
+ const pool = await sql.default.connect(connectionString);
83
+ try {
84
+ const result = await pool.request().query(argv.sql);
85
+ const rows = Array.isArray(result.recordset) ? result.recordset : [];
86
+ if (rows.length === 0) {
87
+ console.log(argv['allow-write']
88
+ ? `Done. Rows affected: ${result.rowsAffected?.join(', ') ?? 0}`
89
+ : 'No rows returned.');
90
+ return;
91
+ }
92
+ console.table(rows);
93
+ console.log(`\n${rows.length} row(s).`);
94
+ } finally {
95
+ await pool.close();
96
+ }
97
+ }),
98
+ })
99
+ .demandCommand(1, 'Specify: query'),
100
+ handler: () => {},
101
+ };