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,352 @@
1
+ import {
2
+ CreateSecretCommand,
3
+ DeleteSecretCommand,
4
+ DescribeSecretCommand,
5
+ ListSecretsCommand,
6
+ SecretsManagerClient,
7
+ Tag,
8
+ UpdateSecretCommand
9
+ } from '@aws-sdk/client-secrets-manager';
10
+ import { GetCallerIdentityCommand, STSClient } from '@aws-sdk/client-sts';
11
+ import Debug from 'debug';
12
+ import { buildAwsClientConfig } from './aws-config';
13
+
14
+ const debug = Debug('env-secrets:secretsmanager-admin');
15
+
16
+ export interface AwsSecretCommandOptions {
17
+ profile?: string;
18
+ region?: string;
19
+ }
20
+
21
+ export interface SecretCreateOptions extends AwsSecretCommandOptions {
22
+ name: string;
23
+ value: string;
24
+ description?: string;
25
+ kmsKeyId?: string;
26
+ tags?: string[];
27
+ }
28
+
29
+ export interface SecretUpdateOptions extends AwsSecretCommandOptions {
30
+ name: string;
31
+ value?: string;
32
+ description?: string;
33
+ kmsKeyId?: string;
34
+ }
35
+
36
+ export interface SecretListOptions extends AwsSecretCommandOptions {
37
+ prefix?: string;
38
+ tags?: string[];
39
+ }
40
+
41
+ export interface SecretDeleteOptions extends AwsSecretCommandOptions {
42
+ name: string;
43
+ recoveryDays?: number;
44
+ forceDeleteWithoutRecovery?: boolean;
45
+ }
46
+
47
+ export interface SecretSummary {
48
+ name: string;
49
+ arn?: string;
50
+ description?: string;
51
+ lastChangedDate?: string;
52
+ }
53
+
54
+ export interface SecretMetadata {
55
+ name?: string;
56
+ arn?: string;
57
+ description?: string;
58
+ kmsKeyId?: string;
59
+ deletedDate?: string;
60
+ lastChangedDate?: string;
61
+ lastAccessedDate?: string;
62
+ createdDate?: string;
63
+ versionIdsToStages?: Record<string, string[]>;
64
+ tags?: Record<string, string>;
65
+ }
66
+
67
+ interface AWSLikeError {
68
+ name?: string;
69
+ message?: string;
70
+ }
71
+
72
+ // Allowed characters are documented by AWS Secrets Manager naming rules.
73
+ // See: https://docs.aws.amazon.com/secretsmanager/latest/userguide/reference_limits.html
74
+ const SECRET_NAME_PATTERN = /^[A-Za-z0-9/_+=.@-]+$/;
75
+
76
+ const formatDate = (value?: Date): string | undefined => {
77
+ if (!value) {
78
+ return undefined;
79
+ }
80
+
81
+ return value.toISOString();
82
+ };
83
+
84
+ const parseTags = (tags?: string[]): Tag[] | undefined => {
85
+ if (!tags || tags.length === 0) {
86
+ return undefined;
87
+ }
88
+
89
+ return tags.map((tag) => {
90
+ const parts = tag.split('=');
91
+ if (parts.length < 2) {
92
+ throw new Error(`Invalid tag format: ${tag}. Use key=value.`);
93
+ }
94
+
95
+ const key = parts[0].trim();
96
+ const value = parts.slice(1).join('=').trim();
97
+
98
+ if (!key || !value) {
99
+ throw new Error(`Invalid tag format: ${tag}. Use key=value.`);
100
+ }
101
+
102
+ return { Key: key, Value: value };
103
+ });
104
+ };
105
+
106
+ const tagsToRecord = (tags?: Tag[]): Record<string, string> | undefined => {
107
+ if (!tags || tags.length === 0) {
108
+ return undefined;
109
+ }
110
+
111
+ const result: Record<string, string> = {};
112
+ for (const tag of tags) {
113
+ if (tag.Key && tag.Value) {
114
+ result[tag.Key] = tag.Value;
115
+ }
116
+ }
117
+
118
+ return Object.keys(result).length > 0 ? result : undefined;
119
+ };
120
+
121
+ const mapAwsError = (error: unknown, secretName?: string): never => {
122
+ const awsError = error as AWSLikeError;
123
+ const secretLabel = secretName ? ` for "${secretName}"` : '';
124
+
125
+ if (awsError?.name === 'AlreadyExistsException') {
126
+ throw new Error(`Secret${secretLabel} already exists.`);
127
+ }
128
+
129
+ if (awsError?.name === 'ResourceNotFoundException') {
130
+ throw new Error(`Secret${secretLabel} was not found.`);
131
+ }
132
+
133
+ if (awsError?.name === 'InvalidRequestException') {
134
+ throw new Error(
135
+ awsError.message || 'Invalid request to AWS Secrets Manager.'
136
+ );
137
+ }
138
+
139
+ if (awsError?.name === 'AccessDeniedException') {
140
+ throw new Error(
141
+ awsError.message ||
142
+ 'Access denied while calling AWS Secrets Manager. Verify IAM permissions.'
143
+ );
144
+ }
145
+
146
+ if (awsError?.message) {
147
+ throw new Error(awsError.message);
148
+ }
149
+
150
+ throw new Error(String(error));
151
+ };
152
+
153
+ const ensureConnected = async (
154
+ clientConfig: ReturnType<typeof buildAwsClientConfig>
155
+ ) => {
156
+ const stsClient = new STSClient(clientConfig);
157
+ await stsClient.send(new GetCallerIdentityCommand({}));
158
+ };
159
+
160
+ const createClient = async (options: AwsSecretCommandOptions) => {
161
+ const config = buildAwsClientConfig(options);
162
+ debug('Creating AWS clients', {
163
+ hasProfile: Boolean(options.profile),
164
+ region: options.region,
165
+ hasEndpoint: Boolean(config.endpoint)
166
+ });
167
+ await ensureConnected(config);
168
+ return new SecretsManagerClient(config);
169
+ };
170
+
171
+ export const validateSecretName = (name: string) => {
172
+ if (!SECRET_NAME_PATTERN.test(name)) {
173
+ throw new Error(
174
+ `Invalid secret name "${name}". Use only letters, numbers, and /_+=.@- characters.`
175
+ );
176
+ }
177
+ };
178
+
179
+ export const createSecret = async (
180
+ options: SecretCreateOptions
181
+ ): Promise<{ arn?: string; name?: string; versionId?: string }> => {
182
+ validateSecretName(options.name);
183
+ debug('createSecret called', {
184
+ name: options.name,
185
+ hasTags: !!options.tags?.length
186
+ });
187
+
188
+ const client = await createClient(options);
189
+ const tags = parseTags(options.tags);
190
+
191
+ try {
192
+ const result = await client.send(
193
+ new CreateSecretCommand({
194
+ Name: options.name,
195
+ Description: options.description,
196
+ SecretString: options.value,
197
+ KmsKeyId: options.kmsKeyId,
198
+ Tags: tags
199
+ })
200
+ );
201
+
202
+ return {
203
+ arn: result.ARN,
204
+ name: result.Name,
205
+ versionId: result.VersionId
206
+ };
207
+ } catch (error: unknown) {
208
+ return mapAwsError(error, options.name);
209
+ }
210
+ };
211
+
212
+ export const updateSecret = async (
213
+ options: SecretUpdateOptions
214
+ ): Promise<{ arn?: string; name?: string; versionId?: string }> => {
215
+ validateSecretName(options.name);
216
+ debug('updateSecret called', { name: options.name });
217
+
218
+ const client = await createClient(options);
219
+
220
+ try {
221
+ const result = await client.send(
222
+ new UpdateSecretCommand({
223
+ SecretId: options.name,
224
+ Description: options.description,
225
+ SecretString: options.value,
226
+ KmsKeyId: options.kmsKeyId
227
+ })
228
+ );
229
+
230
+ return {
231
+ arn: result.ARN,
232
+ name: result.Name,
233
+ versionId: result.VersionId
234
+ };
235
+ } catch (error: unknown) {
236
+ return mapAwsError(error, options.name);
237
+ }
238
+ };
239
+
240
+ export const listSecrets = async (
241
+ options: SecretListOptions
242
+ ): Promise<SecretSummary[]> => {
243
+ debug('listSecrets called', {
244
+ prefix: options.prefix,
245
+ hasTags: !!options.tags?.length
246
+ });
247
+ const client = await createClient(options);
248
+ const requiredTags = parseTags(options.tags);
249
+ const secrets: SecretSummary[] = [];
250
+
251
+ try {
252
+ let nextToken: string | undefined;
253
+
254
+ do {
255
+ const result = await client.send(
256
+ new ListSecretsCommand({ NextToken: nextToken })
257
+ );
258
+
259
+ for (const secret of result.SecretList || []) {
260
+ if (
261
+ options.prefix &&
262
+ secret.Name &&
263
+ !secret.Name.startsWith(options.prefix)
264
+ ) {
265
+ continue;
266
+ }
267
+
268
+ if (requiredTags && requiredTags.length > 0) {
269
+ const available = tagsToRecord(secret.Tags);
270
+ const matchesAll = requiredTags.every(
271
+ (tag) => tag.Key && tag.Value && available?.[tag.Key] === tag.Value
272
+ );
273
+ if (!matchesAll) {
274
+ continue;
275
+ }
276
+ }
277
+
278
+ secrets.push({
279
+ name: secret.Name || '',
280
+ arn: secret.ARN,
281
+ description: secret.Description,
282
+ lastChangedDate: formatDate(secret.LastChangedDate)
283
+ });
284
+ }
285
+
286
+ nextToken = result.NextToken;
287
+ } while (nextToken);
288
+ } catch (error: unknown) {
289
+ return mapAwsError(error);
290
+ }
291
+
292
+ return secrets;
293
+ };
294
+
295
+ export const getSecretMetadata = async (
296
+ options: AwsSecretCommandOptions & { name: string }
297
+ ): Promise<SecretMetadata> => {
298
+ validateSecretName(options.name);
299
+ debug('getSecretMetadata called', { name: options.name });
300
+ const client = await createClient(options);
301
+
302
+ try {
303
+ const result = await client.send(
304
+ new DescribeSecretCommand({ SecretId: options.name })
305
+ );
306
+
307
+ return {
308
+ name: result.Name,
309
+ arn: result.ARN,
310
+ description: result.Description,
311
+ kmsKeyId: result.KmsKeyId,
312
+ deletedDate: formatDate(result.DeletedDate),
313
+ lastChangedDate: formatDate(result.LastChangedDate),
314
+ lastAccessedDate: formatDate(result.LastAccessedDate),
315
+ createdDate: formatDate(result.CreatedDate),
316
+ versionIdsToStages: result.VersionIdsToStages,
317
+ tags: tagsToRecord(result.Tags)
318
+ };
319
+ } catch (error: unknown) {
320
+ return mapAwsError(error, options.name);
321
+ }
322
+ };
323
+
324
+ export const deleteSecret = async (
325
+ options: SecretDeleteOptions
326
+ ): Promise<{ arn?: string; name?: string; deletedDate?: string }> => {
327
+ validateSecretName(options.name);
328
+ debug('deleteSecret called', {
329
+ name: options.name,
330
+ recoveryDays: options.recoveryDays,
331
+ forceDeleteWithoutRecovery: options.forceDeleteWithoutRecovery
332
+ });
333
+ const client = await createClient(options);
334
+
335
+ try {
336
+ const result = await client.send(
337
+ new DeleteSecretCommand({
338
+ SecretId: options.name,
339
+ RecoveryWindowInDays: options.recoveryDays,
340
+ ForceDeleteWithoutRecovery: options.forceDeleteWithoutRecovery
341
+ })
342
+ );
343
+
344
+ return {
345
+ arn: result.ARN,
346
+ name: result.Name,
347
+ deletedDate: formatDate(result.DeletionDate)
348
+ };
349
+ } catch (error: unknown) {
350
+ return mapAwsError(error, options.name);
351
+ }
352
+ };
@@ -3,8 +3,8 @@ import {
3
3
  GetSecretValueCommand
4
4
  } from '@aws-sdk/client-secrets-manager';
5
5
  import { STSClient, GetCallerIdentityCommand } from '@aws-sdk/client-sts';
6
- import { fromIni } from '@aws-sdk/credential-providers';
7
6
  import Debug from 'debug';
7
+ import { buildAwsClientConfig } from './aws-config';
8
8
 
9
9
  const debug = Debug('env-secrets:secretsmanager');
10
10
 
@@ -14,8 +14,26 @@ interface secretsmanagerType {
14
14
  region?: string;
15
15
  }
16
16
 
17
- const checkConnection = async (region?: string) => {
18
- const stsClient = new STSClient({ region });
17
+ interface AWSLikeError {
18
+ name?: string;
19
+ message?: string;
20
+ }
21
+
22
+ const isCredentialsError = (error: unknown): error is AWSLikeError => {
23
+ if (!error || typeof error !== 'object') {
24
+ return false;
25
+ }
26
+
27
+ const errorName = 'name' in error ? error.name : undefined;
28
+ return (
29
+ errorName === 'CredentialsError' || errorName === 'CredentialsProviderError'
30
+ );
31
+ };
32
+
33
+ const checkConnection = async (
34
+ config: ReturnType<typeof buildAwsClientConfig>
35
+ ) => {
36
+ const stsClient = new STSClient(config);
19
37
  const command = new GetCallerIdentityCommand({});
20
38
 
21
39
  try {
@@ -23,6 +41,12 @@ const checkConnection = async (region?: string) => {
23
41
  debug(data);
24
42
  return true;
25
43
  } catch (err) {
44
+ if (isCredentialsError(err) && err.message) {
45
+ // eslint-disable-next-line no-console
46
+ console.error(err.message);
47
+ return false;
48
+ }
49
+
26
50
  // eslint-disable-next-line no-console
27
51
  console.error(err);
28
52
  return false;
@@ -31,33 +55,21 @@ const checkConnection = async (region?: string) => {
31
55
 
32
56
  export const secretsmanager = async (options: secretsmanagerType) => {
33
57
  const { secret, profile, region } = options;
34
- const {
35
- AWS_ACCESS_KEY_ID: awsAccessKeyId,
36
- AWS_SECRET_ACCESS_KEY: awsSecretAccessKey
37
- } = process.env;
58
+ const config = buildAwsClientConfig({ profile, region });
38
59
 
39
- let credentials;
40
60
  if (profile) {
41
61
  debug(`Using profile: ${profile}`);
42
- credentials = fromIni({ profile });
43
- } else if (awsAccessKeyId && awsSecretAccessKey) {
44
- debug('Using environment variables');
45
- credentials = undefined; // Will use environment variables automatically
46
- } else {
62
+ } else if (config.credentials) {
47
63
  debug('Using profile: default');
48
- credentials = fromIni({ profile: 'default' });
64
+ } else {
65
+ debug('Using environment variables');
49
66
  }
50
67
 
51
- const config = {
52
- region,
53
- credentials
54
- };
55
-
56
68
  if (!config.region) {
57
69
  debug('no region set');
58
70
  }
59
71
 
60
- const connected = await checkConnection(region);
72
+ const connected = await checkConnection(config);
61
73
 
62
74
  if (connected) {
63
75
  const client = new SecretsManagerClient(config);
@@ -28,6 +28,48 @@ Currently supported provider: `aws`
28
28
  - `-p, --profile <profile>` - AWS profile to use (defaults to environment variables or IAM role)
29
29
  - `-o, --output <file>` - Output secrets to a file instead of injecting into environment variables. File will be created with 0400 permissions and will not overwrite existing files
30
30
 
31
+ ## AWS Secret Management Commands
32
+
33
+ Use `env-secrets aws secret <command>` to manage AWS Secrets Manager secrets directly.
34
+
35
+ ### Commands
36
+
37
+ - `create` - Create a new secret
38
+ - `update` - Update secret value or metadata
39
+ - `list` - List secrets
40
+ - `get` - Get secret metadata/version info
41
+ - `delete` - Delete a secret
42
+
43
+ ### Shared Options
44
+
45
+ - `-r, --region <region>` - AWS region
46
+ - `-p, --profile <profile>` - AWS profile
47
+ - `--output <format>` - Output format: `json` or `table` (default: `table`)
48
+
49
+ ### Command-Specific Options
50
+
51
+ - `create`
52
+ - `-n, --name <name>` (required)
53
+ - `-v, --value <value>` or `--value-stdin`
54
+ - `-d, --description <description>`
55
+ - `-k, --kms-key-id <kmsKeyId>`
56
+ - `-t, --tag <key=value...>`
57
+ - `update`
58
+ - `-n, --name <name>` (required)
59
+ - `-v, --value <value>` or `--value-stdin`
60
+ - `-d, --description <description>`
61
+ - `-k, --kms-key-id <kmsKeyId>`
62
+ - `list`
63
+ - `--prefix <prefix>`
64
+ - `-t, --tag <key=value...>`
65
+ - `get`
66
+ - `-n, --name <name>` (required)
67
+ - `delete`
68
+ - `-n, --name <name>` (required)
69
+ - `-y, --yes` (required for delete)
70
+ - `--recovery-days <7-30>`
71
+ - `--force-delete-without-recovery`
72
+
31
73
  ### Global Options
32
74
 
33
75
  - `--help` - Show help information
@@ -131,6 +173,31 @@ env-secrets aws -s docker-secrets -r us-east-1 -o .env
131
173
  docker run --env-file .env my-app:latest
132
174
  ```
133
175
 
176
+ ### Secret Management
177
+
178
+ ```bash
179
+ # Create
180
+ env-secrets aws secret create -n app/dev/api -v '{"API_KEY":"abc123"}' --output json
181
+
182
+ # Create from stdin (recommended for sensitive values)
183
+ echo -n 'super-secret-value' | env-secrets aws secret create -n app/dev/raw --value-stdin
184
+
185
+ # Update value
186
+ env-secrets aws secret update -n app/dev/api -v '{"API_KEY":"xyz789"}'
187
+
188
+ # Update from stdin
189
+ echo -n 'rotated-value' | env-secrets aws secret update -n app/dev/raw --value-stdin
190
+
191
+ # List
192
+ env-secrets aws secret list --prefix app/dev --output table
193
+
194
+ # Get metadata (does not print secret value)
195
+ env-secrets aws secret get -n app/dev/api --output json
196
+
197
+ # Delete with confirmation
198
+ env-secrets aws secret delete -n app/dev/raw --recovery-days 7 --yes
199
+ ```
200
+
134
201
  ## Environment Variable Behavior
135
202
 
136
203
  ### JSON Secret Parsing
@@ -738,7 +738,7 @@ jobs:
738
738
  runs-on: ubuntu-latest
739
739
  steps:
740
740
  - uses: actions/checkout@v3
741
-
741
+
742
742
  - name: Configure AWS credentials
743
743
  uses: aws-actions/configure-aws-credentials@v2
744
744
  with:
@@ -4,7 +4,7 @@ title: Installation
4
4
 
5
5
  ## Requirements
6
6
 
7
- - Node.js 18+
7
+ - Node.js 20.0.0 or higher
8
8
  - (For AWS) AWS credentials via env vars, profile, or IAM role
9
9
 
10
10
  ## Install
@@ -4,6 +4,8 @@ title: AWS Secrets Manager
4
4
 
5
5
  `env-secrets` supports pulling a single JSON secret from AWS Secrets Manager, mapping each top-level key to an environment variable.
6
6
 
7
+ It also supports secret lifecycle operations with `env-secrets aws secret <command>`.
8
+
7
9
  ### Create a secret (JSON)
8
10
 
9
11
  ```bash
@@ -22,6 +24,36 @@ env-secrets aws -s local/sample -r us-east-1 -- echo $user/$password
22
24
  - `-r, --region` — AWS region (or `AWS_DEFAULT_REGION`)
23
25
  - `-p, --profile` — AWS profile to use
24
26
 
27
+ ### Secret Management Commands
28
+
29
+ ```bash
30
+ # Create
31
+ env-secrets aws secret create -n app/dev/api -v '{"API_KEY":"abc123"}' --output json
32
+
33
+ # Create from stdin
34
+ echo -n 'super-secret-value' | env-secrets aws secret create -n app/dev/raw --value-stdin
35
+
36
+ # Update
37
+ env-secrets aws secret update -n app/dev/api -v '{"API_KEY":"rotated"}'
38
+
39
+ # List
40
+ env-secrets aws secret list --prefix app/dev --output table
41
+
42
+ # Get metadata (does not print secret value)
43
+ env-secrets aws secret get -n app/dev/api --output json
44
+
45
+ # Delete (requires --yes)
46
+ env-secrets aws secret delete -n app/dev/raw --recovery-days 7 --yes
47
+ ```
48
+
49
+ Supported commands:
50
+
51
+ - `create` with `--value` or `--value-stdin`
52
+ - `update` with value and/or metadata changes
53
+ - `list` with optional prefix/tag filters
54
+ - `get` for metadata/version details
55
+ - `delete` with recovery window or force-delete flags
56
+
25
57
  ### Tips
26
58
 
27
59
  - Use `DEBUG=env-secrets,env-secrets:secretsmanager` for verbose logs.
package/.eslintignore DELETED
@@ -1,4 +0,0 @@
1
- node_modules/
2
- dist/
3
- website/build/
4
- website/node_modules/
package/.eslintrc DELETED
@@ -1,18 +0,0 @@
1
- {
2
- "root": true,
3
- "parser": "@typescript-eslint/parser",
4
- "plugins": ["@typescript-eslint", "prettier"],
5
- "extends": [
6
- "eslint:recommended",
7
- "plugin:@typescript-eslint/eslint-recommended",
8
- "plugin:@typescript-eslint/recommended",
9
- "prettier"
10
- ],
11
- "env": {
12
- "jest": true
13
- },
14
- "rules": {
15
- "no-console": "warn",
16
- "prettier/prettier": "error"
17
- }
18
- }
package/.lintstagedrc DELETED
@@ -1,4 +0,0 @@
1
- {
2
- "*.{js,ts}": ["prettier --write", "eslint --fix"],
3
- "*.{json,md,yaml}": ["prettier --write"]
4
- }