lsh-framework 2.2.5 → 2.3.1
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 +75 -14
- package/dist/lib/storacha-client.js +95 -5
- package/dist/services/secrets/secrets.js +203 -0
- 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;
|
|
@@ -240,22 +273,50 @@ export class SecretsManager {
|
|
|
240
273
|
const effectiveEnv = this.getRepoAwareEnvironment(environment);
|
|
241
274
|
logger.info(`Pulling ${filename} (${effectiveEnv}) from IPFS...`);
|
|
242
275
|
// Get secrets from IPFS storage
|
|
243
|
-
|
|
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);
|
|
244
279
|
if (secrets.length === 0) {
|
|
245
280
|
throw new Error(`No secrets found for environment: ${effectiveEnv}\n\n` +
|
|
246
281
|
`💡 Tip: Check available environments with: lsh env\n` +
|
|
247
282
|
` Or push secrets first with: lsh push --env ${environment}`);
|
|
248
283
|
}
|
|
249
|
-
//
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
fs.
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
284
|
+
// Preserve local LSH-internal keys before overwriting
|
|
285
|
+
const 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);
|
|
259
320
|
// Write new .env
|
|
260
321
|
fs.writeFileSync(envFilePath, envContent, 'utf8');
|
|
261
322
|
logger.info(`✅ Pulled ${secrets.length} secrets from IPFS`);
|
|
@@ -244,12 +244,24 @@ export class StorachaClient {
|
|
|
244
244
|
if (!await this.isAuthenticated()) {
|
|
245
245
|
throw new Error('Not authenticated');
|
|
246
246
|
}
|
|
247
|
+
// Get the latest registry version and increment it
|
|
248
|
+
let registryVersion = 1;
|
|
249
|
+
try {
|
|
250
|
+
const latestRegistry = await this.getLatestRegistry(repoName);
|
|
251
|
+
if (latestRegistry && latestRegistry.registryVersion) {
|
|
252
|
+
registryVersion = latestRegistry.registryVersion + 1;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
catch (err) {
|
|
256
|
+
logger.debug(`Could not fetch latest registry version, using version 1: ${err.message}`);
|
|
257
|
+
}
|
|
247
258
|
const registry = {
|
|
248
259
|
repoName,
|
|
249
260
|
environment,
|
|
250
261
|
cid: secretsCid, // Include the secrets CID
|
|
251
262
|
timestamp: new Date().toISOString(),
|
|
252
|
-
version: '2.
|
|
263
|
+
version: '2.3.0', // LSH version
|
|
264
|
+
registryVersion, // Incremental version counter
|
|
253
265
|
};
|
|
254
266
|
const content = JSON.stringify(registry, null, 2);
|
|
255
267
|
const buffer = Buffer.from(content, 'utf-8');
|
|
@@ -259,7 +271,7 @@ export class StorachaClient {
|
|
|
259
271
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
260
272
|
const file = new File([uint8Array], filename, { type: 'application/json' });
|
|
261
273
|
const cid = await client.uploadFile(file);
|
|
262
|
-
logger.debug(`📝 Uploaded registry for ${repoName} (secrets CID: ${secretsCid}): ${cid}`);
|
|
274
|
+
logger.debug(`📝 Uploaded registry v${registryVersion} for ${repoName} (secrets CID: ${secretsCid}): ${cid}`);
|
|
263
275
|
return cid.toString();
|
|
264
276
|
}
|
|
265
277
|
/**
|
|
@@ -303,6 +315,7 @@ export class StorachaClient {
|
|
|
303
315
|
cid: cid,
|
|
304
316
|
timestamp: json.timestamp,
|
|
305
317
|
secretsCid: json.cid,
|
|
318
|
+
registryVersion: json.registryVersion || 0, // Default to 0 for old registries without version
|
|
306
319
|
});
|
|
307
320
|
}
|
|
308
321
|
}
|
|
@@ -311,11 +324,18 @@ export class StorachaClient {
|
|
|
311
324
|
continue;
|
|
312
325
|
}
|
|
313
326
|
}
|
|
314
|
-
// Sort by
|
|
327
|
+
// Sort by registryVersion (highest first), then timestamp as tie-breaker
|
|
315
328
|
if (registries.length > 0) {
|
|
316
|
-
registries.sort((a, b) =>
|
|
329
|
+
registries.sort((a, b) => {
|
|
330
|
+
// First compare by registryVersion (higher is newer)
|
|
331
|
+
if (b.registryVersion !== a.registryVersion) {
|
|
332
|
+
return b.registryVersion - a.registryVersion;
|
|
333
|
+
}
|
|
334
|
+
// If versions match, use timestamp as tie-breaker
|
|
335
|
+
return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime();
|
|
336
|
+
});
|
|
317
337
|
const latest = registries[0];
|
|
318
|
-
logger.debug(`✅ Found latest CID for ${repoName}: ${latest.secretsCid} (timestamp: ${latest.timestamp})`);
|
|
338
|
+
logger.debug(`✅ Found latest CID for ${repoName}: ${latest.secretsCid} (v${latest.registryVersion}, timestamp: ${latest.timestamp})`);
|
|
319
339
|
return latest.secretsCid;
|
|
320
340
|
}
|
|
321
341
|
// No registry found
|
|
@@ -327,6 +347,76 @@ export class StorachaClient {
|
|
|
327
347
|
return null;
|
|
328
348
|
}
|
|
329
349
|
}
|
|
350
|
+
/**
|
|
351
|
+
* Get the latest registry object for a repo
|
|
352
|
+
* Returns the full registry object including registryVersion
|
|
353
|
+
*/
|
|
354
|
+
async getLatestRegistry(repoName) {
|
|
355
|
+
if (!this.isEnabled()) {
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
if (!await this.isAuthenticated()) {
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
try {
|
|
362
|
+
const client = await this.getClient();
|
|
363
|
+
// Only check recent uploads (limit to 20 for performance)
|
|
364
|
+
const pageSize = 20;
|
|
365
|
+
// Get first page of uploads
|
|
366
|
+
const results = await client.capability.upload.list({
|
|
367
|
+
cursor: '',
|
|
368
|
+
size: pageSize,
|
|
369
|
+
});
|
|
370
|
+
// Collect all registry files for this repo
|
|
371
|
+
const registries = [];
|
|
372
|
+
for (const upload of results.results) {
|
|
373
|
+
try {
|
|
374
|
+
const cid = upload.root.toString();
|
|
375
|
+
// Download with timeout
|
|
376
|
+
const downloadPromise = this.download(cid);
|
|
377
|
+
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 5000));
|
|
378
|
+
const content = await Promise.race([downloadPromise, timeoutPromise]);
|
|
379
|
+
// Skip large files (registry should be < 1KB)
|
|
380
|
+
if (content.length > 1024) {
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
// Try to parse as JSON
|
|
384
|
+
const json = JSON.parse(content.toString('utf-8'));
|
|
385
|
+
// Check if it's an LSH registry file for our repo
|
|
386
|
+
if (json.repoName === repoName && json.version && json.cid && json.timestamp) {
|
|
387
|
+
registries.push({
|
|
388
|
+
repoName: json.repoName,
|
|
389
|
+
environment: json.environment,
|
|
390
|
+
cid: json.cid,
|
|
391
|
+
timestamp: json.timestamp,
|
|
392
|
+
version: json.version,
|
|
393
|
+
registryVersion: json.registryVersion || 0,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
catch {
|
|
398
|
+
// Not an LSH registry file or failed to download
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
// Sort by registryVersion (highest first), then timestamp as tie-breaker
|
|
403
|
+
if (registries.length > 0) {
|
|
404
|
+
registries.sort((a, b) => {
|
|
405
|
+
if (b.registryVersion !== a.registryVersion) {
|
|
406
|
+
return b.registryVersion - a.registryVersion;
|
|
407
|
+
}
|
|
408
|
+
return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime();
|
|
409
|
+
});
|
|
410
|
+
return registries[0];
|
|
411
|
+
}
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
catch (error) {
|
|
415
|
+
const err = error;
|
|
416
|
+
logger.debug(`Failed to get latest registry: ${err.message}`);
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
330
420
|
/**
|
|
331
421
|
* Check if registry exists for a repo by listing uploads
|
|
332
422
|
* Returns true if a registry file for this repo exists in Storacha
|
|
@@ -842,5 +842,208 @@ API_KEY=
|
|
|
842
842
|
process.exit(1);
|
|
843
843
|
}
|
|
844
844
|
});
|
|
845
|
+
// Clear stuck registries and local metadata
|
|
846
|
+
program
|
|
847
|
+
.command('clear')
|
|
848
|
+
.description('Clear local metadata and cache to resolve stuck registries')
|
|
849
|
+
.option('--repo <name>', 'Clear metadata for specific repo only')
|
|
850
|
+
.option('--cache', 'Also clear local encrypted secrets cache')
|
|
851
|
+
.option('--storacha', 'Also delete old Storacha uploads (registries and secrets)')
|
|
852
|
+
.option('--all', 'Clear all metadata and cache (requires confirmation)')
|
|
853
|
+
.option('-y, --yes', 'Skip confirmation prompts')
|
|
854
|
+
.action(async (options) => {
|
|
855
|
+
try {
|
|
856
|
+
const lshDir = path.join(process.env.HOME || process.env.USERPROFILE || '', '.lsh');
|
|
857
|
+
const metadataPath = path.join(lshDir, 'secrets-metadata.json');
|
|
858
|
+
const cacheDir = path.join(lshDir, 'secrets-cache');
|
|
859
|
+
// Determine what we're clearing
|
|
860
|
+
if (!options.repo && !options.all) {
|
|
861
|
+
console.error('❌ Please specify either --repo <name> or --all');
|
|
862
|
+
console.log('');
|
|
863
|
+
console.log('Examples:');
|
|
864
|
+
console.log(' lsh clear --repo lsh_test_repo # Clear metadata for specific repo');
|
|
865
|
+
console.log(' lsh clear --all # Clear all metadata');
|
|
866
|
+
console.log(' lsh clear --all --cache # Clear metadata and cache');
|
|
867
|
+
process.exit(1);
|
|
868
|
+
}
|
|
869
|
+
// Load metadata
|
|
870
|
+
if (!fs.existsSync(metadataPath)) {
|
|
871
|
+
console.log('ℹ️ No metadata file found - nothing to clear');
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
|
|
875
|
+
const keys = Object.keys(metadata);
|
|
876
|
+
if (keys.length === 0) {
|
|
877
|
+
console.log('ℹ️ Metadata is already empty');
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
// Show what will be cleared
|
|
881
|
+
console.log('📋 Current metadata entries:');
|
|
882
|
+
console.log('');
|
|
883
|
+
if (options.repo) {
|
|
884
|
+
const repoKeys = keys.filter(k => metadata[k].git_repo === options.repo);
|
|
885
|
+
if (repoKeys.length === 0) {
|
|
886
|
+
console.log(`ℹ️ No metadata found for repo: ${options.repo}`);
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
console.log(`Repo: ${options.repo}`);
|
|
890
|
+
repoKeys.forEach(key => {
|
|
891
|
+
console.log(` - ${key} (CID: ${metadata[key].cid.substring(0, 12)}...)`);
|
|
892
|
+
});
|
|
893
|
+
console.log('');
|
|
894
|
+
console.log(`Will clear ${repoKeys.length} ${repoKeys.length === 1 ? 'entry' : 'entries'}`);
|
|
895
|
+
}
|
|
896
|
+
else {
|
|
897
|
+
const repoCount = new Set(keys.map(k => metadata[k].git_repo)).size;
|
|
898
|
+
console.log(`Total entries: ${keys.length} across ${repoCount} ${repoCount === 1 ? 'repo' : 'repos'}`);
|
|
899
|
+
}
|
|
900
|
+
if (options.cache) {
|
|
901
|
+
if (fs.existsSync(cacheDir)) {
|
|
902
|
+
const cacheFiles = fs.readdirSync(cacheDir);
|
|
903
|
+
console.log(`Cache files: ${cacheFiles.length}`);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
console.log('');
|
|
907
|
+
// Confirmation
|
|
908
|
+
if (!options.yes) {
|
|
909
|
+
console.log('⚠️ WARNING: This will clear local metadata!');
|
|
910
|
+
console.log('');
|
|
911
|
+
console.log('This is useful when:');
|
|
912
|
+
console.log(' • Registry is returning stale/old CIDs');
|
|
913
|
+
console.log(' • Pull fails with "bad decrypt" errors');
|
|
914
|
+
console.log(' • You need to force a fresh sync');
|
|
915
|
+
console.log('');
|
|
916
|
+
console.log('After clearing, you will need to push secrets again.');
|
|
917
|
+
console.log('');
|
|
918
|
+
const rl = readline.createInterface({
|
|
919
|
+
input: process.stdin,
|
|
920
|
+
output: process.stdout,
|
|
921
|
+
});
|
|
922
|
+
const answer = await new Promise((resolve) => {
|
|
923
|
+
rl.question('Continue? (yes/no): ', (ans) => {
|
|
924
|
+
rl.close();
|
|
925
|
+
resolve(ans.trim().toLowerCase());
|
|
926
|
+
});
|
|
927
|
+
});
|
|
928
|
+
if (answer !== 'yes' && answer !== 'y') {
|
|
929
|
+
console.log('');
|
|
930
|
+
console.log('❌ Cancelled');
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
console.log('');
|
|
935
|
+
// Clear metadata
|
|
936
|
+
if (options.repo) {
|
|
937
|
+
const repoKeys = keys.filter(k => metadata[k].git_repo === options.repo);
|
|
938
|
+
repoKeys.forEach(key => delete metadata[key]);
|
|
939
|
+
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
|
|
940
|
+
console.log(`✅ Cleared ${repoKeys.length} metadata ${repoKeys.length === 1 ? 'entry' : 'entries'} for ${options.repo}`);
|
|
941
|
+
}
|
|
942
|
+
else {
|
|
943
|
+
fs.writeFileSync(metadataPath, '{}');
|
|
944
|
+
console.log(`✅ Cleared all ${keys.length} metadata ${keys.length === 1 ? 'entry' : 'entries'}`);
|
|
945
|
+
}
|
|
946
|
+
// Clear cache if requested
|
|
947
|
+
if (options.cache && fs.existsSync(cacheDir)) {
|
|
948
|
+
const cacheFiles = fs.readdirSync(cacheDir);
|
|
949
|
+
let cleared = 0;
|
|
950
|
+
for (const file of cacheFiles) {
|
|
951
|
+
const filePath = path.join(cacheDir, file);
|
|
952
|
+
if (fs.statSync(filePath).isFile()) {
|
|
953
|
+
fs.unlinkSync(filePath);
|
|
954
|
+
cleared++;
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
console.log(`✅ Cleared ${cleared} cache ${cleared === 1 ? 'file' : 'files'}`);
|
|
958
|
+
}
|
|
959
|
+
// Clear Storacha uploads if requested
|
|
960
|
+
if (options.storacha && options.repo) {
|
|
961
|
+
console.log('');
|
|
962
|
+
console.log('🌐 Clearing Storacha uploads...');
|
|
963
|
+
try {
|
|
964
|
+
const { StorachaClient } = await import('../../lib/storacha-client.js');
|
|
965
|
+
const storacha = new StorachaClient();
|
|
966
|
+
if (!storacha.isEnabled()) {
|
|
967
|
+
console.log('ℹ️ Storacha is not enabled - skipping cloud cleanup');
|
|
968
|
+
}
|
|
969
|
+
else if (!(await storacha.isAuthenticated())) {
|
|
970
|
+
console.log('ℹ️ Not authenticated with Storacha - skipping cloud cleanup');
|
|
971
|
+
}
|
|
972
|
+
else {
|
|
973
|
+
// Get all uploads
|
|
974
|
+
const client = await storacha.getClient();
|
|
975
|
+
const pageSize = 50;
|
|
976
|
+
const results = await client.capability.upload.list({
|
|
977
|
+
cursor: '',
|
|
978
|
+
size: pageSize,
|
|
979
|
+
});
|
|
980
|
+
// Find LSH-related uploads for this repo
|
|
981
|
+
const toDelete = [];
|
|
982
|
+
for (const upload of results.results) {
|
|
983
|
+
try {
|
|
984
|
+
const cid = upload.root.toString();
|
|
985
|
+
// Download with timeout
|
|
986
|
+
const downloadPromise = storacha.download(cid);
|
|
987
|
+
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 5000));
|
|
988
|
+
const content = await Promise.race([downloadPromise, timeoutPromise]);
|
|
989
|
+
// Check if it's a registry file for this repo
|
|
990
|
+
if (content.length < 2048) {
|
|
991
|
+
try {
|
|
992
|
+
const json = JSON.parse(content.toString('utf-8'));
|
|
993
|
+
if (json.repoName === options.repo) {
|
|
994
|
+
toDelete.push({ cid, type: 'registry', size: content.length });
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
catch {
|
|
998
|
+
// Not JSON, might be encrypted secrets
|
|
999
|
+
// Check filename pattern
|
|
1000
|
+
const _filename = `lsh-secrets-${options.repo}`;
|
|
1001
|
+
if (cid.includes(options.repo) || content.toString().includes(options.repo)) {
|
|
1002
|
+
toDelete.push({ cid, type: 'secrets', size: content.length });
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
catch {
|
|
1008
|
+
// Failed to download or parse, skip
|
|
1009
|
+
continue;
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
if (toDelete.length > 0) {
|
|
1013
|
+
console.log(`Found ${toDelete.length} Storacha ${toDelete.length === 1 ? 'upload' : 'uploads'} for ${options.repo}:`);
|
|
1014
|
+
toDelete.forEach((item) => {
|
|
1015
|
+
console.log(` - ${item.type}: ${item.cid.substring(0, 16)}... (${item.size} bytes)`);
|
|
1016
|
+
});
|
|
1017
|
+
// Note: Storacha doesn't currently support deletion via SDK
|
|
1018
|
+
// The uploads will remain but won't be used after metadata is cleared
|
|
1019
|
+
console.log('');
|
|
1020
|
+
console.log('⚠️ Note: Storacha uploads cannot be deleted programmatically.');
|
|
1021
|
+
console.log(' These files will remain in Storacha but won\'t be used after metadata is cleared.');
|
|
1022
|
+
console.log(' To fully remove them, use the Storacha web console:');
|
|
1023
|
+
console.log(' https://console.storacha.network/');
|
|
1024
|
+
}
|
|
1025
|
+
else {
|
|
1026
|
+
console.log(`ℹ️ No Storacha uploads found for ${options.repo}`);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
catch (storageError) {
|
|
1031
|
+
const storErr = storageError;
|
|
1032
|
+
console.error(`⚠️ Failed to check Storacha uploads: ${storErr.message}`);
|
|
1033
|
+
console.log(' Local metadata has been cleared, but cloud uploads may remain.');
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
console.log('');
|
|
1037
|
+
console.log('💡 Next steps:');
|
|
1038
|
+
console.log(' 1. lsh push .env # Push secrets with current key');
|
|
1039
|
+
console.log(' 2. lsh pull .env # Verify pull works');
|
|
1040
|
+
console.log('');
|
|
1041
|
+
}
|
|
1042
|
+
catch (error) {
|
|
1043
|
+
const err = error;
|
|
1044
|
+
console.error('❌ Failed to clear metadata:', err.message);
|
|
1045
|
+
process.exit(1);
|
|
1046
|
+
}
|
|
1047
|
+
});
|
|
845
1048
|
}
|
|
846
1049
|
export default init_secrets;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lsh-framework",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.1",
|
|
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": {
|