lsh-framework 0.8.0 → 0.8.2

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
@@ -126,7 +126,7 @@ lsh lib secrets pull --env prod # for production debugging
126
126
  | `lsh lib secrets create` | Create new .env file |
127
127
  | `lsh lib secrets delete` | Delete .env file (with confirmation) |
128
128
 
129
- See the complete guide: [SECRETS_GUIDE.md](SECRETS_GUIDE.md)
129
+ See the complete guide: [SECRETS_GUIDE.md](docs/features/secrets/SECRETS_GUIDE.md)
130
130
 
131
131
  ## Installation
132
132
 
@@ -536,10 +536,10 @@ lsh lib daemon start
536
536
 
537
537
  ## Documentation
538
538
 
539
- - **[SECRETS_GUIDE.md](SECRETS_GUIDE.md)** - Complete secrets management guide
540
- - **[SECRETS_QUICK_REFERENCE.md](SECRETS_QUICK_REFERENCE.md)** - Quick reference for daily use
539
+ - **[SECRETS_GUIDE.md](docs/features/secrets/SECRETS_GUIDE.md)** - Complete secrets management guide
540
+ - **[SECRETS_QUICK_REFERENCE.md](docs/features/secrets/SECRETS_QUICK_REFERENCE.md)** - Quick reference for daily use
541
541
  - **[SECRETS_CHEATSHEET.txt](SECRETS_CHEATSHEET.txt)** - Command cheatsheet
542
- - **[INSTALL.md](INSTALL.md)** - Detailed installation instructions
542
+ - **[INSTALL.md](docs/deployment/INSTALL.md)** - Detailed installation instructions
543
543
  - **[CLAUDE.md](CLAUDE.md)** - Developer guide for contributors
544
544
 
545
545
  ## Architecture
package/dist/cli.js CHANGED
File without changes
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Git Utilities
3
+ * Helper functions for git repository detection and information extraction
4
+ */
5
+ import * as fs from 'fs';
6
+ import * as path from 'path';
7
+ import { execSync } from 'child_process';
8
+ import { createLogger } from './logger.js';
9
+ const logger = createLogger('GitUtils');
10
+ /**
11
+ * Check if a directory is inside a git repository
12
+ */
13
+ export function isInGitRepo(dir = process.cwd()) {
14
+ try {
15
+ execSync('git rev-parse --is-inside-work-tree', {
16
+ cwd: dir,
17
+ stdio: 'pipe',
18
+ encoding: 'utf8',
19
+ });
20
+ return true;
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ }
26
+ /**
27
+ * Get git repository root path
28
+ */
29
+ export function getGitRootPath(dir = process.cwd()) {
30
+ try {
31
+ const output = execSync('git rev-parse --show-toplevel', {
32
+ cwd: dir,
33
+ stdio: 'pipe',
34
+ encoding: 'utf8',
35
+ });
36
+ return output.trim();
37
+ }
38
+ catch {
39
+ return undefined;
40
+ }
41
+ }
42
+ /**
43
+ * Get git remote URL
44
+ */
45
+ export function getGitRemoteUrl(dir = process.cwd()) {
46
+ try {
47
+ const output = execSync('git remote get-url origin', {
48
+ cwd: dir,
49
+ stdio: 'pipe',
50
+ encoding: 'utf8',
51
+ });
52
+ return output.trim();
53
+ }
54
+ catch {
55
+ return undefined;
56
+ }
57
+ }
58
+ /**
59
+ * Extract repository name from git remote URL or directory name
60
+ */
61
+ export function extractRepoName(remoteUrl, rootPath) {
62
+ if (remoteUrl) {
63
+ // Extract from URL patterns:
64
+ // git@github.com:user/repo.git -> repo
65
+ // https://github.com/user/repo.git -> repo
66
+ const match = remoteUrl.match(/[/:]([\w-]+?)(\.git)?$/);
67
+ if (match) {
68
+ return match[1];
69
+ }
70
+ }
71
+ if (rootPath) {
72
+ // Use directory name as fallback
73
+ return path.basename(rootPath);
74
+ }
75
+ return undefined;
76
+ }
77
+ /**
78
+ * Get current git branch
79
+ */
80
+ export function getCurrentBranch(dir = process.cwd()) {
81
+ try {
82
+ const output = execSync('git rev-parse --abbrev-ref HEAD', {
83
+ cwd: dir,
84
+ stdio: 'pipe',
85
+ encoding: 'utf8',
86
+ });
87
+ return output.trim();
88
+ }
89
+ catch {
90
+ return undefined;
91
+ }
92
+ }
93
+ /**
94
+ * Get comprehensive git repository information
95
+ */
96
+ export function getGitRepoInfo(dir = process.cwd()) {
97
+ const isGitRepo = isInGitRepo(dir);
98
+ if (!isGitRepo) {
99
+ return { isGitRepo: false };
100
+ }
101
+ const rootPath = getGitRootPath(dir);
102
+ const remoteUrl = getGitRemoteUrl(dir);
103
+ const repoName = extractRepoName(remoteUrl, rootPath);
104
+ const currentBranch = getCurrentBranch(dir);
105
+ return {
106
+ isGitRepo: true,
107
+ rootPath,
108
+ repoName,
109
+ remoteUrl,
110
+ currentBranch,
111
+ };
112
+ }
113
+ /**
114
+ * Check if .env.example exists in the repo
115
+ */
116
+ export function hasEnvExample(dir = process.cwd()) {
117
+ const patterns = ['.env.example', '.env.sample', '.env.template'];
118
+ for (const pattern of patterns) {
119
+ const filePath = path.join(dir, pattern);
120
+ if (fs.existsSync(filePath)) {
121
+ return filePath;
122
+ }
123
+ }
124
+ return undefined;
125
+ }
126
+ /**
127
+ * Check if .gitignore exists and contains .env
128
+ */
129
+ export function isEnvIgnored(dir = process.cwd()) {
130
+ const gitignorePath = path.join(dir, '.gitignore');
131
+ if (!fs.existsSync(gitignorePath)) {
132
+ return false;
133
+ }
134
+ try {
135
+ const content = fs.readFileSync(gitignorePath, 'utf8');
136
+ const lines = content.split('\n');
137
+ for (const line of lines) {
138
+ const trimmed = line.trim();
139
+ // Check for .env or *.env patterns
140
+ if (trimmed === '.env' || trimmed === '*.env' || trimmed.includes('.env')) {
141
+ return true;
142
+ }
143
+ }
144
+ return false;
145
+ }
146
+ catch (error) {
147
+ logger.warn(`Failed to read .gitignore: ${error.message}`);
148
+ return false;
149
+ }
150
+ }
151
+ /**
152
+ * Add .env to .gitignore if not already present
153
+ */
154
+ export function ensureEnvInGitignore(dir = process.cwd()) {
155
+ const gitignorePath = path.join(dir, '.gitignore');
156
+ if (isEnvIgnored(dir)) {
157
+ return; // Already ignored
158
+ }
159
+ try {
160
+ let content = '';
161
+ if (fs.existsSync(gitignorePath)) {
162
+ content = fs.readFileSync(gitignorePath, 'utf8');
163
+ // Ensure newline at end
164
+ if (!content.endsWith('\n')) {
165
+ content += '\n';
166
+ }
167
+ }
168
+ content += '\n# Environment variables (managed by LSH)\n.env\n.env.local\n.env.*.local\n';
169
+ fs.writeFileSync(gitignorePath, content, 'utf8');
170
+ logger.info('āœ… Added .env to .gitignore');
171
+ }
172
+ catch (error) {
173
+ logger.warn(`Failed to update .gitignore: ${error.message}`);
174
+ }
175
+ }
176
+ export default {
177
+ isInGitRepo,
178
+ getGitRootPath,
179
+ getGitRemoteUrl,
180
+ extractRepoName,
181
+ getCurrentBranch,
182
+ getGitRepoInfo,
183
+ hasEnvExample,
184
+ isEnvIgnored,
185
+ ensureEnvInGitignore,
186
+ };
@@ -7,14 +7,20 @@ import * as path from 'path';
7
7
  import * as crypto from 'crypto';
8
8
  import DatabasePersistence from './database-persistence.js';
9
9
  import { createLogger } from './logger.js';
10
+ import { getGitRepoInfo, hasEnvExample, ensureEnvInGitignore } from './git-utils.js';
10
11
  const logger = createLogger('SecretsManager');
11
12
  export class SecretsManager {
12
13
  persistence;
13
14
  encryptionKey;
14
- constructor(userId, encryptionKey) {
15
+ gitInfo;
16
+ constructor(userId, encryptionKey, detectGit = true) {
15
17
  this.persistence = new DatabasePersistence(userId);
16
18
  // Use provided key or generate from machine ID + user
17
19
  this.encryptionKey = encryptionKey || this.getDefaultEncryptionKey();
20
+ // Auto-detect git repo context
21
+ if (detectGit) {
22
+ this.gitInfo = getGitRepoInfo();
23
+ }
18
24
  }
19
25
  /**
20
26
  * Get default encryption key from environment or machine
@@ -117,6 +123,11 @@ export class SecretsManager {
117
123
  if (!fs.existsSync(envFilePath)) {
118
124
  throw new Error(`File not found: ${envFilePath}`);
119
125
  }
126
+ // Validate filename pattern for custom files
127
+ const filename = path.basename(envFilePath);
128
+ if (filename !== '.env' && !filename.startsWith('.env.')) {
129
+ throw new Error(`Invalid filename: ${filename}. Must be '.env' or start with '.env.'`);
130
+ }
120
131
  // Warn if using default key
121
132
  if (!process.env.LSH_SECRETS_KEY) {
122
133
  logger.warn('āš ļø Warning: No LSH_SECRETS_KEY set. Using machine-specific key.');
@@ -129,9 +140,10 @@ export class SecretsManager {
129
140
  const env = this.parseEnvFile(content);
130
141
  // Encrypt entire .env content
131
142
  const encrypted = this.encrypt(content);
132
- // Store in Supabase (using job system for now)
143
+ // Include filename in job_id for tracking multiple .env files
144
+ const safeFilename = filename.replace(/[^a-zA-Z0-9._-]/g, '_');
133
145
  const secretData = {
134
- job_id: `secrets_${environment}_${Date.now()}`,
146
+ job_id: `secrets_${environment}_${safeFilename}_${Date.now()}`,
135
147
  command: 'secrets_sync',
136
148
  status: 'completed',
137
149
  output: encrypted,
@@ -140,20 +152,31 @@ export class SecretsManager {
140
152
  working_directory: process.cwd(),
141
153
  };
142
154
  await this.persistence.saveJob(secretData);
143
- logger.info(`āœ… Pushed ${Object.keys(env).length} secrets to Supabase`);
155
+ logger.info(`āœ… Pushed ${Object.keys(env).length} secrets from ${filename} to Supabase`);
144
156
  }
145
157
  /**
146
158
  * Pull .env from Supabase
147
159
  */
148
160
  async pull(envFilePath = '.env', environment = 'dev', force = false) {
149
- logger.info(`Pulling ${environment} secrets from Supabase...`);
150
- // Get latest secrets
161
+ // Validate filename pattern for custom files
162
+ const filename = path.basename(envFilePath);
163
+ if (filename !== '.env' && !filename.startsWith('.env.')) {
164
+ throw new Error(`Invalid filename: ${filename}. Must be '.env' or start with '.env.'`);
165
+ }
166
+ logger.info(`Pulling ${filename} (${environment}) from Supabase...`);
167
+ // Get latest secrets for this specific file
151
168
  const jobs = await this.persistence.getActiveJobs();
169
+ const safeFilename = filename.replace(/[^a-zA-Z0-9._-]/g, '_');
152
170
  const secretsJobs = jobs
153
- .filter(j => j.command === 'secrets_sync' && j.job_id.includes(environment))
171
+ .filter(j => {
172
+ // Match secrets for this environment and filename
173
+ return j.command === 'secrets_sync' &&
174
+ j.job_id.includes(environment) &&
175
+ j.job_id.includes(safeFilename);
176
+ })
154
177
  .sort((a, b) => new Date(b.started_at).getTime() - new Date(a.started_at).getTime());
155
178
  if (secretsJobs.length === 0) {
156
- throw new Error(`No secrets found for environment: ${environment}`);
179
+ throw new Error(`No secrets found for file '${filename}' in environment: ${environment}`);
157
180
  }
158
181
  const latestSecret = secretsJobs[0];
159
182
  if (!latestSecret.output) {
@@ -179,13 +202,57 @@ export class SecretsManager {
179
202
  const secretsJobs = jobs.filter(j => j.command === 'secrets_sync');
180
203
  const envs = new Set();
181
204
  for (const job of secretsJobs) {
182
- const match = job.job_id.match(/secrets_(.+?)_\d+/);
205
+ // Updated regex to handle new format with filename
206
+ const match = job.job_id.match(/secrets_([^_]+)_/);
183
207
  if (match) {
184
208
  envs.add(match[1]);
185
209
  }
186
210
  }
187
211
  return Array.from(envs).sort();
188
212
  }
213
+ /**
214
+ * List all tracked .env files
215
+ */
216
+ async listAllFiles() {
217
+ const jobs = await this.persistence.getActiveJobs();
218
+ const secretsJobs = jobs.filter(j => j.command === 'secrets_sync');
219
+ // Group by environment and filename to get latest of each
220
+ const fileMap = new Map();
221
+ for (const job of secretsJobs) {
222
+ // Parse job_id: secrets_${environment}_${safeFilename}_${timestamp}
223
+ const parts = job.job_id.split('_');
224
+ if (parts.length >= 3 && parts[0] === 'secrets') {
225
+ const environment = parts[1];
226
+ // Handle both old and new format
227
+ let filename = '.env';
228
+ if (parts.length >= 4) {
229
+ // New format with filename
230
+ const timestamp = parts[parts.length - 1];
231
+ // Reconstruct filename from middle parts
232
+ const filenameParts = parts.slice(2, -1);
233
+ if (filenameParts.length > 0) {
234
+ // Convert underscores back to dots for the extension
235
+ filename = filenameParts.join('_');
236
+ // Fix the extension dots that were replaced
237
+ filename = filename.replace(/^env_/, '.env.');
238
+ if (filename === 'env') {
239
+ filename = '.env';
240
+ }
241
+ }
242
+ }
243
+ const key = `${environment}_${filename}`;
244
+ const existing = fileMap.get(key);
245
+ if (!existing || new Date(job.completed_at || job.started_at) > new Date(existing.updated)) {
246
+ fileMap.set(key, {
247
+ filename,
248
+ environment,
249
+ updated: new Date(job.completed_at || job.started_at).toLocaleString()
250
+ });
251
+ }
252
+ }
253
+ }
254
+ return Array.from(fileMap.values()).sort((a, b) => a.filename.localeCompare(b.filename) || a.environment.localeCompare(b.environment));
255
+ }
189
256
  /**
190
257
  * Show secrets (masked)
191
258
  */
@@ -267,7 +334,329 @@ export class SecretsManager {
267
334
  return status;
268
335
  }
269
336
  /**
270
- * Sync command - check status and suggest actions
337
+ * Get repo-aware environment namespace
338
+ * Returns environment name with repo context if in a git repo
339
+ */
340
+ getRepoAwareEnvironment(environment) {
341
+ if (this.gitInfo?.repoName) {
342
+ return `${this.gitInfo.repoName}_${environment}`;
343
+ }
344
+ return environment;
345
+ }
346
+ /**
347
+ * Generate encryption key if not set
348
+ */
349
+ async ensureEncryptionKey() {
350
+ if (process.env.LSH_SECRETS_KEY) {
351
+ return true; // Key already set
352
+ }
353
+ logger.warn('āš ļø No encryption key found. Generating a new key...');
354
+ const key = crypto.randomBytes(32).toString('hex');
355
+ // Try to add to .env file
356
+ const envPath = path.join(process.cwd(), '.env');
357
+ try {
358
+ let content = '';
359
+ if (fs.existsSync(envPath)) {
360
+ content = fs.readFileSync(envPath, 'utf8');
361
+ if (!content.endsWith('\n')) {
362
+ content += '\n';
363
+ }
364
+ }
365
+ // Check if LSH_SECRETS_KEY already exists (but empty)
366
+ if (content.includes('LSH_SECRETS_KEY=')) {
367
+ content = content.replace(/LSH_SECRETS_KEY=.*$/m, `LSH_SECRETS_KEY=${key}`);
368
+ }
369
+ else {
370
+ content += `\n# LSH Secrets Encryption Key (do not commit!)\nLSH_SECRETS_KEY=${key}\n`;
371
+ }
372
+ fs.writeFileSync(envPath, content, 'utf8');
373
+ // Set in current process
374
+ process.env.LSH_SECRETS_KEY = key;
375
+ this.encryptionKey = key;
376
+ logger.info('āœ… Generated and saved encryption key to .env');
377
+ logger.info('šŸ’” Load it now: export LSH_SECRETS_KEY=' + key.substring(0, 8) + '...');
378
+ return true;
379
+ }
380
+ catch (error) {
381
+ logger.error(`Failed to save encryption key: ${error.message}`);
382
+ logger.info('Please set it manually:');
383
+ logger.info(`export LSH_SECRETS_KEY=${key}`);
384
+ return false;
385
+ }
386
+ }
387
+ /**
388
+ * Create .env from .env.example if available
389
+ */
390
+ async createEnvFromExample(envFilePath) {
391
+ const examplePath = hasEnvExample(process.cwd());
392
+ if (!examplePath) {
393
+ // Create minimal template
394
+ const template = `# Environment Configuration
395
+ # Generated by LSH Secrets Manager
396
+
397
+ # Application
398
+ NODE_ENV=development
399
+
400
+ # Database
401
+ DATABASE_URL=
402
+
403
+ # API Keys
404
+ API_KEY=
405
+
406
+ # LSH Secrets Encryption Key (auto-generated)
407
+ LSH_SECRETS_KEY=${this.encryptionKey}
408
+
409
+ # Add your environment variables below
410
+ `;
411
+ try {
412
+ fs.writeFileSync(envFilePath, template, 'utf8');
413
+ logger.info(`āœ… Created ${envFilePath} from template`);
414
+ return true;
415
+ }
416
+ catch (error) {
417
+ logger.error(`Failed to create ${envFilePath}: ${error.message}`);
418
+ return false;
419
+ }
420
+ }
421
+ // Copy from example
422
+ try {
423
+ const content = fs.readFileSync(examplePath, 'utf8');
424
+ let newContent = content;
425
+ // Add encryption key if not present
426
+ if (!content.includes('LSH_SECRETS_KEY')) {
427
+ newContent += `\n# LSH Secrets Encryption Key (auto-generated)\nLSH_SECRETS_KEY=${this.encryptionKey}\n`;
428
+ }
429
+ fs.writeFileSync(envFilePath, newContent, 'utf8');
430
+ logger.info(`āœ… Created ${envFilePath} from ${path.basename(examplePath)}`);
431
+ return true;
432
+ }
433
+ catch (error) {
434
+ logger.error(`Failed to create ${envFilePath}: ${error.message}`);
435
+ return false;
436
+ }
437
+ }
438
+ /**
439
+ * Generate shell export commands for loading .env file
440
+ */
441
+ generateExportCommands(envFilePath) {
442
+ if (!fs.existsSync(envFilePath)) {
443
+ return '# No .env file found\n';
444
+ }
445
+ const content = fs.readFileSync(envFilePath, 'utf8');
446
+ const lines = content.split('\n');
447
+ const exports = [];
448
+ for (const line of lines) {
449
+ // Skip comments and empty lines
450
+ if (line.trim().startsWith('#') || !line.trim()) {
451
+ continue;
452
+ }
453
+ // Parse KEY=VALUE
454
+ const match = line.match(/^([^=]+)=(.*)$/);
455
+ if (match) {
456
+ const key = match[1].trim();
457
+ let value = match[2].trim();
458
+ // Remove quotes if present (we'll add them back for the export)
459
+ if ((value.startsWith('"') && value.endsWith('"')) ||
460
+ (value.startsWith("'") && value.endsWith("'"))) {
461
+ value = value.slice(1, -1);
462
+ }
463
+ // Escape special characters for shell
464
+ const escapedValue = value
465
+ .replace(/\\/g, '\\\\')
466
+ .replace(/"/g, '\\"')
467
+ .replace(/\$/g, '\\$')
468
+ .replace(/`/g, '\\`');
469
+ exports.push(`export ${key}="${escapedValue}"`);
470
+ }
471
+ }
472
+ return exports.join('\n') + '\n';
473
+ }
474
+ /**
475
+ * Smart sync command - automatically set up and synchronize secrets
476
+ * This is the new enhanced sync that does everything automatically
477
+ */
478
+ async smartSync(envFilePath = '.env', environment = 'dev', autoExecute = true, loadMode = false) {
479
+ // Use repo-aware environment if in git repo
480
+ const effectiveEnv = this.getRepoAwareEnvironment(environment);
481
+ const displayEnv = this.gitInfo?.repoName ? `${this.gitInfo.repoName}/${environment}` : environment;
482
+ // In load mode, redirect output to stderr so stdout only has export commands
483
+ const out = loadMode ? console.error : console.log;
484
+ out(`\nšŸ” Smart sync for: ${displayEnv}\n`);
485
+ // Show git repo context if detected
486
+ if (this.gitInfo?.isGitRepo) {
487
+ out('šŸ“ Git Repository:');
488
+ out(` Repo: ${this.gitInfo.repoName || 'unknown'}`);
489
+ if (this.gitInfo.currentBranch) {
490
+ out(` Branch: ${this.gitInfo.currentBranch}`);
491
+ }
492
+ out();
493
+ }
494
+ // Step 1: Ensure encryption key exists
495
+ if (!process.env.LSH_SECRETS_KEY) {
496
+ logger.info('šŸ”‘ No encryption key found...');
497
+ await this.ensureEncryptionKey();
498
+ out();
499
+ }
500
+ // Step 2: Ensure .gitignore includes .env
501
+ if (this.gitInfo?.isGitRepo) {
502
+ ensureEnvInGitignore(process.cwd());
503
+ }
504
+ // Step 3: Check current status
505
+ const status = await this.status(envFilePath, effectiveEnv);
506
+ out('šŸ“Š Current Status:');
507
+ out(` Encryption key: ${status.keySet ? 'āœ…' : 'āŒ'}`);
508
+ out(` Local ${envFilePath}: ${status.localExists ? `āœ… (${status.localKeys} keys)` : 'āŒ'}`);
509
+ out(` Cloud storage: ${status.cloudExists ? `āœ… (${status.cloudKeys} keys)` : 'āŒ'}`);
510
+ if (status.cloudExists && status.keyMatches !== undefined) {
511
+ out(` Key matches: ${status.keyMatches ? 'āœ…' : 'āŒ'}`);
512
+ }
513
+ out();
514
+ // Step 4: Determine action and execute if auto mode
515
+ let action = 'in-sync';
516
+ if (status.cloudExists && status.keyMatches === false) {
517
+ action = 'key-mismatch';
518
+ out('āš ļø Encryption key mismatch!');
519
+ out(' The local key does not match the cloud storage.');
520
+ out(' Please use the original key or push new secrets with:');
521
+ out(` lsh lib secrets push -f ${envFilePath} -e ${environment}`);
522
+ out();
523
+ return;
524
+ }
525
+ if (!status.localExists && !status.cloudExists) {
526
+ action = 'create-and-push';
527
+ out('šŸ†• No secrets found locally or in cloud');
528
+ out(' Creating new .env file...');
529
+ if (autoExecute) {
530
+ await this.createEnvFromExample(envFilePath);
531
+ out(' Pushing to cloud...');
532
+ await this.push(envFilePath, effectiveEnv);
533
+ out();
534
+ out('āœ… Setup complete! Edit your .env and run sync again to update.');
535
+ }
536
+ else {
537
+ out('šŸ’” Run: lsh lib secrets create && lsh lib secrets push');
538
+ }
539
+ out();
540
+ // Output export commands in load mode
541
+ if (loadMode && fs.existsSync(envFilePath)) {
542
+ console.log(this.generateExportCommands(envFilePath));
543
+ }
544
+ return;
545
+ }
546
+ if (status.localExists && !status.cloudExists) {
547
+ action = 'push';
548
+ out('ā¬†ļø Local .env exists but not in cloud');
549
+ if (autoExecute) {
550
+ out(' Pushing to cloud...');
551
+ await this.push(envFilePath, effectiveEnv);
552
+ out('āœ… Secrets pushed to cloud!');
553
+ }
554
+ else {
555
+ out(`šŸ’” Run: lsh lib secrets push -f ${envFilePath} -e ${environment}`);
556
+ }
557
+ out();
558
+ // Output export commands in load mode
559
+ if (loadMode && fs.existsSync(envFilePath)) {
560
+ console.log(this.generateExportCommands(envFilePath));
561
+ }
562
+ return;
563
+ }
564
+ if (!status.localExists && status.cloudExists && status.keyMatches) {
565
+ action = 'pull';
566
+ out('ā¬‡ļø Cloud secrets available but no local file');
567
+ if (autoExecute) {
568
+ out(' Pulling from cloud...');
569
+ await this.pull(envFilePath, effectiveEnv, false);
570
+ out('āœ… Secrets pulled from cloud!');
571
+ }
572
+ else {
573
+ out(`šŸ’” Run: lsh lib secrets pull -f ${envFilePath} -e ${environment}`);
574
+ }
575
+ out();
576
+ // Output export commands in load mode
577
+ if (loadMode && fs.existsSync(envFilePath)) {
578
+ console.log(this.generateExportCommands(envFilePath));
579
+ }
580
+ return;
581
+ }
582
+ if (status.localExists && status.cloudExists && status.keyMatches) {
583
+ if (status.localModified && status.cloudModified) {
584
+ const localNewer = status.localModified > status.cloudModified;
585
+ const timeDiff = Math.abs(status.localModified.getTime() - status.cloudModified.getTime());
586
+ const minutesDiff = Math.floor(timeDiff / (1000 * 60));
587
+ // If difference is less than 1 minute, consider in sync
588
+ if (minutesDiff < 1) {
589
+ out('āœ… Local and cloud are in sync!');
590
+ out();
591
+ if (!loadMode) {
592
+ this.showLoadInstructions(envFilePath);
593
+ }
594
+ else if (fs.existsSync(envFilePath)) {
595
+ console.log(this.generateExportCommands(envFilePath));
596
+ }
597
+ return;
598
+ }
599
+ if (localNewer) {
600
+ action = 'push';
601
+ out('ā¬†ļø Local file is newer than cloud');
602
+ out(` Local: ${status.localModified.toLocaleString()}`);
603
+ out(` Cloud: ${status.cloudModified.toLocaleString()}`);
604
+ if (autoExecute) {
605
+ out(' Pushing to cloud...');
606
+ await this.push(envFilePath, effectiveEnv);
607
+ out('āœ… Secrets synced to cloud!');
608
+ }
609
+ else {
610
+ out(`šŸ’” Run: lsh lib secrets push -f ${envFilePath} -e ${environment}`);
611
+ }
612
+ }
613
+ else {
614
+ action = 'pull';
615
+ out('ā¬‡ļø Cloud is newer than local file');
616
+ out(` Local: ${status.localModified.toLocaleString()}`);
617
+ out(` Cloud: ${status.cloudModified.toLocaleString()}`);
618
+ if (autoExecute) {
619
+ out(' Pulling from cloud (backup created)...');
620
+ await this.pull(envFilePath, effectiveEnv, false);
621
+ out('āœ… Secrets synced from cloud!');
622
+ }
623
+ else {
624
+ out(`šŸ’” Run: lsh lib secrets pull -f ${envFilePath} -e ${environment}`);
625
+ }
626
+ }
627
+ out();
628
+ if (!loadMode) {
629
+ this.showLoadInstructions(envFilePath);
630
+ }
631
+ else if (fs.existsSync(envFilePath)) {
632
+ console.log(this.generateExportCommands(envFilePath));
633
+ }
634
+ return;
635
+ }
636
+ }
637
+ // Default: everything is in sync
638
+ out('āœ… Secrets are synchronized!');
639
+ out();
640
+ if (!loadMode) {
641
+ this.showLoadInstructions(envFilePath);
642
+ }
643
+ else if (fs.existsSync(envFilePath)) {
644
+ console.log(this.generateExportCommands(envFilePath));
645
+ }
646
+ }
647
+ /**
648
+ * Show instructions for loading secrets
649
+ */
650
+ showLoadInstructions(envFilePath) {
651
+ console.log('šŸ“ To load secrets in your current shell:');
652
+ console.log(` export $(cat ${envFilePath} | grep -v '^#' | xargs)`);
653
+ console.log();
654
+ console.log(' Or for safer loading (preserves quotes):');
655
+ console.log(` set -a; source ${envFilePath}; set +a`);
656
+ console.log();
657
+ }
658
+ /**
659
+ * Sync command - check status and suggest actions (legacy, kept for compatibility)
271
660
  */
272
661
  async sync(envFilePath = '.env', environment = 'dev') {
273
662
  console.log(`\nšŸ” Checking secrets status for environment: ${environment}\n`);
@@ -48,9 +48,24 @@ export async function init_secrets(program) {
48
48
  .command('list [environment]')
49
49
  .alias('ls')
50
50
  .description('List all stored environments or show secrets for specific environment')
51
- .action(async (environment) => {
51
+ .option('--all-files', 'List all tracked .env files across environments')
52
+ .action(async (environment, options) => {
52
53
  try {
53
54
  const manager = new SecretsManager();
55
+ // If --all-files flag is set, list all tracked files
56
+ if (options.allFiles) {
57
+ const files = await manager.listAllFiles();
58
+ if (files.length === 0) {
59
+ console.log('No .env files found. Push your first file with: lsh secrets push --file <filename>');
60
+ return;
61
+ }
62
+ console.log('\nšŸ“¦ Tracked .env files:\n');
63
+ for (const file of files) {
64
+ console.log(` • ${file.filename} (${file.environment}) - Last updated: ${file.updated}`);
65
+ }
66
+ console.log();
67
+ return;
68
+ }
54
69
  // If environment specified, show secrets for that environment
55
70
  if (environment) {
56
71
  await manager.show(environment);
@@ -154,19 +169,29 @@ API_KEY=
154
169
  process.exit(1);
155
170
  }
156
171
  });
157
- // Sync command - check status and suggest actions
172
+ // Sync command - automatically set up and synchronize secrets
158
173
  secretsCmd
159
174
  .command('sync')
160
- .description('Check secrets sync status and show recommended actions')
175
+ .description('Automatically set up and synchronize secrets (smart mode)')
161
176
  .option('-f, --file <path>', 'Path to .env file', '.env')
162
177
  .option('-e, --env <name>', 'Environment name', 'dev')
178
+ .option('--dry-run', 'Show what would be done without executing')
179
+ .option('--legacy', 'Use legacy sync mode (suggestions only)')
180
+ .option('--load', 'Output eval-able export commands for loading secrets')
163
181
  .action(async (options) => {
164
182
  try {
165
183
  const manager = new SecretsManager();
166
- await manager.sync(options.file, options.env);
184
+ if (options.legacy) {
185
+ // Use legacy sync (suggestions only)
186
+ await manager.sync(options.file, options.env);
187
+ }
188
+ else {
189
+ // Use new smart sync (auto-execute)
190
+ await manager.smartSync(options.file, options.env, !options.dryRun, options.load);
191
+ }
167
192
  }
168
193
  catch (error) {
169
- console.error('āŒ Failed to check sync status:', error.message);
194
+ console.error('āŒ Failed to sync:', error.message);
170
195
  process.exit(1);
171
196
  }
172
197
  });
@@ -187,6 +212,95 @@ API_KEY=
187
212
  process.exit(1);
188
213
  }
189
214
  });
215
+ // Get a specific secret value
216
+ secretsCmd
217
+ .command('get <key>')
218
+ .description('Get a specific secret value from .env file')
219
+ .option('-f, --file <path>', 'Path to .env file', '.env')
220
+ .action(async (key, options) => {
221
+ try {
222
+ const envPath = path.resolve(options.file);
223
+ if (!fs.existsSync(envPath)) {
224
+ console.error(`āŒ File not found: ${envPath}`);
225
+ process.exit(1);
226
+ }
227
+ const content = fs.readFileSync(envPath, 'utf8');
228
+ const lines = content.split('\n');
229
+ for (const line of lines) {
230
+ if (line.trim().startsWith('#') || !line.trim())
231
+ continue;
232
+ const match = line.match(/^([^=]+)=(.*)$/);
233
+ if (match && match[1].trim() === key) {
234
+ let value = match[2].trim();
235
+ // Remove quotes if present
236
+ if ((value.startsWith('"') && value.endsWith('"')) ||
237
+ (value.startsWith("'") && value.endsWith("'"))) {
238
+ value = value.slice(1, -1);
239
+ }
240
+ console.log(value);
241
+ return;
242
+ }
243
+ }
244
+ console.error(`āŒ Key '${key}' not found in ${options.file}`);
245
+ process.exit(1);
246
+ }
247
+ catch (error) {
248
+ console.error('āŒ Failed to get secret:', error.message);
249
+ process.exit(1);
250
+ }
251
+ });
252
+ // Set a specific secret value
253
+ secretsCmd
254
+ .command('set <key> <value>')
255
+ .description('Set a specific secret value in .env file')
256
+ .option('-f, --file <path>', 'Path to .env file', '.env')
257
+ .action(async (key, value, options) => {
258
+ try {
259
+ const envPath = path.resolve(options.file);
260
+ // Validate key format
261
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
262
+ console.error(`āŒ Invalid key format: ${key}. Must be a valid environment variable name.`);
263
+ process.exit(1);
264
+ }
265
+ let content = '';
266
+ let found = false;
267
+ if (fs.existsSync(envPath)) {
268
+ content = fs.readFileSync(envPath, 'utf8');
269
+ const lines = content.split('\n');
270
+ const newLines = [];
271
+ for (const line of lines) {
272
+ if (line.trim().startsWith('#') || !line.trim()) {
273
+ newLines.push(line);
274
+ continue;
275
+ }
276
+ const match = line.match(/^([^=]+)=(.*)$/);
277
+ if (match && match[1].trim() === key) {
278
+ // Quote values with spaces or special characters
279
+ const needsQuotes = /[\s#]/.test(value);
280
+ const quotedValue = needsQuotes ? `"${value}"` : value;
281
+ newLines.push(`${key}=${quotedValue}`);
282
+ found = true;
283
+ }
284
+ else {
285
+ newLines.push(line);
286
+ }
287
+ }
288
+ content = newLines.join('\n');
289
+ }
290
+ // If key wasn't found, append it
291
+ if (!found) {
292
+ const needsQuotes = /[\s#]/.test(value);
293
+ const quotedValue = needsQuotes ? `"${value}"` : value;
294
+ content = content.trimRight() + `\n${key}=${quotedValue}\n`;
295
+ }
296
+ fs.writeFileSync(envPath, content, 'utf8');
297
+ console.log(`āœ… Set ${key} in ${options.file}`);
298
+ }
299
+ catch (error) {
300
+ console.error('āŒ Failed to set secret:', error.message);
301
+ process.exit(1);
302
+ }
303
+ });
190
304
  // Delete .env file with confirmation
191
305
  secretsCmd
192
306
  .command('delete')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lsh-framework",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
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": {