lsh-framework 1.2.1 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Fuzzy matching utilities for secret keys
3
+ */
4
+ /**
5
+ * Normalize a string for fuzzy matching by removing spaces and special chars
6
+ */
7
+ function normalizeForMatching(str) {
8
+ // Remove spaces, hyphens, and convert to lowercase
9
+ return str.toLowerCase().replace(/[\s-]/g, '');
10
+ }
11
+ /**
12
+ * Calculate fuzzy match score between search string and key
13
+ * Returns a score where higher is better, or -1 if no match
14
+ *
15
+ * Supports space-separated searches:
16
+ * - "stripe api" matches "STRIPE_API_KEY"
17
+ * - "stripe secret" matches "STRIPE_SECRET_KEY"
18
+ */
19
+ export function calculateMatchScore(searchTerm, key) {
20
+ // Empty search term matches nothing
21
+ if (!searchTerm || searchTerm.trim() === '') {
22
+ return -1;
23
+ }
24
+ const searchLower = searchTerm.toLowerCase();
25
+ const keyLower = key.toLowerCase();
26
+ // Normalize both for space-insensitive matching
27
+ const searchNormalized = normalizeForMatching(searchTerm);
28
+ const keyNormalized = normalizeForMatching(key);
29
+ // Exact match (case sensitive) - highest priority
30
+ if (searchTerm === key) {
31
+ return 1100;
32
+ }
33
+ // Exact match (case insensitive)
34
+ if (searchLower === keyLower || searchNormalized === keyNormalized) {
35
+ return 1000;
36
+ }
37
+ // Key starts with search term (case sensitive)
38
+ if (key.startsWith(searchTerm)) {
39
+ return 950;
40
+ }
41
+ // Key starts with search term (case insensitive, normalized)
42
+ if (keyLower.startsWith(searchLower) || keyNormalized.startsWith(searchNormalized)) {
43
+ return 900;
44
+ }
45
+ // Check if search with spaces can match multiple words in key
46
+ // e.g., "stripe api" should match "STRIPE_API_KEY"
47
+ // Only do multi-word matching if user provided spaces (not underscores)
48
+ const hasSpaces = /\s/.test(searchTerm);
49
+ if (hasSpaces) {
50
+ const searchWords = searchTerm.toLowerCase().split(/[\s_-]+/).filter(w => w.length > 0);
51
+ const keyWords = key.toLowerCase().split(/[_-]/).filter(w => w.length > 0);
52
+ if (searchWords.length > 1) {
53
+ // Try to match all search words in order within key words
54
+ let matchCount = 0;
55
+ let lastMatchIndex = -1;
56
+ for (const searchWord of searchWords) {
57
+ let found = false;
58
+ for (let i = lastMatchIndex + 1; i < keyWords.length; i++) {
59
+ if (keyWords[i].startsWith(searchWord) || keyWords[i].includes(searchWord)) {
60
+ matchCount++;
61
+ lastMatchIndex = i;
62
+ found = true;
63
+ break;
64
+ }
65
+ }
66
+ if (!found) {
67
+ break; // If any word doesn't match, stop
68
+ }
69
+ }
70
+ // If all search words matched, score based on how many matched
71
+ if (matchCount === searchWords.length) {
72
+ // Perfect multi-word match - higher than substring matches
73
+ return 850 - (lastMatchIndex * 10); // Earlier matches score higher
74
+ }
75
+ else if (matchCount > 0) {
76
+ // Partial multi-word match
77
+ return 300 + (matchCount * 50);
78
+ }
79
+ }
80
+ }
81
+ // Key contains search term (case insensitive, normalized)
82
+ // Only apply this for single-word searches (no spaces)
83
+ if (!hasSpaces && (keyLower.includes(searchLower) || keyNormalized.includes(searchNormalized))) {
84
+ // Score based on position (earlier is better)
85
+ const position = keyNormalized.indexOf(searchNormalized);
86
+ const relativePosition = position / keyNormalized.length;
87
+ return 500 - (relativePosition * 100);
88
+ }
89
+ // Substring match with underscores/boundaries
90
+ // e.g., "stripe" matches "STRIPE_API_KEY" or "MY_STRIPE_SECRET"
91
+ const words = keyLower.split('_');
92
+ for (let i = 0; i < words.length; i++) {
93
+ if (words[i].startsWith(searchLower)) {
94
+ // Earlier words get higher scores
95
+ return 700 - (i * 50);
96
+ }
97
+ if (words[i].includes(searchLower)) {
98
+ return 400 - (i * 50);
99
+ }
100
+ }
101
+ // No match
102
+ return -1;
103
+ }
104
+ /**
105
+ * Find fuzzy matches for a search term in a list of key-value pairs
106
+ * Returns matches sorted by relevance (best match first)
107
+ */
108
+ export function findFuzzyMatches(searchTerm, secrets) {
109
+ const results = [];
110
+ for (const secret of secrets) {
111
+ const score = calculateMatchScore(searchTerm, secret.key);
112
+ if (score >= 0) {
113
+ results.push({
114
+ key: secret.key,
115
+ value: secret.value,
116
+ score,
117
+ });
118
+ }
119
+ }
120
+ // Sort by score (descending - best match first)
121
+ results.sort((a, b) => b.score - a.score);
122
+ return results;
123
+ }
@@ -404,6 +404,7 @@ API_KEY=
404
404
  .option('--all', 'Get all secrets from the file')
405
405
  .option('--export', 'Output in export format for shell evaluation (alias for --format export)')
406
406
  .option('--format <type>', 'Output format: env, json, yaml, toml, export', 'env')
407
+ .option('--exact', 'Require exact key match (disable fuzzy matching)')
407
408
  .action(async (key, options) => {
408
409
  try {
409
410
  const envPath = path.resolve(options.file);
@@ -413,24 +414,25 @@ API_KEY=
413
414
  }
414
415
  const content = fs.readFileSync(envPath, 'utf8');
415
416
  const lines = content.split('\n');
416
- // Handle --all flag
417
- if (options.all) {
418
- const secrets = [];
419
- for (const line of lines) {
420
- if (line.trim().startsWith('#') || !line.trim())
421
- continue;
422
- const match = line.match(/^([^=]+)=(.*)$/);
423
- if (match) {
424
- const key = match[1].trim();
425
- let value = match[2].trim();
426
- // Remove quotes if present
427
- if ((value.startsWith('"') && value.endsWith('"')) ||
428
- (value.startsWith("'") && value.endsWith("'"))) {
429
- value = value.slice(1, -1);
430
- }
431
- secrets.push({ key, value });
417
+ // Parse all secrets from file
418
+ const secrets = [];
419
+ for (const line of lines) {
420
+ if (line.trim().startsWith('#') || !line.trim())
421
+ continue;
422
+ const match = line.match(/^([^=]+)=(.*)$/);
423
+ if (match) {
424
+ const key = match[1].trim();
425
+ let value = match[2].trim();
426
+ // Remove quotes if present
427
+ if ((value.startsWith('"') && value.endsWith('"')) ||
428
+ (value.startsWith("'") && value.endsWith("'"))) {
429
+ value = value.slice(1, -1);
432
430
  }
431
+ secrets.push({ key, value });
433
432
  }
433
+ }
434
+ // Handle --all flag
435
+ if (options.all) {
434
436
  // Handle format output
435
437
  const format = options.export ? 'export' : options.format.toLowerCase();
436
438
  const validFormats = ['env', 'json', 'yaml', 'toml', 'export'];
@@ -451,22 +453,56 @@ API_KEY=
451
453
  console.error('❌ Please provide a key or use --all flag');
452
454
  process.exit(1);
453
455
  }
454
- for (const line of lines) {
455
- if (line.trim().startsWith('#') || !line.trim())
456
- continue;
457
- const match = line.match(/^([^=]+)=(.*)$/);
458
- if (match && match[1].trim() === key) {
459
- let value = match[2].trim();
460
- // Remove quotes if present
461
- if ((value.startsWith('"') && value.endsWith('"')) ||
462
- (value.startsWith("'") && value.endsWith("'"))) {
463
- value = value.slice(1, -1);
464
- }
465
- console.log(value);
456
+ // Try exact match first
457
+ const exactMatch = secrets.find(s => s.key === key);
458
+ if (exactMatch) {
459
+ console.log(exactMatch.value);
460
+ return;
461
+ }
462
+ // If exact match enabled, don't do fuzzy matching
463
+ if (options.exact) {
464
+ console.error(`❌ Key '${key}' not found in ${options.file}`);
465
+ process.exit(1);
466
+ }
467
+ // Use fuzzy matching
468
+ const { findFuzzyMatches } = await import('../../lib/fuzzy-match.js');
469
+ const matches = findFuzzyMatches(key, secrets);
470
+ if (matches.length === 0) {
471
+ console.error(`❌ No matches found for '${key}' in ${options.file}`);
472
+ console.error('💡 Tip: Use --exact flag for exact matching only');
473
+ process.exit(1);
474
+ }
475
+ // If single match, return it
476
+ if (matches.length === 1) {
477
+ console.log(matches[0].value);
478
+ return;
479
+ }
480
+ // If best match score is significantly higher than second best (clear winner)
481
+ // then auto-select it
482
+ if (matches.length > 1) {
483
+ const bestScore = matches[0].score;
484
+ const secondBestScore = matches[1].score;
485
+ // If best match scores 700+ and is at least 2x better than second best,
486
+ // consider it a clear match
487
+ if (bestScore >= 700 && bestScore >= secondBestScore * 2) {
488
+ console.log(matches[0].value);
466
489
  return;
467
490
  }
468
491
  }
469
- console.error(`❌ Key '${key}' not found in ${options.file}`);
492
+ // Multiple matches - show all matches for user to choose
493
+ console.error(`🔍 Found ${matches.length} matches for '${key}':\n`);
494
+ for (const match of matches) {
495
+ // Mask value for display
496
+ const maskedValue = match.value.length > 4
497
+ ? match.value.substring(0, 4) + '*'.repeat(Math.min(match.value.length - 4, 10))
498
+ : '****';
499
+ console.error(` ${match.key}=${maskedValue}`);
500
+ }
501
+ console.error('');
502
+ console.error('💡 Please specify the exact key name or use one of:');
503
+ for (const match of matches) {
504
+ console.error(` lsh get ${match.key}`);
505
+ }
470
506
  process.exit(1);
471
507
  }
472
508
  catch (error) {
@@ -507,6 +543,42 @@ API_KEY=
507
543
  process.exit(1);
508
544
  }
509
545
  });
546
+ /**
547
+ * Detect if file should use 'export' prefix based on file type
548
+ */
549
+ function shouldUseExport(filePath) {
550
+ const filename = path.basename(filePath);
551
+ const ext = path.extname(filePath);
552
+ // Shell script files - use export
553
+ if (['.sh', '.bash', '.zsh'].includes(ext)) {
554
+ return true;
555
+ }
556
+ // Shell profile/rc files - use export
557
+ if (['.bashrc', '.zshrc', '.profile', '.bash_profile', '.zprofile'].includes(filename)) {
558
+ return true;
559
+ }
560
+ // .envrc files (direnv) - use export
561
+ if (filename === '.envrc' || filename.endsWith('.envrc')) {
562
+ return true;
563
+ }
564
+ // .env files - do NOT use export
565
+ if (filename === '.env' || filename.startsWith('.env.')) {
566
+ return false;
567
+ }
568
+ // Default: no export (safest for most env files)
569
+ return false;
570
+ }
571
+ /**
572
+ * Format a line based on file type
573
+ */
574
+ function formatEnvLine(key, value, filePath) {
575
+ const needsQuotes = /[\s#]/.test(value);
576
+ const quotedValue = needsQuotes ? `"${value}"` : value;
577
+ const useExport = shouldUseExport(filePath);
578
+ return useExport
579
+ ? `export ${key}=${quotedValue}`
580
+ : `${key}=${quotedValue}`;
581
+ }
510
582
  /**
511
583
  * Set a single secret value
512
584
  */
@@ -527,12 +599,11 @@ API_KEY=
527
599
  newLines.push(line);
528
600
  continue;
529
601
  }
530
- const match = line.match(/^([^=]+)=(.*)$/);
602
+ // Match both with and without export
603
+ const match = line.match(/^(?:export\s+)?([^=]+)=(.*)$/);
531
604
  if (match && match[1].trim() === key) {
532
- // Quote values with spaces or special characters
533
- const needsQuotes = /[\s#]/.test(value);
534
- const quotedValue = needsQuotes ? `"${value}"` : value;
535
- newLines.push(`${key}=${quotedValue}`);
605
+ // Use appropriate format for this file type
606
+ newLines.push(formatEnvLine(key, value, envPath));
536
607
  found = true;
537
608
  }
538
609
  else {
@@ -543,9 +614,8 @@ API_KEY=
543
614
  }
544
615
  // If key wasn't found, append it
545
616
  if (!found) {
546
- const needsQuotes = /[\s#]/.test(value);
547
- const quotedValue = needsQuotes ? `"${value}"` : value;
548
- content = content.trimRight() + `\n${key}=${quotedValue}\n`;
617
+ const formattedLine = formatEnvLine(key, value, envPath);
618
+ content = content.trimRight() + `\n${formattedLine}\n`;
549
619
  }
550
620
  fs.writeFileSync(envPath, content, 'utf8');
551
621
  console.log(`✅ Set ${key}`);
@@ -602,8 +672,8 @@ API_KEY=
602
672
  // Skip comments and empty lines
603
673
  if (trimmed.startsWith('#') || !trimmed)
604
674
  continue;
605
- // Parse KEY=VALUE format
606
- const match = trimmed.match(/^([^=]+)=(.*)$/);
675
+ // Parse KEY=VALUE format (with or without export)
676
+ const match = trimmed.match(/^(?:export\s+)?([^=]+)=(.*)$/);
607
677
  if (!match) {
608
678
  errors.push(`Invalid format: ${trimmed}`);
609
679
  continue;
@@ -635,15 +705,14 @@ API_KEY=
635
705
  newLines.push(line);
636
706
  continue;
637
707
  }
638
- const match = line.match(/^([^=]+)=(.*)$/);
708
+ // Match both with and without export
709
+ const match = line.match(/^(?:export\s+)?([^=]+)=(.*)$/);
639
710
  if (match) {
640
711
  const key = match[1].trim();
641
712
  if (newKeys.has(key)) {
642
- // Update existing key
713
+ // Update existing key with appropriate format
643
714
  const value = newKeys.get(key);
644
- const needsQuotes = /[\s#]/.test(value);
645
- const quotedValue = needsQuotes ? `"${value}"` : value;
646
- newLines.push(`${key}=${quotedValue}`);
715
+ newLines.push(formatEnvLine(key, value, envPath));
647
716
  newKeys.delete(key); // Mark as processed
648
717
  hasContent = true;
649
718
  }
@@ -660,13 +729,12 @@ API_KEY=
660
729
  }
661
730
  // Add new keys that weren't in the existing file
662
731
  for (const [key, value] of newKeys.entries()) {
663
- const needsQuotes = /[\s#]/.test(value);
664
- const quotedValue = needsQuotes ? `"${value}"` : value;
732
+ const formattedLine = formatEnvLine(key, value, envPath);
665
733
  if (hasContent) {
666
- newLines.push(`${key}=${quotedValue}`);
734
+ newLines.push(formattedLine);
667
735
  }
668
736
  else {
669
- newLines.push(`${key}=${quotedValue}`);
737
+ newLines.push(formattedLine);
670
738
  hasContent = true;
671
739
  }
672
740
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lsh-framework",
3
- "version": "1.2.1",
3
+ "version": "1.3.1",
4
4
  "description": "Simple, cross-platform encrypted secrets manager with automatic sync and multi-environment support. Just run lsh sync and start managing your secrets.",
5
5
  "main": "dist/app.js",
6
6
  "bin": {