@abw/badger 1.0.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.
@@ -0,0 +1,874 @@
1
+ import process from 'node:process';
2
+ import path from 'node:path';
3
+ import { stat, readFile, writeFile, readdir, rm, mkdir, rmdir } from 'node:fs/promises';
4
+ import yaml from 'js-yaml';
5
+
6
+ /**
7
+ * Determines if a value is a string
8
+ * @param {String} value - value to test
9
+ * @return {Boolean} true if `value` is a string or false if not
10
+ */
11
+ function isString(value) {
12
+ return typeof value === 'string';
13
+ }
14
+
15
+ /**
16
+ * Determines if a value is an array
17
+ * @param {Array} value - value to test
18
+ * @return {Boolean} true if `value` is an Array or false if not
19
+ */
20
+ function isArray(value) {
21
+ return Array.isArray(value);
22
+ }
23
+
24
+ /**
25
+ * Determines if a value is a Function
26
+ * @param {Function} value - value to test
27
+ * @return {Boolean} true if `value` is a Function or false if not
28
+ */
29
+ function isFunction(value) {
30
+ return typeof value === 'function'
31
+ }
32
+
33
+ /**
34
+ * Determines if a value is an Object (but not an Array)
35
+ * @param {Object} value - value to test
36
+ * @return {Boolean} true if `value` is an Object or false if not
37
+ */
38
+ function isObject(value) {
39
+ return typeof value === "object"
40
+ && ! isArray(value)
41
+ && ! isNull(value);
42
+ }
43
+
44
+ /**
45
+ * Determines if a value is `undefined`
46
+ * @param {any} value - value to test
47
+ * @return {Boolean} true if `value` is `undefined` or false if not
48
+ */
49
+ function isUndefined(value) {
50
+ return typeof value === 'undefined';
51
+ }
52
+
53
+ /**
54
+ * Determines if a value is `null`
55
+ * @param {any} value - value to test
56
+ * @return {Boolean} true if `value` is `null` or false if not
57
+ */
58
+ function isNull(value) {
59
+ return value === null;
60
+ }
61
+
62
+ /**
63
+ * Determines if a value is defined and not null
64
+ * @param {any} value - value to test
65
+ * @return {Boolean} true if `value` is not `undefined` or `null`
66
+ */
67
+ function hasValue(value) {
68
+ return ! (isUndefined(value) || isNull(value));
69
+ }
70
+
71
+ /**
72
+ * Determines if all values are defined and not null
73
+ * @param {any[]} values - values to test
74
+ * @return {Boolean} true if all values are not `undefined` or `null`
75
+ */
76
+ function haveValue(...values) {
77
+ return values.every( value => hasValue(value) );
78
+ }
79
+
80
+ /**
81
+ * Determines if a value is undefined or null
82
+ * @param {any} value - value to test
83
+ * @return {Boolean} true if `value` is `undefined` or `null`
84
+ */
85
+ function noValue(value) {
86
+ return ! hasValue(value);
87
+ }
88
+
89
+ /**
90
+ * Throws a new Error object
91
+ * @param {String[]} message - error message string(s)
92
+ * @throws {Error}
93
+ */
94
+ function fail(...message) {
95
+ throw new Error(message.join(''));
96
+ }
97
+
98
+ /**
99
+ * Re-throw an existing Error object
100
+ * @param {Error} error - error object
101
+ * @throws {Error}
102
+ */
103
+ function rethrow(error) {
104
+ throw error;
105
+ }
106
+
107
+ /**
108
+ * Do nothing. Nothing at all.
109
+ */
110
+ function doNothing() {
111
+ // speak again Cordelia
112
+ }
113
+
114
+ const ANSIStart = '\u001B[';
115
+ const ANSIEnd = 'm';
116
+ const ANSIColors = {
117
+ reset: 0,
118
+ bold: 1,
119
+ bright: 1,
120
+ dark: 2,
121
+ black: 0,
122
+ red: 1,
123
+ green: 2,
124
+ yellow: 3,
125
+ blue: 4,
126
+ magenta: 5,
127
+ cyan: 6,
128
+ grey: 7,
129
+ white: 8,
130
+ fg: 30,
131
+ bg: 40,
132
+ };
133
+
134
+ const escapeCode = (str, base=0) => {
135
+ let codes = [ ];
136
+ let pair = str.split(/ /, 2);
137
+ const hue = pair.pop();
138
+ const code = (base ? ANSIColors[base] : 0) + ANSIColors[hue];
139
+ codes.push(code);
140
+ if (pair.length) {
141
+ const shade = pair.length ? pair.shift() : 'dark';
142
+ codes.push(ANSIColors[shade]);
143
+ }
144
+ return ANSIStart + codes.join(';') + ANSIEnd;
145
+ };
146
+
147
+ const escape = (c={}) => {
148
+ // color c can be specified as a string (e.g. 'red') which is shorthand
149
+ // for an object containing 'fg' (e.g. { fg: 'red' }) and/or 'bg' for
150
+ // foreground and background colors respectively
151
+ const col = isObject(c) ? c : { fg: c };
152
+ let escapes = [ ];
153
+ if (col.bg) {
154
+ escapes.push(escapeCode(col.bg, 'bg'));
155
+ }
156
+ if (col.fg) {
157
+ escapes.push(escapeCode(col.fg, 'fg'));
158
+ }
159
+ return escapes.join('');
160
+ };
161
+
162
+ const reset = () => escapeCode('reset');
163
+
164
+ /**
165
+ * Returns a debugging function which is enabled by the first `enabled` argument.
166
+ * If this is `false` then it returns a function which does nothing. If it is
167
+ * true then it returns a function that forwards all arguments to `console.log`.
168
+ * An optional `prefix` be be specified to prefix each debugging line. The
169
+ * optional third argument `color` can be used to specify a color for the prefix.
170
+ * @param {Boolean} enabled - is debugging enabled?
171
+ * @param {String} [prefix] - optional prefix for debugging messages
172
+ * @param {String|Object} [color] - a color name or object (see {@link Badger/Utils/Color})
173
+ * @param {String} [color.fg] - foreground color
174
+ * @param {String} [color.bg] - background color
175
+ * @return {Function} a debugging function
176
+ * @example
177
+ * const debug = Debugger(true)
178
+ * @example
179
+ * const debug = Debugger(true, 'Debug > ')
180
+ * @example
181
+ * const debug = Debugger(true, 'Debug > ', 'blue')
182
+ * @example
183
+ * const debug = Debugger(true, 'Debug > ', { bg: 'blue', fg: 'bright yellow' })
184
+ */
185
+ function Debugger(enabled, prefix='', color) {
186
+ return enabled
187
+ ? prefix
188
+ ? (format, ...args) =>
189
+ console.log(
190
+ '%s' + prefix + '%s' + format,
191
+ color ? escape(color) : '',
192
+ reset(),
193
+ ...args,
194
+ )
195
+ : console.log.bind(console)
196
+ : doNothing;
197
+ }
198
+
199
+ /**
200
+ * Creates a debugging function via {@link Debugger} and attaches it to the object
201
+ * passed as the first argument as the `debug` function.
202
+ * @param {Object} obj - the object to receive the `debug` function
203
+ * @param {Boolean} enabled - is debugging enabled?
204
+ * @param {String} [prefix] - optional prefix for debugging messages
205
+ * @param {String|Object} [color] - a color name or object (see {@link Badger/Utils/Color})
206
+ * @param {String} [color.fg] - foreground color
207
+ * @param {String} [color.bg] - background color
208
+ * @example
209
+ * const debug = addDebug(myObject, true)
210
+ * @example
211
+ * const debug = addDebug(myObject, true, 'Debug > ')
212
+ * @example
213
+ * const debug = addDebug(myObject, true, 'Debug > ', 'blue')
214
+ * @example
215
+ * const debug = addDebug(myObject, true, 'Debug > ', { bg: 'blue', fg: 'bright yellow' })
216
+ */
217
+ function addDebug(obj, enabled, prefix='', color) {
218
+ obj.debug = Debugger(enabled, prefix, color);
219
+ }
220
+
221
+ const defaultOptions = {
222
+ encoding: 'utf8'
223
+ };
224
+
225
+ class Path {
226
+ constructor(path, options={}) {
227
+ // allow path/file/directory to be constructed from an existing object
228
+ if (path instanceof Path) {
229
+ path = path.path();
230
+ }
231
+ this.state = { path, options: { ...defaultOptions, ...options } };
232
+ addDebug(this, options.debug, options.debugPrefix || 'Path', options.debugColor);
233
+ }
234
+ path() {
235
+ return this.state.path;
236
+ }
237
+ relativePath(...parts) {
238
+ return path.join(this.state.path, ...parts);
239
+ }
240
+ options(options={}) {
241
+ return { ...this.state.options, ...options };
242
+ }
243
+ async exists() {
244
+ try {
245
+ await this.stat();
246
+ return true;
247
+ }
248
+ catch (error) {
249
+ return error.code === 'ENOENT'
250
+ ? false
251
+ : rethrow(error);
252
+ }
253
+ }
254
+ async stat() {
255
+ const stats = await stat(this.state.path);
256
+ return this.state.stats = stats;
257
+ }
258
+ unstat() {
259
+ this.state.stats = undefined;
260
+ console.log('XXX unstat: ', this.state.stats);
261
+ return this;
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Function to encode JSON
267
+ * @param {Object} data - The data to encode as JSON text
268
+ * @return {String} a JSON encoded string
269
+ * @example
270
+ * encode({ message: 'Hello World' })
271
+ */
272
+ const encode = data => JSON.stringify(data);
273
+
274
+ /**
275
+ * Function to decode JSON
276
+ * @param {String} text - The JSON text to decode
277
+ * @return {Object|Array} the decoded object or array
278
+ * @example
279
+ * decode("{ message: 'Hello World' }")
280
+ */
281
+ const decode = text => JSON.parse(text);
282
+
283
+ /**
284
+ * An object containing the JSON `encode` and `decode` functions
285
+ */
286
+ const codec = { encode, decode };
287
+
288
+ // simple wrapper around JSON load/dump
289
+
290
+ /**
291
+ * Function to encode YAML
292
+ * @param {Object} data - The data to encode as YAML text
293
+ * @return {String} a YAML encoded string
294
+ * @example
295
+ * encode({ message: 'Hello World' })
296
+ */
297
+ const encode$1 = data => yaml.dump(data);
298
+
299
+ /**
300
+ * Function to decode YAML
301
+ * @param {String} text - The YAML text to decode
302
+ * @return {Object|Array} the decoded object or array
303
+ * @example
304
+ * decode("message: Hello World")
305
+ */
306
+ const decode$1 = text => yaml.load(text);
307
+
308
+ /**
309
+ * An object containing the YAML `encode` and `decode` functions
310
+ */
311
+ const codec$1 = { encode: encode$1, decode: decode$1 };
312
+
313
+ /**
314
+ * Codecs provide a consistent encode()/decode() interface for serialising
315
+ * and de-serialising data. This standard naming convention makes it possible
316
+ * for the ../Filesystem/File.js module to support a "codec" option for
317
+ * files. When this option is set the file.read() and file.write() methods
318
+ * automatically handle the translation to and from the serialised format
319
+ * using a codec object returned by the codec() function below. The codec
320
+ * name can be specified in any case, e.g. "Yaml", "YAML", "yaml", "YaML",
321
+ * etc., and it will be converted to lower case.
322
+ */
323
+
324
+ /**
325
+ * Lookup table for codecs
326
+ */
327
+ const codecs = {
328
+ json: codec, yaml: codec$1
329
+ };
330
+
331
+ /**
332
+ * Function to fetch a codec
333
+ * @param {string} name - The title of the code, in any case, e.g. "yaml", "YAML", "Yaml"
334
+ */
335
+ const codec$2 = name => codecs[
336
+ name.toLowerCase()
337
+ ];
338
+
339
+ class File extends Path {
340
+ /**
341
+ * Returns a new {@link Directory} object for the parent directory of the file
342
+ * @param {Object} [options] - directory configuration options
343
+ * @param {Boolean} [options.codec] - codec for encoding/decoding file data
344
+ * @return {Object} a {@link Directory} object for the parent
345
+ */
346
+ directory(options) {
347
+ return dir(path.dirname(this.state.path), options);
348
+ }
349
+
350
+ /**
351
+ * An alias for the {@link directory} method for lazy people
352
+ * @return {Object} the parent {@link Directory} object
353
+ */
354
+ dir(...args) {
355
+ return this.directory(...args);
356
+ }
357
+
358
+ /**
359
+ * Reads the file content. If a `codec` has been specified then the content is decoded.
360
+ * @param {Object} [options] - directory configuration options
361
+ * @param {Boolean} [options.codec] - codec for encoding/decoding file data
362
+ * @return {String|Object} the file content
363
+ * @example
364
+ * const text = file('myfile.txt').read();
365
+ * @example
366
+ * const data = file('myfile.json', { codec: 'json' }).read();
367
+ * @example
368
+ * const data = file('myfile.json').read({ codec: 'json' });
369
+ */
370
+ read(options) {
371
+ const opts = this.options(options);
372
+ const file = readFile(this.state.path, opts);
373
+ return opts.codec
374
+ ? file.then(text => codec$2(opts.codec).decode(text))
375
+ : file;
376
+ }
377
+
378
+ /**
379
+ * Writes the file content. If a `codec` has been specified then the content will be encoded.
380
+ * @param {String|Object} data - directory configuration options
381
+ * @param {Object} [options] - directory configuration options
382
+ * @param {Boolean} [options.codec] - codec for encoding/decoding file data
383
+ * @example
384
+ * file('myfile.txt').write('Hello World');
385
+ * @example
386
+ * file('myfile.json', { codec: 'json' }).write({ message: 'Hello World' });
387
+ * @example
388
+ * file('myfile.json').write({ message: 'Hello World' }, { codec: 'json' });
389
+ */
390
+ write(data, options) {
391
+ const opts = this.options(options);
392
+ const text = opts.codec
393
+ ? codec$2(opts.codec).encode(data)
394
+ : data;
395
+ return writeFile(this.state.path, text, opts).then( () => this );
396
+ }
397
+ }
398
+
399
+ /**
400
+ * Function to create a new {@link File} object for a file
401
+ * @param {String} path - file path
402
+ * @param {Object} [options] - configuration options
403
+ * @param {Boolean} [options.codec] - a codec for encoding/decoding files
404
+ * @return {Object} the {@link File} object
405
+ */
406
+ const file = (path, options) => {
407
+ return new File(path, options);
408
+ };
409
+
410
+ class Directory extends Path {
411
+ /**
412
+ * Fetch a new {@link File} object for a file in the directory.
413
+ * @param {string} path - file path
414
+ * @param {Object} [options] - file configuration options
415
+ * @param {String} [options.codec] - codec for encoding/decoding file data
416
+ * @return {Object} the {@link File} object
417
+ */
418
+ file(path, options) {
419
+ this.debug("file(%s, %o)", path, options);
420
+ return file(this.relativePath(path), this.options(options));
421
+ }
422
+
423
+ /**
424
+ * Fetch a new {@link Directory} object for a sub-directory in the directory.
425
+ * @param {string} path - directory path
426
+ * @param {Object} [options] - directory configuration options
427
+ * @param {String} [options.codec] - codec for encoding/decoding file data
428
+ * @return {Object} the {@link Directory} object
429
+ */
430
+ directory(path, options) {
431
+ this.debug("directory(%s, %o)", path, options);
432
+ return dir(this.relativePath(path), this.options(options));
433
+ }
434
+
435
+ /**
436
+ * An alias for the {@link directory} method for lazy people
437
+ * @return {Object} the {@link Directory} object
438
+ */
439
+ dir(path, options) {
440
+ this.debug("dir(%s, %o)", path, options);
441
+ return this.directory(path, options);
442
+ }
443
+
444
+ /**
445
+ * Returns a new {@link Directory} object for the parent directory
446
+ * @param {Object} [options] - directory configuration options
447
+ * @param {Boolean} [options.codec] - codec for encoding/decoding file data
448
+ * @return {Object} a {@link Directory} object for the parent
449
+ */
450
+ parent(options) {
451
+ this.debug("parent()");
452
+ return this.directory('..', options);
453
+ }
454
+
455
+ /**
456
+ * Returns the names of the files and sub-directories in the directory
457
+ * @return {Promise} fulfills with an array of the file and directory names
458
+ */
459
+ async read() {
460
+ this.debug("read()");
461
+ return await readdir(this.path());
462
+ }
463
+
464
+ /**
465
+ * Determines if the directory is empty.
466
+ * @return {Promise} fulfills with a boolean value true (empty) or false (not empty).
467
+ */
468
+ async isEmpty() {
469
+ this.debug("isEmpty()");
470
+ const entries = await this.read();
471
+ return entries.length === 0;
472
+ }
473
+
474
+ /**
475
+ * Determines if the directory is not empty.
476
+ * @return {Promise} fulfills with a boolean value true (not empty) or false (empty).
477
+ */
478
+ async notEmpty() {
479
+ this.debug("notEmpty()");
480
+ const empty = await this.isEmpty();
481
+ return !empty;
482
+ }
483
+
484
+ /**
485
+ * Empty the directory.
486
+ * @param {Object} [options] - configuration options
487
+ * @param {Boolean} [options.force] - force removal of files and directories
488
+ * @param {Boolean} [options.recursive] - recursively empty and delete sub-directories
489
+ * @return {Promise} fulfills to the {@link Directory} object
490
+ */
491
+ async empty(options={}) {
492
+ this.debug("empty(%o)", options);
493
+ if (await this.exists() && await this.notEmpty()) {
494
+ await rm(this.path(), options);
495
+ }
496
+ return this;
497
+ }
498
+
499
+ /**
500
+ * Make the directory.
501
+ * @param {Object} [options] - configuration options
502
+ * @param {Boolean} [options.recursive] - create intermediate directories
503
+ * @return {Promise} fulfills to the {@link Directory} object
504
+ */
505
+ async mkdir(options={}) {
506
+ this.debug("mkdir(%o)", options);
507
+ const exists = await this.exists();
508
+ if (! exists) {
509
+ await mkdir(this.path(), options);
510
+ }
511
+ return this;
512
+ }
513
+
514
+ /**
515
+ * Remove the directory.
516
+ * @param {Object} [options] - configuration options
517
+ * @param {Boolean} [options.empty] - delete items in directory
518
+ * @param {Boolean} [options.force] - force delete files and directories
519
+ * @param {Boolean} [options.recursive] - recursively delete sub-directories
520
+ * @return {Promise} fulfills to the {@link Directory} object
521
+ */
522
+ async rmdir(options={}) {
523
+ this.debug("rmdir(%o)", options);
524
+ if (options.empty) {
525
+ await this.empty(options);
526
+ }
527
+ if (await this.exists()) {
528
+ await rmdir(this.path());
529
+ }
530
+ return this;
531
+ }
532
+
533
+ /**
534
+ * Create the directory and any intermediate directories.
535
+ * @param {Object} [options] - configuration options
536
+ * @param {Boolean} [options.recursive=true] - recursively create intermediate directories
537
+ * @return {Promise} fulfills to the {@link Directory} object
538
+ */
539
+ create(options={ recursive: true }) {
540
+ this.debug("create(%o)", options);
541
+ return this.mkdir(options);
542
+ }
543
+
544
+ /**
545
+ * Empty and delete the directory.
546
+ * @param {Object} [options] - configuration options
547
+ * @param {Boolean} [options.empty=true] - empty directory of any files and sub-directories
548
+ * @param {Boolean} [options.recursive=true] - recursively delete sub-directories
549
+ * @param {Boolean} [options.force=true] - force deletion of files and sub-directories
550
+ * @return {Promise} fulfills to the {@link Directory} object
551
+ */
552
+ destroy(options={ empty: true, recursive: true, force: true }) {
553
+ this.debug("destroy(%o)", options);
554
+ return this.rmdir(options);
555
+ }
556
+
557
+ /**
558
+ * Assert that a directory exists and optionally create it
559
+ * @param {Object} [options] - configuration options
560
+ * @param {Boolean} [options.create] - create the directory and any intermediate directories if it doesn't exist - equivalent to adding `mkdir` and `recursive` options or calling {@link create}
561
+ * @param {Boolean} [options.mkdir] - create the directory, add the `recursive` option to create intermediate directories - equivalent to calling {@link mkdir}
562
+ * @param {Boolean} [options.recursive] - when used with `mkdir`, creates any intermediate directories
563
+ * @return {Promise} fulfills to the {@link Directory} object
564
+ */
565
+ async mustExist(options={}) {
566
+ this.debug("mustExist(%o)", options);
567
+ if (await this.exists()) {
568
+ return this;
569
+ }
570
+ if (options.mkdir) {
571
+ return this.mkdir(options);
572
+ }
573
+ if (options.create) {
574
+ return this.create();
575
+ }
576
+ fail("Directory does not exist: ", this.path());
577
+ }
578
+ }
579
+
580
+ /**
581
+ * Function to create a new {@link Directory} object
582
+ * @param {string} path - directory path
583
+ * @param {Object} [options] - configuration options
584
+ * @param {Boolean} [options.codec] - a codec for encoding/decoding files
585
+ * @return {Object} the {@link Directory} object
586
+ */
587
+ const dir = (path, options) => {
588
+ return new Directory(path, options);
589
+ };
590
+
591
+ /**
592
+ * Function to create a new {@link Directory} object for the current working directory
593
+ * @param {Object} [options] - configuration options
594
+ * @param {Boolean} [options.codec] - a codec for encoding/decoding files
595
+ * @return {Object} the {@link Directory} object
596
+ */
597
+ const cwd = options => {
598
+ return dir(process.cwd(), options);
599
+ };
600
+
601
+ /**
602
+ * Function to create a new {@link Directory} object for the directory of a JS source file
603
+ * @param {string} url - module url - from `import.meta.url`
604
+ * @param {Object} [options] - configuration options
605
+ * @param {Boolean} [options.codec] - a codec for encoding/decoding files
606
+ * @return {Object} the {@link Directory} object
607
+ */
608
+ const bin = (url, options) => {
609
+ return dir(
610
+ path.dirname(url.replace(/^file:\/\//, '')),
611
+ options
612
+ );
613
+ };
614
+
615
+ /**
616
+ * Split a comma/whitespace delimited string into an Array
617
+ * @param {String} [value] - string to split
618
+ * @return {Array} array of split strings
619
+ * @example
620
+ * const strings = splitList('one two three')
621
+ * @example
622
+ * const strings = splitList('one,two,three')
623
+ * @example
624
+ * const strings = splitList('one, two, three')
625
+ */
626
+ function splitList(value) {
627
+ return isString(value)
628
+ ? value.split(/,\s*|\s+/)
629
+ : isArray(value)
630
+ ? value
631
+ : [value];
632
+ }
633
+
634
+ /**
635
+ * Join an Array into a single string
636
+ * @param {Array} [array] - array to join
637
+ * @param {String} [joint=' '] - delimiter to join strings
638
+ * @param {String} [lastJoint=joint] - delimiter for final item
639
+ * @return {String} joined string
640
+ * @example
641
+ * joinList(['one', 'two', 'three']); // one two three
642
+ * @example
643
+ * joinList(['one', 'two', 'three'], ', '); // one, two, three
644
+ * @example
645
+ * joinList(['one', 'two', 'three'], ', ', ' and '); // one, two and three
646
+ */
647
+ function joinList(array, joint=' ', lastJoint=joint) {
648
+ let copy = [...array];
649
+ const last = copy.pop();
650
+ return copy.length
651
+ ? [copy.join(joint), last].join(lastJoint)
652
+ : last;
653
+ }
654
+
655
+ /**
656
+ * Join an Array into a single string using commas for delimiters and ` and ` for the final item
657
+ * @param {Array} [array] - array to join
658
+ * @param {String} [joint=', '] - delimiter to join strings
659
+ * @param {String} [lastJoint=' and '] - delimiter for final item
660
+ * @return {String} joined string
661
+ * @example
662
+ * joinListAnd(['one', 'two', 'three']); // one, two and three
663
+ */
664
+ function joinListAnd(array, joint=', ', lastJoint=' and ') {
665
+ return joinList(array, joint, lastJoint);
666
+ }
667
+
668
+ /**
669
+ * Join an Array into a single string using commas for delimiters and ` or ` for the final item
670
+ * @param {Array} [array] - array to join
671
+ * @param {String} [joint=', '] - delimiter to join strings
672
+ * @param {String} [lastJoint=' or '] - delimiter for final item
673
+ * @return {String} joined string
674
+ * @example
675
+ * joinListOr(['one', 'two', 'three']); // one, two or three
676
+ */
677
+ function joinListOr(array, joint=', ', lastJoint=' or ') {
678
+ return joinList(array, joint, lastJoint);
679
+ }
680
+
681
+ /**
682
+ * Capitalise a string by converting the first character to upper case and other characters to lower case
683
+ * @param {String} [word] - word to capitalise
684
+ * @return {String} capitalised string
685
+ * @example
686
+ * capitalise('badger'); // Badger
687
+ * @example
688
+ * capitalise('BADGER'); // Badger
689
+ */
690
+ function capitalise(word) {
691
+ return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
692
+ }
693
+
694
+ /**
695
+ * Convert a snake case string to studly caps
696
+ * @param {String} [snake] - word to capitalise
697
+ * @return {String} capitalised string
698
+ * @example
699
+ * snakeToStudly('happy_badger_dance'); // HappyBadgerDance
700
+ * @example
701
+ * snakeToStudly('happy_badger/dance'); // HappyBadger/Dance
702
+ */
703
+ function snakeToStudly(snake) {
704
+ return snake.split('/').map(
705
+ // each segment can be like foo_bar which we convert to FooBar
706
+ segment => segment.split('_').map(capitalise).join('')
707
+ ).join('/');
708
+ }
709
+
710
+ /**
711
+ * Convert a snake case string to camel case
712
+ * @param {String} [snake] - word to capitalise
713
+ * @return {String} capitalised string
714
+ * @example
715
+ * snakeToCamel('happy_badger_dance'); // happyBadgerDance
716
+ * @example
717
+ * snakeToCamel('happy_badger/dance'); // happyBadger/dance
718
+ */
719
+ function snakeToCamel(snake) {
720
+ return snake.split('/').map(
721
+ // each segment can be like foo_bar which we convert to FooBar
722
+ segment => segment.split('_').map((i, n) => n ? capitalise(i) : i).join('')
723
+ ).join('/');
724
+ }
725
+
726
+ /**
727
+ * Assert that a parameter object contains an item with a defined/non-null value
728
+ * @param {Object} params={} - parameters object
729
+ * @param {String} name - parameter that must be included
730
+ * @return {any} the parameter value
731
+ * @throws {Error} if the parameter is not defined or null
732
+ * @example
733
+ * const foo = requiredParam({ foo: 10 }, 'foo');
734
+ */
735
+ function requiredParam(params={}, name) {
736
+ const value = params[name];
737
+ if (hasValue(value)) {
738
+ return value;
739
+ }
740
+ else {
741
+ fail("Missing value for required parameter: ", name);
742
+ }
743
+ }
744
+
745
+ /**
746
+ * Assert that a parameter object contains all specified item with a defined/non-null value
747
+ * @param {Object} params={} - parameters object
748
+ * @param {Array|String} names - parameters that must be included, as an Array or whitespace/comma delimited string (see {@link splitList})
749
+ * @return {Array} the parameter values
750
+ * @throws {Error} if any parameter is not defined or null
751
+ * @example
752
+ * const [foo, bar] = requiredParams({ foo: 10, bar: 20 }, 'foo bar');
753
+ */
754
+ function requiredParams(params={}, names) {
755
+ return splitList(names).map( name => requiredParam(params, name) );
756
+ }
757
+
758
+ /**
759
+ * An alias for {@link requiredParams} for people who don't like typing long names (and for symmetry with {@link anyParams}))
760
+ */
761
+ const allParams=requiredParams;
762
+
763
+ /**
764
+ * Assert that a parameter object contains any of the specified items with a defined/non-null value
765
+ * @param {Object} params={} - parameters object
766
+ * @param {Array|String} names - parameters of which at least one must be included, as an Array or whitespace/comma delimited string (see {@link splitList})
767
+ * @return {Array} the parameter values
768
+ * @throws {Error} if any parameter is not defined or null
769
+ * @example
770
+ * const [foo, bar] = anyParams({ foo: 10, wiz: 99 }, 'foo bar');
771
+ */
772
+ function anyParams(params, names) {
773
+ let found = false;
774
+ const nlist = splitList(names);
775
+ const values = nlist.map(
776
+ name => {
777
+ const value = params[name];
778
+ if (hasValue(value)) {
779
+ found = true;
780
+ }
781
+ return value;
782
+ }
783
+ );
784
+ return found
785
+ ? values
786
+ : fail("Missing value for one of: ", joinListOr(nlist));
787
+ }
788
+
789
+ const defaults = {
790
+ codecs: 'yaml json',
791
+ };
792
+
793
+ class Config {
794
+ constructor(params={}) {
795
+ const options = { ...defaults, ...params };
796
+ const [rootDir] = allParams(options, 'dir');
797
+ const [codec, codecs] = anyParams(options, 'codec codecs');
798
+
799
+ this.state = {
800
+ dir: dir(rootDir),
801
+ codecs: splitList(codecs) || [codec],
802
+ };
803
+
804
+ addDebug(this, options.debug, options.debugPrefix, options.debugColor);
805
+ this.debug('root dir: ', this.state.dir.path());
806
+ this.debug('codecs: ', this.state.codecs);
807
+ }
808
+ async file(uri) {
809
+ for (let codec of this.state.codecs) {
810
+ const path = uri + '.' + codec;
811
+ const file = this.state.dir.file(path, { codec });
812
+ this.debug('looking for config file: ', file.path());
813
+ if (await file.exists()) {
814
+ this.debug('config file exists: ', file.path());
815
+ return file;
816
+ }
817
+ }
818
+ return undefined;
819
+ }
820
+ async config(uri, defaults) {
821
+ const file = await this.file(uri);
822
+ return file
823
+ ? await file.read()
824
+ : (defaults || fail("No configuration file for " + uri))
825
+ }
826
+ }
827
+
828
+ const config = options => new Config(options);
829
+
830
+ const defaults$1 = {
831
+ config: {
832
+ dir: 'config',
833
+ }
834
+ };
835
+ class Workspace {
836
+ constructor(props={}) {
837
+ const rootDir = dir(requiredParam(props, 'dir'));
838
+ const cfgDir = rootDir.dir(props.config?.dir || defaults$1.config.dir);
839
+ const cfgOpts = { ...defaults$1.config, ...(props.config||{}), dir: cfgDir };
840
+ const cfgObj = config(cfgOpts);
841
+
842
+ this.state = {
843
+ rootDir: rootDir,
844
+ configDir: cfgDir,
845
+ config: cfgObj
846
+ };
847
+
848
+ addDebug(this, props.debug, props.debugPrefix, props.debugColor);
849
+ this.debug('root dir: ', rootDir.path());
850
+ this.debug('config dir: ', cfgDir.path());
851
+ }
852
+ dir(path, options) {
853
+ this.debug("dir(%s, %o)", path, options);
854
+ return hasValue(path)
855
+ ? this.state.rootDir(path, options)
856
+ : this.state.rootDir;
857
+ }
858
+ configDir(path, options) {
859
+ this.debug("configDir(%s, %o)", path, options);
860
+ return hasValue(path)
861
+ ? this.state.configDir(path, options)
862
+ : this.state.configDir;
863
+ }
864
+ config(uri, defaults) {
865
+ this.debug("config(%s, %o)", uri, defaults);
866
+ return hasValue(uri)
867
+ ? this.state.config.config(uri, defaults)
868
+ : this.state.config;
869
+ }
870
+ }
871
+
872
+ const workspace = props => new Workspace(props);
873
+
874
+ export { Config, Path, Workspace, allParams, anyParams, bin, capitalise, codec$2 as codec, codecs, config, cwd, dir, doNothing, fail, file, hasValue, haveValue, isArray, isFunction, isNull, isObject, isString, isUndefined, joinList, joinListAnd, joinListOr, noValue, requiredParam, requiredParams, rethrow, snakeToCamel, snakeToStudly, splitList, workspace };