configorama 0.9.11 → 0.9.13

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 (40) hide show
  1. package/README.md +1 -0
  2. package/cli.js +46 -8
  3. package/index.d.ts +38 -29
  4. package/package.json +1 -17
  5. package/src/main.js +23 -9
  6. package/src/parsers/index.js +3 -1
  7. package/src/parsers/markdown.js +69 -0
  8. package/src/parsers/markdown.test.js +132 -0
  9. package/src/resolvers/valueFromGit.js +27 -24
  10. package/src/resolvers/valueFromNumber.js +10 -1
  11. package/src/types.d.ts +1 -1
  12. package/src/utils/handleSignalEvents.js +1 -5
  13. package/src/utils/lodash.js +72 -18
  14. package/src/utils/parsing/cloudformationSchema.js +5 -10
  15. package/src/utils/parsing/getValueAtPath.js +111 -0
  16. package/src/utils/parsing/getValueAtPath.test.js +152 -0
  17. package/src/utils/parsing/parse.js +21 -0
  18. package/src/utils/regex/index.js +5 -0
  19. package/src/utils/ui/configWizard.js +4 -4
  20. package/src/utils/validation/warnIfNotFound.js +5 -1
  21. package/src/utils/variables/cleanVariable.js +1 -3
  22. package/types/src/main.d.ts +2 -0
  23. package/types/src/main.d.ts.map +1 -1
  24. package/types/src/parsers/markdown.d.ts +17 -0
  25. package/types/src/parsers/markdown.d.ts.map +1 -0
  26. package/types/src/resolvers/valueFromGit.d.ts.map +1 -1
  27. package/types/src/resolvers/valueFromNumber.d.ts +10 -2
  28. package/types/src/resolvers/valueFromNumber.d.ts.map +1 -1
  29. package/types/src/utils/handleSignalEvents.d.ts.map +1 -1
  30. package/types/src/utils/lodash.d.ts +50 -3
  31. package/types/src/utils/lodash.d.ts.map +1 -1
  32. package/types/src/utils/parsing/getValueAtPath.d.ts +18 -0
  33. package/types/src/utils/parsing/getValueAtPath.d.ts.map +1 -0
  34. package/types/src/utils/parsing/parse.d.ts.map +1 -1
  35. package/types/src/utils/regex/index.d.ts +2 -0
  36. package/types/src/utils/regex/index.d.ts.map +1 -1
  37. package/types/src/utils/validation/warnIfNotFound.d.ts +4 -0
  38. package/types/src/utils/validation/warnIfNotFound.d.ts.map +1 -1
  39. package/types/src/utils/variables/cleanVariable.d.ts +1 -1
  40. package/types/src/utils/variables/cleanVariable.d.ts.map +1 -1
package/README.md CHANGED
@@ -283,6 +283,7 @@ Supported file types (extensions are case-insensitive):
283
283
  | TOML | `.toml`, `.tml` |
284
284
  | INI | `.ini` |
285
285
  | JSON | `.json`, `.json5`, `.jsonc` |
286
+ | Markdown | `.md`, `.mdx`, `.markdown`, `.mdown`, `.mkdn`, `.mkd` |
286
287
 
287
288
  ### Sync/Async file references
288
289
 
package/cli.js CHANGED
@@ -7,14 +7,16 @@ const deepLog = require('./src/utils/ui/deep-log')
7
7
  const { logHeader } = require('./src/utils/ui/logs')
8
8
  const configorama = require('./src')
9
9
  const { makeBox } = require('@davidwells/box-logger')
10
+ const getValueAtPath = require('./src/utils/parsing/getValueAtPath')
10
11
 
11
12
  // Parse command line arguments
12
13
  const argv = minimist(process.argv.slice(2), {
13
14
  string: ['output', 'o', 'format', 'f', 'param'],
14
- boolean: ['help', 'h', 'version', 'v', 'debug', 'allow-unknown', 'allow-undefined', 'list', 'info', 'verify'],
15
+ boolean: ['help', 'h', 'version', 'v', 'V', 'debug', 'allow-unknown', 'allow-undefined', 'list', 'info', 'verify'],
15
16
  alias: {
16
17
  h: 'help',
17
- v: 'verify',
18
+ v: 'version',
19
+ V: 'verify',
18
20
  o: 'output',
19
21
  f: 'format',
20
22
  l: 'list',
@@ -31,7 +33,7 @@ if (argv.help) {
31
33
  Configorama - Variable resolution for configuration files
32
34
 
33
35
  Usage:
34
- configorama [options] <file>
36
+ configorama [options] <file> [path]
35
37
 
36
38
  Options:
37
39
  -h, --help Show this help message
@@ -40,13 +42,27 @@ Options:
40
42
  -f, --format <format> Output format: json, yaml, or js (default: json)
41
43
  -d, --debug Enable debug mode
42
44
  -i, --info Show info about the config
43
- -v, --verify Verify the config
45
+ -V, --verify Verify the config
44
46
  --param <key=value> Pass parameter values (can be used multiple times)
45
47
  --allow-unknown Allow unknown variables to pass through
46
48
  --allow-undefined Allow undefined values in the final output
47
49
 
50
+ Path Extraction:
51
+ Use jq-style paths to extract specific values from the resolved config.
52
+ Paths can appear before or after options.
53
+
54
+ Supported syntax:
55
+ .foo Object key access
56
+ .foo.bar Nested key access
57
+ .[0] Array index (0-based)
58
+ .[-1] Negative index (from end)
59
+ .foo[0].bar Mixed access
60
+ .["key-name"] Bracket notation for special keys
61
+
48
62
  Examples:
49
63
  configorama config.yml
64
+ configorama config.yml .database.host
65
+ configorama '.servers[0].port' config.yml
50
66
  configorama --info config.yml
51
67
  configorama --format yaml config.json
52
68
  configorama --output resolved.json config.yml
@@ -63,8 +79,22 @@ if (argv.version) {
63
79
  process.exit(0)
64
80
  }
65
81
 
66
- // Check for input file
67
- const inputFile = argv._[0]
82
+ // Parse positional args: find file path and jq-style extraction path
83
+ // File is first arg that exists as a file, jq path starts with '.' or '['
84
+ let inputFile = null
85
+ let extractPath = null
86
+
87
+ for (const arg of argv._) {
88
+ if (arg === 'setup') continue
89
+
90
+ // jq-style paths start with '.' or '['
91
+ if (arg.startsWith('.') || arg.startsWith('[')) {
92
+ extractPath = arg
93
+ } else if (!inputFile) {
94
+ inputFile = arg
95
+ }
96
+ }
97
+
68
98
  if (!inputFile) {
69
99
  console.error('Error: No input file specified')
70
100
  console.error('Run with --help for usage information')
@@ -135,6 +165,15 @@ options.options = rest
135
165
  // Process the configuration
136
166
  configorama(inputFile, options)
137
167
  .then((config) => {
168
+ // Apply path extraction if specified
169
+ if (extractPath) {
170
+ config = getValueAtPath(config, extractPath)
171
+ if (config === undefined) {
172
+ console.error(`Error: Path not found: ${extractPath}`)
173
+ process.exit(1)
174
+ }
175
+ }
176
+
138
177
  let output
139
178
 
140
179
  // Format the output
@@ -186,10 +225,9 @@ configorama(inputFile, options)
186
225
  content: error.message,
187
226
  type: 'error',
188
227
  })
189
- console.log('error', error)
190
228
  console.log(errorMsg)
191
229
  if (argv.debug) {
192
- console.error(error.stack)
230
+ console.error('error', error)
193
231
  }
194
232
  process.exit(1)
195
233
  })
package/index.d.ts CHANGED
@@ -1,10 +1,7 @@
1
1
  // Type definitions for configorama
2
2
  // Project: https://github.com/DavidWells/configorama
3
3
 
4
- // Re-export variable validation types
5
- export * from './src/types'
6
-
7
- export interface ConfigoramaSettings {
4
+ interface ConfigoramaSettings {
8
5
  /** Options to populate for ${opt:xyz}. These could be CLI flags */
9
6
  options?: Record<string, any>
10
7
  /** Regex of variable syntax */
@@ -64,7 +61,7 @@ export interface ConfigoramaSettings {
64
61
  filePathOverrides?: Record<string, string>
65
62
  }
66
63
 
67
- export interface ConfigoramaResult<T = any> {
64
+ interface ConfigoramaResult<T = any> {
68
65
  /** The variable syntax pattern used */
69
66
  variableSyntax: RegExp
70
67
  /** Map of variable types found */
@@ -83,7 +80,7 @@ export interface ConfigoramaResult<T = any> {
83
80
  * Context passed to JS/TS/ESM config file functions
84
81
  * Used when config files export a function: `export default function(ctx) { ... }`
85
82
  */
86
- export interface ConfigContext<T = any> {
83
+ interface ConfigContext<T = any> {
87
84
  /** The original unresolved configuration object */
88
85
  originalConfig: T
89
86
  /** The current (partially resolved) configuration object */
@@ -104,28 +101,40 @@ declare function configorama<T = any>(
104
101
  settings: ConfigoramaSettings & { returnMetadata: true }
105
102
  ): Promise<ConfigoramaResult<T>>
106
103
 
107
- export default configorama
108
-
109
- /** Configorama sync API */
110
- export function sync<T = any>(
111
- configPathOrObject: string | object,
112
- settings?: ConfigoramaSettings
113
- ): T
114
-
115
- /** Analyze config variables without resolving them */
116
- export function analyze(
117
- configPathOrObject: string | object,
118
- settings?: ConfigoramaSettings
119
- ): Promise<any>
120
-
121
- /** Format utilities for parsing various config formats */
122
- export const format: {
123
- yaml: any
124
- json: any
125
- toml: any
126
- ini: any
127
- hcl: any
104
+ declare namespace configorama {
105
+ // Re-export types for consumers
106
+ export { ConfigoramaSettings, ConfigoramaResult, ConfigContext }
107
+
108
+ /** Configorama sync API */
109
+ export function sync<T = any>(
110
+ configPathOrObject: string | object,
111
+ settings?: ConfigoramaSettings
112
+ ): T
113
+
114
+ /** Analyze config variables without resolving them */
115
+ export function analyze(
116
+ configPathOrObject: string | object,
117
+ settings?: ConfigoramaSettings
118
+ ): Promise<any>
119
+
120
+ /** Format utilities for parsing various config formats */
121
+ export const format: {
122
+ yaml: any
123
+ json: any
124
+ toml: any
125
+ ini: any
126
+ hcl: any
127
+ }
128
+
129
+ /** The Configorama class for advanced usage */
130
+ export const Configorama: any
131
+
132
+ /** Build variable syntax regex */
133
+ export function buildVariableSyntax(
134
+ prefix?: string,
135
+ suffix?: string,
136
+ excludePatterns?: string[]
137
+ ): string
128
138
  }
129
139
 
130
- /** The Configorama class for advanced usage */
131
- export const Configorama: any
140
+ export = configorama
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "configorama",
3
- "version": "0.9.11",
3
+ "version": "0.9.13",
4
4
  "description": "Variable support for configuration files",
5
5
  "main": "src/index.js",
6
6
  "types": "index.d.ts",
@@ -59,26 +59,10 @@
59
59
  "jiti": "^2.4.2",
60
60
  "js-yaml": "^3.14.1",
61
61
  "json5": "^2.2.3",
62
- "lodash.assign": "^4.2.0",
63
62
  "lodash.camelcase": "^4.3.0",
64
- "lodash.capitalize": "^4.2.1",
65
63
  "lodash.clonedeep": "^4.5.0",
66
- "lodash.flatten": "^4.4.0",
67
- "lodash.includes": "^4.3.0",
68
- "lodash.isarray": "^4.0.0",
69
- "lodash.isdate": "^4.0.1",
70
- "lodash.isempty": "^4.4.0",
71
- "lodash.isfunction": "^3.0.9",
72
- "lodash.isnumber": "^3.0.3",
73
- "lodash.isobject": "^3.0.2",
74
- "lodash.isregexp": "^4.0.1",
75
- "lodash.isstring": "^4.0.1",
76
64
  "lodash.kebabcase": "^4.1.1",
77
- "lodash.map": "^4.6.0",
78
- "lodash.mapvalues": "^4.6.0",
79
- "lodash.split": "^4.4.2",
80
65
  "minimist": "^1.2.8",
81
- "promise.prototype.finally": "^3.1.8",
82
66
  "safe-chalk": "^1.0.4",
83
67
  "subscript": "^9.1.0",
84
68
  "sync-rpc": "^1.3.6",
package/src/main.js CHANGED
@@ -7,7 +7,6 @@ console.log = () => {}
7
7
  // process.exit(1)
8
8
  /** */
9
9
  /* External dependencies */
10
- const promiseFinallyShim = require('promise.prototype.finally').shim()
11
10
  const findUp = require('find-up')
12
11
  const traverse = require('traverse')
13
12
  const dotProp = require('dot-prop')
@@ -34,7 +33,7 @@ const { arrayToJsonPath } = require('./utils/parsing/arrayToJsonPath')
34
33
  const { normalizePath, extractFilePath, resolveInnerVariables } = require('./utils/paths/filePathUtils')
35
34
  const { findLineForKey } = require('./utils/paths/findLineForKey')
36
35
  /* Utils - regex */
37
- const { combineRegexes, funcRegex } = require('./utils/regex')
36
+ const { combineRegexes, funcRegex, fileRefSyntax, textRefSyntax } = require('./utils/regex')
38
37
  /* Utils - strings */
39
38
  const formatFunctionArgs = require('./utils/strings/formatFunctionArgs')
40
39
  const { splitByComma } = require('./utils/strings/splitByComma')
@@ -92,8 +91,6 @@ const deepRefSyntax = RegExp(/(\${)?deep:\d+(\.[^}]+)*()}?/)
92
91
  const deepIndexReplacePattern = new RegExp(/^deep:|(\.[^}]+)*$/g)
93
92
  const deepIndexPattern = /deep\:(\d*)/
94
93
  const deepPrefixReplacePattern = /(?:^deep:)\d+\.?/g
95
- const fileRefSyntax = RegExp(/^file\((~?[@\{\}\:\$a-zA-Z0-9._\-\/,'" =+]+?)\)/g)
96
- const textRefSyntax = RegExp(/^text\((~?[@\{\}\:\$a-zA-Z0-9._\-\/,'" =+]+?)\)/g)
97
94
  // TODO update file regex ^file\((~?[a-zA-Z0-9._\-\/, ]+?)\)
98
95
  // To match file(asyncValue.js, lol) input params
99
96
  const selfRefSyntax = RegExp(/^self:/g)
@@ -684,6 +681,17 @@ class Configorama {
684
681
  /** */
685
682
  //process.exit(1)
686
683
 
684
+ // Strip body content before variable resolution, re-attach after
685
+ if (configObject && configObject._body !== undefined) {
686
+ this._markdownContent = configObject._body
687
+ this._markdownContentKey = '_body'
688
+ delete configObject._body
689
+ } else if (configObject && configObject._content !== undefined) {
690
+ this._markdownContent = configObject._content
691
+ this._markdownContentKey = '_content'
692
+ delete configObject._content
693
+ }
694
+
687
695
  this.config = configObject
688
696
  this.originalConfig = cloneDeep(configObject)
689
697
  }
@@ -715,12 +723,8 @@ class Configorama {
715
723
  )
716
724
 
717
725
  if (showFoundVariables) {
718
- //*
719
726
  deepLog('metadata', metadata)
720
- fs.writeFileSync(`metadata-${path.basename(this.configFilePath)}.json`, JSON.stringify(metadata, null, 2))
721
727
  deepLog('enrich', enrich)
722
- // process.exit(1)
723
- /** */
724
728
  }
725
729
 
726
730
  const variableData = metadata.variables
@@ -1240,6 +1244,9 @@ class Configorama {
1240
1244
 
1241
1245
  /* If no variables found just return early */
1242
1246
  if (this.originalString && !this.originalString.match(this.variableSyntax)) {
1247
+ if (this._markdownContent !== undefined) {
1248
+ this.originalConfig[this._markdownContentKey] = this._markdownContent
1249
+ }
1243
1250
  return Promise.resolve(this.originalConfig)
1244
1251
  }
1245
1252
 
@@ -1399,6 +1406,10 @@ class Configorama {
1399
1406
  deepLog(this.config)
1400
1407
  console.log()
1401
1408
  }
1409
+ // Re-attach markdown body content after variable resolution
1410
+ if (this._markdownContent !== undefined) {
1411
+ this.config[this._markdownContentKey] = this._markdownContent
1412
+ }
1402
1413
  return this.config
1403
1414
  })
1404
1415
  })
@@ -2693,7 +2704,10 @@ Missing Value ${missingValue} - ${matchedString}
2693
2704
  && !prop.match(getValueFromEval.match)
2694
2705
  && !prop.match(getValueFromIf.match)
2695
2706
  // AND is not multiline value
2696
- && (func && prop.split('\n').length < 3)) {
2707
+ && (func && prop.split('\n').length < 3)
2708
+ // Only tag as function if the function name is actually registered
2709
+ // Prevents resolved values like git messages "fix(scope)" from being treated as functions
2710
+ && (func[1] && (this.functions[func[1]] || this.functions[func[1].toLowerCase()]))) {
2697
2711
  // console.log('IS FUNCTION')
2698
2712
  /* if matches function signature like ${merge('foo', 'bar')}
2699
2713
  rewrite the variable to run the function after inputs resolved
@@ -9,6 +9,7 @@ const toml = require('./toml')
9
9
  const yaml = require('./yaml')
10
10
  const ini = require('./ini')
11
11
  const hcl = require('./hcl')
12
+ const markdown = require('./markdown')
12
13
 
13
14
  /**
14
15
  * Collection of format parsers for different config file types
@@ -19,5 +20,6 @@ module.exports = {
19
20
  toml: toml,
20
21
  yaml: yaml,
21
22
  ini: ini,
22
- hcl: hcl
23
+ hcl: hcl,
24
+ markdown: markdown
23
25
  }
@@ -0,0 +1,69 @@
1
+ // Extracts and detects frontmatter format from markdown/MDX files
2
+
3
+ /**
4
+ * Detect frontmatter syntax format from raw content
5
+ * @param {string} rawFrontmatter - Raw frontmatter string (without delimiters)
6
+ * @returns {'yaml'|'toml'|'json'} Detected format
7
+ */
8
+ function detectSyntax(rawFrontmatter) {
9
+ const trimmed = rawFrontmatter.trim()
10
+ if (trimmed.startsWith('{')) {
11
+ return 'json'
12
+ }
13
+ if (/^\[[\w.-]+\]/m.test(trimmed)) {
14
+ return 'toml'
15
+ }
16
+ return 'yaml'
17
+ }
18
+
19
+ /**
20
+ * Extract frontmatter and body content from a markdown file
21
+ * @param {string} fileContents - Full file contents
22
+ * @returns {{ frontmatterContent: string|null, content: string, format: 'yaml'|'toml'|'json'|null }}
23
+ */
24
+ function extractFrontmatter(fileContents) {
25
+ const noMatch = { frontmatterContent: null, content: fileContents, format: null }
26
+
27
+ if (!fileContents) {
28
+ return noMatch
29
+ }
30
+
31
+ // Normalize CRLF to LF for consistent delimiter matching
32
+ fileContents = fileContents.replace(/\r\n/g, '\n')
33
+
34
+ // +++ delimiters → TOML
35
+ if (fileContents.startsWith('+++\n')) {
36
+ const endIdx = fileContents.indexOf('\n+++', 4)
37
+ if (endIdx === -1) return noMatch
38
+ const frontmatterContent = fileContents.slice(4, endIdx)
39
+ const content = fileContents.slice(endIdx + 4)
40
+ return { frontmatterContent, content, format: 'toml' }
41
+ }
42
+
43
+ // --- delimiters → detect format from content
44
+ if (fileContents.startsWith('---\n')) {
45
+ const endIdx = fileContents.indexOf('\n---', 4)
46
+ if (endIdx === -1) return noMatch
47
+ const frontmatterContent = fileContents.slice(4, endIdx)
48
+ const content = fileContents.slice(endIdx + 4)
49
+ const format = detectSyntax(frontmatterContent)
50
+ return { frontmatterContent, content, format }
51
+ }
52
+
53
+ // <!-- --> comment frontmatter (strict: position 0)
54
+ if (fileContents.startsWith('<!--\n')) {
55
+ const endIdx = fileContents.indexOf('\n-->', 5)
56
+ if (endIdx === -1) return noMatch
57
+ const frontmatterContent = fileContents.slice(5, endIdx)
58
+ const content = fileContents.slice(endIdx + 4)
59
+ const format = detectSyntax(frontmatterContent)
60
+ return { frontmatterContent, content, format }
61
+ }
62
+
63
+ return noMatch
64
+ }
65
+
66
+ module.exports = {
67
+ extractFrontmatter,
68
+ detectSyntax
69
+ }
@@ -0,0 +1,132 @@
1
+ /* eslint-disable no-template-curly-in-string */
2
+ // Unit tests for markdown frontmatter extraction and format detection
3
+ const { test } = require('uvu')
4
+ const assert = require('uvu/assert')
5
+ const { extractFrontmatter, detectSyntax } = require('./markdown')
6
+
7
+ // --- detectSyntax tests ---
8
+
9
+ test('detectSyntax: JSON detected by leading {', () => {
10
+ assert.is(detectSyntax('{ "title": "hello" }'), 'json')
11
+ })
12
+
13
+ test('detectSyntax: TOML detected by [section] header', () => {
14
+ assert.is(detectSyntax('title = "hello"\n[section]\nkey = "val"'), 'toml')
15
+ })
16
+
17
+ test('detectSyntax: TOML detected by [dotted.section] header', () => {
18
+ assert.is(detectSyntax('[my.config]\nkey = "val"'), 'toml')
19
+ })
20
+
21
+ test('detectSyntax: defaults to yaml', () => {
22
+ assert.is(detectSyntax('title: hello\ndescription: world'), 'yaml')
23
+ })
24
+
25
+ test('detectSyntax: yaml even with empty content', () => {
26
+ assert.is(detectSyntax(''), 'yaml')
27
+ })
28
+
29
+ // --- extractFrontmatter: YAML with --- delimiters ---
30
+
31
+ test('extract YAML frontmatter with --- delimiters', () => {
32
+ const input = '---\ntitle: hello\nstage: ${opt:stage}\n---\n\n# My Doc\n\nBody content here.'
33
+ const result = extractFrontmatter(input)
34
+ assert.is(result.format, 'yaml')
35
+ assert.is(result.frontmatterContent, 'title: hello\nstage: ${opt:stage}')
36
+ assert.is(result.content, '\n\n# My Doc\n\nBody content here.')
37
+ })
38
+
39
+ // --- extractFrontmatter: TOML with +++ delimiters ---
40
+
41
+ test('extract TOML frontmatter with +++ delimiters', () => {
42
+ const input = '+++\ntitle = "hello"\nstage = "${opt:stage}"\n+++\n\n# My Doc\n\nBody.'
43
+ const result = extractFrontmatter(input)
44
+ assert.is(result.format, 'toml')
45
+ assert.is(result.frontmatterContent, 'title = "hello"\nstage = "${opt:stage}"')
46
+ assert.is(result.content, '\n\n# My Doc\n\nBody.')
47
+ })
48
+
49
+ // --- extractFrontmatter: TOML detected inside --- delimiters ---
50
+
51
+ test('extract TOML detected inside --- delimiters via [section]', () => {
52
+ const input = '---\n[metadata]\ntitle = "hello"\n---\n\nBody.'
53
+ const result = extractFrontmatter(input)
54
+ assert.is(result.format, 'toml')
55
+ assert.is(result.frontmatterContent, '[metadata]\ntitle = "hello"')
56
+ assert.is(result.content, '\n\nBody.')
57
+ })
58
+
59
+ // --- extractFrontmatter: JSON detected inside --- delimiters ---
60
+
61
+ test('extract JSON detected inside --- delimiters via leading {', () => {
62
+ const input = '---\n{\n "title": "hello"\n}\n---\n\nBody.'
63
+ const result = extractFrontmatter(input)
64
+ assert.is(result.format, 'json')
65
+ assert.is(result.frontmatterContent, '{\n "title": "hello"\n}')
66
+ assert.is(result.content, '\n\nBody.')
67
+ })
68
+
69
+ // --- extractFrontmatter: HTML comment frontmatter ---
70
+
71
+ test('extract hidden comment frontmatter <!-- -->', () => {
72
+ const input = '<!--\ntitle: hello\nstage: dev\n-->\n\n# Doc\n\nBody.'
73
+ const result = extractFrontmatter(input)
74
+ assert.is(result.format, 'yaml')
75
+ assert.is(result.frontmatterContent, 'title: hello\nstage: dev')
76
+ assert.is(result.content, '\n\n# Doc\n\nBody.')
77
+ })
78
+
79
+ test('comment frontmatter not detected when not at position 0', () => {
80
+ const input = '\n<!--\ntitle: hello\n-->\n\nBody.'
81
+ const result = extractFrontmatter(input)
82
+ assert.is(result.frontmatterContent, null)
83
+ assert.is(result.format, null)
84
+ assert.is(result.content, '\n<!--\ntitle: hello\n-->\n\nBody.')
85
+ })
86
+
87
+ // --- extractFrontmatter: no frontmatter ---
88
+
89
+ test('no frontmatter returns null + full content', () => {
90
+ const input = '# Just a markdown file\n\nNo frontmatter here.'
91
+ const result = extractFrontmatter(input)
92
+ assert.is(result.frontmatterContent, null)
93
+ assert.is(result.format, null)
94
+ assert.is(result.content, '# Just a markdown file\n\nNo frontmatter here.')
95
+ })
96
+
97
+ // --- Multiple --- in body don't confuse extraction ---
98
+
99
+ test('thematic breaks in body do not confuse extraction', () => {
100
+ const input = '---\ntitle: hello\n---\n\nSome text\n\n---\n\nMore text after thematic break.'
101
+ const result = extractFrontmatter(input)
102
+ assert.is(result.format, 'yaml')
103
+ assert.is(result.frontmatterContent, 'title: hello')
104
+ assert.is(result.content, '\n\nSome text\n\n---\n\nMore text after thematic break.')
105
+ })
106
+
107
+ // --- Edge cases ---
108
+
109
+ test('empty file returns no frontmatter', () => {
110
+ const result = extractFrontmatter('')
111
+ assert.is(result.frontmatterContent, null)
112
+ assert.is(result.format, null)
113
+ assert.is(result.content, '')
114
+ })
115
+
116
+ test('--- only at start with no closing returns no frontmatter', () => {
117
+ const input = '---\ntitle: hello\nno closing delimiter'
118
+ const result = extractFrontmatter(input)
119
+ assert.is(result.frontmatterContent, null)
120
+ assert.is(result.format, null)
121
+ assert.is(result.content, '---\ntitle: hello\nno closing delimiter')
122
+ })
123
+
124
+ test('+++ only at start with no closing returns no frontmatter', () => {
125
+ const input = '+++\ntitle = "hello"\nno closing delimiter'
126
+ const result = extractFrontmatter(input)
127
+ assert.is(result.frontmatterContent, null)
128
+ assert.is(result.format, null)
129
+ assert.is(result.content, '+++\ntitle = "hello"\nno closing delimiter')
130
+ })
131
+
132
+ test.run()
@@ -27,6 +27,24 @@ async function _exec(cmd, options = { timeout: 1000 }) {
27
27
  })
28
28
  }
29
29
 
30
+ /**
31
+ * Execute a command with arguments array (safe from shell injection)
32
+ * @param {string} command - Command to execute
33
+ * @param {string[]} args - Arguments array
34
+ * @param {import('child_process').ExecFileOptions} [options] - ExecFile options
35
+ * @returns {Promise<string>}
36
+ */
37
+ async function _execFile(command, args, options = { timeout: 1000 }) {
38
+ return new Promise((resolve, reject) => {
39
+ childProcess.execFile(command, args, options, (err, stdout) => {
40
+ if (err) {
41
+ return reject(err)
42
+ }
43
+ return resolve(String(stdout).trim())
44
+ })
45
+ })
46
+ }
47
+
30
48
  // TODO denote computed fields in metadata
31
49
  /*
32
50
  {
@@ -230,28 +248,16 @@ async function getGitTimestamp(_file, cwd, throwOnMissing = true) {
230
248
  throw new Error('File path must be a string')
231
249
  }
232
250
 
233
- // Check for suspicious characters and patterns that could be used for command injection or path traversal
234
- const dangerousPatterns = [
235
- /[;&|`$]/, // Command injection chars
236
- // /\.\.\//, // Directory traversal
237
- // /\.\./, // Directory traversal
238
- // /^[\/\\]/, // Absolute paths
239
- /[\x00-\x1f\x7f-\x9f]/ // Control characters
240
- ]
241
-
242
- if (dangerousPatterns.some(pattern => pattern.test(_file))) {
243
- throw new Error('Invalid characters or pattern in file path')
244
- }
251
+ // Strip surrounding quotes and leading slash
252
+ const file = _file
253
+ .replace(/^['"]|['"]$/g, '')
254
+ .replace(/^\//, '')
245
255
 
246
- // Only allow alphanumeric chars, dashes, underscores, forward slashes, and dots
247
- if (!/^[a-zA-Z0-9-_./\\'"]+$/.test(_file)) {
256
+ // Reject control characters
257
+ if (/[\x00-\x1f\x7f-\x9f]/.test(file)) {
248
258
  throw new Error('File path contains invalid characters')
249
259
  }
250
260
 
251
- // Normalize path and remove leading slash
252
- const file = _file
253
- .replace(/^\//, '')
254
-
255
261
  const cachedTimestamp = cache.get(file)
256
262
  if (cachedTimestamp) return cachedTimestamp
257
263
 
@@ -263,10 +269,7 @@ async function getGitTimestamp(_file, cwd, throwOnMissing = true) {
263
269
  }
264
270
 
265
271
  try {
266
- const cmd = `git log -1 --pretty="%ai" ${file}`
267
- // console.log('cmd', cmd)
268
- // console.log('cwd', cwd)
269
- const output = await _exec(cmd, { cwd })
272
+ const output = await _execFile('git', ['log', '-1', '--pretty=%ai', '--', file], { cwd })
270
273
  const date = new Date(output)
271
274
  const dateString = date.toISOString()
272
275
  cache.set(file, dateString)
@@ -281,8 +284,8 @@ async function getGitTimestamp(_file, cwd, throwOnMissing = true) {
281
284
  }
282
285
 
283
286
  try {
284
- const backupFile = path.join(projectRoot, _file)
285
- const output = await _exec(`git log -1 --pretty="%ai" ${backupFile}`, { cwd: projectRoot })
287
+ const backupFile = path.join(projectRoot, file)
288
+ const output = await _execFile('git', ['log', '-1', '--pretty=%ai', '--', backupFile], { cwd: projectRoot })
286
289
  const date = new Date(output)
287
290
  const dateString = date.toISOString()
288
291
  cache.set(file, dateString)
@@ -1,5 +1,10 @@
1
- const isNumber = require('lodash.isnumber')
1
+ // Resolves numeric literal variables to their Number values
2
+ const { isNumber } = require('../utils/lodash')
2
3
 
4
+ /**
5
+ * @param {string} variableString
6
+ * @returns {boolean}
7
+ */
3
8
  function isNumberVariable(variableString) {
4
9
  if (!variableString || variableString.trim().length === 0) {
5
10
  return false
@@ -8,6 +13,10 @@ function isNumberVariable(variableString) {
8
13
  return !isNaN(num) && isNumber(num)
9
14
  }
10
15
 
16
+ /**
17
+ * @param {string} variableString
18
+ * @returns {Promise<number>}
19
+ */
11
20
  function getValueFromNumber(variableString) {
12
21
  return Promise.resolve(Number(variableString))
13
22
  }
package/src/types.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // This file provides TypeScript support for validating configuration variables
3
3
 
4
4
  // Valid variable prefixes supported by configorama
5
- export type KnownVariablePrefix = 'env:' | 'opt:' | 'self:' | 'file:' | 'git:' | 'cron:'
5
+ export type KnownVariablePrefix = 'env:' | 'opt:' | 'self:' | 'file:' | 'git:' | 'cron:' | 'param:'
6
6
 
7
7
  // Quoted string literal type for fallback values
8
8
  type QuotedString = `"${string}"` | `'${string}'`