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 +23 -27
- package/dist/cli.js +2 -0
- package/dist/commands/doctor.js +48 -2
- package/dist/commands/ipfs.js +146 -0
- package/dist/lib/ipfs-client-manager.js +321 -0
- package/dist/lib/ipfs-secrets-storage.js +219 -0
- package/dist/lib/secrets-manager.js +82 -161
- package/package.json +1 -1
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
|
|
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
|
-
- **
|
|
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?**
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
33
|
+
# That's it! LSH works immediately with IPFS storage
|
|
34
34
|
# Config: ~/.config/lsh/lshrc (auto-created)
|
|
35
|
-
#
|
|
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
|
|
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.
|
|
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
|
-
# ✅
|
|
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 (
|
|
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.
|
|
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
|
-
#
|
|
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
|
package/dist/commands/doctor.js
CHANGED
|
@@ -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
|
|
226
|
+
// Try to query
|
|
224
227
|
const { error } = await supabase.from('lsh_secrets').select('count').limit(0);
|
|
225
|
-
|
|
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
|
-
|
|
14
|
+
storage;
|
|
15
15
|
encryptionKey;
|
|
16
16
|
gitInfo;
|
|
17
17
|
constructor(userId, encryptionKey, detectGit = true) {
|
|
18
|
-
this.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
|
227
|
-
if (err.message.includes('Destructive change')
|
|
206
|
+
// Re-throw destructive change errors
|
|
207
|
+
if (err.message.includes('Destructive change')) {
|
|
228
208
|
throw err;
|
|
229
209
|
}
|
|
230
|
-
// Ignore other errors (like
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
//
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
await this.
|
|
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
|
|
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
|
|
261
|
-
// Get
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
|
|
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,
|
|
288
|
-
|
|
289
|
-
|
|
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,
|
|
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
|
|
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
|
|
301
|
-
|
|
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
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
357
|
-
const
|
|
358
|
-
|
|
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
|
-
|
|
365
|
-
|
|
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(
|
|
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} (${
|
|
381
|
-
for (const
|
|
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
|
|
339
|
+
// Check IPFS storage
|
|
414
340
|
try {
|
|
415
|
-
|
|
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
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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.
|
|
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": {
|