@lanonasis/cli 3.0.13 → 3.2.14

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.
@@ -2,16 +2,19 @@ import * as fs from 'fs/promises';
2
2
  import * as path from 'path';
3
3
  import * as os from 'os';
4
4
  import { jwtDecode } from 'jwt-decode';
5
- import { fileURLToPath } from 'url';
6
- const __filename = fileURLToPath(import.meta.url);
7
- const __dirname = path.dirname(__filename);
5
+ import { randomUUID } from 'crypto';
8
6
  export class CLIConfig {
9
7
  configDir;
10
8
  configPath;
11
9
  config = {};
10
+ lockFile;
11
+ static CONFIG_VERSION = '1.0.0';
12
+ authCheckCache = null;
13
+ AUTH_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
12
14
  constructor() {
13
15
  this.configDir = path.join(os.homedir(), '.maas');
14
16
  this.configPath = path.join(this.configDir, 'config.json');
17
+ this.lockFile = path.join(this.configDir, 'config.lock');
15
18
  }
16
19
  async init() {
17
20
  try {
@@ -26,28 +29,145 @@ export class CLIConfig {
26
29
  try {
27
30
  const data = await fs.readFile(this.configPath, 'utf-8');
28
31
  this.config = JSON.parse(data);
32
+ // Handle version migration if needed
33
+ await this.migrateConfigIfNeeded();
29
34
  }
30
35
  catch {
31
36
  this.config = {};
37
+ // Set version for new config
38
+ this.config.version = CLIConfig.CONFIG_VERSION;
39
+ }
40
+ }
41
+ async migrateConfigIfNeeded() {
42
+ const currentVersion = this.config.version;
43
+ if (!currentVersion) {
44
+ // Legacy config without version, migrate to current version
45
+ this.config.version = CLIConfig.CONFIG_VERSION;
46
+ // Perform any necessary migrations for legacy configs
47
+ // For now, just ensure the version is set
48
+ await this.save();
49
+ }
50
+ else if (currentVersion !== CLIConfig.CONFIG_VERSION) {
51
+ // Future version migrations would go here
52
+ // For now, just update the version
53
+ this.config.version = CLIConfig.CONFIG_VERSION;
54
+ await this.save();
32
55
  }
33
56
  }
34
57
  async save() {
58
+ await this.atomicSave();
59
+ }
60
+ async atomicSave() {
35
61
  await fs.mkdir(this.configDir, { recursive: true });
36
- this.config.lastUpdated = new Date().toISOString();
37
- await fs.writeFile(this.configPath, JSON.stringify(this.config, null, 2));
62
+ // Acquire file lock to prevent concurrent access
63
+ const lockAcquired = await this.acquireLock();
64
+ if (!lockAcquired) {
65
+ throw new Error('Could not acquire configuration lock. Another process may be modifying the config.');
66
+ }
67
+ try {
68
+ // Set version and update timestamp
69
+ this.config.version = CLIConfig.CONFIG_VERSION;
70
+ this.config.lastUpdated = new Date().toISOString();
71
+ // Create temporary file with unique name
72
+ const tempPath = `${this.configPath}.tmp.${randomUUID()}`;
73
+ // Write to temporary file first
74
+ await fs.writeFile(tempPath, JSON.stringify(this.config, null, 2), 'utf-8');
75
+ // Atomic rename - this is the critical atomic operation
76
+ await fs.rename(tempPath, this.configPath);
77
+ }
78
+ finally {
79
+ // Always release the lock
80
+ await this.releaseLock();
81
+ }
82
+ }
83
+ async backupConfig() {
84
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
85
+ const backupPath = path.join(this.configDir, `config.backup.${timestamp}.json`);
86
+ try {
87
+ // Check if config exists before backing up
88
+ await fs.access(this.configPath);
89
+ await fs.copyFile(this.configPath, backupPath);
90
+ return backupPath;
91
+ }
92
+ catch (error) {
93
+ if (error.code === 'ENOENT') {
94
+ // Config doesn't exist, create empty backup
95
+ await fs.writeFile(backupPath, JSON.stringify({}, null, 2));
96
+ return backupPath;
97
+ }
98
+ throw error;
99
+ }
100
+ }
101
+ async acquireLock(timeoutMs = 5000) {
102
+ const startTime = Date.now();
103
+ while (Date.now() - startTime < timeoutMs) {
104
+ try {
105
+ // Try to create lock file exclusively
106
+ await fs.writeFile(this.lockFile, process.pid.toString(), { flag: 'wx' });
107
+ return true;
108
+ }
109
+ catch (error) {
110
+ if (error.code === 'EEXIST') {
111
+ // Lock file exists, check if process is still running
112
+ try {
113
+ const pidStr = await fs.readFile(this.lockFile, 'utf-8');
114
+ const pid = parseInt(pidStr.trim());
115
+ if (!isNaN(pid)) {
116
+ try {
117
+ // Check if process is still running (works on Unix-like systems)
118
+ process.kill(pid, 0);
119
+ // Process is running, wait and retry
120
+ await new Promise(resolve => setTimeout(resolve, 100));
121
+ continue;
122
+ }
123
+ catch {
124
+ // Process is not running, remove stale lock
125
+ await fs.unlink(this.lockFile).catch(() => { });
126
+ continue;
127
+ }
128
+ }
129
+ }
130
+ catch {
131
+ // Can't read lock file, remove it and retry
132
+ await fs.unlink(this.lockFile).catch(() => { });
133
+ continue;
134
+ }
135
+ }
136
+ else {
137
+ throw error;
138
+ }
139
+ }
140
+ }
141
+ return false;
142
+ }
143
+ async releaseLock() {
144
+ try {
145
+ await fs.unlink(this.lockFile);
146
+ }
147
+ catch {
148
+ // Lock file might not exist or already removed, ignore
149
+ }
38
150
  }
39
151
  getApiUrl() {
40
152
  return process.env.MEMORY_API_URL ||
41
153
  this.config.apiUrl ||
42
154
  'https://api.lanonasis.com/api/v1';
43
155
  }
44
- // Service Discovery Integration
45
- async discoverServices() {
156
+ // Enhanced Service Discovery Integration
157
+ async discoverServices(verbose = false) {
158
+ const discoveryUrl = 'https://mcp.lanonasis.com/.well-known/onasis.json';
46
159
  try {
47
160
  // Use axios instead of fetch for consistency
48
161
  const axios = (await import('axios')).default;
49
- const discoveryUrl = 'https://mcp.lanonasis.com/.well-known/onasis.json';
50
- const response = await axios.get(discoveryUrl);
162
+ if (verbose) {
163
+ console.log(`🔍 Discovering services from ${discoveryUrl}...`);
164
+ }
165
+ const response = await axios.get(discoveryUrl, {
166
+ timeout: 10000,
167
+ headers: {
168
+ 'User-Agent': 'Lanonasis-CLI/3.0.13'
169
+ }
170
+ });
51
171
  // Map discovery response to our config format
52
172
  const discovered = response.data;
53
173
  this.config.discoveredServices = {
@@ -58,38 +178,241 @@ export class CLIConfig {
58
178
  mcp_sse_base: discovered.endpoints?.sse || 'https://mcp.lanonasis.com/api/v1/events',
59
179
  project_scope: 'lanonasis-maas'
60
180
  };
181
+ // Mark discovery as successful
182
+ this.config.lastServiceDiscovery = new Date().toISOString();
61
183
  await this.save();
184
+ if (verbose) {
185
+ console.log('✓ Service discovery completed successfully');
186
+ console.log(` Auth: ${this.config.discoveredServices.auth_base}`);
187
+ console.log(` MCP: ${this.config.discoveredServices.mcp_base}`);
188
+ console.log(` WebSocket: ${this.config.discoveredServices.mcp_ws_base}`);
189
+ }
62
190
  }
63
- catch {
64
- // Service discovery failed, use fallback defaults
65
- if (process.env.CLI_VERBOSE === 'true') {
66
- console.log('Service discovery failed, using fallback defaults');
191
+ catch (error) {
192
+ // Enhanced error handling with user-visible messages
193
+ await this.handleServiceDiscoveryFailure(error, verbose);
194
+ }
195
+ }
196
+ async handleServiceDiscoveryFailure(error, verbose) {
197
+ const errorType = this.categorizeServiceDiscoveryError(error);
198
+ if (verbose || process.env.CLI_VERBOSE === 'true') {
199
+ console.log('⚠️ Service discovery failed, using cached/fallback endpoints');
200
+ switch (errorType) {
201
+ case 'network_error':
202
+ console.log(' Reason: Network connection failed');
203
+ console.log(' This is normal when offline or behind restrictive firewalls');
204
+ break;
205
+ case 'timeout':
206
+ console.log(' Reason: Request timed out');
207
+ console.log(' The discovery service may be temporarily slow');
208
+ break;
209
+ case 'server_error':
210
+ console.log(' Reason: Discovery service returned an error');
211
+ console.log(' The service may be temporarily unavailable');
212
+ break;
213
+ case 'invalid_response':
214
+ console.log(' Reason: Invalid response format from discovery service');
215
+ console.log(' Using known working endpoints instead');
216
+ break;
217
+ default:
218
+ console.log(` Reason: ${error.message || 'Unknown error'}`);
67
219
  }
68
- // Set fallback service endpoints to prevent double slash issues
69
- // Use mcp.lanonasis.com for MCP services (proxied to port 3001)
70
- this.config.discoveredServices = {
71
- auth_base: 'https://api.lanonasis.com', // CLI auth goes to central auth system
72
- memory_base: 'https://api.lanonasis.com/api/v1', // Memory via onasis-core
73
- mcp_base: 'https://mcp.lanonasis.com/api/v1', // MCP HTTP/REST
74
- mcp_ws_base: 'wss://mcp.lanonasis.com/ws', // MCP WebSocket
75
- mcp_sse_base: 'https://mcp.lanonasis.com/api/v1/events', // MCP SSE
76
- project_scope: 'lanonasis-maas' // Correct project scope
77
- };
78
- await this.save();
79
220
  }
221
+ // Use cached endpoints if available and recent (within 24 hours)
222
+ if (this.config.discoveredServices && this.config.lastServiceDiscovery) {
223
+ const lastDiscovery = new Date(this.config.lastServiceDiscovery);
224
+ const hoursSinceDiscovery = (Date.now() - lastDiscovery.getTime()) / (1000 * 60 * 60);
225
+ if (hoursSinceDiscovery < 24) {
226
+ if (verbose) {
227
+ console.log('✓ Using cached service endpoints (less than 24 hours old)');
228
+ }
229
+ return;
230
+ }
231
+ }
232
+ // Set fallback service endpoints
233
+ 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
240
+ };
241
+ // Mark as fallback (don't set lastServiceDiscovery)
242
+ await this.save();
243
+ if (verbose) {
244
+ console.log('✓ Using fallback service endpoints');
245
+ console.log(' These are the standard production endpoints');
246
+ }
247
+ }
248
+ categorizeServiceDiscoveryError(error) {
249
+ if (error.code) {
250
+ switch (error.code) {
251
+ case 'ECONNREFUSED':
252
+ case 'ENOTFOUND':
253
+ case 'ECONNRESET':
254
+ case 'ENETUNREACH':
255
+ return 'network_error';
256
+ case 'ETIMEDOUT':
257
+ return 'timeout';
258
+ }
259
+ }
260
+ if (error.response?.status >= 500) {
261
+ return 'server_error';
262
+ }
263
+ if (error.response?.status === 404) {
264
+ return 'invalid_response';
265
+ }
266
+ const message = error.message?.toLowerCase() || '';
267
+ if (message.includes('timeout')) {
268
+ return 'timeout';
269
+ }
270
+ if (message.includes('network') || message.includes('connection')) {
271
+ return 'network_error';
272
+ }
273
+ return 'unknown';
274
+ }
275
+ // Manual endpoint override functionality
276
+ async setManualEndpoints(endpoints) {
277
+ if (!this.config.discoveredServices) {
278
+ // Initialize with defaults first
279
+ await this.discoverServices();
280
+ }
281
+ // Merge manual overrides with existing endpoints
282
+ this.config.discoveredServices = {
283
+ ...this.config.discoveredServices,
284
+ ...endpoints
285
+ };
286
+ // Mark as manually configured
287
+ this.config.manualEndpointOverrides = true;
288
+ this.config.lastManualEndpointUpdate = new Date().toISOString();
289
+ await this.save();
290
+ }
291
+ hasManualEndpointOverrides() {
292
+ return !!this.config.manualEndpointOverrides;
293
+ }
294
+ async clearManualEndpointOverrides() {
295
+ this.config.manualEndpointOverrides = undefined;
296
+ this.config.lastManualEndpointUpdate = undefined;
297
+ // Rediscover services
298
+ await this.discoverServices();
80
299
  }
81
300
  getDiscoveredApiUrl() {
82
301
  return this.config.discoveredServices?.auth_base || this.getApiUrl();
83
302
  }
84
303
  // Enhanced authentication support
85
304
  async setVendorKey(vendorKey) {
86
- // Validate vendor key format (pk_*.sk_*)
87
- if (!vendorKey.match(/^pk_[a-zA-Z0-9]+\.sk_[a-zA-Z0-9]+$/)) {
88
- throw new Error('Invalid vendor key format. Expected: pk_xxx.sk_xxx');
305
+ // Enhanced format validation with detailed error messages
306
+ const formatValidation = this.validateVendorKeyFormat(vendorKey);
307
+ if (formatValidation !== true) {
308
+ throw new Error(typeof formatValidation === 'string' ? formatValidation : 'Invalid vendor key format');
89
309
  }
310
+ // Server-side validation
311
+ await this.validateVendorKeyWithServer(vendorKey);
90
312
  this.config.vendorKey = vendorKey;
313
+ this.config.authMethod = 'vendor_key';
314
+ this.config.lastValidated = new Date().toISOString();
315
+ await this.resetFailureCount(); // Reset failure count on successful auth
91
316
  await this.save();
92
317
  }
318
+ validateVendorKeyFormat(vendorKey) {
319
+ if (!vendorKey || vendorKey.trim().length === 0) {
320
+ return 'Vendor key is required';
321
+ }
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
+ return true;
355
+ }
356
+ async validateVendorKeyWithServer(vendorKey) {
357
+ try {
358
+ // Import axios dynamically to avoid circular dependency
359
+ const axios = (await import('axios')).default;
360
+ // Ensure service discovery is done
361
+ await this.discoverServices();
362
+ const authBase = this.config.discoveredServices?.auth_base || 'https://api.lanonasis.com';
363
+ // Test vendor key with health endpoint
364
+ await axios.get(`${authBase}/api/v1/health`, {
365
+ headers: {
366
+ 'X-API-Key': vendorKey,
367
+ 'X-Auth-Method': 'vendor_key',
368
+ 'X-Project-Scope': 'lanonasis-maas'
369
+ },
370
+ timeout: 10000
371
+ });
372
+ }
373
+ catch (error) {
374
+ // Provide specific error messages based on response
375
+ if (error.response?.status === 401) {
376
+ const errorData = error.response.data;
377
+ if (errorData?.error?.includes('expired') || errorData?.message?.includes('expired')) {
378
+ throw new Error('Vendor key has expired. Please generate a new key from your dashboard.');
379
+ }
380
+ else if (errorData?.error?.includes('revoked') || errorData?.message?.includes('revoked')) {
381
+ throw new Error('Vendor key has been revoked. Please generate a new key from your dashboard.');
382
+ }
383
+ else if (errorData?.error?.includes('invalid') || errorData?.message?.includes('invalid')) {
384
+ throw new Error('Vendor key is invalid. Please check the key format and ensure it was copied correctly.');
385
+ }
386
+ else {
387
+ throw new Error('Vendor key authentication failed. The key may be invalid, expired, or revoked.');
388
+ }
389
+ }
390
+ else if (error.response?.status === 403) {
391
+ throw new Error('Vendor key access denied. The key may not have sufficient permissions for this operation.');
392
+ }
393
+ else if (error.response?.status === 429) {
394
+ throw new Error('Too many validation attempts. Please wait a moment before trying again.');
395
+ }
396
+ else if (error.response?.status >= 500) {
397
+ throw new Error('Server error during validation. Please try again in a few moments.');
398
+ }
399
+ else if (error.code === 'ECONNREFUSED') {
400
+ throw new Error('Cannot connect to authentication server. Please check your internet connection and try again.');
401
+ }
402
+ else if (error.code === 'ENOTFOUND') {
403
+ throw new Error('Authentication server not found. Please check your internet connection.');
404
+ }
405
+ else if (error.code === 'ETIMEDOUT') {
406
+ throw new Error('Validation request timed out. Please check your internet connection and try again.');
407
+ }
408
+ else if (error.code === 'ECONNRESET') {
409
+ throw new Error('Connection was reset during validation. Please try again.');
410
+ }
411
+ else {
412
+ throw new Error(`Vendor key validation failed: ${error.message || 'Unknown error'}`);
413
+ }
414
+ }
415
+ }
93
416
  getVendorKey() {
94
417
  return this.config.vendorKey;
95
418
  }
@@ -102,11 +425,17 @@ export class CLIConfig {
102
425
  }
103
426
  async setToken(token) {
104
427
  this.config.token = token;
105
- // Decode token to get user info
428
+ this.config.authMethod = 'jwt';
429
+ this.config.lastValidated = new Date().toISOString();
430
+ await this.resetFailureCount(); // Reset failure count on successful auth
431
+ // Decode token to get user info and expiry
106
432
  try {
107
433
  const decoded = jwtDecode(token);
108
- // We'll need to fetch full user details from the API
109
- // For now, store what we can decode
434
+ // Store token expiry
435
+ if (typeof decoded.exp === 'number') {
436
+ this.config.tokenExpiry = decoded.exp;
437
+ }
438
+ // Store user info
110
439
  this.config.user = {
111
440
  email: String(decoded.email || ''),
112
441
  organization_id: String(decoded.organizationId || ''),
@@ -115,7 +444,8 @@ export class CLIConfig {
115
444
  };
116
445
  }
117
446
  catch {
118
- // Invalid token, don't store user info
447
+ // Invalid token, don't store user info or expiry
448
+ this.config.tokenExpiry = undefined;
119
449
  }
120
450
  await this.save();
121
451
  }
@@ -129,30 +459,60 @@ export class CLIConfig {
129
459
  const token = this.getToken();
130
460
  if (!token)
131
461
  return false;
462
+ // Check cache first
463
+ if (this.authCheckCache && (Date.now() - this.authCheckCache.timestamp) < this.AUTH_CACHE_TTL) {
464
+ return this.authCheckCache.isValid;
465
+ }
466
+ // Local expiry check first (fast)
467
+ let locallyValid = false;
132
468
  // Handle simple CLI tokens (format: cli_xxx_timestamp)
133
469
  if (token.startsWith('cli_')) {
134
470
  // Extract timestamp from CLI token
135
471
  const parts = token.split('_');
136
472
  if (parts.length >= 3) {
137
- const timestamp = parseInt(parts[parts.length - 1]);
473
+ const lastPart = parts[parts.length - 1];
474
+ const timestamp = lastPart ? parseInt(lastPart) : NaN;
138
475
  if (!isNaN(timestamp)) {
139
476
  // CLI tokens are valid for 30 days
140
477
  const thirtyDaysInMs = 30 * 24 * 60 * 60 * 1000;
141
- return (Date.now() - timestamp) < thirtyDaysInMs;
478
+ locallyValid = (Date.now() - timestamp) < thirtyDaysInMs;
142
479
  }
143
480
  }
144
- // If we can't parse timestamp, assume valid (fallback)
145
- return true;
481
+ else {
482
+ locallyValid = true; // Fallback for old format
483
+ }
146
484
  }
147
- // Handle JWT tokens
148
- try {
149
- const decoded = jwtDecode(token);
150
- const now = Date.now() / 1000;
151
- return typeof decoded.exp === 'number' && decoded.exp > now;
485
+ else {
486
+ // Handle JWT tokens
487
+ try {
488
+ const decoded = jwtDecode(token);
489
+ const now = Date.now() / 1000;
490
+ locallyValid = typeof decoded.exp === 'number' && decoded.exp > now;
491
+ }
492
+ catch {
493
+ locallyValid = false;
494
+ }
152
495
  }
153
- catch {
496
+ // If expired locally, no need to check server
497
+ if (!locallyValid) {
498
+ this.authCheckCache = { isValid: false, timestamp: Date.now() };
154
499
  return false;
155
500
  }
501
+ // Verify with server (security check)
502
+ try {
503
+ const axios = (await import('axios')).default;
504
+ const response = await axios.post('https://api.lanonasis.com/auth/verify', { token }, { timeout: 5000 });
505
+ const isValid = response.data.valid === true;
506
+ this.authCheckCache = { isValid, timestamp: Date.now() };
507
+ return isValid;
508
+ }
509
+ catch (error) {
510
+ // If server check fails, fall back to local validation
511
+ // This allows offline usage but is less secure
512
+ console.warn('⚠️ Unable to verify token with server, using local validation');
513
+ this.authCheckCache = { isValid: locallyValid, timestamp: Date.now() };
514
+ return locallyValid;
515
+ }
156
516
  }
157
517
  async logout() {
158
518
  this.config.token = undefined;
@@ -175,6 +535,134 @@ export class CLIConfig {
175
535
  return false;
176
536
  }
177
537
  }
538
+ // Enhanced credential validation methods
539
+ async validateStoredCredentials() {
540
+ try {
541
+ const vendorKey = this.getVendorKey();
542
+ const token = this.getToken();
543
+ if (!vendorKey && !token) {
544
+ return false;
545
+ }
546
+ // Import axios dynamically to avoid circular dependency
547
+ const axios = (await import('axios')).default;
548
+ // Ensure service discovery is done
549
+ await this.discoverServices();
550
+ const authBase = this.config.discoveredServices?.auth_base || 'https://api.lanonasis.com';
551
+ const headers = {
552
+ 'X-Project-Scope': 'lanonasis-maas'
553
+ };
554
+ if (vendorKey) {
555
+ headers['X-API-Key'] = vendorKey;
556
+ headers['X-Auth-Method'] = 'vendor_key';
557
+ }
558
+ else if (token) {
559
+ headers['Authorization'] = `Bearer ${token}`;
560
+ headers['X-Auth-Method'] = 'jwt';
561
+ }
562
+ // Validate against server with health endpoint
563
+ await axios.get(`${authBase}/api/v1/health`, {
564
+ headers,
565
+ timeout: 10000
566
+ });
567
+ // Update last validated timestamp
568
+ this.config.lastValidated = new Date().toISOString();
569
+ await this.resetFailureCount();
570
+ await this.save();
571
+ return true;
572
+ }
573
+ catch (error) {
574
+ // Increment failure count
575
+ await this.incrementFailureCount();
576
+ return false;
577
+ }
578
+ }
579
+ async refreshTokenIfNeeded() {
580
+ const token = this.getToken();
581
+ if (!token) {
582
+ return;
583
+ }
584
+ try {
585
+ // Check if token is JWT and if it's close to expiry
586
+ if (token.startsWith('cli_')) {
587
+ // CLI tokens don't need refresh, they're long-lived
588
+ return;
589
+ }
590
+ const decoded = jwtDecode(token);
591
+ const now = Date.now() / 1000;
592
+ const exp = typeof decoded.exp === 'number' ? decoded.exp : 0;
593
+ // Refresh if token expires within 5 minutes
594
+ if (exp > 0 && (exp - now) < 300) {
595
+ // Import axios dynamically
596
+ const axios = (await import('axios')).default;
597
+ await this.discoverServices();
598
+ const authBase = this.config.discoveredServices?.auth_base || 'https://api.lanonasis.com';
599
+ // Attempt token refresh
600
+ const response = await axios.post(`${authBase}/v1/auth/refresh`, {}, {
601
+ headers: {
602
+ 'Authorization': `Bearer ${token}`,
603
+ 'X-Project-Scope': 'lanonasis-maas'
604
+ },
605
+ timeout: 10000
606
+ });
607
+ if (response.data.token) {
608
+ await this.setToken(response.data.token);
609
+ }
610
+ }
611
+ }
612
+ catch (error) {
613
+ // If refresh fails, mark credentials as potentially invalid
614
+ await this.incrementFailureCount();
615
+ }
616
+ }
617
+ async clearInvalidCredentials() {
618
+ this.config.token = undefined;
619
+ this.config.vendorKey = undefined;
620
+ this.config.user = undefined;
621
+ this.config.authMethod = undefined;
622
+ this.config.tokenExpiry = undefined;
623
+ this.config.lastValidated = undefined;
624
+ this.config.authFailureCount = 0;
625
+ this.config.lastAuthFailure = undefined;
626
+ await this.save();
627
+ }
628
+ async incrementFailureCount() {
629
+ this.config.authFailureCount = (this.config.authFailureCount || 0) + 1;
630
+ this.config.lastAuthFailure = new Date().toISOString();
631
+ await this.save();
632
+ }
633
+ async resetFailureCount() {
634
+ this.config.authFailureCount = 0;
635
+ this.config.lastAuthFailure = undefined;
636
+ await this.save();
637
+ }
638
+ getFailureCount() {
639
+ return this.config.authFailureCount || 0;
640
+ }
641
+ getLastAuthFailure() {
642
+ return this.config.lastAuthFailure;
643
+ }
644
+ shouldDelayAuth() {
645
+ const failureCount = this.getFailureCount();
646
+ return failureCount >= 3;
647
+ }
648
+ getAuthDelayMs() {
649
+ const failureCount = this.getFailureCount();
650
+ if (failureCount < 3)
651
+ return 0;
652
+ // Progressive delays: 3 failures = 2s, 4 = 4s, 5 = 8s, 6+ = 16s max
653
+ const baseDelay = 2000; // 2 seconds
654
+ const maxDelay = 16000; // 16 seconds max
655
+ const delay = Math.min(baseDelay * Math.pow(2, failureCount - 3), maxDelay);
656
+ return delay;
657
+ }
658
+ async getDeviceId() {
659
+ if (!this.config.deviceId) {
660
+ // Generate a new device ID
661
+ this.config.deviceId = randomUUID();
662
+ await this.save();
663
+ }
664
+ return this.config.deviceId;
665
+ }
178
666
  // Generic get/set methods for MCP and other dynamic config
179
667
  get(key) {
180
668
  return this.config[key];
@@ -188,7 +676,7 @@ export class CLIConfig {
188
676
  }
189
677
  // MCP-specific helpers
190
678
  getMCPServerPath() {
191
- return this.config.mcpServerPath || path.join(__dirname, '../../../../onasis-gateway/mcp-server/server.js');
679
+ return this.config.mcpServerPath || path.join(process.cwd(), 'onasis-gateway/mcp-server/server.js');
192
680
  }
193
681
  getMCPServerUrl() {
194
682
  return this.config.discoveredServices?.mcp_ws_base ||