molex-env 0.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/LICENSE +21 -0
- package/README.md +75 -0
- package/package.json +32 -0
- package/src/index.js +19 -0
- package/src/lib/apply.js +58 -0
- package/src/lib/cast.js +124 -0
- package/src/lib/core.js +133 -0
- package/src/lib/errors.js +98 -0
- package/src/lib/files.js +36 -0
- package/src/lib/parser.js +115 -0
- package/src/lib/schema.js +59 -0
- package/src/lib/utils.js +21 -0
- package/src/lib/watch.js +76 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
molex-env
|
|
2
|
+
|
|
3
|
+
Native .menv loader with profiles, typing, origin tracking, and optional reload.
|
|
4
|
+
|
|
5
|
+
Highlights
|
|
6
|
+
- Deterministic profile merging
|
|
7
|
+
- Typed parsing (boolean, number, json, date)
|
|
8
|
+
- Strict mode for unknown or duplicate keys
|
|
9
|
+
- Origin tracking (file + line)
|
|
10
|
+
- Immutable config objects
|
|
11
|
+
- Optional file watching for reload
|
|
12
|
+
|
|
13
|
+
Install
|
|
14
|
+
- npm install molex-env
|
|
15
|
+
|
|
16
|
+
Basic usage
|
|
17
|
+
const { load } = require('molex-env');
|
|
18
|
+
|
|
19
|
+
const result = load({
|
|
20
|
+
profile: 'prod',
|
|
21
|
+
export: true,
|
|
22
|
+
strict: true,
|
|
23
|
+
schema: {
|
|
24
|
+
PORT: 'number',
|
|
25
|
+
DEBUG: 'boolean',
|
|
26
|
+
SERVICE_URL: { type: 'string', required: true }
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
console.log(result.parsed.PORT);
|
|
31
|
+
console.log(result.origins.SERVICE_URL);
|
|
32
|
+
|
|
33
|
+
File precedence
|
|
34
|
+
1) .menv
|
|
35
|
+
2) .menv.local
|
|
36
|
+
3) .menv.{profile}
|
|
37
|
+
4) .menv.{profile}.local
|
|
38
|
+
|
|
39
|
+
API
|
|
40
|
+
load(options)
|
|
41
|
+
- cwd: base directory (default: process.cwd())
|
|
42
|
+
- profile: profile name for .menv.{profile}
|
|
43
|
+
- files: custom file list (absolute or relative to cwd)
|
|
44
|
+
- schema: allowed keys, types, defaults, required
|
|
45
|
+
- strict: reject unknown keys, duplicates, and invalid lines
|
|
46
|
+
- cast: true | false | { boolean, number, json, date }
|
|
47
|
+
- export: write values to process.env
|
|
48
|
+
- override: override existing process.env values
|
|
49
|
+
- freeze: deep-freeze parsed config (default true)
|
|
50
|
+
- onWarning: function(info) for non-strict duplicates
|
|
51
|
+
|
|
52
|
+
parse(text, options)
|
|
53
|
+
- Parse a string of .menv content using the same typing rules
|
|
54
|
+
|
|
55
|
+
watch(options, onChange)
|
|
56
|
+
- Watch resolved files and reload on change
|
|
57
|
+
- onChange(err, result)
|
|
58
|
+
|
|
59
|
+
Schema example
|
|
60
|
+
const schema = {
|
|
61
|
+
PORT: { type: 'number', default: 3000 },
|
|
62
|
+
DEBUG: { type: 'boolean', default: false },
|
|
63
|
+
METADATA: { type: 'json' },
|
|
64
|
+
START_DATE: { type: 'date' }
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
Notes
|
|
68
|
+
- Use .menv.local for machine-specific values
|
|
69
|
+
- Use strict mode to detect surprises early
|
|
70
|
+
|
|
71
|
+
Example project
|
|
72
|
+
An example app is included in examples/basic.
|
|
73
|
+
Run it with:
|
|
74
|
+
- npm install (from examples/basic)
|
|
75
|
+
- npm start
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "molex-env",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Native .menv loader with profiles, typing, and origin tracking.",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"files": [
|
|
7
|
+
"src",
|
|
8
|
+
"LICENSE",
|
|
9
|
+
"README.md"
|
|
10
|
+
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"test": "node --test"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"env",
|
|
16
|
+
"dotenv",
|
|
17
|
+
"menv",
|
|
18
|
+
"config",
|
|
19
|
+
"profile",
|
|
20
|
+
"native"
|
|
21
|
+
],
|
|
22
|
+
"author": "",
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/tonywied17/molex-env.git"
|
|
27
|
+
},
|
|
28
|
+
"bugs": {
|
|
29
|
+
"url": "https://github.com/tonywied17/molex-env/issues"
|
|
30
|
+
},
|
|
31
|
+
"homepage": "https://github.com/tonywied17/molex-env#readme"
|
|
32
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { load, parse } = require('./lib/core');
|
|
4
|
+
const { watch } = require('./lib/watch');
|
|
5
|
+
|
|
6
|
+
module.exports = {
|
|
7
|
+
load,
|
|
8
|
+
parse,
|
|
9
|
+
/**
|
|
10
|
+
* Watch resolved .menv files and reload on change.
|
|
11
|
+
* @param {object} options
|
|
12
|
+
* @param {(err: Error|null, result?: {parsed: object, origins: object, files: string[]}) => void} onChange
|
|
13
|
+
* @returns {{close: () => void}}
|
|
14
|
+
*/
|
|
15
|
+
watch(options, onChange)
|
|
16
|
+
{
|
|
17
|
+
return watch(options, onChange, load);
|
|
18
|
+
}
|
|
19
|
+
};
|
package/src/lib/apply.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { unknownKeyError, duplicateKeyError } = require('./errors');
|
|
4
|
+
const { coerceType, autoCast } = require('./cast');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Apply a parsed entry to the state with schema/type checks.
|
|
8
|
+
* @param {{values: object, origins: object, seen: Set<string>}} state
|
|
9
|
+
* @param {{key: string, raw: string, line: number}} entry
|
|
10
|
+
* @param {{schema: object|null, strict: boolean, cast: object, onWarning?: Function}} options
|
|
11
|
+
* @param {string} filePath
|
|
12
|
+
* @returns {void}
|
|
13
|
+
*/
|
|
14
|
+
function applyEntry(state, entry, options, filePath)
|
|
15
|
+
{
|
|
16
|
+
const { schema, strict, cast, onWarning } = options;
|
|
17
|
+
const { key, raw, line } = entry;
|
|
18
|
+
|
|
19
|
+
if (schema && strict && !schema[key])
|
|
20
|
+
{
|
|
21
|
+
throw unknownKeyError(key, filePath, line);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (state.seen.has(key))
|
|
25
|
+
{
|
|
26
|
+
if (strict)
|
|
27
|
+
{
|
|
28
|
+
throw duplicateKeyError(key, filePath, line);
|
|
29
|
+
}
|
|
30
|
+
if (typeof onWarning === 'function')
|
|
31
|
+
{
|
|
32
|
+
onWarning({
|
|
33
|
+
type: 'duplicate',
|
|
34
|
+
key,
|
|
35
|
+
file: filePath,
|
|
36
|
+
line
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const def = schema ? schema[key] : null;
|
|
42
|
+
let value;
|
|
43
|
+
if (def && def.type)
|
|
44
|
+
{
|
|
45
|
+
value = coerceType(raw, def.type, filePath, line);
|
|
46
|
+
} else
|
|
47
|
+
{
|
|
48
|
+
value = autoCast(raw, cast);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
state.values[key] = value;
|
|
52
|
+
state.origins[key] = { file: filePath, line, raw };
|
|
53
|
+
state.seen.add(key);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = {
|
|
57
|
+
applyEntry
|
|
58
|
+
};
|
package/src/lib/cast.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { invalidTypeError } = require('./errors');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Normalize cast options into explicit booleans.
|
|
7
|
+
* @param {boolean|{boolean?: boolean, number?: boolean, json?: boolean, date?: boolean}} cast
|
|
8
|
+
* @returns {{boolean: boolean, number: boolean, json: boolean, date: boolean}}
|
|
9
|
+
*/
|
|
10
|
+
function normalizeCast(cast)
|
|
11
|
+
{
|
|
12
|
+
if (cast === true || cast === undefined)
|
|
13
|
+
{
|
|
14
|
+
return { boolean: true, number: true, json: true, date: true };
|
|
15
|
+
}
|
|
16
|
+
if (cast === false)
|
|
17
|
+
{
|
|
18
|
+
return { boolean: false, number: false, json: false, date: false };
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
boolean: cast.boolean !== false,
|
|
22
|
+
number: cast.number !== false,
|
|
23
|
+
json: cast.json !== false,
|
|
24
|
+
date: cast.date !== false
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Check if a string is a number.
|
|
30
|
+
* @param {string} value
|
|
31
|
+
* @returns {boolean}
|
|
32
|
+
*/
|
|
33
|
+
function isNumber(value)
|
|
34
|
+
{
|
|
35
|
+
return /^-?\d+(\.\d+)?$/.test(value);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Check if a string looks like an ISO date.
|
|
40
|
+
* @param {string} value
|
|
41
|
+
* @returns {boolean}
|
|
42
|
+
*/
|
|
43
|
+
function isIsoDate(value)
|
|
44
|
+
{
|
|
45
|
+
return /^\d{4}-\d{2}-\d{2}(?:[T\s].*)?$/.test(value);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Coerce a raw string to the requested type.
|
|
50
|
+
* @param {string} raw
|
|
51
|
+
* @param {string} type
|
|
52
|
+
* @param {string} file
|
|
53
|
+
* @param {number} line
|
|
54
|
+
* @returns {any}
|
|
55
|
+
*/
|
|
56
|
+
function coerceType(raw, type, file, line)
|
|
57
|
+
{
|
|
58
|
+
if (type === 'string') return raw;
|
|
59
|
+
if (type === 'boolean')
|
|
60
|
+
{
|
|
61
|
+
if (raw.toLowerCase() === 'true') return true;
|
|
62
|
+
if (raw.toLowerCase() === 'false') return false;
|
|
63
|
+
throw invalidTypeError('boolean', raw, file, line);
|
|
64
|
+
}
|
|
65
|
+
if (type === 'number')
|
|
66
|
+
{
|
|
67
|
+
if (!isNumber(raw)) throw invalidTypeError('number', raw, file, line);
|
|
68
|
+
return Number(raw);
|
|
69
|
+
}
|
|
70
|
+
if (type === 'json')
|
|
71
|
+
{
|
|
72
|
+
return JSON.parse(raw);
|
|
73
|
+
}
|
|
74
|
+
if (type === 'date')
|
|
75
|
+
{
|
|
76
|
+
const date = new Date(raw);
|
|
77
|
+
if (Number.isNaN(date.getTime())) throw invalidTypeError('date', raw, file, line);
|
|
78
|
+
return date;
|
|
79
|
+
}
|
|
80
|
+
return raw;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Auto-cast a raw string based on enabled rules.
|
|
85
|
+
* @param {string} raw
|
|
86
|
+
* @param {{boolean: boolean, number: boolean, json: boolean, date: boolean}} cast
|
|
87
|
+
* @returns {any}
|
|
88
|
+
*/
|
|
89
|
+
function autoCast(raw, cast)
|
|
90
|
+
{
|
|
91
|
+
const trimmed = raw.trim();
|
|
92
|
+
if (cast.boolean)
|
|
93
|
+
{
|
|
94
|
+
const lower = trimmed.toLowerCase();
|
|
95
|
+
if (lower === 'true') return true;
|
|
96
|
+
if (lower === 'false') return false;
|
|
97
|
+
}
|
|
98
|
+
if (cast.number && isNumber(trimmed))
|
|
99
|
+
{
|
|
100
|
+
return Number(trimmed);
|
|
101
|
+
}
|
|
102
|
+
if (cast.json && (trimmed.startsWith('{') || trimmed.startsWith('[')))
|
|
103
|
+
{
|
|
104
|
+
try
|
|
105
|
+
{
|
|
106
|
+
return JSON.parse(trimmed);
|
|
107
|
+
} catch (err)
|
|
108
|
+
{
|
|
109
|
+
return trimmed;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (cast.date && isIsoDate(trimmed))
|
|
113
|
+
{
|
|
114
|
+
const date = new Date(trimmed);
|
|
115
|
+
if (!Number.isNaN(date.getTime())) return date;
|
|
116
|
+
}
|
|
117
|
+
return trimmed;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = {
|
|
121
|
+
normalizeCast,
|
|
122
|
+
coerceType,
|
|
123
|
+
autoCast
|
|
124
|
+
};
|
package/src/lib/core.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
|
|
5
|
+
const { normalizeCast } = require('./cast');
|
|
6
|
+
const { normalizeSchema, applySchemaDefaults } = require('./schema');
|
|
7
|
+
const { parseEntries } = require('./parser');
|
|
8
|
+
const { applyEntry } = require('./apply');
|
|
9
|
+
const { resolveFiles } = require('./files');
|
|
10
|
+
const { deepFreeze } = require('./utils');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Build a new parsing state container.
|
|
14
|
+
* @returns {{values: object, origins: object, seen: Set<string>}}
|
|
15
|
+
*/
|
|
16
|
+
function buildState()
|
|
17
|
+
{
|
|
18
|
+
return {
|
|
19
|
+
values: {},
|
|
20
|
+
origins: {},
|
|
21
|
+
seen: new Set()
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Export parsed values to process.env if enabled.
|
|
27
|
+
* @param {object} values
|
|
28
|
+
* @param {object} options
|
|
29
|
+
* @returns {void}
|
|
30
|
+
*/
|
|
31
|
+
function exportToEnv(values, options)
|
|
32
|
+
{
|
|
33
|
+
if (!options.export) return;
|
|
34
|
+
for (const [key, value] of Object.entries(values))
|
|
35
|
+
{
|
|
36
|
+
if (!options.override && Object.prototype.hasOwnProperty.call(process.env, key))
|
|
37
|
+
{
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
process.env[key] = value === undefined ? '' : String(value);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Load .menv files and return parsed values with origins.
|
|
46
|
+
* @param {object} options
|
|
47
|
+
* @returns {{parsed: object, origins: object, files: string[]}}
|
|
48
|
+
*/
|
|
49
|
+
function load(options = {})
|
|
50
|
+
{
|
|
51
|
+
const normalizedSchema = normalizeSchema(options.schema);
|
|
52
|
+
const cast = normalizeCast(options.cast);
|
|
53
|
+
const strict = Boolean(options.strict);
|
|
54
|
+
|
|
55
|
+
const state = buildState();
|
|
56
|
+
const files = resolveFiles(options);
|
|
57
|
+
const readFiles = [];
|
|
58
|
+
|
|
59
|
+
for (const filePath of files)
|
|
60
|
+
{
|
|
61
|
+
if (!fs.existsSync(filePath)) continue;
|
|
62
|
+
const text = fs.readFileSync(filePath, 'utf8');
|
|
63
|
+
const entries = parseEntries(text, { strict, filePath });
|
|
64
|
+
for (const entry of entries)
|
|
65
|
+
{
|
|
66
|
+
applyEntry(state, entry, {
|
|
67
|
+
schema: normalizedSchema,
|
|
68
|
+
strict,
|
|
69
|
+
cast,
|
|
70
|
+
onWarning: options.onWarning
|
|
71
|
+
}, filePath);
|
|
72
|
+
}
|
|
73
|
+
readFiles.push(filePath);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
applySchemaDefaults(state.values, state.origins, normalizedSchema, strict);
|
|
77
|
+
exportToEnv(state.values, options);
|
|
78
|
+
|
|
79
|
+
if (options.freeze !== false)
|
|
80
|
+
{
|
|
81
|
+
deepFreeze(state.values);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
parsed: state.values,
|
|
86
|
+
origins: state.origins,
|
|
87
|
+
files: readFiles
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Parse a string containing .menv content.
|
|
93
|
+
* @param {string} text
|
|
94
|
+
* @param {object} options
|
|
95
|
+
* @returns {{parsed: object, origins: object, files: string[]}}
|
|
96
|
+
*/
|
|
97
|
+
function parse(text, options = {})
|
|
98
|
+
{
|
|
99
|
+
const normalizedSchema = normalizeSchema(options.schema);
|
|
100
|
+
const cast = normalizeCast(options.cast);
|
|
101
|
+
const strict = Boolean(options.strict);
|
|
102
|
+
const state = buildState();
|
|
103
|
+
const filePath = options.filePath || '<inline>';
|
|
104
|
+
|
|
105
|
+
const entries = parseEntries(text, { strict, filePath });
|
|
106
|
+
for (const entry of entries)
|
|
107
|
+
{
|
|
108
|
+
applyEntry(state, entry, {
|
|
109
|
+
schema: normalizedSchema,
|
|
110
|
+
strict,
|
|
111
|
+
cast,
|
|
112
|
+
onWarning: options.onWarning
|
|
113
|
+
}, filePath);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
applySchemaDefaults(state.values, state.origins, normalizedSchema, strict);
|
|
117
|
+
|
|
118
|
+
if (options.freeze !== false)
|
|
119
|
+
{
|
|
120
|
+
deepFreeze(state.values);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
parsed: state.values,
|
|
125
|
+
origins: state.origins,
|
|
126
|
+
files: options.filePath ? [options.filePath] : []
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = {
|
|
131
|
+
load,
|
|
132
|
+
parse
|
|
133
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Base error type for molex-env.
|
|
5
|
+
*/
|
|
6
|
+
class MenvError extends Error
|
|
7
|
+
{
|
|
8
|
+
constructor(message, details)
|
|
9
|
+
{
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = 'MenvError';
|
|
12
|
+
if (details)
|
|
13
|
+
{
|
|
14
|
+
this.details = details;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Format a file/line suffix for error messages.
|
|
21
|
+
* @param {string} file
|
|
22
|
+
* @param {number} line
|
|
23
|
+
* @returns {string}
|
|
24
|
+
*/
|
|
25
|
+
function formatLocation(file, line)
|
|
26
|
+
{
|
|
27
|
+
if (!file) return '';
|
|
28
|
+
if (!line) return ` (${file})`;
|
|
29
|
+
return ` (${file}:${line})`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Create an invalid line error.
|
|
34
|
+
* @param {number} line
|
|
35
|
+
* @param {string} rawLine
|
|
36
|
+
* @param {string} file
|
|
37
|
+
* @returns {MenvError}
|
|
38
|
+
*/
|
|
39
|
+
function invalidLineError(line, rawLine, file)
|
|
40
|
+
{
|
|
41
|
+
return new MenvError(`Invalid line ${line}: ${rawLine}${formatLocation(file)}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Create an unknown key error.
|
|
46
|
+
* @param {string} key
|
|
47
|
+
* @param {string} file
|
|
48
|
+
* @param {number} line
|
|
49
|
+
* @returns {MenvError}
|
|
50
|
+
*/
|
|
51
|
+
function unknownKeyError(key, file, line)
|
|
52
|
+
{
|
|
53
|
+
return new MenvError(`Unknown key: ${key}${formatLocation(file, line)}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Create a duplicate key error.
|
|
58
|
+
* @param {string} key
|
|
59
|
+
* @param {string} file
|
|
60
|
+
* @param {number} line
|
|
61
|
+
* @returns {MenvError}
|
|
62
|
+
*/
|
|
63
|
+
function duplicateKeyError(key, file, line)
|
|
64
|
+
{
|
|
65
|
+
return new MenvError(`Duplicate key: ${key}${formatLocation(file, line)}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Create a missing required key error.
|
|
70
|
+
* @param {string} key
|
|
71
|
+
* @returns {MenvError}
|
|
72
|
+
*/
|
|
73
|
+
function missingRequiredError(key)
|
|
74
|
+
{
|
|
75
|
+
return new MenvError(`Missing required key: ${key}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Create an invalid type error.
|
|
80
|
+
* @param {string} type
|
|
81
|
+
* @param {string} raw
|
|
82
|
+
* @param {string} file
|
|
83
|
+
* @param {number} line
|
|
84
|
+
* @returns {MenvError}
|
|
85
|
+
*/
|
|
86
|
+
function invalidTypeError(type, raw, file, line)
|
|
87
|
+
{
|
|
88
|
+
return new MenvError(`Invalid ${type}: ${raw}${formatLocation(file, line)}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = {
|
|
92
|
+
MenvError,
|
|
93
|
+
invalidLineError,
|
|
94
|
+
unknownKeyError,
|
|
95
|
+
duplicateKeyError,
|
|
96
|
+
missingRequiredError,
|
|
97
|
+
invalidTypeError
|
|
98
|
+
};
|
package/src/lib/files.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resolve .menv file paths based on options.
|
|
7
|
+
* @param {{cwd?: string, files?: string[], profile?: string}} options
|
|
8
|
+
* @returns {string[]}
|
|
9
|
+
*/
|
|
10
|
+
function resolveFiles(options)
|
|
11
|
+
{
|
|
12
|
+
const cwd = options.cwd || process.cwd();
|
|
13
|
+
if (Array.isArray(options.files) && options.files.length > 0)
|
|
14
|
+
{
|
|
15
|
+
return options.files.map((file) => (path.isAbsolute(file) ? file : path.join(cwd, file)));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const files = [
|
|
19
|
+
path.join(cwd, '.menv'),
|
|
20
|
+
path.join(cwd, '.menv.local')
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
if (options.profile)
|
|
24
|
+
{
|
|
25
|
+
files.push(
|
|
26
|
+
path.join(cwd, `.menv.${options.profile}`),
|
|
27
|
+
path.join(cwd, `.menv.${options.profile}.local`)
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return files;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = {
|
|
35
|
+
resolveFiles
|
|
36
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { invalidLineError } = require('./errors');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Strip inline comments while preserving quoted values.
|
|
7
|
+
* @param {string} line
|
|
8
|
+
* @returns {string}
|
|
9
|
+
*/
|
|
10
|
+
function stripInlineComment(line)
|
|
11
|
+
{
|
|
12
|
+
let inSingle = false;
|
|
13
|
+
let inDouble = false;
|
|
14
|
+
let escaped = false;
|
|
15
|
+
for (let i = 0; i < line.length; i += 1)
|
|
16
|
+
{
|
|
17
|
+
const ch = line[i];
|
|
18
|
+
if (escaped)
|
|
19
|
+
{
|
|
20
|
+
escaped = false;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (ch === '\\')
|
|
24
|
+
{
|
|
25
|
+
escaped = true;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (ch === '"' && !inSingle)
|
|
29
|
+
{
|
|
30
|
+
inDouble = !inDouble;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (ch === "'" && !inDouble)
|
|
34
|
+
{
|
|
35
|
+
inSingle = !inSingle;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (ch === '#' && !inSingle && !inDouble)
|
|
39
|
+
{
|
|
40
|
+
return line.slice(0, i);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return line;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Remove wrapping quotes and unescape common sequences.
|
|
48
|
+
* @param {string} value
|
|
49
|
+
* @returns {string}
|
|
50
|
+
*/
|
|
51
|
+
function stripQuotes(value)
|
|
52
|
+
{
|
|
53
|
+
const trimmed = value.trim();
|
|
54
|
+
if (trimmed.length >= 2)
|
|
55
|
+
{
|
|
56
|
+
const first = trimmed[0];
|
|
57
|
+
const last = trimmed[trimmed.length - 1];
|
|
58
|
+
if ((first === '"' && last === '"') || (first === "'" && last === "'"))
|
|
59
|
+
{
|
|
60
|
+
return trimmed
|
|
61
|
+
.slice(1, -1)
|
|
62
|
+
.replace(/\\n/g, '\n')
|
|
63
|
+
.replace(/\\r/g, '\r')
|
|
64
|
+
.replace(/\\t/g, '\t')
|
|
65
|
+
.replace(/\\\\/g, '\\');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return trimmed;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Parse raw text into key/value entries.
|
|
73
|
+
* @param {string} text
|
|
74
|
+
* @param {{strict?: boolean, filePath?: string}} options
|
|
75
|
+
* @returns {{key: string, raw: string, line: number}[]}
|
|
76
|
+
*/
|
|
77
|
+
function parseEntries(text, options)
|
|
78
|
+
{
|
|
79
|
+
const strict = Boolean(options.strict);
|
|
80
|
+
const filePath = options.filePath;
|
|
81
|
+
const lines = text.split(/\r?\n/);
|
|
82
|
+
const entries = [];
|
|
83
|
+
|
|
84
|
+
for (let i = 0; i < lines.length; i += 1)
|
|
85
|
+
{
|
|
86
|
+
const rawLine = lines[i];
|
|
87
|
+
const cleaned = stripInlineComment(rawLine);
|
|
88
|
+
if (!cleaned.trim()) continue;
|
|
89
|
+
|
|
90
|
+
const match = cleaned.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/);
|
|
91
|
+
if (!match)
|
|
92
|
+
{
|
|
93
|
+
if (strict)
|
|
94
|
+
{
|
|
95
|
+
throw invalidLineError(i + 1, rawLine, filePath);
|
|
96
|
+
}
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const key = match[1];
|
|
101
|
+
const rawValue = stripQuotes(match[2] || '');
|
|
102
|
+
|
|
103
|
+
entries.push({
|
|
104
|
+
key,
|
|
105
|
+
raw: rawValue,
|
|
106
|
+
line: i + 1
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return entries;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = {
|
|
114
|
+
parseEntries
|
|
115
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { missingRequiredError } = require('./errors');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Normalize schema definitions into objects.
|
|
7
|
+
* @param {object} schema
|
|
8
|
+
* @returns {object|null}
|
|
9
|
+
*/
|
|
10
|
+
function normalizeSchema(schema)
|
|
11
|
+
{
|
|
12
|
+
if (!schema) return null;
|
|
13
|
+
const normalized = {};
|
|
14
|
+
for (const key of Object.keys(schema))
|
|
15
|
+
{
|
|
16
|
+
const def = schema[key];
|
|
17
|
+
if (typeof def === 'string')
|
|
18
|
+
{
|
|
19
|
+
normalized[key] = { type: def };
|
|
20
|
+
} else if (def && typeof def === 'object')
|
|
21
|
+
{
|
|
22
|
+
normalized[key] = { ...def };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return normalized;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Apply defaults and required checks from schema.
|
|
30
|
+
* @param {object} values
|
|
31
|
+
* @param {object} origins
|
|
32
|
+
* @param {object|null} schema
|
|
33
|
+
* @param {boolean} strict
|
|
34
|
+
* @returns {void}
|
|
35
|
+
*/
|
|
36
|
+
function applySchemaDefaults(values, origins, schema, strict)
|
|
37
|
+
{
|
|
38
|
+
if (!schema) return;
|
|
39
|
+
for (const key of Object.keys(schema))
|
|
40
|
+
{
|
|
41
|
+
const def = schema[key];
|
|
42
|
+
if (values[key] === undefined)
|
|
43
|
+
{
|
|
44
|
+
if (def && Object.prototype.hasOwnProperty.call(def, 'default'))
|
|
45
|
+
{
|
|
46
|
+
values[key] = def.default;
|
|
47
|
+
origins[key] = { file: '<default>', line: 0, raw: undefined };
|
|
48
|
+
} else if (strict && def && def.required)
|
|
49
|
+
{
|
|
50
|
+
throw missingRequiredError(key);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = {
|
|
57
|
+
normalizeSchema,
|
|
58
|
+
applySchemaDefaults
|
|
59
|
+
};
|
package/src/lib/utils.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Recursively freeze an object graph.
|
|
5
|
+
* @param {object} obj
|
|
6
|
+
* @returns {object}
|
|
7
|
+
*/
|
|
8
|
+
function deepFreeze(obj)
|
|
9
|
+
{
|
|
10
|
+
if (!obj || typeof obj !== 'object' || Object.isFrozen(obj)) return obj;
|
|
11
|
+
Object.freeze(obj);
|
|
12
|
+
for (const key of Object.keys(obj))
|
|
13
|
+
{
|
|
14
|
+
deepFreeze(obj[key]);
|
|
15
|
+
}
|
|
16
|
+
return obj;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
module.exports = {
|
|
20
|
+
deepFreeze
|
|
21
|
+
};
|
package/src/lib/watch.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { resolveFiles } = require('./files');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Watch resolved files and reload on changes.
|
|
9
|
+
* @param {object} options
|
|
10
|
+
* @param {(err: Error|null, result?: {parsed: object, origins: object, files: string[]}) => void} onChange
|
|
11
|
+
* @param {(options: object) => {parsed: object, origins: object, files: string[]}} load
|
|
12
|
+
* @returns {{close: () => void}}
|
|
13
|
+
*/
|
|
14
|
+
function watch(options, onChange, load)
|
|
15
|
+
{
|
|
16
|
+
if (typeof onChange !== 'function')
|
|
17
|
+
{
|
|
18
|
+
throw new Error('onChange callback is required');
|
|
19
|
+
}
|
|
20
|
+
if (typeof load !== 'function')
|
|
21
|
+
{
|
|
22
|
+
throw new Error('load function is required');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const files = resolveFiles(options);
|
|
26
|
+
const cwd = options.cwd || process.cwd();
|
|
27
|
+
const basenames = new Set(files.map((file) => path.basename(file)));
|
|
28
|
+
const watchers = [];
|
|
29
|
+
let timer = null;
|
|
30
|
+
|
|
31
|
+
const trigger = () =>
|
|
32
|
+
{
|
|
33
|
+
if (timer) clearTimeout(timer);
|
|
34
|
+
timer = setTimeout(() =>
|
|
35
|
+
{
|
|
36
|
+
try
|
|
37
|
+
{
|
|
38
|
+
const result = load(options);
|
|
39
|
+
onChange(null, result);
|
|
40
|
+
} catch (err)
|
|
41
|
+
{
|
|
42
|
+
onChange(err);
|
|
43
|
+
}
|
|
44
|
+
}, 50);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
for (const filePath of files)
|
|
48
|
+
{
|
|
49
|
+
if (!fs.existsSync(filePath)) continue;
|
|
50
|
+
watchers.push(fs.watch(filePath, trigger));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (options.watchMissing !== false)
|
|
54
|
+
{
|
|
55
|
+
watchers.push(fs.watch(cwd, (event, filename) =>
|
|
56
|
+
{
|
|
57
|
+
if (!filename) return;
|
|
58
|
+
if (basenames.has(filename))
|
|
59
|
+
{
|
|
60
|
+
trigger();
|
|
61
|
+
}
|
|
62
|
+
}));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
close()
|
|
67
|
+
{
|
|
68
|
+
watchers.forEach((watcher) => watcher.close());
|
|
69
|
+
watchers.length = 0;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = {
|
|
75
|
+
watch
|
|
76
|
+
};
|