env-secrets 0.3.3 → 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.
@@ -0,0 +1,397 @@
1
+ import {
2
+ CreateSecretCommand,
3
+ DeleteSecretCommand,
4
+ DescribeSecretCommand,
5
+ GetSecretValueCommand,
6
+ ListSecretsCommand,
7
+ SecretsManagerClient,
8
+ Tag,
9
+ UpdateSecretCommand
10
+ } from '@aws-sdk/client-secrets-manager';
11
+ import { GetCallerIdentityCommand, STSClient } from '@aws-sdk/client-sts';
12
+ import Debug from 'debug';
13
+ import { buildAwsClientConfig } from './aws-config';
14
+
15
+ const debug = Debug('env-secrets:secretsmanager-admin');
16
+
17
+ export interface AwsSecretCommandOptions {
18
+ profile?: string;
19
+ region?: string;
20
+ }
21
+
22
+ export interface SecretCreateOptions extends AwsSecretCommandOptions {
23
+ name: string;
24
+ value: string;
25
+ description?: string;
26
+ kmsKeyId?: string;
27
+ tags?: string[];
28
+ }
29
+
30
+ export interface SecretUpdateOptions extends AwsSecretCommandOptions {
31
+ name: string;
32
+ value?: string;
33
+ description?: string;
34
+ kmsKeyId?: string;
35
+ }
36
+
37
+ export interface SecretListOptions extends AwsSecretCommandOptions {
38
+ prefix?: string;
39
+ tags?: string[];
40
+ }
41
+
42
+ export interface SecretDeleteOptions extends AwsSecretCommandOptions {
43
+ name: string;
44
+ recoveryDays?: number;
45
+ forceDeleteWithoutRecovery?: boolean;
46
+ }
47
+
48
+ export interface SecretSummary {
49
+ name: string;
50
+ arn?: string;
51
+ description?: string;
52
+ lastChangedDate?: string;
53
+ }
54
+
55
+ export interface SecretMetadata {
56
+ name?: string;
57
+ arn?: string;
58
+ description?: string;
59
+ kmsKeyId?: string;
60
+ deletedDate?: string;
61
+ lastChangedDate?: string;
62
+ lastAccessedDate?: string;
63
+ createdDate?: string;
64
+ versionIdsToStages?: Record<string, string[]>;
65
+ tags?: Record<string, string>;
66
+ }
67
+
68
+ interface AWSLikeError {
69
+ name?: string;
70
+ message?: string;
71
+ }
72
+
73
+ // Allowed characters are documented by AWS Secrets Manager naming rules.
74
+ // See: https://docs.aws.amazon.com/secretsmanager/latest/userguide/reference_limits.html
75
+ const SECRET_NAME_PATTERN = /^[A-Za-z0-9/_+=.@-]+$/;
76
+
77
+ const formatDate = (value?: Date): string | undefined => {
78
+ if (!value) {
79
+ return undefined;
80
+ }
81
+
82
+ return value.toISOString();
83
+ };
84
+
85
+ const parseTags = (tags?: string[]): Tag[] | undefined => {
86
+ if (!tags || tags.length === 0) {
87
+ return undefined;
88
+ }
89
+
90
+ return tags.map((tag) => {
91
+ const parts = tag.split('=');
92
+ if (parts.length < 2) {
93
+ throw new Error(`Invalid tag format: ${tag}. Use key=value.`);
94
+ }
95
+
96
+ const key = parts[0].trim();
97
+ const value = parts.slice(1).join('=').trim();
98
+
99
+ if (!key || !value) {
100
+ throw new Error(`Invalid tag format: ${tag}. Use key=value.`);
101
+ }
102
+
103
+ return { Key: key, Value: value };
104
+ });
105
+ };
106
+
107
+ const tagsToRecord = (tags?: Tag[]): Record<string, string> | undefined => {
108
+ if (!tags || tags.length === 0) {
109
+ return undefined;
110
+ }
111
+
112
+ const result: Record<string, string> = {};
113
+ for (const tag of tags) {
114
+ if (tag.Key && tag.Value) {
115
+ result[tag.Key] = tag.Value;
116
+ }
117
+ }
118
+
119
+ return Object.keys(result).length > 0 ? result : undefined;
120
+ };
121
+
122
+ const mapAwsError = (error: unknown, secretName?: string): never => {
123
+ const awsError = error as AWSLikeError;
124
+ const secretLabel = secretName ? ` for "${secretName}"` : '';
125
+
126
+ if (awsError?.name === 'AlreadyExistsException') {
127
+ throw new Error(`Secret${secretLabel} already exists.`);
128
+ }
129
+
130
+ if (awsError?.name === 'ResourceNotFoundException') {
131
+ throw new Error(`Secret${secretLabel} was not found.`);
132
+ }
133
+
134
+ if (awsError?.name === 'InvalidRequestException') {
135
+ throw new Error(
136
+ awsError.message || 'Invalid request to AWS Secrets Manager.'
137
+ );
138
+ }
139
+
140
+ if (awsError?.name === 'AccessDeniedException') {
141
+ throw new Error(
142
+ awsError.message ||
143
+ 'Access denied while calling AWS Secrets Manager. Verify IAM permissions.'
144
+ );
145
+ }
146
+
147
+ if (awsError?.message) {
148
+ throw new Error(awsError.message);
149
+ }
150
+
151
+ throw new Error(String(error));
152
+ };
153
+
154
+ const ensureConnected = async (
155
+ clientConfig: ReturnType<typeof buildAwsClientConfig>
156
+ ) => {
157
+ const stsClient = new STSClient(clientConfig);
158
+ await stsClient.send(new GetCallerIdentityCommand({}));
159
+ };
160
+
161
+ const createClient = async (options: AwsSecretCommandOptions) => {
162
+ const config = buildAwsClientConfig(options);
163
+ debug('Creating AWS clients', {
164
+ hasProfile: Boolean(options.profile),
165
+ region: options.region,
166
+ hasEndpoint: Boolean(config.endpoint)
167
+ });
168
+ await ensureConnected(config);
169
+ return new SecretsManagerClient(config);
170
+ };
171
+
172
+ export const validateSecretName = (name: string) => {
173
+ if (!SECRET_NAME_PATTERN.test(name)) {
174
+ throw new Error(
175
+ `Invalid secret name "${name}". Use only letters, numbers, and /_+=.@- characters.`
176
+ );
177
+ }
178
+ };
179
+
180
+ export const createSecret = async (
181
+ options: SecretCreateOptions
182
+ ): Promise<{ arn?: string; name?: string; versionId?: string }> => {
183
+ validateSecretName(options.name);
184
+ debug('createSecret called', {
185
+ name: options.name,
186
+ hasTags: !!options.tags?.length
187
+ });
188
+
189
+ const client = await createClient(options);
190
+ const tags = parseTags(options.tags);
191
+
192
+ try {
193
+ const result = await client.send(
194
+ new CreateSecretCommand({
195
+ Name: options.name,
196
+ Description: options.description,
197
+ SecretString: options.value,
198
+ KmsKeyId: options.kmsKeyId,
199
+ Tags: tags
200
+ })
201
+ );
202
+
203
+ return {
204
+ arn: result.ARN,
205
+ name: result.Name,
206
+ versionId: result.VersionId
207
+ };
208
+ } catch (error: unknown) {
209
+ return mapAwsError(error, options.name);
210
+ }
211
+ };
212
+
213
+ export const updateSecret = async (
214
+ options: SecretUpdateOptions
215
+ ): Promise<{ arn?: string; name?: string; versionId?: string }> => {
216
+ validateSecretName(options.name);
217
+ debug('updateSecret called', { name: options.name });
218
+
219
+ const client = await createClient(options);
220
+
221
+ try {
222
+ const result = await client.send(
223
+ new UpdateSecretCommand({
224
+ SecretId: options.name,
225
+ Description: options.description,
226
+ SecretString: options.value,
227
+ KmsKeyId: options.kmsKeyId
228
+ })
229
+ );
230
+
231
+ return {
232
+ arn: result.ARN,
233
+ name: result.Name,
234
+ versionId: result.VersionId
235
+ };
236
+ } catch (error: unknown) {
237
+ return mapAwsError(error, options.name);
238
+ }
239
+ };
240
+
241
+ export const listSecrets = async (
242
+ options: SecretListOptions
243
+ ): Promise<SecretSummary[]> => {
244
+ debug('listSecrets called', {
245
+ prefix: options.prefix,
246
+ hasTags: !!options.tags?.length
247
+ });
248
+ const client = await createClient(options);
249
+ const requiredTags = parseTags(options.tags);
250
+ const secrets: SecretSummary[] = [];
251
+
252
+ try {
253
+ let nextToken: string | undefined;
254
+
255
+ do {
256
+ const result = await client.send(
257
+ new ListSecretsCommand({ NextToken: nextToken })
258
+ );
259
+
260
+ for (const secret of result.SecretList || []) {
261
+ if (
262
+ options.prefix &&
263
+ secret.Name &&
264
+ !secret.Name.startsWith(options.prefix)
265
+ ) {
266
+ continue;
267
+ }
268
+
269
+ if (requiredTags && requiredTags.length > 0) {
270
+ const available = tagsToRecord(secret.Tags);
271
+ const matchesAll = requiredTags.every(
272
+ (tag) => tag.Key && tag.Value && available?.[tag.Key] === tag.Value
273
+ );
274
+ if (!matchesAll) {
275
+ continue;
276
+ }
277
+ }
278
+
279
+ secrets.push({
280
+ name: secret.Name || '',
281
+ arn: secret.ARN,
282
+ description: secret.Description,
283
+ lastChangedDate: formatDate(secret.LastChangedDate)
284
+ });
285
+ }
286
+
287
+ nextToken = result.NextToken;
288
+ } while (nextToken);
289
+ } catch (error: unknown) {
290
+ return mapAwsError(error);
291
+ }
292
+
293
+ return secrets;
294
+ };
295
+
296
+ export const getSecretMetadata = async (
297
+ options: AwsSecretCommandOptions & { name: string }
298
+ ): Promise<SecretMetadata> => {
299
+ validateSecretName(options.name);
300
+ debug('getSecretMetadata called', { name: options.name });
301
+ const client = await createClient(options);
302
+
303
+ try {
304
+ const result = await client.send(
305
+ new DescribeSecretCommand({ SecretId: options.name })
306
+ );
307
+
308
+ return {
309
+ name: result.Name,
310
+ arn: result.ARN,
311
+ description: result.Description,
312
+ kmsKeyId: result.KmsKeyId,
313
+ deletedDate: formatDate(result.DeletedDate),
314
+ lastChangedDate: formatDate(result.LastChangedDate),
315
+ lastAccessedDate: formatDate(result.LastAccessedDate),
316
+ createdDate: formatDate(result.CreatedDate),
317
+ versionIdsToStages: result.VersionIdsToStages,
318
+ tags: tagsToRecord(result.Tags)
319
+ };
320
+ } catch (error: unknown) {
321
+ return mapAwsError(error, options.name);
322
+ }
323
+ };
324
+
325
+ export const secretExists = async (
326
+ options: AwsSecretCommandOptions & { name: string }
327
+ ): Promise<boolean> => {
328
+ validateSecretName(options.name);
329
+ debug('secretExists called', { name: options.name });
330
+ const client = await createClient(options);
331
+
332
+ try {
333
+ await client.send(new DescribeSecretCommand({ SecretId: options.name }));
334
+ return true;
335
+ } catch (error: unknown) {
336
+ const awsError = error as AWSLikeError;
337
+ if (awsError?.name === 'ResourceNotFoundException') {
338
+ return false;
339
+ }
340
+
341
+ return mapAwsError(error, options.name);
342
+ }
343
+ };
344
+
345
+ export const getSecretString = async (
346
+ options: AwsSecretCommandOptions & { name: string }
347
+ ): Promise<string> => {
348
+ validateSecretName(options.name);
349
+ debug('getSecretString called', { name: options.name });
350
+ const client = await createClient(options);
351
+
352
+ try {
353
+ const result = await client.send(
354
+ new GetSecretValueCommand({ SecretId: options.name })
355
+ );
356
+
357
+ if (typeof result.SecretString !== 'string') {
358
+ throw new Error(
359
+ `Secret "${options.name}" is not stored as a string value and cannot be edited with append/remove.`
360
+ );
361
+ }
362
+
363
+ return result.SecretString;
364
+ } catch (error: unknown) {
365
+ return mapAwsError(error, options.name);
366
+ }
367
+ };
368
+
369
+ export const deleteSecret = async (
370
+ options: SecretDeleteOptions
371
+ ): Promise<{ arn?: string; name?: string; deletedDate?: string }> => {
372
+ validateSecretName(options.name);
373
+ debug('deleteSecret called', {
374
+ name: options.name,
375
+ recoveryDays: options.recoveryDays,
376
+ forceDeleteWithoutRecovery: options.forceDeleteWithoutRecovery
377
+ });
378
+ const client = await createClient(options);
379
+
380
+ try {
381
+ const result = await client.send(
382
+ new DeleteSecretCommand({
383
+ SecretId: options.name,
384
+ RecoveryWindowInDays: options.recoveryDays,
385
+ ForceDeleteWithoutRecovery: options.forceDeleteWithoutRecovery
386
+ })
387
+ );
388
+
389
+ return {
390
+ arn: result.ARN,
391
+ name: result.Name,
392
+ deletedDate: formatDate(result.DeletionDate)
393
+ };
394
+ } catch (error: unknown) {
395
+ return mapAwsError(error, options.name);
396
+ }
397
+ };
@@ -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
 
@@ -31,10 +31,9 @@ const isCredentialsError = (error: unknown): error is AWSLikeError => {
31
31
  };
32
32
 
33
33
  const checkConnection = async (
34
- region?: string,
35
- credentials?: ReturnType<typeof fromIni>
34
+ config: ReturnType<typeof buildAwsClientConfig>
36
35
  ) => {
37
- const stsClient = new STSClient({ region, credentials });
36
+ const stsClient = new STSClient(config);
38
37
  const command = new GetCallerIdentityCommand({});
39
38
 
40
39
  try {
@@ -56,33 +55,21 @@ const checkConnection = async (
56
55
 
57
56
  export const secretsmanager = async (options: secretsmanagerType) => {
58
57
  const { secret, profile, region } = options;
59
- const {
60
- AWS_ACCESS_KEY_ID: awsAccessKeyId,
61
- AWS_SECRET_ACCESS_KEY: awsSecretAccessKey
62
- } = process.env;
58
+ const config = buildAwsClientConfig({ profile, region });
63
59
 
64
- let credentials;
65
60
  if (profile) {
66
61
  debug(`Using profile: ${profile}`);
67
- credentials = fromIni({ profile });
68
- } else if (awsAccessKeyId && awsSecretAccessKey) {
69
- debug('Using environment variables');
70
- credentials = undefined; // Will use environment variables automatically
71
- } else {
62
+ } else if (config.credentials) {
72
63
  debug('Using profile: default');
73
- credentials = fromIni({ profile: 'default' });
64
+ } else {
65
+ debug('Using environment variables');
74
66
  }
75
67
 
76
- const config = {
77
- region,
78
- credentials
79
- };
80
-
81
68
  if (!config.region) {
82
69
  debug('no region set');
83
70
  }
84
71
 
85
- const connected = await checkConnection(region, credentials);
72
+ const connected = await checkConnection(config);
86
73
 
87
74
  if (connected) {
88
75
  const client = new SecretsManagerClient(config);
@@ -28,6 +28,67 @@ 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
+ Use `env-secrets aws -s <secret-name>` together with either `-- <program-to-run>` to inject secret values into a spawned child process, or with `-o <file>` to write exports to a file.
36
+ `env-secrets aws secret <command>` is the lifecycle-management path and does not inject values by itself.
37
+
38
+ ### Commands
39
+
40
+ - `create` - Create a new secret
41
+ - `update` - Update secret value or metadata
42
+ - `append` - Add or overwrite one key in an existing JSON secret
43
+ - `remove` - Remove one or more keys from an existing JSON secret
44
+ - `upsert` (`import`) - Create or update one JSON secret from an env file
45
+ - `list` - List secrets
46
+ - `get` - Get secret metadata/version info
47
+ - `delete` - Delete a secret
48
+
49
+ ### Shared Options
50
+
51
+ - `-r, --region <region>` - AWS region
52
+ - `-p, --profile <profile>` - AWS profile
53
+ - `--output <format>` - Output format: `json` or `table` (default: `table`)
54
+
55
+ ### Command-Specific Options
56
+
57
+ - `create`
58
+ - `-n, --name <name>` (required)
59
+ - `-v, --value <value>` or `--value-stdin` or `-f, --file <path>`
60
+ - `-d, --description <description>`
61
+ - `-k, --kms-key-id <kmsKeyId>`
62
+ - `-t, --tag <key=value...>`
63
+ - `update`
64
+ - `-n, --name <name>` (required)
65
+ - `-v, --value <value>` or `--value-stdin` or `-f, --file <path>`
66
+ - `-d, --description <description>`
67
+ - `-k, --kms-key-id <kmsKeyId>`
68
+ - `upsert` / `import`
69
+ - `-f, --file <path>` (required)
70
+ - `-n, --name <name>` (required)
71
+ - `-d, --description <description>`
72
+ - `-k, --kms-key-id <kmsKeyId>`
73
+ - `-t, --tag <key=value...>` (applies on create)
74
+ - `append`
75
+ - `-n, --name <name>` (required)
76
+ - `--key <key>` (required)
77
+ - `-v, --value <value>` or `--value-stdin` or `-f, --file <path>`
78
+ - `remove`
79
+ - `-n, --name <name>` (required)
80
+ - `--key <key...>` (required, repeatable)
81
+ - `list`
82
+ - `--prefix <prefix>`
83
+ - `-t, --tag <key=value...>`
84
+ - `get`
85
+ - `-n, --name <name>` (required)
86
+ - `delete`
87
+ - `-n, --name <name>` (required)
88
+ - `-y, --yes` (required for delete)
89
+ - `--recovery-days <7-30>`
90
+ - `--force-delete-without-recovery`
91
+
31
92
  ### Global Options
32
93
 
33
94
  - `--help` - Show help information
@@ -131,6 +192,46 @@ env-secrets aws -s docker-secrets -r us-east-1 -o .env
131
192
  docker run --env-file .env my-app:latest
132
193
  ```
133
194
 
195
+ This is also the supported way to load values into your current shell session.
196
+ `env-secrets aws -s ... -- <command>` only affects the spawned child process.
197
+
198
+ ### Secret Management
199
+
200
+ ```bash
201
+ # Create
202
+ env-secrets aws secret create -n app/dev/api -v '{"API_KEY":"abc123"}' --output json
203
+
204
+ # Create from stdin (recommended for sensitive values)
205
+ echo -n 'super-secret-value' | env-secrets aws secret create -n app/dev/raw --value-stdin
206
+
207
+ # Update value
208
+ env-secrets aws secret update -n app/dev/api -v '{"API_KEY":"xyz789"}'
209
+
210
+ # Update from stdin
211
+ echo -n 'rotated-value' | env-secrets aws secret update -n app/dev/raw --value-stdin
212
+
213
+ # Upsert/import from env file
214
+ env-secrets aws secret upsert --file .env --name app/dev --output json
215
+ # alias
216
+ env-secrets aws secret import --file .env --name app/dev --output json
217
+
218
+ # Result: one secret named app/dev with SecretString like:
219
+ # {"API_KEY":"abc123","DATABASE_URL":"postgres://..."}
220
+
221
+ # Append/remove keys (JSON secret only)
222
+ env-secrets aws secret append -n app/dev --key JIRA_EMAIL_TOKEN -v blah --output json
223
+ env-secrets aws secret remove -n app/dev --key OLD_TOKEN --output json
224
+
225
+ # List
226
+ env-secrets aws secret list --prefix app/dev --output table
227
+
228
+ # Get metadata (does not print secret value)
229
+ env-secrets aws secret get -n app/dev/api --output json
230
+
231
+ # Delete with confirmation
232
+ env-secrets aws secret delete -n app/dev/raw --recovery-days 7 --yes
233
+ ```
234
+
134
235
  ## Environment Variable Behavior
135
236
 
136
237
  ### JSON Secret Parsing
@@ -4,6 +4,13 @@ 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
+
9
+ Use:
10
+
11
+ - `env-secrets aws -s <secret-name> -- <command>` to fetch/inject secret values while running a command (or use `-o <file>` to write env vars to a file).
12
+ - `env-secrets aws secret <command>` for lifecycle operations (`create`, `update`, `append`, `remove`, `upsert/import`, `list`, `get`, `delete`).
13
+
7
14
  ### Create a secret (JSON)
8
15
 
9
16
  ```bash
@@ -22,6 +29,58 @@ env-secrets aws -s local/sample -r us-east-1 -- echo $user/$password
22
29
  - `-r, --region` — AWS region (or `AWS_DEFAULT_REGION`)
23
30
  - `-p, --profile` — AWS profile to use
24
31
 
32
+ ### Inject into current shell
33
+
34
+ By default, variables are injected into the spawned child process only.
35
+ To load variables into your current shell session:
36
+
37
+ ```bash
38
+ env-secrets aws -s local/sample -r us-east-1 -o secrets.env
39
+ source secrets.env
40
+ ```
41
+
42
+ ### Secret Management Commands
43
+
44
+ ```bash
45
+ # Create
46
+ env-secrets aws secret create -n app/dev/api -v '{"API_KEY":"abc123"}' --output json
47
+
48
+ # Create from stdin
49
+ echo -n 'super-secret-value' | env-secrets aws secret create -n app/dev/raw --value-stdin
50
+
51
+ # Update
52
+ env-secrets aws secret update -n app/dev/api -v '{"API_KEY":"rotated"}'
53
+
54
+ # Append/remove keys on JSON secret
55
+ env-secrets aws secret append -n app/dev/api --key JIRA_EMAIL_TOKEN -v blah
56
+ env-secrets aws secret remove -n app/dev/api --key OLD_TOKEN
57
+
58
+ # Upsert/import from env file
59
+ env-secrets aws secret upsert --file .env --name app/dev --output json
60
+
61
+ # Result: one secret named app/dev with SecretString JSON:
62
+ # {"API_KEY":"abc123","DATABASE_URL":"postgres://..."}
63
+
64
+ # List
65
+ env-secrets aws secret list --prefix app/dev --output table
66
+
67
+ # Get metadata (does not print secret value)
68
+ env-secrets aws secret get -n app/dev/api --output json
69
+
70
+ # Delete (requires --yes)
71
+ env-secrets aws secret delete -n app/dev/raw --recovery-days 7 --yes
72
+ ```
73
+
74
+ Supported commands:
75
+
76
+ - `create` with `--value`, `--value-stdin`, or `--file`
77
+ - `update` with value and/or metadata changes
78
+ - `append` / `remove` for key-level edits on JSON object secrets
79
+ - `upsert/import` from env files containing `export KEY=value` or `KEY=value`, stored as one JSON secret via `--name`
80
+ - `list` with optional prefix/tag filters
81
+ - `get` for metadata/version details
82
+ - `delete` with recovery window or force-delete flags
83
+
25
84
  ### Tips
26
85
 
27
86
  - Use `DEBUG=env-secrets,env-secrets:secretsmanager` for verbose logs.