@lanonasis/cli 3.4.15 → 3.6.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/README.md CHANGED
@@ -452,3 +452,71 @@ MIT License - see [LICENSE](../LICENSE) for details.
452
452
  ---
453
453
 
454
454
  _Professional CLI for Enterprise Memory as a Service - Golden Contract Compliant_
455
+
456
+ ## OAuth2 Authentication (v3.5.0+)
457
+
458
+ ### Browser Login with OAuth2 PKCE
459
+
460
+ The CLI now supports secure OAuth2 authentication with PKCE (Proof Key for Code Exchange) for browser-based login:
461
+
462
+ ```bash
463
+ onasis auth login
464
+ # Choose: 🌐 Browser Login (Get token from web page)
465
+ ```
466
+
467
+ **How it works:**
468
+ 1. CLI starts a local callback server on port 8888
469
+ 2. Opens your browser to the OAuth2 authorization page
470
+ 3. You authenticate in the browser
471
+ 4. Authorization code is sent back to the CLI
472
+ 5. CLI exchanges code for access and refresh tokens
473
+ 6. Tokens are securely stored locally
474
+
475
+ **Benefits:**
476
+ - ✅ More secure (PKCE prevents code interception)
477
+ - ✅ Automatic token refresh
478
+ - ✅ Revocable access
479
+ - ✅ Industry-standard OAuth2 flow
480
+
481
+ ### Authentication Methods
482
+
483
+ The CLI supports three authentication methods:
484
+
485
+ 1. **🔑 Vendor Key** (Recommended for API access)
486
+ - Long-lived API key from dashboard
487
+ - Best for automation and CI/CD
488
+
489
+ 2. **🌐 Browser Login** (OAuth2 PKCE)
490
+ - Secure browser-based authentication
491
+ - Automatic token refresh
492
+ - Best for interactive use
493
+
494
+ 3. **⚙️ Username/Password** (Direct credentials)
495
+ - Traditional email/password login
496
+ - Returns JWT token
497
+ - Legacy method
498
+
499
+ ### Token Management
500
+
501
+ OAuth2 tokens are automatically refreshed when expired:
502
+
503
+ ```bash
504
+ # Check authentication status
505
+ onasis auth status
506
+
507
+ # Force re-authentication
508
+ onasis auth logout
509
+ onasis auth login
510
+ ```
511
+
512
+ ### Troubleshooting
513
+
514
+ **Port 8888 already in use:**
515
+ The CLI needs port 8888 for the OAuth callback. If it's in use, close the application using it or use the Vendor Key method instead.
516
+
517
+ **Browser doesn't open:**
518
+ The CLI will show the authorization URL - copy and paste it into your browser manually.
519
+
520
+ **Token refresh failed:**
521
+ Run `onasis auth login` to re-authenticate.
522
+
@@ -2,6 +2,9 @@ import chalk from 'chalk';
2
2
  import inquirer from 'inquirer';
3
3
  import ora from 'ora';
4
4
  import open from 'open';
5
+ import crypto from 'crypto';
6
+ import http from 'http';
7
+ import url from 'url';
5
8
  import { apiClient } from '../utils/api.js';
6
9
  import { CLIConfig } from '../utils/config.js';
7
10
  // Color scheme
@@ -49,9 +52,9 @@ async function handleAuthenticationFailure(error, config, authMethod = 'jwt') {
49
52
  case 'invalid_credentials':
50
53
  console.log(chalk.red('Invalid credentials provided'));
51
54
  if (authMethod === 'vendor_key') {
52
- console.log(chalk.gray('• Check your vendor key format: pk_xxx.sk_xxx'));
53
- console.log(chalk.gray('• Verify the key is active in your account dashboard'));
54
- console.log(chalk.gray('• Ensure you copied the complete key including both parts'));
55
+ console.log(chalk.gray('• Verify the vendor key matches the value shown in your dashboard'));
56
+ console.log(chalk.gray('• Confirm the key is active and has not been revoked'));
57
+ console.log(chalk.gray('• Ensure you copied the entire key without extra spaces'));
55
58
  }
56
59
  else {
57
60
  console.log(chalk.gray('• Double-check your email and password'));
@@ -163,6 +166,120 @@ function categorizeAuthError(error) {
163
166
  }
164
167
  return 'unknown';
165
168
  }
169
+ // ============================================
170
+ // OAuth2 PKCE Helper Functions
171
+ // ============================================
172
+ /**
173
+ * Generate PKCE code verifier and challenge for OAuth2
174
+ */
175
+ function generatePKCE() {
176
+ // Generate random verifier (43-128 chars, base64url)
177
+ const verifier = crypto.randomBytes(32).toString('base64url');
178
+ // Generate challenge: base64url(sha256(verifier))
179
+ const challenge = crypto
180
+ .createHash('sha256')
181
+ .update(verifier)
182
+ .digest('base64url');
183
+ return { verifier, challenge };
184
+ }
185
+ /**
186
+ * Start local HTTP server to catch OAuth2 callback
187
+ */
188
+ function createCallbackServer(port = 8888) {
189
+ return new Promise((resolve, reject) => {
190
+ const server = http.createServer((req, res) => {
191
+ const parsedUrl = url.parse(req.url, true);
192
+ if (parsedUrl.pathname === '/callback') {
193
+ const { code, state, error, error_description } = parsedUrl.query;
194
+ // Send response to browser
195
+ if (error) {
196
+ res.writeHead(400, { 'Content-Type': 'text/html' });
197
+ res.end(`
198
+ <html>
199
+ <head><title>Authentication Failed</title></head>
200
+ <body style="font-family: sans-serif; text-align: center; padding: 50px;">
201
+ <h1>❌ Authentication Failed</h1>
202
+ <p>${error_description || error}</p>
203
+ <p style="color: gray;">You can close this window.</p>
204
+ </body>
205
+ </html>
206
+ `);
207
+ reject(new Error(`OAuth error: ${error_description || error}`));
208
+ }
209
+ else if (code) {
210
+ res.writeHead(200, { 'Content-Type': 'text/html' });
211
+ res.end(`
212
+ <html>
213
+ <head><title>Authentication Successful</title></head>
214
+ <body style="font-family: sans-serif; text-align: center; padding: 50px;">
215
+ <h1>✅ Authentication Successful</h1>
216
+ <p>You can close this window and return to the CLI.</p>
217
+ <script>setTimeout(() => window.close(), 2000);</script>
218
+ </body>
219
+ </html>
220
+ `);
221
+ resolve({ code: code, state: state });
222
+ }
223
+ else {
224
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
225
+ res.end('Invalid callback');
226
+ reject(new Error('No authorization code received'));
227
+ }
228
+ // Close server after handling request
229
+ server.close();
230
+ }
231
+ });
232
+ server.listen(port, () => {
233
+ console.log(chalk.gray(` Local callback server listening on port ${port}`));
234
+ });
235
+ // Timeout after 5 minutes
236
+ setTimeout(() => {
237
+ server.close();
238
+ reject(new Error('Authentication timeout - please try again'));
239
+ }, 300000);
240
+ });
241
+ }
242
+ /**
243
+ * Exchange authorization code for OAuth2 tokens
244
+ */
245
+ async function exchangeCodeForTokens(code, verifier, authBase) {
246
+ const tokenEndpoint = `${authBase}/oauth/token`;
247
+ const response = await apiClient.post(tokenEndpoint, {
248
+ grant_type: 'authorization_code',
249
+ code,
250
+ code_verifier: verifier,
251
+ client_id: 'lanonasis-cli',
252
+ redirect_uri: 'http://localhost:8888/callback'
253
+ });
254
+ return response;
255
+ }
256
+ /**
257
+ * Refresh OAuth2 access token using refresh token
258
+ */
259
+ async function refreshOAuth2Token(config) {
260
+ const refreshToken = config.get('refresh_token');
261
+ if (!refreshToken) {
262
+ return false;
263
+ }
264
+ try {
265
+ const authBase = config.getDiscoveredApiUrl();
266
+ const response = await apiClient.post(`${authBase}/oauth/token`, {
267
+ grant_type: 'refresh_token',
268
+ refresh_token: refreshToken,
269
+ client_id: 'lanonasis-cli'
270
+ });
271
+ await config.setToken(response.access_token);
272
+ if (response.refresh_token) {
273
+ await config.set('refresh_token', response.refresh_token);
274
+ }
275
+ await config.set('token_expires_at', Date.now() + (response.expires_in * 1000));
276
+ return true;
277
+ }
278
+ catch (error) {
279
+ console.error(chalk.yellow('⚠️ Token refresh failed, please re-authenticate'));
280
+ return false;
281
+ }
282
+ }
166
283
  export async function diagnoseCommand() {
167
284
  const config = new CLIConfig();
168
285
  await config.init();
@@ -206,14 +323,10 @@ export async function diagnoseCommand() {
206
323
  diagnostics.hasCredentials = true;
207
324
  diagnostics.credentialType = 'vendor_key';
208
325
  console.log(chalk.green(' ✓ Vendor key found'));
209
- // Validate vendor key format
326
+ // Validate vendor key presence
210
327
  const formatValidation = config.validateVendorKeyFormat(vendorKey);
211
- if (formatValidation === true) {
212
- console.log(chalk.green(' Vendor key format is valid'));
213
- }
214
- else {
215
- console.log(chalk.red(' ✖ Vendor key format is invalid:'));
216
- console.log(chalk.gray(` ${formatValidation}`));
328
+ if (formatValidation !== true) {
329
+ console.log(chalk.red(` Vendor key issue: ${formatValidation}`));
217
330
  }
218
331
  }
219
332
  else if (token) {
@@ -231,8 +344,11 @@ export async function diagnoseCommand() {
231
344
  console.log(chalk.green(' ✓ Token is not expired'));
232
345
  }
233
346
  }
234
- catch {
347
+ catch (error) {
235
348
  console.log(chalk.yellow(' ⚠ Could not validate token expiry'));
349
+ if (process.env.CLI_VERBOSE === 'true' && error instanceof Error) {
350
+ console.log(chalk.gray(` ${error.message}`));
351
+ }
236
352
  }
237
353
  }
238
354
  else {
@@ -315,8 +431,11 @@ export async function diagnoseCommand() {
315
431
  diagnostics.deviceId = deviceId;
316
432
  console.log(chalk.green(' ✓ Device ID:'), chalk.gray(deviceId));
317
433
  }
318
- catch {
434
+ catch (error) {
319
435
  console.log(chalk.yellow(' ⚠ Could not get device ID'));
436
+ if (process.env.CLI_VERBOSE === 'true' && error instanceof Error) {
437
+ console.log(chalk.gray(` ${error.message}`));
438
+ }
320
439
  }
321
440
  // Summary and recommendations
322
441
  console.log(chalk.blue.bold('\n📋 Diagnostic Summary'));
@@ -329,7 +448,7 @@ export async function diagnoseCommand() {
329
448
  }
330
449
  if (!diagnostics.hasCredentials) {
331
450
  issues.push('No authentication credentials stored');
332
- recommendations.push('Run: lanonasis auth login --vendor-key pk_xxx.sk_xxx');
451
+ recommendations.push('Run: lanonasis auth login --vendor-key <your-key>');
333
452
  }
334
453
  if (diagnostics.hasCredentials && !diagnostics.credentialsValid) {
335
454
  issues.push('Stored credentials are invalid');
@@ -364,7 +483,7 @@ export async function diagnoseCommand() {
364
483
  // Additional troubleshooting info
365
484
  if (diagnostics.authFailures > 0 || !diagnostics.credentialsValid) {
366
485
  console.log(chalk.gray('\n🔧 Additional troubleshooting:'));
367
- console.log(chalk.gray(' • Verify your vendor key format: pk_xxx.sk_xxx'));
486
+ console.log(chalk.gray(' • Verify the vendor key matches the value shown in your dashboard'));
368
487
  console.log(chalk.gray(' • Check if your key is active in the dashboard'));
369
488
  console.log(chalk.gray(' • Try browser authentication: lanonasis auth login --use-web-auth'));
370
489
  console.log(chalk.gray(' • Contact support if issues persist'));
@@ -451,84 +570,37 @@ async function handleVendorKeyAuth(vendorKey, config) {
451
570
  async function handleVendorKeyFlow(config) {
452
571
  console.log();
453
572
  console.log(chalk.yellow('🔑 Vendor Key Authentication'));
454
- console.log(chalk.gray('Vendor keys provide secure API access with format: pk_xxx.sk_xxx'));
573
+ console.log(chalk.gray('Vendor keys provide secure API access for automation and integrations.'));
455
574
  console.log();
456
575
  // Enhanced guidance for obtaining vendor keys
457
576
  console.log(chalk.cyan('📋 How to get your vendor key:'));
458
577
  console.log(chalk.gray('1. Visit your Lanonasis dashboard at https://app.lanonasis.com'));
459
578
  console.log(chalk.gray('2. Navigate to Settings → API Keys'));
460
- console.log(chalk.gray('3. Click "Generate New Key" and copy the full key'));
461
- console.log(chalk.gray('4. The key format should be: pk_[letters/numbers].sk_[letters/numbers]'));
579
+ console.log(chalk.gray('3. Click "Generate New Key" and copy the full key value'));
462
580
  console.log();
463
581
  const { vendorKey } = await inquirer.prompt([
464
582
  {
465
583
  type: 'password',
466
584
  name: 'vendorKey',
467
- message: 'Enter your vendor key (pk_xxx.sk_xxx):',
585
+ message: 'Enter your vendor key:',
468
586
  mask: '*',
469
587
  validate: (input) => {
470
- return validateVendorKeyFormat(input);
588
+ return config.validateVendorKeyFormat(input);
471
589
  }
472
590
  }
473
591
  ]);
474
592
  await handleVendorKeyAuth(vendorKey, config);
475
593
  }
476
- // Enhanced vendor key format validation with detailed error messages
477
- function validateVendorKeyFormat(input) {
478
- if (!input || input.trim().length === 0) {
479
- return 'Vendor key is required';
480
- }
481
- const trimmed = input.trim();
482
- // Check basic format
483
- if (!trimmed.includes('.')) {
484
- return 'Invalid format: Vendor key must contain a dot (.) separator\nExpected format: pk_xxx.sk_xxx';
485
- }
486
- const parts = trimmed.split('.');
487
- if (parts.length !== 2) {
488
- return 'Invalid format: Vendor key must have exactly two parts separated by a dot\nExpected format: pk_xxx.sk_xxx';
489
- }
490
- const [publicPart, secretPart] = parts;
491
- // Validate public key part
492
- if (!publicPart.startsWith('pk_')) {
493
- return 'Invalid format: First part must start with "pk_"\nExpected format: pk_xxx.sk_xxx';
494
- }
495
- if (publicPart.length < 4) {
496
- return 'Invalid format: Public key part is too short\nExpected format: pk_xxx.sk_xxx (where xxx is alphanumeric)';
497
- }
498
- const publicKeyContent = publicPart.substring(3); // Remove 'pk_'
499
- if (!/^[a-zA-Z0-9]+$/.test(publicKeyContent)) {
500
- return 'Invalid format: Public key part contains invalid characters\nOnly letters and numbers are allowed after "pk_"';
501
- }
502
- // Validate secret key part
503
- if (!secretPart.startsWith('sk_')) {
504
- return 'Invalid format: Second part must start with "sk_"\nExpected format: pk_xxx.sk_xxx';
505
- }
506
- if (secretPart.length < 4) {
507
- return 'Invalid format: Secret key part is too short\nExpected format: pk_xxx.sk_xxx (where xxx is alphanumeric)';
508
- }
509
- const secretKeyContent = secretPart.substring(3); // Remove 'sk_'
510
- if (!/^[a-zA-Z0-9]+$/.test(secretKeyContent)) {
511
- return 'Invalid format: Secret key part contains invalid characters\nOnly letters and numbers are allowed after "sk_"';
512
- }
513
- // Check minimum length requirements
514
- if (publicKeyContent.length < 8) {
515
- return 'Invalid format: Public key part is too short (minimum 8 characters after "pk_")';
516
- }
517
- if (secretKeyContent.length < 16) {
518
- return 'Invalid format: Secret key part is too short (minimum 16 characters after "sk_")';
519
- }
520
- return true;
521
- }
522
594
  async function handleOAuthFlow(config) {
523
595
  console.log();
524
- console.log(chalk.yellow('🌐 Browser-Based Authentication'));
525
- console.log(chalk.gray('This will open your browser for secure authentication'));
596
+ console.log(chalk.yellow('🌐 Browser-Based OAuth2 Authentication'));
597
+ console.log(chalk.gray('Secure authentication using OAuth2 with PKCE'));
526
598
  console.log();
527
599
  const { openBrowser } = await inquirer.prompt([
528
600
  {
529
601
  type: 'confirm',
530
602
  name: 'openBrowser',
531
- message: 'Open browser for authentication?',
603
+ message: 'Open browser for OAuth2 authentication?',
532
604
  default: true
533
605
  }
534
606
  ]);
@@ -536,63 +608,53 @@ async function handleOAuthFlow(config) {
536
608
  console.log(chalk.yellow('⚠️ Authentication cancelled'));
537
609
  return;
538
610
  }
539
- // Use the browser-based CLI login endpoint discovered from auth_base
540
- const authBase = config.getDiscoveredApiUrl();
541
- const authUrl = `${authBase.replace(/\/$/, '')}/auth/cli-login`;
542
611
  try {
543
- console.log(colors.info('Opening browser...'));
544
- await open(authUrl);
612
+ // Generate PKCE challenge
613
+ const pkce = generatePKCE();
614
+ console.log(chalk.gray(' ✓ Generated PKCE challenge'));
615
+ // Start local callback server
616
+ const callbackPort = 8888;
617
+ const callbackPromise = createCallbackServer(callbackPort);
618
+ console.log(chalk.gray(` ✓ Started local callback server on port ${callbackPort}`));
619
+ // Build OAuth2 authorization URL
620
+ const authBase = config.getDiscoveredApiUrl();
621
+ const authUrl = new URL(`${authBase}/oauth/authorize`);
622
+ authUrl.searchParams.set('response_type', 'code');
623
+ authUrl.searchParams.set('client_id', 'lanonasis-cli');
624
+ authUrl.searchParams.set('redirect_uri', `http://localhost:${callbackPort}/callback`);
625
+ authUrl.searchParams.set('scope', 'read write offline_access');
626
+ authUrl.searchParams.set('code_challenge', pkce.challenge);
627
+ authUrl.searchParams.set('code_challenge_method', 'S256');
628
+ authUrl.searchParams.set('state', crypto.randomBytes(16).toString('hex'));
545
629
  console.log();
546
- console.log(colors.info('Please complete authentication in your browser'));
547
- console.log(colors.info('The page will display your authentication token'));
548
- console.log(colors.muted(`If browser doesn't open, visit: ${authUrl}`));
630
+ console.log(colors.info('Opening browser for authentication...'));
631
+ await open(authUrl.toString());
632
+ console.log(colors.info('Waiting for authentication in browser...'));
633
+ console.log(colors.muted(`If browser doesn't open, visit: ${authUrl.toString()}`));
549
634
  console.log();
550
- // Prompt for the token from the browser page
551
- const { token } = await inquirer.prompt([
552
- {
553
- type: 'input',
554
- name: 'token',
555
- message: 'Paste the authentication token from browser:',
556
- validate: async (input) => {
557
- if (!input || input.trim().length === 0) {
558
- return 'Token is required';
559
- }
560
- const trimmed = input.trim();
561
- // Reject if user pasted a URL instead of token
562
- if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
563
- return 'Please paste the TOKEN from the page, not the URL';
564
- }
565
- // Check token format - should start with 'cli_' or be a JWT
566
- if (!trimmed.startsWith('cli_') && !trimmed.match(/^[\w-]+\.[\w-]+\.[\w-]+$/)) {
567
- return 'Invalid token format. Expected format: cli_xxx or JWT token';
568
- }
569
- // Verify token with server
570
- try {
571
- const response = await apiClient.post('/auth/verify', { token: trimmed });
572
- if (!response.valid) {
573
- return 'Token verification failed. Please try again.';
574
- }
575
- }
576
- catch (error) {
577
- const errorMessage = error instanceof Error ? error.message : 'Server verification failed';
578
- return `Token verification error: ${errorMessage}`;
579
- }
580
- return true;
581
- }
582
- }
583
- ]);
584
- if (token && token.trim()) {
585
- await config.setToken(token.trim());
586
- console.log(chalk.green('✓ Browser authentication successful'));
587
- console.log(colors.info('You can now use Lanonasis services'));
588
- }
589
- else {
590
- console.log(chalk.yellow('⚠️ No token provided'));
591
- }
635
+ // Wait for callback
636
+ const spinner = ora('Waiting for authorization...').start();
637
+ const { code } = await callbackPromise;
638
+ spinner.succeed('Authorization code received');
639
+ // Exchange code for tokens
640
+ spinner.text = 'Exchanging code for access tokens...';
641
+ spinner.start();
642
+ const tokens = await exchangeCodeForTokens(code, pkce.verifier, authBase);
643
+ spinner.succeed('Access tokens received');
644
+ // Store tokens
645
+ await config.setToken(tokens.access_token);
646
+ await config.set('refresh_token', tokens.refresh_token);
647
+ await config.set('token_expires_at', Date.now() + (tokens.expires_in * 1000));
648
+ await config.set('authMethod', 'oauth2');
649
+ console.log();
650
+ console.log(chalk.green('✓ OAuth2 authentication successful'));
651
+ console.log(colors.info('You can now use Lanonasis services'));
592
652
  }
593
- catch {
594
- console.error(chalk.red('✖ Failed to open browser'));
595
- console.log(colors.muted(`Please visit manually: ${authUrl}`));
653
+ catch (error) {
654
+ console.error(chalk.red('✖ OAuth2 authentication failed'));
655
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
656
+ console.error(chalk.gray(` ${errorMessage}`));
657
+ process.exit(1);
596
658
  }
597
659
  }
598
660
  async function handleCredentialsFlow(options, config) {
@@ -48,7 +48,7 @@ export async function generateCompletionData() {
48
48
  options: [
49
49
  { name: '--email', description: 'Email address', type: 'string' },
50
50
  { name: '--password', description: 'Password', type: 'string' },
51
- { name: '--vendor-key', description: 'Vendor key (pk_xxx.sk_xxx)', type: 'string' },
51
+ { name: '--vendor-key', description: 'Vendor key value', type: 'string' },
52
52
  { name: '--oauth', description: 'Use OAuth flow', type: 'boolean' }
53
53
  ]
54
54
  },
@@ -411,15 +411,15 @@ export function configCommands(program) {
411
411
  console.log(chalk.cyan(' → Repaired: Set auth method to jwt'));
412
412
  }
413
413
  }
414
- // Validate vendor key format if present
414
+ // Validate vendor key presence if present
415
415
  if (vendorKey) {
416
416
  const formatValidation = config.validateVendorKeyFormat(vendorKey);
417
417
  if (formatValidation === true) {
418
- console.log(chalk.green(' ✓ Vendor key format is valid'));
418
+ console.log(chalk.green(' ✓ Vendor key is set'));
419
419
  }
420
420
  else {
421
- console.log(chalk.red(' ✖ Vendor key format is invalid'));
422
- validation.issues.push('Invalid vendor key format');
421
+ console.log(chalk.red(` ✖ Vendor key issue: ${formatValidation}`));
422
+ validation.issues.push('Vendor key missing or invalid');
423
423
  }
424
424
  }
425
425
  // Test authentication validity
@@ -233,7 +233,7 @@ export class UserGuidanceSystem {
233
233
  switch (authMethod) {
234
234
  case 'vendor_key':
235
235
  console.log(chalk.yellow('📝 Vendor keys provide secure, programmatic access'));
236
- console.log(chalk.gray('Format: pk_xxxxx.sk_xxxxx'));
236
+ console.log(chalk.gray('Find it in your dashboard under API Keys.'));
237
237
  console.log();
238
238
  break;
239
239
  case 'oauth':
@@ -409,7 +409,7 @@ export async function quickStartCommand() {
409
409
  category: 'Setup',
410
410
  commands: [
411
411
  { cmd: 'lanonasis init', desc: 'Initialize configuration' },
412
- { cmd: 'lanonasis login --vendor-key pk_xxx.sk_xxx', desc: 'Authenticate with vendor key' },
412
+ { cmd: 'lanonasis login --vendor-key <your-key>', desc: 'Authenticate with vendor key' },
413
413
  { cmd: 'lanonasis health', desc: 'Verify system health' }
414
414
  ]
415
415
  },
@@ -293,7 +293,7 @@ export class PowerUserMode {
293
293
  const subCommand = args[0];
294
294
  switch (subCommand) {
295
295
  case 'keys':
296
- console.log('API Keys: pk_xxx...xxx (active), pk_yyy...yyy (revoked)');
296
+ console.log('API Keys: vendor-key-1... (active), vendor-key-2... (revoked)');
297
297
  break;
298
298
  case 'limits':
299
299
  console.log('Rate Limits: 1000/hour (432 used)');
@@ -296,7 +296,7 @@ export class InteractiveSetup {
296
296
  message: 'Select authentication method:',
297
297
  choices: [
298
298
  {
299
- name: authBox('🔑 Vendor Key', 'Secure API access with pk_xxx.sk_xxx format', ['No expiration', 'Ideal for CI/CD', 'Full API access']),
299
+ name: authBox('🔑 Vendor Key', 'Secure API access with long-lived credentials', ['No expiration', 'Ideal for CI/CD', 'Full API access']),
300
300
  value: 'vendor',
301
301
  short: 'Vendor Key'
302
302
  },
@@ -327,32 +327,21 @@ export class InteractiveSetup {
327
327
  }
328
328
  }
329
329
  async authenticateWithVendorKey() {
330
- const creds = await inquirer.prompt([
331
- {
332
- type: 'input',
333
- name: 'publicKey',
334
- message: 'Enter your Public Key (pk_xxx):',
335
- validate: (input) => {
336
- if (!input.startsWith('pk_')) {
337
- return 'Public key must start with pk_';
338
- }
339
- return true;
340
- }
341
- },
330
+ const { vendorKey } = await inquirer.prompt([
342
331
  {
343
332
  type: 'password',
344
- name: 'secretKey',
345
- message: 'Enter your Secret Key (sk_xxx):',
333
+ name: 'vendorKey',
334
+ message: 'Enter your vendor key:',
346
335
  mask: '*',
347
336
  validate: (input) => {
348
- if (!input.startsWith('sk_')) {
349
- return 'Secret key must start with sk_';
337
+ if (!input || input.trim().length === 0) {
338
+ return 'Vendor key is required';
350
339
  }
351
340
  return true;
352
341
  }
353
342
  }
354
343
  ]);
355
- void creds; // collected for future use when hooking real auth
344
+ void vendorKey; // collected for future use when hooking real auth
356
345
  const spinner = ora('Authenticating...').start();
357
346
  await this.simulateDelay(1000);
358
347
  spinner.succeed('Authentication successful!');
@@ -393,7 +382,6 @@ export class InteractiveSetup {
393
382
  mask: '*'
394
383
  }
395
384
  ]);
396
- void auth; // suppress unused until real auth wired
397
385
  const spinner = ora('Signing in...').start();
398
386
  await this.simulateDelay(1000);
399
387
  spinner.succeed('Sign in successful!');
@@ -123,7 +123,7 @@ const showWelcome = () => {
123
123
  console.log();
124
124
  if (isOnasisInvocation) {
125
125
  console.log(colors.info('🔑 Golden Contract Authentication:'));
126
- console.log(` ${colors.success(`${cmdName} login --vendor-key pk_xxx.sk_xxx`)} ${colors.muted('# Vendor key auth')}`);
126
+ console.log(` ${colors.success(`${cmdName} login --vendor-key <your-key>`)} ${colors.muted('# Vendor key auth')}`);
127
127
  console.log(` ${colors.success(`${cmdName} login --oauth`)} ${colors.muted('# Browser OAuth')}`);
128
128
  console.log();
129
129
  }
@@ -175,11 +175,24 @@ const healthCheck = async () => {
175
175
  process.stdout.write('MCP Server status: ');
176
176
  try {
177
177
  const client = getMCPClient();
178
+ const status = client.getConnectionStatus();
179
+ const mcpPreference = await cliConfig.get('mcpPreference') || 'auto';
178
180
  if (client.isConnectedToServer()) {
179
181
  console.log(colors.success('✅ Connected'));
182
+ console.log(` Mode: ${colors.highlight(status.mode)}`);
183
+ console.log(` Server: ${colors.highlight(status.server || 'N/A')}`);
180
184
  }
181
185
  else {
182
186
  console.log(colors.warning('⚠️ Disconnected'));
187
+ console.log(` Configured preference: ${colors.highlight(mcpPreference)}`);
188
+ if (mcpPreference === 'remote') {
189
+ const mcpUrl = cliConfig.getMCPServerUrl();
190
+ console.log(` Remote server: ${colors.highlight(mcpUrl)}`);
191
+ }
192
+ else if (mcpPreference === 'local') {
193
+ const mcpPath = cliConfig.getMCPServerPath();
194
+ console.log(` Local server: ${colors.highlight(mcpPath || 'Not configured')}`);
195
+ }
183
196
  }
184
197
  }
185
198
  catch (error) {
@@ -229,7 +242,7 @@ authCmd
229
242
  .description('Login to your MaaS account')
230
243
  .option('-e, --email <email>', 'email address')
231
244
  .option('-p, --password <password>', 'password')
232
- .option('--vendor-key <key>', 'vendor key (pk_xxx.sk_xxx format)')
245
+ .option('--vendor-key <key>', 'vendor key (as provided in your dashboard)')
233
246
  .option('--oauth', 'use OAuth browser flow')
234
247
  .action(async (options) => {
235
248
  // Handle oauth flag
@@ -249,6 +262,8 @@ authCmd
249
262
  .command('status')
250
263
  .description('Show authentication status')
251
264
  .action(async () => {
265
+ // Initialize config first
266
+ await cliConfig.init();
252
267
  const isAuth = await cliConfig.isAuthenticated();
253
268
  const user = await cliConfig.getCurrentUser();
254
269
  const failureCount = cliConfig.getFailureCount();
@@ -523,6 +538,8 @@ program
523
538
  .command('status')
524
539
  .description('Show overall system status')
525
540
  .action(async () => {
541
+ // Initialize config first
542
+ await cliConfig.init();
526
543
  const isAuth = await cliConfig.isAuthenticated();
527
544
  const apiUrl = cliConfig.getApiUrl();
528
545
  console.log(chalk.blue.bold('MaaS CLI Status'));
package/dist/index.js CHANGED
@@ -270,6 +270,47 @@ const memoryCmd = program
270
270
  requireAuth(memoryCmd);
271
271
  memoryCommands(memoryCmd);
272
272
  // Note: Memory commands are now MCP-powered when available
273
+ // REPL command (lightweight REPL for memory operations)
274
+ program
275
+ .command('repl')
276
+ .description('Start lightweight REPL session for memory operations')
277
+ .option('--mcp', 'Use MCP mode')
278
+ .option('--api <url>', 'Override API URL')
279
+ .option('--token <token>', 'Authentication token')
280
+ .action(async (options) => {
281
+ try {
282
+ // Try to use the REPL package if available
283
+ const { spawn } = await import('child_process');
284
+ const { fileURLToPath } = await import('url');
285
+ const { dirname, join } = await import('path');
286
+ // Try to find the REPL package
287
+ const replPath = join(process.cwd(), 'packages', 'repl-cli', 'dist', 'index.js');
288
+ const args = ['start'];
289
+ if (options.mcp)
290
+ args.push('--mcp');
291
+ if (options.api)
292
+ args.push('--api', options.api);
293
+ if (options.token)
294
+ args.push('--token', options.token);
295
+ const repl = spawn('node', [replPath, ...args], {
296
+ stdio: 'inherit',
297
+ cwd: process.cwd()
298
+ });
299
+ repl.on('error', (err) => {
300
+ console.error(colors.error('Failed to start REPL:'), err.message);
301
+ console.log(colors.muted('Make sure the REPL package is built: cd packages/repl-cli && bun run build'));
302
+ process.exit(1);
303
+ });
304
+ repl.on('exit', (code) => {
305
+ process.exit(code || 0);
306
+ });
307
+ }
308
+ catch (error) {
309
+ console.error(colors.error('Failed to start REPL:'), error instanceof Error ? error.message : String(error));
310
+ console.log(colors.muted('Install the REPL package: cd packages/repl-cli && bun install && bun run build'));
311
+ process.exit(1);
312
+ }
313
+ });
273
314
  // Topic commands (require auth)
274
315
  const topicCmd = program
275
316
  .command('topic')
@@ -59,7 +59,7 @@ export class EnhancedMCPClient extends EventEmitter {
59
59
  const maxRetries = config.maxRetries || 3;
60
60
  const timeout = config.timeout || 30000;
61
61
  let attempts = 0;
62
- while (attempts < maxRetries) {
62
+ while (true) {
63
63
  try {
64
64
  this.updateConnectionStatus(config.name, 'connecting');
65
65
  const client = await this.createClientWithTimeout(config, timeout);
@@ -82,7 +82,6 @@ export class EnhancedMCPClient extends EventEmitter {
82
82
  await this.delay(delay);
83
83
  }
84
84
  }
85
- return false;
86
85
  }
87
86
  /**
88
87
  * Create client with timeout
@@ -202,13 +202,13 @@ export declare const SystemConfigSchema: z.ZodObject<{
202
202
  }, "strip", z.ZodTypeAny, {
203
203
  value?: any;
204
204
  action?: "get" | "set" | "reset";
205
- key?: string;
206
205
  scope?: "user" | "global";
206
+ key?: string;
207
207
  }, {
208
208
  value?: any;
209
209
  action?: "get" | "set" | "reset";
210
- key?: string;
211
210
  scope?: "user" | "global";
211
+ key?: string;
212
212
  }>;
213
213
  export declare const BulkOperationSchema: z.ZodObject<{
214
214
  operation: z.ZodEnum<["create", "update", "delete"]>;
@@ -580,13 +580,13 @@ export declare const MCPSchemas: {
580
580
  }, "strip", z.ZodTypeAny, {
581
581
  value?: any;
582
582
  action?: "get" | "set" | "reset";
583
- key?: string;
584
583
  scope?: "user" | "global";
584
+ key?: string;
585
585
  }, {
586
586
  value?: any;
587
587
  action?: "get" | "set" | "reset";
588
- key?: string;
589
588
  scope?: "user" | "global";
589
+ key?: string;
590
590
  }>;
591
591
  };
592
592
  operations: {
@@ -17,6 +17,7 @@ export declare class CLIMCPServer {
17
17
  * Start MCP server using CLI configuration
18
18
  */
19
19
  start(options?: MCPServerOptions): Promise<void>;
20
+ private resolveMCPServerPath;
20
21
  /**
21
22
  * Start local MCP server using CLI auth config
22
23
  */
@@ -6,10 +6,13 @@
6
6
  */
7
7
  import { fileURLToPath } from 'url';
8
8
  import { dirname, join } from 'path';
9
+ import { existsSync } from 'fs';
10
+ import { createRequire } from 'module';
9
11
  import { spawn } from 'child_process';
10
12
  import { CLIConfig } from './utils/config.js';
11
13
  const __filename = fileURLToPath(import.meta.url);
12
14
  const __dirname = dirname(__filename);
15
+ const nodeRequire = createRequire(import.meta.url);
13
16
  export class CLIMCPServer {
14
17
  config;
15
18
  constructor() {
@@ -28,6 +31,33 @@ export class CLIMCPServer {
28
31
  await this.startLocalMCP(options);
29
32
  }
30
33
  }
34
+ resolveMCPServerPath() {
35
+ const candidates = new Set();
36
+ if (process.env.MCP_SERVER_PATH) {
37
+ candidates.add(process.env.MCP_SERVER_PATH);
38
+ }
39
+ const packageRequests = [
40
+ '@lanonasis/mcp-server/dist/cli-aligned-mcp-server.js',
41
+ 'lanonasis-mcp-server/dist/cli-aligned-mcp-server.js'
42
+ ];
43
+ for (const request of packageRequests) {
44
+ try {
45
+ const resolved = nodeRequire.resolve(request);
46
+ candidates.add(resolved);
47
+ }
48
+ catch {
49
+ // Ignore resolution failures and continue through fallbacks
50
+ }
51
+ }
52
+ candidates.add(join(process.cwd(), 'mcp-server/dist/cli-aligned-mcp-server.js'));
53
+ candidates.add(join(__dirname, '../../../mcp-server/dist/cli-aligned-mcp-server.js'));
54
+ for (const candidate of candidates) {
55
+ if (candidate && existsSync(candidate)) {
56
+ return candidate;
57
+ }
58
+ }
59
+ throw new Error('Unable to locate the CLI-aligned MCP server. Set MCP_SERVER_PATH or install @lanonasis/mcp-server.');
60
+ }
31
61
  /**
32
62
  * Start local MCP server using CLI auth config
33
63
  */
@@ -42,10 +72,11 @@ export class CLIMCPServer {
42
72
  console.error(`Config: ~/.maas/config.json`);
43
73
  console.error(`Auth: ${this.config.hasVendorKey() ? 'Vendor Key' : 'JWT Token'}`);
44
74
  }
75
+ const resolvedPort = typeof port === 'number' && !Number.isNaN(port) ? port : 3001;
45
76
  // Set environment variables from CLI config
46
77
  const env = {
47
78
  ...process.env,
48
- PORT: port?.toString(),
79
+ PORT: resolvedPort.toString(),
49
80
  MEMORY_API_URL: this.config.getApiUrl(),
50
81
  LANONASIS_VENDOR_KEY: this.config.getVendorKey(),
51
82
  LANONASIS_TOKEN: this.config.getToken(),
@@ -84,14 +115,13 @@ export class CLIMCPServer {
84
115
  */
85
116
  async startRemoteMCP(options) {
86
117
  const { verbose } = options;
118
+ const message = 'Remote MCP not implemented; remove --remote or use local mode.';
87
119
  if (verbose) {
88
120
  console.error('🌐 Connecting to remote MCP server...');
89
121
  console.error(`URL: ${this.config.getMCPServerUrl()}`);
90
122
  }
91
- // For remote MCP, we'd need to implement a proxy or client
92
- // For now, fall back to local mode
93
- console.error('⚠️ Remote MCP not yet implemented, falling back to local mode');
94
- await this.startLocalMCP({ ...options, useRemote: false });
123
+ console.error(`❌ ${message}`);
124
+ throw new Error(message);
95
125
  }
96
126
  /**
97
127
  * Check if MCP server is available and configured
@@ -149,6 +179,10 @@ Examples:
149
179
  await server.start(options);
150
180
  }
151
181
  if (import.meta.url === `file://${process.argv[1]}`) {
152
- main().catch(console.error);
182
+ main().catch(error => {
183
+ const message = error instanceof Error ? error.message : String(error);
184
+ console.error(message);
185
+ process.exit(1);
186
+ });
153
187
  }
154
188
  export default CLIMCPServer;
package/dist/utils/api.js CHANGED
@@ -27,7 +27,7 @@ export class APIClient {
27
27
  const token = this.config.getToken();
28
28
  const vendorKey = this.config.getVendorKey();
29
29
  if (vendorKey) {
30
- // Vendor key authentication (pk_*.sk_* format)
30
+ // Vendor key authentication (validated server-side)
31
31
  config.headers['X-API-Key'] = vendorKey;
32
32
  config.headers['X-Auth-Method'] = 'vendor_key';
33
33
  }
@@ -43,6 +43,14 @@ export declare class CLIConfig {
43
43
  private authCheckCache;
44
44
  private readonly AUTH_CACHE_TTL;
45
45
  constructor();
46
+ /**
47
+ * Overrides the configuration storage directory. Primarily used for tests.
48
+ */
49
+ setConfigDirectory(configDir: string): void;
50
+ /**
51
+ * Exposes the current config path for tests and diagnostics.
52
+ */
53
+ getConfigPath(): string;
46
54
  init(): Promise<void>;
47
55
  load(): Promise<void>;
48
56
  private migrateConfigIfNeeded;
@@ -55,6 +63,8 @@ export declare class CLIConfig {
55
63
  discoverServices(verbose?: boolean): Promise<void>;
56
64
  private handleServiceDiscoveryFailure;
57
65
  private categorizeServiceDiscoveryError;
66
+ private resolveFallbackEndpoints;
67
+ private logFallbackUsage;
58
68
  setManualEndpoints(endpoints: Partial<CLIConfigData['discoveredServices']>): Promise<void>;
59
69
  hasManualEndpointOverrides(): boolean;
60
70
  clearManualEndpointOverrides(): Promise<void>;
@@ -71,7 +81,6 @@ export declare class CLIConfig {
71
81
  isAuthenticated(): Promise<boolean>;
72
82
  logout(): Promise<void>;
73
83
  clear(): Promise<void>;
74
- getConfigPath(): string;
75
84
  exists(): Promise<boolean>;
76
85
  validateStoredCredentials(): Promise<boolean>;
77
86
  refreshTokenIfNeeded(): Promise<void>;
@@ -16,6 +16,20 @@ export class CLIConfig {
16
16
  this.configPath = path.join(this.configDir, 'config.json');
17
17
  this.lockFile = path.join(this.configDir, 'config.lock');
18
18
  }
19
+ /**
20
+ * Overrides the configuration storage directory. Primarily used for tests.
21
+ */
22
+ setConfigDirectory(configDir) {
23
+ this.configDir = configDir;
24
+ this.configPath = path.join(configDir, 'config.json');
25
+ this.lockFile = path.join(configDir, 'config.lock');
26
+ }
27
+ /**
28
+ * Exposes the current config path for tests and diagnostics.
29
+ */
30
+ getConfigPath() {
31
+ return this.configPath;
32
+ }
19
33
  async init() {
20
34
  try {
21
35
  await fs.mkdir(this.configDir, { recursive: true });
@@ -229,20 +243,17 @@ export class CLIConfig {
229
243
  return;
230
244
  }
231
245
  }
232
- // Set fallback service endpoints
246
+ const fallback = this.resolveFallbackEndpoints();
233
247
  this.config.discoveredServices = {
234
- auth_base: 'https://api.lanonasis.com', // CLI auth goes to central auth system
235
- memory_base: 'https://api.lanonasis.com/api/v1', // Memory via onasis-core
236
- mcp_base: 'https://mcp.lanonasis.com/api/v1', // MCP HTTP/REST
237
- mcp_ws_base: 'wss://mcp.lanonasis.com/ws', // MCP WebSocket
238
- mcp_sse_base: 'https://mcp.lanonasis.com/api/v1/events', // MCP SSE
239
- project_scope: 'lanonasis-maas' // Correct project scope
248
+ ...fallback.endpoints,
249
+ project_scope: 'lanonasis-maas'
240
250
  };
241
251
  // Mark as fallback (don't set lastServiceDiscovery)
242
252
  await this.save();
253
+ this.logFallbackUsage(fallback.source, this.config.discoveredServices);
243
254
  if (verbose) {
244
255
  console.log('✓ Using fallback service endpoints');
245
- console.log(' These are the standard production endpoints');
256
+ console.log(` Source: ${fallback.source === 'environment' ? 'environment overrides' : 'built-in defaults'}`);
246
257
  }
247
258
  }
248
259
  categorizeServiceDiscoveryError(error) {
@@ -272,6 +283,47 @@ export class CLIConfig {
272
283
  }
273
284
  return 'unknown';
274
285
  }
286
+ resolveFallbackEndpoints() {
287
+ const envAuthBase = process.env.LANONASIS_FALLBACK_AUTH_BASE ?? process.env.AUTH_BASE;
288
+ const envMemoryBase = process.env.LANONASIS_FALLBACK_MEMORY_BASE ?? process.env.MEMORY_BASE;
289
+ const envMcpBase = process.env.LANONASIS_FALLBACK_MCP_BASE ?? process.env.MCP_BASE;
290
+ const envMcpWsBase = process.env.LANONASIS_FALLBACK_MCP_WS_BASE ?? process.env.MCP_WS_BASE;
291
+ const envMcpSseBase = process.env.LANONASIS_FALLBACK_MCP_SSE_BASE ?? process.env.MCP_SSE_BASE;
292
+ const hasEnvOverrides = Boolean(envAuthBase || envMemoryBase || envMcpBase || envMcpWsBase || envMcpSseBase);
293
+ const nodeEnv = (process.env.NODE_ENV ?? '').toLowerCase();
294
+ const isDevEnvironment = nodeEnv === 'development' || nodeEnv === 'test';
295
+ const defaultAuthBase = isDevEnvironment ? 'http://localhost:4000' : 'https://api.lanonasis.com';
296
+ const defaultMemoryBase = isDevEnvironment ? 'http://localhost:4000/api/v1' : 'https://api.lanonasis.com/api/v1';
297
+ const defaultMcpBase = isDevEnvironment ? 'http://localhost:4100/api/v1' : 'https://mcp.lanonasis.com/api/v1';
298
+ const defaultMcpWsBase = isDevEnvironment ? 'ws://localhost:4100/ws' : 'wss://mcp.lanonasis.com/ws';
299
+ const defaultMcpSseBase = isDevEnvironment ? 'http://localhost:4100/api/v1/events' : 'https://mcp.lanonasis.com/api/v1/events';
300
+ const endpoints = {
301
+ auth_base: envAuthBase ?? defaultAuthBase,
302
+ memory_base: envMemoryBase ?? defaultMemoryBase,
303
+ mcp_base: envMcpBase ?? defaultMcpBase,
304
+ mcp_ws_base: envMcpWsBase ?? defaultMcpWsBase,
305
+ mcp_sse_base: envMcpSseBase ?? defaultMcpSseBase
306
+ };
307
+ return {
308
+ endpoints,
309
+ source: hasEnvOverrides ? 'environment' : 'default'
310
+ };
311
+ }
312
+ logFallbackUsage(source, endpoints) {
313
+ const summary = {
314
+ auth: endpoints.auth_base,
315
+ mcp: endpoints.mcp_base,
316
+ websocket: endpoints.mcp_ws_base,
317
+ sse: endpoints.mcp_sse_base,
318
+ source
319
+ };
320
+ const message = `Service discovery fallback activated using ${source === 'environment' ? 'environment overrides' : 'built-in defaults'}`;
321
+ console.warn(`⚠️ ${message}`);
322
+ console.info('📊 service_discovery_fallback', summary);
323
+ if (typeof process.emitWarning === 'function') {
324
+ process.emitWarning(message, 'ServiceDiscoveryFallback');
325
+ }
326
+ }
275
327
  // Manual endpoint override functionality
276
328
  async setManualEndpoints(endpoints) {
277
329
  if (!this.config.discoveredServices) {
@@ -302,55 +354,25 @@ export class CLIConfig {
302
354
  }
303
355
  // Enhanced authentication support
304
356
  async setVendorKey(vendorKey) {
305
- // Enhanced format validation with detailed error messages
306
- const formatValidation = this.validateVendorKeyFormat(vendorKey);
357
+ const trimmedKey = typeof vendorKey === 'string' ? vendorKey.trim() : '';
358
+ // Minimal format validation (non-empty); rely on server-side checks for everything else
359
+ const formatValidation = this.validateVendorKeyFormat(trimmedKey);
307
360
  if (formatValidation !== true) {
308
- throw new Error(typeof formatValidation === 'string' ? formatValidation : 'Invalid vendor key format');
361
+ throw new Error(typeof formatValidation === 'string' ? formatValidation : 'Vendor key is invalid');
309
362
  }
310
363
  // Server-side validation
311
- await this.validateVendorKeyWithServer(vendorKey);
312
- this.config.vendorKey = vendorKey;
364
+ await this.validateVendorKeyWithServer(trimmedKey);
365
+ this.config.vendorKey = trimmedKey;
313
366
  this.config.authMethod = 'vendor_key';
314
367
  this.config.lastValidated = new Date().toISOString();
315
368
  await this.resetFailureCount(); // Reset failure count on successful auth
316
369
  await this.save();
317
370
  }
318
371
  validateVendorKeyFormat(vendorKey) {
319
- if (!vendorKey || vendorKey.trim().length === 0) {
372
+ const trimmed = typeof vendorKey === 'string' ? vendorKey.trim() : '';
373
+ if (!trimmed) {
320
374
  return 'Vendor key is required';
321
375
  }
322
- const trimmed = vendorKey.trim();
323
- // Check basic format
324
- if (!trimmed.includes('.')) {
325
- return 'Invalid vendor key format: Must contain a dot (.) separator. Expected format: pk_xxx.sk_xxx';
326
- }
327
- const parts = trimmed.split('.');
328
- if (parts.length !== 2) {
329
- return 'Invalid vendor key format: Must have exactly two parts separated by a dot. Expected format: pk_xxx.sk_xxx';
330
- }
331
- const [publicPart, secretPart] = parts;
332
- // Validate public key part
333
- if (!publicPart.startsWith('pk_')) {
334
- return 'Invalid vendor key format: First part must start with "pk_". Expected format: pk_xxx.sk_xxx';
335
- }
336
- if (publicPart.length < 11) { // pk_ + minimum 8 chars
337
- return 'Invalid vendor key format: Public key part is too short. Expected format: pk_xxx.sk_xxx (minimum 8 characters after "pk_")';
338
- }
339
- const publicKeyContent = publicPart.substring(3); // Remove 'pk_'
340
- if (!/^[a-zA-Z0-9]+$/.test(publicKeyContent)) {
341
- return 'Invalid vendor key format: Public key part contains invalid characters. Only letters and numbers are allowed after "pk_"';
342
- }
343
- // Validate secret key part
344
- if (!secretPart.startsWith('sk_')) {
345
- return 'Invalid vendor key format: Second part must start with "sk_". Expected format: pk_xxx.sk_xxx';
346
- }
347
- if (secretPart.length < 19) { // sk_ + minimum 16 chars
348
- return 'Invalid vendor key format: Secret key part is too short. Expected format: pk_xxx.sk_xxx (minimum 16 characters after "sk_")';
349
- }
350
- const secretKeyContent = secretPart.substring(3); // Remove 'sk_'
351
- if (!/^[a-zA-Z0-9]+$/.test(secretKeyContent)) {
352
- return 'Invalid vendor key format: Secret key part contains invalid characters. Only letters and numbers are allowed after "sk_"';
353
- }
354
376
  return true;
355
377
  }
356
378
  async validateVendorKeyWithServer(vendorKey) {
@@ -524,7 +546,17 @@ export class CLIConfig {
524
546
  this.authCheckCache = { isValid: false, timestamp: Date.now() };
525
547
  return false;
526
548
  }
527
- // Verify with server (security check)
549
+ // Token is locally valid - check if we need server validation
550
+ // Skip server validation if we have a recent lastValidated timestamp (within 24 hours)
551
+ const lastValidated = this.config.lastValidated;
552
+ const skipServerValidation = lastValidated &&
553
+ (Date.now() - new Date(lastValidated).getTime()) < (24 * 60 * 60 * 1000); // 24 hours
554
+ if (skipServerValidation) {
555
+ // Trust the local validation if it was recently validated
556
+ this.authCheckCache = { isValid: locallyValid, timestamp: Date.now() };
557
+ return locallyValid;
558
+ }
559
+ // Verify with server (security check) for tokens that haven't been validated recently
528
560
  try {
529
561
  const axios = (await import('axios')).default;
530
562
  // Try auth-gateway first (port 4000), then fall back to Netlify function
@@ -547,16 +579,29 @@ export class CLIConfig {
547
579
  }
548
580
  }
549
581
  if (!response || response.data.valid !== true) {
582
+ // Server says invalid - but if locally valid and recent, trust local
583
+ if (locallyValid) {
584
+ if (process.env.CLI_VERBOSE === 'true') {
585
+ console.warn('⚠️ Server validation failed, but token is locally valid - using local validation');
586
+ }
587
+ this.authCheckCache = { isValid: locallyValid, timestamp: Date.now() };
588
+ return locallyValid;
589
+ }
550
590
  this.authCheckCache = { isValid: false, timestamp: Date.now() };
551
591
  return false;
552
592
  }
593
+ // Update lastValidated on successful server validation
594
+ this.config.lastValidated = new Date().toISOString();
595
+ await this.save().catch(() => { }); // Don't fail auth check if save fails
553
596
  this.authCheckCache = { isValid: true, timestamp: Date.now() };
554
597
  return true;
555
598
  }
556
599
  catch {
557
600
  // If all server checks fail, fall back to local validation
558
601
  // This allows offline usage but is less secure
559
- console.warn('⚠️ Unable to verify token with server, using local validation');
602
+ if (process.env.CLI_VERBOSE === 'true') {
603
+ console.warn('⚠️ Unable to verify token with server, using local validation');
604
+ }
560
605
  this.authCheckCache = { isValid: locallyValid, timestamp: Date.now() };
561
606
  return locallyValid;
562
607
  }
@@ -570,9 +615,6 @@ export class CLIConfig {
570
615
  this.config = {};
571
616
  await this.save();
572
617
  }
573
- getConfigPath() {
574
- return this.configPath;
575
- }
576
618
  async exists() {
577
619
  try {
578
620
  await fs.access(this.configPath);
@@ -656,9 +698,12 @@ export class CLIConfig {
656
698
  }
657
699
  }
658
700
  }
659
- catch {
701
+ catch (err) {
660
702
  // If refresh fails, mark credentials as potentially invalid
661
703
  await this.incrementFailureCount();
704
+ if (process.env.CLI_VERBOSE === 'true' || process.env.NODE_ENV !== 'production') {
705
+ console.debug('Token refresh failed:', err.message);
706
+ }
662
707
  }
663
708
  }
664
709
  async clearInvalidCredentials() {
@@ -57,6 +57,23 @@ export declare class MCPClient {
57
57
  private lastHealthCheck;
58
58
  private activeConnectionMode;
59
59
  constructor();
60
+ /**
61
+ * Overrides the configuration directory used by the underlying CLI config.
62
+ * Useful for tests that need isolated config state.
63
+ */
64
+ setConfigDirectory(configDir: string): void;
65
+ /**
66
+ * Returns the current config file path. Primarily used for test introspection.
67
+ */
68
+ getConfigPath(): string;
69
+ /**
70
+ * Helper for tests to seed authentication tokens without accessing internals.
71
+ */
72
+ setTokenForTesting(token: string): Promise<void>;
73
+ /**
74
+ * Helper for tests to seed vendor keys without accessing internals.
75
+ */
76
+ setVendorKeyForTesting(vendorKey: string): Promise<void>;
60
77
  /**
61
78
  * Initialize the MCP client configuration
62
79
  */
@@ -93,10 +110,6 @@ export declare class MCPClient {
93
110
  * Validate authentication credentials before attempting MCP connection
94
111
  */
95
112
  private validateAuthBeforeConnect;
96
- /**
97
- * Validate vendor key format
98
- */
99
- private validateVendorKeyFormat;
100
113
  /**
101
114
  * Validate and refresh token if needed
102
115
  */
@@ -20,6 +20,31 @@ export class MCPClient {
20
20
  constructor() {
21
21
  this.config = new CLIConfig();
22
22
  }
23
+ /**
24
+ * Overrides the configuration directory used by the underlying CLI config.
25
+ * Useful for tests that need isolated config state.
26
+ */
27
+ setConfigDirectory(configDir) {
28
+ this.config.setConfigDirectory(configDir);
29
+ }
30
+ /**
31
+ * Returns the current config file path. Primarily used for test introspection.
32
+ */
33
+ getConfigPath() {
34
+ return this.config.getConfigPath();
35
+ }
36
+ /**
37
+ * Helper for tests to seed authentication tokens without accessing internals.
38
+ */
39
+ async setTokenForTesting(token) {
40
+ await this.config.setToken(token);
41
+ }
42
+ /**
43
+ * Helper for tests to seed vendor keys without accessing internals.
44
+ */
45
+ async setVendorKeyForTesting(vendorKey) {
46
+ await this.config.setVendorKey(vendorKey);
47
+ }
23
48
  /**
24
49
  * Initialize the MCP client configuration
25
50
  */
@@ -220,11 +245,10 @@ export class MCPClient {
220
245
  const msg = error?.message ?? '';
221
246
  if (msg.includes('AUTHENTICATION_REQUIRED')) {
222
247
  console.log(chalk.cyan('• No credentials found. Run: lanonasis auth login'));
223
- console.log(chalk.cyan('• Or set vendor key: lanonasis auth login --vendor-key pk_xxx.sk_xxx'));
248
+ console.log(chalk.cyan('• Or set a vendor key: lanonasis auth login --vendor-key <your-key>'));
224
249
  }
225
250
  else if (msg.includes('AUTHENTICATION_INVALID')) {
226
- console.log(chalk.cyan('• Invalid credentials. Check your vendor key format'));
227
- console.log(chalk.cyan('• Expected format: pk_xxx.sk_xxx'));
251
+ console.log(chalk.cyan('• Invalid credentials. Confirm the vendor key matches your dashboard value'));
228
252
  console.log(chalk.cyan('• Try: lanonasis auth logout && lanonasis auth login'));
229
253
  }
230
254
  else if (msg.includes('expired')) {
@@ -234,7 +258,7 @@ export class MCPClient {
234
258
  else {
235
259
  console.log(chalk.cyan('• Check authentication status: lanonasis auth status'));
236
260
  console.log(chalk.cyan('• Re-authenticate: lanonasis auth login'));
237
- console.log(chalk.cyan('• Verify vendor key: lanonasis auth login --vendor-key pk_xxx.sk_xxx'));
261
+ console.log(chalk.cyan('• Verify vendor key: lanonasis auth login --vendor-key <your-key>'));
238
262
  }
239
263
  }
240
264
  /**
@@ -306,21 +330,14 @@ export class MCPClient {
306
330
  throw new Error(`AUTHENTICATION_INVALID: ${error instanceof Error ? error.message : 'Token validation failed'}`);
307
331
  }
308
332
  }
309
- // If we have a vendor key, validate its format
333
+ // If we have a vendor key, ensure it is valid (non-empty)
310
334
  if (vendorKey && !token) {
311
- if (!this.validateVendorKeyFormat(vendorKey)) {
312
- throw new Error('AUTHENTICATION_INVALID: Invalid vendor key format. Expected format: pk_xxx.sk_xxx');
335
+ const validationResult = this.config.validateVendorKeyFormat(vendorKey);
336
+ if (validationResult !== true) {
337
+ throw new Error(`AUTHENTICATION_INVALID: ${typeof validationResult === 'string' ? validationResult : 'Vendor key is invalid'}`);
313
338
  }
314
339
  }
315
340
  }
316
- /**
317
- * Validate vendor key format
318
- */
319
- validateVendorKeyFormat(vendorKey) {
320
- // Vendor key should be in format: pk_xxx.sk_xxx
321
- const vendorKeyPattern = /^pk_[a-zA-Z0-9]+\.sk_[a-zA-Z0-9]+$/;
322
- return vendorKeyPattern.test(vendorKey);
323
- }
324
341
  /**
325
342
  * Validate and refresh token if needed
326
343
  */
@@ -528,7 +545,7 @@ export class MCPClient {
528
545
  }
529
546
  try {
530
547
  this.lastHealthCheck = new Date();
531
- const connectionMode = this.config.get('mcpConnectionMode') ?? 'remote';
548
+ const connectionMode = this.activeConnectionMode || 'remote';
532
549
  switch (connectionMode) {
533
550
  case 'websocket':
534
551
  await this.checkWebSocketHealth();
@@ -542,7 +559,8 @@ export class MCPClient {
542
559
  }
543
560
  }
544
561
  catch {
545
- console.log(chalk.yellow('⚠️ Health check failed, attempting reconnection...'));
562
+ const connectionMode = this.activeConnectionMode || 'remote';
563
+ console.log(chalk.yellow(`⚠️ ${connectionMode} connection health check failed, attempting reconnection...`));
546
564
  await this.handleHealthCheckFailure();
547
565
  }
548
566
  }
@@ -607,10 +625,11 @@ export class MCPClient {
607
625
  this.isConnected = false;
608
626
  this.stopHealthMonitoring();
609
627
  // Attempt to reconnect with current configuration
610
- const connectionMode = this.config.get('mcpConnectionMode') ?? 'remote';
628
+ const connectionMode = (this.activeConnectionMode || 'remote');
611
629
  const options = {
612
- connectionMode: connectionMode
630
+ connectionMode
613
631
  };
632
+ console.log(chalk.yellow(`↻ Attempting reconnection using ${connectionMode} mode...`));
614
633
  // Add specific URLs if available
615
634
  if (connectionMode === 'websocket') {
616
635
  options.serverUrl = this.config.get('mcpWebSocketUrl');
@@ -648,7 +667,7 @@ export class MCPClient {
648
667
  this.wsConnection = null;
649
668
  }
650
669
  this.isConnected = false;
651
- this.activeConnectionMode = 'local'; // Reset to default
670
+ this.activeConnectionMode = 'websocket'; // Reset to default
652
671
  }
653
672
  /**
654
673
  * Call an MCP tool
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lanonasis/cli",
3
- "version": "3.4.15",
3
+ "version": "3.6.0",
4
4
  "description": "LanOnasis Enterprise CLI - Memory as a Service, API Key Management, and Infrastructure Orchestration",
5
5
  "main": "dist/index-simple.js",
6
6
  "bin": {
@@ -99,4 +99,4 @@
99
99
  "engines": {
100
100
  "node": ">=18.0.0"
101
101
  }
102
- }
102
+ }