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
@@ -0,0 +1,312 @@
1
+ import { SecretsManagerClient } from '@aws-sdk/client-secrets-manager';
2
+ import { STSClient } from '@aws-sdk/client-sts';
3
+ import { fromIni } from '@aws-sdk/credential-providers';
4
+
5
+ const mockSecretsManagerSend = jest.fn();
6
+ const mockSTSSend = jest.fn();
7
+
8
+ jest.mock('@aws-sdk/client-secrets-manager', () => {
9
+ return {
10
+ SecretsManagerClient: jest.fn().mockImplementation(() => ({
11
+ send: mockSecretsManagerSend
12
+ })),
13
+ CreateSecretCommand: jest.fn().mockImplementation((input) => ({ input })),
14
+ UpdateSecretCommand: jest.fn().mockImplementation((input) => ({ input })),
15
+ ListSecretsCommand: jest.fn().mockImplementation((input) => ({ input })),
16
+ DescribeSecretCommand: jest.fn().mockImplementation((input) => ({ input })),
17
+ DeleteSecretCommand: jest.fn().mockImplementation((input) => ({ input }))
18
+ };
19
+ });
20
+
21
+ jest.mock('@aws-sdk/client-sts', () => {
22
+ return {
23
+ STSClient: jest.fn().mockImplementation(() => ({
24
+ send: mockSTSSend
25
+ })),
26
+ GetCallerIdentityCommand: jest
27
+ .fn()
28
+ .mockImplementation((input) => ({ input }))
29
+ };
30
+ });
31
+
32
+ jest.mock('@aws-sdk/credential-providers');
33
+
34
+ import {
35
+ createSecret,
36
+ updateSecret,
37
+ listSecrets,
38
+ getSecretMetadata,
39
+ deleteSecret,
40
+ validateSecretName
41
+ } from '../../src/vaults/secretsmanager-admin';
42
+
43
+ const mockSecretsManagerClient = SecretsManagerClient as jest.MockedClass<
44
+ typeof SecretsManagerClient
45
+ >;
46
+ const mockSTSClient = STSClient as jest.MockedClass<typeof STSClient>;
47
+ const mockFromIni = fromIni as jest.MockedFunction<typeof fromIni>;
48
+
49
+ describe('secretsmanager-admin', () => {
50
+ let originalEnv: NodeJS.ProcessEnv;
51
+
52
+ beforeEach(() => {
53
+ jest.clearAllMocks();
54
+ originalEnv = { ...process.env };
55
+ process.env = { ...originalEnv };
56
+ mockSTSSend.mockResolvedValue({ Account: '123456789012' });
57
+ });
58
+
59
+ afterEach(() => {
60
+ process.env = originalEnv;
61
+ });
62
+
63
+ it('validates secret names', () => {
64
+ expect(() => validateSecretName('valid/name_1')).not.toThrow();
65
+ expect(() => validateSecretName('invalid name')).toThrow(
66
+ 'Invalid secret name'
67
+ );
68
+ });
69
+
70
+ it('creates a secret with tags', async () => {
71
+ mockSecretsManagerSend.mockResolvedValueOnce({
72
+ Name: 'test-secret',
73
+ ARN: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:test-secret',
74
+ VersionId: 'v1'
75
+ });
76
+
77
+ const result = await createSecret({
78
+ name: 'test-secret',
79
+ value: '{"API_KEY":"abc"}',
80
+ description: 'test',
81
+ tags: ['team=platform', 'env=dev'],
82
+ region: 'us-east-1'
83
+ });
84
+
85
+ expect(mockSTSClient).toHaveBeenCalled();
86
+ expect(mockSecretsManagerClient).toHaveBeenCalled();
87
+ expect(mockSecretsManagerSend).toHaveBeenCalledWith(
88
+ expect.objectContaining({
89
+ input: expect.objectContaining({
90
+ Name: 'test-secret',
91
+ SecretString: '{"API_KEY":"abc"}',
92
+ Tags: [
93
+ { Key: 'team', Value: 'platform' },
94
+ { Key: 'env', Value: 'dev' }
95
+ ]
96
+ })
97
+ })
98
+ );
99
+ expect(result).toEqual({
100
+ arn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:test-secret',
101
+ name: 'test-secret',
102
+ versionId: 'v1'
103
+ });
104
+ });
105
+
106
+ it('supports tags with multiple equals signs', async () => {
107
+ mockSecretsManagerSend.mockResolvedValueOnce({
108
+ Name: 'test-secret',
109
+ ARN: 'arn:test',
110
+ VersionId: 'v1'
111
+ });
112
+
113
+ await createSecret({
114
+ name: 'test-secret',
115
+ value: 'value',
116
+ tags: ['url=https://example.com?a=1'],
117
+ region: 'us-east-1'
118
+ });
119
+
120
+ expect(mockSecretsManagerSend).toHaveBeenCalledWith(
121
+ expect.objectContaining({
122
+ input: expect.objectContaining({
123
+ Tags: [{ Key: 'url', Value: 'https://example.com?a=1' }]
124
+ })
125
+ })
126
+ );
127
+ });
128
+
129
+ it('throws for malformed tags', async () => {
130
+ await expect(
131
+ createSecret({
132
+ name: 'test-secret',
133
+ value: 'value',
134
+ tags: ['invalid-tag'],
135
+ region: 'us-east-1'
136
+ })
137
+ ).rejects.toThrow('Invalid tag format');
138
+ });
139
+
140
+ it('omits tags when none are provided', async () => {
141
+ mockSecretsManagerSend.mockResolvedValueOnce({
142
+ Name: 'test-secret',
143
+ ARN: 'arn:test',
144
+ VersionId: 'v1'
145
+ });
146
+
147
+ await createSecret({
148
+ name: 'test-secret',
149
+ value: 'value',
150
+ region: 'us-east-1'
151
+ });
152
+
153
+ expect(mockSecretsManagerSend).toHaveBeenCalledWith(
154
+ expect.objectContaining({
155
+ input: expect.objectContaining({
156
+ Tags: undefined
157
+ })
158
+ })
159
+ );
160
+ });
161
+
162
+ it('maps create secret AlreadyExistsException', async () => {
163
+ mockSecretsManagerSend.mockRejectedValueOnce({
164
+ name: 'AlreadyExistsException'
165
+ });
166
+
167
+ await expect(
168
+ createSecret({
169
+ name: 'existing-secret',
170
+ value: 'abc',
171
+ region: 'us-east-1'
172
+ })
173
+ ).rejects.toThrow('already exists');
174
+ });
175
+
176
+ it('updates a secret value and description', async () => {
177
+ mockSecretsManagerSend.mockResolvedValueOnce({
178
+ Name: 'test-secret',
179
+ ARN: 'arn:test',
180
+ VersionId: 'v2'
181
+ });
182
+
183
+ const result = await updateSecret({
184
+ name: 'test-secret',
185
+ value: '{"API_KEY":"new"}',
186
+ description: 'updated',
187
+ region: 'us-east-1'
188
+ });
189
+
190
+ expect(mockSecretsManagerSend).toHaveBeenCalledWith(
191
+ expect.objectContaining({
192
+ input: expect.objectContaining({
193
+ SecretId: 'test-secret',
194
+ SecretString: '{"API_KEY":"new"}',
195
+ Description: 'updated'
196
+ })
197
+ })
198
+ );
199
+ expect(result.versionId).toBe('v2');
200
+ });
201
+
202
+ it('lists secrets and applies prefix/tag filters', async () => {
203
+ mockSecretsManagerSend.mockResolvedValueOnce({
204
+ SecretList: [
205
+ {
206
+ Name: 'app/one',
207
+ Description: 'One',
208
+ Tags: [{ Key: 'env', Value: 'dev' }],
209
+ LastChangedDate: new Date('2026-02-16T00:00:00.000Z')
210
+ },
211
+ {
212
+ Name: 'app/two',
213
+ Description: 'Two',
214
+ Tags: [{ Key: 'env', Value: 'prod' }],
215
+ LastChangedDate: new Date('2026-02-16T00:00:00.000Z')
216
+ }
217
+ ]
218
+ });
219
+
220
+ const result = await listSecrets({
221
+ prefix: 'app/',
222
+ tags: ['env=dev'],
223
+ region: 'us-east-1'
224
+ });
225
+
226
+ expect(result).toHaveLength(1);
227
+ expect(result[0].name).toBe('app/one');
228
+ });
229
+
230
+ it('ignores invalid AWS tags during tag filtering', async () => {
231
+ mockSecretsManagerSend.mockResolvedValueOnce({
232
+ SecretList: [
233
+ {
234
+ Name: 'app/one',
235
+ Tags: [{ Key: undefined, Value: 'dev' }]
236
+ },
237
+ {
238
+ Name: 'app/two',
239
+ Tags: [{ Key: 'env', Value: 'dev' }]
240
+ }
241
+ ]
242
+ });
243
+
244
+ const result = await listSecrets({
245
+ tags: ['env=dev'],
246
+ region: 'us-east-1'
247
+ });
248
+
249
+ expect(result).toHaveLength(1);
250
+ expect(result[0].name).toBe('app/two');
251
+ });
252
+
253
+ it('returns secret metadata without secret value', async () => {
254
+ mockSecretsManagerSend.mockResolvedValueOnce({
255
+ Name: 'app/meta',
256
+ ARN: 'arn:meta',
257
+ Description: 'Meta secret',
258
+ KmsKeyId: 'kms-key',
259
+ CreatedDate: new Date('2026-02-16T00:00:00.000Z'),
260
+ LastChangedDate: new Date('2026-02-16T01:00:00.000Z'),
261
+ VersionIdsToStages: { abc: ['AWSCURRENT'] },
262
+ Tags: [{ Key: 'env', Value: 'dev' }]
263
+ });
264
+
265
+ const result = await getSecretMetadata({
266
+ name: 'app/meta',
267
+ region: 'us-east-1'
268
+ });
269
+
270
+ expect(result).toEqual(
271
+ expect.objectContaining({
272
+ name: 'app/meta',
273
+ arn: 'arn:meta',
274
+ tags: { env: 'dev' },
275
+ versionIdsToStages: { abc: ['AWSCURRENT'] }
276
+ })
277
+ );
278
+ expect(Object.keys(result)).not.toContain('secretString');
279
+ });
280
+
281
+ it('deletes secret with recovery options', async () => {
282
+ const mockCredentials = jest.fn().mockResolvedValue({
283
+ accessKeyId: 'default',
284
+ secretAccessKey: 'default'
285
+ });
286
+ mockFromIni.mockReturnValue(mockCredentials);
287
+
288
+ mockSecretsManagerSend.mockResolvedValueOnce({
289
+ Name: 'app/delete',
290
+ ARN: 'arn:delete',
291
+ DeletionDate: new Date('2026-02-16T02:00:00.000Z')
292
+ });
293
+
294
+ const result = await deleteSecret({
295
+ name: 'app/delete',
296
+ recoveryDays: 7,
297
+ profile: 'test-profile',
298
+ region: 'us-east-1'
299
+ });
300
+
301
+ expect(mockFromIni).toHaveBeenCalledWith({ profile: 'test-profile' });
302
+ expect(mockSecretsManagerSend).toHaveBeenCalledWith(
303
+ expect.objectContaining({
304
+ input: expect.objectContaining({
305
+ SecretId: 'app/delete',
306
+ RecoveryWindowInDays: 7
307
+ })
308
+ })
309
+ );
310
+ expect(result.name).toBe('app/delete');
311
+ });
312
+ });
@@ -1,13 +1,34 @@
1
- import {
2
- SecretsManagerClient,
3
- GetSecretValueCommand
4
- } from '@aws-sdk/client-secrets-manager';
5
- import { STSClient, GetCallerIdentityCommand } from '@aws-sdk/client-sts';
1
+ import { SecretsManagerClient } from '@aws-sdk/client-secrets-manager';
2
+ import { STSClient } from '@aws-sdk/client-sts';
6
3
  import { fromIni } from '@aws-sdk/credential-providers';
7
4
 
5
+ // Mock send functions - must be declared before jest.mock calls
6
+ const mockSecretsManagerSend = jest.fn();
7
+ const mockSTSSend = jest.fn();
8
+
8
9
  // Mock the AWS SDK and dependencies
9
- jest.mock('@aws-sdk/client-secrets-manager');
10
- jest.mock('@aws-sdk/client-sts');
10
+ jest.mock('@aws-sdk/client-secrets-manager', () => {
11
+ return {
12
+ SecretsManagerClient: jest.fn().mockImplementation(() => ({
13
+ send: mockSecretsManagerSend
14
+ })),
15
+ GetSecretValueCommand: jest.fn().mockImplementation((input) => ({
16
+ input
17
+ }))
18
+ };
19
+ });
20
+
21
+ jest.mock('@aws-sdk/client-sts', () => {
22
+ return {
23
+ STSClient: jest.fn().mockImplementation(() => ({
24
+ send: mockSTSSend
25
+ })),
26
+ GetCallerIdentityCommand: jest.fn().mockImplementation((input) => ({
27
+ input
28
+ }))
29
+ };
30
+ });
31
+
11
32
  jest.mock('@aws-sdk/credential-providers');
12
33
  jest.mock('debug', () => {
13
34
  const mockDebug = jest.fn();
@@ -27,8 +48,6 @@ const mockFromIni = fromIni as jest.MockedFunction<typeof fromIni>;
27
48
 
28
49
  describe('secretsmanager', () => {
29
50
  let originalEnv: NodeJS.ProcessEnv;
30
- let mockSecretsManagerSend: jest.MockedFunction<any>;
31
- let mockSTSSend: jest.MockedFunction<any>;
32
51
 
33
52
  beforeEach(() => {
34
53
  // Clear all mocks
@@ -37,13 +56,6 @@ describe('secretsmanager', () => {
37
56
  // Reset process.env
38
57
  originalEnv = { ...process.env };
39
58
  process.env = { ...originalEnv };
40
-
41
- // Setup AWS client mocks
42
- mockSecretsManagerSend = jest.fn();
43
- mockSTSSend = jest.fn();
44
-
45
- mockSecretsManagerClient.prototype.send = mockSecretsManagerSend;
46
- mockSTSClient.prototype.send = mockSTSSend;
47
59
  });
48
60
 
49
61
  afterEach(() => {
@@ -72,10 +84,10 @@ describe('secretsmanager', () => {
72
84
  });
73
85
 
74
86
  expect(mockSTSSend).toHaveBeenCalledWith(
75
- expect.any(GetCallerIdentityCommand)
87
+ expect.objectContaining({ input: {} })
76
88
  );
77
89
  expect(mockSecretsManagerSend).toHaveBeenCalledWith(
78
- expect.any(GetSecretValueCommand)
90
+ expect.objectContaining({ input: expect.anything() })
79
91
  );
80
92
  expect(result).toEqual({
81
93
  API_KEY: 'test-key',
@@ -93,11 +105,33 @@ describe('secretsmanager', () => {
93
105
  });
94
106
 
95
107
  expect(mockSTSSend).toHaveBeenCalledWith(
96
- expect.any(GetCallerIdentityCommand)
108
+ expect.objectContaining({ input: {} })
97
109
  );
98
110
  expect(mockSecretsManagerSend).not.toHaveBeenCalled();
99
111
  expect(result).toEqual({});
100
112
  });
113
+
114
+ it('should surface a clean error when profile credentials cannot be loaded', async () => {
115
+ const consoleErrorSpy = jest
116
+ .spyOn(console, 'error')
117
+ .mockImplementation(() => undefined);
118
+ mockSTSSend.mockRejectedValueOnce({
119
+ name: 'CredentialsProviderError',
120
+ message: 'Could not load credentials from any providers'
121
+ });
122
+
123
+ const result = await secretsmanager({
124
+ secret: 'my-secret',
125
+ profile: 'missing-profile',
126
+ region: 'us-east-1'
127
+ });
128
+
129
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
130
+ 'Could not load credentials from any providers'
131
+ );
132
+ expect(result).toEqual({});
133
+ expect(mockSecretsManagerSend).not.toHaveBeenCalled();
134
+ });
101
135
  });
102
136
 
103
137
  describe('credential handling', () => {
@@ -426,7 +460,10 @@ describe('secretsmanager', () => {
426
460
  });
427
461
 
428
462
  expect(mockFromIni).toHaveBeenCalledWith({ profile: 'production' });
429
- expect(mockSTSClient).toHaveBeenCalledWith({ region: 'us-west-2' });
463
+ expect(mockSTSClient).toHaveBeenCalledWith({
464
+ region: 'us-west-2',
465
+ credentials: mockCredentials
466
+ });
430
467
  expect(mockSecretsManagerClient).toHaveBeenCalledWith({
431
468
  region: 'us-west-2',
432
469
  credentials: mockCredentials
@@ -0,0 +1,110 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.resolveAwsScope = exports.resolveSecretValue = exports.readStdin = exports.parseRecoveryDays = exports.printData = exports.renderTable = exports.asOutputFormat = void 0;
13
+ const asOutputFormat = (value) => {
14
+ if (value !== 'json' && value !== 'table') {
15
+ throw new Error(`Invalid output format "${value}". Use "json" or "table".`);
16
+ }
17
+ return value;
18
+ };
19
+ exports.asOutputFormat = asOutputFormat;
20
+ const renderTable = (headers, rows) => {
21
+ if (rows.length === 0) {
22
+ return 'No results.';
23
+ }
24
+ const widths = headers.map((header) => {
25
+ return Math.max(header.label.length, ...rows.map((row) => String(row[header.key] || '').length));
26
+ });
27
+ const headerLine = headers
28
+ .map((header, index) => header.label.padEnd(widths[index]))
29
+ .join(' ');
30
+ const divider = headers
31
+ .map((_, index) => '-'.repeat(widths[index]))
32
+ .join(' ');
33
+ const lines = rows.map((row) => headers
34
+ .map((header, index) => String(row[header.key] || '').padEnd(widths[index]))
35
+ .join(' '));
36
+ return [headerLine, divider, ...lines].join('\n');
37
+ };
38
+ exports.renderTable = renderTable;
39
+ const printData = (format, headers, rows) => {
40
+ if (format === 'json') {
41
+ // eslint-disable-next-line no-console
42
+ console.log(JSON.stringify(rows, null, 2));
43
+ return;
44
+ }
45
+ // eslint-disable-next-line no-console
46
+ console.log((0, exports.renderTable)(headers, rows));
47
+ };
48
+ exports.printData = printData;
49
+ const parseRecoveryDays = (value) => {
50
+ const parsed = Number(value);
51
+ if (!Number.isInteger(parsed) || parsed < 7 || parsed > 30) {
52
+ throw new Error('Recovery days must be an integer between 7 and 30.');
53
+ }
54
+ return parsed;
55
+ };
56
+ exports.parseRecoveryDays = parseRecoveryDays;
57
+ const readStdin = (stdin = process.stdin) => __awaiter(void 0, void 0, void 0, function* () {
58
+ const chunks = [];
59
+ return yield new Promise((resolve, reject) => {
60
+ const onData = (chunk) => {
61
+ chunks.push(chunk);
62
+ };
63
+ const onEnd = () => {
64
+ cleanup();
65
+ resolve(Buffer.concat(chunks)
66
+ .toString('utf8')
67
+ .replace(/\r?\n$/, ''));
68
+ };
69
+ const onError = (error) => {
70
+ cleanup();
71
+ reject(error);
72
+ };
73
+ const cleanup = () => {
74
+ stdin.off('data', onData);
75
+ stdin.off('end', onEnd);
76
+ stdin.off('error', onError);
77
+ };
78
+ stdin.on('data', onData);
79
+ stdin.once('end', onEnd);
80
+ stdin.once('error', onError);
81
+ });
82
+ });
83
+ exports.readStdin = readStdin;
84
+ const resolveSecretValue = (value, valueStdin) => __awaiter(void 0, void 0, void 0, function* () {
85
+ if (value && valueStdin) {
86
+ throw new Error('Use either --value or --value-stdin, not both.');
87
+ }
88
+ if (valueStdin) {
89
+ if (process.stdin.isTTY) {
90
+ throw new Error('No stdin detected. Pipe a value when using --value-stdin.');
91
+ }
92
+ return yield (0, exports.readStdin)();
93
+ }
94
+ return value;
95
+ });
96
+ exports.resolveSecretValue = resolveSecretValue;
97
+ const resolveAwsScope = (options, command) => {
98
+ var _a;
99
+ const globalOptions = ((_a = command === null || command === void 0 ? void 0 : command.optsWithGlobals) === null || _a === void 0 ? void 0 : _a.call(command)) || {};
100
+ const profile = options.profile ||
101
+ (typeof globalOptions.profile === 'string'
102
+ ? globalOptions.profile
103
+ : undefined);
104
+ const region = options.region ||
105
+ (typeof globalOptions.region === 'string'
106
+ ? globalOptions.region
107
+ : undefined);
108
+ return { profile, region };
109
+ };
110
+ exports.resolveAwsScope = resolveAwsScope;