Files
timebank-cc-public/app/Console/Commands/ConfigMerge.php
Ronald Huynen 2547717edb Initial commit
2026-03-23 21:37:59 +01:00

525 lines
17 KiB
PHP

<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
class ConfigMerge extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'config:merge
{file? : Config file to merge (themes, timebank-default, timebank_cc)}
{--all : Merge all config files}
{--dry-run : Show what would change without applying}
{--force : Skip confirmation prompts}
{--restore : Restore config from backup}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Merge new configuration keys from .example files into active configs';
/**
* Config files that can be merged
*
* @var array
*/
protected $mergeableConfigs = [
'themes' => '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(" <fg=green>+</> {$keyPath}");
$this->line(" <fg=gray>" . $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 = "<?php\n\n";
$content .= "use Illuminate\\Validation\\Rule;\n\n";
$content .= "return " . $this->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("<fg=yellow>{$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;
}
}
}