815 lines
28 KiB
PHP
815 lines
28 KiB
PHP
<?php
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Support\Facades\File;
|
|
use Illuminate\Support\Facades\Log;
|
|
use PhpParser\Node;
|
|
use PhpParser\ParserFactory;
|
|
|
|
/**
|
|
* Merge new keys from a config's .example file into the active config.
|
|
*
|
|
* Works on the SOURCE, not on evaluated arrays. Each new key's text is copied
|
|
* from the .example file - its comment and any env() call included - into the
|
|
* same place in the active file, and nothing else in that file changes.
|
|
*
|
|
* It used to include both files, merge the resulting arrays and write the
|
|
* whole active file back with var_export(). Including runs every env() call,
|
|
* so each merge froze the values of that moment into the file - for new keys
|
|
* and existing ones alike - and dropped every comment. On production that
|
|
* turned cert_monitor.* and health_monitor.healthcheck_url into fixed values,
|
|
* and a URL added to .env afterwards was never read (2026-10-07).
|
|
*/
|
|
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, or the TIMEBANK_CONFIG name)}
|
|
{--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',
|
|
];
|
|
|
|
/** The template for a platform config that has no .example file of its own. */
|
|
protected $platformTemplate = 'config/timebank-default.php.example';
|
|
|
|
/** Name of a backup of the platform folder's config, so --restore can put it back there. */
|
|
protected const PLATFORM_BACKUP_NAME = 'platform-config.php';
|
|
|
|
/**
|
|
* The fixed list plus this installation's own platform config (TIMEBANK_CONFIG), so a platform
|
|
* with its own name, e.g. config/myorg.php, gets new keys too. When the platform folder has a
|
|
* config.php, that is the platform config (see PlatformServiceProvider) and the one merged.
|
|
*/
|
|
protected function mergeableConfigs(): array
|
|
{
|
|
$configs = $this->mergeableConfigs;
|
|
$platform = (string) config('app.timebank_config');
|
|
|
|
if (preg_match('/^[A-Za-z0-9_-]+$/', $platform)) {
|
|
if ($folderConfig = $this->platformFolderConfig()) {
|
|
$configs[$platform] = $folderConfig;
|
|
} elseif (!isset($configs[$platform])) {
|
|
$configs[$platform] = "config/{$platform}.php";
|
|
}
|
|
}
|
|
|
|
return $configs;
|
|
}
|
|
|
|
/** platform/config.php when it exists. */
|
|
protected function platformFolderConfig(): ?string
|
|
{
|
|
$path = platform_path('config.php');
|
|
|
|
return is_file($path) ? $path : null;
|
|
}
|
|
|
|
/** A platform config without an .example of its own is merged from the default template. */
|
|
protected function examplePathFor(string $path): string
|
|
{
|
|
$platform = (string) config('app.timebank_config');
|
|
|
|
// The platform folder's config takes the template of its platform name
|
|
if ($path === $this->platformFolderConfig()) {
|
|
$named = "config/{$platform}.php.example";
|
|
|
|
return File::exists($this->absolutePath($named)) ? $named : $this->platformTemplate;
|
|
}
|
|
|
|
$own = $path . '.example';
|
|
if (File::exists($this->absolutePath($own))) {
|
|
return $own;
|
|
}
|
|
|
|
if (basename($path) === "{$platform}.php" && !isset($this->mergeableConfigs[$platform])) {
|
|
return $this->platformTemplate;
|
|
}
|
|
|
|
return $own;
|
|
}
|
|
|
|
/**
|
|
* 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 [];
|
|
}
|
|
|
|
$configs = $this->mergeableConfigs();
|
|
if (!isset($configs[$file])) {
|
|
$this->error("Unknown config file: {$file}");
|
|
$this->line('Available files: ' . implode(', ', array_keys($configs)));
|
|
return [];
|
|
}
|
|
|
|
return [$file => $configs[$file]];
|
|
}
|
|
|
|
/** Relative to the project, not to wherever the command was started. */
|
|
protected function absolutePath(string $path): string
|
|
{
|
|
return str_starts_with($path, '/') ? $path : base_path($path);
|
|
}
|
|
|
|
/**
|
|
* Merge a single config file
|
|
*/
|
|
protected function mergeConfigFile(string $name, string $path): array
|
|
{
|
|
$shownPath = $path;
|
|
$shownExample = $this->examplePathFor($path);
|
|
$path = $this->absolutePath($path);
|
|
$examplePath = $this->absolutePath($shownExample);
|
|
|
|
// Check if files exist
|
|
if (!File::exists($examplePath)) {
|
|
$this->warn("⊘ {$name}: Example file not found ({$shownExample})");
|
|
return ['hasChanges' => false, 'applied' => false, 'newKeyCount' => 0];
|
|
}
|
|
|
|
if (!File::exists($path)) {
|
|
$this->warn("⊘ {$name}: Active config not found ({$shownPath}) - run deployment first");
|
|
return ['hasChanges' => false, 'applied' => false, 'newKeyCount' => 0];
|
|
}
|
|
|
|
// Load configs
|
|
try {
|
|
$currentConfig = $this->loadConfig($path);
|
|
$exampleConfig = $this->loadConfig($examplePath);
|
|
// Taken now: comparable() reads a closure's source by line number from its file, and after
|
|
// the merge is written the file has new lines above it.
|
|
$currentComparable = $this->comparable($currentConfig);
|
|
} 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 the merge on the source text.
|
|
try {
|
|
[$mergedSource, $skipped] = $this->mergeSource(
|
|
File::get($path),
|
|
File::get($examplePath),
|
|
$this->findNewKeyPaths($currentConfig, $exampleConfig)
|
|
);
|
|
} catch (\Throwable $e) {
|
|
$this->error("✗ {$name}: Could not merge the source - {$e->getMessage()}");
|
|
return ['hasChanges' => true, 'applied' => false, 'newKeyCount' => count($newKeys)];
|
|
}
|
|
|
|
foreach ($skipped as $keyPath => $reason) {
|
|
$this->warn(" ⊘ {$keyPath}: not merged - {$reason}. Add it by hand from {$shownExample}.");
|
|
}
|
|
|
|
if (File::put($path, $mergedSource) === false) {
|
|
$this->error("✗ {$name}: Failed to write merged config");
|
|
File::copy($backupPath, $path);
|
|
return ['hasChanges' => true, 'applied' => false, 'newKeyCount' => count($newKeys)];
|
|
}
|
|
|
|
// The merged file must load, hold every merged key, and leave every
|
|
// existing value exactly as it was.
|
|
try {
|
|
$merged = $this->loadConfig($path);
|
|
$missing = array_diff(
|
|
array_map(fn ($p) => implode('.', $p), $this->findNewKeyPaths($merged, $exampleConfig)),
|
|
array_keys($skipped)
|
|
);
|
|
|
|
if (!empty($missing)) {
|
|
throw new \Exception('keys still missing after merge: ' . implode(', ', $missing));
|
|
}
|
|
|
|
if ($this->comparable($this->withoutKeys($merged, $this->findNewKeyPaths($currentConfig, $merged)))
|
|
!= $currentComparable) {
|
|
throw new \Exception('existing values changed');
|
|
}
|
|
} catch (\Throwable $e) {
|
|
$this->error("✗ {$name}: Merged config is invalid - {$e->getMessage()}");
|
|
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 ($this->descends($current[$key], $value)) {
|
|
// Both are keyed 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;
|
|
}
|
|
|
|
/**
|
|
* Whether new keys are looked for inside this value. A list (footer links, payment types, validation
|
|
* rules, mime types) is the platform's own choice as a whole: merging it by position would add the
|
|
* template's items, or keys into the wrong item (a mailto link got the template's route). A missing
|
|
* list is still added whole, by its parent. An empty [] still takes the keys of a keyed template array.
|
|
*/
|
|
protected function descends(mixed $current, mixed $example): bool
|
|
{
|
|
if (!is_array($current) || !is_array($example)) {
|
|
return false;
|
|
}
|
|
|
|
$isList = fn (array $value) => $value !== [] && array_is_list($value);
|
|
|
|
return !$isList($example) && !$isList($current);
|
|
}
|
|
|
|
/**
|
|
* 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 = $path === $this->platformFolderConfig() ? self::PLATFORM_BACKUP_NAME : 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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The new keys as paths of key segments, outermost new key only: a new
|
|
* array brings its whole content along.
|
|
*
|
|
* @return array<int, array<int, int|string>>
|
|
*/
|
|
protected function findNewKeyPaths(array $current, array $example, array $prefix = []): array
|
|
{
|
|
$paths = [];
|
|
|
|
foreach ($example as $key => $value) {
|
|
$path = [...$prefix, $key];
|
|
|
|
if (!array_key_exists($key, $current)) {
|
|
$paths[] = $path;
|
|
} elseif ($this->descends($current[$key], $value)) {
|
|
$paths = array_merge($paths, $this->findNewKeyPaths($current[$key], $value, $path));
|
|
}
|
|
}
|
|
|
|
return $paths;
|
|
}
|
|
|
|
/**
|
|
* Copy each new key's source from the example into the current source.
|
|
*
|
|
* @param array<int, array<int, int|string>> $paths From findNewKeyPaths().
|
|
* @return array{0: string, 1: array<string, string>} Merged source, and keys
|
|
* that could not be placed.
|
|
*/
|
|
protected function mergeSource(string $current, string $example, array $paths): array
|
|
{
|
|
$parser = (new ParserFactory())->createForHostVersion();
|
|
$currentRoot = $this->returnedArray($parser->parse($current));
|
|
$exampleRoot = $this->returnedArray($parser->parse($example));
|
|
|
|
if (!$currentRoot || !$exampleRoot) {
|
|
throw new \RuntimeException('a config file does not return an array literal');
|
|
}
|
|
|
|
// [position => text], applied back to front so positions stay valid.
|
|
$edits = [];
|
|
$skipped = [];
|
|
|
|
foreach ($paths as $path) {
|
|
$keyPath = implode('.', $path);
|
|
$key = end($path);
|
|
$parentPath = array_slice($path, 0, -1);
|
|
|
|
$source = $this->arrayAt($exampleRoot, $parentPath);
|
|
$target = $this->arrayAt($currentRoot, $parentPath);
|
|
$item = $source ? $this->itemFor($source, $key) : null;
|
|
|
|
if (!$item || !$target) {
|
|
$skipped[$keyPath] = $target
|
|
? 'not found in the example source'
|
|
: 'its parent is not an array literal in the active file';
|
|
continue;
|
|
}
|
|
|
|
$text = $this->itemSource($example, $item);
|
|
$close = $target->getEndFilePos();
|
|
$lineStart = strrpos(substr($current, 0, $close), "\n");
|
|
$lineStart = $lineStart === false ? 0 : $lineStart + 1;
|
|
$closingOnOwnLine = trim(substr($current, $lineStart, $close - $lineStart)) === '';
|
|
|
|
// The last existing entry needs a comma before the new one.
|
|
$last = null;
|
|
foreach ($target->items as $existing) {
|
|
if ($existing !== null) {
|
|
$last = $existing;
|
|
}
|
|
}
|
|
|
|
if ($last) {
|
|
$after = $last->getEndFilePos() + 1;
|
|
$between = preg_replace('~//[^\n]*|#[^\n]*|/\*.*?\*/~s', '', substr($current, $after, $close - $after));
|
|
|
|
if (!str_starts_with(ltrim($between), ',')) {
|
|
$edits[$after][] = ',';
|
|
}
|
|
}
|
|
|
|
if ($closingOnOwnLine) {
|
|
$edits[$lineStart][] = $text . ",\n";
|
|
} else {
|
|
// "[]" or a one-line array: open it up.
|
|
$indent = preg_match('/^[ \t]*/', substr($current, $lineStart), $m) ? $m[0] : '';
|
|
$edits[$close][] = "\n" . $text . ",\n" . $indent;
|
|
}
|
|
}
|
|
|
|
krsort($edits);
|
|
|
|
foreach ($edits as $position => $texts) {
|
|
$current = substr($current, 0, $position) . implode('', array_unique($texts)) . substr($current, $position);
|
|
}
|
|
|
|
return [$current, $skipped];
|
|
}
|
|
|
|
/** The array literal a config file returns. */
|
|
protected function returnedArray(?array $statements): ?Node\Expr\Array_
|
|
{
|
|
foreach ($statements ?? [] as $statement) {
|
|
if ($statement instanceof Node\Stmt\Return_ && $statement->expr instanceof Node\Expr\Array_) {
|
|
return $statement->expr;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/** The array literal at a key path, or null when it is not one. */
|
|
protected function arrayAt(Node\Expr\Array_ $array, array $path): ?Node\Expr\Array_
|
|
{
|
|
foreach ($path as $key) {
|
|
$item = $this->itemFor($array, $key);
|
|
|
|
if (!$item || !$item->value instanceof Node\Expr\Array_) {
|
|
return null;
|
|
}
|
|
|
|
$array = $item->value;
|
|
}
|
|
|
|
return $array;
|
|
}
|
|
|
|
/**
|
|
* The entry for a key, explicit or implicit (a list's next index). When a key occurs twice, the last
|
|
* one, as in PHP: older platform configs define profile_inactive twice, and a key merged into the
|
|
* first block was lost.
|
|
*/
|
|
protected function itemFor(Node\Expr\Array_ $array, int|string $key): ?Node\ArrayItem
|
|
{
|
|
$next = 0;
|
|
$found = null;
|
|
|
|
foreach ($array->items as $item) {
|
|
if ($item === null) {
|
|
continue;
|
|
}
|
|
|
|
if ($item->key === null) {
|
|
$itemKey = $next++;
|
|
} elseif ($item->key instanceof Node\Scalar\String_) {
|
|
$itemKey = $item->key->value;
|
|
} elseif ($item->key instanceof Node\Scalar\Int_) {
|
|
$itemKey = $item->key->value;
|
|
$next = max($next, $itemKey + 1);
|
|
} else {
|
|
// A computed key cannot be matched.
|
|
continue;
|
|
}
|
|
|
|
// PHP turns "10" into 10 as an array key; match it the same way.
|
|
if ((string) $itemKey === (string) $key) {
|
|
$found = $item;
|
|
}
|
|
}
|
|
|
|
return $found;
|
|
}
|
|
|
|
/**
|
|
* An entry's source text: from the start of its first comment's line to
|
|
* the end of its value, without a trailing comma.
|
|
*/
|
|
protected function itemSource(string $code, Node\ArrayItem $item): string
|
|
{
|
|
$start = $item->getStartFilePos();
|
|
|
|
foreach ($item->getComments() as $comment) {
|
|
// A comment after code on its line ("365, // 1 year") belongs to the previous entry; the parser
|
|
// attaches it to this one, and taking it along duplicated it.
|
|
$commentStart = $comment->getStartFilePos();
|
|
$commentLine = strrpos(substr($code, 0, $commentStart), "\n");
|
|
$commentLine = $commentLine === false ? 0 : $commentLine + 1;
|
|
if (trim(substr($code, $commentLine, $commentStart - $commentLine)) !== '') {
|
|
continue;
|
|
}
|
|
$start = min($start, $commentStart);
|
|
}
|
|
|
|
$lineStart = strrpos(substr($code, 0, $start), "\n");
|
|
$lineStart = $lineStart === false ? 0 : $lineStart + 1;
|
|
|
|
// Only whitespace before it on its line: take the indentation along.
|
|
if (trim(substr($code, $lineStart, $start - $lineStart)) === '') {
|
|
$start = $lineStart;
|
|
}
|
|
|
|
return substr($code, $start, $item->getEndFilePos() - $start + 1);
|
|
}
|
|
|
|
/**
|
|
* Load a config file fresh. include, not require_once: a file included
|
|
* twice in one run (before and after the merge) must be read again.
|
|
*/
|
|
protected function loadConfig(string $path): array
|
|
{
|
|
$config = include $path;
|
|
|
|
if (!is_array($config)) {
|
|
throw new \RuntimeException("{$path} does not return an array");
|
|
}
|
|
|
|
return $config;
|
|
}
|
|
|
|
/** A copy without the given key paths. */
|
|
protected function withoutKeys(array $config, array $paths): array
|
|
{
|
|
foreach ($paths as $path) {
|
|
$ref = &$config;
|
|
$key = array_pop($path);
|
|
|
|
foreach ($path as $segment) {
|
|
$ref = &$ref[$segment];
|
|
}
|
|
|
|
unset($ref[$key]);
|
|
unset($ref);
|
|
}
|
|
|
|
return $config;
|
|
}
|
|
|
|
/**
|
|
* Closures compare by identity, so compare them by their source text -
|
|
* not their line numbers, which a merge above them shifts.
|
|
*/
|
|
protected function comparable($value)
|
|
{
|
|
if (is_array($value)) {
|
|
return array_map(fn ($v) => $this->comparable($v), $value);
|
|
}
|
|
|
|
if ($value instanceof \Closure) {
|
|
$rf = new \ReflectionFunction($value);
|
|
$lines = file($rf->getFileName());
|
|
|
|
return 'closure:' . trim(implode('', array_slice(
|
|
$lines,
|
|
$rf->getStartLine() - 1,
|
|
$rf->getEndLine() - $rf->getStartLine() + 1
|
|
)));
|
|
}
|
|
|
|
return $value;
|
|
}
|
|
|
|
/**
|
|
* 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';
|
|
}
|
|
if (is_object($value)) {
|
|
return $value instanceof \Closure ? 'function' : get_class($value);
|
|
}
|
|
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 = $selected['file'] === self::PLATFORM_BACKUP_NAME
|
|
? platform_path('config.php')
|
|
: base_path("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;
|
|
}
|
|
}
|
|
}
|