configorama 0.11.2 → 1.0.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 (52) hide show
  1. package/README.md +429 -123
  2. package/cli.js +282 -49
  3. package/index.d.ts +43 -1
  4. package/package.json +5 -1
  5. package/src/capabilities.js +59 -0
  6. package/src/capabilities.test.js +44 -0
  7. package/src/display.js +70 -7
  8. package/src/display.test.js +82 -0
  9. package/src/errors.js +73 -0
  10. package/src/index.js +91 -1
  11. package/src/main.js +132 -16
  12. package/src/resolvers/valueFromEval.js +11 -1
  13. package/src/resolvers/valueFromFile.js +12 -3
  14. package/src/resolvers/valueFromGit.js +43 -17
  15. package/src/resolvers/valueFromOptions.js +5 -4
  16. package/src/utils/filters/filterArgs.js +57 -0
  17. package/src/utils/filters/oneOf.js +77 -0
  18. package/src/utils/introspection/audit.js +78 -0
  19. package/src/utils/introspection/graph.js +43 -0
  20. package/src/utils/introspection/model.js +150 -0
  21. package/src/utils/introspection/model.test.js +93 -0
  22. package/src/utils/parsing/commentAnnotations.js +107 -0
  23. package/src/utils/parsing/commentAnnotations.test.js +123 -0
  24. package/src/utils/parsing/enrichMetadata.js +64 -1
  25. package/src/utils/parsing/enrichMetadata.test.js +84 -0
  26. package/src/utils/parsing/extractComment.js +145 -0
  27. package/src/utils/parsing/extractComment.test.js +182 -0
  28. package/src/utils/parsing/preProcess.js +2 -1
  29. package/src/utils/paths/findLineForKey.js +2 -2
  30. package/src/utils/paths/ignorePaths.js +21 -9
  31. package/src/utils/redaction/redact.js +78 -0
  32. package/src/utils/redaction/redact.test.js +38 -0
  33. package/src/utils/redaction/setupRedaction.js +47 -0
  34. package/src/utils/redaction/setupRedaction.test.js +68 -0
  35. package/src/utils/requirements/configRequirements.js +351 -0
  36. package/src/utils/requirements/configRequirements.test.js +380 -0
  37. package/src/utils/requirements/serializeRequirements.js +120 -0
  38. package/src/utils/requirements/serializeRequirements.test.js +211 -0
  39. package/src/utils/security/evalSafety.js +86 -0
  40. package/src/utils/security/evalSafety.test.js +61 -0
  41. package/src/utils/security/safetyPolicy.js +110 -0
  42. package/src/utils/security/safetyPolicy.test.js +29 -0
  43. package/src/utils/strings/didYouMean.js +70 -0
  44. package/src/utils/strings/didYouMean.test.js +52 -0
  45. package/src/utils/strings/formatFunctionArgs.js +6 -1
  46. package/src/utils/strings/splitByComma.js +5 -0
  47. package/src/utils/ui/configWizard.js +208 -34
  48. package/src/utils/ui/createEditorLink.js +17 -1
  49. package/src/utils/ui/promptDescriptors.js +196 -0
  50. package/src/utils/ui/promptDescriptors.test.js +162 -0
  51. package/src/utils/variables/cleanVariable.js +22 -0
  52. package/src/utils/variables/getVariableType.js +1 -0
package/src/main.js CHANGED
@@ -39,7 +39,39 @@ function walkAndUpdate(root, callback) {
39
39
  }
40
40
  visit(root, [], null, null)
41
41
  }
42
+
43
+ function isNestedFilterArgument(property, matchedString) {
44
+ if (typeof property !== 'string' || typeof matchedString !== 'string') return false
45
+ if (property.trim() === matchedString.trim()) return false
46
+ const matchIdx = property.indexOf(matchedString)
47
+ const pipeIdx = property.indexOf('|')
48
+ const openParenIdx = property.lastIndexOf('(', matchIdx)
49
+ const closeParenIdx = property.indexOf(')', matchIdx)
50
+ return pipeIdx !== -1 && matchIdx > pipeIdx && openParenIdx > pipeIdx && closeParenIdx > matchIdx
51
+ }
52
+
53
+ function resolveConfigFilePath(filePath) {
54
+ const absolutePath = path.resolve(filePath)
55
+ try {
56
+ return fs.realpathSync(absolutePath)
57
+ } catch (err) {
58
+ return absolutePath
59
+ }
60
+ }
61
+
62
+ function getConfigFileDirectory(filePath) {
63
+ return path.dirname(resolveConfigFilePath(filePath))
64
+ }
65
+
42
66
  const dotProp = require('dot-prop')
67
+
68
+ function resolveStaticFilterArg(arg, config) {
69
+ const match = String(arg).trim().match(/^\$\{(?:self:)?([^}]+)\}$/)
70
+ if (!match) return arg
71
+ if (!dotProp.has(config, match[1])) return arg
72
+ return encodeFilterArg(dotProp.get(config, match[1]))
73
+ }
74
+
43
75
  /* Utils - root */
44
76
  const {
45
77
  isArray, isString, isNumber, isObject, isDate, isRegExp, isFunction,
@@ -71,17 +103,28 @@ const { replaceAll } = require('./utils/strings/replaceAll')
71
103
  const { getTextAfterOccurrence, findNestedVariable } = require('./utils/strings/textUtils')
72
104
  const { ensureQuote, isSurroundedByQuotes, startsWithQuotedPipe } = require('./utils/strings/quoteUtils')
73
105
  const { splitOnPipe } = require('./utils/strings/splitOnPipe')
106
+ const { encodeFilterArg } = require('./utils/filters/filterArgs')
107
+ const { validateOneOf } = require('./utils/filters/oneOf')
74
108
  /* Utils - ui */
75
109
  const chalk = require('./utils/ui/chalk')
76
110
  const deepLog = require('./utils/ui/deep-log')
77
111
  const { logHeader } = require('./utils/ui/logs')
78
112
  const { runConfigWizard } = require('./utils/ui/configWizard')
113
+ const { buildConfigRequirements } = require('./utils/requirements/configRequirements')
114
+ const { redactUserInputsByRequirements } = require('./utils/redaction/setupRedaction')
79
115
  /* Display */
80
116
  const { displayNoVariablesFound, displayVariableDetails, displayUniqueVariables, displayConfigurableVariables } = require('./display')
81
117
  /* Metadata */
82
118
  const { collectVariableMetadata: collectMetadata } = require('./metadata')
83
119
  /* Utils - validation */
84
120
  const { warnIfNotFound, isValidValue } = require('./utils/validation/warnIfNotFound')
121
+ const {
122
+ assertCustomFunctionsAllowed,
123
+ assertCustomResolversAllowed,
124
+ assertSafeConfigInput,
125
+ normalizeSafetyPolicy,
126
+ } = require('./utils/security/safetyPolicy')
127
+ const { ConfigoramaError } = require('./errors')
85
128
  /* Utils - variables */
86
129
  const cleanVariable = require('./utils/variables/cleanVariable')
87
130
  const appendDeepVariable = require('./utils/variables/appendDeepVariable')
@@ -144,6 +187,8 @@ class Configorama {
144
187
  }
145
188
 
146
189
  const options = opts || {}
190
+ // Setup wizard runs when --setup flag is passed (CLI) or options.setup is true (library)
191
+ this.setupMode = SETUP_MODE || options.setup === true
147
192
  // Set opts to pass into JS file calls
148
193
  this.settings = Object.assign({}, {
149
194
  // Allow unknown ${xyz:...} syntax where xyz is not a registered resolver
@@ -168,6 +213,7 @@ class Configorama {
168
213
  // CloudFormation Fn::Sub, inline Lambda code, and CloudFront functions.
169
214
  ignorePaths: [],
170
215
  skipResolutionPaths: [],
216
+ safeMode: false,
171
217
  }, options)
172
218
 
173
219
  // Backward compat: allowUnknownVars -> allowUnknownVariableTypes
@@ -179,6 +225,13 @@ class Configorama {
179
225
  this.settings.allowUnknownVariableTypes = options.allowUnknownVariables
180
226
  }
181
227
 
228
+ this.safetyPolicy = normalizeSafetyPolicy(this.settings, {
229
+ configDir: options.configDir || (typeof fileOrObject === 'string' ? getConfigFileDirectory(fileOrObject) : process.cwd())
230
+ })
231
+
232
+ assertCustomResolversAllowed(options.variableSources, this.safetyPolicy)
233
+ assertCustomFunctionsAllowed(options.functions, this.safetyPolicy)
234
+
182
235
  // Merge legacy allowUnknownParams and allowUnknownFileRefs into allowUnresolvedVariables
183
236
  let unresolvedSetting = this.settings.allowUnresolvedVariables
184
237
  if (unresolvedSetting !== true) {
@@ -197,6 +250,9 @@ class Configorama {
197
250
  // Paths whose current value is a literal with no variables — skip rebuilding
198
251
  // their leaf object on every subsequent populateObjectImpl iteration.
199
252
  this._resolvedPaths = new Set()
253
+ // Ignore-path decisions are constant for a run (patterns are fixed per
254
+ // instance), so cache per path to skip repeated glob matching.
255
+ this._ignorePathCache = new Map()
200
256
  // Cache raw file contents per absolute path so repeated ${file:...} refs
201
257
  // to the same file (e.g., merged twice into different keys) don't reread.
202
258
  this._fileContentCache = new Map()
@@ -208,7 +264,7 @@ class Configorama {
208
264
  this._needsRawClone = !!(
209
265
  this.settings.returnMetadata ||
210
266
  this.settings.returnPreResolvedVariableDetails ||
211
- VERBOSE || SETUP_MODE || showFound
267
+ VERBOSE || this.setupMode || showFound
212
268
  )
213
269
 
214
270
  this.foundVariables = []
@@ -272,9 +328,10 @@ class Configorama {
272
328
  // Set configPath for file references
273
329
  this.configPath = options.configDir || process.cwd()
274
330
  } else if (typeof fileOrObject === 'string') {
331
+ assertSafeConfigInput(fileOrObject, this.safetyPolicy)
275
332
  // read and parse file
276
333
  const fileContents = fs.readFileSync(fileOrObject, 'utf-8')
277
- const fileDirectory = path.dirname(path.resolve(fileOrObject))
334
+ const fileDirectory = getConfigFileDirectory(fileOrObject)
278
335
  const fileType = path.extname(fileOrObject)
279
336
 
280
337
  this.configFilePath = fileOrObject
@@ -502,9 +559,11 @@ class Configorama {
502
559
  // Build prefix lookup map for O(1) type detection (perf optimization)
503
560
  this._resolverByPrefix = new Map()
504
561
  for (const r of this.variableTypes) {
505
- const prefix = r.prefix || r.type
506
- if (prefix && r.match instanceof RegExp && !r.internal) {
507
- this._resolverByPrefix.set(prefix + ':', r)
562
+ const prefixes = r.prefixes || [r.prefix || r.type]
563
+ for (const prefix of prefixes) {
564
+ if (prefix && r.match instanceof RegExp && !r.internal) {
565
+ this._resolverByPrefix.set(prefix + ':', r)
566
+ }
508
567
  }
509
568
  }
510
569
 
@@ -575,6 +634,37 @@ class Configorama {
575
634
  if (value === undefined || value === null || value === 'null') return ''
576
635
  return String(value)
577
636
  },
637
+ Array: (value) => {
638
+ if (Array.isArray(value)) return value
639
+ if (typeof value !== 'string') {
640
+ throw new Error(`Configorama Error: Expected Array, got "${value}"`)
641
+ }
642
+ const trimmed = value.trim()
643
+ if (!trimmed) return []
644
+ try {
645
+ const parsed = JSON5.parse(trimmed)
646
+ if (Array.isArray(parsed)) return parsed
647
+ throw new Error('not-array')
648
+ } catch (error) {
649
+ if (trimmed.includes(',')) {
650
+ return trimmed.split(',').map(item => item.trim()).filter(Boolean)
651
+ }
652
+ throw new Error(`Configorama Error: Expected Array, got "${value}"`)
653
+ }
654
+ },
655
+ Object: (value) => {
656
+ if (value && typeof value === 'object' && !Array.isArray(value)) return value
657
+ if (typeof value !== 'string') {
658
+ throw new Error(`Configorama Error: Expected Object, got "${value}"`)
659
+ }
660
+ try {
661
+ const parsed = JSON5.parse(value)
662
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed
663
+ } catch (error) {
664
+ // Fall through to consistent error below.
665
+ }
666
+ throw new Error(`Configorama Error: Expected Object, got "${value}"`)
667
+ },
578
668
  Json: (value) => {
579
669
  try {
580
670
  return typeof value === 'string' ? JSON.parse(value) : value
@@ -588,6 +678,7 @@ class Configorama {
588
678
  // The helpText argument is extracted during metadata collection for the wizard
589
679
  return value
590
680
  },
681
+ oneOf: validateOneOf,
591
682
  }
592
683
 
593
684
  // Apply user defined filters
@@ -746,7 +837,7 @@ class Configorama {
746
837
  dynamicArgs: this.settings.dynamicArgs
747
838
  })
748
839
  this.configFileContents = ''
749
- if (VERBOSE || showFoundVariables || this.settings.returnPreResolvedVariableDetails || SETUP_MODE) {
840
+ if (VERBOSE || showFoundVariables || this.settings.returnPreResolvedVariableDetails || this.setupMode) {
750
841
  this.configFileContents = fs.readFileSync(this.configFilePath, 'utf8')
751
842
  }
752
843
  /*
@@ -789,7 +880,7 @@ class Configorama {
789
880
  const variableSyntax = this.variableSyntax
790
881
  const variablesKnownTypes = this.variablesKnownTypes
791
882
 
792
- if (VERBOSE || showFoundVariables || this.settings.returnPreResolvedVariableDetails || SETUP_MODE) {
883
+ if (VERBOSE || showFoundVariables || this.settings.returnPreResolvedVariableDetails || this.setupMode) {
793
884
  const metadata = this.collectVariableMetadata()
794
885
 
795
886
  const enrich = await enrichMetadata(
@@ -844,15 +935,18 @@ class Configorama {
844
935
  displayConfigurableVariables(displayParams)
845
936
 
846
937
 
847
- // WALK through CLI prompt if --setup flag is set
848
- if (SETUP_MODE) {
938
+ // WALK through CLI prompt when setup mode is active
939
+ if (this.setupMode) {
849
940
  logHeader('Setup Mode')
850
941
  // deepLog('enrich', enrich)
851
942
  const userInputs = await runConfigWizard(enrich, this.originalConfig, this.configFilePath)
943
+ const setupRequirements = buildConfigRequirements(enrich)
944
+ this.setupRequirements = setupRequirements
945
+ const displayInputs = redactUserInputsByRequirements(userInputs, setupRequirements)
852
946
 
853
947
  logHeader('User Inputs Summary')
854
948
  console.log()
855
- console.log(JSON.stringify(userInputs, null, 2))
949
+ console.log(JSON.stringify(displayInputs, null, 2))
856
950
 
857
951
  // TODO set values
858
952
 
@@ -902,6 +996,12 @@ class Configorama {
902
996
 
903
997
  const useDotEnv = this.originalConfig.useDotenv || this.originalConfig.useDotEnv
904
998
  if ((useDotEnv && useDotEnv === true) || this.settings.useDotEnvFiles) {
999
+ if (this.safetyPolicy.blockDotEnv) {
1000
+ throw new ConfigoramaError('blocked_by_safe_mode', 'Dotenv loading is blocked in safe mode', {
1001
+ surface: 'dotenv',
1002
+ configPath: this.configFilePath,
1003
+ })
1004
+ }
905
1005
  let providerStage
906
1006
  /* has hardcoded stage */
907
1007
  if (
@@ -1127,7 +1227,14 @@ class Configorama {
1127
1227
  // ## PROPERTY HANDLING ##
1128
1228
  // #######################
1129
1229
  isIgnorePath(pathValue) {
1130
- return shouldIgnorePath(pathValue, this.ignorePathPatterns)
1230
+ if (!this.ignorePathPatterns.length) return false
1231
+ // NUL-join so distinct path arrays can never collide on a shared key.
1232
+ const key = isArray(pathValue) ? pathValue.join('\x00') : String(pathValue)
1233
+ const cached = this._ignorePathCache.get(key)
1234
+ if (cached !== undefined) return cached
1235
+ const result = shouldIgnorePath(pathValue, this.ignorePathPatterns)
1236
+ this._ignorePathCache.set(key, result)
1237
+ return result
1131
1238
  }
1132
1239
  // True when the value has a configorama-typed token that resolves even inside an
1133
1240
  // ignore-path (file/text/env/opt/cron/git/custom) — i.e. not just self/CFN refs.
@@ -1808,6 +1915,9 @@ class Configorama {
1808
1915
  valueToPopulate = `"${valueToPopulate}"`
1809
1916
  }
1810
1917
  }
1918
+ if (isNestedFilterArgument(property, currentMatchedString)) {
1919
+ valueToPopulate = encodeFilterArg(valueToPopulate)
1920
+ }
1811
1921
  property = replaceAll(currentMatchedString, valueToPopulate, property)
1812
1922
  // console.log('property replaceAll', property)
1813
1923
 
@@ -1818,7 +1928,10 @@ class Configorama {
1818
1928
  // partial replacement, number
1819
1929
  } else if (isNumber(valueToPopulate)) {
1820
1930
  if (DEBUG_TYPE) console.log('DEBUG_TYPE isNumber')
1821
- property = replaceAll(matchedString, String(valueToPopulate), property)
1931
+ const replacementValue = isNestedFilterArgument(property, matchedString)
1932
+ ? encodeFilterArg(valueToPopulate)
1933
+ : String(valueToPopulate)
1934
+ property = replaceAll(matchedString, replacementValue, property)
1822
1935
  // TODO This was temp fix for array value mismatch from filters. This fixes filterInner: ${commas | split(${self:inner}, 2) }
1823
1936
  // } else if (isArray(valueToPopulate) && valueToPopulate.length === 1) {
1824
1937
  // property = replaceAll(matchedString, String(valueToPopulate[0]), property)
@@ -1841,7 +1954,9 @@ class Configorama {
1841
1954
  )
1842
1955
  // Only encode for file() or text() references where JSON braces break regex matching
1843
1956
  const isFileOrTextRef = /\bfile\s*\(|\btext\s*\(/.test(property)
1844
- if (isNestedInVariable && isFileOrTextRef) {
1957
+ if (isNestedFilterArgument(property, matchedString)) {
1958
+ property = replaceAll(matchedString, encodeFilterArg(valueToPopulate), property)
1959
+ } else if (isNestedInVariable && isFileOrTextRef) {
1845
1960
  // Encode object as base64 to avoid breaking variable syntax with nested braces
1846
1961
  const encodedObj = encodeJsonForVariable(valueToPopulate)
1847
1962
  property = replaceAll(matchedString, encodedObj, property)
@@ -2069,7 +2184,7 @@ Missing Value ${missingValue} - ${matchedString}
2069
2184
  const rawArgs = funcMatch[2]
2070
2185
  if (rawArgs) {
2071
2186
  const splitter = splitCsv(rawArgs, ', ')
2072
- filterArgs = formatFunctionArgs(splitter)
2187
+ filterArgs = formatFunctionArgs(splitter.map(arg => resolveStaticFilterArg(arg, this.config)))
2073
2188
  }
2074
2189
  }
2075
2190
 
@@ -2515,7 +2630,7 @@ Missing Value ${missingValue} - ${matchedString}
2515
2630
  // Parse arguments using the same logic as functions
2516
2631
  if (rawArgs) {
2517
2632
  const splitter = splitCsv(rawArgs, ', ')
2518
- filterArgs = formatFunctionArgs(splitter)
2633
+ filterArgs = formatFunctionArgs(splitter.map(arg => resolveStaticFilterArg(arg, this.config)))
2519
2634
  }
2520
2635
  }
2521
2636
 
@@ -2787,7 +2902,8 @@ Missing Value ${missingValue} - ${matchedString}
2787
2902
  textRefSyntax: textRefSyntax,
2788
2903
  varPrefix: this.varPrefix,
2789
2904
  varSuffix: this.varSuffix,
2790
- fileContentCache: this._fileContentCache
2905
+ fileContentCache: this._fileContentCache,
2906
+ safetyPolicy: this.safetyPolicy
2791
2907
  }
2792
2908
  return getValueFromFileResolver(ctx, variableString, options)
2793
2909
  }
@@ -1,6 +1,7 @@
1
1
  // const evalRefSyntax = RegExp(/^eval\((~?[\{\}\:\${}a-zA=>+!-Z0-9._\-\/,'"\*\` ]+?)?\)/g)
2
2
  const evalRefSyntax = RegExp(/^eval\((.*)?\)/g)
3
3
  const { replaceOutsideQuotes } = require('../utils/strings/quoteAware')
4
+ const { assertSafeEvalExpression } = require('../utils/security/evalSafety')
4
5
 
5
6
  // Pattern for encoded objects/arrays: __OBJ:base64__ or __ARR:base64__
6
7
  const ENCODED_PATTERN = /__(?:OBJ|ARR):([A-Za-z0-9+/=]+)__/g
@@ -52,7 +53,9 @@ async function getValueFromEval(variableString) {
52
53
 
53
54
  // Use "justin" variant to support strict comparison (===, !==) and other JS-like operators
54
55
  try {
55
- const { default: subscript } = await import('subscript/justin')
56
+ // justin re-exports `parse` from subscript; importing the subpath directly
57
+ // ('subscript/parse') is not resolvable under classic module resolution.
58
+ const { default: subscript, parse } = await import('subscript/justin')
56
59
 
57
60
  // Handle string comparisons by ensuring both sides are quoted
58
61
  let processedExpression = expression.replace(/([a-zA-Z0-9_]+)\s*([=!<>]=?)\s*['"]([^'"]+)['"]/g, '"$1"$2"$3"')
@@ -79,6 +82,13 @@ async function getValueFromEval(variableString) {
79
82
  processedExpression = wrapComparisons(processedExpression)
80
83
 
81
84
  if (process.env.DEBUG_EVAL) console.log('eval processed:', processedExpression)
85
+
86
+ // Block prototype-chain escapes (e.g. "".constructor.constructor) before
87
+ // compiling, whether or not safe mode is enabled.
88
+ let ast = null
89
+ try { ast = parse(processedExpression) } catch (parseError) { ast = null }
90
+ assertSafeEvalExpression(processedExpression, ast)
91
+
82
92
  const fn = subscript(processedExpression)
83
93
  const result = fn(Object.keys(context).length > 0 ? context : undefined)
84
94
  return result
@@ -8,6 +8,7 @@ const { resolveFilePathFromMatch, resolveFilePath } = require('../utils/paths/ge
8
8
  const { findNestedVariables } = require('../utils/variables/findNestedVariables')
9
9
  const { makeBox } = require('@davidwells/box-logger')
10
10
  const { encodeJsSyntax, decodeJsonInVariable, hasEncodedJson } = require('../utils/encoders/js-fixes')
11
+ const { checkFileAccess } = require('../utils/security/safetyPolicy')
11
12
 
12
13
  /* File Parsers */
13
14
  const YAML = require('../parsers/yaml')
@@ -84,7 +85,7 @@ function parseFileContents(content, filePath) {
84
85
  if (ext === 'toml' || ext === 'tml') {
85
86
  return TOML.parse(content)
86
87
  }
87
- if (ext === 'ini') {
88
+ if (isIniLikeExtension(ext)) {
88
89
  return INI.parse(content)
89
90
  }
90
91
 
@@ -92,6 +93,10 @@ function parseFileContents(content, filePath) {
92
93
  return content
93
94
  }
94
95
 
96
+ function isIniLikeExtension(ext) {
97
+ return ext === 'ini' || ext === 'env'
98
+ }
99
+
95
100
  /**
96
101
  * Resolves a value from a file reference
97
102
  * @param {object} ctx - Context object with instance properties
@@ -109,6 +114,7 @@ function parseFileContents(content, filePath) {
109
114
  * @param {string} ctx.varPrefix - Variable prefix (e.g., '${')
110
115
  * @param {string} ctx.varSuffix - Variable suffix (e.g., '}')
111
116
  * @param {Map<string, string>} [ctx.fileContentCache] - Optional per-instance read cache keyed by absolute file path
117
+ * @param {object} [ctx.safetyPolicy] - Optional safe-mode policy for executable and root checks
112
118
  * @param {string} variableString - The variable string to resolve
113
119
  * @param {object} options - Resolution options
114
120
  * @returns {Promise<any>}
@@ -187,6 +193,9 @@ async function getValueFromFile(ctx, variableString, options) {
187
193
  }
188
194
 
189
195
  const exists = fs.existsSync(fullFilePath)
196
+ if (ctx.safetyPolicy) {
197
+ checkFileAccess(fullFilePath, ctx.safetyPolicy, { variableString })
198
+ }
190
199
 
191
200
  const fileRefEntry = {
192
201
  filePath: fullFilePath,
@@ -402,7 +411,7 @@ ${JSON.stringify(options.context, null, 2)}`,
402
411
  if (fileExtension === 'toml' || fileExtension === 'tml') {
403
412
  valueToPopulate = JSON.stringify(TOML.parse(valueToPopulate))
404
413
  }
405
- if (fileExtension === 'ini') {
414
+ if (isIniLikeExtension(fileExtension)) {
406
415
  valueToPopulate = INI.toJson(valueToPopulate)
407
416
  }
408
417
  if (fileExtension === 'tf' || fileExtension === 'hcl') {
@@ -442,7 +451,7 @@ Please use ":" or "." to reference sub properties. ${deepPropertiesStr}`
442
451
  return Promise.resolve(valueToPopulate)
443
452
  }
444
453
 
445
- if (fileExtension === 'ini') {
454
+ if (isIniLikeExtension(fileExtension)) {
446
455
  valueToPopulate = INI.parse(valueToPopulate)
447
456
  return Promise.resolve(valueToPopulate)
448
457
  }
@@ -114,6 +114,32 @@ const GIT_KEYS = {
114
114
  }
115
115
 
116
116
  function createResolver(cwd) {
117
+ let gitRepo
118
+ const gitResultCache = new Map()
119
+
120
+ function isCurrentGitRepo() {
121
+ if (typeof gitRepo === 'undefined') {
122
+ gitRepo = isGitRepo(cwd)
123
+ }
124
+ return gitRepo
125
+ }
126
+
127
+ function cachedSafeGit(key, cmdFn) {
128
+ if (!gitResultCache.has(key)) {
129
+ gitResultCache.set(key, _safeGit(cmdFn))
130
+ }
131
+ return gitResultCache.get(key)
132
+ }
133
+
134
+ function gitExec(args) {
135
+ const key = `git:${JSON.stringify(args)}`
136
+ return cachedSafeGit(key, () => _execFile('git', args))
137
+ }
138
+
139
+ function gitRemote(name = 'origin') {
140
+ return cachedSafeGit(`remote:${name}`, () => getGitRemote(name))
141
+ }
142
+
117
143
  async function _getValueFromGit(variableString) {
118
144
  const variable = variableString.split(`${GIT_PREFIX}:`)[1]
119
145
  let value = null
@@ -123,14 +149,14 @@ function createResolver(cwd) {
123
149
  // undefined. This lets fallbacks like `${git:branch, "main"}` work, and
124
150
  // when there's no fallback the outer resolver throws a clear "Unable to
125
151
  // resolve config variable" error pointing at the config path.
126
- if (!isGitRepo(cwd)) {
152
+ if (!isCurrentGitRepo()) {
127
153
  return undefined
128
154
  }
129
155
 
130
156
  if (variable.match(/^remote/i)) {
131
157
  const hasParams = functionRegex.exec(variableString)
132
158
  const remoteName = (hasParams && hasParams[2]) ? formatFunctionArgs(hasParams[2]) : 'origin'
133
- return _safeGit(() => getGitRemote(remoteName))
159
+ return gitRemote(remoteName)
134
160
  }
135
161
 
136
162
  const normalizedVar = (variable || '').toLowerCase()
@@ -152,7 +178,7 @@ function createResolver(cwd) {
152
178
  case 'repository':
153
179
  case 'reposlug':
154
180
  case 'repo-slug': {
155
- const urla = await _safeGit(() => getGitRemote())
181
+ const urla = await gitRemote()
156
182
  if (!urla) return undefined
157
183
  const parseda = GitUrlParse(urla)
158
184
  value = parseda.full_name
@@ -162,7 +188,7 @@ function createResolver(cwd) {
162
188
  case GIT_KEYS.name:
163
189
  case 'reponame': // repoName
164
190
  case 'repo-name': {
165
- const toplevel = await _safeGit(() => _execFile('git', ['rev-parse', '--show-toplevel']))
191
+ const toplevel = await gitExec(['rev-parse', '--show-toplevel'])
166
192
  if (!toplevel) return undefined
167
193
  value = path.basename(toplevel)
168
194
  break
@@ -173,7 +199,7 @@ function createResolver(cwd) {
173
199
  case 'organization':
174
200
  case 'repoowner': // repoOwner
175
201
  case 'repo-owner': {
176
- const url = await _safeGit(() => getGitRemote())
202
+ const url = await gitRemote()
177
203
  if (!url) return undefined
178
204
  const parsed = GitUrlParse(url)
179
205
  value = parsed.organization || parsed.owner
@@ -185,12 +211,12 @@ function createResolver(cwd) {
185
211
  case 'dirpath': // dirPath
186
212
  case 'dir-path':
187
213
  case 'dir_path': {
188
- const gitBasePath = await _safeGit(() => _execFile('git', ['rev-parse', '--show-toplevel']))
214
+ const gitBasePath = await gitExec(['rev-parse', '--show-toplevel'])
189
215
  if (!gitBasePath) return undefined
190
216
  if (cwd) {
191
217
  const subPath = cwd.replace(gitBasePath, '')
192
- const branch = await _safeGit(() => _execFile('git', ['rev-parse', '--abbrev-ref', 'HEAD']))
193
- const url = await _safeGit(() => getGitRemote())
218
+ const branch = await gitExec(['rev-parse', '--abbrev-ref', 'HEAD'])
219
+ const url = await gitRemote()
194
220
  if (!url) return undefined
195
221
  value = (subPath && branch) ? `${url}/tree/${branch}${subPath}` : url
196
222
  }
@@ -200,12 +226,12 @@ function createResolver(cwd) {
200
226
  case GIT_KEYS.url:
201
227
  case 'repourl': // repoUrl
202
228
  case 'repo-url':
203
- value = await _safeGit(() => getGitRemote())
229
+ value = await gitRemote()
204
230
  break
205
231
  // Current commit sha
206
232
  case 'sha':
207
233
  case 'sha1':
208
- value = await _safeGit(() => _execFile('git', ['rev-parse', '--short', 'HEAD']))
234
+ value = await gitExec(['rev-parse', '--short', 'HEAD'])
209
235
  break
210
236
  // Current commit full sha
211
237
  case GIT_KEYS.commit:
@@ -213,7 +239,7 @@ function createResolver(cwd) {
213
239
  case 'commit-sha':
214
240
  case 'commithash':
215
241
  case 'commit-hash':
216
- value = await _safeGit(() => _execFile('git', ['rev-parse', 'HEAD']))
242
+ value = await gitExec(['rev-parse', 'HEAD'])
217
243
  break
218
244
  // Branches
219
245
  case GIT_KEYS.branch:
@@ -221,7 +247,7 @@ function createResolver(cwd) {
221
247
  case 'branch-name':
222
248
  case 'currentbranch': // currentBranch
223
249
  case 'current-branch':
224
- value = await _safeGit(() => _execFile('git', ['rev-parse', '--abbrev-ref', 'HEAD']))
250
+ value = await gitExec(['rev-parse', '--abbrev-ref', 'HEAD'])
225
251
  break
226
252
  // Commit msg
227
253
  case GIT_KEYS.message:
@@ -230,26 +256,26 @@ function createResolver(cwd) {
230
256
  case 'commit-message':
231
257
  case 'commitmsg': // commitMsg
232
258
  case 'commit-msg':
233
- value = await _safeGit(() => _execFile('git', ['log', '-1', '--pretty=%B']))
259
+ value = await gitExec(['log', '-1', '--pretty=%B'])
234
260
  break
235
261
  // Git tags
236
262
  case GIT_KEYS.tag:
237
263
  case 'describe':
238
- value = await _safeGit(() => _execFile('git', ['describe', '--always']))
264
+ value = await gitExec(['describe', '--always'])
239
265
  break
240
266
  // Git tags
241
267
  case 'describeLight':
242
268
  case 'describelight':
243
269
  case 'describe-light':
244
- value = await _safeGit(() => _execFile('git', ['describe', '--always', '--tags']))
270
+ value = await gitExec(['describe', '--always', '--tags'])
245
271
  break
246
272
  // Is branch dirty
247
273
  case 'isDirty':
248
274
  case 'isdirty':
249
275
  case 'is-dirty': {
250
- const writeTree = await _safeGit(() => _execFile('git', ['write-tree']))
276
+ const writeTree = await gitExec(['write-tree'])
251
277
  if (!writeTree) return undefined
252
- const changes = await _safeGit(() => _execFile('git', ['diff-index', writeTree.trim(), '--']))
278
+ const changes = await gitExec(['diff-index', writeTree.trim(), '--'])
253
279
  if (changes === undefined) return undefined
254
280
  value = `${changes.length > 0}`
255
281
  break
@@ -1,6 +1,6 @@
1
1
  // Resolves values from CLI option flags
2
- // Matches ${opt:FLAG_NAME} syntax with optional fallback values
3
- const optRefSyntax = RegExp(/^opt:/g)
2
+ // Matches ${opt:FLAG_NAME} and ${option:FLAG_NAME} syntax with optional fallback values
3
+ const optRefSyntax = RegExp(/^(?:opt|option):/g)
4
4
 
5
5
  function getValueFromOptions(variableString, options) {
6
6
  const requestedOption = variableString.split(':')[1]
@@ -12,8 +12,9 @@ module.exports = {
12
12
  type: 'options',
13
13
  source: 'user',
14
14
  prefix: 'opt',
15
- syntax: '${opt:flagName}',
16
- description: 'Resolves CLI option flags. Examples: ${opt:stage}, ${opt:other, "fallbackValue"}',
15
+ prefixes: ['opt', 'option'],
16
+ syntax: '${option:flagName}',
17
+ description: 'Resolves CLI option flags. Examples: ${option:stage}, ${opt:stage}, ${option:other, "fallbackValue"}',
17
18
  match: optRefSyntax,
18
19
  resolver: getValueFromOptions
19
20
  }
@@ -0,0 +1,57 @@
1
+ const MARKER = '__CONFIGORAMA_FILTER_ARG__'
2
+
3
+ class ResolvedFilterArg {
4
+ constructor(value) {
5
+ this.value = value
6
+ this.__resolvedFilterArg = true
7
+ }
8
+
9
+ toString() {
10
+ return String(this.value)
11
+ }
12
+
13
+ valueOf() {
14
+ return this.value
15
+ }
16
+ }
17
+
18
+ function encodeBase64Url(value) {
19
+ return Buffer.from(value).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
20
+ }
21
+
22
+ function decodeBase64Url(value) {
23
+ let base64 = value.replace(/-/g, '+').replace(/_/g, '/')
24
+ while (base64.length % 4) base64 += '='
25
+ return Buffer.from(base64, 'base64').toString('utf8')
26
+ }
27
+
28
+ function encodeFilterArg(value) {
29
+ return `${MARKER}:${encodeBase64Url(JSON.stringify(value))}`
30
+ }
31
+
32
+ function isEncodedFilterArg(value) {
33
+ return typeof value === 'string' && value.startsWith(`${MARKER}:`)
34
+ }
35
+
36
+ function decodeFilterArg(value) {
37
+ if (!isEncodedFilterArg(value)) return value
38
+ const encoded = value.slice(MARKER.length + 1)
39
+ return new ResolvedFilterArg(JSON.parse(decodeBase64Url(encoded)))
40
+ }
41
+
42
+ function isResolvedFilterArg(value) {
43
+ return Boolean(value && value.__resolvedFilterArg)
44
+ }
45
+
46
+ function unwrapFilterArg(value) {
47
+ return isResolvedFilterArg(value) ? value.value : value
48
+ }
49
+
50
+ module.exports = {
51
+ ResolvedFilterArg,
52
+ decodeFilterArg,
53
+ encodeFilterArg,
54
+ isEncodedFilterArg,
55
+ isResolvedFilterArg,
56
+ unwrapFilterArg,
57
+ }