@lanonasis/cli 3.1.13 → 3.3.15

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.
@@ -5,16 +5,18 @@ import { CLIConfig } from './config.js';
5
5
  import * as path from 'path';
6
6
  import * as fs from 'fs';
7
7
  import { EventSource } from 'eventsource';
8
- import { fileURLToPath } from 'url';
9
8
  import WebSocket from 'ws';
10
- const __filename = fileURLToPath(import.meta.url);
11
- const __dirname = path.dirname(__filename);
12
9
  export class MCPClient {
13
10
  client = null;
14
11
  config;
15
12
  isConnected = false;
16
13
  sseConnection = null;
17
14
  wsConnection = null;
15
+ retryAttempts = 0;
16
+ maxRetries = 3;
17
+ healthCheckInterval = null;
18
+ connectionStartTime = 0;
19
+ lastHealthCheck = null;
18
20
  constructor() {
19
21
  this.config = new CLIConfig();
20
22
  }
@@ -25,12 +27,22 @@ export class MCPClient {
25
27
  await this.config.init();
26
28
  }
27
29
  /**
28
- * Connect to MCP server (local or remote)
30
+ * Connect to MCP server with retry logic
29
31
  */
30
32
  async connect(options = {}) {
33
+ this.retryAttempts = 0;
34
+ return this.connectWithRetry(options);
35
+ }
36
+ /**
37
+ * Connect to MCP server with retry logic and exponential backoff
38
+ */
39
+ async connectWithRetry(options = {}) {
31
40
  try {
41
+ this.connectionStartTime = Date.now();
32
42
  // Initialize config if not already done
33
43
  await this.init();
44
+ // Validate authentication before attempting connection
45
+ await this.validateAuthBeforeConnect();
34
46
  // Determine connection mode with priority to explicit mode option
35
47
  // Default to 'remote' for better user experience
36
48
  const connectionMode = options.connectionMode ??
@@ -49,10 +61,17 @@ export class MCPClient {
49
61
  this.config.getMCPServerUrl() ??
50
62
  'wss://mcp.lanonasis.com/ws';
51
63
  wsUrl = wsUrlValue;
52
- console.log(chalk.cyan(`Connecting to WebSocket MCP server at ${wsUrl}...`));
64
+ if (this.retryAttempts === 0) {
65
+ console.log(chalk.cyan(`Connecting to WebSocket MCP server at ${wsUrl}...`));
66
+ }
67
+ else {
68
+ console.log(chalk.yellow(`Retry ${this.retryAttempts}/${this.maxRetries}: Connecting to WebSocket MCP server...`));
69
+ }
53
70
  // Initialize WebSocket connection
54
71
  await this.initializeWebSocket(wsUrl);
55
72
  this.isConnected = true;
73
+ this.retryAttempts = 0;
74
+ this.startHealthMonitoring();
56
75
  return true;
57
76
  }
58
77
  case 'remote': {
@@ -62,17 +81,24 @@ export class MCPClient {
62
81
  this.config.getMCPRestUrl() ??
63
82
  'https://mcp.lanonasis.com/api/v1';
64
83
  serverUrl = serverUrlValue;
65
- console.log(chalk.cyan(`Connecting to remote MCP server at ${serverUrl}...`));
84
+ if (this.retryAttempts === 0) {
85
+ console.log(chalk.cyan(`Connecting to remote MCP server at ${serverUrl}...`));
86
+ }
87
+ else {
88
+ console.log(chalk.yellow(`Retry ${this.retryAttempts}/${this.maxRetries}: Connecting to remote MCP server...`));
89
+ }
66
90
  // Initialize SSE connection for real-time updates
67
91
  await this.initializeSSE(serverUrl);
68
92
  this.isConnected = true;
93
+ this.retryAttempts = 0;
94
+ this.startHealthMonitoring();
69
95
  return true;
70
96
  }
71
97
  default: {
72
98
  // Local MCP server connection (default)
73
99
  const serverPathValue = options.serverPath ??
74
100
  this.config.get('mcpServerPath') ??
75
- path.join(__dirname, '../../../../onasis-gateway/mcp-server/server.js');
101
+ path.join(path.resolve(), '../../../../onasis-gateway/mcp-server/server.js');
76
102
  serverPath = serverPathValue;
77
103
  // Check if the server file exists
78
104
  if (!fs.existsSync(serverPath)) {
@@ -81,7 +107,12 @@ export class MCPClient {
81
107
  console.log(chalk.cyan('💡 Or install local server: npm install -g @lanonasis/mcp-server'));
82
108
  throw new Error(`MCP server not found at ${serverPath}`);
83
109
  }
84
- console.log(chalk.cyan(`Connecting to local MCP server at ${serverPath}...`));
110
+ if (this.retryAttempts === 0) {
111
+ console.log(chalk.cyan(`Connecting to local MCP server at ${serverPath}...`));
112
+ }
113
+ else {
114
+ console.log(chalk.yellow(`Retry ${this.retryAttempts}/${this.maxRetries}: Connecting to local MCP server...`));
115
+ }
85
116
  const localTransport = new StdioClientTransport({
86
117
  command: 'node',
87
118
  args: [serverPath]
@@ -92,16 +123,231 @@ export class MCPClient {
92
123
  });
93
124
  await this.client.connect(localTransport);
94
125
  this.isConnected = true;
126
+ this.retryAttempts = 0;
95
127
  console.log(chalk.green('✓ Connected to MCP server'));
128
+ this.startHealthMonitoring();
96
129
  return true;
97
130
  }
98
131
  }
99
132
  }
100
133
  catch (error) {
101
- console.error(chalk.red('Failed to connect to MCP server:'), error);
134
+ return this.handleConnectionFailure(error, options);
135
+ }
136
+ }
137
+ /**
138
+ * Handle connection failures with retry logic and specific error messages
139
+ */
140
+ async handleConnectionFailure(error, options) {
141
+ // Check if this is an authentication error (don't retry these)
142
+ if (this.isAuthenticationError(error)) {
143
+ console.error(chalk.red('Authentication failed:'), error.message);
144
+ this.provideAuthenticationGuidance(error);
102
145
  this.isConnected = false;
103
146
  return false;
104
147
  }
148
+ this.retryAttempts++;
149
+ if (this.retryAttempts >= this.maxRetries) {
150
+ console.error(chalk.red(`Failed to connect after ${this.maxRetries} attempts`));
151
+ this.provideNetworkTroubleshootingGuidance(error);
152
+ this.isConnected = false;
153
+ return false;
154
+ }
155
+ // For network errors, retry with exponential backoff
156
+ const delay = await this.exponentialBackoff(this.retryAttempts);
157
+ console.log(chalk.yellow(`Network error, retrying in ${delay}ms... (${this.retryAttempts}/${this.maxRetries})`));
158
+ console.log(chalk.gray(`Error: ${error.message}`));
159
+ await new Promise(resolve => setTimeout(resolve, delay));
160
+ return this.connectWithRetry(options);
161
+ }
162
+ /**
163
+ * Check if error is authentication-related
164
+ */
165
+ isAuthenticationError(error) {
166
+ const errorMessage = error.message?.toLowerCase() || '';
167
+ return errorMessage.includes('authentication_required') ||
168
+ errorMessage.includes('authentication_invalid') ||
169
+ errorMessage.includes('unauthorized') ||
170
+ errorMessage.includes('invalid token') ||
171
+ errorMessage.includes('token is invalid') ||
172
+ errorMessage.includes('401') ||
173
+ errorMessage.includes('403') ||
174
+ (error.response?.status >= 401 && error.response?.status <= 403);
175
+ }
176
+ /**
177
+ * Provide authentication-specific guidance
178
+ */
179
+ provideAuthenticationGuidance(error) {
180
+ console.log(chalk.yellow('\n🔐 Authentication Issue Detected:'));
181
+ if (error.message?.includes('AUTHENTICATION_REQUIRED')) {
182
+ console.log(chalk.cyan('• No credentials found. Run: lanonasis auth login'));
183
+ console.log(chalk.cyan('• Or set vendor key: lanonasis auth login --vendor-key pk_xxx.sk_xxx'));
184
+ }
185
+ else if (error.message?.includes('AUTHENTICATION_INVALID')) {
186
+ console.log(chalk.cyan('• Invalid credentials. Check your vendor key format'));
187
+ console.log(chalk.cyan('• Expected format: pk_xxx.sk_xxx'));
188
+ console.log(chalk.cyan('• Try: lanonasis auth logout && lanonasis auth login'));
189
+ }
190
+ else if (error.message?.includes('expired')) {
191
+ console.log(chalk.cyan('• Token expired. Re-authenticate: lanonasis auth login'));
192
+ console.log(chalk.cyan('• Or refresh: lanonasis auth refresh (if available)'));
193
+ }
194
+ else {
195
+ console.log(chalk.cyan('• Check authentication status: lanonasis auth status'));
196
+ console.log(chalk.cyan('• Re-authenticate: lanonasis auth login'));
197
+ console.log(chalk.cyan('• Verify vendor key: lanonasis auth login --vendor-key pk_xxx.sk_xxx'));
198
+ }
199
+ }
200
+ /**
201
+ * Provide network troubleshooting guidance
202
+ */
203
+ provideNetworkTroubleshootingGuidance(error) {
204
+ console.log(chalk.yellow('\n🌐 Network Issue Detected:'));
205
+ if (error.message?.includes('ECONNREFUSED') || error.message?.includes('connect ECONNREFUSED')) {
206
+ console.log(chalk.cyan('• Connection refused. Service may be down:'));
207
+ console.log(chalk.cyan(' - For remote: Check https://mcp.lanonasis.com/health'));
208
+ console.log(chalk.cyan(' - For WebSocket: Check wss://mcp.lanonasis.com/ws'));
209
+ console.log(chalk.cyan(' - For local: Install local MCP server'));
210
+ }
211
+ else if (error.message?.includes('timeout') || error.message?.includes('ETIMEDOUT')) {
212
+ console.log(chalk.cyan('• Connection timeout. Check network:'));
213
+ console.log(chalk.cyan(' - Verify internet connectivity'));
214
+ console.log(chalk.cyan(' - Check firewall settings'));
215
+ console.log(chalk.cyan(' - Try different connection mode: --mode remote'));
216
+ }
217
+ else if (error.message?.includes('ENOTFOUND') || error.message?.includes('getaddrinfo')) {
218
+ console.log(chalk.cyan('• DNS resolution failed:'));
219
+ console.log(chalk.cyan(' - Check DNS settings'));
220
+ console.log(chalk.cyan(' - Verify server URL is correct'));
221
+ console.log(chalk.cyan(' - Try using IP address instead of hostname'));
222
+ }
223
+ else if (error.message?.includes('certificate') || error.message?.includes('SSL') || error.message?.includes('TLS')) {
224
+ console.log(chalk.cyan('• SSL/TLS certificate issue:'));
225
+ console.log(chalk.cyan(' - Check system time and date'));
226
+ console.log(chalk.cyan(' - Update CA certificates'));
227
+ console.log(chalk.cyan(' - Try different connection mode'));
228
+ }
229
+ else {
230
+ console.log(chalk.cyan('• General network error:'));
231
+ console.log(chalk.cyan(' - Check server status'));
232
+ console.log(chalk.cyan(' - Verify network connectivity'));
233
+ console.log(chalk.cyan(' - Try: lanonasis mcp diagnose (when available)'));
234
+ }
235
+ }
236
+ /**
237
+ * Calculate exponential backoff delay with jitter
238
+ */
239
+ async exponentialBackoff(attempt) {
240
+ // Base delay of 1 second, exponentially increasing
241
+ const baseDelay = 1000;
242
+ const exponentialDelay = baseDelay * Math.pow(2, attempt - 1);
243
+ // Cap at 10 seconds maximum
244
+ const cappedDelay = Math.min(exponentialDelay, 10000);
245
+ // Add jitter (±25% randomization) to avoid thundering herd
246
+ const jitter = cappedDelay * 0.25 * (Math.random() - 0.5);
247
+ return Math.round(cappedDelay + jitter);
248
+ }
249
+ /**
250
+ * Validate authentication credentials before attempting MCP connection
251
+ */
252
+ async validateAuthBeforeConnect() {
253
+ const token = this.config.get('token');
254
+ const vendorKey = this.config.get('vendorKey');
255
+ // Check if we have any authentication credentials
256
+ if (!token && !vendorKey) {
257
+ throw new Error('AUTHENTICATION_REQUIRED: No authentication credentials found. Run "lanonasis auth login" first.');
258
+ }
259
+ // If we have a token, check if it's expired or needs refresh
260
+ if (token) {
261
+ try {
262
+ await this.validateAndRefreshToken(token);
263
+ }
264
+ catch (error) {
265
+ throw new Error(`AUTHENTICATION_INVALID: ${error instanceof Error ? error.message : 'Token validation failed'}`);
266
+ }
267
+ }
268
+ // If we have a vendor key, validate its format
269
+ if (vendorKey && !token) {
270
+ if (!this.validateVendorKeyFormat(vendorKey)) {
271
+ throw new Error('AUTHENTICATION_INVALID: Invalid vendor key format. Expected format: pk_xxx.sk_xxx');
272
+ }
273
+ }
274
+ }
275
+ /**
276
+ * Validate vendor key format
277
+ */
278
+ validateVendorKeyFormat(vendorKey) {
279
+ // Vendor key should be in format: pk_xxx.sk_xxx
280
+ const vendorKeyPattern = /^pk_[a-zA-Z0-9]+\.sk_[a-zA-Z0-9]+$/;
281
+ return vendorKeyPattern.test(vendorKey);
282
+ }
283
+ /**
284
+ * Validate and refresh token if needed
285
+ */
286
+ async validateAndRefreshToken(token) {
287
+ try {
288
+ // Try to decode the JWT token to check expiration
289
+ const tokenParts = token.split('.');
290
+ if (tokenParts.length === 3) {
291
+ const payload = JSON.parse(Buffer.from(tokenParts[1], 'base64').toString());
292
+ const currentTime = Math.floor(Date.now() / 1000);
293
+ // Check if token is expired or expires within 5 minutes
294
+ if (payload.exp && payload.exp < currentTime + 300) {
295
+ console.log(chalk.yellow('Token is expired or expiring soon, attempting refresh...'));
296
+ await this.refreshTokenIfNeeded();
297
+ }
298
+ }
299
+ }
300
+ catch (error) {
301
+ // If we can't decode the token, try to validate it with the server
302
+ await this.validateTokenWithServer(token);
303
+ }
304
+ }
305
+ /**
306
+ * Refresh token if needed
307
+ */
308
+ async refreshTokenIfNeeded() {
309
+ const refreshToken = this.config.get('refreshToken');
310
+ if (!refreshToken) {
311
+ throw new Error('No refresh token available. Please re-authenticate.');
312
+ }
313
+ try {
314
+ const axios = (await import('axios')).default;
315
+ const authUrl = this.config.get('authUrl') ?? 'https://api.lanonasis.com';
316
+ const response = await axios.post(`${authUrl}/auth/refresh`, {
317
+ refresh_token: refreshToken
318
+ }, {
319
+ timeout: 10000
320
+ });
321
+ if (response.data.access_token) {
322
+ await this.config.setAndSave('token', response.data.access_token);
323
+ console.log(chalk.green('✓ Token refreshed successfully'));
324
+ }
325
+ }
326
+ catch (error) {
327
+ throw new Error('Failed to refresh token. Please re-authenticate.');
328
+ }
329
+ }
330
+ /**
331
+ * Validate token with server
332
+ */
333
+ async validateTokenWithServer(token) {
334
+ try {
335
+ const axios = (await import('axios')).default;
336
+ const authUrl = this.config.get('authUrl') ?? 'https://api.lanonasis.com';
337
+ await axios.get(`${authUrl}/auth/validate`, {
338
+ headers: {
339
+ 'Authorization': `Bearer ${token}`,
340
+ 'x-api-key': String(token)
341
+ },
342
+ timeout: 10000
343
+ });
344
+ }
345
+ catch (error) {
346
+ if (error.response?.status === 401 || error.response?.status === 403) {
347
+ throw new Error('Token is invalid or expired. Please re-authenticate.');
348
+ }
349
+ throw new Error(`Token validation failed: ${error.message}`);
350
+ }
105
351
  }
106
352
  /**
107
353
  * Initialize SSE connection for real-time updates
@@ -208,10 +454,142 @@ export class MCPClient {
208
454
  }
209
455
  this.wsConnection.send(JSON.stringify(message));
210
456
  }
457
+ /**
458
+ * Start health monitoring for the connection
459
+ */
460
+ startHealthMonitoring() {
461
+ // Clear any existing health check interval
462
+ this.stopHealthMonitoring();
463
+ // Start health monitoring every 30 seconds
464
+ this.healthCheckInterval = setInterval(async () => {
465
+ await this.performHealthCheck();
466
+ }, 30000);
467
+ // Perform initial health check
468
+ setTimeout(() => this.performHealthCheck(), 5000);
469
+ }
470
+ /**
471
+ * Stop health monitoring
472
+ */
473
+ stopHealthMonitoring() {
474
+ if (this.healthCheckInterval) {
475
+ clearInterval(this.healthCheckInterval);
476
+ this.healthCheckInterval = null;
477
+ }
478
+ }
479
+ /**
480
+ * Perform a health check on the current connection
481
+ */
482
+ async performHealthCheck() {
483
+ if (!this.isConnected) {
484
+ return;
485
+ }
486
+ try {
487
+ this.lastHealthCheck = new Date();
488
+ const connectionMode = this.config.get('mcpConnectionMode') ?? 'remote';
489
+ switch (connectionMode) {
490
+ case 'websocket':
491
+ await this.checkWebSocketHealth();
492
+ break;
493
+ case 'remote':
494
+ await this.checkRemoteHealth();
495
+ break;
496
+ default:
497
+ await this.checkLocalHealth();
498
+ break;
499
+ }
500
+ }
501
+ catch (error) {
502
+ console.log(chalk.yellow('⚠️ Health check failed, attempting reconnection...'));
503
+ await this.handleHealthCheckFailure();
504
+ }
505
+ }
506
+ /**
507
+ * Check WebSocket connection health
508
+ */
509
+ async checkWebSocketHealth() {
510
+ if (!this.wsConnection || this.wsConnection.readyState !== WebSocket.OPEN) {
511
+ throw new Error('WebSocket connection not open');
512
+ }
513
+ // Send a ping message to check connectivity
514
+ this.sendWebSocketMessage({
515
+ id: Date.now(),
516
+ method: 'ping',
517
+ params: {}
518
+ });
519
+ }
520
+ /**
521
+ * Check remote connection health
522
+ */
523
+ async checkRemoteHealth() {
524
+ const apiUrl = this.config.getMCPRestUrl() ?? 'https://mcp.lanonasis.com/api/v1';
525
+ const token = this.config.get('token');
526
+ if (!token) {
527
+ throw new Error('No authentication token available');
528
+ }
529
+ try {
530
+ const axios = (await import('axios')).default;
531
+ await axios.get(`${apiUrl}/health`, {
532
+ headers: {
533
+ 'Authorization': `Bearer ${token}`,
534
+ 'x-api-key': String(token)
535
+ },
536
+ timeout: 5000
537
+ });
538
+ }
539
+ catch (error) {
540
+ throw new Error(`Remote health check failed: ${error}`);
541
+ }
542
+ }
543
+ /**
544
+ * Check local connection health
545
+ */
546
+ async checkLocalHealth() {
547
+ if (!this.client) {
548
+ throw new Error('Local MCP client not initialized');
549
+ }
550
+ // Try to list tools as a health check
551
+ try {
552
+ await this.client.listTools();
553
+ }
554
+ catch (error) {
555
+ throw new Error(`Local health check failed: ${error}`);
556
+ }
557
+ }
558
+ /**
559
+ * Handle health check failure by attempting reconnection
560
+ */
561
+ async handleHealthCheckFailure() {
562
+ this.isConnected = false;
563
+ this.stopHealthMonitoring();
564
+ // Attempt to reconnect with current configuration
565
+ const connectionMode = this.config.get('mcpConnectionMode') ?? 'remote';
566
+ const options = {
567
+ connectionMode: connectionMode
568
+ };
569
+ // Add specific URLs if available
570
+ if (connectionMode === 'websocket') {
571
+ options.serverUrl = this.config.get('mcpWebSocketUrl');
572
+ }
573
+ else if (connectionMode === 'remote') {
574
+ options.serverUrl = this.config.get('mcpServerUrl');
575
+ }
576
+ else {
577
+ options.serverPath = this.config.get('mcpServerPath');
578
+ }
579
+ // Attempt reconnection
580
+ const reconnected = await this.connect(options);
581
+ if (reconnected) {
582
+ console.log(chalk.green('✓ Reconnected to MCP server'));
583
+ }
584
+ else {
585
+ console.log(chalk.red('✗ Failed to reconnect to MCP server'));
586
+ }
587
+ }
211
588
  /**
212
589
  * Disconnect from MCP server
213
590
  */
214
591
  async disconnect() {
592
+ this.stopHealthMonitoring();
215
593
  if (this.client) {
216
594
  await this.client.close();
217
595
  this.client = null;
@@ -220,6 +598,10 @@ export class MCPClient {
220
598
  this.sseConnection.close();
221
599
  this.sseConnection = null;
222
600
  }
601
+ if (this.wsConnection) {
602
+ this.wsConnection.close();
603
+ this.wsConnection = null;
604
+ }
223
605
  this.isConnected = false;
224
606
  }
225
607
  /**
@@ -372,16 +754,33 @@ export class MCPClient {
372
754
  return this.isConnected;
373
755
  }
374
756
  /**
375
- * Get connection status details
757
+ * Get connection status details with health information
376
758
  */
377
759
  getConnectionStatus() {
378
- const useRemote = this.config.get('mcpUseRemote') ?? false;
760
+ const connectionMode = this.config.get('mcpConnectionMode') ??
761
+ (this.config.get('mcpUseRemote') ? 'remote' : 'local');
762
+ let server;
763
+ switch (connectionMode) {
764
+ case 'websocket':
765
+ server = this.config.get('mcpWebSocketUrl') ?? 'wss://mcp.lanonasis.com/ws';
766
+ break;
767
+ case 'remote':
768
+ server = this.config.get('mcpServerUrl') ?? 'https://mcp.lanonasis.com/api/v1';
769
+ break;
770
+ default:
771
+ server = this.config.get('mcpServerPath') ?? 'local MCP server';
772
+ break;
773
+ }
774
+ const connectionUptime = this.connectionStartTime > 0
775
+ ? Date.now() - this.connectionStartTime
776
+ : undefined;
379
777
  return {
380
778
  connected: this.isConnected,
381
- mode: useRemote ? 'remote' : 'local',
382
- server: useRemote
383
- ? (this.config.get('mcpServerUrl') ?? 'https://api.lanonasis.com')
384
- : (this.config.get('mcpServerPath') ?? 'local MCP server')
779
+ mode: connectionMode,
780
+ server,
781
+ lastHealthCheck: this.lastHealthCheck ?? undefined,
782
+ connectionUptime,
783
+ failureCount: this.retryAttempts
385
784
  };
386
785
  }
387
786
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lanonasis/cli",
3
- "version": "3.1.13",
3
+ "version": "3.3.15",
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": {
@@ -21,8 +21,8 @@
21
21
  "build:mcp-client": "tsc src/mcp/client/enhanced-client.ts --outDir dist/mcp/client",
22
22
  "start": "node dist/index.js",
23
23
  "start:mcp-server": "node dist/mcp/server/lanonasis-server.js",
24
- "test": "jest",
25
- "test:mcp": "jest --testPathPattern=mcp",
24
+ "test": "NODE_OPTIONS=--experimental-vm-modules jest",
25
+ "test:mcp": "NODE_OPTIONS=--experimental-vm-modules jest --testPathPattern=mcp",
26
26
  "test:integration": "npm run build:mcp && npm run test:mcp",
27
27
  "lint": "eslint src/**/*.ts",
28
28
  "type-check": "tsc --noEmit",
@@ -55,7 +55,7 @@
55
55
  },
56
56
  "repository": {
57
57
  "type": "git",
58
- "url": "https://github.com/lanonasis/lanonasis-maas.git",
58
+ "url": "git+https://github.com/lanonasis/lanonasis-maas.git",
59
59
  "directory": "cli"
60
60
  },
61
61
  "bugs": {
@@ -84,11 +84,15 @@
84
84
  "ws": "^8.18.3"
85
85
  },
86
86
  "devDependencies": {
87
+ "@jest/globals": "^29.7.0",
87
88
  "@types/inquirer": "^9.0.7",
89
+ "@types/jest": "^29.5.14",
88
90
  "@types/node": "^22.10.2",
89
91
  "@typescript-eslint/eslint-plugin": "^8.18.1",
90
92
  "@typescript-eslint/parser": "^8.18.1",
91
93
  "eslint": "^9.17.0",
94
+ "jest": "^29.7.0",
95
+ "ts-jest": "^29.2.5",
92
96
  "tsx": "^4.19.2",
93
97
  "typescript": "^5.7.2"
94
98
  },