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.
Files changed (129) hide show
  1. package/README.md +41 -1
  2. package/cli.js +14 -1
  3. package/package.json +18 -6
  4. package/plugins/cloudformation/README.md +79 -0
  5. package/plugins/cloudformation/credentials.js +231 -0
  6. package/plugins/cloudformation/index.js +204 -0
  7. package/plugins/cloudformation/package-lock.json +657 -0
  8. package/plugins/cloudformation/package.json +24 -0
  9. package/plugins/onepassword/README.md +175 -0
  10. package/plugins/onepassword/fields.js +126 -0
  11. package/plugins/onepassword/index.js +356 -0
  12. package/plugins/onepassword/normalize.js +157 -0
  13. package/plugins/onepassword/op-cli.js +117 -0
  14. package/plugins/onepassword/package.json +15 -0
  15. package/plugins/onepassword/parser.js +70 -0
  16. package/plugins/onepassword/sync-factory.js +15 -0
  17. package/src/errors.js +3 -0
  18. package/src/index.js +27 -1
  19. package/src/main.js +24 -32
  20. package/src/parsers/dotenv.js +13 -0
  21. package/src/resolvers/valueFromFile.js +2 -1
  22. package/src/sync.js +27 -0
  23. package/src/utils/PromiseTracker.js +11 -4
  24. package/src/utils/PromiseTracker.test.js +48 -0
  25. package/src/utils/introspection/audit.js +39 -9
  26. package/src/utils/parsing/detectFormat.js +70 -0
  27. package/src/utils/parsing/detectFormat.test.js +68 -0
  28. package/src/utils/parsing/parse.js +11 -37
  29. package/src/utils/paths/fileType.js +27 -0
  30. package/src/utils/paths/fileType.test.js +41 -0
  31. package/src/utils/paths/findLineForKey.js +24 -1
  32. package/src/utils/paths/findLineForKey.test.js +20 -1
  33. package/src/utils/setup/applyAnswers.js +41 -0
  34. package/src/utils/setup/setupEngine.js +85 -0
  35. package/src/utils/setup/stdoutRedirect.js +48 -0
  36. package/src/utils/setup/writeAnswers.js +50 -0
  37. package/src/utils/setup/writeDotenv.js +142 -0
  38. package/src/utils/ui/configWizard.js +20 -1
  39. package/types/src/capabilities.d.ts +8 -0
  40. package/types/src/capabilities.d.ts.map +1 -0
  41. package/types/src/display.d.ts +62 -0
  42. package/types/src/display.d.ts.map +1 -0
  43. package/types/src/errors.d.ts +20 -0
  44. package/types/src/errors.d.ts.map +1 -0
  45. package/types/src/index.d.ts +119 -0
  46. package/types/src/index.d.ts.map +1 -1
  47. package/types/src/main.d.ts +23 -16
  48. package/types/src/main.d.ts.map +1 -1
  49. package/types/src/metadata.d.ts +28 -0
  50. package/types/src/metadata.d.ts.map +1 -0
  51. package/types/src/parsers/dotenv.d.ts +6 -0
  52. package/types/src/parsers/dotenv.d.ts.map +1 -0
  53. package/types/src/parsers/esm.d.ts.map +1 -1
  54. package/types/src/parsers/typescript.d.ts.map +1 -1
  55. package/types/src/resolvers/valueFromCron.d.ts.map +1 -1
  56. package/types/src/resolvers/valueFromEval.d.ts.map +1 -1
  57. package/types/src/resolvers/valueFromFile.d.ts +4 -0
  58. package/types/src/resolvers/valueFromFile.d.ts.map +1 -1
  59. package/types/src/resolvers/valueFromGit.d.ts.map +1 -1
  60. package/types/src/resolvers/valueFromOptions.d.ts +1 -0
  61. package/types/src/resolvers/valueFromOptions.d.ts.map +1 -1
  62. package/types/src/sync.d.ts.map +1 -1
  63. package/types/src/utils/BoundedMap.d.ts +10 -0
  64. package/types/src/utils/BoundedMap.d.ts.map +1 -0
  65. package/types/src/utils/PromiseTracker.d.ts +1 -1
  66. package/types/src/utils/PromiseTracker.d.ts.map +1 -1
  67. package/types/src/utils/filters/filterArgs.d.ts +13 -0
  68. package/types/src/utils/filters/filterArgs.d.ts.map +1 -0
  69. package/types/src/utils/filters/oneOf.d.ts +7 -0
  70. package/types/src/utils/filters/oneOf.d.ts.map +1 -0
  71. package/types/src/utils/introspection/audit.d.ts +14 -0
  72. package/types/src/utils/introspection/audit.d.ts.map +1 -0
  73. package/types/src/utils/introspection/graph.d.ts +4 -0
  74. package/types/src/utils/introspection/graph.d.ts.map +1 -0
  75. package/types/src/utils/introspection/model.d.ts +43 -0
  76. package/types/src/utils/introspection/model.d.ts.map +1 -0
  77. package/types/src/utils/parsing/commentAnnotations.d.ts +18 -0
  78. package/types/src/utils/parsing/commentAnnotations.d.ts.map +1 -0
  79. package/types/src/utils/parsing/detectFormat.d.ts +17 -0
  80. package/types/src/utils/parsing/detectFormat.d.ts.map +1 -0
  81. package/types/src/utils/parsing/enrichMetadata.d.ts.map +1 -1
  82. package/types/src/utils/parsing/extractComment.d.ts +20 -0
  83. package/types/src/utils/parsing/extractComment.d.ts.map +1 -0
  84. package/types/src/utils/parsing/parse.d.ts.map +1 -1
  85. package/types/src/utils/parsing/preProcess.d.ts.map +1 -1
  86. package/types/src/utils/paths/fileType.d.ts +15 -0
  87. package/types/src/utils/paths/fileType.d.ts.map +1 -0
  88. package/types/src/utils/paths/findLineForKey.d.ts +9 -0
  89. package/types/src/utils/paths/findLineForKey.d.ts.map +1 -1
  90. package/types/src/utils/paths/ignorePaths.d.ts +5 -0
  91. package/types/src/utils/paths/ignorePaths.d.ts.map +1 -0
  92. package/types/src/utils/redaction/redact.d.ts +10 -0
  93. package/types/src/utils/redaction/redact.d.ts.map +1 -0
  94. package/types/src/utils/redaction/setupRedaction.d.ts +5 -0
  95. package/types/src/utils/redaction/setupRedaction.d.ts.map +1 -0
  96. package/types/src/utils/requirements/configRequirements.d.ts +70 -0
  97. package/types/src/utils/requirements/configRequirements.d.ts.map +1 -0
  98. package/types/src/utils/requirements/serializeRequirements.d.ts +63 -0
  99. package/types/src/utils/requirements/serializeRequirements.d.ts.map +1 -0
  100. package/types/src/utils/security/dotenvFileRefs.d.ts +13 -0
  101. package/types/src/utils/security/dotenvFileRefs.d.ts.map +1 -0
  102. package/types/src/utils/security/evalSafety.d.ts +17 -0
  103. package/types/src/utils/security/evalSafety.d.ts.map +1 -0
  104. package/types/src/utils/security/safetyPolicy.d.ts +15 -0
  105. package/types/src/utils/security/safetyPolicy.d.ts.map +1 -0
  106. package/types/src/utils/setup/applyAnswers.d.ts +34 -0
  107. package/types/src/utils/setup/applyAnswers.d.ts.map +1 -0
  108. package/types/src/utils/setup/setupEngine.d.ts +89 -0
  109. package/types/src/utils/setup/setupEngine.d.ts.map +1 -0
  110. package/types/src/utils/setup/stdoutRedirect.d.ts +10 -0
  111. package/types/src/utils/setup/stdoutRedirect.d.ts.map +1 -0
  112. package/types/src/utils/setup/writeAnswers.d.ts +17 -0
  113. package/types/src/utils/setup/writeAnswers.d.ts.map +1 -0
  114. package/types/src/utils/setup/writeDotenv.d.ts +27 -0
  115. package/types/src/utils/setup/writeDotenv.d.ts.map +1 -0
  116. package/types/src/utils/strings/didYouMean.d.ts +23 -0
  117. package/types/src/utils/strings/didYouMean.d.ts.map +1 -0
  118. package/types/src/utils/strings/formatFunctionArgs.d.ts.map +1 -1
  119. package/types/src/utils/strings/replaceAll.d.ts.map +1 -1
  120. package/types/src/utils/strings/splitByComma.d.ts.map +1 -1
  121. package/types/src/utils/ui/configWizard.d.ts +51 -7
  122. package/types/src/utils/ui/configWizard.d.ts.map +1 -1
  123. package/types/src/utils/ui/createEditorLink.d.ts +7 -0
  124. package/types/src/utils/ui/createEditorLink.d.ts.map +1 -1
  125. package/types/src/utils/ui/promptDescriptors.d.ts +39 -0
  126. package/types/src/utils/ui/promptDescriptors.d.ts.map +1 -0
  127. package/types/src/utils/variables/cleanVariable.d.ts.map +1 -1
  128. package/types/src/utils/variables/getVariableType.d.ts.map +1 -1
  129. 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
- > **Experimental:** the wizard fills in values for the current resolution run only. It does not write your answers back to the config file yet.
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> Run the interactive config wizard (experimental)
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.0.2",
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
- "package-lock.json",
18
- "README.md"
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