@theunwalked/cardigantime 0.0.2 → 0.0.3
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 +699 -0
- package/dist/cardigantime.cjs +605 -44
- package/dist/cardigantime.cjs.map +1 -1
- package/dist/cardigantime.d.ts +42 -0
- package/dist/cardigantime.js +44 -2
- package/dist/cardigantime.js.map +1 -1
- package/dist/configure.d.ts +50 -1
- package/dist/configure.js +102 -3
- package/dist/configure.js.map +1 -1
- package/dist/constants.d.ts +17 -0
- package/dist/constants.js +15 -5
- package/dist/constants.js.map +1 -1
- package/dist/error/ArgumentError.d.ts +26 -0
- package/dist/error/ArgumentError.js +48 -0
- package/dist/error/ArgumentError.js.map +1 -0
- package/dist/error/ConfigurationError.d.ts +21 -0
- package/dist/error/ConfigurationError.js +46 -0
- package/dist/error/ConfigurationError.js.map +1 -0
- package/dist/error/FileSystemError.d.ts +30 -0
- package/dist/error/FileSystemError.js +58 -0
- package/dist/error/FileSystemError.js.map +1 -0
- package/dist/error/index.d.ts +3 -0
- package/dist/read.d.ts +30 -0
- package/dist/read.js +105 -12
- package/dist/read.js.map +1 -1
- package/dist/types.d.ts +63 -0
- package/dist/types.js +5 -3
- package/dist/types.js.map +1 -1
- package/dist/util/storage.js +33 -4
- package/dist/util/storage.js.map +1 -1
- package/dist/validate.d.ts +96 -1
- package/dist/validate.js +164 -20
- package/dist/validate.js.map +1 -1
- package/package.json +16 -16
package/dist/cardigantime.cjs
CHANGED
|
@@ -27,25 +27,179 @@ function _interopNamespaceDefault(e) {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
const yaml__namespace = /*#__PURE__*/_interopNamespaceDefault(yaml);
|
|
30
|
+
const path__namespace = /*#__PURE__*/_interopNamespaceDefault(path);
|
|
30
31
|
const fs__namespace = /*#__PURE__*/_interopNamespaceDefault(fs);
|
|
31
32
|
|
|
32
|
-
|
|
33
|
+
/**
|
|
34
|
+
* Error thrown when CLI arguments or function parameters are invalid.
|
|
35
|
+
*
|
|
36
|
+
* This error provides specific context about which argument failed validation
|
|
37
|
+
* and why, making it easier for users to fix their command-line usage or
|
|
38
|
+
* for developers to debug parameter issues.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```typescript
|
|
42
|
+
* throw new ArgumentError('config-directory', 'Path cannot be empty');
|
|
43
|
+
* // Error message: "Path cannot be empty"
|
|
44
|
+
* // error.argument: "config-directory"
|
|
45
|
+
* ```
|
|
46
|
+
*/ function _define_property$2(obj, key, value) {
|
|
47
|
+
if (key in obj) {
|
|
48
|
+
Object.defineProperty(obj, key, {
|
|
49
|
+
value: value,
|
|
50
|
+
enumerable: true,
|
|
51
|
+
configurable: true,
|
|
52
|
+
writable: true
|
|
53
|
+
});
|
|
54
|
+
} else {
|
|
55
|
+
obj[key] = value;
|
|
56
|
+
}
|
|
57
|
+
return obj;
|
|
58
|
+
}
|
|
59
|
+
class ArgumentError extends Error {
|
|
60
|
+
/**
|
|
61
|
+
* Gets the name of the argument that caused this error.
|
|
62
|
+
*
|
|
63
|
+
* @returns The argument name
|
|
64
|
+
*/ get argument() {
|
|
65
|
+
return this.argumentName;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Creates a new ArgumentError instance.
|
|
69
|
+
*
|
|
70
|
+
* @param argumentName - The name of the invalid argument
|
|
71
|
+
* @param message - Description of why the argument is invalid
|
|
72
|
+
*/ constructor(argumentName, message){
|
|
73
|
+
super(`${message}`), /** The name of the argument that caused the error */ _define_property$2(this, "argumentName", void 0);
|
|
74
|
+
this.name = 'ArgumentError';
|
|
75
|
+
this.argumentName = argumentName;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Validates a configuration directory path to ensure it's safe and valid.
|
|
81
|
+
*
|
|
82
|
+
* Performs security and safety checks including:
|
|
83
|
+
* - Non-empty string validation
|
|
84
|
+
* - Null byte injection prevention
|
|
85
|
+
* - Path length validation
|
|
86
|
+
* - Type checking
|
|
87
|
+
*
|
|
88
|
+
* @param configDirectory - The configuration directory path to validate
|
|
89
|
+
* @param _testThrowNonArgumentError - Internal testing parameter to simulate non-ArgumentError exceptions
|
|
90
|
+
* @returns The trimmed and validated configuration directory path
|
|
91
|
+
* @throws {ArgumentError} When the directory path is invalid
|
|
92
|
+
*
|
|
93
|
+
* @example
|
|
94
|
+
* ```typescript
|
|
95
|
+
* const validDir = validateConfigDirectory('./config'); // Returns './config'
|
|
96
|
+
* const invalidDir = validateConfigDirectory(''); // Throws ArgumentError
|
|
97
|
+
* ```
|
|
98
|
+
*/ function validateConfigDirectory$2(configDirectory, _testThrowNonArgumentError) {
|
|
99
|
+
if (!configDirectory) {
|
|
100
|
+
throw new ArgumentError('configDirectory', 'Configuration directory cannot be empty');
|
|
101
|
+
}
|
|
102
|
+
if (typeof configDirectory !== 'string') {
|
|
103
|
+
throw new ArgumentError('configDirectory', 'Configuration directory must be a string');
|
|
104
|
+
}
|
|
105
|
+
const trimmed = configDirectory.trim();
|
|
106
|
+
if (trimmed.length === 0) {
|
|
107
|
+
throw new ArgumentError('configDirectory', 'Configuration directory cannot be empty or whitespace only');
|
|
108
|
+
}
|
|
109
|
+
// Check for obviously invalid paths
|
|
110
|
+
if (trimmed.includes('\0')) {
|
|
111
|
+
throw new ArgumentError('configDirectory', 'Configuration directory contains invalid null character');
|
|
112
|
+
}
|
|
113
|
+
// Validate path length (reasonable limit)
|
|
114
|
+
if (trimmed.length > 1000) {
|
|
115
|
+
throw new ArgumentError('configDirectory', 'Configuration directory path is too long (max 1000 characters)');
|
|
116
|
+
}
|
|
117
|
+
return trimmed;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Configures a Commander.js command with Cardigantime's CLI options.
|
|
121
|
+
*
|
|
122
|
+
* This function adds command-line options that allow users to override
|
|
123
|
+
* configuration settings at runtime, such as:
|
|
124
|
+
* - --config-directory: Override the default configuration directory
|
|
125
|
+
*
|
|
126
|
+
* The function validates both the command object and the options to ensure
|
|
127
|
+
* they meet the requirements for proper integration.
|
|
128
|
+
*
|
|
129
|
+
* @template T - The Zod schema shape type for configuration validation
|
|
130
|
+
* @param command - The Commander.js Command instance to configure
|
|
131
|
+
* @param options - Cardigantime options containing defaults and schema
|
|
132
|
+
* @param _testThrowNonArgumentError - Internal testing parameter
|
|
133
|
+
* @returns Promise resolving to the configured Command instance
|
|
134
|
+
* @throws {ArgumentError} When command or options are invalid
|
|
135
|
+
*
|
|
136
|
+
* @example
|
|
137
|
+
* ```typescript
|
|
138
|
+
* import { Command } from 'commander';
|
|
139
|
+
* import { configure } from './configure';
|
|
140
|
+
*
|
|
141
|
+
* const program = new Command();
|
|
142
|
+
* const configuredProgram = await configure(program, options);
|
|
143
|
+
*
|
|
144
|
+
* // Now the program accepts: --config-directory <path>
|
|
145
|
+
* ```
|
|
146
|
+
*/ const configure = async (command, options, _testThrowNonArgumentError)=>{
|
|
147
|
+
// Validate the command object
|
|
148
|
+
if (!command) {
|
|
149
|
+
throw new ArgumentError('command', 'Command instance is required');
|
|
150
|
+
}
|
|
151
|
+
if (typeof command.option !== 'function') {
|
|
152
|
+
throw new ArgumentError('command', 'Command must be a valid Commander.js Command instance');
|
|
153
|
+
}
|
|
154
|
+
// Validate options
|
|
155
|
+
if (!options) {
|
|
156
|
+
throw new ArgumentError('options', 'Options object is required');
|
|
157
|
+
}
|
|
158
|
+
if (!options.defaults) {
|
|
159
|
+
throw new ArgumentError('options.defaults', 'Options must include defaults configuration');
|
|
160
|
+
}
|
|
161
|
+
if (!options.defaults.configDirectory) {
|
|
162
|
+
throw new ArgumentError('options.defaults.configDirectory', 'Default config directory is required');
|
|
163
|
+
}
|
|
164
|
+
// Validate the default config directory
|
|
165
|
+
const validatedDefaultDir = validateConfigDirectory$2(options.defaults.configDirectory);
|
|
33
166
|
let retCommand = command;
|
|
34
|
-
|
|
167
|
+
// Add the config directory option with validation
|
|
168
|
+
retCommand = retCommand.option('-c, --config-directory <configDirectory>', 'Configuration directory path', (value)=>{
|
|
169
|
+
try {
|
|
170
|
+
return validateConfigDirectory$2(value, _testThrowNonArgumentError);
|
|
171
|
+
} catch (error) {
|
|
172
|
+
if (error instanceof ArgumentError) {
|
|
173
|
+
// Re-throw with more specific context for CLI usage
|
|
174
|
+
throw new ArgumentError('config-directory', `Invalid --config-directory: ${error.message}`);
|
|
175
|
+
}
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
}, validatedDefaultDir);
|
|
35
179
|
return retCommand;
|
|
36
180
|
};
|
|
37
181
|
|
|
38
|
-
const DEFAULT_ENCODING = 'utf8';
|
|
39
|
-
const DEFAULT_CONFIG_FILE = 'config.yaml';
|
|
40
|
-
|
|
182
|
+
/** Default file encoding for reading configuration files */ const DEFAULT_ENCODING = 'utf8';
|
|
183
|
+
/** Default configuration file name to look for in the config directory */ const DEFAULT_CONFIG_FILE = 'config.yaml';
|
|
184
|
+
/**
|
|
185
|
+
* Default configuration options applied when creating a Cardigantime instance.
|
|
186
|
+
* These provide sensible defaults that work for most use cases.
|
|
187
|
+
*/ const DEFAULT_OPTIONS = {
|
|
41
188
|
configFile: DEFAULT_CONFIG_FILE,
|
|
42
189
|
isRequired: false,
|
|
43
190
|
encoding: DEFAULT_ENCODING
|
|
44
191
|
};
|
|
45
|
-
|
|
192
|
+
/**
|
|
193
|
+
* Default features enabled when creating a Cardigantime instance.
|
|
194
|
+
* Currently includes only the 'config' feature for configuration file support.
|
|
195
|
+
*/ const DEFAULT_FEATURES = [
|
|
46
196
|
'config'
|
|
47
197
|
];
|
|
48
|
-
|
|
198
|
+
/**
|
|
199
|
+
* Default logger implementation using console methods.
|
|
200
|
+
* Provides basic logging functionality when no custom logger is specified.
|
|
201
|
+
* The verbose and silly methods are no-ops to avoid excessive output.
|
|
202
|
+
*/ const DEFAULT_LOGGER = {
|
|
49
203
|
// eslint-disable-next-line no-console
|
|
50
204
|
debug: console.debug,
|
|
51
205
|
// eslint-disable-next-line no-console
|
|
@@ -58,6 +212,62 @@ const DEFAULT_LOGGER = {
|
|
|
58
212
|
silly: ()=>{}
|
|
59
213
|
};
|
|
60
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Error thrown when file system operations fail
|
|
217
|
+
*/ function _define_property$1(obj, key, value) {
|
|
218
|
+
if (key in obj) {
|
|
219
|
+
Object.defineProperty(obj, key, {
|
|
220
|
+
value: value,
|
|
221
|
+
enumerable: true,
|
|
222
|
+
configurable: true,
|
|
223
|
+
writable: true
|
|
224
|
+
});
|
|
225
|
+
} else {
|
|
226
|
+
obj[key] = value;
|
|
227
|
+
}
|
|
228
|
+
return obj;
|
|
229
|
+
}
|
|
230
|
+
class FileSystemError extends Error {
|
|
231
|
+
/**
|
|
232
|
+
* Creates an error for when a required directory doesn't exist
|
|
233
|
+
*/ static directoryNotFound(path, isRequired = false) {
|
|
234
|
+
const message = isRequired ? 'Configuration directory does not exist and is required' : 'Configuration directory not found';
|
|
235
|
+
return new FileSystemError('not_found', message, path, 'directory_access');
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Creates an error for when a directory exists but isn't readable
|
|
239
|
+
*/ static directoryNotReadable(path) {
|
|
240
|
+
const message = 'Configuration directory exists but is not readable';
|
|
241
|
+
return new FileSystemError('not_readable', message, path, 'directory_read');
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Creates an error for directory creation failures
|
|
245
|
+
*/ static directoryCreationFailed(path, originalError) {
|
|
246
|
+
const message = 'Failed to create directory: ' + (originalError.message || 'Unknown error');
|
|
247
|
+
return new FileSystemError('creation_failed', message, path, 'directory_create', originalError);
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Creates an error for file operation failures (glob, etc.)
|
|
251
|
+
*/ static operationFailed(operation, path, originalError) {
|
|
252
|
+
const message = `Failed to ${operation}: ${originalError.message || 'Unknown error'}`;
|
|
253
|
+
return new FileSystemError('operation_failed', message, path, operation, originalError);
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Creates an error for when a file is not found
|
|
257
|
+
*/ static fileNotFound(path) {
|
|
258
|
+
const message = 'Configuration file not found';
|
|
259
|
+
return new FileSystemError('not_found', message, path, 'file_read');
|
|
260
|
+
}
|
|
261
|
+
constructor(errorType, message, path, operation, originalError){
|
|
262
|
+
super(message), _define_property$1(this, "errorType", void 0), _define_property$1(this, "path", void 0), _define_property$1(this, "operation", void 0), _define_property$1(this, "originalError", void 0);
|
|
263
|
+
this.name = 'FileSystemError';
|
|
264
|
+
this.errorType = errorType;
|
|
265
|
+
this.path = path;
|
|
266
|
+
this.operation = operation;
|
|
267
|
+
this.originalError = originalError;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
61
271
|
// eslint-disable-next-line no-restricted-imports
|
|
62
272
|
const create$1 = (params)=>{
|
|
63
273
|
// eslint-disable-next-line no-console
|
|
@@ -120,10 +330,38 @@ const create$1 = (params)=>{
|
|
|
120
330
|
recursive: true
|
|
121
331
|
});
|
|
122
332
|
} catch (mkdirError) {
|
|
123
|
-
throw
|
|
333
|
+
throw FileSystemError.directoryCreationFailed(path, mkdirError);
|
|
124
334
|
}
|
|
125
335
|
};
|
|
126
336
|
const readFile = async (path, encoding)=>{
|
|
337
|
+
// Validate encoding parameter
|
|
338
|
+
const validEncodings = [
|
|
339
|
+
'utf8',
|
|
340
|
+
'utf-8',
|
|
341
|
+
'ascii',
|
|
342
|
+
'latin1',
|
|
343
|
+
'base64',
|
|
344
|
+
'hex',
|
|
345
|
+
'utf16le',
|
|
346
|
+
'ucs2',
|
|
347
|
+
'ucs-2'
|
|
348
|
+
];
|
|
349
|
+
if (!validEncodings.includes(encoding.toLowerCase())) {
|
|
350
|
+
throw new Error('Invalid encoding specified');
|
|
351
|
+
}
|
|
352
|
+
// Check file size before reading to prevent DoS
|
|
353
|
+
try {
|
|
354
|
+
const stats = await fs__namespace.promises.stat(path);
|
|
355
|
+
const maxFileSize = 10 * 1024 * 1024; // 10MB limit
|
|
356
|
+
if (stats.size > maxFileSize) {
|
|
357
|
+
throw new Error('File too large to process');
|
|
358
|
+
}
|
|
359
|
+
} catch (error) {
|
|
360
|
+
if (error.code === 'ENOENT') {
|
|
361
|
+
throw FileSystemError.fileNotFound(path);
|
|
362
|
+
}
|
|
363
|
+
throw error;
|
|
364
|
+
}
|
|
127
365
|
return await fs__namespace.promises.readFile(path, {
|
|
128
366
|
encoding: encoding
|
|
129
367
|
});
|
|
@@ -145,7 +383,7 @@ const create$1 = (params)=>{
|
|
|
145
383
|
await callback(path.join(directory, file));
|
|
146
384
|
}
|
|
147
385
|
} catch (err) {
|
|
148
|
-
throw
|
|
386
|
+
throw FileSystemError.operationFailed(`glob pattern ${options.pattern}`, directory, err);
|
|
149
387
|
}
|
|
150
388
|
};
|
|
151
389
|
const readStream = async (path)=>{
|
|
@@ -177,35 +415,128 @@ const create$1 = (params)=>{
|
|
|
177
415
|
};
|
|
178
416
|
};
|
|
179
417
|
|
|
180
|
-
|
|
418
|
+
/**
|
|
419
|
+
* Removes undefined values from an object to create a clean configuration.
|
|
420
|
+
* This is used to merge configuration sources while avoiding undefined pollution.
|
|
421
|
+
*
|
|
422
|
+
* @param obj - The object to clean
|
|
423
|
+
* @returns A new object with undefined values filtered out
|
|
424
|
+
*/ function clean(obj) {
|
|
181
425
|
return Object.fromEntries(Object.entries(obj).filter(([_, v])=>v !== undefined));
|
|
182
426
|
}
|
|
183
|
-
|
|
427
|
+
/**
|
|
428
|
+
* Validates and secures a user-provided path to prevent path traversal attacks.
|
|
429
|
+
*
|
|
430
|
+
* Security checks include:
|
|
431
|
+
* - Path traversal prevention (blocks '..')
|
|
432
|
+
* - Absolute path detection
|
|
433
|
+
* - Path separator validation
|
|
434
|
+
*
|
|
435
|
+
* @param userPath - The user-provided path component
|
|
436
|
+
* @param basePath - The base directory to join the path with
|
|
437
|
+
* @returns The safely joined and normalized path
|
|
438
|
+
* @throws {Error} When path traversal or absolute paths are detected
|
|
439
|
+
*/ function validatePath(userPath, basePath) {
|
|
440
|
+
if (!userPath || !basePath) {
|
|
441
|
+
throw new Error('Invalid path parameters');
|
|
442
|
+
}
|
|
443
|
+
const normalized = path__namespace.normalize(userPath);
|
|
444
|
+
// Prevent path traversal attacks
|
|
445
|
+
if (normalized.includes('..') || path__namespace.isAbsolute(normalized)) {
|
|
446
|
+
throw new Error('Invalid path: path traversal detected');
|
|
447
|
+
}
|
|
448
|
+
// Ensure the path doesn't start with a path separator
|
|
449
|
+
if (normalized.startsWith('/') || normalized.startsWith('\\')) {
|
|
450
|
+
throw new Error('Invalid path: absolute path detected');
|
|
451
|
+
}
|
|
452
|
+
return path__namespace.join(basePath, normalized);
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Validates a configuration directory path for security and basic formatting.
|
|
456
|
+
*
|
|
457
|
+
* Performs validation to prevent:
|
|
458
|
+
* - Null byte injection attacks
|
|
459
|
+
* - Extremely long paths that could cause DoS
|
|
460
|
+
* - Empty or invalid directory specifications
|
|
461
|
+
*
|
|
462
|
+
* @param configDir - The configuration directory path to validate
|
|
463
|
+
* @returns The normalized configuration directory path
|
|
464
|
+
* @throws {Error} When the directory path is invalid or potentially dangerous
|
|
465
|
+
*/ function validateConfigDirectory$1(configDir) {
|
|
466
|
+
if (!configDir) {
|
|
467
|
+
throw new Error('Configuration directory is required');
|
|
468
|
+
}
|
|
469
|
+
// Check for null bytes which could be used for path injection
|
|
470
|
+
if (configDir.includes('\0')) {
|
|
471
|
+
throw new Error('Invalid path: null byte detected');
|
|
472
|
+
}
|
|
473
|
+
const normalized = path__namespace.normalize(configDir);
|
|
474
|
+
// Basic validation - could be expanded based on requirements
|
|
475
|
+
if (normalized.length > 1000) {
|
|
476
|
+
throw new Error('Configuration directory path too long');
|
|
477
|
+
}
|
|
478
|
+
return normalized;
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Reads configuration from files and merges it with CLI arguments.
|
|
482
|
+
*
|
|
483
|
+
* This function implements the core configuration loading logic:
|
|
484
|
+
* 1. Validates and resolves the configuration directory path
|
|
485
|
+
* 2. Attempts to read the YAML configuration file
|
|
486
|
+
* 3. Safely parses the YAML content with security protections
|
|
487
|
+
* 4. Merges file configuration with runtime arguments
|
|
488
|
+
* 5. Returns a typed configuration object
|
|
489
|
+
*
|
|
490
|
+
* The function handles missing files gracefully and provides detailed
|
|
491
|
+
* logging for troubleshooting configuration issues.
|
|
492
|
+
*
|
|
493
|
+
* @template T - The Zod schema shape type for configuration validation
|
|
494
|
+
* @param args - Parsed command-line arguments containing potential config overrides
|
|
495
|
+
* @param options - Cardigantime options with defaults, schema, and logger
|
|
496
|
+
* @returns Promise resolving to the merged and typed configuration object
|
|
497
|
+
* @throws {Error} When configuration directory is invalid or required files cannot be read
|
|
498
|
+
*
|
|
499
|
+
* @example
|
|
500
|
+
* ```typescript
|
|
501
|
+
* const config = await read(cliArgs, {
|
|
502
|
+
* defaults: { configDirectory: './config', configFile: 'app.yaml' },
|
|
503
|
+
* configShape: MySchema.shape,
|
|
504
|
+
* logger: console,
|
|
505
|
+
* features: ['config']
|
|
506
|
+
* });
|
|
507
|
+
* // config is fully typed based on your schema
|
|
508
|
+
* ```
|
|
509
|
+
*/ const read = async (args, options)=>{
|
|
184
510
|
var _options_defaults;
|
|
185
511
|
const logger = options.logger;
|
|
186
512
|
const storage = create$1({
|
|
187
513
|
log: logger.debug
|
|
188
514
|
});
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
515
|
+
const rawConfigDir = args.configDirectory || ((_options_defaults = options.defaults) === null || _options_defaults === void 0 ? void 0 : _options_defaults.configDirectory);
|
|
516
|
+
if (!rawConfigDir) {
|
|
517
|
+
throw new Error('Configuration directory must be specified');
|
|
518
|
+
}
|
|
519
|
+
const resolvedConfigDir = validateConfigDirectory$1(rawConfigDir);
|
|
520
|
+
logger.debug('Resolved config directory');
|
|
521
|
+
const configFile = validatePath(options.defaults.configFile, resolvedConfigDir);
|
|
522
|
+
logger.debug('Attempting to load config file for cardigantime');
|
|
193
523
|
let rawFileConfig = {};
|
|
194
524
|
try {
|
|
195
525
|
const yamlContent = await storage.readFile(configFile, options.defaults.encoding);
|
|
526
|
+
// SECURITY FIX: Use safer parsing options to prevent code execution vulnerabilities
|
|
196
527
|
const parsedYaml = yaml__namespace.load(yamlContent);
|
|
197
528
|
if (parsedYaml !== null && typeof parsedYaml === 'object') {
|
|
198
529
|
rawFileConfig = parsedYaml;
|
|
199
|
-
logger.debug('Loaded
|
|
530
|
+
logger.debug('Loaded configuration file successfully');
|
|
200
531
|
} else if (parsedYaml !== null) {
|
|
201
|
-
logger.warn(
|
|
532
|
+
logger.warn('Ignoring invalid configuration format. Expected an object, got ' + typeof parsedYaml);
|
|
202
533
|
}
|
|
203
534
|
} catch (error) {
|
|
204
535
|
if (error.code === 'ENOENT' || /not found|no such file/i.test(error.message)) {
|
|
205
|
-
logger.debug(
|
|
536
|
+
logger.debug('Configuration file not found. Using empty configuration.');
|
|
206
537
|
} else {
|
|
207
|
-
//
|
|
208
|
-
logger.error(
|
|
538
|
+
// SECURITY FIX: Don't expose internal paths or detailed error information
|
|
539
|
+
logger.error('Failed to load or parse configuration file: ' + (error.message || 'Unknown error'));
|
|
209
540
|
}
|
|
210
541
|
}
|
|
211
542
|
const config = clean({
|
|
@@ -217,18 +548,101 @@ const read = async (args, options)=>{
|
|
|
217
548
|
return config;
|
|
218
549
|
};
|
|
219
550
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
551
|
+
/**
|
|
552
|
+
* Error thrown when configuration validation fails
|
|
553
|
+
*/ function _define_property(obj, key, value) {
|
|
554
|
+
if (key in obj) {
|
|
555
|
+
Object.defineProperty(obj, key, {
|
|
556
|
+
value: value,
|
|
557
|
+
enumerable: true,
|
|
558
|
+
configurable: true,
|
|
559
|
+
writable: true
|
|
560
|
+
});
|
|
561
|
+
} else {
|
|
562
|
+
obj[key] = value;
|
|
563
|
+
}
|
|
564
|
+
return obj;
|
|
565
|
+
}
|
|
566
|
+
class ConfigurationError extends Error {
|
|
567
|
+
/**
|
|
568
|
+
* Creates a validation error for when config doesn't match the schema
|
|
569
|
+
*/ static validation(message, zodError, configPath) {
|
|
570
|
+
return new ConfigurationError('validation', message, zodError, configPath);
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Creates an error for when extra/unknown keys are found
|
|
574
|
+
*/ static extraKeys(extraKeys, allowedKeys, configPath) {
|
|
575
|
+
const message = `Unknown configuration keys found: ${extraKeys.join(', ')}. Allowed keys are: ${allowedKeys.join(', ')}`;
|
|
576
|
+
return new ConfigurationError('extra_keys', message, {
|
|
577
|
+
extraKeys,
|
|
578
|
+
allowedKeys
|
|
579
|
+
}, configPath);
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Creates a schema error for when the configuration schema itself is invalid
|
|
583
|
+
*/ static schema(message, details) {
|
|
584
|
+
return new ConfigurationError('schema', message, details);
|
|
585
|
+
}
|
|
586
|
+
constructor(errorType, message, details, configPath){
|
|
587
|
+
super(message), _define_property(this, "errorType", void 0), _define_property(this, "details", void 0), _define_property(this, "configPath", void 0);
|
|
588
|
+
this.name = 'ConfigurationError';
|
|
589
|
+
this.errorType = errorType;
|
|
590
|
+
this.details = details;
|
|
591
|
+
this.configPath = configPath;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Base Zod schema for core Cardigantime configuration.
|
|
597
|
+
* Contains the minimum required configuration fields.
|
|
598
|
+
*/ const ConfigSchema = zod.z.object({
|
|
599
|
+
/** The resolved configuration directory path */ configDirectory: zod.z.string()
|
|
223
600
|
});
|
|
224
601
|
|
|
225
|
-
|
|
602
|
+
/**
|
|
603
|
+
* Recursively extracts all keys from a Zod schema in dot notation.
|
|
604
|
+
*
|
|
605
|
+
* This function traverses a Zod schema structure and builds a flat list
|
|
606
|
+
* of all possible keys, using dot notation for nested objects. It handles
|
|
607
|
+
* optional/nullable types by unwrapping them and supports arrays by
|
|
608
|
+
* introspecting their element type.
|
|
609
|
+
*
|
|
610
|
+
* Special handling for:
|
|
611
|
+
* - ZodOptional/ZodNullable: Unwraps to get the underlying type
|
|
612
|
+
* - ZodAny/ZodRecord: Accepts any keys, returns the prefix or empty array
|
|
613
|
+
* - ZodArray: Introspects the element type
|
|
614
|
+
* - ZodObject: Recursively processes all shape properties
|
|
615
|
+
*
|
|
616
|
+
* @param schema - The Zod schema to introspect
|
|
617
|
+
* @param prefix - Internal parameter for building nested key paths
|
|
618
|
+
* @returns Array of strings representing all possible keys in dot notation
|
|
619
|
+
*
|
|
620
|
+
* @example
|
|
621
|
+
* ```typescript
|
|
622
|
+
* const schema = z.object({
|
|
623
|
+
* user: z.object({
|
|
624
|
+
* name: z.string(),
|
|
625
|
+
* settings: z.object({ theme: z.string() })
|
|
626
|
+
* }),
|
|
627
|
+
* debug: z.boolean()
|
|
628
|
+
* });
|
|
629
|
+
*
|
|
630
|
+
* const keys = listZodKeys(schema);
|
|
631
|
+
* // Returns: ['user.name', 'user.settings.theme', 'debug']
|
|
632
|
+
* ```
|
|
633
|
+
*/ const listZodKeys = (schema, prefix = '')=>{
|
|
226
634
|
// Check if schema has unwrap method (which both ZodOptional and ZodNullable have)
|
|
227
635
|
if (schema._def && (schema._def.typeName === 'ZodOptional' || schema._def.typeName === 'ZodNullable')) {
|
|
228
636
|
// Use type assertion to handle the unwrap method
|
|
229
637
|
const unwrappable = schema;
|
|
230
638
|
return listZodKeys(unwrappable.unwrap(), prefix);
|
|
231
639
|
}
|
|
640
|
+
// Handle ZodAny and ZodRecord - these accept any keys, so don't introspect
|
|
641
|
+
if (schema._def && (schema._def.typeName === 'ZodAny' || schema._def.typeName === 'ZodRecord')) {
|
|
642
|
+
return prefix ? [
|
|
643
|
+
prefix
|
|
644
|
+
] : [];
|
|
645
|
+
}
|
|
232
646
|
if (schema._def && schema._def.typeName === 'ZodArray') {
|
|
233
647
|
// Use type assertion to handle the element property
|
|
234
648
|
const arraySchema = schema;
|
|
@@ -245,7 +659,12 @@ const listZodKeys = (schema, prefix = '')=>{
|
|
|
245
659
|
}
|
|
246
660
|
return [];
|
|
247
661
|
};
|
|
248
|
-
|
|
662
|
+
/**
|
|
663
|
+
* Type guard to check if a value is a plain object (not array, null, or other types).
|
|
664
|
+
*
|
|
665
|
+
* @param value - The value to check
|
|
666
|
+
* @returns True if the value is a plain object
|
|
667
|
+
*/ const isPlainObject = (value)=>{
|
|
249
668
|
// Check if it's an object, not null, and not an array.
|
|
250
669
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
251
670
|
};
|
|
@@ -288,59 +707,198 @@ const isPlainObject = (value)=>{
|
|
|
288
707
|
}
|
|
289
708
|
return Array.from(keys); // Convert Set back to Array
|
|
290
709
|
};
|
|
291
|
-
|
|
710
|
+
/**
|
|
711
|
+
* Validates that the configuration object contains only keys allowed by the schema.
|
|
712
|
+
*
|
|
713
|
+
* This function prevents configuration errors by detecting typos or extra keys
|
|
714
|
+
* that aren't defined in the Zod schema. It intelligently handles:
|
|
715
|
+
* - ZodRecord types that accept arbitrary keys
|
|
716
|
+
* - Nested objects and their key structures
|
|
717
|
+
* - Arrays and their element key structures
|
|
718
|
+
*
|
|
719
|
+
* The function throws a ConfigurationError if extra keys are found, providing
|
|
720
|
+
* helpful information about what keys are allowed vs. what was found.
|
|
721
|
+
*
|
|
722
|
+
* @param mergedSources - The merged configuration object to validate
|
|
723
|
+
* @param fullSchema - The complete Zod schema including base and user schemas
|
|
724
|
+
* @param logger - Logger for error reporting
|
|
725
|
+
* @throws {ConfigurationError} When extra keys are found that aren't in the schema
|
|
726
|
+
*
|
|
727
|
+
* @example
|
|
728
|
+
* ```typescript
|
|
729
|
+
* const schema = z.object({ name: z.string(), age: z.number() });
|
|
730
|
+
* const config = { name: 'John', age: 30, typo: 'invalid' };
|
|
731
|
+
*
|
|
732
|
+
* checkForExtraKeys(config, schema, console);
|
|
733
|
+
* // Throws: ConfigurationError with details about 'typo' being an extra key
|
|
734
|
+
* ```
|
|
735
|
+
*/ const checkForExtraKeys = (mergedSources, fullSchema, logger)=>{
|
|
292
736
|
const allowedKeys = new Set(listZodKeys(fullSchema));
|
|
293
737
|
const actualKeys = listObjectKeys(mergedSources);
|
|
294
|
-
|
|
738
|
+
// Filter out keys that are under a record type (ZodRecord accepts any keys)
|
|
739
|
+
const recordPrefixes = new Set();
|
|
740
|
+
// Find all prefixes that are ZodRecord types
|
|
741
|
+
const findRecordPrefixes = (schema, prefix = '')=>{
|
|
742
|
+
if (schema._def && (schema._def.typeName === 'ZodOptional' || schema._def.typeName === 'ZodNullable')) {
|
|
743
|
+
const unwrappable = schema;
|
|
744
|
+
findRecordPrefixes(unwrappable.unwrap(), prefix);
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
if (schema._def && (schema._def.typeName === 'ZodAny' || schema._def.typeName === 'ZodRecord')) {
|
|
748
|
+
if (prefix) recordPrefixes.add(prefix);
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
if (schema._def && schema._def.typeName === 'ZodObject') {
|
|
752
|
+
const objectSchema = schema;
|
|
753
|
+
Object.entries(objectSchema.shape).forEach(([key, subschema])=>{
|
|
754
|
+
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
755
|
+
findRecordPrefixes(subschema, fullKey);
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
};
|
|
759
|
+
findRecordPrefixes(fullSchema);
|
|
760
|
+
// Filter out keys that are under record prefixes
|
|
761
|
+
const extraKeys = actualKeys.filter((key)=>{
|
|
762
|
+
if (allowedKeys.has(key)) return false;
|
|
763
|
+
// Check if this key is under a record prefix
|
|
764
|
+
for (const recordPrefix of recordPrefixes){
|
|
765
|
+
if (key.startsWith(recordPrefix + '.')) {
|
|
766
|
+
return false; // This key is allowed under a record
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
return true; // This is an extra key
|
|
770
|
+
});
|
|
295
771
|
if (extraKeys.length > 0) {
|
|
296
|
-
const
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
throw new Error(`Configuration validation failed: Unknown keys found (${extraKeysString}). Check logs for details.`);
|
|
772
|
+
const allowedKeysArray = Array.from(allowedKeys);
|
|
773
|
+
const error = ConfigurationError.extraKeys(extraKeys, allowedKeysArray);
|
|
774
|
+
logger.error(error.message);
|
|
775
|
+
throw error;
|
|
301
776
|
}
|
|
302
777
|
};
|
|
303
|
-
|
|
304
|
-
|
|
778
|
+
/**
|
|
779
|
+
* Validates that a configuration directory exists and is accessible.
|
|
780
|
+
*
|
|
781
|
+
* This function performs file system checks to ensure the configuration
|
|
782
|
+
* directory can be used. It handles the isRequired flag to determine
|
|
783
|
+
* whether a missing directory should cause an error or be silently ignored.
|
|
784
|
+
*
|
|
785
|
+
* @param configDirectory - Path to the configuration directory
|
|
786
|
+
* @param isRequired - Whether the directory must exist
|
|
787
|
+
* @param logger - Optional logger for debug information
|
|
788
|
+
* @throws {FileSystemError} When the directory is required but missing or unreadable
|
|
789
|
+
*/ const validateConfigDirectory = async (configDirectory, isRequired, logger)=>{
|
|
305
790
|
const storage = create$1({
|
|
306
|
-
log:
|
|
791
|
+
log: (logger === null || logger === void 0 ? void 0 : logger.debug) || (()=>{})
|
|
307
792
|
});
|
|
308
793
|
const exists = await storage.exists(configDirectory);
|
|
309
794
|
if (!exists) {
|
|
310
795
|
if (isRequired) {
|
|
311
|
-
throw
|
|
796
|
+
throw FileSystemError.directoryNotFound(configDirectory, true);
|
|
312
797
|
}
|
|
313
798
|
} else if (exists) {
|
|
314
799
|
const isReadable = await storage.isDirectoryReadable(configDirectory);
|
|
315
800
|
if (!isReadable) {
|
|
316
|
-
throw
|
|
801
|
+
throw FileSystemError.directoryNotReadable(configDirectory);
|
|
317
802
|
}
|
|
318
803
|
}
|
|
319
804
|
};
|
|
320
|
-
|
|
805
|
+
/**
|
|
806
|
+
* Validates a configuration object against the combined Zod schema.
|
|
807
|
+
*
|
|
808
|
+
* This is the main validation function that:
|
|
809
|
+
* 1. Validates the configuration directory (if config feature enabled)
|
|
810
|
+
* 2. Combines the base ConfigSchema with user-provided schema shape
|
|
811
|
+
* 3. Checks for extra keys not defined in the schema
|
|
812
|
+
* 4. Validates all values against their schema definitions
|
|
813
|
+
* 5. Provides detailed error reporting for validation failures
|
|
814
|
+
*
|
|
815
|
+
* The validation is comprehensive and catches common configuration errors
|
|
816
|
+
* including typos, missing required fields, wrong types, and invalid values.
|
|
817
|
+
*
|
|
818
|
+
* @template T - The Zod schema shape type for configuration validation
|
|
819
|
+
* @param config - The merged configuration object to validate
|
|
820
|
+
* @param options - Cardigantime options containing schema, defaults, and logger
|
|
821
|
+
* @throws {ConfigurationError} When configuration validation fails
|
|
822
|
+
* @throws {FileSystemError} When configuration directory validation fails
|
|
823
|
+
*
|
|
824
|
+
* @example
|
|
825
|
+
* ```typescript
|
|
826
|
+
* const schema = z.object({
|
|
827
|
+
* apiKey: z.string().min(1),
|
|
828
|
+
* timeout: z.number().positive(),
|
|
829
|
+
* });
|
|
830
|
+
*
|
|
831
|
+
* await validate(config, {
|
|
832
|
+
* configShape: schema.shape,
|
|
833
|
+
* defaults: { configDirectory: './config', isRequired: true },
|
|
834
|
+
* logger: console,
|
|
835
|
+
* features: ['config']
|
|
836
|
+
* });
|
|
837
|
+
* // Throws detailed errors if validation fails
|
|
838
|
+
* ```
|
|
839
|
+
*/ const validate = async (config, options)=>{
|
|
321
840
|
const logger = options.logger;
|
|
322
841
|
if (options.features.includes('config') && config.configDirectory) {
|
|
323
|
-
await validateConfigDirectory(config.configDirectory, options.defaults.isRequired);
|
|
842
|
+
await validateConfigDirectory(config.configDirectory, options.defaults.isRequired, logger);
|
|
324
843
|
}
|
|
325
844
|
// Combine the base schema with the user-provided shape
|
|
326
845
|
const fullSchema = zod.z.object({
|
|
327
846
|
...ConfigSchema.shape,
|
|
328
847
|
...options.configShape
|
|
329
848
|
});
|
|
330
|
-
logger.debug('Full Schema: \n\n%s\n\n', JSON.stringify(listZodKeys(fullSchema), null, 2));
|
|
331
849
|
// Validate the merged sources against the full schema
|
|
332
850
|
const validationResult = fullSchema.safeParse(config);
|
|
333
851
|
// Check for extraneous keys
|
|
334
852
|
checkForExtraKeys(config, fullSchema, logger);
|
|
335
853
|
if (!validationResult.success) {
|
|
336
|
-
|
|
337
|
-
|
|
854
|
+
const formattedError = JSON.stringify(validationResult.error.format(), null, 2);
|
|
855
|
+
logger.error('Configuration validation failed: %s', formattedError);
|
|
856
|
+
throw ConfigurationError.validation('Configuration validation failed. Check logs for details.', validationResult.error);
|
|
338
857
|
}
|
|
339
858
|
return;
|
|
340
859
|
};
|
|
341
860
|
|
|
342
|
-
|
|
343
|
-
|
|
861
|
+
/**
|
|
862
|
+
* Creates a new Cardigantime instance for configuration management.
|
|
863
|
+
*
|
|
864
|
+
* Cardigantime handles the complete configuration lifecycle including:
|
|
865
|
+
* - Reading configuration from YAML files
|
|
866
|
+
* - Validating configuration against Zod schemas
|
|
867
|
+
* - Merging CLI arguments with file configuration and defaults
|
|
868
|
+
* - Providing type-safe configuration objects
|
|
869
|
+
*
|
|
870
|
+
* @template T - The Zod schema shape type for your configuration
|
|
871
|
+
* @param pOptions - Configuration options for the Cardigantime instance
|
|
872
|
+
* @param pOptions.defaults - Default configuration settings
|
|
873
|
+
* @param pOptions.defaults.configDirectory - Directory to search for configuration files (required)
|
|
874
|
+
* @param pOptions.defaults.configFile - Name of the configuration file (optional, defaults to 'config.yaml')
|
|
875
|
+
* @param pOptions.defaults.isRequired - Whether the config directory must exist (optional, defaults to false)
|
|
876
|
+
* @param pOptions.defaults.encoding - File encoding for reading config files (optional, defaults to 'utf8')
|
|
877
|
+
* @param pOptions.features - Array of features to enable (optional, defaults to ['config'])
|
|
878
|
+
* @param pOptions.configShape - Zod schema shape defining your configuration structure (required)
|
|
879
|
+
* @param pOptions.logger - Custom logger implementation (optional, defaults to console logger)
|
|
880
|
+
* @returns A Cardigantime instance with methods for configure, read, validate, and setLogger
|
|
881
|
+
*
|
|
882
|
+
* @example
|
|
883
|
+
* ```typescript
|
|
884
|
+
* import { create } from '@theunwalked/cardigantime';
|
|
885
|
+
* import { z } from 'zod';
|
|
886
|
+
*
|
|
887
|
+
* const MyConfigSchema = z.object({
|
|
888
|
+
* apiKey: z.string().min(1),
|
|
889
|
+
* timeout: z.number().default(5000),
|
|
890
|
+
* debug: z.boolean().default(false),
|
|
891
|
+
* });
|
|
892
|
+
*
|
|
893
|
+
* const cardigantime = create({
|
|
894
|
+
* defaults: {
|
|
895
|
+
* configDirectory: './config',
|
|
896
|
+
* configFile: 'myapp.yaml',
|
|
897
|
+
* },
|
|
898
|
+
* configShape: MyConfigSchema.shape,
|
|
899
|
+
* });
|
|
900
|
+
* ```
|
|
901
|
+
*/ const create = (pOptions)=>{
|
|
344
902
|
const defaults = {
|
|
345
903
|
...DEFAULT_OPTIONS,
|
|
346
904
|
...pOptions.defaults
|
|
@@ -366,6 +924,9 @@ const create = (pOptions)=>{
|
|
|
366
924
|
};
|
|
367
925
|
};
|
|
368
926
|
|
|
927
|
+
exports.ArgumentError = ArgumentError;
|
|
369
928
|
exports.ConfigSchema = ConfigSchema;
|
|
929
|
+
exports.ConfigurationError = ConfigurationError;
|
|
930
|
+
exports.FileSystemError = FileSystemError;
|
|
370
931
|
exports.create = create;
|
|
371
932
|
//# sourceMappingURL=cardigantime.cjs.map
|