lsh-framework 1.5.1 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -27
- package/dist/commands/doctor.js +13 -2
- package/dist/lib/ipfs-secrets-storage.js +219 -0
- package/dist/lib/secrets-manager.js +82 -161
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,21 +8,21 @@
|
|
|
8
8
|
|
|
9
9
|
Traditional secret management tools are either too complex, too expensive, or require vendor lock-in. LSH gives you:
|
|
10
10
|
|
|
11
|
-
- **Encrypted sync** across all your machines using
|
|
11
|
+
- **Encrypted sync** across all your machines using IPFS content-addressed storage
|
|
12
12
|
- **Automatic rotation** with built-in daemon scheduling
|
|
13
13
|
- **Team collaboration** with shared encryption keys
|
|
14
14
|
- **Multi-environment** support (dev/staging/prod)
|
|
15
|
-
- **
|
|
16
|
-
- **Free & Open Source** - no per-seat pricing
|
|
15
|
+
- **Local-first** - works offline, your data stays on your machine
|
|
16
|
+
- **Free & Open Source** - no per-seat pricing, no cloud dependencies
|
|
17
17
|
|
|
18
18
|
**Plus, you get a complete shell automation platform as a bonus.**
|
|
19
19
|
|
|
20
20
|
## Quick Start
|
|
21
21
|
|
|
22
|
-
**New to LSH?**
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
**New to LSH?** LSH uses IPFS-based local storage - zero configuration needed!
|
|
23
|
+
- **Local-first** - All secrets stored encrypted on your machine at `~/.lsh/secrets-cache/`
|
|
24
|
+
- **No cloud required** - Works completely offline
|
|
25
|
+
- **Team sync** - Share encryption key to sync across team members
|
|
26
26
|
|
|
27
27
|
### Quick Install (Works Immediately!)
|
|
28
28
|
|
|
@@ -30,9 +30,10 @@ Traditional secret management tools are either too complex, too expensive, or re
|
|
|
30
30
|
# Install LSH
|
|
31
31
|
npm install -g lsh-framework
|
|
32
32
|
|
|
33
|
-
# That's it! LSH works
|
|
33
|
+
# That's it! LSH works immediately with IPFS storage
|
|
34
34
|
# Config: ~/.config/lsh/lshrc (auto-created)
|
|
35
|
-
#
|
|
35
|
+
# Secrets: ~/.lsh/secrets-cache/ (encrypted IPFS storage)
|
|
36
|
+
# Metadata: ~/.lsh/secrets-metadata.json
|
|
36
37
|
|
|
37
38
|
# Start using it right away
|
|
38
39
|
lsh --version
|
|
@@ -40,18 +41,13 @@ lsh config # Edit configuration (optional)
|
|
|
40
41
|
lsh daemon start
|
|
41
42
|
```
|
|
42
43
|
|
|
43
|
-
### Smart Sync (Easiest Way
|
|
44
|
+
### Smart Sync (Easiest Way!)
|
|
44
45
|
|
|
45
46
|
```bash
|
|
46
47
|
# 1. Install
|
|
47
48
|
npm install -g lsh-framework
|
|
48
49
|
|
|
49
|
-
# 2.
|
|
50
|
-
# Add to .env:
|
|
51
|
-
# SUPABASE_URL=https://your-project.supabase.co
|
|
52
|
-
# SUPABASE_ANON_KEY=<your-anon-key>
|
|
53
|
-
|
|
54
|
-
# 3. ONE command does everything!
|
|
50
|
+
# 2. ONE command does everything!
|
|
55
51
|
cd ~/repos/your-project
|
|
56
52
|
lsh sync
|
|
57
53
|
|
|
@@ -59,8 +55,9 @@ lsh sync
|
|
|
59
55
|
# ✅ Auto-generates encryption key
|
|
60
56
|
# ✅ Creates .env from .env.example
|
|
61
57
|
# ✅ Adds .env to .gitignore
|
|
62
|
-
# ✅
|
|
58
|
+
# ✅ Stores encrypted secrets locally via IPFS
|
|
63
59
|
# ✅ Namespaces by repo name
|
|
60
|
+
# ✅ Works completely offline
|
|
64
61
|
```
|
|
65
62
|
|
|
66
63
|
### Sync AND Load in One Command
|
|
@@ -73,29 +70,25 @@ eval "$(lsh sync --load)"
|
|
|
73
70
|
echo $DATABASE_URL
|
|
74
71
|
```
|
|
75
72
|
|
|
76
|
-
### Traditional Method (
|
|
73
|
+
### Traditional Method (Manual Control)
|
|
77
74
|
|
|
78
75
|
```bash
|
|
79
76
|
# 1. Install
|
|
80
77
|
npm install -g lsh-framework
|
|
81
78
|
|
|
82
|
-
# 2. Generate encryption key
|
|
79
|
+
# 2. Generate encryption key (for team sharing)
|
|
83
80
|
lsh key
|
|
84
81
|
# Add the output to your .env:
|
|
85
82
|
# LSH_SECRETS_KEY=<your-key>
|
|
86
83
|
|
|
87
|
-
# 3.
|
|
88
|
-
# Add to .env:
|
|
89
|
-
# SUPABASE_URL=https://your-project.supabase.co
|
|
90
|
-
# SUPABASE_ANON_KEY=<your-anon-key>
|
|
91
|
-
|
|
92
|
-
# 4. Push your secrets
|
|
84
|
+
# 3. Push your secrets (encrypted locally via IPFS)
|
|
93
85
|
lsh push
|
|
94
86
|
|
|
95
|
-
#
|
|
87
|
+
# 4. Pull on any other machine (with same encryption key)
|
|
96
88
|
lsh pull
|
|
97
89
|
|
|
98
|
-
# Done! Your secrets are synced.
|
|
90
|
+
# Done! Your secrets are synced via encrypted IPFS storage.
|
|
91
|
+
# Share the LSH_SECRETS_KEY with team members for collaboration.
|
|
99
92
|
```
|
|
100
93
|
|
|
101
94
|
## Core Features
|
package/dist/commands/doctor.js
CHANGED
|
@@ -220,9 +220,10 @@ async function checkStorageBackend(verbose) {
|
|
|
220
220
|
async function testSupabaseConnection(url, key, verbose) {
|
|
221
221
|
try {
|
|
222
222
|
const supabase = createClient(url, key);
|
|
223
|
-
// Try to query
|
|
223
|
+
// Try to query
|
|
224
224
|
const { error } = await supabase.from('lsh_secrets').select('count').limit(0);
|
|
225
|
-
|
|
225
|
+
// No error means table exists and connection works
|
|
226
|
+
if (!error) {
|
|
226
227
|
return {
|
|
227
228
|
name: 'Supabase Connection',
|
|
228
229
|
status: 'pass',
|
|
@@ -230,6 +231,16 @@ async function testSupabaseConnection(url, key, verbose) {
|
|
|
230
231
|
details: verbose ? url : undefined,
|
|
231
232
|
};
|
|
232
233
|
}
|
|
234
|
+
// Check if table doesn't exist (PGRST116 or relation not found errors)
|
|
235
|
+
if (error.code === 'PGRST116' || error.message.includes('relation') || error.message.includes('table') || error.message.includes('schema cache')) {
|
|
236
|
+
return {
|
|
237
|
+
name: 'Storage Mode',
|
|
238
|
+
status: 'pass',
|
|
239
|
+
message: 'Using IPFS storage (Supabase table not found)',
|
|
240
|
+
details: 'Secrets: ~/.lsh/secrets-cache/ | Metadata: ~/.lsh/secrets-metadata.json | IPFS audit logs: ~/.lsh/ipfs/',
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
// Other connection errors
|
|
233
244
|
return {
|
|
234
245
|
name: 'Supabase Connection',
|
|
235
246
|
status: 'warn',
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IPFS Secrets Storage Adapter
|
|
3
|
+
* Stores encrypted secrets on IPFS using Storacha (formerly web3.storage)
|
|
4
|
+
*/
|
|
5
|
+
import * as fs from 'fs';
|
|
6
|
+
import * as path from 'path';
|
|
7
|
+
import * as os from 'os';
|
|
8
|
+
import * as crypto from 'crypto';
|
|
9
|
+
import { createLogger } from './logger.js';
|
|
10
|
+
const logger = createLogger('IPFSSecretsStorage');
|
|
11
|
+
/**
|
|
12
|
+
* IPFS Secrets Storage
|
|
13
|
+
*
|
|
14
|
+
* Stores encrypted secrets on IPFS with local caching
|
|
15
|
+
*
|
|
16
|
+
* Features:
|
|
17
|
+
* - Content-addressed storage (IPFS CIDs)
|
|
18
|
+
* - AES-256 encryption before upload
|
|
19
|
+
* - Local cache for offline access
|
|
20
|
+
* - Environment-based organization
|
|
21
|
+
*/
|
|
22
|
+
export class IPFSSecretsStorage {
|
|
23
|
+
cacheDir;
|
|
24
|
+
metadataPath;
|
|
25
|
+
metadata;
|
|
26
|
+
constructor() {
|
|
27
|
+
const lshDir = path.join(os.homedir(), '.lsh');
|
|
28
|
+
this.cacheDir = path.join(lshDir, 'secrets-cache');
|
|
29
|
+
this.metadataPath = path.join(lshDir, 'secrets-metadata.json');
|
|
30
|
+
// Ensure directories exist
|
|
31
|
+
if (!fs.existsSync(this.cacheDir)) {
|
|
32
|
+
fs.mkdirSync(this.cacheDir, { recursive: true });
|
|
33
|
+
}
|
|
34
|
+
// Load metadata
|
|
35
|
+
this.metadata = this.loadMetadata();
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Store secrets on IPFS
|
|
39
|
+
*/
|
|
40
|
+
async push(secrets, environment, encryptionKey, gitRepo, gitBranch) {
|
|
41
|
+
try {
|
|
42
|
+
// Encrypt secrets
|
|
43
|
+
const encryptedData = this.encryptSecrets(secrets, encryptionKey);
|
|
44
|
+
// Generate CID from encrypted content
|
|
45
|
+
const cid = this.generateCID(encryptedData);
|
|
46
|
+
// Store locally (cache)
|
|
47
|
+
await this.storeLocally(cid, encryptedData, environment);
|
|
48
|
+
// Update metadata
|
|
49
|
+
const metadata = {
|
|
50
|
+
environment,
|
|
51
|
+
git_repo: gitRepo,
|
|
52
|
+
git_branch: gitBranch,
|
|
53
|
+
cid,
|
|
54
|
+
timestamp: new Date().toISOString(),
|
|
55
|
+
keys_count: secrets.length,
|
|
56
|
+
encrypted: true,
|
|
57
|
+
};
|
|
58
|
+
this.metadata[this.getMetadataKey(gitRepo, environment)] = metadata;
|
|
59
|
+
this.saveMetadata();
|
|
60
|
+
logger.info(`📦 Stored ${secrets.length} secrets on IPFS: ${cid}`);
|
|
61
|
+
logger.info(` Environment: ${environment}`);
|
|
62
|
+
if (gitRepo) {
|
|
63
|
+
logger.info(` Repository: ${gitRepo}/${gitBranch || 'main'}`);
|
|
64
|
+
}
|
|
65
|
+
// TODO: In future, upload to real IPFS network via Storacha
|
|
66
|
+
// For now, using local storage with IPFS-compatible CIDs
|
|
67
|
+
return cid;
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
const err = error;
|
|
71
|
+
logger.error(`Failed to push secrets to IPFS: ${err.message}`);
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Retrieve secrets from IPFS
|
|
77
|
+
*/
|
|
78
|
+
async pull(environment, encryptionKey, gitRepo) {
|
|
79
|
+
try {
|
|
80
|
+
const metadataKey = this.getMetadataKey(gitRepo, environment);
|
|
81
|
+
const metadata = this.metadata[metadataKey];
|
|
82
|
+
if (!metadata) {
|
|
83
|
+
throw new Error(`No secrets found for environment: ${environment}`);
|
|
84
|
+
}
|
|
85
|
+
// Try to load from local cache
|
|
86
|
+
const cachedData = await this.loadLocally(metadata.cid);
|
|
87
|
+
if (!cachedData) {
|
|
88
|
+
throw new Error(`Secrets not found in cache. CID: ${metadata.cid}`);
|
|
89
|
+
}
|
|
90
|
+
// Decrypt secrets
|
|
91
|
+
const secrets = this.decryptSecrets(cachedData, encryptionKey);
|
|
92
|
+
logger.info(`📥 Retrieved ${secrets.length} secrets from IPFS`);
|
|
93
|
+
logger.info(` CID: ${metadata.cid}`);
|
|
94
|
+
logger.info(` Environment: ${environment}`);
|
|
95
|
+
return secrets;
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
const err = error;
|
|
99
|
+
logger.error(`Failed to pull secrets from IPFS: ${err.message}`);
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Check if secrets exist for environment
|
|
105
|
+
*/
|
|
106
|
+
exists(environment, gitRepo) {
|
|
107
|
+
const metadataKey = this.getMetadataKey(gitRepo, environment);
|
|
108
|
+
return !!this.metadata[metadataKey];
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Get metadata for environment
|
|
112
|
+
*/
|
|
113
|
+
getMetadata(environment, gitRepo) {
|
|
114
|
+
const metadataKey = this.getMetadataKey(gitRepo, environment);
|
|
115
|
+
return this.metadata[metadataKey];
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* List all environments
|
|
119
|
+
*/
|
|
120
|
+
listEnvironments() {
|
|
121
|
+
return Object.values(this.metadata);
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Delete secrets for environment
|
|
125
|
+
*/
|
|
126
|
+
async delete(environment, gitRepo) {
|
|
127
|
+
const metadataKey = this.getMetadataKey(gitRepo, environment);
|
|
128
|
+
const metadata = this.metadata[metadataKey];
|
|
129
|
+
if (metadata) {
|
|
130
|
+
// Delete local cache
|
|
131
|
+
const cachePath = path.join(this.cacheDir, `${metadata.cid}.encrypted`);
|
|
132
|
+
if (fs.existsSync(cachePath)) {
|
|
133
|
+
fs.unlinkSync(cachePath);
|
|
134
|
+
}
|
|
135
|
+
// Remove metadata
|
|
136
|
+
delete this.metadata[metadataKey];
|
|
137
|
+
this.saveMetadata();
|
|
138
|
+
logger.info(`🗑️ Deleted secrets for ${environment}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Encrypt secrets using AES-256
|
|
143
|
+
*/
|
|
144
|
+
encryptSecrets(secrets, encryptionKey) {
|
|
145
|
+
const data = JSON.stringify(secrets);
|
|
146
|
+
const key = crypto.createHash('sha256').update(encryptionKey).digest();
|
|
147
|
+
const iv = crypto.randomBytes(16);
|
|
148
|
+
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
|
|
149
|
+
let encrypted = cipher.update(data, 'utf8', 'hex');
|
|
150
|
+
encrypted += cipher.final('hex');
|
|
151
|
+
// Return IV + encrypted data
|
|
152
|
+
return iv.toString('hex') + ':' + encrypted;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Decrypt secrets using AES-256
|
|
156
|
+
*/
|
|
157
|
+
decryptSecrets(encryptedData, encryptionKey) {
|
|
158
|
+
const [ivHex, encrypted] = encryptedData.split(':');
|
|
159
|
+
const key = crypto.createHash('sha256').update(encryptionKey).digest();
|
|
160
|
+
const iv = Buffer.from(ivHex, 'hex');
|
|
161
|
+
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
|
|
162
|
+
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
|
163
|
+
decrypted += decipher.final('utf8');
|
|
164
|
+
return JSON.parse(decrypted);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Generate IPFS-compatible CID from content
|
|
168
|
+
*/
|
|
169
|
+
generateCID(content) {
|
|
170
|
+
const hash = crypto.createHash('sha256').update(content).digest('hex');
|
|
171
|
+
// Format like IPFS CIDv1 (bafkreixxx...)
|
|
172
|
+
return `bafkrei${hash.substring(0, 52)}`;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Store encrypted data locally
|
|
176
|
+
*/
|
|
177
|
+
async storeLocally(cid, encryptedData, environment) {
|
|
178
|
+
const cachePath = path.join(this.cacheDir, `${cid}.encrypted`);
|
|
179
|
+
fs.writeFileSync(cachePath, encryptedData, 'utf8');
|
|
180
|
+
logger.debug(`Cached secrets locally: ${cachePath}`);
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Load encrypted data from local cache
|
|
184
|
+
*/
|
|
185
|
+
async loadLocally(cid) {
|
|
186
|
+
const cachePath = path.join(this.cacheDir, `${cid}.encrypted`);
|
|
187
|
+
if (!fs.existsSync(cachePath)) {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
return fs.readFileSync(cachePath, 'utf8');
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Get metadata key for environment
|
|
194
|
+
*/
|
|
195
|
+
getMetadataKey(gitRepo, environment) {
|
|
196
|
+
return gitRepo ? `${gitRepo}_${environment}` : environment;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Load metadata from disk
|
|
200
|
+
*/
|
|
201
|
+
loadMetadata() {
|
|
202
|
+
if (!fs.existsSync(this.metadataPath)) {
|
|
203
|
+
return {};
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
const content = fs.readFileSync(this.metadataPath, 'utf8');
|
|
207
|
+
return JSON.parse(content);
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return {};
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Save metadata to disk
|
|
215
|
+
*/
|
|
216
|
+
saveMetadata() {
|
|
217
|
+
fs.writeFileSync(this.metadataPath, JSON.stringify(this.metadata, null, 2), 'utf8');
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -5,17 +5,17 @@
|
|
|
5
5
|
import * as fs from 'fs';
|
|
6
6
|
import * as path from 'path';
|
|
7
7
|
import * as crypto from 'crypto';
|
|
8
|
-
import DatabasePersistence from './database-persistence.js';
|
|
9
8
|
import { createLogger, LogLevel } from './logger.js';
|
|
10
9
|
import { getGitRepoInfo, hasEnvExample, ensureEnvInGitignore } from './git-utils.js';
|
|
11
10
|
import { IPFSSyncLogger } from './ipfs-sync-logger.js';
|
|
11
|
+
import { IPFSSecretsStorage } from './ipfs-secrets-storage.js';
|
|
12
12
|
const logger = createLogger('SecretsManager');
|
|
13
13
|
export class SecretsManager {
|
|
14
|
-
|
|
14
|
+
storage;
|
|
15
15
|
encryptionKey;
|
|
16
16
|
gitInfo;
|
|
17
17
|
constructor(userId, encryptionKey, detectGit = true) {
|
|
18
|
-
this.
|
|
18
|
+
this.storage = new IPFSSecretsStorage();
|
|
19
19
|
// Use provided key or generate from machine ID + user
|
|
20
20
|
this.encryptionKey = encryptionKey || this.getDefaultEncryptionKey();
|
|
21
21
|
// Auto-detect git repo context
|
|
@@ -28,7 +28,7 @@ export class SecretsManager {
|
|
|
28
28
|
* Call this when done to allow process to exit
|
|
29
29
|
*/
|
|
30
30
|
async cleanup() {
|
|
31
|
-
|
|
31
|
+
// IPFS storage doesn't need cleanup
|
|
32
32
|
}
|
|
33
33
|
/**
|
|
34
34
|
* Get default encryption key from environment or machine
|
|
@@ -178,78 +178,55 @@ export class SecretsManager {
|
|
|
178
178
|
// Warn if using default key
|
|
179
179
|
if (!process.env.LSH_SECRETS_KEY) {
|
|
180
180
|
logger.warn('⚠️ Warning: No LSH_SECRETS_KEY set. Using machine-specific key.');
|
|
181
|
-
logger.warn(' To share secrets across machines, generate a key with: lsh
|
|
181
|
+
logger.warn(' To share secrets across machines, generate a key with: lsh key');
|
|
182
182
|
logger.warn(' Then add LSH_SECRETS_KEY=<key> to your .env on all machines');
|
|
183
183
|
console.log();
|
|
184
184
|
}
|
|
185
|
-
logger.info(`Pushing ${envFilePath} to
|
|
185
|
+
logger.info(`Pushing ${envFilePath} to IPFS (${this.getRepoAwareEnvironment(environment)})...`);
|
|
186
186
|
const content = fs.readFileSync(envFilePath, 'utf8');
|
|
187
187
|
const env = this.parseEnvFile(content);
|
|
188
188
|
// Check for destructive changes unless force is true
|
|
189
189
|
if (!force) {
|
|
190
190
|
try {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const latestSecret = secretsJobs[0];
|
|
202
|
-
if (latestSecret.output) {
|
|
203
|
-
try {
|
|
204
|
-
const decrypted = this.decrypt(latestSecret.output);
|
|
205
|
-
const cloudEnv = this.parseEnvFile(decrypted);
|
|
206
|
-
const destructive = this.detectDestructiveChanges(cloudEnv, env);
|
|
207
|
-
if (destructive.length > 0) {
|
|
208
|
-
throw new Error(this.formatDestructiveChangesError(destructive));
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
catch (error) {
|
|
212
|
-
const err = error;
|
|
213
|
-
// If decryption fails, it's a key mismatch - let it proceed
|
|
214
|
-
// (will fail later with proper error)
|
|
215
|
-
if (!err.message.includes('Destructive change')) {
|
|
216
|
-
// Only ignore decryption errors, re-throw destructive change errors
|
|
217
|
-
throw err;
|
|
218
|
-
}
|
|
219
|
-
throw err;
|
|
220
|
-
}
|
|
191
|
+
// Check if secrets already exist for this environment
|
|
192
|
+
if (this.storage.exists(environment, this.gitInfo?.repoName)) {
|
|
193
|
+
const existingSecrets = await this.storage.pull(environment, this.encryptionKey, this.gitInfo?.repoName);
|
|
194
|
+
const cloudEnv = {};
|
|
195
|
+
existingSecrets.forEach(s => {
|
|
196
|
+
cloudEnv[s.key] = s.value;
|
|
197
|
+
});
|
|
198
|
+
const destructive = this.detectDestructiveChanges(cloudEnv, env);
|
|
199
|
+
if (destructive.length > 0) {
|
|
200
|
+
throw new Error(this.formatDestructiveChangesError(destructive));
|
|
221
201
|
}
|
|
222
202
|
}
|
|
223
203
|
}
|
|
224
204
|
catch (error) {
|
|
225
205
|
const err = error;
|
|
226
|
-
// Re-throw
|
|
227
|
-
if (err.message.includes('Destructive change')
|
|
206
|
+
// Re-throw destructive change errors
|
|
207
|
+
if (err.message.includes('Destructive change')) {
|
|
228
208
|
throw err;
|
|
229
209
|
}
|
|
230
|
-
// Ignore other errors (like
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
//
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
await this.
|
|
247
|
-
logger.info(`✅ Pushed ${Object.keys(env).length} secrets from ${filename} to Supabase`);
|
|
248
|
-
// Log to IPFS for immutable record
|
|
249
|
-
await this.logToIPFS('push', environment, Object.keys(env).length);
|
|
210
|
+
// Ignore other errors (like missing secrets) and proceed
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// Convert to Secret objects
|
|
214
|
+
const secrets = Object.entries(env).map(([key, value]) => ({
|
|
215
|
+
key,
|
|
216
|
+
value,
|
|
217
|
+
environment,
|
|
218
|
+
createdAt: new Date(),
|
|
219
|
+
updatedAt: new Date(),
|
|
220
|
+
}));
|
|
221
|
+
// Store on IPFS
|
|
222
|
+
const cid = await this.storage.push(secrets, environment, this.encryptionKey, this.gitInfo?.repoName, this.gitInfo?.currentBranch);
|
|
223
|
+
logger.info(`✅ Pushed ${secrets.length} secrets from ${filename} to IPFS`);
|
|
224
|
+
console.log(`📦 IPFS CID: ${cid}`);
|
|
225
|
+
// Log to IPFS for immutable audit record
|
|
226
|
+
await this.logToIPFS('push', environment, secrets.length);
|
|
250
227
|
}
|
|
251
228
|
/**
|
|
252
|
-
* Pull .env from
|
|
229
|
+
* Pull .env from IPFS
|
|
253
230
|
*/
|
|
254
231
|
async pull(envFilePath = '.env', environment = 'dev', force = false) {
|
|
255
232
|
// Validate filename pattern for custom files
|
|
@@ -257,52 +234,41 @@ export class SecretsManager {
|
|
|
257
234
|
if (filename !== '.env' && !filename.startsWith('.env.')) {
|
|
258
235
|
throw new Error(`Invalid filename: ${filename}. Must be '.env' or start with '.env.'`);
|
|
259
236
|
}
|
|
260
|
-
logger.info(`Pulling ${filename} (${environment}) from
|
|
261
|
-
// Get
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
.filter(j => {
|
|
266
|
-
// Match secrets for this environment and filename
|
|
267
|
-
return j.command === 'secrets_sync' &&
|
|
268
|
-
j.job_id.includes(environment) &&
|
|
269
|
-
j.job_id.includes(safeFilename);
|
|
270
|
-
})
|
|
271
|
-
.sort((a, b) => new Date(b.started_at).getTime() - new Date(a.started_at).getTime());
|
|
272
|
-
if (secretsJobs.length === 0) {
|
|
273
|
-
throw new Error(`No secrets found for file '${filename}' in environment: ${environment}`);
|
|
274
|
-
}
|
|
275
|
-
const latestSecret = secretsJobs[0];
|
|
276
|
-
if (!latestSecret.output) {
|
|
277
|
-
throw new Error(`No encrypted data found for environment: ${environment}`);
|
|
237
|
+
logger.info(`Pulling ${filename} (${environment}) from IPFS...`);
|
|
238
|
+
// Get secrets from IPFS storage
|
|
239
|
+
const secrets = await this.storage.pull(environment, this.encryptionKey, this.gitInfo?.repoName);
|
|
240
|
+
if (secrets.length === 0) {
|
|
241
|
+
throw new Error(`No secrets found for environment: ${environment}`);
|
|
278
242
|
}
|
|
279
|
-
const decrypted = this.decrypt(latestSecret.output);
|
|
280
243
|
// Backup existing .env if it exists (unless force is true)
|
|
281
244
|
if (fs.existsSync(envFilePath) && !force) {
|
|
282
245
|
const backup = `${envFilePath}.backup.${Date.now()}`;
|
|
283
246
|
fs.copyFileSync(envFilePath, backup);
|
|
284
247
|
logger.info(`Backed up existing .env to ${backup}`);
|
|
285
248
|
}
|
|
249
|
+
// Convert secrets back to .env format
|
|
250
|
+
const envContent = secrets
|
|
251
|
+
.map(s => `${s.key}=${s.value}`)
|
|
252
|
+
.join('\n') + '\n';
|
|
286
253
|
// Write new .env
|
|
287
|
-
fs.writeFileSync(envFilePath,
|
|
288
|
-
|
|
289
|
-
|
|
254
|
+
fs.writeFileSync(envFilePath, envContent, 'utf8');
|
|
255
|
+
logger.info(`✅ Pulled ${secrets.length} secrets from IPFS`);
|
|
256
|
+
// Get metadata for CID display
|
|
257
|
+
const metadata = this.storage.getMetadata(environment, this.gitInfo?.repoName);
|
|
258
|
+
if (metadata) {
|
|
259
|
+
console.log(`📦 IPFS CID: ${metadata.cid}`);
|
|
260
|
+
}
|
|
290
261
|
// Log to IPFS for immutable record
|
|
291
|
-
await this.logToIPFS('pull', environment,
|
|
262
|
+
await this.logToIPFS('pull', environment, secrets.length);
|
|
292
263
|
}
|
|
293
264
|
/**
|
|
294
265
|
* List all stored environments
|
|
295
266
|
*/
|
|
296
267
|
async listEnvironments() {
|
|
297
|
-
const
|
|
298
|
-
const secretsJobs = jobs.filter(j => j.command === 'secrets_sync');
|
|
268
|
+
const allMetadata = this.storage.listEnvironments();
|
|
299
269
|
const envs = new Set();
|
|
300
|
-
for (const
|
|
301
|
-
|
|
302
|
-
const match = job.job_id.match(/secrets_([^_]+)_/);
|
|
303
|
-
if (match) {
|
|
304
|
-
envs.add(match[1]);
|
|
305
|
-
}
|
|
270
|
+
for (const metadata of allMetadata) {
|
|
271
|
+
envs.add(metadata.environment);
|
|
306
272
|
}
|
|
307
273
|
return Array.from(envs).sort();
|
|
308
274
|
}
|
|
@@ -310,79 +276,39 @@ export class SecretsManager {
|
|
|
310
276
|
* List all tracked .env files
|
|
311
277
|
*/
|
|
312
278
|
async listAllFiles() {
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
const parts = job.job_id.split('_');
|
|
320
|
-
if (parts.length >= 3 && parts[0] === 'secrets') {
|
|
321
|
-
const environment = parts[1];
|
|
322
|
-
// Handle both old and new format
|
|
323
|
-
let filename = '.env';
|
|
324
|
-
if (parts.length >= 4) {
|
|
325
|
-
// New format with filename
|
|
326
|
-
const _timestamp = parts[parts.length - 1];
|
|
327
|
-
// Reconstruct filename from middle parts
|
|
328
|
-
const filenameParts = parts.slice(2, -1);
|
|
329
|
-
if (filenameParts.length > 0) {
|
|
330
|
-
// Convert underscores back to dots for the extension
|
|
331
|
-
filename = filenameParts.join('_');
|
|
332
|
-
// Fix the extension dots that were replaced
|
|
333
|
-
filename = filename.replace(/^env_/, '.env.');
|
|
334
|
-
if (filename === 'env') {
|
|
335
|
-
filename = '.env';
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
const key = `${environment}_${filename}`;
|
|
340
|
-
const existing = fileMap.get(key);
|
|
341
|
-
if (!existing || new Date(job.completed_at || job.started_at) > new Date(existing.updated)) {
|
|
342
|
-
fileMap.set(key, {
|
|
343
|
-
filename,
|
|
344
|
-
environment,
|
|
345
|
-
updated: new Date(job.completed_at || job.started_at).toLocaleString()
|
|
346
|
-
});
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
return Array.from(fileMap.values()).sort((a, b) => a.filename.localeCompare(b.filename) || a.environment.localeCompare(b.environment));
|
|
279
|
+
const allMetadata = this.storage.listEnvironments();
|
|
280
|
+
return allMetadata.map(metadata => ({
|
|
281
|
+
filename: '.env', // Currently IPFS storage tracks only .env files
|
|
282
|
+
environment: metadata.environment,
|
|
283
|
+
updated: new Date(metadata.timestamp).toLocaleString()
|
|
284
|
+
})).sort((a, b) => a.filename.localeCompare(b.filename) || a.environment.localeCompare(b.environment));
|
|
351
285
|
}
|
|
352
286
|
/**
|
|
353
287
|
* Show secrets (masked)
|
|
354
288
|
*/
|
|
355
289
|
async show(environment = 'dev', format = 'env') {
|
|
356
|
-
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
.sort((a, b) => new Date(b.started_at).getTime() - new Date(a.started_at).getTime());
|
|
360
|
-
if (secretsJobs.length === 0) {
|
|
290
|
+
// Get secrets from IPFS storage
|
|
291
|
+
const secrets = await this.storage.pull(environment, this.encryptionKey, this.gitInfo?.repoName);
|
|
292
|
+
if (secrets.length === 0) {
|
|
361
293
|
console.log(`No secrets found for environment: ${environment}`);
|
|
362
294
|
return;
|
|
363
295
|
}
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
throw new Error(`No encrypted data found for environment: ${environment}`);
|
|
367
|
-
}
|
|
368
|
-
const decrypted = this.decrypt(latestSecret.output);
|
|
369
|
-
const env = this.parseEnvFile(decrypted);
|
|
370
|
-
// Convert to array format for formatSecrets
|
|
371
|
-
const secrets = Object.entries(env).map(([key, value]) => ({ key, value }));
|
|
296
|
+
// Convert to simple key-value format for formatSecrets
|
|
297
|
+
const secretsFormatted = secrets.map(s => ({ key: s.key, value: s.value }));
|
|
372
298
|
// Use format utilities if not default env format
|
|
373
299
|
if (format !== 'env') {
|
|
374
300
|
const { formatSecrets } = await import('./format-utils.js');
|
|
375
|
-
const output = formatSecrets(
|
|
301
|
+
const output = formatSecrets(secretsFormatted, format, false); // No masking for structured formats
|
|
376
302
|
console.log(output);
|
|
377
303
|
return;
|
|
378
304
|
}
|
|
379
305
|
// Default env format with masking (legacy behavior)
|
|
380
|
-
console.log(`\n📦 Secrets for ${environment} (${
|
|
381
|
-
for (const
|
|
382
|
-
const masked = value.length > 4
|
|
383
|
-
? value.substring(0, 4) + '*'.repeat(Math.min(value.length - 4, 20))
|
|
306
|
+
console.log(`\n📦 Secrets for ${environment} (${secrets.length} total):\n`);
|
|
307
|
+
for (const secret of secrets) {
|
|
308
|
+
const masked = secret.value.length > 4
|
|
309
|
+
? secret.value.substring(0, 4) + '*'.repeat(Math.min(secret.value.length - 4, 20))
|
|
384
310
|
: '****';
|
|
385
|
-
console.log(` ${key}=${masked}`);
|
|
311
|
+
console.log(` ${secret.key}=${masked}`);
|
|
386
312
|
}
|
|
387
313
|
console.log();
|
|
388
314
|
}
|
|
@@ -410,22 +336,17 @@ export class SecretsManager {
|
|
|
410
336
|
const env = this.parseEnvFile(content);
|
|
411
337
|
status.localKeys = Object.keys(env).length;
|
|
412
338
|
}
|
|
413
|
-
// Check
|
|
339
|
+
// Check IPFS storage
|
|
414
340
|
try {
|
|
415
|
-
|
|
416
|
-
const secretsJobs = jobs
|
|
417
|
-
.filter(j => j.command === 'secrets_sync' && j.job_id.includes(environment))
|
|
418
|
-
.sort((a, b) => new Date(b.started_at).getTime() - new Date(a.started_at).getTime());
|
|
419
|
-
if (secretsJobs.length > 0) {
|
|
341
|
+
if (this.storage.exists(environment, this.gitInfo?.repoName)) {
|
|
420
342
|
status.cloudExists = true;
|
|
421
|
-
const
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
343
|
+
const metadata = this.storage.getMetadata(environment, this.gitInfo?.repoName);
|
|
344
|
+
if (metadata) {
|
|
345
|
+
status.cloudModified = new Date(metadata.timestamp);
|
|
346
|
+
status.cloudKeys = metadata.keys_count;
|
|
347
|
+
// Try to decrypt to check if key matches
|
|
425
348
|
try {
|
|
426
|
-
|
|
427
|
-
const env = this.parseEnvFile(decrypted);
|
|
428
|
-
status.cloudKeys = Object.keys(env).length;
|
|
349
|
+
await this.storage.pull(environment, this.encryptionKey, this.gitInfo?.repoName);
|
|
429
350
|
status.keyMatches = true;
|
|
430
351
|
}
|
|
431
352
|
catch (_error) {
|
|
@@ -435,7 +356,7 @@ export class SecretsManager {
|
|
|
435
356
|
}
|
|
436
357
|
}
|
|
437
358
|
catch (_error) {
|
|
438
|
-
//
|
|
359
|
+
// IPFS check failed
|
|
439
360
|
}
|
|
440
361
|
return status;
|
|
441
362
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lsh-framework",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.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": {
|