env-secrets 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -30,6 +30,19 @@ const exitWithError = (error) => {
30
30
  console.error(error instanceof Error ? error.message : String(error));
31
31
  process.exit(1);
32
32
  };
33
+ const parseSecretJsonObject = (secretName, value) => {
34
+ let parsed;
35
+ try {
36
+ parsed = JSON.parse(value);
37
+ }
38
+ catch (_a) {
39
+ throw new Error(`Secret "${secretName}" is not valid JSON. append/remove requires a JSON object secret.`);
40
+ }
41
+ if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
42
+ throw new Error(`Secret "${secretName}" must be a JSON object. append/remove does not support arrays or scalar values.`);
43
+ }
44
+ return parsed;
45
+ };
33
46
  // main program
34
47
  program
35
48
  .name('env-secrets')
@@ -92,6 +105,7 @@ secretCommand
92
105
  .requiredOption('-n, --name <name>', 'secret name')
93
106
  .option('-v, --value <value>', 'secret value')
94
107
  .option('--value-stdin', 'read secret value from stdin')
108
+ .option('-f, --file <path>', 'read secret value from local file')
95
109
  .option('-d, --description <description>', 'secret description')
96
110
  .option('-k, --kms-key-id <kmsKeyId>', 'kms key id')
97
111
  .option('-t, --tag <tag...>', 'tag in key=value format')
@@ -106,9 +120,9 @@ secretCommand
106
120
  const output = (_a = options.output) !== null && _a !== void 0 ? _a : (typeof globalOptions.output === 'string'
107
121
  ? globalOptions.output
108
122
  : 'table');
109
- const value = yield (0, helpers_1.resolveSecretValue)(options.value, options.valueStdin);
123
+ const value = yield (0, helpers_1.resolveSecretValue)(options.value, options.valueStdin, options.file);
110
124
  if (!value) {
111
- throw new Error('Secret value is required. Provide --value or --value-stdin.');
125
+ throw new Error('Secret value is required. Provide --value, --value-stdin, or --file.');
112
126
  }
113
127
  const result = yield (0, secretsmanager_admin_1.createSecret)({
114
128
  name: options.name,
@@ -135,6 +149,7 @@ secretCommand
135
149
  .requiredOption('-n, --name <name>', 'secret name')
136
150
  .option('-v, --value <value>', 'new secret value')
137
151
  .option('--value-stdin', 'read secret value from stdin')
152
+ .option('-f, --file <path>', 'read secret value from local file')
138
153
  .option('-d, --description <description>', 'secret description')
139
154
  .option('-k, --kms-key-id <kmsKeyId>', 'kms key id')
140
155
  .option('-p, --profile <profile>', 'profile to use')
@@ -148,9 +163,9 @@ secretCommand
148
163
  const output = (_b = options.output) !== null && _b !== void 0 ? _b : (typeof globalOptions.output === 'string'
149
164
  ? globalOptions.output
150
165
  : 'table');
151
- const value = yield (0, helpers_1.resolveSecretValue)(options.value, options.valueStdin);
166
+ const value = yield (0, helpers_1.resolveSecretValue)(options.value, options.valueStdin, options.file);
152
167
  if (!value && !options.description && !options.kmsKeyId) {
153
- throw new Error('Nothing to update. Provide --value/--value-stdin, --description, or --kms-key-id.');
168
+ throw new Error('Nothing to update. Provide --value/--value-stdin/--file, --description, or --kms-key-id.');
154
169
  }
155
170
  const result = yield (0, secretsmanager_admin_1.updateSecret)({
156
171
  name: options.name,
@@ -170,6 +185,226 @@ secretCommand
170
185
  exitWithError(error);
171
186
  }
172
187
  }));
188
+ secretCommand
189
+ .command('upsert')
190
+ .alias('import')
191
+ .description('create or update a secret from a local env file')
192
+ .requiredOption('-f, --file <path>', 'path to env file')
193
+ .requiredOption('-n, --name <name>', 'secret name')
194
+ .option('-d, --description <description>', 'secret description')
195
+ .option('-k, --kms-key-id <kmsKeyId>', 'kms key id')
196
+ .option('-t, --tag <tag...>', 'tag in key=value format (applies on create)')
197
+ .option('-p, --profile <profile>', 'profile to use')
198
+ .option('-r, --region <region>', 'region to use')
199
+ .option('--output <format>', 'output format: json|table')
200
+ .action((options, command) => __awaiter(void 0, void 0, void 0, function* () {
201
+ var _c;
202
+ try {
203
+ const { profile, region } = (0, helpers_1.resolveAwsScope)(options, command);
204
+ const globalOptions = command.optsWithGlobals();
205
+ const output = (_c = options.output) !== null && _c !== void 0 ? _c : (typeof globalOptions.output === 'string'
206
+ ? globalOptions.output
207
+ : 'table');
208
+ const parsed = yield (0, helpers_1.parseEnvSecretsFile)(options.file);
209
+ if (parsed.entries.length === 0) {
210
+ throw new Error('No env entries found. Include lines like KEY=value or export KEY=value.');
211
+ }
212
+ const payload = Object.fromEntries(parsed.entries.map((entry) => [entry.key, entry.value]));
213
+ const value = JSON.stringify(payload);
214
+ const rows = [];
215
+ let created = 0;
216
+ let updated = 0;
217
+ let failed = 0;
218
+ const skipped = parsed.skipped.length;
219
+ for (const skip of parsed.skipped) {
220
+ rows.push({
221
+ name: options.name,
222
+ status: 'skipped',
223
+ line: String(skip.line),
224
+ message: `${skip.reason}: ${skip.key}`
225
+ });
226
+ }
227
+ try {
228
+ try {
229
+ yield (0, secretsmanager_admin_1.createSecret)({
230
+ name: options.name,
231
+ value,
232
+ description: options.description,
233
+ kmsKeyId: options.kmsKeyId,
234
+ tags: options.tag,
235
+ profile,
236
+ region
237
+ });
238
+ created += 1;
239
+ rows.push({
240
+ name: options.name,
241
+ status: 'created',
242
+ message: `imported ${parsed.entries.length} keys`
243
+ });
244
+ }
245
+ catch (createError) {
246
+ const message = createError instanceof Error
247
+ ? createError.message
248
+ : String(createError);
249
+ if (!/already exists/i.test(message)) {
250
+ throw createError;
251
+ }
252
+ yield (0, secretsmanager_admin_1.updateSecret)({
253
+ name: options.name,
254
+ value,
255
+ description: options.description,
256
+ kmsKeyId: options.kmsKeyId,
257
+ profile,
258
+ region
259
+ });
260
+ updated += 1;
261
+ rows.push({
262
+ name: options.name,
263
+ status: 'updated',
264
+ message: `imported ${parsed.entries.length} keys`
265
+ });
266
+ }
267
+ }
268
+ catch (error) {
269
+ failed += 1;
270
+ rows.push({
271
+ name: options.name,
272
+ status: 'failed',
273
+ message: error instanceof Error ? error.message : String(error)
274
+ });
275
+ }
276
+ const summary = { created, updated, skipped, failed };
277
+ if (output === 'json') {
278
+ // eslint-disable-next-line no-console
279
+ console.log(JSON.stringify({ summary, results: rows }, null, 2));
280
+ if (failed > 0) {
281
+ process.exitCode = 1;
282
+ }
283
+ return;
284
+ }
285
+ (0, helpers_1.printData)((0, helpers_1.asOutputFormat)(output), [
286
+ { key: 'name', label: 'Name' },
287
+ { key: 'status', label: 'Status' },
288
+ { key: 'line', label: 'Line' },
289
+ { key: 'message', label: 'Message' }
290
+ ], rows);
291
+ // eslint-disable-next-line no-console
292
+ console.log(`Summary: created=${created}, updated=${updated}, skipped=${skipped}, failed=${failed}`);
293
+ if (failed > 0) {
294
+ process.exitCode = 1;
295
+ }
296
+ }
297
+ catch (error) {
298
+ exitWithError(error);
299
+ }
300
+ }));
301
+ secretCommand
302
+ .command('append')
303
+ .description('append or overwrite one key in an existing JSON secret')
304
+ .requiredOption('-n, --name <name>', 'secret name')
305
+ .requiredOption('--key <key>', 'key to append/update')
306
+ .option('-v, --value <value>', 'value for the key')
307
+ .option('--value-stdin', 'read value from stdin')
308
+ .option('-f, --file <path>', 'read value from local file')
309
+ .option('-p, --profile <profile>', 'profile to use')
310
+ .option('-r, --region <region>', 'region to use')
311
+ .option('--output <format>', 'output format: json|table')
312
+ .action((options, command) => __awaiter(void 0, void 0, void 0, function* () {
313
+ var _d;
314
+ try {
315
+ const { profile, region } = (0, helpers_1.resolveAwsScope)(options, command);
316
+ const globalOptions = command.optsWithGlobals();
317
+ const output = (_d = options.output) !== null && _d !== void 0 ? _d : (typeof globalOptions.output === 'string'
318
+ ? globalOptions.output
319
+ : 'table');
320
+ const value = yield (0, helpers_1.resolveSecretValue)(options.value, options.valueStdin, options.file);
321
+ if (!value) {
322
+ throw new Error('Append value is required. Provide --value, --value-stdin, or --file.');
323
+ }
324
+ const current = yield (0, secretsmanager_admin_1.getSecretString)({
325
+ name: options.name,
326
+ profile,
327
+ region
328
+ });
329
+ const payload = parseSecretJsonObject(options.name, current);
330
+ payload[options.key] = value;
331
+ const result = yield (0, secretsmanager_admin_1.updateSecret)({
332
+ name: options.name,
333
+ value: JSON.stringify(payload),
334
+ profile,
335
+ region
336
+ });
337
+ (0, helpers_1.printData)((0, helpers_1.asOutputFormat)(output), [
338
+ { key: 'name', label: 'Name' },
339
+ { key: 'arn', label: 'ARN' },
340
+ { key: 'versionId', label: 'VersionId' },
341
+ { key: 'key', label: 'Key' },
342
+ { key: 'action', label: 'Action' }
343
+ ], [
344
+ Object.assign(Object.assign({}, result), { key: options.key, action: 'appended' })
345
+ ]);
346
+ }
347
+ catch (error) {
348
+ exitWithError(error);
349
+ }
350
+ }));
351
+ secretCommand
352
+ .command('remove')
353
+ .description('remove one or more keys from an existing JSON secret')
354
+ .requiredOption('-n, --name <name>', 'secret name')
355
+ .requiredOption('--key <key...>', 'one or more keys to remove')
356
+ .option('-p, --profile <profile>', 'profile to use')
357
+ .option('-r, --region <region>', 'region to use')
358
+ .option('--output <format>', 'output format: json|table')
359
+ .action((options, command) => __awaiter(void 0, void 0, void 0, function* () {
360
+ var _e;
361
+ try {
362
+ const { profile, region } = (0, helpers_1.resolveAwsScope)(options, command);
363
+ const globalOptions = command.optsWithGlobals();
364
+ const output = (_e = options.output) !== null && _e !== void 0 ? _e : (typeof globalOptions.output === 'string'
365
+ ? globalOptions.output
366
+ : 'table');
367
+ const keys = options.key;
368
+ const current = yield (0, secretsmanager_admin_1.getSecretString)({
369
+ name: options.name,
370
+ profile,
371
+ region
372
+ });
373
+ const payload = parseSecretJsonObject(options.name, current);
374
+ const removed = [];
375
+ const missing = [];
376
+ for (const key of keys) {
377
+ if (Object.prototype.hasOwnProperty.call(payload, key)) {
378
+ delete payload[key];
379
+ removed.push(key);
380
+ }
381
+ else {
382
+ missing.push(key);
383
+ }
384
+ }
385
+ if (removed.length === 0) {
386
+ throw new Error(`None of the requested keys exist in secret "${options.name}".`);
387
+ }
388
+ const result = yield (0, secretsmanager_admin_1.updateSecret)({
389
+ name: options.name,
390
+ value: JSON.stringify(payload),
391
+ profile,
392
+ region
393
+ });
394
+ (0, helpers_1.printData)((0, helpers_1.asOutputFormat)(output), [
395
+ { key: 'name', label: 'Name' },
396
+ { key: 'arn', label: 'ARN' },
397
+ { key: 'versionId', label: 'VersionId' },
398
+ { key: 'removed', label: 'RemovedKeys' },
399
+ { key: 'missing', label: 'MissingKeys' }
400
+ ], [
401
+ Object.assign(Object.assign({}, result), { removed: removed.join(','), missing: missing.join(',') })
402
+ ]);
403
+ }
404
+ catch (error) {
405
+ exitWithError(error);
406
+ }
407
+ }));
173
408
  secretCommand
174
409
  .command('list')
175
410
  .description('list secrets in AWS Secrets Manager')
@@ -179,11 +414,11 @@ secretCommand
179
414
  .option('-r, --region <region>', 'region to use')
180
415
  .option('--output <format>', 'output format: json|table')
181
416
  .action((options, command) => __awaiter(void 0, void 0, void 0, function* () {
182
- var _c;
417
+ var _f;
183
418
  try {
184
419
  const { profile, region } = (0, helpers_1.resolveAwsScope)(options, command);
185
420
  const globalOptions = command.optsWithGlobals();
186
- const output = (_c = options.output) !== null && _c !== void 0 ? _c : (typeof globalOptions.output === 'string'
421
+ const output = (_f = options.output) !== null && _f !== void 0 ? _f : (typeof globalOptions.output === 'string'
187
422
  ? globalOptions.output
188
423
  : 'table');
189
424
  const result = yield (0, secretsmanager_admin_1.listSecrets)({
@@ -215,11 +450,11 @@ secretCommand
215
450
  .option('-r, --region <region>', 'region to use')
216
451
  .option('--output <format>', 'output format: json|table')
217
452
  .action((options, command) => __awaiter(void 0, void 0, void 0, function* () {
218
- var _d;
453
+ var _g;
219
454
  try {
220
455
  const { profile, region } = (0, helpers_1.resolveAwsScope)(options, command);
221
456
  const globalOptions = command.optsWithGlobals();
222
- const output = (_d = options.output) !== null && _d !== void 0 ? _d : (typeof globalOptions.output === 'string'
457
+ const output = (_g = options.output) !== null && _g !== void 0 ? _g : (typeof globalOptions.output === 'string'
223
458
  ? globalOptions.output
224
459
  : 'table');
225
460
  const result = yield (0, secretsmanager_admin_1.getSecretMetadata)({
@@ -261,11 +496,11 @@ secretCommand
261
496
  .option('-r, --region <region>', 'region to use')
262
497
  .option('--output <format>', 'output format: json|table')
263
498
  .action((options, command) => __awaiter(void 0, void 0, void 0, function* () {
264
- var _e;
499
+ var _h;
265
500
  try {
266
501
  const { profile, region } = (0, helpers_1.resolveAwsScope)(options, command);
267
502
  const globalOptions = command.optsWithGlobals();
268
- const output = (_e = options.output) !== null && _e !== void 0 ? _e : (typeof globalOptions.output === 'string'
503
+ const output = (_h = options.output) !== null && _h !== void 0 ? _h : (typeof globalOptions.output === 'string'
269
504
  ? globalOptions.output
270
505
  : 'table');
271
506
  if (!options.yes) {
@@ -12,7 +12,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
12
12
  return (mod && mod.__esModule) ? mod : { "default": mod };
13
13
  };
14
14
  Object.defineProperty(exports, "__esModule", { value: true });
15
- exports.deleteSecret = exports.getSecretMetadata = exports.listSecrets = exports.updateSecret = exports.createSecret = exports.validateSecretName = void 0;
15
+ exports.deleteSecret = exports.getSecretString = exports.secretExists = exports.getSecretMetadata = exports.listSecrets = exports.updateSecret = exports.createSecret = exports.validateSecretName = void 0;
16
16
  const client_secrets_manager_1 = require("@aws-sdk/client-secrets-manager");
17
17
  const client_sts_1 = require("@aws-sdk/client-sts");
18
18
  const debug_1 = __importDefault(require("debug"));
@@ -213,6 +213,39 @@ const getSecretMetadata = (options) => __awaiter(void 0, void 0, void 0, functio
213
213
  }
214
214
  });
215
215
  exports.getSecretMetadata = getSecretMetadata;
216
+ const secretExists = (options) => __awaiter(void 0, void 0, void 0, function* () {
217
+ (0, exports.validateSecretName)(options.name);
218
+ debug('secretExists called', { name: options.name });
219
+ const client = yield createClient(options);
220
+ try {
221
+ yield client.send(new client_secrets_manager_1.DescribeSecretCommand({ SecretId: options.name }));
222
+ return true;
223
+ }
224
+ catch (error) {
225
+ const awsError = error;
226
+ if ((awsError === null || awsError === void 0 ? void 0 : awsError.name) === 'ResourceNotFoundException') {
227
+ return false;
228
+ }
229
+ return mapAwsError(error, options.name);
230
+ }
231
+ });
232
+ exports.secretExists = secretExists;
233
+ const getSecretString = (options) => __awaiter(void 0, void 0, void 0, function* () {
234
+ (0, exports.validateSecretName)(options.name);
235
+ debug('getSecretString called', { name: options.name });
236
+ const client = yield createClient(options);
237
+ try {
238
+ const result = yield client.send(new client_secrets_manager_1.GetSecretValueCommand({ SecretId: options.name }));
239
+ if (typeof result.SecretString !== 'string') {
240
+ throw new Error(`Secret "${options.name}" is not stored as a string value and cannot be edited with append/remove.`);
241
+ }
242
+ return result.SecretString;
243
+ }
244
+ catch (error) {
245
+ return mapAwsError(error, options.name);
246
+ }
247
+ });
248
+ exports.getSecretString = getSecretString;
216
249
  const deleteSecret = (options) => __awaiter(void 0, void 0, void 0, function* () {
217
250
  (0, exports.validateSecretName)(options.name);
218
251
  debug('deleteSecret called', {
package/docs/AWS.md CHANGED
@@ -153,6 +153,9 @@ 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 append`
157
+ - `env-secrets aws secret remove`
158
+ - `env-secrets aws secret upsert` (alias: `import`)
156
159
  - `env-secrets aws secret list`
157
160
  - `env-secrets aws secret get`
158
161
  - `env-secrets aws secret delete`
@@ -160,6 +163,31 @@ In addition to injecting variables into a process, `env-secrets` can manage AWS
160
163
  `aws secret` subcommands consistently honor `--region`, `--profile`, and `--output`.
161
164
  Use these options directly with each subcommand.
162
165
 
166
+ ### `aws -s` vs `aws secret ...`
167
+
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`).
170
+
171
+ Example:
172
+
173
+ ```bash
174
+ # inject secret values
175
+ env-secrets aws -s my-app/dev/api -r us-east-1 -- node app.js
176
+
177
+ # manage secrets
178
+ env-secrets aws secret get -n my-app/dev/api -r us-east-1 --output json
179
+ ```
180
+
181
+ ### Load secrets into your current shell
182
+
183
+ `env-secrets aws -s ... -- <command>` injects variables into the spawned child process only.
184
+ If you want variables in your current shell session, write exports to a file and source it:
185
+
186
+ ```bash
187
+ env-secrets aws -s my-app/dev/api -r us-east-1 -o secrets.env
188
+ source secrets.env
189
+ ```
190
+
163
191
  ### Secret Management Examples
164
192
 
165
193
  1. **Create a secret with inline value:**
@@ -184,7 +212,28 @@ Use these options directly with each subcommand.
184
212
  env-secrets aws secret update -n my-app/dev/api -v '{"API_KEY":"rotated"}' -r us-east-1
185
213
  ```
186
214
 
187
- 4. **List secrets by prefix:**
215
+ 4. **Upsert from an env file into one JSON secret (`export KEY=value` or `KEY=value`):**
216
+
217
+ ```bash
218
+ env-secrets aws secret upsert --file .env --name my-app/dev -r us-east-1 --output json
219
+ # alias:
220
+ env-secrets aws secret import --file .env --name my-app/dev -r us-east-1 --output json
221
+ ```
222
+
223
+ This creates/updates one secret named `my-app/dev` with a JSON payload like:
224
+
225
+ ```json
226
+ { "API_KEY": "abc123", "DATABASE_URL": "postgres://..." }
227
+ ```
228
+
229
+ 5. **Append/remove keys in an existing JSON secret:**
230
+
231
+ ```bash
232
+ env-secrets aws secret append -n my-app/dev --key JIRA_EMAIL_TOKEN -v blah -r us-east-1
233
+ env-secrets aws secret remove -n my-app/dev --key OLD_TOKEN -r us-east-1
234
+ ```
235
+
236
+ 6. **List secrets by prefix:**
188
237
 
189
238
  ```bash
190
239
  env-secrets aws secret list --prefix my-app/dev -r us-east-1 --output table
@@ -197,13 +246,13 @@ Use these options directly with each subcommand.
197
246
  env-secrets aws secret list --prefix my-app/dev -r us-east-1 --output json
198
247
  ```
199
248
 
200
- 5. **Get metadata and version info (without printing secret value):**
249
+ 7. **Get metadata and version info (without printing secret value):**
201
250
 
202
251
  ```bash
203
252
  env-secrets aws secret get -n my-app/dev/api -r us-east-1 --output json
204
253
  ```
205
254
 
206
- 6. **Delete with explicit confirmation:**
255
+ 8. **Delete with explicit confirmation:**
207
256
 
208
257
  ```bash
209
258
  env-secrets aws secret delete -n my-app/dev/raw --recovery-days 7 --yes -r us-east-1
@@ -212,6 +261,9 @@ Use these options directly with each subcommand.
212
261
  ### Secret Management Safety Notes
213
262
 
214
263
  - `delete` requires `--yes`.
264
+ - `create`/`update` accept `--value`, `--value-stdin`, or `--file` (use only one).
265
+ - `append` and `remove` require the secret value to be a JSON object.
266
+ - `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`.
215
267
  - Use `--value-stdin` to avoid shell history leakage for sensitive values.
216
268
  - Use either `--recovery-days` or `--force-delete-without-recovery` for delete operations.
217
269
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "env-secrets",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
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)",
@@ -52,9 +52,9 @@
52
52
  "typescript": "^4.9.5"
53
53
  },
54
54
  "dependencies": {
55
- "@aws-sdk/client-secrets-manager": "^3.990.0",
56
- "@aws-sdk/client-sts": "^3.990.0",
57
- "@aws-sdk/credential-providers": "^3.990.0",
55
+ "@aws-sdk/client-secrets-manager": "^3.996.0",
56
+ "@aws-sdk/client-sts": "^3.996.0",
57
+ "@aws-sdk/credential-providers": "^3.996.0",
58
58
  "commander": "^9.5.0",
59
59
  "debug": "^4.4.3"
60
60
  },
@@ -1,8 +1,19 @@
1
+ import { readFile } from 'node:fs/promises';
2
+
1
3
  export type OutputFormat = 'json' | 'table';
2
4
  export interface AwsScopeOptions {
3
5
  profile?: string;
4
6
  region?: string;
5
7
  }
8
+ export interface EnvSecretEntry {
9
+ key: string;
10
+ value: string;
11
+ line: number;
12
+ }
13
+ export interface ParsedEnvSecrets {
14
+ entries: EnvSecretEntry[];
15
+ skipped: Array<{ key: string; line: number; reason: string }>;
16
+ }
6
17
 
7
18
  interface CommandLikeWithGlobalOpts {
8
19
  optsWithGlobals?: () => Record<string, unknown>;
@@ -105,10 +116,18 @@ export const readStdin = async (stdin: NodeJS.ReadStream = process.stdin) => {
105
116
 
106
117
  export const resolveSecretValue = async (
107
118
  value?: string,
108
- valueStdin?: boolean
119
+ valueStdin?: boolean,
120
+ valueFile?: string
109
121
  ): Promise<string | undefined> => {
110
- if (value && valueStdin) {
111
- throw new Error('Use either --value or --value-stdin, not both.');
122
+ const providedSources = [
123
+ value !== undefined,
124
+ valueStdin === true,
125
+ valueFile !== undefined
126
+ ].filter(Boolean).length;
127
+ if (providedSources > 1) {
128
+ throw new Error(
129
+ 'Use only one secret value source: --value, --value-stdin, or --file.'
130
+ );
112
131
  }
113
132
 
114
133
  if (valueStdin) {
@@ -120,9 +139,85 @@ export const resolveSecretValue = async (
120
139
  return await readStdin();
121
140
  }
122
141
 
142
+ if (valueFile) {
143
+ const content = await readFile(valueFile, 'utf8');
144
+ return content.replace(/\r?\n$/, '');
145
+ }
146
+
123
147
  return value;
124
148
  };
125
149
 
150
+ const parseEnvLine = (
151
+ line: string,
152
+ lineNumber: number
153
+ ): { key: string; value: string } | undefined => {
154
+ const trimmed = line.trim();
155
+ if (!trimmed || trimmed.startsWith('#')) {
156
+ return undefined;
157
+ }
158
+
159
+ const candidate = trimmed.startsWith('export ')
160
+ ? trimmed.slice('export '.length).trimStart()
161
+ : trimmed;
162
+ const separatorIndex = candidate.indexOf('=');
163
+
164
+ if (separatorIndex <= 0) {
165
+ throw new Error(
166
+ `Malformed env line ${lineNumber}. Expected KEY=value or export KEY=value.`
167
+ );
168
+ }
169
+
170
+ const key = candidate.slice(0, separatorIndex).trim();
171
+ const value = candidate.slice(separatorIndex + 1).trim();
172
+
173
+ if (!key) {
174
+ throw new Error(
175
+ `Malformed env line ${lineNumber}. Expected KEY=value or export KEY=value.`
176
+ );
177
+ }
178
+
179
+ return { key, value };
180
+ };
181
+
182
+ export const parseEnvSecrets = (content: string): ParsedEnvSecrets => {
183
+ const seenKeys = new Set<string>();
184
+ const entries: EnvSecretEntry[] = [];
185
+ const skipped: Array<{ key: string; line: number; reason: string }> = [];
186
+
187
+ const lines = content.split(/\r?\n/);
188
+ for (let index = 0; index < lines.length; index += 1) {
189
+ const parsed = parseEnvLine(lines[index], index + 1);
190
+ if (!parsed) {
191
+ continue;
192
+ }
193
+
194
+ if (seenKeys.has(parsed.key)) {
195
+ skipped.push({
196
+ key: parsed.key,
197
+ line: index + 1,
198
+ reason: 'duplicate key'
199
+ });
200
+ continue;
201
+ }
202
+
203
+ seenKeys.add(parsed.key);
204
+ entries.push({
205
+ key: parsed.key,
206
+ value: parsed.value,
207
+ line: index + 1
208
+ });
209
+ }
210
+
211
+ return { entries, skipped };
212
+ };
213
+
214
+ export const parseEnvSecretsFile = async (
215
+ path: string
216
+ ): Promise<ParsedEnvSecrets> => {
217
+ const content = await readFile(path, 'utf8');
218
+ return parseEnvSecrets(content);
219
+ };
220
+
126
221
  export const resolveAwsScope = (
127
222
  options: AwsScopeOptions,
128
223
  command?: CommandLikeWithGlobalOpts