@girardmedia/bootspring 2.1.2 → 2.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/cli/auth.js DELETED
@@ -1,896 +0,0 @@
1
- /**
2
- * Bootspring Auth CLI Command
3
- *
4
- * Handles login, logout, and authentication status.
5
- */
6
-
7
- const readline = require('readline');
8
- const { exec } = require('child_process');
9
- const os = require('os');
10
- const https = require('https');
11
- const http = require('http');
12
- const auth = require('../core/auth');
13
- const api = require('../core/api-client');
14
- const session = require('../core/session');
15
- const { redactErrorMessage, redactSensitiveString } = require('../core/redaction');
16
-
17
- const API_BASE = process.env.BOOTSPRING_API_URL || api.API_BASE || 'https://api.bootspring.com';
18
-
19
- /**
20
- * Make a direct API request (without v1 prefix)
21
- */
22
- async function directRequest(method, path, options = {}) {
23
- const apiKey = options.apiKey || auth.getApiKey();
24
- const token = apiKey ? null : (options.token || auth.getToken());
25
- const deviceId = auth.getDeviceId();
26
- const url = new URL(`/api${path}`, API_BASE);
27
- const isHttps = url.protocol === 'https:';
28
- const httpModule = isHttps ? https : http;
29
-
30
- const headers = {
31
- 'Content-Type': 'application/json',
32
- 'User-Agent': `bootspring-cli/${require('../package.json').version}`,
33
- 'X-Device-Id': deviceId
34
- };
35
-
36
- if (apiKey) {
37
- headers['X-API-Key'] = apiKey;
38
- } else if (token) {
39
- headers['Authorization'] = `Bearer ${token}`;
40
- }
41
-
42
- return new Promise((resolve, reject) => {
43
- const req = httpModule.request(url, {
44
- method,
45
- headers,
46
- timeout: 30000
47
- }, (res) => {
48
- let body = '';
49
- res.on('data', chunk => body += chunk);
50
- res.on('end', () => {
51
- try {
52
- const json = JSON.parse(body);
53
- if (res.statusCode >= 400) {
54
- const error = new Error(
55
- redactSensitiveString(String(json.error || json.message || 'API Error'))
56
- );
57
- error.status = res.statusCode;
58
- reject(error);
59
- } else {
60
- resolve(json);
61
- }
62
- } catch {
63
- if (res.statusCode >= 400) {
64
- reject(new Error(`API Error: ${res.statusCode}`));
65
- } else {
66
- resolve(body);
67
- }
68
- }
69
- });
70
- });
71
-
72
- req.on('error', (error) => reject(new Error(redactSensitiveString(error.message || String(error)))));
73
- req.on('timeout', () => {
74
- req.destroy();
75
- reject(new Error('Request timeout'));
76
- });
77
- req.end();
78
- });
79
- }
80
-
81
- // ANSI colors
82
- const colors = {
83
- reset: '\x1b[0m',
84
- bold: '\x1b[1m',
85
- dim: '\x1b[2m',
86
- green: '\x1b[32m',
87
- yellow: '\x1b[33m',
88
- red: '\x1b[31m',
89
- cyan: '\x1b[36m',
90
- magenta: '\x1b[35m'
91
- };
92
-
93
- function getAuthIdentity() {
94
- const user = auth.getUser();
95
- const fallbackTier = typeof auth.getTier === 'function' ? String(auth.getTier() || 'free') : 'free';
96
-
97
- return {
98
- email: user?.email || (auth.isApiKeyAuth() ? '(api-key auth)' : '(unknown user)'),
99
- name: user?.name || '(not set)',
100
- tier: String(user?.tier || fallbackTier || 'free')
101
- };
102
- }
103
-
104
- /**
105
- * Prompt for input
106
- */
107
- function prompt(question, hidden = false) {
108
- return new Promise((resolve) => {
109
- const rl = readline.createInterface({
110
- input: process.stdin,
111
- output: process.stdout
112
- });
113
-
114
- if (hidden) {
115
- // Hide password input
116
- process.stdout.write(question);
117
- const stdin = process.stdin;
118
- stdin.setRawMode(true);
119
- stdin.resume();
120
- stdin.setEncoding('utf8');
121
-
122
- let password = '';
123
- const onData = (char) => {
124
- if (char === '\n' || char === '\r' || char === '\u0004') {
125
- stdin.setRawMode(false);
126
- stdin.removeListener('data', onData);
127
- console.log();
128
- rl.close();
129
- resolve(password);
130
- } else if (char === '\u0003') {
131
- process.exit();
132
- } else if (char === '\u007F' || char === '\b') {
133
- password = password.slice(0, -1);
134
- process.stdout.clearLine(0);
135
- process.stdout.cursorTo(0);
136
- process.stdout.write(question + '*'.repeat(password.length));
137
- } else {
138
- password += char;
139
- process.stdout.write('*');
140
- }
141
- };
142
-
143
- stdin.on('data', onData);
144
- } else {
145
- rl.question(question, (answer) => {
146
- rl.close();
147
- resolve(answer.trim());
148
- });
149
- }
150
- });
151
- }
152
-
153
- /**
154
- * Open URL in default browser
155
- */
156
- function openBrowser(url) {
157
- const platform = os.platform();
158
- let cmd;
159
-
160
- switch (platform) {
161
- case 'darwin':
162
- cmd = `open "${url}"`;
163
- break;
164
- case 'win32':
165
- cmd = `start "" "${url}"`;
166
- break;
167
- default:
168
- cmd = `xdg-open "${url}"`;
169
- }
170
-
171
- return new Promise((resolve) => {
172
- exec(cmd, (error) => {
173
- resolve(!error);
174
- });
175
- });
176
- }
177
-
178
- async function activateLinkedProjectSession(apiKey, projectId) {
179
- if (!apiKey) {
180
- return null;
181
- }
182
-
183
- return api.loginWithApiKey(apiKey, projectId ? { projectId } : {});
184
- }
185
-
186
- function getRetryAfterMs(error, fallbackMs) {
187
- const retryAfterSeconds = Number.parseInt(String(error?.details?.retryAfter || ''), 10);
188
- if (Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0) {
189
- return retryAfterSeconds * 1000;
190
- }
191
-
192
- return fallbackMs;
193
- }
194
-
195
- async function fetchProjects(authApiKey = null) {
196
- if (authApiKey) {
197
- const response = await directRequest('GET', '/projects', { apiKey: authApiKey });
198
- return response.projects || [];
199
- }
200
-
201
- const response = await api.listProjects();
202
- return response.projects || [];
203
- }
204
-
205
- /**
206
- * Device authorization flow - browser-based login
207
- */
208
- async function loginWithBrowser(noBrowser = false) {
209
- console.log(`${colors.dim}Requesting device code...${colors.reset}`);
210
-
211
- let deviceResponse;
212
- try {
213
- deviceResponse = await api.requestDeviceCode();
214
- } catch (error) {
215
- console.log(`\n${colors.red}✗ Failed to start device flow: ${redactErrorMessage(error)}${colors.reset}`);
216
- console.log(`\n${colors.dim}Try: bootspring auth login --password${colors.reset}`);
217
- return;
218
- }
219
-
220
- const { device_code, user_code, verification_uri_complete, expires_in, interval } = deviceResponse;
221
-
222
- // Show the URL
223
- console.log(`\n${colors.bold}Opening browser to complete authentication...${colors.reset}`);
224
- console.log(`${colors.cyan}${verification_uri_complete}${colors.reset}\n`);
225
-
226
- // Open browser (unless --no-browser)
227
- if (!noBrowser) {
228
- const opened = await openBrowser(verification_uri_complete);
229
- if (!opened) {
230
- console.log(`${colors.yellow}Could not open browser. Please visit the URL above manually.${colors.reset}\n`);
231
- }
232
- } else {
233
- console.log(`${colors.dim}Open the URL above in your browser to continue.${colors.reset}\n`);
234
- }
235
-
236
- console.log(`${colors.dim}Your code: ${colors.reset}${colors.bold}${user_code}${colors.reset}`);
237
- console.log(`\n${colors.dim}Waiting for authorization... (press Ctrl+C to cancel)${colors.reset}`);
238
-
239
- // Start polling
240
- const pollInterval = (interval || 5) * 1000; // Convert to ms
241
- const timeout = (expires_in || 900) * 1000; // Convert to ms
242
- const startTime = Date.now();
243
-
244
- // Spinner animation
245
- const spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
246
- let spinnerIndex = 0;
247
- let pollBackoffNotified = false;
248
-
249
- const pollForToken = () => {
250
- return new Promise((resolve, reject) => {
251
- const poll = async () => {
252
- // Check timeout
253
- if (Date.now() - startTime > timeout) {
254
- reject(new Error('Authorization timed out. Please try again.'));
255
- return;
256
- }
257
-
258
- // Update spinner
259
- process.stdout.write(`\r${colors.cyan}${spinnerFrames[spinnerIndex]}${colors.reset} Waiting for browser authorization...`);
260
- spinnerIndex = (spinnerIndex + 1) % spinnerFrames.length;
261
-
262
- try {
263
- const response = await api.pollDeviceToken(device_code);
264
-
265
- // Success!
266
- process.stdout.write('\r' + ' '.repeat(50) + '\r'); // Clear line
267
- if (response.api_key) {
268
- resolve(response);
269
- } else {
270
- auth.login(response);
271
- resolve(response);
272
- }
273
- } catch (error) {
274
- if (error.message?.includes('authorization_pending') ||
275
- error.code === 'authorization_pending' ||
276
- error.status === 400 && error.details?.error === 'authorization_pending') {
277
- // Still waiting, continue polling
278
- setTimeout(poll, pollInterval);
279
- } else if (error.status === 429 || error.code === 'too_many_attempts') {
280
- const backoffMs = getRetryAfterMs(error, pollInterval * 4);
281
- if (!pollBackoffNotified) {
282
- process.stdout.write('\r' + ' '.repeat(80) + '\r');
283
- console.log(`${colors.yellow}Rate limited while waiting for browser authorization. Keeping this session alive and retrying automatically...${colors.reset}`);
284
- pollBackoffNotified = true;
285
- }
286
- setTimeout(poll, backoffMs);
287
- } else if (error.message?.includes('access_denied') || error.code === 'access_denied') {
288
- process.stdout.write('\r' + ' '.repeat(50) + '\r');
289
- reject(new Error('Authorization was denied'));
290
- } else if (error.message?.includes('expired') || error.code === 'expired_token') {
291
- process.stdout.write('\r' + ' '.repeat(50) + '\r');
292
- reject(new Error('Device code expired. Please try again.'));
293
- } else {
294
- // Continue polling for other errors (network issues, etc)
295
- setTimeout(poll, pollInterval);
296
- }
297
- }
298
- };
299
-
300
- // Handle Ctrl+C
301
- const cleanup = () => {
302
- process.stdout.write('\r' + ' '.repeat(50) + '\r');
303
- reject(new Error('Cancelled'));
304
- };
305
- process.once('SIGINT', cleanup);
306
-
307
- // Start polling after initial delay
308
- setTimeout(poll, pollInterval);
309
- });
310
- };
311
-
312
- try {
313
- const response = await pollForToken();
314
-
315
- console.log(`${colors.green}✓ Login successful!${colors.reset}`);
316
- console.log(`\n ${colors.bold}Welcome, ${response.user.name || response.user.email}!${colors.reset}`);
317
- console.log(` ${colors.dim}Tier: ${colors.reset}${colors.cyan}${response.user.tier}${colors.reset}`);
318
-
319
- console.log(`\n ${colors.dim}Credentials stored in: ${auth.getCredentialsPath()}${colors.reset}`);
320
-
321
- const existingConfig = session.findLocalConfig();
322
- const hasLocalProjectLink = existingConfig && existingConfig._dir === process.cwd();
323
-
324
- if (response.api_key && hasLocalProjectLink) {
325
- await activateLinkedProjectSession(response.api_key, existingConfig.projectId || response.project?.id);
326
- }
327
-
328
- // Check if this directory already has a local config (already locked to a project)
329
- if (hasLocalProjectLink) {
330
- console.log(`\n ${colors.dim}Project: ${colors.reset}${colors.green}${existingConfig.projectName}${colors.reset} ${colors.dim}(from local config)${colors.reset}`);
331
- } else if (response.project) {
332
- // Project was selected on web during device flow - use it automatically
333
- console.log(`\n ${colors.dim}Project: ${colors.reset}${colors.green}${response.project.name}${colors.reset} ${colors.dim}(selected on web)${colors.reset}`);
334
-
335
- // Save to session
336
- session.setCurrentProject(response.project);
337
- session.addRecentProject(response.project);
338
-
339
- // Create local config to lock directory
340
- try {
341
- const configPath = session.createLocalConfig(process.cwd(), response.project);
342
- if (response.api_key) {
343
- await activateLinkedProjectSession(response.api_key, response.project.id);
344
- }
345
- console.log(` ${colors.dim}Locked directory to "${response.project.name}"${colors.reset}`);
346
- console.log(` ${colors.dim}Config: ${configPath}${colors.reset}`);
347
- } catch (error) {
348
- console.log(` ${colors.yellow}Warning: Could not create local config: ${redactErrorMessage(error)}${colors.reset}`);
349
- }
350
- } else {
351
- // No project selected on web - prompt for selection
352
- await requireProjectSelection({ force: true, lockToDirectory: true, authApiKey: response.api_key });
353
- }
354
- } catch (error) {
355
- console.log(`\n${colors.red}✗ ${redactErrorMessage(error)}${colors.reset}`);
356
- }
357
- }
358
-
359
- /**
360
- * Email/password login (legacy)
361
- */
362
- async function loginWithPassword() {
363
- const email = await prompt(`${colors.cyan}Email:${colors.reset} `);
364
- if (!email) {
365
- console.log(`${colors.red}Email is required${colors.reset}`);
366
- return;
367
- }
368
-
369
- const password = await prompt(`${colors.cyan}Password:${colors.reset} `, true);
370
- if (!password) {
371
- console.log(`${colors.red}Password is required${colors.reset}`);
372
- return;
373
- }
374
-
375
- console.log(`\n${colors.dim}Logging in...${colors.reset}`);
376
-
377
- try {
378
- const response = await api.login(email, password);
379
-
380
- console.log(`\n${colors.green}✓ Login successful!${colors.reset}`);
381
- console.log(`\n ${colors.bold}Welcome, ${response.user.name || response.user.email}!${colors.reset}`);
382
- console.log(` ${colors.dim}Tier: ${colors.reset}${colors.cyan}${response.user.tier}${colors.reset}`);
383
- console.log(`\n ${colors.dim}Credentials stored in: ${auth.getCredentialsPath()}${colors.reset}`);
384
-
385
- // Check if this directory already has a local config
386
- const existingConfig = session.findLocalConfig();
387
- if (existingConfig && existingConfig._dir === process.cwd()) {
388
- console.log(`\n ${colors.dim}Project: ${colors.reset}${colors.green}${existingConfig.projectName}${colors.reset} ${colors.dim}(from local config)${colors.reset}`);
389
- } else {
390
- // Force project selection for new directories
391
- await requireProjectSelection({ force: true, lockToDirectory: true });
392
- }
393
- } catch (error) {
394
- console.log(`\n${colors.red}✗ Login failed: ${redactErrorMessage(error)}${colors.reset}`);
395
-
396
- if (error.code === 'unauthorized') {
397
- console.log(`\n${colors.dim}Forgot your password? Visit https://bootspring.com/forgot-password${colors.reset}`);
398
- }
399
- }
400
- }
401
-
402
- /**
403
- * Login with API key
404
- */
405
- async function loginWithApiKey(apiKey) {
406
- // Validate API key format
407
- if (!apiKey.match(/^(bs|sk)_(live|test)_[A-Za-z0-9_-]{16,}$/)) {
408
- console.log(`${colors.red}Invalid API key format${colors.reset}`);
409
- console.log(`${colors.dim}API keys should start with bs_live_ or bs_test_${colors.reset}`);
410
- return;
411
- }
412
-
413
- console.log(`${colors.dim}Validating API key...${colors.reset}`);
414
-
415
- try {
416
- const response = await api.validateApiKey(apiKey);
417
-
418
- console.log(`\n${colors.green}✓ API key validated!${colors.reset}`);
419
- console.log(`\n ${colors.dim}Tier: ${colors.reset}${colors.cyan}${response.tier}${colors.reset}`);
420
- console.log(` ${colors.dim}Auth: ${colors.reset}${colors.magenta}API Key${colors.reset}`);
421
-
422
- // Show project info if key is project-scoped
423
- if (response.project) {
424
- console.log(` ${colors.dim}Project: ${colors.reset}${colors.green}${response.project.name}${colors.reset} ${colors.dim}(auto-set from key)${colors.reset}`);
425
- }
426
-
427
- console.log(`\n ${colors.dim}Credentials stored in: ${auth.getCredentialsPath()}${colors.reset}`);
428
-
429
- // Always prompt for project selection in new directories
430
- // API key scope determines permissions, but user chooses which project to link
431
- const existingConfig = session.findLocalConfig();
432
- if (existingConfig && existingConfig._dir === process.cwd()) {
433
- await activateLinkedProjectSession(apiKey, existingConfig.projectId || response.project?.id);
434
- console.log(`\n ${colors.dim}Project: ${colors.reset}${colors.green}${existingConfig.projectName}${colors.reset} ${colors.dim}(from local config)${colors.reset}`);
435
- } else {
436
- await requireProjectSelection({ force: true, lockToDirectory: true, authApiKey: apiKey });
437
- }
438
- } catch (error) {
439
- console.log(`\n${colors.red}✗ API key validation failed: ${redactErrorMessage(error)}${colors.reset}`);
440
- console.log(`\n${colors.dim}Generate API keys at: https://bootspring.com/dashboard/keys${colors.reset}`);
441
- }
442
- }
443
-
444
- /**
445
- * Login command
446
- */
447
- async function login(args = []) {
448
- console.log(`\n${colors.bold}Bootspring Login${colors.reset}\n`);
449
-
450
- // Parse flags
451
- const hasApiKey = args.includes('--api-key') || args.includes('-k');
452
- const hasPassword = args.includes('--password') || args.includes('-p');
453
- const noBrowser = args.includes('--no-browser');
454
-
455
- // Get API key value if provided
456
- const apiKeyIndex = args.findIndex(a => a === '--api-key' || a === '-k');
457
- const apiKey = apiKeyIndex !== -1 ? args[apiKeyIndex + 1] : null;
458
-
459
- // Check if already logged in
460
- if (auth.isAuthenticated()) {
461
- const identity = getAuthIdentity();
462
-
463
- // Check if this directory already has a local config
464
- const existingConfig = session.findLocalConfig();
465
- if (existingConfig && existingConfig._dir === process.cwd()) {
466
- // Directory already linked to a project
467
- console.log(`${colors.green}Already authenticated as ${identity.email}${colors.reset}`);
468
- console.log(`${colors.dim}Project: ${colors.reset}${colors.green}${existingConfig.projectName}${colors.reset} ${colors.dim}(from .bootspring.json)${colors.reset}`);
469
- console.log(`\n${colors.dim}To switch projects, run: bootspring switch <project>${colors.reset}`);
470
- console.log(`${colors.dim}To use a different account, run: bootspring auth logout${colors.reset}`);
471
- return;
472
- }
473
-
474
- // Directory not linked - link locally first to avoid unnecessary reauthentication.
475
- console.log(`${colors.green}Authenticated as ${identity.email}${colors.reset}`);
476
- console.log(`${colors.dim}This directory is not linked to a project yet.${colors.reset}\n`);
477
- console.log(`${colors.dim}Fetching your available projects so this folder can be linked without a new browser login...${colors.reset}\n`);
478
-
479
- const linked = await requireProjectSelection({
480
- force: true,
481
- lockToDirectory: true,
482
- authApiKey: auth.isApiKeyAuth() ? auth.getApiKey() : null
483
- });
484
-
485
- if (!linked) {
486
- console.log(`\n${colors.dim}Falling back to browser project selection...${colors.reset}\n`);
487
- await loginWithBrowser(noBrowser);
488
- }
489
- return;
490
- }
491
-
492
- // Route to appropriate login method
493
- if (hasApiKey && apiKey) {
494
- await loginWithApiKey(apiKey);
495
- } else if (hasPassword) {
496
- await loginWithPassword();
497
- } else {
498
- // Default: browser-based device flow
499
- await loginWithBrowser(noBrowser);
500
- }
501
- }
502
-
503
- /**
504
- * Register command
505
- */
506
- async function register() {
507
- console.log(`\n${colors.bold}Create Bootspring Account${colors.reset}\n`);
508
-
509
- // Check if already logged in
510
- if (auth.isAuthenticated()) {
511
- const identity = getAuthIdentity();
512
- console.log(`${colors.yellow}You are already logged in as ${identity.email}${colors.reset}`);
513
- console.log('Run \'bootspring auth logout\' first to create a new account.');
514
- return;
515
- }
516
-
517
- // Get registration info
518
- const name = await prompt(`${colors.cyan}Name (optional):${colors.reset} `);
519
-
520
- const email = await prompt(`${colors.cyan}Email:${colors.reset} `);
521
- if (!email) {
522
- console.log(`${colors.red}Email is required${colors.reset}`);
523
- return;
524
- }
525
-
526
- const password = await prompt(`${colors.cyan}Password:${colors.reset} `, true);
527
- if (!password) {
528
- console.log(`${colors.red}Password is required${colors.reset}`);
529
- return;
530
- }
531
-
532
- if (password.length < 8) {
533
- console.log(`${colors.red}Password must be at least 8 characters${colors.reset}`);
534
- return;
535
- }
536
-
537
- const confirmPassword = await prompt(`${colors.cyan}Confirm Password:${colors.reset} `, true);
538
- if (password !== confirmPassword) {
539
- console.log(`${colors.red}Passwords do not match${colors.reset}`);
540
- return;
541
- }
542
-
543
- console.log(`\n${colors.dim}Creating account...${colors.reset}`);
544
-
545
- try {
546
- const response = await api.register(email, password, name || null);
547
-
548
- console.log(`\n${colors.green}✓ Account created successfully!${colors.reset}`);
549
- console.log(`\n ${colors.bold}Welcome to Bootspring, ${response.user.name || response.user.email}!${colors.reset}`);
550
- console.log(` ${colors.dim}Your tier: ${colors.reset}${colors.cyan}free${colors.reset}`);
551
- console.log(`\n ${colors.dim}Upgrade to Pro for unlimited access: https://bootspring.com/pricing${colors.reset}`);
552
- } catch (error) {
553
- console.log(`\n${colors.red}✗ Registration failed: ${redactErrorMessage(error)}${colors.reset}`);
554
- }
555
- }
556
-
557
- /**
558
- * Logout command
559
- */
560
- async function logoutCmd() {
561
- if (!auth.isAuthenticated()) {
562
- console.log(`${colors.yellow}You are not logged in${colors.reset}`);
563
- return;
564
- }
565
-
566
- const user = auth.getUser() || {};
567
- console.log(`${colors.dim}Logging out ${user.email || '(unknown user)'}...${colors.reset}`);
568
-
569
- let remoteLogoutSucceeded = false;
570
- try {
571
- await api.logout();
572
- remoteLogoutSucceeded = true;
573
- } catch {
574
- // Best-effort remote logout
575
- }
576
-
577
- auth.logout();
578
- if (session.clearSession) {
579
- session.clearSession();
580
- }
581
-
582
- console.log(
583
- remoteLogoutSucceeded
584
- ? `${colors.green}✓ Logged out successfully${colors.reset}`
585
- : `${colors.green}✓ Logged out locally${colors.reset}`
586
- );
587
- }
588
-
589
- /**
590
- * Whoami command - show current user
591
- */
592
- async function whoami() {
593
- if (!auth.isAuthenticated()) {
594
- console.log(`${colors.yellow}Not logged in${colors.reset}`);
595
- console.log(`${colors.dim}Run 'bootspring auth login' to authenticate${colors.reset}`);
596
- return;
597
- }
598
-
599
- const identity = getAuthIdentity();
600
- const isApiKey = auth.isApiKeyAuth();
601
- console.log(`\n${colors.bold}Current User${colors.reset}\n`);
602
- console.log(` ${colors.cyan}Email:${colors.reset} ${identity.email}`);
603
- console.log(` ${colors.cyan}Name:${colors.reset} ${identity.name}`);
604
- console.log(` ${colors.cyan}Tier:${colors.reset} ${identity.tier}`);
605
- console.log(` ${colors.cyan}Auth:${colors.reset} ${isApiKey ? colors.magenta + 'API Key' : colors.green + 'Session'}${colors.reset}`);
606
-
607
- // Try to get fresh data from API
608
- try {
609
- const response = await api.me();
610
- console.log(`\n${colors.bold}Usage This Month${colors.reset}\n`);
611
- console.log(` ${colors.cyan}API Calls:${colors.reset} ${response.usage?.apiCalls || 0}`);
612
- console.log(` ${colors.cyan}Agents Used:${colors.reset} ${response.usage?.agentsInvoked || 0}`);
613
- console.log(` ${colors.cyan}Skills Accessed:${colors.reset} ${response.usage?.skillsAccessed || 0}`);
614
-
615
- if (response.subscription) {
616
- console.log(`\n${colors.bold}Subscription${colors.reset}\n`);
617
- console.log(` ${colors.cyan}Status:${colors.reset} ${response.subscription.status}`);
618
- console.log(` ${colors.cyan}Renews:${colors.reset} ${new Date(response.subscription.currentPeriodEnd).toLocaleDateString()}`);
619
- }
620
- } catch (error) {
621
- if (error.code === 'token_expired') {
622
- console.log(`\n${colors.yellow}Session expired. Please login again.${colors.reset}`);
623
- }
624
- }
625
- }
626
-
627
- /**
628
- * Status command - check API connectivity
629
- */
630
- async function status() {
631
- console.log(`\n${colors.bold}Bootspring Status${colors.reset}\n`);
632
-
633
- // Check authentication
634
- const isAuth = auth.isAuthenticated();
635
- const isApiKey = auth.isApiKeyAuth();
636
- console.log(` ${colors.cyan}Authenticated:${colors.reset} ${isAuth ? colors.green + 'Yes' : colors.yellow + 'No'}${colors.reset}`);
637
-
638
- let keyProject = null;
639
-
640
- if (isAuth) {
641
- const identity = getAuthIdentity();
642
- console.log(` ${colors.cyan}User:${colors.reset} ${identity.email || identity.tier}`);
643
- console.log(` ${colors.cyan}Tier:${colors.reset} ${identity.tier}`);
644
- console.log(` ${colors.cyan}Auth Method:${colors.reset} ${isApiKey ? colors.magenta + 'API Key' : colors.green + 'Session'}${colors.reset}`);
645
-
646
- // If using API key, validate it and get the associated project
647
- if (isApiKey) {
648
- try {
649
- const apiKey = auth.getApiKey();
650
- const validation = await api.validateApiKey(apiKey);
651
- if (validation && validation.project) {
652
- keyProject = validation.project;
653
- }
654
- } catch {
655
- // Ignore validation errors in status
656
- }
657
- }
658
- }
659
-
660
- // Check project context
661
- const sessionState = session.getSessionState();
662
- const currentProject = sessionState.project;
663
-
664
- // Sync project from API key if there's a mismatch or no local project
665
- if (keyProject && (!currentProject || currentProject.id !== keyProject.id)) {
666
- session.setCurrentProject(keyProject);
667
- session.addRecentProject(keyProject);
668
- console.log(`\n${colors.bold}Project Context${colors.reset} ${colors.dim}(synced from API key)${colors.reset}`);
669
- console.log(` ${colors.cyan}Project:${colors.reset} ${keyProject.name}`);
670
- if (keyProject.slug) {
671
- console.log(` ${colors.cyan}Slug:${colors.reset} ${keyProject.slug}`);
672
- }
673
- console.log(` ${colors.cyan}Source:${colors.reset} ${colors.dim}api-key${colors.reset}`);
674
- } else if (currentProject) {
675
- console.log(`\n${colors.bold}Project Context${colors.reset}`);
676
- const isSessionOnlyContext = sessionState.source === 'session' && !sessionState.hasLocalConfig;
677
- const projectLabel = isSessionOnlyContext ? 'Last Session Project:' : 'Project:';
678
- console.log(` ${colors.cyan}${projectLabel}${colors.reset} ${currentProject.name}`);
679
- if (currentProject.slug) {
680
- console.log(` ${colors.cyan}Slug:${colors.reset} ${currentProject.slug}`);
681
- }
682
- const sourceLabel = isSessionOnlyContext
683
- ? 'last session (directory not linked)'
684
- : sessionState.source === 'local'
685
- ? 'local config'
686
- : 'session';
687
- console.log(` ${colors.cyan}Source:${colors.reset} ${colors.dim}${sourceLabel}${colors.reset}`);
688
- if (sessionState.hasLocalConfig) {
689
- console.log(` ${colors.cyan}Config:${colors.reset} ${colors.dim}${sessionState.localConfigPath}${colors.reset}`);
690
- } else if (isSessionOnlyContext) {
691
- console.log(` ${colors.dim} Run 'bootspring auth login' or 'bootspring switch --init' to link this directory.${colors.reset}`);
692
- }
693
- } else {
694
- console.log(`\n ${colors.yellow}No project context${colors.reset}`);
695
- console.log(` ${colors.dim}Run 'bootspring switch <project>' to set a project${colors.reset}`);
696
- }
697
-
698
- // Check API connectivity
699
- console.log(`\n ${colors.dim}Checking API connection...${colors.reset}`);
700
-
701
- try {
702
- const health = await api.healthCheck();
703
- if (health.connected) {
704
- console.log(` ${colors.cyan}API Status:${colors.reset} ${colors.green}Connected${colors.reset}`);
705
- } else {
706
- console.log(` ${colors.cyan}API Status:${colors.reset} ${colors.red}Disconnected${colors.reset}`);
707
- console.log(` ${colors.dim}Error: ${redactSensitiveString(String(health.error || 'Unknown error'))}${colors.reset}`);
708
- }
709
- } catch (error) {
710
- console.log(` ${colors.cyan}API Status:${colors.reset} ${colors.red}Error${colors.reset}`);
711
- console.log(` ${colors.dim}${redactErrorMessage(error)}${colors.reset}`);
712
- }
713
-
714
- console.log(`\n ${colors.cyan}API URL:${colors.reset} ${api.API_BASE}`);
715
- }
716
-
717
- /**
718
- * Switch project command - delegates to switch module
719
- */
720
- async function switchProject(args) {
721
- const switchModule = require('./switch');
722
- await switchModule.run(args);
723
- }
724
-
725
- /**
726
- * Require user to select a project interactively
727
- * Project context is required for all Bootspring commands
728
- * @param {object} options - Options
729
- * @param {boolean} options.force - Force project selection even if session has a project
730
- * @param {boolean} options.lockToDirectory - Create .bootspring.json after selection
731
- */
732
- async function requireProjectSelection(options = {}) {
733
- const { force = false, lockToDirectory = false, authApiKey = null } = options;
734
-
735
- // If not forcing, check if project context already exists
736
- if (!force) {
737
- // Check if project context already exists
738
- if (session.getEffectiveProject && session.getEffectiveProject()) {
739
- return true;
740
- }
741
-
742
- // Check legacy hasProjectContext
743
- if (session.hasProjectContext && session.hasProjectContext()) {
744
- return true;
745
- }
746
-
747
- // Check local .bootspring.json
748
- const localConfig = session.findLocalConfig && session.findLocalConfig();
749
- if (localConfig?.projectId) {
750
- return true;
751
- }
752
- }
753
-
754
- console.log(`\n${colors.yellow}Select a project for this directory${colors.reset}`);
755
- console.log(`${colors.dim}This directory will be locked to the selected project.${colors.reset}\n`);
756
-
757
- // Fetch projects
758
- console.log(`${colors.dim}Fetching projects...${colors.reset}`);
759
-
760
- let projects = [];
761
- try {
762
- projects = await fetchProjects(authApiKey);
763
- } catch (error) {
764
- console.log(`${colors.red}Failed to fetch projects: ${redactErrorMessage(error)}${colors.reset}`);
765
- console.log(`${colors.dim}Run 'bootspring switch <project>' manually to set project${colors.reset}`);
766
- return false;
767
- }
768
-
769
- if (projects.length === 0) {
770
- console.log(`${colors.yellow}No projects found${colors.reset}`);
771
- console.log(`${colors.dim}Create a project at https://bootspring.com/dashboard/projects${colors.reset}`);
772
- return false;
773
- }
774
- let selectedProject = null;
775
-
776
- if (projects.length === 1) {
777
- selectedProject = projects[0];
778
- console.log(`\n${colors.dim}Only one project is available. Linking automatically...${colors.reset}`);
779
- } else {
780
- console.log(`\n${colors.bold}Your Projects${colors.reset}\n`);
781
- for (let i = 0; i < projects.length; i++) {
782
- const project = projects[i];
783
- console.log(` ${colors.cyan}[${i + 1}]${colors.reset} ${colors.bold}${project.name}${colors.reset} ${colors.dim}(${project.slug})${colors.reset}`);
784
- }
785
-
786
- const selection = await prompt(`\n${colors.cyan}Enter number or name:${colors.reset} `);
787
-
788
- if (!selection) {
789
- console.log(`${colors.yellow}No project selected${colors.reset}`);
790
- console.log(`${colors.dim}Run 'bootspring switch <project>' to set project later${colors.reset}`);
791
- return false;
792
- }
793
-
794
- const num = parseInt(selection, 10);
795
-
796
- if (!isNaN(num) && num >= 1 && num <= projects.length) {
797
- selectedProject = projects[num - 1];
798
- } else {
799
- selectedProject = projects.find(
800
- p => p.name.toLowerCase() === selection.toLowerCase() ||
801
- p.slug === selection.toLowerCase()
802
- );
803
- }
804
- }
805
-
806
- if (!selectedProject) {
807
- console.log(`${colors.red}Project not found${colors.reset}`);
808
- console.log(`${colors.dim}Run 'bootspring switch <project>' to try again${colors.reset}`);
809
- return false;
810
- }
811
-
812
- // Set project in session
813
- session.setCurrentProject(selectedProject);
814
- session.addRecentProject(selectedProject);
815
-
816
- console.log(`\n${colors.green}Selected: ${selectedProject.name}${colors.reset}`);
817
-
818
- // Create local config to lock directory
819
- if (lockToDirectory) {
820
- try {
821
- const configPath = session.createLocalConfig(process.cwd(), selectedProject);
822
- if (authApiKey) {
823
- await activateLinkedProjectSession(authApiKey, selectedProject.id);
824
- }
825
- console.log(`${colors.dim}Locked directory to "${selectedProject.name}"${colors.reset}`);
826
- console.log(`${colors.dim}Config: ${configPath}${colors.reset}`);
827
- } catch (error) {
828
- console.log(`${colors.yellow}Warning: Could not create local config: ${redactErrorMessage(error)}${colors.reset}`);
829
- }
830
- }
831
-
832
- return true;
833
- }
834
-
835
- // Alias for backwards compatibility (exported for potential external use)
836
- const _promptForProjectIfNeeded = requireProjectSelection;
837
-
838
- /**
839
- * Main command handler
840
- */
841
- async function run(args) {
842
- const subcommand = args[0] || 'status';
843
-
844
- switch (subcommand) {
845
- case 'login':
846
- await login(args.slice(1));
847
- break;
848
-
849
- case 'register':
850
- case 'signup':
851
- await register();
852
- break;
853
-
854
- case 'logout':
855
- await logoutCmd();
856
- break;
857
-
858
- case 'whoami':
859
- case 'me':
860
- await whoami();
861
- break;
862
-
863
- case 'status':
864
- await status();
865
- break;
866
-
867
- case 'switch':
868
- case 'project':
869
- await switchProject(args.slice(1));
870
- break;
871
-
872
- default:
873
- console.log(`\n${colors.bold}Bootspring Authentication${colors.reset}\n`);
874
- console.log(`${colors.cyan}Usage:${colors.reset} bootspring auth <command>\n`);
875
- console.log(`${colors.bold}Commands:${colors.reset}`);
876
- console.log(` ${colors.green}login${colors.reset} Login to your Bootspring account`);
877
- console.log(` ${colors.green}register${colors.reset} Create a new account`);
878
- console.log(` ${colors.green}logout${colors.reset} Log out and clear credentials`);
879
- console.log(` ${colors.green}whoami${colors.reset} Show current user and usage`);
880
- console.log(` ${colors.green}status${colors.reset} Check authentication and API status`);
881
- console.log(` ${colors.green}switch${colors.reset} Switch current project context`);
882
- console.log(`\n${colors.bold}Login Options:${colors.reset}`);
883
- console.log(` ${colors.dim}(default)${colors.reset} Browser-based login (opens browser)`);
884
- console.log(` ${colors.green}--password, -p${colors.reset} Use email/password login`);
885
- console.log(` ${colors.green}--api-key, -k${colors.reset} Login with an API key`);
886
- console.log(` ${colors.green}--no-browser${colors.reset} Show URL without opening browser`);
887
- console.log(`\n${colors.dim}Examples:${colors.reset}`);
888
- console.log(' bootspring auth login # Opens browser for SSO');
889
- console.log(' bootspring auth login --password # Email/password prompt');
890
- console.log(' bootspring auth login --api-key bs_live_xxxxx');
891
- console.log(' bootspring auth whoami');
892
- console.log(' bootspring switch my-project # Switch project context');
893
- }
894
- }
895
-
896
- module.exports = { run, requireProjectSelection };