configorama 1.0.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -0
- package/package.json +10 -3
- package/plugins/cloudformation/README.md +79 -0
- package/plugins/cloudformation/credentials.js +231 -0
- package/plugins/cloudformation/index.js +204 -0
- package/plugins/cloudformation/package-lock.json +657 -0
- package/plugins/cloudformation/package.json +24 -0
- package/plugins/onepassword/README.md +175 -0
- package/plugins/onepassword/fields.js +126 -0
- package/plugins/onepassword/index.js +345 -0
- package/plugins/onepassword/normalize.js +145 -0
- package/plugins/onepassword/op-cli.js +117 -0
- package/plugins/onepassword/package.json +15 -0
- package/plugins/onepassword/parser.js +70 -0
- package/plugins/onepassword/sync-factory.js +15 -0
- package/src/index.js +8 -1
- package/src/main.js +6 -3
- package/src/metadata.js +7 -0
- package/src/parsers/dotenv.js +13 -0
- package/src/resolvers/valueFromFile.js +16 -6
- package/src/sync.js +27 -0
- package/src/utils/PromiseTracker.js +11 -4
- package/src/utils/PromiseTracker.test.js +48 -0
- package/src/utils/introspection/audit.js +48 -11
- package/src/utils/introspection/model.js +36 -6
- package/src/utils/parsing/enrichMetadata.js +21 -4
- package/src/utils/parsing/parse.js +10 -2
- package/src/utils/paths/fileType.js +27 -0
- package/src/utils/paths/fileType.test.js +41 -0
- package/src/utils/paths/findLineForKey.js +24 -1
- package/src/utils/paths/findLineForKey.test.js +20 -1
- package/src/utils/security/dotenvFileRefs.js +134 -0
- package/src/utils/security/dotenvFileRefs.test.js +47 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/* Normalizes 1Password references into canonical secretRef/item/privateLink forms
|
|
2
|
+
Validates aliases, parses private item links, rejects public share links */
|
|
3
|
+
|
|
4
|
+
const ALIAS_PATTERN = /^[A-Za-z0-9_]+$/
|
|
5
|
+
const PRIVATE_LINK_PREFIXES = ['https://start.1password.com/open/i', 'onepassword://open/i']
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Validate an alias name from the refs config.
|
|
9
|
+
* Dots are reserved as the key path separator so they cannot appear in aliases.
|
|
10
|
+
* @param {string} alias - Alias name to validate
|
|
11
|
+
*/
|
|
12
|
+
function validateAliasName(alias) {
|
|
13
|
+
if (!ALIAS_PATTERN.test(alias)) {
|
|
14
|
+
throw new Error(`Invalid 1Password alias "${alias}". Aliases may contain only letters, numbers, and underscores.`)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Check whether a string is a private item link.
|
|
20
|
+
* @param {string} value - Candidate string
|
|
21
|
+
* @returns {boolean} True when the string is a private item link
|
|
22
|
+
*/
|
|
23
|
+
function isPrivateLink(value) {
|
|
24
|
+
return PRIVATE_LINK_PREFIXES.some((prefix) => value.startsWith(prefix))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Recognize abbreviated private-link forms where the scheme, host, and/or
|
|
29
|
+
* path have been stripped, leaving the query params (e.g. "open/i?...&i=ID",
|
|
30
|
+
* or a bare "v=VAULT&i=ID"). Requires an "i=" param plus a 1Password marker
|
|
31
|
+
* or a pure query string, so ordinary item names and unrelated URLs are not
|
|
32
|
+
* misread as links.
|
|
33
|
+
* @param {string} value - Candidate string
|
|
34
|
+
* @returns {boolean} True when the string looks like private-link query params
|
|
35
|
+
*/
|
|
36
|
+
function looksLikeLinkParams(value) {
|
|
37
|
+
const query = value.includes('?') ? value.slice(value.indexOf('?') + 1) : value
|
|
38
|
+
if (!/(?:^|&)i=[^&]/.test(query)) return false
|
|
39
|
+
return /1password\.com/.test(value) || /(?:^|\/)open\/i/.test(value) || !value.includes('/')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Parse a "Copy Private Link" URL (or an abbreviated form) into item and
|
|
44
|
+
* vault IDs. The a (account) and h (host) params identify the account and are
|
|
45
|
+
* intentionally dropped; item + vault IDs are all op item get needs.
|
|
46
|
+
* @param {string} url - Private item link or its query params
|
|
47
|
+
* @returns {{kind: string, item: string, vault: string|undefined, warnings: Array<{code: string, message: string}>}} Normalized reference
|
|
48
|
+
*/
|
|
49
|
+
function parsePrivateLink(url) {
|
|
50
|
+
const query = url.includes('?') ? url.slice(url.indexOf('?') + 1) : url
|
|
51
|
+
const params = new URLSearchParams(query)
|
|
52
|
+
const item = params.get('i')
|
|
53
|
+
const vault = params.get('v')
|
|
54
|
+
|
|
55
|
+
if (!item) {
|
|
56
|
+
throw new Error('Invalid 1Password private link. Expected query parameter "i" with the item ID.')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const warnings = []
|
|
60
|
+
if (!vault) {
|
|
61
|
+
warnings.push({
|
|
62
|
+
level: 'warning',
|
|
63
|
+
code: 'op_private_link_missing_vault',
|
|
64
|
+
message: '1Password private link did not include a vault ID; service accounts and duplicate item names may require vault scoping.',
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { kind: 'privateLink', item, vault: vault || undefined, warnings }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Normalize a string or object ref value into a canonical reference.
|
|
73
|
+
* Accepted forms: op:// string, item name string, private link string,
|
|
74
|
+
* { item, vault, section, field }, { ref }, { url }.
|
|
75
|
+
* @param {string|object} value - Ref value from config or direct spec
|
|
76
|
+
* @returns {object} Normalized reference ({ kind: 'secretRef'|'item'|'privateLink', ... })
|
|
77
|
+
*/
|
|
78
|
+
function normalizeRefValue(value) {
|
|
79
|
+
if (typeof value === 'string') {
|
|
80
|
+
return normalizeStringRef(value)
|
|
81
|
+
}
|
|
82
|
+
if (value && typeof value === 'object') {
|
|
83
|
+
return normalizeObjectRef(value)
|
|
84
|
+
}
|
|
85
|
+
throw new Error(`Invalid 1Password reference. Expected a string or object, got ${typeof value}.`)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @param {string} value - String ref (secret ref, link, or item name)
|
|
90
|
+
* @returns {object} Normalized reference
|
|
91
|
+
*/
|
|
92
|
+
function normalizeStringRef(value) {
|
|
93
|
+
if (value.startsWith('op://')) {
|
|
94
|
+
return { kind: 'secretRef', ref: value }
|
|
95
|
+
}
|
|
96
|
+
// Public share links are rejected in any form (full URL or share token).
|
|
97
|
+
if (value.includes('share.1password.com') || /1password\.com\/s#/.test(value)) {
|
|
98
|
+
throw new Error('Public 1Password share links are not supported. Use Copy Private Link or an op:// secret reference.')
|
|
99
|
+
}
|
|
100
|
+
// Private links: full URL, onepassword://, or an abbreviated form carrying
|
|
101
|
+
// the query params (scheme/host/path stripped).
|
|
102
|
+
if (isPrivateLink(value) || looksLikeLinkParams(value)) {
|
|
103
|
+
return parsePrivateLink(value)
|
|
104
|
+
}
|
|
105
|
+
if (/^https?:\/\/|^onepassword:\/\//.test(value)) {
|
|
106
|
+
throw new Error('Unrecognized 1Password link. Use Copy Private Link or an op:// secret reference.')
|
|
107
|
+
}
|
|
108
|
+
return { kind: 'item', item: value, vault: undefined, section: undefined, field: undefined }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* @param {object} value - Object ref ({ item }, { ref }, or { url })
|
|
113
|
+
* @returns {object} Normalized reference
|
|
114
|
+
*/
|
|
115
|
+
function normalizeObjectRef(value) {
|
|
116
|
+
const specifiers = ['item', 'ref', 'url'].filter((key) => value[key] !== undefined)
|
|
117
|
+
if (specifiers.length !== 1) {
|
|
118
|
+
throw new Error('Invalid 1Password reference. Object refs must specify exactly one of "item", "ref", or "url".')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (value.ref !== undefined) {
|
|
122
|
+
return normalizeStringRef(value.ref)
|
|
123
|
+
}
|
|
124
|
+
if (value.url !== undefined) {
|
|
125
|
+
const normalized = normalizeStringRef(value.url)
|
|
126
|
+
if (normalized.kind !== 'privateLink') {
|
|
127
|
+
throw new Error('Invalid 1Password reference. "url" must be a private item link.')
|
|
128
|
+
}
|
|
129
|
+
return normalized
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (value.section !== undefined && value.field === undefined) {
|
|
133
|
+
throw new Error('Invalid 1Password reference. "section" is only meaningful together with "field".')
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
kind: 'item',
|
|
138
|
+
item: value.item,
|
|
139
|
+
vault: value.vault,
|
|
140
|
+
section: value.section,
|
|
141
|
+
field: value.field,
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
module.exports = { validateAliasName, normalizeRefValue, parsePrivateLink, isPrivateLink }
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/* Executes the 1Password CLI with execFile and sanitized error translation
|
|
2
|
+
Never logs stdout/stderr; never passes resolved secret values as arguments */
|
|
3
|
+
const childProcess = require('child_process')
|
|
4
|
+
|
|
5
|
+
const AUTH_ERROR_PATTERN = /sign ?in|session|authoriz|authenticat|not currently signed|locked|service account token/i
|
|
6
|
+
const NOT_FOUND_PATTERN = /isn'?t an? (item|vault)|not found|no item|no vault|doesn'?t exist|more than one item/i
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Run the op binary with the given args.
|
|
10
|
+
* No `command -v op` preflight: translating ENOENT is the existence check.
|
|
11
|
+
* @param {string[]} args - CLI arguments
|
|
12
|
+
* @param {object} [options] - { execFile, opPath, account, configDir, subject }
|
|
13
|
+
* @returns {Promise<string>} stdout
|
|
14
|
+
*/
|
|
15
|
+
function runOp(args, options = {}) {
|
|
16
|
+
const execFile = options.execFile || childProcess.execFile
|
|
17
|
+
const binary = options.opPath || 'op'
|
|
18
|
+
const finalArgs = args.slice()
|
|
19
|
+
if (options.account) {
|
|
20
|
+
finalArgs.push('--account', options.account)
|
|
21
|
+
}
|
|
22
|
+
if (options.configDir) {
|
|
23
|
+
finalArgs.push('--config', options.configDir)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
execFile(binary, finalArgs, { maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
28
|
+
if (err) {
|
|
29
|
+
return reject(translateError(err, stderr, options.subject))
|
|
30
|
+
}
|
|
31
|
+
resolve(stdout)
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Translate op failures into sanitized, actionable errors.
|
|
38
|
+
* stderr is inspected for classification but never included in messages.
|
|
39
|
+
* @param {Error & {code?: string|number}} err - execFile error
|
|
40
|
+
* @param {string} stderr - Captured stderr (classification only)
|
|
41
|
+
* @param {string} [subject] - Item/ref identifier already present in user config
|
|
42
|
+
* @returns {Error} Sanitized error
|
|
43
|
+
*/
|
|
44
|
+
function translateError(err, stderr, subject) {
|
|
45
|
+
if (err.code === 'ENOENT') {
|
|
46
|
+
return new Error('1Password CLI "op" was not found on PATH. Install 1Password CLI or remove the op resolver.')
|
|
47
|
+
}
|
|
48
|
+
const detail = String(stderr || err.message || '')
|
|
49
|
+
if (AUTH_ERROR_PATTERN.test(detail)) {
|
|
50
|
+
return new Error('1Password CLI could not read the item. Run op signin, unlock 1Password app integration, or configure OP_SERVICE_ACCOUNT_TOKEN.')
|
|
51
|
+
}
|
|
52
|
+
if (NOT_FOUND_PATTERN.test(detail)) {
|
|
53
|
+
const what = subject ? `"${subject}"` : 'the requested item, vault, or field'
|
|
54
|
+
return new Error(`1Password item, vault, or field could not be found (${what}).`)
|
|
55
|
+
}
|
|
56
|
+
return new Error('1Password CLI command failed.')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Reject config-controlled values that would be parsed as an op flag.
|
|
61
|
+
* execFile takes no shell, so this is not command injection - but a
|
|
62
|
+
* leading "-" turns a positional/vault value into an option that changes
|
|
63
|
+
* op's behavior. Secret refs are exempt: they always start with "op://".
|
|
64
|
+
* @param {string} value - Value destined for the op argument list
|
|
65
|
+
* @param {string} label - Human label for the error message
|
|
66
|
+
*/
|
|
67
|
+
function assertNotFlag(value, label) {
|
|
68
|
+
if (typeof value === 'string' && value.startsWith('-')) {
|
|
69
|
+
throw new Error(`Invalid 1Password ${label} "${value}": ${label} cannot start with "-" (it would be read as an op flag). Use the item ID or an op:// secret reference.`)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Read a secret reference with op read.
|
|
75
|
+
* @param {string} ref - op://vault/item/field reference
|
|
76
|
+
* @param {object} [options] - { execFile, account, configDir }
|
|
77
|
+
* @returns {Promise<string>} Secret value
|
|
78
|
+
*/
|
|
79
|
+
function readSecretRef(ref, options = {}) {
|
|
80
|
+
return runOp(['read', '--no-newline', ref], {
|
|
81
|
+
execFile: options.execFile,
|
|
82
|
+
opPath: options.opPath,
|
|
83
|
+
account: options.account,
|
|
84
|
+
configDir: options.configDir,
|
|
85
|
+
subject: ref,
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Fetch an item as JSON with op item get.
|
|
91
|
+
* --reveal is required to include concealed field values in the JSON.
|
|
92
|
+
* @param {string} spec - Item ID or item name
|
|
93
|
+
* @param {object} [options] - { execFile, account, configDir, vault }
|
|
94
|
+
* @returns {Promise<object>} Parsed item JSON
|
|
95
|
+
*/
|
|
96
|
+
async function getItem(spec, options = {}) {
|
|
97
|
+
assertNotFlag(spec, 'item')
|
|
98
|
+
const args = ['item', 'get', spec, '--format', 'json', '--reveal']
|
|
99
|
+
if (options.vault) {
|
|
100
|
+
assertNotFlag(options.vault, 'vault')
|
|
101
|
+
args.push('--vault', options.vault)
|
|
102
|
+
}
|
|
103
|
+
const stdout = await runOp(args, {
|
|
104
|
+
execFile: options.execFile,
|
|
105
|
+
opPath: options.opPath,
|
|
106
|
+
account: options.account,
|
|
107
|
+
configDir: options.configDir,
|
|
108
|
+
subject: spec,
|
|
109
|
+
})
|
|
110
|
+
try {
|
|
111
|
+
return JSON.parse(stdout)
|
|
112
|
+
} catch (err) {
|
|
113
|
+
throw new Error(`Could not parse 1Password CLI output as JSON for item "${spec}".`)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = { runOp, readSecretRef, getItem }
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "configorama-onepassword",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "1Password variable source plugin for configorama",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "node parser.test.js && node normalize.test.js && node op-cli.test.js && node fields.test.js && node index.test.js && node smoke.test.js"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [
|
|
10
|
+
"configorama",
|
|
11
|
+
"1password",
|
|
12
|
+
"secrets"
|
|
13
|
+
],
|
|
14
|
+
"license": "MIT"
|
|
15
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/* Parses 1Password field text as INI/dotenv for key path reads
|
|
2
|
+
Raw field text is only parsed when a config reference asks for a key path */
|
|
3
|
+
const ini = require('ini')
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Restore ini module coercions so secret values stay strings.
|
|
7
|
+
* The ini parser coerces true/false/null which corrupts literal
|
|
8
|
+
* secret text like FLAG=true.
|
|
9
|
+
* @param {*} value - Parsed ini node
|
|
10
|
+
* @returns {*} Node with primitives restored to strings
|
|
11
|
+
*/
|
|
12
|
+
function stringifyPrimitives(value) {
|
|
13
|
+
if (value === true) return 'true'
|
|
14
|
+
if (value === false) return 'false'
|
|
15
|
+
if (value === null) return 'null'
|
|
16
|
+
if (typeof value === 'object') {
|
|
17
|
+
const result = {}
|
|
18
|
+
for (const key of Object.keys(value)) {
|
|
19
|
+
result[key] = stringifyPrimitives(value[key])
|
|
20
|
+
}
|
|
21
|
+
return result
|
|
22
|
+
}
|
|
23
|
+
return value
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Parse structured secret text.
|
|
28
|
+
* V1 supports INI/dotenv only. Future format detection order:
|
|
29
|
+
* 1. explicit format option on the ref
|
|
30
|
+
* 2. JSON if the trimmed value starts with { or [
|
|
31
|
+
* 3. YAML only with proof it is not confused with INI
|
|
32
|
+
* 4. INI fallback
|
|
33
|
+
* @param {string} value - Raw field text
|
|
34
|
+
* @param {object} [options] - Reserved for future format selection
|
|
35
|
+
* @returns {object} Parsed key/value structure
|
|
36
|
+
*/
|
|
37
|
+
function parseStructuredSecret(value, options = {}) {
|
|
38
|
+
let parsed
|
|
39
|
+
try {
|
|
40
|
+
parsed = ini.parse(value)
|
|
41
|
+
} catch (err) {
|
|
42
|
+
throw new Error(`Could not parse 1Password field "${options.fieldName || 'value'}" as INI/dotenv.`)
|
|
43
|
+
}
|
|
44
|
+
return stringifyPrimitives(parsed)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Read a dot-separated key path from a parsed structure.
|
|
49
|
+
* Error messages never include secret values.
|
|
50
|
+
* @param {object} parsed - Result of parseStructuredSecret
|
|
51
|
+
* @param {string} keyPath - Dot-separated path, e.g. "database.password"
|
|
52
|
+
* @param {string} fieldName - 1Password field name for error messages
|
|
53
|
+
* @returns {string} Selected value
|
|
54
|
+
*/
|
|
55
|
+
function getKeyPath(parsed, keyPath, fieldName) {
|
|
56
|
+
const segments = keyPath.split('.')
|
|
57
|
+
let current = parsed
|
|
58
|
+
for (const segment of segments) {
|
|
59
|
+
if (current === null || typeof current !== 'object' || !(segment in current)) {
|
|
60
|
+
throw new Error(`Key path "${keyPath}" was not found in 1Password field "${fieldName}".`)
|
|
61
|
+
}
|
|
62
|
+
current = current[segment]
|
|
63
|
+
}
|
|
64
|
+
if (typeof current === 'object') {
|
|
65
|
+
throw new Error(`Key path "${keyPath}" was not found in 1Password field "${fieldName}".`)
|
|
66
|
+
}
|
|
67
|
+
return current
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = { parseStructuredSecret, getKeyPath }
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/* Factory entry for configorama.sync() workers
|
|
2
|
+
Rebuilds the 1Password resolver from JSON-serializable options inside the sync worker */
|
|
3
|
+
const createOnePasswordResolver = require('./index')
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {object} options - JSON-round-tripped syncOptions ({ refs, account, configDir, skipResolution })
|
|
7
|
+
* @returns {object} Variable source
|
|
8
|
+
*/
|
|
9
|
+
module.exports = function createSyncOnePasswordResolver(options = {}) {
|
|
10
|
+
if (options.hasInjectedExecFile) {
|
|
11
|
+
throw new Error('1Password resolver options are not serializable for sync usage. Remove the injected execFile or use the async API.')
|
|
12
|
+
}
|
|
13
|
+
const { hasInjectedExecFile, ...factoryOptions } = options
|
|
14
|
+
return createOnePasswordResolver(factoryOptions)
|
|
15
|
+
}
|
package/src/index.js
CHANGED
|
@@ -163,7 +163,14 @@ module.exports.audit = async (configPathOrObject, settings = {}) => {
|
|
|
163
163
|
const requirements = buildConfigRequirements(analysis)
|
|
164
164
|
const introspection = buildIntrospection(analysis, { requirements })
|
|
165
165
|
const customResolvers = Array.isArray(settings.variableSources)
|
|
166
|
-
? settings.variableSources
|
|
166
|
+
? settings.variableSources
|
|
167
|
+
.filter(source => source.type)
|
|
168
|
+
.map(source => ({
|
|
169
|
+
type: source.type,
|
|
170
|
+
sensitive: source.sensitive,
|
|
171
|
+
risk: source.risk,
|
|
172
|
+
description: source.description,
|
|
173
|
+
}))
|
|
167
174
|
: []
|
|
168
175
|
const originalConfig = analysis.originalConfig || {}
|
|
169
176
|
return buildAuditReport(introspection, {
|
package/src/main.js
CHANGED
|
@@ -92,6 +92,7 @@ const { mergeByKeys } = require('./utils/parsing/mergeByKeys')
|
|
|
92
92
|
const { arrayToJsonPath } = require('./utils/parsing/arrayToJsonPath')
|
|
93
93
|
/* Utils - paths */
|
|
94
94
|
const { findLineByPath } = require('./utils/paths/findLineForKey')
|
|
95
|
+
const { configFileType } = require('./utils/paths/fileType')
|
|
95
96
|
const { normalizeIgnorePaths, compileIgnorePaths, shouldIgnorePath } = require('./utils/paths/ignorePaths')
|
|
96
97
|
/* Utils - regex */
|
|
97
98
|
const { combineRegexes, funcRegex, fileRefSyntax, textRefSyntax } = require('./utils/regex')
|
|
@@ -2574,8 +2575,8 @@ Missing Value ${missingValue} - ${matchedString}
|
|
|
2574
2575
|
if (valueCount.length === 1 && noNestedVars) {
|
|
2575
2576
|
let lineInfo = ''
|
|
2576
2577
|
if (this.originalString && this.configFilePath && valueObject.path) {
|
|
2577
|
-
const ext =
|
|
2578
|
-
if (ext === '.yml' || ext === '.yaml' || ext === '.json') {
|
|
2578
|
+
const ext = configFileType(this.configFilePath)
|
|
2579
|
+
if (ext === '.yml' || ext === '.yaml' || ext === '.json' || ext === '.env') {
|
|
2579
2580
|
const rawLines = this.originalString.split('\n')
|
|
2580
2581
|
const lineNum = findLineByPath(arrayToJsonPath(valueObject.path), rawLines, ext)
|
|
2581
2582
|
if (lineNum) lineInfo = ` at line ${lineNum},`
|
|
@@ -3049,7 +3050,9 @@ Missing Value ${missingValue} - ${matchedString}
|
|
|
3049
3050
|
// ###############
|
|
3050
3051
|
initialCall(func) {
|
|
3051
3052
|
this.deep = []
|
|
3052
|
-
|
|
3053
|
+
// Progress reporting is a debug-only aid; the tracker still coordinates
|
|
3054
|
+
// promises and detects cycles regardless.
|
|
3055
|
+
this.tracker.start(DEBUG)
|
|
3053
3056
|
return func().finally(() => {
|
|
3054
3057
|
this.tracker.stop()
|
|
3055
3058
|
this.deep = []
|
package/src/metadata.js
CHANGED
|
@@ -9,6 +9,7 @@ const { normalizePath, extractFilePath, resolveInnerVariables } = require('./uti
|
|
|
9
9
|
const { shouldIgnorePath } = require('./utils/paths/ignorePaths')
|
|
10
10
|
const { findNestedVariables } = require('./utils/variables/findNestedVariables')
|
|
11
11
|
const { splitOnPipe } = require('./utils/strings/splitOnPipe')
|
|
12
|
+
const { applyDotenvFileRefMetadata } = require('./utils/security/dotenvFileRefs')
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Collect metadata about all variables found in the configuration
|
|
@@ -329,6 +330,12 @@ function collectVariableMetadata({
|
|
|
329
330
|
containsVariables,
|
|
330
331
|
exists: fileExists,
|
|
331
332
|
}
|
|
333
|
+
applyDotenvFileRefMetadata(configPathEntry, {
|
|
334
|
+
filePath: absolutePath,
|
|
335
|
+
relativePath: resolvedPath,
|
|
336
|
+
originalVariableString: rawValue,
|
|
337
|
+
resolvedVariableString: resolvedVarString,
|
|
338
|
+
})
|
|
332
339
|
if (globPattern) {
|
|
333
340
|
configPathEntry.pattern = globPattern
|
|
334
341
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Parses .env file contents into a flat key/value object
|
|
2
|
+
// Values keep their raw text so configorama can resolve ${...} references in them
|
|
3
|
+
const dotenv = require('dotenv')
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {string} contents - Raw .env file text
|
|
7
|
+
* @returns {Object} Flat map of KEY -> raw string value
|
|
8
|
+
*/
|
|
9
|
+
function parse(contents) {
|
|
10
|
+
return dotenv.parse(contents)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
module.exports = { parse }
|
|
@@ -9,6 +9,7 @@ const { findNestedVariables } = require('../utils/variables/findNestedVariables'
|
|
|
9
9
|
const { makeBox } = require('@davidwells/box-logger')
|
|
10
10
|
const { encodeJsSyntax, decodeJsonInVariable, hasEncodedJson } = require('../utils/encoders/js-fixes')
|
|
11
11
|
const { checkFileAccess } = require('../utils/security/safetyPolicy')
|
|
12
|
+
const { applyDotenvFileRefMetadata, isIniLikeFilePath } = require('../utils/security/dotenvFileRefs')
|
|
12
13
|
|
|
13
14
|
/* File Parsers */
|
|
14
15
|
const YAML = require('../parsers/yaml')
|
|
@@ -85,7 +86,7 @@ function parseFileContents(content, filePath) {
|
|
|
85
86
|
if (ext === 'toml' || ext === 'tml') {
|
|
86
87
|
return TOML.parse(content)
|
|
87
88
|
}
|
|
88
|
-
if (isIniLikeExtension(ext)) {
|
|
89
|
+
if (isIniLikeExtension(ext, filePath)) {
|
|
89
90
|
return INI.parse(content)
|
|
90
91
|
}
|
|
91
92
|
|
|
@@ -93,8 +94,8 @@ function parseFileContents(content, filePath) {
|
|
|
93
94
|
return content
|
|
94
95
|
}
|
|
95
96
|
|
|
96
|
-
function isIniLikeExtension(ext) {
|
|
97
|
-
return ext === 'ini' || ext === 'env'
|
|
97
|
+
function isIniLikeExtension(ext, filePath) {
|
|
98
|
+
return ext === 'ini' || ext === 'env' || isIniLikeFilePath(filePath)
|
|
98
99
|
}
|
|
99
100
|
|
|
100
101
|
/**
|
|
@@ -205,6 +206,14 @@ async function getValueFromFile(ctx, variableString, options) {
|
|
|
205
206
|
containsVariables: options.context.value !== options.context.originalSource,
|
|
206
207
|
exists,
|
|
207
208
|
}
|
|
209
|
+
applyDotenvFileRefMetadata(fileRefEntry, {
|
|
210
|
+
filePath: fullFilePath,
|
|
211
|
+
relativePath,
|
|
212
|
+
resolvedPath,
|
|
213
|
+
variableString,
|
|
214
|
+
originalVariableString: options.context.originalSource,
|
|
215
|
+
resolvedVariableString: options.context.value,
|
|
216
|
+
})
|
|
208
217
|
|
|
209
218
|
if (wasOverridden) {
|
|
210
219
|
fileRefEntry.wasOverridden = true
|
|
@@ -257,7 +266,8 @@ Default fallback value will be used if provided.
|
|
|
257
266
|
|
|
258
267
|
${JSON.stringify(options.context, null, 2)}`,
|
|
259
268
|
})
|
|
260
|
-
|
|
269
|
+
// Diagnostic goes to stderr so it never pollutes resolved-config stdout
|
|
270
|
+
console.error(errorMsg)
|
|
261
271
|
}
|
|
262
272
|
// TODO maybe reject. YAML does not allow for null/undefined values
|
|
263
273
|
// return Promise.reject(new Error(errorMsg))
|
|
@@ -411,7 +421,7 @@ ${JSON.stringify(options.context, null, 2)}`,
|
|
|
411
421
|
if (fileExtension === 'toml' || fileExtension === 'tml') {
|
|
412
422
|
valueToPopulate = JSON.stringify(TOML.parse(valueToPopulate))
|
|
413
423
|
}
|
|
414
|
-
if (isIniLikeExtension(fileExtension)) {
|
|
424
|
+
if (isIniLikeExtension(fileExtension, relativePath)) {
|
|
415
425
|
valueToPopulate = INI.toJson(valueToPopulate)
|
|
416
426
|
}
|
|
417
427
|
if (fileExtension === 'tf' || fileExtension === 'hcl') {
|
|
@@ -451,7 +461,7 @@ Please use ":" or "." to reference sub properties. ${deepPropertiesStr}`
|
|
|
451
461
|
return Promise.resolve(valueToPopulate)
|
|
452
462
|
}
|
|
453
463
|
|
|
454
|
-
if (isIniLikeExtension(fileExtension)) {
|
|
464
|
+
if (isIniLikeExtension(fileExtension, relativePath)) {
|
|
455
465
|
valueToPopulate = INI.parse(valueToPopulate)
|
|
456
466
|
return Promise.resolve(valueToPopulate)
|
|
457
467
|
}
|
package/src/sync.js
CHANGED
|
@@ -9,6 +9,21 @@ const enrichMetadata = require('./utils/parsing/enrichMetadata')
|
|
|
9
9
|
*/
|
|
10
10
|
module.exports = function configoramaSync(variableSources = []) {
|
|
11
11
|
const customVariableSources = variableSources.map((varSrc) => {
|
|
12
|
+
/* Plugin factories: match/resolver do not survive the JSON trip through
|
|
13
|
+
sync-rpc, so plugins carry a syncFactory path + JSON syncOptions and
|
|
14
|
+
the real source is rebuilt here inside the worker process */
|
|
15
|
+
if (varSrc.syncFactory) {
|
|
16
|
+
const factoryPath = getFullPath(varSrc.syncFactory)
|
|
17
|
+
if (!fs.existsSync(factoryPath)) {
|
|
18
|
+
throw new Error(`Sync factory missing. Can't find ${factoryPath}`)
|
|
19
|
+
}
|
|
20
|
+
const createSource = require(factoryPath)
|
|
21
|
+
if (typeof createSource !== 'function') {
|
|
22
|
+
throw new Error(`Sync factory must export a function. Check ${factoryPath}`)
|
|
23
|
+
}
|
|
24
|
+
return createSource(varSrc.syncOptions || {})
|
|
25
|
+
}
|
|
26
|
+
|
|
12
27
|
if (!varSrc.match || typeof varSrc.match !== 'string') {
|
|
13
28
|
throw new Error('Variable source must be string for .sync usage')
|
|
14
29
|
}
|
|
@@ -67,6 +82,18 @@ module.exports = function configoramaSync(variableSources = []) {
|
|
|
67
82
|
instance.variableTypes
|
|
68
83
|
)
|
|
69
84
|
|
|
85
|
+
/* Collect custom metadata from worker-side sources, mirroring the
|
|
86
|
+
collectMetadata loop the async API runs in src/index.js */
|
|
87
|
+
for (const source of customVariableSources) {
|
|
88
|
+
if (typeof source.collectMetadata === 'function') {
|
|
89
|
+
const customData = source.collectMetadata()
|
|
90
|
+
if (customData !== undefined && customData !== null) {
|
|
91
|
+
const metadataKey = source.metadataKey || `${source.type}References`
|
|
92
|
+
enrichedMetadata[metadataKey] = customData
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
70
97
|
return {
|
|
71
98
|
variableSyntax: instance.variableSyntax,
|
|
72
99
|
variableTypes: instance.variableTypes,
|
|
@@ -13,15 +13,21 @@ class PromiseTracker {
|
|
|
13
13
|
this.startTime = Date.now()
|
|
14
14
|
this.cursor = 0
|
|
15
15
|
}
|
|
16
|
-
start() {
|
|
16
|
+
start(enableProgress = false) {
|
|
17
17
|
this.reset()
|
|
18
|
-
|
|
18
|
+
// The tracker's job is cycle detection and promise coordination; the
|
|
19
|
+
// periodic progress report is a debug-only aid, off unless requested.
|
|
20
|
+
if (enableProgress) {
|
|
21
|
+
this.interval = setInterval(this.report.bind(this), 2500)
|
|
22
|
+
}
|
|
19
23
|
}
|
|
20
24
|
report() {
|
|
21
25
|
const delta = Date.now() - this.startTime
|
|
22
26
|
const pending = this.getPending()
|
|
23
27
|
const dots = dotDotDot(this.cursor++, 100, '...')
|
|
24
|
-
|
|
28
|
+
// Diagnostic progress goes to stderr so it never pollutes stdout (resolved
|
|
29
|
+
// config output, or `configx --export` lines consumed by eval).
|
|
30
|
+
console.error([
|
|
25
31
|
`Fetching Async values${dots}`,
|
|
26
32
|
].concat(
|
|
27
33
|
pending.map((promise) => `- ${promise.waitList}`)
|
|
@@ -40,7 +46,8 @@ class PromiseTracker {
|
|
|
40
46
|
/**/
|
|
41
47
|
}
|
|
42
48
|
stop() {
|
|
43
|
-
clearInterval(this.interval)
|
|
49
|
+
if (this.interval) clearInterval(this.interval)
|
|
50
|
+
this.interval = null
|
|
44
51
|
this.reset()
|
|
45
52
|
}
|
|
46
53
|
add(variable, promise, specifier, hasFilter, promiseKey) {
|
|
@@ -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()
|