@velaro/cli 0.1.2 → 0.3.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.
@@ -0,0 +1,163 @@
1
+ /**
2
+ * velaro check — full site health and configuration summary.
3
+ *
4
+ * Shows every feature area with:
5
+ * ✓ enabled and configured
6
+ * ⚠ enabled but nothing configured yet
7
+ * ✗ not on your plan
8
+ */
9
+
10
+ import { get } from '../api.js';
11
+ import { getSubscription } from '../subscription.js';
12
+ import { runCommand } from '../run.js';
13
+
14
+ const TICK = '✓';
15
+ const WARN = '⚠';
16
+ const CROSS = '✗';
17
+
18
+ export const checkCommand = {
19
+ command: 'check',
20
+ describe: 'Show full site configuration and health summary',
21
+ handler: runCommand(async () => {
22
+ const [sub, apiStatus] = await Promise.all([
23
+ getSubscription().catch(() => null),
24
+ get('/Status').catch(() => null),
25
+ ]);
26
+
27
+ if (!sub) {
28
+ console.error('Could not retrieve subscription. Check your login: velaro whoami');
29
+ process.exit(1);
30
+ }
31
+
32
+ const ok = (msg) => console.log(` ${TICK} ${msg}`);
33
+ const warn = (msg) => console.log(` ${WARN} ${msg}`);
34
+ const off = (msg) => console.log(` ${CROSS} ${msg}`);
35
+
36
+ console.log('\n── API Health ────────────────────────────────────────────────');
37
+ if (apiStatus) {
38
+ ok(`API reachable`);
39
+ } else {
40
+ warn('API did not respond to /Status');
41
+ }
42
+
43
+ // ── Deployments ─────────────────────────────────────────────────────────
44
+ console.log('\n── Deployments ───────────────────────────────────────────────');
45
+ try {
46
+ const [deps, teams] = await Promise.all([get('/Deployment'), get('/Teams/List')]);
47
+ const teamMap = Object.fromEntries((teams ?? []).map(t => [t.id, t.name]));
48
+ if (deps?.length) {
49
+ for (const d of deps) {
50
+ ok(`[${d.id}] "${d.displayName}" → team: ${teamMap[d.teamId] ?? `#${d.teamId}`} (embed key: ${d.deploymentId})`);
51
+ }
52
+ } else {
53
+ warn('No deployments — create one in the admin UI to get your embed snippet.');
54
+ }
55
+ } catch { warn('Could not load deployments'); }
56
+
57
+ // ── AI Bots ──────────────────────────────────────────────────────────────
58
+ console.log('\n── AI Bots ───────────────────────────────────────────────────');
59
+ if (sub.enableAI) {
60
+ try {
61
+ const bots = await get('/AIConfiguration/list');
62
+ if (bots?.length) {
63
+ for (const b of bots) ok(`[${b.id}] "${b.name}" model=${b.aiModel ?? 'default'}`);
64
+ } else {
65
+ warn('AI enabled but no bots configured. Run: velaro bot setup --name "My Bot"');
66
+ }
67
+ } catch { warn('Could not load bot list'); }
68
+ } else {
69
+ off('AI Bots — not on your plan (upgrade at velaro.com/pricing)');
70
+ }
71
+
72
+ // ── Knowledge Base ───────────────────────────────────────────────────────
73
+ console.log('\n── Knowledge Base ────────────────────────────────────────────');
74
+ if (sub.enableKnowledgeBase) {
75
+ try {
76
+ const [qna, overrides] = await Promise.all([
77
+ get('/api/BotQnA').catch(() => []),
78
+ get('/Ticket/knowledge-overrides').catch(() => []),
79
+ ]);
80
+ ok(`${qna?.length ?? 0} Q&A pair(s) · ${overrides?.length ?? 0} override(s)`);
81
+ } catch { warn('Could not load knowledge base'); }
82
+ } else {
83
+ off('Knowledge Base — not on your plan');
84
+ }
85
+
86
+ // ── Workflows ────────────────────────────────────────────────────────────
87
+ console.log('\n── Workflows ─────────────────────────────────────────────────');
88
+ if (sub.enableAutomation) {
89
+ try {
90
+ const workflows = (await get('/Workflows/List') ?? []).filter(w => !w.isTemplate);
91
+ const enabled = workflows.filter(w => w.enabled).length;
92
+ const disabled = workflows.length - enabled;
93
+ if (workflows.length) {
94
+ ok(`${workflows.length} workflow(s) · ${enabled} enabled · ${disabled} disabled`);
95
+ for (const w of workflows) {
96
+ const sym = w.enabled ? TICK : WARN;
97
+ console.log(` ${sym} [${w.id}] "${w.name}" (${w.enabled ? 'on' : 'off'})`);
98
+ }
99
+ } else {
100
+ warn('Automation enabled but no workflows configured.');
101
+ }
102
+ } catch { warn('Could not load workflows'); }
103
+ } else {
104
+ off('Workflows — not on your plan');
105
+ }
106
+
107
+ // ── Routing Rules ────────────────────────────────────────────────────────
108
+ console.log('\n── Routing Rules ─────────────────────────────────────────────');
109
+ if (sub.enableWorkflowRules) {
110
+ try {
111
+ const rules = await get('/Rules/List');
112
+ if (rules?.length) {
113
+ ok(`${rules.length} routing rule(s) configured`);
114
+ } else {
115
+ warn('Routing rules enabled but none configured.');
116
+ }
117
+ } catch { warn('Could not load rules'); }
118
+ } else {
119
+ off('Routing Rules — not on your plan');
120
+ }
121
+
122
+ // ── Channels ─────────────────────────────────────────────────────────────
123
+ console.log('\n── Channels ──────────────────────────────────────────────────');
124
+ const channels = [
125
+ ['enableWeb', 'Web Chat'],
126
+ ['enableSms', 'SMS'],
127
+ ['enableEmail', 'Email'],
128
+ ['enableWhatsapp', 'WhatsApp'],
129
+ ['enableIvr', 'IVR / Voice'],
130
+ ['enableFacebook', 'Facebook Messenger'],
131
+ ['enableInstagram', 'Instagram'],
132
+ ['enableRcs', 'RCS'],
133
+ ];
134
+ for (const [flag, label] of channels) {
135
+ if (sub[flag]) ok(label); else off(`${label} — not on your plan`);
136
+ }
137
+
138
+ // ── IVR detail ───────────────────────────────────────────────────────────
139
+ if (sub.enableIvr) {
140
+ try {
141
+ const ivr = await get('/api/ivr/config');
142
+ if (ivr?.phoneNumber) {
143
+ console.log(`\n── IVR Config ────────────────────────────────────────────────`);
144
+ ok(`Phone: ${ivr.phoneNumber}`);
145
+ if (ivr.bridgeNumber) ok(`Bridge: ${ivr.bridgeNumber}`);
146
+ else warn('No bridge (transfer) number set — agents cannot receive transferred calls.');
147
+ }
148
+ } catch { /* IVR config is optional */ }
149
+ }
150
+
151
+ // ── Agents ───────────────────────────────────────────────────────────────
152
+ console.log('\n── Agents ────────────────────────────────────────────────────');
153
+ try {
154
+ const agents = await get('/Users/List');
155
+ const online = (agents ?? []).filter(a => a.status === 'Available').length;
156
+ const away = (agents ?? []).filter(a => a.status === 'Away').length;
157
+ const offline = (agents ?? []).length - online - away;
158
+ ok(`${agents?.length ?? 0} agent(s) · ${online} online · ${away} away · ${offline} offline`);
159
+ } catch { warn('Could not load agents'); }
160
+
161
+ console.log('\n──────────────────────────────────────────────────────────────\n');
162
+ }),
163
+ };
@@ -0,0 +1,107 @@
1
+ import { get, post } from '../api.js';
2
+ import { runCommand } from '../run.js';
3
+
4
+ export const deploymentCommand = {
5
+ command: 'deployment <subcommand>',
6
+ describe: 'Manage chat widget deployments',
7
+ builder: (yargs) =>
8
+ yargs
9
+ .command(deploymentListCommand)
10
+ .command(deploymentRenameCommand)
11
+ .command(deploymentAssignCommand)
12
+ .demandCommand(1, 'Specify a subcommand: list, rename, assign-team'),
13
+ handler: () => {},
14
+ };
15
+
16
+ // ── list ───────────────────────────────────────────────────────────────────────
17
+
18
+ const deploymentListCommand = {
19
+ command: 'list',
20
+ describe: 'List chat widget deployments',
21
+ handler: runCommand(async () => {
22
+ const [deployments, teams] = await Promise.all([
23
+ get('/Deployment'),
24
+ get('/Teams/List'),
25
+ ]);
26
+
27
+ if (!deployments?.length) {
28
+ console.log('No deployments found.');
29
+ console.log('\nA deployment is a chat widget embed — one per page or team you want to serve.');
30
+ return;
31
+ }
32
+
33
+ const teamMap = Object.fromEntries((teams ?? []).map(t => [t.id, t.name]));
34
+
35
+ const rows = deployments.map(d => ({
36
+ id: `[${d.id}]`,
37
+ key: d.deploymentId ?? '—',
38
+ team: teamMap[d.teamId] ?? `team ${d.teamId}`,
39
+ name: d.displayName ?? '(unnamed)',
40
+ }));
41
+
42
+ const idW = Math.max(...rows.map(r => r.id.length));
43
+ const keyW = Math.max('embed-key'.length, ...rows.map(r => r.key.length));
44
+ const teamW = Math.max('team'.length, ...rows.map(r => r.team.length));
45
+
46
+ console.log(`\n${'id'.padStart(idW)} ${'embed-key'.padEnd(keyW)} ${'team'.padEnd(teamW)} name`);
47
+ console.log(`${'-'.repeat(idW)} ${'-'.repeat(keyW)} ${'-'.repeat(teamW)} ----`);
48
+ for (const r of rows) {
49
+ console.log(`${r.id.padStart(idW)} ${r.key.padEnd(keyW)} ${r.team.padEnd(teamW)} ${r.name}`);
50
+ }
51
+ console.log(`\n${rows.length} deployment(s)`);
52
+ console.log('\nThe embed-key goes in your widget JavaScript snippet.');
53
+ }),
54
+ };
55
+
56
+ // ── rename ─────────────────────────────────────────────────────────────────────
57
+
58
+ const deploymentRenameCommand = {
59
+ command: 'rename <id> <name>',
60
+ describe: 'Rename a deployment',
61
+ builder: (y) =>
62
+ y
63
+ .positional('id', { type: 'number', describe: 'Deployment numeric ID' })
64
+ .positional('name', { type: 'string', describe: 'New display name' }),
65
+ handler: runCommand(async (argv) => {
66
+ const dep = await get(`/Deployment/${argv.id}`);
67
+ if (!dep) {
68
+ console.error(`Deployment ${argv.id} not found.`);
69
+ process.exit(1);
70
+ }
71
+
72
+ await post('/Deployment', { ...dep, id: argv.id, displayName: argv.name });
73
+ console.log(`Deployment ${argv.id} renamed to "${argv.name}".`);
74
+ }),
75
+ };
76
+
77
+ // ── assign-team ────────────────────────────────────────────────────────────────
78
+
79
+ const deploymentAssignCommand = {
80
+ command: 'assign-team <id> <team-id>',
81
+ describe: 'Assign a team to a deployment',
82
+ builder: (y) =>
83
+ y
84
+ .positional('id', { type: 'number', describe: 'Deployment numeric ID' })
85
+ .positional('team-id', { type: 'number', describe: 'Team ID to assign' }),
86
+ handler: runCommand(async (argv) => {
87
+ const [dep, teams] = await Promise.all([
88
+ get(`/Deployment/${argv.id}`),
89
+ get('/Teams/List'),
90
+ ]);
91
+
92
+ if (!dep) {
93
+ console.error(`Deployment ${argv.id} not found.`);
94
+ process.exit(1);
95
+ }
96
+
97
+ const team = (teams ?? []).find(t => t.id === argv['team-id']);
98
+ if (!team) {
99
+ console.error(`Team ${argv['team-id']} not found.`);
100
+ console.error('Run: velaro team list');
101
+ process.exit(1);
102
+ }
103
+
104
+ await post('/Deployment', { ...dep, id: argv.id, teamId: argv['team-id'] });
105
+ console.log(`Deployment ${argv.id} ("${dep.displayName}") assigned to team "${team.name}".`);
106
+ }),
107
+ };
@@ -0,0 +1,241 @@
1
+ import { readFileSync } from 'fs';
2
+ import { basename } from 'path';
3
+ import { get, post, del } from '../api.js';
4
+ import { requireFeature } from '../subscription.js';
5
+ import { runCommand } from '../run.js';
6
+ import { articleCommand } from './article.js';
7
+
8
+ export const kbCommand = {
9
+ command: 'kb <subcommand>',
10
+ describe: 'Manage knowledge base — articles, Q&A pairs, bot overrides, and custom content',
11
+ builder: (yargs) =>
12
+ yargs
13
+ .command(articleCommand)
14
+ .command(qnaCommand)
15
+ .command(overrideCommand)
16
+ .command(contentCommand)
17
+ .demandCommand(1, 'Specify a subcommand: article, qna, override, content'),
18
+ handler: () => {},
19
+ };
20
+
21
+ // ────────────────────────────────────────────────────────────────────────────
22
+ // Q&A
23
+ // ────────────────────────────────────────────────────────────────────────────
24
+
25
+ const qnaCommand = {
26
+ command: 'qna <subcommand>',
27
+ describe: 'Manage Q&A knowledge pairs',
28
+ builder: (yargs) =>
29
+ yargs
30
+ .command({
31
+ command: 'list',
32
+ describe: 'List Q&A pairs',
33
+ builder: (y) => y.option('bot-id', { type: 'number', describe: 'Filter by bot ID' }),
34
+ handler: runCommand(async (argv) => {
35
+ await requireFeature('enableKnowledgeBase', 'Knowledge Base');
36
+ const params = argv['bot-id'] ? `?aiConfigurationId=${argv['bot-id']}` : '';
37
+ const items = await get(`/api/BotQnA${params}`);
38
+ if (!items?.length) { console.log('No Q&A pairs found.'); return; }
39
+
40
+ console.log(`\nFound ${items.length} Q&A pair(s):\n`);
41
+ for (const item of items) {
42
+ console.log(` [${item.id}] Q: ${truncate(item.question, 70)}`);
43
+ console.log(` A: ${truncate(item.answer, 70)}\n`);
44
+ }
45
+ }),
46
+ })
47
+ .command({
48
+ command: 'add',
49
+ describe: 'Add a Q&A pair',
50
+ builder: (y) =>
51
+ y
52
+ .option('bot-id', { type: 'number', demandOption: true, describe: 'Bot ID' })
53
+ .option('question', { type: 'string', demandOption: true, describe: 'Question text' })
54
+ .option('answer', { type: 'string', demandOption: true, describe: 'Answer text' }),
55
+ handler: runCommand(async (argv) => {
56
+ await requireFeature('enableKnowledgeBase', 'Knowledge Base');
57
+ const result = await post('/api/BotQnA', {
58
+ aiConfigurationId: argv['bot-id'],
59
+ question: argv.question,
60
+ answer: argv.answer,
61
+ });
62
+ console.log(`Q&A pair added: [${result.id}]`);
63
+ }),
64
+ })
65
+ .command({
66
+ command: 'delete <id>',
67
+ describe: 'Delete a Q&A pair',
68
+ builder: (y) => y.positional('id', { type: 'number', describe: 'Q&A pair ID' }),
69
+ handler: runCommand(async (argv) => {
70
+ await requireFeature('enableKnowledgeBase', 'Knowledge Base');
71
+ await del(`/api/BotQnA/${argv.id}`);
72
+ console.log(`Q&A pair ${argv.id} deleted.`);
73
+ }),
74
+ })
75
+ .demandCommand(1, 'Specify a subcommand: list, add, delete'),
76
+ handler: () => {},
77
+ };
78
+
79
+ // ────────────────────────────────────────────────────────────────────────────
80
+ // Overrides — authoritative facts that take priority over KB search
81
+ // ────────────────────────────────────────────────────────────────────────────
82
+
83
+ const overrideCommand = {
84
+ command: 'override <subcommand>',
85
+ describe: 'Manage bot knowledge overrides (authoritative facts)',
86
+ builder: (yargs) =>
87
+ yargs
88
+ .command({
89
+ command: 'list',
90
+ describe: 'List knowledge overrides',
91
+ handler: runCommand(async () => {
92
+ await requireFeature('enableKnowledgeBase', 'Knowledge Base');
93
+ const items = await get('/Ticket/knowledge-overrides');
94
+ if (!items?.length) { console.log('No overrides found.'); return; }
95
+
96
+ console.log(`\nFound ${items.length} override(s):\n`);
97
+ for (const item of items) {
98
+ const status = item.isActive ? 'active ' : 'inactive';
99
+ const expiry = item.expiresAt ? ` expires ${new Date(item.expiresAt).toLocaleDateString()}` : '';
100
+ console.log(` [${item.id}] ${status} ${truncate(item.title ?? item.content, 60)}${expiry}`);
101
+ }
102
+ }),
103
+ })
104
+ .command({
105
+ command: 'add',
106
+ describe: 'Add a knowledge override',
107
+ builder: (y) =>
108
+ y
109
+ .option('title', { type: 'string', demandOption: true, describe: 'Override title' })
110
+ .option('content', { type: 'string', demandOption: true, describe: 'Authoritative content' })
111
+ .option('expires', { type: 'string', describe: 'Expiry date (ISO 8601, e.g. 2026-12-31)' }),
112
+ handler: runCommand(async (argv) => {
113
+ await requireFeature('enableKnowledgeBase', 'Knowledge Base');
114
+ const payload = { title: argv.title, content: argv.content, isActive: true };
115
+ if (argv.expires) payload.expiresAt = new Date(argv.expires).toISOString();
116
+ const result = await post('/Ticket/knowledge-overrides', payload);
117
+ console.log(`Override added: [${result.id}] "${argv.title}"`);
118
+ }),
119
+ })
120
+ .command({
121
+ command: 'delete <id>',
122
+ describe: 'Delete a knowledge override',
123
+ builder: (y) => y.positional('id', { type: 'number', describe: 'Override ID' }),
124
+ handler: runCommand(async (argv) => {
125
+ await requireFeature('enableKnowledgeBase', 'Knowledge Base');
126
+ await del(`/Ticket/knowledge-overrides/${argv.id}`);
127
+ console.log(`Override ${argv.id} deleted.`);
128
+ }),
129
+ })
130
+ .demandCommand(1, 'Specify a subcommand: list, add, delete'),
131
+ handler: () => {},
132
+ };
133
+
134
+ // ────────────────────────────────────────────────────────────────────────────
135
+ // Custom content — push arbitrary text/files into the site's KB index
136
+ // ────────────────────────────────────────────────────────────────────────────
137
+ // Note: content goes into the site's default index (determined server-side by
138
+ // SiteId). Index selection is not yet supported by this API endpoint.
139
+
140
+ const contentCommand = {
141
+ command: 'content <subcommand>',
142
+ describe: 'Push custom text or files into the knowledge base index',
143
+ builder: (yargs) =>
144
+ yargs
145
+ .command({
146
+ command: 'ingest',
147
+ describe: 'Push a file or text snippet into the KB index',
148
+ builder: (y) =>
149
+ y
150
+ .option('file', {
151
+ describe: 'Path to a .txt or .md file to ingest',
152
+ type: 'string',
153
+ })
154
+ .option('text', {
155
+ describe: 'Inline text content to ingest (alternative to --file)',
156
+ type: 'string',
157
+ })
158
+ .option('content-id', {
159
+ describe: 'Stable ID for this document (used for dedup/updates). Defaults to filename.',
160
+ type: 'string',
161
+ })
162
+ .option('title', {
163
+ describe: 'Document title shown when bot attributes this content. Defaults to filename.',
164
+ type: 'string',
165
+ })
166
+ .option('source-url', {
167
+ describe: 'Canonical URL to show when bot cites this content',
168
+ type: 'string',
169
+ })
170
+ .check((argv) => {
171
+ if (!argv.file && !argv.text) throw new Error('Provide either --file or --text');
172
+ if (argv.file && argv.text) throw new Error('Use --file or --text, not both');
173
+ return true;
174
+ }),
175
+ handler: runCommand(async (argv) => {
176
+ await requireFeature('enableKnowledgeBase', 'Knowledge Base');
177
+
178
+ let text, defaultId, defaultTitle;
179
+ if (argv.file) {
180
+ text = readFileSync(argv.file, 'utf8');
181
+ defaultId = basename(argv.file);
182
+ defaultTitle = basename(argv.file);
183
+ } else {
184
+ text = argv.text;
185
+ defaultId = `inline-${Date.now()}`;
186
+ defaultTitle = defaultId;
187
+ }
188
+
189
+ const contentId = argv['content-id'] || defaultId;
190
+ const title = argv.title || defaultTitle;
191
+
192
+ const result = await post('/AzureIndexes/IngestContent', {
193
+ contentId,
194
+ title,
195
+ text,
196
+ sourceUrl: argv['source-url'] || undefined,
197
+ });
198
+
199
+ if (result.unchanged) {
200
+ console.log(`Unchanged: "${title}" — content hash matches, no re-embedding needed.`);
201
+ } else {
202
+ console.log(`Indexed: "${title}" [${contentId}]`);
203
+ console.log(` ${result.chunks} chunk(s) embedded and searchable immediately.`);
204
+ }
205
+ }),
206
+ })
207
+ .command({
208
+ command: 'list',
209
+ describe: 'List all custom content documents in the KB index',
210
+ handler: runCommand(async () => {
211
+ await requireFeature('enableKnowledgeBase', 'Knowledge Base');
212
+ const items = await get('/AzureIndexes/IngestContent');
213
+ if (!items?.length) { console.log('No custom content found.'); return; }
214
+
215
+ console.log(`\nFound ${items.length} document(s):\n`);
216
+ for (const item of items) {
217
+ const indexed = item.lastIndexedAt ? new Date(item.lastIndexedAt).toLocaleDateString() : 'never';
218
+ const status = item.lastError ? ` error: ${item.lastError}` : '';
219
+ console.log(` [${item.contentId}] chunks=${item.chunkCount ?? '?'} indexed=${indexed}${status}`);
220
+ if (item.title && item.title !== item.contentId) console.log(` "${item.title}"`);
221
+ }
222
+ }),
223
+ })
224
+ .command({
225
+ command: 'remove <content-id>',
226
+ describe: 'Remove a custom content document from the KB index',
227
+ builder: (y) => y.positional('content-id', { type: 'string', describe: 'Content ID to remove' }),
228
+ handler: runCommand(async (argv) => {
229
+ await requireFeature('enableKnowledgeBase', 'Knowledge Base');
230
+ await del(`/AzureIndexes/IngestContent/${encodeURIComponent(argv['content-id'])}`);
231
+ console.log(`Removed "${argv['content-id']}" from the index.`);
232
+ }),
233
+ })
234
+ .demandCommand(1, 'Specify a subcommand: ingest, list, remove'),
235
+ handler: () => {},
236
+ };
237
+
238
+ function truncate(str, max) {
239
+ if (!str) return '—';
240
+ return str.length <= max ? str : str.slice(0, max - 1) + '…';
241
+ }
@@ -1,53 +1,60 @@
1
- import { requestDeviceCode, pollForToken, exchangeForVelaroToken } from '../oauth.js';
2
- import { readConfig, writeConfig, DEFAULT_API_BASE } from '../config.js';
3
- import { runCommand } from '../run.js';
4
-
5
- export const loginCommand = {
6
- command: 'login',
7
- describe: 'Authenticate with Velaro using your browser',
8
- builder: (y) =>
9
- y.option('api', {
10
- describe: 'Velaro Messaging API base URL',
11
- default: DEFAULT_API_BASE,
12
- type: 'string',
13
- }),
14
-
15
- handler: runCommand(async (argv) => {
16
- const apiBase = argv.api;
17
-
18
- console.log('Starting Velaro login...\n');
19
-
20
- const deviceData = await requestDeviceCode();
21
-
22
- console.log(` Open: ${deviceData.verification_uri}`);
23
- console.log(` Enter: ${deviceData.user_code}\n`);
24
- console.log('Waiting for you to complete login in your browser...');
25
-
26
- const entraTokens = await pollForToken(deviceData.device_code, deviceData.interval ?? 5);
27
- const velaroResult = await exchangeForVelaroToken(entraTokens.access_token, apiBase);
28
-
29
- writeConfig({
30
- ...readConfig(),
31
- apiBase,
32
- velaroToken: velaroResult.token.token,
33
- velaroExpires: velaroResult.token.expires,
34
- entraRefreshToken: entraTokens.refresh_token,
35
- siteId: velaroResult.profile?.SiteId,
36
- userName: velaroResult.profile?.Name,
37
- });
38
-
39
- console.log(`\nLogged in as ${velaroResult.profile?.Name ?? velaroResult.profile?.UserName}`);
40
- console.log(`Site ID: ${velaroResult.profile?.SiteId}`);
41
- console.log('Credentials saved to ~/.velaro/config.json');
42
- }),
43
- };
44
-
45
- export const logoutCommand = {
46
- command: 'logout',
47
- describe: 'Clear stored credentials',
48
- handler: runCommand(async () => {
49
- const { clearConfig } = await import('../config.js');
50
- clearConfig();
51
- console.log('Logged out. Credentials removed from ~/.velaro/config.json');
52
- }),
53
- };
1
+ import { requestDeviceCode, pollForToken, exchangeForVelaroToken } from '../oauth.js';
2
+ import { readConfig, writeConfig, DEFAULT_API_BASE, STAGING_API_BASE } from '../config.js';
3
+ import { runCommand } from '../run.js';
4
+
5
+ export const loginCommand = {
6
+ command: 'login',
7
+ describe: 'Authenticate with Velaro using your browser',
8
+ builder: (y) =>
9
+ y
10
+ .option('staging', {
11
+ describe: 'Connect to the Velaro staging environment',
12
+ type: 'boolean',
13
+ default: false,
14
+ })
15
+ .option('api', {
16
+ describe: 'Override the Velaro API base URL. Set VELARO_API_BASE env var to make it permanent. ' +
17
+ 'Production cutover: set this to your production API URL before going live.',
18
+ type: 'string',
19
+ }),
20
+
21
+ handler: runCommand(async (argv) => {
22
+ const apiBase = argv.api ?? (argv.staging ? STAGING_API_BASE : DEFAULT_API_BASE);
23
+
24
+ console.log('Starting Velaro login...\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, apiBase);
34
+
35
+ writeConfig({
36
+ ...readConfig(),
37
+ apiBase,
38
+ velaroToken: velaroResult.token.token,
39
+ velaroExpires: velaroResult.token.expires,
40
+ entraRefreshToken: entraTokens.refresh_token,
41
+ siteId: velaroResult.profile?.SiteId,
42
+ userName: velaroResult.profile?.Name,
43
+ });
44
+
45
+ const envLabel = argv.staging || argv.api ? ` (${apiBase})` : '';
46
+ console.log(`\nLogged in as ${velaroResult.profile?.Name ?? velaroResult.profile?.UserName}${envLabel}`);
47
+ console.log(`Site ID: ${velaroResult.profile?.SiteId}`);
48
+ console.log('Credentials saved to ~/.velaro/config.json');
49
+ }),
50
+ };
51
+
52
+ export const logoutCommand = {
53
+ command: 'logout',
54
+ describe: 'Clear stored credentials',
55
+ handler: runCommand(async () => {
56
+ const { clearConfig } = await import('../config.js');
57
+ clearConfig();
58
+ console.log('Logged out. Credentials removed from ~/.velaro/config.json');
59
+ }),
60
+ };