configorama 0.6.9 → 0.6.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.
@@ -0,0 +1,567 @@
1
+ // Wizard to prompt user through config setup
2
+ const p = require('@clack/prompts')
3
+ const chalk = require('./chalk')
4
+ const dotProp = require('dot-prop')
5
+ const fs = require('fs')
6
+ const path = require('path')
7
+
8
+ /**
9
+ * Groups variables by type for wizard flow
10
+ * @param {object} uniqueVariables - The uniqueVariables from enriched metadata
11
+ * @param {object} originalConfig - The original config before resolution
12
+ * @returns {object} Grouped variables by type
13
+ */
14
+ function groupVariablesByType(uniqueVariables, originalConfig = {}) {
15
+ const grouped = {
16
+ options: [],
17
+ env: [],
18
+ self: [],
19
+ }
20
+
21
+ // Track variables we've already added to avoid duplicates
22
+ const addedVars = new Set()
23
+
24
+ for (const [varKey, varData] of Object.entries(uniqueVariables)) {
25
+ const { variable, variableType, isRequired, defaultValue, defaultValueSrc, occurrences, innerVariables, hasValue } = varData
26
+
27
+ // Handle top-level variables (not file/text types)
28
+ if (variableType !== 'file' && variableType !== 'text') {
29
+ // Skip if has a value (for self refs and dot.prop)
30
+ if (hasValue === true) {
31
+ continue
32
+ }
33
+
34
+ // For self/dot.prop refs, skip if they have a defaultValueSrc (means they have a static value in config)
35
+ if ((variableType === 'self' || variableType === 'dot.prop') && defaultValueSrc) {
36
+ continue
37
+ }
38
+
39
+ // For self/dot.prop refs, skip if ANY occurrence has a fallback
40
+ // This means the variable has a fallback chain and will resolve naturally
41
+ if (variableType === 'self' || variableType === 'dot.prop') {
42
+ const hasAnyFallback = occurrences.some(occ => occ.hasFallback)
43
+ if (hasAnyFallback) {
44
+ continue
45
+ }
46
+
47
+ // Skip if this variable only appears as part of another variable's fallback chain
48
+ // (i.e., appears in 'value' field but not as 'originalString')
49
+ const isOnlyFallback = occurrences.every(occ => {
50
+ // If it has originalString, it's a top-level variable
51
+ if (occ.originalString) return false
52
+ // If it appears in a 'value' field, it's part of a fallback chain
53
+ if (occ.value && occ.value.includes(',')) return true
54
+ return false
55
+ })
56
+ if (isOnlyFallback) {
57
+ continue
58
+ }
59
+
60
+ // Skip if the variable path itself contains unresolved variables
61
+ // e.g., self:stages.${opt:stage}.SECRET - can't set this in config because path is dynamic
62
+ const cleanPath = variable.replace(/^self:/, '')
63
+ if (cleanPath.includes('${')) {
64
+ continue
65
+ }
66
+
67
+ // Also check if value exists in original config
68
+ const configValue = dotProp.get(originalConfig, cleanPath)
69
+ if (configValue !== undefined) {
70
+ // Value exists in config, skip prompting
71
+ continue
72
+ }
73
+ }
74
+
75
+ const cleanName = variable.replace(/^(opt|env|self):/, '')
76
+
77
+ if (!addedVars.has(variable)) {
78
+ addedVars.add(variable)
79
+
80
+ // Check if ANY occurrence is required without fallback
81
+ const hasRequiredOccurrence = occurrences.some(occ => occ.isRequired && !occ.hasFallback)
82
+
83
+ // Check if ANY occurrence has a default/fallback value
84
+ const fallbackOccurrence = occurrences.find(occ => occ.hasFallback && occ.defaultValue)
85
+ const availableDefault = fallbackOccurrence?.defaultValue || defaultValue
86
+
87
+ const varInfo = {
88
+ key: varKey,
89
+ variable,
90
+ cleanName,
91
+ variableType,
92
+ isRequired: hasRequiredOccurrence,
93
+ defaultValue: availableDefault,
94
+ hasFallback: !!availableDefault,
95
+ occurrences: occurrences || [],
96
+ }
97
+
98
+ if (variableType === 'options') {
99
+ grouped.options.push(varInfo)
100
+ } else if (variableType === 'env') {
101
+ grouped.env.push(varInfo)
102
+ } else if (variableType === 'self') {
103
+ grouped.self.push(varInfo)
104
+ }
105
+ }
106
+ }
107
+
108
+ // Extract inner variables from file/text references
109
+ if (innerVariables && Array.isArray(innerVariables)) {
110
+ for (const innerVar of innerVariables) {
111
+ // Skip if already has a value
112
+ if (innerVar.hasValue) {
113
+ continue
114
+ }
115
+
116
+ // Skip if not required
117
+ if (!innerVar.isRequired) {
118
+ continue
119
+ }
120
+
121
+ const cleanName = innerVar.variable.replace(/^(opt|env|self):/, '')
122
+
123
+ // If already added, append this occurrence to existing variable
124
+ const existingVarIndex = grouped[innerVar.variableType === 'options' ? 'options' : innerVar.variableType === 'env' ? 'env' : 'self']
125
+ .findIndex(v => v.variable === innerVar.variable)
126
+
127
+ if (existingVarIndex >= 0) {
128
+ // Add this occurrence to the existing variable
129
+ const varList = innerVar.variableType === 'options' ? grouped.options : innerVar.variableType === 'env' ? grouped.env : grouped.self
130
+ const existingVar = varList[existingVarIndex]
131
+
132
+ // Add occurrence from parent file variable
133
+ if (occurrences && occurrences.length > 0) {
134
+ existingVar.occurrences.push(...occurrences)
135
+ }
136
+ continue
137
+ }
138
+
139
+ // Skip if already added to the set
140
+ if (addedVars.has(innerVar.variable)) {
141
+ continue
142
+ }
143
+
144
+ addedVars.add(innerVar.variable)
145
+
146
+ const varInfo = {
147
+ key: innerVar.variable,
148
+ variable: innerVar.variable,
149
+ cleanName,
150
+ variableType: innerVar.variableType,
151
+ isRequired: innerVar.isRequired,
152
+ defaultValue: innerVar.defaultValue,
153
+ occurrences: occurrences ? [...occurrences] : [], // Use parent file variable occurrences
154
+ }
155
+
156
+ if (innerVar.variableType === 'options') {
157
+ grouped.options.push(varInfo)
158
+ } else if (innerVar.variableType === 'env') {
159
+ grouped.env.push(varInfo)
160
+ } else if (innerVar.variableType === 'self') {
161
+ grouped.self.push(varInfo)
162
+ }
163
+ }
164
+ }
165
+ }
166
+
167
+ return grouped
168
+ }
169
+
170
+ /**
171
+ * Detects if a variable name suggests sensitive data
172
+ * @param {string} name - Variable name
173
+ * @returns {boolean} True if likely sensitive
174
+ */
175
+ function isSensitiveVariable(name) {
176
+ const sensitivePatterns = [
177
+ /secret/i,
178
+ /password/i,
179
+ /token/i,
180
+ /key/i,
181
+ /credential/i,
182
+ /auth/i,
183
+ ]
184
+ return sensitivePatterns.some(pattern => pattern.test(name))
185
+ }
186
+
187
+ /**
188
+ * Validates input value based on expected type
189
+ * @param {string} value - Input value
190
+ * @param {string} expectedType - Expected type (String, Number, Boolean, Json)
191
+ * @returns {string|undefined} Error message if invalid, undefined if valid
192
+ */
193
+ function validateType(value, expectedType) {
194
+ if (!expectedType || !value) return
195
+
196
+ switch (expectedType) {
197
+ case 'Boolean':
198
+ const lowerVal = value.toLowerCase()
199
+ const validBooleans = ['true', 'false', 'yes', 'no', 'on', 'off', '1', '0']
200
+ if (!validBooleans.includes(lowerVal)) {
201
+ return `Must be a boolean value (true/false, yes/no, on/off, 1/0)`
202
+ }
203
+ break
204
+
205
+ case 'Number':
206
+ if (isNaN(Number(value))) {
207
+ return `Must be a valid number`
208
+ }
209
+ break
210
+
211
+ case 'Json':
212
+ try {
213
+ JSON.parse(value)
214
+ } catch (e) {
215
+ return `Must be valid JSON`
216
+ }
217
+ break
218
+
219
+ case 'String':
220
+ // String is always valid
221
+ break
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Extracts type from variable occurrences
227
+ * @param {Array} occurrences - Variable occurrences
228
+ * @returns {string|null} Expected type or null
229
+ */
230
+ function getExpectedType(occurrences) {
231
+ if (!occurrences || occurrences.length === 0) return null
232
+
233
+ for (const occ of occurrences) {
234
+ if (occ.filters && Array.isArray(occ.filters)) {
235
+ for (const filter of occ.filters) {
236
+ // Check if filter starts with uppercase letter
237
+ if (filter && typeof filter === 'string' && /^[A-Z]/.test(filter)) {
238
+ return filter
239
+ }
240
+ }
241
+ }
242
+ }
243
+ return null
244
+ }
245
+
246
+ /**
247
+ * Extracts help text from variable occurrences
248
+ * @param {Array} occurrences - Variable occurrences
249
+ * @returns {string|null} Help text or null
250
+ */
251
+ function getHelpText(occurrences) {
252
+ if (!occurrences || occurrences.length === 0) return null
253
+
254
+ for (const occ of occurrences) {
255
+ // Check for description field first (preferred)
256
+ if (occ.description) {
257
+ return occ.description
258
+ }
259
+
260
+ // Fallback to checking filters array (for backwards compatibility)
261
+ if (occ.filters && Array.isArray(occ.filters)) {
262
+ for (const filter of occ.filters) {
263
+ // Check if filter has help() syntax
264
+ const helpMatch = filter.match(/^help\(['"](.+)['"]\)$/)
265
+ if (helpMatch) {
266
+ return helpMatch[1]
267
+ }
268
+ }
269
+ }
270
+ }
271
+ return null
272
+ }
273
+
274
+ /**
275
+ * Creates a human-readable prompt message
276
+ * @param {object} varInfo - Variable info
277
+ * @returns {string} Prompt message
278
+ */
279
+ function createPromptMessage(varInfo) {
280
+ const { cleanName, variableType, occurrences } = varInfo
281
+
282
+ let typeLabel
283
+ if (variableType === 'options') {
284
+ typeLabel = 'Flag'
285
+ } else if (variableType === 'env') {
286
+ typeLabel = 'Env'
287
+ } else if (variableType === 'self') {
288
+ typeLabel = 'Config'
289
+ } else {
290
+ typeLabel = 'Value'
291
+ }
292
+
293
+ // Check for type from filters
294
+ const expectedType = getExpectedType(occurrences)
295
+
296
+ // Append type to label if found
297
+ if (expectedType) {
298
+ typeLabel = `${typeLabel}:${expectedType}`
299
+ }
300
+
301
+ // Collect all unique descriptions from occurrences
302
+ const descriptions = []
303
+ if (occurrences && occurrences.length > 0) {
304
+ occurrences.forEach(occ => {
305
+ if (occ.description && !descriptions.includes(occ.description)) {
306
+ descriptions.push(occ.description)
307
+ }
308
+ })
309
+ }
310
+
311
+ // Build context from all occurrences
312
+ let contextHint = ''
313
+
314
+ // Show combined descriptions if available
315
+ if (descriptions.length > 0) {
316
+ contextHint = ` - ${descriptions.join('. ')}`
317
+ }
318
+
319
+ // Show usage list if there are occurrences
320
+ if (occurrences && occurrences.length > 0) {
321
+ // Parse occurrences into key-value pairs with descriptions
322
+ const parsedOccurrences = occurrences.map(occ => {
323
+ const keyPath = occ.path
324
+ let originalValue = occ.value || occ.originalString || occ.varMatch
325
+
326
+ // Strip help() filter from the displayed value
327
+ if (originalValue && typeof originalValue === 'string') {
328
+ // Remove | help('...') including nested parens
329
+ originalValue = originalValue.replace(/\s*\|\s*help\([^)]*(?:\([^)]*\))?[^)]*\)/g, '')
330
+ }
331
+
332
+ if (keyPath && originalValue) {
333
+ return {
334
+ key: keyPath,
335
+ value: originalValue,
336
+ description: occ.description
337
+ }
338
+ } else if (keyPath) {
339
+ return {
340
+ key: keyPath,
341
+ value: '',
342
+ description: occ.description
343
+ }
344
+ }
345
+ return null
346
+ }).filter(Boolean)
347
+
348
+ if (parsedOccurrences.length > 0) {
349
+ // Get the variable reference syntax
350
+ const varPrefix = variableType === 'options' ? 'opt' : variableType === 'env' ? 'env' : 'self'
351
+ const varSyntax = `\${${varPrefix}:${cleanName}}`
352
+
353
+ // Show variable syntax and count (only if no descriptions, otherwise it's redundant)
354
+ if (descriptions.length === 0) {
355
+ contextHint = ` - ${varSyntax} - Used in ${parsedOccurrences.length} ${parsedOccurrences.length === 1 ? 'place' : 'places'}`
356
+ }
357
+
358
+ // Find longest key for alignment
359
+ const maxKeyLength = Math.max(...parsedOccurrences.map(o => o.key.length))
360
+
361
+ // List all occurrences with bullets and aligned values (using invisible unicode for indentation)
362
+ const indent = '\u2800\u2800\u2800' // Braille blank pattern (invisible but not stripped)
363
+ const usageList = parsedOccurrences.map(({ key, value, description }, index) => {
364
+ const padding = ' '.repeat(maxKeyLength - key.length)
365
+ const leadingEmptyLine = index === 0 ? '│\n' : ''
366
+ // Only show inline description if there are multiple occurrences (otherwise it's redundant with header)
367
+ const descComment = description && parsedOccurrences.length > 1 ? ` - # ${description}` : ''
368
+ return value ? `${leadingEmptyLine}│${indent}- ${key}:${padding} ${value}${descComment}` : `${leadingEmptyLine}│${indent}• ${key}${descComment}`
369
+ })
370
+ contextHint += '\n' + usageList.join('\n') + '\n│'
371
+ }
372
+ }
373
+
374
+ const message = `[${typeLabel}] ${cleanName}${chalk.gray(contextHint)}`
375
+
376
+ return message
377
+ }
378
+
379
+ /**
380
+ * Runs config setup wizard
381
+ * @param {object} metadata - Enriched metadata from configorama
382
+ * @param {object} originalConfig - The original config before resolution
383
+ * @returns {Promise<object>} User inputs by variable type
384
+ */
385
+ async function runConfigWizard(metadata, originalConfig = {}, configFilePath = '') {
386
+ const { uniqueVariables } = metadata
387
+
388
+ if (!uniqueVariables || Object.keys(uniqueVariables).length === 0) {
389
+ p.intro(chalk.cyan('Configuration Wizard'))
390
+ if (configFilePath) {
391
+ p.note(`File: ${configFilePath}`, 'File')
392
+ }
393
+ p.outro('No variables found that require setup.')
394
+ return {}
395
+ }
396
+
397
+ const grouped = groupVariablesByType(uniqueVariables, originalConfig)
398
+ const totalVars = grouped.options.length + grouped.env.length + grouped.self.length
399
+
400
+ if (totalVars === 0) {
401
+ p.intro(chalk.cyan('Configuration Wizard'))
402
+ p.outro('No variables found that require setup.')
403
+ return {}
404
+ }
405
+
406
+ p.intro(chalk.cyan('Configuration Wizard'))
407
+ if (configFilePath) {
408
+ p.note(`Processing config file: ${configFilePath}`)
409
+ }
410
+
411
+ const userInputs = {
412
+ options: {},
413
+ env: {},
414
+ self: {},
415
+ }
416
+
417
+ // Prompt for options (CLI flags)
418
+ if (grouped.options.length > 0) {
419
+ const flagsList = grouped.options.map(v => {
420
+ const varSyntax = `\${opt:${v.cleanName}}`
421
+ return ` - ${varSyntax}`
422
+ }).join('\n')
423
+ const noteContent = `Found ${grouped.options.length} CLI flag(s)\n${flagsList}`
424
+ p.note(noteContent, 'CLI Flags')
425
+
426
+ for (const varInfo of grouped.options) {
427
+ const message = createPromptMessage(varInfo)
428
+ const isSensitive = isSensitiveVariable(varInfo.cleanName)
429
+ const promptFn = isSensitive ? p.password : p.text
430
+ const expectedType = getExpectedType(varInfo.occurrences)
431
+
432
+ const placeholder = varInfo.hasFallback
433
+ ? `${varInfo.defaultValue} (default)`
434
+ : `Enter value for --${varInfo.cleanName}`
435
+
436
+ const value = await promptFn({
437
+ message,
438
+ placeholder,
439
+ validate: (val) => {
440
+ // Only required if no fallback exists
441
+ if (!val && varInfo.isRequired && !varInfo.hasFallback) {
442
+ return 'This value is required'
443
+ }
444
+ // Type validation
445
+ const typeError = validateType(val, expectedType)
446
+ if (typeError) return typeError
447
+ }
448
+ })
449
+
450
+ if (p.isCancel(value)) {
451
+ p.cancel('Setup cancelled')
452
+ process.exit(0)
453
+ }
454
+
455
+ userInputs.options[varInfo.cleanName] = value || varInfo.defaultValue
456
+ }
457
+ }
458
+
459
+ // Prompt for environment variables
460
+ if (grouped.env.length > 0) {
461
+ const envList = grouped.env.map(v => {
462
+ const varSyntax = `\${env:${v.cleanName}}`
463
+ return ` - ${varSyntax}`
464
+ }).join('\n')
465
+ const noteContent = `Found ${grouped.env.length} environment variable(s)\n${envList}`
466
+ p.note(noteContent, 'Environment Variables')
467
+
468
+ for (const varInfo of grouped.env) {
469
+ const message = createPromptMessage(varInfo)
470
+ const isSensitive = isSensitiveVariable(varInfo.cleanName)
471
+ const promptFn = isSensitive ? p.password : p.text
472
+ const expectedType = getExpectedType(varInfo.occurrences)
473
+
474
+ const placeholder = varInfo.hasFallback
475
+ ? `${varInfo.defaultValue} (default)`
476
+ : `Enter environment variable for ${varInfo.cleanName}`
477
+
478
+ const value = await promptFn({
479
+ message,
480
+ placeholder,
481
+ validate: (val) => {
482
+ // Only required if no fallback exists
483
+ if (!val && varInfo.isRequired && !varInfo.hasFallback) {
484
+ return 'This value is required'
485
+ }
486
+ // Type validation
487
+ const typeError = validateType(val, expectedType)
488
+ if (typeError) return typeError
489
+ }
490
+ })
491
+
492
+ if (p.isCancel(value)) {
493
+ p.cancel('Setup cancelled')
494
+ process.exit(0)
495
+ }
496
+
497
+ userInputs.env[varInfo.cleanName] = value || varInfo.defaultValue
498
+ }
499
+ }
500
+
501
+ // Prompt for self references (if any need values)
502
+ if (grouped.self.length > 0) {
503
+ const selfList = grouped.self.map(v => {
504
+ const varSyntax = `\${self:${v.cleanName}}`
505
+ return ` - ${varSyntax}`
506
+ }).join('\n')
507
+ const noteContent = `Found ${grouped.self.length} config reference(s)\n${selfList}`
508
+ p.note(noteContent, 'Config References')
509
+
510
+ for (const varInfo of grouped.self) {
511
+ const message = createPromptMessage(varInfo)
512
+ const isSensitive = isSensitiveVariable(varInfo.cleanName)
513
+ const promptFn = isSensitive ? p.password : p.text
514
+ const expectedType = getExpectedType(varInfo.occurrences)
515
+
516
+ const placeholder = varInfo.hasFallback
517
+ ? `${varInfo.defaultValue} (default)`
518
+ : `Enter value for ${varInfo.cleanName}`
519
+
520
+ const value = await promptFn({
521
+ message,
522
+ placeholder,
523
+ validate: (val) => {
524
+ // Only required if no fallback exists
525
+ if (!val && varInfo.isRequired && !varInfo.hasFallback) {
526
+ return 'This value is required'
527
+ }
528
+ // Type validation
529
+ const typeError = validateType(val, expectedType)
530
+ if (typeError) return typeError
531
+ }
532
+ })
533
+
534
+ if (p.isCancel(value)) {
535
+ p.cancel('Setup cancelled')
536
+ process.exit(0)
537
+ }
538
+
539
+ userInputs.self[varInfo.cleanName] = value || varInfo.defaultValue
540
+ }
541
+ }
542
+
543
+ p.outro(chalk.green('Setup complete!'))
544
+
545
+ // Remove empty sections
546
+ if (Object.keys(userInputs.options).length === 0) {
547
+ delete userInputs.options
548
+ }
549
+ if (Object.keys(userInputs.env).length === 0) {
550
+ delete userInputs.env
551
+ }
552
+ if (Object.keys(userInputs.self).length === 0) {
553
+ delete userInputs.self
554
+ }
555
+
556
+ return userInputs
557
+ }
558
+
559
+ module.exports = {
560
+ runConfigWizard,
561
+ groupVariablesByType,
562
+ isSensitiveVariable,
563
+ createPromptMessage,
564
+ getExpectedType,
565
+ getHelpText,
566
+ validateType,
567
+ }
@@ -0,0 +1,15 @@
1
+ const { decodeJsSyntax, hasParenthesesPlaceholder } = require('./js-fixes')
2
+ const { decodeUnknown, hasEncodedUnknown } = require('./unknown-values')
3
+
4
+ // Generic decoder for all encoded values
5
+ function decodeEncodedValue(value) {
6
+ if (hasParenthesesPlaceholder(value)) {
7
+ return decodeJsSyntax(value)
8
+ }
9
+ if (hasEncodedUnknown(value)) {
10
+ return decodeUnknown(value)
11
+ }
12
+ return value
13
+ }
14
+
15
+ module.exports = { decodeEncodedValue }
@@ -0,0 +1,22 @@
1
+ const PAREN_OPEN_PLACEHOLDER = '__PH_PAREN_OPEN__'
2
+ const OPEN_PAREN_PLACEHOLDER_PATTERN = /__PH_PAREN_OPEN__/g
3
+
4
+ function encodeJsSyntax(value = '') {
5
+ return value.replace(/\(/g, PAREN_OPEN_PLACEHOLDER)
6
+ }
7
+
8
+ function decodeJsSyntax(value) {
9
+ if (!value) return value
10
+ return value.replace(OPEN_PAREN_PLACEHOLDER_PATTERN, '(')
11
+ }
12
+
13
+ function hasParenthesesPlaceholder(value = '') {
14
+ return OPEN_PAREN_PLACEHOLDER_PATTERN.test(value)
15
+ }
16
+
17
+ module.exports = {
18
+ OPEN_PAREN_PLACEHOLDER_PATTERN,
19
+ hasParenthesesPlaceholder,
20
+ encodeJsSyntax,
21
+ decodeJsSyntax,
22
+ }
@@ -1,3 +1,6 @@
1
+ const PASSTHROUGH_PREFIX = '>passthrough'
2
+ const PASSTHROUGH_PATTERN = />passthrough/g
3
+
1
4
  /**
2
5
  * Encode unknown variable for passthrough
3
6
  */
@@ -5,12 +8,16 @@ function encodeUnknown(v) {
5
8
  return `>passthrough[_[${Buffer.from(v).toString('base64')}]_]`
6
9
  }
7
10
 
11
+ function hasEncodedUnknown(value) {
12
+ return PASSTHROUGH_PATTERN.test(value)
13
+ }
14
+
8
15
  /**
9
16
  * Decode unknown variable from passthrough
10
17
  */
11
18
  function decodeUnknown(rawValue) {
12
19
  const x = findUnknownValues(rawValue)
13
- let val = rawValue.replace(/>passthrough/g, '')
20
+ let val = rawValue.replace(PASSTHROUGH_PATTERN, '')
14
21
  if (x.length) {
15
22
  x.forEach(({ match, value }) => {
16
23
  const decodedValue = Buffer.from(value, 'base64').toString('ascii')
@@ -40,6 +47,8 @@ function findUnknownValues(text) {
40
47
  }
41
48
 
42
49
  module.exports = {
50
+ PASSTHROUGH_PATTERN,
51
+ hasEncodedUnknown,
43
52
  encodeUnknown,
44
53
  decodeUnknown,
45
54
  findUnknownValues