env-secrets 0.4.0 → 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
@@ -133,6 +133,9 @@ yarn test:unit:coverage # Run tests with coverage
133
133
  4. Ensure all CI checks pass
134
134
  5. Submit a pull request with a clear description
135
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
136
139
 
137
140
  ## Development Environment
138
141
 
package/README.md CHANGED
@@ -103,7 +103,7 @@ 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:
106
+ For `aws secret` management subcommands (`create`, `update`, `append`, `remove`, `upsert`/`import`, `list`, `get`, `delete`), use:
107
107
 
108
108
  - `-r, --region <region>` to target a specific region
109
109
  - `-p, --profile <profile>` to select credentials profile
@@ -111,6 +111,9 @@ For `aws secret` management subcommands (`create`, `update`, `list`, `get`, `del
111
111
 
112
112
  These options are honored consistently on `aws secret` subcommands.
113
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
+
114
117
  #### Examples
115
118
 
116
119
  1. **Create a secret using AWS CLI:**
@@ -217,6 +220,42 @@ env-secrets aws -s my-secret -r us-east-1 -o secrets.env
217
220
  # Error: File secrets.env already exists and will not be overwritten
218
221
  ```
219
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
+
220
259
  ## Security Considerations
221
260
 
222
261
  - 🔐 **Credential Management**: The tool respects AWS credential precedence (environment variables, IAM roles, profiles)
@@ -8,7 +8,8 @@ import {
8
8
  createTestProfile,
9
9
  restoreTestProfile,
10
10
  TestSecret,
11
- CreatedSecret
11
+ CreatedSecret,
12
+ execAwslocalCommand
12
13
  } from './utils/test-utils';
13
14
  import { debugLog } from './utils/debug-logger';
14
15
  import * as fs from 'fs';
@@ -417,6 +418,97 @@ describe('End-to-End Tests', () => {
417
418
  expect(deleteResult.code).toBe(0);
418
419
  });
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
+
420
512
  test('should require confirmation for delete', async () => {
421
513
  const secret = await createTestSecret({
422
514
  name: `managed-secret-confirm-${Date.now()}`,
@@ -485,6 +577,102 @@ describe('End-to-End Tests', () => {
485
577
  }>;
486
578
  expect(eastRows).toEqual([]);
487
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);
675
+ });
488
676
  });
489
677
  });
490
678
  });
@@ -1,7 +1,11 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
1
4
  import { PassThrough } from 'node:stream';
2
5
 
3
6
  import {
4
7
  asOutputFormat,
8
+ parseEnvSecrets,
5
9
  parseRecoveryDays,
6
10
  printData,
7
11
  readStdin,
@@ -73,7 +77,29 @@ describe('cli/helpers', () => {
73
77
 
74
78
  it('rejects when both --value and --value-stdin are used', async () => {
75
79
  await expect(resolveSecretValue('inline', true)).rejects.toThrow(
76
- 'Use either --value or --value-stdin, not both.'
80
+ 'Use only one secret value source: --value, --value-stdin, or --file.'
81
+ );
82
+ });
83
+
84
+ it('rejects when --file and --value-stdin are used together', async () => {
85
+ await expect(
86
+ resolveSecretValue(undefined, true, './secret.txt')
87
+ ).rejects.toThrow(
88
+ 'Use only one secret value source: --value, --value-stdin, or --file.'
89
+ );
90
+ });
91
+
92
+ it('rejects when --value and --file are used together', async () => {
93
+ await expect(
94
+ resolveSecretValue('inline', false, './secret.txt')
95
+ ).rejects.toThrow(
96
+ 'Use only one secret value source: --value, --value-stdin, or --file.'
97
+ );
98
+ });
99
+
100
+ it('treats explicit empty --value as provided for mutual exclusion', async () => {
101
+ await expect(resolveSecretValue('', false, './secret.txt')).rejects.toThrow(
102
+ 'Use only one secret value source: --value, --value-stdin, or --file.'
77
103
  );
78
104
  });
79
105
 
@@ -94,6 +120,68 @@ describe('cli/helpers', () => {
94
120
  });
95
121
  });
96
122
 
123
+ it('reads secret value from file and strips one trailing newline', async () => {
124
+ const dir = mkdtempSync(join(tmpdir(), 'env-secrets-test-'));
125
+ const file = join(dir, 'secret.txt');
126
+ writeFileSync(file, 'file-secret\n');
127
+
128
+ await expect(resolveSecretValue(undefined, false, file)).resolves.toBe(
129
+ 'file-secret'
130
+ );
131
+
132
+ rmSync(dir, { recursive: true, force: true });
133
+ });
134
+
135
+ it('parses env secrets from KEY=value and export KEY=value formats', () => {
136
+ const parsed = parseEnvSecrets(
137
+ [
138
+ '# comment',
139
+ 'export API_KEY=secret1',
140
+ 'DATABASE_URL=postgres://db',
141
+ ''
142
+ ].join('\n')
143
+ );
144
+
145
+ expect(parsed.entries).toEqual([
146
+ { key: 'API_KEY', value: 'secret1', line: 2 },
147
+ { key: 'DATABASE_URL', value: 'postgres://db', line: 3 }
148
+ ]);
149
+ expect(parsed.skipped).toEqual([]);
150
+ });
151
+
152
+ it('handles whitespace around equals in env file', () => {
153
+ const parsed = parseEnvSecrets(' export NAME = secret1 ');
154
+
155
+ expect(parsed.entries).toEqual([
156
+ { key: 'NAME', value: 'secret1', line: 1 }
157
+ ]);
158
+ });
159
+
160
+ it('skips duplicate keys when parsing env file', () => {
161
+ const parsed = parseEnvSecrets(['A=1', 'A=2'].join('\n'));
162
+
163
+ expect(parsed.entries).toEqual([{ key: 'A', value: '1', line: 1 }]);
164
+ expect(parsed.skipped).toEqual([
165
+ { key: 'A', line: 2, reason: 'duplicate key' }
166
+ ]);
167
+ });
168
+
169
+ it('throws clear error for malformed env lines', () => {
170
+ expect(() => parseEnvSecrets('NOT_A_VALID_LINE')).toThrow(
171
+ 'Malformed env line 1'
172
+ );
173
+ expect(() => parseEnvSecrets('BAD=secret')).not.toThrow();
174
+ });
175
+
176
+ it('does not include raw line content in malformed env errors', () => {
177
+ expect(() => parseEnvSecrets('export SECRET_ONLY')).toThrow(
178
+ 'Expected KEY=value or export KEY=value.'
179
+ );
180
+ expect(() => parseEnvSecrets('export SECRET_ONLY')).not.toThrow(
181
+ /SECRET_ONLY/
182
+ );
183
+ });
184
+
97
185
  it('prefers explicit aws scope options over global options', () => {
98
186
  const command = {
99
187
  optsWithGlobals: () => ({
@@ -12,6 +12,7 @@ jest.mock('@aws-sdk/client-secrets-manager', () => {
12
12
  })),
13
13
  CreateSecretCommand: jest.fn().mockImplementation((input) => ({ input })),
14
14
  UpdateSecretCommand: jest.fn().mockImplementation((input) => ({ input })),
15
+ GetSecretValueCommand: jest.fn().mockImplementation((input) => ({ input })),
15
16
  ListSecretsCommand: jest.fn().mockImplementation((input) => ({ input })),
16
17
  DescribeSecretCommand: jest.fn().mockImplementation((input) => ({ input })),
17
18
  DeleteSecretCommand: jest.fn().mockImplementation((input) => ({ input }))
@@ -37,6 +38,8 @@ import {
37
38
  listSecrets,
38
39
  getSecretMetadata,
39
40
  deleteSecret,
41
+ secretExists,
42
+ getSecretString,
40
43
  validateSecretName
41
44
  } from '../../src/vaults/secretsmanager-admin';
42
45
 
@@ -278,6 +281,46 @@ describe('secretsmanager-admin', () => {
278
281
  expect(Object.keys(result)).not.toContain('secretString');
279
282
  });
280
283
 
284
+ it('returns true when secret exists', async () => {
285
+ mockSecretsManagerSend.mockResolvedValueOnce({
286
+ Name: 'app/existing'
287
+ });
288
+
289
+ await expect(
290
+ secretExists({ name: 'app/existing', region: 'us-east-1' })
291
+ ).resolves.toBe(true);
292
+ });
293
+
294
+ it('returns false when secret does not exist', async () => {
295
+ mockSecretsManagerSend.mockRejectedValueOnce({
296
+ name: 'ResourceNotFoundException'
297
+ });
298
+
299
+ await expect(
300
+ secretExists({ name: 'app/missing', region: 'us-east-1' })
301
+ ).resolves.toBe(false);
302
+ });
303
+
304
+ it('gets current secret string value', async () => {
305
+ mockSecretsManagerSend.mockResolvedValueOnce({
306
+ SecretString: '{"API_KEY":"abc"}'
307
+ });
308
+
309
+ await expect(
310
+ getSecretString({ name: 'app/existing', region: 'us-east-1' })
311
+ ).resolves.toBe('{"API_KEY":"abc"}');
312
+ });
313
+
314
+ it('rejects binary/non-string secrets for append/remove workflows', async () => {
315
+ mockSecretsManagerSend.mockResolvedValueOnce({
316
+ SecretBinary: new Uint8Array([1, 2, 3])
317
+ });
318
+
319
+ await expect(
320
+ getSecretString({ name: 'app/existing', region: 'us-east-1' })
321
+ ).rejects.toThrow('cannot be edited with append/remove');
322
+ });
323
+
281
324
  it('deletes secret with recovery options', async () => {
282
325
  const mockCredentials = jest.fn().mockResolvedValue({
283
326
  accessKeyId: 'default',
@@ -9,7 +9,8 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
9
9
  });
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
- exports.resolveAwsScope = exports.resolveSecretValue = exports.readStdin = exports.parseRecoveryDays = exports.printData = exports.renderTable = exports.asOutputFormat = void 0;
12
+ exports.resolveAwsScope = exports.parseEnvSecretsFile = exports.parseEnvSecrets = exports.resolveSecretValue = exports.readStdin = exports.parseRecoveryDays = exports.printData = exports.renderTable = exports.asOutputFormat = void 0;
13
+ const promises_1 = require("node:fs/promises");
13
14
  const asOutputFormat = (value) => {
14
15
  if (value !== 'json' && value !== 'table') {
15
16
  throw new Error(`Invalid output format "${value}". Use "json" or "table".`);
@@ -81,9 +82,14 @@ const readStdin = (stdin = process.stdin) => __awaiter(void 0, void 0, void 0, f
81
82
  });
82
83
  });
83
84
  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.');
85
+ const resolveSecretValue = (value, valueStdin, valueFile) => __awaiter(void 0, void 0, void 0, function* () {
86
+ const providedSources = [
87
+ value !== undefined,
88
+ valueStdin === true,
89
+ valueFile !== undefined
90
+ ].filter(Boolean).length;
91
+ if (providedSources > 1) {
92
+ throw new Error('Use only one secret value source: --value, --value-stdin, or --file.');
87
93
  }
88
94
  if (valueStdin) {
89
95
  if (process.stdin.isTTY) {
@@ -91,9 +97,65 @@ const resolveSecretValue = (value, valueStdin) => __awaiter(void 0, void 0, void
91
97
  }
92
98
  return yield (0, exports.readStdin)();
93
99
  }
100
+ if (valueFile) {
101
+ const content = yield (0, promises_1.readFile)(valueFile, 'utf8');
102
+ return content.replace(/\r?\n$/, '');
103
+ }
94
104
  return value;
95
105
  });
96
106
  exports.resolveSecretValue = resolveSecretValue;
107
+ const parseEnvLine = (line, lineNumber) => {
108
+ const trimmed = line.trim();
109
+ if (!trimmed || trimmed.startsWith('#')) {
110
+ return undefined;
111
+ }
112
+ const candidate = trimmed.startsWith('export ')
113
+ ? trimmed.slice('export '.length).trimStart()
114
+ : trimmed;
115
+ const separatorIndex = candidate.indexOf('=');
116
+ if (separatorIndex <= 0) {
117
+ throw new Error(`Malformed env line ${lineNumber}. Expected KEY=value or export KEY=value.`);
118
+ }
119
+ const key = candidate.slice(0, separatorIndex).trim();
120
+ const value = candidate.slice(separatorIndex + 1).trim();
121
+ if (!key) {
122
+ throw new Error(`Malformed env line ${lineNumber}. Expected KEY=value or export KEY=value.`);
123
+ }
124
+ return { key, value };
125
+ };
126
+ const parseEnvSecrets = (content) => {
127
+ const seenKeys = new Set();
128
+ const entries = [];
129
+ const skipped = [];
130
+ const lines = content.split(/\r?\n/);
131
+ for (let index = 0; index < lines.length; index += 1) {
132
+ const parsed = parseEnvLine(lines[index], index + 1);
133
+ if (!parsed) {
134
+ continue;
135
+ }
136
+ if (seenKeys.has(parsed.key)) {
137
+ skipped.push({
138
+ key: parsed.key,
139
+ line: index + 1,
140
+ reason: 'duplicate key'
141
+ });
142
+ continue;
143
+ }
144
+ seenKeys.add(parsed.key);
145
+ entries.push({
146
+ key: parsed.key,
147
+ value: parsed.value,
148
+ line: index + 1
149
+ });
150
+ }
151
+ return { entries, skipped };
152
+ };
153
+ exports.parseEnvSecrets = parseEnvSecrets;
154
+ const parseEnvSecretsFile = (path) => __awaiter(void 0, void 0, void 0, function* () {
155
+ const content = yield (0, promises_1.readFile)(path, 'utf8');
156
+ return (0, exports.parseEnvSecrets)(content);
157
+ });
158
+ exports.parseEnvSecretsFile = parseEnvSecretsFile;
97
159
  const resolveAwsScope = (options, command) => {
98
160
  var _a;
99
161
  const globalOptions = ((_a = command === null || command === void 0 ? void 0 : command.optsWithGlobals) === null || _a === void 0 ? void 0 : _a.call(command)) || {};