lsh-framework 1.0.0 → 1.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/README.md +36 -0
- package/dist/services/secrets/secrets.js +210 -38
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -179,6 +179,38 @@ lsh pull --env staging # for testing
|
|
|
179
179
|
lsh pull --env prod # for production debugging
|
|
180
180
|
```
|
|
181
181
|
|
|
182
|
+
### 📝 Batch Upsert Secrets
|
|
183
|
+
|
|
184
|
+
**New in v1.1.0:** Pipe environment variables directly into your `.env` file!
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
# Copy all current environment variables
|
|
188
|
+
printenv | lsh set
|
|
189
|
+
|
|
190
|
+
# Import from another .env file
|
|
191
|
+
cat .env.backup | lsh set
|
|
192
|
+
|
|
193
|
+
# Import specific variables
|
|
194
|
+
printenv | grep "^AWS_" | lsh set
|
|
195
|
+
|
|
196
|
+
# Merge multiple sources
|
|
197
|
+
cat .env.base .env.local | lsh set
|
|
198
|
+
|
|
199
|
+
# From file with --stdin flag
|
|
200
|
+
lsh set --stdin < .env.production
|
|
201
|
+
|
|
202
|
+
# Single key-value still works
|
|
203
|
+
lsh set API_KEY sk_live_12345
|
|
204
|
+
lsh set DATABASE_URL postgres://localhost/db
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
**Features:**
|
|
208
|
+
- ✅ Automatic upsert (updates existing, adds new)
|
|
209
|
+
- ✅ Preserves comments and formatting
|
|
210
|
+
- ✅ Handles quoted values
|
|
211
|
+
- ✅ Validates key names
|
|
212
|
+
- ✅ Shows summary of changes
|
|
213
|
+
|
|
182
214
|
## Secrets Commands
|
|
183
215
|
|
|
184
216
|
| Command | Description |
|
|
@@ -192,6 +224,10 @@ lsh pull --env prod # for production debugging
|
|
|
192
224
|
| `lsh delete` | Delete .env file (with confirmation) |
|
|
193
225
|
| `lsh sync` | Smart sync (auto-setup and sync) |
|
|
194
226
|
| `lsh status` | Get detailed secrets status |
|
|
227
|
+
| `lsh get <key>` | Get a specific secret value |
|
|
228
|
+
| `lsh set <key> <value>` | Set a single secret value |
|
|
229
|
+
| `printenv \| lsh set` | Batch upsert from stdin (pipe) |
|
|
230
|
+
| `lsh set --stdin < file` | Batch upsert from file |
|
|
195
231
|
|
|
196
232
|
See the complete guide: [SECRETS_GUIDE.md](docs/features/secrets/SECRETS_GUIDE.md)
|
|
197
233
|
|
|
@@ -351,52 +351,31 @@ API_KEY=
|
|
|
351
351
|
process.exit(1);
|
|
352
352
|
}
|
|
353
353
|
});
|
|
354
|
-
// Set a specific secret value
|
|
354
|
+
// Set a specific secret value or batch upsert from stdin
|
|
355
355
|
program
|
|
356
|
-
.command('set
|
|
357
|
-
.description('Set a specific secret value in .env file')
|
|
356
|
+
.command('set [key] [value]')
|
|
357
|
+
.description('Set a specific secret value in .env file, or batch upsert from stdin (KEY=VALUE format)')
|
|
358
358
|
.option('-f, --file <path>', 'Path to .env file', '.env')
|
|
359
|
+
.option('--stdin', 'Read KEY=VALUE pairs from stdin (one per line)')
|
|
359
360
|
.action(async (key, value, options) => {
|
|
360
361
|
try {
|
|
361
362
|
const envPath = path.resolve(options.file);
|
|
362
|
-
//
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
363
|
+
// Check if we should read from stdin
|
|
364
|
+
const isStdin = options.stdin || (!key && !value);
|
|
365
|
+
if (isStdin) {
|
|
366
|
+
// Batch mode: read from stdin
|
|
367
|
+
await batchSetSecrets(envPath);
|
|
366
368
|
}
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
if (line.trim().startsWith('#') || !line.trim()) {
|
|
375
|
-
newLines.push(line);
|
|
376
|
-
continue;
|
|
377
|
-
}
|
|
378
|
-
const match = line.match(/^([^=]+)=(.*)$/);
|
|
379
|
-
if (match && match[1].trim() === key) {
|
|
380
|
-
// Quote values with spaces or special characters
|
|
381
|
-
const needsQuotes = /[\s#]/.test(value);
|
|
382
|
-
const quotedValue = needsQuotes ? `"${value}"` : value;
|
|
383
|
-
newLines.push(`${key}=${quotedValue}`);
|
|
384
|
-
found = true;
|
|
385
|
-
}
|
|
386
|
-
else {
|
|
387
|
-
newLines.push(line);
|
|
388
|
-
}
|
|
369
|
+
else {
|
|
370
|
+
// Single mode: set one key-value pair
|
|
371
|
+
if (!key || value === undefined) {
|
|
372
|
+
console.error('❌ Usage: lsh set <key> <value>');
|
|
373
|
+
console.error(' Or pipe input: printenv | lsh set');
|
|
374
|
+
console.error(' Or use stdin: lsh set --stdin < file.env');
|
|
375
|
+
process.exit(1);
|
|
389
376
|
}
|
|
390
|
-
|
|
391
|
-
}
|
|
392
|
-
// If key wasn't found, append it
|
|
393
|
-
if (!found) {
|
|
394
|
-
const needsQuotes = /[\s#]/.test(value);
|
|
395
|
-
const quotedValue = needsQuotes ? `"${value}"` : value;
|
|
396
|
-
content = content.trimRight() + `\n${key}=${quotedValue}\n`;
|
|
377
|
+
await setSingleSecret(envPath, key, value);
|
|
397
378
|
}
|
|
398
|
-
fs.writeFileSync(envPath, content, 'utf8');
|
|
399
|
-
console.log(`✅ Set ${key} in ${options.file}`);
|
|
400
379
|
}
|
|
401
380
|
catch (error) {
|
|
402
381
|
const err = error;
|
|
@@ -404,6 +383,199 @@ API_KEY=
|
|
|
404
383
|
process.exit(1);
|
|
405
384
|
}
|
|
406
385
|
});
|
|
386
|
+
/**
|
|
387
|
+
* Set a single secret value
|
|
388
|
+
*/
|
|
389
|
+
async function setSingleSecret(envPath, key, value) {
|
|
390
|
+
// Validate key format
|
|
391
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
|
|
392
|
+
console.error(`❌ Invalid key format: ${key}. Must be a valid environment variable name.`);
|
|
393
|
+
process.exit(1);
|
|
394
|
+
}
|
|
395
|
+
let content = '';
|
|
396
|
+
let found = false;
|
|
397
|
+
if (fs.existsSync(envPath)) {
|
|
398
|
+
content = fs.readFileSync(envPath, 'utf8');
|
|
399
|
+
const lines = content.split('\n');
|
|
400
|
+
const newLines = [];
|
|
401
|
+
for (const line of lines) {
|
|
402
|
+
if (line.trim().startsWith('#') || !line.trim()) {
|
|
403
|
+
newLines.push(line);
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
const match = line.match(/^([^=]+)=(.*)$/);
|
|
407
|
+
if (match && match[1].trim() === key) {
|
|
408
|
+
// Quote values with spaces or special characters
|
|
409
|
+
const needsQuotes = /[\s#]/.test(value);
|
|
410
|
+
const quotedValue = needsQuotes ? `"${value}"` : value;
|
|
411
|
+
newLines.push(`${key}=${quotedValue}`);
|
|
412
|
+
found = true;
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
newLines.push(line);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
content = newLines.join('\n');
|
|
419
|
+
}
|
|
420
|
+
// If key wasn't found, append it
|
|
421
|
+
if (!found) {
|
|
422
|
+
const needsQuotes = /[\s#]/.test(value);
|
|
423
|
+
const quotedValue = needsQuotes ? `"${value}"` : value;
|
|
424
|
+
content = content.trimRight() + `\n${key}=${quotedValue}\n`;
|
|
425
|
+
}
|
|
426
|
+
fs.writeFileSync(envPath, content, 'utf8');
|
|
427
|
+
console.log(`✅ Set ${key}`);
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Batch upsert secrets from stdin
|
|
431
|
+
*/
|
|
432
|
+
async function batchSetSecrets(envPath) {
|
|
433
|
+
return new Promise((resolve, reject) => {
|
|
434
|
+
let inputData = '';
|
|
435
|
+
const stdin = process.stdin;
|
|
436
|
+
// Check if stdin is a TTY (interactive terminal)
|
|
437
|
+
if (stdin.isTTY) {
|
|
438
|
+
console.error('❌ No input provided. Please pipe data or use --stdin flag.');
|
|
439
|
+
console.error('');
|
|
440
|
+
console.error('Examples:');
|
|
441
|
+
console.error(' printenv | lsh set');
|
|
442
|
+
console.error(' lsh set --stdin < .env.backup');
|
|
443
|
+
console.error(' echo "API_KEY=secret123" | lsh set');
|
|
444
|
+
process.exit(1);
|
|
445
|
+
}
|
|
446
|
+
stdin.setEncoding('utf8');
|
|
447
|
+
stdin.on('data', (chunk) => {
|
|
448
|
+
inputData += chunk;
|
|
449
|
+
});
|
|
450
|
+
stdin.on('end', () => {
|
|
451
|
+
try {
|
|
452
|
+
const lines = inputData.split('\n').filter(line => line.trim());
|
|
453
|
+
if (lines.length === 0) {
|
|
454
|
+
console.error('❌ No valid KEY=VALUE pairs found in input');
|
|
455
|
+
process.exit(1);
|
|
456
|
+
}
|
|
457
|
+
// Read existing .env file
|
|
458
|
+
let content = '';
|
|
459
|
+
const existingKeys = new Map();
|
|
460
|
+
if (fs.existsSync(envPath)) {
|
|
461
|
+
content = fs.readFileSync(envPath, 'utf8');
|
|
462
|
+
const existingLines = content.split('\n');
|
|
463
|
+
for (const line of existingLines) {
|
|
464
|
+
if (line.trim().startsWith('#') || !line.trim())
|
|
465
|
+
continue;
|
|
466
|
+
const match = line.match(/^([^=]+)=(.*)$/);
|
|
467
|
+
if (match) {
|
|
468
|
+
existingKeys.set(match[1].trim(), line);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
const updates = [];
|
|
473
|
+
const errors = [];
|
|
474
|
+
const newKeys = new Map();
|
|
475
|
+
// Parse input lines
|
|
476
|
+
for (const line of lines) {
|
|
477
|
+
const trimmed = line.trim();
|
|
478
|
+
// Skip comments and empty lines
|
|
479
|
+
if (trimmed.startsWith('#') || !trimmed)
|
|
480
|
+
continue;
|
|
481
|
+
// Parse KEY=VALUE format
|
|
482
|
+
const match = trimmed.match(/^([^=]+)=(.*)$/);
|
|
483
|
+
if (!match) {
|
|
484
|
+
errors.push(`Invalid format: ${trimmed}`);
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
const key = match[1].trim();
|
|
488
|
+
let value = match[2].trim();
|
|
489
|
+
// Validate key format
|
|
490
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
|
|
491
|
+
errors.push(`Invalid key format: ${key}`);
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
// Remove surrounding quotes if present
|
|
495
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
496
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
497
|
+
value = value.slice(1, -1);
|
|
498
|
+
}
|
|
499
|
+
// Track if this is an update or addition
|
|
500
|
+
const action = existingKeys.has(key) ? 'updated' : 'added';
|
|
501
|
+
updates.push({ key, value, action });
|
|
502
|
+
newKeys.set(key, value);
|
|
503
|
+
}
|
|
504
|
+
// Build new content
|
|
505
|
+
const newLines = [];
|
|
506
|
+
let hasContent = false;
|
|
507
|
+
if (fs.existsSync(envPath)) {
|
|
508
|
+
const existingLines = content.split('\n');
|
|
509
|
+
for (const line of existingLines) {
|
|
510
|
+
if (line.trim().startsWith('#') || !line.trim()) {
|
|
511
|
+
newLines.push(line);
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
const match = line.match(/^([^=]+)=(.*)$/);
|
|
515
|
+
if (match) {
|
|
516
|
+
const key = match[1].trim();
|
|
517
|
+
if (newKeys.has(key)) {
|
|
518
|
+
// Update existing key
|
|
519
|
+
const value = newKeys.get(key);
|
|
520
|
+
const needsQuotes = /[\s#]/.test(value);
|
|
521
|
+
const quotedValue = needsQuotes ? `"${value}"` : value;
|
|
522
|
+
newLines.push(`${key}=${quotedValue}`);
|
|
523
|
+
newKeys.delete(key); // Mark as processed
|
|
524
|
+
hasContent = true;
|
|
525
|
+
}
|
|
526
|
+
else {
|
|
527
|
+
// Keep existing line
|
|
528
|
+
newLines.push(line);
|
|
529
|
+
hasContent = true;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
else {
|
|
533
|
+
newLines.push(line);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
// Add new keys that weren't in the existing file
|
|
538
|
+
for (const [key, value] of newKeys.entries()) {
|
|
539
|
+
const needsQuotes = /[\s#]/.test(value);
|
|
540
|
+
const quotedValue = needsQuotes ? `"${value}"` : value;
|
|
541
|
+
if (hasContent) {
|
|
542
|
+
newLines.push(`${key}=${quotedValue}`);
|
|
543
|
+
}
|
|
544
|
+
else {
|
|
545
|
+
newLines.push(`${key}=${quotedValue}`);
|
|
546
|
+
hasContent = true;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
// Write updated content
|
|
550
|
+
let finalContent = newLines.join('\n');
|
|
551
|
+
if (hasContent && !finalContent.endsWith('\n')) {
|
|
552
|
+
finalContent += '\n';
|
|
553
|
+
}
|
|
554
|
+
fs.writeFileSync(envPath, finalContent, 'utf8');
|
|
555
|
+
// Report results
|
|
556
|
+
const added = updates.filter(u => u.action === 'added').length;
|
|
557
|
+
const updated = updates.filter(u => u.action === 'updated').length;
|
|
558
|
+
console.log(`✅ Batch upsert complete:`);
|
|
559
|
+
if (added > 0)
|
|
560
|
+
console.log(` Added: ${added} key(s)`);
|
|
561
|
+
if (updated > 0)
|
|
562
|
+
console.log(` Updated: ${updated} key(s)`);
|
|
563
|
+
if (errors.length > 0) {
|
|
564
|
+
console.log('');
|
|
565
|
+
console.log('⚠️ Skipped invalid entries:');
|
|
566
|
+
errors.forEach(err => console.log(` ${err}`));
|
|
567
|
+
}
|
|
568
|
+
resolve();
|
|
569
|
+
}
|
|
570
|
+
catch (error) {
|
|
571
|
+
reject(error);
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
stdin.on('error', (error) => {
|
|
575
|
+
reject(error);
|
|
576
|
+
});
|
|
577
|
+
});
|
|
578
|
+
}
|
|
407
579
|
// Delete .env file with confirmation
|
|
408
580
|
program
|
|
409
581
|
.command('delete')
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lsh-framework",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Encrypted secrets manager with automatic rotation, team sync, and multi-environment support. Built on a powerful shell with daemon scheduling and CI/CD integration.",
|
|
5
5
|
"main": "dist/app.js",
|
|
6
6
|
"bin": {
|