configorama 0.6.11 → 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 +775 -857
  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} +244 -94
  17. package/src/utils/{parse.js → parsing/parse.js} +13 -13
  18. package/src/utils/parsing/preProcess.js +165 -0
  19. package/src/utils/paths/filePathUtils.js +136 -0
  20. package/src/utils/paths/filePathUtils.test.js +214 -0
  21. package/src/utils/paths/findLineForKey.js +47 -0
  22. package/src/utils/paths/findLineForKey.test.js +126 -0
  23. package/src/utils/{getFullFilePath.js → paths/getFullFilePath.js} +22 -26
  24. package/src/utils/{resolveAlias.js → paths/resolveAlias.js} +1 -1
  25. package/src/utils/regex/index.js +23 -1
  26. package/src/utils/resolution/preResolveVariable.js +260 -0
  27. package/src/utils/resolution/preResolveVariable.test.js +98 -0
  28. package/src/utils/strings/bracketMatcher.js +86 -0
  29. package/src/utils/strings/bracketMatcher.test.js +135 -0
  30. package/src/utils/{formatFunctionArgs.js → strings/formatFunctionArgs.js} +3 -2
  31. package/src/utils/strings/formatFunctionArgs.test.js +77 -0
  32. package/src/utils/strings/quoteUtils.js +89 -0
  33. package/src/utils/strings/quoteUtils.test.js +217 -0
  34. package/src/utils/strings/replaceAll.test.js +82 -0
  35. package/src/utils/{splitByComma.js → strings/splitByComma.js} +1 -1
  36. package/src/utils/strings/splitCsv.js +38 -0
  37. package/src/utils/strings/splitCsv.test.js +96 -0
  38. package/src/utils/strings/textUtils.test.js +86 -0
  39. package/src/utils/{configWizard.js → ui/configWizard.js} +212 -60
  40. package/src/utils/{createEditorLink.js → ui/createEditorLink.js} +11 -2
  41. package/src/utils/{logs.js → ui/logs.js} +3 -3
  42. package/src/utils/validation/isValidValue.test.js +64 -0
  43. package/src/utils/validation/warnIfNotFound.js +52 -0
  44. package/src/utils/variables/appendDeepVariable.test.js +41 -0
  45. package/src/utils/{cleanVariable.js → variables/cleanVariable.js} +5 -26
  46. package/src/utils/{find-nested-variables.js → variables/findNestedVariables.js} +2 -2
  47. package/src/utils/{find-nested-variables.test.js → variables/findNestedVariables.test.js} +5 -5
  48. package/src/utils/variables/getVariableType.test.js +109 -0
  49. package/src/utils/variables/variableUtils.test.js +117 -0
  50. package/src/utils/isValidValue.js +0 -8
  51. package/src/utils/splitCsv.js +0 -29
  52. package/src/utils/trimSurroundingQuotes.js +0 -5
  53. /package/src/utils/{arrayToJsonPath.js → parsing/arrayToJsonPath.js} +0 -0
  54. /package/src/utils/{cloudformationSchema.js → parsing/cloudformationSchema.js} +0 -0
  55. /package/src/utils/{mergeByKeys.js → parsing/mergeByKeys.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
@@ -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
@@ -0,0 +1,136 @@
1
+ // Utilities for parsing and normalizing file paths in variable references
2
+
3
+ const { splitCsv } = require('../strings/splitCsv')
4
+
5
+ /**
6
+ * Normalize a file path (add ./ prefix, fix .//, skip deep refs)
7
+ * @param {string} filePath - The file path to normalize
8
+ * @returns {string|null} Normalized path, or null if should be skipped
9
+ */
10
+ function normalizePath(filePath) {
11
+ // Skip deep references
12
+ if (filePath.includes('deep:')) {
13
+ return null
14
+ }
15
+
16
+ let normalized = filePath
17
+
18
+ // Add ./ prefix for relative paths
19
+ if (
20
+ !filePath.startsWith('./') &&
21
+ !filePath.startsWith('../') &&
22
+ !filePath.startsWith('/') &&
23
+ !filePath.startsWith('~')
24
+ ) {
25
+ normalized = './' + filePath
26
+ }
27
+
28
+ // Fix double slashes
29
+ if (normalized.startsWith('.//')) {
30
+ normalized = normalized.replace('.//', './')
31
+ }
32
+
33
+ return normalized
34
+ }
35
+
36
+ /**
37
+ * Extract file path from a file() or text() variable string
38
+ * @param {string} variableString - The variable string (with or without ${} wrapper)
39
+ * @returns {object|null} Object with filePath, or null if no match
40
+ */
41
+ function extractFilePath(variableString) {
42
+ // Match both ${file(...)} and file(...) formats
43
+ const fileMatch = variableString.match(/^(?:\$\{)?(?:file|text)\((.*?)\)/)
44
+ if (!fileMatch || !fileMatch[1]) {
45
+ return null
46
+ }
47
+
48
+ const { trimSurroundingQuotes } = require('../strings/quoteUtils')
49
+ const fileContent = fileMatch[1].trim()
50
+ const parts = splitCsv(fileContent)
51
+ let filePath = parts[0].trim()
52
+
53
+ // Remove quotes if present
54
+ filePath = trimSurroundingQuotes(filePath, false)
55
+
56
+ return { filePath }
57
+ }
58
+
59
+ /**
60
+ * Normalize a file() or text() variable string
61
+ * Strips key accessors and normalizes the path inside
62
+ * @param {string} variableString - e.g. "file('./config.json'):key" or "file(config.json)"
63
+ * @returns {string} Normalized variable string, e.g. "file(./config.json)"
64
+ */
65
+ function normalizeFileVariable(variableString) {
66
+ if (!variableString.match(/^(?:file|text)\(/)) {
67
+ return variableString
68
+ }
69
+
70
+ // Strip sub-key accessors like :topLevel, :nested.value, etc.
71
+ let normalized = variableString.replace(/:[\w.[\]]+$/, '')
72
+
73
+ // Normalize the path inside
74
+ normalized = normalized.replace(/^(file|text)\((.+?)\)/, (match, funcName, filePath) => {
75
+ let cleanPath = filePath.trim().replace(/^["']|["']$/g, '')
76
+ const normalizedPath = normalizePath(cleanPath)
77
+ return normalizedPath ? `${funcName}(${normalizedPath})` : match
78
+ })
79
+
80
+ return normalized
81
+ }
82
+
83
+ /**
84
+ * Resolve inner variables in a string from config values
85
+ * @param {string} str - String containing variables like ${self:stage}
86
+ * @param {RegExp} variableSyntax - Regex to match variable syntax
87
+ * @param {object} config - Config object to look up values
88
+ * @param {function} getProp - Function to get nested property (e.g. dotProp.get)
89
+ * @returns {{resolved: string, didResolve: boolean}} Resolved string and whether resolution happened
90
+ */
91
+ function resolveInnerVariables(str, variableSyntax, config, getProp) {
92
+ const varMatches = str.match(variableSyntax)
93
+ if (!varMatches) {
94
+ return { resolved: str, didResolve: false }
95
+ }
96
+
97
+ let canResolve = true
98
+ let resolved = str
99
+ for (const varMatch of varMatches) {
100
+ const innerVar = varMatch.slice(2, -1) // Remove ${ and }
101
+ let configPath = null
102
+
103
+ // Handle self: prefix
104
+ if (innerVar.startsWith('self:')) {
105
+ configPath = innerVar.slice(5)
106
+ } else if (!innerVar.includes(':')) {
107
+ // dot.prop style
108
+ configPath = innerVar
109
+ }
110
+
111
+ if (configPath) {
112
+ const configValue = getProp(config, configPath)
113
+ // Only use if it's a static value (not another variable)
114
+ if (configValue !== undefined &&
115
+ typeof configValue === 'string' &&
116
+ !configValue.match(variableSyntax)) {
117
+ resolved = resolved.replace(varMatch, configValue)
118
+ } else {
119
+ canResolve = false
120
+ break
121
+ }
122
+ } else {
123
+ canResolve = false
124
+ break
125
+ }
126
+ }
127
+
128
+ return { resolved: canResolve ? resolved : str, didResolve: canResolve }
129
+ }
130
+
131
+ module.exports = {
132
+ normalizePath,
133
+ extractFilePath,
134
+ normalizeFileVariable,
135
+ resolveInnerVariables,
136
+ }
@@ -0,0 +1,214 @@
1
+ // Tests for file path parsing and normalization utilities
2
+
3
+ const { test } = require('uvu')
4
+ const assert = require('uvu/assert')
5
+ const { normalizePath, extractFilePath, normalizeFileVariable, resolveInnerVariables } = require('./filePathUtils')
6
+
7
+ // normalizePath tests
8
+
9
+ test('normalizePath - returns null for deep: references', () => {
10
+ assert.is(normalizePath('deep:1'), null)
11
+ assert.is(normalizePath('some/deep:path'), null)
12
+ })
13
+
14
+ test('normalizePath - adds ./ prefix to bare paths', () => {
15
+ assert.is(normalizePath('config.json'), './config.json')
16
+ assert.is(normalizePath('path/to/file.yml'), './path/to/file.yml')
17
+ })
18
+
19
+ test('normalizePath - keeps ./ paths unchanged', () => {
20
+ assert.is(normalizePath('./config.json'), './config.json')
21
+ assert.is(normalizePath('./path/to/file.yml'), './path/to/file.yml')
22
+ })
23
+
24
+ test('normalizePath - keeps ../ paths unchanged', () => {
25
+ assert.is(normalizePath('../config.json'), '../config.json')
26
+ assert.is(normalizePath('../../path/to/file.yml'), '../../path/to/file.yml')
27
+ })
28
+
29
+ test('normalizePath - keeps absolute paths unchanged', () => {
30
+ assert.is(normalizePath('/etc/config.json'), '/etc/config.json')
31
+ assert.is(normalizePath('/home/user/file.yml'), '/home/user/file.yml')
32
+ })
33
+
34
+ test('normalizePath - keeps ~ paths unchanged', () => {
35
+ assert.is(normalizePath('~/config.json'), '~/config.json')
36
+ assert.is(normalizePath('~/.config/file.yml'), '~/.config/file.yml')
37
+ })
38
+
39
+ test('normalizePath - fixes .// to ./', () => {
40
+ assert.is(normalizePath('.//config.json'), './config.json')
41
+ assert.is(normalizePath('.//path/to/file.yml'), './path/to/file.yml')
42
+ })
43
+
44
+ // extractFilePath tests
45
+
46
+ test('extractFilePath - extracts path from file(...) format', () => {
47
+ const result = extractFilePath('file(./config.json)')
48
+ assert.is(result.filePath, './config.json')
49
+ })
50
+
51
+ test('extractFilePath - extracts path from ${file(...)} format', () => {
52
+ const result = extractFilePath('${file(./config.json)}')
53
+ assert.is(result.filePath, './config.json')
54
+ })
55
+
56
+ test('extractFilePath - extracts path from text(...) format', () => {
57
+ const result = extractFilePath('text(./readme.txt)')
58
+ assert.is(result.filePath, './readme.txt')
59
+ })
60
+
61
+ test('extractFilePath - extracts path from ${text(...)} format', () => {
62
+ const result = extractFilePath('${text(./readme.txt)}')
63
+ assert.is(result.filePath, './readme.txt')
64
+ })
65
+
66
+ test('extractFilePath - handles single-quoted paths', () => {
67
+ const result = extractFilePath("file('./config.json')")
68
+ assert.is(result.filePath, './config.json')
69
+ })
70
+
71
+ test('extractFilePath - handles double-quoted paths', () => {
72
+ const result = extractFilePath('file("./config.json")')
73
+ assert.is(result.filePath, './config.json')
74
+ })
75
+
76
+ test('extractFilePath - extracts first path when fallback present', () => {
77
+ const result = extractFilePath("file(./env.yml, 'default')")
78
+ assert.is(result.filePath, './env.yml')
79
+ })
80
+
81
+ test('extractFilePath - handles path with key accessor', () => {
82
+ const result = extractFilePath('file(./env.yml):FOO')
83
+ assert.is(result.filePath, './env.yml')
84
+ })
85
+
86
+ test('extractFilePath - handles complex fallback with key accessor', () => {
87
+ const result = extractFilePath("file(./env.yml):SECRET, 'default-value'")
88
+ assert.is(result.filePath, './env.yml')
89
+ })
90
+
91
+ test('extractFilePath - returns null for non-file patterns', () => {
92
+ assert.is(extractFilePath('opt:stage'), null)
93
+ assert.is(extractFilePath('${self:provider.stage}'), null)
94
+ assert.is(extractFilePath('env:FOO'), null)
95
+ })
96
+
97
+ test('extractFilePath - returns null for empty input', () => {
98
+ assert.is(extractFilePath(''), null)
99
+ })
100
+
101
+ test('extractFilePath - handles bare filename', () => {
102
+ const result = extractFilePath('file(config.json)')
103
+ assert.is(result.filePath, 'config.json')
104
+ })
105
+
106
+ // normalizeFileVariable tests
107
+
108
+ test('normalizeFileVariable - returns non-file strings unchanged', () => {
109
+ assert.is(normalizeFileVariable('opt:stage'), 'opt:stage')
110
+ assert.is(normalizeFileVariable('self:provider.stage'), 'self:provider.stage')
111
+ })
112
+
113
+ test('normalizeFileVariable - normalizes bare path in file()', () => {
114
+ assert.is(normalizeFileVariable('file(config.json)'), 'file(./config.json)')
115
+ })
116
+
117
+ test('normalizeFileVariable - normalizes bare path in text()', () => {
118
+ assert.is(normalizeFileVariable('text(readme.txt)'), 'text(./readme.txt)')
119
+ })
120
+
121
+ test('normalizeFileVariable - strips key accessor', () => {
122
+ assert.is(normalizeFileVariable('file(./env.yml):FOO'), 'file(./env.yml)')
123
+ assert.is(normalizeFileVariable('file(./config.json):nested.value'), 'file(./config.json)')
124
+ })
125
+
126
+ test('normalizeFileVariable - strips key accessor with array notation', () => {
127
+ assert.is(normalizeFileVariable('file(./data.json):items[0]'), 'file(./data.json)')
128
+ })
129
+
130
+ test('normalizeFileVariable - removes quotes from path', () => {
131
+ assert.is(normalizeFileVariable("file('./config.json')"), 'file(./config.json)')
132
+ assert.is(normalizeFileVariable('file("./config.json")'), 'file(./config.json)')
133
+ })
134
+
135
+ test('normalizeFileVariable - handles combined normalization', () => {
136
+ assert.is(normalizeFileVariable("file('config.json'):key"), 'file(./config.json)')
137
+ })
138
+
139
+ test('normalizeFileVariable - keeps ./ paths unchanged', () => {
140
+ assert.is(normalizeFileVariable('file(./already-normalized.yml)'), 'file(./already-normalized.yml)')
141
+ })
142
+
143
+ test('normalizeFileVariable - keeps ../ paths unchanged', () => {
144
+ assert.is(normalizeFileVariable('file(../parent/config.yml)'), 'file(../parent/config.yml)')
145
+ })
146
+
147
+ // resolveInnerVariables tests
148
+
149
+ const variableSyntax = /\$\{([^}]+)\}/g
150
+ const getProp = (obj, path) => path.split('.').reduce((o, k) => o && o[k], obj)
151
+
152
+ test('resolveInnerVariables - returns unchanged when no variables', () => {
153
+ const result = resolveInnerVariables('./config.json', variableSyntax, {}, getProp)
154
+ assert.is(result.resolved, './config.json')
155
+ assert.is(result.didResolve, false)
156
+ })
157
+
158
+ test('resolveInnerVariables - resolves self: variables from config', () => {
159
+ const config = { stage: 'prod' }
160
+ const result = resolveInnerVariables('./database-${self:stage}.json', variableSyntax, config, getProp)
161
+ assert.is(result.resolved, './database-prod.json')
162
+ assert.is(result.didResolve, true)
163
+ })
164
+
165
+ test('resolveInnerVariables - resolves dot.prop style variables', () => {
166
+ const config = { stage: 'dev' }
167
+ const result = resolveInnerVariables('./database-${stage}.json', variableSyntax, config, getProp)
168
+ assert.is(result.resolved, './database-dev.json')
169
+ assert.is(result.didResolve, true)
170
+ })
171
+
172
+ test('resolveInnerVariables - resolves nested config paths', () => {
173
+ const config = { provider: { stage: 'test' } }
174
+ const result = resolveInnerVariables('./db-${self:provider.stage}.json', variableSyntax, config, getProp)
175
+ assert.is(result.resolved, './db-test.json')
176
+ assert.is(result.didResolve, true)
177
+ })
178
+
179
+ test('resolveInnerVariables - resolves multiple variables', () => {
180
+ const config = { stage: 'prod', region: 'us-east-1' }
181
+ const result = resolveInnerVariables('./config-${self:stage}-${self:region}.json', variableSyntax, config, getProp)
182
+ assert.is(result.resolved, './config-prod-us-east-1.json')
183
+ assert.is(result.didResolve, true)
184
+ })
185
+
186
+ test('resolveInnerVariables - does not resolve if config value is a variable', () => {
187
+ const config = { stage: '${opt:stage}' }
188
+ const result = resolveInnerVariables('./database-${self:stage}.json', variableSyntax, config, getProp)
189
+ assert.is(result.resolved, './database-${self:stage}.json')
190
+ assert.is(result.didResolve, false)
191
+ })
192
+
193
+ test('resolveInnerVariables - does not resolve if config value is undefined', () => {
194
+ const config = {}
195
+ const result = resolveInnerVariables('./database-${self:stage}.json', variableSyntax, config, getProp)
196
+ assert.is(result.resolved, './database-${self:stage}.json')
197
+ assert.is(result.didResolve, false)
198
+ })
199
+
200
+ test('resolveInnerVariables - does not resolve env: or opt: variables', () => {
201
+ const config = { stage: 'prod' }
202
+ const result = resolveInnerVariables('./database-${env:STAGE}.json', variableSyntax, config, getProp)
203
+ assert.is(result.resolved, './database-${env:STAGE}.json')
204
+ assert.is(result.didResolve, false)
205
+ })
206
+
207
+ test('resolveInnerVariables - partial resolution fails completely', () => {
208
+ const config = { stage: 'prod' }
209
+ const result = resolveInnerVariables('./config-${self:stage}-${env:REGION}.json', variableSyntax, config, getProp)
210
+ assert.is(result.resolved, './config-${self:stage}-${env:REGION}.json')
211
+ assert.is(result.didResolve, false)
212
+ })
213
+
214
+ test.run()
@@ -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
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Tests for findLineForKey utility
3
+ */
4
+ const { test } = require('uvu')
5
+ const assert = require('uvu/assert')
6
+ const { findLineForKey } = require('./findLineForKey')
7
+
8
+ // YAML tests
9
+ test('YAML - finds key at start of line', () => {
10
+ const lines = ['foo: bar', 'baz: qux']
11
+ assert.equal(findLineForKey('foo', lines, '.yml'), 1)
12
+ assert.equal(findLineForKey('baz', lines, '.yml'), 2)
13
+ })
14
+
15
+ test('YAML - finds key with indentation', () => {
16
+ const lines = ['root:', ' nested: value', ' other: thing']
17
+ assert.equal(findLineForKey('nested', lines, '.yaml'), 2)
18
+ assert.equal(findLineForKey('other', lines, '.yaml'), 3)
19
+ })
20
+
21
+ test('YAML - finds key with space before colon', () => {
22
+ const lines = ['foo : bar']
23
+ assert.equal(findLineForKey('foo', lines, '.yml'), 1)
24
+ })
25
+
26
+ test('YAML - returns 0 for missing key', () => {
27
+ const lines = ['foo: bar']
28
+ assert.equal(findLineForKey('missing', lines, '.yml'), 0)
29
+ })
30
+
31
+ // JSON tests
32
+ test('JSON - finds quoted key', () => {
33
+ const lines = ['{', ' "foo": "bar",', ' "baz": 123', '}']
34
+ assert.equal(findLineForKey('foo', lines, '.json'), 2)
35
+ assert.equal(findLineForKey('baz', lines, '.json'), 3)
36
+ })
37
+
38
+ test('JSON - finds key with space before colon', () => {
39
+ const lines = ['{ "foo" : "bar" }']
40
+ assert.equal(findLineForKey('foo', lines, '.json'), 1)
41
+ })
42
+
43
+ test('JSON5 - works same as JSON', () => {
44
+ const lines = ['{', ' "foo": "bar"', '}']
45
+ assert.equal(findLineForKey('foo', lines, '.json5'), 2)
46
+ })
47
+
48
+ // TOML tests
49
+ test('TOML - finds key with equals', () => {
50
+ const lines = ['foo = "bar"', 'baz = 123']
51
+ assert.equal(findLineForKey('foo', lines, '.toml'), 1)
52
+ assert.equal(findLineForKey('baz', lines, '.toml'), 2)
53
+ })
54
+
55
+ test('TOML - finds key without spaces around equals', () => {
56
+ const lines = ['foo="bar"']
57
+ assert.equal(findLineForKey('foo', lines, '.toml'), 1)
58
+ })
59
+
60
+ // INI tests
61
+ test('INI - finds key with equals', () => {
62
+ const lines = ['[section]', 'foo = bar', 'baz = qux']
63
+ assert.equal(findLineForKey('foo', lines, '.ini'), 2)
64
+ assert.equal(findLineForKey('baz', lines, '.ini'), 3)
65
+ })
66
+
67
+ // JS/TS tests
68
+ test('JS - finds unquoted key', () => {
69
+ const lines = ['module.exports = {', ' foo: "bar",', ' baz: 123', '}']
70
+ assert.equal(findLineForKey('foo', lines, '.js'), 2)
71
+ assert.equal(findLineForKey('baz', lines, '.js'), 3)
72
+ })
73
+
74
+ test('JS - finds double-quoted key', () => {
75
+ const lines = ['module.exports = {', ' "foo": "bar"', '}']
76
+ assert.equal(findLineForKey('foo', lines, '.js'), 2)
77
+ })
78
+
79
+ test('JS - finds single-quoted key', () => {
80
+ const lines = ["module.exports = {", " 'foo': 'bar'", "}"]
81
+ assert.equal(findLineForKey('foo', lines, '.js'), 2)
82
+ })
83
+
84
+ test('JS - finds backtick-quoted key', () => {
85
+ const lines = ['module.exports = {', ' `foo`: "bar"', '}']
86
+ assert.equal(findLineForKey('foo', lines, '.js'), 2)
87
+ })
88
+
89
+ test('TS - works same as JS', () => {
90
+ const lines = ['export default {', ' foo: "bar"', '}']
91
+ assert.equal(findLineForKey('foo', lines, '.ts'), 2)
92
+ })
93
+
94
+ test('MJS - works same as JS', () => {
95
+ const lines = ['export default {', ' foo: "bar"', '}']
96
+ assert.equal(findLineForKey('foo', lines, '.mjs'), 2)
97
+ })
98
+
99
+ // Edge cases
100
+ test('returns 0 for empty lines array', () => {
101
+ assert.equal(findLineForKey('foo', [], '.yml'), 0)
102
+ })
103
+
104
+ test('returns 0 for null/undefined key', () => {
105
+ const lines = ['foo: bar']
106
+ assert.equal(findLineForKey(null, lines, '.yml'), 0)
107
+ assert.equal(findLineForKey(undefined, lines, '.yml'), 0)
108
+ })
109
+
110
+ test('returns 0 for null/undefined lines', () => {
111
+ assert.equal(findLineForKey('foo', null, '.yml'), 0)
112
+ assert.equal(findLineForKey('foo', undefined, '.yml'), 0)
113
+ })
114
+
115
+ test('escapes special regex characters in key', () => {
116
+ const lines = ['foo.bar: value', 'baz[0]: thing']
117
+ assert.equal(findLineForKey('foo.bar', lines, '.yml'), 1)
118
+ assert.equal(findLineForKey('baz[0]', lines, '.yml'), 2)
119
+ })
120
+
121
+ test('unknown file type falls back to YAML-style', () => {
122
+ const lines = ['foo: bar']
123
+ assert.equal(findLineForKey('foo', lines, '.unknown'), 1)
124
+ })
125
+
126
+ test.run()