lsh-framework 1.2.1 → 1.3.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/lib/fuzzy-match.js +123 -0
- package/dist/services/secrets/secrets.js +65 -29
- 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) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lsh-framework",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
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": {
|