@theunwalked/cardigantime 0.0.9 → 0.0.11

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.
@@ -176,6 +176,10 @@ class ArgumentError extends Error {
176
176
  throw error;
177
177
  }
178
178
  }, validatedDefaultDir);
179
+ // Add the init config option
180
+ retCommand = retCommand.option('--init-config', 'Generate initial configuration file and exit');
181
+ // Add the check config option
182
+ retCommand = retCommand.option('--check-config', 'Display resolved configuration with source tracking and exit');
179
183
  return retCommand;
180
184
  };
181
185
 
@@ -1051,6 +1055,263 @@ const create$1 = (params)=>{
1051
1055
  }
1052
1056
  return rawFileConfig;
1053
1057
  }
1058
+ /**
1059
+ * Recursively tracks the source of configuration values from hierarchical loading.
1060
+ *
1061
+ * @param config - The configuration object to track
1062
+ * @param sourcePath - Path to the configuration file
1063
+ * @param level - Hierarchical level
1064
+ * @param prefix - Current object path prefix for nested values
1065
+ * @param tracker - The tracker object to populate
1066
+ */ function trackConfigSources(config, sourcePath, level, prefix = '', tracker = {}) {
1067
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
1068
+ // For primitives and arrays, track the entire value
1069
+ tracker[prefix] = {
1070
+ value: config,
1071
+ sourcePath,
1072
+ level,
1073
+ sourceLabel: `Level ${level}: ${path__namespace.basename(path__namespace.dirname(sourcePath))}`
1074
+ };
1075
+ return tracker;
1076
+ }
1077
+ // For objects, recursively track each property
1078
+ for (const [key, value] of Object.entries(config)){
1079
+ const fieldPath = prefix ? `${prefix}.${key}` : key;
1080
+ trackConfigSources(value, sourcePath, level, fieldPath, tracker);
1081
+ }
1082
+ return tracker;
1083
+ }
1084
+ /**
1085
+ * Merges multiple configuration source trackers with proper precedence.
1086
+ * Lower level numbers have higher precedence.
1087
+ *
1088
+ * @param trackers - Array of trackers from different config sources
1089
+ * @returns Merged tracker with proper precedence
1090
+ */ function mergeConfigTrackers(trackers) {
1091
+ const merged = {};
1092
+ for (const tracker of trackers){
1093
+ for (const [key, info] of Object.entries(tracker)){
1094
+ // Only update if we don't have this key yet, or if this source has higher precedence (lower level)
1095
+ if (!merged[key] || info.level < merged[key].level) {
1096
+ merged[key] = info;
1097
+ }
1098
+ }
1099
+ }
1100
+ return merged;
1101
+ }
1102
+ /**
1103
+ * Formats a configuration value for display, handling different types appropriately.
1104
+ *
1105
+ * @param value - The configuration value to format
1106
+ * @returns Formatted string representation
1107
+ */ function formatConfigValue(value) {
1108
+ if (value === null) return 'null';
1109
+ if (value === undefined) return 'undefined';
1110
+ if (typeof value === 'string') return `"${value}"`;
1111
+ if (typeof value === 'boolean') return value.toString();
1112
+ if (typeof value === 'number') return value.toString();
1113
+ if (Array.isArray(value)) {
1114
+ if (value.length === 0) return '[]';
1115
+ if (value.length <= 3) {
1116
+ return `[${value.map(formatConfigValue).join(', ')}]`;
1117
+ }
1118
+ return `[${value.slice(0, 2).map(formatConfigValue).join(', ')}, ... (${value.length} items)]`;
1119
+ }
1120
+ if (typeof value === 'object') {
1121
+ const keys = Object.keys(value);
1122
+ if (keys.length === 0) return '{}';
1123
+ if (keys.length <= 2) {
1124
+ return `{${keys.slice(0, 2).join(', ')}}`;
1125
+ }
1126
+ return `{${keys.slice(0, 2).join(', ')}, ... (${keys.length} keys)}`;
1127
+ }
1128
+ return String(value);
1129
+ }
1130
+ /**
1131
+ * Displays configuration with source tracking in a git blame-like format.
1132
+ *
1133
+ * @param config - The resolved configuration object
1134
+ * @param tracker - Configuration source tracker
1135
+ * @param discoveredDirs - Array of discovered configuration directories
1136
+ * @param logger - Logger instance for output
1137
+ */ function displayConfigWithSources(config, tracker, discoveredDirs, logger) {
1138
+ logger.info('\n' + '='.repeat(80));
1139
+ logger.info('CONFIGURATION SOURCE ANALYSIS');
1140
+ logger.info('='.repeat(80));
1141
+ // Display discovered configuration hierarchy
1142
+ logger.info('\nDISCOVERED CONFIGURATION HIERARCHY:');
1143
+ if (discoveredDirs.length === 0) {
1144
+ logger.info(' No configuration directories found in hierarchy');
1145
+ } else {
1146
+ discoveredDirs.sort((a, b)=>a.level - b.level) // Sort by precedence (lower level = higher precedence)
1147
+ .forEach((dir)=>{
1148
+ const precedence = dir.level === 0 ? '(highest precedence)' : dir.level === Math.max(...discoveredDirs.map((d)=>d.level)) ? '(lowest precedence)' : '';
1149
+ logger.info(` Level ${dir.level}: ${dir.path} ${precedence}`);
1150
+ });
1151
+ }
1152
+ // Display resolved configuration with sources
1153
+ logger.info('\nRESOLVED CONFIGURATION WITH SOURCES:');
1154
+ logger.info('Format: [Source] key: value\n');
1155
+ const sortedKeys = Object.keys(tracker).sort();
1156
+ const maxKeyLength = Math.max(...sortedKeys.map((k)=>k.length), 20);
1157
+ const maxSourceLength = Math.max(...Object.values(tracker).map((info)=>info.sourceLabel.length), 25);
1158
+ for (const key of sortedKeys){
1159
+ const info = tracker[key];
1160
+ const paddedKey = key.padEnd(maxKeyLength);
1161
+ const paddedSource = info.sourceLabel.padEnd(maxSourceLength);
1162
+ const formattedValue = formatConfigValue(info.value);
1163
+ logger.info(`[${paddedSource}] ${paddedKey}: ${formattedValue}`);
1164
+ }
1165
+ // Display summary
1166
+ logger.info('\n' + '-'.repeat(80));
1167
+ logger.info('SUMMARY:');
1168
+ logger.info(` Total configuration keys: ${Object.keys(tracker).length}`);
1169
+ logger.info(` Configuration sources: ${discoveredDirs.length}`);
1170
+ // Count values by source
1171
+ const sourceCount = {};
1172
+ for (const info of Object.values(tracker)){
1173
+ sourceCount[info.sourceLabel] = (sourceCount[info.sourceLabel] || 0) + 1;
1174
+ }
1175
+ logger.info(' Values by source:');
1176
+ for (const [source, count] of Object.entries(sourceCount)){
1177
+ logger.info(` ${source}: ${count} value(s)`);
1178
+ }
1179
+ logger.info('='.repeat(80));
1180
+ }
1181
+ /**
1182
+ * Checks and displays the resolved configuration with detailed source tracking.
1183
+ *
1184
+ * This function provides a git blame-like view of configuration resolution,
1185
+ * showing which file and hierarchical level contributed each configuration value.
1186
+ *
1187
+ * @template T - The Zod schema shape type for configuration validation
1188
+ * @param args - Parsed command-line arguments
1189
+ * @param options - Cardigantime options with defaults, schema, and logger
1190
+ * @returns Promise that resolves when the configuration check is complete
1191
+ *
1192
+ * @example
1193
+ * ```typescript
1194
+ * await checkConfig(cliArgs, {
1195
+ * defaults: { configDirectory: './config', configFile: 'app.yaml' },
1196
+ * configShape: MySchema.shape,
1197
+ * logger: console,
1198
+ * features: ['config', 'hierarchical']
1199
+ * });
1200
+ * // Outputs detailed configuration source analysis
1201
+ * ```
1202
+ */ const checkConfig = async (args, options)=>{
1203
+ var _options_defaults, _options_defaults_pathResolution;
1204
+ const logger = options.logger;
1205
+ logger.info('Starting configuration check...');
1206
+ const rawConfigDir = args.configDirectory || ((_options_defaults = options.defaults) === null || _options_defaults === void 0 ? void 0 : _options_defaults.configDirectory);
1207
+ if (!rawConfigDir) {
1208
+ throw new Error('Configuration directory must be specified');
1209
+ }
1210
+ const resolvedConfigDir = validateConfigDirectory$1(rawConfigDir);
1211
+ logger.verbose(`Resolved config directory: ${resolvedConfigDir}`);
1212
+ let rawFileConfig = {};
1213
+ let discoveredDirs = [];
1214
+ let tracker = {};
1215
+ // Check if hierarchical configuration discovery is enabled
1216
+ if (options.features.includes('hierarchical')) {
1217
+ logger.verbose('Using hierarchical configuration discovery for source tracking');
1218
+ try {
1219
+ var _options_defaults_pathResolution1, _options_defaults_pathResolution2;
1220
+ // Extract the config directory name from the path for hierarchical discovery
1221
+ const configDirName = path__namespace.basename(resolvedConfigDir);
1222
+ const startingDir = path__namespace.dirname(resolvedConfigDir);
1223
+ logger.debug(`Using hierarchical discovery: configDirName=${configDirName}, startingDir=${startingDir}`);
1224
+ const hierarchicalResult = await loadHierarchicalConfig({
1225
+ configDirName,
1226
+ configFileName: options.defaults.configFile,
1227
+ startingDir,
1228
+ encoding: options.defaults.encoding,
1229
+ logger,
1230
+ pathFields: (_options_defaults_pathResolution1 = options.defaults.pathResolution) === null || _options_defaults_pathResolution1 === void 0 ? void 0 : _options_defaults_pathResolution1.pathFields,
1231
+ resolvePathArray: (_options_defaults_pathResolution2 = options.defaults.pathResolution) === null || _options_defaults_pathResolution2 === void 0 ? void 0 : _options_defaults_pathResolution2.resolvePathArray,
1232
+ fieldOverlaps: options.defaults.fieldOverlaps
1233
+ });
1234
+ rawFileConfig = hierarchicalResult.config;
1235
+ discoveredDirs = hierarchicalResult.discoveredDirs;
1236
+ // Build detailed source tracking by re-loading each config individually
1237
+ const trackers = [];
1238
+ // Sort by level (highest level first = lowest precedence first) to match merge order
1239
+ const sortedDirs = [
1240
+ ...discoveredDirs
1241
+ ].sort((a, b)=>b.level - a.level);
1242
+ for (const dir of sortedDirs){
1243
+ const storage = create$1({
1244
+ log: logger.debug
1245
+ });
1246
+ const configFilePath = path__namespace.join(dir.path, options.defaults.configFile);
1247
+ try {
1248
+ const exists = await storage.exists(configFilePath);
1249
+ if (!exists) continue;
1250
+ const isReadable = await storage.isFileReadable(configFilePath);
1251
+ if (!isReadable) continue;
1252
+ const yamlContent = await storage.readFile(configFilePath, options.defaults.encoding);
1253
+ const parsedYaml = yaml__namespace.load(yamlContent);
1254
+ if (parsedYaml !== null && typeof parsedYaml === 'object') {
1255
+ const levelTracker = trackConfigSources(parsedYaml, configFilePath, dir.level);
1256
+ trackers.push(levelTracker);
1257
+ }
1258
+ } catch (error) {
1259
+ logger.debug(`Error loading config for source tracking from ${configFilePath}: ${error.message}`);
1260
+ }
1261
+ }
1262
+ // Merge trackers with proper precedence
1263
+ tracker = mergeConfigTrackers(trackers);
1264
+ if (hierarchicalResult.errors.length > 0) {
1265
+ logger.warn('Configuration loading warnings:');
1266
+ hierarchicalResult.errors.forEach((error)=>logger.warn(` ${error}`));
1267
+ }
1268
+ } catch (error) {
1269
+ logger.error('Hierarchical configuration loading failed: ' + (error.message || 'Unknown error'));
1270
+ logger.verbose('Falling back to single directory configuration loading');
1271
+ // Fall back to single directory mode for source tracking
1272
+ rawFileConfig = await loadSingleDirectoryConfig(resolvedConfigDir, options, logger);
1273
+ const configFilePath = path__namespace.join(resolvedConfigDir, options.defaults.configFile);
1274
+ tracker = trackConfigSources(rawFileConfig, configFilePath, 0);
1275
+ discoveredDirs = [
1276
+ {
1277
+ path: resolvedConfigDir,
1278
+ level: 0
1279
+ }
1280
+ ];
1281
+ }
1282
+ } else {
1283
+ // Use traditional single directory configuration loading
1284
+ logger.verbose('Using single directory configuration loading for source tracking');
1285
+ rawFileConfig = await loadSingleDirectoryConfig(resolvedConfigDir, options, logger);
1286
+ const configFilePath = path__namespace.join(resolvedConfigDir, options.defaults.configFile);
1287
+ tracker = trackConfigSources(rawFileConfig, configFilePath, 0);
1288
+ discoveredDirs = [
1289
+ {
1290
+ path: resolvedConfigDir,
1291
+ level: 0
1292
+ }
1293
+ ];
1294
+ }
1295
+ // Apply path resolution if configured (this doesn't change source tracking)
1296
+ let processedConfig = rawFileConfig;
1297
+ if ((_options_defaults_pathResolution = options.defaults.pathResolution) === null || _options_defaults_pathResolution === void 0 ? void 0 : _options_defaults_pathResolution.pathFields) {
1298
+ processedConfig = resolveConfigPaths(rawFileConfig, resolvedConfigDir, options.defaults.pathResolution.pathFields, options.defaults.pathResolution.resolvePathArray || []);
1299
+ }
1300
+ // Build final configuration including built-in values
1301
+ const finalConfig = clean({
1302
+ ...processedConfig,
1303
+ configDirectory: resolvedConfigDir
1304
+ });
1305
+ // Add built-in configuration to tracker
1306
+ tracker['configDirectory'] = {
1307
+ value: resolvedConfigDir,
1308
+ sourcePath: 'built-in',
1309
+ level: -1,
1310
+ sourceLabel: 'Built-in (runtime)'
1311
+ };
1312
+ // Display the configuration with source information
1313
+ displayConfigWithSources(finalConfig, tracker, discoveredDirs, logger);
1314
+ };
1054
1315
 
1055
1316
  /**
1056
1317
  * Error thrown when configuration validation fails
@@ -1363,6 +1624,110 @@ class ConfigurationError extends Error {
1363
1624
  return;
1364
1625
  };
1365
1626
 
1627
+ /**
1628
+ * Extracts default values from a Zod schema recursively.
1629
+ *
1630
+ * This function traverses a Zod schema and builds an object containing
1631
+ * all the default values defined in the schema. It handles:
1632
+ * - ZodDefault types with explicit default values
1633
+ * - ZodOptional/ZodNullable types by unwrapping them
1634
+ * - ZodObject types by recursively processing their shape
1635
+ * - ZodArray types by providing an empty array as default
1636
+ *
1637
+ * @param schema - The Zod schema to extract defaults from
1638
+ * @returns An object containing all default values from the schema
1639
+ *
1640
+ * @example
1641
+ * ```typescript
1642
+ * const schema = z.object({
1643
+ * name: z.string().default('app'),
1644
+ * port: z.number().default(3000),
1645
+ * debug: z.boolean().default(false),
1646
+ * database: z.object({
1647
+ * host: z.string().default('localhost'),
1648
+ * port: z.number().default(5432)
1649
+ * })
1650
+ * });
1651
+ *
1652
+ * const defaults = extractSchemaDefaults(schema);
1653
+ * // Returns: { name: 'app', port: 3000, debug: false, database: { host: 'localhost', port: 5432 } }
1654
+ * ```
1655
+ */ const extractSchemaDefaults = (schema)=>{
1656
+ // Handle ZodDefault - extract the default value
1657
+ if (schema._def && schema._def.typeName === 'ZodDefault') {
1658
+ const defaultSchema = schema;
1659
+ return defaultSchema._def.defaultValue();
1660
+ }
1661
+ // Handle ZodOptional and ZodNullable - unwrap and recurse
1662
+ if (schema._def && (schema._def.typeName === 'ZodOptional' || schema._def.typeName === 'ZodNullable')) {
1663
+ const unwrappable = schema;
1664
+ return extractSchemaDefaults(unwrappable.unwrap());
1665
+ }
1666
+ // Handle ZodObject - recursively process shape
1667
+ if (schema._def && schema._def.typeName === 'ZodObject') {
1668
+ const objectSchema = schema;
1669
+ const result = {};
1670
+ for (const [key, subschema] of Object.entries(objectSchema.shape)){
1671
+ const defaultValue = extractSchemaDefaults(subschema);
1672
+ if (defaultValue !== undefined) {
1673
+ result[key] = defaultValue;
1674
+ }
1675
+ }
1676
+ return Object.keys(result).length > 0 ? result : undefined;
1677
+ }
1678
+ // Handle ZodArray - provide empty array as default
1679
+ if (schema._def && schema._def.typeName === 'ZodArray') {
1680
+ const arraySchema = schema;
1681
+ const elementDefaults = extractSchemaDefaults(arraySchema.element);
1682
+ // Return an empty array, or an array with one example element if it has defaults
1683
+ return elementDefaults !== undefined ? [
1684
+ elementDefaults
1685
+ ] : [];
1686
+ }
1687
+ // Handle ZodRecord - provide empty object as default
1688
+ if (schema._def && schema._def.typeName === 'ZodRecord') {
1689
+ return {};
1690
+ }
1691
+ // For other types, return undefined (no default available)
1692
+ return undefined;
1693
+ };
1694
+ /**
1695
+ * Generates a complete configuration object with all default values populated.
1696
+ *
1697
+ * This function combines the base ConfigSchema with a user-provided schema shape
1698
+ * and extracts all available default values to create a complete configuration
1699
+ * example that can be serialized to YAML.
1700
+ *
1701
+ * @template T - The Zod schema shape type
1702
+ * @param configShape - The user's configuration schema shape
1703
+ * @param configDirectory - The configuration directory to include in the defaults
1704
+ * @returns An object containing all default values suitable for YAML serialization
1705
+ *
1706
+ * @example
1707
+ * ```typescript
1708
+ * const shape = z.object({
1709
+ * apiKey: z.string().describe('Your API key'),
1710
+ * timeout: z.number().default(5000).describe('Request timeout in milliseconds'),
1711
+ * features: z.array(z.string()).default(['auth', 'logging'])
1712
+ * }).shape;
1713
+ *
1714
+ * const config = generateDefaultConfig(shape, './config');
1715
+ * // Returns: { timeout: 5000, features: ['auth', 'logging'] }
1716
+ * // Note: apiKey is not included since it has no default
1717
+ * ```
1718
+ */ const generateDefaultConfig = (configShape, configDirectory)=>{
1719
+ // Create the full schema by combining base and user schema
1720
+ const fullSchema = zod.z.object({
1721
+ ...configShape
1722
+ });
1723
+ // Extract defaults from the full schema
1724
+ const defaults = extractSchemaDefaults(fullSchema);
1725
+ // Don't include configDirectory in the generated file since it's runtime-specific
1726
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
1727
+ const { configDirectory: _, ...configDefaults } = defaults || {};
1728
+ return configDefaults || {};
1729
+ };
1730
+
1366
1731
  /**
1367
1732
  * Creates a new Cardigantime instance for configuration management.
1368
1733
  *
@@ -1438,11 +1803,76 @@ class ConfigurationError extends Error {
1438
1803
  logger = pLogger;
1439
1804
  options.logger = pLogger;
1440
1805
  };
1806
+ const generateConfig = async (configDirectory)=>{
1807
+ const targetDir = configDirectory || options.defaults.configDirectory;
1808
+ const configFile = options.defaults.configFile;
1809
+ const encoding = options.defaults.encoding;
1810
+ logger.verbose(`Generating configuration file in: ${targetDir}`);
1811
+ // Create storage utility
1812
+ const storage = create$1({
1813
+ log: logger.debug
1814
+ });
1815
+ // Ensure the target directory exists
1816
+ const dirExists = await storage.exists(targetDir);
1817
+ if (!dirExists) {
1818
+ logger.info(`Creating configuration directory: ${targetDir}`);
1819
+ try {
1820
+ await storage.createDirectory(targetDir);
1821
+ } catch (error) {
1822
+ throw FileSystemError.directoryCreationFailed(targetDir, error);
1823
+ }
1824
+ }
1825
+ // Check if directory is writable
1826
+ const isWritable = await storage.isDirectoryWritable(targetDir);
1827
+ if (!isWritable) {
1828
+ throw new FileSystemError('not_writable', 'Configuration directory is not writable', targetDir, 'directory_write');
1829
+ }
1830
+ // Build the full config file path
1831
+ const configFilePath = path__namespace.join(targetDir, configFile);
1832
+ // Generate default configuration
1833
+ const defaultConfig = generateDefaultConfig(options.configShape);
1834
+ // Convert to YAML with nice formatting
1835
+ const yamlContent = yaml__namespace.dump(defaultConfig, {
1836
+ indent: 2,
1837
+ lineWidth: 120,
1838
+ noRefs: true,
1839
+ sortKeys: true
1840
+ });
1841
+ // Add header comment to the YAML file
1842
+ const header = `# Configuration file generated by Cardigantime
1843
+ # This file contains default values for your application configuration.
1844
+ # Modify the values below to customize your application's behavior.
1845
+ #
1846
+ # For more information about Cardigantime configuration:
1847
+ # https://github.com/SemicolonAmbulance/cardigantime
1848
+
1849
+ `;
1850
+ const finalContent = header + yamlContent;
1851
+ // Check if config file already exists
1852
+ const configExists = await storage.exists(configFilePath);
1853
+ if (configExists) {
1854
+ logger.warn(`Configuration file already exists: ${configFilePath}`);
1855
+ logger.warn('This file was not overwritten, but here is what the default configuration looks like if you want to copy it:');
1856
+ logger.info('\n' + '='.repeat(60));
1857
+ logger.info(finalContent.trim());
1858
+ logger.info('='.repeat(60));
1859
+ return;
1860
+ }
1861
+ // Write the configuration file
1862
+ try {
1863
+ await storage.writeFile(configFilePath, finalContent, encoding);
1864
+ logger.info(`Configuration file generated successfully: ${configFilePath}`);
1865
+ } catch (error) {
1866
+ throw FileSystemError.operationFailed('write configuration file', configFilePath, error);
1867
+ }
1868
+ };
1441
1869
  return {
1442
1870
  setLogger,
1443
1871
  configure: (command)=>configure(command, options),
1444
1872
  validate: (config)=>validate(config, options),
1445
- read: (args)=>read(args, options)
1873
+ read: (args)=>read(args, options),
1874
+ generateConfig,
1875
+ checkConfig: (args)=>checkConfig(args, options)
1446
1876
  };
1447
1877
  };
1448
1878