configorama 1.0.2 → 1.2.1
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 +41 -1
- package/cli.js +14 -1
- package/package.json +18 -6
- package/plugins/cloudformation/README.md +79 -0
- package/plugins/cloudformation/credentials.js +231 -0
- package/plugins/cloudformation/index.js +204 -0
- package/plugins/cloudformation/package-lock.json +657 -0
- package/plugins/cloudformation/package.json +24 -0
- package/plugins/onepassword/README.md +175 -0
- package/plugins/onepassword/fields.js +126 -0
- package/plugins/onepassword/index.js +356 -0
- package/plugins/onepassword/normalize.js +157 -0
- package/plugins/onepassword/op-cli.js +117 -0
- package/plugins/onepassword/package.json +15 -0
- package/plugins/onepassword/parser.js +70 -0
- package/plugins/onepassword/sync-factory.js +15 -0
- package/src/errors.js +3 -0
- package/src/index.js +27 -1
- package/src/main.js +24 -32
- package/src/parsers/dotenv.js +13 -0
- package/src/resolvers/valueFromFile.js +2 -1
- package/src/sync.js +27 -0
- package/src/utils/PromiseTracker.js +11 -4
- package/src/utils/PromiseTracker.test.js +48 -0
- package/src/utils/introspection/audit.js +39 -9
- package/src/utils/parsing/detectFormat.js +70 -0
- package/src/utils/parsing/detectFormat.test.js +68 -0
- package/src/utils/parsing/parse.js +11 -37
- package/src/utils/paths/fileType.js +27 -0
- package/src/utils/paths/fileType.test.js +41 -0
- package/src/utils/paths/findLineForKey.js +24 -1
- package/src/utils/paths/findLineForKey.test.js +20 -1
- package/src/utils/setup/applyAnswers.js +41 -0
- package/src/utils/setup/setupEngine.js +85 -0
- package/src/utils/setup/stdoutRedirect.js +48 -0
- package/src/utils/setup/writeAnswers.js +50 -0
- package/src/utils/setup/writeDotenv.js +142 -0
- package/src/utils/ui/configWizard.js +20 -1
- package/types/src/capabilities.d.ts +8 -0
- package/types/src/capabilities.d.ts.map +1 -0
- package/types/src/display.d.ts +62 -0
- package/types/src/display.d.ts.map +1 -0
- package/types/src/errors.d.ts +20 -0
- package/types/src/errors.d.ts.map +1 -0
- package/types/src/index.d.ts +119 -0
- package/types/src/index.d.ts.map +1 -1
- package/types/src/main.d.ts +23 -16
- package/types/src/main.d.ts.map +1 -1
- package/types/src/metadata.d.ts +28 -0
- package/types/src/metadata.d.ts.map +1 -0
- package/types/src/parsers/dotenv.d.ts +6 -0
- package/types/src/parsers/dotenv.d.ts.map +1 -0
- package/types/src/parsers/esm.d.ts.map +1 -1
- package/types/src/parsers/typescript.d.ts.map +1 -1
- package/types/src/resolvers/valueFromCron.d.ts.map +1 -1
- package/types/src/resolvers/valueFromEval.d.ts.map +1 -1
- package/types/src/resolvers/valueFromFile.d.ts +4 -0
- package/types/src/resolvers/valueFromFile.d.ts.map +1 -1
- package/types/src/resolvers/valueFromGit.d.ts.map +1 -1
- package/types/src/resolvers/valueFromOptions.d.ts +1 -0
- package/types/src/resolvers/valueFromOptions.d.ts.map +1 -1
- package/types/src/sync.d.ts.map +1 -1
- package/types/src/utils/BoundedMap.d.ts +10 -0
- package/types/src/utils/BoundedMap.d.ts.map +1 -0
- package/types/src/utils/PromiseTracker.d.ts +1 -1
- package/types/src/utils/PromiseTracker.d.ts.map +1 -1
- package/types/src/utils/filters/filterArgs.d.ts +13 -0
- package/types/src/utils/filters/filterArgs.d.ts.map +1 -0
- package/types/src/utils/filters/oneOf.d.ts +7 -0
- package/types/src/utils/filters/oneOf.d.ts.map +1 -0
- package/types/src/utils/introspection/audit.d.ts +14 -0
- package/types/src/utils/introspection/audit.d.ts.map +1 -0
- package/types/src/utils/introspection/graph.d.ts +4 -0
- package/types/src/utils/introspection/graph.d.ts.map +1 -0
- package/types/src/utils/introspection/model.d.ts +43 -0
- package/types/src/utils/introspection/model.d.ts.map +1 -0
- package/types/src/utils/parsing/commentAnnotations.d.ts +18 -0
- package/types/src/utils/parsing/commentAnnotations.d.ts.map +1 -0
- package/types/src/utils/parsing/detectFormat.d.ts +17 -0
- package/types/src/utils/parsing/detectFormat.d.ts.map +1 -0
- package/types/src/utils/parsing/enrichMetadata.d.ts.map +1 -1
- package/types/src/utils/parsing/extractComment.d.ts +20 -0
- package/types/src/utils/parsing/extractComment.d.ts.map +1 -0
- package/types/src/utils/parsing/parse.d.ts.map +1 -1
- package/types/src/utils/parsing/preProcess.d.ts.map +1 -1
- package/types/src/utils/paths/fileType.d.ts +15 -0
- package/types/src/utils/paths/fileType.d.ts.map +1 -0
- package/types/src/utils/paths/findLineForKey.d.ts +9 -0
- package/types/src/utils/paths/findLineForKey.d.ts.map +1 -1
- package/types/src/utils/paths/ignorePaths.d.ts +5 -0
- package/types/src/utils/paths/ignorePaths.d.ts.map +1 -0
- package/types/src/utils/redaction/redact.d.ts +10 -0
- package/types/src/utils/redaction/redact.d.ts.map +1 -0
- package/types/src/utils/redaction/setupRedaction.d.ts +5 -0
- package/types/src/utils/redaction/setupRedaction.d.ts.map +1 -0
- package/types/src/utils/requirements/configRequirements.d.ts +70 -0
- package/types/src/utils/requirements/configRequirements.d.ts.map +1 -0
- package/types/src/utils/requirements/serializeRequirements.d.ts +63 -0
- package/types/src/utils/requirements/serializeRequirements.d.ts.map +1 -0
- package/types/src/utils/security/dotenvFileRefs.d.ts +13 -0
- package/types/src/utils/security/dotenvFileRefs.d.ts.map +1 -0
- package/types/src/utils/security/evalSafety.d.ts +17 -0
- package/types/src/utils/security/evalSafety.d.ts.map +1 -0
- package/types/src/utils/security/safetyPolicy.d.ts +15 -0
- package/types/src/utils/security/safetyPolicy.d.ts.map +1 -0
- package/types/src/utils/setup/applyAnswers.d.ts +34 -0
- package/types/src/utils/setup/applyAnswers.d.ts.map +1 -0
- package/types/src/utils/setup/setupEngine.d.ts +89 -0
- package/types/src/utils/setup/setupEngine.d.ts.map +1 -0
- package/types/src/utils/setup/stdoutRedirect.d.ts +10 -0
- package/types/src/utils/setup/stdoutRedirect.d.ts.map +1 -0
- package/types/src/utils/setup/writeAnswers.d.ts +17 -0
- package/types/src/utils/setup/writeAnswers.d.ts.map +1 -0
- package/types/src/utils/setup/writeDotenv.d.ts +27 -0
- package/types/src/utils/setup/writeDotenv.d.ts.map +1 -0
- package/types/src/utils/strings/didYouMean.d.ts +23 -0
- package/types/src/utils/strings/didYouMean.d.ts.map +1 -0
- package/types/src/utils/strings/formatFunctionArgs.d.ts.map +1 -1
- package/types/src/utils/strings/replaceAll.d.ts.map +1 -1
- package/types/src/utils/strings/splitByComma.d.ts.map +1 -1
- package/types/src/utils/ui/configWizard.d.ts +51 -7
- package/types/src/utils/ui/configWizard.d.ts.map +1 -1
- package/types/src/utils/ui/createEditorLink.d.ts +7 -0
- package/types/src/utils/ui/createEditorLink.d.ts.map +1 -1
- package/types/src/utils/ui/promptDescriptors.d.ts +39 -0
- package/types/src/utils/ui/promptDescriptors.d.ts.map +1 -0
- package/types/src/utils/variables/cleanVariable.d.ts.map +1 -1
- package/types/src/utils/variables/getVariableType.d.ts.map +1 -1
- package/types/src/utils/variables/variableUtils.d.ts +1 -1
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/* Tests that resolution progress output goes to stderr, not stdout */
|
|
2
|
+
const { test } = require('uvu')
|
|
3
|
+
const assert = require('uvu/assert')
|
|
4
|
+
const PromiseTracker = require('./PromiseTracker')
|
|
5
|
+
|
|
6
|
+
test('start() does not enable progress logging by default', () => {
|
|
7
|
+
const tracker = new PromiseTracker()
|
|
8
|
+
tracker.start()
|
|
9
|
+
const hasInterval = !!tracker.interval
|
|
10
|
+
tracker.stop()
|
|
11
|
+
assert.is(hasInterval, false, 'no interval timer unless progress is requested')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test('start(true) enables the progress interval; stop() clears it', () => {
|
|
15
|
+
const tracker = new PromiseTracker()
|
|
16
|
+
tracker.start(true)
|
|
17
|
+
assert.ok(tracker.interval, 'interval set when progress requested')
|
|
18
|
+
tracker.stop()
|
|
19
|
+
assert.is(tracker.interval, null, 'interval cleared on stop')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('report() writes progress to stderr, not stdout', () => {
|
|
23
|
+
const tracker = new PromiseTracker()
|
|
24
|
+
const pending = Promise.resolve()
|
|
25
|
+
tracker.add('${op(a?x=1&y=2)}', pending, '${op(a?x=1&y=2)}')
|
|
26
|
+
// add() attaches a .then that flips state to resolved on next tick; force pending
|
|
27
|
+
tracker.promiseList[0].state = 'pending'
|
|
28
|
+
|
|
29
|
+
const outLines = []
|
|
30
|
+
const errLines = []
|
|
31
|
+
const origLog = console.log
|
|
32
|
+
const origErr = console.error
|
|
33
|
+
console.log = (...args) => outLines.push(args.join(' '))
|
|
34
|
+
console.error = (...args) => errLines.push(args.join(' '))
|
|
35
|
+
try {
|
|
36
|
+
tracker.report()
|
|
37
|
+
} finally {
|
|
38
|
+
console.log = origLog
|
|
39
|
+
console.error = origErr
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
assert.is(outLines.length, 0, 'nothing on stdout')
|
|
43
|
+
assert.ok(errLines.some((line) => line.includes('Fetching Async values')), 'progress on stderr')
|
|
44
|
+
// the pending variable (with & in it) must not leak to stdout
|
|
45
|
+
assert.ok(outLines.every((line) => !line.includes('&')))
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test.run()
|
|
@@ -41,15 +41,11 @@ function buildAuditReport(introspection, options = {}) {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
if (options.customResolvers && options.customResolvers.length) {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
kind: 'source',
|
|
50
|
-
variableType: resolver,
|
|
51
|
-
message: `Custom resolver "${resolver}" can execute user-provided code.`,
|
|
52
|
-
})
|
|
44
|
+
const sorted = options.customResolvers.slice().sort((a, b) => {
|
|
45
|
+
return String(a.type || a).localeCompare(String(b.type || b))
|
|
46
|
+
})
|
|
47
|
+
for (const resolver of sorted) {
|
|
48
|
+
findings.push(customResolverFinding(resolver))
|
|
53
49
|
}
|
|
54
50
|
}
|
|
55
51
|
|
|
@@ -70,6 +66,40 @@ function buildAuditReport(introspection, options = {}) {
|
|
|
70
66
|
}
|
|
71
67
|
}
|
|
72
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Build the audit finding for one custom resolver.
|
|
71
|
+
* Resolvers that self-describe as sensitive/risky get a specific finding
|
|
72
|
+
* instead of the generic custom_extension one - never both.
|
|
73
|
+
* @param {string|{type: string, sensitive?: boolean, risk?: string, description?: string}} resolver
|
|
74
|
+
* @returns {object} Audit finding
|
|
75
|
+
*/
|
|
76
|
+
function customResolverFinding(resolver) {
|
|
77
|
+
const source = typeof resolver === 'string' ? { type: resolver } : resolver
|
|
78
|
+
const { type, sensitive, risk, description } = source
|
|
79
|
+
|
|
80
|
+
if (sensitive === true || risk) {
|
|
81
|
+
const finding = {
|
|
82
|
+
id: `customResolver:${type}`,
|
|
83
|
+
severity: 'high',
|
|
84
|
+
risk: risk || 'custom_extension',
|
|
85
|
+
kind: 'source',
|
|
86
|
+
variableType: type,
|
|
87
|
+
message: `Custom resolver "${type}" reads secret values.${description ? ` ${description}.` : ''}`,
|
|
88
|
+
}
|
|
89
|
+
if (sensitive === true) finding.sensitive = true
|
|
90
|
+
return finding
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
id: `customResolver:${type}`,
|
|
95
|
+
severity: 'high',
|
|
96
|
+
risk: 'custom_extension',
|
|
97
|
+
kind: 'source',
|
|
98
|
+
variableType: type,
|
|
99
|
+
message: `Custom resolver "${type}" can execute user-provided code.`,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
73
103
|
function messageForNode(node) {
|
|
74
104
|
if (node.risk === 'executable_code') return 'Reference may execute JavaScript or TypeScript.'
|
|
75
105
|
if (node.risk === 'process_spawn') return 'Reference may spawn a git process.'
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Detects a config file's format from raw content when its name/extension is
|
|
2
|
+
// ambiguous. Returns a file-extension string for the parser dispatch in parse.js.
|
|
3
|
+
// Best-effort heuristic — an explicit, recognized extension always wins over this.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Whether every meaningful line is a flat KEY=VALUE assignment (dotenv shape).
|
|
7
|
+
* Deliberately variable-syntax-agnostic: it inspects line structure, not the
|
|
8
|
+
* variable delimiters, so it works whether a config uses ${...}, $[...], or a
|
|
9
|
+
* custom syntax. Blank lines and #/; comments are ignored.
|
|
10
|
+
* @param {string} text - Trimmed file contents
|
|
11
|
+
* @returns {boolean} True for flat key=value content with no YAML/TOML structure
|
|
12
|
+
*/
|
|
13
|
+
function isFlatKeyValue(text) {
|
|
14
|
+
const lines = text.split('\n')
|
|
15
|
+
let assignments = 0
|
|
16
|
+
for (const raw of lines) {
|
|
17
|
+
const line = raw.trim()
|
|
18
|
+
if (!line || line.startsWith('#') || line.startsWith(';')) continue
|
|
19
|
+
if (/^(?:export\s+)?[A-Za-z_][A-Za-z0-9_.-]*\s*=/.test(line)) {
|
|
20
|
+
assignments++
|
|
21
|
+
continue
|
|
22
|
+
}
|
|
23
|
+
// A non-blank, non-comment line that isn't an assignment (e.g. a YAML
|
|
24
|
+
// mapping, a TOML section, or freeform text) — not a flat env file.
|
|
25
|
+
return false
|
|
26
|
+
}
|
|
27
|
+
return assignments > 0
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Detect config format from file contents when the extension is missing or
|
|
32
|
+
* unrecognized.
|
|
33
|
+
* @param {string} contents - Raw file contents
|
|
34
|
+
* @returns {string} Detected file extension (e.g. '.json', '.yml', '.toml', '.env')
|
|
35
|
+
*/
|
|
36
|
+
function detectFormat(contents) {
|
|
37
|
+
const trimmed = contents.trimStart()
|
|
38
|
+
|
|
39
|
+
// JSON object: starts with {
|
|
40
|
+
if (trimmed[0] === '{') return '.json'
|
|
41
|
+
|
|
42
|
+
// TOML section headers must be checked before JSON array (both start with [)
|
|
43
|
+
// TOML: [section.subsection] (dots distinguish from INI)
|
|
44
|
+
if (/^\[[\w-]+\.[\w.-]+\]/.test(trimmed)) return '.toml'
|
|
45
|
+
// TOML: array-of-tables [[section]]
|
|
46
|
+
if (/^\[\[[\w.-]+\]\]/.test(trimmed)) return '.toml'
|
|
47
|
+
|
|
48
|
+
// JSON array: starts with [ followed by non-word char (quotes, numbers, braces, whitespace)
|
|
49
|
+
if (trimmed[0] === '[') return '.json'
|
|
50
|
+
|
|
51
|
+
// TOML: multi-line strings
|
|
52
|
+
if (trimmed.startsWith('"""')) return '.toml'
|
|
53
|
+
|
|
54
|
+
// YAML: starts with document marker
|
|
55
|
+
if (trimmed.startsWith('---')) return '.yml'
|
|
56
|
+
|
|
57
|
+
// HCL: terraform keywords
|
|
58
|
+
if (/^(resource|variable|locals|provider|data|module|output|terraform)\s/.test(trimmed)) return '.tf'
|
|
59
|
+
|
|
60
|
+
// Flat KEY=VALUE content (env-file shape) -> dotenv parser. dotenv is lossless
|
|
61
|
+
// for string values, which is what configorama resolves ${...} into. TOML
|
|
62
|
+
// errors on unquoted values; ini truncates values at ';' (breaks connection
|
|
63
|
+
// strings) and mangles 'export' prefixes.
|
|
64
|
+
if (isFlatKeyValue(trimmed)) return '.env'
|
|
65
|
+
|
|
66
|
+
// Default: YAML (most permissive parser)
|
|
67
|
+
return '.yml'
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = { detectFormat, isFlatKeyValue }
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/* Tests for content-based config format detection */
|
|
2
|
+
const { test } = require('uvu')
|
|
3
|
+
const assert = require('uvu/assert')
|
|
4
|
+
const { detectFormat, isFlatKeyValue } = require('./detectFormat')
|
|
5
|
+
|
|
6
|
+
/* JSON */
|
|
7
|
+
test('detects JSON object and array', () => {
|
|
8
|
+
assert.is(detectFormat('{\n "a": 1\n}'), '.json')
|
|
9
|
+
assert.is(detectFormat('[\n {"a": 1}\n]'), '.json')
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
/* TOML — structural markers win */
|
|
13
|
+
test('detects TOML by section headers and leading multiline string', () => {
|
|
14
|
+
assert.is(detectFormat('[server.settings]\nname = "test"\nport = 3000'), '.toml')
|
|
15
|
+
assert.is(detectFormat('[[items]]\nname = "a"'), '.toml')
|
|
16
|
+
assert.is(detectFormat('"""\nmulti\n"""'), '.toml')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
/* YAML */
|
|
20
|
+
test('detects YAML by document marker and mappings', () => {
|
|
21
|
+
assert.is(detectFormat('---\nname: test'), '.yml')
|
|
22
|
+
assert.is(detectFormat('name: test\nport: 3000'), '.yml')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('YAML with template values still detects as YAML', () => {
|
|
26
|
+
assert.is(detectFormat("name: ${env:APP_NAME, 'x'}\nport: 3000"), '.yml')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
/* HCL */
|
|
30
|
+
test('detects terraform/HCL by keyword', () => {
|
|
31
|
+
assert.is(detectFormat('variable "region" {\n default = "us-east-1"\n}'), '.tf')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
/* dotenv — flat key=value shape */
|
|
35
|
+
test('detects flat key=value (no space) as dotenv', () => {
|
|
36
|
+
assert.is(detectFormat('FOO=bar\nBAZ=qux'), '.env')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('detects key=value with template values as dotenv (syntax-agnostic)', () => {
|
|
40
|
+
assert.is(detectFormat('stage=${option:stage}\nDB=${op:x}'), '.env')
|
|
41
|
+
// custom variable syntax must not change detection
|
|
42
|
+
assert.is(detectFormat('stage=$[option:stage]\nDB=$[op:x]'), '.env')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('detects dotenv with comments and export prefixes', () => {
|
|
46
|
+
assert.is(detectFormat('# comment\nexport TOKEN=abc\nURL=postgres://h/db;sslmode=require'), '.env')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('a single non-assignment line disqualifies dotenv detection', () => {
|
|
50
|
+
// has a YAML mapping mixed in -> not flat key=value -> YAML default
|
|
51
|
+
assert.is(detectFormat('FOO=bar\nname: test'), '.yml')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('empty or comment-only content is not dotenv', () => {
|
|
55
|
+
assert.is(detectFormat('# just a comment\n'), '.yml')
|
|
56
|
+
assert.is(detectFormat(''), '.yml')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
/* isFlatKeyValue unit */
|
|
60
|
+
test('isFlatKeyValue recognizes assignment lines only', () => {
|
|
61
|
+
assert.is(isFlatKeyValue('A=1\nB=2'), true)
|
|
62
|
+
assert.is(isFlatKeyValue('export A=1\n# c\nB=2'), true)
|
|
63
|
+
assert.is(isFlatKeyValue('A=1\nplain text line'), false)
|
|
64
|
+
assert.is(isFlatKeyValue('name: value'), false)
|
|
65
|
+
assert.is(isFlatKeyValue(''), false)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test.run()
|
|
@@ -4,8 +4,11 @@ const path = require('path')
|
|
|
4
4
|
const YAML = require('../../parsers/yaml')
|
|
5
5
|
const TOML = require('../../parsers/toml')
|
|
6
6
|
const INI = require('../../parsers/ini')
|
|
7
|
+
const DOTENV = require('../../parsers/dotenv')
|
|
7
8
|
const JSON5 = require('../../parsers/json5')
|
|
8
9
|
const HCL = require('../../parsers/hcl')
|
|
10
|
+
const { isEnvFile } = require('../paths/fileType')
|
|
11
|
+
const { detectFormat } = require('./detectFormat')
|
|
9
12
|
const { executeTypeScriptFileSync } = require('../../parsers/typescript')
|
|
10
13
|
const { executeESMFileSync } = require('../../parsers/esm')
|
|
11
14
|
const cloudFormationSchema = require('./cloudformationSchema')
|
|
@@ -21,41 +24,6 @@ const KNOWN_EXTENSIONS = new Set([
|
|
|
21
24
|
'.md', '.mdx', '.markdown', '.mdown', '.mkdn', '.mkd', '.mdwn', '.markdn', '.mdtxt', '.mdtext'
|
|
22
25
|
])
|
|
23
26
|
|
|
24
|
-
/**
|
|
25
|
-
* Detect config format from file contents when extension is missing
|
|
26
|
-
* @param {string} contents - Raw file contents
|
|
27
|
-
* @returns {string} Detected file extension (e.g. '.json', '.yml', '.toml')
|
|
28
|
-
*/
|
|
29
|
-
function detectFormat(contents) {
|
|
30
|
-
const trimmed = contents.trimStart()
|
|
31
|
-
|
|
32
|
-
// JSON object: starts with {
|
|
33
|
-
if (trimmed[0] === '{') return '.json'
|
|
34
|
-
|
|
35
|
-
// TOML section headers must be checked before JSON array (both start with [)
|
|
36
|
-
// TOML: [section.subsection] (dots distinguish from INI)
|
|
37
|
-
if (/^\[[\w-]+\.[\w.-]+\]/.test(trimmed)) return '.toml'
|
|
38
|
-
// TOML: array-of-tables [[section]]
|
|
39
|
-
if (/^\[\[[\w.-]+\]\]/.test(trimmed)) return '.toml'
|
|
40
|
-
|
|
41
|
-
// JSON array: starts with [ followed by non-word char (quotes, numbers, braces, whitespace)
|
|
42
|
-
if (trimmed[0] === '[') return '.json'
|
|
43
|
-
|
|
44
|
-
// TOML: multi-line strings
|
|
45
|
-
if (trimmed.startsWith('"""')) return '.toml'
|
|
46
|
-
// TOML: key = value
|
|
47
|
-
if (/^\w[\w.-]*\s*=\s/m.test(trimmed)) return '.toml'
|
|
48
|
-
|
|
49
|
-
// YAML: starts with document marker
|
|
50
|
-
if (trimmed.startsWith('---')) return '.yml'
|
|
51
|
-
|
|
52
|
-
// HCL: terraform keywords
|
|
53
|
-
if (/^(resource|variable|locals|provider|data|module|output|terraform)\s/.test(trimmed)) return '.tf'
|
|
54
|
-
|
|
55
|
-
// Default: YAML (most permissive parser)
|
|
56
|
-
return '.yml'
|
|
57
|
-
}
|
|
58
|
-
|
|
59
27
|
/**
|
|
60
28
|
* @typedef {Object} ParseOptions
|
|
61
29
|
* @property {string} contents - Raw file contents to parse
|
|
@@ -72,8 +40,12 @@ function detectFormat(contents) {
|
|
|
72
40
|
function parseFileContents({ contents, filePath, varRegex, dynamicArgs }) {
|
|
73
41
|
let fileType = path.extname(filePath)
|
|
74
42
|
|
|
75
|
-
//
|
|
76
|
-
|
|
43
|
+
// Dotenv files have no extension (path.extname('.env') === ''), so detect
|
|
44
|
+
// them by name before falling back to content sniffing.
|
|
45
|
+
if (isEnvFile(filePath)) {
|
|
46
|
+
fileType = '.env'
|
|
47
|
+
} else if (!fileType || !KNOWN_EXTENSIONS.has(fileType.toLowerCase())) {
|
|
48
|
+
// Content-based detection for extensionless or unrecognized files
|
|
77
49
|
fileType = detectFormat(contents)
|
|
78
50
|
}
|
|
79
51
|
|
|
@@ -105,6 +77,8 @@ function parseFileContents({ contents, filePath, varRegex, dynamicArgs }) {
|
|
|
105
77
|
configObject = TOML.parse(contents)
|
|
106
78
|
} else if (fileType.match(/\.(ini)/i)) {
|
|
107
79
|
configObject = INI.parse(contents)
|
|
80
|
+
} else if (fileType === '.env') {
|
|
81
|
+
configObject = DOTENV.parse(contents)
|
|
108
82
|
} else if (fileType.match(/\.(json|json5|jsonc)/i)) {
|
|
109
83
|
configObject = JSON5.parse(contents)
|
|
110
84
|
} else if (fileType.match(/\.(tf|hcl)$/i) || filePath.match(/\.tf\.json$/i)) {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Classifies config files by type, treating dotenv files as ".env"
|
|
2
|
+
// so extension-less names like .env / .env.local / deploy.env resolve consistently
|
|
3
|
+
const path = require('path')
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Whether a path is a dotenv file (.env, .env.local, deploy.env, ...).
|
|
7
|
+
* `path.extname('.env')` is '' (dotfile), so detection is by basename.
|
|
8
|
+
* @param {string} filePath - File path or name
|
|
9
|
+
* @returns {boolean} True for dotenv files
|
|
10
|
+
*/
|
|
11
|
+
function isEnvFile(filePath) {
|
|
12
|
+
const base = path.basename(String(filePath || ''))
|
|
13
|
+
return base === '.env' || base.startsWith('.env.') || base.endsWith('.env')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Normalized config file type: '.env' for dotenv files, else the lowercased
|
|
18
|
+
* extension.
|
|
19
|
+
* @param {string} filePath - File path or name
|
|
20
|
+
* @returns {string} File type (e.g. '.yml', '.env', '')
|
|
21
|
+
*/
|
|
22
|
+
function configFileType(filePath) {
|
|
23
|
+
if (isEnvFile(filePath)) return '.env'
|
|
24
|
+
return path.extname(String(filePath || '')).toLowerCase()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = { isEnvFile, configFileType }
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/* Tests for config file type classification (dotenv detection) */
|
|
2
|
+
const { test } = require('uvu')
|
|
3
|
+
const assert = require('uvu/assert')
|
|
4
|
+
const { isEnvFile, configFileType } = require('./fileType')
|
|
5
|
+
|
|
6
|
+
test('isEnvFile detects the env-stage-loader precedence names', () => {
|
|
7
|
+
// .env.{environment}.local, .env.{environment}, .env.local, .env
|
|
8
|
+
assert.ok(isEnvFile('.env'))
|
|
9
|
+
assert.ok(isEnvFile('.env.local'))
|
|
10
|
+
assert.ok(isEnvFile('.env.production'))
|
|
11
|
+
assert.ok(isEnvFile('.env.production.local'))
|
|
12
|
+
assert.ok(isEnvFile('/path/to/.env.staging.local'))
|
|
13
|
+
assert.ok(isEnvFile('deploy.env'))
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('isEnvFile rejects non-dotenv names', () => {
|
|
17
|
+
assert.is(isEnvFile('config.yml'), false)
|
|
18
|
+
assert.is(isEnvFile('env.js'), false)
|
|
19
|
+
assert.is(isEnvFile('environment.json'), false)
|
|
20
|
+
assert.is(isEnvFile('.environment'), false)
|
|
21
|
+
assert.is(isEnvFile(''), false)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('configFileType returns .env for staged dotenv names', () => {
|
|
25
|
+
assert.is(configFileType('.env.production.local'), '.env')
|
|
26
|
+
assert.is(configFileType('.env.staging'), '.env')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('configFileType returns .env for dotenv files', () => {
|
|
30
|
+
assert.is(configFileType('.env'), '.env')
|
|
31
|
+
assert.is(configFileType('/x/.env.local'), '.env')
|
|
32
|
+
assert.is(configFileType('deploy.env'), '.env')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('configFileType returns lowercased extension otherwise', () => {
|
|
36
|
+
assert.is(configFileType('config.YML'), '.yml')
|
|
37
|
+
assert.is(configFileType('data.json'), '.json')
|
|
38
|
+
assert.is(configFileType('noext'), '')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test.run()
|
|
@@ -31,6 +31,10 @@ function findLineForKey(keyToFind, lines, fileType) {
|
|
|
31
31
|
if (fileType === '.ini') {
|
|
32
32
|
return new RegExp(`^\\s*${escapedKey}\\s*=`).test(line)
|
|
33
33
|
}
|
|
34
|
+
// dotenv: KEY= or export KEY=
|
|
35
|
+
if (fileType === '.env') {
|
|
36
|
+
return new RegExp(`^\\s*(?:export\\s+)?${escapedKey}\\s*=`).test(line)
|
|
37
|
+
}
|
|
34
38
|
// JS/TS/ESM: key: or "key": or 'key': or `key`: or [`key`]:
|
|
35
39
|
if (['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts'].includes(fileType)) {
|
|
36
40
|
return new RegExp(`(?:${escapedKey}|"${escapedKey}"|'${escapedKey}'|\`${escapedKey}\`|\\[\`${escapedKey}\`\\])\\s*:`).test(line)
|
|
@@ -55,13 +59,32 @@ function findLineByPath(configPath, lines, fileType) {
|
|
|
55
59
|
|
|
56
60
|
const isYaml = fileType === '.yml' || fileType === '.yaml'
|
|
57
61
|
const isJson = fileType === '.json' || fileType === '.json5' || fileType === '.jsonc'
|
|
58
|
-
|
|
62
|
+
const isEnv = fileType === '.env'
|
|
63
|
+
if (!isYaml && !isJson && !isEnv) return 0
|
|
59
64
|
|
|
60
65
|
const segments = configPath.split('.')
|
|
66
|
+
if (isEnv) return findLineByPathEnv(segments, lines)
|
|
61
67
|
if (isYaml) return findLineByPathYaml(segments, lines)
|
|
62
68
|
return findLineByPathJson(segments, lines)
|
|
63
69
|
}
|
|
64
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Find a dotenv key's line. .env is flat, so the key is the first path segment.
|
|
73
|
+
* Handles leading whitespace and an optional `export ` prefix.
|
|
74
|
+
* @param {string[]} segments
|
|
75
|
+
* @param {string[]} lines
|
|
76
|
+
* @returns {number}
|
|
77
|
+
*/
|
|
78
|
+
function findLineByPathEnv(segments, lines) {
|
|
79
|
+
const key = segments[0]
|
|
80
|
+
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
81
|
+
const pattern = new RegExp(`^\\s*(?:export\\s+)?${escaped}\\s*=`)
|
|
82
|
+
for (let li = 0; li < lines.length; li++) {
|
|
83
|
+
if (pattern.test(lines[li])) return li + 1
|
|
84
|
+
}
|
|
85
|
+
return 0
|
|
86
|
+
}
|
|
87
|
+
|
|
65
88
|
/**
|
|
66
89
|
* @param {string[]} segments
|
|
67
90
|
* @param {string[]} lines
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
const { test } = require('uvu')
|
|
5
5
|
const assert = require('uvu/assert')
|
|
6
|
-
const { findLineForKey } = require('./findLineForKey')
|
|
6
|
+
const { findLineForKey, findLineByPath } = require('./findLineForKey')
|
|
7
7
|
|
|
8
8
|
// YAML tests
|
|
9
9
|
test('YAML - finds key at start of line', () => {
|
|
@@ -123,4 +123,23 @@ test('unknown file type falls back to YAML-style', () => {
|
|
|
123
123
|
assert.equal(findLineForKey('foo', lines, '.unknown'), 1)
|
|
124
124
|
})
|
|
125
125
|
|
|
126
|
+
// dotenv
|
|
127
|
+
test('dotenv - finds KEY= line', () => {
|
|
128
|
+
const lines = ['API_URL=https://x', 'stage=${opt:stage}']
|
|
129
|
+
assert.equal(findLineForKey('stage', lines, '.env'), 2)
|
|
130
|
+
assert.equal(findLineForKey('API_URL', lines, '.env'), 1)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('dotenv - finds export-prefixed and whitespace-padded keys', () => {
|
|
134
|
+
const lines = ['# comment', 'export TOKEN=abc', ' PADDED = x']
|
|
135
|
+
assert.equal(findLineForKey('TOKEN', lines, '.env'), 2)
|
|
136
|
+
assert.equal(findLineForKey('PADDED', lines, '.env'), 3)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('findLineByPath - dotenv key lookup', () => {
|
|
140
|
+
const lines = ['A=1', 'stage=${opt:stage}', 'B=2']
|
|
141
|
+
assert.equal(findLineByPath('stage', lines, '.env'), 2)
|
|
142
|
+
assert.equal(findLineByPath('missing', lines, '.env'), 0)
|
|
143
|
+
})
|
|
144
|
+
|
|
126
145
|
test.run()
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Applies setup wizard answer groups onto an in-memory resolution context.
|
|
2
|
+
// The caller chooses the env target (process.env or a plain object copy).
|
|
3
|
+
const dotProp = require('dot-prop')
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {Object} ResolutionContext
|
|
7
|
+
* @property {Object.<string, any>} options - options hive for ${opt:...} variables
|
|
8
|
+
* @property {Object.<string, any>} env - env target; pass process.env or a plain object
|
|
9
|
+
* @property {Object.<string, any>} config - config object under resolution
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Apply answers from the setup wizard to a resolution context
|
|
14
|
+
* @param {ResolutionContext} context - context to mutate
|
|
15
|
+
* @param {Object} [answers] - answer groups { options, env, self, dotProp }
|
|
16
|
+
* @returns {ResolutionContext} the same context, mutated
|
|
17
|
+
*/
|
|
18
|
+
function applyAnswers(context, answers) {
|
|
19
|
+
if (!answers) return context
|
|
20
|
+
|
|
21
|
+
if (answers.options) {
|
|
22
|
+
Object.assign(context.options, answers.options)
|
|
23
|
+
}
|
|
24
|
+
if (answers.env) {
|
|
25
|
+
Object.assign(context.env, answers.env)
|
|
26
|
+
}
|
|
27
|
+
if (answers.self) {
|
|
28
|
+
Object.assign(context.config, answers.self)
|
|
29
|
+
}
|
|
30
|
+
if (answers.dotProp) {
|
|
31
|
+
for (const [key, value] of Object.entries(answers.dotProp)) {
|
|
32
|
+
dotProp.set(context.config, key, value)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return context
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = {
|
|
40
|
+
applyAnswers,
|
|
41
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Orchestrates the config setup wizard: analyze config, prompt for missing values,
|
|
2
|
+
// and return grouped answers plus a redacted copy safe for display.
|
|
3
|
+
const path = require('path')
|
|
4
|
+
const { runConfigWizard } = require('../ui/configWizard')
|
|
5
|
+
const { buildConfigRequirements } = require('../requirements/configRequirements')
|
|
6
|
+
const { redactUserInputsByRequirements } = require('../redaction/setupRedaction')
|
|
7
|
+
|
|
8
|
+
const ANSWER_GROUPS = /** @type {const} */ (['options', 'env', 'self', 'dotProp'])
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {Object} SetupAnswers
|
|
12
|
+
* @property {Object.<string, any>} options - answers for ${opt:...} variables
|
|
13
|
+
* @property {Object.<string, any>} env - answers for ${env:...} variables
|
|
14
|
+
* @property {Object.<string, any>} self - answers for ${self:...} variables
|
|
15
|
+
* @property {Object.<string, any>} dotProp - answers for dot-prop config references
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {Object} SetupResult
|
|
20
|
+
* @property {number} schemaVersion - result shape version
|
|
21
|
+
* @property {string|null} configPath - absolute path to the config file, null for object input
|
|
22
|
+
* @property {Array<Object>} requirements - prompt requirements from buildConfigRequirements
|
|
23
|
+
* @property {SetupAnswers} answers - raw user answers grouped by variable type
|
|
24
|
+
* @property {SetupAnswers} redactedAnswers - answers with sensitive values redacted for display
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Ensure every answer group exists so callers get a stable shape
|
|
29
|
+
* @param {Object|undefined} userInputs - answers from the prompt renderer
|
|
30
|
+
* @returns {SetupAnswers} answers with all groups present
|
|
31
|
+
*/
|
|
32
|
+
function normalizeAnswerGroups(userInputs) {
|
|
33
|
+
/** @type {SetupAnswers} */
|
|
34
|
+
const normalized = { options: {}, env: {}, self: {}, dotProp: {} }
|
|
35
|
+
for (const group of ANSWER_GROUPS) {
|
|
36
|
+
normalized[group] = Object.assign({}, userInputs && userInputs[group])
|
|
37
|
+
}
|
|
38
|
+
return normalized
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Run the setup flow: analyze the config, prompt for unresolved values, return answers.
|
|
43
|
+
* Pure orchestration - applies nothing to process.env, config, or disk.
|
|
44
|
+
* @param {string|Object} configPathOrObject - path to config file or raw config object
|
|
45
|
+
* @param {Object} [settings] - configorama settings, plus:
|
|
46
|
+
* @param {Function} [settings.promptRenderer] - replaces the interactive wizard (mocks, answers files)
|
|
47
|
+
* @param {Object} [settings.streams] - stream overrides forwarded to the prompt renderer
|
|
48
|
+
* @param {Object} deps - injected dependencies
|
|
49
|
+
* @param {Function} [deps.analyze] - configorama.analyze, injected to avoid a require cycle
|
|
50
|
+
* @param {Object} [deps.analysis] - pre-computed analysis; skips the analyze call
|
|
51
|
+
* @returns {Promise<SetupResult>} collected answers and requirements
|
|
52
|
+
*/
|
|
53
|
+
async function runSetup(configPathOrObject, settings = {}, deps = {}) {
|
|
54
|
+
const { analyze } = deps
|
|
55
|
+
if (!deps.analysis && typeof analyze !== 'function') {
|
|
56
|
+
throw new Error('setup engine requires an analyze function or a pre-computed analysis')
|
|
57
|
+
}
|
|
58
|
+
const { promptRenderer, streams, ...analyzeSettings } = settings
|
|
59
|
+
|
|
60
|
+
const analysis = deps.analysis || await analyze(configPathOrObject, analyzeSettings)
|
|
61
|
+
const requirements = buildConfigRequirements(analysis)
|
|
62
|
+
|
|
63
|
+
const configPath = typeof configPathOrObject === 'string'
|
|
64
|
+
? path.resolve(configPathOrObject)
|
|
65
|
+
: null
|
|
66
|
+
|
|
67
|
+
const renderPrompts = promptRenderer || runConfigWizard
|
|
68
|
+
const userInputs = await renderPrompts(analysis, analysis.originalConfig || {}, configPath || '', streams)
|
|
69
|
+
|
|
70
|
+
const answers = normalizeAnswerGroups(userInputs)
|
|
71
|
+
const redactedAnswers = normalizeAnswerGroups(redactUserInputsByRequirements(answers, requirements))
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
schemaVersion: 1,
|
|
75
|
+
configPath,
|
|
76
|
+
requirements,
|
|
77
|
+
answers,
|
|
78
|
+
redactedAnswers,
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = {
|
|
83
|
+
runSetup,
|
|
84
|
+
normalizeAnswerGroups,
|
|
85
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Temporarily delegates process.stdout writes to another stream so interactive
|
|
2
|
+
// prompt UI can render elsewhere (e.g. stderr) while stdout stays machine-clean.
|
|
3
|
+
|
|
4
|
+
const MIRRORED_TTY_PROPS = ['rows', 'columns', 'isTTY']
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Run fn with all process.stdout writes forwarded to target.
|
|
8
|
+
* TTY dimensions are mirrored from target so prompt layout matches the
|
|
9
|
+
* stream it actually renders on (stdout is often a pipe in --export mode).
|
|
10
|
+
* @param {import('stream').Writable} target - stream that receives the writes
|
|
11
|
+
* @param {Function} fn - async function to run while redirected
|
|
12
|
+
* @returns {Promise<any>} fn's result
|
|
13
|
+
*/
|
|
14
|
+
async function withStdoutRedirected(target, fn) {
|
|
15
|
+
const originalWrite = process.stdout.write
|
|
16
|
+
const savedDescriptors = {}
|
|
17
|
+
|
|
18
|
+
process.stdout.write = /** @type {typeof process.stdout.write} */ (
|
|
19
|
+
(chunk, encoding, callback) => target.write(chunk, encoding, callback)
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
for (const prop of MIRRORED_TTY_PROPS) {
|
|
23
|
+
if (target[prop] === undefined) continue
|
|
24
|
+
savedDescriptors[prop] = Object.getOwnPropertyDescriptor(process.stdout, prop) || null
|
|
25
|
+
Object.defineProperty(process.stdout, prop, {
|
|
26
|
+
configurable: true,
|
|
27
|
+
enumerable: true,
|
|
28
|
+
get() { return target[prop] },
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
return await fn()
|
|
34
|
+
} finally {
|
|
35
|
+
process.stdout.write = originalWrite
|
|
36
|
+
for (const [prop, descriptor] of Object.entries(savedDescriptors)) {
|
|
37
|
+
if (descriptor) {
|
|
38
|
+
Object.defineProperty(process.stdout, prop, descriptor)
|
|
39
|
+
} else {
|
|
40
|
+
delete process.stdout[prop]
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = {
|
|
47
|
+
withStdoutRedirected,
|
|
48
|
+
}
|