lsh-framework 3.2.2 → 3.2.4

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.
@@ -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,12 +8,47 @@ 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
  */
14
15
  function isOutputFormat(value) {
15
16
  return ['env', 'json', 'yaml', 'toml', 'export'].includes(value);
16
17
  }
18
+ /**
19
+ * Find existing LSH_SECRETS_KEY from environment, local .env, or global ~/.env
20
+ */
21
+ function findExistingKey() {
22
+ // 1. Check environment variable
23
+ const envKey = process.env[ENV_VARS.LSH_SECRETS_KEY];
24
+ if (envKey)
25
+ return envKey;
26
+ // 2. Check local .env
27
+ const localEnvPath = path.join(process.cwd(), '.env');
28
+ const localKey = readKeyFromEnvFile(localEnvPath);
29
+ if (localKey)
30
+ return localKey;
31
+ // 3. Check global ~/.env
32
+ const globalEnvPath = path.join(process.env.HOME || '~', '.env');
33
+ const globalKey = readKeyFromEnvFile(globalEnvPath);
34
+ if (globalKey)
35
+ return globalKey;
36
+ return null;
37
+ }
38
+ function readKeyFromEnvFile(envPath) {
39
+ try {
40
+ if (fs.existsSync(envPath)) {
41
+ const content = fs.readFileSync(envPath, 'utf-8');
42
+ const match = content.match(/^LSH_SECRETS_KEY=['"]?([^'"\n]+)['"]?/m);
43
+ if (match)
44
+ return match[1];
45
+ }
46
+ }
47
+ catch {
48
+ // Ignore read errors
49
+ }
50
+ return null;
51
+ }
17
52
  export async function init_secrets(program) {
18
53
  // Push secrets to cloud
19
54
  program
@@ -26,6 +61,8 @@ export async function init_secrets(program) {
26
61
  .action(async (options) => {
27
62
  const manager = new SecretsManager({ globalMode: options.global });
28
63
  try {
64
+ const ipfsManager = new IPFSClientManager();
65
+ await ipfsManager.ensureDaemonRunning();
29
66
  // Resolve file path (handles global mode)
30
67
  const filePath = manager.resolveFilePath(options.file);
31
68
  // v2.0: Use context-aware default environment
@@ -53,6 +90,8 @@ export async function init_secrets(program) {
53
90
  .action(async (options) => {
54
91
  const manager = new SecretsManager({ globalMode: options.global });
55
92
  try {
93
+ const ipfsManager = new IPFSClientManager();
94
+ await ipfsManager.ensureDaemonRunning();
56
95
  // Resolve file path (handles global mode)
57
96
  const filePath = manager.resolveFilePath(options.file);
58
97
  // v2.0: Use context-aware default environment
@@ -259,25 +298,153 @@ export async function init_secrets(program) {
259
298
  process.exit(1);
260
299
  }
261
300
  });
262
- // Generate encryption key
263
- program
301
+ // Key management command group
302
+ const keyCmd = program
264
303
  .command('key')
304
+ .description('Manage your LSH encryption key');
305
+ // Default action: show existing key or prompt to generate
306
+ keyCmd
307
+ .action(async () => {
308
+ const existingKey = findExistingKey();
309
+ if (existingKey) {
310
+ const masked = existingKey.slice(0, 8) + '...' + existingKey.slice(-4);
311
+ console.log(`\n🔑 LSH_SECRETS_KEY=${masked}\n`);
312
+ console.log(' lsh key show --no-mask Reveal full key');
313
+ console.log(' lsh key generate Generate a new key');
314
+ console.log(' lsh key import Import a key from a teammate\n');
315
+ }
316
+ else {
317
+ console.log('\n⚠️ No encryption key found.\n');
318
+ console.log(' lsh key generate Create a new key');
319
+ console.log(' lsh key import Import a key from a teammate\n');
320
+ }
321
+ });
322
+ // lsh key show
323
+ keyCmd
324
+ .command('show')
325
+ .description('Display the current encryption key')
326
+ .option('--no-mask', 'Show the full key (default: masked)')
327
+ .option('--export', 'Output in export format for shell evaluation')
328
+ .action(async (options) => {
329
+ const existingKey = findExistingKey();
330
+ if (!existingKey) {
331
+ console.error('❌ No encryption key found. Run: lsh key generate');
332
+ process.exit(1);
333
+ }
334
+ if (options.export) {
335
+ console.log(`export LSH_SECRETS_KEY='${existingKey}'`);
336
+ return;
337
+ }
338
+ const displayKey = options.mask === false ? existingKey : existingKey.slice(0, 8) + '...' + existingKey.slice(-4);
339
+ console.log(`\n🔑 LSH_SECRETS_KEY=${displayKey}\n`);
340
+ if (options.mask !== false) {
341
+ console.log('💡 Use --no-mask to reveal the full key');
342
+ }
343
+ console.log('💡 Share this key securely with your team to sync secrets.');
344
+ console.log(' Never commit it to git!\n');
345
+ });
346
+ // lsh key generate
347
+ keyCmd
348
+ .command('generate')
265
349
  .description('Generate a new encryption key')
350
+ .option('--force', 'Overwrite existing key')
266
351
  .option('--export', 'Output in export format for shell evaluation')
267
352
  .action(async (options) => {
353
+ const existingKey = findExistingKey();
354
+ if (existingKey && !options.force) {
355
+ console.error('❌ An encryption key already exists. Use --force to overwrite.');
356
+ console.error('⚠️ Warning: existing secrets will NOT be decryptable with a new key.\n');
357
+ process.exit(1);
358
+ }
268
359
  const { randomBytes } = await import('crypto');
269
360
  const key = randomBytes(32).toString('hex');
270
361
  if (options.export) {
271
- // Just output the export statement for eval
272
362
  console.log(`export LSH_SECRETS_KEY='${key}'`);
363
+ return;
273
364
  }
274
- else {
275
- // Interactive output with tips
276
- console.log('\n🔑 New encryption key (add to your .env):\n');
277
- console.log(`export LSH_SECRETS_KEY='${key}'\n`);
278
- console.log('💡 Tip: Share this key securely with your team to sync secrets.');
279
- console.log(' Never commit it to git!\n');
280
- console.log('💡 To load immediately: eval "$(lsh key --export)"\n');
365
+ if (existingKey && options.force) {
366
+ console.log('\n⚠️ Replacing existing key. Old secrets will NOT be decryptable with this new key!\n');
367
+ }
368
+ console.log('\n🔑 New encryption key (add to your .env):\n');
369
+ console.log(`export LSH_SECRETS_KEY='${key}'\n`);
370
+ console.log('💡 Tip: Share this key securely with your team to sync secrets.');
371
+ console.log(' Never commit it to git!\n');
372
+ console.log('💡 To load immediately: eval "$(lsh key generate --export)"\n');
373
+ });
374
+ // lsh key import
375
+ keyCmd
376
+ .command('import')
377
+ .description('Import an encryption key from a teammate')
378
+ .argument('[key]', 'The encryption key to import')
379
+ .option('-f, --file <path>', 'Path to .env file to save to', '.env')
380
+ .option('-g, --global', 'Save to global ~/.env instead of local')
381
+ .action(async (keyArg, options) => {
382
+ let keyValue = keyArg;
383
+ // If no key argument, prompt for it
384
+ if (!keyValue) {
385
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
386
+ keyValue = await new Promise((resolve) => {
387
+ rl.question('🔑 Paste the encryption key: ', (answer) => {
388
+ rl.close();
389
+ resolve(answer.trim());
390
+ });
391
+ });
392
+ }
393
+ if (!keyValue || keyValue.length === 0) {
394
+ console.error('❌ No key provided.');
395
+ process.exit(1);
396
+ }
397
+ // Validate key format (should be 64 hex chars = 32 bytes)
398
+ if (!/^[0-9a-fA-F]{64}$/.test(keyValue)) {
399
+ console.error('❌ Invalid key format. Expected 64 hex characters (256-bit key).');
400
+ process.exit(1);
401
+ }
402
+ // Check for existing key
403
+ const existingKey = findExistingKey();
404
+ if (existingKey) {
405
+ if (existingKey === keyValue) {
406
+ console.log('\n✅ This key is already configured.\n');
407
+ return;
408
+ }
409
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
410
+ const confirm = await new Promise((resolve) => {
411
+ rl.question('⚠️ An existing key will be replaced. Continue? (y/N) ', (answer) => {
412
+ rl.close();
413
+ resolve(answer.trim().toLowerCase());
414
+ });
415
+ });
416
+ if (confirm !== 'y' && confirm !== 'yes') {
417
+ console.log('Aborted.');
418
+ return;
419
+ }
420
+ }
421
+ // Save to .env file
422
+ const envPath = options.global
423
+ ? path.join(process.env.HOME || '~', '.env')
424
+ : path.resolve(options.file);
425
+ try {
426
+ let content = '';
427
+ if (fs.existsSync(envPath)) {
428
+ content = fs.readFileSync(envPath, 'utf-8');
429
+ // Replace existing key if present
430
+ if (/^LSH_SECRETS_KEY=/m.test(content)) {
431
+ content = content.replace(/^LSH_SECRETS_KEY=.*/m, `LSH_SECRETS_KEY=${keyValue}`);
432
+ }
433
+ else {
434
+ content = content.trimEnd() + `\nLSH_SECRETS_KEY=${keyValue}\n`;
435
+ }
436
+ }
437
+ else {
438
+ content = `LSH_SECRETS_KEY=${keyValue}\n`;
439
+ }
440
+ fs.writeFileSync(envPath, content, { mode: 0o600 });
441
+ console.log(`\n✅ Key saved to ${envPath}\n`);
442
+ console.log('💡 Now pull secrets: lsh sync pull\n');
443
+ }
444
+ catch (error) {
445
+ const err = error;
446
+ console.error(`❌ Failed to save key: ${err.message}`);
447
+ process.exit(1);
281
448
  }
282
449
  });
283
450
  // Create .env file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lsh-framework",
3
- "version": "3.2.2",
3
+ "version": "3.2.4",
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": {