lsh-framework 3.1.25 → 3.2.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/dist/cli.js +2 -0
- package/dist/commands/ipfs.js +30 -2
- package/dist/commands/sync.js +101 -11
- package/dist/constants/config.js +6 -0
- package/dist/lib/ipfs-client-manager.js +54 -2
- package/dist/lib/ipfs-secrets-storage.js +59 -2
- package/dist/lib/ipfs-sync.js +62 -3
- package/dist/lib/ipns-key-manager.js +82 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -13,6 +13,7 @@ import { registerSyncHistoryCommands } from './commands/sync-history.js';
|
|
|
13
13
|
import { registerSyncCommands } from './commands/sync.js';
|
|
14
14
|
import { registerMigrateCommand } from './commands/migrate.js';
|
|
15
15
|
import { registerContextCommand } from './commands/context.js';
|
|
16
|
+
import { registerIPFSCommands } from './commands/ipfs.js';
|
|
16
17
|
import { init_secrets } from './services/secrets/secrets.js';
|
|
17
18
|
import { loadGlobalConfigSync } from './lib/config-manager.js';
|
|
18
19
|
import { CLI_TEXT, CLI_HELP } from './constants/ui.js';
|
|
@@ -147,6 +148,7 @@ function findSimilarCommands(input, validCommands) {
|
|
|
147
148
|
registerSyncCommands(program);
|
|
148
149
|
registerMigrateCommand(program);
|
|
149
150
|
registerContextCommand(program);
|
|
151
|
+
registerIPFSCommands(program);
|
|
150
152
|
// Secrets management (primary feature)
|
|
151
153
|
await init_secrets(program);
|
|
152
154
|
// Shell completion
|
package/dist/commands/ipfs.js
CHANGED
|
@@ -6,6 +6,9 @@ import chalk from 'chalk';
|
|
|
6
6
|
import ora from 'ora';
|
|
7
7
|
import { IPFSClientManager } from '../lib/ipfs-client-manager.js';
|
|
8
8
|
import { getIPFSSync } from '../lib/ipfs-sync.js';
|
|
9
|
+
import { deriveKeyInfo, ensureKeyImported } from '../lib/ipns-key-manager.js';
|
|
10
|
+
import { getGitRepoInfo } from '../lib/git-utils.js';
|
|
11
|
+
import { ENV_VARS, DEFAULTS } from '../constants/index.js';
|
|
9
12
|
/**
|
|
10
13
|
* Register IPFS commands
|
|
11
14
|
*/
|
|
@@ -64,6 +67,31 @@ export function registerIPFSCommands(program) {
|
|
|
64
67
|
console.log(chalk.gray(' Start with: lsh ipfs start'));
|
|
65
68
|
}
|
|
66
69
|
console.log('');
|
|
70
|
+
// IPNS info
|
|
71
|
+
const encryptionKey = process.env[ENV_VARS.LSH_SECRETS_KEY];
|
|
72
|
+
if (encryptionKey && daemonInfo) {
|
|
73
|
+
console.log(chalk.bold('IPNS:'));
|
|
74
|
+
try {
|
|
75
|
+
const gitInfo = getGitRepoInfo();
|
|
76
|
+
const repoName = gitInfo?.repoName || DEFAULTS.DEFAULT_ENVIRONMENT;
|
|
77
|
+
const keyInfo = deriveKeyInfo(encryptionKey, repoName, DEFAULTS.DEFAULT_ENVIRONMENT);
|
|
78
|
+
const ipnsName = await ensureKeyImported(ipfsSync.getApiUrl(), keyInfo);
|
|
79
|
+
if (ipnsName) {
|
|
80
|
+
console.log(chalk.green(` ✅ IPNS name: ${ipnsName}`));
|
|
81
|
+
const resolvedCid = await ipfsSync.resolveIPNS(ipnsName);
|
|
82
|
+
if (resolvedCid) {
|
|
83
|
+
console.log(` Latest CID: ${resolvedCid.substring(0, 20)}...`);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
console.log(chalk.gray(' No published record yet'));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
console.log(chalk.gray(' Could not derive IPNS info'));
|
|
92
|
+
}
|
|
93
|
+
console.log('');
|
|
94
|
+
}
|
|
67
95
|
// Quick actions
|
|
68
96
|
console.log(chalk.bold('Quick Actions:'));
|
|
69
97
|
if (!info.installed) {
|
|
@@ -73,8 +101,8 @@ export function registerIPFSCommands(program) {
|
|
|
73
101
|
console.log(chalk.cyan(' lsh ipfs start # Start daemon'));
|
|
74
102
|
}
|
|
75
103
|
else {
|
|
76
|
-
console.log(chalk.cyan(' lsh
|
|
77
|
-
console.log(chalk.cyan(' lsh
|
|
104
|
+
console.log(chalk.cyan(' lsh sync push # Push secrets'));
|
|
105
|
+
console.log(chalk.cyan(' lsh sync pull # Pull latest via IPNS'));
|
|
78
106
|
}
|
|
79
107
|
console.log('');
|
|
80
108
|
}
|
package/dist/commands/sync.js
CHANGED
|
@@ -12,7 +12,8 @@ import * as crypto from 'crypto';
|
|
|
12
12
|
import { getIPFSSync } from '../lib/ipfs-sync.js';
|
|
13
13
|
import { IPFSClientManager } from '../lib/ipfs-client-manager.js';
|
|
14
14
|
import { getGitRepoInfo } from '../lib/git-utils.js';
|
|
15
|
-
import {
|
|
15
|
+
import { deriveKeyInfo, ensureKeyImported } from '../lib/ipns-key-manager.js';
|
|
16
|
+
import { ENV_VARS, DEFAULTS } from '../constants/index.js';
|
|
16
17
|
/**
|
|
17
18
|
* Register sync commands
|
|
18
19
|
*/
|
|
@@ -243,8 +244,28 @@ export function registerSyncCommands(program) {
|
|
|
243
244
|
if (options.description) {
|
|
244
245
|
console.log(chalk.bold('Description:'), chalk.gray(options.description));
|
|
245
246
|
}
|
|
247
|
+
// Publish to IPNS
|
|
248
|
+
if (encryptionKey) {
|
|
249
|
+
try {
|
|
250
|
+
const repoName = gitInfo?.repoName || DEFAULTS.DEFAULT_ENVIRONMENT;
|
|
251
|
+
const env = options.env || DEFAULTS.DEFAULT_ENVIRONMENT;
|
|
252
|
+
const keyInfo = deriveKeyInfo(encryptionKey, repoName, env);
|
|
253
|
+
const ipnsName = await ensureKeyImported(ipfsSync.getApiUrl(), keyInfo);
|
|
254
|
+
if (ipnsName) {
|
|
255
|
+
const publishedName = await ipfsSync.publishToIPNS(cid, keyInfo.keyName);
|
|
256
|
+
if (publishedName) {
|
|
257
|
+
console.log(chalk.bold('IPNS:'), chalk.cyan(publishedName));
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
// Non-fatal: IPNS is a convenience
|
|
263
|
+
}
|
|
264
|
+
}
|
|
246
265
|
console.log('');
|
|
247
266
|
console.log(chalk.gray('Pull on another machine:'));
|
|
267
|
+
console.log(chalk.cyan(' lsh sync pull'));
|
|
268
|
+
console.log(chalk.gray('Or by specific CID:'));
|
|
248
269
|
console.log(chalk.cyan(` lsh sync pull ${cid}`));
|
|
249
270
|
console.log('');
|
|
250
271
|
}
|
|
@@ -325,15 +346,30 @@ export function registerSyncCommands(program) {
|
|
|
325
346
|
spinner.succeed(chalk.green('Uploaded to IPFS!'));
|
|
326
347
|
console.log('');
|
|
327
348
|
console.log(chalk.bold('CID:'), chalk.cyan(cid));
|
|
349
|
+
// Publish to IPNS
|
|
350
|
+
if (encryptionKey) {
|
|
351
|
+
try {
|
|
352
|
+
const repoName = gitInfo?.repoName || DEFAULTS.DEFAULT_ENVIRONMENT;
|
|
353
|
+
const env = options.env || DEFAULTS.DEFAULT_ENVIRONMENT;
|
|
354
|
+
const keyInfo = deriveKeyInfo(encryptionKey, repoName, env);
|
|
355
|
+
const ipnsName = await ensureKeyImported(ipfsSync.getApiUrl(), keyInfo);
|
|
356
|
+
if (ipnsName) {
|
|
357
|
+
const publishedName = await ipfsSync.publishToIPNS(cid, keyInfo.keyName);
|
|
358
|
+
if (publishedName) {
|
|
359
|
+
console.log(chalk.bold('IPNS:'), chalk.cyan(publishedName));
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
// Non-fatal
|
|
365
|
+
}
|
|
366
|
+
}
|
|
328
367
|
console.log('');
|
|
329
|
-
console.log(chalk.gray('
|
|
368
|
+
console.log(chalk.gray('Teammates can pull with just:'));
|
|
369
|
+
console.log(chalk.cyan(' lsh sync pull'));
|
|
370
|
+
console.log(chalk.gray('Or by specific CID:'));
|
|
330
371
|
console.log(chalk.cyan(` lsh sync pull ${cid}`));
|
|
331
372
|
console.log('');
|
|
332
|
-
console.log(chalk.gray('Public gateway URLs:'));
|
|
333
|
-
ipfsSync.getGatewayUrls(cid).slice(0, 2).forEach(url => {
|
|
334
|
-
console.log(chalk.gray(` ${url}`));
|
|
335
|
-
});
|
|
336
|
-
console.log('');
|
|
337
373
|
}
|
|
338
374
|
catch (error) {
|
|
339
375
|
const err = error;
|
|
@@ -342,14 +378,68 @@ export function registerSyncCommands(program) {
|
|
|
342
378
|
process.exit(1);
|
|
343
379
|
}
|
|
344
380
|
});
|
|
345
|
-
// lsh sync pull
|
|
381
|
+
// lsh sync pull [cid]
|
|
346
382
|
syncCommand
|
|
347
|
-
.command('pull
|
|
348
|
-
.description('⬇️ Pull secrets from IPFS
|
|
383
|
+
.command('pull [cid]')
|
|
384
|
+
.description('⬇️ Pull secrets from IPFS (auto-resolves via IPNS if no CID given)')
|
|
349
385
|
.option('-o, --output <path>', 'Output file path', '.env')
|
|
386
|
+
.option('-e, --env <name>', 'Environment name', '')
|
|
350
387
|
.option('--force', 'Overwrite existing file without backup')
|
|
351
388
|
.action(async (cid, options) => {
|
|
352
|
-
const spinner = ora('Downloading from IPFS...').start();
|
|
389
|
+
const spinner = ora(cid ? 'Downloading from IPFS...' : 'Resolving latest secrets via IPNS...').start();
|
|
390
|
+
// If no CID provided, resolve via IPNS
|
|
391
|
+
if (!cid) {
|
|
392
|
+
try {
|
|
393
|
+
const ipfsSync = getIPFSSync();
|
|
394
|
+
if (!await ipfsSync.checkDaemon()) {
|
|
395
|
+
spinner.fail(chalk.red('IPFS daemon not running'));
|
|
396
|
+
console.log(chalk.gray(' Start with: lsh sync start'));
|
|
397
|
+
process.exit(1);
|
|
398
|
+
}
|
|
399
|
+
// Get encryption key
|
|
400
|
+
let ipnsKey = process.env[ENV_VARS.LSH_SECRETS_KEY];
|
|
401
|
+
if (!ipnsKey) {
|
|
402
|
+
const outputPath = path.resolve(options.output);
|
|
403
|
+
if (fs.existsSync(outputPath)) {
|
|
404
|
+
const content = fs.readFileSync(outputPath, 'utf-8');
|
|
405
|
+
const keyMatch = content.match(/^LSH_SECRETS_KEY=(.+)$/m);
|
|
406
|
+
if (keyMatch) {
|
|
407
|
+
ipnsKey = keyMatch[1].trim().replace(/^["']|["']$/g, '');
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
if (!ipnsKey) {
|
|
412
|
+
spinner.fail(chalk.red('LSH_SECRETS_KEY required for IPNS resolution'));
|
|
413
|
+
console.log(chalk.gray(' Set it: export LSH_SECRETS_KEY=<key>'));
|
|
414
|
+
process.exit(1);
|
|
415
|
+
}
|
|
416
|
+
const gitInfo = getGitRepoInfo();
|
|
417
|
+
const repoName = gitInfo?.repoName || DEFAULTS.DEFAULT_ENVIRONMENT;
|
|
418
|
+
const environment = options.env || DEFAULTS.DEFAULT_ENVIRONMENT;
|
|
419
|
+
const keyInfo = deriveKeyInfo(ipnsKey, repoName, environment);
|
|
420
|
+
const ipnsName = await ensureKeyImported(ipfsSync.getApiUrl(), keyInfo);
|
|
421
|
+
if (!ipnsName) {
|
|
422
|
+
spinner.fail(chalk.red('Failed to derive IPNS key'));
|
|
423
|
+
process.exit(1);
|
|
424
|
+
}
|
|
425
|
+
spinner.text = `Resolving IPNS: ${ipnsName.substring(0, 20)}...`;
|
|
426
|
+
const resolvedCid = await ipfsSync.resolveIPNS(ipnsName);
|
|
427
|
+
if (!resolvedCid) {
|
|
428
|
+
spinner.fail(chalk.red('No secrets found via IPNS'));
|
|
429
|
+
console.log(chalk.gray(' No one has pushed secrets for this repo/environment yet.'));
|
|
430
|
+
console.log(chalk.gray(' Push first: lsh sync push'));
|
|
431
|
+
process.exit(1);
|
|
432
|
+
}
|
|
433
|
+
cid = resolvedCid;
|
|
434
|
+
spinner.succeed(chalk.green(`Resolved IPNS → CID: ${cid.substring(0, 16)}...`));
|
|
435
|
+
spinner.start('Downloading from IPFS...');
|
|
436
|
+
}
|
|
437
|
+
catch (error) {
|
|
438
|
+
const err = error;
|
|
439
|
+
spinner.fail(chalk.red(`IPNS resolution failed: ${err.message}`));
|
|
440
|
+
process.exit(1);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
353
443
|
try {
|
|
354
444
|
const ipfsSync = getIPFSSync();
|
|
355
445
|
// Download from IPFS
|
package/dist/constants/config.js
CHANGED
|
@@ -134,4 +134,10 @@ export const DEFAULTS = {
|
|
|
134
134
|
DEFAULT_SHELL_UNIX: '/bin/sh',
|
|
135
135
|
DEFAULT_SHELL_WIN: 'cmd.exe',
|
|
136
136
|
DEFAULT_EDITOR: 'vi',
|
|
137
|
+
// IPNS configuration
|
|
138
|
+
DEFAULT_ENVIRONMENT: 'default',
|
|
139
|
+
IPNS_PUBLISH_LIFETIME: '87600h', // ~10 years, re-published on each push
|
|
140
|
+
IPNS_RESOLVE_TIMEOUT_MS: 30000, // 30s for DHT lookup
|
|
141
|
+
IPNS_KEY_PREFIX: 'lsh-',
|
|
142
|
+
IPNS_KEY_DERIVATION_CONTEXT: 'lsh-ipns-v1',
|
|
137
143
|
};
|
|
@@ -171,6 +171,12 @@ export class IPFSClientManager {
|
|
|
171
171
|
throw error;
|
|
172
172
|
}
|
|
173
173
|
}
|
|
174
|
+
/**
|
|
175
|
+
* Get the IPFS repo path used by lsh
|
|
176
|
+
*/
|
|
177
|
+
getRepoPath() {
|
|
178
|
+
return path.join(this.ipfsDir, 'repo');
|
|
179
|
+
}
|
|
174
180
|
/**
|
|
175
181
|
* Start IPFS daemon
|
|
176
182
|
*/
|
|
@@ -179,9 +185,23 @@ export class IPFSClientManager {
|
|
|
179
185
|
if (!clientInfo.installed) {
|
|
180
186
|
throw new Error('IPFS client not installed. Run: lsh ipfs install');
|
|
181
187
|
}
|
|
182
|
-
|
|
183
|
-
const ipfsRepoPath = path.join(this.ipfsDir, 'repo');
|
|
188
|
+
const ipfsRepoPath = this.getRepoPath();
|
|
184
189
|
const ipfsCmd = clientInfo.path || 'ipfs';
|
|
190
|
+
// Auto-initialize repo if it doesn't exist
|
|
191
|
+
if (!fs.existsSync(path.join(ipfsRepoPath, 'config'))) {
|
|
192
|
+
logger.info('🔧 IPFS repository not found, initializing...');
|
|
193
|
+
try {
|
|
194
|
+
await execAsync(`${ipfsCmd} init`, {
|
|
195
|
+
env: { ...process.env, IPFS_PATH: ipfsRepoPath },
|
|
196
|
+
});
|
|
197
|
+
logger.info('✅ IPFS repository initialized');
|
|
198
|
+
}
|
|
199
|
+
catch (initError) {
|
|
200
|
+
const err = initError;
|
|
201
|
+
throw new Error(`Failed to auto-initialize IPFS repo: ${err.message}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
logger.info('🚀 Starting IPFS daemon...');
|
|
185
205
|
try {
|
|
186
206
|
// Start daemon as fully detached background process
|
|
187
207
|
// Using spawn with detached:true and stdio:'ignore' allows parent to exit
|
|
@@ -197,6 +217,17 @@ export class IPFSClientManager {
|
|
|
197
217
|
if (daemon.pid) {
|
|
198
218
|
fs.writeFileSync(pidPath, daemon.pid.toString(), 'utf8');
|
|
199
219
|
}
|
|
220
|
+
// Wait for daemon to actually be ready (poll the API)
|
|
221
|
+
const ready = await this.waitForDaemon(10000);
|
|
222
|
+
if (!ready) {
|
|
223
|
+
// Clean up PID file since daemon didn't start
|
|
224
|
+
if (fs.existsSync(pidPath)) {
|
|
225
|
+
fs.unlinkSync(pidPath);
|
|
226
|
+
}
|
|
227
|
+
throw new Error('IPFS daemon process started but API is not responding. ' +
|
|
228
|
+
'The daemon may have crashed. Check if IPFS repo is properly initialized: ' +
|
|
229
|
+
`IPFS_PATH=${ipfsRepoPath}`);
|
|
230
|
+
}
|
|
200
231
|
logger.info('✅ IPFS daemon started');
|
|
201
232
|
logger.info(` PID: ${daemon.pid}`);
|
|
202
233
|
logger.info(' API: http://localhost:5001');
|
|
@@ -208,6 +239,27 @@ export class IPFSClientManager {
|
|
|
208
239
|
throw error;
|
|
209
240
|
}
|
|
210
241
|
}
|
|
242
|
+
/**
|
|
243
|
+
* Wait for daemon API to become ready
|
|
244
|
+
*/
|
|
245
|
+
async waitForDaemon(timeoutMs) {
|
|
246
|
+
const start = Date.now();
|
|
247
|
+
while (Date.now() - start < timeoutMs) {
|
|
248
|
+
try {
|
|
249
|
+
const response = await fetch('http://127.0.0.1:5001/api/v0/id', {
|
|
250
|
+
method: 'POST',
|
|
251
|
+
signal: AbortSignal.timeout(2000),
|
|
252
|
+
});
|
|
253
|
+
if (response.ok)
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
// Daemon not ready yet, keep polling
|
|
258
|
+
}
|
|
259
|
+
await new Promise(resolve => { setTimeout(resolve, 500); });
|
|
260
|
+
}
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
211
263
|
/**
|
|
212
264
|
* Stop IPFS daemon
|
|
213
265
|
*/
|
|
@@ -13,7 +13,8 @@ import * as os from 'os';
|
|
|
13
13
|
import * as crypto from 'crypto';
|
|
14
14
|
import { createLogger } from './logger.js';
|
|
15
15
|
import { getIPFSSync } from './ipfs-sync.js';
|
|
16
|
-
import {
|
|
16
|
+
import { deriveKeyInfo, ensureKeyImported } from './ipns-key-manager.js';
|
|
17
|
+
import { ENV_VARS, DEFAULTS } from '../constants/index.js';
|
|
17
18
|
const logger = createLogger('IPFSSecretsStorage');
|
|
18
19
|
/**
|
|
19
20
|
* IPFS Secrets Storage
|
|
@@ -108,6 +109,28 @@ export class IPFSSecretsStorage {
|
|
|
108
109
|
logger.warn(` ⚠️ IPFS upload failed: ${err.message}`);
|
|
109
110
|
}
|
|
110
111
|
}
|
|
112
|
+
// Publish to IPNS if we uploaded to the network
|
|
113
|
+
if (uploadedToNetwork && realCid && encryptionKey) {
|
|
114
|
+
try {
|
|
115
|
+
const repoName = gitRepo || DEFAULTS.DEFAULT_ENVIRONMENT;
|
|
116
|
+
const env = environment || DEFAULTS.DEFAULT_ENVIRONMENT;
|
|
117
|
+
const keyInfo = deriveKeyInfo(encryptionKey, repoName, env);
|
|
118
|
+
const ipnsName = await ensureKeyImported(ipfsSync.getApiUrl(), keyInfo);
|
|
119
|
+
if (ipnsName) {
|
|
120
|
+
const publishedName = await ipfsSync.publishToIPNS(realCid, keyInfo.keyName);
|
|
121
|
+
if (publishedName) {
|
|
122
|
+
metadata.ipns_name = publishedName;
|
|
123
|
+
this.metadata[this.getMetadataKey(gitRepo, environment)] = metadata;
|
|
124
|
+
await this.saveMetadata();
|
|
125
|
+
logger.info(` 🔗 Published to IPNS: ${publishedName}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
const err = error;
|
|
131
|
+
logger.warn(` ⚠️ IPNS publish failed (non-fatal): ${err.message}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
111
134
|
if (!uploadedToNetwork) {
|
|
112
135
|
logger.warn(` 📁 Secrets cached locally only (no network sync)`);
|
|
113
136
|
logger.warn(` 💡 Start IPFS daemon for network sync: lsh ipfs start`);
|
|
@@ -131,7 +154,41 @@ export class IPFSSecretsStorage {
|
|
|
131
154
|
const displayEnv = gitRepo
|
|
132
155
|
? (environment ? `${gitRepo}_${environment}` : gitRepo)
|
|
133
156
|
: (environment || 'default');
|
|
134
|
-
// If no local metadata,
|
|
157
|
+
// If no local metadata, try IPNS resolution first
|
|
158
|
+
if (!metadata && encryptionKey) {
|
|
159
|
+
try {
|
|
160
|
+
const ipfsSync = getIPFSSync();
|
|
161
|
+
if (await ipfsSync.checkDaemon()) {
|
|
162
|
+
const repoName = gitRepo || DEFAULTS.DEFAULT_ENVIRONMENT;
|
|
163
|
+
const env = environment || DEFAULTS.DEFAULT_ENVIRONMENT;
|
|
164
|
+
const keyInfo = deriveKeyInfo(encryptionKey, repoName, env);
|
|
165
|
+
const ipnsName = await ensureKeyImported(ipfsSync.getApiUrl(), keyInfo);
|
|
166
|
+
if (ipnsName) {
|
|
167
|
+
logger.info(` 🔍 Resolving via IPNS: ${ipnsName.substring(0, 20)}...`);
|
|
168
|
+
const resolvedCid = await ipfsSync.resolveIPNS(ipnsName);
|
|
169
|
+
if (resolvedCid) {
|
|
170
|
+
logger.info(` ✅ IPNS resolved to CID: ${resolvedCid}`);
|
|
171
|
+
metadata = {
|
|
172
|
+
environment,
|
|
173
|
+
git_repo: gitRepo,
|
|
174
|
+
cid: resolvedCid,
|
|
175
|
+
ipns_name: ipnsName,
|
|
176
|
+
timestamp: new Date().toISOString(),
|
|
177
|
+
keys_count: 0,
|
|
178
|
+
encrypted: true,
|
|
179
|
+
};
|
|
180
|
+
this.metadata[metadataKey] = metadata;
|
|
181
|
+
await this.saveMetadata();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
const err = error;
|
|
188
|
+
logger.debug(` IPNS resolution failed: ${err.message}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// If still no metadata, check IPFS sync history
|
|
135
192
|
if (!metadata && gitRepo) {
|
|
136
193
|
try {
|
|
137
194
|
const ipfsSync = getIPFSSync();
|
package/dist/lib/ipfs-sync.js
CHANGED
|
@@ -130,14 +130,16 @@ export class IPFSSync {
|
|
|
130
130
|
}
|
|
131
131
|
/**
|
|
132
132
|
* Download data from IPFS
|
|
133
|
-
* Tries local daemon first
|
|
133
|
+
* Tries local daemon first (with longer timeout for DHT discovery),
|
|
134
|
+
* then falls back to public gateways
|
|
134
135
|
*/
|
|
135
136
|
async download(cid) {
|
|
136
|
-
// Try local daemon first
|
|
137
|
+
// Try local daemon first — use a longer timeout (60s) because
|
|
138
|
+
// the local node may need time for DHT content discovery
|
|
137
139
|
try {
|
|
138
140
|
const localResponse = await fetch(`${this.LOCAL_IPFS_API}/cat?arg=${cid}`, {
|
|
139
141
|
method: 'POST',
|
|
140
|
-
signal: AbortSignal.timeout(
|
|
142
|
+
signal: AbortSignal.timeout(60000),
|
|
141
143
|
});
|
|
142
144
|
if (localResponse.ok) {
|
|
143
145
|
const arrayBuffer = await localResponse.arrayBuffer();
|
|
@@ -335,6 +337,63 @@ export class IPFSSync {
|
|
|
335
337
|
getGatewayUrls(cid) {
|
|
336
338
|
return this.GATEWAYS.map(template => template.replace('{cid}', cid));
|
|
337
339
|
}
|
|
340
|
+
/**
|
|
341
|
+
* Get the Kubo API URL
|
|
342
|
+
*/
|
|
343
|
+
getApiUrl() {
|
|
344
|
+
return this.LOCAL_IPFS_API;
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Publish a CID to IPNS under the given key name.
|
|
348
|
+
* The key must already be imported into Kubo.
|
|
349
|
+
* Returns the IPNS name on success, null on failure.
|
|
350
|
+
*/
|
|
351
|
+
async publishToIPNS(cid, keyName) {
|
|
352
|
+
try {
|
|
353
|
+
const response = await fetch(`${this.LOCAL_IPFS_API}/name/publish?arg=${cid}&key=${encodeURIComponent(keyName)}&lifetime=87600h&resolve=false`, {
|
|
354
|
+
method: 'POST',
|
|
355
|
+
signal: AbortSignal.timeout(30000),
|
|
356
|
+
});
|
|
357
|
+
if (!response.ok) {
|
|
358
|
+
const errorText = await response.text();
|
|
359
|
+
logger.warn(`IPNS publish failed: ${errorText}`);
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
const data = await response.json();
|
|
363
|
+
logger.info(`📡 IPNS published: ${data.Name} → ${data.Value}`);
|
|
364
|
+
return data.Name;
|
|
365
|
+
}
|
|
366
|
+
catch (error) {
|
|
367
|
+
const err = error;
|
|
368
|
+
logger.debug(`IPNS publish error: ${err.message}`);
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Resolve an IPNS name to its current CID.
|
|
374
|
+
* Returns the CID (without /ipfs/ prefix) on success, null on failure/timeout.
|
|
375
|
+
*/
|
|
376
|
+
async resolveIPNS(ipnsName) {
|
|
377
|
+
try {
|
|
378
|
+
const response = await fetch(`${this.LOCAL_IPFS_API}/name/resolve?arg=${encodeURIComponent(ipnsName)}&nocache=true`, {
|
|
379
|
+
method: 'POST',
|
|
380
|
+
signal: AbortSignal.timeout(30000),
|
|
381
|
+
});
|
|
382
|
+
if (!response.ok) {
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
const data = await response.json();
|
|
386
|
+
// Strip /ipfs/ prefix to get the raw CID
|
|
387
|
+
const resolvedCid = data.Path.replace(/^\/ipfs\//, '');
|
|
388
|
+
logger.info(`📡 IPNS resolved: ${ipnsName} → ${resolvedCid}`);
|
|
389
|
+
return resolvedCid;
|
|
390
|
+
}
|
|
391
|
+
catch (error) {
|
|
392
|
+
const err = error;
|
|
393
|
+
logger.debug(`IPNS resolve error: ${err.message}`);
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
338
397
|
}
|
|
339
398
|
// Singleton instance
|
|
340
399
|
let ipfsSyncInstance = null;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IPNS Key Manager
|
|
3
|
+
*
|
|
4
|
+
* Deterministic IPNS key derivation from LSH_SECRETS_KEY.
|
|
5
|
+
* Same key + repo + env always produces the same IPNS name,
|
|
6
|
+
* so teammates only need to share the encryption key.
|
|
7
|
+
*/
|
|
8
|
+
import * as crypto from 'crypto';
|
|
9
|
+
import { DEFAULTS } from '../constants/config.js';
|
|
10
|
+
import { createLogger } from './logger.js';
|
|
11
|
+
const logger = createLogger('IPNSKeyManager');
|
|
12
|
+
// PKCS8 DER prefix for ed25519 private keys (RFC 8410)
|
|
13
|
+
// This wraps a raw 32-byte seed into a valid PKCS8 structure.
|
|
14
|
+
const ED25519_PKCS8_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex');
|
|
15
|
+
/**
|
|
16
|
+
* Derive deterministic IPNS key info from secrets key + repo + env.
|
|
17
|
+
* Same inputs always produce the same key.
|
|
18
|
+
*/
|
|
19
|
+
export function deriveKeyInfo(secretsKey, repoName, environment) {
|
|
20
|
+
const context = `${DEFAULTS.IPNS_KEY_DERIVATION_CONTEXT}:${repoName}:${environment}`;
|
|
21
|
+
const seed = crypto.createHmac('sha256', secretsKey)
|
|
22
|
+
.update(context)
|
|
23
|
+
.digest();
|
|
24
|
+
const keyName = DEFAULTS.IPNS_KEY_PREFIX +
|
|
25
|
+
crypto.createHash('sha256').update(seed).digest('hex').substring(0, 16);
|
|
26
|
+
return { keyName, seed };
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Build a PEM-encoded PKCS8 ed25519 private key from a 32-byte seed.
|
|
30
|
+
* This is the format Kubo's /key/import accepts with format=pem-pkcs8-cleartext.
|
|
31
|
+
*/
|
|
32
|
+
export function buildPemFromSeed(seed) {
|
|
33
|
+
const pkcs8Der = Buffer.concat([ED25519_PKCS8_PREFIX, seed]);
|
|
34
|
+
const b64 = pkcs8Der.toString('base64');
|
|
35
|
+
const lines = b64.match(/.{1,64}/g) || [b64];
|
|
36
|
+
return `-----BEGIN PRIVATE KEY-----\n${lines.join('\n')}\n-----END PRIVATE KEY-----\n`;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Ensure the derived key is imported into the local Kubo node.
|
|
40
|
+
* Idempotent: checks /key/list first, only imports if missing.
|
|
41
|
+
* Returns the IPNS name (peer ID) on success, null on failure.
|
|
42
|
+
*/
|
|
43
|
+
export async function ensureKeyImported(kuboApiUrl, keyInfo) {
|
|
44
|
+
try {
|
|
45
|
+
// Check if key already exists
|
|
46
|
+
const listResponse = await fetch(`${kuboApiUrl}/key/list`, {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
signal: AbortSignal.timeout(5000),
|
|
49
|
+
});
|
|
50
|
+
if (listResponse.ok) {
|
|
51
|
+
const listData = await listResponse.json();
|
|
52
|
+
const existing = listData.Keys?.find(k => k.Name === keyInfo.keyName);
|
|
53
|
+
if (existing) {
|
|
54
|
+
logger.debug(`IPNS key "${keyInfo.keyName}" already imported: ${existing.Id}`);
|
|
55
|
+
return existing.Id;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// Import the key
|
|
59
|
+
const pem = buildPemFromSeed(keyInfo.seed);
|
|
60
|
+
const formData = new FormData();
|
|
61
|
+
const blob = new Blob([pem], { type: 'application/x-pem-file' });
|
|
62
|
+
formData.append('file', blob, 'key.pem');
|
|
63
|
+
const importResponse = await fetch(`${kuboApiUrl}/key/import?arg=${encodeURIComponent(keyInfo.keyName)}&format=pem-pkcs8-cleartext`, {
|
|
64
|
+
method: 'POST',
|
|
65
|
+
body: formData,
|
|
66
|
+
signal: AbortSignal.timeout(5000),
|
|
67
|
+
});
|
|
68
|
+
if (!importResponse.ok) {
|
|
69
|
+
const errorText = await importResponse.text();
|
|
70
|
+
logger.warn(`Failed to import IPNS key: ${errorText}`);
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
const importData = await importResponse.json();
|
|
74
|
+
logger.info(`Imported IPNS key "${importData.Name}": ${importData.Id}`);
|
|
75
|
+
return importData.Id;
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
const err = error;
|
|
79
|
+
logger.debug(`IPNS key import failed: ${err.message}`);
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lsh-framework",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.2.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": {
|