env-secrets 0.3.3 → 0.5.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/AGENTS.md CHANGED
@@ -51,7 +51,7 @@ Always run quaity checks after creating or modifing files
51
51
  ### Testing Strategy
52
52
 
53
53
  Always run unit tests after creating or modifying files.
54
- Always run end to end tests before pushing code to a remote git repository.
54
+ Always start Docker Compose LocalStack and run end to end tests before pushing code to a remote git repository.
55
55
 
56
56
  - **Unit Tests**: Jest framework, located in `__tests__/`
57
57
  - **E2E Tests**: Located in `__e2e__/`
@@ -60,6 +60,7 @@ Always run end to end tests before pushing code to a remote git repository.
60
60
  - `yarn test` - runs all tests
61
61
  - `yarn test:unit` - runs unit tests only
62
62
  - `yarn test:e2e` - builds and runs e2e tests
63
+ - `docker compose up -d localstack` - start LocalStack for e2e tests
63
64
 
64
65
  ## Project Structure
65
66
 
@@ -120,8 +121,9 @@ yarn test:unit:coverage # Run tests with coverage
120
121
 
121
122
  1. Run `yarn prettier:fix && yarn lint` to ensure code quality
122
123
  2. Run `yarn test` to ensure all tests pass
123
- 3. Update tests for new features or bug fixes
124
- 4. Update documentation if needed
124
+ 3. Run `docker compose up -d localstack` and then `yarn test:e2e` before pushing
125
+ 4. Update tests for new features or bug fixes
126
+ 5. Update documentation if needed
125
127
 
126
128
  ### Pull Request Process
127
129
 
@@ -130,6 +132,10 @@ yarn test:unit:coverage # Run tests with coverage
130
132
  3. Add tests for new functionality
131
133
  4. Ensure all CI checks pass
132
134
  5. Submit a pull request with a clear description
135
+ 6. Always request a GitHub Copilot review on every new pull request
136
+ 7. After requesting Copilot review, wait 5 minutes and check for review comments
137
+ 8. If no Copilot review is present yet, wait another 5 minutes and check again
138
+ 9. Create a plan to address Copilot feedback, but evaluate each suggestion critically and do not accept recommendations blindly
133
139
 
134
140
  ## Development Environment
135
141
 
@@ -138,6 +144,8 @@ yarn test:unit:coverage # Run tests with coverage
138
144
  - Node.js 20.0.0 or higher (see .nvmrc)
139
145
  - Yarn package manager
140
146
  - AWS CLI (for testing AWS integration)
147
+ - Homebrew (macOS/Linux) with `awscli-local` installed:
148
+ - `brew install awscli-local`
141
149
 
142
150
  ### Setup
143
151
 
@@ -145,5 +153,6 @@ yarn test:unit:coverage # Run tests with coverage
145
153
  git clone https://github.com/markcallen/env-secrets.git
146
154
  cd env-secrets
147
155
  yarn install
156
+ brew install awscli-local
148
157
  yarn build
149
158
  ```
package/README.md CHANGED
@@ -103,6 +103,17 @@ env-secrets aws -s my-app-secrets -r us-east-1 -- node app.js
103
103
  - `-o, --output <file>` (optional): Output secrets to a file instead of injecting into environment variables. File will be created with 0400 permissions and will not overwrite existing files
104
104
  - `-- <program-to-run>`: The program to run with the injected environment variables (only used when `-o` is not specified)
105
105
 
106
+ For `aws secret` management subcommands (`create`, `update`, `append`, `remove`, `upsert`/`import`, `list`, `get`, `delete`), use:
107
+
108
+ - `-r, --region <region>` to target a specific region
109
+ - `-p, --profile <profile>` to select credentials profile
110
+ - `--output <format>` for `json` or `table`
111
+
112
+ These options are honored consistently on `aws secret` subcommands.
113
+
114
+ `env-secrets aws -s` is for fetching/injecting secret values into a child process.
115
+ `env-secrets aws secret ...` is for lifecycle management commands (`create`, `update`, `append`, `remove`, `upsert`/`import`, `list`, `get`, `delete`).
116
+
106
117
  #### Examples
107
118
 
108
119
  1. **Create a secret using AWS CLI:**
@@ -209,6 +220,42 @@ env-secrets aws -s my-secret -r us-east-1 -o secrets.env
209
220
  # Error: File secrets.env already exists and will not be overwritten
210
221
  ```
211
222
 
223
+ 10. **Load secrets into your current shell session:**
224
+
225
+ ```bash
226
+ # Write export statements to a file
227
+ env-secrets aws -s my-secret -r us-east-1 -o secrets.env
228
+
229
+ # Load into current shell
230
+ source secrets.env
231
+ ```
232
+
233
+ Note: `env-secrets aws -s ... -- <command>` injects secrets into the spawned child process only.
234
+ To affect your current shell, use file output and `source` it.
235
+
236
+ 11. **Upsert secrets from a local env file:**
237
+
238
+ ```bash
239
+ # Supported line formats:
240
+ # export NAME=secret1
241
+ # NAME=secret1
242
+ env-secrets aws secret upsert --file .env --name app/dev --output json
243
+
244
+ # Creates/updates a single secret named app/dev
245
+ # with SecretString like:
246
+ # {"NAME":"secret1"}
247
+ ```
248
+
249
+ 12. **Append/remove keys on an existing JSON secret:**
250
+
251
+ ```bash
252
+ # Add or overwrite one key
253
+ env-secrets aws secret append -n app/dev --key JIRA_EMAIL_TOKEN -v blah --output json
254
+
255
+ # Remove one or more keys
256
+ env-secrets aws secret remove -n app/dev --key API_KEY --key OLD_TOKEN --output json
257
+ ```
258
+
212
259
  ## Security Considerations
213
260
 
214
261
  - 🔐 **Credential Management**: The tool respects AWS credential precedence (environment variables, IAM roles, profiles)
@@ -459,11 +506,8 @@ The end-to-end tests use LocalStack to emulate AWS Secrets Manager and test the
459
506
  1. **Install awslocal** (required for e2e tests):
460
507
 
461
508
  ```bash
462
- # Using pip (recommended)
463
- pip install awscli-local
464
-
465
- # Or using npm
466
- npm install -g awscli-local
509
+ # macOS/Linux (recommended)
510
+ brew install awscli-local
467
511
  ```
468
512
 
469
513
  2. **Start LocalStack**:
@@ -508,15 +552,15 @@ The end-to-end test suite includes:
508
552
  - **Program Execution**: Tests for executing programs with injected environment variables
509
553
  - **Error Handling**: Tests for various error scenarios and edge cases
510
554
  - **AWS Profile Support**: Tests for both default and custom AWS profiles
511
- - **Region Support**: Tests for different AWS regions
555
+ - **Region Support**: Tests for different AWS regions, including multi-region `aws secret list` isolation checks
512
556
 
513
557
  #### Troubleshooting E2E Tests
514
558
 
515
559
  **awslocal not found**:
516
560
 
517
561
  ```bash
518
- # Install awslocal
519
- pip install awscli-local
562
+ # Install awslocal (macOS/Linux)
563
+ brew install awscli-local
520
564
 
521
565
  # Verify installation
522
566
  awslocal --version
package/__e2e__/README.md CHANGED
@@ -33,11 +33,8 @@ localstack start
33
33
  The tests require `awslocal` to be installed, which is a wrapper around AWS CLI that automatically points to LocalStack:
34
34
 
35
35
  ```bash
36
- # Install awslocal using pip (recommended)
37
- pip install awscli-local
38
-
39
- # Or using npm
40
- npm install -g awscli-local
36
+ # Install awslocal (macOS/Linux recommended)
37
+ brew install awscli-local
41
38
 
42
39
  # Verify installation
43
40
  awslocal --version
@@ -2,12 +2,14 @@ import {
2
2
  LocalStackHelper,
3
3
  cli,
4
4
  cliWithEnv,
5
+ cliWithEnvAndStdin,
5
6
  createTempFile,
6
7
  cleanupTempFile,
7
8
  createTestProfile,
8
9
  restoreTestProfile,
9
10
  TestSecret,
10
- CreatedSecret
11
+ CreatedSecret,
12
+ execAwslocalCommand
11
13
  } from './utils/test-utils';
12
14
  import { debugLog } from './utils/debug-logger';
13
15
  import * as fs from 'fs';
@@ -332,7 +334,344 @@ describe('End-to-End Tests', () => {
332
334
  const result = await cliWithEnv(['aws'], getLocalStackEnv());
333
335
 
334
336
  expect(result.code).toBe(1);
335
- expect(result.stderr).toContain('required option');
337
+ expect(result.stderr).toContain('Missing required option --secret');
338
+ });
339
+ });
340
+
341
+ describe('AWS Secret Management Commands', () => {
342
+ test('should create, list, get, and delete a secret', async () => {
343
+ const secretName = `e2e-managed-secret-${Date.now()}`;
344
+
345
+ const createResult = await cliWithEnv(
346
+ ['aws', 'secret', 'create', '-n', secretName, '-v', 'initial-value'],
347
+ getLocalStackEnv()
348
+ );
349
+
350
+ expect(createResult.code).toBe(0);
351
+ expect(createResult.stdout).toContain(secretName);
352
+
353
+ const listResult = await cliWithEnv(
354
+ ['aws', 'secret', 'list', '--prefix', 'e2e-managed-secret-'],
355
+ getLocalStackEnv()
356
+ );
357
+ expect(listResult.code).toBe(0);
358
+ expect(listResult.stdout).toContain(secretName);
359
+
360
+ const getResult = await cliWithEnv(
361
+ ['aws', 'secret', 'get', '-n', secretName],
362
+ getLocalStackEnv()
363
+ );
364
+ expect(getResult.code).toBe(0);
365
+ expect(getResult.stdout).toContain(secretName);
366
+ expect(getResult.stdout).not.toContain('initial-value');
367
+
368
+ const deleteResult = await cliWithEnv(
369
+ [
370
+ 'aws',
371
+ 'secret',
372
+ 'delete',
373
+ '-n',
374
+ secretName,
375
+ '--force-delete-without-recovery',
376
+ '--yes'
377
+ ],
378
+ getLocalStackEnv()
379
+ );
380
+ expect(deleteResult.code).toBe(0);
381
+ });
382
+
383
+ test('should update secret value from stdin', async () => {
384
+ const secret = await createTestSecret({
385
+ name: `managed-secret-stdin-${Date.now()}`,
386
+ value: 'initial-value',
387
+ description: 'Secret for stdin update test'
388
+ });
389
+
390
+ const updateResult = await cliWithEnvAndStdin(
391
+ [
392
+ 'aws',
393
+ 'secret',
394
+ 'update',
395
+ '-n',
396
+ secret.prefixedName,
397
+ '--value-stdin'
398
+ ],
399
+ getLocalStackEnv(),
400
+ 'stdin-updated-value'
401
+ );
402
+
403
+ expect(updateResult.code).toBe(0);
404
+ expect(updateResult.stderr).toBe('');
405
+
406
+ const deleteResult = await cliWithEnv(
407
+ [
408
+ 'aws',
409
+ 'secret',
410
+ 'delete',
411
+ '-n',
412
+ secret.prefixedName,
413
+ '--force-delete-without-recovery',
414
+ '--yes'
415
+ ],
416
+ getLocalStackEnv()
417
+ );
418
+ expect(deleteResult.code).toBe(0);
419
+ });
420
+
421
+ test('should append and remove keys on a JSON secret', async () => {
422
+ const secretName = `managed-secret-append-remove-${Date.now()}`;
423
+ const tempFile = path.join(
424
+ os.tmpdir(),
425
+ `env-secrets-append-remove-${Date.now()}.env`
426
+ );
427
+ fs.writeFileSync(tempFile, 'API_KEY=first');
428
+
429
+ const createResult = await cliWithEnv(
430
+ [
431
+ 'aws',
432
+ 'secret',
433
+ 'upsert',
434
+ '--file',
435
+ tempFile,
436
+ '--name',
437
+ secretName,
438
+ '--output',
439
+ 'json'
440
+ ],
441
+ getLocalStackEnv()
442
+ );
443
+ expect(createResult.code).toBe(0);
444
+
445
+ const appendResult = await cliWithEnv(
446
+ [
447
+ 'aws',
448
+ 'secret',
449
+ 'append',
450
+ '-n',
451
+ secretName,
452
+ '--key',
453
+ 'JIRA_EMAIL_TOKEN',
454
+ '-v',
455
+ 'blah',
456
+ '--output',
457
+ 'json'
458
+ ],
459
+ getLocalStackEnv()
460
+ );
461
+ expect(appendResult.code).toBe(0);
462
+
463
+ const afterAppend = await execAwslocalCommand(
464
+ `awslocal secretsmanager get-secret-value --secret-id "${secretName}" --region us-east-1 --query SecretString --output text`,
465
+ getLocalStackEnv()
466
+ );
467
+ expect(JSON.parse(afterAppend.stdout.trim())).toEqual({
468
+ API_KEY: 'first',
469
+ JIRA_EMAIL_TOKEN: 'blah'
470
+ });
471
+
472
+ const removeResult = await cliWithEnv(
473
+ [
474
+ 'aws',
475
+ 'secret',
476
+ 'remove',
477
+ '-n',
478
+ secretName,
479
+ '--key',
480
+ 'API_KEY',
481
+ '--output',
482
+ 'json'
483
+ ],
484
+ getLocalStackEnv()
485
+ );
486
+ expect(removeResult.code).toBe(0);
487
+
488
+ const afterRemove = await execAwslocalCommand(
489
+ `awslocal secretsmanager get-secret-value --secret-id "${secretName}" --region us-east-1 --query SecretString --output text`,
490
+ getLocalStackEnv()
491
+ );
492
+ expect(JSON.parse(afterRemove.stdout.trim())).toEqual({
493
+ JIRA_EMAIL_TOKEN: 'blah'
494
+ });
495
+
496
+ const deleteResult = await cliWithEnv(
497
+ [
498
+ 'aws',
499
+ 'secret',
500
+ 'delete',
501
+ '-n',
502
+ secretName,
503
+ '--force-delete-without-recovery',
504
+ '--yes'
505
+ ],
506
+ getLocalStackEnv()
507
+ );
508
+ expect(deleteResult.code).toBe(0);
509
+ cleanupTempFile(tempFile);
510
+ });
511
+
512
+ test('should require confirmation for delete', async () => {
513
+ const secret = await createTestSecret({
514
+ name: `managed-secret-confirm-${Date.now()}`,
515
+ value: 'value',
516
+ description: 'Secret for delete confirmation test'
517
+ });
518
+
519
+ const result = await cliWithEnv(
520
+ ['aws', 'secret', 'delete', '-n', secret.prefixedName],
521
+ getLocalStackEnv()
522
+ );
523
+
524
+ expect(result.code).toBe(1);
525
+ expect(result.stderr).toContain('requires --yes confirmation');
526
+ });
527
+
528
+ test('should honor region flag for secret list across multiple regions', async () => {
529
+ const secret = await createTestSecret(
530
+ {
531
+ name: `managed-secret-multi-region-${Date.now()}`,
532
+ value: '{"region":"us-west-2"}',
533
+ description: 'Secret used for region isolation test'
534
+ },
535
+ 'us-west-2'
536
+ );
537
+
538
+ const westResult = await cliWithEnv(
539
+ [
540
+ 'aws',
541
+ 'secret',
542
+ 'list',
543
+ '--prefix',
544
+ secret.prefixedName,
545
+ '-r',
546
+ 'us-west-2',
547
+ '--output',
548
+ 'json'
549
+ ],
550
+ getLocalStackEnv()
551
+ );
552
+ expect(westResult.code).toBe(0);
553
+ const westRows = JSON.parse(westResult.stdout) as Array<{
554
+ name: string;
555
+ }>;
556
+ expect(westRows.some((row) => row.name === secret.prefixedName)).toBe(
557
+ true
558
+ );
559
+
560
+ const eastResult = await cliWithEnv(
561
+ [
562
+ 'aws',
563
+ 'secret',
564
+ 'list',
565
+ '--prefix',
566
+ secret.prefixedName,
567
+ '-r',
568
+ 'us-east-1',
569
+ '--output',
570
+ 'json'
571
+ ],
572
+ getLocalStackEnv()
573
+ );
574
+ expect(eastResult.code).toBe(0);
575
+ const eastRows = JSON.parse(eastResult.stdout) as Array<{
576
+ name: string;
577
+ }>;
578
+ expect(eastRows).toEqual([]);
579
+ });
580
+
581
+ test('should upsert secrets from env file', async () => {
582
+ const secretName = `e2e-upsert-${Date.now()}`;
583
+ const tempFile = path.join(
584
+ os.tmpdir(),
585
+ `env-secrets-upsert-${Date.now()}.env`
586
+ );
587
+
588
+ fs.writeFileSync(
589
+ tempFile,
590
+ ['# sample', 'export API_KEY=first', 'DB_URL = postgres://one'].join(
591
+ '\n'
592
+ )
593
+ );
594
+
595
+ const firstRun = await cliWithEnv(
596
+ [
597
+ 'aws',
598
+ 'secret',
599
+ 'upsert',
600
+ '--file',
601
+ tempFile,
602
+ '--name',
603
+ secretName,
604
+ '--output',
605
+ 'json'
606
+ ],
607
+ getLocalStackEnv()
608
+ );
609
+ expect(firstRun.code).toBe(0);
610
+ const firstJson = JSON.parse(firstRun.stdout) as {
611
+ summary: { created: number; updated: number; skipped: number };
612
+ };
613
+ expect(firstJson.summary.created).toBe(1);
614
+ expect(firstJson.summary.updated).toBe(0);
615
+
616
+ const firstSecret = await execAwslocalCommand(
617
+ `awslocal secretsmanager get-secret-value --secret-id "${secretName}" --region us-east-1 --query SecretString --output text`,
618
+ getLocalStackEnv()
619
+ );
620
+ expect(JSON.parse(firstSecret.stdout.trim())).toEqual({
621
+ API_KEY: 'first',
622
+ DB_URL: 'postgres://one'
623
+ });
624
+
625
+ fs.writeFileSync(
626
+ tempFile,
627
+ ['export API_KEY=second', 'DB_URL=postgres://two'].join('\n')
628
+ );
629
+
630
+ const secondRun = await cliWithEnv(
631
+ [
632
+ 'aws',
633
+ 'secret',
634
+ 'import',
635
+ '--file',
636
+ tempFile,
637
+ '--name',
638
+ secretName,
639
+ '--output',
640
+ 'json'
641
+ ],
642
+ getLocalStackEnv()
643
+ );
644
+ expect(secondRun.code).toBe(0);
645
+ const secondJson = JSON.parse(secondRun.stdout) as {
646
+ summary: { created: number; updated: number; skipped: number };
647
+ };
648
+ expect(secondJson.summary.created).toBe(0);
649
+ expect(secondJson.summary.updated).toBe(1);
650
+ expect(secondJson.summary.skipped).toBe(0);
651
+
652
+ const secondSecret = await execAwslocalCommand(
653
+ `awslocal secretsmanager get-secret-value --secret-id "${secretName}" --region us-east-1 --query SecretString --output text`,
654
+ getLocalStackEnv()
655
+ );
656
+ expect(JSON.parse(secondSecret.stdout.trim())).toEqual({
657
+ API_KEY: 'second',
658
+ DB_URL: 'postgres://two'
659
+ });
660
+
661
+ const deleteResult = await cliWithEnv(
662
+ [
663
+ 'aws',
664
+ 'secret',
665
+ 'delete',
666
+ '-n',
667
+ secretName,
668
+ '--force-delete-without-recovery',
669
+ '--yes'
670
+ ],
671
+ getLocalStackEnv()
672
+ );
673
+ expect(deleteResult.code).toBe(0);
674
+ cleanupTempFile(tempFile);
336
675
  });
337
676
  });
338
677
  });
@@ -1,4 +1,4 @@
1
- import { exec } from 'child_process';
1
+ import { exec, spawn } from 'child_process';
2
2
  import { promisify } from 'util';
3
3
  import * as path from 'path';
4
4
  import * as fs from 'fs';
@@ -506,6 +506,66 @@ export async function cliWithEnv(
506
506
  });
507
507
  }
508
508
 
509
+ export async function cliWithEnvAndStdin(
510
+ args: string[],
511
+ env: Record<string, string>,
512
+ stdin: string,
513
+ cwd = '.'
514
+ ): Promise<CliResult> {
515
+ return await new Promise((resolve) => {
516
+ const cleanEnv = { ...process.env };
517
+ delete cleanEnv.AWS_PROFILE;
518
+ delete cleanEnv.AWS_DEFAULT_PROFILE;
519
+ delete cleanEnv.AWS_SESSION_TOKEN;
520
+ delete cleanEnv.AWS_SECURITY_TOKEN;
521
+ delete cleanEnv.AWS_ROLE_ARN;
522
+ delete cleanEnv.AWS_ROLE_SESSION_NAME;
523
+ delete cleanEnv.AWS_WEB_IDENTITY_TOKEN_FILE;
524
+ delete cleanEnv.AWS_WEB_IDENTITY_TOKEN;
525
+
526
+ const defaultEnv = {
527
+ AWS_ENDPOINT_URL: process.env.LOCALSTACK_URL || 'http://localhost:4566',
528
+ AWS_ACCESS_KEY_ID: 'test',
529
+ AWS_SECRET_ACCESS_KEY: 'test',
530
+ AWS_DEFAULT_REGION: 'us-east-1',
531
+ AWS_REGION: 'us-east-1',
532
+ NODE_ENV: 'test'
533
+ };
534
+
535
+ const envVars = { ...cleanEnv, ...defaultEnv, ...env };
536
+ const child = spawn('node', [path.resolve('./dist/index'), ...args], {
537
+ cwd,
538
+ env: envVars,
539
+ stdio: 'pipe'
540
+ });
541
+
542
+ let stdout = '';
543
+ let stderr = '';
544
+ let error: Error | null = null;
545
+
546
+ child.stdout.on('data', (chunk) => {
547
+ stdout += chunk.toString();
548
+ });
549
+ child.stderr.on('data', (chunk) => {
550
+ stderr += chunk.toString();
551
+ });
552
+ child.on('error', (err) => {
553
+ error = err;
554
+ });
555
+ child.on('close', (code) => {
556
+ resolve({
557
+ code: code ?? (error ? 1 : 0),
558
+ error,
559
+ stdout,
560
+ stderr
561
+ });
562
+ });
563
+
564
+ child.stdin.write(stdin);
565
+ child.stdin.end();
566
+ });
567
+ }
568
+
509
569
  export function createTempFile(content: string) {
510
570
  const tempDir = os.tmpdir();
511
571
  const tempFile = path.join(tempDir, `env-secrets-test-${Date.now()}.env`);