OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)

This commit is contained in:
wangruiguo
2026-09-03 18:49:20 +08:00
commit d647428529
3501 changed files with 1988906 additions and 0 deletions

View File

@@ -0,0 +1,78 @@
<?php
namespace Leantime\Command;
use Illuminate\Console\Command;
use Leantime\Domain\Plugins\Models\InstalledPlugin;
use Leantime\Domain\Plugins\Services\Plugins;
use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Class AbstractPluginCommand
*/
abstract class AbstractPluginCommand extends Command
{
protected SymfonyStyle $io;
/**
* Constructor.
*/
public function __construct(
protected readonly Plugins $plugins
) {
parent::__construct();
}
/**
* Execute the command
*
* @return int 0 if everything went fine, or an exit code.
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$this->input = $input;
$this->io = new SymfonyStyle($input, $output);
return $this->executeCommand();
}
/**
* Execute the actual command.
*/
abstract protected function executeCommand(): int;
/**
* Asks a confirmation question.
*/
public function confirm($question, $default = false): bool
{
return $this->io->confirm($question, ! $this->input->isInteractive());
}
/**
* @return array|InstalledPlugin[]
*/
protected function getAllPlugins(): array
{
return array_values(
array_merge(
$this->plugins->getAllPlugins() ?: [],
$this->plugins->discoverNewPlugins(),
)
);
}
protected function getPlugin(string $name): InstalledPlugin
{
foreach ($this->getAllPlugins() as $plugin) {
if ($name === $plugin->name) {
return $plugin;
}
}
throw new RuntimeException(sprintf('Invalid plugin name: %s', $name));
}
}

View File

@@ -0,0 +1,146 @@
<?php
namespace Leantime\Command;
use Illuminate\Console\Command;
use Illuminate\Contracts\Container\BindingResolutionException;
use Leantime\Domain\Auth\Models\Roles;
use Leantime\Domain\Clients\Repositories\Clients;
use Leantime\Domain\Users\Repositories\Users;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Class AddUserCommand
*
* This command adds a new user.
*/
#[AsCommand(
name: 'user:add',
description: 'Add a new user (email, password, role required options)',
)]
class AddUserCommand extends Command
{
protected function configure(): void
{
parent::configure();
$this->addOption(name: 'email', mode: InputOption::VALUE_REQUIRED, description: "User's Email")
->addOption(name: 'password', mode: InputOption::VALUE_REQUIRED, description: "User's Password")
->addOption(
'role',
null,
InputOption::VALUE_REQUIRED,
"User's Role",
array_values(Roles::getRoles()),
)
->addOption(name: 'client-id', mode: InputOption::VALUE_OPTIONAL, description: 'Id of The Client to Assign the User To')
->addOption(name: 'first-name', mode: InputOption::VALUE_OPTIONAL, description: "User's First name")
->addOption(name: 'last-name', mode: InputOption::VALUE_OPTIONAL, description: "User's Last Name")
->addOption(name: 'phone', mode: InputOption::VALUE_OPTIONAL, description: "User's Phone");
}
/**
* Execute the command
*
*
* @return int 0 if everything went fine, or an exit code.
*
* @throws BindingResolutionException
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
! defined('BASE_URL') && define('BASE_URL', '');
! defined('CURRENT_URL') && define('CURRENT_URL', '');
$io = new SymfonyStyle($input, $output);
$email = $input->getOption('email');
if ($email === null) {
$io->error('Email is Required "--email"');
return Command::INVALID;
}
if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
$io->error('Email is Invalid');
return Command::INVALID;
}
$password = $input->getOption('password');
if ($password === null) {
$io->error('Password is Required "--password"');
return Command::INVALID;
}
$role = $input->getOption('role');
if ($role === null) {
$io->error('Role is Required "--role"');
return Command::INVALID;
}
if (! in_array($role, array_values(Roles::getRoles()))) {
$io->error('Role is Invalid');
return Command::INVALID;
}
$clientId = $input->getOption('client-id');
if ($clientId === null) {
$clientsRepository = app()->make(Clients::class);
$clients = $clientsRepository->getAll();
if (count($clients) < 1) {
$io->error('No clients found, cannot add user');
return Command::FAILURE;
}
$clientId = $clients[0]['id'];
}
$firstName = $input->getOption('first-name');
$lastName = $input->getOption('last-name');
$phone = $input->getOption('phone');
$user = [
'user' => $email,
'password' => $password,
'role' => array_search($role, Roles::getRoles()),
'clientId' => $clientId,
'firstname' => $firstName,
'lastname' => $lastName,
'phone' => $phone,
'status' => 'A',
];
try {
$usersRepo = app()->make(Users::class);
if ($usersRepo->usernameExist($email)) {
$io->error('User Already Exists');
return Command::INVALID;
}
$userId = $usersRepo->addUser($user);
if (! $userId) {
$io->error('Failed to Add User');
return Command::FAILURE;
}
} catch (\Exception $ex) {
$io->error($ex);
return Command::FAILURE;
}
$io->success('User created successfully');
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,209 @@
<?php
namespace Leantime\Command;
use Illuminate\Console\Command;
use Illuminate\Contracts\Container\BindingResolutionException;
use Leantime\Domain\Goalcanvas\Repositories\Goalcanvas as GoalcanvasRepo;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Import historical goal values from a CSV so a program three years into a
* grant doesn't have to wait until year 5 to see an arc. Rows land in
* `zp_goal_history` in exactly the same shape as the nightly capture
* (`itemId`, `value`, `userId`, `dateRecorded`) — a value is a value; a
* consumer cannot tell the difference and shouldn't need to.
*/
#[AsCommand(
name: 'goals:backfillHistory',
description: 'Import historical goal values from a CSV into zp_goal_history.',
)]
class BackfillGoalHistoryCommand extends Command
{
protected function configure(): void
{
parent::configure();
$this->addOption('file', null, InputOption::VALUE_REQUIRED, 'Path to a CSV with header row: itemId,value,dateRecorded[,userId]')
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Parse and validate the CSV without inserting.');
}
/**
* @throws BindingResolutionException
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
! defined('BASE_URL') && define('BASE_URL', '');
! defined('CURRENT_URL') && define('CURRENT_URL', '');
$io = new SymfonyStyle($input, $output);
$path = $input->getOption('file');
$dryRun = (bool) $input->getOption('dry-run');
if ($path === null || $path === '') {
$io->error('Missing --file <path.csv>');
return Command::INVALID;
}
if (! is_file($path) || ! is_readable($path)) {
$io->error("File not found or unreadable: {$path}");
return Command::INVALID;
}
$handle = fopen($path, 'r');
if ($handle === false) {
$io->error("Could not open {$path}");
return Command::FAILURE;
}
$header = fgetcsv($handle);
if ($header === false) {
fclose($handle);
$io->error('CSV is empty.');
return Command::INVALID;
}
$header = array_map(static fn ($h) => is_string($h) ? strtolower(trim($h)) : '', $header);
// Match by header name so column order doesn't matter — a spreadsheet
// exported from anywhere can be fed in without hand-editing.
$idxItem = array_search('itemid', $header, true);
$idxValue = array_search('value', $header, true);
$idxDate = array_search('daterecorded', $header, true);
$idxUser = array_search('userid', $header, true);
if ($idxItem === false || $idxValue === false || $idxDate === false) {
fclose($handle);
$io->error('CSV header must include: itemId, value, dateRecorded (userId optional).');
return Command::INVALID;
}
$rows = [];
$errors = [];
$lineNo = 1;
while (($record = fgetcsv($handle)) !== false) {
$lineNo++;
if ($record === [null] || $record === []) {
continue;
}
$itemId = (int) ($record[$idxItem] ?? 0);
$rawValue = $record[$idxValue] ?? '';
$rawDate = trim((string) ($record[$idxDate] ?? ''));
if ($itemId <= 0) {
$errors[] = "line {$lineNo}: itemId missing or invalid";
continue;
}
if ($rawValue === '' || ! is_numeric($rawValue)) {
$errors[] = "line {$lineNo}: value missing or not numeric";
continue;
}
// zp_goal_history rows are stored in UTC (the period reads in the Goalcanvas
// repository normalize to UTC) — parse offset-less inputs AS UTC and normalize
// offset-carrying inputs TO UTC, never the server timezone.
try {
$dateRecorded = (new \Carbon\CarbonImmutable($rawDate, 'UTC'))->setTimezone('UTC');
} catch (\Exception $e) {
$errors[] = "line {$lineNo}: unparseable dateRecorded '{$rawDate}'";
continue;
}
$rows[] = [
'itemId' => $itemId,
'value' => (float) $rawValue,
'userId' => ($idxUser !== false && isset($record[$idxUser]) && $record[$idxUser] !== '') ? (int) $record[$idxUser] : null,
'dateRecorded' => $dateRecorded->format('Y-m-d H:i:s'),
];
}
fclose($handle);
if ($errors !== []) {
$io->section('Validation errors');
foreach ($errors as $e) {
$io->writeln(' - '.$e);
}
$io->error(sprintf('%d row(s) invalid; nothing written.', count($errors)));
return Command::INVALID;
}
if ($rows === []) {
$io->warning('No data rows after header — nothing to import.');
return Command::SUCCESS;
}
// Reject itemIds that aren't actually goals BEFORE writing. The
// history table has no FK to zp_canvas_items so nothing else would
// catch a CSV pointing at a non-goal id (or an id that doesn't
// exist). Silent inserts against phantom ids would just be dead
// rows nobody ever reads.
try {
$repo = app()->make(GoalcanvasRepo::class);
$csvItemIds = array_map(static fn ($r) => (int) $r['itemId'], $rows);
$validItemIds = array_flip($repo->filterGoalItemIds($csvItemIds));
} catch (\Throwable $e) {
$io->error($e->getMessage());
return Command::FAILURE;
}
$rejected = [];
$rows = array_values(array_filter($rows, static function ($row) use ($validItemIds, &$rejected): bool {
if (! isset($validItemIds[(int) $row['itemId']])) {
$rejected[(int) $row['itemId']] = true;
return false;
}
return true;
}));
if ($rejected !== []) {
$io->warning(sprintf(
'%d row(s) skipped — itemId not found or not a goal: %s',
count($rejected),
implode(', ', array_keys($rejected))
));
}
if ($rows === []) {
$io->error('No valid rows remain after filtering; nothing written.');
return Command::INVALID;
}
if ($dryRun) {
$io->success(sprintf('Dry run: %d row(s) would be inserted.', count($rows)));
return Command::SUCCESS;
}
try {
$written = $repo->insertGoalHistoryRows($rows);
} catch (\Throwable $e) {
$io->error($e->getMessage());
return Command::FAILURE;
}
// Backfill is NOT idempotent per-day the way the nightly job is —
// re-running with the same CSV duplicates rows. Loud in the success
// message so re-runs don't quietly double the data.
$io->success(sprintf('Imported %d row(s) into zp_goal_history.', $written));
$io->note('Re-running this command with the same CSV will duplicate rows. This command is one-shot; the nightly reports:goalValueSnapshot job handles ongoing captures.');
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,84 @@
<?php
namespace Leantime\Command;
use Illuminate\Console\Command;
use Illuminate\Contracts\Container\BindingResolutionException;
use Leantime\Core\Configuration\Environment;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Class BackupDbCommand
*
* Command to back up the database.
*
* Usage:
* php bin/console db:backup
*/
#[AsCommand(
name: 'db:backup',
description: 'Backs up database',
)]
class BackupDbCommand extends Command
{
protected function configure(): void
{
parent::configure();
}
/**
* Execute the command
*
*
* @return int 0 if everything went fine, or an exit code.
*
* @throws BindingResolutionException
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$config = app()->make(Environment::class);
$io = new SymfonyStyle($input, $output);
$date = new \DateTime;
$backupFile = $config->dbDatabase.'_'.$date->format('Y-m-d').'.sql';
$backupPath = APP_ROOT.'/'.$config->dbBackupPath.$backupFile;
if (! is_dir(APP_ROOT.'/'.$config->dbBackupPath)) {
mkdir(APP_ROOT.'/'.$config->dbBackupPath);
}
$output = [];
$cmd = sprintf(
'mysqldump --column-statistics=0 --user=\'%s\' --password=\'%s\' --host=%s %s --port=%s --result-file=%s 2>&1',
$config->dbUser,
$config->dbPassword,
$config->dbHost == 'localhost' ? '127.0.0.1' : $config->dbHost,
$config->dbDatabase,
$config->dbPort,
$backupPath
);
exec($cmd, $output, $worked);
switch ($worked) {
case 0:
chmod(APP_ROOT.'/'.$config->userFilePath, 0755);
$io->success('Success, database was backedup successfully');
return Command::SUCCESS;
case 2:
case 1:
$io->error('There was an issue backing up the database');
$io->listing($output);
return Command::FAILURE;
}
return Command::FAILURE;
}
}

View File

@@ -0,0 +1,193 @@
<?php
namespace Leantime\Command;
use DigitalJoeCo\Leantime\Documentor\Documentor;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Leantime\Core\Events\EventDispatcher;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Finder\Finder;
#[AsCommand(
name: 'event:check-listeners',
description: 'Validate event listener paths against available events',
)]
class CheckEventListeners extends Command
{
protected $signature = 'event:check-listeners {--debug : Show detailed debug information} {--clear-cache : Clear the event cache}';
protected $description = 'Check if all registered event listeners match existing events';
protected $events = [];
protected $listeners = [];
protected $cacheFile = 'storage/event_cache.json';
public function handle()
{
ini_set('memory_limit', '1024M'); // Increase memory limit to 1GB
$this->info('Scanning for event contexts and dispatched events...');
if ($this->option('clear-cache')) {
$this->clearCache();
}
if ($this->loadFromCache()) {
$this->info('Loaded events from cache.');
} else {
$this->scanForEventContexts();
$this->storeToCache();
}
$this->scanForListeners();
$this->validateListeners();
return Command::SUCCESS;
}
private function scanForEventContexts(): void
{
$files = File::allFiles(app_path());
$finder = new Finder;
$finder->files()
->in('app')
->notPath('vendor')
->notPath('Plugins/*/vendor')
->name('*.php');
$documentor = new Documentor($this->output);
$documentor->relative = 'app';
$progress_bar = new ProgressBar($this->output, \iterator_count($finder));
ProgressBar::setFormatDefinition('custom', ' %current%/%max% -- %message% (%filename%)');
$progress_bar->setFormat('custom');
$progress_bar->setMessage('Finding Events');
$progress_bar->start();
foreach ($finder as $file) {
$progress_bar->setMessage('Processing file…');
$progress_bar->setMessage($file->getPathname(), 'filename');
$this->parseFileForEventContexts($file, $documentor);
$progress_bar->advance();
}
$progress_bar->setMessage('Completed parsing files');
$progress_bar->finish();
}
private function parseFileForEventContexts($file, Documentor $documentor): void
{
try {
if ($this->option('debug')) {
// $this->info("Parsing file: {$file->getPathname()}");
}
$documentor->parse($file);
foreach ($documentor->get_hooks() as $hook) {
$context = 'Leantime.'.$hook->get_hook();
if ($this->option('debug')) {
$this->info("Found event: {$context}");
}
if (! in_array($context, $this->events)) {
$this->events[] = $context;
}
}
} catch (\Exception $e) {
$this->error("Failed to parse file: {$file->getPathname()} - ".$e->getMessage());
}
}
private function scanForListeners(): void
{
$registries = EventDispatcher::get_registries();
$this->listeners = array_merge($registries['events'], $registries['filters']);
}
private function loadFromCache(): bool
{
if (file_exists($this->cacheFile)) {
$this->events = json_decode(file_get_contents($this->cacheFile), true);
return true;
}
return false;
}
private function storeToCache(): void
{
file_put_contents($this->cacheFile, json_encode($this->events));
}
private function clearCache(): void
{
if (file_exists($this->cacheFile)) {
unlink($this->cacheFile);
$this->info('Cache cleared.');
}
}
private function validateListeners(): void
{
$normalizedEvents = array_map('strtolower', $this->events);
$unmatchedListeners = [];
foreach ($this->listeners as $listener) {
$listener = strtolower($listener);
if (! $this->matchesAnyEvent($listener, $normalizedEvents)) {
$unmatchedListeners[] = $listener;
}
}
if (! empty($unmatchedListeners)) {
$this->warn('Unmatched listeners found:');
foreach ($unmatchedListeners as $listener) {
$this->line("- $listener");
}
} else {
$this->info('All listeners have corresponding events.');
}
}
private function matchesAnyEvent(string $listener, array $events): bool
{
preg_match_all('/\{RGX:(.*?):RGX\}/', $listener, $regexMatches);
$key = strtr($listener, [
...collect($regexMatches[0])->mapWithKeys(fn ($match, $i) => [$match => "REGEX_MATCH_$i"])->toArray(),
'*' => 'RANDOM_STRING',
'?' => 'RANDOM_CHARACTER',
]);
// escape the non regex characters
$pattern = preg_quote($key, '/');
$pattern = strtr($pattern, [
'RANDOM_STRING' => '.*?', // 0 or more (lazy) - asterisk (*)
'RANDOM_CHARACTER' => '.', // 1 character - question mark (?)
...collect($regexMatches[1])->mapWithKeys(fn ($match, $i) => ["REGEX_MATCH_$i" => $match])->toArray(),
]);
foreach ($events as $event) {
if (preg_match("/^$pattern$/", $event)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,148 @@
<?php
namespace Leantime\Command;
use Illuminate\Console\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Finder\Finder;
#[AsCommand(
name: 'translations:check-unused',
description: 'Check for unused translation strings in the codebase',
)]
class CheckTranslations extends Command
{
protected $signature = 'translations:check-unused
{--debug : Show detailed debug information}
{--export= : Export results to a file}
{--exclude=vendor,node_modules,.git,storage,cache : Comma separated list of directories to exclude}';
protected $description = 'Scan codebase for unused translation strings';
protected $translations = [];
protected $usedTranslations = [];
public function handle()
{
$this->info('Scanning for translation strings...');
// Parse the language file
$this->parseLanguageFile();
// Scan files for usage
$this->scanFiles();
// Generate report
$this->generateReport();
return Command::SUCCESS;
}
private function parseLanguageFile(): void
{
$langFile = app_path('Language/en-US.ini');
if (! file_exists($langFile)) {
$this->error('Language file not found: '.$langFile);
return;
}
$this->info('Parsing language file...');
$this->translations = parse_ini_file($langFile);
if ($this->option('debug')) {
$this->info(sprintf('Found %d translation strings', count($this->translations)));
}
}
private function scanFiles(): void
{
$excludeDirs = explode(',', $this->option('exclude'));
if ($this->option('debug')) {
$this->info('Excluding directories: '.implode(', ', $excludeDirs));
}
$finder = new Finder;
$finder->files()
->in(app_path())
->name('*.php')
->name('*.tpl.php')
->name('*.html')
->name('*.blade.php')
->name('*.js')
->notPath($excludeDirs);
$progress = new ProgressBar($this->output, iterator_count($finder));
$progress->setFormat('debug');
$progress->start();
foreach ($finder as $file) {
$this->scanFileForTranslations($file);
$progress->advance();
}
$progress->finish();
$this->line('');
}
private function scanFileForTranslations($file): void
{
$content = file_get_contents($file->getRealPath());
$filePath = $file->getRelativePathname();
foreach ($this->translations as $key => $value) {
// Search for various usage patterns
$patterns = [
preg_quote($key, '/'), // Direct key usage
preg_quote("'$key'", '/'), // Single quoted
preg_quote("\"$key\"", '/'), // Double quoted
preg_quote('__("'.$key.'")', '/'), // PHP translation function
preg_quote("__('$key')", '/'), // PHP translation function
preg_quote('$tpl->__("'.$key.'")', '/'), // Template translation
preg_quote('$tpl->__(\''.$key.'\')', '/'), // Template translation
];
if ($this->option('debug')) {
$this->line("Scanning file: {$filePath}");
}
foreach ($patterns as $pattern) {
if (preg_match('/'.$pattern.'/', $content)) {
if ($this->option('debug')) {
$this->line(" - Found usage of key: {$key}");
}
$this->usedTranslations[$key] = true;
break;
}
}
}
}
private function generateReport(): void
{
$unusedTranslations = array_diff_key($this->translations, $this->usedTranslations);
if (empty($unusedTranslations)) {
$this->info('No unused translations found.');
return;
}
$this->warn(sprintf('Found %d unused translations:', count($unusedTranslations)));
foreach ($unusedTranslations as $key => $value) {
$this->line("- $key");
}
if ($exportFile = $this->option('export')) {
file_put_contents(
$exportFile,
json_encode($unusedTranslations, JSON_PRETTY_PRINT)
);
$this->info("Results exported to: $exportFile");
}
}
}

View File

@@ -0,0 +1,139 @@
<?php
namespace Leantime\Command;
use Illuminate\Console\Command;
use Illuminate\Filesystem\FilesystemManager;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class CleanupOrphanedFilesCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'files:cleanup {--dry-run : Run without actually deleting files}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Clean up orphaned files that are not referenced in the database';
/**
* Execute the console command.
*
* @return int
*/
public function handle(FilesystemManager $filesystemManager)
{
$this->info('Starting orphaned files cleanup...');
$dryRun = $this->option('dry-run');
if ($dryRun) {
$this->warn('Running in dry-run mode. No files will be deleted.');
}
// Get all files from database
$dbFiles = DB::table('zp_file')->select('encName', 'extension')->get();
$dbFileNames = [];
foreach ($dbFiles as $file) {
$dbFileNames[] = $file->encName.'.'.$file->extension;
}
$this->info('Found '.count($dbFileNames).' files in database.');
// Process local storage
$this->processStorage($filesystemManager, 'local', $dbFileNames, $dryRun);
// Process public storage
$this->processStorage($filesystemManager, 'public', $dbFileNames, $dryRun);
// Process S3 storage if configured
if (config('filesystems.default') === 's3') {
$this->processStorage($filesystemManager, 's3', $dbFileNames, $dryRun);
}
$this->info('Orphaned files cleanup completed.');
return 0;
}
/**
* Process a specific storage disk to find and delete orphaned files
*
* @return void
*/
private function processStorage(FilesystemManager $filesystemManager, string $disk, array $dbFileNames, bool $dryRun)
{
$this->info("Processing {$disk} storage...");
try {
$storage = $filesystemManager->disk($disk);
$files = $storage->files();
$orphanedFiles = array_filter($files, function ($file) use ($dbFileNames) {
$fileName = basename($file);
return ! in_array($fileName, $dbFileNames);
});
$this->info('Found '.count($orphanedFiles)." orphaned files in {$disk} storage.");
$deletedCount = 0;
foreach ($orphanedFiles as $file) {
// Skip system files and directories
if (in_array(basename($file), ['.gitignore', '.htaccess', 'index.html'])) {
continue;
}
$this->line("Processing: {$file}");
// Check file age (only delete files older than 24 hours)
try {
$lastModified = $storage->lastModified($file);
$fileAge = time() - $lastModified;
if ($fileAge < 86400) { // 24 hours
$this->warn("Skipping {$file} - file is less than 24 hours old");
continue;
}
if (! $dryRun) {
if ($storage->delete($file)) {
$this->info("Deleted: {$file}");
$deletedCount++;
} else {
$this->error("Failed to delete: {$file}");
}
} else {
$this->info("Would delete: {$file} (dry run)");
$deletedCount++;
}
} catch (\Exception $e) {
$this->error("Error processing {$file}: ".$e->getMessage());
Log::error('Error in orphaned files cleanup: '.$e->getMessage(), [
'file' => $file,
'disk' => $disk,
'exception' => $e,
]);
}
}
$actionText = $dryRun ? 'Would have deleted' : 'Deleted';
$this->info("{$actionText} {$deletedCount} orphaned files from {$disk} storage.");
} catch (\Exception $e) {
$this->error("Error accessing {$disk} storage: ".$e->getMessage());
Log::error('Error accessing storage in orphaned files cleanup: '.$e->getMessage(), [
'disk' => $disk,
'exception' => $e,
]);
}
}
}

71
app/Command/ClearAll.php Normal file
View File

@@ -0,0 +1,71 @@
<?php
namespace Leantime\Command;
use Illuminate\Console\Command;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Support\Facades\Cache;
use Leantime\Core\Configuration\Environment;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Class BackupDbCommand
*
* Command to back up the database.
*
* Usage:
* php bin/console db:backup
*/
#[AsCommand(
name: 'cache:clearAll',
description: 'Clear all caches',
)]
class ClearAll extends Command
{
protected function configure(): void
{
parent::configure();
}
/**
* Execute the command
*
*
* @return int 0 if everything went fine, or an exit code.
*
* @throws BindingResolutionException
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$config = app()->make(Environment::class);
$io = new SymfonyStyle($input, $output);
$io->section('Clearing all caches');
$io->text('Clear Views');
$this->call('view:clear');
$io->text('Clear Installation Cache');
Cache::store('installation')->forget('languages.lang_en-US');
$this->components->info('cleared language file cache');
// $this->call("cache:clear", ["installation"]);
$io->text('Clear Bootstrap Cache');
$commandOut = exec('rm -rf ./storage/framework/composerPaths.php');
$this->components->info('composerPaths removed');
$commandOut = exec('rm -rf ./storage/framework/viewPaths.php');
$this->components->info('viewpaths removed');
$commandOut = exec('rm -rf ./bootstrap/cache/*.php');
$this->components->info('bootstrap cache cleared');
$io->success('All Caches cleared');
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace Leantime\Command;
use Illuminate\Console\Command;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Support\Facades\Cache;
use Leantime\Core\Configuration\Environment;
use Leantime\Core\Language;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Class BackupDbCommand
*
* Command to back up the database.
*
* Usage:
* php bin/console db:backup
*/
#[AsCommand(
name: 'language:clear',
description: 'Clear all cached language files',
)]
class ClearLanguage extends Command
{
protected function configure(): void
{
parent::configure();
}
/**
* Execute the command
*
*
* @return int 0 if everything went fine, or an exit code.
*
* @throws BindingResolutionException
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$config = app()->make(Environment::class);
$language = app()->make(Language::class);
$io = new SymfonyStyle($input, $output);
$io->section('Clearing Language Cache');
$langList = $language->getLanguageList();
if ($langList) {
foreach ($langList as $key => $lang) {
$result = Cache::store('installation')->forget('languages.lang_'.$key);
if ($result) {
$this->components->info('Cleared: '.$key);
} else {
$this->components->warn('Failed to clear: '.$key);
}
}
}
$this->components->info('cleared language file cache');
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,86 @@
<?php
namespace Leantime\Command;
use Illuminate\Console\Command;
use Leantime\Domain\Auth\Repositories\AccessTokenRepository;
use Leantime\Domain\Users\Repositories\Users;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'auth:create-bearer-token',
description: 'Mint a Sanctum-style Bearer token for an existing user. Plugin-independent — does not require AdvancedAuth. CLI-only.',
)]
class CreateBearerTokenCommand extends Command
{
protected function configure(): void
{
parent::configure();
$this->addOption('email', null, InputOption::VALUE_REQUIRED, 'Email of an existing user to mint a token for')
->addOption('name', null, InputOption::VALUE_OPTIONAL, 'Token name (logged on the row, useful for traceability)', 'cli-issued')
->addOption('quiet-output', null, InputOption::VALUE_NONE, 'Print only the token (no decoration). Useful for shell capture.');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
// Hard CLI gate. The Laravel console kernel already only runs
// commands from CLI in practice, but a future HTTP-served
// Artisan::call() (or a misconfigured kernel) must not be able
// to mint a bearer through here. Bail before any work happens.
if (! app()->runningInConsole()) {
return Command::FAILURE;
}
! defined('BASE_URL') && define('BASE_URL', '');
! defined('CURRENT_URL') && define('CURRENT_URL', '');
$io = new SymfonyStyle($input, $output);
$email = $input->getOption('email');
if ($email === null || ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
$io->error('A valid --email is required');
return Command::INVALID;
}
try {
$usersRepo = app()->make(Users::class);
$user = $usersRepo->getUserByEmail($email);
if (! $user || empty($user['id'])) {
$io->error("No user found with email: {$email}");
return Command::FAILURE;
}
$tokenRepo = app()->make(AccessTokenRepository::class);
$result = $tokenRepo->createToken((int) $user['id'], (string) $input->getOption('name'));
} catch (\Throwable $e) {
$io->error($e->getMessage());
return Command::FAILURE;
}
if ($input->getOption('quiet-output')) {
$output->write($result['token']);
return Command::SUCCESS;
}
$io->success(sprintf(
"Bearer token minted for user #%d (%s)\nToken id: %d\nToken: %s\n\nUse with: Authorization: Bearer %s",
$user['id'],
$email,
$result['id'],
$result['token'],
$result['token'],
));
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace Leantime\Command;
use Illuminate\Console\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Input\InputArgument;
/**
* Class DisablePluginCommand
*
* This class represents a command that disables plugins.
*/
#[AsCommand(
name: 'plugin:disable',
description: 'Disable a plugin',
)]
class DisablePluginCommand extends AbstractPluginCommand
{
/**
* {@inheritdoc}
*/
protected function configure(): void
{
$this->addArgument('plugin', InputArgument::REQUIRED, 'The plugin name');
}
/**
* {@inheritdoc}
*/
protected function executeCommand(): int
{
$name = $this->input->getArgument('plugin');
$plugin = $this->getPlugin($name);
if (! isset($plugin->id)) {
throw new RuntimeException(sprintf('Plugin %s is not installed', $plugin->name));
}
if (! $plugin->enabled) {
throw new RuntimeException(sprintf('Plugin %s is not enabled', $plugin->name));
}
if (! $this->confirm(sprintf('Disable plugin %s', $plugin->name))) {
return Command::SUCCESS;
}
return $this->plugins->disablePlugin($plugin->id) ? Command::SUCCESS : Command::FAILURE;
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace Leantime\Command;
use Illuminate\Console\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Input\InputArgument;
/**
* Class EnablePluginCommand
*
* This class represents a command that enables plugins.
*/
#[AsCommand(
name: 'plugin:enable',
description: 'Enable a plugin',
)]
class EnablePluginCommand extends AbstractPluginCommand
{
/**
* {@inheritdoc}
*/
protected function configure(): void
{
$this->addArgument('plugin', InputArgument::REQUIRED, 'The plugin name');
}
/**
* {@inheritdoc}
*/
protected function executeCommand(): int
{
$name = $this->input->getArgument('plugin');
$plugin = $this->getPlugin($name);
if (! isset($plugin->id)) {
throw new RuntimeException(sprintf('Plugin %s is not installed', $plugin->name));
}
if ($plugin->enabled) {
throw new RuntimeException(sprintf('Plugin %s is already enabled', $plugin->name));
}
if (! $this->confirm(sprintf('Enable plugin %s', $plugin->name))) {
return Command::SUCCESS;
}
return $this->plugins->enablePlugin($plugin->id) ? Command::SUCCESS : Command::FAILURE;
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace Leantime\Command;
use Illuminate\Console\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Input\InputArgument;
/**
* Class InstallPluginCommand
*
* This class represents a command that installs plugins.
*/
#[AsCommand(
name: 'plugin:install',
description: 'Install a plugin',
)]
class InstallPluginCommand extends AbstractPluginCommand
{
/**
* {@inheritdoc}
*/
protected function configure(): void
{
$this->addArgument('plugin', InputArgument::REQUIRED, 'The plugin name');
}
/**
* {@inheritdoc}
*/
protected function executeCommand(): int
{
$name = $this->input->getArgument('plugin');
$plugin = $this->getPlugin($name);
if (! isset($plugin->foldername)) {
throw new RuntimeException(sprintf('Plugin %s cannot be installed', $plugin->name));
}
if (! $this->confirm(sprintf('Install plugin %s', $plugin->name))) {
return Command::SUCCESS;
}
return $this->plugins->installPlugin($plugin->foldername) ? Command::SUCCESS : Command::FAILURE;
}
}

View File

@@ -0,0 +1,93 @@
<?php
namespace Leantime\Command;
use Illuminate\Console\Command;
use Leantime\Domain\Plugins\Models\InstalledPlugin;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputOption;
use function Symfony\Component\String\u;
/**
* Class ListPluginCommand
*
* This class represents a command that lists all plugins.
*/
#[AsCommand(
name: 'plugin:list',
description: 'List all plugins',
)]
final class ListPluginCommand extends AbstractPluginCommand
{
/**
* {@inheritdoc}
*/
protected function configure(): void
{
$this->addOption('order-by', null, InputOption::VALUE_REQUIRED, 'Plugin order', 'name');
$this->addOption('installed', null, InputOption::VALUE_REQUIRED, 'Filter plugins on installed status');
$this->addOption('enabled', null, InputOption::VALUE_REQUIRED, 'Filter plugins on enabled status');
$this->setHelp(<<<'EOL'
Examples
Show all plugins:
bin/leantime plugin:list
Show only installed plugins:
bin/leantime plugin:list --installed=true
Show only non-installed plugins:
bin/leantime plugin:list --installed=false
Show only enabled plugins:
bin/leantime plugin:list --enabled=true
Show plugins that are both installed and enabled:
bin/leantime plugin:list --installed=true --enabled=true
EOL);
}
/**
* {@inheritdoc}
*/
protected function executeCommand(): int
{
$plugins = $this->getAllPlugins();
// Filter by “installed" if requested.
$installed = $this->input->getOption('installed');
if ($installed !== null) {
$installed = filter_var($installed, FILTER_VALIDATE_BOOLEAN);
$plugins = array_filter($plugins, fn (InstalledPlugin $p) => ! ($installed xor isset($p->id)));
}
// Filter by “enabled" if requested.
$enabled = $this->input->getOption('enabled');
if ($enabled !== null) {
$enabled = filter_var($enabled, FILTER_VALIDATE_BOOLEAN);
$plugins = array_filter($plugins, fn (InstalledPlugin $p) => ! ($enabled xor $p->enabled));
}
$orderBy = $this->input->getOption('order-by');
usort($plugins, static fn (InstalledPlugin $p0, InstalledPlugin $p1) => ($p0->{$orderBy} ?? null) <=> ($p1->{$orderBy} ?? null));
foreach ($plugins as $plugin) {
$this->io->definitionList(
// Pad name to line up with wrapped description.
['Name' => u($plugin->name)->padEnd(60)],
['Description' => u($plugin->description)->wordwrap(60)],
['Installed' => isset($plugin->id) ? 'yes' : 'no'],
['Enabled' => $plugin->enabled ? 'yes' : 'no'],
);
}
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,133 @@
<?php
namespace Leantime\Command;
use Exception;
use Illuminate\Console\Command;
use Illuminate\Contracts\Container\BindingResolutionException;
use Leantime\Domain\Help\Services\Helper;
use Leantime\Domain\Install\Repositories\Install;
use Leantime\Domain\Users\Repositories\Users;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Represents the MigrateCommand class.
*/
#[AsCommand(
name: 'db:migrate',
description: 'Runs and pending Leantime Database Migrations',
)]
class MigrateCommand extends Command
{
protected function configure(): void
{
parent::configure();
$this->setAliases(['db:install', 'db:update']);
$this->addOption('email', null, InputOption::VALUE_OPTIONAL, "User's Email", null)
->addOption('password', null, InputOption::VALUE_OPTIONAL, "User's Password", null)
->addOption('company-name', null, InputOption::VALUE_OPTIONAL, 'Company Name', null)
->addOption('first-name', null, InputOption::VALUE_OPTIONAL, "User's First name", null)
->addOption('last-name', null, InputOption::VALUE_OPTIONAL, "User's Last Name", null);
}
/**
* Execute the command
*
*
* @return int 0 if everything went fine, or an exit code.
*
* @throws BindingResolutionException
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
! defined('BASE_URL') && define('BASE_URL', '');
! defined('CURRENT_URL') && define('CURRENT_URL', '');
$install = app()->make(Install::class);
$io = new SymfonyStyle($input, $output);
$silent = $input->getOption('silent') === 'true';
try {
if (! $install->checkIfInstalled()) {
if ($silent) {
$adminEmail = 'admin@leantime.io';
$setupConfig = [
'email' => 'admin@leantime.io',
'password' => '',
'firstname' => '',
'lastname' => '',
'company' => 'Leantime',
];
} else {
$email = $input->getOption('email');
$password = $input->getOption('password');
$companyName = $input->getOption('company-name');
$firstName = $input->getOption('first-name');
$lastName = $input->getOption('last-name');
$adminEmail = $email ?? $io->ask('Admin Email');
$adminPassword = $password ?? $io->askHidden('Admin Password');
$adminFirstName = $firstName ?? $io->ask('Admin First Name');
$adminLastName = $lastName ?? $io->ask('Admin Last Name');
$companyName = $companyName ?? $io->ask('Company Name');
$setupConfig = [
'email' => $adminEmail,
'password' => $adminPassword,
'firstname' => $adminFirstName,
'lastname' => $adminLastName,
'company' => $companyName,
];
}
$io->text('Installing DB For First Time');
$installStatus = $install->setupDB($setupConfig);
if ($installStatus !== true) {
// setupDB() returns bool; on failure it logs the cause. Give the CLI user a
// concrete pointer instead of the empty string (string) false would produce.
$io->error('Database installation failed. Check the application logs for details.');
return Command::FAILURE;
}
// Latest install via UI sends user to account set up flow.
// Turning this off for console installs
$usersRepo = app()->make(Users::class);
$getAdminUser = $usersRepo->getUserByEmail($adminEmail, '');
if ($getAdminUser !== false && is_array($getAdminUser)) {
$userId = $getAdminUser['id'];
$usersRepo->patchUser($userId, ['password' => $setupConfig['password'], 'status' => 'a']);
$helperService = app()->make(Helper::class);
$helperService->createDefaultProject($userId, 'owner');
}
if ($silent) {
$usersRepo = app()->make(Users::class);
$userId = array_values($usersRepo->getUserByEmail($adminEmail))[0];
$usersRepo->deleteUser($userId);
}
$io->text('Successfully Installed DB');
}
$success = $install->updateDB();
if ($success !== true) {
throw new Exception('Migration Failed; See below'.PHP_EOL.implode(PHP_EOL, $success));
}
} catch (Exception $ex) {
$io->error($ex);
return Command::FAILURE;
}
$io->success('Database Successfully Migrated');
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace Leantime\Command;
use Illuminate\Console\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Input\InputArgument;
/**
* Class RemovePluginCommand
*
* This class represents a command that removes plugins.
*/
#[AsCommand(
name: 'plugin:remove',
description: 'Remove a plugin',
)]
class RemovePluginCommand extends AbstractPluginCommand
{
/**
* {@inheritdoc}
*/
protected function configure(): void
{
$this->addArgument('plugin', InputArgument::REQUIRED, 'The plugin name');
}
/**
* {@inheritdoc}
*/
protected function executeCommand(): int
{
$name = $this->input->getArgument('plugin');
$plugin = $this->getPlugin($name);
if (! isset($plugin->id)) {
throw new RuntimeException(sprintf('Plugin %s is not installed', $plugin->name));
}
if (! $this->confirm(sprintf('Remove plugin %s', $plugin->name))) {
return Command::SUCCESS;
}
return $this->plugins->removePlugin($plugin->id) ? Command::SUCCESS : Command::FAILURE;
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace Leantime\Command;
use Illuminate\Console\Command;
use Illuminate\Contracts\Container\BindingResolutionException;
use Leantime\Domain\Setting\Repositories\Setting;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Class SaveSettingCommand
*
* Command for saving a setting, will create it if it doesn't exist.
*/
#[AsCommand(
name: 'setting:save',
description: 'Saves a setting, will create it if it does not exist',
)]
class SaveSettingCommand extends Command
{
protected function configure(): void
{
parent::configure();
$this->addOption('key', null, InputOption::VALUE_REQUIRED, 'Setting Key')
->addOption('value', null, InputOption::VALUE_REQUIRED, 'Setting Value');
}
/**
* Execute the command
*
*
* @return int 0 if everything went fine, or an exit code.
*
* @throws BindingResolutionException
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
! defined('BASE_URL') && define('BASE_URL', '');
! defined('CURRENT_URL') && define('CURRENT_URL', '');
$io = new SymfonyStyle($input, $output);
$key = $input->getOption('key');
$value = $input->getOption('value');
if ($key == '') {
$io->error('key parameter needs to be set');
return Command::INVALID;
}
if ($value == '') {
$io->error('value parameter needs to be set');
return Command::INVALID;
}
try {
$setting = app()->make(Setting::class);
$result = $setting->saveSetting($key, $value);
if (! $result) {
$io->error('Failed to save setting');
return Command::FAILURE;
}
} catch (\Exception $ex) {
$io->error($ex);
return Command::FAILURE;
}
$io->success('Saved Successfully');
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace Leantime\Command;
use Illuminate\Console\Command;
use Leantime\Core\Auth\Permissions\PermissionRegistry;
use Leantime\Core\Auth\Permissions\PermissionRepository;
use Leantime\Core\Auth\Permissions\PermissionSeeder;
use Leantime\Core\Auth\Permissions\PermissionService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Discovers every domain/plugin permission declaration and syncs the `domain.action`
* vocabulary into the database.
*
* Usage:
* php bin/leantime permissions:sync # upsert the vocabulary
* php bin/leantime permissions:sync --seed # also (re)grant built-in role defaults
* php bin/leantime permissions:sync --prune # also remove permissions no longer declared
*/
#[AsCommand(
name: 'permissions:sync',
description: 'Sync the discovered permission vocabulary into the database',
)]
class SyncPermissionsCommand extends Command
{
public function __construct(
private readonly PermissionSeeder $seeder,
private readonly PermissionRepository $repo,
private readonly PermissionService $permissions,
private readonly PermissionRegistry $registry,
) {
parent::__construct();
}
protected function configure(): void
{
parent::configure();
$this->addOption('seed', null, InputOption::VALUE_NONE, 'Also (re)grant the built-in roles their default permissions');
$this->addOption('prune', null, InputOption::VALUE_NONE, 'Remove permissions that are no longer declared in code');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
// Re-glob providers from disk first — the discovered-provider list is cached
// cross-request outside debug mode, so a stale cache (e.g. predating a newly-shipped
// domain's permission class) would otherwise hide its permissions from the sync. This
// also makes the command a reliable recovery step after such a cache goes stale.
$this->registry->flush();
$keys = $this->seeder->syncDiscoveredPermissions();
$io->success(count($keys).' permissions synced.');
if ($input->getOption('prune')) {
$pruned = $this->repo->pruneOrphanPermissions($keys);
$io->info($pruned.' orphaned permission(s) pruned.');
}
if ($input->getOption('seed')) {
$this->seeder->seedBuiltInRoles();
$io->info('Built-in role defaults seeded.');
}
$this->permissions->flushCache();
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace Leantime\Command;
use Illuminate\Console\Command;
use Illuminate\Contracts\Container\BindingResolutionException;
use Leantime\Core\Configuration\Environment;
use Leantime\Core\Mailer;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Class TestEmailCommand
*
* This class represents a command that sends an email to test the system configuration.
*/
#[AsCommand(
name: 'email:testemail',
description: 'Sends an email to test system configuration',
)]
class TestEmailCommand extends Command
{
protected function configure(): void
{
parent::configure();
$this->addOption('address', null, InputOption::VALUE_REQUIRED, 'Recipient email address');
}
/**
* Execute the command
*
* @return int 0 if everything went fine, or an exit code.
*
* @throws BindingResolutionException
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
// Depending on the entry point, the constants may not be defined
! defined('BASE_URL') && define('BASE_URL', '');
! defined('CURRENT_URL') && define('CURRENT_URL', '');
$io = new SymfonyStyle($input, $output);
$address = $input->getOption('address');
if ($address == '') {
$io->error('address parameter needs to be set');
return Command::INVALID;
}
$config = app()->make(Environment::class);
// Force debug output from the mailer subsystem
$config->debug = 1;
$io = new SymfonyStyle($input, $output);
$io->writeln('Sending a test email using current configuration');
$mailer = app()->make(Mailer::class);
$mailer->setSubject('Leantime email test');
$mailer->setHtml('This is a test of the leantime mailer configuration. If you have received this email, then the mail configuration is correct.', true);
$mailer->sendMail([$input->getOption('address')], 'Command-line test');
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,192 @@
<?php
namespace Leantime\Command;
use Illuminate\Console\Command;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Support\Facades\Http;
use Leantime\Core\Configuration\AppSettings;
use Leantime\Domain\Plugins\Services\Plugins;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Class UpdateLeantime
*/
#[AsCommand(
name: 'system:update',
description: 'Updates Leantime to the latest version from Github',
)]
class UpdateLeantime extends Command
{
protected function configure(): void
{
parent::configure();
$this->addOption(name: 'skipDbBackup', mode: InputOption::VALUE_NONE);
}
/**
* Executes the update process.
*
* @param InputInterface $input The input interface object.
* @param OutputInterface $output The output interface object.
* @return int 0 if everything went fine, or an exit code.
*
* @throws BindingResolutionException|\Throwable
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$appSettings = app()->make(AppSettings::class);
$io = new SymfonyStyle($input, $output);
$currentVersion = $appSettings->appVersion;
$io->text('Starting the updater');
// Check Versions + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + */
$io->section('Version Check');
$io->text('Your current version is: v'.$currentVersion);
$url = 'https://github.com/leantime/leantime/releases/latest';
$io->text('Checking latest version on Github...');
// Create stream context to follow redirects
$context = stream_context_create(
[
'http' => [
'method' => 'GET',
'header' => 'Accept: application/json',
'follow_location' => true,
],
],
);
// Use file_get_contents() with HTTP context to fetch url
$result = file_get_contents($url, false, $context);
$jsonResponse = json_decode($result, true);
$latestVersion = $jsonResponse['tag_name'] ?? null;
$io->text('The latest Leantime version is: '.$latestVersion);
// Build download URL
if (version_compare($currentVersion, ltrim($latestVersion, 'v'), '>=')) {
$io->success('You are on the most up to date version');
return self::SUCCESS;
}
// Backup DB + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + */
$io->section('Database Backup');
$skipBackup = $input->getOption('skipDbBackup');
if ($skipBackup === false) {
$backUp = new ArrayInput([
// The command name is passed as first argument
'command' => 'db:backup',
]);
$this->getApplication()->doRun($backUp, $output);
}
// Download and extract + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + */
$io->section('Download & Extract');
$io->text('Downloading latest version...');
$downloadUrl = 'https://github.com/leantime/leantime/releases/download/'.$latestVersion.'/Leantime-'.$latestVersion.'.zip';
$zipFile = storage_path('/framework/cache/latest.zip');
Http::sink($zipFile)->get($downloadUrl);
$io->text('Extracting Archive...');
if (! class_exists(\ZipArchive::class)) {
$io->text('ZipArchive not found. Cannot auto-update until the php zip extension is installed. On linux, \'sudo apt install php-zip\'');
return self::FAILURE;
}
$zip = new \ZipArchive;
if ($zip->open($zipFile) === true) {
$zip->extractTo(storage_path('/framework/cache/leantime'));
$zip->close();
$io->success('New update zip file successfully extracted to '.storage_path('/framework/cache/leantime'));
} else {
$io->text('Error opening downloaded zip file!');
return self::FAILURE;
}
// Disable Plugins + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + */
// If we got here everything is ready to go and we just need to move the files.
// Let's disable plugins
$io->section('Disabling Plugins');
/** @var Plugins $plugins */
$plugins = app()->make(Plugins::class);
$enabledPlugins = $plugins->getAllPlugins(enabledOnly: true);
foreach ($enabledPlugins as $plugin) {
if ($plugin->type != 'system' && isset($plugin->id)) {
$plugins->disablePlugin($plugin->id);
$io->text($plugin->name.': Disabled');
}
}
$io->success('Plugins disabled successfully');
// Applying Update + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + */
$io->section('Applying Update');
$cp_retval = 0;
$cp_output = [];
exec('cp -r '.storage_path('/framework/cache/leantime').'/* '.APP_ROOT.'/', $cp_output, $cp_retval);
$io->text('Returned with status '.$cp_retval.' and output:');
$io->text($cp_output);
if ($cp_retval == 0) {
$io->success('Files were updated');
} else {
$io->error('Could not apply update. Please check the output above for more information.');
return self::FAILURE;
}
// Clear Cache + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + */
$io->section('Clearing Cache');
// Deliberately NOT a shell glob: inside the double quotes the shell never expanded
// `*.php`, so this delete silently did nothing for years. A stale
// bootstrap/cache/packages.php then survives updates and Laravel trusts it as-is —
// any provider added to composer.json since (e.g. laravel/mcp) never loads.
foreach (glob(APP_ROOT.'/bootstrap/cache/*.php') ?: [] as $cachedFile) {
unlink($cachedFile);
}
exec('rm -rf "'.APP_ROOT.'/storage/framework/composerPaths.php"');
exec('rm -rf "'.APP_ROOT.'/storage/framework/viewPaths.php"');
exec('rm -rf "'.APP_ROOT.'/storage/framework/cache/leantime"');
exec('rm -rf "'.APP_ROOT.'/storage/framework/cache/latest.zip"');
exec('find "'.APP_ROOT.'/storage/framework/cache" -type f ! -name ".gitignore" -delete');
exec('find "'.APP_ROOT.'/storage/framework/sessions" -type f ! -name ".gitignore" -delete');
exec('find "'.APP_ROOT.'/storage/framework/views" -type f ! -name ".gitignore" -delete');
$io->success('Clearing Cache Complete');
// Enable Plugins + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + */
$io->section('Re-enabling Plugins');
foreach ($enabledPlugins as $plugin) {
if ($plugin->type != 'system' && isset($plugin->id)) {
$plugins->enablePlugin($plugin->id);
$io->text($plugin->name.': Enabled');
}
}
$io->success('Plugins were enabled');
$io->section('Summary');
$io->success('Update applied Successfully');
return Command::SUCCESS;
}
}