env-secrets 0.5.2 → 0.5.4

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 (56) hide show
  1. package/.claude/rules/cicd.md +189 -0
  2. package/.claude/rules/docs.md +96 -0
  3. package/.claude/rules/git-hooks.md +43 -0
  4. package/.claude/rules/local-dev-badges.md +91 -0
  5. package/.claude/rules/local-dev-env.md +382 -0
  6. package/.claude/rules/local-dev-license.md +104 -0
  7. package/.claude/rules/local-dev-mcp.md +70 -0
  8. package/.claude/rules/observability.md +23 -0
  9. package/.claude/rules/publishing-api.md +158 -0
  10. package/.claude/rules/publishing-apps.md +204 -0
  11. package/.claude/rules/publishing-apt.md +146 -0
  12. package/.claude/rules/publishing-brew.md +110 -0
  13. package/.claude/rules/publishing-cli.md +238 -0
  14. package/.claude/rules/publishing-libraries.md +115 -0
  15. package/.claude/rules/publishing-sdks.md +109 -0
  16. package/.claude/rules/publishing-web.md +185 -0
  17. package/.claude/rules/typescript-linting.md +141 -0
  18. package/.claude/rules/typescript-logging.md +356 -0
  19. package/.claude/rules/typescript-testing.md +185 -0
  20. package/.claude/settings.json +18 -0
  21. package/.claude/skills/github-health-check.skill +0 -0
  22. package/.codex/rules/cicd.md +21 -0
  23. package/.codex/rules/docs.md +98 -0
  24. package/.codex/rules/git-hooks.md +43 -0
  25. package/.codex/rules/github-health-check.md +440 -0
  26. package/.codex/rules/local-dev-env.md +47 -0
  27. package/.codex/rules/local-dev-license.md +3 -1
  28. package/.codex/rules/publishing-api.md +160 -0
  29. package/.codex/rules/publishing-apps.md +206 -0
  30. package/.codex/rules/publishing-apt.md +148 -0
  31. package/.codex/rules/publishing-brew.md +112 -0
  32. package/.codex/rules/publishing-cli.md +240 -0
  33. package/.codex/rules/publishing-libraries.md +117 -0
  34. package/.codex/rules/publishing-sdks.md +111 -0
  35. package/.codex/rules/publishing-web.md +187 -0
  36. package/.codex/rules/typescript-linting.md +143 -0
  37. package/.codex/rules/typescript-logging.md +358 -0
  38. package/.codex/rules/typescript-testing.md +187 -0
  39. package/.github/workflows/deploy-docs.yml +2 -2
  40. package/.github/workflows/unittests.yaml +1 -1
  41. package/.rulesrc.json +20 -0
  42. package/AGENTS.md +34 -0
  43. package/CLAUDE.md +58 -0
  44. package/README.md +17 -3
  45. package/__e2e__/aws-exec-args.test.ts +97 -1
  46. package/__e2e__/aws-secret-value-args.test.ts +142 -0
  47. package/__e2e__/utils/test-utils.ts +78 -0
  48. package/__tests__/cli/helpers.test.ts +35 -0
  49. package/__tests__/index.test.ts +208 -58
  50. package/dist/cli/helpers.js +13 -1
  51. package/dist/index.js +94 -44
  52. package/docker-compose.yaml +1 -1
  53. package/docs/AWS.md +42 -13
  54. package/package.json +6 -6
  55. package/src/cli/helpers.ts +16 -0
  56. package/src/index.ts +117 -52
package/docs/AWS.md CHANGED
@@ -153,11 +153,12 @@ In addition to injecting variables into a process, `env-secrets` can manage AWS
153
153
 
154
154
  - `env-secrets aws secret create`
155
155
  - `env-secrets aws secret update`
156
+ - `env-secrets aws secret upsert` (alias: `import`)
156
157
  - `env-secrets aws secret append`
157
158
  - `env-secrets aws secret remove`
158
- - `env-secrets aws secret upsert` (alias: `import`)
159
159
  - `env-secrets aws secret list`
160
160
  - `env-secrets aws secret get`
161
+ - `env-secrets aws secret value`
161
162
  - `env-secrets aws secret delete`
162
163
 
163
164
  `aws secret` subcommands consistently honor `--region`, `--profile`, and `--output`.
@@ -165,8 +166,8 @@ Use these options directly with each subcommand.
165
166
 
166
167
  ### `aws -s` vs `aws secret ...`
167
168
 
168
- - `env-secrets aws -s <secret-name> -- <command>`: retrieves a secret value and injects it into the environment for the spawned process (or use `-o <file>` to write exports to a file).
169
- - `env-secrets aws secret ...`: management commands only (`create`, `update`, `append`, `remove`, `upsert/import`, `list`, `get`, `delete`).
169
+ - `env-secrets aws -s <secret-name> -- <command>`: retrieves a secret value and injects it into the environment for the spawned process (or use `-o <file>` to write exports to a file). Use `--no-shell` to run the program directly without a shell wrapper (disables shell expansion).
170
+ - `env-secrets aws secret ...`: management commands only (`create`, `update`, `upsert/import`, `append`, `remove`, `list`, `get`, `value`, `delete`).
170
171
 
171
172
  Example:
172
173
 
@@ -206,18 +207,24 @@ source secrets.env
206
207
  - dotenv-style input is converted (`KEY=value` -> `{"KEY":"value"}`)
207
208
  - non-object/scalar input is wrapped (`super-secret-value` -> `{"value":"super-secret-value"}`)
208
209
 
210
+ Optional flags: `-d/--description`, `-k/--kms-key-id`, `-t/--tag key=value` (repeatable).
211
+
209
212
  2. **Create from stdin (recommended for sensitive values):**
210
213
 
211
214
  ```bash
212
215
  echo -n 'super-secret-value' | env-secrets aws secret create -n my-app/dev/raw --value-stdin -r us-east-1
213
216
  ```
214
217
 
218
+ `create` and `update` accept `--value`, `--value-stdin`, or `--file` — use only one.
219
+
215
220
  3. **Update an existing secret value:**
216
221
 
217
222
  ```bash
218
223
  env-secrets aws secret update -n my-app/dev/api -v '{"API_KEY":"rotated"}' -r us-east-1
219
224
  ```
220
225
 
226
+ Optional flags: `-d/--description`, `-k/--kms-key-id`. Accepts `--value-stdin` or `--file` instead of `-v`.
227
+
221
228
  4. **Upsert from an env file into one JSON secret (`export KEY=value` or `KEY=value`):**
222
229
 
223
230
  ```bash
@@ -232,47 +239,69 @@ source secrets.env
232
239
  { "API_KEY": "abc123", "DATABASE_URL": "postgres://..." }
233
240
  ```
234
241
 
242
+ Optional flags: `-d/--description`, `-k/--kms-key-id`, `-t/--tag key=value` (repeatable, applies on create only).
243
+
235
244
  5. **Append/remove keys in an existing JSON secret:**
236
245
 
237
246
  ```bash
247
+ # Append a key (accepts --value-stdin or --file instead of -v)
238
248
  env-secrets aws secret append -n my-app/dev --key JIRA_EMAIL_TOKEN -v blah -r us-east-1
239
- env-secrets aws secret remove -n my-app/dev --key OLD_TOKEN -r us-east-1
249
+
250
+ # Remove one or more keys
251
+ env-secrets aws secret remove -n my-app/dev --key OLD_TOKEN --key UNUSED_KEY -r us-east-1
240
252
  ```
241
253
 
242
- 6. **List secrets by prefix:**
254
+ 6. **List secrets by prefix or tag:**
243
255
 
244
256
  ```bash
245
257
  env-secrets aws secret list --prefix my-app/dev -r us-east-1 --output table
246
- ```
247
258
 
248
- Multi-region validation example:
259
+ # Filter by tag
260
+ env-secrets aws secret list -t env=production -t team=platform -r us-east-1
249
261
 
250
- ```bash
262
+ # Multi-region comparison
251
263
  env-secrets aws secret list --prefix my-app/dev -r us-west-2 --output json
252
264
  env-secrets aws secret list --prefix my-app/dev -r us-east-1 --output json
253
265
  ```
254
266
 
255
- 7. **Get metadata and version info (without printing secret value):**
267
+ 7. **Get metadata and version info (without printing secret values):**
256
268
 
257
269
  ```bash
258
270
  env-secrets aws secret get -n my-app/dev/api -r us-east-1 --output json
259
271
  ```
260
272
 
261
- 8. **Delete with explicit confirmation:**
273
+ 8. **Get the values of a secret:**
274
+
275
+ ```bash
276
+ # Table output — values masked by default
277
+ env-secrets aws secret value -n my-app/dev/api -r us-east-1
278
+
279
+ # Reveal actual values (warning printed to stderr)
280
+ env-secrets aws secret value -n my-app/dev/api -r us-east-1 --reveal
281
+
282
+ # JSON output — always returns full values (warns on TTY)
283
+ env-secrets aws secret value -n my-app/dev/api -r us-east-1 --output json
284
+ ```
285
+
286
+ 9. **Delete with explicit confirmation:**
262
287
 
263
288
  ```bash
289
+ # With a recovery window (7–30 days)
264
290
  env-secrets aws secret delete -n my-app/dev/raw --recovery-days 7 --yes -r us-east-1
291
+
292
+ # Permanent delete with no recovery window
293
+ env-secrets aws secret delete -n my-app/dev/raw --force-delete-without-recovery --yes -r us-east-1
265
294
  ```
266
295
 
267
296
  ### Secret Management Safety Notes
268
297
 
269
- - `delete` requires `--yes`.
270
- - `create`/`update` accept `--value`, `--value-stdin`, or `--file` (use only one).
298
+ - `delete` requires `--yes`. Use either `--recovery-days <7-30>` or `--force-delete-without-recovery`, not both.
299
+ - `create`, `update`, and `append` accept `--value`, `--value-stdin`, or `--file` (use only one).
271
300
  - `create` always stores `SecretString` as a JSON object.
272
301
  - `append` and `remove` require the secret value to be a JSON object.
273
302
  - `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`.
274
303
  - Use `--value-stdin` to avoid shell history leakage for sensitive values.
275
- - Use either `--recovery-days` or `--force-delete-without-recovery` for delete operations.
304
+ - `value` masks secret values as `****` in table output by default. Use `--reveal` to show them (prints a warning to stderr). JSON output always returns full values and warns when stdout is a terminal.
276
305
 
277
306
  ## Examples
278
307
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "env-secrets",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
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",
@@ -48,14 +48,14 @@
48
48
  "release-it": "^15.11.0",
49
49
  "rimraf": "^3.0.2",
50
50
  "tsc-files": "^1.1.4",
51
- "ts-jest": "^29.4.6",
51
+ "ts-jest": "^29.4.9",
52
52
  "ts-node": "^10.9.2",
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.1038.0",
57
+ "@aws-sdk/client-sts": "^3.1038.0",
58
+ "@aws-sdk/credential-providers": "^3.1038.0",
59
59
  "commander": "^9.5.0",
60
60
  "debug": "^4.4.3"
61
61
  },
@@ -218,6 +218,22 @@ export const parseEnvSecretsFile = async (
218
218
  return parseEnvSecrets(content);
219
219
  };
220
220
 
221
+ export const resolveOutputFormat = (
222
+ options: { output?: string },
223
+ command?: CommandLikeWithGlobalOpts
224
+ ): OutputFormat => {
225
+ if (options.output) {
226
+ return asOutputFormat(options.output);
227
+ }
228
+
229
+ const globalOutput = command?.optsWithGlobals?.()?.output;
230
+ if (globalOutput === 'json' || globalOutput === 'table') {
231
+ return globalOutput;
232
+ }
233
+
234
+ return 'table';
235
+ };
236
+
221
237
  export const resolveAwsScope = (
222
238
  options: AwsScopeOptions,
223
239
  command?: CommandLikeWithGlobalOpts
package/src/index.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  printData,
24
24
  parseRecoveryDays,
25
25
  resolveAwsScope,
26
+ resolveOutputFormat,
26
27
  resolveSecretValue
27
28
  } from './cli/helpers';
28
29
  import { objectToExport } from './vaults/utils';
@@ -121,6 +122,10 @@ const awsCommand = program
121
122
  '-o, --output <file>',
122
123
  'output secrets to file instead of environment variables'
123
124
  )
125
+ .option(
126
+ '--no-shell',
127
+ 'run program directly without a shell (disables shell expansion)'
128
+ )
124
129
  .action(async (program, options) => {
125
130
  if (!options.secret) {
126
131
  exitWithError(
@@ -163,10 +168,22 @@ const awsCommand = program
163
168
  return;
164
169
  }
165
170
 
166
- spawn(program[0], program.slice(1), {
167
- stdio: 'inherit',
168
- shell: true,
169
- env
171
+ const child = options.shell
172
+ ? spawn(program.join(' '), [], {
173
+ stdio: 'inherit',
174
+ shell: true,
175
+ env
176
+ })
177
+ : spawn(program[0], program.slice(1), { stdio: 'inherit', env });
178
+
179
+ child.on('error', (err) => {
180
+ // eslint-disable-next-line no-console
181
+ console.error(`Failed to start process: ${err.message}`);
182
+ process.exit(1);
183
+ });
184
+
185
+ child.on('exit', (code, signal) => {
186
+ process.exit(signal ? 1 : code ?? 0);
170
187
  });
171
188
  }
172
189
  }
@@ -192,12 +209,7 @@ secretCommand
192
209
  .action(async (options, command) => {
193
210
  try {
194
211
  const { profile, region } = resolveAwsScope(options, command);
195
- const globalOptions = command.optsWithGlobals();
196
- const output =
197
- options.output ??
198
- (typeof globalOptions.output === 'string'
199
- ? globalOptions.output
200
- : 'table');
212
+ const output = resolveOutputFormat(options, command);
201
213
  const value = await resolveSecretValue(
202
214
  options.value,
203
215
  options.valueStdin,
@@ -249,12 +261,7 @@ secretCommand
249
261
  .action(async (options, command) => {
250
262
  try {
251
263
  const { profile, region } = resolveAwsScope(options, command);
252
- const globalOptions = command.optsWithGlobals();
253
- const output =
254
- options.output ??
255
- (typeof globalOptions.output === 'string'
256
- ? globalOptions.output
257
- : 'table');
264
+ const output = resolveOutputFormat(options, command);
258
265
  const value = await resolveSecretValue(
259
266
  options.value,
260
267
  options.valueStdin,
@@ -304,12 +311,7 @@ secretCommand
304
311
  .action(async (options, command) => {
305
312
  try {
306
313
  const { profile, region } = resolveAwsScope(options, command);
307
- const globalOptions = command.optsWithGlobals();
308
- const output =
309
- options.output ??
310
- (typeof globalOptions.output === 'string'
311
- ? globalOptions.output
312
- : 'table');
314
+ const output = resolveOutputFormat(options, command);
313
315
  const parsed = await parseEnvSecretsFile(options.file);
314
316
 
315
317
  if (parsed.entries.length === 0) {
@@ -438,12 +440,7 @@ secretCommand
438
440
  .action(async (options, command) => {
439
441
  try {
440
442
  const { profile, region } = resolveAwsScope(options, command);
441
- const globalOptions = command.optsWithGlobals();
442
- const output =
443
- options.output ??
444
- (typeof globalOptions.output === 'string'
445
- ? globalOptions.output
446
- : 'table');
443
+ const output = resolveOutputFormat(options, command);
447
444
  const value = await resolveSecretValue(
448
445
  options.value,
449
446
  options.valueStdin,
@@ -503,12 +500,7 @@ secretCommand
503
500
  .action(async (options, command) => {
504
501
  try {
505
502
  const { profile, region } = resolveAwsScope(options, command);
506
- const globalOptions = command.optsWithGlobals();
507
- const output =
508
- options.output ??
509
- (typeof globalOptions.output === 'string'
510
- ? globalOptions.output
511
- : 'table');
503
+ const output = resolveOutputFormat(options, command);
512
504
  const keys = options.key as string[];
513
505
  const current = await getSecretString({
514
506
  name: options.name,
@@ -574,12 +566,7 @@ secretCommand
574
566
  .action(async (options, command) => {
575
567
  try {
576
568
  const { profile, region } = resolveAwsScope(options, command);
577
- const globalOptions = command.optsWithGlobals();
578
- const output =
579
- options.output ??
580
- (typeof globalOptions.output === 'string'
581
- ? globalOptions.output
582
- : 'table');
569
+ const output = resolveOutputFormat(options, command);
583
570
  const result = await listSecrets({
584
571
  prefix: options.prefix,
585
572
  tags: options.tag,
@@ -616,12 +603,7 @@ secretCommand
616
603
  .action(async (options, command) => {
617
604
  try {
618
605
  const { profile, region } = resolveAwsScope(options, command);
619
- const globalOptions = command.optsWithGlobals();
620
- const output =
621
- options.output ??
622
- (typeof globalOptions.output === 'string'
623
- ? globalOptions.output
624
- : 'table');
606
+ const output = resolveOutputFormat(options, command);
625
607
  const result = await getSecretMetadata({
626
608
  name: options.name,
627
609
  profile,
@@ -656,6 +638,94 @@ secretCommand
656
638
  }
657
639
  });
658
640
 
641
+ secretCommand
642
+ .command('value')
643
+ .description('get the values of a secret')
644
+ .requiredOption('-n, --name <name>', 'secret name')
645
+ .option(
646
+ '--reveal',
647
+ 'reveal secret values in table output (values are masked by default)',
648
+ false
649
+ )
650
+ .option('-p, --profile <profile>', 'profile to use')
651
+ .option('-r, --region <region>', 'region to use')
652
+ .option('--output <format>', 'output format: json|table')
653
+ .action(async (options, command) => {
654
+ try {
655
+ const { profile, region } = resolveAwsScope(options, command);
656
+ const output = resolveOutputFormat(options, command);
657
+
658
+ let secretString: string;
659
+ try {
660
+ secretString = await getSecretString({
661
+ name: options.name,
662
+ profile,
663
+ region
664
+ });
665
+ } catch (error: unknown) {
666
+ const msg = error instanceof Error ? error.message : String(error);
667
+ if (msg.includes('cannot be edited with append/remove')) {
668
+ throw new Error(
669
+ `Secret "${options.name}" is stored as binary and cannot be displayed as text.`
670
+ );
671
+ }
672
+ throw error;
673
+ }
674
+
675
+ let jsonEntries: Array<{ key: string; value: unknown }>;
676
+ try {
677
+ const parsed = JSON.parse(secretString) as unknown;
678
+ if (parsed && !Array.isArray(parsed) && typeof parsed === 'object') {
679
+ jsonEntries = Object.entries(parsed as Record<string, unknown>).map(
680
+ ([key, value]) => ({ key, value })
681
+ );
682
+ } else {
683
+ jsonEntries = [{ key: options.name, value: secretString }];
684
+ }
685
+ } catch {
686
+ jsonEntries = [{ key: options.name, value: secretString }];
687
+ }
688
+
689
+ if (output === 'json') {
690
+ if (process.stdout.isTTY) {
691
+ // eslint-disable-next-line no-console
692
+ console.error('Warning: displaying sensitive secret values.');
693
+ }
694
+ const result = Object.fromEntries(
695
+ jsonEntries.map(({ key, value }) => [key, value])
696
+ );
697
+ // eslint-disable-next-line no-console
698
+ console.log(JSON.stringify(result, null, 2));
699
+ return;
700
+ }
701
+
702
+ if (options.reveal) {
703
+ // eslint-disable-next-line no-console
704
+ console.error('Warning: displaying sensitive secret values.');
705
+ }
706
+
707
+ const rows = jsonEntries.map(({ key, value }) => ({
708
+ key,
709
+ value: options.reveal
710
+ ? typeof value === 'string'
711
+ ? value
712
+ : JSON.stringify(value)
713
+ : '****'
714
+ }));
715
+
716
+ printData(
717
+ asOutputFormat(output),
718
+ [
719
+ { key: 'key', label: 'Key' },
720
+ { key: 'value', label: 'Value' }
721
+ ],
722
+ rows
723
+ );
724
+ } catch (error: unknown) {
725
+ exitWithError(error);
726
+ }
727
+ });
728
+
659
729
  secretCommand
660
730
  .command('delete')
661
731
  .description('delete a secret in AWS Secrets Manager')
@@ -677,12 +747,7 @@ secretCommand
677
747
  .action(async (options, command) => {
678
748
  try {
679
749
  const { profile, region } = resolveAwsScope(options, command);
680
- const globalOptions = command.optsWithGlobals();
681
- const output =
682
- options.output ??
683
- (typeof globalOptions.output === 'string'
684
- ? globalOptions.output
685
- : 'table');
750
+ const output = resolveOutputFormat(options, command);
686
751
  if (!options.yes) {
687
752
  throw new Error('Delete requires --yes confirmation.');
688
753
  }