lsh-framework 3.0.0 → 3.1.0

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/dist/cli.js CHANGED
@@ -47,7 +47,7 @@ program
47
47
  program
48
48
  .option('-v, --verbose', 'Verbose output')
49
49
  .option('-d, --debug', 'Debug mode')
50
- .action(async (options) => {
50
+ .action(async (_options) => {
51
51
  // No arguments - show secrets-focused help
52
52
  console.log('LSH - Encrypted Secrets Manager');
53
53
  console.log('');
@@ -263,7 +263,7 @@ async function testSupabaseConnection(url, key, verbose) {
263
263
  /**
264
264
  * Check if in git repository
265
265
  */
266
- async function checkGitRepository(verbose) {
266
+ async function checkGitRepository(_verbose) {
267
267
  try {
268
268
  const gitPath = path.join(process.cwd(), '.git');
269
269
  // Use stat instead of access to avoid TOCTOU race condition
@@ -204,7 +204,7 @@ async function checkExistingConfig() {
204
204
  /**
205
205
  * Pull secrets after init is complete
206
206
  */
207
- async function pullSecretsAfterInit(encryptionKey, repoName) {
207
+ async function pullSecretsAfterInit(_encryptionKey, _repoName) {
208
208
  const spinner = ora('Pulling secrets from cloud...').start();
209
209
  try {
210
210
  // Dynamically import SecretsManager to avoid circular dependencies
@@ -399,7 +399,7 @@ async function configurePostgres(config, skipTest) {
399
399
  /**
400
400
  * Configure local-only mode
401
401
  */
402
- async function configureLocal(config) {
402
+ async function configureLocal(_config) {
403
403
  console.log(chalk.cyan('\nšŸ’¾ Local Encryption Mode'));
404
404
  console.log(chalk.gray('Secrets will be encrypted locally. No cloud sync available.'));
405
405
  console.log('');
@@ -127,7 +127,6 @@ async function checkCIStatus(_version) {
127
127
  const ghData = JSON.parse(data);
128
128
  const runs = ghData.workflow_runs || [];
129
129
  // Find the most recent workflow run for main branch
130
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
131
130
  const mainRuns = runs.filter((run) => run.head_branch === 'main' && run.status === 'completed');
132
131
  if (mainRuns.length > 0) {
133
132
  const latestRun = mainRuns[0];
@@ -87,6 +87,8 @@ export const DANGEROUS_PATTERNS = [
87
87
  riskLevel: RISK_LEVELS.MEDIUM,
88
88
  },
89
89
  {
90
+ // Detect null byte injection attacks (legitimate security pattern)
91
+ // eslint-disable-next-line no-control-regex
90
92
  pattern: /\x00/i,
91
93
  description: ERRORS.NULL_BYTE,
92
94
  riskLevel: RISK_LEVELS.HIGH,
@@ -108,13 +108,15 @@ export class LSHJobDaemon extends EventEmitter {
108
108
  await fs.promises.unlink(this.config.pidFile);
109
109
  }
110
110
  catch (_error) {
111
- // Ignore if file doesn\'t exist
111
+ // Ignore if file doesn't exist
112
112
  }
113
- // Close log stream
113
+ // Log before closing stream
114
+ this.log('INFO', 'Daemon stopped');
115
+ // Close log stream AFTER logging
114
116
  if (this.logStream) {
115
117
  this.logStream.end();
118
+ this.logStream = undefined;
116
119
  }
117
- this.log('INFO', 'Daemon stopped');
118
120
  this.emit('stopped');
119
121
  }
120
122
  /**
@@ -729,8 +731,23 @@ export class LSHJobDaemon extends EventEmitter {
729
731
  }
730
732
  // Module-level logger for CLI operations
731
733
  const cliLogger = createLogger('LSHDaemonCLI');
734
+ // Helper to check if this module is run directly (ESM-compatible)
735
+ // Uses indirect eval to avoid parse-time errors in CommonJS/Jest environments
736
+ const isMainModule = () => {
737
+ try {
738
+ // Use Function constructor to avoid parse-time errors with import.meta
739
+ // eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
740
+ const getImportMetaUrl = new Function('return import.meta.url');
741
+ const metaUrl = getImportMetaUrl();
742
+ return metaUrl === `file://${process.argv[1]}`;
743
+ }
744
+ catch {
745
+ // Fallback for CommonJS or environments that don't support import.meta
746
+ return false;
747
+ }
748
+ };
732
749
  // CLI interface for the daemon
733
- if (import.meta.url === `file://${process.argv[1]}`) {
750
+ if (isMainModule()) {
734
751
  const command = process.argv[2];
735
752
  const subCommand = process.argv[3];
736
753
  const _args = process.argv.slice(4);
@@ -9,6 +9,7 @@ import { billingService } from '../lib/saas-billing.js';
9
9
  import { auditLogger, getIpFromRequest } from '../lib/saas-audit.js';
10
10
  import { emailService } from '../lib/saas-email.js';
11
11
  import { secretsService } from '../lib/saas-secrets.js';
12
+ import { getErrorMessage, getAuthenticatedUser } from '../lib/saas-types.js'; // eslint-disable-line no-duplicate-imports
12
13
  /**
13
14
  * Rate Limiters for different endpoint types
14
15
  */
@@ -83,7 +84,7 @@ export async function authenticateUser(req, res, next) {
83
84
  success: false,
84
85
  error: {
85
86
  code: 'UNAUTHORIZED',
86
- message: error.message || 'Authentication failed',
87
+ message: getErrorMessage(error) || 'Authentication failed',
87
88
  },
88
89
  });
89
90
  }
@@ -101,20 +102,26 @@ export async function requireOrganizationMembership(req, res, next) {
101
102
  error: { code: 'INVALID_INPUT', message: 'Organization ID required' },
102
103
  });
103
104
  }
104
- const role = await organizationService.getUserRole(organizationId, req.user.id);
105
+ if (!req.user) {
106
+ return res.status(401).json({
107
+ success: false,
108
+ error: { code: 'UNAUTHORIZED', message: 'User not authenticated' },
109
+ });
110
+ }
111
+ const role = await organizationService.getUserRole(organizationId, getAuthenticatedUser(req).id);
105
112
  if (!role) {
106
113
  return res.status(403).json({
107
114
  success: false,
108
115
  error: { code: 'FORBIDDEN', message: 'Not a member of this organization' },
109
116
  });
110
117
  }
111
- req.organizationRole = role;
118
+ req.organizationId = organizationId;
112
119
  next();
113
120
  }
114
121
  catch (error) {
115
122
  return res.status(500).json({
116
123
  success: false,
117
- error: { code: 'INTERNAL_ERROR', message: error.message },
124
+ error: { code: 'INTERNAL_ERROR', message: getErrorMessage(error) },
118
125
  });
119
126
  }
120
127
  }
@@ -183,7 +190,7 @@ export function setupSaaSApiRoutes(app) {
183
190
  catch (error) {
184
191
  res.status(400).json({
185
192
  success: false,
186
- error: { code: error.message.includes('ALREADY_EXISTS') ? 'EMAIL_ALREADY_EXISTS' : 'INTERNAL_ERROR', message: error.message },
193
+ error: { code: getErrorMessage(error).includes('ALREADY_EXISTS') ? 'EMAIL_ALREADY_EXISTS' : 'INTERNAL_ERROR', message: getErrorMessage(error) },
187
194
  });
188
195
  }
189
196
  });
@@ -215,10 +222,10 @@ export function setupSaaSApiRoutes(app) {
215
222
  });
216
223
  }
217
224
  catch (error) {
218
- const statusCode = error.message.includes('EMAIL_NOT_VERIFIED') ? 403 : 401;
225
+ const statusCode = getErrorMessage(error).includes('EMAIL_NOT_VERIFIED') ? 403 : 401;
219
226
  res.status(statusCode).json({
220
227
  success: false,
221
- error: { code: error.message || 'INVALID_CREDENTIALS', message: error.message },
228
+ error: { code: getErrorMessage(error) || 'INVALID_CREDENTIALS', message: getErrorMessage(error) },
222
229
  });
223
230
  }
224
231
  });
@@ -252,7 +259,7 @@ export function setupSaaSApiRoutes(app) {
252
259
  catch (error) {
253
260
  res.status(400).json({
254
261
  success: false,
255
- error: { code: 'INVALID_TOKEN', message: error.message },
262
+ error: { code: 'INVALID_TOKEN', message: getErrorMessage(error) },
256
263
  });
257
264
  }
258
265
  });
@@ -276,7 +283,7 @@ export function setupSaaSApiRoutes(app) {
276
283
  catch (error) {
277
284
  res.status(400).json({
278
285
  success: false,
279
- error: { code: 'INTERNAL_ERROR', message: error.message },
286
+ error: { code: 'INTERNAL_ERROR', message: getErrorMessage(error) },
280
287
  });
281
288
  }
282
289
  });
@@ -302,7 +309,7 @@ export function setupSaaSApiRoutes(app) {
302
309
  catch (error) {
303
310
  res.status(401).json({
304
311
  success: false,
305
- error: { code: 'INVALID_TOKEN', message: error.message },
312
+ error: { code: 'INVALID_TOKEN', message: getErrorMessage(error) },
306
313
  });
307
314
  }
308
315
  });
@@ -335,7 +342,7 @@ export function setupSaaSApiRoutes(app) {
335
342
  const organization = await organizationService.createOrganization({
336
343
  name,
337
344
  slug,
338
- ownerId: req.user.id,
345
+ ownerId: getAuthenticatedUser(req).id,
339
346
  });
340
347
  res.json({
341
348
  success: true,
@@ -345,7 +352,7 @@ export function setupSaaSApiRoutes(app) {
345
352
  catch (error) {
346
353
  res.status(400).json({
347
354
  success: false,
348
- error: { code: 'INTERNAL_ERROR', message: error.message },
355
+ error: { code: 'INTERNAL_ERROR', message: getErrorMessage(error) },
349
356
  });
350
357
  }
351
358
  });
@@ -372,7 +379,7 @@ export function setupSaaSApiRoutes(app) {
372
379
  catch (error) {
373
380
  res.status(500).json({
374
381
  success: false,
375
- error: { code: 'INTERNAL_ERROR', message: error.message },
382
+ error: { code: 'INTERNAL_ERROR', message: getErrorMessage(error) },
376
383
  });
377
384
  }
378
385
  });
@@ -391,7 +398,7 @@ export function setupSaaSApiRoutes(app) {
391
398
  catch (error) {
392
399
  res.status(500).json({
393
400
  success: false,
394
- error: { code: 'INTERNAL_ERROR', message: error.message },
401
+ error: { code: 'INTERNAL_ERROR', message: getErrorMessage(error) },
395
402
  });
396
403
  }
397
404
  });
@@ -402,7 +409,7 @@ export function setupSaaSApiRoutes(app) {
402
409
  app.post('/api/v1/organizations/:organizationId/members', writeLimiter, authenticateUser, requireOrganizationMembership, async (req, res) => {
403
410
  try {
404
411
  // Check permission
405
- const canInvite = await organizationService.hasPermission(req.params.organizationId, req.user.id, 'canInviteMembers');
412
+ const canInvite = await organizationService.hasPermission(req.params.organizationId, getAuthenticatedUser(req).id, 'canInviteMembers');
406
413
  if (!canInvite) {
407
414
  return res.status(403).json({
408
415
  success: false,
@@ -414,7 +421,7 @@ export function setupSaaSApiRoutes(app) {
414
421
  organizationId: req.params.organizationId,
415
422
  userId,
416
423
  role: role || 'member',
417
- invitedBy: req.user.id,
424
+ invitedBy: getAuthenticatedUser(req).id,
418
425
  });
419
426
  res.json({
420
427
  success: true,
@@ -424,7 +431,7 @@ export function setupSaaSApiRoutes(app) {
424
431
  catch (error) {
425
432
  res.status(400).json({
426
433
  success: false,
427
- error: { code: 'INTERNAL_ERROR', message: error.message },
434
+ error: { code: 'INTERNAL_ERROR', message: getErrorMessage(error) },
428
435
  });
429
436
  }
430
437
  });
@@ -449,7 +456,7 @@ export function setupSaaSApiRoutes(app) {
449
456
  name,
450
457
  slug,
451
458
  description,
452
- }, req.user.id);
459
+ }, getAuthenticatedUser(req).id);
453
460
  res.json({
454
461
  success: true,
455
462
  data: { team },
@@ -458,7 +465,7 @@ export function setupSaaSApiRoutes(app) {
458
465
  catch (error) {
459
466
  res.status(400).json({
460
467
  success: false,
461
- error: { code: 'INTERNAL_ERROR', message: error.message },
468
+ error: { code: 'INTERNAL_ERROR', message: getErrorMessage(error) },
462
469
  });
463
470
  }
464
471
  });
@@ -477,7 +484,7 @@ export function setupSaaSApiRoutes(app) {
477
484
  catch (error) {
478
485
  res.status(500).json({
479
486
  success: false,
480
- error: { code: 'INTERNAL_ERROR', message: error.message },
487
+ error: { code: 'INTERNAL_ERROR', message: getErrorMessage(error) },
481
488
  });
482
489
  }
483
490
  });
@@ -505,7 +512,7 @@ export function setupSaaSApiRoutes(app) {
505
512
  description,
506
513
  tags,
507
514
  rotationIntervalDays,
508
- createdBy: req.user.id,
515
+ createdBy: getAuthenticatedUser(req).id,
509
516
  });
510
517
  res.json({
511
518
  success: true,
@@ -513,10 +520,10 @@ export function setupSaaSApiRoutes(app) {
513
520
  });
514
521
  }
515
522
  catch (error) {
516
- const statusCode = error.message.includes('TIER_LIMIT_EXCEEDED') ? 402 : 400;
523
+ const statusCode = getErrorMessage(error).includes('TIER_LIMIT_EXCEEDED') ? 402 : 400;
517
524
  res.status(statusCode).json({
518
525
  success: false,
519
- error: { code: 'INTERNAL_ERROR', message: error.message },
526
+ error: { code: 'INTERNAL_ERROR', message: getErrorMessage(error) },
520
527
  });
521
528
  }
522
529
  });
@@ -541,7 +548,7 @@ export function setupSaaSApiRoutes(app) {
541
548
  catch (error) {
542
549
  res.status(500).json({
543
550
  success: false,
544
- error: { code: 'INTERNAL_ERROR', message: error.message },
551
+ error: { code: 'INTERNAL_ERROR', message: getErrorMessage(error) },
545
552
  });
546
553
  }
547
554
  });
@@ -574,7 +581,7 @@ export function setupSaaSApiRoutes(app) {
574
581
  catch (error) {
575
582
  res.status(500).json({
576
583
  success: false,
577
- error: { code: 'INTERNAL_ERROR', message: error.message },
584
+ error: { code: 'INTERNAL_ERROR', message: getErrorMessage(error) },
578
585
  });
579
586
  }
580
587
  });
@@ -590,7 +597,7 @@ export function setupSaaSApiRoutes(app) {
590
597
  description,
591
598
  tags,
592
599
  rotationIntervalDays,
593
- updatedBy: req.user.id,
600
+ updatedBy: getAuthenticatedUser(req).id,
594
601
  });
595
602
  res.json({
596
603
  success: true,
@@ -600,7 +607,7 @@ export function setupSaaSApiRoutes(app) {
600
607
  catch (error) {
601
608
  res.status(400).json({
602
609
  success: false,
603
- error: { code: 'INTERNAL_ERROR', message: error.message },
610
+ error: { code: 'INTERNAL_ERROR', message: getErrorMessage(error) },
604
611
  });
605
612
  }
606
613
  });
@@ -610,7 +617,7 @@ export function setupSaaSApiRoutes(app) {
610
617
  */
611
618
  app.delete('/api/v1/teams/:teamId/secrets/:secretId', writeLimiter, authenticateUser, async (req, res) => {
612
619
  try {
613
- await secretsService.deleteSecret(req.params.secretId, req.user.id);
620
+ await secretsService.deleteSecret(req.params.secretId, getAuthenticatedUser(req).id);
614
621
  res.json({
615
622
  success: true,
616
623
  data: { message: 'Secret deleted successfully' },
@@ -619,7 +626,7 @@ export function setupSaaSApiRoutes(app) {
619
626
  catch (error) {
620
627
  res.status(400).json({
621
628
  success: false,
622
- error: { code: 'INTERNAL_ERROR', message: error.message },
629
+ error: { code: 'INTERNAL_ERROR', message: getErrorMessage(error) },
623
630
  });
624
631
  }
625
632
  });
@@ -644,7 +651,7 @@ export function setupSaaSApiRoutes(app) {
644
651
  catch (error) {
645
652
  res.status(500).json({
646
653
  success: false,
647
- error: { code: 'INTERNAL_ERROR', message: error.message },
654
+ error: { code: 'INTERNAL_ERROR', message: getErrorMessage(error) },
648
655
  });
649
656
  }
650
657
  });
@@ -661,7 +668,7 @@ export function setupSaaSApiRoutes(app) {
661
668
  error: { code: 'INVALID_INPUT', message: 'Environment and content required' },
662
669
  });
663
670
  }
664
- const result = await secretsService.importFromEnv(req.params.teamId, environment, content, req.user.id);
671
+ const result = await secretsService.importFromEnv(req.params.teamId, environment, content, getAuthenticatedUser(req).id);
665
672
  res.json({
666
673
  success: true,
667
674
  data: result,
@@ -670,7 +677,7 @@ export function setupSaaSApiRoutes(app) {
670
677
  catch (error) {
671
678
  res.status(400).json({
672
679
  success: false,
673
- error: { code: 'INTERNAL_ERROR', message: error.message },
680
+ error: { code: 'INTERNAL_ERROR', message: getErrorMessage(error) },
674
681
  });
675
682
  }
676
683
  });
@@ -684,7 +691,7 @@ export function setupSaaSApiRoutes(app) {
684
691
  app.get('/api/v1/organizations/:organizationId/audit-logs', authenticateUser, requireOrganizationMembership, async (req, res) => {
685
692
  try {
686
693
  // Check permission
687
- const canView = await organizationService.hasPermission(req.params.organizationId, req.user.id, 'canViewAuditLogs');
694
+ const canView = await organizationService.hasPermission(req.params.organizationId, getAuthenticatedUser(req).id, 'canViewAuditLogs');
688
695
  if (!canView) {
689
696
  return res.status(403).json({
690
697
  success: false,
@@ -709,7 +716,7 @@ export function setupSaaSApiRoutes(app) {
709
716
  catch (error) {
710
717
  res.status(500).json({
711
718
  success: false,
712
- error: { code: 'INTERNAL_ERROR', message: error.message },
719
+ error: { code: 'INTERNAL_ERROR', message: getErrorMessage(error) },
713
720
  });
714
721
  }
715
722
  });
@@ -723,7 +730,7 @@ export function setupSaaSApiRoutes(app) {
723
730
  app.post('/api/v1/organizations/:organizationId/billing/checkout', writeLimiter, authenticateUser, requireOrganizationMembership, async (req, res) => {
724
731
  try {
725
732
  // Check permission
726
- const canManage = await organizationService.hasPermission(req.params.organizationId, req.user.id, 'canManageBilling');
733
+ const canManage = await organizationService.hasPermission(req.params.organizationId, getAuthenticatedUser(req).id, 'canManageBilling');
727
734
  if (!canManage) {
728
735
  return res.status(403).json({
729
736
  success: false,
@@ -754,7 +761,7 @@ export function setupSaaSApiRoutes(app) {
754
761
  catch (error) {
755
762
  res.status(400).json({
756
763
  success: false,
757
- error: { code: 'INTERNAL_ERROR', message: error.message },
764
+ error: { code: 'INTERNAL_ERROR', message: getErrorMessage(error) },
758
765
  });
759
766
  }
760
767
  });
@@ -771,7 +778,7 @@ export function setupSaaSApiRoutes(app) {
771
778
  }
772
779
  catch (error) {
773
780
  console.error('Webhook error:', error);
774
- res.status(400).json({ error: error.message });
781
+ res.status(400).json({ error: getErrorMessage(error) });
775
782
  }
776
783
  });
777
784
  console.log('āœ… SaaS API routes registered');
@@ -6,6 +6,7 @@ import express from 'express';
6
6
  import cors from 'cors';
7
7
  import rateLimit from 'express-rate-limit';
8
8
  import { setupSaaSApiRoutes } from './saas-api-routes.js';
9
+ import { getErrorMessage } from '../lib/saas-types.js';
9
10
  /**
10
11
  * SaaS API Server
11
12
  */
@@ -123,19 +124,22 @@ export class SaaSApiServer {
123
124
  */
124
125
  setupErrorHandlers() {
125
126
  // Global error handler
126
- this.app.use((err, req, res, _next) => {
127
+ const errorHandler = (err, _req, res, _next) => {
127
128
  console.error('API Error:', err);
128
129
  // Don't leak error details in production
129
130
  const isDev = process.env.NODE_ENV !== 'production';
130
- res.status(err.status || 500).json({
131
+ const statusCode = err.status || 500;
132
+ const errorCode = err.code || 'INTERNAL_ERROR';
133
+ res.status(statusCode).json({
131
134
  success: false,
132
135
  error: {
133
- code: err.code || 'INTERNAL_ERROR',
134
- message: isDev ? err.message : 'Internal server error',
136
+ code: errorCode,
137
+ message: isDev ? getErrorMessage(err) : 'Internal server error',
135
138
  details: isDev ? err.stack : undefined,
136
139
  },
137
140
  });
138
- });
141
+ };
142
+ this.app.use(errorHandler);
139
143
  }
140
144
  /**
141
145
  * Start the server
@@ -160,6 +160,8 @@ export class CronJobManager extends BaseJobManager {
160
160
  */
161
161
  async getJobReport(jobId) {
162
162
  // Try to get historical data from database if available, otherwise use current job info
163
+ // Using any[] because getJobHistory returns ShellJob (snake_case properties)
164
+ // but getJob returns JobSpec (camelCase properties), and this code handles both
163
165
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
164
166
  let jobs = [];
165
167
  try {
@@ -269,13 +269,32 @@ export class IPFSSecretsStorage {
269
269
  * Decrypt secrets using AES-256
270
270
  */
271
271
  decryptSecrets(encryptedData, encryptionKey) {
272
- const [ivHex, encrypted] = encryptedData.split(':');
273
- const key = crypto.createHash('sha256').update(encryptionKey).digest();
274
- const iv = Buffer.from(ivHex, 'hex');
275
- const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
276
- let decrypted = decipher.update(encrypted, 'hex', 'utf8');
277
- decrypted += decipher.final('utf8');
278
- return JSON.parse(decrypted);
272
+ try {
273
+ const [ivHex, encrypted] = encryptedData.split(':');
274
+ const key = crypto.createHash('sha256').update(encryptionKey).digest();
275
+ const iv = Buffer.from(ivHex, 'hex');
276
+ const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
277
+ let decrypted = decipher.update(encrypted, 'hex', 'utf8');
278
+ decrypted += decipher.final('utf8');
279
+ return JSON.parse(decrypted);
280
+ }
281
+ catch (error) {
282
+ const err = error;
283
+ // Catch crypto errors (bad decrypt, wrong block length) AND JSON parse errors
284
+ // (wrong key can produce garbage that fails JSON.parse)
285
+ if (err.message.includes('bad decrypt') ||
286
+ err.message.includes('wrong final block length') ||
287
+ err.message.includes('Unexpected token') ||
288
+ err.message.includes('JSON')) {
289
+ throw new Error('Decryption failed. This usually means:\n' +
290
+ ' 1. You need to set LSH_SECRETS_KEY environment variable\n' +
291
+ ' 2. The key must match the one used during encryption\n' +
292
+ ' 3. Generate a shared key with: lsh secrets key\n' +
293
+ ' 4. Add it to your .env: LSH_SECRETS_KEY=<key>\n' +
294
+ '\nOriginal error: ' + err.message);
295
+ }
296
+ throw error;
297
+ }
279
298
  }
280
299
  /**
281
300
  * Generate IPFS-compatible CID from content
@@ -292,7 +292,6 @@ export class JobManager extends BaseJobManager {
292
292
  /**
293
293
  * Get job statistics
294
294
  */
295
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
296
295
  getJobStats() {
297
296
  const jobs = Array.from(this.jobs.values());
298
297
  const stats = {
@@ -76,7 +76,6 @@ zsh-source${options.importOptions ? ' ' + options.importOptions.join(' ') : ''}
76
76
  /**
77
77
  * Source .lshrc commands
78
78
  */
79
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
80
79
  async source(_executor) {
81
80
  if (!this.exists()) {
82
81
  return [];
@@ -173,6 +173,7 @@ export class AuditLogger {
173
173
  /**
174
174
  * Map database log to AuditLog type
175
175
  */
176
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- DB row type varies by schema
176
177
  mapDbLogToLog(dbLog) {
177
178
  return {
178
179
  id: dbLog.id,
@@ -200,9 +201,11 @@ export const auditLogger = new AuditLogger();
200
201
  * Helper function to extract IP from request
201
202
  */
202
203
  export function getIpFromRequest(req) {
203
- return (req.headers['x-forwarded-for']?.split(',')[0].trim() ||
204
- req.headers['x-real-ip'] ||
205
- req.connection?.remoteAddress ||
204
+ const forwarded = req.headers['x-forwarded-for'];
205
+ const forwardedIp = typeof forwarded === 'string' ? forwarded.split(',')[0].trim() : undefined;
206
+ const realIp = req.headers['x-real-ip'];
207
+ return (forwardedIp ||
208
+ (typeof realIp === 'string' ? realIp : undefined) ||
206
209
  req.socket?.remoteAddress);
207
210
  }
208
211
  /**
@@ -82,7 +82,7 @@ export function verifyToken(token) {
82
82
  type: decoded.type,
83
83
  };
84
84
  }
85
- catch (error) {
85
+ catch (_error) {
86
86
  throw new Error('Invalid or expired token');
87
87
  }
88
88
  }
@@ -320,6 +320,7 @@ export class AuthService {
320
320
  if (error) {
321
321
  throw new Error(`Failed to get user organizations: ${error.message}`);
322
322
  }
323
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- DB join result
323
324
  return (data || []).map((row) => this.mapDbOrgToOrg(row.organizations));
324
325
  }
325
326
  /**
@@ -337,7 +338,7 @@ export class AuthService {
337
338
  return generateToken();
338
339
  }
339
340
  const resetToken = generateToken();
340
- const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
341
+ const _expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour (for future use)
341
342
  // Store reset token (we'll need a password_reset_tokens table)
342
343
  // For now, just return the token
343
344
  return resetToken;
@@ -345,7 +346,7 @@ export class AuthService {
345
346
  /**
346
347
  * Reset password
347
348
  */
348
- async resetPassword(token, newPassword) {
349
+ async resetPassword(_token, _newPassword) {
349
350
  // TODO: Implement password reset
350
351
  // Need to create password_reset_tokens table
351
352
  throw new Error('Not implemented');
@@ -378,6 +379,7 @@ export class AuthService {
378
379
  /**
379
380
  * Map database user to User type
380
381
  */
382
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- DB row type varies by schema
381
383
  mapDbUserToUser(dbUser) {
382
384
  return {
383
385
  id: dbUser.id,
@@ -403,6 +405,7 @@ export class AuthService {
403
405
  /**
404
406
  * Map database organization to Organization type
405
407
  */
408
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- DB row type varies by schema
406
409
  mapDbOrgToOrg(dbOrg) {
407
410
  return {
408
411
  id: dbOrg.id,
@@ -155,19 +155,21 @@ export class BillingService {
155
155
  /**
156
156
  * Verify webhook signature
157
157
  */
158
- verifyWebhookSignature(payload, signature) {
158
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Stripe event structure
159
+ verifyWebhookSignature(payload, _signature) {
159
160
  // In production, use Stripe's webhook signature verification
160
161
  // For now, just parse the payload
161
162
  try {
162
163
  return JSON.parse(payload);
163
164
  }
164
- catch (error) {
165
+ catch (_error) {
165
166
  throw new Error('Invalid webhook payload');
166
167
  }
167
168
  }
168
169
  /**
169
170
  * Handle checkout completed
170
171
  */
172
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Stripe checkout session object
171
173
  async handleCheckoutCompleted(session) {
172
174
  const organizationId = session.metadata?.organization_id;
173
175
  if (!organizationId) {
@@ -180,6 +182,7 @@ export class BillingService {
180
182
  /**
181
183
  * Handle subscription updated
182
184
  */
185
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Stripe subscription object
183
186
  async handleSubscriptionUpdated(subscription) {
184
187
  const organizationId = subscription.metadata?.organization_id;
185
188
  if (!organizationId) {
@@ -233,6 +236,7 @@ export class BillingService {
233
236
  /**
234
237
  * Handle subscription deleted
235
238
  */
239
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Stripe subscription object
236
240
  async handleSubscriptionDeleted(subscription) {
237
241
  const organizationId = subscription.metadata?.organization_id;
238
242
  if (!organizationId) {
@@ -265,6 +269,7 @@ export class BillingService {
265
269
  /**
266
270
  * Handle invoice paid
267
271
  */
272
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Stripe invoice object
268
273
  async handleInvoicePaid(invoice) {
269
274
  const organizationId = invoice.subscription_metadata?.organization_id;
270
275
  if (!organizationId) {
@@ -287,6 +292,7 @@ export class BillingService {
287
292
  /**
288
293
  * Handle invoice payment failed
289
294
  */
295
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Stripe invoice object
290
296
  async handleInvoicePaymentFailed(invoice) {
291
297
  const organizationId = invoice.subscription_metadata?.organization_id;
292
298
  if (!organizationId) {
@@ -353,6 +359,7 @@ export class BillingService {
353
359
  /**
354
360
  * Map database subscription to Subscription type
355
361
  */
362
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- DB row type varies by schema
356
363
  mapDbSubscriptionToSubscription(dbSub) {
357
364
  return {
358
365
  id: dbSub.id,
@@ -377,6 +384,7 @@ export class BillingService {
377
384
  /**
378
385
  * Map database invoice to Invoice type
379
386
  */
387
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- DB row type varies by schema
380
388
  mapDbInvoiceToInvoice(dbInvoice) {
381
389
  return {
382
390
  id: dbInvoice.id,
@@ -7,7 +7,7 @@ import { getSupabaseClient } from './supabase-client.js';
7
7
  const ALGORITHM = 'aes-256-cbc';
8
8
  const KEY_LENGTH = 32; // 256 bits
9
9
  const IV_LENGTH = 16; // 128 bits
10
- const SALT_LENGTH = 32;
10
+ const _SALT_LENGTH = 32; // Reserved for future use
11
11
  const PBKDF2_ITERATIONS = 100000;
12
12
  /**
13
13
  * Get master encryption key from environment
@@ -199,6 +199,7 @@ export class EncryptionService {
199
199
  /**
200
200
  * Map database key to EncryptionKey type
201
201
  */
202
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- DB row type varies by schema
202
203
  mapDbKeyToKey(dbKey) {
203
204
  return {
204
205
  id: dbKey.id,
@@ -332,6 +332,7 @@ export class OrganizationService {
332
332
  /**
333
333
  * Map database org to Organization type
334
334
  */
335
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- DB row type varies by schema
335
336
  mapDbOrgToOrg(dbOrg) {
336
337
  return {
337
338
  id: dbOrg.id,
@@ -352,6 +353,7 @@ export class OrganizationService {
352
353
  /**
353
354
  * Map database member to OrganizationMember type
354
355
  */
356
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- DB row type varies by schema
355
357
  mapDbMemberToMember(dbMember) {
356
358
  return {
357
359
  id: dbMember.id,
@@ -368,6 +370,7 @@ export class OrganizationService {
368
370
  /**
369
371
  * Map database member detailed to OrganizationMemberDetailed type
370
372
  */
373
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- DB row with joined user data
371
374
  mapDbMemberDetailedToMemberDetailed(dbMember) {
372
375
  return {
373
376
  id: dbMember.id,
@@ -558,6 +561,7 @@ export class TeamService {
558
561
  /**
559
562
  * Map database team to Team type
560
563
  */
564
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- DB row type varies by schema
561
565
  mapDbTeamToTeam(dbTeam) {
562
566
  return {
563
567
  id: dbTeam.id,
@@ -574,6 +578,7 @@ export class TeamService {
574
578
  /**
575
579
  * Map database team member to TeamMember type
576
580
  */
581
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- DB row type varies by schema
577
582
  mapDbTeamMemberToTeamMember(dbMember) {
578
583
  return {
579
584
  id: dbMember.id,
@@ -2,6 +2,7 @@
2
2
  * LSH SaaS Secrets Management Service
3
3
  * Multi-tenant secrets with per-team encryption
4
4
  */
5
+ import { getErrorMessage, } from './saas-types.js';
5
6
  import { getSupabaseClient } from './supabase-client.js';
6
7
  import { encryptionService } from './saas-encryption.js';
7
8
  import { auditLogger } from './saas-audit.js';
@@ -216,6 +217,7 @@ export class SecretsService {
216
217
  if (error) {
217
218
  throw new Error(`Failed to get secrets summary: ${error.message}`);
218
219
  }
220
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- DB row from view
219
221
  return (data || []).map((row) => ({
220
222
  teamId: row.team_id,
221
223
  teamName: row.team_name,
@@ -313,7 +315,7 @@ export class SecretsService {
313
315
  }
314
316
  }
315
317
  catch (error) {
316
- errors.push(`${secret.key}: ${error.message}`);
318
+ errors.push(`${secret.key}: ${getErrorMessage(error)}`);
317
319
  }
318
320
  }
319
321
  return { created, updated, errors };
@@ -351,6 +353,7 @@ export class SecretsService {
351
353
  /**
352
354
  * Map database secret to Secret type
353
355
  */
356
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- DB row type varies by schema
354
357
  mapDbSecretToSecret(dbSecret) {
355
358
  return {
356
359
  id: dbSecret.id,
@@ -106,3 +106,60 @@ export var ErrorCode;
106
106
  ErrorCode["INTERNAL_ERROR"] = "INTERNAL_ERROR";
107
107
  ErrorCode["SERVICE_UNAVAILABLE"] = "SERVICE_UNAVAILABLE";
108
108
  })(ErrorCode || (ErrorCode = {}));
109
+ /**
110
+ * Helper to safely extract error message
111
+ */
112
+ export function getErrorMessage(error) {
113
+ if (error instanceof Error) {
114
+ return error.message;
115
+ }
116
+ if (typeof error === 'string') {
117
+ return error;
118
+ }
119
+ return 'Unknown error occurred';
120
+ }
121
+ /**
122
+ * Helper to safely extract error for logging
123
+ */
124
+ export function getErrorDetails(error) {
125
+ if (error instanceof Error) {
126
+ return {
127
+ message: error.message,
128
+ stack: error.stack,
129
+ code: error.code,
130
+ };
131
+ }
132
+ return { message: String(error) };
133
+ }
134
+ /**
135
+ * Helper to get authenticated user from request.
136
+ * Use after authenticateUser middleware - throws if user not present.
137
+ */
138
+ export function getAuthenticatedUser(req) {
139
+ if (!req.user) {
140
+ throw new Error('User not authenticated');
141
+ }
142
+ return req.user;
143
+ }
144
+ /**
145
+ * Create a standardized API error response
146
+ */
147
+ export function createErrorResponse(code, message, details) {
148
+ return {
149
+ success: false,
150
+ error: {
151
+ code,
152
+ message,
153
+ details,
154
+ },
155
+ };
156
+ }
157
+ /**
158
+ * Create a standardized API success response
159
+ */
160
+ export function createSuccessResponse(data) {
161
+ return {
162
+ success: true,
163
+ data,
164
+ };
165
+ }
@@ -14,15 +14,53 @@ export class SecretsManager {
14
14
  storage;
15
15
  encryptionKey;
16
16
  gitInfo;
17
- constructor(userId, encryptionKey, detectGit = true) {
17
+ globalMode;
18
+ homeDir;
19
+ constructor(userIdOrOptions, encryptionKey, detectGit) {
18
20
  this.storage = new IPFSSecretsStorage();
21
+ this.homeDir = process.env.HOME || process.env.USERPROFILE || '';
22
+ // Handle both legacy and new constructor signatures
23
+ let options;
24
+ if (typeof userIdOrOptions === 'object') {
25
+ options = userIdOrOptions;
26
+ }
27
+ else {
28
+ options = {
29
+ userId: userIdOrOptions,
30
+ encryptionKey,
31
+ detectGit: detectGit ?? true,
32
+ globalMode: false,
33
+ };
34
+ }
35
+ this.globalMode = options.globalMode ?? false;
19
36
  // Use provided key or generate from machine ID + user
20
- this.encryptionKey = encryptionKey || this.getDefaultEncryptionKey();
21
- // Auto-detect git repo context
22
- if (detectGit) {
37
+ this.encryptionKey = options.encryptionKey || this.getDefaultEncryptionKey();
38
+ // Auto-detect git repo context (skip if in global mode)
39
+ if (!this.globalMode && (options.detectGit ?? true)) {
23
40
  this.gitInfo = getGitRepoInfo();
24
41
  }
25
42
  }
43
+ /**
44
+ * Check if running in global mode
45
+ */
46
+ isGlobalMode() {
47
+ return this.globalMode;
48
+ }
49
+ /**
50
+ * Get the home directory path
51
+ */
52
+ getHomeDir() {
53
+ return this.homeDir;
54
+ }
55
+ /**
56
+ * Resolve file path - in global mode, resolves relative to $HOME
57
+ */
58
+ resolveFilePath(filePath) {
59
+ if (this.globalMode && !path.isAbsolute(filePath)) {
60
+ return path.join(this.homeDir, filePath);
61
+ }
62
+ return filePath;
63
+ }
26
64
  /**
27
65
  * Cleanup resources (stop timers, close connections)
28
66
  * Call this when done to allow process to exit
@@ -430,8 +468,13 @@ export class SecretsManager {
430
468
  /**
431
469
  * Get the default environment name based on context
432
470
  * v2.0: In git repo, default is repo name; otherwise 'dev'
471
+ * Global mode: always returns 'dev' (which resolves to 'global' namespace)
433
472
  */
434
473
  getDefaultEnvironment() {
474
+ // Global mode uses simple 'dev' which maps to 'global' namespace
475
+ if (this.globalMode) {
476
+ return 'dev';
477
+ }
435
478
  // Check for v1 compatibility mode
436
479
  if (process.env.LSH_V1_COMPAT === 'true') {
437
480
  return 'dev'; // v1.x behavior
@@ -447,11 +490,19 @@ export class SecretsManager {
447
490
  * v2.0: Returns environment name with repo context if in a git repo
448
491
  *
449
492
  * Behavior:
493
+ * - Global mode: returns 'global' or 'global_env' (e.g., global_staging)
450
494
  * - Empty env in repo: returns just repo name (v2.0 default)
451
495
  * - Named env in repo: returns repo_env (e.g., repo_staging)
452
496
  * - Any env outside repo: returns env as-is
453
497
  */
454
498
  getRepoAwareEnvironment(environment) {
499
+ // Global mode uses 'global' namespace
500
+ if (this.globalMode) {
501
+ if (environment === '' || environment === 'default' || environment === 'dev') {
502
+ return 'global';
503
+ }
504
+ return `global_${environment}`;
505
+ }
455
506
  if (this.gitInfo?.repoName) {
456
507
  // v2.0: Empty environment means "use repo name only"
457
508
  if (environment === '' || environment === 'default') {
@@ -610,8 +661,14 @@ LSH_SECRETS_KEY=${this.encryptionKey}
610
661
  // In load mode, suppress all output except the final export commands
611
662
  const out = loadMode ? () => { } : console.log;
612
663
  out(`\nšŸ” Smart sync for: ${displayEnv}\n`);
613
- // Show git repo context if detected
614
- if (this.gitInfo?.isGitRepo) {
664
+ // Show workspace context
665
+ if (this.globalMode) {
666
+ out('🌐 Global Workspace:');
667
+ out(` Location: ${this.homeDir}`);
668
+ out(` Namespace: global`);
669
+ out();
670
+ }
671
+ else if (this.gitInfo?.isGitRepo) {
615
672
  out('šŸ“ Git Repository:');
616
673
  out(` Repo: ${this.gitInfo.repoName || 'unknown'}`);
617
674
  if (this.gitInfo.currentBranch) {
@@ -4,7 +4,6 @@
4
4
  */
5
5
  import { createClient } from '@supabase/supabase-js';
6
6
  export class SupabaseClient {
7
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
8
7
  client;
9
8
  config;
10
9
  constructor(config) {
@@ -32,7 +31,7 @@ export class SupabaseClient {
32
31
  */
33
32
  async testConnection() {
34
33
  try {
35
- const { _data, error } = await this.client
34
+ const { error } = await this.client
36
35
  .from('shell_history')
37
36
  .select('count')
38
37
  .limit(1);
@@ -14,13 +14,16 @@ export async function init_secrets(program) {
14
14
  .description('Push local .env to encrypted cloud storage')
15
15
  .option('-f, --file <path>', 'Path to .env file', '.env')
16
16
  .option('-e, --env <name>', 'Environment name (dev/staging/prod)', 'dev')
17
+ .option('-g, --global', 'Use global workspace ($HOME)')
17
18
  .option('--force', 'Force push even if destructive changes detected')
18
19
  .action(async (options) => {
19
- const manager = new SecretsManager();
20
+ const manager = new SecretsManager({ globalMode: options.global });
20
21
  try {
22
+ // Resolve file path (handles global mode)
23
+ const filePath = manager.resolveFilePath(options.file);
21
24
  // v2.0: Use context-aware default environment
22
25
  const env = options.env === 'dev' ? manager.getDefaultEnvironment() : options.env;
23
- await manager.push(options.file, env, options.force);
26
+ await manager.push(filePath, env, options.force);
24
27
  }
25
28
  catch (error) {
26
29
  const err = error;
@@ -38,13 +41,16 @@ export async function init_secrets(program) {
38
41
  .description('Pull .env from encrypted cloud storage')
39
42
  .option('-f, --file <path>', 'Path to .env file', '.env')
40
43
  .option('-e, --env <name>', 'Environment name (dev/staging/prod)', 'dev')
44
+ .option('-g, --global', 'Use global workspace ($HOME)')
41
45
  .option('--force', 'Overwrite without creating backup')
42
46
  .action(async (options) => {
43
- const manager = new SecretsManager();
47
+ const manager = new SecretsManager({ globalMode: options.global });
44
48
  try {
49
+ // Resolve file path (handles global mode)
50
+ const filePath = manager.resolveFilePath(options.file);
45
51
  // v2.0: Use context-aware default environment
46
52
  const env = options.env === 'dev' ? manager.getDefaultEnvironment() : options.env;
47
- await manager.pull(options.file, env, options.force);
53
+ await manager.pull(filePath, env, options.force);
48
54
  }
49
55
  catch (error) {
50
56
  const err = error;
@@ -62,12 +68,14 @@ export async function init_secrets(program) {
62
68
  .alias('ls')
63
69
  .description('List secrets in the current local .env file')
64
70
  .option('-f, --file <path>', 'Path to .env file', '.env')
71
+ .option('-g, --global', 'Use global workspace ($HOME)')
65
72
  .option('--keys-only', 'Show only keys, not values')
66
73
  .option('--format <type>', 'Output format: env, json, yaml, toml, export', 'env')
67
74
  .option('--no-mask', 'Show full values (default: auto based on format)')
68
75
  .action(async (options) => {
69
76
  try {
70
- const envPath = path.resolve(options.file);
77
+ const manager = new SecretsManager({ globalMode: options.global });
78
+ const envPath = path.resolve(manager.resolveFilePath(options.file));
71
79
  if (!fs.existsSync(envPath)) {
72
80
  console.error(`āŒ File not found: ${envPath}`);
73
81
  console.log('šŸ’” Tip: Pull from cloud with: lsh pull --env <environment>');
@@ -138,11 +146,12 @@ export async function init_secrets(program) {
138
146
  program
139
147
  .command('env [environment]')
140
148
  .description('List all stored environments or show secrets for specific environment')
149
+ .option('-g, --global', 'Use global workspace ($HOME)')
141
150
  .option('--all-files', 'List all tracked .env files across environments')
142
151
  .option('--format <type>', 'Output format: env, json, yaml, toml, export', 'env')
143
152
  .action(async (environment, options) => {
144
153
  try {
145
- const manager = new SecretsManager();
154
+ const manager = new SecretsManager({ globalMode: options.global });
146
155
  // If --all-files flag is set, list all tracked files
147
156
  if (options.allFiles) {
148
157
  const files = await manager.listAllFiles();
@@ -269,23 +278,26 @@ API_KEY=
269
278
  .description('Automatically set up and synchronize secrets (smart mode)')
270
279
  .option('-f, --file <path>', 'Path to .env file', '.env')
271
280
  .option('-e, --env <name>', 'Environment name', 'dev')
281
+ .option('-g, --global', 'Use global workspace ($HOME)')
272
282
  .option('--dry-run', 'Show what would be done without executing')
273
283
  .option('--legacy', 'Use legacy sync mode (suggestions only)')
274
284
  .option('--load', 'Output eval-able export commands for loading secrets')
275
285
  .option('--force', 'Force sync even if destructive changes detected')
276
286
  .option('--force-rekey', 'Re-encrypt cloud secrets with current local key (use when key mismatch)')
277
287
  .action(async (options) => {
278
- const manager = new SecretsManager();
288
+ const manager = new SecretsManager({ globalMode: options.global });
279
289
  try {
290
+ // Resolve file path (handles global mode)
291
+ const filePath = manager.resolveFilePath(options.file);
280
292
  // v2.0: Use context-aware default environment
281
293
  const env = options.env === 'dev' ? manager.getDefaultEnvironment() : options.env;
282
294
  if (options.legacy) {
283
295
  // Use legacy sync (suggestions only)
284
- await manager.sync(options.file, env);
296
+ await manager.sync(filePath, env);
285
297
  }
286
298
  else {
287
299
  // Use new smart sync (auto-execute)
288
- await manager.smartSync(options.file, env, !options.dryRun, options.load, options.force, options.forceRekey);
300
+ await manager.smartSync(filePath, env, !options.dryRun, options.load, options.force, options.forceRekey);
289
301
  }
290
302
  }
291
303
  catch (error) {
@@ -304,10 +316,12 @@ API_KEY=
304
316
  .description('Get detailed secrets status (JSON output)')
305
317
  .option('-f, --file <path>', 'Path to .env file', '.env')
306
318
  .option('-e, --env <name>', 'Environment name', 'dev')
319
+ .option('-g, --global', 'Use global workspace ($HOME)')
307
320
  .action(async (options) => {
308
321
  try {
309
- const manager = new SecretsManager();
310
- const status = await manager.status(options.file, options.env);
322
+ const manager = new SecretsManager({ globalMode: options.global });
323
+ const filePath = manager.resolveFilePath(options.file);
324
+ const status = await manager.status(filePath, options.env);
311
325
  console.log(JSON.stringify(status, null, 2));
312
326
  }
313
327
  catch (error) {
@@ -322,14 +336,20 @@ API_KEY=
322
336
  .description('Show current directory context and tracked environment')
323
337
  .option('-f, --file <path>', 'Path to .env file', '.env')
324
338
  .option('-e, --env <name>', 'Environment name', 'dev')
339
+ .option('-g, --global', 'Use global workspace ($HOME)')
325
340
  .action(async (options) => {
326
341
  try {
327
- const gitInfo = getGitRepoInfo();
328
- const manager = new SecretsManager();
329
- const envPath = path.resolve(options.file);
342
+ const gitInfo = options.global ? null : getGitRepoInfo();
343
+ const manager = new SecretsManager({ globalMode: options.global });
344
+ const envPath = path.resolve(manager.resolveFilePath(options.file));
330
345
  console.log('\nšŸ“ Current Directory Context\n');
331
- // Git Repository Info
332
- if (gitInfo.isGitRepo) {
346
+ // Workspace Info
347
+ if (options.global) {
348
+ console.log('🌐 Global Workspace:');
349
+ console.log(` Location: ${manager.getHomeDir()}`);
350
+ console.log(' Mode: Global (not repo-specific)');
351
+ }
352
+ else if (gitInfo?.isGitRepo) {
333
353
  console.log('šŸ“ Git Repository:');
334
354
  console.log(` Root: ${gitInfo.rootPath || 'unknown'}`);
335
355
  console.log(` Name: ${gitInfo.repoName || 'unknown'}`);
@@ -347,12 +367,22 @@ API_KEY=
347
367
  // Environment Tracking
348
368
  console.log('šŸ” Environment Tracking:');
349
369
  // Show the effective environment name used for cloud storage
350
- const effectiveEnv = gitInfo.repoName
351
- ? `${gitInfo.repoName}_${options.env}`
352
- : options.env;
370
+ let effectiveEnv;
371
+ if (options.global) {
372
+ effectiveEnv = options.env === 'dev' ? 'global' : `global_${options.env}`;
373
+ }
374
+ else {
375
+ effectiveEnv = gitInfo?.repoName
376
+ ? `${gitInfo.repoName}_${options.env}`
377
+ : options.env;
378
+ }
353
379
  console.log(` Base environment: ${options.env}`);
354
380
  console.log(` Cloud storage name: ${effectiveEnv}`);
355
- if (gitInfo.repoName) {
381
+ if (options.global) {
382
+ console.log(' Namespace: global');
383
+ console.log(' ā„¹ļø Global workspace mode enabled');
384
+ }
385
+ else if (gitInfo?.repoName) {
356
386
  console.log(` Namespace: ${gitInfo.repoName}`);
357
387
  console.log(' ā„¹ļø Repo-based isolation enabled');
358
388
  }
@@ -420,13 +450,15 @@ API_KEY=
420
450
  .command('get [key]')
421
451
  .description('Get a specific secret value from .env file, or all secrets with --all')
422
452
  .option('-f, --file <path>', 'Path to .env file', '.env')
453
+ .option('-g, --global', 'Use global workspace ($HOME)')
423
454
  .option('--all', 'Get all secrets from the file')
424
455
  .option('--export', 'Output in export format for shell evaluation (alias for --format export)')
425
456
  .option('--format <type>', 'Output format: env, json, yaml, toml, export', 'env')
426
457
  .option('--exact', 'Require exact key match (disable fuzzy matching)')
427
458
  .action(async (key, options) => {
428
459
  try {
429
- const envPath = path.resolve(options.file);
460
+ const manager = new SecretsManager({ globalMode: options.global });
461
+ const envPath = path.resolve(manager.resolveFilePath(options.file));
430
462
  if (!fs.existsSync(envPath)) {
431
463
  console.error(`āŒ File not found: ${envPath}`);
432
464
  process.exit(1);
@@ -535,10 +567,12 @@ API_KEY=
535
567
  .command('set [key] [value]')
536
568
  .description('Set a specific secret value in .env file, or batch upsert from stdin (KEY=VALUE format)')
537
569
  .option('-f, --file <path>', 'Path to .env file', '.env')
570
+ .option('-g, --global', 'Use global workspace ($HOME)')
538
571
  .option('--stdin', 'Read KEY=VALUE pairs from stdin (one per line)')
539
572
  .action(async (key, value, options) => {
540
573
  try {
541
- const envPath = path.resolve(options.file);
574
+ const manager = new SecretsManager({ globalMode: options.global });
575
+ const envPath = path.resolve(manager.resolveFilePath(options.file));
542
576
  // Check if we should read from stdin
543
577
  const isStdin = options.stdin || (!key && !value);
544
578
  if (isStdin) {
@@ -792,10 +826,12 @@ API_KEY=
792
826
  .command('delete')
793
827
  .description('Delete .env file (requires confirmation)')
794
828
  .option('-f, --file <path>', 'Path to .env file', '.env')
829
+ .option('-g, --global', 'Use global workspace ($HOME)')
795
830
  .option('-y, --yes', 'Skip confirmation prompt')
796
831
  .action(async (options) => {
797
832
  try {
798
- const envPath = path.resolve(options.file);
833
+ const manager = new SecretsManager({ globalMode: options.global });
834
+ const envPath = path.resolve(manager.resolveFilePath(options.file));
799
835
  // Check if file exists
800
836
  if (!fs.existsSync(envPath)) {
801
837
  console.log(`āŒ File not found: ${envPath}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lsh-framework",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "Simple, cross-platform encrypted secrets manager with automatic sync, IPFS audit logs, and multi-environment support. Just run lsh sync and start managing your secrets.",
5
5
  "main": "dist/app.js",
6
6
  "bin": {
@@ -18,7 +18,8 @@
18
18
  "build": "tsc",
19
19
  "watch": "tsc --watch",
20
20
  "test": "node --experimental-vm-modules ./node_modules/.bin/jest",
21
- "test:coverage": "node --experimental-vm-modules ./node_modules/.bin/jest --coverage",
21
+ "test:ci": "node --experimental-vm-modules ./node_modules/.bin/jest --runInBand",
22
+ "test:coverage": "node --experimental-vm-modules ./node_modules/.bin/jest --coverage --runInBand",
22
23
  "clean": "rm -rf ./build; rm -rf ./bin; rm -rf ./dist",
23
24
  "lint": "eslint src --ext .js,.ts,.tsx",
24
25
  "lint:fix": "eslint src --ext .js,.ts,.tsx --fix",