Initial commit
This commit is contained in:
524
app/Console/Commands/ConfigMerge.php
Normal file
524
app/Console/Commands/ConfigMerge.php
Normal file
@@ -0,0 +1,524 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user