configorama 0.11.2 → 1.0.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.
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 +117 -15
  12. package/src/resolvers/valueFromEval.js +11 -1
  13. package/src/resolvers/valueFromFile.js +5 -0
  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,25 @@ 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
+ }
42
52
  const dotProp = require('dot-prop')
53
+
54
+ function resolveStaticFilterArg(arg, config) {
55
+ const match = String(arg).trim().match(/^\$\{(?:self:)?([^}]+)\}$/)
56
+ if (!match) return arg
57
+ if (!dotProp.has(config, match[1])) return arg
58
+ return encodeFilterArg(dotProp.get(config, match[1]))
59
+ }
60
+
43
61
  /* Utils - root */
44
62
  const {
45
63
  isArray, isString, isNumber, isObject, isDate, isRegExp, isFunction,
@@ -71,17 +89,28 @@ const { replaceAll } = require('./utils/strings/replaceAll')
71
89
  const { getTextAfterOccurrence, findNestedVariable } = require('./utils/strings/textUtils')
72
90
  const { ensureQuote, isSurroundedByQuotes, startsWithQuotedPipe } = require('./utils/strings/quoteUtils')
73
91
  const { splitOnPipe } = require('./utils/strings/splitOnPipe')
92
+ const { encodeFilterArg } = require('./utils/filters/filterArgs')
93
+ const { validateOneOf } = require('./utils/filters/oneOf')
74
94
  /* Utils - ui */
75
95
  const chalk = require('./utils/ui/chalk')
76
96
  const deepLog = require('./utils/ui/deep-log')
77
97
  const { logHeader } = require('./utils/ui/logs')
78
98
  const { runConfigWizard } = require('./utils/ui/configWizard')
99
+ const { buildConfigRequirements } = require('./utils/requirements/configRequirements')
100
+ const { redactUserInputsByRequirements } = require('./utils/redaction/setupRedaction')
79
101
  /* Display */
80
102
  const { displayNoVariablesFound, displayVariableDetails, displayUniqueVariables, displayConfigurableVariables } = require('./display')
81
103
  /* Metadata */
82
104
  const { collectVariableMetadata: collectMetadata } = require('./metadata')
83
105
  /* Utils - validation */
84
106
  const { warnIfNotFound, isValidValue } = require('./utils/validation/warnIfNotFound')
107
+ const {
108
+ assertCustomFunctionsAllowed,
109
+ assertCustomResolversAllowed,
110
+ assertSafeConfigInput,
111
+ normalizeSafetyPolicy,
112
+ } = require('./utils/security/safetyPolicy')
113
+ const { ConfigoramaError } = require('./errors')
85
114
  /* Utils - variables */
86
115
  const cleanVariable = require('./utils/variables/cleanVariable')
87
116
  const appendDeepVariable = require('./utils/variables/appendDeepVariable')
@@ -144,6 +173,8 @@ class Configorama {
144
173
  }
145
174
 
146
175
  const options = opts || {}
176
+ // Setup wizard runs when --setup flag is passed (CLI) or options.setup is true (library)
177
+ this.setupMode = SETUP_MODE || options.setup === true
147
178
  // Set opts to pass into JS file calls
148
179
  this.settings = Object.assign({}, {
149
180
  // Allow unknown ${xyz:...} syntax where xyz is not a registered resolver
@@ -168,6 +199,7 @@ class Configorama {
168
199
  // CloudFormation Fn::Sub, inline Lambda code, and CloudFront functions.
169
200
  ignorePaths: [],
170
201
  skipResolutionPaths: [],
202
+ safeMode: false,
171
203
  }, options)
172
204
 
173
205
  // Backward compat: allowUnknownVars -> allowUnknownVariableTypes
@@ -179,6 +211,13 @@ class Configorama {
179
211
  this.settings.allowUnknownVariableTypes = options.allowUnknownVariables
180
212
  }
181
213
 
214
+ this.safetyPolicy = normalizeSafetyPolicy(this.settings, {
215
+ configDir: options.configDir || (typeof fileOrObject === 'string' ? path.dirname(path.resolve(fileOrObject)) : process.cwd())
216
+ })
217
+
218
+ assertCustomResolversAllowed(options.variableSources, this.safetyPolicy)
219
+ assertCustomFunctionsAllowed(options.functions, this.safetyPolicy)
220
+
182
221
  // Merge legacy allowUnknownParams and allowUnknownFileRefs into allowUnresolvedVariables
183
222
  let unresolvedSetting = this.settings.allowUnresolvedVariables
184
223
  if (unresolvedSetting !== true) {
@@ -197,6 +236,9 @@ class Configorama {
197
236
  // Paths whose current value is a literal with no variables — skip rebuilding
198
237
  // their leaf object on every subsequent populateObjectImpl iteration.
199
238
  this._resolvedPaths = new Set()
239
+ // Ignore-path decisions are constant for a run (patterns are fixed per
240
+ // instance), so cache per path to skip repeated glob matching.
241
+ this._ignorePathCache = new Map()
200
242
  // Cache raw file contents per absolute path so repeated ${file:...} refs
201
243
  // to the same file (e.g., merged twice into different keys) don't reread.
202
244
  this._fileContentCache = new Map()
@@ -208,7 +250,7 @@ class Configorama {
208
250
  this._needsRawClone = !!(
209
251
  this.settings.returnMetadata ||
210
252
  this.settings.returnPreResolvedVariableDetails ||
211
- VERBOSE || SETUP_MODE || showFound
253
+ VERBOSE || this.setupMode || showFound
212
254
  )
213
255
 
214
256
  this.foundVariables = []
@@ -272,6 +314,7 @@ class Configorama {
272
314
  // Set configPath for file references
273
315
  this.configPath = options.configDir || process.cwd()
274
316
  } else if (typeof fileOrObject === 'string') {
317
+ assertSafeConfigInput(fileOrObject, this.safetyPolicy)
275
318
  // read and parse file
276
319
  const fileContents = fs.readFileSync(fileOrObject, 'utf-8')
277
320
  const fileDirectory = path.dirname(path.resolve(fileOrObject))
@@ -502,9 +545,11 @@ class Configorama {
502
545
  // Build prefix lookup map for O(1) type detection (perf optimization)
503
546
  this._resolverByPrefix = new Map()
504
547
  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)
548
+ const prefixes = r.prefixes || [r.prefix || r.type]
549
+ for (const prefix of prefixes) {
550
+ if (prefix && r.match instanceof RegExp && !r.internal) {
551
+ this._resolverByPrefix.set(prefix + ':', r)
552
+ }
508
553
  }
509
554
  }
510
555
 
@@ -575,6 +620,37 @@ class Configorama {
575
620
  if (value === undefined || value === null || value === 'null') return ''
576
621
  return String(value)
577
622
  },
623
+ Array: (value) => {
624
+ if (Array.isArray(value)) return value
625
+ if (typeof value !== 'string') {
626
+ throw new Error(`Configorama Error: Expected Array, got "${value}"`)
627
+ }
628
+ const trimmed = value.trim()
629
+ if (!trimmed) return []
630
+ try {
631
+ const parsed = JSON5.parse(trimmed)
632
+ if (Array.isArray(parsed)) return parsed
633
+ throw new Error('not-array')
634
+ } catch (error) {
635
+ if (trimmed.includes(',')) {
636
+ return trimmed.split(',').map(item => item.trim()).filter(Boolean)
637
+ }
638
+ throw new Error(`Configorama Error: Expected Array, got "${value}"`)
639
+ }
640
+ },
641
+ Object: (value) => {
642
+ if (value && typeof value === 'object' && !Array.isArray(value)) return value
643
+ if (typeof value !== 'string') {
644
+ throw new Error(`Configorama Error: Expected Object, got "${value}"`)
645
+ }
646
+ try {
647
+ const parsed = JSON5.parse(value)
648
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed
649
+ } catch (error) {
650
+ // Fall through to consistent error below.
651
+ }
652
+ throw new Error(`Configorama Error: Expected Object, got "${value}"`)
653
+ },
578
654
  Json: (value) => {
579
655
  try {
580
656
  return typeof value === 'string' ? JSON.parse(value) : value
@@ -588,6 +664,7 @@ class Configorama {
588
664
  // The helpText argument is extracted during metadata collection for the wizard
589
665
  return value
590
666
  },
667
+ oneOf: validateOneOf,
591
668
  }
592
669
 
593
670
  // Apply user defined filters
@@ -746,7 +823,7 @@ class Configorama {
746
823
  dynamicArgs: this.settings.dynamicArgs
747
824
  })
748
825
  this.configFileContents = ''
749
- if (VERBOSE || showFoundVariables || this.settings.returnPreResolvedVariableDetails || SETUP_MODE) {
826
+ if (VERBOSE || showFoundVariables || this.settings.returnPreResolvedVariableDetails || this.setupMode) {
750
827
  this.configFileContents = fs.readFileSync(this.configFilePath, 'utf8')
751
828
  }
752
829
  /*
@@ -789,7 +866,7 @@ class Configorama {
789
866
  const variableSyntax = this.variableSyntax
790
867
  const variablesKnownTypes = this.variablesKnownTypes
791
868
 
792
- if (VERBOSE || showFoundVariables || this.settings.returnPreResolvedVariableDetails || SETUP_MODE) {
869
+ if (VERBOSE || showFoundVariables || this.settings.returnPreResolvedVariableDetails || this.setupMode) {
793
870
  const metadata = this.collectVariableMetadata()
794
871
 
795
872
  const enrich = await enrichMetadata(
@@ -844,15 +921,18 @@ class Configorama {
844
921
  displayConfigurableVariables(displayParams)
845
922
 
846
923
 
847
- // WALK through CLI prompt if --setup flag is set
848
- if (SETUP_MODE) {
924
+ // WALK through CLI prompt when setup mode is active
925
+ if (this.setupMode) {
849
926
  logHeader('Setup Mode')
850
927
  // deepLog('enrich', enrich)
851
928
  const userInputs = await runConfigWizard(enrich, this.originalConfig, this.configFilePath)
929
+ const setupRequirements = buildConfigRequirements(enrich)
930
+ this.setupRequirements = setupRequirements
931
+ const displayInputs = redactUserInputsByRequirements(userInputs, setupRequirements)
852
932
 
853
933
  logHeader('User Inputs Summary')
854
934
  console.log()
855
- console.log(JSON.stringify(userInputs, null, 2))
935
+ console.log(JSON.stringify(displayInputs, null, 2))
856
936
 
857
937
  // TODO set values
858
938
 
@@ -902,6 +982,12 @@ class Configorama {
902
982
 
903
983
  const useDotEnv = this.originalConfig.useDotenv || this.originalConfig.useDotEnv
904
984
  if ((useDotEnv && useDotEnv === true) || this.settings.useDotEnvFiles) {
985
+ if (this.safetyPolicy.blockDotEnv) {
986
+ throw new ConfigoramaError('blocked_by_safe_mode', 'Dotenv loading is blocked in safe mode', {
987
+ surface: 'dotenv',
988
+ configPath: this.configFilePath,
989
+ })
990
+ }
905
991
  let providerStage
906
992
  /* has hardcoded stage */
907
993
  if (
@@ -1127,7 +1213,14 @@ class Configorama {
1127
1213
  // ## PROPERTY HANDLING ##
1128
1214
  // #######################
1129
1215
  isIgnorePath(pathValue) {
1130
- return shouldIgnorePath(pathValue, this.ignorePathPatterns)
1216
+ if (!this.ignorePathPatterns.length) return false
1217
+ // NUL-join so distinct path arrays can never collide on a shared key.
1218
+ const key = isArray(pathValue) ? pathValue.join('\x00') : String(pathValue)
1219
+ const cached = this._ignorePathCache.get(key)
1220
+ if (cached !== undefined) return cached
1221
+ const result = shouldIgnorePath(pathValue, this.ignorePathPatterns)
1222
+ this._ignorePathCache.set(key, result)
1223
+ return result
1131
1224
  }
1132
1225
  // True when the value has a configorama-typed token that resolves even inside an
1133
1226
  // ignore-path (file/text/env/opt/cron/git/custom) — i.e. not just self/CFN refs.
@@ -1808,6 +1901,9 @@ class Configorama {
1808
1901
  valueToPopulate = `"${valueToPopulate}"`
1809
1902
  }
1810
1903
  }
1904
+ if (isNestedFilterArgument(property, currentMatchedString)) {
1905
+ valueToPopulate = encodeFilterArg(valueToPopulate)
1906
+ }
1811
1907
  property = replaceAll(currentMatchedString, valueToPopulate, property)
1812
1908
  // console.log('property replaceAll', property)
1813
1909
 
@@ -1818,7 +1914,10 @@ class Configorama {
1818
1914
  // partial replacement, number
1819
1915
  } else if (isNumber(valueToPopulate)) {
1820
1916
  if (DEBUG_TYPE) console.log('DEBUG_TYPE isNumber')
1821
- property = replaceAll(matchedString, String(valueToPopulate), property)
1917
+ const replacementValue = isNestedFilterArgument(property, matchedString)
1918
+ ? encodeFilterArg(valueToPopulate)
1919
+ : String(valueToPopulate)
1920
+ property = replaceAll(matchedString, replacementValue, property)
1822
1921
  // TODO This was temp fix for array value mismatch from filters. This fixes filterInner: ${commas | split(${self:inner}, 2) }
1823
1922
  // } else if (isArray(valueToPopulate) && valueToPopulate.length === 1) {
1824
1923
  // property = replaceAll(matchedString, String(valueToPopulate[0]), property)
@@ -1841,7 +1940,9 @@ class Configorama {
1841
1940
  )
1842
1941
  // Only encode for file() or text() references where JSON braces break regex matching
1843
1942
  const isFileOrTextRef = /\bfile\s*\(|\btext\s*\(/.test(property)
1844
- if (isNestedInVariable && isFileOrTextRef) {
1943
+ if (isNestedFilterArgument(property, matchedString)) {
1944
+ property = replaceAll(matchedString, encodeFilterArg(valueToPopulate), property)
1945
+ } else if (isNestedInVariable && isFileOrTextRef) {
1845
1946
  // Encode object as base64 to avoid breaking variable syntax with nested braces
1846
1947
  const encodedObj = encodeJsonForVariable(valueToPopulate)
1847
1948
  property = replaceAll(matchedString, encodedObj, property)
@@ -2069,7 +2170,7 @@ Missing Value ${missingValue} - ${matchedString}
2069
2170
  const rawArgs = funcMatch[2]
2070
2171
  if (rawArgs) {
2071
2172
  const splitter = splitCsv(rawArgs, ', ')
2072
- filterArgs = formatFunctionArgs(splitter)
2173
+ filterArgs = formatFunctionArgs(splitter.map(arg => resolveStaticFilterArg(arg, this.config)))
2073
2174
  }
2074
2175
  }
2075
2176
 
@@ -2515,7 +2616,7 @@ Missing Value ${missingValue} - ${matchedString}
2515
2616
  // Parse arguments using the same logic as functions
2516
2617
  if (rawArgs) {
2517
2618
  const splitter = splitCsv(rawArgs, ', ')
2518
- filterArgs = formatFunctionArgs(splitter)
2619
+ filterArgs = formatFunctionArgs(splitter.map(arg => resolveStaticFilterArg(arg, this.config)))
2519
2620
  }
2520
2621
  }
2521
2622
 
@@ -2787,7 +2888,8 @@ Missing Value ${missingValue} - ${matchedString}
2787
2888
  textRefSyntax: textRefSyntax,
2788
2889
  varPrefix: this.varPrefix,
2789
2890
  varSuffix: this.varSuffix,
2790
- fileContentCache: this._fileContentCache
2891
+ fileContentCache: this._fileContentCache,
2892
+ safetyPolicy: this.safetyPolicy
2791
2893
  }
2792
2894
  return getValueFromFileResolver(ctx, variableString, options)
2793
2895
  }
@@ -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')
@@ -109,6 +110,7 @@ function parseFileContents(content, filePath) {
109
110
  * @param {string} ctx.varPrefix - Variable prefix (e.g., '${')
110
111
  * @param {string} ctx.varSuffix - Variable suffix (e.g., '}')
111
112
  * @param {Map<string, string>} [ctx.fileContentCache] - Optional per-instance read cache keyed by absolute file path
113
+ * @param {object} [ctx.safetyPolicy] - Optional safe-mode policy for executable and root checks
112
114
  * @param {string} variableString - The variable string to resolve
113
115
  * @param {object} options - Resolution options
114
116
  * @returns {Promise<any>}
@@ -187,6 +189,9 @@ async function getValueFromFile(ctx, variableString, options) {
187
189
  }
188
190
 
189
191
  const exists = fs.existsSync(fullFilePath)
192
+ if (ctx.safetyPolicy) {
193
+ checkFileAccess(fullFilePath, ctx.safetyPolicy, { variableString })
194
+ }
190
195
 
191
196
  const fileRefEntry = {
192
197
  filePath: fullFilePath,
@@ -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
+ }
@@ -0,0 +1,77 @@
1
+ const { splitCsv } = require('../strings/splitCsv')
2
+ const { trimSurroundingQuotes } = require('../strings/quoteUtils')
3
+ const { isResolvedFilterArg, unwrapFilterArg } = require('./filterArgs')
4
+
5
+ function isRuntimeContextArg(value) {
6
+ return typeof value === 'string' && value.startsWith('from ')
7
+ }
8
+
9
+ function stripRuntimeContext(args) {
10
+ if (!args.length) return args
11
+ const last = args[args.length - 1]
12
+ return isRuntimeContextArg(last) ? args.slice(0, -1) : args
13
+ }
14
+
15
+ function hasDynamicArgument(value) {
16
+ return typeof value === 'string' && value.includes('${')
17
+ }
18
+
19
+ function parseOneOfLiteral(rawValue) {
20
+ const trimmed = String(rawValue).trim()
21
+ const unquoted = trimSurroundingQuotes(trimmed, false)
22
+ if (unquoted !== trimmed) return unquoted
23
+ if (/^-?(?:\d+|\d*\.\d+)(?:e[+-]?\d+)?$/i.test(trimmed)) return Number(trimmed)
24
+ return trimmed
25
+ }
26
+
27
+ function parseOneOfFilter(filter) {
28
+ if (typeof filter !== 'string') return null
29
+ const match = filter.match(/^oneOf\(([\s\S]*)\)$/)
30
+ if (!match) return null
31
+
32
+ const args = splitCsv(match[1], ',', { protectVariables: true })
33
+ .filter(arg => arg !== '')
34
+
35
+ if (args.some(hasDynamicArgument)) {
36
+ return {
37
+ dynamic: true,
38
+ allowedValues: null,
39
+ }
40
+ }
41
+
42
+ return {
43
+ dynamic: false,
44
+ allowedValues: args.map(arg => String(parseOneOfLiteral(arg))),
45
+ }
46
+ }
47
+
48
+ function validateOneOf(value, ...rawArgs) {
49
+ const args = stripRuntimeContext(rawArgs)
50
+ if (!args.length) {
51
+ throw new Error('Configorama Error: oneOf() requires at least one allowed value')
52
+ }
53
+ if (args.some(hasDynamicArgument)) {
54
+ throw new Error('Configorama Error: oneOf(${...}) dynamic arguments are not supported yet')
55
+ }
56
+
57
+ const hasResolvedFilterArg = args.some(isResolvedFilterArg)
58
+ const unwrappedArgs = args.map(unwrapFilterArg)
59
+ if (hasResolvedFilterArg) {
60
+ if (unwrappedArgs.length !== 1 || !Array.isArray(unwrappedArgs[0])) {
61
+ throw new Error('Configorama Error: oneOf(${...}) must resolve to an array')
62
+ }
63
+ }
64
+
65
+ const allowed = hasResolvedFilterArg ? unwrappedArgs[0] : unwrappedArgs
66
+ const allowedValues = allowed.map(String)
67
+ if (!allowedValues.some(allowed => allowed === String(value))) {
68
+ throw new Error(`Configorama Error: Value "${value}" is not oneOf(${allowedValues.join(', ')})`)
69
+ }
70
+ return value
71
+ }
72
+
73
+ module.exports = {
74
+ parseOneOfFilter,
75
+ parseOneOfLiteral,
76
+ validateOneOf,
77
+ }