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.
- package/dist/lib/fuzzy-match.js +123 -0
- package/dist/services/secrets/secrets.js +116 -48
- package/package.json +1 -1
|
@@ -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
|
-
//
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
-
|
|
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
|
-
|
|
602
|
+
// Match both with and without export
|
|
603
|
+
const match = line.match(/^(?:export\s+)?([^=]+)=(.*)$/);
|
|
531
604
|
if (match && match[1].trim() === key) {
|
|
532
|
-
//
|
|
533
|
-
|
|
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
|
|
547
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
664
|
-
const quotedValue = needsQuotes ? `"${value}"` : value;
|
|
732
|
+
const formattedLine = formatEnvLine(key, value, envPath);
|
|
665
733
|
if (hasContent) {
|
|
666
|
-
newLines.push(
|
|
734
|
+
newLines.push(formattedLine);
|
|
667
735
|
}
|
|
668
736
|
else {
|
|
669
|
-
newLines.push(
|
|
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.
|
|
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": {
|