@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 +19 -2
- package/dist/commands/auth.js +85 -11
- package/dist/commands/mcp.js +48 -0
- package/dist/index.js +65 -6
- package/dist/mcp/client/enhanced-client.js +1 -5
- package/dist/mcp/schemas/tool-schemas.d.ts +4 -4
- package/dist/utils/api.js +2 -1
- package/dist/utils/config.d.ts +10 -0
- package/dist/utils/config.js +191 -13
- package/dist/utils/crypto-utils.d.ts +76 -0
- package/dist/utils/crypto-utils.js +171 -0
- package/dist/utils/hash-utils.d.ts +23 -0
- package/dist/utils/hash-utils.js +37 -0
- package/dist/utils/mcp-client.d.ts +4 -0
- package/dist/utils/mcp-client.js +38 -0
- package/package.json +5 -1
- package/scripts/postinstall.js +100 -0
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# @lanonasis/cli v3.
|
|
1
|
+
# @lanonasis/cli v3.7.0 - Enterprise Security & MCP Experience
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@lanonasis/cli)
|
|
4
4
|
[](https://www.npmjs.com/package/@lanonasis/cli)
|
|
@@ -53,7 +53,24 @@ memory list
|
|
|
53
53
|
maas memory list
|
|
54
54
|
```
|
|
55
55
|
|
|
56
|
-
## đ Authentication
|
|
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
|
|
package/dist/commands/auth.js
CHANGED
|
@@ -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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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',
|
|
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
|
-
|
|
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
|
}
|
package/dist/commands/mcp.js
CHANGED
|
@@ -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
|
-
|
|
288
|
-
const
|
|
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:
|
|
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(
|
|
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) {
|
package/dist/utils/config.d.ts
CHANGED
|
@@ -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>;
|
package/dist/utils/config.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
618
|
-
if (
|
|
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
|
|
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
|
*/
|
package/dist/utils/mcp-client.js
CHANGED
|
@@ -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.
|
|
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
|
+
});
|