lsh-framework 1.5.1 → 1.7.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.
package/README.md CHANGED
@@ -8,21 +8,21 @@
8
8
 
9
9
  Traditional secret management tools are either too complex, too expensive, or require vendor lock-in. LSH gives you:
10
10
 
11
- - **Encrypted sync** across all your machines using Supabase/PostgreSQL
11
+ - **Encrypted sync** across all your machines using IPFS content-addressed storage
12
12
  - **Automatic rotation** with built-in daemon scheduling
13
13
  - **Team collaboration** with shared encryption keys
14
14
  - **Multi-environment** support (dev/staging/prod)
15
- - **Self-hosted** - your data, your infrastructure
16
- - **Free & Open Source** - no per-seat pricing
15
+ - **Local-first** - works offline, your data stays on your machine
16
+ - **Free & Open Source** - no per-seat pricing, no cloud dependencies
17
17
 
18
18
  **Plus, you get a complete shell automation platform as a bonus.**
19
19
 
20
20
  ## Quick Start
21
21
 
22
- **New to LSH?** See our [Quick Start Guide](docs/QUICK_START.md) for three easy onboarding options:
23
- 1. **Local-Only Mode** - Zero configuration, works immediately (no database needed)
24
- 2. **Local PostgreSQL** - Docker-based setup for local development
25
- 3. **Supabase Cloud** - Full team collaboration features
22
+ **New to LSH?** LSH uses IPFS-based local storage - zero configuration needed!
23
+ - **Local-first** - All secrets stored encrypted on your machine at `~/.lsh/secrets-cache/`
24
+ - **No cloud required** - Works completely offline
25
+ - **Team sync** - Share encryption key to sync across team members
26
26
 
27
27
  ### Quick Install (Works Immediately!)
28
28
 
@@ -30,9 +30,10 @@ Traditional secret management tools are either too complex, too expensive, or re
30
30
  # Install LSH
31
31
  npm install -g lsh-framework
32
32
 
33
- # That's it! LSH works without any database configuration
33
+ # That's it! LSH works immediately with IPFS storage
34
34
  # Config: ~/.config/lsh/lshrc (auto-created)
35
- # Data: ~/.lsh/data/storage.json (local storage)
35
+ # Secrets: ~/.lsh/secrets-cache/ (encrypted IPFS storage)
36
+ # Metadata: ~/.lsh/secrets-metadata.json
36
37
 
37
38
  # Start using it right away
38
39
  lsh --version
@@ -40,18 +41,13 @@ lsh config # Edit configuration (optional)
40
41
  lsh daemon start
41
42
  ```
42
43
 
43
- ### Smart Sync (Easiest Way for Cloud!)
44
+ ### Smart Sync (Easiest Way!)
44
45
 
45
46
  ```bash
46
47
  # 1. Install
47
48
  npm install -g lsh-framework
48
49
 
49
- # 2. Configure Supabase (optional - free tier works!)
50
- # Add to .env:
51
- # SUPABASE_URL=https://your-project.supabase.co
52
- # SUPABASE_ANON_KEY=<your-anon-key>
53
-
54
- # 3. ONE command does everything!
50
+ # 2. ONE command does everything!
55
51
  cd ~/repos/your-project
56
52
  lsh sync
57
53
 
@@ -59,8 +55,9 @@ lsh sync
59
55
  # ✅ Auto-generates encryption key
60
56
  # ✅ Creates .env from .env.example
61
57
  # ✅ Adds .env to .gitignore
62
- # ✅ Pushes to cloud (if configured) or local storage
58
+ # ✅ Stores encrypted secrets locally via IPFS
63
59
  # ✅ Namespaces by repo name
60
+ # ✅ Works completely offline
64
61
  ```
65
62
 
66
63
  ### Sync AND Load in One Command
@@ -73,29 +70,25 @@ eval "$(lsh sync --load)"
73
70
  echo $DATABASE_URL
74
71
  ```
75
72
 
76
- ### Traditional Method (Still Works)
73
+ ### Traditional Method (Manual Control)
77
74
 
78
75
  ```bash
79
76
  # 1. Install
80
77
  npm install -g lsh-framework
81
78
 
82
- # 2. Generate encryption key
79
+ # 2. Generate encryption key (for team sharing)
83
80
  lsh key
84
81
  # Add the output to your .env:
85
82
  # LSH_SECRETS_KEY=<your-key>
86
83
 
87
- # 3. Configure Supabase (free tier works!)
88
- # Add to .env:
89
- # SUPABASE_URL=https://your-project.supabase.co
90
- # SUPABASE_ANON_KEY=<your-anon-key>
91
-
92
- # 4. Push your secrets
84
+ # 3. Push your secrets (encrypted locally via IPFS)
93
85
  lsh push
94
86
 
95
- # 5. Pull on any other machine
87
+ # 4. Pull on any other machine (with same encryption key)
96
88
  lsh pull
97
89
 
98
- # Done! Your secrets are synced.
90
+ # Done! Your secrets are synced via encrypted IPFS storage.
91
+ # Share the LSH_SECRETS_KEY with team members for collaboration.
99
92
  ```
100
93
 
101
94
  ## Core Features
@@ -822,6 +815,9 @@ lsh daemon start
822
815
  - **[SECRETS_QUICK_REFERENCE.md](docs/features/secrets/SECRETS_QUICK_REFERENCE.md)** - Quick reference for daily use
823
816
  - **[SECRETS_CHEATSHEET.txt](SECRETS_CHEATSHEET.txt)** - Command cheatsheet
824
817
 
818
+ ### IPFS Integration
819
+ - **[IPFS_CLIENT_GUIDE.md](docs/features/ipfs/IPFS_CLIENT_GUIDE.md)** - 🆕 IPFS client installation and management (v1.6.0+)
820
+
825
821
  ### Installation & Development
826
822
  - **[INSTALL.md](docs/deployment/INSTALL.md)** - Detailed installation instructions
827
823
  - **[CLAUDE.md](CLAUDE.md)** - Developer guide for contributors
package/dist/cli.js CHANGED
@@ -10,6 +10,7 @@ import { registerDoctorCommands } from './commands/doctor.js';
10
10
  import { registerCompletionCommands } from './commands/completion.js';
11
11
  import { registerConfigCommands } from './commands/config.js';
12
12
  import { registerSyncHistoryCommands } from './commands/sync-history.js';
13
+ import { registerIPFSCommands } from './commands/ipfs.js';
13
14
  import { init_daemon } from './services/daemon/daemon.js';
14
15
  import { init_supabase } from './services/supabase/supabase.js';
15
16
  import { init_cron } from './services/cron/cron.js';
@@ -144,6 +145,7 @@ function findSimilarCommands(input, validCommands) {
144
145
  registerDoctorCommands(program);
145
146
  registerConfigCommands(program);
146
147
  registerSyncHistoryCommands(program);
148
+ registerIPFSCommands(program);
147
149
  // Secrets management (primary feature)
148
150
  await init_secrets(program);
149
151
  // Supporting services
@@ -7,6 +7,7 @@ import * as fs from 'fs/promises';
7
7
  import * as path from 'path';
8
8
  import { createClient } from '@supabase/supabase-js';
9
9
  import { getPlatformPaths, getPlatformInfo } from '../lib/platform-utils.js';
10
+ import { IPFSClientManager } from '../lib/ipfs-client-manager.js';
10
11
  /**
11
12
  * Register doctor commands
12
13
  */
@@ -48,6 +49,8 @@ async function runHealthCheck(options) {
48
49
  checks.push(...storageChecks);
49
50
  // Git repository check
50
51
  checks.push(await checkGitRepository(options.verbose));
52
+ // IPFS client check
53
+ checks.push(await checkIPFSClient(options.verbose));
51
54
  // Permissions check
52
55
  checks.push(await checkPermissions(options.verbose));
53
56
  // Display results
@@ -220,9 +223,10 @@ async function checkStorageBackend(verbose) {
220
223
  async function testSupabaseConnection(url, key, verbose) {
221
224
  try {
222
225
  const supabase = createClient(url, key);
223
- // Try to query (404 for missing table is fine - means connection works)
226
+ // Try to query
224
227
  const { error } = await supabase.from('lsh_secrets').select('count').limit(0);
225
- if (!error || error.code === 'PGRST116' || error.message.includes('relation')) {
228
+ // No error means table exists and connection works
229
+ if (!error) {
226
230
  return {
227
231
  name: 'Supabase Connection',
228
232
  status: 'pass',
@@ -230,6 +234,16 @@ async function testSupabaseConnection(url, key, verbose) {
230
234
  details: verbose ? url : undefined,
231
235
  };
232
236
  }
237
+ // Check if table doesn't exist (PGRST116 or relation not found errors)
238
+ if (error.code === 'PGRST116' || error.message.includes('relation') || error.message.includes('table') || error.message.includes('schema cache')) {
239
+ return {
240
+ name: 'Storage Mode',
241
+ status: 'pass',
242
+ message: 'Using IPFS storage (Supabase table not found)',
243
+ details: 'Secrets: ~/.lsh/secrets-cache/ | Metadata: ~/.lsh/secrets-metadata.json | IPFS audit logs: ~/.lsh/ipfs/',
244
+ };
245
+ }
246
+ // Other connection errors
233
247
  return {
234
248
  name: 'Supabase Connection',
235
249
  status: 'warn',
@@ -283,6 +297,38 @@ async function checkGitRepository(verbose) {
283
297
  };
284
298
  }
285
299
  }
300
+ /**
301
+ * Check IPFS client installation
302
+ */
303
+ async function checkIPFSClient(verbose) {
304
+ try {
305
+ const manager = new IPFSClientManager();
306
+ const info = await manager.detect();
307
+ if (info.installed) {
308
+ return {
309
+ name: 'IPFS Client',
310
+ status: 'pass',
311
+ message: `${info.type} v${info.version} installed`,
312
+ details: verbose ? `Path: ${info.path}` : undefined,
313
+ };
314
+ }
315
+ return {
316
+ name: 'IPFS Client',
317
+ status: 'warn',
318
+ message: 'Not installed (optional for local storage)',
319
+ details: 'Install with: lsh ipfs install',
320
+ };
321
+ }
322
+ catch (error) {
323
+ const err = error;
324
+ return {
325
+ name: 'IPFS Client',
326
+ status: 'warn',
327
+ message: 'Could not check IPFS client',
328
+ details: err.message,
329
+ };
330
+ }
331
+ }
286
332
  /**
287
333
  * Check file permissions
288
334
  */
@@ -0,0 +1,146 @@
1
+ /**
2
+ * IPFS Commands
3
+ * Manage IPFS client installation and configuration
4
+ */
5
+ import chalk from 'chalk';
6
+ import ora from 'ora';
7
+ import { IPFSClientManager } from '../lib/ipfs-client-manager.js';
8
+ /**
9
+ * Register IPFS commands
10
+ */
11
+ export function registerIPFSCommands(program) {
12
+ const ipfsCommand = program
13
+ .command('ipfs')
14
+ .description('Manage IPFS client installation and configuration');
15
+ // lsh ipfs status
16
+ ipfsCommand
17
+ .command('status')
18
+ .description('Check IPFS client installation status')
19
+ .option('--json', 'Output as JSON')
20
+ .action(async (options) => {
21
+ try {
22
+ const manager = new IPFSClientManager();
23
+ const info = await manager.detect();
24
+ if (options.json) {
25
+ console.log(JSON.stringify(info, null, 2));
26
+ return;
27
+ }
28
+ console.log(chalk.bold.cyan('\n📦 IPFS Client Status'));
29
+ console.log(chalk.gray('━'.repeat(50)));
30
+ console.log('');
31
+ if (info.installed) {
32
+ console.log(chalk.green('✅ IPFS client installed'));
33
+ console.log(` Type: ${info.type}`);
34
+ console.log(` Version: ${info.version}`);
35
+ console.log(` Path: ${info.path}`);
36
+ }
37
+ else {
38
+ console.log(chalk.yellow('⚠️ IPFS client not installed'));
39
+ console.log('');
40
+ console.log(chalk.gray(' Install with: lsh ipfs install'));
41
+ }
42
+ console.log('');
43
+ }
44
+ catch (error) {
45
+ const err = error;
46
+ console.error(chalk.red('\n❌ Failed to check status:'), err.message);
47
+ process.exit(1);
48
+ }
49
+ });
50
+ // lsh ipfs install
51
+ ipfsCommand
52
+ .command('install')
53
+ .description('Install IPFS client (Kubo)')
54
+ .option('-f, --force', 'Force reinstall even if already installed')
55
+ .option('-v, --version <version>', 'Install specific version')
56
+ .action(async (options) => {
57
+ const spinner = ora('Installing IPFS client...').start();
58
+ try {
59
+ const manager = new IPFSClientManager();
60
+ await manager.install({
61
+ force: options.force,
62
+ version: options.version,
63
+ });
64
+ spinner.succeed(chalk.green('IPFS client installed successfully!'));
65
+ console.log('');
66
+ console.log(chalk.gray('Next steps:'));
67
+ console.log(chalk.cyan(' lsh ipfs init # Initialize IPFS repository'));
68
+ console.log(chalk.cyan(' lsh ipfs start # Start IPFS daemon'));
69
+ console.log('');
70
+ }
71
+ catch (error) {
72
+ const err = error;
73
+ spinner.fail(chalk.red('Installation failed'));
74
+ console.error(chalk.red(err.message));
75
+ process.exit(1);
76
+ }
77
+ });
78
+ // lsh ipfs uninstall
79
+ ipfsCommand
80
+ .command('uninstall')
81
+ .description('Uninstall LSH-managed IPFS client')
82
+ .action(async () => {
83
+ try {
84
+ const manager = new IPFSClientManager();
85
+ await manager.uninstall();
86
+ }
87
+ catch (error) {
88
+ const err = error;
89
+ console.error(chalk.red('\n❌ Uninstallation failed:'), err.message);
90
+ process.exit(1);
91
+ }
92
+ });
93
+ // lsh ipfs init
94
+ ipfsCommand
95
+ .command('init')
96
+ .description('Initialize IPFS repository')
97
+ .action(async () => {
98
+ const spinner = ora('Initializing IPFS repository...').start();
99
+ try {
100
+ const manager = new IPFSClientManager();
101
+ await manager.init();
102
+ spinner.succeed(chalk.green('IPFS repository initialized!'));
103
+ console.log('');
104
+ console.log(chalk.gray('Next step:'));
105
+ console.log(chalk.cyan(' lsh ipfs start # Start IPFS daemon'));
106
+ console.log('');
107
+ }
108
+ catch (error) {
109
+ const err = error;
110
+ spinner.fail(chalk.red('Initialization failed'));
111
+ console.error(chalk.red(err.message));
112
+ process.exit(1);
113
+ }
114
+ });
115
+ // lsh ipfs start
116
+ ipfsCommand
117
+ .command('start')
118
+ .description('Start IPFS daemon')
119
+ .action(async () => {
120
+ try {
121
+ const manager = new IPFSClientManager();
122
+ await manager.start();
123
+ }
124
+ catch (error) {
125
+ const err = error;
126
+ console.error(chalk.red('\n❌ Failed to start daemon:'), err.message);
127
+ process.exit(1);
128
+ }
129
+ });
130
+ // lsh ipfs stop
131
+ ipfsCommand
132
+ .command('stop')
133
+ .description('Stop IPFS daemon')
134
+ .action(async () => {
135
+ try {
136
+ const manager = new IPFSClientManager();
137
+ await manager.stop();
138
+ }
139
+ catch (error) {
140
+ const err = error;
141
+ console.error(chalk.red('\n❌ Failed to stop daemon:'), err.message);
142
+ process.exit(1);
143
+ }
144
+ });
145
+ }
146
+ export default registerIPFSCommands;
@@ -0,0 +1,321 @@
1
+ /**
2
+ * IPFS Client Manager
3
+ * Detects, installs, and manages IPFS clients across platforms
4
+ */
5
+ import * as fs from 'fs';
6
+ import * as path from 'path';
7
+ import * as os from 'os';
8
+ import { exec } from 'child_process';
9
+ import { promisify } from 'util';
10
+ import { createLogger } from './logger.js';
11
+ import { getPlatformInfo } from './platform-utils.js';
12
+ const execAsync = promisify(exec);
13
+ const logger = createLogger('IPFSClientManager');
14
+ /**
15
+ * IPFS Client Manager
16
+ *
17
+ * Manages IPFS client installation and configuration:
18
+ * 1. Kubo (formerly go-ipfs) - Official Go implementation
19
+ * 2. Helia - Modern JavaScript implementation
20
+ * 3. js-ipfs - Legacy JavaScript implementation (deprecated)
21
+ */
22
+ export class IPFSClientManager {
23
+ lshDir;
24
+ ipfsDir;
25
+ binDir;
26
+ constructor() {
27
+ this.lshDir = path.join(os.homedir(), '.lsh');
28
+ this.ipfsDir = path.join(this.lshDir, 'ipfs');
29
+ this.binDir = path.join(this.ipfsDir, 'bin');
30
+ // Ensure directories exist
31
+ this.ensureDirectories();
32
+ }
33
+ /**
34
+ * Detect if IPFS client is installed
35
+ */
36
+ async detect() {
37
+ // Check for system-wide Kubo installation
38
+ try {
39
+ const { stdout: kuboVersion } = await execAsync('ipfs version');
40
+ const versionMatch = kuboVersion.match(/ipfs version ([0-9.]+)/);
41
+ if (versionMatch) {
42
+ const { stdout: kuboPath } = await execAsync('which ipfs');
43
+ return {
44
+ installed: true,
45
+ version: versionMatch[1],
46
+ path: kuboPath.trim(),
47
+ type: 'kubo',
48
+ };
49
+ }
50
+ }
51
+ catch {
52
+ // Kubo not found in system PATH
53
+ }
54
+ // Check for local LSH-managed installation
55
+ const localIpfsPath = path.join(this.binDir, 'ipfs');
56
+ if (fs.existsSync(localIpfsPath)) {
57
+ try {
58
+ const { stdout: localVersion } = await execAsync(`${localIpfsPath} version`);
59
+ const versionMatch = localVersion.match(/ipfs version ([0-9.]+)/);
60
+ if (versionMatch) {
61
+ return {
62
+ installed: true,
63
+ version: versionMatch[1],
64
+ path: localIpfsPath,
65
+ type: 'kubo',
66
+ };
67
+ }
68
+ }
69
+ catch {
70
+ // Local binary exists but not working
71
+ }
72
+ }
73
+ return {
74
+ installed: false,
75
+ };
76
+ }
77
+ /**
78
+ * Install IPFS client
79
+ */
80
+ async install(options = {}) {
81
+ const platformInfo = getPlatformInfo();
82
+ const clientInfo = await this.detect();
83
+ // Check if already installed
84
+ if (clientInfo.installed && !options.force) {
85
+ logger.info(`✅ IPFS already installed: ${clientInfo.type} v${clientInfo.version}`);
86
+ logger.info(` Path: ${clientInfo.path}`);
87
+ return;
88
+ }
89
+ logger.info('📦 Installing IPFS client (Kubo)...');
90
+ // Determine version to install
91
+ const version = options.version || await this.getLatestKuboVersion();
92
+ logger.info(` Version: ${version}`);
93
+ logger.info(` Platform: ${platformInfo.platformName} ${platformInfo.arch}`);
94
+ // Download and install based on platform
95
+ try {
96
+ if (platformInfo.platform === 'darwin') {
97
+ await this.installKuboMacOS(version);
98
+ }
99
+ else if (platformInfo.platform === 'linux') {
100
+ await this.installKuboLinux(version);
101
+ }
102
+ else if (platformInfo.platform === 'win32') {
103
+ await this.installKuboWindows(version);
104
+ }
105
+ else {
106
+ throw new Error(`Unsupported platform: ${platformInfo.platform}`);
107
+ }
108
+ logger.info('✅ IPFS client installed successfully!');
109
+ // Verify installation
110
+ const verifyInfo = await this.detect();
111
+ if (verifyInfo.installed) {
112
+ logger.info(` Version: ${verifyInfo.version}`);
113
+ logger.info(` Path: ${verifyInfo.path}`);
114
+ }
115
+ }
116
+ catch (error) {
117
+ const err = error;
118
+ logger.error(`❌ Installation failed: ${err.message}`);
119
+ throw error;
120
+ }
121
+ }
122
+ /**
123
+ * Uninstall LSH-managed IPFS client
124
+ */
125
+ async uninstall() {
126
+ const clientInfo = await this.detect();
127
+ if (!clientInfo.installed) {
128
+ logger.info('ℹ️ No IPFS client installed');
129
+ return;
130
+ }
131
+ // Only uninstall if it's LSH-managed
132
+ if (clientInfo.path?.startsWith(this.binDir)) {
133
+ logger.info('🗑️ Uninstalling LSH-managed IPFS client...');
134
+ if (fs.existsSync(this.ipfsDir)) {
135
+ fs.rmSync(this.ipfsDir, { recursive: true });
136
+ }
137
+ logger.info('✅ IPFS client uninstalled');
138
+ }
139
+ else {
140
+ logger.info('ℹ️ System-wide IPFS installation detected');
141
+ logger.info(' LSH cannot uninstall system packages');
142
+ logger.info(` Path: ${clientInfo.path}`);
143
+ }
144
+ }
145
+ /**
146
+ * Initialize IPFS repository
147
+ */
148
+ async init() {
149
+ const clientInfo = await this.detect();
150
+ if (!clientInfo.installed) {
151
+ throw new Error('IPFS client not installed. Run: lsh ipfs install');
152
+ }
153
+ const ipfsRepoPath = path.join(this.ipfsDir, 'repo');
154
+ // Check if already initialized
155
+ if (fs.existsSync(path.join(ipfsRepoPath, 'config'))) {
156
+ logger.info('✅ IPFS repository already initialized');
157
+ return;
158
+ }
159
+ logger.info('🔧 Initializing IPFS repository...');
160
+ try {
161
+ const ipfsCmd = clientInfo.path || 'ipfs';
162
+ await execAsync(`${ipfsCmd} init`, {
163
+ env: { ...process.env, IPFS_PATH: ipfsRepoPath },
164
+ });
165
+ logger.info('✅ IPFS repository initialized');
166
+ logger.info(` Path: ${ipfsRepoPath}`);
167
+ }
168
+ catch (error) {
169
+ const err = error;
170
+ logger.error(`❌ Initialization failed: ${err.message}`);
171
+ throw error;
172
+ }
173
+ }
174
+ /**
175
+ * Start IPFS daemon
176
+ */
177
+ async start() {
178
+ const clientInfo = await this.detect();
179
+ if (!clientInfo.installed) {
180
+ throw new Error('IPFS client not installed. Run: lsh ipfs install');
181
+ }
182
+ logger.info('🚀 Starting IPFS daemon...');
183
+ const ipfsRepoPath = path.join(this.ipfsDir, 'repo');
184
+ const ipfsCmd = clientInfo.path || 'ipfs';
185
+ try {
186
+ // Start daemon in background
187
+ const daemon = exec(`${ipfsCmd} daemon`, {
188
+ env: { ...process.env, IPFS_PATH: ipfsRepoPath },
189
+ });
190
+ // Log PID for management
191
+ const pidPath = path.join(this.ipfsDir, 'daemon.pid');
192
+ if (daemon.pid) {
193
+ fs.writeFileSync(pidPath, daemon.pid.toString(), 'utf8');
194
+ }
195
+ logger.info('✅ IPFS daemon started');
196
+ logger.info(` PID: ${daemon.pid}`);
197
+ logger.info(' API: http://localhost:5001');
198
+ logger.info(' Gateway: http://localhost:8080');
199
+ }
200
+ catch (error) {
201
+ const err = error;
202
+ logger.error(`❌ Failed to start daemon: ${err.message}`);
203
+ throw error;
204
+ }
205
+ }
206
+ /**
207
+ * Stop IPFS daemon
208
+ */
209
+ async stop() {
210
+ const pidPath = path.join(this.ipfsDir, 'daemon.pid');
211
+ if (!fs.existsSync(pidPath)) {
212
+ logger.info('ℹ️ IPFS daemon not running (no PID file)');
213
+ return;
214
+ }
215
+ const pid = parseInt(fs.readFileSync(pidPath, 'utf8'), 10);
216
+ logger.info(`🛑 Stopping IPFS daemon (PID: ${pid})...`);
217
+ try {
218
+ process.kill(pid, 'SIGTERM');
219
+ fs.unlinkSync(pidPath);
220
+ logger.info('✅ IPFS daemon stopped');
221
+ }
222
+ catch (error) {
223
+ const err = error;
224
+ logger.error(`❌ Failed to stop daemon: ${err.message}`);
225
+ throw error;
226
+ }
227
+ }
228
+ /**
229
+ * Get latest Kubo version from GitHub releases
230
+ */
231
+ async getLatestKuboVersion() {
232
+ try {
233
+ // Use GitHub API to get latest release
234
+ const response = await fetch('https://api.github.com/repos/ipfs/kubo/releases/latest');
235
+ const data = await response.json();
236
+ // Remove 'v' prefix if present
237
+ return data.tag_name.replace(/^v/, '');
238
+ }
239
+ catch {
240
+ // Fallback to known stable version
241
+ return '0.26.0';
242
+ }
243
+ }
244
+ /**
245
+ * Install Kubo on macOS
246
+ */
247
+ async installKuboMacOS(version) {
248
+ const arch = os.arch() === 'arm64' ? 'arm64' : 'amd64';
249
+ const downloadUrl = `https://dist.ipfs.tech/kubo/v${version}/kubo_v${version}_darwin-${arch}.tar.gz`;
250
+ const tarPath = path.join(this.ipfsDir, 'kubo.tar.gz');
251
+ logger.info(' Downloading Kubo...');
252
+ // Download
253
+ await execAsync(`curl -L -o ${tarPath} ${downloadUrl}`);
254
+ logger.info(' Extracting...');
255
+ // Extract
256
+ await execAsync(`tar -xzf ${tarPath} -C ${this.ipfsDir}`);
257
+ // Move binary
258
+ const extractedBinPath = path.join(this.ipfsDir, 'kubo', 'ipfs');
259
+ fs.mkdirSync(this.binDir, { recursive: true });
260
+ fs.renameSync(extractedBinPath, path.join(this.binDir, 'ipfs'));
261
+ // Make executable
262
+ fs.chmodSync(path.join(this.binDir, 'ipfs'), 0o755);
263
+ // Cleanup
264
+ fs.unlinkSync(tarPath);
265
+ fs.rmSync(path.join(this.ipfsDir, 'kubo'), { recursive: true });
266
+ }
267
+ /**
268
+ * Install Kubo on Linux
269
+ */
270
+ async installKuboLinux(version) {
271
+ const arch = os.arch() === 'arm64' ? 'arm64' : 'amd64';
272
+ const downloadUrl = `https://dist.ipfs.tech/kubo/v${version}/kubo_v${version}_linux-${arch}.tar.gz`;
273
+ const tarPath = path.join(this.ipfsDir, 'kubo.tar.gz');
274
+ logger.info(' Downloading Kubo...');
275
+ // Download
276
+ await execAsync(`curl -L -o ${tarPath} ${downloadUrl}`);
277
+ logger.info(' Extracting...');
278
+ // Extract
279
+ await execAsync(`tar -xzf ${tarPath} -C ${this.ipfsDir}`);
280
+ // Move binary
281
+ const extractedBinPath = path.join(this.ipfsDir, 'kubo', 'ipfs');
282
+ fs.mkdirSync(this.binDir, { recursive: true });
283
+ fs.renameSync(extractedBinPath, path.join(this.binDir, 'ipfs'));
284
+ // Make executable
285
+ fs.chmodSync(path.join(this.binDir, 'ipfs'), 0o755);
286
+ // Cleanup
287
+ fs.unlinkSync(tarPath);
288
+ fs.rmSync(path.join(this.ipfsDir, 'kubo'), { recursive: true });
289
+ }
290
+ /**
291
+ * Install Kubo on Windows
292
+ */
293
+ async installKuboWindows(version) {
294
+ const downloadUrl = `https://dist.ipfs.tech/kubo/v${version}/kubo_v${version}_windows-amd64.zip`;
295
+ const zipPath = path.join(this.ipfsDir, 'kubo.zip');
296
+ logger.info(' Downloading Kubo...');
297
+ // Download
298
+ await execAsync(`curl -L -o ${zipPath} ${downloadUrl}`);
299
+ logger.info(' Extracting...');
300
+ // Extract (Windows has built-in tar that supports zip)
301
+ await execAsync(`tar -xf ${zipPath} -C ${this.ipfsDir}`);
302
+ // Move binary
303
+ const extractedBinPath = path.join(this.ipfsDir, 'kubo', 'ipfs.exe');
304
+ fs.mkdirSync(this.binDir, { recursive: true });
305
+ fs.renameSync(extractedBinPath, path.join(this.binDir, 'ipfs.exe'));
306
+ // Cleanup
307
+ fs.unlinkSync(zipPath);
308
+ fs.rmSync(path.join(this.ipfsDir, 'kubo'), { recursive: true });
309
+ }
310
+ /**
311
+ * Ensure required directories exist
312
+ */
313
+ ensureDirectories() {
314
+ if (!fs.existsSync(this.lshDir)) {
315
+ fs.mkdirSync(this.lshDir, { recursive: true });
316
+ }
317
+ if (!fs.existsSync(this.ipfsDir)) {
318
+ fs.mkdirSync(this.ipfsDir, { recursive: true });
319
+ }
320
+ }
321
+ }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * IPFS Secrets Storage Adapter
3
+ * Stores encrypted secrets on IPFS using Storacha (formerly web3.storage)
4
+ */
5
+ import * as fs from 'fs';
6
+ import * as path from 'path';
7
+ import * as os from 'os';
8
+ import * as crypto from 'crypto';
9
+ import { createLogger } from './logger.js';
10
+ const logger = createLogger('IPFSSecretsStorage');
11
+ /**
12
+ * IPFS Secrets Storage
13
+ *
14
+ * Stores encrypted secrets on IPFS with local caching
15
+ *
16
+ * Features:
17
+ * - Content-addressed storage (IPFS CIDs)
18
+ * - AES-256 encryption before upload
19
+ * - Local cache for offline access
20
+ * - Environment-based organization
21
+ */
22
+ export class IPFSSecretsStorage {
23
+ cacheDir;
24
+ metadataPath;
25
+ metadata;
26
+ constructor() {
27
+ const lshDir = path.join(os.homedir(), '.lsh');
28
+ this.cacheDir = path.join(lshDir, 'secrets-cache');
29
+ this.metadataPath = path.join(lshDir, 'secrets-metadata.json');
30
+ // Ensure directories exist
31
+ if (!fs.existsSync(this.cacheDir)) {
32
+ fs.mkdirSync(this.cacheDir, { recursive: true });
33
+ }
34
+ // Load metadata
35
+ this.metadata = this.loadMetadata();
36
+ }
37
+ /**
38
+ * Store secrets on IPFS
39
+ */
40
+ async push(secrets, environment, encryptionKey, gitRepo, gitBranch) {
41
+ try {
42
+ // Encrypt secrets
43
+ const encryptedData = this.encryptSecrets(secrets, encryptionKey);
44
+ // Generate CID from encrypted content
45
+ const cid = this.generateCID(encryptedData);
46
+ // Store locally (cache)
47
+ await this.storeLocally(cid, encryptedData, environment);
48
+ // Update metadata
49
+ const metadata = {
50
+ environment,
51
+ git_repo: gitRepo,
52
+ git_branch: gitBranch,
53
+ cid,
54
+ timestamp: new Date().toISOString(),
55
+ keys_count: secrets.length,
56
+ encrypted: true,
57
+ };
58
+ this.metadata[this.getMetadataKey(gitRepo, environment)] = metadata;
59
+ this.saveMetadata();
60
+ logger.info(`📦 Stored ${secrets.length} secrets on IPFS: ${cid}`);
61
+ logger.info(` Environment: ${environment}`);
62
+ if (gitRepo) {
63
+ logger.info(` Repository: ${gitRepo}/${gitBranch || 'main'}`);
64
+ }
65
+ // TODO: In future, upload to real IPFS network via Storacha
66
+ // For now, using local storage with IPFS-compatible CIDs
67
+ return cid;
68
+ }
69
+ catch (error) {
70
+ const err = error;
71
+ logger.error(`Failed to push secrets to IPFS: ${err.message}`);
72
+ throw error;
73
+ }
74
+ }
75
+ /**
76
+ * Retrieve secrets from IPFS
77
+ */
78
+ async pull(environment, encryptionKey, gitRepo) {
79
+ try {
80
+ const metadataKey = this.getMetadataKey(gitRepo, environment);
81
+ const metadata = this.metadata[metadataKey];
82
+ if (!metadata) {
83
+ throw new Error(`No secrets found for environment: ${environment}`);
84
+ }
85
+ // Try to load from local cache
86
+ const cachedData = await this.loadLocally(metadata.cid);
87
+ if (!cachedData) {
88
+ throw new Error(`Secrets not found in cache. CID: ${metadata.cid}`);
89
+ }
90
+ // Decrypt secrets
91
+ const secrets = this.decryptSecrets(cachedData, encryptionKey);
92
+ logger.info(`📥 Retrieved ${secrets.length} secrets from IPFS`);
93
+ logger.info(` CID: ${metadata.cid}`);
94
+ logger.info(` Environment: ${environment}`);
95
+ return secrets;
96
+ }
97
+ catch (error) {
98
+ const err = error;
99
+ logger.error(`Failed to pull secrets from IPFS: ${err.message}`);
100
+ throw error;
101
+ }
102
+ }
103
+ /**
104
+ * Check if secrets exist for environment
105
+ */
106
+ exists(environment, gitRepo) {
107
+ const metadataKey = this.getMetadataKey(gitRepo, environment);
108
+ return !!this.metadata[metadataKey];
109
+ }
110
+ /**
111
+ * Get metadata for environment
112
+ */
113
+ getMetadata(environment, gitRepo) {
114
+ const metadataKey = this.getMetadataKey(gitRepo, environment);
115
+ return this.metadata[metadataKey];
116
+ }
117
+ /**
118
+ * List all environments
119
+ */
120
+ listEnvironments() {
121
+ return Object.values(this.metadata);
122
+ }
123
+ /**
124
+ * Delete secrets for environment
125
+ */
126
+ async delete(environment, gitRepo) {
127
+ const metadataKey = this.getMetadataKey(gitRepo, environment);
128
+ const metadata = this.metadata[metadataKey];
129
+ if (metadata) {
130
+ // Delete local cache
131
+ const cachePath = path.join(this.cacheDir, `${metadata.cid}.encrypted`);
132
+ if (fs.existsSync(cachePath)) {
133
+ fs.unlinkSync(cachePath);
134
+ }
135
+ // Remove metadata
136
+ delete this.metadata[metadataKey];
137
+ this.saveMetadata();
138
+ logger.info(`🗑️ Deleted secrets for ${environment}`);
139
+ }
140
+ }
141
+ /**
142
+ * Encrypt secrets using AES-256
143
+ */
144
+ encryptSecrets(secrets, encryptionKey) {
145
+ const data = JSON.stringify(secrets);
146
+ const key = crypto.createHash('sha256').update(encryptionKey).digest();
147
+ const iv = crypto.randomBytes(16);
148
+ const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
149
+ let encrypted = cipher.update(data, 'utf8', 'hex');
150
+ encrypted += cipher.final('hex');
151
+ // Return IV + encrypted data
152
+ return iv.toString('hex') + ':' + encrypted;
153
+ }
154
+ /**
155
+ * Decrypt secrets using AES-256
156
+ */
157
+ decryptSecrets(encryptedData, encryptionKey) {
158
+ const [ivHex, encrypted] = encryptedData.split(':');
159
+ const key = crypto.createHash('sha256').update(encryptionKey).digest();
160
+ const iv = Buffer.from(ivHex, 'hex');
161
+ const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
162
+ let decrypted = decipher.update(encrypted, 'hex', 'utf8');
163
+ decrypted += decipher.final('utf8');
164
+ return JSON.parse(decrypted);
165
+ }
166
+ /**
167
+ * Generate IPFS-compatible CID from content
168
+ */
169
+ generateCID(content) {
170
+ const hash = crypto.createHash('sha256').update(content).digest('hex');
171
+ // Format like IPFS CIDv1 (bafkreixxx...)
172
+ return `bafkrei${hash.substring(0, 52)}`;
173
+ }
174
+ /**
175
+ * Store encrypted data locally
176
+ */
177
+ async storeLocally(cid, encryptedData, environment) {
178
+ const cachePath = path.join(this.cacheDir, `${cid}.encrypted`);
179
+ fs.writeFileSync(cachePath, encryptedData, 'utf8');
180
+ logger.debug(`Cached secrets locally: ${cachePath}`);
181
+ }
182
+ /**
183
+ * Load encrypted data from local cache
184
+ */
185
+ async loadLocally(cid) {
186
+ const cachePath = path.join(this.cacheDir, `${cid}.encrypted`);
187
+ if (!fs.existsSync(cachePath)) {
188
+ return null;
189
+ }
190
+ return fs.readFileSync(cachePath, 'utf8');
191
+ }
192
+ /**
193
+ * Get metadata key for environment
194
+ */
195
+ getMetadataKey(gitRepo, environment) {
196
+ return gitRepo ? `${gitRepo}_${environment}` : environment;
197
+ }
198
+ /**
199
+ * Load metadata from disk
200
+ */
201
+ loadMetadata() {
202
+ if (!fs.existsSync(this.metadataPath)) {
203
+ return {};
204
+ }
205
+ try {
206
+ const content = fs.readFileSync(this.metadataPath, 'utf8');
207
+ return JSON.parse(content);
208
+ }
209
+ catch {
210
+ return {};
211
+ }
212
+ }
213
+ /**
214
+ * Save metadata to disk
215
+ */
216
+ saveMetadata() {
217
+ fs.writeFileSync(this.metadataPath, JSON.stringify(this.metadata, null, 2), 'utf8');
218
+ }
219
+ }
@@ -5,17 +5,17 @@
5
5
  import * as fs from 'fs';
6
6
  import * as path from 'path';
7
7
  import * as crypto from 'crypto';
8
- import DatabasePersistence from './database-persistence.js';
9
8
  import { createLogger, LogLevel } from './logger.js';
10
9
  import { getGitRepoInfo, hasEnvExample, ensureEnvInGitignore } from './git-utils.js';
11
10
  import { IPFSSyncLogger } from './ipfs-sync-logger.js';
11
+ import { IPFSSecretsStorage } from './ipfs-secrets-storage.js';
12
12
  const logger = createLogger('SecretsManager');
13
13
  export class SecretsManager {
14
- persistence;
14
+ storage;
15
15
  encryptionKey;
16
16
  gitInfo;
17
17
  constructor(userId, encryptionKey, detectGit = true) {
18
- this.persistence = new DatabasePersistence(userId);
18
+ this.storage = new IPFSSecretsStorage();
19
19
  // Use provided key or generate from machine ID + user
20
20
  this.encryptionKey = encryptionKey || this.getDefaultEncryptionKey();
21
21
  // Auto-detect git repo context
@@ -28,7 +28,7 @@ export class SecretsManager {
28
28
  * Call this when done to allow process to exit
29
29
  */
30
30
  async cleanup() {
31
- await this.persistence.cleanup();
31
+ // IPFS storage doesn't need cleanup
32
32
  }
33
33
  /**
34
34
  * Get default encryption key from environment or machine
@@ -178,78 +178,55 @@ export class SecretsManager {
178
178
  // Warn if using default key
179
179
  if (!process.env.LSH_SECRETS_KEY) {
180
180
  logger.warn('⚠️ Warning: No LSH_SECRETS_KEY set. Using machine-specific key.');
181
- logger.warn(' To share secrets across machines, generate a key with: lsh secrets key');
181
+ logger.warn(' To share secrets across machines, generate a key with: lsh key');
182
182
  logger.warn(' Then add LSH_SECRETS_KEY=<key> to your .env on all machines');
183
183
  console.log();
184
184
  }
185
- logger.info(`Pushing ${envFilePath} to Supabase (${environment})...`);
185
+ logger.info(`Pushing ${envFilePath} to IPFS (${this.getRepoAwareEnvironment(environment)})...`);
186
186
  const content = fs.readFileSync(envFilePath, 'utf8');
187
187
  const env = this.parseEnvFile(content);
188
188
  // Check for destructive changes unless force is true
189
189
  if (!force) {
190
190
  try {
191
- const jobs = await this.persistence.getActiveJobs();
192
- const safeFilename = filename.replace(/[^a-zA-Z0-9._-]/g, '_');
193
- const secretsJobs = jobs
194
- .filter(j => {
195
- return j.command === 'secrets_sync' &&
196
- j.job_id.includes(environment) &&
197
- j.job_id.includes(safeFilename);
198
- })
199
- .sort((a, b) => new Date(b.started_at).getTime() - new Date(a.started_at).getTime());
200
- if (secretsJobs.length > 0) {
201
- const latestSecret = secretsJobs[0];
202
- if (latestSecret.output) {
203
- try {
204
- const decrypted = this.decrypt(latestSecret.output);
205
- const cloudEnv = this.parseEnvFile(decrypted);
206
- const destructive = this.detectDestructiveChanges(cloudEnv, env);
207
- if (destructive.length > 0) {
208
- throw new Error(this.formatDestructiveChangesError(destructive));
209
- }
210
- }
211
- catch (error) {
212
- const err = error;
213
- // If decryption fails, it's a key mismatch - let it proceed
214
- // (will fail later with proper error)
215
- if (!err.message.includes('Destructive change')) {
216
- // Only ignore decryption errors, re-throw destructive change errors
217
- throw err;
218
- }
219
- throw err;
220
- }
191
+ // Check if secrets already exist for this environment
192
+ if (this.storage.exists(environment, this.gitInfo?.repoName)) {
193
+ const existingSecrets = await this.storage.pull(environment, this.encryptionKey, this.gitInfo?.repoName);
194
+ const cloudEnv = {};
195
+ existingSecrets.forEach(s => {
196
+ cloudEnv[s.key] = s.value;
197
+ });
198
+ const destructive = this.detectDestructiveChanges(cloudEnv, env);
199
+ if (destructive.length > 0) {
200
+ throw new Error(this.formatDestructiveChangesError(destructive));
221
201
  }
222
202
  }
223
203
  }
224
204
  catch (error) {
225
205
  const err = error;
226
- // Re-throw any errors (including destructive change errors)
227
- if (err.message.includes('Destructive change') || err.message.includes('Decryption failed')) {
206
+ // Re-throw destructive change errors
207
+ if (err.message.includes('Destructive change')) {
228
208
  throw err;
229
209
  }
230
- // Ignore other errors (like connection issues) and proceed
231
- }
232
- }
233
- // Encrypt entire .env content
234
- const encrypted = this.encrypt(content);
235
- // Include filename in job_id for tracking multiple .env files
236
- const safeFilename = filename.replace(/[^a-zA-Z0-9._-]/g, '_');
237
- const secretData = {
238
- job_id: `secrets_${environment}_${safeFilename}_${Date.now()}`,
239
- command: 'secrets_sync',
240
- status: 'completed',
241
- output: encrypted,
242
- started_at: new Date().toISOString(),
243
- completed_at: new Date().toISOString(),
244
- working_directory: process.cwd(),
245
- };
246
- await this.persistence.saveJob(secretData);
247
- logger.info(`✅ Pushed ${Object.keys(env).length} secrets from ${filename} to Supabase`);
248
- // Log to IPFS for immutable record
249
- await this.logToIPFS('push', environment, Object.keys(env).length);
210
+ // Ignore other errors (like missing secrets) and proceed
211
+ }
212
+ }
213
+ // Convert to Secret objects
214
+ const secrets = Object.entries(env).map(([key, value]) => ({
215
+ key,
216
+ value,
217
+ environment,
218
+ createdAt: new Date(),
219
+ updatedAt: new Date(),
220
+ }));
221
+ // Store on IPFS
222
+ const cid = await this.storage.push(secrets, environment, this.encryptionKey, this.gitInfo?.repoName, this.gitInfo?.currentBranch);
223
+ logger.info(`✅ Pushed ${secrets.length} secrets from ${filename} to IPFS`);
224
+ console.log(`📦 IPFS CID: ${cid}`);
225
+ // Log to IPFS for immutable audit record
226
+ await this.logToIPFS('push', environment, secrets.length);
250
227
  }
251
228
  /**
252
- * Pull .env from Supabase
229
+ * Pull .env from IPFS
253
230
  */
254
231
  async pull(envFilePath = '.env', environment = 'dev', force = false) {
255
232
  // Validate filename pattern for custom files
@@ -257,52 +234,41 @@ export class SecretsManager {
257
234
  if (filename !== '.env' && !filename.startsWith('.env.')) {
258
235
  throw new Error(`Invalid filename: ${filename}. Must be '.env' or start with '.env.'`);
259
236
  }
260
- logger.info(`Pulling ${filename} (${environment}) from Supabase...`);
261
- // Get latest secrets for this specific file
262
- const jobs = await this.persistence.getActiveJobs();
263
- const safeFilename = filename.replace(/[^a-zA-Z0-9._-]/g, '_');
264
- const secretsJobs = jobs
265
- .filter(j => {
266
- // Match secrets for this environment and filename
267
- return j.command === 'secrets_sync' &&
268
- j.job_id.includes(environment) &&
269
- j.job_id.includes(safeFilename);
270
- })
271
- .sort((a, b) => new Date(b.started_at).getTime() - new Date(a.started_at).getTime());
272
- if (secretsJobs.length === 0) {
273
- throw new Error(`No secrets found for file '${filename}' in environment: ${environment}`);
274
- }
275
- const latestSecret = secretsJobs[0];
276
- if (!latestSecret.output) {
277
- throw new Error(`No encrypted data found for environment: ${environment}`);
237
+ logger.info(`Pulling ${filename} (${environment}) from IPFS...`);
238
+ // Get secrets from IPFS storage
239
+ const secrets = await this.storage.pull(environment, this.encryptionKey, this.gitInfo?.repoName);
240
+ if (secrets.length === 0) {
241
+ throw new Error(`No secrets found for environment: ${environment}`);
278
242
  }
279
- const decrypted = this.decrypt(latestSecret.output);
280
243
  // Backup existing .env if it exists (unless force is true)
281
244
  if (fs.existsSync(envFilePath) && !force) {
282
245
  const backup = `${envFilePath}.backup.${Date.now()}`;
283
246
  fs.copyFileSync(envFilePath, backup);
284
247
  logger.info(`Backed up existing .env to ${backup}`);
285
248
  }
249
+ // Convert secrets back to .env format
250
+ const envContent = secrets
251
+ .map(s => `${s.key}=${s.value}`)
252
+ .join('\n') + '\n';
286
253
  // Write new .env
287
- fs.writeFileSync(envFilePath, decrypted, 'utf8');
288
- const env = this.parseEnvFile(decrypted);
289
- logger.info(`✅ Pulled ${Object.keys(env).length} secrets from Supabase`);
254
+ fs.writeFileSync(envFilePath, envContent, 'utf8');
255
+ logger.info(`✅ Pulled ${secrets.length} secrets from IPFS`);
256
+ // Get metadata for CID display
257
+ const metadata = this.storage.getMetadata(environment, this.gitInfo?.repoName);
258
+ if (metadata) {
259
+ console.log(`📦 IPFS CID: ${metadata.cid}`);
260
+ }
290
261
  // Log to IPFS for immutable record
291
- await this.logToIPFS('pull', environment, Object.keys(env).length);
262
+ await this.logToIPFS('pull', environment, secrets.length);
292
263
  }
293
264
  /**
294
265
  * List all stored environments
295
266
  */
296
267
  async listEnvironments() {
297
- const jobs = await this.persistence.getActiveJobs();
298
- const secretsJobs = jobs.filter(j => j.command === 'secrets_sync');
268
+ const allMetadata = this.storage.listEnvironments();
299
269
  const envs = new Set();
300
- for (const job of secretsJobs) {
301
- // Updated regex to handle new format with filename
302
- const match = job.job_id.match(/secrets_([^_]+)_/);
303
- if (match) {
304
- envs.add(match[1]);
305
- }
270
+ for (const metadata of allMetadata) {
271
+ envs.add(metadata.environment);
306
272
  }
307
273
  return Array.from(envs).sort();
308
274
  }
@@ -310,79 +276,39 @@ export class SecretsManager {
310
276
  * List all tracked .env files
311
277
  */
312
278
  async listAllFiles() {
313
- const jobs = await this.persistence.getActiveJobs();
314
- const secretsJobs = jobs.filter(j => j.command === 'secrets_sync');
315
- // Group by environment and filename to get latest of each
316
- const fileMap = new Map();
317
- for (const job of secretsJobs) {
318
- // Parse job_id: secrets_${environment}_${safeFilename}_${timestamp}
319
- const parts = job.job_id.split('_');
320
- if (parts.length >= 3 && parts[0] === 'secrets') {
321
- const environment = parts[1];
322
- // Handle both old and new format
323
- let filename = '.env';
324
- if (parts.length >= 4) {
325
- // New format with filename
326
- const _timestamp = parts[parts.length - 1];
327
- // Reconstruct filename from middle parts
328
- const filenameParts = parts.slice(2, -1);
329
- if (filenameParts.length > 0) {
330
- // Convert underscores back to dots for the extension
331
- filename = filenameParts.join('_');
332
- // Fix the extension dots that were replaced
333
- filename = filename.replace(/^env_/, '.env.');
334
- if (filename === 'env') {
335
- filename = '.env';
336
- }
337
- }
338
- }
339
- const key = `${environment}_${filename}`;
340
- const existing = fileMap.get(key);
341
- if (!existing || new Date(job.completed_at || job.started_at) > new Date(existing.updated)) {
342
- fileMap.set(key, {
343
- filename,
344
- environment,
345
- updated: new Date(job.completed_at || job.started_at).toLocaleString()
346
- });
347
- }
348
- }
349
- }
350
- return Array.from(fileMap.values()).sort((a, b) => a.filename.localeCompare(b.filename) || a.environment.localeCompare(b.environment));
279
+ const allMetadata = this.storage.listEnvironments();
280
+ return allMetadata.map(metadata => ({
281
+ filename: '.env', // Currently IPFS storage tracks only .env files
282
+ environment: metadata.environment,
283
+ updated: new Date(metadata.timestamp).toLocaleString()
284
+ })).sort((a, b) => a.filename.localeCompare(b.filename) || a.environment.localeCompare(b.environment));
351
285
  }
352
286
  /**
353
287
  * Show secrets (masked)
354
288
  */
355
289
  async show(environment = 'dev', format = 'env') {
356
- const jobs = await this.persistence.getActiveJobs();
357
- const secretsJobs = jobs
358
- .filter(j => j.command === 'secrets_sync' && j.job_id.includes(environment))
359
- .sort((a, b) => new Date(b.started_at).getTime() - new Date(a.started_at).getTime());
360
- if (secretsJobs.length === 0) {
290
+ // Get secrets from IPFS storage
291
+ const secrets = await this.storage.pull(environment, this.encryptionKey, this.gitInfo?.repoName);
292
+ if (secrets.length === 0) {
361
293
  console.log(`No secrets found for environment: ${environment}`);
362
294
  return;
363
295
  }
364
- const latestSecret = secretsJobs[0];
365
- if (!latestSecret.output) {
366
- throw new Error(`No encrypted data found for environment: ${environment}`);
367
- }
368
- const decrypted = this.decrypt(latestSecret.output);
369
- const env = this.parseEnvFile(decrypted);
370
- // Convert to array format for formatSecrets
371
- const secrets = Object.entries(env).map(([key, value]) => ({ key, value }));
296
+ // Convert to simple key-value format for formatSecrets
297
+ const secretsFormatted = secrets.map(s => ({ key: s.key, value: s.value }));
372
298
  // Use format utilities if not default env format
373
299
  if (format !== 'env') {
374
300
  const { formatSecrets } = await import('./format-utils.js');
375
- const output = formatSecrets(secrets, format, false); // No masking for structured formats
301
+ const output = formatSecrets(secretsFormatted, format, false); // No masking for structured formats
376
302
  console.log(output);
377
303
  return;
378
304
  }
379
305
  // Default env format with masking (legacy behavior)
380
- console.log(`\n📦 Secrets for ${environment} (${Object.keys(env).length} total):\n`);
381
- for (const [key, value] of Object.entries(env)) {
382
- const masked = value.length > 4
383
- ? value.substring(0, 4) + '*'.repeat(Math.min(value.length - 4, 20))
306
+ console.log(`\n📦 Secrets for ${environment} (${secrets.length} total):\n`);
307
+ for (const secret of secrets) {
308
+ const masked = secret.value.length > 4
309
+ ? secret.value.substring(0, 4) + '*'.repeat(Math.min(secret.value.length - 4, 20))
384
310
  : '****';
385
- console.log(` ${key}=${masked}`);
311
+ console.log(` ${secret.key}=${masked}`);
386
312
  }
387
313
  console.log();
388
314
  }
@@ -410,22 +336,17 @@ export class SecretsManager {
410
336
  const env = this.parseEnvFile(content);
411
337
  status.localKeys = Object.keys(env).length;
412
338
  }
413
- // Check cloud storage
339
+ // Check IPFS storage
414
340
  try {
415
- const jobs = await this.persistence.getActiveJobs();
416
- const secretsJobs = jobs
417
- .filter(j => j.command === 'secrets_sync' && j.job_id.includes(environment))
418
- .sort((a, b) => new Date(b.started_at).getTime() - new Date(a.started_at).getTime());
419
- if (secretsJobs.length > 0) {
341
+ if (this.storage.exists(environment, this.gitInfo?.repoName)) {
420
342
  status.cloudExists = true;
421
- const latestSecret = secretsJobs[0];
422
- status.cloudModified = new Date(latestSecret.completed_at || latestSecret.started_at);
423
- // Try to decrypt to check if key matches
424
- if (latestSecret.output) {
343
+ const metadata = this.storage.getMetadata(environment, this.gitInfo?.repoName);
344
+ if (metadata) {
345
+ status.cloudModified = new Date(metadata.timestamp);
346
+ status.cloudKeys = metadata.keys_count;
347
+ // Try to decrypt to check if key matches
425
348
  try {
426
- const decrypted = this.decrypt(latestSecret.output);
427
- const env = this.parseEnvFile(decrypted);
428
- status.cloudKeys = Object.keys(env).length;
349
+ await this.storage.pull(environment, this.encryptionKey, this.gitInfo?.repoName);
429
350
  status.keyMatches = true;
430
351
  }
431
352
  catch (_error) {
@@ -435,7 +356,7 @@ export class SecretsManager {
435
356
  }
436
357
  }
437
358
  catch (_error) {
438
- // Cloud check failed, likely no connection
359
+ // IPFS check failed
439
360
  }
440
361
  return status;
441
362
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lsh-framework",
3
- "version": "1.5.1",
3
+ "version": "1.7.0",
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": {