configorama 1.2.2 → 1.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "configorama",
3
- "version": "1.2.2",
3
+ "version": "1.3.1",
4
4
  "description": "Variable support for configuration files",
5
5
  "main": "src/index.js",
6
6
  "types": "index.d.ts",
@@ -29,10 +29,6 @@
29
29
  "scripts": {
30
30
  "info": "repomix --include \"cli.js,src/**/*.js\" --ignore \"**/*.test.js\" --copy",
31
31
  "docs": "node ./scripts/docs.js",
32
- "site:dev": "cd site && npm run dev",
33
- "site:build": "cd site && npm run build",
34
- "site:validate": "cd site && npm run validate",
35
- "site:deploy": "cd site && npm run deploy",
36
32
  "t": "./scripts/run-tests.sh",
37
33
  "test": "npm run test:slow && npm run test:lib && uvu tests \".*\\.test.js$\"",
38
34
  "test:tests": "uvu tests \".*\\.test.js$\" ",
@@ -47,17 +43,15 @@
47
43
  "publish": "git push origin && git push origin --tags",
48
44
  "release:patch": "npm version patch && npm publish",
49
45
  "release:minor": "npm version minor && 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
+ "release:major": "npm version major && npm publish"
54
47
  },
55
48
  "author": "David Wells",
56
49
  "license": "MIT",
57
50
  "homepage": "https://github.com/DavidWells/configorama#readme",
58
51
  "repository": {
59
52
  "type": "git",
60
- "url": "https://github.com/DavidWells/configorama"
53
+ "url": "https://github.com/DavidWells/configorama",
54
+ "directory": "packages/configorama"
61
55
  },
62
56
  "dependencies": {
63
57
  "@clack/prompts": "^0.11.0",
@@ -83,8 +77,8 @@
83
77
  },
84
78
  "devDependencies": {
85
79
  "@cdktf/hcl2json": "^0.21.0",
80
+ "@davidwells/op-stash": "^0.1.2",
86
81
  "@types/node": "^24.10.1",
87
- "lerna": "^9.0.0",
88
82
  "markdown-magic": "^4.8.0",
89
83
  "tsx": "^4.7.0",
90
84
  "typescript": "^5.8.3",
@@ -96,10 +90,14 @@
96
90
  },
97
91
  "peerDependencies": {
98
92
  "@cdktf/hcl2json": ">=0.20.0",
93
+ "@davidwells/op-stash": ">=0.1.0",
99
94
  "ts-node": ">=10.0.0",
100
95
  "tsx": ">=4.0.0"
101
96
  },
102
97
  "peerDependenciesMeta": {
98
+ "@davidwells/op-stash": {
99
+ "optional": true
100
+ },
103
101
  "@cdktf/hcl2json": {
104
102
  "optional": true
105
103
  },
@@ -110,6 +108,5 @@
110
108
  "optional": true
111
109
  }
112
110
  },
113
- "packageManager": "pnpm@10.33.0",
114
- "gitHead": "05dde9eefc90001bcea7f7e40f173cb87d18b4b6"
111
+ "gitHead": "54014035ae878f6d6634655e87a8a21df07d1f1f"
115
112
  }
@@ -50,6 +50,7 @@ Options:
50
50
  | `configDir` | string | Passed to `op` as `--config` |
51
51
  | `opPath` | string | Path to the `op` binary (defaults to `op` on `PATH`) |
52
52
  | `skipResolution` | boolean | Record metadata and return deterministic placeholders without calling `op` |
53
+ | `cache` | object | Optional cache provider config. `{ provider: 'op-stash' }` enables `@davidwells/op-stash` for every supported `${op...}` syntax |
53
54
 
54
55
  ## Alias syntax (recommended)
55
56
 
@@ -160,6 +161,49 @@ configorama: requesting 3 items from 1Password (expect an authorization prompt)
160
161
 
161
162
  The hint is TTY-only — silent in CI and pipes.
162
163
 
164
+ ## Optional op-stash
165
+
166
+ For fresh-process workflows such as agents repeatedly running `configx .env -- <command>`, install `@davidwells/op-stash` and opt in explicitly:
167
+
168
+ ```bash
169
+ npm install @davidwells/op-stash
170
+ ```
171
+
172
+ ```js
173
+ const createOnePasswordResolver = require('configorama/plugins/onepassword')
174
+
175
+ module.exports = {
176
+ variableSources: [
177
+ createOnePasswordResolver({
178
+ cache: {
179
+ provider: 'op-stash',
180
+ ttlSeconds: 300,
181
+ scope: process.env.OP_STASH_SCOPE || 'user',
182
+ fallbackToOp: false,
183
+ allowServiceAccountTokenCache: false
184
+ }
185
+ })
186
+ ]
187
+ }
188
+ ```
189
+
190
+ Every supported syntax caches when the cache option is configured — aliases, item names and IDs, private links, explicit fields, inferred fields, and structured note key paths, plus direct `op://` refs:
191
+
192
+ ```yaml
193
+ a: ${op:npm.NPM_TOKEN} # alias + structured note key path
194
+ b: ${op(database-prod).password} # item name + field
195
+ c: ${op(<private link>).KEY} # private link + key
196
+ d: ${op(op://vault/item/field)} # direct secret ref
197
+ ```
198
+
199
+ The cache stores **final resolved values only**, never `op item get` JSON. A cached entry is exactly the string a variable resolved to, so the daemon never holds more secret material than a config actually requested, and a cache hit returns the value without any `op` call. Item JSON is deliberately not cached: whole items hold every revealed field, complicate invalidation, and would turn the cache into something 1Password-shape-aware instead of a scalar secret store.
200
+
201
+ One granularity consequence: `${op(op://vault/item/notesPlain).KEY}` caches the selected `KEY` value, not the whole `notesPlain` field. Resolving a key path that no earlier run resolved costs one fresh `op` call even when a sibling key is already cached.
202
+
203
+ Values live in daemon memory only — until TTL expiry, `op-stash clear`, or `op-stash stop`; nothing is written to disk. Short TTLs (minutes, not hours) are recommended for interactive agent workflows. `OP_STASH_DISABLED=1` bypasses all cache paths.
204
+
205
+ The plugin fails closed by default when a configured cache provider is broken. Set `fallbackToOp: true` to degrade to direct resolution. If `OP_SERVICE_ACCOUNT_TOKEN` is set, the cache is bypassed unless `allowServiceAccountTokenCache: true` is configured.
206
+
163
207
  ## Security model
164
208
 
165
209
  - Secrets are fetched from `op` at resolution time. The plugin never writes them to disk, never logs CLI output, and never passes resolved values as command-line arguments. Resolved values **do** enter the config object returned to your code — treat that object as sensitive (don't log it, commit it, or emit it to an untrusted sink).
@@ -0,0 +1,34 @@
1
+ /* Builds synthetic cache refs for final-value op-stash entries.
2
+ NUL-joined fixed-order dimensions hashed with SHA-256 — never raw links. */
3
+ const crypto = require('crypto')
4
+
5
+ // Bump whenever field-selection semantics or the envelope shape change:
6
+ // old entries then live under unreachable keys and expire unread.
7
+ const CACHE_REF_SCHEMA_VERSION = 'v1'
8
+
9
+ /**
10
+ * Deterministic cache reference for one final-value resolver operation.
11
+ * Dimensions are requested inputs only — never discovered outputs — so the
12
+ * key is computable before any op call. account/configDir/opPath are NOT
13
+ * included here; they travel via getOrSet opts and op-stash applies them as
14
+ * key dimensions, exactly as it does for read (one owner per dimension).
15
+ * @param {object} reference - Normalized reference ({ kind, item, vault, section, field, ref })
16
+ * @param {string|undefined} keyPath - Requested key path
17
+ * @returns {string} configorama-op://v1/<sha256hex>
18
+ */
19
+ function buildCacheRef(reference, keyPath) {
20
+ const itemOrRef = reference.kind === 'secretRef' ? reference.ref : reference.item
21
+ const parts = [
22
+ CACHE_REF_SCHEMA_VERSION,
23
+ reference.kind || '',
24
+ itemOrRef || '',
25
+ reference.vault || '',
26
+ reference.section || '',
27
+ reference.field || '',
28
+ keyPath || '',
29
+ ]
30
+ const hash = crypto.createHash('sha256').update(parts.join('\0')).digest('hex')
31
+ return `configorama-op://${CACHE_REF_SCHEMA_VERSION}/${hash}`
32
+ }
33
+
34
+ module.exports = { buildCacheRef, CACHE_REF_SCHEMA_VERSION }
@@ -0,0 +1,45 @@
1
+ /* Encodes cached final values as { value, fieldName } JSON strings.
2
+ Private plugin convention: op-stash stores these as opaque strings. */
3
+
4
+ /**
5
+ * @param {string} value - Final resolved value
6
+ * @param {string|undefined} fieldName - Discovered field name for audit metadata
7
+ * @returns {string} Envelope JSON string
8
+ */
9
+ function encodeEnvelope(value, fieldName) {
10
+ if (typeof value !== 'string') throw new Error('Envelope value must be a string.')
11
+ return JSON.stringify(fieldName === undefined ? { value } : { value, fieldName })
12
+ }
13
+
14
+ /**
15
+ * @param {string} encoded - Envelope JSON string
16
+ * @returns {{value: string, fieldName: string|undefined}}
17
+ */
18
+ function decodeEnvelope(encoded) {
19
+ let parsed
20
+ try {
21
+ parsed = JSON.parse(encoded)
22
+ } catch (err) {
23
+ throw new Error('Malformed op-stash envelope: not valid JSON.')
24
+ }
25
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || typeof parsed.value !== 'string') {
26
+ throw new Error('Malformed op-stash envelope: missing string value.')
27
+ }
28
+ return { value: parsed.value, fieldName: typeof parsed.fieldName === 'string' ? parsed.fieldName : undefined }
29
+ }
30
+
31
+ /**
32
+ * validateCached hook: rejecting here makes getOrSet recompute and overwrite.
33
+ * @param {string} encoded - Cached string
34
+ * @returns {boolean}
35
+ */
36
+ function isValidEnvelope(encoded) {
37
+ try {
38
+ decodeEnvelope(encoded)
39
+ return true
40
+ } catch (err) {
41
+ return false
42
+ }
43
+ }
44
+
45
+ module.exports = { encodeEnvelope, decodeEnvelope, isValidEnvelope }
@@ -4,6 +4,8 @@ const { readSecretRef, getItem } = require('./op-cli')
4
4
  const { validateAliasName, normalizeRefValue, isItemId } = require('./normalize')
5
5
  const { selectField, trySelectField } = require('./fields')
6
6
  const { parseStructuredSecret, getKeyPath } = require('./parser')
7
+ const { buildCacheRef } = require('./cache-ref')
8
+ const { encodeEnvelope, decodeEnvelope, isValidEnvelope } = require('./envelope')
7
9
 
8
10
  const OP_PREFIX = 'op'
9
11
 
@@ -45,6 +47,7 @@ const opVariableSyntax = /^op(?::|\()/
45
47
  * @param {string} [options.opPath] - Path to the op binary (defaults to "op" on PATH)
46
48
  * @param {string} [options.programName] - Host tool name for the auth-prompt hint (defaults to $CONFIGORAMA_PROGRAM_NAME or "configorama")
47
49
  * @param {boolean} [options.skipResolution] - Collect metadata and return placeholders without calling op
50
+ * @param {object} [options.cache] - Optional cache provider config ({ provider: 'op-stash', ttlSeconds, scope, fallbackToOp, allowServiceAccountTokenCache })
48
51
  * @param {Function} [options.execFile] - execFile injection for tests (not serializable; unavailable in sync mode)
49
52
  * @returns {object} Variable source configuration with resolver and metadata collector
50
53
  */
@@ -56,9 +59,17 @@ function createOnePasswordResolver(options = {}) {
56
59
  opPath,
57
60
  programName,
58
61
  skipResolution = false,
62
+ cache,
59
63
  execFile,
60
64
  } = options
61
65
 
66
+ // An unrecognized provider fails loudly: a typo silently disabling a
67
+ // requested secrets cache would look identical to working caching.
68
+ if (cache && cache.provider !== 'op-stash') {
69
+ throw new Error(`Unknown 1Password cache provider "${cache.provider}". Supported: 'op-stash'.`)
70
+ }
71
+ const opStash = cache ? loadOpStash() : undefined
72
+
62
73
  const aliasRefs = {}
63
74
  for (const alias of Object.keys(refs)) {
64
75
  validateAliasName(alias)
@@ -207,6 +218,75 @@ function createOnePasswordResolver(options = {}) {
207
218
  return `[OP:item:${reference.item}${keyPath ? `:${keyPath}` : ''}]`
208
219
  }
209
220
 
221
+ /**
222
+ * Resolve a normalized reference to its final value.
223
+ * Direct secret refs without a key path keep the read path (same daemon
224
+ * keys as standalone `op-stash read`); every other syntax caches its
225
+ * final resolved value under a synthetic ref.
226
+ * @param {object} reference - Normalized reference
227
+ * @param {string|undefined} keyPath - Requested key path
228
+ * @param {string} [label] - Human label for the auth hint
229
+ * @returns {Promise<{value: string, fieldName: string|undefined}>} Final value
230
+ */
231
+ async function resolveReference(reference, keyPath, label) {
232
+ if (reference.kind === 'secretRef' && keyPath === undefined) {
233
+ const value = await cached(secretRefCache, `${scopeKey}|${reference.ref}`, () => {
234
+ return readSecretRefWithOptionalCache(reference.ref)
235
+ }, label)
236
+ return { value, fieldName: undefined }
237
+ }
238
+ return finalValueWithOptionalCache(reference, keyPath, label)
239
+ }
240
+
241
+ /**
242
+ * Final-value caching through op-stash getOrSet. The cached payload is a
243
+ * { value, fieldName } envelope so audit metadata survives cache hits;
244
+ * malformed envelopes are rejected via validateCached and recomputed.
245
+ * @param {object} reference - Normalized reference
246
+ * @param {string|undefined} keyPath - Requested key path
247
+ * @param {string} [label] - Human label for the auth hint
248
+ * @returns {Promise<{value: string, fieldName: string|undefined}>} Final value
249
+ */
250
+ async function finalValueWithOptionalCache(reference, keyPath, label) {
251
+ if (!opStash || shouldBypassOpStash()) {
252
+ return computeFinalValue(reference, keyPath, label)
253
+ }
254
+ const cacheRef = buildCacheRef(reference, keyPath)
255
+ const encoded = await opStash.getOrSet(cacheRef, async () => {
256
+ const resolved = await computeFinalValue(reference, keyPath, label)
257
+ return encodeEnvelope(resolved.value, resolved.fieldName)
258
+ }, {
259
+ account,
260
+ configDir,
261
+ opPath,
262
+ ttlSeconds: cache.ttlSeconds,
263
+ scope: cache.scope,
264
+ fallbackToOp: cache.fallbackToOp === true,
265
+ validateCached: isValidEnvelope,
266
+ stderr: process.stderr,
267
+ })
268
+ return decodeEnvelope(encoded)
269
+ }
270
+
271
+ /**
272
+ * Compute the final resolver value with no persistent cache involved.
273
+ * This is the getOrSet producer body; the cold-start latch lives in here
274
+ * (via cached) so daemon hits never serialize behind it and auth hints
275
+ * only print when an op call actually happens.
276
+ * @param {object} reference - Normalized reference
277
+ * @param {string|undefined} keyPath - Requested key path
278
+ * @param {string} [label] - Human label for the auth hint
279
+ * @returns {Promise<{value: string, fieldName: string|undefined}>} Final value
280
+ */
281
+ async function computeFinalValue(reference, keyPath, label) {
282
+ const { value, fieldName, remainingKeyPath } = await selectBackingValue(reference, keyPath, label)
283
+ if (remainingKeyPath === undefined) {
284
+ return { value, fieldName }
285
+ }
286
+ const parsedValue = parseStructuredSecret(value, { fieldName })
287
+ return { value: getKeyPath(parsedValue, remainingKeyPath, fieldName), fieldName }
288
+ }
289
+
210
290
  /**
211
291
  * Fetch and select the backing field value for a normalized reference.
212
292
  * For items without a configured field, the first key path segment may
@@ -218,7 +298,7 @@ function createOnePasswordResolver(options = {}) {
218
298
  * @param {string} [label] - Human label for the auth hint
219
299
  * @returns {Promise<{value: string, fieldName: string, remainingKeyPath: string|undefined}>} Selected value
220
300
  */
221
- async function fetchValue(reference, keyPath, label) {
301
+ async function selectBackingValue(reference, keyPath, label) {
222
302
  if (reference.kind === 'secretRef') {
223
303
  const value = await cached(secretRefCache, `${scopeKey}|${reference.ref}`, () => {
224
304
  return readSecretRef(reference.ref, cliOptions)
@@ -301,16 +381,11 @@ function createOnePasswordResolver(options = {}) {
301
381
  || (configPath ? configPath.split('.').pop() : undefined)
302
382
  || reference.field
303
383
  || (reference.kind === 'secretRef' ? reference.ref : reference.item)
304
- const { value, fieldName, remainingKeyPath } = await fetchValue(reference, keyPath, label)
305
- if (entry.field === undefined && reference.kind !== 'secretRef') {
306
- entry.field = fieldName
384
+ const resolved = await resolveReference(reference, keyPath, label)
385
+ if (entry.field === undefined && reference.kind !== 'secretRef' && resolved.fieldName !== undefined) {
386
+ entry.field = resolved.fieldName
307
387
  }
308
-
309
- if (remainingKeyPath === undefined) {
310
- return value
311
- }
312
- const parsedValue = parseStructuredSecret(value, { fieldName })
313
- return getKeyPath(parsedValue, remainingKeyPath, fieldName)
388
+ return resolved.value
314
389
  }
315
390
 
316
391
  return {
@@ -342,6 +417,7 @@ function createOnePasswordResolver(options = {}) {
342
417
  */
343
418
  function buildSyncOptions() {
344
419
  const syncOptions = { refs, account, configDir, opPath, skipResolution }
420
+ if (cache !== undefined) syncOptions.cache = cache
345
421
  if (execFile) {
346
422
  // Functions cannot cross the JSON boundary into the sync worker;
347
423
  // flag it so sync-factory can fail loudly instead of silently
@@ -350,6 +426,48 @@ function createOnePasswordResolver(options = {}) {
350
426
  }
351
427
  return syncOptions
352
428
  }
429
+
430
+ /**
431
+ * @param {string} ref - Direct op:// secret reference
432
+ * @returns {Promise<string>} Secret value
433
+ */
434
+ function readSecretRefWithOptionalCache(ref) {
435
+ if (!opStash || shouldBypassOpStash()) {
436
+ return readSecretRef(ref, cliOptions)
437
+ }
438
+ return opStash.read(ref, {
439
+ account,
440
+ configDir,
441
+ opPath,
442
+ ttlSeconds: cache.ttlSeconds,
443
+ scope: cache.scope,
444
+ fallbackToOp: cache.fallbackToOp === true,
445
+ stderr: process.stderr,
446
+ })
447
+ }
448
+
449
+ /**
450
+ * @returns {boolean} Whether cache must be bypassed for this read
451
+ */
452
+ function shouldBypassOpStash() {
453
+ if (process.env.OP_STASH_DISABLED === '1') return true
454
+ if (execFile) return true
455
+ if (process.env.OP_SERVICE_ACCOUNT_TOKEN && !(cache && cache.allowServiceAccountTokenCache === true)) {
456
+ return true
457
+ }
458
+ return false
459
+ }
460
+ }
461
+
462
+ /**
463
+ * @returns {object} @davidwells/op-stash API
464
+ */
465
+ function loadOpStash() {
466
+ try {
467
+ return require('@davidwells/op-stash')
468
+ } catch (err) {
469
+ throw new Error('1Password op-stash provider requested but @davidwells/op-stash is not installed. Install it with: npm install @davidwells/op-stash')
470
+ }
353
471
  }
354
472
 
355
473
  module.exports = createOnePasswordResolver
@@ -1 +0,0 @@
1
- //# sourceMappingURL=valueFromSelf.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"valueFromSelf.d.ts","sourceRoot":"","sources":["../../../src/resolvers/valueFromSelf.js"],"names":[],"mappings":""}