525 lines
17 KiB
PHP
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;
|
|
}
|
|
}
|
|
}
|