configorama 1.0.2 → 1.2.1
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/README.md +41 -1
- package/cli.js +14 -1
- package/package.json +18 -6
- package/plugins/cloudformation/README.md +79 -0
- package/plugins/cloudformation/credentials.js +231 -0
- package/plugins/cloudformation/index.js +204 -0
- package/plugins/cloudformation/package-lock.json +657 -0
- package/plugins/cloudformation/package.json +24 -0
- package/plugins/onepassword/README.md +175 -0
- package/plugins/onepassword/fields.js +126 -0
- package/plugins/onepassword/index.js +356 -0
- package/plugins/onepassword/normalize.js +157 -0
- package/plugins/onepassword/op-cli.js +117 -0
- package/plugins/onepassword/package.json +15 -0
- package/plugins/onepassword/parser.js +70 -0
- package/plugins/onepassword/sync-factory.js +15 -0
- package/src/errors.js +3 -0
- package/src/index.js +27 -1
- package/src/main.js +24 -32
- package/src/parsers/dotenv.js +13 -0
- package/src/resolvers/valueFromFile.js +2 -1
- package/src/sync.js +27 -0
- package/src/utils/PromiseTracker.js +11 -4
- package/src/utils/PromiseTracker.test.js +48 -0
- package/src/utils/introspection/audit.js +39 -9
- package/src/utils/parsing/detectFormat.js +70 -0
- package/src/utils/parsing/detectFormat.test.js +68 -0
- package/src/utils/parsing/parse.js +11 -37
- package/src/utils/paths/fileType.js +27 -0
- package/src/utils/paths/fileType.test.js +41 -0
- package/src/utils/paths/findLineForKey.js +24 -1
- package/src/utils/paths/findLineForKey.test.js +20 -1
- package/src/utils/setup/applyAnswers.js +41 -0
- package/src/utils/setup/setupEngine.js +85 -0
- package/src/utils/setup/stdoutRedirect.js +48 -0
- package/src/utils/setup/writeAnswers.js +50 -0
- package/src/utils/setup/writeDotenv.js +142 -0
- package/src/utils/ui/configWizard.js +20 -1
- package/types/src/capabilities.d.ts +8 -0
- package/types/src/capabilities.d.ts.map +1 -0
- package/types/src/display.d.ts +62 -0
- package/types/src/display.d.ts.map +1 -0
- package/types/src/errors.d.ts +20 -0
- package/types/src/errors.d.ts.map +1 -0
- package/types/src/index.d.ts +119 -0
- package/types/src/index.d.ts.map +1 -1
- package/types/src/main.d.ts +23 -16
- package/types/src/main.d.ts.map +1 -1
- package/types/src/metadata.d.ts +28 -0
- package/types/src/metadata.d.ts.map +1 -0
- package/types/src/parsers/dotenv.d.ts +6 -0
- package/types/src/parsers/dotenv.d.ts.map +1 -0
- package/types/src/parsers/esm.d.ts.map +1 -1
- package/types/src/parsers/typescript.d.ts.map +1 -1
- package/types/src/resolvers/valueFromCron.d.ts.map +1 -1
- package/types/src/resolvers/valueFromEval.d.ts.map +1 -1
- package/types/src/resolvers/valueFromFile.d.ts +4 -0
- package/types/src/resolvers/valueFromFile.d.ts.map +1 -1
- package/types/src/resolvers/valueFromGit.d.ts.map +1 -1
- package/types/src/resolvers/valueFromOptions.d.ts +1 -0
- package/types/src/resolvers/valueFromOptions.d.ts.map +1 -1
- package/types/src/sync.d.ts.map +1 -1
- package/types/src/utils/BoundedMap.d.ts +10 -0
- package/types/src/utils/BoundedMap.d.ts.map +1 -0
- package/types/src/utils/PromiseTracker.d.ts +1 -1
- package/types/src/utils/PromiseTracker.d.ts.map +1 -1
- package/types/src/utils/filters/filterArgs.d.ts +13 -0
- package/types/src/utils/filters/filterArgs.d.ts.map +1 -0
- package/types/src/utils/filters/oneOf.d.ts +7 -0
- package/types/src/utils/filters/oneOf.d.ts.map +1 -0
- package/types/src/utils/introspection/audit.d.ts +14 -0
- package/types/src/utils/introspection/audit.d.ts.map +1 -0
- package/types/src/utils/introspection/graph.d.ts +4 -0
- package/types/src/utils/introspection/graph.d.ts.map +1 -0
- package/types/src/utils/introspection/model.d.ts +43 -0
- package/types/src/utils/introspection/model.d.ts.map +1 -0
- package/types/src/utils/parsing/commentAnnotations.d.ts +18 -0
- package/types/src/utils/parsing/commentAnnotations.d.ts.map +1 -0
- package/types/src/utils/parsing/detectFormat.d.ts +17 -0
- package/types/src/utils/parsing/detectFormat.d.ts.map +1 -0
- package/types/src/utils/parsing/enrichMetadata.d.ts.map +1 -1
- package/types/src/utils/parsing/extractComment.d.ts +20 -0
- package/types/src/utils/parsing/extractComment.d.ts.map +1 -0
- package/types/src/utils/parsing/parse.d.ts.map +1 -1
- package/types/src/utils/parsing/preProcess.d.ts.map +1 -1
- package/types/src/utils/paths/fileType.d.ts +15 -0
- package/types/src/utils/paths/fileType.d.ts.map +1 -0
- package/types/src/utils/paths/findLineForKey.d.ts +9 -0
- package/types/src/utils/paths/findLineForKey.d.ts.map +1 -1
- package/types/src/utils/paths/ignorePaths.d.ts +5 -0
- package/types/src/utils/paths/ignorePaths.d.ts.map +1 -0
- package/types/src/utils/redaction/redact.d.ts +10 -0
- package/types/src/utils/redaction/redact.d.ts.map +1 -0
- package/types/src/utils/redaction/setupRedaction.d.ts +5 -0
- package/types/src/utils/redaction/setupRedaction.d.ts.map +1 -0
- package/types/src/utils/requirements/configRequirements.d.ts +70 -0
- package/types/src/utils/requirements/configRequirements.d.ts.map +1 -0
- package/types/src/utils/requirements/serializeRequirements.d.ts +63 -0
- package/types/src/utils/requirements/serializeRequirements.d.ts.map +1 -0
- package/types/src/utils/security/dotenvFileRefs.d.ts +13 -0
- package/types/src/utils/security/dotenvFileRefs.d.ts.map +1 -0
- package/types/src/utils/security/evalSafety.d.ts +17 -0
- package/types/src/utils/security/evalSafety.d.ts.map +1 -0
- package/types/src/utils/security/safetyPolicy.d.ts +15 -0
- package/types/src/utils/security/safetyPolicy.d.ts.map +1 -0
- package/types/src/utils/setup/applyAnswers.d.ts +34 -0
- package/types/src/utils/setup/applyAnswers.d.ts.map +1 -0
- package/types/src/utils/setup/setupEngine.d.ts +89 -0
- package/types/src/utils/setup/setupEngine.d.ts.map +1 -0
- package/types/src/utils/setup/stdoutRedirect.d.ts +10 -0
- package/types/src/utils/setup/stdoutRedirect.d.ts.map +1 -0
- package/types/src/utils/setup/writeAnswers.d.ts +17 -0
- package/types/src/utils/setup/writeAnswers.d.ts.map +1 -0
- package/types/src/utils/setup/writeDotenv.d.ts +27 -0
- package/types/src/utils/setup/writeDotenv.d.ts.map +1 -0
- package/types/src/utils/strings/didYouMean.d.ts +23 -0
- package/types/src/utils/strings/didYouMean.d.ts.map +1 -0
- package/types/src/utils/strings/formatFunctionArgs.d.ts.map +1 -1
- package/types/src/utils/strings/replaceAll.d.ts.map +1 -1
- package/types/src/utils/strings/splitByComma.d.ts.map +1 -1
- package/types/src/utils/ui/configWizard.d.ts +51 -7
- package/types/src/utils/ui/configWizard.d.ts.map +1 -1
- package/types/src/utils/ui/createEditorLink.d.ts +7 -0
- package/types/src/utils/ui/createEditorLink.d.ts.map +1 -1
- package/types/src/utils/ui/promptDescriptors.d.ts +39 -0
- package/types/src/utils/ui/promptDescriptors.d.ts.map +1 -0
- package/types/src/utils/variables/cleanVariable.d.ts.map +1 -1
- package/types/src/utils/variables/getVariableType.d.ts.map +1 -1
- package/types/src/utils/variables/variableUtils.d.ts +1 -1
package/README.md
CHANGED
|
@@ -141,6 +141,7 @@ npx configorama inspect config.yml
|
|
|
141
141
|
- [Functions (Experimental)](#functions-experimental)
|
|
142
142
|
- [Bundled Plugins](#bundled-plugins)
|
|
143
143
|
- [CloudFormation](#cloudformation)
|
|
144
|
+
- [1Password](#1password)
|
|
144
145
|
- [API Reference](#api-reference)
|
|
145
146
|
- [Async API](#async-api)
|
|
146
147
|
- [Sync API](#sync-api)
|
|
@@ -1550,6 +1551,36 @@ Peer dependency (install separately):
|
|
|
1550
1551
|
npm install @aws-sdk/client-cloudformation @aws-sdk/credential-providers
|
|
1551
1552
|
```
|
|
1552
1553
|
|
|
1554
|
+
### 1Password
|
|
1555
|
+
|
|
1556
|
+
Resolves secret values through the [1Password CLI](https://developer.1password.com/docs/cli/) (`op`). Secrets are fetched at resolution time — never persisted, never logged.
|
|
1557
|
+
|
|
1558
|
+
```yaml
|
|
1559
|
+
npmToken: ${op:npm.NPM_TOKEN}
|
|
1560
|
+
dbPassword: ${op:database}
|
|
1561
|
+
directRef: ${op(op://vault/item/field)}
|
|
1562
|
+
```
|
|
1563
|
+
|
|
1564
|
+
```javascript
|
|
1565
|
+
const configorama = require('configorama')
|
|
1566
|
+
const createOnePasswordResolver = require('configorama/plugins/onepassword')
|
|
1567
|
+
|
|
1568
|
+
const opResolver = createOnePasswordResolver({
|
|
1569
|
+
refs: {
|
|
1570
|
+
npm: 'op://production/npm-automation/notesPlain',
|
|
1571
|
+
database: { item: 'database-prod', vault: 'production', field: 'password' },
|
|
1572
|
+
},
|
|
1573
|
+
})
|
|
1574
|
+
|
|
1575
|
+
const config = await configorama('config.yml', {
|
|
1576
|
+
variableSources: [opResolver]
|
|
1577
|
+
})
|
|
1578
|
+
```
|
|
1579
|
+
|
|
1580
|
+
Full docs: [`plugins/onepassword/README.md`](./plugins/onepassword/README.md). Covers alias refs, private item links, field inference and ambiguity rules, INI/dotenv key paths, `skipResolution`, and sync usage.
|
|
1581
|
+
|
|
1582
|
+
No npm dependencies — requires the `op` binary on `PATH` and a signed-in CLI (or `OP_SERVICE_ACCOUNT_TOKEN`).
|
|
1583
|
+
|
|
1553
1584
|
---
|
|
1554
1585
|
|
|
1555
1586
|
## API Reference
|
|
@@ -2273,7 +2304,16 @@ stage: ${opt:stage | help('Deployment stage, e.g. dev or prod')}
|
|
|
2273
2304
|
|
|
2274
2305
|
The [Variable Source Types](#variable-source-types) table describes how the wizard treats each source.
|
|
2275
2306
|
|
|
2276
|
-
|
|
2307
|
+
The wizard prints a summary of your answers with sensitive values redacted, applies them to the current resolution run, and never edits the source config file.
|
|
2308
|
+
|
|
2309
|
+
Programmatic access to the same engine returns the answers without applying them anywhere:
|
|
2310
|
+
|
|
2311
|
+
```javascript
|
|
2312
|
+
const result = await configorama.setup('config.yml')
|
|
2313
|
+
// { schemaVersion, configPath, requirements, answers, redactedAnswers }
|
|
2314
|
+
```
|
|
2315
|
+
|
|
2316
|
+
To apply answers to explicit targets - your current shell, a one-off command, a `.env.local`, or a JSON answers file - use [`configx setup`](./packages/configx/README.md#setup-wizard). Its safe write behavior (0600 permissions, managed-block merge, sensitive-value confirmation) is built on the same engine.
|
|
2277
2317
|
|
|
2278
2318
|
---
|
|
2279
2319
|
|
package/cli.js
CHANGED
|
@@ -61,7 +61,8 @@ Commands:
|
|
|
61
61
|
(default) Resolve <file> and print the result
|
|
62
62
|
inspect <file> Introspect a config without resolving it (full model)
|
|
63
63
|
--view requirements|audit|graph for a single slice
|
|
64
|
-
setup <file>
|
|
64
|
+
setup <file> Prompt for missing config values, then resolve with the
|
|
65
|
+
answers applied (summary redacts sensitive values)
|
|
65
66
|
capabilities Print the machine-readable CLI contract (JSON)
|
|
66
67
|
|
|
67
68
|
Options:
|
|
@@ -449,6 +450,17 @@ if (requirementsMode) {
|
|
|
449
450
|
// Process the configuration
|
|
450
451
|
const shouldRedactSetupStdout = options.setup && !argv.output && !argv.copy
|
|
451
452
|
let setupRequirementsForRedaction = []
|
|
453
|
+
|
|
454
|
+
// If stdin ends mid-wizard the pending prompt promise never settles, the event
|
|
455
|
+
// loop drains, and node would exit 0 as if setup succeeded. Fail closed instead.
|
|
456
|
+
const onSetupBeforeExit = () => {
|
|
457
|
+
process.stderr.write('configorama: setup cancelled (input ended before completion)\n')
|
|
458
|
+
process.exit(1)
|
|
459
|
+
}
|
|
460
|
+
if (options.setup) {
|
|
461
|
+
process.on('beforeExit', onSetupBeforeExit)
|
|
462
|
+
}
|
|
463
|
+
|
|
452
464
|
const configPromise = shouldRedactSetupStdout
|
|
453
465
|
? (() => {
|
|
454
466
|
const instance = new Configorama(inputFile, options)
|
|
@@ -461,6 +473,7 @@ const configPromise = shouldRedactSetupStdout
|
|
|
461
473
|
|
|
462
474
|
configPromise
|
|
463
475
|
.then((config) => {
|
|
476
|
+
process.removeListener('beforeExit', onSetupBeforeExit)
|
|
464
477
|
let outputConfig = shouldRedactSetupStdout
|
|
465
478
|
? redactConfigByRequirements(config, setupRequirementsForRedaction)
|
|
466
479
|
: config
|
package/package.json
CHANGED
|
@@ -1,21 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "configorama",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"description": "Variable support for configuration files",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./src/index.js",
|
|
9
|
-
"./parse-file": "./src/utils/parsing/parse.js"
|
|
9
|
+
"./parse-file": "./src/utils/parsing/parse.js",
|
|
10
|
+
"./plugins/cloudformation": "./plugins/cloudformation/index.js",
|
|
11
|
+
"./plugins/onepassword": "./plugins/onepassword/index.js"
|
|
10
12
|
},
|
|
11
13
|
"files": [
|
|
12
14
|
"cli.js",
|
|
13
15
|
"src",
|
|
16
|
+
"plugins",
|
|
14
17
|
"types",
|
|
15
18
|
"index.d.ts",
|
|
16
19
|
"package.json",
|
|
17
|
-
"
|
|
18
|
-
"
|
|
20
|
+
"README.md",
|
|
21
|
+
"!plugins/*/*.test.js",
|
|
22
|
+
"!plugins/*/example",
|
|
23
|
+
"!plugins/*/node_modules"
|
|
19
24
|
],
|
|
20
25
|
"bin": {
|
|
21
26
|
"config": "./cli.js",
|
|
@@ -42,7 +47,10 @@
|
|
|
42
47
|
"publish": "git push origin && git push origin --tags",
|
|
43
48
|
"release:patch": "npm version patch && npm publish",
|
|
44
49
|
"release:minor": "npm version minor && npm publish",
|
|
45
|
-
"release:major": "npm version major && npm publish"
|
|
50
|
+
"release:major": "npm version major && npm publish",
|
|
51
|
+
"release": "lerna publish",
|
|
52
|
+
"version:all": "lerna version --no-push --no-git-tag-version",
|
|
53
|
+
"test:all": "pnpm -r test"
|
|
46
54
|
},
|
|
47
55
|
"author": "David Wells",
|
|
48
56
|
"license": "MIT",
|
|
@@ -56,6 +64,7 @@
|
|
|
56
64
|
"@davidwells/box-logger": "^2.0.3",
|
|
57
65
|
"@iarna/toml": "^2.2.5",
|
|
58
66
|
"dot-prop": "^5.3.0",
|
|
67
|
+
"dotenv": "^17.2.2",
|
|
59
68
|
"env-stage-loader": "^1.1.3",
|
|
60
69
|
"find-up": "^3.0.0",
|
|
61
70
|
"git-url-parse": "^14.0.0",
|
|
@@ -75,6 +84,7 @@
|
|
|
75
84
|
"devDependencies": {
|
|
76
85
|
"@cdktf/hcl2json": "^0.21.0",
|
|
77
86
|
"@types/node": "^24.10.1",
|
|
87
|
+
"lerna": "^9.0.0",
|
|
78
88
|
"markdown-magic": "^4.8.0",
|
|
79
89
|
"tsx": "^4.7.0",
|
|
80
90
|
"typescript": "^5.8.3",
|
|
@@ -99,5 +109,7 @@
|
|
|
99
109
|
"ts-node": {
|
|
100
110
|
"optional": true
|
|
101
111
|
}
|
|
102
|
-
}
|
|
112
|
+
},
|
|
113
|
+
"packageManager": "pnpm@10.33.0",
|
|
114
|
+
"gitHead": "2ce70bfa849433ba74c60fa3c1cd3dba942b6556"
|
|
103
115
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# CloudFormation variable source
|
|
2
|
+
|
|
3
|
+
Resolves CloudFormation stack output values in configorama configs.
|
|
4
|
+
|
|
5
|
+
## Syntax
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
${cf:stackName.outputKey} # default region, default credentials
|
|
9
|
+
${cf(region):stackName.outputKey} # explicit region, default credentials
|
|
10
|
+
${cf(account:region):stackName.outputKey} # explicit account alias + region
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
const configorama = require('configorama')
|
|
17
|
+
const createCloudFormationResolver = require('configorama/plugins/cloudformation')
|
|
18
|
+
|
|
19
|
+
const cfResolver = createCloudFormationResolver({
|
|
20
|
+
defaultRegion: 'us-east-1', // optional
|
|
21
|
+
skipResolution: false, // true = collect metadata without calling AWS
|
|
22
|
+
// credentials: { ... } // optional: bypass env-var discovery
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const result = await configorama(configPath, {
|
|
26
|
+
returnMetadata: true,
|
|
27
|
+
variableSources: [cfResolver]
|
|
28
|
+
})
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Multi-account
|
|
32
|
+
|
|
33
|
+
Account aliases map to env-var prefixes. To make `${cf(prod:us-west-2):…}` work,
|
|
34
|
+
set:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
export PROD_AWS_ACCESS_KEY_ID=AKIA...
|
|
38
|
+
export PROD_AWS_SECRET_ACCESS_KEY=...
|
|
39
|
+
export PROD_AWS_REGION=us-west-2 # optional; falls back to AWS_REGION
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The alias is the prefix lowercased, so `STAGING_AWS_ACCESS_KEY_ID` becomes
|
|
43
|
+
`staging`. The alias is **not** an AWS 12-digit account number; it's whatever
|
|
44
|
+
string you put before `_AWS_ACCESS_KEY_ID`.
|
|
45
|
+
|
|
46
|
+
### Credential discovery rules
|
|
47
|
+
|
|
48
|
+
1. Scan `process.env` for `{PREFIX}_AWS_ACCESS_KEY_ID` → register `{prefix}` (lowercased).
|
|
49
|
+
2. If `AWS_ACCESS_KEY_ID` is set unprefixed, register it as `default`.
|
|
50
|
+
3. If no `default` exists and there's exactly one prefixed set, use it as
|
|
51
|
+
`default` (and copy into unprefixed env vars so the AWS SDK can find them).
|
|
52
|
+
|
|
53
|
+
### Parallel safety
|
|
54
|
+
|
|
55
|
+
Multiple resolves for the **same** account run concurrently. Resolves for
|
|
56
|
+
**different** accounts are serialized via a refcounted mutex, since
|
|
57
|
+
`process.env` is process-global and two accounts can't be active at once.
|
|
58
|
+
|
|
59
|
+
## Metadata
|
|
60
|
+
|
|
61
|
+
Each resolved variable is recorded in `result.metadata.cfReferences`:
|
|
62
|
+
|
|
63
|
+
```js
|
|
64
|
+
{
|
|
65
|
+
raw: '${cf(prod:us-west-2):api-service-prod.ApiUrl}',
|
|
66
|
+
resolved: '${cf(prod:us-west-2):api-service-prod.ApiUrl}',
|
|
67
|
+
stackName: 'api-service-prod',
|
|
68
|
+
outputKey: 'ApiUrl',
|
|
69
|
+
region: 'us-west-2',
|
|
70
|
+
account: 'prod', // null when not multi-account
|
|
71
|
+
configPath: 'provider.environment.API_URL',
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Skip resolution
|
|
76
|
+
|
|
77
|
+
`skipResolution: true` collects metadata without calling AWS. Values are
|
|
78
|
+
replaced with placeholders like `[CF:prod:us-west-2:api-service-prod.ApiUrl]`,
|
|
79
|
+
useful in CI for dependency analysis.
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/* AWS credential discovery and runtime swapping for multi-account deployments */
|
|
2
|
+
|
|
3
|
+
const AWS_CRED_SUFFIXES = [
|
|
4
|
+
'AWS_ACCESS_KEY_ID',
|
|
5
|
+
'AWS_SECRET_ACCESS_KEY',
|
|
6
|
+
'AWS_SESSION_TOKEN',
|
|
7
|
+
'AWS_REGION'
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
/** @type {Map<string, Object>|null} */
|
|
11
|
+
let credentialCache = null
|
|
12
|
+
|
|
13
|
+
// Mutex state for credential swapping (prevents race conditions in parallel deploys)
|
|
14
|
+
/** @type {string|null} */
|
|
15
|
+
let activeAccount = null
|
|
16
|
+
/** @type {number} */
|
|
17
|
+
let activeRefCount = 0
|
|
18
|
+
/** @type {Promise<void>|null} */
|
|
19
|
+
let lockPromise = null
|
|
20
|
+
/** @type {Function|null} */
|
|
21
|
+
let lockResolve = null
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {Object} AwsCredentials
|
|
25
|
+
* @property {string} [AWS_ACCESS_KEY_ID]
|
|
26
|
+
* @property {string} [AWS_SECRET_ACCESS_KEY]
|
|
27
|
+
* @property {string} [AWS_SESSION_TOKEN]
|
|
28
|
+
* @property {string} [AWS_REGION]
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Discover all credential sets from env vars matching {PREFIX}_AWS_ACCESS_KEY_ID.
|
|
33
|
+
* E.g., STAGING_AWS_ACCESS_KEY_ID, PROD_AWS_ACCESS_KEY_ID -> "staging", "prod".
|
|
34
|
+
*
|
|
35
|
+
* @returns {Map<string, AwsCredentials>} Map of account alias -> credentials
|
|
36
|
+
*/
|
|
37
|
+
function discoverCredentialSets() {
|
|
38
|
+
if (credentialCache) return credentialCache
|
|
39
|
+
|
|
40
|
+
const sets = new Map()
|
|
41
|
+
const pattern = /^(.+)_AWS_ACCESS_KEY_ID$/
|
|
42
|
+
|
|
43
|
+
for (const key of Object.keys(process.env)) {
|
|
44
|
+
const match = key.match(pattern)
|
|
45
|
+
if (match) {
|
|
46
|
+
const prefix = match[1]
|
|
47
|
+
const name = prefix.toLowerCase()
|
|
48
|
+
|
|
49
|
+
sets.set(name, {
|
|
50
|
+
AWS_ACCESS_KEY_ID: process.env[`${prefix}_AWS_ACCESS_KEY_ID`],
|
|
51
|
+
AWS_SECRET_ACCESS_KEY: process.env[`${prefix}_AWS_SECRET_ACCESS_KEY`],
|
|
52
|
+
AWS_SESSION_TOKEN: process.env[`${prefix}_AWS_SESSION_TOKEN`],
|
|
53
|
+
AWS_REGION: process.env[`${prefix}_AWS_REGION`] || process.env.AWS_REGION,
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Capture "default" from unprefixed vars if present and not already discovered
|
|
59
|
+
if (process.env.AWS_ACCESS_KEY_ID && !sets.has('default')) {
|
|
60
|
+
sets.set('default', {
|
|
61
|
+
AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID,
|
|
62
|
+
AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY,
|
|
63
|
+
AWS_SESSION_TOKEN: process.env.AWS_SESSION_TOKEN,
|
|
64
|
+
AWS_REGION: process.env.AWS_REGION,
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Auto-default: if no unprefixed creds but exactly one prefixed set, use it as default
|
|
69
|
+
if (!sets.has('default') && sets.size === 1) {
|
|
70
|
+
const [, creds] = [...sets.entries()][0]
|
|
71
|
+
sets.set('default', creds)
|
|
72
|
+
for (const [key, value] of Object.entries(creds)) {
|
|
73
|
+
if (value && !process.env[key]) {
|
|
74
|
+
process.env[key] = value
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
credentialCache = sets
|
|
80
|
+
return sets
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Get available account aliases.
|
|
85
|
+
* @returns {string[]}
|
|
86
|
+
*/
|
|
87
|
+
function getAvailableAccounts() {
|
|
88
|
+
return [...discoverCredentialSets().keys()]
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Check if credentials exist for an account alias.
|
|
93
|
+
* @param {string} account
|
|
94
|
+
* @returns {boolean}
|
|
95
|
+
*/
|
|
96
|
+
function hasCredentials(account) {
|
|
97
|
+
return discoverCredentialSets().has(account.toLowerCase())
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Get credentials for an account alias.
|
|
102
|
+
* @param {string} account
|
|
103
|
+
* @returns {AwsCredentials|null}
|
|
104
|
+
*/
|
|
105
|
+
function getCredentials(account) {
|
|
106
|
+
return discoverCredentialSets().get(account.toLowerCase()) || null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Snapshot current AWS env vars so they can be restored after a swap.
|
|
111
|
+
* @returns {AwsCredentials}
|
|
112
|
+
*/
|
|
113
|
+
function saveCurrentCredentials() {
|
|
114
|
+
const creds = {}
|
|
115
|
+
for (const suffix of AWS_CRED_SUFFIXES) {
|
|
116
|
+
if (process.env[suffix]) {
|
|
117
|
+
creds[suffix] = process.env[suffix]
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return creds
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Replace AWS env vars with the supplied credentials.
|
|
125
|
+
* @param {AwsCredentials} creds
|
|
126
|
+
*/
|
|
127
|
+
function applyCredentials(creds) {
|
|
128
|
+
for (const suffix of AWS_CRED_SUFFIXES) {
|
|
129
|
+
delete process.env[suffix]
|
|
130
|
+
}
|
|
131
|
+
for (const [key, value] of Object.entries(creds)) {
|
|
132
|
+
if (value) process.env[key] = value
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Acquire credential lock for an account.
|
|
138
|
+
* Same account can have multiple concurrent holders (refcounted).
|
|
139
|
+
* Different account must wait for current holders to release.
|
|
140
|
+
*
|
|
141
|
+
* @param {string} account
|
|
142
|
+
*/
|
|
143
|
+
async function acquireLock(account) {
|
|
144
|
+
while (activeAccount !== null && activeAccount !== account) {
|
|
145
|
+
if (lockPromise) {
|
|
146
|
+
await lockPromise
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (activeAccount === null) {
|
|
151
|
+
activeAccount = account
|
|
152
|
+
lockPromise = new Promise(resolve => { lockResolve = resolve })
|
|
153
|
+
}
|
|
154
|
+
activeRefCount++
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Release credential lock.
|
|
159
|
+
*/
|
|
160
|
+
function releaseLock() {
|
|
161
|
+
activeRefCount--
|
|
162
|
+
if (activeRefCount === 0) {
|
|
163
|
+
activeAccount = null
|
|
164
|
+
if (lockResolve) {
|
|
165
|
+
lockResolve()
|
|
166
|
+
lockResolve = null
|
|
167
|
+
lockPromise = null
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Execute a function with specific account credentials.
|
|
174
|
+
* Uses a refcounted mutex so multiple resolves for the same account run in
|
|
175
|
+
* parallel but different accounts are serialized — env vars are global, so two
|
|
176
|
+
* accounts can't be active at once.
|
|
177
|
+
*
|
|
178
|
+
* @template T
|
|
179
|
+
* @param {string} account - Account alias (matches {ACCOUNT}_AWS_ACCESS_KEY_ID prefix, case-insensitive)
|
|
180
|
+
* @param {() => Promise<T>} fn - Async function to execute with swapped credentials
|
|
181
|
+
* @returns {Promise<T>}
|
|
182
|
+
*/
|
|
183
|
+
async function useCredentials(account, fn) {
|
|
184
|
+
const accountLower = account.toLowerCase()
|
|
185
|
+
const sets = discoverCredentialSets()
|
|
186
|
+
|
|
187
|
+
// If requesting default and we have unprefixed creds already active, no swap needed
|
|
188
|
+
if (accountLower === 'default' && !sets.has('default')) {
|
|
189
|
+
return fn()
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const targetCreds = sets.get(accountLower)
|
|
193
|
+
if (!targetCreds) {
|
|
194
|
+
const available = [...sets.keys()].join(', ') || 'none'
|
|
195
|
+
throw new Error(`No credentials found for account "${account}". Available: ${available}`)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
await acquireLock(accountLower)
|
|
199
|
+
const savedCreds = saveCurrentCredentials()
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
applyCredentials(targetCreds)
|
|
203
|
+
return await fn()
|
|
204
|
+
} finally {
|
|
205
|
+
applyCredentials(savedCreds)
|
|
206
|
+
releaseLock()
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Clear the credential cache and reset mutex state (useful for testing).
|
|
212
|
+
*/
|
|
213
|
+
function clearCache() {
|
|
214
|
+
credentialCache = null
|
|
215
|
+
activeAccount = null
|
|
216
|
+
activeRefCount = 0
|
|
217
|
+
lockPromise = null
|
|
218
|
+
lockResolve = null
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
module.exports = {
|
|
222
|
+
discoverCredentialSets,
|
|
223
|
+
getAvailableAccounts,
|
|
224
|
+
hasCredentials,
|
|
225
|
+
getCredentials,
|
|
226
|
+
useCredentials,
|
|
227
|
+
clearCache,
|
|
228
|
+
// Low-level (exported for testing)
|
|
229
|
+
saveCurrentCredentials,
|
|
230
|
+
applyCredentials,
|
|
231
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/* CloudFormation stack output variable source */
|
|
2
|
+
const { useCredentials } = require('./credentials')
|
|
3
|
+
|
|
4
|
+
const CF_PREFIX = 'cf'
|
|
5
|
+
// Supports: cf:stack.output, cf(region):stack.output, cf(account:region):stack.output
|
|
6
|
+
const cfVariableSyntax = RegExp(/^cf(\([a-z0-9_-]+(:[a-z0-9_-]+)?\))?:/i)
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Creates a CloudFormation variable source resolver.
|
|
10
|
+
*
|
|
11
|
+
* Syntax:
|
|
12
|
+
* ${cf:stackName.outputKey} — default region, default creds
|
|
13
|
+
* ${cf(region):stackName.outputKey} — explicit region, default creds
|
|
14
|
+
* ${cf(account:region):stackName.outputKey} — explicit account alias + region
|
|
15
|
+
*
|
|
16
|
+
* `account` is an env-var-prefix alias matching `{ACCOUNT}_AWS_ACCESS_KEY_ID`
|
|
17
|
+
* (case-insensitive). E.g., with PROD_AWS_ACCESS_KEY_ID set, use `cf(prod:us-west-2):…`.
|
|
18
|
+
* See credentials.js for the discovery rules.
|
|
19
|
+
*
|
|
20
|
+
* @param {object} options - Configuration options
|
|
21
|
+
* @param {object} [options.credentials] - AWS credentials (bypasses env-var discovery)
|
|
22
|
+
* @param {string} [options.defaultRegion] - Default region if not specified in variable
|
|
23
|
+
* @param {boolean} [options.skipResolution] - Skip AWS calls, just collect metadata
|
|
24
|
+
* @param {object} [options.clientOptions] - Additional options passed to CloudFormation client
|
|
25
|
+
* @returns {object} Variable source configuration with resolver and metadata collector
|
|
26
|
+
*/
|
|
27
|
+
function createCloudFormationResolver(options = {}) {
|
|
28
|
+
const {
|
|
29
|
+
credentials,
|
|
30
|
+
defaultRegion = process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || 'us-east-1',
|
|
31
|
+
skipResolution = false,
|
|
32
|
+
clientOptions = {}
|
|
33
|
+
} = options
|
|
34
|
+
|
|
35
|
+
const cfReferences = []
|
|
36
|
+
|
|
37
|
+
// Clients are cached per (account, region) — different accounts cannot share a
|
|
38
|
+
// client because the provider chain memoizes credentials on first resolve.
|
|
39
|
+
const clientCache = new Map()
|
|
40
|
+
const outputCache = new Map()
|
|
41
|
+
|
|
42
|
+
async function getClient(region, account) {
|
|
43
|
+
const cacheKey = `${account || 'default'}:${region}`
|
|
44
|
+
if (clientCache.has(cacheKey)) {
|
|
45
|
+
return clientCache.get(cacheKey)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const { CloudFormationClient } = await import('@aws-sdk/client-cloudformation')
|
|
49
|
+
const { fromNodeProviderChain } = await import('@aws-sdk/credential-providers')
|
|
50
|
+
|
|
51
|
+
const clientConfig = {
|
|
52
|
+
region,
|
|
53
|
+
credentials: credentials || fromNodeProviderChain(),
|
|
54
|
+
...clientOptions
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const client = new CloudFormationClient(clientConfig)
|
|
58
|
+
clientCache.set(cacheKey, client)
|
|
59
|
+
return client
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function getStackOutput(stackName, outputKey, region, account = null) {
|
|
63
|
+
const cacheKey = `${account || 'default'}:${region}:${stackName}`
|
|
64
|
+
|
|
65
|
+
if (outputCache.has(cacheKey)) {
|
|
66
|
+
const outputs = outputCache.get(cacheKey)
|
|
67
|
+
const output = outputs.find(o => o.OutputKey === outputKey)
|
|
68
|
+
return output ? output.OutputValue : null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const fetchStack = async () => {
|
|
72
|
+
const client = await getClient(region, account)
|
|
73
|
+
const { DescribeStacksCommand } = await import('@aws-sdk/client-cloudformation')
|
|
74
|
+
const command = new DescribeStacksCommand({ StackName: stackName })
|
|
75
|
+
|
|
76
|
+
let response
|
|
77
|
+
try {
|
|
78
|
+
response = await client.send(command)
|
|
79
|
+
} catch (err) {
|
|
80
|
+
const code = err.Code || err.name
|
|
81
|
+
const accountInfo = account ? ` for account "${account}"` : ''
|
|
82
|
+
const messages = {
|
|
83
|
+
ExpiredToken: `AWS credentials expired${accountInfo}. Refresh your credentials and try again.`,
|
|
84
|
+
AccessDenied: `Access denied to CloudFormation stack "${stackName}" in ${region}${accountInfo}. Check IAM permissions.`,
|
|
85
|
+
ValidationError: `Stack "${stackName}" not found in ${region}${accountInfo}.`,
|
|
86
|
+
CredentialsProviderError: `No AWS credentials found${accountInfo}. Configure credentials via environment, ~/.aws/credentials, or pass explicitly.`,
|
|
87
|
+
}
|
|
88
|
+
throw new Error(messages[code] || `CloudFormation error: ${err.message}`)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!response.Stacks || response.Stacks.length === 0) {
|
|
92
|
+
throw new Error(`CloudFormation stack "${stackName}" not found in region "${region}"`)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const outputs = response.Stacks[0].Outputs || []
|
|
96
|
+
outputCache.set(cacheKey, outputs)
|
|
97
|
+
|
|
98
|
+
const output = outputs.find(o => o.OutputKey === outputKey)
|
|
99
|
+
return output ? output.OutputValue : null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (account) {
|
|
103
|
+
return useCredentials(account, fetchStack)
|
|
104
|
+
}
|
|
105
|
+
return fetchStack()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Parse cf: variable string.
|
|
110
|
+
*
|
|
111
|
+
* @param {string} varString
|
|
112
|
+
* @returns {{stackName: string, outputKey: string, region: string, account: string|null}}
|
|
113
|
+
*/
|
|
114
|
+
function parseVariable(varString) {
|
|
115
|
+
let region = defaultRegion
|
|
116
|
+
let account = null
|
|
117
|
+
|
|
118
|
+
const paramsMatch = varString.match(/^cf\(([a-z0-9_-]+(?::[a-z0-9_-]+)?)\):/i)
|
|
119
|
+
if (paramsMatch) {
|
|
120
|
+
const params = paramsMatch[1]
|
|
121
|
+
if (params.includes(':')) {
|
|
122
|
+
const [accountPart, regionPart] = params.split(':')
|
|
123
|
+
account = accountPart
|
|
124
|
+
region = regionPart
|
|
125
|
+
} else {
|
|
126
|
+
region = params
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const withoutPrefix = varString.replace(/^cf(\([a-z0-9_-]+(?::[a-z0-9_-]+)?\))?:/i, '')
|
|
131
|
+
|
|
132
|
+
const dotIndex = withoutPrefix.indexOf('.')
|
|
133
|
+
if (dotIndex === -1) {
|
|
134
|
+
throw new Error(`Invalid cf: variable syntax "${varString}". Expected format: cf:stackName.outputKey`)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const stackName = withoutPrefix.slice(0, dotIndex)
|
|
138
|
+
const outputKey = withoutPrefix.slice(dotIndex + 1)
|
|
139
|
+
|
|
140
|
+
if (!stackName || !outputKey) {
|
|
141
|
+
throw new Error(`Invalid cf: variable syntax "${varString}". Both stackName and outputKey are required.`)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return { stackName, outputKey, region, account }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function resolver(varString, opts, currentObject, valueObject) {
|
|
148
|
+
const { stackName, outputKey, region, account } = parseVariable(varString)
|
|
149
|
+
|
|
150
|
+
cfReferences.push({
|
|
151
|
+
raw: valueObject.originalSource,
|
|
152
|
+
resolved: `\${${varString}}`,
|
|
153
|
+
stackName,
|
|
154
|
+
outputKey,
|
|
155
|
+
region,
|
|
156
|
+
account,
|
|
157
|
+
configPath: valueObject.path.join('.')
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
if (skipResolution) {
|
|
161
|
+
const accountInfo = account ? `${account}:` : ''
|
|
162
|
+
return `[CF:${accountInfo}${region}:${stackName}.${outputKey}]`
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const value = await getStackOutput(stackName, outputKey, region, account)
|
|
166
|
+
|
|
167
|
+
if (value === null) {
|
|
168
|
+
const accountInfo = account ? `, account: ${account}` : ''
|
|
169
|
+
throw new Error(`Output "${outputKey}" not found in CloudFormation stack "${stackName}" (region: ${region}${accountInfo})`)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return value
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
type: CF_PREFIX,
|
|
177
|
+
source: 'remote',
|
|
178
|
+
prefix: CF_PREFIX,
|
|
179
|
+
syntax: '${cf:stackName.outputKey}, ${cf(region):stackName.outputKey}, or ${cf(account:region):stackName.outputKey}',
|
|
180
|
+
description: 'Resolves CloudFormation stack output values (supports multi-region and multi-account)',
|
|
181
|
+
match: cfVariableSyntax,
|
|
182
|
+
resolver,
|
|
183
|
+
metadataKey: 'cfReferences',
|
|
184
|
+
collectMetadata: () => cfReferences,
|
|
185
|
+
clearCache: () => {
|
|
186
|
+
outputCache.clear()
|
|
187
|
+
clientCache.clear()
|
|
188
|
+
cfReferences.length = 0
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (require.main === module) {
|
|
194
|
+
const instance = createCloudFormationResolver()
|
|
195
|
+
instance.resolver(
|
|
196
|
+
'cf:rbac-service-v2-dev.RBACTableArn',
|
|
197
|
+
{},
|
|
198
|
+
{},
|
|
199
|
+
{ originalSource: '${cf:rbac-service-v2-dev.RBACTableArn}', path: ['provider', 'rbacTableArn'] }
|
|
200
|
+
).then(console.log).catch(console.error)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
module.exports = createCloudFormationResolver
|
|
204
|
+
module.exports.cfVariableSyntax = cfVariableSyntax
|