lsh-framework 3.2.1 → 3.2.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.
@@ -87,6 +87,7 @@ async function runSetupWizard(options) {
87
87
  // Check if secrets already exist locally
88
88
  const cloudCheck = await checkCloudSecretsExist();
89
89
  let encryptionKey;
90
+ let importedKey = false;
90
91
  if (cloudCheck.exists && cloudCheck.repoName) {
91
92
  // Secrets found! This is an existing project
92
93
  console.log(chalk.cyan(`\n✨ Found existing secrets for "${cloudCheck.repoName}" in cloud!`));
@@ -120,6 +121,7 @@ async function runSetupWizard(options) {
120
121
  },
121
122
  ]);
122
123
  encryptionKey = existingKey.trim();
124
+ importedKey = true;
123
125
  }
124
126
  else {
125
127
  // Generate new key (will overwrite existing)
@@ -128,28 +130,61 @@ async function runSetupWizard(options) {
128
130
  }
129
131
  }
130
132
  else {
131
- // No existing secrets - generate new key
132
- encryptionKey = generateEncryptionKey();
133
+ // No existing secrets found locally ask if joining an existing project
134
+ const { keyChoice } = await inquirer.prompt([
135
+ {
136
+ type: 'list',
137
+ name: 'keyChoice',
138
+ message: 'How would you like to set up this project?',
139
+ choices: [
140
+ { name: '🔑 Generate a new encryption key (new project)', value: 'generate' },
141
+ { name: '🤝 Import an existing key from a teammate', value: 'import' },
142
+ ],
143
+ },
144
+ ]);
145
+ if (keyChoice === 'import') {
146
+ const { existingKey } = await inquirer.prompt([
147
+ {
148
+ type: 'password',
149
+ name: 'existingKey',
150
+ message: 'Enter the LSH_SECRETS_KEY from your teammate:',
151
+ mask: '*',
152
+ validate: (input) => {
153
+ if (!input.trim())
154
+ return 'Encryption key is required';
155
+ if (input.length < 16)
156
+ return 'Key is too short (minimum 16 characters)';
157
+ return true;
158
+ },
159
+ },
160
+ ]);
161
+ encryptionKey = existingKey.trim();
162
+ importedKey = true;
163
+ }
164
+ else {
165
+ encryptionKey = generateEncryptionKey();
166
+ }
133
167
  }
134
168
  const config = {
135
169
  encryptionKey,
136
170
  };
137
- // If using existing key and secrets exist, offer to pull them now
138
- if (cloudCheck.exists && config.encryptionKey && cloudCheck.repoName) {
171
+ // Save configuration first so LSH_SECRETS_KEY is available for pull
172
+ await saveConfiguration(config, baseDir, globalMode);
173
+ // If user imported a key, offer to pull secrets via IPNS
174
+ if (importedKey && daemonRunning) {
139
175
  const { pullNow } = await inquirer.prompt([
140
176
  {
141
177
  type: 'confirm',
142
178
  name: 'pullNow',
143
- message: 'Pull secrets now?',
179
+ message: 'Pull secrets now via IPNS?',
144
180
  default: true,
145
181
  },
146
182
  ]);
147
183
  if (pullNow) {
148
- await pullSecretsAfterInit(config.encryptionKey, cloudCheck.repoName);
184
+ const gitInfo = getGitRepoInfo();
185
+ await pullSecretsAfterInit(config.encryptionKey, cloudCheck.repoName || gitInfo?.repoName || '');
149
186
  }
150
187
  }
151
- // Save configuration
152
- await saveConfiguration(config, baseDir, globalMode);
153
188
  // Show success message
154
189
  showSuccessMessage(config);
155
190
  }
@@ -333,18 +368,12 @@ function showSuccessMessage(config) {
333
368
  console.log(chalk.gray(' 1. (Optional) Start IPFS daemon for network sync:'));
334
369
  console.log(chalk.cyan(' lsh ipfs install && lsh ipfs init && lsh ipfs start'));
335
370
  console.log('');
336
- console.log(chalk.gray(' 2. Push your secrets to IPFS:'));
337
- console.log(chalk.cyan(' lsh sync push'));
338
- console.log(chalk.gray(' (Returns a CID - share this with teammates)'));
339
- console.log('');
340
- console.log(chalk.gray(' 3. On another machine:'));
341
- console.log(chalk.cyan(' lsh init'));
342
- console.log(chalk.cyan(' export LSH_SECRETS_KEY=<key-from-teammate>'));
343
- console.log(chalk.cyan(' lsh sync pull <cid>'));
371
+ console.log(chalk.gray(' 2. Push your secrets:'));
372
+ console.log(chalk.cyan(' lsh push'));
344
373
  console.log('');
345
- console.log(chalk.gray(' Alternatively, use the classic push/pull commands:'));
346
- console.log(chalk.cyan(' lsh push --env dev'));
347
- console.log(chalk.cyan(' lsh pull --env dev'));
374
+ console.log(chalk.gray(' 3. Teammates (share only the key, nothing else):'));
375
+ console.log(chalk.cyan(' lsh init # Choose "Import existing key"'));
376
+ console.log(chalk.cyan(' lsh pull # Auto-resolves latest via IPNS'));
348
377
  console.log('');
349
378
  console.log(chalk.gray('📖 Documentation: https://github.com/gwicho38/lsh'));
350
379
  console.log('');
@@ -45,8 +45,14 @@ export function registerSyncHistoryCommands(program) {
45
45
  if (options.url) {
46
46
  // Show URLs only
47
47
  for (const record of records) {
48
- const cid = `bafkrei${record.key_fingerprint.substring(0, 52)}`;
49
- console.log(`ipfs://${cid}`);
48
+ if (record.cid) {
49
+ console.log(`ipfs://${record.cid}`);
50
+ }
51
+ else {
52
+ // Old records without real CID
53
+ const recordId = record.key_fingerprint.substring(0, 16);
54
+ console.log(`lsh-record://${recordId} (local)`);
55
+ }
50
56
  }
51
57
  }
52
58
  else {
@@ -143,57 +143,15 @@ export function registerSyncCommands(program) {
143
143
  const ipfsSync = getIPFSSync();
144
144
  const gitInfo = getGitRepoInfo();
145
145
  // Step 1: Ensure IPFS is set up and daemon is running
146
- let daemonReady = await ipfsSync.checkDaemon();
147
- if (!daemonReady) {
148
- // Check if IPFS is installed
149
- const clientInfo = await manager.detect();
150
- if (!clientInfo.installed) {
151
- const installSpinner = ora('Installing IPFS client...').start();
152
- try {
153
- await manager.install();
154
- installSpinner.succeed(chalk.green('IPFS client installed'));
155
- }
156
- catch (error) {
157
- const err = error;
158
- installSpinner.fail(chalk.red('Failed to install IPFS'));
159
- console.error(chalk.red(err.message));
160
- process.exit(1);
161
- }
162
- }
163
- // Initialize repo if needed
164
- const initSpinner = ora('Initializing IPFS...').start();
165
- try {
166
- await manager.init();
167
- initSpinner.succeed(chalk.green('IPFS initialized'));
168
- }
169
- catch (error) {
170
- const err = error;
171
- if (!err.message.includes('already') && !err.message.includes('exists')) {
172
- initSpinner.fail(chalk.red('Failed to initialize IPFS'));
173
- console.error(chalk.red(err.message));
174
- process.exit(1);
175
- }
176
- initSpinner.succeed(chalk.green('IPFS already initialized'));
177
- }
178
- // Start daemon
179
- const startSpinner = ora('Starting IPFS daemon...').start();
180
- try {
181
- await manager.start();
182
- // Give daemon a moment to start accepting connections
183
- await new Promise(resolve => { setTimeout(resolve, 2000); });
184
- startSpinner.succeed(chalk.green('IPFS daemon started'));
185
- daemonReady = true;
186
- }
187
- catch (error) {
188
- const err = error;
189
- startSpinner.fail(chalk.red('Failed to start daemon'));
190
- console.error(chalk.red(err.message));
191
- process.exit(1);
192
- }
193
- }
194
- else {
146
+ try {
147
+ await manager.ensureDaemonRunning();
195
148
  console.log(chalk.green('✓ IPFS daemon running'));
196
149
  }
150
+ catch (error) {
151
+ const err = error;
152
+ console.error(chalk.red(err.message));
153
+ process.exit(1);
154
+ }
197
155
  // Step 2: Read and validate .env file
198
156
  const envPath = path.resolve(options.file);
199
157
  if (!fs.existsSync(envPath)) {
@@ -287,12 +245,14 @@ export function registerSyncCommands(program) {
287
245
  try {
288
246
  const ipfsSync = getIPFSSync();
289
247
  const gitInfo = getGitRepoInfo();
290
- // Check daemon
291
- if (!await ipfsSync.checkDaemon()) {
292
- spinner.fail(chalk.red('IPFS daemon not running'));
293
- console.log('');
294
- console.log(chalk.gray('Initialize IPFS first:'));
295
- console.log(chalk.cyan(' lsh sync init'));
248
+ // Ensure daemon is running (auto-setup if needed)
249
+ try {
250
+ const ipfsManager = new IPFSClientManager();
251
+ await ipfsManager.ensureDaemonRunning();
252
+ }
253
+ catch (error) {
254
+ const err = error;
255
+ spinner.fail(chalk.red(err.message));
296
256
  process.exit(1);
297
257
  }
298
258
  // Read .env file
@@ -391,9 +351,13 @@ export function registerSyncCommands(program) {
391
351
  if (!cid) {
392
352
  try {
393
353
  const ipfsSync = getIPFSSync();
394
- if (!await ipfsSync.checkDaemon()) {
395
- spinner.fail(chalk.red('IPFS daemon not running'));
396
- console.log(chalk.gray(' Start with: lsh sync start'));
354
+ try {
355
+ const ipfsManager = new IPFSClientManager();
356
+ await ipfsManager.ensureDaemonRunning();
357
+ }
358
+ catch (error) {
359
+ const err = error;
360
+ spinner.fail(chalk.red(err.message));
397
361
  process.exit(1);
398
362
  }
399
363
  // Get encryption key
@@ -7,8 +7,10 @@ import * as path from 'path';
7
7
  import * as os from 'os';
8
8
  import { exec, spawn } from 'child_process';
9
9
  import { promisify } from 'util';
10
+ import * as readline from 'readline';
10
11
  import { createLogger } from './logger.js';
11
12
  import { getPlatformInfo } from './platform-utils.js';
13
+ import { getLshConfig } from './lsh-config.js';
12
14
  const execAsync = promisify(exec);
13
15
  const logger = createLogger('IPFSClientManager');
14
16
  /**
@@ -364,6 +366,82 @@ export class IPFSClientManager {
364
366
  fs.unlinkSync(zipPath);
365
367
  fs.rmSync(path.join(this.ipfsDir, 'kubo'), { recursive: true });
366
368
  }
369
+ /**
370
+ * Ensure IPFS daemon is installed and running.
371
+ * Prompts once for install consent, then auto-manages daemon lifecycle.
372
+ */
373
+ async ensureDaemonRunning() {
374
+ // Step 1: Check if daemon is already running
375
+ const isRunning = await this.isDaemonRunning();
376
+ if (isRunning) {
377
+ return;
378
+ }
379
+ // Step 2: Check if Kubo is installed
380
+ const clientInfo = await this.detect();
381
+ const config = getLshConfig();
382
+ if (!clientInfo.installed) {
383
+ if (config.getIpfsConsent()) {
384
+ logger.info('📦 Installing IPFS client (Kubo)...');
385
+ await this.install();
386
+ await this.init();
387
+ }
388
+ else {
389
+ if (!process.stdin.isTTY) {
390
+ throw new Error('IPFS (Kubo) is required for sync but is not installed.\n' +
391
+ 'Install manually: lsh ipfs install && lsh ipfs start');
392
+ }
393
+ const consent = await this.promptUser('IPFS (Kubo) is required for sync. Install now? [Y/n] ');
394
+ if (consent.toLowerCase() === 'n') {
395
+ throw new Error('IPFS is required for push/pull.\n' +
396
+ 'Install manually: lsh ipfs install && lsh ipfs start');
397
+ }
398
+ logger.info('📦 Installing IPFS client (Kubo)...');
399
+ await this.install();
400
+ await this.init();
401
+ config.setIpfsConsent(true);
402
+ }
403
+ }
404
+ // Step 3: Start daemon
405
+ logger.info('🚀 Starting IPFS daemon...');
406
+ await this.start();
407
+ // Step 4: Wait for API readiness
408
+ const ready = await this.waitForDaemon(15000);
409
+ if (!ready) {
410
+ throw new Error('IPFS daemon started but API is not responding.\n' +
411
+ 'Try manually: lsh ipfs start');
412
+ }
413
+ logger.info('✅ IPFS daemon ready');
414
+ }
415
+ /**
416
+ * Check if daemon API is responding
417
+ */
418
+ async isDaemonRunning() {
419
+ try {
420
+ const response = await fetch('http://127.0.0.1:5001/api/v0/id', {
421
+ method: 'POST',
422
+ signal: AbortSignal.timeout(3000),
423
+ });
424
+ return response.ok;
425
+ }
426
+ catch {
427
+ return false;
428
+ }
429
+ }
430
+ /**
431
+ * Prompt user for input via readline
432
+ */
433
+ promptUser(question) {
434
+ return new Promise((resolve) => {
435
+ const rl = readline.createInterface({
436
+ input: process.stdin,
437
+ output: process.stdout,
438
+ });
439
+ rl.question(question, (answer) => {
440
+ rl.close();
441
+ resolve(answer || 'y');
442
+ });
443
+ });
444
+ }
367
445
  /**
368
446
  * Ensure required directories exist
369
447
  */
@@ -63,11 +63,16 @@ export class IPFSSecretsStorage {
63
63
  try {
64
64
  // Encrypt secrets
65
65
  const encryptedData = this.encryptSecrets(secrets, encryptionKey);
66
- // Generate CID from encrypted content
67
- const cid = this.generateCID(encryptedData);
68
- // Store locally (cache)
66
+ // Upload to IPFS (daemon guaranteed running by ensureDaemonRunning)
67
+ const ipfsSync = getIPFSSync();
68
+ const filename = `lsh-secrets-${environment}.encrypted`;
69
+ const cid = await ipfsSync.upload(Buffer.from(encryptedData, 'utf-8'), filename, { environment, gitRepo });
70
+ if (!cid) {
71
+ throw new Error('IPFS upload failed. Is the daemon running? Check: lsh ipfs status');
72
+ }
73
+ // Cache locally for fast re-reads
69
74
  await this.storeLocally(cid, encryptedData, environment);
70
- // Update metadata
75
+ // Update metadata with real CID
71
76
  const metadata = {
72
77
  environment,
73
78
  git_repo: gitRepo,
@@ -84,40 +89,15 @@ export class IPFSSecretsStorage {
84
89
  if (gitRepo) {
85
90
  logger.info(` Repository: ${gitRepo}/${gitBranch || 'main'}`);
86
91
  }
87
- // Try native IPFS upload
88
- const ipfsSync = getIPFSSync();
89
- let uploadedToNetwork = false;
90
- let realCid = null;
91
- if (await ipfsSync.checkDaemon()) {
92
- try {
93
- const filename = `lsh-secrets-${environment}.encrypted`;
94
- realCid = await ipfsSync.upload(Buffer.from(encryptedData, 'utf-8'), filename, { environment, gitRepo });
95
- if (realCid) {
96
- // Update CID to the real IPFS CID
97
- logger.info(` 🌐 Synced to IPFS (CID: ${realCid})`);
98
- uploadedToNetwork = true;
99
- // Update metadata with real CID if different
100
- if (realCid !== cid) {
101
- metadata.cid = realCid;
102
- this.metadata[this.getMetadataKey(gitRepo, environment)] = metadata;
103
- await this.saveMetadata();
104
- }
105
- }
106
- }
107
- catch (error) {
108
- const err = error;
109
- logger.warn(` ⚠️ IPFS upload failed: ${err.message}`);
110
- }
111
- }
112
- // Publish to IPNS if we uploaded to the network
113
- if (uploadedToNetwork && realCid && encryptionKey) {
92
+ // Publish to IPNS
93
+ if (encryptionKey) {
114
94
  try {
115
95
  const repoName = gitRepo || DEFAULTS.DEFAULT_ENVIRONMENT;
116
96
  const env = environment || DEFAULTS.DEFAULT_ENVIRONMENT;
117
97
  const keyInfo = deriveKeyInfo(encryptionKey, repoName, env);
118
98
  const ipnsName = await ensureKeyImported(ipfsSync.getApiUrl(), keyInfo);
119
99
  if (ipnsName) {
120
- const publishedName = await ipfsSync.publishToIPNS(realCid, keyInfo.keyName);
100
+ const publishedName = await ipfsSync.publishToIPNS(cid, keyInfo.keyName);
121
101
  if (publishedName) {
122
102
  metadata.ipns_name = publishedName;
123
103
  this.metadata[this.getMetadataKey(gitRepo, environment)] = metadata;
@@ -128,14 +108,11 @@ export class IPFSSecretsStorage {
128
108
  }
129
109
  catch (error) {
130
110
  const err = error;
131
- logger.warn(` ⚠️ IPNS publish failed (non-fatal): ${err.message}`);
111
+ logger.error(`Content uploaded (CID: ${cid}) but IPNS publish failed: ${err.message}\n` +
112
+ `Other machines won't find it via 'lsh pull' until you re-push.`);
132
113
  }
133
114
  }
134
- if (!uploadedToNetwork) {
135
- logger.warn(` 📁 Secrets cached locally only (no network sync)`);
136
- logger.warn(` 💡 Start IPFS daemon for network sync: lsh ipfs start`);
137
- }
138
- return realCid || cid;
115
+ return cid;
139
116
  }
140
117
  catch (error) {
141
118
  const err = error;
@@ -148,89 +125,61 @@ export class IPFSSecretsStorage {
148
125
  */
149
126
  async pull(environment, encryptionKey, gitRepo) {
150
127
  try {
151
- const metadataKey = this.getMetadataKey(gitRepo, environment);
152
- let metadata = this.metadata[metadataKey];
153
- // Construct display name for error messages
154
- const displayEnv = gitRepo
155
- ? (environment ? `${gitRepo}_${environment}` : gitRepo)
156
- : (environment || 'default');
157
- // If no local metadata, try IPNS resolution first
158
- if (!metadata && encryptionKey) {
128
+ const ipfsSync = getIPFSSync();
129
+ // Step 1: Always resolve via IPNS (source of truth)
130
+ let resolvedCid = null;
131
+ if (encryptionKey) {
159
132
  try {
160
- const ipfsSync = getIPFSSync();
161
- if (await ipfsSync.checkDaemon()) {
162
- const repoName = gitRepo || DEFAULTS.DEFAULT_ENVIRONMENT;
163
- const env = environment || DEFAULTS.DEFAULT_ENVIRONMENT;
164
- const keyInfo = deriveKeyInfo(encryptionKey, repoName, env);
165
- const ipnsName = await ensureKeyImported(ipfsSync.getApiUrl(), keyInfo);
166
- if (ipnsName) {
167
- logger.info(` 🔍 Resolving via IPNS: ${ipnsName.substring(0, 20)}...`);
168
- const resolvedCid = await ipfsSync.resolveIPNS(ipnsName);
169
- if (resolvedCid) {
170
- logger.info(` ✅ IPNS resolved to CID: ${resolvedCid}`);
171
- metadata = {
172
- environment,
173
- git_repo: gitRepo,
174
- cid: resolvedCid,
175
- ipns_name: ipnsName,
176
- timestamp: new Date().toISOString(),
177
- keys_count: 0,
178
- encrypted: true,
179
- };
180
- this.metadata[metadataKey] = metadata;
181
- await this.saveMetadata();
182
- }
133
+ const repoName = gitRepo || DEFAULTS.DEFAULT_ENVIRONMENT;
134
+ const env = environment || DEFAULTS.DEFAULT_ENVIRONMENT;
135
+ const keyInfo = deriveKeyInfo(encryptionKey, repoName, env);
136
+ const ipnsName = await ensureKeyImported(ipfsSync.getApiUrl(), keyInfo);
137
+ if (ipnsName) {
138
+ logger.info(` 🔍 Resolving via IPNS: ${ipnsName.substring(0, 20)}...`);
139
+ resolvedCid = await ipfsSync.resolveIPNS(ipnsName);
140
+ if (resolvedCid) {
141
+ logger.info(` ✅ IPNS resolved to CID: ${resolvedCid}`);
142
+ // Update local metadata
143
+ const metadataKey = this.getMetadataKey(gitRepo, environment);
144
+ this.metadata[metadataKey] = {
145
+ environment,
146
+ git_repo: gitRepo,
147
+ cid: resolvedCid,
148
+ ipns_name: ipnsName,
149
+ timestamp: new Date().toISOString(),
150
+ keys_count: 0,
151
+ encrypted: true,
152
+ };
153
+ await this.saveMetadata();
183
154
  }
184
155
  }
185
156
  }
186
157
  catch (error) {
187
158
  const err = error;
188
- logger.debug(` IPNS resolution failed: ${err.message}`);
159
+ logger.debug(` IPNS resolution error: ${err.message}`);
189
160
  }
190
161
  }
191
- // If still no metadata, check IPFS sync history
192
- if (!metadata && gitRepo) {
193
- try {
194
- const ipfsSync = getIPFSSync();
195
- const latestCid = await ipfsSync.getLatestCid(gitRepo, environment);
196
- if (latestCid) {
197
- logger.info(` ✅ Found secrets in IPFS history (CID: ${latestCid})`);
198
- // Create metadata from history
199
- metadata = {
200
- environment,
201
- git_repo: gitRepo,
202
- cid: latestCid,
203
- timestamp: new Date().toISOString(),
204
- keys_count: 0, // Unknown until decrypted
205
- encrypted: true,
206
- };
207
- this.metadata[metadataKey] = metadata;
208
- await this.saveMetadata();
209
- }
210
- }
211
- catch (error) {
212
- // History check failed, continue to error
213
- const err = error;
214
- logger.debug(` IPFS history check failed: ${err.message}`);
215
- }
216
- }
217
- if (!metadata) {
218
- throw new Error(`No secrets found for environment: ${displayEnv}\n\n` +
219
- `💡 Tip: Check available environments with: lsh env\n` +
220
- ` Or push secrets first with: lsh push\n` +
221
- ` Or pull by CID with: lsh sync pull <cid>`);
162
+ // No fallback to local metadata IPNS is the source of truth
163
+ if (!resolvedCid) {
164
+ throw new Error('Could not resolve secrets from network.\n\n' +
165
+ 'Possible causes:\n' +
166
+ ' - The machine that pushed secrets is offline or IPFS daemon stopped\n' +
167
+ ' - IPNS record hasn\'t propagated yet (try again in 30s)\n' +
168
+ ' - Network connectivity issue\n' +
169
+ ' - IPNS record expired from DHT (records are cached ~24-48h;\n' +
170
+ ' the publishing machine must be online periodically)\n\n' +
171
+ 'Troubleshooting:\n' +
172
+ ' lsh ipfs status # Check local daemon\n' +
173
+ ' lsh doctor # Full health check');
222
174
  }
223
- // Try to load from local cache
224
- let cachedData = await this.loadLocally(metadata.cid);
225
- // If not in cache, try downloading from IPFS
175
+ // Step 2: Load content try cache first, then IPFS download
176
+ let cachedData = await this.loadLocally(resolvedCid);
226
177
  if (!cachedData) {
227
- const ipfsSync = getIPFSSync();
228
178
  try {
229
179
  logger.info(` 🌐 Downloading from IPFS...`);
230
- const downloadedData = await ipfsSync.download(metadata.cid);
180
+ const downloadedData = await ipfsSync.download(resolvedCid);
231
181
  if (downloadedData) {
232
- // Store in local cache for future use
233
- await this.storeLocally(metadata.cid, downloadedData.toString('utf-8'), environment);
182
+ await this.storeLocally(resolvedCid, downloadedData.toString('utf-8'), environment);
234
183
  cachedData = downloadedData.toString('utf-8');
235
184
  logger.info(` ✅ Downloaded and cached from IPFS`);
236
185
  }
@@ -241,14 +190,13 @@ export class IPFSSecretsStorage {
241
190
  }
242
191
  }
243
192
  if (!cachedData) {
244
- throw new Error(`Secrets not found in cache or IPFS. CID: ${metadata.cid}\n\n` +
245
- `💡 Tip: Start IPFS daemon: lsh ipfs start\n` +
246
- ` Or pull directly by CID: lsh sync pull <cid>`);
193
+ throw new Error(`Secrets resolved via IPNS (CID: ${resolvedCid}) but download failed.\n\n` +
194
+ `💡 The source machine may be offline. Try again when it's online.`);
247
195
  }
248
- // Decrypt secrets
196
+ // Step 3: Decrypt
249
197
  const secrets = this.decryptSecrets(cachedData, encryptionKey);
250
198
  logger.info(`📥 Retrieved ${secrets.length} secrets from IPFS`);
251
- logger.info(` CID: ${metadata.cid}`);
199
+ logger.info(` CID: ${resolvedCid}`);
252
200
  logger.info(` Environment: ${environment}`);
253
201
  return secrets;
254
202
  }
@@ -344,14 +292,6 @@ export class IPFSSecretsStorage {
344
292
  throw error;
345
293
  }
346
294
  }
347
- /**
348
- * Generate IPFS-compatible CID from content
349
- */
350
- generateCID(content) {
351
- const hash = crypto.createHash('sha256').update(content).digest('hex');
352
- // Format like IPFS CIDv1 (bafkreixxx...)
353
- return `bafkrei${hash.substring(0, 52)}`;
354
- }
355
295
  /**
356
296
  * Store encrypted data locally
357
297
  */
@@ -64,7 +64,7 @@ export class IPFSSyncLogger {
64
64
  };
65
65
  // Use file-based storage with IPFS-like CIDs
66
66
  // Records are stored locally and can optionally be uploaded to IPFS
67
- const cid = this.generateContentId(record);
67
+ const cid = this.generateRecordId(record);
68
68
  const repoEnv = this.getRepoEnvKey(record.git_repo, record.environment);
69
69
  // Store the record locally
70
70
  await this.storeRecordLocally(cid, record);
@@ -73,9 +73,9 @@ export class IPFSSyncLogger {
73
73
  this.syncLog[repoEnv] = [];
74
74
  }
75
75
  this.syncLog[repoEnv].push({
76
- cid,
76
+ cid, // This is the record ID (file key), NOT the IPFS CID
77
77
  timestamp: record.timestamp,
78
- url: `ipfs://${cid}`,
78
+ url: record.cid ? `ipfs://${record.cid}` : `lsh-record://${cid}`,
79
79
  action: record.action,
80
80
  });
81
81
  // Save sync log
@@ -138,7 +138,7 @@ export class IPFSSyncLogger {
138
138
  /**
139
139
  * Generate a content-addressed ID (like IPFS CID)
140
140
  */
141
- generateContentId(record) {
141
+ generateRecordId(record) {
142
142
  const content = JSON.stringify(record);
143
143
  const hash = crypto.createHash('sha256').update(content).digest('hex');
144
144
  // Format like IPFS CIDv1 (bafkreixxx...)
@@ -346,31 +346,41 @@ export class IPFSSync {
346
346
  /**
347
347
  * Publish a CID to IPNS under the given key name.
348
348
  * The key must already be imported into Kubo.
349
+ * Publishes to DHT and blocks until confirmed.
350
+ * Retries once on failure.
349
351
  * Returns the IPNS name on success, null on failure.
350
352
  */
351
353
  async publishToIPNS(cid, keyName) {
352
- try {
353
- // allow-offline=true stores the record locally and returns immediately;
354
- // the daemon propagates it to the DHT in the background.
355
- // Without this, first publishes can take 60-90s for DHT propagation.
356
- const response = await fetch(`${this.LOCAL_IPFS_API}/name/publish?arg=${cid}&key=${encodeURIComponent(keyName)}&lifetime=87600h&resolve=false&offline=true`, {
357
- method: 'POST',
358
- signal: AbortSignal.timeout(15000),
359
- });
360
- if (!response.ok) {
361
- const errorText = await response.text();
362
- logger.warn(`IPNS publish failed: ${errorText}`);
363
- return null;
354
+ const url = `${this.LOCAL_IPFS_API}/name/publish?arg=${cid}&key=${encodeURIComponent(keyName)}&lifetime=87600h&resolve=false&offline=false&allow-offline=false`;
355
+ for (let attempt = 1; attempt <= 2; attempt++) {
356
+ try {
357
+ logger.info('📡 Publishing to IPNS (waiting for network confirmation)...');
358
+ const response = await fetch(url, {
359
+ method: 'POST',
360
+ signal: AbortSignal.timeout(120000),
361
+ });
362
+ if (!response.ok) {
363
+ const errorText = await response.text();
364
+ logger.warn(`IPNS publish failed (attempt ${attempt}): ${errorText}`);
365
+ if (attempt === 2)
366
+ return null;
367
+ continue;
368
+ }
369
+ const data = await response.json();
370
+ logger.info(`📡 IPNS published: ${data.Name} → ${data.Value}`);
371
+ return data.Name;
372
+ }
373
+ catch (error) {
374
+ const err = error;
375
+ logger.warn(`IPNS publish error (attempt ${attempt}): ${err.message}`);
376
+ if (attempt === 2) {
377
+ logger.error(`IPNS publish failed after retry. Content is uploaded (CID: ${cid}) ` +
378
+ `but other machines won't find it via 'lsh pull' until you re-push.`);
379
+ return null;
380
+ }
364
381
  }
365
- const data = await response.json();
366
- logger.info(`📡 IPNS published: ${data.Name} → ${data.Value}`);
367
- return data.Name;
368
- }
369
- catch (error) {
370
- const err = error;
371
- logger.debug(`IPNS publish error: ${err.message}`);
372
- return null;
373
382
  }
383
+ return null;
374
384
  }
375
385
  /**
376
386
  * Resolve an IPNS name to its current CID.
@@ -126,6 +126,13 @@ export class LshConfigManager {
126
126
  getConfigPath() {
127
127
  return this.configPath;
128
128
  }
129
+ getIpfsConsent() {
130
+ return this.config.ipfs_consent === true;
131
+ }
132
+ setIpfsConsent(value) {
133
+ this.config.ipfs_consent = value;
134
+ this.saveConfig();
135
+ }
129
136
  }
130
137
  // Singleton instance
131
138
  let _configManager = null;
@@ -299,7 +299,7 @@ export class SecretsManager {
299
299
  logger.info(`✅ Pushed ${secrets.length} secrets from ${filename} to IPFS`);
300
300
  console.log(`📦 IPFS CID: ${cid}`);
301
301
  // Log to IPFS for immutable audit record
302
- await this.logToIPFS('push', effectiveEnv, secrets.length);
302
+ await this.logToIPFS('push', effectiveEnv, secrets.length, cid);
303
303
  }
304
304
  /**
305
305
  * Pull .env from IPFS
@@ -961,7 +961,7 @@ LSH_SECRETS_KEY=${this.encryptionKey}
961
961
  /**
962
962
  * Log sync operation to IPFS for immutable record
963
963
  */
964
- async logToIPFS(action, environment, keysCount) {
964
+ async logToIPFS(action, environment, keysCount, realCid) {
965
965
  try {
966
966
  const ipfsLogger = new IPFSSyncLogger();
967
967
  if (!ipfsLogger.isEnabled()) {
@@ -971,10 +971,13 @@ LSH_SECRETS_KEY=${this.encryptionKey}
971
971
  action,
972
972
  environment: this.getRepoAwareEnvironment(environment),
973
973
  keys_count: keysCount,
974
+ cid: realCid,
974
975
  });
975
976
  if (cid) {
976
- console.log(`📝 Recorded on IPFS: ipfs://${cid}`);
977
- console.log(` View: https://ipfs.io/ipfs/${cid}`);
977
+ if (realCid) {
978
+ console.log(`📝 Recorded on IPFS: ipfs://${realCid}`);
979
+ console.log(` View: https://ipfs.io/ipfs/${realCid}`);
980
+ }
978
981
  }
979
982
  }
980
983
  catch (error) {
@@ -8,6 +8,7 @@ import * as path from 'path';
8
8
  import * as readline from 'readline';
9
9
  import { getGitRepoInfo } from '../../lib/git-utils.js';
10
10
  import { ENV_VARS } from '../../constants/index.js';
11
+ import { IPFSClientManager } from '../../lib/ipfs-client-manager.js';
11
12
  /**
12
13
  * Type guard to check if a string is a valid OutputFormat.
13
14
  */
@@ -26,6 +27,8 @@ export async function init_secrets(program) {
26
27
  .action(async (options) => {
27
28
  const manager = new SecretsManager({ globalMode: options.global });
28
29
  try {
30
+ const ipfsManager = new IPFSClientManager();
31
+ await ipfsManager.ensureDaemonRunning();
29
32
  // Resolve file path (handles global mode)
30
33
  const filePath = manager.resolveFilePath(options.file);
31
34
  // v2.0: Use context-aware default environment
@@ -53,6 +56,8 @@ export async function init_secrets(program) {
53
56
  .action(async (options) => {
54
57
  const manager = new SecretsManager({ globalMode: options.global });
55
58
  try {
59
+ const ipfsManager = new IPFSClientManager();
60
+ await ipfsManager.ensureDaemonRunning();
56
61
  // Resolve file path (handles global mode)
57
62
  const filePath = manager.resolveFilePath(options.file);
58
63
  // v2.0: Use context-aware default environment
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lsh-framework",
3
- "version": "3.2.1",
3
+ "version": "3.2.3",
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": {