configorama 0.6.12 → 0.6.13

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 (66) hide show
  1. package/README.md +196 -24
  2. package/cli.js +3 -3
  3. package/package.json +1 -1
  4. package/src/index.js +22 -32
  5. package/src/main.js +690 -778
  6. package/src/parsers/yaml.js +3 -47
  7. package/src/resolvers/valueFromCron.js +3 -1
  8. package/src/resolvers/valueFromEnv.js +1 -0
  9. package/src/resolvers/valueFromEval.js +1 -0
  10. package/src/resolvers/valueFromFile.js +394 -0
  11. package/src/resolvers/valueFromGit.js +3 -2
  12. package/src/resolvers/valueFromOptions.js +1 -0
  13. package/src/resolvers/valueFromString.js +2 -1
  14. package/src/sync.js +12 -5
  15. package/src/utils/parsing/arrayToJsonPath.test.js +56 -0
  16. package/src/utils/{enrichMetadata.js → parsing/enrichMetadata.js} +177 -15
  17. package/src/utils/{parse.js → parsing/parse.js} +13 -13
  18. package/src/utils/parsing/preProcess.js +165 -0
  19. package/src/utils/{filePathUtils.js → paths/filePathUtils.js} +3 -2
  20. package/src/utils/paths/findLineForKey.js +47 -0
  21. package/src/utils/paths/findLineForKey.test.js +126 -0
  22. package/src/utils/{getFullFilePath.js → paths/getFullFilePath.js} +22 -26
  23. package/src/utils/{resolveAlias.js → paths/resolveAlias.js} +1 -1
  24. package/src/utils/regex/index.js +23 -1
  25. package/src/utils/resolution/preResolveVariable.js +260 -0
  26. package/src/utils/resolution/preResolveVariable.test.js +98 -0
  27. package/src/utils/strings/bracketMatcher.js +86 -0
  28. package/src/utils/strings/bracketMatcher.test.js +135 -0
  29. package/src/utils/{formatFunctionArgs.js → strings/formatFunctionArgs.js} +3 -2
  30. package/src/utils/strings/formatFunctionArgs.test.js +77 -0
  31. package/src/utils/strings/quoteUtils.js +89 -0
  32. package/src/utils/strings/quoteUtils.test.js +217 -0
  33. package/src/utils/strings/replaceAll.test.js +82 -0
  34. package/src/utils/{splitByComma.js → strings/splitByComma.js} +1 -1
  35. package/src/utils/strings/splitCsv.js +38 -0
  36. package/src/utils/strings/splitCsv.test.js +96 -0
  37. package/src/utils/strings/textUtils.test.js +86 -0
  38. package/src/utils/{configWizard.js → ui/configWizard.js} +177 -38
  39. package/src/utils/{createEditorLink.js → ui/createEditorLink.js} +11 -2
  40. package/src/utils/{logs.js → ui/logs.js} +3 -3
  41. package/src/utils/validation/isValidValue.test.js +64 -0
  42. package/src/utils/validation/warnIfNotFound.js +52 -0
  43. package/src/utils/variables/appendDeepVariable.test.js +41 -0
  44. package/src/utils/{cleanVariable.js → variables/cleanVariable.js} +5 -26
  45. package/src/utils/{find-nested-variables.js → variables/findNestedVariables.js} +2 -2
  46. package/src/utils/{find-nested-variables.test.js → variables/findNestedVariables.test.js} +5 -5
  47. package/src/utils/variables/getVariableType.test.js +109 -0
  48. package/src/utils/variables/variableUtils.test.js +117 -0
  49. package/src/utils/isValidValue.js +0 -8
  50. package/src/utils/splitCsv.js +0 -29
  51. package/src/utils/trimSurroundingQuotes.js +0 -5
  52. /package/src/utils/{arrayToJsonPath.js → parsing/arrayToJsonPath.js} +0 -0
  53. /package/src/utils/{cloudformationSchema.js → parsing/cloudformationSchema.js} +0 -0
  54. /package/src/utils/{mergeByKeys.js → parsing/mergeByKeys.js} +0 -0
  55. /package/src/utils/{filePathUtils.test.js → paths/filePathUtils.test.js} +0 -0
  56. /package/src/utils/{find-project-root.js → paths/findProjectRoot.js} +0 -0
  57. /package/src/utils/{resolveAlias.test.js → paths/resolveAlias.test.js} +0 -0
  58. /package/src/utils/{replaceAll.js → strings/replaceAll.js} +0 -0
  59. /package/src/utils/{splitByComma.test.js → strings/splitByComma.test.js} +0 -0
  60. /package/src/utils/{textUtils.js → strings/textUtils.js} +0 -0
  61. /package/src/utils/{chalk.js → ui/chalk.js} +0 -0
  62. /package/src/utils/{deep-log.js → ui/deep-log.js} +0 -0
  63. /package/src/utils/{appendDeepVariable.js → variables/appendDeepVariable.js} +0 -0
  64. /package/src/utils/{cleanVariable.test.js → variables/cleanVariable.test.js} +0 -0
  65. /package/src/utils/{getVariableType.js → variables/getVariableType.js} +0 -0
  66. /package/src/utils/{variableUtils.js → variables/variableUtils.js} +0 -0
@@ -1,7 +1,8 @@
1
1
  const dotProp = require('dot-prop')
2
2
  const fs = require('fs')
3
3
  const path = require('path')
4
- const { normalizePath, extractFilePath, normalizeFileVariable, resolveInnerVariables } = require('./filePathUtils')
4
+ const { normalizePath, extractFilePath, normalizeFileVariable, resolveInnerVariables } = require('../paths/filePathUtils')
5
+ const { preResolveString, preResolveSingle } = require('../resolution/preResolveVariable')
5
6
 
6
7
  // Type filters that indicate expected value types
7
8
  const TYPE_FILTERS = ['Boolean', 'String', 'Number', 'Array', 'Object', 'Json']
@@ -87,6 +88,18 @@ function createOccurrence(instance, varMatch, options = {}) {
87
88
  return occurrence
88
89
  }
89
90
 
91
+ /**
92
+ * Get source type for a variable type
93
+ * @param {string} variableType - The variable type (e.g., 'env', 'opt', 'git')
94
+ * @param {Array} variableTypes - Array of variable type definitions
95
+ * @returns {string|undefined} The source type ('user', 'config', 'readonly', 'remote')
96
+ */
97
+ function getSourceForType(variableType, variableTypes) {
98
+ if (!variableTypes || !variableType) return undefined
99
+ const typeDef = variableTypes.find(vt => vt.type === variableType)
100
+ return typeDef?.source
101
+ }
102
+
90
103
  /**
91
104
  * Enriches variable metadata with resolution tracking data.
92
105
  * @param {object} metadata - The metadata object from collectVariableMetadata.
@@ -96,21 +109,31 @@ function createOccurrence(instance, varMatch, options = {}) {
96
109
  * @param {object} originalConfig - The original config object (before resolution) for self/dot.prop lookups.
97
110
  * @param {string} configPath - The path to the config file.
98
111
  * @param {Array} filterNames - Array of known filter names.
99
- * @returns {object} Enriched metadata with resolution details and a complete file reference list.
112
+ * @param {object} [resolvedConfig] - The resolved config object (optional, for post-resolution enrichment).
113
+ * @param {object} [options] - CLI options object for opt: resolution.
114
+ * @param {Array} [variableTypes] - Array of variable type definitions with source info.
115
+ * @returns {Promise<object>} Enriched metadata with resolution details and a complete file reference list.
100
116
  */
101
- function enrichMetadata(
117
+ async function enrichMetadata(
102
118
  metadata,
103
119
  resolutionTracking,
104
120
  variableSyntax,
105
121
  fileRefsFound = [],
106
122
  originalConfig = {},
107
123
  configPath,
108
- filterNames = []
124
+ filterNames = [],
125
+ resolvedConfig,
126
+ options = {},
127
+ variableTypes = []
109
128
  ) {
110
129
  if (!resolutionTracking) {
111
130
  return metadata
112
131
  }
113
132
 
133
+ // Create resolve context early for resolving descriptions
134
+ const configDir = configPath ? path.dirname(configPath) : undefined
135
+ const resolveContext = { config: originalConfig, variableSyntax, configDir, options }
136
+
114
137
  const varKeys = Object.keys(metadata.variables)
115
138
 
116
139
  for (const key of varKeys) {
@@ -124,6 +147,11 @@ function enrichMetadata(
124
147
  // For each resolveDetail, find the matching resolution history entry
125
148
  for (let i = 0; i < varData.resolveDetails.length; i++) {
126
149
  const detail = varData.resolveDetails[i]
150
+ // Add source type for this variable type
151
+ const sourceType = getSourceForType(detail.variableType, variableTypes)
152
+ if (sourceType) {
153
+ detail.variableSourceType = sourceType
154
+ }
127
155
  const isOutermost = i === varData.resolveDetails.length - 1
128
156
 
129
157
  if (isOutermost && trackingData.resolutionHistory.length > 0) {
@@ -167,7 +195,8 @@ function enrichMetadata(
167
195
  varData.type = instanceType
168
196
  }
169
197
  if (instanceDescription) {
170
- varData.description = instanceDescription
198
+ // Resolve any variables in the description (e.g., ${allowedValues} -> actual values)
199
+ varData.description = await preResolveString(instanceDescription, resolveContext)
171
200
  }
172
201
  }
173
202
  }
@@ -317,11 +346,32 @@ function enrichMetadata(
317
346
  for (const key of varKeys) {
318
347
  const varInstances = metadata.variables[key]
319
348
  const firstInstance = varInstances[0]
320
- const lastResolveDetail = firstInstance.resolveDetails[firstInstance.resolveDetails.length - 1]
349
+
350
+ // Extract variable name from key (e.g. "${self:service}" -> "self:service")
351
+ const keyVarName = key.slice(2, -1).split(',')[0].trim()
352
+
353
+ // Find the resolveDetail that matches THIS key's variable (not just outermost)
354
+ let matchingDetail = null
355
+ for (const instance of varInstances) {
356
+ if (instance.resolveDetails && instance.resolveDetails.length > 0) {
357
+ const found = instance.resolveDetails.find((detail) => {
358
+ const detailVar = detail.valueBeforeFallback || detail.variable
359
+ return detailVar === keyVarName
360
+ })
361
+ if (found) {
362
+ matchingDetail = found
363
+ break
364
+ }
365
+ }
366
+ }
367
+ // Fallback to last resolveDetail if no match found
368
+ if (!matchingDetail) {
369
+ matchingDetail = firstInstance.resolveDetails[firstInstance.resolveDetails.length - 1]
370
+ }
321
371
 
322
372
  // Get the base variable name without fallback
323
373
  // Use valueBeforeFallback if present, otherwise use the variable string
324
- let baseVar = lastResolveDetail.valueBeforeFallback || lastResolveDetail.variable
374
+ let baseVar = matchingDetail.valueBeforeFallback || matchingDetail.variable
325
375
 
326
376
  // Strip filters from baseVar using known filter names
327
377
  // e.g., "opt:stage | toUpperCase | help(...)" -> "opt:stage"
@@ -344,7 +394,8 @@ function enrichMetadata(
344
394
  if (!uniqueVariablesMap.has(baseVar)) {
345
395
  uniqueVariablesMap.set(baseVar, {
346
396
  variable: baseVar,
347
- variableType: lastResolveDetail.variableType,
397
+ variableType: matchingDetail.variableType,
398
+ variableSourceType: getSourceForType(matchingDetail.variableType, variableTypes),
348
399
  occurrences: [],
349
400
  innerVariables: [],
350
401
  })
@@ -365,9 +416,25 @@ function enrichMetadata(
365
416
  // The outermost variable is the last one in resolveDetails
366
417
  const outermostDetail = instance.resolveDetails[instance.resolveDetails.length - 1]
367
418
 
419
+ // Find position of first | (filter separator) in the outermost variable's original string
420
+ // Variables after this position are filter-internal (e.g., inside help('...${var}...'))
421
+ const originalStr = outermostDetail.originalStringValue || ''
422
+ const outerVarStart = outermostDetail.start || 0
423
+ // Find first | that's inside the outermost variable (after its opening ${)
424
+ const filterSeparatorPos = originalStr.indexOf('|', outerVarStart + 2)
425
+
368
426
  for (let i = 0; i < instance.resolveDetails.length - 1; i++) {
369
427
  const detail = instance.resolveDetails[i]
370
428
 
429
+ // Check if this variable is inside filter arguments (after the first |)
430
+ // These are meta-variables used to build filter values, not user-configurable variables
431
+ const isInsideFilter = filterSeparatorPos !== -1 && detail.start > filterSeparatorPos
432
+ if (isInsideFilter) {
433
+ // Mark this detail as filter-internal and skip adding to uniqueVariables
434
+ detail.isFilterInnerVariable = true
435
+ continue
436
+ }
437
+
371
438
  // Check if this variable is actually INSIDE the outermost variable's boundaries
372
439
  // A variable is "inner" only if it's contained within the parent's start/end range
373
440
  const isInnerVariable = detail.start >= outermostDetail.start && detail.end <= outermostDetail.end
@@ -385,6 +452,7 @@ function enrichMetadata(
385
452
  uniqueVariablesMap.set(normalizedSiblingVar, {
386
453
  variable: normalizedSiblingVar,
387
454
  variableType: detail.variableType,
455
+ variableSourceType: getSourceForType(detail.variableType, variableTypes),
388
456
  occurrences: [],
389
457
  innerVariables: [],
390
458
  })
@@ -393,11 +461,29 @@ function enrichMetadata(
393
461
  const siblingEntry = uniqueVariablesMap.get(normalizedSiblingVar)
394
462
 
395
463
  // Add occurrence for this sibling variable
464
+ // Check if this self-reference resolves to a value in originalConfig
465
+ let siblingDefaultValue = detail.hasFallback ? (detail.fallbackValues?.[0]?.stringValue || detail.fallbackValues?.[0]?.variable) : undefined
466
+ let siblingDefaultValueSrc
467
+ let siblingIsRequired = !detail.hasFallback
468
+
469
+ if (detail.variableType === 'self' || detail.variableType === 'dot.prop') {
470
+ const varPath = (detail.valueBeforeFallback || detail.variable).replace('self:', '')
471
+ const resolvedValue = dotProp.get(originalConfig, varPath)
472
+ if (resolvedValue !== undefined) {
473
+ siblingDefaultValue = resolvedValue
474
+ siblingDefaultValueSrc = varPath
475
+ siblingIsRequired = false
476
+ }
477
+ }
478
+
396
479
  const siblingOccurrence = createOccurrence(instance, detail.varMatch, {
397
- isRequired: !detail.hasFallback,
480
+ isRequired: siblingIsRequired,
398
481
  hasFallback: !!detail.hasFallback,
399
- defaultValue: detail.hasFallback ? (detail.fallbackValues?.[0]?.stringValue || detail.fallbackValues?.[0]?.variable) : undefined,
482
+ defaultValue: siblingDefaultValue,
400
483
  })
484
+ if (siblingDefaultValueSrc) {
485
+ siblingOccurrence.defaultValueSrc = siblingDefaultValueSrc
486
+ }
401
487
 
402
488
  // Check if this exact occurrence already exists
403
489
  const occurrenceExists = siblingEntry.occurrences.some(occ =>
@@ -547,7 +633,7 @@ function enrichMetadata(
547
633
  }
548
634
 
549
635
  // Aggregate types and descriptions for each uniqueVariable
550
- for (const [, entry] of uniqueVariablesMap) {
636
+ for (const [varKey, entry] of uniqueVariablesMap) {
551
637
  // Collect unique types from occurrences
552
638
  const types = entry.occurrences
553
639
  .map(occ => occ.type)
@@ -556,18 +642,94 @@ function enrichMetadata(
556
642
  entry.types = types
557
643
  }
558
644
 
559
- // Collect unique descriptions from occurrences
560
- const descriptions = entry.occurrences
645
+ // Collect unique descriptions from occurrences and resolve any variables
646
+ const rawDescriptions = entry.occurrences
561
647
  .map(occ => occ.description)
562
648
  .filter((d, i, a) => d && a.indexOf(d) === i)
563
- if (descriptions.length > 0) {
564
- entry.descriptions = descriptions
649
+
650
+ if (rawDescriptions.length > 0) {
651
+ // Build map from raw -> resolved description
652
+ const descriptionMap = new Map()
653
+ await Promise.all(
654
+ rawDescriptions.map(async desc => {
655
+ // Resolve any variables in the description
656
+ const resolved = await preResolveString(desc, resolveContext)
657
+ descriptionMap.set(desc, resolved)
658
+ })
659
+ )
660
+
661
+ // Update each occurrence's description with resolved version
662
+ for (const occ of entry.occurrences) {
663
+ if (occ.description && descriptionMap.has(occ.description)) {
664
+ occ.description = descriptionMap.get(occ.description)
665
+ }
666
+ }
667
+
668
+ entry.descriptions = Array.from(descriptionMap.values())
669
+ }
670
+
671
+ // Try to resolve defaultValue for variables that can be pre-resolved
672
+ // This applies to git:, env:, self: variables without existing defaults
673
+ const firstOcc = entry.occurrences[0]
674
+ if (firstOcc && firstOcc.isRequired && !firstOcc.defaultValue) {
675
+ const resolvableTypes = ['git', 'env', 'self', 'dot.prop']
676
+ if (resolvableTypes.includes(entry.variableType)) {
677
+ try {
678
+ const resolved = await preResolveSingle(entry.variable, resolveContext)
679
+ if (resolved !== undefined) {
680
+ const formattedValue = Array.isArray(resolved)
681
+ ? resolved.join(', ')
682
+ : (typeof resolved === 'object' ? JSON.stringify(resolved) : String(resolved))
683
+
684
+ // Update occurrences and entry
685
+ entry.occurrences.forEach(occ => {
686
+ occ.defaultValue = formattedValue
687
+ occ.isRequired = false
688
+ })
689
+ entry.resolvedValue = formattedValue
690
+ }
691
+ } catch (e) {
692
+ // Couldn't resolve, leave as required
693
+ }
694
+ }
565
695
  }
566
696
  }
567
697
 
568
698
  // Convert map to object for metadata
569
699
  metadata.uniqueVariables = Object.fromEntries(uniqueVariablesMap)
570
700
 
701
+ // Add resolvedPropertyValue to resolutionTracking if resolvedConfig provided
702
+ if (resolvedConfig) {
703
+ metadata.resolutionHistory = {}
704
+ for (const pathKey in resolutionTracking) {
705
+ const tracking = resolutionTracking[pathKey]
706
+ const keys = pathKey.split('.')
707
+ let resolvedValue = resolvedConfig
708
+
709
+ for (const key of keys) {
710
+ if (resolvedValue && typeof resolvedValue === 'object') {
711
+ resolvedValue = resolvedValue[key]
712
+ } else {
713
+ resolvedValue = undefined
714
+ break
715
+ }
716
+ }
717
+
718
+ // Unescape any __CONFIGVAR:...__ placeholders in tracking data
719
+ // (resolutionTracking uses escaped config during resolution)
720
+ const cleanTracking = JSON.parse(
721
+ JSON.stringify(tracking).replace(/__CONFIGVAR:([A-Za-z0-9+/=]+)__/g, (_, encoded) => {
722
+ return Buffer.from(encoded, 'base64').toString('utf8')
723
+ })
724
+ )
725
+
726
+ metadata.resolutionHistory[pathKey] = {
727
+ ...cleanTracking,
728
+ resolvedPropertyValue: resolvedValue
729
+ }
730
+ }
731
+ }
732
+
571
733
  return metadata
572
734
  }
573
735
 
@@ -1,9 +1,9 @@
1
- const YAML = require('../parsers/yaml')
2
- const TOML = require('../parsers/toml')
3
- const INI = require('../parsers/ini')
4
- const JSON5 = require('../parsers/json5')
5
- const { executeTypeScriptFileSync } = require('../parsers/typescript')
6
- const { executeESMFileSync } = require('../parsers/esm')
1
+ const YAML = require('../../parsers/yaml')
2
+ const TOML = require('../../parsers/toml')
3
+ const INI = require('../../parsers/ini')
4
+ const JSON5 = require('../../parsers/json5')
5
+ const { executeTypeScriptFileSync } = require('../../parsers/typescript')
6
+ const { executeESMFileSync } = require('../../parsers/esm')
7
7
  const cloudFormationSchema = require('./cloudformationSchema')
8
8
 
9
9
  /**
@@ -18,7 +18,7 @@ const cloudFormationSchema = require('./cloudformationSchema')
18
18
  function parseFileContents(fileContents, fileType, filePath, varRegex, opts = {}) {
19
19
  let configObject
20
20
 
21
- if (fileType.match(/\.(yml|yaml)/)) {
21
+ if (fileType.match(/\.(yml|yaml)/i)) {
22
22
  try {
23
23
  const ymlText = YAML.preProcess(fileContents, varRegex)
24
24
  configObject = YAML.parse(ymlText)
@@ -36,14 +36,14 @@ function parseFileContents(fileContents, fileType, filePath, varRegex, opts = {}
36
36
  configObject = result.data
37
37
  }
38
38
  }
39
- } else if (fileType.match(/\.(toml|tml)/)) {
39
+ } else if (fileType.match(/\.(toml|tml)/i)) {
40
40
  configObject = TOML.parse(fileContents)
41
- } else if (fileType.match(/\.(ini)/)) {
41
+ } else if (fileType.match(/\.(ini)/i)) {
42
42
  configObject = INI.parse(fileContents)
43
- } else if (fileType.match(/\.(json|json5)/)) {
43
+ } else if (fileType.match(/\.(json|json5)/i)) {
44
44
  configObject = JSON5.parse(fileContents)
45
45
  // TODO detect js syntax and use appropriate parser
46
- } else if (fileType.match(/\.(js|cjs)/)) {
46
+ } else if (fileType.match(/\.(js|cjs)/i)) {
47
47
  let jsFile
48
48
  try {
49
49
  jsFile = require(filePath)
@@ -60,7 +60,7 @@ function parseFileContents(fileContents, fileType, filePath, varRegex, opts = {}
60
60
  } catch (err) {
61
61
  throw new Error(err)
62
62
  }
63
- } else if (fileType.match(/\.(ts|tsx)/)) {
63
+ } else if (fileType.match(/\.(ts|tsx|mts|cts)/i)) {
64
64
  try {
65
65
  let jsArgs = opts.dynamicArgs || {}
66
66
  if (jsArgs && typeof jsArgs === 'function') {
@@ -76,7 +76,7 @@ function parseFileContents(fileContents, fileType, filePath, varRegex, opts = {}
76
76
  } catch (err) {
77
77
  throw new Error(`Failed to execute TypeScript file ${filePath}: ${err.message}`)
78
78
  }
79
- } else if (fileType.match(/\.(mjs|esm)/)) {
79
+ } else if (fileType.match(/\.(mjs|esm)/i)) {
80
80
  try {
81
81
  let jsArgs = opts.dynamicArgs || {}
82
82
  if (jsArgs && typeof jsArgs === 'function') {
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Preprocesses config to fix malformed fallback references
3
+ * and escape variables inside help() filter arguments
4
+ */
5
+ const { splitByComma } = require('../strings/splitByComma')
6
+
7
+ /**
8
+ * Preprocess config to fix malformed fallback references
9
+ * @param {Object} configObject - The parsed configuration object
10
+ * @param {RegExp} variableSyntax - The variable syntax regex to use
11
+ * @returns {Object} The preprocessed configuration object
12
+ */
13
+ function preProcess(configObject, variableSyntax) {
14
+ // Known reference prefixes that should be wrapped in ${}
15
+ const refPrefixes = ['self:', 'opt:', 'env:', 'file:', 'text:', 'deep:']
16
+
17
+ /**
18
+ * Escape variables inside help() filter arguments so main resolver skips them
19
+ * Uses base64 encoding to preserve exact original syntax (supports custom variable syntax)
20
+ * @param {string} str - String potentially containing help() with variables
21
+ * @returns {string} String with help() variables escaped
22
+ */
23
+ function escapeHelpVariables(str) {
24
+ if (typeof str !== 'string') return str
25
+ if (!variableSyntax) return str
26
+
27
+ // Match help('...') or help("...") containing variables
28
+ const helpPattern = /help\(['"]([^'"]+)['"]\)/g
29
+
30
+ return str.replace(helpPattern, (match, helpContent) => {
31
+ // Check if help content contains variables
32
+ if (!helpContent.match(variableSyntax)) return match
33
+
34
+ // Replace each variable match with base64-encoded placeholder
35
+ const escaped = helpContent.replace(variableSyntax, (varMatch) => {
36
+ const encoded = Buffer.from(varMatch).toString('base64')
37
+ return `__CONFIGVAR:${encoded}__`
38
+ })
39
+ return `help('${escaped}')`
40
+ })
41
+ }
42
+
43
+ /**
44
+ * Fix malformed fallback references in a string
45
+ * @param {string} str - String potentially containing variables
46
+ * @returns {string} String with fixed fallback references
47
+ */
48
+ function fixFallbacksInString(str) {
49
+ if (typeof str !== 'string') return str
50
+
51
+ let result = str
52
+ let changed = true
53
+
54
+ // Keep iterating until no more changes (to handle nested variables)
55
+ while (changed) {
56
+ changed = false
57
+
58
+ // Find innermost ${...} blocks (ones that don't contain other ${)
59
+ let i = 0
60
+ while (i < result.length) {
61
+ if (result[i] === '$' && result[i + 1] === '{') {
62
+ const start = i
63
+ let braceCount = 1
64
+ let j = i + 2
65
+
66
+ // Find the matching closing brace by counting { and }
67
+ while (j < result.length && braceCount > 0) {
68
+ if (result[j] === '{') {
69
+ braceCount++
70
+ } else if (result[j] === '}') {
71
+ braceCount--
72
+ }
73
+ j++
74
+ }
75
+
76
+ if (braceCount === 0) {
77
+ const end = j
78
+ const match = result.substring(start, end)
79
+ const content = result.substring(start + 2, end - 1)
80
+
81
+ // Only process if there's a comma (indicating fallback syntax)
82
+ if (content.includes(',')) {
83
+ // Split by comma
84
+ const parts = splitByComma(content, variableSyntax)
85
+
86
+ if (parts.length > 1) {
87
+ // Check if the first part has nested ${} - if so, skip this (process inner ones first)
88
+ const firstPart = parts[0]
89
+ if (firstPart.includes('${')) {
90
+ i = start + 2 // Move past ${ to find inner variables
91
+ continue
92
+ }
93
+
94
+ // Check each part after the first (these are fallback values)
95
+ const fixed = parts.map((part, index) => {
96
+ if (index === 0) {
97
+ return part // Keep the main reference as-is
98
+ }
99
+
100
+ const trimmed = part.trim()
101
+
102
+ // Check if this looks like a reference but is not wrapped
103
+ const looksLikeRef = refPrefixes.some(prefix => trimmed.startsWith(prefix))
104
+ const alreadyWrapped = trimmed.startsWith('${') && trimmed.endsWith('}')
105
+
106
+ if (looksLikeRef && !alreadyWrapped) {
107
+ return ` \${${trimmed}}`
108
+ }
109
+
110
+ return ` ${trimmed}`
111
+ })
112
+
113
+ const replacement = `\${${fixed.join(',')}}`
114
+ if (replacement !== match) {
115
+ result = result.substring(0, start) + replacement + result.substring(end)
116
+ changed = true
117
+ break // Restart search from beginning
118
+ }
119
+ }
120
+ }
121
+
122
+ i = start + 2 // Move past ${ to continue searching for nested variables
123
+ } else {
124
+ i++
125
+ }
126
+ } else {
127
+ i++
128
+ }
129
+ }
130
+ }
131
+
132
+ return result
133
+ }
134
+
135
+ /**
136
+ * Recursively traverse and fix the config object
137
+ */
138
+ function traverseAndFix(obj) {
139
+ if (typeof obj === 'string') {
140
+ // First escape help() variables, then fix fallbacks
141
+ const withHelpEscaped = escapeHelpVariables(obj)
142
+ return fixFallbacksInString(withHelpEscaped)
143
+ }
144
+
145
+ if (Array.isArray(obj)) {
146
+ return obj.map(item => traverseAndFix(item))
147
+ }
148
+
149
+ if (obj !== null && typeof obj === 'object') {
150
+ const result = {}
151
+ for (const key in obj) {
152
+ if (obj.hasOwnProperty(key)) {
153
+ result[key] = traverseAndFix(obj[key])
154
+ }
155
+ }
156
+ return result
157
+ }
158
+
159
+ return obj
160
+ }
161
+
162
+ return traverseAndFix(configObject)
163
+ }
164
+
165
+ module.exports = preProcess
@@ -1,6 +1,6 @@
1
1
  // Utilities for parsing and normalizing file paths in variable references
2
2
 
3
- const { splitCsv } = require('./splitCsv')
3
+ const { splitCsv } = require('../strings/splitCsv')
4
4
 
5
5
  /**
6
6
  * Normalize a file path (add ./ prefix, fix .//, skip deep refs)
@@ -45,12 +45,13 @@ function extractFilePath(variableString) {
45
45
  return null
46
46
  }
47
47
 
48
+ const { trimSurroundingQuotes } = require('../strings/quoteUtils')
48
49
  const fileContent = fileMatch[1].trim()
49
50
  const parts = splitCsv(fileContent)
50
51
  let filePath = parts[0].trim()
51
52
 
52
53
  // Remove quotes if present
53
- filePath = filePath.replace(/^['"]|['"]$/g, '')
54
+ filePath = trimSurroundingQuotes(filePath, false)
54
55
 
55
56
  return { filePath }
56
57
  }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Finds the line number for a given key in config file contents
3
+ */
4
+
5
+ /**
6
+ * Find the line number where a key is defined in config file contents
7
+ * @param {string} keyToFind - The key to search for
8
+ * @param {string[]} lines - Array of file lines
9
+ * @param {string} fileType - File extension (e.g., '.yml', '.json', '.toml')
10
+ * @returns {number} Line number (1-indexed) or 0 if not found
11
+ */
12
+ function findLineForKey(keyToFind, lines, fileType) {
13
+ if (!keyToFind || !lines || !lines.length) return 0
14
+
15
+ const escapedKey = keyToFind.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
16
+
17
+ const lineIdx = lines.findIndex((line) => {
18
+ // YAML: key: or key :
19
+ if (fileType === '.yml' || fileType === '.yaml') {
20
+ return new RegExp(`^\\s*${escapedKey}\\s*:`).test(line)
21
+ }
22
+ // TOML: key = or key=
23
+ if (fileType === '.toml') {
24
+ return new RegExp(`^\\s*${escapedKey}\\s*=`).test(line)
25
+ }
26
+ // JSON: "key": or "key" :
27
+ if (fileType === '.json' || fileType === '.json5') {
28
+ return new RegExp(`"${escapedKey}"\\s*:`).test(line)
29
+ }
30
+ // INI: key = or key=
31
+ if (fileType === '.ini') {
32
+ return new RegExp(`^\\s*${escapedKey}\\s*=`).test(line)
33
+ }
34
+ // JS/TS/ESM: key: or "key": or 'key': or `key`: or [`key`]:
35
+ if (['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts'].includes(fileType)) {
36
+ return new RegExp(`(?:${escapedKey}|"${escapedKey}"|'${escapedKey}'|\`${escapedKey}\`|\\[\`${escapedKey}\`\\])\\s*:`).test(line)
37
+ }
38
+ // Default fallback: try YAML-style
39
+ return line.includes(`${keyToFind}:`)
40
+ })
41
+
42
+ return lineIdx !== -1 ? lineIdx + 1 : 0
43
+ }
44
+
45
+ module.exports = {
46
+ findLineForKey
47
+ }