@velaro/cli 1.4.53 → 1.4.55
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -0
- package/assets/billing-doc-patterns/concentric-arcs.png +0 -0
- package/assets/billing-doc-patterns/diagonal-hatch.png +0 -0
- package/assets/billing-doc-patterns/dot-grid.png +0 -0
- package/assets/billing-doc-patterns/hex-grid.png +0 -0
- package/assets/billing-doc-patterns/wave-lines.png +0 -0
- package/assets/velaro-logo-lockup-full-color.png +0 -0
- package/bin/velaro.js +4 -0
- package/lib/commands/article.js +7 -3
- package/lib/commands/billing-subscription.js +610 -3
- package/lib/commands/contact-list.js +88 -1
- package/lib/commands/db.js +3 -0
- package/lib/commands/entitlement.js +24 -1
- package/lib/commands/login.js +7 -6
- package/lib/commands/logs.js +129 -5
- package/lib/commands/mcp-key.js +24 -14
- package/lib/commands/order-form.js +107 -0
- package/lib/commands/outbound-activity.js +124 -0
- package/lib/commands/subscription.js +61 -2
- package/lib/commands/support.js +19 -1
- package/lib/commands/teams.js +49 -1
- package/lib/config.js +24 -2
- package/lib/config.test.js +94 -0
- package/lib/kb-screenshot.js +120 -43
- package/lib/mcp-key.test.js +17 -0
- package/lib/pdf/billing-document.js +534 -0
- package/lib/pdf/billing-document.test.js +349 -0
- package/lib/site-flags.js +71 -0
- package/lib/site-flags.test.js +57 -0
- package/package.json +6 -3
package/lib/kb-screenshot.js
CHANGED
|
@@ -31,8 +31,10 @@ const execFileAsync = promisify(execFile);
|
|
|
31
31
|
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
32
32
|
|
|
33
33
|
const APP_URLS = {
|
|
34
|
-
admin:
|
|
35
|
-
messaging:
|
|
34
|
+
admin: process.env.ADMIN_UI_URL || 'https://admin-staging.velaro.com',
|
|
35
|
+
messaging: process.env.MSG_UI_URL || 'https://messaging-staging.velaro.com',
|
|
36
|
+
// v10 (livefluence). Console-staging, never prod -- same "staging only" rule as admin/messaging.
|
|
37
|
+
livefluence: process.env.LIVEFLUENCE_UI_URL || 'https://console-staging.velaro.com',
|
|
36
38
|
};
|
|
37
39
|
|
|
38
40
|
// Key Vault fallback for credentials — used only when neither E2E_USER_* nor TEST_USER_*
|
|
@@ -41,6 +43,14 @@ const KV_NAME = process.env.KB_SCREENSHOT_KV_NAME || 'velaro-e
|
|
|
41
43
|
const KV_EMAIL_SECRET = process.env.KB_SCREENSHOT_KV_EMAIL_SECRET || 'claude-test-agent-staging1032-email';
|
|
42
44
|
const KV_PASSWORD_SECRET = process.env.KB_SCREENSHOT_KV_PASSWORD_SECRET || 'claude-test-agent-staging1032-password';
|
|
43
45
|
|
|
46
|
+
// v10 (livefluence) console-staging uses a DIFFERENT test account than admin/messaging's
|
|
47
|
+
// default above -- confirmed live 2026-09-18 (memory: project_test_agent_reset_and_transfer_test_gaps_2026-09-15).
|
|
48
|
+
// Never silently reuse the admin/messaging KV secret names for v10; they resolve to a
|
|
49
|
+
// different account that has no guarantee of being provisioned on site 1032's v10 side.
|
|
50
|
+
const LIVEFLUENCE_KV_NAME = process.env.LIVEFLUENCE_KB_SCREENSHOT_KV_NAME || 'velaro-eastus-stg-kv';
|
|
51
|
+
const LIVEFLUENCE_KV_USERNAME_SECRET = process.env.LIVEFLUENCE_KB_SCREENSHOT_KV_USERNAME_SECRET || 'claude-test-agent-staging1032-username';
|
|
52
|
+
const LIVEFLUENCE_KV_PASSWORD_SECRET = process.env.LIVEFLUENCE_KB_SCREENSHOT_KV_PASSWORD_SECRET || 'claude-test-agent-staging1032-password';
|
|
53
|
+
|
|
44
54
|
/**
|
|
45
55
|
* Pulls the email/password pair from Azure Key Vault via DefaultAzureCredential.
|
|
46
56
|
* Locally this picks up the operator's own `az login` session automatically — no
|
|
@@ -49,16 +59,16 @@ const KV_PASSWORD_SECRET = process.env.KB_SCREENSHOT_KV_PASSWORD_SECRET || 'clau
|
|
|
49
59
|
* identity) to authenticate — out of scope here since this CLI command is
|
|
50
60
|
* operator-run today, not a CI step.
|
|
51
61
|
*/
|
|
52
|
-
async function loadCredentialsFromKeyVault() {
|
|
62
|
+
async function loadCredentialsFromKeyVault(vaultName, emailSecretName, passwordSecretName) {
|
|
53
63
|
const { SecretClient } = await import('@azure/keyvault-secrets');
|
|
54
64
|
const { DefaultAzureCredential } = await import('@azure/identity');
|
|
55
65
|
|
|
56
|
-
const vaultUrl = `https://${
|
|
66
|
+
const vaultUrl = `https://${vaultName}.vault.azure.net`;
|
|
57
67
|
const client = new SecretClient(vaultUrl, new DefaultAzureCredential());
|
|
58
68
|
|
|
59
69
|
const [emailSecret, passwordSecret] = await Promise.all([
|
|
60
|
-
client.getSecret(
|
|
61
|
-
client.getSecret(
|
|
70
|
+
client.getSecret(emailSecretName),
|
|
71
|
+
client.getSecret(passwordSecretName),
|
|
62
72
|
]);
|
|
63
73
|
|
|
64
74
|
return { email: emailSecret.value, password: passwordSecret.value };
|
|
@@ -78,15 +88,34 @@ async function loadPlaywright() {
|
|
|
78
88
|
|
|
79
89
|
/**
|
|
80
90
|
* Logs into the given app on staging using the same form-fill steps as
|
|
81
|
-
* e2e/tests/auth.setup.ts, navigates to `route`,
|
|
91
|
+
* e2e/tests/auth.setup.ts, navigates to `route`, optionally clicks a selector,
|
|
92
|
+
* and screenshots to `outputPath`.
|
|
93
|
+
*
|
|
94
|
+
* @param {object} [opts]
|
|
95
|
+
* @param {string} [opts.click] Selector (any Playwright engine — CSS, `text=`, `role=`) to
|
|
96
|
+
* click after the route loads, before capturing. Needed to reach anything not visible on
|
|
97
|
+
* initial page load — an export menu, a "..." row action, a modal trigger.
|
|
98
|
+
* @param {boolean} [opts.popup] The click in `opts.click` opens a NEW browser window/tab
|
|
99
|
+
* (`window.open`, `target="_blank"`) rather than something in the current page — e.g. a
|
|
100
|
+
* per-transcript "Export" action. Without this, `page.screenshot()` still captures the
|
|
101
|
+
* ORIGINAL page (usually just the button that was clicked), because Playwright never
|
|
102
|
+
* automatically follows focus to a new window. Root cause of the 2026-09-18 KB gap: the
|
|
103
|
+
* transcript-export screenshot needed this and the tool had no way to follow the popup.
|
|
82
104
|
*/
|
|
83
|
-
export async function loginAndScreenshot(app, route, outputPath) {
|
|
105
|
+
export async function loginAndScreenshot(app, route, outputPath, opts = {}) {
|
|
106
|
+
const baseUrl = APP_URLS[app];
|
|
107
|
+
if (!baseUrl) throw new Error(`Unknown app "${app}" — expected "admin", "messaging", or "livefluence".`);
|
|
108
|
+
|
|
109
|
+
const isLivefluence = app === 'livefluence';
|
|
110
|
+
|
|
84
111
|
let email = process.env.E2E_USER_EMAIL || process.env.TEST_USER_EMAIL;
|
|
85
112
|
let password = process.env.E2E_USER_PASSWORD || process.env.TEST_USER_PASSWORD;
|
|
86
113
|
|
|
87
114
|
if (!email || !password) {
|
|
88
115
|
try {
|
|
89
|
-
const kvCreds =
|
|
116
|
+
const kvCreds = isLivefluence
|
|
117
|
+
? await loadCredentialsFromKeyVault(LIVEFLUENCE_KV_NAME, LIVEFLUENCE_KV_USERNAME_SECRET, LIVEFLUENCE_KV_PASSWORD_SECRET)
|
|
118
|
+
: await loadCredentialsFromKeyVault(KV_NAME, KV_EMAIL_SECRET, KV_PASSWORD_SECRET);
|
|
90
119
|
email = kvCreds.email;
|
|
91
120
|
password = kvCreds.password;
|
|
92
121
|
} catch (err) {
|
|
@@ -99,16 +128,16 @@ export async function loginAndScreenshot(app, route, outputPath) {
|
|
|
99
128
|
}
|
|
100
129
|
|
|
101
130
|
if (!email || !password) {
|
|
131
|
+
const [vault, userSecret, passSecret] = isLivefluence
|
|
132
|
+
? [LIVEFLUENCE_KV_NAME, LIVEFLUENCE_KV_USERNAME_SECRET, LIVEFLUENCE_KV_PASSWORD_SECRET]
|
|
133
|
+
: [KV_NAME, KV_EMAIL_SECRET, KV_PASSWORD_SECRET];
|
|
102
134
|
throw new Error(
|
|
103
135
|
'E2E_USER_EMAIL/E2E_USER_PASSWORD (or TEST_USER_EMAIL/TEST_USER_PASSWORD) must be set, ' +
|
|
104
|
-
`and Key Vault (${
|
|
105
|
-
`"${
|
|
136
|
+
`and Key Vault (${vault}) did not return usable values for secrets ` +
|
|
137
|
+
`"${userSecret}"/"${passSecret}".`
|
|
106
138
|
);
|
|
107
139
|
}
|
|
108
140
|
|
|
109
|
-
const baseUrl = APP_URLS[app];
|
|
110
|
-
if (!baseUrl) throw new Error(`Unknown app "${app}" — expected "admin" or "messaging".`);
|
|
111
|
-
|
|
112
141
|
const { chromium } = await loadPlaywright();
|
|
113
142
|
const browser = await chromium.launch();
|
|
114
143
|
|
|
@@ -116,30 +145,52 @@ export async function loginAndScreenshot(app, route, outputPath) {
|
|
|
116
145
|
const context = await browser.newContext();
|
|
117
146
|
const page = await context.newPage();
|
|
118
147
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
148
|
+
if (isLivefluence) {
|
|
149
|
+
// ── v10 login (Client.ConsoleNew's own /Account/Login form, no CIAM redirect) ──
|
|
150
|
+
// Direct email+password form on the same host -- confirmed live 2026-09-18 via
|
|
151
|
+
// interactive login at app.velaro.com/Account/Login. Unlike admin/messaging, there
|
|
152
|
+
// is no login.velaro.com hop and no "stay signed in" prompt to handle.
|
|
153
|
+
await page.goto(new URL('/Account/Login', baseUrl).toString());
|
|
154
|
+
// The field's accessible label/placeholder is literally "Username" (an email
|
|
155
|
+
// address is entered into it, but it is NOT labeled "Email") -- confirmed live
|
|
156
|
+
// 2026-09-18 against the real login form; an /email/i selector silently matched
|
|
157
|
+
// nothing and the fill() was a no-op, which the old broad waitForURL(/velaro\.com/)
|
|
158
|
+
// masked because that regex also matches the login page's own URL.
|
|
159
|
+
await page.getByPlaceholder(/username/i).fill(email);
|
|
160
|
+
await page.getByPlaceholder(/password/i).fill(password);
|
|
161
|
+
// Exact match -- "Sign in with SSO" also matches a loose /sign in/i regex and sits
|
|
162
|
+
// right next to the real submit button (confirmed live 2026-09-18, strict-mode
|
|
163
|
+
// violation on first test run).
|
|
164
|
+
await page.getByRole('button', { name: 'Sign In', exact: true }).click();
|
|
165
|
+
// Must NOT match the login page's own URL -- wait for the post-login redirect away
|
|
166
|
+
// from /Account/Login specifically, not just "somewhere on velaro.com".
|
|
167
|
+
await page.waitForURL((url) => !url.pathname.toLowerCase().includes('/account/login'), { timeout: 30_000 });
|
|
168
|
+
} else {
|
|
169
|
+
// ── Login (adapted from e2e/tests/auth.setup.ts) ──────────────────────────
|
|
170
|
+
// Role/label based selectors, not type-attribute selectors — the CIAM login page's
|
|
171
|
+
// markup does not reliably render the email field as a literal input[type="email"]
|
|
172
|
+
// or the submit controls as input[type="submit"] (found 2026-08-01 in auth.setup.ts,
|
|
173
|
+
// confirmed again 2026-08-13 against live admin-staging while verifying the Key
|
|
174
|
+
// Vault credential fallback below: the email step is input[name="username"]
|
|
175
|
+
// type="text", and both submit controls are <button type="submit">). Role/label
|
|
176
|
+
// selectors track the accessible page contract instead of an implementation detail
|
|
177
|
+
// that can change under us.
|
|
178
|
+
await page.goto(baseUrl);
|
|
179
|
+
await page.waitForURL(/login\.velaro\.com/, { timeout: 30_000 });
|
|
180
|
+
|
|
181
|
+
await page.getByRole('textbox', { name: /email/i }).fill(email);
|
|
182
|
+
await page.getByRole('button', { name: /next|sign in/i }).click();
|
|
183
|
+
|
|
184
|
+
await page.getByLabel(/password/i).fill(password);
|
|
185
|
+
await page.getByRole('button', { name: /sign in|next/i }).click();
|
|
186
|
+
|
|
187
|
+
// Handle "Stay signed in?" prompt — not always shown.
|
|
188
|
+
try {
|
|
189
|
+
await page.getByRole('button', { name: /yes|no|stay signed in/i }).first().click({ timeout: 5_000 });
|
|
190
|
+
} catch { /* prompt not shown — proceed */ }
|
|
191
|
+
|
|
192
|
+
await page.waitForURL(/velaro\.com/, { timeout: 30_000 });
|
|
193
|
+
}
|
|
143
194
|
|
|
144
195
|
// ── Navigate + capture ──────────────────────────────────────────────────
|
|
145
196
|
const target = new URL(route, baseUrl).toString();
|
|
@@ -149,7 +200,28 @@ export async function loginAndScreenshot(app, route, outputPath) {
|
|
|
149
200
|
// capture just because the network never goes fully idle.
|
|
150
201
|
});
|
|
151
202
|
|
|
152
|
-
|
|
203
|
+
let captureTarget = page;
|
|
204
|
+
|
|
205
|
+
if (opts.click) {
|
|
206
|
+
if (opts.popup) {
|
|
207
|
+
// context.waitForEvent('page') must be armed BEFORE the click, not after — the
|
|
208
|
+
// popup can open (and even finish loading) before an await on the click resolves.
|
|
209
|
+
const [popup] = await Promise.all([
|
|
210
|
+
context.waitForEvent('page', { timeout: 15_000 }),
|
|
211
|
+
page.click(opts.click),
|
|
212
|
+
]);
|
|
213
|
+
await popup.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {});
|
|
214
|
+
captureTarget = popup;
|
|
215
|
+
} else {
|
|
216
|
+
await page.click(opts.click);
|
|
217
|
+
// Give in-page content (a modal, an inline export panel) a moment to render —
|
|
218
|
+
// there's no popup "page" event to wait on for same-page UI.
|
|
219
|
+
await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
await captureTarget.screenshot({ path: outputPath, fullPage: true });
|
|
224
|
+
if (captureTarget !== page) await captureTarget.close();
|
|
153
225
|
} finally {
|
|
154
226
|
await browser.close();
|
|
155
227
|
}
|
|
@@ -259,21 +331,26 @@ export function escapeAttr(s) {
|
|
|
259
331
|
|
|
260
332
|
/**
|
|
261
333
|
* @param {object} opts
|
|
262
|
-
* @param {"admin"|"messaging"} opts.app
|
|
334
|
+
* @param {"admin"|"messaging"|"livefluence"} opts.app
|
|
263
335
|
* @param {string} opts.route
|
|
264
336
|
* @param {number} opts.articleId
|
|
265
337
|
* @param {string} [opts.heading]
|
|
266
338
|
* @param {{x:number,y:number,label?:string}[]} [opts.annotations]
|
|
339
|
+
* @param {string} [opts.click] Selector to click after the route loads, before capturing.
|
|
340
|
+
* @param {boolean} [opts.popup] The click opens a new browser window — capture that
|
|
341
|
+
* window's content instead of the original page's. See loginAndScreenshot() for why
|
|
342
|
+
* this is required (a popup is invisible to a plain page.screenshot() otherwise).
|
|
267
343
|
* @param {{token:string, apiBase:string}} [opts.auth] Explicit auth override for callers
|
|
268
344
|
* that don't use the CLI's stored login config (e.g. mcp/server.js, which authenticates
|
|
269
345
|
* via VELARO_MCP_KEY/VELARO_JWT env vars). CLI usage omits this and falls back to
|
|
270
346
|
* getCredentials() (the normal `velaro login` flow).
|
|
271
347
|
* @returns {Promise<{articleId:number, imageUrl:string, insertedAfterHeading:boolean}>}
|
|
272
348
|
*/
|
|
273
|
-
export async function captureKbScreenshot({ app, route, articleId, heading, annotations, auth }) {
|
|
274
|
-
if (!app || !APP_URLS[app]) throw new Error('app must be "admin" or "
|
|
349
|
+
export async function captureKbScreenshot({ app, route, articleId, heading, annotations, click, popup, auth }) {
|
|
350
|
+
if (!app || !APP_URLS[app]) throw new Error('app must be "admin", "messaging", or "livefluence"');
|
|
275
351
|
if (!route) throw new Error('route is required');
|
|
276
352
|
if (!articleId) throw new Error('articleId is required');
|
|
353
|
+
if (popup && !click) throw new Error('--popup requires --click (nothing to click that would open one)');
|
|
277
354
|
|
|
278
355
|
const rawPath = join(tmpdir(), `kb-screenshot-${articleId}-${Date.now()}.png`);
|
|
279
356
|
let polishedPath = rawPath;
|
|
@@ -283,7 +360,7 @@ export async function captureKbScreenshot({ app, route, articleId, heading, anno
|
|
|
283
360
|
? { velaroToken: auth.token, adminApiBase: auth.apiBase }
|
|
284
361
|
: await getCredentials();
|
|
285
362
|
|
|
286
|
-
await loginAndScreenshot(app, route, rawPath);
|
|
363
|
+
await loginAndScreenshot(app, route, rawPath, { click, popup });
|
|
287
364
|
polishedPath = await polishScreenshot(rawPath, annotations);
|
|
288
365
|
const imageUrl = await uploadImage(polishedPath, creds);
|
|
289
366
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { normalizeMcpKeysResponse } from './commands/mcp-key.js';
|
|
4
|
+
|
|
5
|
+
test('MCP key list reads the current API response shape', () => {
|
|
6
|
+
const keys = [{ id: 1, label: 'Staff', keyPrefix: 'vel_live_example' }];
|
|
7
|
+
assert.deepEqual(normalizeMcpKeysResponse({ keys, monthlyLimit: 100 }), keys);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test('MCP key list accepts the legacy array response', () => {
|
|
11
|
+
const keys = [{ id: 2, label: 'Legacy' }];
|
|
12
|
+
assert.deepEqual(normalizeMcpKeysResponse(keys), keys);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('MCP key list rejects an unexpected response instead of reporting no keys', () => {
|
|
16
|
+
assert.throws(() => normalizeMcpKeysResponse({ error: 'unavailable' }), /Unexpected MCP API key list response/);
|
|
17
|
+
});
|