lsh-framework 1.0.0 → 1.2.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 CHANGED
@@ -104,6 +104,34 @@ lsh sync # Stored as: app1_dev
104
104
 
105
105
  cd ~/repos/app2
106
106
  lsh sync # Stored as: app2_dev (separate!)
107
+
108
+ # See what's tracked in the current directory
109
+ lsh info
110
+ ```
111
+
112
+ **Example `lsh info` output:**
113
+ ```
114
+ šŸ“ Current Directory Context
115
+
116
+ šŸ“ Git Repository:
117
+ Name: myapp
118
+ Branch: main
119
+
120
+ šŸ” Environment Tracking:
121
+ Base environment: dev
122
+ Cloud storage name: myapp_dev
123
+ Namespace: myapp
124
+ ā„¹ļø Repo-based isolation enabled
125
+
126
+ šŸ“„ Local .env File:
127
+ Keys: 12
128
+ Has encryption key: āœ…
129
+
130
+ ā˜ļø Cloud Storage:
131
+ Environment: myapp_dev
132
+ Keys stored: 12
133
+ Last updated: 11/6/2025, 10:15:23 PM
134
+ Key matches: āœ…
107
135
  ```
108
136
 
109
137
  No more conflicts between projects using the same environment names!
@@ -179,6 +207,38 @@ lsh pull --env staging # for testing
179
207
  lsh pull --env prod # for production debugging
180
208
  ```
181
209
 
210
+ ### šŸ“ Batch Upsert Secrets
211
+
212
+ **New in v1.1.0:** Pipe environment variables directly into your `.env` file!
213
+
214
+ ```bash
215
+ # Copy all current environment variables
216
+ printenv | lsh set
217
+
218
+ # Import from another .env file
219
+ cat .env.backup | lsh set
220
+
221
+ # Import specific variables
222
+ printenv | grep "^AWS_" | lsh set
223
+
224
+ # Merge multiple sources
225
+ cat .env.base .env.local | lsh set
226
+
227
+ # From file with --stdin flag
228
+ lsh set --stdin < .env.production
229
+
230
+ # Single key-value still works
231
+ lsh set API_KEY sk_live_12345
232
+ lsh set DATABASE_URL postgres://localhost/db
233
+ ```
234
+
235
+ **Features:**
236
+ - āœ… Automatic upsert (updates existing, adds new)
237
+ - āœ… Preserves comments and formatting
238
+ - āœ… Handles quoted values
239
+ - āœ… Validates key names
240
+ - āœ… Shows summary of changes
241
+
182
242
  ## Secrets Commands
183
243
 
184
244
  | Command | Description |
@@ -191,7 +251,12 @@ lsh pull --env prod # for production debugging
191
251
  | `lsh create` | Create new .env file |
192
252
  | `lsh delete` | Delete .env file (with confirmation) |
193
253
  | `lsh sync` | Smart sync (auto-setup and sync) |
194
- | `lsh status` | Get detailed secrets status |
254
+ | `lsh status` | Get detailed secrets status (JSON) |
255
+ | `lsh info` | Show current context and tracked environment |
256
+ | `lsh get <key>` | Get a specific secret value |
257
+ | `lsh set <key> <value>` | Set a single secret value |
258
+ | `printenv \| lsh set` | Batch upsert from stdin (pipe) |
259
+ | `lsh set --stdin < file` | Batch upsert from file |
195
260
 
196
261
  See the complete guide: [SECRETS_GUIDE.md](docs/features/secrets/SECRETS_GUIDE.md)
197
262
 
@@ -6,6 +6,7 @@ import SecretsManager from '../../lib/secrets-manager.js';
6
6
  import * as fs from 'fs';
7
7
  import * as path from 'path';
8
8
  import * as readline from 'readline';
9
+ import { getGitRepoInfo } from '../../lib/git-utils.js';
9
10
  export async function init_secrets(program) {
10
11
  // Push secrets to cloud
11
12
  program
@@ -272,6 +273,105 @@ API_KEY=
272
273
  process.exit(1);
273
274
  }
274
275
  });
276
+ // Info command - show relevant context information
277
+ program
278
+ .command('info')
279
+ .description('Show current directory context and tracked environment')
280
+ .option('-f, --file <path>', 'Path to .env file', '.env')
281
+ .option('-e, --env <name>', 'Environment name', 'dev')
282
+ .action(async (options) => {
283
+ try {
284
+ const gitInfo = getGitRepoInfo();
285
+ const manager = new SecretsManager();
286
+ const envPath = path.resolve(options.file);
287
+ console.log('\nšŸ“ Current Directory Context\n');
288
+ // Git Repository Info
289
+ if (gitInfo.isGitRepo) {
290
+ console.log('šŸ“ Git Repository:');
291
+ console.log(` Root: ${gitInfo.rootPath || 'unknown'}`);
292
+ console.log(` Name: ${gitInfo.repoName || 'unknown'}`);
293
+ if (gitInfo.currentBranch) {
294
+ console.log(` Branch: ${gitInfo.currentBranch}`);
295
+ }
296
+ if (gitInfo.remoteUrl) {
297
+ console.log(` Remote: ${gitInfo.remoteUrl}`);
298
+ }
299
+ }
300
+ else {
301
+ console.log('šŸ“ Not in a git repository');
302
+ }
303
+ console.log('');
304
+ // Environment Tracking
305
+ console.log('šŸ” Environment Tracking:');
306
+ // Show the effective environment name used for cloud storage
307
+ const effectiveEnv = gitInfo.repoName
308
+ ? `${gitInfo.repoName}_${options.env}`
309
+ : options.env;
310
+ console.log(` Base environment: ${options.env}`);
311
+ console.log(` Cloud storage name: ${effectiveEnv}`);
312
+ if (gitInfo.repoName) {
313
+ console.log(` Namespace: ${gitInfo.repoName}`);
314
+ console.log(' ā„¹ļø Repo-based isolation enabled');
315
+ }
316
+ else {
317
+ console.log(' Namespace: (none - not in git repo)');
318
+ console.log(' āš ļø No isolation - shared environment name');
319
+ }
320
+ console.log('');
321
+ // Local File Status
322
+ console.log('šŸ“„ Local .env File:');
323
+ if (fs.existsSync(envPath)) {
324
+ const content = fs.readFileSync(envPath, 'utf8');
325
+ const lines = content.split('\n').filter(line => {
326
+ const trimmed = line.trim();
327
+ return trimmed && !trimmed.startsWith('#') && trimmed.includes('=');
328
+ });
329
+ console.log(` Path: ${envPath}`);
330
+ console.log(` Keys: ${lines.length}`);
331
+ console.log(` Size: ${Math.round(content.length / 1024 * 10) / 10} KB`);
332
+ // Check for encryption key
333
+ const hasKey = content.includes('LSH_SECRETS_KEY=');
334
+ console.log(` Has encryption key: ${hasKey ? 'āœ…' : 'āŒ'}`);
335
+ }
336
+ else {
337
+ console.log(` Path: ${envPath}`);
338
+ console.log(' Status: āŒ Not found');
339
+ }
340
+ console.log('');
341
+ // Cloud Status
342
+ console.log('ā˜ļø Cloud Storage:');
343
+ try {
344
+ const status = await manager.status(options.file, options.env);
345
+ if (status.cloudExists) {
346
+ console.log(` Environment: ${effectiveEnv}`);
347
+ console.log(` Keys stored: ${status.cloudKeys}`);
348
+ console.log(` Last updated: ${status.cloudModified ? new Date(status.cloudModified).toLocaleString() : 'unknown'}`);
349
+ if (status.keyMatches !== undefined) {
350
+ console.log(` Key matches: ${status.keyMatches ? 'āœ…' : 'āŒ MISMATCH'}`);
351
+ }
352
+ }
353
+ else {
354
+ console.log(` Environment: ${effectiveEnv}`);
355
+ console.log(' Status: āŒ Not synced yet');
356
+ }
357
+ }
358
+ catch (_error) {
359
+ console.log(' Status: āš ļø Unable to check (Supabase not configured)');
360
+ }
361
+ console.log('');
362
+ // Quick Actions
363
+ console.log('šŸ’” Quick Actions:');
364
+ console.log(` Push: lsh push --env ${options.env}`);
365
+ console.log(` Pull: lsh pull --env ${options.env}`);
366
+ console.log(` Sync: lsh sync --env ${options.env}`);
367
+ console.log('');
368
+ }
369
+ catch (error) {
370
+ const err = error;
371
+ console.error('āŒ Failed to get info:', err.message);
372
+ process.exit(1);
373
+ }
374
+ });
275
375
  // Get a specific secret value
276
376
  program
277
377
  .command('get [key]')
@@ -351,52 +451,31 @@ API_KEY=
351
451
  process.exit(1);
352
452
  }
353
453
  });
354
- // Set a specific secret value
454
+ // Set a specific secret value or batch upsert from stdin
355
455
  program
356
- .command('set <key> <value>')
357
- .description('Set a specific secret value in .env file')
456
+ .command('set [key] [value]')
457
+ .description('Set a specific secret value in .env file, or batch upsert from stdin (KEY=VALUE format)')
358
458
  .option('-f, --file <path>', 'Path to .env file', '.env')
459
+ .option('--stdin', 'Read KEY=VALUE pairs from stdin (one per line)')
359
460
  .action(async (key, value, options) => {
360
461
  try {
361
462
  const envPath = path.resolve(options.file);
362
- // Validate key format
363
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
364
- console.error(`āŒ Invalid key format: ${key}. Must be a valid environment variable name.`);
365
- process.exit(1);
463
+ // Check if we should read from stdin
464
+ const isStdin = options.stdin || (!key && !value);
465
+ if (isStdin) {
466
+ // Batch mode: read from stdin
467
+ await batchSetSecrets(envPath);
366
468
  }
367
- let content = '';
368
- let found = false;
369
- if (fs.existsSync(envPath)) {
370
- content = fs.readFileSync(envPath, 'utf8');
371
- const lines = content.split('\n');
372
- const newLines = [];
373
- for (const line of lines) {
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
- }
469
+ else {
470
+ // Single mode: set one key-value pair
471
+ if (!key || value === undefined) {
472
+ console.error('āŒ Usage: lsh set <key> <value>');
473
+ console.error(' Or pipe input: printenv | lsh set');
474
+ console.error(' Or use stdin: lsh set --stdin < file.env');
475
+ process.exit(1);
389
476
  }
390
- content = newLines.join('\n');
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`;
477
+ await setSingleSecret(envPath, key, value);
397
478
  }
398
- fs.writeFileSync(envPath, content, 'utf8');
399
- console.log(`āœ… Set ${key} in ${options.file}`);
400
479
  }
401
480
  catch (error) {
402
481
  const err = error;
@@ -404,6 +483,199 @@ API_KEY=
404
483
  process.exit(1);
405
484
  }
406
485
  });
486
+ /**
487
+ * Set a single secret value
488
+ */
489
+ async function setSingleSecret(envPath, key, value) {
490
+ // Validate key format
491
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
492
+ console.error(`āŒ Invalid key format: ${key}. Must be a valid environment variable name.`);
493
+ process.exit(1);
494
+ }
495
+ let content = '';
496
+ let found = false;
497
+ if (fs.existsSync(envPath)) {
498
+ content = fs.readFileSync(envPath, 'utf8');
499
+ const lines = content.split('\n');
500
+ const newLines = [];
501
+ for (const line of lines) {
502
+ if (line.trim().startsWith('#') || !line.trim()) {
503
+ newLines.push(line);
504
+ continue;
505
+ }
506
+ const match = line.match(/^([^=]+)=(.*)$/);
507
+ if (match && match[1].trim() === key) {
508
+ // Quote values with spaces or special characters
509
+ const needsQuotes = /[\s#]/.test(value);
510
+ const quotedValue = needsQuotes ? `"${value}"` : value;
511
+ newLines.push(`${key}=${quotedValue}`);
512
+ found = true;
513
+ }
514
+ else {
515
+ newLines.push(line);
516
+ }
517
+ }
518
+ content = newLines.join('\n');
519
+ }
520
+ // If key wasn't found, append it
521
+ if (!found) {
522
+ const needsQuotes = /[\s#]/.test(value);
523
+ const quotedValue = needsQuotes ? `"${value}"` : value;
524
+ content = content.trimRight() + `\n${key}=${quotedValue}\n`;
525
+ }
526
+ fs.writeFileSync(envPath, content, 'utf8');
527
+ console.log(`āœ… Set ${key}`);
528
+ }
529
+ /**
530
+ * Batch upsert secrets from stdin
531
+ */
532
+ async function batchSetSecrets(envPath) {
533
+ return new Promise((resolve, reject) => {
534
+ let inputData = '';
535
+ const stdin = process.stdin;
536
+ // Check if stdin is a TTY (interactive terminal)
537
+ if (stdin.isTTY) {
538
+ console.error('āŒ No input provided. Please pipe data or use --stdin flag.');
539
+ console.error('');
540
+ console.error('Examples:');
541
+ console.error(' printenv | lsh set');
542
+ console.error(' lsh set --stdin < .env.backup');
543
+ console.error(' echo "API_KEY=secret123" | lsh set');
544
+ process.exit(1);
545
+ }
546
+ stdin.setEncoding('utf8');
547
+ stdin.on('data', (chunk) => {
548
+ inputData += chunk;
549
+ });
550
+ stdin.on('end', () => {
551
+ try {
552
+ const lines = inputData.split('\n').filter(line => line.trim());
553
+ if (lines.length === 0) {
554
+ console.error('āŒ No valid KEY=VALUE pairs found in input');
555
+ process.exit(1);
556
+ }
557
+ // Read existing .env file
558
+ let content = '';
559
+ const existingKeys = new Map();
560
+ if (fs.existsSync(envPath)) {
561
+ content = fs.readFileSync(envPath, 'utf8');
562
+ const existingLines = content.split('\n');
563
+ for (const line of existingLines) {
564
+ if (line.trim().startsWith('#') || !line.trim())
565
+ continue;
566
+ const match = line.match(/^([^=]+)=(.*)$/);
567
+ if (match) {
568
+ existingKeys.set(match[1].trim(), line);
569
+ }
570
+ }
571
+ }
572
+ const updates = [];
573
+ const errors = [];
574
+ const newKeys = new Map();
575
+ // Parse input lines
576
+ for (const line of lines) {
577
+ const trimmed = line.trim();
578
+ // Skip comments and empty lines
579
+ if (trimmed.startsWith('#') || !trimmed)
580
+ continue;
581
+ // Parse KEY=VALUE format
582
+ const match = trimmed.match(/^([^=]+)=(.*)$/);
583
+ if (!match) {
584
+ errors.push(`Invalid format: ${trimmed}`);
585
+ continue;
586
+ }
587
+ const key = match[1].trim();
588
+ let value = match[2].trim();
589
+ // Validate key format
590
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
591
+ errors.push(`Invalid key format: ${key}`);
592
+ continue;
593
+ }
594
+ // Remove surrounding quotes if present
595
+ if ((value.startsWith('"') && value.endsWith('"')) ||
596
+ (value.startsWith("'") && value.endsWith("'"))) {
597
+ value = value.slice(1, -1);
598
+ }
599
+ // Track if this is an update or addition
600
+ const action = existingKeys.has(key) ? 'updated' : 'added';
601
+ updates.push({ key, value, action });
602
+ newKeys.set(key, value);
603
+ }
604
+ // Build new content
605
+ const newLines = [];
606
+ let hasContent = false;
607
+ if (fs.existsSync(envPath)) {
608
+ const existingLines = content.split('\n');
609
+ for (const line of existingLines) {
610
+ if (line.trim().startsWith('#') || !line.trim()) {
611
+ newLines.push(line);
612
+ continue;
613
+ }
614
+ const match = line.match(/^([^=]+)=(.*)$/);
615
+ if (match) {
616
+ const key = match[1].trim();
617
+ if (newKeys.has(key)) {
618
+ // Update existing key
619
+ const value = newKeys.get(key);
620
+ const needsQuotes = /[\s#]/.test(value);
621
+ const quotedValue = needsQuotes ? `"${value}"` : value;
622
+ newLines.push(`${key}=${quotedValue}`);
623
+ newKeys.delete(key); // Mark as processed
624
+ hasContent = true;
625
+ }
626
+ else {
627
+ // Keep existing line
628
+ newLines.push(line);
629
+ hasContent = true;
630
+ }
631
+ }
632
+ else {
633
+ newLines.push(line);
634
+ }
635
+ }
636
+ }
637
+ // Add new keys that weren't in the existing file
638
+ for (const [key, value] of newKeys.entries()) {
639
+ const needsQuotes = /[\s#]/.test(value);
640
+ const quotedValue = needsQuotes ? `"${value}"` : value;
641
+ if (hasContent) {
642
+ newLines.push(`${key}=${quotedValue}`);
643
+ }
644
+ else {
645
+ newLines.push(`${key}=${quotedValue}`);
646
+ hasContent = true;
647
+ }
648
+ }
649
+ // Write updated content
650
+ let finalContent = newLines.join('\n');
651
+ if (hasContent && !finalContent.endsWith('\n')) {
652
+ finalContent += '\n';
653
+ }
654
+ fs.writeFileSync(envPath, finalContent, 'utf8');
655
+ // Report results
656
+ const added = updates.filter(u => u.action === 'added').length;
657
+ const updated = updates.filter(u => u.action === 'updated').length;
658
+ console.log(`āœ… Batch upsert complete:`);
659
+ if (added > 0)
660
+ console.log(` Added: ${added} key(s)`);
661
+ if (updated > 0)
662
+ console.log(` Updated: ${updated} key(s)`);
663
+ if (errors.length > 0) {
664
+ console.log('');
665
+ console.log('āš ļø Skipped invalid entries:');
666
+ errors.forEach(err => console.log(` ${err}`));
667
+ }
668
+ resolve();
669
+ }
670
+ catch (error) {
671
+ reject(error);
672
+ }
673
+ });
674
+ stdin.on('error', (error) => {
675
+ reject(error);
676
+ });
677
+ });
678
+ }
407
679
  // Delete .env file with confirmation
408
680
  program
409
681
  .command('delete')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lsh-framework",
3
- "version": "1.0.0",
3
+ "version": "1.2.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": {