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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 gwicho38
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,8 +1,10 @@
1
- # LSH v3.1.19 - Encrypted Secrets Manager
1
+ # LSH Encrypted Secrets Manager
2
2
 
3
3
  **The simplest way to sync `.env` files across all your machines.**
4
4
 
5
- `lsh` is an encrypted secrets manager that syncs your environment files across development machines with AES-256 encryption via the IPFS network. Push once, pull anywhere.
5
+ `lsh` is an encrypted secrets manager that syncs your environment files across development machines with AES-256 encryption over the IPFS network. Secrets are encrypted locally, addressed by content (CID), and published under a deterministic IPNS name derived from your shared key — so a teammate with the same key can pull the latest version with no account or server.
6
+
7
+ > **Durability note:** by default the encrypted content is pinned **only on the machine that pushed it** and served peer-to-peer. Another machine can pull as long as a node that holds the content is online and the IPNS record is still live. For "pull anywhere, anytime" durability, configure a remote pinning service (see [Durable sync](#durable-sync-remote-pinning)).
6
8
 
7
9
  [![npm version](https://badge.fury.io/js/lsh-framework.svg)](https://badge.fury.io/js/lsh-framework)
8
10
  [![Node.js CI](https://github.com/gwicho38/lsh/actions/workflows/node.js.yml/badge.svg)](https://github.com/gwicho38/lsh/actions/workflows/node.js.yml)
@@ -71,25 +73,49 @@ lsh pull --env staging
71
73
  ## How It Works
72
74
 
73
75
  ```
74
- Your Machine Storacha (IPFS Network)
75
- ┌─────────────┐ ┌─────────────────────┐
76
- │ .env │ AES-256 Encrypted Blob
77
- │ (secrets) │ ───encrypt───► (content-addressed)
78
- └─────────────┘ └─────────────────────┘
79
-
80
-
81
- Another Machine ┌─────────────────────┐
82
- ┌─────────────┐ AES-256 Registry │
83
- │ .env │ ◄──decrypt──── │ (points to blob)
84
- │ (secrets) │ └─────────────────────┘
85
- └─────────────┘
76
+ Machine A (push) Local Kubo (IPFS) node IPFS DHT / swarm
77
+ ┌─────────────┐ AES-256 ┌─────────────────────┐ ┌──────────────────┐
78
+ │ .env │ ───encrypt───► ipfs add (pin local)│ ──────► │ IPNS record:
79
+ │ (secrets) │ │ → CID publish │ name → CID
80
+ └─────────────┘ └─────────────────────┘ │ (key-derived) │
81
+ └──────────────────┘
82
+ │ resolve
83
+ Machine B (pull) ▼
84
+ ┌─────────────┐ AES-256 ┌─────────────────────┐ fetch ┌──────────────────┐
85
+ │ .env │ ◄──decrypt──── │ ipfs cat <CID> │ ◄─────── │ a node holding
86
+ │ (secrets) │ └─────────────────────┘ swarm │ the block (A or │
87
+ └─────────────┘ │ a pinning svc) │
88
+ └──────────────────┘
86
89
  ```
87
90
 
88
- 1. Your `.env` is encrypted locally with AES-256
89
- 2. Encrypted data uploads to IPFS via Storacha
90
- 3. A registry tracks the latest version per repository
91
- 4. Other machines pull via the content ID (CID)
92
- 5. Decryption happens locally with your shared key
91
+ 1. Your `.env` is encrypted locally with AES-256 (the key never leaves the machine).
92
+ 2. The ciphertext is added to your **local Kubo (IPFS) daemon** and pinned there, producing a content ID (CID).
93
+ 3. The CID is published to **IPNS** under a name derived deterministically from `LSH_SECRETS_KEY` + repo + environment (`HMAC-SHA256`), so teammates need only the shared key.
94
+ 4. Another machine derives the same IPNS name, resolves it to the latest CID over the network, and fetches the ciphertext over the IPFS swarm.
95
+ 5. Decryption happens locally with the shared key.
96
+
97
+ **What this means:** the encrypted block is only guaranteed to exist where it was pushed. Cross-machine pull works while a node holding the block is online (the publisher, a peer that cached it, or — recommended — a [remote pinning service](#durable-sync-remote-pinning)).
98
+
99
+ ## Durable sync (remote pinning)
100
+
101
+ Out of the box, `lsh sync` is zero-config but **not durable**: the encrypted content lives only on the machine that pushed it. If that machine sleeps or goes offline before a teammate pulls — and no peer has cached the block — the pull will stall. `lsh sync push` warns you when no durable pin is configured.
102
+
103
+ To make secrets available "anytime, anywhere", point `lsh` at any IPFS **remote pinning service** (Pinata, Filebase, 4EVERLAND, web3.storage, an IPFS Cluster, etc.). `lsh` uses your local Kubo daemon's remote-pinning support — no extra dependency, and your encryption key never leaves your machine (the service only ever stores ciphertext).
104
+
105
+ ```bash
106
+ # 1. Register a pinning service with your local Kubo daemon (one-time)
107
+ ipfs pin remote service add pinata https://api.pinata.cloud/psa <YOUR_JWT>
108
+
109
+ # 2. Tell lsh which service to use (only needed if more than one is configured)
110
+ export LSH_SECRETS_KEY=<your-key>
111
+ export LSH_PIN_SERVICE=pinata
112
+
113
+ # 3. Push — content is now pinned remotely and survives this machine going offline
114
+ lsh sync push --env dev
115
+ # → "Pinned: pinata (durable)"
116
+ ```
117
+
118
+ If exactly one remote service is configured, `lsh` uses it automatically and `LSH_PIN_SERVICE` is optional.
93
119
 
94
120
  ## Installation
95
121
 
@@ -138,16 +164,18 @@ lsh pull
138
164
  # 1. Install LSH
139
165
  npm install -g lsh-framework
140
166
 
141
- # 2. Authenticate with Storacha (one-time)
142
- lsh storacha login your@email.com
167
+ # 2. Install + start a local IPFS (Kubo) daemon (one-time)
168
+ lsh sync init
143
169
 
144
- # 3. Add your encryption key
170
+ # 3. Add your encryption key (shared with your other machines / team)
145
171
  echo "LSH_SECRETS_KEY=your-shared-key" > .env
146
172
 
147
- # 4. Pull secrets
148
- lsh pull
173
+ # 4. Pull secrets (resolves the latest version via IPNS)
174
+ lsh sync pull
149
175
  ```
150
176
 
177
+ > Requires a local IPFS (Kubo) daemon — `lsh sync init` installs and starts one. The pushing machine must be online (or a pinning service configured) for others to fetch the content.
178
+
151
179
  ## Multi-Environment Support
152
180
 
153
181
  ```bash
@@ -215,10 +243,10 @@ eval "$(lsh list --format export)"
215
243
 
216
244
  ## Security
217
245
 
218
- - **AES-256-CBC** encryption for all secrets
246
+ - **AES-256** encryption for all secrets (the key never leaves your machine)
219
247
  - **Content-addressed storage** - tamper-proof IPFS CIDs
220
- - **Zero-knowledge** - Storacha never sees your unencrypted data
221
- - **Local-first** - Works offline with cached secrets
248
+ - **Zero-knowledge** - the IPFS network (and any pinning service) only ever sees ciphertext
249
+ - **Local-first** - works offline with cached secrets
222
250
 
223
251
  ### Best Practices
224
252
 
@@ -258,16 +286,25 @@ lsh key
258
286
  lsh push --force
259
287
  ```
260
288
 
261
- ### "Storacha authentication required"
289
+ ### Pull hangs or "Could not resolve secrets from network"
290
+
291
+ The IPNS name resolved but no online node is serving the content (or the IPNS record expired). Either:
262
292
 
263
293
  ```bash
264
- lsh storacha login your@email.com
265
- # Check email for verification
294
+ # On the machine that pushed: make sure its daemon is running, then re-push
295
+ lsh sync status
296
+ lsh sync push --env dev
297
+
298
+ # Better: configure a remote pinning service so content stays available
299
+ # even when the pushing machine is offline (see "Durable sync" below)
266
300
  ```
267
301
 
268
- ### Pull fails after clearing metadata
302
+ ### "IPFS daemon not running"
269
303
 
270
- v3.0.0 fix: Pull now automatically checks the Storacha registry when local metadata is missing.
304
+ ```bash
305
+ lsh sync init # install + start a local Kubo daemon
306
+ lsh sync status # verify it is up
307
+ ```
271
308
 
272
309
  ```bash
273
310
  # If secrets were pushed before, pull should auto-recover
@@ -314,8 +351,9 @@ lsh -i
314
351
  # Required
315
352
  LSH_SECRETS_KEY=<your-encryption-key>
316
353
 
317
- # Optional - Storacha (default enabled)
318
- LSH_STORACHA_ENABLED=true
354
+ # Optional - name of a kubo remote pinning service for durable sync
355
+ # (configure once with: ipfs pin remote service add <name> <endpoint> <key>)
356
+ LSH_PIN_SERVICE=<service-name>
319
357
 
320
358
  # Optional - Supabase backend
321
359
  SUPABASE_URL=https://xxx.supabase.co
@@ -9,6 +9,7 @@ import { getIPFSSync } from '../lib/ipfs-sync.js';
9
9
  import { deriveKeyInfo, ensureKeyImported } from '../lib/ipns-key-manager.js';
10
10
  import { getGitRepoInfo } from '../lib/git-utils.js';
11
11
  import { ENV_VARS, DEFAULTS } from '../constants/index.js';
12
+ import { extractErrorMessage } from '../lib/lsh-error.js';
12
13
  /**
13
14
  * Register IPFS commands
14
15
  */
@@ -107,8 +108,7 @@ export function registerIPFSCommands(program) {
107
108
  console.log('');
108
109
  }
109
110
  catch (error) {
110
- const err = error;
111
- console.error(chalk.red('\n❌ Failed to check status:'), err.message);
111
+ console.error(chalk.red('\n❌ Failed to check status:'), extractErrorMessage(error));
112
112
  process.exit(1);
113
113
  }
114
114
  });
@@ -134,9 +134,8 @@ export function registerIPFSCommands(program) {
134
134
  console.log('');
135
135
  }
136
136
  catch (error) {
137
- const err = error;
138
137
  spinner.fail(chalk.red('Installation failed'));
139
- console.error(chalk.red(err.message));
138
+ console.error(chalk.red(extractErrorMessage(error)));
140
139
  process.exit(1);
141
140
  }
142
141
  });
@@ -150,8 +149,7 @@ export function registerIPFSCommands(program) {
150
149
  await manager.uninstall();
151
150
  }
152
151
  catch (error) {
153
- const err = error;
154
- console.error(chalk.red('\n❌ Uninstallation failed:'), err.message);
152
+ console.error(chalk.red('\n❌ Uninstallation failed:'), extractErrorMessage(error));
155
153
  process.exit(1);
156
154
  }
157
155
  });
@@ -171,9 +169,8 @@ export function registerIPFSCommands(program) {
171
169
  console.log('');
172
170
  }
173
171
  catch (error) {
174
- const err = error;
175
172
  spinner.fail(chalk.red('Initialization failed'));
176
- console.error(chalk.red(err.message));
173
+ console.error(chalk.red(extractErrorMessage(error)));
177
174
  process.exit(1);
178
175
  }
179
176
  });
@@ -187,8 +184,7 @@ export function registerIPFSCommands(program) {
187
184
  await manager.start();
188
185
  }
189
186
  catch (error) {
190
- const err = error;
191
- console.error(chalk.red('\n❌ Failed to start daemon:'), err.message);
187
+ console.error(chalk.red('\n❌ Failed to start daemon:'), extractErrorMessage(error));
192
188
  process.exit(1);
193
189
  }
194
190
  });
@@ -202,8 +198,7 @@ export function registerIPFSCommands(program) {
202
198
  await manager.stop();
203
199
  }
204
200
  catch (error) {
205
- const err = error;
206
- console.error(chalk.red('\n❌ Failed to stop daemon:'), err.message);
201
+ console.error(chalk.red('\n❌ Failed to stop daemon:'), extractErrorMessage(error));
207
202
  process.exit(1);
208
203
  }
209
204
  });
@@ -14,6 +14,7 @@ import { IPFSClientManager } from '../lib/ipfs-client-manager.js';
14
14
  import { getGitRepoInfo } from '../lib/git-utils.js';
15
15
  import { deriveKeyInfo, ensureKeyImported } from '../lib/ipns-key-manager.js';
16
16
  import { ENV_VARS, DEFAULTS } from '../constants/index.js';
17
+ import { extractErrorMessage } from '../lib/lsh-error.js';
17
18
  /**
18
19
  * Register sync commands
19
20
  */
@@ -81,9 +82,8 @@ export function registerSyncCommands(program) {
81
82
  installSpinner.succeed(chalk.green('IPFS client installed'));
82
83
  }
83
84
  catch (error) {
84
- const err = error;
85
85
  installSpinner.fail(chalk.red('Failed to install IPFS'));
86
- console.error(chalk.red(err.message));
86
+ console.error(chalk.red(extractErrorMessage(error)));
87
87
  process.exit(1);
88
88
  }
89
89
  }
@@ -98,14 +98,14 @@ export function registerSyncCommands(program) {
98
98
  initSpinner.succeed(chalk.green('IPFS repository initialized'));
99
99
  }
100
100
  catch (error) {
101
- const err = error;
101
+ const msg = extractErrorMessage(error);
102
102
  // Check if already initialized
103
- if (err.message.includes('already') || err.message.includes('exists')) {
103
+ if (msg.includes('already') || msg.includes('exists')) {
104
104
  initSpinner.succeed(chalk.green('IPFS repository already initialized'));
105
105
  }
106
106
  else {
107
107
  initSpinner.fail(chalk.red('Failed to initialize IPFS'));
108
- console.error(chalk.red(err.message));
108
+ console.error(chalk.red(msg));
109
109
  process.exit(1);
110
110
  }
111
111
  }
@@ -116,9 +116,8 @@ export function registerSyncCommands(program) {
116
116
  startSpinner.succeed(chalk.green('IPFS daemon started'));
117
117
  }
118
118
  catch (error) {
119
- const err = error;
120
119
  startSpinner.fail(chalk.red('Failed to start daemon'));
121
- console.error(chalk.red(err.message));
120
+ console.error(chalk.red(extractErrorMessage(error)));
122
121
  process.exit(1);
123
122
  }
124
123
  // Final status
@@ -148,8 +147,7 @@ export function registerSyncCommands(program) {
148
147
  console.log(chalk.green('✓ IPFS daemon running'));
149
148
  }
150
149
  catch (error) {
151
- const err = error;
152
- console.error(chalk.red(err.message));
150
+ console.error(chalk.red(extractErrorMessage(error)));
153
151
  process.exit(1);
154
152
  }
155
153
  // Step 2: Read and validate .env file
@@ -228,9 +226,8 @@ export function registerSyncCommands(program) {
228
226
  console.log('');
229
227
  }
230
228
  catch (error) {
231
- const err = error;
232
229
  uploadSpinner.fail(chalk.red('Sync failed'));
233
- console.error(chalk.red(err.message));
230
+ console.error(chalk.red(extractErrorMessage(error)));
234
231
  process.exit(1);
235
232
  }
236
233
  });
@@ -251,8 +248,7 @@ export function registerSyncCommands(program) {
251
248
  await ipfsManager.ensureDaemonRunning();
252
249
  }
253
250
  catch (error) {
254
- const err = error;
255
- spinner.fail(chalk.red(err.message));
251
+ spinner.fail(chalk.red(extractErrorMessage(error)));
256
252
  process.exit(1);
257
253
  }
258
254
  // Read .env file
@@ -306,35 +302,59 @@ export function registerSyncCommands(program) {
306
302
  spinner.succeed(chalk.green('Uploaded to IPFS!'));
307
303
  console.log('');
308
304
  console.log(chalk.bold('CID:'), chalk.cyan(cid));
309
- // Publish to IPNS
305
+ const repoName = gitInfo?.repoName || DEFAULTS.DEFAULT_ENVIRONMENT;
306
+ const env = options.env || DEFAULTS.DEFAULT_ENVIRONMENT;
307
+ // Publish to IPNS so teammates can `lsh sync pull` without a CID.
308
+ let ipnsPublished = false;
310
309
  if (encryptionKey) {
311
310
  try {
312
- const repoName = gitInfo?.repoName || DEFAULTS.DEFAULT_ENVIRONMENT;
313
- const env = options.env || DEFAULTS.DEFAULT_ENVIRONMENT;
314
311
  const keyInfo = deriveKeyInfo(encryptionKey, repoName, env);
315
312
  const ipnsName = await ensureKeyImported(ipfsSync.getApiUrl(), keyInfo);
316
313
  if (ipnsName) {
317
314
  const publishedName = await ipfsSync.publishToIPNS(cid, keyInfo.keyName);
318
315
  if (publishedName) {
319
316
  console.log(chalk.bold('IPNS:'), chalk.cyan(publishedName));
317
+ ipnsPublished = true;
320
318
  }
321
319
  }
322
320
  }
323
- catch {
324
- // Non-fatal
321
+ catch (error) {
322
+ console.error(chalk.yellow(`IPNS publish error: ${extractErrorMessage(error)}`));
325
323
  }
326
324
  }
325
+ // Durable remote pin (best-effort): makes the content survive this
326
+ // machine going offline. No-op unless a pinning service is configured.
327
+ const pinnedService = await ipfsSync.addRemotePin(cid, `lsh-${repoName}-${env}`);
328
+ if (pinnedService) {
329
+ console.log(chalk.bold('Pinned:'), chalk.cyan(`${pinnedService} (durable)`));
330
+ }
327
331
  console.log('');
328
332
  console.log(chalk.gray('Teammates can pull with just:'));
329
333
  console.log(chalk.cyan(' lsh sync pull'));
330
334
  console.log(chalk.gray('Or by specific CID:'));
331
335
  console.log(chalk.cyan(` lsh sync pull ${cid}`));
332
336
  console.log('');
337
+ // Honest durability reporting — do not claim success the user cannot rely on.
338
+ if (!pinnedService) {
339
+ console.log(chalk.yellow('⚠️ No remote pin — this content lives ONLY on this machine.'));
340
+ console.log(chalk.gray(' If this machine goes offline, teammates cannot fetch it.'));
341
+ console.log(chalk.gray(' Enable durable pinning (one-time):'));
342
+ console.log(chalk.gray(' ipfs pin remote service add <name> <endpoint> <key>'));
343
+ console.log(chalk.gray(' export LSH_PIN_SERVICE=<name>'));
344
+ console.log('');
345
+ }
346
+ if (!ipnsPublished) {
347
+ spinner.warn(chalk.yellow('IPNS publish failed — teammates CANNOT `lsh sync pull` (no CID) until you re-push.'));
348
+ console.log(chalk.gray(` They can still pull by explicit CID: lsh sync pull ${cid}`));
349
+ console.log('');
350
+ // The headline promise ("teammates can pull with just: lsh sync pull") failed,
351
+ // so exit non-zero rather than reporting a success the user cannot rely on.
352
+ process.exit(1);
353
+ }
333
354
  }
334
355
  catch (error) {
335
- const err = error;
336
356
  spinner.fail(chalk.red('Push failed'));
337
- console.error(chalk.red(err.message));
357
+ console.error(chalk.red(extractErrorMessage(error)));
338
358
  process.exit(1);
339
359
  }
340
360
  });
@@ -357,8 +377,7 @@ export function registerSyncCommands(program) {
357
377
  await ipfsManager.ensureDaemonRunning();
358
378
  }
359
379
  catch (error) {
360
- const err = error;
361
- spinner.fail(chalk.red(err.message));
380
+ spinner.fail(chalk.red(extractErrorMessage(error)));
362
381
  process.exit(1);
363
382
  }
364
383
  // Get encryption key
@@ -400,8 +419,7 @@ export function registerSyncCommands(program) {
400
419
  spinner.start('Downloading from IPFS...');
401
420
  }
402
421
  catch (error) {
403
- const err = error;
404
- spinner.fail(chalk.red(`IPNS resolution failed: ${err.message}`));
422
+ spinner.fail(chalk.red(`IPNS resolution failed: ${extractErrorMessage(error)}`));
405
423
  process.exit(1);
406
424
  }
407
425
  }
@@ -480,9 +498,8 @@ export function registerSyncCommands(program) {
480
498
  console.log('');
481
499
  }
482
500
  catch (error) {
483
- const err = error;
484
501
  spinner.fail(chalk.red('Pull failed'));
485
- console.error(chalk.red(err.message));
502
+ console.error(chalk.red(extractErrorMessage(error)));
486
503
  process.exit(1);
487
504
  }
488
505
  });
@@ -565,8 +582,7 @@ export function registerSyncCommands(program) {
565
582
  console.log('');
566
583
  }
567
584
  catch (error) {
568
- const err = error;
569
- console.error(chalk.red('Failed to check status:'), err.message);
585
+ console.error(chalk.red('Failed to check status:'), extractErrorMessage(error));
570
586
  process.exit(1);
571
587
  }
572
588
  });
@@ -615,8 +631,7 @@ export function registerSyncCommands(program) {
615
631
  console.log('');
616
632
  }
617
633
  catch (error) {
618
- const err = error;
619
- console.error(chalk.red('Failed to get history:'), err.message);
634
+ console.error(chalk.red('Failed to get history:'), extractErrorMessage(error));
620
635
  process.exit(1);
621
636
  }
622
637
  });
@@ -648,9 +663,8 @@ export function registerSyncCommands(program) {
648
663
  }
649
664
  }
650
665
  catch (error) {
651
- const err = error;
652
666
  spinner.fail(chalk.red('Verification failed'));
653
- console.error(chalk.red(err.message));
667
+ console.error(chalk.red(extractErrorMessage(error)));
654
668
  process.exit(1);
655
669
  }
656
670
  });
@@ -665,8 +679,7 @@ export function registerSyncCommands(program) {
665
679
  console.log(chalk.green('✅ Sync history cleared'));
666
680
  }
667
681
  catch (error) {
668
- const err = error;
669
- console.error(chalk.red('Failed to clear history:'), err.message);
682
+ console.error(chalk.red('Failed to clear history:'), extractErrorMessage(error));
670
683
  process.exit(1);
671
684
  }
672
685
  });
@@ -690,8 +703,7 @@ export function registerSyncCommands(program) {
690
703
  await manager.start();
691
704
  }
692
705
  catch (error) {
693
- const err = error;
694
- console.error(chalk.red('Failed to start daemon:'), err.message);
706
+ console.error(chalk.red('Failed to start daemon:'), extractErrorMessage(error));
695
707
  process.exit(1);
696
708
  }
697
709
  });
@@ -705,8 +717,7 @@ export function registerSyncCommands(program) {
705
717
  await manager.stop();
706
718
  }
707
719
  catch (error) {
708
- const err = error;
709
- console.error(chalk.red('Failed to stop daemon:'), err.message);
720
+ console.error(chalk.red('Failed to stop daemon:'), extractErrorMessage(error));
710
721
  process.exit(1);
711
722
  }
712
723
  });
@@ -30,6 +30,9 @@ export const ENV_VARS = {
30
30
  // Secrets management
31
31
  LSH_SECRETS_KEY: 'LSH_SECRETS_KEY',
32
32
  LSH_MASTER_KEY: 'LSH_MASTER_KEY',
33
+ // Name of the kubo remote pinning service to use for durable sync
34
+ // (configured via `ipfs pin remote service add <name> <endpoint> <key>`).
35
+ LSH_PIN_SERVICE: 'LSH_PIN_SERVICE',
33
36
  // Feature flags
34
37
  LSH_LOCAL_STORAGE_QUIET: 'LSH_LOCAL_STORAGE_QUIET',
35
38
  LSH_V1_COMPAT: 'LSH_V1_COMPAT',
@@ -25,7 +25,7 @@ export class FloatingPointArithmetic {
25
25
  return this.roundToPrecision(result);
26
26
  }
27
27
  catch (error) {
28
- throw new Error(`Arithmetic error: ${error.message}`);
28
+ throw new Error(`Arithmetic error: ${error.message}`, { cause: error });
29
29
  }
30
30
  }
31
31
  /**
@@ -193,7 +193,7 @@ export class FloatingPointArithmetic {
193
193
  return result;
194
194
  }
195
195
  catch (error) {
196
- throw new Error(`Expression evaluation failed: ${error.message}`);
196
+ throw new Error(`Expression evaluation failed: ${error.message}`, { cause: error });
197
197
  }
198
198
  }
199
199
  /**
@@ -5,14 +5,20 @@
5
5
  import * as fs from 'fs';
6
6
  import * as path from 'path';
7
7
  import * as os from 'os';
8
- import { exec, spawn } from 'child_process';
8
+ import { exec, execFile, spawn } from 'child_process';
9
9
  import { promisify } from 'util';
10
10
  import * as readline from 'readline';
11
11
  import { createLogger } from './logger.js';
12
12
  import { getPlatformInfo } from './platform-utils.js';
13
13
  import { getLshConfig } from './lsh-config.js';
14
14
  const execAsync = promisify(exec);
15
+ // execFile does NOT spawn a shell: arguments are passed literally to the binary,
16
+ // so interpolated values (e.g. a Kubo version) cannot inject shell commands.
17
+ const execFileAsync = promisify(execFile);
15
18
  const logger = createLogger('IPFSClientManager');
19
+ // Kubo releases are strict semver (e.g. "0.26.0"). Reject anything else before it
20
+ // reaches a download URL or subprocess — defense in depth against command injection.
21
+ const KUBO_VERSION_RE = /^\d+\.\d+\.\d+$/;
16
22
  /**
17
23
  * IPFS Client Manager
18
24
  *
@@ -91,6 +97,9 @@ export class IPFSClientManager {
91
97
  logger.info('📦 Installing IPFS client (Kubo)...');
92
98
  // Determine version to install
93
99
  const version = options.version || await this.getLatestKuboVersion();
100
+ if (!KUBO_VERSION_RE.test(version)) {
101
+ throw new Error(`Invalid Kubo version "${version}": expected semver like 0.26.0`);
102
+ }
94
103
  logger.info(` Version: ${version}`);
95
104
  logger.info(` Platform: ${platformInfo.platformName} ${platformInfo.arch}`);
96
105
  // Download and install based on platform
@@ -200,7 +209,7 @@ export class IPFSClientManager {
200
209
  }
201
210
  catch (initError) {
202
211
  const err = initError;
203
- throw new Error(`Failed to auto-initialize IPFS repo: ${err.message}`);
212
+ throw new Error(`Failed to auto-initialize IPFS repo: ${err.message}`, { cause: initError });
204
213
  }
205
214
  }
206
215
  logger.info('🚀 Starting IPFS daemon...');
@@ -267,6 +276,24 @@ export class IPFSClientManager {
267
276
  */
268
277
  async stop() {
269
278
  const pidPath = path.join(this.ipfsDir, 'daemon.pid');
279
+ // Try graceful shutdown via IPFS API first (works even without PID file)
280
+ try {
281
+ const response = await fetch('http://127.0.0.1:5001/api/v0/shutdown', {
282
+ method: 'POST',
283
+ signal: AbortSignal.timeout(5000),
284
+ });
285
+ if (response.ok) {
286
+ // Clean up PID file if it exists
287
+ if (fs.existsSync(pidPath)) {
288
+ fs.unlinkSync(pidPath);
289
+ }
290
+ logger.info('✅ IPFS daemon stopped');
291
+ return;
292
+ }
293
+ }
294
+ catch {
295
+ // API shutdown failed, fall back to PID-based kill
296
+ }
270
297
  if (!fs.existsSync(pidPath)) {
271
298
  logger.info('ℹ️ IPFS daemon not running (no PID file)');
272
299
  return;
@@ -280,6 +307,13 @@ export class IPFSClientManager {
280
307
  }
281
308
  catch (error) {
282
309
  const err = error;
310
+ // Process already gone — clean up stale PID file
311
+ if (err.code === 'ESRCH') {
312
+ if (fs.existsSync(pidPath))
313
+ fs.unlinkSync(pidPath);
314
+ logger.info('✅ IPFS daemon already stopped (stale PID file cleaned)');
315
+ return;
316
+ }
283
317
  logger.error(`❌ Failed to stop daemon: ${err.message}`);
284
318
  throw error;
285
319
  }
@@ -289,8 +323,12 @@ export class IPFSClientManager {
289
323
  */
290
324
  async getLatestKuboVersion() {
291
325
  try {
292
- // Use GitHub API to get latest release
293
- const response = await fetch('https://api.github.com/repos/ipfs/kubo/releases/latest');
326
+ // Use GitHub API to get latest release. Bound the request: an unbounded
327
+ // fetch hangs indefinitely on a blocked/slow network (e.g. CI runners),
328
+ // which would stall install() and time out tests before the fallback.
329
+ const response = await fetch('https://api.github.com/repos/ipfs/kubo/releases/latest', {
330
+ signal: AbortSignal.timeout(3000),
331
+ });
294
332
  const data = await response.json();
295
333
  // Remove 'v' prefix if present
296
334
  return data.tag_name.replace(/^v/, '');
@@ -308,11 +346,11 @@ export class IPFSClientManager {
308
346
  const downloadUrl = `https://dist.ipfs.tech/kubo/v${version}/kubo_v${version}_darwin-${arch}.tar.gz`;
309
347
  const tarPath = path.join(this.ipfsDir, 'kubo.tar.gz');
310
348
  logger.info(' Downloading Kubo...');
311
- // Download
312
- await execAsync(`curl -L -o ${tarPath} ${downloadUrl}`);
349
+ // Download (execFile: no shell, args passed literally)
350
+ await execFileAsync('curl', ['-L', '-o', tarPath, downloadUrl]);
313
351
  logger.info(' Extracting...');
314
352
  // Extract
315
- await execAsync(`tar -xzf ${tarPath} -C ${this.ipfsDir}`);
353
+ await execFileAsync('tar', ['-xzf', tarPath, '-C', this.ipfsDir]);
316
354
  // Move binary
317
355
  const extractedBinPath = path.join(this.ipfsDir, 'kubo', 'ipfs');
318
356
  fs.mkdirSync(this.binDir, { recursive: true });
@@ -331,11 +369,11 @@ export class IPFSClientManager {
331
369
  const downloadUrl = `https://dist.ipfs.tech/kubo/v${version}/kubo_v${version}_linux-${arch}.tar.gz`;
332
370
  const tarPath = path.join(this.ipfsDir, 'kubo.tar.gz');
333
371
  logger.info(' Downloading Kubo...');
334
- // Download
335
- await execAsync(`curl -L -o ${tarPath} ${downloadUrl}`);
372
+ // Download (execFile: no shell, args passed literally)
373
+ await execFileAsync('curl', ['-L', '-o', tarPath, downloadUrl]);
336
374
  logger.info(' Extracting...');
337
375
  // Extract
338
- await execAsync(`tar -xzf ${tarPath} -C ${this.ipfsDir}`);
376
+ await execFileAsync('tar', ['-xzf', tarPath, '-C', this.ipfsDir]);
339
377
  // Move binary
340
378
  const extractedBinPath = path.join(this.ipfsDir, 'kubo', 'ipfs');
341
379
  fs.mkdirSync(this.binDir, { recursive: true });
@@ -353,11 +391,11 @@ export class IPFSClientManager {
353
391
  const downloadUrl = `https://dist.ipfs.tech/kubo/v${version}/kubo_v${version}_windows-amd64.zip`;
354
392
  const zipPath = path.join(this.ipfsDir, 'kubo.zip');
355
393
  logger.info(' Downloading Kubo...');
356
- // Download
357
- await execAsync(`curl -L -o ${zipPath} ${downloadUrl}`);
394
+ // Download (execFile: no shell, args passed literally)
395
+ await execFileAsync('curl', ['-L', '-o', zipPath, downloadUrl]);
358
396
  logger.info(' Extracting...');
359
397
  // Extract (Windows has built-in tar that supports zip)
360
- await execAsync(`tar -xf ${zipPath} -C ${this.ipfsDir}`);
398
+ await execFileAsync('tar', ['-xf', zipPath, '-C', this.ipfsDir]);
361
399
  // Move binary
362
400
  const extractedBinPath = path.join(this.ipfsDir, 'kubo', 'ipfs.exe');
363
401
  fs.mkdirSync(this.binDir, { recursive: true });