Files
timebank-cc-public/app/Console/Commands/ConfigMerge.php
T

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;
}
}
}