lsh-framework 3.2.5 → 3.5.1
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/LICENSE +21 -0
- package/README.md +72 -34
- package/dist/commands/ipfs.js +7 -12
- package/dist/commands/self.js +22 -16
- package/dist/commands/sync.js +49 -38
- package/dist/constants/config.js +3 -0
- package/dist/lib/floating-point-arithmetic.js +2 -2
- package/dist/lib/ipfs-client-manager.js +51 -13
- package/dist/lib/ipfs-secrets-storage.js +21 -16
- package/dist/lib/ipfs-sync.js +88 -14
- package/dist/lib/secrets-manager.js +117 -47
- package/dist/lib/sync-key-store.js +87 -0
- package/dist/services/secrets/secrets.js +77 -39
- package/package.json +16 -16
- package/dist/__tests__/fixtures/job-fixtures.js +0 -204
- package/dist/__tests__/fixtures/supabase-mocks.js +0 -252
- package/dist/daemon/job-registry.js +0 -556
- package/dist/daemon/lshd.js +0 -968
- package/dist/daemon/saas-api-routes.js +0 -599
- package/dist/daemon/saas-api-server.js +0 -231
- package/dist/examples/supabase-integration.js +0 -106
- package/dist/lib/api-response.js +0 -226
- package/dist/lib/base-command-registrar.js +0 -287
- package/dist/lib/base-job-manager.js +0 -295
- package/dist/lib/cloud-config-manager.js +0 -348
- package/dist/lib/cron-job-manager.js +0 -368
- package/dist/lib/daemon-client-helper.js +0 -145
- package/dist/lib/daemon-client.js +0 -513
- package/dist/lib/database-persistence.js +0 -727
- package/dist/lib/database-schema.js +0 -259
- package/dist/lib/database-types.js +0 -90
- package/dist/lib/enhanced-history-system.js +0 -247
- package/dist/lib/history-system.js +0 -246
- package/dist/lib/job-manager.js +0 -436
- package/dist/lib/job-storage-database.js +0 -164
- package/dist/lib/job-storage-memory.js +0 -73
- package/dist/lib/local-storage-adapter.js +0 -507
- package/dist/lib/optimized-job-scheduler.js +0 -356
- package/dist/lib/saas-audit.js +0 -215
- package/dist/lib/saas-auth.js +0 -465
- package/dist/lib/saas-billing.js +0 -503
- package/dist/lib/saas-email.js +0 -403
- package/dist/lib/saas-encryption.js +0 -221
- package/dist/lib/saas-organizations.js +0 -662
- package/dist/lib/saas-secrets.js +0 -408
- package/dist/lib/saas-types.js +0 -165
- package/dist/lib/supabase-client.js +0 -125
- package/dist/lib/supabase-utils.js +0 -396
- package/dist/services/cron/cron-registrar.js +0 -240
- package/dist/services/cron/cron.js +0 -9
- package/dist/services/daemon/daemon-registrar.js +0 -585
- package/dist/services/daemon/daemon.js +0 -9
- package/dist/services/supabase/supabase-registrar.js +0 -375
- package/dist/services/supabase/supabase.js +0 -9
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Sync .env files across machines using encrypted Supabase storage
|
|
4
4
|
*/
|
|
5
5
|
import * as fs from 'fs';
|
|
6
|
+
import * as os from 'os';
|
|
6
7
|
import * as path from 'path';
|
|
7
8
|
import * as crypto from 'crypto';
|
|
8
9
|
import { createLogger, LogLevel } from './logger.js';
|
|
@@ -10,7 +11,53 @@ import { getGitRepoInfo, hasEnvExample, ensureEnvInGitignore } from './git-utils
|
|
|
10
11
|
import { IPFSSyncLogger } from './ipfs-sync-logger.js';
|
|
11
12
|
import { IPFSSecretsStorage } from './ipfs-secrets-storage.js';
|
|
12
13
|
import { ENV_VARS } from '../constants/index.js';
|
|
14
|
+
import { extractErrorMessage } from './lsh-error.js';
|
|
15
|
+
import { SyncKeyStore } from './sync-key-store.js';
|
|
13
16
|
const logger = createLogger('SecretsManager');
|
|
17
|
+
/**
|
|
18
|
+
* Read LSH_SECRETS_KEY from a .env file without loading the entire file into process.env
|
|
19
|
+
*/
|
|
20
|
+
function readKeyFromEnvFile(envPath) {
|
|
21
|
+
try {
|
|
22
|
+
if (fs.existsSync(envPath)) {
|
|
23
|
+
const content = fs.readFileSync(envPath, 'utf8');
|
|
24
|
+
const match = content.match(/^LSH_SECRETS_KEY=['"]?([^'"\n]+)['"]?/m);
|
|
25
|
+
if (match)
|
|
26
|
+
return match[1];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// Ignore read errors
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Find LSH_SECRETS_KEY from environment, local .env, global ~/.env,
|
|
36
|
+
* or the persistent SyncKeyStore at $LSH_HOME/sync_key.json.
|
|
37
|
+
* Returns null if no explicit key is found (does not generate a fallback).
|
|
38
|
+
*/
|
|
39
|
+
export function findEncryptionKey() {
|
|
40
|
+
// 1. Check environment variable
|
|
41
|
+
const envKey = process.env[ENV_VARS.LSH_SECRETS_KEY];
|
|
42
|
+
if (envKey)
|
|
43
|
+
return envKey;
|
|
44
|
+
// 2. Check local .env
|
|
45
|
+
const localKey = readKeyFromEnvFile(path.join(process.cwd(), '.env'));
|
|
46
|
+
if (localKey)
|
|
47
|
+
return localKey;
|
|
48
|
+
// 3. Check global ~/.env
|
|
49
|
+
const home = process.env[ENV_VARS.HOME] || process.env[ENV_VARS.USERPROFILE] || '';
|
|
50
|
+
if (home) {
|
|
51
|
+
const globalKey = readKeyFromEnvFile(path.join(home, '.env'));
|
|
52
|
+
if (globalKey)
|
|
53
|
+
return globalKey;
|
|
54
|
+
}
|
|
55
|
+
// 4. Check persistent sync key store ($LSH_HOME/sync_key.json)
|
|
56
|
+
const stored = new SyncKeyStore().get();
|
|
57
|
+
if (stored)
|
|
58
|
+
return stored;
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
14
61
|
export class SecretsManager {
|
|
15
62
|
storage;
|
|
16
63
|
encryptionKey;
|
|
@@ -73,57 +120,85 @@ export class SecretsManager {
|
|
|
73
120
|
* Get default encryption key from environment or machine
|
|
74
121
|
*/
|
|
75
122
|
getDefaultEncryptionKey() {
|
|
76
|
-
// Check
|
|
77
|
-
const
|
|
78
|
-
if (
|
|
79
|
-
return
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
const
|
|
123
|
+
// Check environment, local .env, and global ~/.env
|
|
124
|
+
const explicitKey = findEncryptionKey();
|
|
125
|
+
if (explicitKey)
|
|
126
|
+
return explicitKey;
|
|
127
|
+
logger.warn('No explicit LSH_SECRETS_KEY found. Generating machine-derived fallback key.');
|
|
128
|
+
logger.warn('Set LSH_SECRETS_KEY in your environment for portable, secure encryption.');
|
|
129
|
+
// Generate from multiple machine-specific entropy sources
|
|
130
|
+
const hostname = os.hostname();
|
|
131
|
+
const uid = String(os.userInfo().uid);
|
|
132
|
+
const homedir = os.homedir();
|
|
133
|
+
// Try to read /etc/machine-id (Linux) for additional entropy
|
|
134
|
+
let machineSpecific;
|
|
135
|
+
try {
|
|
136
|
+
machineSpecific = fs.readFileSync('/etc/machine-id', 'utf8').trim();
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// Fallback to CPU model on macOS / systems without machine-id
|
|
140
|
+
machineSpecific = os.cpus()[0]?.model || 'no-cpu-info';
|
|
141
|
+
}
|
|
142
|
+
const seed = `${hostname}-${uid}-${homedir}-${machineSpecific}-lsh-secrets`;
|
|
85
143
|
// Create deterministic key
|
|
86
144
|
return crypto.createHash('sha256').update(seed).digest('hex');
|
|
87
145
|
}
|
|
88
146
|
/**
|
|
89
|
-
* Encrypt a value
|
|
147
|
+
* Encrypt a value using AES-256-GCM with authentication tag
|
|
90
148
|
*/
|
|
91
149
|
encrypt(text) {
|
|
92
|
-
const iv = crypto.randomBytes(
|
|
150
|
+
const iv = crypto.randomBytes(12); // 96-bit IV recommended for GCM
|
|
93
151
|
const key = Buffer.from(this.encryptionKey, 'hex');
|
|
94
|
-
const cipher = crypto.createCipheriv('aes-256-
|
|
152
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key.slice(0, 32), iv);
|
|
95
153
|
let encrypted = cipher.update(text, 'utf8', 'hex');
|
|
96
154
|
encrypted += cipher.final('hex');
|
|
97
|
-
|
|
155
|
+
const authTag = cipher.getAuthTag();
|
|
156
|
+
// Format: iv:authTag:encrypted (3-part GCM format)
|
|
157
|
+
return iv.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted;
|
|
98
158
|
}
|
|
99
159
|
/**
|
|
100
|
-
* Decrypt a value
|
|
160
|
+
* Decrypt a value (supports both AES-256-GCM and legacy AES-256-CBC)
|
|
101
161
|
*/
|
|
102
162
|
decrypt(text) {
|
|
103
163
|
try {
|
|
104
164
|
const parts = text.split(':');
|
|
105
|
-
|
|
165
|
+
const key = Buffer.from(this.encryptionKey, 'hex');
|
|
166
|
+
if (parts.length === 3) {
|
|
167
|
+
// AES-256-GCM format: iv:authTag:encrypted
|
|
168
|
+
const iv = Buffer.from(parts[0], 'hex');
|
|
169
|
+
const authTag = Buffer.from(parts[1], 'hex');
|
|
170
|
+
const encryptedText = parts[2];
|
|
171
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key.slice(0, 32), iv);
|
|
172
|
+
decipher.setAuthTag(authTag);
|
|
173
|
+
let decrypted = decipher.update(encryptedText, 'hex', 'utf8');
|
|
174
|
+
decrypted += decipher.final('utf8');
|
|
175
|
+
return decrypted;
|
|
176
|
+
}
|
|
177
|
+
else if (parts.length === 2) {
|
|
178
|
+
// Legacy AES-256-CBC format: iv:encrypted
|
|
179
|
+
const iv = Buffer.from(parts[0], 'hex');
|
|
180
|
+
const encryptedText = parts[1];
|
|
181
|
+
const decipher = crypto.createDecipheriv('aes-256-cbc', key.slice(0, 32), iv);
|
|
182
|
+
let decrypted = decipher.update(encryptedText, 'hex', 'utf8');
|
|
183
|
+
decrypted += decipher.final('utf8');
|
|
184
|
+
return decrypted;
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
106
187
|
throw new Error('Invalid encrypted format');
|
|
107
188
|
}
|
|
108
|
-
const iv = Buffer.from(parts[0], 'hex');
|
|
109
|
-
const encryptedText = parts[1];
|
|
110
|
-
const key = Buffer.from(this.encryptionKey, 'hex');
|
|
111
|
-
const decipher = crypto.createDecipheriv('aes-256-cbc', key.slice(0, 32), iv);
|
|
112
|
-
let decrypted = decipher.update(encryptedText, 'hex', 'utf8');
|
|
113
|
-
decrypted += decipher.final('utf8');
|
|
114
|
-
return decrypted;
|
|
115
189
|
}
|
|
116
190
|
catch (error) {
|
|
117
|
-
const
|
|
118
|
-
if (
|
|
191
|
+
const msg = extractErrorMessage(error);
|
|
192
|
+
if (msg.includes('bad decrypt') || msg.includes('wrong final block length') ||
|
|
193
|
+
msg.includes('Unsupported state') || msg.includes('unable to authenticate')) {
|
|
119
194
|
throw new Error('Decryption failed. This usually means:\n' +
|
|
120
195
|
' 1. You need to set LSH_SECRETS_KEY environment variable\n' +
|
|
121
196
|
' 2. The key must match the one used during encryption\n' +
|
|
122
197
|
' 3. Generate a shared key with: lsh secrets key\n' +
|
|
123
198
|
' 4. Add it to your .env: LSH_SECRETS_KEY=<key>\n' +
|
|
124
|
-
'\nOriginal error: ' +
|
|
199
|
+
'\nOriginal error: ' + msg, { cause: error });
|
|
125
200
|
}
|
|
126
|
-
throw
|
|
201
|
+
throw error;
|
|
127
202
|
}
|
|
128
203
|
}
|
|
129
204
|
/**
|
|
@@ -243,8 +318,8 @@ export class SecretsManager {
|
|
|
243
318
|
}
|
|
244
319
|
// Get the effective environment name (repo-aware)
|
|
245
320
|
const effectiveEnv = this.getRepoAwareEnvironment(environment);
|
|
246
|
-
// Warn if using
|
|
247
|
-
if (!
|
|
321
|
+
// Warn if using machine-derived fallback key (no explicit key found anywhere)
|
|
322
|
+
if (!findEncryptionKey()) {
|
|
248
323
|
logger.warn('⚠️ Warning: No LSH_SECRETS_KEY set. Using machine-specific key.');
|
|
249
324
|
logger.warn(' To share secrets across machines, generate a key with: lsh key');
|
|
250
325
|
logger.warn(' Then add LSH_SECRETS_KEY=<key> to your .env on all machines');
|
|
@@ -277,10 +352,9 @@ export class SecretsManager {
|
|
|
277
352
|
}
|
|
278
353
|
}
|
|
279
354
|
catch (error) {
|
|
280
|
-
const err = error;
|
|
281
355
|
// Re-throw destructive change errors
|
|
282
|
-
if (
|
|
283
|
-
throw
|
|
356
|
+
if (extractErrorMessage(error).includes('Destructive change')) {
|
|
357
|
+
throw error;
|
|
284
358
|
}
|
|
285
359
|
// Ignore other errors (like missing secrets) and proceed
|
|
286
360
|
}
|
|
@@ -357,8 +431,8 @@ export class SecretsManager {
|
|
|
357
431
|
const finalEnv = { ...pulledEnv, ...localLshKeys };
|
|
358
432
|
// Convert to .env format
|
|
359
433
|
const envContent = this.formatEnvFile(finalEnv);
|
|
360
|
-
// Write new .env
|
|
361
|
-
fs.writeFileSync(envFilePath, envContent, 'utf8');
|
|
434
|
+
// Write new .env with restrictive permissions (owner read/write only)
|
|
435
|
+
fs.writeFileSync(envFilePath, envContent, { encoding: 'utf8', mode: 0o600 });
|
|
362
436
|
logger.info(`✅ Pulled ${secrets.length} secrets from IPFS`);
|
|
363
437
|
// Get metadata for CID display
|
|
364
438
|
const metadata = this.storage.getMetadata(environment, this.gitInfo?.repoName);
|
|
@@ -430,7 +504,7 @@ export class SecretsManager {
|
|
|
430
504
|
cloudExists: false,
|
|
431
505
|
cloudKeys: 0,
|
|
432
506
|
cloudModified: undefined,
|
|
433
|
-
keySet: !!
|
|
507
|
+
keySet: !!findEncryptionKey(),
|
|
434
508
|
keyMatches: undefined,
|
|
435
509
|
suggestions: [],
|
|
436
510
|
};
|
|
@@ -518,7 +592,7 @@ export class SecretsManager {
|
|
|
518
592
|
* Generate encryption key if not set
|
|
519
593
|
*/
|
|
520
594
|
async ensureEncryptionKey() {
|
|
521
|
-
if (
|
|
595
|
+
if (findEncryptionKey()) {
|
|
522
596
|
return true; // Key already set
|
|
523
597
|
}
|
|
524
598
|
logger.warn('⚠️ No encryption key found. Generating a new key...');
|
|
@@ -540,7 +614,7 @@ export class SecretsManager {
|
|
|
540
614
|
else {
|
|
541
615
|
content += `\n# LSH Secrets Encryption Key (do not commit!)\nLSH_SECRETS_KEY=${key}\n`;
|
|
542
616
|
}
|
|
543
|
-
fs.writeFileSync(envPath, content, 'utf8');
|
|
617
|
+
fs.writeFileSync(envPath, content, { encoding: 'utf8', mode: 0o600 });
|
|
544
618
|
// Set in current process
|
|
545
619
|
process.env[ENV_VARS.LSH_SECRETS_KEY] = key;
|
|
546
620
|
this.encryptionKey = key;
|
|
@@ -549,8 +623,7 @@ export class SecretsManager {
|
|
|
549
623
|
return true;
|
|
550
624
|
}
|
|
551
625
|
catch (error) {
|
|
552
|
-
|
|
553
|
-
logger.error(`Failed to save encryption key: ${_err.message}`);
|
|
626
|
+
logger.error(`Failed to save encryption key: ${extractErrorMessage(error)}`);
|
|
554
627
|
logger.info('Please set it manually:');
|
|
555
628
|
logger.info(`export LSH_SECRETS_KEY=${key}`);
|
|
556
629
|
return false;
|
|
@@ -581,13 +654,12 @@ LSH_SECRETS_KEY=${this.encryptionKey}
|
|
|
581
654
|
# Add your environment variables below
|
|
582
655
|
`;
|
|
583
656
|
try {
|
|
584
|
-
fs.writeFileSync(envFilePath, template, 'utf8');
|
|
657
|
+
fs.writeFileSync(envFilePath, template, { encoding: 'utf8', mode: 0o600 });
|
|
585
658
|
logger.info(`✅ Created ${envFilePath} from template`);
|
|
586
659
|
return true;
|
|
587
660
|
}
|
|
588
661
|
catch (error) {
|
|
589
|
-
|
|
590
|
-
logger.error(`Failed to create ${envFilePath}: ${_err.message}`);
|
|
662
|
+
logger.error(`Failed to create ${envFilePath}: ${extractErrorMessage(error)}`);
|
|
591
663
|
return false;
|
|
592
664
|
}
|
|
593
665
|
}
|
|
@@ -599,13 +671,12 @@ LSH_SECRETS_KEY=${this.encryptionKey}
|
|
|
599
671
|
if (!content.includes('LSH_SECRETS_KEY')) {
|
|
600
672
|
newContent += `\n# LSH Secrets Encryption Key (auto-generated)\nLSH_SECRETS_KEY=${this.encryptionKey}\n`;
|
|
601
673
|
}
|
|
602
|
-
fs.writeFileSync(envFilePath, newContent, 'utf8');
|
|
674
|
+
fs.writeFileSync(envFilePath, newContent, { encoding: 'utf8', mode: 0o600 });
|
|
603
675
|
logger.info(`✅ Created ${envFilePath} from ${path.basename(examplePath)}`);
|
|
604
676
|
return true;
|
|
605
677
|
}
|
|
606
678
|
catch (error) {
|
|
607
|
-
|
|
608
|
-
logger.error(`Failed to create ${envFilePath}: ${_err.message}`);
|
|
679
|
+
logger.error(`Failed to create ${envFilePath}: ${extractErrorMessage(error)}`);
|
|
609
680
|
return false;
|
|
610
681
|
}
|
|
611
682
|
}
|
|
@@ -679,7 +750,7 @@ LSH_SECRETS_KEY=${this.encryptionKey}
|
|
|
679
750
|
out();
|
|
680
751
|
}
|
|
681
752
|
// Step 1: Ensure encryption key exists
|
|
682
|
-
if (!
|
|
753
|
+
if (!findEncryptionKey()) {
|
|
683
754
|
logger.info('🔑 No encryption key found...');
|
|
684
755
|
await this.ensureEncryptionKey();
|
|
685
756
|
out();
|
|
@@ -982,8 +1053,7 @@ LSH_SECRETS_KEY=${this.encryptionKey}
|
|
|
982
1053
|
}
|
|
983
1054
|
catch (error) {
|
|
984
1055
|
// Don't fail operation if IPFS logging fails
|
|
985
|
-
|
|
986
|
-
logger.warn(`⚠️ Could not log to IPFS: ${err.message}`);
|
|
1056
|
+
logger.warn(`⚠️ Could not log to IPFS: ${extractErrorMessage(error)}`);
|
|
987
1057
|
}
|
|
988
1058
|
}
|
|
989
1059
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent storage for the LSH sync secret.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the design of mcli's `mcli sync key` store: a single 64-char
|
|
5
|
+
* hex value kept in ``$LSH_HOME/sync_key.json`` (default
|
|
6
|
+
* ``~/.config/lsh/sync_key.json``) with mode 0600. The
|
|
7
|
+
* ``LSH_SECRETS_KEY`` environment variable still wins when set; the
|
|
8
|
+
* store is only consulted as a fallback so users do not have to re-paste
|
|
9
|
+
* their secret on every shell.
|
|
10
|
+
*/
|
|
11
|
+
import * as crypto from 'crypto';
|
|
12
|
+
import * as fs from 'fs';
|
|
13
|
+
import * as os from 'os';
|
|
14
|
+
import * as path from 'path';
|
|
15
|
+
export const HEX64_REGEX = /^[0-9a-fA-F]{64}$/;
|
|
16
|
+
const FILENAME = 'sync_key.json';
|
|
17
|
+
/** Resolve the directory where lsh stores its config (`$LSH_HOME` or `~/.config/lsh`). */
|
|
18
|
+
export function getLshHome() {
|
|
19
|
+
const overridden = process.env.LSH_HOME;
|
|
20
|
+
if (overridden && overridden.trim().length > 0)
|
|
21
|
+
return overridden;
|
|
22
|
+
return path.join(os.homedir(), '.config', 'lsh');
|
|
23
|
+
}
|
|
24
|
+
/** Return a freshly-generated 64-char hex secret without persisting it. */
|
|
25
|
+
export function generateKey() {
|
|
26
|
+
return crypto.randomBytes(32).toString('hex');
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Persistent on-disk store for the LSH sync secret.
|
|
30
|
+
*/
|
|
31
|
+
export class SyncKeyStore {
|
|
32
|
+
/** Absolute path to the on-disk key file. */
|
|
33
|
+
get path() {
|
|
34
|
+
return path.join(getLshHome(), FILENAME);
|
|
35
|
+
}
|
|
36
|
+
/** Read the persisted key, or `null` if none is configured / file is malformed. */
|
|
37
|
+
get() {
|
|
38
|
+
const file = this.path;
|
|
39
|
+
if (!fs.existsSync(file))
|
|
40
|
+
return null;
|
|
41
|
+
try {
|
|
42
|
+
const raw = fs.readFileSync(file, 'utf-8');
|
|
43
|
+
const parsed = JSON.parse(raw);
|
|
44
|
+
if (typeof parsed.key === 'string' && HEX64_REGEX.test(parsed.key)) {
|
|
45
|
+
return parsed.key;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// fall through to null
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
/** Validate and persist a key. Throws on invalid input. */
|
|
54
|
+
set(key) {
|
|
55
|
+
if (typeof key !== 'string' || !HEX64_REGEX.test(key)) {
|
|
56
|
+
throw new Error('sync key must be a 64-char hex string');
|
|
57
|
+
}
|
|
58
|
+
this.write(key);
|
|
59
|
+
}
|
|
60
|
+
/** Generate a new key and persist it. Refuses to overwrite unless `force=true`. */
|
|
61
|
+
generate(force = false) {
|
|
62
|
+
if (fs.existsSync(this.path) && !force) {
|
|
63
|
+
throw new Error(`sync key already exists at ${this.path}; use force=true to overwrite`);
|
|
64
|
+
}
|
|
65
|
+
const key = generateKey();
|
|
66
|
+
this.write(key);
|
|
67
|
+
return key;
|
|
68
|
+
}
|
|
69
|
+
/** Delete the persisted key, if any. No-op when the file is absent. */
|
|
70
|
+
clear() {
|
|
71
|
+
try {
|
|
72
|
+
fs.unlinkSync(this.path);
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
const e = err;
|
|
76
|
+
if (e.code !== 'ENOENT')
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
write(key) {
|
|
81
|
+
const dir = path.dirname(this.path);
|
|
82
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
83
|
+
// Write first, then chmod, so the secret is never world-readable.
|
|
84
|
+
fs.writeFileSync(this.path, JSON.stringify({ key }));
|
|
85
|
+
fs.chmodSync(this.path, 0o600);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -2,53 +2,25 @@
|
|
|
2
2
|
* Secrets Management Commands
|
|
3
3
|
* Sync .env files across development environments
|
|
4
4
|
*/
|
|
5
|
-
import SecretsManager from '../../lib/secrets-manager.js';
|
|
5
|
+
import SecretsManager, { findEncryptionKey } from '../../lib/secrets-manager.js';
|
|
6
6
|
import * as fs from 'fs';
|
|
7
7
|
import * as path from 'path';
|
|
8
8
|
import * as readline from 'readline';
|
|
9
9
|
import { getGitRepoInfo } from '../../lib/git-utils.js';
|
|
10
10
|
import { ENV_VARS } from '../../constants/index.js';
|
|
11
11
|
import { IPFSClientManager } from '../../lib/ipfs-client-manager.js';
|
|
12
|
+
import { SyncKeyStore } from '../../lib/sync-key-store.js';
|
|
13
|
+
function maskKey(key) {
|
|
14
|
+
if (key.length <= 12)
|
|
15
|
+
return '*'.repeat(key.length);
|
|
16
|
+
return `${key.slice(0, 4)}...${key.slice(-4)}`;
|
|
17
|
+
}
|
|
12
18
|
/**
|
|
13
19
|
* Type guard to check if a string is a valid OutputFormat.
|
|
14
20
|
*/
|
|
15
21
|
function isOutputFormat(value) {
|
|
16
22
|
return ['env', 'json', 'yaml', 'toml', 'export'].includes(value);
|
|
17
23
|
}
|
|
18
|
-
/**
|
|
19
|
-
* Find existing LSH_SECRETS_KEY from environment, local .env, or global ~/.env
|
|
20
|
-
*/
|
|
21
|
-
function findExistingKey() {
|
|
22
|
-
// 1. Check environment variable
|
|
23
|
-
const envKey = process.env[ENV_VARS.LSH_SECRETS_KEY];
|
|
24
|
-
if (envKey)
|
|
25
|
-
return envKey;
|
|
26
|
-
// 2. Check local .env
|
|
27
|
-
const localEnvPath = path.join(process.cwd(), '.env');
|
|
28
|
-
const localKey = readKeyFromEnvFile(localEnvPath);
|
|
29
|
-
if (localKey)
|
|
30
|
-
return localKey;
|
|
31
|
-
// 3. Check global ~/.env
|
|
32
|
-
const globalEnvPath = path.join(process.env.HOME || '~', '.env');
|
|
33
|
-
const globalKey = readKeyFromEnvFile(globalEnvPath);
|
|
34
|
-
if (globalKey)
|
|
35
|
-
return globalKey;
|
|
36
|
-
return null;
|
|
37
|
-
}
|
|
38
|
-
function readKeyFromEnvFile(envPath) {
|
|
39
|
-
try {
|
|
40
|
-
if (fs.existsSync(envPath)) {
|
|
41
|
-
const content = fs.readFileSync(envPath, 'utf-8');
|
|
42
|
-
const match = content.match(/^LSH_SECRETS_KEY=['"]?([^'"\n]+)['"]?/m);
|
|
43
|
-
if (match)
|
|
44
|
-
return match[1];
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
catch {
|
|
48
|
-
// Ignore read errors
|
|
49
|
-
}
|
|
50
|
-
return null;
|
|
51
|
-
}
|
|
52
24
|
export async function init_secrets(program) {
|
|
53
25
|
// Push secrets to cloud
|
|
54
26
|
program
|
|
@@ -305,7 +277,7 @@ export async function init_secrets(program) {
|
|
|
305
277
|
// Default action: show existing key or prompt to generate
|
|
306
278
|
keyCmd
|
|
307
279
|
.action(async () => {
|
|
308
|
-
const existingKey =
|
|
280
|
+
const existingKey = findEncryptionKey();
|
|
309
281
|
if (existingKey) {
|
|
310
282
|
const masked = existingKey.slice(0, 8) + '...' + existingKey.slice(-4);
|
|
311
283
|
console.log(`\n🔑 LSH_SECRETS_KEY=${masked}\n`);
|
|
@@ -326,7 +298,7 @@ export async function init_secrets(program) {
|
|
|
326
298
|
.option('--no-mask', 'Show the full key (default: masked)')
|
|
327
299
|
.option('--export', 'Output in export format for shell evaluation')
|
|
328
300
|
.action(async (options) => {
|
|
329
|
-
const existingKey =
|
|
301
|
+
const existingKey = findEncryptionKey();
|
|
330
302
|
if (!existingKey) {
|
|
331
303
|
console.error('❌ No encryption key found. Run: lsh key generate');
|
|
332
304
|
process.exit(1);
|
|
@@ -350,7 +322,7 @@ export async function init_secrets(program) {
|
|
|
350
322
|
.option('--force', 'Overwrite existing key')
|
|
351
323
|
.option('--export', 'Output in export format for shell evaluation')
|
|
352
324
|
.action(async (options) => {
|
|
353
|
-
const existingKey =
|
|
325
|
+
const existingKey = findEncryptionKey();
|
|
354
326
|
if (existingKey && !options.force) {
|
|
355
327
|
console.error('❌ An encryption key already exists. Use --force to overwrite.');
|
|
356
328
|
console.error('⚠️ Warning: existing secrets will NOT be decryptable with a new key.\n');
|
|
@@ -400,7 +372,7 @@ export async function init_secrets(program) {
|
|
|
400
372
|
process.exit(1);
|
|
401
373
|
}
|
|
402
374
|
// Check for existing key
|
|
403
|
-
const existingKey =
|
|
375
|
+
const existingKey = findEncryptionKey();
|
|
404
376
|
if (existingKey) {
|
|
405
377
|
if (existingKey === keyValue) {
|
|
406
378
|
console.log('\n✅ This key is already configured.\n');
|
|
@@ -447,6 +419,72 @@ export async function init_secrets(program) {
|
|
|
447
419
|
process.exit(1);
|
|
448
420
|
}
|
|
449
421
|
});
|
|
422
|
+
// lsh key gen — generate + persist to ~/.config/lsh/sync_key.json
|
|
423
|
+
keyCmd
|
|
424
|
+
.command('gen')
|
|
425
|
+
.description('Generate a new key and persist it to the LSH sync key store')
|
|
426
|
+
.option('-f, --force', 'Overwrite an existing stored key')
|
|
427
|
+
.option('--show', 'Print the full key (default masks it)')
|
|
428
|
+
.action((options) => {
|
|
429
|
+
const store = new SyncKeyStore();
|
|
430
|
+
let key;
|
|
431
|
+
try {
|
|
432
|
+
key = store.generate(options.force === true);
|
|
433
|
+
}
|
|
434
|
+
catch (err) {
|
|
435
|
+
const e = err;
|
|
436
|
+
console.error(`❌ ${e.message}`);
|
|
437
|
+
console.error("💡 Use --force to overwrite, or run 'lsh key show' to view the existing key.");
|
|
438
|
+
process.exit(1);
|
|
439
|
+
return; // for the type-checker
|
|
440
|
+
}
|
|
441
|
+
console.log(`✅ Generated sync key at ${store.path}`);
|
|
442
|
+
if (options.show) {
|
|
443
|
+
console.log(`🔑 ${key}`);
|
|
444
|
+
}
|
|
445
|
+
else {
|
|
446
|
+
console.log(`🔑 ${maskKey(key)} (use --show to print full)`);
|
|
447
|
+
}
|
|
448
|
+
console.log("💡 Share this key with teammates / your other hosts. Then run `lsh key set <key>` on each peer.");
|
|
449
|
+
});
|
|
450
|
+
// lsh key set — paste a 64-char hex key from another host into the store
|
|
451
|
+
keyCmd
|
|
452
|
+
.command('set <key>')
|
|
453
|
+
.description('Persist an existing 64-char hex key to the LSH sync key store')
|
|
454
|
+
.action((keyValue) => {
|
|
455
|
+
const store = new SyncKeyStore();
|
|
456
|
+
try {
|
|
457
|
+
store.set(keyValue.trim());
|
|
458
|
+
}
|
|
459
|
+
catch (err) {
|
|
460
|
+
const e = err;
|
|
461
|
+
console.error(`❌ ${e.message}`);
|
|
462
|
+
process.exit(1);
|
|
463
|
+
}
|
|
464
|
+
console.log('✅ Sync key stored.');
|
|
465
|
+
});
|
|
466
|
+
// lsh key clear — delete the persisted key (env var, if set, is untouched)
|
|
467
|
+
keyCmd
|
|
468
|
+
.command('clear')
|
|
469
|
+
.description('Delete the persisted LSH sync key (env var is untouched)')
|
|
470
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
471
|
+
.action(async (options) => {
|
|
472
|
+
if (!options.yes) {
|
|
473
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
474
|
+
const answer = await new Promise((resolve) => {
|
|
475
|
+
rl.question('Remove the stored sync key? [y/N] ', (ans) => {
|
|
476
|
+
rl.close();
|
|
477
|
+
resolve(ans.trim().toLowerCase());
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
if (answer !== 'y' && answer !== 'yes') {
|
|
481
|
+
console.log('Aborted.');
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
new SyncKeyStore().clear();
|
|
486
|
+
console.log('✅ Stored sync key removed.');
|
|
487
|
+
});
|
|
450
488
|
// Create .env file
|
|
451
489
|
program
|
|
452
490
|
.command('create')
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lsh-framework",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.5.1",
|
|
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
|
-
"main": "dist/
|
|
5
|
+
"main": "dist/cli.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"lsh": "./dist/cli.js"
|
|
8
8
|
},
|
|
@@ -64,39 +64,39 @@
|
|
|
64
64
|
],
|
|
65
65
|
"dependencies": {
|
|
66
66
|
"@supabase/supabase-js": "^2.57.4",
|
|
67
|
-
"@types/proper-lockfile": "^4.1.4",
|
|
68
67
|
"bcrypt": "^6.0.0",
|
|
69
68
|
"chalk": "^5.3.0",
|
|
70
69
|
"chokidar": "^5.0.0",
|
|
71
|
-
"commander": "^
|
|
70
|
+
"commander": "^15.0.0",
|
|
72
71
|
"cors": "^2.8.5",
|
|
73
72
|
"dotenv": "^17.2.3",
|
|
74
|
-
"express": "^
|
|
73
|
+
"express": "^5.2.1",
|
|
75
74
|
"express-rate-limit": "^8.2.1",
|
|
76
75
|
"glob": "^13.0.0",
|
|
77
|
-
"inquirer": "^
|
|
76
|
+
"inquirer": "^14.0.1",
|
|
78
77
|
"js-yaml": "^4.1.0",
|
|
79
78
|
"jsonwebtoken": "^9.0.2",
|
|
80
79
|
"node-cron": "^4.2.1",
|
|
81
80
|
"ora": "^9.0.0",
|
|
82
81
|
"pg": "^8.16.3",
|
|
83
82
|
"proper-lockfile": "^4.1.2",
|
|
84
|
-
"smol-toml": "^1.3.1"
|
|
85
|
-
"uuid": "^13.0.0"
|
|
83
|
+
"smol-toml": "^1.3.1"
|
|
86
84
|
},
|
|
87
85
|
"devDependencies": {
|
|
88
|
-
"@
|
|
86
|
+
"@eslint/js": "^10.0.1",
|
|
87
|
+
"@types/bcrypt": "^6.0.0",
|
|
89
88
|
"@types/cors": "^2.8.17",
|
|
90
|
-
"@types/express": "^
|
|
89
|
+
"@types/express": "^5.0.6",
|
|
91
90
|
"@types/jest": "^30.0.0",
|
|
92
91
|
"@types/js-yaml": "^4.0.9",
|
|
93
92
|
"@types/jsonwebtoken": "^9.0.5",
|
|
94
|
-
"@types/node": "^
|
|
95
|
-
"@
|
|
96
|
-
"@typescript-eslint/
|
|
97
|
-
"eslint": "^
|
|
98
|
-
"
|
|
93
|
+
"@types/node": "^25.9.1",
|
|
94
|
+
"@types/proper-lockfile": "^4.1.4",
|
|
95
|
+
"@typescript-eslint/eslint-plugin": "^8.60.0",
|
|
96
|
+
"@typescript-eslint/parser": "^8.60.0",
|
|
97
|
+
"eslint": "^10.4.1",
|
|
98
|
+
"jest": "^30.4.2",
|
|
99
99
|
"ts-jest": "^29.2.5",
|
|
100
|
-
"typescript": "^
|
|
100
|
+
"typescript": "^6.0.3"
|
|
101
101
|
}
|
|
102
102
|
}
|