configorama 0.9.8 → 0.9.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -0
- package/package.json +1 -1
- package/src/main.js +254 -101
- package/src/parsers/esm.js +0 -14
- package/src/parsers/typescript.js +0 -10
- package/src/resolvers/valueFromEval.js +69 -11
- package/src/resolvers/valueFromFile.js +1 -1
- package/src/resolvers/valueFromIf.js +75 -0
- package/src/resolvers/valueFromIf.test.js +66 -0
- package/src/resolvers/valueFromNumber.js +3 -0
- package/src/utils/handleSignalEvents.js +3 -4
- package/src/utils/lodash.js +18 -7
- package/src/utils/parsing/cloudformationSchema.js +1 -2
- package/src/utils/parsing/cloudformationSchema.test.js +14 -0
- package/src/utils/parsing/preProcess.js +220 -5
- package/src/utils/paths/getFullFilePath.js +6 -2
- package/src/utils/paths/getFullFilePath.test.js +18 -0
- package/src/utils/regex/index.js +18 -3
- package/src/utils/regex/index.test.js +24 -0
- package/src/utils/strings/quoteAware.js +141 -0
- package/src/utils/strings/replaceAll.js +13 -1
- package/src/utils/strings/splitByComma.js +25 -15
- package/src/utils/strings/splitByComma.test.js +19 -0
- package/src/utils/strings/splitOnPipe.js +30 -0
- package/src/utils/strings/splitOnPipe.test.js +68 -0
- package/src/utils/validation/isValidValue.test.js +1 -1
- package/src/utils/variables/findNestedVariables.js +8 -2
- package/types/src/main.d.ts +3 -1
- package/types/src/main.d.ts.map +1 -1
- package/types/src/parsers/esm.d.ts.map +1 -1
- package/types/src/parsers/typescript.d.ts.map +1 -1
- package/types/src/resolvers/valueFromEval.d.ts +1 -0
- package/types/src/resolvers/valueFromEval.d.ts.map +1 -1
- package/types/src/resolvers/valueFromIf.d.ts +7 -0
- package/types/src/resolvers/valueFromIf.d.ts.map +1 -0
- package/types/src/resolvers/valueFromNumber.d.ts.map +1 -1
- package/types/src/utils/handleSignalEvents.d.ts.map +1 -1
- package/types/src/utils/lodash.d.ts.map +1 -1
- package/types/src/utils/parsing/preProcess.d.ts +5 -1
- package/types/src/utils/parsing/preProcess.d.ts.map +1 -1
- package/types/src/utils/paths/getFullFilePath.d.ts.map +1 -1
- package/types/src/utils/regex/index.d.ts.map +1 -1
- package/types/src/utils/strings/quoteAware.d.ts +30 -0
- package/types/src/utils/strings/quoteAware.d.ts.map +1 -0
- package/types/src/utils/strings/replaceAll.d.ts.map +1 -1
- package/types/src/utils/strings/splitByComma.d.ts +1 -1
- package/types/src/utils/strings/splitByComma.d.ts.map +1 -1
- package/types/src/utils/strings/splitOnPipe.d.ts +8 -0
- package/types/src/utils/strings/splitOnPipe.d.ts.map +1 -0
- package/types/src/utils/variables/findNestedVariables.d.ts.map +1 -1
|
@@ -1,29 +1,86 @@
|
|
|
1
1
|
// const evalRefSyntax = RegExp(/^eval\((~?[\{\}\:\${}a-zA=>+!-Z0-9._\-\/,'"\*\` ]+?)?\)/g)
|
|
2
2
|
const evalRefSyntax = RegExp(/^eval\((.*)?\)/g)
|
|
3
|
+
const { replaceOutsideQuotes } = require('../utils/strings/quoteAware')
|
|
4
|
+
|
|
5
|
+
// Pattern for encoded objects/arrays: __OBJ:base64__ or __ARR:base64__
|
|
6
|
+
const ENCODED_PATTERN = /__(?:OBJ|ARR):([A-Za-z0-9+/=]+)__/g
|
|
7
|
+
|
|
8
|
+
// Encode object/array for embedding in eval expressions
|
|
9
|
+
function encodeValue(value) {
|
|
10
|
+
const prefix = Array.isArray(value) ? 'ARR' : 'OBJ'
|
|
11
|
+
const encoded = Buffer.from(JSON.stringify(value)).toString('base64')
|
|
12
|
+
return `__${prefix}:${encoded}__`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Decode encoded values and build context for subscript
|
|
16
|
+
function decodeValues(expression) {
|
|
17
|
+
const context = {}
|
|
18
|
+
let idx = 0
|
|
19
|
+
|
|
20
|
+
const processed = expression.replace(ENCODED_PATTERN, (match, base64) => {
|
|
21
|
+
const decoded = JSON.parse(Buffer.from(base64, 'base64').toString('utf8'))
|
|
22
|
+
const placeholder = `__VAL${idx}__`
|
|
23
|
+
context[`__VAL${idx}__`] = decoded
|
|
24
|
+
idx++
|
|
25
|
+
return placeholder
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
return { processed, context }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Wrap individual comparisons in parentheses for correct precedence with && / ||
|
|
32
|
+
// Subscript has operator precedence issues without explicit parens
|
|
33
|
+
function wrapComparisons(expr) {
|
|
34
|
+
if (!/&&|\|\|/.test(expr)) return expr
|
|
35
|
+
|
|
36
|
+
// Match comparisons: value op value (where op is ===, !==, ==, !=, >=, <=, >, <)
|
|
37
|
+
// Values can be: quoted strings, numbers, identifiers, or __VAL0__ placeholders
|
|
38
|
+
const compPattern = /((?:"[^"]*"|'[^']*'|__VAL\d+__|__NULL__|[a-zA-Z_][a-zA-Z0-9_]*|[\d.]+))\s*(===|!==|==|!=|>=|<=|>|<)\s*((?:"[^"]*"|'[^']*'|__VAL\d+__|__NULL__|[a-zA-Z_][a-zA-Z0-9_]*|[\d.]+))/g
|
|
39
|
+
|
|
40
|
+
return expr.replace(compPattern, '($1 $2 $3)')
|
|
41
|
+
}
|
|
3
42
|
|
|
4
43
|
async function getValueFromEval(variableString) {
|
|
5
|
-
// console.log('getValueFromEval variableString', variableString)
|
|
6
|
-
// console.log('getValueFromEval variableString', variableString)
|
|
7
44
|
// Extract the expression inside eval()
|
|
8
45
|
const match = variableString.match(/^eval\((.+)\)$/)
|
|
9
|
-
// console.log('match', match)
|
|
10
46
|
if (!match) {
|
|
11
47
|
throw new Error(`Invalid eval syntax: ${variableString}. Expected format: eval(expression)`)
|
|
12
48
|
}
|
|
13
|
-
|
|
49
|
+
|
|
14
50
|
const expression = match[1].trim()
|
|
15
|
-
|
|
16
|
-
|
|
51
|
+
if (process.env.DEBUG_EVAL) console.log('eval expression:', expression)
|
|
52
|
+
|
|
17
53
|
// Use "justin" variant to support strict comparison (===, !==) and other JS-like operators
|
|
18
54
|
try {
|
|
19
55
|
const { default: subscript } = await import('subscript/justin')
|
|
20
|
-
|
|
56
|
+
|
|
21
57
|
// Handle string comparisons by ensuring both sides are quoted
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
//
|
|
58
|
+
let processedExpression = expression.replace(/([a-zA-Z0-9_]+)\s*([=!<>]=?)\s*['"]([^'"]+)['"]/g, '"$1"$2"$3"')
|
|
59
|
+
|
|
60
|
+
// Decode any encoded objects/arrays
|
|
61
|
+
const { processed: withDecodedValues, context: valueContext } = decodeValues(processedExpression)
|
|
62
|
+
processedExpression = withDecodedValues
|
|
63
|
+
|
|
64
|
+
// Workaround: subscript doesn't handle null keyword correctly
|
|
65
|
+
// Replace null with placeholder and inject via context (but not inside quoted strings)
|
|
66
|
+
const hasNull = /\bnull\b/.test(processedExpression)
|
|
67
|
+
if (hasNull) {
|
|
68
|
+
processedExpression = replaceOutsideQuotes(processedExpression, 'null', '__NULL__')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Build context with null and any decoded values
|
|
72
|
+
/** @type {Record<string, unknown>} */
|
|
73
|
+
const context = { ...valueContext }
|
|
74
|
+
if (hasNull) {
|
|
75
|
+
context.__NULL__ = null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Wrap comparisons in parens for correct precedence with && / ||
|
|
79
|
+
processedExpression = wrapComparisons(processedExpression)
|
|
80
|
+
|
|
81
|
+
if (process.env.DEBUG_EVAL) console.log('eval processed:', processedExpression)
|
|
25
82
|
const fn = subscript(processedExpression)
|
|
26
|
-
const result = fn()
|
|
83
|
+
const result = fn(Object.keys(context).length > 0 ? context : undefined)
|
|
27
84
|
return result
|
|
28
85
|
} catch (error) {
|
|
29
86
|
throw new Error(`Error evaluating expression "${expression}": ${error.message}`)
|
|
@@ -33,6 +90,7 @@ async function getValueFromEval(variableString) {
|
|
|
33
90
|
module.exports = {
|
|
34
91
|
type: 'eval',
|
|
35
92
|
source: 'readonly',
|
|
93
|
+
encodeValue,
|
|
36
94
|
description: '${eval(expression)} - Evaluates mathematical expressions',
|
|
37
95
|
match: evalRefSyntax,
|
|
38
96
|
resolver: getValueFromEval
|
|
@@ -258,7 +258,7 @@ ${JSON.stringify(options.context, null, 2)}`,
|
|
|
258
258
|
|
|
259
259
|
/* handle case for referencing raw JS files to inline them */
|
|
260
260
|
if (argsToPass.length
|
|
261
|
-
&& (argsToPass && argsToPass[0] && argsToPass[0].toLowerCase() === 'raw')
|
|
261
|
+
&& (argsToPass && argsToPass[0] && typeof argsToPass[0] === 'string' && argsToPass[0].toLowerCase() === 'raw')
|
|
262
262
|
|| opts.asRawText
|
|
263
263
|
) {
|
|
264
264
|
// Encode foo() to foo__PH_PAREN_OPEN__) to avoid function collisions
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/* ${if(...)} syntax - alias for eval() with more intuitive name for conditionals */
|
|
2
|
+
const { resolver: evalResolver } = require('./valueFromEval')
|
|
3
|
+
const { findOutsideQuotes } = require('../utils/strings/quoteAware')
|
|
4
|
+
|
|
5
|
+
// Match both:
|
|
6
|
+
// if(condition ? trueVal : falseVal) - ternary inside
|
|
7
|
+
// if(condition) ? trueVal : falseVal - ternary outside
|
|
8
|
+
const ifRefSyntax = RegExp(/^if\s*\(.*\)(\s*\?.*)?/g)
|
|
9
|
+
|
|
10
|
+
async function getValueFromIf(variableString) {
|
|
11
|
+
if (process.env.DEBUG_IF) console.log('if resolver input:', variableString)
|
|
12
|
+
|
|
13
|
+
// Validate: check for empty condition
|
|
14
|
+
const emptyConditionMatch = variableString.match(/^if\s*\(\s*\)/)
|
|
15
|
+
if (emptyConditionMatch) {
|
|
16
|
+
throw new Error('Empty condition in ${if()}. Expected: ${if(condition) ? trueVal : falseVal}')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Check for external ternary: if(condition) ? trueVal : falseVal
|
|
20
|
+
// Must properly balance parentheses to find where if() ends
|
|
21
|
+
const match = variableString.match(/^if\s*\(/)
|
|
22
|
+
if (match) {
|
|
23
|
+
const afterIf = variableString.substring(match[0].length)
|
|
24
|
+
let depth = 1
|
|
25
|
+
let i = 0
|
|
26
|
+
|
|
27
|
+
// Find the matching closing paren
|
|
28
|
+
while (i < afterIf.length && depth > 0) {
|
|
29
|
+
if (afterIf[i] === '(') depth++
|
|
30
|
+
else if (afterIf[i] === ')') depth--
|
|
31
|
+
if (depth > 0) i++
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (depth === 0) {
|
|
35
|
+
// Check what comes after the if() block
|
|
36
|
+
const afterCondition = afterIf.substring(i + 1).trim()
|
|
37
|
+
|
|
38
|
+
if (afterCondition.startsWith('?')) {
|
|
39
|
+
// External ternary: if(condition) ? trueVal : falseVal
|
|
40
|
+
const condition = afterIf.substring(0, i)
|
|
41
|
+
const ternaryPart = afterCondition.substring(1).trim() // after ?
|
|
42
|
+
|
|
43
|
+
// Find the colon separating trueVal and falseVal (outside quotes and encoded patterns)
|
|
44
|
+
const colonIdx = findOutsideQuotes(ternaryPart, (str, idx) => {
|
|
45
|
+
if (str[idx] !== ':') return 0
|
|
46
|
+
// Skip colons inside encoded patterns __OBJ:...__ or __ARR:...__
|
|
47
|
+
const before = str.substring(0, idx)
|
|
48
|
+
if (/__(?:OBJ|ARR|VAL\d+)$/.test(before)) return 0
|
|
49
|
+
return 1
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
if (colonIdx !== -1) {
|
|
53
|
+
const trueVal = ternaryPart.substring(0, colonIdx).trim()
|
|
54
|
+
const falseVal = ternaryPart.substring(colonIdx + 1).trim()
|
|
55
|
+
const expression = `(${condition}) ? ${trueVal} : ${falseVal}`
|
|
56
|
+
if (process.env.DEBUG_IF) console.log('if resolver external ternary:', expression)
|
|
57
|
+
return evalResolver(`eval(${expression})`)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Standard syntax: if(condition ? trueVal : falseVal) or if(boolExpr)
|
|
64
|
+
const converted = variableString.replace(/^if\s*\(/, 'eval(')
|
|
65
|
+
if (process.env.DEBUG_IF) console.log('if resolver standard syntax:', converted)
|
|
66
|
+
return evalResolver(converted)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = {
|
|
70
|
+
type: 'if',
|
|
71
|
+
source: 'readonly',
|
|
72
|
+
description: '${if(condition) ? "yes" : "no"} - Conditional expressions',
|
|
73
|
+
match: ifRefSyntax,
|
|
74
|
+
resolver: getValueFromIf
|
|
75
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/* Tests for ${if(...)} syntax - alias for eval */
|
|
2
|
+
const { test } = require('uvu')
|
|
3
|
+
const assert = require('uvu/assert')
|
|
4
|
+
const configorama = require('../../src')
|
|
5
|
+
|
|
6
|
+
test('if() basic ternary', async () => {
|
|
7
|
+
const result = await configorama({
|
|
8
|
+
yes: '${if(5 > 3 ? "yes" : "no")}',
|
|
9
|
+
no: '${if(3 > 5 ? "yes" : "no")}'
|
|
10
|
+
})
|
|
11
|
+
assert.is(result.yes, 'yes')
|
|
12
|
+
assert.is(result.no, 'no')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
test('if() with parentheses around condition', async () => {
|
|
16
|
+
const result = await configorama({
|
|
17
|
+
result: '${if((10 < 20) ? "smaller" : "bigger")}'
|
|
18
|
+
})
|
|
19
|
+
assert.is(result.result, 'smaller')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('if() boolean result', async () => {
|
|
23
|
+
const result = await configorama({
|
|
24
|
+
isTrue: '${if(10 == 10)}',
|
|
25
|
+
isFalse: '${if(10 == 5)}'
|
|
26
|
+
})
|
|
27
|
+
assert.is(result.isTrue, true)
|
|
28
|
+
assert.is(result.isFalse, false)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('if() with variables', async () => {
|
|
32
|
+
const result = await configorama({
|
|
33
|
+
threshold: 50,
|
|
34
|
+
value: 75,
|
|
35
|
+
status: '${if(${self:value} > ${self:threshold} ? "above" : "below")}'
|
|
36
|
+
})
|
|
37
|
+
assert.is(result.status, 'above')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('if() nested ternary', async () => {
|
|
41
|
+
const result = await configorama({
|
|
42
|
+
score: 85,
|
|
43
|
+
grade: '${if(${self:score} >= 90 ? "A" : ${self:score} >= 80 ? "B" : "C")}'
|
|
44
|
+
})
|
|
45
|
+
assert.is(result.grade, 'B')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('if() with logical operators', async () => {
|
|
49
|
+
const result = await configorama({
|
|
50
|
+
both: '${if(true && true)}',
|
|
51
|
+
either: '${if(false || true)}',
|
|
52
|
+
neither: '${if(false && false)}'
|
|
53
|
+
})
|
|
54
|
+
assert.is(result.both, true)
|
|
55
|
+
assert.is(result.either, true)
|
|
56
|
+
assert.is(result.neither, false)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('if() arithmetic in condition', async () => {
|
|
60
|
+
const result = await configorama({
|
|
61
|
+
result: '${if((5 + 5) > 8 ? "big" : "small")}'
|
|
62
|
+
})
|
|
63
|
+
assert.is(result.result, 'big')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test.run()
|
|
@@ -24,10 +24,9 @@ Exit received. Waiting for current operation to finish...
|
|
|
24
24
|
// Clean up readline interface when done
|
|
25
25
|
process.once('exit', () => rl.close())
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
rl.
|
|
29
|
-
rl.
|
|
30
|
-
rl.once('SIGBREAK', () => process.emit('SIGBREAK'))
|
|
27
|
+
rl.on('SIGINT', () => process.emit('SIGINT'))
|
|
28
|
+
rl.on('SIGTERM', () => process.emit('SIGTERM'))
|
|
29
|
+
rl.on('SIGBREAK', () => process.emit('SIGBREAK'))
|
|
31
30
|
}
|
|
32
31
|
|
|
33
32
|
// Remove any existing listeners before adding new ones
|
package/src/utils/lodash.js
CHANGED
|
@@ -47,26 +47,37 @@ function set(object, path, value) {
|
|
|
47
47
|
return object;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
// Cache for trim regex patterns (perf: avoid recompilation)
|
|
51
|
+
const trimRegexCache = new Map()
|
|
52
|
+
|
|
50
53
|
// Custom implementation of lodash.trim
|
|
51
54
|
function trim(string, chars) {
|
|
52
55
|
if (string === null || string === undefined) {
|
|
53
56
|
return '';
|
|
54
57
|
}
|
|
55
|
-
|
|
58
|
+
|
|
56
59
|
string = String(string);
|
|
57
|
-
|
|
60
|
+
|
|
58
61
|
if (!chars && String.prototype.trim) {
|
|
59
62
|
return string.trim();
|
|
60
63
|
}
|
|
61
|
-
|
|
64
|
+
|
|
62
65
|
if (!chars) {
|
|
63
66
|
// Default characters to trim (whitespace)
|
|
64
67
|
chars = ' \t\n\r\f\v\u00a0\u1680\u2000\u200a\u2028\u2029\u202f\u205f\u3000\ufeff';
|
|
65
68
|
}
|
|
66
|
-
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
69
|
+
|
|
70
|
+
// Check cache first
|
|
71
|
+
let pattern = trimRegexCache.get(chars)
|
|
72
|
+
if (!pattern) {
|
|
73
|
+
// Create and cache regex pattern with the characters to trim
|
|
74
|
+
const escaped = chars.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')
|
|
75
|
+
pattern = new RegExp(`^[${escaped}]+|[${escaped}]+$`, 'g')
|
|
76
|
+
trimRegexCache.set(chars, pattern)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Reset lastIndex for global regex reuse
|
|
80
|
+
pattern.lastIndex = 0
|
|
70
81
|
return string.replace(pattern, '');
|
|
71
82
|
}
|
|
72
83
|
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
const YAML = require('js-yaml');
|
|
2
2
|
const includes = require('lodash.includes');
|
|
3
3
|
const isString = require('lodash.isstring');
|
|
4
|
-
const split = require('lodash.split');
|
|
5
4
|
const flatten = require('lodash.flatten');
|
|
6
5
|
const map = require('lodash.map');
|
|
7
6
|
|
|
@@ -67,7 +66,7 @@ const createSchema = () => {
|
|
|
67
66
|
map(['mapping', 'scalar', 'sequence'], kind => yamlType(functionName, kind))
|
|
68
67
|
)
|
|
69
68
|
);
|
|
70
|
-
return YAML.Schema.create(types);
|
|
69
|
+
return YAML.Schema.create(YAML.DEFAULT_SAFE_SCHEMA, types);
|
|
71
70
|
};
|
|
72
71
|
|
|
73
72
|
module.exports = {
|
|
@@ -233,4 +233,18 @@ test('!EachMemberIn - member inclusion check', () => {
|
|
|
233
233
|
assert.equal(parsed.Value['Fn::EachMemberIn'], [['a', 'b'], ['a', 'b', 'c']])
|
|
234
234
|
})
|
|
235
235
|
|
|
236
|
+
// ==========================================
|
|
237
|
+
// Security - Unsafe YAML tags should be blocked
|
|
238
|
+
// ==========================================
|
|
239
|
+
|
|
240
|
+
test('security - !!js/function should be rejected', () => {
|
|
241
|
+
const maliciousYaml = `Handler: !!js/function 'function() { return "pwned"; }'`
|
|
242
|
+
assert.throws(() => parseCfYaml(maliciousYaml), /unknown tag/)
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
test('security - !!js/regexp should be rejected', () => {
|
|
246
|
+
const maliciousYaml = `Pattern: !!js/regexp /test/`
|
|
247
|
+
assert.throws(() => parseCfYaml(maliciousYaml), /unknown tag/)
|
|
248
|
+
})
|
|
249
|
+
|
|
236
250
|
test.run()
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Preprocesses config to fix malformed fallback references
|
|
3
|
-
*
|
|
2
|
+
* Preprocesses config to fix malformed fallback references,
|
|
3
|
+
* escape variables inside help() filter arguments,
|
|
4
|
+
* and convert bare references in if() expressions
|
|
4
5
|
*/
|
|
5
6
|
const { splitByComma } = require('../strings/splitByComma')
|
|
7
|
+
const { getQuoteRanges } = require('../strings/quoteAware')
|
|
6
8
|
const { extractVariableWrapper } = require('../variables/variableUtils')
|
|
7
9
|
|
|
8
10
|
/**
|
|
@@ -10,9 +12,12 @@ const { extractVariableWrapper } = require('../variables/variableUtils')
|
|
|
10
12
|
* @param {Object} configObject - The parsed configuration object
|
|
11
13
|
* @param {RegExp} variableSyntax - The variable syntax regex to use
|
|
12
14
|
* @param {Array} [variableTypes] - Array of variable type definitions with type/prefix fields
|
|
15
|
+
* @param {Object} [options] - Options for preprocessing
|
|
16
|
+
* @param {boolean} [options.skipFallbackFix] - Skip fixing malformed fallbacks (for object configs)
|
|
13
17
|
* @returns {Object} The preprocessed configuration object
|
|
14
18
|
*/
|
|
15
|
-
function preProcess(configObject, variableSyntax, variableTypes) {
|
|
19
|
+
function preProcess(configObject, variableSyntax, variableTypes, options = {}) {
|
|
20
|
+
const { skipFallbackFix = false } = options
|
|
16
21
|
// Extract prefix/suffix from variable syntax for reconstructing variables
|
|
17
22
|
const { prefix: varPrefix, suffix: varSuffix } = variableSyntax
|
|
18
23
|
? extractVariableWrapper(variableSyntax.source)
|
|
@@ -51,6 +56,214 @@ function preProcess(configObject, variableSyntax, variableTypes) {
|
|
|
51
56
|
})
|
|
52
57
|
}
|
|
53
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Convert bare config references inside if() expressions to ${...} syntax
|
|
61
|
+
* Also wraps unquoted ${...} refs in quotes for proper string comparison
|
|
62
|
+
* e.g., ${if(provider.stage === "prod")} => ${if("${provider.stage}" === "prod")}
|
|
63
|
+
* e.g., ${if(${provider.stage} === "prod")} => ${if("${provider.stage}" === "prod")}
|
|
64
|
+
* @param {string} str - String potentially containing if() expressions
|
|
65
|
+
* @returns {string} String with bare refs converted
|
|
66
|
+
*/
|
|
67
|
+
function convertBareRefsInIf(str) {
|
|
68
|
+
if (typeof str !== 'string') return str
|
|
69
|
+
|
|
70
|
+
const reserved = ['true', 'false', 'null', 'undefined', 'NaN', 'Infinity']
|
|
71
|
+
const prefixLen = varPrefix.length
|
|
72
|
+
const suffixLen = varSuffix.length
|
|
73
|
+
|
|
74
|
+
// Find if( blocks and process them
|
|
75
|
+
let result = str
|
|
76
|
+
let i = 0
|
|
77
|
+
|
|
78
|
+
while (i < result.length) {
|
|
79
|
+
// Look for ${if( or similar with custom prefix
|
|
80
|
+
const ifStart = result.indexOf(varPrefix + 'if(', i)
|
|
81
|
+
if (ifStart === -1) break
|
|
82
|
+
|
|
83
|
+
// Find the matching closing suffix by counting nested prefixes/suffixes
|
|
84
|
+
const contentStart = ifStart + prefixLen + 3 // after "${if("
|
|
85
|
+
let depth = 1
|
|
86
|
+
let j = contentStart
|
|
87
|
+
|
|
88
|
+
while (j < result.length && depth > 0) {
|
|
89
|
+
if (result.substring(j, j + prefixLen) === varPrefix) {
|
|
90
|
+
depth++
|
|
91
|
+
j += prefixLen
|
|
92
|
+
} else if (result.substring(j, j + suffixLen) === varSuffix) {
|
|
93
|
+
depth--
|
|
94
|
+
if (depth > 0) j += suffixLen
|
|
95
|
+
} else {
|
|
96
|
+
j++
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (depth === 0) {
|
|
101
|
+
// Extract the if content (everything between "if(" and the final ")")
|
|
102
|
+
const fullContent = result.substring(contentStart, j)
|
|
103
|
+
|
|
104
|
+
// Process the content: wrap bare refs and unquoted var refs in quotes
|
|
105
|
+
let processed = fullContent
|
|
106
|
+
|
|
107
|
+
// 1. First convert bare refs (word.word or word:word) to quoted var refs
|
|
108
|
+
// Must do this BEFORE handling ${...} to avoid double-wrapping
|
|
109
|
+
// Pattern excludes refs inside ${...} by using negative lookbehind for varPrefix
|
|
110
|
+
const escapedPrefix = varPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
111
|
+
const bareRefPattern = new RegExp(
|
|
112
|
+
`(?<!${escapedPrefix}[^${varSuffix}]*)(?<!")(?<!')(?<=^|[^.\\w])([a-zA-Z_][a-zA-Z0-9_]*(?:[.:][a-zA-Z_][a-zA-Z0-9_]*)+)(?![.\\w])`,
|
|
113
|
+
'g'
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
// Simpler approach: find bare refs that are NOT inside ${...}
|
|
117
|
+
// Build list of ${...} ranges to exclude
|
|
118
|
+
const varRanges = []
|
|
119
|
+
let pos = 0
|
|
120
|
+
while (pos < processed.length) {
|
|
121
|
+
if (processed.substring(pos, pos + prefixLen) === varPrefix) {
|
|
122
|
+
const start = pos
|
|
123
|
+
let varDepth = 1
|
|
124
|
+
pos += prefixLen
|
|
125
|
+
while (pos < processed.length && varDepth > 0) {
|
|
126
|
+
if (processed.substring(pos, pos + prefixLen) === varPrefix) {
|
|
127
|
+
varDepth++
|
|
128
|
+
pos += prefixLen
|
|
129
|
+
} else if (processed.substring(pos, pos + suffixLen) === varSuffix) {
|
|
130
|
+
varDepth--
|
|
131
|
+
if (varDepth > 0) pos += suffixLen
|
|
132
|
+
} else {
|
|
133
|
+
pos++
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
pos += suffixLen
|
|
137
|
+
varRanges.push([start, pos])
|
|
138
|
+
} else {
|
|
139
|
+
pos++
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Build list of quoted string ranges to exclude
|
|
144
|
+
const quoteRanges = getQuoteRanges(fullContent)
|
|
145
|
+
|
|
146
|
+
// Comparison operators for detecting string comparison context
|
|
147
|
+
const comparisonOps = ['===', '!==', '==', '!=']
|
|
148
|
+
|
|
149
|
+
// Find and replace bare refs, skipping those inside ${...} or quoted strings
|
|
150
|
+
// Only quote bare refs that are in string comparison context
|
|
151
|
+
const simpleBarePat = /([a-zA-Z_][a-zA-Z0-9_]*(?:[.:][a-zA-Z_][a-zA-Z0-9_]*)+)/g
|
|
152
|
+
let offset = 0
|
|
153
|
+
let match
|
|
154
|
+
while ((match = simpleBarePat.exec(fullContent)) !== null) {
|
|
155
|
+
const bareRef = match[1]
|
|
156
|
+
const matchStart = match.index
|
|
157
|
+
const matchEnd = matchStart + bareRef.length
|
|
158
|
+
|
|
159
|
+
// Skip if inside a ${...} range
|
|
160
|
+
const insideVar = varRanges.some(([s, e]) => matchStart >= s && matchEnd <= e)
|
|
161
|
+
if (insideVar) continue
|
|
162
|
+
|
|
163
|
+
// Skip if inside a quoted string
|
|
164
|
+
const insideQuote = quoteRanges.some(([s, e]) => matchStart >= s && matchEnd <= e)
|
|
165
|
+
if (insideQuote) continue
|
|
166
|
+
|
|
167
|
+
// Skip reserved words
|
|
168
|
+
if (reserved.includes(bareRef)) continue
|
|
169
|
+
|
|
170
|
+
// Check if this ref is in a string comparison context
|
|
171
|
+
const afterRef = fullContent.substring(matchEnd).trimStart()
|
|
172
|
+
const beforeRef = fullContent.substring(0, matchStart).trimEnd()
|
|
173
|
+
|
|
174
|
+
const isComparedToString = comparisonOps.some(op => {
|
|
175
|
+
// Check if followed by: op "string"
|
|
176
|
+
if (afterRef.startsWith(op)) {
|
|
177
|
+
const afterOp = afterRef.substring(op.length).trimStart()
|
|
178
|
+
return afterOp.startsWith('"') || afterOp.startsWith("'")
|
|
179
|
+
}
|
|
180
|
+
// Check if preceded by: "string" op
|
|
181
|
+
for (const o of comparisonOps) {
|
|
182
|
+
const pattern = new RegExp(`["'][^"']*["']\\s*${o.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`)
|
|
183
|
+
if (pattern.test(beforeRef)) return true
|
|
184
|
+
}
|
|
185
|
+
return false
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
// Replace with var ref - quoted if string comparison, unquoted otherwise
|
|
189
|
+
const replacement = isComparedToString
|
|
190
|
+
? `"${varPrefix}${bareRef}${varSuffix}"`
|
|
191
|
+
: `${varPrefix}${bareRef}${varSuffix}`
|
|
192
|
+
processed = processed.substring(0, matchStart + offset) + replacement + processed.substring(matchEnd + offset)
|
|
193
|
+
offset += replacement.length - bareRef.length
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 2. Quote unquoted ${...} refs that are used in string comparisons
|
|
197
|
+
// Pattern: ref followed by comparison operator and string, or string followed by operator and ref
|
|
198
|
+
// e.g., ${foo} === "bar" or "bar" === ${foo}
|
|
199
|
+
// Find ${...} refs that are in comparison context
|
|
200
|
+
pos = 0
|
|
201
|
+
let newProcessed = ''
|
|
202
|
+
while (pos < processed.length) {
|
|
203
|
+
if (processed.substring(pos, pos + prefixLen) === varPrefix) {
|
|
204
|
+
const precededByQuote = pos > 0 && processed[pos - 1] === '"'
|
|
205
|
+
|
|
206
|
+
// Find matching suffix
|
|
207
|
+
let varDepth = 1
|
|
208
|
+
let endPos = pos + prefixLen
|
|
209
|
+
while (endPos < processed.length && varDepth > 0) {
|
|
210
|
+
if (processed.substring(endPos, endPos + prefixLen) === varPrefix) {
|
|
211
|
+
varDepth++
|
|
212
|
+
endPos += prefixLen
|
|
213
|
+
} else if (processed.substring(endPos, endPos + suffixLen) === varSuffix) {
|
|
214
|
+
varDepth--
|
|
215
|
+
if (varDepth > 0) endPos += suffixLen
|
|
216
|
+
} else {
|
|
217
|
+
endPos++
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
endPos += suffixLen
|
|
221
|
+
|
|
222
|
+
const varRef = processed.substring(pos, endPos)
|
|
223
|
+
const followedByQuote = endPos < processed.length && processed[endPos] === '"'
|
|
224
|
+
|
|
225
|
+
// Check if this ref is in a string comparison context
|
|
226
|
+
const afterRef = processed.substring(endPos).trimStart()
|
|
227
|
+
const beforeRef = processed.substring(0, pos).trimEnd()
|
|
228
|
+
|
|
229
|
+
const isComparedToString = comparisonOps.some(op => {
|
|
230
|
+
// Check if followed by: op "string"
|
|
231
|
+
if (afterRef.startsWith(op)) {
|
|
232
|
+
const afterOp = afterRef.substring(op.length).trimStart()
|
|
233
|
+
return afterOp.startsWith('"') || afterOp.startsWith("'")
|
|
234
|
+
}
|
|
235
|
+
// Check if preceded by: "string" op
|
|
236
|
+
for (const o of comparisonOps) {
|
|
237
|
+
const pattern = new RegExp(`["'][^"']*["']\\s*${o.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`)
|
|
238
|
+
if (pattern.test(beforeRef)) return true
|
|
239
|
+
}
|
|
240
|
+
return false
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
if (!precededByQuote && !followedByQuote && isComparedToString) {
|
|
244
|
+
newProcessed += '"' + varRef + '"'
|
|
245
|
+
} else {
|
|
246
|
+
newProcessed += varRef
|
|
247
|
+
}
|
|
248
|
+
pos = endPos
|
|
249
|
+
} else {
|
|
250
|
+
newProcessed += processed[pos]
|
|
251
|
+
pos++
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
processed = newProcessed
|
|
255
|
+
|
|
256
|
+
// Reconstruct
|
|
257
|
+
result = result.substring(0, contentStart) + processed + result.substring(j)
|
|
258
|
+
i = contentStart + processed.length + suffixLen
|
|
259
|
+
} else {
|
|
260
|
+
i = ifStart + prefixLen
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return result
|
|
265
|
+
}
|
|
266
|
+
|
|
54
267
|
/**
|
|
55
268
|
* Fix malformed fallback references in a string
|
|
56
269
|
* @param {string} str - String potentially containing variables
|
|
@@ -154,9 +367,11 @@ function preProcess(configObject, variableSyntax, variableTypes) {
|
|
|
154
367
|
*/
|
|
155
368
|
function traverseAndFix(obj) {
|
|
156
369
|
if (typeof obj === 'string') {
|
|
157
|
-
// First escape help() variables, then fix fallbacks
|
|
370
|
+
// First escape help() variables, convert bare refs in if(), then fix fallbacks
|
|
158
371
|
const withHelpEscaped = escapeHelpVariables(obj)
|
|
159
|
-
|
|
372
|
+
const withBareRefsConverted = convertBareRefsInIf(withHelpEscaped)
|
|
373
|
+
// Skip fallback fixing for object configs (they handle bare refs differently)
|
|
374
|
+
return skipFallbackFix ? withBareRefsConverted : fixFallbacksInString(withBareRefsConverted)
|
|
160
375
|
}
|
|
161
376
|
|
|
162
377
|
if (Array.isArray(obj)) {
|
|
@@ -19,8 +19,12 @@ function resolveFilePath(pathToResolve, basePath) {
|
|
|
19
19
|
fullFilePath = fs.realpathSync(fullFilePath)
|
|
20
20
|
// Only use findUp for relative paths (not absolute paths)
|
|
21
21
|
} else if (!path.isAbsolute(pathToResolve)) {
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
// Strip ./ and ../ prefixes for findUp, but preserve directory structure like utils/
|
|
23
|
+
let searchPath = pathToResolve
|
|
24
|
+
while (searchPath.startsWith('./') || searchPath.startsWith('../')) {
|
|
25
|
+
searchPath = searchPath.replace(/^\.\.?\//, '')
|
|
26
|
+
}
|
|
27
|
+
const findUpResult = findUp.sync(searchPath, { cwd: basePath })
|
|
24
28
|
if (findUpResult) {
|
|
25
29
|
fullFilePath = findUpResult
|
|
26
30
|
}
|
|
@@ -109,6 +109,24 @@ test('resolveFilePath - relative path without ./ prefix triggers findUp', () =>
|
|
|
109
109
|
assert.is(result, expected)
|
|
110
110
|
})
|
|
111
111
|
|
|
112
|
+
test('resolveFilePath - preserves directory structure when using findUp', () => {
|
|
113
|
+
// Create additional structure for this test:
|
|
114
|
+
// _test-getFullFilePath/
|
|
115
|
+
// config.yml <- WRONG file
|
|
116
|
+
// utils/
|
|
117
|
+
// config.yml <- CORRECT file
|
|
118
|
+
// subdir/deepdir/ <- searching from here
|
|
119
|
+
const utilsDir = path.join(testDir, 'utils')
|
|
120
|
+
fs.mkdirSync(utilsDir, { recursive: true })
|
|
121
|
+
fs.writeFileSync(path.join(utilsDir, 'config.yml'), 'correct: true')
|
|
122
|
+
|
|
123
|
+
// From deepDir, request "utils/config.yml" - should find testDir/utils/config.yml
|
|
124
|
+
const result = resolveFilePath('utils/config.yml', deepDir)
|
|
125
|
+
const expected = path.join(utilsDir, 'config.yml')
|
|
126
|
+
assert.is(result, expected,
|
|
127
|
+
`Should preserve 'utils/' directory and find utils/config.yml, not root config.yml. Got ${result}`)
|
|
128
|
+
})
|
|
129
|
+
|
|
112
130
|
// ==========================================
|
|
113
131
|
// getFullPath - wrapper function
|
|
114
132
|
// ==========================================
|