configorama 1.1.0 → 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 (108) hide show
  1. package/README.md +10 -1
  2. package/cli.js +14 -1
  3. package/package.json +9 -4
  4. package/plugins/onepassword/index.js +15 -4
  5. package/plugins/onepassword/normalize.js +13 -1
  6. package/src/errors.js +3 -0
  7. package/src/index.js +19 -0
  8. package/src/main.js +18 -29
  9. package/src/utils/parsing/detectFormat.js +70 -0
  10. package/src/utils/parsing/detectFormat.test.js +68 -0
  11. package/src/utils/parsing/parse.js +1 -35
  12. package/src/utils/setup/applyAnswers.js +41 -0
  13. package/src/utils/setup/setupEngine.js +85 -0
  14. package/src/utils/setup/stdoutRedirect.js +48 -0
  15. package/src/utils/setup/writeAnswers.js +50 -0
  16. package/src/utils/setup/writeDotenv.js +142 -0
  17. package/src/utils/ui/configWizard.js +20 -1
  18. package/types/src/capabilities.d.ts +8 -0
  19. package/types/src/capabilities.d.ts.map +1 -0
  20. package/types/src/display.d.ts +62 -0
  21. package/types/src/display.d.ts.map +1 -0
  22. package/types/src/errors.d.ts +20 -0
  23. package/types/src/errors.d.ts.map +1 -0
  24. package/types/src/index.d.ts +119 -0
  25. package/types/src/index.d.ts.map +1 -1
  26. package/types/src/main.d.ts +23 -16
  27. package/types/src/main.d.ts.map +1 -1
  28. package/types/src/metadata.d.ts +28 -0
  29. package/types/src/metadata.d.ts.map +1 -0
  30. package/types/src/parsers/dotenv.d.ts +6 -0
  31. package/types/src/parsers/dotenv.d.ts.map +1 -0
  32. package/types/src/parsers/esm.d.ts.map +1 -1
  33. package/types/src/parsers/typescript.d.ts.map +1 -1
  34. package/types/src/resolvers/valueFromCron.d.ts.map +1 -1
  35. package/types/src/resolvers/valueFromEval.d.ts.map +1 -1
  36. package/types/src/resolvers/valueFromFile.d.ts +4 -0
  37. package/types/src/resolvers/valueFromFile.d.ts.map +1 -1
  38. package/types/src/resolvers/valueFromGit.d.ts.map +1 -1
  39. package/types/src/resolvers/valueFromOptions.d.ts +1 -0
  40. package/types/src/resolvers/valueFromOptions.d.ts.map +1 -1
  41. package/types/src/sync.d.ts.map +1 -1
  42. package/types/src/utils/BoundedMap.d.ts +10 -0
  43. package/types/src/utils/BoundedMap.d.ts.map +1 -0
  44. package/types/src/utils/PromiseTracker.d.ts +1 -1
  45. package/types/src/utils/PromiseTracker.d.ts.map +1 -1
  46. package/types/src/utils/filters/filterArgs.d.ts +13 -0
  47. package/types/src/utils/filters/filterArgs.d.ts.map +1 -0
  48. package/types/src/utils/filters/oneOf.d.ts +7 -0
  49. package/types/src/utils/filters/oneOf.d.ts.map +1 -0
  50. package/types/src/utils/introspection/audit.d.ts +14 -0
  51. package/types/src/utils/introspection/audit.d.ts.map +1 -0
  52. package/types/src/utils/introspection/graph.d.ts +4 -0
  53. package/types/src/utils/introspection/graph.d.ts.map +1 -0
  54. package/types/src/utils/introspection/model.d.ts +43 -0
  55. package/types/src/utils/introspection/model.d.ts.map +1 -0
  56. package/types/src/utils/parsing/commentAnnotations.d.ts +18 -0
  57. package/types/src/utils/parsing/commentAnnotations.d.ts.map +1 -0
  58. package/types/src/utils/parsing/detectFormat.d.ts +17 -0
  59. package/types/src/utils/parsing/detectFormat.d.ts.map +1 -0
  60. package/types/src/utils/parsing/enrichMetadata.d.ts.map +1 -1
  61. package/types/src/utils/parsing/extractComment.d.ts +20 -0
  62. package/types/src/utils/parsing/extractComment.d.ts.map +1 -0
  63. package/types/src/utils/parsing/parse.d.ts.map +1 -1
  64. package/types/src/utils/parsing/preProcess.d.ts.map +1 -1
  65. package/types/src/utils/paths/fileType.d.ts +15 -0
  66. package/types/src/utils/paths/fileType.d.ts.map +1 -0
  67. package/types/src/utils/paths/findLineForKey.d.ts +9 -0
  68. package/types/src/utils/paths/findLineForKey.d.ts.map +1 -1
  69. package/types/src/utils/paths/ignorePaths.d.ts +5 -0
  70. package/types/src/utils/paths/ignorePaths.d.ts.map +1 -0
  71. package/types/src/utils/redaction/redact.d.ts +10 -0
  72. package/types/src/utils/redaction/redact.d.ts.map +1 -0
  73. package/types/src/utils/redaction/setupRedaction.d.ts +5 -0
  74. package/types/src/utils/redaction/setupRedaction.d.ts.map +1 -0
  75. package/types/src/utils/requirements/configRequirements.d.ts +70 -0
  76. package/types/src/utils/requirements/configRequirements.d.ts.map +1 -0
  77. package/types/src/utils/requirements/serializeRequirements.d.ts +63 -0
  78. package/types/src/utils/requirements/serializeRequirements.d.ts.map +1 -0
  79. package/types/src/utils/security/dotenvFileRefs.d.ts +13 -0
  80. package/types/src/utils/security/dotenvFileRefs.d.ts.map +1 -0
  81. package/types/src/utils/security/evalSafety.d.ts +17 -0
  82. package/types/src/utils/security/evalSafety.d.ts.map +1 -0
  83. package/types/src/utils/security/safetyPolicy.d.ts +15 -0
  84. package/types/src/utils/security/safetyPolicy.d.ts.map +1 -0
  85. package/types/src/utils/setup/applyAnswers.d.ts +34 -0
  86. package/types/src/utils/setup/applyAnswers.d.ts.map +1 -0
  87. package/types/src/utils/setup/setupEngine.d.ts +89 -0
  88. package/types/src/utils/setup/setupEngine.d.ts.map +1 -0
  89. package/types/src/utils/setup/stdoutRedirect.d.ts +10 -0
  90. package/types/src/utils/setup/stdoutRedirect.d.ts.map +1 -0
  91. package/types/src/utils/setup/writeAnswers.d.ts +17 -0
  92. package/types/src/utils/setup/writeAnswers.d.ts.map +1 -0
  93. package/types/src/utils/setup/writeDotenv.d.ts +27 -0
  94. package/types/src/utils/setup/writeDotenv.d.ts.map +1 -0
  95. package/types/src/utils/strings/didYouMean.d.ts +23 -0
  96. package/types/src/utils/strings/didYouMean.d.ts.map +1 -0
  97. package/types/src/utils/strings/formatFunctionArgs.d.ts.map +1 -1
  98. package/types/src/utils/strings/replaceAll.d.ts.map +1 -1
  99. package/types/src/utils/strings/splitByComma.d.ts.map +1 -1
  100. package/types/src/utils/ui/configWizard.d.ts +51 -7
  101. package/types/src/utils/ui/configWizard.d.ts.map +1 -1
  102. package/types/src/utils/ui/createEditorLink.d.ts +7 -0
  103. package/types/src/utils/ui/createEditorLink.d.ts.map +1 -1
  104. package/types/src/utils/ui/promptDescriptors.d.ts +39 -0
  105. package/types/src/utils/ui/promptDescriptors.d.ts.map +1 -0
  106. package/types/src/utils/variables/cleanVariable.d.ts.map +1 -1
  107. package/types/src/utils/variables/getVariableType.d.ts.map +1 -1
  108. package/types/src/utils/variables/variableUtils.d.ts +1 -1
package/README.md CHANGED
@@ -2304,7 +2304,16 @@ stage: ${opt:stage | help('Deployment stage, e.g. dev or prod')}
2304
2304
 
2305
2305
  The [Variable Source Types](#variable-source-types) table describes how the wizard treats each source.
2306
2306
 
2307
- > **Experimental:** the wizard fills in values for the current resolution run only. It does not write your answers back to the config file yet.
2307
+ The wizard prints a summary of your answers with sensitive values redacted, applies them to the current resolution run, and never edits the source config file.
2308
+
2309
+ Programmatic access to the same engine returns the answers without applying them anywhere:
2310
+
2311
+ ```javascript
2312
+ const result = await configorama.setup('config.yml')
2313
+ // { schemaVersion, configPath, requirements, answers, redactedAnswers }
2314
+ ```
2315
+
2316
+ To apply answers to explicit targets - your current shell, a one-off command, a `.env.local`, or a JSON answers file - use [`configx setup`](./packages/configx/README.md#setup-wizard). Its safe write behavior (0600 permissions, managed-block merge, sensitive-value confirmation) is built on the same engine.
2308
2317
 
2309
2318
  ---
2310
2319
 
package/cli.js CHANGED
@@ -61,7 +61,8 @@ Commands:
61
61
  (default) Resolve <file> and print the result
62
62
  inspect <file> Introspect a config without resolving it (full model)
63
63
  --view requirements|audit|graph for a single slice
64
- setup <file> Run the interactive config wizard (experimental)
64
+ setup <file> Prompt for missing config values, then resolve with the
65
+ answers applied (summary redacts sensitive values)
65
66
  capabilities Print the machine-readable CLI contract (JSON)
66
67
 
67
68
  Options:
@@ -449,6 +450,17 @@ if (requirementsMode) {
449
450
  // Process the configuration
450
451
  const shouldRedactSetupStdout = options.setup && !argv.output && !argv.copy
451
452
  let setupRequirementsForRedaction = []
453
+
454
+ // If stdin ends mid-wizard the pending prompt promise never settles, the event
455
+ // loop drains, and node would exit 0 as if setup succeeded. Fail closed instead.
456
+ const onSetupBeforeExit = () => {
457
+ process.stderr.write('configorama: setup cancelled (input ended before completion)\n')
458
+ process.exit(1)
459
+ }
460
+ if (options.setup) {
461
+ process.on('beforeExit', onSetupBeforeExit)
462
+ }
463
+
452
464
  const configPromise = shouldRedactSetupStdout
453
465
  ? (() => {
454
466
  const instance = new Configorama(inputFile, options)
@@ -461,6 +473,7 @@ const configPromise = shouldRedactSetupStdout
461
473
 
462
474
  configPromise
463
475
  .then((config) => {
476
+ process.removeListener('beforeExit', onSetupBeforeExit)
464
477
  let outputConfig = shouldRedactSetupStdout
465
478
  ? redactConfigByRequirements(config, setupRequirementsForRedaction)
466
479
  : config
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "configorama",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "Variable support for configuration files",
5
5
  "main": "src/index.js",
6
6
  "types": "index.d.ts",
@@ -17,7 +17,6 @@
17
17
  "types",
18
18
  "index.d.ts",
19
19
  "package.json",
20
- "package-lock.json",
21
20
  "README.md",
22
21
  "!plugins/*/*.test.js",
23
22
  "!plugins/*/example",
@@ -48,7 +47,10 @@
48
47
  "publish": "git push origin && git push origin --tags",
49
48
  "release:patch": "npm version patch && npm publish",
50
49
  "release:minor": "npm version minor && npm publish",
51
- "release:major": "npm version major && npm publish"
50
+ "release:major": "npm version major && npm publish",
51
+ "release": "lerna publish",
52
+ "version:all": "lerna version --no-push --no-git-tag-version",
53
+ "test:all": "pnpm -r test"
52
54
  },
53
55
  "author": "David Wells",
54
56
  "license": "MIT",
@@ -82,6 +84,7 @@
82
84
  "devDependencies": {
83
85
  "@cdktf/hcl2json": "^0.21.0",
84
86
  "@types/node": "^24.10.1",
87
+ "lerna": "^9.0.0",
85
88
  "markdown-magic": "^4.8.0",
86
89
  "tsx": "^4.7.0",
87
90
  "typescript": "^5.8.3",
@@ -106,5 +109,7 @@
106
109
  "ts-node": {
107
110
  "optional": true
108
111
  }
109
- }
112
+ },
113
+ "packageManager": "pnpm@10.33.0",
114
+ "gitHead": "2ce70bfa849433ba74c60fa3c1cd3dba942b6556"
110
115
  }
@@ -1,7 +1,7 @@
1
1
  /* 1Password variable source for configorama
2
2
  Resolves ${op:alias} and ${op(item)} references through the op CLI */
3
3
  const { readSecretRef, getItem } = require('./op-cli')
4
- const { validateAliasName, normalizeRefValue } = require('./normalize')
4
+ const { validateAliasName, normalizeRefValue, isItemId } = require('./normalize')
5
5
  const { selectField, trySelectField } = require('./fields')
6
6
  const { parseStructuredSecret, getKeyPath } = require('./parser')
7
7
 
@@ -173,10 +173,21 @@ function createOnePasswordResolver(options = {}) {
173
173
 
174
174
  validateAliasName(alias)
175
175
  const reference = aliasRefs[alias]
176
- if (!reference) {
177
- throw new Error(`Unknown 1Password alias "${alias}". Configure refs.${alias}.`)
176
+ if (reference) {
177
+ return { reference, keyPath, alias }
178
178
  }
179
- return { reference, keyPath, alias }
179
+ // Not a configured alias. A bare 1Password item ID (26-char base32) is
180
+ // unambiguous, so accept it as a direct item reference. Configured aliases
181
+ // always take precedence (checked above), so this only affects strings that
182
+ // would otherwise error as an unknown alias.
183
+ if (isItemId(alias)) {
184
+ return {
185
+ reference: { kind: 'item', item: alias, vault: undefined, section: undefined, field: undefined },
186
+ keyPath,
187
+ alias: undefined,
188
+ }
189
+ }
190
+ throw new Error(`Unknown 1Password alias "${alias}". Configure refs.${alias}.`)
180
191
  }
181
192
 
182
193
  /**
@@ -2,8 +2,20 @@
2
2
  Validates aliases, parses private item links, rejects public share links */
3
3
 
4
4
  const ALIAS_PATTERN = /^[A-Za-z0-9_]+$/
5
+ // 1Password item and vault IDs are 26-character lowercase base32 strings.
6
+ const ITEM_ID_PATTERN = /^[a-z0-9]{26}$/
5
7
  const PRIVATE_LINK_PREFIXES = ['https://start.1password.com/open/i', 'onepassword://open/i']
6
8
 
9
+ /**
10
+ * Whether a string has the shape of a 1Password item ID (26-char base32).
11
+ * Used to let colon syntax accept a bare item ID where an alias would go.
12
+ * @param {string} value - Candidate string
13
+ * @returns {boolean} True when it looks like an item ID
14
+ */
15
+ function isItemId(value) {
16
+ return ITEM_ID_PATTERN.test(value)
17
+ }
18
+
7
19
  /**
8
20
  * Validate an alias name from the refs config.
9
21
  * Dots are reserved as the key path separator so they cannot appear in aliases.
@@ -142,4 +154,4 @@ function normalizeObjectRef(value) {
142
154
  }
143
155
  }
144
156
 
145
- module.exports = { validateAliasName, normalizeRefValue, parsePrivateLink, isPrivateLink }
157
+ module.exports = { validateAliasName, normalizeRefValue, parsePrivateLink, isPrivateLink, isItemId }
package/src/errors.js CHANGED
@@ -16,6 +16,9 @@ const ERROR_CODES = [
16
16
  { code: 'no_input_file', description: 'No config file was provided on the command line.' },
17
17
  { code: 'file_not_found', description: 'The provided config file path does not exist.' },
18
18
  { code: 'path_not_found', description: 'A jq-style extraction path matched nothing in the resolved config.' },
19
+ { code: 'invalid_env_key', description: 'A setup write target received a key that is not a valid dotenv name.' },
20
+ { code: 'target_file_exists', description: 'A setup write target already exists and neither --merge nor --force was passed.' },
21
+ { code: 'managed_block_invalid', description: 'A dotenv merge target has missing, duplicated, or out-of-order managed block markers.' },
19
22
  { code: 'configorama_error', description: 'Generic, unclassified configorama error.' },
20
23
  ]
21
24
 
package/src/index.js CHANGED
@@ -4,6 +4,9 @@ const enrichMetadata = require('./utils/parsing/enrichMetadata')
4
4
  const { buildVariableSyntax } = require('./utils/variables/variableUtils')
5
5
  const { serializeRequirements } = require('./utils/requirements/serializeRequirements')
6
6
  const { buildConfigRequirements } = require('./utils/requirements/configRequirements')
7
+ const { runSetup } = require('./utils/setup/setupEngine')
8
+ const { writeDotenv } = require('./utils/setup/writeDotenv')
9
+ const { writeAnswers } = require('./utils/setup/writeAnswers')
7
10
  const { buildIntrospection } = require('./utils/introspection/model')
8
11
  const { buildAuditReport } = require('./utils/introspection/audit')
9
12
  const { formatGraph } = require('./utils/introspection/graph')
@@ -142,6 +145,22 @@ module.exports.analyze = async (configPathOrObject, settings = {}) => {
142
145
  return analysis
143
146
  }
144
147
 
148
+ /**
149
+ * Run the interactive setup wizard and return grouped answers without applying them
150
+ * @param {string|Object} configPathOrObject - Path to config file or raw config object
151
+ * @param {object} [settings] - Same settings as the main API, plus promptRenderer/streams
152
+ * @return {Promise<object>} { schemaVersion, configPath, requirements, answers, redactedAnswers }
153
+ */
154
+ module.exports.setup = async (configPathOrObject, settings = {}) => {
155
+ return runSetup(configPathOrObject, settings, { analyze: module.exports.analyze })
156
+ }
157
+
158
+ /**
159
+ * Safe writers for persisting setup answers (0600, atomic, overwrite-guarded)
160
+ */
161
+ module.exports.writeDotenv = writeDotenv
162
+ module.exports.writeAnswers = writeAnswers
163
+
145
164
  module.exports.introspect = async (configPathOrObject, settings = {}) => {
146
165
  const analysis = await module.exports.analyze(configPathOrObject, {
147
166
  ...settings,
package/src/main.js CHANGED
@@ -110,9 +110,8 @@ const { validateOneOf } = require('./utils/filters/oneOf')
110
110
  const chalk = require('./utils/ui/chalk')
111
111
  const deepLog = require('./utils/ui/deep-log')
112
112
  const { logHeader } = require('./utils/ui/logs')
113
- const { runConfigWizard } = require('./utils/ui/configWizard')
114
- const { buildConfigRequirements } = require('./utils/requirements/configRequirements')
115
- const { redactUserInputsByRequirements } = require('./utils/redaction/setupRedaction')
113
+ const { runSetup } = require('./utils/setup/setupEngine')
114
+ const { applyAnswers } = require('./utils/setup/applyAnswers')
116
115
  /* Display */
117
116
  const { displayNoVariablesFound, displayVariableDetails, displayUniqueVariables, displayConfigurableVariables } = require('./display')
118
117
  /* Metadata */
@@ -176,7 +175,6 @@ const functionPrefixPattern = /^> function /
176
175
 
177
176
  let DEBUG = process.argv.includes('--debug') ? true : false
178
177
  let VERBOSE = process.argv.includes('--verbose') ? true : false
179
- let SETUP_MODE = process.argv.includes('--setup') ? true : false
180
178
  // DEBUG = true
181
179
  let DEBUG_TYPE = false
182
180
 
@@ -188,8 +186,8 @@ class Configorama {
188
186
  }
189
187
 
190
188
  const options = opts || {}
191
- // Setup wizard runs when --setup flag is passed (CLI) or options.setup is true (library)
192
- this.setupMode = SETUP_MODE || options.setup === true
189
+ // Setup wizard is explicit opt-in: the CLI translates --setup/`setup` into options.setup
190
+ this.setupMode = options.setup === true
193
191
  // Set opts to pass into JS file calls
194
192
  this.settings = Object.assign({}, {
195
193
  // Allow unknown ${xyz:...} syntax where xyz is not a registered resolver
@@ -940,34 +938,25 @@ class Configorama {
940
938
  if (this.setupMode) {
941
939
  logHeader('Setup Mode')
942
940
  // deepLog('enrich', enrich)
943
- const userInputs = await runConfigWizard(enrich, this.originalConfig, this.configFilePath)
944
- const setupRequirements = buildConfigRequirements(enrich)
945
- this.setupRequirements = setupRequirements
946
- const displayInputs = redactUserInputsByRequirements(userInputs, setupRequirements)
941
+ const setupResult = await runSetup(this.configFilePath || this.config, this.settings, {
942
+ analysis: Object.assign({ originalConfig: this.originalConfig }, enrich),
943
+ })
944
+ this.setupRequirements = setupResult.requirements
945
+
946
+ // Summary shows only answered groups, sensitive values redacted
947
+ const displayInputs = {}
948
+ for (const [group, values] of Object.entries(setupResult.redactedAnswers)) {
949
+ if (Object.keys(values).length > 0) {
950
+ displayInputs[group] = values
951
+ }
952
+ }
947
953
 
948
954
  logHeader('User Inputs Summary')
949
955
  console.log()
950
956
  console.log(JSON.stringify(displayInputs, null, 2))
951
957
 
952
- // TODO set values
953
-
954
- // Apply user inputs to options and environment
955
- if (userInputs.options) {
956
- Object.assign(this.options, userInputs.options)
957
- }
958
- if (userInputs.env) {
959
- Object.assign(process.env, userInputs.env)
960
- }
961
-
962
- if (userInputs.self) {
963
- Object.assign(this.config, userInputs.self)
964
- }
965
-
966
- if (userInputs.dotProp) {
967
- for (const [key, value] of Object.entries(userInputs.dotProp)) {
968
- dotProp.set(this.config, key, value)
969
- }
970
- }
958
+ // Apply user inputs to options, environment, and config
959
+ applyAnswers({ options: this.options, env: process.env, config: this.config }, setupResult.answers)
971
960
 
972
961
  console.log()
973
962
  logHeader('Resolving Configuration')
@@ -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()
@@ -8,6 +8,7 @@ const DOTENV = require('../../parsers/dotenv')
8
8
  const JSON5 = require('../../parsers/json5')
9
9
  const HCL = require('../../parsers/hcl')
10
10
  const { isEnvFile } = require('../paths/fileType')
11
+ const { detectFormat } = require('./detectFormat')
11
12
  const { executeTypeScriptFileSync } = require('../../parsers/typescript')
12
13
  const { executeESMFileSync } = require('../../parsers/esm')
13
14
  const cloudFormationSchema = require('./cloudformationSchema')
@@ -23,41 +24,6 @@ const KNOWN_EXTENSIONS = new Set([
23
24
  '.md', '.mdx', '.markdown', '.mdown', '.mkdn', '.mkd', '.mdwn', '.markdn', '.mdtxt', '.mdtext'
24
25
  ])
25
26
 
26
- /**
27
- * Detect config format from file contents when extension is missing
28
- * @param {string} contents - Raw file contents
29
- * @returns {string} Detected file extension (e.g. '.json', '.yml', '.toml')
30
- */
31
- function detectFormat(contents) {
32
- const trimmed = contents.trimStart()
33
-
34
- // JSON object: starts with {
35
- if (trimmed[0] === '{') return '.json'
36
-
37
- // TOML section headers must be checked before JSON array (both start with [)
38
- // TOML: [section.subsection] (dots distinguish from INI)
39
- if (/^\[[\w-]+\.[\w.-]+\]/.test(trimmed)) return '.toml'
40
- // TOML: array-of-tables [[section]]
41
- if (/^\[\[[\w.-]+\]\]/.test(trimmed)) return '.toml'
42
-
43
- // JSON array: starts with [ followed by non-word char (quotes, numbers, braces, whitespace)
44
- if (trimmed[0] === '[') return '.json'
45
-
46
- // TOML: multi-line strings
47
- if (trimmed.startsWith('"""')) return '.toml'
48
- // TOML: key = value
49
- if (/^\w[\w.-]*\s*=\s/m.test(trimmed)) return '.toml'
50
-
51
- // YAML: starts with document marker
52
- if (trimmed.startsWith('---')) return '.yml'
53
-
54
- // HCL: terraform keywords
55
- if (/^(resource|variable|locals|provider|data|module|output|terraform)\s/.test(trimmed)) return '.tf'
56
-
57
- // Default: YAML (most permissive parser)
58
- return '.yml'
59
- }
60
-
61
27
  /**
62
28
  * @typedef {Object} ParseOptions
63
29
  * @property {string} contents - Raw file contents to parse
@@ -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
+ }