lsh-framework 2.2.4 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lib/lsh-config.js +139 -0
- package/dist/lib/secrets-manager.js +77 -15
- package/package.json +1 -1
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LSH Configuration Manager
|
|
3
|
+
*
|
|
4
|
+
* Manages encryption keys and configuration for different repositories.
|
|
5
|
+
* Keys are stored in ~/.lsh/config.json separate from the .env files being synced.
|
|
6
|
+
*
|
|
7
|
+
* This prevents sync conflicts where different hosts overwrite each other's keys.
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from 'fs';
|
|
10
|
+
import * as path from 'path';
|
|
11
|
+
import * as os from 'os';
|
|
12
|
+
import { logger } from './logger.js';
|
|
13
|
+
export class LshConfigManager {
|
|
14
|
+
configPath;
|
|
15
|
+
config;
|
|
16
|
+
constructor(configPath) {
|
|
17
|
+
this.configPath = configPath || path.join(os.homedir(), '.lsh', 'config.json');
|
|
18
|
+
this.config = this.loadConfig();
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Load config from disk, or create default if it doesn't exist
|
|
22
|
+
*/
|
|
23
|
+
loadConfig() {
|
|
24
|
+
try {
|
|
25
|
+
if (fs.existsSync(this.configPath)) {
|
|
26
|
+
const data = fs.readFileSync(this.configPath, 'utf-8');
|
|
27
|
+
return JSON.parse(data);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
const err = error;
|
|
32
|
+
logger.warn(`Failed to load config: ${err.message}`);
|
|
33
|
+
}
|
|
34
|
+
// Return default config
|
|
35
|
+
return {
|
|
36
|
+
version: '1.0.0',
|
|
37
|
+
keys: {},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Save config to disk
|
|
42
|
+
*/
|
|
43
|
+
saveConfig() {
|
|
44
|
+
try {
|
|
45
|
+
const dir = path.dirname(this.configPath);
|
|
46
|
+
if (!fs.existsSync(dir)) {
|
|
47
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
48
|
+
}
|
|
49
|
+
fs.writeFileSync(this.configPath, JSON.stringify(this.config, null, 2), 'utf-8');
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
const err = error;
|
|
53
|
+
logger.error(`Failed to save config: ${err.message}`);
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Get encryption key for a specific repository
|
|
59
|
+
*/
|
|
60
|
+
getKey(repoName) {
|
|
61
|
+
// Check config file first
|
|
62
|
+
if (this.config.keys[repoName]) {
|
|
63
|
+
// Update last used timestamp
|
|
64
|
+
this.config.keys[repoName].lastUsed = new Date().toISOString();
|
|
65
|
+
this.saveConfig();
|
|
66
|
+
return this.config.keys[repoName].key;
|
|
67
|
+
}
|
|
68
|
+
// Fall back to environment variables
|
|
69
|
+
const envKey = process.env.LSH_SECRETS_KEY || process.env.LSH_MASTER_KEY;
|
|
70
|
+
if (envKey) {
|
|
71
|
+
logger.debug(`Using encryption key from environment for ${repoName}`);
|
|
72
|
+
return envKey;
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Set encryption key for a specific repository
|
|
78
|
+
*/
|
|
79
|
+
setKey(repoName, key) {
|
|
80
|
+
this.config.keys[repoName] = {
|
|
81
|
+
key,
|
|
82
|
+
createdAt: this.config.keys[repoName]?.createdAt || new Date().toISOString(),
|
|
83
|
+
lastUsed: new Date().toISOString(),
|
|
84
|
+
};
|
|
85
|
+
this.saveConfig();
|
|
86
|
+
logger.debug(`Saved encryption key for ${repoName}`);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Check if a key exists for a repository
|
|
90
|
+
*/
|
|
91
|
+
hasKey(repoName) {
|
|
92
|
+
return !!this.config.keys[repoName];
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Remove encryption key for a repository
|
|
96
|
+
*/
|
|
97
|
+
removeKey(repoName) {
|
|
98
|
+
delete this.config.keys[repoName];
|
|
99
|
+
this.saveConfig();
|
|
100
|
+
logger.debug(`Removed encryption key for ${repoName}`);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* List all repositories with stored keys
|
|
104
|
+
*/
|
|
105
|
+
listKeys() {
|
|
106
|
+
return Object.entries(this.config.keys).map(([repoName, data]) => ({
|
|
107
|
+
repoName,
|
|
108
|
+
createdAt: data.createdAt,
|
|
109
|
+
lastUsed: data.lastUsed,
|
|
110
|
+
}));
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Export key for sharing with other hosts
|
|
114
|
+
*/
|
|
115
|
+
exportKey(repoName) {
|
|
116
|
+
const key = this.getKey(repoName);
|
|
117
|
+
if (!key) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
return `export LSH_SECRETS_KEY='${key}'`;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Get config file path (for debugging/migration)
|
|
124
|
+
*/
|
|
125
|
+
getConfigPath() {
|
|
126
|
+
return this.configPath;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// Singleton instance
|
|
130
|
+
let _configManager = null;
|
|
131
|
+
/**
|
|
132
|
+
* Get the global LSH config manager instance
|
|
133
|
+
*/
|
|
134
|
+
export function getLshConfig() {
|
|
135
|
+
if (!_configManager) {
|
|
136
|
+
_configManager = new LshConfigManager();
|
|
137
|
+
}
|
|
138
|
+
return _configManager;
|
|
139
|
+
}
|
|
@@ -112,6 +112,32 @@ export class SecretsManager {
|
|
|
112
112
|
}
|
|
113
113
|
return env;
|
|
114
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* Filter out LSH-internal keys that should not be synced
|
|
117
|
+
* These keys are host-specific and syncing them would cause conflicts
|
|
118
|
+
*/
|
|
119
|
+
filterLshInternalKeys(env) {
|
|
120
|
+
const filtered = {};
|
|
121
|
+
const excludedKeys = new Set([
|
|
122
|
+
'LSH_SECRETS_KEY', // Encryption key - host-specific, managed separately
|
|
123
|
+
'LSH_MASTER_KEY', // Alternative encryption key name
|
|
124
|
+
'LSH_INTERNAL_', // Any other LSH internal keys
|
|
125
|
+
]);
|
|
126
|
+
for (const [key, value] of Object.entries(env)) {
|
|
127
|
+
// Skip exact matches
|
|
128
|
+
if (excludedKeys.has(key)) {
|
|
129
|
+
logger.debug(`Filtering out LSH-internal key: ${key}`);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
// Skip keys starting with LSH_INTERNAL_
|
|
133
|
+
if (key.startsWith('LSH_INTERNAL_')) {
|
|
134
|
+
logger.debug(`Filtering out LSH-internal key: ${key}`);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
filtered[key] = value;
|
|
138
|
+
}
|
|
139
|
+
return filtered;
|
|
140
|
+
}
|
|
115
141
|
/**
|
|
116
142
|
* Format env vars as .env file content
|
|
117
143
|
*/
|
|
@@ -186,13 +212,20 @@ export class SecretsManager {
|
|
|
186
212
|
}
|
|
187
213
|
logger.info(`Pushing ${envFilePath} to IPFS (${effectiveEnv})...`);
|
|
188
214
|
const content = fs.readFileSync(envFilePath, 'utf8');
|
|
189
|
-
const
|
|
215
|
+
const envParsed = this.parseEnvFile(content);
|
|
216
|
+
// Filter out LSH-internal keys (encryption keys, etc.) that should not be synced
|
|
217
|
+
const env = this.filterLshInternalKeys(envParsed);
|
|
218
|
+
const filteredCount = Object.keys(envParsed).length - Object.keys(env).length;
|
|
219
|
+
if (filteredCount > 0) {
|
|
220
|
+
logger.debug(`Filtered out ${filteredCount} LSH-internal key(s) from sync`);
|
|
221
|
+
}
|
|
190
222
|
// Check for destructive changes unless force is true
|
|
191
223
|
if (!force) {
|
|
192
224
|
try {
|
|
193
225
|
// Check if secrets already exist for this environment
|
|
194
|
-
|
|
195
|
-
|
|
226
|
+
// Use raw environment to match storage.push() behavior
|
|
227
|
+
if (this.storage.exists(environment, this.gitInfo?.repoName)) {
|
|
228
|
+
const existingSecrets = await this.storage.pull(environment, this.encryptionKey, this.gitInfo?.repoName);
|
|
196
229
|
const cloudEnv = {};
|
|
197
230
|
existingSecrets.forEach(s => {
|
|
198
231
|
cloudEnv[s.key] = s.value;
|
|
@@ -221,7 +254,8 @@ export class SecretsManager {
|
|
|
221
254
|
updatedAt: new Date(),
|
|
222
255
|
}));
|
|
223
256
|
// Store on IPFS
|
|
224
|
-
const cid = await this.storage.push(secrets,
|
|
257
|
+
const cid = await this.storage.push(secrets, environment, // Use raw environment, storage will apply repo-aware naming
|
|
258
|
+
this.encryptionKey, this.gitInfo?.repoName, this.gitInfo?.currentBranch);
|
|
225
259
|
logger.info(`✅ Pushed ${secrets.length} secrets from ${filename} to IPFS`);
|
|
226
260
|
console.log(`📦 IPFS CID: ${cid}`);
|
|
227
261
|
// Log to IPFS for immutable audit record
|
|
@@ -239,22 +273,50 @@ export class SecretsManager {
|
|
|
239
273
|
const effectiveEnv = this.getRepoAwareEnvironment(environment);
|
|
240
274
|
logger.info(`Pulling ${filename} (${effectiveEnv}) from IPFS...`);
|
|
241
275
|
// Get secrets from IPFS storage
|
|
242
|
-
|
|
276
|
+
// Use raw environment parameter to match how push() stores them
|
|
277
|
+
const secrets = await this.storage.pull(environment, // Use raw environment, not effectiveEnv
|
|
278
|
+
this.encryptionKey, this.gitInfo?.repoName);
|
|
243
279
|
if (secrets.length === 0) {
|
|
244
280
|
throw new Error(`No secrets found for environment: ${effectiveEnv}\n\n` +
|
|
245
281
|
`💡 Tip: Check available environments with: lsh env\n` +
|
|
246
282
|
` Or push secrets first with: lsh push --env ${environment}`);
|
|
247
283
|
}
|
|
248
|
-
//
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
fs.
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
284
|
+
// Preserve local LSH-internal keys before overwriting
|
|
285
|
+
let localLshKeys = {};
|
|
286
|
+
if (fs.existsSync(envFilePath)) {
|
|
287
|
+
const existingContent = fs.readFileSync(envFilePath, 'utf8');
|
|
288
|
+
const existingEnv = this.parseEnvFile(existingContent);
|
|
289
|
+
// Extract LSH-internal keys to preserve
|
|
290
|
+
const lshKeyNames = ['LSH_SECRETS_KEY', 'LSH_MASTER_KEY'];
|
|
291
|
+
for (const keyName of lshKeyNames) {
|
|
292
|
+
if (existingEnv[keyName]) {
|
|
293
|
+
localLshKeys[keyName] = existingEnv[keyName];
|
|
294
|
+
logger.debug(`Preserving local ${keyName}`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
// Also preserve any LSH_INTERNAL_* keys
|
|
298
|
+
for (const [key, value] of Object.entries(existingEnv)) {
|
|
299
|
+
if (key.startsWith('LSH_INTERNAL_')) {
|
|
300
|
+
localLshKeys[key] = value;
|
|
301
|
+
logger.debug(`Preserving local ${key}`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
// Backup existing .env (unless force is true)
|
|
305
|
+
if (!force) {
|
|
306
|
+
const backup = `${envFilePath}.backup.${Date.now()}`;
|
|
307
|
+
fs.copyFileSync(envFilePath, backup);
|
|
308
|
+
logger.info(`Backed up existing ${filename} to ${backup}`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
// Convert secrets back to env object
|
|
312
|
+
const pulledEnv = {};
|
|
313
|
+
secrets.forEach(s => {
|
|
314
|
+
pulledEnv[s.key] = s.value;
|
|
315
|
+
});
|
|
316
|
+
// Merge pulled secrets with preserved local LSH keys
|
|
317
|
+
const finalEnv = { ...pulledEnv, ...localLshKeys };
|
|
318
|
+
// Convert to .env format
|
|
319
|
+
const envContent = this.formatEnvFile(finalEnv);
|
|
258
320
|
// Write new .env
|
|
259
321
|
fs.writeFileSync(envFilePath, envContent, 'utf8');
|
|
260
322
|
logger.info(`✅ Pulled ${secrets.length} secrets from IPFS`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lsh-framework",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"description": "Simple, cross-platform encrypted secrets manager with automatic sync, IPFS audit logs, and multi-environment support. Just run lsh sync and start managing your secrets.",
|
|
5
5
|
"main": "dist/app.js",
|
|
6
6
|
"bin": {
|