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 +4 -4
- package/dist/cli.js +0 -0
- package/dist/lib/git-utils.js +186 -0
- package/dist/lib/secrets-manager.js +399 -10
- package/dist/services/secrets/secrets.js +119 -5
- package/package.json +1 -1
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
150
|
-
|
|
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 =>
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
.
|
|
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 -
|
|
172
|
+
// Sync command - automatically set up and synchronize secrets
|
|
158
173
|
secretsCmd
|
|
159
174
|
.command('sync')
|
|
160
|
-
.description('
|
|
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
|
-
|
|
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
|
|
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.
|
|
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": {
|