@karmaniverous/aws-secrets-manager-tools 0.1.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/LICENSE +28 -0
- package/README.md +96 -0
- package/dist/cli/aws-secrets-manager-tools/index.js +889 -0
- package/dist/index.d.ts +250 -0
- package/dist/mjs/index.js +873 -0
- package/package.json +148 -0
|
@@ -0,0 +1,889 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createCli } from '@karmaniverous/get-dotenv/cli';
|
|
3
|
+
import { cmdPlugin, batchPlugin, awsPlugin, initPlugin } from '@karmaniverous/get-dotenv/plugins';
|
|
4
|
+
import { readMergedOptions, z, definePlugin } from '@karmaniverous/get-dotenv/cliHost';
|
|
5
|
+
import { SecretsManagerClient, GetSecretValueCommand, PutSecretValueCommand, CreateSecretCommand, DeleteSecretCommand } from '@aws-sdk/client-secrets-manager';
|
|
6
|
+
import { dotenvExpand, getDotenvCliOptions2Options, editDotenvFile } from '@karmaniverous/get-dotenv';
|
|
7
|
+
import { omit, pick } from 'radash';
|
|
8
|
+
import { Buffer } from 'node:buffer';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Requirements addressed:
|
|
12
|
+
* - `push` should create only when the secret doesn't exist (not on any error).
|
|
13
|
+
*/
|
|
14
|
+
const getAwsErrorCode = (err) => {
|
|
15
|
+
if (!err || typeof err !== 'object')
|
|
16
|
+
return;
|
|
17
|
+
const e = err;
|
|
18
|
+
const code = e.name ?? e.code ?? e.Code;
|
|
19
|
+
return typeof code === 'string' ? code : undefined;
|
|
20
|
+
};
|
|
21
|
+
const isAwsErrorCode = (err, code) => getAwsErrorCode(err) === code;
|
|
22
|
+
const isResourceNotFoundError = (err) => isAwsErrorCode(err, 'ResourceNotFoundException');
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Requirements addressed:
|
|
26
|
+
* - Optional AWS X-Ray capture support.
|
|
27
|
+
* - Default behavior “auto”: only attempt capture when AWS_XRAY_DAEMON_ADDRESS
|
|
28
|
+
* is set.
|
|
29
|
+
* - Avoid importing/enabling X-Ray when the daemon address is not set (the
|
|
30
|
+
* X-Ray SDK will throw otherwise).
|
|
31
|
+
*/
|
|
32
|
+
const shouldEnableXray = (mode, daemonAddress) => {
|
|
33
|
+
if (mode === 'off')
|
|
34
|
+
return false;
|
|
35
|
+
if (mode === 'on')
|
|
36
|
+
return true;
|
|
37
|
+
return Boolean(daemonAddress);
|
|
38
|
+
};
|
|
39
|
+
const captureAwsSdkV3Client = async (client, { mode = 'auto', logger = console, daemonAddress = process.env.AWS_XRAY_DAEMON_ADDRESS, } = {}) => {
|
|
40
|
+
if (!shouldEnableXray(mode, daemonAddress))
|
|
41
|
+
return client;
|
|
42
|
+
if (!daemonAddress) {
|
|
43
|
+
throw new Error('X-Ray capture requested but AWS_XRAY_DAEMON_ADDRESS is not set.');
|
|
44
|
+
}
|
|
45
|
+
// Guarded dynamic import: some X-Ray SDK integrations throw when daemon
|
|
46
|
+
// configuration is missing, so do not import unless we are capturing.
|
|
47
|
+
let mod;
|
|
48
|
+
try {
|
|
49
|
+
mod = (await import('aws-xray-sdk'));
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
throw new Error("X-Ray capture is enabled but 'aws-xray-sdk' is not installed. Install it or set xray to 'off'.");
|
|
53
|
+
}
|
|
54
|
+
const AWSXRay = (mod.default ?? mod);
|
|
55
|
+
if (typeof AWSXRay.captureAWSv3Client !== 'function') {
|
|
56
|
+
logger.debug('aws-xray-sdk does not expose captureAWSv3Client', AWSXRay);
|
|
57
|
+
throw new Error('aws-xray-sdk missing captureAWSv3Client export.');
|
|
58
|
+
}
|
|
59
|
+
logger.debug('Enabling AWS X-Ray capture for AWS SDK v3 client.');
|
|
60
|
+
return AWSXRay.captureAWSv3Client(client);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Requirements addressed:
|
|
65
|
+
* - Provide a public tools-style wrapper `AwsSecretsManagerTools`.
|
|
66
|
+
* - Package consumers should not need to construct SecretsManagerClient; they
|
|
67
|
+
* should use `AwsSecretsManagerTools.init(...)` and optionally import AWS SDK
|
|
68
|
+
* Commands for advanced operations.
|
|
69
|
+
* - Expose the fully configured SDK client via `tools.client`.
|
|
70
|
+
* - Support optional AWS X-Ray capture:
|
|
71
|
+
* - Default “auto”: enable only when AWS_XRAY_DAEMON_ADDRESS is set.
|
|
72
|
+
* - In “auto”, if the daemon address is set but aws-xray-sdk is missing,
|
|
73
|
+
* throw with a clear message.
|
|
74
|
+
* - Enforce a minimal logger contract (debug/info/warn/error); do not attempt
|
|
75
|
+
* to polyfill or proxy unknown loggers.
|
|
76
|
+
* - Secret values are JSON object maps of env vars.
|
|
77
|
+
*/
|
|
78
|
+
const assertLogger = (candidate) => {
|
|
79
|
+
if (!candidate || typeof candidate !== 'object') {
|
|
80
|
+
throw new Error('logger must be an object with debug, info, warn, and error methods');
|
|
81
|
+
}
|
|
82
|
+
const logger = candidate;
|
|
83
|
+
if (typeof logger.debug !== 'function' ||
|
|
84
|
+
typeof logger.info !== 'function' ||
|
|
85
|
+
typeof logger.warn !== 'function' ||
|
|
86
|
+
typeof logger.error !== 'function') {
|
|
87
|
+
throw new Error('logger must implement debug, info, warn, and error methods; wrap/proxy your logger if needed');
|
|
88
|
+
}
|
|
89
|
+
return logger;
|
|
90
|
+
};
|
|
91
|
+
const parseEnvSecretMap = (secretString) => {
|
|
92
|
+
let parsed;
|
|
93
|
+
try {
|
|
94
|
+
parsed = JSON.parse(secretString);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
throw new Error('SecretString is not valid JSON.');
|
|
98
|
+
}
|
|
99
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
100
|
+
throw new Error('Secret JSON must be an object map.');
|
|
101
|
+
}
|
|
102
|
+
const out = {};
|
|
103
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
104
|
+
if (v === null) {
|
|
105
|
+
out[k] = undefined;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (typeof v === 'string') {
|
|
109
|
+
out[k] = v;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
throw new Error(`Secret JSON value for '${k}' must be a string or null.`);
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
};
|
|
116
|
+
const toSecretString = (value) => JSON.stringify(value);
|
|
117
|
+
/**
|
|
118
|
+
* Tools-style AWS Secrets Manager wrapper for env-map secrets.
|
|
119
|
+
*
|
|
120
|
+
* The secret payload is always a JSON object map of environment variables:
|
|
121
|
+
* `Record<string, string | undefined>`.
|
|
122
|
+
*
|
|
123
|
+
* Consumers should typically use the convenience methods on this class, and
|
|
124
|
+
* use {@link AwsSecretsManagerTools.client} as an escape hatch when they need
|
|
125
|
+
* AWS SDK operations not wrapped here.
|
|
126
|
+
*/
|
|
127
|
+
class AwsSecretsManagerTools {
|
|
128
|
+
/**
|
|
129
|
+
* The effective SDK client (captured when X-Ray is enabled).
|
|
130
|
+
*
|
|
131
|
+
* Import AWS SDK `*Command` classes as needed and call `tools.client.send(...)`.
|
|
132
|
+
*/
|
|
133
|
+
client;
|
|
134
|
+
/**
|
|
135
|
+
* The effective client config used to construct the base client.
|
|
136
|
+
*
|
|
137
|
+
* Note: this may contain functions/providers (e.g., credential providers).
|
|
138
|
+
*/
|
|
139
|
+
clientConfig;
|
|
140
|
+
/** The logger used by this wrapper and (when applicable) by the AWS client. */
|
|
141
|
+
logger;
|
|
142
|
+
/** Materialized X-Ray state (mode + enabled + daemonAddress when relevant). */
|
|
143
|
+
xray;
|
|
144
|
+
constructor({ client, clientConfig, logger, xray, }) {
|
|
145
|
+
this.client = client;
|
|
146
|
+
this.clientConfig = clientConfig;
|
|
147
|
+
this.logger = logger;
|
|
148
|
+
this.xray = xray;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Initialize an `AwsSecretsManagerTools` instance.
|
|
152
|
+
*
|
|
153
|
+
* This factory owns all setup (including optional X-Ray capture) so consumers
|
|
154
|
+
* do not need to construct a base Secrets Manager client themselves.
|
|
155
|
+
*
|
|
156
|
+
* @throws If `clientConfig.logger` is provided but does not implement
|
|
157
|
+
* `debug`, `info`, `warn`, and `error`.
|
|
158
|
+
* @throws If X-Ray capture is enabled (via `xray: 'on'` or `xray: 'auto'`
|
|
159
|
+
* with `AWS_XRAY_DAEMON_ADDRESS` set) but `aws-xray-sdk` is not installed.
|
|
160
|
+
* @throws If X-Ray capture is requested but `AWS_XRAY_DAEMON_ADDRESS` is not set.
|
|
161
|
+
*/
|
|
162
|
+
static async init({ clientConfig = {}, xray: xrayMode = 'auto', } = {}) {
|
|
163
|
+
const logger = assertLogger(clientConfig.logger ?? console);
|
|
164
|
+
const effectiveClientConfig = {
|
|
165
|
+
...clientConfig,
|
|
166
|
+
logger,
|
|
167
|
+
};
|
|
168
|
+
const base = new SecretsManagerClient(effectiveClientConfig);
|
|
169
|
+
const daemonAddress = process.env.AWS_XRAY_DAEMON_ADDRESS;
|
|
170
|
+
const enabled = shouldEnableXray(xrayMode, daemonAddress);
|
|
171
|
+
const xrayState = {
|
|
172
|
+
mode: xrayMode,
|
|
173
|
+
enabled,
|
|
174
|
+
...(enabled && daemonAddress ? { daemonAddress } : {}),
|
|
175
|
+
};
|
|
176
|
+
const effectiveClient = enabled
|
|
177
|
+
? await captureAwsSdkV3Client(base, {
|
|
178
|
+
mode: xrayMode,
|
|
179
|
+
logger,
|
|
180
|
+
daemonAddress,
|
|
181
|
+
})
|
|
182
|
+
: base;
|
|
183
|
+
return new AwsSecretsManagerTools({
|
|
184
|
+
client: effectiveClient,
|
|
185
|
+
clientConfig: effectiveClientConfig,
|
|
186
|
+
logger,
|
|
187
|
+
xray: xrayState,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Read a Secrets Manager secret and parse it as an env-map secret.
|
|
192
|
+
*
|
|
193
|
+
* @param opts - Options:
|
|
194
|
+
* - `secretId`: Secret name or ARN.
|
|
195
|
+
* - `versionId`: Optional version id to read.
|
|
196
|
+
*
|
|
197
|
+
* @throws If the secret is missing, binary, invalid JSON, or not an object map.
|
|
198
|
+
*/
|
|
199
|
+
async readEnvSecret(opts) {
|
|
200
|
+
const { secretId, versionId } = opts;
|
|
201
|
+
if (!secretId)
|
|
202
|
+
throw new Error('secretId is required');
|
|
203
|
+
this.logger.debug(`Getting secret value...`, { secretId, versionId });
|
|
204
|
+
const res = (await this.client.send(new GetSecretValueCommand({
|
|
205
|
+
SecretId: secretId,
|
|
206
|
+
...(versionId ? { VersionId: versionId } : {}),
|
|
207
|
+
})));
|
|
208
|
+
if (!res.SecretString) {
|
|
209
|
+
throw new Error('SecretString is missing (binary secrets not supported).');
|
|
210
|
+
}
|
|
211
|
+
return parseEnvSecretMap(res.SecretString);
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Write a new version value for an existing secret.
|
|
215
|
+
*
|
|
216
|
+
* This does not create the secret if it does not exist.
|
|
217
|
+
*
|
|
218
|
+
* @param opts - Options:
|
|
219
|
+
* - `secretId`: Secret name or ARN.
|
|
220
|
+
* - `value`: Env-map payload to store (JSON object map).
|
|
221
|
+
* - `versionId`: Optional client request token (idempotency).
|
|
222
|
+
*/
|
|
223
|
+
async updateEnvSecret(opts) {
|
|
224
|
+
const { secretId, value, versionId } = opts;
|
|
225
|
+
if (!secretId)
|
|
226
|
+
throw new Error('secretId is required');
|
|
227
|
+
this.logger.debug(`Putting secret value...`, { secretId, versionId });
|
|
228
|
+
await this.client.send(new PutSecretValueCommand({
|
|
229
|
+
SecretId: secretId,
|
|
230
|
+
SecretString: toSecretString(value),
|
|
231
|
+
...(versionId ? { ClientRequestToken: versionId } : {}),
|
|
232
|
+
}));
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Create a new secret containing an env-map.
|
|
236
|
+
*
|
|
237
|
+
* @param opts - Options:
|
|
238
|
+
* - `secretId`: Secret name (or ARN in some contexts).
|
|
239
|
+
* - `value`: Env-map payload to store (JSON object map).
|
|
240
|
+
* - `description`: Optional AWS secret description.
|
|
241
|
+
* - `forceOverwriteReplicaSecret`: See AWS CreateSecret behavior for replicas.
|
|
242
|
+
* - `versionId`: Optional client request token (idempotency).
|
|
243
|
+
*/
|
|
244
|
+
async createEnvSecret(opts) {
|
|
245
|
+
const { secretId, value, description, forceOverwriteReplicaSecret, versionId, } = opts;
|
|
246
|
+
if (!secretId)
|
|
247
|
+
throw new Error('secretId is required');
|
|
248
|
+
this.logger.debug(`Creating secret...`, { secretId, versionId });
|
|
249
|
+
await this.client.send(new CreateSecretCommand({
|
|
250
|
+
Name: secretId,
|
|
251
|
+
SecretString: toSecretString(value),
|
|
252
|
+
...(versionId ? { ClientRequestToken: versionId } : {}),
|
|
253
|
+
...(description ? { Description: description } : {}),
|
|
254
|
+
...(typeof forceOverwriteReplicaSecret === 'boolean'
|
|
255
|
+
? { ForceOverwriteReplicaSecret: forceOverwriteReplicaSecret }
|
|
256
|
+
: {}),
|
|
257
|
+
}));
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Put a secret value, creating the secret only when it does not exist.
|
|
261
|
+
*
|
|
262
|
+
* This creates only when the update fails with `ResourceNotFoundException`;
|
|
263
|
+
* other errors are re-thrown.
|
|
264
|
+
*
|
|
265
|
+
* @returns `'updated'` if updated; `'created'` if the secret was created.
|
|
266
|
+
* @throws Re-throws any non-ResourceNotFound AWS errors.
|
|
267
|
+
*/
|
|
268
|
+
async upsertEnvSecret({ secretId, value, }) {
|
|
269
|
+
try {
|
|
270
|
+
await this.updateEnvSecret({ secretId, value });
|
|
271
|
+
return 'updated';
|
|
272
|
+
}
|
|
273
|
+
catch (err) {
|
|
274
|
+
if (!isResourceNotFoundError(err))
|
|
275
|
+
throw err;
|
|
276
|
+
await this.createEnvSecret({ secretId, value });
|
|
277
|
+
return 'created';
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Delete a secret.
|
|
282
|
+
*
|
|
283
|
+
* By default, deletion is recoverable (AWS default recovery window) unless
|
|
284
|
+
* `forceDeleteWithoutRecovery` is set.
|
|
285
|
+
*
|
|
286
|
+
* @param opts - Options:
|
|
287
|
+
* - `secretId`: Secret name or ARN.
|
|
288
|
+
* - `recoveryWindowInDays`: Explicit recovery window to use.
|
|
289
|
+
* - `forceDeleteWithoutRecovery`: Dangerous: delete without recovery.
|
|
290
|
+
*
|
|
291
|
+
* @throws If both `recoveryWindowInDays` and `forceDeleteWithoutRecovery` are provided.
|
|
292
|
+
*/
|
|
293
|
+
async deleteSecret(opts) {
|
|
294
|
+
const { secretId, recoveryWindowInDays, forceDeleteWithoutRecovery } = opts;
|
|
295
|
+
if (!secretId)
|
|
296
|
+
throw new Error('secretId is required');
|
|
297
|
+
if (typeof recoveryWindowInDays !== 'undefined' &&
|
|
298
|
+
typeof forceDeleteWithoutRecovery !== 'undefined') {
|
|
299
|
+
throw new Error('recoveryWindowInDays and forceDeleteWithoutRecovery are mutually exclusive');
|
|
300
|
+
}
|
|
301
|
+
this.logger.debug(`Deleting secret...`, {
|
|
302
|
+
secretId,
|
|
303
|
+
recoveryWindowInDays,
|
|
304
|
+
forceDeleteWithoutRecovery,
|
|
305
|
+
});
|
|
306
|
+
await this.client.send(new DeleteSecretCommand({
|
|
307
|
+
SecretId: secretId,
|
|
308
|
+
...(typeof recoveryWindowInDays === 'number'
|
|
309
|
+
? { RecoveryWindowInDays: recoveryWindowInDays }
|
|
310
|
+
: {}),
|
|
311
|
+
...(typeof forceDeleteWithoutRecovery === 'boolean'
|
|
312
|
+
? { ForceDeleteWithoutRecovery: forceDeleteWithoutRecovery }
|
|
313
|
+
: {}),
|
|
314
|
+
}));
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Requirements addressed:
|
|
320
|
+
* - Secret name expansion expands against `{ ...process.env, ...ctx.dotenv }`.
|
|
321
|
+
* - include/exclude ignore unknown keys; use radash (no lodash).
|
|
322
|
+
*/
|
|
323
|
+
const buildExpansionEnv = (ctxDotenv) => ({
|
|
324
|
+
...process.env,
|
|
325
|
+
...ctxDotenv,
|
|
326
|
+
});
|
|
327
|
+
const expandSecretName = (raw, envRef) => dotenvExpand(raw, envRef) ?? raw;
|
|
328
|
+
const applyIncludeExclude = (env, { include, exclude, }) => {
|
|
329
|
+
let out = env;
|
|
330
|
+
if (exclude?.length)
|
|
331
|
+
out = omit(out, exclude);
|
|
332
|
+
if (include?.length)
|
|
333
|
+
out = pick(out, include);
|
|
334
|
+
return out;
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Requirements addressed:
|
|
339
|
+
* - Enforce AWS Secrets Manager SecretString size limits (65,536 bytes).
|
|
340
|
+
* - Provide safe parsing helpers for CLI-mapped inputs.
|
|
341
|
+
* - Render config-derived defaults in dynamic option help text.
|
|
342
|
+
* - Access aws plugin ctx state via runtime narrowing (no casts).
|
|
343
|
+
*/
|
|
344
|
+
const silentLogger = {
|
|
345
|
+
debug: () => {
|
|
346
|
+
// no-op
|
|
347
|
+
},
|
|
348
|
+
info: () => {
|
|
349
|
+
// no-op
|
|
350
|
+
},
|
|
351
|
+
warn: () => {
|
|
352
|
+
// no-op
|
|
353
|
+
},
|
|
354
|
+
error: () => {
|
|
355
|
+
// no-op
|
|
356
|
+
},
|
|
357
|
+
};
|
|
358
|
+
const requireString = (v, msg) => {
|
|
359
|
+
if (typeof v !== 'string' || !v)
|
|
360
|
+
throw new Error(msg);
|
|
361
|
+
return v;
|
|
362
|
+
};
|
|
363
|
+
const toNumber = (v) => {
|
|
364
|
+
if (typeof v === 'undefined')
|
|
365
|
+
return;
|
|
366
|
+
if (typeof v === 'number')
|
|
367
|
+
return v;
|
|
368
|
+
if (typeof v === 'string' && v.trim())
|
|
369
|
+
return Number(v);
|
|
370
|
+
return;
|
|
371
|
+
};
|
|
372
|
+
const assertBytesWithinSecretsManagerLimit = (value) => {
|
|
373
|
+
const s = JSON.stringify(value);
|
|
374
|
+
const bytes = Buffer.byteLength(s, 'utf8');
|
|
375
|
+
if (bytes > 65_536) {
|
|
376
|
+
throw new Error(`SecretString size ${String(bytes)} bytes exceeds 65536 bytes; narrow selection with --from/--include/--exclude.`);
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
const describeDefault = (v) => {
|
|
380
|
+
if (Array.isArray(v))
|
|
381
|
+
return v.length ? v.join(' ') : 'none';
|
|
382
|
+
if (typeof v === 'string' && v.trim())
|
|
383
|
+
return v;
|
|
384
|
+
return 'none';
|
|
385
|
+
};
|
|
386
|
+
const isRecord = (v) => typeof v === 'object' && v !== null;
|
|
387
|
+
const getAwsRegion = (ctx) => {
|
|
388
|
+
if (!isRecord(ctx.plugins))
|
|
389
|
+
return;
|
|
390
|
+
const aws = ctx.plugins['aws'];
|
|
391
|
+
if (!isRecord(aws))
|
|
392
|
+
return;
|
|
393
|
+
const region = aws['region'];
|
|
394
|
+
return typeof region === 'string' ? region : undefined;
|
|
395
|
+
};
|
|
396
|
+
const describeConfigKeyListDefaults = ({ cfgInclude, cfgExclude, }) => {
|
|
397
|
+
// Avoid throwing in help rendering: show an explicit invalid marker.
|
|
398
|
+
if (cfgInclude?.length && cfgExclude?.length) {
|
|
399
|
+
const msg = '(invalid: both set in config)';
|
|
400
|
+
return { includeDefault: msg, excludeDefault: msg };
|
|
401
|
+
}
|
|
402
|
+
return {
|
|
403
|
+
includeDefault: describeDefault(cfgExclude?.length ? undefined : cfgInclude),
|
|
404
|
+
excludeDefault: describeDefault(cfgInclude?.length ? undefined : cfgExclude),
|
|
405
|
+
};
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Requirements addressed:
|
|
410
|
+
* - Provide `aws secrets delete`.
|
|
411
|
+
* - Require `--force` for delete-without-recovery; otherwise use recoverable
|
|
412
|
+
* deletion and do not set RecoveryWindowInDays unless explicitly provided.
|
|
413
|
+
* - For config-backed plugin options, use plugin dynamic options to show
|
|
414
|
+
* composed defaults in help output.
|
|
415
|
+
*/
|
|
416
|
+
const registerDeleteCommand = ({ cli, plugin, }) => {
|
|
417
|
+
const del = cli
|
|
418
|
+
.ns('delete')
|
|
419
|
+
.description('Delete a Secrets Manager secret (recoverable by default).');
|
|
420
|
+
const delRecoveryOpt = del
|
|
421
|
+
.createOption('--recovery-window-days <number>', 'recovery window in days (omit to use AWS default)')
|
|
422
|
+
.conflicts('force');
|
|
423
|
+
const delForceOpt = del
|
|
424
|
+
.createOption('--force', 'force delete without recovery (DANGEROUS)')
|
|
425
|
+
.conflicts('recoveryWindowDays')
|
|
426
|
+
.default(false);
|
|
427
|
+
del
|
|
428
|
+
.addOption(plugin.createPluginDynamicOption(del, '-s, --secret-name <string>', (_helpCfg, pluginCfg) => `secret name (supports $VAR expansion) (default: ${pluginCfg.secretName ?? '$STACK_NAME'})`))
|
|
429
|
+
.addOption(delRecoveryOpt)
|
|
430
|
+
.addOption(delForceOpt)
|
|
431
|
+
.action(async (opts) => {
|
|
432
|
+
const bag = readMergedOptions(del);
|
|
433
|
+
const sdkLogger = bag.debug ? console : silentLogger;
|
|
434
|
+
const logger = console;
|
|
435
|
+
const ctx = cli.getCtx();
|
|
436
|
+
const cfg = plugin.readConfig(del);
|
|
437
|
+
const envRef = buildExpansionEnv(ctx.dotenv);
|
|
438
|
+
const secretNameRaw = opts.secretName ?? cfg.secretName ?? '$STACK_NAME';
|
|
439
|
+
const secretId = expandSecretName(secretNameRaw, envRef);
|
|
440
|
+
if (!secretId)
|
|
441
|
+
throw new Error('secret-name is required.');
|
|
442
|
+
const recoveryWindowInDays = toNumber(opts.recoveryWindowDays);
|
|
443
|
+
const region = getAwsRegion(ctx);
|
|
444
|
+
const tools = await AwsSecretsManagerTools.init({
|
|
445
|
+
clientConfig: region
|
|
446
|
+
? { region, logger: sdkLogger }
|
|
447
|
+
: { logger: sdkLogger },
|
|
448
|
+
});
|
|
449
|
+
logger.info(`Deleting secret '${secretId}' from AWS Secrets Manager...`);
|
|
450
|
+
await tools.deleteSecret({
|
|
451
|
+
secretId,
|
|
452
|
+
...(opts.force
|
|
453
|
+
? { forceDeleteWithoutRecovery: true }
|
|
454
|
+
: typeof recoveryWindowInDays === 'number'
|
|
455
|
+
? { recoveryWindowInDays }
|
|
456
|
+
: {}),
|
|
457
|
+
});
|
|
458
|
+
logger.info('Done.');
|
|
459
|
+
});
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Requirements addressed:
|
|
464
|
+
* - `push` selects a subset of `ctx.dotenv` keys using `ctx.dotenvProvenance`,
|
|
465
|
+
* matching only the effective provenance entry (last entry for a key).
|
|
466
|
+
* - `push` supports repeatable `--from <selector...>` with grammar:
|
|
467
|
+
* - file:<scope>:<privacy> (scope: global|env|*; privacy: public|private|*)
|
|
468
|
+
* - config:<configScope>:<scope>:<privacy> (configScope: packaged|project|*)
|
|
469
|
+
* - dynamic:<dynamicSource> (dynamicSource: config|programmatic|dynamicPath|*)
|
|
470
|
+
* - vars
|
|
471
|
+
* - `pull` uses `--to <scope>:<privacy>` (scope: global|env; privacy: public|private).
|
|
472
|
+
* - No path-based selector matching is supported.
|
|
473
|
+
* - Provenance entry shapes are sourced from get-dotenv’s public ctx types.
|
|
474
|
+
*/
|
|
475
|
+
const isOneOf = (v, allowed) => allowed.includes(v);
|
|
476
|
+
const parseParts = (raw) => raw
|
|
477
|
+
.split(':')
|
|
478
|
+
.map((p) => p.trim())
|
|
479
|
+
.filter(Boolean);
|
|
480
|
+
const parseFromSelector = (raw) => {
|
|
481
|
+
const parts = parseParts(raw);
|
|
482
|
+
const kind = parts[0];
|
|
483
|
+
if (parts.length === 1 && kind === 'vars')
|
|
484
|
+
return { kind: 'vars' };
|
|
485
|
+
if (kind === 'file') {
|
|
486
|
+
if (parts.length !== 3)
|
|
487
|
+
throw new Error(`Invalid --from selector: ${raw}`);
|
|
488
|
+
const scope = parts[1];
|
|
489
|
+
const privacy = parts[2];
|
|
490
|
+
if (!isOneOf(scope, ['global', 'env', '*']) ||
|
|
491
|
+
!isOneOf(privacy, ['public', 'private', '*'])) {
|
|
492
|
+
throw new Error(`Invalid --from selector: ${raw}`);
|
|
493
|
+
}
|
|
494
|
+
return { kind: 'file', scope, privacy };
|
|
495
|
+
}
|
|
496
|
+
if (kind === 'config') {
|
|
497
|
+
if (parts.length !== 4)
|
|
498
|
+
throw new Error(`Invalid --from selector: ${raw}`);
|
|
499
|
+
const configScope = parts[1];
|
|
500
|
+
const scope = parts[2];
|
|
501
|
+
const privacy = parts[3];
|
|
502
|
+
if (!isOneOf(configScope, ['packaged', 'project', '*']) ||
|
|
503
|
+
!isOneOf(scope, ['global', 'env', '*']) ||
|
|
504
|
+
!isOneOf(privacy, ['public', 'private', '*'])) {
|
|
505
|
+
throw new Error(`Invalid --from selector: ${raw}`);
|
|
506
|
+
}
|
|
507
|
+
return { kind: 'config', configScope, scope, privacy };
|
|
508
|
+
}
|
|
509
|
+
if (kind === 'dynamic') {
|
|
510
|
+
if (parts.length !== 2)
|
|
511
|
+
throw new Error(`Invalid --from selector: ${raw}`);
|
|
512
|
+
const dynamicSource = parts[1];
|
|
513
|
+
if (!isOneOf(dynamicSource, [
|
|
514
|
+
'config',
|
|
515
|
+
'programmatic',
|
|
516
|
+
'dynamicPath',
|
|
517
|
+
'*',
|
|
518
|
+
])) {
|
|
519
|
+
throw new Error(`Invalid --from selector: ${raw}`);
|
|
520
|
+
}
|
|
521
|
+
return { kind: 'dynamic', dynamicSource };
|
|
522
|
+
}
|
|
523
|
+
throw new Error(`Invalid --from selector: ${raw}`);
|
|
524
|
+
};
|
|
525
|
+
const parseToSelector = (raw) => {
|
|
526
|
+
const parts = parseParts(raw);
|
|
527
|
+
if (parts.length !== 2)
|
|
528
|
+
throw new Error(`Invalid --to selector: ${raw}`);
|
|
529
|
+
const scope = parts[0];
|
|
530
|
+
const privacy = parts[1];
|
|
531
|
+
if (!isOneOf(scope, ['global', 'env']) ||
|
|
532
|
+
!isOneOf(privacy, ['public', 'private'])) {
|
|
533
|
+
throw new Error(`Invalid --to selector: ${raw}`);
|
|
534
|
+
}
|
|
535
|
+
return { scope, privacy };
|
|
536
|
+
};
|
|
537
|
+
const wildcardMatch = (v, sel) => sel === '*' || v === sel;
|
|
538
|
+
const getEffectiveProvenanceEntry = (entries) => entries && entries.length ? entries[entries.length - 1] : undefined;
|
|
539
|
+
const matchesFromSelector = (entry, sel) => {
|
|
540
|
+
switch (entry.kind) {
|
|
541
|
+
case 'vars':
|
|
542
|
+
return sel.kind === 'vars';
|
|
543
|
+
case 'dynamic':
|
|
544
|
+
return (sel.kind === 'dynamic' &&
|
|
545
|
+
wildcardMatch(entry.dynamicSource, sel.dynamicSource));
|
|
546
|
+
case 'file':
|
|
547
|
+
return (sel.kind === 'file' &&
|
|
548
|
+
wildcardMatch(entry.scope, sel.scope) &&
|
|
549
|
+
wildcardMatch(entry.privacy, sel.privacy));
|
|
550
|
+
case 'config':
|
|
551
|
+
return (sel.kind === 'config' &&
|
|
552
|
+
wildcardMatch(entry.configScope, sel.configScope) &&
|
|
553
|
+
wildcardMatch(entry.scope, sel.scope) &&
|
|
554
|
+
wildcardMatch(entry.privacy, sel.privacy));
|
|
555
|
+
}
|
|
556
|
+
};
|
|
557
|
+
/**
|
|
558
|
+
* Select env values for a secret payload using provenance selectors.
|
|
559
|
+
*
|
|
560
|
+
* Notes:
|
|
561
|
+
* - Iterates keys from provenance (not from dotenv), so keys lacking provenance
|
|
562
|
+
* are excluded by default.
|
|
563
|
+
* - Uses only the effective entry (last entry) for matching.
|
|
564
|
+
* - Excludes keys whose effective value is undefined, or whose effective entry
|
|
565
|
+
* has `op: 'unset'`.
|
|
566
|
+
*/
|
|
567
|
+
const selectEnvByProvenance = (dotenv, provenance, selectors) => {
|
|
568
|
+
const out = {};
|
|
569
|
+
for (const [key, entries] of Object.entries(provenance)) {
|
|
570
|
+
const value = dotenv[key];
|
|
571
|
+
if (typeof value === 'undefined')
|
|
572
|
+
continue;
|
|
573
|
+
const effective = getEffectiveProvenanceEntry(entries);
|
|
574
|
+
if (!effective)
|
|
575
|
+
continue;
|
|
576
|
+
if (effective.op === 'unset')
|
|
577
|
+
continue;
|
|
578
|
+
if (selectors.some((s) => matchesFromSelector(effective, s))) {
|
|
579
|
+
out[key] = value;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
return out;
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Requirements addressed:
|
|
587
|
+
* - Support safe plugin defaults from get-dotenv config under `plugins['aws/secrets']`
|
|
588
|
+
* using a schema-typed config (no casts required at call sites).
|
|
589
|
+
* - CLI flags override config defaults.
|
|
590
|
+
* - include/exclude are mutually exclusive; unknown keys are ignored at filter time.
|
|
591
|
+
*/
|
|
592
|
+
const secretsPluginConfigSchema = z.object({
|
|
593
|
+
/**
|
|
594
|
+
* Default secret name for all `aws secrets` subcommands.
|
|
595
|
+
*
|
|
596
|
+
* Supports `$VAR` expansion at action time against `{ ...process.env, ...ctx.dotenv }`.
|
|
597
|
+
*/
|
|
598
|
+
secretName: z.string().optional(),
|
|
599
|
+
/**
|
|
600
|
+
* Default template extension used by `aws secrets pull` when the destination
|
|
601
|
+
* dotenv file is missing (e.g. `.env.local.template` -\> `.env.local`).
|
|
602
|
+
*/
|
|
603
|
+
templateExtension: z.string().optional(),
|
|
604
|
+
/**
|
|
605
|
+
* Defaults for `aws secrets push`.
|
|
606
|
+
*/
|
|
607
|
+
push: z
|
|
608
|
+
.object({
|
|
609
|
+
/**
|
|
610
|
+
* Default provenance selectors for determining which loaded keys are
|
|
611
|
+
* included in the secret payload.
|
|
612
|
+
*
|
|
613
|
+
* When omitted, the CLI default is `file:env:private`.
|
|
614
|
+
*/
|
|
615
|
+
from: z.array(z.string()).optional(),
|
|
616
|
+
/**
|
|
617
|
+
* Default include list applied after provenance selection.
|
|
618
|
+
*
|
|
619
|
+
* Mutually exclusive with `push.exclude`.
|
|
620
|
+
*/
|
|
621
|
+
include: z.array(z.string()).optional(),
|
|
622
|
+
/**
|
|
623
|
+
* Default exclude list applied after provenance selection.
|
|
624
|
+
*
|
|
625
|
+
* Mutually exclusive with `push.include`.
|
|
626
|
+
*/
|
|
627
|
+
exclude: z.array(z.string()).optional(),
|
|
628
|
+
})
|
|
629
|
+
.optional(),
|
|
630
|
+
/**
|
|
631
|
+
* Defaults for `aws secrets pull`.
|
|
632
|
+
*/
|
|
633
|
+
pull: z
|
|
634
|
+
.object({
|
|
635
|
+
/**
|
|
636
|
+
* Default destination selector for `aws secrets pull`.
|
|
637
|
+
*
|
|
638
|
+
* Format: `(global|env):(public|private)`, e.g. `env:private`.
|
|
639
|
+
*/
|
|
640
|
+
to: z.string().optional(),
|
|
641
|
+
/**
|
|
642
|
+
* Default include list applied to pulled keys before editing the target
|
|
643
|
+
* dotenv file.
|
|
644
|
+
*
|
|
645
|
+
* Mutually exclusive with `pull.exclude`.
|
|
646
|
+
*/
|
|
647
|
+
include: z.array(z.string()).optional(),
|
|
648
|
+
/**
|
|
649
|
+
* Default exclude list applied to pulled keys before editing the target
|
|
650
|
+
* dotenv file.
|
|
651
|
+
*
|
|
652
|
+
* Mutually exclusive with `pull.include`.
|
|
653
|
+
*/
|
|
654
|
+
exclude: z.array(z.string()).optional(),
|
|
655
|
+
})
|
|
656
|
+
.optional(),
|
|
657
|
+
});
|
|
658
|
+
const resolveIncludeExclude = ({ cliInclude, cliExclude, cfgInclude, cfgExclude, }) => {
|
|
659
|
+
// CLI overrides config: if either include/exclude is provided on CLI, ignore
|
|
660
|
+
// config’s include/exclude entirely.
|
|
661
|
+
const include = cliInclude ?? (cliExclude ? undefined : cfgInclude);
|
|
662
|
+
const exclude = cliExclude ?? (cliInclude ? undefined : cfgExclude);
|
|
663
|
+
if (include?.length && exclude?.length) {
|
|
664
|
+
throw new Error('--exclude and --include are mutually exclusive.');
|
|
665
|
+
}
|
|
666
|
+
return { include, exclude };
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Requirements addressed:
|
|
671
|
+
* - Provide `aws secrets pull`.
|
|
672
|
+
* - For config-backed plugin options, use plugin dynamic options to show
|
|
673
|
+
* composed defaults in help output.
|
|
674
|
+
* - Use get-dotenv precedence semantics for deterministic dotenv editing.
|
|
675
|
+
* - Replace scope/privacy flags with `--to <scope>:<privacy>`.
|
|
676
|
+
*/
|
|
677
|
+
const registerPullCommand = ({ cli, plugin, }) => {
|
|
678
|
+
const pull = cli
|
|
679
|
+
.ns('pull')
|
|
680
|
+
.description('Update local dotenv from a Secrets Manager secret (env-map).');
|
|
681
|
+
pull
|
|
682
|
+
.addOption(plugin.createPluginDynamicOption(pull, '-s, --secret-name <string>', (_helpCfg, pluginCfg) => `secret name (supports $VAR expansion) (default: ${pluginCfg.secretName ?? '$STACK_NAME'})`))
|
|
683
|
+
.addOption(plugin.createPluginDynamicOption(pull, '-t, --template-extension <string>', (_helpCfg, pluginCfg) => {
|
|
684
|
+
const def = pluginCfg.templateExtension ?? 'template';
|
|
685
|
+
return `dotenv template extension used when target file is missing (default: ${def})`;
|
|
686
|
+
}))
|
|
687
|
+
.addOption(plugin.createPluginDynamicOption(pull, '--to <scope:privacy>', (_helpCfg, pluginCfg) => {
|
|
688
|
+
const def = pluginCfg.pull?.to ?? 'env:private';
|
|
689
|
+
return `destination dotenv selector (global|env):(public|private) (default: ${def})`;
|
|
690
|
+
}))
|
|
691
|
+
.addOption(plugin
|
|
692
|
+
.createPluginDynamicOption(pull, '-e, --exclude <strings...>', (_helpCfg, pluginCfg) => {
|
|
693
|
+
const { excludeDefault } = describeConfigKeyListDefaults({
|
|
694
|
+
cfgInclude: pluginCfg.pull?.include,
|
|
695
|
+
cfgExclude: pluginCfg.pull?.exclude,
|
|
696
|
+
});
|
|
697
|
+
return `space-delimited list of keys to exclude from the pulled secret (default: ${excludeDefault})`;
|
|
698
|
+
})
|
|
699
|
+
.conflicts('include'))
|
|
700
|
+
.addOption(plugin
|
|
701
|
+
.createPluginDynamicOption(pull, '-i, --include <strings...>', (_helpCfg, pluginCfg) => {
|
|
702
|
+
const { includeDefault } = describeConfigKeyListDefaults({
|
|
703
|
+
cfgInclude: pluginCfg.pull?.include,
|
|
704
|
+
cfgExclude: pluginCfg.pull?.exclude,
|
|
705
|
+
});
|
|
706
|
+
return `space-delimited list of keys to include from the pulled secret (default: ${includeDefault})`;
|
|
707
|
+
})
|
|
708
|
+
.conflicts('exclude'))
|
|
709
|
+
.action(async (opts, command) => {
|
|
710
|
+
const logger = console;
|
|
711
|
+
const ctx = cli.getCtx();
|
|
712
|
+
const bag = readMergedOptions(command);
|
|
713
|
+
const rootOpts = getDotenvCliOptions2Options(bag);
|
|
714
|
+
const cfg = plugin.readConfig(pull);
|
|
715
|
+
const sdkLogger = bag.debug ? console : silentLogger;
|
|
716
|
+
const paths = rootOpts.paths ?? ['./'];
|
|
717
|
+
const dotenvToken = rootOpts.dotenvToken ?? '.env';
|
|
718
|
+
const privateToken = rootOpts.privateToken ?? 'local';
|
|
719
|
+
const toRaw = opts.to ?? cfg.pull?.to ?? 'env:private';
|
|
720
|
+
const to = parseToSelector(toRaw);
|
|
721
|
+
const envRef = buildExpansionEnv(ctx.dotenv);
|
|
722
|
+
const secretNameRaw = opts.secretName ?? cfg.secretName ?? '$STACK_NAME';
|
|
723
|
+
const secretId = expandSecretName(secretNameRaw, envRef);
|
|
724
|
+
if (!secretId)
|
|
725
|
+
throw new Error('secret-name is required.');
|
|
726
|
+
const region = getAwsRegion(ctx);
|
|
727
|
+
const tools = await AwsSecretsManagerTools.init({
|
|
728
|
+
clientConfig: region
|
|
729
|
+
? { region, logger: sdkLogger }
|
|
730
|
+
: { logger: sdkLogger },
|
|
731
|
+
});
|
|
732
|
+
logger.info(`Pulling secret '${secretId}' from AWS Secrets Manager...`);
|
|
733
|
+
const rawSecrets = await tools.readEnvSecret({ secretId });
|
|
734
|
+
const { include, exclude } = resolveIncludeExclude({
|
|
735
|
+
cliInclude: opts.include,
|
|
736
|
+
cliExclude: opts.exclude,
|
|
737
|
+
cfgInclude: cfg.pull?.include,
|
|
738
|
+
cfgExclude: cfg.pull?.exclude,
|
|
739
|
+
});
|
|
740
|
+
const secrets = applyIncludeExclude(rawSecrets, { include, exclude });
|
|
741
|
+
const templateExtension = opts.templateExtension ?? cfg.templateExtension ?? 'template';
|
|
742
|
+
const editCommon = {
|
|
743
|
+
paths,
|
|
744
|
+
dotenvToken,
|
|
745
|
+
privateToken,
|
|
746
|
+
privacy: to.privacy,
|
|
747
|
+
templateExtension,
|
|
748
|
+
};
|
|
749
|
+
const res = to.scope === 'env'
|
|
750
|
+
? await editDotenvFile(secrets, {
|
|
751
|
+
...editCommon,
|
|
752
|
+
scope: 'env',
|
|
753
|
+
env: requireString(bag.env ?? bag.defaultEnv, 'env is required (use --env or defaultEnv).'),
|
|
754
|
+
})
|
|
755
|
+
: await editDotenvFile(secrets, {
|
|
756
|
+
...editCommon,
|
|
757
|
+
scope: 'global',
|
|
758
|
+
});
|
|
759
|
+
logger.info(`Updated ${res.path}`);
|
|
760
|
+
});
|
|
761
|
+
};
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Requirements addressed:
|
|
765
|
+
* - Provide `aws secrets push`.
|
|
766
|
+
* - `push` selects payload keys using effective provenance entry only and
|
|
767
|
+
* repeatable `--from <selector>` selectors (default: file:env:private).
|
|
768
|
+
* - Enforce AWS Secrets Manager SecretString size limit (65,536 bytes).
|
|
769
|
+
* - Dynamic options must be registered on the command to drive typing + help.
|
|
770
|
+
*/
|
|
771
|
+
const registerPushCommand = ({ cli, plugin, }) => {
|
|
772
|
+
const push = cli
|
|
773
|
+
.ns('push')
|
|
774
|
+
.description('Create or update a Secrets Manager secret from selected loaded keys.');
|
|
775
|
+
push
|
|
776
|
+
.addOption(plugin.createPluginDynamicOption(push, '-s, --secret-name <string>', (_helpCfg, pluginCfg) => `secret name (supports $VAR expansion) (default: ${pluginCfg.secretName ?? '$STACK_NAME'})`))
|
|
777
|
+
// Repeatable: `--from <selector>` may be specified multiple times.
|
|
778
|
+
.addOption(plugin
|
|
779
|
+
.createPluginDynamicOption(push, '--from <selector>', (_helpCfg, pluginCfg) => {
|
|
780
|
+
const def = pluginCfg.push?.from?.length
|
|
781
|
+
? pluginCfg.push.from
|
|
782
|
+
: ['file:env:private'];
|
|
783
|
+
return `provenance selectors for secret payload keys (default: ${describeDefault(def)})`;
|
|
784
|
+
})
|
|
785
|
+
.argParser((value, previous) => [
|
|
786
|
+
...(previous ?? []),
|
|
787
|
+
value,
|
|
788
|
+
])
|
|
789
|
+
.default(Array()))
|
|
790
|
+
.addOption(plugin
|
|
791
|
+
.createPluginDynamicOption(push, '-e, --exclude <strings...>', (_helpCfg, pluginCfg) => {
|
|
792
|
+
const { excludeDefault } = describeConfigKeyListDefaults({
|
|
793
|
+
cfgInclude: pluginCfg.push?.include,
|
|
794
|
+
cfgExclude: pluginCfg.push?.exclude,
|
|
795
|
+
});
|
|
796
|
+
return `space-delimited list of environment variables to exclude (default: ${excludeDefault})`;
|
|
797
|
+
})
|
|
798
|
+
.conflicts('include'))
|
|
799
|
+
.addOption(plugin
|
|
800
|
+
.createPluginDynamicOption(push, '-i, --include <strings...>', (_helpCfg, pluginCfg) => {
|
|
801
|
+
const { includeDefault } = describeConfigKeyListDefaults({
|
|
802
|
+
cfgInclude: pluginCfg.push?.include,
|
|
803
|
+
cfgExclude: pluginCfg.push?.exclude,
|
|
804
|
+
});
|
|
805
|
+
return `space-delimited list of environment variables to include (default: ${includeDefault})`;
|
|
806
|
+
})
|
|
807
|
+
.conflicts('exclude'))
|
|
808
|
+
.action(async (opts) => {
|
|
809
|
+
const ctx = cli.getCtx();
|
|
810
|
+
const cfg = plugin.readConfig(push);
|
|
811
|
+
const bag = readMergedOptions(push);
|
|
812
|
+
const sdkLogger = bag.debug ? console : silentLogger;
|
|
813
|
+
const logger = console;
|
|
814
|
+
const fromRaw = opts.from?.length
|
|
815
|
+
? opts.from
|
|
816
|
+
: cfg.push?.from?.length
|
|
817
|
+
? cfg.push.from
|
|
818
|
+
: ['file:env:private'];
|
|
819
|
+
const fromSelectors = fromRaw.map(parseFromSelector);
|
|
820
|
+
const { include, exclude } = resolveIncludeExclude({
|
|
821
|
+
cliInclude: opts.include,
|
|
822
|
+
cliExclude: opts.exclude,
|
|
823
|
+
cfgInclude: cfg.push?.include,
|
|
824
|
+
cfgExclude: cfg.push?.exclude,
|
|
825
|
+
});
|
|
826
|
+
const envRef = buildExpansionEnv(ctx.dotenv);
|
|
827
|
+
const secretNameRaw = opts.secretName ?? cfg.secretName ?? '$STACK_NAME';
|
|
828
|
+
const secretId = expandSecretName(secretNameRaw, envRef);
|
|
829
|
+
if (!secretId)
|
|
830
|
+
throw new Error('secret-name is required.');
|
|
831
|
+
const selected = selectEnvByProvenance(ctx.dotenv, ctx.dotenvProvenance, fromSelectors);
|
|
832
|
+
const secrets = applyIncludeExclude(selected, { include, exclude });
|
|
833
|
+
assertBytesWithinSecretsManagerLimit(secrets);
|
|
834
|
+
const region = getAwsRegion(ctx);
|
|
835
|
+
const tools = await AwsSecretsManagerTools.init({
|
|
836
|
+
clientConfig: region
|
|
837
|
+
? { region, logger: sdkLogger }
|
|
838
|
+
: { logger: sdkLogger },
|
|
839
|
+
});
|
|
840
|
+
logger.info(`Pushing secret '${secretId}' to AWS Secrets Manager...`);
|
|
841
|
+
const mode = await tools.upsertEnvSecret({ secretId, value: secrets });
|
|
842
|
+
logger.info(mode === 'created' ? 'Created.' : 'Updated.');
|
|
843
|
+
});
|
|
844
|
+
};
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* Requirements addressed:
|
|
848
|
+
* - Provide get-dotenv plugin mounted as `aws secrets` with commands:
|
|
849
|
+
* - `aws secrets pull`
|
|
850
|
+
* - `aws secrets push`
|
|
851
|
+
* - `aws secrets delete`
|
|
852
|
+
* - Keep the plugin adapter thin: command registration is decomposed into
|
|
853
|
+
* dedicated modules; core behavior lives outside this file.
|
|
854
|
+
* - For config-backed plugin options, register dynamic options on the command
|
|
855
|
+
* so help reflects composed defaults and option parsing is typed.
|
|
856
|
+
*/
|
|
857
|
+
/**
|
|
858
|
+
* get-dotenv plugin that provides `aws secrets pull|push|delete`.
|
|
859
|
+
*
|
|
860
|
+
* Intended usage: mount under `awsPlugin().use(secretsPlugin())`.
|
|
861
|
+
*/
|
|
862
|
+
const secretsPlugin = () => {
|
|
863
|
+
const plugin = definePlugin({
|
|
864
|
+
ns: 'secrets',
|
|
865
|
+
configSchema: secretsPluginConfigSchema,
|
|
866
|
+
setup(cli) {
|
|
867
|
+
cli.description('AWS Secrets Manager helpers (env-map secrets).');
|
|
868
|
+
registerPullCommand({ cli, plugin });
|
|
869
|
+
registerPushCommand({ cli, plugin });
|
|
870
|
+
registerDeleteCommand({ cli, plugin });
|
|
871
|
+
},
|
|
872
|
+
});
|
|
873
|
+
return plugin;
|
|
874
|
+
};
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* Requirements addressed:
|
|
878
|
+
* - Replace sample CLI with a get-dotenv CLI alias `aws-secrets-manager-tools`.
|
|
879
|
+
* - Duplicate default get-dotenv CLI composition, but omit awsWhoamiPlugin.
|
|
880
|
+
* - Mount secrets plugin under aws: `awsPlugin().use(secretsPlugin())`.
|
|
881
|
+
*/
|
|
882
|
+
await createCli({
|
|
883
|
+
alias: 'aws-secrets-manager-tools',
|
|
884
|
+
compose: (program) => program
|
|
885
|
+
.use(cmdPlugin({ asDefault: true, optionAlias: '-c, --cmd <command...>' }))
|
|
886
|
+
.use(batchPlugin())
|
|
887
|
+
.use(awsPlugin().use(secretsPlugin()))
|
|
888
|
+
.use(initPlugin()),
|
|
889
|
+
})();
|