@benzid.wael/secure-vault 0.0.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.
@@ -0,0 +1,807 @@
1
+ import chalk from 'chalk';
2
+ import ora from 'ora';
3
+ import { spawn } from 'child_process';
4
+ import os from 'os';
5
+ import path from 'path';
6
+ import fs from 'fs-extra';
7
+ import password from '@inquirer/password';
8
+
9
+ import { EnvironmentVaultService } from '../../src/electron/services/EnvironmentVaultService.js';
10
+ import { EnvironmentVault } from '../../src/electron/models/EnvironmentVault.js';
11
+
12
+ function clipboardWrite(text) {
13
+ const platform = process.platform;
14
+ const cmd =
15
+ platform === 'darwin' ? 'pbcopy' : platform === 'win32' ? 'clip' : 'xclip';
16
+ const args = platform === 'linux' ? ['-selection', 'clipboard'] : [];
17
+
18
+ return new Promise((resolve, reject) => {
19
+ const proc = spawn(cmd, args, { stdio: 'pipe' });
20
+ proc.on('error', () => reject(new Error('Clipboard not available')));
21
+ proc.on('close', resolve);
22
+ proc.stdin.end(text);
23
+ });
24
+ }
25
+
26
+ async function copyToClipboard(text, label) {
27
+ try {
28
+ await clipboardWrite(text);
29
+ console.log(chalk.gray(` Copied ${label} to clipboard`));
30
+ } catch {
31
+ console.log(chalk.yellow(' Clipboard not available on this system'));
32
+ }
33
+ }
34
+
35
+ function parseEnvOption(val) {
36
+ const entries = {};
37
+ for (const pair of val.split(',')) {
38
+ const sep = pair.includes('=') ? '=' : ':';
39
+ const idx = pair.indexOf(sep);
40
+ if (idx === -1) {
41
+ throw new Error(
42
+ `Invalid --env format: "${pair}". Expected envName=filePath`
43
+ );
44
+ }
45
+ const envName = pair.slice(0, idx);
46
+ let filePath = pair.slice(idx + 1).trim();
47
+ if (filePath.startsWith('~')) {
48
+ filePath = os.homedir() + filePath.slice(1);
49
+ }
50
+ if (!envName || !filePath) {
51
+ throw new Error(
52
+ `Invalid --env format: "${pair}". Expected envName=filePath`
53
+ );
54
+ }
55
+ entries[envName.trim()] = filePath;
56
+ }
57
+ return entries;
58
+ }
59
+
60
+ async function getPassword(provided, promptMessage) {
61
+ if (provided) return provided;
62
+ if (process.env.VAULT_ENV_PASSWORD) return process.env.VAULT_ENV_PASSWORD;
63
+ return password({ message: promptMessage, mask: true });
64
+ }
65
+
66
+ async function resolveVaultPath(options) {
67
+ const vaultPath = EnvironmentVaultService.resolveVaultPath({
68
+ vault: options.vault,
69
+ name: options.name,
70
+ });
71
+
72
+ if (!vaultPath) {
73
+ const cwdName = process
74
+ .cwd()
75
+ .split(/[/\\]/)
76
+ .pop()
77
+ .replace(/[^a-z0-9_-]/gi, '_')
78
+ .toLowerCase();
79
+ console.log(chalk.red('No vault found.'));
80
+ console.log(
81
+ chalk.yellow(` Create one: vault env init --name ${cwdName}`)
82
+ );
83
+ console.log(chalk.yellow(` Specify: --vault <path> or --name <name>`));
84
+ process.exit(1);
85
+ }
86
+
87
+ return vaultPath;
88
+ }
89
+
90
+ async function loadVault(options) {
91
+ const vaultPassword = await getPassword(
92
+ options.password,
93
+ 'Enter vault password:'
94
+ );
95
+ const vaultPath = await resolveVaultPath(options);
96
+ const result = await EnvironmentVaultService.loadVault(
97
+ vaultPath,
98
+ vaultPassword
99
+ );
100
+
101
+ if (!result.success) {
102
+ console.log(chalk.red(result.error));
103
+ process.exit(1);
104
+ }
105
+
106
+ return { vaultPath, vaultPassword, vault: result.data };
107
+ }
108
+
109
+ export function registerEnvCommand(program) {
110
+ const env = program.command('env').description('Manage environment vaults');
111
+
112
+ env
113
+ .command('init')
114
+ .description('Initialize a new environment vault')
115
+ .option('-n, --name <name>', 'Vault name (defaults to CWD directory name)')
116
+ .option('-v, --vault <path>', 'Exact path for the vault file')
117
+ .option('--password <password>', 'Vault password (non-interactive)')
118
+ .option(
119
+ '-e, --env <pairs>',
120
+ 'Import .env files: envName=path (or envName:path)',
121
+ (val, prev = {}) => ({ ...prev, ...parseEnvOption(val) }),
122
+ {}
123
+ )
124
+ .action(async (options) => {
125
+ try {
126
+ const vaultPassword = await getPassword(
127
+ options.password,
128
+ 'Enter vault password:'
129
+ );
130
+
131
+ if (!options.password && !process.env.VAULT_ENV_PASSWORD) {
132
+ const confirm = await password({
133
+ message: 'Confirm vault password:',
134
+ mask: true,
135
+ });
136
+ if (vaultPassword !== confirm) {
137
+ console.log(chalk.red('Passwords do not match.'));
138
+ process.exit(1);
139
+ }
140
+ }
141
+
142
+ const vaultPath = options.vault
143
+ ? path.resolve(options.vault)
144
+ : options.name
145
+ ? EnvironmentVaultService.getEnvVaultPath(options.name)
146
+ : EnvironmentVaultService.defaultVaultPath();
147
+
148
+ const vaultModel = new EnvironmentVault();
149
+ const envs = options.env || {};
150
+
151
+ for (const [envName, envFile] of Object.entries(envs)) {
152
+ const step = ora(
153
+ `Reading ${chalk.cyan(envName)} from ${chalk.gray(envFile)}`
154
+ ).start();
155
+ let content;
156
+ try {
157
+ content = await fs.readFile(envFile, 'utf-8');
158
+ } catch (err) {
159
+ step.fail(chalk.red(`Cannot read ${envFile}: ${err.message}`));
160
+ process.exit(1);
161
+ }
162
+ const parsed = EnvironmentVault.parseEnvFile(content);
163
+ const count = Object.keys(parsed).length;
164
+ vaultModel.importFromEnvFile(envName, content, {
165
+ message: 'Initial import',
166
+ });
167
+ step.succeed(`Imported ${chalk.bold(envName)} (${count} variables)`);
168
+ }
169
+
170
+ const step = ora('Encrypting vault…').start();
171
+ const result = await EnvironmentVaultService.createVault(
172
+ vaultPath,
173
+ vaultPassword,
174
+ vaultModel.toJSON()
175
+ );
176
+
177
+ if (!result.success) {
178
+ step.fail(chalk.red(result.error));
179
+ process.exit(1);
180
+ }
181
+
182
+ step.succeed(
183
+ chalk.green(`Environment vault created at ${result.path}`)
184
+ );
185
+ } catch (error) {
186
+ console.error(chalk.red(error.message));
187
+ process.exit(1);
188
+ }
189
+ });
190
+
191
+ env
192
+ .command('import')
193
+ .description('Import .env files into an existing vault')
194
+ .argument('<envName>', 'Environment name (e.g. staging, production)')
195
+ .argument('[files...]', 'One or more .env files to import')
196
+ .option('-n, --name <name>', 'Vault name')
197
+ .option('-v, --vault <path>', 'Exact vault file path')
198
+ .option('--password <password>', 'Vault password (non-interactive)')
199
+ .action(async (envName, files, options) => {
200
+ const spinner = ora('Importing .env files...').start();
201
+
202
+ try {
203
+ const vaultPassword = await getPassword(
204
+ options.password,
205
+ 'Enter vault password:'
206
+ );
207
+ const vaultPath = await resolveVaultPath(options);
208
+
209
+ if (!files || files.length === 0) {
210
+ spinner.fail(chalk.red('No .env files specified.'));
211
+ process.exit(1);
212
+ }
213
+
214
+ for (const file of files) {
215
+ spinner.text = `Importing ${file} as "${envName}"...`;
216
+
217
+ const result = await EnvironmentVaultService.importEnvFile(
218
+ vaultPath,
219
+ vaultPassword,
220
+ envName,
221
+ file
222
+ );
223
+
224
+ if (!result.success) {
225
+ spinner.fail(chalk.red(`${file}: ${result.error}`));
226
+ process.exit(1);
227
+ }
228
+ }
229
+
230
+ spinner.succeed(
231
+ chalk.green(`Imported ${files.length} file(s) into "${envName}".`)
232
+ );
233
+ } catch (error) {
234
+ spinner.fail(chalk.red(error.message));
235
+ process.exit(1);
236
+ }
237
+ });
238
+
239
+ env
240
+ .command('set')
241
+ .description('Set an environment variable')
242
+ .argument('<key>', 'Variable name')
243
+ .argument('<value>', 'Variable value')
244
+ .option('-n, --name <name>', 'Vault name')
245
+ .option('-v, --vault <path>', 'Exact vault file path')
246
+ .option('--password <password>', 'Vault password (non-interactive)')
247
+ .option('-e, --env <name>', 'Environment name (defaults to "default")')
248
+ .option('--public', 'Mark variable as non-sensitive')
249
+ .option('-m, --message <text>', 'Version message')
250
+ .action(async (key, value, options) => {
251
+ try {
252
+ const { vaultPath, vaultPassword } = await loadVault(options);
253
+ const envName = options.env || 'default';
254
+
255
+ const spinner = ora(`Setting ${key}...`).start();
256
+
257
+ const result = await EnvironmentVaultService.setEnv(
258
+ vaultPath,
259
+ vaultPassword,
260
+ envName,
261
+ key,
262
+ value,
263
+ { isPublic: !!options.public, message: options.message }
264
+ );
265
+
266
+ if (!result.success) {
267
+ spinner.fail(chalk.red(result.error));
268
+ process.exit(1);
269
+ }
270
+
271
+ spinner.succeed(chalk.green(`Set ${key} in "${envName}".`));
272
+ } catch (error) {
273
+ console.log(chalk.red(error.message));
274
+ process.exit(1);
275
+ }
276
+ });
277
+
278
+ env
279
+ .command('get')
280
+ .description('Get an environment variable')
281
+ .argument('<key>', 'Variable name')
282
+ .option('-n, --name <name>', 'Vault name')
283
+ .option('-v, --vault <path>', 'Exact vault file path')
284
+ .option('--password <password>', 'Vault password (non-interactive)')
285
+ .option('-e, --env <name>', 'Environment name (defaults to "default")')
286
+ .option('--pair', 'Output as KEY=VALUE')
287
+ .option('--clip', 'Copy value to clipboard')
288
+ .action(async (key, options) => {
289
+ try {
290
+ const { vaultPath, vaultPassword } = await loadVault(options);
291
+ const envName = options.env || 'default';
292
+
293
+ const result = await EnvironmentVaultService.getEnv(
294
+ vaultPath,
295
+ vaultPassword,
296
+ envName,
297
+ key
298
+ );
299
+
300
+ if (!result.success) {
301
+ console.log(chalk.red(result.error));
302
+ process.exit(1);
303
+ }
304
+
305
+ if (options.pair) {
306
+ const pair = `${key}=${result.data.value}`;
307
+ console.log(pair);
308
+ if (options.clip) await copyToClipboard(pair, 'pair');
309
+ } else if (options.clip) {
310
+ await copyToClipboard(result.data.value, 'value');
311
+ } else {
312
+ console.log(result.data.value);
313
+ }
314
+ } catch (error) {
315
+ console.log(chalk.red(error.message));
316
+ process.exit(1);
317
+ }
318
+ });
319
+
320
+ env
321
+ .command('show')
322
+ .description('Show environment details and all variables')
323
+ .argument('[envName]', 'Environment name (defaults to "default")')
324
+ .option('-n, --name <name>', 'Vault name')
325
+ .option('-v, --vault <path>', 'Exact vault file path')
326
+ .option('--password <password>', 'Vault password (non-interactive)')
327
+ .action(async (envName, options) => {
328
+ try {
329
+ const { vaultPath, vaultPassword } = await loadVault(options);
330
+ const name = envName || 'default';
331
+
332
+ const result = await EnvironmentVaultService.showEnv(
333
+ vaultPath,
334
+ vaultPassword,
335
+ name
336
+ );
337
+
338
+ if (!result.success) {
339
+ console.log(chalk.red(result.error));
340
+ process.exit(1);
341
+ }
342
+
343
+ const { data } = result;
344
+ console.log(chalk.bold(`\nEnvironment: ${data.name}`));
345
+ console.log(
346
+ chalk.gray(
347
+ ` Active version: v${data.activeVersion} of ${data.totalVersions}`
348
+ )
349
+ );
350
+ console.log(chalk.gray(` Variables: ${data.keyCount}\n`));
351
+
352
+ for (const k of data.keys) {
353
+ const icon = k.sensitive ? chalk.red('🔒') : chalk.green('🔓');
354
+ const display = k.sensitive ? chalk.gray('***') : k.value;
355
+ console.log(` ${icon} ${chalk.cyan(k.key)} = ${display}`);
356
+ }
357
+
358
+ console.log();
359
+ } catch (error) {
360
+ console.log(chalk.red(error.message));
361
+ process.exit(1);
362
+ }
363
+ });
364
+
365
+ env
366
+ .command('list')
367
+ .description('List all environments in the vault')
368
+ .option('-n, --name <name>', 'Vault name')
369
+ .option('-v, --vault <path>', 'Exact vault file path')
370
+ .option('--password <password>', 'Vault password (non-interactive)')
371
+ .action(async (options) => {
372
+ try {
373
+ const { vaultPath, vaultPassword } = await loadVault(options);
374
+
375
+ const result = await EnvironmentVaultService.listEnvs(
376
+ vaultPath,
377
+ vaultPassword
378
+ );
379
+
380
+ if (!result.success) {
381
+ console.log(chalk.red(result.error));
382
+ process.exit(1);
383
+ }
384
+
385
+ if (result.data.length === 0) {
386
+ console.log(chalk.yellow('\n No environments in this vault.\n'));
387
+ return;
388
+ }
389
+
390
+ console.log(chalk.bold(`\nEnvironments (${result.data.length}):\n`));
391
+ for (const env of result.data) {
392
+ console.log(
393
+ ` ${chalk.cyan(env.name)}` +
394
+ ` (v${env.activeVersion}, ${env.keyCount} keys, ${env.versionCount} versions)`
395
+ );
396
+ }
397
+ console.log();
398
+ } catch (error) {
399
+ console.log(chalk.red(error.message));
400
+ process.exit(1);
401
+ }
402
+ });
403
+
404
+ env
405
+ .command('rm')
406
+ .description('Remove a variable from an environment')
407
+ .argument('<key>', 'Variable name to remove')
408
+ .option('-n, --name <name>', 'Vault name')
409
+ .option('-v, --vault <path>', 'Exact vault file path')
410
+ .option('--password <password>', 'Vault password (non-interactive)')
411
+ .option('-e, --env <name>', 'Environment name (defaults to "default")')
412
+ .action(async (key, options) => {
413
+ const spinner = ora(`Removing ${key}...`).start();
414
+
415
+ try {
416
+ const { vaultPath, vaultPassword } = await loadVault(options);
417
+ const envName = options.env || 'default';
418
+
419
+ const result = await EnvironmentVaultService.removeKey(
420
+ vaultPath,
421
+ vaultPassword,
422
+ envName,
423
+ key
424
+ );
425
+
426
+ if (!result.success) {
427
+ spinner.fail(chalk.red(result.error));
428
+ process.exit(1);
429
+ }
430
+
431
+ spinner.succeed(chalk.green(`Removed ${key} from "${envName}".`));
432
+ } catch (error) {
433
+ spinner.fail(chalk.red(error.message));
434
+ process.exit(1);
435
+ }
436
+ });
437
+
438
+ env
439
+ .command('export')
440
+ .description('Export an environment as dotenv or JSON')
441
+ .argument('[envName]', 'Environment name (defaults to "default")')
442
+ .option('-n, --name <name>', 'Vault name')
443
+ .option('-v, --vault <path>', 'Exact vault file path')
444
+ .option('--password <password>', 'Vault password (non-interactive)')
445
+ .option(
446
+ '-f, --format <format>',
447
+ 'Output format: dotenv (default) or json',
448
+ 'dotenv'
449
+ )
450
+ .option('--clip', 'Copy output to clipboard')
451
+ .action(async (envName, options) => {
452
+ try {
453
+ const { vaultPath, vaultPassword } = await loadVault(options);
454
+ const name = envName || 'default';
455
+
456
+ const result = await EnvironmentVaultService.exportEnv(
457
+ vaultPath,
458
+ vaultPassword,
459
+ name,
460
+ options.format
461
+ );
462
+
463
+ if (!result.success) {
464
+ console.log(chalk.red(result.error));
465
+ process.exit(1);
466
+ }
467
+
468
+ if (options.clip) {
469
+ const text =
470
+ typeof result.data === 'string'
471
+ ? result.data
472
+ : JSON.stringify(result.data, null, 2);
473
+ await copyToClipboard(text, 'export');
474
+ console.log(text);
475
+ } else {
476
+ console.log(result.data);
477
+ }
478
+ } catch (error) {
479
+ console.log(chalk.red(error.message));
480
+ process.exit(1);
481
+ }
482
+ });
483
+
484
+ env
485
+ .command('delete')
486
+ .description('Delete an entire environment')
487
+ .argument('<envName>', 'Environment name to delete')
488
+ .option('-n, --name <name>', 'Vault name')
489
+ .option('-v, --vault <path>', 'Exact vault file path')
490
+ .option('--password <password>', 'Vault password (non-interactive)')
491
+ .action(async (envName, options) => {
492
+ const spinner = ora(`Deleting environment "${envName}"...`).start();
493
+
494
+ try {
495
+ const { vaultPath, vaultPassword } = await loadVault(options);
496
+
497
+ const result = await EnvironmentVaultService.deleteEnv(
498
+ vaultPath,
499
+ vaultPassword,
500
+ envName
501
+ );
502
+
503
+ if (!result.success) {
504
+ spinner.fail(chalk.red(result.error));
505
+ process.exit(1);
506
+ }
507
+
508
+ spinner.succeed(chalk.green(`Deleted environment "${envName}".`));
509
+ } catch (error) {
510
+ spinner.fail(chalk.red(error.message));
511
+ process.exit(1);
512
+ }
513
+ });
514
+
515
+ env
516
+ .command('rename')
517
+ .description('Rename an environment')
518
+ .argument('<oldName>', 'Current environment name')
519
+ .argument('<newName>', 'New environment name')
520
+ .option('-n, --name <name>', 'Vault name')
521
+ .option('-v, --vault <path>', 'Exact vault file path')
522
+ .option('--password <password>', 'Vault password (non-interactive)')
523
+ .action(async (oldName, newName, options) => {
524
+ const spinner = ora(`Renaming "${oldName}" to "${newName}"...`).start();
525
+
526
+ try {
527
+ const { vaultPath, vaultPassword } = await loadVault(options);
528
+
529
+ const result = await EnvironmentVaultService.renameEnv(
530
+ vaultPath,
531
+ vaultPassword,
532
+ oldName,
533
+ newName
534
+ );
535
+
536
+ if (!result.success) {
537
+ spinner.fail(chalk.red(result.error));
538
+ process.exit(1);
539
+ }
540
+
541
+ spinner.succeed(chalk.green(`Renamed "${oldName}" to "${newName}".`));
542
+ } catch (error) {
543
+ spinner.fail(chalk.red(error.message));
544
+ process.exit(1);
545
+ }
546
+ });
547
+
548
+ env
549
+ .command('template')
550
+ .description('Generate a .env.template from an environment')
551
+ .argument('[envName]', 'Environment name (defaults to "default")')
552
+ .option('-n, --name <name>', 'Vault name')
553
+ .option('-v, --vault <path>', 'Exact vault file path')
554
+ .option('--password <password>', 'Vault password (non-interactive)')
555
+ .option('--clip', 'Copy template to clipboard')
556
+ .action(async (envName, options) => {
557
+ try {
558
+ const { vaultPath, vaultPassword } = await loadVault(options);
559
+ const name = envName || 'default';
560
+
561
+ const result = await EnvironmentVaultService.templateEnv(
562
+ vaultPath,
563
+ vaultPassword,
564
+ name
565
+ );
566
+
567
+ if (!result.success) {
568
+ console.log(chalk.red(result.error));
569
+ process.exit(1);
570
+ }
571
+
572
+ console.log(result.data);
573
+ if (options.clip) await copyToClipboard(result.data, 'template');
574
+ } catch (error) {
575
+ console.log(chalk.red(error.message));
576
+ process.exit(1);
577
+ }
578
+ });
579
+
580
+ env
581
+ .command('history')
582
+ .description('Show version history for an environment')
583
+ .argument('[envName]', 'Environment name (defaults to "default")')
584
+ .option('-n, --name <name>', 'Vault name')
585
+ .option('-v, --vault <path>', 'Exact vault file path')
586
+ .option('--password <password>', 'Vault password (non-interactive)')
587
+ .action(async (envName, options) => {
588
+ try {
589
+ const { vaultPath, vaultPassword } = await loadVault(options);
590
+ const name = envName || 'default';
591
+
592
+ const result = await EnvironmentVaultService.getHistory(
593
+ vaultPath,
594
+ vaultPassword,
595
+ name
596
+ );
597
+
598
+ if (!result.success) {
599
+ console.log(chalk.red(result.error));
600
+ process.exit(1);
601
+ }
602
+
603
+ if (result.data.length === 0) {
604
+ console.log(chalk.yellow('\n No history for this environment.\n'));
605
+ return;
606
+ }
607
+
608
+ console.log(
609
+ chalk.bold(
610
+ `\nHistory for "${name}" (${result.data.length} versions):\n`
611
+ )
612
+ );
613
+
614
+ for (const v of result.data) {
615
+ const active = v.active ? chalk.green(' (active)') : '';
616
+ const msg = v.message ? ` - ${v.message}` : '';
617
+ console.log(` v${v.n}${active}${chalk.gray(msg)}`);
618
+ for (const [key, val] of Object.entries(v.vars)) {
619
+ const icon = v.nonSensitive?.includes(key) ? '🔓' : '🔒';
620
+ console.log(` ${icon} ${chalk.cyan(key)}=${val}`);
621
+ }
622
+ console.log();
623
+ }
624
+ } catch (error) {
625
+ console.log(chalk.red(error.message));
626
+ process.exit(1);
627
+ }
628
+ });
629
+
630
+ env
631
+ .command('rollback')
632
+ .description('Rollback to a previous version')
633
+ .argument('<versionN>', 'Version number to rollback to', Number)
634
+ .option('-n, --name <name>', 'Vault name')
635
+ .option('-v, --vault <path>', 'Exact vault file path')
636
+ .option('--password <password>', 'Vault password (non-interactive)')
637
+ .option('-e, --env <name>', 'Environment name (defaults to "default")')
638
+ .action(async (versionN, options) => {
639
+ const spinner = ora(`Rolling back to v${versionN}...`).start();
640
+
641
+ try {
642
+ const { vaultPath, vaultPassword } = await loadVault(options);
643
+ const envName = options.env || 'default';
644
+
645
+ const result = await EnvironmentVaultService.rollbackEnv(
646
+ vaultPath,
647
+ vaultPassword,
648
+ envName,
649
+ versionN
650
+ );
651
+
652
+ if (!result.success) {
653
+ spinner.fail(chalk.red(result.error));
654
+ process.exit(1);
655
+ }
656
+
657
+ spinner.succeed(
658
+ chalk.green(`Rolled back "${envName}" to v${versionN}.`)
659
+ );
660
+ } catch (error) {
661
+ spinner.fail(chalk.red(error.message));
662
+ process.exit(1);
663
+ }
664
+ });
665
+
666
+ env
667
+ .command('squash')
668
+ .description('Squash version history')
669
+ .option('-n, --name <name>', 'Vault name')
670
+ .option('-v, --vault <path>', 'Exact vault file path')
671
+ .option('--password <password>', 'Vault password (non-interactive)')
672
+ .option('-e, --env <name>', 'Environment name (defaults to "default")')
673
+ .option('-k, --keep <count>', 'Number of versions to keep', Number, 1)
674
+ .action(async (options) => {
675
+ const spinner = ora(
676
+ `Squashing history (keeping ${options.keep})...`
677
+ ).start();
678
+
679
+ try {
680
+ const { vaultPath, vaultPassword } = await loadVault(options);
681
+ const envName = options.env || 'default';
682
+
683
+ const result = await EnvironmentVaultService.squashEnv(
684
+ vaultPath,
685
+ vaultPassword,
686
+ envName,
687
+ options.keep
688
+ );
689
+
690
+ if (!result.success) {
691
+ spinner.fail(chalk.red(result.error));
692
+ process.exit(1);
693
+ }
694
+
695
+ spinner.succeed(
696
+ chalk.green(
697
+ `Squashed "${envName}" history to ${options.keep} version(s).`
698
+ )
699
+ );
700
+ } catch (error) {
701
+ spinner.fail(chalk.red(error.message));
702
+ process.exit(1);
703
+ }
704
+ });
705
+
706
+ env
707
+ .command('diff')
708
+ .description('Diff two environments')
709
+ .argument('<envA>', 'First environment')
710
+ .argument('<envB>', 'Second environment')
711
+ .option('-n, --name <name>', 'Vault name')
712
+ .option('-v, --vault <path>', 'Exact vault file path')
713
+ .option('--password <password>', 'Vault password (non-interactive)')
714
+ .action(async (envA, envB, options) => {
715
+ try {
716
+ const { vaultPath, vaultPassword } = await loadVault(options);
717
+
718
+ const result = await EnvironmentVaultService.diffEnvs(
719
+ vaultPath,
720
+ vaultPassword,
721
+ envA,
722
+ envB
723
+ );
724
+
725
+ if (!result.success) {
726
+ console.log(chalk.red(result.error));
727
+ process.exit(1);
728
+ }
729
+
730
+ const { data } = result;
731
+
732
+ console.log(chalk.bold(`\nDiff: ${envA} → ${envB}\n`));
733
+
734
+ if (data.added.length > 0) {
735
+ console.log(chalk.green(' Added:'));
736
+ for (const k of data.added) console.log(` + ${chalk.cyan(k)}`);
737
+ }
738
+ if (data.removed.length > 0) {
739
+ console.log(chalk.red(' Removed:'));
740
+ for (const k of data.removed) console.log(` - ${chalk.cyan(k)}`);
741
+ }
742
+ if (data.changed.length > 0) {
743
+ console.log(chalk.yellow(' Changed:'));
744
+ for (const k of data.changed) console.log(` ~ ${chalk.cyan(k)}`);
745
+ }
746
+ if (data.unchanged.length > 0) {
747
+ console.log(
748
+ chalk.gray(` Unchanged: ${data.unchanged.length} key(s)`)
749
+ );
750
+ }
751
+
752
+ console.log();
753
+ } catch (error) {
754
+ console.log(chalk.red(error.message));
755
+ process.exit(1);
756
+ }
757
+ });
758
+
759
+ env
760
+ .command('change-password')
761
+ .description('Change the vault password')
762
+ .option('-n, --name <name>', 'Vault name')
763
+ .option('-v, --vault <path>', 'Exact vault file path')
764
+ .option('--password <password>', 'Current vault password (non-interactive)')
765
+ .action(async (options) => {
766
+ const spinner = ora('Changing vault password...').start();
767
+
768
+ try {
769
+ const vaultPath = await resolveVaultPath(options);
770
+ const currentPassword = await getPassword(
771
+ options.password,
772
+ 'Enter current password:'
773
+ );
774
+ const newPassword = await password({
775
+ message: 'Enter new password:',
776
+ mask: true,
777
+ });
778
+ const confirmPassword = await password({
779
+ message: 'Confirm new password:',
780
+ mask: true,
781
+ });
782
+
783
+ if (newPassword !== confirmPassword) {
784
+ spinner.fail(chalk.red('Passwords do not match.'));
785
+ process.exit(1);
786
+ }
787
+
788
+ const result = await EnvironmentVaultService.changePassword(
789
+ vaultPath,
790
+ currentPassword,
791
+ newPassword
792
+ );
793
+
794
+ if (!result.success) {
795
+ spinner.fail(chalk.red(result.error));
796
+ process.exit(1);
797
+ }
798
+
799
+ spinner.succeed(chalk.green('Vault password changed.'));
800
+ } catch (error) {
801
+ spinner.fail(chalk.red(error.message));
802
+ process.exit(1);
803
+ }
804
+ });
805
+
806
+ return env;
807
+ }