@faizahmed/secret-keystore 1.1.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/bin/cli.js ADDED
@@ -0,0 +1,969 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * @faizahmed/secret-keystore CLI
5
+ *
6
+ * Command-line interface for encrypting configuration files.
7
+ *
8
+ * Usage:
9
+ * npx @faizahmed/secret-keystore encrypt [options]
10
+ */
11
+
12
+ const fs = require('node:fs');
13
+ const path = require('node:path');
14
+ const os = require('node:os');
15
+ const crypto = require('node:crypto');
16
+ const { spawn, spawnSync } = require('node:child_process');
17
+ const {
18
+ encryptKMSEnvContent,
19
+ encryptKMSJsonContent,
20
+ encryptKMSYamlContent,
21
+ decryptKMSEnvContent,
22
+ decryptKMSJsonContent,
23
+ decryptKMSYamlContent,
24
+ parseEnvContent,
25
+ maskKmsKeyId,
26
+ validateKmsKeyId,
27
+ config,
28
+ rotateKMSContent,
29
+ isAlreadyEncrypted,
30
+ getAllPaths,
31
+ getByPath,
32
+ parseYaml
33
+ } = require('../src/index');
34
+
35
+ // ═══════════════════════════════════════════════════════════════════════════
36
+ // ARGUMENT PARSING
37
+ // ═══════════════════════════════════════════════════════════════════════════
38
+
39
+ /**
40
+ * Strip surrounding quotes from a value
41
+ */
42
+ function stripQuotes(value) {
43
+ if (!value) return value;
44
+ return value.replaceAll(/(^["'])|(["']$)/g, '');
45
+ }
46
+
47
+ /**
48
+ * Parse comma-separated list into array
49
+ */
50
+ function parseCommaSeparated(value) {
51
+ return value
52
+ .split(',')
53
+ .map(item => item.trim())
54
+ .filter(Boolean);
55
+ }
56
+
57
+ /**
58
+ * Apply key-value argument to parsed object
59
+ */
60
+ function applyKeyValueArg(parsed, key, value) {
61
+ const handlers = {
62
+ path: () => {
63
+ parsed.path = value;
64
+ },
65
+ format: () => {
66
+ parsed.format = value;
67
+ },
68
+ 'kms-key-id': () => {
69
+ parsed.kmsKeyId = value;
70
+ },
71
+ 'old-kms-key-id': () => {
72
+ parsed.oldKmsKeyId = value;
73
+ },
74
+ keys: () => {
75
+ parsed.keys = parseCommaSeparated(value);
76
+ },
77
+ patterns: () => {
78
+ parsed.patterns = parseCommaSeparated(value);
79
+ },
80
+ exclude: () => {
81
+ parsed.exclude = parseCommaSeparated(value);
82
+ },
83
+ region: () => {
84
+ parsed.region = value;
85
+ },
86
+ output: () => {
87
+ parsed.output = value;
88
+ }
89
+ };
90
+
91
+ const handler = handlers[key];
92
+ if (handler) handler();
93
+ }
94
+
95
+ /**
96
+ * Parse a --key=value or --key value style argument
97
+ */
98
+ function parseKeyValueArg(arg, args, currentIndex) {
99
+ let key, value;
100
+ let nextIndex = currentIndex;
101
+
102
+ if (arg.includes('=')) {
103
+ const eqIndex = arg.indexOf('=');
104
+ key = arg.substring(2, eqIndex);
105
+ value = arg.substring(eqIndex + 1);
106
+ } else {
107
+ key = arg.substring(2);
108
+ nextIndex = currentIndex + 1;
109
+ value = args[nextIndex];
110
+ }
111
+
112
+ value = stripQuotes(value);
113
+ return { key, value, nextIndex };
114
+ }
115
+
116
+ function parseArgs(args) {
117
+ const parsed = {
118
+ command: null,
119
+ path: './.env',
120
+ format: null, // auto-detect
121
+ kmsKeyId: null, // REQUIRED
122
+ oldKmsKeyId: null, // required for rotate
123
+ keys: null,
124
+ patterns: null,
125
+ exclude: null,
126
+ region: null,
127
+ output: null,
128
+ useCredentials: false,
129
+ dryRun: false,
130
+ exec: null, // command + args after `--` (for `run`)
131
+ help: false,
132
+ version: false
133
+ };
134
+
135
+ const setCommand = name => () => {
136
+ parsed.command = name;
137
+ };
138
+
139
+ const flagHandlers = {
140
+ encrypt: setCommand('encrypt'),
141
+ decrypt: setCommand('decrypt'),
142
+ run: setCommand('run'),
143
+ rotate: setCommand('rotate'),
144
+ edit: setCommand('edit'),
145
+ init: setCommand('init'),
146
+ keys: setCommand('keys'),
147
+ status: setCommand('status'),
148
+ import: setCommand('import'),
149
+ '--help': () => {
150
+ parsed.help = true;
151
+ },
152
+ '-h': () => {
153
+ parsed.help = true;
154
+ },
155
+ '--version': () => {
156
+ parsed.version = true;
157
+ },
158
+ '-v': () => {
159
+ parsed.version = true;
160
+ },
161
+ '--use-credentials': () => {
162
+ parsed.useCredentials = true;
163
+ },
164
+ '--dry-run': () => {
165
+ parsed.dryRun = true;
166
+ }
167
+ };
168
+
169
+ let i = 0;
170
+ while (i < args.length) {
171
+ const arg = args[i];
172
+
173
+ // Everything after `--` is the command to exec (for `run`)
174
+ if (arg === '--') {
175
+ parsed.exec = args.slice(i + 1);
176
+ break;
177
+ }
178
+
179
+ // Handle simple flags
180
+ const flagHandler = flagHandlers[arg];
181
+ if (flagHandler) {
182
+ flagHandler();
183
+ i += 1;
184
+ continue;
185
+ }
186
+
187
+ // Handle --key=value or --key value format
188
+ if (arg.startsWith('--')) {
189
+ const { key, value, nextIndex } = parseKeyValueArg(arg, args, i);
190
+ applyKeyValueArg(parsed, key, value);
191
+ i = nextIndex + 1;
192
+ } else {
193
+ i += 1;
194
+ }
195
+ }
196
+
197
+ return parsed;
198
+ }
199
+
200
+ // ═══════════════════════════════════════════════════════════════════════════
201
+ // HELP & VERSION
202
+ // ═══════════════════════════════════════════════════════════════════════════
203
+
204
+ function printVersion() {
205
+ const pkg = require('../package.json');
206
+ console.log(`${pkg.name} v${pkg.version}`);
207
+ }
208
+
209
+ function printHelp() {
210
+ console.log(String.raw`
211
+ @faizahmed/secret-keystore - Secure secrets management with AWS KMS
212
+
213
+ USAGE:
214
+ npx @faizahmed/secret-keystore <command> [options]
215
+
216
+ COMMANDS:
217
+ encrypt Encrypt values in a configuration file
218
+ decrypt Decrypt values in a configuration file
219
+ run Decrypt and run a command with secrets injected into its env
220
+ rotate Re-encrypt a file under a new KMS Key ID (requires --old-kms-key-id)
221
+ edit Decrypt → open in $EDITOR → re-encrypt on save (via a secure temp file)
222
+ init Scaffold a starter .env
223
+ keys List the keys/paths in a file (no values)
224
+ status Show which keys are encrypted vs plaintext (no values)
225
+ import Encrypt an existing plaintext .env in place (migration)
226
+
227
+ OPTIONS:
228
+ --kms-key-id=<id> REQUIRED. KMS Key ID (ARN, UUID, or alias)
229
+
230
+ --old-kms-key-id=<id> For "rotate": the current key the file is encrypted with
231
+
232
+ --path=<path> Path to config file (default: ./.env)
233
+
234
+ --format=<format> File format: env, json, yaml (auto-detected if omitted)
235
+
236
+ --keys=<keys> Comma-separated list of keys to encrypt
237
+ (encrypts all non-reserved keys if omitted)
238
+
239
+ --patterns=<patterns> Comma-separated glob patterns (** only)
240
+ Example: --patterns="**.password,**.secret_key"
241
+
242
+ --exclude=<keys> Comma-separated keys/paths to exclude
243
+
244
+ --region=<region> AWS region (uses AWS_REGION env var if omitted)
245
+
246
+ --output=<path> Output file (default: overwrite input file)
247
+
248
+ --use-credentials Use explicit AWS credentials instead of IAM role
249
+ Requires AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY
250
+
251
+ --dry-run Show what would be encrypted without making changes
252
+
253
+ --help, -h Show this help message
254
+
255
+ --version, -v Show version number
256
+
257
+ AUTHENTICATION:
258
+ By default, this CLI uses IAM roles for AWS authentication.
259
+ This is the recommended approach for production environments.
260
+
261
+ To use explicit credentials (e.g., for local development):
262
+ 1. Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables
263
+ 2. Pass --use-credentials flag
264
+
265
+ EXAMPLES:
266
+ # Encrypt all keys in .env (kms-key-id is REQUIRED)
267
+ npx @faizahmed/secret-keystore encrypt --kms-key-id="alias/my-key"
268
+
269
+ # Encrypt specific keys only
270
+ npx @faizahmed/secret-keystore encrypt \
271
+ --kms-key-id="arn:aws:kms:us-east-1:123456789:key/abc-123" \
272
+ --keys="DB_PASSWORD,API_KEY"
273
+
274
+ # Encrypt YAML file with patterns
275
+ npx @faizahmed/secret-keystore encrypt \
276
+ --path="./secrets.yaml" \
277
+ --kms-key-id="alias/my-key" \
278
+ --patterns="**.password,**.secret"
279
+
280
+ # Dry run to preview changes
281
+ npx @faizahmed/secret-keystore encrypt \
282
+ --kms-key-id="alias/my-key" \
283
+ --dry-run
284
+
285
+ # Encrypt to a different output file
286
+ npx @faizahmed/secret-keystore encrypt \
287
+ --path="./.env" \
288
+ --output="./.env.encrypted" \
289
+ --kms-key-id="alias/my-key"
290
+
291
+ # Decrypt all encrypted values in a file (in place)
292
+ npx @faizahmed/secret-keystore decrypt \
293
+ --path="./.env" \
294
+ --kms-key-id="alias/my-key"
295
+
296
+ # Decrypt to a separate output file
297
+ npx @faizahmed/secret-keystore decrypt \
298
+ --path="./.env.encrypted" \
299
+ --output="./.env" \
300
+ --kms-key-id="alias/my-key"
301
+
302
+ # Run your app with decrypted secrets injected into its environment
303
+ npx @faizahmed/secret-keystore run \
304
+ --kms-key-id="alias/my-key" -- node server.js
305
+
306
+ # Rotate a file from an old key to a new key
307
+ npx @faizahmed/secret-keystore rotate \
308
+ --old-kms-key-id="alias/old-key" \
309
+ --kms-key-id="alias/new-key"
310
+
311
+ # Edit an encrypted file in $EDITOR (re-encrypts on save)
312
+ npx @faizahmed/secret-keystore edit \
313
+ --kms-key-id="alias/my-key" --path="./.env"
314
+
315
+ # Inspect a file without revealing values
316
+ npx @faizahmed/secret-keystore status --path="./.env"
317
+ `);
318
+ }
319
+
320
+ // ═══════════════════════════════════════════════════════════════════════════
321
+ // FORMAT DETECTION
322
+ // ═══════════════════════════════════════════════════════════════════════════
323
+
324
+ function detectFormat(filePath) {
325
+ const ext = path.extname(filePath).toLowerCase();
326
+
327
+ switch (ext) {
328
+ case '.json':
329
+ return 'json';
330
+ case '.yaml':
331
+ case '.yml':
332
+ return 'yaml';
333
+ case '.env':
334
+ default:
335
+ return 'env';
336
+ }
337
+ }
338
+
339
+ // ═══════════════════════════════════════════════════════════════════════════
340
+ // ENCRYPT COMMAND - HELPER FUNCTIONS
341
+ // ═══════════════════════════════════════════════════════════════════════════
342
+
343
+ /**
344
+ * Validate required KMS key ID argument
345
+ */
346
+ function validateRequiredKmsKeyId(kmsKeyId) {
347
+ if (!kmsKeyId) {
348
+ console.error('❌ Error: --kms-key-id is REQUIRED');
349
+ console.error(' Example: --kms-key-id="arn:aws:kms:us-east-1:123456789:key/abc-123"');
350
+ console.error(' Example: --kms-key-id="alias/my-key"');
351
+ process.exit(1);
352
+ }
353
+
354
+ try {
355
+ validateKmsKeyId(kmsKeyId);
356
+ } catch (error) {
357
+ console.error(`❌ Error: Invalid KMS Key ID - ${error.message}`);
358
+ process.exit(1);
359
+ }
360
+ }
361
+
362
+ /**
363
+ * Validate and resolve file path
364
+ */
365
+ function resolveAndValidatePath(inputPath) {
366
+ const resolvedPath = path.resolve(process.cwd(), inputPath);
367
+
368
+ if (!fs.existsSync(resolvedPath)) {
369
+ console.error(`❌ Error: File not found: ${resolvedPath}`);
370
+ process.exit(1);
371
+ }
372
+
373
+ return resolvedPath;
374
+ }
375
+
376
+ /**
377
+ * Build AWS credentials from environment variables
378
+ */
379
+ function buildAwsCredentials() {
380
+ const accessKeyId = process.env.AWS_ACCESS_KEY_ID;
381
+ const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;
382
+ const sessionToken = process.env.AWS_SESSION_TOKEN;
383
+
384
+ if (!accessKeyId || !secretAccessKey) {
385
+ console.error(
386
+ '❌ Error: --use-credentials requires AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY'
387
+ );
388
+ process.exit(1);
389
+ }
390
+
391
+ const credentials = { accessKeyId, secretAccessKey };
392
+ if (sessionToken) {
393
+ credentials.sessionToken = sessionToken;
394
+ }
395
+
396
+ return credentials;
397
+ }
398
+
399
+ /**
400
+ * Match a key against a pattern
401
+ */
402
+ function matchesPattern(key, pattern) {
403
+ if (pattern.startsWith('**.')) {
404
+ const suffix = pattern.slice(3);
405
+ return key.endsWith(suffix) || key === suffix;
406
+ }
407
+ return key === pattern;
408
+ }
409
+
410
+ /**
411
+ * Filter keys for dry run preview
412
+ */
413
+ function filterKeysForDryRun(allKeys, args) {
414
+ let keysToEncrypt = args.keys || allKeys;
415
+
416
+ if (args.patterns) {
417
+ keysToEncrypt = allKeys.filter(k => args.patterns.some(p => matchesPattern(k, p)));
418
+ }
419
+
420
+ if (args.exclude) {
421
+ keysToEncrypt = keysToEncrypt.filter(k => !args.exclude.includes(k));
422
+ }
423
+
424
+ return keysToEncrypt;
425
+ }
426
+
427
+ /**
428
+ * Run dry run mode - show what would be encrypted
429
+ */
430
+ function runDryRun(content, format, args) {
431
+ console.log('Keys that would be encrypted:');
432
+
433
+ if (format === 'env') {
434
+ const parsed = parseEnvContent(content);
435
+ const allKeys = parsed.filter(e => e.type === 'keyvalue').map(e => e.key);
436
+ const keysToEncrypt = filterKeysForDryRun(allKeys, args);
437
+
438
+ keysToEncrypt.forEach(k => console.log(` • ${k}`));
439
+ console.log(`\nTotal: ${keysToEncrypt.length} keys`);
440
+ } else {
441
+ console.log(' (pattern matching preview for JSON/YAML not implemented in dry-run)');
442
+ }
443
+
444
+ console.log('\n✨ Dry run complete. No changes made.\n');
445
+ }
446
+
447
+ /**
448
+ * Encrypt content based on format
449
+ */
450
+ async function encryptByFormat(content, format, kmsKeyId, options) {
451
+ const encryptors = {
452
+ json: encryptKMSJsonContent,
453
+ yaml: encryptKMSYamlContent,
454
+ env: encryptKMSEnvContent
455
+ };
456
+
457
+ const encryptor = encryptors[format] || encryptKMSEnvContent;
458
+ return encryptor(content, kmsKeyId, options);
459
+ }
460
+
461
+ /**
462
+ * Print operation summary (encrypt or decrypt)
463
+ */
464
+ function printSummary(result, verb = 'Encrypted') {
465
+ const processed = (result.encrypted || result.decrypted || []).length;
466
+ console.log('\n📊 Summary:');
467
+ console.log(` ✅ ${verb}: ${processed}`);
468
+ console.log(` ⏭️ Skipped: ${result.skipped.length}`);
469
+ console.log(` ❌ Failed: ${result.failed.length}`);
470
+
471
+ if (result.failed.length > 0) {
472
+ console.log('\n⚠️ Failed keys:');
473
+ result.failed.forEach(f => console.log(` • ${f.key || f.path}: ${f.error.message}`));
474
+ process.exit(1);
475
+ }
476
+
477
+ console.log('\n✨ Done!\n');
478
+ }
479
+
480
+ // ═══════════════════════════════════════════════════════════════════════════
481
+ // ENCRYPT COMMAND
482
+ // ═══════════════════════════════════════════════════════════════════════════
483
+
484
+ async function runEncrypt(args) {
485
+ console.log('\n🔐 @faizahmed/secret-keystore - Encrypt\n');
486
+
487
+ validateRequiredKmsKeyId(args.kmsKeyId);
488
+
489
+ const resolvedPath = resolveAndValidatePath(args.path);
490
+ const format = args.format || detectFormat(resolvedPath);
491
+ const content = fs.readFileSync(resolvedPath, 'utf-8');
492
+
493
+ console.log(`📂 File: ${resolvedPath}`);
494
+ console.log(`📄 Format: ${format}`);
495
+ console.log(`🔑 KMS Key: ${maskKmsKeyId(args.kmsKeyId)}`);
496
+ console.log(args.dryRun ? '🔍 Mode: DRY RUN (no changes will be made)\n' : '');
497
+
498
+ // Build credentials
499
+ const credentials = args.useCredentials ? buildAwsCredentials() : null;
500
+ console.log(
501
+ args.useCredentials
502
+ ? '🔑 Using explicit AWS credentials\n'
503
+ : '🔑 Using IAM role (default)\n'
504
+ );
505
+
506
+ const options = {
507
+ aws: {
508
+ credentials,
509
+ region: args.region || process.env.AWS_REGION
510
+ },
511
+ paths: args.keys,
512
+ patterns: args.patterns,
513
+ exclude: args.exclude ? { paths: args.exclude } : undefined,
514
+ logLevel: 'info'
515
+ };
516
+
517
+ if (args.dryRun) {
518
+ runDryRun(content, format, args);
519
+ return;
520
+ }
521
+
522
+ let result;
523
+ try {
524
+ result = await encryptByFormat(content, format, args.kmsKeyId, options);
525
+ } catch (error) {
526
+ console.error(`\n❌ Error: ${error.message}`);
527
+ if (error.cause) {
528
+ console.error(` Cause: ${error.cause.message}`);
529
+ }
530
+ process.exit(1);
531
+ }
532
+
533
+ const outputPath = args.output ? path.resolve(process.cwd(), args.output) : resolvedPath;
534
+
535
+ if (result.encrypted.length > 0) {
536
+ fs.writeFileSync(outputPath, result.content, 'utf-8');
537
+ console.log(`\n💾 Written to: ${outputPath}`);
538
+ }
539
+
540
+ printSummary(result);
541
+ }
542
+
543
+ // ═══════════════════════════════════════════════════════════════════════════
544
+ // DECRYPT COMMAND
545
+ // ═══════════════════════════════════════════════════════════════════════════
546
+
547
+ /**
548
+ * Decrypt content based on format
549
+ */
550
+ async function decryptByFormat(content, format, kmsKeyId, options) {
551
+ const decryptors = {
552
+ json: decryptKMSJsonContent,
553
+ yaml: decryptKMSYamlContent,
554
+ env: decryptKMSEnvContent
555
+ };
556
+
557
+ const decryptor = decryptors[format] || decryptKMSEnvContent;
558
+ return decryptor(content, kmsKeyId, options);
559
+ }
560
+
561
+ async function runDecrypt(args) {
562
+ console.log('\n🔓 @faizahmed/secret-keystore - Decrypt\n');
563
+
564
+ validateRequiredKmsKeyId(args.kmsKeyId);
565
+
566
+ const resolvedPath = resolveAndValidatePath(args.path);
567
+ const format = args.format || detectFormat(resolvedPath);
568
+ const content = fs.readFileSync(resolvedPath, 'utf-8');
569
+
570
+ console.log(`📂 File: ${resolvedPath}`);
571
+ console.log(`📄 Format: ${format}`);
572
+ console.log(`🔑 KMS Key: ${maskKmsKeyId(args.kmsKeyId)}`);
573
+
574
+ // Build credentials
575
+ const credentials = args.useCredentials ? buildAwsCredentials() : null;
576
+ console.log(
577
+ args.useCredentials
578
+ ? '🔑 Using explicit AWS credentials\n'
579
+ : '🔑 Using IAM role (default)\n'
580
+ );
581
+
582
+ const options = {
583
+ aws: {
584
+ credentials,
585
+ region: args.region || process.env.AWS_REGION
586
+ },
587
+ paths: args.keys,
588
+ patterns: args.patterns,
589
+ exclude: args.exclude ? { paths: args.exclude } : undefined,
590
+ logLevel: 'info'
591
+ };
592
+
593
+ let result;
594
+ try {
595
+ result = await decryptByFormat(content, format, args.kmsKeyId, options);
596
+ } catch (error) {
597
+ console.error(`\n❌ Error: ${error.message}`);
598
+ if (error.cause) {
599
+ console.error(` Cause: ${error.cause.message}`);
600
+ }
601
+ process.exit(1);
602
+ }
603
+
604
+ const outputPath = args.output ? path.resolve(process.cwd(), args.output) : resolvedPath;
605
+
606
+ if (result.decrypted.length > 0) {
607
+ fs.writeFileSync(outputPath, result.content, 'utf-8');
608
+ console.log(`\n💾 Written to: ${outputPath}`);
609
+ }
610
+
611
+ printSummary(result, 'Decrypted');
612
+ }
613
+
614
+ // ═══════════════════════════════════════════════════════════════════════════
615
+ // SHARED OPTIONS
616
+ // ═══════════════════════════════════════════════════════════════════════════
617
+
618
+ function buildCliOptions(args) {
619
+ const credentials = args.useCredentials ? buildAwsCredentials() : null;
620
+ return {
621
+ aws: { credentials, region: args.region || process.env.AWS_REGION },
622
+ paths: args.keys,
623
+ patterns: args.patterns,
624
+ exclude: args.exclude ? { paths: args.exclude } : undefined,
625
+ logLevel: 'info'
626
+ };
627
+ }
628
+
629
+ /** List keys/paths in a config file (names only — never returns values to callers that print). */
630
+ function listFileEntries(content, format) {
631
+ if (format === 'env') {
632
+ return parseEnvContent(content)
633
+ .filter(e => e.type === 'keyvalue')
634
+ .map(e => ({ name: e.key, value: e.value }));
635
+ }
636
+ const obj = format === 'json' ? JSON.parse(content) : parseYaml(content);
637
+ return getAllPaths(obj).map(p => ({ name: p, value: getByPath(obj, p) }));
638
+ }
639
+
640
+ /** Keys/paths whose values are currently encrypted. */
641
+ function encryptedSelection(content, format) {
642
+ return listFileEntries(content, format)
643
+ .filter(e => typeof e.value === 'string' && isAlreadyEncrypted(e.value))
644
+ .map(e => e.name);
645
+ }
646
+
647
+ // ═══════════════════════════════════════════════════════════════════════════
648
+ // RUN COMMAND
649
+ // ═══════════════════════════════════════════════════════════════════════════
650
+
651
+ async function runRun(args) {
652
+ validateRequiredKmsKeyId(args.kmsKeyId);
653
+
654
+ if (!args.exec || args.exec.length === 0) {
655
+ console.error('❌ Error: `run` requires a command after `--`.');
656
+ console.error(' Example: secret-keystore run --kms-key-id="alias/k" -- node server.js');
657
+ process.exit(1);
658
+ }
659
+
660
+ const credentials = args.useCredentials ? buildAwsCredentials() : null;
661
+ const explicitPath = args.path && args.path !== './.env' ? args.path : undefined;
662
+
663
+ let store;
664
+ try {
665
+ store = await config({
666
+ kmsKeyId: args.kmsKeyId,
667
+ cwd: process.cwd(),
668
+ path: explicitPath,
669
+ aws: { credentials, region: args.region || process.env.AWS_REGION }
670
+ });
671
+ } catch (error) {
672
+ console.error(`\n❌ Error: ${error.message}`);
673
+ process.exit(1);
674
+ }
675
+
676
+ // Secrets are injected into the CHILD's environment only. The parent never
677
+ // places them in its own process.env.
678
+ const childEnv = { ...process.env, ...store.getAll() };
679
+ const [command, ...commandArgs] = args.exec;
680
+
681
+ const child = spawn(command, commandArgs, { stdio: 'inherit', env: childEnv });
682
+
683
+ child.on('error', error => {
684
+ store.destroy();
685
+ console.error(`\n❌ Failed to start "${command}": ${error.message}`);
686
+ process.exit(1);
687
+ });
688
+
689
+ child.on('exit', (code, signal) => {
690
+ store.destroy();
691
+ if (signal) {
692
+ process.kill(process.pid, signal);
693
+ return;
694
+ }
695
+ process.exit(code ?? 0);
696
+ });
697
+ }
698
+
699
+ // ═══════════════════════════════════════════════════════════════════════════
700
+ // ROTATE COMMAND
701
+ // ═══════════════════════════════════════════════════════════════════════════
702
+
703
+ async function runRotate(args) {
704
+ console.log('\n🔄 @faizahmed/secret-keystore - Rotate\n');
705
+
706
+ validateRequiredKmsKeyId(args.kmsKeyId);
707
+
708
+ if (!args.oldKmsKeyId) {
709
+ console.error('❌ Error: `rotate` requires --old-kms-key-id (the current key).');
710
+ process.exit(1);
711
+ }
712
+ try {
713
+ validateKmsKeyId(args.oldKmsKeyId);
714
+ } catch (error) {
715
+ console.error(`❌ Error: Invalid --old-kms-key-id - ${error.message}`);
716
+ process.exit(1);
717
+ }
718
+
719
+ const resolvedPath = resolveAndValidatePath(args.path);
720
+ const format = args.format || detectFormat(resolvedPath);
721
+ const content = fs.readFileSync(resolvedPath, 'utf-8');
722
+
723
+ console.log(`📂 File: ${resolvedPath}`);
724
+ console.log(`🔑 Old Key: ${maskKmsKeyId(args.oldKmsKeyId)}`);
725
+ console.log(`🔑 New Key: ${maskKmsKeyId(args.kmsKeyId)}\n`);
726
+
727
+ let result;
728
+ try {
729
+ result = await rotateKMSContent(
730
+ content,
731
+ format,
732
+ args.oldKmsKeyId,
733
+ args.kmsKeyId,
734
+ buildCliOptions(args)
735
+ );
736
+ } catch (error) {
737
+ console.error(`\n❌ Error: ${error.message}`);
738
+ if (error.cause) console.error(` Cause: ${error.cause.message}`);
739
+ process.exit(1);
740
+ }
741
+
742
+ const outputPath = args.output ? path.resolve(process.cwd(), args.output) : resolvedPath;
743
+ if (result.rotated.length > 0) {
744
+ fs.writeFileSync(outputPath, result.content, 'utf-8');
745
+ console.log(`💾 Written to: ${outputPath}`);
746
+ }
747
+
748
+ console.log(`\n📊 Rotated ${result.rotated.length} value(s).`);
749
+ console.log('\n✨ Done!\n');
750
+ }
751
+
752
+ // ═══════════════════════════════════════════════════════════════════════════
753
+ // EDIT COMMAND
754
+ // ═══════════════════════════════════════════════════════════════════════════
755
+
756
+ function secureDelete(file) {
757
+ try {
758
+ const size = fs.statSync(file).size;
759
+ fs.writeFileSync(file, crypto.randomBytes(size));
760
+ } catch {
761
+ // best-effort overwrite
762
+ }
763
+ try {
764
+ fs.rmSync(file, { force: true });
765
+ } catch {
766
+ // best-effort delete
767
+ }
768
+ }
769
+
770
+ async function runEdit(args) {
771
+ console.log('\n📝 @faizahmed/secret-keystore - Edit\n');
772
+
773
+ validateRequiredKmsKeyId(args.kmsKeyId);
774
+
775
+ const editor = process.env.EDITOR || process.env.VISUAL;
776
+ if (!editor) {
777
+ console.error(
778
+ '❌ Error: no editor found. Set $EDITOR or $VISUAL (e.g. export EDITOR=vim).'
779
+ );
780
+ process.exit(1);
781
+ }
782
+
783
+ const resolvedPath = resolveAndValidatePath(args.path);
784
+ const format = args.format || detectFormat(resolvedPath);
785
+ const content = fs.readFileSync(resolvedPath, 'utf-8');
786
+ const options = buildCliOptions(args);
787
+
788
+ // Remember which keys were encrypted so we can re-encrypt exactly those on save.
789
+ const selection = encryptedSelection(content, format);
790
+
791
+ let decrypted;
792
+ try {
793
+ decrypted = await decryptByFormat(content, format, args.kmsKeyId, options);
794
+ } catch (error) {
795
+ console.error(`\n❌ Error: ${error.message}`);
796
+ process.exit(1);
797
+ }
798
+
799
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sks-edit-'));
800
+ const tmpFile = path.join(tmpDir, path.basename(resolvedPath));
801
+ fs.writeFileSync(tmpFile, decrypted.content, { mode: 0o600 });
802
+
803
+ try {
804
+ const res = spawnSync(editor, [tmpFile], { stdio: 'inherit', shell: true });
805
+ if (res.status !== 0) {
806
+ console.error('\n❌ Editor exited non-zero; aborting (no changes written).');
807
+ process.exit(1);
808
+ }
809
+
810
+ const edited = fs.readFileSync(tmpFile, 'utf-8');
811
+
812
+ if (selection.length === 0) {
813
+ // Nothing was encrypted; write the edited content back as-is.
814
+ fs.writeFileSync(resolvedPath, edited, 'utf-8');
815
+ console.log(`\n💾 Saved: ${resolvedPath} (no encrypted values to re-encrypt)`);
816
+ } else {
817
+ const reencrypted = await encryptByFormat(edited, format, args.kmsKeyId, {
818
+ ...options,
819
+ paths: selection,
820
+ patterns: undefined
821
+ });
822
+ fs.writeFileSync(resolvedPath, reencrypted.content, 'utf-8');
823
+ console.log(`\n💾 Saved & re-encrypted: ${resolvedPath}`);
824
+ console.log(`📊 Re-encrypted ${reencrypted.encrypted.length} value(s).`);
825
+ }
826
+ } finally {
827
+ secureDelete(tmpFile);
828
+ try {
829
+ fs.rmSync(tmpDir, { recursive: true, force: true });
830
+ } catch {
831
+ // best-effort cleanup
832
+ }
833
+ }
834
+
835
+ console.log('\n✨ Done!\n');
836
+ }
837
+
838
+ // ═══════════════════════════════════════════════════════════════════════════
839
+ // INIT COMMAND
840
+ // ═══════════════════════════════════════════════════════════════════════════
841
+
842
+ function runInit(args) {
843
+ console.log('\n🚀 @faizahmed/secret-keystore - Init\n');
844
+
845
+ const target = path.resolve(process.cwd(), args.path || './.env');
846
+ if (fs.existsSync(target)) {
847
+ console.error(`❌ Error: ${target} already exists. Refusing to overwrite.`);
848
+ process.exit(1);
849
+ }
850
+
851
+ const template = [
852
+ '# secret-keystore configuration',
853
+ '# Reserved keys (never encrypted):',
854
+ 'KMS_KEY_ID=alias/your-kms-key',
855
+ 'AWS_REGION=us-east-1',
856
+ '',
857
+ '# Your secrets — encrypt with:',
858
+ '# secret-keystore encrypt --kms-key-id="alias/your-kms-key"',
859
+ 'DB_PASSWORD=change-me',
860
+ 'API_KEY=change-me',
861
+ ''
862
+ ].join('\n');
863
+
864
+ fs.writeFileSync(target, template, 'utf-8');
865
+
866
+ console.log(`✅ Created ${target}\n`);
867
+ console.log('Next steps:');
868
+ console.log(' 1. Set KMS_KEY_ID and your secret values in the file');
869
+ console.log(' 2. Encrypt: secret-keystore encrypt --kms-key-id="alias/your-kms-key"');
870
+ console.log(' 3. At runtime: const s = await config({ kmsKeyId }) — secrets stay in memory\n');
871
+ }
872
+
873
+ // ═══════════════════════════════════════════════════════════════════════════
874
+ // KEYS & STATUS COMMANDS (read-only — never print secret values)
875
+ // ═══════════════════════════════════════════════════════════════════════════
876
+
877
+ function runKeys(args) {
878
+ const resolvedPath = resolveAndValidatePath(args.path);
879
+ const format = args.format || detectFormat(resolvedPath);
880
+ const content = fs.readFileSync(resolvedPath, 'utf-8');
881
+
882
+ for (const entry of listFileEntries(content, format)) {
883
+ console.log(entry.name);
884
+ }
885
+ }
886
+
887
+ function runStatus(args) {
888
+ console.log('\n📋 @faizahmed/secret-keystore - Status\n');
889
+
890
+ const resolvedPath = resolveAndValidatePath(args.path);
891
+ const format = args.format || detectFormat(resolvedPath);
892
+ const content = fs.readFileSync(resolvedPath, 'utf-8');
893
+
894
+ let encrypted = 0;
895
+ let plaintext = 0;
896
+ for (const entry of listFileEntries(content, format)) {
897
+ const isEnc = typeof entry.value === 'string' && isAlreadyEncrypted(entry.value);
898
+ if (isEnc) encrypted += 1;
899
+ else plaintext += 1;
900
+ console.log(` ${isEnc ? '🔒 encrypted' : '🔓 plaintext'} ${entry.name}`);
901
+ }
902
+
903
+ const total = encrypted + plaintext;
904
+ console.log(`\n📊 ${encrypted} encrypted, ${plaintext} plaintext, ${total} total\n`);
905
+ }
906
+
907
+ // ═══════════════════════════════════════════════════════════════════════════
908
+ // IMPORT COMMAND (encrypt an existing plaintext .env in place)
909
+ // ═══════════════════════════════════════════════════════════════════════════
910
+
911
+ async function runImport(args) {
912
+ console.log('\n📥 @faizahmed/secret-keystore - Import (migrate plaintext → encrypted)\n');
913
+ // Encrypt all non-reserved keys in place.
914
+ await runEncrypt({ ...args, keys: null, patterns: null });
915
+ }
916
+
917
+ // ═══════════════════════════════════════════════════════════════════════════
918
+ // MAIN
919
+ // ═══════════════════════════════════════════════════════════════════════════
920
+
921
+ async function main() {
922
+ const args = parseArgs(process.argv.slice(2));
923
+
924
+ // Show version
925
+ if (args.version) {
926
+ printVersion();
927
+ process.exit(0);
928
+ }
929
+
930
+ // Show help
931
+ if (args.help || process.argv.length <= 2) {
932
+ printHelp();
933
+ process.exit(0);
934
+ }
935
+
936
+ // Validate and dispatch command
937
+ const commands = {
938
+ encrypt: runEncrypt,
939
+ decrypt: runDecrypt,
940
+ run: runRun,
941
+ rotate: runRotate,
942
+ edit: runEdit,
943
+ init: runInit,
944
+ keys: runKeys,
945
+ status: runStatus,
946
+ import: runImport
947
+ };
948
+
949
+ const handler = commands[args.command];
950
+ if (!handler) {
951
+ console.error(
952
+ 'Error: Unknown command. Use one of: ' + Object.keys(commands).join(', ') + '.'
953
+ );
954
+ console.error('Run with --help for usage information.');
955
+ process.exit(1);
956
+ }
957
+
958
+ await handler(args);
959
+ }
960
+
961
+ // Top-level await with IIFE for error handling
962
+ (async () => {
963
+ try {
964
+ await main();
965
+ } catch (error) {
966
+ console.error(`\n❌ Unexpected error: ${error.message}\n`);
967
+ process.exit(1);
968
+ }
969
+ })();