configorama 1.0.1 → 1.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/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
package/package.json CHANGED
@@ -1,21 +1,27 @@
1
1
  {
2
2
  "name": "configorama",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
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
20
  "package-lock.json",
18
- "README.md"
21
+ "README.md",
22
+ "!plugins/*/*.test.js",
23
+ "!plugins/*/example",
24
+ "!plugins/*/node_modules"
19
25
  ],
20
26
  "bin": {
21
27
  "config": "./cli.js",
@@ -56,6 +62,7 @@
56
62
  "@davidwells/box-logger": "^2.0.3",
57
63
  "@iarna/toml": "^2.2.5",
58
64
  "dot-prop": "^5.3.0",
65
+ "dotenv": "^17.2.2",
59
66
  "env-stage-loader": "^1.1.3",
60
67
  "find-up": "^3.0.0",
61
68
  "git-url-parse": "^14.0.0",
@@ -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