'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(" + {$keyPath}"); $this->line(" " . $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> */ 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> $paths From findNewKeyPaths(). * @return array{0: string, 1: array} 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("{$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; } } }