@meltstudio/config-loader 3.3.0 → 3.5.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 CHANGED
@@ -61,6 +61,8 @@ No separate interface to maintain. No `as` casts. The types flow from the schema
61
61
  - **`.env` file support** — load environment variables from `.env` files with automatic line tracking
62
62
  - **Nested objects and arrays** — deeply nested configs with full type safety
63
63
  - **Structured errors** — typed `ConfigLoadError` with per-field error details and warnings
64
+ - **Enum constraints** — restrict values to a fixed set with `oneOf`, with full type narrowing
65
+ - **Sensitive fields** — mark fields with `sensitive: true` to auto-mask in `printConfig()` and `maskSecrets()`
64
66
  - **Schema validation** — optional per-field validation via [Standard Schema](https://github.com/standard-schema/standard-schema) (Zod, Valibot, ArkType, or custom)
65
67
  - **Strict mode** — promote warnings to errors for production safety
66
68
  - **Default values** — static or computed (via functions)
@@ -253,6 +255,97 @@ c.array({
253
255
  }); // { name: string; age: number }[]
254
256
  ```
255
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
+
256
349
  ## Validation
257
350
 
258
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.
@@ -471,6 +564,8 @@ import c, {
471
564
  ConfigNodeArray, // Class representing an array of ConfigNode values
472
565
  type RecursivePartial, // Deep partial utility used by the defaults option
473
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
474
569
  } from "@meltstudio/config-loader";
475
570
  ```
476
571
 
package/dist/index.d.ts CHANGED
@@ -60,7 +60,9 @@ interface OptionClassParams<T extends OptionKind> {
60
60
  env: string | null;
61
61
  cli: boolean;
62
62
  help: string;
63
+ sensitive?: boolean;
63
64
  defaultValue?: TypedDefaultValue<T>;
65
+ oneOf?: ReadonlyArray<string | number | boolean>;
64
66
  validate?: StandardSchemaV1;
65
67
  }
66
68
  declare class OptionBase<T extends OptionKind = OptionKind> {
@@ -81,6 +83,7 @@ declare class OptionBase<T extends OptionKind = OptionKind> {
81
83
  } | null;
82
84
  }, envFileResults?: EnvFileResult[], errors?: OptionErrors): ConfigNode | null;
83
85
  private resolveValue;
86
+ private runOneOfCheck;
84
87
  private runValidation;
85
88
  private resolveFromFileData;
86
89
  checkType(val: Value, path: Path, sourceOfVal: string, errors?: OptionErrors): Value;
@@ -98,10 +101,11 @@ declare class ConfigNode {
98
101
  argName: string | null;
99
102
  line: number | null;
100
103
  column: number | null;
101
- constructor(value: Value | ArrayValue, path: string, sourceType: SourceTypes, file: string | null, variableName: string | null, argName: string | null, line?: number | null, column?: number | null);
104
+ sensitive: boolean;
105
+ constructor(value: Value | ArrayValue, path: string, sourceType: SourceTypes, file: string | null, variableName: string | null, argName: string | null, line?: number | null, column?: number | null, sensitive?: boolean);
102
106
  }
103
107
 
104
- declare class PrimitiveOption<T extends PrimitiveKind = PrimitiveKind> extends OptionBase<T> {
108
+ declare class PrimitiveOption<T extends PrimitiveKind = PrimitiveKind, Narrowed = TypeOfPrimitiveKind<T>> extends OptionBase<T> {
105
109
  }
106
110
 
107
111
  type NodeTree = {
@@ -140,7 +144,7 @@ type TypeOfPrimitiveKind<T extends PrimitiveKind> = T extends "boolean" ? boolea
140
144
  /** Recursively infers the plain TypeScript type from a schema definition. Maps option nodes to their resolved value types. */
141
145
  type SchemaValue<T extends OptionBase | Node> = T extends OptionBase ? T extends ArrayOption<OptionTypes> ? SchemaValue<T["item"]>[] : T extends ObjectOption<infer R> ? {
142
146
  [K in keyof R]: SchemaValue<R[K]>;
143
- } : T extends PrimitiveOption<infer R> ? TypeOfPrimitiveKind<R> : never : T extends Node ? {
147
+ } : T extends PrimitiveOption<infer _R, infer Narrowed> ? Narrowed : never : T extends Node ? {
144
148
  [K in keyof T]: SchemaValue<T[K]>;
145
149
  } : never;
146
150
  type Path = Array<string | number>;
@@ -223,6 +227,24 @@ declare class SettingsBuilder<T extends Node> {
223
227
  loadExtended(sources: SettingsSources<SchemaValue<T>>): ExtendedResult;
224
228
  }
225
229
 
230
+ /**
231
+ * Masks sensitive values in an `ExtendedResult` from `loadExtended()`.
232
+ * Fields marked `sensitive: true` have their values replaced with `"***"`.
233
+ *
234
+ * @param result - The `ExtendedResult` returned by `loadExtended()`.
235
+ * @returns A new `ExtendedResult` with sensitive values masked.
236
+ */
237
+ declare function maskSecrets(result: ExtendedResult): ExtendedResult;
238
+ /**
239
+ * Masks sensitive values in a plain config object from `load()`.
240
+ * Fields marked `sensitive: true` in the schema have their values replaced with `"***"`.
241
+ *
242
+ * @param config - The plain config object returned by `load()`.
243
+ * @param schema - The schema definition used to identify sensitive fields.
244
+ * @returns A new object with sensitive values masked.
245
+ */
246
+ declare function maskSecrets<T extends Record<string, unknown>>(config: T, schema: Node): T;
247
+
226
248
  declare class ConfigNodeArray {
227
249
  arrayValues: ConfigNode[];
228
250
  constructor(arrayValues: ConfigNode[]);
@@ -260,6 +282,10 @@ interface OptionPropsArgs<T> {
260
282
  defaultValue?: T | (() => T);
261
283
  /** Help text shown in CLI `--help` output. */
262
284
  help?: string;
285
+ /** Mark this field as sensitive. Sensitive values are masked by `printConfig()` and `maskSecrets()`. */
286
+ sensitive?: boolean;
287
+ /** Restrict the value to a fixed set of allowed values. Checked after type coercion, before `validate`. */
288
+ oneOf?: readonly T[];
263
289
  /** Standard Schema validator run after type coercion. Accepts Zod, Valibot, ArkType, or any Standard Schema v1 implementation. */
264
290
  validate?: StandardSchemaV1;
265
291
  }
@@ -283,6 +309,38 @@ interface ObjectOptionPropsArgs<T extends Node> {
283
309
  /** Standard Schema validator run on the resolved object. Accepts Zod, Valibot, ArkType, or any Standard Schema v1 implementation. */
284
310
  validate?: StandardSchemaV1;
285
311
  }
312
+ /**
313
+ * Creates a string configuration option.
314
+ * @param opts - Option configuration (env, cli, required, defaultValue, help, oneOf).
315
+ * @returns A `PrimitiveOption<"string">` for use in a schema.
316
+ * @example
317
+ * c.string({ env: "HOST", defaultValue: "localhost" })
318
+ * c.string({ env: "NODE_ENV", oneOf: ["development", "staging", "production"] })
319
+ */
320
+ declare function string<const V extends readonly string[]>(opts: OptionPropsArgs<string> & {
321
+ oneOf: V;
322
+ }): PrimitiveOption<"string", V[number]>;
323
+ declare function string(opts?: OptionPropsArgs<string>): PrimitiveOption<"string">;
324
+ /**
325
+ * Creates a number configuration option. String values from env/CLI are coerced to numbers.
326
+ * @param opts - Option configuration (env, cli, required, defaultValue, help, oneOf).
327
+ * @returns A `PrimitiveOption<"number">` for use in a schema.
328
+ * @example
329
+ * c.number({ env: "PORT", defaultValue: 3000 })
330
+ * c.number({ env: "LOG_LEVEL", oneOf: [0, 1, 2, 3] })
331
+ */
332
+ declare function number<const V extends readonly number[]>(opts: OptionPropsArgs<number> & {
333
+ oneOf: V;
334
+ }): PrimitiveOption<"number", V[number]>;
335
+ declare function number(opts?: OptionPropsArgs<number>): PrimitiveOption<"number">;
336
+ /**
337
+ * Creates a boolean configuration option. String values `"true"`/`"false"` are coerced.
338
+ * @param opts - Option configuration (env, cli, required, defaultValue, help).
339
+ * @returns A `PrimitiveOption<"boolean">` for use in a schema.
340
+ * @example
341
+ * c.bool({ env: "DEBUG", defaultValue: false })
342
+ */
343
+ declare function bool(opts?: OptionPropsArgs<boolean>): PrimitiveOption<"boolean">;
286
344
  /**
287
345
  * Config-loader entry point. Provides factory functions to define a typed configuration schema.
288
346
  *
@@ -297,12 +355,12 @@ interface ObjectOptionPropsArgs<T extends Node> {
297
355
  * ```
298
356
  */
299
357
  declare const option: {
300
- string: (opts?: OptionPropsArgs<string>) => PrimitiveOption<"string">;
301
- number: (opts?: OptionPropsArgs<number>) => PrimitiveOption<"number">;
302
- bool: (opts?: OptionPropsArgs<boolean>) => PrimitiveOption<"boolean">;
358
+ string: typeof string;
359
+ number: typeof number;
360
+ bool: typeof bool;
303
361
  array: <T extends OptionTypes>(opts: ArrayOptionPropsArgs<T>) => ArrayOption<T>;
304
362
  object: <T extends Node>(opts: ObjectOptionPropsArgs<T>) => ObjectOption<T>;
305
363
  schema: <T extends Node>(theSchema: T) => SettingsBuilder<T>;
306
364
  };
307
365
 
308
- export { type ConfigErrorEntry, ConfigFileError, ConfigLoadError, ConfigNode, ConfigNodeArray, type ExtendedResult, type NodeTree, type RecursivePartial, type SchemaValue, type SettingsSources, type StandardSchemaV1, option as default, printConfig };
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 };
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
+ maskSecrets: () => maskSecrets,
38
39
  printConfig: () => printConfig
39
40
  });
40
41
  module.exports = __toCommonJS(index_exports);
@@ -81,7 +82,8 @@ var ConfigNode = class {
81
82
  argName;
82
83
  line;
83
84
  column;
84
- constructor(value, path2, sourceType, file, variableName, argName, line = null, column = null) {
85
+ sensitive;
86
+ constructor(value, path2, sourceType, file, variableName, argName, line = null, column = null, sensitive = false) {
85
87
  this.value = value;
86
88
  this.path = path2;
87
89
  this.sourceType = sourceType;
@@ -90,6 +92,7 @@ var ConfigNode = class {
90
92
  this.argName = argName;
91
93
  this.line = line;
92
94
  this.column = column;
95
+ this.sensitive = sensitive;
93
96
  }
94
97
  };
95
98
  var configNode_default = ConfigNode;
@@ -292,6 +295,13 @@ var OptionBase = class {
292
295
  envFileResults,
293
296
  errors
294
297
  );
298
+ if (resolved && this.params.sensitive) {
299
+ resolved.sensitive = true;
300
+ }
301
+ if (resolved && this.params.oneOf) {
302
+ const passed = this.runOneOfCheck(resolved, path2, errors);
303
+ if (!passed) return resolved;
304
+ }
295
305
  if (resolved && this.params.validate) {
296
306
  this.runValidation(resolved, path2, errors);
297
307
  }
@@ -455,6 +465,27 @@ var OptionBase = class {
455
465
  }
456
466
  return null;
457
467
  }
468
+ runOneOfCheck(node, path2, errors) {
469
+ const allowed = this.params.oneOf;
470
+ if (!allowed) return true;
471
+ const value = node.value;
472
+ if (valueIsInvalid(value)) return true;
473
+ if (!allowed.includes(value)) {
474
+ const ident = path2.join(".");
475
+ const source = node.file ?? node.variableName ?? node.argName ?? node.sourceType;
476
+ const allowedStr = allowed.map((v) => `'${String(v)}'`).join(", ");
477
+ errors?.errors.push({
478
+ message: `Value '${typeof value === "object" ? JSON.stringify(value) : String(value)}' for '${ident}' is not one of: ${allowedStr}.`,
479
+ path: ident,
480
+ source,
481
+ kind: "validation",
482
+ line: node.line ?? void 0,
483
+ column: node.column ?? void 0
484
+ });
485
+ return false;
486
+ }
487
+ return true;
488
+ }
458
489
  runValidation(node, path2, errors) {
459
490
  const validator = this.params.validate;
460
491
  if (!validator) return;
@@ -1034,7 +1065,12 @@ var Settings = class {
1034
1065
  addArg(node, path2 = []) {
1035
1066
  if (node.params.cli) {
1036
1067
  const ident = path2.join(".");
1037
- this.program.option(`--${ident} <value>`, node.params.help);
1068
+ let help = node.params.help;
1069
+ if (node.params.oneOf) {
1070
+ const allowed = node.params.oneOf.map(String).join(", ");
1071
+ help = help ? `${help} (one of: ${allowed})` : `one of: ${allowed}`;
1072
+ }
1073
+ this.program.option(`--${ident} <value>`, help);
1038
1074
  }
1039
1075
  }
1040
1076
  getValuesFromTree(node) {
@@ -1097,6 +1133,76 @@ var SettingsBuilder = class {
1097
1133
  }
1098
1134
  };
1099
1135
 
1136
+ // src/maskSecrets.ts
1137
+ var MASK = "***";
1138
+ function maskNodeTree(tree) {
1139
+ const result = {};
1140
+ for (const [key, entry] of Object.entries(tree)) {
1141
+ if (entry instanceof configNode_default) {
1142
+ if (entry.sensitive) {
1143
+ const masked = new configNode_default(
1144
+ MASK,
1145
+ entry.path,
1146
+ entry.sourceType,
1147
+ entry.file,
1148
+ entry.variableName,
1149
+ entry.argName,
1150
+ entry.line,
1151
+ entry.column,
1152
+ entry.sensitive
1153
+ );
1154
+ if (entry.value instanceof configNodeArray_default) {
1155
+ masked.value = entry.value;
1156
+ }
1157
+ result[key] = masked;
1158
+ } else {
1159
+ result[key] = entry;
1160
+ }
1161
+ } else {
1162
+ result[key] = maskNodeTree(entry);
1163
+ }
1164
+ }
1165
+ return result;
1166
+ }
1167
+ function maskPlainObject(obj, schema2) {
1168
+ const result = {};
1169
+ for (const [key, value] of Object.entries(obj)) {
1170
+ const schemaNode = schema2[key];
1171
+ if (!schemaNode) {
1172
+ result[key] = value;
1173
+ continue;
1174
+ }
1175
+ if (schemaNode instanceof ObjectOption) {
1176
+ if (value && typeof value === "object" && !Array.isArray(value)) {
1177
+ result[key] = maskPlainObject(
1178
+ value,
1179
+ schemaNode.item
1180
+ );
1181
+ } else {
1182
+ result[key] = value;
1183
+ }
1184
+ } else if (schemaNode instanceof OptionBase) {
1185
+ result[key] = schemaNode.params.sensitive ? MASK : value;
1186
+ } else {
1187
+ result[key] = value;
1188
+ }
1189
+ }
1190
+ return result;
1191
+ }
1192
+ function maskSecrets(resultOrConfig, schema2) {
1193
+ if ("data" in resultOrConfig && "warnings" in resultOrConfig && !schema2) {
1194
+ const extended = resultOrConfig;
1195
+ return {
1196
+ data: maskNodeTree(extended.data),
1197
+ warnings: [...extended.warnings]
1198
+ };
1199
+ }
1200
+ if (schema2) {
1201
+ return maskPlainObject(resultOrConfig, schema2);
1202
+ }
1203
+ return resultOrConfig;
1204
+ }
1205
+
1100
1206
  // src/printConfig.ts
1101
1207
  function truncate(str, max) {
1102
1208
  if (str.length <= max) return str;
@@ -1153,7 +1259,7 @@ function flattenTree(tree, prefix = "") {
1153
1259
  } else {
1154
1260
  rows.push({
1155
1261
  path: path2,
1156
- value: formatValue(entry.value),
1262
+ value: entry.sensitive ? "***" : formatValue(entry.value),
1157
1263
  source: entry.sourceType,
1158
1264
  detail: formatDetail(entry)
1159
1265
  });
@@ -1225,27 +1331,27 @@ var DEFAULTS = {
1225
1331
  cli: false,
1226
1332
  help: ""
1227
1333
  };
1228
- var string = (opts) => {
1334
+ function string(opts) {
1229
1335
  return new PrimitiveOption({
1230
1336
  kind: "string",
1231
1337
  ...DEFAULTS,
1232
1338
  ...opts
1233
1339
  });
1234
- };
1235
- var number = (opts) => {
1340
+ }
1341
+ function number(opts) {
1236
1342
  return new PrimitiveOption({
1237
1343
  kind: "number",
1238
1344
  ...DEFAULTS,
1239
1345
  ...opts
1240
1346
  });
1241
- };
1242
- var bool = (opts) => {
1347
+ }
1348
+ function bool(opts) {
1243
1349
  return new PrimitiveOption({
1244
1350
  kind: "boolean",
1245
1351
  ...DEFAULTS,
1246
1352
  ...opts
1247
1353
  });
1248
- };
1354
+ }
1249
1355
  var array = (opts) => {
1250
1356
  return new ArrayOption({
1251
1357
  ...DEFAULTS,
@@ -1276,5 +1382,6 @@ var index_default = option;
1276
1382
  ConfigLoadError,
1277
1383
  ConfigNode,
1278
1384
  ConfigNodeArray,
1385
+ maskSecrets,
1279
1386
  printConfig
1280
1387
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meltstudio/config-loader",
3
- "version": "3.3.0",
3
+ "version": "3.5.0",
4
4
  "description": "Type-safe configuration loader with full TypeScript inference. Load from YAML, JSON, .env, environment variables, and CLI args.",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",