@meltstudio/config-loader 3.5.0 → 3.7.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 +27 -768
- package/dist/index.d.ts +44 -1
- package/dist/index.js +238 -4
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# @meltstudio/config-loader
|
|
2
2
|
|
|
3
|
-
A type-safe configuration loader for Node.js. Define your schema once, load from YAML or
|
|
3
|
+
A type-safe configuration loader for Node.js. Define your schema once, load from YAML, JSON, or TOML files, `.env` files, environment variables, and CLI arguments — and get a fully typed result with zero manual type annotations.
|
|
4
4
|
|
|
5
|
-
> **Upgrading from v1?** v1.x is deprecated
|
|
5
|
+
> **Upgrading from v1?** v1.x is deprecated and no longer maintained. Install the latest version with `npm install @meltstudio/config-loader@latest` or `yarn add @meltstudio/config-loader@latest`.
|
|
6
6
|
|
|
7
7
|
**[Full documentation](https://meltstudio.github.io/config-loader/)**
|
|
8
8
|
|
|
@@ -16,47 +16,34 @@ import c from "@meltstudio/config-loader";
|
|
|
16
16
|
const config = c
|
|
17
17
|
.schema({
|
|
18
18
|
port: c.number({ required: true, env: "PORT" }),
|
|
19
|
+
host: c.string({ env: "HOST", defaultValue: "localhost" }),
|
|
20
|
+
env: c.string({
|
|
21
|
+
env: "NODE_ENV",
|
|
22
|
+
defaultValue: "development",
|
|
23
|
+
oneOf: ["development", "staging", "production"],
|
|
24
|
+
}),
|
|
25
|
+
apiKey: c.string({ env: "API_KEY", sensitive: true }),
|
|
19
26
|
database: c.object({
|
|
20
27
|
item: {
|
|
21
28
|
host: c.string({ required: true }),
|
|
22
|
-
|
|
23
|
-
item: {
|
|
24
|
-
username: c.string(),
|
|
25
|
-
password: c.string({ env: "DB_PASSWORD" }),
|
|
26
|
-
},
|
|
27
|
-
}),
|
|
29
|
+
password: c.string({ env: "DB_PASSWORD", sensitive: true }),
|
|
28
30
|
},
|
|
29
31
|
}),
|
|
30
|
-
features: c.array({
|
|
31
|
-
required: true,
|
|
32
|
-
item: c.object({
|
|
33
|
-
item: {
|
|
34
|
-
name: c.string(),
|
|
35
|
-
enabled: c.bool(),
|
|
36
|
-
},
|
|
37
|
-
}),
|
|
38
|
-
}),
|
|
39
32
|
})
|
|
40
33
|
.load({
|
|
41
34
|
env: true,
|
|
42
35
|
args: true,
|
|
43
36
|
files: "./config.yaml",
|
|
37
|
+
envFile: "./.env",
|
|
44
38
|
});
|
|
45
39
|
|
|
46
|
-
// config is fully typed
|
|
47
|
-
// {
|
|
48
|
-
// port: number;
|
|
49
|
-
// database: { host: string; credentials: { username: string; password: string } };
|
|
50
|
-
// features: { name: string; enabled: boolean }[];
|
|
51
|
-
// }
|
|
40
|
+
// config is fully typed — no `as` casts, no separate interfaces
|
|
52
41
|
```
|
|
53
42
|
|
|
54
|
-
No separate interface to maintain. No `as` casts. The types flow from the schema.
|
|
55
|
-
|
|
56
43
|
## Features
|
|
57
44
|
|
|
58
45
|
- **Full type inference** — schema definition produces typed output automatically
|
|
59
|
-
- **Multiple sources** — YAML
|
|
46
|
+
- **Multiple sources** — YAML, JSON, TOML files, `.env` files, environment variables, CLI arguments
|
|
60
47
|
- **Priority resolution** — CLI > process.env > `.env` files > Config files > Defaults
|
|
61
48
|
- **`.env` file support** — load environment variables from `.env` files with automatic line tracking
|
|
62
49
|
- **Nested objects and arrays** — deeply nested configs with full type safety
|
|
@@ -67,10 +54,7 @@ No separate interface to maintain. No `as` casts. The types flow from the schema
|
|
|
67
54
|
- **Strict mode** — promote warnings to errors for production safety
|
|
68
55
|
- **Default values** — static or computed (via functions)
|
|
69
56
|
- **Multiple files / directory loading** — load from a list of files or an entire directory
|
|
70
|
-
|
|
71
|
-
## Requirements
|
|
72
|
-
|
|
73
|
-
- Node.js >= 20
|
|
57
|
+
- **File watching** — `watch()` reloads config on file changes with debouncing, change detection, and error recovery
|
|
74
58
|
|
|
75
59
|
## Installation
|
|
76
60
|
|
|
@@ -82,753 +66,28 @@ npm install @meltstudio/config-loader
|
|
|
82
66
|
yarn add @meltstudio/config-loader
|
|
83
67
|
```
|
|
84
68
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
**config.yaml:**
|
|
88
|
-
|
|
89
|
-
```yaml
|
|
90
|
-
version: 1.0.0
|
|
91
|
-
website:
|
|
92
|
-
title: My Website
|
|
93
|
-
description: A simple and elegant website
|
|
94
|
-
isProduction: false
|
|
95
|
-
|
|
96
|
-
database:
|
|
97
|
-
host: localhost
|
|
98
|
-
port: 5432
|
|
99
|
-
credentials:
|
|
100
|
-
username: admin
|
|
101
|
-
password: secret
|
|
102
|
-
|
|
103
|
-
socialMedia: [https://twitter.com/example, https://instagram.com/example]
|
|
104
|
-
|
|
105
|
-
features:
|
|
106
|
-
- name: Store
|
|
107
|
-
enabled: true
|
|
108
|
-
- name: Admin
|
|
109
|
-
enabled: false
|
|
110
|
-
```
|
|
111
|
-
|
|
112
|
-
**index.ts:**
|
|
113
|
-
|
|
114
|
-
```typescript
|
|
115
|
-
import path from "path";
|
|
116
|
-
import c from "@meltstudio/config-loader";
|
|
117
|
-
|
|
118
|
-
const config = c
|
|
119
|
-
.schema({
|
|
120
|
-
version: c.string({ required: true, cli: true }),
|
|
121
|
-
website: c.object({
|
|
122
|
-
item: {
|
|
123
|
-
title: c.string({ required: true }),
|
|
124
|
-
url: c.string({
|
|
125
|
-
required: false,
|
|
126
|
-
defaultValue: "www.mywebsite.dev",
|
|
127
|
-
}),
|
|
128
|
-
description: c.string({ required: true }),
|
|
129
|
-
isProduction: c.bool({ required: true }),
|
|
130
|
-
},
|
|
131
|
-
}),
|
|
132
|
-
database: c.object({
|
|
133
|
-
item: {
|
|
134
|
-
host: c.string({ required: true }),
|
|
135
|
-
port: c.number({ required: true }),
|
|
136
|
-
credentials: c.object({
|
|
137
|
-
item: {
|
|
138
|
-
username: c.string(),
|
|
139
|
-
password: c.string(),
|
|
140
|
-
},
|
|
141
|
-
}),
|
|
142
|
-
},
|
|
143
|
-
}),
|
|
144
|
-
socialMedia: c.array({
|
|
145
|
-
required: true,
|
|
146
|
-
item: c.string({ required: true }),
|
|
147
|
-
}),
|
|
148
|
-
features: c.array({
|
|
149
|
-
required: true,
|
|
150
|
-
item: c.object({
|
|
151
|
-
item: {
|
|
152
|
-
name: c.string(),
|
|
153
|
-
enabled: c.bool(),
|
|
154
|
-
},
|
|
155
|
-
}),
|
|
156
|
-
}),
|
|
157
|
-
})
|
|
158
|
-
.load({
|
|
159
|
-
env: false,
|
|
160
|
-
args: true,
|
|
161
|
-
files: path.join(__dirname, "./config.yaml"),
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
console.log(JSON.stringify(config, null, 2));
|
|
165
|
-
```
|
|
166
|
-
|
|
167
|
-
Output:
|
|
168
|
-
|
|
169
|
-
```json
|
|
170
|
-
{
|
|
171
|
-
"version": "1.0.0",
|
|
172
|
-
"website": {
|
|
173
|
-
"title": "My Website",
|
|
174
|
-
"url": "www.mywebsite.dev",
|
|
175
|
-
"description": "A simple and elegant website",
|
|
176
|
-
"isProduction": false
|
|
177
|
-
},
|
|
178
|
-
"database": {
|
|
179
|
-
"host": "localhost",
|
|
180
|
-
"port": 5432,
|
|
181
|
-
"credentials": {
|
|
182
|
-
"username": "admin",
|
|
183
|
-
"password": "secret"
|
|
184
|
-
}
|
|
185
|
-
},
|
|
186
|
-
"socialMedia": [
|
|
187
|
-
"https://twitter.com/example",
|
|
188
|
-
"https://instagram.com/example"
|
|
189
|
-
],
|
|
190
|
-
"features": [
|
|
191
|
-
{ "name": "Store", "enabled": true },
|
|
192
|
-
{ "name": "Admin", "enabled": false }
|
|
193
|
-
]
|
|
194
|
-
}
|
|
195
|
-
```
|
|
196
|
-
|
|
197
|
-
## Schema API
|
|
198
|
-
|
|
199
|
-
### Primitives
|
|
200
|
-
|
|
201
|
-
```typescript
|
|
202
|
-
c.string({
|
|
203
|
-
required: true,
|
|
204
|
-
env: "MY_VAR",
|
|
205
|
-
cli: true,
|
|
206
|
-
defaultValue: "fallback",
|
|
207
|
-
});
|
|
208
|
-
c.number({ required: true, env: "PORT" });
|
|
209
|
-
c.bool({ env: "DEBUG", defaultValue: false });
|
|
210
|
-
```
|
|
211
|
-
|
|
212
|
-
### Objects
|
|
213
|
-
|
|
214
|
-
Use `c.object()` to declare nested object schemas:
|
|
215
|
-
|
|
216
|
-
```typescript
|
|
217
|
-
c.object({
|
|
218
|
-
item: {
|
|
219
|
-
host: c.string(),
|
|
220
|
-
port: c.number(),
|
|
221
|
-
},
|
|
222
|
-
});
|
|
223
|
-
```
|
|
224
|
-
|
|
225
|
-
Objects can be nested arbitrarily deep:
|
|
226
|
-
|
|
227
|
-
```typescript
|
|
228
|
-
c.schema({
|
|
229
|
-
database: c.object({
|
|
230
|
-
item: {
|
|
231
|
-
host: c.string(),
|
|
232
|
-
port: c.number(),
|
|
233
|
-
credentials: c.object({
|
|
234
|
-
item: {
|
|
235
|
-
username: c.string(),
|
|
236
|
-
password: c.string({ env: "DB_PASSWORD" }),
|
|
237
|
-
},
|
|
238
|
-
}),
|
|
239
|
-
},
|
|
240
|
-
}),
|
|
241
|
-
});
|
|
242
|
-
```
|
|
243
|
-
|
|
244
|
-
`c.object()` accepts a `required` option (defaults to `false`). When the entire subtree is absent from all sources, child `required` options will trigger errors through normal validation.
|
|
245
|
-
|
|
246
|
-
### Arrays
|
|
247
|
-
|
|
248
|
-
```typescript
|
|
249
|
-
c.array({ required: true, item: c.string() }); // string[]
|
|
250
|
-
c.array({ required: true, item: c.number() }); // number[]
|
|
251
|
-
c.array({
|
|
252
|
-
item: c.object({
|
|
253
|
-
item: { name: c.string(), age: c.number() },
|
|
254
|
-
}),
|
|
255
|
-
}); // { name: string; age: number }[]
|
|
256
|
-
```
|
|
257
|
-
|
|
258
|
-
## Enum Constraints (`oneOf`)
|
|
259
|
-
|
|
260
|
-
Use `oneOf` to restrict a field to a fixed set of allowed values. The check runs after type coercion and before any `validate` schema:
|
|
261
|
-
|
|
262
|
-
```typescript
|
|
263
|
-
const config = c
|
|
264
|
-
.schema({
|
|
265
|
-
env: c.string({
|
|
266
|
-
env: "NODE_ENV",
|
|
267
|
-
defaultValue: "development",
|
|
268
|
-
oneOf: ["development", "staging", "production"],
|
|
269
|
-
}),
|
|
270
|
-
logLevel: c.number({
|
|
271
|
-
env: "LOG_LEVEL",
|
|
272
|
-
defaultValue: 1,
|
|
273
|
-
oneOf: [0, 1, 2, 3],
|
|
274
|
-
}),
|
|
275
|
-
})
|
|
276
|
-
.load({ env: true, args: false });
|
|
277
|
-
```
|
|
278
|
-
|
|
279
|
-
If a value is not in the allowed set, a `ConfigLoadError` is thrown with `kind: "validation"`.
|
|
280
|
-
|
|
281
|
-
### Type Narrowing
|
|
282
|
-
|
|
283
|
-
When `oneOf` is provided, the inferred type is automatically narrowed to the union of the allowed values:
|
|
284
|
-
|
|
285
|
-
```typescript
|
|
286
|
-
const config = c
|
|
287
|
-
.schema({
|
|
288
|
-
env: c.string({ oneOf: ["dev", "staging", "prod"] }),
|
|
289
|
-
})
|
|
290
|
-
.load({ env: false, args: false });
|
|
291
|
-
|
|
292
|
-
// config.env is typed as "dev" | "staging" | "prod", not string
|
|
293
|
-
```
|
|
294
|
-
|
|
295
|
-
When used with `cli: true`, the `--help` output automatically lists the allowed values.
|
|
296
|
-
|
|
297
|
-
## Sensitive Fields
|
|
298
|
-
|
|
299
|
-
Mark fields as `sensitive: true` to prevent their values from being exposed in logs or debug output:
|
|
300
|
-
|
|
301
|
-
```typescript
|
|
302
|
-
const schema = {
|
|
303
|
-
host: c.string({ defaultValue: "localhost" }),
|
|
304
|
-
apiKey: c.string({ env: "API_KEY", sensitive: true }),
|
|
305
|
-
db: c.object({
|
|
306
|
-
item: {
|
|
307
|
-
host: c.string({ defaultValue: "db.local" }),
|
|
308
|
-
password: c.string({ env: "DB_PASS", sensitive: true }),
|
|
309
|
-
},
|
|
310
|
-
}),
|
|
311
|
-
};
|
|
312
|
-
|
|
313
|
-
const config = c.schema(schema).load({ env: true, args: false });
|
|
314
|
-
```
|
|
315
|
-
|
|
316
|
-
Sensitive values load normally — `config.apiKey` returns the real value. The flag only affects masking utilities.
|
|
317
|
-
|
|
318
|
-
### `printConfig()` auto-masking
|
|
319
|
-
|
|
320
|
-
`printConfig()` automatically masks sensitive fields:
|
|
321
|
-
|
|
322
|
-
```typescript
|
|
323
|
-
const result = c.schema(schema).loadExtended({ env: true, args: false });
|
|
324
|
-
printConfig(result);
|
|
325
|
-
// apiKey shows "***" instead of the real value
|
|
326
|
-
// db.password shows "***" instead of the real value
|
|
327
|
-
```
|
|
328
|
-
|
|
329
|
-
### `maskSecrets()`
|
|
330
|
-
|
|
331
|
-
Use `maskSecrets()` to create a safe-to-log copy of your config:
|
|
332
|
-
|
|
333
|
-
```typescript
|
|
334
|
-
import c, { maskSecrets } from "@meltstudio/config-loader";
|
|
335
|
-
|
|
336
|
-
// With a plain config from load()
|
|
337
|
-
const config = c.schema(schema).load({ env: true, args: false });
|
|
338
|
-
console.log(maskSecrets(config, schema));
|
|
339
|
-
// { host: "localhost", apiKey: "***", db: { host: "db.local", password: "***" } }
|
|
340
|
-
|
|
341
|
-
// With an extended result from loadExtended()
|
|
342
|
-
const result = c.schema(schema).loadExtended({ env: true, args: false });
|
|
343
|
-
const masked = maskSecrets(result);
|
|
344
|
-
// masked.data contains ConfigNodes with "***" for sensitive values
|
|
345
|
-
```
|
|
346
|
-
|
|
347
|
-
The original config object is never mutated — `maskSecrets()` always returns a new copy.
|
|
348
|
-
|
|
349
|
-
## Validation
|
|
350
|
-
|
|
351
|
-
Add per-field validation using the `validate` option. config-loader accepts any [Standard Schema v1](https://github.com/standard-schema/standard-schema) implementation — including **Zod**, **Valibot**, and **ArkType** — or a custom validator.
|
|
352
|
-
|
|
353
|
-
Validation runs **after** type coercion, so validators see the final typed value (e.g., the number `3000`, not the string `"3000"` from an env var).
|
|
354
|
-
|
|
355
|
-
### With Zod
|
|
356
|
-
|
|
357
|
-
```typescript
|
|
358
|
-
import c from "@meltstudio/config-loader";
|
|
359
|
-
import { z } from "zod";
|
|
360
|
-
|
|
361
|
-
const config = c
|
|
362
|
-
.schema({
|
|
363
|
-
port: c.number({
|
|
364
|
-
required: true,
|
|
365
|
-
env: "PORT",
|
|
366
|
-
validate: z.number().min(1).max(65535),
|
|
367
|
-
}),
|
|
368
|
-
host: c.string({
|
|
369
|
-
required: true,
|
|
370
|
-
validate: z.string().url(),
|
|
371
|
-
}),
|
|
372
|
-
env: c.string({
|
|
373
|
-
defaultValue: "development",
|
|
374
|
-
validate: z.enum(["development", "staging", "production"]),
|
|
375
|
-
}),
|
|
376
|
-
})
|
|
377
|
-
.load({ env: true, args: false, files: "./config.yaml" });
|
|
378
|
-
```
|
|
379
|
-
|
|
380
|
-
### With a custom validator
|
|
381
|
-
|
|
382
|
-
Any object with a `~standard.validate()` method works:
|
|
383
|
-
|
|
384
|
-
```typescript
|
|
385
|
-
const portValidator = {
|
|
386
|
-
"~standard": {
|
|
387
|
-
version: 1,
|
|
388
|
-
vendor: "my-app",
|
|
389
|
-
validate(value: unknown) {
|
|
390
|
-
if (typeof value === "number" && value >= 1 && value <= 65535) {
|
|
391
|
-
return { value };
|
|
392
|
-
}
|
|
393
|
-
return { issues: [{ message: "must be a valid port (1-65535)" }] };
|
|
394
|
-
},
|
|
395
|
-
},
|
|
396
|
-
};
|
|
397
|
-
|
|
398
|
-
c.number({ required: true, env: "PORT", validate: portValidator });
|
|
399
|
-
```
|
|
400
|
-
|
|
401
|
-
Validation errors are collected alongside other config errors and thrown as `ConfigLoadError` with `kind: "validation"`:
|
|
402
|
-
|
|
403
|
-
```typescript
|
|
404
|
-
try {
|
|
405
|
-
const config = c.schema({ ... }).load({ ... });
|
|
406
|
-
} catch (err) {
|
|
407
|
-
if (err instanceof ConfigLoadError) {
|
|
408
|
-
for (const entry of err.errors) {
|
|
409
|
-
if (entry.kind === "validation") {
|
|
410
|
-
console.error(`Validation: ${entry.path} — ${entry.message}`);
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
```
|
|
416
|
-
|
|
417
|
-
## Loading Sources
|
|
418
|
-
|
|
419
|
-
```typescript
|
|
420
|
-
.load({
|
|
421
|
-
env: true, // Read from process.env
|
|
422
|
-
args: true, // Read from CLI arguments (--database.port 3000)
|
|
423
|
-
files: "./config.yaml", // Single YAML file
|
|
424
|
-
files: "./config.json", // Single JSON file
|
|
425
|
-
files: ["./base.yaml", "./overrides.json"], // Mix YAML and JSON (first takes priority)
|
|
426
|
-
dir: "./config.d/", // All files in a directory (sorted)
|
|
427
|
-
envFile: "./.env", // Single .env file
|
|
428
|
-
envFile: ["./.env", "./.env.local"], // Multiple .env files (later overrides earlier)
|
|
429
|
-
defaults: { port: 3000 }, // Programmatic defaults
|
|
430
|
-
})
|
|
431
|
-
```
|
|
432
|
-
|
|
433
|
-
Both YAML (`.yaml`, `.yml`) and JSON (`.json`) files are supported. The format is detected automatically from the file extension.
|
|
434
|
-
|
|
435
|
-
**Priority order:** CLI arguments > `process.env` > `.env` files > Config files > Defaults
|
|
436
|
-
|
|
437
|
-
## Extended Loading (Source Metadata)
|
|
438
|
-
|
|
439
|
-
Use `loadExtended()` instead of `load()` to get each value wrapped in a `ConfigNode` that includes source metadata — where the value came from, which file, environment variable, or CLI argument provided it.
|
|
440
|
-
|
|
441
|
-
```typescript
|
|
442
|
-
import c from "@meltstudio/config-loader";
|
|
443
|
-
|
|
444
|
-
const { data, warnings } = c
|
|
445
|
-
.schema({
|
|
446
|
-
port: c.number({ required: true, env: "PORT" }),
|
|
447
|
-
host: c.string({ defaultValue: "localhost" }),
|
|
448
|
-
})
|
|
449
|
-
.loadExtended({
|
|
450
|
-
env: true,
|
|
451
|
-
args: false,
|
|
452
|
-
files: "./config.yaml",
|
|
453
|
-
});
|
|
454
|
-
|
|
455
|
-
// `warnings` is a string[] of non-fatal issues (e.g. type coercions, unused env mappings)
|
|
456
|
-
if (warnings.length > 0) {
|
|
457
|
-
warnings.forEach((w) => console.warn(w));
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
// Each leaf in `data` is a ConfigNode with:
|
|
461
|
-
// {
|
|
462
|
-
// value: 3000,
|
|
463
|
-
// path: "port",
|
|
464
|
-
// sourceType: "env" | "envFile" | "file" | "args" | "default",
|
|
465
|
-
// file: "./config.yaml" | "./.env" | null,
|
|
466
|
-
// variableName: "PORT" | null,
|
|
467
|
-
// argName: null,
|
|
468
|
-
// line: 5 | null, // source line (1-based) for YAML, JSON, and .env files; null for env/args/default
|
|
469
|
-
// column: 3 | null // source column (1-based) for YAML, JSON, and .env files; null for env/args/default
|
|
470
|
-
// }
|
|
471
|
-
console.log(data.port.value); // 3000
|
|
472
|
-
console.log(data.port.sourceType); // "env"
|
|
473
|
-
console.log(data.port.variableName); // "PORT"
|
|
474
|
-
```
|
|
475
|
-
|
|
476
|
-
This is useful for debugging configuration resolution, building admin UIs that show where each setting originated, or auditing which sources are active.
|
|
477
|
-
|
|
478
|
-
### Debug Helper
|
|
479
|
-
|
|
480
|
-
Use `printConfig()` to format the result of `loadExtended()` as a readable table:
|
|
481
|
-
|
|
482
|
-
```typescript
|
|
483
|
-
import c, { printConfig } from "@meltstudio/config-loader";
|
|
484
|
-
|
|
485
|
-
const result = c
|
|
486
|
-
.schema({
|
|
487
|
-
host: c.string({ defaultValue: "localhost" }),
|
|
488
|
-
port: c.number({ env: "PORT" }),
|
|
489
|
-
debug: c.bool({ cli: true }),
|
|
490
|
-
})
|
|
491
|
-
.loadExtended({ env: true, args: true, files: "./config.yaml" });
|
|
492
|
-
|
|
493
|
-
printConfig(result);
|
|
494
|
-
```
|
|
495
|
-
|
|
496
|
-
Output:
|
|
497
|
-
|
|
498
|
-
```
|
|
499
|
-
┌───────┬───────────┬─────────┬────────────────┐
|
|
500
|
-
│ Path │ Value │ Source │ Detail │
|
|
501
|
-
├───────┼───────────┼─────────┼────────────────┤
|
|
502
|
-
│ host │ localhost │ default │ │
|
|
503
|
-
│ port │ 8080 │ env │ PORT │
|
|
504
|
-
│ debug │ true │ args │ --debug │
|
|
505
|
-
└───────┴───────────┴─────────┴────────────────┘
|
|
506
|
-
```
|
|
507
|
-
|
|
508
|
-
Options:
|
|
69
|
+
Requires Node.js >= 20.
|
|
509
70
|
|
|
510
|
-
|
|
511
|
-
- `printConfig(result, { maxValueLength: 30 })` — truncate long values (default: 50)
|
|
71
|
+
## Documentation
|
|
512
72
|
|
|
513
|
-
|
|
73
|
+
See the **[full documentation](https://meltstudio.github.io/config-loader/)** for:
|
|
514
74
|
|
|
515
|
-
|
|
75
|
+
- [Schema API](https://meltstudio.github.io/config-loader/schema-api) — primitives, objects, arrays, `oneOf`, `sensitive`, validation
|
|
76
|
+
- [Loading & Sources](https://meltstudio.github.io/config-loader/loading-and-sources) — `load()`, `loadExtended()`, `watch()`, file/env/CLI/.env sources, `printConfig()`, `maskSecrets()`, error handling, strict mode
|
|
77
|
+
- [TypeScript Utilities](https://meltstudio.github.io/config-loader/typescript-utilities) — `SchemaValue`, exported types, type narrowing
|
|
516
78
|
|
|
517
|
-
|
|
518
|
-
import c, { ConfigLoadError } from "@meltstudio/config-loader";
|
|
519
|
-
|
|
520
|
-
try {
|
|
521
|
-
const config = c.schema({ port: c.number({ required: true }) }).load({
|
|
522
|
-
env: false,
|
|
523
|
-
args: false,
|
|
524
|
-
files: "./config.yaml",
|
|
525
|
-
});
|
|
526
|
-
} catch (err) {
|
|
527
|
-
if (err instanceof ConfigLoadError) {
|
|
528
|
-
for (const entry of err.errors) {
|
|
529
|
-
console.error(`[${entry.kind}] ${entry.message}`);
|
|
530
|
-
// e.g. [required] Required option 'port' not provided.
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
```
|
|
79
|
+
## Examples
|
|
535
80
|
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
### Strict Mode
|
|
539
|
-
|
|
540
|
-
Enable `strict: true` to promote all warnings to errors, causing `ConfigLoadError` to be thrown for any ambiguous or lossy configuration:
|
|
541
|
-
|
|
542
|
-
```typescript
|
|
543
|
-
.load({
|
|
544
|
-
env: true,
|
|
545
|
-
args: false,
|
|
546
|
-
files: "./config.yaml",
|
|
547
|
-
strict: true,
|
|
548
|
-
})
|
|
549
|
-
```
|
|
81
|
+
The [`example/`](./example) directory contains runnable examples:
|
|
550
82
|
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
## TypeScript Utilities
|
|
554
|
-
|
|
555
|
-
config-loader exports several types for advanced use cases:
|
|
556
|
-
|
|
557
|
-
```typescript
|
|
558
|
-
import c, {
|
|
559
|
-
type SchemaValue, // Infer the resolved config type from a schema
|
|
560
|
-
type SettingsSources, // Type for the sources object passed to load()
|
|
561
|
-
type ExtendedResult, // Return type of loadExtended()
|
|
562
|
-
type NodeTree, // Tree of ConfigNode objects (ExtendedResult.data)
|
|
563
|
-
ConfigNode, // Class representing a resolved value with source metadata
|
|
564
|
-
ConfigNodeArray, // Class representing an array of ConfigNode values
|
|
565
|
-
type RecursivePartial, // Deep partial utility used by the defaults option
|
|
566
|
-
type StandardSchemaV1, // Standard Schema v1 interface for validators
|
|
567
|
-
maskSecrets, // Create a safe-to-log copy with sensitive values masked
|
|
568
|
-
printConfig, // Format loadExtended() result as a readable table
|
|
569
|
-
} from "@meltstudio/config-loader";
|
|
570
|
-
```
|
|
571
|
-
|
|
572
|
-
The most commonly needed is `SchemaValue`, which infers the plain TypeScript type from a schema:
|
|
573
|
-
|
|
574
|
-
```typescript
|
|
575
|
-
const mySchema = {
|
|
576
|
-
port: c.number({ env: "PORT" }),
|
|
577
|
-
db: c.object({ item: { host: c.string(), port: c.number() } }),
|
|
578
|
-
};
|
|
579
|
-
|
|
580
|
-
type MyConfig = SchemaValue<typeof mySchema>;
|
|
581
|
-
// { port: number; db: { host: string; port: number } }
|
|
582
|
-
```
|
|
583
|
-
|
|
584
|
-
## CLI Arguments
|
|
585
|
-
|
|
586
|
-
Set `cli: true` on an option to allow overriding via command line:
|
|
587
|
-
|
|
588
|
-
```typescript
|
|
589
|
-
c.schema({
|
|
590
|
-
version: c.string({ required: true, cli: true }),
|
|
591
|
-
});
|
|
592
|
-
```
|
|
83
|
+
- **[Basic](./example/basic)** — Schema definition, YAML file loading, nested objects and arrays, CLI arguments
|
|
84
|
+
- **[Advanced](./example/advanced)** — TOML config, `.env` files, `oneOf` constraints, `sensitive` fields, validation, `printConfig()`, `maskSecrets()`, error handling
|
|
593
85
|
|
|
594
86
|
```bash
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
## Environment Variables
|
|
599
|
-
|
|
600
|
-
Set `env: "VAR_NAME"` on an option and `env: true` in the load options:
|
|
601
|
-
|
|
602
|
-
```typescript
|
|
603
|
-
c.schema({
|
|
604
|
-
database: c.object({
|
|
605
|
-
item: {
|
|
606
|
-
password: c.string({ env: "DB_PASSWORD" }),
|
|
607
|
-
},
|
|
608
|
-
}),
|
|
609
|
-
}).load({ env: true, args: false, files: "./config.yaml" });
|
|
610
|
-
```
|
|
611
|
-
|
|
612
|
-
## `.env` File Support
|
|
613
|
-
|
|
614
|
-
Load environment variables from `.env` files using the `envFile` option. Options with an `env` mapping automatically pick up values from `.env` files — no new syntax needed on individual fields.
|
|
615
|
-
|
|
616
|
-
**.env:**
|
|
617
|
-
|
|
618
|
-
```bash
|
|
619
|
-
DB_HOST=localhost
|
|
620
|
-
DB_PORT=5432
|
|
621
|
-
DB_PASSWORD="s3cret"
|
|
622
|
-
APP_NAME='My App'
|
|
623
|
-
# This is a comment
|
|
624
|
-
```
|
|
625
|
-
|
|
626
|
-
**Usage:**
|
|
627
|
-
|
|
628
|
-
```typescript
|
|
629
|
-
const config = c
|
|
630
|
-
.schema({
|
|
631
|
-
host: c.string({ env: "DB_HOST" }),
|
|
632
|
-
port: c.number({ env: "DB_PORT" }),
|
|
633
|
-
password: c.string({ env: "DB_PASSWORD" }),
|
|
634
|
-
})
|
|
635
|
-
.load({
|
|
636
|
-
env: true,
|
|
637
|
-
args: false,
|
|
638
|
-
envFile: "./.env",
|
|
639
|
-
});
|
|
640
|
-
```
|
|
641
|
-
|
|
642
|
-
`process.env` always takes precedence over `.env` file values. This means you can use `.env` files for development defaults while overriding them in production via real environment variables.
|
|
643
|
-
|
|
644
|
-
**Multiple `.env` files:**
|
|
645
|
-
|
|
646
|
-
```typescript
|
|
647
|
-
.load({
|
|
648
|
-
env: true,
|
|
649
|
-
args: false,
|
|
650
|
-
envFile: ["./.env", "./.env.local"], // .env.local overrides .env
|
|
651
|
-
})
|
|
652
|
-
```
|
|
653
|
-
|
|
654
|
-
When using multiple files, later files override earlier ones for the same key.
|
|
655
|
-
|
|
656
|
-
The `.env` parser supports:
|
|
657
|
-
|
|
658
|
-
- `KEY=VALUE` pairs (whitespace trimmed)
|
|
659
|
-
- Comments (lines starting with `#`)
|
|
660
|
-
- Quoted values (double `"..."` or single `'...'` quotes stripped)
|
|
661
|
-
- Empty values (`KEY=`)
|
|
662
|
-
|
|
663
|
-
When using `loadExtended()`, values from `.env` files have `sourceType: "envFile"` with `file`, `line`, and `column` metadata pointing to the `.env` file location.
|
|
664
|
-
|
|
665
|
-
## Common Patterns
|
|
666
|
-
|
|
667
|
-
### Load from YAML with env overrides
|
|
668
|
-
|
|
669
|
-
```typescript
|
|
670
|
-
import c from "@meltstudio/config-loader";
|
|
671
|
-
|
|
672
|
-
const config = c
|
|
673
|
-
.schema({
|
|
674
|
-
port: c.number({ required: true, env: "PORT", defaultValue: 3000 }),
|
|
675
|
-
host: c.string({ required: true, env: "HOST", defaultValue: "localhost" }),
|
|
676
|
-
})
|
|
677
|
-
.load({ env: true, args: false, files: "./config.yaml" });
|
|
678
|
-
```
|
|
679
|
-
|
|
680
|
-
### Strict mode for production
|
|
681
|
-
|
|
682
|
-
```typescript
|
|
683
|
-
const config = c
|
|
684
|
-
.schema({
|
|
685
|
-
port: c.number({ required: true, env: "PORT" }),
|
|
686
|
-
dbUrl: c.string({ required: true, env: "DATABASE_URL" }),
|
|
687
|
-
})
|
|
688
|
-
.load({
|
|
689
|
-
env: true,
|
|
690
|
-
args: false,
|
|
691
|
-
files: "./config.yaml",
|
|
692
|
-
strict: true, // any type coercion or ambiguity throws an error
|
|
693
|
-
});
|
|
694
|
-
```
|
|
695
|
-
|
|
696
|
-
### Catch and inspect errors
|
|
697
|
-
|
|
698
|
-
```typescript
|
|
699
|
-
import c, { ConfigLoadError } from "@meltstudio/config-loader";
|
|
700
|
-
|
|
701
|
-
try {
|
|
702
|
-
const config = c
|
|
703
|
-
.schema({ port: c.number({ required: true }) })
|
|
704
|
-
.load({ env: false, args: false, files: "./config.yaml" });
|
|
705
|
-
} catch (err) {
|
|
706
|
-
if (err instanceof ConfigLoadError) {
|
|
707
|
-
for (const entry of err.errors) {
|
|
708
|
-
console.error(`[${entry.kind}] ${entry.path}: ${entry.message}`);
|
|
709
|
-
}
|
|
710
|
-
// err.warnings contains non-fatal issues
|
|
711
|
-
}
|
|
712
|
-
}
|
|
87
|
+
yarn example:basic
|
|
88
|
+
yarn example:advanced
|
|
713
89
|
```
|
|
714
90
|
|
|
715
|
-
### Load from a directory of config files
|
|
716
|
-
|
|
717
|
-
```typescript
|
|
718
|
-
const config = c
|
|
719
|
-
.schema({
|
|
720
|
-
port: c.number({ required: true }),
|
|
721
|
-
host: c.string({ required: true }),
|
|
722
|
-
})
|
|
723
|
-
.load({ env: false, args: false, dir: "./config.d/" });
|
|
724
|
-
// All YAML/JSON files in the directory are loaded and merged (sorted by filename)
|
|
725
|
-
```
|
|
726
|
-
|
|
727
|
-
### Access source metadata with loadExtended
|
|
728
|
-
|
|
729
|
-
```typescript
|
|
730
|
-
const { data, warnings } = c
|
|
731
|
-
.schema({
|
|
732
|
-
port: c.number({ required: true, env: "PORT" }),
|
|
733
|
-
})
|
|
734
|
-
.loadExtended({ env: true, args: false, files: "./config.yaml" });
|
|
735
|
-
|
|
736
|
-
const portNode = data.port; // ConfigNode
|
|
737
|
-
console.log(portNode.value); // 3000
|
|
738
|
-
console.log(portNode.sourceType); // "env" | "file" | "default" | "args" | "envFile"
|
|
739
|
-
console.log(portNode.file); // "./config.yaml" or null
|
|
740
|
-
console.log(portNode.line); // source line number or null
|
|
741
|
-
```
|
|
742
|
-
|
|
743
|
-
### Combine .env files with process.env
|
|
744
|
-
|
|
745
|
-
```typescript
|
|
746
|
-
const config = c
|
|
747
|
-
.schema({
|
|
748
|
-
apiKey: c.string({ required: true, env: "API_KEY" }),
|
|
749
|
-
debug: c.bool({ env: "DEBUG", defaultValue: false }),
|
|
750
|
-
})
|
|
751
|
-
.load({
|
|
752
|
-
env: true, // reads process.env
|
|
753
|
-
args: false,
|
|
754
|
-
envFile: ["./.env", "./.env.local"], // .env.local overrides .env
|
|
755
|
-
});
|
|
756
|
-
// Priority: process.env > .env.local > .env > defaults
|
|
757
|
-
```
|
|
758
|
-
|
|
759
|
-
## Common Mistakes
|
|
760
|
-
|
|
761
|
-
### Forgetting `item` in `c.object()`
|
|
762
|
-
|
|
763
|
-
```typescript
|
|
764
|
-
// WRONG — fields are passed directly
|
|
765
|
-
c.object({ host: c.string(), port: c.number() });
|
|
766
|
-
|
|
767
|
-
// CORRECT — fields must be inside `item`
|
|
768
|
-
c.object({ item: { host: c.string(), port: c.number() } });
|
|
769
|
-
```
|
|
770
|
-
|
|
771
|
-
### Setting `env` on an option but not enabling env loading
|
|
772
|
-
|
|
773
|
-
```typescript
|
|
774
|
-
// WRONG — env: "PORT" is set but env loading is disabled
|
|
775
|
-
c.schema({ port: c.number({ env: "PORT" }) }).load({
|
|
776
|
-
env: false,
|
|
777
|
-
args: false,
|
|
778
|
-
files: "./config.yaml",
|
|
779
|
-
});
|
|
780
|
-
// This emits a warning: "Options [port] have env mappings but env loading is disabled"
|
|
781
|
-
|
|
782
|
-
// CORRECT — set env: true in load options
|
|
783
|
-
c.schema({ port: c.number({ env: "PORT" }) }).load({
|
|
784
|
-
env: true,
|
|
785
|
-
args: false,
|
|
786
|
-
files: "./config.yaml",
|
|
787
|
-
});
|
|
788
|
-
```
|
|
789
|
-
|
|
790
|
-
### Expecting `.env` files to work without `envFile`
|
|
791
|
-
|
|
792
|
-
```typescript
|
|
793
|
-
// WRONG — .env files are not loaded by default
|
|
794
|
-
c.schema({ key: c.string({ env: "API_KEY" }) }).load({
|
|
795
|
-
env: true,
|
|
796
|
-
args: false,
|
|
797
|
-
});
|
|
798
|
-
// This only reads process.env, not .env files
|
|
799
|
-
|
|
800
|
-
// CORRECT — explicitly pass envFile
|
|
801
|
-
c.schema({ key: c.string({ env: "API_KEY" }) }).load({
|
|
802
|
-
env: true,
|
|
803
|
-
args: false,
|
|
804
|
-
envFile: "./.env",
|
|
805
|
-
});
|
|
806
|
-
```
|
|
807
|
-
|
|
808
|
-
### Not catching `ConfigLoadError`
|
|
809
|
-
|
|
810
|
-
```typescript
|
|
811
|
-
// WRONG — unhandled error crashes the process with an unhelpful stack trace
|
|
812
|
-
const config = c
|
|
813
|
-
.schema({ port: c.number({ required: true }) })
|
|
814
|
-
.load({ env: false, args: false });
|
|
815
|
-
|
|
816
|
-
// CORRECT — catch and handle structured errors
|
|
817
|
-
try {
|
|
818
|
-
const config = c
|
|
819
|
-
.schema({ port: c.number({ required: true }) })
|
|
820
|
-
.load({ env: false, args: false });
|
|
821
|
-
} catch (err) {
|
|
822
|
-
if (err instanceof ConfigLoadError) {
|
|
823
|
-
console.error(err.errors); // structured error details
|
|
824
|
-
}
|
|
825
|
-
}
|
|
826
|
-
```
|
|
827
|
-
|
|
828
|
-
### Using `required: true` on nested fields without the parent object
|
|
829
|
-
|
|
830
|
-
If the parent object is entirely absent from all sources, child `required` fields will still trigger errors. Use `required` on the parent `c.object()` only if the entire subtree must be present.
|
|
831
|
-
|
|
832
91
|
## Documentation for AI Agents
|
|
833
92
|
|
|
834
93
|
This project provides machine-readable documentation for AI coding agents at the docs site:
|
|
@@ -840,4 +99,4 @@ These files follow the [llms.txt standard](https://llmstxt.org/) and are generat
|
|
|
840
99
|
|
|
841
100
|
## License
|
|
842
101
|
|
|
843
|
-
|
|
102
|
+
Built by [Melt Studio](https://meltstudio.co). Licensed under the [MIT License](./LICENSE).
|
package/dist/index.d.ts
CHANGED
|
@@ -207,6 +207,40 @@ declare class ObjectOption<T extends Node = Node> extends OptionBase<"object"> {
|
|
|
207
207
|
|
|
208
208
|
type OptionTypes = PrimitiveOption | ArrayOption<OptionTypes> | ObjectOption<Node>;
|
|
209
209
|
|
|
210
|
+
/** A single change detected between two config loads. */
|
|
211
|
+
interface ConfigChange {
|
|
212
|
+
/** Dot-separated path to the changed key (e.g. "db.url"). */
|
|
213
|
+
path: string;
|
|
214
|
+
/** The previous value (undefined if key was added). */
|
|
215
|
+
oldValue: unknown;
|
|
216
|
+
/** The new value (undefined if key was removed). */
|
|
217
|
+
newValue: unknown;
|
|
218
|
+
/** Whether the change was an addition, removal, or modification. */
|
|
219
|
+
type: "added" | "removed" | "changed";
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Compares two plain config objects and returns a list of changes.
|
|
223
|
+
* Sensitive fields (from the schema) are masked in the output.
|
|
224
|
+
*/
|
|
225
|
+
declare function diffConfig(oldConfig: Record<string, unknown>, newConfig: Record<string, unknown>, schema?: Node): ConfigChange[];
|
|
226
|
+
|
|
227
|
+
/** Options for the `watch()` method. */
|
|
228
|
+
interface WatchOptions<T> {
|
|
229
|
+
/** Called after a successful reload with the new config, old config, and list of changes. */
|
|
230
|
+
onChange: (newConfig: T, oldConfig: T, changes: ConfigChange[]) => void;
|
|
231
|
+
/** Called when a reload fails (parse error, validation error). The previous config is retained. */
|
|
232
|
+
onError?: (error: Error) => void;
|
|
233
|
+
/** Debounce interval in milliseconds. Default: 100. */
|
|
234
|
+
debounce?: number;
|
|
235
|
+
}
|
|
236
|
+
/** Handle returned by `watch()`. Provides access to the current config and a `close()` method. */
|
|
237
|
+
interface ConfigWatcher<T> {
|
|
238
|
+
/** The current resolved configuration. Updated on each successful reload. */
|
|
239
|
+
readonly config: T;
|
|
240
|
+
/** Stop watching all files. Idempotent. */
|
|
241
|
+
close(): void;
|
|
242
|
+
}
|
|
243
|
+
|
|
210
244
|
/** Fluent builder that takes a schema and resolves configuration from multiple sources. */
|
|
211
245
|
declare class SettingsBuilder<T extends Node> {
|
|
212
246
|
private readonly schema;
|
|
@@ -225,6 +259,15 @@ declare class SettingsBuilder<T extends Node> {
|
|
|
225
259
|
* @throws {ConfigLoadError} If validation fails.
|
|
226
260
|
*/
|
|
227
261
|
loadExtended(sources: SettingsSources<SchemaValue<T>>): ExtendedResult;
|
|
262
|
+
/**
|
|
263
|
+
* Watches config files for changes and reloads automatically.
|
|
264
|
+
* File watchers are `.unref()`'d so they don't prevent the process from exiting.
|
|
265
|
+
* @param sources - Which sources to read (env, args, files, etc.).
|
|
266
|
+
* @param options - Callbacks and debounce configuration.
|
|
267
|
+
* @returns A `ConfigWatcher` with the current config and a `close()` method.
|
|
268
|
+
* @throws {ConfigLoadError} If the initial load fails.
|
|
269
|
+
*/
|
|
270
|
+
watch(sources: SettingsSources<SchemaValue<T>>, options: WatchOptions<SchemaValue<T>>): ConfigWatcher<SchemaValue<T>>;
|
|
228
271
|
}
|
|
229
272
|
|
|
230
273
|
/**
|
|
@@ -363,4 +406,4 @@ declare const option: {
|
|
|
363
406
|
schema: <T extends Node>(theSchema: T) => SettingsBuilder<T>;
|
|
364
407
|
};
|
|
365
408
|
|
|
366
|
-
export { type ConfigErrorEntry, ConfigFileError, ConfigLoadError, ConfigNode, ConfigNodeArray, type ExtendedResult, type NodeTree, type RecursivePartial, type SchemaValue, type SettingsSources, type StandardSchemaV1, option as default, maskSecrets, printConfig };
|
|
409
|
+
export { type ConfigChange, type ConfigErrorEntry, ConfigFileError, ConfigLoadError, ConfigNode, ConfigNodeArray, type ConfigWatcher, type ExtendedResult, type NodeTree, type RecursivePartial, type SchemaValue, type SettingsSources, type StandardSchemaV1, type WatchOptions, option as default, diffConfig, maskSecrets, printConfig };
|
package/dist/index.js
CHANGED
|
@@ -35,6 +35,7 @@ __export(index_exports, {
|
|
|
35
35
|
ConfigNode: () => configNode_default,
|
|
36
36
|
ConfigNodeArray: () => configNodeArray_default,
|
|
37
37
|
default: () => index_default,
|
|
38
|
+
diffConfig: () => diffConfig,
|
|
38
39
|
maskSecrets: () => maskSecrets,
|
|
39
40
|
printConfig: () => printConfig
|
|
40
41
|
});
|
|
@@ -126,6 +127,7 @@ var fs = __toESM(require("fs"));
|
|
|
126
127
|
var import_js_yaml = __toESM(require("js-yaml"));
|
|
127
128
|
var import_js_yaml_source_map = __toESM(require("js-yaml-source-map"));
|
|
128
129
|
var path = __toESM(require("path"));
|
|
130
|
+
var import_smol_toml = require("smol-toml");
|
|
129
131
|
var fileCache = /* @__PURE__ */ new Map();
|
|
130
132
|
var JsonSourceMap = class {
|
|
131
133
|
locations = /* @__PURE__ */ new Map();
|
|
@@ -188,6 +190,47 @@ var JsonSourceMap = class {
|
|
|
188
190
|
return this.locations.get(key);
|
|
189
191
|
}
|
|
190
192
|
};
|
|
193
|
+
function escapeRegex(str) {
|
|
194
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
195
|
+
}
|
|
196
|
+
function walkTomlObject(obj, prefix, lines, locations) {
|
|
197
|
+
for (const key of Object.keys(obj)) {
|
|
198
|
+
const fullPath = [...prefix, key].join(".");
|
|
199
|
+
for (let i = 0; i < lines.length; i++) {
|
|
200
|
+
const line = lines[i];
|
|
201
|
+
const keyPattern = new RegExp(`^\\s*${escapeRegex(key)}\\s*=`);
|
|
202
|
+
if (keyPattern.test(line)) {
|
|
203
|
+
const idx = line.indexOf(key);
|
|
204
|
+
locations.set(fullPath, {
|
|
205
|
+
line: i + 1,
|
|
206
|
+
column: idx + 1,
|
|
207
|
+
position: 0
|
|
208
|
+
});
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const val = obj[key];
|
|
213
|
+
if (val && typeof val === "object" && !Array.isArray(val)) {
|
|
214
|
+
walkTomlObject(
|
|
215
|
+
val,
|
|
216
|
+
[...prefix, key],
|
|
217
|
+
lines,
|
|
218
|
+
locations
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
function buildTomlSourceMap(content, data) {
|
|
224
|
+
const locations = /* @__PURE__ */ new Map();
|
|
225
|
+
const lines = content.split("\n");
|
|
226
|
+
walkTomlObject(data, [], lines, locations);
|
|
227
|
+
return {
|
|
228
|
+
lookup(lookupPath) {
|
|
229
|
+
const key = Array.isArray(lookupPath) ? lookupPath.join(".") : lookupPath;
|
|
230
|
+
return locations.get(key);
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
}
|
|
191
234
|
function loadConfigFile(filePath) {
|
|
192
235
|
const cached = fileCache.get(filePath);
|
|
193
236
|
if (cached) return cached;
|
|
@@ -202,6 +245,15 @@ function loadConfigFile(filePath) {
|
|
|
202
245
|
fileCache.set(filePath, result2);
|
|
203
246
|
return result2;
|
|
204
247
|
}
|
|
248
|
+
if (ext === ".toml") {
|
|
249
|
+
const data2 = (0, import_smol_toml.parse)(content);
|
|
250
|
+
const result2 = {
|
|
251
|
+
data: data2,
|
|
252
|
+
sourceMap: buildTomlSourceMap(content, data2)
|
|
253
|
+
};
|
|
254
|
+
fileCache.set(filePath, result2);
|
|
255
|
+
return result2;
|
|
256
|
+
}
|
|
205
257
|
const sourceMap = new import_js_yaml_source_map.default();
|
|
206
258
|
const data = import_js_yaml.default.load(content, { listener: sourceMap.listen() });
|
|
207
259
|
const result = { data, sourceMap };
|
|
@@ -1102,6 +1154,176 @@ var Settings = class {
|
|
|
1102
1154
|
};
|
|
1103
1155
|
var settings_default = Settings;
|
|
1104
1156
|
|
|
1157
|
+
// src/watcher.ts
|
|
1158
|
+
var fs4 = __toESM(require("fs"));
|
|
1159
|
+
|
|
1160
|
+
// src/diffConfig.ts
|
|
1161
|
+
var MASK = "***";
|
|
1162
|
+
function collectSensitivePaths(schema2, prefix = "") {
|
|
1163
|
+
const paths = /* @__PURE__ */ new Set();
|
|
1164
|
+
for (const key of Object.keys(schema2)) {
|
|
1165
|
+
const opt = schema2[key];
|
|
1166
|
+
const fullPath = prefix ? `${prefix}.${key}` : key;
|
|
1167
|
+
if (opt instanceof PrimitiveOption && opt.params.sensitive) {
|
|
1168
|
+
paths.add(fullPath);
|
|
1169
|
+
}
|
|
1170
|
+
if (opt instanceof ObjectOption) {
|
|
1171
|
+
const childPaths = collectSensitivePaths(opt.item, fullPath);
|
|
1172
|
+
for (const p of childPaths) {
|
|
1173
|
+
paths.add(p);
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
return paths;
|
|
1178
|
+
}
|
|
1179
|
+
function diffObjects(oldObj, newObj, sensitivePaths, prefix = "") {
|
|
1180
|
+
const changes = [];
|
|
1181
|
+
const allKeys = /* @__PURE__ */ new Set([...Object.keys(oldObj), ...Object.keys(newObj)]);
|
|
1182
|
+
for (const key of allKeys) {
|
|
1183
|
+
const fullPath = prefix ? `${prefix}.${key}` : key;
|
|
1184
|
+
const isSensitive = sensitivePaths.has(fullPath);
|
|
1185
|
+
const oldVal = oldObj[key];
|
|
1186
|
+
const newVal = newObj[key];
|
|
1187
|
+
const hasOld = key in oldObj;
|
|
1188
|
+
const hasNew = key in newObj;
|
|
1189
|
+
if (hasNew && !hasOld) {
|
|
1190
|
+
changes.push({
|
|
1191
|
+
path: fullPath,
|
|
1192
|
+
type: "added",
|
|
1193
|
+
oldValue: void 0,
|
|
1194
|
+
newValue: isSensitive ? MASK : newVal
|
|
1195
|
+
});
|
|
1196
|
+
} else if (hasOld && !hasNew) {
|
|
1197
|
+
changes.push({
|
|
1198
|
+
path: fullPath,
|
|
1199
|
+
type: "removed",
|
|
1200
|
+
oldValue: isSensitive ? MASK : oldVal,
|
|
1201
|
+
newValue: void 0
|
|
1202
|
+
});
|
|
1203
|
+
} else if (oldVal !== null && newVal !== null && typeof oldVal === "object" && typeof newVal === "object" && !Array.isArray(oldVal) && !Array.isArray(newVal)) {
|
|
1204
|
+
changes.push(
|
|
1205
|
+
...diffObjects(
|
|
1206
|
+
oldVal,
|
|
1207
|
+
newVal,
|
|
1208
|
+
sensitivePaths,
|
|
1209
|
+
fullPath
|
|
1210
|
+
)
|
|
1211
|
+
);
|
|
1212
|
+
} else if (!Object.is(oldVal, newVal)) {
|
|
1213
|
+
if (Array.isArray(oldVal) && Array.isArray(newVal) && JSON.stringify(oldVal) === JSON.stringify(newVal)) {
|
|
1214
|
+
continue;
|
|
1215
|
+
}
|
|
1216
|
+
changes.push({
|
|
1217
|
+
path: fullPath,
|
|
1218
|
+
type: "changed",
|
|
1219
|
+
oldValue: isSensitive ? MASK : oldVal,
|
|
1220
|
+
newValue: isSensitive ? MASK : newVal
|
|
1221
|
+
});
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
return changes;
|
|
1225
|
+
}
|
|
1226
|
+
function diffConfig(oldConfig, newConfig, schema2) {
|
|
1227
|
+
const sensitivePaths = schema2 ? collectSensitivePaths(schema2) : /* @__PURE__ */ new Set();
|
|
1228
|
+
return diffObjects(oldConfig, newConfig, sensitivePaths);
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
// src/watcher.ts
|
|
1232
|
+
function resolveFilePaths(sources) {
|
|
1233
|
+
const paths = [];
|
|
1234
|
+
const configFiles = validateFiles(
|
|
1235
|
+
sources.files ?? false,
|
|
1236
|
+
sources.dir ?? false
|
|
1237
|
+
);
|
|
1238
|
+
if (Array.isArray(configFiles)) {
|
|
1239
|
+
paths.push(...configFiles);
|
|
1240
|
+
} else if (configFiles) {
|
|
1241
|
+
paths.push(configFiles);
|
|
1242
|
+
}
|
|
1243
|
+
if (sources.envFile) {
|
|
1244
|
+
const envFiles = Array.isArray(sources.envFile) ? sources.envFile : [sources.envFile];
|
|
1245
|
+
paths.push(...envFiles);
|
|
1246
|
+
}
|
|
1247
|
+
return paths;
|
|
1248
|
+
}
|
|
1249
|
+
function createWatcher(schema2, sources, options) {
|
|
1250
|
+
const debounceMs = options.debounce ?? 100;
|
|
1251
|
+
const initialSettings = new settings_default(schema2, sources);
|
|
1252
|
+
let currentConfig = initialSettings.get();
|
|
1253
|
+
const filePaths = resolveFilePaths(sources);
|
|
1254
|
+
const watchers = [];
|
|
1255
|
+
let debounceTimer = null;
|
|
1256
|
+
let closed = false;
|
|
1257
|
+
function reload() {
|
|
1258
|
+
if (closed) return;
|
|
1259
|
+
try {
|
|
1260
|
+
clearFileCache();
|
|
1261
|
+
const settings = new settings_default(schema2, sources);
|
|
1262
|
+
const newConfig = settings.get();
|
|
1263
|
+
const changes = diffConfig(
|
|
1264
|
+
currentConfig,
|
|
1265
|
+
newConfig,
|
|
1266
|
+
schema2
|
|
1267
|
+
);
|
|
1268
|
+
if (changes.length > 0) {
|
|
1269
|
+
const oldConfig = currentConfig;
|
|
1270
|
+
currentConfig = newConfig;
|
|
1271
|
+
options.onChange(newConfig, oldConfig, changes);
|
|
1272
|
+
}
|
|
1273
|
+
} catch (err) {
|
|
1274
|
+
if (options.onError) {
|
|
1275
|
+
options.onError(err instanceof Error ? err : new Error(String(err)));
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
function scheduleReload() {
|
|
1280
|
+
if (closed) return;
|
|
1281
|
+
if (debounceTimer) {
|
|
1282
|
+
clearTimeout(debounceTimer);
|
|
1283
|
+
}
|
|
1284
|
+
debounceTimer = setTimeout(reload, debounceMs);
|
|
1285
|
+
debounceTimer.unref();
|
|
1286
|
+
}
|
|
1287
|
+
for (const filePath of filePaths) {
|
|
1288
|
+
try {
|
|
1289
|
+
const watcher = fs4.watch(filePath, () => {
|
|
1290
|
+
scheduleReload();
|
|
1291
|
+
});
|
|
1292
|
+
watcher.unref();
|
|
1293
|
+
watchers.push(watcher);
|
|
1294
|
+
} catch {
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
if (sources.dir && typeof sources.dir === "string") {
|
|
1298
|
+
try {
|
|
1299
|
+
const dirWatcher = fs4.watch(sources.dir, () => {
|
|
1300
|
+
scheduleReload();
|
|
1301
|
+
});
|
|
1302
|
+
dirWatcher.unref();
|
|
1303
|
+
watchers.push(dirWatcher);
|
|
1304
|
+
} catch {
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
function close() {
|
|
1308
|
+
if (closed) return;
|
|
1309
|
+
closed = true;
|
|
1310
|
+
if (debounceTimer) {
|
|
1311
|
+
clearTimeout(debounceTimer);
|
|
1312
|
+
debounceTimer = null;
|
|
1313
|
+
}
|
|
1314
|
+
for (const watcher of watchers) {
|
|
1315
|
+
watcher.close();
|
|
1316
|
+
}
|
|
1317
|
+
watchers.length = 0;
|
|
1318
|
+
}
|
|
1319
|
+
return {
|
|
1320
|
+
get config() {
|
|
1321
|
+
return currentConfig;
|
|
1322
|
+
},
|
|
1323
|
+
close
|
|
1324
|
+
};
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1105
1327
|
// src/builder/settings.ts
|
|
1106
1328
|
var SettingsBuilder = class {
|
|
1107
1329
|
schema;
|
|
@@ -1131,17 +1353,28 @@ var SettingsBuilder = class {
|
|
|
1131
1353
|
warnings: settings.getWarnings()
|
|
1132
1354
|
};
|
|
1133
1355
|
}
|
|
1356
|
+
/**
|
|
1357
|
+
* Watches config files for changes and reloads automatically.
|
|
1358
|
+
* File watchers are `.unref()`'d so they don't prevent the process from exiting.
|
|
1359
|
+
* @param sources - Which sources to read (env, args, files, etc.).
|
|
1360
|
+
* @param options - Callbacks and debounce configuration.
|
|
1361
|
+
* @returns A `ConfigWatcher` with the current config and a `close()` method.
|
|
1362
|
+
* @throws {ConfigLoadError} If the initial load fails.
|
|
1363
|
+
*/
|
|
1364
|
+
watch(sources, options) {
|
|
1365
|
+
return createWatcher(this.schema, sources, options);
|
|
1366
|
+
}
|
|
1134
1367
|
};
|
|
1135
1368
|
|
|
1136
1369
|
// src/maskSecrets.ts
|
|
1137
|
-
var
|
|
1370
|
+
var MASK2 = "***";
|
|
1138
1371
|
function maskNodeTree(tree) {
|
|
1139
1372
|
const result = {};
|
|
1140
1373
|
for (const [key, entry] of Object.entries(tree)) {
|
|
1141
1374
|
if (entry instanceof configNode_default) {
|
|
1142
1375
|
if (entry.sensitive) {
|
|
1143
1376
|
const masked = new configNode_default(
|
|
1144
|
-
|
|
1377
|
+
MASK2,
|
|
1145
1378
|
entry.path,
|
|
1146
1379
|
entry.sourceType,
|
|
1147
1380
|
entry.file,
|
|
@@ -1182,7 +1415,7 @@ function maskPlainObject(obj, schema2) {
|
|
|
1182
1415
|
result[key] = value;
|
|
1183
1416
|
}
|
|
1184
1417
|
} else if (schemaNode instanceof OptionBase) {
|
|
1185
|
-
result[key] = schemaNode.params.sensitive ?
|
|
1418
|
+
result[key] = schemaNode.params.sensitive ? MASK2 : value;
|
|
1186
1419
|
} else {
|
|
1187
1420
|
result[key] = value;
|
|
1188
1421
|
}
|
|
@@ -1210,7 +1443,7 @@ function truncate(str, max) {
|
|
|
1210
1443
|
}
|
|
1211
1444
|
function formatValue(val) {
|
|
1212
1445
|
if (val === null || val === void 0) return "";
|
|
1213
|
-
if (typeof val === "object") return JSON.stringify(val)
|
|
1446
|
+
if (typeof val === "object") return JSON.stringify(val);
|
|
1214
1447
|
if (typeof val === "string") return val;
|
|
1215
1448
|
return `${val}`;
|
|
1216
1449
|
}
|
|
@@ -1382,6 +1615,7 @@ var index_default = option;
|
|
|
1382
1615
|
ConfigLoadError,
|
|
1383
1616
|
ConfigNode,
|
|
1384
1617
|
ConfigNodeArray,
|
|
1618
|
+
diffConfig,
|
|
1385
1619
|
maskSecrets,
|
|
1386
1620
|
printConfig
|
|
1387
1621
|
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meltstudio/config-loader",
|
|
3
|
-
"version": "3.
|
|
4
|
-
"description": "Type-safe configuration loader with full TypeScript inference. Load from YAML, JSON, .env, environment variables, and CLI args.",
|
|
3
|
+
"version": "3.7.0",
|
|
4
|
+
"description": "Type-safe configuration loader with full TypeScript inference. Load from YAML, JSON, TOML, .env, environment variables, and CLI args.",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
7
7
|
"repository": "https://github.com/MeltStudio/config-loader",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"config",
|
|
16
16
|
"yaml",
|
|
17
17
|
"json",
|
|
18
|
+
"toml",
|
|
18
19
|
"loader",
|
|
19
20
|
"env",
|
|
20
21
|
"environment",
|
|
@@ -38,7 +39,8 @@
|
|
|
38
39
|
"type-check": "tsc --noEmit",
|
|
39
40
|
"test": "jest --verbose",
|
|
40
41
|
"replace-tspaths": "./scripts/replace-tspaths/index.mjs",
|
|
41
|
-
"example:
|
|
42
|
+
"example:basic": "ts-node -r tsconfig-paths/register ./example/basic/index.ts",
|
|
43
|
+
"example:advanced": "ts-node -r tsconfig-paths/register ./example/advanced/index.ts",
|
|
42
44
|
"prepare": "husky",
|
|
43
45
|
"docs:build": "docusaurus build && node scripts/fix-llms-urls.mjs",
|
|
44
46
|
"docs:serve": "docusaurus serve",
|
|
@@ -48,6 +50,7 @@
|
|
|
48
50
|
"commander": "^14.0.0",
|
|
49
51
|
"js-yaml": "^4.1.0",
|
|
50
52
|
"js-yaml-source-map": "^0.2.2",
|
|
53
|
+
"smol-toml": "^1.6.0",
|
|
51
54
|
"tslib": "^2.3.0"
|
|
52
55
|
},
|
|
53
56
|
"devDependencies": {
|