lsh-framework 3.2.5 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +72 -34
  3. package/dist/commands/ipfs.js +7 -12
  4. package/dist/commands/sync.js +49 -38
  5. package/dist/constants/config.js +3 -0
  6. package/dist/lib/floating-point-arithmetic.js +2 -2
  7. package/dist/lib/ipfs-client-manager.js +51 -13
  8. package/dist/lib/ipfs-secrets-storage.js +21 -16
  9. package/dist/lib/ipfs-sync.js +88 -14
  10. package/dist/lib/secrets-manager.js +117 -47
  11. package/dist/lib/sync-key-store.js +87 -0
  12. package/dist/services/secrets/secrets.js +77 -39
  13. package/package.json +16 -16
  14. package/dist/__tests__/fixtures/job-fixtures.js +0 -204
  15. package/dist/__tests__/fixtures/supabase-mocks.js +0 -252
  16. package/dist/daemon/job-registry.js +0 -556
  17. package/dist/daemon/lshd.js +0 -968
  18. package/dist/daemon/saas-api-routes.js +0 -599
  19. package/dist/daemon/saas-api-server.js +0 -231
  20. package/dist/examples/supabase-integration.js +0 -106
  21. package/dist/lib/api-response.js +0 -226
  22. package/dist/lib/base-command-registrar.js +0 -287
  23. package/dist/lib/base-job-manager.js +0 -295
  24. package/dist/lib/cloud-config-manager.js +0 -348
  25. package/dist/lib/cron-job-manager.js +0 -368
  26. package/dist/lib/daemon-client-helper.js +0 -145
  27. package/dist/lib/daemon-client.js +0 -513
  28. package/dist/lib/database-persistence.js +0 -727
  29. package/dist/lib/database-schema.js +0 -259
  30. package/dist/lib/database-types.js +0 -90
  31. package/dist/lib/enhanced-history-system.js +0 -247
  32. package/dist/lib/history-system.js +0 -246
  33. package/dist/lib/job-manager.js +0 -436
  34. package/dist/lib/job-storage-database.js +0 -164
  35. package/dist/lib/job-storage-memory.js +0 -73
  36. package/dist/lib/local-storage-adapter.js +0 -507
  37. package/dist/lib/optimized-job-scheduler.js +0 -356
  38. package/dist/lib/saas-audit.js +0 -215
  39. package/dist/lib/saas-auth.js +0 -465
  40. package/dist/lib/saas-billing.js +0 -503
  41. package/dist/lib/saas-email.js +0 -403
  42. package/dist/lib/saas-encryption.js +0 -221
  43. package/dist/lib/saas-organizations.js +0 -662
  44. package/dist/lib/saas-secrets.js +0 -408
  45. package/dist/lib/saas-types.js +0 -165
  46. package/dist/lib/supabase-client.js +0 -125
  47. package/dist/lib/supabase-utils.js +0 -396
  48. package/dist/services/cron/cron-registrar.js +0 -240
  49. package/dist/services/cron/cron.js +0 -9
  50. package/dist/services/daemon/daemon-registrar.js +0 -585
  51. package/dist/services/daemon/daemon.js +0 -9
  52. package/dist/services/supabase/supabase-registrar.js +0 -375
  53. package/dist/services/supabase/supabase.js +0 -9
@@ -15,6 +15,7 @@ import { createLogger } from './logger.js';
15
15
  import { getIPFSSync } from './ipfs-sync.js';
16
16
  import { deriveKeyInfo, ensureKeyImported } from './ipns-key-manager.js';
17
17
  import { ENV_VARS, DEFAULTS } from '../constants/index.js';
18
+ import { extractErrorMessage } from './lsh-error.js';
18
19
  const logger = createLogger('IPFSSecretsStorage');
19
20
  /**
20
21
  * IPFS Secrets Storage
@@ -107,16 +108,23 @@ export class IPFSSecretsStorage {
107
108
  }
108
109
  }
109
110
  catch (error) {
110
- const err = error;
111
- logger.error(`Content uploaded (CID: ${cid}) but IPNS publish failed: ${err.message}\n` +
111
+ logger.error(`Content uploaded (CID: ${cid}) but IPNS publish failed: ${extractErrorMessage(error)}\n` +
112
112
  `Other machines won't find it via 'lsh pull' until you re-push.`);
113
113
  }
114
114
  }
115
+ // Durable remote pin (best-effort): survives this machine going offline.
116
+ // No-op unless a kubo remote pinning service is configured.
117
+ const pinnedService = await ipfsSync.addRemotePin(cid, `lsh-${gitRepo || DEFAULTS.DEFAULT_ENVIRONMENT}-${environment}`);
118
+ if (pinnedService) {
119
+ logger.info(` 📌 Remote-pinned to "${pinnedService}" (durable)`);
120
+ }
121
+ else {
122
+ logger.warn('No remote pin — content lives only on this machine until a peer caches it. Configure LSH_PIN_SERVICE for durability.');
123
+ }
115
124
  return cid;
116
125
  }
117
126
  catch (error) {
118
- const err = error;
119
- logger.error(`Failed to push secrets to IPFS: ${err.message}`);
127
+ logger.error(`Failed to push secrets to IPFS: ${extractErrorMessage(error)}`);
120
128
  throw error;
121
129
  }
122
130
  }
@@ -155,8 +163,7 @@ export class IPFSSecretsStorage {
155
163
  }
156
164
  }
157
165
  catch (error) {
158
- const err = error;
159
- logger.debug(` IPNS resolution error: ${err.message}`);
166
+ logger.debug(` IPNS resolution error: ${extractErrorMessage(error)}`);
160
167
  }
161
168
  }
162
169
  // No fallback to local metadata — IPNS is the source of truth
@@ -185,8 +192,7 @@ export class IPFSSecretsStorage {
185
192
  }
186
193
  }
187
194
  catch (error) {
188
- const err = error;
189
- logger.debug(` IPFS download failed: ${err.message}`);
195
+ logger.debug(` IPFS download failed: ${extractErrorMessage(error)}`);
190
196
  }
191
197
  }
192
198
  if (!cachedData) {
@@ -201,8 +207,7 @@ export class IPFSSecretsStorage {
201
207
  return secrets;
202
208
  }
203
209
  catch (error) {
204
- const err = error;
205
- logger.error(`Failed to pull secrets from IPFS: ${err.message}`);
210
+ logger.error(`Failed to pull secrets from IPFS: ${extractErrorMessage(error)}`);
206
211
  throw error;
207
212
  }
208
213
  }
@@ -275,19 +280,19 @@ export class IPFSSecretsStorage {
275
280
  return JSON.parse(decrypted);
276
281
  }
277
282
  catch (error) {
278
- const err = error;
283
+ const msg = extractErrorMessage(error);
279
284
  // Catch crypto errors (bad decrypt, wrong block length) AND JSON parse errors
280
285
  // (wrong key can produce garbage that fails JSON.parse)
281
- if (err.message.includes('bad decrypt') ||
282
- err.message.includes('wrong final block length') ||
283
- err.message.includes('Unexpected token') ||
284
- err.message.includes('JSON')) {
286
+ if (msg.includes('bad decrypt') ||
287
+ msg.includes('wrong final block length') ||
288
+ msg.includes('Unexpected token') ||
289
+ msg.includes('JSON')) {
285
290
  throw new Error('Decryption failed. This usually means:\n' +
286
291
  ' 1. You need to set LSH_SECRETS_KEY environment variable\n' +
287
292
  ' 2. The key must match the one used during encryption\n' +
288
293
  ' 3. Generate a shared key with: lsh key\n' +
289
294
  ' 4. Add it to your .env: LSH_SECRETS_KEY=<key>\n' +
290
- '\nOriginal error: ' + err.message);
295
+ '\nOriginal error: ' + msg, { cause: error });
291
296
  }
292
297
  throw error;
293
298
  }
@@ -14,6 +14,8 @@ import * as fsPromises from 'fs/promises';
14
14
  import * as path from 'path';
15
15
  import * as os from 'os';
16
16
  import { createLogger } from './logger.js';
17
+ import { extractErrorMessage } from './lsh-error.js';
18
+ import { ENV_VARS } from '../constants/config.js';
17
19
  const logger = createLogger('IPFSSync');
18
20
  /**
19
21
  * Native IPFS Sync
@@ -123,8 +125,7 @@ export class IPFSSync {
123
125
  return cid;
124
126
  }
125
127
  catch (error) {
126
- const err = error;
127
- logger.error(`IPFS upload error: ${err.message}`);
128
+ logger.error(`IPFS upload error: ${extractErrorMessage(error)}`);
128
129
  return null;
129
130
  }
130
131
  }
@@ -151,6 +152,7 @@ export class IPFSSync {
151
152
  logger.debug('Local daemon download failed, trying gateways...');
152
153
  }
153
154
  // Fall back to public gateways
155
+ let backoffMs = 1000;
154
156
  for (const gatewayTemplate of this.GATEWAYS) {
155
157
  const gatewayUrl = gatewayTemplate.replace('{cid}', cid);
156
158
  try {
@@ -165,6 +167,8 @@ export class IPFSSync {
165
167
  }
166
168
  catch {
167
169
  logger.debug(`Gateway ${gatewayUrl} failed, trying next...`);
170
+ await new Promise((resolve) => { setTimeout(resolve, backoffMs); });
171
+ backoffMs = Math.min(backoffMs * 2, 10000);
168
172
  }
169
173
  }
170
174
  logger.error(`Failed to download CID: ${cid}`);
@@ -229,8 +233,7 @@ export class IPFSSync {
229
233
  return false;
230
234
  }
231
235
  catch (error) {
232
- const err = error;
233
- logger.error(`Pin failed: ${err.message}`);
236
+ logger.error(`Pin failed: ${extractErrorMessage(error)}`);
234
237
  return false;
235
238
  }
236
239
  }
@@ -255,8 +258,7 @@ export class IPFSSync {
255
258
  return false;
256
259
  }
257
260
  catch (error) {
258
- const err = error;
259
- logger.error(`Unpin failed: ${err.message}`);
261
+ logger.error(`Unpin failed: ${extractErrorMessage(error)}`);
260
262
  return false;
261
263
  }
262
264
  }
@@ -297,8 +299,7 @@ export class IPFSSync {
297
299
  await fsPromises.writeFile(this.historyPath, JSON.stringify(history, null, 2), 'utf-8');
298
300
  }
299
301
  catch (error) {
300
- const err = error;
301
- logger.debug(`Failed to save history: ${err.message}`);
302
+ logger.debug(`Failed to save history: ${extractErrorMessage(error)}`);
302
303
  }
303
304
  }
304
305
  /**
@@ -327,8 +328,7 @@ export class IPFSSync {
327
328
  }
328
329
  }
329
330
  catch (error) {
330
- const err = error;
331
- logger.error(`Failed to clear history: ${err.message}`);
331
+ logger.error(`Failed to clear history: ${extractErrorMessage(error)}`);
332
332
  }
333
333
  }
334
334
  /**
@@ -343,6 +343,82 @@ export class IPFSSync {
343
343
  getApiUrl() {
344
344
  return this.LOCAL_IPFS_API;
345
345
  }
346
+ /**
347
+ * List the names of remote pinning services configured in the local Kubo
348
+ * node (via `ipfs pin remote service add`). Returns [] on any error.
349
+ */
350
+ async listRemoteServices() {
351
+ try {
352
+ const response = await fetch(`${this.LOCAL_IPFS_API}/pin/remote/service/ls`, {
353
+ method: 'POST',
354
+ signal: AbortSignal.timeout(5000),
355
+ });
356
+ if (!response.ok)
357
+ return [];
358
+ const data = await response.json();
359
+ return (data.RemoteServices || []).map((s) => s.Service).filter(Boolean);
360
+ }
361
+ catch {
362
+ return [];
363
+ }
364
+ }
365
+ /**
366
+ * Decide which remote pinning service to pin to.
367
+ * - If LSH_PIN_SERVICE is set, use it only when it is actually configured.
368
+ * - Otherwise, use the sole configured service when exactly one exists.
369
+ * - Returns null when nothing is configured or the choice is ambiguous.
370
+ */
371
+ async resolveRemoteService() {
372
+ const services = await this.listRemoteServices();
373
+ const explicit = process.env[ENV_VARS.LSH_PIN_SERVICE];
374
+ if (explicit) {
375
+ return services.includes(explicit) ? explicit : null;
376
+ }
377
+ return services.length === 1 ? services[0] : null;
378
+ }
379
+ /**
380
+ * Pin a CID to a configured remote pinning service so the content survives
381
+ * this machine going offline. This is what makes "pull anywhere, anytime"
382
+ * real: without it, blocks live only on the pushing node.
383
+ *
384
+ * Returns the service name on success, or null when no service is
385
+ * configured (the common zero-config case) or the pin request failed.
386
+ * Never throws — durable pinning is best-effort and the caller decides how
387
+ * loudly to warn.
388
+ */
389
+ async addRemotePin(cid, pinName) {
390
+ try {
391
+ const service = await this.resolveRemoteService();
392
+ if (!service)
393
+ return null;
394
+ const url = `${this.LOCAL_IPFS_API}/pin/remote/add` +
395
+ `?arg=${encodeURIComponent(cid)}` +
396
+ `&service=${encodeURIComponent(service)}` +
397
+ `&name=${encodeURIComponent(pinName)}` +
398
+ `&background=true`;
399
+ const response = await fetch(url, {
400
+ method: 'POST',
401
+ signal: AbortSignal.timeout(30000),
402
+ });
403
+ if (!response.ok) {
404
+ const errorText = await response.text();
405
+ // An already-present pin is reported as an error by some services; treat
406
+ // a "already pinned"/"duplicate" message as success rather than a failure.
407
+ if (/already|duplicate|exists/i.test(errorText)) {
408
+ logger.info(`📌 Already remote-pinned on "${service}": ${cid}`);
409
+ return service;
410
+ }
411
+ logger.warn(`Remote pin failed on "${service}": ${errorText}`);
412
+ return null;
413
+ }
414
+ logger.info(`📌 Remote-pinned to "${service}" (durable): ${cid}`);
415
+ return service;
416
+ }
417
+ catch (error) {
418
+ logger.warn(`Remote pin error: ${extractErrorMessage(error)}`);
419
+ return null;
420
+ }
421
+ }
346
422
  /**
347
423
  * Publish a CID to IPNS under the given key name.
348
424
  * The key must already be imported into Kubo.
@@ -371,8 +447,7 @@ export class IPFSSync {
371
447
  return data.Name;
372
448
  }
373
449
  catch (error) {
374
- const err = error;
375
- logger.warn(`IPNS publish error (attempt ${attempt}): ${err.message}`);
450
+ logger.warn(`IPNS publish error (attempt ${attempt}): ${extractErrorMessage(error)}`);
376
451
  if (attempt === 2) {
377
452
  logger.error(`IPNS publish failed after retry. Content is uploaded (CID: ${cid}) ` +
378
453
  `but other machines won't find it via 'lsh pull' until you re-push.`);
@@ -402,8 +477,7 @@ export class IPFSSync {
402
477
  return resolvedCid;
403
478
  }
404
479
  catch (error) {
405
- const err = error;
406
- logger.debug(`IPNS resolve error: ${err.message}`);
480
+ logger.debug(`IPNS resolve error: ${extractErrorMessage(error)}`);
407
481
  return null;
408
482
  }
409
483
  }
@@ -3,6 +3,7 @@
3
3
  * Sync .env files across machines using encrypted Supabase storage
4
4
  */
5
5
  import * as fs from 'fs';
6
+ import * as os from 'os';
6
7
  import * as path from 'path';
7
8
  import * as crypto from 'crypto';
8
9
  import { createLogger, LogLevel } from './logger.js';
@@ -10,7 +11,53 @@ import { getGitRepoInfo, hasEnvExample, ensureEnvInGitignore } from './git-utils
10
11
  import { IPFSSyncLogger } from './ipfs-sync-logger.js';
11
12
  import { IPFSSecretsStorage } from './ipfs-secrets-storage.js';
12
13
  import { ENV_VARS } from '../constants/index.js';
14
+ import { extractErrorMessage } from './lsh-error.js';
15
+ import { SyncKeyStore } from './sync-key-store.js';
13
16
  const logger = createLogger('SecretsManager');
17
+ /**
18
+ * Read LSH_SECRETS_KEY from a .env file without loading the entire file into process.env
19
+ */
20
+ function readKeyFromEnvFile(envPath) {
21
+ try {
22
+ if (fs.existsSync(envPath)) {
23
+ const content = fs.readFileSync(envPath, 'utf8');
24
+ const match = content.match(/^LSH_SECRETS_KEY=['"]?([^'"\n]+)['"]?/m);
25
+ if (match)
26
+ return match[1];
27
+ }
28
+ }
29
+ catch {
30
+ // Ignore read errors
31
+ }
32
+ return null;
33
+ }
34
+ /**
35
+ * Find LSH_SECRETS_KEY from environment, local .env, global ~/.env,
36
+ * or the persistent SyncKeyStore at $LSH_HOME/sync_key.json.
37
+ * Returns null if no explicit key is found (does not generate a fallback).
38
+ */
39
+ export function findEncryptionKey() {
40
+ // 1. Check environment variable
41
+ const envKey = process.env[ENV_VARS.LSH_SECRETS_KEY];
42
+ if (envKey)
43
+ return envKey;
44
+ // 2. Check local .env
45
+ const localKey = readKeyFromEnvFile(path.join(process.cwd(), '.env'));
46
+ if (localKey)
47
+ return localKey;
48
+ // 3. Check global ~/.env
49
+ const home = process.env[ENV_VARS.HOME] || process.env[ENV_VARS.USERPROFILE] || '';
50
+ if (home) {
51
+ const globalKey = readKeyFromEnvFile(path.join(home, '.env'));
52
+ if (globalKey)
53
+ return globalKey;
54
+ }
55
+ // 4. Check persistent sync key store ($LSH_HOME/sync_key.json)
56
+ const stored = new SyncKeyStore().get();
57
+ if (stored)
58
+ return stored;
59
+ return null;
60
+ }
14
61
  export class SecretsManager {
15
62
  storage;
16
63
  encryptionKey;
@@ -73,57 +120,85 @@ export class SecretsManager {
73
120
  * Get default encryption key from environment or machine
74
121
  */
75
122
  getDefaultEncryptionKey() {
76
- // Check for explicit key
77
- const envKey = process.env[ENV_VARS.LSH_SECRETS_KEY];
78
- if (envKey) {
79
- return envKey;
80
- }
81
- // Generate from machine ID and user
82
- const machineId = process.env[ENV_VARS.HOSTNAME] || 'localhost';
83
- const user = process.env[ENV_VARS.USER] || 'unknown';
84
- const seed = `${machineId}-${user}-lsh-secrets`;
123
+ // Check environment, local .env, and global ~/.env
124
+ const explicitKey = findEncryptionKey();
125
+ if (explicitKey)
126
+ return explicitKey;
127
+ logger.warn('No explicit LSH_SECRETS_KEY found. Generating machine-derived fallback key.');
128
+ logger.warn('Set LSH_SECRETS_KEY in your environment for portable, secure encryption.');
129
+ // Generate from multiple machine-specific entropy sources
130
+ const hostname = os.hostname();
131
+ const uid = String(os.userInfo().uid);
132
+ const homedir = os.homedir();
133
+ // Try to read /etc/machine-id (Linux) for additional entropy
134
+ let machineSpecific;
135
+ try {
136
+ machineSpecific = fs.readFileSync('/etc/machine-id', 'utf8').trim();
137
+ }
138
+ catch {
139
+ // Fallback to CPU model on macOS / systems without machine-id
140
+ machineSpecific = os.cpus()[0]?.model || 'no-cpu-info';
141
+ }
142
+ const seed = `${hostname}-${uid}-${homedir}-${machineSpecific}-lsh-secrets`;
85
143
  // Create deterministic key
86
144
  return crypto.createHash('sha256').update(seed).digest('hex');
87
145
  }
88
146
  /**
89
- * Encrypt a value
147
+ * Encrypt a value using AES-256-GCM with authentication tag
90
148
  */
91
149
  encrypt(text) {
92
- const iv = crypto.randomBytes(16);
150
+ const iv = crypto.randomBytes(12); // 96-bit IV recommended for GCM
93
151
  const key = Buffer.from(this.encryptionKey, 'hex');
94
- const cipher = crypto.createCipheriv('aes-256-cbc', key.slice(0, 32), iv);
152
+ const cipher = crypto.createCipheriv('aes-256-gcm', key.slice(0, 32), iv);
95
153
  let encrypted = cipher.update(text, 'utf8', 'hex');
96
154
  encrypted += cipher.final('hex');
97
- return iv.toString('hex') + ':' + encrypted;
155
+ const authTag = cipher.getAuthTag();
156
+ // Format: iv:authTag:encrypted (3-part GCM format)
157
+ return iv.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted;
98
158
  }
99
159
  /**
100
- * Decrypt a value
160
+ * Decrypt a value (supports both AES-256-GCM and legacy AES-256-CBC)
101
161
  */
102
162
  decrypt(text) {
103
163
  try {
104
164
  const parts = text.split(':');
105
- if (parts.length !== 2) {
165
+ const key = Buffer.from(this.encryptionKey, 'hex');
166
+ if (parts.length === 3) {
167
+ // AES-256-GCM format: iv:authTag:encrypted
168
+ const iv = Buffer.from(parts[0], 'hex');
169
+ const authTag = Buffer.from(parts[1], 'hex');
170
+ const encryptedText = parts[2];
171
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key.slice(0, 32), iv);
172
+ decipher.setAuthTag(authTag);
173
+ let decrypted = decipher.update(encryptedText, 'hex', 'utf8');
174
+ decrypted += decipher.final('utf8');
175
+ return decrypted;
176
+ }
177
+ else if (parts.length === 2) {
178
+ // Legacy AES-256-CBC format: iv:encrypted
179
+ const iv = Buffer.from(parts[0], 'hex');
180
+ const encryptedText = parts[1];
181
+ const decipher = crypto.createDecipheriv('aes-256-cbc', key.slice(0, 32), iv);
182
+ let decrypted = decipher.update(encryptedText, 'hex', 'utf8');
183
+ decrypted += decipher.final('utf8');
184
+ return decrypted;
185
+ }
186
+ else {
106
187
  throw new Error('Invalid encrypted format');
107
188
  }
108
- const iv = Buffer.from(parts[0], 'hex');
109
- const encryptedText = parts[1];
110
- const key = Buffer.from(this.encryptionKey, 'hex');
111
- const decipher = crypto.createDecipheriv('aes-256-cbc', key.slice(0, 32), iv);
112
- let decrypted = decipher.update(encryptedText, 'hex', 'utf8');
113
- decrypted += decipher.final('utf8');
114
- return decrypted;
115
189
  }
116
190
  catch (error) {
117
- const err = error;
118
- if (err.message.includes('bad decrypt') || err.message.includes('wrong final block length')) {
191
+ const msg = extractErrorMessage(error);
192
+ if (msg.includes('bad decrypt') || msg.includes('wrong final block length') ||
193
+ msg.includes('Unsupported state') || msg.includes('unable to authenticate')) {
119
194
  throw new Error('Decryption failed. This usually means:\n' +
120
195
  ' 1. You need to set LSH_SECRETS_KEY environment variable\n' +
121
196
  ' 2. The key must match the one used during encryption\n' +
122
197
  ' 3. Generate a shared key with: lsh secrets key\n' +
123
198
  ' 4. Add it to your .env: LSH_SECRETS_KEY=<key>\n' +
124
- '\nOriginal error: ' + err.message);
199
+ '\nOriginal error: ' + msg, { cause: error });
125
200
  }
126
- throw err;
201
+ throw error;
127
202
  }
128
203
  }
129
204
  /**
@@ -243,8 +318,8 @@ export class SecretsManager {
243
318
  }
244
319
  // Get the effective environment name (repo-aware)
245
320
  const effectiveEnv = this.getRepoAwareEnvironment(environment);
246
- // Warn if using default key
247
- if (!process.env[ENV_VARS.LSH_SECRETS_KEY]) {
321
+ // Warn if using machine-derived fallback key (no explicit key found anywhere)
322
+ if (!findEncryptionKey()) {
248
323
  logger.warn('⚠️ Warning: No LSH_SECRETS_KEY set. Using machine-specific key.');
249
324
  logger.warn(' To share secrets across machines, generate a key with: lsh key');
250
325
  logger.warn(' Then add LSH_SECRETS_KEY=<key> to your .env on all machines');
@@ -277,10 +352,9 @@ export class SecretsManager {
277
352
  }
278
353
  }
279
354
  catch (error) {
280
- const err = error;
281
355
  // Re-throw destructive change errors
282
- if (err.message.includes('Destructive change')) {
283
- throw err;
356
+ if (extractErrorMessage(error).includes('Destructive change')) {
357
+ throw error;
284
358
  }
285
359
  // Ignore other errors (like missing secrets) and proceed
286
360
  }
@@ -357,8 +431,8 @@ export class SecretsManager {
357
431
  const finalEnv = { ...pulledEnv, ...localLshKeys };
358
432
  // Convert to .env format
359
433
  const envContent = this.formatEnvFile(finalEnv);
360
- // Write new .env
361
- fs.writeFileSync(envFilePath, envContent, 'utf8');
434
+ // Write new .env with restrictive permissions (owner read/write only)
435
+ fs.writeFileSync(envFilePath, envContent, { encoding: 'utf8', mode: 0o600 });
362
436
  logger.info(`✅ Pulled ${secrets.length} secrets from IPFS`);
363
437
  // Get metadata for CID display
364
438
  const metadata = this.storage.getMetadata(environment, this.gitInfo?.repoName);
@@ -430,7 +504,7 @@ export class SecretsManager {
430
504
  cloudExists: false,
431
505
  cloudKeys: 0,
432
506
  cloudModified: undefined,
433
- keySet: !!process.env[ENV_VARS.LSH_SECRETS_KEY],
507
+ keySet: !!findEncryptionKey(),
434
508
  keyMatches: undefined,
435
509
  suggestions: [],
436
510
  };
@@ -518,7 +592,7 @@ export class SecretsManager {
518
592
  * Generate encryption key if not set
519
593
  */
520
594
  async ensureEncryptionKey() {
521
- if (process.env[ENV_VARS.LSH_SECRETS_KEY]) {
595
+ if (findEncryptionKey()) {
522
596
  return true; // Key already set
523
597
  }
524
598
  logger.warn('⚠️ No encryption key found. Generating a new key...');
@@ -540,7 +614,7 @@ export class SecretsManager {
540
614
  else {
541
615
  content += `\n# LSH Secrets Encryption Key (do not commit!)\nLSH_SECRETS_KEY=${key}\n`;
542
616
  }
543
- fs.writeFileSync(envPath, content, 'utf8');
617
+ fs.writeFileSync(envPath, content, { encoding: 'utf8', mode: 0o600 });
544
618
  // Set in current process
545
619
  process.env[ENV_VARS.LSH_SECRETS_KEY] = key;
546
620
  this.encryptionKey = key;
@@ -549,8 +623,7 @@ export class SecretsManager {
549
623
  return true;
550
624
  }
551
625
  catch (error) {
552
- const _err = error;
553
- logger.error(`Failed to save encryption key: ${_err.message}`);
626
+ logger.error(`Failed to save encryption key: ${extractErrorMessage(error)}`);
554
627
  logger.info('Please set it manually:');
555
628
  logger.info(`export LSH_SECRETS_KEY=${key}`);
556
629
  return false;
@@ -581,13 +654,12 @@ LSH_SECRETS_KEY=${this.encryptionKey}
581
654
  # Add your environment variables below
582
655
  `;
583
656
  try {
584
- fs.writeFileSync(envFilePath, template, 'utf8');
657
+ fs.writeFileSync(envFilePath, template, { encoding: 'utf8', mode: 0o600 });
585
658
  logger.info(`✅ Created ${envFilePath} from template`);
586
659
  return true;
587
660
  }
588
661
  catch (error) {
589
- const _err = error;
590
- logger.error(`Failed to create ${envFilePath}: ${_err.message}`);
662
+ logger.error(`Failed to create ${envFilePath}: ${extractErrorMessage(error)}`);
591
663
  return false;
592
664
  }
593
665
  }
@@ -599,13 +671,12 @@ LSH_SECRETS_KEY=${this.encryptionKey}
599
671
  if (!content.includes('LSH_SECRETS_KEY')) {
600
672
  newContent += `\n# LSH Secrets Encryption Key (auto-generated)\nLSH_SECRETS_KEY=${this.encryptionKey}\n`;
601
673
  }
602
- fs.writeFileSync(envFilePath, newContent, 'utf8');
674
+ fs.writeFileSync(envFilePath, newContent, { encoding: 'utf8', mode: 0o600 });
603
675
  logger.info(`✅ Created ${envFilePath} from ${path.basename(examplePath)}`);
604
676
  return true;
605
677
  }
606
678
  catch (error) {
607
- const _err = error;
608
- logger.error(`Failed to create ${envFilePath}: ${_err.message}`);
679
+ logger.error(`Failed to create ${envFilePath}: ${extractErrorMessage(error)}`);
609
680
  return false;
610
681
  }
611
682
  }
@@ -679,7 +750,7 @@ LSH_SECRETS_KEY=${this.encryptionKey}
679
750
  out();
680
751
  }
681
752
  // Step 1: Ensure encryption key exists
682
- if (!process.env[ENV_VARS.LSH_SECRETS_KEY]) {
753
+ if (!findEncryptionKey()) {
683
754
  logger.info('🔑 No encryption key found...');
684
755
  await this.ensureEncryptionKey();
685
756
  out();
@@ -982,8 +1053,7 @@ LSH_SECRETS_KEY=${this.encryptionKey}
982
1053
  }
983
1054
  catch (error) {
984
1055
  // Don't fail operation if IPFS logging fails
985
- const err = error;
986
- logger.warn(`⚠️ Could not log to IPFS: ${err.message}`);
1056
+ logger.warn(`⚠️ Could not log to IPFS: ${extractErrorMessage(error)}`);
987
1057
  }
988
1058
  }
989
1059
  }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Persistent storage for the LSH sync secret.
3
+ *
4
+ * Mirrors the design of mcli's `mcli sync key` store: a single 64-char
5
+ * hex value kept in ``$LSH_HOME/sync_key.json`` (default
6
+ * ``~/.config/lsh/sync_key.json``) with mode 0600. The
7
+ * ``LSH_SECRETS_KEY`` environment variable still wins when set; the
8
+ * store is only consulted as a fallback so users do not have to re-paste
9
+ * their secret on every shell.
10
+ */
11
+ import * as crypto from 'crypto';
12
+ import * as fs from 'fs';
13
+ import * as os from 'os';
14
+ import * as path from 'path';
15
+ export const HEX64_REGEX = /^[0-9a-fA-F]{64}$/;
16
+ const FILENAME = 'sync_key.json';
17
+ /** Resolve the directory where lsh stores its config (`$LSH_HOME` or `~/.config/lsh`). */
18
+ export function getLshHome() {
19
+ const overridden = process.env.LSH_HOME;
20
+ if (overridden && overridden.trim().length > 0)
21
+ return overridden;
22
+ return path.join(os.homedir(), '.config', 'lsh');
23
+ }
24
+ /** Return a freshly-generated 64-char hex secret without persisting it. */
25
+ export function generateKey() {
26
+ return crypto.randomBytes(32).toString('hex');
27
+ }
28
+ /**
29
+ * Persistent on-disk store for the LSH sync secret.
30
+ */
31
+ export class SyncKeyStore {
32
+ /** Absolute path to the on-disk key file. */
33
+ get path() {
34
+ return path.join(getLshHome(), FILENAME);
35
+ }
36
+ /** Read the persisted key, or `null` if none is configured / file is malformed. */
37
+ get() {
38
+ const file = this.path;
39
+ if (!fs.existsSync(file))
40
+ return null;
41
+ try {
42
+ const raw = fs.readFileSync(file, 'utf-8');
43
+ const parsed = JSON.parse(raw);
44
+ if (typeof parsed.key === 'string' && HEX64_REGEX.test(parsed.key)) {
45
+ return parsed.key;
46
+ }
47
+ }
48
+ catch {
49
+ // fall through to null
50
+ }
51
+ return null;
52
+ }
53
+ /** Validate and persist a key. Throws on invalid input. */
54
+ set(key) {
55
+ if (typeof key !== 'string' || !HEX64_REGEX.test(key)) {
56
+ throw new Error('sync key must be a 64-char hex string');
57
+ }
58
+ this.write(key);
59
+ }
60
+ /** Generate a new key and persist it. Refuses to overwrite unless `force=true`. */
61
+ generate(force = false) {
62
+ if (fs.existsSync(this.path) && !force) {
63
+ throw new Error(`sync key already exists at ${this.path}; use force=true to overwrite`);
64
+ }
65
+ const key = generateKey();
66
+ this.write(key);
67
+ return key;
68
+ }
69
+ /** Delete the persisted key, if any. No-op when the file is absent. */
70
+ clear() {
71
+ try {
72
+ fs.unlinkSync(this.path);
73
+ }
74
+ catch (err) {
75
+ const e = err;
76
+ if (e.code !== 'ENOENT')
77
+ throw err;
78
+ }
79
+ }
80
+ write(key) {
81
+ const dir = path.dirname(this.path);
82
+ fs.mkdirSync(dir, { recursive: true });
83
+ // Write first, then chmod, so the secret is never world-readable.
84
+ fs.writeFileSync(this.path, JSON.stringify({ key }));
85
+ fs.chmodSync(this.path, 0o600);
86
+ }
87
+ }