@james-pre/config 0.0.1

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.
@@ -0,0 +1,343 @@
1
+ var __runInitializers = (this && this.__runInitializers) || function (thisArg, initializers, value) {
2
+ var useValue = arguments.length > 2;
3
+ for (var i = 0; i < initializers.length; i++) {
4
+ value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
5
+ }
6
+ return useValue ? value : void 0;
7
+ };
8
+ var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
9
+ function accept(f) { if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); return f; }
10
+ var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
11
+ var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
12
+ var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
13
+ var _, done = false;
14
+ for (var i = decorators.length - 1; i >= 0; i--) {
15
+ var context = {};
16
+ for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
17
+ for (var p in contextIn.access) context.access[p] = contextIn.access[p];
18
+ context.addInitializer = function (f) { if (done) throw new TypeError("Cannot add initializers after decoration has completed"); extraInitializers.push(accept(f || null)); };
19
+ var result = (0, decorators[i])(kind === "accessor" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context);
20
+ if (kind === "accessor") {
21
+ if (result === void 0) continue;
22
+ if (result === null || typeof result !== "object") throw new TypeError("Object expected");
23
+ if (_ = accept(result.get)) descriptor.get = _;
24
+ if (_ = accept(result.set)) descriptor.set = _;
25
+ if (_ = accept(result.init)) initializers.unshift(_);
26
+ }
27
+ else if (_ = accept(result)) {
28
+ if (kind === "field") initializers.unshift(_);
29
+ else descriptor[key] = _;
30
+ }
31
+ }
32
+ if (target) Object.defineProperty(target, contextIn.name, descriptor);
33
+ done = true;
34
+ };
35
+ // SPDX-License-Identifier: LGPL-3.0-or-later
36
+ import EventEmitter from 'node:events';
37
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
38
+ import { homedir } from 'node:os';
39
+ import { dirname, join, resolve, sep } from 'node:path';
40
+ import { deepAssign, getByString, memoize, setByString } from 'utilium';
41
+ import * as z from 'zod';
42
+ const kInit = Symbol.for('LoadOptions:init');
43
+ const include = z.string().array().optional();
44
+ function canInclude(options, file) {
45
+ return (!!options.enableIncludes
46
+ && typeof file === 'object'
47
+ && file !== null
48
+ && 'include' in file
49
+ && Array.isArray(file.include));
50
+ }
51
+ const xdgConfigDir = process.env.XDG_CONFIG_HOME
52
+ || (process.platform == 'win32' ? process.env.APPDATA : null)
53
+ || join(homedir(), '.config');
54
+ const systemConfigDir = (process.platform == 'win32' ? process.env.PROGRAMDATA : null) || '/etc';
55
+ function typeFromPath(path) {
56
+ if (path.startsWith(systemConfigDir + sep))
57
+ return 'system';
58
+ if (path.startsWith(xdgConfigDir + sep))
59
+ return 'user';
60
+ return 'local';
61
+ }
62
+ /** Resolve a `ManagerOptions` path, which may omit the `.json` extension */
63
+ function withExtension(path) {
64
+ return path.endsWith('.json') ? path : path + '.json';
65
+ }
66
+ /**
67
+ * Remove `.default()` and `.prefault()` from a schema and everything nested within it.
68
+ */
69
+ function stripDefaults(schema) {
70
+ const def = schema._zod.def;
71
+ switch (def.type) {
72
+ case 'default':
73
+ case 'prefault':
74
+ return stripDefaults(def.innerType);
75
+ case 'object':
76
+ return z.core.clone(schema, {
77
+ ...def,
78
+ shape: Object.fromEntries(Object.entries(def.shape).map(([key, member]) => [key, stripDefaults(member)])),
79
+ catchall: def.catchall && stripDefaults(def.catchall),
80
+ });
81
+ case 'array':
82
+ return z.core.clone(schema, { ...def, element: stripDefaults(def.element) });
83
+ case 'record':
84
+ case 'map':
85
+ case 'set':
86
+ return z.core.clone(schema, { ...def, valueType: stripDefaults(def.valueType) });
87
+ case 'union':
88
+ return z.core.clone(schema, { ...def, options: def.options.map(stripDefaults) });
89
+ case 'tuple':
90
+ return z.core.clone(schema, {
91
+ ...def,
92
+ items: def.items.map(stripDefaults),
93
+ rest: def.rest && stripDefaults(def.rest),
94
+ });
95
+ case 'optional':
96
+ case 'nullable':
97
+ case 'readonly':
98
+ case 'nonoptional':
99
+ case 'catch':
100
+ return z.core.clone(schema, { ...def, innerType: stripDefaults(def.innerType) });
101
+ default:
102
+ return schema;
103
+ }
104
+ }
105
+ /**
106
+ * Compute a schema's default value from `.default()` on it and on anything nested within it.
107
+ * Members without a default are left out, so the result is not necessarily a complete config.
108
+ */
109
+ function defaultsOf(schema) {
110
+ const def = schema._zod.def;
111
+ switch (def.type) {
112
+ case 'default':
113
+ case 'prefault':
114
+ case 'catch':
115
+ try {
116
+ return schema.parse(undefined);
117
+ }
118
+ catch {
119
+ return defaultsOf(def.innerType);
120
+ }
121
+ case 'object': {
122
+ const value = {};
123
+ for (const [key, member] of Object.entries(def.shape)) {
124
+ const inner = defaultsOf(member);
125
+ if (inner !== undefined)
126
+ value[key] = inner;
127
+ }
128
+ return Object.keys(value).length ? value : undefined;
129
+ }
130
+ default:
131
+ return def.innerType ? defaultsOf(def.innerType) : undefined;
132
+ }
133
+ }
134
+ /**
135
+ * Manager for configuration files
136
+ */
137
+ let Manager = (() => {
138
+ let _classSuper = EventEmitter;
139
+ let _instanceExtraInitializers = [];
140
+ let _get_defaultPaths_decorators;
141
+ return class Manager extends _classSuper {
142
+ static {
143
+ const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
144
+ _get_defaultPaths_decorators = [memoize];
145
+ __esDecorate(this, null, _get_defaultPaths_decorators, { kind: "getter", name: "defaultPaths", static: false, private: false, access: { has: obj => "defaultPaths" in obj, get: obj => obj.defaultPaths }, metadata: _metadata }, null, _instanceExtraInitializers);
146
+ if (_metadata) Object.defineProperty(this, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
147
+ }
148
+ options = __runInitializers(this, _instanceExtraInitializers);
149
+ schema;
150
+ fileSchema;
151
+ files = new Map();
152
+ data;
153
+ constructor(shape, options = {}) {
154
+ super({ captureRejections: true });
155
+ this.options = options;
156
+ this.schema = z.object(shape);
157
+ this.fileSchema = z.deepPartial(stripDefaults(z.object(options.enableIncludes ? { ...shape, include } : shape)));
158
+ this.data = this.schema.parse(this.defaults);
159
+ }
160
+ /**
161
+ * A fresh config with only the schema's defaults applied.
162
+ */
163
+ get defaults() {
164
+ return structuredClone(defaultsOf(this.schema) ?? {});
165
+ }
166
+ get(key) {
167
+ return getByString(this.data, key);
168
+ }
169
+ set(key, value) {
170
+ setByString(this.data, key, value);
171
+ }
172
+ /**
173
+ * Deeply merge `config` into the current config. Arrays are replaced rather than combined.
174
+ */
175
+ merge(config) {
176
+ deepAssign(this.data, structuredClone(config), { replaceArrays: true });
177
+ this.emit('change');
178
+ }
179
+ /** Replace the current config with `config`, applying defaults for anything missing. */
180
+ replace(config) {
181
+ for (const key of Object.keys(this.data))
182
+ delete this.data[key];
183
+ Object.assign(this.data, this.schema.parse(config));
184
+ this.emit('change');
185
+ }
186
+ loadFile(path, options) {
187
+ if (this.files.has(path))
188
+ return;
189
+ let json;
190
+ try {
191
+ json = JSON.parse(readFileSync(path, 'utf8'));
192
+ }
193
+ catch (e) {
194
+ if (!options.create || e.code != 'ENOENT') {
195
+ if (!options.optional)
196
+ throw e;
197
+ this.emit('load_error', path, 'read', e);
198
+ return;
199
+ }
200
+ try {
201
+ mkdirSync(dirname(path), { recursive: true });
202
+ writeFileSync(path, '{}', 'utf-8');
203
+ }
204
+ catch (e) {
205
+ if (!options.optional)
206
+ throw e;
207
+ this.emit('load_error', path, 'create', e);
208
+ return;
209
+ }
210
+ this.emit('create', path);
211
+ json = {};
212
+ }
213
+ let file;
214
+ try {
215
+ file = this.fileSchema.parse(json);
216
+ }
217
+ catch (e) {
218
+ if (!options.loose)
219
+ throw e;
220
+ this.emit('load_error', path, 'parse', e);
221
+ file = json;
222
+ }
223
+ this.files.set(path, {
224
+ auto: false,
225
+ wasIncluded: false,
226
+ type: typeFromPath(path),
227
+ ...(options[kInit] || {}),
228
+ data: file,
229
+ path,
230
+ });
231
+ this.merge(file);
232
+ this.emit('load', path, file);
233
+ if (canInclude(this.options, file))
234
+ for (const include of file.include ?? []) {
235
+ this.loadFile(resolve(dirname(path), include), {
236
+ ...options,
237
+ optional: true,
238
+ create: false,
239
+ [kInit]: { ...options[kInit], wasIncluded: true },
240
+ });
241
+ }
242
+ this.emit('post_load', path, file);
243
+ }
244
+ /** Get the entry for `path`, adding one for a file that has not been loaded */
245
+ fileAt(path) {
246
+ const existing = this.files.get(path);
247
+ if (existing)
248
+ return existing;
249
+ const file = {
250
+ path,
251
+ data: {},
252
+ type: typeFromPath(path),
253
+ auto: false,
254
+ wasIncluded: false,
255
+ };
256
+ this.files.set(path, file);
257
+ return file;
258
+ }
259
+ writeFile(file) {
260
+ mkdirSync(dirname(file.path), { recursive: true });
261
+ writeFileSync(file.path, JSON.stringify(file.data, null, '\t'), 'utf-8');
262
+ this.emit('write', file.path, file.data);
263
+ }
264
+ /**
265
+ * Replace the contents of the config file at `path` and write it,
266
+ * then rebuild the current config from the defaults and all loaded files.
267
+ */
268
+ replaceFile(path, config) {
269
+ const file = this.fileAt(path);
270
+ file.data = this.fileSchema.parse(config);
271
+ this.writeFile(file);
272
+ this.replace(this.defaults);
273
+ for (const file of this.files.values())
274
+ this.merge(file.data);
275
+ }
276
+ /**
277
+ * Merge `config` into the current config and into the config file at `path`, then write it.
278
+ */
279
+ updateFile(path, config) {
280
+ const file = this.fileAt(path);
281
+ deepAssign(file.data, this.fileSchema.parse(config), { replaceArrays: true });
282
+ this.merge(config);
283
+ this.writeFile(file);
284
+ }
285
+ /**
286
+ * The files `loadDefaults` will try to load, ordered from most global to most local.
287
+ * @internal
288
+ */
289
+ get defaultPaths() {
290
+ const paths = [];
291
+ const { system, xdg } = this.options;
292
+ if (system)
293
+ paths.push({ type: 'system', path: join(systemConfigDir, withExtension(system)) });
294
+ if (xdg)
295
+ paths.push({ type: 'user', path: join(xdgConfigDir, withExtension(xdg)) });
296
+ return paths;
297
+ }
298
+ /**
299
+ * Load the files from `defaultPaths`. Missing files are skipped unless `options` says otherwise.
300
+ */
301
+ loadDefaults(options) {
302
+ for (const { path, type } of this.defaultPaths) {
303
+ this.loadFile(path, { optional: true, ...options, [kInit]: { type, auto: true } });
304
+ }
305
+ }
306
+ /**
307
+ * The file a change of the given type is written to:
308
+ * the most recently loaded file of that type, or the path `loadDefaults` would use for it.
309
+ */
310
+ findPath(type) {
311
+ const loaded = this.files
312
+ .values()
313
+ .filter(file => (!type || file.type == type) && !file.wasIncluded)
314
+ .map(file => file.path)
315
+ .toArray();
316
+ if (loaded.length)
317
+ return loaded.at(-1);
318
+ const fallback = type ? this.defaultPaths.find(entry => entry.type == type) : this.defaultPaths.at(-1);
319
+ if (!fallback)
320
+ throw new Error(`No ${type ?? 'writable'} configuration file to write to`);
321
+ return fallback.path;
322
+ }
323
+ /**
324
+ * Merge `config` into the current config and save it.
325
+ */
326
+ update(config, type) {
327
+ this.updateFile(this.findPath(type), config);
328
+ }
329
+ reloadFiles(options) {
330
+ const files = this.files
331
+ .values()
332
+ .filter(file => !file.wasIncluded)
333
+ .toArray();
334
+ this.files.clear();
335
+ this.replace(this.defaults);
336
+ for (const file of files) {
337
+ this.loadFile(file.path, { ...options, [kInit]: { type: file.type, auto: file.auto } });
338
+ }
339
+ this.emit('reload');
340
+ }
341
+ };
342
+ })();
343
+ export { Manager };
@@ -0,0 +1,119 @@
1
+ import EventEmitter from 'node:events';
2
+ import type { FlattenKeys, GetByString, PartialRecursive } from 'utilium';
3
+ import * as z from 'zod';
4
+ export type FileType = 'system' | 'user' | 'local';
5
+ /** Information about a loaded config file */
6
+ interface File<Value> {
7
+ path: string;
8
+ wasIncluded: boolean;
9
+ data: Value;
10
+ type: FileType;
11
+ auto: boolean;
12
+ }
13
+ /**
14
+ * How a file is being loaded
15
+ * @internal
16
+ */
17
+ interface FileLoadInit {
18
+ wasIncluded?: boolean;
19
+ type?: FileType;
20
+ auto?: boolean;
21
+ }
22
+ export interface LoadOptions {
23
+ /**
24
+ * If enabled, the config file will still be loaded if it does not match the schema.
25
+ */
26
+ loose?: boolean;
27
+ /**
28
+ * If enabled, the config file will be skipped if it does not exist.
29
+ */
30
+ optional?: boolean;
31
+ /**
32
+ * If enabled, an empty config file will be created when it does not exist.
33
+ * Included files are never created.
34
+ */
35
+ create?: boolean;
36
+ /**
37
+ * Used to mark files that are included, auto-loaded, etc.
38
+ * @internal
39
+ */
40
+ [kInit]?: FileLoadInit;
41
+ }
42
+ declare const kInit: unique symbol;
43
+ export interface ManagerOptions {
44
+ /** If true, allow specifying `include: [...]` to load additional files */
45
+ enableIncludes?: boolean;
46
+ /**
47
+ * If set, try to load files in the XDG config directory (or the OS-equivalent).
48
+ * This corresponds to the relative path, though you can omit `.json`
49
+ */
50
+ xdg?: string;
51
+ /**
52
+ * Try to load system-wide configuration from `/etc` (or the OS-equivalent).
53
+ * Trailing `.json` can be omitted.
54
+ */
55
+ system?: string;
56
+ }
57
+ /**
58
+ * Manage for configuration files
59
+ */
60
+ export declare class Manager<Shape extends Readonly<Record<string, z.ZodType>>, LoadOpts extends LoadOptions = LoadOptions, out In extends z.input<z.ZodObject<Shape>> = z.input<z.ZodObject<Shape>>> extends EventEmitter<{
61
+ load: [path: string, config: z.output<ReturnType<typeof z.deepPartial<z.ZodObject<Shape>>>>];
62
+ load_error: [path: string, stage: 'read' | 'create' | 'parse', error: Error];
63
+ change: [];
64
+ }> {
65
+ protected options: ManagerOptions;
66
+ readonly schema: z.ZodObject<Shape>;
67
+ readonly fileSchema: ReturnType<typeof z.deepPartial<z.ZodObject<Shape>>>;
68
+ protected files: Map<string, File<z.output<typeof this.fileSchema>>>;
69
+ readonly data: z.output<z.ZodObject<Shape>>;
70
+ constructor(shape: Shape, options?: ManagerOptions);
71
+ /**
72
+ * A fresh config with only the schema's defaults applied.
73
+ */
74
+ get defaults(): z.output<z.ZodObject<Shape>> & In;
75
+ get<const K extends string | number = FlattenKeys<z.output<z.ZodObject<Shape>>>>(key: K): GetByString<z.output<z.ZodObject<Shape>>, K>;
76
+ set<const K extends string | number = FlattenKeys<z.output<z.ZodObject<Shape>>>, V = GetByString<z.output<z.ZodObject<Shape>>, K>>(key: K, value: V): void;
77
+ /**
78
+ * Deeply merge `config` into the current config. Arrays are replaced rather than combined.
79
+ */
80
+ merge(config: PartialRecursive<In>): void;
81
+ /** Replace the current config with `config`, applying defaults for anything missing. */
82
+ replace(config: In): void;
83
+ loadFile(path: string, options: LoadOpts): void;
84
+ /** Get the entry for `path`, adding one for a file that has not been loaded */
85
+ protected fileAt(path: string): File<z.output<typeof this.fileSchema>>;
86
+ protected writeFile(file: File<z.output<typeof this.fileSchema>>): void;
87
+ /**
88
+ * Replace the contents of the config file at `path` and write it,
89
+ * then rebuild the current config from the defaults and all loaded files.
90
+ */
91
+ replaceFile(path: string, config: In): void;
92
+ /**
93
+ * Merge `config` into the current config and into the config file at `path`, then write it.
94
+ */
95
+ updateFile(path: string, config: PartialRecursive<In>): void;
96
+ /**
97
+ * The files `loadDefaults` will try to load, ordered from most global to most local.
98
+ * @internal
99
+ */
100
+ get defaultPaths(): ReadonlyArray<{
101
+ path: string;
102
+ type: FileType;
103
+ }>;
104
+ /**
105
+ * Load the files from `defaultPaths`. Missing files are skipped unless `options` says otherwise.
106
+ */
107
+ loadDefaults(options: LoadOpts): void;
108
+ /**
109
+ * The file a change of the given type is written to:
110
+ * the most recently loaded file of that type, or the path `loadDefaults` would use for it.
111
+ */
112
+ findPath(type?: FileType): string;
113
+ /**
114
+ * Merge `config` into the current config and save it.
115
+ */
116
+ update(config: PartialRecursive<In>, type?: FileType): void;
117
+ reloadFiles(options: LoadOpts): void;
118
+ }
119
+ export {};