env-secrets 0.3.3 → 0.4.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,7 @@ 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
133
136
 
134
137
  ## Development Environment
135
138
 
@@ -138,6 +141,8 @@ yarn test:unit:coverage # Run tests with coverage
138
141
  - Node.js 20.0.0 or higher (see .nvmrc)
139
142
  - Yarn package manager
140
143
  - AWS CLI (for testing AWS integration)
144
+ - Homebrew (macOS/Linux) with `awscli-local` installed:
145
+ - `brew install awscli-local`
141
146
 
142
147
  ### Setup
143
148
 
@@ -145,5 +150,6 @@ yarn test:unit:coverage # Run tests with coverage
145
150
  git clone https://github.com/markcallen/env-secrets.git
146
151
  cd env-secrets
147
152
  yarn install
153
+ brew install awscli-local
148
154
  yarn build
149
155
  ```
package/README.md CHANGED
@@ -103,6 +103,14 @@ 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`, `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
+
106
114
  #### Examples
107
115
 
108
116
  1. **Create a secret using AWS CLI:**
@@ -459,11 +467,8 @@ The end-to-end tests use LocalStack to emulate AWS Secrets Manager and test the
459
467
  1. **Install awslocal** (required for e2e tests):
460
468
 
461
469
  ```bash
462
- # Using pip (recommended)
463
- pip install awscli-local
464
-
465
- # Or using npm
466
- npm install -g awscli-local
470
+ # macOS/Linux (recommended)
471
+ brew install awscli-local
467
472
  ```
468
473
 
469
474
  2. **Start LocalStack**:
@@ -508,15 +513,15 @@ The end-to-end test suite includes:
508
513
  - **Program Execution**: Tests for executing programs with injected environment variables
509
514
  - **Error Handling**: Tests for various error scenarios and edge cases
510
515
  - **AWS Profile Support**: Tests for both default and custom AWS profiles
511
- - **Region Support**: Tests for different AWS regions
516
+ - **Region Support**: Tests for different AWS regions, including multi-region `aws secret list` isolation checks
512
517
 
513
518
  #### Troubleshooting E2E Tests
514
519
 
515
520
  **awslocal not found**:
516
521
 
517
522
  ```bash
518
- # Install awslocal
519
- pip install awscli-local
523
+ # Install awslocal (macOS/Linux)
524
+ brew install awscli-local
520
525
 
521
526
  # Verify installation
522
527
  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,6 +2,7 @@ import {
2
2
  LocalStackHelper,
3
3
  cli,
4
4
  cliWithEnv,
5
+ cliWithEnvAndStdin,
5
6
  createTempFile,
6
7
  cleanupTempFile,
7
8
  createTestProfile,
@@ -332,7 +333,157 @@ describe('End-to-End Tests', () => {
332
333
  const result = await cliWithEnv(['aws'], getLocalStackEnv());
333
334
 
334
335
  expect(result.code).toBe(1);
335
- expect(result.stderr).toContain('required option');
336
+ expect(result.stderr).toContain('Missing required option --secret');
337
+ });
338
+ });
339
+
340
+ describe('AWS Secret Management Commands', () => {
341
+ test('should create, list, get, and delete a secret', async () => {
342
+ const secretName = `e2e-managed-secret-${Date.now()}`;
343
+
344
+ const createResult = await cliWithEnv(
345
+ ['aws', 'secret', 'create', '-n', secretName, '-v', 'initial-value'],
346
+ getLocalStackEnv()
347
+ );
348
+
349
+ expect(createResult.code).toBe(0);
350
+ expect(createResult.stdout).toContain(secretName);
351
+
352
+ const listResult = await cliWithEnv(
353
+ ['aws', 'secret', 'list', '--prefix', 'e2e-managed-secret-'],
354
+ getLocalStackEnv()
355
+ );
356
+ expect(listResult.code).toBe(0);
357
+ expect(listResult.stdout).toContain(secretName);
358
+
359
+ const getResult = await cliWithEnv(
360
+ ['aws', 'secret', 'get', '-n', secretName],
361
+ getLocalStackEnv()
362
+ );
363
+ expect(getResult.code).toBe(0);
364
+ expect(getResult.stdout).toContain(secretName);
365
+ expect(getResult.stdout).not.toContain('initial-value');
366
+
367
+ const deleteResult = await cliWithEnv(
368
+ [
369
+ 'aws',
370
+ 'secret',
371
+ 'delete',
372
+ '-n',
373
+ secretName,
374
+ '--force-delete-without-recovery',
375
+ '--yes'
376
+ ],
377
+ getLocalStackEnv()
378
+ );
379
+ expect(deleteResult.code).toBe(0);
380
+ });
381
+
382
+ test('should update secret value from stdin', async () => {
383
+ const secret = await createTestSecret({
384
+ name: `managed-secret-stdin-${Date.now()}`,
385
+ value: 'initial-value',
386
+ description: 'Secret for stdin update test'
387
+ });
388
+
389
+ const updateResult = await cliWithEnvAndStdin(
390
+ [
391
+ 'aws',
392
+ 'secret',
393
+ 'update',
394
+ '-n',
395
+ secret.prefixedName,
396
+ '--value-stdin'
397
+ ],
398
+ getLocalStackEnv(),
399
+ 'stdin-updated-value'
400
+ );
401
+
402
+ expect(updateResult.code).toBe(0);
403
+ expect(updateResult.stderr).toBe('');
404
+
405
+ const deleteResult = await cliWithEnv(
406
+ [
407
+ 'aws',
408
+ 'secret',
409
+ 'delete',
410
+ '-n',
411
+ secret.prefixedName,
412
+ '--force-delete-without-recovery',
413
+ '--yes'
414
+ ],
415
+ getLocalStackEnv()
416
+ );
417
+ expect(deleteResult.code).toBe(0);
418
+ });
419
+
420
+ test('should require confirmation for delete', async () => {
421
+ const secret = await createTestSecret({
422
+ name: `managed-secret-confirm-${Date.now()}`,
423
+ value: 'value',
424
+ description: 'Secret for delete confirmation test'
425
+ });
426
+
427
+ const result = await cliWithEnv(
428
+ ['aws', 'secret', 'delete', '-n', secret.prefixedName],
429
+ getLocalStackEnv()
430
+ );
431
+
432
+ expect(result.code).toBe(1);
433
+ expect(result.stderr).toContain('requires --yes confirmation');
434
+ });
435
+
436
+ test('should honor region flag for secret list across multiple regions', async () => {
437
+ const secret = await createTestSecret(
438
+ {
439
+ name: `managed-secret-multi-region-${Date.now()}`,
440
+ value: '{"region":"us-west-2"}',
441
+ description: 'Secret used for region isolation test'
442
+ },
443
+ 'us-west-2'
444
+ );
445
+
446
+ const westResult = await cliWithEnv(
447
+ [
448
+ 'aws',
449
+ 'secret',
450
+ 'list',
451
+ '--prefix',
452
+ secret.prefixedName,
453
+ '-r',
454
+ 'us-west-2',
455
+ '--output',
456
+ 'json'
457
+ ],
458
+ getLocalStackEnv()
459
+ );
460
+ expect(westResult.code).toBe(0);
461
+ const westRows = JSON.parse(westResult.stdout) as Array<{
462
+ name: string;
463
+ }>;
464
+ expect(westRows.some((row) => row.name === secret.prefixedName)).toBe(
465
+ true
466
+ );
467
+
468
+ const eastResult = await cliWithEnv(
469
+ [
470
+ 'aws',
471
+ 'secret',
472
+ 'list',
473
+ '--prefix',
474
+ secret.prefixedName,
475
+ '-r',
476
+ 'us-east-1',
477
+ '--output',
478
+ 'json'
479
+ ],
480
+ getLocalStackEnv()
481
+ );
482
+ expect(eastResult.code).toBe(0);
483
+ const eastRows = JSON.parse(eastResult.stdout) as Array<{
484
+ name: string;
485
+ }>;
486
+ expect(eastRows).toEqual([]);
336
487
  });
337
488
  });
338
489
  });
@@ -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`);
@@ -0,0 +1,129 @@
1
+ import { PassThrough } from 'node:stream';
2
+
3
+ import {
4
+ asOutputFormat,
5
+ parseRecoveryDays,
6
+ printData,
7
+ readStdin,
8
+ renderTable,
9
+ resolveAwsScope,
10
+ resolveSecretValue
11
+ } from '../../src/cli/helpers';
12
+
13
+ describe('cli/helpers', () => {
14
+ it('validates output format', () => {
15
+ expect(asOutputFormat('json')).toBe('json');
16
+ expect(asOutputFormat('table')).toBe('table');
17
+ expect(() => asOutputFormat('xml')).toThrow('Invalid output format');
18
+ });
19
+
20
+ it('renders empty table message', () => {
21
+ const output = renderTable([{ key: 'name', label: 'Name' }], []);
22
+ expect(output).toBe('No results.');
23
+ });
24
+
25
+ it('renders aligned table output', () => {
26
+ const output = renderTable(
27
+ [
28
+ { key: 'name', label: 'Name' },
29
+ { key: 'value', label: 'Value' }
30
+ ],
31
+ [{ name: 'one', value: '1' }]
32
+ );
33
+
34
+ expect(output).toContain('Name');
35
+ expect(output).toContain('Value');
36
+ expect(output).toContain('one');
37
+ });
38
+
39
+ it('prints JSON output', () => {
40
+ const consoleSpy = jest
41
+ .spyOn(console, 'log')
42
+ .mockImplementation(() => undefined);
43
+
44
+ printData('json', [{ key: 'name', label: 'Name' }], [{ name: 'value' }]);
45
+
46
+ expect(consoleSpy).toHaveBeenCalledWith(
47
+ JSON.stringify([{ name: 'value' }], null, 2)
48
+ );
49
+ consoleSpy.mockRestore();
50
+ });
51
+
52
+ it('parses valid recovery days and rejects invalid values', () => {
53
+ expect(parseRecoveryDays('7')).toBe(7);
54
+ expect(parseRecoveryDays('30')).toBe(30);
55
+ expect(() => parseRecoveryDays('6')).toThrow();
56
+ expect(() => parseRecoveryDays('31')).toThrow();
57
+ expect(() => parseRecoveryDays('abc')).toThrow();
58
+ });
59
+
60
+ it('reads stdin content and strips one trailing newline', async () => {
61
+ const stream = new PassThrough();
62
+ const promise = readStdin(stream as unknown as NodeJS.ReadStream);
63
+
64
+ stream.write('secret-value\n');
65
+ stream.end();
66
+
67
+ await expect(promise).resolves.toBe('secret-value');
68
+ });
69
+
70
+ it('resolves secret value from explicit --value', async () => {
71
+ await expect(resolveSecretValue('inline', false)).resolves.toBe('inline');
72
+ });
73
+
74
+ it('rejects when both --value and --value-stdin are used', async () => {
75
+ await expect(resolveSecretValue('inline', true)).rejects.toThrow(
76
+ 'Use either --value or --value-stdin, not both.'
77
+ );
78
+ });
79
+
80
+ it('rejects stdin mode when no stdin is provided', async () => {
81
+ const originalStdin = process.stdin;
82
+ Object.defineProperty(process, 'stdin', {
83
+ value: { ...originalStdin, isTTY: true },
84
+ configurable: true
85
+ });
86
+
87
+ await expect(resolveSecretValue(undefined, true)).rejects.toThrow(
88
+ 'No stdin detected. Pipe a value when using --value-stdin.'
89
+ );
90
+
91
+ Object.defineProperty(process, 'stdin', {
92
+ value: originalStdin,
93
+ configurable: true
94
+ });
95
+ });
96
+
97
+ it('prefers explicit aws scope options over global options', () => {
98
+ const command = {
99
+ optsWithGlobals: () => ({
100
+ profile: 'global-profile',
101
+ region: 'us-west-2'
102
+ })
103
+ };
104
+
105
+ expect(
106
+ resolveAwsScope(
107
+ { profile: 'local-profile', region: 'us-east-1' },
108
+ command
109
+ )
110
+ ).toEqual({
111
+ profile: 'local-profile',
112
+ region: 'us-east-1'
113
+ });
114
+ });
115
+
116
+ it('falls back to global aws scope options when local options are absent', () => {
117
+ const command = {
118
+ optsWithGlobals: () => ({
119
+ profile: 'global-profile',
120
+ region: 'us-west-2'
121
+ })
122
+ };
123
+
124
+ expect(resolveAwsScope({}, command)).toEqual({
125
+ profile: 'global-profile',
126
+ region: 'us-west-2'
127
+ });
128
+ });
129
+ });
@@ -0,0 +1,85 @@
1
+ import { fromIni } from '@aws-sdk/credential-providers';
2
+
3
+ jest.mock('@aws-sdk/credential-providers');
4
+
5
+ import { buildAwsClientConfig } from '../../src/vaults/aws-config';
6
+
7
+ const mockFromIni = fromIni as jest.MockedFunction<typeof fromIni>;
8
+
9
+ describe('aws-config', () => {
10
+ let originalEnv: NodeJS.ProcessEnv;
11
+
12
+ beforeEach(() => {
13
+ jest.clearAllMocks();
14
+ originalEnv = { ...process.env };
15
+ process.env = { ...originalEnv };
16
+ });
17
+
18
+ afterEach(() => {
19
+ process.env = originalEnv;
20
+ });
21
+
22
+ it('uses explicit profile when provided', () => {
23
+ const mockCredentials = jest.fn();
24
+ mockFromIni.mockReturnValue(mockCredentials as ReturnType<typeof fromIni>);
25
+
26
+ const config = buildAwsClientConfig({
27
+ profile: 'my-profile',
28
+ region: 'us-east-1'
29
+ });
30
+
31
+ expect(mockFromIni).toHaveBeenCalledWith({ profile: 'my-profile' });
32
+ expect(config).toEqual({
33
+ region: 'us-east-1',
34
+ credentials: mockCredentials
35
+ });
36
+ });
37
+
38
+ it('uses environment credentials when access key and secret are present', () => {
39
+ process.env.AWS_ACCESS_KEY_ID = 'test';
40
+ process.env.AWS_SECRET_ACCESS_KEY = 'test';
41
+
42
+ const config = buildAwsClientConfig({ region: 'us-east-1' });
43
+
44
+ expect(mockFromIni).not.toHaveBeenCalled();
45
+ expect(config).toEqual({
46
+ region: 'us-east-1',
47
+ credentials: undefined
48
+ });
49
+ });
50
+
51
+ it('falls back to default profile when no explicit credentials are provided', () => {
52
+ const mockCredentials = jest.fn();
53
+ mockFromIni.mockReturnValue(mockCredentials as ReturnType<typeof fromIni>);
54
+
55
+ const config = buildAwsClientConfig({ region: 'us-east-1' });
56
+
57
+ expect(mockFromIni).toHaveBeenCalledWith({ profile: 'default' });
58
+ expect(config).toEqual({
59
+ region: 'us-east-1',
60
+ credentials: mockCredentials
61
+ });
62
+ });
63
+
64
+ it('prefers AWS_ENDPOINT_URL for custom endpoint', () => {
65
+ const mockCredentials = jest.fn();
66
+ mockFromIni.mockReturnValue(mockCredentials as ReturnType<typeof fromIni>);
67
+ process.env.AWS_ENDPOINT_URL = 'http://localhost:4566';
68
+ process.env.AWS_SECRETS_MANAGER_ENDPOINT = 'http://localhost:4577';
69
+
70
+ const config = buildAwsClientConfig({ region: 'us-east-1' });
71
+
72
+ expect(config.endpoint).toBe('http://localhost:4566');
73
+ });
74
+
75
+ it('uses AWS_SECRETS_MANAGER_ENDPOINT when AWS_ENDPOINT_URL is unset', () => {
76
+ const mockCredentials = jest.fn();
77
+ mockFromIni.mockReturnValue(mockCredentials as ReturnType<typeof fromIni>);
78
+ delete process.env.AWS_ENDPOINT_URL;
79
+ process.env.AWS_SECRETS_MANAGER_ENDPOINT = 'http://localhost:4577';
80
+
81
+ const config = buildAwsClientConfig({ region: 'us-east-1' });
82
+
83
+ expect(config.endpoint).toBe('http://localhost:4577');
84
+ });
85
+ });