mirror of
https://github.com/HDInnovations/UNIT3D-Community-Edition.git
synced 2026-03-14 17:10:19 -05:00
651 lines
19 KiB
PHP
651 lines
19 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* NOTICE OF LICENSE.
|
|
*
|
|
* UNIT3D Community Edition is open-sourced software licensed under the GNU Affero General Public License v3.0
|
|
* The details is bundled with this project in the file LICENSE.txt.
|
|
*
|
|
* @project UNIT3D Community Edition
|
|
*
|
|
* @author HDVinnie <hdinnovations@protonmail.com>
|
|
* @license https://www.gnu.org/licenses/agpl-3.0.en.html/ GNU Affero General Public License v3.0
|
|
*/
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Console\ConsoleTools;
|
|
use Exception;
|
|
use FilesystemIterator;
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Support\Str;
|
|
use Symfony\Component\Console\Input\ArgvInput;
|
|
use Symfony\Component\Console\Output\ConsoleOutput;
|
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
|
use RuntimeException;
|
|
use Throwable;
|
|
use RecursiveDirectoryIterator;
|
|
use RecursiveIteratorIterator;
|
|
|
|
class GitUpdater extends Command
|
|
{
|
|
use ConsoleTools;
|
|
|
|
/**
|
|
* The copy command.
|
|
*/
|
|
private string $copyCommand = 'cp -Rfp';
|
|
|
|
/**
|
|
* The console command signature.
|
|
*
|
|
* @var string
|
|
*/
|
|
protected $signature = 'git:update';
|
|
|
|
/**
|
|
* The console command description.
|
|
*
|
|
* @var string
|
|
*/
|
|
protected $description = 'Update UNIT3D using Git';
|
|
|
|
/**
|
|
* Files that should be backed up and restored.
|
|
*
|
|
* @var array<string>
|
|
*/
|
|
private const array ADDITIONAL_FILES = [
|
|
'.env',
|
|
'laravel-echo-server.json',
|
|
];
|
|
|
|
/**
|
|
* Directories that should be excluded from updates.
|
|
*
|
|
* @var array<string>
|
|
*/
|
|
private const array EXCLUDED_DIRECTORIES = [
|
|
'unit3d-announce',
|
|
'unit3d-theme-utility',
|
|
];
|
|
|
|
/**
|
|
* List of files that were updated.
|
|
*
|
|
* @var array<string>
|
|
*/
|
|
private array $updatedFiles = [];
|
|
|
|
/**
|
|
* Path to the log file.
|
|
*
|
|
* @var string
|
|
*/
|
|
private string $logFile;
|
|
|
|
/**
|
|
* Execute the console command.
|
|
*
|
|
* @throws Exception|Throwable If there is an error during execution.
|
|
*/
|
|
final public function handle(): void
|
|
{
|
|
$this->input = new ArgvInput();
|
|
$this->output = new ConsoleOutput();
|
|
$this->io = new SymfonyStyle($this->input, $this->output);
|
|
|
|
$this->logFile = storage_path('logs/git-updater-'.now()->format('Y-m-d').'.log');
|
|
$this->log('Starting GitUpdater');
|
|
|
|
$this->displayBanner();
|
|
|
|
if (!$this->confirmUpdate()) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$this->performUpdate();
|
|
} catch (Throwable $e) {
|
|
$this->log('Error during update: '.$e->getMessage());
|
|
$this->alert('error', 'Update failed');
|
|
$this->error('Error: '.$e->getMessage());
|
|
|
|
if ($this->io->confirm('Would you like to restore from backup?', true)) {
|
|
$this->restoreFromBackup();
|
|
}
|
|
|
|
throw $e;
|
|
}
|
|
|
|
$this->log('Update completed successfully');
|
|
$this->info('Please report any errors or issues.');
|
|
$this->taskCompleted('Update process completed');
|
|
}
|
|
|
|
/**
|
|
* Display the updater banner.
|
|
*/
|
|
private function displayBanner(): void
|
|
{
|
|
$this->io->newLine();
|
|
$this->io->writeln('
|
|
<fg=cyan>┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓</>
|
|
<fg=cyan>┃</><fg=green> 🚀 UNIT3D Git Updater </><fg=cyan>┃</>
|
|
<fg=cyan>┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛</>
|
|
');
|
|
|
|
$this->io->writeln('
|
|
<fg=yellow>DISCLAIMER:</> This software is provided "AS IS" without warranty of any kind.
|
|
The authors are not liable for any damages arising from the use of this software.
|
|
<fg=red>USE AT YOUR OWN RISK - MAKE SURE YOU HAVE BACKUPS!</>
|
|
');
|
|
$this->io->newLine();
|
|
}
|
|
|
|
/**
|
|
* Ask for confirmation to proceed with the update.
|
|
*/
|
|
private function confirmUpdate(): bool
|
|
{
|
|
if (!$this->io->confirm('Would you like to proceed with the update?', true)) {
|
|
$this->warning('Update aborted by user');
|
|
$this->log('Update aborted by user');
|
|
|
|
return false;
|
|
}
|
|
|
|
$this->io->writeln('
|
|
Press CTRL + C ANYTIME to abort!
|
|
<fg=red>Note: Aborting may leave your application in an inconsistent state.</>
|
|
');
|
|
|
|
sleep(1);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Perform the update process.
|
|
*/
|
|
private function performUpdate(): void
|
|
{
|
|
$currentVersion = $this->getCurrentVersion();
|
|
|
|
$updatingFiles = $this->checkForUpdates();
|
|
|
|
if (\count($updatingFiles) > 0) {
|
|
$this->alert('info', \sprintf('Found %d Files Needing Updates', \count($updatingFiles)));
|
|
|
|
$this->note('Files that need to be updated:');
|
|
$this->io->listing($updatingFiles);
|
|
|
|
if ($this->io->confirm('Start the update process?', true)) {
|
|
$this->log('Starting update process with '.\count($updatingFiles).' files');
|
|
|
|
$this->call('down');
|
|
|
|
$this->execCommand('git add .');
|
|
|
|
$pathsToBackup = $this->getPathsToBackup();
|
|
|
|
$this->backupFiles($pathsToBackup);
|
|
|
|
$this->header('Resetting Repository');
|
|
$this->execCommands([
|
|
'git fetch origin',
|
|
'git reset --hard origin/master',
|
|
]);
|
|
|
|
$this->restoreBackupFiles($pathsToBackup);
|
|
|
|
$conflicts = array_intersect($updatingFiles, $pathsToBackup);
|
|
|
|
if ($conflicts !== []) {
|
|
$this->warning('There are some files that were not updated because of conflicts.');
|
|
$this->warning('We will walk you through updating these files now.');
|
|
$this->manualUpdateFiles($conflicts);
|
|
}
|
|
|
|
$this->header('Database Migrations');
|
|
|
|
if ($this->io->confirm('Run new database migrations?', true)) {
|
|
$this->runMigrations();
|
|
}
|
|
|
|
$this->clearApplicationCache();
|
|
|
|
$this->header('Composer Packages');
|
|
|
|
if ($this->io->confirm('Install/update Composer packages?', true)) {
|
|
$this->installComposerPackages();
|
|
}
|
|
|
|
$this->updateConfigurationFile();
|
|
|
|
$this->setApplicationCache();
|
|
|
|
$this->header('Frontend Assets');
|
|
|
|
if ($this->io->confirm('Compile frontend assets?', true)) {
|
|
$this->compileAssets();
|
|
}
|
|
|
|
$this->setFilePermissions();
|
|
|
|
$this->restartServices();
|
|
|
|
$this->updatedFiles = $updatingFiles;
|
|
|
|
$newVersion = $this->getCurrentVersion();
|
|
$this->displayVersionInformation($currentVersion, $newVersion);
|
|
$this->generateUpdateReport();
|
|
|
|
$this->header('Bringing Site Back Online');
|
|
$this->call('up');
|
|
$this->success('Site is now online');
|
|
|
|
if ($this->io->confirm('Remove update backups?', true)) {
|
|
$this->header('Cleaning Up');
|
|
$this->execCommand('rm -rf '.storage_path('gitupdate'));
|
|
$this->success('Backups deleted successfully');
|
|
}
|
|
} else {
|
|
$this->alert('warning', 'Update aborted');
|
|
$this->log('Update aborted by user after displaying files to update');
|
|
}
|
|
} else {
|
|
$this->alert('success', 'No available updates found');
|
|
$this->log('No updates available');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check for available updates.
|
|
*
|
|
* @return array<string> List of files to be updated
|
|
*/
|
|
private function checkForUpdates(): array
|
|
{
|
|
$this->header('Checking For Updates');
|
|
$this->log('Checking for updates');
|
|
|
|
$this->execCommand('git fetch origin');
|
|
$process = $this->execCommand('git diff ..origin/master --name-only');
|
|
$updatingFiles = array_filter(explode("\n", $process->getOutput()), 'strlen');
|
|
|
|
$updatingFiles = array_filter($updatingFiles, fn ($file) => array_all(self::EXCLUDED_DIRECTORIES, fn ($excludedDir) => !str_starts_with($file, $excludedDir.'/')));
|
|
|
|
$this->log('Found '.\count($updatingFiles).' files needing update');
|
|
|
|
return $updatingFiles;
|
|
}
|
|
|
|
/**
|
|
* Get the paths that need to be backed up.
|
|
*
|
|
* @return array<string> List of paths to backup
|
|
*/
|
|
private function getPathsToBackup(): array
|
|
{
|
|
$process = $this->execCommand('git diff master --name-only');
|
|
$paths = array_filter(explode("\n", $process->getOutput()), 'strlen');
|
|
|
|
$paths = array_filter($paths, fn ($file) => array_all(self::EXCLUDED_DIRECTORIES, fn ($excludedDir) => !str_starts_with($file, $excludedDir.'/')));
|
|
|
|
return [...$paths, ...self::ADDITIONAL_FILES];
|
|
}
|
|
|
|
/**
|
|
* Backup files before updating.
|
|
*
|
|
* @param array<string> $paths Files to backup
|
|
*/
|
|
private function backupFiles(array $paths): void
|
|
{
|
|
$this->header('Creating Backups');
|
|
$this->log('Starting backup of '.\count($paths).' files/directories');
|
|
|
|
$this->execCommands([
|
|
'rm -rf '.storage_path('gitupdate'),
|
|
'mkdir -p '.storage_path('gitupdate'),
|
|
], true);
|
|
|
|
$this->info('Backing up '.\count($paths).' files/directories...');
|
|
|
|
$progress = $this->io->createProgressBar(\count($paths));
|
|
$progress->setFormat(' %current%/%max% [%bar%] %percent:3s%% %elapsed:6s% ⇒ %message%');
|
|
$progress->setMessage('Starting backup...');
|
|
$progress->start();
|
|
|
|
foreach ($paths as $path) {
|
|
$progress->setMessage($path);
|
|
|
|
if (!file_exists(base_path($path)) && !is_dir(base_path($path))) {
|
|
$this->log('Invalid path: '.$path);
|
|
$progress->advance();
|
|
|
|
continue;
|
|
}
|
|
|
|
$backupPath = \dirname(storage_path('gitupdate/'.$path));
|
|
|
|
if (!is_dir($backupPath) && !mkdir($backupPath, 0775, true) && !is_dir($backupPath)) {
|
|
$this->log('Failed to create directory: '.$backupPath);
|
|
|
|
throw new RuntimeException(\sprintf('Directory "%s" could not be created', $backupPath));
|
|
}
|
|
|
|
$this->execCommand($this->copyCommand.' '.base_path($path).' '.storage_path('gitupdate/'.$path), true);
|
|
|
|
$progress->advance();
|
|
}
|
|
|
|
$progress->finish();
|
|
$this->io->newLine(2);
|
|
|
|
$this->log('Backup completed');
|
|
$this->taskCompleted('Backup completed');
|
|
}
|
|
|
|
/**
|
|
* Restore files from backup.
|
|
*
|
|
* @param array<string> $paths Files to restore
|
|
*/
|
|
private function restoreBackupFiles(array $paths): void
|
|
{
|
|
$this->header('Restoring Files');
|
|
$this->log('Restoring '.\count($paths).' backup files');
|
|
|
|
$progress = $this->io->createProgressBar(\count($paths));
|
|
$progress->setFormat(' %current%/%max% [%bar%] %percent:3s%% %elapsed:6s% ⇒ %message%');
|
|
$progress->setMessage('Starting restore...');
|
|
$progress->start();
|
|
|
|
foreach ($paths as $path) {
|
|
$progress->setMessage($path);
|
|
|
|
$to = Str::replaceLast('/.', '', base_path(\dirname($path)));
|
|
$from = storage_path('gitupdate/'.$path);
|
|
|
|
if (!file_exists($from)) {
|
|
$progress->advance();
|
|
|
|
continue;
|
|
}
|
|
|
|
if (is_dir($from)) {
|
|
$to .= '/'.basename($from).'/';
|
|
$from .= '/*';
|
|
}
|
|
|
|
$this->execCommand(\sprintf('%s %s %s', $this->copyCommand, $from, $to), true);
|
|
$progress->advance();
|
|
}
|
|
|
|
$progress->finish();
|
|
$this->io->newLine(2);
|
|
|
|
$this->execCommands([
|
|
'git add .',
|
|
'git checkout origin/master -- bun.lockb',
|
|
'git checkout origin/master -- composer.lock',
|
|
]);
|
|
|
|
$this->log('Restore completed');
|
|
$this->taskCompleted('Files restored successfully');
|
|
}
|
|
|
|
/**
|
|
* Manually update files that have conflicts.
|
|
*
|
|
* @param array<string> $conflicts Conflicted files
|
|
*/
|
|
private function manualUpdateFiles(array $conflicts): void
|
|
{
|
|
$this->header('Resolving File Conflicts');
|
|
$this->log('Starting manual update for '.\count($conflicts).' conflicting files');
|
|
|
|
$this->warning('Updating will cause you to LOSE any changes you might have made to these files!');
|
|
|
|
foreach ($conflicts as $file) {
|
|
if ($this->io->confirm(\sprintf('Update %s', $file), true)) {
|
|
$this->execCommand(\sprintf('git checkout origin/master -- %s', $file));
|
|
$this->success('Updated: '.$file);
|
|
$this->log('Manually updated file: '.$file);
|
|
} else {
|
|
$this->note('Skipped: '.$file);
|
|
$this->log('Skipped manual update for: '.$file);
|
|
}
|
|
}
|
|
|
|
$this->taskCompleted('Conflict resolution completed');
|
|
}
|
|
|
|
/**
|
|
* Run database migrations.
|
|
*/
|
|
private function runMigrations(): void
|
|
{
|
|
$this->log('Running database migrations');
|
|
$this->call('migrate');
|
|
$this->taskCompleted('Migrations completed');
|
|
}
|
|
|
|
/**
|
|
* Clear application cache.
|
|
*/
|
|
private function clearApplicationCache(): void
|
|
{
|
|
$this->header('Clearing Application Cache');
|
|
$this->log('Clearing application cache');
|
|
$this->call('clear:all_cache');
|
|
$this->taskCompleted('Cache cleared');
|
|
}
|
|
|
|
/**
|
|
* Set application cache.
|
|
*/
|
|
private function setApplicationCache(): void
|
|
{
|
|
$this->header('Setting Application Cache');
|
|
$this->log('Setting application cache');
|
|
$this->call('set:all_cache');
|
|
$this->taskCompleted('Cache set');
|
|
}
|
|
|
|
/**
|
|
* Install/update Composer packages.
|
|
*/
|
|
private function installComposerPackages(): void
|
|
{
|
|
$this->log('Running composer commands');
|
|
|
|
$this->execCommands([
|
|
'composer self-update',
|
|
'composer install --prefer-dist --no-dev -o',
|
|
]);
|
|
|
|
$this->taskCompleted('Composer packages installed');
|
|
}
|
|
|
|
/**
|
|
* Update the UNIT3D configuration file.
|
|
*/
|
|
private function updateConfigurationFile(): void
|
|
{
|
|
$this->header('Updating Configuration');
|
|
$this->log('Updating UNIT3D config file');
|
|
$this->execCommand('git fetch origin && git checkout origin/master -- config/unit3d.php');
|
|
$this->taskCompleted('Configuration updated');
|
|
}
|
|
|
|
/**
|
|
* Compile frontend assets.
|
|
*/
|
|
private function compileAssets(): void
|
|
{
|
|
$this->log('Running asset compilation');
|
|
|
|
$this->execCommands([
|
|
'bun install',
|
|
'bun run build',
|
|
]);
|
|
|
|
$this->taskCompleted('Assets compiled');
|
|
}
|
|
|
|
/**
|
|
* Set proper file permissions.
|
|
*/
|
|
private function setFilePermissions(): void
|
|
{
|
|
$this->header('Setting File Permissions');
|
|
$this->log('Refreshing file permissions');
|
|
$this->execCommand('chown -R www-data: storage bootstrap public config');
|
|
$this->taskCompleted('Permissions set');
|
|
}
|
|
|
|
/**
|
|
* Restart supervisor and PHP services.
|
|
*/
|
|
private function restartServices(): void
|
|
{
|
|
$this->header('Restarting Services');
|
|
$this->log('Restarting supervisor and PHP services');
|
|
|
|
$this->call('queue:restart');
|
|
$this->success('Queue workers restarted');
|
|
|
|
$this->execCommand('supervisorctl reread && supervisorctl update && supervisorctl reload');
|
|
$this->success('Supervisor services restarted');
|
|
|
|
$this->execCommand('systemctl restart php8.4-fpm');
|
|
$this->success('PHP-FPM service restarted');
|
|
|
|
$this->taskCompleted('Services restarted');
|
|
}
|
|
|
|
/**
|
|
* Get the current git version.
|
|
*/
|
|
private function getCurrentVersion(): string
|
|
{
|
|
$process = $this->execCommand('git describe --tags --always');
|
|
$version = trim($process->getOutput());
|
|
|
|
return $version ?: 'unknown';
|
|
}
|
|
|
|
/**
|
|
* Display version information before and after update.
|
|
*/
|
|
private function displayVersionInformation(string $oldVersion, string $newVersion): void
|
|
{
|
|
$this->header('Version Information');
|
|
|
|
$this->io->definitionList(
|
|
['Previous version' => "<fg=yellow>{$oldVersion}</>"],
|
|
['Current version' => "<fg=green>{$newVersion}</>"]
|
|
);
|
|
|
|
$this->log("Updated from version {$oldVersion} to {$newVersion}");
|
|
|
|
if ($oldVersion === $newVersion) {
|
|
$this->warning('No version change detected');
|
|
} else {
|
|
$this->success('Successfully upgraded!');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate an update report of what was changed.
|
|
*/
|
|
private function generateUpdateReport(): void
|
|
{
|
|
$this->header('Update Report');
|
|
|
|
$filesByType = [];
|
|
|
|
foreach ($this->updatedFiles as $file) {
|
|
$extension = pathinfo($file, PATHINFO_EXTENSION) ?: 'other';
|
|
$filesByType[$extension][] = $file;
|
|
}
|
|
|
|
ksort($filesByType);
|
|
|
|
$this->note('Files updated by type:');
|
|
|
|
foreach ($filesByType as $type => $files) {
|
|
$icon = $this->getFileTypeIcon($type);
|
|
$this->io->section("{$icon} {$type} files (".\count($files).")");
|
|
$this->io->listing($files);
|
|
}
|
|
|
|
$this->success('Update completed at: '.now()->toDateTimeString());
|
|
$this->log('Generated update report with '.\count($this->updatedFiles).' files');
|
|
}
|
|
|
|
/**
|
|
* Get an appropriate icon for file types.
|
|
*/
|
|
private function getFileTypeIcon(string $extension): string
|
|
{
|
|
return match(strtolower($extension)) {
|
|
'php' => '🐘',
|
|
'js' => '🟨',
|
|
'vue' => '🟩',
|
|
'css', 'scss', 'sass' => '🎨',
|
|
'json' => '📝',
|
|
'md' => '📄',
|
|
'jpg', 'jpeg', 'png', 'gif', 'svg' => '🖼️',
|
|
'lock' => '🔒',
|
|
'env', 'yml', 'yaml' => '⚙️',
|
|
'sql' => '🗄️',
|
|
'gitignore' => '👁️',
|
|
default => '📁',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Restore from backup in case of failure.
|
|
*/
|
|
private function restoreFromBackup(): void
|
|
{
|
|
$this->header('Recovery Process');
|
|
$this->log('Attempting to restore from backup after failure');
|
|
|
|
if (!is_dir(storage_path('gitupdate'))) {
|
|
$this->error('No backup found to restore from!');
|
|
$this->log('Recovery failed - no backup directory found');
|
|
|
|
return;
|
|
}
|
|
|
|
$paths = [];
|
|
$backupDir = storage_path('gitupdate');
|
|
|
|
$iterator = new RecursiveIteratorIterator(
|
|
new RecursiveDirectoryIterator($backupDir, FilesystemIterator::SKIP_DOTS),
|
|
RecursiveIteratorIterator::SELF_FIRST
|
|
);
|
|
|
|
foreach ($iterator as $item) {
|
|
if (!$item->isDir()) {
|
|
$path = substr($item->getPathname(), \strlen($backupDir) + 1);
|
|
$paths[] = $path;
|
|
}
|
|
}
|
|
|
|
$this->info('Found '.\count($paths).' files to restore');
|
|
$this->restoreBackupFiles($paths);
|
|
$this->log('Recovery completed - restored '.\count($paths).' files from backup');
|
|
|
|
$this->call('up');
|
|
$this->alert('success', 'Site has been restored from backup and is back online');
|
|
}
|
|
}
|