@velaro/cli 0.5.0 → 1.2.0
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 +5 -1
- package/lib/api.js +52 -52
- package/lib/commands/agent.js +50 -50
- package/lib/commands/article.js +120 -12
- package/lib/commands/check.js +163 -163
- package/lib/commands/deployment.js +107 -107
- package/lib/commands/env.js +45 -45
- package/lib/commands/index.js +212 -0
- package/lib/commands/ingest.js +31 -31
- package/lib/commands/kb.js +27 -4
- package/lib/commands/login.js +86 -86
- package/lib/commands/ops.js +173 -0
- package/lib/commands/site.js +62 -62
- package/lib/commands/status.js +24 -24
- package/lib/commands/team.js +144 -144
- package/lib/commands/update.js +47 -47
- package/lib/commands/whoami.js +22 -22
- package/lib/config.js +83 -83
- package/lib/oauth.js +135 -135
- package/lib/run.js +16 -16
- package/lib/subscription.js +39 -39
- package/lib/track.js +35 -35
- package/lib/update-check.js +64 -64
- package/package.json +2 -2
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { get, post, getCredentials } from '../api.js';
|
|
2
|
+
|
|
3
|
+
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
function isVelaroAdmin(creds) {
|
|
6
|
+
return creds.siteId === 1032;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function mb(n) {
|
|
10
|
+
if (n >= 1024) return `${(n / 1024).toFixed(1)} GB`;
|
|
11
|
+
return `${n} MB`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function bar(used, total, width = 20) {
|
|
15
|
+
if (!total) return '[ ] n/a';
|
|
16
|
+
const pct = Math.min(used / total, 1);
|
|
17
|
+
const filled = Math.round(pct * width);
|
|
18
|
+
const color = pct >= 0.9 ? '\x1b[31m' : pct >= 0.7 ? '\x1b[33m' : '\x1b[32m';
|
|
19
|
+
return `${color}[${'█'.repeat(filled)}${' '.repeat(width - filled)}]\x1b[0m ${(pct * 100).toFixed(0)}%`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ── list ─────────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
const listCommand = {
|
|
25
|
+
command: 'list',
|
|
26
|
+
describe: 'List indexes. Velaro admins see all physical indexes; customers see their virtual indexes + quota.',
|
|
27
|
+
builder: (y) =>
|
|
28
|
+
y.option('site', { type: 'number', describe: 'Site ID (Velaro staff only)' }),
|
|
29
|
+
handler: async (argv) => {
|
|
30
|
+
const creds = await getCredentials();
|
|
31
|
+
|
|
32
|
+
if (isVelaroAdmin(creds)) {
|
|
33
|
+
// ── Velaro admin: show all physical indexes with storage health ─────────
|
|
34
|
+
const stats = await get('/DatabaseTool/SearchIndexStats');
|
|
35
|
+
|
|
36
|
+
console.log('\n\x1b[1mPhysical Indexes\x1b[0m');
|
|
37
|
+
console.log(` Shared: ${stats.sharedIndexes.length} Dedicated: ${stats.dedicatedIndexes.length} Total: ${stats.totalSlots}`);
|
|
38
|
+
|
|
39
|
+
const LIMIT_MB = 160 * 1024;
|
|
40
|
+
|
|
41
|
+
console.log('\n\x1b[1mShared Indexes\x1b[0m');
|
|
42
|
+
for (const idx of stats.sharedIndexes) {
|
|
43
|
+
console.log(`\n \x1b[36m${idx.indexName}\x1b[0m ${idx.sites?.length ?? 0} sites ${idx.totalDocuments.toLocaleString()} docs`);
|
|
44
|
+
console.log(` Storage: ${bar(idx.storageMb, LIMIT_MB)} ${mb(idx.storageMb)} / ${mb(LIMIT_MB)}`);
|
|
45
|
+
if (idx.sites?.length) {
|
|
46
|
+
for (const s of idx.sites.slice(0, 10)) {
|
|
47
|
+
const badge = s.assignmentType === 'legacy-fallback' ? '\x1b[33m[legacy]\x1b[0m' :
|
|
48
|
+
s.assignmentType === 'dedicated' ? '\x1b[35m[dedicated]\x1b[0m' :
|
|
49
|
+
'\x1b[34m[shared]\x1b[0m';
|
|
50
|
+
console.log(` Site ${s.siteId.toString().padEnd(8)} ${badge} ${s.documentCount.toLocaleString()} docs`);
|
|
51
|
+
}
|
|
52
|
+
if (idx.sites.length > 10) console.log(` ... and ${idx.sites.length - 10} more`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (stats.dedicatedIndexes.length) {
|
|
57
|
+
console.log('\n\x1b[1mDedicated Indexes\x1b[0m');
|
|
58
|
+
for (const idx of stats.dedicatedIndexes) {
|
|
59
|
+
console.log(` \x1b[35m${idx.indexName}\x1b[0m Site ${idx.siteId} ${idx.documentCount.toLocaleString()} docs`);
|
|
60
|
+
console.log(` Storage: ${bar(idx.storageMb, LIMIT_MB)} ${mb(idx.storageMb)} / ${mb(LIMIT_MB)}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const legacy = stats.sharedIndexes.flatMap(s => s.sites ?? []).filter(s => s.assignmentType === 'legacy-fallback').length;
|
|
65
|
+
if (legacy > 0) {
|
|
66
|
+
console.log(`\n\x1b[33m⚠ ${legacy} legacy site(s) have no index assignment (fallback to velaro-shared-index)\x1b[0m`);
|
|
67
|
+
}
|
|
68
|
+
} else {
|
|
69
|
+
// ── Customer: show their virtual indexes + quota ────────────────────────
|
|
70
|
+
const siteId = argv.site ?? creds.siteId;
|
|
71
|
+
const [indexes, breakdown] = await Promise.all([
|
|
72
|
+
get('/AzureIndexes/List'),
|
|
73
|
+
get(`/DatabaseTool/SearchIndexSite?siteId=${siteId}`).catch(() => null),
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
// Quota from subscription
|
|
77
|
+
const sub = await get('/Subscription/Get').catch(() => null);
|
|
78
|
+
const chunkCap = sub?.maxIndexedChunkTotal ?? 0;
|
|
79
|
+
const usedChunks = indexes.reduce((s, i) => s + (i.documentCount ?? 0), 0);
|
|
80
|
+
|
|
81
|
+
console.log('\n\x1b[1mYour Search Indexes\x1b[0m');
|
|
82
|
+
|
|
83
|
+
if (chunkCap > 0) {
|
|
84
|
+
console.log(`\nQuota: ${bar(usedChunks, chunkCap)} ${usedChunks.toLocaleString()} / ${chunkCap.toLocaleString()} chunks used`);
|
|
85
|
+
const remaining = chunkCap - usedChunks;
|
|
86
|
+
if (remaining <= 0) {
|
|
87
|
+
console.log('\x1b[31m✗ Quota full — new content will not be indexed until you upgrade or remove content.\x1b[0m');
|
|
88
|
+
} else if (remaining < chunkCap * 0.1) {
|
|
89
|
+
console.log(`\x1b[33m⚠ Only ${remaining.toLocaleString()} chunks remaining. Consider upgrading your plan.\x1b[0m`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (breakdown) {
|
|
94
|
+
const typeLabel = breakdown.assignmentType === 'dedicated' ? '\x1b[35m[dedicated index]\x1b[0m' :
|
|
95
|
+
breakdown.assignmentType === 'shared' ? '\x1b[34m[shared index]\x1b[0m' :
|
|
96
|
+
'\x1b[33m[legacy]\x1b[0m';
|
|
97
|
+
console.log(`\nPhysical index: \x1b[36m${breakdown.physicalIndex}\x1b[0m ${typeLabel}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
console.log('');
|
|
101
|
+
for (const idx of indexes) {
|
|
102
|
+
const docs = idx.documentCount != null ? `${idx.documentCount.toLocaleString()} docs` : '';
|
|
103
|
+
console.log(` \x1b[36m${idx.name}\x1b[0m (id: ${idx.id}) ${docs}`);
|
|
104
|
+
if (idx.description) console.log(` ${idx.description}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!indexes.length) console.log(' No indexes found. Create one in your bot settings.');
|
|
108
|
+
}
|
|
109
|
+
console.log('');
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// ── site (Velaro staff only) ──────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
const siteCommand = {
|
|
116
|
+
command: 'site <siteId>',
|
|
117
|
+
describe: 'Show index breakdown for a specific site (Velaro staff only)',
|
|
118
|
+
handler: async (argv) => {
|
|
119
|
+
const creds = await getCredentials();
|
|
120
|
+
if (!isVelaroAdmin(creds)) {
|
|
121
|
+
console.error('This command is only available to Velaro staff.');
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
const data = await get(`/DatabaseTool/SearchIndexSite?siteId=${argv.siteId}`);
|
|
125
|
+
console.log(`\nSite ${argv.siteId} → \x1b[36m${data.physicalIndex}\x1b[0m [${data.assignmentType}]`);
|
|
126
|
+
console.log(`Total docs: ${data.totalDocuments.toLocaleString()}\n`);
|
|
127
|
+
for (const t of data.indexTypes) {
|
|
128
|
+
console.log(` ${t.indexName.padEnd(40)} ${t.count.toLocaleString()} docs`);
|
|
129
|
+
}
|
|
130
|
+
console.log('');
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// ── provision (Velaro staff only) ─────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
const provisionCommand = {
|
|
137
|
+
command: 'provision <siteId>',
|
|
138
|
+
describe: 'Provision a dedicated physical index for a site (Velaro staff only)',
|
|
139
|
+
handler: async (argv) => {
|
|
140
|
+
const creds = await getCredentials();
|
|
141
|
+
if (!isVelaroAdmin(creds)) {
|
|
142
|
+
console.error('This command is only available to Velaro staff.');
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
console.log(`Provisioning dedicated index for site ${argv.siteId}...`);
|
|
146
|
+
const result = await post('/DatabaseTool/ProvisionDedicatedIndex', { SiteId: argv.siteId });
|
|
147
|
+
console.log(`\x1b[32m✓ ${result.message}\x1b[0m`);
|
|
148
|
+
console.log(` Index: \x1b[36m${result.indexName}\x1b[0m`);
|
|
149
|
+
console.log('\nNote: Re-ingest content to populate the new index.');
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// ── reingest (customer-facing, credit-gated) ──────────────────────────────────
|
|
154
|
+
|
|
155
|
+
const reingestCommand = {
|
|
156
|
+
command: 'reingest',
|
|
157
|
+
describe: 'Re-ingest all content sources for your site (requires available index quota)',
|
|
158
|
+
builder: (y) =>
|
|
159
|
+
y
|
|
160
|
+
.option('source', {
|
|
161
|
+
type: 'string',
|
|
162
|
+
describe: 'Specific source to reingest: kb | shopify | bigcommerce | scraper',
|
|
163
|
+
})
|
|
164
|
+
.option('site', {
|
|
165
|
+
type: 'number',
|
|
166
|
+
describe: 'Site ID (Velaro staff only)',
|
|
167
|
+
}),
|
|
168
|
+
handler: async (argv) => {
|
|
169
|
+
const creds = await getCredentials();
|
|
170
|
+
|
|
171
|
+
// Check quota before allowing reingest
|
|
172
|
+
const sub = await get('/Subscription/Get').catch(() => null);
|
|
173
|
+
if (!isVelaroAdmin(creds) && sub?.maxIndexedChunkTotal > 0) {
|
|
174
|
+
const indexes = await get('/AzureIndexes/List').catch(() => []);
|
|
175
|
+
const usedChunks = indexes.reduce((s, i) => s + (i.documentCount ?? 0), 0);
|
|
176
|
+
if (usedChunks >= sub.maxIndexedChunkTotal) {
|
|
177
|
+
console.error(`\x1b[31m✗ Index quota full (${usedChunks.toLocaleString()} / ${sub.maxIndexedChunkTotal.toLocaleString()} chunks).\x1b[0m`);
|
|
178
|
+
console.error(' Upgrade your plan or remove content before reingesting.');
|
|
179
|
+
process.exit(1);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const sources = argv.source ? [argv.source] : ['kb', 'shopify', 'bigcommerce', 'scraper'];
|
|
184
|
+
const siteParam = isVelaroAdmin(creds) && argv.site ? `?siteId=${argv.site}` : '';
|
|
185
|
+
|
|
186
|
+
for (const source of sources) {
|
|
187
|
+
try {
|
|
188
|
+
console.log(` Syncing ${source}...`);
|
|
189
|
+
await post(`/AzureIndexes/SyncIntegration${siteParam}`, { source, forceAll: true });
|
|
190
|
+
console.log(` \x1b[32m✓ ${source} queued\x1b[0m`);
|
|
191
|
+
} catch (e) {
|
|
192
|
+
console.log(` \x1b[33m⚠ ${source} skipped: ${e.message}\x1b[0m`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
console.log('\nReingest queued. Run `velaro index list` to monitor progress.');
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
// ── export ────────────────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
export const indexCommand = {
|
|
202
|
+
command: 'index <subcommand>',
|
|
203
|
+
describe: 'Manage search indexes — view quota, physical index health, and reingest content',
|
|
204
|
+
builder: (yargs) =>
|
|
205
|
+
yargs
|
|
206
|
+
.command(listCommand)
|
|
207
|
+
.command(siteCommand)
|
|
208
|
+
.command(provisionCommand)
|
|
209
|
+
.command(reingestCommand)
|
|
210
|
+
.demandCommand(1, 'Specify a subcommand: list, site, provision, reingest'),
|
|
211
|
+
handler: () => {},
|
|
212
|
+
};
|
package/lib/commands/ingest.js
CHANGED
|
@@ -1,31 +1,31 @@
|
|
|
1
|
-
import { getCredentials, post } from '../api.js';
|
|
2
|
-
import { runCommand } from '../run.js';
|
|
3
|
-
|
|
4
|
-
export const ingestCommand = {
|
|
5
|
-
command: 'ingest',
|
|
6
|
-
describe: 'Trigger knowledge base ingestion for a completed scraper job',
|
|
7
|
-
builder: (y) =>
|
|
8
|
-
y.option('job-id', {
|
|
9
|
-
describe: 'Scraper job ID to ingest',
|
|
10
|
-
type: 'string',
|
|
11
|
-
demandOption: true,
|
|
12
|
-
}),
|
|
13
|
-
|
|
14
|
-
handler: runCommand(async (argv) => {
|
|
15
|
-
// Single credential resolution — avoids double readConfig() from calling readConfig()
|
|
16
|
-
// here and again inside post() → getCredentials().
|
|
17
|
-
const creds = await getCredentials();
|
|
18
|
-
|
|
19
|
-
console.log(`Triggering ingestion for job ${argv['job-id']} on site ${creds.siteId}...`);
|
|
20
|
-
|
|
21
|
-
const result = await post('/AzureIndexes/IngestJobDirect', {
|
|
22
|
-
siteId: creds.siteId,
|
|
23
|
-
jobId: argv['job-id'],
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
console.log('Ingestion started.');
|
|
27
|
-
console.log(` Job ID: ${result.jobId}`);
|
|
28
|
-
console.log(` Index: ${result.physicalIndex}`);
|
|
29
|
-
console.log('\nEmbedding runs in the background. Check logs for completion.');
|
|
30
|
-
}),
|
|
31
|
-
};
|
|
1
|
+
import { getCredentials, post } from '../api.js';
|
|
2
|
+
import { runCommand } from '../run.js';
|
|
3
|
+
|
|
4
|
+
export const ingestCommand = {
|
|
5
|
+
command: 'ingest',
|
|
6
|
+
describe: 'Trigger knowledge base ingestion for a completed scraper job',
|
|
7
|
+
builder: (y) =>
|
|
8
|
+
y.option('job-id', {
|
|
9
|
+
describe: 'Scraper job ID to ingest',
|
|
10
|
+
type: 'string',
|
|
11
|
+
demandOption: true,
|
|
12
|
+
}),
|
|
13
|
+
|
|
14
|
+
handler: runCommand(async (argv) => {
|
|
15
|
+
// Single credential resolution — avoids double readConfig() from calling readConfig()
|
|
16
|
+
// here and again inside post() → getCredentials().
|
|
17
|
+
const creds = await getCredentials();
|
|
18
|
+
|
|
19
|
+
console.log(`Triggering ingestion for job ${argv['job-id']} on site ${creds.siteId}...`);
|
|
20
|
+
|
|
21
|
+
const result = await post('/AzureIndexes/IngestJobDirect', {
|
|
22
|
+
siteId: creds.siteId,
|
|
23
|
+
jobId: argv['job-id'],
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
console.log('Ingestion started.');
|
|
27
|
+
console.log(` Job ID: ${result.jobId}`);
|
|
28
|
+
console.log(` Index: ${result.physicalIndex}`);
|
|
29
|
+
console.log('\nEmbedding runs in the background. Check logs for completion.');
|
|
30
|
+
}),
|
|
31
|
+
};
|
package/lib/commands/kb.js
CHANGED
|
@@ -25,7 +25,7 @@ export const kbCommand = {
|
|
|
25
25
|
|
|
26
26
|
const reindexCommand = {
|
|
27
27
|
command: 'reindex',
|
|
28
|
-
describe: 'Re-embed
|
|
28
|
+
describe: 'Re-embed KB articles or scraper/ingestion content (use after embedding model upgrade)',
|
|
29
29
|
builder: (y) =>
|
|
30
30
|
y
|
|
31
31
|
.option('all', {
|
|
@@ -36,15 +36,38 @@ const reindexCommand = {
|
|
|
36
36
|
.option('site', {
|
|
37
37
|
type: 'number',
|
|
38
38
|
describe: 'Reindex a specific site by ID (Velaro staff only)',
|
|
39
|
+
})
|
|
40
|
+
.option('ingestion', {
|
|
41
|
+
type: 'boolean',
|
|
42
|
+
describe: 'Reindex scraper/ingestion Azure Search indexers instead of KB articles',
|
|
43
|
+
default: false,
|
|
39
44
|
}),
|
|
40
45
|
handler: runCommand(async (argv) => {
|
|
41
|
-
if (argv.
|
|
42
|
-
|
|
46
|
+
if (argv.ingestion) {
|
|
47
|
+
if (argv.all) {
|
|
48
|
+
console.log('Starting bulk ingestion reindex for all sites — runs in background.');
|
|
49
|
+
console.log('Watch server logs ([ReindexAllIngestion]) for progress.\n');
|
|
50
|
+
const result = await post('/DatabaseTool/ReindexAllIngestionIndexes', {});
|
|
51
|
+
console.log(result.message ?? 'Queued.');
|
|
52
|
+
} else if (argv.site) {
|
|
53
|
+
console.log(`Reindexing scraper/ingestion index for site ${argv.site}…`);
|
|
54
|
+
const result = await post(`/DatabaseTool/ReindexIngestionIndex/${argv.site}`, {});
|
|
55
|
+
if (result.success) {
|
|
56
|
+
console.log(`✅ Triggered ${result.indexersRun} indexer(s) — re-processing in background.`);
|
|
57
|
+
} else {
|
|
58
|
+
console.error(`❌ Failed: ${result.message}`);
|
|
59
|
+
}
|
|
60
|
+
} else {
|
|
61
|
+
console.error('--ingestion requires --site <id> or --all (Velaro staff only)');
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
} else if (argv.all) {
|
|
65
|
+
console.log('Starting bulk KB reindex for all sites — this runs in the background.');
|
|
43
66
|
console.log('Watch server logs ([ReindexAllKb]) for progress.\n');
|
|
44
67
|
const result = await post('/DatabaseTool/ReindexAllKb', {});
|
|
45
68
|
console.log(result.message ?? 'Queued.');
|
|
46
69
|
} else if (argv.site) {
|
|
47
|
-
console.log(`Reindexing site ${argv.site}…`);
|
|
70
|
+
console.log(`Reindexing KB articles for site ${argv.site}…`);
|
|
48
71
|
const result = await post(`/DatabaseTool/ReindexKb/${argv.site}`, {});
|
|
49
72
|
if (result.success) {
|
|
50
73
|
console.log(`✅ Done — ${result.articles} articles reindexed.`);
|
package/lib/commands/login.js
CHANGED
|
@@ -1,86 +1,86 @@
|
|
|
1
|
-
import { requestDeviceCode, pollForToken, exchangeForVelaroToken } from '../oauth.js';
|
|
2
|
-
import { readConfig, writeConfig, setEnvCredentials, getActiveEnv, ENVS } from '../config.js';
|
|
3
|
-
import { runCommand } from '../run.js';
|
|
4
|
-
|
|
5
|
-
export const loginCommand = {
|
|
6
|
-
command: 'login',
|
|
7
|
-
describe: 'Authenticate with Velaro (saves credentials for the chosen environment)',
|
|
8
|
-
builder: (y) =>
|
|
9
|
-
y
|
|
10
|
-
.option('staging', {
|
|
11
|
-
describe: 'Log in to the staging environment',
|
|
12
|
-
type: 'boolean',
|
|
13
|
-
default: false,
|
|
14
|
-
})
|
|
15
|
-
.option('api', {
|
|
16
|
-
describe: 'Override the admin API base URL (advanced)',
|
|
17
|
-
type: 'string',
|
|
18
|
-
}),
|
|
19
|
-
|
|
20
|
-
handler: runCommand(async (argv) => {
|
|
21
|
-
const env = argv.staging || argv.api?.includes('staging') ? 'staging' : 'prod';
|
|
22
|
-
const adminApiBase = argv.api || ENVS[env].adminApiBase;
|
|
23
|
-
|
|
24
|
-
console.log(`Starting Velaro login (${env})...\n`);
|
|
25
|
-
|
|
26
|
-
const deviceData = await requestDeviceCode();
|
|
27
|
-
|
|
28
|
-
console.log(` Open: ${deviceData.verification_uri}`);
|
|
29
|
-
console.log(` Enter: ${deviceData.user_code}\n`);
|
|
30
|
-
console.log('Waiting for you to complete login in your browser...');
|
|
31
|
-
|
|
32
|
-
const entraTokens = await pollForToken(deviceData.device_code, deviceData.interval ?? 5);
|
|
33
|
-
const velaroResult = await exchangeForVelaroToken(entraTokens.access_token, adminApiBase);
|
|
34
|
-
|
|
35
|
-
setEnvCredentials(env, {
|
|
36
|
-
adminApiBase,
|
|
37
|
-
velaroToken: velaroResult.token.token,
|
|
38
|
-
velaroExpires: velaroResult.token.expires,
|
|
39
|
-
entraRefreshToken: entraTokens.refresh_token,
|
|
40
|
-
siteId: velaroResult.profile?.SiteId,
|
|
41
|
-
userName: velaroResult.profile?.Name,
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
// Set as active if it's the only env logged in, or if explicitly chosen
|
|
45
|
-
const cfg = readConfig();
|
|
46
|
-
if (!cfg.activeEnv || Object.keys(cfg.envs || {}).length === 1) {
|
|
47
|
-
cfg.activeEnv = env;
|
|
48
|
-
writeConfig(cfg);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
console.log(`\n✅ Logged in to ${env} as ${velaroResult.profile?.Name ?? velaroResult.profile?.UserName}`);
|
|
52
|
-
console.log(` Site ID: ${velaroResult.profile?.SiteId}`);
|
|
53
|
-
console.log(` API: ${adminApiBase}`);
|
|
54
|
-
console.log('\nCredentials saved. Run "velaro env" to see all environments.');
|
|
55
|
-
}),
|
|
56
|
-
};
|
|
57
|
-
|
|
58
|
-
export const logoutCommand = {
|
|
59
|
-
command: 'logout',
|
|
60
|
-
describe: 'Clear stored credentials',
|
|
61
|
-
builder: (y) =>
|
|
62
|
-
y.option('staging', { describe: 'Log out of staging only', type: 'boolean', default: false })
|
|
63
|
-
.option('all', { describe: 'Log out of all environments', type: 'boolean', default: false }),
|
|
64
|
-
|
|
65
|
-
handler: runCommand(async (argv) => {
|
|
66
|
-
const { readConfig, writeConfig, clearConfig } = await import('../config.js');
|
|
67
|
-
if (argv.all) {
|
|
68
|
-
clearConfig();
|
|
69
|
-
console.log('Logged out of all environments.');
|
|
70
|
-
return;
|
|
71
|
-
}
|
|
72
|
-
const env = argv.staging ? 'staging' : getActiveEnv();
|
|
73
|
-
const cfg = readConfig();
|
|
74
|
-
if (cfg.envs?.[env]) {
|
|
75
|
-
delete cfg.envs[env];
|
|
76
|
-
if (cfg.activeEnv === env) {
|
|
77
|
-
const remaining = Object.keys(cfg.envs || {});
|
|
78
|
-
cfg.activeEnv = remaining[0] || 'prod';
|
|
79
|
-
}
|
|
80
|
-
writeConfig(cfg);
|
|
81
|
-
console.log(`Logged out of ${env}.`);
|
|
82
|
-
} else {
|
|
83
|
-
console.log(`Not logged in to ${env}.`);
|
|
84
|
-
}
|
|
85
|
-
}),
|
|
86
|
-
};
|
|
1
|
+
import { requestDeviceCode, pollForToken, exchangeForVelaroToken } from '../oauth.js';
|
|
2
|
+
import { readConfig, writeConfig, setEnvCredentials, getActiveEnv, ENVS } from '../config.js';
|
|
3
|
+
import { runCommand } from '../run.js';
|
|
4
|
+
|
|
5
|
+
export const loginCommand = {
|
|
6
|
+
command: 'login',
|
|
7
|
+
describe: 'Authenticate with Velaro (saves credentials for the chosen environment)',
|
|
8
|
+
builder: (y) =>
|
|
9
|
+
y
|
|
10
|
+
.option('staging', {
|
|
11
|
+
describe: 'Log in to the staging environment',
|
|
12
|
+
type: 'boolean',
|
|
13
|
+
default: false,
|
|
14
|
+
})
|
|
15
|
+
.option('api', {
|
|
16
|
+
describe: 'Override the admin API base URL (advanced)',
|
|
17
|
+
type: 'string',
|
|
18
|
+
}),
|
|
19
|
+
|
|
20
|
+
handler: runCommand(async (argv) => {
|
|
21
|
+
const env = argv.staging || argv.api?.includes('staging') ? 'staging' : 'prod';
|
|
22
|
+
const adminApiBase = argv.api || ENVS[env].adminApiBase;
|
|
23
|
+
|
|
24
|
+
console.log(`Starting Velaro login (${env})...\n`);
|
|
25
|
+
|
|
26
|
+
const deviceData = await requestDeviceCode();
|
|
27
|
+
|
|
28
|
+
console.log(` Open: ${deviceData.verification_uri}`);
|
|
29
|
+
console.log(` Enter: ${deviceData.user_code}\n`);
|
|
30
|
+
console.log('Waiting for you to complete login in your browser...');
|
|
31
|
+
|
|
32
|
+
const entraTokens = await pollForToken(deviceData.device_code, deviceData.interval ?? 5);
|
|
33
|
+
const velaroResult = await exchangeForVelaroToken(entraTokens.access_token, adminApiBase);
|
|
34
|
+
|
|
35
|
+
setEnvCredentials(env, {
|
|
36
|
+
adminApiBase,
|
|
37
|
+
velaroToken: velaroResult.token.token,
|
|
38
|
+
velaroExpires: velaroResult.token.expires,
|
|
39
|
+
entraRefreshToken: entraTokens.refresh_token,
|
|
40
|
+
siteId: velaroResult.profile?.SiteId,
|
|
41
|
+
userName: velaroResult.profile?.Name,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Set as active if it's the only env logged in, or if explicitly chosen
|
|
45
|
+
const cfg = readConfig();
|
|
46
|
+
if (!cfg.activeEnv || Object.keys(cfg.envs || {}).length === 1) {
|
|
47
|
+
cfg.activeEnv = env;
|
|
48
|
+
writeConfig(cfg);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
console.log(`\n✅ Logged in to ${env} as ${velaroResult.profile?.Name ?? velaroResult.profile?.UserName}`);
|
|
52
|
+
console.log(` Site ID: ${velaroResult.profile?.SiteId}`);
|
|
53
|
+
console.log(` API: ${adminApiBase}`);
|
|
54
|
+
console.log('\nCredentials saved. Run "velaro env" to see all environments.');
|
|
55
|
+
}),
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const logoutCommand = {
|
|
59
|
+
command: 'logout',
|
|
60
|
+
describe: 'Clear stored credentials',
|
|
61
|
+
builder: (y) =>
|
|
62
|
+
y.option('staging', { describe: 'Log out of staging only', type: 'boolean', default: false })
|
|
63
|
+
.option('all', { describe: 'Log out of all environments', type: 'boolean', default: false }),
|
|
64
|
+
|
|
65
|
+
handler: runCommand(async (argv) => {
|
|
66
|
+
const { readConfig, writeConfig, clearConfig } = await import('../config.js');
|
|
67
|
+
if (argv.all) {
|
|
68
|
+
clearConfig();
|
|
69
|
+
console.log('Logged out of all environments.');
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const env = argv.staging ? 'staging' : getActiveEnv();
|
|
73
|
+
const cfg = readConfig();
|
|
74
|
+
if (cfg.envs?.[env]) {
|
|
75
|
+
delete cfg.envs[env];
|
|
76
|
+
if (cfg.activeEnv === env) {
|
|
77
|
+
const remaining = Object.keys(cfg.envs || {});
|
|
78
|
+
cfg.activeEnv = remaining[0] || 'prod';
|
|
79
|
+
}
|
|
80
|
+
writeConfig(cfg);
|
|
81
|
+
console.log(`Logged out of ${env}.`);
|
|
82
|
+
} else {
|
|
83
|
+
console.log(`Not logged in to ${env}.`);
|
|
84
|
+
}
|
|
85
|
+
}),
|
|
86
|
+
};
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { get, getCredentials } from '../api.js';
|
|
2
|
+
import { readConfig, ENVS } from '../config.js';
|
|
3
|
+
|
|
4
|
+
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
function ago(dateStr) {
|
|
7
|
+
if (!dateStr) return 'unknown';
|
|
8
|
+
const diffMs = Date.now() - new Date(dateStr).getTime();
|
|
9
|
+
const mins = Math.round(diffMs / 60000);
|
|
10
|
+
if (mins < 2) return 'just now';
|
|
11
|
+
if (mins < 60) return `${mins}m ago`;
|
|
12
|
+
const hrs = Math.round(mins / 60);
|
|
13
|
+
if (hrs < 24) return `${hrs}h ago`;
|
|
14
|
+
return `${Math.round(hrs / 24)}d ago`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function utcLabel(dateStr) {
|
|
18
|
+
if (!dateStr) return '';
|
|
19
|
+
const d = new Date(dateStr);
|
|
20
|
+
return d.toISOString().replace('T', ' ').slice(0, 16) + ' UTC';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const OK = '\x1b[32m✓\x1b[0m';
|
|
24
|
+
const WARN = '\x1b[33m⚠\x1b[0m';
|
|
25
|
+
const ERR = '\x1b[31m✗\x1b[0m';
|
|
26
|
+
const HIGH = '\x1b[31m[high]\x1b[0m ';
|
|
27
|
+
const MED = '\x1b[33m[med]\x1b[0m ';
|
|
28
|
+
const LOW = '\x1b[36m[low]\x1b[0m ';
|
|
29
|
+
|
|
30
|
+
function severityPrefix(s) {
|
|
31
|
+
if (s === 'high') return HIGH;
|
|
32
|
+
if (s === 'medium') return MED;
|
|
33
|
+
return LOW;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function pingVersion(label, base) {
|
|
37
|
+
try {
|
|
38
|
+
const res = await fetch(`${base}/Version`, { signal: AbortSignal.timeout(6000) });
|
|
39
|
+
if (!res.ok) return { label, ok: false, status: res.status };
|
|
40
|
+
const data = await res.json();
|
|
41
|
+
return { label, ok: true, version: data.version, deployedAt: data.deployedAt };
|
|
42
|
+
} catch (e) {
|
|
43
|
+
return { label, ok: false, err: e.message };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function pingHealth(label, url) {
|
|
48
|
+
try {
|
|
49
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(6000) });
|
|
50
|
+
return { label, ok: res.ok, status: res.status };
|
|
51
|
+
} catch (e) {
|
|
52
|
+
return { label, ok: false, err: e.message };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── ops command ───────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
export const opsCommand = {
|
|
59
|
+
command: 'ops',
|
|
60
|
+
describe: 'System health: deploy times, Hangfire, API status, and active alerts (Velaro staff only)',
|
|
61
|
+
handler: async () => {
|
|
62
|
+
const creds = await getCredentials();
|
|
63
|
+
const isAdmin = creds.siteId === 1032;
|
|
64
|
+
|
|
65
|
+
if (!isAdmin) {
|
|
66
|
+
console.error('velaro ops is only available to Velaro staff.');
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const cfg = readConfig();
|
|
71
|
+
|
|
72
|
+
// ── 1. Deploy versions (anonymous — both envs in parallel) ───────────────
|
|
73
|
+
const prodBase = cfg.envs?.prod?.adminApiBase ?? ENVS.prod.adminApiBase;
|
|
74
|
+
const stagingBase = cfg.envs?.staging?.adminApiBase ?? ENVS.staging.adminApiBase;
|
|
75
|
+
|
|
76
|
+
const [prodVer, stagingVer] = await Promise.all([
|
|
77
|
+
pingVersion('prod', prodBase),
|
|
78
|
+
pingVersion('staging', stagingBase),
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
console.log('\n\x1b[1m── Deploy ──────────────────────────────────────────────────\x1b[0m');
|
|
82
|
+
for (const v of [prodVer, stagingVer]) {
|
|
83
|
+
if (v.ok) {
|
|
84
|
+
const sym = OK;
|
|
85
|
+
const ver = `\x1b[36m${v.version}\x1b[0m`;
|
|
86
|
+
const time = `${ago(v.deployedAt).padEnd(12)} (${utcLabel(v.deployedAt)})`;
|
|
87
|
+
console.log(` ${sym} ${v.label.padEnd(8)} ${ver.padEnd(30)} deployed ${time}`);
|
|
88
|
+
} else {
|
|
89
|
+
const detail = v.err ?? `HTTP ${v.status}`;
|
|
90
|
+
console.log(` ${ERR} ${v.label.padEnd(8)} UNREACHABLE — ${detail}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── 2. API health (admin + messaging, both envs) ──────────────────────────
|
|
95
|
+
const prodMsgBase = cfg.envs?.prod?.messagingApiBase ?? ENVS.prod.messagingApiBase;
|
|
96
|
+
const stagingMsgBase = cfg.envs?.staging?.messagingApiBase ?? ENVS.staging.messagingApiBase;
|
|
97
|
+
|
|
98
|
+
const [adminProdHealth, adminStagingHealth, msgProdHealth, msgStagingHealth] = await Promise.all([
|
|
99
|
+
pingHealth('admin/prod', `${prodBase}/Status`),
|
|
100
|
+
pingHealth('admin/staging', `${stagingBase}/Status`),
|
|
101
|
+
pingHealth('msg/prod', `${prodMsgBase}/api/health`),
|
|
102
|
+
pingHealth('msg/staging', `${stagingMsgBase}/api/health`),
|
|
103
|
+
]);
|
|
104
|
+
|
|
105
|
+
console.log('\n\x1b[1m── API Health ──────────────────────────────────────────────\x1b[0m');
|
|
106
|
+
for (const h of [adminProdHealth, adminStagingHealth, msgProdHealth, msgStagingHealth]) {
|
|
107
|
+
if (h.ok) {
|
|
108
|
+
console.log(` ${OK} ${h.label}`);
|
|
109
|
+
} else {
|
|
110
|
+
const detail = h.err ?? `HTTP ${h.status}`;
|
|
111
|
+
console.log(` ${ERR} ${h.label.padEnd(20)} ${detail}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── 3. Hangfire failed jobs ───────────────────────────────────────────────
|
|
116
|
+
console.log('\n\x1b[1m── Hangfire ────────────────────────────────────────────────\x1b[0m');
|
|
117
|
+
try {
|
|
118
|
+
const jobs = await get('/DatabaseTool/FailedJobs');
|
|
119
|
+
if (!jobs?.length) {
|
|
120
|
+
console.log(` ${OK} 0 failed jobs`);
|
|
121
|
+
} else {
|
|
122
|
+
const sym = jobs.length >= 20 ? ERR : jobs.length >= 5 ? WARN : WARN;
|
|
123
|
+
console.log(` ${sym} ${jobs.length} failed job(s)`);
|
|
124
|
+
for (const j of jobs.slice(0, 5)) {
|
|
125
|
+
const when = j.failedAt ? ago(j.failedAt) : '?';
|
|
126
|
+
const name = [j.typeName, j.methodName].filter(Boolean).join('.');
|
|
127
|
+
const msg = j.exceptionMessage?.split('\n')[0]?.slice(0, 80) ?? j.reason?.slice(0, 80) ?? '';
|
|
128
|
+
console.log(` ${WARN} ${name.padEnd(35)} ${when.padEnd(10)} ${msg}`);
|
|
129
|
+
}
|
|
130
|
+
if (jobs.length > 5) console.log(` ... and ${jobs.length - 5} more`);
|
|
131
|
+
console.log(' Open /hangfire to inspect and retry.');
|
|
132
|
+
}
|
|
133
|
+
} catch (e) {
|
|
134
|
+
console.log(` ${WARN} Could not load Hangfire jobs: ${e.message}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── 4. Active alerts (needs-attention via messaging API) ──────────────────
|
|
138
|
+
console.log('\n\x1b[1m── Alerts ──────────────────────────────────────────────────\x1b[0m');
|
|
139
|
+
try {
|
|
140
|
+
const res = await fetch(`${prodMsgBase}/SuperAdmin/needs-attention`, {
|
|
141
|
+
headers: {
|
|
142
|
+
Authorization: `Bearer ${creds.velaroToken}`,
|
|
143
|
+
'X-Internal-SiteId': String(creds.siteId),
|
|
144
|
+
'Content-Type': 'application/json',
|
|
145
|
+
},
|
|
146
|
+
signal: AbortSignal.timeout(15000),
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
if (!res.ok) {
|
|
150
|
+
console.log(` ${WARN} Could not reach alerts endpoint (HTTP ${res.status})`);
|
|
151
|
+
} else {
|
|
152
|
+
const items = await res.json();
|
|
153
|
+
const alertItems = Array.isArray(items)
|
|
154
|
+
? items.filter(i => i.type !== 'hangfire_failed') // shown above
|
|
155
|
+
: [];
|
|
156
|
+
if (!alertItems.length) {
|
|
157
|
+
console.log(` ${OK} Nothing needs attention`);
|
|
158
|
+
} else {
|
|
159
|
+
for (const item of alertItems) {
|
|
160
|
+
console.log(` ${severityPrefix(item.severity)}${item.title}`);
|
|
161
|
+
if (item.recommendedFix) {
|
|
162
|
+
console.log(` \x1b[2m${item.recommendedFix.slice(0, 100)}\x1b[0m`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
} catch (e) {
|
|
168
|
+
console.log(` ${WARN} Alerts unavailable: ${e.message}`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
console.log('\n──────────────────────────────────────────────────────────────\n');
|
|
172
|
+
},
|
|
173
|
+
};
|