lsh-framework 0.8.1 ā 0.8.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +0 -0
- package/dist/lib/git-utils.js +186 -0
- package/dist/lib/secrets-manager.js +345 -3
- package/dist/services/api/api.js +58 -0
- package/dist/services/secrets/secrets.js +14 -4
- package/package.json +2 -1
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
|
+
};
|
|
@@ -6,15 +6,21 @@ import * as fs from 'fs';
|
|
|
6
6
|
import * as path from 'path';
|
|
7
7
|
import * as crypto from 'crypto';
|
|
8
8
|
import DatabasePersistence from './database-persistence.js';
|
|
9
|
-
import { createLogger } from './logger.js';
|
|
9
|
+
import { createLogger, LogLevel } 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
|
|
@@ -328,7 +334,343 @@ export class SecretsManager {
|
|
|
328
334
|
return status;
|
|
329
335
|
}
|
|
330
336
|
/**
|
|
331
|
-
*
|
|
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
|
+
// In load mode, suppress all logger output to prevent zsh glob interpretation
|
|
480
|
+
// Save original level and restore at the end
|
|
481
|
+
const originalLogLevel = loadMode ? logger['config'].level : undefined;
|
|
482
|
+
if (loadMode) {
|
|
483
|
+
logger.setLevel(LogLevel.NONE);
|
|
484
|
+
}
|
|
485
|
+
try {
|
|
486
|
+
// Use repo-aware environment if in git repo
|
|
487
|
+
const effectiveEnv = this.getRepoAwareEnvironment(environment);
|
|
488
|
+
const displayEnv = this.gitInfo?.repoName ? `${this.gitInfo.repoName}/${environment}` : environment;
|
|
489
|
+
// In load mode, suppress all output except the final export commands
|
|
490
|
+
const out = loadMode ? () => { } : console.log;
|
|
491
|
+
out(`\nš Smart sync for: ${displayEnv}\n`);
|
|
492
|
+
// Show git repo context if detected
|
|
493
|
+
if (this.gitInfo?.isGitRepo) {
|
|
494
|
+
out('š Git Repository:');
|
|
495
|
+
out(` Repo: ${this.gitInfo.repoName || 'unknown'}`);
|
|
496
|
+
if (this.gitInfo.currentBranch) {
|
|
497
|
+
out(` Branch: ${this.gitInfo.currentBranch}`);
|
|
498
|
+
}
|
|
499
|
+
out();
|
|
500
|
+
}
|
|
501
|
+
// Step 1: Ensure encryption key exists
|
|
502
|
+
if (!process.env.LSH_SECRETS_KEY) {
|
|
503
|
+
logger.info('š No encryption key found...');
|
|
504
|
+
await this.ensureEncryptionKey();
|
|
505
|
+
out();
|
|
506
|
+
}
|
|
507
|
+
// Step 2: Ensure .gitignore includes .env
|
|
508
|
+
if (this.gitInfo?.isGitRepo) {
|
|
509
|
+
ensureEnvInGitignore(process.cwd());
|
|
510
|
+
}
|
|
511
|
+
// Step 3: Check current status
|
|
512
|
+
const status = await this.status(envFilePath, effectiveEnv);
|
|
513
|
+
out('š Current Status:');
|
|
514
|
+
out(` Encryption key: ${status.keySet ? 'ā
' : 'ā'}`);
|
|
515
|
+
out(` Local ${envFilePath}: ${status.localExists ? `ā
(${status.localKeys} keys)` : 'ā'}`);
|
|
516
|
+
out(` Cloud storage: ${status.cloudExists ? `ā
(${status.cloudKeys} keys)` : 'ā'}`);
|
|
517
|
+
if (status.cloudExists && status.keyMatches !== undefined) {
|
|
518
|
+
out(` Key matches: ${status.keyMatches ? 'ā
' : 'ā'}`);
|
|
519
|
+
}
|
|
520
|
+
out();
|
|
521
|
+
// Step 4: Determine action and execute if auto mode
|
|
522
|
+
let action = 'in-sync';
|
|
523
|
+
if (status.cloudExists && status.keyMatches === false) {
|
|
524
|
+
action = 'key-mismatch';
|
|
525
|
+
out('ā ļø Encryption key mismatch!');
|
|
526
|
+
out(' The local key does not match the cloud storage.');
|
|
527
|
+
out(' Please use the original key or push new secrets with:');
|
|
528
|
+
out(` lsh lib secrets push -f ${envFilePath} -e ${environment}`);
|
|
529
|
+
out();
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
if (!status.localExists && !status.cloudExists) {
|
|
533
|
+
action = 'create-and-push';
|
|
534
|
+
out('š No secrets found locally or in cloud');
|
|
535
|
+
out(' Creating new .env file...');
|
|
536
|
+
if (autoExecute) {
|
|
537
|
+
await this.createEnvFromExample(envFilePath);
|
|
538
|
+
out(' Pushing to cloud...');
|
|
539
|
+
await this.push(envFilePath, effectiveEnv);
|
|
540
|
+
out();
|
|
541
|
+
out('ā
Setup complete! Edit your .env and run sync again to update.');
|
|
542
|
+
}
|
|
543
|
+
else {
|
|
544
|
+
out('š” Run: lsh lib secrets create && lsh lib secrets push');
|
|
545
|
+
}
|
|
546
|
+
out();
|
|
547
|
+
// Output export commands in load mode
|
|
548
|
+
if (loadMode && fs.existsSync(envFilePath)) {
|
|
549
|
+
console.log(this.generateExportCommands(envFilePath));
|
|
550
|
+
}
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
if (status.localExists && !status.cloudExists) {
|
|
554
|
+
action = 'push';
|
|
555
|
+
out('ā¬ļø Local .env exists but not in cloud');
|
|
556
|
+
if (autoExecute) {
|
|
557
|
+
out(' Pushing to cloud...');
|
|
558
|
+
await this.push(envFilePath, effectiveEnv);
|
|
559
|
+
out('ā
Secrets pushed to cloud!');
|
|
560
|
+
}
|
|
561
|
+
else {
|
|
562
|
+
out(`š” Run: lsh lib secrets push -f ${envFilePath} -e ${environment}`);
|
|
563
|
+
}
|
|
564
|
+
out();
|
|
565
|
+
// Output export commands in load mode
|
|
566
|
+
if (loadMode && fs.existsSync(envFilePath)) {
|
|
567
|
+
console.log(this.generateExportCommands(envFilePath));
|
|
568
|
+
}
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
if (!status.localExists && status.cloudExists && status.keyMatches) {
|
|
572
|
+
action = 'pull';
|
|
573
|
+
out('ā¬ļø Cloud secrets available but no local file');
|
|
574
|
+
if (autoExecute) {
|
|
575
|
+
out(' Pulling from cloud...');
|
|
576
|
+
await this.pull(envFilePath, effectiveEnv, false);
|
|
577
|
+
out('ā
Secrets pulled from cloud!');
|
|
578
|
+
}
|
|
579
|
+
else {
|
|
580
|
+
out(`š” Run: lsh lib secrets pull -f ${envFilePath} -e ${environment}`);
|
|
581
|
+
}
|
|
582
|
+
out();
|
|
583
|
+
// Output export commands in load mode
|
|
584
|
+
if (loadMode && fs.existsSync(envFilePath)) {
|
|
585
|
+
console.log(this.generateExportCommands(envFilePath));
|
|
586
|
+
}
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
if (status.localExists && status.cloudExists && status.keyMatches) {
|
|
590
|
+
if (status.localModified && status.cloudModified) {
|
|
591
|
+
const localNewer = status.localModified > status.cloudModified;
|
|
592
|
+
const timeDiff = Math.abs(status.localModified.getTime() - status.cloudModified.getTime());
|
|
593
|
+
const minutesDiff = Math.floor(timeDiff / (1000 * 60));
|
|
594
|
+
// If difference is less than 1 minute, consider in sync
|
|
595
|
+
if (minutesDiff < 1) {
|
|
596
|
+
out('ā
Local and cloud are in sync!');
|
|
597
|
+
out();
|
|
598
|
+
if (!loadMode) {
|
|
599
|
+
this.showLoadInstructions(envFilePath);
|
|
600
|
+
}
|
|
601
|
+
else if (fs.existsSync(envFilePath)) {
|
|
602
|
+
console.log(this.generateExportCommands(envFilePath));
|
|
603
|
+
}
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
if (localNewer) {
|
|
607
|
+
action = 'push';
|
|
608
|
+
out('ā¬ļø Local file is newer than cloud');
|
|
609
|
+
out(` Local: ${status.localModified.toLocaleString()}`);
|
|
610
|
+
out(` Cloud: ${status.cloudModified.toLocaleString()}`);
|
|
611
|
+
if (autoExecute) {
|
|
612
|
+
out(' Pushing to cloud...');
|
|
613
|
+
await this.push(envFilePath, effectiveEnv);
|
|
614
|
+
out('ā
Secrets synced to cloud!');
|
|
615
|
+
}
|
|
616
|
+
else {
|
|
617
|
+
out(`š” Run: lsh lib secrets push -f ${envFilePath} -e ${environment}`);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
else {
|
|
621
|
+
action = 'pull';
|
|
622
|
+
out('ā¬ļø Cloud is newer than local file');
|
|
623
|
+
out(` Local: ${status.localModified.toLocaleString()}`);
|
|
624
|
+
out(` Cloud: ${status.cloudModified.toLocaleString()}`);
|
|
625
|
+
if (autoExecute) {
|
|
626
|
+
out(' Pulling from cloud (backup created)...');
|
|
627
|
+
await this.pull(envFilePath, effectiveEnv, false);
|
|
628
|
+
out('ā
Secrets synced from cloud!');
|
|
629
|
+
}
|
|
630
|
+
else {
|
|
631
|
+
out(`š” Run: lsh lib secrets pull -f ${envFilePath} -e ${environment}`);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
out();
|
|
635
|
+
if (!loadMode) {
|
|
636
|
+
this.showLoadInstructions(envFilePath);
|
|
637
|
+
}
|
|
638
|
+
else if (fs.existsSync(envFilePath)) {
|
|
639
|
+
console.log(this.generateExportCommands(envFilePath));
|
|
640
|
+
}
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
// Default: everything is in sync
|
|
645
|
+
out('ā
Secrets are synchronized!');
|
|
646
|
+
out();
|
|
647
|
+
if (!loadMode) {
|
|
648
|
+
this.showLoadInstructions(envFilePath);
|
|
649
|
+
}
|
|
650
|
+
else if (fs.existsSync(envFilePath)) {
|
|
651
|
+
console.log(this.generateExportCommands(envFilePath));
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
finally {
|
|
655
|
+
// Restore original logger level if it was changed
|
|
656
|
+
if (loadMode && originalLogLevel !== undefined) {
|
|
657
|
+
logger.setLevel(originalLogLevel);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Show instructions for loading secrets
|
|
663
|
+
*/
|
|
664
|
+
showLoadInstructions(envFilePath) {
|
|
665
|
+
console.log('š To load secrets in your current shell:');
|
|
666
|
+
console.log(` export $(cat ${envFilePath} | grep -v '^#' | xargs)`);
|
|
667
|
+
console.log();
|
|
668
|
+
console.log(' Or for safer loading (preserves quotes):');
|
|
669
|
+
console.log(` set -a; source ${envFilePath}; set +a`);
|
|
670
|
+
console.log();
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Sync command - check status and suggest actions (legacy, kept for compatibility)
|
|
332
674
|
*/
|
|
333
675
|
async sync(envFilePath = '.env', environment = 'dev') {
|
|
334
676
|
console.log(`\nš Checking secrets status for environment: ${environment}\n`);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import AsyncLock from 'async-lock';
|
|
2
|
+
import request from 'request';
|
|
3
|
+
import { CONFIG } from './config.js';
|
|
4
|
+
import { FILE } from './file.js';
|
|
5
|
+
const semaphore = new AsyncLock();
|
|
6
|
+
let pkgId;
|
|
7
|
+
export const makePOSTRequest = async (typeName, method, data, onSuccess) => {
|
|
8
|
+
console.log("makePostRequest");
|
|
9
|
+
const url = CONFIG.URL + '/api/8' + '/' + typeName + '/' + method;
|
|
10
|
+
console.log(url);
|
|
11
|
+
// Prevent parallel writes/deletions
|
|
12
|
+
return semaphore.acquire('request', (done) => {
|
|
13
|
+
return request.post(url, {
|
|
14
|
+
method: 'POST',
|
|
15
|
+
body: data,
|
|
16
|
+
json: true,
|
|
17
|
+
headers: {
|
|
18
|
+
Authorization: CONFIG.AUTH_TOKEN,
|
|
19
|
+
},
|
|
20
|
+
}, (err, response, body) => {
|
|
21
|
+
console.log(body);
|
|
22
|
+
onSuccess?.(response);
|
|
23
|
+
done();
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
};
|
|
27
|
+
const getMetadataPath = (path) => {
|
|
28
|
+
console.log("getMetadataPath");
|
|
29
|
+
return path.substring(path.indexOf(CONFIG.PATH_TO_PACKAGE_REPO) + CONFIG.PATH_TO_PACKAGE_REPO.length);
|
|
30
|
+
};
|
|
31
|
+
const getPkgId = async () => {
|
|
32
|
+
console.log("getPkgId");
|
|
33
|
+
if (pkgId) {
|
|
34
|
+
return pkgId;
|
|
35
|
+
}
|
|
36
|
+
await makePOSTRequest('Pkg', 'inst', ['Pkg'], (body) => {
|
|
37
|
+
pkgId = body;
|
|
38
|
+
});
|
|
39
|
+
return pkgId;
|
|
40
|
+
};
|
|
41
|
+
const _writeContent = async (path) => {
|
|
42
|
+
console.log("writeContent");
|
|
43
|
+
const pkgId = await getPkgId();
|
|
44
|
+
const metadataPath = getMetadataPath(path);
|
|
45
|
+
const content = FILE.encodeContent(path);
|
|
46
|
+
if (await content === FILE.NO_CHANGE_TO_FILE) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
return makePOSTRequest('Pkg', 'writeContent', [pkgId, metadataPath, {
|
|
50
|
+
type: 'ContentValue',
|
|
51
|
+
content,
|
|
52
|
+
}], () => console.log("Success"));
|
|
53
|
+
};
|
|
54
|
+
const _deleteContent = async (path) => {
|
|
55
|
+
const pkgId = await getPkgId();
|
|
56
|
+
const metadataPath = getMetadataPath(path);
|
|
57
|
+
return makePOSTRequest('Pkg', 'deleteContent', [pkgId, metadataPath, true], () => console.log("deleted!"));
|
|
58
|
+
};
|
|
@@ -169,19 +169,29 @@ API_KEY=
|
|
|
169
169
|
process.exit(1);
|
|
170
170
|
}
|
|
171
171
|
});
|
|
172
|
-
// Sync command -
|
|
172
|
+
// Sync command - automatically set up and synchronize secrets
|
|
173
173
|
secretsCmd
|
|
174
174
|
.command('sync')
|
|
175
|
-
.description('
|
|
175
|
+
.description('Automatically set up and synchronize secrets (smart mode)')
|
|
176
176
|
.option('-f, --file <path>', 'Path to .env file', '.env')
|
|
177
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')
|
|
178
181
|
.action(async (options) => {
|
|
179
182
|
try {
|
|
180
183
|
const manager = new SecretsManager();
|
|
181
|
-
|
|
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
|
+
}
|
|
182
192
|
}
|
|
183
193
|
catch (error) {
|
|
184
|
-
console.error('ā Failed to
|
|
194
|
+
console.error('ā Failed to sync:', error.message);
|
|
185
195
|
process.exit(1);
|
|
186
196
|
}
|
|
187
197
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lsh-framework",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.3",
|
|
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": {
|
|
@@ -106,6 +106,7 @@
|
|
|
106
106
|
"ioredis": "^5.8.0",
|
|
107
107
|
"jsonwebtoken": "^9.0.2",
|
|
108
108
|
"lodash": "^4.17.21",
|
|
109
|
+
"lsh-framework": "^0.8.2",
|
|
109
110
|
"node-cron": "^3.0.3",
|
|
110
111
|
"node-fetch": "^3.3.2",
|
|
111
112
|
"ora": "^8.0.1",
|