env-secrets 0.5.1 → 0.5.3

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.
@@ -34,7 +34,7 @@ jobs:
34
34
  working-directory: website
35
35
  run: yarn build
36
36
  - name: Upload artifact
37
- uses: actions/upload-pages-artifact@v4
37
+ uses: actions/upload-pages-artifact@v5
38
38
  with:
39
39
  path: website/build
40
40
 
package/README.md CHANGED
@@ -246,6 +246,12 @@ env-secrets aws secret upsert --file .env --name app/dev --output json
246
246
  # {"NAME":"secret1"}
247
247
  ```
248
248
 
249
+ `env-secrets aws secret create` also always stores `SecretString` as a JSON object:
250
+
251
+ - object JSON input is preserved semantically as an object (formatting and key order may change)
252
+ - dotenv-style input is converted into key/value pairs
253
+ - scalar input is wrapped as `{"value":"..."}`.
254
+
249
255
  12. **Append/remove keys on an existing JSON secret:**
250
256
 
251
257
  ```bash
@@ -1,4 +1,4 @@
1
- import { cliWithEnv } from './utils/test-utils';
1
+ import { cliWithEnv, cliWithRealSpawn } from './utils/test-utils';
2
2
  import { registerAwsE2eContext } from './utils/aws-e2e-context';
3
3
 
4
4
  describe('AWS Program Execution CLI Args', () => {
@@ -42,3 +42,99 @@ describe('AWS Program Execution CLI Args', () => {
42
42
  expect(envVars.API_KEY).toBe('secret123');
43
43
  });
44
44
  });
45
+
46
+ describe('AWS Real Spawn Execution (no NODE_ENV=test)', () => {
47
+ const { createTestSecret, getLocalStackEnv } = registerAwsE2eContext();
48
+
49
+ test('injected env vars are visible to the spawned child process (shell mode)', async () => {
50
+ const secret = await createTestSecret({
51
+ name: `test-secret-realspawn-${Date.now()}`,
52
+ value: '{"INJECTED_KEY": "injected_value"}',
53
+ description: 'Real spawn env injection test'
54
+ });
55
+
56
+ // printenv avoids any shell-quoting complexity in the test command string
57
+ const result = await cliWithRealSpawn(
58
+ ['aws', '-s', secret.prefixedName, '--', 'printenv', 'INJECTED_KEY'],
59
+ getLocalStackEnv()
60
+ );
61
+
62
+ expect(result.code).toBe(0);
63
+ expect(result.stdout.trim()).toBe('injected_value');
64
+ });
65
+
66
+ test('exit code of child process is propagated on success', async () => {
67
+ const secret = await createTestSecret({
68
+ name: `test-secret-exitcode-ok-${Date.now()}`,
69
+ value: '{"DUMMY": "1"}',
70
+ description: 'Exit code propagation test (success)'
71
+ });
72
+
73
+ // Use --no-shell so node -e args are passed directly without shell re-parsing
74
+ const result = await cliWithRealSpawn(
75
+ [
76
+ 'aws',
77
+ '-s',
78
+ secret.prefixedName,
79
+ '--no-shell',
80
+ '--',
81
+ 'node',
82
+ '-e',
83
+ 'process.exit(0)'
84
+ ],
85
+ getLocalStackEnv()
86
+ );
87
+
88
+ expect(result.code).toBe(0);
89
+ });
90
+
91
+ test('exit code of child process is propagated on failure', async () => {
92
+ const secret = await createTestSecret({
93
+ name: `test-secret-exitcode-fail-${Date.now()}`,
94
+ value: '{"DUMMY": "1"}',
95
+ description: 'Exit code propagation test (failure)'
96
+ });
97
+
98
+ // Use --no-shell so node -e args are passed directly without shell re-parsing
99
+ const result = await cliWithRealSpawn(
100
+ [
101
+ 'aws',
102
+ '-s',
103
+ secret.prefixedName,
104
+ '--no-shell',
105
+ '--',
106
+ 'node',
107
+ '-e',
108
+ 'process.exit(42)'
109
+ ],
110
+ getLocalStackEnv()
111
+ );
112
+
113
+ expect(result.code).toBe(42);
114
+ });
115
+
116
+ test('--no-shell passes args directly and env is injected', async () => {
117
+ const secret = await createTestSecret({
118
+ name: `test-secret-noshell-${Date.now()}`,
119
+ value: '{"INJECTED_KEY": "direct_value"}',
120
+ description: 'No-shell spawn test'
121
+ });
122
+
123
+ // printenv avoids any shell-quoting complexity in the test command string
124
+ const result = await cliWithRealSpawn(
125
+ [
126
+ 'aws',
127
+ '-s',
128
+ secret.prefixedName,
129
+ '--no-shell',
130
+ '--',
131
+ 'printenv',
132
+ 'INJECTED_KEY'
133
+ ],
134
+ getLocalStackEnv()
135
+ );
136
+
137
+ expect(result.code).toBe(0);
138
+ expect(result.stdout.trim()).toBe('direct_value');
139
+ });
140
+ });
@@ -5,7 +5,8 @@ import * as path from 'path';
5
5
  import {
6
6
  cliWithEnv,
7
7
  cliWithEnvAndStdin,
8
- cleanupTempFile
8
+ cleanupTempFile,
9
+ execAwslocalCommand
9
10
  } from './utils/test-utils';
10
11
  import { registerAwsE2eContext } from './utils/aws-e2e-context';
11
12
 
@@ -38,6 +39,14 @@ describe('AWS Secret Subcommand Lifecycle Args', () => {
38
39
  expect(getResult.stdout).toContain(secretName);
39
40
  expect(getResult.stdout).not.toContain('initial-value');
40
41
 
42
+ const createdSecret = await execAwslocalCommand(
43
+ `awslocal secretsmanager get-secret-value --secret-id "${secretName}" --region us-east-1 --query SecretString --output text`,
44
+ getLocalStackEnv()
45
+ );
46
+ expect(JSON.parse(createdSecret.stdout.trim())).toEqual({
47
+ value: 'initial-value'
48
+ });
49
+
41
50
  const deleteResult = await cliWithEnv(
42
51
  [
43
52
  'aws',
@@ -84,6 +93,39 @@ describe('AWS Secret Subcommand Lifecycle Args', () => {
84
93
  expect(deleteResult.code).toBe(0);
85
94
  });
86
95
 
96
+ test('should create a json secret object from stdin', async () => {
97
+ const secretName = `managed-secret-create-stdin-${Date.now()}`;
98
+
99
+ const createResult = await cliWithEnvAndStdin(
100
+ ['aws', 'secret', 'create', '-n', secretName, '--value-stdin'],
101
+ getLocalStackEnv(),
102
+ 'stdin-create-value'
103
+ );
104
+ expect(createResult.code).toBe(0);
105
+
106
+ const createdSecret = await execAwslocalCommand(
107
+ `awslocal secretsmanager get-secret-value --secret-id "${secretName}" --region us-east-1 --query SecretString --output text`,
108
+ getLocalStackEnv()
109
+ );
110
+ expect(JSON.parse(createdSecret.stdout.trim())).toEqual({
111
+ value: 'stdin-create-value'
112
+ });
113
+
114
+ const deleteResult = await cliWithEnv(
115
+ [
116
+ 'aws',
117
+ 'secret',
118
+ 'delete',
119
+ '-n',
120
+ secretName,
121
+ '--force-delete-without-recovery',
122
+ '--yes'
123
+ ],
124
+ getLocalStackEnv()
125
+ );
126
+ expect(deleteResult.code).toBe(0);
127
+ });
128
+
87
129
  test('should create from a single-line env file and retrieve via aws -s', async () => {
88
130
  const secretName = `managed-secret-create-single-file-${Date.now()}`;
89
131
  const tempFile = path.join(
@@ -119,6 +161,14 @@ describe('AWS Secret Subcommand Lifecycle Args', () => {
119
161
  >;
120
162
  expect(envVars.GITHUB_PAT).toBe('github_pat_single_line');
121
163
 
164
+ const createdSecret = await execAwslocalCommand(
165
+ `awslocal secretsmanager get-secret-value --secret-id "${secretName}" --region us-east-1 --query SecretString --output text`,
166
+ getLocalStackEnv()
167
+ );
168
+ expect(JSON.parse(createdSecret.stdout.trim())).toEqual({
169
+ GITHUB_PAT: 'github_pat_single_line'
170
+ });
171
+
122
172
  const deleteResult = await cliWithEnv(
123
173
  [
124
174
  'aws',
@@ -176,6 +226,15 @@ describe('AWS Secret Subcommand Lifecycle Args', () => {
176
226
  expect(envVars.GITHUB_PAT).toBe('github_pat_multi_line');
177
227
  expect(envVars.API_URL).toBe('https://example.com');
178
228
 
229
+ const createdSecret = await execAwslocalCommand(
230
+ `awslocal secretsmanager get-secret-value --secret-id "${secretName}" --region us-east-1 --query SecretString --output text`,
231
+ getLocalStackEnv()
232
+ );
233
+ expect(JSON.parse(createdSecret.stdout.trim())).toEqual({
234
+ GITHUB_PAT: 'github_pat_multi_line',
235
+ API_URL: 'https://example.com'
236
+ });
237
+
179
238
  const deleteResult = await cliWithEnv(
180
239
  [
181
240
  'aws',
@@ -654,6 +654,84 @@ export function restoreTestProfile(
654
654
  }
655
655
  }
656
656
 
657
+ /**
658
+ * Like cliWithEnv but does NOT set NODE_ENV=test, so the real spawn path in
659
+ * src/index.ts is exercised instead of the early-return test branch.
660
+ */
661
+ export async function cliWithRealSpawn(
662
+ args: string[],
663
+ env: Record<string, string>,
664
+ cwd = '.'
665
+ ): Promise<CliResult> {
666
+ return new Promise((resolve) => {
667
+ const cleanEnv = { ...process.env };
668
+ delete cleanEnv.AWS_PROFILE;
669
+ delete cleanEnv.AWS_DEFAULT_PROFILE;
670
+ delete cleanEnv.AWS_SESSION_TOKEN;
671
+ delete cleanEnv.AWS_SECURITY_TOKEN;
672
+ delete cleanEnv.AWS_ROLE_ARN;
673
+ delete cleanEnv.AWS_ROLE_SESSION_NAME;
674
+ delete cleanEnv.AWS_WEB_IDENTITY_TOKEN_FILE;
675
+ delete cleanEnv.AWS_WEB_IDENTITY_TOKEN;
676
+ // Deliberately omit NODE_ENV=test so real spawn is used
677
+ delete cleanEnv.NODE_ENV;
678
+
679
+ const defaultEnv = {
680
+ AWS_ENDPOINT_URL: process.env.LOCALSTACK_URL || 'http://localhost:4566',
681
+ AWS_ACCESS_KEY_ID: 'test',
682
+ AWS_SECRET_ACCESS_KEY: 'test',
683
+ AWS_DEFAULT_REGION: 'us-east-1',
684
+ AWS_REGION: 'us-east-1'
685
+ };
686
+
687
+ const envVars = { ...cleanEnv, ...defaultEnv, ...env };
688
+ const cliPath = path.resolve('./dist/index');
689
+ const spawnArgs = [cliPath, ...args];
690
+ const command = `node ${cliPath} ${args.join(' ')}`;
691
+
692
+ debugLog(`Running CLI command (real spawn): ${command}`);
693
+
694
+ const child = spawn('node', spawnArgs, { cwd, env: envVars });
695
+
696
+ let stdout = '';
697
+ let stderr = '';
698
+ child.stdout.on('data', (chunk: Buffer) => {
699
+ stdout += chunk.toString();
700
+ });
701
+ child.stderr.on('data', (chunk: Buffer) => {
702
+ stderr += chunk.toString();
703
+ });
704
+
705
+ child.on('close', (code: number | null, signal: NodeJS.Signals | null) => {
706
+ const exitCode = code ?? (signal ? 1 : 0);
707
+ const errorMessage = signal
708
+ ? `Process terminated by signal ${signal}`
709
+ : exitCode !== 0
710
+ ? `Process exited with code ${exitCode}`
711
+ : null;
712
+ const result = {
713
+ code: exitCode,
714
+ error: errorMessage ? new Error(errorMessage) : null,
715
+ stdout,
716
+ stderr
717
+ };
718
+
719
+ if (exitCode !== 0) {
720
+ debugError(
721
+ signal
722
+ ? `CLI command failed: terminated by signal ${signal}`
723
+ : `CLI command failed with code ${exitCode}`
724
+ );
725
+ debugError(`Command: ${command}`);
726
+ debugError(`Stdout: ${result.stdout}`);
727
+ debugError(`Stderr: ${result.stderr}`);
728
+ }
729
+
730
+ resolve(result);
731
+ });
732
+ });
733
+ }
734
+
657
735
  export async function checkAwslocalInstalled(): Promise<void> {
658
736
  try {
659
737
  await execAwslocalCommand('awslocal --version', {});
@@ -39,6 +39,24 @@ const mockObjectToExport = objectToExport as jest.MockedFunction<
39
39
  typeof objectToExport
40
40
  >;
41
41
 
42
+ // Build a ChildProcess-like mock that records event handlers
43
+ function makeChildMock() {
44
+ const handlers: Record<string, ((...args: unknown[]) => void)[]> = {};
45
+ const child = {
46
+ stdio: 'inherit',
47
+ on: jest.fn((event: string, cb: (...args: unknown[]) => void) => {
48
+ if (!handlers[event]) handlers[event] = [];
49
+ handlers[event].push(cb);
50
+ return child;
51
+ }),
52
+ emit(event: string, ...args: unknown[]) {
53
+ (handlers[event] ?? []).forEach((cb) => cb(...args));
54
+ }
55
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
56
+ } as any;
57
+ return child;
58
+ }
59
+
42
60
  describe('index.ts CLI functionality', () => {
43
61
  beforeEach(() => {
44
62
  jest.clearAllMocks();
@@ -94,43 +112,67 @@ describe('index.ts CLI functionality', () => {
94
112
  );
95
113
  });
96
114
 
97
- it('should spawn a program when provided', async () => {
115
+ it('should spawn using shell (default) with joined command string', async () => {
98
116
  const mockEnv = { SECRET_KEY: 'secret_value' };
99
117
  mockSecretsmanager.mockResolvedValue(mockEnv);
100
118
 
101
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
102
- const mockChildProcess = {
103
- stdio: 'inherit'
104
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
105
- } as any;
106
- mockSpawn.mockReturnValue(mockChildProcess);
119
+ const child = makeChildMock();
120
+ mockSpawn.mockReturnValue(child);
107
121
 
108
122
  const program = ['node', 'script.js', 'arg1', 'arg2'];
109
- const options = { secret: 'my-secret' };
123
+ const options = { secret: 'my-secret', shell: true };
110
124
 
111
- // Simulate the action logic
112
125
  let env = await mockSecretsmanager(options);
113
126
  env = Object.assign({}, process.env, env);
114
127
 
115
128
  if (program && program.length > 0) {
116
- mockSpawn(program[0], program.slice(1), {
129
+ mockSpawn(program.join(' '), [], {
117
130
  stdio: 'inherit',
118
131
  shell: true,
119
132
  env
120
133
  });
121
134
  }
122
135
 
123
- expect(mockSpawn).toHaveBeenCalledWith(
124
- 'node',
125
- ['script.js', 'arg1', 'arg2'],
126
- {
127
- stdio: 'inherit',
128
- shell: true,
129
- env: expect.objectContaining({
130
- SECRET_KEY: 'secret_value'
131
- })
136
+ expect(mockSpawn).toHaveBeenCalledWith('node script.js arg1 arg2', [], {
137
+ stdio: 'inherit',
138
+ shell: true,
139
+ env: expect.objectContaining({
140
+ SECRET_KEY: 'secret_value'
141
+ })
142
+ });
143
+ });
144
+
145
+ it('should spawn without shell when --no-shell is passed', async () => {
146
+ const mockEnv = { SECRET_KEY: 'secret_value' };
147
+ mockSecretsmanager.mockResolvedValue(mockEnv);
148
+
149
+ const child = makeChildMock();
150
+ mockSpawn.mockReturnValue(child);
151
+
152
+ const program = ['node', 'script.js', 'arg1'];
153
+ const options = { secret: 'my-secret', shell: false };
154
+
155
+ let env = await mockSecretsmanager(options);
156
+ env = Object.assign({}, process.env, env);
157
+
158
+ if (program && program.length > 0) {
159
+ if (options.shell) {
160
+ mockSpawn(program.join(' '), [], {
161
+ stdio: 'inherit',
162
+ shell: true,
163
+ env
164
+ });
165
+ } else {
166
+ mockSpawn(program[0], program.slice(1), { stdio: 'inherit', env });
132
167
  }
133
- );
168
+ }
169
+
170
+ expect(mockSpawn).toHaveBeenCalledWith('node', ['script.js', 'arg1'], {
171
+ stdio: 'inherit',
172
+ env: expect.objectContaining({
173
+ SECRET_KEY: 'secret_value'
174
+ })
175
+ });
134
176
  });
135
177
 
136
178
  it('should not spawn a program when no program is provided', async () => {
@@ -143,7 +185,7 @@ describe('index.ts CLI functionality', () => {
143
185
 
144
186
  const program: string[] = [];
145
187
  if (program && program.length > 0) {
146
- mockSpawn(program[0], program.slice(1), {
188
+ mockSpawn(program.join(' '), [], {
147
189
  stdio: 'inherit',
148
190
  shell: true,
149
191
  env
@@ -163,7 +205,7 @@ describe('index.ts CLI functionality', () => {
163
205
 
164
206
  const program: string[] = [];
165
207
  if (program && program.length > 0) {
166
- mockSpawn(program[0], program.slice(1), {
208
+ mockSpawn(program.join(' '), [], {
167
209
  stdio: 'inherit',
168
210
  shell: true,
169
211
  env
@@ -173,26 +215,21 @@ describe('index.ts CLI functionality', () => {
173
215
  expect(mockSpawn).not.toHaveBeenCalled();
174
216
  });
175
217
 
176
- it('should handle single program argument', async () => {
218
+ it('should handle single program argument (shell mode)', async () => {
177
219
  const mockEnv = { SECRET_KEY: 'secret_value' };
178
220
  mockSecretsmanager.mockResolvedValue(mockEnv);
179
221
 
180
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
181
- const mockChildProcess = {
182
- stdio: 'inherit'
183
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
184
- } as any;
185
- mockSpawn.mockReturnValue(mockChildProcess);
222
+ const child = makeChildMock();
223
+ mockSpawn.mockReturnValue(child);
186
224
 
187
225
  const program = ['echo'];
188
- const options = { secret: 'my-secret' };
226
+ const options = { secret: 'my-secret', shell: true };
189
227
 
190
- // Simulate the action logic
191
228
  let env = await mockSecretsmanager(options);
192
229
  env = Object.assign({}, process.env, env);
193
230
 
194
231
  if (program && program.length > 0) {
195
- mockSpawn(program[0], program.slice(1), {
232
+ mockSpawn(program.join(' '), [], {
196
233
  stdio: 'inherit',
197
234
  shell: true,
198
235
  env
@@ -219,13 +256,12 @@ describe('index.ts CLI functionality', () => {
219
256
 
220
257
  mockSecretsmanager.mockResolvedValue(mockSecrets);
221
258
 
222
- // Simulate the action logic
223
259
  let env = await mockSecretsmanager({ secret: 'my-secret' });
224
260
  env = Object.assign({}, process.env, env);
225
261
 
226
262
  const program = ['echo'];
227
263
  if (program && program.length > 0) {
228
- mockSpawn(program[0], program.slice(1), {
264
+ mockSpawn(program.join(' '), [], {
229
265
  stdio: 'inherit',
230
266
  shell: true,
231
267
  env
@@ -247,13 +283,12 @@ describe('index.ts CLI functionality', () => {
247
283
  it('should handle secretsmanager returning empty object', async () => {
248
284
  mockSecretsmanager.mockResolvedValue({});
249
285
 
250
- // Simulate the action logic
251
286
  let env = await mockSecretsmanager({ secret: 'my-secret' });
252
287
  env = Object.assign({}, process.env, env);
253
288
 
254
289
  const program = ['echo'];
255
290
  if (program && program.length > 0) {
256
- mockSpawn(program[0], program.slice(1), {
291
+ mockSpawn(program.join(' '), [], {
257
292
  stdio: 'inherit',
258
293
  shell: true,
259
294
  env
@@ -276,6 +311,128 @@ describe('index.ts CLI functionality', () => {
276
311
  'AWS connection failed'
277
312
  );
278
313
  });
314
+
315
+ describe('ChildProcess error and exit handling', () => {
316
+ beforeEach(() => {
317
+ jest.spyOn(process, 'exit').mockImplementation(() => {
318
+ throw new Error('process.exit called');
319
+ });
320
+ jest.spyOn(console, 'error').mockImplementation(() => undefined);
321
+ });
322
+
323
+ afterEach(() => {
324
+ jest.restoreAllMocks();
325
+ });
326
+
327
+ it('should print error message and exit 1 on child process error', async () => {
328
+ const mockEnv = { SECRET_KEY: 'secret_value' };
329
+ mockSecretsmanager.mockResolvedValue(mockEnv);
330
+
331
+ const child = makeChildMock();
332
+ mockSpawn.mockReturnValue(child);
333
+
334
+ let env = await mockSecretsmanager({ secret: 'my-secret' });
335
+ env = Object.assign({}, process.env, env);
336
+
337
+ const program = ['nonexistent-cmd'];
338
+ mockSpawn(program.join(' '), [], {
339
+ stdio: 'inherit',
340
+ shell: true,
341
+ env
342
+ });
343
+
344
+ // Simulate attaching handlers as src/index.ts does
345
+ child.on('error', (err: Error) => {
346
+ // eslint-disable-next-line no-console
347
+ console.error(`Failed to start process: ${err.message}`);
348
+ process.exit(1);
349
+ });
350
+
351
+ expect(() => child.emit('error', new Error('spawn ENOENT'))).toThrow(
352
+ 'process.exit called'
353
+ );
354
+
355
+ // eslint-disable-next-line no-console
356
+ expect(console.error).toHaveBeenCalledWith(
357
+ 'Failed to start process: spawn ENOENT'
358
+ );
359
+ expect(process.exit).toHaveBeenCalledWith(1);
360
+ });
361
+
362
+ it('should propagate child exit code 0', async () => {
363
+ const mockEnv = { SECRET_KEY: 'secret_value' };
364
+ mockSecretsmanager.mockResolvedValue(mockEnv);
365
+
366
+ const child = makeChildMock();
367
+ mockSpawn.mockReturnValue(child);
368
+
369
+ let env = await mockSecretsmanager({ secret: 'my-secret' });
370
+ env = Object.assign({}, process.env, env);
371
+
372
+ mockSpawn(['node', '-e', '"process.exit(0)"'].join(' '), [], {
373
+ stdio: 'inherit',
374
+ shell: true,
375
+ env
376
+ });
377
+
378
+ child.on('exit', (code: number | null, signal: string | null) => {
379
+ process.exit(signal ? 1 : code ?? 0);
380
+ });
381
+
382
+ expect(() => child.emit('exit', 0, null)).toThrow(
383
+ 'process.exit called'
384
+ );
385
+ expect(process.exit).toHaveBeenCalledWith(0);
386
+ });
387
+
388
+ it('should propagate non-zero child exit code', async () => {
389
+ const mockEnv = { SECRET_KEY: 'secret_value' };
390
+ mockSecretsmanager.mockResolvedValue(mockEnv);
391
+
392
+ const child = makeChildMock();
393
+ mockSpawn.mockReturnValue(child);
394
+
395
+ let env = await mockSecretsmanager({ secret: 'my-secret' });
396
+ env = Object.assign({}, process.env, env);
397
+
398
+ mockSpawn(['node', '-e', '"process.exit(42)"'].join(' '), [], {
399
+ stdio: 'inherit',
400
+ shell: true,
401
+ env
402
+ });
403
+
404
+ child.on('exit', (code: number | null, signal: string | null) => {
405
+ process.exit(signal ? 1 : code ?? 0);
406
+ });
407
+
408
+ expect(() => child.emit('exit', 42, null)).toThrow(
409
+ 'process.exit called'
410
+ );
411
+ expect(process.exit).toHaveBeenCalledWith(42);
412
+ });
413
+
414
+ it('should exit 1 when child is killed by a signal', async () => {
415
+ const mockEnv = { SECRET_KEY: 'secret_value' };
416
+ mockSecretsmanager.mockResolvedValue(mockEnv);
417
+
418
+ const child = makeChildMock();
419
+ mockSpawn.mockReturnValue(child);
420
+
421
+ let env = await mockSecretsmanager({ secret: 'my-secret' });
422
+ env = Object.assign({}, process.env, env);
423
+
424
+ mockSpawn('sleep 10', [], { stdio: 'inherit', shell: true, env });
425
+
426
+ child.on('exit', (code: number | null, signal: string | null) => {
427
+ process.exit(signal ? 1 : code ?? 0);
428
+ });
429
+
430
+ expect(() => child.emit('exit', null, 'SIGTERM')).toThrow(
431
+ 'process.exit called'
432
+ );
433
+ expect(process.exit).toHaveBeenCalledWith(1);
434
+ });
435
+ });
279
436
  });
280
437
 
281
438
  describe('Debug logging', () => {
@@ -314,12 +471,8 @@ describe('index.ts CLI functionality', () => {
314
471
  const mockEnv = { SECRET_KEY: 'secret_value' };
315
472
  mockSecretsmanager.mockResolvedValue(mockEnv);
316
473
 
317
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
318
- const mockChildProcess = {
319
- stdio: 'inherit'
320
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
321
- } as any;
322
- mockSpawn.mockReturnValue(mockChildProcess);
474
+ const child = makeChildMock();
475
+ mockSpawn.mockReturnValue(child);
323
476
 
324
477
  const program = ['node', 'script.js', 'arg1'];
325
478
 
@@ -331,7 +484,7 @@ describe('index.ts CLI functionality', () => {
331
484
  // Simulate debug logging
332
485
  mockDebugInstance(`${program[0]} ${program.slice(1).join(' ')}`);
333
486
 
334
- mockSpawn(program[0], program.slice(1), {
487
+ mockSpawn(program.join(' '), [], {
335
488
  stdio: 'inherit',
336
489
  shell: true,
337
490
  env
@@ -438,16 +591,12 @@ describe('index.ts CLI functionality', () => {
438
591
  const mockSecrets = { SECRET_KEY: 'secret_value' };
439
592
  mockSecretsmanager.mockResolvedValue(mockSecrets);
440
593
 
441
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
442
- const mockChildProcess = {
443
- stdio: 'inherit'
444
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
445
- } as any;
446
- mockSpawn.mockReturnValue(mockChildProcess);
447
-
448
- const options: { secret: string; output?: string } = {
449
- secret: 'my-secret'
450
- // No output option
594
+ const child = makeChildMock();
595
+ mockSpawn.mockReturnValue(child);
596
+
597
+ const options: { secret: string; output?: string; shell: boolean } = {
598
+ secret: 'my-secret',
599
+ shell: true
451
600
  };
452
601
 
453
602
  const program = ['echo', 'hello'];
@@ -474,7 +623,7 @@ describe('index.ts CLI functionality', () => {
474
623
  const env = Object.assign({}, process.env, secrets);
475
624
 
476
625
  if (program && program.length > 0) {
477
- mockSpawn(program[0], program.slice(1), {
626
+ mockSpawn(program.join(' '), [], {
478
627
  stdio: 'inherit',
479
628
  shell: true,
480
629
  env
@@ -483,7 +632,7 @@ describe('index.ts CLI functionality', () => {
483
632
  }
484
633
 
485
634
  expect(mockSecretsmanager).toHaveBeenCalledWith(options);
486
- expect(mockSpawn).toHaveBeenCalledWith('echo', ['hello'], {
635
+ expect(mockSpawn).toHaveBeenCalledWith('echo hello', [], {
487
636
  stdio: 'inherit',
488
637
  shell: true,
489
638
  env: expect.objectContaining({
@@ -538,9 +687,10 @@ describe('index.ts CLI functionality', () => {
538
687
  mockObjectToExport.mockReturnValue(mockEnvContent);
539
688
  mockExistsSync.mockReturnValue(false);
540
689
 
541
- const options: { secret: string; output: string } = {
690
+ const options: { secret: string; output: string; shell: boolean } = {
542
691
  secret: 'my-secret',
543
- output: '/tmp/secrets.env'
692
+ output: '/tmp/secrets.env',
693
+ shell: true
544
694
  };
545
695
 
546
696
  const program = ['echo', 'hello'];
@@ -566,7 +716,7 @@ describe('index.ts CLI functionality', () => {
566
716
  const env = Object.assign({}, process.env, secrets);
567
717
 
568
718
  if (program && program.length > 0) {
569
- mockSpawn(program[0], program.slice(1), {
719
+ mockSpawn(program.join(' '), [], {
570
720
  stdio: 'inherit',
571
721
  shell: true,
572
722
  env
package/dist/index.js CHANGED
@@ -43,6 +43,40 @@ const parseSecretJsonObject = (secretName, value) => {
43
43
  }
44
44
  return parsed;
45
45
  };
46
+ const parseEnvToObject = (value) => {
47
+ try {
48
+ const parsed = (0, helpers_1.parseEnvSecrets)(value);
49
+ if (parsed.entries.length === 0) {
50
+ return undefined;
51
+ }
52
+ return Object.fromEntries(parsed.entries.map((entry) => [entry.key, entry.value]));
53
+ }
54
+ catch (_a) {
55
+ return undefined;
56
+ }
57
+ };
58
+ const toSecretJsonObject = (value) => {
59
+ try {
60
+ const parsed = JSON.parse(value);
61
+ if (parsed && !Array.isArray(parsed) && typeof parsed === 'object') {
62
+ return parsed;
63
+ }
64
+ if (typeof parsed === 'string') {
65
+ const envPayload = parseEnvToObject(parsed);
66
+ if (envPayload) {
67
+ return envPayload;
68
+ }
69
+ }
70
+ return { value: parsed };
71
+ }
72
+ catch (_a) {
73
+ const envPayload = parseEnvToObject(value);
74
+ if (envPayload) {
75
+ return envPayload;
76
+ }
77
+ }
78
+ return { value };
79
+ };
46
80
  // main program
47
81
  program
48
82
  .name('env-secrets')
@@ -57,6 +91,7 @@ const awsCommand = program
57
91
  .option('-p, --profile <profile>', 'profile to use')
58
92
  .option('-r, --region <region>', 'region to use')
59
93
  .option('-o, --output <file>', 'output secrets to file instead of environment variables')
94
+ .option('--no-shell', 'run program directly without a shell (disables shell expansion)')
60
95
  .action((program, options) => __awaiter(void 0, void 0, void 0, function* () {
61
96
  if (!options.secret) {
62
97
  exitWithError(new Error('Missing required option --secret for this command.'));
@@ -89,10 +124,20 @@ const awsCommand = program
89
124
  console.log(JSON.stringify(env));
90
125
  return;
91
126
  }
92
- (0, node_child_process_1.spawn)(program[0], program.slice(1), {
93
- stdio: 'inherit',
94
- shell: true,
95
- env
127
+ const child = options.shell
128
+ ? (0, node_child_process_1.spawn)(program.join(' '), [], {
129
+ stdio: 'inherit',
130
+ shell: true,
131
+ env
132
+ })
133
+ : (0, node_child_process_1.spawn)(program[0], program.slice(1), { stdio: 'inherit', env });
134
+ child.on('error', (err) => {
135
+ // eslint-disable-next-line no-console
136
+ console.error(`Failed to start process: ${err.message}`);
137
+ process.exit(1);
138
+ });
139
+ child.on('exit', (code, signal) => {
140
+ process.exit(signal ? 1 : code !== null && code !== void 0 ? code : 0);
96
141
  });
97
142
  }
98
143
  }
@@ -125,9 +170,10 @@ secretCommand
125
170
  if (!value) {
126
171
  throw new Error('Secret value is required. Provide --value, --value-stdin, or --file.');
127
172
  }
173
+ const payload = toSecretJsonObject(value);
128
174
  const result = yield (0, secretsmanager_admin_1.createSecret)({
129
175
  name: options.name,
130
- value,
176
+ value: JSON.stringify(payload),
131
177
  description: options.description,
132
178
  kmsKeyId: options.kmsKeyId,
133
179
  tags: options.tag,
@@ -1,7 +1,7 @@
1
1
  services:
2
2
  localstack:
3
3
  container_name: 'localstack'
4
- image: localstack/localstack:latest
4
+ image: localstack/localstack:3
5
5
  ports:
6
6
  - '4566:4566' # LocalStack Gateway
7
7
  environment:
package/docs/AWS.md CHANGED
@@ -200,6 +200,12 @@ source secrets.env
200
200
  --output json
201
201
  ```
202
202
 
203
+ `create` always writes a JSON object:
204
+
205
+ - object JSON input is preserved semantically as an object (formatting and key order may change) (`{"API_KEY":"abc123"}`)
206
+ - dotenv-style input is converted (`KEY=value` -> `{"KEY":"value"}`)
207
+ - non-object/scalar input is wrapped (`super-secret-value` -> `{"value":"super-secret-value"}`)
208
+
203
209
  2. **Create from stdin (recommended for sensitive values):**
204
210
 
205
211
  ```bash
@@ -262,6 +268,7 @@ source secrets.env
262
268
 
263
269
  - `delete` requires `--yes`.
264
270
  - `create`/`update` accept `--value`, `--value-stdin`, or `--file` (use only one).
271
+ - `create` always stores `SecretString` as a JSON object.
265
272
  - `append` and `remove` require the secret value to be a JSON object.
266
273
  - `upsert/import --file --name` parses `export KEY=value` and `KEY=value`, stores them as one JSON secret object, ignores blank lines/comments, and reports `created`, `updated`, `skipped`, and `failed`.
267
274
  - Use `--value-stdin` to avoid shell history leakage for sensitive values.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "env-secrets",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "get secrets from a secrets vault and inject them into the running environment",
5
5
  "main": "index.js",
6
6
  "author": "Mark C Allen (@markcallen)",
@@ -31,7 +31,7 @@
31
31
  },
32
32
  "devDependencies": {
33
33
  "@everydaydevopsio/ballast": "^3.2.1",
34
- "@types/debug": "^4.1.12",
34
+ "@types/debug": "^4.1.13",
35
35
  "@types/jest": "^29.5.14",
36
36
  "@types/node": "^18.19.130",
37
37
  "@typescript-eslint/eslint-plugin": "^5.62.0",
@@ -53,9 +53,9 @@
53
53
  "typescript": "^4.9.5"
54
54
  },
55
55
  "dependencies": {
56
- "@aws-sdk/client-secrets-manager": "^3.1004.0",
57
- "@aws-sdk/client-sts": "^3.1004.0",
58
- "@aws-sdk/credential-providers": "^3.1004.0",
56
+ "@aws-sdk/client-secrets-manager": "^3.1030.0",
57
+ "@aws-sdk/client-sts": "^3.1030.0",
58
+ "@aws-sdk/credential-providers": "^3.1030.0",
59
59
  "commander": "^9.5.0",
60
60
  "debug": "^4.4.3"
61
61
  },
package/src/index.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  } from './vaults/secretsmanager-admin';
19
19
  import {
20
20
  asOutputFormat,
21
+ parseEnvSecrets,
21
22
  parseEnvSecretsFile,
22
23
  printData,
23
24
  parseRecoveryDays,
@@ -58,6 +59,48 @@ const parseSecretJsonObject = (
58
59
  return parsed as Record<string, unknown>;
59
60
  };
60
61
 
62
+ const parseEnvToObject = (
63
+ value: string
64
+ ): Record<string, unknown> | undefined => {
65
+ try {
66
+ const parsed = parseEnvSecrets(value);
67
+ if (parsed.entries.length === 0) {
68
+ return undefined;
69
+ }
70
+
71
+ return Object.fromEntries(
72
+ parsed.entries.map((entry) => [entry.key, entry.value])
73
+ );
74
+ } catch {
75
+ return undefined;
76
+ }
77
+ };
78
+
79
+ const toSecretJsonObject = (value: string): Record<string, unknown> => {
80
+ try {
81
+ const parsed = JSON.parse(value) as unknown;
82
+ if (parsed && !Array.isArray(parsed) && typeof parsed === 'object') {
83
+ return parsed as Record<string, unknown>;
84
+ }
85
+
86
+ if (typeof parsed === 'string') {
87
+ const envPayload = parseEnvToObject(parsed);
88
+ if (envPayload) {
89
+ return envPayload;
90
+ }
91
+ }
92
+
93
+ return { value: parsed };
94
+ } catch {
95
+ const envPayload = parseEnvToObject(value);
96
+ if (envPayload) {
97
+ return envPayload;
98
+ }
99
+ }
100
+
101
+ return { value };
102
+ };
103
+
61
104
  // main program
62
105
  program
63
106
  .name('env-secrets')
@@ -78,6 +121,10 @@ const awsCommand = program
78
121
  '-o, --output <file>',
79
122
  'output secrets to file instead of environment variables'
80
123
  )
124
+ .option(
125
+ '--no-shell',
126
+ 'run program directly without a shell (disables shell expansion)'
127
+ )
81
128
  .action(async (program, options) => {
82
129
  if (!options.secret) {
83
130
  exitWithError(
@@ -120,10 +167,22 @@ const awsCommand = program
120
167
  return;
121
168
  }
122
169
 
123
- spawn(program[0], program.slice(1), {
124
- stdio: 'inherit',
125
- shell: true,
126
- env
170
+ const child = options.shell
171
+ ? spawn(program.join(' '), [], {
172
+ stdio: 'inherit',
173
+ shell: true,
174
+ env
175
+ })
176
+ : spawn(program[0], program.slice(1), { stdio: 'inherit', env });
177
+
178
+ child.on('error', (err) => {
179
+ // eslint-disable-next-line no-console
180
+ console.error(`Failed to start process: ${err.message}`);
181
+ process.exit(1);
182
+ });
183
+
184
+ child.on('exit', (code, signal) => {
185
+ process.exit(signal ? 1 : code ?? 0);
127
186
  });
128
187
  }
129
188
  }
@@ -166,9 +225,10 @@ secretCommand
166
225
  );
167
226
  }
168
227
 
228
+ const payload = toSecretJsonObject(value);
169
229
  const result = await createSecret({
170
230
  name: options.name,
171
- value,
231
+ value: JSON.stringify(payload),
172
232
  description: options.description,
173
233
  kmsKeyId: options.kmsKeyId,
174
234
  tags: options.tag,