diff --git a/.gitignore b/.gitignore index 6cc8b810..40a21331 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ # for keeping empty directories /config/packages/local.yaml /config/bundles-local.php +/var/dev/* /var/data/* /var/cache/* /var/invoices* diff --git a/kimai.sh b/kimai.sh new file mode 100755 index 00000000..bb4e7593 --- /dev/null +++ b/kimai.sh @@ -0,0 +1,116 @@ +#!/bin/bash + +# -------------------------------------------------------------------------------- +# This script was added with 2.24.0 and is in BETA status. +# +# To improve this script across platforms I need your feedback! +# -------------------------------------------------------------------------------- + +function update_kimai() { + if [[ "$1" =~ ^([0-9]+\.){2,3}[0-9]+$ ]]; then + export VERSION=$1 + else + echo "You need to supply a full Kimai version like: \"2.24.0\"" + exit 1 + fi + git checkout -- composer.json + git checkout -- composer.lock + git checkout -- symfony.lock + + if [[ -n $(git status --porcelain) ]]; then + echo "Cannot update: file changes detected. Run \"git status\" for details." + exit 1 + fi + + rm -rf var/cache/* 2>&1 + git fetch --tags + git checkout "$VERSION" + $KIMAI_PHP "$KIMAI_COMPOSER" install --no-dev --optimize-autoloader + + $KIMAI_PHP bin/console kimai:install + + install_plugins + + if [[ -z "${KIMAI_NO_PERMS}" ]]; then + set_permission + fi +} + +function install_plugins() { + # detect if there are additional plugins that we need to install + packages="$($PHP bin/console kimai:plugin --composer)" + export PACKAGES=$packages + if [ -n "$PACKAGES" ]; then + $KIMAI_PHP "$KIMAI_COMPOSER" require "$PACKAGES" + $KIMAI_PHP bin/console kimai:plugins --install + fi +} + +function set_permission() { + chown -R "$KIMAI_USER":"$KIMAI_GROUP" . + chmod -R g+r . + chmod -R g+rw var/ +} + +if [[ -z "${KIMAI_USER}" ]]; then + export KIMAI_USER="" +fi + +if [[ -z "${KIMAI_GROUP}" ]]; then + export KIMAI_GROUP="www-data" +fi + +if [[ -z "${KIMAI_PHP}" ]]; then + export KIMAI_PHP="php" +fi + +if [[ -z "${KIMAI_COMPOSER}" ]]; then + export KIMAI_COMPOSER="composer" +fi + +cd "$(dirname "$0")" || { echo "Cannot change working directory."; exit 1; } + +# we need a few commands installed in order for this script to complete +command -v $KIMAI_COMPOSER >/dev/null 2>&1 || { echo >&2 "Update requires 'composer' but it's not installed."; exit 1; } +command -v git >/dev/null 2>&1 || { echo >&2 "Update requires 'git' but it's not installed."; exit 1; } +command -v $KIMAI_PHP >/dev/null 2>&1 || { echo >&2 "Update requires 'php' but it's not installed."; exit 1; } + +if [[ -n $1 ]]; then + if [ "$1" == 'update' ]; then + update_kimai "$2" + exit + elif [ "$1" == 'permission' ]; then + set_permission + exit + elif [ "$1" == 'plugins' ]; then + install_plugins + exit + else + echo "" + echo ">> Unknown command: $1" + fi +fi + +echo "" +echo "This script has the following sub-commands:" +echo "" +echo "$0 update - Install Kimai version " +echo "$0 permission - Fix file permissions" +echo "$0 plugins - Install plugins from var/packages/*.zip" +echo "" +echo "Use the following environment variables to customize the runtime:" +echo "" +echo "KIMAI_USER - Username of the webserver/php process that needs write access" +echo "KIMAI_GROUP - Group of the webserver/php process that needs write access" +echo "KIMAI_PHP - Full path to PHP executable in the correct version" +echo "KIMAI_COMPOSER - Path to composer executable or .phar file" +echo "KIMAI_NO_PERMS - Skip changing permissions" +echo "" +echo "Examples:" +echo "" +echo "$0 2.24.0" +echo "KIMAI_PHP=/usr/bin/php8.3 $0 2.24.0" +echo "KIMAI_PHP=/usr/bin/php8.3 KIMAI_COMPOSER=/tmp/composer.phar $0 2.24.0" +echo "KIMAI_PHP=php8.3 KIMAI_GROUP=httpd $0 2.24.0" +echo "KIMAI_NO_PERMS=1 KIMAI_PHP=/usr/bin/php8.3 $0 2.24.0" +echo "" diff --git a/phpstan.neon b/phpstan.neon index 8dac7e08..3189516d 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -329,16 +329,6 @@ parameters: count: 4 path: src/Command/InstallCommand.php - - - message: "#^Cannot call method get\\(\\) on Symfony\\\\Component\\\\Console\\\\Helper\\\\HelperSet\\|null\\.$#" - count: 1 - path: src/Command/InstallCommand.php - - - - message: "#^Method App\\\\Command\\\\InstallCommand\\:\\:askConfirmation\\(\\) should return bool but returns mixed\\.$#" - count: 1 - path: src/Command/InstallCommand.php - - message: "#^Binary operation \"\\.\" between non\\-empty\\-string and non\\-empty\\-list\\\\|string results in an error\\.$#" count: 1 @@ -554,11 +544,6 @@ parameters: count: 1 path: src/Command/TranslationCommand.php - - - message: "#^Cannot call method find\\(\\) on Symfony\\\\Component\\\\Console\\\\Application\\|null\\.$#" - count: 3 - path: src/Command/UpdateCommand.php - - message: "#^Method App\\\\Configuration\\\\LdapConfiguration\\:\\:getConnectionParameters\\(\\) return type has no value type specified in iterable type array\\.$#" count: 1 @@ -3669,11 +3654,6 @@ parameters: count: 1 path: src/Plugin/PluginMetadata.php - - - message: "#^Parameter \\#2 \\$array of function array_key_exists expects array, mixed given\\.$#" - count: 1 - path: src/Plugin/PluginMetadata.php - - message: "#^Cannot access offset 'duration' on mixed\\.$#" count: 3 diff --git a/src/Command/InstallCommand.php b/src/Command/InstallCommand.php index 1983fe32..54dca286 100644 --- a/src/Command/InstallCommand.php +++ b/src/Command/InstallCommand.php @@ -11,14 +11,13 @@ namespace App\Command; use App\Constants; use Doctrine\DBAL\Connection; +use Symfony\Bundle\FrameworkBundle\Console\Application; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Helper\QuestionHelper; 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\Question\ConfirmationQuestion; use Symfony\Component\Console\Style\SymfonyStyle; /** @@ -26,10 +25,10 @@ use Symfony\Component\Console\Style\SymfonyStyle; * * @codeCoverageIgnore */ -#[AsCommand(name: 'kimai:install')] +#[AsCommand(name: 'kimai:install', description: 'Kimai installation command', aliases: ['kimai:update'])] final class InstallCommand extends Command { - public function __construct(private Connection $connection, private string $kernelEnvironment) + public function __construct(private readonly Connection $connection) { parent::__construct(); } @@ -37,8 +36,7 @@ final class InstallCommand extends Command protected function configure(): void { $this - ->setDescription('Basic installation for Kimai') - ->setHelp('This command will perform the basic installation steps to get Kimai up and running.') + ->setHelp('This command will perform the installation steps to bootstrap the application, database and plugins.') ->addOption('no-cache', null, InputOption::VALUE_NONE, 'Skip cache re-generation') ; } @@ -47,19 +45,23 @@ final class InstallCommand extends Command { $io = new SymfonyStyle($input, $output); - $io->title('Kimai installation running ...'); + $io->text('Start installation ...'); + + /** @var Application $application */ + $application = $this->getApplication(); + $environment = $application->getKernel()->getEnvironment(); - // create the database, in case it is not yet existing try { - $this->createDatabase($io, $input, $output); + // creates the database if it is not yet existing + $this->createDatabase($io, $output); } catch (\Exception $ex) { $io->error('Failed to create database: ' . $ex->getMessage()); return Command::FAILURE; } - // bootstrap database ONLY via doctrine migrations, so all installation will have the correct and same state try { + // bootstrap database ONLY via doctrine migrations, so all installation will have the same state $this->importMigrations($io, $output); } catch (\Exception $ex) { $io->error('Failed to set migration status: ' . $ex->getMessage()); @@ -68,12 +70,22 @@ final class InstallCommand extends Command } if (!$input->getOption('no-cache')) { - // flush the cache, just to make sure ... and ignore result - $this->rebuildCaches($this->kernelEnvironment, $io, $input, $output); + // show manual steps in case this fails + $cacheResult = $this->rebuildCaches($environment, $io, $input, $output); + + if ($cacheResult !== Command::SUCCESS) { + $io->warning( + [ + 'Please run the cache commands manually:', + 'bin/console cache:clear --env=' . $environment . PHP_EOL . + 'bin/console cache:warmup --env=' . $environment + ] + ); + } } $io->success( - \sprintf('Congratulations! Successfully installed %s version %s', Constants::SOFTWARE, Constants::VERSION) + \sprintf('Successfully installed %s version %s 🎉', Constants::SOFTWARE, Constants::VERSION) ); return Command::SUCCESS; @@ -81,11 +93,13 @@ final class InstallCommand extends Command private function rebuildCaches(string $environment, SymfonyStyle $io, InputInterface $input, OutputInterface $output): int { - $io->text('Rebuilding your cache, please be patient ...'); + $io->text('Rebuilding your cache ...'); $command = $this->getApplication()->find('cache:clear'); try { - $command->run(new ArrayInput(['--env' => $environment]), $output); + if (0 !== $command->run(new ArrayInput(['--env' => $environment]), $output)) { + throw new \RuntimeException('Invalid file permissions?'); + } } catch (\Exception $ex) { $io->error('Failed to clear cache: ' . $ex->getMessage()); @@ -94,7 +108,9 @@ final class InstallCommand extends Command $command = $this->getApplication()->find('cache:warmup'); try { - $command->run(new ArrayInput(['--env' => $environment]), $output); + if (0 !== $command->run(new ArrayInput(['--env' => $environment]), $output)) { + throw new \RuntimeException('Invalid file permissions?'); + } } catch (\Exception $ex) { $io->error('Failed to warmup cache: ' . $ex->getMessage()); @@ -106,15 +122,27 @@ final class InstallCommand extends Command private function importMigrations(SymfonyStyle $io, OutputInterface $output): void { + try { + if (!$this->connection->createSchemaManager()->tablesExist(['migration_versions'])) { + throw new \RuntimeException('Migration table does not exist'); + } + } catch (\Exception $ex) { + $io->error(['Failed to detect migration status, aborting update.', $ex->getMessage()]); + + return; + } + $command = $this->getApplication()->find('doctrine:migrations:migrate'); $cmdInput = new ArrayInput(['--allow-no-migration' => true]); $cmdInput->setInteractive(false); - $command->run($cmdInput, $output); + $result = $command->run($cmdInput, $output); - $io->writeln(''); + if (0 !== $result) { + throw new \Exception('Failed updating database.'); + } } - private function createDatabase(SymfonyStyle $io, InputInterface $input, OutputInterface $output): void + private function createDatabase(SymfonyStyle $io, OutputInterface $output): void { try { if ($this->connection->isConnected()) { @@ -122,39 +150,17 @@ final class InstallCommand extends Command return; } - - if (!$this->askConfirmation($input, $output, \sprintf('Create the database "%s" (yes) or skip (no)?', $this->connection->getDatabase()), true)) { - throw new \Exception('Skipped database creation, aborting installation'); - } - } catch (\Exception $exception) { - // this likely means that the database does not exist. the latest doctrine release - // changed the behavior: in previous version this code did not throw an exception. + } catch (\Exception $ex) { + // this likely means that the database does not exist and the connection failed } - $options = ['--if-not-exists' => true]; - $command = $this->getApplication()->find('doctrine:database:create'); - $result = $command->run(new ArrayInput($options), $output); + $cmdInput = new ArrayInput(['--if-not-exists' => true]); + $cmdInput->setInteractive(false); + $result = $command->run($cmdInput, $output); if (0 !== $result) { - throw new \Exception('Failed creating database. Check your credentials in DATABASE_URL'); + throw new \Exception('Failed creating database: check your DATABASE_URL.'); } } - - /** - * @param InputInterface $input - * @param OutputInterface $output - * @param string $question - * @param bool $default - * @return bool - */ - private function askConfirmation(InputInterface $input, OutputInterface $output, $question, $default = false): bool - { - /** @var QuestionHelper $questionHelper */ - $questionHelper = $this->getHelperSet()->get('question'); - $text = \sprintf('%s (yes/no) [%s]:', $question, $default ? 'yes' : 'no'); - $question = new ConfirmationQuestion(' ' . $text . ' ', $default, '/^y|yes/i'); - - return $questionHelper->ask($input, $output, $question); - } } diff --git a/src/Command/PluginCommand.php b/src/Command/PluginCommand.php index 2f0fdb51..0f48f422 100644 --- a/src/Command/PluginCommand.php +++ b/src/Command/PluginCommand.php @@ -9,37 +9,130 @@ namespace App\Command; +use App\Plugin\Package; +use App\Plugin\PackageManager; +use App\Plugin\Plugin; use App\Plugin\PluginManager; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; +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; -/** - * Command used to fetch plugin information. - */ -#[AsCommand(name: 'kimai:plugins')] +#[AsCommand(name: 'kimai:plugins', description: 'Manage Kimai plugins')] final class PluginCommand extends Command { - public function __construct(private PluginManager $plugins) + public function __construct( + private readonly PluginManager $pluginManager, + private readonly PackageManager $packageManager + ) { parent::__construct(); } protected function configure(): void { - $this - ->setDescription('Receive plugin information') - ->setHelp('This command prints detailed plugin information.') - ; + $this->setHelp('Shows information about already installed plugins by default.'); + $this->addOption('available', null, InputOption::VALUE_NONE, 'Show list of available plugins in ' . PackageManager::PACKAGE_DIR); + $this->addOption('composer', null, InputOption::VALUE_NONE, 'Dump list of available composer packages in ' . PackageManager::PACKAGE_DIR); + $this->addOption('install', null, InputOption::VALUE_NONE, 'Run plugins installer, previously installed via ./kimai.sh'); } protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); - $plugins = $this->plugins->getPlugins(); + if ($input->getOption('available')) { + return $this->listPackages($io, $this->packageManager->getAvailablePackages()); + } elseif ($input->getOption('composer')) { + return $this->listComposerPackages($io, $this->packageManager->getAvailablePackages()); + } elseif ($input->getOption('install')) { + return $this->installPlugins($io, $output, $this->pluginManager->getPlugins()); + } + + return $this->listInstalledPlugins($io, $this->pluginManager->getPlugins()); + } + + /** + * @param Plugin[] $plugins + */ + private function installPlugins(SymfonyStyle $io, OutputInterface $output, array $plugins): int + { + foreach ($plugins as $plugin) { + $config = $plugin->getPath() . '/migrations/doctrine_migrations.yaml'; + if (!file_exists($config)) { + $config = $plugin->getPath() . '/Migrations/doctrine_migrations.yaml'; + if (!file_exists($config)) { + continue; + } + } + + $command = $this->getApplication()?->find('doctrine:migrations:migrate'); + if ($command === null) { + throw new \RuntimeException('Failed finding doctrine migrations command'); + } + $cmdInput = new ArrayInput(['--allow-no-migration' => true, '--configuration' => $config]); + $cmdInput->setInteractive(false); + if (0 !== $command->run($cmdInput, $output)) { + $io->error('Failed to install bundle database: ' . $config); + } + } + + return Command::SUCCESS; + } + + /** + * @param Package[] $packages + */ + private function listComposerPackages(SymfonyStyle $io, array $packages): int + { + if (empty($packages)) { + return Command::SUCCESS; + } + + $all = []; + foreach ($packages as $package) { + $all[] = $package->getMetadata()->getPackage(); + } + $io->write(implode(' ', $all)); + + return Command::SUCCESS; + } + + /** + * @param Package[] $packages + */ + private function listPackages(SymfonyStyle $io, array $packages): int + { + if (empty($packages)) { + $io->warning('No packages to install found'); + + return Command::SUCCESS; + } + + $rows = []; + foreach ($packages as $package) { + $metadata = $package->getMetadata(); + $rows[] = [ + $metadata->getName(), + $metadata->getVersion(), + $metadata->getKimaiVersion(), + $metadata->getPackage(), + $package->getPackageFile()->getPathname(), + ]; + } + $io->table(['Name', 'Version', 'Requires', 'Package', 'Directory'], $rows); + + return Command::SUCCESS; + } + + /** + * @param array $plugins + */ + private function listInstalledPlugins(SymfonyStyle $io, array $plugins): int + { if (empty($plugins)) { $io->warning('No plugins installed'); diff --git a/src/Command/UpdateCommand.php b/src/Command/UpdateCommand.php deleted file mode 100644 index a1451465..00000000 --- a/src/Command/UpdateCommand.php +++ /dev/null @@ -1,137 +0,0 @@ -setDescription('Update your Kimai installation') - ->setHelp('This command will execute all required steps to update your Kimai installation.') - ; - } - - protected function execute(InputInterface $input, OutputInterface $output): int - { - $io = new SymfonyStyle($input, $output); - - $io->title('Kimai updates running ...'); - - $environment = $this->kernelEnvironment; - - // make sure database is available, Kimai running and installed - try { - if (!$this->connection->createSchemaManager()->tablesExist(['kimai2_users', 'kimai2_timesheet'])) { - $io->error('Tables missing. Did you run the installer already?'); - - return Command::FAILURE; - } - - if (!$this->connection->createSchemaManager()->tablesExist(['migration_versions'])) { - $io->error('Unknown migration status, aborting database update'); - - return Command::FAILURE; - } - } catch (ConnectionException $e) { - $io->error(['Database connection could not be established.', $e->getMessage()]); - - return Command::FAILURE; - } catch (\Exception $ex) { - $io->error(['Failed to validate database.', $ex->getMessage()]); - - return Command::FAILURE; - } - - // execute latest doctrine migrations - try { - $command = $this->getApplication()->find('doctrine:migrations:migrate'); - $cmdInput = new ArrayInput(['--allow-no-migration' => true]); - $cmdInput->setInteractive(false); - if (0 !== $command->run($cmdInput, $output)) { - throw new \RuntimeException('CRITICAL: problem when migrating database'); - } - - $io->writeln(''); - } catch (\Exception $ex) { - $io->error($ex->getMessage()); - - return Command::FAILURE; - } - - // flush the cache, in case values from the database are cached - $cacheResult = $this->rebuildCaches($environment, $io, $input, $output); - - if ($cacheResult !== Command::SUCCESS) { - $io->warning( - [ - \sprintf('Updated %s to version %s but the cache could not be rebuilt.', Constants::SOFTWARE, Constants::VERSION), - 'Please run the cache commands manually:', - 'bin/console cache:clear --env=' . $environment . PHP_EOL . - 'bin/console cache:warmup --env=' . $environment - ] - ); - } else { - $io->success( - \sprintf('Congratulations! Successfully updated %s to version %s', Constants::SOFTWARE, Constants::VERSION) - ); - } - - return Command::SUCCESS; - } - - private function rebuildCaches(string $environment, SymfonyStyle $io, InputInterface $input, OutputInterface $output): int - { - $io->text('Rebuilding your cache, please be patient ...'); - - $command = $this->getApplication()->find('cache:clear'); - try { - if (0 !== $command->run(new ArrayInput(['--env' => $environment]), $output)) { - throw new \RuntimeException('Could not clear cache, missing permissions?'); - } - } catch (\Exception $ex) { - $io->error($ex->getMessage()); - - return Command::FAILURE; - } - - $command = $this->getApplication()->find('cache:warmup'); - try { - if (0 !== $command->run(new ArrayInput(['--env' => $environment]), $output)) { - throw new \RuntimeException('Could not warmup cache, missing permissions?'); - } - } catch (\Exception $ex) { - $io->error($ex->getMessage()); - - return Command::FAILURE; - } - - return Command::SUCCESS; - } -} diff --git a/src/Kernel.php b/src/Kernel.php index 4c36e9d0..9293f2b3 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -109,7 +109,7 @@ class Kernel extends BaseKernel throw new \Exception(\sprintf('Bundle "%s" does not implement %s, which is not supported since 2.0.', $bundleName, PluginInterface::class)); } - $meta = new PluginMetadata($fullPath); + $meta = PluginMetadata::createFromPath($fullPath); if ($meta->getKimaiVersion() > Constants::VERSION_ID) { throw new \Exception(\sprintf('Bundle "%s" requires minimum Kimai version %s, but yours is lower: %s (%s). Please update Kimai or use a lower Plugin version.', $bundleName, $meta->getKimaiVersion(), Constants::VERSION, Constants::VERSION_ID)); @@ -169,11 +169,8 @@ class Kernel extends BaseKernel $routes->import($configDir . '/routes/' . $this->environment . '/*.yaml'); } - // load application routes - $routes->import($configDir . '/routes.yaml'); - foreach ($this->getBundles() as $bundle) { - if (str_contains(\get_class($bundle), 'KimaiPlugin\\')) { + if ($bundle instanceof PluginInterface || str_contains(\get_class($bundle), 'KimaiPlugin\\')) { if (is_dir($bundle->getPath() . '/Resources/config/')) { $routes->import($bundle->getPath() . '/Resources/config/routes' . self::CONFIG_EXTS); } elseif (is_dir($bundle->getPath() . '/config/')) { @@ -181,5 +178,8 @@ class Kernel extends BaseKernel } } } + + // load application routes as last one, so bundles cannot override application ones + $routes->import($configDir . '/routes.yaml'); } } diff --git a/src/Plugin/AbstractPluginExtension.php b/src/Plugin/AbstractPluginExtension.php index ba92cc47..ceda0b1c 100644 --- a/src/Plugin/AbstractPluginExtension.php +++ b/src/Plugin/AbstractPluginExtension.php @@ -14,17 +14,6 @@ use Symfony\Component\HttpKernel\DependencyInjection\Extension; abstract class AbstractPluginExtension extends Extension { - protected function registerIcon(ContainerBuilder $container, string $name, string $icon): void - { - $container->setParameter( - 'tabler_bundle.icons', - array_merge( - $container->getParameter('tabler_bundle.icons'), - [$name => $icon] - ) - ); - } - protected function registerBundleConfiguration(ContainerBuilder $container, array $configs): void { $bundleConfig = [$this->getAlias() => $configs]; diff --git a/src/Plugin/Package.php b/src/Plugin/Package.php new file mode 100644 index 00000000..1c61c957 --- /dev/null +++ b/src/Plugin/Package.php @@ -0,0 +1,30 @@ +packageFile; + } + + public function getMetadata(): PluginMetadata + { + return $this->pluginMetadata; + } +} diff --git a/src/Plugin/PackageManager.php b/src/Plugin/PackageManager.php new file mode 100644 index 00000000..be61150e --- /dev/null +++ b/src/Plugin/PackageManager.php @@ -0,0 +1,155 @@ +findAvailablePackages($this->projectDirectory . '/' . self::PACKAGE_DIR); + } + + /** + * Copied from Composer\Repository\ArtifactRepository + * @see https://github.com/composer/composer/blob/main/src/Composer/Repository/ArtifactRepository.php + * + * @return Package[] + */ + private function findAvailablePackages(string $path): array + { + $packages = []; + + $directory = new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::FOLLOW_SYMLINKS); + $iterator = new \RecursiveIteratorIterator($directory); + $regex = new \RegexIterator($iterator, '/^.+\.zip$/i'); + /** @var \SplFileInfo $file */ + foreach ($regex as $file) { + if (!$file->isFile()) { + continue; + } + + $package = $this->getComposerJson($file->getPathname()); + if ($package === null) { + continue; + } + + $content = json_decode($package, true); + if (\JSON_ERROR_NONE !== json_last_error() || !\is_array($content)) { + throw new \RuntimeException('Failed to parse composer.json file in: ' . $file->getPathname()); + } + + $packages[] = new Package($file, PluginMetadata::createFromArray($content)); + } + + return $packages; + } + + /** + * Copied from Composer\Util\Zip + * @see https://github.com/composer/composer/blob/main/src/Composer/Util/Zip.php + */ + private function getComposerJson(string $pathToZip): ?string + { + if (!\extension_loaded('zip')) { + throw new \RuntimeException('The Zip Util requires PHP\'s zip extension'); + } + + $zip = new \ZipArchive(); + if ($zip->open($pathToZip) !== true) { + return null; + } + + if (0 === $zip->numFiles) { + $zip->close(); + + return null; + } + + $foundFileIndex = self::locateFile($zip, 'composer.json'); + + $content = null; + $configurationFileName = $zip->getNameIndex($foundFileIndex); + if ($configurationFileName !== false) { + $stream = $zip->getStream($configurationFileName); + + if (false !== $stream) { + $content = stream_get_contents($stream); + if ($content === false) { + $content = null; + } + } + } + + $zip->close(); + + return $content; + } + + /** + * Copied from Composer\Util\Zip + * @see https://github.com/composer/composer/blob/main/src/Composer/Util/Zip.php + */ + private static function locateFile(\ZipArchive $zip, string $filename): int + { + // return root composer.json if it is there and is a file + if (false !== ($index = $zip->locateName($filename)) && $zip->getFromIndex($index) !== false) { + return $index; + } + + $topLevelPaths = []; + for ($i = 0; $i < $zip->numFiles; $i++) { + $name = $zip->getNameIndex($i); + if ($name === false) { + continue; + } + $dirname = \dirname($name); + + // ignore OSX specific resource fork folder + if (strpos($name, '__MACOSX') !== false) { + continue; + } + + // handle archives with proper TOC + if ($dirname === '.') { + $topLevelPaths[$name] = true; + if (\count($topLevelPaths) > 1) { + throw new \RuntimeException('Archive has more than one top level directories, and no composer.json was found on the top level, so it\'s an invalid archive. Top level paths found were: ' . implode(',', array_keys($topLevelPaths))); + } + continue; + } + + // handle archives which do not have a TOC record for the directory itself + if (false === strpos($dirname, '\\') && false === strpos($dirname, '/')) { + $topLevelPaths[$dirname . '/'] = true; + if (\count($topLevelPaths) > 1) { + throw new \RuntimeException('Archive has more than one top level directories, and no composer.json was found on the top level, so it\'s an invalid archive. Top level paths found were: ' . implode(',', array_keys($topLevelPaths))); + } + } + } + + if ($topLevelPaths && false !== ($index = $zip->locateName(key($topLevelPaths) . $filename)) && $zip->getFromIndex($index) !== false) { + return $index; + } + + throw new \RuntimeException('No composer.json found either at the top level or within the topmost directory'); + } +} diff --git a/src/Plugin/Plugin.php b/src/Plugin/Plugin.php index b19f8870..7a28bb1e 100644 --- a/src/Plugin/Plugin.php +++ b/src/Plugin/Plugin.php @@ -20,7 +20,7 @@ final class Plugin public function getMetadata(): PluginMetadata { if ($this->metadata === null) { - $this->metadata = new PluginMetadata($this->getPath()); + $this->metadata = PluginMetadata::createFromPath($this->getPath()); } return $this->metadata; @@ -33,12 +33,7 @@ final class Plugin public function getName(): string { - $meta = $this->getMetadata(); - if ($meta->getName() !== null) { - return $meta->getName(); - } - - return $this->getId(); + return $this->getMetadata()->getName(); } public function getId(): string diff --git a/src/Plugin/PluginMetadata.php b/src/Plugin/PluginMetadata.php index 3d15e161..8ecb5302 100644 --- a/src/Plugin/PluginMetadata.php +++ b/src/Plugin/PluginMetadata.php @@ -13,57 +13,79 @@ use App\Constants; class PluginMetadata { - private ?string $version = null; - private ?int $kimaiVersion = null; - private ?string $homepage = null; - private ?string $description = null; - private ?string $name = null; + private string $package; + private string $version; + private int $kimaiVersion; + private string $homepage; + private string $description; + private string $name; - /** - * @throws \Exception - */ - public function __construct(string $path) + public static function createFromPath(string $path): self { if (!is_dir($path) || !is_readable($path)) { throw new \Exception(\sprintf('Bundle directory "%s" cannot be accessed.', $path)); } - $pluginName = basename($path); $composer = $path . '/composer.json'; if (!file_exists($composer) || !is_readable($composer)) { - throw new \Exception(\sprintf('Bundle "%s" does not ship composer.json, which is required since 2.0.', $pluginName)); + throw new \Exception('Bundle does not ship composer.json, which is required since 2.0.'); } + /** @var array|null $json */ $json = json_decode(file_get_contents($composer), true); + if ($json === null) { + throw new \Exception('Could not parse composer.json, invalid JSON?'); + } + + return self::createFromArray($json); + } + + /** + * @param array $json + */ + public static function createFromArray(array $json): self + { if (!\array_key_exists('extra', $json)) { - throw new \Exception(\sprintf('Bundle "%s" does not define an "extra" node in composer.json, which is required since 2.0.', $pluginName)); + throw new \Exception('Bundle "%s" does not define an "extra" node in composer.json, which is required since 2.0.'); } if (!\array_key_exists('kimai', $json['extra'])) { - throw new \Exception(\sprintf('Bundle "%s" does not define the "extra.kimai" node in composer.json, which is required since 2.0.', $pluginName)); + throw new \Exception('Bundle does not define the "extra.kimai" node in composer.json, which is required since 2.0.'); } if (!\array_key_exists('require', $json['extra']['kimai'])) { - throw new \Exception(\sprintf('Bundle "%s" does not define the minimum Kimai version in "extra.kimai.required" in composer.json, which is required since 2.0.', $pluginName)); + throw new \Exception('Bundle does not define the minimum Kimai version in "extra.kimai.required" in composer.json, which is required since 2.0.'); } if (!\array_key_exists('name', $json['extra']['kimai'])) { - throw new \Exception(\sprintf('Bundle "%s" does not define its name in "extra.kimai.name" in composer.json, which is required since 2.0.', $pluginName)); + throw new \Exception('Bundle does not define its name in "extra.kimai.name" in composer.json, which is required since 2.0.'); } if (!\is_int($json['extra']['kimai']['require'])) { - throw new \Exception(\sprintf('Bundle "%s" defines an invalid Kimai minimum version in extra.kimai.require. Please provide an integer as in Constants::VERSION_ID.', $pluginName)); + throw new \Exception('Bundle defines an invalid Kimai minimum version in extra.kimai.require. Please provide an integer as in Constants::VERSION_ID.'); } - $this->description = $json['description'] ?? ''; - $this->homepage = $json['homepage'] ?? Constants::HOMEPAGE . '/store/'; - $this->name = $json['extra']['kimai']['name']; - $this->kimaiVersion = $json['extra']['kimai']['require']; + $meta = new self(); + + $meta->package = $json['name'] ?? ''; + $meta->description = $json['description'] ?? ''; + $meta->homepage = $json['homepage'] ?? Constants::HOMEPAGE . '/store/'; + $meta->name = $json['extra']['kimai']['name']; + $meta->kimaiVersion = $json['extra']['kimai']['require']; // the version field is required if we use composer to install a plugin via var/packages/ - $this->version = $json['extra']['kimai']['version'] ?? ($json['version'] ?? 'unknown'); + $meta->version = $json['extra']['kimai']['version'] ?? ($json['version'] ?? 'unknown'); + + return $meta; + } + + private function __construct() {} + + public function getPackage(): string + { + return $this->package; } public function getDescription(): ?string @@ -71,22 +93,22 @@ class PluginMetadata return $this->description; } - public function getVersion(): ?string + public function getVersion(): string { return $this->version; } - public function getKimaiVersion(): ?int + public function getKimaiVersion(): int { return $this->kimaiVersion; } - public function getHomepage(): ?string + public function getHomepage(): string { return $this->homepage; } - public function getName(): ?string + public function getName(): string { return $this->name; } diff --git a/tests/Command/InstallCommandTest.php b/tests/Command/InstallCommandTest.php index f5221cf8..22ac9b4c 100644 --- a/tests/Command/InstallCommandTest.php +++ b/tests/Command/InstallCommandTest.php @@ -29,8 +29,7 @@ class InstallCommandTest extends KernelTestCase $container = self::$kernel->getContainer(); $this->application->add(new InstallCommand( - $container->get('doctrine')->getConnection(), - $this->application->getKernel()->getEnvironment() + $container->get('doctrine')->getConnection() )); } diff --git a/tests/Command/PluginCommandTest.php b/tests/Command/PluginCommandTest.php index ab627b12..8561fae5 100644 --- a/tests/Command/PluginCommandTest.php +++ b/tests/Command/PluginCommandTest.php @@ -10,6 +10,7 @@ namespace App\Tests\Command; use App\Command\PluginCommand; +use App\Plugin\PackageManager; use App\Plugin\PluginInterface; use App\Plugin\PluginManager; use Symfony\Bundle\FrameworkBundle\Console\Application; @@ -41,7 +42,7 @@ class PluginCommandTest extends KernelTestCase { $kernel = self::bootKernel(); $this->application = new Application($kernel); - $this->application->add(new PluginCommand(new PluginManager($plugins))); + $this->application->add(new PluginCommand(new PluginManager($plugins), new PackageManager(__DIR__ . '/../../'))); $command = $this->application->find('kimai:plugins'); $commandTester = new CommandTester($command); diff --git a/tests/Command/UpdateCommandTest.php b/tests/Command/UpdateCommandTest.php deleted file mode 100644 index 38eb189f..00000000 --- a/tests/Command/UpdateCommandTest.php +++ /dev/null @@ -1,63 +0,0 @@ -application = new Application($kernel); - $container = self::$kernel->getContainer(); - - $this->application->add(new UpdateCommand( - $container->get('doctrine')->getConnection(), - $this->application->getKernel()->getEnvironment() - )); - - return $this->application->find('kimai:update'); - } - - public function testFullRun(): void - { - $command = $this->getCommand(); - $commandTester = new CommandTester($command); - $commandTester->setInputs(['no']); - $commandTester->execute([ - 'command' => $command->getName(), - ]); - - $result = $commandTester->getDisplay(); - - self::assertStringContainsString('Kimai updates running', $result); - // make sure migrations run always - self::assertStringContainsString('[OK] Already at the latest version ("DoctrineMigrations\\', $result); - - self::assertStringContainsString( - \sprintf('[OK] Congratulations! Successfully updated Kimai to version %s', Constants::VERSION), - $result - ); - - self::assertEquals(0, $commandTester->getStatusCode()); - } -} diff --git a/tests/Plugin/PluginMetadataTest.php b/tests/Plugin/PluginMetadataTest.php index f176007a..d83961a9 100644 --- a/tests/Plugin/PluginMetadataTest.php +++ b/tests/Plugin/PluginMetadataTest.php @@ -20,8 +20,8 @@ class PluginMetadataTest extends TestCase public function testNonExistingDirectoryThrowsException(): void { $this->expectException(\Exception::class); - $this->expectExceptionMessage('Bundle "Plugin" does not ship composer.json, which is required since 2.0.'); + $this->expectExceptionMessage('Bundle does not ship composer.json, which is required since 2.0.'); - new PluginMetadata(__DIR__); + PluginMetadata::createFromPath(__DIR__); } } diff --git a/tests/phpstan.neon b/tests/phpstan.neon index b58097dd..5750efc4 100644 --- a/tests/phpstan.neon +++ b/tests/phpstan.neon @@ -729,11 +729,6 @@ parameters: count: 1 path: Command/PromoteUserCommandTest.php - - - message: "#^Cannot call method getConnection\\(\\) on object\\|null\\.$#" - count: 1 - path: Command/UpdateCommandTest.php - - message: "#^Method App\\\\Tests\\\\Command\\\\VersionCommandTest\\:\\:getCommandTester\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" count: 1 diff --git a/var/packages/.gitkeep b/var/packages/.gitkeep index 21d13dc6..040f8d15 100644 --- a/var/packages/.gitkeep +++ b/var/packages/.gitkeep @@ -1 +1 @@ -This directory can store as Kimai plugin/bundle folder. \ No newline at end of file +Put your Kimai plugin ZIP files in this directory and run "./kimai.sh plugins" \ No newline at end of file