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
|
@@ -3,6 +3,8 @@ const fs = require('fs')
|
|
|
3
3
|
const path = require('path')
|
|
4
4
|
const { normalizePath, extractFilePath, normalizeFileVariable, resolveInnerVariables } = require('../paths/filePathUtils')
|
|
5
5
|
const { preResolveString, preResolveSingle } = require('../resolution/preResolveVariable')
|
|
6
|
+
const { parseOneOfFilter } = require('../filters/oneOf')
|
|
7
|
+
const { extractComment } = require('./extractComment')
|
|
6
8
|
|
|
7
9
|
// Type filters that indicate expected value types
|
|
8
10
|
const TYPE_FILTERS = ['Boolean', 'String', 'Number', 'Array', 'Object', 'Json']
|
|
@@ -61,6 +63,8 @@ function createOccurrence(instance, varMatch, options = {}) {
|
|
|
61
63
|
|
|
62
64
|
// Extract type from filters
|
|
63
65
|
const type = extractTypeFromFilters(filters)
|
|
66
|
+
const oneOfFilter = filters && filters.find(filter => typeof filter === 'string' && filter.match(/^oneOf\(/))
|
|
67
|
+
const oneOf = parseOneOfFilter(oneOfFilter)
|
|
64
68
|
|
|
65
69
|
const occurrence = {
|
|
66
70
|
originalString: instance.originalStringValue,
|
|
@@ -79,6 +83,11 @@ function createOccurrence(instance, varMatch, options = {}) {
|
|
|
79
83
|
|
|
80
84
|
if (description) {
|
|
81
85
|
occurrence.description = description
|
|
86
|
+
occurrence.descriptionSource = 'help'
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (oneOf && oneOf.allowedValues) {
|
|
90
|
+
occurrence.allowedValues = oneOf.allowedValues
|
|
82
91
|
}
|
|
83
92
|
|
|
84
93
|
if (instance.defaultValueSrc) {
|
|
@@ -133,6 +142,56 @@ async function enrichMetadata(
|
|
|
133
142
|
// Create resolve context early for resolving descriptions
|
|
134
143
|
const configDir = configPath ? path.dirname(configPath) : undefined
|
|
135
144
|
const resolveContext = { config: originalConfig, variableSyntax, configDir, options }
|
|
145
|
+
const commentFileType = configPath ? path.extname(configPath) : ''
|
|
146
|
+
let commentLines = []
|
|
147
|
+
if (configPath) {
|
|
148
|
+
try {
|
|
149
|
+
commentLines = fs.readFileSync(configPath, 'utf8').split('\n')
|
|
150
|
+
} catch (error) {
|
|
151
|
+
commentLines = []
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function applyCommentFallback(occurrence) {
|
|
156
|
+
if (!occurrence) return occurrence
|
|
157
|
+
const comment = extractComment(occurrence.path, commentLines, commentFileType)
|
|
158
|
+
if (comment) {
|
|
159
|
+
if (comment.description && (!occurrence.description || comment.descriptionSource === 'commentTag')) {
|
|
160
|
+
occurrence.description = comment.description
|
|
161
|
+
occurrence.descriptionSource = comment.descriptionSource
|
|
162
|
+
}
|
|
163
|
+
if (comment.obtainHint) occurrence.obtainHint = comment.obtainHint
|
|
164
|
+
if (comment.examples) occurrence.examples = comment.examples
|
|
165
|
+
if (comment.defaultHint) occurrence.defaultHint = comment.defaultHint
|
|
166
|
+
if (comment.sensitive !== undefined) {
|
|
167
|
+
occurrence.sensitive = comment.sensitive
|
|
168
|
+
occurrence.sensitiveSource = 'commentTag'
|
|
169
|
+
}
|
|
170
|
+
if (comment.group) occurrence.group = comment.group
|
|
171
|
+
if (comment.deprecationMessage) occurrence.deprecationMessage = comment.deprecationMessage
|
|
172
|
+
}
|
|
173
|
+
return occurrence
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function applyDynamicOneOfAllowedValues(occurrence) {
|
|
177
|
+
if (!occurrence || occurrence.allowedValues || !occurrence.filters) return occurrence
|
|
178
|
+
const oneOfFilter = occurrence.filters.find(filter => typeof filter === 'string' && filter.match(/^oneOf\(/))
|
|
179
|
+
if (!oneOfFilter) return occurrence
|
|
180
|
+
|
|
181
|
+
const match = oneOfFilter.match(/^oneOf\(\s*\$\{([^}]+)\}\s*\)$/)
|
|
182
|
+
if (!match) return occurrence
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
const resolved = await preResolveSingle(match[1], resolveContext)
|
|
186
|
+
if (Array.isArray(resolved)) {
|
|
187
|
+
occurrence.allowedValues = resolved.map(String)
|
|
188
|
+
}
|
|
189
|
+
} catch (error) {
|
|
190
|
+
// Leave unresolved dynamic oneOf args without allowedValues; runtime will report errors.
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return occurrence
|
|
194
|
+
}
|
|
136
195
|
|
|
137
196
|
const varKeys = Object.keys(metadata.variables)
|
|
138
197
|
|
|
@@ -439,6 +498,8 @@ async function enrichMetadata(
|
|
|
439
498
|
for (const instance of varInstances) {
|
|
440
499
|
// Add this occurrence with its full context
|
|
441
500
|
const occurrence = createOccurrence(instance, key)
|
|
501
|
+
applyCommentFallback(occurrence)
|
|
502
|
+
await applyDynamicOneOfAllowedValues(occurrence)
|
|
442
503
|
entry.occurrences.push(occurrence)
|
|
443
504
|
|
|
444
505
|
// Find inner variables in resolveDetails (excluding the outermost variable itself)
|
|
@@ -511,6 +572,8 @@ async function enrichMetadata(
|
|
|
511
572
|
hasFallback: !!detail.hasFallback,
|
|
512
573
|
defaultValue: siblingDefaultValue,
|
|
513
574
|
})
|
|
575
|
+
applyCommentFallback(siblingOccurrence)
|
|
576
|
+
await applyDynamicOneOfAllowedValues(siblingOccurrence)
|
|
514
577
|
if (siblingDefaultValueSrc) {
|
|
515
578
|
siblingOccurrence.defaultValueSrc = siblingDefaultValueSrc
|
|
516
579
|
}
|
|
@@ -763,4 +826,4 @@ async function enrichMetadata(
|
|
|
763
826
|
return metadata
|
|
764
827
|
}
|
|
765
828
|
|
|
766
|
-
module.exports = enrichMetadata
|
|
829
|
+
module.exports = enrichMetadata
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/* eslint-disable no-template-curly-in-string */
|
|
2
|
+
const { test } = require('uvu')
|
|
3
|
+
const assert = require('uvu/assert')
|
|
4
|
+
const fs = require('fs')
|
|
5
|
+
const os = require('os')
|
|
6
|
+
const path = require('path')
|
|
7
|
+
const configorama = require('../../index')
|
|
8
|
+
|
|
9
|
+
function writeTempConfig(lines) {
|
|
10
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'configorama-annotations-'))
|
|
11
|
+
const configPath = path.join(dir, 'config.yml')
|
|
12
|
+
fs.writeFileSync(configPath, lines.join('\n'))
|
|
13
|
+
return { dir, configPath }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
test('enriched metadata maps comment annotations onto occurrences', async () => {
|
|
17
|
+
const { dir, configPath } = writeTempConfig([
|
|
18
|
+
'# Stripe live secret key',
|
|
19
|
+
'# @from Stripe dashboard > Developers > API keys',
|
|
20
|
+
'# @example sk_live_...',
|
|
21
|
+
'# @default Set in CI',
|
|
22
|
+
'# @sensitive false',
|
|
23
|
+
'# @group Payments',
|
|
24
|
+
'# @deprecated Use STRIPE_RESTRICTED_KEY instead',
|
|
25
|
+
'stripeSecret: ${env:STRIPE_SECRET_KEY}',
|
|
26
|
+
])
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const analysis = await configorama.analyze(configPath, { options: {} })
|
|
30
|
+
const occurrence = analysis.uniqueVariables['env:STRIPE_SECRET_KEY'].occurrences[0]
|
|
31
|
+
|
|
32
|
+
assert.is(occurrence.description, 'Stripe live secret key')
|
|
33
|
+
assert.is(occurrence.descriptionSource, 'leadingComment')
|
|
34
|
+
assert.is(occurrence.obtainHint, 'Stripe dashboard > Developers > API keys')
|
|
35
|
+
assert.equal(occurrence.examples, ['sk_live_...'])
|
|
36
|
+
assert.is(occurrence.defaultHint, 'Set in CI')
|
|
37
|
+
assert.is(occurrence.defaultValue, undefined)
|
|
38
|
+
assert.is(occurrence.sensitive, false)
|
|
39
|
+
assert.is(occurrence.sensitiveSource, 'commentTag')
|
|
40
|
+
assert.is(occurrence.group, 'Payments')
|
|
41
|
+
assert.is(occurrence.deprecationMessage, 'Use STRIPE_RESTRICTED_KEY instead')
|
|
42
|
+
} finally {
|
|
43
|
+
fs.rmSync(dir, { recursive: true, force: true })
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('enriched metadata lets @description override help on the same occurrence', async () => {
|
|
48
|
+
const { dir, configPath } = writeTempConfig([
|
|
49
|
+
'# Plain comment loses',
|
|
50
|
+
'# @description Comment tag wins',
|
|
51
|
+
'apiKey: ${env:CONFIGORAMA_ANNOTATED_API_KEY | help("Help loses")}',
|
|
52
|
+
])
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const analysis = await configorama.analyze(configPath, { options: {} })
|
|
56
|
+
const occurrence = analysis.uniqueVariables['env:CONFIGORAMA_ANNOTATED_API_KEY'].occurrences[0]
|
|
57
|
+
|
|
58
|
+
assert.is(occurrence.description, 'Comment tag wins')
|
|
59
|
+
assert.is(occurrence.descriptionSource, 'commentTag')
|
|
60
|
+
} finally {
|
|
61
|
+
fs.rmSync(dir, { recursive: true, force: true })
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('enriched metadata keeps normal comment below help while preserving independent tags', async () => {
|
|
66
|
+
const { dir, configPath } = writeTempConfig([
|
|
67
|
+
'# Plain comment loses to help',
|
|
68
|
+
'# @from Dashboard > Tokens',
|
|
69
|
+
'apiToken: ${env:CONFIGORAMA_ANNOTATED_TOKEN | help("Help wins")}',
|
|
70
|
+
])
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const analysis = await configorama.analyze(configPath, { options: {} })
|
|
74
|
+
const occurrence = analysis.uniqueVariables['env:CONFIGORAMA_ANNOTATED_TOKEN'].occurrences[0]
|
|
75
|
+
|
|
76
|
+
assert.is(occurrence.description, 'Help wins')
|
|
77
|
+
assert.is(occurrence.descriptionSource, 'help')
|
|
78
|
+
assert.is(occurrence.obtainHint, 'Dashboard > Tokens')
|
|
79
|
+
} finally {
|
|
80
|
+
fs.rmSync(dir, { recursive: true, force: true })
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test.run()
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
const { findLineByPath, findLineForKey } = require('../paths/findLineForKey')
|
|
2
|
+
const { parseCommentAnnotations } = require('./commentAnnotations')
|
|
3
|
+
|
|
4
|
+
function getCommentMarkers(fileType) {
|
|
5
|
+
if (fileType === '.json') return []
|
|
6
|
+
if (fileType === '.json5' || fileType === '.jsonc') return ['//']
|
|
7
|
+
if (fileType === '.hcl') return ['//', '#']
|
|
8
|
+
if (['.yml', '.yaml', '.toml', '.ini'].includes(fileType)) return ['#']
|
|
9
|
+
return ['#', '//']
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function isDecorationComment(text) {
|
|
13
|
+
return /^[\s\-=_*]{3,}$/.test(text)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function stripCommentMarker(line, marker) {
|
|
17
|
+
return line.trim().slice(marker.length).replace(/^\s?/, '')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function findCommentStart(line, markers) {
|
|
21
|
+
let quote = null
|
|
22
|
+
let variableDepth = 0
|
|
23
|
+
|
|
24
|
+
for (let i = 0; i < line.length; i++) {
|
|
25
|
+
const char = line[i]
|
|
26
|
+
const prev = i > 0 ? line[i - 1] : ''
|
|
27
|
+
|
|
28
|
+
if (!quote && char === '$' && line[i + 1] === '{') {
|
|
29
|
+
variableDepth++
|
|
30
|
+
i++
|
|
31
|
+
continue
|
|
32
|
+
}
|
|
33
|
+
if (!quote && variableDepth > 0 && char === '}') {
|
|
34
|
+
variableDepth--
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
if ((char === "'" || char === '"') && prev !== '\\') {
|
|
38
|
+
if (!quote) quote = char
|
|
39
|
+
else if (quote === char) quote = null
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
if (quote || variableDepth > 0) continue
|
|
43
|
+
|
|
44
|
+
for (const marker of markers) {
|
|
45
|
+
if (line.slice(i, i + marker.length) === marker) {
|
|
46
|
+
return { index: i, marker }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function getLineNumber(configPath, lines, fileType) {
|
|
55
|
+
if (['.yml', '.yaml', '.json5', '.jsonc'].includes(fileType)) {
|
|
56
|
+
const byPath = findLineByPath(configPath, lines, fileType)
|
|
57
|
+
if (byPath) return byPath
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (fileType === '.json') return 0
|
|
61
|
+
const key = String(configPath).split('.').pop()
|
|
62
|
+
return findLineForKey(key, lines, fileType)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function getLeadingComment(lines, lineIndex, markers) {
|
|
66
|
+
const comments = getLeadingCommentLines(lines, lineIndex, markers)
|
|
67
|
+
return comments.length ? comments.join(' ') : null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function getLeadingCommentLines(lines, lineIndex, markers) {
|
|
71
|
+
const comments = []
|
|
72
|
+
for (let i = lineIndex - 1; i >= 0; i--) {
|
|
73
|
+
const line = lines[i]
|
|
74
|
+
if (!line || line.trim() === '') break
|
|
75
|
+
|
|
76
|
+
const trimmed = line.trim()
|
|
77
|
+
const marker = markers.find(item => trimmed.startsWith(item))
|
|
78
|
+
if (!marker) break
|
|
79
|
+
|
|
80
|
+
const text = stripCommentMarker(trimmed, marker).trim()
|
|
81
|
+
if (text && !isDecorationComment(text)) comments.unshift(text)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return comments
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function hasAnnotations(annotations) {
|
|
88
|
+
return annotations && Object.keys(annotations).length > 0
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function buildCommentResult(commentLines, fallbackDescriptionSource) {
|
|
92
|
+
const parsed = parseCommentAnnotations(commentLines)
|
|
93
|
+
if (!parsed.description && !hasAnnotations(parsed.annotations)) return null
|
|
94
|
+
|
|
95
|
+
const result = {}
|
|
96
|
+
if (parsed.description) {
|
|
97
|
+
result.description = parsed.description
|
|
98
|
+
result.descriptionSource = parsed.descriptionSource || fallbackDescriptionSource
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (hasAnnotations(parsed.annotations)) {
|
|
102
|
+
result.annotations = parsed.annotations
|
|
103
|
+
for (const [key, value] of Object.entries(parsed.annotations)) {
|
|
104
|
+
if (key === 'description') continue
|
|
105
|
+
result[key] = value
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return result
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function extractComment(configPath, lines, fileType) {
|
|
113
|
+
try {
|
|
114
|
+
const markers = getCommentMarkers(fileType)
|
|
115
|
+
if (!markers.length || !configPath || !Array.isArray(lines) || !lines.length) return null
|
|
116
|
+
|
|
117
|
+
const lineNumber = getLineNumber(configPath, lines, fileType)
|
|
118
|
+
if (!lineNumber) return null
|
|
119
|
+
|
|
120
|
+
const lineIndex = lineNumber - 1
|
|
121
|
+
const line = lines[lineIndex] || ''
|
|
122
|
+
const inlineStart = findCommentStart(line, markers)
|
|
123
|
+
if (inlineStart) {
|
|
124
|
+
const text = stripCommentMarker(line.slice(inlineStart.index), inlineStart.marker).trim()
|
|
125
|
+
if (text && !isDecorationComment(text)) {
|
|
126
|
+
return buildCommentResult([text], 'comment')
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const leading = getLeadingCommentLines(lines, lineIndex, markers)
|
|
131
|
+
const result = buildCommentResult(leading, 'leadingComment')
|
|
132
|
+
if (result) return result
|
|
133
|
+
} catch (error) {
|
|
134
|
+
return null
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return null
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
module.exports = {
|
|
141
|
+
extractComment,
|
|
142
|
+
findCommentStart,
|
|
143
|
+
getCommentMarkers,
|
|
144
|
+
getLeadingComment,
|
|
145
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/* eslint-disable no-template-curly-in-string */
|
|
2
|
+
const { test } = require('uvu')
|
|
3
|
+
const assert = require('uvu/assert')
|
|
4
|
+
const fs = require('fs')
|
|
5
|
+
const os = require('os')
|
|
6
|
+
const path = require('path')
|
|
7
|
+
const configorama = require('../../index')
|
|
8
|
+
const { extractComment, findCommentStart, getCommentMarkers } = require('./extractComment')
|
|
9
|
+
|
|
10
|
+
test('extractComment reads trailing inline YAML comments', () => {
|
|
11
|
+
const lines = ['apiKey: ${env:API_KEY} # API key from dashboard']
|
|
12
|
+
assert.equal(extractComment('apiKey', lines, '.yml'), {
|
|
13
|
+
description: 'API key from dashboard',
|
|
14
|
+
descriptionSource: 'comment',
|
|
15
|
+
})
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('extractComment reads leading block comments and drops decoration', () => {
|
|
19
|
+
const lines = [
|
|
20
|
+
'# --------',
|
|
21
|
+
'# Database host',
|
|
22
|
+
'# Used by app startup',
|
|
23
|
+
'host: ${env:DB_HOST}',
|
|
24
|
+
]
|
|
25
|
+
assert.equal(extractComment('host', lines, '.yaml'), {
|
|
26
|
+
description: 'Database host Used by app startup',
|
|
27
|
+
descriptionSource: 'leadingComment',
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('extractComment ignores comment markers inside variables and quotes', () => {
|
|
32
|
+
const lines = ['apiKey: ${env:API_KEY, "abc#123"} # Real comment']
|
|
33
|
+
const start = findCommentStart(lines[0], getCommentMarkers('.yml'))
|
|
34
|
+
assert.is(lines[0].slice(start.index), '# Real comment')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('extractComment uses path-aware lookup for nested duplicate YAML keys', () => {
|
|
38
|
+
const lines = [
|
|
39
|
+
'first:',
|
|
40
|
+
' apiKey: ${env:FIRST_KEY} # First key',
|
|
41
|
+
'second:',
|
|
42
|
+
' apiKey: ${env:SECOND_KEY} # Second key',
|
|
43
|
+
]
|
|
44
|
+
assert.equal(extractComment('second.apiKey', lines, '.yml'), {
|
|
45
|
+
description: 'Second key',
|
|
46
|
+
descriptionSource: 'comment',
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('extractComment skips JSON comments and handles failures without throwing', () => {
|
|
51
|
+
assert.is(extractComment('apiKey', ['"apiKey": "${env:API_KEY}" // ignored'], '.json'), null)
|
|
52
|
+
assert.is(extractComment(null, null, '.yml'), null)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('extractComment handles JSON5 line comments', () => {
|
|
56
|
+
const lines = [
|
|
57
|
+
'{',
|
|
58
|
+
' "apiKey": "${env:API_KEY}" // API key',
|
|
59
|
+
'}',
|
|
60
|
+
]
|
|
61
|
+
assert.equal(extractComment('apiKey', lines, '.json5'), {
|
|
62
|
+
description: 'API key',
|
|
63
|
+
descriptionSource: 'comment',
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('extractComment parses leading YAML annotation tags', () => {
|
|
68
|
+
const lines = [
|
|
69
|
+
'# Stripe live secret key',
|
|
70
|
+
'# @from Stripe dashboard > Developers > API keys',
|
|
71
|
+
'# @sensitive true',
|
|
72
|
+
'stripeSecret: ${env:STRIPE_SECRET_KEY}',
|
|
73
|
+
]
|
|
74
|
+
assert.equal(extractComment('stripeSecret', lines, '.yml'), {
|
|
75
|
+
description: 'Stripe live secret key',
|
|
76
|
+
descriptionSource: 'leadingComment',
|
|
77
|
+
annotations: {
|
|
78
|
+
obtainHint: 'Stripe dashboard > Developers > API keys',
|
|
79
|
+
sensitive: true,
|
|
80
|
+
},
|
|
81
|
+
obtainHint: 'Stripe dashboard > Developers > API keys',
|
|
82
|
+
sensitive: true,
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('extractComment lets explicit @description override plain comment text', () => {
|
|
87
|
+
const lines = [
|
|
88
|
+
'# Plain comment loses to explicit description',
|
|
89
|
+
'# @description Stripe live secret key',
|
|
90
|
+
'stripeSecret: ${env:STRIPE_SECRET_KEY}',
|
|
91
|
+
]
|
|
92
|
+
assert.equal(extractComment('stripeSecret', lines, '.yaml'), {
|
|
93
|
+
description: 'Stripe live secret key',
|
|
94
|
+
descriptionSource: 'commentTag',
|
|
95
|
+
annotations: {
|
|
96
|
+
description: 'Stripe live secret key',
|
|
97
|
+
},
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('extractComment parses inline tags without requiring a description', () => {
|
|
102
|
+
const lines = [
|
|
103
|
+
'stripeSecret: ${env:STRIPE_SECRET_KEY} # @from Stripe dashboard > Developers > API keys',
|
|
104
|
+
]
|
|
105
|
+
assert.equal(extractComment('stripeSecret', lines, '.yaml'), {
|
|
106
|
+
annotations: {
|
|
107
|
+
obtainHint: 'Stripe dashboard > Developers > API keys',
|
|
108
|
+
},
|
|
109
|
+
obtainHint: 'Stripe dashboard > Developers > API keys',
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('extractComment parses JSONC and HCL annotation comments', () => {
|
|
114
|
+
const jsoncLines = [
|
|
115
|
+
'{',
|
|
116
|
+
' // @group Payments',
|
|
117
|
+
' "stripeSecret": "${env:STRIPE_SECRET_KEY}"',
|
|
118
|
+
'}',
|
|
119
|
+
]
|
|
120
|
+
assert.equal(extractComment('stripeSecret', jsoncLines, '.jsonc'), {
|
|
121
|
+
annotations: {
|
|
122
|
+
group: 'Payments',
|
|
123
|
+
},
|
|
124
|
+
group: 'Payments',
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
const hclLines = [
|
|
128
|
+
'// Stripe live secret key',
|
|
129
|
+
'// @from Stripe dashboard > Developers > API keys',
|
|
130
|
+
'stripe_secret = "${env:STRIPE_SECRET_KEY}"',
|
|
131
|
+
]
|
|
132
|
+
assert.equal(extractComment('stripe_secret', hclLines, '.hcl'), {
|
|
133
|
+
description: 'Stripe live secret key',
|
|
134
|
+
descriptionSource: 'leadingComment',
|
|
135
|
+
annotations: {
|
|
136
|
+
obtainHint: 'Stripe dashboard > Developers > API keys',
|
|
137
|
+
},
|
|
138
|
+
obtainHint: 'Stripe dashboard > Developers > API keys',
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('extractComment keeps unknown tag-shaped comments as plain description', () => {
|
|
143
|
+
const lines = [
|
|
144
|
+
'# @david rotate this key after launch',
|
|
145
|
+
'stripeSecret: ${env:STRIPE_SECRET_KEY}',
|
|
146
|
+
]
|
|
147
|
+
assert.equal(extractComment('stripeSecret', lines, '.yaml'), {
|
|
148
|
+
description: '@david rotate this key after launch',
|
|
149
|
+
descriptionSource: 'leadingComment',
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test('requirements model uses comments when help is absent and keeps help precedence', async () => {
|
|
154
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'configorama-comments-'))
|
|
155
|
+
const configPath = path.join(dir, 'config.yml')
|
|
156
|
+
fs.writeFileSync(configPath, [
|
|
157
|
+
'# API key from dashboard',
|
|
158
|
+
'apiKey: ${env:CONFIGORAMA_COMMENT_API_KEY}',
|
|
159
|
+
'withHelp: ${env:CONFIGORAMA_COMMENT_HELP | help("Help wins")} # Comment loses',
|
|
160
|
+
'nested:',
|
|
161
|
+
' apiKey: ${env:CONFIGORAMA_COMMENT_NESTED} # Nested key',
|
|
162
|
+
].join('\n'))
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
const result = await configorama.analyze(configPath, {
|
|
166
|
+
instructions: true,
|
|
167
|
+
options: {}
|
|
168
|
+
})
|
|
169
|
+
const byVariable = Object.fromEntries(result.requirements.map(req => [req.variable, req]))
|
|
170
|
+
|
|
171
|
+
assert.is(byVariable['env:CONFIGORAMA_COMMENT_API_KEY'].description, 'API key from dashboard')
|
|
172
|
+
assert.is(byVariable['env:CONFIGORAMA_COMMENT_API_KEY'].descriptionSource, 'leadingComment')
|
|
173
|
+
assert.is(byVariable['env:CONFIGORAMA_COMMENT_HELP'].description, 'Help wins')
|
|
174
|
+
assert.is(byVariable['env:CONFIGORAMA_COMMENT_HELP'].descriptionSource, 'help')
|
|
175
|
+
assert.is(byVariable['env:CONFIGORAMA_COMMENT_NESTED'].description, 'Nested key')
|
|
176
|
+
assert.is(byVariable['env:CONFIGORAMA_COMMENT_NESTED'].descriptionSource, 'comment')
|
|
177
|
+
} finally {
|
|
178
|
+
fs.rmSync(dir, { recursive: true, force: true })
|
|
179
|
+
}
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test.run()
|
|
@@ -26,7 +26,8 @@ function preProcess(configObject, variableSyntax, variableTypes, options = {}) {
|
|
|
26
26
|
// Extract reference prefixes from variable types, or use defaults
|
|
27
27
|
const refPrefixes = variableTypes && variableTypes.length > 0
|
|
28
28
|
? variableTypes
|
|
29
|
-
.
|
|
29
|
+
.flatMap(v => v.prefixes || [v.prefix || v.type])
|
|
30
|
+
.map(prefix => prefix + ':')
|
|
30
31
|
.filter(p => p !== 'dot.prop:' && p !== 'string:' && p !== 'number:')
|
|
31
32
|
: ['self:', 'opt:', 'env:', 'file:', 'text:', 'deep:']
|
|
32
33
|
|
|
@@ -19,8 +19,8 @@ function findLineForKey(keyToFind, lines, fileType) {
|
|
|
19
19
|
if (fileType === '.yml' || fileType === '.yaml') {
|
|
20
20
|
return new RegExp(`^\\s*${escapedKey}\\s*:`).test(line)
|
|
21
21
|
}
|
|
22
|
-
// TOML: key = or key=
|
|
23
|
-
if (fileType === '.toml') {
|
|
22
|
+
// TOML/HCL: key = or key=
|
|
23
|
+
if (fileType === '.toml' || fileType === '.hcl') {
|
|
24
24
|
return new RegExp(`^\\s*${escapedKey}\\s*=`).test(line)
|
|
25
25
|
}
|
|
26
26
|
// JSON: "key": or "key" :
|
|
@@ -30,17 +30,29 @@ function patternToSegments(pattern) {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
function matchSegments(patternSegments, pathSegments) {
|
|
33
|
-
|
|
33
|
+
return matchFrom(patternSegments, 0, pathSegments, 0)
|
|
34
|
+
}
|
|
34
35
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
// Index-based glob match: '**' spans zero-or-more segments (with backtracking),
|
|
37
|
+
// '*' matches one segment, anything else matches literally. Avoids per-call array
|
|
38
|
+
// allocation (no destructuring/slice) that dominated resolution hot paths.
|
|
39
|
+
function matchFrom(pattern, pi, path, si) {
|
|
40
|
+
while (pi < pattern.length) {
|
|
41
|
+
const head = pattern[pi]
|
|
42
|
+
if (head === '**') {
|
|
43
|
+
// '**' consumes zero segments here...
|
|
44
|
+
if (matchFrom(pattern, pi + 1, path, si)) return true
|
|
45
|
+
// ...or one more path segment, still anchored on '**'
|
|
46
|
+
if (si >= path.length) return false
|
|
47
|
+
si++
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
if (si >= path.length) return false
|
|
51
|
+
if (head !== '*' && head !== path[si]) return false
|
|
52
|
+
pi++
|
|
53
|
+
si++
|
|
39
54
|
}
|
|
40
|
-
|
|
41
|
-
if (!pathSegments.length) return false
|
|
42
|
-
if (head !== '*' && head !== pathSegments[0]) return false
|
|
43
|
-
return matchSegments(tail, pathSegments.slice(1))
|
|
55
|
+
return si === path.length
|
|
44
56
|
}
|
|
45
57
|
|
|
46
58
|
function normalizeIgnorePaths(options = {}) {
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
const dotProp = require('dot-prop')
|
|
2
|
+
|
|
3
|
+
const REDACTED_VALUE = '********'
|
|
4
|
+
|
|
5
|
+
const DEFAULT_SENSITIVE_PATTERNS = [
|
|
6
|
+
/secret/i,
|
|
7
|
+
/password/i,
|
|
8
|
+
/token/i,
|
|
9
|
+
/key/i,
|
|
10
|
+
/credential/i,
|
|
11
|
+
/auth/i,
|
|
12
|
+
/private[-_]?key/i,
|
|
13
|
+
/client[-_]?secret/i,
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
function cloneJson(value) {
|
|
17
|
+
if (value === undefined) return undefined
|
|
18
|
+
return JSON.parse(JSON.stringify(value))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function toRegex(pattern) {
|
|
22
|
+
if (pattern instanceof RegExp) return pattern
|
|
23
|
+
return new RegExp(String(pattern), 'i')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function getSensitivePatterns(options = {}) {
|
|
27
|
+
const customPatterns = (options.patterns || options.sensitivePatterns || []).map(toRegex)
|
|
28
|
+
return DEFAULT_SENSITIVE_PATTERNS.concat(customPatterns)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isSensitiveName(name, options = {}) {
|
|
32
|
+
if (!name) return false
|
|
33
|
+
const value = String(name)
|
|
34
|
+
return getSensitivePatterns(options).some(pattern => pattern.test(value))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getSensitiveOverride(entries = []) {
|
|
38
|
+
const entry = entries.find(item => item && typeof item.value === 'boolean')
|
|
39
|
+
if (!entry) return null
|
|
40
|
+
return entry.value
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isSensitiveVariable(name, options = {}) {
|
|
44
|
+
const override = getSensitiveOverride(options.sensitiveEntries || [])
|
|
45
|
+
if (override !== null) return override
|
|
46
|
+
return isSensitiveName(name, options) || isSensitiveName(options.path, options)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function redactValue(value) {
|
|
50
|
+
if (value === undefined) return undefined
|
|
51
|
+
return REDACTED_VALUE
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function redactObjectByPaths(value, paths = []) {
|
|
55
|
+
const redacted = cloneJson(value)
|
|
56
|
+
for (const configPath of paths || []) {
|
|
57
|
+
if (configPath && dotProp.has(redacted, configPath)) {
|
|
58
|
+
dotProp.set(redacted, configPath, REDACTED_VALUE)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return redacted
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function redactRequirementValue(requirement, value) {
|
|
65
|
+
return requirement && requirement.sensitive === true ? REDACTED_VALUE : value
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = {
|
|
69
|
+
DEFAULT_SENSITIVE_PATTERNS,
|
|
70
|
+
REDACTED_VALUE,
|
|
71
|
+
cloneJson,
|
|
72
|
+
getSensitivePatterns,
|
|
73
|
+
isSensitiveName,
|
|
74
|
+
isSensitiveVariable,
|
|
75
|
+
redactObjectByPaths,
|
|
76
|
+
redactRequirementValue,
|
|
77
|
+
redactValue,
|
|
78
|
+
}
|