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.
Files changed (129) hide show
  1. package/README.md +41 -1
  2. package/cli.js +14 -1
  3. package/package.json +18 -6
  4. package/plugins/cloudformation/README.md +79 -0
  5. package/plugins/cloudformation/credentials.js +231 -0
  6. package/plugins/cloudformation/index.js +204 -0
  7. package/plugins/cloudformation/package-lock.json +657 -0
  8. package/plugins/cloudformation/package.json +24 -0
  9. package/plugins/onepassword/README.md +175 -0
  10. package/plugins/onepassword/fields.js +126 -0
  11. package/plugins/onepassword/index.js +356 -0
  12. package/plugins/onepassword/normalize.js +157 -0
  13. package/plugins/onepassword/op-cli.js +117 -0
  14. package/plugins/onepassword/package.json +15 -0
  15. package/plugins/onepassword/parser.js +70 -0
  16. package/plugins/onepassword/sync-factory.js +15 -0
  17. package/src/errors.js +3 -0
  18. package/src/index.js +27 -1
  19. package/src/main.js +24 -32
  20. package/src/parsers/dotenv.js +13 -0
  21. package/src/resolvers/valueFromFile.js +2 -1
  22. package/src/sync.js +27 -0
  23. package/src/utils/PromiseTracker.js +11 -4
  24. package/src/utils/PromiseTracker.test.js +48 -0
  25. package/src/utils/introspection/audit.js +39 -9
  26. package/src/utils/parsing/detectFormat.js +70 -0
  27. package/src/utils/parsing/detectFormat.test.js +68 -0
  28. package/src/utils/parsing/parse.js +11 -37
  29. package/src/utils/paths/fileType.js +27 -0
  30. package/src/utils/paths/fileType.test.js +41 -0
  31. package/src/utils/paths/findLineForKey.js +24 -1
  32. package/src/utils/paths/findLineForKey.test.js +20 -1
  33. package/src/utils/setup/applyAnswers.js +41 -0
  34. package/src/utils/setup/setupEngine.js +85 -0
  35. package/src/utils/setup/stdoutRedirect.js +48 -0
  36. package/src/utils/setup/writeAnswers.js +50 -0
  37. package/src/utils/setup/writeDotenv.js +142 -0
  38. package/src/utils/ui/configWizard.js +20 -1
  39. package/types/src/capabilities.d.ts +8 -0
  40. package/types/src/capabilities.d.ts.map +1 -0
  41. package/types/src/display.d.ts +62 -0
  42. package/types/src/display.d.ts.map +1 -0
  43. package/types/src/errors.d.ts +20 -0
  44. package/types/src/errors.d.ts.map +1 -0
  45. package/types/src/index.d.ts +119 -0
  46. package/types/src/index.d.ts.map +1 -1
  47. package/types/src/main.d.ts +23 -16
  48. package/types/src/main.d.ts.map +1 -1
  49. package/types/src/metadata.d.ts +28 -0
  50. package/types/src/metadata.d.ts.map +1 -0
  51. package/types/src/parsers/dotenv.d.ts +6 -0
  52. package/types/src/parsers/dotenv.d.ts.map +1 -0
  53. package/types/src/parsers/esm.d.ts.map +1 -1
  54. package/types/src/parsers/typescript.d.ts.map +1 -1
  55. package/types/src/resolvers/valueFromCron.d.ts.map +1 -1
  56. package/types/src/resolvers/valueFromEval.d.ts.map +1 -1
  57. package/types/src/resolvers/valueFromFile.d.ts +4 -0
  58. package/types/src/resolvers/valueFromFile.d.ts.map +1 -1
  59. package/types/src/resolvers/valueFromGit.d.ts.map +1 -1
  60. package/types/src/resolvers/valueFromOptions.d.ts +1 -0
  61. package/types/src/resolvers/valueFromOptions.d.ts.map +1 -1
  62. package/types/src/sync.d.ts.map +1 -1
  63. package/types/src/utils/BoundedMap.d.ts +10 -0
  64. package/types/src/utils/BoundedMap.d.ts.map +1 -0
  65. package/types/src/utils/PromiseTracker.d.ts +1 -1
  66. package/types/src/utils/PromiseTracker.d.ts.map +1 -1
  67. package/types/src/utils/filters/filterArgs.d.ts +13 -0
  68. package/types/src/utils/filters/filterArgs.d.ts.map +1 -0
  69. package/types/src/utils/filters/oneOf.d.ts +7 -0
  70. package/types/src/utils/filters/oneOf.d.ts.map +1 -0
  71. package/types/src/utils/introspection/audit.d.ts +14 -0
  72. package/types/src/utils/introspection/audit.d.ts.map +1 -0
  73. package/types/src/utils/introspection/graph.d.ts +4 -0
  74. package/types/src/utils/introspection/graph.d.ts.map +1 -0
  75. package/types/src/utils/introspection/model.d.ts +43 -0
  76. package/types/src/utils/introspection/model.d.ts.map +1 -0
  77. package/types/src/utils/parsing/commentAnnotations.d.ts +18 -0
  78. package/types/src/utils/parsing/commentAnnotations.d.ts.map +1 -0
  79. package/types/src/utils/parsing/detectFormat.d.ts +17 -0
  80. package/types/src/utils/parsing/detectFormat.d.ts.map +1 -0
  81. package/types/src/utils/parsing/enrichMetadata.d.ts.map +1 -1
  82. package/types/src/utils/parsing/extractComment.d.ts +20 -0
  83. package/types/src/utils/parsing/extractComment.d.ts.map +1 -0
  84. package/types/src/utils/parsing/parse.d.ts.map +1 -1
  85. package/types/src/utils/parsing/preProcess.d.ts.map +1 -1
  86. package/types/src/utils/paths/fileType.d.ts +15 -0
  87. package/types/src/utils/paths/fileType.d.ts.map +1 -0
  88. package/types/src/utils/paths/findLineForKey.d.ts +9 -0
  89. package/types/src/utils/paths/findLineForKey.d.ts.map +1 -1
  90. package/types/src/utils/paths/ignorePaths.d.ts +5 -0
  91. package/types/src/utils/paths/ignorePaths.d.ts.map +1 -0
  92. package/types/src/utils/redaction/redact.d.ts +10 -0
  93. package/types/src/utils/redaction/redact.d.ts.map +1 -0
  94. package/types/src/utils/redaction/setupRedaction.d.ts +5 -0
  95. package/types/src/utils/redaction/setupRedaction.d.ts.map +1 -0
  96. package/types/src/utils/requirements/configRequirements.d.ts +70 -0
  97. package/types/src/utils/requirements/configRequirements.d.ts.map +1 -0
  98. package/types/src/utils/requirements/serializeRequirements.d.ts +63 -0
  99. package/types/src/utils/requirements/serializeRequirements.d.ts.map +1 -0
  100. package/types/src/utils/security/dotenvFileRefs.d.ts +13 -0
  101. package/types/src/utils/security/dotenvFileRefs.d.ts.map +1 -0
  102. package/types/src/utils/security/evalSafety.d.ts +17 -0
  103. package/types/src/utils/security/evalSafety.d.ts.map +1 -0
  104. package/types/src/utils/security/safetyPolicy.d.ts +15 -0
  105. package/types/src/utils/security/safetyPolicy.d.ts.map +1 -0
  106. package/types/src/utils/setup/applyAnswers.d.ts +34 -0
  107. package/types/src/utils/setup/applyAnswers.d.ts.map +1 -0
  108. package/types/src/utils/setup/setupEngine.d.ts +89 -0
  109. package/types/src/utils/setup/setupEngine.d.ts.map +1 -0
  110. package/types/src/utils/setup/stdoutRedirect.d.ts +10 -0
  111. package/types/src/utils/setup/stdoutRedirect.d.ts.map +1 -0
  112. package/types/src/utils/setup/writeAnswers.d.ts +17 -0
  113. package/types/src/utils/setup/writeAnswers.d.ts.map +1 -0
  114. package/types/src/utils/setup/writeDotenv.d.ts +27 -0
  115. package/types/src/utils/setup/writeDotenv.d.ts.map +1 -0
  116. package/types/src/utils/strings/didYouMean.d.ts +23 -0
  117. package/types/src/utils/strings/didYouMean.d.ts.map +1 -0
  118. package/types/src/utils/strings/formatFunctionArgs.d.ts.map +1 -1
  119. package/types/src/utils/strings/replaceAll.d.ts.map +1 -1
  120. package/types/src/utils/strings/splitByComma.d.ts.map +1 -1
  121. package/types/src/utils/ui/configWizard.d.ts +51 -7
  122. package/types/src/utils/ui/configWizard.d.ts.map +1 -1
  123. package/types/src/utils/ui/createEditorLink.d.ts +7 -0
  124. package/types/src/utils/ui/createEditorLink.d.ts.map +1 -1
  125. package/types/src/utils/ui/promptDescriptors.d.ts +39 -0
  126. package/types/src/utils/ui/promptDescriptors.d.ts.map +1 -0
  127. package/types/src/utils/variables/cleanVariable.d.ts.map +1 -1
  128. package/types/src/utils/variables/getVariableType.d.ts.map +1 -1
  129. 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
- for (const resolver of options.customResolvers.slice().sort()) {
45
- findings.push({
46
- id: `customResolver:${resolver}`,
47
- severity: 'high',
48
- risk: 'custom_extension',
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
- // Content-based detection for extensionless or unrecognized files
76
- if (!fileType || !KNOWN_EXTENSIONS.has(fileType.toLowerCase())) {
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
- if (!isYaml && !isJson) return 0
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
+ }