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.
- package/README.md +429 -123
- package/cli.js +282 -49
- package/index.d.ts +43 -1
- package/package.json +5 -1
- package/src/capabilities.js +59 -0
- package/src/capabilities.test.js +44 -0
- package/src/display.js +70 -7
- package/src/display.test.js +82 -0
- package/src/errors.js +73 -0
- package/src/index.js +91 -1
- package/src/main.js +117 -15
- package/src/resolvers/valueFromEval.js +11 -1
- package/src/resolvers/valueFromFile.js +5 -0
- package/src/resolvers/valueFromGit.js +43 -17
- package/src/resolvers/valueFromOptions.js +5 -4
- package/src/utils/filters/filterArgs.js +57 -0
- package/src/utils/filters/oneOf.js +77 -0
- package/src/utils/introspection/audit.js +78 -0
- package/src/utils/introspection/graph.js +43 -0
- package/src/utils/introspection/model.js +150 -0
- package/src/utils/introspection/model.test.js +93 -0
- package/src/utils/parsing/commentAnnotations.js +107 -0
- package/src/utils/parsing/commentAnnotations.test.js +123 -0
- package/src/utils/parsing/enrichMetadata.js +64 -1
- package/src/utils/parsing/enrichMetadata.test.js +84 -0
- package/src/utils/parsing/extractComment.js +145 -0
- package/src/utils/parsing/extractComment.test.js +182 -0
- package/src/utils/parsing/preProcess.js +2 -1
- package/src/utils/paths/findLineForKey.js +2 -2
- package/src/utils/paths/ignorePaths.js +21 -9
- package/src/utils/redaction/redact.js +78 -0
- package/src/utils/redaction/redact.test.js +38 -0
- package/src/utils/redaction/setupRedaction.js +47 -0
- package/src/utils/redaction/setupRedaction.test.js +68 -0
- package/src/utils/requirements/configRequirements.js +351 -0
- package/src/utils/requirements/configRequirements.test.js +380 -0
- package/src/utils/requirements/serializeRequirements.js +120 -0
- package/src/utils/requirements/serializeRequirements.test.js +211 -0
- package/src/utils/security/evalSafety.js +86 -0
- package/src/utils/security/evalSafety.test.js +61 -0
- package/src/utils/security/safetyPolicy.js +110 -0
- package/src/utils/security/safetyPolicy.test.js +29 -0
- package/src/utils/strings/didYouMean.js +70 -0
- package/src/utils/strings/didYouMean.test.js +52 -0
- package/src/utils/strings/formatFunctionArgs.js +6 -1
- package/src/utils/strings/splitByComma.js +5 -0
- package/src/utils/ui/configWizard.js +208 -34
- package/src/utils/ui/createEditorLink.js +17 -1
- package/src/utils/ui/promptDescriptors.js +196 -0
- package/src/utils/ui/promptDescriptors.test.js +162 -0
- package/src/utils/variables/cleanVariable.js +22 -0
- 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 ||
|
|
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
|
|
506
|
-
|
|
507
|
-
|
|
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 ||
|
|
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 ||
|
|
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
|
|
848
|
-
if (
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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 (!
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
193
|
-
const url = await
|
|
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
|
|
229
|
+
value = await gitRemote()
|
|
204
230
|
break
|
|
205
231
|
// Current commit sha
|
|
206
232
|
case 'sha':
|
|
207
233
|
case 'sha1':
|
|
208
|
-
value = await
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
276
|
+
const writeTree = await gitExec(['write-tree'])
|
|
251
277
|
if (!writeTree) return undefined
|
|
252
|
-
const changes = await
|
|
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
|
-
|
|
16
|
-
|
|
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
|
+
}
|