lsh-framework 2.1.2 → 2.2.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/commands/init.js +135 -1
- package/dist/lib/ipfs-secrets-storage.js +13 -0
- package/dist/lib/storacha-client.js +96 -0
- package/package.json +1 -1
package/dist/commands/init.js
CHANGED
|
@@ -10,6 +10,7 @@ import * as crypto from 'crypto';
|
|
|
10
10
|
import { createClient } from '@supabase/supabase-js';
|
|
11
11
|
import ora from 'ora';
|
|
12
12
|
import { getPlatformPaths } from '../lib/platform-utils.js';
|
|
13
|
+
import { getGitRepoInfo } from '../lib/git-utils.js';
|
|
13
14
|
/**
|
|
14
15
|
* Register init commands
|
|
15
16
|
*/
|
|
@@ -101,9 +102,56 @@ async function runSetupWizard(options) {
|
|
|
101
102
|
]);
|
|
102
103
|
storageType = storage;
|
|
103
104
|
}
|
|
105
|
+
// Check if secrets already exist for this repo in the cloud
|
|
106
|
+
const cloudCheck = await checkCloudSecretsExist();
|
|
107
|
+
let encryptionKey;
|
|
108
|
+
if (cloudCheck.exists && cloudCheck.repoName) {
|
|
109
|
+
// Secrets found! This is an existing project
|
|
110
|
+
console.log(chalk.cyan(`\n✨ Found existing secrets for "${cloudCheck.repoName}" in cloud!`));
|
|
111
|
+
console.log(chalk.gray('This appears to be an existing project.'));
|
|
112
|
+
console.log('');
|
|
113
|
+
const { useExisting } = await inquirer.prompt([
|
|
114
|
+
{
|
|
115
|
+
type: 'confirm',
|
|
116
|
+
name: 'useExisting',
|
|
117
|
+
message: 'Pull existing secrets from another machine?',
|
|
118
|
+
default: true,
|
|
119
|
+
},
|
|
120
|
+
]);
|
|
121
|
+
if (useExisting) {
|
|
122
|
+
// Prompt for existing encryption key
|
|
123
|
+
const { existingKey } = await inquirer.prompt([
|
|
124
|
+
{
|
|
125
|
+
type: 'password',
|
|
126
|
+
name: 'existingKey',
|
|
127
|
+
message: 'Enter the encryption key from your other machine:',
|
|
128
|
+
mask: '*',
|
|
129
|
+
validate: (input) => {
|
|
130
|
+
if (!input.trim())
|
|
131
|
+
return 'Encryption key is required';
|
|
132
|
+
if (input.length !== 64)
|
|
133
|
+
return 'Key should be 64 characters (32 bytes hex)';
|
|
134
|
+
if (!/^[0-9a-f]+$/i.test(input))
|
|
135
|
+
return 'Key should be hexadecimal';
|
|
136
|
+
return true;
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
]);
|
|
140
|
+
encryptionKey = existingKey.trim();
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
// Generate new key (will overwrite existing)
|
|
144
|
+
console.log(chalk.yellow('\n⚠️ Generating new key will overwrite existing secrets!'));
|
|
145
|
+
encryptionKey = generateEncryptionKey();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
// No existing secrets - generate new key
|
|
150
|
+
encryptionKey = generateEncryptionKey();
|
|
151
|
+
}
|
|
104
152
|
const config = {
|
|
105
153
|
storageType,
|
|
106
|
-
encryptionKey
|
|
154
|
+
encryptionKey,
|
|
107
155
|
};
|
|
108
156
|
// Configure based on storage type
|
|
109
157
|
if (storageType === 'storacha') {
|
|
@@ -118,6 +166,20 @@ async function runSetupWizard(options) {
|
|
|
118
166
|
else {
|
|
119
167
|
await configureLocal(config);
|
|
120
168
|
}
|
|
169
|
+
// If using existing key and secrets exist, offer to pull them now
|
|
170
|
+
if (cloudCheck.exists && config.encryptionKey && cloudCheck.repoName) {
|
|
171
|
+
const { pullNow } = await inquirer.prompt([
|
|
172
|
+
{
|
|
173
|
+
type: 'confirm',
|
|
174
|
+
name: 'pullNow',
|
|
175
|
+
message: 'Pull secrets now?',
|
|
176
|
+
default: true,
|
|
177
|
+
},
|
|
178
|
+
]);
|
|
179
|
+
if (pullNow) {
|
|
180
|
+
await pullSecretsAfterInit(config.encryptionKey, cloudCheck.repoName);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
121
183
|
// Save configuration
|
|
122
184
|
await saveConfiguration(config);
|
|
123
185
|
// Show success message
|
|
@@ -139,6 +201,78 @@ async function checkExistingConfig() {
|
|
|
139
201
|
return false;
|
|
140
202
|
}
|
|
141
203
|
}
|
|
204
|
+
/**
|
|
205
|
+
* Pull secrets after init is complete
|
|
206
|
+
*/
|
|
207
|
+
async function pullSecretsAfterInit(encryptionKey, repoName) {
|
|
208
|
+
const spinner = ora('Pulling secrets from cloud...').start();
|
|
209
|
+
try {
|
|
210
|
+
// Dynamically import SecretsManager to avoid circular dependencies
|
|
211
|
+
const { SecretsManager } = await import('../lib/secrets-manager.js');
|
|
212
|
+
const secretsManager = new SecretsManager();
|
|
213
|
+
// Pull secrets for this repo
|
|
214
|
+
await secretsManager.pull('.env', '', false);
|
|
215
|
+
spinner.succeed(chalk.green('✅ Secrets pulled successfully!'));
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
spinner.fail(chalk.red('❌ Failed to pull secrets'));
|
|
219
|
+
const err = error;
|
|
220
|
+
console.log(chalk.yellow(`\n⚠️ ${err.message}`));
|
|
221
|
+
console.log(chalk.gray('\nYou can try pulling manually later with:'));
|
|
222
|
+
console.log(chalk.cyan(` lsh pull`));
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Check if secrets already exist in cloud for current repo
|
|
227
|
+
*/
|
|
228
|
+
async function checkCloudSecretsExist() {
|
|
229
|
+
try {
|
|
230
|
+
const gitInfo = getGitRepoInfo();
|
|
231
|
+
if (!gitInfo?.repoName) {
|
|
232
|
+
return { exists: false };
|
|
233
|
+
}
|
|
234
|
+
const repoName = gitInfo.repoName;
|
|
235
|
+
const environment = repoName; // Default environment for repo
|
|
236
|
+
// First check local metadata (fast path)
|
|
237
|
+
const paths = getPlatformPaths();
|
|
238
|
+
const metadataPath = path.join(paths.dataDir, 'secrets-metadata.json');
|
|
239
|
+
try {
|
|
240
|
+
const metadataContent = await fs.readFile(metadataPath, 'utf-8');
|
|
241
|
+
const metadata = JSON.parse(metadataContent);
|
|
242
|
+
// Check if any environment matches this repo name
|
|
243
|
+
const hasSecrets = Object.keys(metadata).some(env => env === repoName || env.startsWith(`${repoName}_`));
|
|
244
|
+
if (hasSecrets) {
|
|
245
|
+
return { exists: true, repoName, environment };
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
// Metadata file doesn't exist or can't be read - continue to network check
|
|
250
|
+
}
|
|
251
|
+
// Check Storacha network for registry file (works on new machines)
|
|
252
|
+
try {
|
|
253
|
+
const { getStorachaClient } = await import('../lib/storacha-client.js');
|
|
254
|
+
const storacha = getStorachaClient();
|
|
255
|
+
// Only check network if Storacha is enabled and authenticated
|
|
256
|
+
if (storacha.isEnabled() && await storacha.isAuthenticated()) {
|
|
257
|
+
const spinner = ora('Checking Storacha network for existing secrets...').start();
|
|
258
|
+
const registryExists = await storacha.checkRegistry(repoName);
|
|
259
|
+
spinner.stop();
|
|
260
|
+
if (registryExists) {
|
|
261
|
+
return { exists: true, repoName, environment };
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
// Network check failed, but that's okay - just means no secrets found
|
|
267
|
+
const err = error;
|
|
268
|
+
console.log(chalk.gray(` (Network check skipped: ${err.message})`));
|
|
269
|
+
}
|
|
270
|
+
return { exists: false, repoName, environment };
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
return { exists: false };
|
|
274
|
+
}
|
|
275
|
+
}
|
|
142
276
|
/**
|
|
143
277
|
* Configure Supabase
|
|
144
278
|
*/
|
|
@@ -72,6 +72,19 @@ export class IPFSSecretsStorage {
|
|
|
72
72
|
// encryptedData is already a Buffer, pass it directly
|
|
73
73
|
await storacha.upload(Buffer.from(encryptedData), filename);
|
|
74
74
|
logger.info(` ☁️ Synced to Storacha network`);
|
|
75
|
+
// Upload registry file if this is a git repo
|
|
76
|
+
// This allows detection on new machines without local metadata
|
|
77
|
+
if (gitRepo) {
|
|
78
|
+
try {
|
|
79
|
+
await storacha.uploadRegistry(gitRepo, environment);
|
|
80
|
+
logger.debug(` 📝 Registry uploaded for ${gitRepo}`);
|
|
81
|
+
}
|
|
82
|
+
catch (regError) {
|
|
83
|
+
// Registry upload failed, but secrets are still uploaded
|
|
84
|
+
const _regErr = regError;
|
|
85
|
+
logger.debug(` Registry upload failed: ${_regErr.message}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
75
88
|
}
|
|
76
89
|
catch (error) {
|
|
77
90
|
const err = error;
|
|
@@ -233,6 +233,102 @@ export class StorachaClient {
|
|
|
233
233
|
throw new Error(`Failed to download from Storacha: ${err.message}`);
|
|
234
234
|
}
|
|
235
235
|
}
|
|
236
|
+
/**
|
|
237
|
+
* Upload registry file for a repo
|
|
238
|
+
* Registry files mark that secrets exist for a repo without exposing the secrets themselves
|
|
239
|
+
*/
|
|
240
|
+
async uploadRegistry(repoName, environment) {
|
|
241
|
+
if (!this.isEnabled()) {
|
|
242
|
+
throw new Error('Storacha is not enabled');
|
|
243
|
+
}
|
|
244
|
+
if (!await this.isAuthenticated()) {
|
|
245
|
+
throw new Error('Not authenticated');
|
|
246
|
+
}
|
|
247
|
+
const registry = {
|
|
248
|
+
repoName,
|
|
249
|
+
environment,
|
|
250
|
+
timestamp: new Date().toISOString(),
|
|
251
|
+
version: '2.2.1',
|
|
252
|
+
};
|
|
253
|
+
const content = JSON.stringify(registry, null, 2);
|
|
254
|
+
const buffer = Buffer.from(content, 'utf-8');
|
|
255
|
+
const filename = `lsh-registry-${repoName}.json`;
|
|
256
|
+
const client = await this.getClient();
|
|
257
|
+
const uint8Array = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
|
258
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
259
|
+
const file = new File([uint8Array], filename, { type: 'application/json' });
|
|
260
|
+
const cid = await client.uploadFile(file);
|
|
261
|
+
logger.debug(`📝 Uploaded registry for ${repoName}: ${cid}`);
|
|
262
|
+
return cid.toString();
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Check if registry exists for a repo by listing uploads
|
|
266
|
+
* Returns true if a registry file for this repo exists in Storacha
|
|
267
|
+
*
|
|
268
|
+
* NOTE: This is optimized to check only recent small files (likely registry files)
|
|
269
|
+
* to avoid downloading large encrypted secret files.
|
|
270
|
+
*/
|
|
271
|
+
async checkRegistry(repoName) {
|
|
272
|
+
if (!this.isEnabled()) {
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
if (!await this.isAuthenticated()) {
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
try {
|
|
279
|
+
const client = await this.getClient();
|
|
280
|
+
// Only check recent uploads (limit to 20 for performance)
|
|
281
|
+
const pageSize = 20;
|
|
282
|
+
// Get first page of uploads
|
|
283
|
+
const results = await client.capability.upload.list({
|
|
284
|
+
cursor: '',
|
|
285
|
+
size: pageSize,
|
|
286
|
+
});
|
|
287
|
+
// Track checked count for logging
|
|
288
|
+
let checked = 0;
|
|
289
|
+
let skipped = 0;
|
|
290
|
+
// Check if any uploads match our registry pattern
|
|
291
|
+
// Registry files are small JSON files (~200 bytes)
|
|
292
|
+
// Skip large files (encrypted secrets are much larger)
|
|
293
|
+
for (const upload of results.results) {
|
|
294
|
+
try {
|
|
295
|
+
const cid = upload.root.toString();
|
|
296
|
+
// Quick heuristic: registry files are tiny (<1KB)
|
|
297
|
+
// Skip if this looks like a large encrypted file based on CID
|
|
298
|
+
// We'll attempt download with a timeout
|
|
299
|
+
const downloadPromise = this.download(cid);
|
|
300
|
+
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 5000) // 5s timeout per file
|
|
301
|
+
);
|
|
302
|
+
const content = await Promise.race([downloadPromise, timeoutPromise]);
|
|
303
|
+
// Skip large files (registry should be < 1KB)
|
|
304
|
+
if (content.length > 1024) {
|
|
305
|
+
skipped++;
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
checked++;
|
|
309
|
+
// Try to parse as JSON
|
|
310
|
+
const json = JSON.parse(content.toString('utf-8'));
|
|
311
|
+
// Check if it's an LSH registry file for our repo
|
|
312
|
+
if (json.repoName === repoName && json.version) {
|
|
313
|
+
logger.debug(`✅ Found registry for ${repoName} at CID: ${cid} (checked ${checked} files, skipped ${skipped})`);
|
|
314
|
+
return true;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
// Not an LSH registry file, timed out, or failed to download - continue
|
|
319
|
+
skipped++;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
// No registry found
|
|
323
|
+
logger.debug(`❌ No registry found for ${repoName} (checked ${checked} files, skipped ${skipped})`);
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
const err = error;
|
|
328
|
+
logger.debug(`Failed to check registry: ${err.message}`);
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
236
332
|
/**
|
|
237
333
|
* Enable Storacha network sync
|
|
238
334
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lsh-framework",
|
|
3
|
-
"version": "2.1
|
|
3
|
+
"version": "2.2.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": {
|