env-secrets 0.3.2 → 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.
Files changed (42) hide show
  1. package/.codex/rules/cicd.md +170 -0
  2. package/.codex/rules/linting.md +174 -0
  3. package/.codex/rules/local-dev-badges.md +93 -0
  4. package/.codex/rules/local-dev-env.md +271 -0
  5. package/.codex/rules/local-dev-license.md +104 -0
  6. package/.codex/rules/local-dev-mcp.md +72 -0
  7. package/.codex/rules/logging.md +358 -0
  8. package/.codex/rules/observability.md +25 -0
  9. package/.codex/rules/testing.md +133 -0
  10. package/.github/workflows/lint.yaml +7 -8
  11. package/.github/workflows/release.yml +1 -1
  12. package/.github/workflows/unittests.yaml +1 -1
  13. package/AGENTS.md +10 -4
  14. package/README.md +14 -9
  15. package/__e2e__/README.md +2 -5
  16. package/__e2e__/index.test.ts +152 -1
  17. package/__e2e__/utils/test-utils.ts +61 -1
  18. package/__tests__/cli/helpers.test.ts +129 -0
  19. package/__tests__/vaults/aws-config.test.ts +85 -0
  20. package/__tests__/vaults/secretsmanager-admin.test.ts +312 -0
  21. package/__tests__/vaults/secretsmanager.test.ts +57 -20
  22. package/dist/cli/helpers.js +110 -0
  23. package/dist/index.js +221 -2
  24. package/dist/vaults/aws-config.js +29 -0
  25. package/dist/vaults/secretsmanager-admin.js +240 -0
  26. package/dist/vaults/secretsmanager.js +20 -16
  27. package/docs/AWS.md +78 -3
  28. package/eslint.config.js +67 -0
  29. package/jest.e2e.config.js +1 -0
  30. package/package.json +23 -13
  31. package/src/cli/helpers.ts +144 -0
  32. package/src/index.ts +287 -2
  33. package/src/vaults/aws-config.ts +51 -0
  34. package/src/vaults/secretsmanager-admin.ts +352 -0
  35. package/src/vaults/secretsmanager.ts +32 -20
  36. package/website/docs/cli-reference.mdx +67 -0
  37. package/website/docs/examples.mdx +1 -1
  38. package/website/docs/installation.mdx +1 -1
  39. package/website/docs/providers/aws-secrets-manager.mdx +32 -0
  40. package/.eslintignore +0 -4
  41. package/.eslintrc +0 -18
  42. package/.lintstagedrc +0 -4
@@ -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
+ });