@lanonasis/cli 3.6.7 → 3.7.1

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
@@ -1,4 +1,4 @@
1
- # @lanonasis/cli v3.4.15 - Enhanced MCP & Interactive CLI Experience
1
+ # @lanonasis/cli v3.7.0 - Enterprise Security & MCP Experience
2
2
 
3
3
  [![NPM Version](https://img.shields.io/npm/v/@lanonasis/cli)](https://www.npmjs.com/package/@lanonasis/cli)
4
4
  [![Downloads](https://img.shields.io/npm/dt/@lanonasis/cli)](https://www.npmjs.com/package/@lanonasis/cli)
@@ -53,7 +53,24 @@ memory list
53
53
  maas memory list
54
54
  ```
55
55
 
56
- ## 🔐 Authentication Methods
56
+ ## 🔐 Security & Authentication
57
+
58
+ ### Enterprise-Grade SHA-256 Security (v3.7.0+)
59
+
60
+ All API keys are now secured with SHA-256 cryptographic hashing:
61
+
62
+ - ✅ **Automatic Hash Normalization**: Keys are automatically hashed before transmission
63
+ - ✅ **Double-Hash Prevention**: Smart detection prevents re-hashing already hashed keys
64
+ - ✅ **Cross-Platform Compatibility**: Works seamlessly across Node.js and browser environments
65
+ - ✅ **Zero Configuration**: Security is automatic and transparent
66
+
67
+ ```typescript
68
+ // Hash utilities are built-in and automatic
69
+ // Your vendor keys are automatically secured
70
+ onasis login --vendor-key pk_xxxxx.sk_xxxxx // ✅ Automatically hashed
71
+ ```
72
+
73
+ ### Authentication Methods
57
74
 
58
75
  ### 1. Vendor Key Authentication (Recommended)
59
76
 
@@ -5,6 +5,7 @@ import open from 'open';
5
5
  import crypto from 'crypto';
6
6
  import http from 'http';
7
7
  import url from 'url';
8
+ import axios from 'axios';
8
9
  import { apiClient } from '../utils/api.js';
9
10
  import { CLIConfig } from '../utils/config.js';
10
11
  // Color scheme
@@ -242,16 +243,40 @@ function createCallbackServer(port = 8888) {
242
243
  /**
243
244
  * Exchange authorization code for OAuth2 tokens
244
245
  */
245
- async function exchangeCodeForTokens(code, verifier, authBase) {
246
+ async function exchangeCodeForTokens(code, verifier, authBase, redirectUri) {
246
247
  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;
248
+ try {
249
+ // Use axios directly to have full control over error handling
250
+ const response = await axios.post(tokenEndpoint, {
251
+ grant_type: 'authorization_code',
252
+ code,
253
+ code_verifier: verifier,
254
+ client_id: 'lanonasis-cli',
255
+ redirect_uri: redirectUri
256
+ }, {
257
+ headers: {
258
+ 'Content-Type': 'application/json',
259
+ }
260
+ });
261
+ return response.data;
262
+ }
263
+ catch (error) {
264
+ // Extract detailed error information from axios error response
265
+ if (error.response) {
266
+ const errorData = error.response.data || {};
267
+ const status = error.response.status;
268
+ const errorMessage = errorData.error_description || errorData.error || error.message || `Request failed with status code ${status}`;
269
+ const details = errorData.details;
270
+ const enhancedError = new Error(errorMessage);
271
+ enhancedError.response = error.response;
272
+ enhancedError.status = status;
273
+ enhancedError.details = details;
274
+ enhancedError.errorData = errorData;
275
+ throw enhancedError;
276
+ }
277
+ // If it's not an axios error, just rethrow
278
+ throw error;
279
+ }
255
280
  }
256
281
  /**
257
282
  * Refresh OAuth2 access token using refresh token
@@ -618,10 +643,11 @@ async function handleOAuthFlow(config) {
618
643
  console.log(chalk.gray(` ✓ Started local callback server on port ${callbackPort}`));
619
644
  // Build OAuth2 authorization URL
620
645
  const authBase = config.getDiscoveredApiUrl();
646
+ const redirectUri = `http://localhost:${callbackPort}/callback`;
621
647
  const authUrl = new URL(`${authBase}/oauth/authorize`);
622
648
  authUrl.searchParams.set('response_type', 'code');
623
649
  authUrl.searchParams.set('client_id', 'lanonasis-cli');
624
- authUrl.searchParams.set('redirect_uri', `http://localhost:${callbackPort}/callback`);
650
+ authUrl.searchParams.set('redirect_uri', redirectUri);
625
651
  authUrl.searchParams.set('scope', 'read write offline_access');
626
652
  authUrl.searchParams.set('code_challenge', pkce.challenge);
627
653
  authUrl.searchParams.set('code_challenge_method', 'S256');
@@ -639,7 +665,14 @@ async function handleOAuthFlow(config) {
639
665
  // Exchange code for tokens
640
666
  spinner.text = 'Exchanging code for access tokens...';
641
667
  spinner.start();
642
- const tokens = await exchangeCodeForTokens(code, pkce.verifier, authBase);
668
+ // Debug logging in verbose mode
669
+ if (process.env.CLI_VERBOSE === 'true') {
670
+ console.log(chalk.dim(` Code length: ${code.length}`));
671
+ console.log(chalk.dim(` Verifier length: ${pkce.verifier.length}`));
672
+ console.log(chalk.dim(` Redirect URI: ${redirectUri}`));
673
+ console.log(chalk.dim(` Token endpoint: ${authBase}/oauth/token`));
674
+ }
675
+ const tokens = await exchangeCodeForTokens(code, pkce.verifier, authBase, redirectUri);
643
676
  spinner.succeed('Access tokens received');
644
677
  // Store tokens
645
678
  await config.setToken(tokens.access_token);
@@ -653,8 +686,49 @@ async function handleOAuthFlow(config) {
653
686
  }
654
687
  catch (error) {
655
688
  console.error(chalk.red('✖ OAuth2 authentication failed'));
689
+ // Display detailed error information
656
690
  const errorMessage = error instanceof Error ? error.message : 'Unknown error';
657
691
  console.error(chalk.gray(` ${errorMessage}`));
692
+ // Show validation details if available
693
+ if (error.details) {
694
+ console.error(chalk.yellow('\n Validation errors:'));
695
+ for (const [field, messages] of Object.entries(error.details)) {
696
+ const msgArray = Array.isArray(messages) ? messages : [messages];
697
+ msgArray.forEach((msg) => {
698
+ console.error(chalk.gray(` â€ĸ ${field}: ${msg}`));
699
+ });
700
+ }
701
+ }
702
+ // Show error data if available
703
+ if (error.errorData) {
704
+ const errorData = error.errorData;
705
+ if (errorData.error) {
706
+ console.error(chalk.yellow(`\n Error: ${errorData.error}`));
707
+ }
708
+ if (errorData.error_description) {
709
+ console.error(chalk.gray(` ${errorData.error_description}`));
710
+ }
711
+ // Show details if not already shown above
712
+ if (!error.details && errorData.details) {
713
+ console.error(chalk.yellow('\n Details:'));
714
+ console.error(chalk.gray(JSON.stringify(errorData.details, null, 2)));
715
+ }
716
+ }
717
+ // Show full error response in verbose mode
718
+ if (process.env.CLI_VERBOSE === 'true') {
719
+ if (error.response?.data) {
720
+ console.error(chalk.dim('\n Full error response:'));
721
+ console.error(chalk.dim(JSON.stringify(error.response.data, null, 2)));
722
+ }
723
+ if (error.response?.config) {
724
+ console.error(chalk.dim('\n Request config:'));
725
+ console.error(chalk.dim(JSON.stringify({
726
+ url: error.response.config.url,
727
+ method: error.response.config.method,
728
+ data: error.response.config.data
729
+ }, null, 2)));
730
+ }
731
+ }
658
732
  process.exit(1);
659
733
  }
660
734
  }
@@ -178,6 +178,50 @@ export function mcpCommands(program) {
178
178
  // Reload config from disk to get latest preference
179
179
  await client.init();
180
180
  const status = client.getConnectionStatus();
181
+ // Also perform a lightweight live health check against the MCP HTTP endpoint
182
+ const config = new CLIConfig();
183
+ await config.init();
184
+ let healthLabel = chalk.gray('Unknown');
185
+ let healthDetails;
186
+ try {
187
+ const axios = (await import('axios')).default;
188
+ // Derive MCP health URL from discovered REST base (e.g. https://mcp.lanonasis.com/api/v1 -> https://mcp.lanonasis.com/health)
189
+ const restUrl = config.getMCPRestUrl();
190
+ const rootBase = restUrl.replace(/\/api\/v1$/, '');
191
+ const healthUrl = `${rootBase}/health`;
192
+ const token = config.getToken();
193
+ const vendorKey = config.getVendorKey();
194
+ const headers = {};
195
+ if (vendorKey) {
196
+ headers['X-API-Key'] = vendorKey;
197
+ headers['X-Auth-Method'] = 'vendor_key';
198
+ }
199
+ else if (token) {
200
+ headers['Authorization'] = `Bearer ${token}`;
201
+ headers['X-Auth-Method'] = 'jwt';
202
+ }
203
+ const response = await axios.get(healthUrl, {
204
+ headers,
205
+ timeout: 5000
206
+ });
207
+ const overallStatus = String(response.data?.status ?? '').toLowerCase();
208
+ const ok = response.status === 200 && (!overallStatus || overallStatus === 'healthy');
209
+ if (ok) {
210
+ healthLabel = chalk.green('Reachable');
211
+ }
212
+ else {
213
+ healthLabel = chalk.yellow('Degraded');
214
+ }
215
+ }
216
+ catch (error) {
217
+ healthLabel = chalk.red('Unreachable');
218
+ if (error instanceof Error) {
219
+ healthDetails = error.message;
220
+ }
221
+ else if (error !== null && error !== undefined) {
222
+ healthDetails = String(error);
223
+ }
224
+ }
181
225
  console.log(chalk.cyan('\n📊 MCP Connection Status'));
182
226
  console.log(chalk.cyan('========================'));
183
227
  console.log(`Status: ${status.connected ? chalk.green('Connected') : chalk.red('Disconnected')}`);
@@ -198,6 +242,10 @@ export function mcpCommands(program) {
198
242
  }
199
243
  console.log(`Mode: ${modeDisplay}`);
200
244
  console.log(`Server: ${status.server}`);
245
+ console.log(`Health: ${healthLabel}`);
246
+ if (healthDetails && process.env.CLI_VERBOSE === 'true') {
247
+ console.log(chalk.gray(`Health details: ${healthDetails}`));
248
+ }
201
249
  if (status.connected) {
202
250
  if (status.mode === 'remote') {
203
251
  console.log(`\n${chalk.cyan('Features:')}`);
package/dist/index.js CHANGED
@@ -283,9 +283,68 @@ program
283
283
  // Try to use the REPL package if available
284
284
  const { spawn } = await import('child_process');
285
285
  const { fileURLToPath } = await import('url');
286
- const { dirname, join } = await import('path');
287
- // Try to find the REPL package
288
- const replPath = join(process.cwd(), 'packages', 'repl-cli', 'dist', 'index.js');
286
+ const { dirname, join, resolve } = await import('path');
287
+ const { existsSync } = await import('fs');
288
+ const moduleLib = (await import('module')).default;
289
+ const cliDir = dirname(fileURLToPath(import.meta.url));
290
+ const cliRoot = resolve(cliDir, '..');
291
+ const searchPaths = [process.cwd(), cliDir, cliRoot];
292
+ const attempted = [];
293
+ let replPath = null;
294
+ const addCandidate = (candidate) => {
295
+ if (!candidate)
296
+ return;
297
+ const normalized = resolve(candidate);
298
+ if (attempted.includes(normalized))
299
+ return;
300
+ attempted.push(normalized);
301
+ if (!replPath && existsSync(normalized)) {
302
+ replPath = normalized;
303
+ }
304
+ };
305
+ const resolveRepl = () => {
306
+ try {
307
+ return require.resolve('@lanonasis/repl-cli/dist/index.js', { paths: searchPaths });
308
+ }
309
+ catch {
310
+ // ignore resolution errors
311
+ }
312
+ try {
313
+ const pkgPath = require.resolve('@lanonasis/repl-cli/package.json', { paths: searchPaths });
314
+ return join(dirname(pkgPath), 'dist', 'index.js');
315
+ }
316
+ catch {
317
+ return null;
318
+ }
319
+ };
320
+ // Prefer Node resolution (local dependency or workspace)
321
+ addCandidate(resolveRepl());
322
+ // Monorepo layouts (compiled or running from dist)
323
+ addCandidate(join(cliRoot, '..', 'packages', 'repl-cli', 'dist', 'index.js'));
324
+ addCandidate(join(cliRoot, '..', '..', 'packages', 'repl-cli', 'dist', 'index.js'));
325
+ addCandidate(join(process.cwd(), 'apps', 'lanonasis-maas', 'packages', 'repl-cli', 'dist', 'index.js'));
326
+ addCandidate(join(process.cwd(), 'packages', 'repl-cli', 'dist', 'index.js'));
327
+ addCandidate(join(process.cwd(), 'dist', 'index.js'));
328
+ addCandidate(join(process.cwd(), '..', 'dist', 'index.js'));
329
+ // Project/local node_modules
330
+ for (const base of searchPaths) {
331
+ addCandidate(join(base, 'node_modules', '@lanonasis', 'repl-cli', 'dist', 'index.js'));
332
+ }
333
+ // Global node_modules locations
334
+ const globalPaths = Array.isArray(moduleLib?.globalPaths) ? moduleLib.globalPaths : [];
335
+ for (const globalPath of globalPaths) {
336
+ addCandidate(join(globalPath, '@lanonasis', 'repl-cli', 'dist', 'index.js'));
337
+ }
338
+ if (!replPath) {
339
+ console.error(colors.error('REPL package not found.'));
340
+ console.log(colors.muted('Searched locations:'));
341
+ attempted.forEach(c => console.log(colors.muted(` - ${c}`)));
342
+ console.log(colors.info('\n💡 Options:'));
343
+ console.log(colors.info(' 1. Run from monorepo root: cd /path/to/lan-onasis-monorepo && onasis repl'));
344
+ console.log(colors.info(' 2. Use direct command: cd apps/lanonasis-maas/packages/repl-cli && node dist/index.js start'));
345
+ console.log(colors.info(' 3. Install globally: npm install -g @lanonasis/repl-cli && onasis-repl start'));
346
+ process.exit(1);
347
+ }
289
348
  const args = ['start'];
290
349
  if (options.mcp)
291
350
  args.push('--mcp');
@@ -295,11 +354,11 @@ program
295
354
  args.push('--token', options.token);
296
355
  const repl = spawn('node', [replPath, ...args], {
297
356
  stdio: 'inherit',
298
- cwd: process.cwd()
357
+ cwd: dirname(replPath)
299
358
  });
300
359
  repl.on('error', (err) => {
301
360
  console.error(colors.error('Failed to start REPL:'), err.message);
302
- console.log(colors.muted('Make sure the REPL package is built: cd packages/repl-cli && bun run build'));
361
+ console.log(colors.muted(`Make sure the REPL package is built: cd ${dirname(replPath)} && bun run build`));
303
362
  process.exit(1);
304
363
  });
305
364
  repl.on('exit', (code) => {
@@ -308,7 +367,7 @@ program
308
367
  }
309
368
  catch (error) {
310
369
  console.error(colors.error('Failed to start REPL:'), error instanceof Error ? error.message : String(error));
311
- console.log(colors.muted('Install the REPL package: cd packages/repl-cli && bun install && bun run build'));
370
+ console.log(colors.muted('Install the REPL package: cd apps/lanonasis-maas/packages/repl-cli && bun install && bun run build'));
312
371
  process.exit(1);
313
372
  }
314
373
  });
@@ -128,11 +128,7 @@ export class EnhancedMCPClient extends EventEmitter {
128
128
  name: `lanonasis-cli-${config.name}`,
129
129
  version: '3.0.1'
130
130
  }, {
131
- capabilities: {
132
- tools: {},
133
- resources: {},
134
- prompts: {}
135
- }
131
+ capabilities: {}
136
132
  });
137
133
  await client.connect(transport);
138
134
  return client;
@@ -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
- scope?: "user" | "global";
206
205
  key?: string;
206
+ scope?: "user" | "global";
207
207
  }, {
208
208
  value?: any;
209
209
  action?: "get" | "set" | "reset";
210
- scope?: "user" | "global";
211
210
  key?: string;
211
+ scope?: "user" | "global";
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
- scope?: "user" | "global";
584
583
  key?: string;
584
+ scope?: "user" | "global";
585
585
  }, {
586
586
  value?: any;
587
587
  action?: "get" | "set" | "reset";
588
- scope?: "user" | "global";
589
588
  key?: string;
589
+ scope?: "user" | "global";
590
590
  }>;
591
591
  };
592
592
  operations: {
package/dist/utils/api.js CHANGED
@@ -2,6 +2,7 @@ import axios from 'axios';
2
2
  import chalk from 'chalk';
3
3
  import { randomUUID } from 'crypto';
4
4
  import { CLIConfig } from './config.js';
5
+ import { ensureApiKeyHash } from './hash-utils.js';
5
6
  export class APIClient {
6
7
  client;
7
8
  config;
@@ -30,7 +31,7 @@ export class APIClient {
30
31
  const vendorKey = this.config.getVendorKey();
31
32
  if (vendorKey) {
32
33
  // Vendor key authentication (validated server-side)
33
- config.headers['X-API-Key'] = vendorKey;
34
+ config.headers['X-API-Key'] = ensureApiKeyHash(vendorKey);
34
35
  config.headers['X-Auth-Method'] = 'vendor_key';
35
36
  }
36
37
  else if (token) {
@@ -42,6 +42,7 @@ export declare class CLIConfig {
42
42
  private static readonly CONFIG_VERSION;
43
43
  private authCheckCache;
44
44
  private readonly AUTH_CACHE_TTL;
45
+ private apiKeyStorage;
45
46
  constructor();
46
47
  /**
47
48
  * Overrides the configuration storage directory. Primarily used for tests.
@@ -74,6 +75,15 @@ export declare class CLIConfig {
74
75
  validateVendorKeyFormat(vendorKey: string): string | boolean;
75
76
  private validateVendorKeyWithServer;
76
77
  getVendorKey(): string | undefined;
78
+ /**
79
+ * Synchronous wrapper for async retrieve operation
80
+ * Note: ApiKeyStorage.retrieve() is async but we need sync for existing code
81
+ */
82
+ private getVendorKeySync;
83
+ /**
84
+ * Async method to get vendor key from secure storage
85
+ */
86
+ getVendorKeyAsync(): Promise<string | undefined>;
77
87
  hasVendorKey(): boolean;
78
88
  setApiUrl(url: string): Promise<void>;
79
89
  setToken(token: string): Promise<void>;
@@ -3,6 +3,7 @@ import * as path from 'path';
3
3
  import * as os from 'os';
4
4
  import { jwtDecode } from 'jwt-decode';
5
5
  import { randomUUID } from 'crypto';
6
+ import { ApiKeyStorage } from '@lanonasis/oauth-client';
6
7
  export class CLIConfig {
7
8
  configDir;
8
9
  configPath;
@@ -11,10 +12,13 @@ export class CLIConfig {
11
12
  static CONFIG_VERSION = '1.0.0';
12
13
  authCheckCache = null;
13
14
  AUTH_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
15
+ apiKeyStorage;
14
16
  constructor() {
15
17
  this.configDir = path.join(os.homedir(), '.maas');
16
18
  this.configPath = path.join(this.configDir, 'config.json');
17
19
  this.lockFile = path.join(this.configDir, 'config.lock');
20
+ // Initialize secure storage for vendor keys using oauth-client's ApiKeyStorage
21
+ this.apiKeyStorage = new ApiKeyStorage();
18
22
  }
19
23
  /**
20
24
  * Overrides the configuration storage directory. Primarily used for tests.
@@ -169,6 +173,20 @@ export class CLIConfig {
169
173
  }
170
174
  // Enhanced Service Discovery Integration
171
175
  async discoverServices(verbose = false) {
176
+ // Skip service discovery in test environment
177
+ if (process.env.NODE_ENV === 'test' || process.env.SKIP_SERVICE_DISCOVERY === 'true') {
178
+ if (!this.config.discoveredServices) {
179
+ this.config.discoveredServices = {
180
+ auth_base: 'https://auth.lanonasis.com',
181
+ memory_base: 'https://mcp.lanonasis.com/api/v1',
182
+ mcp_base: 'https://mcp.lanonasis.com/api/v1',
183
+ mcp_ws_base: 'wss://mcp.lanonasis.com/ws',
184
+ mcp_sse_base: 'https://mcp.lanonasis.com/api/v1/events',
185
+ project_scope: 'lanonasis-maas'
186
+ };
187
+ }
188
+ return;
189
+ }
172
190
  const discoveryUrl = 'https://mcp.lanonasis.com/.well-known/onasis.json';
173
191
  try {
174
192
  // Use axios instead of fetch for consistency
@@ -403,7 +421,21 @@ export class CLIConfig {
403
421
  }
404
422
  // Server-side validation
405
423
  await this.validateVendorKeyWithServer(trimmedKey);
406
- this.config.vendorKey = trimmedKey;
424
+ // Initialize and store using ApiKeyStorage from @lanonasis/oauth-client
425
+ // This handles encryption automatically (AES-256-GCM with machine-derived key)
426
+ await this.apiKeyStorage.initialize();
427
+ await this.apiKeyStorage.store({
428
+ apiKey: trimmedKey,
429
+ organizationId: this.config.user?.organization_id,
430
+ userId: this.config.user?.email,
431
+ environment: process.env.NODE_ENV || 'production',
432
+ createdAt: new Date().toISOString()
433
+ });
434
+ if (process.env.CLI_VERBOSE === 'true') {
435
+ console.log('🔐 Vendor key stored securely via @lanonasis/oauth-client');
436
+ }
437
+ // Store a reference marker in config (not the actual key)
438
+ this.config.vendorKey = 'stored_in_api_key_storage';
407
439
  this.config.authMethod = 'vendor_key';
408
440
  this.config.lastValidated = new Date().toISOString();
409
441
  await this.resetFailureCount(); // Reset failure count on successful auth
@@ -423,11 +455,45 @@ export class CLIConfig {
423
455
  // Ensure service discovery is done
424
456
  await this.discoverServices();
425
457
  const authBase = this.config.discoveredServices?.auth_base || 'https://auth.lanonasis.com';
426
- await this.pingAuthHealth(axios, authBase, {
427
- 'X-API-Key': vendorKey,
428
- 'X-Auth-Method': 'vendor_key',
429
- 'X-Project-Scope': 'lanonasis-maas'
430
- }, { timeout: 10000, proxy: false });
458
+ const normalizedBase = authBase.replace(/\/$/, '');
459
+ // Try multiple validation endpoints
460
+ const validationEndpoints = [
461
+ `${normalizedBase}/api/v1/auth/validate`,
462
+ `${normalizedBase}/api/v1/auth/validate-vendor-key`,
463
+ `${normalizedBase}/v1/auth/validate`
464
+ ];
465
+ let lastError;
466
+ let validated = false;
467
+ for (const endpoint of validationEndpoints) {
468
+ try {
469
+ const response = await axios.post(endpoint, { key: vendorKey }, {
470
+ headers: {
471
+ 'X-API-Key': vendorKey,
472
+ 'X-Auth-Method': 'vendor_key',
473
+ 'X-Project-Scope': 'lanonasis-maas',
474
+ 'Content-Type': 'application/json'
475
+ },
476
+ timeout: 10000,
477
+ proxy: false
478
+ });
479
+ // Check if response indicates validation success
480
+ if (response.data && (response.data.valid === true || response.data.success === true)) {
481
+ validated = true;
482
+ break;
483
+ }
484
+ }
485
+ catch (error) {
486
+ lastError = error;
487
+ // Continue to next endpoint if this one fails
488
+ continue;
489
+ }
490
+ }
491
+ if (!validated && lastError) {
492
+ throw lastError;
493
+ }
494
+ else if (!validated) {
495
+ throw new Error('Vendor key validation failed: Unable to validate key with server');
496
+ }
431
497
  }
432
498
  catch (error) {
433
499
  // Provide specific error messages based on response
@@ -473,9 +539,59 @@ export class CLIConfig {
473
539
  }
474
540
  }
475
541
  getVendorKey() {
476
- return this.config.vendorKey;
542
+ try {
543
+ // Retrieve from secure storage using ApiKeyStorage (synchronous wrapper)
544
+ const stored = this.getVendorKeySync();
545
+ return stored;
546
+ }
547
+ catch (error) {
548
+ if (process.env.CLI_VERBOSE === 'true') {
549
+ console.error('âš ī¸ Failed to load vendor key from secure storage:', error);
550
+ }
551
+ return undefined;
552
+ }
553
+ }
554
+ /**
555
+ * Synchronous wrapper for async retrieve operation
556
+ * Note: ApiKeyStorage.retrieve() is async but we need sync for existing code
557
+ */
558
+ getVendorKeySync() {
559
+ // For now, check legacy storage. We'll update callers to use async later
560
+ if (this.config.vendorKey && this.config.vendorKey !== 'stored_in_api_key_storage') {
561
+ if (process.env.CLI_VERBOSE === 'true') {
562
+ console.log('â„šī¸ Using legacy vendor key storage');
563
+ }
564
+ return this.config.vendorKey;
565
+ }
566
+ return undefined;
567
+ }
568
+ /**
569
+ * Async method to get vendor key from secure storage
570
+ */
571
+ async getVendorKeyAsync() {
572
+ try {
573
+ await this.apiKeyStorage.initialize();
574
+ const stored = await this.apiKeyStorage.retrieve();
575
+ if (stored) {
576
+ return stored.apiKey;
577
+ }
578
+ }
579
+ catch (error) {
580
+ if (process.env.CLI_VERBOSE === 'true') {
581
+ console.error('âš ī¸ Failed to retrieve vendor key:', error);
582
+ }
583
+ }
584
+ // Fallback: check for legacy plaintext storage in config
585
+ if (this.config.vendorKey && this.config.vendorKey !== 'stored_in_api_key_storage') {
586
+ if (process.env.CLI_VERBOSE === 'true') {
587
+ console.log('â„šī¸ Found legacy plaintext vendor key, will migrate on next auth');
588
+ }
589
+ return this.config.vendorKey;
590
+ }
591
+ return undefined;
477
592
  }
478
593
  hasVendorKey() {
594
+ // Check for marker or legacy storage
479
595
  return !!this.config.vendorKey;
480
596
  }
481
597
  async setApiUrl(url) {
@@ -517,6 +633,30 @@ export class CLIConfig {
517
633
  return this.config.user;
518
634
  }
519
635
  async isAuthenticated() {
636
+ // Check if using vendor key authentication
637
+ if (this.config.authMethod === 'vendor_key') {
638
+ const vendorKey = this.getVendorKey();
639
+ if (!vendorKey)
640
+ return false;
641
+ // Check cache first
642
+ if (this.authCheckCache && (Date.now() - this.authCheckCache.timestamp) < this.AUTH_CACHE_TTL) {
643
+ return this.authCheckCache.isValid;
644
+ }
645
+ // Check if recently validated (within 24 hours)
646
+ const lastValidated = this.config.lastValidated;
647
+ const recentlyValidated = lastValidated &&
648
+ (Date.now() - new Date(lastValidated).getTime()) < (24 * 60 * 60 * 1000);
649
+ if (recentlyValidated) {
650
+ this.authCheckCache = { isValid: true, timestamp: Date.now() };
651
+ return true;
652
+ }
653
+ // For vendor keys, we trust that they were validated during setVendorKey()
654
+ // and rely on the lastValidated timestamp. For additional security,
655
+ // the server should revoke keys that are invalid.
656
+ this.authCheckCache = { isValid: true, timestamp: Date.now() };
657
+ return true;
658
+ }
659
+ // Handle token-based authentication
520
660
  const token = this.getToken();
521
661
  if (!token)
522
662
  return false;
@@ -601,27 +741,65 @@ export class CLIConfig {
601
741
  'https://auth.lanonasis.com/v1/auth/verify-token'
602
742
  ];
603
743
  let response = null;
744
+ let networkError = false;
745
+ let authError = false;
604
746
  for (const endpoint of endpoints) {
605
747
  try {
606
748
  response = await axios.post(endpoint, { token }, { timeout: 3000 });
607
749
  if (response.data.valid === true) {
608
750
  break;
609
751
  }
752
+ // Server explicitly said invalid - this is an auth error, not network error
753
+ if (response.status === 401 || response.status === 403 || response.data.valid === false) {
754
+ authError = true;
755
+ }
610
756
  }
611
- catch {
757
+ catch (error) {
758
+ // Check if this is a network error (no response) vs auth error (got response)
759
+ if (error.response) {
760
+ // Got a response, likely 401/403
761
+ authError = true;
762
+ }
763
+ else {
764
+ // Network error (ECONNREFUSED, ETIMEDOUT, etc.)
765
+ networkError = true;
766
+ }
612
767
  // Try next endpoint
613
768
  continue;
614
769
  }
615
770
  }
616
771
  if (!response || response.data.valid !== true) {
617
- // Server says invalid - but if locally valid and recent, trust local
618
- if (locallyValid) {
772
+ // If server explicitly rejected (auth error), don't trust local validation
773
+ if (authError) {
619
774
  if (process.env.CLI_VERBOSE === 'true') {
620
- console.warn('âš ī¸ Server validation failed, but token is locally valid - using local validation');
775
+ console.warn('âš ī¸ Server validation failed with authentication error - token is invalid');
776
+ }
777
+ this.authCheckCache = { isValid: false, timestamp: Date.now() };
778
+ return false;
779
+ }
780
+ // If purely network error AND locally valid AND recently validated (within 7 days)
781
+ // allow offline usage with grace period
782
+ if (networkError && locallyValid) {
783
+ const gracePeriod = 7 * 24 * 60 * 60 * 1000; // 7 days
784
+ const lastValidated = this.config.lastValidated;
785
+ const withinGracePeriod = lastValidated &&
786
+ (Date.now() - new Date(lastValidated).getTime()) < gracePeriod;
787
+ if (withinGracePeriod) {
788
+ if (process.env.CLI_VERBOSE === 'true') {
789
+ console.warn('âš ī¸ Unable to reach server, using cached validation (offline mode)');
790
+ }
791
+ this.authCheckCache = { isValid: true, timestamp: Date.now() };
792
+ return true;
793
+ }
794
+ else {
795
+ if (process.env.CLI_VERBOSE === 'true') {
796
+ console.warn('âš ī¸ Token validation grace period expired, server validation required');
797
+ }
798
+ this.authCheckCache = { isValid: false, timestamp: Date.now() };
799
+ return false;
621
800
  }
622
- this.authCheckCache = { isValid: locallyValid, timestamp: Date.now() };
623
- return locallyValid;
624
801
  }
802
+ // Default to invalid if we can't validate
625
803
  this.authCheckCache = { isValid: false, timestamp: Date.now() };
626
804
  return false;
627
805
  }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Cryptographic utilities for secure credential storage
3
+ *
4
+ * Security Model:
5
+ * - API Keys: Hashed with SHA-256 (one-way, server compares hashes)
6
+ * - Vendor Keys: Encrypted with AES-256-GCM (reversible, needed for API headers)
7
+ * - Encryption key derived from machine-specific identifier + user password (optional)
8
+ */
9
+ /**
10
+ * Encrypted data structure
11
+ */
12
+ export interface EncryptedData {
13
+ encrypted: string;
14
+ iv: string;
15
+ authTag: string;
16
+ salt: string;
17
+ version: string;
18
+ }
19
+ /**
20
+ * Encrypt sensitive data (like vendor keys)
21
+ *
22
+ * @param data - The sensitive data to encrypt
23
+ * @param passphrase - Optional user passphrase for additional security
24
+ * @returns Encrypted data structure
25
+ */
26
+ export declare function encryptCredential(data: string, passphrase?: string): EncryptedData;
27
+ /**
28
+ * Decrypt sensitive data
29
+ *
30
+ * @param encryptedData - The encrypted data structure
31
+ * @param passphrase - Optional user passphrase (must match encryption)
32
+ * @returns Decrypted data
33
+ */
34
+ export declare function decryptCredential(encryptedData: EncryptedData, passphrase?: string): string;
35
+ /**
36
+ * Secure vendor key storage wrapper
37
+ */
38
+ export interface SecureVendorKey {
39
+ keyHash: string;
40
+ encryptedKey: EncryptedData;
41
+ createdAt: string;
42
+ lastUsed?: string;
43
+ encrypted: true;
44
+ }
45
+ /**
46
+ * Securely store a vendor key
47
+ *
48
+ * @param vendorKey - The raw vendor key
49
+ * @param passphrase - Optional user passphrase for additional security
50
+ * @returns Secure storage structure
51
+ */
52
+ export declare function secureStoreVendorKey(vendorKey: string, passphrase?: string): SecureVendorKey;
53
+ /**
54
+ * Retrieve a vendor key from secure storage
55
+ *
56
+ * @param secureKey - The secure storage structure
57
+ * @param passphrase - Optional user passphrase (must match storage)
58
+ * @returns Decrypted vendor key
59
+ */
60
+ export declare function retrieveVendorKey(secureKey: SecureVendorKey, passphrase?: string): string;
61
+ /**
62
+ * Validate a vendor key against stored hash
63
+ *
64
+ * @param vendorKey - The key to validate
65
+ * @param storedHash - The stored hash to compare against
66
+ * @returns True if key matches hash
67
+ */
68
+ export declare function validateVendorKeyHash(vendorKey: string, storedHash: string): boolean;
69
+ /**
70
+ * Check if a value is encrypted vendor key data
71
+ */
72
+ export declare function isEncryptedVendorKey(value: any): value is SecureVendorKey;
73
+ /**
74
+ * Migration helper: Check if vendor key needs encryption upgrade
75
+ */
76
+ export declare function needsEncryptionMigration(vendorKey: any): boolean;
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Cryptographic utilities for secure credential storage
3
+ *
4
+ * Security Model:
5
+ * - API Keys: Hashed with SHA-256 (one-way, server compares hashes)
6
+ * - Vendor Keys: Encrypted with AES-256-GCM (reversible, needed for API headers)
7
+ * - Encryption key derived from machine-specific identifier + user password (optional)
8
+ */
9
+ import crypto from 'crypto';
10
+ import { homedir, platform, hostname } from 'os';
11
+ import { hashApiKey, isSha256Hash } from './hash-utils.js';
12
+ // Encryption algorithm configuration
13
+ const ALGORITHM = 'aes-256-gcm';
14
+ const KEY_LENGTH = 32; // 256 bits
15
+ const IV_LENGTH = 16; // 128 bits for GCM
16
+ const AUTH_TAG_LENGTH = 16; // 128 bits
17
+ const SALT_LENGTH = 32; // 256 bits
18
+ // Default salt component for key derivation when no passphrase is provided
19
+ // This is a known constant, not a secret - it's part of the encryption scheme
20
+ const DEFAULT_SALT_COMPONENT = 'lanonasis-cli-default-salt';
21
+ /**
22
+ * Get a machine-specific identifier for key derivation
23
+ * Uses hostname + platform + homedir to create a stable machine fingerprint
24
+ */
25
+ function getMachineId() {
26
+ const machineFingerprint = `${hostname()}-${platform()}-${homedir()}`;
27
+ return crypto
28
+ .createHash('sha256')
29
+ .update(machineFingerprint)
30
+ .digest('hex');
31
+ }
32
+ /**
33
+ * Derive an encryption key from machine ID and optional user passphrase
34
+ * Uses PBKDF2 with 100,000 iterations
35
+ */
36
+ function deriveEncryptionKey(salt, passphrase) {
37
+ const baseSecret = getMachineId() + (passphrase || DEFAULT_SALT_COMPONENT);
38
+ return crypto.pbkdf2Sync(baseSecret, salt, 100000, KEY_LENGTH, 'sha256');
39
+ }
40
+ /**
41
+ * Encrypt sensitive data (like vendor keys)
42
+ *
43
+ * @param data - The sensitive data to encrypt
44
+ * @param passphrase - Optional user passphrase for additional security
45
+ * @returns Encrypted data structure
46
+ */
47
+ export function encryptCredential(data, passphrase) {
48
+ if (!data || typeof data !== 'string') {
49
+ throw new Error('Data must be a non-empty string');
50
+ }
51
+ // Generate random salt and IV
52
+ const salt = crypto.randomBytes(SALT_LENGTH);
53
+ const iv = crypto.randomBytes(IV_LENGTH);
54
+ // Derive encryption key
55
+ const key = deriveEncryptionKey(salt, passphrase);
56
+ // Create cipher and encrypt
57
+ const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
58
+ let encrypted = cipher.update(data, 'utf8', 'base64');
59
+ encrypted += cipher.final('base64');
60
+ // Get authentication tag
61
+ const authTag = cipher.getAuthTag();
62
+ return {
63
+ encrypted,
64
+ iv: iv.toString('base64'),
65
+ authTag: authTag.toString('base64'),
66
+ salt: salt.toString('base64'),
67
+ version: '1.0'
68
+ };
69
+ }
70
+ /**
71
+ * Decrypt sensitive data
72
+ *
73
+ * @param encryptedData - The encrypted data structure
74
+ * @param passphrase - Optional user passphrase (must match encryption)
75
+ * @returns Decrypted data
76
+ */
77
+ export function decryptCredential(encryptedData, passphrase) {
78
+ if (!encryptedData || typeof encryptedData !== 'object') {
79
+ throw new Error('Encrypted data must be an object');
80
+ }
81
+ try {
82
+ // Parse encrypted data components
83
+ const salt = Buffer.from(encryptedData.salt, 'base64');
84
+ const iv = Buffer.from(encryptedData.iv, 'base64');
85
+ const authTag = Buffer.from(encryptedData.authTag, 'base64');
86
+ // Derive the same encryption key
87
+ const key = deriveEncryptionKey(salt, passphrase);
88
+ // Create decipher and decrypt
89
+ const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
90
+ decipher.setAuthTag(authTag);
91
+ let decrypted = decipher.update(encryptedData.encrypted, 'base64', 'utf8');
92
+ decrypted += decipher.final('utf8');
93
+ return decrypted;
94
+ }
95
+ catch (error) {
96
+ throw new Error('Decryption failed - credential may be corrupted or wrong passphrase');
97
+ }
98
+ }
99
+ /**
100
+ * Securely store a vendor key
101
+ *
102
+ * @param vendorKey - The raw vendor key
103
+ * @param passphrase - Optional user passphrase for additional security
104
+ * @returns Secure storage structure
105
+ */
106
+ export function secureStoreVendorKey(vendorKey, passphrase) {
107
+ if (!vendorKey || typeof vendorKey !== 'string') {
108
+ throw new Error('Vendor key must be a non-empty string');
109
+ }
110
+ return {
111
+ keyHash: hashApiKey(vendorKey),
112
+ encryptedKey: encryptCredential(vendorKey, passphrase),
113
+ createdAt: new Date().toISOString(),
114
+ encrypted: true
115
+ };
116
+ }
117
+ /**
118
+ * Retrieve a vendor key from secure storage
119
+ *
120
+ * @param secureKey - The secure storage structure
121
+ * @param passphrase - Optional user passphrase (must match storage)
122
+ * @returns Decrypted vendor key
123
+ */
124
+ export function retrieveVendorKey(secureKey, passphrase) {
125
+ if (!secureKey || !secureKey.encryptedKey) {
126
+ throw new Error('Invalid secure key structure');
127
+ }
128
+ return decryptCredential(secureKey.encryptedKey, passphrase);
129
+ }
130
+ /**
131
+ * Validate a vendor key against stored hash
132
+ *
133
+ * @param vendorKey - The key to validate
134
+ * @param storedHash - The stored hash to compare against
135
+ * @returns True if key matches hash
136
+ */
137
+ export function validateVendorKeyHash(vendorKey, storedHash) {
138
+ if (!vendorKey || !storedHash) {
139
+ return false;
140
+ }
141
+ try {
142
+ const keyHash = hashApiKey(vendorKey);
143
+ return keyHash === storedHash;
144
+ }
145
+ catch {
146
+ return false;
147
+ }
148
+ }
149
+ /**
150
+ * Check if a value is encrypted vendor key data
151
+ */
152
+ export function isEncryptedVendorKey(value) {
153
+ return (value &&
154
+ typeof value === 'object' &&
155
+ value.encrypted === true &&
156
+ typeof value.keyHash === 'string' &&
157
+ isSha256Hash(value.keyHash) &&
158
+ value.encryptedKey &&
159
+ typeof value.encryptedKey.encrypted === 'string');
160
+ }
161
+ /**
162
+ * Migration helper: Check if vendor key needs encryption upgrade
163
+ */
164
+ export function needsEncryptionMigration(vendorKey) {
165
+ // If it's a plain string, it's not encrypted
166
+ if (typeof vendorKey === 'string' && !isSha256Hash(vendorKey)) {
167
+ return true;
168
+ }
169
+ // If it's not an encrypted structure, needs migration
170
+ return !isEncryptedVendorKey(vendorKey);
171
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Local Hash Utilities for CLI
3
+ * Copied from shared/hash-utils.ts to avoid TypeScript rootDir issues
4
+ */
5
+ /**
6
+ * Determine if the provided value is already a SHA-256 hex digest
7
+ */
8
+ export declare function isSha256Hash(value: string): boolean;
9
+ /**
10
+ * Hash an API key with SHA-256
11
+ * Used for: Database storage, validation, lookups
12
+ *
13
+ * @param apiKey - The raw API key to hash
14
+ * @returns SHA-256 hash as hex string (64 characters)
15
+ */
16
+ export declare function hashApiKey(apiKey: string): string;
17
+ /**
18
+ * Normalize any API key input to a SHA-256 hex digest
19
+ * Leaves an existing 64-char hex hash untouched to prevent double hashing
20
+ */
21
+ export declare function ensureApiKeyHash(apiKey: string): string;
22
+ export type ApiKeyHash = string;
23
+ export type ApiKey = string;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Local Hash Utilities for CLI
3
+ * Copied from shared/hash-utils.ts to avoid TypeScript rootDir issues
4
+ */
5
+ import crypto from 'crypto';
6
+ /**
7
+ * Determine if the provided value is already a SHA-256 hex digest
8
+ */
9
+ export function isSha256Hash(value) {
10
+ return typeof value === 'string' && /^[a-f0-9]{64}$/i.test(value.trim());
11
+ }
12
+ /**
13
+ * Hash an API key with SHA-256
14
+ * Used for: Database storage, validation, lookups
15
+ *
16
+ * @param apiKey - The raw API key to hash
17
+ * @returns SHA-256 hash as hex string (64 characters)
18
+ */
19
+ export function hashApiKey(apiKey) {
20
+ if (!apiKey || typeof apiKey !== 'string') {
21
+ throw new Error('API key must be a non-empty string');
22
+ }
23
+ return crypto
24
+ .createHash('sha256')
25
+ .update(apiKey)
26
+ .digest('hex');
27
+ }
28
+ /**
29
+ * Normalize any API key input to a SHA-256 hex digest
30
+ * Leaves an existing 64-char hex hash untouched to prevent double hashing
31
+ */
32
+ export function ensureApiKeyHash(apiKey) {
33
+ if (isSha256Hash(apiKey)) {
34
+ return apiKey.toLowerCase();
35
+ }
36
+ return hashApiKey(apiKey);
37
+ }
@@ -82,6 +82,10 @@ export declare class MCPClient {
82
82
  * Connect to MCP server with retry logic
83
83
  */
84
84
  connect(options?: MCPConnectionOptions): Promise<boolean>;
85
+ /**
86
+ * Persist successful connection mode and URLs to config for future use
87
+ */
88
+ private persistConnectionState;
85
89
  /**
86
90
  * Connect to MCP server with retry logic and exponential backoff
87
91
  */
@@ -58,6 +58,36 @@ export class MCPClient {
58
58
  this.retryAttempts = 0;
59
59
  return this.connectWithRetry(options);
60
60
  }
61
+ /**
62
+ * Persist successful connection mode and URLs to config for future use
63
+ */
64
+ async persistConnectionState(mode, url) {
65
+ try {
66
+ // Save the successful connection mode as preference
67
+ this.config.set('mcpConnectionMode', mode);
68
+ this.config.set('mcpPreference', mode);
69
+ // Save the specific URL that worked
70
+ if (url) {
71
+ if (mode === 'websocket') {
72
+ this.config.set('mcpWebSocketUrl', url);
73
+ }
74
+ else if (mode === 'remote') {
75
+ this.config.set('mcpServerUrl', url);
76
+ }
77
+ else if (mode === 'local') {
78
+ this.config.set('mcpServerPath', url);
79
+ }
80
+ }
81
+ // Save to disk
82
+ await this.config.save();
83
+ }
84
+ catch (error) {
85
+ // Don't fail connection if persistence fails, just log
86
+ if (process.env.CLI_VERBOSE === 'true') {
87
+ console.warn('âš ī¸ Failed to persist connection state:', error);
88
+ }
89
+ }
90
+ }
61
91
  /**
62
92
  * Connect to MCP server with retry logic and exponential backoff
63
93
  */
@@ -104,6 +134,8 @@ export class MCPClient {
104
134
  this.isConnected = true;
105
135
  this.activeConnectionMode = 'websocket';
106
136
  this.retryAttempts = 0;
137
+ // Persist successful connection state
138
+ await this.persistConnectionState('websocket', wsUrl);
107
139
  this.startHealthMonitoring();
108
140
  return true;
109
141
  }
@@ -125,6 +157,8 @@ export class MCPClient {
125
157
  this.isConnected = true;
126
158
  this.activeConnectionMode = 'remote';
127
159
  this.retryAttempts = 0;
160
+ // Persist successful connection state
161
+ await this.persistConnectionState('remote', serverUrl);
128
162
  this.startHealthMonitoring();
129
163
  return true;
130
164
  }
@@ -172,6 +206,8 @@ export class MCPClient {
172
206
  this.isConnected = true;
173
207
  this.activeConnectionMode = 'local';
174
208
  this.retryAttempts = 0;
209
+ // Persist successful connection state
210
+ await this.persistConnectionState('local', serverPath);
175
211
  console.log(chalk.green('✓ Connected to MCP server'));
176
212
  this.startHealthMonitoring();
177
213
  return true;
@@ -188,6 +224,8 @@ export class MCPClient {
188
224
  this.isConnected = true;
189
225
  this.activeConnectionMode = 'remote';
190
226
  this.retryAttempts = 0;
227
+ // Persist successful connection state
228
+ await this.persistConnectionState('remote', serverUrl);
191
229
  this.startHealthMonitoring();
192
230
  return true;
193
231
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lanonasis/cli",
3
- "version": "3.6.7",
3
+ "version": "3.7.1",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -17,10 +17,13 @@
17
17
  },
18
18
  "files": [
19
19
  "dist",
20
+ "scripts",
20
21
  "README.md",
21
22
  "LICENSE"
22
23
  ],
23
24
  "dependencies": {
25
+ "@lanonasis/oauth-client": "^1.0.0",
26
+ "@lanonasis/security-sdk": "^1.0.1",
24
27
  "@modelcontextprotocol/sdk": "^1.1.1",
25
28
  "axios": "^1.7.7",
26
29
  "chalk": "^5.3.0",
@@ -53,6 +56,7 @@
53
56
  "scripts": {
54
57
  "build": "rimraf dist && tsc -p tsconfig.json",
55
58
  "prepublishOnly": "npm run build",
59
+ "postinstall": "node scripts/postinstall.js",
56
60
  "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
57
61
  "test:watch": "npm test -- --watch",
58
62
  "test:coverage": "npm test -- --coverage"
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env node
2
+ import { fileURLToPath } from 'url';
3
+ import { dirname, resolve, join } from 'path';
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
5
+ import { homedir } from 'os';
6
+
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = dirname(__filename);
9
+
10
+ /**
11
+ * Postinstall script to auto-configure MCP server path
12
+ * This runs after npm/bun install and sets up the local MCP server path automatically
13
+ */
14
+
15
+ async function postinstall() {
16
+ console.log('âš™ī¸ Configuring Lanonasis CLI...');
17
+
18
+ const configDir = join(homedir(), '.maas');
19
+ const configFile = join(configDir, 'config.json');
20
+
21
+ // Ensure config directory exists
22
+ if (!existsSync(configDir)) {
23
+ mkdirSync(configDir, { recursive: true });
24
+ }
25
+
26
+ // Potential MCP server locations (in priority order)
27
+ const potentialPaths = [
28
+ // Development: relative to CLI in monorepo
29
+ resolve(__dirname, '../../../onasis-core/dist/mcp-server.js'),
30
+ resolve(__dirname, '../../../onasis-core/src/mcp/server.ts'),
31
+ // Global install: look in common locations
32
+ resolve(homedir(), 'DevOps/_project_folders/lan-onasis-monorepo/packages/onasis-core/dist/mcp-server.js'),
33
+ resolve(homedir(), 'projects/lan-onasis-monorepo/packages/onasis-core/dist/mcp-server.js'),
34
+ // Check if installed alongside CLI in node_modules
35
+ resolve(__dirname, '../../onasis-core/dist/mcp-server.js'),
36
+ ];
37
+
38
+ // Find the first existing MCP server path
39
+ let mcpServerPath = null;
40
+ for (const path of potentialPaths) {
41
+ if (existsSync(path)) {
42
+ mcpServerPath = path;
43
+ console.log(`✓ Found MCP server at: ${path}`);
44
+ break;
45
+ }
46
+ }
47
+
48
+ // Load existing config or create new one
49
+ let config = {
50
+ version: '1.0.0',
51
+ apiUrl: 'https://mcp.lanonasis.com/api/v1'
52
+ };
53
+
54
+ if (existsSync(configFile)) {
55
+ try {
56
+ const existingConfig = readFileSync(configFile, 'utf-8');
57
+ config = JSON.parse(existingConfig);
58
+ } catch (error) {
59
+ console.warn('âš ī¸ Could not read existing config, will create new one');
60
+ }
61
+ }
62
+
63
+ // Update config with MCP server path if found
64
+ if (mcpServerPath) {
65
+ config.mcpServerPath = mcpServerPath;
66
+ config.mcpConnectionMode = 'local';
67
+ config.mcpPreference = 'local';
68
+ console.log('✓ Configured local MCP server path');
69
+ } else {
70
+ // No local server found, prefer remote/websocket
71
+ console.log('â„šī¸ No local MCP server found, will use remote connection');
72
+ config.mcpConnectionMode = 'websocket';
73
+ config.mcpPreference = 'websocket';
74
+ config.mcpWebSocketUrl = 'wss://mcp.lanonasis.com/ws';
75
+ }
76
+
77
+ // Save config
78
+ try {
79
+ writeFileSync(configFile, JSON.stringify(config, null, 2), 'utf-8');
80
+ console.log('✓ Configuration saved to ~/.maas/config.json');
81
+ } catch (error) {
82
+ console.error('✖ Failed to save configuration:', error.message);
83
+ }
84
+
85
+ console.log('');
86
+ console.log('🎉 Lanonasis CLI configured successfully!');
87
+ console.log('');
88
+ console.log('Get started:');
89
+ console.log(' onasis --help # Show available commands');
90
+ console.log(' onasis auth # Authenticate with your account');
91
+ console.log(' onasis mcp connect # Connect to MCP services');
92
+ console.log('');
93
+ }
94
+
95
+ // Run postinstall
96
+ postinstall().catch((error) => {
97
+ console.error('✖ Postinstall failed:', error);
98
+ // Don't fail installation if postinstall fails
99
+ process.exit(0);
100
+ });