'config/themes.php', 'timebank-default' => 'config/timebank-default.php', 'timebank_cc' => 'config/timebank_cc.php', ]; /** * Execute the console command. */ public function handle() { // Handle restore option if ($this->option('restore')) { return $this->restoreFromBackup(); } // Determine which files to merge $filesToMerge = $this->getFilesToMerge(); if (empty($filesToMerge)) { $this->error('No valid config files specified.'); return 1; } $anyChanges = false; $results = []; foreach ($filesToMerge as $name => $path) { $result = $this->mergeConfigFile($name, $path); $results[$name] = $result; if ($result['hasChanges']) { $anyChanges = true; } } // Summary if (!$this->option('dry-run')) { $this->newLine(); $this->line('═══════════════════════════════════════════════════════'); $this->info('Config Merge Summary'); $this->line('═══════════════════════════════════════════════════════'); foreach ($results as $name => $result) { if ($result['hasChanges'] && $result['applied']) { $this->info("✓ {$name}: {$result['newKeyCount']} new keys merged"); } elseif ($result['hasChanges'] && !$result['applied']) { $this->warn("⊘ {$name}: {$result['newKeyCount']} new keys available (not applied)"); } else { $this->comment(" {$name}: Up to date"); } } } return $anyChanges ? 0 : 0; } /** * Get the list of files to merge */ protected function getFilesToMerge(): array { if ($this->option('all')) { return $this->mergeableConfigs; } $file = $this->argument('file'); if (!$file) { $this->error('Please specify a config file or use --all'); return []; } if (!isset($this->mergeableConfigs[$file])) { $this->error("Unknown config file: {$file}"); $this->line('Available files: ' . implode(', ', array_keys($this->mergeableConfigs))); return []; } return [$file => $this->mergeableConfigs[$file]]; } /** * Merge a single config file */ protected function mergeConfigFile(string $name, string $path): array { $examplePath = $path . '.example'; // Check if files exist if (!File::exists($examplePath)) { $this->warn("⊘ {$name}: Example file not found ({$examplePath})"); return ['hasChanges' => false, 'applied' => false, 'newKeyCount' => 0]; } if (!File::exists($path)) { $this->warn("⊘ {$name}: Active config not found ({$path}) - run deployment first"); return ['hasChanges' => false, 'applied' => false, 'newKeyCount' => 0]; } // Load configs try { $currentConfig = include $path; $exampleConfig = include $examplePath; } catch (\Throwable $e) { $this->error("✗ {$name}: Failed to load config - {$e->getMessage()}"); return ['hasChanges' => false, 'applied' => false, 'newKeyCount' => 0]; } if (!is_array($currentConfig) || !is_array($exampleConfig)) { $this->error("✗ {$name}: Invalid config format"); return ['hasChanges' => false, 'applied' => false, 'newKeyCount' => 0]; } // Find new keys $newKeys = $this->findNewKeys($currentConfig, $exampleConfig); if (empty($newKeys)) { $this->comment(" {$name}: No new keys found"); return ['hasChanges' => false, 'applied' => false, 'newKeyCount' => 0]; } // Display changes $this->newLine(); $this->line("─────────────────────────────────────────────────────"); $this->info("Config: {$name}"); $this->line("─────────────────────────────────────────────────────"); $this->warn("Found " . count($newKeys) . " new configuration key(s):"); $this->newLine(); foreach ($newKeys as $keyPath => $value) { $this->line(" + {$keyPath}"); $this->line(" " . $this->formatValue($value) . ""); } // Dry run - stop here if ($this->option('dry-run')) { return ['hasChanges' => true, 'applied' => false, 'newKeyCount' => count($newKeys)]; } // Confirm merge if (!$this->option('force')) { if (!$this->confirm("Merge these keys into {$name}?", false)) { $this->comment("Skipped {$name}"); return ['hasChanges' => true, 'applied' => false, 'newKeyCount' => count($newKeys)]; } } // Create backup $backupPath = $this->createBackup($path); if (!$backupPath) { $this->error("✗ {$name}: Failed to create backup"); return ['hasChanges' => true, 'applied' => false, 'newKeyCount' => count($newKeys)]; } $this->line(" Backup created: {$backupPath}"); // Perform merge $mergedConfig = $this->deepMergeNewKeys($currentConfig, $exampleConfig); // Write merged config if (!$this->writeConfig($path, $mergedConfig)) { $this->error("✗ {$name}: Failed to write merged config"); // Restore from backup File::copy($backupPath, $path); return ['hasChanges' => true, 'applied' => false, 'newKeyCount' => count($newKeys)]; } // Validate merged config can be loaded try { $testLoad = include $path; if (!is_array($testLoad)) { throw new \Exception('Config does not return an array'); } } catch (\Throwable $e) { $this->error("✗ {$name}: Merged config is invalid - {$e->getMessage()}"); // Restore from backup File::copy($backupPath, $path); return ['hasChanges' => true, 'applied' => false, 'newKeyCount' => count($newKeys)]; } // Log the merge Log::channel('single')->info('Config merged', [ 'file' => $name, 'path' => $path, 'new_keys' => array_keys($newKeys), 'new_key_count' => count($newKeys), 'backup' => $backupPath, ]); $this->info("✓ {$name}: Successfully merged " . count($newKeys) . " new keys"); return ['hasChanges' => true, 'applied' => true, 'newKeyCount' => count($newKeys)]; } /** * Find new keys in example config that don't exist in current config */ protected function findNewKeys(array $current, array $example, string $prefix = ''): array { $newKeys = []; foreach ($example as $key => $value) { $keyPath = $prefix ? "{$prefix}.{$key}" : $key; if (!array_key_exists($key, $current)) { // This is a new key $newKeys[$keyPath] = $value; } elseif (is_array($value) && is_array($current[$key])) { // Both are arrays - recurse to find nested new keys $nestedKeys = $this->findNewKeys($current[$key], $value, $keyPath); $newKeys = array_merge($newKeys, $nestedKeys); } // Otherwise key exists and is not both arrays - skip (preserve current value) } return $newKeys; } /** * Perform deep merge of new keys only */ protected function deepMergeNewKeys(array $current, array $example): array { $result = $current; foreach ($example as $key => $value) { if (!array_key_exists($key, $current)) { // New key - add it $result[$key] = $value; } elseif (is_array($value) && is_array($current[$key])) { // Both are arrays - recurse $result[$key] = $this->deepMergeNewKeys($current[$key], $value); } // Otherwise preserve current value } return $result; } /** * Create a backup of the config file */ protected function createBackup(string $path): ?string { $backupDir = storage_path('config-backups'); if (!File::isDirectory($backupDir)) { File::makeDirectory($backupDir, 0755, true); } $filename = basename($path); $timestamp = date('Y-m-d_His'); $backupPath = "{$backupDir}/{$filename}.backup.{$timestamp}"; if (File::copy($path, $backupPath)) { // Cleanup old backups (keep last 5) $this->cleanupOldBackups($backupDir, $filename); return $backupPath; } return null; } /** * Cleanup old backup files */ protected function cleanupOldBackups(string $backupDir, string $filename): void { $pattern = "{$backupDir}/{$filename}.backup.*"; $backups = glob($pattern); if (count($backups) > 5) { // Sort by modification time (oldest first) usort($backups, function ($a, $b) { return filemtime($a) - filemtime($b); }); // Delete oldest backups, keeping last 5 $toDelete = array_slice($backups, 0, count($backups) - 5); foreach ($toDelete as $backup) { File::delete($backup); } } } /** * Write config array to file */ protected function writeConfig(string $path, array $config): bool { $content = "varExportPretty($config, 0) . ";\n"; return File::put($path, $content) !== false; } /** * Pretty print PHP array */ protected function varExportPretty($var, int $indent = 0): string { $indentStr = str_repeat(' ', $indent * 4); $nextIndentStr = str_repeat(' ', ($indent + 1) * 4); if (!is_array($var)) { if (is_string($var)) { return "'" . addcslashes($var, "'\\") . "'"; } if (is_bool($var)) { return $var ? 'true' : 'false'; } if (is_null($var)) { return 'null'; } return var_export($var, true); } if (empty($var)) { return '[]'; } $output = "[\n"; foreach ($var as $key => $value) { $output .= $nextIndentStr; // Format key if (is_int($key)) { $output .= $key; } else { $output .= "'" . addcslashes($key, "'\\") . "'"; } $output .= ' => '; // Format value if (is_array($value)) { $output .= $this->varExportPretty($value, $indent + 1); } elseif (is_string($value)) { $output .= "'" . addcslashes($value, "'\\") . "'"; } elseif (is_bool($value)) { $output .= $value ? 'true' : 'false'; } elseif (is_null($value)) { $output .= 'null'; } elseif ($value instanceof \Closure) { // Handle closures - just export as string representation $output .= var_export($value, true); } else { $output .= var_export($value, true); } $output .= ",\n"; } $output .= $indentStr . ']'; return $output; } /** * Format a value for display */ protected function formatValue($value): string { if (is_array($value)) { if (empty($value)) { return '[]'; } $preview = array_slice($value, 0, 3, true); $formatted = []; foreach ($preview as $k => $v) { $formatted[] = is_int($k) ? $this->formatValue($v) : "{$k}: " . $this->formatValue($v); } $result = '[' . implode(', ', $formatted); if (count($value) > 3) { $result .= ', ... +' . (count($value) - 3) . ' more'; } return $result . ']'; } if (is_string($value)) { return strlen($value) > 50 ? '"' . substr($value, 0, 47) . '..."' : "\"{$value}\""; } if (is_bool($value)) { return $value ? 'true' : 'false'; } if (is_null($value)) { return 'null'; } return (string) $value; } /** * Restore config from backup */ protected function restoreFromBackup(): int { $backupDir = storage_path('config-backups'); if (!File::isDirectory($backupDir)) { $this->error('No backups found'); return 1; } // List available backups $backups = File::glob("{$backupDir}/*.backup.*"); if (empty($backups)) { $this->error('No backups found'); return 1; } // Group by config file $grouped = []; foreach ($backups as $backup) { $basename = basename($backup); preg_match('/^(.+?)\.backup\.(.+)$/', $basename, $matches); if ($matches) { $configFile = $matches[1]; $timestamp = $matches[2]; $grouped[$configFile][] = [ 'path' => $backup, 'timestamp' => $timestamp, 'time' => filemtime($backup), ]; } } // Sort by time (newest first) foreach ($grouped as &$backupList) { usort($backupList, function ($a, $b) { return $b['time'] - $a['time']; }); } // Display available backups $this->info('Available backups:'); $this->newLine(); $options = []; $i = 1; foreach ($grouped as $configFile => $backupList) { $this->line("{$configFile}"); foreach ($backupList as $backup) { $date = date('Y-m-d H:i:s', $backup['time']); $this->line(" {$i}. {$date}"); $options[$i] = [ 'file' => $configFile, 'path' => $backup['path'], ]; $i++; } $this->newLine(); } // Get user choice $choice = $this->ask('Enter backup number to restore (or 0 to cancel)'); if ($choice === '0' || !isset($options[(int)$choice])) { $this->comment('Restore cancelled'); return 0; } $selected = $options[(int)$choice]; $configPath = "config/{$selected['file']}"; if (!File::exists($configPath)) { $this->error("Config file not found: {$configPath}"); return 1; } if (!$this->confirm("Restore {$selected['file']} from backup?", false)) { $this->comment('Restore cancelled'); return 0; } // Create backup of current config before restoring $currentBackupPath = $this->createBackup($configPath); if ($currentBackupPath) { $this->line("Current config backed up to: {$currentBackupPath}"); } // Restore if (File::copy($selected['path'], $configPath)) { $this->info("✓ Successfully restored {$selected['file']}"); return 0; } else { $this->error("✗ Failed to restore config"); return 1; } } }