@lanonasis/cli 3.9.10 → 3.9.12

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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog - @lanonasis/cli
2
2
 
3
+ ## [3.9.11] - 2026-03-27
4
+
5
+ ### 🐛 Bug Fixes
6
+
7
+ - **`memory stats` no longer crashes on wrapped responses**: The CLI now normalizes both flat and `{ data: ... }` stats payloads and safely defaults optional fields like `total_size_bytes` and `avg_access_count` when the backend omits them.
8
+ - **Secure storage no longer initializes on every command startup**: Vendor key storage is now lazy-loaded, which prevents unnecessary keychain fallback warnings on CLI commands that do not access vendor key storage.
9
+ - **Bundled auth client updated to `@lanonasis/oauth-client@2.0.4`**: Pulls in the ESM-safe native keychain loader fix and semver-compliant optional React peer metadata.
10
+
3
11
  ## [3.9.8] - 2026-02-25
4
12
 
5
13
  ### ✨ New Features
package/README.md CHANGED
@@ -1,11 +1,11 @@
1
- # @lanonasis/cli v3.9.7 - OAuth PKCE Auth & whoami
1
+ # @lanonasis/cli v3.9.11 - Stable Stats & Cleaner Startup
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)
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
6
  [![Golden Contract](https://img.shields.io/badge/Onasis--Core-v0.1%20Compliant-gold)](https://api.lanonasis.com/.well-known/onasis.json)
7
7
 
8
- 🎉 **NEW IN v3.9.7**: Full OAuth PKCE session support across CLI and API gateway. New `onasis whoami` command. `auth status` now shows live user profile and probes real memory API access. Seven auth verification fixes eliminate false-positive "Authenticated: Yes" reports.
8
+ 🎉 **NEW IN v3.9.11**: `onasis memory stats` now handles wrapped/partial backend responses without crashing, vendor key secure storage is lazy-loaded so unrelated commands no longer trigger noisy fallback warnings, and the bundled `@lanonasis/oauth-client` is updated to `2.0.4` for the ESM-safe keychain loader fix.
9
9
 
10
10
  ## 🚀 Quick Start
11
11
 
@@ -15,6 +15,24 @@ const colors = {
15
15
  muted: chalk.gray,
16
16
  highlight: chalk.white.bold
17
17
  };
18
+ const AUTH_API_KEYS_BASE = '/api/v1/auth/api-keys';
19
+ const VALID_ACCESS_LEVELS = ['public', 'authenticated', 'team', 'admin', 'enterprise'];
20
+ function unwrapApiResponse(response) {
21
+ if (response && typeof response === 'object' && 'data' in response) {
22
+ return response.data ?? response;
23
+ }
24
+ return response;
25
+ }
26
+ function parseScopes(scopes) {
27
+ if (!scopes) {
28
+ return undefined;
29
+ }
30
+ const parsed = scopes
31
+ .split(',')
32
+ .map((scope) => scope.trim())
33
+ .filter(Boolean);
34
+ return parsed.length > 0 ? parsed : undefined;
35
+ }
18
36
  const apiKeysCommand = new Command('api-keys')
19
37
  .alias('keys')
20
38
  .description(colors.info('🔐 Manage API keys securely with enterprise-grade encryption'));
@@ -65,7 +83,8 @@ projectsCommand
65
83
  ]);
66
84
  projectData = { ...projectData, ...answers };
67
85
  }
68
- const project = await apiClient.post('/api-keys/projects', projectData);
86
+ const projectRes = await apiClient.post(`${AUTH_API_KEYS_BASE}/projects`, projectData);
87
+ const project = unwrapApiResponse(projectRes);
69
88
  console.log(chalk.green('✅ Project created successfully!'));
70
89
  console.log(chalk.blue(`Project ID: ${project.id}`));
71
90
  console.log(chalk.blue(`Name: ${project.name}`));
@@ -85,12 +104,12 @@ projectsCommand
85
104
  .option('--json', 'Output as JSON')
86
105
  .action(async (options) => {
87
106
  try {
88
- const projects = await apiClient.get('/api-keys/projects');
107
+ const projects = unwrapApiResponse(await apiClient.get(`${AUTH_API_KEYS_BASE}/projects`));
89
108
  if (options.json) {
90
109
  console.log(JSON.stringify(projects, null, 2));
91
110
  return;
92
111
  }
93
- if (projects.length === 0) {
112
+ if (!Array.isArray(projects) || projects.length === 0) {
94
113
  console.log(chalk.yellow('No projects found'));
95
114
  return;
96
115
  }
@@ -122,30 +141,29 @@ apiKeysCommand
122
141
  .command('create')
123
142
  .description('Create a new API key')
124
143
  .option('-n, --name <name>', 'API key name')
125
- .option('-v, --value <value>', 'API key value')
126
- .option('-t, --type <type>', 'Key type (api_key, database_url, oauth_token, etc.)')
127
- .option('-e, --environment <env>', 'Environment (development, staging, production)')
128
- .option('-p, --project-id <id>', 'Project ID')
129
- .option('--access-level <level>', 'Access level (public, authenticated, team, admin, enterprise)')
130
- .option('--tags <tags>', 'Comma-separated tags')
131
- .option('--expires-at <date>', 'Expiration date (ISO format)')
132
- .option('--rotation-frequency <days>', 'Rotation frequency in days', '90')
144
+ .option('-d, --description <description>', 'API key description (optional)')
145
+ .option('--access-level <level>', 'Access level (public, authenticated, team, admin, enterprise)', 'team')
146
+ .option('--expires-in-days <days>', 'Expiration in days (default: 365)', '365')
147
+ .option('--scopes <scopes>', 'Comma-separated scopes (optional)')
133
148
  .option('--interactive', 'Interactive mode')
134
149
  .action(async (options) => {
135
150
  try {
151
+ const accessLevel = (options.accessLevel || 'team').toLowerCase();
152
+ const expiresInDays = parseInt(options.expiresInDays, 10);
153
+ if (!VALID_ACCESS_LEVELS.includes(accessLevel)) {
154
+ throw new Error('Invalid access level. Allowed: public, authenticated, team, admin, enterprise');
155
+ }
156
+ if (!Number.isInteger(expiresInDays) || expiresInDays <= 0 || expiresInDays > 3650) {
157
+ throw new Error('expires-in-days must be a positive integer up to 3650');
158
+ }
136
159
  let keyData = {
137
160
  name: options.name,
138
- value: options.value,
139
- keyType: options.type,
140
- environment: options.environment || 'development',
141
- projectId: options.projectId,
142
- accessLevel: options.accessLevel || 'team',
143
- tags: options.tags ? options.tags.split(',').map((tag) => tag.trim()) : [],
144
- expiresAt: options.expiresAt,
145
- rotationFrequency: parseInt(options.rotationFrequency)
161
+ access_level: accessLevel,
162
+ expires_in_days: expiresInDays,
163
+ description: options.description?.trim() || undefined,
164
+ scopes: parseScopes(options.scopes)
146
165
  };
147
- if (options.interactive || !keyData.name || !keyData.value || !keyData.projectId) {
148
- const projects = await apiClient.get('/api-keys/projects');
166
+ if (options.interactive || !keyData.name) {
149
167
  const answers = await inquirer.prompt([
150
168
  {
151
169
  type: 'input',
@@ -155,85 +173,62 @@ apiKeysCommand
155
173
  validate: (input) => input.length > 0 || 'Name is required'
156
174
  },
157
175
  {
158
- type: 'password',
159
- name: 'value',
160
- message: 'API key value:',
161
- when: !keyData.value,
162
- validate: (input) => input.length > 0 || 'Value is required'
163
- },
164
- {
165
- type: 'select',
166
- name: 'keyType',
167
- message: 'Key type:',
168
- when: !keyData.keyType,
169
- choices: [
170
- 'api_key',
171
- 'database_url',
172
- 'oauth_token',
173
- 'certificate',
174
- 'ssh_key',
175
- 'webhook_secret',
176
- 'encryption_key'
177
- ]
178
- },
179
- {
180
- type: 'select',
181
- name: 'environment',
182
- message: 'Environment:',
183
- choices: ['development', 'staging', 'production'],
184
- default: 'development'
185
- },
186
- {
187
- type: 'select',
188
- name: 'projectId',
189
- message: 'Select project:',
190
- when: !keyData.projectId && projects.length > 0,
191
- choices: projects.map((p) => ({ name: `${p.name} (${p.id})`, value: p.id }))
176
+ type: 'input',
177
+ name: 'description',
178
+ message: 'Description (optional):',
179
+ default: keyData.description || ''
192
180
  },
193
181
  {
194
182
  type: 'select',
195
- name: 'accessLevel',
183
+ name: 'access_level',
196
184
  message: 'Access level:',
197
- choices: ['public', 'authenticated', 'team', 'admin', 'enterprise'],
198
- default: 'team'
185
+ choices: VALID_ACCESS_LEVELS,
186
+ default: keyData.access_level
199
187
  },
200
188
  {
201
- type: 'input',
202
- name: 'tags',
203
- message: 'Tags (comma-separated, optional):',
204
- filter: (input) => input ? input.split(',').map((tag) => tag.trim()) : []
189
+ type: 'number',
190
+ name: 'expires_in_days',
191
+ message: 'Expires in days:',
192
+ default: keyData.expires_in_days,
193
+ validate: (input) => Number.isInteger(input) && input > 0 && input <= 3650 || 'Must be between 1 and 3650 days'
205
194
  },
206
195
  {
207
196
  type: 'input',
208
- name: 'expiresAt',
209
- message: 'Expiration date (YYYY-MM-DD, optional):',
210
- validate: (input) => {
211
- if (!input)
212
- return true;
213
- const date = new Date(input);
214
- return !isNaN(date.getTime()) || 'Please enter a valid date';
215
- },
216
- filter: (input) => input ? new Date(input).toISOString() : undefined
217
- },
218
- {
219
- type: 'number',
220
- name: 'rotationFrequency',
221
- message: 'Rotation frequency (days):',
222
- default: 90,
223
- validate: (input) => input > 0 && input <= 365 || 'Must be between 1 and 365 days'
197
+ name: 'scopes',
198
+ message: 'Scopes (comma-separated, optional):',
199
+ default: (keyData.scopes || []).join(', ')
224
200
  }
225
201
  ]);
226
- keyData = { ...keyData, ...answers };
202
+ keyData = {
203
+ ...keyData,
204
+ ...answers,
205
+ description: typeof answers.description === 'string'
206
+ ? answers.description.trim() || undefined
207
+ : keyData.description,
208
+ scopes: parseScopes(typeof answers.scopes === 'string' ? answers.scopes : undefined) ?? keyData.scopes
209
+ };
227
210
  }
228
- const apiKey = await apiClient.post('/api-keys', keyData);
211
+ const apiKey = unwrapApiResponse(await apiClient.post(AUTH_API_KEYS_BASE, keyData));
229
212
  console.log(colors.success('🔐 API key created successfully!'));
230
213
  console.log(colors.info('━'.repeat(50)));
231
214
  console.log(`${colors.highlight('Key ID:')} ${colors.primary(apiKey.id)}`);
232
215
  console.log(`${colors.highlight('Name:')} ${colors.accent(apiKey.name)}`);
233
- console.log(`${colors.highlight('Type:')} ${colors.info(apiKey.keyType)}`);
234
- console.log(`${colors.highlight('Environment:')} ${colors.accent(apiKey.environment)}`);
216
+ console.log(`${colors.highlight('Access Level:')} ${colors.info(apiKey.access_level || keyData.access_level)}`);
217
+ console.log(`${colors.highlight('Permissions:')} ${colors.muted((apiKey.permissions || keyData.scopes || []).join(', ') || 'legacy:full_access')}`);
218
+ if (apiKey.expires_at) {
219
+ console.log(`${colors.highlight('Expires At:')} ${colors.warning(formatDate(apiKey.expires_at))}`);
220
+ }
221
+ if (keyData.description) {
222
+ console.log(`${colors.highlight('Description:')} ${colors.muted(keyData.description)}`);
223
+ }
235
224
  console.log(colors.info('━'.repeat(50)));
236
- console.log(colors.warning('⚠️ The key value is securely encrypted and cannot be retrieved later.'));
225
+ if (apiKey.key) {
226
+ console.log(`${colors.highlight('API Key:')} ${colors.primary(apiKey.key)}`);
227
+ console.log(colors.warning('⚠️ Save this key now. It will not be shown again.'));
228
+ }
229
+ else {
230
+ console.log(colors.warning('⚠️ Key value was not returned. If newly created, it cannot be retrieved later.'));
231
+ }
237
232
  }
238
233
  catch (error) {
239
234
  const errorMessage = error instanceof Error ? error.message : String(error);
@@ -245,15 +240,12 @@ apiKeysCommand
245
240
  .command('list')
246
241
  .alias('ls')
247
242
  .description('List API keys')
248
- .option('-p, --project-id <id>', 'Filter by project ID')
243
+ .option('--all', 'Include inactive keys')
249
244
  .option('--json', 'Output as JSON')
250
245
  .action(async (options) => {
251
246
  try {
252
- let url = '/api-keys';
253
- if (options.projectId) {
254
- url += `?projectId=${options.projectId}`;
255
- }
256
- const apiKeys = await apiClient.get(url);
247
+ const query = options.all ? '?active_only=false' : '';
248
+ const apiKeys = unwrapApiResponse(await apiClient.get(`${AUTH_API_KEYS_BASE}${query}`));
257
249
  if (options.json) {
258
250
  console.log(JSON.stringify(apiKeys, null, 2));
259
251
  return;
@@ -266,20 +258,19 @@ apiKeysCommand
266
258
  console.log(colors.primary('🔐 API Key Management'));
267
259
  console.log(colors.info('═'.repeat(80)));
268
260
  const table = new Table({
269
- head: ['ID', 'Name', 'Type', 'Environment', 'Status', 'Usage', 'Last Rotated'].map(h => colors.accent(h)),
261
+ head: ['ID', 'Name', 'Access', 'Permissions', 'Service', 'Status', 'Expires'].map(h => colors.accent(h)),
270
262
  style: { head: [], border: [] }
271
263
  });
272
264
  apiKeys.forEach((key) => {
273
- const statusColor = key.status === 'active' ? colors.success :
274
- key.status === 'rotating' ? colors.warning : colors.error;
265
+ const statusColor = key.is_active ? colors.success : colors.error;
275
266
  table.push([
276
267
  truncateText(key.id, 20),
277
268
  key.name,
278
- key.keyType,
279
- key.environment,
280
- statusColor(key.status),
281
- colors.highlight(key.usageCount.toString()),
282
- formatDate(key.lastRotated)
269
+ key.access_level,
270
+ truncateText((key.permissions || []).join(', ') || 'legacy:full_access', 28),
271
+ key.service || 'all',
272
+ statusColor(key.is_active ? 'active' : 'inactive'),
273
+ key.expires_at ? formatDate(key.expires_at) : colors.muted('Never')
283
274
  ]);
284
275
  });
285
276
  console.log(table.toString());
@@ -299,7 +290,7 @@ apiKeysCommand
299
290
  .option('--json', 'Output as JSON')
300
291
  .action(async (keyId, options) => {
301
292
  try {
302
- const apiKey = await apiClient.get(`/api-keys/${keyId}`);
293
+ const apiKey = unwrapApiResponse(await apiClient.get(`${AUTH_API_KEYS_BASE}/${keyId}`));
303
294
  if (options.json) {
304
295
  console.log(JSON.stringify(apiKey, null, 2));
305
296
  return;
@@ -308,21 +299,20 @@ apiKeysCommand
308
299
  console.log(colors.info('═'.repeat(60)));
309
300
  console.log(`${colors.highlight('ID:')} ${colors.primary(apiKey.id)}`);
310
301
  console.log(`${colors.highlight('Name:')} ${colors.accent(apiKey.name)}`);
311
- console.log(`${colors.highlight('Type:')} ${colors.info(apiKey.keyType)}`);
312
- console.log(`${colors.highlight('Environment:')} ${colors.accent(apiKey.environment)}`);
313
- console.log(`${colors.highlight('Project ID:')} ${colors.muted(apiKey.projectId)}`);
314
- console.log(`${colors.highlight('Access Level:')} ${colors.warning(apiKey.accessLevel)}`);
315
- const statusColor = apiKey.status === 'active' ? colors.success :
316
- apiKey.status === 'rotating' ? colors.warning : colors.error;
317
- console.log(`${colors.highlight('Status:')} ${statusColor(apiKey.status)}`);
318
- console.log(`${colors.highlight('Usage Count:')} ${colors.accent(apiKey.usageCount)}`);
319
- console.log(`${colors.highlight('Tags:')} ${colors.muted(apiKey.tags.join(', ') || 'None')}`);
320
- console.log(`${colors.highlight('Rotation Frequency:')} ${colors.info(apiKey.rotationFrequency)} days`);
321
- console.log(`${colors.highlight('Last Rotated:')} ${colors.muted(formatDate(apiKey.lastRotated))}`);
322
- console.log(`${colors.highlight('Created:')} ${colors.muted(formatDate(apiKey.createdAt))}`);
323
- console.log(`${colors.highlight('Updated:')} ${colors.muted(formatDate(apiKey.updatedAt))}`);
324
- if (apiKey.expiresAt) {
325
- console.log(`${colors.highlight('Expires:')} ${colors.warning(formatDate(apiKey.expiresAt))}`);
302
+ if (apiKey.description) {
303
+ console.log(`${colors.highlight('Description:')} ${colors.muted(apiKey.description)}`);
304
+ }
305
+ console.log(`${colors.highlight('Access Level:')} ${colors.warning(apiKey.access_level)}`);
306
+ console.log(`${colors.highlight('Permissions:')} ${colors.muted((apiKey.permissions || []).join(', ') || 'legacy:full_access')}`);
307
+ console.log(`${colors.highlight('Service Scope:')} ${colors.info(apiKey.service || 'all')}`);
308
+ const statusColor = apiKey.is_active ? colors.success : colors.error;
309
+ console.log(`${colors.highlight('Status:')} ${statusColor(apiKey.is_active ? 'active' : 'inactive')}`);
310
+ if (apiKey.last_used_at) {
311
+ console.log(`${colors.highlight('Last Used:')} ${colors.muted(formatDate(apiKey.last_used_at))}`);
312
+ }
313
+ console.log(`${colors.highlight('Created:')} ${colors.muted(formatDate(apiKey.created_at))}`);
314
+ if (apiKey.expires_at) {
315
+ console.log(`${colors.highlight('Expires:')} ${colors.warning(formatDate(apiKey.expires_at))}`);
326
316
  }
327
317
  console.log(colors.info('═'.repeat(60)));
328
318
  }
@@ -337,23 +327,39 @@ apiKeysCommand
337
327
  .description('Update an API key')
338
328
  .argument('<keyId>', 'API key ID')
339
329
  .option('-n, --name <name>', 'New name')
340
- .option('-v, --value <value>', 'New value')
341
- .option('--tags <tags>', 'Comma-separated tags')
342
- .option('--rotation-frequency <days>', 'Rotation frequency in days')
330
+ .option('-d, --description <description>', 'New description')
331
+ .option('--access-level <level>', 'New access level')
332
+ .option('--expires-in-days <days>', 'Set a new expiry in days')
333
+ .option('--clear-expiry', 'Remove the current expiry')
334
+ .option('--scopes <scopes>', 'Replace scopes with a comma-separated list')
343
335
  .option('--interactive', 'Interactive mode')
344
336
  .action(async (keyId, options) => {
345
337
  try {
346
338
  let updateData = {};
347
339
  if (options.name)
348
340
  updateData.name = options.name;
349
- if (options.value)
350
- updateData.value = options.value;
351
- if (options.tags)
352
- updateData.tags = options.tags.split(',').map((tag) => tag.trim());
353
- if (options.rotationFrequency)
354
- updateData.rotationFrequency = parseInt(options.rotationFrequency);
341
+ if (options.description !== undefined)
342
+ updateData.description = options.description.trim() || null;
343
+ if (options.accessLevel) {
344
+ const accessLevel = options.accessLevel.toLowerCase();
345
+ if (!VALID_ACCESS_LEVELS.includes(accessLevel)) {
346
+ throw new Error('Invalid access level. Allowed: public, authenticated, team, admin, enterprise');
347
+ }
348
+ updateData.access_level = accessLevel;
349
+ }
350
+ if (options.expiresInDays) {
351
+ const expiresInDays = parseInt(options.expiresInDays, 10);
352
+ if (!Number.isInteger(expiresInDays) || expiresInDays <= 0 || expiresInDays > 3650) {
353
+ throw new Error('expires-in-days must be a positive integer up to 3650');
354
+ }
355
+ updateData.expires_in_days = expiresInDays;
356
+ }
357
+ if (options.clearExpiry)
358
+ updateData.clear_expiry = true;
359
+ if (options.scopes)
360
+ updateData.scopes = parseScopes(options.scopes);
355
361
  if (options.interactive || Object.keys(updateData).length === 0) {
356
- const current = await apiClient.get(`/api-keys/${keyId}`);
362
+ const current = unwrapApiResponse(await apiClient.get(`${AUTH_API_KEYS_BASE}/${keyId}`));
357
363
  const answers = await inquirer.prompt([
358
364
  {
359
365
  type: 'input',
@@ -362,47 +368,75 @@ apiKeysCommand
362
368
  default: current.name,
363
369
  when: !updateData.name
364
370
  },
371
+ {
372
+ type: 'input',
373
+ name: 'description',
374
+ message: 'Description (optional):',
375
+ default: current.description || '',
376
+ when: !updateData.description
377
+ },
378
+ {
379
+ type: 'select',
380
+ name: 'access_level',
381
+ message: 'Access level:',
382
+ choices: VALID_ACCESS_LEVELS,
383
+ default: current.access_level,
384
+ when: !updateData.access_level
385
+ },
365
386
  {
366
387
  type: 'confirm',
367
- name: 'updateValue',
368
- message: 'Update the key value?',
388
+ name: 'changeExpiry',
389
+ message: 'Change expiry?',
369
390
  default: false,
370
- when: !updateData.value
391
+ when: updateData.expires_in_days === undefined && !updateData.clear_expiry
371
392
  },
372
393
  {
373
- type: 'password',
374
- name: 'value',
375
- message: 'New key value:',
376
- when: (answers) => answers.updateValue
394
+ type: 'number',
395
+ name: 'expires_in_days',
396
+ message: 'Expires in days:',
397
+ default: current.expires_at ? 365 : 365,
398
+ when: (answers) => answers.changeExpiry === true && updateData.expires_in_days === undefined && !updateData.clear_expiry,
399
+ validate: (input) => Number.isInteger(input) && input > 0 && input <= 3650 || 'Must be between 1 and 3650 days'
377
400
  },
378
401
  {
379
- type: 'input',
380
- name: 'tags',
381
- message: 'Tags (comma-separated):',
382
- default: current.tags.join(', '),
383
- filter: (input) => input.split(',').map((tag) => tag.trim()),
384
- when: !updateData.tags
402
+ type: 'confirm',
403
+ name: 'clear_expiry',
404
+ message: 'Clear expiry instead?',
405
+ default: false,
406
+ when: (answers) => answers.changeExpiry === true && updateData.expires_in_days === undefined && !updateData.clear_expiry && Boolean(current.expires_at)
385
407
  },
386
408
  {
387
- type: 'number',
388
- name: 'rotationFrequency',
389
- message: 'Rotation frequency (days):',
390
- default: current.rotationFrequency,
391
- validate: (input) => input > 0 && input <= 365 || 'Must be between 1 and 365 days',
392
- when: !updateData.rotationFrequency
409
+ type: 'input',
410
+ name: 'scopes',
411
+ message: 'Scopes (comma-separated, optional):',
412
+ default: (current.permissions || []).join(', '),
413
+ when: !updateData.scopes
393
414
  }
394
415
  ]);
395
- updateData = { ...updateData, ...answers };
396
- delete updateData.updateValue;
416
+ updateData = {
417
+ ...updateData,
418
+ ...answers,
419
+ description: typeof answers.description === 'string'
420
+ ? answers.description.trim() || null
421
+ : updateData.description,
422
+ scopes: parseScopes(typeof answers.scopes === 'string' ? answers.scopes : undefined) ?? updateData.scopes
423
+ };
424
+ delete updateData.changeExpiry;
425
+ }
426
+ if (Object.keys(updateData).length === 0) {
427
+ console.log(colors.warning('🚫 Nothing to update'));
428
+ return;
397
429
  }
398
- const updatedKey = await apiClient.put(`/api-keys/${keyId}`, updateData);
430
+ const updatedKey = unwrapApiResponse(await apiClient.put(`${AUTH_API_KEYS_BASE}/${keyId}`, updateData));
399
431
  console.log(colors.success('🔄 API key updated successfully!'));
400
432
  console.log(colors.info('━'.repeat(40)));
401
433
  console.log(`${colors.highlight('Name:')} ${colors.accent(updatedKey.name)}`);
402
- console.log(`${colors.highlight('Status:')} ${colors.success(updatedKey.status)}`);
403
- if (updateData.value) {
404
- console.log(colors.warning('⚠️ The key value has been updated and re-encrypted.'));
434
+ if (updatedKey.description || updateData.description) {
435
+ console.log(`${colors.highlight('Description:')} ${colors.muted(updatedKey.description || updateData.description)}`);
405
436
  }
437
+ console.log(`${colors.highlight('Access Level:')} ${colors.info(updatedKey.access_level)}`);
438
+ console.log(`${colors.highlight('Permissions:')} ${colors.muted((updatedKey.permissions || []).join(', ') || 'legacy:full_access')}`);
439
+ console.log(`${colors.highlight('Expires:')} ${updatedKey.expires_at ? colors.warning(formatDate(updatedKey.expires_at)) : colors.muted('Never')}`);
406
440
  console.log(colors.info('━'.repeat(40)));
407
441
  }
408
442
  catch (error) {
@@ -420,7 +454,7 @@ apiKeysCommand
420
454
  .action(async (keyId, options) => {
421
455
  try {
422
456
  if (!options.force) {
423
- const apiKey = await apiClient.get(`/api-keys/${keyId}`);
457
+ const apiKey = unwrapApiResponse(await apiClient.get(`${AUTH_API_KEYS_BASE}/${keyId}`));
424
458
  const { confirm } = await inquirer.prompt([
425
459
  {
426
460
  type: 'confirm',
@@ -434,7 +468,7 @@ apiKeysCommand
434
468
  return;
435
469
  }
436
470
  }
437
- await apiClient.delete(`/api-keys/${keyId}`);
471
+ await apiClient.delete(`${AUTH_API_KEYS_BASE}/${keyId}`);
438
472
  console.log(colors.success('🗑️ API key deleted successfully!'));
439
473
  }
440
474
  catch (error) {
@@ -564,7 +598,7 @@ mcpCommand
564
598
  delete toolData.maxConcurrentSessions;
565
599
  delete toolData.maxSessionDuration;
566
600
  }
567
- const tool = await apiClient.post('/api-keys/mcp/tools', toolData);
601
+ const tool = unwrapApiResponse(await apiClient.post(`${AUTH_API_KEYS_BASE}/mcp/tools`, toolData));
568
602
  console.log(colors.success('🤖 MCP tool registered successfully!'));
569
603
  console.log(colors.info('━'.repeat(50)));
570
604
  console.log(`${colors.highlight('Tool ID:')} ${colors.primary(tool.toolId)}`);
@@ -584,12 +618,12 @@ mcpCommand
584
618
  .option('--json', 'Output as JSON')
585
619
  .action(async (options) => {
586
620
  try {
587
- const tools = await apiClient.get('/api-keys/mcp/tools');
621
+ const tools = unwrapApiResponse(await apiClient.get(`${AUTH_API_KEYS_BASE}/mcp/tools`));
588
622
  if (options.json) {
589
623
  console.log(JSON.stringify(tools, null, 2));
590
624
  return;
591
625
  }
592
- if (tools.length === 0) {
626
+ if (!Array.isArray(tools) || tools.length === 0) {
593
627
  console.log(colors.warning('⚠️ No MCP tools found'));
594
628
  console.log(colors.muted('Run: lanonasis api-keys mcp register-tool'));
595
629
  return;
@@ -644,7 +678,8 @@ mcpCommand
644
678
  };
645
679
  if (options.interactive || !requestData.toolId || !requestData.organizationId ||
646
680
  requestData.keyNames.length === 0 || !requestData.environment || !requestData.justification) {
647
- const tools = await apiClient.get('/api-keys/mcp/tools');
681
+ const mcpTools = unwrapApiResponse(await apiClient.get(`${AUTH_API_KEYS_BASE}/mcp/tools`));
682
+ const tools = Array.isArray(mcpTools) ? mcpTools : [];
648
683
  const answers = await inquirer.prompt([
649
684
  {
650
685
  type: 'select',
@@ -695,7 +730,7 @@ mcpCommand
695
730
  ]);
696
731
  requestData = { ...requestData, ...answers };
697
732
  }
698
- const response = await apiClient.post('/api-keys/mcp/request-access', requestData);
733
+ const response = unwrapApiResponse(await apiClient.post(`${AUTH_API_KEYS_BASE}/mcp/request-access`, requestData));
699
734
  console.log(colors.success('🔐 Access request created successfully!'));
700
735
  console.log(colors.info('━'.repeat(50)));
701
736
  console.log(`${colors.highlight('Request ID:')} ${colors.primary(response.requestId)}`);
@@ -721,7 +756,7 @@ analyticsCommand
721
756
  .option('--json', 'Output as JSON')
722
757
  .action(async (options) => {
723
758
  try {
724
- let url = '/api-keys/analytics/usage';
759
+ let url = `${AUTH_API_KEYS_BASE}/analytics/usage`;
725
760
  const params = new URLSearchParams();
726
761
  if (options.keyId)
727
762
  params.append('keyId', options.keyId);
@@ -730,12 +765,12 @@ analyticsCommand
730
765
  if (params.toString()) {
731
766
  url += `?${params.toString()}`;
732
767
  }
733
- const analytics = await apiClient.get(url);
768
+ const analytics = unwrapApiResponse(await apiClient.get(url));
734
769
  if (options.json) {
735
770
  console.log(JSON.stringify(analytics, null, 2));
736
771
  return;
737
772
  }
738
- if (analytics.length === 0) {
773
+ if (!Array.isArray(analytics) || analytics.length === 0) {
739
774
  console.log(chalk.yellow('No usage data found'));
740
775
  return;
741
776
  }
@@ -770,16 +805,16 @@ analyticsCommand
770
805
  .option('--json', 'Output as JSON')
771
806
  .action(async (options) => {
772
807
  try {
773
- let url = '/api-keys/analytics/security-events';
808
+ let url = `${AUTH_API_KEYS_BASE}/analytics/security-events`;
774
809
  if (options.severity) {
775
810
  url += `?severity=${options.severity}`;
776
811
  }
777
- const events = await apiClient.get(url);
812
+ const events = unwrapApiResponse(await apiClient.get(url));
778
813
  if (options.json) {
779
814
  console.log(JSON.stringify(events, null, 2));
780
815
  return;
781
816
  }
782
- if (events.length === 0) {
817
+ if (!Array.isArray(events) || events.length === 0) {
783
818
  console.log(colors.success('✅ No security events found'));
784
819
  return;
785
820
  }
@@ -712,12 +712,8 @@ async function handleOAuthFlow(config) {
712
712
  await config.set('refresh_token', tokens.refresh_token);
713
713
  await config.set('token_expires_at', Date.now() + (tokens.expires_in * 1000));
714
714
  await config.set('authMethod', 'oauth');
715
- // The OAuth access token from auth-gateway works as the API token for all services
716
- // Store it as the vendor key equivalent for MCP and API access
717
715
  spinner.text = 'Configuring unified access...';
718
716
  spinner.start();
719
- // Use the OAuth access token directly - it's already an auth-gateway token
720
- await config.setVendorKey(tokens.access_token);
721
717
  spinner.succeed('Unified authentication configured');
722
718
  console.log();
723
719
  console.log(chalk.green('✓ OAuth2 authentication successful'));
@@ -439,6 +439,12 @@ export function mcpCommands(program) {
439
439
  if (options.args) {
440
440
  try {
441
441
  args = JSON.parse(options.args);
442
+ // Validate that args is a plain object (not array or primitive)
443
+ if (typeof args !== 'object' || args === null || Array.isArray(args)) {
444
+ spinner.fail('Arguments must be a JSON object, not an array or primitive value');
445
+ console.log(chalk.yellow('Example: --args \'{"key": "value"}\''));
446
+ process.exit(1);
447
+ }
442
448
  }
443
449
  catch {
444
450
  spinner.fail('Invalid JSON arguments');
@@ -103,12 +103,20 @@ const ensureBehaviorActions = (value, fieldName, options = {}) => {
103
103
  if (!tool) {
104
104
  throw new Error(`${fieldName}[${index}].tool is required`);
105
105
  }
106
- const parsed = { tool };
107
- if (entry.params !== undefined) {
108
- if (!isPlainObject(entry.params)) {
109
- throw new Error(`${fieldName}[${index}].params must be a JSON object`);
106
+ const outcome = typeof entry.outcome === 'string' ? entry.outcome.trim() : '';
107
+ if (!['success', 'partial', 'failed'].includes(outcome)) {
108
+ throw new Error(`${fieldName}[${index}].outcome must be success, partial, or failed`);
109
+ }
110
+ const parsed = {
111
+ tool,
112
+ outcome: outcome,
113
+ };
114
+ const rawParameters = entry.parameters ?? entry.params;
115
+ if (rawParameters !== undefined) {
116
+ if (!isPlainObject(rawParameters)) {
117
+ throw new Error(`${fieldName}[${index}].parameters must be a JSON object`);
110
118
  }
111
- parsed.params = entry.params;
119
+ parsed.parameters = rawParameters;
112
120
  }
113
121
  if (entry.timestamp !== undefined) {
114
122
  if (typeof entry.timestamp !== 'string' || !entry.timestamp.trim()) {
@@ -127,6 +135,33 @@ const ensureBehaviorActions = (value, fieldName, options = {}) => {
127
135
  return parsed;
128
136
  });
129
137
  };
138
+ const ensureCompletedSteps = (value, fieldName) => {
139
+ if (value === undefined) {
140
+ return [];
141
+ }
142
+ if (!Array.isArray(value)) {
143
+ throw new Error(`${fieldName} must be a JSON array`);
144
+ }
145
+ return value.map((entry, index) => {
146
+ if (typeof entry === 'string') {
147
+ const normalized = entry.trim();
148
+ if (!normalized) {
149
+ throw new Error(`${fieldName}[${index}] must be a non-empty string`);
150
+ }
151
+ return normalized;
152
+ }
153
+ if (isPlainObject(entry)) {
154
+ const tool = typeof entry.tool === 'string'
155
+ ? entry.tool?.trim()
156
+ : '';
157
+ if (!tool) {
158
+ throw new Error(`${fieldName}[${index}].tool must be a non-empty string`);
159
+ }
160
+ return tool;
161
+ }
162
+ throw new Error(`${fieldName}[${index}] must be a string or an object with a tool field`);
163
+ });
164
+ };
130
165
  const clampThreshold = (value) => {
131
166
  if (!Number.isFinite(value))
132
167
  return 0.55;
@@ -235,8 +270,16 @@ const createIntelligenceTransport = async () => {
235
270
  }),
236
271
  };
237
272
  }
238
- // Legacy key path: use CLI API client auth middleware directly.
239
- return { mode: 'api' };
273
+ // Pre-hashed key (64 hex chars from oauth-client normalizeApiKey): inject via header.
274
+ // The edge functions accept pre-hashed keys after the auth.ts shared utility update.
275
+ return {
276
+ mode: 'sdk',
277
+ client: new MemoryIntelligenceClient({
278
+ apiUrl,
279
+ allowMissingAuth: true,
280
+ headers: { 'X-API-Key': apiKey },
281
+ }),
282
+ };
240
283
  }
241
284
  throw new Error('Authentication required. Run "lanonasis auth login" first.');
242
285
  };
@@ -1207,7 +1250,7 @@ export function memoryCommands(program) {
1207
1250
  .description('Suggest next actions from learned behavior patterns')
1208
1251
  .requiredOption('--task <text>', 'Current task description')
1209
1252
  .option('--state <json>', 'Additional current state JSON object')
1210
- .option('--completed-steps <json>', 'Completed steps JSON array')
1253
+ .option('--completed-steps <json>', 'Completed steps JSON array (strings preferred)')
1211
1254
  .option('--max-suggestions <number>', 'Maximum suggestions', '3')
1212
1255
  .option('--json', 'Output raw JSON payload')
1213
1256
  .action(async (options) => {
@@ -1218,9 +1261,7 @@ export function memoryCommands(program) {
1218
1261
  const parsedState = parseJsonOption(options.state, '--state');
1219
1262
  const state = ensureJsonObject(parsedState, '--state') || {};
1220
1263
  const parsedCompletedSteps = parseJsonOption(options.completedSteps, '--completed-steps');
1221
- const completedSteps = parsedCompletedSteps === undefined
1222
- ? undefined
1223
- : ensureBehaviorActions(parsedCompletedSteps, '--completed-steps', { allowEmpty: true });
1264
+ const completedSteps = ensureCompletedSteps(parsedCompletedSteps, '--completed-steps');
1224
1265
  const result = await postIntelligenceEndpoint(transport, '/intelligence/behavior-suggest', {
1225
1266
  user_id: userId,
1226
1267
  current_state: {
package/dist/index.js CHANGED
@@ -280,10 +280,16 @@ authCmd
280
280
  const profileClient = new APIClient();
281
281
  profileClient.noExit = true;
282
282
  const profile = await profileClient.getUserProfile();
283
+ await cliConfig.updateCurrentUserProfile(profile);
284
+ const organizationId = profile.organization_id || profile.organizationId;
283
285
  console.log(`Email: ${profile.email}`);
284
286
  if (profile.name)
285
287
  console.log(`Name: ${profile.name}`);
288
+ if (organizationId)
289
+ console.log(`Organization: ${organizationId}`);
286
290
  console.log(`Role: ${profile.role}`);
291
+ if (profile.plan)
292
+ console.log(`Plan: ${profile.plan}`);
287
293
  if (profile.provider)
288
294
  console.log(`Provider: ${profile.provider}`);
289
295
  if (profile.last_sign_in_at) {
@@ -736,13 +742,21 @@ program
736
742
  const profileClient = new APIClient();
737
743
  profileClient.noExit = true;
738
744
  const profile = await profileClient.getUserProfile();
745
+ await cliConfig.updateCurrentUserProfile(profile);
746
+ const organizationId = profile.organization_id || profile.organizationId;
739
747
  console.log(chalk.blue.bold('👤 Current User'));
740
748
  console.log('━'.repeat(40));
741
749
  console.log(`Email: ${chalk.white(profile.email)}`);
742
750
  if (profile.name) {
743
751
  console.log(`Name: ${chalk.white(profile.name)}`);
744
752
  }
753
+ if (organizationId) {
754
+ console.log(`Organization: ${chalk.white(organizationId)}`);
755
+ }
745
756
  console.log(`Role: ${chalk.white(profile.role)}`);
757
+ if (profile.plan) {
758
+ console.log(`Plan: ${chalk.white(profile.plan)}`);
759
+ }
746
760
  if (profile.provider) {
747
761
  console.log(`Provider: ${chalk.white(profile.provider)}`);
748
762
  }
@@ -169,7 +169,10 @@ export interface UserProfile {
169
169
  email: string;
170
170
  name: string | null;
171
171
  avatar_url: string | null;
172
+ organization_id?: string | null;
173
+ organizationId?: string | null;
172
174
  role: string;
175
+ plan?: string | null;
173
176
  provider: string | null;
174
177
  project_scope: string | null;
175
178
  platform: string | null;
@@ -185,7 +188,10 @@ export declare class APIClient {
185
188
  private config;
186
189
  /** When true, throw on 401/403 instead of printing+exiting (for callers that handle errors) */
187
190
  noExit: boolean;
191
+ private isLikelyHashedCredential;
188
192
  private normalizeMemoryEntry;
193
+ private tryNormalizeMemoryEntry;
194
+ private normalizeMemoryStats;
189
195
  private shouldUseLegacyMemoryRpcFallback;
190
196
  private shouldRetryViaApiGateway;
191
197
  private shouldRetryViaSupabaseMemoryFunctions;
package/dist/utils/api.js CHANGED
@@ -2,11 +2,22 @@ import axios from 'axios';
2
2
  import chalk from 'chalk';
3
3
  import { randomUUID } from 'crypto';
4
4
  import { CLIConfig } from './config.js';
5
+ const MEMORY_TYPES = [
6
+ 'context',
7
+ 'project',
8
+ 'knowledge',
9
+ 'reference',
10
+ 'personal',
11
+ 'workflow'
12
+ ];
5
13
  export class APIClient {
6
14
  client;
7
15
  config;
8
16
  /** When true, throw on 401/403 instead of printing+exiting (for callers that handle errors) */
9
17
  noExit = false;
18
+ isLikelyHashedCredential(value) {
19
+ return typeof value === 'string' && /^[a-f0-9]{64}$/i.test(value.trim());
20
+ }
10
21
  normalizeMemoryEntry(payload) {
11
22
  // API responses are inconsistent across gateways:
12
23
  // - Some return the memory entry directly
@@ -27,13 +38,77 @@ export class APIClient {
27
38
  }
28
39
  return payload;
29
40
  }
41
+ tryNormalizeMemoryEntry(payload) {
42
+ if (!payload || typeof payload !== 'object') {
43
+ return undefined;
44
+ }
45
+ const normalized = this.normalizeMemoryEntry(payload);
46
+ if (!normalized || typeof normalized !== 'object') {
47
+ return undefined;
48
+ }
49
+ const normalizedRecord = normalized;
50
+ return typeof normalizedRecord.id === 'string' ? normalized : undefined;
51
+ }
52
+ normalizeMemoryStats(payload) {
53
+ if (!payload || typeof payload !== 'object') {
54
+ throw new Error('Memory stats endpoint returned an invalid response.');
55
+ }
56
+ const envelope = payload;
57
+ const rawStats = envelope.data && typeof envelope.data === 'object' && !Array.isArray(envelope.data)
58
+ ? envelope.data
59
+ : envelope;
60
+ const rawByType = rawStats.memories_by_type && typeof rawStats.memories_by_type === 'object' && !Array.isArray(rawStats.memories_by_type)
61
+ ? rawStats.memories_by_type
62
+ : rawStats.by_type && typeof rawStats.by_type === 'object' && !Array.isArray(rawStats.by_type)
63
+ ? rawStats.by_type
64
+ : {};
65
+ const memoriesByType = MEMORY_TYPES.reduce((accumulator, memoryType) => {
66
+ const value = rawByType[memoryType];
67
+ accumulator[memoryType] = typeof value === 'number' ? value : 0;
68
+ return accumulator;
69
+ }, {});
70
+ const recentMemories = Array.isArray(rawStats.recent_memories)
71
+ ? rawStats.recent_memories
72
+ .map((entry) => this.tryNormalizeMemoryEntry(entry))
73
+ .filter((entry) => entry !== undefined)
74
+ : [];
75
+ const totalMemories = typeof rawStats.total_memories === 'number'
76
+ ? rawStats.total_memories
77
+ : Object.values(memoriesByType).reduce((sum, count) => sum + count, 0);
78
+ return {
79
+ total_memories: totalMemories,
80
+ memories_by_type: memoriesByType,
81
+ total_size_bytes: typeof rawStats.total_size_bytes === 'number' ? rawStats.total_size_bytes : 0,
82
+ avg_access_count: typeof rawStats.avg_access_count === 'number' ? rawStats.avg_access_count : 0,
83
+ most_accessed_memory: this.tryNormalizeMemoryEntry(rawStats.most_accessed_memory),
84
+ recent_memories: recentMemories
85
+ };
86
+ }
30
87
  shouldUseLegacyMemoryRpcFallback(error) {
31
88
  const status = error?.response?.status;
32
89
  const errorData = error?.response?.data;
33
90
  const message = `${errorData?.error || ''} ${errorData?.message || ''}`.toLowerCase();
91
+ const rawRequestUrl = String(error?.config?.url || '');
92
+ const requestPath = (() => {
93
+ try {
94
+ if (/^https?:\/\//i.test(rawRequestUrl)) {
95
+ return new URL(rawRequestUrl).pathname;
96
+ }
97
+ }
98
+ catch {
99
+ // Fall back to the raw URL if URL parsing fails.
100
+ }
101
+ return rawRequestUrl;
102
+ })();
103
+ const normalizedRequestUrl = requestPath.startsWith('/memory')
104
+ ? this.normalizeMcpPathToApi(requestPath)
105
+ : requestPath;
34
106
  if (status === 405) {
35
107
  return true;
36
108
  }
109
+ if (status === 404 && /^\/api\/v1\/memories\/[^/?#]+$/.test(normalizedRequestUrl)) {
110
+ return true;
111
+ }
37
112
  if (status === 400 && message.includes('memory id is required')) {
38
113
  return true;
39
114
  }
@@ -238,6 +313,9 @@ export class APIClient {
238
313
  const forceDirectApi = forceApiFromEnv || forceApiFromConfig || forceDirectApiRetry;
239
314
  const prefersTokenAuth = Boolean(token) && (authMethod === 'jwt' || authMethod === 'oauth' || authMethod === 'oauth2');
240
315
  const useVendorKeyAuth = Boolean(vendorKey) && !prefersTokenAuth;
316
+ if (authMethod === 'vendor_key' && this.isLikelyHashedCredential(vendorKey)) {
317
+ throw new Error('Stored vendor key is in legacy hashed format. Run "lanonasis auth login --vendor-key <your-key>" to refresh secure storage.');
318
+ }
241
319
  // Determine the correct API base URL:
242
320
  // - Auth endpoints -> auth.lanonasis.com
243
321
  // - Memory/MCP operations (JWT or vendor key) -> mcp.lanonasis.com (the memory service)
@@ -567,7 +645,7 @@ export class APIClient {
567
645
  return this.normalizeMemoryEntry(response.data);
568
646
  }
569
647
  catch (error) {
570
- if (this.shouldUseLegacyMemoryRpcFallback(error)) {
648
+ if (this.shouldUseLegacyMemoryRpcFallback(error) || error?.response?.status === 404) {
571
649
  const fallback = await this.client.post('/api/v1/memory/update', {
572
650
  id,
573
651
  ...data
@@ -585,7 +663,7 @@ export class APIClient {
585
663
  await this.client.delete(`/api/v1/memories/${id}`);
586
664
  }
587
665
  catch (error) {
588
- if (this.shouldUseLegacyMemoryRpcFallback(error)) {
666
+ if (this.shouldUseLegacyMemoryRpcFallback(error) || error?.response?.status === 404) {
589
667
  await this.client.post('/api/v1/memory/delete', { id });
590
668
  return;
591
669
  }
@@ -601,7 +679,7 @@ export class APIClient {
601
679
  }
602
680
  async getMemoryStats() {
603
681
  const response = await this.client.get('/api/v1/memories/stats');
604
- return response.data;
682
+ return this.normalizeMemoryStats(response.data);
605
683
  }
606
684
  async bulkDeleteMemories(memoryIds) {
607
685
  const response = await this.client.post('/api/v1/memories/bulk/delete', {
@@ -48,9 +48,15 @@ export declare class CLIConfig {
48
48
  private static readonly CONFIG_VERSION;
49
49
  private authCheckCache;
50
50
  private readonly AUTH_CACHE_TTL;
51
- private apiKeyStorage;
51
+ private apiKeyStorage?;
52
52
  private vendorKeyCache?;
53
+ private isLegacyHashedCredential;
54
+ private getLegacyHashedVendorKeyReason;
55
+ private normalizeOptionalString;
56
+ private extractOrganizationId;
57
+ private buildUserProfile;
53
58
  constructor();
59
+ private getApiKeyStorage;
54
60
  /**
55
61
  * Overrides the configuration storage directory. Primarily used for tests.
56
62
  */
@@ -117,6 +123,7 @@ export declare class CLIConfig {
117
123
  getToken(): string | undefined;
118
124
  getAuthMethod(): string | undefined;
119
125
  getCurrentUser(): Promise<UserProfile | undefined>;
126
+ updateCurrentUserProfile(profile: Record<string, unknown>): Promise<void>;
120
127
  isAuthenticated(): Promise<boolean>;
121
128
  logout(): Promise<void>;
122
129
  clear(): Promise<void>;
@@ -15,12 +15,51 @@ export class CLIConfig {
15
15
  AUTH_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
16
16
  apiKeyStorage;
17
17
  vendorKeyCache;
18
+ isLegacyHashedCredential(value) {
19
+ return typeof value === 'string' && /^[a-f0-9]{64}$/i.test(value.trim());
20
+ }
21
+ getLegacyHashedVendorKeyReason() {
22
+ return 'Stored vendor key is in legacy hashed format. Run "lanonasis auth login --vendor-key <your-key>" to refresh secure storage.';
23
+ }
24
+ normalizeOptionalString(value) {
25
+ if (typeof value === 'string') {
26
+ const trimmed = value.trim();
27
+ return trimmed.length > 0 ? trimmed : undefined;
28
+ }
29
+ if (value === null || value === undefined) {
30
+ return undefined;
31
+ }
32
+ return String(value);
33
+ }
34
+ extractOrganizationId(source) {
35
+ return this.normalizeOptionalString(source.organization_id)
36
+ ?? this.normalizeOptionalString(source.organizationId);
37
+ }
38
+ buildUserProfile(source, existing) {
39
+ const email = this.normalizeOptionalString(source.email) ?? existing?.email ?? '';
40
+ const organization_id = this.extractOrganizationId(source) ?? existing?.organization_id ?? '';
41
+ const role = this.normalizeOptionalString(source.role) ?? existing?.role ?? '';
42
+ const plan = this.normalizeOptionalString(source.plan) ?? existing?.plan ?? '';
43
+ if (!email && !organization_id && !role && !plan) {
44
+ return existing;
45
+ }
46
+ return {
47
+ email,
48
+ organization_id,
49
+ role,
50
+ plan
51
+ };
52
+ }
18
53
  constructor() {
19
54
  this.configDir = path.join(os.homedir(), '.maas');
20
55
  this.configPath = path.join(this.configDir, 'config.json');
21
56
  this.lockFile = path.join(this.configDir, 'config.lock');
22
- // Initialize secure storage for vendor keys using oauth-client's ApiKeyStorage
23
- this.apiKeyStorage = new ApiKeyStorage();
57
+ }
58
+ getApiKeyStorage() {
59
+ if (!this.apiKeyStorage) {
60
+ this.apiKeyStorage = new ApiKeyStorage();
61
+ }
62
+ return this.apiKeyStorage;
24
63
  }
25
64
  /**
26
65
  * Overrides the configuration storage directory. Primarily used for tests.
@@ -627,6 +666,13 @@ export class CLIConfig {
627
666
  await this.discoverServices();
628
667
  const token = this.getToken();
629
668
  const vendorKey = await this.getVendorKeyAsync();
669
+ if (this.config.authMethod === 'vendor_key' && this.isLegacyHashedCredential(vendorKey)) {
670
+ return {
671
+ valid: false,
672
+ method: 'vendor_key',
673
+ reason: this.getLegacyHashedVendorKeyReason()
674
+ };
675
+ }
630
676
  if (this.config.authMethod === 'vendor_key' && vendorKey) {
631
677
  return this.verifyVendorKeyWithAuthGateway(vendorKey);
632
678
  }
@@ -710,8 +756,9 @@ export class CLIConfig {
710
756
  }
711
757
  // Initialize and store using ApiKeyStorage from @lanonasis/oauth-client
712
758
  // This handles encryption automatically (AES-256-GCM with machine-derived key)
713
- await this.apiKeyStorage.initialize();
714
- await this.apiKeyStorage.store({
759
+ const apiKeyStorage = this.getApiKeyStorage();
760
+ await apiKeyStorage.initialize();
761
+ await apiKeyStorage.store({
715
762
  apiKey: trimmedKey,
716
763
  organizationId: this.config.user?.organization_id,
717
764
  userId: this.config.user?.email,
@@ -835,8 +882,9 @@ export class CLIConfig {
835
882
  */
836
883
  async getVendorKeyAsync() {
837
884
  try {
838
- await this.apiKeyStorage.initialize();
839
- const stored = await this.apiKeyStorage.retrieve();
885
+ const apiKeyStorage = this.getApiKeyStorage();
886
+ await apiKeyStorage.initialize();
887
+ const stored = await apiKeyStorage.retrieve();
840
888
  if (stored) {
841
889
  this.vendorKeyCache = stored.apiKey;
842
890
  return this.vendorKeyCache;
@@ -878,12 +926,7 @@ export class CLIConfig {
878
926
  this.config.tokenExpiry = decoded.exp;
879
927
  }
880
928
  // Store user info
881
- this.config.user = {
882
- email: String(decoded.email || ''),
883
- organization_id: String(decoded.organizationId || ''),
884
- role: String(decoded.role || ''),
885
- plan: String(decoded.plan || '')
886
- };
929
+ this.config.user = this.buildUserProfile(decoded, this.config.user);
887
930
  }
888
931
  catch {
889
932
  // Invalid token, don't store user info or expiry
@@ -902,6 +945,23 @@ export class CLIConfig {
902
945
  async getCurrentUser() {
903
946
  return this.config.user;
904
947
  }
948
+ async updateCurrentUserProfile(profile) {
949
+ const nextUser = this.buildUserProfile(profile, this.config.user);
950
+ if (!nextUser) {
951
+ return;
952
+ }
953
+ const currentUser = this.config.user;
954
+ const changed = !currentUser
955
+ || currentUser.email !== nextUser.email
956
+ || currentUser.organization_id !== nextUser.organization_id
957
+ || currentUser.role !== nextUser.role
958
+ || currentUser.plan !== nextUser.plan;
959
+ if (!changed) {
960
+ return;
961
+ }
962
+ this.config.user = nextUser;
963
+ await this.save();
964
+ }
905
965
  async isAuthenticated() {
906
966
  // Attempt refresh for OAuth sessions before checks (prevents intermittent auth dropouts).
907
967
  // This is safe to call even when not using OAuth; it will no-op.
@@ -912,6 +972,13 @@ export class CLIConfig {
912
972
  const vendorKey = await this.getVendorKeyAsync();
913
973
  if (!vendorKey)
914
974
  return false;
975
+ if (this.isLegacyHashedCredential(vendorKey)) {
976
+ if (process.env.CLI_VERBOSE === 'true') {
977
+ console.warn(`⚠️ ${this.getLegacyHashedVendorKeyReason()}`);
978
+ }
979
+ this.authCheckCache = { isValid: false, timestamp: Date.now() };
980
+ return false;
981
+ }
915
982
  // Check in-memory cache first (5-minute TTL)
916
983
  if (this.authCheckCache && (Date.now() - this.authCheckCache.timestamp) < this.AUTH_CACHE_TTL) {
917
984
  return this.authCheckCache.isValid;
@@ -1146,10 +1213,13 @@ export class CLIConfig {
1146
1213
  this.vendorKeyCache = undefined;
1147
1214
  this.config.vendorKey = undefined;
1148
1215
  this.config.authMethod = undefined;
1216
+ this.config.refresh_token = undefined;
1217
+ this.config.token_expires_at = undefined;
1149
1218
  try {
1150
- await this.apiKeyStorage.initialize();
1219
+ const apiKeyStorage = this.getApiKeyStorage();
1220
+ await apiKeyStorage.initialize();
1151
1221
  // ApiKeyStorage may implement clear() to remove encrypted secrets
1152
- const storage = this.apiKeyStorage;
1222
+ const storage = apiKeyStorage;
1153
1223
  if (typeof storage.clear === 'function') {
1154
1224
  await storage.clear();
1155
1225
  }
@@ -1270,14 +1340,6 @@ export class CLIConfig {
1270
1340
  if (typeof expiresIn === 'number' && Number.isFinite(expiresIn)) {
1271
1341
  this.config.token_expires_at = Date.now() + (expiresIn * 1000);
1272
1342
  }
1273
- // Keep the encrypted "vendor key" in sync for MCP/WebSocket clients that use X-API-Key.
1274
- // This does not change authMethod away from oauth (setVendorKey guards against that).
1275
- try {
1276
- await this.setVendorKey(accessToken);
1277
- }
1278
- catch {
1279
- // Non-fatal: bearer token refresh still helps API calls.
1280
- }
1281
1343
  await this.save().catch(() => { });
1282
1344
  return;
1283
1345
  }
@@ -1327,9 +1389,23 @@ export class CLIConfig {
1327
1389
  this.config.user = undefined;
1328
1390
  this.config.authMethod = undefined;
1329
1391
  this.config.tokenExpiry = undefined;
1392
+ this.config.refresh_token = undefined;
1393
+ this.config.token_expires_at = undefined;
1330
1394
  this.config.lastValidated = undefined;
1331
1395
  this.config.authFailureCount = 0;
1332
1396
  this.config.lastAuthFailure = undefined;
1397
+ this.vendorKeyCache = undefined;
1398
+ try {
1399
+ const apiKeyStorage = this.getApiKeyStorage();
1400
+ await apiKeyStorage.initialize();
1401
+ const storage = apiKeyStorage;
1402
+ if (typeof storage.clear === 'function') {
1403
+ await storage.clear();
1404
+ }
1405
+ }
1406
+ catch {
1407
+ // Ignore secure storage cleanup failures while invalidating credentials
1408
+ }
1333
1409
  await this.save();
1334
1410
  }
1335
1411
  async incrementFailureCount() {
@@ -360,6 +360,10 @@ export class MCPClient {
360
360
  const authMethod = String(this.config.get('authMethod') || '').toLowerCase();
361
361
  const token = this.config.get('token');
362
362
  const vendorKey = await this.config.getVendorKeyAsync();
363
+ const isLikelyHashedCredential = typeof vendorKey === 'string' && /^[a-f0-9]{64}$/i.test(vendorKey.trim());
364
+ if (authMethod === 'vendor_key' && isLikelyHashedCredential) {
365
+ throw new Error('AUTHENTICATION_INVALID: Stored vendor key is in legacy hashed format. Run "lanonasis auth login --vendor-key <your-key>" to refresh secure storage.');
366
+ }
363
367
  if (authMethod === 'vendor_key' && typeof vendorKey === 'string' && vendorKey.trim().length > 0) {
364
368
  return { value: vendorKey.trim(), source: 'vendor_key' };
365
369
  }
@@ -51,9 +51,21 @@ export class ConnectionManagerImpl {
51
51
  try {
52
52
  // Load persisted configuration first
53
53
  await this.loadConfig();
54
- // First, try to detect the server path
54
+ // Prefer a configured path only if the file exists (ignore stale machine-specific commits)
55
55
  const configuredPath = this.config.localServerPath?.trim();
56
- const serverPath = configuredPath || (await this.detectServerPath());
56
+ let serverPath = null;
57
+ if (configuredPath) {
58
+ try {
59
+ await fs.access(configuredPath);
60
+ serverPath = configuredPath;
61
+ }
62
+ catch {
63
+ serverPath = await this.detectServerPath();
64
+ }
65
+ }
66
+ else {
67
+ serverPath = await this.detectServerPath();
68
+ }
57
69
  if (!serverPath) {
58
70
  return {
59
71
  success: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lanonasis/cli",
3
- "version": "3.9.10",
3
+ "version": "3.9.12",
4
4
  "description": "Professional CLI for LanOnasis Memory as a Service (MaaS) with MCP support, seamless inline editing, and enterprise-grade security",
5
5
  "keywords": [
6
6
  "lanonasis",
@@ -52,11 +52,11 @@
52
52
  "CHANGELOG.md"
53
53
  ],
54
54
  "dependencies": {
55
- "@lanonasis/mem-intel-sdk": "2.0.3",
56
- "@lanonasis/oauth-client": "2.0.0",
55
+ "@lanonasis/mem-intel-sdk": "2.0.6",
56
+ "@lanonasis/oauth-client": "2.0.4",
57
57
  "@lanonasis/security-sdk": "1.0.5",
58
- "@modelcontextprotocol/sdk": "^1.26.0",
59
- "axios": "^1.13.5",
58
+ "@modelcontextprotocol/sdk": "^1.28.0",
59
+ "axios": "^1.13.6",
60
60
  "chalk": "^5.6.2",
61
61
  "cli-progress": "^3.12.0",
62
62
  "cli-table3": "^0.6.5",
@@ -64,26 +64,40 @@
64
64
  "date-fns": "^4.1.0",
65
65
  "dotenv": "^17.3.1",
66
66
  "eventsource": "^4.1.0",
67
- "inquirer": "^13.2.5",
67
+ "inquirer": "^13.3.2",
68
68
  "jwt-decode": "^4.0.0",
69
69
  "open": "^11.0.0",
70
70
  "ora": "^9.3.0",
71
71
  "table": "^6.9.0",
72
72
  "word-wrap": "^1.2.5",
73
- "ws": "^8.19.0",
73
+ "ws": "^8.20.0",
74
74
  "zod": "^4.3.6"
75
75
  },
76
76
  "devDependencies": {
77
- "@jest/globals": "^30.2.0",
77
+ "@jest/globals": "^30.3.0",
78
78
  "@types/cli-progress": "^3.11.6",
79
79
  "@types/inquirer": "^9.0.9",
80
- "@types/node": "^25.3.0",
80
+ "@types/node": "^25.5.0",
81
81
  "@types/ws": "^8.18.1",
82
- "fast-check": "^4.5.3",
83
- "jest": "^30.2.0",
82
+ "fast-check": "^4.6.0",
83
+ "jest": "^30.3.0",
84
84
  "rimraf": "^6.1.3",
85
85
  "ts-jest": "^29.4.6",
86
- "typescript": "^5.9.3"
86
+ "typescript": "^6.0.2"
87
+ },
88
+ "overrides": {
89
+ "@jest/transform": "^30.3.0",
90
+ "@hono/node-server": "^1.19.10",
91
+ "ajv": "^8.18.0",
92
+ "babel-jest": "^30.3.0",
93
+ "brace-expansion": "^1.1.13",
94
+ "express-rate-limit": "^8.2.2",
95
+ "handlebars": "^4.7.9",
96
+ "hono": "^4.12.8",
97
+ "jest-util": "^30.3.0",
98
+ "picomatch": "^2.3.2",
99
+ "qs": "^6.15.0",
100
+ "test-exclude": "^8.0.0"
87
101
  },
88
102
  "scripts": {
89
103
  "build": "rimraf dist && tsc -p tsconfig.json && chmod +x dist/index.js dist/mcp-server-entry.js 2>/dev/null || true",